在 第1节 分布式锁问题背景 中我们介绍秒杀超卖库存的问题。并给出了synchronized
或 ReentrantLock
的解决方案,但是在大多数场景下无法解决超卖问题,比如:
- MySQL事务会导致
synchronized
失效 - 应用集群部署会导致
synchronized
失效
本篇笔记将介绍利用 Redis 分布式锁来彻底解决该问题,并介绍如何正确的实现一个Redis分布式锁。
1. 用Redis模拟超卖现象
- 引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 业务代码
// @Autowired 是根据类型去找的,所以变量名可以叫 redisTemplate;
// 如果使用 @Resource,则变量名不能叫 redisTemplate
@Autowired
private StringRedisTemplate redisTemplate;
public void decuct() {
// goods:count的值就是商品总量,这里设置成 5
String count = redisTemplate.opsForValue().get("goods:count");
log.info("当前库存值,count = {}", count);
if (!StringUtils.isEmpty(count)) {
Integer goodsCount = Integer.valueOf(count);
if (goodsCount > 0) {
// 扣减库存
redisTemplate.opsForValue().set("goods:count", String.valueOf(--goodsCount));
}
}
}
1.1 问题复现
-
设置redis中的库存为 5
-
同时启动 3 个线程并发扣减库存
-
观察结果
结论:本应该扣除 3 ,通过并发请求发现只扣除了 1
2. redis 分布式锁解决库存扣减问题
2.1 Lua 简单入门
2.1 Redis 中如何解析 Lua 脚本
Redis 从 2.6 版本开始使用 EVAL
命令来对 Lua 脚本解析。
EVAL script numkeys key [key ...] arg [arg ...]
- numkeys : 表示 key 参数的个数
- key : 表示要操作的redis key
- arg : 表示要操作的每个 key 对应的值
2.2 Lua 语法
127.0.0.1:6679> eval "return 'hello Lua'" 0
"hello Lua"
127.0.0.1:6679>
(1)变量定义
Redis 中使用 Lua 脚本时只支持局部变量定义
local a=5
127.0.0.1:6679> eval " local a=5 return a" 0
(integer) 5
127.0.0.1:6679>
(2)分支控制
- if 语句
if 布尔表达式
then
布尔表达式 true 时执行
end
- if...else 语句
if 布尔表达式
then
布尔表达式 true 时执行
else
布尔表达式 false 时执行
end
- if 嵌套
if 布尔表达式1
then
布尔表达式1 为 true 时执行
if 布尔表达式2
then
布尔表达式2 为 true 时执行
end
end
- elseif 嵌套
if 布尔表达式1 then
布尔表达式1 为 true 时执行
elseif 布尔表达式2 then
布尔表达式2 为 true 时执行
else
布尔表达式2 为 false 时执行
end
(3)实战案例
- 案例一
127.0.0.1:6679> eval " if 10 > 20 then return 10 else return 20 end " 0
(integer) 20
127.0.0.1:6679>
- 案例二
127.0.0.1:6679> eval "if KEYS[1] > ARGV[1] then return KEYS[2] else return ARGV[2] end" 2 10 20 30 40
"40"
127.0.0.1:6679>
- 案例三
127.0.0.1:6679> eval "return {10, 20, 30, 40}" 0
1) (integer) 10
2) (integer) 20
3) (integer) 30
4) (integer) 40
127.0.0.1:6679>
- 案例三
127.0.0.1:6679> eval "return { KEYS[1],KEYS[2],ARGV[1],ARGV[2] }" 3 10 20 30 40 50 60
1) "10"
2) "20"
3) "40"
4) "50"
127.0.0.1:6679>
(4)redis.call()
函数使用
127.0.0.1:6679> eval "return redis.call('set','lock','aaa')" 0
OK
127.0.0.1:6679>
127.0.0.1:6679>
127.0.0.1:6679> get lock
"aaa"
127.0.0.1:6679>
或者
127.0.0.1:6679> eval "return redis.call('set',KEYS[1],ARGV[1])" 1 lock aaa
OK
127.0.0.1:6679> get lock
"aaa"
127.0.0.1:6679>
2.2 用Redis锁来防止并发扣减库存
2.2.1 setIfAbsent
方法实现锁
// @Autowired 是根据类型去找的,所以变量名可以叫 redisTemplate;
// 如果使用 @Resource,则变量名不能叫 redisTemplate
@Autowired
private StringRedisTemplate redisTemplate;
public void decuct() {
// 1. 加 redis 锁
String uuid = UUID.randomUUID().toString();
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);
if (!lock) {
log.info("锁获取失败 ...");
return;
}
try {
String count = redisTemplate.opsForValue().get("goods:count");
log.info("当前库存值,count = {}", count);
if (!StringUtils.isEmpty(count)) {
Integer goodsCount = Integer.valueOf(count);
if (goodsCount > 0) {
redisTemplate.opsForValue().set("goods:count", String.valueOf(--goodsCount));
}
}
} finally {
// 2. 释放 redis 锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] "
+ " then "
+ " return redis.call( 'del', KEYS[1] ) "
+ " else "
+ " return 0 "
+ " end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList("lock"), uuid);
log.info("redis 锁释放成功 ...");
}
}
- 当用 Jmeter 启动并发线程请求扣减库存时,可以得到正确的结果,并不会出现超卖的情况!!!
(1)方法 setIfAbsent
原理
String uuid = UUID.randomUUID().toString();
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);
setIfAbsent
方法的底层也是调用SET key value [EX seconds] [PX milliseconds] [NX|XX]
命令
EX
:设置键key的过期时间,单位是秒PX
: 设置键key的过期时间,单位是毫秒NX
: 只有键key不存在的时候才会设置key的值XX
: 只有键key存在的时候才会设置key的值
实际开发中我们通常使用 EX + NX 组合,代码如下:
127.0.0.1:6679> set locked ok EX 60 NX
OK
127.0.0.1:6679> ttl locked
(integer) 52
127.0.0.1:6679>
127.0.0.1:6679> ttl locked
(integer) 47
(2)必须设置锁过期时间
设置过期时间是为了防止死锁的发生。如果应用程序从 Redis 中拿到锁后服务宕机,此时锁没有过期时间,那么就会成为死锁
(3)必须防止锁误删
- 这点很多开发人员都容易忽略。在高并发场景,很容易出现 A 线程删除了 B 线程的锁
- 解决方案就是删除锁之前先判断锁是否属于当前线程的锁,使用 Redis 的
get + del
命令组合
String script = "if redis.call('get', KEYS[1]) == ARGV[1] "
+ " then "
+ " return redis.call( 'del', KEYS[1] ) "
+ " else "
+ " return 0 "
+ " end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList("lock"), uuid);
- 锁的唯一标识最好使用雪花ID、UUID 这样全局唯一的ID
- 为什么要使用Lua脚本
Redis 内置执行Lua脚本,能够保证将组合命令一次性发送到 Redis 服务端,然后 Redis 服务端排队执行,避免了其他命令插队的情况
3. Redis 分布式可重入锁
利用 Redis 分布式锁解决秒杀超卖问题,可以说能够满足日常大部分开发功能的需要。但是在极少数场景中也可能会出现死锁,此时就需要可重入锁 !!!
- 实现 Redis 分布式可重入锁的思路可参考JDK的
ReentrantLock
非公平可重入锁的思路,ReentrantLock的实现原理 - 利用 Redis 的
hash
数据类型 + Lua 脚本 来 Redis 分布式可重入锁
3.1 加锁实现
- 使用
EXISTS
命令判断锁是否存在,不存在直接上锁HSET key field value
- 如果锁存在,使用
HEXISTS
命令判断是否是当前线程的锁,如果是当前线程的锁则重入HINCRBY key field increment
Lua 脚本如下:
if redis.call('exists', 'lock') == 0
then
redis.call('hset', 'lock', uuid, 1)
redis.call('expire', 'lock', 10)
return 1
elseif redis.call('hexists', 'lock', uuid) == 1
then
redis.call('hincrby', 'lock', 1)
redis.call('expire', 'lock', 10)
return 1
else
return 0
end
-- 上述代码可优化
if redis.call('exists', 'lock') == 0 or redis.call('hexists', 'lock', uuid) == 1
then
redis.call('hincrby', 'lock', 1)
redis.call('expire', 'lock', 10)
return 1
else
return 0
end
Redis可执行脚本如下:
String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 "
+ " then "
+ " redis.call('hincrby', KEYS[1], ARGV[1], 1) "
+ " redis.call('expire', KEYS[1], ARGV[2]) "
+ " return 1 "
+ " else "
+ " return 0 "
+ " end ";
3.2 释放锁实现
- 使用
hexists
命令判断当前线程的锁是否存在,不存在则返回 nil - 如果当前线程的锁存在,则使用
hincrby
命令减 1 操作,然后判断减 1 后的值是否为 0,为 0 则使用del
命令删除锁并返回 1 - 如果减 1 后的值不为 0,则返回 0
if redis.call('hexists', 'lock', uuid) == 0
then
return nil
elseif redis.call('hincrby', 'lock', uuid, -1) == 0
then
return redis.call('del', 'lock')
else
return 0
end
Redis可执行脚本如下:
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 "
+ " then "
+ " return nil "
+ " elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 "
+ " then "
+ " return redis.call('del', KEYS[1]) "
+ " else "
+ " return 0 "
+ " end";
4. Redis 分布式锁工具类封装
4.1 封装分布式全局ID工具类
- 简单实现可使用 UUID
- 如果有条件,可使用雪花ID
import org.springframework.util.AlternativeJdkIdGenerator;
import java.util.UUID;
public class UuidUtil {
public static String jdkGenerateId() {
return UUID.randomUUID().toString().replace("-", "");
}
public static String highGenerateId() {
AlternativeJdkIdGenerator uuidGenerator = new AlternativeJdkIdGenerator();
return uuidGenerator.generateId().toString().replace("-", "");
}
}
4.2 Redis 锁工具类
@Component
public class RedisLock {
private static final Logger log = LoggerFactory.getLogger(RedisLock.class);
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 加分布式独占锁
* @param expireSeconds 锁过期秒时间
* @return
*/
public boolean tryLock(String key, String value, long expireSeconds) {
try {
boolean lock = redisTemplate.opsForValue().setIfAbsent(key, value, expireSeconds, TimeUnit.SECONDS);
if (lock) {
return true;
}
} catch (Exception e) {
log.error("加 redis 分布式锁异常,key = {}", key, e);
}
return false;
}
/**
* 脚本加分布式锁
* (1) setnx + expire 命令组合
* @return
*/
public boolean tryScriptLock(String key, String value, long expireSeconds) {
String script = "if redis.call('setNX',KEYS[1], ARGV[1]) == 1 "
+ " then "
+ " return redis.call('expire',KEYS[1], ARGV[2]) "
+ " else "
+ " return 0 "
+ " end";
try {
boolean lock = redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(value), String.valueOf(expireSeconds));
if (lock) {
return true;
}
} catch (Exception e) {
log.error("lua脚本加 redis 分布式锁异常,key = {}", key, e);
}
return false;
}
/**
* 释放分布式锁
* @return
*/
public boolean releasesLock(String key, String value) {
final String script = "if redis.call('get', KEYS[1]) == ARGV[1] "
+ " then "
+ " return redis.call( 'del', KEYS[1] ) "
+ " else "
+ " return 0 "
+ " end";
boolean unLock = false;
try {
unLock = redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(key), value);
} catch (Exception e) {
log.error("释放 redis 分布式锁异常,key = {}", key, e);
}
return unLock;
}
/**
* 加分布式可重入锁:hash + lua 脚本实现
* (1) 使用 `exists` 命令判断锁是否存在
* (2) 如果锁存在,使用 `hexists` 命令判断是否是当前线程的锁,如果是当前线程的锁则重入 `hincrby key field increment`
* @param expireSeconds 锁过期秒时间
* @return
*/
public boolean tryReentrantLock(String key, String value, long expireSeconds) {
final String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 "
+ " then "
+ " redis.call('hincrby', KEYS[1], ARGV[1], 1) "
+ " redis.call('expire', KEYS[1], ARGV[2]) "
+ " return 1 "
+ " else "
+ " return 0 "
+ " end ";
try {
boolean lock = redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(key), value, String.valueOf(expireSeconds));
if (lock) {
return true;
}
} catch (Exception e) {
log.error("加 redis 可重入分布式锁异常,key = {}", key, e);
}
return false;
}
/**
* 释放分布式可重入锁
* (1) 使用`hexists`命令判断当前线程的锁是否存在,不存在则返回 nil
* (2) 如果当前线程的锁存在,则使用`hincrby`命令减 1 操作,然后判断减 1 后的值是否为 0,为 0 则使用 `del` 命令删除锁并返回 1
* (3) 如果减 1 后的值不为 0,则返回 0
* @return
*/
public void releasesReentrantLock(String key, String value) {
final String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 "
+ " then "
+ " return nil "
+ " elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 "
+ " then "
+ " return redis.call('del', KEYS[1]) "
+ " else "
+ " return 0 "
+ " end";
try {
Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(key), value);
if (null == result) {
throw new IllegalMonitorStateException("the lock don't belong to the current thread");
}
} catch (Exception e) {
log.error("释放 redis 分布式可重入锁异常,key = {}", key, e);
}
}
/**
* 加分布式可重试锁
* (1) 并发请求时会导致线程阻塞
* (2) 一定程度上保证了锁的可靠性
* @param expireSeconds 锁过期秒时间
* @param retrySeconds 获取锁失败后重试秒时间
* @return
*/
public boolean tryRetryLock(String key, String value, long expireSeconds, long retrySeconds) {
try {
boolean lock = redisTemplate.opsForValue().setIfAbsent(key, value, expireSeconds, TimeUnit.SECONDS);
// 加锁失败后重试
long exitTime = 0;
retrySeconds = retrySeconds > expireSeconds ? expireSeconds : retrySeconds;
long timeout = System.currentTimeMillis() + retrySeconds * 1000;
while ((exitTime = timeout - System.currentTimeMillis()) > 0L && !lock) {
lock = redisTemplate.opsForValue().setIfAbsent(key, value, expireSeconds, TimeUnit.SECONDS);
log.warn("锁重试,退出重试剩余时间,exitTime = {} ms", exitTime);
}
if (lock) {
return true;
}
} catch (Exception e) {
log.error("加 redis 分布式锁异常,key = {}", key, e);
}
return false;
}
}
4.3 可重入锁验证
public void decuctReentrantLock() {
// 1. 加 redis 锁
String uuid = UuidUtil.highGenerateId();
boolean lock = redisLock.tryReentrantLock("reent:lock", uuid, 30);
if (!lock) {
return;
}
try {
String count = redisTemplate.opsForValue().get("goods:count");
log.info("当前库存值,count = {}", count);
if (!StringUtils.isEmpty(count)) {
Integer goodsCount = Integer.valueOf(count);
if (goodsCount > 0) {
redisTemplate.opsForValue().set("goods:count", String.valueOf(--goodsCount));
}
}
this.getLockAgain(uuid);
} finally {
// 2. 释放 redis 锁
redisLock.releasesReentrantLock("reent:lock", uuid);
Map<Object, Object> map3 = redisTemplate.opsForHash().entries("reent:lock");
log.info("当前锁情况3:" + JacksonUtil.toJson(map3));
log.info("redis 锁释放成功 ...");
}
}
public void getLockAgain(String uuid) {
redisLock.tryReentrantLock("reent:lock", uuid, 30);
Map<Object, Object> map = redisTemplate.opsForHash().entries("reent:lock");
log.info("当前锁情况:" + JacksonUtil.toJson(map));
redisLock.releasesReentrantLock("reent:lock", uuid);
Map<Object, Object> map2 = redisTemplate.opsForHash().entries("reent:lock");
log.info("当前锁情况2:" + JacksonUtil.toJson(map2));
}