分类:后端开发 · 作者:管理员 · 发布于 2026-09-26 · 阅读 0 · 标签:接口幂等 / Spring Boot / Redis / 分布式锁 / 消息队列
背景:幂等性——比想象中更难
做后端开发的,几乎没人逃过幂等坑。最典型的场景:支付回调到达两次,库存扣了双倍;用户狂点提交按钮,生成了两条重复订单。我最早接手的一个电商中台项目,上线第一周就因为 MQ 重复消费导致一笔订单退款退了两次,财务对账对了一整天。
所谓幂等(Idempotent),就是同一个请求无论被调用多少次,产生的结果都是一致的。本文从最朴素的本地消息表方案讲起,逐步演进到基于 Redis 的 Token 预生成方案,最后给出生产环境最常用的 Redis+Lua 分布式锁方案,逐一分析适用场景与踩坑点。
方案一:本地消息表——"朴素但可靠"
适用于单体应用或数据源可控的早期项目。核心思路:将"业务操作"与"幂等记录"放在同一个本地事务中,利用数据库的唯一约束天然去重。
设计
- 建一张
idempotent_record表,以业务幂等键(如支付单号+退款批次)做唯一索引 - 业务操作前先 insert 幂等记录,成功则执行业务逻辑,失败(唯一键冲突)则直接返回已处理
- 业务操作与 insert 在同一个 @Transactional 内
核心代码
@Service
public class RefundService {
@Autowired
private IdempotentRecordMapper idempotentMapper;
@Autowired
private RefundOrderMapper refundOrderMapper;
@Transactional(rollbackFor = Exception.class)
public RefundResult processRefund(String refundNo, String orderNo, BigDecimal amount) {
// 1. 尝试插入幂等记录 —— 唯一键冲突即重复
IdempotentRecord record = new IdempotentRecord();
record.setBizKey(refundNo); // 唯一约束: biz_key
record.setBizType("REFUND");
record.setStatus(0);
try {
idempotentMapper.insert(record);
} catch (DuplicateKeyException e) {
// 已处理过,查询已有结果返回
RefundResult existed = refundOrderMapper.selectResultByRefundNo(refundNo);
return RefundResult.repeated(existed);
}
// 2. 执行业务 —— 调用支付网关退款
RefundResult result = payGateway.refund(orderNo, amount);
// 3. 更新幂等记录状态
record.setStatus(1);
record.setResultJson(JSON.toJSONString(result));
idempotentMapper.updateByBizKey(record);
return result;
}
}
DDL
CREATE TABLE `idempotent_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`biz_key` VARCHAR(128) NOT NULL COMMENT '业务幂等键,如退款单号',
`biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型: REFUND/ORDER/COUPON',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-处理中 1-已完成',
`result_json` JSON DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_key` (`biz_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
踩坑记录
- ⚠️ 超时场景的死锁: 如果 insert 幂等记录后业务逻辑超时回滚,insert 也一起回滚了。下次同一个请求过来又能 insert 进去 —— 等于没防住。解决方案:幂等记录表与业务表分离事务,幂等 insert 用单独的事务管理器提前提交。
- ⚠️ 分布式环境失效: 多个服务实例共享同一个数据库才能用。拆库拆表后唯一索引跨不了库,这个方案就废了。
- ⚠️ 垃圾回收: 幂等记录只增不删,需要定时任务清理过期数据(保留 30 天足够)。
方案二:Redis Token 预生成——"防重复提交"利器
最经典的场景:前端提交订单/表单前,先向后端申请一个一次性 Token,提交时带上 Token,后端消费后删除。Token 本身在 Redis 中设置过期时间,天然防重复。
设计
- 客户端调用
POST /api/idempotent/token获取一个唯一 Token(UUID) - 后端将 Token 存入 Redis,key=
idem:token:{uuid},过期时间 30 分钟 - 客户端提交业务请求时,在 Header 或 Body 中携带该 Token
- 后端拦截器在执行业务前检查并删除 Token(Redis DEL),删除成功则放行,失败(Token 不存在)则直接拒绝
拦截器实现
@Component
public class IdempotentInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) return true;
HandlerMethod hm = (HandlerMethod) handler;
Idempotent anno = hm.getMethodAnnotation(Idempotent.class);
if (anno == null) return true;
String token = request.getHeader("Idempotent-Token");
if (StringUtils.isBlank(token)) {
throw new BizException(400, "缺少幂等 Token");
}
// 使用 Lua 脚本实现「检查并删除」的原子操作
String lua = "if redis.call('GET', KEYS[1]) == ARGV[1] " +
"then return redis.call('DEL', KEYS[1]) " +
"else return 0 end";
String key = "idem:token:" + token;
Long result = redisTemplate.execute(
(RedisCallback<Long>) con ->
con.eval(lua.getBytes(), ReturnType.INTEGER, 1,
key.getBytes(), token.getBytes())
);
if (result == null || result == 0) {
throw new BizException(429, "重复请求或 Token 已过期");
}
return true;
}
}
踩坑记录
- ⚠️ 先查再删不是原子的: 很多人写
if(redisTemplate.hasKey(key)) {redisTemplate.delete(key);}——高并发下两个请求同时 hasKey 都返回 true,都继续执行,重复了。必须用 GET + DEL 的 Lua 脚本 保证原子性。 - ⚠️ Token 泄露风险: 如果 Token 在 URL 参数中传递,会被浏览器历史/Referer 泄露。必须走 Header。
- ⚠️ 不适合异步/消息场景: MQ 消费方没法先去拿个 Token。这个方案只适合同步请求-响应模式。
方案三:Redis+Lua 分布式锁——"最通用的重型武器"
适合高并发、分布式的核心链路。不需要预申请 Token,直接在业务入口处用 Redis SET NX + Lua 脚本 做分布式锁,结合业务幂等键(如订单号、退款单号)加锁。
设计
- 业务幂等键 =
biz:{type}:{bizId},如biz:refund:RF20260926001 - 使用
SET key value NX EX 30获取锁,业务完成后释放 - 释放时用 Lua 脚本校验 value(防止误删其他线程的锁)
- 结合本地消息表的落盘记录做兜底(二次确认)
工具类
@Component
public class RedisDistributedLock {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LOCK_SCRIPT =
"if redis.call('GET', KEYS[1]) == ARGV[1] " +
"then return redis.call('DEL', KEYS[1]) " +
"else return 0 end";
/**
* 尝试获取锁,成功返回 true
* @param key 业务幂等键
* @param value 随机值(UUID),用于安全释放
* @param expire 锁过期时间,秒
*/
public boolean tryLock(String key, String value, long expire) {
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, value, expire, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
/**
* 安全释放锁(Lua 保证原子性)
*/
public boolean unlock(String key, String value) {
Long result = redisTemplate.execute(
(RedisCallback<Long>) con ->
con.eval(LOCK_SCRIPT.getBytes(),
ReturnType.INTEGER, 1,
key.getBytes(), value.getBytes())
);
return Long.valueOf(1L).equals(result);
}
}
业务应用
@Service
public class OrderService {
@Autowired
private RedisDistributedLock lock;
@Transactional(rollbackFor = Exception.class)
public OrderResult createOrder(CreateOrderRequest req) {
String lockKey = "biz:order:" + req.getOrderNo();
String lockVal = UUID.randomUUID().toString();
// 1. 获取分布式锁
if (!lock.tryLock(lockKey, lockVal, 10)) {
// 重复请求 —— 查询已有结果返回
return OrderResult.repeated(orderMapper.selectByOrderNo(req.getOrderNo()));
}
try {
// 2. 查询本地幂等记录做二次校验(兜底)
Order existed = orderMapper.selectByOrderNo(req.getOrderNo());
if (existed != null) {
return OrderResult.repeated(fromEntity(existed));
}
// 3. 执行业务逻辑
// ... 校验库存、冻结金额、生成订单 ...
orderMapper.insert(orderEntity);
// 4. 发送消息 —— 使用 confirm 机制确保投递
rabbitTemplate.convertAndSend("order.exchange", "order.created", orderEntity);
return OrderResult.success(orderEntity);
} finally {
// 5. 安全释放锁
lock.unlock(lockKey, lockVal);
}
}
}
踩坑记录
- ⚠️ 锁超时释放后业务还没做完: 这是分布式锁最经典的坑。业务执行超过锁的 expire 时间,锁自动释放,另一个请求拿到锁进来执行了。解决方案:看门狗(Watchdog)机制——用一个后台线程每过 1/3 过期时间就续约一次。或者干脆把过期时间设大(如 30 秒),配合本地事务超时兜底。
- ⚠️ Redis 主从切换导致的锁丢失: 主节点挂了但锁还没同步到从节点,新主节点没有锁记录,两个请求都能拿到锁。Redlock 算法可以解决,但开销大。对大部分业务场景,配合本地数据库的唯一索引做第二轮兜底已经足够。
- ⚠️ 不要在 finally 里只删 key 不校验 value: 如果业务执行太久锁已经自动释放,另一个请求拿到了锁,你的 finally 直接 DEL 会把人家的锁也删了。必须用 Lua 脚本校验 value。
三种方案对比
| 维度 | 本地消息表 | Redis Token | Redis+Lua 分布式锁 |
|---|---|---|---|
| 适用场景 | 单体/读写同一库 | 同步提交表单 | 高并发分布式核心链路 |
| 复杂度 | 低(DDL+事务) | 中(拦截器+Lua) | 高(锁管理+续约+兜底) |
| 原子性保障 | 数据库唯一约束 | Lua 脚本(GET+DEL) | SET NX + Lua 释放 |
| MQ/异步场景 | ✅ 天然支持 | ❌ 不支持 | ✅ 需配合落盘兜底 |
| 性能损耗 | 低(单表唯一索引) | 中(两次网络开销) | 低(单次 SET NX) |
| 分布式支持 | ❌ 仅同库 | ✅ Redis 共享 | ✅ Redis 共享 |
最佳实践总结
- 先从业务出发选方案: 如果只是防表单重复提交,Redis Token 方案最简单直接。如果是支付/退款这种资金链路,必须本地落盘+Redis 锁双重保障。
- Lua 脚本是原子性的基石: 不管是"检查再删除"还是"安全释放锁",永远用 Lua 脚本一次搞定,别在应用层写 get→if→del。
- 分布式锁要配合落盘兜底: 锁可能丢,但数据库的唯一索引不会骗人。分布式锁做第一道拦截,数据库做第二道保证,两条腿走路才稳。
- 幂等键的设计要可回溯: 用业务意义明确的唯一标识(支付单号、退款批次号、订单号+操作类型),别用 sessionId 或 requestId 这种纯技术标识——出问题排查时根本对不上业务。
- 别忘了过期清理: 无论是数据库的幂等记录表还是 Redis 的 Token,都得有 GC 机制。数据库用定时任务按月分表或 delete 过期数据,Redis 依赖 TTL 自动过期。不加清理的生产环境撑不过三个月。
幂等性没有银弹。理解每个方案的原理与边界,根据业务场景搭配使用,才是后端工程师的必修课。