一、幂等性概念与重要性
1.1 什么是幂等性?
/**
* 幂等性:无论调用多少次,结果都相同的操作
* f(f(x)) = f(x)
*
* 举例:
* 1. GET请求:天然幂等(查询不改变状态)
* 2. DELETE请求:删除同一个资源多次,结果相同(资源已不存在)
* 3. PUT请求:更新同一个资源为相同值,结果相同
* 4. POST请求:通常不幂等(创建多个资源)
*/
1.2 为什么需要幂等性?
java
// 场景分析
public class IdempotentScenario {
// 1. 网络重试
// 客户端未收到响应,自动重试 → 可能重复提交
// 2. 前端重复点击
// 用户连续点击提交按钮 → 多次请求
// 3. 消息队列重复消费
// MQ保证至少一次投递 → 可能重复处理
// 4. 分布式系统调用失败重试
// 服务间调用失败 → 上游服务重试
}
二、幂等性实现方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 唯一索引 | 数据库唯一约束 | 简单可靠 | 仅限数据库操作 | 数据创建类接口 |
| Token机制 | 一次性令牌 | 完全防止重复提交 | 需要额外交互 | 前端表单提交 |
| 乐观锁 | 版本号控制 | 高性能,无锁竞争 | 需要设计版本字段 | 数据更新类接口 |
| 状态机 | 状态流转约束 | 业务语义清晰 | 需要设计状态字段 | 有状态流转的业务 |
| 分布式锁 | 互斥锁 | 保证强一致性 | 性能开销大 | 并发控制场景 |
| 防重表 | 记录请求ID | 通用性强 | 需要额外表维护 | 通用接口幂等 |
三、具体实现方案详解
3.1 唯一索引方案
java
/**
* 方案一:数据库唯一索引
* 适用场景:创建类操作(订单、支付记录等)
*/
@Service
@Transactional
public class OrderService {
@Autowired
private OrderRepository orderRepository;
public Order createOrder(OrderRequest request) {
// 业务唯一标识(如:订单号+业务类型)
String businessKey = generateBusinessKey(request);
try {
// 尝试插入,依赖数据库唯一索引保证幂等
Order order = new Order();
order.setOrderNo(request.getOrderNo());
order.setBusinessType(request.getType());
order.setUniqueKey(businessKey); // 唯一键
order.setStatus(OrderStatus.CREATED);
return orderRepository.save(order);
} catch (DataIntegrityViolationException e) {
// 唯一索引冲突,说明已创建过
log.info("订单已存在,businessKey: {}", businessKey);
// 返回已存在的订单
return orderRepository.findByUniqueKey(businessKey)
.orElseThrow(() -> new BusinessException("订单查询失败"));
}
}
private String generateBusinessKey(OrderRequest request) {
// 生成唯一业务键
return String.format("%s_%s_%s",
request.getOrderNo(),
request.getType(),
request.getUserId()
);
}
}
// 数据库表设计
@Entity
@Table(name = "t_order",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"unique_key"})
})
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "unique_key", nullable = false)
private String uniqueKey; // 业务唯一键
private String orderNo;
private String businessType;
private String status;
// ...
}
3.2 Token令牌方案
javascript
/**
* 方案二:Token令牌机制
* 适用场景:前端表单提交,防止重复点击
*/
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 1. 获取Token
@GetMapping("/token")
public ApiResponse<String> getToken() {
String token = UUID.randomUUID().toString();
// Token存入Redis,设置5分钟过期
String key = "submit_token:" + token;
redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
return ApiResponse.success(token);
}
// 2. 提交订单(验证Token)
@PostMapping("/submit")
public ApiResponse<OrderDTO> submitOrder(
@RequestHeader("X-Submit-Token") String token,
@RequestBody OrderRequest request) {
// 验证Token
if (!validateToken(token)) {
return ApiResponse.error("重复提交或Token已过期");
}
// 删除Token,保证一次性使用
deleteToken(token);
// 执行业务逻辑
OrderDTO order = orderService.createOrder(request);
return ApiResponse.success(order);
}
private boolean validateToken(String token) {
String key = "submit_token:" + token;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
private void deleteToken(String token) {
String key = "submit_token:" + token;
redisTemplate.delete(key);
}
}
// 前端实现示例(Vue)
<script>
export default {
methods: {
async submitOrder() {
// 1. 获取Token
const { data: token } = await this.$http.get('/api/orders/token');
// 2. 提交请求(携带Token)
try {
const response = await this.$http.post('/api/orders/submit',
this.formData,
{ headers: { 'X-Submit-Token': token } }
);
// 3. 处理响应
if (response.code === 200) {
this.$message.success('提交成功');
}
} catch (error) {
if (error.response?.data?.message?.includes('重复提交')) {
this.$message.warning('请勿重复提交');
}
}
}
}
}
</script>
3.3 乐观锁方案
java
/**
* 方案三:乐观锁(版本号控制)
* 适用场景:更新类操作,并发更新
*/
@Entity
@Table(name = "t_account")
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String accountNo;
private BigDecimal balance;
@Version // JPA乐观锁注解
private Integer version;
// 其他字段...
}
@Service
public class AccountService {
@Autowired
private AccountRepository accountRepository;
/**
* 扣减余额(幂等更新)
* @param requestId 请求唯一ID,用于幂等
*/
@Transactional
public void deductBalance(String accountNo, BigDecimal amount, String requestId) {
// 方法1:使用版本号
Account account = accountRepository.findByAccountNoForUpdate(accountNo);
// 检查余额
if (account.getBalance().compareTo(amount) < 0) {
throw new BusinessException("余额不足");
}
// 更新余额
account.setBalance(account.getBalance().subtract(amount));
try {
// 保存时会检查版本号
accountRepository.save(account);
// 记录操作流水(幂等检查)
saveDeductRecord(requestId, accountNo, amount);
} catch (OptimisticLockingFailureException e) {
// 版本冲突,说明数据已被修改
log.warn("账户余额并发更新冲突,accountNo: {}", accountNo);
throw new ConcurrentUpdateException("请重试");
}
}
/**
* 方法2:使用条件更新(推荐)
*/
@Transactional
public boolean deductBalanceWithCondition(String accountNo, BigDecimal amount,
String requestId, BigDecimal expectBalance) {
// 先检查是否已处理过
if (isRequestProcessed(requestId)) {
return true; // 幂等返回
}
// 使用条件更新(CAS操作)
int rows = accountRepository.updateBalance(
accountNo,
amount,
expectBalance // 期望的余额(用于比较)
);
if (rows > 0) {
// 更新成功,记录请求ID
markRequestProcessed(requestId, accountNo, amount);
return true;
}
return false; // 更新失败(可能余额不足或数据已变更)
}
// SQL示例
@Modifying
@Query("UPDATE Account a SET a.balance = a.balance - :amount, a.version = a.version + 1 " +
"WHERE a.accountNo = :accountNo AND a.balance >= :amount")
int deductBalance(@Param("accountNo") String accountNo,
@Param("amount") BigDecimal amount);
}
3.4 状态机方案
java
/**
* 方案四:状态机幂等
* 适用场景:有明确状态流转的业务(订单、工单等)
*/
@Entity
@Table(name = "t_order")
public class Order {
@Id
private String orderId;
@Enumerated(EnumType.STRING)
private OrderStatus status;
// 订单其他字段...
/**
* 状态流转方法
* 保证幂等:只有特定状态才能转移到目标状态
*/
public void transitionTo(OrderStatus targetStatus) {
// 定义状态流转规则
Map<OrderStatus, List<OrderStatus>> transitionRules = new HashMap<>();
transitionRules.put(OrderStatus.CREATED,
Arrays.asList(OrderStatus.PAID, OrderStatus.CANCELLED));
transitionRules.put(OrderStatus.PAID,
Arrays.asList(OrderStatus.SHIPPED, OrderStatus.REFUNDING));
// ... 其他规则
// 检查当前状态是否可以转移到目标状态
List<OrderStatus> allowedTargets = transitionRules.get(this.status);
if (allowedTargets == null || !allowedTargets.contains(targetStatus)) {
throw new IllegalStateException(
String.format("状态流转不允许:%s -> %s", this.status, targetStatus)
);
}
// 执行状态转移
this.status = targetStatus;
}
}
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
/**
* 支付订单(幂等)
*/
@Transactional
public void payOrder(String orderId, String paymentNo) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
// 检查是否已支付
if (order.getStatus() == OrderStatus.PAID) {
log.info("订单已支付,幂等返回,orderId: {}, paymentNo: {}", orderId, paymentNo);
return;
}
// 状态流转(如果状态不是CREATED会抛异常)
order.transitionTo(OrderStatus.PAID);
// 更新其他信息
order.setPaymentNo(paymentNo);
order.setPayTime(LocalDateTime.now());
orderRepository.save(order);
// 后续业务处理...
}
/**
* 取消订单(幂等)
*/
@Transactional
public void cancelOrder(String orderId, String reason) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
// 幂等检查:已取消的订单直接返回
if (order.getStatus() == OrderStatus.CANCELLED) {
log.info("订单已取消,幂等返回,orderId: {}", orderId);
return;
}
// 检查是否允许取消(只有特定状态可以取消)
if (!canCancel(order.getStatus())) {
throw new BusinessException("当前状态不允许取消");
}
// 状态流转
order.transitionTo(OrderStatus.CANCELLED);
order.setCancelReason(reason);
order.setCancelTime(LocalDateTime.now());
orderRepository.save(order);
// 执行取消相关业务(退款、库存释放等)
}
private boolean canCancel(OrderStatus status) {
// 定义可以取消的状态
return Arrays.asList(
OrderStatus.CREATED,
OrderStatus.PAID,
OrderStatus.SHIPPING
).contains(status);
}
}
3.5 防重表方案
java
/**
* 方案五:防重表(通用方案)
* 适用场景:通用接口幂等,不依赖业务状态
*/
@Entity
@Table(name = "t_request_record",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"request_id", "biz_type"})
})
public class RequestRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "request_id", nullable = false)
private String requestId; // 请求唯一标识
@Column(name = "biz_type", nullable = false)
private String bizType; // 业务类型
@Column(name = "biz_id")
private String bizId; // 业务ID(如订单号)
@Enumerated(EnumType.STRING)
private RequestStatus status; // 处理状态
@Column(columnDefinition = "text")
private String requestParams; // 请求参数(用于校验)
private String result; // 处理结果
@Column(name = "create_time")
private LocalDateTime createTime;
@Column(name = "update_time")
private LocalDateTime updateTime;
}
@Aspect
@Component
@Slf4j
public class IdempotentAspect {
@Autowired
private RequestRecordRepository requestRecordRepository;
/**
* 幂等注解
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Idempotent {
String key() default ""; // 幂等键(支持SpEL)
long expire() default 3600; // 过期时间(秒)
String message() default "重复请求";
}
/**
* 幂等切面
*/
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 1. 获取方法参数
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Object[] args = joinPoint.getArgs();
// 2. 生成幂等键(支持SpEL表达式)
String requestId = generateRequestId(idempotent, method, args);
// 3. 检查是否已处理
Optional<RequestRecord> existingRecord =
requestRecordRepository.findByRequestIdAndBizType(
requestId,
method.getName()
);
if (existingRecord.isPresent()) {
// 已处理,直接返回之前的结果
RequestRecord record = existingRecord.get();
if (record.getStatus() == RequestStatus.SUCCESS) {
log.info("幂等返回,requestId: {}", requestId);
return JSON.parseObject(record.getResult(), method.getReturnType());
} else if (record.getStatus() == RequestStatus.PROCESSING) {
// 正在处理中,防止并发
throw new BusinessException("请求正在处理中,请勿重复提交");
}
// 失败状态可以重试
}
// 4. 创建处理记录
RequestRecord record = new RequestRecord();
record.setRequestId(requestId);
record.setBizType(method.getName());
record.setRequestParams(JSON.toJSONString(args));
record.setStatus(RequestStatus.PROCESSING);
record.setCreateTime(LocalDateTime.now());
try {
requestRecordRepository.save(record);
} catch (DataIntegrityViolationException e) {
// 并发插入,说明已有其他请求在处理
throw new BusinessException(idempotent.message());
}
// 5. 执行业务逻辑
try {
Object result = joinPoint.proceed();
// 6. 更新记录为成功
record.setStatus(RequestStatus.SUCCESS);
record.setResult(JSON.toJSONString(result));
record.setUpdateTime(LocalDateTime.now());
requestRecordRepository.save(record);
return result;
} catch (Exception e) {
// 7. 更新记录为失败
record.setStatus(RequestStatus.FAILED);
record.setResult(e.getMessage());
record.setUpdateTime(LocalDateTime.now());
requestRecordRepository.save(record);
throw e;
}
}
private String generateRequestId(Idempotent idempotent, Method method, Object[] args) {
if (!StringUtils.isEmpty(idempotent.key())) {
// 使用SpEL解析表达式
ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(idempotent.key());
EvaluationContext context = new StandardEvaluationContext();
// 设置参数
Parameter[] parameters = method.getParameters();
for (int i = 0; i < parameters.length; i++) {
context.setVariable(parameters[i].getName(), args[i]);
}
Object value = expression.getValue(context);
return String.valueOf(value);
}
// 默认生成方式:方法名 + 参数MD5
StringBuilder builder = new StringBuilder(method.getName());
for (Object arg : args) {
builder.append(JSON.toJSONString(arg));
}
return DigestUtils.md5DigestAsHex(builder.toString().getBytes());
}
}
// 使用示例
@RestController
@RequestMapping("/api/payment")
public class PaymentController {
@PostMapping("/pay")
@Idempotent(key = "#request.orderNo + '_' + #request.userId")
public ApiResponse<PaymentResult> pay(@RequestBody PaymentRequest request) {
// 业务逻辑...
return paymentService.process(request);
}
@PostMapping("/refund")
@Idempotent(key = "#refundNo", message = "退款申请已提交,请勿重复操作")
public ApiResponse<Void> refund(@RequestParam String refundNo) {
// 业务逻辑...
return ApiResponse.success();
}
}
3.6 分布式锁方案
java
/**
* 方案六:分布式锁(强一致性)
* 适用场景:并发控制,保证同一时间只有一个请求处理
*/
@Component
@Slf4j
public class DistributedLockIdempotent {
@Autowired
private RedissonClient redissonClient;
/**
* 分布式锁实现幂等
*/
public <T> T executeWithLock(String lockKey, String requestId,
Supplier<T> businessLogic, long expireSeconds) {
// 1. 尝试获取锁
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
// 尝试加锁,最多等待3秒,锁超时时间expireSeconds
locked = lock.tryLock(3, expireSeconds, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("系统繁忙,请稍后重试");
}
// 2. 检查是否已处理(Redis记录)
String processedKey = "idempotent:processed:" + requestId;
String processed = (String) redissonClient.getBucket(processedKey).get();
if ("1".equals(processed)) {
log.info("请求已处理,幂等返回,requestId: {}", requestId);
throw new IdempotentException("重复请求");
}
// 3. 执行业务逻辑
T result = businessLogic.get();
// 4. 标记已处理
redissonClient.getBucket(processedKey).set("1", expireSeconds, TimeUnit.SECONDS);
return result;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException("获取锁失败");
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 使用示例
*/
public void processOrder(String orderNo, BigDecimal amount) {
String lockKey = "order:pay:" + orderNo;
String requestId = generateRequestId(orderNo, amount);
executeWithLock(lockKey, requestId, () -> {
// 业务逻辑
orderService.pay(orderNo, amount);
return null;
}, 30); // 锁30秒
}
/**
* 基于Redis的轻量级幂等方案
*/
public boolean tryIdempotent(String key, long expireSeconds, Runnable task) {
// 使用SETNX实现(Redis单命令原子性)
String redisKey = "idempotent:" + key;
// SETNX + EXPIRE 原子操作
Boolean success = redissonClient.getBucket(redisKey).trySet("1", expireSeconds, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(success)) {
try {
task.run();
return true;
} catch (Exception e) {
// 失败时删除key,允许重试
redissonClient.getBucket(redisKey).delete();
throw e;
}
}
return false; // 已存在,幂等返回
}
}
四、复杂场景综合方案
4.1 消息队列幂等消费
java
/**
* 消息队列幂等消费方案
*/
@Component
@Slf4j
public class MQIdempotentConsumer {
@Autowired
private MessageRecordRepository messageRecordRepository;
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 方案1:消息ID去重
*/
@RabbitListener(queues = "order.create.queue")
@Transactional
public void handleOrderCreate(Message message, Channel channel) throws IOException {
String messageId = message.getMessageProperties().getMessageId();
// 1. 检查是否已消费
if (messageRecordRepository.existsByMessageId(messageId)) {
log.info("消息已消费,幂等丢弃,messageId: {}", messageId);
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
return;
}
// 2. 解析消息内容
OrderMessage orderMessage = JSON.parseObject(
new String(message.getBody()),
OrderMessage.class
);
// 3. 业务处理
try {
orderService.createOrderFromMQ(orderMessage);
// 4. 记录已消费
MessageRecord record = new MessageRecord();
record.setMessageId(messageId);
record.setBizId(orderMessage.getOrderNo());
record.setStatus(ConsumeStatus.SUCCESS);
record.setConsumeTime(LocalDateTime.now());
messageRecordRepository.save(record);
// 5. 确认消息
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e) {
log.error("消息消费失败", e);
// 记录失败
MessageRecord record = new MessageRecord();
record.setMessageId(messageId);
record.setStatus(ConsumeStatus.FAILED);
record.setErrorMsg(e.getMessage());
messageRecordRepository.save(record);
// 拒绝消息,重新入队
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
}
}
/**
* 方案2:Redis布隆过滤器(大数据量去重)
*/
public boolean isDuplicateWithBloomFilter(String messageId) {
// 使用Redisson的布隆过滤器
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("mq:bloomfilter");
// 初始化布隆过滤器
if (!bloomFilter.isExists()) {
bloomFilter.tryInit(1000000L, 0.01); // 100万数据,1%误判率
}
// 检查是否存在
if (bloomFilter.contains(messageId)) {
return true; // 可能存在(有1%误判)
}
// 添加并返回false
bloomFilter.add(messageId);
return false;
}
}
四、复杂场景综合方案
4.1 消息队列幂等消费
java
/**
* 消息队列幂等消费方案
*/
@Component
@Slf4j
public class MQIdempotentConsumer {
@Autowired
private MessageRecordRepository messageRecordRepository;
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 方案1:消息ID去重
*/
@RabbitListener(queues = "order.create.queue")
@Transactional
public void handleOrderCreate(Message message, Channel channel) throws IOException {
String messageId = message.getMessageProperties().getMessageId();
// 1. 检查是否已消费
if (messageRecordRepository.existsByMessageId(messageId)) {
log.info("消息已消费,幂等丢弃,messageId: {}", messageId);
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
return;
}
// 2. 解析消息内容
OrderMessage orderMessage = JSON.parseObject(
new String(message.getBody()),
OrderMessage.class
);
// 3. 业务处理
try {
orderService.createOrderFromMQ(orderMessage);
// 4. 记录已消费
MessageRecord record = new MessageRecord();
record.setMessageId(messageId);
record.setBizId(orderMessage.getOrderNo());
record.setStatus(ConsumeStatus.SUCCESS);
record.setConsumeTime(LocalDateTime.now());
messageRecordRepository.save(record);
// 5. 确认消息
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e) {
log.error("消息消费失败", e);
// 记录失败
MessageRecord record = new MessageRecord();
record.setMessageId(messageId);
record.setStatus(ConsumeStatus.FAILED);
record.setErrorMsg(e.getMessage());
messageRecordRepository.save(record);
// 拒绝消息,重新入队
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
}
}
/**
* 方案2:Redis布隆过滤器(大数据量去重)
*/
public boolean isDuplicateWithBloomFilter(String messageId) {
// 使用Redisson的布隆过滤器
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("mq:bloomfilter");
// 初始化布隆过滤器
if (!bloomFilter.isExists()) {
bloomFilter.tryInit(1000000L, 0.01); // 100万数据,1%误判率
}
// 检查是否存在
if (bloomFilter.contains(messageId)) {
return true; // 可能存在(有1%误判)
}
// 添加并返回false
bloomFilter.add(messageId);
return false;
}
}
4.2 分布式系统调用幂等
java
/**
* 微服务间调用幂等方案
*/
@FeignClient(name = "inventory-service")
public interface InventoryFeignClient {
/**
* 扣减库存(幂等接口)
* @param requestId 请求唯一ID
*/
@PostMapping("/inventory/deduct")
ApiResponse<Void> deductStock(@RequestBody StockDeductRequest request);
}
@Service
public class InventoryService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 扣减库存实现
*/
@PostMapping("/deduct")
@Transactional
public ApiResponse<Void> deductStock(@RequestBody StockDeductRequest request) {
// 1. 幂等检查
String requestKey = String.format("deduct:%s:%s",
request.getRequestId(), request.getSkuCode());
// 使用Redis原子操作
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(requestKey, "processing", 5, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(success)) {
// 已处理或正在处理
String status = redisTemplate.opsForValue().get(requestKey);
if ("success".equals(status)) {
return ApiResponse.success("已处理");
}
return ApiResponse.error("处理中,请勿重复请求");
}
try {
// 2. 业务逻辑
int rows = inventoryMapper.deductStock(
request.getSkuCode(),
request.getQuantity()
);
if (rows == 0) {
throw new BusinessException("库存不足");
}
// 3. 标记成功
redisTemplate.opsForValue().set(requestKey, "success", 1, TimeUnit.HOURS);
return ApiResponse.success();
} catch (Exception e) {
// 4. 失败时删除key,允许重试
redisTemplate.delete(requestKey);
throw e;
}
}
/**
* 基于数据库的分布式锁方案
*/
@Transactional
public ApiResponse<Void> deductStockWithDBLock(@RequestBody StockDeductRequest request) {
// 1. 获取分布式锁(数据库行锁)
LockRecord lock = lockRepository.findByLockKeyForUpdate(request.getRequestId());
if (lock != null && lock.getStatus() == LockStatus.PROCESSED) {
return ApiResponse.success("已处理");
}
// 2. 创建或更新锁记录
if (lock == null) {
lock = new LockRecord();
lock.setLockKey(request.getRequestId());
lock.setStatus(LockStatus.PROCESSING);
lock.setCreateTime(LocalDateTime.now());
lockRepository.save(lock);
}
try {
// 3. 执行业务逻辑
inventoryMapper.deductStock(request.getSkuCode(), request.getQuantity());
// 4. 更新锁状态
lock.setStatus(LockStatus.PROCESSED);
lock.setUpdateTime(LocalDateTime.now());
lockRepository.save(lock);
return ApiResponse.success();
} catch (Exception e) {
// 5. 失败时删除锁记录
lockRepository.delete(lock);
throw e;
}
}
}
4.3 前端重复提交防护
javascript
/**
* 前端幂等性防护方案
*/
// 方案1:按钮防重复点击
class SubmitButton {
constructor(button, options = {}) {
this.button = button;
this.delay = options.delay || 3000; // 禁用3秒
this.originalText = button.innerHTML;
this.isSubmitting = false;
this.init();
}
init() {
this.button.addEventListener('click', (e) => {
if (this.isSubmitting) {
e.preventDefault();
e.stopPropagation();
return;
}
this.disable();
this.isSubmitting = true;
// 自动恢复
setTimeout(() => {
this.enable();
}, this.delay);
});
}
disable() {
this.button.disabled = true;
this.button.innerHTML = '提交中...';
this.button.classList.add('disabled');
}
enable() {
this.button.disabled = false;
this.button.innerHTML = this.originalText;
this.button.classList.remove('disabled');
this.isSubmitting = false;
}
}
// 方案2:请求拦截器
const pendingRequests = new Map();
axios.interceptors.request.use(config => {
// 生成请求唯一标识
const requestKey = `${config.method}-${config.url}-${JSON.stringify(config.params)}-${JSON.stringify(config.data)}`;
// 检查是否已有相同请求在pending
if (pendingRequests.has(requestKey)) {
// 取消当前请求
config.cancelToken = new axios.CancelToken(cancel => {
cancel('重复请求,自动取消');
});
} else {
// 添加到pending列表
pendingRequests.set(requestKey, config);
// 请求完成后移除
config.complete = () => {
pendingRequests.delete(requestKey);
};
}
// 添加时间戳防止缓存
if (config.method === 'get') {
config.params = {
...config.params,
_t: Date.now()
};
}
return config;
});
// 方案3:Token机制(配合后端)
class TokenManager {
constructor() {
this.token = null;
this.tokenExpire = 0;
}
async getToken() {
// 检查token是否有效
if (this.token && Date.now() < this.tokenExpire) {
return this.token;
}
// 请求新token
const response = await axios.get('/api/token');
this.token = response.data.token;
this.tokenExpire = Date.now() + 5 * 60 * 1000; // 5分钟有效期
return this.token;
}
async requestWithToken(url, data) {
const token = await this.getToken();
try {
const response = await axios.post(url, data, {
headers: {
'X-Request-Token': token
}
});
// 成功消费token
this.token = null;
return response;
} catch (error) {
if (error.response?.status === 409) { // 409 Conflict
// Token重复,获取新token重试
this.token = null;
return this.requestWithToken(url, data);
}
throw error;
}
}
}
五、最佳实践总结
5.1 方案选择建议
java
public class IdempotentStrategySelector {
/**
* 根据场景选择合适的幂等方案
*/
public IdempotentStrategy selectStrategy(BusinessScenario scenario) {
switch (scenario) {
case FORM_SUBMIT:
// 表单提交:Token机制 + 前端防重复
return IdempotentStrategy.TOKEN;
case ORDER_CREATE:
// 订单创建:唯一索引 + 状态机
return IdempotentStrategy.UNIQUE_INDEX;
case INVENTORY_DEDUCT:
// 库存扣减:乐观锁 + 防重表
return IdempotentStrategy.OPTIMISTIC_LOCK;
case PAYMENT_PROCESS:
// 支付处理:分布式锁 + 防重表
return IdempotentStrategy.DISTRIBUTED_LOCK;
case MQ_CONSUME:
// 消息消费:消息ID去重 + 布隆过滤器
return IdempotentStrategy.MESSAGE_ID;
case BATCH_PROCESS:
// 批量处理:请求ID记录 + 幂等表
return IdempotentStrategy.REQUEST_RECORD;
default:
// 默认:防重表(最通用)
return IdempotentStrategy.REQUEST_RECORD;
}
}
}
5.2 设计原则
java
public class IdempotentDesignPrinciples {
/**
* 幂等性设计原则
*/
// 原则1:识别幂等操作
// - GET、PUT、DELETE天然幂等
// - POST需要特殊处理
// 原则2:设计唯一标识
// - 业务唯一键(订单号、支付流水号)
// - 请求唯一ID(UUID、时间戳+随机数)
// 原则3:实现幂等校验
// - 前置校验:检查是否已处理
// - 后置记录:处理成功后标记
// 原则4:保证原子性
// - 数据库操作:事务 + 唯一约束
// - Redis操作:SETNX + EXPIRE
// - 分布式锁:Redisson、Zookeeper
// 原则5:考虑并发场景
// - 乐观锁解决并发更新
// - 分布式锁解决并发创建
// 原则6:设计重试机制
// - 可重试的失败(网络超时)
// - 不可重试的失败(参数错误)
// 原则7:监控与告警
// - 监控幂等失败率
// - 告警异常重复请求
}
5.3 监控与告警
java
@Component
@Slf4j
public class IdempotentMonitor {
@Autowired
private MeterRegistry meterRegistry;
private final Counter duplicateRequestCounter;
private final Timer idempotentProcessingTimer;
public IdempotentMonitor() {
// 注册监控指标
duplicateRequestCounter = Counter.builder("idempotent.duplicate.requests")
.description("重复请求数量")
.register(meterRegistry);
idempotentProcessingTimer = Timer.builder("idempotent.processing.time")
.description("幂等处理耗时")
.register(meterRegistry);
}
/**
* 记录幂等处理
*/
public void recordIdempotentProcess(String requestId, long duration, boolean isDuplicate) {
if (isDuplicate) {
duplicateRequestCounter.increment();
log.info("幂等请求拦截,requestId: {}", requestId);
// 告警(高频重复请求)
if (duplicateRequestCounter.count() > 1000) { // 阈值
alertService.sendAlert("高频重复请求告警");
}
}
idempotentProcessingTimer.record(duration, TimeUnit.MILLISECONDS);
}
/**
* 生成幂等性报告
*/
public IdempotentReport generateReport() {
IdempotentReport report = new IdempotentReport();
// 统计重复请求率
double duplicateRate = calculateDuplicateRate();
report.setDuplicateRate(duplicateRate);
// 统计平均处理时间
double avgTime = idempotentProcessingTimer.mean(TimeUnit.MILLISECONDS);
report.setAvgProcessingTime(avgTime);
// 异常情况统计
report.setExceptionCount(getExceptionCount());
return report;
}
}
六、常见问题与解决方案
6.1 幂等与并发控制的区别
java
public class IdempotentVsConcurrent {
/**
* 幂等性:关注多次调用结果一致性
* 并发控制:关注同时调用时的正确性
*
* 两者关系:
* 1. 幂等性解决重试问题
* 2. 并发控制解决同时执行问题
* 3. 有些方案可以同时解决两者(如分布式锁)
*/
// 场景分析
public void updateUserBalance(String userId, BigDecimal amount) {
// 问题1:多次调用(幂等问题)
// 用户重复点击提交按钮 → 需要幂等性保证
// 问题2:并发调用(并发问题)
// 两个请求同时处理同一个用户 → 需要并发控制
// 解决方案:分布式锁 + 幂等token
String lockKey = "balance:update:" + userId;
String requestId = generateRequestId(userId, amount);
distributedLock.lock(lockKey, () -> {
// 并发控制:保证同时只有一个请求进入
if (isRequestProcessed(requestId)) {
return; // 幂等返回
}
// 执行业务逻辑
updateBalance(userId, amount);
// 标记已处理
markRequestProcessed(requestId);
});
}
}
6.2 幂等key的设计
java
public class IdempotentKeyDesign {
/**
* 幂等Key设计原则
*/
// 1. 唯一性:全局唯一
public String generateUniqueKey() {
// UUID
return UUID.randomUUID().toString();
// 时间戳 + 随机数
return System.currentTimeMillis() + "_" + RandomUtils.nextInt(1000);
// 雪花算法
return SnowflakeIdGenerator.nextId();
}
// 2. 业务语义:包含业务信息
public String generateBusinessKey(String orderNo, String userId, String action) {
return String.format("%s:%s:%s", orderNo, userId, action);
}
// 3. 可读性:便于调试
public String generateReadableKey(String prefix, String... parts) {
return prefix + ":" + String.join("_", parts);
}
// 4. 长度控制:避免过长
public String generateShortKey(String input) {
// 使用Hash缩短长度
return DigestUtils.md5DigestAsHex(input.getBytes());
}
// 5. 分区友好:便于分库分表
public String generateShardingKey(String userId, String bizType) {
// 基于用户ID分片
int shard = Math.abs(userId.hashCode()) % 1024;
return String.format("%04d_%s_%s", shard, bizType, userId);
}
}
总结
保证接口幂等性是分布式系统设计的重要环节。选择方案时需要综合考虑:
-
业务场景:创建、更新、删除等不同操作
-
性能要求:高并发下的性能影响
-
实现复杂度:开发维护成本
-
一致性要求:强一致还是最终一致
-
技术栈限制:现有技术架构支持
推荐实践:
-
简单场景:使用数据库唯一索引
-
前端交互:使用Token机制
-
更新操作:使用乐观锁
-
并发控制:使用分布式锁
-
通用方案:使用防重表+切面
-
消息消费:使用消息ID去重