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 唯一约束 冲突? 是 捕获冲突异常 跳过/ACK 否 写入成功 幂等完成 关键点: • 唯一约束冲突是幂等信号 • 冲突后不重试,直接跳过 • 写入成功则正常ACK

流程其实不复杂。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不同而成功插入。这完全违背了幂等的初衷。