企业级幂等方案

在分布式系统中,由于网络不可靠,超时重试、消息重投、网关重试是常态。接口幂等性(Idempotency)是指同一个操作无论执行一次还是多次,最终产生的业务结果与执行一次完全一致,不会产生副作用(如重复扣款、重复创建数据等)。

以下是工业界主流的幂等性设计方案,按适用场景分类:

一、 新增/创建类场景

推荐方案:数据库唯一索引(去重表)

实现原理:利用数据库唯一约束的原子性,为业务关键字段(如订单号、支付流水号、请求ID)建立唯一索引。当重复请求插入时,数据库会直接拦截并抛出唯一键冲突异常(DuplicateKeyException),服务端捕获后直接返回成功结果。

优点:实现简单、强可靠、无需额外中间件、无并发安全问题。

缺点:仅适用于"新增"场景,无法解决更新类操作的幂等性。

二、 更新/状态流转类场景

推荐方案 1:有限状态机控制

实现原理:基于业务固有状态流转规则,限制状态单向推进。例如订单状态只能从"待支付"变为"已支付",更新操作时携带前置状态作为条件(如 UPDATE ... SET status='已支付' WHERE id=? AND status='待支付')。若重复请求到来时状态已变更,SQL 影响行数为 0,直接判定为重复请求。

优点:业务语义清晰,天然防重复操作,贴合真实业务逻辑。

缺点:需要精心设计状态流转规则,通常需结合数据库行锁防止并发状态变更。

推荐方案 2:乐观锁(版本号机制)

实现原理:在业务数据表新增 version 字段,每次更新时携带当前版本号作为条件(如 WHERE id=? AND version=old_version),更新成功后版本号加 1。重复请求携带旧版本号匹配失败,更新无效。

优点:无额外建表,并发安全,性能好。

缺点:仅支持更新类操作,不支持新增创建场景。

三、 高并发与通用接口防重

推荐方案 1:Token 令牌机制

实现原理:客户端在提交表单前先请求服务端获取唯一 Token(存入 Redis,设置过期时间)。提交业务请求时携带该 Token,服务端使用 Redis 的原子操作(如 delete 或 SETNX)校验并销毁 Token。删除成功则执行业务,删除失败则判定为重复提交。

优点:通用性强,任何接口都能用,特别适合传统表单提交防重复点击。

缺点:客户端需多一次请求获取 Token,增加了交互次数;无法解决 MQ 消息重投、服务间重试等场景。

推荐方案 2:Redis 幂等锁(高并发通用临时幂等)

实现原理:以全局唯一业务流水号作为幂等 Key,请求执行前使用 SETNX 原子命令抢占标识。抢占成功则执行业务,完成后更新 Key 标记成功并设置过期时间;抢占失败则直接返回。

优点:性能高,无数据库写入压力,适合高并发短流程接口。

缺点:Redis 宕机可能丢失未完成的幂等状态,需配合数据库唯一约束作为兜底。

四、 核心设计原则与组合使用

在实际生产项目中,通常不会单一使用某种方案,而是组合使用以构建多重防线:

唯一性标识:为每次操作生成全局唯一的幂等号(如 UUID、雪花算法、业务单号+操作类型)。

多层兜底:例如前端禁用按钮(减少无效请求) + Redis 临时防重(高并发拦截) + 数据库唯一索引兜底(最终一致性保障)。

事务一致性:幂等结果的存储与业务操作需保证原子性(处于同一事务或保证最终一致)。

存储过期:幂等结果(如 Redis 中的 Key)应设置合理的过期时间(如 24 小时),避免存储无限增长。

为了让你更直观地理解,我将结合具体的业务场景(创建订单、支付扣款、消息队列消费),为你提供生产级的代码级解决方案。

在工业界,单一方案往往存在短板(如 Redis 会宕机、前端可绕过),因此生产环境几乎永远是多层叠加的。以下是三大核心场景的生产级实战方案:

场景一:创建订单(新增类操作)

痛点:网络超时或用户手抖,导致同一笔订单被创建两次。

生产级方案:全局唯一标识 + 数据库唯一索引

这是最基础也是最坚固的防线。核心思想是:在插入数据前,确保有一个全局唯一的幂等键(如订单号),利用数据库的原子性拦截重复数据。

java

编辑

@Transactional(rollbackFor = Exception.class)

public String createOrder(OrderDTO orderDTO) {

String orderNo = orderDTO.getOrderNo(); // 全局唯一订单号(如雪花算法生成)

try {

OrderDO orderDO = new OrderDO();

orderDO.setOrderNo(orderNo);

orderDO.setAmount(orderDTO.getAmount());

orderMapper.insert(orderDO); // 插入数据

return "订单创建成功";

} catch (DuplicateKeyException e) {

// 捕获唯一索引冲突,判定为重复创建,直接返回成功

log.warn("订单{}已存在,无需重复创建", orderNo);

return "订单创建成功";

}

}

