一. 概述

1.1 引言

当前参与的项目中会遇到一些线程安全问题,由于业务是多节点部署的,Java的单机的并发同步手段synchronized和java.util.concurrent包已经不太够用了,这个时候我们需要分布式锁来保证线程安全问题,所以这里学习总结了几种分布式锁的实现思路。

分布式的CAP理论告诉我们任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。一般情况下,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性,只要这个最终时间是在用户可以接受的范围内即可。在很多时候,为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。这里我们主要介绍对象分布式锁,分布式锁的的具体实现方案主要如下三种:

  • 基于数据库的实现

  • 基于缓存(redis)的实现

  • 基于zookeeper的实现

1.2 分布式锁的要求

一个可靠的、高可用的分布式锁需要满足以下几点

  • 互斥性:任意时刻只能有一个客户端拥有锁,不能被多个客户端获取

  • 安全性:锁只能被持有该锁的客户端删除,不能被其它客户端删除

  • 死锁避免:获取锁的客户端因为某些原因而宕机,而未能释放锁,其它客户端也就无法获取该锁,需要有机制来避免该类问题的发生

  • 高可用:当部分节点宕机,客户端仍能获取锁或者释放锁

二. 基于数据库的实现

2.1 基于数据库实现的乐观锁

乐观锁的通常是基于数据版本号来实现的。比如,有个商品表t_goods,有一个字段left_count用来记录商品的库存个数。在并发的情况下,为了保证不出现超卖现象,即left_count不为负数。乐观锁的实现方式为给商品表增加一个版本号字段version,默认为0,每修改一次数据,将版本号加1。

无版本号并发超卖示例:

-- 线程1查询,当前left_count为1,则有记录
select * from t_goods where id = 10001 and left_count > 0
-- 线程2查询,当前left_count为1,也有记录
select * from t_goods  where id = 10001 and left_count > 0
-- 线程1下单成功库存减一,修改left_count为0,
update t_goods set left_count = left_count - 1 where id = 10001
-- 线程2下单成功库存减一,修改left_count为-1,产生脏数据
update t_goods set left_count = left_count - 1 where id = 10001

有版本号的乐观锁示例:

-- 线程1查询,当前left_count为1,则有记录,当前版本号为999
select left_count, version from t_goods where id = 10001 and left_count > 0;
-- 线程2查询,当前left_count为1,也有记录,当前版本号为999
select left_count, version from t_goods where id = 10001 and left_count > 0;
-- 线程1,更新完成后当前的version为1000,update状态为1,更新成功
update t_goods set version = 1000, left_count = left_count-1 where id = 10001 and version = 999;
-- 线程2,更新由于当前的version为1000,udpate状态为0,更新失败,再针对相关业务做异常处理
update t_goods set version = 1000, left_count = left_count-1 where id = 10001 and version = 999;

可以发现,这种和CAS的乐观锁机制是类似的,所不同的是CAS的硬件来保证原子性,而这里是通过数据库来保证单条SQL语句的原子性。顺带一提CAS的ABA问题一般也是通过版本号来解决。

2.2 基于数据库实现的排他锁

基于数据库的排他锁需要通过数据库的唯一性约束UNIQUE KEY来保证数据的唯一性,从而为锁的独占性提供基础。

表结构如下:

