一、核心概念:什么是幂等性
接口幂等性指同一操作的一次或多次请求,结果完全一致,无副作用。
- 典型反例:支付时网络异常导致用户重复点击,引发多次扣款;
- 核心目标:避免重复请求造成数据错乱(如多扣款、多创建订单)。
二、幂等性判定:哪些操作天然幂等/非幂等
1. 天然幂等操作(无需额外处理)
sql
-- 1. 查询:多次执行结果一致,不改变数据状态
SELECT * FROM user WHERE id = 1;
-- 2. 基于条件更新(固定值):多次执行状态不变
UPDATE goods SET stock = 10 WHERE id = 2;
-- 3. 删除:多次执行结果相同(第一次删除后,后续无数据可删)
DELETE FROM order WHERE order_no = 'O20250101001';
-- 4. 唯一主键插入:重复插入会触发主键冲突,仅成功一次
INSERT INTO user (user_id, name) VALUES (1001, '张三'); -- user_id为主键
2. 非幂等操作(必须做幂等处理)
sql
-- 1. 增量更新:每次执行结果变化
UPDATE goods SET stock = stock - 1 WHERE id = 2;
-- 2. 非唯一键插入:重复执行会新增多条数据
INSERT INTO log (content) VALUES ('操作日志'); -- 无唯一约束
三、需要保证幂等性的场景
- 用户交互:多次点击按钮、页面回退重新提交;
- 系统调用:微服务间通信(如Feign)因网络异常触发重试;
- 其他:定时任务重复执行、消息队列重复消费。
四、幂等性解决方案(含示例)
1. Token机制(通用方案)
核心逻辑 :请求前获取唯一Token,请求时携带Token,服务端验证Token有效性。
步骤+示例:
-
前端获取Token:调用服务端
/get-token接口,服务端生成Token存入Redis(过期时间5分钟); -
前端发起业务请求:请求头携带
X-Idempotent-Token: xxx; -
服务端验证Token(原子操作,用Lua脚本避免并发问题):
lua-- Redis Lua脚本:判断Token存在则删除,返回1;不存在返回0 if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) -- 第一次请求,删除Token继续业务 else return 0 -- 重复请求,直接返回 end -
服务端处理:返回1则执行业务(如创建订单),返回0则直接返回"重复请求"。
注意:优先"先删除Token再执行业务",若业务失败,前端需重新获取Token重试。
2. 锁机制(更新/并发场景)
(1)数据库悲观锁(写多读少场景)
核心逻辑 :通过 for update 锁定数据,防止并发修改。
示例:
java
// 订单支付扣减库存(事务内执行)
@Transactional
public boolean payOrder(String orderNo) {
// 锁定订单记录,避免并发支付(id需为主键/唯一索引)
Order order = orderMapper.selectByIdForUpdate(orderNo);
if (order == null || order.getStatus() == 1) {
return false; // 订单不存在或已支付
}
// 扣减库存、更新订单状态等业务逻辑
order.setStatus(1);
orderMapper.updateById(order);
return true;
}
SQL示例:
sql
SELECT * FROM `order` WHERE order_no = 'O20250101001' FOR UPDATE;
(2)数据库乐观锁(读多写少场景)
核心逻辑 :基于版本号(version)控制,仅当版本号匹配时才更新。
示例:
-
数据库表添加
version字段(默认值1); -
实体类添加版本号属性:
javapublic class Goods { private Long id; private Integer stock; private Integer version; // 乐观锁版本号 // getter/setter } -
更新SQL(MyBatis-Plus自动生成):
sql-- 仅当version=1时更新,更新后version自动+1 UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = 2 AND version = 1; -
业务处理:若更新行数为0,说明是重复请求,直接返回失败。
(3)业务层分布式锁(跨服务/多机器场景)
核心逻辑 :用Redis/ZooKeeper实现分布式锁,锁定业务唯一标识(如订单号)。
示例(Redis分布式锁):
java
public boolean processOrder(String orderNo) {
String lockKey = "lock:order:" + orderNo;
// 获取锁(过期时间30秒,避免死锁)
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(locked)) {
return false; // 已被其他服务锁定,重复请求
}
try {
// 校验订单是否已处理
Order order = orderMapper.selectById(orderNo);
if (order.getStatus() == 1) {
return false;
}
// 执行业务逻辑
updateOrderStatus(orderNo);
return true;
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}
3. 唯一约束(插入场景)
(1)数据库唯一约束
核心逻辑 :给业务唯一标识(如订单号、请求ID)添加唯一索引,避免重复插入。
示例:
- 订单表
order_no字段添加唯一索引; - 插入订单时,若重复提交,数据库会抛出唯一约束异常,服务端捕获后返回"重复创建"。
(2)Redis Set防重
核心逻辑 :将业务唯一标识(如请求参数MD5)存入Redis Set,存在则为重复请求。
示例:
java
public boolean checkRepeat(String requestParam) {
// 计算请求参数的MD5(作为唯一标识)
String md5 = DigestUtils.md5DigestAsHex(requestParam.getBytes());
String key = "repeat:check:" + md5;
// 若已存在,返回true(重复);不存在则存入Set(过期时间10分钟)
return !redisTemplate.opsForSet().add(key, md5, 10, TimeUnit.MINUTES);
}
// 业务调用
public Result submitForm(String formData) {
if (checkRepeat(formData)) {
return Result.error("重复提交");
}
// 执行业务逻辑
return Result.success();
}
(3)防重表
核心逻辑 :创建专门的防重表,用业务唯一标识作为唯一索引,与业务操作同事务。
示例:
-
防重表设计:
sqlCREATE TABLE `idempotent_table` ( `id` bigint AUTO_INCREMENT PRIMARY KEY, `unique_key` varchar(64) NOT NULL COMMENT '业务唯一标识(如订单号)', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_unique_key` (`unique_key`) ); -
业务代码(同事务):
java@Transactional public boolean createOrder(OrderDTO orderDTO) { // 1. 插入防重表(唯一索引冲突则事务回滚) IdempotentTable table = new IdempotentTable(); table.setUniqueKey(orderDTO.getOrderNo()); try { idempotentMapper.insert(table); } catch (DuplicateKeyException e) { return false; // 重复请求 } // 2. 执行业务逻辑(创建订单) Order order = new Order(); order.setOrderNo(orderDTO.getOrderNo()); orderMapper.insert(order); return true; }
4. 全局请求唯一ID
核心逻辑 :给每个请求分配唯一ID(如通过Nginx、网关生成),服务端记录已处理的ID。
示例:
-
Nginx配置生成唯一请求ID:
nginxproxy_set_header X-Request-Id $request_id; # 转发请求时添加唯一ID -
服务端拦截器处理:
javapublic boolean preHandle(HttpServletRequest request, HttpServletResponse response) { String requestId = request.getHeader("X-Request-Id"); if (redisTemplate.hasKey("request:processed:" + requestId)) { response.getWriter().write("重复请求"); return false; } // 标记为已处理(过期时间5分钟) redisTemplate.opsForValue().set("request:processed:" + requestId, "1", 5, TimeUnit.MINUTES); return true; }
五、方案选型建议
| 场景 | 推荐方案 | 核心优势 |
|---|---|---|
| 前后端交互(如表单提交) | Token机制 | 通用性强,无侵入性 |
| 读多写少的更新场景 | 数据库乐观锁 | 性能高,无锁等待 |
| 写多读少的并发场景 | 数据库悲观锁/分布式锁 | 安全性高,避免并发冲突 |
| 插入场景(如创建订单) | 数据库唯一约束/防重表 | 数据库层面保障,可靠性高 |
| 跨服务/网关层防重 | 全局请求唯一ID | 无业务侵入,适合统一拦截 |