Redis分布式锁
实现
Redis 锁主要利用 Redis 的 setnx 命令。
- 加锁命令:SETNX key value,当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY 是锁的唯一标识,一般按业务来决定命名。
- 解锁命令:DEL key,通过删除键值对释放锁,以便其他线程可以通过 SETNX 命令来获取锁。
- 锁超时:EXPIRE key timeout, 设置 key 的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。
存在的问题
- SETNX和EXPIRE非原子性
如果SETNX成功,在设置锁超时时间后,服务器挂掉、重启或网络问题等,导致 EXPIRE 命令没有执行,锁没有设置超时时间变成死锁。
解决方案:可以使用lua脚本来保证原子执行
if (redis.call('setnx', KEYS[1], ARGV[1]) < 1)
then return 0;
end;
redis.call('expire', KEYS[1], tonumber(ARGV[2]));
return 1;
- 锁误解除
如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。
解决方案:在value中设置能够表示当前线程的身份信息,比如服务器MAC地址+线程ID,然后判断身份信息是否是持有当前锁的线程,持有锁的线程才能执行释放锁逻辑。
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
then return redis.call('del', KEYS[1])
else return 0
end
-
超时解锁出现并发
如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。
解决方案:
将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成;
为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。 -
锁不可重入
当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。
解决方案:可以使用Redis Hash数据结构来实现分布式锁,既存锁的标识也对重入次数进行计数。
// 如果 lock_key 不存在
if (redis.call('exists', KEYS[1]) == 0)
then
// 设置 lock_key 线程标识 1 进行加锁
redis.call('hset', KEYS[1], ARGV[2], 1);
// 设置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 如果 lock_key 存在且线程标识是当前欲加锁的线程标识
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
// 自增
then redis.call('hincrby', KEYS[1], ARGV[2], 1);
// 重置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 如果加锁失败,返回锁剩余时间
return redis.call('pttl', KEYS[1]);
-
无法等待锁释放
解决方案:
可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率。
另一种方式是使用 Redis 的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功 后释放时,发送锁释放消息。 -
主从切换问题
为了保证 Redis 的可用性,一般采用主从方式部署。主从数据同步有异步和同步两种方式,Redis 将指令记录在本地内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一致的状态,一边向主节点反馈同步情况。
在包含主从模式的集群部署方式中,当主节点挂掉时,从节点会取而代之,但客户端无明显感知。当客户端 A 成功加锁,指令还未同步,此时主节点挂掉,从节点提升为主节点,新的主节点没有锁的数据,当客户端 B 加锁时就会成功。 -
Redis集群脑裂问题
集群脑裂指因为网络问题,导致 Redis master 节点跟 slave 节点和 sentinel 集群处于不同的网络分区,因为 sentinel 集群无法感知到 master 的存在,所以将 slave 节点提升为 master 节点,此时存在两个不同的 master 节点。Redis Cluster 集群部署方式同理。
当不同的客户端连接不同的 master 节点时,两个客户端可以同时拥有同一把锁。
可以通过设置连接到master的最少slave数量,否则拒绝写入。 -
结论
Redis 以其高性能著称,但使用其实现分布式锁来解决并发仍存在一些困难。Redis 分布式锁只能作为一种缓解并发的手段,如果要完全解决并发问题,仍需要数据库或zookeeper的防并发手段。
如果是单机Redis实现分布式锁只保证了数据一致性,无法保证高可用和分区容错性,
如果配置了主从同步,由于是异步同步数据,master故障时可以自动切换slave,实现了高可用和分区容错性,但是异步同步数据无法保证数据一致性。
基于zk实现的分布式锁保证数据一致性,因为zk的所有节点数据都是同步复制的,并且其中一个数据节点挂掉不影响整体服务,满足分区容错性,但是如果master节点宕机,会导致整个zk集群重新选举leader期间服务不可用,所以无法满足高可用。
基于数据库实现的分布式锁,如果只有一个主库智能保证数据一致性,无法保证高可用和分区容错性。如果配置了主从同步,异步同步数据方式,master故障时可以自动切换到slave,满足分区容错性,但是异步同步的数据可能会丢失,无法保证数据一致性。如果配置了全同步方式同步数据,每次更新数据都需要等待从库commit数据,虽然保证了数据一致性,但是只要有一个从库没有commit数据,就会阻塞主库,影响了系统的整体可用性。
所以根据CAP理论,无论哪种方式都无法同时满足数据一致性,高可用和分区容错性,所以有的分布式锁选择放弃了数据一致性,比如redis。有的则放弃了高可用,比如zk。
即使是redis作者的redlock算法也是在CAP上做了一些平衡,没有完全的满足高可用要求
网友评论