前阵子做一个数据迁移项目,碰到一个特别实际的需求:存量表里好几百万条数据没有业务ID,要批量补上雪花算法生成的全局唯一ID。问题是应用层的发号器早就下线了,代码也不想为了这一次性需求重新发版。我当时的念头是——能不能直接在MySQL的SQL层把雪花ID算出来,跑一条UPDATE或者INSERT SELECT就把ID补齐。折腾了几天,方案踩通了,也踩了不少坑。这篇文章就把完整的实现思路、SQL代码和踩坑记录整理出来,给同样有补数、初始化、ETL场景需求的同学做个参考。
这套方案适合谁?数据开发、DBA、偏后端的同学应该都用得上。尤其是你在做历史数据回填、造测试数据、写存储过程、或者用Kettle这类ETL工具不想额外写代码的时候,直接用一条SQL生成雪花ID会顺手很多。原理不复杂,核心就是位运算,但里面有不少MySQL特有的细节,不注意就会生成重复ID或者负数ID。
1. 为什么要把雪花ID生成搬进SQL里
1.1 应用层生成ID很常见,但依然有缺口
现在主流雪花ID实现都是Java、Go、Python这些语言里封装好的组件,比如MyBatis-Plus的IdWorker、Python的snowflake库。应用层生成ID的优势很明显:语言层面可以轻松维护状态,比如记录上一次的毫秒时间戳、当前序列号、机器ID,碰到时间回拨还能做出复杂的容错策略。
但现实工作中总有几个场景绕不开SQL生成这个需求。
第一类是历史数据回填。业务早期表结构设计不规范,主键用的是自增ID,后来引入雪花ID作为业务主键,结果存量数据没有雪花ID。这时候应用层的老接口可能已经下线了,重新部署一个发号服务只为了跑一次数据修正,成本太高。第二类是数据初始化与测试环境造数。我需要快速造出一个符合业务规则的ID,但手头没有可用的应用项目。第三类是存储过程和触发器场景。你在数据库里直接写了一段业务逻辑,希望在插入时自动生成唯一ID,总不能为了一个字段去依赖外部应用服务。这三种场景下,SQL生成ID不只是替代方案,而是最顺手的选择。
使用SQL生成雪花ID的思路也简单:既然雪花ID本质是一个64位整数,那就把时间戳、机器ID、序列号三部分用位运算拼接起来。MySQL支持左移和按位或,这正好满足需求。序列号用MySQL会话变量来维护,时间戳从系统时间算出来,机器ID自己配置,拼完之后就是一个标准的雪花ID。
1.2 为什么要自己写,而不直接用UUID或者随机数
有人可能会问,为什么不直接UUID或者UUID_SHORT?UUID是一个36位字符串,无序且长度大,直接作为InnoDB主键会造成大量随机IO,索引性能很差。MySQL 8.0之后虽然有UUIDv7,但如果业务系统已经围绕雪花ID做了分库分表路由,UUID的格式对不上,底层存储和二进制转换也很麻烦。UUID_SHORT在MySQL里是64位整数,但它生成规则依赖server_id和time,在多实例或分库场景下仍然会撞,而且时间戳位段不是标准雪花算法格式,跟别的系统对接时解析不出来。
雪花ID的优势是全局有序、趋势递增、64位可以存成BIGINT,而且各部分位段可以自定义解析,能够反解出生成时间。用SQL生成雪花ID,实际上就是把这个通用的生成规则用SQL语法重新实现一遍,确保ID格式和应用层生成的一致,才能和其他系统的ID兼容。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 雪花ID的结构拆解与SQL运算基础
2.1 64位里每一位都藏着什么信息
标准雪花算法生成的ID是一个64位long型整数,通常从高位到低位依次分为四段:
| 段位 | 位数 | 作用 | 能表示的范围 |
|---|---|---|---|
| 符号位 | 1 | 固定为0,保证ID为正数 | 不用 |
| 时间戳 | 41 | 毫秒级时间戳与起始纪元的差值 | 约69.7年 |
| 机器ID | 10 | 区分不同节点或进程 | 0~1023 |
| 序列号 | 12 | 同一毫秒内递增,区分同节点同毫秒的多个ID | 0~4095 |
这41位时间戳能撑多长时间取决于你选的起始纪元。如果你用2021年1月1日作为起始点,那么大概可以用到2090年左右。机器ID的10位最多支持1024个节点,序列号的12位支持每毫秒4096个ID,也就是说单个节点一毫秒最多生成4096个,换算下来单节点每秒约409.6万。这个设计对大多数业务足够,如果真的超出,那要考虑的不是改雪花算法,而是换发号服务。
关键的地方是,各部分是通过位移来拼装的,不是字符串拼接。时间戳部分是 (当前毫秒时间戳 - 起始纪元) << 22,左移22位是因为后面机器ID占10位、序列号占12位。机器ID部分是 机器ID << 12,左移12位给序列号让出空间。序列号部分直接落在低12位,不用位移。然后三部分做按位或运算,拼完后就是一个二进制位正好对上的64位整数。
以数字举例。假设当前毫秒时间戳差值是 1680000000000,机器ID是5,序列号是1。计算过程就是:
code复制1680000000000 << 22 // 时间戳左移22位,低22位都是0
| 5 << 12 // 机器ID左移12位,让出序列号的12位
| 1 // 序列号直接放最低位
最终得到一个BIGINT整数,这个值的二进制形态里正好包含三段信息。MySQL的 << 和 | 运算正好能实现这个逻辑,这也是纯SQL方案能成立的根本原因。
2.2 MySQL的位运算和类型匹配
MySQL里做这些运算要注意整型的范围。标准雪花ID是有符号BIGINT类型,最大值是 2^63 - 1,也就是 9223372036854775807。只要符号位是0,整个ID不会超过这个值。在我们上面的位段设计里,符号位固定为0,所以最终结果一定是正数。但如果你不小心把时间戳差值算得过大,导致左移后占用了第63位,结果就会变成负数。这一点在3.3节的参数验证里我会再强调。
MySQL的位运算返回的是BIGINT UNSIGNED类型,如果直接插入BIGINT列,值范围没问题。但如果你的表字段使用的是INT类型,雪花ID保存时会溢出报错或者截断,这个简直是新手最容易踩的坑。建议字段类型就是BIGINT,最好加UNSIGNED。MySQL 8.0里用BIGINT UNSIGNED存储会更宽松,但考虑到某些ORM框架对无符号类型的支持不好,我一般还是用普通BIGINT,只要保证符号位为0就行。
另一个容易忽略的点是 UNIX_TIMESTAMP(NOW(3)) * 1000 得到的毫秒时间戳有小数误差风险。UNIX_TIMESTAMP返回的是带小数的秒值,比如 1680000000.123,直接乘以1000得到 1680000000123.000,虽然有小数,但浮点乘法在极端情况下可能产生精度丢失。更稳妥的写法是 FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000),先把数值转换成整数。实际测试下来大部分机器都没问题,但补数这种一次性大批量操作,我不想让精度问题在后期查数据时冒出来,所以每次都强制取整。
3. 手写一套可用的SQL雪花ID方案
3.1 单条SQL生成一个ID(INSERT直接使用)
如果只是偶尔生成一个ID,比如手动插入一条测试数据,可以用最简单的一段SQL:
sql复制SET @epoch = 1609459200000; -- 起始纪元:2021-01-01 00:00:00
SET @machine_id = 5; -- 机器ID,0~1023之间
SET @last_ms = 0;
SET @seq = 0;
SELECT
((FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) - @epoch) << 22)
| (@machine_id << 12)
| (IF(FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) = @last_ms, @seq := @seq + 1, @seq := 0))
) AS snowflake_id;
这段SQL的逻辑很直观:先计算当前毫秒时间戳与起始纪元的差值,左移22位,然后拼接机器ID左移12位,最后处理序列号。序列号的规则是:如果当前毫秒和上一次记录的最后毫秒相同,则取上次序列号加一,否则序列号归零并更新最后毫秒。
但这里有个MySQL用户变量的执行顺序坑。在一条SELECT语句里,@last_ms 和 @seq 的赋值顺序其实不是严格从左到右的,MySQL可能先计算后面的IF表达式,导致判断用的是未更新的旧值,同一次查询中会用到不同毫秒的时间戳。这个我在4.2节会细说。单次手动执行问题不大,因为每次执行都重新跑一遍SET语句,状态是干净的。但如果要让这段SQL稳定地被重复调用,我建议用存储过程,把状态封装在过程内部。
3.2 用存储过程批量生成ID
要批量生成一批ID,比如一次生成1000个,最稳的方式是写一个存储过程。存储过程的好处是局部变量作用域清晰,状态不会像用户变量那样被外部污染,而且循环逻辑可以完整地处理时间回拨、序列号溢出这些边界情况。
下面这个存储过程是我实际用过的版本,入参有两个:机器ID和生成数量。它会生成一批ID放到临时表里并返回:
sql复制DELIMITER //
CREATE PROCEDURE gen_snowflake_ids(
IN p_machine_id INT,
IN p_count INT
)
BEGIN
DECLARE v_epoch BIGINT DEFAULT 1609459200000;
DECLARE v_ts BIGINT DEFAULT 0;
DECLARE v_last_ts BIGINT DEFAULT 0;
DECLARE v_seq INT DEFAULT 0;
DECLARE v_i INT DEFAULT 0;
-- 临时表存放生成的ID
DROP TEMPORARY TABLE IF EXISTS tmp_sf_ids;
CREATE TEMPORARY TABLE tmp_sf_ids (id BIGINT PRIMARY KEY);
WHILE v_i < p_count DO
-- 取当前毫秒时间戳
SET v_ts = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000);
-- 时间回拨处理:如果系统时间倒退,沿用上一次的时间戳
IF v_ts < v_last_ts THEN
SET v_ts = v_last_ts;
END IF;
-- 同一毫秒内序列号递增,否则重置为0
IF v_ts = v_last_ts THEN
SET v_seq = v_seq + 1;
ELSE
SET v_seq = 0;
SET v_last_ts = v_ts;
END IF;
-- 序列号溢出处理:12位最多4095,超出后时间戳加1,序列号重新计数
IF v_seq >= 4096 THEN
SET v_last_ts = v_last_ts + 1;
SET v_seq = 0;
END IF;
INSERT INTO tmp_sf_ids VALUES (
((v_ts - v_epoch) << 22) | (p_machine_id << 12) | v_seq
);
SET v_i = v_i + 1;
END WHILE;
SELECT id FROM tmp_sf_ids ORDER BY id;
END //
DELIMITER ;
调用方式:
sql复制CALL gen_snowflake_ids(5, 1000);
这个存储过程处理了几个关键细节。时间回拨的问题上,如果系统时间因为NTP校正往回跳,我会直接把 v_ts 设置成 v_last_ts,这样时间戳不会变小,生成的ID依然单调递增。代价是这段时间生成的ID时间戳不是真实时间,但在离线补数场景里完全能接受。序列号溢出问题上,如果同一毫秒内超过了4096个,把时间戳加1毫秒再继续,序列号归零,确保ID唯一性。
实际跑下来,生成一万个ID大概就是秒级,因为每个ID只是几次数值和位运算,瓶颈在于INSERT语句本身。如果要更高吞吐,可以把临时表换成内存临时表,或者把结果直接写入目标表。
3.3 给存量数据更新雪花ID
存量数据回填是最常见的场景。表里假设有 id 作为原有自增主键,biz_id 是要补的雪花ID字段。我的做法是用一个UPDATE JOIN,利用MySQL变量生成从1开始递增的序列号,然后拼出雪花ID:
sql复制SET @epoch = 1609459200000;
SET @machine_id = 7;
SET @seq = 0;
UPDATE t_legacy_data AS a
JOIN (
SELECT t.id,
((ts.ts - @epoch) << 22)
| (@machine_id << 12)
| (@seq := @seq + 1) AS snowflake_id
FROM (
SELECT id, FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) AS ts
FROM t_legacy_data
ORDER BY id
) t
) b ON a.id = b.id
SET a.biz_id = b.snowflake_id;
这里有一点要注意:MySQL对带有ORDER BY和用户变量的派生表,优化器行为比较玄学,有可能变量递增的顺序和ORDER BY不一致。我习惯先把排序后的数据包一层子查询,让外层再处理ID生成,能规避掉大部分变量顺序问题。实际数据量超过几百万行的时候,UPDATE JOIN一次性执行容易锁表时间过长,我建议分批处理,每次取几千行ID先算出来,再逐批更新。
时间戳在批量更新时,我每次都取一次 NOW(3),同一批所有行共用同一个时间戳,然后再用序列号区分。这样生成的ID在同一批内是连续的,并且因为时间戳相同,看起来非常整齐。如果每行都单独调 NOW(3),时间戳会漂移,ID虽然也能用,但不便于后续按时间范围排查数据。
4. 性能、并发与踩坑实录
4.1 并发场景下SQL方案的边界
必须坦白讲,纯SQL方案天生不适合高并发线上环境。核心原因在于MySQL用户变量是会话级别的,不同连接之间互不可见。假设两个连接同时执行相同的生成SQL,机器ID相同,又刚好落在同一毫秒,它们各自的序列号都从0开始,最后生成的ID就会完全一样。
这不是说SQL方案没用,而是要明确它的适用边界。我给出的使用建议是:
| 场景 | 是否推荐 | 原因 |
|---|---|---|
| 离线数据回填、初始化 | 推荐 | 单连接串行执行,序列状态连续 |
| 存储过程内部生成ID | 可以 | 存储过程局部变量保证单会话内正确 |
| 定时任务批量造数 | 推荐 | 串行执行,不会撞ID |
| 高并发线上写请求 | 不推荐 | 多会话之间无法共享序列号 |
| 分布式多节点同时补数 | 不推荐 | 机器ID配置不当会发生重复 |
如果你需要在多连接下并行跑,至少要做到两点:一是给每个连接配置不同的机器ID,让机器ID位段去区分;二是给目标表的ID字段加唯一索引,一旦发生重复,MySQL会立刻报错,而不是静默覆盖。这两条是兜底方案,不能只靠脑子判断。
另外,如果你真的要在生产环境的高并发链路上使用雪花ID,我的建议还是把发号逻辑放回应用层或者专门的ID服务。SQL方案更适合数据工程场景,而不是在线交易场景。这不算技术上的妥协,而是每种方案都有它最合适的生存空间。
4.2 常见问题与排查技巧
我自己在这个方案上踩过不少坑,整理成一个排查表,方便你直接对号入座。
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 生成的ID是负数 | 时间戳差值过大,左移后占用符号位;或者字段类型是INT | 检查起始纪元是否设置过小;确认目标字段类型是BIGINT |
| 同一批数据里出现重复ID | 用户变量没初始化;多连接并发执行;序列号溢出后时间戳没有自增 | 检查SET语句是否在当前会话执行;不同连接配置不同机器ID;看看存储过程里有没有做溢出判断 |
| 批量执行时ID顺序混乱 | 子查询中ORDER BY和用户变量组合时被MySQL优化器打乱顺序 | 多包一层子查询,让变量在外层执行;或者改为存储过程循环 |
| 时间戳字段漂移严重 | 每行都调用 UNIX_TIMESTAMP(NOW(3)),导致每行时间不同 |
先在变量里 SET @ts = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000); 再引用 |
| 插入ID时提示BIGINT value is out of range | 手工设置的机器ID超过了1023,或者时间戳差值超过了41位范围 | 检查机器ID范围;调整起始纪元到更近的时间 |
| 存储过程创建失败 | DELIMITER没有正确设置,或者存储过程权限不足 | 确认DELIMITER语句;用root账号或授权账号执行 |
序列号溢出的情况很容易被忽略。假设线上某一毫秒生成的ID超过了4096个,普通SQL版本没有处理逻辑,就会造成序列号超过12位,结果拼出来的ID里序列号可能进位到机器ID的位段,导致ID重复或者结构异常。我在存储过程里加了 IF v_seq >= 4096 的判断,就是为了防止这种情况。如果你用的是单条SQL方案,建议在业务上对生成频率做限制,或者使用MySQL 8.0之后的新特性把ID生成放在应用层处理。
还有朋友遇到过一个问题:在Navicat里执行多行SQL,明明开了新查询窗口,用户变量却还是上一次的值。这是因为MySQL用户变量是会话级别的,一直存活到连接关闭。只要你没关连接,@seq 的值就会保留。为了避免脏数据,我在每次执行生成SQL之前,都会显式初始化一遍所有变量,比如 SET @seq = 0; SET @last_ms = 0;。这个习惯非常重要,建议你一定养成。
4.3 一些实操心得
最后分享几个我在这套方案落地过程中的经验。
时间戳一定要统一取一次。我之前一个版本里,每条SQL都在几处调用 UNIX_TIMESTAMP(NOW(3)),结果同一行的几个表达式拿到的时间戳不一致,导致位运算拼接时低22位出现错位。后来改成先存变量再引用,问题立刻消失。这也是为什么上面所有示例代码里都有 SET @ts = ... 或者存储过程里的 SET v_ts = ...,这不是为了多此一举,是为了避免很多隐蔽的坑。
机器ID尽量按照任务去分配,不要所有任务都用同一个默认值。如果只是本地跑测试,用1到20以内的任意数字都行。如果多个开发者同时在自己的机器上跑脚本,最好约定一套机器ID分配表,比如A开发用1号,B开发用2号。虽然数据中心里一般不会多个脚本同时对同一张表补数,但提前约定总比出问题了再查要省时间。
给目标ID字段加上唯一索引再操作。这个建议看起来有点笨,实际是非常有效的自我保护。你在执行UPDATE或INSERT之前,如果目标表已经存在少量重复ID,唯一索引会立刻拦住,而不是等你跑完几百万行数据才发现ID重复。补数结束后再根据业务需要决定是否保留唯一索引,通常建议保留,因为业务主键本来就该唯一。
如果有MySQL 8.0环境,你还可以考虑用它自带的UUIDv7去做部分替代。UUIDv7在8.0的某些版本里表现不错,趋势递增,格式也是64位可转换。但如果你需要和应用层已有的雪花ID做兼容,或者后续要按位段解析时间戳,那还是老老实实自己生成标准雪花ID更合适。数据库提供的函数不一定能完全匹配你业务里其他系统生成的ID格式。
这套SQL方案我用在一个两千万行的表上做数据补齐,串行分批跑,每批一万行,整个过程大概十几分钟,最终校验重复率为0。操作起来比想象中顺利,前提是提前把机器ID、起始纪元、序列号边界这些参数都预置好,脚本本身不要有状态污染。说实话,雪花ID就是一个64位数学组合问题,用SQL的位运算重现一遍并不难,难的是在动手之前想清楚你的并发边界在哪里,以及你的数据量会不会触及到序列号上限。想清楚这些,再用SQL生成ID就会是一件非常顺手的事。
