MySQL 和 SQL 本身能不能直接生成雪花算法 ID?这个问题我最近刚好在一套老系统的数据迁移脚本里踩了一遍坑,结论是:可以,但网上一大半的“纯 SQL 实现雪花算法”帖子要么只给你 Java 代码,要么贴一段看着没问题、实际并发一高就翻车的 SQL。原因很简单,雪花算法虽然是个位运算问题,但在 MySQL 里实现时,难点从来不是移位和或运算,而是状态存储、时间精度、并发控制这三件事。这篇文章把我最终验证可用的存储过程方案、批量取号方案以及中间踩过的坑完整记录下来,给需要在 MySQL 侧直接生成分布式 ID 的场景做个参考。如果你只是把 MySQL 当个发号数据库,不想为了临时任务引入额外 SDK,这篇文章应该能直接帮你省下半天时间。
1. 为什么我会放着应用层框架不用,非要在 MySQL 里硬算
1.1 一次历史数据迁移逼出来的需求
当时的情况是这样的:一套老业务系统的主键一直是自增 int,跑了六七年之后,数据量上来,单表扛不住,需要拆表。拆表之前,所有历史数据的主键必须重新映射成全局不重复的长整型 ID,不能继续用自增,因为不同分表之间自增会撞。应用层那套新系统已经准备用雪花算法生成后续 ID,可历史数据清洗脚本跑在一台只装了 MySQL 的跳板机上,运维又不希望我为了一次性任务去部署 ZooKeeper、Redis 或者引入发号服务组件。
所以最省事的方式就是:直接在 MySQL 内部写一段 SQL/存储过程,让 MySQL 按雪花算法格式返回一批 ID,然后清洗脚本拿到这批 ID 去更新老数据。听起来很朴素,真正动手后才发现纯 SQL 实现和 Java 版雪花算法的思路差异非常大。
1.2 雪花 ID 的位结构决定了它的计算方式不是难点
我们先回顾一下标准雪花 ID 的 64 位布局:
- 第 1 位,符号位,固定为 0;
- 后续 41 位,是当前时间戳相对某个纪元时间的毫秒差;
- 再往后 10 位,是机器标识,可以拆成数据中心 ID 和机器 ID 两部分;
- 最后 12 位,是同一毫秒内的自增序列号。
从纯计算角度讲,MySQL 里一条 SQL 完全可以做位运算:
sql复制SET @id = ((@cur_ms - @epoch_ms) << 22) | (@worker_id << 12) | @seq;
但真正的问题在于,这段 SQL 运行时,@seq、@cur_ms、@last_ms 这些状态从哪里来、如何保证并发下不重复。Java 里可以用单例对象加锁,或者在内存里维护一个 volatile 变量,可 MySQL 的 SQL 会话是无状态的:连接关闭、变量释放、会话结束之后,所有内存状态全部清零。如果只是在一条 SELECT 里临时算一下,没有任何机制能保证两个会话在同一毫秒拿到不同的序列号。
因此,必须先回答三个问题:状态存在哪、当前毫秒怎么精确取、并发安全靠谁保证。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 纯 SQL 实现绕不开的三个核心设计约束
2.1 没有共享内存,状态必须落库
雪花算法里最重要的运行状态是两个值:当前 worker 上一次生成 ID 的毫秒时间戳,以及该毫秒内已经用到的最大序列号。应用层实现可以用一个对象字段保存,纯 SQL 只能把这两个值存到一张表里。
我建的状态表结构如下:
sql复制CREATE TABLE t_snowflake_state (
worker_id BIGINT PRIMARY KEY COMMENT '机器/服务编号',
last_millis BIGINT NOT NULL COMMENT '上次发号的毫秒时间戳',
sequence BIGINT NOT NULL COMMENT '上次发号时用到的序列号'
) ENGINE=InnoDB;
每个 worker_id 对应一行,这张表全库只需要几行,不存在数据膨胀问题。每次发号时,会话先读取某个 worker 行的状态,计算完新 ID 后再把最新状态更新回去。
有人可能问:这张表会不会成为性能瓶颈?会,而且必然。所以在正文后面我会专门讲批量取号方案,把“每生成一个 ID 更新一次状态表”降级成“每取一批 ID 才更新一次状态表”,单次并发读写压力可以非常小。
2.2 取毫秒时间戳的八十一难
这是网上代码最容易翻车的地方之一。很多人写:
sql复制SET @cur_ms = UNIX_TIMESTAMP(NOW(3)) * 1000;
看起来很合理,实际上 UNIX_TIMESTAMP() 接收一个带毫秒精度的 DATETIME 参数后,返回的是整数秒,毫秒部分直接就丢掉了,后面再乘 1000 也只是把整秒放大,根本拿不到毫秒。如果你在边界时刻连续调用,还会出现同一毫秒 ID 全部重复的情况。
正确做法是用微秒函数补毫秒:
sql复制SET @cur_ms = UNIX_TIMESTAMP() * 1000 + FLOOR(MICROSECOND(NOW(6)) / 1000);
这条语句里 UNIX_TIMESTAMP() 返回当前整秒,NOW(6) 返回含微秒的当前时间,取微秒部分除以 1000 就是当前毫秒。合在一起,就是标准的毫秒级时间戳。由于这两次调用发生在同一条 SQL 内,MySQL 对当前时间函数的取值在语句级别是一致的,基本不会出现跨秒边界后整秒与微秒来自两个时间点的问题。
要注意,取到的时间戳是自 Unix 纪元以来的绝对毫秒数,不入库时可以直接参与位运算。雪花算法通常要减去一个基准时间,比如 2020-01-01 00:00:00 对应的毫秒数 1577808000000,这样 41 位时间戳能往后支持到 2089 年附近,足够用几十年。
2.3 并发安全只能靠 MySQL 自己的行锁,不能靠玄学
应用层做雪花算法要加锁或使用原子变量,SQL 会话之间天然没有共享内存,所以并发保护必须由数据库自身提供。最稳妥的方式是:在事务里对状态表的对应行执行 SELECT ... FOR UPDATE,把这一行锁住,然后再做时间比较、序列自增、状态回写、提交。其他会话如果同时操作同一个 worker 行,会在这个当前读上排队,直到前一个事务提交。
有人可能想用 MySQL 的 GET_LOCK() 做命名锁,这个思路也可以,但要注意:MySQL 的 GET_LOCK() 不能用在存储函数、触发器里,只适合在存储过程或普通 SQL 会话里使用。对多数场景来说,InnoDB 行锁已经足够,而且不引入额外命名锁管理问题。
因此,正式的存储过程实施必须以“SQL 语句 + 状态表 + 行锁事务”三件套为骨架。
3. 单条生成:一个带行锁事务的存储过程
3.1 初始化状态表
先初始化一台 worker。假设当前有两台写库服务需要发号,我给它们分配的 worker_id 分别是 1 和 2,那就在状态表里插入两行初始状态:
sql复制INSERT INTO t_snowflake_state(worker_id, last_millis, sequence)
VALUES
(1, 0, 0),
(2, 0, 0);
初始时间设成 0,是为了保证第一次调用时,当前时间一定大于等于上一次时间,逻辑可以正常走到“重置序列号为 0”分支。
3.2 单条生成存储过程:完整代码
MySQL 5.7/8.0 均可使用。下面的过程实现的是:输入 worker_id,输出一个雪花 ID。
sql复制DELIMITER $$
CREATE PROCEDURE snowflake_next_id(
IN v_worker_id BIGINT,
OUT v_id BIGINT
)
BEGIN
DECLARE v_epoch_ms BIGINT DEFAULT 1577808000000;
DECLARE v_last_ms BIGINT DEFAULT 0;
DECLARE v_seq BIGINT DEFAULT 0;
DECLARE v_cur_ms BIGINT DEFAULT 0;
DECLARE v_worker_shift BIGINT DEFAULT 0;
SET v_worker_shift = (v_worker_id % 1024) << 12;
START TRANSACTION;
SELECT last_millis, sequence
INTO v_last_ms, v_seq
FROM t_snowflake_state
WHERE worker_id = v_worker_id
FOR UPDATE;
SELECT UNIX_TIMESTAMP() * 1000 + FLOOR(MICROSECOND(NOW(6)) / 1000)
INTO v_cur_ms;
-- 处理系统时钟回拨
IF v_cur_ms < v_last_ms THEN
ROLLBACK;
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'system clock moved backwards, reject';
END IF;
-- 同一毫秒内,序列号加 1;跨毫秒则序列号重置为 0
IF v_cur_ms = v_last_ms THEN
SET v_seq = v_seq + 1;
ELSE
SET v_seq = 0;
END IF;
-- 如果同一毫秒内序列号达到 4096,说明这一毫秒的配额已经用完
IF v_seq >= 4096 THEN
ROLLBACK;
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'snowflake sequence overflow, retry later';
END IF;
UPDATE t_snowflake_state
SET last_millis = v_cur_ms,
sequence = v_seq
WHERE worker_id = v_worker_id;
-- 组合 64 位雪花 ID
SET v_id = ((v_cur_ms - v_epoch_ms) << 22)
| v_worker_shift
| v_seq;
COMMIT;
END$$
DELIMITER ;
过程逻辑并不复杂,核心就是三件事:先锁行,再比较时间,更新序列状态。当两个会话同时调用同一个 worker 时,第二个会话会被阻塞在 SELECT ... FOR UPDATE 上,等第一个事务提交后,它读到的 v_last_ms 和 v_seq 一定是最新值,所以不会出现两个会话都读到同一初始状态、然后生成重复 ID 的问题。
当两个会话使用不同 worker_id 时,锁的是状态表里不同的行,完全并发,互不影响。
3.3 调用方式和事务边界提醒
单条生成的使用方式是在应用层或脚本中分两步:
sql复制CALL snowflake_next_id(1, @new_id);
SELECT @new_id;
如果业务 SQL 要插入一条数据,就先把 ID 取出来,再执行 INSERT:
sql复制CALL snowflake_next_id(1, @new_id);
INSERT INTO t_order (id, order_no)
VALUES (@new_id, 'NO20250101001');
这里有个非常容易踩的坑:存储过程内部用了 START TRANSACTION 和 COMMIT,等于过程内部自己管理了事务提交。如果你的调用方之前已经开启了一个事务,比如:
sql复制START TRANSACTION;
-- 做了别的事,还没提交
CALL snowflake_next_id(1, @new_id);
存储过程里的 START TRANSACTION 会先把当前已有事务提交掉,这会造成你之前所有未提交的写操作被隐式提交,后果非常严重。因此,在实际业务代码里,不要让这个存储过程处于一个更大的外层事务中间。更合理的做法是在一条独立的数据库连接里取 ID,再回到业务事务里使用;如果是在存储过程/触发器内部调用它,一定要仔细确认事务边界。
单条生成的实现因为每次生成都要锁状态行、更新状态行,按每秒并发 1000 次以内的低频场景完全够用。如果需要在一次事务里插入几千、几万条历史数据,逐条 CALL 会产生大量锁竞争和网络交互,不建议这时候用单条过程,可以跳到下一节的批量取号方案。
4. 批量取号:一次拿 1000 个 ID 的 SQL 方案
4.1 为什么要做批量而不是循环调用
批量插入或者历史数据回填时,逐条调用存储过程,每调一次就更新一次状态表,锁竞争和上下文切换成本都很高。正确做法是让存储过程一次从状态表取出一段连续的序列号段,比如一次取 1000 个,然后由普通 SQL 语句直接拼出 1000 个雪花 ID。整个过程只需要锁一次状态行,状态表只更新一次,整体性能会好非常多。
需要注意,连续序列号段受最后 12 位序列号的限制,同一 worker 同一毫秒最多只能有 4096 个 ID,所以一次取的批量数量不宜超过 3000 左右,留一些余量给同毫秒内其他并发调用。如果已经接近 4096,存储过程会报溢出,这时应用层稍等 1ms 后重试即可。
4.2 批量取号存储过程
为了配合批量生成,状态表仍需保留。我设计的批量过程只做一件事:返回本次批次的起始序列号 out_start_seq 和当前毫秒时间戳 out_cur_ms,同时将状态表里的 sequence 推进到起始序列号加批量大小减一的位置。
sql复制DELIMITER $$
CREATE PROCEDURE snowflake_take_segment(
IN v_worker_id BIGINT,
IN v_batch_size INT,
OUT v_start_seq BIGINT,
OUT v_cur_ms BIGINT
)
BEGIN
DECLARE v_epoch_ms BIGINT DEFAULT 1577808000000;
DECLARE v_last_ms BIGINT DEFAULT 0;
DECLARE v_seq BIGINT DEFAULT 0;
DECLARE v_end_seq BIGINT DEFAULT 0;
DECLARE v_worker_shift BIGINT DEFAULT 0;
SET v_worker_shift = (v_worker_id % 1024) << 12;
START TRANSACTION;
SELECT last_millis, sequence
INTO v_last_ms, v_seq
FROM t_snowflake_state
WHERE worker_id = v_worker_id
FOR UPDATE;
SELECT UNIX_TIMESTAMP() * 1000 + FLOOR(MICROSECOND(NOW(6)) / 1000)
INTO v_cur_ms;
IF v_cur_ms < v_last_ms THEN
ROLLBACK;
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'system clock moved backwards, reject';
END IF;
-- 同毫秒内从当前序列 + 1 开始取;跨毫秒直接从 0 开始
IF v_cur_ms = v_last_ms THEN
SET v_start_seq = v_seq + 1;
ELSE
SET v_start_seq = 0;
END IF;
SET v_end_seq = v_start_seq + v_batch_size - 1;
IF v_end_seq > 4095 THEN
ROLLBACK;
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'snowflake sequence overflow in this millisecond';
END IF;
UPDATE t_snowflake_state
SET last_millis = v_cur_ms,
sequence = v_end_seq
WHERE worker_id = v_worker_id;
COMMIT;
END$$
DELIMITER ;
注意,如果当前这个毫秒已经生成过一些 ID,比如之前已经取到序列号 100,本次再取 1000 个,那最终 end_seq 会是 1100,仍在 4095 范围内。存储过程里的 v_start_seq 输出的是本次批次可用的第一个序列号,实际生成的 ID 就是 [v_start_seq, v_end_seq] 这个闭区间上的所有整数。
4.3 批量生成 1000 个雪花 ID 的完整示例
先调用批量取号过程:
sql复制CALL snowflake_take_segment(1, 1000, @start_seq, @cur_ms);
然后利用 MySQL 8.0 的递归 CTE 生成从 0 到 999 的连续数字序列:
sql复制SET @epoch_ms = 1577808000000;
SET @base_id = ((@cur_ms - @epoch_ms) << 22) | ((1 % 1024) << 12);
WITH RECURSIVE seq_num(n) AS (
SELECT 0
UNION ALL
SELECT n + 1 FROM seq_num WHERE n < 999
)
SELECT @base_id | (@start_seq + n) AS snowflake_id
FROM seq_num;
如果你用的是 MySQL 5.7,不支持递归 CTE,可以提前准备一张数字辅助表。例如建一张 seq_num 表,预先插入 0 到 4095 的整数,然后直接:
sql复制SELECT @base_id | (@start_seq + n) AS snowflake_id
FROM seq_num
WHERE n < 1000;
不需要维护 4096 行的数字表,一次插入即可长期使用。
这样生成的 1000 个 ID,在同一个毫秒内使用了 0 ~ 999 这段连续序列号,整体是趋势递增的;由于这一批只会更新一次状态表,对比逐条 CALL,数据库负载低了一个数量级。
5. 并发重复、时间回拨和溢出处理:实际踩坑复盘
5.1 网上很多版本的 SQL 为什么会重复
网上搜索“MySQL 雪花算法 SQL”,很容易看到这类实现:
sql复制SET @seq = FLOOR(RAND() * 4096);
SELECT ((UNIX_TIMESTAMP(NOW(3)) - @epoch_ms) << 22) | (@worker_id << 12) | @seq;
这个写法的问题在于:
- 序列号用随机数生成,只要同一毫秒内随机到同一个数字,就发生碰撞;
- 没有持久化状态,同一毫秒并发执行几次,每次随机序列完全不可控;
- 时间戳取的
UNIX_TIMESTAMP(NOW(3))还会丢失毫秒精度。
把随机数当成序列号,本质上已经偏离了雪花算法的“自增序列”语义,生产环境一旦用起来,最终一定会出现主键冲突。真正靠谱的实现,序列号必须基于上一次状态做自增,并且自增过程要受到状态表行锁保护,不能靠任何形式的随机函数。
5.2 时钟回拨处理策略
系统时间如果因为 NTP 校准或者人工调整往回调,当前时间戳可能会小于状态表里记录的 last_millis。此时如果继续生成 ID,新 ID 的时间戳段会小于以前生成过的 ID,虽然不一定会立刻重复,但趋势递增性和排序性就被破坏了,严重时可能生成与历史 ID 完全相同的组合。
处理策略我在存储过程里已经写了:如果 v_cur_ms < v_last_ms,直接回滚事务并报错,绝不向下执行。实际运维中更重要的是,部署 MySQL 的机器一定要开启 NTP 时间同步,不要手动随意去改服务器时间。如果只是几毫秒的轻微回拨,大多可以通过等待后重试解决;如果回拨超过几十毫秒,存储过程会持续报错,这时宁可暂停发号,也不要冒险生成 ID。
5.3 序列号溢出不一定要死等
标准雪花算法在序列号达到 4096 后,通常会让当前线程自旋等待到下一毫秒,再继续生成。MySQL 存储过程里做长时间空转等待不太合适,因为会长期占用状态表的行锁,其他请求全部会堆积起来。
我的选择是:直接返回 sequence overflow 错误,由调用方捕获后稍等 1ms 重试。这个策略在批量插入和低并发场景里非常稳定,几毫秒后重试成功率接近 100%。如果是高并发在线发号,就说明你不应该用这种纯 SQL 方案,而应该考虑应用层缓存一批 ID 或使用独立的发号中间件。
5.4 验证 ID 是否合规的三个方法
生成完一批 ID 后,建议做下面几个自检:
- 唯一性校验:对结果集求
COUNT(DISTINCT id)与COUNT(*),两者相等才算没重复; - ID 正负校验:所有 ID 输出都应该是正数,如果出现负数,说明位运算结果超出了 BIGINT 有符号范围,多半是时间戳减 epoch 后位数超过 41 位;
- 趋势递增校验:把带时间戳的 ID
>> 22解析回相对毫秒,检查是否随生成时间单调递增。
如果要反向解析某条 ID 的生成时间,可以这样:
sql复制SET @decode_ms = (@id >> 22) + 1577808000000;
SELECT FROM_UNIXTIME(@decode_ms /
