分布式服务幂等性问题如何解决
在分布式系统中解决幂等性问题是保证系统健壮性的关键挑战之一。以下从原理到实践的完整解决方案,涵盖7种核心模式及落地实现细节:
一、幂等性问题本质
核心定义:相同请求多次执行对系统状态的影响 = 执行一次的结果
产生场景:
- 网络抖动导致客户端重复提交
- 服务端超时后重试机制
- MQ消费者重复消费消息
- 分布式事务补偿机制
# 典型案例:支付重复扣款
curl -X POST https://pay/order/123
请求结果:因网络超时未响应 → 客户端重试 → 两次扣款❗
二、解决方案全景图
| 方案 | 适用场景 | 原理复杂度 | 实现成本 |
|---|---|---|---|
| 唯一请求ID | 大部分写操作 | ★☆☆ | 低 |
| 数据库唯一约束 | 数据强唯一性场景 | ★★☆ | 中 |
| Token机制 | 防表单重复提交 | ★☆☆ | 低 |
| 状态机流转 | 订单/工单流程类系统 | ★★★ | 高 |
| 分布式锁 | 高并发竞争资源 | ★★☆ | 中 |
| 版本号控制(乐观锁) | 存在版本属性的更新 | ★★☆ | 中 |
| 去重表(Redis/HBase) | 高频临时性幂等校验 | ★★☆ | 高 |
三、核心解决方案详解
1. 唯一请求ID模式
实现流程:
sequenceDiagram
participant Client
participant Server
participant DB
Client->>Server: 请求携带全局ID(X-Request-ID)
Server->>DB: 查询是否存在相同ID
alt 首次请求
DB-->>Server: 不存在记录
Server->>DB: 执行业务操作+记录ID
Server-->>Client: 返回成功
else 重复请求
DB-->>Server: 已存在记录
Server-->>Client: 返回先前结果
end
技术要点:
- ID生成使用雪花算法(Snowflake)或Redis原子操作
- 存储使用Redis(TTL自动过期)或MySQL(需定期清理)
- 防暴力破解:ID长度≥20位(包含时间戳+随机数)
示例代码(Spring Boot+Redis):
@RestController
public class PaymentController {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@PostMapping("/pay")
public ResponseEntity<?> pay(@RequestHeader("X-Request-ID") String requestId,
@RequestBody PaymentRequest request) {
// Redis原子操作判断是否存在
Boolean isAbsent = redisTemplate.opsForValue()
.setIfAbsent("payment:req:"+requestId, "processing", 5, TimeUnit.MINUTES);
if (!isAbsent) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("code": 409, "message": "Duplicate request"));
}
try {
// 真实支付逻辑...
return ResponseEntity.ok(Map.of("status", "success"));
} finally {
// 完成处理时更新状态(异步操作)
redisTemplate.opsForValue().set("payment:req:"+requestId, "completed");
}
}
}
2. 数据库唯一约束
设计模式:
-- 订单表增加唯一索引
ALTER TABLE orders
ADD UNIQUE INDEX uniq_request (user_id, request_hash);
-- 请求哈希生成算法(Java示例)
String requestHash = DigestUtils.md5Hex(
userId + orderNo + productId + amount
);
异常处理:
@Transactional
public void createOrder(Order order) {
try {
orderDao.insert(order); // 触发唯一约束
} catch (DataIntegrityViolationException ex) {
// 抓取重复提交异常
Order existing = orderDao.selectByHash(order.getRequestHash());
throw new DuplicateOrderException(existing.getOrderNo());
}
}
性能优化:
- 分库分表时需确保唯一键在分片键内
- 高频写场景配合消息队列削峰填谷
3. Token令牌机制
交互流程:
- 客户端先请求获取Token(携带用户ID)
- 服务端生成Token存储(Redis: user:123 -> token:xyz)
- 客户端提交请求时携带Token
- 服务端验证Token后立即删除(原子操作)
关键代码:
public class TokenService {
public String generateToken(String userId) {
String token = UUID.randomUUID().toString();
String key = "token:" + userId;
redisTemplate.opsForValue().set(key, token, 5, TimeUnit.MINUTES);
return token;
}
public boolean validateToken(String userId, String token) {
String key = "token:" + userId;
// Lua脚本保证原子性校验+删除
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key),
token
);
return result != null && result == 1L;
}
}
4. 分布式锁 + 幂等框架整合
架构设计:

