Skip to content

用数据库表当队列:自增 ID 的顺序不是提交顺序 ​

一个订单服务把待发送的通知写进一张 jobs 表,后台任务每隔一秒执行 SELECT * FROM jobs WHERE id > ? ORDER BY id,处理完把最大的 id 记下来作为下一次的起点。运行了几个月,偶尔有用户反映收不到通知。查表发现那些任务一直在,状态也是待处理,只是再也没有被拉到过。

用一张表做任务队列很常见:不需要引入消息中间件,任务和业务数据可以在同一个事务里写入(这正是事务性发件箱的思路)。但「按 id 递增拉取」这个看起来最自然的消费方式,隐含了一个不成立的假设:id 越小的行越早提交。自增 id 在插入时分配,提交可能晚得多;一个拿到小 id 的长事务提交时,消费者的游标早已越过了它。

本文在 MySQL 8.4.11 上用几个连接固定交错顺序,复现这个遗漏,再看按状态认领和租约的做法。实验见文末。

会话 A会话 B消费者INSERT → id=1事务还在做别的事COMMITINSERT → id=2COMMIT看到 [2]游标 = 2id > 2 → []1 永远被跳过按状态认领WHERE status='pending' … SKIP LOCKED晚提交的行提交后仍是 pending,下次被认领租约 locked_until过期可重新认领;完成时带 locked_by 条件
图 1 · 实测(MySQL 8.4.11,innodb_autoinc_lock_mode=2):A 先拿到 id=1 却晚提交,B 拿到 id=2 先提交。消费者第一次只看到 2,游标推进到 2;A 提交后,id > 2 永远不会再取到 1。按状态用 FOR UPDATE SKIP LOCKED 认领,晚提交的行提交后照样会被认领。

一、按 id 拉取:晚提交的小 id 永远被跳过 ​

text
mysql=8.4.11 innodb_autoinc_lock_mode=2 transaction_isolation=REPEATABLE-READ
A 拿到 id=1 未提交,B 拿到 id=2 已提交;消费者第一次拉取看到 ['B-晚分配早提交'],记下最大 id=2
A 提交之后,消费者按 id > 2 拉取看到 [];表里实际有 ['A-早分配晚提交', 'B-晚分配早提交'],id=1 永远不会被拉到

会话 A 先插入,拿到 id=1,但事务里还有别的事要做;会话 B 后插入,拿到 id=2,立即提交。消费者这时只能看到已提交的 B,于是把游标推进到 2。A 提交之后,id > 2 的条件把它永久排除在外。

这不是并发量很大时才会遇到的边角情况。只要写入任务的事务里还有其他操作(更新订单、调用其他服务、等待锁),分配 id 与提交之间就有时间差。MySQL 8.0 起默认的 innodb_autoinc_lock_mode=2 允许多个事务交错分配自增值,但即使在更保守的模式下,「分配顺序 ≠ 提交顺序」也依然成立,因为锁只保护分配本身,不保护到提交为止的这段时间。

另外两个相关的现象:

text
一个事务拿到 id=3 后回滚,下一条拿到 id=4:自增值不回收,id 有空洞,「连续」不能用来判断是否漏了
按 created_at 拉取:第一次看到 ['B-晚插入早提交'],A 提交后按 created_at > 上次最大值 看到 [](created_at 在插入时取值,不是提交时)

想用「id 是否连续」来检测遗漏也行不通:回滚、插入失败都会留下空洞,空洞和「还没提交」无法区分。换成插入时间做游标是同一个问题,CURRENT_TIMESTAMP 在执行插入语句时取值,不是提交时。

二、按状态认领:FOR UPDATE SKIP LOCKED ​

问题的根源是用一个单调的游标表示「处理到哪了」。换一种思路:每一行自己记录状态,消费者认领的是「所有待处理的行」,而不是「某个位置之后的行」。

sql
SELECT id FROM jobs WHERE status = 'pending' ORDER BY id LIMIT 2 FOR UPDATE SKIP LOCKED;
UPDATE jobs SET status = 'processing', locked_by = ?, locked_until = NOW(6) + INTERVAL 30 SECOND WHERE id IN (...);
text
两个消费者同时认领 2 条:w1 得到 [1, 2],w2 跳过被锁的行得到 [3, 4];另一个事务插入后未提交的 id=5,两者都没有认领到
晚提交的任务提交之后,下一次按状态认领得到 [5]:认领看的是状态,不是「上次读到哪」

FOR UPDATE 给认领到的行加锁,SKIP LOCKED 让其他消费者跳过已被锁住的行,而不是排队等待,多个消费者因此可以并行地各取各的。晚提交的 id=5 在提交之前不会被认领,提交之后仍然是 pending,下一次认领就能拿到,不会因为别人「已经走过去了」而被跳过。(status, id) 上的索引让这个查询不必扫描已完成的行。

三、租约:认领之后消费者崩溃 ​

认领只是改了状态,真正的处理在事务之外进行(发通知、调接口)。消费者在处理中途崩溃,这行就会永远停在 processing。所以认领时同时写一个租约到期时间:

text
w1 认领后崩溃、租约过期:w4 重新认领到 [1, 2];w2 的租约未过期,不会被抢走
w1 恢复后想把 id=1 标记完成(带 locked_by 条件):影响 0 行;w4 标记完成:影响 1 行

租约过期的任务可以被重新认领。这带来一个必然的结果:同一个任务可能被执行两次。w1 也许只是卡住了,并没有崩溃,它恢复后会继续做完。因此:

  • 任务本身要幂等,例如通知按任务 id 去重,下游接口带幂等键。
  • 标记完成时带上 locked_by 条件,旧的持有者不能覆盖新持有者的状态。这和分布式锁的 fencing token 是同一个思路:持有权过期之后,旧持有者的写入要能被识别并拒绝。
  • 租约时长要大于正常处理时间,处理时间长的任务可以中途续租。

四、如果一定要按顺序消费 ​

按状态认领不保证顺序:w1 和 w2 并行处理,id=3 可能比 id=1 先完成。如果业务确实需要按顺序,例如同一个订单的状态变化,常见的做法是:

  • 按业务键分区:同一个订单的任务只由一个消费者处理,不同订单之间并行。
  • 读取提交顺序:从 binlog 读取变更(CDC),binlog 里的事件按提交顺序排列,这正是「按 id 拉取」想要却得不到的顺序。
  • 只在单写者场景用游标:如果任务只由一个串行的写入者在自己的事务里写入,分配顺序等于提交顺序,游标才是安全的。

任务量大、消费者多、需要回放和分区时,就该考虑消息队列了。表做队列的优势是和业务写入在同一个事务里,这一点可以通过发件箱表加 CDC 保留下来。

五、检查清单 ​

问题做法
消费者怎么知道哪些行要处理行上的状态,而不是「大于上次最大 id 或时间」
多个消费者并行FOR UPDATE SKIP LOCKED 认领,认领与改状态在同一个事务里
消费者崩溃认领时写租约到期时间,过期后可重新认领
重复执行任务幂等;完成时带 locked_by 条件
需要顺序按业务键分区,或从 binlog 读取提交顺序
检测遗漏监控长时间 pending 的行,不依赖 id 连续

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权