
关键词:幂等性 / 分布式锁 / Redisson / Token 机制 / SpEL 动态键 / 重复提交
一、问题场景:一个按钮点了两下,数据库多了两条记录
面试必考题:分布式系统如何保证接口幂等?
真实场景比面试题残酷得多:
- 用户网卡,提交订单按钮点了 3 次 → 3 条相同的订单
- 支付回调由于网络抖动重试了 5 次 → 用户被扣了 5 次钱
- MQ 消费者重启后重新消费 → 库存被扣成负数
- 前端没有防重复提交,loading 遮罩失效 → 审批单重复创建
这些问题的根因都一样:同一个业务操作被多次执行,但系统没有识别出"这是同一个操作" 。
Forge Admin 的 forge-starter-idempotent 模块给出的答案是:一个 @Idempotent 注解,三种策略,覆盖从"直接拒绝"到"返回缓存结果"再到"Token 防重"的全部场景。
二、解决方案:一个注解,三种策略
2.1 @Idempotent 注解
java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String prefix() default "idempotent:"; // 幂等键前缀
int expire() default 600; // 键过期时间(秒)
String key() default ""; // SpEL 表达式
String message() default "请勿重复提交"; // 提示消息
IdempotentStrategy strategy() default RETURN_CACHE; // 策略
int cacheExpire() default 3600; // 结果缓存时间
boolean cacheResult() default true; // 是否缓存结果
boolean deleteKeyAfterSuccess() default false; // 成功后删键
}
2.2 策略一:严格模式(STRICT)------直接拒绝
适用于:支付、转账等绝不允许重复的场景。
ini
@Idempotent(
key = "'payment:' + #request.paymentId",
strategy = IdempotentStrategy.STRICT,
message = "支付请求正在处理中,请勿重复提交"
)
public PaymentResult pay(PaymentRequest request) {
// 支付逻辑
}
实现原理:
arduino
请求进入
→ lockManager.tryLock(idempotentKey, 3s, 5s)
→ 获取锁成功 → 执行业务逻辑 → 释放锁 → 返回结果
→ 获取锁失败 → 说明有同名请求正在处理 → throw IdempotentException
用 Redisson 分布式锁做互斥:同一时刻只有一个请求能获得锁并执行业务逻辑,后来的请求直接拒绝。
2.3 策略二:缓存模式(RETURN_CACHE)------返回上次结果
适用于:查询类接口、不介意返回"旧"结果的场景。这是默认策略。
ini
@Idempotent(
key = "'order:query:' + #orderId",
strategy = IdempotentStrategy.RETURN_CACHE,
cacheExpire = 600 // 缓存 10 分钟
)
public OrderDTO queryOrder(Long orderId) {
return orderService.getById(orderId);
}
实现原理:
ini
请求进入
→ lockManager.tryLock(idempotentKey)
→ 获取锁成功:
→ 执行业务逻辑
→ 将结果缓存到 Redis Hash(key=idempotentKey, field=result)
→ 释放锁
→ 返回结果
→ 获取锁失败(有人正在执行):
→ 等待 100ms → 查 Redis 缓存
→ 有缓存结果 → 直接返回缓存
→ 无缓存 → 说明执行失败,抛异常
这个策略的精妙之处在于并发请求的"等一等"逻辑:不是直接返回缓存(可能还是空的),而是等 100ms 后重查,给第一个请求留出执行时间。
2.4 策略三:Token 模式(TOKEN_REQUIRED)------先领号再办事
适用于:表单提交、审批操作等需要"先获取凭证"的场景。这是最安全也最复杂的一种。
ini
@Idempotent(
key = "'leave:submit:' + #requestId",
strategy = IdempotentStrategy.TOKEN_REQUIRED,
deleteKeyAfterSuccess = true // 一次性操作,提交成功删除幂等键
)
public LeaveResult submitLeave(LeaveRequest request) {
// 请假申请逻辑
}
Token 模式是多一步流程的:
bash
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 打开表单 │── GET /idempotent/token ─→│ 后端 │ │ Redis │
│ │←── { token: "abc123" } │ │ │ │
│ │ │ 存入 token │─────→ │ token:abc123 │
│ │ │ 状态:UNUSED│ │ = UNUSED │
│ │ │ │ │ │
│ 提交表单 │── POST /leave/submit ──→│ │ │ │
│ Header: │ X-Idempotent-Token: │ 校验 token│ │ │
│ │ abc123 │ ←─────── │ │ │
│ │ │ token 存在且 UNUSED → 标记 CONSUMED│
│ │ │ │─────→ │ = CONSUMED │
│ │ │ 执行业务 │ │ │
│ │←── 201 Created ────────│ │ │ │
└──────────┘ └──────────┘ └──────────┘
关键点:Token 的"验证 + 消费"是一个原子操作------先标记为 CONSUMED,再执行业务。如果有人拿同一个 Token 第二次请求,校验时发现已经是 CONSUMED,直接拒绝。
三、数据结构:幂等键与缓存存储
3.1 Redis 存储结构
所有幂等数据存储在 Redis 中,避免了应用层内存缓存的重启丢失问题:
锁键(Redisson 分布式锁):
ini
Key: idempotent:lock:{幂等键}
Value: Redisson RLock(waitTime=3s, leaseTime=5s)
结果缓存(Hash 结构):
makefile
Key: idempotent:result:{幂等键}
Fields:
- requestId: "请求唯一标识"
- result: "序列化的业务结果"
- status: "SUCCESS / FAIL"
- executeTime: "2026-05-27 10:30:00"
TTL: 3600s(默认)
Token 缓存:
makefile
Key: idempotent:token:{token值}
Value: UNUSED / CONSUMED
TTL: 300s(未使用时) / 60s(消费后)
3.2 幂等键生成规则
typescript
public String generate(ProceedingJoinPoint joinPoint, String prefix, String key) {
if (key != null && !key.isEmpty()) {
// 使用 SpEL 解析,支持 #paramName.property 语法
Object spelResult = SpelUtil.parse(key, args, paramNames);
keyValue = spelResult.toString();
} else {
// 兜底:方法签名 + 参数值的 MD5
keyValue = DigestUtil.md5Hex(methodSignature + ":" + argsStr);
}
return prefix + keyValue;
}
SpEL 表达式实战:
| 场景 | 表达式 | 生成的键 |
|---|---|---|
| 按单个参数 | 'order:' + #orderId |
idempotent:order:12345 |
| 引用对象属性 | 'payment:' + #request.paymentId |
idempotent:payment:PAY001 |
| 多参数组合 | 'user:' + #userId + ':action:' + #type |
idempotent:user:100:action:transfer |
| 常量键(全接口防重) | 'global:submit' |
idempotent:global:submit |
注意 :SpEL 解析失败(表达式拼错、参数名为空)时 keyValue 为空字符串,最终键为 idempotent:------所有请求共享同一个键。生产环境务必配合参数校验,或在 SpEL 外层包裹异常兜底。
四、实现链路:从注解到拒绝的每一步
4.1 切面入口:IdempotentAspect
java
@Around("@annotation(idempotent) || @within(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 1. 生成幂等键
String idempotentKey = keyGenerator.generate(joinPoint, idempotent.prefix(), idempotent.key());
// 2. 根据策略分发到对应 handler
IdempotentStrategyHandler handler = strategyHandlerMap.get(idempotent.strategy());
return handler.handle(joinPoint, idempotent, idempotentKey);
}
切面只做两件事:生成键 + 策略分发。业务逻辑全部在 StrategyHandler 中,新增策略只需实现接口。
4.2 Redisson 分布式锁
java
public class RedissonLockManager implements LockManager {
private static final String LOCK_PREFIX = "idempotent:lock:";
private static final long DEFAULT_WAIT_TIME = 3; // 秒
private static final long DEFAULT_LEASE_TIME = 5; // 秒
@Override
public boolean tryLock(String idempotentKey, long waitTime, long leaseTime) {
RLock lock = redissonClient.getLock(LOCK_PREFIX + idempotentKey);
return lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
}
@Override
public void unlock(String idempotentKey) {
RLock lock = redissonClient.getLock(LOCK_PREFIX + idempotentKey);
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
锁的 key = idempotent:lock: + 幂等键。tryLock 参数:最长等待 3 秒,锁持有最长 5 秒。如果业务执行超过 5 秒锁会自动释放(防止死锁),但这也意味着一个已知的风险:leaseTime 硬编码 5 秒,如果业务执行超过 5 秒可能导致两个请求同时执行。
4.3 Token 机制:验证与消费
Token 是幂等方案中最容易被"简化"掉的一环。很多开发者会问:"直接用幂等键不行吗,为什么还要 Token?"
答案是:幂等键通常基于请求体参数生成,但表单提交场景中,用户可能在点提交按钮之前就已经构造好了请求参数。如果用户真的在 10 秒内点了两次按钮(两次请求参数完全相同),幂等键也是完全相同的,第二种策略(缓存模式)可以兜底。
但 Token 模式解决了一个更微妙的场景:用户打开表单页面,过了 5 分钟再提交。这期间另一个标签页也打开了同一个表单并提交了。没有 Token 的话,两次提交的幂等键可能因为参数不同(如不同的表单草稿 ID)而无法拦截。Token 机制保证了"一次打开、一次提交"的严格对应。
当前 Token 实现的已知可优化点:validate + consume 是两个独立操作(先查 Redis 判断 UNUSED,再 SET 为 CONSUMED),存在并发竞争窗口。线上高并发场景建议升级为 Lua 脚本原子操作:
lua
-- 原子验证+消费
local status = redis.call('GET', KEYS[1])
if status == 'UNUSED' then
redis.call('SET', KEYS[1], 'CONSUMED', 'EX', ARGV[1])
return 1
end
return 0
五、设计取舍:我们做了哪些选择,为什么
5.1 为什么选择 Redisson 而不是手撸 RedLock?
Redisson 的 RLock 基于 Redis 的 SET NX PX + Lua 脚本实现,内置了 WatchDog 自动续期机制。如果你自己用 SET NX EX 5,需要额外处理"业务执行超时但锁即将过期"的问题。Redisson 的 WatchDog 每 lockTimeout/3 检查一次,如果锁仍被持有则自动续期。
代价是引入 Redisson 依赖(约 3MB),但对于需要可靠分布式锁的场景,这远比手撸 RedLock 要省心。
5.2 为什么默认策略是 RETURN_CACHE 而不是 STRICT?
从安全角度,STRICT(直接拒绝)最安全。但从用户体验角度,RETURN_CACHE(返回缓存结果)更友好:
- 用户刷新页面导致重复查询 → 返回缓存结果,无感知
- 网络超时导致前端重试 → 返回缓存结果,不会报"请勿重复提交"
- 只有真正需要强幂等的支付/转账场景才需要 STRICT
这是一种"宽容的幂等"------不是阻止你再次请求,而是确保多次请求的效果一致。
5.3 为什么结果缓存用 Redis Hash 而不是 String?
幂等结果不仅仅是返回值,还包括请求 ID、执行状态、执行时间等元数据。Hash 结构可以独立更新每个字段而不影响其他字段,且支持 HGET 按需读取。
5.4 Prometheus 监控:幂等不只是安全,也是可观测性
模块内置了 10 个 Prometheus 指标:
| 指标 | 说明 |
|---|---|
idempotent_requests_total |
总请求数 |
idempotent_duplicate_total |
重复请求数 |
idempotent_cache_hit_total |
缓存命次数 |
idempotent_execute_duration |
执行耗时分布 |
idempotent_lock_wait_duration |
锁等待耗时 |
这些指标可以直接接入 Grafana 面板,让你实时看到哪些接口正在被频繁重复调用------这在排查生产问题时非常有用。
六、二开指南:三步接入幂等
6.1 引入依赖
xml
<dependency>
<groupId>com.mdframe.forge</groupId>
<artifactId>forge-starter-idempotent</artifactId>
</dependency>
依赖已包含 Redisson,不需要额外配置 Redis 连接(复用项目已有的 Redis 配置)。
6.2 基础用法
less
@RestController
@RequestMapping("/api/order")
public class OrderController {
@PostMapping("/create")
@Idempotent(key = "'order:create:' + #request.orderNo")
public RespInfo<OrderDTO> createOrder(@RequestBody CreateOrderRequest request) {
return RespInfo.success(orderService.create(request));
}
}
前端无需任何额外配置------对于 RETURN_CACHE 和 STRICT 策略,幂等键完全由后端生成。
6.3 Token 模式需要前端配合
csharp
// 1. 进入表单页面时获取 Token
const tokenResp = await axios.get('/api/idempotent/token')
const token = tokenResp.data.data
// 2. 提交时带上 Token
await axios.post('/api/order/create', formData, {
headers: {
'X-Idempotent-Token': token
}
})
6.4 自定义幂等键生成器
如果 SpEL 表达式不够用(比如需要从更复杂的上下文提取键值),可以实现自定义生成器:
typescript
@Component
public class CustomKeyGenerator implements IdempotentKeyGenerator {
@Override
public String generate(ProceedingJoinPoint joinPoint, String prefix, String key) {
// 从 ThreadLocal 或其他上下文获取租户 ID
String tenantId = TenantContextHolder.getTenantId();
String methodKey = generateMethodKey(joinPoint);
return prefix + tenantId + ":" + methodKey;
}
}
七、体验预告
forge-starter-idempotent 用一个注解解决了分布式幂等的三大核心场景:直接拒绝、返回缓存、Token 防重。从实际落地来看,标准 CRUD 接口增加幂等保护只需 1 行代码(加 @Idempotent 注解),Token 模式额外需要前后端各 3 行代码。
我们也在持续迭代:下一版本将支持基于数据库的幂等键持久化(适合对 Redis 不可用的场景),以及 Token 的 Lua 原子操作升级。
系列文章到此结束。如果你对 Forge Admin 的任何模块有深入兴趣,欢迎提 Issue 或在 GitHub Discussions 中交流。
体验 Forge Admin
- 在线演示 :Forge Admin 后台管理
- 默认账号:admin / 123456
- 多租户体验:登录后查看不同租户的数据隔离效果
- Gitee :ForgeLab/forge-admin
- GitHub :yaomindong1996/forge-admin