一、设计目标

  • 通用性:不绑定具体业务(剧情、视频、评论等),通过注解 + 配置即可在任意接口启用幂等控制。
  • 高并发:主通路基于 Redis + 分布式锁(Redisson),单次请求只做 O(1) 原子操作,可承受高 QPS。
  • 多策略:支持多种幂等 key 生成方式(令牌、Header、参数、SpEL)与多种存储方式(Redis、DB)。
  • 易用性:开发者只需在方法上加一个 @Idempotent 注解即可使用。
  • 可观测性:幂等 key / 令牌带有业务场景描述,便于日志、监控和排查问题。

二、整体技术架构

2.1 组件划分

  • 幂等注解层

    • @Idempotent:用于声明某个接口/方法需要幂等控制,并指定 key 生成方式、存储方式、过期时间等。
  • AOP 切面层

    • IdempotentAspect:在方法执行前后统一完成:
      • 幂等 key 生成
      • 幂等令牌校验与消费(可选)
      • 分布式锁获取与释放(可选)
      • 幂等记录写入(Redis / DB)
  • Key 生成与存储层

    • IdempotentKeyGenerator:根据 IdempotentType + 注解配置 + 请求上下文生成幂等 key 片段。
    • IdempotentStorage:幂等记录存储抽象接口。
    • RedisIdempotentStorage:Redis 实现,适合高并发主场景。
    • DbIdempotentStorage:DB 实现,适合强一致、审计或异地多活场景。
  • 分布式锁层

    • IdempotentLockManager:分布式锁抽象接口。
    • RedissonLockManager:基于 Redisson 的 Redis 分布式锁实现。
  • 幂等令牌层

    • IdempotentTokenService:幂等令牌服务接口。
    • RedisIdempotentTokenService:基于 Redis 的一次性令牌实现。
    • IdempotentTokenController:对外提供令牌获取接口。
  • 异常与 Web 层

    • IdempotentException / DuplicateRequestException:幂等相关异常类型。
    • IdempotentExceptionHandler:统一将幂等异常转为结构化 JSON 返回。
    • IdempotentDemoController:示例接口,演示不同幂等使用方式。

2.2 请求处理时序

以“基于幂等令牌的 POST 提交”为例:

  1. 前端调用 /api/idempotent/token 获取一次性幂等令牌 token
  2. 前端在实际业务请求中,将 token 放入 Header:Idempotency-Token: {token}
  3. 请求进入 @Idempotent 标记的方法:
    1. IdempotentAspect 拦截方法调用。
    2. IdempotentKeyGenerator 从 Header 读取 token,生成幂等 key 片段。
    3. RedisIdempotentTokenService 校验并消费此令牌(一次性)。
    4. 根据方法签名 + key 片段构建全局幂等 key:{className}#{methodName}:{token}
    5. 根据注解配置选择存储实现(Redis / DB)。
    6. 如开启分布式锁,则通过 RedissonLockManager 获取锁。
    7. 存储层执行 trySet(idempotentKey, ttl)
      • 成功:当前请求是首个有效请求,继续执行业务逻辑。
      • 失败:说明已存在相同请求(正在处理或已成功),抛出 DuplicateRequestException
    8. 业务逻辑执行完成,返回结果。
    9. 如业务异常且 deleteKeyOnError=true,则删除幂等 key,允许重试。

三、核心组件实现说明

3.1 幂等注解 @Idempotent

  • 位置:com.video.idempotent.annotation.Idempotent
  • 关键字段:
    • IdempotentType type():key 生成方式
      • TOKEN:从 Header 中读取一次性幂等令牌。
      • HEADER:从指定 Header 中读取幂等 key(如 X-Request-Id)。
      • PARAM:从指定请求参数中读取幂等 key。
      • SPEL:通过 SpEL 表达式从方法入参计算幂等 key。
    • IdempotentStorageType storage():幂等记录存储方式(REDIS / DB)。
    • String key():当 type=SPEL 时使用,如 "#request.requestId"
    • String headerName()TOKEN/HEADER 模式对应 Header 名称,默认 Idempotency-Token
    • String paramName()PARAM 模式对应参数名,默认 idempotentKey
    • long expireSeconds():幂等记录 TTL,默认 300 秒。
    • boolean useLock():是否启用分布式锁,默认开启。
    • boolean deleteKeyOnError():业务异常时是否删除幂等 key,允许后续重试。
    • String description():业务场景描述,用于日志与监控。

