概述
在分布式场景下,通常需要通过锁来解决共享资源问题,当然能通过无锁来避免就最好,毕竟锁是有性能开销的;当前常用的分布式锁主要有redis和zk,下面主要讲解下两者的实现、场景对比及存在的问题等。
常规方案
zookeeper
zk主要是基于创建临时顺序节点和watch监听机制来实现。
临时顺序节点:保证锁的公平,同时保证锁的释放(客户端断开的话会自动删除节点)
watch机制:等待锁的节点会监听前一个节点的删除事件,这样可以避免羊群效应(所谓羊群效应就是一个节点挂掉,所有节点都去监听,然后做出反应,这样会给服务器带来很大的压力,然而结果却是只能有一个节点获取);这点跟AQS的实现是一致的(FIFO等待队列锁唤醒也是监听前一个节点的事件)
整个大致流程:
- 一个分布式锁通常用一个 znode 来表示,如果节点不存在,则先创建节点。假设这里用 /test/lock 来表示一个需要创建的分布式锁;
- 独占锁的所有客户端,使用锁节点下的子节点来表示;当某个客户端需要占用锁时,就在 /test/lock 节点下面创建一个临时顺序子节点。比如子节点的前缀是 /test/lock/seq-,则第1个占用的锁就是 /test/lock/seq-00000001 ,下一个子节点则是 /test/lock/seq-00000002,以此类推;
- 当客户端创建子节点后,需要获取锁节点下所有的子节点进行判断:判断自己创建的节点是否在所有列表中是序号最小的,如果是,则加锁成功;否则监听前一个子节点的变更事件,等待锁的释放;
- 一旦队列中后面的节点获取到前一个节点的变更事件后,判断自己的节点序号是否是最小的,如果是,则加锁成功,否则继续监听,直到获取锁;
- 锁获得成功后,开始进行业务处理,完成业务处理后,删除掉对应的节点,完成释放锁工作,以便后续节点能捕获到节点事件变更,从而获取锁。
通过zk可以实现锁的公平、锁的可重入以及读写锁(通过对子节点进行读写分组来实现)。
redis
加锁:主要是通过setNX原子互斥性或者lua脚本的原子性来实现;如实现锁的可重入也可以通过lua脚本来实现。另外这里需要设置过期时间,以免程序异常崩溃一直锁住。
释放锁:由于锁是有过期时间的,正常过期时间设置的比业务执行时间多很多,这样确保业务执行完后才释放锁,但是这个时间是很难把握衡量的,所以从逻辑角度来说,在释放锁的时候需要判断当前释放的锁是否是自己加的锁(在加锁的时候加上锁标识,释放的时候进行判断),否则不能释放;这里需要通过lua脚本来保证原子性。
//下面是不可重入的;如果需要可重入则在判断存在且是自己的时候进行计数加,释放的时候进行减数直到最后删除
//加锁
String lua =
"if redis.call(\"EXISTS\",KEYS[1]) == 0 then\n" +
" redis.call(\"SET\",KEYS[1], ARGV[1])\n" +
" redis.call(\"EXPIRE\",KEYS[1], ARGV[2])\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";
//释放锁
String lua =
"if redis.call(\"GET\",KEYS[1]) == ARGV[1] then\n" +
" redis.call(\"DEL\",KEYS[1])\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";
当然针对业务执行时间有可能超过过期时间的问题,也可以通过看门狗(定时续锁的过期时间)的方式解决。JAVA的话整体可以参考Redisson。
注意的点:
1、多命令操作时需要保证原子性;例如通过lua脚本
2、如果是集群模式,多个命令操作需要确保是在同一个节点;如果是lua脚本,可以通过统一key前缀来确保所有key散列到同一节点,例如 {common}:buss:lock, 用“{}”来表示
3、在锁释放时需要判断当前锁释放是自己加的4、针对加锁等待重试时,需要加上随机等待时间,避免羊群效应
对比
指标 | zookeeper | reids |
性能 | zk的加锁和释放锁都是通过操作节点来实现,这样只能在leader节点上操作,同时需要同步到半数以上的follower节点上,这样性能是比较差的 | redis的主从同步是异步的,同时如果是集群的模式,不同的锁能散裂到不同的节点进行操作,即可以有多个操作节点,所以整体性能比zk好很多 |
可靠性 | zk的cp模式保证了一致性,相对比较可靠 | 由上所知,由于主从同步是异步的,当主节点异常的时候,选举新的从节点为主节点,这个时候有可能之前加在原主节点上的锁还没同步到从节点,从而引起锁的丢失,造成同一时间有多个相同的锁,所以可靠性比zk弱 |
可用性 | 弱 | 相当AP模式,好些 |
针对上面提到redis主从异步同步带来的问题,也可以通过redis的红锁来解决(即过半原则),类似NWR(控制一致性)。
问题考虑
在java语言中,由于存在gc,在异常情况下有可能stop the word 的时间比较长,超过了锁的超时时间,这样同时可能产生多个锁的问题。
针对这个问题,当然需要解决gc长时间的问题;另外也可以在业务处理完后结合锁判断(判断锁是否存在或者合法)+ 事务来进行处理。需要注意的是GC有可能发生在任意期间,也许恰巧在判断完后发生;或者又存在网络延迟的问题;这里某些情况只能尽少的减下机率,还是要考虑原子性的问题
其他
- 通过数据库的排他锁来实现, for update
- 好处:无需引入第三方组件,架构简单
- 不足:性能不足
- 另外如果场景符合的话可以考虑CAS来实现无锁,前提是竞争不激烈,否则回滚、重试的代价比较大;同时需要的话可以加上版本号来解决ABA的问题