幂等方案设计二服务幂等

346 阅读14分钟

服务幂等有以下几种方案: 防重表

对于防止数据重复提交,还有一种解决方案就是通过防重表实现。防重表的实现思路也非常简单。首先创建一张表

作为防重表,同时在该表中建立一个或多个字段的唯一索引作为防重字段,用于保证并发情况下,数据只有一条。

在向业务表中插入数据之前先向防重表插入,如果插入失败则表示是重复数据。

image.png

对于防重表的解决方案,可能有人会说为什么不使用悲观锁。悲观锁在使用的过程中也是会发生死锁的。悲观锁是

通过锁表的方式实现的。 假设现在一个用户A访问表A(锁住了表A),然后试图访问表B; 另一个用户B访问表

B(锁住了表B),然后试图访问表A。 这时对于用户A来说,由于表B已经被用户B锁住了,所以用户A必须等到用

户B释放表B才能访问。 同时对于用户B来说,由于表A已经被用户A锁住了,所以用户B必须等到用户A释放表A才

能访问。此时死锁就已经产生了

select+insert防重提交

对于一些后台系统,并发量并不高的情况下,对于幂等的实现非常简单,通过select+insert思想即可完成幂等控

制。

在业务执行前,先判断是否已经操作过,如果没有则执行,否则判断为重复操作

image.png

image.png

@Override
@Transactional(rollbackFor = Exception.class)
public  String addOrder(Order order) {

    order.setCreateTime(new Date());
    order.setUpdateTime(new Date());

    //根据订单id查询订单信息
    Order orderResult = orderMapper.selectByPrimaryKey(order.getId());

    if (orderResult != null){

        return "repeat request";
    }

    int result = orderMapper.insert(order);
    if (result != 1){
        return "fail";
    }

    return "success";
}

对于上述功能实现,在并发下,并不能完成幂等性控制。通过jemeter测试,模拟50个并发,可以发现,插入了重 复数据。产生了脏数据。

要解决这个问题,非常简单,在数据库层面添加唯一索引即可,将id设置为唯一索引,也是最容易想到的方式,一

旦id出现重复,就会出现异常,避免了脏数据的发生也可以解决永久性幂等。但该方案无法用于分库分表情况,其

只适用于单表情况。

mysql乐观锁

假设现在订单已经生成成功,那么就会涉及到扣减库存的操作。当高并发下同时扣减库存时,非常容易出现数据错

误问题。

MySQL乐观锁是基于数据库完成分布式锁的一种实现,实现的方式有两种:基于版本号、基于条件。但是实现思

想都是基于MySQL的行锁思想来实现的。

image.png


@Update("update tb_stock set amount=amount-#{num},version=version+1 where goods_id=#{goodsId} and version=#{version}")
int lessInventoryByVersion(@Param("goodsId") String goodsId,@Param("num") int num,@Param("version") int version);

基于条件实现: 通过版本号控制是一种非常常见的方式,适合于大多数场景。但现在库存扣减的场景来说,通过版本号控制就是多

人并发访问购买时,查询时显示可以购买,但最终只有一个人能成功,这也是不可以的。其实最终只要商品库存不

发生超卖就可以。那此时就可以通过条件来进行控制。

  @Update("update tb_stock set amount=amount-#{num} where goods_id=#{goodsId} and amount-#{num}>=0")
    int lessInventoryByVersionOut(@Param("goodsId") String goodsId,@Param("num") int num);
}

mysql乐观锁更适用于一些需要计数的表上,而且在竞争不激烈,出现并发冲突几率较小时,推荐使用乐观锁。虽

然通过MySQL乐观锁可以完成并发控制,但锁的操作是直接作用于数据库上,这样就会在一定程度上对数据库性能

产生影响。并且mysql的连接数量是有限的,如果出现大量锁操作占用连接时,也会造成MySQL的性能瓶颈。

zookeeper分布式锁

对于分布式锁的实现,zookeeper天然携带的一些特性能够很完美的实现分布式锁。其内部主要是利用znode节点

特性和watch机制完成。

在zookeeper中节点会分为四类,分别是:

持久节点:一旦创建,则永久存在于zookeeper中,除非手动删除 持久有序节点:一旦创建,则永久存在于zookeeper中,除非手动删除。同时每个节点都会默认存在节点序

号,每个节点的序号都是有序递增的。如demo000001、demo000002.....demo00000N

临时节点:当节点创建后,一旦服务器重启或宕机,则被自动删除。

临时有序节点:当节点创建后,一旦服务器重启或宕机,则被自动删除。同时每个节点都会默认存在节点序

号,每个节点的序号都是有序递增的。如demo000001、demo000002.....demo00000N

watch监听机制:

watch监听机制主要用于监听节点状态变更,用于后续事件触发,假设当B节点监听A节点时,一旦A节点发生修

