Redis为什么这么快?万一内存满了咋办?
1.Redis底层存储结构
Redis是内存存储,都是以键值对形式存放在内存。其实就是存放到RedisDB中名叫dict的HASHTABLE中。
/* Redis数据库结构体 */
typedef struct redisDb {
// 数据库键空间,存放着所有的键值对(键为key,值为相应的类型对象)
dict *dict;
// 键的过期时间
dict *expires;
// 处于阻塞状态的键和相应的client(主要用于List类型的阻塞操作)
dict *blocking_keys;
// 准备好数据可以解除阻塞状态的键和相应的client
dict *ready_keys;
// 被watch命令监控的key和相应client
dict *watched_keys;
// 数据库ID标识
int id;
// 数据库内所有键的平均TTL(生存时间)
long long avg_ttl;
} redisDb;
从上面可以看到,Redis底层结构最主要的部分是dict结构。也就是Hash对象结构。
typedef struct dict {
dictType *type; // 字典类型,例如哈希表
void *privdata; // 额外的私有数据
dictEntry **table; // 哈希表数组,存储键值对
unsigned long size; // 哈希表的大小
unsigned long sizemask; // 大小掩码,用于哈希计算
unsigned long used; // 已使用的槽位数量
} dict;
RedisDB即数据库对象,字典里主要是kv数据,k是字符串,v是任意Redis对象。即以下结构:
1、添加数据。key必须为String对象,value可以是任何类型的对象。 2、查询数据。在dict找到对应key查询。 3、更新数据。在原有key基础上赋值。 4、删除数据。将key和value从dict结构里删除。
我们还发现在RedisDB里有一个过期键的字段: dict *expires;
如果设置了过期时间就如同以下效果:
这里的dict中和expires中的key对象,实际都是存储的String对象指针。
哎,那么这里的key过期之后,不仅在dict里还在expires里,这是占用了两份内存?
无论是数据字典还是过期字典里面的key对象,实际都是存储的String对象指针,所以不会重复占用内容,Redis对内存的使用还是很珍惜的。
那如果对象过期了,要怎么处理?
其实,过期之后的键不是立即删除的,一般有三种清除策略,分别是定时删除、定期删除和惰性删除。
2.为什么Redis这么快?
Redis在处理客户端的请求时包括获取socket读、解析、执行、内容返回socket写等都由一个顺序串行的主线程处理,这就是所谓的单线程。
为什么Redis是单线程?
Redis定位是内存kv存储,一般来说执行会很快,而往往影响性能的是网络IO,处理逻辑多线程没有太大收益。 多线程会带来上下文切换成本、同步机制的开销、线程本身使用的内存资源等。
言归正传,为什么Redis这么快呢?
按理说,Redis核心的请求是单线程,要比多线程差很多,但是Redis却能用单线程模型达到每秒数万级别的处理能力。主要有以下几个关键因素:
- 大部分操作内存上完成,内存操作本就很快。
- 对象底层实现选用了许多高效的数据结构,如ZIPLIST、Hash、跳表,并且一种对象底层可能根据不同条件选用多个高效结构。
- 多路复用机制,使其在网络IO操作上能并发处理大量客户端请求,实现高吞吐量。
接下来重点探讨一下,为什么要有多路复用机制。
尽管 Redis 的核心操作是在内存中完成的,但为了保证数据的可靠性和高可用性,磁盘 I/O 操作不可忽视。优化磁盘 I/O 的性能,尤其是在持久化和复制过程中,能够显著提高 Redis 在高负载情况下的整体性能。
IO多路复用就是有触发IO操作的时候,就会产生通知,收到通知,再去处理对应的事件,针对IO多路复用,Redis做了一层包装,叫Reactor模型(本质就是监听各种事件,当事件发生时,将事件分发给不同的处理器)(并发)。
综上,有以下几个问题:
1、Redis是多线程还是单线程的? Redis的核心处理逻辑一直都是单线程的,某些异步流程从4.0开始用多线程,如UNLINK、FLUSHALL ASYNC等非阻塞操作,网络IO解包从6.0开始使用多路复用。
2、为什么单线程快? 首先,Redis是在内存层面操作的本身就快,同时很多对象底层根据不同场景选用高效的数据结构,大大提高了性能;还采用了多路复用的机制,使其在网络IO操作中能并发处理大量的客户端请求,实现高吞吐量。
3.万一内存满了?咋办?
在32位操作系统中,maxmemory默认是3G,对于64位操作系统默认1024KB、MB、GB。
那我们就肯定会想,要么阻止再次写入数据,要么就淘汰原来的数据。这不很简单吗,那我们一起来看看这是怎么处理的。
内存淘汰策略
- noeviction(不开启数据淘汰)
- 开启数据淘汰
- 针对有过期时间的数据volatile
- LRU(最近访问最少使用,尝试回收最长时间未使用的)
- LFU(回收最不常用的键)
- RANDOM(回收随机的键)
- TTL(回收在过期集合的键,优先回收存活时间较短的键)
- 对于所有数据allkeys
- LRU
- LFU
- RANDOM
- 针对有过期时间的数据volatile
1.LRU(最近最久未使用)
LRU是记录每个key最近访问时间,维护一个访问时间的数据。这需要维护一个全局链表,对Redis来说是个巨大的成本,所以Redis选择采样的方式来做,叫近似LRU算法。
那么这个近似LRU算法是怎么实现的呢?
在LRU模式下,RedisObject对象中的字段lru存储的是key被访问时的时钟,当key被访问,Redis会更新这个lru字段。当内存不足时,就会执行近似LRU算法,就是随机采样n个key,根据字段lru淘汰掉最旧的那个key,如果淘汰后仍不足,就继续这个步骤。 采样范围
- Allkeys
- volatile从有过期时间的key随机采样 缺点 它不是一个完整的LRU,随机采样导致结果不是全局真正的最久未访问。 优化 Redis3.0会维护一个大小为16的池子,池中数据根据访问时间排序。每次选取的key都会放到池中,然后淘汰掉最久未访问的。 来个RANDOM和LRU的思考,嗯? volatile-RANDOM随机选取有过期时间的元素后就直接淘汰被选取的元素,而LRU在随机挑选出元素后,淘汰掉其中最久没有被访问的那个元素。
2.LFU(最不常用淘汰)
LFU优先淘汰活跃最低,使用频率最低的。在4.0引入LFU,其实LRU就可以解决大部分问题了,但是脱离频率,只谈最近访问过的key,在某些场景里是不合适的。
从上面我们知道了lru这个字段(24位)是记录时间戳的,那么在这里LFU复用这个字段,前16位存储上一次访问时间戳(精度是1分钟),低8位存储访问计数。
那么这个lfu(lru)字段数据怎么更新呢? 1、相对于上次访问时间间隔,使用函数LFUDecrAndReturn计算访问计数。 2、一定概率增加访问计数。 3、当前时间更新高16位,次数更新低8位。
总结
1、内存回收什么时候发起?
每次读写的时候,都会调用processCommand函数,转而调用freememoryIfNeeded函数。
2、介绍下Redis LRU算法?
LRU是最近最久未使用算法,记录每个key的最近访问时间,淘汰最久未没访问到的数据,但是这个LRU算法不是标准的。在Redis中,对LRU算法做了优化,这块需要讲一下吗? (如果需要) 标准LRU算法需要维护双链表,内存消耗大,所以Redis采用近似LRU算法采样进行淘汰,就是随机采样n个key,默认为5个,然后根据时间戳淘汰最旧的那个key。3.0版本后,有对近似LRU做了优化,就是维护了一个候选池,池中的数据根据访问时间进行排序。每次随机选取的key都会放入池中,然后淘汰掉最久未访问的。
3、什么是LFU算法,为什么Redis引入LFU算法?
LFU算法是考虑到了访问频率的影响,LFU记录了上一次访问时间戳和访问计数,访问计数同时受上一次访问时间和访问频率的影响,因为每次访问都可能会增加计数。
参考资料
1、https://xiaolincoding.com/redis/