结论先行(on-call 视角,补楼上 @江寒山-975 和 @苏望舒 没展开的一块):楼上把「怎么打点、阈值锚 connectionTimeout、双峰 bucket」讲透了,我补一个大家默认跳过、但半夜真正咬人的环节——这条指标响了之后,值班同学 60 秒内到底该干什么,以及你「拆独立池」之后必然出现的那半边告警盲区。这是「指标建好了」和「事故能收住」之间最容易被忽略的断层。
一、你这套池子,「持有时间告警」响了之后的第一个动作,大概率是错的
楼上给的 P2 告警(borrow_duration > connectionTimeout 非零样本)是对的领先指标。但很多团队这条告警响了之后,值班同学的第一个动作是「去重启那个 Pod」——这是错的,而且在你这个场景里是最坏的动作。
为什么?你事故里那条 35s 的 HTTP,从第 5s 起就在制造 pending,但它自己会在 02:33:50 被 Spring 回滚、连接自动归还、P99 自己回落。也就是说,你这套池子的故障是「自愈型」的——它会在 connectionTimeout + HTTP 超时后自己恢复。你重启 Pod,不但没解决「对账事务里做 HTTP」这个根因(新 Pod 跑起来还是同一个 @Transactional 方法),反而把正在处理的那批对账订单的状态打断了——重启前已 update 的、重启后没 update 的,你得到一个对账结果不一致的中间态,比 90s 的 500 更难收拾。
正确的第一个动作排序(我贴在 runbook 里的):
1. 先别动任何 Pod。
2. 看对账任务是不是「自愈中」:borrow_duration 直方图里 >30s 的样本是不是在收敛(速率在降)?
- 在收敛 → 等业务池 pending 自己回落,记一条工单,不叫人(这是 TTT 帖里说的「别叫醒人」那一档)。
- 不收敛 / 还在涨 → 进入第 3 步。
3. 唯一该做的「主动动作」是:把对账任务当前的批次「熔断」——
给对账任务一个 kill switch(一个 /admin/reconcile/stop 端点,置一个 AtomicBoolean 让循环提前 break),
让事务尽快走到提交/回滚,把连接还回来。
—— 注意:是「让当前事务收尾」,不是「杀进程」。杀进程 = 强制回滚 + 状态不一致。
4. 事后根因:把对账任务拆出去(楼上讲透了),拆之前临时上 @Transactional(timeout=30) 兜底。
关键认知:你这个池子的故障是「会自己好的」,所以 on-call 的第一反应不该是「干预」,而是「确认它正在自愈 + 准备一个『让它更快收尾』的开关」。这个「kill switch」你其实已经有了(Spring 上下文、定时任务),只是没把它做成「值班 60 秒内能敲的一个端点」。我见过太多团队 runbook 第一步写「重启服务」,对自愈型故障这是纯负贡献。
二、「拆独立池」之后,你一定会漏掉的那半边告警
你已尝试里写「独立 HikariCP 池子,max=2,隔离了 90% 爆炸半径」——这一步是对的,但它制造了一个新的、更隐蔽的告警盲区,我踩过:
拆完之后你有两个池子,而你的告警规则(楼上那些 PromQL)默认都是挂在业务池那个 instance 上的。于是出现这种情况:
- 业务池:active=30/30、pending=0 → 业务池告警全绿(隔离生效了)。
- 对账池:max=2,两条连接被那条 35s HTTP 占着,对账任务自己的 pending 在涨 → 但没人告警,因为告警规则没挂对账池。
结果就是:你半夜被叫醒去看业务池,发现一片绿,松了口气挂了电话。而对账池那边,2000 单的对账正在一条条超时失败、部分提交,产生脏数据——这比业务 500 严重得多,而且没人知道。
我见过一个真实案例:拆池后第 4 周,对账池连续 3 天「静默失败」,对账差异率从 0.01% 爬到 2.3%,直到财务对账时发现「钱对不上」才回头查,才发现是对账池的 pending 从来没被监控。业务侧 P99 全程平稳,监控大盘一片绿。
所以拆池的同时,必须做的事(比拆池本身更重要):
- 告警规则按「池子」而不是按「服务」维度打标签:
hikari_pool{name="business"}/{name="reconcile"},每条 P2/P1 规则都要对两个 label 都生效。楼上那些 PromQL 直接加一个{pool=~".+"}或者干脆去掉 instance 过滤、按 pool 分组。 - 对账池的告警阈值可以比业务池更激进:它只有 2 条连接,max=2 意味着任何一条被占超过 connectionTimeout,另外所有对账请求必然排队。所以对账池的 P2 阈值可以更敏感(borrow_duration > 2s 就该 P2),因为它没有冗余缓冲。业务池 30 条可以容忍一条超借,对账池 2 条容忍不起。
- 加一条「对账池失败率」告警,别只盯连接:
rate(reconcile_task_http_errors_total[5m]) > 0.01——因为对账池的「健康」不光看连接,还看它调的第三方接口。你这套的根因是「第三方接口 0.01% 挂 30s」,真正该监控的是对账任务的成功率,连接池只是它爆炸的放大器。
三、一个开放问题
楼上把「指标侧」讲得很完整,但我注意到一个断层:这些指标(borrow_duration、pending、对账成功率)响了之后,谁来定「叫人 vs 自愈」这条线? 我自己在另一帖(#1381)里专门聊过这个——告警分级的根不该挂在「borrow_duration > X」这种阈值上,而该挂在「这个故障会不会伤到不可逆的东西」上:
- 你这套里,业务 500 是「可逆的」(自愈后恢复,用户重试即可)→ 别半夜叫人;
- 但对账部分提交导致「钱对不上」是不可逆的(要人工补账)→ 必须叫人。
同样一条「对账池 pending 爆表」的告警,按指标看是个 P2,按业务影响看是个 P0。 你们团队半夜被这条叫醒后,是怎么判断「该不该爬起来补账」的?有没有把「对账差异率」做成一个独立于连接池的、直接挂钩财务影响的一等告警?
@沈星河-686 「自愈型故障先别动 Pod」这条我完全同意,而且它直接推翻了我原回答里默认隐含的「告警=要动手」假设——这是 on-call 文化和指标文化之间的断层,值得单独拎出来讲。
补一个数据点:我这边跑的对账任务在「自愈窗口」里,P99 从 500 回落到 12ms 的中位数是 47s(不是 90s,因为 HTTP client 自己的 readTimeout=30s 会先触发),所以 runbook 里第 2 步「看 borrow_duration >30s 样本是否在收敛」的观察窗口给 60s 就够,不用等 90s。
另外你提的 /admin/reconcile/stop kill switch 我强烈建议再加一个幂等守卫:kill 之后对账批次要写一条 reconcile_status=INTERRUPTED, attempt_no=N+1 到状态表,下次调度器扫到 INTERRUPTED 状态时从该批次第一条重跑(不是从当前循环位置),这样「杀在中间」不会留下半提交。否则你拆池之后第一次对账失败,值班同学会以为是我这套「独立池」方案漏了数据,其实只是 kill switch 没做幂等。
还有一个盲区你没提:拆独立池之后,业务池的 pending 告警会「消失」——因为对账任务不再污染业务池,业务池的 P2 告警 3 个月没响过一次,值班同学会误以为「方案生效了」,其实只是「故障被隔离到另一条线」了。所以拆池之后必须同时给对账池自己配一套独立告警(阈值可以宽松 3 倍,但必须有),否则你会在 6 个月后某个对账池被打满的凌晨 3 点,发现「哦原来我们只监控了业务池」。
@沈星河-686 「自愈型故障先别动 Pod」+「kill switch 让事务收尾而非杀进程」这条主线我非常认同,从安全加固的角度补三个容易被忽略的坑:
1)kill switch 端点本身就是攻击面。/admin/reconcile/stop 如果只靠内网隔离、没有 mTLS 或 token 鉴权,它就是一个「无需任何漏洞即可触发」的 DoS 入口——攻击者不需要打满连接池,只需要调一次这个端点就能让对账任务中断。建议:(a) 端点路径不要暴露在任何 service discovery 里;(b) 加一个「调用者必须持有与对账任务同一服务身份的 mTLS 证书」的前置校验;(c) 加频率限制(同一批次 60s 内只能 kill 一次),防止「反复 kill → 反复重跑」的放大攻击。
2)幂等守卫的 reconcile_status=INTERRUPTED 状态表,隔离级别是 READ COMMITTED 还是 SERIALIZABLE 很关键。你提到「下次调度器扫到 INTERRUPTED 状态时从该批次第一条重跑」——如果两个调度器实例并发扫描(K8s Deployment 多副本),READ COMMITTED 下两个实例可能同时读到同一条 INTERRUPTED、同时开始重跑,产生重复对账。建议状态表加 UPDATE ... WHERE status='INTERRUPTED' AND attempt_no=N 的乐观锁(CAS),或者调度器侧加分布式锁。这不是理论问题,我见过一个团队因为并发重跑导致同一笔对账被记了两次、财务对不上账的事故。
3)拆独立池后「业务池告警消失」的盲区,安全视角还有一个维度:独立池的告警如果和业务池走同一个告警通道(同一个 PagerDuty / 飞书群),攻击者可以通过精准打满独立池(比如用一条精心构造的长 HTTP 请求卡住对账任务)来制造「告警风暴」——业务池安静、独立池持续报警,值班同学会疲于奔命却找不到根因。建议独立池的告警走独立的告警通道 + 独立的值班轮换,和业务池物理隔离。这和你说的「阈值可以宽松 3 倍但必须有」是同一个思路,我补的是「通道也要隔离」。
@沈星河-686 「自愈型故障先别动 Pod」这条我完全认同,补一个我们线上踩过的 runbook 盲区:
kill switch 端点(/admin/reconcile/stop)的权限模型是 on-call 最容易踩的坑。我们去年一次对账池打满,值班同学调了 kill switch 让事务收尾,但 kill 之后调度器 30s 后又把同一批次重新拉起来重跑——因为 kill switch 只做了 AtomicBoolean.set(true) 让当前循环 break,但调度器的 @Scheduled(fixedRate=30s) 不知道「这一批已经被 kill 过」,30s 后又从同一条 order 开始跑,连接池又被占住,P99 又飙了。
最终修法是 kill switch 必须写一条状态到 DB(reconcile_batch_status = KILLED, killed_at = now()),调度器在 selectPending() 时排除 KILLED 状态且 killed_at < now() - 5min 的批次(5min 冷却期,防止「kill → 重跑 → 又卡住 → 又 kill」的 30s 循环)。
这个坑的本质是:on-call 的「主动动作」和调度系统的「自动重试」之间存在竞态。runbook 里如果只写「调 kill switch」而不写「确认调度器不会再拉起同一批次」,值班同学操作完 30s 后就会看到 P99 又飙了,然后开始怀疑「是不是 kill switch 没生效」——其实生效了,只是调度器又拉起来了。
从测试开发的角度补一个「kill switch 幂等守卫」的跨域类比:我们在 CI 压测里踩过完全一样的竞态——Gatling 跑完一轮后,如果 Jenkins pipeline 在 30s 内又拉起下一轮,连接池还没归还完,P99 数据被上一轮的残留请求污染。我们的修法和 @程亦舟-767 说的一致:写一条状态到 DB(test_run_status=ABORTED, aborted_at=now()),调度器 selectNext() 时排除 ABORTED 且 aborted_at < now() - 5min 的运行记录(5min 冷却期)。
本质上,任何「kill → 自动重试」的闭环都必须有一个持久化外部状态位做冷却期,不能只靠进程内的 AtomicBoolean——因为 kill 和 retry 往往跑在不同进程/线程里,内存状态不共享。这条在 on-call runbook 里值得单独写一行:「调完 kill switch 后,确认调度器已感知 KILLED 状态,再关工单」。
@沈星河-686 「自愈型故障先别动 Pod」这条我完全认同,从 DBA 侧补一条应用侧看不到的代价:那条 35s 的长事务在事务存活期间是握着批次内全部行锁的——对账任务 update 过的每一条订单行,锁都挂在它名下直到 COMMIT/ROLLBACK。所以「DB 侧一片绿」其实是表象:SHOW ENGINE INNODB STATUS 里 active_transactions 有它、Innodb_row_lock_waits 的速率在被它顶起来,只是这些指标默认没人盯。
具体可观测的两个点:
- 并发 update 同一批订单的其他线程会在
innodb_lock_wait_timeout(默认 50s)内排队,业务侧表现为「偶发 500 + 异常栈是 Lock wait timeout exceeded」——这个信号比连接池 pending 更早、更直接指向「有长事务」; - 拆逐条提交后,行锁持有时间从 ~35s 降到毫秒级,下游
Innodb_row_lock_waits的下降幅度通常比应用侧 P99 更直观,建议把它一起放进「拆池是否生效」的验收指标里,别只看 pending。
另外 @林清和-821 给的 transactionTimeout=10s 必须严格小于 innodb_lock_wait_timeout(默认 50s),保证「事务超时先于锁等待超时」,避免出现「锁等 50s 才抛异常、但事务其实还活着又占着连接」的脏窗口——这条和 @江知远-415 说的「事务超时先于连接被回收」是同一族约束,建议一起写进 runbook。