这就是程序猿的快乐吧

导航

Redis高并发分布式锁实战

Redis分布式锁原理

手写分布式锁

场景:秒杀减库存 准备:启动redis,存储key:stock、value:300

以下代码是一个减库存的接口。

    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
    	 if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                System.out.println("扣减成功,剩余库存:" + realStock);
          } else {
                System.out.println("扣减失败,库存不足");
          }
          return "end";
    }

接口的含义是从redis拿到库存值,判断是否大于0,大于0 则减1 并更新redis存储的库存值,反之小于0,则打印扣减失败,库存不足。

首先不难看出接口是有并发问题的,如果同时多个线程执行减库存操作,查询出来的库存值都一致再存储到redis里边,那肯定就有问题了,假设同时过来三个线程查出来300库存,调用接口减库存,同时更新库存值299,这样的话就会造成超卖!

这样的话,可能大家第一想法就是有并发问题的这块代码加上锁,比如说synchronized,jvm的内置锁,进程级别的锁。代码如下:

    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 synchronized(this) {
             int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
             if (stock > 0) {
                    int realStock = stock - 1;
                    stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                    System.out.println("扣减成功,剩余库存:" + realStock);
              } else {
                    System.out.println("扣减失败,库存不足");
              }
       		}
       		return "end";
    }

这样根据synchronized的特性,多个线程请求这个服务,线上环境部署一台应用,在单台服务器上是可以解决问题的。

但是在目前高可用集群环境多台服务器下还是会有问题的。

比如说上图,通过nginx负载均衡两个tomcat进程,分发请求到不同的tomcat里边去,发现还是会发生上边超卖的情况。

下边是我自己压测的过程,有兴趣的可以自己测试一下:

1.在本地启动两个tomcat,并将上边加了sync锁的代码打包放到tomcat里边运行。

2.配置nginx 权重1:1的负载上边两个tomcat的地址+端口,启动nginx

3.配置jmeter压测上边配置的nginx负载的tomcat,我这jmeter配置的200个线程数,Ramp-up :1s 循环次数4次,代表1s内执行200次请求,循环4次,启动jmeter。

4.通过两个tomcat打印的日志信息可以发现了有重复库存的出现,说明在同一时间请求,超卖问题已经出现,在集群环境下sync不能解决并发问题。

这其中还会发生一个问题,那就是有可能在同一台服务器上已经减过的库存再次出现,这是因为另一台服务器给set的值导致从redis取的时候取到已经减过的库存,导致的超卖问题,也是典型的分布式问题。

如果系统中用到redis的话,推荐用redis实现分布式锁解决这个问题,接下来一步步进行分析。

如果用redis实现分布式锁的话,我们一般用setnx这个命令来实现:

SETNX
格式:setnx key value 
将 key 的值设为 value ,当且仅当 key 不存在。
若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。
返回值: 
设置成功,返回 1 。
设置失败,返回 0 。

根据setnx的特性,多个进程过来请求的话,让他们同时去使用setnx命令去设置同一个值,如果设置成功,则说明抢到锁,可以进行执行逻辑代码,如果没设置成功的话,说明没抢到锁,没抢到的线程进行等待重试。

根据以上思路,快速实现一个简单的入门级别分布式锁,代码如下:

    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 //在实际应用过程中,肯定是给操作的对象上锁,比如说操作某件商品,就对应商品id进行上锁
    	 String lockKey = "lock:product_101";
    	 //上锁
    	 Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "xxx");
    	 if(!result) {
    	 	return "系统繁忙,请稍后再试";
    	 }
         int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
         if (stock > 0) {
             int realStock = stock - 1;
             stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
             System.out.println("扣减成功,剩余库存:" + realStock);
         } else {
         	System.out.println("扣减失败,库存不足");
         }
         //释放锁
         stringRedisTemplate.delete(lockKey);
         return "end";
    }

当然这个只是入门级别的分布式锁,肯定有很多问题会发生,在实际业务过程中,“上锁” 之后的业务代码肯定是会很多的,在操作过程中如果发生异常,就执行不到释放锁的代码,这样就会发生死锁的问题,上锁的“product_101”key就会一直在redis里边存在,其他的线程就不能再去对上锁的“product_101”进行操作了。

