一、先搞懂最基础的问题:什么是消息重试、接口幂等性,为啥会失效?
很多开发者刚做后端接口时,都会遇到过一个头疼的情况:用户点了一次付款按钮,结果银行卡扣了两次钱,订单也生成了两次。追根溯源,大多是消息重试和接口幂等性没处理好导致的。 先把两个核心概念拆成大白话讲清楚,别搞晦涩的定义。消息重试,说白了就是系统怕自己发的消息对方没收到,或者发的请求对方没处理完,就再发一遍。比如用户点付款后,系统发了个请求给支付服务,网络卡了没收到响应,系统就会自动再发一次,这就是重试。接口幂等性,通俗讲就是同一个请求发1次和发10次,最终的结果完全一样,不会多做一次操作。 那为啥重试会让幂等性失效?举个真实的场景:用户点付款后,系统发了第一次请求,支付服务已经扣了钱、生成了订单,但响应结果因为网络延迟没传回来。发起请求的系统以为没成功,就自动重试发了第二次请求,支付服务又扣了一次钱、生成了第二个订单——这就是幂等性失效了,核心原因就是系统没法判断同一个请求是不是已经处理过了。
二、先试试简单的方案:用去重表做基础幂等处理
很多人第一反应的幂等处理方案,就是搞一张专门的去重表,用来记录已经处理过的请求,避免重复处理。这个方案逻辑简单,大部分场景都能用,我们先把它的原理、实现、优缺点讲清楚。
2.1 去重表的核心原理
去重表的本质就是一张数据库表,用来存每一个请求的唯一标识(叫请求ID)、请求的状态(比如待处理、已处理、处理失败)、处理时间这些信息。每次处理请求前,先去这张表里查有没有这个请求ID,如果有就说明已经处理过了,直接返回之前的结果;如果没有,就先把请求ID插进去,再处理业务逻辑,处理完更新状态。 举个最常见的例子,用MySQL做去重表,先建表:
-- 去重表,记录已处理的请求信息
CREATE TABLE `idempotent_duplicate` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`request_id` varchar(64) NOT NULL COMMENT '请求唯一标识,必须全局唯一',
`request_type` varchar(32) NOT NULL COMMENT '请求类型,比如pay(付款)、refund(退款)',
`status` tinyint NOT NULL COMMENT '请求状态:0=待处理,1=已处理,2=处理失败',
`result` varchar(255) DEFAULT NULL COMMENT '处理结果,比如成功的订单号、失败的错误信息',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_request_id` (`request_id`) -- 给请求ID加唯一索引,避免重复插入
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接口幂等去重表';
这个表的核心是request_id的唯一索引,它能保证同一个请求ID只能插一次,从数据库层面避免了重复记录。
2.2 用Java实现去重表的幂等逻辑
我们用Spring Boot + MyBatis Plus的技术栈来写示例,先明确技术栈:Java 17、Spring Boot 3.2、MyBatis Plus 3.5.5、MySQL 8.0。 首先写去重表的实体类:
import com.baomidou.mybatisplus.annotation.*;
import java.time.LocalDateTime;
@TableName("idempotent_duplicate")
public class IdempotentDuplicate {
@TableId(type = IdType.AUTO)
private Long id;
private String requestId;
private String requestType;
private Integer status;
private String result;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
// 省略getter和setter
}
然后写业务逻辑,以付款接口为例:
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PayService extends ServiceImpl<IdempotentDuplicateMapper, IdempotentDuplicate> {
/**
* 处理付款请求
* @param requestId 请求唯一标识,由调用方生成并传入,必须全局唯一
* @param orderId 订单ID
* @param amount 付款金额
* @return 付款结果
*/
@Transactional(rollbackFor = Exception.class)
public String processPay(String requestId, String orderId, BigDecimal amount) {
// 第一步:先查去重表,判断请求是否已处理
IdempotentDuplicate duplicate = lambdaQuery()
.eq(IdempotentDuplicate::getRequestId, requestId)
.one();
// 如果已处理,直接返回之前的结果
if (duplicate != null && duplicate.getStatus() == 1) {
return duplicate.getResult();
}
// 如果是待处理或处理失败,先插入去重表(如果不存在)
if (duplicate == null) {
duplicate = new IdempotentDuplicate();
duplicate.setRequestId(requestId);
duplicate.setRequestType("pay");
duplicate.setStatus(0); // 标记为待处理
save(duplicate);
} else {
// 如果之前处理失败,更新为待处理,准备重新处理
lambdaUpdate()
.set(IdempotentDuplicate::getStatus, 0)
.eq(IdempotentDuplicate::getRequestId, requestId)
.update();
}
try {
// 第二步:处理业务逻辑,比如扣钱、生成订单
String payResult = doPay(orderId, amount);
// 第三步:更新去重表状态为已处理,记录结果
lambdaUpdate()
.set(IdempotentDuplicate::getStatus, 1)
.set(IdempotentDuplicate::getResult, payResult)
.eq(IdempotentDuplicate::getRequestId, requestId)
.update();
return payResult;
} catch (Exception e) {
// 第四步:业务处理失败,更新状态为处理失败
lambdaUpdate()
.set(IdempotentDuplicate::getStatus, 2)
.set(IdempotentDuplicate::getResult, e.getMessage())
.eq(IdempotentDuplicate::getRequestId, requestId)
.update();
throw e;
}
}
// 模拟业务处理:扣钱、生成订单
private String doPay(String orderId, BigDecimal amount) {
// 实际业务中这里会调用支付服务、生成订单等
return "付款成功,订单号:" + orderId;
}
}
2.3 去重表方案的优缺点和注意事项
优点很明显:逻辑简单,容易理解和实现,大部分单机或小集群场景都能覆盖,数据库的唯一索引能从底层保证数据不重复。 缺点也很突出:如果是分布式场景,多个服务实例共用一张去重表,会出现数据库的性能瓶颈,比如高并发下的锁等待、连接耗尽;另外,如果业务系统的数据库挂了,去重表也就没法用了,幂等处理会失效。 注意事项:第一,请求ID必须全局唯一,不能重复,比如可以用UUID加服务实例标识生成;第二,去重表的请求ID必须加唯一索引,这是核心;第三,要及时清理过期的去重数据,比如超过30天的请求记录可以删除,避免表太大影响性能。
三、分布式场景的升级方案:去重表加分布式锁
如果你的系统是分布式的,有多个服务实例同时处理请求,只用去重表可能会出问题。比如两个服务实例同时收到同一个请求,同时去查去重表,都没查到,然后同时插入去重表,最后只有一个能插入成功,另一个会报错,但这种场景下可能会有额外的性能损耗,甚至出现重复处理的极端情况。这时候就需要给去重表加分布式锁,进一步保证幂等性。
3.1 分布式锁的核心作用
分布式锁的作用,就是在分布式场景下,保证同一个请求同一时间只能被一个服务实例处理。简单说,就是给每个请求加一把锁,谁拿到锁谁才能处理,没拿到锁的就等或者直接返回,避免多个实例同时处理同一个请求。 常用的分布式锁有Redis分布式锁、ZooKeeper分布式锁,这里我们用Redis分布式锁来做示例,还是基于之前的Java技术栈。
3.2 结合Redis分布式锁的幂等实现
先明确Redis分布式锁的核心逻辑:加锁时用set key value ex 过期时间 nx的命令,这个命令的意思是如果key不存在就加锁,同时设置过期时间,避免死锁;解锁时要判断锁是不是自己的,避免误删别人的锁。
我们先写一个简单的Redis分布式锁工具类:
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Component
public class RedisLockUtil {
private final StringRedisTemplate stringRedisTemplate;
// 锁的前缀,避免和其他key冲突
private static final String LOCK_PREFIX = "idempotent:lock:";
// 锁的过期时间,单位:秒,根据业务处理时间调整
private static final int LOCK_EXPIRE_TIME = 30;
public RedisLockUtil(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 加锁
* @param requestId 请求ID,作为锁的key
* @return 锁的value,解锁时需要用到
*/
public String lock(String requestId) {
String lockKey = LOCK_PREFIX + requestId;
// 生成唯一的value,用来判断锁是不是自己的
String lockValue = UUID.randomUUID().toString();
// 加锁:nx表示key不存在才设置,ex表示过期时间
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, LOCK_EXPIRE_TIME, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success) ? lockValue : null;
}
/**
* 解锁
* @param requestId 请求ID
* @param lockValue 加锁时返回的value
*/
public void unlock(String requestId, String lockValue) {
String lockKey = LOCK_PREFIX + requestId;
// 先判断锁是不是自己的,再删除,避免误删
String currentValue = stringRedisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentValue)) {
stringRedisTemplate.delete(lockKey);
}
}
}
然后修改之前的PayService,加入分布式锁的逻辑:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PayService extends ServiceImpl<IdempotentDuplicateMapper, IdempotentDuplicate> {
private final RedisLockUtil redisLockUtil;
// 构造注入Redis锁工具
public PayService(RedisLockUtil redisLockUtil) {
this.redisLockUtil = redisLockUtil;
}
@Transactional(rollbackFor = Exception.class)
public String processPay(String requestId, String orderId, BigDecimal amount) {
// 第一步:先加分布式锁,保证同一时间只有一个实例处理这个请求
String lockValue = redisLockUtil.lock(requestId);
// 如果加锁失败,说明已经有实例在处理,直接返回
if (lockValue == null) {
// 这里可以等一会再重试,或者直接返回已处理
return "请求正在处理中,请稍后再试";
}
try {
// 第二步:查去重表,判断请求是否已处理
IdempotentDuplicate duplicate = lambdaQuery()
.eq(IdempotentDuplicate::getRequestId, requestId)
.one();
if (duplicate != null && duplicate.getStatus() == 1) {
return duplicate.getResult();
}
// 第三步:插入或更新去重表
if (duplicate == null) {
duplicate = new IdempotentDuplicate();
duplicate.setRequestId(requestId);
duplicate.setRequestType("pay");
duplicate.setStatus(0);
save(duplicate);
} else {
lambdaUpdate()
.set(IdempotentDuplicate::getStatus, 0)
.eq(IdempotentDuplicate::getRequestId, requestId)
.update();
}
// 第四步:处理业务逻辑
String payResult = doPay(orderId, amount);
// 第五步:更新去重表状态
lambdaUpdate()
.set(IdempotentDuplicate::getStatus, 1)
.set(IdempotentDuplicate::getResult, payResult)
.eq(IdempotentDuplicate::getRequestId, requestId)
.update();
return payResult;
} catch (Exception e) {
// 第六步:业务失败,更新状态
lambdaUpdate()
.set(IdempotentDuplicate::getStatus, 2)
.set(IdempotentDuplicate::getResult, e.getMessage())
.eq(IdempotentDuplicate::getRequestId, requestId)
.update();
throw e;
} finally {
// 第七步:解锁,不管成功失败都要解锁
redisLockUtil.unlock(requestId, lockValue);
}
}
private String doPay(String orderId, BigDecimal amount) {
return "付款成功,订单号:" + orderId;
}
}
3.3 分布式锁加去重表方案的优缺点和注意事项
优点:解决了分布式场景下多个实例同时处理同一个请求的问题,比单纯的去重表更可靠,能覆盖大部分高并发分布式场景;另外,分布式锁的加锁、解锁逻辑简单,容易实现。 缺点:依赖Redis的可用性,如果Redis挂了,分布式锁就没法用了;另外,锁的过期时间不好设置,设置太短可能业务还没处理完锁就过期了,设置太长会导致其他请求长时间等待;还有,加锁解锁会增加系统的性能开销。 注意事项:第一,锁的过期时间一定要大于业务处理的最大时间,避免业务没处理完锁就释放;第二,解锁时一定要判断锁的value是不是自己的,避免误删;第三,Redis要做高可用,比如用哨兵模式或集群模式,避免单点故障。
四、最难处理的场景:超时重试的幂等兼容
前面的方案都是处理正常的重试场景,但有一种场景最头疼:请求超时了,调用方重试,这时候怎么保证幂等?比如调用方发了个请求,等了5秒没收到响应,就认为超时了,然后重试发了第二次请求,但实际上第一次请求已经在处理了,甚至已经处理完了,只是响应没传回来。这种场景下,怎么避免重复处理?
4.1 超时重试的核心问题
超时重试的核心问题是:调用方不知道第一次请求的处理状态,服务端也不知道调用方会不会重试,所以需要在服务端做更细致的状态判断,还要给调用方明确的响应规则。 我们分两种情况来处理:一种是调用方已经处理完业务,还没更新去重表状态的超时;另一种是调用方还没开始处理业务的超时。
4.2 优化去重表的状态判断逻辑
首先,我们可以给去重表加一个“超时时间”的字段,用来标记请求的处理是否超时。比如业务处理的最大时间是10秒,那如果请求的状态是待处理,且创建时间超过10秒,就认为是超时了,可以重新处理。 修改去重表的建表语句:
-- 去重表,增加超时时间字段
CREATE TABLE `idempotent_duplicate` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`request_id` varchar(64) NOT NULL COMMENT '请求唯一标识,必须全局唯一',
`request_type` varchar(32) NOT NULL COMMENT '请求类型,比如pay(付款)、refund(退款)',
`status` tinyint NOT NULL COMMENT '请求状态:0=待处理,1=已处理,2=处理失败',
`result` varchar(255) DEFAULT NULL COMMENT '处理结果,比如成功的订单号、失败的错误信息',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`timeout_time` datetime NOT NULL COMMENT '请求超时时间,比如创建时间加10秒',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_request_id` (`request_id`) -- 给请求ID加唯一索引,避免重复插入
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接口幂等去重表';
然后修改业务逻辑,处理超时的情况:
import java.time.LocalDateTime;
@Transactional(rollbackFor = Exception.class)
public String processPay(String requestId, String orderId, BigDecimal amount) {
String lockValue = redisLockUtil.lock(requestId);
if (lockValue == null) {
return "请求正在处理中,请稍后再试";
}
try {
IdempotentDuplicate duplicate = lambdaQuery()
.eq(IdempotentDuplicate::getRequestId, requestId)
.one();
if (duplicate != null) {
// 如果已处理,直接返回结果
if (duplicate.getStatus() == 1) {
return duplicate.getResult();
}
// 如果状态是待处理,判断是否超时
if (duplicate.getStatus() == 0) {
LocalDateTime now = LocalDateTime.now();
// 如果超时,就重新处理
if (now.isAfter(duplicate.getTimeoutTime())) {
// 重置状态为待处理
lambdaUpdate()
.set(IdempotentDuplicate::getStatus, 0)
.eq(IdempotentDuplicate::getRequestId, requestId)
.update();
} else {
// 没超时,说明正在处理,返回处理中
return "请求正在处理中,请稍后再试";
}
}
// 如果是处理失败,重新处理
if (duplicate.getStatus() == 2) {
lambdaUpdate()
.set(IdempotentDuplicate::getStatus, 0)
.eq(IdempotentDuplicate::getRequestId, requestId)
.update();
}
} else {
// 第一次请求,插入去重表,设置超时时间为当前时间加10秒
duplicate = new IdempotentDuplicate();
duplicate.setRequestId(requestId);
duplicate.setRequestType("pay");
duplicate.setStatus(0);
duplicate.setTimeoutTime(LocalDateTime.now().plusSeconds(10));
save(duplicate);
}
// 处理业务逻辑
String payResult = doPay(orderId, amount);
// 更新状态为已处理
lambdaUpdate()
.set(IdempotentDuplicate::getStatus, 1)
.set(IdempotentDuplicate::getResult, payResult)
.eq(IdempotentDuplicate::getRequestId, requestId)
.update();
return payResult;
} catch (Exception e) {
lambdaUpdate()
.set(IdempotentDuplicate::getStatus, 2)
.set(IdempotentDuplicate::getResult, e.getMessage())
.eq(IdempotentDuplicate::getRequestId, requestId)
.update();
throw e;
} finally {
redisLockUtil.unlock(requestId, lockValue);
}
}
4.3 调用方的超时重试规则优化
除了服务端的处理,调用方也要做相应的优化,避免无意义的重试。比如调用方可以设置重试的最大次数,比如最多重试3次,每次重试的间隔时间递增,比如第一次等5秒,第二次等10秒,第三次等15秒;另外,调用方要根据服务端的响应来判断是否需要重试,比如如果服务端返回“请求正在处理中”,就不要重试,等一会再问。 比如用Java的RestTemplate做调用方的重试:
import org.springframework.web.client.RestTemplate;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Component;
@Component
public class PayCaller {
private final RestTemplate restTemplate;
public PayCaller(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
/**
* 调用付款接口,最多重试3次,每次间隔递增
* @param requestId 请求ID
* @param orderId 订单ID
* @param amount 金额
* @return 结果
*/
@Retryable(
value = {RuntimeException.class}, // 遇到RuntimeException才重试
maxAttempts = 3, // 最多重试3次(包括第一次)
backoff = @Backoff(delay = 5000, multiplier = 2) // 第一次间隔5秒,第二次10秒,第三次20秒
)
public String callPay(String requestId, String orderId, BigDecimal amount) {
String url = "http://pay-service/processPay?requestId={requestId}&orderId={orderId}&amount={amount}";
return restTemplate.getForObject(url, String.class, requestId, orderId, amount);
}
}
4.4 超时重试场景的优缺点和注意事项
优点:解决了超时重试导致的幂等性失效问题,能覆盖大部分极端场景;调用方和服务端配合,能减少无意义的重试,提升系统的稳定性。 缺点:增加了系统的复杂度,需要同时优化服务端和调用方的逻辑;超时时间的设置需要根据业务场景调整,设置不好会导致重复处理或长时间等待。 注意事项:第一,服务端的超时时间一定要大于业务处理的最大时间;第二,调用方的重试次数和间隔时间要合理,避免给服务端造成压力;第三,要给调用方明确的响应规则,比如返回“处理中”就不要重试。
五、应用场景、优缺点总结和注意事项汇总
5.1 应用场景
这套方案能覆盖大部分需要幂等处理的场景,比如:
- 支付场景:付款、退款、转账等,避免重复扣钱、重复退款;
- 订单场景:创建订单、修改订单、取消订单等,避免重复创建订单、重复修改订单;
- 消息场景:MQ消息的消费、通知消息的发送等,避免重复消费、重复发送通知;
- 接口调用场景:第三方接口的调用、内部服务的调用等,避免重复调用。
5.2 方案优缺点汇总
优点:
- 逻辑清晰,从基础到分布式再到超时场景,层层递进,覆盖全面;
- 基于成熟的技术栈,容易实现和维护;
- 能保证高并发、分布式场景下的幂等性,避免重复执行;
- 调用方和服务端配合,能减少无意义的重试,提升系统的稳定性。 缺点:
- 依赖数据库和Redis的可用性,单点故障会影响幂等处理;
- 加锁、去重表的操作会增加系统的性能开销;
- 超时时间、锁的过期时间等参数的设置需要根据业务场景调整,调试成本高。
5.3 注意事项汇总
- 请求ID必须全局唯一,不能重复,建议用UUID加服务实例标识生成;
- 去重表的请求ID必须加唯一索引,这是核心;
- 分布式锁的过期时间必须大于业务处理的最大时间,解锁时必须判断锁的value;
- 服务端的超时时间必须大于业务处理的最大时间;
- 调用方的重试次数和间隔时间要合理,避免给服务端造成压力;
- 要及时清理过期的去重数据,避免表太大影响性能;
- 数据库和Redis要做高可用,避免单点故障。
Comments