ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

写代码前先问5个为什么:一次日志风暴事故复盘

写代码前先问5个为什么:一次日志风暴事故复盘 前一阵子凌晨两点四十分告警群炸了。系统崩溃不是单台机器响应慢是批处理任务把整条链路打挂。登录服务器第一眼看到的景象就让人头皮发麻/var/log所在分区已经 100%一个应用日志文件膨胀到了几十个 G旁边还躺着几个 G 的崩溃转储文件。当天下午复盘我们围绕“为什么”连问了五轮最后问出了一行写在catch块里的代码。也是从那天起团队立下一条铁规写代码前先问 5 个“为什么”。这篇文章不讲高大上的研发流程就是一次真实事故的复盘以及我们从里面抽出来的“写代码前置自检五问”。同步附上日志风暴、磁盘塞满这类问题的排查命令还有把五问落进代码评审和日常开发里的具体做法。适合写过代码、为线上事故熬过夜的人也适合正在头疼“代码评审流于形式”的技术负责人。1. 崩溃那一夜系统是怎么被日志和内存“撑死”的1.1 事故现象凌晨告警与暴涨的日志文件那天晚上是一个常规的跑批窗口交易对账任务准时启动。刚开始告警只提示“消费延迟增大”没人太当回事因为这类对账任务偶尔会因为数据库慢查询拖后腿。等第二条告警出来情况已经失控应用节点连续拒绝请求健康检查开始失败紧接着磁盘可用空间归零。先看文件系统/data分区被写满/var/log下某个app-trade.log已经长到了 40 多 G。更诡异的是系统里还出现了几个 core 文件单个大小就有 2 到 3 G。我当时的第一反应是“日志太吵了清掉再说”于是删掉一部分老日志后把人肉重启了应用。结果十分钟后磁盘又满了日志还在以肉眼可见的速度疯涨。那个瞬间我意识到这不是日志配置的问题是有东西在无限循环里高速制造垃圾。后来通过堆栈和代码定位真凶是一个消息消费线程。伪代码大概是这个逻辑while (running) { Task task queue.poll(5, TimeUnit.SECONDS); try { process(task); } catch (Exception ex) { // Redis 连不上时这里会疯狂打印并重试 pendingTasks.add(task); retryCount; LOGGER.error(process failed, retry{}, retryCount, ex); TimeUnit.MILLISECONDS.sleep(100); } }Redis 出现故障后process()每次调用都会在尝试获取连接时抛出异常然后被 catch 住任务被塞进pendingTasks这个内存集合记录重试次数并打印完整异常堆栈。整个循环每 100 毫秒转一圈一分钟就是 600 次失败日志每次堆栈可能几十行。几千条消息在队列里排着内存集合越来越大最终把堆撑爆进程被 OOM Killer 干掉了。崩溃转储文件自然也就特别巨大毕竟那是进程整个内存的镜像。1.2 恢复不是结束从“磁盘满”追到“一行边界代码”把 Redis 故障隔离掉以后系统确实恢复了但这只能算救火完成。复盘会上我们问了第一个问题为什么一个 Redis 故障能让磁盘满没人能一句话答上来。于是我们开始顺着因果链追问为什么内存会无限膨胀因为失败任务被无脑放入内存集合。为什么要放内存而不是换一种方式处理因为写代码的人当时默认“重试一定会尽快成功”压根没想过 Redis 会持续不可用。再往前问为什么没有人发现这个消费线程的异常行为因为日志虽然刷屏但我们没有配“日志速率告警”日志量大到一定程度时才通过磁盘告警触达这时通常已经晚了。问到这一层大家沉默了。真正的问题代码只有一行pendingTasks.add(task)。可这一行背后藏着五个没有在写代码前想清楚的“为什么”。恢复服务只需要重启但这次事故真正让我们改变的是“事后追责”变成“事前自检”。如果写那段消费逻辑时有人先问一句“Redis 挂了会怎样”就不会有那天晚上。2. 5 个“为什么”清单写代码前逼自己回答的问题2.1 经典 5Why 是事后追溯我们把它前移到了动手前生产管理领域一直有“连续问五个为什么找根因”的做法。经典的 5Why 是事后追溯从问题表象出发一级一级往上游问直到找到系统性原因。但做过几次复盘的人都知道事后追溯有一个尴尬之处——链条可能已经断过好几次越往上问越接近“团队惯性”和“管理文化”改起来周期特别长。我们这次不一样的地方在于把 5Why 的思路从“事后追因”换成了“事前预检”。既然大多数线上故障的因果链在代码写成那一刻就已经定型了那不如在动手之前就把链条检查一遍。写代码前回答不出某个“为什么”那就说明这个环节会在未来的某次故障里坑你。五问并不需要长篇大论它是每次写代码前花三五分钟过一遍的“安全驾驶检查”比出了事故再追责便宜得多。这种前置五问本质上是把设计评审中最关键的几个问题做成了固定动作。很多团队不是没有设计评审而是评审只发生在大型项目上日常的业务代码、临时补丁、AI 生成的工具脚本根本没人审。我们要补的正是最日常、最容易出事故的那部分。2.2 写代码前必问的五个问题以及答不上来的代价我们最终沉淀的五问如下。第一问这段代码要解决什么问题它值不值得写 这一问看着简单但能过滤掉大量“为写而写”的代码。很多时候需求本身是含糊的代码写着写着就变成了堆功能。如果答不上来这段代码服务的业务场景那说明需求还没搞明白写出来的东西大概率有偏差。AI 辅助开发的年代更是如此模型很容易生成看起来完整但完全没用的方法。第二问为什么用这个实现方式有没有更简单的选择 这个问题考察的是方案选型。代码复杂度和故障概率几乎成正比。同样是任务处理为什么不用失败即丢弃、后续靠对账补偿的方案而是要用一个内存集合无限重试如果再选一次我们会选“失败后记录 offset定时回溯重放”这种把状态外置、风险可控的方案。写代码时优先选最容易证明正确性的方案而不是最炫酷的方案。第三问如果它依赖的 Redis、数据库、第三方接口挂了这段代码会变成什么样 这大概是最能救命的一问。很多代码默认外部依赖是永远健康的实际上中间件故障、网络抖动才是常态。问完这一问就能发现刚刚那个消费线程至少应该有四件事失败重试次数上限、失败数据的落盘或外部队列、对重试频率做退避、对整个异常路径设置熔断开关。第四问边界条件和异常路径有哪些能不能一口气说出三个 空值、超时、重复调用、并发冲突、幂等、数值溢出、时区差异这些都是边界。支付、充值这类写接口尤其敏感同样一笔请求被重试两次如果接口不幂等系统恢复后就会出现双倍扣款。写代码之前说不出来至少三个边界场景说明异常分支根本没设计测试用例自然也不可能覆盖到。第五问如果这段代码上线后出了问题我能在几分钟内感知到 这就是可观测性的问题。很多开发会写代码但没想过“我怎么知道它坏了”。当晚事故里的消费线程如果配了日志速率告警Redis 挂掉后 30 秒内就能收到告警根本不会拖到磁盘满了才发现。这一问要求在动手前就把指标、日志、告警链路想清楚而不是上线后再补。2.3 问题之间的逻辑需求、方案、韧性、边界、观测这五个问题不是随机凑出来的它们的顺序正好覆盖了代码生命周期的五个关键节点。前两问解决“写什么、怎么写”关注的是需求理解和方案取舍第三问关注系统韧性问的是外部依赖不可用时的表现第四问关注边界完整性问的是各种异常输入下代码是否安全第五问关注可观测性问的是故障发生后的发现与定位速度。一个代码片段如果能把五问全部回答清楚它基本就具备了一个小模块的设计文档。反过来说如果你发现某段代码五问全部含糊那它就是一个在等事故的雷。我们的经验是五问不需要写长篇文档自己心里过一遍、能明确说出结果就行。核心是“回答得出来”不是“留痕填表”。当然也要提醒一句五问不是放之四海而皆准的教条。团队后来逐步把问题措辞调整成了适合自己业务的版本比如涉及资金的对账代码会多加一问“金额精度与幂等怎么保证”涉及定时任务的会多问“任务重复调度怎么办”。五问真正的作用是触发思考而不是成为新的形式主义表格。3. 铁规落地从一张 Checklist 到代码评审的固定动作3.1 一张可抄作业的 Checklist 模板直接贴进 PR 描述光有口号没用团队想了很多办法把五问落到日常动作里。最终真正执行下来最有效的是把它做成 PR 描述里的固定模板。每个开发者提交代码时必须填写下面的内容Reviewer 也默认按这个模板来审【写代码前五问 CheckList】 Q1 价值: 这段代码解决什么问题对应哪个业务场景 Q2 方案: 为什么选这个实现有备选方案吗复杂度是否可接受 Q3 依赖: 如果 Redis / DB / 第三方接口不可用这段代码会怎样 有降级或熔断吗 Q4 边界: 空值、超时、重复、并发等场景分别怎么处理举三个例子。 Q5 观测: 上线后我通过哪个指标/日志判断它正常异常告警链路通了吗注意回答质量是有要求的。最忌讳的写法是“已考虑”“无风险”“按最佳实践处理”这种空话。比如 Q4 如果只写“有异常处理”等于没有信息量。我们要求每个人把场景具体化空值时返回什么、重复请求是否幂等、超时时间怎么定、积压后从哪个 offset 继续消费都要写清楚。这套模板刚推行时确实会拖慢提交流程。但一个月后大家发现填模板的时间其实就是在替代过去“写代码时反复纠结”“提测后被测试打回”的时间整体效率反而上去了。更重要的是PR 描述第一次变得值得读Reviewer 不用再对着代码猜作者意图。3.2 代码评审不再说“不对”而是问“第五个为什么”模板是静态的真正让五问活起来的是代码评审环节。以前评审经常是“这里命名不好”“这段逻辑太复杂”“加个注释吧”讨论散落在细节里最后 Reviewer 和作者都很累。现在评审动作收拢成了一套固定打法围绕五问逐项核对重点看 Q3、Q4、Q5。举两个评审对话的对比。以前遇到复杂逻辑我们可能会说“这里逻辑太绕了能不能拆简单点”作者听了常常不知道怎么改因为“简单”没有标准。现在我们会直接问“你 Q2 说这是唯一方案那如果队列消费落后两小时这个方案还会保持稳定吗”这个问题立刻把讨论拉回技术决策层面作者需要拿出数据或者换方案评审就不再是情绪化的“挑刺”。再比如 Q5 的默认问法“你说上线后看消费延迟那如果进程直接 OOM 挂了呢延迟指标还看得到吗”这种追问逼着作者想办法给进程装上“心跳”类基础探活而不只是业务指标。五问推动评审的价值在于它给了双方一个共同的坐标讨论永远围绕“作者当时的问题假设是否成立”而不是围绕“谁的代码水平更高”。3.3 AI 写代码越热这五个问题越不能省最近团队里用 AI 写代码的同学越来越多各种智能体和提示词工程的话题也很热经常有人问“哪个 AI 写代码更强”。我的看法是不用纠结谁强更强的其实是“能把边界要求说清楚的人”。AI 最擅长的是生成“看起来正确”的代码它会自动补全你没想到的分支但它不会主动告诉你它默认 Redis 永远可用默认消息不会重复默认金额不会溢出。我们已经踩过 AI 生成代码的坑所以给团队定的规矩是AI 写代码可以但提交前必须自己过五问并且要求 AI 在注释里显式声明异常行为。比如写消费任务时提示词里可以直接加一句请实现任务消费逻辑并在注释中明确标注 1. 当 Redis 不可用时这段代码的具体行为 2. 消息积压恢复后的启动策略 3. 异常日志的采样策略和告警埋点位置把 Q3、Q4、Q5 的要求直接写进提示词AI 的输出质量会明显不一样。但无论模型多强最后对代码负责的一定是提交代码的人。五问不能由 AI 代答只能作为人类判断的工具。这一点我们始终没动摇。4. 崩溃与日志类问题排查亲测有效的命令和工具4.1 磁盘瞬间塞满用一套命令把“元凶文件”揪出来我处理过不少磁盘被塞满的现场包括客户那边一台 Linux 服务器崩溃后系统转储文件大得直接把根分区写满。这类问题的第一原则是先定位再清理别急着删文件。很多人上来就rm -rf一个日志文件结果隐患还在几分钟后磁盘又满白忙活。常用命令组合df -hT # 看文件系统占用情况和类型 du -sh /var/log/* # 逐个目录找体积最大的 du -h --max-depth1 /data # 按目录层级往下钻取 lsof | grep deleted # 检查已删除但进程仍占用的文件 find / -xdev -type f -size 500M # 全盘扫描大于 500M 的大文件 cat /proc/sys/kernel/core_pattern # 查看崩溃转储文件的落地规则这里有个特别容易踩的坑日志文件被rm删除后如果进程还在持续写入磁盘空间其实不会释放因为删除只断了目录项文件句柄还被进程占着。判断方法就是lsof | grep deleted出来一堆记录的话说明空间被活进程锁住了。这时候要么重启进程要么kill后让日志滚动重新创建单纯删文件没用。系统崩溃转储文件特别大的问题也常在这里出现。某些 Linux 发行版的默认配置会把 core dump 写到固定目录并保留全部历史一旦进程反复崩溃转储文件就能把磁盘撑爆。排查时除了看core_pattern还要看/etc/security/limits.conf里的core限制项。生产环境的通用做法是限制 core 文件大小或者只在排查阶段临时开启。4.2 日志风暴从重复堆栈反推问题代码行的技巧日志风暴的高发区就是异常处理里的printStackTrace或者logger.error。排查时不要盯着一两条报错看要统计报错出现的密度和频率这样才能和“真的偶尔出错”区分开。先用wc -l看日志文件行数再抓一个关键词统计重复量grep RedisConnectionFailureException app.log | wc -l grep RedisConnectionFailureException app.log | awk {print $1, $2} | sort | uniq -c | tail -20第二条命令能按秒聚合出异常出现的频率。如果看到一秒钟几百上千次基本可以确定是循环里打日志接下来直接看堆栈顶部的调用链回代码里找那个循环。定位到循环后别只顾着把日志删掉或者把sleep时间调大要根除两类问题第一循环里有没有重试次数上限第二异常日志有没有做采样限流。生产环境里给 ERROR 日志加“相同异常每分钟只打一次”的限流是很有必要的否则风暴一旦来临整个磁盘就是牺牲品。说到“表面现象和真实根因的差距”我常拿一个开发环境的小问题举例有人在 VSCode 里写 C 代码发现没有代码提示第一反应是重装插件折腾半天没用。真实原因多半是项目没有生成compile_commands.jsonVSCode 的 IntelliSense 不知道头文件路径自然给不出提示。遇到这种问题应该查看 C/C 插件的诊断日志或者用 CMake 工具生成索引文件而不是“觉得环境坏了”就瞎重装。这套思路和排查线上故障是一样的先看现象再找根因不要被表面问题牵着走。4.3 崩溃文件特别大先分清 core dump、堆转储和日志服务器上一夜之间多出几个 G 的崩溃文件很多人第一反应是“中毒了”或者“编译缓存没清”实际上要先搞清楚它是哪一类文件处理方式完全不同。我整理了一张区分表类型产生条件为什么大排查命令处理建议core dump进程崩溃时内核写内存镜像大小约等于进程内存占用gdb core.xxx看调用栈生产环境限制大小仅在需要时开启JVM heap dumpOOM 或主动导出堆大小可能达到数 Gjmap -dump:formatb,fileheap.hprof 进程号配合 MAT 分析别在高负载时执行应用日志日志风暴/无限循环每分钟可写几百 MBdu/tail/greplogrotate 限大小、异常日志限流区分它们的意义在于止损方式不同。如果是 core dump直接删文件通常问题不大但要检查为什么进程崩溃可能需要保留最新一个供 gdb 分析。如果是 heap dump那它本身就是分析材料导出后删掉不影响进程但一旦 OOM 真发生你得先判断是堆不够还是内存泄漏。如果是日志风暴那删文件只是止血必须回到代码里解决循环。我们现场的教训是拿到一个几 G 的巨大文件第一反而不是去“清理磁盘”而是先file命令识别文件类型再ls -lh --time看生成时间是否与崩溃时间吻合。用这个思路十分钟内就能确定优先级而不是在那瞎猜。5. 复盘常见误区与推行阻力怎么让五问别变成形式5.1 最常见三个复盘误区我们最开始也全部踩中复盘会开了很多次真正有产出的少。最典型的误区有三个。误区一把根因归到“资源不足”。比如磁盘满就说“加块盘”、堆溢出就说“调大内存”。为什么磁盘会满为什么堆会被撑爆资源告警只是结果不是原因。如果注意力只停留在资源扩容上下次换一个业务场景同样的代码会以另一种方式爆发。误区二把根因归到个人身上。“就是某某写代码不够小心”“当时 review 怎么没看出来”。这种话一说复盘就变成追责会所有人开始防御没有人再去想系统为什么不防呆。五问的正确目标是流程和机制不是人名。代码写得不好不是原因缺少前置自检清单和评审标准才是。误区三只修现场不修习惯。故障恢复后补个告警、修个 bug以为完事了没有把教训沉淀成开发流程和评审模板里的新问题。这样的复盘只能覆盖过去不能预防未来。当晚我们恢复后的第一个动作是写五问清单第二个动作是监控覆盖率审计把 Redis 故障演练直接排进了下个迭代。5.2 推行五问时遭遇的阻力以及我们的应对五问清单不是一开始就顺畅执行的团队里出现过几种典型声音。第一种声音是“问这么多写代码好慢”。实际跑一个月就会发现回答五问只需要几分钟但它能帮你在动手前把方案调整好避免写完一版再推翻。我们面对紧急热修时也允许走“简化版三问”只答 Q3 依赖故障、Q4 边界场景、Q5 观测方式三分钟过完再动手。慢在三分钟前不慢在三小时后。第二种声音是“Checklist 变成走形式”。填表交差是最容易发生的事对策是评审时随机挑选一个问题要求当面讲清楚。比如作者 Q3 写了“Redis 异常时有降级”那 Review 就追问“降级开关怎么触发手动还是自动降级以后数据怎么补偿”答不上来就是没过要回去补设计。形式主义最怕的是真刀真枪的追问。第三种声音是“代码评审变成拷问”。这个问题确实存在所以复盘会上我会提醒大家区分“防御式提问”和“攻击式提问”。好的问法是“我们一起看看这个假设是否成立”而不是“你这写的什么玩意”。五问是帮团队一起排雷的工具不是比谁更细的武器。5.3 一些个人体会提问比答案更值钱经历过这次事故我最大的感受是程序员这个职业最容易犯的错不是代码写得有 bug而是“默认外部世界按照你的想象运转”。写代码真正难的地方不是在正确输入下做正确输出而是在异常输入、异常依赖、异常时序下保证不犯严重错误。五问清单看着朴素但它把“我想当然”变成了“我验证过”。现在我带人的时候不太在意对方能背多少 API反而更在意他提交代码之前能不能一口气说出这段代码的三个风险点。说得出来代码基本上稳说不上来我宁愿他先别写。这种能力不需要天赋靠的就是每次动手前老老实实问自己五个为什么练出来的习惯而已。
返回列表