【Redis 原理】通信协议 && 内存回收

文章目录

通信协议--RESP

Redis是一个CS架构的软件,通信一般分两步(不包括pipeline和PubSub):

  1. 客户端(client)向服务端(server)发送一条命令
  2. 服务端解析并执行命令,返回响应结果给客户端

因此客户端发送命令的格式、服务端响应结果的格式必须有一个规范,这个规范就是通信协议。

而在Redis中采用的是RESPRedis Serialization Protocol)协议:

  • Redis 1.2版本引入了RESP协议
  • Redis 2.0版本中成为与Redis服务端通信的标准,称为RESP2
  • Redis 6.0版本中,从RESP2升级到了RESP3协议,增加了更多数据类型并且支持6.0的新特性--客户端缓存

但目前,默认使用的依然是RESP2协议,也是我们要学习的协议版本(以下简称RESP)

在RESP中,通过首字节的字符来区分不同数据类型,常用的数据类型包括5种:

  1. 单行字符串:首字节是 + ,后面跟上单行字符串,以CRLF( "\r\n" )结尾。例如返回OK+OK\r\n

    由此可见:单行字符串中不能包含一些特殊字符(如\r\n等),是非二进制安全的

  2. 错误(Errors):首字节是 -,与单行字符串格式一样,只是字符串是异常信息,例如:-Error message\r\n

  3. 数值:首字节是 :,后面跟上数字格式的字符串,以CRLF结尾。例如::10\r\n

  4. 多行字符串:首字节是 $,表示二进制安全的字符串,最大支持512MB:

    如果大小为0,则代表空字符串$0\r\n\r\n

    如果大小为-1,则代表不存在$-1\r\n

  5. 数组:首字节是 *,后面跟上数组元素个数,再跟上元素,元素数据类型不限

内存回收

Redis之所以性能强,最主要的原因就是基于内存存储

然而单节点的Redis其内存大小不宜过大,会影响持久化或主从同步性能。

我们可以通过修改配置文件来设置Redis的最大内存:

bash 复制代码
# 格式:
# maxmemory <bytes>
# 例如:
maxmemory 1gb

当内存使用达到上限时,就无法存储更多数据了。

为了解决这个问题,Redis提供了一些策略实现内存回收:
内存过期策略 && 内存淘汰策略

内存过期策略

Redis可以通过expire命令给Redis的key设置TTL(存活时间):

可以发现,当key的TTL到期以后,再次访问name返回的是nil,说明这个key已经不存在了,对应的内存也得到释放。从而起到内存回收的目的。

但是这背后是怎么实现的?让我们从以下两个问题入手:

Q1:Redis是如何知道一个key是否过期呢?

Redis本身是一个典型的key-value内存存储数据库,因此所有的key、value都保存在之前学习过的Dict结构中。

并且在其database结构体中,有两个关键Dict:一个用来记录key-value;另一个用来记录key-TTL,定义如下:

c 复制代码
typedef struct redisDb {
    dict *dict;                 /* 存放所有key及value的地方,也被称为keyspace*/
    dict *expires;              /* 存放每一个key及其对应的TTL存活时间,只包含设置了TTL的key*/
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID,0~15 */
    long long avg_ttl;          /* 记录平均TTL时长 */
    unsigned long expires_cursor; /* expire检查时在dict中抽样的索引位置. */
    list *defrag_later;         /* 等待碎片整理的key列表. */
} redisDb;

因此到这里我们可以解答Q1:利用两个Dict分别记录key-value对及key-ttl键值对,这样就可以查询一个key是否过期

Q2:是不是TTL到期就立即删除了呢?

NO,TTL到期的key并不是立即删除,而是采用如下策略:
惰性删除 && 周期删除

惰性删除

顾明思议并不是在TTL到期后就立刻删除,而是在访问一个key的时候,检查该key的存活时间,如果已经过期才执行删除

核心代码片段如下:

c 复制代码
// 查找一个key执行写操作
robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags) {
    // 检查key是否过期
    expireIfNeeded(db,key);
    return lookupKey(db,key,flags);
}
// 查找一个key执行读操作
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
    robj *val;
    // 检查key是否过期    
    if (expireIfNeeded(db,key) == 1) {
        // ...略
    }
    return NULL;
}

int expireIfNeeded(redisDb *db, robj *key) {
    // 判断是否过期,如果未过期直接结束并返回0
    if (!keyIsExpired(db,key)) return 0;
    // ... 略
    // 删除过期key
    deleteExpiredKeyAndPropagate(db,key);
    return 1;
}

可以看到不论是查找一个key做读还是写操作,都会调用expireIfNeeded函数来判断当前key是否过期,如果过期就会删除该key

那么这就有一个问题:如果一个key我们设置了TTL,并且它已经过期了,但是一直没有人去访问这个key,如果redis仅仅靠惰性删除的话,显然这个本该删除的key就永远不可能删除了。这显然是不合理的。因此有了下面的周期删除。

周期删除

通过一个定时任务,周期性的抽样部分过期的key,然后执行删除。执行周期有两种:

  1. Redis服务初始化函数initServer()中设置定时任务,按照server.hz的频率来执行过期key清理,模式为SLOW,核心代码如下:
c 复制代码
// server.c
void initServer(void){
    // ...
    // 创建定时器,关联回调函数serverCron,处理周期取决于server.hz,默认10
    aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) 
}


// server.c
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    // 更新lruclock到当前时间,为后期的LRU和LFU做准备
    unsigned int lruclock = getLRUClock();
    atomicSet(server.lruclock,lruclock);
    // 执行database的数据清理,例如过期key处理
    databasesCron();
}

