微服务接口幂等性:Token机制、状态机与数据库唯一约束三种方案
大约 7 分钟
微服务接口幂等性:Token机制、状态机与数据库唯一约束三种方案
适读人群:需要处理重复请求、保证业务幂等的微服务开发者 | 阅读时长:约19分钟
开篇故事
生产环境出现过这么一个问题:用户点击支付按钮,网络抖动,前端收不到响应,于是重试了一次。结果用户被扣款两次。
排查下来:第一次请求已经成功扣款,只是响应在回来的路上丢失了。第二次请求到达时,后端把它当成新请求,又扣了一次。
这是典型的幂等性问题:同一个操作执行多次,应该只产生一次效果。
幂等性不只是支付场景的问题,任何写操作都需要考虑:创建订单、发短信、发通知、更新状态……今天把三种主流方案完整讲清楚。
一、为什么幂等性这么重要
1.1 重复请求的来源
1.2 GET 天然幂等,写操作需要设计
- GET/查询:天然幂等,多次查询结果相同
- PUT(替换):幂等,多次替换结果相同
- DELETE:幂等,删除后再删除效果相同
- POST(创建)/更新:不幂等,需要设计
二、方案一:Token 机制(防重 Token)
2.1 工作原理
2.2 完整实现
package com.laozhang.idempotent.token;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* Token 幂等服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class IdempotentTokenService {
private static final String TOKEN_PREFIX = "idempotent:token:";
private static final long TOKEN_EXPIRE_SECONDS = 300; // 5分钟
private final StringRedisTemplate redisTemplate;
/**
* 生成并存储幂等Token
*/
public String generateToken(String businessType) {
String token = businessType + ":" + UUID.randomUUID().toString().replace("-", "");
String redisKey = TOKEN_PREFIX + token;
redisTemplate.opsForValue().set(redisKey, "1", TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS);
return token;
}
/**
* 消费Token(原子操作,只能消费一次)
* 使用 Lua 脚本保证原子性:getdel
*/
public boolean consumeToken(String token) {
if (token == null || token.trim().isEmpty()) {
return false;
}
String redisKey = TOKEN_PREFIX + token;
// Redis 6.2+ 支持 GETDEL 命令(取出并删除,原子操作)
// 对于老版本,使用 Lua 脚本:
String luaScript =
"local val = redis.call('GET', KEYS[1])\n" +
"if val then\n" +
" redis.call('DEL', KEYS[1])\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
Long result = redisTemplate.execute(script, List.of(redisKey));
return Long.valueOf(1).equals(result);
}
}Controller 层接口:
package com.laozhang.idempotent.controller;
import com.laozhang.idempotent.token.IdempotentTokenService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class OrderController {
private final IdempotentTokenService tokenService;
private final OrderService orderService;
/**
* 步骤1:获取幂等Token
*/
@GetMapping("/idempotent/token")
public Result<String> getIdempotentToken() {
String token = tokenService.generateToken("order");
return Result.success(token);
}
/**
* 步骤2:创建订单(携带Token)
*/
@PostMapping("/order")
public Result<OrderVO> createOrder(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody CreateOrderRequest request
) {
// 消费Token,确保只处理一次
boolean consumed = tokenService.consumeToken(idempotencyKey);
if (!consumed) {
return Result.error("请勿重复提交,或Token已过期");
}
return Result.success(orderService.createOrder(request));
}
}三、方案二:状态机幂等
3.1 原理
利用业务状态流转的单向性实现幂等。订单从 PENDING → PAID,如果已经是 PAID,再次支付操作应该直接返回成功(不再扣款)。
3.2 完整实现
package com.laozhang.idempotent.statemachine;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderPaymentService {
private final OrderMapper orderMapper;
private final PaymentGateway paymentGateway;
/**
* 支付订单(状态机幂等)
*/
@Transactional(rollbackFor = Exception.class)
public PaymentResult pay(Long orderId, PaymentRequest request) {
// 1. 查询订单当前状态(加 FOR UPDATE 悲观锁,防并发)
Order order = orderMapper.selectByIdForUpdate(orderId);
if (order == null) {
throw new BusinessException("订单不存在: " + orderId);
}
// 2. 状态机幂等判断
switch (order.getStatus()) {
case PAID:
// 已支付:幂等返回成功(不重复扣款)
log.info("[Payment] 订单已支付,幂等返回成功 orderId={}", orderId);
return PaymentResult.success(order.getPaymentId());
case PAYMENT_FAILED:
// 支付失败:可以重新支付
log.info("[Payment] 订单支付失败,允许重新支付 orderId={}", orderId);
break;
case PENDING:
case PAYING:
// 待支付/支付中:正常流程
break;
default:
throw new BusinessException("订单状态不允许支付: " + order.getStatus());
}
// 3. 更新状态为 PAYING(CAS 更新,防并发)
int updated = orderMapper.updateStatusCAS(orderId, order.getStatus(), OrderStatus.PAYING);
if (updated == 0) {
throw new BusinessException("订单状态已变更,请刷新后重试");
}
try {
// 4. 调用支付网关
String paymentId = paymentGateway.createPayment(orderId, request);
// 5. 更新状态为 PAID
orderMapper.updateStatusAndPaymentId(orderId, OrderStatus.PAID, paymentId);
return PaymentResult.success(paymentId);
} catch (Exception e) {
// 6. 异常时更新状态为 PAYMENT_FAILED
orderMapper.updateStatus(orderId, OrderStatus.PAYMENT_FAILED);
throw e;
}
}
}SQL:CAS 更新(Compare-And-Swap):
-- 只有当前状态是 oldStatus 时才更新,避免并发冲突
UPDATE orders
SET status = #{newStatus}
WHERE id = #{orderId} AND status = #{oldStatus};四、方案三:数据库唯一约束
4.1 适用场景
对于"创建类"操作,可以利用数据库唯一索引天然保证幂等:相同的业务唯一键只能插入一次。
-- 订单表加唯一索引
ALTER TABLE orders ADD UNIQUE KEY uk_outer_order_no (outer_order_no);
-- outer_order_no: 外部系统传入的订单号,业务唯一标识4.2 完整实现
package com.laozhang.idempotent.unique;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderCreationService {
private final OrderMapper orderMapper;
/**
* 创建订单(数据库唯一约束幂等)
*
* @param outerOrderNo 外部订单号(调用方保证唯一,如前端生成的UUID)
*/
public OrderVO createOrder(String outerOrderNo, CreateOrderRequest request) {
// 先查询是否已经存在
Order existing = orderMapper.selectByOuterOrderNo(outerOrderNo);
if (existing != null) {
// 幂等:已创建,直接返回已有结果
log.info("[Order] 订单已存在,幂等返回 outerOrderNo={}", outerOrderNo);
return OrderVO.from(existing);
}
// 构建订单
Order order = Order.builder()
.outerOrderNo(outerOrderNo)
.userId(request.getUserId())
.amount(request.getAmount())
.status(OrderStatus.PENDING)
.build();
try {
orderMapper.insert(order);
return OrderVO.from(order);
} catch (DuplicateKeyException e) {
// 并发场景:两个请求同时进来,一个成功插入,另一个触发唯一约束
// 此时查询并返回已存在的订单
log.warn("[Order] 并发创建订单,唯一约束冲突,outerOrderNo={}", outerOrderNo);
existing = orderMapper.selectByOuterOrderNo(outerOrderNo);
if (existing != null) {
return OrderVO.from(existing);
}
throw new BusinessException("订单创建失败,请重试");
}
}
}五、踩坑实录
坑1:Token 方案在网络抖动下的安全问题
场景:请求发出后,Token 被消费,但响应在途中丢失。客户端重试时,Token 已不存在,被当成重复请求拒绝。
解决方案:状态区分,而不是删除 Token:
// 改进版:消费Token后标记为"已使用",而不是删除
// PROCESSING:第一次请求正在处理中
// DONE:已处理完成
// 没有key:Token不存在或过期
public ConsumeResult consumeToken(String token) {
String key = TOKEN_PREFIX + token;
String luaScript =
"local val = redis.call('GET', KEYS[1])\n" +
"if val == '1' then\n" +
" redis.call('SET', KEYS[1], 'processing', 'EX', ARGV[1])\n" +
" return 'first'\n" +
"elseif val == 'processing' then\n" +
" return 'processing'\n" +
"elseif val == 'done' then\n" +
" return 'done'\n" +
"else\n" +
" return 'invalid'\n" +
"end";
// ... 执行lua脚本 ...
}坑2:状态机方案中 CAS 更新失败后无限重试
症状:并发场景下,两个相同请求同时进来,一个成功,另一个 CAS 失败后一直重试,把服务搞崩了。
解决:CAS 失败后不重试,而是重新查询状态,根据新状态决定是返回成功还是报错:
int updated = orderMapper.updateStatusCAS(orderId, oldStatus, OrderStatus.PAYING);
if (updated == 0) {
// 不重试!重新查询状态
Order current = orderMapper.selectById(orderId);
if (current.getStatus() == OrderStatus.PAID) {
return PaymentResult.success(current.getPaymentId()); // 已支付,幂等成功
}
throw new BusinessException("请稍后重试");
}坑3:唯一约束方案处理不了"更新"操作
症状:创建操作用了唯一约束,但更新操作(修改订单金额)的幂等无法用唯一约束实现。
解决:更新操作用版本号(乐观锁):
// 带版本号的更新:version字段每次更新都+1
// 只有version匹配才更新成功
UPDATE orders
SET amount = #{amount}, version = version + 1
WHERE id = #{id} AND version = #{expectedVersion};六、三种方案对比与选型
| 方案 | 实现复杂度 | 适用场景 | 局限性 |
|---|---|---|---|
| Token机制 | 中 | 前端表单提交、API调用 | 依赖Redis,Token过期有风险 |
| 状态机 | 高 | 有明确状态流转的业务 | 只适合有状态的业务对象 |
| 数据库唯一约束 | 低 | 创建类操作 | 只能处理创建,不能处理更新 |
选型建议:
- 简单防重提交:Token 机制
- 支付/订单等核心流程:状态机
- 数据创建(消息去重):数据库唯一约束
