为什么Redis要有淘汰机制?
淘汰机制的存在是必要的,因为Redis是一种基于内存的数据库,所有数据都存储在内存中。然而,内存资源是有限的。在Redis的配置文件redis.conf中,有一个关键的配置项:
# maxmemory <bytes> // Redis可以使用的最大内存量
这个配置项决定了Redis能够占用的最大内存空间。官方文档提供了详细信息:Redis Eviction Policies。
如果maxmemory设置为0,Redis将默认使用所有可用内存,但对于32位系统,其隐式最大值为3GB。若不实施淘汰机制,Redis的内存一旦填满,就无法再存储新的数据,这将严重影响Redis的功能发挥。因此,为了保证Redis的持续可用性,我们必须采取一定的策略来管理内存中的数据,确保Redis能够高效运行。
Redis淘汰策略
由于Redis的内存空间是有限的,且内存中的数据可能尚未过期,当内存接近满载时,如果没有过期的数据可供淘汰,那么内存将会达到其容量上限。一旦内存满了,Redis将无法再接受新的数据写入,这会影响到其正常功能的发挥。因此,我们不得不采取一些策略来应对这一问题,以确保Redis的持续可用性和高效运行。这些策略帮助我们在内存空间紧张时合理地管理数据,避免服务因内存不足而中断。在Redis中,处理内存满载的情况是通过配置特定的淘汰策略来实现的。
根据Redis官方文档的介绍(Redis Eviction Policies),当达到maxmemory
限制时,Redis的行为是通过maxmemory-policy
配置指令来设定的。通过在配置文件中设置maxmemory-policy
,我们可以选择适合我们需求的淘汰策略。
# maxmemory-policy noeviction // 默认策略,不淘汰数据,只允许读操作,不允许写操作
在Redis中,当内存达到上限时,不同的淘汰策略决定了如何处理新数据的写入和老数据的移除。以下是官方提供的几种淘汰策略的详细说明:
- noeviction:这是默认策略,当内存达到限制时,不会淘汰任何数据,只允许读取操作,但不允许写入新数据。在主数据库使用复制功能时,这一策略同样适用。
- allkeys-lru:该策略会保留最近最常使用的数据,并淘汰最久未使用的数据(LRU)。这是基于一种近似LRU算法,作用于所有的键。
- allkeys-lfu:该策略会保留最频繁使用的数据,并淘汰使用频率最低的数据(LFU)。这也是基于一种近似LFU算法,作用于所有的键。
- volatile-lru:与allkeys-lru类似,但这个策略只作用于那些设置了过期时间(expire field)的键。
- volatile-lfu:与allkeys-lfu类似,但这个策略只作用于那些设置了过期时间的键。
- allkeys-random:该策略会随机淘汰数据,以为新数据的写入腾出空间。这是基于随机算法,作用于所有的键。
- volatile-random:与allkeys-random类似,但这个策略只作用于设置了过期时间的键。
- volatile-ttl:这个策略会淘汰那些设置了过期时间且剩余生存时间(TTL)最短的键。
这8种策略可分为「不进行数据淘汰」和「进行数据淘汰」两类策略。
1、不进行数据淘汰的策略
noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,会报错通知禁止写入,不淘汰任何数据,但是如果没用数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作。
2、进行数据淘汰的策略
针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。
a)在设置了过期时间的数据中进行淘汰:
- volatile-random:随机淘汰设置了过期时间的任意键值;
- volatile-ttl:优先淘汰更早过期的键值。淘汰即将过期的键,即选择生存时间(TTL)最短的键优先删除。
- volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
- volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;利用LFU计数器跟踪键的访问频次,较少访问的键更可能被选中淘汰。
b)在所有数据范围内进行淘汰:
- allkeys-random:随机淘汰任意键值;对所有键进行随机选择并删除,不考虑访问频率或过期时间。
- allkeys-lru:淘汰整个键值中最久未使用的键值;不区分键是否设置过期时间;适用于希望在整体数据集层面维持热点数据的场景。
- allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。同样利用LFU计数器,但决策范围扩大至整个数据集。
Redis内存淘汰触发条件的相关配置如下:
Redis通过配置项maxmemory
来设定其允许使用的最大内存容量。当Redis实际占用的内存达到这一阈值时,将触发内存淘汰机制,开始删除部分数据以释放内存空间,防止服务因内存溢出而异常。
Redis内存淘汰策略可在配置文件redis.conf
中通过maxmemory-policy
参数设定,或者在运行时使用CONFIG SET
命令动态修改。适时监控Redis内存使用情况,并根据实际需求调整淘汰策略,是保证服务高效稳定的关键运维工作。
Redis淘汰流程
Redis的淘汰流程是一个精心设计的过程,旨在高效地管理内存使用。以下是详细的淘汰流程:
-
初始化淘汰池:Redis维护一个淘汰池,默认大小为16。这个池子采用末尾淘汰制,即最不适合保留的数据会被放置在池子的末尾。
-
内存检查:每次执行指令前,Redis会自旋检查当前可用内存是否足够执行该指令。
-
内存不足时的处理:
- 如果内存不足以执行指令,Redis会从淘汰池的尾部选择一个最合适的候选数据进行淘汰。
- 取样过程:为了提高效率,Redis不会检查所有数据,而是通过配置
maxmemory-samples
参数来随机选取一定数量的样本数据。 - 选择淘汰数据:在这些样本数据中,根据配置的淘汰算法(如LRU或LFU),找到最应该被淘汰的数据。
- 比较与替换:将找到的最适合淘汰的数据与淘汰池中的数据进行比较,如果它比淘汰池中的任何数据更适合淘汰,则将其放入淘汰池。
- 排序:淘汰池中的数据会根据适合淘汰的程度进行排序,最应该淘汰的数据被放置在池子的尾部。
-
淘汰数据:最终,被选为淘汰的数据会被从Redis中删除,并且从淘汰池中移除。
淘汰池的设计意义在于它提高了数据淘汰的精准度。由于淘汰池中只保留了每次取样后最适合淘汰的16个数据,这确保了Redis在内存紧张时能够高效、精确地释放空间。
LRU算法
LRU(Least Recently Used)算法的核心思想是,如果一个数据在一段时间内没有被访问,那么在未来的时间内被访问的可能性也很小。因此,当需要淘汰数据以释放空间时,最久未使用的数据会被优先考虑淘汰。
衡量标准: LRU算法的衡量标准确实是基于时间。具体来说,是根据数据最后一次被访问的时间来衡量。数据越长时间未被访问,就越有可能被淘汰。因此,衡量标准是数据的“最近使用时间”,而不是绝对的时间点。
实现原理: 为了实现LRU算法,以下是所需的步骤和考虑因素:
-
记录访问时间:LRU算法需要跟踪每个对象的最后访问时间。这通常通过在数据结构中增加一个时间戳来实现。
-
计算未访问时长:一旦知道了对象的最后访问时间,就可以通过将这个时间戳与当前系统时间进行比较,来计算对象自上次被访问以来已经过去的时间。
以下是LRU算法的具体实现步骤:
-
维护访问时间:每当对象被访问时,更新其最后访问时间。这可以通过将对象移动到某种数据结构的头部(表示最近访问)来实现,或者直接更新对象的时间戳。
-
淘汰策略:
- 当需要淘汰数据时,算法会检查所有对象的最后访问时间。
- 选择最久未访问的对象进行淘汰。在实现时,这通常意味着从数据结构的尾部选择对象,因为尾部对象是最久未被访问的。
-
数据结构选择:为了高效地实现LRU算法,通常使用一种结合了哈希表和双向链表的数据结构。哈希表用于快速定位对象,而双向链表则用于维护对象的访问顺序。
通过这种方式,LRU算法能够确保最久未使用的数据被优先淘汰,从而优化缓存和内存的使用效率。
在Redis中,所有的数据类型,如字符串、列表、集合等,都会被封装在一个名为redisObject
的通用数据结构中。这个redisObject
结构是Redis内部用来表示所有数据类型的抽象层,它包含了一些通用的元数据和指向实际值的指针。
redisObject
结构中确实包含了一个名为lru
的字段,这个字段用于记录对象的最后一次被访问时间。以下是redisObject
结构的一些关键字段,以及lru
字段的作用:
- type:指示对象的数据类型(字符串、列表、集合等)。
- encoding:指示对象内部使用的编码方式。
- ptr:指向实际存储数据的地方的指针。
- lru:记录对象最后一次被访问的时间戳。
lru
字段对于实现LRU淘汰策略至关重要,因为它允许Redis:
- 跟踪访问时间:每当对象被访问时,Redis会更新其
lru
字段为当前时间戳。 - 确定淘汰候选:当需要进行内存淘汰时,Redis会检查所有对象的
lru
字段,找出最久未被访问的对象作为淘汰的候选。
由于lru
字段的存在,Redis能够高效地实现LRU淘汰机制,确保内存中保留的是最活跃的数据,而淘汰那些长时间未被访问的数据。这种机制对于缓存系统来说尤其重要,因为它能够保证缓存中的数据是最有可能被再次访问的。
LRU流程图
LFU算法
LFU(Least Frequently Used)算法的原理是,数据被访问的次数越少,就越有可能被淘汰。这个算法通过记录每个对象的访问次数来决定哪些数据应该被淘汰。
LFU的实现:
- 记录访问次数:每当对象被访问时,LFU算法会增加该对象的访问计数。
- 淘汰策略:在需要淘汰数据时,LFU算法会比较所有对象的访问次数,并选择访问次数最少的对象进行淘汰。
LFU的时效性问题: LFU算法存在一个时效性问题,即它只考虑访问次数,而不考虑时间因素。这意味着即使一些数据很久以前被频繁访问,它们的访问次数依然很高,这会导致新数据难以进入缓存,而旧数据却难以被淘汰。
Redis解决LFU时效性的方法: Redis为了解决LFU算法的时效性问题,实现了一种更为复杂的LFU算法,该算法不仅考虑访问次数,还考虑了时间因素。以下是Redis中LFU算法的一些特点:
-
递减计数:Redis的LFU算法会对访问计数进行递减处理,使得随着时间的推移,旧数据的访问计数会逐渐降低,从而让新数据有机会进入缓存。
-
访问频率的衰减:Redis的LFU算法对访问频率进行衰减处理,这意味着即使一个键被频繁访问,它的计数也会随时间逐渐减少,这有助于防止旧数据长期占据缓存。
-
初始计数优势:新数据在初始阶段会有一个较高的计数,这样它们就不容易被立即淘汰,从而有机会在缓存中积累更多的访问次数。
-
计数更新策略:Redis的LFU算法在更新计数时,不仅考虑当前的访问,还会根据一定的时间窗口来调整计数,使得算法能够更好地适应数据访问模式的变化。
通过这些机制,Redis的LFU算法能够在考虑数据访问频率的同时,也考虑到数据的时效性,从而更合理地管理缓存中的数据。
lfu-decay-time 1 //多少分钟没操作访问就去衰减一次
LFU增加逻辑
Redis中LFU算法的增加逻辑可以概括为以下几个要点:
-
计数上限:在Redis中,LFU的计数器有一个最大值,通常是255。一旦计数器达到这个最大值,就不会再增加。由于255对于大多数应用场景来说足够大,因此这种情况发生的概率并不高,足以支持非常大的数据量。
-
计数器增加逻辑:
- 随机性:计数器的增加是随机的,这意味着每次访问键时,并不总是增加计数器。
- 计数器值与增加概率:计数器的当前值会影响增加计数器的概率。计数器的值越大,增加计数器的概率就越小。这是因为Redis假设,如果一个键被频繁访问,那么它很可能在未来也会被频繁访问,因此不需要频繁地增加计数器。
- 配置参数
lfu-log-factor
:Redis中的lfu-log-factor
配置参数也会影响计数器增加的概率。这个参数的值越大,计数器增加的几率就越小。lfu-log-factor
用于调整计数器增加的速度,其值越高,计数器增加的速度就越慢。
- 如果计数器的值小于最大值255,Redis会根据计数器的当前值和
lfu-log-factor
的配置来计算一个概率。
LFU流程图
Redis高性能原理的深度剖析
Redis是一个开源的内存数据结构存储系统,它支持多种类型的数据结构,如字符串、散列、列表、集合、有序集合等。Redis以其出色的性能和低延迟特性而闻名,这主要得益于其核心数据结构的设计和实现,以及其高性能的存储和访问机制。
核心数据结构
Redis的数据结构设计非常灵活,它不仅支持基本的数据类型,还支持复杂的数据类型,并且提供了丰富的操作命令。
字符串(String)
字符串是Redis中最基本的数据结构,它允许用户将字符串值设置、获取、删除等。
使用场景:
-
缓存数据(如HTML页面、JSON序列化的对象)
SET key value //存入字符串键值对
MSET key value [key value ...] //批量存储字符串键值对
SETNX key value //存入一个不存在的字符串键值对
GET key //获取一个字符串键值
MGET key [key ...] //批量获取字符串键值
DEL key [key ...] //删除一个键
EXPIRE key seconds //设置一个键的过期时间(秒)
-
计数器(如微博点赞数、视频播放次数)
INCR key //将key中储存的数字值加1
DECR key //将key中储存的数字值减1
INCRBY key increment //将key所储存的值加上increment
DECRBY key decrement //将key所储存的值减去decrement
-
分布式锁
SETNX product:10001 true //返回1代表获取锁成功
SETNX product:10001 true //返回0代表获取锁失败
DEL product:10001 //执行完业务释放锁
SET product:10001 true ex 10 nx //防止程序意外终止导致死锁
优点:
-
简单易用
-
可以用于简单的缓存和计数场景
缺点:
-
不适合存储复杂的数据结构
哈希(Hash)
集合是一个无序集合,它通过哈希表实现,具有快速添加、删除和查找操作的特点。
使用场景:
-
存储用户信息
-
记录对象属性(用户信息)
-
缓存关系型数据库的行数据
HMSET user 1:name zhangsan 1:age 18
HMSET user 2:name lisi 1:age 19
HMGET user 1:name 1:age
命令执行结果:
id | name | age |
---|---|---|
1 | zhangsan | 18 |
2 | lisi | 19 |
优点:
-
可以存储复杂的数据结构
-
访问和更新效率高
-
同类数据归类整合储存,方便数据管理
缺点:
-
不适合存储大量数据,因为所有数据都在一个键下
-
过期功能不能使用在field上,只能用在key上
-
Redis集群架构下不适合大规模使用
列表(List)
列表是简单的字符串链表,支持从两端插入和删除元素,常用于消息队列等场景。
使用场景:
-
消息队列
Blocking MQ(阻塞队列)= LPUSH + BRPOP
Queue(队列)= LPUSH + RPOP# 从列表左侧插入元素
LPUSH tasks "process_video"
# 从列表右侧弹出元素
BPOP tasks
-
朋友圈时间线
LPUSH message:{用户id} 1
LPUSH message:{用户id} 2
LPUSH message:{用户id} 3
LPUSH message:{用户id} 4LRANGE message:{用户id} 0 3
优点:
-
支持双向操作(从头部或尾部添加/移除元素)
-
可以用作队列或栈
缺点:
-
元素数量较多时,访问中间元素较慢
-
列表尾部添加和移除操作很快,但头部操作较慢
集合(Set)
集合是一个无序集合,它通过哈希表实现,具有快速添加、删除和查找操作的特点。
使用场景:
-
微信微博点赞/收藏
1) 点赞
SADD like:{消息ID} {用户ID}
2) 取消点赞
SREM like:{消息ID} {用户ID}
3) 检查用户是否点过赞
SISMEMBER like:{消息ID} {用户ID}
4) 获取点赞的用户列表
SMEMBERS like:{消息ID}
5) 获取点赞用户数
SCARD like:{消息ID}
-
好友关系(共同关注)
SINTER set1 set2 set3 { c }
SUNION set1 set2 set3 { a,b,c,d,e }
SDIFF set1 set2 set3 { a }
-
抽奖活动(随机选取中奖者)
1)点击参与抽奖加入集合
SADD key {userlD}
2)查看参与抽奖所有用户
SMEMBERS key
3)抽取count名中奖者
SRANDMEMBER key [count] / SPOP key [count]
优点:
-
元素唯一,不会重复
-
支持集合间的操作,如并集、交集
缺点:
-
不支持排序
-
不能直接获取集合中的元素
有序集合(Sorted Set)
有序集合是将集合和散列表结合起来,给每个元素设置一个分数,然后根据分数进行排序。
使用场景:
-
排行榜系统
//展示当日排行前十
ZREVRANGE hotNews:20190819 0 9 WITHSCORES
高性能原理
-
Redis 为什么快?
因为它所有的数据都在内存中,所有的运算都是内存级别的运算,而且单线程避免了多线程的切换性能损耗问题。正因为 Redis 是单线程,所以要小心使用 Redis 指令,对于那些耗时的指令(比如keys),一定要谨慎使用,一不小心就可能会导致 Redis 卡顿。
-
Redis线程形式
Redis 的单线程主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。Redis有也有多线程的功能,比如持久化、异步删除、集群数据同步等
-
redis支持高并发
Redis的IO多路复用:redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,依次放到文件事件分派器,事件分派器将事件分发给事件处理器。
Redis之所以能够提供高性能的数据存储和访问,主要得益于其内存存储、单线程模型、高效的数据结构设计以及持久化机制等。
Redis 锁过期但任务未完成时的解决方案
在分布式系统中,分布式锁是一种常见的同步机制,用于确保在多个进程或服务器之间对共享资源的安全访问。Redis是实现分布式锁的流行工具之一,因为它提供了高性能和丰富的命令集。然而,在使用Redis分布式锁时,一个常见的问题是锁过期了,但业务逻辑还没有处理完毕。这可能导致多个进程同时进入临界区,造成数据不一致或其他问题。
分布式锁的基本实现
首先,我们来看一个简单的 Redis 分布式锁的实现。
获取锁
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
public class RedisLock {
private Jedis jedis;
private String lockKey;
private int lockExpire;
public RedisLock(Jedis jedis, String lockKey, int lockExpire) {
this.jedis = jedis;
this.lockKey = lockKey;
this.lockExpire = lockExpire;
}
public boolean tryLock(String requestId) {
SetParams params = new SetParams();
params.nx().px(lockExpire);
String result = jedis.set(lockKey, requestId, params);
return "OK".equals(result);
}
public void unlock(String requestId) {
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
}
}
使用锁
Jedis jedis = new Jedis("localhost");
RedisLock redisLock = new RedisLock(jedis, "my_lock", 5000);
String requestId = UUID.randomUUID().toString();
if (redisLock.tryLock(requestId)) {
try {
// 业务逻辑
Thread.sleep(6000); // 模拟业务逻辑处理时间超过锁过期时间
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
redisLock.unlock(requestId);
}
} else {
System.out.println("获取锁失败");
}
在上述代码中,tryLock 方法尝试获取锁,unlock 方法释放锁。然而,如果业务逻辑处理时间超过锁的过期时间,锁会自动释放,导致其他进程可以获取锁,进而导致并发问题。
锁过期问题
当锁过期时,可能会出现以下问题:
-
数据不一致:多个进程同时进入临界区,导致数据竞争和不一致。
-
业务逻辑冲突:业务逻辑处理同一资源时可能出现冲突。
-
资源浪费:资源被多次处理,导致效率低下。
解决方案
为了解决锁过期问题,可以考虑以下几种解决方案:
1. 自动续期
自动续期是一种常见的解决方案。当锁接近过期时,自动延长锁的过期时间,确保业务逻辑在锁持有期间不会被其他进程获取。
public class RedisLockWithRenewal extends RedisLock {
private ScheduledExecutorService scheduler;
public RedisLockWithRenewal(Jedis jedis, String lockKey, int lockExpire) {
super(jedis, lockKey, lockExpire);
scheduler = Executors.newScheduledThreadPool(1);
}
@Override
public boolean tryLock(String requestId) {
boolean locked = super.tryLock(requestId);
if (locked) {
startAutoRenewal(requestId);
}
return locked;
}
private void startAutoRenewal(String requestId) {
scheduler.scheduleAtFixedRate(() -> {
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('pexpire', KEYS[1], ARGV[2]) " +
"else return 0 end";
jedis.eval(script, Collections.singletonList(lockKey), Arrays.asList(requestId, String.valueOf(lockExpire)));
}, lockExpire / 3, lockExpire / 3, TimeUnit.MILLISECONDS);
}
@Override
public void unlock(String requestId) {
scheduler.shutdown();
super.unlock(requestId);
}
}
在这个实现中,我们使用 ScheduledExecutorService 定期检查并延长锁的过期时间,确保锁在业务逻辑处理完之前不会过期。
2. 锁重入机制
锁重入机制允许持有锁的线程可以再次获取锁而不被阻塞,这可以通过记录锁持有者的信息来实现。
import java.util.concurrent.ConcurrentHashMap;
public class ReentrantRedisLock extends RedisLock {
private ConcurrentHashMap<String, Integer> lockHolderMap = new ConcurrentHashMap<>();
public ReentrantRedisLock(Jedis jedis, String lockKey, int lockExpire) {
super(jedis, lockKey, lockExpire);
}
@Override
public synchronized boolean tryLock(String requestId) {
if (lockHolderMap.containsKey(requestId)) {
lockHolderMap.put(requestId, lockHolderMap.get(requestId) + 1);
return true;
} else {
boolean locked = super.tryLock(requestId);
if (locked) {
lockHolderMap.put(requestId, 1);
}
return locked;
}
}
@Override
public synchronized void unlock(String requestId) {
if (lockHolderMap.containsKey(requestId)) {
int count = lockHolderMap.get(requestId);
if (count > 1) {
lockHolderMap.put(requestId, count - 1);
} else {
lockHolderMap.remove(requestId);
super.unlock(requestId);
}
}
}
}
通过这种方式,同一线程可以多次获取锁,直到所有业务逻辑执行完毕,最后一次调用 unlock 时才真正释放锁。
3. 使用 Redisson 库
Redisson是一个Redis客户端,提供了分布式锁的实现,支持自动续期等高级特性,简化了分布式锁的使用。
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.util.concurrent.TimeUnit;
public class RedissonLockExample {
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redissonClient = Redisson.create(config);
RLock lock = redissonClient.getLock("my_lock");
try {
if (lock.tryLock(0, 10, TimeUnit.SECONDS)) {
try {
// 业务逻辑
Thread.sleep(6000); // 模拟业务逻辑处理时间
} finally {
lock.unlock();
}
} else {
System.out.println("获取锁失败");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
redissonClient.shutdown();
}
}
}
Redisson 提供了强大的分布式锁功能,支持自动续期、锁重入等特性,极大地简化了开发工作。
Redis跳跃列表
Redis跳跃列表的基本概念
Redis的跳跃列表是一种随机化的数据结构,由William Pugh在论文《Skip lists: a probabilistic alternative to balanced trees》中提出。它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。这种数据结构支持O(logN)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。在Redis中,跳跃表是有序集合(zSet)数据类型的实现之一,也在集群节点中用作内部数据结构。
Redis跳跃列表的工作原理
Redis的跳跃列表通过在链表中添加多级索引来提高查找效率。具体来说,它首先将链表中的每个节点按照一定的概率提升到更高层次的链表中。这样,当我们需要查找某个节点时,可以先从最高层次的链表开始查找,如果找不到,再逐层向下查找,直到找到为止。由于高层链表的节点数量较少,因此可以大大减少查找的时间复杂度。
Redis跳跃列表的优点
-
查询效率高:由于跳跃列表采用了多级索引结构,因此在查询时可以通过逐层查找的方式快速定位到目标节点,时间复杂度接近O(logN)。
-
实现简单:相比于红黑树等平衡树结构,跳跃列表的实现更为简单直观。在Redis中,跳跃列表的实现代码也相对较少,易于维护和扩展。
-
支持范围查询:由于跳跃列表是有序的,因此可以很方便地支持范围查询操作。只需要指定查询的起始节点和终止节点,就可以获取到该范围内的所有节点数据。
Redis跳跃列表的缺点
-
空间消耗增加:相比于简单的有序链表或平衡树结构,跳跃列表需要额外的空间来存储多级索引。每个节点除了存储数据值外,还需要存储多个指针指向其他层的节点。因此,在存储相同数量的数据时,跳跃列表会占用更多的内存空间。
-
动态更新开销:当在跳跃列表中插入或删除元素时,可能需要调整多个层次的链表结构,以确保其有序性和查询效率。这可能会带来一定的性能开销。特别是在大量插入或删除操作的情况下,这种开销可能会更加明显。
-
并发控制复杂:在Redis中,由于跳跃列表被用作有序集合的底层实现之一,因此需要考虑并发控制的问题。当多个客户端同时对同一个有序集合进行读写操作时,需要采取合适的并发控制策略来确保数据的一致性和正确性。这可能会增加实现的复杂度和开销。
高并发下MySQL中热点数据如何持续保留在Redis中?
LRU和LFU算法
LRU:最近最少使用,是种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。
LFU:最近最不经常使用,选择最近使用次数最少的页面予以淘汰。对于每个节点,都需要维护其使用次数count、最近使用时间time。
LFU删除节点的策略是: 优先删除使用次数Count最小的那个节点,因为它最近最不经常使用所以删除它。如果使用次数相同并且节点有多个,那么在这些节点中删除最近使用时间time最早的那个节点。
热点数据持久保留方案
在高并发场景下缓存热点数据一般都是采用LRU或者LFU方案实现,因为其他方案对热点数据不友好。
如果采用Reids的LRU方式缓存热点数据,那么会将最近在redis中没有的数据缓存起来,如果空间的不足的情况下会移出队列中最近未访问的数据。但是本方案存在一个很严重的问题,假设数据4使用了1万次,其他的数据只使用了一次,那么对应的先后使用时间上的关系如下:
此时将数据4移除就不适合了,因为它是使用次数最高的,只是最近时间没有被访问,极端的情况下可能因为大量请求来访问数据4,此时数据4在Redis中刚好被移除,那么请求将会都打到MySQL上进而导致MySQL被打垮。
如果Redis中采用LFU算法缓存热点,那么内存不足的情况下会清理到最近不常用的数据,然后存储热点数据。此方案存储热点数据比LRU方式更加合理,因为它只会清理那些不常用的数据,针对高并发下缓存大批量的热点数据建议采用这种LFU的方式。
在高并发且热点数据量很大的情况下,建议使用Redis的LFU方式淘汰数据,因为此方式更加科学合理。在某些热点数据访问量一样的,那么在淘汰数据的时候依据时间来淘汰,极端情况下被清理掉的热点数据下一时刻被大量访问,此时要做一下系统的保护(最简单的是加锁访问数据库)。
Redis如何保障高可用?
Redis一般通过主从复制机制保障Redis始终处于可用状态,可以部署一主一从或者一主多从来保障主节点故障时,从节点可以提升为新的主节点,继续提供服务。
为了自动实现这一过程,Redis提供了哨兵模式,通过哨兵检测主节点的健康状况,一旦主节点出现故障,哨兵会根据预设规则选举出新的主节点,继续向外提供服务。
Redis还提供了集群模式采用数据分片(Sharding)策略,将数据均匀分布到多个节点上,每个节点独立处理一部分数据。这样既分散了单节点的压力,也降低了单点故障对整体系统的影响。
主从复制(Replication):
(1)基础原理:Redis通过主从复制机制实现数据的备份与同步。在一个Redis集群中,存在一个主节点(Master)和至少一个从节点(Slave)。主节点负责处理客户端写请求并将其操作日志(即RDB快照或AOF日志)发送给从节点。从节点接收并执行这些日志,从而保证自身数据与主节点一致,实现数据的实时备份。主从复制的数据首次同步流程如下图[1]:
如果从节点都跟随主节点,那么主节点的压力会比较大,所以推荐需要部署更多的从节点分摊读请求压力时,从节点跟随上游的从节点即可,如下图所示[1]:
(2)故障切换:当主节点发生故障时,从节点可被提升为新的主节点,继续提供服务。这种机制确保了即使单点故障,系统仍能保持对外服务,保障了数据的高可用性。但是需要手工操作。
哨兵模式(Sentinel):
Redis 在 2.8 版本以后提供的哨兵(Sentinel)机制,它的作用是实现主从节点故障转移。Sentinel是一个独立的进程,用于监控Redis主从集群的运行状态,包括节点存活、主从关系、数据同步等。它提供了自动故障检测、故障转移、配置通知等功能,极大地提升了Redis集群的自动化运维能力[2]。
Sentinel主要负责的就是三个任务:
1、监控:监控主库运行状态,并判断主库是否客观下线;
2、选主(选择主库):在主库客观下线后,选取新主库;
3、通知。选出新主库后,通知从库(从库执行replicaof命令,与新主库同步)和客户端(通知客户端与新主库连接)。
如何判断主节点真的故障了?
哨兵会每隔 1 秒给所有主从节点发送 PING 命令,当主从节点收到 PING 命令后,会发送一个响应命令给哨兵,这样就可以判断它们是否在正常运行。如果在规定时间内没有响应,哨兵就会标记主节点「主观下线」,如果询问其他哨兵得到半数以上的反馈为主节点下线,就会标记主节点「客观下线」。
如何选出主节点?
哨兵自己先确定谁是leader来执行选主,即:投票自己为leader,半数以上节点同意。之后从从节点中选出新的主节点,规则如下:
1、第一轮考察:优先级最高的从节点胜出
2、第二轮考察:复制进度最靠前的从节点胜出
3、第二轮考察:复制进度最靠前的从节点胜出
Redis Cluster 集群模式的作用是什么?
当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群(Redis Cluster )方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。
Redis Cluster 方案采用哈希槽(Hash Slot),来处理数据和节点之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中,具体执行过程分为两大步:
1、根据键值对的 key,按照 CRC16 算法 (opens new window)计算一个 16 bit 的值。
2、再用 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。
Redis Cluster采用数据分片(Sharding)策略,将数据均匀分布到多个节点上,每个节点独立处理一部分数据。这样既分散了单节点的压力,也降低了单点故障对整体系统的影响。客户端通过哈希槽(Hash Slot)机制透明地寻址到正确的节点进行读写操作。如下图[3]:
对于客户端如何找到数据,Redis Cluster 方案提供了一种重定向机制,所谓的“重定向”,就是指,客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令[3]。
如果 Slot 正在迁移,则客户端会收到一条 ASK 报错信息,告诉客户端正在迁移(ASK 命令并不会更新客户端缓存的哈希槽分配信息),此时,客户端需要先给 Slot 所在的实例发送一个 ASKING 命令,表示让这个实例运行执行客户端接下来发送的命令,然后客户端再向这个实例发送对应的操作命令。
Cluster节点间通过Gossip协议进行通信,共享集群状态信息,包括节点新增、移除、主从切换等。当某个主节点故障时,其对应的从节点会发起选举成为新主节点,同时其他节点及客户端会收到更新通知,自动调整连接。此过程无需人工干预,实现了自动化的故障恢复。
什么是Redis脑裂?
由于网络问题,集群节点之间失去联系。主从数据不同步;重新平衡选举,产生两个主服务。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于会从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了[4]。
解决方案是:
当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。在 Redis 的配置文件中有两个参数我们可以设置:
1、min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。
2、min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果超过,主节点会禁止写数据。
我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。
这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的写请求了。
等到新主库上线时,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。
结语
Redis通过主从复制、哨兵模式、集群模式等多维度构建了强大的高可用体系:
1、主从复制与哨兵模式,实现了节点间的实时数据同步与自动故障转移,确保在单点故障时服务连续性;
2、集群模式通过数据分片与节点间通信,有效分散系统压力,降低单点故障风险,提供水平扩展能力。
面对面试官关于Redis如何保障高可用性的提问,我们可以自信地阐述上述策略与技术手段,展现对Redis高可用特性的深入理解和掌握。
Redis有哪些持久化机制?
Redis作为一款高性能的键值对数据库,其数据主要存储在内存中以实现极高的访问效率。然而,内存存储意味着在服务器重启、故障或其他意外情况发生时,如果没有适当的保护措施,数据可能会丢失。因此,Redis设计并提供了两种核心的持久化机制来确保数据的长期安全性和可靠性,即RDB(Redis Database)持久化和AOF(Append-Only File)持久化。此外,还有一种结合两者优点的 混合使用(RDB + AOF)策略。
1、RDB持久化
RDB持久化是一种基于快照的机制,它将某个时刻的Redis数据集以二进制的形式序列化到磁盘上的一个文件(通常名为dump.rdb
)。Redis采用的是全量快照。
优点:
(1)数据紧凑:RDB文件通过压缩算法进行存储,具有较小的体积,便于传输和备份。
(2)恢复速度快:由于RDB文件仅包含数据集的完整快照,所以在 Redis 重启时,可以直接读取该文件进行快速数据加载,恢复过程高效。
(3)资源友好:创建RDB文件的过程通常是异步且fork-based的,对主线程影响较小,不会显著阻塞 Redis 的服务响应。bgsave命令生成RDB快照,会创建一个子进程,专门用于写入RDB文件,避免阻塞主线程。而save命令是在主线程执行的。
虽然bgsave不阻塞主线程,但是还是有影响的,bgsave子进程共享主线程的所有内存数据,此时主线程对数据进行读操作时,两者互不影响;但是如果是写操作,就会影响了,Redis 会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。如下图所示[1]:
缺点:
(1)可能的数据丢失:RDB持久化是周期性进行的,取决于配置的保存策略(如save
或bgsave
命令触发),在两次快照之间发生的数据变更如果未被同步到磁盘,可能在故障时丢失。
(2)资源消耗:虽然创建RDB时对主线程影响较小,但fork子进程和压缩操作仍需消耗一定的CPU和内存资源。
触发时机:
RDB持久化可以通过以下方式触发:
(1)执行bgsave
命令手动触发。
(2)配置文件中预设的save
规则满足时(如一定时间内数据发生特定数量的更改)。
(3)在主从复制场景下,当从节点首次连接主节点时,主节点会发送最新的RDB文件给从节点。
2、AOF持久化
AOF持久化采取日志记录的方式,将Redis服务器执行的所有写命令(包括数据添加、修改、删除等操作)以文本格式追加到一个名为appendonly.aof
的文件中。这种方式提供了更为详细的更新历史记录,确保了更高的数据一致性。AOF日志是写内存命令执行后才写入磁盘的,如下图:
优点:
(1)数据安全性高:AOF记录了所有写操作,即使在故障时只部分写入了磁盘,也能通过重放未完成的命令尽可能恢复到最新状态,数据丢失的可能性相对较小。
(2)可调整的持久化级别:AOF支持多种写回策略(如always
、everysec
、no - 每秒fsync一次(always策略),也可以配置为每次写命令立即同步(everysec)或由操作系统决定何时同步(no)
),允许用户根据实际需求在数据安全性与性能之间做出权衡。
缺点与挑战:
(1)文件体积增长较快:随着操作的积累,AOF文件大小可能会比RDB文件更大,尤其是在使用低级别的持久化策略时。
不过Redis有AOF重写机制,会合并写入命名,比如两次set A的命令会合并为1条,从而减少日志文件大小。每次 AOF 重写时,Redis 会先执行一个内存拷贝,用于重写;然后,使用两个日志保证在重写过程中,新写入的数据不会丢失。而且,因为 Redis 采用额外的线程进行数据重写,所以,这个过程并不会阻塞主线程。如下图[1]:
(2)恢复速度较慢:在Redis重启时,需要重新执行AOF文件中的所有写命令才能恢复数据集,相较于直接加载RDB文件,可能耗时更长。
(3)潜在的数据不一致风险:在极少数情况下,如AOF文件损坏或命令解析出错,可能导致数据恢复不完全或出现错误。
触发时机:
AOF持久化默认为everysec
策略,根据always
、everysec
、no策略选择写入时机不同
。此外,执行BGREWRITEAOF
命令可以触发AOF重写,优化文件大小并合并重复命令。
如果同时分别开启RDB和AOF时,会优先使用AOF文件进行恢复,因为相比RDB,AOF文件保存的命令操作通常更全些。
3、混合持久化(RDB + AOF)
为了同时利用RDB的快速恢复能力和AOF的高数据安全性,Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。
在这种模式下,Redis在重启时首先加载RDB文件以快速初始化数据集,然后重放AOF文件中自最后一次RDB快照以来的增量更新命令。这种组合方式既保证了快速恢复,又最大限度地减少了数据丢失的风险。
结语
Redis提供了RDB、AOF以及混合持久化三种持久化机制,以适应不同应用场景对数据安全、性能和存储空间的需求。面试者在理解和掌握这些机制的基础上,应能根据实际业务场景分析优劣,并合理选择和配置持久化策略。
数据不能丢失时,内存快照和 AOF 的混合使用是一个很好的选择;如果允许分钟级别的数据丢失,可以只使用 RDB;如果只用 AOF,优先使用 everysec 的配置选项,因为它在可靠性和性能之间取得一个平衡。
Redis 的字符串原理是什么?
在 Redis 中,并没有使用 C 标准库提供提供的字符串,而是实现了一种动态字符串,即 SDS (Simple Dynamic String),然后通过这种数据结构来表示字符串。
C 语言字符串的缺陷
C 语言的字符串其实本质上就是 char* 的字符数组,其本身是存在一定缺陷的,首先我们来盘点一下其缺陷。
1)C 语言字符数组的结尾位置就用 “\0” 表示,意思是指字符串的结束
2)C 语言字符数组获取长度只能通过遍历获得,时间复杂度是 O(n)
3)字符串操作函数不高效且不安全,比如缓冲区溢出,其可能导致程序异常终止
针对以上问题,C 语言在其基础上进行了一定的改进,接下里我们来注意进行分析。
SDS 结构
如下图所示,SDS 数据结构分为四个部分的内容,如下图所示
1)len (长度):记录了 SDS 字符串数组的长度,当需要获取字符串长度的时候,只需要返回这个成员变量的值就可以了,时间复杂度是O(1)
2)alloc(分配空间长度):这个字段的主要作用是指分配给字符数组的存储的空间大小,当需要计算剩余空间大小的时候,只需要 alloc - len 就可以直接进行计算,然后判断空间大小是否符合修改需求,如果不满足需求的话,就执行相应的修改操作,这样的话就可以很好地解决我们上面所说的缓冲区溢出问题。
3)flags(表示 SDS 的类型):一共设计了五种类型的 SDS,分别是 sdshdr 5、sdshdr 8、sdshdr 16、sdshdr 32、sdshdr 64(这个的记忆页很简单,就是 32 开始,128,即 2 的多少次方去记忆就可以了),这个类型的主要作用就是灵活保存不同大小的字符串,从而有效节省内存空间。
4)buf(存储数据的字符数组):主要起到保存数据的作用,如字符串、二进制数据(二进制安全就是一个重要原因)等
然后在分析完 SDS 的结构之后,接下来我们来分析原因,我们可以发现,SDS 相对于 C 语言原生的字符串,其多了几个字段,即 len、alloc、flags,这几个字段帮助 SDS 解决了 C 语言字符串的问题:
1)长度计算
首先是 len,他记录了 SDS 的字符串长度,因此当你需要获取字符串长度的时候,你只需要返回 len 的值就可以了,不需要再去遍历字符串,这样操作的时间复杂度就降低了许多,直接从 O(n) 变成了 O (1)
2)二进制安全
第二个点在于存储的字符数组,SDS 中进行了改进,SDS 中不在需要 \0 来判断字符串是否结束,这就是我们上面所说的 Redis 字符串中的 buf 数组可以存储任何的二进制数据,因此存储二进制数据的时候便不会发生字符截断的问题,避免了由于特殊字符引发的异常,不过需要注意一个点,Redis 为了兼容 C 标准库的一些操作, Redis 仍然为末尾的 \0 预留了内存空间
3)修改操作高效
其次就是 alloc 字段了,alloc 字段用来记录字符串预分配的内存大小,当发生字符串修改的时候,通过 allloc - len 判断当前空间是否足够,如果不足够的话就进行扩容。
然后这里放一段 sds 扩容的代码,用于帮助理解
hisds hi_sdsMakeRoomFor(hisds s, size_t addlen)
{... ...// avail 值就是 allloc-len 的值// 如果值的大小即剩余空间大于增加的字符串长度,则表示空间足够,不需要进行扩容,直接返回就可以if (avail >= addlen)return s;//获取当前 s 的长度大小len = hi_sdslen(s);sh = (char *)s - hi_sdsHdrSize(oldtype);//求扩容以后 s 需要的最小长度newlen = (len + addlen);//根据获取到的新长度,为 s 分配新空间所需要的内存空间大小if (newlen < HI_SDS_MAX_PREALLOC)//新长度 < HI_SDS_MAX_PREALLOC,这里 HI_SDS_MAX_PREALLOC 的值 为 1 MB//表示分配所需空间 2 倍的空间newlen *= 2;else//不满足条件的话,分配长度为目前长度 + 1MB 的空间newlen += HI_SDS_MAX_PREALLOC;...
}
我们可以发现,当 sds 扩容的时候,其是根据 sds 的长度进行判断的,其判断的值就是所需要的 sds 的长度是否超过 1 MB
-
如果需要的 sds 长度小于 1MB 的话,其扩容规则就是翻倍扩容,即 2 倍的新长度
-
如果需要的 sds 长度大于等于 1 MB 的话,其扩容规则就是 newlen + 1 MB
在扩容之前,SDS 会有限检查使用空间够不够使用,如果不够使用的话,则会进行扩容处理,即分配额外的未使用空间,这样可以有效地减少内存分配的次数,并且解决了缓冲区溢出的问题。
4)按需使用,节省内存
最后一个要介绍的点就是 flag 字段,和那个字段主要分为 5 种类型,即 sdshdr5、sdshdr8、sdshdr16、sdshdr32 以及 sdshdr64 五种字符串,他们分别对应存储长度小于等于 2 的 5/8/16/32/64 次方字节的字符串。
通过使用不同存储类型的结构题,灵活保存不同大小的字符串,从而节省内存空间,此外,Redis SDS 底层还使用了消除内存对齐的方式进行内存的优化,从而保证所有的成员尽可能在内存中相邻,从而保证按照实际大小分配内存,节约内存的使用。
Redis 字符串:
优点:
-
分布式存储: Redis 是一种分布式缓存系统,可以在多台服务器上进行数据存储和访问,提高了系统的可扩展性和容错性。
-
支持持久化: Redis 支持将数据持久化到磁盘,保证数据不会因为服务器重启或断电而丢失。
-
数据结构丰富: Redis 的字符串不仅仅支持简单的键值对,还支持列表、哈希、集合等多种数据结构,提供了更多的灵活性和功能。
-
高性能: Redis 是基于内存的数据库,读写速度非常快,适用于高并发场景。
缺点:
-
内存消耗较高: Redis 数据存储在内存中,如果数据量较大,可能会消耗大量的内存资源。
-
单个值大小限制: Redis 单个字符串值的大小受到内存限制,如果需要存储大对象,可能会有限制。
-
学习成本: Redis 是一种新的数据存储技术,需要学习其特有的命令和使用方式。
普通 C 语言字符串:
优点:
-
简单易用: C 语言中的字符串是一种基本数据类型,使用起来非常简单和灵活。
-
低资源消耗: 普通 C 语言字符串通常存储在栈上或堆上,内存消耗较低。
-
无需学习成本: 对于熟悉 C 语言的开发者来说,使用普通的 C 语言字符串无需学习新的技术和命令。
缺点:
-
不支持分布式: 普通 C 语言字符串只能存储在单个程序的内存空间中,无法实现分布式存储和访问。
-
不支持持久化: C 语言字符串通常存储在内存中,程序结束或崩溃时数据会丢失。
-
功能受限: 普通 C 语言字符串功能相对简单,不支持丰富的数据结构和高级功能。
1)SDS 中的 len 和 alloc 字段分别有什么作用?为什么 len 的存在可以提高字符串长度获取的效率?
2)在 SDS 中,为什么 buf 数组可以存储任何的二进制数据?这如何避免了字符截断的问题?
3)文章中提到了 Redis 为了兼容 C 标准库的一些操作而预留了末尾的 \0,这个操作有什么具体的目的或好处?
Redis的过期删除策略
在计算机系统中有三层存储结构,最上面是处理器,中间是内存,最下面是磁盘。它们各自的容量从几MB到几十TB不等,访问性能从几ns到几ms不等。如下图所示:
内存资源相比于磁盘还是比较稀缺的,Redis作为基于内存的数据库设计了完善的过期删除策略和内存淘汰机制,一起高效完成Redis的内存管理。
Redis采用的是「惰性删除+定期删除」配合使用的策略,能够实现对过期数据的高效、精准管理,既保证了内存资源的有效利用,又避免了过度频繁的删除操作对系统性能造成影响。
1、惰性删除(Lazy Deletion)
惰性删除是一种被动触发的过期数据清理方式。当客户端尝试访问一个键时,Redis会先检查该键是否设置了过期时间以及是否已过期。如果发现键已过期,则立即执行删除操作,并向客户端返回nil
,表示该键不存在。如下图所示[1]:
这种策略的核心思想是“按需清理”,即只有在真正访问到过期键时才进行删除,不会主动去遍历查找过期键。这种方式的优点在于对系统的实时性能影响较小,因为只有在实际请求发生时才会触发删除操作。然而,其缺点是可能存在一些长期未被访问但已过期的键占用内存,造成一定的内存浪费。
2、定期删除(Periodic Deletion)
为弥补惰性删除可能造成的内存浪费,Redis引入了定期删除策略。Redis服务器会周期性地运行一个后台“定时任务”,用于扫描并删除已过期的键。这个定时任务的执行频率以及每次扫描的键数量都可以通过配置参数进行调整,以适应不同的应用场景和负载情况。如下图所示[1]:
定期删除可以在一定程度上确保过期键能够及时得到清理,避免大量过期键长时间占用内存。然而,过于频繁或深度的扫描可能会导致CPU资源消耗增大,影响Redis服务的其他核心操作。因此,如何平衡扫描效率与系统负载,是定期删除策略实施时需要考虑的关键问题。
Redis提供了「惰性删除+定期删除」配合使用的策略,对过期数据实现了高效、精准的管理,又提供了丰富的内存淘汰机制以应对不同业务场景的需求。
理解各策略的工作原理及适用条件很有必要,结合实际应用特点进行合理配置与调整,能够确保Redis在有限内存下更高效稳定地运行。
高QPS下热点数据的存取方案
热点数据的发布
运营人员发布热点数据(如网站首页的轮播图的配置),首先将热点数据同步到Mysql中,然后热点数据发送一条添加或者修改消息到MQ中(如Kafka、RocketMQ等),通过MQ来同步Redis中数据一致性。通过MQ同步热点数据到Redis中会存在一定的延迟,但是针对首页轮播图业务场景来讲是可以忽略的(因为只是做展示而已,不会影响实际的业务),但是如果想要低延迟我们可以采用Canal来监听Mysql的binlog方案来实现,这里就不具体的展开了。
热点数据的读取过程
用户通过客户端访问数据的时候,请求先到达OpenResty上(单机支持10K-1000K并发连接),由于OpenResty是基于Nginx实现的,所以OpenResty可以通过内部的location配置来执行预先配置的Lua脚本,执行Lua脚本的时候我们的逻辑如下:
(1)先读取Redis集群,判断是否有需要的数据,如果Redis中存在需要的数据就直接返回数据给客户端。
(2)如果在Redis中没有获取到数据,Lua会连接Mysql获取需要的数据,数据读取到之后同步一份数据到Redis集群中,最后返回数据到客户端。
2.3 整体热点数据发布和存取的流程
Lua脚本的代码
ngx.header.content_type="application/json;charset=utf8"
local uri_args = ngx.req.get_uri_args();
local id = uri_args["id"];
local cache_ngx = ngx.shared.dis_cache;
local hotCache = cache_ngx:get('hot_cache_'..id);
ngx.say(hotCache)
if hotCache == "" or hotCache == nil then
local redis = require("resty.redis");
local red = redis:new()
red:set_timeout(2000)
red:connect("192.168.223.134", 6379)
local rescontent=red:get("content_"..id);
if ngx.null == rescontent then
local cjson = require("cjson");
local mysql = require("resty.mysql");
local db = mysql:new();
db:set_timeout(2000)
local props = {
host = "192.168.223.132",
port = 3306,
database = "test",
user = "root",
password = "123456"
}
local res = db:connect(props);
local select_sql = "select * from tb_hot where id="..id.." order by created";
res = db:query(select_sql);
local responsejson = cjson.encode(res);
red:set("content_"..id,responsejson);
ngx.say(responsejson);
db:close()
else
cache_ngx:set('hot_cache_'..id, rescontent, 10*60);
ngx.say(rescontent)
end
red:close()
else
ngx.say(contentCache)
end
总结:
高并发下热点的数据的存取本文采用OpenResty+Lua+Redis的方案,方案的实现难度小,可用性高,支持几十万甚至百万的QPS。