幂等性设计模式:数据库去重表与唯一索引
什么是幂等性
幂等性(Idempotence)是一个数学与计算机科学中的概念,在分布式系统和 API 设计中指:同一个操作执行一次与执行多次所产生的副作用完全相同。换句话说,重复发起同一个请求,系统状态只变化一次,后续请求不会造成额外影响。
典型的幂等场景:
- 用户连续点击“提交订单”按钮,系统只创建一笔订单。
- 支付回调通知因网络重发多次,系统只入账一次。
- 接口超时后客户端重试,业务操作不能重复执行。
实现幂等性的方式多种多样,本文重点讲解最可靠、最广泛使用的一种方案——数据库去重表 + 唯一索引。
为什么需要幂等性保障
在分布式环境中,网络不可靠、服务可能超时、消息可能重复投递,如果不做幂等处理,就会导致:
- 数据重复:同一个订单被创建多次。
- 资金损失:重复扣款或重复入账。
- 状态混乱:状态机发生不可预期的跳转。
幂等性设计是构建健壮系统的基本要求,尤其在金融、电商、支付等业务中不可或缺。
数据库去重表与唯一索引方案原理
该方案的核心思想是:在业务操作执行之前,先通过唯一约束记录“操作凭证”,凭证出现冲突则判定为重复操作,直接返回成功或忽略。
什么是去重表
去重表(Deduplication Table)是一张专门用于记录已成功处理过的操作凭证的数据表。凭证通常是一个全局唯一的请求 ID,由客户端生成或由业务规则拼装(如订单号、流水号等)。
表结构一般非常简单:
CREATE TABLE idempotent_operations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
unique_key VARCHAR(128) NOT NULL COMMENT '幂等凭证,例如请求唯一ID',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_unique_key (unique_key)
);
unique_key是业务操作对应的唯一标识,通过唯一索引保证不会重复插入。- 执行操作时,先尝试向该表插入记录,插入成功则继续执行业务逻辑;插入时如果出现唯一键冲突,说明该凭证已经处理过,直接返回幂等成功。
为什么选择唯一索引
唯一索引是数据库自身提供的强约束,具备 ACID 特性,可以保证在并发场景下同一凭证只能插入成功一次。相比应用层分布式锁或 Redis 去重,数据库唯一索引方案无需引入额外组件,实现简单、可靠性极高。
具体实现步骤
以一个“用户提现”场景为例,接口需要保证同一笔提现请求不被重复处理。
步骤一:定义去重表
CREATE TABLE withdraw_dedup (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
withdraw_id VARCHAR(64) NOT NULL COMMENT '提现业务单号,作为幂等凭证',
created DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_withdraw_id (withdraw_id)
);
步骤二:编写业务处理伪代码
public WithdrawResult withdraw(String withdrawId, BigDecimal amount, String account) {
// 1. 先尝试插入去重记录
try {
int rows = dedupMapper.insert(withdrawId);
if (rows > 0) {
// 插入成功,说明当前凭证第一次处理,执行核心业务
accountService.decreaseBalance(account, amount);
recordService.createWithdrawRecord(withdrawId, amount, account);
return WithdrawResult.success();
}
} catch (DuplicateKeyException e) {
// 唯一键冲突,说明之前已处理过
// 2. 查询原处理结果并返回
WithdrawRecord existing = recordService.findByWithdrawId(withdrawId);
if (existing != null) {
return WithdrawResult.success(existing);
}
// 极端情况:插入冲突但业务结果还未落库,可做适当重试或返回“处理中”
throw new BusinessException("操作已受理,请稍后查询结果");
}
return WithdrawResult.success();
}
核心要点:
- 先写去重表,后执行业务。如果业务执行失败,去重表应当回滚,避免凭证被误判为已成功处理。通常通过数据库事务保证(即将去重插入和业务操作放在同一个本地事务中)。
- 处理冲突时返回一致的结果。重复请求必须得到与首次成功完全相同的响应,否则客户端会困惑。
步骤三:异常处理与补偿
- 业务执行失败:事务应回滚,去重记录随之删除(在同一事务内),该凭证后续可以重新处理。
- 业务部分成功:例如扣款完成,但记录未写入,需要设计补偿或重试机制,并保证重试时业务操作幂等。此时去重表已经存在记录,业务层需检查状态,若已扣款则补录记录,否则进行适当补偿。
- 凭证生成策略:客户端应生成唯一 ID(如 UUID、雪花 ID);也可由业务要素生成,但必须确保业务上全局唯一(如“用户ID+订单号”组合)。
进阶优化与注意事项
1. 去重表的分库分表
在海量请求场景下,单表可能成为瓶颈。可以按 unique_key 进行分片,例如以凭证哈希取模。确保同一个凭证始终路由到同一分片,唯一索引在分片上依然有效。
2. 过期数据清理
去重表会持续增长,需要定期清理历史数据。可以保留一个安全窗口(如 90 天),超过窗口的凭证允许删除。注意:超过窗口的重复请求可能重现,此时若凭证已被删除,幂等无法保证。因此需根据业务特性设计凭证有效期,或在业务表上同样维护幂等约束。
3. 与业务操作的原子性
务必使用本地事务包裹“去重表插入”和“业务操作”,否则会出现去重记录存在但业务未执行的情况,导致后续重复请求误判为成功。如果业务操作跨多个服务或数据库,则需要结合分布式事务或最终一致性的补偿手段,此时仅依靠单库去重表不够,需引入“幂等处理表 + 状态机”等更复杂的模式。
4. 凭证的选择
尽量避免用业务含义过强的字段作为凭证,例如直接用“提现金额+时间”,这些可能非全局唯一。推荐:
- 客户端传入唯一的
requestId或idempotentKey。 - 服务端生成全局 ID,但需在首次调用时返回给客户端,客户端后续重试携带同一 ID。
5. 避免使用自增 ID 作为全局凭证
自增 ID 不具备分布式唯一性,分库分表后会出现冲突,也不利于跨系统交互。应使用分布式 ID 生成器(如雪花算法、号段模式)或标准 UUID。
总结
数据库去重表加唯一索引是实现幂等性最朴素且可靠的模式。它利用关系型数据库的强约束特性,从本质上防止了重复操作的提交。实现时需注意:
- 凭证唯一且与操作一一对应。
- 先插入去重记录,再执行核心业务,通过事务保持原子性。
- 处理好冲突时的业务结果查询,保证响应一致。
- 设计合理的数据清理和凭证生成策略。
掌握这一基础模式后,再配合分布式事务、状态机等手段,就能应对绝大多数需要幂等保障的场景。