搜狐技术产品

分库分表后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 就是定时炸弹。

分库分表数据分片:hash 分片将数据按哈希值分布到多个 shard,各 shard 自增 ID 互不感知
分库分表数据分片:hash 分片将数据按哈希值分布到多个 shard,各 shard 自增 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% 时,异步提前加载下一个号段到内存。等当前号段耗尽,切换过去就行,完全无感知。

美团 Leaf 号段模式基础架构:Leaf 服务节点从数据库批量取号段缓存在内存中,不同业务 tag 独立管理
美团 Leaf 号段模式基础架构:Leaf 服务节点从数据库批量取号段缓存在内存中,不同业务 tag 独立管理
美团 Leaf 双缓冲机制:当前号段消耗 10% 时异步加载下一号段,两段无缝切换彻底消除取号等待
美团 Leaf 双缓冲机制:当前号段消耗 10% 时异步加载下一号段,两段无缝切换彻底消除取号等待

号段模式生成的 ID 是递增的 long 型整数,对 MySQL B+ 树索引很友好。缺点是 ID 连续性较强,竞争对手如果盯着你的订单号,可能推算出你一天的业务量。另外强依赖数据库高可用,数据库挂了,内存里的缓存还能撑一会儿,但号段用完就歇菜了。


方案四:雪花算法(Snowflake)

雪花算法是 Twitter 2010 年开源的方案,目前用得最广。它生成的是一个 64 位的 long 型整数,结构如下:

Snowflake 64 位 ID 结构:1 位符号位 + 41 位时间戳 + 10 位机器 ID + 12 位序列号
Snowflake 64 位 ID 结构:1 位符号位 + 41 位时间戳 + 10 位机器 ID + 12 位序列号

具体分配:

  • • 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. 1. 等待:如果回拨幅度很小(几毫秒),线程阻塞等待时间追上去
  2. 2. 报错:回拨幅度大就直接抛异常,让调用方处理重试
  3. 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,接入成本很低。


各方案对比

方案
ID 类型
是否有序
性能
依赖
主要风险
UUID
String 36位
无序
极高(本地)
无
索引性能差,存储大
数据库步长
Long 递增
有序
中(依赖 DB)
MySQL
扩容困难,步长固定
号段模式
Long 趋势递增
近似有序
高(内存)
MySQL
ID 规律暴露,DB 高可用要求高
雪花算法
Long 趋势递增
趋势有序
极高(本地)
无
时钟回拨、Worker ID 管理
美团 Leaf
Long 趋势递增
趋势有序
极高
MySQL/ZK
组件依赖变多

怎么选:

  • • 大多数业务:直接用雪花算法,或者直接接入 Leaf-snowflake。本地生成,性能高,接入简单。
  • • 金融类、对 ID 连续性有强要求:用号段模式或 Leaf-segment,ID 更紧凑,不依赖系统时钟。
  • • 临时标识、幂等 key:UUID 够用,不要拿来做数据库主键。
  • • 数据库步长方案:能不用就不用,扩容的时候会很头疼。

如果团队在用 ShardingSphere,它内置了雪花算法的 ID 生成器,配置 type: SNOWFLAKE 即可,不需要自己维护。


小结

分库分表之后 ID 冲突的根本原因是:各分片自己管自己的自增序列,互不知情。解决办法就是把 ID 的生成权从各个数据库手里收回来,统一生成。

几个方案里,雪花算法是目前用得最多的,兼顾了性能和有序性。号段模式在对 ID 顺序性要求极高的场景下更稳。UUID 简单但不适合做主键。数据库步长太死板,不推荐在新项目里使用。

真要上生产,不建议自己手撸雪花算法,时钟回拨和 Worker ID 管理的细节很容易出问题。美团 Leaf 或者百度的 UidGenerator 都是经过大规模验证的方案,直接用更安全。

点下方的“❤”支持我们,非常感谢!