ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Java生产事故复盘:从并发超卖到系统雪崩的避坑指南

Java生产事故复盘:从并发超卖到系统雪崩的避坑指南 1. 从一次夜间紧急工单说起做Java开发这几年最怕的不是需求复杂也不是工期紧张而是半夜手机震动——线上报警了。有一次我印象特别深凌晨两点多某商城系统的下单接口突然大面积超时紧接着库存数据出现负数用户疯狂下单系统直接被打穿。事后复盘发现问题根源居然只是一个很不起眼的并发控制缺失库存扣减用的是先查询再更新的逻辑没有加锁也没有做乐观锁控制多个请求同时读到同一个库存值各自扣减后写回于是出现了严重超卖。那次事故直接导致故障时长近一小时损失难以估量。但也正是从那次开始我养成了一个习惯每遇到一起线上事故不管大小都会认真做一次复盘把根因、修复过程、预防措施完整记录下来。这篇文章就是基于我这些年积累的编码生产事故总结复盘经验整理成一份可以反复参考的资料。如果你也是一名Java开发尤其在做电商、交易、支付这类高并发场景的系统这篇文章应该能帮你少踩不少坑。我不会去讲那些教科书上早就写过的理论而是把真实遇到过的场景、当时怎么排查、问题出在哪里、代码层面怎么改、流程层面怎么堵一五一十说清楚。很多内容是我个人踩坑后的沉淀不一定适合所有团队但至少代表了一条被验证过的路。2. 编码生产事故的主要形态与根因分析生产事故千奇百怪但把根子刨出来看Java后端领域的事故大多集中在几个点上。我先把这几年见过、经历过的典型类型梳理一遍再逐一展开讲案例。事故类型典型现象根因走向并发控制缺失超卖、重复扣款、数据被覆盖锁粒度、事务隔离级别、幂等设计空指针与数据异常接口500、NullPointerException刷屏下游数据为空、参数校验缺失事务边界错位部分成功部分失败、数据不一致事务方法自调用、异常被吞异步与线程池问题消息积压、线程池拒绝、状态丢失异步链路未做补偿、参数未传全内存与性能问题频繁Full GC、OOM、接口RT飙升大对象、集合无界、慢SQL2.1 并发控制缺失从超卖事故讲到锁的选型先说我开头提到的那个超卖案例。当时的下单接口代码大致长这样public boolean createOrder(Long userId, Long skuId, Integer num) { // 1. 查库存 Stock stock stockMapper.selectBySkuId(skuId); // 2. 判断库存是否足够 if (stock.getAvailable() num) { return false; } // 3. 扣减库存 stock.setAvailable(stock.getAvailable() - num); stockMapper.updateById(stock); // 4. 创建订单 orderMapper.insert(Order.build(userId, skuId, num)); return true; }这段代码在低并发下完全没问题但一旦遇到秒杀或者大促问题立刻暴露。两个线程同时执行到第二步读到的可用库存都是10都认为可以扣5分别更新成5写回实际卖出了10件而库存只剩5件系统还能继续往下卖直到库存变负数为止。这类问题的解法业内已经比较成熟了无非几种乐观锁方式在更新SQL里加上库存充足条件UPDATE stock SET available available - #{num} WHERE sku_id #{skuId} AND available #{num}悲观锁方式查询时加上行锁。分布式锁方式适合跨服务的库存扣减。我用得最多的是第一种因为实现简单、性能也好。实际工作中要注意很多人以为加上synchronized就万事大吉了但如果是多实例部署synchronized只能锁住本机JVM内的线程跨节点依然会有问题。这点新手特别容易踩。后来我还总结出一条经验**凡是涉及金额、库存、状态流转的写操作必须在设计阶段就把并发模型画出来而不是等上线出了事再去补锁。**并发问题的修复成本永远比预防成本高一个数量级。2.2 空指针与数据异常那些被“看似不会为空”的数据坑惨的时刻如果说并发问题还分场景那空指针就是天天见的常客。有一次我们的用户信息接口突然在某个渠道报错排查发现是上游服务返回的用户扩展字段里某个属性为null而我们在代码里直接调用了该属性的方法。这种问题在Java里实在太多了尤其是从接口拿数据时对方的文档写着“非空”但实际上某些历史数据就是不满足约束。我当时给团队立了一条规矩**除了基础类型和明确由自己写入且必填的字段其他所有外部数据都要按可能为null来处理。**这不是代码洁癖是对系统边界保持敬畏。处理空指针的方法很多我用得最多的组合是方法入口用Objects.requireNonNull或者提前校验。传输对象用Optional做显式处理不要无脑get()。集合操作前判断空集合Collections.emptyList()兜底。参数校验落地到注解上比如NotNull、NotBlank在Controller层就把问题挡住。数据异常类的事故就更隐蔽了。比如前一阵我们做数据订正脚本把一批订单的金额字段从字符串转成了整数结果有个别订单因为包含了小数点后两位以外的特殊字符转换直接抛异常脚本中断。这种看起来不影响线上接口的事故往往会造成更大的数据污染。我现在的习惯是凡是批量更新、数据订正、迁移脚本先加试跑模式只输出结果不落库核对无误后再全量执行而且在执行前必须手动备份相关表。2.3 事务边界错位一个Transactional引发的连环事故事务是Java后端最常用的能力之一但也是最容易被误用的。我遇到过一个特别典型的案例某个订单回调服务里开发同学在方法上加了Transactional方法内部先更新订单状态再调用远程接口通知仓库发货最后更新一张积分表。整个方法跑完大约需要3秒期间事务一直持有数据库连接。问题出在远程调用如果超时事务会长时间挂起连接池很快被耗尽其他请求全部排队等待最终导致服务雪崩。事务里做远程调用这件事我一直强调严格控制事务边界绝不在事务内做耗时操作。另一个坑是事务自调用。同一个类里A方法调用B方法B上有Transactional但Spring的AOP基于代理自调用不会触发代理逻辑事务注解直接失效。很多团队因此专门用Autowired注入自身来绕开或者把被调方法拆到另一个Service里。后来我还在Code Review清单里加了一条自查项检查所有事务注解是否真正生效。还有一类事务问题是异常被吞。方法上声明了Transactional(rollbackFor Exception.class)但内部catch了异常又没往外抛事务照样不回滚。数据已经写了一半接口返回成功下游却看到的是半成品数据。现在我对团队的规范是事务方法内部尽量不要自己catch异常要catch就处理后重新抛出至少要区分哪些异常必须回滚、哪些可以忽略。2.4 异步处理与线程池场景下的隐患异步化是提升接口响应速度的利器但异步链路里的坑一点也不少。第一个坑是线程池参数拍脑袋定。有一次我们把一个异步推送任务放到了一个无界的线程池里高峰时任务排队上百万个内存直接飙升到接近堆上限频繁Full GC。后来改成有界队列、自定义拒绝策略才稳定下来。线程池的核心参数并没有万能公式但至少要根据任务类型估算。比如任务平均执行时间是50ms期望吞吐是每秒200个任务那核心线程数至少要让处理能力大于流入速度否则队列只会越积越长。我们在生产环境的做法是核心线程数和最大线程数压到合理范围内队列用有界队列拒绝策略选择CallerRunsPolicy让提交任务的线程自己跑这样不会丢任务也起到了天然限流的作用。还有一个经典问题异步线程里的上下文丢失。比如在普通请求线程里设置了一个追踪ID丢到异步线程池执行时追踪ID就拿不到了排查问题的时候日志全对不上。我们后期统一通过TransmittableThreadLocal解决没有用原生的ThreadLocal。异步场景另一个高频事故是漏传参数或者丢失主键。有一次我们把某个通知任务异步化只传了订单号过去异步线程里根据订单号查询时订单状态已经变了导致通知内容发错。这个问题的本质是异步执行时的数据快照和提交时的数据快照可能完全不同依赖实时查询的结果去做业务判断要特别小心。2.5 内存与性能方面的生产事故内存和性能事故比较隐蔽往往不是一次性爆发的而是积少成多。我举一个例子有个报表导出功能一次性从数据库查了几十万条记录放进了List然后逐个处理每个对象还保留着一个很大的JSON字段。几个用户同时操作内存立刻吃紧Full GC频繁整个服务RT翻倍其他接口跟着一起遭殃。这个问题的本质是大对象驻留老年代解决思路就两个字分页或者流式处理。数据库端用游标读取应用端处理一批就释放一批千万不能把全量数据一次性load到内存里。性能类的生产事故里慢SQL也是大头。最常见的情况是查询条件里用了函数计算导致索引失效。比如WHERE DATE_FORMAT(create_time, %Y-%m-%d) 2024-01-01这种写法一定全表扫描。正确的做法是算好时间范围再传参WHERE create_time ? AND create_time ?。我在复盘这些性能事故的时候越来越觉得**性能问题不是什么高深莫测的东西大多数时候就是数据的访问模式错了。**只要对数据量有感知对索引的使用有常识就能避免绝大部分问题。3. 事故复盘方法论与排查实操遇到生产事故最怕的是慌乱。我自己的排查流程已经固定下来了不管什么事故都按这套思路走效率会高很多。第一步看监控、看日志、定性。先把影响范围搞清楚是全部用户挂还是部分接口挂是持续挂还是间歇性挂。这一步决定了接下来的排查方向。第二步看最近变更。很多事故不是突然出现的而是某个发布或者配置改动触发的。排查变更比从代码海洋里捞针快得多。我们团队已经形成习惯每次发布都必须有变更记录否则出了事故连方向都找不到。第三步定位根因。这一步要用到日志、线程栈、堆转储、APM链路追踪等手段。第四步验证并修复。修复不能用“试试看”的心态必须能解释为什么这么改是对的最好有压测或者回放作为佐证。第五步沉淀复盘报告。这一步很多人忽略但恰恰是最重要的。3.1 第一时间定位问题的思路我举个例子。有一次线上接口突然超时我第一反应不是打开IDE看代码而是先看监控面板的P95和P99。发现P99飙升但P50正常。基本可以断定不是整体负载问题而是少数慢请求或者某条特殊路径存在问题。再往下看发现超时请求集中在某几个商品SKU而这些SKU有一个共同特点积压的库存记录特别多。再结合数据库慢查询日志发现对库存表的分页查询走了全表扫描因为查询条件里store_id没有索引。整个过程大概花了二十分钟如果从代码开始看可能两小时都未必找得到。这里我要强调一点**排查事故时要克制住“凭经验猜”的冲动。**我见过不少老开发一看超时就说“是不是GC了”上去先抓堆栈折腾半天发现根本不是。正确做法是用数据说话用监控面板一步步缩小范围。一线排查的常用命令我也会整理成一个速查表场景命令/操作作用查看JVM堆使用jstat -gcutil pid 1000观察GC频率、内存余量查看线程状态jstack pid thread.txt分析死锁、线程阻塞抓取堆转储jmap -dump:formatb,fileheap.hprof pid分析OOM、大对象查询慢SQL开启慢日志并分析定位SQL性能问题链路追踪根据traceId串联日志定位跨服务耗时3.2 日志与监控如何帮助我们复盘做事故复盘最怕的是没有数据全靠记忆。所以日志记录这件事真到了复盘时才会发现有多重要。我之前踩过一个坑生产环境某个对象的序列化日志被打得铺天盖地一天几个GB真正需要追踪的业务字段却没有一个。后来我主导了一次日志规范梳理才把日志从“能跑就行”提升到“可观测”的水平。日志规范里核心就几条关键业务操作必须记录入参、出参、耗时、traceId。日志级别要有明确规范debug不进生产info记录业务关键节点error记录异常和失败路径。禁止在循环里打日志尤其禁止在大集合的循环里打。敏感信息脱敏不能把手机号、身份证明文打进日志。监控方面我们现在做到了三层基础层监控CPU、内存、磁盘、网络应用层监控QPS、RT、错误率、线程池活跃度业务层监控订单量、支付成功率、库存扣减次数这种和用户感受直接相关的指标。有了这三层事故的定位速度会快非常多。3.3 从现象到根因的推理过程复盘文档里我最看重的一个环节就是从现象到根因的推理过程而不是最终结论。因为只有把推理链写出来才能验证当时是不是有逻辑跳跃才能让后人学习到完整的思路。举一个之前遇到的事故。现象是某天晚上整体订单量无明显增长但支付回调接口的错误率突然上升持续了大概十分钟后自行恢复。如果只看结论可能就写一句“上游服务抖动导致的偶发失败”。但我们深入推理后发现错误率上升的时间点正好和某台Tomcat节点被重启的时间吻合——那台节点重启是因为健康检查失败被自动摘除了。健康检查失败的原因是JVM发生了一次长达20秒的Full GC而Full GC的诱因是前一天的灰度发布增加了对象缓存的大小。所以整条链路是灰度发布加缓存 → 内存占用升高 → 触发Full GC → 节点对健康检查无响应 → 被摘除 → 流量重新分配到其他节点 → 其他节点压力短暂升高 → 支付回调偶发超时 → 客户端重试反馈到错误率上。如果只停留在“上游抖动”这件事永远不可能真正解决。也正因为我们坚持把推理链挖到最底层才发现真正需要优化的是对象缓存的过期策略和堆内存参数。这种复盘方式让我对系统运行的理解上升了不止一个层次。4. 如何从制度与习惯上减少编码生产事故事故复盘再多如果只是写文档那也只是一堆文字。真正有效的复盘一定要落在制度建设和习惯改变上。4.1 代码评审与静态检查的落地很多人觉得代码评审走形式但我认为**评审是编码生产事故的第一道防线。**问题在于很多团队的评审只关注“代码能不能跑”“功能对不对”很少关注“会不会挂”。我建议评审时至少增加这几个视角并发安全视角这段代码有没有可能被多个线程同时执行覆盖的共享资源是什么边界视角传入参数有没有可能为null集合有没有可能为空数据量有没有可能超出预期依赖视角这个方法依赖的外部服务不可用时系统会怎样生命周期视角临时文件、线程、连接这些资源是否都会释放静态检查工具也是必须的比如SonarQube、SpotBugs、Alibaba Java Coding Guidelines插件。但要记住工具只能抓明显的坏味道抓不了设计层面的问题。真正有价值的还是人。后来我们团队要求所有代码合并前必须有至少一个评审人认真过一遍不是点个“通过”按钮那种而是要真的理解改动逻辑。4.2 测试策略与灰度发布编码事故里很多都是可以通过测试提前发现的。我们现在的分支覆盖率要求可能不算高但核心链路的场景测试覆盖得很全。什么叫核心链路就是用户下单、支付回调、退款、库存扣减、对账这些每一个都要有正反向用例。测试环境的问题是数据量小、不会触发并发和性能隐患所以单测过了不代表生产安全。我比较推崇的是把压测常态化。团队每隔一个迭代做一次核心接口的压测不需要极其精确但至少能知道当前接口的瓶颈在哪里。很多线上超时事故其实在压测阶段就该暴露了。灰度发布更是救命稻草。我们现在的发布方式是先金丝雀节点观察几分钟没有异常再全量。而且灰度期间要重点盯业务指标不只是CPU、内存这些技术指标。有一次新版本发布后接口技术上一切正常但订单转化率掉了1.5%灰度监控立刻把它抓了出来一查发现是前端页面的某个控件被错误地挪了位置。技术指标正常不代表业务正常这条经验很重要。4.3 团队层面的复盘与知识沉淀团队例行的事故复盘会一定要开成“学习会”而不是“追责会”。一旦追责所有人都会选择隐瞒和掩藏事故信息就会丢失。我们的原则很简单**只谈技术根因不谈责任人。**所有代码都是团队资产别人写的烂代码如果你没有在评审时拦住那这个责任是共同的。复盘会的流程通常是这样的事故当事人讲时间线和现象。大家一起剖析根因允许反驳和补充。得出行动计划责任到人给截止时间。把复盘内容归档到团队知识库并且同步到新人学习资料里。我还特别建议做“事故案例库”。每一起事故不论大小都记录在一个共享文档里标注清楚标题、现象、根因、影响、修复方案、预防措施。新人入职的时候不要先看规范文档先看事故案例库这比任何培训都有效。因为规范讲的是“应该怎么做”事故案例讲的是“不这么做会有什么后果”后者才能真正让人记住。5. 我个人的几条心得与建议讲完这么多最后再说几条比较零散、但确实是我工作里反复验证过的经验吧。第一**写代码前先想一想失败的情况。**很多事故的根源是开发默认“所有事都会成功”。但真实生产环境里下游服务会超时数据库连接会断缓存会失效消息会丢失。写代码的时候把异常路径和正常路径同等对待很多事故从一开始就不会存在。第二**变更比故障更值得关注。**生产事故绝大多数是由某个变更触发的。所以做任何变更前都要想一想影响范围列一个回滚方案。发布后盯着看一会儿再离开工位。不用多十五分钟就好往往能拦住大部分问题。第三**不要迷信“测试环境能测出来”。**生产环境的数据量、并发量、网络状况测试环境永远模拟不出来。所以线上监控和日志必须从一开始就做好那是你唯一能在生产环境看清系统的眼睛。第四**复盘报告不是写给领导看的是写给自己和下一个人看的。**哪怕没有人要求你写出了事故我也会自己写一份哪怕只是几百字的备忘录下次遇到类似问题翻出来看一眼能省几个小时。做Java开发越久我越觉得真正拉开差距的往往不是框架用得有多熟新特性背得有多牢而是面对复杂系统时那种“下意识地敬畏”敬畏数据敬畏并发敬畏每一个可能为空的外部依赖。一次生产事故是一次很好的学习机会关键看你能不能把机会变成经验把经验变成制度把制度变成习惯。希望这篇文章里记录的那些坑和思路能帮你在码代码的路上少翻几次车。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表