幂等性方案架构设计

170 阅读4分钟

以下内容分成 接口幂等、服务间幂等、MQ消息幂等 三个部分

1. 接口幂等

提供给前端使用的接口,如何在前端重复调用的情况下保证幂等

1.1 前端防重

例如表单重复提交、按钮多次重复点击等,不可靠

1.2 PRG模式

POST-REDIRECT-GET,提交表单会重定向到另一个提交成功的页面,是较为常见的一种前端防重策略

1.3 token模式

需要前后端配合来完成,主要步骤就是前端要先向服务器获取一个token,然后再携带token进行请求,token的生成、删除、判断由后端完成。

token机制.png

问题: 在高并发情况下,会存在token还未删除完成,第二次请求又过来的情况

解决方案:

  1. 加锁;

  2. 使用 redis incr,在第2步生成token时,初始值为1,第5步时判断token如果存在,不用执行删除操作,只需要再执行一次 incr 操作,如果返回2,则代表该token是第一次使用,如果返回大于2的值,则代表token已经使用;

  3. 先删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 事件监听机制,用于监听节点状态变更

先创建一个对应方法(比如下单操作)的父节点(持久节点),后续每个线程在该父节点下创建临时有序节点,线程在想要获取锁时,判断自己的节点序号是否是最小的,如果是,则获取锁,释放锁时,将自己的节点删除即可。

  • zookeeperCP 模式,强一致性
  • 基于 watch 实现锁释放的自动监听,性能更好
  • 需要频繁创建删除节点,吞吐量没 redis
2.4.2 实现流程

Zookeeper分布式锁.png

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 进行防重操作

操作发生异常时,也可以使用死信队列和定时任务的方式对数据进行补偿

MQ幂等.png

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;
        }

    }
}

另外在对消息进行批量消费时,也需要考虑幂等性,实现方案和上面一致。