如何通过MySQL存储过程高效实现基于原子操作的唯一序列号长尾词生成?
- 内容介绍
- 文章标签
- 相关推荐
本文共计750个文字,预计阅读时间需要3分钟。
很多人第一次回应是:
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") 显式接收。
真正难的不是写出来,而是确认每个环节都落在「单行更新 + 显式锁 + 无隐式依赖」这条线上。任何试图绕开行锁、依赖应用层计数、或混用多种生成逻辑的地方,上线后早晚出重号。
本文共计750个文字,预计阅读时间需要3分钟。
很多人第一次回应是:
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") 显式接收。
真正难的不是写出来,而是确认每个环节都落在「单行更新 + 显式锁 + 无隐式依赖」这条线上。任何试图绕开行锁、依赖应用层计数、或混用多种生成逻辑的地方,上线后早晚出重号。

