分布式锁相信大家一定不会陌生, 想要用好或者自己写一个却没那么简单
想要达到上述的条件, 一定要 掌握分布式锁的应用场景, 以及分布式锁的不同实现, 不同实现之间有什么区别
如果想真正了解分布式锁, 需要结合一定场景; 举个例子, 某夕夕上抢购 AirPods Pro 的 100 元优惠券
如果使用下面这段代码当作抢购优惠券的后台程序, 我们一起看一下, 可能存在什么样的问题
很明显的就是这段流程在并发场景下并不安全, 会导致优惠券发放超过预期, 类似电商抢购超卖问题。分布式情况下只能通过分布式锁 来解决多个服务资源共享的问题了。
分布式锁的定义:
保证同一时间只能有一个客户端对共享资源进行操作。
另外有几点要求也是必须要满足的:
分布式锁实现大致分为三种, Redis、Zookeeper、数据库, 文章以 Redis 展开分布式锁的讨论。
先来构思下分布式锁实现思路
要求:
实现:
向 Redis 中添加一个 lockKey 锁标志位, 如果添加成功则能够继续向下执行扣减优惠券数量操作, 最后再释放此标志位
由于使用的是 Spring 提供的 Redis 封装的 Start 包, 所有有些命令与 Redis 原生命令不相符
1 setIfAbsent(key, val) -> setnx(key, val)
加了简单的几行代码, 一个简单的分布式锁的雏形就出来了。
上面第一版基于 setnx 命令实现分布式锁的缺陷也是很明显的, 那就是一定情况下可能发生死锁
画个图, 举个例子说明哈
上图说明, 线程1在成功获取锁后, 执行流程时异常结束, 没有执行释放锁操作, 这样就会产生死锁。
如果方法执行异常导致的线程被回收, 那么可以将解锁操作放到 finally 块中。但是还有存在死锁问题, 如果获得锁的线程在执行中, 服务被强制停止或服务器宕机, 锁依然不会得到释放。这种极端情况下我们还是要考虑的, 毕竟不能只想着服务没问题对吧。对 Redis 的锁标志位加上过期时间就能很好的防止死锁问题, 继续更改下程序代码。
虽然小红旗处对分布式锁添加了过期时间, 但依然无法避免极端情况下的死锁问题。那就是如果在客户端加锁成功后, 还没有设置过期时间时宕机。如果想要避免添加锁时死锁, 那就对添加锁标志位与添加过期时间命令保证一个原子性, 要么一起成功, 要么一起失败。
我们的添加锁原子命令就要登场了, 从 Redis 2.6.12 版本起, 提供了可选的 字符串 set 复合命令。
1 SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
可选参数如下:
继续完善分布式锁的应用程序, 代码如下:
我使用的 2.0.9.RELEASE 版本的 SpringBoot, RedisTemplate 中不支持 set 复合命令, 所以临时换个 Jedis 来实现。
加锁以及设置过期时间确实保证了原子性, 但是这样的分布式锁就没有问题了么?
我们根据图片以及流程描述设想一下这个场景:
如果线上真的发生上述问题, 那真的是xxx, 更甚者可能存在线程一将线程二的锁释放掉之后, 线程三获取到锁, 然后线程二执行完将线程三的锁释放。
事当如今, 只能创建辨别客户端身份的唯一值了, 将加锁及解锁归一化, 上代码~
这一版的代码相当于我们添加锁标志位时, 同时为每个客户端设置了 uuid 作为锁标志位的 val, 解锁时需要判断锁的 val 是否和自己客户端的相同, 辨别成功才会释放锁。但是上述代码执行业务逻辑如果抛出异常, 锁只能等待过期时间, 我们可以将解锁操作放到 finally 块。
大眼一看, 上上下下实现了四版分布式锁, 也该没问题了吧。
真相就是: 解锁时, 由于判断锁和删除标志位并不是原子性的, 所以可能还是会存在误删。
解决这种非原子操作的方式只能 将判断元素值和删除标志位当作一个原子操作。
很不友好的是, del 删除操作并没有提供原子命令, 所以我们需要想点办法
Redis在 2.6 推出了脚本功能, 允许开发者使用 Lua 语言编写脚本传到 Redis 中执行
原本我们需要向 Redis 服务请求多次命令, 可以将命令写在 Lua 脚本中, 这样执行只会发起一次网络请求
Redis 会将 Lua 脚本中的命令当作一个整体执行, 中间不会插入其它命令
客户端发送的脚步会存储 Redis 中, 其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑
那我们编写一个简单的 Lua 脚本实现原子删除操作。
重点就在 Lua 脚本这一块, 重点说一下这块的逻辑
script 脚本就是我们在 Redis 中执行的 Lua 脚本, 后面跟的两个 List 分别是 KEYS、ARGV。
KEYS[1]: lockKey
ARGV[1]: lockValue
代码不是很多, 也比较简单, 就是在 Java 中代码实现的逻辑放到了一个 Lua 脚本中
1 # 获取 KEYS[1] 对应的 Val 2 local cliVal = redis.call('get', KEYS[1]) 3 # 判断 KEYS[1] 与 ARGV[1] 是否保持一致 4 if(cliVal == ARGV[1]) then 5 # 删除 KEYS[1] 6 redis.call('del', KEYS[1]) 7 return 'OK' 8 else 9 return nil 10 end