26年七月13号也即是昨天钉钉告警群突然开始疯狂刷屏。订单同步响应时间持续上涨退款同步开始延迟业务同事也陆续反馈第三方订单迟迟没有同步到下游系统。一开始我以为只是第三方接口慢了但排查后发现真正的问题出在我们自己的线程池。原来是我们这边的thirdparty微服务里订单同步、退款单同步、订单状态同步这3个方法共用同一个线程池。当天订单状态同步的流量陡增线程池队列容量1000很快被打满工作线程全部处于忙碌状态。后续提交的退款单同步和订单同步任务开始触发拒绝策略。共享线程池 共享故障域。一个非核心业务订单状态同步的流量峰值把核心业务订单同步全部拖垮。下游系统大面积数据延迟。当时我没有任何办法赶紧重启服务虽然队列里的任务全部都丢失了好在当前写这块代码的程序员是有将请求先落地到数据库后再去执行程序的。因此我可以从数据库里找到这些记录并根据日志系统里的请求参数进行比对再次重新把数据补充推送。紧急bug肯定是必须当天处理的我立刻改程序上线杜绝这样的事情再次发生。用四层防线把这个问题彻底解决如下图第一层防线线程池隔离改造前3个方法共用1个线程池privatefinalThreadPoolUtilspoolnewThreadPoolUtils(5,20,1000,业务同步线程池);订单状态同步的流量把队列打满后其他业务全部排队或被拒绝。改造是按业务维度拆成3个独立池privatefinalThreadPoolUtilsorderSyncPoolnewThreadPoolUtils(5,20,1000,订单同步线程池);privatefinalThreadPoolUtilsrefundOrderSyncPoolnewThreadPoolUtils(3,10,1000,退款单同步线程池);privatefinalThreadPoolUtilsorderStatusSyncPoolnewThreadPoolUtils(5,15,1000,订单状态同步线程池);效果非核心业务的流量再大也不会影响核心业务。故障被隔离在单个业务线内不会扩散。不过这时候还有一个问题。如果某个池的队列也被打满了呢任务会被拒绝策略处理。第二层防线CallerRunsPolicy 结构化告警原来的线程池配置了一个自定义的拒绝策略但这个策略有个致命问题它只打日志不抛异常也不执行任务。// 原拒绝策略只log不执行任务publicvoidrejectedExecution(Runnabler,ThreadPoolExecutore){log.warn(线程池队列已满任务被拒绝);// 没有 super.rejectedExecution(r, e);// 任务直接丢弃调用方无感知}队列满时任务被静默丢弃。调用方HTTP接口或Dubbo RPC已经返回了接收成功的响应但实际上订单数据根本没有处理。数据丢了没人知道。比任务执行失败更麻烦的是调用方根本不知道它失败了。我把它换成了CallerRunsPolicy队列满时由提交任务的线程HTTP线程或Dubbo线程同步执行这个任务。任务不会被丢弃数据不会丢失。// 自定义CallerRunsPolicy先打ERROR日志再同步执行任务publicclassCustomCallerRunsPolicyextendsThreadPoolExecutor.CallerRunsPolicy{privatefinalAtomicIntegercallerRunsCountnewAtomicInteger(0);OverridepublicvoidrejectedExecution(Runnabler,ThreadPoolExecutore){intcountcallerRunsCount.incrementAndGet();// 输出结构化告警日志StructuredLog.error(log).moduleCode(THREAD_POOL).moduleName(poolName).eventCode(POOL_FULL).eventName(线程池队列已满由调用线程同步执行).put(activeThreads,e.getActiveCount()).put(queueSize,e.getQueue().size()).put(callerRunsCount,count).status(error).log();// 调用父类方法由调用线程同步执行任务super.rejectedExecution(r,e);}}队列满时这条ERROR级别的日志会立刻输出包含线程池名称、当前活跃线程数、队列大小、累计触发次数。调用方线程被阻塞去执行任务响应时间变长但数据不丢。代价是系统开始向调用方施加背压。调用线程需要自己执行任务因此接口RT会变长。但这也是一种主动限流机制系统处理不过来就让上游慢下来而不是继续接收请求导致数据丢失。我们这个场景下数据不丢比响应快更重要因为第三方SaaS系统只会推送一次。而且线程池已经做了隔离一个池的压力不会传导到其他池影响范围可控。CallerRunsPolicy保证了数据不丢结构化日志记录了告警信息。不过这时候还有一个问题。如果没人盯着日志平台告警信息就沉在海量日志里了。第三层防线钉钉独立告警群分钟级我这边为thirdparty这个模块建了一个独立的钉钉告警群专门用来接收线程池相关的告警。链路是这样的线程池队列满 → CustomCallerRunsPolicy输出ERROR级别结构化日志 → 日志平台采集 → 配置告警规则匹配POOL_FULL事件 → 钉钉机器人推送告警群。从事件发生到钉钉收到告警不超过1分钟。告警消息里包含关键信息哪个线程池满了、当前活跃线程数、队列大小、累计触发次数。开发人员收到告警后可以立即判断是需要扩容、限流、还是等流量自然回落。这样开发至少第一时间能知道出了问题。由于钉钉是有公司和名字水印的我不好直接贴出画一张示意图到这里其实正常情况下的问题已经处理得差不多了。不过还有一种情况没覆盖服务器重启。第四层防线数据补偿工具ArrayBlockingQueue是JVM内存队列不持久化。如果服务器在队列有积压时重启队列里所有未处理的任务全部丢失。CallerRunsPolicy在这种情况下也帮不上忙。上文提到过每次请求到达Controller之前拦截器会把完整的请求参数写入数据库对应的表是third_docs_record// 拦截器预持久化请求参数先落库再进入业务逻辑if(flag){ThirdDocsRecordDTOrecordThirdDocsRecordDTO.builder().businessNo(extractBusinessNo(params)).servletPath(servletPath).param(requestBody)// 完整请求JSON.nonce(nonce).build();thirdDocsRecordService.save(record);returntrue;}这张表记录了每一次请求的完整参数。基于它我们构建了一个数据补偿工具类// 数据补偿工具按时间范围 单据号列表重新推送未处理的单据publicvoidcompensate(DatestartTime,DateendTime,ListStringdocsNoList){// 1. 查询该时间范围内、且单据号在列表中的third_docs_record记录ListThirdDocsRecordPOrecordsrecordMapper.selectByTimeRangeAndDocsNos(startTime,endTime,docsNoList);// 2. 逐条检查下游系统是否已收到for(ThirdDocsRecordPOrecord:records){if(isAlreadySynced(record.getBusinessNo())){continue;// 已处理跳过}// 3. 未处理的重新调用同步方法replaySync(record.getParam());}}输入时间范围和单据号列表工具会查询该时间段内指定单据号的记录逐条检查下游系统是否已经收到。未收到的重新调用同步方法推送。幂等性由下游系统的查重逻辑保证订单同步和退款单同步在入库前都会检查单据号是否已存在重复推送不会造成数据重复。即使服务器重启导致内存队列里的任务全部丢失也可以通过补偿工具把数据重新推回去。小结后续你们也用线程池的时候也多问问自己几个问题一个业务崩了会不会拖垮其他业务队列满了数据会不会丢出问题以后我能不能第一时间知道就算真的丢了能不能补回来这四个问题也就是这次事故之后留下来的四层防线线程池隔离非核心业务的流量峰值不会影响核心业务CallerRunsPolicy队列满时任务不丢由调用线程同步执行钉钉分钟级告警故障发生1分钟内开发人员就会收到通知数据补偿工具极端情况下数据丢失当天可以通过补偿工具恢复改造后的运维体验故障发生时立刻知道钉钉告警当天可以处理完毕补偿工具兜底。