解决办法就是try{xxx} finally{} 不管抛异常还是怎么样都需要把这个锁给释放了:

    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 //在实际应用过程中,肯定是给操作的对象上锁,比如说操作某件商品,就对应商品id进行上锁
    	 String lockKey = "lock:product_101";
    	 //上锁
    	 Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "xxx");
    	 if(!result) {
    	 	return "系统繁忙,请稍后再试";
    	 }
    	 try {
             int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
             if (stock > 0) {
                 int realStock = stock - 1;
                 stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                 System.out.println("扣减成功,剩余库存:" + realStock);
             } else {
                System.out.println("扣减失败,库存不足");
             }
         } finally {
             //释放锁
             stringRedisTemplate.delete(lockKey);
         }
         return "end";
    }

加try{}finally{}就一定解决问题了吗 也不一定, 如果服务器宕机,或者basis进行服务器重启的时候,一样执行不到finally的代码,还是会发生死锁的情况。

发生上边的情况我们可以进行设置超时时间,但是有个问题注意一下,比如说下边这段代码:

Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "xxx");
stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);

不要把设置超时时间和设置key分开写,这样的话容易发生原子性问题,原子是什么,原子是最小的单位不可再分割。保证原子性的话,需要redis把它当成一条命令去执行,不能分开来执行。在开发过程中这点也要注意,如果像以上代码分开来的话比如说执行到设置key这段代码服务器发生问题宕机或者重启,还是会发生以上死锁问题,正确的代码如下:

    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 //在实际应用过程中,肯定是给操作的对象上锁,比如说操作某件商品,就对应商品id进行上锁
    	 String lockKey = "lock:product_101";
    	 //上锁 保证原子性
    	 Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "xxx", 10, TimeUnit.SECONDS);
    	 if(!result) {
    	 	return "系统繁忙,请稍后再试";
    	 }
    	 try {
             int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
             if (stock > 0) {
                 int realStock = stock - 1;
                 stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                 System.out.println("扣减成功,剩余库存:" + realStock);
             } else {
                System.out.println("扣减失败,库存不足");
             }
         } finally {
             //释放锁
             stringRedisTemplate.delete(lockKey);
         }
         return "end";
    }

以上代码在并发不是特别高的情况下是不会有问题的,但是如果说在极端高并发的场景下并且执行的业务代码逻辑又特别长,第一个请求过来执行超过了10秒钟,锁就失效了,这样第二个请求就能获取锁去执行了,在执行过程中,第一个请求执行完了执行delete key去释放锁,这样,第三个请求就能进来了,然后第二个请求执行完,就会把第三个请求的锁释放掉,这样周而复始,还是会有问题,上边的超卖问题还是得不到解决,甚至 在极端高并发的情况下,造成大量的超卖。

首先分析这个问题出现的根本原因在哪里,不难看出当前线程抢占的锁被其他线程给删除掉了,这样肯定是不合理的,线程自己的锁肯定需要自己来删除,明白的这个点后,我们可以给锁加上一个用uuid生成的clientId放到value里边去,以此来判断是不是线程自己的锁,代码如下:

    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 //在实际应用过程中,肯定是给操作的对象上锁,比如说操作某件商品,就对应商品id进行上锁
    	 String lockKey = "lock:product_101";
    	 //上锁 保证原子性
    	 String clientId = UUID.randomUUID().toString();
    	 Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);
    	 if(!result) {
    	 	return "系统繁忙,请稍后再试";
    	 }
    	 try {
             int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
             if (stock > 0) {
                 int realStock = stock - 1;
                 stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                 System.out.println("扣减成功,剩余库存:" + realStock);
             } else {
                System.out.println("扣减失败,库存不足");
             }
         } finally {
             //释放锁
             if	(clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
             	stringRedisTemplate.delete(lockKey);
             }
         }
         return "end";
    }

假设在这样的情况下,线程1在执行到判断是不是自己的锁的时候 也就是 if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {}这段代码的时候刚好是9.9秒时间,在要删除锁的时候进行了卡顿,这个时候线程2进来了,线程2在执行逻辑的时候,线程1的卡顿结束,又把线程2的锁给删除了,这样说白了判断clientId和删除key这两个代码还是有原子性问题的,但是就没有线程的api可以调用了。