3.2 Key 生成器 IdempotentKeyGenerator

  • 位置:com.video.idempotent.core.IdempotentKeyGenerator
  • 作用:根据注解和请求上下文生成幂等 key 片段(不含方法签名前缀):
    • TOKEN/HEADER:从当前 HttpServletRequest 的 Header 中取值。
    • PARAM:优先从请求参数中取值,如无则根据方法参数名查找。
    • SPEL:使用 Spring SpEL 解析,如 "#dto.userId + ':' + #dto.bizId"
  • 实际最终幂等 key 为:
    • "{fullyQualifiedClassName}#{methodName}:{keyPart}"
    • 这样保证不同方法间幂等 key 自然隔离。

3.3 幂等存储抽象与实现

3.3.1 接口 IdempotentStorage
  • 位置:com.video.idempotent.core.IdempotentStorage
  • 方法:
    • IdempotentStorageType type():返回实现类型(REDIS / DB)。
    • boolean trySet(String key, Duration ttl):仅当 key 不存在时成功写入,并设置过期时间。
    • boolean exists(String key):判断 key 是否存在且未过期。
    • void delete(String key):删除 key。
3.3.2 Redis 实现 RedisIdempotentStorage
  • 位置:com.video.idempotent.storage.RedisIdempotentStorage
  • 存储结构:
    • key:idempotent:key:{idempotentKey}
    • value:当前时间戳(或占位符),不依赖具体内容。
  • 实现要点:
    • 使用 RedisTemplate<String, Object>setIfAbsent 实现原子 SETNX
    • 写入时设置 TTL,过期后自动清理,避免 Redis key 堆积。
    • 只进行简单 O(1) 操作,不依赖 Lua 脚本,适合高并发调用。
3.3.3 DB 实现 DbIdempotentStorage
  • 位置:com.video.idempotent.storage.DbIdempotentStorage
  • 依赖实体与 Mapper:
    • com.video.entity.IdempotentRecord
    • com.video.mapper.IdempotentRecordMapper
  • 建议表结构(已在实体中约定):
