分布式锁的实现
1. 背景
目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论:任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(partition Tolerance),最多只能同时满足两项。
所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。
在很多场景中,为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发处理的相关的API,但是这些API在分布式场景中就无能为力。单纯的Java API并不能提供分布式锁的实现。目前比较常见的有以下几种方案:
- 基于数据库实现分布式锁
- 基于缓存(redis,memcached,tair)实现分布式锁
- 基于zookeeper实现分布式锁
分布式需要具有如下的特性:
- 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行;
- 锁钥匙可重入锁(避免死锁);
- 锁最好是一把阻塞锁(根据业务需求考虑要不要这条);
- 有高可用的获取锁和释放锁功能;
- 获取锁和释放锁的性能要好;
2. 基于数据库实现分布式锁
2.1 基于数据库表
要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表的数据来实现。
当锁住某个方法或资源时,就在该表中增加一条记录,想要释放锁的时候就删除这条记录。
创建这样一张数据库表:
想要锁住某个方法时,执行以下SQL:
因为method_name是唯一索引,如果有多个请求同时提交到时数据库的话,数据库会保证只有一个操作可以成功,就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
当方法执行完毕周后,想要释放锁的话,需要执行以下SQL:
上面这种简单的实现有以下几个问题:
- 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用;
- 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁;
- 这把锁只能是非阻塞的,因此数据的insert操作,一旦插入失败就会直接报错,没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作;
- 这把锁是非重入的,同一线程在没有释放锁之前无法再次获得该锁,因为数据中数据已经存在了。
当然可以有其他方式解决上面的问题:
- 数据库时单点:搞两个数据库,数据直接双向同步,一旦挂掉快速切换到备库上;
- 没有失效时间:做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍;
- 非阻塞锁:搞一个while循环,直到insert成功在返回成功;
- 非重入锁:在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
2.2 基于数据库排他锁
除了可以通过增删操作数据库表中的记录之外,其实还可以借助数据库中自带的锁来实现分布式锁。
可以通过数据库的排他锁来实现分布式锁,基于MySQL的InnoDB引擎,可以使用一下方法来实现加锁操作:
在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁,当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
(备注:InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。我们希望使用行级锁,就要给method_name添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题,重载方法的话,建议把参数类型也加上)
可以认为获得排他锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,在通过以下方法解锁。
通过connection.commit()操作来释放锁。
这种方法可以有效的解决上面提到定位无法释放锁和阻塞锁的问题。
- 阻塞锁:for update语句会在执行成功后立刻返回,在执行失败时一直处于阻塞状态,直到成功;
- 锁定之后服务宕机,无法释放:使用这种方式,服务宕机之后数据库会自己把锁释放掉。
但是还是无法直接解决数据库单点和可重入问题。
3. 基于缓存实现分布式锁
相比于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点,而且很多缓存是可以集群部署的,可以解决单点问题。
目前有很多成熟的缓存产品,包括Redis,memcached,tair。
以tair为例来分析使用缓存实现分布式锁的方案。
以上实现同样存在几个问题:
- 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在tair中,其他线程无法再获得锁;
- 这把锁只能是非阻塞的,无论成功还是失败都直接返回;
- 这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为使用到的key在tair中已经存在,无法再执行put操作。
4. 基于zookeeper实现分布式锁
基于zookeeper临时有序节点可以实现分布式锁。
大致思想即为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
- 锁无法释放:使用zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在zk上创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
- 非阻塞锁:使用zk可以实现阻塞的锁,客户端可以通过在zk中创建顺序节点,并且在节点上绑定监听器,一旦节点变化,zk会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
- 不可重入:使用zk也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中国的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
- 单点问题:使用zk可以有效的解决单点问题,zk是集群部署,只要集群中有半数以上的机器存活,就可以对外提供服务。
可以使用zk的第三方库Curator客户端,这个客户端中封装了一个可重入的锁服务:
Curator提供的InterProcessMutex是分布式锁的实现。acquire方法用于获取锁,release方法用于释放锁。
使用ZK实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是,其实并不是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。
其实,使用Zookeeper也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。)
使用zk实现分布式锁有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题,实现较为简单。
但是性能上不如使用缓存实现分布式锁。
5. 实例
上面几种方式,哪种方式都无法做到完美,就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,跟不同的应用场景选择最适合自己的才是王道。
实际我们在项目中也遇到了分布式锁的场景:同一用户多个设备需要调用接口进行同步恢复,根据业务场景我们基于数据库(mysql)分布式锁,且该锁不需要支持可重入和阻塞特性。
字段名称 | 类型 | 键 | 说明 |
userId | varchar(255) | 主键 | 用户标示 |
userLock | tinyint |
用户锁 1:已占用 0:未占用 |
|
lockTime | bigint | 获取锁的时间(时间戳形式)解决释放锁失败的问题 |
获取锁的时候,只要执行如下的update语句,如果返回的被更新的记录数大于零,则认为获取锁。
如果更新的时候userLock=0,则同一时刻只能有一个线程更新成功。
如果更新的时候userLock=1,但是已经超时了,则也可以更新,同时也只能有一个线程更新成功(备注:锁释放失败场景,通过超时解决)。
update t_user set userLock = 1, lockTime = unix_timestamp(now()) * 1000
where userId = #{u.userId}
and ( userLock = 0 or ( userLock = 1 and (unix_timestamp(now()) * 1000 - lockTime) > #{cfgLockExpireTime}))