4、业务主键设计:如何选择与生成全局唯一的业务主键

CDC同步里最头疼的问题是什么?我个人觉得,是主键冲突。

你想,数据从源库流到目标库,如果两条记录的业务含义相同,但主键不同,那就会产生重复数据。更麻烦的是,如果主键相同但业务含义不同,那就会互相覆盖,数据直接乱掉。

所以,业务主键的设计,是幂等写入的第一道防线。主键选对了,去重逻辑就简单了。主键选错了,后面再怎么折腾也是白费力气。

核心原则:业务主键必须在全局范围内唯一,且一旦确定,永不改变。

4.1 自增ID为什么不行?

很多同学习惯用数据库自增ID做主键。方便,确实方便。但在CDC场景下,这玩意儿就是个坑。

我在项目中遇到过这么个事:两个分库各自生成自增ID,结果同步到同一个目标表时,ID直接撞车了。源库A的ID=100是一条订单,源库B的ID=100是另一条订单,到了目标库,后到的把先到的覆盖了。数据对不上,业务方找上门来,那叫一个狼狈。

自增ID的问题说白了就三点:

  • 全局不唯一:不同库、不同表各自独立计数,合并时必然冲突
  • 业务无意义:ID本身不携带任何业务信息,出了问题你根本不知道这条数据对应哪个业务实体
  • 迁移困难:一旦数据需要跨库迁移,自增ID的连续性就断了,还得重新映射

避坑指南:我曾经在一个金融项目中看到有人用"自增ID+库编号"拼接做主键,以为这样就能全局唯一。结果呢?分库扩容时库编号变了,历史数据的主键全部失效。嗯,那次的回滚操作,我到现在还记得。

4.2 全局唯一主键的三种主流方案

那到底用什么?我这些年总结下来,靠谱的方案就三种。咱们一个一个说。

方案 典型实现 优点 缺点 适用场景
业务复合主键 租户ID + 业务ID 天然唯一,无需额外生成 字段可能变更,长度较大 多租户系统、SaaS平台
UUID / ULID 32位十六进制字符串 生成简单,全局唯一 无序,索引性能差 分布式日志、消息队列
分布式ID生成器 雪花算法(Snowflake) 有序递增,高性能 依赖时钟,需额外部署 高并发交易、订单系统

4.3 业务复合主键:最稳妥的选择

我个人习惯,能不用生成式ID就不用。为什么?因为业务本身已经提供了唯一性约束。

举个例子:一个电商系统的订单表,订单号本身就是全局唯一的。你直接用订单号做主键,什么问题都没有。如果是多租户系统,那就用租户ID + 业务ID做复合主键。

这样做的好处是:

  • 天然幂等:同一条业务数据,主键一定相同,CDC同步时直接覆盖即可
  • 可读性强:看到主键就知道是哪条业务数据,排查问题非常方便
  • 无需额外组件:不依赖任何中间件,系统架构更简单

但要注意,业务主键也可能变化。比如用户改了手机号,你用手机号做主键就完蛋了。所以,一定要选择那些一旦生成就永不变化的业务字段。

实战技巧:我在做CRM系统时,客户主键用的是"客户编号",这个编号在客户创建时生成,后续无论客户怎么修改信息,编号都不变。CDC同步时,直接用这个编号做去重键,稳得很。

4.4 UUID/ULID:简单但要注意性能

如果业务上实在找不到合适的复合主键,那就用UUID。生成简单,全局唯一,谁都能用。

但UUID有个致命问题:无序。你想想看,数据库的B+树索引是按顺序组织的,UUID随机插入会导致频繁的页分裂和索引重建。数据量一上来,写入性能直线下降。

所以,我建议用ULID(Universally Unique Lexicographically Sortable Identifier)。它比UUID多了时间戳前缀,天然有序,索引性能好很多。

// ULID 示例:01ARZ3NDEKTSV4RRFFQ69G5FAV
// 前10个字符是时间戳(毫秒级),后面16个字符是随机数
// 按时间排序,天然有序

// 生成ULID(Java示例)
import de.huxhorn.sulky.ulid.ULID;

ULID ulid = new ULID();
String id = ulid.nextULID(); // 输出:01ARZ3NDEKTSV4RRFFQ69G5FAV

注意:ULID虽然有序,但只是"大致有序",不是严格递增。如果对顺序要求极高(比如金融交易流水),还是得用雪花算法。

4.5 雪花算法:高并发场景的利器

说到分布式ID,绕不开雪花算法。它把64位ID拆成几段:时间戳、机器ID、序列号。每毫秒能生成几千个不重复的ID,而且趋势递增。

我在一个日订单量千万级的系统中用过雪花算法,效果很好。但要注意几个坑:

  • 时钟回拨:如果服务器时钟往回跳,就可能生成重复ID。我建议在生成ID前先检查时钟,如果发现回拨就等待或报错
  • 机器ID分配:每个节点的机器ID必须全局唯一,可以用ZooKeeper或数据库来分配
  • 序列号溢出:同一毫秒内序列号用完了怎么办?可以等待下一毫秒,或者增加序列号位数
// 雪花算法ID结构(64位)
// 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
// 1位符号位 | 41位时间戳 | 10位机器ID | 12位序列号

// 生成示例(伪代码)
long timestamp = currentTimeMillis();
long sequence = getAndIncrement(); // 同一毫秒内自增
long workerId = getWorkerId();     // 机器ID,全局唯一
long id = (timestamp << 22) | (workerId << 12) | sequence;

4.6 主键设计的最终建议

说了这么多,到底怎么选?我给出一个决策流程:

  1. 先看业务:有没有天然唯一的业务字段?有的话,直接用业务复合主键
  2. 再看规模:数据量不大(百万级以下),用ULID,简单省事
  3. 最后看性能:高并发、大数据量,上雪花算法,但要处理好时钟问题

不管选哪种方案,记住一句话:主键一旦确定,就不要改。CDC同步的幂等性,就靠它了。

总结:业务主键是CDC同步的基石。选对了,去重、合并、回溯都顺风顺水。选错了,后面所有的努力都是在填坑。

业务主键设计决策流程 开始设计主键 是否有天然唯一的业务字段? 是 业务复合主键 否 数据量 < 1000万? (或对顺序要求不高) 是 ULID 否 雪花算法 主键确定后永不修改 💡 核心原则 全局唯一 + 不可变 是幂等写入的基础