void databasesCron(void) {
    // 尝试清理部分过期key,清理模式默认为SLOW
    activeExpireCycle(
          ACTIVE_EXPIRE_CYCLE_SLOW);
}
  1. Redis的每个事件循环前会调用beforeSleep()函数,执行过期key清理,模式为FAST
c 复制代码
void beforeSleep(struct aeEventLoop *eventLoop){
    // ...
    // 尝试清理部分过期key,清理模式默认为FAST
    activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST);
}

SLOW模式规则:

  1. 执行频率受server.hz影响,默认为10,即每秒执行10次,每个执行周期100ms。
  2. 执行清理耗时不超过一次执行周期的25%.默认slow模式耗时不超过25ms
  3. 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
  4. 如果没达到时间上限(25ms)并且过期key比例大于10%,再进行一次抽样,否则结束

FAST模式规则(过期key比例小于10%不执行 ):

  1. 执行频率受beforeSleep()调用频率影响,但两次FAST模式间隔不低于2ms
  2. 执行清理耗时不超过1ms
  3. 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
  4. 如果没达到时间上限(1ms)并且过期key比例大于10%,再进行一次抽样,否则结束

内存淘汰策略

当Redis内存使用达到设置的上限时,主动挑选部分key删除以释放更多内存的流程

Redis会在处理客户端命令的方法processCommand()中尝试做内存淘汰:

c 复制代码
int processCommand(client *c) {
    // 如果服务器设置了server.maxmemory属性,并且并未有执行lua脚本
    if (server.maxmemory && !server.lua_timedout) {
        // 尝试进行内存淘汰performEvictions
        int out_of_memory = (performEvictions() == EVICT_FAIL);
        // ...
        if (out_of_memory && reject_cmd_on_oom) {
            rejectCommand(c, shared.oomerr);
            return C_OK;
        }
        // ....
    }
}

Redis支持8种不同策略来选择要删除的key:

  1. noeviction不淘汰任何key ,但是内存满时不允许写入新数据,默认就是这种策略
  2. volatile-ttl: 对设置了TTL的key ,比较key的剩余TTL值,TTL越小越先被淘汰
  3. allkeys-random:对全体key随机进行淘汰。也就是直接从db->dict中随机挑选
  4. volatile-random:对设置了TTL的key随机进行淘汰。也就是从db->expires中随机挑选
  5. allkeys-lru: 对全体key ,基于LRU算法进行淘汰
  6. volatile-lru: 对设置了TTL的key ,基于LRU算法进行淘汰
  7. allkeys-lfu: 对全体key ,基于LFU算法进行淘汰
  8. volatile-lfu: 对设置了TTL的key ,基于LFU算法进行淘汰

比较容易混淆的有两个:
LRU(Least Recently Used),最少最近使用。用当前时间减去最后一次访问时间 ,这个值越大则淘汰优先级越高

LFU(Least Frequently Used),最少频率使用。会统计每个key的访问频率值越小淘汰优先级越高

对于上面的8种策略,可以通过如下字段设置具体的策略:

我们着重了解一下LRU和LFU的实现方式

我们都知道,Redis的数据都会被封装为RedisObject结构:

c 复制代码
typedef struct redisObject {
    unsigned type:4;        // 对象类型
    unsigned encoding:4;    // 编码方式
    unsigned lru:LRU_BITS;  // LRU:以秒为单位记录最近一次访问时间,长度24bit
			  // LFU:高16位以分钟为单位记录最近一次访问时间,低8位记录逻辑访问次数
    int refcount;           // 引用计数,计数为0则可以回收
    void *ptr;              // 数据指针,指向真实数据
} robj;

其中 unsigned lru:LRU_BITS; 根据maxmemory-policy可以设置具体的策略:

  1. maxmemory-policy allkeys-lrumaxmemory-policy volatile-lru
    此时obj种的lru以秒为单位记录最近一次访问时间,长度24bit
  2. maxmemory-policy allkeys-lfumaxmemory-policy volatile-lfu
    此时obj种的lru的高16位以分钟为单位记录最近一次访问时间,低8位记录逻辑访问次数
    问:何为逻辑访问次数?
    答:LFU的访问次数之所以叫做逻辑访问次数,是因为并不是每次key被访问都计数,而是通过以下运算方式:
    ①生成0~1之间的随机数R
    ②计算 (旧次数 * lfu_log_factor + 1),记录为P
    ③如果 R < P ,则计数器 + 1,且最大不超过255
    ④访问次数会随时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟(默认是1),计数器 -1

具体的淘汰策略如下图所示:

相关推荐
宇钶宇夕4 分钟前
EPLAN 电气制图:建立自己的部件库,添加部件-加SQL Server安装教程(三)上
运维·服务器·数据库·程序人生·自动化
爱可生开源社区28 分钟前
SQLShift 重磅更新:支持 SQL Server 存储过程转换至 GaussDB!
数据库
贾修行1 小时前
SQL Server 空间函数从入门到精通:原理、实战与多数据库性能对比
数据库·sqlserver
傲祥Ax1 小时前
Redis总结
数据库·redis·redis重点总结
一屉大大大花卷2 小时前
初识Neo4j之入门介绍(一)
数据库·neo4j
周胡杰2 小时前
鸿蒙arkts使用关系型数据库,使用DB Browser for SQLite连接和查看数据库数据?使用TaskPool进行频繁数据库操作
前端·数据库·华为·harmonyos·鸿蒙·鸿蒙系统
wkj0012 小时前
navicate如何设置数据库引擎
数据库·mysql
赵渝强老师2 小时前
【赵渝强老师】Oracle RMAN的目录数据库
数据库·oracle
暖暖木头2 小时前
Oracle注释详解
数据库·oracle
御控工业物联网3 小时前
御控网关如何实现MQTT、MODBUS、OPCUA、SQL、HTTP之间协议转换
数据库·sql·http