说说分布式锁

79 阅读4分钟

对于单机多线程来说,在 Java 中,我们通常使用 ReentrantLock 类、synchronized 关键字这类 JDK 自带的 本地锁 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。

在分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁 就诞生了。

实现分布式锁最常用的方法就是基于Redis.

SETNX 命令就可以帮助我们实现一个简易的互斥锁,EXPIRE 设置key的过期时间。若key不存在才会设置key的值,若key已经存在,SETNX 什么都不会做。释放锁就通过 DEL 命令删除key。 一定要保证设置指定 key 的值和过期时间是一个原子操作!!!  不然的话,依然可能会出现锁无法被释放的问题。

Redis的分布式锁存在锁释放的原子性问题。如果不采用原子操作来释放锁,可能会导致误删另一个客户端锁的现象。应对策略是使用Lua脚本来确保释放锁时的原子性。在删除锁之前,先检查锁的持有者是否是当前客户端。

当然,它还存在锁过期时间的问题,需要设置合理的过期时间,业务逻辑中需要较长的时间,可设置锁的自动续期机制(通过定期刷新锁的 TTL(Time to Live))。这就引入了Redission。

Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。

Redission底层是redis的setnx命令和Lua脚本。 它的分布式锁自带自动续期机制,当一个线程获取一个锁并且没有明确指定过期时间时,Redission会启用 **Watch Dog( 看门狗)**机制。

它的工作流程如下:一个线程加锁成功后,会另开一个线程(称为看门狗)进行监控,不断监听持有锁的线程,给线程增加持有锁的时间,也就是“续期”,规则是每隔releaseTime(锁过期时间,默认是30s)的1/3做一次续期(重新设置锁过期时间为releaseTime),手动释放锁时,通知对应线程的Watch dog不需要再监听了。

Redission 实现的分布式锁是可重入的,这样做是为了避免死锁的产生。 这个重入其实就是在内部判断是否是当前线程持有的锁:如果是当前线程持有的,计数+1,释放就在计数上-1.在存储数据的时候采用Hash结构,其中key是当前线程的唯一标识,value是当前线程的重入次数。

所谓可重入锁,也叫递归锁,指的是在同一个线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。防止在同一线程中多次获取锁导致死锁产生。像 Java 中的 synchronizedReentrantLock 都属于可重入锁。

不可重入的分布式锁基本可以满足绝大部分业务场景了,一些特殊的场景可能会需要使用可重入的分布式锁。

在集群情况下,Redission 实现的分布式锁不能解决主从一致性问题。 由于 Redis 集群数据同步到各个节点时是异步的,如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。 可以利用Redission提供的Redlock来解决这个问题,主要作用是:不能只在一个实例上创建锁,应该是在多个redis实例上创建锁,并且要求在大多数redis节点上都成功创建锁,红锁中要求redis的节点数量要过半。但红锁实现比较复杂,性能会变低,运维成本高。

如果业务非要保证数据的强一致性,建议使用zookeeper实现的分布式锁,只是性能会差一些。 ZooKeeper 分布式锁是基于 临时顺序节点 和 Watcher(事件监听器)  实现的。不过,现在很多项目都不会用到 ZooKeeper,如果单纯是因为分布式锁而引入 ZooKeeper 的话,那是不太可取的,不建议这样做,为了一个小小的功能增加了系统的复杂度。