2

在传统单机业务系统中,我们一般通过线程同步方法或同步代码块(Java)解决多线程并发场景资源竞争的问题,但当系统扩展到集群模式的分布式系统上时,需要实现不同主机上多个进程间资源的竞争资源的协调,这时,单进程的锁失效,锁也要能支持分布式。

分布式锁的特性

首先,类比单进程的锁,我们来看下分布式锁要支持那些特性,才是一个可用的解决方案。
基本特性(能用)

  1. 互斥性:需要保证锁只能被分布式系统中的某台服务器的某个线程获取。
  2. 不死锁:如果获得锁的线程发生崩溃而没有释放锁,需要保证锁能释放被其他线程获取。

高级特性(好用)

  1. 可重入:获取锁的线程可多次获得锁,避免死锁。
  2. 阻塞锁:线程阻塞的锁,简化客户端的实现。
  3. 高可用:提供获得锁和释放锁的HA。
  4. 锁性能:高效获得和释放锁。

分布式锁的实现方式

  1. 数据库:借助数据库实现分布式锁
  2. Redis:基于Redis的分布式锁
  3. Zookeeper:基于Zookeeper的分布式锁

借助数据库实现分布式锁

在分布式系统开始大规模应用前,由于数据库都是集中部署的,一些双机部署的系统要实现分布式锁,一般会想到借助数据库实现分布式锁。
比如在双机部署系统中,我们要每天定时给客户发值班通知短信,由于两台服务器时间一致,会同时运行定时任务,但短信总不能发两条。

针对这种情况,可以在数据库中设计一张表,记录某个日期短信是否发送了,表包括日期和短信发生状态两个字段,定时任务启动后,执行如下流程:

  1. 根据日期字段查询数据库,判断短信是否发送过了,如果没查询到记录,则插入一条数据,状态为todo。
  2. 尝试通过select for update语句,利用数据自带排他锁,锁定记录。如果执行成功则表示获得了锁,继续执行下一步;如果锁定不成功,则程序阻塞。
  3. 锁定成功后,先判断状态是否为todo,如果是,则执行短信发送任务;如果不是,说明短信已发送,rollback释放锁,直接返回。
  4. 更新记录状态为done,并通过commit释放锁。

以上是一个特殊的场景,从特殊推导出借助数据库实现锁的一般设计。

  1. 数据库锁表(lock_table)结构

    1. ID:主键,自动生成
    2. lock_name:锁名称,锁的唯一标识
    3. lock_client:锁的客户端标识,用于重入时使用
    4. timestamp:锁最后一次更新时间(暂时无用)
  2. 初始化锁:向lock_table插入锁数据,如果已插入,则直接返回。
  3. 获取锁:利用数据库排他锁,通过select * from lock_table where lock_name=xxx for update获取锁。
  4. 更新锁:获取到锁后,更新lock_client为自身标识。
  5. 执行互斥业务:执行锁对应的业务逻辑。
  6. 释放锁:使用commit提交事务,释放锁。

以上步骤,实现了如下特性:

  • 如果第三步获取锁失败,则系统会阻塞等待,实现了阻塞锁
  • select for update实现了互斥锁
  • lock_client字段实现可锁可重入
  • 如果客户端端口,事务自动rollback实现了不会死锁

如果要实现高可用,则需要数据库自身支持HA;由于数据库锁开销比较大,锁的性能相当较差。

基于Redis的分布式锁

Redis分布式锁,主要通过Redis的setnx(SET IF NOT EXIST)结合缓存过期时间等特性来实现。

以Spring的RedisTemplate为例,setnx对应发方法为:

Boolean setIfAbsent(K key, V value, Duration timeout)

如果Redis缓存中不存在此Key,则创建,并返回true;如果已经存在,则无动作,返回false。

相关设计点如下:

  • 为了解决可重入问题,我们把这里的value和数据库的lock_client做相同设计;
  • 由于timeout的存在,可以解决死锁问题;
  • 需要注意,如果互斥的业务逻辑,获取锁后,在timeout内未执行完,会导致锁被是否,所以获取锁之后,需要启动一个定时器,在业务执行完成前,定期去延长timeout,防止锁过期;
  • 这是一把非阻塞锁,如果线程获取不到锁,需要自旋;
  • 锁业务逻辑执行完后,通过删除Key来释放锁;
  • 调用setIfAbsent,务必调用带timeout的重载方法,实现创建Key和设置timeout的原子操作,不然可能会出现创建Key后,客户端崩溃而为设置timeout,导致缓存永不过期。

基于Zookeeper的分布式锁

利用Zookeeper特性,有两种实现锁的方式。

  1. 创建节点的排他性:利用创建节点的排他性,多个进程竞争创建一个节点时,只有一个进程能成功获得锁;其他竞争此锁的进程,可通过监听节点的释放来获取锁。
  2. 临时有序节点:在同一个目录下,每个进程创建自己的的节点,序号最小的节点获得锁,各进程排队获得锁。

原理都比较简单和直观,下面简要描述下临时有序节点方式的实现原理如下:

  1. 客户端要获取锁是,在Zookeeper指定目录下创建一个瞬时有序节点;
  2. 判断自己创建的有序节点是否目录下序号最小的,如果是最小的则获得锁,执行业务逻辑;
  3. 如果不是最小的,则监听目录下节点的删除事件,每次有节点删除后,判断自身是否是序号最小的,从而确认自己是否获得锁;
  4. 互斥业务逻辑执行完后,删除节点,释放锁。

相关设计点:

  • 由于临时节点在客户端断开后自动删除,可解决死锁问题。
  • 当自身节点的序号不是最小的时候,通过监听机制,一直等到自身节点序号为最小,可实现阻塞锁
  • 在创建节点是,客户端把自身信息写入节点,获取所有,通过节点信息判断,可实现锁重入
  • 由于ZK本身为集群部署,可解决单点问题,实现HA。

乘着风
107 声望12 粉丝

五岁时,妈妈告诉我,人生的关键在于快乐。上学后,人们问我长大了要做什么,我写下“快乐”。他们告诉我,我理解错了题目,我告诉他们,他们理解错了人生。——约翰·列侬