改、删除、子节点列表发生变更等事件,B节点则会收到A节点改变的通知,接着完成其他额外事情。

image.png

实现原理:

其实现思想是当某个线程要对方法加锁时,首先会在zookeeper中创建一个与当前方法对应的父节点,接着每个要

获取当前方法的锁的线程,都会在父节点下创建一个临时有序节点,因为节点序号是递增的,所以后续要获取锁的

线程在zookeeper中的序号也是逐次递增的。根据这个特性,当前序号最小的节点一定是首先要获取锁的线程,因

此可以规定序号最小的节点获得锁。所以,每个线程再要获取锁时,可以判断自己的节点序号是否是最小的,如果

是则获取到锁。当释放锁时,只需将自己的临时有序节点删除即可。

image.png

根据上图,在并发下,每个线程都会在对应方法节点下创建属于自己的临时节点,且每个节点都是临时且有序的。

那么zookeeper又是如何有序的将锁分配给不同线程呢? 这里就应用到了watch监听机制。每当添加一个新的临时

节点时,其都会基于watcher机制监听着它本身的前一个节点等待前一个节点的通知,当前一个节点删除时,就轮

到它来持有锁了。然后依次类推。

image.png

1)zookeeper是基于cp模式,能够保证数据强一致性。

2)基于watch机制实现锁释放的自动监听,锁操作性能较好。

3)频繁创建节点,对于zk服务器压力较大,吞吐量没有redis强。

原理剖析及实现:

低效锁思想&实现:

在通过zookeeper实现分布式锁时,有另外一种实现的写法,这种也是非常常见的,但是它的效率并不高,此处可

以先对这种实现方式进行探讨。

image.png

此种实现方式,只会存在一个锁节点。当创建锁节点时,如果锁节点不存在,则创建成功,代表当前线程获取到

锁,如果创建锁节点失败,代表已经有其他线程获取到锁,则该线程会监听锁节点的释放。当锁节点释放后,则继

续尝试创建锁节点加锁。

实现:

定义锁的抽象类:

import org.I0Itec.zkclient.ZkClient;

public abstract class AbstractLock {

    //zk服务器地址
    public static final String ZK_SERVER_ADDR="192.168.200.131:2181";

    //zk超时时间:连接超时、session有效时间
    public static final int CONNECTION_TIME_OUT=30000;
    public static final int SESSION_TIME_OUT=30000;

    //创建zk客户端
    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+"    :获取锁成功");
        }else {
            System.out.println(threadName+"     :获取锁失败,进入等待");
            waitLock();
            getLock();
        }
    }

}

穿件lowlock:

/**
 * zk低效锁实现
 */
public class LowLock extends AbstractLock{

    public static final String LOCK_NODE_NAME="/lock_node";

    private CountDownLatch countDownLatch;

    @Override
    public boolean tryLock() {
        if (zkClient == null){
            return false;
        }

        try {
            zkClient.createEphemeral(LOCK_NODE_NAME);
            return true;
        } catch (RuntimeException e) {
            //获取锁失败
            return false;
        }

    }

    @Override
    public void waitLock() {

        IZkDataListener zkDataListener = new IZkDataListener() {

            @Override
            public void handleDataChange(String dataPath, Object data) throws Exception {

            }

            //节点被删除时触发
            @Override
            public void handleDataDeleted(String dataPath) throws Exception {
                if (countDownLatch !=null){
                    countDownLatch.countDown();
                }
            }
        };

        //注册监听器
        zkClient.subscribeDataChanges(LOCK_NODE_NAME,zkDataListener);

        //如果锁节点存在,阻塞当前线程
        if (zkClient.exists(LOCK_NODE_NAME)){
            countDownLatch = new CountDownLatch(1);

            try {
                countDownLatch.await();
                System.out.println(Thread.currentThread().getName()+":     等待获取锁");
            } catch (InterruptedException e) {
            }
        }

        //如果锁节点不存在
        //删除监听
        zkClient.unsubscribeDataChanges(LOCK_NODE_NAME,zkDataListener);

    }

    @Override
    public void releaseLock() {

        zkClient.delete(LOCK_NODE_NAME);
        zkClient.close();
        System.out.println(Thread.currentThread().getName()+":      释放锁");
    }
}

创建测试类:

public class LockTest {

    public static void main(String[] args) {

        //模拟多个10个客户端
        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 LowLock();

            //abstractLock.getLock();

            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            //abstractLock.releaseLock();
        }
    }
}

经过测试可以发现,当一个线程获取到锁之后,其他线程都会监听这把锁进入到等待状态,一旦持有锁的线程

释放锁后,其他线程则都会监听到,并竞争这把锁。

这种方案的低效点就在于,只有一个锁节点,其他线程都会监听同一个锁节点,一旦锁节点释放后,其他线程都会

