一、简述Redis和Zookeeper分别是如何设计分布式锁的?
Redis:
-
客户端向Redis发送一个请求,请求获取锁
-
Redis服务器尝试向Redis中写入一个key-value,(通过setnx()方法,key表示锁名称,value表示随机生成的唯一的标识符),若返回1则写入成功表明该客户端获取到了锁
-
客户端获取锁之后需要在给锁设置一个过期时间(通过pexpire()方法,该方法可同时设置key值和过期时间),避免锁被长时间占用导致死锁
-
当客户端完成操作后,需要通过Redis的del命令释放(删除)锁
需要注意的是:为了避免某些异常情况而导致锁不能被正常释放,比如客户端宕机、网络异常等,可以在锁的value中保存一些额外的信息,比如客户端ID和获取锁的时间戳等,这样即使出现异常,其他客户端仍可以在锁过期后获取锁
lock_key = "my_lock" client_id = uuid() // 客户端ID lock_value = client_id + ":" + timestamp() // 锁的value lock_expire_time = 30000 // 设置锁的过期时间为30秒 while (true): result = redis.setnx(lock_key, lock_value) if result == 1: // 获取锁成功 redis.pexpire(lock_key, lock_expire_time) // 设置锁的过期时间 break else: // 获取锁失败,检查锁是否过期或者锁的拥有者是否宕机 current_value = redis.get(lock_key) // 如果当前锁的拥有者仍然是当前客户端,则重新设置锁的过期时间 if current_value is not None and current_value.split(":")[0] == client_id: redis.pexpire(lock_key, lock_expire_time) break else: // 如果当前锁的拥有者不是当前客户端,则等待一段时间后再次尝试获取 sleep(100) // 完成操作后释放锁 redis.del(lock_key)
同时,在锁的占用期间需要避免锁的过期时间被其他客户端修改,可以通过续约的方式来实现。续约指的是在获取锁之后,通过定时向Redis发送续约请求,来更新锁的过期时间。
lock_key = "my_lock" client_id = uuid() // 客户端ID lock_value = client_id + ":" + timestamp() // 锁的value lock_expire_time = 30000 // 设置锁的过期时间为30秒 renew_interval = 10000 // 续约的时间间隔为10秒 // 获取锁 result = redis.setnx(lock_key, lock_value) if result == 1: // 获取锁成功 redis.pexpire(lock_key, lock_expire_time) // 设置锁的过期时间 // 启动定时器,定时续约 timer = start_timer(renew_interval, function() { redis.pexpire(lock_key, lock_expire_time) // 续约锁的过期时间 }) // 执行业务逻辑 // ... // 完成操作后释放锁 redis.del(lock_key) // 取消定时器 stop_timer(timer) else: // 获取锁失败,检查锁是否过期或者锁的拥有者是否宕机 current_value = redis.get(lock_key) // 如果当前锁的拥有者仍然是当前客户端,则启动定时器,定时续约 if current_value is not None and current_value.split(":")[0] == client_id: redis.pexpire(lock_key, lock_expire_time) // 启动定时器,定时续约 timer = start_timer(renew_interval, function() { redis.pexpire(lock_key, lock_expire_time) // 续约锁的过期时间 }) // 执行业务逻辑 // ... // 完成操作后释放锁 redis.del(lock_key) // 取消定时器 stop_timer(timer) else: // 如果当前锁的拥有者不是当前客户端,则等待一段时间后再次尝试获取 sleep(100)
续约的时间间隔需要根据业务逻辑和网络环境来合理设置。如果续约时间间隔过长,可能会导致锁被其他客户端抢占;过短则可能会影响性能。
Zookeeper:
-
创建一个znode节点作为锁,节点名称通常使用序号如:”/locak-0001”来确保唯一性
-
当节点需要获取锁时,通过create方法在Zookeeper上创建一个短暂的有序节点,并设置Watcher监听器,如果创建成功,说明该节点成功获取锁,否则需要等待其他节点释放该锁
-
判断该节点是否为当前最小节点,若是则说明该节点成功获取锁,否则说明其他节点已经获取了锁,当前节点需要通过Watcher监听器继续等待
-
当节点完成任务后,通过调用delete方法删除该节点以此释放锁
// 获取zookeeper连接
ZooKeeper zk = new ZooKeeper("localhost:2181", 5000, null);
// 创建一个znode节点作为锁
String lockPath = "/lock";
// 创建一个短暂的,有序的znode节点
String lockNode = zk.create(lockPath + "/lock-", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
// 第三个参数:表示ACL的访问策略,这里是开放的ACL策略,即任何客户端都可以对该znode进行读写操作
// 第四个参数:表示znode节点的创建模式,这里是短暂的,有序的模式,即该znode节点会在客户端会话结束时自动删除,并且它的节点名称会根据当前已存在的子节点按照顺序编号自动递增
// 判断当前节点是否为最小节点
while (true) {
// 获取locakpath路径下的所有子节点名称,false表示不需要对子节点进行监听
List<String> nodes = zk.getChildren(lockPath, false);
Collections.sort(nodes); // 对子节点的名称进行排序
if (lockNode.equals(lockPath + "/" + nodes.get(0))) {
// 当前节点为最小节点,获取锁成功
break;
} else {
// 不是最小节点,找到它前面的那个节点并对它进行监听
// 利用二分查找算法(binarySearch)查找当前节点位置
String previousNode = nodes.get(Collections.binarySearch(nodes, lockNode.substring(lockNode.lastIndexOf("/") + 1)) - 1);
// 对前一个节点进行监听,true表示监听节点的状态变化,null表示不需要传递额外的数据
zk.getData(lockPath + "/" + previousNode, true, null);
}
}
// 执行任务
// ...
// 释放锁
zk.delete(lockNode, -1);
Redis和Zookeeper在实现分布式锁时有何区别?
Redis | Zookeeper | |
---|---|---|
锁的信息存储方式 | 内存中 | 文件系统中 |
实现方式 | Redis通过SETNX实现 | Zookeeper通过创建短暂节点实现 |
锁的特性 | 不支持重入特性 | 支持重入特性 |
锁的释放方式 | 只能通过持有锁的客户端主动释放 | 可以设置自动释放 |
锁的实现难度 | 较简单 | 较复杂,但在特定场景下更加稳定可靠 |
补充说明:什么是重入特性?
重入特性指的是同一个线程在持有锁的情况下可以再次获得锁,而不会被阻塞。例如:在一个方法中需要获取分布式锁,在获取锁的情况下再执行其他业务逻辑,执行过程中可能会调用其他方法,这些方法也需要获取同一个锁,如果不支持重入特性,就会发生死锁。
所以需要根据业务场景和需求来选择合适的实现方式。
如何选择合适的实现方式?
Redis:性能高、短时间的锁、实现简单的需求时
Zookeeper:对可靠性和稳定性要求高,需要重入特性或支持锁的过期时间自动续约时
二、Redis 的key 是如何寻址的?
-
Redis将key进行哈希取值
-
根据这个哈希值计算出该key应该存储在哪个哈希表索引上
-
如果这个索引位置已经存在key值了,则Redis会使用链表将这些key串联起来,形成一个哈希表桶
-
当需要获取该key值时,Redis首先计算出这个key的哈希值,再去找到对应的索引
-
如果该索引只有一个key则直接返回这个key值,否则需要遍历链表,找到对应的key值以便返回。Redis通过这种方式可以高效地处理大量的key,并且可以快速定位到key所对应的值,同时因为哈希表的设计,可以避免不必要的内存浪费,提高系统性能和可靠性
三、Redis的主从复制
1、请简述Redis的主从复制实现思路
-
从节点向主节点发送SYNC命令,请求同步数据
-
主节点接受到SYNC命令,创建一个后台进程,执行数据同步,并将同步操作的结果保存到缓冲区中
-
主节点将缓冲区的同步数据发送给从节点,并将同步操作的结果持久化到磁盘中
-
从节点接收到同步数据后,将其保存到自己的数据库中,并且将同步操作的结果持久化到磁盘中
-
从节点向主节点发送PSYNC命令,请求增量数据同步
-
后面两个步骤同3、4
-
从节点周期性地向主节点发送PING命令,检查主节点是否存活
-
如果主节点宕机或发生网络分区,从节点会成为新的主节点,并且继续向其他从节点同步数据
补充说明:发生宕机后,Redis会根据以下因素选择一个从节点作为新的主节点
-
slave-priority:从节点的优先级,用于选举新的主节点,默认为100
-
slave-ignore-max-replication-offset:是否忽略从节点的复制偏移量限制,用于选举最近的从节点作为新的主节点
-
slave-ignore-connection-ping:是否忽略从节点的网络延迟,用于选举网络最快的从节点作为新的主节点,默认为no
-
2、Redis主从复制的作用
-
负载均衡:基于主从结构,配合读写分离,由slave分担master负载,并根据需求的变化,改变slave的数量,通过多个从节点分担数据读取负载,大大提高Redis服务器并发量与数据吞吐量
-
高可用:基于主从复制,构建哨兵模式与集群,实现Redis的高可用方案
-
读写分离:master写、slave读,提高服务器的读写负载能力
-
故障恢复:当master出现问题时,由slave提供服务,实现快速的故障恢复
四、缓存雪崩、缓存击穿、缓存穿透、缓存预热
1、缓存雪崩:短时间内,大量key集中过期
解决方案:
① 构建多级缓存架构:Nginx缓存 + redis缓存 + ehcache缓存
② 在key的过期时间上加随机值,避免同时失效
③ 合理设置缓存的过期时间和淘汰策略,比如LRU(默认使用)
④ 限流、降级:避免瞬时的请求过多导致系统奔溃
⑤ 加锁(慎用)
补充说明:
LRU:是一种缓存淘汰策略,当缓存空间不足时,淘汰掉最近最少使用的缓存数据。
LRU算法的基本实现步骤:
-
初始化一个双向链表和一个HashMap,链表保存缓存数据的访问顺序,HashMap保存缓存数据的key和value,查询时间复杂度为O(1)
-
当访问一个缓存数据时,首先在HashMap中查询该数据是否存在,若存在则将其移到双向链表的头部,以此表示最近被使用过
-
若不存在,也将该数据添加到双向链表的头部,并在HashMap中保存该数据的key和value
-
如果双向链表的长度超过了缓存的最大容量,则需要淘汰链表尾部的数据,并在HashMap中删除该数据的key和value
补充说明:O(1)时间复杂度较低所以适用于内存缓存中,但是LRU算法需要维护一个链表,增加了额外的空间开销,在分布式缓存中,LRU算法因为需要维护全局的访问顺序,不易实现和维护所以不太适合使用,一般采用更为简单和高效的淘汰策略:
① LFU(Least Frequently Used):选择使用频率最低的元素进行淘汰,适用于访问热度分布不均匀的场景
② FIFO(First In, First Out):按照元素加入缓存的时间顺序进行淘汰,适用于缓存数据没有时效性要求的场景
③ LRU-K:LRU改进版,除了考虑元素最近使用的时间,还考虑了K次使用的时间,适用于访问热度分布不均匀的场景
还可以根据具体的情况组合使用,例如将LFU和LRU结合起来使用,即在缓存空间不足时,先淘汰最不怎么使用的元素,再在相同频率的元素中选择最久未使用的元素进行淘汰
2、缓存击穿:某个key过期,但访问量却巨大
解决方案:
① 设置热点数据永不过期
② 预热缓存:在系统启动或者低峰期时,预先将热点数据加载到缓存中,避免在高峰期突然加载导致缓存击穿
③ 加互斥锁(慎用):当缓存失效时,不立即去 load db,先使用如 Redis 的 setnx 去设置一个互斥锁,当操作成功返回时再进行 load db 的操作并回设缓存,否则重试 get 缓存的方法
3、缓存穿透:指查询一个一定不存在的数据(或出现非正常的URL访问)
解决方案:
① 对查询结果为null的数据进行缓存,并设置一个较短的过期时间
② 布隆过滤器:将所有可能存在的数据哈希到一个足够大的bitmap中,则那个一定不存在的数据就会被这个bitmap过滤掉,从而避免了对DB的查询
③ 互斥锁(Mutex):当多个请求同时访问同一个不存在的key时,只有一个请求访问数据库,其他请求等待结果返回
public class Cache {
private Map<String, Object> cacheMap = new HashMap<>();
public Object get(String key) {
Object value = cacheMap.get(key);
if (value == null) {
// key 对应的数据不存在缓存中,需要从数据库中查询,但是防止缓存穿透,需要先判断 key 是否合法
if (!isKeyValid(key)) {
return null;
}
value = getValueFromDatabase(key);
cacheMap.put(key, value);
}
return value;
}
private boolean isKeyValid(String key) {
// 假设对于 key 为 null 或者以特定的字符串开头的 key 都是非法的,容易导致缓存穿透
return key != null && !key.startsWith("illegal_");
}
private Object getValueFromDatabase(String key) {
// 这里为了模拟缓存穿透的情况,如果查询数据库时发现 key 不存在,则返回 null
if (key.equals("non_existent_key")) {
return null;
}
return "value_" + key;
}
}
在上述示例中,我们使用了一个 Map
实现了一个简单的缓存,当调用 get
方法时,先在 cacheMap
中查找是否存在对应的 key
,如果存在则直接返回对应的 value
;否则先判断 key
是否合法,如果不合法则直接返回 null
,否则从数据库中查询对应的数据,然后将查询结果放入缓存并返回。在这个过程中,我们通过 isKeyValid
方法实现了对 key
的验证,以防止缓存穿透。
4.缓存预热:提前将相关的缓存数据直接加载到缓存系统
五、简述Redis数据删除策略,并说明各自的优缺点
1、定时删除
定时删除采用的是定期扫描的方式,即Redis默认每秒钟随机抽取一些键值对,然后判断它们是否过期,过期则删除。当然,随机抽取的键值对数量是有上限的,由配置文件的maxmemory-samples
参数控制,默认值是5。
优点:
① 简单高效,适用于小规模的缓存系统
② 可控性强:可以根据业务需求自由设置删除时间,可保证在指定时间内被删除,防止占用过多的内存空间
缺点:
① 粒度不够细:定时删除只能根据时间进行删除,无法根据具体的缓存数据情况进行判断和删除
② 不及时:需要等待指定时间后才能删除,不适用于数据更新频繁的场景
③ 可能会造成缓存雪崩:万一都是在同一时间点删除
④ CPU压力大,影响Redis服务器响应时间和指令吞吐量
2、惰性删除
数据到达过期时间不做处理,而是等到下次访问该数据时,判断是否过期,过期则删除
优点:节约CPU性能
缺点:内存压力大,可能出现长期占用内存的数据
3、定期删除
周期性轮询Redis中的时效性数据,采用随机抽取的策略。在Redis中,有一个参数hz
表示每秒执行的操作数
优点:相对于惰性删除,定期删除具有一定的主动性,可以在一定程度上减少过期键的数量,降低内存使用率
缺点:定期删除的键值对数量是随机的,有可能删除了一些未过期的键,且不能保证每个过期键都会被删除,那么这个键就有可能一直留在Redis中,占用内存空间。
六、简述Redis持久化是如何实现的?有何优缺点?
1、RDB(默认)
RDB会定期将Redis在内存中的数据快照文件(.rdb文件)保存到磁盘上。可以通过修改配置文件中的save
选项来指定保存快照的时间间隔和触发快照保存的条件
# save命令的参数含义为在n秒内,如果至少有m个key被修改,则进行一次RDB持久化
save 300 10
save 60 10000
优点:在数据量大的情况下,恢复数据速度较快,只需加载一个快照文件即可。
缺点:由于是定期持久化,可能存在数据丢失的情况
2、AOF
AOF会将Redis执行的所有写操作(添加、更新、删除)记录在一个日志文件中(appendonly.aof文件),保证数据得到实时性和完整性
优点:对于需要实时同步数据的场景,AOF比RDB更适用
缺点:在写入频繁的情况下可能会导致性能问题;文件较大占用更多磁盘空间;恢复数据的速度较慢
# 表示将AOF文件存放在/usr/local/redis目录下,并将文件名设为appendonly.aof
dir /usr/local/redis
appendfilename "appendonly.aof"
打开AOF功能:可以通过redis.conf文件中的appendonly
配置项来开启AOF持久化功能,默认值为no
AOF同步方式:可以通过redis.conf文件中的appendfsync
配置项来指定AOF同步方式。常见的同步方式有三种:
-
always:表示每个写操作都立即将数据同步到磁盘,保证数据不丢失,但会影响Redis的性能。
-
everysec(建议):表示每秒将写操作同步到磁盘,性能较高,不过可能会导致数据丢失,但在一定程度上保证了性能
-
no:表示不进行同步,Redis会将数据缓存在操作系统的页缓存中,数据可能会丢失。
七、缓存与数据库数据不一致问题
原因:
1、数据库更新后,Redis没有及时同步更新,导致Redis缓存的数据过期或失效
2、Redis缓存中的数据更新了,但数据库没有及时同步更新
解决方案:在数据更新时,使用事务同时更新Redis和数据库
八、主从数据库不一致如何解决
1、忽略不一致,在数据一致性要求不高的业务下,未必需要时时一致性
2、强制读主库,使用一个高可用的主库,数据库读写都在主库,添加一个缓存,提升数据读取的性能
九、Redis当中有哪些数据结构
String、Hash、List、Set、Sorted Set有序集合、HyperLogLog、Geospatial Index等
十、Redis常见的性能问题和解决方案
1、master最好不要做持久化工作,因为会让master负载增加,导致请求响应时间变慢
2、如果数据比较重要,某个slave开启AOF备份,策略设置成每秒同步1次
3、为了主从复制的速度和连接的稳定性,master和slave最好在同一个局域网下
4、尽量避免在压力大的主库上增加从库
5、主从复制不要采用网状结构,尽量是链式结构,如:Master <-- slave1 <-- slave2
十一、假如Redis里面有1亿个key,其中有10w个key是以某个固定的已知前缀开头的,如何将它们全部找出来?
可以使用Redis的SCAN
命令配合正则表达式来实现这个功能。具体如下:
1、使用SCAN
命令获取Redis中所有的key,SCAN
命令可以遍历整个数据集并返回所有匹配的key。
2、对于返回的每个key,判断其是否以目标前缀开头,可以使用Redis的KEYS
命令和正则表达式来匹配目标前缀。
public class RedisKeySearchDemo {
public static void main(String[] args) {
// 创建 Redis 客户端
Jedis jedis = new Jedis("localhost", 6379);
// 插入 100000000 个 key
for (int i = 0; i < 100000000; i++) {
jedis.set("key" + i, "value" + i);
}
// 创建一个 HashSet 用于存储找到的 key
Set<String> keys = new HashSet<>();
// 创建扫描参数,匹配以 "key1" 开头的 key
ScanParams params = new ScanParams().match("key1*");
// 初始游标为 "0"
String cursor = "0";
// 循环扫描 Redis
do {
// 执行 SCAN 命令,获取扫描结果
ScanResult<String> scanResult = jedis.scan(cursor, params);
// 获取 SCAN 命令返回的当前游标的值
cursor = scanResult.getCursor();
// 将扫描结果加入到 keys 集合中
List<String> result = scanResult.getResult();
keys.addAll(result);
} while (!cursor.equals("0"));
// 输出符合条件的 key 的数量
System.out.println(keys.size());
// 关闭 Redis 客户端
jedis.close();
}
}
补充说明:除了scan指令,用keys指令可不可以?
可以,但如果Redis正在给线上的业务提供服务,由于Redis有个特性是单线程,keys指令会导致线程阻塞一段时间,从而影响线上业务正常运行。scan可以分批返回结果,不会阻塞。