为什么需要分布式锁呢?
分布式锁的存在是为了在分布式系统中同步访问共享资源,确保在高并发情况下,同一时间只有一个客户端或线程能够操作特定的资源,从而避免数据不一致或竞争条件等问题
实现思路。
首先我们必须保证同一时间只有一个客户端(部署的优惠券服务)操作数量加减。其次本次 客户端操作完成后,需要让 其它客户端继续执行:
- 客户端一存放一个标志位,如果添加成功,操作减优惠券数量操作。
- 客户端二添加标志位失败,本次减库存操作失败(或继续尝试获取等)。
- 客户端一优惠券操作完成后,需要将标志位释放,以便其余客户端对库存进行操作。
1. 第一版 setnx
向 Redis 中添加一个 lockKey 锁标志位,如果添加成功则能够继续向下执行扣减优惠券数量操作,最后再释放此标志位。
由于使用的是 Spring 提供的 Redis 封装的 Start 包,所有有些命令与 Redis 原生命令不相符。
setIfAbsent(key,val) -> setnx(key,val)
加了简单的几行代码,一个简单的分布式锁的雏形就出来了。
2. 第二版 expire
上面第一版基于 setnx 命令实现分布式锁的缺陷也是很明显的,那就是 一定情况下可能发生死锁。
画个图,举个例子说明哈。
上图说明,线程 1 在成功获取锁后,执行流程时异常结束,没有执行释放锁操作,这样就会 产生死锁。
如果方法执行异常导致的线程被回收,那么可以将解锁操作放到 finally 块中。
但是还有存在死锁问题,如果获得锁的线程在执行中,服务被强制停止或服务器宕机,锁依然不会得到释放。
这种极端情况下我们还是要考虑的,毕竟不能只想着服务没问题对吧。
对 Redis 的 锁标志位加上过期时间 就能很好的防止死锁问题,继续更改下程序代码。
虽然 小红旗处 对分布式锁添加了过期时间,但依然无法避免极端情况下的死锁问题。
那就是如果在客户端加锁成功后,还没有设置过期时间时宕机。
如果想要避免添加锁时死锁,那就对添加锁标志位 & 添加过期时间命令 保证一个原子性,要么一起成功,要么一起失败。
3. 第三版 set
我们的添加锁原子命令就要登场了,从 Redis 2.6.12 版本起,提供了可选的 字符串 set 复合命令。
SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
可选参数如下:
- EX: 设置超时时间,单位是秒。
- PX: 设置超时时间,单位是毫秒。
- NX: IF NOT EXIST 的缩写,只有 KEY 不存在的前提下 才会设置值。
- XX: IF EXIST 的缩写,只有在 KEY 存在的前提下 才会设置值。
继续完善分布式锁的应用程序,代码如下:
我使用的 2.0.9.RELEASE 版本的 SpringBoot,RedisTemplate 中不支持 set 复合命令,所以临时换个 Jedis 来实现。
加锁以及设置过期时间确实保证了原子性,但是这样的分布式锁就没有问题了么?
我们根据图片以及流程描述设想一下这个场景:
- 线程一获取锁成功,设置过期时间五秒,接着执行业务逻辑;
- 接着线程一获取锁后执行业务流程,执行的时间超过了过期时间,锁标志位过期进行释放,此时线程二获取锁成功;
- 然而此时线程一执行完业务后,开始执行释放锁的流程,然后顺手就把线程二获取的锁释放了。
如果线上真的发生上述问题,就可能会造成线上数据和业务的运行异常,更甚者可能存在线程一将线程二的锁释放掉之后,线程三获取到锁,然后线程二执行完将线程三的锁释放。
4. 第四版 verify value
事当如今,只能创建辨别客户端身份的唯一值了,将加锁及解锁归一化,上代码。
这一版的代码相当于我们添加锁标志位时,同时为每个客户端设置了 uuid 作为锁标志位的 val,解锁时需要判断锁的 val 是否和自己客户端的相同,辨别成功才会释放锁。
但是上述代码执行业务逻辑如果抛出异常,锁只能等待过期时间,我们可以将解锁操作放到 finally 块。
大眼一看,上上下下实现了四版分布式锁,也该没问题了吧。
真相就是: 解锁时, 由于判断锁和删除标志位并不是原子性的,所以可能还是会存在误删。
- 线程一获取锁后,执行流程 balabala... 判断锁也是自家的,这时 CPU 转头去做别的事情了,恰巧线程一的锁过期时间到了;
- 线程二此时顺理成章的获取到了分布式锁,执行业务逻辑 balabala;
- 线程一再次分配到时间片继续执行删除操作。
解决这种非原子操作的方式只能 将判断元素值和删除标志位当作一个原子操作。
5. 第五版 lua
很不友好的是,del 删除操作并没有提供原子命令,所以我们需要想点办法。
Redis 在 2.6 推出了脚本功能,允许开发者使用 Lua 语言编写脚本传到 Redis 中执行。
使用 Lua 脚本有什么好处呢?
- 减少网络开销:原本我们需要向 Redis 服务请求多次命令,可以将命令写在 Lua 脚本中,这样执行只会发起一次网络请求。
- 原子操作:Redis 会将 Lua 脚本中的命令当作一个整体执行,中间不会插入其它命令。
- 复用:客户端发送的脚步会存储 Redis 中,其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑。
那我们编写一个简单的 Lua 脚本实现原子删除操作。
重点就在 Lua 脚本这一块,重点说一下这块的逻辑。
script 脚本就是我们在 Redis 中执行的 Lua 脚本,后面跟的两个 List 分别是 KEYS、ARGV。
cache.eval(script,Lists.newArrayList(lockKey),Lists.newArrayList(lockValue));
- KEYS[1]: lockKey
- ARGV[1]: lockValue
代码不是很多,也比较简单,就是在 Java 中代码实现的逻辑放到了一个 Lua 脚本中。
# 获取 KEYS[1] 对应的 Val
local cliVal = redis.call('get',KEYS[1])
# 判断 KEYS[1] 与 ARGV[1] 是否保持一致
if(cliVal == ARGV[1]) then
# 删除 KEYS[1]
redis.call('del',KEYS[1])
return 'OK'
else
return nil
end
到了这种程度,已经可以放到一些并发量不大的项目中生产使用了。