商户报:每天早上第一张单的流水号不是 1,是一个不知从哪来的大数。
第二单、第三单又全对。每晚零点之后,bug 准时复位。这种”只在边界条件出错”的缺陷最会躲人——它躲了相当久,直到一次污染名册 100 行的事故把它钉死在案。
背景与旧形制
单号有业务语义:按租户、按日期从 1 起的连续流水(会计凭证、任务编号、渠道单号序列都如此)。雪花样全局 ID 给不了”每天从 1 起”,所以用 MySQL 序列表,每租户每天一行 current_no。旧代码是一句被广泛抄写的 ODKU 形制:
INSERT INTO t (k..., current_no) VALUES (..., LAST_INSERT_ID(1))
ON DUPLICATE KEY UPDATE current_no = LAST_INSERT_ID(current_no + 1);
SELECT LAST_INSERT_ID();
看起来很聪明:一条语句完成”不存在则建、存在则自增”,LAST_INSERT_ID(expr) 把任意表达式登记进会话变量,SELECT 读回。生产跑了一阵子才暴露它的三个坑。
三个坑
坑一:首触返回自增 ID。 oa_task_sequence、sys_note_code_sequence 这类表带 AUTO_INCREMENT 主键。当天首次执行走 INSERT 分支,行上分配的自增值会覆盖会话的 LAST_INSERT_ID——当天第一号 = 行 ID 而不是 1。这就是”早上第一单流水号是错的”的直接来源。
坑二:每发一号烧一枚 auto_increment。 ODKU 每次执行都先做 INSERT 动作(含自增分配)再撞键转 UPDATE,序列值和行 ID 一起膨胀,表的自增空间被白白消耗。
坑三:跨连接读回会话脏值。 LAST_INSERT_ID 是会话级状态,而代码里 UPDATE 和 SELECT 是两次独立的 JDBC 调用——在连接池下可能落在两条连接上。读到别人的会话值,轻则 0(误触发降级路径),重则旧值——重号。污染名册 100 行那次就是它:两个并发请求拿到同一个号,各自写了一份名册。
方案推演
为什么不用 Redis INCR?我们的语义是”允许跳号、不允许重号”(取号事务先提交,业务回滚不回收号),Redis 持久化策略一旦回退对”不重号”是硬伤。为什么不上号段模式(一次取一段缓存在进程内)?租户 ≤100 店、按租户按日分组的量级,一行一行的行锁排队完全够用,号段引入的”段丢失跳号放大”和复杂度不值。MySQL 序列表本身没有错,错在用法没有钉住”同一连接”和”无自增干扰”两个前提。
实现:三段式取号
// ① 建组:种值恒 0,独立 REQUIRES_NEW 短事务,先于任何 UPDATE
"INSERT IGNORE INTO t (k1, k2, current_no) VALUES (?, ?, 0)"
// ② 取号:LAST_INSERT_ID(expr) 把新值登记进本会话
"UPDATE t SET current_no = LAST_INSERT_ID(current_no + 1) WHERE k1 = ? AND k2 = ?"
// ③ 读回:与 ② 同一 REQUIRES_NEW 事务 ⇒ 同一连接
"SELECT LAST_INSERT_ID()"
三个坑各对应一招:种值恒 0 且 INSERT IGNORE,首触 +1 后必为 1,新序列组绝不继承行 ID(坑一);建组只在行缺失时发生,进程内 ensuredKeys 标记压频,稳态零 INSERT,不再烧自增(坑二);②③ 包在同一个 REQUIRES_NEW 事务里,Spring 事务钉死同一条连接,会话值必是本事务刚写入的 current_no(坑三)。
两段都是只含一两条语句的短事务,毫秒级提交,并发取号只在该行 X 锁上排队,没有”持 S 等 X”的环状等待。还有两个细节值得抄:一是建组必须先播种后 UPDATE——顺序反了会死锁:UPDATE 未命中会留下间隙锁,并发请求再各自 INSERT 播种,插入意向锁互等成环——先播种则 INSERT IGNORE 无冲突;二是自愈路径,UPDATE 命中 0 行(序列行被运维误删)时清掉标记强制重播一次,仍取不到就抛带上下文的异常,把”序列缺失”变成可运维定位的信息。序列表整体不可用时,降级为时间戳 base36 兜底(仍保证唯一),单号不断供。
一句原则
LAST_INSERT_ID(expr) 是会话级技巧,离开”同一连接”就失效;凡是依赖会话状态的 SQL,必须用显式事务边界把连接钉死——并把”允许跳号、不允许重号”这类语义写进代码注释第一行,让后来者不必重新踩一遍。