2016年的一个深夜,我正在家看电视,手机突然响了——运维群里@我,说线上服务挂了,用户大面积报错。

我当时心里一紧,赶紧打开电脑,连上VPN,开始排查。

那是我第一次独立处理线上重大故障,说实话,当时有点慌。日志翻了半天没找到原因,服务重启了也没用,用户还在不断报错。最后折腾了两个多小时,才定位到是数据库连接池满了,导致所有请求都超时。

从那以后,我经历了不少线上故障,也慢慢积累了一套排查思路。现在再遇到线上问题,我已经能比较从容地应对了。

今天就来聊聊,线上出Bug了该怎么办,我的排查思路和方法论。

一、第一步:止损优先

线上出问题,第一步不是找原因,而是止损——先让服务恢复正常,减少影响范围和损失。

很多新手一遇到线上问题就慌了,开始翻日志、改代码、查原因,结果查了半天没查到,用户还在不断报错,影响越来越大。

正确的做法是:先止损,再排查。

常见的止损手段:

  1. 回滚:如果是刚发布的版本导致的问题,第一时间回滚到上一个稳定版本。这是最快、最有效的止损手段。
  2. 重启服务:如果是内存泄漏、死锁、连接池满等问题,重启服务可能能临时恢复。但是重启只是临时手段,不是根本解决方案,重启后还要继续排查根因。
  3. 降级:如果某个非核心功能出问题,可以先把这个功能降级(关闭、返回默认值、用缓存),保证核心功能正常。
  4. 限流:如果是流量突增导致服务扛不住,可以先限流,保护服务不被打垮。
  5. 切流量:如果有多台服务器或多个机房,可以把出问题的服务器/机房的流量切走,先保证其他服务器/机房正常。
  6. 熔断:如果依赖的下游服务出问题,可以熔断,避免级联故障。

止损的原则是:先恢复服务,再找原因。不要为了找原因而让服务一直挂着,那样损失会越来越大。

当然,止损的时候也要注意:

  • 不要盲目重启,重启可能会丢失现场(如内存中的堆栈信息、日志),如果需要保留现场,可以先dump再重启
  • 回滚前确认回滚版本是稳定的,不要回滚到一个也有问题的版本
  • 降级、限流、切流量这些操作,要提前做好预案,不要临时想办法

二、第二步:信息收集

止损之后,服务暂时恢复了,接下来要做的是信息收集——收集尽可能多的信息,为后续的定位做准备。

信息收集的几个维度:

1. 现象

  • 具体的错误信息是什么?(错误码、错误提示、异常堆栈)
  • 影响范围有多大?(所有用户还是部分用户?所有功能还是部分功能?)
  • 什么时候开始的?(精确到分钟,方便对照发布时间、变更时间)
  • 是持续性的还是偶发性的?(一直报错还是偶尔报错?)

2. 日志

日志是排查问题最重要的信息来源。

  • 应用日志:查看应用的错误日志、异常堆栈,找到报错的具体位置和原因
  • 访问日志:Nginx/Apache的访问日志,查看请求量、状态码分布、响应时间
  • 数据库日志:MySQL的慢查询日志、错误日志,查看是否有慢SQL、死锁、连接错误
  • 系统日志:操作系统的日志(/var/log/messages、dmesg),查看是否有OOM、磁盘满、网络错误
  • 中间件日志:Redis、MQ、缓存等中间件的日志

看日志的技巧:

  • 先看错误发生的时间点,找到对应的日志
  • 用grep搜索关键词(错误码、异常类名、请求ID)
  • 看日志的上下文,不要只看报错的那一行
  • 对比正常时的日志和异常时的日志,找出差异

3. 监控

监控数据能帮你快速了解系统的整体状态。

  • 服务器监控:CPU、内存、磁盘、网络、负载
  • 应用监控:QPS、响应时间、错误率、线程数、连接数
  • 数据库监控:连接数、慢查询数、QPS、锁等待
  • 中间件监控:Redis的内存、命中率、连接数;MQ的堆积数

看监控的技巧:

  • 看问题发生时间点的监控曲线,是否有异常波动
  • 对比正常时的监控数据和异常时的,找出差异
  • 看多个监控指标之间的关联(如CPU飙升是否伴随着QPS飙升)

