在review导入表数据和结构处的重构时候,看到下面的逻辑,偶然发现了一个问题。
- 表A加写锁,加锁失败就等待。
- 写入数据,涉及大量的数据导入
- 释放锁和异常处理 一个正常的锁,但是当时看到一个很老的注释,大概意思是尝试捕获OOM的异常,将表删除和锁释放掉。 于是发现这里如果因为GC等待时间过程,被Server认为锁失效了那岂不是锁非常不安全了?而这是带有超时时间锁的通病。查找了一下资料发现这个问题很早就讨论过。
首先简单解释一下通用的分布式锁模型。
- Client A 向锁服务提供方(Server X)申请某一个资源锁。
- Server X 确认改资源锁是可获取的,那么就会发送消息给 Client A 通知成功获得锁。
- Server X 确认改资源锁已经被占用,那么就会发送消息给 Client A 通知或者等待此资源锁(这里会有不同实现)
- 为了确保锁的liveness,Client A 拿到的资源锁是有时间。也就是只能在一段时间内拥有资源锁
- 如果需要长期持有锁,Client A需要不断向Server X发送心跳续约。
这个模型中有个天生的缺陷。Client A 与Server X之间通过心跳与租约来确认锁的状态。如果Client A没有主动释放锁的情况下,锁超过租约的期限,Server X会主动认为该资源锁已经释放并分配給其它申请者。这里资源锁的 safety 的条件成立的前提是Client A不再存在。而实际上这个前提在分布式中实在太强太难满足了。网络延迟和 GC 很容易就导致心跳超时,从而导致锁续约失败,而此时Client A还存在没有崩溃。因此会出现下列的场景。
- Client A 和 Client B 同时向(Server X)申请资源锁 W
- Client A获得了锁 W,并且持续发送心跳给锁W 续约,而 Client B一直在等待资源锁 W
- Client A经历了一次长时间的 Full GC,导致长时间没有发送心跳给 Server X。Server X认为锁 W 已经超期然后让 Client B获得了。
- Client A的 Full GC 结束并恢复正常,此时Client A自身仍然持有着锁 W。这里发送了冲突违背了锁的 safety 的要求。
对于这个问题Martin给的处理方式我认为也是有问题的,我打算再单独整理分析一下分布式锁。
神仙打架
关于这个问题有两篇对应文章, How to do distributed locking 和 Is Redlock safe 。 antirez 是 redis 的作者,但是对于分布式锁的处理竟然有这么大的漏洞,也是蛮让人吃惊的。 下面的逻辑上 antirez 针对上述分布式锁问题的处理办法,这里主要针对网络 If you read the Redlock specification, that I hadn’t touched for months, you can see the steps to acquire the lock are:
1. Get the current time.
2. … All the steps needed to acquire the lock …
3. Get the current time, again.
4. Check *if* we are already out of time, or *if* we acquired the lock fast enough.
5. Do some work with your lock.
并且认为第3步会进行一次时间比较,如果 Client A发现自己已经超时,那么就主动放弃拥有锁。这样就确保了分布式锁定 safety 要求了。 这个算法有以下几个破绽
- 以来系统时间,没法排除 Client A和 Server X 关于超时时间都判定。可能Client A时钟走的慢,而 Server X走到快。
- Client A发生时钟跳跃的情况如何处理
- 级别觉得1 和 2问题太少见了,那么如何处理4和5之间关于 GC 时间导致超时。
而且在Martin文中也强调了 GC 会发生在任何时候,是无法预估的,不知道antirez怎么就想出一个这个方法的。在 Hacker News 上真是被喷惨了。
总结
深切体会到,分布式把很多不是问题的变成了问题,把小问题放大成大麻烦。最让我担心的是,这样的代码我们并没有认识到是有问题的,已经存在了许久了。不知道有多少Bug因为这个而引起的,还有多少有问题的写法。