最近看了极客时间左耳听风的专栏,对于分布式系统的设计有了更深的认识,准备结合陈皓的总结加上自己看过的资料对于分布式系统进行一个归纳。
在归纳之前,我不得不首先链出另一篇优秀的总结归纳文章,基于Redis的分布式锁到底安全吗,由于百度的搜索算法问题加上这篇文章的标题和Redis强挂钩,因此我几乎是在写完整篇博客最后才看到的这篇文章,而当我看完这篇文章后,我在写这篇博客过程中的不少疑惑也得到了解答。
这篇文章对how-to-do-distributed-locking与Is Redlock safe中的许多细节总结的更加完善,所以你可以首先阅读本文,然后花大概4倍的时间详细阅读基于Redis的分布式锁到底安全吗及本文提供的参考文献。
为什么使用分布式锁?
为什么要使用分布式锁是一个很重要的问题,当你决定在一个系统中采用分布式锁的时候,说明你遇到了真正的麻烦, 因为很多时候,一个悲观的分布式锁实现对于你的系统都不会是最好的解决方案。很多人在举例子说明使用分布式锁的场景的时候都用了银行转账这一场景作为例子。下面让我们来就这一例子进行分析。
在转账类业务中,我们通常有两块DB,一个本地账户DB以及对方账户的DB,很多情况下这两个DB不在一起,因此我们的业务分成两步,一是我方账户扣款,然后发送消息给对方账户,对方账户执行加钱操作后返回确认消息或者失败消息,我方账户再确认或者回滚,这是典型的用MQ实现的最终一致性的分布式事务的方式之一。那么在整个扣款的场景中,分布式锁起到了什么作用,我们看到,并没有用到分布式锁,整个转账的数据一致性是通过MQ加上业务代码实现exactly once语义来实现的(通常是生产者失败重试,消费者实现业务幂等性)。整个转账类业务可以看做一个第三方的提供的API,而它内部的数据一致性并不是通过我们在调用方使用分布式锁来实现的。那么分布式锁究竟需要在哪些场景下使用,又需要取得哪些作用呢?
回顾一下我们使用本地锁的时候,主要是用来做什么。以一个多线程累加器作为例子,多线程不做同步的时候,调用一个i++的代码块10000次,我们最终得到的结果是多少,这显然是一个不确定的值,但是有一点可以肯定,那就是他最终的结果一定不会大于10000(假设i的初始值为0),而加入了同步或者CAS操作后,它最终的结果就是10000了,这说明了我们加锁的最终目的,是为了让我们的每一次操作都被正确的执行,其每次操作的结果,没有被别的线程所覆盖,即我们没有“少做”操作,而不是为了去除重复操作。再说明白一点的就是,在i++这个操作本身没有提供原子性语义的时候,我们通过“加锁”这一操作,让所有的i++都能正确的执行。同样的,我们现在可以来说明在分布式的环境中什么时候需要用到分布式锁了,那就是当我们不同的节点都需要调用一个第三方的API或者服务,而这个服务本身并未提供明确的原子性的语义的时候,我们需要对这里的调用加锁,那么锁到什么时候呢?我认为是锁到这次调用获得了明确的结束返回值的时候。
我认为理解了什么时候使用分布式锁比怎么实现一个分布式锁更加重要,因为大多数时候,锁的开销和节点宕机故障等问题都使得它不是你解决当下业务问题的最好选择,但这并不是说什么时候都不用分布式锁,GFS和Big Table等大型系统都用google的分布式锁服务chubby来解决分布式协作、元数据存储和Master选择等一系列问题。
在How to do distributed locking一文中,作者也对使用分布式锁的一些问题作出了解释。它认为锁定的目的是确保在可能尝试执行相同工作的多个节点中,只有一个实际执行它(至少一次只执行一次)。这项工作可能是将一些数据写入共享存储系统,执行某些计算,调用某些外部API等(分布式锁的三种实现的对比这篇文章列举了两个典型场景)。在较高的层次上,有两个原因可能导致您需要锁定分布式应用程序:效率或正确性。要区分这些情况,需要知道如果锁定失败会发生什么:
- 效率:采取锁定可以避免不必要地执行相同的工作两次(例如,一些昂贵的计算)。如果锁定失败并且两个节点最终完成相同的工作,结果是成本增加(为一项网上购物付了两次的钱)或稍有不便(例如用户最终两次收到相同的电子邮件通知)。
- 正确性:采取锁定可防止并发进程弄乱系统状态。如果锁定失败并且两个节点同时处理同一条数据,则结果会是文件损坏,数据丢失,永久性不一致,给予患者的药物剂量错误或其他一些严重问题。两者都是想要锁的有效案例,但你需要非常清楚你正在处理的两个中的哪一个。
分布式锁的典型实现
首先分布式锁也是一种互斥锁,和我们本地的锁一样,在设计时我们应当考虑到分布式锁需要有的一些特性:
-
互斥性:和我们本地锁一样互斥性是最基本,但是分布式锁需要保证在不同节点的不同线程的互斥。
-
可重入性:同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁。
-
锁超时:和本地锁一样支持锁超时,防止死锁。
-
高效,高可用:加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级。
-
支持阻塞和非阻塞:和 ReentrantLock 一样支持 lock 和 trylock 以及 tryLock(long timeOut)。
-
支持公平锁和非公平锁(可选):公平锁的意思是按照请求加锁的顺序获得锁,非公平锁就相反是无序的。这个一般来说实现的比较少。
分布式锁需要一个中立的节点来提供锁服务,常见的能提供面向多连接的提供互斥性的服务有:数据库、Zookeeper、Redis,除此之外,还有一些自研的针对此种场景的服务,比如google的Chubby(Paxos工程实践--Google chubby设计与实现)。下面分别聊聊以上的典型实现以及容易出现的一些问题。
数据库实现分布式锁
直接使用DB来进行分布式锁服务的比较少,因为DB本身就是我们要重点保护的对象,在高并发场景中,我们往往需要用CDN、缓存、限流等措施来降低对DB层的操作数,这个时候如果我们在应用层直接使用DB来做分布式锁服务,很容易产生性能瓶颈。另外一个问题就是,锁超时释放不好实现,因为如果获取锁的节点故障了,它自己是无法再去判断是否超时的,
言归正传,如果确定要使用DB来做分布式锁服务,一般是通过数据库主键的互斥性来完成的,如下图所示,对于分布式锁我们可以创建一个锁表:
CREATE TABLE `resource_lock` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`resource_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '资源名字',
`node_info` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '机器信息',
`count` int(11) NOT NULL DEFAULT 0 COMMENT '锁的次数',
`desc` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '描述信息',
`update_time` timestamp(0) NOT NULL ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间',
`create_time` timestamp(0) NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `idx_resource_name`(`resource_name`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
然后我们在lock的时候,首先查找一个资源名称是否存在,如果不存在,则获取锁,并插入resourcce_name(表中resource_name是 UK),防止别的节点获取到锁。
搞懂“分布式锁”,看这篇文章就对了有比较详细的伪代码实现。由于基于数据库的分布式锁相对来说不是最优的方案,因此这里也不过多展开,详细的代码后续会放入我的github上。
基于Redis的分布式锁实现
redis的setnx、get和getset等指令提供了实现一个分布式锁的条件,原理是setnx指令只有当某个key不存在时才能成功的set key,因此可以让多个进程并发的去set 同一个key,只有一个进程能设置成功。而其它的进程因为之前有人把 key 设置成功了,而导致失败(也就是获得锁失败)。同时redis的exipre可以设置某条记录的过期自动删除,从而为锁超时释放提供了条件。
我们通过下面的脚本为申请成功的锁解锁:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
上面的代码是说,只有当key对应的value等于自己设置的value时,才会去删除key,这是为了避免client释放了其他client申请的锁,否则就可能出现如下情况:
- ClientA获得锁后准备释放锁时因某种原因超时,没有及时到达Redis.
- 该锁到期 key自动被清除, ClientB获得了锁,重新set了key。
- ClientA的解锁请求到达,该锁对应的key被del,于是ClientC获得了锁
- ClientB和ClientC同时获得了锁。
因此采用lua脚本,对key的value进行判断后再删除,保证了解锁的安全性。而关于value的生成,只要能保证大概率下不会碰撞到即可,官方推荐的是/dev/urandom中取20个byte作为随机数。Redis官方文档说:"直接采用时间戳+客户端编号的方式生成的随机数对绝大多数场景已经足够安全了"。
围绕着setnx这些指令设计一个基于redis的分布式锁并不困难,redis本身支持cluster配置,解决了提供锁的服务端宕机带来的问题。基于redis的分布式锁redlock则是采用5台(可配置)redis服务器并检查多数锁以获取锁,有多种语言的实现版本,其中java语言版本的工业级实现为redisson。
Martin Kleppmann认为如果您仅仅为了提高效率而使用锁,则不必承担Redlock的成本和复杂性,运行5台Redis服务器并检查多数锁以获取锁。最好只使用一个Redis实例,或者使用一个异步复制的辅助实例以防主节点崩溃。
如果使用单个Redis实例,当Redis节点上的电源突然消失,或者出现其他问题,您将丢弃一些锁。但是如果你只是使用锁作为效率优化,并且崩溃不会经常发生,那没什么大不了的。这个“没什么大不了的”情景是Redis闪耀的地方。至少如果您依赖于单个Redis实例,那么每个看系统的人都很清楚锁是近似的,并且仅用于非关键目的。
Martin Kleppmann 对5个副本和多数表决权的Redlock算法提出了质疑,并构造了一个可能出现问题的场景。如下图所示:
前面的例子是ClientA释放锁导致CLientB与ClientC都获得了锁,这里的情况是ClientA与ClientB因为A获得锁超时而出现的问题,导致了ClientA与B都获得了锁,因此ClientB更新的资源被ClientA再次更新而导致数据出错。
而这不是简单脑补的案例,HBase就曾经遇到了这样的问题。通常来说,GC停顿都比较短暂,但是"stop-the-world" GC有时会停顿很长甚至长达分钟级别的停顿,这种长时间的停顿就满足了上面例子设想的情况。你无法通过增加一个对使用的锁的逾期检查来解决这个问题,因为GC可以发生在线程运行的任何时间点,比如恰好在你对逾期检查之后和准备写数据之后。
通过栅栏(fencing)使得锁更安全
解决上面提到的问题需要使用到我们常见的乐观锁机制,即我们需要增加一个字段fencing token在每一次对DB的写入当中,这个字段是每次使用时单调递增的,类似于下图的样子。
5.png
从图中可以看到:
- 锁服务需要有一个单调递增的版本号。
- 写数据的时候,也需要带上自己的版本号。
- 数据库服务需要保存数据的版本号,然后对请求做检查。
使用Zookeeper做锁服务同样会遇到这样的问题,不过在zookeeper中你可以使用zxid或者znode的版本号来做为zookeeper版本的分布式锁的fencing token。
重新审视我们的Redlock算法,它的问题在于,它并没有提供一个设施来生成我们所需的fencing tokens,因此你需要一个额外的组件来生成全局递增的fencing tokens,单纯redis集群和redlock算法无法解决,那么这又带来了一个问题,既然在锁失效的情况下已经存在一种fencing机制能继续保持资源的互斥访问了,那为什么还要使用一个分布式锁并且还要求它提供那么强的安全性保证呢?(Is Redlock safe)。
基于此,Martin Kleppmann 给出了一个比较有见地的结论,即按照锁的目的来区分。Martin得出了如下的结论:
如果是为了效率(efficiency)而使用分布式锁,允许锁的偶尔失效,那么使用单Redis节点的锁方案就足够了,简单而且效率高。Redlock则是个过重的实现(heavyweight)。
如果是为了正确性(correctness)在很严肃的场合使用分布式锁,那么不要使用Redlock。它不是建立在异步模型上的一个足够强的算法,它对于系统模型的假设中包含很多危险的成分(对于timing)。而且,它没有一个机制能够提供fencing token。那应该使用什么技术呢?Martin认为,应该考虑类似Zookeeper的方案,或者支持事务的数据库。
redis的作者Salvatore Sanfilippo网名antirez针对Martin Kleppmann 的问题作出了回应,首先,关于fencing机制。antirez对于Martin的这种论证方式提出了质疑:既然在锁失效的情况下已经存在一种fencing机制能继续保持资源的互斥访问了,那为什么还要使用一个分布式锁并且还要求它提供那么强的安全性保证呢?即使退一步讲,Redlock虽然提供不了Martin所讲的递增的fencing token,但利用Redlock产生的随机字符串(my_random_value)可以达到同样的效果。这个随机字符串虽然不是递增的,但却是唯一的,可以称之为unique token。antirez举了个例子,比如,你可以用它来实现“Check and Set”操作。
基于zookeeper实现的分布式锁
zookeeper具有几个特性:
- 不同的客户端都可以通过对某个节点注册一个watch获得通知的方式进行监视
- 一个节点只能有一个客户端能够成功创建,直到这个借点过期被删除或者主动删除后其余节点才能去竞争创建这个临时节点
- 创建和删除节点会产生一个事务号zxid,这个zxid是单调递增的
从字面上看,zookeeper似乎完全匹配了我们创建一个安全的分布式锁的所有条件,不过更深层次的分析它依然会遇到redis方案中的时钟过期问题。
ZooKeeper是怎么检测出某个客户端已经崩溃了呢?实际上,每个客户端都与ZooKeeper的某台服务器维护着一个Session,这个Session依赖定期的心跳(heartbeat)来维持。如果ZooKeeper长时间收不到客户端的心跳(这个时间称为Sesion的过期时间),那么它就认为Session过期了,通过这个Session所创建的所有的ephemeral类型的znode节点都会被自动删除。
设想如下的执行序列:
客户端1创建了znode节点/lock,获得了锁。
客户端1进入了长时间的GC pause。
客户端1连接到ZooKeeper的Session过期了。znode节点/lock被自动删除。
客户端2创建了znode节点/lock,从而获得了锁。
客户端1从GC pause中恢复过来,它仍然认为自己持有锁。
最后,客户端1和客户端2都认为自己持有了锁,冲突了。这与之前Martin在文章中描述的由于GC pause导致的分布式锁失效的情况类似。
这里不过多的分析zookeeper如何解决这个问题,因为在基于Redis的分布式锁到底安全吗中已经有对其的详细分析了。另外,zookeeper的api对节点的watch操作是比较麻烦的,当一个节点的watch事件被触发后需要重新注册,比较繁琐,幸好开源的客户端Zkclient与apache的curator已经为我们经常使用的场景提供了更为方便好用的api。在很多情况下,我们只需要使用 curator提供的InterProcessMutex类的acquire与release方法就能实现分布式锁的使用。
总结
- 分布式服务的场景是非常复杂的,因此在分析的时候需要慎之又慎,尤其是在使用分布式锁的时候,一定要考虑清楚是否真的需要分布式锁来提供互斥的服务。
- 在使用DB做分布式锁服务的时候,我们需要考虑能否在业务中增加fencing token的列的方式,采用乐观锁的方式实现数据的更新,而不是额外采用一张表在API调用的前端进行分布式悲观锁的实现。
- 我们已经看到了无论采用redis、zookeeper甚至是chubby(文章最前端的blog有分析)都无法保证分布式锁绝对的正确性,但是你应该清楚在哪些情况会给你的分布式锁带来哪种正确性的问题。就像Martin说的那样,如果你需要的是一个为了效率而在业务上有一定容忍度的场景,那么你完全可以采用一个单节点的redis加备份节点来实现分布式锁服务。而如果是为了更高的正确性,你应该明白采用哪些方案(redlock的expire检查,fencing token,chubby的CheckSequencer()检查、lock-delay)来一步步尽量提升锁的正确性。
- 在写这篇关于分布式锁的总结时,重新理解了数学之美的作者吴军在《得到》上面的《第145封信|没有傻问题,只有不懂装懂的人》那篇文章,那篇文章一有这样一个例子,有一个报告者说采用了基于大数据和深度学习做到了准确地预测潜在的信息安全问题,吴军问了一个傻问题,如果不采用深度学习,就采用最简单的统计方法,是否也能取得不错的效果,而事实上,这个答案是肯定的,其结果主要受益于数据本身。分布式系统中很多问题看似简单,实际深究的时候却绕不过去,而我们往往都对它们做了某些假定(比如时钟正确,资源服务器本身不是分布式的,对资源的read then write即是原子操作),而要真的分布式的问题,必须与这些假定的问题硬碰硬,才能找到真正切实有效的方案。
参考文献
[1] Martin Kleppmann : How to do distributed locking
[2] 极客时间.陈皓.《管理设计篇-分布式锁》
[3] antirez Is Redlock safe
[4] 胡飞的学习笔记: 基于Redis的分布式锁到底安全吗
[5] https://news.ycombinator.com/item?id=11066258
网友评论