收到通知,然后竞争获取锁节点。这种大量的通知操作会严重降低zookeeper性能,对于这种由于一个被watch的

znode节点的变化,而造成大量的通知操作,叫做羊群效应。

image.png

高效锁思想&实现:

为了避免羊群效应的出现,业界内普遍的解决方案就是,让获取锁的线程产生排队,后一个监听前一个,依次排

序。推荐使用这种方式实现分布式锁

image.png

按照上述流程会在根节点下为每一个等待获取锁的线程创建一个对应的临时有序节点,序号最小的节点会持有锁,

并且后一个节点只监听其前面的一个节点,从而可以让获取锁的过程有序且高效。


public class HighLock extends AbstractLock{

    private static String PARENT_NODE_PATH="";

    public HighLock(String parentNodePath){
        PARENT_NODE_PATH = parentNodePath;
    }

    //当前节点路径
    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 (RuntimeException e) {
        }


        //创建临时有序节点
        if (currentNodePath == null || "".equals(currentNodePath)){
            //根节点下创建第一个临时有序的子节点
            currentNodePath = zkClient.createEphemeralSequential(PARENT_NODE_PATH+"/","lock");
        }

        //不是第一个子节点,获取父节点下的所有子节点
        List<String> childrenNodeList = zkClient.getChildren(PARENT_NODE_PATH);

        //子节点排序
        Collections.sort(childrenNodeList);

        //判断是否加锁成功
        if (currentNodePath.equals(PARENT_NODE_PATH+"/"+childrenNodeList.get(0))){
            return true;
        }else {
            //获取前面的节点名称,并赋值
            int length = PARENT_NODE_PATH.length();
            int currentNodeNumber = Collections.binarySearch(childrenNodeList, currentNodePath.substring(length + 1));
            preNodePath = PARENT_NODE_PATH+"/"+childrenNodeList.get(currentNodeNumber-1);
        }

        return false;
    }

    @Override
    public void waitLock() {

        IZkDataListener zkDataListener = new IZkDataListener() {
            @Override
            public void handleDataChange(String dataPath, Object data) throws Exception {

            }

            @Override
            public void handleDataDeleted(String dataPath) throws Exception {

                if (countDownLatch != null){
                    countDownLatch.countDown();
                }
            }
        };

        //监听前一个节点的改变
        zkClient.subscribeDataChanges(preNodePath,zkDataListener);

        if (zkClient.exists(preNodePath)){
            countDownLatch = new CountDownLatch(1);

            try {
                countDownLatch.await();
            } catch (InterruptedException e) {

            }
        }

        zkClient.unsubscribeDataChanges(preNodePath,zkDataListener);
    }

    @Override
    public void releaseLock() {
        zkClient.delete(currentNodePath);
        zkClient.close();
    }
}

根据结果可以看到,每一个线程都会有自己的节点信息,并且都会有对应的序号。序号最小的节点首先获取到

锁,然后依次类推。

zk锁扣减库存业务实现

image.png

1)生成操作标识是为了防止feign调用超时出现重试,如果没有操作标识的话,库存服务无法判定是一次操作还是

多次操作,通过标识可以用于区分重试时当前是哪次操作。从而避免多次扣减库存情况的出现。

2)库存服务先检查redis再检查Mysql,出于两点考虑:

避免服务间重试时,库存服务无法区分是否为同一个操作,导致相同操作被执行多次。同时缓存结合关系型

数据库,可以起到减轻数据库压力的作用。

库存流水表不仅用于区分操作,同时每一次扣减库存时信息都会被记录,可以用于后期的库存信息统计等操

作。

总的来说,就是通过操作标识结合zookeeper分布式锁,完成mysql乐观锁的操作,思想上都是相同的。

/**
 * 生成订单
 * @param order
 * @return
 */