4. 用户反馈

  • 用户报的具体错误是什么?
  • 用户的操作路径是什么?(做了什么操作后出现的问题)
  • 用户的环境是什么?(什么浏览器、什么设备、什么网络)
  • 是所有用户都有问题,还是特定用户才有问题?

用户反馈能帮你快速复现问题,尤其是一些特定场景下才出现的Bug。

5. 变更记录

  • 问题发生前,是否有代码发布?
  • 是否有配置变更?
  • 是否有基础设施变更?(服务器、数据库、网络)
  • 是否有依赖的第三方服务变更?

很多线上问题,都是由变更引起的。对照问题发生时间和变更时间,能快速定位问题来源。

三、第三步:问题定位

收集了足够的信息之后,接下来就是问题定位——找到Bug的根本原因。

定位问题的几种常用方法:

1. 复现

能复现的问题,就解决了一半。

  • 尝试在测试环境复现问题
  • 如果测试环境复现不了,尝试在预发布环境或灰度环境复现
  • 根据用户的操作路径,一步步复现
  • 如果是偶发性问题,尝试多次复现,找到规律

复现问题的时候,要注意:

  • 尽量模拟和线上一样的环境和数据
  • 记录复现的步骤和条件
  • 如果复现不了,不要硬试,换其他方法

2. 日志分析

日志是定位问题最直接的方法。

  • 找到报错的异常堆栈,定位到具体的代码行
  • 看异常的类型和消息,判断问题的性质
  • 看异常发生前的日志,了解上下文
  • 如果有多个异常,看第一个异常(后续的异常可能是连锁反应)

常见的异常类型:

  • NullPointerException(空指针):某个对象为null,调用了它的方法
  • SQLException(数据库异常):SQL执行出错,可能是SQL语法错、数据约束冲突、连接失败
  • IOException(IO异常):文件读写、网络通信出错
  • TimeoutException(超时异常):请求超时,可能是下游服务慢、网络慢
  • OutOfMemoryError(内存溢出):内存不够用了,可能是内存泄漏、加载了大数据

3. 二分法

如果不知道问题出在哪里,可以用二分法快速缩小范围。

  • 代码二分:如果是刚发布的版本导致的问题,可以回滚到中间版本,看是否有问题,逐步缩小范围
  • 时间二分:如果不知道问题什么时候开始的,可以查不同时间点的日志/监控,逐步缩小时间范围
  • 模块二分:如果不知道哪个模块出问题,可以逐个关闭/开启模块,看问题是否复现

二分法的核心是:每次排除一半的可能性,快速定位到问题所在。

4. 对比法

对比正常和异常的差异,找到问题所在。

  • 对比正常服务器和异常服务器的配置、日志、监控
  • 对比正常用户和异常用户的数据、操作路径
  • 对比正常时间段和异常时间段的日志、监控
  • 对比正常版本和异常版本的代码差异(git diff)

对比法能帮你快速找到"什么变了",而"变了的东西"往往就是问题的原因。

5. 调试工具

如果日志和监控都找不到问题,可以用调试工具深入分析。

  • jstack:查看Java线程堆栈,定位死锁、线程阻塞
  • jmap/jstat:查看JVM内存使用,定位内存泄漏
  • tcpdump/wireshark:抓包分析网络请求,定位网络问题
  • strace:跟踪系统调用,定位IO问题
  • gdb:调试C/C++程序
  • Chrome DevTools:调试前端问题
  • 数据库执行计划:EXPLAIN分析慢SQL

调试工具能帮你深入到系统内部,看到日志和监控看不到的细节。

四、常见的线上Bug类型

根据我的经验,线上Bug常见的有以下几类:

1. 空指针(NullPointerException)

最常见的Bug。某个对象为null,但是代码调用了它的方法或属性。

常见原因:

  • 从数据库/缓存/接口查询的数据为null,没有做判空
  • 链式调用中某个环节返回null
  • 并发情况下,对象被另一个线程置为null

排查方法:看异常堆栈,定位到具体的代码行,检查哪个对象可能为null。

预防:对可能为null的对象做判空处理,使用Optional(Java 8+)或空对象模式。

