元数据卡
- 前置知识:终端基本操作(第2章),包管理器(第5章)
- 预计时间:40 分钟
- 核心难度:
- 完成标志:能够读懂栈调用错误,能通过日志定位问题,能写出最小复现代码
你在哪
你还在出发前的工坊里。工具越装越多,代码越写越复杂——然后你遇到了第一个让你束手无策的报错。屏幕上的红色字迹堆了好几页,你连从哪里看起都不知道。工坊的师傅走过来,看了一眼屏幕:
你的任务
你的程序崩了。你觉得是"服务器连不上了",但服务器说你"验证失败了",然后你又去查验证,发现其实是"配置文件读错了",最后——是昨天你改了 application.properties 忘了加一个逗号。这种"问题链"在 Debug 中极其常见。你需要的不是先知先觉,而是一套系统化的追查方法:读报错栈、搜错误信息、看日志文件、写最小复现。这就是本章要给你的——野外生存手册。
本章分层
- 必读:读懂栈调用(Stack Trace)、搜索错误信息的黄金法则、给关键路径加日志、写最小复现代码
- 选读:日志级别(TRACE/DEBUG/INFO/WARN/ERROR)的使用场景、日志文件轮转配置
- 深水区:logback.xml 详细配置、生产环境日志路径排查、日志采集管道(ELK)
本章不会要求你掌握
- logback XML 的完整配置语法——用库的默认配置就够了
- 生产环境日志轮转策略和文件路径排查——等项目上线后再学
- 断言(assert)的使用——开发期检查工具,但不是日志的替代品
遭遇战 → 获得技能
场景:程序崩溃了
你的程序刚才还在好好跑着。你加了一行代码,编译通过,信心满满地运行了。
然后屏幕上炸出了一片红色——几十行英文字母,夹杂着数字和奇怪的符号,从屏幕底部一路滚到顶部,像一个滚下悬崖的石头上刻满了咒语。
你完全懵了。这些字堆在一起像外语——不,更像是加密后的外语。你在里面找来找去,但每一个词都不像你能理解的:"Exception"、"Thread"、"NullPointer"、"at"——这些字你都认识,但它们组合在一起却毫无意义。
你转头看了看窗外的工坊——一切显得那么陌生。
你写了一小段 Java 代码,编译通过了,但运行时报了这么个错:
Exception in thread "main" java.lang.NullPointerException
at com.example.Main.processData(Main.java:12)
at com.example.Main.main(Main.java:6)你的第一反应是什么?
大多数人第一反应是:"我哪里错了?"——但这个质问太空泛了。让我们先停一下,学会看这个错误信息本身。
第一招:读懂栈调用(Stack Trace)
"先别慌。"工坊主人走过来,手指点在屏幕最上方。"从上面开始读——不是从下面。"
"从上面?"你困惑地看着那坨红色。
他指着第一行:Exception in thread "main" java.lang.NullPointerException。"这行告诉你两件事:出错的线程叫 main,错误的类型是 NullPointerException。NullPointer 的意思是你用了一个不存在的东西。"
他又指了指下面几行。"剩下的每一行,都是一个脚印——告诉我你的程序是怎么走到这一步的。"
错误信息里的每一行都是一个线索。
Exception in thread "main" ← 什么线程出错
java.lang.NullPointerException ← 什么类型的错误
at com.example.Main.processData(Main.java:12) ← 在哪出的错(文件:行号)
at com.example.Main.main(Main.java:6) ← 谁调了它(调用链)读的方式:从下往上读调用链,从上往下找错误类型。
- 先看异常类型:
NullPointerException→ 某个对象是 null 但你调了它的方法 - 看第一行具体位置:
Main.java:12→ 打开文件看第 12 行 - 看调用链回溯:是谁在第 6 行调了
processData
假设你的代码是:
public class Main {
public static void main(String[] args) {
String input = null;
processData(input); // 第6行:调了 processData
}
public static void processData(String data) {
int length = data.length(); // 第12行:data 是 null!
System.out.println(length);
}
}第 12 行 data.length()——data 是 null,调用 .length() 就崩了。解决方式:确保 data 不为 null,或者在调用前检查。
给不同语言的读者:
Python 的栈调用长这样:
Traceback (most recent call last):
File "main.py", line 6, in <module>
process_data(input_val)
File "main.py", line 3, in process_data
length = len(data)
TypeError: object of type 'NoneType' has no len()JavaScript(Node.js)的栈调用长这样:
TypeError: Cannot read properties of null (reading 'length')
at processData (/app/main.js:7:18)
at Object.<anonymous> (/app/main.js:3:1)看到了吗?格式不同,但结构一样:错误类型 → 位置 → 调用链。不管你在什么语言里,读栈调用的方法是一样的。
第二招:搜索错误信息
你读懂了栈调用——找到了出问题的行,修好了第一个 bug。
但第二个错误出现了,这一次你完全看不懂:
org.postgresql.util.PSQLException:
ERROR: duplicate key value violates unique constraint "users_pkey""duplicate……duplicate……"你嘴里念叨着。"复制?什么复制了?"你尝试着猜它的意思,但猜错了方向,浪费了半小时。
"你又来了。"工坊主人叹了口气。"不是所有问题都需要你自己猜。其他人也走过这条路,他们把答案写在了网上。"
你遇到一个你不认识的错误。比如:
org.postgresql.util.PSQLException:
ERROR: duplicate key value violates unique constraint "users_pkey"看不懂?别慌。把错误类型 + 关键信息复制出来搜:
duplicate key value violates unique constraint "users_pkey"搜索结果会告诉你:你往数据库里插了一条记录,但主键已经存在了。现在你知道去检查:是不是重复插入了?是不是没做去重?是不是主键生成逻辑有问题?
搜错误的黄金法则:
- 复制完整的错误信息(包括异常类型和关键信息),不要只复制"it doesn't work"
- 去掉和你项目相关的部分(包名
com.example、类名MyApp、服务器 IP),保留异常类型和通用描述 - 加语言/框架标签 → 比如 "NullPointerException Java" 或 "duplicate key PostgreSQL Spring Boot"
- 优先看 Stack Overflow、GitHub Issues、官方文档——社区遇到同样问题的人大概率已经解了
Stack Overflow 的搜索结果通常长这样:
Q:
PSQLException: duplicate key value violates unique constraint
A: You're trying to insert a row with a primary key that already exists. Check if you're inserting twice, or if your sequence generator is out of sync.
把重点放在理解问题原因而不是直接复制粘贴解决方案。同一个错误可能有一百种成因,你的情况不见得和别人一样。
第三招:看日志文件(Log Files)
你能读懂栈调用了,会搜错误信息了。但有一个问题——程序不是在终端里跑的时候才报错。真正重要的是:程序在没有人盯着的时候,它出了什么问题。
"你的程序昨天半夜崩了,"你的同事说。"但你没在盯着终端——你睡着了。那你怎么知道发生了什么?"
你愣住了。"我……我可以在终端里开着程序?"
"那不是办法。"工坊主人摇头。"程序需要自己记录——像航海日志一样,每一件大事小情都写下来。哪怕没人看着,记录也在。"
真正的生产级程序不会把错误直接打到你终端上。它们会把运行过程中的所有事件——包括错误——记录到日志文件里。
你部署了你的 Java Web 应用,用户说"系统报错了",但你本地跑没问题。怎么办?先找日志:
# 常见日志位置
/var/log/myapp/application.log
/var/log/myapp/error.log
~/logs/app.log日志的每一行通常有标准格式:
2026-06-23 14:32:15.123 INFO [main] com.example.OrderService - 开始处理订单 #1024
2026-06-23 14:32:15.456 ERROR [main] com.example.OrderService - 处理订单失败:库存不足
java.lang.RuntimeException: 库存不足
at com.example.InventoryService.checkStock(InventoryService.java:45)
at com.example.OrderService.processOrder(OrderService.java:78)看看这条日志告诉我们什么:
| 信息 | 例子 |
|---|---|
| 时间戳 | 2026-06-23 14:32:15.456 |
| 日志级别 | ERROR |
| 线程 | [main] |
| 来源 | com.example.OrderService |
| 消息 | 处理订单失败:库存不足 |
| 栈调用 | 后面跟着的异常堆栈 |
日志级别帮你快速过滤:
# 只查 ERROR
grep "ERROR" application.log
# 查某个时间段的错误
grep "2026-06-23 14:" application.log | grep "ERROR"
# 查跟某个用户/订单相关的
grep "订单 #1024" application.log动手:给你的程序加日志
还记得上一章我们装了日志库?现在用起来。
# Python + loguru
from loguru import logger
def process_order(order_id):
logger.info("开始处理订单 #{}", order_id)
try:
# 一些可能出错的操作
result = risky_operation()
logger.info("订单 #{}} 处理成功", order_id)
return result
except Exception as e:
logger.error("订单 #{}} 处理失败: {}", order_id, str(e))
logger.exception("完整异常信息") # 自动记录栈信息
raise// Java + SLF4J
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
public void processOrder(String orderId) {
log.info("开始处理订单 {}", orderId);
try {
// 业务逻辑
log.info("订单 {} 处理成功", orderId);
} catch (Exception e) {
log.error("订单 {} 处理失败", orderId, e); // 自动记录栈信息
}
}
}加日志不是写注释——它是给你的程序装了一个"黑匣子"。出问题的时候,日志就是你唯一的证人。
第四招:写最小复现(Minimal Reproducible Example)
你找到了 bug——但你自己修不了。你需要请教工坊里另一个更资深的工匠。
你打开聊天框,开始打字:"我的程序报错了,我有一个 Spring Boot 应用,连接了 PostgreSQL 数据库,用的是 HikariCP 连接池,然后在我调用 UserService 的时候报了 NullPointerException……"
你发了一大段文字过去,附上了五百行代码。
对方回了两个字:"太多了。"
你有点委屈——但他说得对。你塞了太多无关信息过去。代码里有数据库连接、有配置加载、有路由分发、有缓存机制……但 bug 可能就在一行代码里。
这是 Debug 里最被低估的能力。你遇到了一个 bug,但真实代码太长太复杂,没办法直接发给别人看。你需要的是一段独立的、尽可能短的代码来复现这个 bug。
不要这样问问题:
我的 Spring Boot 应用报 NullPointerException,谁能帮我看看?附上 500 行代码。
要这样:
我有一段代码,
user.getName()在特定场景下报了 NullPointerException。下面是最小复现:javapublic class Main { public static void main(String[] args) { User user = getUser(); // 返回 null System.out.println(user.getName()); // 报 NullPointerException } }
最小复现的原则:
- 去掉所有无关代码——不涉及 bug 的部分全部删掉
- 用硬编码数据——不要从数据库或 API 读取,直接写死在代码里
- 确保可独立运行——别人 copy-paste 就能跑
- 包含完整的错误信息——你拿到的报错是什么
写最小复现的过程本身就是在 Debug。很多时候你写到一半,自己就发现 bug 在哪了。这是一个被反复验证的神奇现象——把你的问题讲清楚的过程,就是解决问题的过程。
🏔 深入冒险
日志级别使用指南
日志不是越详细越好,太少则什么都找不到,太多则淹没关键信息。标准日志级别从低到高:
| 级别 | 什么时候用 | 示例 |
|---|---|---|
| TRACE | 几乎不用,只有极端调试才开 | 进入/退出某个循环的每一轮 |
| DEBUG | 开发阶段的信息 | SQL 语句、API 请求参数 |
| INFO | 正常运行的里程碑事件 | 服务启动、订单创建、定时任务执行 |
| WARN | 不致命但值得关注的情况 | 配置项缺失(用默认值)、重试、降级 |
| ERROR | 需要人工介入的错误 | 数据库连不上、支付失败、文件找不到 |
开发时: 日志级别开到 DEBUG 甚至 TRACE,你能看到最详细的信息。
生产环境: 开到 INFO 或 WARN,避免性能开销和日志量过大。
排查问题时: 临时把某个包的级别降到 DEBUG,定位完了再改回去。
进阶:logback.xml 配置(以下内容等你到真实项目上线后再回来看)
在 Java 的 logback 里这样配置:
xml<!-- logback.xml --> `<configuration>` <!-- 全局级别 --> <root level="INFO"/> <!-- 只把某个包调成 DEBUG --> <logger name="com.example.OrderService" level="DEBUG"/> </configuration>
常见陷阱
不要 System.out.println
你的代码里如果还残留着 System.out.println("到了这里")——停。这有几个问题:
- 它只输出到 stdout,不会进日志文件
- 生产环境没人看 stdout
- 你不会想在生产环境看到用户订单信息打印在控制台
规则:用日志库,永远不用 System.out 和 print() 来 Debug。
进阶:生产环境日志路径问题(以下是上线后才需要知道的事,现在了解即可)
教训:确认日志文件的位置
你写了一个 API,本地测试完全没问题。部署到服务器,用户说报错。你登上去看日志——没有日志。查了半天发现:你的日志配置里写了不同的路径。本地测试时日志写到
./logs/app.log,但 Docker 部署时工作目录变了,写去了/app/current/logs/app.log,你一直在看/var/log/app.log。bash# 先确认程序在哪个目录 ps aux | grep myapp # 在对应目录下找日志 cd /usr/local/myapp/ find . -name "*.log" -type f教训:日志文件轮转
程序跑了三天,日志文件积累了 2GB。你登上去
cat application.log,终端卡死了。日志文件需要配置轮转(Rotation)——每天的日志写到一个新文件,旧的压缩保存。这是生产环境必备,但你需要时再配就行。
通关挑战
🗡 热身(5 分钟,必做):故意写一段会报异常/错误的代码(比如访问数组越界、用 null 对象),观察栈调用的每一行。试着从栈调用中找出 bug 的位置。
挑战(30 分钟,选做):在一个已有项目里加上日志库(上一章的 loguru/slf4j/winston)。给关键路径(启动、请求、错误)加上日志,然后故意制造一个错误,从日志中找出它。
观察:写两段不同语言的代码(比如 Python 和 Java),让它们报相同的错(比如除以零),观察栈调用格式的差异和共性。
排障:你启动一个 Java 服务,报错:
Exception in thread "main" java.lang.NoClassDefFoundError: com/fasterxml/jackson/core/JsonFactory你明明在 pom.xml 里写了 Jackson 依赖。问题出在哪里?提示:构建时是不是 mvn compile 还是 mvn package?依赖在运行时能找到吗?(答案:缺少 Jackson-core 库的传递依赖,或者构建产物没有包含所有依赖。解决:用 mvn dependency:tree 检查,或者配置 shade plugin 打包成 fat jar。)
验收标准
完成本章后,你应该能:
- 读懂任意语言的栈调用信息,找 bug 看错误类型和位置
- 能用关键词 + 错误信息进行有效搜索
- 在三种语言中至少一种正确配置并写入日志文件
- 会使用
grep、tail、less等命令查看和过滤日志 - 能写出最小复现来隔离和报告一个 bug
- 理解为什么不能用
System.out.println替代日志
常见卡点
- "栈调用看不懂":别一次看整段,从异常类型开始,往上找你的代码(不是第三方库的代码)。找到
at com.yourpackage.xxx的那一行,那就是入口。 - "搜不到结果":去掉具体数值、用户 ID、时间戳,只留错误类型和关键描述。用英文搜比用中文搜结果多 5 倍。
- "日志文件太大打不开":不要用
cat。用tail -n 100 app.log(最后 100 行)或less app.log(分页浏览),或者grep ERROR app.log过滤。 - "我把错误修好了,但不知道原因":回去看日志,找到错误前后的日志上下文。如果还是看不出,那就下次加更多日志再试。可观测性不是一次配置完的,是持续迭代的。
- "最小复现写不出来":从有问题的代码里逐行删除,每删一次就运行一次,直到删到不能再删还能复现。这个过程中你自己可能就找到 bug 了。
- 两个经典的错误类型:
NullPointerException/NoneType has no ...:几乎所有语言的 #1 错误——你用它的时候它不存在ClassNotFoundException/ModuleNotFoundError:你代码里 import 了一个东西,但运行时找不到它——通常是依赖没装对
现在不需要理解
- 远程调试(Remote Debug):在生产环境打断点——这是高级技能,你现在用日志就够
- APM 工具(Datadog、New Relic、SkyWalking):分布式追踪和性能监控,团队大项目才需要
- 结构化日志(JSON 格式日志):给日志处理工具用的,你现阶段可读文本日志就行
- 日志采集管道(Filebeat → Logstash → Elasticsearch):ELK 栈的完整链路,那是运维的事,你现在只需要知道"日志写到文件"就够了
- 断言(assert):开发期检查工具,但不要在生产和日志场景中用于流程控制
旅人笔记
程序总会出错——但你会读栈调用、会搜错误信息、会看日志、会写最小复现。这些技能比任何一门编程语言都持久。每次 Debug 都是一次"缩小嫌疑范围"的过程,最终你会找到那一个逗号、那一个 null、那一个拼写错误。它不是天赋,是一套可以练习、可以优化的方法。
→ 下一站预告
日志和报错你学会读了。但有些时候,程序不是在你的机器上跑的——它在 Docker 容器里。你需要一个方法,把你的整个项目打包起来,在别的机器上也能一模一样地运行。下一站:Docker。