@Idemptent
@PostMapping("/genOrder")
public String genOrder(@RequestBody Order order) throws InterruptedException {

    //新增订单
    String orderId = String.valueOf(idWorker.nextId());
    order.setId(orderId);
    order.setCreateTime(new Date());
    order.setUpdateTime(new Date());
    orderService.addOrder(order);

    //新增订单明细
    List<String> goodsIds = JSON.parseArray(order.getGoodsIds(), String.class);
    List<OrderDetail> orderDetailList = new ArrayList<>();

    for (String goodsId : goodsIds) {
        OrderDetail orderDetail = new OrderDetail();
        orderDetail.setId(String.valueOf(idWorker.nextId()));
        orderDetail.setOrderId(orderId);
        orderDetail.setGoodsId(goodsId);
        orderDetail.setGoodsPrice(1);
        orderDetail.setGoodsNum(1);
        orderDetailService.addOrderDetail(orderDetail);

        orderDetailList.add(orderDetail);
    }

    //生成操作标识并存入redis
    redisTemplate.opsForValue().set(orderId,"orderId",30,TimeUnit.MINUTES);

    //扣减库存
    stockFeign.reduceStock(JSON.toJSONString(orderDetailList),orderId);

    //数据回查
    if (stockFlowFeign.findByFlag(orderId).size() >0){
        //操作成功
        return "success";
    }else {
        TimeUnit.SECONDS.sleep(3);
        //异步查询处理结果
        CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(()->{
            return stockFlowFeign.findByFlag(orderId).size();
        },executor);
        try {
            if (future1.get()>0){
                return "success";
            }
        }catch (Exception e){
            throw new RuntimeException("执行有误");
        }

        TimeUnit.SECONDS.sleep(5);
        CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(()->{
            return stockFlowFeign.findByFlag(orderId).size();
        },executor);
        try {
            if (future2.get()>0){
                return "success";
            }
        }catch (Exception e){
            throw new RuntimeException("执行有误");
        }

        TimeUnit.SECONDS.sleep(10);
        CompletableFuture<Integer> future3 = CompletableFuture.supplyAsync(()->{
            return stockFlowFeign.findByFlag(orderId).size();
        },executor);
        try {
            if (future3.get()>0){
                return "success";
            }
        }catch (Exception e){
            throw new RuntimeException("执行有误");
        }
        return "false";
    }
}
/**
 * 扣减库存
 * @param orderListValue
 * @param flag
 * @throws InterruptedException
 */
@PutMapping("/reduceStock/{flag}")
public void reduceStock(@RequestParam String orderListValue, @PathVariable("flag") String flag) throws InterruptedException {

    System.out.println("reduce stock");

    //redis去重校验
    if(!redisTemplate.delete(flag)){
        System.out.println("redis验重 重复操作");
        return;
    }

    //mysql去重校验
    int size = stockFlowService.findByFlag(flag).size();
    if (size>0){
        System.out.println("mysql验重 重复操作");
        return;
    }

    //扣减库存
    List<OrderDetail> orderDetailList = JSON.parseArray(orderListValue, OrderDetail.class);
    stockService.reduceStock(orderDetailList,flag);

    TimeUnit.SECONDS.sleep(6);

}

扣减库存:

@Service
public class StockServiceImpl implements StockService {

    @Autowired
    private StockMapper stockMapper;

    @Autowired
    private StockFlowMapper stockFlowMapper;

    @Autowired
    private IdWorker idWorker;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean reduceStock(List<OrderDetail> orderDetailList, String flag) {

        String methodName = Thread.currentThread().getStackTrace()[1].getMethodName();

        AbstractLock zkLock = new HighLock("/"+methodName);

        //加锁
        try {

            zkLock.getLock();

            orderDetailList.stream().forEach(orderDetail -> {

                //扣减库存
                int reduceStockResult = stockMapper.reduceStock(orderDetail.getGoodsId(), orderDetail.getGoodsNum());
                if (reduceStockResult != 1){
                    //扣减库存失败
                    throw new RuntimeException("扣减库存失败");
                }

                //新增库存流水
                StockFlow stockFlow = new StockFlow();
                stockFlow.setId(String.valueOf(idWorker.nextId()));
                stockFlow.setFlag(flag);
                stockFlow.setGoodsId(orderDetail.getGoodsId());
                stockFlow.setNum(orderDetail.getGoodsNum());
                stockFlowMapper.insert(stockFlow);
            });

            return true;
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            zkLock.releaseLock();
        }

        return false;
    }

    @Override
    public Stock getStockInfo(String goodsId) {
        Stock stock = stockMapper.findByGoodsId(goodsId);
        return stock;
    }
}

redis分布式锁

单节点Redis实现分布式锁

分布式锁的一个很重要的特性就是互斥性,同一时间内多个调用方加锁竞争,只能有一个调用方加锁成功。而

redis是基于单线程模型的,可以利用这个特性让调用方的请求排队,对于并发请求,只会有一个请求能获取到

锁。

redis实现分布式锁也很简单,基于客户端的几个API就可以完成,主要涉及三个核心API:

setNx():向redis中存key-value,只有当key不存在时才会设置成功,否则返回0。用于体现互斥性。

expire():设置key的过期时间,用于避免死锁出现。

delete():删除key,用于释放锁。

1)编写工具类实现加锁

通过jedis.set进行加锁,如果返回值是OK,代表加锁成功

如果加锁失败,则自旋不断尝试获取锁,同时在一定时间内如果仍没有获取到锁,则退出自旋,不再尝试获取锁。

requestId:用于标识当前每个线程自己持有的锁标记

public class SingleRedisLock {

    JedisPool jedisPool = new JedisPool("192.168.200.128",6379);

    //锁过期时间
    private long lockLeaseTime = 30000;

    //获取锁的超时时间
    private long timeOut=30000;

