接口幂等性的5种实现:Token机制、乐观锁、状态机、数据库唯一索引
接口幂等性的5种实现:Token机制、乐观锁、状态机、数据库唯一索引
适读人群:Java后端开发 | 难度:★★★★☆ | 出现频率:高
开篇故事
做支付业务那几年,幂等性是我接触最频繁的话题。
用户点击了付款按钮,网络超时,前端重试了一次。如果没有幂等性保障,这笔钱会被扣两次。
或者,我们调用支付宝接口付款,因为网络问题没收到响应,系统自动重试,如果支付宝那边实际上已经成功了,重试又发了一次,就会双扣。
还有消息队列,消息可能因为各种原因被重复投递,消费者必须能处理重复消息而不影响业务数据。
幂等性是分布式系统的基石能力之一,今天把5种实现方案从原理到代码全部讲透。
一、高频考点拆解
幂等性这道题,面试官考察:
第一层:能说清楚什么是幂等性(同一操作执行多次和执行一次的结果相同)
第二层:能说出几种具体的实现方案,知道各自的适用场景
第三层:能指出每种方案的边界情况和潜在问题
二、深度原理分析
2.1 幂等性的定义
数学上的幂等:f(f(x)) = f(x),即函数多次调用和一次调用效果相同。
接口幂等性:同一个请求执行多次,和执行一次的结果完全相同,对系统状态没有额外影响。
哪些操作天然幂等:
- GET查询:重复查询不改变数据
- DELETE(根据ID删除):第一次删了,第二次"删"不存在的,结果一样是不存在
- SELECT...UPDATE(用条件控制):
UPDATE SET status=1 WHERE status=0,第一次改了,第二次条件不满足,不执行
哪些操作不是幂等的:
- INSERT:重复插入产生重复数据
- UPDATE(不带状态控制):
UPDATE SET amount = amount - 100,每次执行都会减少100
2.2 需要幂等性的场景
三、5种实现方案
方案1:Token机制(推荐用于表单提交)
原理:提交前先获取一个唯一Token,提交时携带Token,服务端验证Token是否存在——存在则处理并删除,不存在则返回"重复提交"。
@Service
public class TokenService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String TOKEN_PREFIX = "idempotent:token:";
private static final long TOKEN_EXPIRE = 300; // 5分钟
// 生成Token
public String generateToken() {
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(TOKEN_PREFIX + token, "1",
TOKEN_EXPIRE, TimeUnit.SECONDS);
return token;
}
// 验证并消费Token(原子操作,用Lua脚本保证原子性)
public boolean validateAndDeleteToken(String token) {
String key = TOKEN_PREFIX + token;
// Lua脚本:先检查key是否存在,存在则删除并返回1,不存在返回0
// 这个操作必须是原子的,否则两个并发请求都检查到key存在,都执行删除
String luaScript =
"if redis.call('exists', KEYS[1]) == 1 then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(key)
);
return result != null && result == 1;
}
}
// 控制器层使用
@RestController
public class OrderController {
@Autowired
private TokenService tokenService;
@Autowired
private OrderService orderService;
@GetMapping("/order/token")
public String getToken() {
return tokenService.generateToken();
}
@PostMapping("/order/create")
public ResponseEntity<String> createOrder(
@RequestHeader("X-Idempotency-Token") String token,
@RequestBody CreateOrderRequest request) {
if (!tokenService.validateAndDeleteToken(token)) {
return ResponseEntity.status(HttpStatus.CONFLICT).body("重复提交,请勿重复操作");
}
orderService.create(request);
return ResponseEntity.ok("创建成功");
}
}方案2:数据库唯一索引(适合插入操作)
原理:在业务唯一标识字段上建唯一索引,重复插入会抛唯一键冲突异常,捕获异常作为幂等判断。
-- 订单表,order_no是业务唯一标识
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(50) NOT NULL UNIQUE, -- 唯一索引!
user_id BIGINT,
amount DECIMAL(10,2),
status TINYINT DEFAULT 0
);
-- 支付流水表
CREATE TABLE payment_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
out_trade_no VARCHAR(50) NOT NULL UNIQUE, -- 第三方订单号,唯一
order_id BIGINT,
amount DECIMAL(10,2),
status TINYINT
);@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Transactional
public void createOrder(CreateOrderRequest request) {
try {
Order order = new Order();
order.setOrderNo(request.getOrderNo()); // 调用方生成的唯一订单号
order.setUserId(request.getUserId());
order.setAmount(request.getAmount());
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
// 唯一键冲突 = 重复请求,直接返回(或查询已有记录返回)
log.info("订单{}已存在,幂等处理", request.getOrderNo());
// 可以查询已有记录返回
}
}
}注意:这种方案需要调用方提供唯一的业务标识(如orderNo)。如果调用方每次重试都传相同的orderNo,唯一索引就能保证幂等。
方案3:乐观锁(适合UPDATE操作)
原理:在数据行上维护版本号,UPDATE时同时检查版本号,版本号匹配才更新,并递增版本号。并发时只有一个操作能成功,其他的版本号不匹配,更新失败。
-- 版本号字段
ALTER TABLE orders ADD COLUMN version INT DEFAULT 0;// Mapper中的SQL
// <update id="updateStatusWithVersion">
// UPDATE orders SET status = #{newStatus}, version = version + 1
// WHERE id = #{id} AND version = #{version} AND status = #{oldStatus}
// </update>
@Service
public class OrderStatusService {
@Autowired
private OrderMapper orderMapper;
// 幂等的状态更新:支付成功,更新订单状态
public boolean paySuccess(long orderId) {
// 获取当前订单
Order order = orderMapper.findById(orderId);
if (order == null) {
throw new OrderNotFoundException("订单不存在");
}
// 只有待支付状态才能转为已支付
if (order.getStatus() != OrderStatus.PENDING) {
log.info("订单{}状态为{},不是待支付,幂等返回", orderId, order.getStatus());
return true; // 幂等:已经是目标状态,返回成功
}
// 乐观锁更新:version匹配才更新
int rows = orderMapper.updateStatusWithVersion(
orderId,
OrderStatus.PENDING.getCode(), // 旧状态
OrderStatus.PAID.getCode(), // 新状态
order.getVersion() // 当前版本号
);
if (rows == 0) {
// 更新失败:要么状态已改变,要么版本号已变(被其他线程抢先更新)
// 重新查询状态
order = orderMapper.findById(orderId);
if (order.getStatus() == OrderStatus.PAID.getCode()) {
return true; // 已经是已支付,幂等成功
}
throw new ConcurrentUpdateException("并发更新冲突,请重试");
}
return true;
}
}方案4:状态机(适合有状态流转的业务)
原理:业务状态按固定流向流转,每种状态只能向特定的下一个状态转变。重复操作时,如果目标状态已经达到,直接返回成功;如果状态不对,拒绝操作。
public enum OrderStatus {
PENDING(0, "待支付"),
PAID(1, "已支付"),
COMPLETED(2, "已完成"),
CANCELLED(3, "已取消");
private final int code;
private final String desc;
// 定义允许的状态转换
private static final Map<Integer, Set<Integer>> TRANSITIONS = new HashMap<>();
static {
TRANSITIONS.put(PENDING.code, new HashSet<>(Arrays.asList(PAID.code, CANCELLED.code)));
TRANSITIONS.put(PAID.code, new HashSet<>(Arrays.asList(COMPLETED.code)));
// COMPLETED和CANCELLED是终态,不能再转换
}
public static boolean canTransit(int fromStatus, int toStatus) {
Set<Integer> allowedTargets = TRANSITIONS.get(fromStatus);
return allowedTargets != null && allowedTargets.contains(toStatus);
}
}@Service
public class StateMachineOrderService {
@Transactional
public void transit(long orderId, int targetStatus) {
Order order = orderMapper.findByIdForUpdate(orderId); // 悲观锁
if (order.getStatus() == targetStatus) {
return; // 幂等:已经是目标状态
}
if (!OrderStatus.canTransit(order.getStatus(), targetStatus)) {
throw new InvalidStateTransitionException(
"不允许从" + order.getStatus() + "转换到" + targetStatus
);
}
orderMapper.updateStatus(orderId, order.getStatus(), targetStatus);
}
}方案5:分布式锁(适合防重和限流)
原理:用唯一请求标识(requestId)作为Redis key,加分布式锁,锁存在期间重复请求被拦截。
@Component
public class IdempotentAspect {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 自定义注解标记需要幂等的方法
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
// 从请求头获取幂等键
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
String idempotentKey = request.getHeader("X-Request-Id");
if (StringUtils.isBlank(idempotentKey)) {
throw new IllegalArgumentException("缺少幂等键X-Request-Id");
}
String lockKey = "idempotent:" + idempotentKey;
// SET key value NX EX:不存在才设置,原子操作
Boolean success = redisTemplate.opsForValue().setIfAbsent(
lockKey, "1",
idempotent.expireSeconds(), TimeUnit.SECONDS
);
if (Boolean.TRUE.equals(success)) {
// 第一次执行,正常处理
return pjp.proceed();
} else {
// 重复请求,返回固定响应
return Result.fail("重复请求,请勿重复提交");
}
}
}
// 自定义幂等注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
int expireSeconds() default 60; // 幂等有效期,单位秒
}
// 使用
@PostMapping("/payment/create")
@Idempotent(expireSeconds = 300) // 5分钟内相同X-Request-Id的请求被幂等处理
public Result createPayment(@RequestBody PaymentRequest request) {
return paymentService.create(request);
}四、面试官追问
追问1:Token机制中,为什么验证和删除Token必须是原子操作?
我的回答:如果不是原子操作,比如先check再delete:线程A检查Token存在,线程B也检查Token存在,两者都通过了检查,然后线程A删除,线程B也删除——两个请求都被处理了,幂等失效。必须用Lua脚本或Redis的原子命令保证check和delete是原子的,不可分割。这是并发安全的关键。
追问2:在微服务场景下,A服务调用B服务,B服务实现了幂等,但A服务不知道B是否真的成功了,应该怎么处理?
我的回答:这是分布式系统中的典型问题。B实现了幂等是前提条件,但A还需要做超时重试。标准做法是:A调用B时,生成一个唯一的requestId,无论重试多少次,这个requestId不变。B根据requestId判断是否已经处理过:如果已处理,返回之前的处理结果(不是简单返回成功,而是缓存第一次的实际结果,保证返回一致性);如果未处理,正常处理并缓存结果。这要求B不只是"幂等",还要"幂等且返回一致结果"。Dubbo等RPC框架通常在框架层面做了这个处理。
追问3:唯一索引实现幂等,如果业务需要物理删除记录,会有什么问题?
我的回答:一旦物理删除了已有的记录,同样的orderNo就可以再次插入,之前认为已处理的请求如果重试就会被再次处理,幂等性被破坏。有两种解决方案:第一,改为软删除(加deleted字段),唯一索引改为联合唯一(order_no + deleted_at),但这样同一个order_no在不同时间删除后重建可能冲突,逻辑更复杂。第二,设计一个独立的幂等表,专门存幂等记录,不和业务表混在一起,幂等记录只追加,不删除(定期归档即可)。
五、同类题目举一反三
消息队列的消费幂等如何实现?
消息中间件(RocketMQ、Kafka)都是At-least-once语义,消息可能被重复投递。消费者实现幂等的标准方案:以消息ID或业务Key为唯一标识,消费成功后在Redis或DB中记录该消息已消费,每次消费前先查是否已处理,已处理直接跳过。RocketMQ消息体里有MessageId,Kafka可以用partition+offset或业务字段作为唯一键。
六、踩坑实录
坑一:Token有效期设置不合理
Token设了5分钟过期,但用户把表单填完用了7分钟才提交。Token失效了,用户提交失败,体验极差。Token有效期要根据业务场景合理设置:快速操作(搜索、查询)可以短些,复杂表单要留足够时间,或者允许Token续期。
坑二:幂等key设计不当导致误幂等
有个接口,幂等key用的是userId + timestamp(精确到秒)。结果同一个用户在同一秒提交了两个不同的合法请求,被当成重复请求拦截了一个。幂等key要能唯一标识"同一个请求",而不是"同一个用户在同一时刻的请求"。通常应该包含具体的业务参数(如订单ID、商品ID等),或者让客户端生成一个UUID作为request_id。
坑三:乐观锁冲突后没有重试,返回了错误
乐观锁更新失败(version不匹配)不一定是真的错误,可能只是并发冲突,重试就能成功。但代码里直接抛出了异常,返回给了用户,用户看到"更新失败"就以为出了问题。正确处理是:乐观锁冲突后,在业务层重试N次(通常3次足够),每次重新查最新的version,重试耗尽才报错。
七、总结
5种幂等实现方案的适用场景速查:
| 方案 | 适用场景 | 核心机制 |
|---|---|---|
| Token机制 | 表单提交防重复 | Redis原子检查删除Token |
| 数据库唯一索引 | INSERT操作 | 利用数据库约束 |
| 乐观锁+版本号 | UPDATE操作 | version字段控制并发 |
| 状态机 | 有状态流转的业务 | 合法状态转换检查 |
| 分布式锁+RequestId | 通用接口防重 | Redis SET NX |
实际项目中通常组合使用:表单提交用Token机制,消息消费用RequestId查重,状态变更用乐观锁+状态机。
