深圳网站优化技巧,网站编程所用的语言有,seo视频教程百度云,免费下载网页模板前言在分布式系统中#xff0c;由于redis分布式锁相对于更简单和高效#xff0c;成为了分布式锁的首先#xff0c;被我们用到了很多实际业务场景当中。但不是说用了redis分布式锁#xff0c;就可以高枕无忧了#xff0c;如果没有用好或者用对#xff0c;也会引来一些意想… 前言在分布式系统中由于redis分布式锁相对于更简单和高效成为了分布式锁的首先被我们用到了很多实际业务场景当中。但不是说用了redis分布式锁就可以高枕无忧了如果没有用好或者用对也会引来一些意想不到的问题。今天我们就一起聊聊redis分布式锁的一些坑给有需要的朋友一个参考。1 非原子操作使用redis的分布式锁我们首先想到的可能是setNx命令。if (jedis.setnx(lockKey, val) 1) {jedis.expire(lockKey, timeout);
}容易三下五除二我们就可以把代码写好。这段代码确实可以加锁成功但你有没有发现什么问题加锁操作和后面的设置超时时间是分开的并非原子操作。假如加锁成功但是设置超时时间失败了该lockKey就变成永不失效。假如在高并发场景中有大量的lockKey加锁成功了但不会失效有可能直接导致redis内存空间不足。那么有没有保证原子性的加锁命令呢答案是有请看下面。2 忘了释放锁上面说到使用setNx命令加锁操作和设置超时时间是分开的并非原子操作。而在redis中还有set命令该命令可以指定多个参数。String result jedis.set(lockKey, requestId, NX, PX, expireTime);
if (OK.equals(result)) {return true;
}
return false;其中lockKey锁的标识requestId请求idNX只在键不存在时才对键进行设置操作。PX设置键的过期时间为 millisecond 毫秒。expireTime过期时间set命令是原子操作加锁和设置超时时间一个命令就能轻松搞定。nice使用set命令加锁表面上看起来没有问题。但如果仔细想想加锁之后每次都要达到了超时时间才释放锁会不会有点不合理加锁后如果不及时释放锁会有很多问题。分布式锁更合理的用法是手动加锁业务操作手动释放锁如果手动释放锁失败了则达到超时时间redis会自动释放锁。大致流程图如下那么问题来了如何释放锁呢伪代码如下try{String result jedis.set(lockKey, requestId, NX, PX, expireTime);if (OK.equals(result)) {return true;}return false;
} finally {unlock(lockKey);
}需要捕获业务代码的异常然后在finally中释放锁。换句话说就是无论代码执行成功或失败了都需要释放锁。此时有些朋友可能会问假如刚好在释放锁的时候系统被重启了或者网络断线了或者机房断点了不也会导致释放锁失败这是一个好问题因为这种小概率问题确实存在。但还记得前面我们给锁设置过超时时间吗即使出现异常情况造成释放锁失败但到了我们设定的超时时间锁还是会被redis自动释放。但只在finally中释放锁就够了吗3 释放了别人的锁做人要厚道先回答上面的问题只在finally中释放锁当然是不够的因为释放锁的姿势还是不对。哪里不对答在多线程场景中可能会出现释放了别人的锁的情况。有些朋友可能会反驳假设在多线程场景中线程A获取到了锁但如果线程A没有释放锁此时线程B是获取不到锁的何来释放了别人锁之说答假如线程A和线程B都使用lockKey加锁。线程A加锁成功了但是由于业务功能耗时时间很长超过了设置的超时时间。这时候redis会自动释放lockKey锁。此时线程B就能给lockKey加锁成功了接下来执行它的业务操作。恰好这个时候线程A执行完了业务功能接下来在finally方法中释放了锁lockKey。这不就出问题了线程B的锁被线程A释放了。我想这个时候线程B肯定哭晕在厕所里并且嘴里还振振有词。那么如何解决这个问题呢不知道你们注意到没在使用set命令加锁时除了使用lockKey锁标识还多设置了一个参数requestId为什么要需要记录requestId呢答requestId是在释放锁的时候用的。伪代码如下if (jedis.get(lockKey).equals(requestId)) {jedis.del(lockKey);return true;
}
return false;在释放锁的时候先获取到该锁的值之前设置值就是requestId然后判断跟之前设置的值是否相同如果相同才允许删除锁返回成功。如果不同则直接返回失败。换句话说就是自己只能释放自己加的锁不允许释放别人加的锁。这里为什么要用requestId用userId不行吗答如果用userId的话对于请求来说并不唯一多个不同的请求可能使用同一个userId。而requestId是全局唯一的不存在加锁和释放锁乱掉的情况。此外使用lua脚本也能解决释放了别人的锁的问题if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1])
else return 0
endlua脚本能保证查询锁是否存在和删除锁是原子操作用它来释放锁效果更好一些。说到lua脚本其实加锁操作也建议使用lua脚本if (redis.call(exists, KEYS[1]) 0) thenredis.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]);这是redisson框架的加锁代码写的不错大家可以借鉴一下。有趣下面还有哪些好玩的东西4 大量失败请求上面的加锁方法看起来好像没有问题但如果你仔细想想如果有1万的请求同时去竞争那把锁可能只有一个请求是成功的其余的9999个请求都会失败。在秒杀场景下会有什么问题答每1万个请求有1个成功。再1万个请求有1个成功。如此下去直到库存不足。这就变成均匀分布的秒杀了跟我们想象中的不一样。如何解决这个问题呢此外还有一种场景比如有两个线程同时上传文件到sftp上传文件前先要创建目录。假设两个线程需要创建的目录名都是当天的日期比如20210920如果不做任何控制直接并发的创建目录第二个线程必然会失败。这时候有些朋友可能会说这还不容易加一个redis分布式锁就能解决问题了此外再判断一下如果目录已经存在就不创建只有目录不存在才需要创建。伪代码如下try {String result jedis.set(lockKey, requestId, NX, PX, expireTime);if (OK.equals(result)) {if(!exists(path)) {mkdir(path);}return true;}
} finally{unlock(lockKey,requestId);
}
return false;一切看似美好但经不起仔细推敲。来自灵魂的一问第二个请求如果加锁失败了接下来是返回失败还是返回成功呢主要流程图如下显然第二个请求肯定是不能返回失败的如果返回失败了这个问题还是没有被解决。如果文件还没有上传成功直接返回成功会有更大的问题。头疼到底该如何解决呢答使用自旋锁。try {Long start System.currentTimeMillis();while(true) {String result jedis.set(lockKey, requestId, NX, PX, expireTime);if (OK.equals(result)) {if(!exists(path)) {mkdir(path);}return true;}long time System.currentTimeMillis() - start;if (timetimeout) {return false;}try {Thread.sleep(50);} catch (InterruptedException e) {e.printStackTrace();}}
} finally{unlock(lockKey,requestId);
}
return false;在规定的时间比如500毫秒内自旋不断尝试加锁说白了就是在死循环中不断尝试加锁如果成功则直接返回。如果失败则休眠50毫秒再发起新一轮的尝试。如果到了超时时间还未加锁成功则直接返回失败。好吧学到一招了还有吗5 锁重入问题我们都知道redis分布式锁是互斥的。假如我们对某个key加锁了如果该key对应的锁还没失效再用相同key去加锁大概率会失败。没错大部分场景是没问题的。为什么说是大部分场景呢因为还有这样的场景假设在某个请求中需要获取一颗满足条件的菜单树或者分类树。我们以菜单为例这就需要在接口中从根节点开始递归遍历出所有满足条件的子节点然后组装成一颗菜单树。需要注意的是菜单不是一成不变的在后台系统中运营同学可以动态添加、修改和删除菜单。为了保证在并发的情况下每次都可能获取最新的数据这里可以加redis分布式锁。加redis分布式锁的思路是对的。但接下来问题来了在递归方法中递归遍历多次每次都是加的同一把锁。递归第一层当然是可以加锁成功的但递归第二层、第三层...第N层不就会加锁失败了递归方法中加锁的伪代码如下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(level10){this.fun(level,lockKey,requestId);} else {return;}}return;} finally {unlock(lockKey,requestId);}
}如果你直接这么用看起来好像没有问题。但最终执行程序之后发现等待你的结果只有一个出现异常。因为从根节点开始第一层递归加锁成功还没释放锁就直接进入第二层递归。因为锁名为lockKey并且值为requestId的锁已经存在所以第二层递归大概率会加锁失败然后返回到第一层。第一层接下来正常释放锁然后整个递归方法直接返回了。这下子大家知道出现什么问题了吧没错递归方法其实只执行了第一层递归就返回了其他层递归由于加锁失败根本没法执行。那么这个问题该如何解决呢答使用可重入锁。我们以redisson框架为例它的内部实现了可重入锁的功能。古时候有句话说得好为人不识陈近南便称英雄也枉然。我说分布式锁不识redisson便称好锁也枉然。哈哈哈只是自娱自乐一下。由此可见redisson在redis分布式锁中的江湖地位很高。伪代码如下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(level10){this.fun(lock,level);} else {return;}} finally {lock.unlock();}
}上面的代码也许并不完美这里只是给了一个大致的思路如果大家有这方面需求的话以上代码仅供参考。接下来聊聊redisson可重入锁的实现原理。加锁主要是通过以下脚本实现的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)
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]);其中KEYS[1]锁名ARGV[1]过期时间ARGV[2]uuid : threadId可认为是requestId先判断如果锁名不存在则加锁。接下来判断如果锁名和requestId值都存在则使用hincrby命令给该锁名和requestId值计数每次都加1。注意一下这里就是重入锁的关键锁重入一次值就加1。如果锁名存在但值不是requestId则返回过期时间。释放锁主要是通过以下脚本实现的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先判断如果锁名和requestId值不存在则直接返回。如果锁名和requestId值存在则重入锁减1。如果减1后重入锁的value值还大于0说明还有引用则重试设置过期时间。如果减1后重入锁的value值还等于0则可以删除锁然后发消息通知等待线程抢锁。再次强调一下如果你们系统可以容忍数据暂时不一致有些场景不加锁也行我在这里只是举个例子本节内容并不适用于所有场景。6 锁竞争问题如果有大量需要写入数据的业务场景使用普通的redis分布式锁是没有问题的。但如果有些业务场景写入的操作比较少反而有大量读取的操作。这样直接使用普通的redis分布式锁会不会有点浪费性能我们都知道锁的粒度越粗多个线程抢锁时竞争就越激烈造成多个线程锁等待的时间也就越长性能也就越差。所以提升redis分布式锁性能的第一步就是要把锁的粒度变细。6.1 读写锁众所周知加锁的目的是为了保证在并发环境中读写数据的安全性即不会出现数据错误或者不一致的情况。但在绝大多数实际业务场景中一般是读数据的频率远远大于写数据。而线程间的并发读操作是并不涉及并发安全问题我们没有必要给读操作加互斥锁只要保证读写、写写并发操作上锁是互斥的就行这样可以提升系统的性能。我们以redisson框架为例它内部已经实现了读写锁的功能。读锁的伪代码如下RReadWriteLock readWriteLock redisson.getReadWriteLock(readWriteLock);
RLock rLock readWriteLock.readLock();
try {rLock.lock();//业务操作
} catch (Exception e) {log.error(e);
} finally {rLock.unlock();
}写锁的伪代码如下RReadWriteLock readWriteLock redisson.getReadWriteLock(readWriteLock);
RLock rLock readWriteLock.writeLock();
try {rLock.lock();//业务操作
} catch (InterruptedException e) {log.error(e);
} finally {rLock.unlock();
}将读锁和写锁分开最大的好处是提升读操作的性能因为读和读之间是共享的不存在互斥性。而我们的实际业务场景中绝大多数数据操作都是读操作。所以如果提升了读操作的性能也就会提升整个锁的性能。下面总结一个读写锁的特点读与读是共享的不互斥读与写互斥写与写互斥6.2 锁分段此外为了减小锁的粒度比较常见的做法是将大锁分段。在java中ConcurrentHashMap就是将数据分为16段每一段都有单独的锁并且处于不同锁段的数据互不干扰以此来提升锁的性能。放在实际业务场景中我们可以这样做比如在秒杀扣库存的场景中现在的库存中有2000个商品用户可以秒杀。为了防止出现超卖的情况通常情况下可以对库存加锁。如果有1W的用户竞争同一把锁显然系统吞吐量会非常低。为了提升系统性能我们可以将库存分段比如分为100段这样每段就有20个商品可以参与秒杀。在秒杀的过程中先把用户id获取hash值然后除以100取模。模为1的用户访问第1段库存模为2的用户访问第2段库存模为3的用户访问第3段库存后面以此类推到最后模为100的用户访问第100段库存。如此一来在多线程环境中可以大大的减少锁的冲突。以前多个线程只能同时竞争1把锁尤其在秒杀的场景中竞争太激烈了简直可以用惨绝人寰来形容其后果是导致绝大数线程在锁等待。现在多个线程同时竞争100把锁等待的线程变少了从而系统吞吐量也就提升了。需要注意的地方是将锁分段虽说可以提升系统的性能但它也会让系统的复杂度提升不少。因为它需要引入额外的路由算法跨段统计等功能。我们在实际业务场景中需要综合考虑不是说一定要将锁分段。7 锁超时问题我在前面提到过如果线程A加锁成功了但是由于业务功能耗时时间很长超过了设置的超时时间这时候redis会自动释放线程A加的锁。有些朋友可能会说到了超时时间锁被释放了就释放了呗对功能又没啥影响。答错错错。对功能其实有影响。通常我们加锁的目的是为了防止访问临界资源时出现数据异常的情况。比如线程A在修改数据C的值线程B也在修改数据C的值如果不做控制在并发情况下数据C的值会出问题。为了保证某个方法或者段代码的互斥性即如果线程A执行了某段代码是不允许其他线程在某一时刻同时执行的我们可以用synchronized关键字加锁。但这种锁有很大的局限性只能保证单个节点的互斥性。如果需要在多个节点中保持互斥性就需要用redis分布式锁。做了这么多铺垫现在回到正题。假设线程A加redis分布式锁的代码包含代码1和代码2两段代码。由于该线程要执行的业务操作非常耗时程序在执行完代码1的时已经到了设置的超时时间redis自动释放了锁。而代码2还没来得及执行。此时代码2相当于裸奔的状态无法保证互斥性。假如它里面访问了临界资源并且其他线程也访问了该资源可能就会出现数据异常的情况。PS我说的访问临界资源不单单指读取还包含写入那么如何解决这个问题呢答如果达到了超时时间但业务代码还没执行完需要给锁自动续期。我们可以使用TimerTask类来实现自动续期的功能Timer timer new Timer();
timer.schedule(new TimerTask() {Overridepublic void run(Timeout timeout) throws Exception {//自动续期逻辑}
}, 10000, TimeUnit.MILLISECONDS);获取锁之后自动开启一个定时任务每隔10秒钟自动刷新一次过期时间。这种机制在redisson框架中有个比较霸气的名字watch dog即传说中的看门狗。当然自动续期功能我们还是优先推荐使用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秒。如果业务代码到了这个总的过期时间还没有执行完就不再自动续期了。自动续期的功能是获取锁之后开启一个定时任务每隔10秒判断一下锁是否存在如果存在则刷新过期时间。如果续期3次也就是30秒之后业务方法还是没有执行完就不再续期了。8 主从复制的问题上面花了这么多篇幅介绍的内容对单个redis实例是没有问题的。but如果redis存在多个实例。比如做了主从或者使用了哨兵模式基于redis的分布式锁的功能就会出现问题。具体是什么问题假设redis现在用的主从模式1个master节点3个slave节点。master节点负责写数据slave节点负责读数据。本来是和谐共处相安无事的。redis加锁操作都在master上进行加锁成功后再异步同步给所有的slave。突然有一天master节点由于某些不可逆的原因挂掉了。这样需要找一个slave升级为新的master节点假如slave1被选举出来了。如果有个锁A比较悲催刚加锁成功master就挂了还没来得及同步到slave1。这样会导致新master节点中的锁A丢失了。后面如果有新的线程使用锁A加锁依然可以成功分布式锁失效了。那么如何解决这个问题呢答redisson框架为了解决这个问题提供了一个专门的类RedissonRedLock使用了Redlock算法。RedissonRedLock解决问题的思路如下需要搭建几套相互独立的redis环境假如我们在这里搭建了5套。每套环境都有一个redisson node节点。多个redisson node节点组成了RedissonRedLock。环境包含单机、主从、哨兵和集群模式可以是一种或者多种混合。在这里我们以主从为例架构图如下RedissonRedLock加锁过程如下获取所有的redisson node节点信息循环向所有的redisson node节点加锁假设节点数为N例子中N等于5。如果在N个节点当中有N/2 1个节点加锁成功了那么整个RedissonRedLock加锁是成功的。如果在N个节点当中小于N/2 1个节点加锁成功那么整个RedissonRedLock加锁是失败的。如果中途发现各个节点加锁的总耗时大于等于设置的最大等待时间则直接返回失败。从上面可以看出使用Redlock算法确实能解决多实例场景中假如master节点挂了导致分布式锁失效的问题。但也引出了一些新问题比如需要额外搭建多套环境申请更多的资源需要评估一下成本和性价比。如果有N个redisson node节点需要加锁N次最少也需要加锁N/21次才知道redlock加锁是否成功。显然增加了额外的时间成本有点得不偿失。由此可见在实际业务场景尤其是高并发业务中RedissonRedLock其实使用的并不多。在分布式环境中CAP是绕不过去的。CAP指的是在一个分布式系统中一致性Consistency可用性Availability分区容错性Partition tolerance这三个要素最多只能同时实现两点不可能三者兼顾。如果你的实际业务场景更需要的是保证数据一致性。那么请使用CP类型的分布式锁比如zookeeper它是基于磁盘的性能可能没那么好但数据一般不会丢。如果你的实际业务场景更需要的是保证数据高可用性。那么请使用AP类型的分布式锁比如redis它是基于内存的性能比较好但有丢失数据的风险。其实在我们绝大多数分布式业务场景中使用redis分布式锁就够了真的别太较真。因为数据不一致问题可以通过最终一致性方案解决。但如果系统不可用了对用户来说是暴击一万点伤害。最后说一句(求关注别白嫖我)如果这篇文章对您有所帮助或者有所启发的话帮忙扫描下发二维码关注一下您的支持是我坚持写作最大的动力。求一键三连点赞、转发、在看。