为什么需要分布式锁
随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,即单个机器上的锁,无法控制影响其他机器的对数据的行为(数据是共享的,但是锁没有共享)。
分布式锁主流的实现方案:
- 基于数据库实现分布式锁
- 基于缓存(
Redis等) - 基于
Zookeeper
分布式锁解决方案都有各自的优缺点
- 性能:
redis最高 - 可靠性:
zookeeper最高
需要满足的四个条件
确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
- 互斥性。在任意时刻,只有一个客户端能持有锁。
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- 专一性,加锁和解锁必须是同一个客户端,解铃还须系铃人,客户端自己不能把别人加的锁给解了。
- 加锁和解锁必须具有原子性。
下面用"公共换衣间"作为例子讲解四个条件。
互斥性
场景解释:
任意时间,换衣间只能有一个人(对应进程)在换衣服。
所以得给换衣间加门锁。
Redis 实现:
通过NX(not exist)操作 :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。
不发生死锁
场景解释:
如果换衣间的人,不小心睡着了(对应进程发生异常),那么外面等待的人,永远进不去换衣间,就陷入了死锁!所以,我们得给锁设定最大持有时间!
Redis 实现
通过 ex(expire) 操作:对锁设置过期时间,SET key value EX seconds
专一性加锁和解锁必须是同一个客户端
场景解释:
如果换衣间的人A睡着了(对应进程A发生异常),一直到锁到期了,下一个人B(对应进程B)进来换衣了,然后A又醒了(进程A恢复了),他径直就打开了门。。。B傻眼了(进程B受到影响)。
所以,我们得使得每个人只能释放自己的锁,不能释放别人的锁!最简单的方式就是,给锁加标识,每个人加锁时,都有一个编号,每次开锁前,得检查是不是自己的编号!
Redis 实现:
setnx 获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否为自己的锁。
加锁和解锁必须具有原子性
场景解释
之前的加锁流程是,进程 A,Step1: 获取到锁后,Step2: 再设置过期时间,但是如果Step 1之后,进程 A 突然异常,那么锁就没有时间限制了,就会死锁。
之前的解锁流程是,进程 A,step1: 释放前检查uuid编号,一致,step2:再释放锁;但是如果step1 之后,锁刚好过期,然后立马被进程B获取了,那么step2释放的将是B的锁!
上述两个问题,根本原因都是加锁和解锁没有原子性。所以得使加锁和解锁具有原子性。
Redis 实现
加锁:可以用同一条语句,实现同时获取锁和设置过期时间,不分步实现:SET key value NX EX seconds
解锁:可以用lua脚本语言,同时实现检查编号和释放锁:
// 定义lua 脚本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; // 一步实现检查编号和释放锁, KEYS[1] ARGV[1]分别是当前编号和之前获取的编号
// 使用redis执行lua执行
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(script);
Tips:Lua 是一个小巧的脚本语言,Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数,Lua并没有提供强大的库,一个完整的Lua解释器不过200k,所以Lua不适合作为开发独立应用程序的语言,而是作为嵌入式脚本语言。
LUA脚本是类似redis事务,有一定的原子性,不会被其他命令插队,可以完成一些redis事务性的操作。通过lua脚本解决争抢问题,实际上是redis 利用其单线程的特性,用任务队列的方式解决多任务并发问题(库存超卖,用乐观锁会有库存遗留问题,用LUA可以实现类似悲观锁的效果)。
