在传统单机业务系统中,我们一般通过线程同步方法或同步代码块(Java)解决多线程并发场景资源竞争的问题,但当系统扩展到集群模式的分布式系统上时,需要实现不同主机上多个进程间资源的竞争资源的协调,这时,单进程的锁失效,锁也要能支持分布式。
分布式锁的特性
首先,类比单进程的锁,我们来看下分布式锁要支持那些特性,才是一个可用的解决方案。
基本特性(能用)
- 互斥性:需要保证锁只能被分布式系统中的某台服务器的某个线程获取。
- 不死锁:如果获得锁的线程发生崩溃而没有释放锁,需要保证锁能释放被其他线程获取。
高级特性(好用)
- 可重入:获取锁的线程可多次获得锁,避免死锁。
- 阻塞锁:线程阻塞的锁,简化客户端的实现。
- 高可用:提供获得锁和释放锁的HA。
- 锁性能:高效获得和释放锁。
分布式锁的实现方式
- 数据库:借助数据库实现分布式锁
- Redis:基于Redis的分布式锁
- Zookeeper:基于Zookeeper的分布式锁
借助数据库实现分布式锁
在分布式系统开始大规模应用前,由于数据库都是集中部署的,一些双机部署的系统要实现分布式锁,一般会想到借助数据库实现分布式锁。
比如在双机部署系统中,我们要每天定时给客户发值班通知短信,由于两台服务器时间一致,会同时运行定时任务,但短信总不能发两条。
针对这种情况,可以在数据库中设计一张表,记录某个日期短信是否发送了,表包括日期和短信发生状态两个字段,定时任务启动后,执行如下流程:
- 根据日期字段查询数据库,判断短信是否发送过了,如果没查询到记录,则插入一条数据,状态为todo。
- 尝试通过
select for update
语句,利用数据自带排他锁,锁定记录。如果执行成功则表示获得了锁,继续执行下一步;如果锁定不成功,则程序阻塞。 - 锁定成功后,先判断状态是否为todo,如果是,则执行短信发送任务;如果不是,说明短信已发送,rollback释放锁,直接返回。
- 更新记录状态为done,并通过commit释放锁。
以上是一个特殊的场景,从特殊推导出借助数据库实现锁的一般设计。
-
数据库锁表(lock_table)结构
- ID:主键,自动生成
- lock_name:锁名称,锁的唯一标识
- lock_client:锁的客户端标识,用于重入时使用
- timestamp:锁最后一次更新时间(暂时无用)
- 初始化锁:向lock_table插入锁数据,如果已插入,则直接返回。
- 获取锁:利用数据库排他锁,通过
select * from lock_table where lock_name=xxx for update
获取锁。 - 更新锁:获取到锁后,更新lock_client为自身标识。
- 执行互斥业务:执行锁对应的业务逻辑。
- 释放锁:使用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特性,有两种实现锁的方式。
- 创建节点的排他性:利用创建节点的排他性,多个进程竞争创建一个节点时,只有一个进程能成功获得锁;其他竞争此锁的进程,可通过监听节点的释放来获取锁。
- 临时有序节点:在同一个目录下,每个进程创建自己的的节点,序号最小的节点获得锁,各进程排队获得锁。
原理都比较简单和直观,下面简要描述下临时有序节点方式的实现原理如下:
- 客户端要获取锁是,在Zookeeper指定目录下创建一个瞬时有序节点;
- 判断自己创建的有序节点是否目录下序号最小的,如果是最小的则获得锁,执行业务逻辑;
- 如果不是最小的,则监听目录下节点的删除事件,每次有节点删除后,判断自身是否是序号最小的,从而确认自己是否获得锁;
- 互斥业务逻辑执行完后,删除节点,释放锁。
相关设计点:
- 由于临时节点在客户端断开后自动删除,可解决死锁问题。
- 当自身节点的序号不是最小的时候,通过监听机制,一直等到自身节点序号为最小,可实现阻塞锁。
- 在创建节点是,客户端把自身信息写入节点,获取所有,通过节点信息判断,可实现锁重入。
- 由于ZK本身为集群部署,可解决单点问题,实现HA。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。