接口幂等性通用解决方案设计(Spring Boot)
·
文章目录
一、设计目标
- 通用性:不绑定具体业务(剧情、视频、评论等),通过注解 + 配置即可在任意接口启用幂等控制。
- 高并发:主通路基于 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 提交”为例:
- 前端调用
/api/idempotent/token获取一次性幂等令牌token。 - 前端在实际业务请求中,将
token放入 Header:Idempotency-Token: {token}。 - 请求进入
@Idempotent标记的方法:IdempotentAspect拦截方法调用。IdempotentKeyGenerator从 Header 读取token,生成幂等 key 片段。RedisIdempotentTokenService校验并消费此令牌(一次性)。- 根据方法签名 + key 片段构建全局幂等 key:
{className}#{methodName}:{token}。 - 根据注解配置选择存储实现(Redis / DB)。
- 如开启分布式锁,则通过
RedissonLockManager获取锁。 - 存储层执行
trySet(idempotentKey, ttl):- 成功:当前请求是首个有效请求,继续执行业务逻辑。
- 失败:说明已存在相同请求(正在处理或已成功),抛出
DuplicateRequestException。
- 业务逻辑执行完成,返回结果。
- 如业务异常且
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:当前时间戳(或占位符),不依赖具体内容。
- key:
- 实现要点:
- 使用
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.IdempotentRecordcom.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_key且expire_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:业务场景描述(可选)
- key:
- 核心逻辑:
- 生成令牌:
- 生成随机 UUID,去掉横线作为令牌。
- 写入 Redis,设置 TTL(默认 5 分钟)。
- 校验并消费令牌:
- 读取 Redis,存在则视为有效,随后删除 key(一次性)。
- 如果配置了场景描述,可进行简单校验。
- 生成令牌:
3.6 AOP 切面 IdempotentAspect
- 位置:
com.video.idempotent.aspect.IdempotentAspect - 关键步骤:
- 获取方法与
@Idempotent注解。 - 调用
IdempotentKeyGenerator生成 key 片段。 - 如
type=TOKEN,调用IdempotentTokenService.validateAndConsume校验令牌。 - 构建完整幂等 key:
methodId + ':' + keyPart。 - 根据
storage选择存储实现(Redis / DB)。 - 计算 TTL,并组合锁 key:默认
idempotent:lock:{idempotentKey}。 - 如果开启锁:
tryLock失败则抛出DuplicateRequestException(请求过于频繁或并发)。
- 调用存储层
trySet:- 返回
false:直接抛出DuplicateRequestException。 - 返回
true:继续执行业务逻辑。
- 返回
- 业务异常时,如
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 = TOKEN或SPEL(以业务订单号作为 key)。storage = REDIS(高并发主场景)。expireSeconds视订单有效期设定,例如1800(30 分钟)。useLock = true,避免极端并发下多次落库/扣款。- 业务异常时根据场景决定是否
deleteKeyOnError:- 若可能产生部分成功(例如已扣券但 DB 写失败),可设置为
false,防止重复请求导致二次扣减,由人工/对账修复。
- 若可能产生部分成功(例如已扣券但 DB 写失败),可设置为
5.2 表单提交 / 按钮防抖(弱幂等、响应快速)
- 推荐配置:
type = TOKEN或HEADER(前端统一生成X-Request-Id)。storage = REDIS,expireSeconds较短(如 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+ 场景仍能稳定支撑。
- 单次请求只进行 1 次
-
Redisson 锁:
- 只在高价值接口(如支付)上开启锁,避免全局使用导致额外 RTT 开销。
- 默认立即返回,不阻塞等待锁,减小线程等待时间。
-
DB 存储:
- 通过唯一键约束保证幂等,QPS 较低时不会成为瓶颈。
- 建议对
idempotent_key建立合适长度和前缀的索引,避免索引过大。
7.2 压测方案建议
-
压测工具:JMeter、wrk、Gatling 等。
-
场景设计:
- 场景 A:单接口高并发重复提交
- 目标:模拟大量客户端向同一幂等接口(同一个幂等 key)并发提交。
- 期望:仅首个请求成功,其他全部返回 429;平均响应时间稳定,Redis 命中率高。
- 场景 B:多不同幂等 key 并发
- 目标:模拟大量不同用户/订单的并发请求。
- 期望:成功率 ≈ 100%,Redis 及 DB 不成为瓶颈。
- 场景 A:单接口高并发重复提交
-
关键指标:
- 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 锁 + 令牌服务 实现了一套通用、可插拔的接口幂等解决方案。
- 通过灵活的
IdempotentType与IdempotentStorageType,可以在不同业务场景下选择最合适的幂等策略。 - 整体实现与现有业务代码解耦,仅依赖公共组件(Redis、MyBatis-Plus、Redisson),方便在项目中广泛复用和扩展。
更多推荐




所有评论(0)