分库分表后ID冲突怎么办?一口气给你说出5种方案!
为什么分库分表之后 ID 会冲突?
单库单表的时候,主键用数据库自增 ID 很省心。AUTO_INCREMENT 帮你管好一切,每条记录的 ID 绝对不会重复。
但一旦做了分库分表,麻烦就来了。假设你把 order 表拆成了 4 张,分别存在 db_0 和 db_1 两个库里。每张表的自增序列是独立的,db_0 的 order_0 表自增到了 1000,db_1 的 order_2 表也自增到了 1000。ID=1000 就同时出现在两个地方,根本无法区分哪条是哪条。
一旦要做跨分片查询,或者后续数据迁移,这些重复 ID 就是定时炸弹。
所以核心问题是:ID 的生成必须脱离单张表,改成全局统一的方式。
解决这个问题的方法不少,各有各的适用场景。下面一个一个讲。
方案一:UUID
最简单粗暴的思路,直接用 UUID 做主键。UUID 是 128 位的,生成规则保证了不会重复。代码一行就能搞定:
String id = UUID.randomUUID().toString();
// 结果类似:550e8400-e29b-41d4-a716-446655440000听起来很美,但实际用起来问题不少。
第一个问题是存储。UUID 带连字符是 36 个字符,就算去掉连字符也有 32 个。MySQL 用 VARCHAR(32) 存,比 BIGINT 占用的空间多好几倍,记录多了差距很明显。
第二个问题更要命:UUID 是完全随机的,没有顺序。MySQL 的 InnoDB 引擎用 B+ 树存数据,主键要尽量有序,这样插入新记录时叶子节点能顺序追加,效率高。UUID 是随机的,每次插入都可能落到 B+ 树的任意一个地方,触发页分裂,大量写入时性能会很差。
第三个问题是可读性差,36 位一串字母数字,排查问题的时候看着眼睛疼,也没法从 ID 里判断大概的创建时间。
UUID 可以用在对性能要求不高、只需要唯一标识的场景,比如幂等 Token、链路追踪 ID。但作为数据库主键,尤其是高并发写入的业务表,不推荐。
方案二:数据库自增步长
这个方案不引入新组件,只改数据库配置。思路是:让每台数据库的自增起点不一样,步长设成分库数量,这样每台库生成的 ID 天然就不会重叠。
比如两台库的情况:
• 库 1:起点 1,步长 2,生成 1, 3, 5, 7, 9... • 库 2:起点 2,步长 2,生成 2, 4, 6, 8, 10...
MySQL 对应的配置:
-- 库 1
SET @@auto_increment_offset = 1;
SET @@auto_increment_increment = 2;
-- 库 2
SET @@auto_increment_offset = 2;
SET @@auto_increment_increment = 2;这个方案的好处是改动小,ID 依然是数字,依然递增。
但它有个硬伤:扩容难。一开始你按 2 个库设步长为 2,后来业务增长想扩到 4 个库,之前已经生成的 ID 就和新库的 ID 规划冲突了,很难平滑过渡。步长一旦定了,基本只能一直用下去。
适合库数量固定、业务规模可预期的中小型系统,如果有明确的扩容预期,不建议选这个。
方案三:号段模式(Segment)
号段模式是对数据库自增的升级版。核心思路是:不每次都去数据库取一个 ID,而是一次性“批发”一段 ID 回来,放在内存里慢慢用。
数据库里有一张专用的号段表:
CREATE TABLE `leaf_alloc` (
`biz_tag` VARCHAR(128) NOT NULL COMMENT '业务标识,如 order、user',
`max_id` BIGINT(20) NOT NULL COMMENT '当前已分配的最大 ID',
`step` INT(11) NOT NULL COMMENT '每次取的号段大小',
`description` VARCHAR(256) DEFAULT NULL,
`update_time` TIMESTAMP NOT NULL,
PRIMARY KEY (`biz_tag`)
);每次服务需要 ID 时,用一条 UPDATE 把 max_id 往前推一个步长,然后把这段 ID 的范围返回给服务端缓存。比如当前 max_id 是 1000,step 是 500,执行完 UPDATE 之后 max_id 变成 1500,服务端就有 1001~1500 这 500 个 ID 可以用了。
UPDATE leaf_alloc
SET max_id = max_id + step, update_time = NOW()
WHERE biz_tag = 'order';这样数据库压力大幅减小,原来每条记录都要访问数据库,现在每用完一个步长才访问一次。
但有个小问题:号段快用完的时候,需要再去数据库取下一段。这次取的过程如果稍慢,业务就会有一段时间的短暂等待。美团 Leaf 用双缓冲解决了这个问题:当当前号段消耗到 10% 时,异步提前加载下一个号段到内存。等当前号段耗尽,切换过去就行,完全无感知。
号段模式生成的 ID 是递增的 long 型整数,对 MySQL B+ 树索引很友好。缺点是 ID 连续性较强,竞争对手如果盯着你的订单号,可能推算出你一天的业务量。另外强依赖数据库高可用,数据库挂了,内存里的缓存还能撑一会儿,但号段用完就歇菜了。
方案四:雪花算法(Snowflake)
雪花算法是 Twitter 2010 年开源的方案,目前用得最广。它生成的是一个 64 位的 long 型整数,结构如下:
具体分配:
• 1 位:符号位,始终为 0,保证 ID 是正数 • 41 位:时间戳,精确到毫秒,从自定义起点算起,可以用约 69 年 • 10 位:机器 ID,支持 1024 个节点(通常拆成 5 位数据中心 + 5 位机器) • 12 位:序列号,同一毫秒内可以生成 4096 个 ID
单机理论吞吐量:1000 × 4096 = 409 万 ID/秒,本地生成,不需要任何网络请求。
一段简化版的 Java 实现大概是这样:
public synchronized long nextId() {
long currentMs = System.currentTimeMillis();
if (currentMs == lastMs) {
// 同一毫秒内,序列号自增
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
// 序列号溢出,等到下一毫秒
currentMs = waitNextMs(lastMs);
}
} else {
sequence = 0L;
}
lastMs = currentMs;
return (currentMs - EPOCH) << TIMESTAMP_SHIFT
| workerId << WORKER_ID_SHIFT
| sequence;
}雪花算法的 ID 整体上随时间递增,写入 MySQL 时页分裂问题远比 UUID 少。而且高位包含时间戳,能大概知道这条记录是什么时候生成的。
但它有一个经典问题:时钟回拨。服务器的系统时间不是绝对单调的,NTP 同步或者运维手动调时间,都可能导致当前时间比上次生成 ID 的时间还早。这时候再生成 ID,currentMs < lastMs,用同一个时间戳就会产生重复 ID。
常见的处理方式有三种:
1. 等待:如果回拨幅度很小(几毫秒),线程阻塞等待时间追上去 2. 报错:回拨幅度大就直接抛异常,让调用方处理重试 3. 备用序列:切换到一个备用的 Worker ID 继续生成,回拨期间用不同的机器位区分,避免重复
另一个问题是 Worker ID 怎么分配。每台机器的 Worker ID 必须唯一,手动配置在小集群里还好,一旦到了容器化、弹性扩缩容的环境,机器起来就得马上知道自己的 ID,纯靠配置文件很难维护。
方案五:美团 Leaf
美团开源的 Leaf 把上面两种方案都做了工业级增强,提供号段模式和 Snowflake 模式两套实现,可以按需选用。
Leaf-snowflake 解决了 Snowflake 的两个痛点:
第一个是 Worker ID 自动分配。Leaf 用 ZooKeeper 的顺序持久节点来发号。服务启动时,去 ZooKeeper 注册一个节点,注册成功后拿到的序号就是 Worker ID。不需要手动维护,容器重启也能自动拿到一个合法的 ID。
第二个是时钟回拨检测。Leaf 服务会定期(每隔几秒)把当前时间戳写到 ZooKeeper 节点上。如果下次启动时发现本地时间比 ZooKeeper 记录的时间早,说明发生了较大的时钟回拨,服务直接拒绝启动并报警,而不是带着问题运行下去。
ZooKeeper 节点结构:
/snowflake/{appName}/forever/{ip:port}-{workerID} <- 持久节点,存 Worker ID
/snowflake/{appName}/temporary/{ip:port} <- 周期性更新时间戳Maven 引入方式:
<dependency>
<groupId>com.sankuai.inf.leaf</groupId>
<artifactId>leaf-core</artifactId>
<version>1.0.1</version>
</dependency>配置好数据库或 ZooKeeper 地址,调用 IDGen#get(key) 就能取到 ID,接入成本很低。
各方案对比
怎么选:
• 大多数业务:直接用雪花算法,或者直接接入 Leaf-snowflake。本地生成,性能高,接入简单。 • 金融类、对 ID 连续性有强要求:用号段模式或 Leaf-segment,ID 更紧凑,不依赖系统时钟。 • 临时标识、幂等 key:UUID 够用,不要拿来做数据库主键。 • 数据库步长方案:能不用就不用,扩容的时候会很头疼。
如果团队在用 ShardingSphere,它内置了雪花算法的 ID 生成器,配置 type: SNOWFLAKE 即可,不需要自己维护。
小结
分库分表之后 ID 冲突的根本原因是:各分片自己管自己的自增序列,互不知情。解决办法就是把 ID 的生成权从各个数据库手里收回来,统一生成。
几个方案里,雪花算法是目前用得最多的,兼顾了性能和有序性。号段模式在对 ID 顺序性要求极高的场景下更稳。UUID 简单但不适合做主键。数据库步长太死板,不推荐在新项目里使用。
真要上生产,不建议自己手撸雪花算法,时钟回拨和 Worker ID 管理的细节很容易出问题。美团 Leaf 或者百度的 UidGenerator 都是经过大规模验证的方案,直接用更安全。
点下方的“❤”支持我们,非常感谢!