CREATE TABLE a_idempotent_record (
    id             BIGINT       NOT NULL PRIMARY KEY,
    idempotent_key VARCHAR(128) NOT NULL,
    expire_time    DATETIME     NOT NULL,
    create_time    DATETIME     NOT NULL,
    UNIQUE KEY uk_idempotent_key (idempotent_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • 实现要点:
    • trySet:插入一条记录,若触发唯一键冲突(DuplicateKeyException),说明幂等 key 已存在,返回 false
    • exists:按 idempotent_keyexpire_time >= now() 查询计数。
    • delete:按 idempotent_key 删除记录。

在高并发写场景推荐首选 Redis,DB 存储适合作审计、报表或低 QPS 管理类操作。

3.4 分布式锁 RedissonLockManager

  • 位置:com.video.idempotent.lock.RedissonLockManager
  • 底层使用 RedissonClient 获取 RLock,实现:
    • tryLock(lockKey, ttl)
      • lock.tryLock(0, ttl, TimeUnit.MILLISECONDS),不阻塞等待,拿不到锁则直接返回 false
    • unlock(lockKey):当前线程持有锁时释放。
  • 与幂等存储关系:
    • 在极限高并发下,仅靠 SETNX 即可实现幂等,但使用锁可避免部分竞争开销(例如防止大量线程同时争抢 trySet)。
    • 可通过注解 useLock=false 在部分场景关闭锁,仅使用存储原子操作。

3.5 幂等令牌服务 RedisIdempotentTokenService

  • 位置:com.video.idempotent.token.RedisIdempotentTokenService
  • 接口:IdempotentTokenService
  • 存储结构:
    • key:idempotent:token:{token}
    • value:业务场景描述(可选)
  • 核心逻辑:
    • 生成令牌
      • 生成随机 UUID,去掉横线作为令牌。
      • 写入 Redis,设置 TTL(默认 5 分钟)。
    • 校验并消费令牌
      • 读取 Redis,存在则视为有效,随后删除 key(一次性)。
      • 如果配置了场景描述,可进行简单校验。

3.6 AOP 切面 IdempotentAspect

  • 位置:com.video.idempotent.aspect.IdempotentAspect
  • 关键步骤:
    1. 获取方法与 @Idempotent 注解。
    2. 调用 IdempotentKeyGenerator 生成 key 片段。
    3. type=TOKEN,调用 IdempotentTokenService.validateAndConsume 校验令牌。
    4. 构建完整幂等 key:methodId + ':' + keyPart
    5. 根据 storage 选择存储实现(Redis / DB)。
    6. 计算 TTL,并组合锁 key:默认 idempotent:lock:{idempotentKey}
    7. 如果开启锁:
      • tryLock 失败则抛出 DuplicateRequestException(请求过于频繁或并发)。
    8. 调用存储层 trySet
      • 返回 false:直接抛出 DuplicateRequestException
      • 返回 true:继续执行业务逻辑。
    9. 业务异常时,如 deleteKeyOnError=true,则删除幂等 key,允许重试。

3.7 异常处理 IdempotentExceptionHandler

  • 位置:com.video.idempotent.handler.IdempotentExceptionHandler
  • 作用:
    • 捕获所有 IdempotentException(包含 DuplicateRequestException),统一返回:
{
  "code": 429,
  "message": "请勿重复提交,相同请求已在处理中或已处理完成",
  "data": null
}
  • 避免直接修改 HTTP 状态码,在不影响现有全局异常处理机制的前提下提供明确的业务错误码。

四、使用示例

4.1 幂等令牌获取接口

  • 位置:com.video.idempotent.web.IdempotentTokenController
  • API:GET /api/idempotent/token?scene=createOrder
  • 示例响应:
{
  "code": 200,
  "message": "success",
  "data": {
    "token": "f3a9c81b12c9442e9c0e3f7e9a6d1234",
    "scene": "createOrder"
  }
}

前端在接下来的业务请求中,将 token 放入 Header:

Idempotency-Token: f3a9c81b12c9442e9c0e3f7e9a6d1234

4.2 基于令牌的幂等接口

  • 位置:com.video.idempotent.web.IdempotentDemoController
  • 示例方法:
@PostMapping("/submit/token")
@Idempotent(type = IdempotentType.TOKEN, description = "基于令牌的幂等提交示例")
public Result<String> submitWithToken(@RequestBody DemoRequest request) {
    String bizId = UUID.randomUUID().toString();
    return Result.success("success-token-" + bizId);
}

客户端多次重复提交时,只会有首个请求成功执行,其他请求将收到 429 错误码提示“幂等令牌无效或已被使用”。

4.3 基于业务请求 ID 的幂等接口

@PostMapping("/submit/requestId")
@Idempotent(type = IdempotentType.SPEL, key = "#request.requestId", description = "基于业务请求ID的幂等提交示例")
public Result<String> submitWithRequestId(@RequestBody DemoRequest request) {
    String bizId = UUID.randomUUID().toString();
    return Result.success("success-requestId-" + bizId);
}
  • 业务侧保证 request.requestId 全局唯一(如订单号、交易流水号)。
  • 服务端无需额外令牌接口,直接以业务 ID 作为幂等 key。

4.4 基于 Header 的幂等接口

@PostMapping("/submit/header")
@Idempotent(type = IdempotentType.HEADER, headerName = "X-Request-Id", description = "基于 Header 的幂等提交示例")
public Result<String> submitWithHeader() {
    String bizId = UUID.randomUUID().toString();
    return Result.success("success-header-" + bizId);
}
  • 客户端在每次请求前生成一个全局唯一的 X-Request-Id
  • 适合前后端统一接入幂等控制的场景。

五、不同业务场景下的配置建议

5.1 下单 / 支付类(强幂等、结果敏感)

  • 推荐配置:
    • type = TOKENSPEL(以业务订单号作为 key)。
    • storage = REDIS(高并发主场景)。
    • expireSeconds 视订单有效期设定,例如 1800(30 分钟)。
    • useLock = true,避免极端并发下多次落库/扣款。
    • 业务异常时根据场景决定是否 deleteKeyOnError
      • 若可能产生部分成功(例如已扣券但 DB 写失败),可设置为 false,防止重复请求导致二次扣减,由人工/对账修复。

5.2 表单提交 / 按钮防抖(弱幂等、响应快速)

  • 推荐配置:
    • type = TOKENHEADER(前端统一生成 X-Request-Id)。
    • storage = REDISexpireSeconds 较短(如 60 秒)。
    • useLock = false,仅依赖 Redis 原子写即可。
    • deleteKeyOnError = true,用户操作失败可重试。

5.3 后台管理类批量操作(低 QPS、审计要求高)

  • 推荐配置:
    • type = SPEL,以批次号/任务 ID 作为 key。
    • storage = DB,幂等记录落库,配合审计与报表使用。
    • expireSeconds 可设较长,如 7 天。
    • 通过 DB 唯一约束保证数据层的幂等。

5.4 异步消息消费(MQ 幂等)

  • 可在 MQ 消费方法上增加 @Idempotent 注解,例如:
    • type = SPEL, key = "#message.msgId"
    • 结合 Redis/DB 存储,实现消费层幂等(防止消息重复投递)。

当前示例未直接改造 MQ 消费逻辑,但架构上完全支持上述模式接入。

六、异常处理与重试策略

6.1 异常类型

  • IdempotentException:泛化幂等异常,包含配置错误、令牌校验失败等。
  • DuplicateRequestException:典型的重复请求异常(包含并发场景下的重复)。

6.2 重试策略建议

  • 接口层面

    • 对返回 code=429 的请求,前端应视为“重复提交”,不要做自动重试。
    • 对于网络超时但服务端已经处理成功的情况,幂等策略可以防止再次提交造成重复写。
  • 业务层面

    • 对确实需要重试的操作(如调用第三方支付、发送消息失败),应在业务内部实现可靠重试,而不是简单重发 HTTP 请求。

6.3 失败时是否删除幂等 key

  • deleteKeyOnError = true
    • 适用于失败可以安全重试的场景(如普通表单、简单写库)。
  • deleteKeyOnError = false
    • 适用于失败可能产生“部分成功”副作用的场景(如调用第三方支付已扣款但 DB 写失败)。
    • 建议通过人工或对账系统处理此类异常请求。

七、性能优化与评估方案

7.1 性能设计考量

  • Redis 存储

    • 单次请求只进行 1 次 SETNX + EXPIRE(通过 setIfAbsent + TTL)操作,时延在亚毫秒级。
    • 对于 QPS 1w+ 场景仍能稳定支撑。
  • Redisson 锁

    • 只在高价值接口(如支付)上开启锁,避免全局使用导致额外 RTT 开销。
    • 默认立即返回,不阻塞等待锁,减小线程等待时间。
  • DB 存储

    • 通过唯一键约束保证幂等,QPS 较低时不会成为瓶颈。
    • 建议对 idempotent_key 建立合适长度和前缀的索引,避免索引过大。

7.2 压测方案建议

  1. 压测工具:JMeter、wrk、Gatling 等。

  2. 场景设计

    • 场景 A:单接口高并发重复提交
      • 目标:模拟大量客户端向同一幂等接口(同一个幂等 key)并发提交。
      • 期望:仅首个请求成功,其他全部返回 429;平均响应时间稳定,Redis 命中率高。
    • 场景 B:多不同幂等 key 并发
      • 目标:模拟大量不同用户/订单的并发请求。
      • 期望:成功率 ≈ 100%,Redis 及 DB 不成为瓶颈。
  3. 关键指标

    • QPS:接口吞吐量。
    • 平均/95%/99% 响应时间。
    • Redis 命令执行耗时与 CPU 占用。
    • 数据库 a_idempotent_record 表的写入/查询延迟(仅在 DB 模式下)。

7.3 单元测试示例

  • 位置:src/test/java/com/video/idempotent/IdempotentAnnotationTest.java
  • 作用:验证 @Idempotent 注解默认值与配置是否符合预期:
static class SampleService {
    @Idempotent
    public void defaultIdempotent() {}

    @Idempotent(type = IdempotentType.SPEL, key = "#id")
    public void spelIdempotent(Long id) {}
}

后续可以进一步补充基于 SpringBootTest 的集成测试(需要启动 Redis),验证并发场景下的真实行为。

八、小结

  • 本方案通过 注解 + AOP + Redis/DB + Redisson 锁 + 令牌服务 实现了一套通用、可插拔的接口幂等解决方案。
  • 通过灵活的 IdempotentTypeIdempotentStorageType,可以在不同业务场景下选择最合适的幂等策略。
  • 整体实现与现有业务代码解耦,仅依赖公共组件(Redis、MyBatis-Plus、Redisson),方便在项目中广泛复用和扩展。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