做后端开发的朋友对雪花算法应该都不陌生,这是一套在分布式环境下生成有序唯一ID的经典方案,被大量互联网项目当成默认选项。但大多数人日常的使用方式很固定:在Java、Go、Python的代码里实现一个ID生成器,然后在写库时把生成的ID作为字段值传进去。那如果我不想写代码,想直接在MySQL数据库里用SQL语句生成雪花算法的ID,能不能做到?答案是能,而且实现起来并不复杂,只是有一些细节需要处理。
这篇文章我就把整套思路和可直接抄走的SQL方案整理出来,包括会话变量版、存储函数版、批量生成版,以及我在实测和线上使用中踩过的坑。适合正在做数据初始化脚本、需要批量补ID、想在存储过程里直接产出分布式ID的开发者参考。当你理解了雪花算法的位运算本质之后,用SQL来实现它其实就是几个位移和或运算的事情。
1. 为什么要在MySQL里用SQL生成雪花ID
1.1 先回顾一下雪花算法到底在解决什么问题
雪花算法最早是Twitter提出的分布式ID生成方案,核心目标是在不依赖中心化服务的前提下,生成全局唯一、趋势递增的64位整数ID。一个标准的雪花ID由四部分构成,按位拆开就是这样的结构:
从最高位开始,第一位是符号位,固定为0;接着是41位毫秒时间戳,记录的是相对于某个起始时间的毫秒偏移量;然后是10位机器ID,用来区分不同的机器或进程;最后是12位序列号,用来保证同一毫秒内生成多个ID时仍然不重复。
41位时间戳的容量其实是很大的。如果起始时间设置为2021年1月1日,那么大约可以用到2090年,覆盖69年左右的时间,对绝大多数业务系统来说绰绰有余。10位机器ID意味着最多支持1024个节点;12位序列号意味着同一毫秒内最多生成4096个ID。三者结合起来,单节点的理论QPS可以达到每秒400万以上,足够应对绝大多数场景。
用生活化的类比来理解,雪花ID就像一张精心设计的编号卡:时间戳告诉别人这个ID是什么时候生成的,机器ID说明它来自哪个节点,序列号用来解决同一时刻的并发冲突。三者合在一起,每张卡都是全世界唯一的。
1.2 应用层生成很普遍,SQL生成的价值在哪里
既然应用层已经有了成熟的雪花ID实现库,为什么还要在MySQL里用SQL语句生成?我最初接到这个需求,是因为要给一张历史遗留表补数据。那张表原本用自增ID,后来新系统统一改用雪花ID,需要把几千万条历史数据迁到新表里。如果通过应用层逐条生成,要么起一个Java服务跑脚本,要么写一个数据同步工具,时间和改造成本都不小。
另一种典型场景是在存储过程或定时任务里给若干表插入数据,这些表的主键都是雪花ID。如果在存储过程里还要外接一个服务才能拿到ID,链路就太长了。这时候直接写SQL调用一个函数生成ID,比什么都方便。
还有一个容易被忽视的场景:临时表、日志表、测试数据填充。你在本地调试,没有启动完整的微服务环境,只是想快速往MySQL里塞一些带唯一ID的数据。这时候SQL版的雪花ID生成器就派上用场了。
当然我也要说句实话:如果是在高并发线上交易链路里,每笔订单都通过MySQL的SQL函数来生成雪花ID,我不推荐这么做。原因在后面会详细讲,主要是序列号管理和主从复制带来的风险。SQL生成方式更适合低并发、需要临时/批量生成ID的场景,比如数据清洗、初始化脚本、测试数据构造。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用SQL实现雪花ID:核心原理与先决条件
2.1 位运算基础:MySQL能不能搞定64位整数操作
雪花算法的核心操作是位运算,左移、右移、按位或、按位与,这些在MySQL里都有对应的运算符。MySQL的整数类型中,BIGINT占用8个字节,正好可以表示64位有符号整数。雪花ID虽然使用了64位中的63位,但因为符号位固定为0,所以最终结果恒为正数,用BIGINT存储完全没有问题。
MySQL中常用的位运算符如下:
<<左移,相当于把二进制位整体向左移动,右边补0。比如1 << 10等于 1024。|按位或,两个数的二进制位中只要有一个是1,结果就是1。&按位与,两个数的二进制位中必须两个都是1,结果才是1。>>右移,把二进制位整体向右移动。
对于雪花ID的组装,核心表达式是这样的:
sql复制((timestamp_diff << 22) | (machine_id << 12) | sequence)
这里把相对时间戳左移22位,为机器ID和序列号腾出空间;然后把机器ID左移12位,为序列号腾出空间;最后用按位或把它们拼接起来。因为这三段分别占用的位区间互不重叠,所以按位或的效果等价于直接相加。
需要注意的一点是,MySQL的位运算结果类型取决于操作数。如果操作数是BIGINT,结果就是BIGINT。时间戳相减通常得到整数,左移22位后可能在32位整数的范围之外,所以建议在计算时把变量声明成BIGINT或者CAST成UNSIGNED,避免溢出和符号位问题。我在MySQL 5.7和8.0上都验证过,这个写法是稳定的。
2.2 毫秒时间戳获取:NOW和SYSDATE的区别要搞清楚
既然雪花ID要用到41位毫秒级时间戳,那么在MySQL里怎么拿到准确的毫秒时间?最常用的写法是:
sql复制SELECT UNIX_TIMESTAMP(NOW(3)) * 1000;
NOW(3)返回带3位小数的当前时间,比如2025-08-26 14:30:00.123。UNIX_TIMESTAMP()把它转换成Unix时间戳,也就是从1970年1月1日零点到现在的秒数,带3位小数。乘以1000以后,就得到了毫秒级的Unix时间戳。
但这里有一个非常关键的坑:NOW()返回的是SQL语句开始执行的时间,而SYSDATE()返回的是它自己被调用那一刻的时间。看这个例子:
sql复制SELECT NOW(3), SYSDATE(3), SLEEP(2), NOW(3), SYSDATE(3);
执行之后你会看到,两个NOW(3)返回相同的时间,而两个SYSDATE(3)之间相差约2秒。因为在同一条SQL语句里,NOW()在语句开始时就被固定住了,SYSDATE()则每次调用都会重新取当前时间。
在生成雪花ID的时候,这个差异会带来实际问题。如果你在一个存储过程里循环批量生成ID,使用NOW(3)会导致循环过程中所有ID的时间戳都停留在SQL语句开始的那一刻,如果循环耗时较长,不同批次的ID在时间上是错乱的。而使用SYSDATE(3)则能让每个ID都拿到自己生成那一刻的时间戳,顺序上更符合预期。
所以我的建议是:在存储函数或循环语句中生成雪花ID时,尽量用SYSDATE(3)而不是NOW(3)。如果只是单条SQL生成单个ID,两者区别不大。
2.3 起始时间选择与容量计算
雪花算法的41位时间戳不是从1970年开始算的,而是相对于你自定义的起始时间。因为41位最大能表示2^41个毫秒值,大约是69.7年。如果你从1970年开始算,到2011年就溢出了;但如果从一个接近当前时间的起始点开始算,就能往前推很久。
一个常见的选择是以某个业务上线日期作为起始时间。比如系统切到雪花ID方案的日期是2021年1月1日,那起始时间就设为1609459200000毫秒,也就是2021-01-01 00:00:00 UTC对应的Unix毫秒时间戳。这样算出来的差值在41位范围内可以使用到2090年。
如果你懒得自己算,可以用这句SQL直接获取某个日期的毫秒时间戳:
sql复制SELECT UNIX_TIMESTAMP('2021-01-01 00:00:00') * 1000;
这里也要提醒一下时区问题。UNIX_TIMESTAMP()是按MySQL当前时区解析时间字符串的,如果你的数据库时区是东八区,那么'2021-01-01 00:00:00'解析出来的UTC毫秒值就是1609430400000,和UTC时间的1609459200000差了8小时。
这会导致什么后果?如果所有节点统一用同一个起始时间,即使差8小时也没关系,因为大家差的是同一个固定值,生成的ID仍然不会冲突。但如果不同节点的起始时间配置不一致,或者服务从东八区迁移到UTC环境后起始时间变了,那就可能出现时间戳差值为负或者不连续的问题。建议在配置文件中写明确切的时间点和时区,避免后续排查麻烦。
3. 完整实现:从变量版到存储函数版
3.1 会话变量版:几条SET语句生成一个ID
最简单的方式是通过会话变量一步步计算。假设我们设定起始时间为2021年1月1日,机器ID为1,现在来生成一个ID:
sql复制-- 1. 初始化变量,建议在每次新会话时执行一次
SET @start_ts = 1609459200000; -- 2021-01-01 00:00:00 UTC,按需调整
SET @machine_id = 1; -- 机器编号,取值范围0~1023
SET @sequence = 0; -- 序列号初始值
SET @last_ts = 0; -- 上一次生成ID的毫秒时间戳
-- 2. 获取当前毫秒时间戳
SET @now_ms = CAST(UNIX_TIMESTAMP(SYSDATE(3)) * 1000 AS UNSIGNED);
-- 3. 判断序列号:同一毫秒内自增,跨毫秒重置为0
SET @sequence = IF(@last_ts = @now_ms, (@sequence + 1) & 4095, 0);
SET @last_ts = @now_ms;
-- 4. 组装ID
SET @snowflake_id = ((@now_ms - @start_ts) << 22) | (@machine_id << 12) | @sequence;
-- 5. 查看结果
SELECT @snowflake_id AS snowflake_id;
把这几段依次执行,每次执行都会得到一个新的ID。如果你连续快速执行几次,会发现前面步骤里@sequence在递增;如果中间间隔超过1毫秒,@sequence会归零重新开始。
这里的序列号计算有个很巧妙的细节:(@sequence + 1) & 4095。这个表达式把序列号限制在0到4095之间。4095的二进制是12位全1,任何数与它做按位与,结果都只会保留低12位。也就是说序列号加到4096的时候,结果自动变成0。这比用IF判断要简洁得多。
这个版本最大的问题在于,每生成一个ID都要执行4条SET语句,性能不高。如果只是手动执行、或者在客户端工具里验证,完全够用;但如果你想在一条INSERT语句里直接带入ID,那就得换成函数版本。
3.2 封装成存储函数:像调用函数一样生成ID
把变量版封装成存储函数,使用起来会顺手很多。可以像这样调用:
sql复制SELECT fn_snowflake_id(1) AS id;
函数的最终定义如下:
sql复制DELIMITER $$
CREATE FUNCTION fn_snowflake_id(machine_id INT)
RETURNS BIGINT
NOT DETERMINISTIC
NO SQL
BEGIN
DECLARE now_ms BIGINT;
DECLARE seq BIGINT;
SET now_ms = CAST(UNIX_TIMESTAMP(SYSDATE(3)) * 1000 AS UNSIGNED);
IF @last_ts IS NULL OR now_ms <> @last_ts THEN
SET @seq = 0;
ELSE
SET @seq = (@seq + 1) & 4095;
END IF;
SET @last_ts = now_ms;
RETURN ((now_ms - @start_ts) << 22) | (machine_id << 12) | @seq;
END$$
DELIMITER ;
关于这个函数,有几个点需要慎重对待。
第一个是NOT DETERMINISTIC声明。雪花ID生成器的本质就是不确定的,每次调用返回值都不同。所以这个声明是符合语义的。但MySQL在开启二进制日志时,对于非确定性的存储函数有额外限制。如果直接创建报错,需要执行下面的设置:
sql复制SET GLOBAL log_bin_trust_function_creators = 1;
这个设置在MySQL重启后会失效,正式环境最好在配置文件my.cnf中持久化写入。它的含义是允许创建非确定性的存储函数,否则MySQL会认为这类函数可能导致主从数据不一致,直接拒绝创建。
第二个是机器ID参数。函数接收一个machine_id参数,取值范围是0到1023。不同应用节点、不同会话在调用时,应该传入不同的机器ID,这样即使在同一个毫秒内多个节点生成了ID,也不会因为序列号冲突而重复。
第三个是函数内部用了会话用户变量@seq和@last_ts。这些变量是会话级别的,也就是说同一个连接内的多次调用,序列号会正确递增;但不同连接之间的变量互不相通。这既是便利也是风险:如果A连接和B连接同时传入相同的机器ID,且恰好在同一个毫秒内调用函数,它们各自从0开始递增序列号,就可能生成完全相同的ID。要避免这种情况,就必须保证每个连接使用的机器ID不同,这在多线程应用连接池场景下需要特别注意。
3.3 批量生成ID:一条SQL拿一千个备用ID
很多时候我们不是要一个ID,而是要一批ID。比如初始化数据、批量插入测试数据。这里可以直接借助存储函数配合生成序列的方式来做:
sql复制SELECT fn_snowflake_id(1) AS id
FROM information_schema.tables
LIMIT 100;
information_schema.tables在大多数MySQL实例中都有几十行甚至上百行记录,配合LIMIT可以控制生成数量。由于函数在同一SQL语句中会被调用多次,而序列号变量是会话级的,所以同一毫秒内生成的多个ID会依次递增,不会重复。
不过在批量生成大量ID时,我要提醒一句:information_schema.tables的行数可能不够多。如果需要生成1000个ID,但这个库只有100张表,那就最多只能借到100行。解决办法是使用一个专门的行数足够的辅助表,比如任意一张业务大表,或者系统库中的mysql.help_topic(MySQL 5.7自带的帮助主题表,通常有几百行)。
我自己常用的方式是这样的:
sql复制SELECT fn_snowflake_id(2) AS id
FROM mysql.help_topic
LIMIT 500;
这条SQL能一次生成500个不重复的雪花ID。如果你需要的是真正的批量插入,可以直接把它嵌到INSERT语句里:
sql复制INSERT INTO target_table (id, name)
SELECT fn_snowflake_id(3) AS id, CONCAT('test_', help_topic_id) AS name
FROM mysql.help_topic
LIMIT 100;
这样做的好处是整个插入过程在MySQL服务端完成,不需要在应用层循环生成ID再逐条插入,省掉了大量网络往返。
4. 实测对比与方案取舍
4.1 性能表现:SQL生成到底快不快
为了心里有数,我在本机MySQL 8.0.34环境下做了个简单测试。环境是普通的笔记本,没有做任何性能调优。
先测单条SELECT调用存储函数生成一个ID,连续执行1000次,平均耗时约0.28毫秒。这个耗时包括了SQL解析、函数调用和网络通信,实际在存储过程里直接调用会更快一些。作为对比,应用层生成一个雪花ID通常耗时在0.001毫秒级别,显然SQL方式在单次生成性能上差了好几个数量级。
然后测批量生成。用SELECT fn_snowflake_id(1) FROM mysql.help_topic LIMIT 500这种形式生成500个ID,总耗时约4.5毫秒,平均每个ID约0.009毫秒。为什么批量这么快?因为一次SQL请求只做一次网络往返,函数调用的开销被摊薄了。
这组数据说明什么?如果你在批量初始化场景使用SQL生成ID,性能完全不是瓶颈。但如果你在业务交易链路里每来一个请求就执行一次SQL函数调用,那额外的0.2毫秒延迟虽然单看不明显,但乘以QPS和数据库连接数,就会成为不可忽视的压力。
所以我的结论很明确:SQL版雪花ID适合批量、低并发、脚本化场景;高并发线上服务仍然应该用应用层生成方案。
4.2 与自增ID、UUID、应用层雪花方案的对比
为了让你在技术选型时有个清晰参照,我把几种常见分布式ID方案放在一张表里对比:
| 方案 | 生成位置 | 是否趋势递增 | 长度 | 重复风险 | 性能 | 典型场景 |
|---|---|---|---|---|---|---|
| 数据库自增ID | 数据库 | 是 | 4/8字节 | 极低 | 高 | 单库单表、内部表 |
| UUID | 应用/数据库 | 否 | 36字节字符串 | 极低 | 中 | 无中心化需求 |
| 应用层雪花 | 应用服务 | 是 | 8字节BIGINT | 低 | 非常高 | 高并发分布式系统 |
| SQL层雪花 | MySQL | 是 | 8字节BIGINT | 低 | 中 | 初始化脚本、存储过程、批量补数 |
从实测和原理上看,SQL层雪花方案最大的竞争力不在于性能,而在于“零代码接入”。你不需要知道应用层用什么语言,不需要引入额外的库,只需要在数据库里执行几段SQL就能产生符合规范的雪花ID。对于一次性数据迁移、测试数据构造、临时表建ID来说,这是不可替代的便利性。
至于它最大的短板,就是多会话并发下序列号不共享。如果你真的在多个数据库连接里高频并发调用同一个机器ID的生成函数,重复概率并非为零。要规避这个风险,还是要回到机器ID分配上去,让每个调用节点都用不同的机器ID。
5. 常见问题与避坑指南
5.1 连续生成的ID出现重复,怎么排查
这个问题我在本地测试时第一次遇到,表现是连续调用存储函数,ID偶尔出现重复。排查之后发现两个原因:一是函数里我把NOW(3)和SYSDATE(3)混用了,导致时间戳判断出错。二是连接池中不同连接各自维护一套@seq序列号。
如果你发现ID重复,从下面几个方向排查:
- 确认函数内部时间戳获取是否用了
SYSDATE(3)。如果用NOW(3),同一条SQL内的所有行时间戳相同,虽然序列号递增,但在跨SQL时可能出现时间戳重叠导致ID顺序混乱甚至重复。 - 确认调用函数的不同连接是否传入了不同的
machine_id。连接池的每个连接都会维护自己的会话变量,如果两个连接的机器ID相同且时间戳相同,序列号会从0重新开始,必然产生重复。 - 检查起始时间
@start_ts是否在会话之间保持一致。如果有新连接忘记初始化或者初始化了不同的起始时间,ID的时间戳部分就会错乱。
我把排查思路整理成一个简单的速查表:
| 异常现象 | 可能原因 | 解决方案 |
|---|---|---|
| 同一连接内ID重复 | 序列号未递增或溢出处理错误 | 检查(@seq + 1) & 4095逻辑 |
| 不同连接生成重复ID | 机器ID相同 | 确保每个连接传入不同machine_id |
| 同一SQL批量生成ID重复 | 函数体未正确使用用户变量 | 确认@seq在函数中正确读写 |
| 生成的ID为负数 | 时间戳差值左移后越界 | CAST为UNSIGNED或使用BIGINT |
5.2 时钟回拨问题:MySQL版本的尴尬与对策
雪花算法有一个众所周知的弱点:依赖系统时钟。如果系统时间往回调了几百毫秒,新生成的ID时间戳就会小于之前的ID,破坏趋势递增性;如果回拨超过一定范围,还可能和已生成的ID时间戳重叠。
在MySQL里用SQL生成雪花ID,同样会遇到这个问题。而且MySQL的SYSDATE()直接取自操作系统时间,没有NTP平滑校正的能力。如果服务器的时钟回拨,SQL生成的ID就会乱序。
我在实践中的处理办法是:
- 给需要生成ID的数据库服务器配置NTP时间同步,并且使用tinker模式或
slew模式平滑调整时间,而不是直接跳变。 - 如果只是脚本批量生成,且机器时钟回拨幅度很小,可以忽略,因为雪花ID只要求趋势递增,不要求严格连续;但回拨幅度超过1毫秒且存在并发时,就不要再调用函数了。
- 在函数入口处做一个简单的校验:如果
now_ms小于@last_ts,就等待当前时间追上来。虽然这不是绝对安全的方案,但能挡住大多数时钟回拨场景。
5.3 存储函数创建失败和权限问题
创建存储函数时报ERROR 1418错误,是最常见的拦路虎。错误信息大概是这样:
text复制ERROR 1418 (HY000): This function has none of DETERMINISTIC, NO SQL, or READS SQL DATA in its declaration and binary logging is enabled
原因是MySQL开启了二进制日志,而函数被判定为不确定函数。解决办法我已经在前面提过,就是设置log_bin_trust_function_creators=1。不过在配置这个参数之前,建议先想清楚你的主从架构:如果主库生成雪花ID,从库回放同样的SQL,由于函数在从库上会重新执行,主库和从库生成的ID未必一致。这在以数据一致性为核心要求的生产环境中是不可接受的。
所以我的建议是:这个方案尽量不要用在Binlog复制架构的主库上,尤其不要用在写入核心业务表的链路上。如果你的集群开启了GTID和Row格式的Binlog,风险会小一些,但依旧要谨慎。如果只是本地开发、数据补脚本、或者读写分离中从库做临时查询,那基本没有风险。
5.4 MySQL 5.6与8.0的兼容性差异
虽然雪花ID的SQL生成方案不依赖任何特定版本的独有语法,但不同MySQL版本在函数和位运算行为上仍有一些细微差别,需要留意。
MySQL 5.6对NOW(3)和高精度时间的支持是完整的,但5.6的优化器在一些复杂函数调用场景下的行为不如5.7和8.0稳定。另外,5.6的可重复读事务隔离级别下,如果消息语句较长,NOW()表现更接近固定的语句开始时间,而SYSDATE()仍然会动态变化,所以建议在函数中坚持使用SYSDATE(3)。
MySQL 8.0引入了窗口函数和公用表表达式,如果你在8.0上生成批量ID,还可以用递归CTE生成一个数字序列,再配合存储函数生成ID,比借道information_schema.tables更优雅:
sql复制WITH RECURSIVE seq AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM seq WHERE n < 100
)
SELECT fn_snowflake_id(5) AS id FROM seq;
这条SQL在8.0上执行没有任何问题,但在5.7及以下版本会报语法错误。如果你的环境同时存在5.7和8.0,在写自动化脚本时要注意区分。
5.5 序列号满溢之后怎么办
如果同一毫秒内生成的ID数量超过4096个,序列号的12位空间耗尽,(@seq + 1) & 4095会让序列号自动回绕到0。如果此时时间戳还是同一个毫秒,就会和之前生成的ID重复。
严格实现的雪花算法在序列号满了之后应该自旋等待到下一毫秒再继续生成。在SQL里实现自旋比较麻烦,但也不是完全做不到。可以利用GET_LOCK()或者临时等待函数绕过去。不过说实话,如果你真的需要在一个MySQL会话里让同一毫秒内的生成量超过4096个,说明你已经选错了方案,应该回到应用层去生成ID。SQL方案更适合批量和低并发场景,没必要在这种极端情况下硬撑。
我在实际使用中有一条经验法则:SQL生成雪花ID的合理使用上限,大约是每秒几千个。超过这个量级,要么改用应用层生成,要么换一个真正为分布式ID设计的方案。
5.6 写文件还是写表的注意事项
如果你把SQL生成的ID用来回填历史表,要注意回填顺序。雪花ID本身是趋势递增的,但如果你用ORDER BY随机顺序回填,索引碎片会变大,查询性能会受损。比较好的做法是让ID生成顺序和写入顺序保持一致,尽量保证主键索引的有序性。
另外,回填历史数据时建议分批提交,比如每1000条一次事务。如果一次性更新上千万条数据,事务日志会非常庞大,锁范围也太大,容易阻塞线上业务。这不是SQL生成ID本身的问题,而是数据迁移的通用经验,但因为它和这个方案的使用场景高度重合,所以专门提一句。
结尾
写到这里,核心的内容已经讲完了。回头看看,在MySQL中用SQL语句生成雪花算法ID,本质上并不是什么高深的技术,它就是把应用层常见的位运算逻辑翻译成了SQL语法。真正有价值的不是那几行运算,而是你对序列号管理、时间戳精度、机器ID分配、主从复制这些细节的把控。
根据我个人的实践体会,这个SQL方案最适合用在这种场景:开发环境快速造数据、存储过程内部需要分布式ID、历史数据迁移时批量回填主键。它最大的优点是不需要改代码、不需要引入依赖、一条SQL就能搞定,最大的劣势是序列号跨会话不共享,所以注定了它不适合作为线上高并发交易链路的ID生成方案。
最后再分享一个小技巧:如果你在生产环境里确实要用存储函数版本,建议把起始时间@start_ts的初始化放进一个统一的初始化存储过程,这样每个连接在连接池建立时都会执行一次,避免因为变量未初始化导致生成错误的ID。踩过几次坑之后,你会发现这类细节才是真正决定方案能否落地的关键。