我们发现上边有很多问题都是因为时间超时导致redis存储的锁失效,然后其他线程并发来执行,当然我们也可以将超时时间设置的长一些,设置为30s,当然有业务逻辑超过30s的很少,但是还是存在的,比如说再跑定时任务的时候用到了分布式锁,超过了30秒钟就一样还是会出现锁超时的问题,所以这样单纯的延长时间还是治标不治本,所以想要完美的解决这个问题就要引入一个完美的解决方案叫做锁续命(watchDog)。

锁续命(watchDog):假设主线程抢到锁开始执行业务逻辑,开启一个分线程,在分线程里边做一个定时任务,比如说设置的锁超时时间是30s,那么我们的定时任务时间就设置为10s,定时任务设置的时间一定要比锁超时时间小,每10s定时任务先去判断主线程有没有结束,没有结束的话说明主线程就还在,还在进行业务逻辑操作,这个时候我们执行一条expire命令,将主线程锁的超时时间重新设置为30s,这样的话只要主线程还没结束,主线程就会被分线程定时任务去做续命逻辑,维持在30s,判断主线程结束,就不再执行续命逻辑。

Redisson

依据上边的场景加问题,市面上有很多优秀的分布式锁框架,其中一个Redisson的实现中,就有锁续命的实现,使用方法也很简单。

引入redisson的jar包

            <dependency>
                <groupId>org.redisson</groupId>
                <artifactId>redisson</artifactId>
                <version>3.16.8</version>
            </dependency>

引入之后,进行redisson客户端的配置,注入到spring容器。

    @Bean
    public Redisson redisson() {
        // 此为单机模式
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379").setDatabase(0);
        return (Redisson) Redisson.create(config);
    }

然后我们再实现分布式锁就很简单了,代码如下:

    @Autowired
    private Redisson redisson;
    
    @RequestMapping("/deduct_stock")
    public String deductStock() {
    	 //在实际应用过程中,肯定是给操作的对象上锁,比如说操作某件商品,就对应商品id进行上锁
    	 String lockKey = "lock:product_101";
    	 //上锁 保证原子性
    	/* String clientId = UUID.randomUUID().toString();
    	 Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);
    	 if(!result) {
    	 	return "系统繁忙,请稍后再试";
    	 }*/
    	//获取锁对象
    	 RLock redissonLock = redisson.getLock(lockKey);
        //加分布式锁
         redissonLock.lock();  //  .setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);
    	 try {
             int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
             if (stock > 0) {
                 int realStock = stock - 1;
                 stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
                 System.out.println("扣减成功,剩余库存:" + realStock);
             } else {
                System.out.println("扣减失败,库存不足");
             }
         } finally {
             //释放锁
             /*if	(clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
             	stringRedisTemplate.delete(lockKey);
             }*/
            //解锁
            redissonLock.unlock();
         }
         return "end";
    }

再redisson加分布所锁的过程中,也就是 redissonLock.lock(),lock()方法是做了很多操作的。

redisson加锁的核心流程:

如上图所示:假设现在有两个线程同时调用lock()方法给同一个key加锁,原理也就是跟执行setnx命令差不多,只能有一个线程能加锁成功,如果线程1执行成功,那么相应的线程2就执行不成功,线程2就会自旋并间歇性的尝试去加锁,检测锁是否还存在,如果不存在会去尝试加锁,在的话就会继续等待(不会一直while尝试加锁,会有阻塞的逻辑)。线程1加锁成功会另外开启一个后台线程每隔一段时间检查是否还持有锁,如果持有则延长加锁的时间。

有了以上的逻辑,我们继续根据源码推敲。

加锁成功:

在RedissonLock类的lock()方法中发现会先获取线程的id,然后执行tryAcquire()方法,并且传递一个值为-1l的leaseTime参数

trytryAcquire()紧接着调用tryAcquireAsync()方法并且根据leaseTime参数值判断是否-1l走相应的逻辑:

我们点进去else继续看发现会根据传递的参数执行一段lua脚本:

    <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
                "if (redis.call('exists', KEYS[1]) == 0) then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +
                        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +
                        "return redis.call('pttl', KEYS[1]);",
                Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
    }

具体lua脚本介绍可参考"Redis持久化、主从与哨兵架构详解"中"Redis Lua脚本"介绍。

