不能直接用LAST_INSERT_ID()拼流水号,因其会话局限、易被干扰且无业务规则支持;应使用UPDATE原子更新+唯一索引序列表,结合日期分段和BIGINT存储,并在MyBatis中正确配置存储过程调用。

为什么不能直接用 LAST_INSERT_ID() 拼流水号
很多人第一反应是:表里有个自增 id,LPAD(LAST_INSERT_ID(), 6, '0') 不就完事了?但实际踩坑最多的就是这个。问题在于:LAST_INSERT_ID() 只在当前会话中有效,且依赖上一条 INSERT;如果中间穿插了其他语句(比如日志插入、临时表操作),或者用了连接池(不同请求复用连接),值就不可靠。更关键的是,它不带业务上下文——你没法按「每天重置」「按前缀隔离」这类规则生成。
UPDATE ... SET current_value = @next := current_value + 1 是最稳的原子写法
真正能扛并发、防重复的核心,是把读+写合并成一条 UPDATE,利用 MySQL 的行级锁和原子赋值。下面这段才是生产可用的最小可靠单元:
SET @next := 0; UPDATE sequences SET current_value = @next := current_value + 1 WHERE seq_name = 'order_no' RETURNING @next;
注意三点:
-
RETURNING在 MySQL 8.0.23+ 才支持;低版本必须拆成两步:先SELECT ... FOR UPDATE,再UPDATE,且必须在同一个事务里 -
sequences表的seq_name字段必须建唯一索引,否则WHERE条件可能锁住多行,引发死锁 - 别用
INT存大序列号——万一超 21 亿,直接溢出变负数;建议用BIGINT
带日期前缀的流水号,千万别在存储过程里查最大值
像 SELECT MAX(order_no) FROM orders WHERE order_no LIKE 'NO20260411%' 这种写法,表面看逻辑清晰,实则灾难:全表扫描 + LIKE 无法走索引 + 并发时多个会话同时读到相同最大值,必然冲突。
正确做法是把「日期段」也作为序列键的一部分:
- 建表时让
seq_name包含日期,例如'order_no_20260411' - 存储过程里用
DATE_FORMAT(NOW(), '%Y%m%d')动态拼seq_name - 首次调用自动插入默认值(
INSERT IGNORE或先SELECT再INSERT)
这样每条记录只锁自己那一行,完全规避竞争。
MyBatis 调用存储过程时,@Options(statementType = StatementType.CALLABLE) 不够
光加这个注解只是告诉 MyBatis “这是个存储过程”,但没解决参数绑定和结果获取的实际问题。常见错误包括:
- OUT 参数没声明为
mode=OUT,导致 Java 端收不到返回值 - MySQL 存储过程里用了
SELECT直接输出结果,而 MyBatis 默认只取第一个结果集,容易漏掉OUT值 - 没设
fetchSize = -1,批量调用时连接被占满
推荐写法:
@Select("{CALL generate_serial_number(#{prefix, mode=IN}, #{suffix, mode=IN}, #{result, mode=OUT, jdbcType=VARCHAR})}")
@Options(statementType = StatementType.CALLABLE, fetchSize = -1)
对应存储过程参数必须严格匹配 OUT 变量名和类型,且 Java 方法参数要用 @Param("result") 显式接收。
真正难的不是写出来,而是确认每个环节都落在「单行更新 + 显式锁 + 无隐式依赖」这条线上。任何试图绕开行锁、依赖应用层计数、或混用多种生成逻辑的地方,上线后早晚出重号。











