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 主键设计的最终建议
说了这么多,到底怎么选?我给出一个决策流程:
- 先看业务:有没有天然唯一的业务字段?有的话,直接用业务复合主键
- 再看规模:数据量不大(百万级以下),用ULID,简单省事
- 最后看性能:高并发、大数据量,上雪花算法,但要处理好时钟问题
不管选哪种方案,记住一句话:主键一旦确定,就不要改。CDC同步的幂等性,就靠它了。
总结:业务主键是CDC同步的基石。选对了,去重、合并、回溯都顺风顺水。选错了,后面所有的努力都是在填坑。