Redis面试题
⚫ 熟悉Redis缓存,熟悉数据类型,缓存持久化,分布式锁,对于缓存雪崩等问题有解决方案;
数据类型
String(字符串)
1、字符串是 Redis 中最简单和最常用的数据类型。可以用来存储如字符串、整数、浮点数、图片(图片的base64编码或图片的路径)、序列化后的对象等。
2、每个键(key)对应一个值(value),一个键最大能存储512MB的数据。
1
2 >SET key "value"
>GET keyHash(哈希)
1、Redis Hash是一个String类型的field和value的映射表,类似于Java中的Map
。 2、Hash特别适合用于存储对象,如用户信息、商品详情等。
3、 每个Hash可以存储2^32 - 1个键值对。
1
2 >HSET user:1000 name "John"
>HGET user:1000 nameList(列表)
1、 列表是一个有序的字符串集合,可以从两端压入或弹出元素,支持在列表的头部或尾部添加元素。
2、 列表最多可存储2^32 - 1个元素。
1
2
3 >LPUSH mylist "world"
>LPUSH mylist "hello"
>LRANGE mylist 0 -1Set(集合)
1、Set是一个无序的字符串集合,不允许重复元素。集合适用于去重和集合运算(如交集、并集、差集)。Set的添加、删除、查找操作的复杂度都是O(1)。
myset "hello"
1
2
3 >SADD myset "hello"
>SADD myset "world"
>SMEMBERS mysetZset(有序集合)
Zset和Set一样也是String类型元素的集合,且不允许重复的成员。有序集合类似于集合,但每个元素都会关联一个double类型的分数(score),redis正是通过分数来为集合中的成员进行从小到大的排序。
优点
应用场景
字符串(String)
哈希(Hash)
列表(List)
集合(Set)
有序集合(Sorted Set)
Redis单线程的理解
Redis核心操作是单线程的。Redis在处理并发请求时有简单、高效和一致性的优点。但是Redis在某些方面使用了额外的线程来处理后台任务。
Redis的主要操作,包括网络IO和键值对读写,确实是由一个线程来完成的。这保证了Redis在处理客户端请求时的简单性和一致性,避免了多线程可能带来的上下文切换开销和竞争条件。利用队列技术将并发访问变为串行访问,消除了传统数据库串行控制的开销。这意味着,虽然多个客户端可能同时发送请求,但Redis会将这些请求放入队列中,并逐个处理它们。
Redis 单线程性能高的原因:
1、 高效的 I/O 多路复用:Redis使用网络IO多路复用技术(如epoll)来同时处理多个客户端连接。这使得Redis能够高效地利用系统资源,为大量并发连接提供高性能的服务。官网数据 10w/qps。
2、 由于Redis基于内存操作,并且采用了单线程模型,不需要处理线程切换问题和多线程之间资源竞争,以及锁的问题。
Redis 多线程主要做的事情:
持久化(例如,在保存RDB快照时,Redis会自动fork一个子进程去处理)、异步删除和集群数据同步等。这些任务不会阻塞Redis的主线程,从而确保Redis能够持续地为客户端提供服务。
过期策略
Redis 实际使用的是定期删除+惰性删除的方式!定期删除减少 cpu 消耗和浪费,配合惰性删除,二次检查保险。
主动删除
定时删除
当设置键的过期时间时,Redis会为该键创建一个定时器,当过期时间到达时自动删除该键。redis.c 下的 activeExpireCycle 函数实现了定期删除粗略,配合 Redis的服务器的 serverCron函数,在服务器周期执行serverCron 的时候,activeExpireCycle函数就会被调用,在一定的时间内,分多次遍历 redis 中的数据库,从数据库的expires字典中检查一部分键的过期时间,此操作是随机性的,然后删除其中的过期键。
优点:删除操作会在数据到期时立即进行,确保内存及时释放。
缺点:定时器的管理会消耗系统资源,特别是在大量键设置过期时间的情况下,删除 key 会对响应时间和吞吐量产生影响。
定期删除
Redis会定期扫描数据库中的键,并删除其中已过期的键。通过随机抽取一定数量的键,并检查它们是否过期,如果过期就删除,Redis默认每隔100ms(可以通过配置文件中的hz参数进行调整)就执行一次过期扫描任务。
配置redis.conf的hz选项,默认为10,1s刷新的频率。即1秒执行10次,相当于100ms执行一次,hz值越大,说明刷新频率越快,Redis性能损耗也越大
优点:通过限制删除操作执行的时长和频率来减少删除操作对CPU的影响,同时能有效释放过期键占用的内存。
缺点:难以确定删除操作执行的时长和频率,如果执行的太频繁,会对CPU造成负担,就变成了定时删除;如果执行的太少,则过期键长时间占用的内存没有及时释放,造成内存浪费。
内存不足
当Redis的内存达到最大限制时,还会触发内存淘汰策略,策略不同决定哪些数据会被删除以腾出空间。
no eviction:禁止淘汰,达到内存限制时拒绝新的写请求。
allkeys-lru:从所有键中淘汰最近最少使用的键。
volatile-lru:从设置了过期时间的键中驱逐最近最少使用的键。
allkeys-random:从所有键中随机驱逐键。
volatile-random:从设置了过期时间的键中随机驱逐键。
volatile-ttl:从设置了过期时间的键中驱逐剩余时间最短的键。被动删除
惰性删除
Redis不会在键过期时立即删除它,而是在下一次访问这个键时检查其是否过期,然后删除过期的键。假设这个键已经过期,但是后面一直没有被访问,则会永远存在。不会被删除,这就是惰性删除。
惰性删除策略由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令在执行之前都会调用expireIfNeeded函数对输入键进行检查。如果输入键已经过期,那么expireIfNeeded函数将输入键从数据库中删除;如果输入键未过期,那么expireIfNeeded函数不做动作。
优点:惰性删除不会增加额外的系统开销,不浪费 cpu,只在访问时进行检查。
缺点:如果某个键永远不会被访问,即使设置了过期时间,它也不会被自动删除,造成内存泄漏问题。
缓存穿透
缓存穿透是指在高并发场景下,如果某一个key被高并发访问,但该key在缓存中不存在,那么请求会穿透到数据库查询。如果这个key在数据库中也不存在,就会导致每次请求都要到数据库去查询,给数据库带来压力。严重的缓存穿透会导致数据库宕机。可以看到核心的重点在于不命中和返回空。解决方案也围绕这些即可。
解决方案
1、 缓存空对象
当数据库中查不到数据时,缓存一个空对象(例如一个标记为空或不存在的对象),并给这个空对象的缓存设置一个过期时间。这样,下次再查询该数据时,就可以直接从缓存中拿到空对象,从而避免了不必要的数据库查询。
这种解决方式有两个缺点:
需要缓存层提供更多的内存空间来缓存这些空对象,当空对象很多时,会浪费更多的内存。
会导致缓存层和存储层的数据不一致,即使设置了较短的过期时间,也会在这段时间内造成数据不一致问题。比如缓存还是空对象,这个时候数据库已经有值了。这种引入复杂性,当数据库值变化的时候,要清空缓存。
1
2
3
4
5
6
7
8
9
10
11 >String key = "jichiKey";
>String value = redis.get(key);
>if (value == null) {
value = database.query(key);
if (value == null) {
// 缓存空结果,设置短过期时间
redis.set(key, "", 60); // 60秒过期
} else {
redis.set(key, value, 3600); // 1小时过期
}
>}2、 使用布隆过滤器
布隆过滤器用于检测一个元素是否在集合中。访问缓存和数据库之前,先判断布隆过滤器里面有没有这个 key,如果 key 存在,可以继续往下走,如果 key 不存在,就不用往下进行走了。比较适合数据 key 相对固定的场景。可以减少误识别率。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 >// expectedInsertions:预期插入的元素数量
>// falsePositiveProbability:可接受的误判率
>BloomFilter<String> bloomFilter = new BloomFilter<>(expectedInsertions, falsePositiveProbability);
// 初始化布隆过滤器,插入所有可能存在的键
>for (String key : allPossibleKeys) {
bloomFilter.put(key);
>}
>// 查询时使用布隆过滤器
String key = "jichiKey";
if (!bloomFilter.mightContain(key)) {
// 布隆过滤器判断不存在,直接返回
return null;
} else {
// 布隆过滤器判断可能存在,查询缓存和数据库
String value = redis.get(key);
if (value == null) {
value = database.query(key);
redis.set(key, value, 3600); // 1小时过期
}
return value;
>}3、缓存预热
在系统启动时,提前将热门数据加载到缓存中,可以避免因为请求热门数据而导致的缓存穿透问题。需要根据系统的实际情况和业务需求来判断是否需要对缓存进行预热。比如在一些高并发的系统下,提前预热可以大大减少毛刺的产生,以及提高性能和系统稳定。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 >
>public abstract class AbstractCache {
public void initCache(){}
public <T> T getCache(String key){
return null;
}
public void clearCache(){}
public void reloadCache(){
clearCache();
initCache();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 >// CommandLineRunner: 在应用程序启动后自动执行特定代码
>
>public class InitCache implements CommandLineRunner {
public void run(String... args) throws Exception {
//我要知道哪些缓存需要进行一个预热
ApplicationContext applicationContext = SpringContextUtil.getApplicationContext();
Map<String, AbstractCache> beanMap = applicationContext.getBeansOfType(AbstractCache.class);
//调用init方法
if(beanMap.isEmpty()){
return;
}
for(Map.Entry<String,AbstractCache> entry : beanMap.entrySet()){
AbstractCache abstractCache = (AbstractCache) SpringContextUtil.getBean(entry.getValue().getClass());
abstractCache.initCache();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 >
>public class CategoryCache extends AbstractCache {
private static final String CATEGORY_CACHE_KEY = "CATEGORY";
private RedisUtil redisUtil;
private RedisTemplate redisTemplate;
public void initCache() {
//跟数据库做联动了,跟其他的数据来源进行联动
redisUtil.set("category","知识");
}
public <T> T getCache(String key) {
if(!redisTemplate.hasKey(key).booleanValue()){
reloadCache();
}
return (T) redisTemplate.opsForValue().get(key);
}
public void clearCache() {
redisTemplate.delete(CATEGORY_CACHE_KEY);
}
}
缓存击穿
缓存击穿是指在高并发的情况下,某个热点key突然失效或者未被缓存,导致大量请求直接穿透到后端数据库,从而使得数据库负载过高,甚至崩溃的问题。
这里要注意一个点就是比如构建这个 key 的缓存需要一定的时间,例如当缓存没有,查询数据后,重新放入缓存的过程需要一定的时间,如果这个时候,不进行控制,可能有很多请求都在做同一件事构建缓存,可能会引发数据库的压力剧增,或者影响到第三方服务。
解决方案
1、互斥锁
在缓存失效时,通过加锁机制保证只有一个线程能访问数据库并更新缓存,其他线程等待该线程完成后再读取缓存。核心重点 :只有一个线程访问数据库和建立缓存。
根据上面的流程图,我们可以看到一个非常具体的实现步骤:
- 当缓存失效时,尝试获取一个分布式锁。
- 获取锁的线程去数据库查询数据并更新缓存。
- 其他未获取锁的线程等待锁释放后,再次尝试读取缓存。
假设没有双重检查:
- 线程A发现缓存为空
- 线程A获取锁
- 线程A正要查询数据库
- 线程B也发现缓存为空
- 线程B等待锁
- 线程A查询完毕,设置缓存
- 线程B获取锁后,若没有二次检查,会重复查询数据库
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 >public String getValue(String key) {
String value = redis.get(key);
if (value == null) {
// 尝试获取锁
boolean lockAcquired = redis.setnx("lock:" + key, "1");
if (lockAcquired) {
try {
// 双重检查锁,防止重复查询数据库
value = redis.get(key);
if (value == null) {
value = database.query(key);
redis.set(key, value, 3600); // 1小时过期
}
} finally {
// 释放锁
redis.del("lock:" + key);
}
} else {
// 等待锁释放,再次尝试获取缓存
while ((value = redis.get(key)) == null) {
try {
Thread.sleep(100); // 等待100毫秒
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
return value;
>}2、不过期
设置一个较长的缓存过期时间,同时在缓存中存储一个逻辑过期时间。当逻辑过期时间到达时,后台异步更新缓存,而不是让用户请求直接穿透到数据库。这种方案可以彻底防止请求打到数据库,不过就是造成了代码实现过于复杂,因为你需要尽可能的保持二者的一致。
实现步骤:
- 在缓存中存储数据时,附带一个逻辑过期时间。
- 读取缓存时,检查逻辑过期时间是否到达。
- 如果逻辑过期时间到达,异步线程去数据库查询新数据并更新缓存,但仍返回旧数据给用户,避免缓存失效时大量请求直接访问数据库。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 >class CacheEntry {
private String value;
private long expireTime;
public CacheEntry(String value, long expireTime) {
this.value = value;
this.expireTime = expireTime;
}
public String getValue() {
return value;
}
public boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
>}
>public String getValue(String key) {
CacheEntry cacheEntry = redis.get(key);
if (cacheEntry == null || cacheEntry.isExpired()) {
// 异步更新缓存
executorService.submit(() -> {
String newValue = database.query(key);
redis.set(key, new CacheEntry(newValue, System.currentTimeMillis() + 3600 * 1000)); // 1小时逻辑过期
});
}
return cacheEntry != null ? cacheEntry.getValue() : null;
>}方案对比
互斥锁要注意的点是,阻塞等待可能会存在死锁或者请求阻塞的情况,降低了高并发的吞吐量。
不过期这种方式,设置逻辑时间是一个非常考验功底的情况,设置的过程,数据不一致性的时间就越长,所以要考虑好方案和业务情况。互斥锁,就不存在这种问题。各有优势,按照情况来进行选择。
缓存雪崩
缓存雪崩是指在某一时刻,大量缓存数据同时失效,导致大量的请求直接穿透到数据库,瞬间给数据库带来巨大的压力,可能导致数据库崩溃或服务不可用。
常见原因
- 缓存数据过期时间相同:当缓存系统中大量数据的过期时间被设置为同一时间点或相近的时间段时,这些数据会同时失效,从而引发缓存雪崩。
- 缓存服务器故障:当缓存服务器发生故障时,如果没有有效的容错机制,缓存中的数据将无法被访问,系统可能直接请求后端服务或数据库,导致系统性能下降。
解决方案
设置合理的缓存过期时间
缓存过期时间的设置需要根据业务需求和数据的变化频率来确定。对于不经常变化的数据,可以设置较长的过期时间,以减少对数据库的频繁访问。对于经常变化的数据,可以设置较短的过期时间,确保缓存数据的实时性。总之就是尽量打散缓存的过期时间,最好做到均匀的时间分布,减轻系统同一时刻的压力。
使用热点数据预加载
预先将热点数据加载到缓存中,并设置较长的过期时间,可以避免在同一时间点大量请求直接访问数据库。可以根据业务需求,在系统启动或低峰期进行预热操作,将热点数据提前加载到缓存中。
热点数据预加载可以提升系统的性能和响应速度,减轻数据库的负载。
缓存高可用
缓存做成集群的形式,提高可用性,防止缓存挂掉后,造成的穿透问题。
当缓存服务器发生故障或宕机时,需要有相应的故障转移和降级策略。可以通过监控系统来及时发现缓存故障,并进行自动切换到备份缓存服务器。同时,可以实现降级策略,当缓存失效时,系统可以直接访问数据库,保证系统的可用性。通过缓存故障转移和降级策略,可以保证系统在缓存不可用或故障的情况下仍然可以正常运行,提高系统的稳定性和容错性。
setnx和setex的区别
SET:最基础的命令,setnx 和 setex 都是在此基础上进行变种。set 命令就是设置键值对,如果已经有值则覆盖,没值就放进去,不涉及过期时间的概念。
SETNX:是一个设置键-值对的命令,但仅在键不存在时才设置该键。如果键已经存在,则不进行任何操作。它是“Set if Not Exists”的缩写,即“如果不存在则设置”。
SETEX:这个命令用于为指定的键设置值及其过期时间。如果键已经存在,SETEX命令将会替换旧的值和过期时间。
命令使用
SETNX的语法为:SETNX key value。其中,key是要设置的键名,value是要设置的值。如果key不存在,则返回1表示设置成功;如果key已经存在,则返回0表示设置失败。
SETEX的语法为:SETEX key seconds value。其中,key是要设置的键名,seconds是过期时间(以秒为单位),value是要设置的值。如果设置成功,则返回“OK”。
应用场景
SETNX常用于分布式场景中的锁机制。例如,在多个客户端同时访问共享资源或执行关键操作时,可以使用SETNX命令尝试在Redis中设置一个特定的键作为锁键,从而确保只有一个客户端能够成功设置该键并执行关键操作。其他执行命令因为设置不成功,所以就可以认为是未获得到锁。
SETEX则用于为键设置值和过期时间。这在需要临时存储数据或限制数据有效期的场景中非常有用。例如,可以使用SETEX命令存储会话信息或缓存数据,并为其设置适当的过期时间以自动删除过期的数据。
redis快的原因
纯内存
Redis将数据存储在内存中,避免了大量访问数据库和直接读取磁盘数据的操作。内存的读写速度远超过磁盘I/O,使得Redis的数据访问非常迅速。
数据结构合理
Redis内部的数据结构都是为快速读写而设计的,如跳跃表、SDS(简单动态字符串)、链表和Hash等。基本都能够在 o(1)复杂度下完成大部分操作,比如 hash 的结构,想获取其中一个属性的值,非常的方便,不像数据库查询需要磁盘寻找。
单线程操作
Redis采用单线程模型来处理客户端请求,避免了多线程带来的上下文切换和竞争条件。单线程模型使得Redis不需要考虑各种锁的问题,减少了性能消耗。但是持久化,异步删除等等是异步线程处理,但这不影响性能。不过要注意一个点,因为单线程,某个命令如果耗时太大,可能会产生阻塞,也就是我们经常说的,不要使用 keys,或者直接读整个 hash 大 key。
io 多路复用模型
Redis在网络通信和磁盘写入方面采用了异步式的IO处理,即使用epoll多路复用技术同时处理多个网络请求,减少了I/O阻塞及上下文切换开销,提高了系统的吞吐量和响应时间。
专门设计的数据结构
redis 的每种数据类型对应的底层存储结构都不一样,经历过多种方式的设计。拿 string 类型来进行说,如果存储数字的话,是用int类型的编码。如果存储非数字,小于等于39字节的字符串,是embstr。大于39个字节,则是raw编码。这种根据类型和字节数的设计,在 key 越多的场景下,占用空间越少。
持久化方式
RDB(Redis Database)和AOF(Append Only File)
Redis 4.0引入了混合持久化模式
RDB(Redis Database)
RDB持久化方式会在指定的时间间隔内生成数据集的快照,并将其保存到磁盘上。这个快照文件的默认名称是dump.rdb。
RDB的配置可以在redis.conf文件中进行。例如:
1
2
3 >save 900 1 # 如果900秒(15分钟)内至少有1个键发生变化,就触发一次RDB快照
>save 300 10 # 如果300秒(5分钟)内至少有10个键发生变化,就触发一次RDB快照
>save 60 10000 # 如果60秒(1分钟)内至少有10000个键发生变化,就触发一次RDB快照rdb的优势与劣势
AOF(Append Only File)
AOF持久化方式记录每一个写操作到日志文件中(默认名称是appendonly.aof)。Redis会将这些写操作以追加的方式写入到AOF文件中。
AOF的配置可以在redis.conf文件中进行。例如:
1
2
3
4
5
6 >appendonly yes # 启用AOF持久化
>appendfilename "appendonly.aof"
>appendfsync everysec # 每秒钟同步一次AOF文件
># 其他选项:
># appendfsync always # 每个写操作都同步到AOF文件,性能较差但数据最安全
># appendfsync no # 由操作系统决定何时同步,性能最好但数据安全性较差aof的优势与劣势
混合持久化(Hybrid Persistence)
混合持久化模式结合了RDB和AOF的优点。在Redis 4.0及以上版本中,混合持久化模式在生成新的AOF文件时,会首先创建一个RDB快照,然后在快照之后追加AOF日志。这种方式可以在保证数据恢复速度的同时,减少数据丢失的风险。
混合持久化的配置可以在redis.conf文件中进行。
1 >aof-use-rdb-preamble yes # 启用混合持久化模式优点:
1、 结合了RDB和AOF的优点,既能快速恢复数据,又能减少数据丢失的风险。
选择建议
RDB:适用于对数据一致性要求不高,但需要快速恢复数据的场景,例如缓存服务器。
AOF:适用于对数据一致性要求高的场景,例如金融交易系统。
混合持久化:适用于需要综合考虑数据恢复速度和数据一致性的场景。
redis事务机制
redis中事务是一组命令的集合,一组命令要么全部执行,要么全部不执行。事务在Redis中是通过流水线(Pipeline)技术实现的,所有命令在执行之前都会被放入一个队列中,直到执行EXEC命令时,所有命令才会按顺序执行。
命令
MULTI
MULTI命令用于标记一个事务的开始。执行MULTI后,所有的命令都会被放入一个队列中,而不是立即执行。
EXEC
EXEC命令用于执行从MULTI命令开始后放入队列中的所有命令。所有命令会按顺序执行,并且在执行过程中不会被其他客户端的命令打断。Redis事务在执行EXEC命令时具有原子性,即所有命令要么全部执行,要么全部不执行。Redis事务并不支持回滚机制。如果在事务执行过程中发生错误,已经执行的命令不会被回滚。
DISCARD
DISCARD命令用于放弃从MULTI命令开始后放入队列中的所有命令,并且取消事务。
WATCH
WATCH命令用于监视一个或多个键,在事务执行之前,如果这些键被其他客户端修改,事务将被中止。WATCH命令通常用于实现乐观锁。这样可以防止事务中的数据竞争问题。
事务的工作原理
事务的执行过程
1、 开始事务:使用MULTI命令开始一个事务。
2、 命令入队:在事务开始之后,所有的命令都会被放入队列中,而不是立即执行。
4、 执行事务:使用EXEC命令执行队列中的所有命令。如果在使用WATCH监视的键在事务执行前被修改,事务将被中止。
4、 放弃事务:使用DISCARD命令可以放弃当前事务队列中的所有命令。
1
2
3
4
5
6
7 ># 开始事务
>MULTI
># 添加命令到事务队列
>SET key1 value1
>SET key2 value2
># 执行事务
>EXEC如果在事务执行之前,使用WATCH命令监视了某个键,并且该键在事务执行前被修改,事务将被中止:
1
2
3
4
5
6
7
8
9 ># 监视键
>WATCH key1
># 开始事务
>MULTI
># 添加命令到事务队列
>SET key1 value1
>SET key2 value2
># 执行事务(如果key1在此之前被修改,事务将被中止)
>EXEC在EXEC命令执行时,所有被MULTI命令包裹的命令会按顺序一次性执行。意味着在EXEC执行时,Redis会将所有命令作为一个整体进行处理。Redis保证单个命令的原子性,即每个命令在执行时是不可分割的。
但是,Redis事务并不完全等同于传统关系型数据库的事务。
如果在EXEC执行过程中某个命令失败(例如,命令语法错误),该命令会被跳过,但其他命令仍然会继续执行。这与关系型数据库的事务不同,后者通常会在某个命令失败时回滚整个事务。
Redis事务没有回滚机制。如果某个命令执行失败,已经执行的命令不会被撤销。
哨兵模式(sentinal)与集群模式(cluster)的区别
哨兵模式
Sentinel是一种用于监控Redis主从复制结构并实现自动故障转移的系统。它主要关注的是高可用性,保证当主服务器发生故障时,能够自动将一个从服务器提升为新的主服务器,并通知客户端进行相应的切换。
集群模式
Cluster是一种分布式存储解决方案,支持自动分片和高可用性。支持主从复制和故障转移,还能够将数据分布在多个节点上,实现数据的水平扩展。
哨兵模式 集群模式 架构区别 Sentinel节点监控主从服务器的状态,当主服务器故障时,Sentinel节点会选举一个新的主服务器。 数据自动分片存储在多个节点上,每个节点负责一部分数据。每个分片有一个主节点和一个或多个从节点。使用Gossip协议进行节点间通信,自动检测故障并进行主从切换。 数据存储 数据存储在一个主服务器及其从服务器上,不进行数据分片。 数据通过哈希槽(hash slots)机制分布在多个节点上,每个节点负责一部分哈希槽。 扩展性 只提供高可用性,不能水平扩展数据存储容量。 支持水平扩展,可以通过增加节点来扩展数据存储容量和处理能力。 高可用 通过心跳机制检测主服务器的状态,自动选举新的主服务器并更新拓扑结构。 使用Gossip协议进行节点间的故障检测,自动进行主从切换,确保集群的高可用性。 使用场景 适用于小规模的Redis部署,主要关注高可用性。不需要数据分片。 适用于大规模的Redis部署,支持水平扩展。需要同时满足高可用性和数据分片、扩展性需求。
常见性能问题和解决方案
redis 内存空间不足
由于Redis的数据存储在内存中,当数据量增大时,可能会出现内存不足的情况,导致性能下降或服务不可用。
解决方案:
内存优化:使用更高效的数据结构(如哈希表、压缩列表)来存储数据,减少内存占用。
水平扩展:使用Redis集群模式,将数据分片存储在多个节点上,扩展内存容量。
redis 的大 key
某些键可能存储了大量数据(如大列表、大哈希表),操作这些大键可能导致阻塞,影响性能。
解决方案:
拆分大键:将大键拆分成多个小键,减少单个键的操作时间。
分批处理:对于需要迭代处理的大键,使用SCAN、SSCAN、HSCAN、ZSCAN等命令进行分批处理,避免单次操作时间过长。
监控和预警:定期监控Redis中的大键,及时发现并处理。
阻塞操作
某些Redis命令(如KEYS、FLUSHALL、SAVE等)会阻塞服务器,导致其他操作无法执行。
解决方案:
避免阻塞命令:尽量避免使用阻塞命令,使用非阻塞的替代命令(如SCAN代替KEYS)。
异步操作:对于需要执行的阻塞操作,尽量使用异步方式(如FLUSHALL ASYNC)。
网络延迟
Redis是基于TCP协议的网络服务,高网络延迟会影响Redis的性能。
解决方案:
本地部署:尽量将Redis服务器部署在与应用服务器同一内网,减少网络延迟。
连接池:使用连接池来复用Redis连接,减少连接建立和关闭的开销。
慢查询
某些复杂的查询或数据操作可能会导致Redis响应变慢,影响整体性能。
解决方案:
慢查询日志:启用Redis的慢查询日志功能,定期检查慢查询并优化。
索引优化:合理使用Redis的数据结构和索引,优化查询性能。
主从复制延迟
在主从复制架构中,从服务器可能会因为网络或负载问题导致复制延迟,影响数据一致性。
解决方案:
优化网络:确保主从服务器之间的网络连接稳定,带宽充足。
调整复制参数:优化Redis的复制参数(如repl-backlog-size、repl-timeout等),减少复制延迟。
监控复制状态:定期监控主从复制状态,及时发现并处理延迟问题。
持久化性能问题
Redis的持久化操作(如RDB快照和AOF日志)可能会影响性能,尤其是在大数据量或高并发情况下。
解决方案:
合理配置持久化策略:根据业务需求配置合理的持久化策略,平衡性能和数据安全性。
异步持久化:使用异步持久化方式(如AOF的fsync策略),减少对主线程的影响。
jedis和redisson的对比
Jedis和Redisson是两种常用的Java Redis客户端。Jedis是一个轻量级的Redis客户端,易于集成和使用。Redisson是一个在Redis的基础上实现的Java驻内存数据网格。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。
Jedis
优点
直观的API:提供了直接且简单的API,便于操作Redis的各种数据结构和命令。
性能高:由于其轻量级特性,Jedis在单线程操作中性能较高。
广泛使用:Jedis是较早的Java Redis客户端之一,有着广泛的社区支持和文档资源。
缺点
线程安全性:Jedis实例不是线程安全的,需要通过连接池(JedisPool)来管理连接,增加了复杂性。
功能有限:Jedis主要提供了对Redis命令的直接封装,缺乏高级特性,如分布式锁、限流器等。
集群支持:虽然Jedis支持Redis集群,但配置和使用相对复杂,且在某些场景下性能不如Redisson。
Redisson
优点
线程安全:Redisson的所有对象都是线程安全的,简化了多线程环境下的使用。
高级特性:提供了许多高级特性,如分布式锁、分布式集合、分布式队列、分布式缓存、限流器等,适合复杂的分布式系统。
易用性:Redisson的API设计更加面向对象,提供了丰富的分布式数据结构和并发工具,使开发更加简便。
集群支持:Redisson对Redis集群的支持更加友好和高效,配置和使用相对简单。
缺点
重量级:Redisson的功能丰富,但也带来了较大的依赖包和内存占用,相比Jedis更为重量级。
性能开销:由于提供了许多高级特性,Redisson在某些场景下的性能可能不如Jedis。
学习曲线:Redisson的API和功能较多,学习和掌握所有特性需要一定的时间。
选择建议
1、 如果你的应用场景比较简单,只需要基本的Redis操作,并且对性能有较高要求,Jedis是一个不错的选择。
2、复杂分布式系统:如果你的应用需要使用Redis的高级特性,如分布式锁、限流器、分布式集合等,或者需要在多线程环境中使用Redis,Redisson会更合适。
3、 集群支持:如果需要使用Redis集群,Redisson的配置和使用相对简单、性能较好,更加推荐使用。
内存回收
当Redis的内存用完时,会根据配置的内存回收策略采取不同的措施。可以在内存达到限制时决定如何处理新的写请求。主要的策略有如下 8 种。
内存回收策略
- noeviction:不删除任何键,当内存不足时返回错误。这是默认策略。
当内存达到限制时,Redis将不再接受任何写请求,并返回错误。例如,客户端尝试设置新键时,会收到类似以下的错误信息:
1 >(error) OOM command not allowed when used memory > 'maxmemory'.
allkeys-lru:使用最近最少使用(LRU)算法回收所有键。
volatile-lru:使用最近最少使用(LRU)算法回收设置了过期时间的键。
allkeys-random:随机回收所有键。
volatile-random:随机回收设置了过期时间的键。
volatile-ttl:回收那些剩余生存时间(TTL)最短的键。
volatile-lfu:使用最长时间没有被使用(LFU)算法回收设置了过期时间的键。
allkeys-lfu:使用最长时间没有被使用(LFU)算法回收所有键。
配置内存回收策略的方式
redis.conf文件中配置内存回收策略
1
2 >maxmemory 100mb
>maxmemory-policy allkeys-lru也可通过命令行参数设置
1 >redis-server --maxmemory 100mb --maxmemory-policy allkeys-lru
实现延时队列
可以使用有序集合(Sorted Set)来实现延时队列。有序集合中的每个元素有一个关联的分数,可以用来表示任务的执行时间戳。具体的步骤如下,非常简单
添加任务到延时队列
将任务添加到有序集合中,使用任务的执行时间作为分数(score)。
1
2
3
4
5
6
7
8
9
10 >// 示例代码:添加任务到延时队列
>String queueName = "delay_queue";
>String taskId="task_1";
>long delay=5000; // 延迟时间(毫秒)
>long executionTime= System.currentTimeMillis() + delay;
>Jedis jedis = newJedis("localhost");
>// key(操作的集合名称) Score(任务的执行时间) Member(任务的唯一标识符)
>//将 taskId 添加到名为 queueName 的有序集合中,其排序依据是 executionTime(分数)。
>jedis.zadd(queueName, executionTime, taskId);
>jedis.close();轮询延时队列并执行任务
定期检查有序集合中的任务,找到那些执行时间已经到达或超过当前时间的任务,并执行这些任务。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 >// 示例代码:轮询延时队列并执行任务
>String queueName = "delay_queue";
>Jedis jedis=new Jedis("localhost");
>while (true) {
long currentTime= System.currentTimeMillis();
// key ScoreMin ScoreMax offset偏移量 count返回数量限制
Set<Tuple> tasks = jedis.zrangeByScoreWithScores(queueName, 0, currentTime, 0, 1);
if (tasks.isEmpty()) {
// 没有任务需要执行,休眠一段时间
Thread.sleep(1000);
continue;
}
for (Tuple task : tasks) {
StringtaskId= task.getElement();
// 执行任务
executeTask(taskId);
// 从队列中移除已执行的任务
jedis.zrem(queueName, taskId);
}
>}
>jedis.close();
>private static void executeTask(String taskId) {
// 实现任务执行逻辑
System.out.println("Executing task: " + taskId);
>}
使用redis统计网站的uv
使用Set统计UV
Set是一种集合数据结构,可以存储不重复的元素。将每个访客的唯一标识(如用户ID或IP地址)添加到Set中,可以很方便地统计独立访客数。
- 记录访客访问:每次有访客访问时,将其唯一标识添加到当天的Set中。
- 获取UV:使用SCARD命令获取Set中元素的数量,即为独立访客数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56 >import redis.clients.jedis.Jedis;
>import java.time.LocalDate;
>import java.time.format.DateTimeFormatter;
>public class UVTrackerSet {
private Jedis jedis;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
public UVTrackerSet(String redisHost, int redisPort) {
this.jedis = newJedis(redisHost, redisPort);
}
public void recordVisit(String userId) {
String date= LocalDate.now().format(DATE_FORMATTER);
String key="uv:set:" + date;
jedis.sadd(key, userId);
// 设置键的过期时间为30天,防止内存无限增长
jedis.expire(key, 30 * 24 * 60 * 60);
}
public long getUV(String date) {
String key="uv:set:" + date;
return jedis.scard(key);
}
public long getUVRange(String startDate, String endDate) {
LocalDatestart= LocalDate.parse(startDate, DATE_FORMATTER);
LocalDateend= LocalDate.parse(endDate, DATE_FORMATTER);
String[] keys = start.datesUntil(end.plusDays(1))
.map(date -> "uv:set:" + date.format(DATE_FORMATTER))
.toArray(String[]::new);
StringtempKey="uv:set:range";
jedis.sunionstore(tempKey, keys);
longuvCount= jedis.scard(tempKey);
jedis.del(tempKey);
return uvCount;
}
public static void main(String[] args) {
UVTrackerSet tracker = new UVTrackerSet("localhost", 6379);
// 记录访客访问
tracker.recordVisit("user_123");
tracker.recordVisit("user_456");
// 获取指定日期的UV
Stringtoday= LocalDate.now().format(DATE_FORMATTER);
System.out.println("UV for " + today + ": " + tracker.getUV(today));
// 获取一段时间内的UV
StringstartDate="2023-07-01";
StringendDate="2023-07-07";
System.out.println("UV from " + startDate + " to " + endDate + ": " + tracker.getUVRange(startDate, endDate));
}
>}使用HyperLogLog统计UV\
HyperLogLog是一种概率性数据结构,可以在固定的内存空间内提供高效的基数估计。它适合处理大规模数据。
- 记录访客访问:每次有访客访问时,将其唯一标识添加到当天的HyperLogLog中。
- 获取UV:使用PFCOUNT命令获取HyperLogLog的基数估计。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57 >import redis.clients.jedis.Jedis;
>import java.time.LocalDate;
>import java.time.format.DateTimeFormatter;
>public class UVTrackerHLL {
private Jedis jedis;
private static final DateTimeFormatter DATE_FORMATTER= DateTimeFormatter.ofPattern("yyyy-MM-dd");
public UVTrackerHLL(String redisHost, int redisPort) {
this.jedis = newJedis(redisHost, redisPort);
}
public void recordVisit(String userId) {
Stringdate= LocalDate.now().format(DATE_FORMATTER);
Stringkey="uv:hll:" + date;
jedis.pfadd(key, userId);
// 设置键的过期时间为30天,防止内存无限增长
jedis.expire(key, 30 * 24 * 60 * 60);
}
public long getUV(String date) {
Stringkey="uv:hll:" + date;
return jedis.pfcount(key);
}
public long getUVRange(String startDate, String endDate) {
LocalDatestart= LocalDate.parse(startDate, DATE_FORMATTER);
LocalDateend= LocalDate.parse(endDate, DATE_FORMATTER);
String[] keys = start.datesUntil(end.plusDays(1))
.map(date -> "uv:hll:" + date.format(DATE_FORMATTER))
.toArray(String[]::new);
StringtempKey="uv:hll:range";
jedis.pfmerge(tempKey, keys);
longuvCount= jedis.pfcount(tempKey);
jedis.del(tempKey);
return uvCount;
}
public static void main(String[] args) {
UVTrackerHLLtracker=newUVTrackerHLL("localhost", 6379);
// 记录访客访问
tracker.recordVisit("user_123");
tracker.recordVisit("user_456");
// 获取指定日期的UV
Stringtoday= LocalDate.now().format(DATE_FORMATTER);
System.out.println("UV for " + today + ": " + tracker.getUV(today));
// 获取一段时间内的UV
StringstartDate="2023-07-01";
StringendDate="2023-07-07";
System.out.println("UV from " + startDate + " to " + endDate + ": " + tracker.getUVRange(startDate, endDate));
}
>}
set HyperLogLog 精准度 精确统计,无误差 存在一定误差(通常在0.81%左右) 占用内存 内存占用较大,尤其是当访客数量很大时 内存占用小,通常只需要12KB内存。 内存占用情况 小数据量,同时对内存不敏感可以 适合大规模数据
看门狗机制的原理
Redisson 的看门狗机制是一种用于自动续约分布式锁的机制,确保在持有锁的客户端处理完业务逻辑之前,锁不会过期。比如,我们平时使用分布式锁的时候,一般会设置一个锁的过期时间,那么如果锁过期的时候,业务还没执行完怎么办,于是就有了看门狗。
原理
初始锁定:
当客户端获取到锁时,会在 Redis 中设置一个键(代表锁)和一个过期时间(默认30秒)。同时,Redisson 会启动一个后台任务(看门狗),这个任务会定期检查锁的状态。
自动续约:
看门狗任务会每隔一段时间(默认是锁的过期时间的1/3,即10秒)检查锁的状态。如果锁仍然被持有(即客户端还在持有锁且没有释放),看门狗任务会将锁的过期时间重置为初始值(例如,再次设置为30秒)。这样,锁的过期时间不断被延长,直到客户端明确释放锁或者客户端挂掉。
释放锁:
当客户端完成业务逻辑后,会显式地调用unlock()方法释放锁。一旦锁被释放,看门狗任务会停止续约,锁在 Redis 中的键会被删除或自然过期。
看门狗机制的工作流程
获取锁:客户端请求获取锁,Redis 中创建一个键表示锁,并设置一个过期时间(例如30秒)。启动看门狗任务,定期检查锁的状态。
定期续约:看门狗任务每隔一定时间(例如10秒)检查锁的状态。如果锁仍然被持有(即客户端还在处理业务逻辑),看门狗任务会重置锁的过期时间(例如,再次设置为30秒)。
锁的释放:客户端业务逻辑完成后,调用unlock()方法释放锁。看门狗任务停止续约,锁在 Redis 中的键被删除或自然过期。
看门狗机制的优势
高可靠性:通过自动续约机制,确保锁在持有者处理完业务逻辑之前不会过期,避免了锁意外过期导致的并发问题。
自动管理:无需手动续约锁的过期时间,简化了分布式锁的使用和管理。
容错性:如果客户端在持有锁期间崩溃或断开连接,锁会在过期时间后自动释放,避免了死锁问题。
示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 >import org.redisson.api.RLock;
>import org.redisson.api.RedissonClient;
>import java.util.concurrent.TimeUnit;
>public class RedissonLockExample {
public static void main(String[] args) {
RedissonClient redissonClient = RedissonConfig.createClient();
RLock lock = redissonClient.getLock("myLock");
try {
// 尝试获取锁,等待时间为100秒,锁的过期时间为10秒
if (lock.tryLock(100, 10, TimeUnit.SECONDS)) {
try {
// 业务逻辑
System.out.println("Lock acquired, executing business logic");
// 模拟长时间运行的任务
Thread.sleep(20000);
} finally {
lock.unlock();
System.out.println("Lock released");
}
} else {
System.out.println("Could not acquire lock");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
redissonClient.shutdown();
}
}
>}
分布式锁的特点
分布式锁主要有三个特点,是我们要时刻进行注意的。
- 互斥性:在某一时刻,只有一个客户端可以持有锁。
- 容错性:即使某个持有锁的客户端崩溃或失去连接,锁也能够被其他客户端重新获取。
- 高可用性:锁服务需要高可用,通常需要在分布式环境中实现。
分布式锁的实现方式
基于数据库
使用数据库的SELECT … FOR UPDATE 语句或类似的行级锁机制来实现分布式锁。优点是实现简单,缺点是性能较低,依赖于数据库的高可用性。高并发情况下也会对数据库造成非常大的压力。
1
2
3
4
5 >-- 获取锁
>SELECT * FROM locks WHERE resource = 'resource_name' FOR UPDATE;
>-- 释放锁
>DELETE FROM locks WHERE resource = 'resource_name';基于 Redis
Redis 提供了原子操作和高性能的特性,非常适合用来实现分布式锁。通常使用SETNX命令来实现。
1
2
3
4
5
6
7
8
9
10 >// 获取锁
>String result = jedis.set("lock_key", "lock_value", "NX", "PX", 30000);
>if ("OK".equals(result)) {
// 锁获取成功
>}
>// 释放锁
>if (lock_value.equals(jedis.get("lock_key"))) {
jedis.del("lock_key");
>}基于 Zookeeper
Zookeeper 提供了分布式协调服务,可以用来实现分布式锁。通过创建临时顺序节点来实现锁机制。
1
2
3
4
5
6
7
8
9 >// 创建一个临时顺序节点
>String path = zookeeper.create("/locks/lock-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
>// 检查是否获取到锁
>List<String> children = zookeeper.getChildren("/locks", false);
>Collections.sort(children);
>if (path.equals("/locks/" + children.get(0))) {
// 获取到锁
>}分布式锁的使用场景
- 分布式事务:在分布式系统中,需要确保多个节点上的操作在同一事务中执行。
- 资源共享:如分布式系统中的限流、分布式任务调度等场景。
- 数据一致性:在多个节点并发访问同一资源时,确保数据一致性。
分布式锁的常见常见问题
- 死锁:如果某个节点在持有锁期间崩溃或失去连接,可能会导致其他节点无法获取锁。
- 性能:分布式锁的实现需要考虑性能问题,尤其是在高并发场景下。
- 可靠性:锁服务需要高可用,通常需要在分布式环境中实现。
分布式锁的改进
- 锁过期时间:设置锁的过期时间,避免死锁问题。
- 租约机制:使用租约机制,定期续约锁,确保锁在持有期间不会被其他节点获取。
- 锁竞争优化:使用合适的锁竞争算法,减少锁竞争的开销。