基于 redis 的分布式锁是最常见的分布式锁实现方式, 但是要实现一个严谨的没有并发安全问题 redis 分布式锁也有很多细节需要注意;
不可重入锁
lock
lock 语义需要实现资源的互斥获取, 在 redis 体系下可以使用 set
命令实现; 当某一个请求成功针对目标 lockKey set 了指定的值, 表明抢锁成功, lockKey 对应的 value 是当前请求占据该锁的唯一标识;
set 命令需要确保用 NX 和 EX/PX 实现锁的独占性和可过期, 以 java 为例:1
2
3
4public boolean lock(String lockKey, String value, int expireTime) {
final String result = jedis.set(lockKey, value, "NX" "EX", expireTime);
return "OK".equals(result);
}
unlock
unlock 语义需要实现资源的释放, 在 redis 体系下即需要当 value 为期望值时删除 lockKey; 但是相比于 lock 阶段仅使用 set 命令的原子性, unlock 阶段存在两个逻辑: get
和 del
, 我们必须要使用具有原子效果的方式实现以上两条命令;
如果不考虑原子性, 就会存在如下图所示的线程安全问题:
当一个线程执行完 get 命令时, lockKey 恰好过期删除, 紧接着另外一个线程 lock 抢锁成功, 再次写入 lockKey; 接着第一个线程继续执行 del 命令, 就错误释放了别的线程的锁;
基于 watch / multi
为了解决这个问题, 可以使用 redis 的另外两个命令: watch
和 multi
:
- watch: 监听指定的 key, 当其对应的 value 在事务 exec 执行之前被修改了, 事务将被打断;
- multi: 开启事务, 保证被提交的一组命令一定被执行, 但不保证一定执行成功 (运行时报错不回滚), 也没有隔离级别;
1 | public boolean unlock(String lockKey, String expectValue) throws Exception { |
使用 lua 脚本
redis lua 脚本执行的原子性保证:
- redis 会将整个 lua 脚本作为一个整体, 执行过程中不会被其他 redis 命令打断;
- 同时 redis 使用单个 lua 环境来运行所有 lua 脚本, 确保脚本在执行过程中不会被其他 lua 脚本影响;
1
2
3
4
5
6
7
8
9
10
11def unlock(redis_client, lock_key, lock_value):
# 使用 lua 脚本确保原子性释放锁
lua_script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
redis_client.eval(lua_script, 1, lock_key, lock_value)
return True