3、唯一约束与主键策略:利用数据库唯一键实现幂等
聊到幂等,很多人第一反应就是「去重」。没错,去重是幂等的核心。但怎么去重?最朴素、最可靠的方式,就是利用数据库自带的唯一约束。说白了,让数据库替你做判断——同样的数据,你插一次能成功,插第二次就报错。这个报错,就是幂等的关键信号。
我早期做CDC同步时,踩过一个坑。当时觉得业务主键已经能保证唯一了,就没额外加约束。结果有一次网络抖动,同一条insert消息被重复消费了两次。下游库直接插了两条一模一样的记录,业务方对账对了一整晚。嗯,从那以后,我再也不敢轻视唯一约束了。
3.1 唯一约束为什么能保障幂等
先想一个问题:幂等写入的本质是什么?是「同一个操作,执行多次和执行一次的结果相同」。对于insert来说,就是同一条数据不能出现两次。
数据库的唯一约束,天然就是干这个的。你定义一个唯一键,数据库就会在写入时检查这个键是否已存在。如果存在,就拒绝写入。这个拒绝动作,就是幂等保障的基石。
核心逻辑: 唯一约束将「业务上的重复」转化为「数据库层面的冲突」。冲突即幂等。
举个例子,假设你在同步用户数据:
-- 下游表结构
CREATE TABLE cdc_user (
user_id VARCHAR(64) PRIMARY KEY, -- 业务主键
user_name VARCHAR(128),
email VARCHAR(256),
updated_at TIMESTAMP
);
-- 重复写入时,数据库报错:Duplicate entry 'xxx' for key 'PRIMARY'
INSERT INTO cdc_user (user_id, user_name, email, updated_at)
VALUES ('U1001', '张三', 'zhangsan@example.com', '2024-01-15 10:00:00');
第一次插入成功。第二次插入时,主键冲突,数据库返回错误。CDC消费端捕获到这个错误,就知道这条消息已经被处理过了,直接跳过。这就是最基础的幂等模式。
3.2 主键策略:选对唯一键是关键
唯一约束好用,但前提是你得选对「唯一键」。我个人习惯把唯一键分为三类,你可以根据场景来选:
| 类型 | 说明 | 适用场景 | 注意事项 |
|---|---|---|---|
| 业务主键 | 业务上天然唯一的字段,如用户ID、订单号 | 源库和目标库主键一致,直接映射 | 源库主键变更时,下游需要同步更新 |
| 联合唯一索引 | 多个字段组合保证唯一性 | 没有单一主键,但多字段组合唯一 | 字段顺序影响索引效率,注意设计 |
| 业务去重键 | 专门为幂等设计的字段,如消息ID、事件ID | 源库没有合适主键,或需要跨表去重 | 需要额外生成和维护去重ID |
你想想看,如果源库的表有业务主键,那直接拿它当下游主键是最省事的。但现实往往没那么理想——有些表是联合主键,有些表甚至没有主键。这时候就得动点脑筋了。
我的建议: 如果源库没有合适的主键,可以在CDC同步链路中插入一个「消息唯一ID」。这个ID由源端生成,包含时间戳、分片号和自增序列,保证全局唯一。下游用这个ID做唯一约束,干净利落。
3.3 联合唯一索引的实战设计
联合唯一索引,说白了就是用多个字段拼出一个「业务唯一性」。我在一个电商项目中遇到过这种情况:订单明细表没有单独的明细ID,而是用「订单号 + 商品SKU + 子订单序号」来唯一标识一条记录。
CDC同步时,下游表这样设计:
CREATE TABLE cdc_order_detail (
order_id VARCHAR(32) NOT NULL,
sku_id VARCHAR(32) NOT NULL,
sub_seq INT NOT NULL,
quantity INT,
price DECIMAL(10,2),
created_at TIMESTAMP,
-- 联合唯一索引
UNIQUE KEY uk_order_sku_seq (order_id, sku_id, sub_seq)
);
这样,同一条订单明细无论被重复投递多少次,数据库都会因为联合唯一索引而拒绝重复插入。注意,这里用的是 UNIQUE KEY 而不是 PRIMARY KEY,因为主键我习惯用自增ID,方便后续做分页和排查。
曾经踩过的坑: 联合唯一索引的字段顺序很重要。把区分度高的字段放前面,能提高索引效率。我曾经把区分度低的字段放前面,结果索引扫描效率极低,同步延迟飙升。后来调整了字段顺序,问题才解决。
3.4 唯一约束与幂等写入的完整流程
光有唯一约束还不够,你得把「约束冲突」和「幂等处理」串起来。下面这张图展示了完整的流程:
流程其实不复杂。CDC消费端拿到消息后,直接尝试INSERT。如果唯一约束没冲突,写入成功,正常提交偏移量。如果冲突了,捕获异常,确认这条消息已经处理过,直接跳过。关键就两点:一是「冲突即幂等」,二是「不要重试冲突的写入」。
3.5 唯一约束的局限性
唯一约束虽然好用,但不是万能的。我总结了几种它搞不定的场景:
- UPDATE操作: 唯一约束只对INSERT有效。如果CDC同步的是UPDATE,重复执行不会报错,但结果可能不对。比如同一条更新执行两次,第二次可能把正确的数据又改了一遍。
- DELETE操作: 删除后重新插入,唯一约束不会拦截。如果业务上不允许重复插入,需要额外判断。
- 批量写入: 一批数据中部分冲突、部分不冲突,处理起来比较麻烦。要么整批回滚,要么逐条判断。
- 跨表事务: 唯一约束只能保证单表的幂等。如果一次CDC事件涉及多张表的写入,需要分布式事务或额外的幂等方案。
我的经验: 唯一约束最适合「INSERT-only」的CDC场景。如果源库有大量的UPDATE和DELETE,建议结合「版本号」或「更新时间戳」来做幂等判断。后面几章我会详细讲这些方案。
3.6 避坑指南
最后分享几个实战中容易踩的坑:
- 唯一键字段不能为NULL: 数据库唯一约束允许NULL值存在,但多个NULL值不视为重复。如果你的唯一键字段可能为NULL,那唯一约束就形同虚设了。
- 字符集和排序规则: 不同字符集下,字符串的比较结果可能不同。比如utf8_general_ci和utf8_bin,前者不区分大小写,后者区分。确保源库和目标库的字符集一致,否则可能出现「该冲突的没冲突,不该冲突的冲突了」。
- 索引长度限制: InnoDB的索引最大长度为767字节(或3072字节,取决于配置)。如果唯一键字段太长,会报错。我遇到过用URL做唯一键的案例,结果字段长度超限,索引创建失败。
- 不要依赖自增ID做唯一键: 自增ID只能保证表内唯一,不能保证业务唯一。两条业务上重复的数据,可能因为自增ID不同而成功插入。这完全违背了幂等的初衷。