    /**
     * 加锁
     * @param lockKey
     * @param requestId
     * @return
     */
    public boolean tryLock(String lockKey,String requestId){

        String threadName = Thread.currentThread().getName();

        Jedis jedis = this.jedisPool.getResource();

        //获取当前时间
        long start = System.currentTimeMillis();

        try{

            //获取锁
            for (;;){

                String lockResult = jedis.set(lockKey, requestId, SetParams.setParams().nx().px(lockLeaseTime));

                if ("OK".equals(lockResult)){
                    System.out.println(threadName+":    获取锁成功");
                    return true;
                }

                //获取锁失败,在一定的时间内不断尝试的获取锁,当超时了之后,则退出自旋
                System.out.println(threadName+":    获取锁失败,进入自旋等待");
                long l = System.currentTimeMillis() - start;
                if (l>=timeOut){
                    System.out.println(threadName+":   在规定时间内,没有获取到锁,退出自旋");
                    return false;
                }

                try {
                    TimeUnit.MILLISECONDS.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }finally {
            jedis.close();
        }
    }

    /**
     * 解锁
     * @param lockKey
     * @param requestId
     * @return
     */
    public boolean releaseLock(String lockKey,String requestId){

        String threadName = Thread.currentThread().getName();
        System.out.println(threadName+":  释放锁");

        Jedis jedis = this.jedisPool.getResource();

        String lua =
                "if redis.call('get',KEYS[1]) == ARGV[1] then" +
                        "   return redis.call('del',KEYS[1]) " +
                        "else" +
                        "   return 0 " +
                        "end";

        try {
            Object result = jedis.eval(lua, Collections.singletonList(lockKey), Collections.singletonList(requestId));

            if ("1".equals(result.toString())){
                return true;
            }

            return false;
        }finally {
            jedis.close();
        }
    }
}

解锁时,要避免当前线程将别人的锁释放掉。假设线程A加锁成功,当过了一段时间线程A来解锁,但线程A的锁已

经过期了,在这个时间节点,线程B也来加锁,因为线程A的锁已经过期,所以线程B时可以加锁成功的。此时,就

会出现问题,线程A将线程B的锁给释放了。

对于这个问题,就需要使用到加锁时的requestId。当解锁时要判断当前锁键的value与传入的value是否相同,相

同的话,则代表是同一个人,可以解锁。否则不能解锁。

但是对于这个操作,有非常多的人,会先查询做对比,接着相同则删除。虽然思路是对的,但是忽略了一个问题,

原子性。判断与删除分成两步执行,则无法保证原子性,一样会出现问题。所以解锁时不仅要保证加锁和解锁是同

一个人还要保证解锁的原子性。因此结合lua脚本完成查询&删除操作。

编写测试类:


public class LockTest {

    public static void main(String[] args) {

        //模拟多个5个客户端
        for (int i=0;i<5;i++) {
            Thread thread = new Thread(new LockRunnable());
            thread.start();
        }
    }

    private static class LockRunnable implements Runnable {
        @Override
        public void run() {

            SingleRedisLock singleRedisLock = new SingleRedisLock();

            String requestId = UUID.randomUUID().toString();

            boolean lockResult = singleRedisLock.tryLock("lock", requestId);

            if (lockResult){

                try {
                    TimeUnit.SECONDS.sleep(5);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

            singleRedisLock.releaseLock("lock",requestId);
        }
    }
}

此时可以发现,多线程会竞争同一把锁,且没有获取获取到锁的线程会自旋不断尝试去获取锁。每当一个线程将锁

释放后,则会有另外一个线程持有锁。依次类推。

单节点问题:

当对业务进行加锁时,锁的过期时间,绝对不能想当然的设置一个值。假设线程A在执行某个业务时加锁成功

并设置锁过期时间。但该业务执行时间过长,业务的执行时间超过了锁过期时间,那么在业务还没执行完

时,锁就自动释放了。接着后续线程就可以获取到锁,又来执行该业务。就会造成线程A还没执行完,后续线

程又来执行,导致同一个业务逻辑被重复执行。因此对于锁的超时时间,需要结合着业务执行时间来判断,

让锁的过期时间大于业务执行时间。

Redisson实现分布式锁

redisson是redis官网推荐实现分布式锁的一个第三方类库。其内部完成的功能非常强大,对各种锁都有实现,同

时对于使用者来说非常简单,让使用者能够将更多的关注点放在业务逻辑上。此处重点利用Redisson解决单机

Redis锁产生的两个问题。

单机版本redisson分布式锁:

基于redisson实现分布式锁很简单,直接基于lock()&unlock()方法操作即可

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.0.1</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>
<!--Redis分布式锁-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.13.1</version>
</dependency>

image.png


@Component
public class RedissonLock {

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 加锁
     */
    public boolean addLock(String lockKey){

        try{
            if (redissonClient == null){
                return false;
            }

            RLock lock = redissonClient.getLock(lockKey);

            //设置锁的过期时间
            lock.lock(1, TimeUnit.SECONDS);

            System.out.println(Thread.currentThread().getName()+":     获取到锁");
            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;
        }
    }
}

测试:


@SpringBootTest
@RunWith(SpringRunner.class)
public class RedissonLockTest {

    @Autowired
    private RedissonLock redissonLock;

    @Test
    public void easyLock(){
        //模拟多个10个客户端
        for (int i=0;i<10;i++) {
            Thread thread = new Thread(new LockRunnable());
            thread.start();
        }

        try {
            System.in.read();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private class LockRunnable implements Runnable {
        @Override
        public void run() {
            redissonLock.addLock("demo");
            try {
                TimeUnit.SECONDS.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            redissonLock.releaseLock("demo");
        }
    }
}

根据执行效果可知,多线程并发获取所时,当一个线程获取到锁,其他线程则获取不到,并且其内部会不断尝试获

取锁,当持有锁的线程将锁释放后,其他线程则会继续去竞争锁

lock()源码分析

在上述加锁方法实现中,最核心就是getLock()和lock()。get()源码非常简单,根据当前传入的锁名称创建并返回

一个RLock对象。

当获取到RLock对象后,调用其内部的lock()执行加锁操作。根据源码描述,当线程获取锁时,如果没有获取到

锁,则会让其进入自旋,直到获取到锁。 如果获取到锁,则会一直保留到调用unLock()手动释放或根据传入的

leaseTime时间自动释放。

当前传入两个参数值:锁超时时间,时间单位。主要用于避免死锁的出现,假设持有锁的redis节点宕机,到期后

锁可以自动释放。

在三个参数的lock()重载方法中,首先会获取当前线程id,接着调用tryAcquire()方法尝试获取锁,如果返回值为

null,代表获取到锁。 如果返回值不是null,则根据当前线程id创建异步任务并放入线程池中,接着进入自旋,在

自旋过程中,尝试调用tryAcquire()获取锁,如果获取到则退出自旋。否则会不断的尝试获取锁。

image.png

在lock()方法中,最核心的是tryAcquire()。其内部核心实现会调用tryAcquireAsync(),并传入过期时间、时间单位

和当前线程id,进行锁的获取。如果leaseTime不为-1,代表设置了有效时间,接着调用tryAcquireAsync()去获取锁。如果是-1的话,则默认把永不过期改为30秒过期,并且创建异步任务,如果没有获取到锁,则什么都不做。如

果获取到了锁,则调用scheduleExpirationRenewal()对当前线程id的锁进行延时。

image.png

最终的tryLockInnerAsync()则是获取锁的具体实现。可以看到,其内部是基于lua脚本语言完成锁获取的。因为获

取锁的过程涉及到了多步,为了保证执行过程的原子性,所以使用了lua,最核心的就是要理解这段lua脚本的执行

过程。

image.png

对于这款lua脚本来说,KEYS[1]代表需要加锁的key,ARGV[1]代表锁的超时时间,ARGV[2]代表锁的唯一标识。

对于这段lua脚本,简单来说:

1)检查锁key是否被占用了,如果没有则设置锁key和唯一标识,初始值为1,并且设置锁key的过期时间。

2)如果锁key存在,并且value也匹配,表示是当前线程持有的锁,那么重入次数加1,并且设置失效时间。

3)返回锁key的失效时间毫秒数。

unLock()源码分析:

在释放锁时,unlock()内部会调用unlockAsync()对当前线程持有的锁进行释放。其内部最终会执行

unlockInnerAsync()方法完成锁释放并返回结果

image.png

image.png

相关参数:

KEYS[1]:当前锁key。

KEYS[2]:redis消息的ChannelName,每个锁对应唯一的一个 channelName。

ARGV[1]:redis消息体,用于标记redis的key已经解锁,用于通知其他线程申请锁。

ARGV[2]:锁超时时间。

ARGV[3]:锁的唯一标识。

1)判断锁key和锁的唯一标识是否匹配,如果不匹配,表示锁已经被占用,那么直接返回。 2)如果是当前线程持有锁,则value值-1,用于重入操作。 3)如果-1后的值大于0,则对锁设置过期时间。 4)如果-1后的值为0,则删除锁key,并发布消息,该锁已被释放。用于通知其他线程申请锁。

