接口幂等性保障方案
接口幂等性保障方案
作者:技术团队
更新时间:2026-01-16
文档类型:技术知识库
目录
一、什么是幂等性
1.1 定义
幂等性(Idempotence):同一个请求执行一次和执行N次的效果完全相同,不会因为重复调用而产生副作用。
1.2 数学表达
f(f(x)) = f(x)
多次调用函数f的结果与调用一次的结果相同。
1.3 天然幂等的操作
| HTTP方法 | SQL操作 | 说明 | 幂等性 |
|---|---|---|---|
| GET | SELECT | 查询操作 | ✅ 天然幂等 |
| PUT | UPDATE SET status=1 | 更新为固定值 | ✅ 天然幂等 |
| DELETE | DELETE WHERE id=1 | 删除指定数据 | ✅ 天然幂等 |
| POST | INSERT | 新增操作 | ❌ 非幂等 |
| POST | UPDATE SET amount=amount+100 | 累加操作 | ❌ 非幂等 |
二、为什么需要幂等性
2.1 重复请求的常见场景
graph TB
A[重复请求来源] --> B[前端重复点击]
A --> C[网络超时重试]
A --> D[消息队列重复投递]
A --> E[分布式系统重试]
A --> F[接口调用方重试]
B --> G[用户快速点击提交按钮]
C --> H[请求超时后客户端自动重试]
D --> I[MQ消费失败后重新投递]
E --> J[微服务调用失败后重试]
F --> K[第三方回调失败后重复通知]
2.2 不保证幂等的后果
| 场景 | 后果 | 影响 |
|---|---|---|
| 订单重复创建 | 同一商品生成多个订单 | 资金损失、库存混乱 |
| 支付重复扣款 | 用户被多次扣款 | 用户投诉、法律风险 |
| 库存重复扣减 | 库存超卖 | 无法发货、商业纠纷 |
| 消息重复消费 | 业务逻辑重复执行 | 数据不一致 |
| 积分重复发放 | 用户获得额外积分 | 运营成本增加 |
三、按场景分类的幂等性方案
场景1:防止前端重复提交
问题描述
- 用户快速连续点击"提交订单"按钮
- 网络延迟导致用户重复点击
- 表单重复提交
解决方案:Token机制
原理
- 用户打开表单页面时,后端生成唯一Token
- 前端提交表单时携带Token
- 后端校验Token,验证通过后立即删除
- 后续重复请求因Token已失效而被拒绝
流程图
sequenceDiagram
participant 前端
participant 后端
participant Redis
前端->>后端: 1. 请求获取Token
后端->>Redis: 2. 生成并存储Token
Redis-->>后端: 3. 存储成功
后端-->>前端: 4. 返回Token
前端->>后端: 5. 提交表单(携带Token)
后端->>Redis: 6. 验证并删除Token (DEL)
alt Token存在
Redis-->>后端: 删除成功(返回1)
后端->>后端: 7. 执行业务逻辑
后端-->>前端: 8. 返回成功
else Token不存在
Redis-->>后端: 删除失败(返回0)
后端-->>前端: 9. 返回"请勿重复提交"
end
前端->>后端: 10. 用户再次点击(重复提交)
后端->>Redis: 11. 验证Token
Redis-->>后端: Token已被删除
后端-->>前端: 12. 返回"请勿重复提交"
代码实现
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private OrderService orderService;
/**
* 获取防重复提交Token
*/
@GetMapping("/getToken")
public Result<String> getToken() {
// 生成唯一Token
String token = UUID.randomUUID().toString().replace("-", "");
String key = "order:token:" + token;
// 存储到Redis,设置5分钟过期
redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
return Result.success(token);
}
/**
* 提交订单(防重复提交)
*/
@PostMapping("/submit")
public Result<Long> submitOrder(@RequestBody OrderSubmitDTO dto) {
String token = dto.getToken();
if (StringUtils.isBlank(token)) {
return Result.fail("Token不能为空");
}
// 验证Token并删除(原子操作)
String key = "order:token:" + token;
Boolean deleted = redisTemplate.delete(key);
if (deleted == null || !deleted) {
// Token不存在或已被删除,说明是重复提交
return Result.fail("请勿重复提交订单");
}
try {
// 执行业务逻辑
Long orderId = orderService.createOrder(dto);
return Result.success(orderId);
} catch (Exception e) {
// 业务异常时,可以选择恢复Token(允许重试)
// redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
throw e;
}
}
}
前端配合
// Vue示例
export default {
data() {
return {
token: '',
submitting: false
}
},
async mounted() {
// 页面加载时获取Token
const res = await this.$axios.get('/order/getToken')
this.token = res.data
},
methods: {
async submitOrder() {
if (this.submitting) {
this.$message.warning('正在提交,请稍候')
return
}
this.submitting = true
try {
const res = await this.$axios.post('/order/submit', {
...this.formData,
token: this.token // 携带Token
})
this.$message.success('提交成功')
// 提交成功后重新获取Token
const tokenRes = await this.$axios.get('/order/getToken')
this.token = tokenRes.data
} catch (error) {
this.$message.error(error.message)
} finally {
this.submitting = false
}
}
}
}
优点
- ✅ 用户体验好,前端可实时反馈
- ✅ 实现简单,前后端配合容易
- ✅ 支持Token过期自动失效
缺点
- ❌ 需要前端配合传递Token
- ❌ Redis宕机时失效(可降级为UUID去重)
- ❌ 不适用于后端间调用
适用场景
- 用户表单提交(订单、支付、注册等)
- 按钮点击操作(关注、点赞等)
- 需要前端实时反馈的场景
场景2:防止订单重复创建
问题描述
- 用户下单时网络抖动,前端重复发起请求
- 订单创建接口被多次调用
- 生成多个相同内容的订单
解决方案:数据库唯一索引
原理
在数据库表中对订单号或其他业务唯一键设置唯一索引,利用数据库约束防止重复插入。
数据库设计
CREATE TABLE `t_order` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`order_no` VARCHAR(32) NOT NULL COMMENT '订单号',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`product_id` BIGINT NOT NULL COMMENT '商品ID',
`amount` DECIMAL(10,2) NOT NULL COMMENT '订单金额',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`) -- 唯一索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
代码实现
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
/**
* 创建订单(防重复)
*/
@Transactional(rollbackFor = Exception.class)
public Long createOrder(OrderCreateDTO dto) {
// 生成订单号(客户端生成或后端生成)
String orderNo = dto.getOrderNo();
if (StringUtils.isBlank(orderNo)) {
// 后端生成订单号:时间戳 + 用户ID + 随机数
orderNo = generateOrderNo(dto.getUserId());
}
// 构建订单对象
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(dto.getUserId());
order.setProductId(dto.getProductId());
order.setAmount(dto.getAmount());
order.setStatus(OrderStatus.WAIT_PAY);
try {
// 插入订单
orderMapper.insert(order);
log.info("创建订单成功: orderNo={}", orderNo);
return order.getId();
} catch (DuplicateKeyException e) {
// 捕获唯一索引冲突异常
log.warn("订单号已存在,重复创建: orderNo={}", orderNo);
// 查询已存在的订单并返回
Order existOrder = orderMapper.selectByOrderNo(orderNo);
if (existOrder != null) {
return existOrder.getId();
}
throw new BusinessException("订单创建失败,请稍后重试");
}
}
/**
* 生成订单号
*/
private String generateOrderNo(Long userId) {
// 格式:yyyyMMddHHmmss + 用户ID后4位 + 4位随机数
String timestamp = LocalDateTime.now().format(
DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
String userSuffix = String.format("%04d", userId % 10000);
String random = String.format("%04d", new Random().nextInt(10000));
return timestamp + userSuffix + random;
}
}
Mapper
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
/**
* 根据订单号查询
*/
@Select("SELECT * FROM t_order WHERE order_no = #{orderNo}")
Order selectByOrderNo(@Param("orderNo") String orderNo);
}
优点
- ✅ 数据库层面保证,最可靠
- ✅ 无需额外中间件(Redis等)
- ✅ 即使应用重启也不影响
缺点
- ❌ 只能防止插入重复,不适用于更新操作
- ❌ 唯一索引冲突会抛异常,需要捕获处理
- ❌ 数据库性能开销(索引维护)
适用场景
- 订单创建
- 用户注册(手机号、邮箱唯一)
- 流水号生成(交易流水、支付流水)
- 任何需要保证数据唯一性的插入操作
场景3:防止支付重复扣款
问题描述
- 用户支付时网络异常,触发重试
- 支付网关回调接口被多次调用
- 第三方支付平台可能重复发送通知
- 导致用户被多次扣款
解决方案:业务单号(outTradeNo)幂等
原理
使用业务单号(商户订单号、支付流水号)作为唯一标识,结合Redis和数据库双重保证。
流程图
sequenceDiagram
participant 支付网关
participant 应用服务器
participant Redis
participant MySQL
支付网关->>应用服务器: 1. 支付回调(outTradeNo)
应用服务器->>Redis: 2. SETNX(key, 1)
alt 首次请求
Redis-->>应用服务器: 设置成功
应用服务器->>MySQL: 3. 查询订单状态
alt 订单未支付
应用服务器->>MySQL: 4. 更新订单状态为已支付
应用服务器->>MySQL: 5. 插入支付流水记录
MySQL-->>应用服务器: 更新成功
应用服务器-->>支付网关: 6. 返回SUCCESS
else 订单已支付
应用服务器-->>支付网关: 返回SUCCESS(幂等)
end
else 重复请求
Redis-->>应用服务器: 设置失败(Key已存在)
应用服务器->>MySQL: 查询支付结果
应用服务器-->>支付网关: 7. 返回SUCCESS(幂等)
end
支付网关->>应用服务器: 8. 再次回调(重复)
应用服务器->>Redis: 9. SETNX(key, 1)
Redis-->>应用服务器: 设置失败
应用服务器-->>支付网关: 10. 直接返回SUCCESS
代码实现
@Service
public class PaymentService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private OrderMapper orderMapper;
@Autowired
private PaymentRecordMapper paymentRecordMapper;
/**
* 处理支付回调(幂等)
*/
@Transactional(rollbackFor = Exception.class)
public void handlePayCallback(PayCallbackDTO dto) {
String outTradeNo = dto.getOutTradeNo(); // 商户订单号
String transactionId = dto.getTransactionId(); // 第三方交易号
// 1. Redis幂等性检查(快速失败)
String idempotentKey = "pay:callback:" + outTradeNo;
Boolean lockSuccess = redisTemplate.opsForValue()
.setIfAbsent(idempotentKey, "1", 24, TimeUnit.HOURS);
if (lockSuccess == null || !lockSuccess) {
log.warn("重复的支付回调: outTradeNo={}", outTradeNo);
// 查询支付结果并返回(幂等)
PaymentRecord record = paymentRecordMapper.selectByOutTradeNo(outTradeNo);
if (record != null && record.getStatus() == PayStatus.SUCCESS) {
return; // 已支付成功,幂等返回
}
}
try {
// 2. 查询订单
Order order = orderMapper.selectByOrderNo(outTradeNo);
if (order == null) {
throw new BusinessException("订单不存在: " + outTradeNo);
}
// 3. 检查订单状态(数据库层幂等)
if (order.getStatus() == OrderStatus.PAID) {
log.info("订单已支付,幂等返回: orderNo={}", outTradeNo);
return;
}
if (order.getStatus() != OrderStatus.WAIT_PAY) {
throw new BusinessException("订单状态异常: " + order.getStatus());
}
// 4. 更新订单状态(带状态校验)
int updated = orderMapper.updateStatusWithCheck(
order.getId(),
OrderStatus.WAIT_PAY, // 旧状态
OrderStatus.PAID // 新状态
);
if (updated == 0) {
log.warn("订单状态已变更: orderNo={}", outTradeNo);
return; // 幂等返回
}
// 5. 插入支付流水记录(唯一索引保证)
PaymentRecord record = new PaymentRecord();
record.setOutTradeNo(outTradeNo);
record.setTransactionId(transactionId);
record.setAmount(order.getAmount());
record.setStatus(PayStatus.SUCCESS);
record.setPayTime(LocalDateTime.now());
try {
paymentRecordMapper.insert(record);
} catch (DuplicateKeyException e) {
log.warn("支付流水已存在: outTradeNo={}", outTradeNo);
return; // 幂等返回
}
// 6. 发送支付成功消息(异步)
// mqProducer.send(new PaySuccessEvent(order.getId()));
log.info("支付回调处理成功: orderNo={}, transactionId={}",
outTradeNo, transactionId);
} catch (Exception e) {
// 业务异常时删除Redis Key,允许重试
redisTemplate.delete(idempotentKey);
log.error("支付回调处理失败: outTradeNo={}", outTradeNo, e);
throw e;
}
}
}
Mapper
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
/**
* 带状态校验的更新(CAS)
*/
@Update("UPDATE t_order SET status=#{newStatus}, update_time=NOW() " +
"WHERE id=#{orderId} AND status=#{oldStatus}")
int updateStatusWithCheck(@Param("orderId") Long orderId,
@Param("oldStatus") Integer oldStatus,
@Param("newStatus") Integer newStatus);
}
@Mapper
public interface PaymentRecordMapper extends BaseMapper<PaymentRecord> {
/**
* 根据商户订单号查询
*/
@Select("SELECT * FROM t_payment_record WHERE out_trade_no=#{outTradeNo}")
PaymentRecord selectByOutTradeNo(@Param("outTradeNo") String outTradeNo);
}
数据库设计
-- 支付流水表
CREATE TABLE `t_payment_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`out_trade_no` VARCHAR(64) NOT NULL COMMENT '商户订单号',
`transaction_id` VARCHAR(64) NOT NULL COMMENT '第三方交易号',
`amount` DECIMAL(10,2) NOT NULL COMMENT '支付金额',
`status` TINYINT NOT NULL COMMENT '支付状态',
`pay_time` DATETIME COMMENT '支付时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_out_trade_no` (`out_trade_no`), -- 商户订单号唯一
UNIQUE KEY `uk_transaction_id` (`transaction_id`) -- 第三方交易号唯一
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付流水表';
优点
- ✅ Redis + 数据库双重保证,安全可靠
- ✅ 快速失败(Redis层),性能好
- ✅ 数据库兜底(唯一索引),数据一致
- ✅ 支持幂等查询结果
缺点
- ❌ 实现相对复杂
- ❌ 依赖Redis(可降级为纯数据库方案)
适用场景
- 支付回调处理
- 第三方接口回调
- 退款处理
- 任何需要保证资金安全的场景
场景4:防止库存超卖
问题描述
- 高并发下多个用户同时购买同一商品
- 库存数据被并发修改
- 实际扣减次数超过库存数量
- 导致库存变为负数,超卖
解决方案对比
| 方案 | 原理 | 性能 | 复杂度 | 推荐场景 |
|---|---|---|---|---|
| 分布式锁 | Redisson锁 | 中 | 中 | 中等并发 |
| 数据库悲观锁 | SELECT FOR UPDATE | 低 | 低 | 低并发 |
| 数据库乐观锁 | 版本号 | 高 | 中 | 高并发 |
| Redis原子操作 | DECR | 很高 | 高 | 超高并发 |
方案1:分布式锁(Redisson)
代码实现
@Service
public class StockService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private StockMapper stockMapper;
@Autowired
private OrderService orderService;
/**
* 扣减库存(分布式锁)
*/
public void deductStock(Long productId, Integer count, Long userId) {
String lockKey = "stock:lock:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁:最多等待10秒,锁30秒后自动释放
boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("系统繁忙,请稍后重试");
}
// 1. 查询库存
Stock stock = stockMapper.selectById(productId);
if (stock == null) {
throw new BusinessException("商品不存在");
}
// 2. 检查库存
if (stock.getAmount() < count) {
throw new BusinessException("库存不足");
}
// 3. 扣减库存
stock.setAmount(stock.getAmount() - count);
stockMapper.updateById(stock);
// 4. 创建订单
orderService.createOrder(userId, productId, count);
log.info("扣减库存成功: productId={}, count={}, remaining={}",
productId, count, stock.getAmount());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException("获取锁失败");
} finally {
// 释放锁(仅当前线程持有时才释放)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
优点
- ✅ 实现简单,逻辑清晰
- ✅ 适用于复杂业务逻辑
- ✅ 支持锁自动续期(Redisson Watchdog)
缺点
- ❌ 性能相对较低(锁等待)
- ❌ 依赖Redis,Redis宕机影响业务
- ❌ 需要处理锁超时、死锁等问题
方案2:数据库乐观锁(推荐)
数据库设计
CREATE TABLE `t_stock` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`product_id` BIGINT NOT NULL COMMENT '商品ID',
`amount` INT NOT NULL COMMENT '库存数量',
`version` INT NOT NULL DEFAULT 0 COMMENT '版本号',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存表';
代码实现
@Service
public class StockService {
@Autowired
private StockMapper stockMapper;
/**
* 扣减库存(乐观锁,带重试)
*/
public void deductStock(Long productId, Integer count) {
int maxRetry = 3; // 最多重试3次
int retryCount = 0;
while (retryCount < maxRetry) {
try {
// 1. 查询库存(不加锁)
Stock stock = stockMapper.selectById(productId);
if (stock == null) {
throw new BusinessException("商品不存在");
}
// 2. 检查库存
if (stock.getAmount() < count) {
throw new BusinessException("库存不足");
}
// 3. 扣减库存(乐观锁更新)
int updated = stockMapper.deductWithVersion(
productId,
count,
stock.getVersion()
);
if (updated > 0) {
// 更新成功,跳出重试循环
log.info("扣减库存成功: productId={}, count={}, version={}",
productId, count, stock.getVersion());
return;
} else {
// 更新失败,版本号冲突,重试
retryCount++;
log.warn("库存扣减冲突,重试第{}次: productId={}", retryCount, productId);
if (retryCount >= maxRetry) {
throw new BusinessException("系统繁忙,请稍后重试");
}
// 短暂等待后重试(避免CPU空转)
Thread.sleep(50);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException("系统异常");
}
}
}
}
Mapper
@Mapper
public interface StockMapper extends BaseMapper<Stock> {
/**
* 扣减库存(乐观锁)
* 返回影响行数:0表示版本号冲突,1表示成功
*/
@Update("UPDATE t_stock " +
"SET amount = amount - #{count}, " +
" version = version + 1 " +
"WHERE product_id = #{productId} " +
" AND amount >= #{count} " + // 防止库存变负数
" AND version = #{version}") // 版本号校验
int deductWithVersion(@Param("productId") Long productId,
@Param("count") Integer count,
@Param("version") Integer version);
}
优点
- ✅ 性能好,无锁等待
- ✅ 不依赖Redis等中间件
- ✅ 数据库原生支持
缺点
- ❌ 冲突时需要重试,用户体验较差
- ❌ 高并发下重试次数多
方案3:Redis原子操作(超高并发)
代码实现
@Service
public class StockService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private StockMapper stockMapper;
/**
* 预热库存到Redis
*/
public void preloadStock(Long productId) {
Stock stock = stockMapper.selectById(productId);
String key = "stock:" + productId;
redisTemplate.opsForValue().set(key, String.valueOf(stock.getAmount()));
}
/**
* 扣减库存(Redis原子操作)
*/
public void deductStock(Long productId, Integer count) {
String key = "stock:" + productId;
// 使用Lua脚本保证原子性
String luaScript =
"local stock = tonumber(redis.call('get', KEYS[1])) " +
"if stock >= tonumber(ARGV[1]) then " +
" redis.call('decrby', KEYS[1], ARGV[1]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
RedisScript<Long> script = RedisScript.of(luaScript, Long.class);
Long result = redisTemplate.execute(
script,
Collections.singletonList(key),
String.valueOf(count)
);
if (result == null || result == 0) {
throw new BusinessException("库存不足");
}
// 异步更新数据库(最终一致性)
asyncUpdateDatabase(productId, count);
log.info("扣减Redis库存成功: productId={}, count={}", productId, count);
}
/**
* 异步更新数据库
*/
@Async
public void asyncUpdateDatabase(Long productId, Integer count) {
try {
stockMapper.deduct(productId, count);
} catch (Exception e) {
log.error("异步更新数据库失败: productId={}", productId, e);
// 补偿机制:恢复Redis库存
String key = "stock:" + productId;
redisTemplate.opsForValue().increment(key, count);
}
}
}
优点
- ✅ 性能极高,支持超高并发
- ✅ 原子操作,无竞态条件
- ✅ 无锁,无等待
缺点
- ❌ 实现复杂,需要数据同步
- ❌ 数据一致性要求高(需要补偿机制)
- ❌ Redis宕机影响业务
场景5:防止MQ消息重复消费
问题描述
- MQ消息可能重复投递(网络波动、消费者重启)
- 消费者业务逻辑被重复执行
- 导致数据重复或状态异常
解决方案:消息ID + 数据库唯一索引
流程图
sequenceDiagram
participant MQ
participant 消费者
participant Redis
participant MySQL
MQ->>消费者: 1. 投递消息(messageId)
消费者->>Redis: 2. SETNX(消息ID)
alt 首次消费
Redis-->>消费者: 设置成功
消费者->>MySQL: 3. 插入消费记录(唯一索引)
alt 插入成功
MySQL-->>消费者: 插入成功
消费者->>消费者: 4. 执行业务逻辑
消费者->>MQ: 5. ACK确认
else 记录已存在
MySQL-->>消费者: 唯一索引冲突
消费者->>MQ: ACK确认(幂等)
end
else 重复消费
Redis-->>消费者: 设置失败
消费者->>MQ: 6. ACK确认(幂等)
end
MQ->>消费者: 7. 再次投递(重复)
消费者->>Redis: 8. SETNX(消息ID)
Redis-->>消费者: 设置失败
消费者->>MQ: 9. 直接ACK
数据库设计
-- 消息消费记录表
CREATE TABLE `t_message_consume_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`message_id` VARCHAR(64) NOT NULL COMMENT '消息ID',
`topic` VARCHAR(64) NOT NULL COMMENT '消息主题',
`content` TEXT COMMENT '消息内容',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '消费状态:0-消费中,1-成功,2-失败',
`consume_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消费时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_message_id` (`message_id`) -- 消息ID唯一索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息消费日志';
代码实现
/**
* RocketMQ消费者(幂等处理)
*/
@Component
@RocketMQMessageListener(
topic = "ORDER_TOPIC",
consumerGroup = "order-consumer-group"
)
public class OrderMessageConsumer implements RocketMQListener<MessageExt> {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private MessageConsumeLogMapper consumeLogMapper;
@Autowired
private OrderService orderService;
@Override
public void onMessage(MessageExt message) {
String messageId = message.getMsgId();
String body = new String(message.getBody(), StandardCharsets.UTF_8);
log.info("收到消息: messageId={}, body={}", messageId, body);
try {
// 1. Redis快速幂等检查
String idempotentKey = "mq:consumed:" + messageId;
Boolean lockSuccess = redisTemplate.opsForValue()
.setIfAbsent(idempotentKey, "1", 24, TimeUnit.HOURS);
if (lockSuccess == null || !lockSuccess) {
log.warn("重复消费的消息: messageId={}", messageId);
return; // 幂等返回,ACK确认
}
// 2. 数据库幂等记录(防止Redis数据丢失)
MessageConsumeLog consumeLog = new MessageConsumeLog();
consumeLog.setMessageId(messageId);
consumeLog.setTopic(message.getTopic());
consumeLog.setContent(body);
consumeLog.setStatus(ConsumeStatus.CONSUMING);
try {
consumeLogMapper.insert(consumeLog);
} catch (DuplicateKeyException e) {
log.warn("消息已消费过: messageId={}", messageId);
return; // 幂等返回
}
// 3. 执行业务逻辑
OrderMessage orderMsg = JSON.parseObject(body, OrderMessage.class);
orderService.processOrder(orderMsg);
// 4. 更新消费状态为成功
consumeLog.setStatus(ConsumeStatus.SUCCESS);
consumeLogMapper.updateById(consumeLog);
log.info("消息消费成功: messageId={}", messageId);
} catch (Exception e) {
log.error("消息消费失败: messageId={}", messageId, e);
// 更新消费状态为失败
consumeLogMapper.updateStatusByMessageId(
messageId, ConsumeStatus.FAILED);
// 删除Redis标识,允许重新消费
String idempotentKey = "mq:consumed:" + messageId;
redisTemplate.delete(idempotentKey);
// 抛出异常,触发消息重试
throw new RuntimeException("消息处理失败", e);
}
}
}
Kafka消费者示例
@Component
public class KafkaOrderConsumer {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private MessageConsumeLogMapper consumeLogMapper;
@KafkaListener(topics = "order-topic", groupId = "order-group")
public void consume(ConsumerRecord<String, String> record) {
// 使用 offset + partition + topic 作为唯一ID
String messageId = String.format("%s-%d-%d",
record.topic(), record.partition(), record.offset());
String body = record.value();
log.info("收到Kafka消息: messageId={}, body={}", messageId, body);
// 幂等处理逻辑同上...
}
}
优点
- ✅ Redis + 数据库双重保证
- ✅ 适用于各种MQ(RocketMQ、Kafka、RabbitMQ)
- ✅ 可追溯消费记录
缺点
- ❌ 需要额外的消费日志表
- ❌ 依赖Redis(可降级为纯数据库方案)
适用场景
- RocketMQ消息消费
- Kafka消息消费
- RabbitMQ消息消费
- 任何需要保证消息幂等的场景
场景6:防止状态重复变更
问题描述
- 订单状态从"待支付"变更为"已支付"
- 并发情况下可能被多次变更
- 状态流转不符合业务规则
解决方案:状态机 + CAS更新
状态机设计
stateDiagram-v2
[*] --> 待支付: 创建订单
待支付 --> 已支付: 支付成功
待支付 --> 已取消: 超时/用户取消
已支付 --> 待发货: 商家确认
待发货 --> 已发货: 物流发货
已发货 --> 已完成: 用户确认收货
已完成 --> 已评价: 用户评价
已支付 --> 退款中: 用户申请退款
退款中 --> 已退款: 退款成功
退款中 --> 已支付: 拒绝退款
已取消 --> [*]
已评价 --> [*]
已退款 --> [*]
代码实现
/**
* 订单状态枚举
*/
public enum OrderStatus {
WAIT_PAY(0, "待支付"),
PAID(1, "已支付"),
WAIT_DELIVER(2, "待发货"),
DELIVERED(3, "已发货"),
COMPLETED(4, "已完成"),
CANCELLED(5, "已取消"),
REFUNDING(6, "退款中"),
REFUNDED(7, "已退款");
private final Integer code;
private final String desc;
// 定义允许的状态流转规则
private static final Map<OrderStatus, Set<OrderStatus>> STATE_MACHINE =
new HashMap<>();
static {
STATE_MACHINE.put(WAIT_PAY, Set.of(PAID, CANCELLED));
STATE_MACHINE.put(PAID, Set.of(WAIT_DELIVER, REFUNDING));
STATE_MACHINE.put(WAIT_DELIVER, Set.of(DELIVERED, REFUNDING));
STATE_MACHINE.put(DELIVERED, Set.of(COMPLETED, REFUNDING));
STATE_MACHINE.put(COMPLETED, Set.of(REFUNDING));
STATE_MACHINE.put(REFUNDING, Set.of(REFUNDED, PAID));
}
/**
* 检查状态流转是否合法
*/
public boolean canTransitionTo(OrderStatus target) {
Set<OrderStatus> allowedStates = STATE_MACHINE.get(this);
return allowedStates != null && allowedStates.contains(target);
}
}
Service实现
@Service
public class OrderStatusService {
@Autowired
private OrderMapper orderMapper;
/**
* 更新订单状态(幂等)
*/
@Transactional(rollbackFor = Exception.class)
public void updateOrderStatus(Long orderId, OrderStatus targetStatus) {
// 1. 查询当前订单状态
Order order = orderMapper.selectById(orderId);
if (order == null) {
throw new BusinessException("订单不存在");
}
OrderStatus currentStatus = OrderStatus.fromCode(order.getStatus());
// 2. 幂等性检查:如果已经是目标状态,直接返回
if (currentStatus == targetStatus) {
log.info("订单状态已是目标状态,幂等返回: orderId={}, status={}",
orderId, targetStatus);
return;
}
// 3. 状态机校验:检查是否允许流转
if (!currentStatus.canTransitionTo(targetStatus)) {
throw new BusinessException(
String.format("订单状态不允许从[%s]变更为[%s]",
currentStatus.getDesc(), targetStatus.getDesc())
);
}
// 4. CAS更新:带状态校验的更新(乐观锁)
int updated = orderMapper.updateStatusWithCheck(
orderId,
currentStatus.getCode(), // 期望的旧状态
targetStatus.getCode() // 目标新状态
);
if (updated == 0) {
log.warn("订单状态更新失败,可能已被其他请求修改: orderId={}", orderId);
throw new BusinessException("订单状态已变更,请刷新后重试");
}
// 5. 记录状态变更日志
OrderStatusLog statusLog = new OrderStatusLog();
statusLog.setOrderId(orderId);
statusLog.setOldStatus(currentStatus.getCode());
statusLog.setNewStatus(targetStatus.getCode());
statusLog.setChangeTime(LocalDateTime.now());
orderStatusLogMapper.insert(statusLog);
log.info("订单状态更新成功: orderId={}, {} -> {}",
orderId, currentStatus.getDesc(), targetStatus.getDesc());
}
}
Mapper
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
/**
* CAS更新状态(带状态校验)
*/
@Update("UPDATE t_order " +
"SET status = #{newStatus}, update_time = NOW() " +
"WHERE id = #{orderId} AND status = #{oldStatus}")
int updateStatusWithCheck(@Param("orderId") Long orderId,
@Param("oldStatus") Integer oldStatus,
@Param("newStatus") Integer newStatus);
}
优点
- ✅ 业务语义清晰,符合领域模型
- ✅ 状态流转规则集中管理
- ✅ CAS更新保证并发安全
- ✅ 天然支持幂等(相同状态直接返回)
缺点
- ❌ 状态机设计需要前期规划
- ❌ 状态流转规则复杂时维护成本高
适用场景
- 订单状态流转
- 支付状态流转
- 审批流程
- 任何有明确状态流转规则的业务
场景7:防止余额重复扣减
问题描述
- 用户余额扣减操作被重复执行
- 并发扣减导致余额错误
- 余额不足时仍被扣减(负数)
解决方案:数据库行锁 + 余额校验
代码实现
@Service
public class AccountService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountLogMapper accountLogMapper;
/**
* 扣减余额(幂等)
*/
@Transactional(rollbackFor = Exception.class)
public void deductBalance(Long userId, BigDecimal amount, String bizNo) {
// 1. 幂等性检查:根据业务单号查询扣款记录
AccountLog existLog = accountLogMapper.selectByBizNo(bizNo);
if (existLog != null) {
log.warn("重复的扣款请求: userId={}, bizNo={}", userId, bizNo);
return; // 幂等返回
}
// 2. 查询账户并加行锁(SELECT FOR UPDATE)
Account account = accountMapper.selectByIdForUpdate(userId);
if (account == null) {
throw new BusinessException("账户不存在");
}
// 3. 检查余额是否足够
if (account.getBalance().compareTo(amount) < 0) {
throw new BusinessException("余额不足");
}
// 4. 扣减余额
BigDecimal newBalance = account.getBalance().subtract(amount);
account.setBalance(newBalance);
accountMapper.updateById(account);
// 5. 记录扣款流水(唯一索引保证幂等)
AccountLog accountLog = new AccountLog();
accountLog.setUserId(userId);
accountLog.setBizNo(bizNo); // 业务单号(唯一)
accountLog.setType(AccountLogType.DEDUCT);
accountLog.setAmount(amount);
accountLog.setBeforeBalance(account.getBalance().add(amount));
accountLog.setAfterBalance(newBalance);
accountLog.setCreateTime(LocalDateTime.now());
try {
accountLogMapper.insert(accountLog);
} catch (DuplicateKeyException e) {
// 唯一索引冲突,说明已扣款
log.warn("扣款记录已存在: bizNo={}", bizNo);
throw new BusinessException("请勿重复扣款");
}
log.info("扣减余额成功: userId={}, amount={}, bizNo={}, newBalance={}",
userId, amount, bizNo, newBalance);
}
/**
* 充值余额(幂等)
*/
@Transactional(rollbackFor = Exception.class)
public void rechargeBalance(Long userId, BigDecimal amount, String bizNo) {
// 幂等性检查
AccountLog existLog = accountLogMapper.selectByBizNo(bizNo);
if (existLog != null) {
log.warn("重复的充值请求: userId={}, bizNo={}", userId, bizNo);
return;
}
// 查询账户并加锁
Account account = accountMapper.selectByIdForUpdate(userId);
if (account == null) {
throw new BusinessException("账户不存在");
}
// 增加余额
BigDecimal newBalance = account.getBalance().add(amount);
account.setBalance(newBalance);
accountMapper.updateById(account);
// 记录充值流水
AccountLog accountLog = new AccountLog();
accountLog.setUserId(userId);
accountLog.setBizNo(bizNo);
accountLog.setType(AccountLogType.RECHARGE);
accountLog.setAmount(amount);
accountLog.setBeforeBalance(account.getBalance().subtract(amount));
accountLog.setAfterBalance(newBalance);
accountLogMapper.insert(accountLog);
log.info("充值余额成功: userId={}, amount={}, bizNo={}, newBalance={}",
userId, amount, bizNo, newBalance);
}
}
Mapper
@Mapper
public interface AccountMapper extends BaseMapper<Account> {
/**
* 查询账户并加行锁
*/
@Select("SELECT * FROM t_account WHERE user_id = #{userId} FOR UPDATE")
Account selectByIdForUpdate(@Param("userId") Long userId);
}
@Mapper
public interface AccountLogMapper extends BaseMapper<AccountLog> {
/**
* 根据业务单号查询流水
*/
@Select("SELECT * FROM t_account_log WHERE biz_no = #{bizNo}")
AccountLog selectByBizNo(@Param("bizNo") String bizNo);
}
数据库设计
-- 账户表
CREATE TABLE `t_account` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`balance` DECIMAL(20,2) NOT NULL DEFAULT 0.00 COMMENT '账户余额',
`frozen_balance` DECIMAL(20,2) NOT NULL DEFAULT 0.00 COMMENT '冻结金额',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户表';
-- 账户流水表
CREATE TABLE `t_account_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`biz_no` VARCHAR(64) NOT NULL COMMENT '业务单号',
`type` TINYINT NOT NULL COMMENT '类型:1-充值,2-扣减',
`amount` DECIMAL(20,2) NOT NULL COMMENT '金额',
`before_balance` DECIMAL(20,2) NOT NULL COMMENT '变更前余额',
`after_balance` DECIMAL(20,2) NOT NULL COMMENT '变更后余额',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_no` (`biz_no`), -- 业务单号唯一
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户流水表';
优点
- ✅ SELECT FOR UPDATE 保证并发安全
- ✅ 业务单号保证幂等性
- ✅ 流水记录可追溯
缺点
- ❌ 行锁性能相对较低
- ❌ 可能发生死锁(需要注意锁顺序)
适用场景
- 用户余额扣减/充值
- 积分变更
- 虚拟货币操作
- 任何涉及金额计算的场景
场景8:防止第三方接口重复调用
问题描述
- 调用第三方接口(短信、邮件、推送)
- 网络异常导致重试
- 第三方接口被重复调用
- 造成资源浪费或费用增加
解决方案:请求指纹 + Redis缓存
代码实现
@Service
public class SmsService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ThirdPartySmsClient smsClient;
/**
* 发送短信(幂等)
*/
public void sendSms(String mobile, String content, String bizType) {
// 1. 生成请求指纹(MD5)
String fingerprint = generateFingerprint(mobile, content, bizType);
String cacheKey = "sms:fingerprint:" + fingerprint;
// 2. 检查是否已发送(1小时内)
String cached = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isNotBlank(cached)) {
log.warn("重复的短信发送请求: mobile={}, bizType={}",
mobile, bizType);
return; // 幂等返回
}
try {
// 3. 调用第三方接口发送短信
SmsResult result = smsClient.send(mobile, content);
if (!result.isSuccess()) {
throw new BusinessException("短信发送失败: " + result.getMessage());
}
// 4. 记录发送成功标识(1小时过期)
redisTemplate.opsForValue().set(
cacheKey,
result.getMessageId(),
1,
TimeUnit.HOURS
);
log.info("短信发送成功: mobile={}, messageId={}",
mobile, result.getMessageId());
} catch (Exception e) {
log.error("短信发送异常: mobile={}", mobile, e);
throw new BusinessException("短信发送失败,请稍后重试");
}
}
/**
* 生成请求指纹
*/
private String generateFingerprint(String mobile, String content, String bizType) {
String raw = mobile + "|" + content + "|" + bizType;
return DigestUtils.md5DigestAsHex(raw.getBytes(StandardCharsets.UTF_8));
}
}
验证码发送(限流)
@Service
public class CaptchaService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private SmsService smsService;
/**
* 发送验证码(幂等 + 限流)
*/
public void sendCaptcha(String mobile, CaptchaType type) {
String rateLimitKey = "captcha:limit:" + mobile + ":" + type;
String cacheKey = "captcha:code:" + mobile + ":" + type;
// 1. 限流检查:60秒内只能发送一次
Long ttl = redisTemplate.getExpire(rateLimitKey, TimeUnit.SECONDS);
if (ttl != null && ttl > 0) {
throw new BusinessException(
String.format("操作过于频繁,请%d秒后再试", ttl));
}
// 2. 生成6位验证码
String code = String.format("%06d", new Random().nextInt(1000000));
// 3. 发送短信
String content = String.format("您的验证码是:%s,5分钟内有效", code);
smsService.sendSms(mobile, content, type.name());
// 4. 缓存验证码(5分钟有效)
redisTemplate.opsForValue().set(cacheKey, code, 5, TimeUnit.MINUTES);
// 5. 设置限流标识(60秒)
redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS);
log.info("验证码发送成功: mobile={}, type={}", mobile, type);
}
/**
* 校验验证码
*/
public boolean verifyCaptcha(String mobile, CaptchaType type, String code) {
String cacheKey = "captcha:code:" + mobile + ":" + type;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isBlank(cached)) {
return false; // 验证码不存在或已过期
}
boolean valid = cached.equals(code);
if (valid) {
// 验证成功后删除验证码(一次性使用)
redisTemplate.delete(cacheKey);
}
return valid;
}
}
邮件发送(异步 + 幂等)
@Service
public class EmailService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private JavaMailSender mailSender;
/**
* 异步发送邮件(幂等)
*/
@Async
public void sendEmail(String to, String subject, String content, String bizNo) {
String cacheKey = "email:sent:" + bizNo;
// 幂等性检查
Boolean sent = redisTemplate.hasKey(cacheKey);
if (Boolean.TRUE.equals(sent)) {
log.warn("重复的邮件发送请求: to={}, bizNo={}", to, bizNo);
return;
}
try {
// 构建邮件
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(content, true); // 支持HTML
// 发送邮件
mailSender.send(message);
// 标记已发送(24小时)
redisTemplate.opsForValue().set(cacheKey, "1", 24, TimeUnit.HOURS);
log.info("邮件发送成功: to={}, subject={}, bizNo={}",
to, subject, bizNo);
} catch (Exception e) {
log.error("邮件发送失败: to={}, bizNo={}", to, bizNo, e);
// 不抛出异常,避免异步任务失败
}
}
}
优点
- ✅ 防止重复调用第三方接口
- ✅ 节省成本(短信、推送等按次收费)
- ✅ 支持限流控制
缺点
- ❌ 依赖Redis,Redis宕机影响功能
- ❌ 指纹算法需要合理设计
适用场景
- 短信发送
- 邮件发送
- Push推送
- 第三方API调用(支付、物流查询等)
四、方案对比与选择指南
4.1 方案对比表
| 方案 | 实现复杂度 | 性能 | 可靠性 | 依赖组件 | 适用场景 |
|---|---|---|---|---|---|
| Token机制 | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | Redis | 前端表单提交 |
| 唯一索引 | ⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 数据库 | 订单创建、用户注册 |
| 业务单号 | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | Redis+数据库 | 支付、退款 |
| 分布式锁 | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | Redis | 库存扣减、复杂业务 |
| 悲观锁 | ⭐ | ⭐ | ⭐⭐⭐⭐ | 数据库 | 低并发场景 |
| 乐观锁 | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | 数据库 | 高并发场景 |
| 状态机 | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | 数据库 | 状态流转业务 |
| Redis原子操作 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | Redis | 超高并发 |
4.2 决策树
graph TD
A[需要保证幂等性] --> B{是否前端操作?}
B -->|是| C[Token机制]
B -->|否| D{是否涉及金额?}
D -->|是| E{支付还是余额?}
E -->|支付| F[业务单号 + 唯一索引]
E -->|余额| G[行锁 + 流水记录]
D -->|否| H{是否高并发?}
H -->|是| I{读多还是写多?}
I -->|写多| J[Redis原子操作]
I -->|读多| K[乐观锁]
H -->|否| L{是否状态流转?}
L -->|是| M[状态机 + CAS]
L -->|否| N[唯一索引或分布式锁]
4.3 场景选择速查表
| 业务场景 | 推荐方案 | 备选方案 |
|---|---|---|
| 用户下单 | Token + 唯一索引 | 分布式锁 |
| 支付回调 | 业务单号 + 唯一索引 | 状态机 |
| 库存扣减 | 乐观锁 | 分布式锁、Redis原子 |
| 余额变更 | 行锁 + 流水记录 | 分布式锁 |
| 订单状态变更 | 状态机 + CAS | 乐观锁 |
| MQ消息消费 | 消息ID + 唯一索引 | 分布式锁 |
| 短信发送 | 请求指纹 + Redis | Token |
| 用户注册 | 唯一索引 | Token |
| 积分发放 | 业务单号 + 唯一索引 | 行锁 |
| 第三方回调 | 业务单号 + Redis | 唯一索引 |
五、实战案例
案例1:电商秒杀系统
需求: 10000件商品,10万用户同时抢购,保证不超卖
方案组合:
- Redis预加载库存(Lua脚本扣减)
- 数据库乐观锁兜底
- 唯一索引防止重复下单
@Service
public class SecKillService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private StockMapper stockMapper;
/**
* 秒杀抢购
*/
public OrderVO secKill(Long userId, Long productId) {
// 1. 检查用户是否已抢购(幂等)
String userKey = "seckill:user:" + productId + ":" + userId;
Boolean purchased = redisTemplate.hasKey(userKey);
if (Boolean.TRUE.equals(purchased)) {
throw new BusinessException("您已参与过该商品的秒杀");
}
// 2. Redis扣减库存(Lua脚本保证原子性)
String stockKey = "seckill:stock:" + productId;
String luaScript =
"local stock = tonumber(redis.call('get', KEYS[1])) " +
"if stock and stock > 0 then " +
" redis.call('decr', KEYS[1]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
RedisScript<Long> script = RedisScript.of(luaScript, Long.class);
Long result = redisTemplate.execute(
script, Collections.singletonList(stockKey));
if (result == null || result == 0) {
throw new BusinessException("商品已售罄");
}
// 3. 标记用户已抢购(1小时)
redisTemplate.opsForValue().set(userKey, "1", 1, TimeUnit.HOURS);
// 4. 异步创建订单(MQ)
SecKillMessage message = new SecKillMessage();
message.setUserId(userId);
message.setProductId(productId);
mqProducer.send("SECKILL_ORDER_TOPIC", message);
// 5. 返回结果(订单异步生成)
OrderVO vo = new OrderVO();
vo.setStatus("PROCESSING");
vo.setMessage("抢购成功,订单生成中...");
return vo;
}
}
案例2:订单支付全流程
需求: 用户支付订单,防止重复支付、重复扣款
方案组合:
- 前端Token防重复提交
- 支付单号保证幂等
- 订单状态机控制流转
@Service
public class OrderPayService {
@Autowired
private OrderService orderService;
@Autowired
private PaymentService paymentService;
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 支付订单(完整流程)
*/
@Transactional(rollbackFor = Exception.class)
public PayResult payOrder(OrderPayDTO dto) {
Long orderId = dto.getOrderId();
String token = dto.getToken();
// 1. 验证Token(防前端重复提交)
String tokenKey = "order:pay:token:" + token;
Boolean deleted = redisTemplate.delete(tokenKey);
if (deleted == null || !deleted) {
throw new BusinessException("请勿重复支付");
}
try {
// 2. 查询订单
Order order = orderService.getById(orderId);
if (order == null) {
throw new BusinessException("订单不存在");
}
// 3. 检查订单状态(幂等)
if (order.getStatus() == OrderStatus.PAID.getCode()) {
log.info("订单已支付,幂等返回: orderId={}", orderId);
return PayResult.success(order.getPayNo());
}
if (order.getStatus() != OrderStatus.WAIT_PAY.getCode()) {
throw new BusinessException("订单状态异常,无法支付");
}
// 4. 生成支付单号(幂等标识)
String payNo = generatePayNo(orderId);
// 5. 调用支付网关
PayGatewayResult gatewayResult = paymentService.pay(
payNo,
order.getAmount(),
dto.getPayType()
);
if (!gatewayResult.isSuccess()) {
throw new BusinessException("支付失败: " + gatewayResult.getMessage());
}
// 6. 更新订单状态
orderService.updatePayInfo(
orderId,
payNo,
gatewayResult.getTransactionId()
);
return PayResult.success(payNo);
} catch (Exception e) {
// 失败时恢复Token,允许重新支付
redisTemplate.opsForValue().set(tokenKey, "1", 5, TimeUnit.MINUTES);
throw e;
}
}
}
六、常见问题与陷阱
6.1 Redis宕机怎么办?
问题: 基于Redis的幂等方案,Redis宕机后失效
解决方案:
-
Redis主从 + 哨兵
- 部署Redis高可用集群
- 自动故障转移
-
降级方案
public void deductStock(Long productId, Integer count) { try { // 尝试Redis方案 redisStockService.deduct(productId, count); } catch (Exception e) { log.error("Redis异常,降级为数据库乐观锁", e); // 降级为数据库乐观锁 dbStockService.deductWithOptimisticLock(productId, count); } } -
数据库兜底
- Redis快速失败
- 数据库唯一索引保证最终一致性
6.2 Token被前端缓存怎么办?
问题: 前端缓存Token,刷新页面后Token失效
解决方案:
// 不缓存Token,每次打开页面重新获取
async mounted() {
const res = await this.$axios.get('/order/getToken')
this.token = res.data
}
// 或者:提交成功后立即获取新Token
async submitOrder() {
await this.$axios.post('/order/submit', { token: this.token })
// 重新获取Token
const res = await this.$axios.get('/order/getToken')
this.token = res.data
}
6.3 分布式锁死锁怎么办?
问题: 服务宕机,锁未释放,导致死锁
解决方案:
-
设置锁过期时间
// 锁30秒后自动释放 lock.tryLock(10, 30, TimeUnit.SECONDS); -
Redisson Watchdog
- Redisson自动续期机制
- 线程存活时自动延长锁时间
-
手动续期
// 长时间任务,手动续期 RFuture<Boolean> future = lock.renewExpirationAsync();
6.4 乐观锁冲突率太高怎么办?
问题: 高并发下乐观锁冲突频繁,用户体验差
解决方案:
-
增加重试次数
int maxRetry = 5; // 从3次增加到5次 -
随机退避
// 重试间隔随机化,避免冲突 Thread.sleep(50 + new Random().nextInt(50)); -
分段锁
// 库存拆分为多个段,减少冲突 // 商品1000件 -> 拆分为10段,每段100件 -
切换为分布式锁或Redis原子操作
6.5 幂等Key的过期时间怎么设置?
原则:
- 短期操作(表单提交):5分钟
- 支付回调:24小时
- MQ消费:24-48小时
- 第三方接口调用:根据业务(短信1小时,邮件24小时)
示例:
// 表单提交:5分钟
redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
// 支付回调:24小时
redisTemplate.opsForValue().set(key, "1", 24, TimeUnit.HOURS);
// 短信发送:1小时
redisTemplate.opsForValue().set(key, "1", 1, TimeUnit.HOURS);
6.6 业务异常时是否删除幂等标识?
原则:
- 数据校验失败 → 不删除(如"库存不足"、"余额不足")
- 系统异常 → 删除,允许重试(如网络异常、数据库异常)
- 业务状态异常 → 不删除(如"订单已支付"、"订单已取消")
示例:
try {
// 业务逻辑
} catch (BusinessException e) {
// 业务异常:不删除幂等标识
log.warn("业务异常: {}", e.getMessage());
throw e;
} catch (Exception e) {
// 系统异常:删除幂等标识,允许重试
log.error("系统异常", e);
redisTemplate.delete(idempotentKey);
throw e;
}
6.7 如何测试幂等性?
测试方法:
-
单元测试(模拟重复请求)
@Test public void testIdempotent() { OrderDTO dto = new OrderDTO(); dto.setUserId(1L); dto.setProductId(100L); // 第一次调用 Long orderId1 = orderService.createOrder(dto); // 第二次调用(重复) Long orderId2 = orderService.createOrder(dto); // 验证:两次调用返回相同订单ID assertEquals(orderId1, orderId2); } -
压力测试(JMeter)
- 模拟1000个并发请求
- 验证数据库只插入1条记录
-
手动测试
- 快速点击按钮多次
- 检查是否生成重复数据
七、总结
核心原则
- 业务语义清晰 - 幂等方案要符合业务逻辑
- 多层防护 - Redis + 数据库双重保证
- 性能优先 - 快速失败,减少数据库压力
- 可降级 - Redis宕机时有备用方案
- 可追溯 - 记录幂等日志,便于排查问题
选择建议
| 优先级 | 考虑因素 | 建议 |
|---|---|---|
| 1 | 是否涉及金额 | 优先考虑可靠性,采用多重保障 |
| 2 | 并发量 | 高并发用Redis,低并发用数据库 |
| 3 | 业务复杂度 | 简单业务用唯一索引,复杂业务用状态机 |
| 4 | 前后端分离 | 前端操作用Token,后端操作用业务单号 |
| 5 | 第三方依赖 | 第三方接口调用必须做幂等 |
最佳实践
-
设计阶段
- 识别哪些接口需要幂等
- 选择合适的幂等方案
- 设计唯一标识(Token、业务单号)
-
开发阶段
- 统一幂等工具类/注解
- 规范异常处理
- 完善日志记录
-
测试阶段
- 单元测试覆盖幂等场景
- 压力测试验证并发安全
- 模拟各种异常情况
-
上线阶段
- 监控幂等Key的命中率
- 告警Redis/数据库异常
- 定期清理过期数据
文档结束
如有问题或建议,欢迎反馈。

浙公网安备 33010602011771号