2. 死锁

两个或多个线程互相等待对方释放锁,导致都无法继续执行。

常见原因:

  • 锁的获取顺序不一致(线程A先锁X再锁Y,线程B先锁Y再锁X)
  • 数据库事务中的锁等待
  • 分布式锁的使用不当

排查方法:用jstack查看线程堆栈,找到BLOCKED状态的线程,分析锁的依赖关系。

预防:统一锁的获取顺序,设置锁超时时间,避免在锁内做耗时操作。

3. 内存泄漏

内存用了不释放,导致内存占用越来越高,最终OOM。

常见原因:

  • 静态集合类(如static Map/List)只加不删
  • 各种连接(数据库连接、网络连接)没关闭
  • 监听器/回调没注销
  • 缓存只加不淘汰
  • 内部类持有外部类引用(Java)

排查方法:用jmap dump内存,用MAT/VisualVM分析,找到占用内存最多的对象,分析引用链。

预防:及时释放资源,使用弱引用/软引用,缓存设置淘汰策略,定期内存泄漏检测。

4. 性能问题

服务响应慢、QPS上不去、CPU/内存占用高。

常见原因:

  • 慢SQL:没有索引、索引失效、大表全表扫描
  • N+1查询:循环中查询数据库
  • 大对象:一次性加载大量数据到内存
  • 同步调用:应该异步的操作用了同步
  • 锁竞争:锁的粒度太大,导致线程等待
  • 资源不足:CPU、内存、带宽不够

排查方法:

  • 看监控,找到瓶颈在哪里(CPU、内存、磁盘IO、网络)
  • 看慢查询日志,找到慢SQL,用EXPLAIN分析
  • 用jstack看线程堆栈,找到线程阻塞在哪里
  • 用压测工具复现性能问题

预防:SQL加索引,避免N+1查询,大操作异步化,缓存热点数据,定期性能测试。

5. 数据不一致

主从数据不一致、缓存和数据库不一致、分布式系统数据不一致。

常见原因:

  • 主从延迟
  • 缓存更新不及时或缓存失败
  • 分布式事务失败
  • 并发写入冲突
  • 数据迁移/同步出错

排查方法:

  • 对比主库和从库的数据
  • 对比缓存和数据库的数据
  • 看数据操作的日志,找到不一致的时间点
  • 分析并发场景下的数据操作

预防:

  • 主从延迟监控,延迟过大时读主库
  • 缓存更新策略(先更数据库再删缓存,或用双写)
  • 分布式事务用TCC、Saga、消息事务等方案
  • 并发写入用乐观锁或分布式锁

6. 并发问题

多线程/多进程环境下的数据竞争、竞态条件。

常见原因:

  • 共享变量没有同步
  • 检查-执行不是原子操作(check-then-act)
  • 死循环、活锁
  • 线程池配置不当

排查方法:

  • 代码审查,检查共享变量的访问是否有同步
  • 用jstack看线程状态
  • 压测复现并发问题

预防:

  • 共享变量用synchronized/ReentrantLock同步
  • 使用原子类(AtomicInteger等)
  • 使用并发集合(ConcurrentHashMap等)
  • 尽量避免共享状态,用不可变对象

五、第四步:修复和验证

定位到问题之后,接下来就是修复和验证

修复的原则:

  1. 最小改动:修复Bug时,尽量做最小的改动,不要顺手重构、不要改无关的代码。改动越大,引入新Bug的风险越大。
  2. 根因修复:要修复根本原因,而不是表面现象。比如空指针,不要只加个判空就完事,要搞清楚为什么这个对象会是null,从源头上避免。
  3. 兼容性:修复时要考虑兼容性,不要破坏现有的功能和接口。
  4. 可回滚:修复的代码要可回滚,如果修复后出现新问题,能快速回滚。

验证的步骤:

  1. 本地验证:在本地开发环境复现问题,修复后验证问题是否解决。
  2. 测试环境验证:部署到测试环境,跑一遍相关的测试用例,确保修复有效且没有引入新问题。
  3. 预发布/灰度验证:部署到预发布环境或灰度环境,观察一段时间,确认没有问题。
  4. 全量发布:确认没有问题后,全量发布。
  5. 线上监控:发布后密切监控,观察错误率、响应时间、日志等,确认问题彻底解决。

