以下内容分成 接口幂等、服务间幂等、MQ消息幂等 三个部分
1. 接口幂等
提供给前端使用的接口,如何在前端重复调用的情况下保证幂等
1.1 前端防重
例如表单重复提交、按钮多次重复点击等,不可靠
1.2 PRG模式
POST-REDIRECT-GET,提交表单会重定向到另一个提交成功的页面,是较为常见的一种前端防重策略
1.3 token模式
需要前后端配合来完成,主要步骤就是前端要先向服务器获取一个token,然后再携带token进行请求,token的生成、删除、判断由后端完成。
问题: 在高并发情况下,会存在token还未删除完成,第二次请求又过来的情况
解决方案:
-
加锁;
-
使用 redis incr,在第2步生成token时,初始值为1,第5步时判断token如果存在,不用执行删除操作,只需要再执行一次 incr 操作,如果返回2,则代表该token是第一次使用,如果返回大于2的值,则代表token已经使用;
-
先删token,再执行业务,如果业务操作执行失败,前端再重头来一次即可。
使用自定义注解的方式来实现token的判断
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
}
public class IdempotentInterceptor implements HandlerInterceptor {
@Autowired
private RedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
Method method = handlerMethod.getMethod();
Idempotent annotation = method.getAnnotation(Idempotent.class);
if (annotation != null) {
checkToken(request);
}
return false;
}
private void checkToken(HttpServletRequest request) {
String token = request.getHeader("token");
if (token.isBlank()) {
throw new RuntimeException("非法请求");
}
Boolean delete = redisTemplate.delete(token);
if (Boolean.FALSE.equals(delete)) {
throw new RuntimeException("重复请求");
}
}
}
2. 服务幂等
微服务之间的相互调用,在重试机制下如何保证幂等
2.1 防重表
新建一张表,使用一个或多个字段作为唯一索引,操作业务表之前先向该表插入数据。
2.2 select + insert
适用并发不高的系统
先查询数据库(比如根据订单id查询),数据不存在则执行插入,存在则表示是重复请求。
2.3 MySql 乐观锁
以下两种都是基于 MySql 行锁原理
2.3.1 基于 version 字段
表中新增加 version 字段,操作成功 version + 1
UPDATE tb set version = version + 1 WHERE version = #{version}
2.3.2 基于条件
UPDATE tb set amount = amount - #{num} WHERE amount - #{num} >= 0
2.4 Zookeeper 分布式锁
2.4.1 实现原理
zookeeper 有如下几种节点类型
- 持久节点
- 持久有序节点
- 临时节点
- 临时有序节点
另外 zookeeper 也有 watch 事件监听机制,用于监听节点状态变更
先创建一个对应方法(比如下单操作)的父节点(持久节点),后续每个线程在该父节点下创建临时有序节点,线程在想要获取锁时,判断自己的节点序号是否是最小的,如果是,则获取锁,释放锁时,将自己的节点删除即可。
- zookeeper 是 CP 模式,强一致性
- 基于 watch 实现锁释放的自动监听,性能更好
- 需要频繁创建删除节点,吞吐量没 redis 高
2.4.2 实现流程
2.4.3 实现代码
抽象类
import org.I0Itec.zkclient.ZkClient;
public abstract class AbstractLock {
/**
* Zookeeper地址
*/
public static final String ZK_SERVER_ADDR = "";
/**
* 连接超时时间
*/
public static final int CONNECTION_TIME_OUT = 3000;
/**
* session超时时间
*/
public static final int SESSION_TIME_OUT = 3000;
protected ZkClient zkClient = new ZkClient(ZK_SERVER_ADDR, SESSION_TIME_OUT, CONNECTION_TIME_OUT);
public abstract boolean tryLock();
public abstract void waitLock();
public abstract void releaseLock();
public void getLock() {
String threadName = Thread.currentThread().getName();
if (tryLock()) {
System.out.println(threadName + " - 获取锁成功");
return;
}
System.out.println(threadName + " - 获取锁失败,等待再次获取锁");
// 自旋等待并再尝试获取锁
waitLock();
getLock();
}
}
实现类
import org.I0Itec.zkclient.IZkDataListener;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public class ZookeeperLock extends AbstractLock {
/**
* 父节点名称
*/
private static final String PARENT_NODE_PATH = "ORDER_LOCK";
/**
* 当前节点名称
*/
private String currentNodePath;
/**
* 前一个节点名称
*/
private String preNodePath;
private CountDownLatch countDownLatch;
@Override
public boolean tryLock() {
try {
// 判断父节点是否存在
if (!zkClient.exists(PARENT_NODE_PATH)) {
// 创建父节点
zkClient.createPersistent(PARENT_NODE_PATH);
}
} catch (Exception e) {
}
if (currentNodePath.isBlank()) {
// 创建临时节点
currentNodePath = zkClient.createEphemeralSequential(PARENT_NODE_PATH + "/", "lock");
}
// 获取父节点下所有的子节点
List<String> children = zkClient.getChildren(PARENT_NODE_PATH);
// 对子节点进行排序
Collections.sort(children);
if (currentNodePath.equals(PARENT_NODE_PATH + "/" + children.get(0))) {
// 当前节点是序号最小的一个节点
return true;
}
// 获取上一个节点
int length = PARENT_NODE_PATH.length();
int currentNodeNum = Collections.binarySearch(children, currentNodePath.substring(length + 1));
preNodePath = PARENT_NODE_PATH + "/" + children.get(currentNodeNum - 1);
return false;
}
@Override
public void waitLock() {
IZkDataListener iZkDataListener = new IZkDataListener() {
@Override
public void handleDataChange(String s, Object o) throws Exception {
}
@Override
public void handleDataDeleted(String s) throws Exception {
// 监听删除节点
if (countDownLatch != null) {
countDownLatch.countDown();
}
}
};
// 注册监听
zkClient.subscribeDataChanges(preNodePath, iZkDataListener);
if (zkClient.exists(preNodePath)) {
countDownLatch = new CountDownLatch(1);
try {
countDownLatch.await();
} catch (InterruptedException e) {
}
}
// 解除监听
zkClient.unsubscribeDataChanges(preNodePath, iZkDataListener);
}
@Override
public void releaseLock() {
// 释放锁
zkClient.delete(currentNodePath);
zkClient.close();
}
}
测试
public class LockTest {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new LockRunnable());
thread.start();
}
}
private static class LockRunnable implements Runnable {
@Override
public void run() {
AbstractLock abstractLock = new ZookeeperLock();
abstractLock.getLock();
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
abstractLock.releaseLock();
}
}
}
2.5 Redisson 锁
这个用起来更简单,引入 Redisson 即可
Redisson 也是基于 lua 脚本来实现加锁和释放锁的
@Bean
public RedissonClient redissonClient() {
String url = "redis://192.168.1.100:6379";
Config config = new Config();
config.useSingleServer().setAddress(url);
config.setLockWatchdogTimeout(3000L);
try {
return Redisson.create(config);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
@Component
public class RedissonLockUtil {
@Autowired
private RedissonClient redissonClient;
/**
* 加锁
*/
public boolean addLock(String lockKey) {
try {
if (redissonClient == null) {
return false;
}
RLock lock = redissonClient.getLock(lockKey);
boolean b = lock.tryLock(100, TimeUnit.SECONDS);
System.out.println(Thread.currentThread().getName() + " - 获取锁 " + b);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* 释放锁
*/
public boolean releaseLock(String lockKey) {
try {
if (redissonClient == null) {
return false;
}
RLock lock = redissonClient.getLock(lockKey);
lock.unlock();
System.out.println(Thread.currentThread().getName() + " - 已释放锁");
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
}
3. 消息幂等
在 RabbitMQ 中,如果 consumer 出现异常,默认是会有重试的
3.1 配置MQ消息重试
配置文件
spring:
rabbitmq:
host: 192.168.1.100
username: guest
password: guest
listener:
simple:
retry:
# 开启重试机制(默认是开启的)
enabled: true
# 最大重试次数
max-attempts: 5
# 重试间隔时间(毫秒)
initial-interval: 3000
重试机制下,保证幂等的操作和上面所说的服务幂等实现思路是一致的
3.2 实现流程
使用 Redis 进行防重操作
操作发生异常时,也可以使用死信队列和定时任务的方式对数据进行补偿
3.3 实现代码
发送mq消息
@PostMapping("/addOrder")
public void addOrder() {
// 消息id
String messageId = UUID.randomUUID().toString();
// 商品id
String productId = "12345";
MessageProperties messageProperties = new MessageProperties();
// 必须设置消息的id,并且是唯一的
messageProperties.setMessageId(messageId);
messageProperties.setContentType("text/plan");
messageProperties.setContentEncoding("utf-8");
Message message = new Message(productId.getBytes(), messageProperties);
rabbitTemplate.convertAndSend(RabbitMQConfig.QUEUE_ORDER, message);
}
监听mq消息
@Component
public class OrderListener {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Transactional(rollbackFor = Exception.class)
@RabbitListener(queues = RabbitMQConfig.QUEUE_ORDER)
public void receiveMessage(Message message) {
// 消息的id
String messageId = message.getMessageProperties().getMessageId();
try {
// redis校验
ValueOperations<String, Object> valueOperations = redisTemplate.opsForValue();
Boolean setSuccess = valueOperations.setIfAbsent(messageId, messageId, 30, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(setSuccess)) {
// 已经存在
System.out.println("重复消息");
return;
}
// TODO mysql校验,判断数据是否已处理等
// 商品id
String productId = null;
productId = new String(message.getBody(), StandardCharsets.UTF_8);
// TODO 库存扣减、生成订单等操作
} catch (RuntimeException e) {
// 释放锁
String luaScript = "if redis.call('get', KEYS[1] == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end)";
RedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
redisTemplate.execute(redisScript, Collections.singletonList(messageId), Collections.singletonList(messageId));
System.out.println("执行异常,释放锁");
throw e;
}
}
}
另外在对消息进行批量消费时,也需要考虑幂等性,实现方案和上面一致。