锁续期:

对于锁续期问题,在单点redis实现分布式锁时已经介绍过了,用于防止业务执行超时或宕机而引起的业务被重复

执行。

根据对lock方法的解析,可以发现,当设置完过期时间后,当前锁的过期时间就已经被设定了,不会发生改变,锁

到期后则会被自动释放,因此在业务执行中,通过lock()方法加锁会造成隐患。

看门狗: 所谓的看门狗是redisson用于自动延长锁有效期的实现机制。其本质是一个后台线程,用于不断延长锁key的生存

时间。

改造锁示例代码,让锁超时时间为1秒,但是业务执行时,需要耗时3秒,此时执行可以发现,多线程间在上一个

锁没有释放的情况下,后续线程又获取到了锁。但是解锁的时候,出现异常,因为加锁时的唯一标识与解锁时的唯

一标识发生了改变,造成死锁。

因为业务执行多久无法确定一个准确值,所以在看门狗的实现中,不需要对锁key设置过期时间,当过期时间为-1

时,这时会启动一个定时任务,在业务释放锁之前,会一直不停的增加这个锁的有效时间,从而保证在业务执行完

毕前,这把锁不会被提前释放掉。

要开启看门狗机制也很简单,只需要将加锁时使用lock()改为tryLock()即可

    并且根据之前lock的源码分析,如果没有设置锁超时,默认过期时间为30秒即watchdog每隔30秒来进行一次续期,该值可以修改
