前言
实现分布式锁有两个关键点:
- 锁的排他性:同一个锁在被持有的时间段内只能被一个线程持有。
- 锁超时机制:保证持有锁的线程出现异常时(Client失效、服务重启、宕机等)锁不会被永久占用。
分布式锁一般有三种实现方式:
- 数据库锁
- 数据库表记录:
- 锁的唯一性:由数据库的唯一索引保证。加锁时插入一条数据,插入成功表示加锁成功;解锁时删除对应的数据,删除成功表示锁释放成功。
- 乐观锁:
- 锁的唯一性:根据数据表中版本号字段来保证。查询数据时将版本号一并查出,每次更新时,在更新过程中判断之前查询到的版本号与数据库中的版本号是否相同(sql的where条件),如果相同则更新成功,同时版本号加1;如果不相同,则更新失败,说明发生了并发。
- 悲观锁:
- 锁的唯一性:依赖数据库中自带的锁。
- 数据库表记录:
- 基于Redis的分布式锁:
- 场景:满足高性能的场景。
- 举例:Jedis、Redisson
- 基于ZooKeeper的分布式锁:
- 场景:满足强一致、高可用的场景。
- 举例:Netflix的Curator
基于Redis的分布式锁实现
方案一:代码中直接使用 setnx命令 + expire命令 + del命令 来实现[加锁]、[设置锁超时时间]、[解锁] 操作。
存在的问题:
-
锁无法释放:当setnx命令执行成功,但是expire命令没有执行或执行失败(服务重启或网络问题)时,锁就会因为没有设置超时时间导致永远无法释放,造成死锁。
- 解决:
- 方法一:使用lua脚本来保证 加锁和设置超时时间 这两个操作的原子性。
- 方法二:Redis 2.8 之后支持 nx + ex 作为原子操作来执行:set key value ex expireTime nx
- 解决:
- 锁被其它线程释放:
- 解决:加锁(key)时取当前线程的一个标识(每个线程都创建一个uuid作为加锁的标识)作为value,线程在执行 解锁 操作前 先判断锁(key)对应的标识(value)是否为当前线程持有,当线程持有锁的标识时才可以操作解锁。
方案二:代码中使用 lua脚本[setnx命令 + expire命令] + lua脚本[del命令] 来实现 [加锁并且同时设置锁超时时间]、[解锁] 操作。
存在的问题:
- 超时自动解锁导致并发:当线程a获取锁后,线程a的执行时间超过锁的超时时间(eg:JVM出现了STW)时,锁会自动释放。在线程a执行完成前,线程b获取锁并执行,造成线程a和线程b并发执行。
- 解决:
- 将锁的超时时间设置的足够大,保证锁超时自动释放。
- 但是这样做仍然存在问题:当client端获取锁后还没来的急释放就挂掉时,锁不能及时被释放,导致其他client一段时间内不能获取到锁从而无法相应的执行任务。
- 为获取锁的线程增加守护线程(看门狗),将要过期但未释放的锁的超时时间延长。
- 当看门狗线程出现异常时也可能导致锁长时间未释放或出现并发:此时就需要人工介入,通过平台配置或登录机器将锁直接释放。
- 将锁的超时时间设置的足够大,保证锁超时自动释放。
- 解决:
- 锁不可重入:
- 使用 Redis Map 数据结构来实现分布式锁,既存锁的标识也对重入次数进行计数
方案一和方案二共同存在的问题:
主备切换:
- 线程a获取锁成功后,mater还没来的及将锁被获取的指令同步给slave就挂掉了,slave成为新的master,新的master中没有锁的数据,故其它线程可以再次加锁成功。
集群脑裂:
- 集群脑裂是指在哨兵模式或集群模式下,因为网络问题导致 master 节点跟 slave 节点和 sentinel集群(集群模式下持有槽的主节点担任sentinel) 处于不同的网络分区,因为 sentinel集群无法感知到 master 的存在,所以将 slave 节点提升为 master 节点,此时存在两个不同的 master 节点。
方案三:Redisson
基于ZooKeeper实现的分布式锁
举例:Netflix的Curator
- 说明:Curator是ZooKeeper客户端的封装。
- 原理:
- ZooKeeper 分布式锁是基于 临时顺序节点(EPHEMERAL_SEQUENTIAL) 来实现的,锁可理解为 ZooKeeper 上的一个节点,当需要获取锁时,就在这个锁节点下创建一个临时顺序节点。
- 当存在多个客户端同时来获取锁,就按顺序依次创建多个临时顺序节点,但只有排列序号是第一的那个节点能获取锁成功,其他节点则按顺序分别监听前一个节点的变化,当被监听者释放锁时,监听者就可以马上获得锁。
- 节点的临时性特性保证了锁持有者与ZooKeeper断开时强制释放锁。
- 优点:节点的SEQUENTIAL特性避免了锁释放时出现的惊群效应。
基于数据库锁实现的分布式锁
- 数据库表记录:
- 锁的唯一性:
- 由数据库的唯一索引保证。加锁时插入一条数据,插入成功表示加锁成功;解锁时删除对应的数据,删除成功表示锁释放成功。
- 锁超时机制:定时任务,定时清理创建时间在指定范围内的数据。
- 缺点:如果持有锁的时间超过定时任务的清理周期,则锁会被误删,导致锁被提前释放。
- 说明:我们一般不会采用这种方案。
- 锁的唯一性:
- 乐观锁:
- 锁的唯一性:
- 根据数据表中版本号字段来保证。查询数据时将版本号一并查出,每次更新时,在更新过程中判断之前查询到的版本号与数据库中的版本号是否相同(sql的where条件),如果相同则更新成功,同时版本号加1;如果不相同,则更新失败,说明发生了并发。
- 缺点:当应用并发量高的时候,version值在频繁变化,则会导致大量请求失败,影响系统的可用性。
- 场景:适合并发量不高,并且写操作不频繁的场景。
- 锁的唯一性:
- 悲观锁:
- 锁的唯一性:
- 依赖数据库中自带的锁。
- 缺点:每次请求都会额外产生加锁的开销且未获取到锁的请求将会阻塞等待锁的获取,在高并发环境下,容易造成大量请求阻塞,影响系统可用性。
- 场景:适合并发量不高,并且写操作不频繁的场景。
- 锁的唯一性:
数据库锁应用案例
-
库存的初始化、增加、扣减
库存的初始化、增加、扣减:
// 初始化库存
try {
insertInventoryData(inventoryDataMaintainDto);
} catch (DuplicateKeyException e) {
log.info("新增库存记录发生并发,插入操作变为更新操作,key=[{}]", inventoryDataMaintainDto.getKey());
updateInventoryData(inventoryDataMaintainDto);
}
// 增加库存
update inventory
sku_count = sku_count + #{skuCount, jdbcType=BIGINT},
update_time = #{updateTime, jdbcType=TIMESTAMP}
where id=xx
// 扣减库存 - case when:保证不会有负库存
update inventory
sku_count = (
CASE
WHEN (sku_count - #{skuCount, jdbcType=BIGINT} >= 0) THEN
sku_count - #{skuCount, jdbcType=BIGINT}
ELSE
'更新sku库存异常,库存不足'
END
),
update_time = #{updateTime, jdbcType=TIMESTAMP}
where id=xx
// 更新商品信息 - 乐观锁:保证商品信息的正确更新
update sku
version = (
CASE
WHEN (version = #{item.version, jdbcType=BIGINT}) THEN
version + 1
ELSE
'更新sku信息异常'
END),
name = #{name, jdbcType=BIGINT}
where id=xx