接口幂等设计实战:让你的API稳如老狗!
接口幂等设计实战:让你的API稳如老狗!
作为一名资深后端开发,你有没有遇到过这样的场景:用户在支付时网络卡顿,疯狂点击支付按钮,结果银行卡被扣了三次款?或者在提交订单时页面无响应,用户以为没提交就又点了一次,结果收到了两个一模一样的包裹?
今天就来聊聊如何通过接口幂等设计,让你的API稳如老狗,再也不怕用户"手抖"!
一、什么是接口幂等性?
在开始实战之前,我们先来理解一下什么是接口幂等性。
1.1 幂等性的定义
幂等性(Idempotence)是数学中的一个概念,表示一个操作无论执行多少次,结果都是一样的。在计算机领域,特别是API设计中,幂等性指的是:
同一个请求,无论执行多少次,产生的结果和副作用都是一样的。
举个生活中的例子:
- 乘电梯:按下5楼按钮一次和按十次,电梯最终都会停在5楼
- 开关灯:开关灯按钮无论按多少次,灯的状态只有两种(开或关)
1.2 为什么需要接口幂等性?
在分布式系统中,由于网络不稳定、用户误操作、系统重试机制等原因,同一个请求可能会被多次发送。如果没有幂等性保障,就会出现各种问题:
- 重复支付:用户被重复扣款
- 重复下单:用户收到多个相同订单
- 重复创建:数据库中出现重复数据
- 库存超卖:库存被错误扣减
- 状态异常:业务状态被错误修改
二、常见的幂等性问题场景
让我们来看看在实际开发中,哪些场景容易出现幂等性问题:
2.1 用户操作层面
- 网络卡顿:用户点击提交后页面无响应,以为没成功就重复点击
- 误触操作:用户不小心双击或多次点击按钮
- 刷新页面:用户提交后刷新页面导致重复提交
2.2 系统层面
- 网络重试:客户端或网关自动重试机制
- 消息队列:消息消费失败后的重复投递
- 负载均衡:请求被转发到多个实例
- 超时重试:服务调用超时后的自动重试
2.3 典型业务场景
- 支付场景:支付接口被重复调用
- 订单场景:创建订单接口被重复调用
- 库存场景:扣减库存接口被重复调用
- 退款场景:退款接口被重复调用
三、接口幂等性实现方案
针对不同的业务场景,我们可以采用不同的幂等性实现方案:
3.1 数据库唯一约束
这是最简单直接的方式,通过数据库的唯一约束来保证数据不重复。
-- 创建订单表时添加唯一约束
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) UNIQUE NOT NULL, -- 订单号唯一
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
优点:
- 实现简单
- 数据库层面保证
缺点:
- 只能防止重复插入,不能防止重复处理
- 错误处理复杂(需要捕获唯一约束异常)
3.2 状态机控制
通过业务状态来控制操作的幂等性。
// 订单状态机示例
public class OrderService {
public void payOrder(Long orderId) {
Order order = orderRepository.findById(orderId);
// 检查订单状态
if (order.getStatus() != OrderStatus.PENDING) {
throw new BusinessException("订单状态不正确");
}
// 更新订单状态
order.setStatus(OrderStatus.PAID);
order.setPaidAt(new Date());
orderRepository.save(order);
// 执行支付逻辑
paymentService.processPayment(order);
}
}
优点:
- 业务逻辑清晰
- 适用于有明确状态流转的场景
缺点:
- 状态设计复杂
- 需要仔细考虑所有状态转换
3.3 Token机制
这是最常用也是最灵活的幂等性实现方式。
3.3.1 Token机制原理
- 客户端在发起业务请求前,先向服务端申请一个唯一的Token
- 服务端生成Token并存储到Redis中,设置过期时间
- 客户端带着Token发起业务请求
- 服务端收到请求后,检查Redis中是否存在该Token
- 如果存在,则处理业务并删除Token;如果不存在,则认为是重复请求
3.3.2 核心实现代码
首先,我们需要定义一个自定义注解:
// Idempotent.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* 幂等key的前缀
*/
String prefix() default "idempotent";
/**
* 过期时间(秒)
*/
long expireTime() default 300;
/**
* 提示信息
*/
String message() default "请勿重复提交";
}
接下来实现AOP切面:
// IdempotentAspect.java
@Aspect
@Component
@Slf4j
public class IdempotentAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Around("@annotation(idempotent)")
public Object handleIdempotent(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 获取请求中的token
String token = getRequestToken();
if (StringUtils.isBlank(token)) {
throw new BusinessException("缺少幂等token");
}
// 构造Redis key
String key = idempotent.prefix() + ":" + token;
// 使用Lua脚本保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
Boolean result = redisTemplate.execute(
new DefaultRedisScript<>(script, Boolean.class),
Collections.singletonList(key),
"1" // 这里可以是任意值,用于Lua脚本比较
);
if (Boolean.TRUE.equals(result)) {
// Token有效,执行业务逻辑
try {
return joinPoint.proceed();
} catch (Exception e) {
// 如果业务执行失败,需要将token重新放回redis(可选)
redisTemplate.opsForValue().set(key, "1", idempotent.expireTime(), TimeUnit.SECONDS);
throw e;
}
} else {
// Token无效或已使用
throw new BusinessException(idempotent.message());
}
}
/**
* 从请求头或参数中获取token
*/
private String getRequestToken() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
// 先从header中获取
String token = request.getHeader("Idempotent-Token");
if (StringUtils.isBlank(token)) {
// 从参数中获取
token = request.getParameter("idempotentToken");
}
return token;
}
}
Token服务实现:
// TokenService.java
@Service
public class TokenService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 生成幂等token
*/
public String generateToken(String prefix) {
String token = UUID.randomUUID().toString().replace("-", "");
String key = prefix + ":" + token;
// 存储到Redis,设置过期时间
redisTemplate.opsForValue().set(key, "1", 300, TimeUnit.SECONDS);
return token;
}
/**
* 验证并消费token
*/
public boolean consumeToken(String prefix, String token) {
String key = prefix + ":" + token;
// 使用原子操作删除token
Boolean result = redisTemplate.delete(key);
return Boolean.TRUE.equals(result);
}
}
控制器实现:
// OrderController.java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@Autowired
private TokenService tokenService;
/**
* 生成幂等token
*/
@GetMapping("/token")
public Result<String> generateToken() {
String token = tokenService.generateToken("order:create");
return Result.success(token);
}
/**
* 创建订单(幂等)
*/
@PostMapping
@Idempotent(prefix = "order:create", expireTime = 300, message = "订单已提交,请勿重复提交")
public Result<Order> createOrder(@RequestBody CreateOrderRequest request) {
Order order = orderService.createOrder(request);
return Result.success(order);
}
}
3.4 分布式锁
对于需要严格保证顺序执行的场景,可以使用分布式锁。
// DistributedLockService.java
@Service
public class DistributedLockService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 获取分布式锁
*/
public boolean tryLock(String key, String value, long expireTime) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('expire', KEYS[1], ARGV[2]) " +
"else return redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2], 'NX') end";
Boolean result = redisTemplate.execute(
new DefaultRedisScript<>(script, Boolean.class),
Collections.singletonList(key),
value,
String.valueOf(expireTime)
);
return Boolean.TRUE.equals(result);
}
/**
* 释放分布式锁
*/
public void releaseLock(String key, String value) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key),
value
);
}
}
使用分布式锁的业务方法:
// OrderService.java
@Service
public class OrderService {
@Autowired
private DistributedLockService lockService;
public Order createOrder(CreateOrderRequest request) {
// 使用用户ID和商品ID作为锁的key
String lockKey = "order:create:" + request.getUserId() + ":" + request.getProductId();
String lockValue = UUID.randomUUID().toString();
try {
// 获取锁,超时时间10秒
if (!lockService.tryLock(lockKey, lockValue, 10)) {
throw new BusinessException("系统繁忙,请稍后重试");
}
// 执行创建订单逻辑
return doCreateOrder(request);
} finally {
// 释放锁
lockService.releaseLock(lockKey, lockValue);
}
}
private Order doCreateOrder(CreateOrderRequest request) {
// 实际的创建订单逻辑
return order;
}
}
四、不同HTTP方法的幂等性
RESTful API中不同HTTP方法的幂等性要求是不同的:
| HTTP方法 | 幂等性 | 安全性 | 说明 |
|---|---|---|---|
| GET | 是 | 是 | 获取资源,不应产生副作用 |
| HEAD | 是 | 是 | 获取资源元信息,不应产生副作用 |
| POST | 否 | 否 | 创建资源,通常会产生副作用 |
| PUT | 是 | 否 | 更新资源,多次执行结果相同 |
| PATCH | 否 | 否 | 部分更新资源,可能产生不同结果 |
| DELETE | 是 | 否 | 删除资源,多次执行结果相同 |
五、最佳实践建议
5.1 选择合适的幂等性方案
- 简单场景:使用数据库唯一约束
- 复杂业务:使用状态机控制
- 高频场景:使用Token机制
- 严格顺序:使用分布式锁
5.2 Token机制优化
- 合理设置过期时间:根据业务特点设置合适的过期时间
- Token前缀分类:不同业务使用不同的前缀
- 异常处理:业务执行失败时考虑是否需要恢复Token
- 监控告警:监控幂等性失败的情况
5.3 前端配合
- 按钮防重复点击:点击后禁用按钮
- Loading状态:显示加载状态提示用户
- 友好提示:给用户明确的操作反馈
// 前端防重复提交示例
class OrderService {
async createOrder(orderData) {
// 禁用提交按钮
this.disableSubmitButton();
try {
// 获取幂等token
const tokenResponse = await this.getAuthToken();
const token = tokenResponse.data;
// 设置请求头
const headers = {
'Idempotent-Token': token,
'Content-Type': 'application/json'
};
// 发起请求
const response = await fetch('/api/orders', {
method: 'POST',
headers: headers,
body: JSON.stringify(orderData)
});
if (response.ok) {
const result = await response.json();
// 显示成功提示
this.showSuccess('订单提交成功');
return result;
} else {
throw new Error('提交失败');
}
} catch (error) {
// 显示错误提示
this.showError('提交失败,请重试');
throw error;
} finally {
// 恢复提交按钮
this.enableSubmitButton();
}
}
}
5.4 监控和日志
- 记录幂等性失败:统计幂等性检查失败的次数
- 分析失败原因:分析用户重复提交的原因
- 性能监控:监控Redis等组件的性能
// 幂等性监控示例
@Aspect
@Component
@Slf4j
public class IdempotentMonitorAspect {
@Autowired
private MeterRegistry meterRegistry;
@Around("@annotation(idempotent)")
public Object monitorIdempotent(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
Timer.Sample sample = Timer.start(meterRegistry);
String methodName = joinPoint.getSignature().getName();
try {
Object result = joinPoint.proceed();
// 记录成功请求
sample.stop(Timer.builder("idempotent.requests")
.tag("method", methodName)
.tag("result", "success")
.register(meterRegistry));
return result;
} catch (BusinessException e) {
// 记录幂等性失败
if (e.getMessage().contains("重复")) {
sample.stop(Timer.builder("idempotent.requests")
.tag("method", methodName)
.tag("result", "duplicate")
.register(meterRegistry));
log.warn("幂等性检查失败: method={}, message={}", methodName, e.getMessage());
}
throw e;
} catch (Exception e) {
// 记录其他异常
sample.stop(Timer.builder("idempotent.requests")
.tag("method", methodName)
.tag("result", "error")
.register(meterRegistry));
throw e;
}
}
}
六、总结
接口幂等性设计是构建高可用系统的重要一环,通过合理的幂等性保障,我们可以:
- 提升用户体验:避免用户因误操作导致的问题
- 保证数据一致性:防止重复数据和状态异常
- 增强系统稳定性:减少因重复请求导致的系统异常
- 降低业务风险:避免重复支付等严重问题
在实际项目中,建议根据业务场景选择合适的幂等性方案:
- 对于简单创建操作,可以使用数据库唯一约束
- 对于复杂业务逻辑,推荐使用Token机制
- 对于需要严格顺序的场景,可以结合分布式锁
- 前后端协同配合,提供更好的用户体验
掌握了这些幂等性设计技巧,相信你再面对重复请求问题时会更加从容不迫,让你的API稳如老狗!
今日思考:你们团队在项目中是如何处理接口幂等性的?有没有遇到过什么有趣的场景?欢迎在评论区分享你的经验!