config.setLockWatchdogTimeout(3000L);

进行测试,当加锁后,线程睡眠10秒钟,然后释放锁,可以看到在这段时间内,当前线程会一直持有锁,直到锁

释放。在多线程环境下,也是阻塞等待进行锁的获取

红锁:

当在单点redis中实现redis锁时,一旦redis服务器宕机,则无法进行锁操作。因此会考虑将redis配置为主从结 构,但在主从结构中,数据复制是异步实现的。假设在主从结构中,master会异步将数据复制到slave中,一旦某 个线程持有了锁,在还没有将数据复制到slave时,master宕机。则slave会被提升为master,但被提升为slave的 master中并没有之前线程的锁信息,那么其他线程则又可以重新加锁

redlock是一种基于多节点redis实现分布式锁的算法,可以有效解决redis单点故障的问题。官方建议搭建五台 redis服务器对redlock算法进行实现:

1)记录获取锁前的当前时间

2)使用相同的key,value获取所有redis实例中的锁,并且设置获取锁的时间要远远小于锁自动释放的时间。假设锁自动释放时间是10秒,则获取时间应在5-50毫秒之间。通过这种方式避免客户端长时间等待一个已经关闭的实

例,如果一个实例不可用了,则尝试获取下一个实例。

3)客户端通过获取所有实例的锁后的时间减去第一步的时间,得到的差值要小于锁自动释放时间,避免拿到一个
已经过期的锁。并且要有超过半数的redis实例成功获取到锁,才算最终获取锁成功。如果不是超过半数,有可能
出现多个客户端重复获取到锁,导致锁失效。

4)当已经获取到锁,那么它的真正失效时间应该为:过期时间-第三步的差值。

5)如果客户端获取锁失败,则在所有redis实例中释放掉锁。为了保证更高效的获取锁,还可以设置重试策略,在一定时间后重新尝试获取锁,但不能是无休止的,要设置重试次数。
虽然通过redlock能够更加有效的防止redis单点问题,但是仍然是存在隐患的。假设redis没有开启持久化,
clientA获取锁后,所有redis故障重启,则会导致clientA锁记录消失,clientB仍然能够获取到锁。这种情况虽然发生几率极低,但并不能保证肯定不会发生。

保证的方案就是开始AOF持久化,但是要注意同步的策略,使用每秒同步,如果在一秒内重启,仍然数据丢失。使

用always又会造成性能急剧下降。

官方推荐使用默认的AOF策略即每秒同步,且在redis停掉后,要在ttl时间后再重启。 缺点就是ttl时间内redis无法对外提供服务

redisson对于红锁的实现已经非常完善,通过其内部提供的api既可以完成红锁的操作:


@Configuration
public class RedissonRedLockConfig {

    public RedissonRedLock initRedissonRedLock(String lockKey){

        Config config1 = new Config();
        config1.useSingleServer().setAddress("redis://192.168.200.150:7000").setDatabase(0);
        RedissonClient redissonClient1 = Redisson.create(config1);

        Config config2 = new Config();
        config2.useSingleServer().setAddress("redis://192.168.200.150:7001").setDatabase(0);
        RedissonClient redissonClient2 = Redisson.create(config2);

        Config config3 = new Config();
        config3.useSingleServer().setAddress("redis://192.168.200.150:7002").setDatabase(0);
        RedissonClient redissonClient3 = Redisson.create(config3);

        Config config4 = new Config();
        config4.useSingleServer().setAddress("redis://192.168.200.150:7003").setDatabase(0);
        RedissonClient redissonClient4 = Redisson.create(config4);

        Config config5 = new Config();
        config5.useSingleServer().setAddress("redis://192.168.200.150:7004").setDatabase(0);
        RedissonClient redissonClient5 = Redisson.create(config5);

        RLock rLock1 = redissonClient1.getLock(lockKey);
        RLock rLock2 = redissonClient2.getLock(lockKey);
        RLock rLock3 = redissonClient3.getLock(lockKey);
        RLock rLock4 = redissonClient4.getLock(lockKey);
        RLock rLock5 = redissonClient5.getLock(lockKey);

        RedissonRedLock redissonRedLock = new RedissonRedLock(rLock1,rLock2,rLock3,rLock4,rLock5);

        return redissonRedLock;
    }
}