lua脚本具有减少网络开销、原子操作、替代redis的事物等优势,这里这段lua脚本大概的意思是:判断传入的getLock()方法里边的name存不存在,如果返回0,表示不存在则存入一个hash结构,key为传入的name(也就是上边代码的lockKey),value是根据threadId生成的一个唯一的名称(相当于上边手写分布式锁的clientId)并且给这个key设置对应参数1(unit.toMillis(leaseTime))里边的超时时间也就是30s,后边的hincrby加上增量1是为了可重入锁设置,可重入锁是在加锁的代码块里执行逻辑,可能在加锁的代码块之外的代码还有可能发生并发问题就再尝试加一层锁,当然是同一个线程,是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,而其他的线程是不可以的。成功后返回null,否则判断hash结构(hexists)加上传入的参数,key为锁名称,value为生成的客户端唯一值(this.getLockName(threadId)),如果存在则设置这个结构hincrby为哈希表key中field键的值加上增量1并且设置超时时间为传入参数的时间leaseTime(30s)(可重复锁)。

这里解释一下为什么说传入的leaseTime是30s,上边截图里解释了调用tryAcquireAsync()方法的else代码块,这里传入的时间是 this.internalLockLeaseTime,根据代码跟踪发现this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),继续跟踪发现this.lockWatchdogTimeout = 30000L,如果不做任何设置,就是30s,也可以通过设置redisson里边的config参数,设置其他的超时时间(不建议)。

继续分析tryAcquireAsync()方法,执行完lua脚本加锁或者重置锁的超时时间之后,会调用一个回调方法,这个回调方法会调用一个重置到期时间(锁续命)的方法:

重置到期方法会调用更新过期时间的方法renewExpiration(),该方法主要实现了延时任务,也就是this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS按照不设置超时时间的情况下,默认是30s除去3,就是每10s会执行一次去重置该锁的过期时间。

其中renewExpirationAsync()方法调用一段lua脚本

    protected RFuture<Boolean> renewExpirationAsync(long threadId) {
        return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return 1; " +
                        "end; " +
                        "return 0;",
                Collections.singletonList(getRawName()),
                internalLockLeaseTime, getLockName(threadId));
    }

该脚本大概意思就是根据锁名称和根据线程id生成的唯一标识判断该锁是否还存在,如果存在则重新设置超时时间默认为30s并且返回true,然后继续调用回调方法,回调方法判断如果执行成功的话,继续每隔10s调用renewExpiration(),也就是延时任务自己去续命。

加锁不成功:

我们重新回到加锁的tryAcquireAsync方法,继续分析tryLockInnerAsync方法执行的lua脚本,在如果线程不存在就直接加锁,和锁重复的判断逻辑之后,还有一段脚本return redis.call('pttl', KEYS[1]),意思是返回该锁剩余的过期时间,假如加锁成功之后过期时间是30s,过了5s,剩余时间就是25s。

重新回到最开始的lock方法,在尝试加锁不成功之后返回超时时间,如果超时时间不为null(lua脚本执行的时候如果加锁成功会返回nil 也就是null),会执行while(true)循环进行等待加锁。

其中this.commandExecutor.getNow(future)).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); latch()方法会返回一个Semaphore(信号量),然后调用tryAcquire方法进行等待(许可)上边尝试加锁返回的锁的剩余的超时时间(返回的ttl),等待的过程中不会占用cpu,就算1000个线程在等待也会让出cpu空间,不会耗费性能,等待结束后继续调用tryAcquire()方法尝试加锁,也就是上图所示的间歇性尝试加锁(自旋)

但是这样还有有一个问题,那就是如果在获取到返回的超时时间之后,假如返回的超时时间是20s,在这20s内锁被释放掉了,难道该线程还需要一直等待20s吗,答案肯定是不需要,有一个地方进行阻塞等待,就肯定需要去唤醒在阻塞的线程继续一起抢锁,这样才能完成的形成一个闭环。

在没有抢到锁的线程会利用redis的订阅功能,订阅一个名叫“redisson_lock__channel”+ 锁的名称的channel频道,等待频道发布消息进行唤醒。