集成框架(推荐):
- Spring自带
@Idempotent注解+AOP - Redisson分布式锁(支持多种锁类型)
- 阿里开源的resilience4j幂等模块
AOP实现示例:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String key() default ""; // SpEL表达式
int ttl() default 300; // 默认5分钟
Class<? extends Payload> type(); // 根据payload类型处理
}
@Aspect
@Component
public class IdempotentAspect {
@Around("@annotation(idempotent)")
public Object checkIdempotent(ProceedingJoinPoint joinPoint, Idempotent idempotent) {
// 解析SpEL表达式生成唯一Key
String lockKey = generateKey(joinPoint, idempotent);
// Redisson尝试获取锁
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(0, idempotent.ttl(), TimeUnit.SECONDS)) {
return joinPoint.proceed();
} else {
throw new ConcurrentRequestException("请求正在处理中");
}
} finally {
lock.unlock();
}
}
}
四、特殊场景解决方案
1. MQ消息幂等
RocketMQ方案:
// 消费者实现MessageListener
public class OrderListener implements MessageListenerOrderly {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> messages,
ConsumeOrderlyContext context) {
for (MessageExt msg : messages) {
String msgId = msg.getMsgId();
if (processed(msgId)) {
continue; // 跳过已处理消息
}
processOrder(msg);
markProcessed(msgId);
}
return ConsumeOrderlyStatus.SUCCESS;
}
private boolean processed(String msgId) {
// Redis记录已处理消息ID
return !redisTemplate.opsForValue()
.setIfAbsent("mq:processed:"+msgId, "1", 7, TimeUnit.DAYS);
}
}
2. 第三方接口调用
public class ThirdPartyService {
@Retryable(value = IOException.class, maxAttempts = 3)
@Idempotent(type = Payment.class, key = "#payment.tradeNo")
public void callPaymentAPI(Payment payment) {
// 使用feign调用对方接口
Response resp = paymentClient.create(payment);
if (resp.code() == 504) {
// 明确要求对方做幂等处理的场景
throw new RetryableException("需要重试");
}
}
}
五、最佳实践建议
-
分层防御策略:
- 前端:按钮防重+Token机制(第一道防线)
- 网关:全局请求ID生成与校验(第二道防线)
- 服务层:分布式锁/唯一索引(最后屏障)
-
监控指标:
# Grafana监控面板关键指标 idempotent_requests_total{status="duplicate"} // 重复请求计数 redis_lock_wait_duration_seconds_bucket // 锁竞争延迟分布 db_unique_violations_total // 唯一约束冲突数 -
压力测试要点:
# 使用wrk测试高并发幂等性 wrk -t12 -c400 -d60s --脚本=post_duplicate.lua http://api/order测试Lua脚本:
-- 模拟重复提交同一个请求ID request_id = math.random(1000000) wrk.headers["X-Request-ID"] = tostring(request_id) for i=1,5 do -- 每个线程重复提交5次 wrk.body = '{"amount":100}' wrk.method = "POST" wrk.path = "/order" wrk.request() end -
容灾方案:
- 白名单机制:在Redis故障时允许特定商户跳过校验
- 异步对账:定时任务扫描疑似重复数据
- 熔断降级:检测到存储层异常时暂时关闭复杂幂等校验
唯一请求ID与Token令牌机制虽同属幂等性解决方案,但在设计理念和实现方式上有本质区别。以下分七个维度详细对比:
一、概念定义层面的差异
| 维度 | 唯一请求ID | Token令牌 |
|---|---|---|
| 本质属性 | 请求的身份证 | 操作的准入证 |
| 业务隐喻 | 快递单号(标识物流唯一性) | 电影票(一次入场凭证) |
| 设计意图 | 标识每次请求的唯一性 | 确保操作仅能被提交一次 |
二、核心交互流程对比
1. 请求序列图差异
唯一请求ID流程:
sequenceDiagram
Client->>Server: 请求(携带X-Request-ID)
Server->>Redis: EXISTS request_id
alt 首次请求
Server-->>Client: 执行业务操作
Server->>Redis: SET request_id
else 重复请求
Server-->>Client: 返回已处理结果
end
Token令牌流程:
sequenceDiagram
Client->>Server: 预获取Token
Server->>Redis: SET token (有效期)
Server-->>Client: 下发Token
Client->>Server: 正式请求(携带Token)
Server->>Redis: DEL token原子校验
alt 校验通过
Server-->>Client: 业务处理成功
else Token失效
Server-->>Client: 拒绝请求
end
2. 关键路径耗时
| 阶段 | 唯一请求ID | Token机制 |
|---|---|---|
| 准备阶段 | 0ms | RTT*1 |
| 正式请求阶段 | Redis读 | Redis写+删除 |
| 异常处理复杂度 | 简单 | 中等 |
三、技术实现的本质区别
1. 存储结构差异
唯一请求ID存储:
Redis Key: req_id:{uuid}
Value: "processed" (仅标记存在性)
TTL: 根据业务设置(常5-30分钟)
Token存储结构:
Redis Key: user_token:{userID}
Value: 随机字符串(如JWT签名Token)
TTL: 较短(常1-5分钟,使用后立即删除)
2. 防重机制对比
// 唯一请求ID校验逻辑
Boolean exists = redis.get("req:"+requestId);
if (exists) {
return cachedResponse;
}
// Token校验典型实现(Lua原子脚本)
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long result = redis.eval(script, key, token);
四、安全防御侧重点
| 攻击类型 | 唯一请求ID防御力 | Token机制防御力 |
|---|---|---|
| 暴力重放攻击 | ★★☆ | ★★★★★ |
| 中间人篡改 | 无 | 令牌签名防御 |
| 客户端预测生成ID | 中等(依赖ID强度) | 高 |
| Token泄露复用 | 不适用 | 需结合HTTPS |
加密示例(JWT Token):
String token = JWT.create()
.withClaim("userId", 123)
.withExpiresAt(new Date(System.currentTimeMillis()+300000))
.sign(Algorithm.HMAC256("secret"));
// 包含签名防止篡改
五、适用场景对照表
| 场景 | 首选方案 | 原因说明 |
|---|---|---|
| 前端防表单重复提交 | Token | 天然适配按钮禁用场景 |
| 支付接口幂等 | 请求ID | 需要明确追踪每次支付请求 |
| MQ消费者幂等 | 请求ID | 消息去重需长期存储 |
| 高并发抢购活动 | Token + 队列 | 预扣Token控制流量洪峰 |
| 第三方回调接口校验 | 请求ID + 验签 | 无法控制请求方行为 |
混合使用案例(电商下单):
- 客户端预申请Token(防止快速点击)
- 用户提交订单携带Token + 生成请求ID
- 服务端用Token防表单重复,用请求ID保证支付幂等
六、性能与存储开销对比
1. 压力测试指标(单节点Redis, 8核16G)
| 指标 | 唯一请求ID模式 | Token机制 |
|---|---|---|
| QPS(纯校验操作) | 12,000 | 8,500 |
| 网络消耗(每次请求) | 1次GET | 1次DEL(含Lua脚本) |
| 内存占用(1亿请求) | ≈5GB (精简KV存储) | ≈15GB(需保留用户关联) |
| GC影响 | 低 | 中(Lua脚本解析消耗) |
2. 优化建议
- 请求ID:采用分段缓存(本地缓存+Redis)减少IO
- Token:客户端预获取多个Token(预约令牌池)
七、研发落地成本对比
| 维度 | 唯一请求ID | Token机制 |
|---|---|---|
| 客户端改造量 | 中(需生成全局ID) | 高(需实现Token获取/携带) |
| 服务端复杂度 | 低(仅校验存在性) | 中(需管理Token生命周期) |
| 联调测试难点 | ID生成冲突 | Token超时重试机制 |
| 运维成本 | 定期清理过期ID | Token泄露风险监控 |
选型决策树
graph TD
Q{需防御何种重复提交?}
Q -->|"网络超时导致重复"| A1[请求ID:便于请求追踪]
Q -->|"用户故意重复点击"| B1[Token机制:主动防御]
Q -->|"高敏感金融操作"| C1[请求ID+Token双重校验]
A1 --> D["适用:支付/交易API"]
B1 --> E["适用:表单/订单提交"]
C1 --> F["适用:数字资产转账"]
通过上述对比可明确:
- 唯一请求ID 像病历号——标记每次就诊记录,适合需要回溯每次请求详情的场景
- Token机制 像一次性密码——严格限定单次准入,适合需要主动防御用户交互重复的场合
二者并非互斥关系,关键业务可组合使用形成多层防御。
定位问题原因*
根据原因思考问题解决方案*
实践验证方案有效性*
提交验证结果

浙公网安备 33010602011771号