@SpringBootTest
@RunWith(SpringRunner.class)
public class RedLockTest {

    @Autowired
    private RedissonRedLockConfig redissonRedLockConfig;

    @Test
    public void easyLock(){
        //模拟多个10个客户端
        for (int i=0;i<10;i++) {
            Thread thread = new Thread(new RedLockTest.RedLockRunnable());
            thread.start();
        }

        try {
            System.in.read();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private class RedLockRunnable implements Runnable {
        @Override
        public void run() {
            RedissonRedLock redissonRedLock = redissonRedLockConfig.initRedissonRedLock("demo");

            try {
                boolean lockResult = redissonRedLock.tryLock(100, 10, TimeUnit.SECONDS);

                if (lockResult){
                    System.out.println("获取锁成功");
                    TimeUnit.SECONDS.sleep(3);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                redissonRedLock.unlock();
                System.out.println("释放锁");
            }
        }
    }
}

redissonRedLock加锁源码分析

 @Override
    public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
//        try {
//            return tryLockAsync(waitTime, leaseTime, unit).get();
//        } catch (ExecutionException e) {
//            throw new IllegalStateException(e);
//        }
        long newLeaseTime = -1;
        if (leaseTime != -1) {
            if (waitTime == -1) {
                newLeaseTime = unit.toMillis(leaseTime);
            } else {
                newLeaseTime = unit.toMillis(waitTime)*2;
            }
        }
        
        long time = System.currentTimeMillis();
        long remainTime = -1;
        if (waitTime != -1) {
            remainTime = unit.toMillis(waitTime);
        }
        
        long lockWaitTime = calcLockWaitTime(remainTime);
        
         // 1. 允许加锁失败节点个数限制(N‐(N/2+1)),当前假设五个节点,则允许失败节点数为2
        int failedLocksLimit = failedLocksLimit();
        
        //2. 遍历所有节点执行lua加锁,用于保证原子性
        
        List<RLock> acquiredLocks = new ArrayList<>(locks.size());
        for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
            RLock lock = iterator.next();
            boolean lockAcquired;
            
            //3.对节点尝试加锁
            
            try {
                if (waitTime == -1 && leaseTime == -1) {
                    lockAcquired = lock.tryLock();
                } else {
                    long awaitTime = Math.min(lockWaitTime, remainTime);
                    lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
                }
            } catch (RedisResponseTimeoutException e) {
            
            //如果抛出这类异常,为了防止加锁成功,但是响应失败,需要解锁所有节点
                unlockInner(Arrays.asList(lock));
                lockAcquired = false;
            } catch (Exception e) {
            //抛出异常表示获取锁失败
                lockAcquired = false;
            }
            
            if (lockAcquired) {
            //如果获取到锁则添加到已获取锁集合中
            
                acquiredLocks.add(lock);
            } else {
            
            计算已经申请锁失败的节点是否已经到达 允许加锁失败节点个数限制 (N‐(N/2+1))
            如果已经到达, 就认定最终申请锁失败,则没有必要继续从后面的节点申请了
            因为 Redlock 算法要求至少N/2+1 个节点都加锁成功,才算最终的锁申请成功
          
                if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
                    break;
                }

                if (failedLocksLimit == 0) {
                    unlockInner(acquiredLocks);
                    if (waitTime == -1) {
                        return false;
                    }
                    failedLocksLimit = failedLocksLimit();
                    acquiredLocks.clear();
                    // reset iterator
                    while (iterator.hasPrevious()) {
                        iterator.previous();
                    }
                } else {
                    failedLocksLimit--;
                }
            }
            
            //计算从各个节点获取锁已经消耗的总时间,如果已经等于最大等待时间,则申请锁失败,返回false
            
            if (remainTime != -1) {
                remainTime -= System.currentTimeMillis() - time;
                time = System.currentTimeMillis();
                if (remainTime <= 0) {
                    unlockInner(acquiredLocks);
                    return false;
                }
            }
        }

        if (leaseTime != -1) {
            List<RFuture<Boolean>> futures = new ArrayList<>(acquiredLocks.size());
            for (RLock rLock : acquiredLocks) {
                RFuture<Boolean> future = ((RedissonLock) rLock).expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS);
                futures.add(future);
            }
            
            for (RFuture<Boolean> rFuture : futures) {
                rFuture.syncUninterruptibly();
            }
        }
        //如果逻辑正常执行完则认为最终申请锁成功,返回true
        return true;
    }