分布式幂等怎么落地到业务接口?从 Token 到 Redisson 锁

关键词:幂等性 / 分布式锁 / 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

相关推荐
x***r1511 小时前
jdk-11.0.16.1_windows使用步骤详解(附JDK 11环境变量配置与验证教程)
java·开发语言·windows
弹简特1 小时前
【Java项目-轻聊】01-项目演示+项目介绍+准备工作+项目源码
java
luck_bor2 小时前
File类&递归作业
java·开发语言
武子康2 小时前
Java-07 深入浅出 MyBatis数据库一对多关系模型实战:表结构设计与查询实现
java·后端
花椒技术3 小时前
企业内部 Agent 落地复盘:Gateway、Skill 和二次确认如何串起受控业务执行
后端·agent·ai编程
Agent手记4 小时前
制造业生产流程自动化,Agent需要具备哪些能力?深度拆解2026工业级智能体落地范式与核心架构
大数据·人工智能·ai·架构·自动化
REDcker4 小时前
Linux OverlayFS详解
java·linux·运维
Royzst4 小时前
xml知识点
java·服务器·前端
Yunzenn5 小时前
深度分析字节最新研究cola-DLM 第 07 章:推理流水线逐行拆解 —— 从 prompt 到生成文本
人工智能·驱动开发·深度学习·chatgpt·架构·prompt·github
我是一颗柠檬5 小时前
【MySQL全面教学】MySQL事务与ACID Day9(2026年)
数据库·后端·mysql