本文目录导读:

针对 Java 接口的幂等性(Idempotence)流程进行“规整”,核心目标是防止重复请求对系统状态产生副作用,规整意味着要建立一个清晰、可复用、易理解的框架,而非零散地在每个接口中写重复的检查逻辑。
以下是一套从原理到实现的规整流程及最佳实践。
核心核心核心:幂等性的本质与策略选择
- 本质:客户端发起同一个请求(例如支付、下单)多次,服务器只处理一次,后续请求直接返回第一次的结果(或成功提示),不会产生额外影响。
- 常见策略:
- Token 机制:适用于“前端预防”,前端先请求一个全局唯一 Token,后端在关键操作前验证并删除 Token(需要严格保证删除的原子性)。
- 去重表(幂等表):最常用、最可靠,利用数据库唯一索引(或分布式锁 + 表记录)作为锁,适用所有写操作。
- 乐观锁:适用于“更新操作”,依靠版本号或状态机。
UPDATE ... WHERE version = old_version AND status = 'pending'。 - 状态机:适用于“流程管控”,业务状态只能沿预定方向流转,不允许回退或重复触发已结束的状态。
规整的第一步:根据业务场景选择一种或组合策略。 通常推荐 “去重表”作为通用兜底方案。
规整流程架构(以“去重表 + Token”组合为例)
以下是规整后的系统流程示意图,可以清晰地拆分职责。
[客户端请求]
|
v
[1. 幂等 Token/Key 生成] (若使用Token机制,在此步骤)
| <-- 客户端携带 Token 或 业务幂等ID (如订单号、支付流水号)
|
v
[2. 幂等拦截器 / Filter] (统一入口,非侵入)
| - 提取幂等 Key (Header中的幂等Token, 或RequestBody中的流水号)
| - 校验Key非空 (对缺失的请求可直接拒绝或标记为“无幂等要求”)
|
v
[3. 幂等核心逻辑引擎] (核心规整点)
| a. **全局锁 & 去重检查**:
| - 尝试获取分布式锁 (Redis锁, key = "IDEMPOTENT:" + 幂等Key, 过期时间 3-5 秒)
| - 查询幂等表 (数据库) where request_id = 幂等Key
| ↓
| ├─ **存在且已成功**: 直接返回缓存的结果 (Response from DB)
| | (注意: 这里需区分“成功”与“处理中”状态,如果是“处理中”则返回“正在处理”异常)
| |
| └─ **不存在**: 插入幂等记录 (状态 = PROCESSING)
| | (利用唯一索引保证幂等插入,若插入失败说明已经被并发重复请求插入,回退并返回“重复请求”)
| |
| v
| b. **执行业务逻辑** (原Controller Method)
| - 执行业务逻辑 (下单、支付、写库)
| - 若业务成功: 更新幂等表状态为 SUCCESS, 并记录返回结果
| - 若业务失败: 更新幂等表状态为 FAILED (或回滚记录,根据业务需求)
|
v
[4. 统一返回响应]
- 返回业务结果 (第一次请求) 或 返回缓存的结果 (重复请求)
- 注意异常处理: 如果幂等锁超时或数据库异常,需给出明确的错误码 (如 429 Too Many Requests 或 409 Conflict)
代码层面的“规整”实现步骤
为了做到真正的规整(而非在每个接口里写重复代码),需要封装一个幂等注解 + AOP 切面 + 幂等组件。
定义幂等注解(Annotation)
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
// 幂等Key的来源
// 可选: HEADER / PARAM / BODY (如JSON字段) / TOKEN (自动生成)
Source source() default Source.TOKEN;
// 若source为PARAM或BODY,指定字段名 (如 "orderId", "requestId")
String field() default "";
// 幂等超时时间 (去重表记录保留时间, 单位秒)
long timeout() default 86400;
// 是否在业务失败后允许同Key重试 (默认不允许)
boolean allowRetryOnFailure() default false;
}
幂等组件核心实现(AOP + 去重表)
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redisTemplate; // 用于分布式锁与缓存
@Autowired
private IdempotentRecordMapper mapper; // 数据库幂等记录表 Mapper
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 1. 解析幂等 Key
String idempotentKey = extractKey(joinPoint, idempotent);
if (StringUtils.isBlank(idempotentKey)) {
// 若无法获取key,可拒绝请求或仅作记录 (根据业务)
throw new IllegalArgumentException("幂等Key必传");
}
// 2. 获取分布式锁 (防止多个实例并发插入去重记录)
String lockKey = "IDEM:LOCK:" + idempotentKey;
Boolean lock = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS);
if (!lock) {
// 未获取到锁,表示正在被其他实例处理,直接返回“处理中”
// 注意:此处是快速失败,也可设重试机制
return createRetryResponse("请求正在处理中,请稍后重试");
}
try {
// 3. 查询幂等记录
IdempotentRecord record = mapper.selectByKey(idempotentKey);
if (record != null) {
// 3.1 记录存在
if ("SUCCESS".equals(record.getStatus())) {
// 返回历史成功结果 (从缓存或记录中获取)
return deserializeResult(record.getResponseData());
} else if ("PROCESSING".equals(record.getStatus())) {
// 正在处理,返回处理中状态 (或用轮询等待机制)
return createProcessingResponse();
} else if ("FAILED".equals(record.getStatus())) {
// 失败记录
if (idempotent.allowRetryOnFailure()) {
// 允许重试: 继续往下执行业务
// 注意: 需要先将状态改回PROCESSING,或删除旧记录再插入新记录
// 简单处理:改状态 + 清空结果
mapper.updateStatus(idempotentKey, "PROCESSING");
} else {
// 不允许重试: 直接返回失败结果
return createFailedResponse("该请求已失败,不可重复发送");
}
}
} else {
// 3.2 记录不存在: 插入“PROCESSING”状态记录 (利用唯一索引保证幂等)
int insertCount = mapper.insert(new IdempotentRecord(idempotentKey, "PROCESSING", null));
if (insertCount <= 0) {
// 插入失败(并发冲突),说明有其他线程已插入相同key
// 返回“正在处理”或“重复请求”
return createRetryResponse("重复请求,请稍后重试");
}
}
// 4. 执行目标方法 (业务逻辑)
Object result = joinPoint.proceed();
// 5. 更新幂等记录为成功 (或缓存结果)
mapper.updateStatusAndData(idempotentKey, "SUCCESS", serializeResult(result));
return result;
} catch (Exception e) {
// 6. 异常处理: 更新状态为失败
mapper.updateStatus(idempotentKey, "FAILED");
throw e;
} finally {
// 7. 释放分布式锁
redisTemplate.delete(lockKey);
}
}
}
数据库设计“去重表”(最关键)
CREATE TABLE `idempotent_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `idempotent_key` varchar(255) NOT NULL COMMENT '幂等Key (requestId/orderId/token)', `status` varchar(20) NOT NULL DEFAULT 'PROCESSING' COMMENT '状态: PROCESSING / SUCCESS / FAILED', `response_data` text DEFAULT NULL COMMENT '业务返回结果JSON', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_idempotent_key` (`idempotent_key`) USING BTREE COMMENT '这是一切的基础', KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='幂等去重表';
规整的关键原则与避坑指南
-
Key 的选取必须是全局唯一 + 业务相关:
- 推荐:业务流水号(如
orderId,paymentId)或 UUID(需由前端提供并持久化)。不要只用时间戳或随机数,因为无法对应同一笔业务。 - Token 机制的规整:前端提交时需携带 Token,Token 在业务入库前必须保证原子性删除(建议使用 Redis Lua 脚本
DEL key配合NX模式)。
- 推荐:业务流水号(如
-
去重表是核心,分布式锁是保障:
- 唯一索引保证了并发写入时也能通过数据库层面发现冲突。
- 分布式锁是为了减少不必要的数据库 IO(比如锁等待期间,后续请求直接等待锁或返回“处理中”)。
- 不要完全依赖缓存(Redis)做幂等,数据库才是最终一致性的保障。
-
状态机是状态变动的规则:
- 订单支付”接口,订单状态由
待支付->支付中->已支付。 - 幂等检查:查询订单当前状态是否已为
已支付,若是则直接返回成功,否则继续处理。
- 订单支付”接口,订单状态由
-
响应数据必须可序列化:
- 幂等表需要存储
response_data(JSON),彻底保证“幂等获取结果一致”,缓存的返回不能有副作用。
- 幂等表需要存储
-
不要对“读”接口做幂等:
读接口天然幂等,无需套用此流程,只针对写(增、删、改)操作。
-
失败重试策略:
- 默认业务失败了,同 Key 的请求是不允许再执行的,除非明确配置
allowRetryOnFailure = true,且修改幂等状态回PROCESSING。
- 默认业务失败了,同 Key 的请求是不允许再执行的,除非明确配置
规整流程的标准化步骤
- 定义幂等 Key:所有写接口统一从 Header(开中台推荐)或 RequestBody 中提取
idempotent_key。 - 入口拦截:通过自定义注解 + AOP 统一拦截,而非修改 Controller 代码。
- 核心去重:数据库唯一索引 + 分布式锁(或原子操作)。
- 状态管理:
PROCESSING -> SUCCESS / FAILED。 - 异常兜底:超时、并发插入失败等异常时,返回明确的 HTTP 状态码(409 Conflict 或 200 + 业务错误码)。
- 结果缓存:成功后将序列化的结果存幂等表,重复请求返回缓存结果。
这套规整方案完成后,你只需在需要幂等的方法上加 @Idempotent 注解,即可享受完整的幂等安全防护。