注:数据库需提前对 order_no 建立唯一索引:CREATE UNIQUE INDEX uk_order_no ON t_order(order_no);

场景二:支付扣款(状态流转类操作)

痛点:支付回调超时,网关自动重试,导致用户被重复扣款(资损级事故)。

生产级方案:状态机 + 乐观锁(企业最爱)

资金类场景的黄金法则是:宁可多一次状态查询,不可多一次资金操作。通过限制状态单向推进,确保只有处于"待支付"状态的订单才能被更新。

java

编辑

@Transactional(rollbackFor = Exception.class)

public String payOrder(String orderNo) {

// 核心SQL:仅当状态为"待支付(1)"时,才允许更新为"已支付(2)"

int rows = orderMapper.updateStatus(

orderNo,

OrderStatus.PAID, // 目标状态

OrderStatus.WAIT_PAY // 前置状态

);

复制代码
if (rows == 1) {
    // 影响行数为1,说明是首次处理,执行核心扣款逻辑
    doActualDeduction(orderNo);
    return "支付成功";
} else {
    // 影响行数为0,说明状态已变更(重复请求或非法请求)
    log.info("订单{}支付操作已执行或状态异常", orderNo);
    return "支付成功"; // 幂等返回,结果与首次一致
}

}

注:对应的 SQL 为 UPDATE order_info SET status=2 WHERE order_no=? AND status=1;

场景三:MQ 消息消费(分布式异步场景)

痛点:RocketMQ/Kafka 只保证 At Least Once(至少投递一次),网络抖动或消费者重启会导致消息被重复投递。

生产级方案:去重表 + 业务事务绑定

对于消息队列,最稳妥的方式是建立一张独立的去重表,将"记录已处理"与"执行业务逻辑"放在同一个本地事务中。

java

编辑

@Transactional(rollbackFor = Exception.class)

public void handleOrderMessage(String messageKey) {

// 1. 插入去重表(利用唯一索引)

int rows = idempotentMapper.insertIgnore(messageKey);

if (rows == 0) {

// 已处理过,直接返回成功

log.info("消息{}已消费,跳过", messageKey);

return;

}

// 2. 执行业务逻辑(如创建订单、扣减库存)

businessService.createOrder(messageKey);

}

注:insertIgnore 在遇到唯一键冲突时不会报错,而是静默返回 0 行受影响。

场景四:前端表单提交(通用防重)

痛点:用户快速双击提交按钮。

生产级方案:Redis Token 机制

前端在打开表单时向后端请求一个一次性 Token(存入 Redis)。提交时携带该 Token,后端使用 Lua 脚本保证"校验+删除"的原子性。

java

编辑

// 校验并删除 Token(需使用 Lua 脚本保证原子性)

String luaScript = "if redis.call('get', KEYS1) then return redis.call('del', KEYS1) else return 0 end";

Long result = redisTemplate.execute(script, Collections.singletonList("order:token:" + token));

if (result == 1) {

// 校验通过,执行业务逻辑

createOrder(orderDTO);

} else {

// Token 不存在或已被使用,拒绝请求

throw new BusinessException("请勿重复提交");

}

💡 生产级架构的三条通用红线

幂等键必须全局唯一且稳定:绝不能用数据库自增 ID 作为幂等键(每次请求都不一样),必须使用业务外部流水号、消息 ID 或订单号。

防重放要看语义:查询/状态校验可以随便做;但写操作(如扣款)必须先判重再执行,且判重与执行之间必须是原子的。

组合拳防御:单一方案都有短板。生产上通常是"前端按钮禁用(第一道) + Redis Token 拦截(第二道) + 数据库唯一索引/状态机兜底(最后防线)"。

相关推荐
Lucifer三思而后行1 小时前
我准备写 26 篇文章,带 DBA 系统学一遍 AI
数据库·人工智能·dba
for_ever_love__1 小时前
SQL学习: SQL入门与DDL
数据库·sql·学习
2601_9638702010 小时前
【计算机毕业设计】基于Spring Boot的专科医院医疗管理系统
java·spring boot·课程设计
Bingo_BIG10 小时前
Java Spring 批量修改,实体、接口、方法的定义
java·spring
Python私教12 小时前
从表格到管理系统:别先写页面,先补齐权限、流程和审计
数据库·后端·架构
陈皮波比茶12 小时前
Redis学习
数据库·redis·学习
—Miss. Z—12 小时前
备份与恢复
数据库·oracle
ltl12 小时前
RocksDB Leveled Compaction:层级不变式与 CompactionPicker
数据库
ltl12 小时前
DuckDB 向量化与 Morsel-Driven Pipeline
数据库