当前位置:首页 > 问答 > 正文

用Redis做Java读写锁,感觉读写控制还挺有意思的,就是实现细节得琢磨下

前几天在网上看技术文章,看到一篇讲分布式锁的,里面提到了用Redis实现Java风格的读写锁,这个点子一下子让我觉得挺有意思的,我们平时在单台服务器上写多线程程序,用Java自带的ReadWriteLock就能很方便地控制“读”和“写”的并发——可以让很多个线程同时读一个数据,但只允许一个线程来写,而且写的时候不允许读,那如果我们的程序跑在很多台服务器上,怎么实现同样的效果呢?Redis因为速度快、支持原子操作,自然就成了一个不错的选择,把单机的思维搬到分布式环境里,里面的实现细节可真得好好琢磨一下。

最直观的想法是怎么表示“读锁”和“写锁”,在单机环境下,锁就是个内存里的对象,在Redis里,我们得用Key-Value来模拟,一个常见的做法是,为每一个需要加锁的资源(比如一个用户数据)创建两个(或更多)Redis的key,比如说,对于一个资源叫“user:123”,我们可以设计一个key叫lock:user:123:write来表示写锁,用另一个key叫lock:user:123:read来表示读锁的计数,写锁可以简单点,哪个客户端成功设置了值(比如设置成自己的客户端ID),就算拿到了写锁,读锁就麻烦点,因为允许多个客户端同时读,所以需要一个计数器,每来一个读请求,就把计数器加1;读完就减1,计数器大于0,就表示有读锁存在。

关键的问题来了:怎么保证加锁和解锁的原子性?这可是分布式系统的核心难题,尝试获取写锁的时候,我们必须检查当前是否已经有读锁或者其他写锁存在,在Redis里,我们不能先GET一下看看读锁计数是否为0,然后再决定SET写锁,因为这两个操作不是原子的,中间可能被其他客户端的操作插队,这就会导致多个客户端都以为没有锁,然后都成功设置了写锁,锁就失效了,必须用Redis的原子性命令来实现,Lua脚本就成了救命稻草,Redis允许我们用Lua脚本把多个命令打包在一起执行,在这个脚本执行期间,不会有其他命令插进来,我们可以写一个Lua脚本,里面先检查读锁计数和写锁是否存在,如果条件都满足,再设置写锁,这样才算是一个安全的加锁操作,读锁的加锁和解锁同样需要用Lua脚本来保证计数器增减和判断的原子性。

另一个需要琢磨的细节是“锁的归属”问题,在单机锁里,谁加的锁谁释放,很清楚,但在分布式环境下,一个客户端加锁后,可能会因为网络问题或者自身宕机而长时间挂起,导致它加的锁永远无法释放,这就是可怕的死锁,我们必须给锁设置一个过期时间,用Redis的SET命令时,可以加上PX参数来指定毫秒级的超时时间,这样即使客户端崩溃,锁也会在超时后自动释放,避免了系统卡死。

但设置过期时间又引出了新问题,如果一个客户端加锁成功后,业务逻辑执行的时间超过了锁的过期时间,那么锁就会自动失效,被其他客户端获取,这时,原来的客户端执行完逻辑再去释放锁,可能释放的就是别人的锁了,这会造成混乱,在释放锁的时候,也要用Lua脚本:先判断当前锁的值是不是自己当初设置的那个(比如自己的客户端ID),如果是,才删除锁;如果不是,说明锁已经过期并被别人拿走,那就不能删,这就像是用一把唯一的钥匙去开锁,发现锁芯已经换了,就不能硬来了。

还有更复杂的情况,锁续期”的问题,如果某个读操作特别耗时,可能在操作完成前,读锁的计数器就因为过期时间到了而被清空,为了避免这种情况,有的实现会引入一个后台线程,在锁快要过期但业务还没做完时,定期去延长锁的过期时间(俗称“看门狗”机制),但这又把复杂度提升了一个级别,需要小心处理。

还得考虑等待锁的客户端的行为,是直接失败返回,还是阻塞等待?如果等待,怎么实现高效的通知机制?有人可能会想到用Redis的发布订阅(Pub/Sub)功能,当一个锁被释放时,发布一个消息,让所有等待的客户端去争抢,但这又会引发“惊群效应”,大量客户端同时抢锁可能冲垮Redis,所以更常见的做法是让客户端进行简单的、带有随机延迟的重试。

这么一路琢磨下来,看似一个简单的读写锁概念,在分布式环境下要实现得健壮、高效,需要考虑的细节非常多,原子性、死锁预防、锁归属、超时与续期、等待策略,每一个点没处理好都可能埋下坑,也正是通过这些细节的打磨,才能让我们对分布式系统的并发控制有更深刻的理解,用Redis做读写锁,确实是个有趣的实践。

用Redis做Java读写锁,感觉读写控制还挺有意思的,就是实现细节得琢磨下