幂等与消息可靠:最终一致落地的通用做法

微服务里“同一操作被执行两次”很常见:客户端超时重试、网关重试、MQ 重复投递、支付回调重复通知。如果不做幂等,就会出现重复下单、重复扣款、重复发货。本章讲幂等的通用实现与基于消息的最终一致落地套路。

幂等是什么

幂等:同一操作执行一次和多次的结果一致。查询、按 ID 删除天然幂等;扣库存、加余额、创建订单这类“有状态变更”必须额外保证。判定标准是副作用:多次调用不产生重复副作用即算幂等,不要求返回值完全一致。

方案一:唯一键约束(数据库兜底)

给关键操作一个业务唯一键(订单号、流水号),数据库加唯一索引,重复插入报冲突即视为“已处理”:

CREATE TABLE t_deduct_log (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  biz_no VARCHAR(64) NOT NULL COMMENT '业务幂等键',
  UNIQUE KEY uk_biz_no (biz_no)
);
try {
    deductLogMapper.insert(log); // 重复时抛 DuplicateKeyException
    // 继续真正扣减
} catch (DuplicateKeyException e) {
    return Result.fail("重复请求,已处理");
}

数据库唯一约束是最终裁决者,可靠度最高,适合资金类核心操作。

方案二:Redis 令牌/去重(前置拦截)

高并发下先让 Redis 挡掉绝大多数重复,原子占位 + 过期时间:

String key = "idem:order:" + orderId;
Boolean first = stringRedisTemplate.opsForValue()
        .setIfAbsent(key, "1", Duration.ofMinutes(30));
if (!Boolean.TRUE.equals(first)) {
    return Result.fail("重复提交");
}
try {
    // 业务处理;失败时删除 key 允许重试
} catch (Exception e) {
    stringRedisTemplate.delete(key);
    throw e;
}

注意:Redis 方案要与数据库唯一键配合——Redis 挡高频重复,唯一键兜底“处理了但未及写回”的极端窗口。

方案三:状态机 / 乐观锁

更新类操作用版本号或状态校验,防止旧状态覆盖新状态:

UPDATE t_order SET status=20, version=version+1
WHERE id=100 AND status=10 AND version=1;
-- 影响行数 0 = 状态已变化,本次请求视为重复/过期

消息可靠与最终一致

跨服务一致性(下单后扣库存、扣款)一般做“最终一致”:本地事务成功后保证消息送达,消费端保证只生效一次。

  1. 本地消息表:业务与消息同库同事务写入(订单=已创建,消息=待发送),定时任务扫表发 MQ,确认后标记已发送。
  2. 事务消息(以 RocketMQ 为例):先发对消费者不可见的“半消息”→ 执行本地事务 → commit 后消息可见、rollback 则丢弃;长时间未决由 MQ 回查本地事务状态。该机制把“本地事务”与“发消息”原子化,是主流做法。
  3. 消费端:手动 ACK + 幂等去重,失败重试超限进死信队列人工处理:
@RabbitListener(queues = "order.paid")
public void onOrderPaid(Message msg) {
    String bizNo = msg.getMessageProperties().getMessageId();
    if (idempotentService.exists(bizNo)) { return; } // 已消费
    try {
        orderPaidHandler.handle(msg);
        idempotentService.mark(bizNo);
    } catch (Exception e) {
        // 不 ACK,等待重投;超限进死信队列
    }
}

自查清单

  • 每个写接口都有明确幂等键?重复请求返回“已处理”而非报错?
  • MQ 消费者是否手动 ACK + 去重?失败有重试上限与死信出口?
  • 重试只针对临时失败(超时/5xx),业务失败不重试?

小结

幂等是最终一致的地基:写接口用唯一键或状态机保证幂等,跨服务用本地消息表/事务消息保证送达,消费者手动 ACK 加去重保证不重复生效。动手前先问“重复执行会发生什么”,再决定用 Redis 前置拦截还是数据库兜底。

笔记加载中…