Skip to content

元数据卡

  • 前置知识:终端基本操作(第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)             ← 谁调了它(调用链)

读的方式:从下往上读调用链,从上往下找错误类型

  1. 先看异常类型:NullPointerException → 某个对象是 null 但你调了它的方法
  2. 看第一行具体位置:Main.java:12 → 打开文件看第 12 行
  3. 看调用链回溯:是谁在第 6 行调了 processData

假设你的代码是:

java
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 的栈调用长这样:

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"

搜索结果会告诉你:你往数据库里插了一条记录,但主键已经存在了。现在你知道去检查:是不是重复插入了?是不是没做去重?是不是主键生成逻辑有问题?

搜错误的黄金法则:

  1. 复制完整的错误信息(包括异常类型和关键信息),不要只复制"it doesn't work"
  2. 去掉和你项目相关的部分(包名 com.example、类名 MyApp、服务器 IP),保留异常类型和通用描述
  3. 加语言/框架标签 → 比如 "NullPointerException Java" 或 "duplicate key PostgreSQL Spring Boot"
  4. 优先看 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 应用,用户说"系统报错了",但你本地跑没问题。怎么办?先找日志:

bash
# 常见日志位置
/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
消息处理订单失败:库存不足
栈调用后面跟着的异常堆栈

日志级别帮你快速过滤:

bash
# 只查 ERROR
grep "ERROR" application.log

# 查某个时间段的错误
grep "2026-06-23 14:" application.log | grep "ERROR"

# 查跟某个用户/订单相关的
grep "订单 #1024" application.log

动手:给你的程序加日志

还记得上一章我们装了日志库?现在用起来。

python
# 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
// 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。下面是最小复现:

java
public class Main {
    public static void main(String[] args) {
        User user = getUser();          // 返回 null
        System.out.println(user.getName());  // 报 NullPointerException
    }
}

最小复现的原则:

  1. 去掉所有无关代码——不涉及 bug 的部分全部删掉
  2. 用硬编码数据——不要从数据库或 API 读取,直接写死在代码里
  3. 确保可独立运行——别人 copy-paste 就能跑
  4. 包含完整的错误信息——你拿到的报错是什么

写最小复现的过程本身就是在 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("到了这里")——停。这有几个问题:

  1. 它只输出到 stdout,不会进日志文件
  2. 生产环境没人看 stdout
  3. 你不会想在生产环境看到用户订单信息打印在控制台

规则:用日志库,永远不用 System.outprint() 来 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 看错误类型和位置
  • 能用关键词 + 错误信息进行有效搜索
  • 在三种语言中至少一种正确配置并写入日志文件
  • 会使用 greptailless 等命令查看和过滤日志
  • 能写出最小复现来隔离和报告一个 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。

Built with VitePress | Software Systems Atlas