添加锁和设置有效期要保证有效
因为有多个机器争抢同一把锁,它们使用的 key 都是相同的。
比如:A、B、C、D… 一共 26 台机器都用 goods:1 作为 key 添加锁,只有获得锁才能操作这个共享资源。假设 A 添加成功,但是添加锁和设置有效期的操作是分开的。如果 A 添加锁成功,但是 A 机器宕机了导致没有设置有效期,这个 key 就是永久有效的,而 A 机器重启之后是不会删除这个 key 的。
那接下来的 B、C、D… 一共 25 台机器都在等 A 释放分布式锁,但是 A 又不会释放这个锁,这就导致了死锁。
那为什么这些机器使用的 key 是相同的呢?因为锁要保证唯一性啊,这个其实跟 synchronized 是一样的,要保证锁只能被一个 "人" 使用。
那怎么保证添加锁和设置有效期的操作都有效呢?
- 如果是 String 类型,提供了 set [key] [value] nx ex [timeout] 命令可以同时添加 key 和设置有效期
- 如果不是 String 类型,可以用 Lua 脚本实现这两个操作。
有效时间自动续期
下一个问题来了,key 要添加有效期,这个有效期要设置多长呢?
我知道,让测试人员进行压力测试,取最长响应时间作为有效期。
其实不是很严谨,因为测试环境的情况是有限的,而实际的生产环境的各种情况是无限的,用有限去模拟无限,也不一定准啊。再说了,测试要是什么都测得出来,世界上就不会有 bug 了。
所以,比较推荐的做法是设置一个有效期,然后在有效期内不断检查是否完成任务,如果没有完成就自动续期。这样不需要写死有效期,而是可以动态维护这个有效期。
像 Redisson 的看门狗就是干这个事情的,默认添加 30 秒的有效期,然后每 10 秒钟自动续期。
那接下来是不是要介绍 Redisson 呢?没必要啊,我们可以自己实现这个逻辑嘛,再引一个依赖进来多麻烦啊,还增加了 pom.xml 的体积。
锁重入
什么是锁重入,就是要多次获得同一个锁嘛。
我们可以用 value 来表示锁重入的次数,一开始 value = 1,锁重入就 value += 1,锁释放就让 value -= 1,如果 value == 1 就删除这个 key。
当然,分布式锁可以不支持锁重入,也可以支持,不过学会锁重入怎么写,不支持锁重入就更简单了。
只删自己加的锁
在删除锁的时候要先判断这个锁是不是自己添加的,只能删除自己添加的锁。
因为可能会有以下情况:
- A 获得锁,然后 A 去 work 了,但是因为 A 没有续期导致锁过期释放了。
- 因为锁被释放了,B 获得了锁,这个时候 A finish了要释放锁,如果不做判断就会把 B 的锁释放掉。
- 那接下来 C 又可以获得锁,B 删除锁的时候就会把 C 的锁删掉,那接下来 D、E、F 都可以获得锁了。
这会导致锁没有效果,A 获得了锁,B 也可以拿到锁,它们都可以操作共享资源,还是会有并发问题。
666,左脑攻击右脑的来了,明明前面说了可以自动续期,这里又说没有续期导致锁过期,感觉你是那种上下文只有 128 token 的低级模型。
其实自动续期也不一定可以保证锁永远有效啊,比如说网络问题导致续期的请求没有发送出去,或者系统卡顿,续期线程没有执行也是有可能的。
所以在删除锁之前要判断是不是自己的锁,就相当于给每个锁添加一个唯一标识,根据这个唯一标识判断是不是自己的锁。
代码实现
public class LockClient {
private static final Logger log = LoggerFactory.getLogger(LockClient.class);
public Lock getLock(StringRedisTemplate stringRedisTemplate) {
return new Lock(stringRedisTemplate);
}
class Lock {
private StringRedisTemplate stringRedisTemplate;
private String key;
private String id;
private Thread thread;
private String add = """
— KEYS[1]: key
— ARGV[1]: 唯一标识
— ARGV[2]: 计数器
— ARGV[3]: 锁过期时间
local key = KEYS[1]
if redis.call('EXISTS', key) == 0 then
redis.call('HSET', key, 'id', ARGV[1])
redis.call('HSET', key, 'value', ARGV[2])
redis.call('EXPIRE', key, ARGV[3])
return 1
end
local currentId = redis.call('HGET', key, 'id')
if currentId == ARGV[1] then
local newCount = redis.call('HINCRBY', key, 'value', 1)
return newCount
end
return 0
""";
private String delete = """
— KEYS[1]: key
— ARGV[1]: 唯一标识
local key = KEYS[1]
— 锁不存在
if redis.call('EXISTS', key) == 0 then
return 0
end
— 不是自己的锁
local currentId = redis.call('HGET', key, 'id')
if currentId ~= ARGV[1] then
return 0
end
— 是自己的锁
local count = tonumber(redis.call('HGET', key, 'value'))
— 计数器-1,保留锁
if count > 1 then
local newCount = redis.call('HINCRBY', key, 'value', -1)
return newCount
end
— 删除锁
redis.call('DEL', key)
return -1
""";
public Lock(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public boolean lock(String key) {
if (this.key == null)
this.key = key;
if (this.id == null)
this.id = getId();
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(add);
script.setResultType(Long.class);
Long result = stringRedisTemplate.execute(
script,
Collections.singletonList(key),
id,
"1",
"30"
);
if (result == 1) {
thread = new Thread(() -> {
while (true) {
if (thread.isInterrupted()) {
// 被打断则不需要续期
break;
}
try {
Thread.sleep(Duration.ofSeconds(10));
} catch (InterruptedException e) {
break;
}
stringRedisTemplate.expire(key, 30, TimeUnit.SECONDS);
log.info("key:{},续期成功", key);
}
});
thread.setDaemon(true);
thread.start();
}
return result >= 1;
}
public boolean unlock() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(delete);
script.setResultType(Long.class);
Long result = stringRedisTemplate.execute(
script,
Collections.singletonList(key),
id
);
if (result == 0) {
log.info("锁不存在或者不是自己的锁");
return false;
} else if (result == -1) {
log.info("删除锁成功");
thread.interrupt();
return true;
} else {
log.info("锁重入次数-1");
return true;
}
}
private String getId() {
// 用毫秒级时刻作为唯一标识
return "" + System.currentTimeMillis();
}
}
}
先不讲解代码,先看怎么使用:
LockClient client = new LockClient();
LockClient.Lock lock = client.getLock(stringRedisTemplate);
for (int i = 0; i < 10; i++) {
Thread.sleep(Duration.ofSeconds(5));
lock.lock("goods:1");
System.out.println("添加锁一次");
}
System.out.println("测试释放锁");
for (int i = 0; i < 10; i++) {
Thread.sleep(Duration.ofSeconds(5));
lock.unlock();
System.out.println("释放锁一次");
}
new LockClient 的对象,然后调用 getLock 方法获取 Lock 对象,通过 Lock 对象的 lock 方法和 unlock 方法添加锁、锁重入、释放锁。
那接下来先看 LockClient 和 Lock 的关系:Lock 是 LockClient 的内部类,通过 LockClient 可以获取 Lock 对象。
再来看 Lock 类:
- StringRedisTemplate stringRedisTemplate:操作 Redis
- String key:用户传进来的 key
- String id:唯一标识
- Thread thread:用来实现自动续期的异步线程
- String add/String delete:添加锁、删除锁的 Lua 脚本
id 作为唯一标识,是通过 getId 方法获取的,以当前时间的毫秒级时间戳作为唯一标识。

在 lock 方法中,先执行 add 脚本尝试添加锁、锁重入:

id 只获取一次,因为一个 Lock 对象只能上一次锁,Lock 对象的 key、id、thread 这些属性在第一次添加锁的时候就固定了。
execute 执行的结果返回值 result 表示本次添加锁的结果。
- 如果 result == 1 说明添加锁成功
- 如果 result > 1 说明锁重入成功
- 如果 result == 0 说明添加锁失败
只有在第一次添加锁,也就是 result == 1 的时候才需要开启守护线程,每 10 秒自动续期。在删除锁的时候会打断这个线程表示不需要续期。

在 unlock 方法中,执行 delete 脚本减少锁重入次数、删除锁,根据 result 的取值不同打印不同的信息。