CREATE TABLE `distribute_lock` (
   `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
   `unique_mutex` varchar(64) NOT NULL COMMENT '需要锁住的资源或者方法',
   -- `state` tinyint NOT NULL DEFAULT 1 COMMENT '1:未分配;2:已分配
   PRIMARY KEY (`id`),
   UNIQUE KEY `unique_mutex`
);

其中,unique_mutex就是我们需要加锁的对象,需要用UNIQUE KEY来保证此对象唯一。

加锁时增加一条记录:

insert into distribute_lock(unique_mutex) values('mutex_demo'); 

如果当前SQL执行成功代表加锁成功,如果抛出唯一索引异常(DuplicatedKeyException)则代表加锁失败,当前锁已经被其他竞争者获取。

解锁锁时删除该记录:

delete from distribute_lock(unique_mutex) values('muetx_demo');

除了增删记录,也可以通过更新state字段来标识是否获取到锁。

-- 获取锁
update distribute_lock set state = 2 where `unique_mutex` = 'muetx_demo' and state=1;

更新之前需要SELECT确认锁在数据库中存在,没有则创建之。如果创建或更新失败,则说明这个资源已经被别的线程占用了。

2.3 小结

数据库排他锁可能出现的问题及解决思路:

  1. 没有失效时间, 一旦解锁失败,会导致锁记录一直在数据库中,其他线程无法再获得锁。

  • 可通过定时任务清除超时数据来解决

  • 是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。

  • 可通过增加字段记录当前主机信息和当线程信息,

 

  • 这个锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的在线程并不会进入阻塞队列,需要不停自旋直到获得锁,相对耗资源。

 

总的来说,基于数据库的分布式锁,能够满足一些简单的需求,好处是能够少引入依赖,实现较为简单,缺点是性能较低,且难以满足复杂场景下的高并发需求。

三. 基于redis的实现

3.1 基本实现思路

一个简单的分布式锁机制是使用setnx、expire 、del 三个命令的组合来实现的。setnx命令的含义为:当且仅当key不存在时,value设置成功,返回1;否则返回0。另外两个命令,见名知意,就不多做解释了。

# 加锁,设置锁的唯一标识key,返回1说明加锁成功,返回0加锁失败
setnx key value
# 设置锁超时时间为30s,防止死锁
expire key 30
# 解锁, 删除锁
del key

这种思路存在的问题:

  1. setnx和expire的非原子性:如果加锁之后,服务器宕机,导致expire和del均执行不了,会导致死锁。

  2. del导致误删:A线程超时之后未执行完, 锁过期释放;B线程获得锁,此时A线程执行完,执行del将B线程的锁删除。

  3. 锁过期后引起的并发:A线程超时之后未执行完, 锁过期释放;B线程获得锁,此时A、B线程并发执行会导致线程安全问题。

对应的解决思路:

  1. 将加锁和设置锁过期时间做成一个原子性操作

  • 在Redis 2.6.12版本之后,set命令增加了NX可选参数,可替代setnx命令;增加了EX可选参数,可以设置key的同时指定过期时间

  • 或者将两个操作封装在lua脚本中,发送给Redis执行,从而实现操作的原子性。

 

  • 将key的value设置为线程相关信息,del释放锁之前先判断一下锁是不是自己的。(释放和判断不是原子性的,需要封装在lua脚本中)

  • 启动一个守护线程,在后台自动给自己的锁''续期“,执行完成,显式关掉守护进程

 

3.2 redis分布式锁的缺点

在大型的应用中,一般redis服务都是集群形式部署的,由于Slave同步Master是异步的,所以会出现客户端A在Master上加锁,此时Master宕机,Slave没有完成锁的同步,Slave变为Master,客户端B此时可以完成加锁操作。

为了解决这一问题,官方给出了redlock算法,即使这样在一些较复杂的场景下也不能100%保证没有问题。较复杂,留待后续研究。

四. 基于zookeeper的实现

4.1 基本实现思路

zookeeper 是一个开源的分布式协调服务框架,主要用来解决分布式集群中的一致性问题和数据管理问题。zookeeper本质上是一个分布式文件系统,由一群树状节点组成,每个节点可以存放少量数据,且具有唯一性。

zookeeper有四种类型的节点:

  • 持久节点(PERSISTENT)

    • 默认节点类型,断开连接仍然存在

  • 持久顺序节点(PERSISTENT_SEQUENTIAL)

    • 在持久节点的基础上,增加了顺序性。指定创建同名节点,会根据创建顺序在指定的节点名称后面带上顺序编号,以保证节点具有唯一性和顺序性

  • 临时节点(EPHEMERAL)

    • 断开连接后,节点会被删除

  • 临时顺序节点(EPHEMERAL_SEQUENTIAL)

    • 在临时节点的基础上,增加了顺序性。

基于zookeeper实现的分布式锁主要利用了zookeeper临时顺序节点的特性和事件监听机制。主要思路如下:

  1. 创建节点实现加锁,通过节点的唯一性,来实现锁的互斥

  • 如果使用临时节点,节点创建成功表示获取到锁

  • 如果使用临时顺序节点,客户端创建的节点为顺序最小节点,表示获取到锁

 

  • 删除节点实现解锁

  • 通过临时节点的断开连接自动删除的特性来避免持有锁的服务器宕机而导致的死锁

  • 通过节点的顺序性和事件监听机制,大节点监听小节点,形成节点监听链,来实现等待队列(公平锁)

 

其他思路:

  • 不使用监听机制,未获取到锁的线程自旋重试或者失败退出(根据业务决定),可实现非阻塞的乐观锁。

  • 不使用临时顺序节点,而使用临时节点,所有客户端都去监听该临时节点,可实现非公平锁。但是会产生"羊群效应",单个事件,引发多个服务器响应,占用服务器资源和网络带宽,需要根据业务场景选用。

4.2 zookeeper分布式锁的缺点

zookeeper分布式锁有着较好的可靠性,但是也有如下缺点:

  • zookeeper分布式锁是性能可能没有redis分布式锁高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能。

  • 使用zookeeper也有可能带来并发问题,只是并不常见而已。比如,由于网络抖动,客户端与zk集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。

五 总结

上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。

  1. 从实现的复杂性角度(从高到低)zookeeper >= redis> 数据库

  • 数据库实现的分布式锁易于理解和实现,且不会给项目引入其他依赖。zookeeper和redis需要考虑的情况更多,实现相对较为复杂,但是都有现成的分布式锁框架curator和redision,用起来代码反而可能会更简洁。

  • 从性能角度(从高到低)redis>zookeeper > 数据库

  • redis数据存在内存,速度很快;zookeeper虽然数据也存在内存中,但是本身维护节点的一致性。需要耗费一些性能;数据库则只有索引在内存中,数据存于磁盘,性能较差。

  • 从可靠性角度(从高到低)zookeeper > redis > 数据库

  • zookeeper天生设计定位就是分布式协调,强一致性,可靠性较高;redis分布式锁需要较多额外手段去保证可靠性;数据库则较难满足复杂场景的需求。