那么什么时候会给频道 发布消息呢,肯定是在unlock方法,释放锁的时候会去在频道发布消息,告诉在等待的线程可以进行抢锁了。进入unlock方法之后发现调用了unlockAsync方法,该方法又调用了unlockInnerAsync方法,unlockInnerAsync方法打开之后发现依旧是一段lua脚本:

    protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                        "return nil;" +
                        "end; " +
                        "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                        "if (counter > 0) then " +
                        "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                        "return 0; " +
                        "else " +
                        "redis.call('del', KEYS[1]); " +
                        "redis.call('publish', KEYS[2], ARGV[1]); " +
                        "return 1; " +
                        "end; " +
                        "return nil;",
                Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
    }

改lua脚本大概意思是:首先根据this.getRawName()也就是锁名称和this.getLockName(threadId)是以上生成的代表持有锁的唯一标识(客户端标识)判断是否存在,如果不存在代表没有自己的锁,肯定是不能删除的,然后继续判断可重入锁,进行减1,如果大于0则重置超时时间,否则根据锁名称进行删除锁并发布订阅,内容为频道名称和解锁内容(0)并返回1。

发布成功之后,回到刚刚加锁的代码块中回看订阅频道的类:

protected CompletableFuture<RedissonLockEntry> subscribe(long threadId) {
    return this.pubSub.subscribe(this.getEntryName(), this.getChannelName());
}

this.pubSub这个类中去发布的订阅,这个类是LockPubSub,在这个类中有消费onMessage方法,也就是说发布成功之后,当队列有消息监听改队列的线程会调用onMessage方法去消费:

先判断lua脚本里返回的消息是不是0,调用poll方法删除检索并删除队列的元素,如果不为null回调run方法。然后调用getLatch方法获取当初阻塞等待的信号量,调用release()方法唤醒线程去抢锁

为了满足各种需求redisson还提供了其他丰富的api,其中trylock()也是一种尝试加锁的api,但是不会自旋阻塞去一直尝试加锁,没有看门狗(getLockWatchdogTimeout())的逻辑。它会根据设置的时间去尝试加锁,加锁成功返回true,失败返回false,加锁成功后设置的leaseTime就是锁的最长超时时间,三个参数如下:

boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;

waitTime是指在这段时间内尝试加锁会去等锁,比如说设置10s,在10s内加锁成功返回true,失败返回false,超过等待时间,加锁失败后不会发生自旋去一直尝试加锁。

leaseTime是指超时时间,加锁成功后,leaseTime就是锁设置的超时时间。

unit是指时间的单位SECONDS,MINUTES,HOURS....

redis分布式锁在集群中存在的问题

假设线程1在主节点加锁成功,主节点在同步数据到从节点的过程中宕机,重新选举从节点为主节点,这个时候新的主节点是不存在线程1的锁的,这个时候线程2过来加锁成功执行逻辑完成,再来一个线程过来加锁成功,而线程1并发问题还没执行完成,这样的话就又会出现“超卖”的问题,这样的问题我们称为redis主从架构锁失效问题

关于zk(zookeeper):zk也支持集群架构,zk也可以实现分布式锁。假如现在有一个zk集群,主节点是leader,有两个从节点follower1,follower2。从cap(CAP 理论是针对分布式数据库而言的,它是指在一个分布式系统中,一致性(Consistency, C)、可用性(Availability, A)、分区容错性(Partition Tolerance, P)三者不可兼得。)的理论来说redis集群着重于满足ap(如果要可用性高并允许分区,则需放弃一致性。一旦分区发生,节点之间可能会失去联系,为了实现高可用,每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致性。),对可用性满足的多一点,基于zk的集群架构对cp(如果不要求可用性,相当于每个请求都需要在各服务器之间强一致,而分区容错性会导致同步时间无限延长,如此 CP 也是可以保证的。很多传统的数据库分布式事务都属于这种模式。)对数据一致性的要求更高一点,比如说要基于zk集群实现分布式锁,也是去设置key,value和redis类似在主节点进行写数据,但是zk主节点写入数据后并不会直接向客户端返回结果,而是先向从节点follower节点同步数据,从节点同步完成数据后向主节点发送成功消息,主节点收到消息后计算集群内有半数以上节点同步完成数据才会认为数据真的写成功,这个时候才会向客户端反馈成功消息继续执行逻辑,以此来保证数据一致性。这个就是zk的zab协议中的广播——主从同步(主从同步数据比较简单, 当有写操作时,如果是从机接收,会转到主机。做一次转发,保证写都是在主机上进行。主先提议事务,收到过半回复后,再发提交。 主收到写操作时,先本地生成事务为事务生成zxid,然后发给所有follower节点。 当follower收到事务时,先把提议事务的日志写到本地磁盘,成功后返回给leader。 leader收到过半反馈后对事务提交。再通知所有的follower提交事务,follower收到后也提交事务,提交后就可以对客户端进行分发了。),当zk集群的主节点宕机后,也会发生选举,但是zk底层的zab协议中的选举机制决定了会选举出同步数据更多的节点为主节点(后续会解释),所以如果用zk来实现分布式锁的话,上述锁阐述的redis主从架构锁失效问题也就会得到解决,但是相应的因为底层实现的原因性能就会比redis差很多。

