
电脑右下角的监控告警弹窗在下午三点十七分准时出现。堆内存使用率从45%一路爬到92%Full GC从每天两三次变成十分钟一次。我盯着屏幕看了几秒脑子里冒出的第一个念头是又来了。做开发这些年这类事情说不上多新鲜但每次排查完复盘总能从里面翻出一点之前没想明白的东西。这篇开发随记没有完整的专题主线更像是我工作间隙随手记下的几段经历一次内存排查的过程、一次代码评审室的争论、一个死锁告警的根因、一场从加个字段开始的需求连锁反应以及一次被日志逼疯之后的改造。写下来的目的很简单——这些坑不一定人人会踩但踩过的人应该都能从我这段记录里看到点自己的影子。1. 定时任务把内存撑爆了一次Full GC排查全程1.1 告警先于直觉到达先说背景。当时线上跑着一个数据迁移工具负责把旧系统的订单数据同步到新系统每两小时触发一次。前两轮执行都很安静监控曲线平坦得让人放心结果第三轮刚开始没几分钟告警群里就有人喊服务JVM堆内存飙升Full GC频繁接口响应时间已经出现明显毛刺。我先看了下GC日志老年代占用曲线几乎是垂直拉升。这个服务平时很轻堆配置也就两个G按理说一次数据同步不至于吃成这样。直觉告诉我问题大概率出在任务自身的处理逻辑上——不是SQL跑得慢而是内存里攒了太多不该攒的东西。1.2 快照定位问题不在SQL性能而在内存组装排问题我喜欢直接抓现场。用jmap拉了一份堆快照丢进MAT里看Dominator Tree排在最前面的对象引用路径很快指向了那个迁移任务的内部结构。一个ArrayList里有几十万个对象实例往下追踪来源发现是从订单明细表一次性查出来的。看了代码问题就清楚了。整个同步逻辑分三步分页查询订单列表每页1000条但分页用的是limit offset,size这种写法offset随着页数递增越来越深每查出一页订单程序会把这一页订单涉及的全部明细一次性查出来拼成一个巨大的对象列表所有明细在内存里组装完成后才统一做批量写入。这套流程在数据量小的时候跑得很顺但订单数据是持续增长的。等到第三次执行的时候单页订单关联的明细条数已经远远超出预期全量捞出来再统一写入对象一直被强引用持有GC怎么回收都腾不出空间。环节改造前改造后订单列表查询limit 0,1000逐页深翻基于lastId游标每批取500条明细组装一页订单的全部明细一次性捞出每批订单单独查明细处理完立即释放写库方式全量对象入内存后批量insert每批处理完直接提交列表清空1.3 为什么前两次执行没爆第三次才爆这个问题当时也让我困惑了一阵。前两次执行的数据量明明差不多为什么偏偏第三轮出问题后来想明白了问题不在单次执行而在执行之间的叠加。第一次跑的时候生成的对象大多在年轻代里任务结束就被回收了老年代没什么压力第二次跑的时候有一部分对象因为存活时间稍长已经晋升到了老年代占了一小块位置到第三次执行老年代里的残留和本次新晋升的对象叠在一起直接突破阈值触发了连续Full GC。内存问题很多时候都不是一瞬间涨上来的而是几次运行残留叠加的结果。如果只盯着某一次执行看很容易误判。1.4 修复方案与事后改进修复本身不复杂核心就两件事把深分页改成游标模式把全量组装再写库改成分批处理即释放。改造后的核心逻辑大概长这样long lastId 0L; int batchSize 500; while (true) { // 游标式查询每次只取id大于lastId的一批 ListOrder orders orderMapper.findOrdersAfter(lastId, batchSize); if (orders.isEmpty()) { break; } // 每批订单单独查明细处理完就释放 for (Order order : orders) { ListOrderDetail details detailMapper.findByOrderId(order.getId()); processAndWrite(order, details); } // 移动游标 lastId orders.get(orders.size() - 1).getId(); }顺手还做了一件事在任务表里加了一个水位线字段记录本次同步到的最大orderId。这样即使任务中途挂了重启后也能从断点继续不需要从头扫一遍既省内存又省时间。提示迁移类任务尽量给它们独立的JVM参数别和核心业务服务混在一起。我这次排查完之后给迁移服务单独设了-Xmx1g -Xms512m并加了堆内存告警阈值早发现总比爆了再救要轻松。2. 代码评审室里被问住的一次重启之后你怎么办2.1 我当时的方案线程池加内存队列看起来很合理那是另一个项目做一个批量导入功能。用户会上传一批文件系统需要扫描目录、解析文件内容、逐行写入数据库。我当时的方案设计得挺顺一个定时任务扫描新文件把文件路径丢进ArrayBlockingQueue后面挂着20个线程的消费线程池线程从队列里拿文件路径解析Excel每攒够2000条记录批量insert一次。当时觉得这个设计挺好看。队列平滑削峰线程池控制并发批量插入保证吞吐正常路径下数据能稳定入库。评审会上大家也没提出什么大问题直到我被问到了一句服务重启的时候队列里还没处理完的文件怎么办2.2 这句问题的杀伤力在于重启我愣了一下。队列是内存里的文件路径只要进了队列又没有消费完服务一重启这些路径就全部消失了。文件本身还在磁盘上但没人知道它该被处理。更麻烦的是扫描任务是按文件前缀和文件名去识别是否处理过的重启之后如果重新扫描同一批文件可能被重复读取。我当时只考虑了正常流程跑得顺不顺没考虑流程中断之后现场长什么样。系统设计里最贵的往往不是正常路径的优化而是异常路径上的可追溯、可恢复能力。队列越深、积压越多重启丢的数据就越多而且这个问题在开发环境根本测不出来必须放到批量文件堆积线上重启这种组合场景下才暴露。2.3 最终改成任务表加状态机那次评审之后我把方案改了一版核心是引入一张文件任务表把内存队列改成数据库任务队列文件路径作为唯一标识入库时状态记为PENDING消费线程从库里捞PENDING记录处理前更新为PROCESSING处理成功更新为SUCCESS失败记为FAILED并记录错误信息启动时扫描凡是PENDING的记录直接重新入队重新处理PROCESSING但超过10分钟没有心跳更新的自动重置为PENDING。这样不只解决了重启丢任务的问题还白捡了一个好处同事排查问题的时候直接查库就能看到每个文件目前卡在哪个阶段不用再翻日志猜进度。状态机跑起来之后这个导入功能的线上问题定位时间明显缩短了。经验只有一句话多想想异常时现场是什么样比多想想正常时性能能多高更值钱。3. 一个死锁告警背后两个服务把加锁顺序写反了3.1 告警文本和第一直觉有段时间线上隔三差五收到一条MySQL死锁告警错误信息很标准Deadlock found when trying to get lock。第一次看到这种报错的人可能会觉得是数据库抽风但做开发的都懂死锁十有八九是代码层面的锁顺序问题。那段时间涉及订单的服务有两个一个是订单管理服务负责订单的创建和更新另一个是结算服务负责订单的金额汇总和入账。两边处理的是同一批订单数据。3.2 根因同一批数据两个微服务各自处理看死锁日志两个事务的锁等待关系一目了然。事务A持有订单主表的行锁正在等待子表的锁事务B持有子表的行锁正在等待订单主表的锁。两边各攥着一把钥匙同时等着对方手里的另一把钥匙谁都不松手死锁就这么产生了。深入代码一看原因很简单。订单管理服务在一个事务里先update订单主表更新状态然后循环处理子表逐条update明细结算服务正好反过来事务开始先汇总子表批量更新明细数据最后再去更新订单主表的结算状态。两个服务处理同一批订单时加锁顺序完全相反。这种问题为什么难排查因为两个服务各自跑一遍单元测试都不会出问题接口调一次两次也正常只有两边恰好同时处理同一批订单时锁等待才会形成环。耦合不在代码层面而在数据层面。3.3 解决统一锁顺序和超时时间解决方案没有太花哨的三件事统一加锁顺序约定所有涉及订单主表和明细表的更新一律先主表后子表所有服务遵守同一个顺序。这是最根本的修复。缩短锁持有时间把锁范围内不需要的远程调用挪到事务外面事务体尽量只做数据库操作。配置合理的锁等待超时通过连接串参数把lock_wait_timeout调小一些比如5秒。如果真发生极端锁竞争让事务快速失败并回滚而不是长时间挂起拖垮整条链路。这次之后我还定了一个规矩凡是涉及多个表的写操作在设计阶段就把事务里操作表的顺序画出来评审的时候一眼就能看到有没有交叉风险。别等死锁日志出来了再去猜。4. 产品口中的加个字段从来不是加个字段4.1 需求原文和改动清单产品发来一条需求原话我到现在还记得很简单的需求用户列表页面加一个字段展示会员等级。我看着也觉得简单用户表加一列查询的时候带出来前端表格多一列。排期报了两天结果做下去才发现这个加个字段牵出来的改动远不是表面那些。真实情况是这样一堆事数据库用户表加member_level列存量数据还得写回刷脚本几百万用户不能手工改后端代码实体类加属性Mapper映射更新查询接口DTO加字段多一个枚举类缓存用户信息服务接口做了缓存缓存key得加版本号不然旧缓存数据不带这个字段线上看到的还是空值导出功能Excel模板要加一列导出工具类的列顺序同步调整否则导出会错位权限控制会员等级信息不是所有人都能看特定角色才可见又牵出接口层的字段过滤报表统计报表中心要按会员等级分组统计背后的聚合查询得新写测试回归涉及列名变更和展示逻辑至少用户查询、导出、权限三条链路都要回归一遍。4.2 我的教训先列清单再报排期两天工时后来实际花了四天其中一天还是因为缓存版本问题在线上排查了一个多小时。问题不在需求本身在我一开始的评估方式。我只看到了加字段这个动作没看到它顺着代码链路向后传导的涟漪。现在接到类似需求我养成了一个习惯先花十几分钟把所有改动点列成一张清单从数据库到接口到缓存到前端再到导出和报表凡是能想到的全写下来再拿这张清单跟产品对齐工作量和风险。表面上这十几分钟浪费了实际上往往能帮双方省下一天以上的返工时间。5. 日志系统改造没有traceId时我花了两个小时找一次超时5.1 一次让人崩溃的排查有一次线上反馈订单查询接口偶发超时我接手排查。接口链路不长也就三个服务按理说很快能找到问题但现实是日志里没有统一的链路标识我只能拿着userId和时间段在应用日志里手工搜索请求参数再顺着参数跳到下一个服务继续搜运气好搜到了就往下走运气不好同一时间段日志量一大整个人就陷进去了。那次从下午两点查到四点最终还是靠加日志重新触发了一轮请求才定位到问题——第三方接口偶发超时。两个小时就这么没了而解决这个偶发超时本身只花了几分钟。5.2 改造方案入口生成traceIdMDC贯穿链路那个下午之后我推动了日志链路改造。方案不复杂关键就三步在网关或者服务入口拦截器里生成traceId放进MDC日志pattern统一输出这个标识HTTP调用通过Feign请求头透传到下游异步线程池处理任务时通过TaskDecorator把上下文传递进去。核心代码量很小大概长这样// 入口拦截器 public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String traceId request.getHeader(X-Trace-Id); if (traceId null || traceId.isEmpty()) { traceId UUID.randomUUID().toString().replace(-, ); } MDC.put(traceId, traceId); return true; } Override public void afterCompletion(...) { MDC.remove(traceId); }改完之后再遇到线上问题只需要拿traceId在日志平台里一搜整条调用链上的日志全串起来了。排查耗时直接从小时级降到分钟级。5.3 顺手做的日志治理三个清单链路打通之后我又顺手做了一轮日志治理核心是理清三类问题该打的日志对慢请求、异常分支、关键状态流转必须打日志而且要有足够的上下文参数不该打的日志循环体内的debug打点、无意义的状态打印一律清掉。日志不是越多越好日志量大了反而会掩盖真正有用的信息必须脱敏的日志身份证号、手机号、支付信息、密钥凭证一律脱敏或直接不落盘。日志也是数据一样要当数据管起来。做完这三件事日志平台的压力小了排查效率反而高了。有些团队光顾着吐槽日志平台搜索慢没想过可能是自己往里扔了太多垃圾。6. 收个尾开发随记里最常写的那几件事这篇随记写到这里差不多该收了。回头看看这几段经历多少能摸出点共性。内存爆掉的迁移任务死在数据量越积越大这个变量上线程池队列版本的导入方案死在重启这个变量上死锁问题死在多服务并发处理同一批数据上加个字段的需求死在连锁反应被低估上。做开发这些年我发现真正在线上惹祸的往往不是某个复杂的设计而是一些我们在正常路径上根本不会去想的变量。最后分享一个我自己的小习惯。每次上线完我会在工程的docs目录下留一份简短的changelog记录这次改动涉及的表、脚本、接口和配置项。下次这个模块一旦出问题先翻这份文档再去看代码通常能直接跳过一大段盲猜的过程。平时花五分钟写下的东西排查的时候能省回五十分钟。