
最近在做订单中心重构老项目的自增主键在分表之后让人头疼跨库存量一大就撞车。原来想引进一套独立的发号器可为了几个ID就把Redis或者ZK请进来运维成本确实有点重。后来我想雪花算法的位结构其实很规整既然业务表就在MySQL里能不能直接让MySQL自己算ID试了一圈还真能跑而且用一条SQL就能搞定。这篇文章就把我在MySQL里用纯SQL实现雪花算法ID的完整过程写出来包括位结构拆解、两种可落地的生成方案、并发踩坑和验证手段给准备在数据库层解决分布式ID问题的同学一个可参考的思路。适合这几类人不想为上号器单独维护组件的中小团队、DBA 想给研发提供无侵入的取号接口、以及纯粹对雪花ID实现细节感兴趣的后端开发。看完你至少能拿到一份可复制的存储函数代码并且知道在什么并发量下它靠谱、什么量级下应该换方案。1. 雪花ID的位结构设计者到底在64位里塞了什么1.1 一段可解析的64位整数雪花算法的核心不是“随机”而是把ID做成一个“能反推出生成时间、机器编号、并发序号”的复合结构。标准版本是一个64位有符号长整型从高位到低位分成四段第0位符号位固定为0保证整个ID是正数避免出现负数ID带来的一堆兼容问题。第1~41位41位时间戳记录生成时刻与某个自定义起点twepoch之间的毫秒差。41位最多表示约69年的时间跨度业务上完全够用。第42~51位10位机器ID记录生成节点编号通常拆成5位数据中心和5位工作节点最多支持1024个节点。第52~63位12位序列号记录同一毫秒内生成的第几个ID范围0~4095支持单节点单毫秒最多4096个ID。你可以把它理解成一次发牌时间戳决定了ID从第几毫秒来机器ID决定了是哪台机器发的牌序列号定死了这一毫秒里的第几张牌。三者用位运算拼起来整个ID还能大致看出生成时刻这点对排查问题非常友好。1.2 41位时间戳的边界效应新手容易忽略一个细节41位时间戳存的不是完整毫秒时间而是“当前毫秒值减去起点毫秒值”的差值。选不同起点直接影响算法能用到哪一年。比如起点选2000年这个方案撑不到2040年就溢出了。我一般把起点定在2021年1月1日也就是1609459200000毫秒算下来能用接近七十年。起点越近留给未来的时间余额越充足但代价是起点之前的时间无法生成ID。真正生产环境只需要保证服务器时间与起点之后对齐不会有问题。1.3 机器ID与序列号的权衡空间10位机器ID和12位序列号并非雷打不动。如果你只有几十台机器完全可以匀出更多位数给序列号提升单节点吞吐。比如把机器ID改为5位支持32节点序列号扩到17位单节点单毫秒就能支持131072个ID吞吐能力翻了好几倍。反过来如果ID要兼容历史系统或者有特殊排序需求也可以调整位数分配但务必留足序列号余量。实际项目中我见过一种很实用的做法把机器ID从参数传给生成函数而不是写死在代码里。这样同一套函数在A库部署时传3、在B库部署时传5不用改函数体只需要约定每个节点编号唯一即可。后面给出的SQL方案就采用这个思路把机器ID做成函数入参。2. MySQL写雪花ID前这几项前置知识必须搞清楚2.1 MySQL位运算左移、右移、与、或纯SQL拼雪花ID离不开位操作。MySQL对整型的位运算支持其实很到位常见操作符有四个。左移a n等价于把a的二进制整体向左移动n位低位补0。拼接时间戳时需要把毫秒差值左移22位给机器ID和序列号腾位置。右移a n等价于把a的二进制整体向右移动n位。解析ID时从低到高逐段还原靠的就是右移加掩码。按位与a b两个二进制位都是1时结果位为1。解析时用id 4095取低12位序列号用(id 12) 1023取中间10位机器ID。按位或a | b任一二进制位为1时结果位为1。最后拼接三段数据就是通过或运算把三块二进制合并成一个64位整数。只要记住“左移腾位、或运算拼接、与运算取段”位运算部分就算过关了。算好各段偏移量代码写起来和切豆腐一样。2.2 用户变量和LAST_INSERT_ID()的冷门技巧SQL方案里最难处理的不是时间戳拼接而是“同一毫秒内序列号如何自增”。一种容易想到的做法是查一张序列表然后加1。但读改写三步在并发下很容易出事要么重复要么需要锁表性能还差。网上有一种巧妙的做法利用MySQL的LAST_INSERT_ID(expr)特性这个函数接受参数时会像AUTO_INCREMENT生成主键一样把参数值记录为本次会话的“下一个自增值”之后调用LAST_INSERT_ID()能取回这个值。配合一条更新语句就能实现无锁式序列自增UPDATE snowflake_seq SET id LAST_INSERT_ID(id 1);这条语句先把当前行的id值加1写回去同时把id 1注册为会话自增值。紧接着执行SELECT LAST_INSERT_ID()就能拿到更新后的序列值。整个过程由MySQL内部保证不需要额外加锁。这是整个SQL方案里含金量最高的一段很多文章都没有点透建议实操时反复体会一下。2.3 BIGINT的符号位陷阱MySQL的BIGINT默认是有符号的取值范围是-9223372036854775808到9223372036854775807。雪花ID占满64位若第0位是1整个ID就会被MySQL当成负数。负数ID在排序、展示、接口联调时非常讨人厌所以方案里必须保证拼出来的ID最高位始终为0。好消息是只要时间戳差值不超过41位左移后的范围最高位一定是0。真正危险的场景是服务器时间严重超前于自定义起点导致差值溢出41位。有人把起点选得过于靠近当前时间然后故意用负数调用。某些变种算法把符号位也用作数据位。实操时我习惯在生成函数末尾对符号位做一次防御性检查如果返回值小于0强制返回当前时间戳的低41位重新拼接。之后也给了排查方法后面第6节详细讲。2.4 存储函数的创建权限MySQL对存储函数有一个让新手困惑的报错ERROR 1418 (HY000): This function has none of DETERMINISTIC, NO SQL, or READS SQL DATA in its declaration and binary logging is enabled原因很简单开启了binlogMySQL不允许创建一个无法确定“是否修改数据、是否确定返回”的函数否则主从复制时无法正确重放。解决方式是在创建函数时显式声明特性或者在MySQL配置中打开信任开关SET GLOBAL log_bin_trust_function_creators 1;这个开关需要足够权限并且它是全局的。如果公司DBA管得严更推荐在CREATE语句中显式声明函数特性既过了MySQL的校验也不影响主从复制。后面给出的完整代码中我直接写了DETERMINISTIC严格讲这个函数并不是纯确定性的但这是MySQL校验机制和实际需求之间的常规折中你完全可以在声明里改成READS SQL DATA。这一点我在常见问题里还会展开。3. 第一种落地姿势轻量表达式方案一条SELECT直接生成3.1 最简SQL长什么样如果只是临时脚本、测试环境或者业务本身就是单线程批量处理不需要引入存储函数一条SELECT就能生成雪花ID。先初始化会话自增序列SELECT LAST_INSERT_ID(0);然后每执行一次下面这条SQL就得到一个雪花IDSELECT ((FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) - 1609459200000) 22) | (1 12) | (LAST_INSERT_ID(LAST_INSERT_ID() 1) % 4096) AS snowflake_id;你可能会问LAST_INSERT_ID(LAST_INSERT_ID() 1)是什么黑魔法它拆开来看内层LAST_INSERT_ID()先取出当前会话的自增值加1后作为参数传给外层LAST_INSERT_ID(expr)注册为新的自增值整个表达式的值就是expr本身。所以每次执行这条SELECT序列号都会步进1。再对4096取模就得到12位序列号。3.2 轻量方案的边界这个方案胜在短小精悍不建表、不建函数复制到命令行就能跑。但它有一个致命的短板序列号状态保存在“会话用户变量”里也就是每个数据库连接各自维护一套。两个连接同时执行这条SQL拿到的序列号可能是重复的。所以它只适合下面几种场景单连接、串行执行的初始化脚本。为测试表批量造数据不需要高并发。想快速验证雪花算法语义不想建任何对象。如果同一个业务库有多个连接在并发写入直接套用会撞ID。真正想在生产环境用必须把“序列号来源”从会话变量换成一个所有连接共享的存储载体这就引出第二种方案。4. 生产可用方案带序列表的存储函数实现4.1 整体设计思路数据段怎么拼第二种方案的思路是用一张只有一行数据的序列表配合LAST_INSERT_ID(id 1)技巧让所有连接共享同一个自增源头。函数负责取时间戳、取序列、拼位段最后返回BIGINT。整体拼装公式按位来ID (毫秒时间戳差值 22) | (机器ID 12) | (序列号 % 4096)三段分别占用41位、10位、12位加起来正好63位有效载荷最高位符号位空出符合雪花算法的标准结构。4.2 建序列表并初始化先建一张极简的序列表它只有两列一个业务主键id固定为0一个自增序号生成列。CREATE TABLE IF NOT EXISTS snowflake_seq ( id TINYINT NOT NULL DEFAULT 0, value BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINE InnoDB; INSERT INTO snowflake_seq VALUES (0, 0) ON DUPLICATE KEY UPDATE value value;为什么只放一行这张表的使命就是“所有连接抢同一把锁做原子自增”一行足矣行数越多反而让序列号来源不统一。value列从0开始每调用一次函数就加1取模后作为序列号。除非每秒百万级写入否则一个BIGINT的余量足够用到天荒地老。严格地说这张表还可以加一个last_ms列来记录上一次使用的时间戳用于检测同一毫秒内序列耗尽或时钟回拨。我先给最简单版本把回拨处理放到第6节讲避免一开始就陷入边界条件。4.3 完整函数代码与逐段解读下面是生产可用的存储函数全量代码DROP FUNCTION IF EXISTS snowflake_id; DELIMITER $$ CREATE FUNCTION snowflake_id(p_worker_id BIGINT) RETURNS BIGINT DETERMINISTIC BEGIN DECLARE v_ms BIGINT DEFAULT 0; DECLARE v_worker BIGINT DEFAULT 0; DECLARE v_seq BIGINT DEFAULT 0; DECLARE v_twepoch BIGINT DEFAULT 1609459200000; DECLARE v_result BIGINT DEFAULT 0; -- 第一步取当前毫秒时间戳并减去自定义起点 SET v_ms FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) - v_twepoch; -- 第二步机器ID限定在10位范围内 SET v_worker p_worker_id 1023; -- 第三步从共享序列表获得本次会话的下一个序列号 UPDATE snowflake_seq SET value LAST_INSERT_ID(value 1); SET v_seq LAST_INSERT_ID() % 4096; -- 第四步三段拼接得到最终雪花ID SET v_result (v_ms 22) | (v_worker 12) | v_seq; RETURN v_result; END$$ DELIMITER ;逐段解释一下第一步UNIX_TIMESTAMP(NOW(3))返回带三位毫秒精度的秒值FLOOR(... * 1000)转成整数毫秒再减去起点毫秒1609459200000得到参与位运算的毫秒差值。这里用FLOOR而不是ROUND是为了避免毫秒进位导致时间戳跳跃。第二步p_worker_id 1023相当于对1024取模把外部传入的机器ID强制约束在0~1023范围内防止有人传入一个超出10位的数字把高位位段污染。第三步前面说的原子自增技巧。每次调用value列都会递增所有连接通过InnoDB行锁串行化这里的更新不会出现两个连接拿到同一个序列号。第四步位运算合并三段。 22和 12的偏移量必须和位段定义严格对应一个偏移写错整个ID结构就乱了。调用方式很简单SELECT snowflake_id(1);在INSERT语句里也可以直接嵌入INSERT INTO order_info (order_id, user_id, amount, create_time) VALUES (snowflake_id(1), 10086, 99.00, NOW());4.4 序列号毫秒内耗尽怎么办标准雪花算法要求单节点单毫秒最多4096个ID。如果某一次的突发流量特别大同一毫秒内调用了第4097次这里的v_seq LAST_INSERT_ID() % 4096会把序列号轮回归零。时间戳没变、序列号又回到0就可能生成重复ID。处理这个问题的常见思路是检测到同一毫秒内序列号已经用了4096个就让当前线程“蹭”到下一毫秒再生成。问题是纯存储函数里难以保存“上一次的毫秒值”所以简单版本选择了“取模轮回容忍风险”。如果业务不能容忍毫秒级极端情况可以把序列表扩展一行状态字段保存上一次使用的毫秒值。每次生成时把当前毫秒与上次毫秒比对序列号归零且毫秒相等时就调用SLEEP(0.001)蹭到下一秒不相等就正常生成并更新last_ms。这个方案在函数里写起来完全可以实现代价是多一次读表性能损耗不大。我在很多生产表上直接用了“取模轮回”版本因为单库单表真正达到每秒409万次生成的场景极少MySQL自己的写入吞吐早就先顶到天花板了。如果真到了那个量级你该考虑的不是继续优化这个SQL函数而是把ID生成挪到应用层用内存批量预取。4.5 批量生成一批ID的SQL技巧实际业务还有一个高频需求一次插几千行或几万行数据每行都要一个ID。最简单的是在INSERT的VALUES里反复调用函数INSERT INTO order_info (order_id, user_id) VALUES (snowflake_id(1), 101), (snowflake_id(1), 102), (snowflake_id(1), 103);把函数调用写在VALUES里是合法的MySQL执行时会逐行调用序列号会连续步进。但如果一次生成几万条SQL文本会变得非常长而且每条函数调用都要执行一次UPDATE总体耗时偏长。更推荐的批量姿势是利用一条SELECT驱动批量生成INSERT INTO order_info (order_id, user_id, create_time) SELECT snowflake_id(1) AS order_id, rn : rn 1 AS user_id, NOW() FROM ( SELECT rn : 0 ) r, information_schema.columns LIMIT 10000;这段SQL的核心是借information_schema.columns这张系统表充当行数发生器配合用户变量rn生成1~10000的编号。每行调用一次snowflake_id(1)序列表也只会被顺序更新一万次整体效率比逐条INSERT好得多。注意这里LIMIT和系统表行数之间要留足余量否则行数发生器不够用。实际项目里也可以建一张专门用于行数扩充的辅助表比系统表更稳。5. 生成之后怎么验证唯一性检查、ID反解、排序规律5.1 唯一性检查最基础也是最重要的验证函数上线前我会先在临时表里批量生成一批ID然后用一条聚合SQL验证是否重复CREATE TEMPORARY TABLE tmp_ids (id BIGINT PRIMARY KEY); INSERT INTO tmp_ids SELECT snowflake_id(1) FROM information_schema.columns LIMIT 100000; SELECT COUNT(*) AS total_cnt, COUNT(DISTINCT id) AS unique_cnt, COUNT(DISTINCT id) / COUNT(*) AS unique_ratio FROM tmp_ids;unique_ratio必须等于1。只要出现一丁点小于1的情况说明序列号或者时间戳发生了碰撞需要立即排查序列表状态和函数逻辑。这个临时表验证流程建议在每次改完函数后都跑一遍特别是改了位偏移量或者序列获取方式之后。5.2 ID反解工具从雪花ID还原三段信息雪花ID最大的魅力之一是“可反解”。遇到线上疑难数据反解出生成的毫秒时间、机器编号和序列号往往能直接定位是哪台机器在哪个时间点生成的。反解SQL非常简单SELECT id, ((id 22) 1609459200000) AS generate_ms, ((id 12) 1023) AS worker_id, (id 4095) AS seq FROM tmp_ids LIMIT 5;(id 22)把高位时间戳段挪到最右侧加上起点毫秒就是完整的时间戳。(id 12) 1023取中间10位机器ID。id 4095取低12位序列号。还可以顺手转成可读时间SELECT FROM_UNIXTIME((((id 22) 1609459200000) / 1000)) AS generate_time FROM tmp_ids LIMIT 5;这套反解SQL不管是排查问题还是做数据血缘分析都非常实用。建议把它封装成一条固定视图线上随时查。5.3 排序规律不是严格自增但趋势递增雪花ID号称“趋势递增”不代表每相邻两个ID都严格递增。原因是同一毫秒内序列号在递增没问题但跨毫秒时如果上一毫秒的序列号恰好接近4096而当前毫秒的序列号从0重新开始且时间戳段增长量又不大可能出现后生成的ID数值比先生成的略大但不连续的情况。更准确地说雪花ID在“毫秒时间戳升序”这一层决定了整体向上趋势但在同一毫秒边界处不能保证严格单调递增。大多数业务场景比如订单按创建时间倒序排列、分页查询、索引页分裂等趋势递增已经足够了。如果你要求绝对严格递增需要在生成层额外记录上一毫秒的ID值发现新ID不大于旧ID时主动等待或取反。这个需求在纯SQL方案里也能实现但复杂度会上升非必要不建议。6. 并发、回拨、重复常见问题与排查实录6.1 创建函数报1418错误怎么办现象是创建函数时报错提示函数没有DETERMINISTIC、NO SQL或READS SQL DATA其中之一。原因在2.4节里说过本质是binlog开关和函数确定性校验之间的限制。优先解决办法在创建函数时显式加上声明比如我的代码中保留了DETERMINISTIC或者换成READS SQL DATA。如果你所在团队不允许在主库创建这类函数需要走变更审批流程提前把SQL提交给DBA评估。如果只是为了本地开发调试执行一次SET GLOBAL log_bin_trust_function_creators 1也行。注意这个操作要全局权限并且它会让MySQL信任所有函数的创建者。我一般只在测试库开生产库还是走显式声明。6.2 生成出来的ID变成负数排查步骤分两步。第一步看时间戳差值是否溢出SELECT FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) - 1609459200000如果结果超过2^41左右说明服务器时间异常或者起点设置过于久远。第二步检查拼接结果去掉低12位序列号单独看(v_ms 22) | (v_worker 12)是不是负数。如果这一步就负了说明41位时间戳段已经溢出把起点调近或者同步系统时间即可。我踩过的坑是把v_twepoch误写成一个很大的毫秒值时间戳差值就变成了负数左移后符号位直接被占满返回的ID全是负数。后来又发现服务器时间被运维调快了五分钟也导致类似现象。遇到负数ID优先怀疑“时间”这个方向大概率没错。6.3 不同连接生成的ID出现重复序列号依赖共享序列表所以只要多个连接都从同一张表取序列理论上是安全的。但有一种隐蔽情况会造成重复有人把序列表的数据复制到了另一个环境两个环境里的value值一样生成出来的ID就会撞。所以分库分表后每个分库必须使用不同的机器ID并且序列表的初始值也要隔离。另一个隐蔽场景是测试环境多套库共用了同一个序列表备份文件机器ID又配的一样一顿操作下来重复率惊人。排查思路很简单反解ID里的worker_id看看是哪个节点的产物再单独查序列表的value是否在两套库中一致。6.4 时间回拨导致ID回退或冲突服务器时钟往前跳也就是所谓的“回拨”会让将来的ID可能小于已生成的ID最直接的后果是唯一索引撞车。纯SQL层面能做的处理相对有限一个实用思路是以序列表中保存的last_ms为参考如果当前毫秒值小于等于上次的毫秒值就强制把时间戳推进到last_ms 1。相当于把时间基准从操作系统时钟切换为“数据库生成器内部时钟”。这样即使服务器时间回拨ID也能保持时间戳单调直到回拨幅度超过可容忍范围。具体的实现要点是给序列表增加一列last_ms在函数里先读后写。并发下必须保证读和写是一个原子操作我的建议是直接把它和value放在同一条UPDATE里例如UPDATE snowflake_seq SET value LAST_INSERT_ID(value 1), last_ms GREATEST(last_ms, v_ms) WHERE id 0;这条语句既更新了序列值又用GREATEST把last_ms单调托底。函数内部再根据新旧last_ms判断是否需要回拨修正。这个补丁逻辑能扛住大部分偶发回拨场景但如果是运维手动改时钟跳了几分钟最好的方案还是停写、校准、再恢复算法层面的修正只能兜小概率抖动。6.5 批量INSERT多次调用函数序列号出现断层有同学反馈一次INSERT多行数据生成了100条ID序列号并不是0~99而是中间跳了很多。这不是bug。原因有两个层面函数内嵌在SELECT或INSERT语句里MySQL执行计划可能因类型推导或者子查询展开而调整函数的实际调用次数和顺序。LAST_INSERT_ID(id 1)风格的自增在同一个语句内多次调用时MySQL并不保证和语句文本顺序完全一致。解决思路把“取一批序列号”和“生成ID”拆成两步。先一次申请N个序列号再逐行拼接。用存储过程或者应用层循环都能规避这个不确定性。如果你只是在INSERT VALUES里写十个函数调用实测下来绝大多数MySQL版本是顺序执行的但一旦提升到几百行就不建议依赖这个行为了。6.6 性能瓶颈在哪里很多人担心这个方案会拖垮数据库。实测下来单条INSERT里嵌入一次函数调用对OLTP业务影响几乎可以忽略。真正的瓶颈在于批量生成时每生成一个ID都要执行一次UPDATE snowflake_seq虽然只是更新一行但行锁竞争和事务提交开销会被放大。如果压测发现批量生成很慢优先考虑两个优化方向。一是把序列表换成内存表或者NIP表减少刷盘开销二是应用层做“预取段”一次性从数据库申请1000个序列号缓存在应用内存里用完再取下一批。这样数据库只需每1000次请求执行一次UPDATE性能提升非常明显。配合存储过程批量申请序列段这个方案就升级成了适合更高并发场景的版本。下面给一个简单的批量申请序列号的存储过程框架它一次把p_num个序列号的基础值取回来后续由应用层自行拼装IDDROP PROCEDURE IF EXISTS seq_fetch; DELIMITER $$ CREATE PROCEDURE seq_fetch(IN p_num INT, OUT p_seq_start BIGINT) BEGIN UPDATE snowflake_seq SET value LAST_INSERT_ID(value p_num); SET p_seq_start LAST_INSERT_ID() - p_num 1; END$$ DELIMITER ;调用后拿到起始序列号再按“(序列号i)%4096”拼入ID即可。这种预取思路让数据库层的压力从“每ID一次更新”降为“每批一次更新”实测在十万级数据导入时省掉了大量行锁竞争是目前这套方案能支撑更高吞吐的关键补丁。7. 快速参考几张对比表和避坑清单7.1 两种方案怎么选维度轻量表达式方案存储函数序列表方案建表/建函数不需要需要序列表和存储函数跨连接唯一性不保证通过序列表行锁保证部署成本极低中等性能高中等受行锁影响适用场景单线程脚本、测试造数生产环境的并发业务写入扩展空间几乎为零可加时钟回拨保护和批量预取如果只是临时跑脚本用轻量表达式。如果是要给线上业务提供服务直接上存储函数序列表方案别在临时方案上浪费时间。7.2 避坑清单位偏移量写错是最大的坑务必用反解SQL验证位段。机器ID必须全局唯一分库环境殊为重要。时间起点twepoch要选近一些否则提前溢出。开启binlog环境创建函数记得处理log_bin_trust_function_creators。批量生成时不要把函数调用写进超大VALUES里拆成“预取序列号应用拼接”。最后说两句实在话我自己在这套方案上踩过不少坑最深的感受是雪花ID的难点从来不是“64位怎么拼”而是“序列号的来源在并发下怎么保证不重”。MySQL的LAST_INSERT_ID(id 1)技巧是整套方案的点睛之笔把这个机制吃透后其他都是顺水推舟。另外也不要神化这套SQL实现。它最适合的场景是中小业务规模、不想引入额外发号组件的团队。一旦你的业务真正到了每秒数万甚至数十万写入更好的选择仍然是应用层批量预取序列号甚至用专门的发号服务。但把数据库当成一个“自带发号能力”的组件遇到瓶颈时你至少知道往哪个方向改造而不是一头雾水重写整套ID策略。