选redis还是zk实现分布式锁:首先zk的性能肯定不如redis,但是从分布式锁的角度语义上来说,zk可能更适合一些,所以如果对性能要求比较高的话就选redis,对数据的强一致性有特别严格要求的话就选zk,现在的主流的分布式锁方案还是redis,也有一些办法去减少redis主从架构锁失效问题。

redis的红锁(建议不要使用):

首先需要实现红锁需要有多个redis节点,这个节点最好是奇数节点会对资源的利用率更高。加入说现在有3个redis节点,他们之间的关系是相等的,没有主从、集群关系,都是对等的单节点,过来存储锁也是一样的类似setnx(如果存在key不做操作)的机制,但是需要在所有节点去执行存储的动作,有半数以上的节点返回存储成功客户端才会认为加锁成功,才能走加锁的逻辑。这样做的好处就是因为半数以上成功才算成功的机制就算其中一个节点宕机也不会产生锁失效的问题。但是这样就失去了使用redis 的意义,对性能上也会产生影响。因为集群架构是会立马返回结果,但是这种红锁的机制也是去牺牲了一些可用性去同步多个节点后才会返回结果。对数据一致性会保证的更好一点。

redlock在redisson里边的实现,使用demo如下:

对于redisson中的redlock这样做还是会有问题的,单实例肯定不是很可靠吧?加锁成功之后,结果 Redis 服务宕机了,这不就凉凉~这时候会提出来将 Redis 主从部署。即使是主从,也是存在巧合的!比如说现在为了高可用给每个redis节点加上一个从节点,主从结构中存在明显的竞态:

  1. 客户端 A 从 master 获取到锁
  2. 在 master 将锁同步到 slave 之前,master 宕掉了。
  3. slave 节点被晋级为 master 节点
  4. 客户端 B 取得了同一个资源被客户端 A 已经获取到的另外一个锁。安全失效!

有时候程序就是这么巧,比如说正好一个节点挂掉的时候,多个客户端同时取到了锁。如果你可以接受这种小概率错误,那用这个基于复制的方案就完全没有问题。

当然也可以直接简单粗暴多加一些单节点,但是根据以上的半数以上机制来看节点加的越多,需要存值的节点也就越多,消耗的性能就越多,这样就违背了用redis 的初衷。

那我使用集群呢?

如果还记得前面的内容,应该是知道对集群进行加锁的时候,其实是通过 CRC16 的 hash 函数来对 key 进行取模,将结果路由到预先分配过 slot 的相应节点上。

发现其实还是发到单个节点上的

还有一个问题就是redis宕机后恢复数据的问题,就算不用主从结构,单机节点追求redis高性能的情况下一般设置持久化策略是不会设置立即持久化的,比如aof大多数情况下会设置1s后持久化这样子。假设现在其中一个节点宕机又立即重启的情况下,redis恢复数据如果aof持久化配置的策略不是每一条都存储的情况下,还是有可能丢失数据从而发生以上锁失效问题。

redis作者也对redlock有一定的争议:

https://www.cnblogs.com/liuzhihang/p/15003362.html

结论

Redisson RedLock 是基于联锁 MultiLock 实现的,但是使用过程中需要自己判断 key 落在哪个节点上,对使用者不是很友好。

