我的 Agent 平台在并发测试里出了个怪现象:审批接口越来越慢,最后整个服务的数据库连接池被拖垮。排查到最后,凶手是一个被写进事务里的 HTTP 调用——它的读超时是 120 秒,而这 120 秒里,三张表上行锁全部被它一个人占着。这篇文章把这次事故完整复盘一遍:现象、勘查、归因、修法,以及拆完事务之后冒出来的那一串新问题。
1. 背景:这个事务在干什么
先交代场景。我在做一个多租户 AI Agent 平台,架构是双语言分层:Java(Spring Boot + MySQL)独占所有写路径——事务、多租户、计费账本这些“钱的路径”;Python(FastAPI)独占检索和 Agent 执行这条“智能的路径”。MySQL 只有 Java 能碰,Python 对库只读。
这次出事的是工具审批链路。Agent 要执行敏感工具(比如写文件、发请求)之前,必须先走一遍人工审批:
- Agent(Python 侧)发起工具调用,平台拦截,生成一条待审批记录;
- 管理员在界面上点“批准”;
- 平台把审批结果落库,然后真正执行那个工具。
审批的落库不是一个表的事,而是三张表必须一起前进:agent_approval(审批单)、agent_task(任务)、agent_run(执行)。为什么必须同事务?因为分三步写、写完第一步崩,就会留下“人已经批了,但任务还卡在等待审批”的崩溃中间态——恢复后不知道该听谁的。所以三表迁移放在同一个事务里:要么一起前进,要么一起不动。这个设计本身是对的。
问题出在我一开始的写法上。当时的代码,直觉非常自然:“审批通过 → 落库 → 通知执行”,既然要原子,那就都放进事务里:
@Transactional
public void decideApproval(Long approvalId, boolean approve) {
approvalMapper.updateStatus(approvalId, APPROVED); // agent_approval 加行锁
taskMapper.transitionTask(taskId, RUNNING); // agent_task 加行锁
runMapper.updateStatus(runId, RUNNING); // agent_run 加行锁
toolClient.executeWithApproval(payload); // HTTP 调 Python 执行工具 ← 凶手
}
“要么都成功,要么都失败”——听起来很原子,很安全。直到并发测试把它打回原形。
2. 现象:连接池是怎么死的
并发压审批接口,两个现象同时出现:
- 审批接口响应越来越慢,最后整批超时;
SHOW PROCESSLIST一看,一堆查询堵在等待上,部分连接报Lock wait timeout exceeded。
顺着查:用 sys.innodb_lock_waits 找堵住别人的事务,看到一个 120 秒前开启、到现在还没提交的长事务。再看被堵连接的报错——Lock wait timeout exceeded,等锁等满了超时。
这里先做一个重要的定性:这是行锁等待,不是死锁。两者在 MySQL 里是完全不同的故障:死锁是 InnoDB 立即检测到“互相等待”的环,当场杀掉一方、立即报 Deadlock found;行锁等待是有人持锁不放,等待方一直阻塞,等满 innodb_lock_wait_timeout(默认 50 秒)才报错。
排查手法也不同:
- 慢查询是“一条查询自己慢,EXPLAIN 能看到烂执行计划,但没有等待者”;
- 行锁事故的指纹是“一群查询同时慢 + 一个长事务”——一群人等一个人。
3. 归因:两个时钟
找到长事务之后,读代码就快了:事务里夹着那次 toolClient.executeWithApproval()——一个跨服务的 HTTP 调用,Python 侧去执行工具(背后可能还有 LLM 调用),它的读超时是 120 秒。
于是这里出现了两个时钟,分清它们是理解这次事故的关键:
- 持锁方的时钟:工具执行的读超时,120 秒。只要这个调用不返回,事务就不提交,锁就不放。
- 等待方的时钟:InnoDB 行锁等待超时,默认 50 秒。被堵的查询等满 50 秒就报
Lock wait timeout放弃。
所以真实图景是:一个事务持有三张表的行锁,等一个最长 120 秒才超时的远程调用;所有并发的审批、过审批期清理(expireApprovals)、任务取消(cancelTask)——它们全都要碰这三张表——在这 50~120 秒里全部排队。队列越长,连接占用越久,连接池被拖垮,整个服务跟着陪葬。
一句话归因:事务的锁租期,等于事务里最慢那一步的耗时。我在事务里放了一个 120 秒的外部调用,就等于把三行数据的行锁,租给了一个不受我控制的远程系统 120 秒。
“放进事务里图个原子”这个直觉错在哪?错在搞错了事务的管辖范围。事务保证的是数据库状态的原子切换,它的实现机制是“持锁直到提交”。数据库能帮你回滚的,只有它自己管理的数据——一次 HTTP 调用的副作用,数据库根本回滚不了。你以为买到了原子性,实际买到的是 120 秒的全局串行,外加锁等待雪崩的风险。
4. 修法(一):把慢调用移出事务
第一刀很直接:事务瘦身。事务里只留毫秒级的数据库状态迁移,工具执行挪到事务提交之后——Spring 里用 TransactionSynchronizationManager 注册 afterCommit 回调:
@Transactional
public void decideApproval(Long approvalId, boolean approve) {
// 事务内:全部是毫秒级 DB 操作
taskMapper.transitionTaskGuarded(taskId, RUNNING);
runMapper.updateStatus(runId, RUNNING);
// ...三表状态迁移,见下一节
}
// 事务提交、锁放掉之后,才碰外部世界
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
toolClient.executeWithApproval(payload);
}
});
改完这一刀,锁持有时间从 120 秒回到毫秒级,连接池立刻不堵了。但如果文章停在这里,那只是修了一个症状——把慢调用移出事务的那一刻,一个新问题就诞生了:
批准了,但还没执行——中间这段时间,谁来保证“恰好执行一次”?
原来的写法虽然性能灾难,但它在语义上是“连续”的:落库和执行挤在一起,不存在中间态。拆开之后,“已批准未执行”成了一个合法存在的状态区间,于是必须回答四个新问题。
5. 修法(二):拆开之后,原子性去哪了
5.1 并发双批:守卫 UPDATE
两个管理员同时点批准怎么办?答案是把判断塞进 UPDATE 的 WHERE 里,用影响行数当裁决:
UPDATE agent_approval
SET status = 'approved'
WHERE id = #{approvalId}
AND status = 'pending';
两个并发请求,只有一方能让影响行数等于 1,另一方拿到 0 行、直接让位。为什么不能“先 SELECT 看一眼是不是 pending,再 UPDATE”?因为查和改之间有窗口——查的时候是 pending,改之前别人已经改成 approved 了,你是基于过期信息做的判断(TOCTOU)。而 WHERE status='pending' 是当前读:在行锁保护之内,对最新版本原子地完成“判断 + 修改”。
5.2 一次性执行令牌:exactly-once 的锚
光有审批守卫还不够,还要保证工具执行只发生一次。方案是给每次批准签发一张一次性的执行令牌,三态:none → issued → consumed。
- 签发,和审批落库在同一个事务里,同样带守卫——只有第一个赢得审批的请求能签发:
UPDATE agent_approval
SET execution_token = #{uuid}, execution_token_status = 'issued'
WHERE approval_id = #{approvalId}
AND status = 'approved'
AND execution_token_status = 'none';
- 消费,在 Python 侧执行工具之前,fail-closed 地回调 Java:
UPDATE agent_approval
SET execution_token_status = 'consumed'
WHERE approval_id = #{approvalId}
AND execution_token = #{token}
AND execution_token_status = 'issued';
-- 影响行数 = 1 才放行执行;= 0 一律拒绝
这套设计的口诀是:批准了不等于跑了,跑之前再验一次码。重放、重复执行、输家抢跑,全被 WHERE status='issued' 这道守卫拦下——影响行数不为 1,就不执行。
有个自然会问的问题:令牌为什么不放 Redis,SETNX 不是更快?因为单一事实源。审批是三表同事务,令牌如果放 Redis,就存在两个真相源:进程崩在“MySQL 已提交、Redis 还没写”之间,审批通过了、令牌永远签发不出来,任务卡死。把令牌和状态放同一行、同一事务,凭证即状态,不存在两边打架。SETNX 的快,解决不了跨系统的原子性——毫秒级的错,比秒级的对更贵。
5.3 afterCommit 里调用失败了怎么办
工具执行挪到事务外,那它失败的时候,数据库里是什么状态?答案是:干净的。三表在提交那一刻已经自洽(approval=approved、token=issued、task=running),Python 侧执行失败碰不到这三行。失败走补偿链:把 run 收敛到 failed 终态、结算用量、审批记录里留下失败原因,可以重试。
5.4 afterCommit 之后进程崩了呢
更极端一点:事务刚提交,进程就死了,工具永远没执行——数据不一致吗?不一致,但不是矛盾,是未完成。库里留下的是“干净的非终态”:批了、令牌发了、任务在跑,只是没人收尾。兜底是超时收敛:扫描任务发现长期停在非终态,用守卫迁移推到 timed_out 并结算;已消费未执行的令牌随过期作废,而 consumed 状态正好防住重放执行。
这一整套下来的效果:最坏情况是“批了没执行”(可重试),绝不会“批一次、执行两次” 。像快递:订单成立、快递失败可以重新约,签收码一次有效——最坏是没送到,绝不会送两次。
6. 追问:为什么不加分布式锁、上消息队列
修完后我自问过,也被别人问过:要不要更进一步,加分布式锁?上 MQ?
不加锁:事故的根因是锁持有时间,不是缺锁。事务瘦身后,互斥窗口只剩毫秒级,守卫 UPDATE 本身天然幂等——再叠一把分布式锁,是给已经解决的问题增加新的故障面(锁服务本身要保活、要续期、要处理过期后业务没跑完)。
不上 MQ:审批是低频操作,事务一致性优先;而且引入 MQ 意味着凭空多出一类“结果未知”的状态要管理——为了消灭一个 120 秒的等待,引入一个新的一致性问题,不划算。
原则就一句:先修根因,机制只买需要的。
7. 结果与复盘
修完之后:持有时间 120 秒 → 毫秒级;重复审批天然幂等;并发场景补了回归测试,专门锁定“输家不结算”——在状态机的设计里,只有赢得终态迁移的一方才有资格做结算。这次事故最后归宿在代码注释里:三表事务的入口处写着一行——工具执行读超时 120 秒,绝不能留在事务内。
四条带走的结论:
- 事务边界就是锁的租期。 事务的耗时不由你写了多少 SQL 决定,由最慢的那一步决定。
- 外部调用永远不进事务。 数据库的原子性只覆盖它自己管理的数据;HTTP 调用放进事务,等于把锁租给一个不受你控制的系统。
- 拆事务不是丢掉原子性,是重新分配原子性。 数据库状态的切换,靠三表同事务保证“一起前进或一起不动”;跨系统的副作用,靠一次性令牌 + 守卫 UPDATE 保证“恰好一次”。两段原子性,各自管各自的地盘。
- 并发故障先定性再动手。 死锁(InnoDB 立即杀一方)、行锁等待(等满超时)、慢查询(没有等待者)是三种病,勘查指纹分别是“立即报 Deadlock”、“一群查询同时慢 + 一个长事务”、“EXPLAIN 烂但没人排队”。
这次事故留给我的,还有一个比修法本身更通用的东西:“把判断塞进原子操作”——数据库里是守卫 UPDATE 的 WHERE,文件系统里是破坏性操作前二次 realpath,Redis 里是 Lua 脚本。从那以后,它成了我处理一切并发判断的默认直觉。