幂等性是程序设计中保障系统可靠性的核心特性,指同一操作无论执行多少次,结果都与执行一次完全一致,不会重复创建数据、重复扣减金额、重复发送消息等。其核心解决的是网络重试、消息重发、用户重复提交等场景下的异常问题。以下是工业界主流的幂等性解决方案,按「适用场景 + 实现原理 + 优缺点 + 代码示例」分层讲解:
一、核心设计原则
在选择方案前,需明确幂等性设计的核心原则:
- 唯一性标识:为每次操作生成全局唯一的幂等号(Idempotent ID),作为去重依据;
- 原子性校验:校验 + 执行业务逻辑必须是原子操作(避免并发下重复执行);
- 无副作用:重复执行时,除 “返回成功” 外,不产生任何业务副作用;
- 性能平衡:避免过度设计(如全链路分布式锁)导致性能损耗。
二、主流解决方案(按场景优先级排序)
方案 1:基于唯一索引 / 主键(数据库层)
适用场景:新增数据场景(如创建订单、用户注册、商品入库),核心是避免重复插入。实现原理:利用数据库「唯一索引 / 主键约束」,确保重复插入时触发约束异常,业务层捕获异常并判定为 “操作已执行”。核心步骤:
- 为业务关键字段(如订单号、用户 ID + 业务类型)创建唯一索引;
- 插入数据时,若抛出
DuplicateKeyException,则判定为重复操作,直接返回成功。
代码示例(Java + MySQL) :
// 1. 数据库表设计:订单表,订单号order_no创建唯一索引
// CREATE UNIQUE INDEX uk_order_no ON t_order(order_no);
// 2. 业务代码
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Transactional(rollbackFor = Exception.class)
public String createOrder(OrderDTO orderDTO) {
String orderNo = orderDTO.getOrderNo(); // 全局唯一订单号(如雪花算法生成)
try {
OrderDO orderDO = new OrderDO();
orderDO.setOrderNo(orderNo);
orderDO.setUserId(orderDTO.getUserId());
orderDO.setAmount(orderDTO.getAmount());
orderMapper.insert(orderDO); // 插入数据
return "订单创建成功";
} catch (DuplicateKeyException e) {
// 捕获唯一索引冲突,判定为重复创建
log.warn("订单{}已存在,无需重复创建", orderNo);
return "订单创建成功"; // 幂等返回,结果与首次一致
}
}
}
优缺点:
- ✅ 优点:实现简单、性能高(数据库索引天然高效)、无需额外存储;
- ❌ 缺点:仅适用于 “新增场景”,无法解决更新 / 删除的幂等性;需提前规划唯一索引字段。
方案 2:基于幂等号 + 状态机(业务层 + 数据库)
适用场景:有状态流转的操作(如订单支付、退款、物流状态更新),核心是避免状态重复变更。实现原理:
- 为每次操作生成全局唯一的幂等号(如
reqId、bizId); - 业务表新增「幂等号字段 + 状态字段」,并为幂等号创建唯一索引;
- 执行操作时,先校验 “幂等号是否存在 + 当前状态是否允许执行”,仅当两者满足时才执行业务逻辑。
核心步骤:
- 生成幂等号(如 UUID、雪花 ID、用户 ID + 时间戳 + 业务类型);
- 数据库操作:
INSERT ... ON DUPLICATE KEY UPDATE或SELECT FOR UPDATE校验状态; - 状态机约束:仅允许从 “初始状态”→“目标状态”(如订单仅能从 “待支付”→“已支付”)。
代码示例(订单支付场景) :
// 1. 数据库表设计:订单表新增idempotent_id(唯一索引)、status字段
// CREATE UNIQUE INDEX uk_idempotent_id ON t_order(idempotent_id);
// 2. 业务代码
@Service
public class PayService {
@Autowired
private OrderMapper orderMapper;
@Transactional(rollbackFor = Exception.class)
public String payOrder(String idempotentId, String orderNo) {
// 1. 加行锁查询订单,确保原子性
OrderDO orderDO = orderMapper.selectByOrderNoForUpdate(orderNo);
if (orderDO == null) {
return "订单不存在";
}
// 2. 校验幂等号和状态:幂等号已存在 → 操作已执行;状态非待支付 → 拒绝执行
if (idempotentId.equals(orderDO.getIdempotentId())) {
log.warn("订单{}支付操作已执行,幂等号{}", orderNo, idempotentId);
return "支付成功";
}
if (!"WAIT_PAY".equals(orderDO.getStatus())) {
return "订单状态非待支付,无法支付";
}
// 3. 执行业务逻辑:扣减库存、更新订单状态、记录幂等号
orderDO.setStatus("PAID");
orderDO.setIdempotentId(idempotentId);
orderMapper.updateById(orderDO);
// 其他业务:扣减库存、生成支付记录...
return "支付成功";
}
}
优缺点:
- ✅ 优点:适配绝大多数业务场景(新增 / 更新 / 状态变更)、状态机约束更安全;
- ❌ 缺点:需额外维护幂等号和状态字段,开发成本略高。
方案 3:基于分布式锁(Redis/ZooKeeper)
适用场景:高并发下的核心操作(如秒杀下单、库存扣减),核心是避免并发重复执行。实现原理:
- 以 “业务唯一标识”(如订单号、商品 ID + 用户 ID)为锁 Key;
- 抢占分布式锁(如 Redis 的 SET NX EX),只有抢到锁的线程能执行操作;
- 操作完成后释放锁,重复请求因抢不到锁直接返回成功。
代码示例(Redis 分布式锁 + Lua 脚本,保证原子性) :
@Service
public class SeckillService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private SeckillMapper seckillMapper;
// Lua脚本:抢锁+扣减库存原子操作
private static final String LUA_SCRIPT = """
local key = KEYS[1]
local stockKey = KEYS[2]
local userId = ARGV[1]
-- 1. 检查是否已抢过锁(避免重复下单)
if redis.call('exists', key) == 1 then
return 1 -- 已抢锁,重复操作
end
-- 2. 检查库存
local stock = tonumber(redis.call('get', stockKey))
if stock <= 0 then
return 0 -- 库存不足
end
-- 3. 扣减库存+加锁(设置过期时间,避免死锁)
redis.call('decr', stockKey)
redis.call('setex', key, 60, userId)
return 2 -- 操作成功
""";
public String seckill(Long goodsId, Long userId) {
// 锁Key:秒杀_商品ID_用户ID(保证唯一)
String lockKey = "seckill:lock:" + goodsId + ":" + userId;
String stockKey = "seckill:stock:" + goodsId;
DefaultRedisScript<Long> script = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
Long result = redisTemplate.execute(script, Arrays.asList(lockKey, stockKey), userId.toString());
if (result == 1) {
return "您已参与过秒杀,无需重复操作"; // 幂等返回
} else if (result == 0) {
return "秒杀库存不足";
} else {
// 执行业务入库(兜底,即使Redis锁失效,数据库唯一索引仍能保证幂等)
seckillMapper.insertSeckillRecord(goodsId, userId);
return "秒杀成功";
}
}
}
优缺点:
- ✅ 优点:适配高并发场景、支持所有操作类型(增删改查)、实时性高;
- ❌ 缺点:需维护分布式锁(过期时间难设置)、有性能损耗(网络 IO)、可能出现死锁(需设置过期时间)。
方案 4:基于 Token 机制(接口层)
适用场景:前端重复提交(如表单提交、按钮多次点击)、API 接口防重放。实现原理:
- 前端请求 “获取 Token” 接口,后端生成唯一 Token(如 UUID),存储到 Redis(设置过期时间),并返回给前端;
- 前端提交业务请求时,携带该 Token;
- 后端校验 Token:若 Redis 中存在则删除(原子操作),执行业务逻辑;若不存在则判定为重复请求。
核心步骤:
前端:获取Token → 携带Token提交表单 → 后端:校验Token(存在则删除+执行业务)/(不存在则拒绝)
代码示例:
// 1. 获取Token接口
@RestController
@RequestMapping("/token")
public class TokenController {
@Autowired
private StringRedisTemplate redisTemplate;
@GetMapping("/get")
public String getToken() {
String token = UUID.randomUUID().toString();
// 存储Token,过期时间5分钟(避免Redis堆积)
redisTemplate.opsForValue().set("token:" + token, "valid", 5, TimeUnit.MINUTES);
return token;
}
}
// 2. 业务接口(校验Token)
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private OrderService orderService;
@PostMapping("/create")
public String createOrder(@RequestParam String token, @RequestBody OrderDTO orderDTO) {
// 原子操作:删除Token(避免并发重复提交)
Boolean exists = redisTemplate.delete("token:" + token);
if (!exists) {
return "重复提交,请稍后再试";
}
// 执行业务逻辑
return orderService.createOrder(orderDTO);
}
}
优缺点:
- ✅ 优点:适配前端重复提交场景、实现简单、无数据库侵入;
- ❌ 缺点:需额外的 Token 分发流程、依赖 Redis、无法解决服务端重试场景(如消息重发)。
方案 5:基于消息幂等(消息队列层)
适用场景:消息队列消费场景(如 RocketMQ/Kafka 消费消息),核心是避免重复消费。实现原理:
- 生产者发送消息时,携带全局唯一的消息 ID(如
msgId或业务唯一键); - 消费者消费前,先校验该消息 ID 是否已消费(存储到 Redis / 数据库);
- 消费完成后,标记消息为 “已消费”(校验 + 标记需原子操作)。
代码示例(RocketMQ 消费幂等) :
@Component
public class OrderMsgConsumer implements RocketMQListener<MessageExt> {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private OrderService orderService;
@Override
public void onMessage(MessageExt messageExt) {
// 1. 获取消息唯一标识(msgId或业务键)
String msgId = messageExt.getMsgId();
String bizKey = new String(messageExt.getBody()).split(",")[0]; // 如订单号
// 2. 幂等校验:Redis SET NX 原子操作
String key = "msg:consumed:" + bizKey;
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "consumed", 24, TimeUnit.HOURS);
if (!success) {
log.warn("消息{}已消费,幂等返回", msgId);
return;
}
// 3. 执行业务消费逻辑
orderService.handleMsg(bizKey);
}
}
进阶优化:
- RocketMQ 内置
MSG_ID但可能重复(如消息重发),建议使用「业务唯一键」(如订单号)作为幂等号; - 消费完成后手动提交 offset(避免自动提交导致重复消费)。
方案 6:基于版本号 / 乐观锁(更新场景)
适用场景:数据更新场景(如商品库存扣减、用户余额修改),核心是避免并发更新导致数据不一致。实现原理:
- 业务表新增「版本号(version)」字段,初始值为 0;
- 更新数据时,SQL 中携带版本号:
UPDATE table SET ..., version = version + 1 WHERE id = ? AND version = ?; - 若更新行数为 0,说明版本号不匹配(已被其他线程更新),判定为重复操作。
代码示例:
@Service
public class StockService {
@Autowired
private StockMapper stockMapper;
@Transactional(rollbackFor = Exception.class)
public String deductStock(Long goodsId, Integer num, Integer version) {
// 乐观锁更新:仅当版本号匹配时才扣减库存
int rows = stockMapper.deductStock(goodsId, num, version);
if (rows == 0) {
log.warn("商品{}库存扣减失败,版本号{}已过期", goodsId, version);
return "操作失败(重复请求/并发更新)";
}
return "库存扣减成功";
}
}
// Mapper XML
<update id="deductStock">
UPDATE t_stock
SET stock = stock - #{num}, version = version + 1
WHERE goods_id = #{goodsId} AND version = #{version}
</update>
优缺点:
- ✅ 优点:无锁设计、性能高、适配更新场景;
- ❌ 缺点:需额外维护版本号字段、重复请求会返回失败(需业务层重试)。
三、方案选型指南(按场景匹配)
| 业务场景 | 推荐方案 | 补充说明 |
|---|---|---|
| 新增数据(订单 / 用户) | 唯一索引 + 幂等号 | 优先数据库层方案,性能最高 |
| 状态变更(支付 / 退款) | 幂等号 + 状态机 + 分布式锁 | 结合状态机避免重复变更 |
| 前端重复提交(表单) | Token 机制 | 简单高效,无侵入 |
| 消息消费(MQ) | 消息 ID + Redis / 数据库标记 | 优先业务唯一键,避免 MSG_ID 重复 |
| 并发更新(库存 / 余额) | 版本号 / 乐观锁 | 无锁设计,适配高并发 |
| 高并发核心操作(秒杀) | 分布式锁 + 唯一索引(双重保障) | 分布式锁防并发,唯一索引兜底 |
四、避坑要点
- 幂等号设计:必须是全局唯一(如雪花 ID),避免 “用户 ID + 时间戳” 因并发重复;
- 原子性保障:校验 + 执行业务必须原子(如数据库事务、Redis Lua 脚本、分布式锁),否则并发下仍会重复;
- 过期清理:Redis 存储的幂等号 / Token 需设置过期时间,避免数据堆积;
- 异常处理:捕获幂等异常时,需返回 “成功”(而非 “重复操作”),保证结果一致性;
- 兜底方案:核心场景建议 “双重幂等”(如分布式锁 + 唯一索引),避免单一方案失效。
五、面试高频考点
- **幂等性的核心是什么?**答:核心是 “唯一性标识 + 原子性校验”,确保重复操作无业务副作用。
- **分布式锁和乐观锁的选型区别?**答:分布式锁适合高并发写、强一致性场景(如秒杀),但有性能损耗;乐观锁适合读多写少、并发更新场景,无锁设计性能更高。
- **消息消费幂等为什么不用 MSG_ID?**答:MQ 的 MSG_ID 可能因消息重发(如 Broker 重启)重复,建议用业务唯一键(如订单号)作为幂等号。