最近在规范平台缓存使用时发现,很多业务用到了 reids 分布式锁,但普遍存在一些细节问题,根据这些问题,本文将会尝试去总结分布式锁常见的问题。最后聊聊redis乐观锁。
分布式锁
如果是单机环境,对于并发问题,直接用 java 提供的 synchronized 或 Lock 实现即可,而涉及到多进程环境,那么就需要依赖一个第三方系统来提供锁机制。
redis作为一个缓存中间件系统,就能提供这种分布式锁机制,其本质就是在redis里面占一个坑,当别的进程也要来占坑时,发现已经被占领了,就只要等待稍后再尝试。
在java中我们一般这样用:
boolean result = jedis.setnx("lock-key",String.valueOf(System.currentTimeMillis()))== 1L;
if (result) {
try {
// do something
} finally {
jedis.del("lock-key");
}
}
须知一:潜在死锁
上面程序逻辑存在一个问题,就是如果加锁和解锁中间执行的业务中断,比如服务器挂了,或者线程被杀掉,那么就可能会导致 del 指令没有被调用,这样就会陷入死锁,锁永远得不到释放。
那么实际应用中我们应该给锁加上过期时间,比如5秒,这样即使出现上面说的异常,也可以保证5秒后锁会自动释放。所以程序可以优化成如下:
try {
boolean result = jedis.setnx("lock-key",String.valueOf(System.currentTimeMillis()))== 1L;
if (result) {
jedis.expire("lock-key",5);
// do something
}
} finally {
jedis.del("lock-key");
}
这样写也存在一个问题:由于 setnx 和 expire 非原子性,如果在 setnx 和 expire 之间出现机器挂掉或者是被人为杀掉,就会导致死锁。
加锁正确姿势:
String result = jedis.set("lock-key", String.valueOf(System.currentTimeMillis(), "NX", "PX", 5);
if ("OK".equals(result)) {
return true;
}
return false;
须知二:超时问题
Redis 分布式锁并不能解决超时问题,其实基于 ZooKeeper 实现的分布式锁也没办法避免超时问题。
考虑如下场景,加锁和解锁之间的业务非常耗时,那么就可能存在:
- 线程一拿到锁之后执行业务
- 还没执行完锁就超时过期了
- 线程二此时拿到锁乘虚而入,开始执行业务...
当然这是 redis 分布式锁在死锁和超时问题之间做出的妥协,没办法完全避免,但是需要业务在使用时,衡量加锁的粒度及过期时间。
须知三:可重入性
可重入性是指线程在持有锁的情况下,再次请求持有同一把锁,那么是可以获取到的。在 java 中, synchronized 和 ReentrantLock 都是可重入锁。
redis本身不具备可重入性,如果要支持可重入锁,可以借助 Threadlocal 对请求的 setnx 进行包装,Threadlocal 变量存储当前持有锁的计数。
须知四:集群环境如何保证锁的安全
redis分布式锁在集群环境下,不是绝对的安全的。比如:主节点的锁还没来得及同步到从节点,此时主节点挂了,从节点取而代之。
线程1在主节点已经成功拿到一把锁,此时切到了从节点,这把锁不存在了,此时线程2轻松在从节点取到这把锁,这就导致一把锁被两个线程拿到了。
Redlock 算法
Redlock 算法就是为了解决这个问题,他的原理是在加锁时,向过半节点发送 set 指令,只要过半节点返回成功,那就认为加锁成功。释放锁时,再向所有节点发送 del 指令。
代价也很明显,跟tomcat 的 session 共享机制一样,随着集群机器的增加,势必会有损性能。Redlock 算法还需要考虑出错重试、时钟漂移等很多细节问题。
所以一般这种由于主从节点同步时间差导致的锁不安全问题,业务系统一般都是选择忍受的,生产上这种场景发生的概率也不大。
redis乐观锁
以上讨论的 reids 分布式锁本质就是使用 setnx 指令实现占位功能,所以这种分布式锁是一种悲观锁,我们也可以借助 redis 的 watch 指令实现乐观锁。
实现原理
结合 redis 事务,watch 会在事务开始之前盯住某个变量,当事务执行提交执行时,redis 会自动检查被watch的变量,是否被修改过了,如果变量被修改过,事务提交指令 exec 会返回 null 告知客户端事务执行失败。
举例
场景:redis 存储了用户金额,现在有两个并发请求改账户额度,业务实现上需要获取到金额,再修改金额,最后写入 redis 。
> set account 100
> watch account
OK
> set account 50 # 事务执行过程中被修改
OK
> multi # 开始事务
OK
> incr account # 账户额度+1
QUEUED
> exec # 事务提交,返回失败
(nil)
当 exec 指令返回一个 null 时,客户端知道了事务执行是失败的。
注意
- 上面例子中的 multi/exec 对应数据库中的 begin/commit,discard 对应 rollback
- Redis 禁止在 multi 和 exec 之间执行 watch 指令,而必须在 multi 之前做好盯住关键变量,否则会出错。
网友评论