Redisson RedLock 已经被弃用,直接使用普通的加锁即可,会基于 wait 机制将锁同步到从节点,但是也并不能保证一致性。仅仅是最大限度的保证一致性。

分布式锁的优化:分布式锁从底层来讲就是把并行执行的请求给串行化了,因为redis是单线程的肯定就不会有并发问题了。

分布式锁一旦加了之后,对同一个商品的下单请求,会导致所有客户端都必须对同一个商品的库存锁key进行加锁。

比如,对iphone这个商品的下单,都必对“iphone_stock”这个锁key来加锁。这样会导致对同一个商品的下单请求,就必须串行化,一个接一个的处理。假设加锁之后,释放锁之前,查库存 -> 创建订单 -> 扣减库存,这个过程性能很高吧,算他全过程20毫秒,这应该不错了。那么1秒是1000毫秒,只能容纳50个对这个商品的请求依次串行完成处理。

缺陷:同一个商品多用户同时下单的时候,会基于分布式锁串行化处理,导致没法同时处理同一个商品的大量下单的请求。

这种方案,要是应对那种低并发、无秒杀场景的普通小电商系统,可能还可以接受。

解决方案:

1.从粒度着手,锁的粒度范围越小越好,加锁的代码越少性能就越高,因为加锁的代码会串行执行,没有必要加锁的代码肯定是让他们并行执行这样效率更高。

2.分段锁。其实说出来也很简单,看过java里的ConcurrentHashMap的源码和底层原理,应该知道里面的 核心思路:分段加锁

把数据分成很多个段,每个段是一个单独的锁,所以多个线程过来并发修改数据的时候,可以并发的修改不同段的数据。不至于说,同一时间只能有一个线程独占修改ConcurrentHashMap中的数据。

另外,Java 8中新增了一个LongAdder类,也是针对Java 7以前的AtomicLong进行的优化,解决的是CAS类操作在高并发场景下,使用乐观锁思路,会导致大量线程长时间重复循环。

LongAdder中也是采用了类似的分段CAS操作,失败则自动迁移到下一个分段进行CAS的思路。

其实分布式锁的优化思路也是类似的,之前我们是在另外一个业务场景下落地了这个方案到生产中,不是在库存超卖问题里用的。

但是库存超卖这个业务场景不错,很容易理解,所以我们就用这个场景来说一下。

分段加锁思想。假如你现在iphone有1000个库存,那么你完全可以给拆成20个库存段,要是你愿意,可以在数据库的表里建20个库存字段,每个库存段是50件库存,比如stock_01对应50件库存,stock_02对应50件库存。类似这样的,也可以在redis之类的地方放20个库存key。

接着,1000个/s 请求,用一个简单的随机算法,每个请求都是随机在20个分段库存里,选择一个进行加锁。

每个下单请求锁了一个库存分段,然后在业务逻辑里面,就对数据库或者是Redis中的那个分段库存进行操作即可,包括查库存 -> 判断库存是否充足 -> 扣减库存。

相当于一个20毫秒,可以并发处理掉20个下单请求,那么1秒,也就可以依次处理掉20 * 50 = 1000个对iphone的下单请求了。

一旦对某个数据做了分段处理之后,有一个坑一定要注意:就是如果某个下单请求,咔嚓加锁,然后发现这个分段库存里的库存不足了,此时咋办?
这时你得自动释放锁,然后立马换下一个分段库存,再次尝试加锁后尝试处理。 这个过程一定要实现。

分布式锁并发优化方案的不足:

最大的不足,很不方便,实现太复杂。

  • 首先,你得对一个数据分段存储,一个库存字段本来好好的,现在要分为20个分段库存字段;
  • 其次,你在每次处理库存的时候,还得自己写随机算法,随机挑选一个分段来处理;
  • 最后,如果某个分段中的数据不足了,你还得自动切换到下一个分段数据去处理。

这个过程都是要手动写代码实现的,还是有点工作量,挺麻烦的。

不过我们确实在一些业务场景里,因为用到了分布式锁,然后又必须要进行锁并发的优化,又进一步用到了分段加锁的技术方案,效果当然是很好的了,一下子并发性能可以增长几十倍。

posted on 2022-03-16 18:38  这就是程序猿的快乐吧  阅读(1104)  评论(0编辑  收藏  举报