Redis 数据库实现相关源码阅读笔记,源码文件 server.h & server.c & db.c & evict.c & expire.c & blocked.c。
1. 小结
-
Redis 数据库对象主要由以下几部分组成:
id: Database ID,值域为 $0$ 至 databases $- 1$dict:数据库具体内容 —— key-value pair- key 为 sds strings,不使用 redis object 是为了节省内存
- value 为 redis object
expires:记录数据对象过期时间的 key-ttl pair- key 为 sds strings,会复用
dict中的键值以节省内存 - value 为long long 整型,记录了该 key 将于何时 (absolute unix time in milliseconds) 过期
- 被动清理过期键:一般地,在查找 key 是否在
dict中时,会先判断该 key 是否已过期,若为主库,则会删除已过期键 - 主动清理过期键:调用
activeExpireCycle
- key 为 sds strings,会复用
avg_ttl:统计过期时间平均值expires_cursor:用于渐进式主动过期blocking_keys&ready_keys:用于实现带阻塞的命令watched_keys:用于实现事务defrag_later:用于内存碎片整理
-
键过期以被动的方式进行,只会在访问该 key 的时候触发:
- 在客户端访问该 key 时调用
expireIfNeeded触发过期删除操作当从库处于 read-only 模式时,访问从库的 key 不会触发删除操作
- Redis 服务器定时调用
activeExpireCycle主动扫描键空间以清理过期 key可使用配置项 active-expire-effort 修改主动清理操作单次执行时间上限
- 在客户端访问该 key 时调用
-
过期
key的删除分为同步和异步两种方式,由配置项 lazyfree-lazy-expire 决定采用何种方式。 -
当设置了
maxmemory限制时,Redis 会根据所设置的内存策略 maxmemory-policy 决定淘汰 key 的方式。
Tips
- 为性能考虑,应尽量避免调用
gettimeofday来获取时间,可使用 Redis 缓存的时间值。
2. redisDb
Redis 数据库由多个 dict 组成,其结构定义如下:
|
|
在之前 Redis dict 结构的介绍中,我们知道 dict 可以有不同的 dictType,不同类型的 dict 的区别在于 kv 类型及其相关操作不同,在 redisDb 中的各个 dict 其类型如下:
| field | dictType | key | value |
|---|---|---|---|
dict |
dbDictType | sds strings | redis objects |
expires |
keyptrDictType | sds strings | long long |
blocking_keys |
keylistDictType | redis objects | lists |
ready_keys |
objectKeyPointerValueDictType | redis objects | dummy pointers |
watched_keys |
keylistDictType | redis objects | lists |
dbDictType
Redis 数据库 keysapce 的 dictType 为 dbDictType, 其定义如下,键使用 sds 而不使用 robj,可以节省内存。
|
|
keyptrDictType
键类型为 sds,会复用 redisDb->dict 中的键值,值类型为 long long,记录了该对象何时 (absolute unix time in milliseconds) 会过期。
|
|
keylistDictType
|
|
objectKeyPointerValueDictType
|
|
3. redisDb APIs
3.1. 查找
lookupKey
|
|
其中 flags 用于指示是否更新 robj->lru,其取值有二:
LOOKUP_NONE: no special flags are passed.LOOKUP_NOTOUCH: don’t alter the last access time of the key.
除此之外,若此时正有子线程正在进行保存 RDB、重写 AOF 或加载 module 相关操作时,也不会更新该数据对象的 lru 值,以避免一个数据对象被疯狂复制多次。
key 存在于 db 中时,返回其关联的 value 值,否则返回 NULL。
lookupKeyReadWithFlags
|
|
在该函数中,在调用 lookupKey 判断 key 是否存在于 db 中之前,会先调用 expireIfNeeded 判断该 key 是否已过期(有关过期的内容将在下文详述)。从库为了和主库保持数据的一致性,并不会主动清理过期键,而是会同步主库在清理过期键时发送的 DEL 命令,但在该查找操作中,如果 key 已过期,从库虽未删除该 key,但仍然会返回 NULL,表示该 key 逻辑上不存在,以和主库保持一致的视图。
调用该函数有以下副作用:
- 在 Redis 主库中,若
key达到其过期时间,则会被删除,同步或异步删除则取决于配置的 lazyfree-lazy-expire 值 (调用expireIfNeeded引起) - 可能会更新
robj->lru(调用lookupKey引起) - 会更新数据库的
hits或misses状态信息 - 如果启用了键空间通知,则会触发
keymiss通知
lookupKeyRead
|
|
lookupKeyWriteWithFlags
|
|
others
|
|
3.2. 添加
dbAdd
|
|
- 在将
key加入db->dict时,会复制key的字符串值,将key以sds形式加入db。 - 如果值类型为 list、zset 或 stream,
key此时在db->blocking_keys中且不在db->ready_keys时,会将key加入db->ready_keys中 - 若 Redis 服务器为 cluster 模式,会将
key和对应的slot建立映射,表示key存在于该slot中
dbAddRDBLoad
|
|
- 即使
key此时在db->dict中,也不会触发 aborted,而是会返回 0 - 如果
key成功加入db->dict中,则返回 1 - 由于传入的
key类型为sds,所以和dbAdd相比并无复制字符串操作,因此在成功加入时,调用方不可对key进行释放操作,此时key的所有权交予db->dict;但在返回 0 时,key的释放操作需由调用方完成 - 若 Redis 服务器为 cluster 模式,会将
key和对应的slot建立映射,表示key存在于该slot中
dbOverwrite
|
|
setKey & genericSetKey
|
|
在 genericSetKey 中:
keepttl指示是否保持该key的 ttl 不变,其值由具体的写入命令参数决定signal指示是否需要调用signalModifiedKey通知该key已被修改,除了在 redis module 中,该值一直为 1,而在 redis module 中,该值为 0。使用 Redis module 可以通过调用RedisModule_SignalModifiedKey通知该key已被修改- 在该函数中无论
key先前是否存在,都会将key-val对写入db中 val的引用计数会增加- 在
keepttl不为 1 时,与key关联的过期时间会被移除
3.3. 删除
删除某个 key
|
|
删除指定的 db
|
|
3.4. Utils
dbRandomKey
|
|
dbUnshareStringValue
|
|
4. expire
设置了过期时间的 key,会将 <key, absolute-expire-time> 映射加入到 redisDb->expires 字典中,以支持后续的键过期行为。Redis 的键过期以被动的方式进行,只会在访问到该 key 的时候触发。访问的方式大致可以分为两种:
-
在客户端访问到该
key时调用expireIfNeeded触发过期删除操作,例如查找、删除、随机返回 key和 Scan操作时 -
为防止有些过期
key一直未被访问从而无法释放,Redis 主库还会定时调用activeExpireCycle主动去扫描过期键空间清理过期key
过期 key 的删除分为同步和异步两种方式,由配置项 lazyfree-lazy-expire 决定采用何种方式。
4.1. Expire APIs
removeExpire
|
|
只有当 key 在 redisDb->dict 中时,才会尝试从 redisDb->expires 删除与 key 对应的 dictEntry 并释放其占用的内存。
setExpire
|
|
- 当调用请求来自客户端时,
c为客户端上下文,否则c为NULL when为过期时间,其为 unix time 的绝对值,即key在when这一时刻过期,单位为msredisDb->expires复用redisDb->dict的键字符串
getExpire
|
|
propagateExpire
|
|
keyIsExpired
|
|
- 如果此时正在 loading data,则不会进行过期操作,因此该函数直接返回 0
- 如果正在运行 lua 脚本,则会把 lua 脚本开始运行的时间
server.lua_time_start作为当前时间,这是为了防止在 lua 脚本运行期间 key 过期同步至从库,从而导致在从库上 lua 脚本的运行和主库不一致的情况发生,详情可见 issue 1525 - 如果此时正在某个命令运行期间,会使用缓存的
server.mstime作为当前时间
expireIfNeeded
|
|
正如之前所述,在客户端访问 key 时可能需要先调用 expireIfNeeded 检测该 key 是否已过期。该函数的运行模式与 Redis 服务器角色有关:
- 在 Redis 主库中,如果此时
key已过期,会触发删除key操作,并将删除操作同步至 salves 和 AOF - 在 Redis 从库中,如果此时
key已过期,则只会立马返回 1 表示该key已过期,但不会执行删除操作,这是因为从库为了和主库的数据保持一致,其过期key的清理由同步主库的 del 消息完成
4.2. 主动清理过期键
之前我们介绍过 Redis 主库除了在访问 key 时触发过期操作,还会定期调用 activeExpireCycle 清理过期 key,其接口如下:
|
|
type 参数表示该函数使用何种模式运行,共有两种模式:
ACTIVE_EXPIRE_CYCLE_FAST: 在beforeSleep中调用activeExpireCycle时使用该模式,将单次清理过期 key 的执行时间限制在config_cycle_fast_duration(默认为 $1ms$) 之内ACTIVE_EXPIRE_CYCLE_SLOW: 在databaseCron中调用activeExpireCycle时使用该模式,将定期清理过期 key 操作使用 CPU 占比控制在config_cycle_slow_time_perc(默认为 $25%$) 之内,单次执行时间限制为config_cycle_slow_time_perc*1000000/server.hz/100
上述出现的参数可使用配置项 active-expire-effort 配置,对应关系如下:
| active-expire-effort | fast_duration(ms) | slow_time_perc | keys_per_loop | acceptable_stale |
|---|---|---|---|---|
| $a + 1$ | $1 + \frac{a}{4}$ | $25\% + 2a\%$ | $20 + 5a$ | $10\% - a\%$ |
| $[1, 10]$ | $[1, 3.25]$ | $[25\%, 43\%]$ | $[20, 65]$ | $[10\%, 1\%]$ |
其中,keys_per_loop(config_keys_per_loop) 指的是每次迭代扫描的 key 的数量上限,acceptable_stale(config_cycle_acceptable_stale) 指的是可容忍的已过期键比例。从上表可以的值,当 active-expire-effort 值越大时,每次清理过期 key 的调用执行时间上限越长,单个迭代内扫描 key 数量上限越大,对已过期键占比的容忍度越低。
该函数以“伪增量”(期间写入的过期 key 可能在本次全面遍历中未被访问)形式对过期键空间进行扫描,使用局部静态变量记录上次执行的状态:
current_db记录上次调用此函数时的server.db的位置,每个redisDb内部的扫描位置则由redisDb->expires_cursor记录timelimit_exit记录上次执行是否超时。每次调用该函数扫描的 db 数量上限默认为CRON_DBS_PER_CALL如果该值大于server.dbnum或上次该函数执行超时时,每次调用该函数扫描的 db 数量上限则设置为server.dbnumlast_fast_cycle记录上次以 fast 模式运行该函数的起始时间,以防止上次 fast 模式扫描过程超时且尚未终止时启动新的 fast 模式扫描过程
在以下情况下,该函数会立马返回,不启动扫描过程:
- 在 client paused 期间,Redis 数据库是静态的,其不仅会阻止客户端的写入,也不会执行 evict 和 expire 操作,因此,若
server.clients_paused为真时,该函数会直接返回 - 在 fast 模式下,上次该函数并未超时退出,且此时已过期键占比近似值
server.stat_expired_stale_perc小于可容忍上限config_cycle_acceptable_stale - 在 fast 模式下,上次 fast 模式扫描过程超时且可能尚未终止
在扫描过程中,该函数会试图遍历指定的 db 数量,其终止条件有二:
- 函数运行时间超出时间限制
- 每个 db 在单次迭代中已过期键占比低于可容忍值,则切换至下一 db,遍历 db 数量达到指定上限时扫描终止
在扫描过程中,该函数还会更新 Redis 运行状态:
server.stat_expiredkeys:已过期键总数redisDb->avg_ttl:ttl 近似平均值,先前值为 0 时,使用本次迭代值作为 ttl 近似平均值;否则以先前值权重 $98\%$,单次迭代值权重 $2\%$ 进行加权平均server.stat_expired_time_cap_reached_count:Expire cycles 超时退出次数server.stat_expire_cycle_time_used: Expire cycles 使用总时间 (ms)server.stat_expired_stale_perc:已过期键占比近似值,以先前值占比 $95\%$,本次调用运行值占比 $5\%$ 进行加权平均
4.3. 处于可写模式下的从库清理过期键
TODO
4.4. Expire Commands
TODO
5. evict
在之前关于 redis object 的介绍中,我们知道 redis obejct 有一个 filed 为 lru,其在创建时初始化,在每次查找操作时更新,它的作用就是用于挑选目标 key 进行淘汰。
Redis 作为内存数据库,通常会设置 maxmemory 指定可用的最大内存空间大小。若设置了 maxmemory 值,则 Redis Server 会在每次执行命令之前调用 freeMemoryIfNeededAndSafe 以检查目前内存使用量是否已经达到 maxmemory,若为真,会根据设置的 maxmemory-policy 决定淘汰 key 的方式,可选策略如下:
volatile-lru: Evict using approximated LRU, only keys with an expire set.allkeys-lru: Evict any key using approximated LRU.volatile-lfu: Evict using approximated LFU, only keys with an expire set.allkeys-lfu: Evict any key using approximated LFU.volatile-random: Remove a random key having an expire set.allkeys-random: Remove a random key, any key.volatile-ttl: Remove the key with the nearest expire time (minor TTL)noeviction: Don’t evict anything, just return an error on write operations.
接下来我们重点介绍在 Redis 中 LRU/LFU 相关策略的实现。
5.1. Least Recently Used(LRU)
获取 lruclock
|
|
通过调用 getLRUClock 可以获得此时的 lruclock 值,其精度由 LRU_CLOCK_RESOLUTION 控制,数值最大位数为 LRU_BITS。
从 issues 2552 可知,为性能考虑,应尽量避免调用 gettimeofday 来获取时间,因此 Redis 还提供了 LRU_CLOCK 接口在 LRU_CLOCK_RESOLUTION 精度范围内,返回 Redis Server 此时正缓存的 lruclock 值。
计算 idle time
|
|
5.2. Least Frequently Used(LFU)
从 redisObject 的定义中,我们知道 LFU 和 LRU 复用同一 field lru,因此在 LFU 中访问频率的大小只可用 LRU_BITS 位来表示。Redis 使用下图所示的结构表示频率:
|
|
Last decr time: 最近一次访问时间LOG_C:对数计数器
增加计数
因为位数有限,因此 LFU 使用 8-bit 对数计数器 LOG_C 表示访问频率,在初始 createObject 时,值为 LFU_INIT_VAL,其大小表示其接下来可能会被访问的“潜能”。
在每次访问 Redis 数据对象时,会调用 LFULogIncr 尝试增加对数计数,其代码如下所示:
|
|
可以看出,counter 增加满足以下公式:
$$ NewC = \begin{cases} 255, & curC = 255 \cr curC + 1, & curC \le \text{LFU_INIT_VAL} \cr curC_{1 - p} \enspace | \enspace (curC + 1)_p, & curC > \text{LFU_INIT_VAL} \cr \end{cases} $$
其中当目前计数值大于 LFU_INIT_VAL 时,会以概率 $p$ 加 1,从代码中可以看出 $p$ 的大小和当前计数值大小反相关。代码中出现的常量 lfu_log_factor 可通过 redis.conf 配置,其值越大,计数器加 1 的概率就越小,可参考如下表格(表格中 counter 数值非数学期望值):
| factor | 100 hits | 1000 hits | 100K hits | 1M hits | 10M hits |
|---|---|---|---|---|---|
| 0 | 104 | 255 | 255 | 255 | 255 |
| 1 | 18 | 49 | 255 | 255 | 255 |
| 10 | 10 | 18 | 142 | 255 | 255 |
| 100 | 8 | 11 | 49 | 143 | 255 |
减小计数
使用 LFU 策略时,需要并不是历史总访问频率的大小,而是“最近”访问频率的信息,因此需要设计合理降低对数计数器的方法。Redis 使用 16-bit 的 Last decr time 记录数据对象最近一次被访问的时间(取名为 Last decr time 可能是因为在每次更新 LFU 时会先调用 LFUDecrAndReturn 下调当前计数值)。相关源码如下:
|
|
可以看出,在 LFUDecrAndReturn 中,参数 lfu_decay_time 类似于元素的半衰期,控制着计数值随时间流逝而衰减的行为,其值同样可通过 redis.conf 配置。
- 当
lfu_decay_time为 0 时,计数器不随时间衰减 - 当
lfu_decay_time大于 0 时,每经过lfu_decay_time分钟,计数器减 1,直至为 0
还可以发现在 LFUDecrAndReturn 中并不会改变 robj->lru 的值,只是返回此时经过衰减之后的计数值。其值的更改会在每次真正访问该数据对象时发生。
5.3. Eviction pool
Redis 使用淘汰候选数组 EvictionPoolLRU 保存着可淘汰 key 的信息,其中每个候选 key 的相关信息由 evictionPoolEntry 表示,其结构定义如下:
|
|
key字段存储该数据对象的键值,但并不会复用sds,而是会复制数据对象的键值cached字段是为了在键长度小于EVPOOL_CACHED_SDS_SIZE时复用之前复制数据对象键值时分配的内存空间,否则会申请新的内存空间来存储数据对象的键值
在数组 EvictionPoolLRU 中,
- 使用 LRU 相关策略时,entry 按照 idle time 从小到大排序
- 使用 LFU 相关策略时,entry 按照访问频率从大到小排序
当 redis server 初始化时,会调用 evictionPoolAlloc 初始化 EvictionPoolLRU,此时数组中尚无候选 key 信息,cached 字段指向长为 EVPOOL_CACHED_SDS_SIZE 的 sds。数组会在每次需要淘汰 key 时通过调用 evictionPoolPopulate 填充。其接口如下:
|
|
dbid: database IDsampledict: 采样的 keysapce,随 maxmemory-policy 而定- 若只能淘汰过期 key,则采样空间为
redisDb->expires - 若所有 key 均可淘汰,则采样空间为
redisDb->dict
- 若只能淘汰过期 key,则采样空间为
keydict: redisDb 的 keysapce,即redisDb->dictpool: 静态变量EvictionPoolLRU
该函数的大致处理流程如下:
-
从采样空间
sampledict中尝试采样maxmemory_samples个数据对象maxmemory_samples可由 redis.conf 配置- 返回的采样结果中,数据对象可能有重复,其总数也可能小于
maxmemory_samples
-
获得采样数据对象的 idle 值
- 如果淘汰策略为 LRU 相关,
idle = estimateObjectIdleTime(o),即为该对象的 idle time - 如果淘汰策略为 LFU 相关,
idle = 255 - LFUDecrAndReturn(o),此时对数计数器值越大,idle 越小 - 如果淘汰策略为 TTL 相关,
idle = ULLONG_MAX - expire_unix_time,越早过期,idle 越大
- 如果淘汰策略为 LRU 相关,
-
根据 idle 值,将该数据对象插入
EvictionPoolLRU中相应的位置,维持数组的有序性(idle 从小到大)
5.4. Free memory
获得目前数据库内存使用情况
|
|
Redis 在进行淘汰 key 操作时,会排除 slave 和 AOF 客户端缓冲区占用的内存,该排除部分的值大小可调用 freeMemoryGetNotCountedMemory 获得。
在 getMaxmemoryState 中,除了返回 C_OK 或 C_ERR 表示是否超最大内存限制之外,还会视情况填充传入的非 NULL 参数:
total: total amount of bytes used. (Populated both for C_ERR and C_OK)logical: the amount of memory used minus the slaves/AOF buffers. (Populated when C_ERR is returned)tofree: the amount of memory that should be released in order to return back into the memory limits. (Populated when C_ERR is returned)level: the ratio of memory usage (used_memory/maxmemory). (Populated both for C_ERR and C_OK)
freeMemoryIfNeeded
正如前文所述,Redis 服务器设置了 maxmemory 值时,会在每次执行命令之前调用 freeMemoryIfNeededAndSafe 以检查目前内存使用量是否已经达到 maxmemory,该函数源码如下:
|
|
可以看出,freeMemoryIfNeededAndSafe 会在以下两种情况下,不检测当前内存使用状况,直接返回 C_OK:
- 当前有执行超时的 lua 脚本
- 当前正在加载数据
在 freeMemoryIfNeeded 函数中,会根据当前内存使用状况和 maxmemory 值尝试进行内存释放操作以使内存使用量满足 maxmemory 的限制。其接口如下:
|
|
- 一般情况下,当前内存使用量低于
maxmemory或在进行释放内存操作后低于maxmemory时,函数返回C_OK,否则返回C_ERR。 - 默认情况下从库无
maxmemory限制,直接返回C_OK可通过将 replica-ignore-maxmemory 设置为 no,使得从库也执行驱逐策略,但可能会造成主从数据不一致
- 在 client paused 期间,Redis 数据库是静态的,其不仅会阻止客户端的写入,也不会执行 evict 和 expire 操作,因此,若
server.clients_paused为真时,该函数会直接返回C_OK - 当内存管理策略为
noeviction时,会检查当前 lazyfree 队列中是否有任务,若为真则等待lazyfree任务完成后调用getMaxmemoryState得到此时的内存使用状态并返回 - 根据内存管理策略采样选择最合适的 key 进行淘汰直至内存使用量低于
maxmemory- 当内存管理策略不为 random 相关类型时,每次删除一个 key 之前都会调用
evictionPoolPopulate填充 evict pool,随后从后往前遍历得到此时最适合删除的 key - 当内存管略为 volatile 相关类型时,因为采样空间
redisDb->expires可能为空,因此可能无法通过淘汰 key 使得内存使用量低于maxmemory,此时只能和noeviction一样,等待 lazyfree 的操作结果
- 当内存管理策略不为 random 相关类型时,每次删除一个 key 之前都会调用