几个月前我接了一个历史数据迁移的活,老系统导出的明细表没有主键,新库又要求主键用雪花算法 ID。迁移脚本跑在数据库服务器上,应用层的 ID 生成器根本调不到,机房环境又不允许我临时部署一个发号服务。当时我盯着 MySQL 命令行想了很久,最后决定直接在 SQL 里把雪花算法的生成逻辑写出来。这篇就是那次落地过程的完整记录:什么时候适合在 MySQL 里用 SQL 语句生成雪花算法 ID、标准的位运算怎么翻译成一条条 SELECT、以及哪些看起来没问题的写法会在实际环境里悄悄给你挖坑。
1. 什么时候才会需要“用SQL在MySQL里生成雪花ID”
先说句大实话:雪花算法是标准的数据结构算法,绝大多数正常业务里,它应该写在应用层代码里,而不是 SQL 里。但现实世界总会给你一些“不方便正常实现”的场景,把我逼到 SQL 里的主要是这三个。
1.1 被逼到SQL角落的三种典型场景
第一种是历史数据回填。比如你现在要合并两个老系统的数据,两个库里的主键都是从 1 开始自增的,合并到新表时必须重新生成全局唯一主键。这时候一个跑了三四年的 ETL 脚本比微服务更可靠,你在脚本里想去调用一个发号服务,网络不通、鉴权没有、代码库也没有现成的 SDK,只能就地解决。
第二种是测试数据灌库。压测平台或者 QA 环境经常需要一张几百万行的表,主键要求是雪花 ID。写一段 Java/Python 脚本去生成再导入当然可以,但如果你只是想在 MySQL 里用一条 SQL 起一个测试数据源,不想在环境里再维护一份代码,那直接在 SQL 里生成是效率最高的路径。
第三种更常见——从文件导入数据时补主键。比方说业务方丢给你一张 CSV,上面只有业务字段,没有主键,你需要把表建好再把数据导进去。传统的做法是在导入后用 UPDATE 循环补 ID,但如果导入工具本身支持 SELECT 表达式生成主键,直接在 SQL 生成雪花 ID 就能一步到位。
我自己当时遇到的属于第一种。迁移脚本用存储过程写的,跑在源库服务器上,最顺手的方式就是让存储过程在 INSERT 时直接调用一个返回雪花 ID 的函数。这种情况下“用 SQL 生成雪花 ID”不是可选方案,是唯一可靠方案。
1.2 自增ID和UUID为什么撑不住
有人可能会问,既然在 SQL 里了,为什么不用自增 ID 或者 UUID?这两个确实都能在 SQL 里实现。
自增 ID 的问题在于它是单表单库的。迁移任务里两张表都要合并,A 表自增到 100,B 表自增到 100,合并后主键就冲突了。更麻烦的是,分库分表之后每个库的自增序列都是独立的,跨库合并 ID 时你根本没法保证全局唯一。UUID 的问题则体现在两个维度:一是无序,作为聚簇索引写入时会导致 B+ 树频繁页分裂和随机 IO,在高写入压力下性能损失很明显;二是存储成本,36 个字符的字符串做主键,比 8 字节的 BIGINT 占了一倍的存储空间,索引也更大,每张关联表都要背这个成本。
雪花算法 ID 是 64 位整数,既能全局唯一,又带时间趋势,作为主键写入时比 UUID 顺序好得多,作为分表键也能直接看出来这一行数据大约是什么时候创建的。这也是为什么数据平台和业务中台里,雪花 ID 几乎成了默认主键格式。
1.3 先划清边界:这个方案不是用来扛高并发的
把话说在前面,避免误导。SQL 生成雪花 ID 的方案只适合低频、批量、脚本化的场景,比如数据迁移、测试造数、ETL 回填。如果你的业务接口每秒钟要生成上千个 ID,千万别用这个方案,否则最后一定会在序列号和并发控制上翻车。高并发场景应该让应用层进程内生成,或者用独立的发号服务/号段模式;数据库 SQL 方案更适合“一天跑一次、一次几分钟”的任务。
这个边界想清楚之后,再往下看实现就会轻松很多,因为你知道自己不是在做高并发组件,而是在写一个靠谱的脚本工具。
2. 雪花ID的位结构,跟SQL位运算怎么对上
要理解 SQL 里的实现,先得把雪花 ID 的位结构掰开揉碎。这不是多余的科普,因为你只有清楚每一段二进制是干什么的,才能在调试时一眼看出错在哪。
2.1 标准64位雪花ID的位分配
标准雪花算法生成的 ID 是一个 64 位整数,拆开来看是这样:
| 段位 | 位数 | 含义 | 取值范围 |
|---|---|---|---|
| 符号位 | 1 | 恒为 0,保证 ID 为正数 | 0 |
| 毫秒时间戳 | 41 | 相对某个自定义纪元的毫秒数 | 0 ~ 2^41-1 |
| 机器ID | 10 | 表示生成节点的编号 | 0 ~ 1023 |
| 序列号 | 12 | 同一毫秒内的递增序号 | 0 ~ 4095 |
41 位时间戳是什么意思?2 的 41 次方约等于 2.2 万亿毫秒,除以 1000 是 22 亿秒,再除以一年的秒数(31536000),大约 69.7 年。如果时间戳从 Unix epoch(1970 年 1 月 1 日)开始算,那么到 2039 年就会溢出。所以实现雪花算法时都会选一个自定义纪元,比如 2021 年 1 月 1 日,这样可以用到 2090 年前后。这里强调一下:不要直接用 Unix epoch,除非你想在 2039 年被迫重构所有主键生成逻辑。
10 位机器 ID 可以支持 1024 个不同节点,12 位序列号意味着同一台机器在同一毫秒里最多生成 4096 个 ID。如果某毫秒的请求超过 4096 个,就要等到下一毫秒再继续生成。这些上限在后面写代码时都得对上。
2.2 把位结构理解成“时间窗+窗口号+流水号”
如果你不是天天写位运算的人,可以把这个结构想成医院的挂号单。41 位时间戳是“取号时间”,精确到毫秒,但它不是绝对时间,而是相对当天零点的毫秒数;10 位机器 ID 是“窗口编号”,同一时间多个窗口在同时发号;12 位序列号是“这个窗口在这一毫秒内发出去的第几张号”。三部分拼起来,就是一个在全院范围内不会重复的挂号单号。
所以雪花 ID 的“拼接”本质上是把三段信息塞进一个 64 位整数的不同区间,每个区间各管各的,互不重叠。这种拼接在 SQL 里靠三个运算符就能完成:左移<<、按位或|、按位与&。
2.3 MySQL里做位拼接的三个操作符
左移<<是把一个数的二进制位整体往左移动,右边空出来的位置补 0。在雪花 ID 里,时间戳部分需要左移 22 位,因为后面的机器 ID 占 10 位、序列号占 12 位,加起来 22 位。相当于给时间戳腾出低位空间。
按位或|是把两个二进制数对应位做“或”操作。因为三段信息已经通过左移占好了各自的位段,互相不重叠,所以按位或可以直接把它们拼起来。这里有个细节:不重叠的位段按位或和直接相加结果是一样的,但代码里用按位或语义更清晰,一看就知道是“拼接”,不是“加”。
按位与&则是提取某一段。比如id & 4095会取出低 12 位,也就是序列号;(id >> 12) & 1023会取出中间 10 位,也就是机器 ID。这个在验证 ID 时非常有用。
我用一个简化的例子说明。假设时间戳是二进制的100101,机器 ID 是10,序列号是5,每个部分都占 6 位,拼接表达式就是(100101 << 12) | (10 << 6) | 5。左移之后,各段到了自己的位置,按位或把它们拼成一个完整的二进制串。
2.4 动手前先定好三个全局参数
实现之前先把三个参数确定下来:自定义纪元时间、机器 ID、序列号初始值。自定义纪元我建议直接用一个绝对毫秒常量,比如 UTC 时间 2021-01-01 00:00:00 对应的 Unix 毫秒数1609459200000。不要用UNIX_TIMESTAMP('2021-01-01 00:00:00')在初始化时动态计算,因为会话时区不同会导致这个函数返回不同数值,写死常量最稳。
机器 ID 在多机房场景下需要保证每台服务实例唯一。最简单的是把实例编号写进配置;如果不想写配置,可以用@@server_uuid做哈希映射,这部分我放到第 5 章细说。序列号初始值从 0 开始,保存在会话变量里。
参数定好后,就可以写实际 SQL 了。
3. 单条SQL生成雪花ID的实现拆解
现在到核心环节。我先把思路理清楚:要生成一个雪花 ID,SQL 需要完成三件事——拿到当前毫秒时间戳、确定机器 ID、维护同一毫秒内的序列号。前两件事都好办,第三件事才是真正难搞的。
3.1 最难的不是位运算,是序列号
SQL 本身是一个无状态查询语言,按一次按钮返回一次结果,它不记得上一次调用发生在哪一毫秒。但序列号的递增要求你知道“上一次的毫秒值”和“上一次的序列号”,否则同一毫秒内生成的两个 ID 可能序列号一样,导致 ID 重复。
解决方案是 MySQL 的用户变量。用户变量以@开头,属于当前会话,连接断开就消失。我可以用@last_ms记住上一次的毫秒时间戳,用@seq记住当前序列号。每次生成 ID 时,先比较当前毫秒和@last_ms:如果相同,@seq加 1;如果不同,说明跨毫秒了,@seq重置为 0。
3.2 第一步:初始化会话变量
在 MySQL 命令行或者客户端里,先把四个会话变量初始化好:
SET @start_ms = 1609459200000; -- 自定义纪元,这里表示 UTC 2021-01-01 00:00:00 SET @machine_id = 1; -- 当前节点编号,多节点时改成各自的编号 SET @last_ms = 0; -- 上次生成 ID 的毫秒值,初始为 0 SET @seq = 0; -- 当前毫秒内的序列号,初始为 0@last_ms初始为 0 保证了第一次调用时和当前毫秒不相等,序列号会从 0 开始。
3.3 第二步:完整SELECT长什么样
接下来是生成雪花 ID 的单条 SELECT。我先把可以直接跑的代码贴出来,再逐段解释。
SELECT ((t.ms - @start_ms) << 22) | ((@machine_id & 1023) << 12) | (@seq := IF(t.ms = @last_ms, @seq + 1, 0)) AS snowflake_id, @last_ms := t.ms FROM ( SELECT FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) AS ms ) AS t;注意:这段 SQL 用途是理解核心逻辑,教学目的更多一些。它没有处理同一毫秒内序列号达到 4095 的溢出问题,真正的生产版本我在第 4 章用存储函数解决。先看原理。
3.4 逐段拆解这段SQL
第一段是获取当前毫秒时间戳。NOW(3)返回带 3 位小数秒的当前时间,UNIX_TIMESTAMP(NOW(3))把它转成带小数的 Unix 秒(比如 1774500000.123),乘 1000 后得到 Unix 毫秒,最后的FLOOR取整。这里不要用UNIX_TIMESTAMP(NOW(3)) * 1000 + MICROSECOND(NOW(3)) / 1000,因为UNIX_TIMESTAMP已经包含小数部分了,再加一次会把毫秒算错。我最初就踩过这个坑,出来的时间戳总是多了几百毫秒,排查半天才意识到是重复加了。
这个毫秒值会存在子查询t中,整个 SELECT 只取一次,避免在外层多次调用NOW(3)时跨毫秒导致时间戳不一致。这是一个隐藏的坑:如果你直接在外层表达式里写两次NOW(3),第一次可能取到 12:00:00.999,第二次取到 12:00:01.000,前后一毫秒的差距就可能导致序列号错乱。
第二段是时间戳部分。t.ms - @start_ms计算从自定义纪元到现在的毫秒数,然后<< 22左移 22 位,把低 22 位腾给机器 ID 和序列号。前面说了,10 位机器 ID 加 12 位序列号正好 22 位。
第三段是机器 ID 部分。@machine_id & 1023的意思是取机器 ID 的低 10 位。1023 等于 2 的 10 次方减 1,二进制是 10 个 1,做按位与就能把超出 10 位的部分截掉。这样可以防止配置机器 ID 时不小心写成 2048 或者其他超范围数字导致位段错乱。之后<< 12给序列号腾出低 12 位。
第四段是序列号部分,也是整个 SQL 里最“黑”的一行。@seq := IF(t.ms = @last_ms, @seq + 1, 0)是一个赋值表达式:如果当前毫秒和上次的毫秒一样,@seq加 1;否则说明跨毫秒了,@seq重置为 0。这个赋值表达式会成为结果集的snowflake_id的低 12 位,和前面按位或拼起来。
第五段是@last_ms := t.ms,把这次的毫秒值记录下来,给下一次调用比较用。注意这里@last_ms的赋值会出现在 SELECT 的结果列里,但真正拼接 ID 时用的是第二段的表达式,所以执行顺序很关键。MySQL 文档对用户变量在 SELECT 中的赋值顺序并没有做严格保证,在 8.0 下某些写法甚至会被优化器改变求值顺序,所以这个单条 SELECT 版本只能作为验证算法,不建议直接放到生产。这是我一直强调用存储函数封装的原因。
3.5 先验证再放心:唯一性检查和反向解析
写完 SQL 之后,第一件事不是直接大批量生成,而是先做唯一性验证。我会在同一个会话里连续执行这条 SELECT 多次,每执行一次就记录一个 ID,然后把结果放到临时表里检查。
CREATE TEMPORARY TABLE tmp_ids (id BIGINT UNSIGNED); INSERT INTO tmp_ids VALUES (上面那个SELECT的结果); -- 重复插入若干次后 SELECT COUNT(*) AS total, COUNT(DISTINCT id) AS distinct_cnt FROM tmp_ids;如果total和distinct_cnt相等,说明至少在这个会话里没有重复。接下来做反向解析,把 ID 拆回时间戳、机器 ID 和序列号,检查时间戳是否按顺序递增:
SELECT id, (id >> 22) + 1609459200000 AS ts_ms, (id >> 12) & 1023 AS machine_id, id & 4095 AS seq FROM tmp_ids ORDER BY id DESC LIMIT 5;反向解析是排查问题的利器。比如你怀疑生成的 ID 里时间戳不对,或者机器位串位了,用这几条表达式立刻能看出来是哪一段出了问题。
4. 封装成存储函数,批量生成ID更方便
单条 SELECT 验证完原理之后,我建议立刻封装成存储函数。原因有两条:一是用户变量在 SELECT 中的求值顺序不可靠,二是你真的不想在每个 INSERT 语句里都复制那一长串位运算。封装成函数后,调用就是一个SELECT snowflake_id(),迁移脚本瞬间干净很多。
4.1 为什么要封装:用户变量的求值顺序坑
MySQL 官方文档明确说过,在 SELECT 语句中给用户变量赋值的求值顺序是不确定的。简单说,就是你不能天然地认为@seq := ...一定先于@last_ms := t.ms执行。虽然在我本机反复测试时顺序碰巧是对的,但这个行为依赖 MySQL 版本、优化器路径、甚至索引选择,生产环境里一旦被优化器换了顺序,生成的 ID 就可能错乱。
存储函数里可以声明局部变量(DECLARE),逻辑是顺序执行的语句,完全可控。这就是封装函数最大的意义。
4.2 函数实现与初始化
下面这个是完整可用的存储函数版本,处理了序列号溢出,建议直接抄。
DELIMITER // DROP FUNCTION IF EXISTS snowflake_id; CREATE FUNCTION snowflake_id() RETURNS BIGINT UNSIGNED NOT DETERMINISTIC NO SQL BEGIN DECLARE cur_ms BIGINT UNSIGNED; DECLARE result_id BIGINT UNSIGNED; -- 取当前毫秒时间戳 SELECT FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) INTO cur_ms; -- 跨毫秒则重置序列号 IF cur_ms <> @sf_last_ms THEN SET @sf_seq = 0; SET @sf_last_ms = cur_ms; ELSE -- 同毫秒内,序列号 +1 IF @sf_seq >= 4095 THEN -- 同一毫秒的 4096 个号用完了,忙等到下一毫秒 WHILE cur_ms <= @sf_last_ms DO SELECT SLEEP(0.001) INTO @sleep_ret; SELECT FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) INTO cur_ms; END WHILE; SET @sf_seq = 0; SET @sf_last_ms = cur_ms; ELSE SET @sf_seq = @sf_seq + 1; END IF; END IF; SET result_id = ((cur_ms - @sf_start_ms) << 22) | ((@sf_machine_id & 1023) << 12) | @sf_seq; RETURN result_id; END // DELIMITER ;使用之前先初始化四个会话变量:
SET @sf_start_ms = 1609459200000; SET @sf_machine_id = 1; SET @sf_last_ms = 0; SET @sf_seq = 0;然后就可以直接调用了:
SELECT snowflake_id() AS id;函数里的NOT DETERMINISTIC是必要声明,因为结果依赖NOW(3),MySQL 会以此禁止优化器对函数结果做缓存。NO SQL表示函数内部不执行增删改查,只是计算。
需要解释的是序列号溢出那段。标准 12 位序列号的上限是 4095,如果同一毫秒内已经生成了 4096 个 ID(序号从 0 到 4095),再生成就必须等到下一毫秒。代码里用WHILE循环不断重取时间戳,直到cur_ms大于@sf_last_ms。SLEEP(0.001)是休息一毫秒,避免空转把 CPU 吃满。
4.3 批量生成1万个ID的两种姿势
函数封装好之后,批量生成就很简单了。MySQL 8.0 可以用递归 CTE:
SET SESSION cte_max_recursion_depth = 10000; WITH RECURSIVE seq (n) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 10000 ) SELECT snowflake_id() AS id FROM seq;MySQL 5.7 没有递归 CTE,可以用一张数字表做笛卡尔积:
CREATE TEMPORARY TABLE seq_1k AS SELECT a.n + b.n * 10 + c.n * 100 + 1 AS n FROM (SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) a, (SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) b, (SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3) c; SELECT snowflake_id() AS id FROM seq_1k;这里有一个容易忽略的细节:存储函数内部使用的是会话变量@sf_seq和@sf_last_ms,同一个会话里连续调用会维持状态,所以批量生成时不会因为函数返回相同值而重复。如果换了一个新的数据库连接,这两个变量会变回NULL,和@sf_last_ms比较时逻辑也会正确——因为NULL <> 任何值结果为真,序列号会从 0 重新开始。
4.4 两种方案的取舍建议
单条 SELECT 版本和存储函数版本,我的建议很明确:能建函数就建函数。哪怕你觉得单条 SQL 很酷、不用改库结构,但维护成本和埋坑概率都在那里。下面是实际使用时的对比:
| 方案 | 维护成本 | 稳定性 | 适合场景 |
|---|---|---|---|
| 单条 SELECT | 高,SQL 长且依赖求值顺序 | 受优化器版本影响 | 临时验证、一次性查询 |
| 存储函数 | 低,调用简单 | 高,逻辑顺序可控 | 迁移脚本、批量造数、INSERT 中直接引用 |
5. 多机器部署的机器位分配,以及位数怎么调
真正部署到多台机器时,你会发现“机器 ID 从哪来”才是最容易出事故的地方。单机测试时写个 1 就完事,两台机器如果都写了 1,生成的 ID 就会在同一毫秒内撞车。
5.1 机器ID的三种来源
机器 ID 的获取方式,我实际用过三种。
第一种是手动配置,最靠谱也最笨。每个实例的初始化 SQL 单独写死@sf_machine_id = 1、2、3。优点是绝对可控,缺点是运维上容易漏改。多台服务器部署时,谁漏了改就会生成重复 ID。
第二种是用@@server_uuid做哈希。每个 MySQL 实例的server_uuid都是全局唯一的,可以这样映射到 0~1023:
SET @sf_machine_id = CAST(CRC32(@@server_uuid) % 1024 AS UNSIGNED);这个方案不需要人工维护,唯一的风险是取模后可能碰撞——服务器数量不多时,碰撞概率可以接受,但如果超过几十台,最好还是人工分配。
第三种是取server_id。如果你的环境里已经给每台实例配置了不同的server_id,直接SELECT @@server_id也行。但要特别注意,MySQL 默认安装的 server_id 都是 1,很多业务库根本没改过,这种情况下取出来全是一样的,不能用。
我的建议是:如果是正式环境多节点,人工分配机器 ID,并把分配表写到运维文档里。如果只是临时脚本,用@@server_uuid哈希最省事。
5.2 位数不是死的,可以调
标准雪花算法是 10 位机器 ID 和 12 位序列号。但如果你确定部署规模不大,比如只有七八台节点,那 10 位机器 ID 纯属浪费,不如把位数让给序列号,提高单节点每毫秒的吞吐。
位数分配有两个约束:机器 ID 加序列号总共占 22 位(因为 64 位里符号位 1 位、时间戳 41 位,剩 22 位)。我们可以自由组合,就像下面这样:
| 方案 | 机器位数 | 序列号位数 | 支持机器数 | 单毫秒ID数 | 适合规模 |
|---|---|---|---|---|---|
| 标准 | 10 | 12 | 1024 | 4096 | 中等规模集群 |
| 紧凑 | 5 | 17 | 32 | 131072 | 并发高但节点少 |
| 宽松 | 8 | 14 | 256 | 16384 | 中小集群通用 |
对应地,生成公式要参数化:
SET @machine_bits = 5; SET @seq_bits = 17; -- 时间戳左移 @machine_bits + @seq_bits 位 -- 机器 ID 左移 @seq_bits 位函数里判断序列号上限的4095也要改成(1 << seq_bits) - 1。这里容易漏改,很多人直接复制标准代码,然后把机器位数改了,忘了同步序列号上限,结果生成到一半就开始重复。
5.3 序列号到上限时的真实解法
标准 12 位序列号每毫秒支持 4096 个 ID。如果你所在的场景确实出现了“同一毫秒内生成超过 4096 个”的极端情况,函数里的忙等待是真实解法。SLEEP(0.001)会让当前会话睡一毫秒,然后重新取时间戳,直到进入下一毫秒,序列号归零继续。
这种方法在批量脚本场景里完全够用,因为生成任务本身不是 RT 敏感的。但如果你的系统真的高频到每毫秒都超过 4096,请回到第 1 章的忠告——这个方案本身就不该用于高并发在线场景。
5.4 多会话并发时的重复风险
存储函数内部用的是会话变量,这意味着两个不同的数据库连接同时调用snowflake_id()时,各自的@sf_seq和@sf_last_ms是互相独立的。如果两条连接在同一毫秒内都从@sf_seq = 0开始,机器 ID 又恰好相同,就会生成完全相同的 ID。
所以多会话并发时要做控制。最简单的办法是用 MySQL 命名锁把生成过程串行化:
SELECT GET_LOCK('sf_gen_lock', 10); SELECT snowflake_id(); SELECT RELEASE_LOCK('sf_gen_lock');命名锁GET_LOCK在同一 MySQL 实例内有效,跨实例无效。如果你的迁移脚本只用同一台库,这招够用;如果跨多实例分散调用,我建议还是回归到人工分配机器 ID,保证每个连接用不同的@sf_machine_id,这样即使并发也不会撞。
6. 实测踩坑:时区、版本、溢出和并发边界
最后这部分,是我在真实环境里踩过、之后每一次写这套逻辑都会检查一遍的问题。这些坑都很隐蔽,光看教程不会注意到,但一旦触发就是连续几小时的排查。
6.1 时区怪象:ID解析出来的时间差8小时
有一次我生成了一批 ID,用反向解析语句查(id >> 22) + 1609459200000转成时间,结果发现时间戳全部比当前时间晚了 8 小时。直觉上第一反应是“生成逻辑写错了”,但检查位运算又看不出问题。
后来发现,问题出在显示环节。FROM_UNIXTIME函数把 Unix 毫秒转成可读时间时,会按当前会话的时区来转换,会话时区是东八区时显示的就是东八区的时间,是 UTC 时区时显示的就是 UTC 时间。如果你在生成 ID 时用了 UTC 纪元常量,解析时却用了东八区的会话,两者的差值就是 8 小时。解决办法是在执行环境里统一时区:
SET time_zone = '+08:00';或者在解析时显式指定:
SELECT FROM_UNIXTIME((id >> 22) + 1609459200000, '+08:00') AS gen_time;另外还有一个更隐蔽的时区坑:不要用UNIX_TIMESTAMP('2021-01-01 00:00:00')去算纪元常量,因为这个函数会按当前会话时区解析字符串。同一个字符串在东八区会话和 UTC 会话里得到的 Unix 秒不一样。我建议直接写死1609459200000,并约定它就是 UTC 时间的 2021-01-01 零点,这样无论会话时区怎么变,绝对时间都是同一个点。
6.2 版本差异和位运算溢出
MySQL 5.7 和 8.0 对用户变量赋值的行为不完全一样。8.0 里的优化器会对表达式做更激进的常量替换,某些老的写法在 8.0 下可能导致@seq不递增,生成的 ID 大量重复。这就是我一直强调“别用单条 SELECT 上生产”的原因,存储函数里用局部变量和顺序执行的 SET 语句,可以避开这个版本差异。
位运算溢出问题则集中体现在旧版本上。如果 MySQL 把中间表达式当作 32 位有符号整数处理,左移 22 位就可能溢出变成负数。5.7 之后的版本基本没有这个问题,但保险起见,函数返回类型必须声明为BIGINT UNSIGNED,中间计算过程中也尽量用CAST(... AS UNSIGNED)包一层。比如时间戳部分:
SET result_id = CAST(((cur_ms - @sf_start_ms) << 22) | ((@sf_machine_id & 1023) << 12) | @sf_seq AS UNSIGNED);还有一个坑是UNIX_TIMESTAMP(NOW(3)) * 1000的返回值类型。这个表达式在 MySQL 里会返回 DECIMAL 类型,带 3 位小数,和 BIGINT 比较时没问题,但如果直接赋给 BIGINT 变量,要确保先FLOOR取整,否则 DECIMAL 转 BIGINT 的规则可能产生舍入误差。
6.3 性能实测:一次生成10万个ID要多长时间
我专门在一台 8 核 16G 内存、MySQL 8.0 的测试实例上跑过批量生成 10 万个 ID 的实验,用的是第 4 章的存储函数加数字序列表。结果大约是 18 秒,平均每秒生成 5500 个左右。这个速度对“批量造数、历史回填”是能用的,但如果你想在业务接口里实时调用,这个性能显然不达标。
如果只是临时验证逻辑,用单条 SELECT 而不是函数会快一些,因为少了函数调用开销。但我不建议为了性能放弃函数封装,迁移场景根本不在乎这十几秒,稳定性和可维护性更重要。
性能优化的方向其实不是琢磨 SQL 本身,而是换一个更适合批量生成的思路:比如用 SQL 生成一段连续的数字序列,再把 100 个作为一个批次,由应用层把序列号拼进雪花 ID 结构里。这个思路绕开了数据库逐行调用函数,速度会快很多——但那就不是“纯 SQL 生成”了,属于混合方案,看你的场景是否允许。
6.4 我现在的使用习惯
经过那次迁移之后,我现在遇到“要在 MySQL 里生成分布式 ID”的需求,基本流程就是这样:先判断场景,如果只是迁移脚本和测试造数,就建一个存储函数,机器 ID 用@@server_uuid哈希或人工分配,纪元常量写死 UTC 2021-01-01,然后让存储函数只做一件事——每次调用返回一个全局唯一的雪花 ID。如果场景是高频接口,我会直接回绝,让业务方去应用层实现,不在数据库里折腾。
还有一个小技巧,如果你要在一条 INSERT 里给多行数据生成 ID,最好的方式不是外层一个个调函数,而是先用 CTE 或数字表生成一张临时 ID 表,再 JOIN 进 INSERT。这样序列号状态的维护发生在同一个查询里,变量状态连贯,不会因为连接池切换连接导致状态丢失。这也是我在迁移脚本里最常用到的模式。