ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

分库分表后主键怎么生成?自增、雪花算法与号段模式对比

2026/9/26 12:44:07 拓冰建站 浏览量
分库分表后主键怎么生成?自增、雪花算法与号段模式对比 分库分表这件事很多团队都是被流量逼着上的。表一拆第一个绕不开的问题就是主键怎么生成。你还在用数据库自增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位符号位固定041位时间戳毫秒10位机器ID12位毫秒内序列号总共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) ) ENGINEInnoDB;每个业务线一条记录比如订单业务是biz_tag order。应用节点启动时执行一条更新语句把max_id往后推进一个stepUPDATE 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的那一天再在号段模式前面加缓存层、或者叠一组发号节点做并发预取也不迟。