京东面试:Redis主从切换,锁失效怎么办?
文章很长,且持续更新,建议收藏起来,慢慢读!疯狂创客圈总目录 博客园版 为您奉上珍贵的学习资源 :
免费赠送 :《尼恩Java面试宝典》 持续更新+ 史上最全 + 面试必备 2000页+ 面试必备 + 大厂必备 +涨薪必备
免费赠送 :《尼恩技术圣经+高并发系列PDF》 ,帮你 实现技术自由,完成职业升级, 薪酬猛涨!加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷1)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷2)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷3)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领
免费赠送 资源宝库: Java 必备 百度网盘资源大合集 价值>10000元 加尼恩领取
京东面试:Redis主从切换,锁失效怎么办?
尼恩特别说明: 尼恩的文章,都会在 《技术自由圈》 公号 发布, 并且维护最新版本。 如果发现图片 不可见, 请去 《技术自由圈》 公号 查找
尼恩说在前面
在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如得物、阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试资格,遇到很多很重要的面试题:
Redis分布式锁,master 挂了但slave 没有完成复制,锁失效了,怎么办?
Redis 主从切换,数据丢了怎么办?
最近有小伙伴在面试 京东,又遇到了相关的面试题。小伙伴懵了,因为没有遇到过,所以支支吾吾的说了几句,面试官不满意,面试挂了。
所以,尼恩给大家做一下系统化、体系化的梳理,使得大家内力猛增,可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提”。
当然,这道面试题,以及参考答案,也会收入咱们的 《尼恩Java面试宝典PDF》V171版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请关注本公众号【技术自由圈】获取,回复:领电子书
1: Redis主从架构的 分布式锁的 执行流程
在Redis主从架构中,写入的,都是 master Redis实例,master 主实例会向 slave 从实例同步key。
一个业务线程 通过向主Redis实例中写入 key-value 来实现加分布式锁,加锁后开始执行业务代码。
具体如下图所示:
当前,这里也涉及到一个核心问题: 锁过期了,业务还没执行完, 怎么办?
这个也是面试的核心题目,具体请参考下面的答案: Redis锁如何续期?Redis锁超时,任务没完怎么办?
咱们现在聚焦的问题: master Redis实例挂掉了,slave 从Redis 还没有完成复制,导致 Redis分布式锁失效,怎么办?
2:master 挂了但slave 没有完成复制,锁失效了,怎么办?
一般情况下:如果主master Redis实例挂掉了,会选举出一个从Redis实例成为主的。这是redis 集群的故障转移机制。
但是,如果刚刚加锁的key还没有来得及同步到slave Redis中,新选出的主Redis实例中就没有这个key,这个时候业务线程B就能加锁来获取分布式锁,导致锁失效了。
具体如下图:
线程B 加锁成功,也执行业务代码了。
而这个时候A还没有执行结束,所以就会出现并发安全问题,这就是Redis主从架构下的分布式锁失效问题
3:Redis 分布式锁的高可用方案
本质上,Redis 分布式锁的高可用,有两个层面的解决方案:
4: Server端 的高可用方案
Redis 分布式锁的Server端高可用方案, 就是通过配置, 保证Server 尽量可能少的数据丢失。
在redis的配置文件中有两个参数我们可以设置:
min-slaves-to-write 1
min-slaves-max-lag 10
min-slaves-to-write默认情况下是0,min-slaves-max-lag默认情况下是10。
两大相关的配置参数
min-slaves-to-write
:设置主库最少得有 N 个健康的从库存活才能执行写命令。这个配置虽然不能保证 N 个从库都一定能接收到主库的写操作,但是能避免当没有足够健康的从库时,主库无法正常写入,以此来避免数据的丢失。min-slaves-max-lag
:配置从库和主库进行数据复制时的 ACK 消息延迟的最大时间,可以确保从库在指定的时间内,如果 ACK 时间没在规定时间内,则拒绝写入。
以上面配置为例,这两个参数表示至少有1个salve的与master的同步复制延迟不能超过10s,一旦所有的slave复制和同步的延迟达到了10s,那么此时master就不会接受任何请求。
我们可以减小min-slaves-max-lag参数的值,这样就可以避免在发生故障时大量的数据丢失,一旦发现延迟超过了该值就不会往master中写入数据。
配置了 Server端 的高可用方案, 那么对于client,我们可以设计好合理的降级措施。
如果 Server端 不可用,需要进行及时预警和合理的降级。 比如,把redis 锁降级为 Zookeeper 分布式锁。
尼恩特别说明:从 CAP定理来说, Redis集群倾向AP(高并发),ZP集群则倾向CP(高可用)。
从写入的流程上来说:
- 在向Redis集群里的主结点写入数据时,写入主节点就立刻告诉客户端写入成功。
- 而在向ZK的主结点写入数据时,并不是立刻告诉客户端写入成功,而是先同步给从结点,至少半数的节点同步成功才能返回“写入成功”给客户端。
这个时候如果ZK的主节点挂了,ZK的ZAB分布式一致性协议能保证一定是数据同步完成的结点被选举为主节点,所以ZK 不会发生分布式锁的失效问题。
但是,ZK是低性能的方案。
5: Client 端 的高可用方案
如果不改用ZK,就是要用Redis Client 端 方案来解决主从架构的分布式锁失效问题。
Client 端 的高可用方案,就是使用 红锁(RedLock)。
什么是RedLock ? 红锁(RedLock) 的设计,就是从 client 客户端 解决了单一 Redis 实例作为分布式锁可能出现的单点故障问题。
红锁(RedLock)是一种分布式锁算法,由 Redis 的作者 Salvatore Sanfilippo(也称为 Antirez)设计,用于在分布式系统中实现可靠的锁机制。
尼恩告诉大家,其实 RedLock底层逻辑和ZK很类似。
5.1 红锁(RedLock)实现原理:
- 多节点加锁: RedLock 不在单个 Redis 实例上加锁,而是在多个独立的 Redis 实例上同时尝试获取锁。通常建议使用奇数个 Redis 实例(如 5 个),以确保系统具有较好的容错性。
- 多数节点同意: 系统只有在获得了大多数 Redis 实例的锁(即 N/2 + 1 个节点,N 为节点总数)之后,才认为成功获取了分布式锁。这样即使部分 Redis 实例发生故障,整体锁服务仍然可用。
- 时间同步: 为防止客户端在持有锁的过程中发生故障而导致锁无法释放,RedLock 会在获取锁时设置一个超时时间。如果客户端在锁超时之前未能完成任务并释放锁,其他客户端可以在锁超时后重新尝试获取。
- 锁释放: 释放锁时,客户端需要向所有 Redis 实例发送释放锁的命令,以确保所有实例上的锁都被清除。
首先要有多个(最好是奇数个)对等的(没有主从关系)Redis结点。
当进行加锁时(比如是用SETNX命令),则这个设置key-value的命令会发给每个Redis结点执行,当且仅当客户端收到超过半数的结点写成功的消息时,才认为加锁成功,才开始执行后面的业务代码。
只有在获得了大多数 Redis 实例的锁(即 N/2 + 1 个节点,N 为节点总数)之后,才认为成功获取了分布式锁。
上图中,Client 1向Redis 1/2/3三个结点去写key-value,假设 在Redis 1和Redis 2写入成功了,Redis 3还没有写入成功的状态,这个时候Client 1就已经认为加锁成功了,实际上已经可以执行业务代码了。
实际的实现过程中,不一定是三个redis 实例,可以是三个 key,三个key 一般会路由到多个 redis 实例上,避免了单点故障问题。
只要 Client 拿到其中的两个key,这个时候Client 就已经认为加锁成功了。
接下来看看 故障发生的场景。
假设有一个Redis结点挂了(如下图所示Redis 1挂了),这个时候假设Client 2也要尝试加锁。
此时Redis 2由于已经被Client 1写过了,没法写入成功,但是Redis 3可以写入成功。
此时Client 2 只有1个结点能写入成功,所以认为加锁不成功,这样Client 2就不会开始错误的执行业务代码,也就不会出现并发安全问题。
尼恩提示:以上内容比较复杂,后面会在《尼恩Java面试宝典 配套视频》中 详细解读。
另外,如果没有 面试机会,可以找尼恩来打造一个绝世好简历,实现 职业逆袭:34岁被裁8月,转架构收一大厂offer, 年薪65W,后逆天改命!
5.2 红锁(RedLock)具体应用:
红锁(RedLock)工作流程,总结如下:
- 客户端尝试顺序地向所有 Redis 实例发送加锁命令。
- 对于每个实例,客户端尝试在指定的超时时间内获取锁。
- 客户端计算已经成功加锁的实例数量,如果达到多数(N/2 + 1),则认为客户端成功获取了分布式锁。
- 如果获取锁失败,客户端需要向所有实例发送释放锁的命令,以避免留下未释放的锁。
在 Java 中的应用:
在 Java 中,可以使用 Redisson 框架来实现 RedLock。RedissonRedLock 实际上是基于 RedissonMultiLock 实现的,从继承关系可以看出这一点。
Redisson 提供了 RedissonMultiLock 类,它可以同时管理多个锁,并保证操作的原子性。
以下是 Redisson 中 RedLock 的简单使用示例:
RedissonClient redisson = // 初始化 Redisson 客户端
RLock lock1 = redisson.getLock("lock1");
RLock lock2 = redisson.getLock("lock2");
RLock lock3 = redisson.getLock("lock3");
RedissonMultiLock multiLock = new RedissonMultiLock(lock1, lock2, lock3);
try {
if (multiLock.tryLock()) {
// 成功获取锁,执行业务逻辑
} else {
// 获取锁失败
}
} finally {
multiLock.unlock(); // 释放锁
}
通过以上机制,RedLock 在分布式环境下提供了一种较为可靠的锁方案,能够应对部分节点故障,并保持锁服务的可用性和安全性。
RedLock 具备以下主要特性:
- 互斥性:在任何时间,只有一个客户端可以获得锁,确保了资源的互斥访问。
- 避免死锁:通过为锁设置一个较短的过期时间,即使客户端在获得锁后由于网络故障等原因未能按时释放锁,锁也会因为过期而自动释放,避免了死锁的发生。
- 容错性:即使一部分 Redis 节点宕机,只要大多数节点(即过半数以上的节点)仍在线,RedLock 算法就能继续提供服务,并确保锁的正确性。
5.3 RedLock 存在问题
RedLock 由于其设计原理和实现上的复杂性,存在一些问题和争议。
RedLock 的性能问题
由于 RedLock 需要在多个节点间进行交互,网络延迟和节点超时确实可能影响加锁的性能。
特别是在节点数量较多或网络状况不佳的情况下,这种影响会更加明显。
RedLock 的并发安全性问题
客户端在持有锁的过程中发生长时间停顿(例如 JVM 的 STW),导致锁实际上已经失效,但客户端由于停顿结束后仍然认为持有锁。
RedLock 被官方废弃
Redisson 官方已经废弃了 RedLock,这也反映了分布式系统设计中的一些挑战。
由于上面的原因,RedLock 很少被使用。
但是如果一定要保证 客户端的锁高可用性, RedLock 还是一种不错的选项。
6:RedissonMultiLock 联锁 源码
尼恩带大家 看看 红锁的源码,其实非常简单:
public class RedissonRedLock extends RedissonMultiLock {
public RedissonRedLock(RLock... locks) {
super(locks);
}
protected int failedLocksLimit() {
return this.locks.size() - this.minLocksAmount(this.locks);
}
protected int minLocksAmount(List<RLock> locks) {
return locks.size() / 2 + 1;
}
protected long calcLockWaitTime(long remainTime) {
return Math.max(remainTime / (long)this.locks.size(), 1L);
}
public void unlock() {
this.unlockInner(this.locks);
}
}
RedissonMultiLock 联锁 是redlock红锁的 基础类。 所以,关键的源码,还是在MultiLock (联锁) 。
实际上,redisson MultiLock (联锁) 使用场景更广。
比如,MultiLock (联锁) 可以将多个锁合并为一个大锁,对一个大锁进行统一的申请加锁以及释放锁,一次性锁定多个资源,再去处理一些事情,然后一次性释放所有的资源对应的锁
在项目里使用的时候,很多时候一次性要锁定多个资源,比如说锁掉一个库存,锁掉一个订单,锁掉一个积分,一次性锁掉多个资源,多个资源都不让别人随意修改,然后你再一次性更新多个资源,释放多个锁
RedissonMultiLock 联锁 的使用参考代码:
public static void main(String[] args) throws Exception {
Config config = new Config();
// 1. 这里的Redis集群是我本地搭建的一套集群,因为是研究源码,所以配置直接硬编码到代码里
config.useClusterServers()
.addNodeAddress("redis://192.168.0.107:7001")
.addNodeAddress("redis://192.168.0.107:7002")
.addNodeAddress("redis://192.168.0.110:7003")
.addNodeAddress("redis://192.168.0.110:7004")
.addNodeAddress("redis://192.168.0.111:7005")
.addNodeAddress("redis://192.168.0.111:7006");
RedissonClient redisson = Redisson.create(config);
RLock lock1 = redisson.getLock("lock1");
RLock lock2 = redisson.getLock("lock2");
RLock lock3 = redisson.getLock("lock3");
RedissonMultiLock lock = new RedissonMultiLock(lock1,lock2,lock3);
// 代码片段二、
lock.lock();
// 代码片段六、
lock.unlock();
}
源码:使用lock方法 获取联锁
RedissonMultiLock类中
public void lock(long leaseTime, TimeUnit unit) {
try {
// 1. 代码片段三、
lockInterruptibly(leaseTime, unit);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
源码:使用lockInterruptibly 方法 获取联锁
@Override
public void lockInterruptibly() throws InterruptedException {
// 这里的-1后面会用到,具体-1代表是什么意思,后面的代码分析,参考代码片段四、
lockInterruptibly(-1, null);
}
重载版本:
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
// 1. 通过代码片段三可以知道,leaseTime为-1 unit=null
// baseWaitTime = 锁的个数(3个) * 1500 = 4500毫秒
long baseWaitTime = locks.size() * 1500;
long waitTime = -1;
// leaseTime肯定是-1,所以这里成立,不走else逻辑了,这里的代码写的就感觉很有意思,上面等于-1,下面等于-1还if判断
if (leaseTime == -1) {
// waitTime= 4500毫秒
waitTime = baseWaitTime;
unit = TimeUnit.MILLISECONDS;
} else {
waitTime = unit.toMillis(leaseTime);
if (waitTime <= 2000) {
waitTime = 2000;
} else if (waitTime <= baseWaitTime) {
waitTime = ThreadLocalRandom.current().nextLong(waitTime/2, waitTime);
} else {
waitTime = ThreadLocalRandom.current().nextLong(baseWaitTime, waitTime);
}
waitTime = unit.convert(waitTime, TimeUnit.MILLISECONDS);
}
// 这里有个死循环逻辑,其实就是不停的去获取锁
while (true) {
// 1. 代码片段五、waitTime = 4500毫秒,leaseTime = -1
if (tryLock(waitTime, leaseTime, unit)) {
return;
}
}
}
源码:使用lockInterruptibly 方法 获取联锁
核心的代码很简单,就是对 子锁的列表进行迭代, 一个一个去获取子锁。
// waitTime = 4500毫秒,leaseTime = -1
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
// try {
// return tryLockAsync(waitTime, leaseTime, unit).get();
// } catch (ExecutionException e) {
// throw new IllegalStateException(e);
// }
// 1. newLeaseTime = -1,其实这里的参数值,都会影响对程序的逻辑以及加锁释放锁
// 1.现在是真的想不通这个逻辑,先等于-1,然后在if判断,和下面的remainTime一样
long newLeaseTime = -1;
if (leaseTime != -1) {
newLeaseTime = unit.toMillis(waitTime)*2;
}
// 当前时间
long time = System.currentTimeMillis();
long remainTime = -1;
if (waitTime != -1) {
remainTime = unit.toMillis(waitTime);
}
// 这里其实就是返回remainTime,calcLockWaitTime是给他什么参数,返回什么参数,也挺有意思的。
long lockWaitTime = calcLockWaitTime(remainTime);
// 这里会返回一个固定的值0
int failedLocksLimit = failedLocksLimit();
List<RLock> acquiredLocks = new ArrayList<RLock>(locks.size());
// 1. 拿到锁的迭代器
for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
RLock lock = iterator.next();
boolean lockAcquired;
try {
// waitTime = 4500毫秒,leaseTime = -1 参数传递进来的,所以会走else逻辑
if (waitTime == -1 && leaseTime == -1) {
lockAcquired = lock.tryLock();
} else {
// 这里去lockWaitTime和remainTime中的最小值(lockWaitTime = 0,就是上面那个固定值,remainTime=-1),
// 所以awaitTime=-1,这个-1其实很关键,在tryLock中,-1代表了如果获取锁成功了,就会启动一个lock watchDog,不停的刷新锁的生存时间
long awaitTime = Math.min(lockWaitTime, remainTime);
// 这里就是获取锁,等待awaitTime=4500毫秒,获取锁成功,启动一个watchDog
lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
}
} catch (Exception e) {
lockAcquired = false;
}
if (lockAcquired) {
acquiredLocks.add(lock);
} else {
if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
break;
}
if (failedLocksLimit == 0) {
unlockInner(acquiredLocks);
if (waitTime == -1 && leaseTime == -1) {
return false;
}
failedLocksLimit = failedLocksLimit();
acquiredLocks.clear();
// reset iterator
while (iterator.hasPrevious()) {
iterator.previous();
}
} else {
failedLocksLimit--;
}
}
if (remainTime != -1) {
// 如果获取锁成功,当前时间减去获取锁耗费的时间time
remainTime -= (System.currentTimeMillis() - time);
time = System.currentTimeMillis();
if (remainTime <= 0) {
// 如果remainTime <0 说明获取锁超时,那么就释放掉这个锁
unlockInner(acquiredLocks);
// 返回false,说明加锁失败
return false;
}
}
}
if (leaseTime != -1) {
List<RFuture<Boolean>> futures = new ArrayList<RFuture<Boolean>>(acquiredLocks.size());
for (RLock rLock : acquiredLocks) {
RFuture<Boolean> future = rLock.expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS);
futures.add(future);
}
for (RFuture<Boolean> rFuture : futures) {
rFuture.syncUninterruptibly();
}
}
return true;
}
unlock释放 联锁
// 释放锁的话,就是依次调用所有的锁的释放的逻辑,lua脚本,同步等待所有的锁释放完毕,才会返回
@Override
public void unlock() {
List<RFuture<Void>> futures = new ArrayList<RFuture<Void>>(locks.size());
for (RLock lock : locks) {
// 代码片段七、
futures.add(lock.unlockAsync());
}
for (RFuture<Void> future : futures) {
future.syncUninterruptibly();
}
}
子锁的释放:通过lua脚本释放内部的子锁
这里的释放锁的底层lua脚本,和加锁很类似,就不做具体的分析了,一眼看上去,其实还是很简单的
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}
最后释放锁的Lua 脚本就不一行一行的分析注释了,这些lua脚本相对来说还是比较简单的,如果要学习lua,请阅读尼恩的清华大学出版社出版的《 Java 高并发核心编程卷3》
其实Redisson中的MultiLock的加锁与释放锁相对来说还是比较简单的,这也归根于Redisson的源码写的比较优雅又关系。
如果要实现锁的高可用,红锁还是可以用的。
说在最后:有问题找老架构取经
Redis 分布式锁 主从切换问题的,有两个层面的解决方案:
按照两个层面进行 深度回答,可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提”。
在面试之前,建议大家系统化的刷一波 5000页《尼恩Java面试宝典PDF》,里边有大量的大厂真题、面试难题、架构难题。
很多小伙伴刷完后, 吊打面试官, 大厂横着走。
在刷题过程中,如果有啥问题,大家可以来 找 40岁老架构师尼恩交流。
另外,如果没有面试机会,可以找尼恩来改简历、做帮扶。前段时间,刚指导一个27岁 被裁小伙,拿到了一个年薪45W的JD +PDD offer,逆天改命。
狠狠卷,实现 “offer自由” 很容易的, 前段时间一个武汉的跟着尼恩卷了2年的小伙伴, 在极度严寒/痛苦被裁的环境下, offer拿到手软, 实现真正的 “offer自由” 。
技术自由的实现路径:
实现你的 架构自由:
《阿里二面:千万级、亿级数据,如何性能优化? 教科书级 答案来了》
《峰值21WQps、亿级DAU,小游戏《羊了个羊》是怎么架构的?》
… 更多架构文章,正在添加中
实现你的 响应式 自由:
这是老版本 《Flux、Mono、Reactor 实战(史上最全)》
实现你的 spring cloud 自由:
《Spring cloud Alibaba 学习圣经》 PDF
《分库分表 Sharding-JDBC 底层原理、核心实战(史上最全)》
《一文搞定:SpringBoot、SLF4j、Log4j、Logback、Netty之间混乱关系(史上最全)》
实现你的 linux 自由:
实现你的 网络 自由:
《网络三张表:ARP表, MAC表, 路由表,实现你的网络自由!!》
实现你的 分布式锁 自由:
实现你的 王者组件 自由:
《队列之王: Disruptor 原理、架构、源码 一文穿透》
《缓存之王:Caffeine 源码、架构、原理(史上最全,10W字 超级长文)》
《Java Agent 探针、字节码增强 ByteBuddy(史上最全)》