分库分表这件事,很多团队都是被流量逼着上的。表一拆,第一个绕不开的问题就是主键怎么生成。你还在用数据库自增ID,拆完之后每个库都有自己的计数器,两边同时生成出同一个ID,业务立刻出乱子。所以"分库分表自增主键方式"就成了大家搜索最多的话题之一。
这篇文章不讲虚的,直接把目前生产环境真正在用的几条路摆出来:改MySQL自增配置凑合用的过渡方案、雪花算法及其改造、美团Leaf那套号段模式,以及它们之间的硬对比。重点不是背概念,是帮你搞清楚每种方式背后的取舍逻辑。适合正在做分库分表设计、或者已经在拆表但被主键问题卡住的同学。
1. 分库分表之后,自增主键为什么成了头号麻烦
1.1 单库时代的问题:AUTO_INCREMENT的盲区
在单库单表阶段,自增主键几乎是默认选择。MySQL内部给每张表维护一个计数器,每次插入自动加一,天然有序,索引友好,业务完全不用操心主键怎么来。但分库分表一上,这个机制立刻失效——每个分片都是独立的MySQL实例,各自的计数器互不感知,都从同一个起始值开始跑。两个分片同时插入数据,都能生成ID等于100的记录,主键直接重复。
有人会说:那我用「分片编号 + 自增ID」拼一个复合主键行不行?行,但代价是后续所有查询条件都得带上分片编号,跨分片合并、归档、导出时还要拆字段处理,复杂度是持续性的,不是一次性投入。
更麻烦的是,分布式环境下我们不仅需要ID全局唯一,还需要ID尽量有序。因为大多数数据库表的主键就是聚簇索引,无序的ID会导致B+树频繁页分裂,写入吞吐骤降。用InnoDB做过测试都清楚:主键是随机UUID时,插入数据页的定位基本变成随机I/O,性能损耗30%往上走,这是实测差距,不是理论推导。
1.2 分布式主键的三个硬要求
分库分表后的主键生成方案,本质上是在三个需求之间找平衡:
- 唯一性:全局绝对不能重复,这个不用解释。
- 趋势递增:新ID比旧ID大,且最好均匀分布,保证索引空间局部性,减少页分裂。
- 高可用:发号器不能成为新的单点,一旦它挂了,所有写流量全部瘫痪。
如果只追求唯一,UUID一抓一大把就能顶,但后面的查询、索引、归档全都会很难受。这也是为什么大家宁可多花点时间设计一套ID生成方案,也不愿意拿UUID糊弄过去——分库分表本来就是为性能和扩展性上的,不能主键又把性能拖回去。
2. 最省事的过渡方案:改MySQL自增参数凑合
2.1 参数怎么配,原理是什么
如果你项目规模不大、短期不打算扩容,其实MySQL给了现成的参数:auto_increment_offset(起始偏移量)和auto_increment_increment(步长)。两个参数一配,不用改一行业务代码,就能让不同分片生成互不重复的自增ID。
以4个分片为例。步长设成4,分片0的偏移量设1,分片1设2,分片2设3,分片3设4:
-- 分片0 SET GLOBAL auto_increment_offset = 1; SET GLOBAL auto_increment_increment = 4; -- 分片1 SET GLOBAL auto_increment_offset = 2; SET GLOBAL auto_increment_increment = 4; -- 分片2 SET GLOBAL auto_increment_offset = 3; SET GLOBAL auto_increment_increment = 4; -- 分片3 SET GLOBAL auto_increment_offset = 4; SET GLOBAL auto_increment_increment = 4;配完之后,分片0生成的ID序列是1、5、9、13……分片1是2、6、10、14……分片2是3、7、11、15……分片3是4、8、12、16……合到一起全局不重复,而且每个分片内的ID仍然是自增的。这个方案最大的好处是零代码改动,DBA改两个参数重启实例就完事,特别适合救急。
2.2 这个方案的天花板在哪
但它有几个硬伤,我挨个说,你们评估的时候心里要有数。
第一个是扩容困难。假设你从4个分片扩到8个,步长就得从4改成8,但老分片上已经按步长4生成过一批ID了。新分片如果按新的偏移和步长发号,很容易跟老分片的历史ID撞车。举例:老分片2按步长4发过2、6、10,你新分片6按步长8从6开始发,立刻产生6、10这种重复ID。想不撞车,只能把历史数据重新hash分布一遍,那等于做一次全量数据迁移,生产上基本不可接受。所以这个方案实际上是「锁死分片数」的方案。
第二个是单分片存储上限。每个分片的ID空间只有全量空间的四分之一,BIGINT最大是922亿亿级别的数字,看着很大,但如果表里还混着其他业务标记、或者分片数很多,就不得不提前算清楚。一旦某分片ID用尽,这个分片就写不进去了,回补成本极高。
第三个是跨分片顺序混乱。分片0生成了5,分片1生成了2,从全局看2出现在5前面,但2实际是后插入的。如果业务依赖ID大小判断"哪个是最新一条",就会得出错误结果。这个坑在分页和"最新动态"类场景里尤其容易踩。
我的建议是:这个方案只适合数据量可控、分片数长期不变、对ID全局顺序不敏感的场景,或者拿来当临时方案,给后面切换号段模式争取时间。
3. 雪花算法:分布式ID的事实标准
3.1 64位怎么分配,每一段都算得清清楚楚
说到达分布式ID,雪花算法(Snowflake)是绕不开的。它最初是Twitter开源的方案,核心思路很清晰:用一个64位的有符号长整型做ID。
这64位的分配方式是:1位符号位(固定0),41位时间戳(毫秒),10位机器ID,12位毫秒内序列号,总共64位。
- 41位时间戳:记录从某个起始时间(Twitter用的是2010年11月4日,对应毫秒值1288834974657)到当前时刻经过的毫秒数。41位最大能表示约2199亿毫秒,换算下来是69.7年,也就是说从2010年起算,撑到2079年没问题。
- 10位机器ID:理论上支持1024个节点,实际部署时通常拆成机房ID加机器ID,比如5位机房(32个机房)加5位机器(每机房32台)。
- 12位序列号:同一毫秒内最多生成4096个ID。如果当前毫秒的序列号用完了,就自旋阻塞到下一毫秒再继续。
ID的大小关系是:时间戳在高位,所以整体上ID随时间递增;同一毫秒内的多个ID靠序列号区分先后。
3.2 一个能跑的雪花算法实现
人气最高的写法,核心逻辑其实就三块:取当前时间戳、移位、序列号自增。我贴一段Java实现,你们直接看位移运算就能理解上面说的位分配:
public class SnowflakeIdWorker { private final long twepoch = 1288834974657L; private final long workerIdBits = 10L; private final long sequenceBits = 12L; private final long maxWorkerId = ~(-1L << workerIdBits); // 1023 private final long sequenceMask = ~(-1L << sequenceBits); // 4095 private long workerId; private long sequence = 0L; private long lastTimestamp = -1L; public synchronized long nextId() { long timestamp = System.currentTimeMillis(); if (timestamp < lastTimestamp) { // 时钟回拨,需要处理 throw new RuntimeException("Clock moved backwards"); } if (timestamp == lastTimestamp) { sequence = (sequence + 1) & sequenceMask; if (sequence == 0) { // 当前毫秒序列号用尽,等下一毫秒 timestamp = tilNextMillis(lastTimestamp); } } else { sequence = 0L; } lastTimestamp = timestamp; return ((timestamp - twepoch) << 22) | (workerId << 12) | sequence; } private long tilNextMillis(long lastTimestamp) { long timestamp = System.currentTimeMillis(); while (timestamp <= lastTimestamp) { timestamp = System.currentTimeMillis(); } return timestamp; } }注意(timestamp - twepoch) << 22这行:22位是10位机器ID加12位序列号的总和,时间戳左移22位之后,低位正好留给机器ID和序列号。workerId << 12给序列号腾出12位空间。三个数字按位或拼起来,就得到了完整ID。
3.3 生产化之前必须处理的三件事
直接把原始版代码扔上线是不行的,至少还有三件事要处理。
第一件是时钟回拨。服务器时间被NTP校准或人工调整往回跳几十毫秒,生成的ID就可能比之前小,甚至出现重复。业界通用做法是:发现回拨就先自旋等待,等本地时间追平上次生成ID的时间戳;回拨幅度超过某个阈值(比如1秒)就直接报错,同时上监控告警。有些改造版会记录最后一个ID的时间戳,用拒发的方式保护唯一性。
第二件是机器ID分配。10位机器ID如果靠配置文件手写,部署一多必然出错。常见做法是应用启动时从注册中心拉取一个空闲节点ID,或者干脆在机器ID里编码「机房+业务域+机器序号」三段信息,让ID本身能反查出是哪台机器生成的,排查问题时非常有用。
第三件是注意事项的前端兼容。雪花算法生成的ID大概是1.8乘以10的18次方这个量级,已经超过JavaScript Number能精确表达的范围(2的53次方左右)。如果ID直接以数字形式传给浏览器,精度会丢失,产生一个错误但看起来差不多的数字。数据库里存的ID和前端拿到的ID对不上,查不到数据的Bug就是这么来的。解决方案是JSON序列化时把ID转成字符串,所有涉及ID传输的接口字段都定义成字符串类型。
性能方面,雪花算法本身没有任何外部依赖,生成一个ID就是几次位运算加几次内存访问,单机百万级QPS完全没压力。这也是它成为分布式ID事实标准的核心原因。
4. 号段模式:生产环境更稳的务实选择
4.1 Leaf号段模式的工作流程
雪花算法虽好,但有一个生态位覆盖不到:当ID需要跟某个业务含义绑定、或者你不想自己维护时钟回拨那套逻辑时,号段模式(segment模式)更合适。美团开源的Leaf就是号段模式的代表实现,很多公司的发号服务都是照着它搭的。
它的思路用一句话概括:数据库只负责分配"区间",应用在本地内存里从区间内逐号发放。数据库里维护一张发号表,结构大致是这样:
CREATE TABLE `leaf_alloc` ( `biz_tag` varchar(128) NOT NULL DEFAULT '', `max_id` bigint(20) NOT NULL DEFAULT '1', `step` int(11) NOT NULL DEFAULT '1000', `description` varchar(256) DEFAULT NULL, `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`biz_tag`) ) ENGINE=InnoDB;每个业务线一条记录,比如订单业务是biz_tag = 'order'。应用节点启动时,执行一条更新语句,把max_id往后推进一个step:
UPDATE leaf_alloc SET max_id = max_id + step WHERE biz_tag = 'order';更新完成后读一下max_id,假设更新前是1000,更新后是2000,那这个节点就拿到了1001到2000这个区间的发号权。它在内存里从1001开始,每发一个ID就加一,发到2000之后,再走一次上面的UPDATE申请下一段。
4.2 step怎么定,双buffer怎么理解
号段模式的好处是数据库压力被摊薄了:一次申请能扛住step个ID的流量。把step设成1000甚至5000,一个发号表支撑的QPS就能到几万几十万。应用本地发号,没有任何RPC调用,性能接近雪花算法。
step的取值是个权衡。设小了,数据库更新频繁,单表扛不住并发;设大了,应用重启时会丢掉本段未发完的ID,造成ID空洞累积——数据库已经把max_id推进到2000了,但应用只发到1500就重启,那1501到2000这批ID就永远空着了。这块空洞不影响唯一性,但如果你业务要求ID严格连续(比如票据号),号段模式直接不适合。
Leaf还做了双buffer优化:当前段剩余ID低于某个阈值(比如10%)时,后台线程提前把下一段申请好,避免发完再等数据库的间隙。这样即使数据库偶尔抖一下,应用也有缓冲段顶着,不会出现发号断流。
4.3 发号表的单点风险与高可用
号段模式最大的坑是发号表单点。所有业务的ID都依赖这一张表,这张表挂了,全链路写流量全部雪崩。生产上必须做高可用:最简单的做法是给发号表配主从,让发号服务独立部署成集群,用多节点加数据库主备的方式来扛。
更稳妥的做法是发号服务单独抽出来部署,对外提供HTTP或RPC接口,业务方不直接碰数据库。这样发号表的连接数可控、权限可控、扩容也方便。代价是引入一个中间件服务,团队得有对应的运维能力。
我倾向于把号段模式作为默认选项,原因是它对业务友好:ID趋势递增且基本连续,DBA能看懂,应用也好排查问题。运维成本一次投入之后,收益是长期的。
5. 方案硬碰硬:关键维度的对比与选型建议
5.1 六个维度的横向对比
三种方案在关键维度上的差异,直接看表:
| 维度 | 自增步长方案 | 雪花算法 | 号段模式(Leaf) |
|---|---|---|---|
| 全局唯一 | 是(分片数不变时) | 是 | 是 |
| 全局趋势递增 | 弱,跨分片顺序不保证 | 强,按时间递增 | 强,段内连续 |
| 单点风险 | 低,各分片独立 | 无外部依赖 | 发号表是单点,需高可用设计 |
| 性能 | 取决于分片库本身 | 最高,纯位运算 | 高,依赖step配置 |
| 分片扩容 | 极难,基本要迁移数据 | 容易,机器ID重分配 | 容易,新增biz_tag即可 |
| 实现成本 | 零 | 中,需处理时钟回拨和机器ID | 高,需维护发号服务 |
| ID能否反解析 | 否 | 可解析时间戳和机器 | 否 |
5.2 按团队规模和场景选型
我给几条比较实在的建议,你们对照自己的处境选:
- 三五人小团队、数据量还没到非拆不可的程度:先用自增步长方案顶着,零成本,把号段模式留作演进方向。别一上来就上发号中间件,运维成本后期会吃掉你的开发精力。
- 有专门中间件或基础架构团队、集群规模较大:优先上号段模式。运维可控、时序明确、业务好理解,分片扩容就是加一条
biz_tag的事。 - 日志类、消息类高吞吐场景,对ID趋势递增要求不苛刻:雪花算法最省心,不引入额外中间件,性能最高。
- ID需要参与分片路由:号段模式更好,可以按分片维度预分配号段,让ID直接携带分片信息。
我在三个项目里分别用过这三种方案,体验是:中小团队最稳的路径是直接上号段模式,别在自增步长上磨太久,也别在雪花算法上过度设计。理由我放到后面说。
6. 实战教训:我在这条路上踩过的坑
6.1 时钟回拨引发的线上事故
第一个坑就是雪花算法的时钟回拨,发生在某次机房NTP校准期间。时间回跳了大概200毫秒,我们的生成器直接抛异常——初始版本代码写的是"发现回拨就抛RuntimeException"。因为发号是同步调用的,主键生成失败直接导致下单接口报错,线上告警刷屏,严重性直接被拉满。
后来整改成:回拨在1秒以内用自旋等待,等时间追上来;超过1秒降级为「时间戳加偏移量」的方式生成,偏移量按回拨幅度动态补,保证生成的ID不回退。同时把降级事件打进监控日志。这里有个细节:降级期间生成的ID虽然不回退,但会跟正常时间线产生一个跳跃,业务排序要能容忍这种跳跃。
6.2 固定步长的扩容灾难
第二个坑是自增步长方案的扩容灾难。有个老项目上线时用了4个分片,步长设4。一年后业务翻倍,要扩到10个分片,结果就像之前分析的那样:历史ID全跟新分片的序列撞车,最后只能停服做数据重新hash分布,线上停了40分钟。老板的脸是黑的。
如果当时直接上号段模式,扩容就是给新分片预分配一段ID区间,然后改一下分片路由规则,老数据完全不用动。这40分钟就是当时贪图"配置一下就行"的代价。
6.3 订单号反查分片:ID里要藏路由信息
第三个坑是订单号反查分片。我们订单表是用用户ID做分片键的,这本身没毛病。但客服系统那边经常拿订单号来查库,而订单号里没有编码任何分片信息,导致每次查询都是全分片广播扫描。数据量小的时候还能忍,数据量上来后,一次反查能拖垮好几个分片。
后来我们改成了号段模式,并且按分片维度预先划分号段:比如分片0用1001到2000这个区间,分片1用2001到3000,这样拿到订单号就能立刻算出它属于哪个分片,反查直接落库。这个设计如果一开始没做,后面补的时候要把所有存量订单号都重新生成或者做映射表,痛苦程度远超想象。
6.4 事务里不要远程发号
还有一个容易被忽略的细节:发号和插入事务的耦合。无论用哪个方案,都不要在数据库事务内部远程获取ID。一次插入要多一次网络RTT,事务时间被拉长,锁竞争和死锁概率都会上升。
正确做法是事务外先本地生成好ID,再作为参数传进事务执行。这一点在号段模式下尤其容易做到,因为本地发号本身就是内存操作;如果你用的是远程发号服务,就一定要先取好ID再开事务。
6.5 前端数字精度丢失
前面提过的JavaScript精度问题,在实战中也确实踩到过。客服后台用的前端框架把订单号直接当数字渲染,订单号是雪花算法生成的18位长整数,超出JavaScript安全整数范围,解析之后末尾几位变成0。用户在详情页怎么都查不到订单,排查了一个多小时才发现是精度丢了一位。
从那以后我们定了规矩:所有长整型ID在接口层统一转成字符串输出。这个改动很小,但省掉了无数个"订单怎么查不到"的工单。号段模式因为ID通常从1开始慢慢涨,短期内不会碰到这个问题,这也是它在业务系统里更省心的一个隐性优势。
如果让我给一个结论,我不会说"哪个方案最好",我只会说:分库分表的主键方案不是一个纯粹的算法问题,而是一个跟分片策略、扩容计划、团队维护能力强耦合的工程问题。先想清楚未来一年分片数会不会变,再决定上哪种方式。以我个人的体会,号段模式普遍适用,因为它在时序性、扩展性和运维成本之间拿到了一个最舒服的平衡点。真到了要支撑千万级TPS的那一天,再在号段模式前面加缓存层、或者叠一组发号节点做并发预取也不迟。