diff --git a/Solution.md b/Solution.md index 22c141d..0037fdf 100644 --- a/Solution.md +++ b/Solution.md @@ -2045,22 +2045,13 @@ public void unlock() { ## Redis -https://mp.weixin.qq.com/s?__biz=MzAwMDg2OTAxNg==&mid=2652055114&idx=1&sn=f4d73fa2e294d633224f4d94a0667e70&chksm=8105d1bdb67258ab458389bd23d8e0211d34835f9da3745a0e6c244ec943911220db0c011665&mpshare=1&scene=23&srcid=1014HUmkIFVfi7rEQQ6bmuqH&sharer_sharetime=1634173594040&sharer_shareid=0f9991a2eb945ab493c13ed9bfb8bf4b%23rd - -### 分布式锁的问题 +### 锁的问题 #### 非原子操作 `加锁操作`和后面的`设置超时时间`是分开的,并`非原子操作`。解决方案: -- **set命令** -- **LUA脚本** - - - -#### 忘了释放锁 - -在redis中还有`set`命令是原子操作,加锁和设置超时时间,一个命令就能轻松搞定。 +**方案一:set命令** ```java String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); @@ -2070,7 +2061,7 @@ if ("OK".equals(result)) { return false; ``` -其中: +在redis中还有`set`命令是原子操作,加锁和设置超时时间,一个命令就能轻松搞定。其中: - `lockKey`:锁的标识 - `requestId`:请求id @@ -2078,7 +2069,29 @@ return false; - `PX`:设置键的过期时间为 millisecond 毫秒 - `expireTime`:过期时 -使用`set`命令加锁,表面上看起来没有问题。但如果仔细想想,加锁之后,每次都要达到了超时时间才释放锁,会不会有点不合理?加锁后,如果不及时释放锁,会有很多问题。分布式锁更合理的流程如下: + + +**方案二:LUA脚本** + +```lua +if (redis.call('exists', KEYS[1]) == 0) then + redis.call('hset', KEYS[1], ARGV[2], 1); + redis.call('pexpire', KEYS[1], ARGV[1]); + return nil; +end +if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) + redis.call('hincrby', KEYS[1], ARGV[2], 1); + redis.call('pexpire', KEYS[1], ARGV[1]); + return nil; +end +return redis.call('pttl', KEYS[1]); +``` + + + +#### 忘了释放锁 + +加锁之后,每次都要达到了超时时间才释放锁,不会有点不合理。如果不及时释放锁,会有很多问题。合理流程如下: ![Redis释放锁流程](images/Solution/Redis释放锁流程.jpg) @@ -2086,13 +2099,13 @@ return false; ```java try{ - String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); - if ("OK".equals(result)) { - return true; - } - return false; + String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); + if ("OK".equals(result)) { + return true; + } + return false; } finally { - unlock(lockKey); + unlock(lockKey); } ``` @@ -2100,26 +2113,268 @@ try{ #### 释放了别人的锁 +自己只能释放自己加的锁,不允许释放别人加的锁。 + +**方案一:requestId方案** + +伪代码如下: + +```java +if (jedis.get(lockKey).equals(requestId)) { + jedis.del(lockKey); + return true; +} +return false; +``` + +**方案二:LUA脚本方案** + +```lua +if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) +else + return 0 +end +``` + #### 大量失败请求 +在秒杀场景下,会有什么问题?每1万个同时请求,有1个成功。再1万个同时请求,有1个成功。如此下去,直到库存不足。这就变成均匀分布的秒杀了,跟我们想象中的不一样(应该是谁先来谁得到)。 + +**解决方案:自旋锁** + +在规定的时间,比如500毫秒内,自旋不断尝试加锁(说白了,就是在死循环中,不断尝试加锁),如果成功则直接返回。如果失败,则休眠50毫秒,再发起新一轮的尝试。如果到了超时时间,还未加锁成功,则直接返回失败。 + +```java +try { + Long start = System.currentTimeMillis(); + while(true) { + String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); + if ("OK".equals(result)) { + // 创建订单 + createOrder(); + return true; + } + + long time = System.currentTimeMillis() - start; + if (time>=timeout) { + return false; + } + + try { + Thread.sleep(50); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } +} finally{ + unlock(lockKey,requestId); +} +return false; +``` + #### 锁重入问题 +假设需要获取一颗满足条件的菜单树。需要在接口中从根节点开始,递归遍历出所有满足条件的子节点,然后组装成一颗菜单树。在后台系统中运营同学可以动态添加、修改和删除菜单。为了保证在并发的情况下,每次都可能获取最新的数据,这里可以加redis分布式锁。在递归方法中递归遍历多次,每次都是加的同一把锁。递归第一层当然是可以加锁成功的,但递归第二层、第三层...第N层,不就会加锁失败了? + +递归方法中加锁的伪代码(会出现异常)如下: + +```java +private int expireTime = 1000; +public void fun(int level,String lockKey,String requestId){ + try{ + String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); + if ("OK".equals(result)) { + if(level<=10){ + this.fun(++level,lockKey,requestId); + } else { + return; + } + } + return; + } finally { + unlock(lockKey,requestId); + } +} +``` + +**基于Redisson实现可重入锁** + +伪代码如下: + +```java +private int expireTime = 1000; + +public void run(String lockKey) { + RLock lock = redisson.getLock(lockKey); + this.fun(lock,1); +} + +public void fun(RLock lock,int level){ + try{ + lock.lock(5, TimeUnit.SECONDS); + if(level<=10){ + this.fun(lock,++level); + } else { + return; + } + } finally { + lock.unlock(); + } +} +``` + #### 锁竞争问题 +如果有大量需要写入数据的业务场景,使用普通的redis分布式锁是没有问题的。但如果有些业务场景,写入的操作比较少,反而有大量读取的操作。这样直接使用普通的redis分布式锁,会不会有点浪费性能? + +**读写锁** + +读写锁的特点: + +- **读与读是共享的,不互斥** +- **读与写互斥** +- **写与写互斥** + +我们以redisson框架为例,它内部已经实现了读写锁的功能。读锁的伪代码如下: + +```java +RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock"); +RLock rLock = readWriteLock.readLock(); +try { + rLock.lock(); + //业务操作 +} catch (Exception e) { + log.error(e); +} finally { + rLock.unlock(); +} +``` + +写锁的伪代码如下: + +```java +RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock"); +RLock rLock = readWriteLock.writeLock(); +try { + rLock.lock(); + //业务操作 +} catch (InterruptedException e) { + log.error(e); +} finally { + rLock.unlock(); +} +``` + +将读锁和写锁分开,最大的好处是提升读操作的性能,因为读和读之间是共享的,不存在互斥性。而我们的实际业务场景中,绝大多数数据操作都是读操作。所以,如果提升了读操作的性能,也就会提升整个锁的性能。 + + + +**锁分段** + +此外,为了减小锁的粒度,比较常见的做法是将大锁:`分段`。 + +比如在秒杀扣库存的场景中,现在的库存中有2000个商品,用户可以秒杀。为了防止出现超卖的情况,通常情况下,可以对库存加锁。如果有1W的用户竞争同一把锁,显然系统吞吐量会非常低。 + +为了提升系统性能,我们可以将库存分段,比如:分为100段,这样每段就有20个商品可以参与秒杀。 + +在秒杀的过程中,先把用户id获取hash值,然后除以100取模。模为1的用户访问第1段库存,模为2的用户访问第2段库存,模为3的用户访问第3段库存,后面以此类推,到最后模为100的用户访问第100段库存。 + +![Redis分布式锁-分段锁](images/Solution/Redis分布式锁-分段锁.png) + +**注意**:将锁分段虽说可以提升系统的性能,但它也会让系统的复杂度提升不少。因为它需要引入额外的路由算法,跨段统计等功能。我们在实际业务场景中,需要综合考虑,不是说一定要将锁分段。 + #### 锁超时问题 +如果线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间,这时候redis会自动释放线程A加的锁。 + +**解决方案:自动续期** + +自动续期的功能是获取锁之后开启一个定时任务,每隔10秒判断一下锁是否存在,如果存在,则刷新过期时间。如果续期3次,也就是30秒之后,业务方法还是没有执行完,就不再续期了。 + +我们可以使用`TimerTask`类,来实现自动续期的功能: + +```java +Timer timer = new Timer(); +timer.schedule(new TimerTask() { + @Override + public void run(Timeout timeout) throws Exception { + //自动续期逻辑 + } +}, 10000, TimeUnit.MILLISECONDS); +``` + +获取锁之后,自动开启一个定时任务,每隔10秒钟,自动刷新一次过期时间。这种机制在redisson框架中,有个比较霸气的名字:`watch dog`,即传说中的`看门狗`。当然自动续期功能,我们还是优先推荐使用lua脚本实现,比如: + +```lua +if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then + redis.call('pexpire', KEYS[1], ARGV[1]); + return 1; +end; +return 0; +``` + +**需要**:在实现自动续期功能时,还需要设置一个总的过期时间,可以跟redisson保持一致,设置成30秒。如果业务代码到了这个总的过期时间,还没有执行完,就不再自动续期了。 + #### 主从复制的问题 +如果redis存在多个实例。比如:做了主从或使用了哨兵模式,基于redis的分布式锁的功能,就会出现问题。 + +比如锁A刚加锁成功master就挂了,还没来得及同步到slave上。这样会导致新master节点中的锁A丢失了。后面,如果有新的线程,使用锁A加锁,依然可以成功,分布式锁失效了。 + + + +**解决方案:RedissonRedLock** + +RedissonRedLock解决问题的思路如下: + +1. 需要搭建几套相互独立的redis环境,假如我们在这里搭建了5套 +2. 每套环境都有一个redisson node节点 +3. 多个redisson node节点组成了RedissonRedLock +4. 环境包含:单机、主从、哨兵和集群模式,可以是一种或者多种混合 + +在这里我们以主从为例,架构图如下: + +![RedissonRedLock](images/Solution/RedissonRedLock.png) + +RedissonRedLock加锁过程如下: + +1. 获取所有的redisson node节点信息,循环向所有的redisson node节点加锁,假设节点数为N,例子中N等于5 +2. 如果在N个节点当中,有N/2 + 1个节点加锁成功了,那么整个RedissonRedLock加锁是成功的 +3. 如果在N个节点当中,小于N/2 + 1个节点加锁成功,那么整个RedissonRedLock加锁是失败的 +4. 如果中途发现各个节点加锁的总耗时,大于等于设置的最大等待时间,则直接返回失败 + +从上面可以看出,使用Redlock算法,确实能解决多实例场景中,假如master节点挂了,导致分布式锁失效的问题。但也引出了一些新问题,比如: + +- 需要额外搭建多套环境,申请更多的资源,需要评估一下成本和性价比 +- 如果有N个redisson node节点,需要加锁N次,最少也需要加锁N/2+1次,才知道redlock加锁是否成功。显然,增加了额外的时间成本,有点得不偿失 + + + +**场景选择** + +在实际业务场景,尤其是高并发业务中,RedissonRedLock其实使用的并不多。在分布式环境中,CAP是绕不过去的:一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)。 + +- 如果你的实际业务场景,更需要的是**保证数据一致性**,那么请使用CP类型的分布式锁 + + 比如:zookeeper,它是基于磁盘的,性能可能没那么好,但数据一般不会丢 + +- 如果你的实际业务场景,更需要的是**保证数据高可用性**。那么请使用AP类型的分布式锁 + + 比如:redis,它是基于内存的,性能比较好,但有丢失数据的风险 + +其实,在绝大多数分布式业务场景中,使用redis分布式锁就够了,真的别太较真。因为数据不一致问题,可以通过最终一致性方案解决。但如果系统不可用了,对用户来说是暴击一万点伤害。 + ### LUA+SETNX+EXPIRE diff --git a/images/Solution/RedissonRedLock.png b/images/Solution/RedissonRedLock.png new file mode 100644 index 0000000..073d21a Binary files /dev/null and b/images/Solution/RedissonRedLock.png differ diff --git a/images/Solution/Redis分布式锁-分段锁.png b/images/Solution/Redis分布式锁-分段锁.png new file mode 100644 index 0000000..8e27f6a Binary files /dev/null and b/images/Solution/Redis分布式锁-分段锁.png differ diff --git a/images/Solution/Redis释放锁流程.jpg b/images/Solution/Redis释放锁流程.jpg index 43e167f..e96a9ef 100644 Binary files a/images/Solution/Redis释放锁流程.jpg and b/images/Solution/Redis释放锁流程.jpg differ