验证的时候要注意:

  • 不要只验证修复的那个场景,还要验证相关的场景,避免修复一个Bug引入另一个Bug
  • 偶发性问题要多次验证,确保不是"碰巧没出现"
  • 发布后至少观察30分钟到1小时,确认稳定后再下班/休息

六、第五步:事后复盘

问题解决之后,不要就这么过去了,要做事后复盘——总结经验教训,避免类似问题再次发生。

复盘的内容:

  1. 问题回顾:问题是什么、什么时候发生的、影响范围有多大、持续了多长时间。
  2. 时间线:从问题发生到发现、到止损、到定位、到修复、到验证,每个时间点做了什么。
  3. 根因分析:问题的根本原因是什么(技术原因、流程原因、人为原因)。
  4. 做得好的地方:这次处理中,哪些地方做得好,值得保持。
  5. 做得不好的地方:哪些地方做得不好,需要改进。比如:

- 发现不及时(监控没覆盖到) - 止损慢(没有回滚预案) - 定位慢(日志不全、没有链路追踪) - 沟通不畅(信息不同步)

  1. 改进措施:针对做得不好的地方,制定具体的改进措施,明确责任人和时间节点。

复盘的原则:

  • 对事不对人:复盘是为了改进,不是为了追责
  • 深挖根因:不要停留在表面,要找到根本原因
  • 可执行:改进措施要具体、可执行,不要写空话
  • 跟踪落实:改进措施要跟踪落实,不要复盘完就忘了

七、预防:如何减少线上Bug

最后,聊聊如何从源头上减少线上Bug。

1. 代码质量

  • Code Review:每一行代码都要经过Review
  • 单元测试:核心逻辑要有单元测试,覆盖率尽量高
  • 集成测试:关键流程要有集成测试
  • 静态代码检查:用SonarQube、FindBugs等工具检查代码质量

2. 测试

  • 测试环境要尽量和线上一致
  • 测试用例要覆盖正常场景、异常场景、边界场景
  • 回归测试:每次发布前跑一遍回归测试
  • 灰度测试:新功能先灰度,观察没问题再全量

3. 发布

  • 小步快跑:每次发布的改动尽量小,不要一次发太多
  • 发布时间:尽量在业务低峰期发布
  • 回滚预案:每次发布前都要准备好回滚预案
  • 发布后观察:发布后密切监控,至少观察30分钟

4. 监控

  • 全面的监控:服务器、应用、数据库、中间件都要监控
  • 告警:设置合理的告警阈值,问题早发现
  • 日志:详细的日志,方便排查问题
  • 链路追踪:分布式系统要有链路追踪,方便定位问题

5. 架构

  • 高可用:避免单点故障,多机房、多副本
  • 容错:降级、限流、熔断,保护系统
  • 可扩展:水平扩展,应对流量增长
  • 松耦合:模块之间松耦合,一个模块出问题不影响其他模块

6. 应急

  • 应急预案:常见故障要有应急预案
  • 应急演练:定期做故障演练,验证应急预案
  • On-call:建立On-call机制,问题有人及时处理

八、写在最后

线上出Bug,是每个程序员都会遇到的事。区别在于,遇到线上问题时,你是慌慌张张、越搞越糟,还是从容不迫、高效解决。

我的排查思路总结起来就是五步:

  1. 止损优先:先恢复服务,再找原因
  2. 信息收集:收集日志、监控、用户反馈、变更记录
  3. 问题定位:用复现、日志分析、二分法、对比法、调试工具定位根因
  4. 修复验证:最小改动修复,多环境验证
  5. 事后复盘:总结经验教训,持续改进

这套思路不是万能的,但是能帮你在遇到线上问题时,有一个清晰的框架,不慌不乱、高效排查。

最后,想说的是:线上Bug不可怕,可怕的是不从Bug中学习。每一个线上Bug,都是一次成长的机会。认真对待每一个Bug,深入分析根因,做好预防,你会越来越强。

愿每一个程序员,都能少遇到线上Bug,遇到了也能从容应对。愿每一个系统,都能稳定运行,永不宕机。