在分布式系统中,重复请求是最隐蔽的业务炸弹。 用户手抖、网络抖动、支付回调、消息队列重试…… 任意一次“重复操作”,都有可能导致 重复扣款、重复发货、数据异常。
Spring Boot 实现接口幂等性的 4 种主流方案, 覆盖从“轻量级本地防重”到“分布式高并发控制”。
Token令牌机制 —— 经典且稳
数据库唯一索引 —— 简洁又强一致
分布式锁机制 —— 并发场景的核心武器
请求内容摘要 —— 最通用、最透明
Token 令牌机制:最经典的防重手段
核心思想:
“先拿令牌 → 再执行业务 → 用完即焚”
通过在请求前生成一次性令牌(Token),在执行接口时验证并原子删除,保证每个请求只被处理一次。
package com.icoderoad.order;
import io.seata.core.model.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
private
StringRedisTemplate redis;
/**① 预生成 Token,供前端使用 */
@GetMapping("/token")
public String getToken() {
String token = UUID.randomUUID().toString();
redis.opsForValue().set("tk:" + token, "1", Duration.ofMinutes(10));
return token;
}
/**② 下单接口,Header 中携带令牌 */
@PostMapping
public Result create(@RequestHeader("Idempotent-Token") String token, @RequestBody OrderReq req) {
String key = "tk:" + token;
Boolean first = redis.delete(key);
if (Boolean.FALSE.equals(first)) {
return Result.fail("请勿重复下单");
}
Order order = orderService.create(req);
return Result.ok(order);
}
}
要点解析:
-
UUID生成全局唯一 Token; -
Redis 设置 TTL(10分钟)避免缓存堆积;
-
delete()是原子操作,可安全防重; -
Header 传递令牌,保持接口语义清晰。
数据库唯一索引:最低成本的幂等保证
核心思想:
“唯一键 + 异常即幂等”
通过数据库层面的 唯一索引,让重复请求在插入时直接报错,天然具备幂等特性。
package com.icoderoad.payment;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import javax.persistence.*;
import io.seata.core.model.Result;
import org.springframework.beans.factory.annotation.Autowired;
import java.math.BigDecimal;
@Entity
@Table(name = "t_payment", uniqueConstraints = @UniqueConstraint(columnNames = "transaction_id"))
public class Payment {
@Id
private Long id;
@Column(name = "transaction_id")
private String txId;
private BigDecimal amount;
private String status;
}
@Service
public class PayService {
@Autowired
private PaymentRepo repo;
public Result pay(PayReq req) {
try {
Payment p = new Payment();
p.setTxId(req.getTxId());
p.setAmount(req.getAmount());
p.setStatus("SUCCESS");
repo.save(p);
return Result.ok("支付成功");
} catch (DataIntegrityViolationException e) {
Payment exist = repo.findByTxId(req.getTxId());
return
Result.ok("已支付", exist.getId());
}
}
}
要点解析:
-
uniqueConstraints确保事务级防重; -
异常捕获后直接返回幂等响应;
-
无需外部依赖,兼容老旧系统。
分布式锁机制:高并发下的“互斥利器”
核心思想:
“对关键资源加锁,谁抢到谁执行”
在并发操作中通过 Redisson 或 Zookeeper 实现互斥访问,保障同一用户或订单只被处理一次。
package com.icoderoad.stock;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
import io.seata.core.model.Result;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
@Service
public class StockService {
@Autowired
private
RedissonClient redisson;
@Autowired
private StockRepo repo;
public Result deduct(DeductCmd cmd) {
String lockKey = "lock:stock:" + cmd.getProductId();
RLock lock = redisson.getLock(lockKey);
try {
if (!lock.tryLock(3, 5, TimeUnit.SECONDS)) {
return Result.fail("处理中,请稍后");
}
if (repo.existsByRequestId(cmd.getRequestId())) {
return
Result.ok("已扣减");
}
repo.deductStock(cmd);
return Result.ok("扣减成功");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Result.fail("系统繁忙");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
要点解析:
-
tryLock避免线程永久阻塞; -
Redisson 自动续期机制防止死锁;
-
requestId与唯一索引配合,形成“双保险”; -
适合秒杀、库存、并发下单等高频场景。
请求内容摘要:最透明的零侵入方案
核心思想:
“以请求内容为幂等标识,天然适配所有接口”
将请求体生成 MD5/SHA256摘要 作为幂等键,通过 Redis 进行原子性验证,真正做到“客户端无感”。
package com.icoderoad.common.aop;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import org.apache.commons.io.IOUtils;
import javax.servlet.http.HttpServletRequest;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.annotation.ElementType;
import java.lang.annotation.RetentionPolicy;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
int expire() default 3600; // 秒
}
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redis;
@Around("@annotation(idem)")
public Object around(ProceedingJoinPoint pjp, Idempotent idem) throws Throwable {
HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String body = IOUtils.toString(req.getReader());
String digest = DigestUtils.md5DigestAsHex(body.getBytes(StandardCharsets.UTF_8));
String key = "idem:digest:" + digest;
Boolean absent = redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(idem.expire()));
if (Boolean.FALSE.equals(absent)) {
return Result.fail("重复请求");
}
try {
return pjp.proceed();
} catch (Exception e) {
redis.delete(key);
throw e;
}
}
}
使用示例:
import io.seata.core.model.Result;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/transfer")
public class TransferController {
@PostMapping
@Idempotent(expire = 7200)
public Result transfer(@RequestBody TransferCmd cmd) {
return
Result.ok(transferSvc.doTransfer(cmd));
}
}
要点解析:
-
使用 MD5 压缩请求体,确保唯一性;
-
setIfAbsent保证 Redis 原子操作; -
异常回滚防止误判;
-
注解 + AOP 实现零侵入式幂等控制。
方案对比与落地建议
| 方案类型 | 实现复杂度 | 外部依赖 | 典型场景 |
|---|---|---|---|
| Token令牌 | 中等 | Redis | 下单、支付、表单提交 |
| 唯一索引 | 低 | 无 | 注册、支付回调 |
| 分布式锁 | 中高 | Redis/ZK | 秒杀、库存扣减 |
| 内容摘要 | 中 | Redis | 转账、接口回调 |
结语:幂等性不是装饰,而是底线
幂等控制是后端架构中防止业务灾难的安全阀。 选择方案时请遵循以下三条原则:
-
先业务分析,再加锁 —— 能靠唯一键解决的,不必上分布式锁;
-
核心路径必防重 —— 特别是支付、库存、转账等资金相关接口;
-
幂等监控要同步上线 —— 及时发现、告警、自动恢复。
记住:幂等性不是性能开销,而是系统稳定的基石。
从 Token 到摘要,每一种方案都有其价值, 真正的架构师,懂得“用最小的代价,守住最大的安全”。
2273

被折叠的 条评论
为什么被折叠?



