一个 watermark += 1,打满了我们的数据库连接池
August 8, 2026
TL;DR:每个请求都在事务里执行
UPDATE project_event SET watermark = watermark + 1,对同一行加了行锁,而锁要持有到事务提交才释放——事务里偏偏还包着一次调用生图服务的 RPC,虽然生成是异步的,但"提交任务"这个动作本身要 1~2 秒。于是所有并发请求在同一行上串行排队,每个排队的请求都占着一个连接池名额不放,池子瞬间耗尽。换成雪花 ID 在应用层发号后,事务和热点行一起消失,问题解决。
技术栈:Next.js + Prisma + MySQL(InnoDB)。
事故现场
我们是一个生图/生视频应用。某天测试生成接口时发现:并发连 10 都撑不住——前 5 个请求正常,从第 6 个开始陆续超时失败,稳定复现。报错是连接池耗尽(Prisma 的经典报错 Timed out fetching a new connection from the connection pool)。
当时的反应是:怎么可能,10 个并发就把池子占满了?我将信将疑地把最大连接数调到 10 再压——这次并发 20,从第 11 个开始稳定失败。
扩大连接池测试失败边界
两次测试摆在一起:
- 池大小 5 → 第 6 个开始失败
- 池大小 10 → 第 11 个开始失败
失败边界 = 连接池大小 + 1。说明瓶颈在"获取数据库连接"这个环节。
什么是行锁
排查后定位到接口里的一个事务,其中有这样一步:
UPDATE project_event SET watermark = watermark + 1 WHERE project_id = ?;
UPDATE 是"读—改—写"。如果不加约束,两个事务同时读到 watermark = 5,各自 +1 都写回 6——加了两次只涨了 1,两个事件拿到同一个号(丢失更新)。为了防这种事,InnoDB 规定:事务修改某一行,必须先给这一行上行锁,其他要写这行的事务排队等待。
行锁两个关键属性:
- 粒度是一行:改 A 项目和改 B 项目互不干扰,只有大量请求打同一行(热点行)时才排队;
- 锁持有到事务结束:整个事务
COMMIT或ROLLBACK才释放。
为什么行锁会拖垮连接池
单看这条 UPDATE,锁只持有几毫秒,完全无害。问题出在事务的内容上:
await prisma.$transaction(async (tx) => {
await tx.projectEvent.update({ ... }); // watermark + 1,此刻拿到行锁
await tx.generationTask.create({ ... });
await submitGenerationTask({ ... }); // 调生图服务提交任务:1~2 秒
// 生成是异步的,不等结果——但提交这个 RPC 本身要 1~2 秒
});
我们的生图流程是"提交任务 + 异步等结果",所以直觉上觉得"这个接口很快"。但异步的是生成,不是事务:行锁从 UPDATE 执行那一刻拿到,要等事务提交才还,而事务里抱着锁干等了那个 1~2 秒的提交 RPC。从业务看 1~2 秒很快;从数据库看,正常事务持锁是毫秒级,这是正常值的几百倍。
并发场景变成这样:
请求A: |--拿锁--[ 提交RPC 2s ]--提交--|
请求B: |------- 等锁 -------|--拿锁--...
请求C: |------------ 等锁 ------------|--拿锁--...
而最容易被忽略的一环是:排队等锁的事务,连接是不还的。每个请求先占一个连接、BEGIN、发起 UPDATE,然后堵在行锁上,连接一直被攥着。10 个请求占满 10 个连接,第 11 个连数据库都摸不到,等够 Prisma 事务的 maxWait(默认 2 秒)就报超时。这就是"失败边界 = 池大小 + 1"的完整解释。
watermark 是干嘛的
project_event.watermark 本质是项目内的事件发号器:每产生一个事件就 +1,给事件分配一个连续、唯一、有序的序号(用来拼事件 ID、保证下游按序处理)。它用"UPDATE 计数行 + 行锁"保证并发下不发出重号,代价是同一项目的所有请求在发号这一步被强制串行——项目接口的吞吐上限被限在 1 ÷ 事务持锁时间,与机器配置、连接池大小无关。
为什么换雪花 ID 就好了
一定要靠数据库发号吗?不一定。雪花 ID在应用内存里用「毫秒时间戳 + 机器位 + 序列号」拼出 ID,天然全局唯一、大致按时间有序,发号没有任何共享状态,零竞争。
watermark 和雪花 ID 解决的是同一个问题——"发一个唯一且有序的号",只是一个在数据库里发(要行锁),一个在应用层发(不要)。换掉之后,不再需要为拿号去 UPDATE 那行数据,事务连同行锁被整个拿掉,连接占用从秒级降到毫秒级,池子自然再也不满。
要交代清楚的边界:雪花 ID 保证唯一和大致有序,但不保证严格连续(时钟回拨、多机时钟漂移)。我们的场景只要"唯一 + 能排序",所以换得对。如果业务依赖严格连续无空洞的序号(对账),或者并发根本不高,其实 MySQL 的 AUTO_INCREMENT 就够了;另外也有一条更小的修复路径——保留 watermark,但把事务收窄到只做发号 + insert,提交 RPC 挪出事务,同样能活。
复现实验
为了验证这套推理,我搭了个最小复现环境(Prisma connection_limit=10,40 并发打同一项目;复现用的是 PostgreSQL,行锁语义与 InnoDB 一致):
表格
| 版本 | 成功 | 吞吐 | 峰值等锁连接 | | :------------------ | :------ | :---------- | :---------- | | 问题版(事务持锁 ~1.5s) | 11/40 | 0.7 req/s | 9 | | 快提交版(事务持锁 ~100ms) | 28/40 | 9.0 req/s | 9 | | 快提交版 × 200 并发 | 29/200 | 9.1 req/s | 9 | | 雪花 ID 版(无事务) | 40/40 | 23.5 req/s | 0 |
两个要点:一是问题版的数据和我们生产现场几乎一致(吞吐被钉死在 1 ÷ 持锁时间);二是即使事务很快也一样有天花板——100ms 持锁,吞吐上限就是约 10 req/s,200 并发打进去成功率照样崩。行锁串行决定天花板,事务里的慢操作只是决定天花板有多低。
写在最后
复盘下来,有两层教训:
技术上,真正的根因是事务边界,不是那张表。 哪怕保留 watermark 发号,只要把事务收窄到只包含数据库操作,并发也能正常跑;把 RPC 放进事务,才是把行锁从"毫秒级的正常同步"变成"秒级系统瓶颈"的那只手。
工程上,这套"计数表发号 + 长事务"的设计是 AI 给的,我们没审就用了。 在没有搞清楚"为什么需要这张表、这个 UPDATE 并发下意味着什么"之前全盘接受方案赶工,问题就会埋在系统各处,直到压测那天才炸出来。AI 不能代替你做工程决策,我们也不应该外包思考。
