因为原《Redis底层对象实现原理分析》太大了,所以被拆解出来介绍Redis基础设施的相关实现,包括:
- redisDb,以及在这上面的增删改查
- Redis的expire和evict机制
- Redis的事件机制
- Redis的主从复制(一部分)
注意,很多实现在引入主从复制之后都变得非常复杂,有很多边边角角要考虑,这也导致Redis的代码相比3.0版本要难看很多。本文对主从复制的涉及,局限于帮助理解实现。
本文介绍的部分比如propagate机制。
本文中不介绍的是,它们在系列的其他文章中讲解:
- Redis的对象实现
- Redis Sentinel
- Redis Cluster
- Redis AOF/RDB
Redis源码结构
在3.0版本中,redis的主要结构都定义在redis.h中,在新版本中,它们被放到了server.h中。
我们主要介绍
- 一些常用的类
- redisServer
- redisDb
- redisObject
包含添加对象的逻辑
- 删除逻辑
包含对同步删除和异步删除的讨论。 - 查找逻辑
- expire
- evict
- propagate
- 事件机制
- 内存管理
Redis Server
这个章节中介绍Redis数据库顶层键的架构和增删改查的实现,主要包括:
redisDb类
1 | typedef struct redisDb { |
可以发现,redisDb
本身就依赖dict
和list
等Redis底层结构的实现,说明Redis的复用性还是很好的。
client类
client类对应了3.0版本中的redisClient
类。因为Redis对IO是多路复用的,所以需要为每个客户端连接维护一个状态,所以client
实际上类似于session一样,是在服务器端维护的一个状态。而真正的Redis客户端定义在redis-cli.c这个文件里面。
1 | // server.h |
redisServer类
server
是一个全局对象,它的类型是redisServer
。
1 | // server.h |
Redis基础类
增删改查涉及的系统梳理
- DB部分
更新dirty - Cluster部分
- 事件部分
signalModifiedKey:包含通知WATCH列表、通知客户端更新缓存
notifyKeyspaceEvent:通过PUBLUSH发送消息 - 主从复制/持久化部分
propagate对应命令(在call中处理) - Module部分
Redis Object
诸如dict
、sds
之类的对象,在db层面实际上是用redisObject
封装的,需要的时候通过robj->ptr
获取实际需要的指针。
1 | typedef struct redisObject { |
Redis对象的类型是用OBJ_
宏来列出的
1 | // server.h |
Redis对象实际使用的内部结构是用OBJ_ENCODING_
宏来表示的,如前文所列举的,同一个对象可能有不同的实现。
1 | // server.h |
引用计数
有两种特殊的引用计数值:
OBJ_SHARED_REFCOUNT
由makeObjectShared
函数生成,在这种情况下这个对象是immutable的,因此可以不加锁地进行访问。这种对象也不受incrRefCount
/decrRefCount
控制。
注意,这种对象设为immutable是合理的,它的一个通常作用是共享小整数对象,例如Redis会共享0到9999。OBJ_STATIC_REFCOUNT
一般由initStaticStringObject
宏生成。看上去这个一般用在在栈上面分配的临时对象的refcount,我对此也不是很确定。1
2
3
4
// 第一个有特殊含义的refcount值
一般来说,使用引用计数可能存在循环引用的问题。Redis巧妙地避免了这个问题,首先在Redis的所有redisObject
里面,只有String会被嵌入到其他类型中,也就是说ZSET等其他的数据类型不会互相引用(在Geo等新数据结构里面也是这样的么?)。而Redis对String类型引入对象共享机制,保证了不会产生互相引用。
1 | void incrRefCount(robj *o) { |
decrRefCount
还负责销毁对象,步骤是freeXXXObject
,然后在zfree
。前者用来释放o->ptr
指向的对象的内存,后者用来释放o
的内存。
1 | void decrRefCount(robj *o) { |
我们可以这样理解incr/decrRefCount
,如果我们创建或者复制一个对象,就要incr,如果我们要删除一个对象就要decr。
创建对象
通过createObject
创建对象,refcount
设为1。encoding
设为OBJ_ENCODING_RAW
,也就是普通SDS字符串。传入的type
是OBJ_
宏的某个特定值。
1 | robj *createObject(int type, void *ptr) { |
一般在创建完对象后,还需要通过dbAdd
将它插入到数据库里面。
1 | /* Add the key to the DB. It's up to the caller to increment the reference |
对象装箱拆箱
这个都是返回一个“新”对象,这里“新”的意思是在使用完这个对象都应该decrRefCount
。
如果是原生encoding储存的,就直接返回。
1 |
|
如果使用SDS保存的整数,实际上里面是个long long,那么就需要先ll2string
把这个转换成字符串。
1 | if (o->type == OBJ_STRING && o->encoding == OBJ_ENCODING_INT) { |
Redis Command
redisCommand对象
redisCommand对象有新旧很多种版本,新旧版本中存在一些区别,例如sflag
的内容,我们以新版本为主。
1 | // server.h |
command id,是从0开始递增的,作用是检查ACL。一个connection在执行命令前,服务器先要检查第id
位有没有设置,如果设置了,说明这个connection有对应的权限。
1 | int id; |
解释如下:
sflags
是字符串格式的,表示这个命令的一些特性
例如:
write对应CMD_WRITE
read-only对应CMD_READONLYflags
是通过populateCommandTableParseFlags
从sflags
生成的二进制表示。详见server.h中的CMD_
定义,我们在下面会讲解。- 下面是key三元组:
firstkey
表示第一个key参数的位置,lastkey
表示最后一个key参数的位置,keystep
表示key参数步长。通过上面三个参数,可以拿到所有的key。通常发生在getKeysFromCommand
到getKeysUsingCommandTable
函数调用链中。引入这个三元组的目的是有一些指令(如mset
和msetnx
的keystep
取2)是支持在一个命令中对多个key/value对进行赋值的。我们需要注意的是诸如ZADD
的指令虽然可以同时添加很多个(score, member)
对,但是实际上他们是对一个key添加的,所以它们的三元组都是1。 getkeys_proc
表示从命令中判断命令的key,实际上就是当firstkey
、lastkey
和keystep
不能描述的时候,就会用到这个,返回一个int*
表示所有key。例如后面举的eval的例子。microseconds
表示该命令的调用总时间calls
表示该命令的调用总次数id
是在运行时给每个指令分配的id
flags枚举
从sflags可以解析得到flags,枚举如下:
CMD_WRITE (1ULL<<0)
CMD_READONLY (1ULL<<1)
对应read-only,一般包括所有的非特殊的命令,例如返回keys的值,或者返回一些其他信息,例如TIME等。诸如admin、transaction相关的信息,也不会被标记为readonly,因为他们会影响服务器状态。
只读命令和非只读命令在主从复制时,是不一样的。CMD_DENYOOM (1ULL<<2)
对应use-memory,表示这个命令可能导致内存增加。需要在发生OOM的时候拒绝掉。CMD_MODULE (1ULL<<3)
CMD_ADMIN (1ULL<<4)
对应admin,诸如SAVE或者SHUTDOWN的命令。CMD_PUBSUB (1ULL<<5)
SUBSCRIBE、UNSUBSCRIBECMD_NOSCRIPT (1ULL<<6)
这样的命令不能在lua脚本中使用,例如AUTH、SAVE等。CMD_RANDOM (1ULL<<7)
对应random,有的命令即使在相同的情况下的运行结果也是不确定的,诸如SPOP、RANDOMKEY。CMD_SORT_FOR_SCRIPT (1ULL<<8)
对应to-sort,需要对输出序列进行排序。CMD_LOADING (1ULL<<9)
在服务器启动载入过程中可以执行的命令。如果没标记该项目的命令,启动过程中不能执行。CMD_STALE (1ULL<<10)
CMD_SKIP_MONITOR (1ULL<<11)
no-monitor,不自动将这个命令propagate到MONITOR。CMD_SKIP_SLOWLOG (1ULL<<12)
no-slowlog,不自动将这个命令propagate到slowlog。比如EXEC、AUTH之类的命。CMD_ASKING (1ULL<<13)
CMD_FAST (1ULL<<14)
这个命令是O(1)或者O(log(N))复杂度的,他们不会延误执行。注意所有可能导致DEL操作的并不是FAST命令,例如SET。CMD_NO_AUTH (1ULL<<15)
Demo
我们结合一个具体的定义来了解这个结构:
1 | // server.c |
命令的处理顺序
- call
- processCommand
- processCommandAndResetClient
- processInputBuffer
- readQueryFromClient
- handleClientsWithPendingReadsUsingThreads
- handleClientsWithPendingReadsUsingThreads
- stopThreadedIO
- beforeSleep
- processInputBuffer
- processCommandAndResetClient
- processCommand
processCommand
这个函数很复杂:
- 通过call执行命令
- 准备从客户端进行一次读取
返回C_OK
表示这个客户端还存在,否则表示这个客户端没了。
首先需要特别处理quit命令。
1 | int processCommand(client *c) { |
下面通过lookupCommand
查找对应的命令结构,并处理找不到或者命令格式错误的情况。
1 | /* Now lookup the command and check ASAP about trivial error conditions |
判断命令的性质,是只读的,还是可写的等性质。
1 | int is_write_command = (c->cmd->flags & CMD_WRITE) || |
进行auth和ACL检查。
auth也就是登录状态检查。
ACL,即Access Control List,有一系列条件规则组成,用来具体控制某些用户是否可以运行某些命令。
1 | ... |
如果启用了Redis Cluster,就要进行转发。
1 | ... |
处理oom相关行为
1 | ... |
下面才是真正的执行,对于非multi,会调用call
1 | ... |
call
call
就是调用指令的函数,有一系列的flag:
CMD_CALL_NONE
CMD_CALL_SLOWLOG
检查指令执行的速度,是否记录到slow log中呢?CMD_CALL_STATS
Populate command stats.CMD_CALL_PROPAGATE_AOF
如果对数据有改动(可以通过server.dirty
字段看出),或者client有一个强迫propagate的CLIENT_FORCE_AOF
,就加到AOF上。
相应的,如果client设置了CLIENT_PREVENT_AOF_PROP
,那么即使数据集变动了,也不会写AOF。
注意,无论client设置了什么,如果没有CMD_CALL_PROPAGATE_AOF
,那么永远不会写AOF。CMD_CALL_PROPAGATE_REPL
同理,但是对Slave。同样有CLIENT_FORCE_REPL
/CLIENT_PREVENT_REPL_PROP
。CMD_CALL_PROPAGATE
相当于PROPAGATE_AOF|PROPAGATE_REPL
CMD_CALL_FULL
相当于SLOWLOG|STATS|PROPAGATE
call
主要就是用c->cmd->proc(c)
执行命令,后者实际上就是xxxCommand()
这样的命令。
1 | void call(client *c, int flags) { |
fixed_time_expire
在expire机制中见到过的,如果有命令在执行过程中,这个值就不是0。
还会把除了ADMIN之外的命令发送给MONITOR,ADMIN命令展示出来太危险了。
1 | ... |
下面是一些初始化和执行工作。
1 | ... |
在执行后,统计数据库被修改的次数dirty
。在《Redis底层对象实现原理分析》中看到,比如我新加一个元素,或者修改一个元素,都会导致dirty
增加。也就对应了数据的改变。
1 | ... |
记录延迟信息,并记录slowlog。其中latencyAddSampleIfNeeded
在适当的时候调用latencyAddSample
。
1 | ... |
下面处理propagate的情况,这个对应了CALL_
开头的一些规则,就不详解了。最终会计算得到一个propagate_flags
传给propagate
。
1 | ... |
在结束之后,我们需要还原一下有关propagate的相关flag,因为call
可能被递归调用。
【Q】我觉得这里一个典型的例子就是这里的multi、exec。
1 | /* Restore the old replication flags, since call() can be executed |
alsoPropagate
函数可以往server.also_propagate
里面加一些其他的op。下面就处理alsoPropagate
的逻辑,也就是当propagate完当前的命令之后,还可以再去propagate一些命令。并且这些命令不被CLIENT_PREVENT_PROP
影响。
1 | if (server.also_propagate.numops) { |
如果说已经被包在了MULTI里面,就不在继续包在also_propagate
里面propagate了。execCommandPropagateMulti
实际上就是下面的propagate调用。这里的shared.multi
或者shared.exec
实际上是缓存的字符串对象EXEC
和MULTI
,减少频繁的内存分配的作用。
1 | void execCommandPropagateMulti(client *c) { |
接下来就是做propagate。
1 | ... |
这个应该是和客户端缓存有关的,如果client提供了keys tracking功能,要通知。这个函数里面维护了一个tracking invalidation表,这样客户端会收到一个invalidation信息。
1 | /* If the client has keys tracking enabled for client side caching, |
删除逻辑实现
为了理解下面论述中涉及到的expire相关实现,我们需要先介绍一些UNLINK
和DEL
的实现。
delGenericCommand的实现是比较Legacy的,从c->argv
中读取所有需要被删除的key,然后调用dbAsyncDelete或者dbSyncDelete。
1 | /* This command implements DEL and LAZYDEL. */ |
容易发现,删除有两种模式:异步(lazy)删除和同步删除。异步删除的情况包括:
- delete逻辑
delGenericCommand
中传入lazy
如果是unlink命令,那么一定是异步删除。
如果是del命令,则取决于server.lazyfree_lazy_user_del
。dbDelete
中设置了server.lazyfree_lazy_server_del
- expire逻辑
expireIfNeeded
中如果设置server.lazyfree_lazy_expire
,则使用异步删除
对应了Redis的lazy过期策略。activeExpireCycleTryExpire
中如果设置server.lazyfree_lazy_expire
,则使用异步删除
对应着Redis的定期循环,主动过期策略。expireGenericCommand
中如果设置server.lazyfree_lazy_expire
,则使用异步删除
直接运行expire命令,主动检查一下有没有过期。
- evict逻辑
freeMemoryIfNeeded
中如果设置server.lazyfree_lazy_eviction
,则使用异步删除
- 其他
RM_UnlinkKey
这些lazyfree_lazy_
开头的配置,默认都是0。也就是说这些情况下默认都是同步删除。
同步删除的情况类似,除了“其他”中发生了变化:
- 其他
rdbLoadRio
下面的代码会进行事件通知,我们将专门进行介绍
1 | // 【续】delGenericCommand函数 |
del和unlink的唯一区别是,unlink一定是lazy删除的,但是del取决于配置lazyfree_lazy_user_del
。
1 | void delCommand(client *c) { |
同步删除
看简单的同步实现。
首先,如果db->expires
非空,从db->expires
里面删除key
,实际上是删除的过期时间。
这里有个注释,说从db->expires
中删除一个entry不会释放key->ptr
这个sds,因为它和db->dict
是共享的。这里应该说的是在setExpire
里面往db->expires
添加key的时候,加的实际上是指向db->dict
中的指针。
但果真是这样的么?继续看dictDelete
最终调用dictGenericDelete
。查看dictDelete
实际上是dictGenericDelete
的实现(在“dict的其他相关方法”这个章节中介绍),发现新版本的代码肯定会调用dictFreeKey
(Redis3.0里面有个dictFreeEntryKey
,不要混淆了)。
1 | /* Delete a key, value, and associated expiration entry if any, from the DB */ |
检查dictFreeKey的实现发现,这个函数调用keyDestructor
,它似乎一定会导致对应sds的析构。看上去和上面的注释是矛盾的。
1 | // dict.h |
究竟是怎么回事呢?我们看下keyptrDictType和dbDictType这两个dict类型就有了答案。原来对于db->expires
,它实际的类型就没有设置keyDestructor,所以不会析构key。
1 | // server.c |
可以说一下#define DICT_NOTUSED(V) ((void) V)
是经典的关闭编译器unused variable warning的办法。
1 | // server.c |
下面接着看dbSyncDelete
的逻辑,刚才是删除的db->expires
,还需要删除db->dict
。
此外server.cluster_enabled
的情况进行了额外的处理。
1 | // 再从db->dict里面删除key |
异步删除
异步删除的核心是调用dictUnlink
而不是dictDelete
。
前面的是大差不差的,删除db->expires
里面的字段,因为他们的dictType不一样,他们的析构行为(keyDestructor
)也不一样。这就导致expire可以直接dictDelete。
1 | // lazyfree.c |
下面调用dictUnlink
而不是dictDelete
了。这里注意区别一下dictUnlink
和前面提到的UNLINK命令。dictUnlink
的作用是将对应的key从dict中删除,但不会释放对应的结构,而是直接返回。
1 | // 续dbAsyncDelete |
拿到这个de
,我们手动来析构。会首先使用lazyfreeGetFreeEffort
来计算析构的代价,如果代价过高,就将这个对象放到lazy free list里面让它后台去析构。不然的话就在后面的代码中同步析构,这是因为如果对象很小,那么再搞这一套异步反而更耗时。
1 | // 续dbAsyncDelete |
先来看lazy的实现,如果算出来值得,那么就lazy。但这里还有个特殊情况我们不能异步删除,根据注释,如果这个对象是被共享的(val->refcount
就是一个大于1的值),我们不能就直接把它现在就回收掉。这个倒不经常发生,但确实Redis的一些实现代码会用incrRefCount
来保护对象,然后调用dbDelete
。在这种情况下我们会fall through到下面dictFreeUnlinkedEntry
的调用,它的最终效果相当于直接调用decrRefCount
。
经过了上述的判断,我们就可以使用bioCreateBackgroundJob来异步删除了。
1 | // 续dbAsyncDelete |
下面就是同步删除的实现。dictFreeUnlinkedEntry
这一块就是给之前nofree
没做的事情擦一下屁股,包含调用dictFreeKey
啥的来释放key和value所占用的内存。slotToKeyDel
这个是Redis Cluster的实现逻辑,用来算出来这个key在哪个slot上。
1 | // 续dbAsyncDelete |
这里面涉及的几个函数来讲解一下
lazyfreeGetFreeEffort
这个函数计算并返回释放一个对象的代价。返回值不一定是这个对象对应的内存分配次数,但是和这个量成比例的。具体来说:
- 对于字符串,函数永远返回1。
- 对于用诸如哈希表等数据结构表示的聚合对象,返回组成该对象元素的数量。
- 对于只需要一次内存分配就产生的对象,认为是独立的一个对象,即使实际上是由多个造成的。
- 对于列表对象,返回quicklist里面的元素数量。
1 | size_t lazyfreeGetFreeEffort(robj *obj) { |
对于ZSET,如果是跳表实现,就返回跳表的长度。如果是ziplist实现就返回1?
1 | } else if (obj->type == OBJ_ZSET && obj->encoding == OBJ_ENCODING_SKIPLIST){ |
atomicIncr
是一个原子操作,更新lazyfree里面的一个static变量lazyfree_objects
。根据不同的操作系统的支持,有三种实现:
如果支持atomic语义
1 |
如果有sync语义,一般是gcc的一个内置宏
1 |
如果什么都没有,用mutex,mutex的名字是变量名加上_mutex
,这些mutex随着变量名一起被定义,只是可能不会被用到,如lazyfree_objects_mutex。
1 |
|
bioCreateBackgroundJob
所有的bio开头的函数表示Redis的Background IO服务。根据注释,将来也许会迁移到libeio。
1 | void bioCreateBackgroundJob(int type, void *arg1, void *arg2, void *arg3) { |
dictSetVal
见dict相关
slotToKeyDel
见 Redis Cluster 相关
查找
lookUpKey相关方法根据查找目的是读或者写区分了lookupKeyRead
、lookupKeyWrite
两个方向的函数,此外还根据是否WithFlags
或者OrReply
派生出其他几种函数。
对于lookupKeyWrite
来讲,有一个副作用,就是会先检查一下要不要expire,如果需要就直接expire掉。
对于lookupKeyRead
来讲,也要处理expire的问题,但是因为涉及到主从复制的问题,所以要进行额外处理。【Q】为什么不需要对写处理呢?我想应该是因为只有Master处理写,处理完再发指令给Slave。
直接介绍带Flags的版本。
1 | robj *lookupKeyRead(redisDb *db, robj *key) { |
WithFlags
目前只包含了LOOKUP_NONE
和LOOKUP_NOTOUCH
两个选项。:
LOOKUP_NONE
LOOKUP_NOTOUCH
表示这次访问不要更新LRU啥的,例如type这样的命令就带上这个参数。
lookupKeyReadWithFlags
下面查看lookupKeyReadWithFlags
的实现,相比于写要复杂点,因为要处理键过期的时候读的问题。
1 | robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) { |
Master的masterhost
肯定是NULL,这是一个经典判定。首先考虑Master的情况,如果key过期了,那么就直接安全地返回NULL,并且触发一个keymiss事件。这里注释上说在Master情况下,expireIfNeeded
返回0当且只当这个key不存在。
为什么强调Master呢,实际上可以结合expireIfNeeded
的实现来看。提前说一下,对Slave而言,expireIfNeeded
并不会真的让key过期并删除,而只是返回key在逻辑上是过期的,而真正的过期是由Master来同步的,其目的是保持Slave和Master的一致。
1 | ... |
更新统计信息,server.stat_keyspace_misses
可以通过INFO keyspace_misses
命令来查看。
1 | ... |
上面是对Master情况的处理,下面是对Slave的情况。我们已经知道,Slave并不会真的删除过期key,而是等待Master的Del指令。所以即使expireIfNeeded
返回1表示过期,
但根据注释,对Slave而言,作为一个额外的安全措施,如果相关指令是只读的,还是可以在这里安全地返回NULL。Redis的说法是:对于只读命令,这样可以向client提供一个更加一致性的行为。这个会包含GETS,当使用Slave来扩容读的时候。我的理解就是尽管slave上还没有删除,但是过期就是过期,我们要和Master一致。
1 | ... |
lookupKeyWriteWithFlags
首先查看lookupKeyWriteWithFlags
的实现,直接先检查下expire,然后调用lookupKey
。这里的expireIfNeeded
也是Redis的lazy过期策略的实现,在每次查找的时候都会调用,检查这个键是不是已经过期了。
不同于lookupKeyReadWithFlags
,这里就不会统计keymiss啥的了。
1 | // db.c |
lookUpKey
lookUpKey
的主要内容包括从db里面找到对应的key,并且维护LRU或LFU。它是一个较为底层的 API。
1 | // db.c |
下面是LRU和LFU的实现,更新每个key的访问情况,从而方便后续evict。详细见有关updateLFU的实现见”Redis的LRU和LFU实现”这一章节。
但先要做一些判断:
- 如果设置了
LOOKUP_NOTOUCH
。 - 如果有子进程正在进行保存,就不进行LFU操作,以免破坏COW。
1 | ... |
expire
如何判断键已过期?
诸如EXPIRE
/RENAME
等的实现中会调用setExpire
函数设置过期时间。setExpire
会把每个键的过期时间都被存在db->expires
这个字典里面。
通过getExpire
可以从字典中读取到过期时间。
getExpire
1 | /* Return the expire time of the specified key, or -1 if no expire |
keyIsExpired
keyIsExpired作用是判断某个键有没有过期。主要功能就是比较现在的时间,和获得的key的过期时间。被expireIfNeeded调用,
1 | /* Check if the key is expired. */ |
下面一段代码的目的是:如果在执行lua脚本,将时间设置成脚本执行开始的时间,这样在脚本执行过程中就不会expire。这么做的原因是源自Github上面的Issue1525。作者发现这个脚本在Master和Slave上的执行是不一样的。原因是在Master上第一次执行可能key存在,第二次就不存在了。这导致incr
实际只被执行了一次。但是因为此时Master会合成一个DEL指令,让Slave也删除并过期这个Key。此时,如果相同的脚本运行在Slave上面,那么incr
一次也不会被执行。
1 | if redis.call("exists",KEYS[1]) == 1 |
为了保障向Slave和AOF的propagate是一致的,首先在执行lua脚本的时候,要禁止expire(就是这里的行为);但是在执行脚本之前,先要对涉及的key做下expireIfNeeded
。
1 | ... |
expireIfNeeded
expireIfNeeded
用来删除过期的键,它是被动expire的关键步骤。返回0表示键有效(键未过期,或永不过期),否则返回1表示已经过期并被删除。
对于Master,如果找到的键是expire的,会被从数据库中evict掉。并且会导致想AOF和Slave流propagate一条DEL或者UNLINK指令。
1 | // db.c |
如果没有过期,就返回0
1 | ... |
首先,通过keyIsExpired
检测是不是已经过期了,如果还没有过期,上面就直接返回0了,再往下就是处理过期的情况。
根据注释,如果Redis运行在主从模式下,并且是在Slave上,expireIfNeeded
直接返回,而不是继续删除键。这是因为Slave上的key过期是由Master控制的,Slave并不直接处理key的过期。Master会发送一个同步的DEL
命令给Slave来删除某个键,Slave等到那时候再删除,这样做的目的是出于一致性的考量。
但尽管如此,对Slave调用expireIfNeeded
也应该返回一个正确的值,也就是这个时候键应不应该过期。因此,Slave上是先过期,然后再删除键的,这其中存在一个窗口时间,因为Slave还没有来得及收到并处理Master的DEL
。
下面肯定对应了已经过期的情况了。
1 | ... |
下面负责通知删除事件,这里还出现了propagateExpire
函数,我们也统一在后面讲解
1 | ... |
下面是真正的过期删除的过程。这里根据server.lazyfree_lazy_expire
的配置,可以选择异步删除或者同步删除,这类似于上面讨论过的UNLINK
和DEL
的实现。事实上在expireGenericCommand
上就可以看到对应的映射关系。
1 | ... |
主动expire实现
在databasesCron
可以看到,如果开启了主动expire,并且自己是master,则会定时运行activeExpireCycle。
介绍参数:active_expire_effort
默认值为1,表示避免有超过10%
的过期key,同时CPU占用不超过25%。config_keys_per_loop
表示
1 | void activeExpireCycle(int type) { |
下面是几个全局变量:
- timelimit_exit表示是否已经超时了。
1
2
3
4
5
6
7
8
9
10
11...
/* This function has some global state in order to continue the work
* incrementally across calls. */
static unsigned int current_db = 0; /* Last DB tested. */
static int timelimit_exit = 0; /* Time limit hit in previous call? */
static long long last_fast_cycle = 0; /* When last fast cycle ran. */
int j, iteration = 0;
int dbs_per_call = CRON_DBS_PER_CALL;
long long start = ustime(), timelimit, elapsed;
...
如果所有的clients停止了,那么我们的主动expire循环也要停止,从而保持数据库是静态的。没搞懂为啥这么设计。
1 | ... |
在这里,Redis的主动过期策略分为了fast和slow两个模式。第一种在key比较少的情况下尝试是用较少的cpu,一旦这些过期的键的数量小于某个给定值,就退出。第二种更激进一点,以减少内存占用。
1 | ... |
每次扫多少db呢?默认dbs_per_call
为CRON_DBS_PER_CALL,即16:
dbs_per_call
不能超过总的db数。- 如果
timelimit_exit
,需要扫描全部db
我的理解是如果上次active expire都超时了,说明肯定有很多expire key等待清理,我们全部做一遍,以免占用太多内存。
1 | ... |
在这里通过计算耗时,来限制active expire循环对CPU的占用。默认CPU限制是ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC。我们最多在这个函数中只能用timelimit这么多微秒。server.hz
指的是表示一秒钟被触发多少次,config_cycle_slow_time_perc
是个CPU的百分比,也就是每次迭代中只能用config_cycle_slow_time_perc/100
这么久。因为每次迭代的耗时是1/server.hz
秒,即1000000/server.hz
微秒。
1 | ... |
外层的循环,遍历所有的数据库。如果timelimit_exit
为1,说明内层循环中已经发现执行超时了,外层循坏也退出。
1 | ... |
内层的循环,如果每次循环结束,还是有很高的没有处理的过期的key,就需要继续做。但我们也不能一直这么做下去,所以每过16次,就会检查是否超过timelimit。如果是的话,就设置timelimit_exit为1,然后退出当前循环。
1 | ... |
propagateExpire
在前面的代码中,还看到propagateExpire
的使用。我们知道,在主从结构下,键实际的expire操作是在Master完成的。在expire之后,Master会发送DEL指令给Slave和AOF,也就是这个函数。
在注释中还指出,因为AOF,以及Master到Slave的连接都是保证有序的,所以即使有操作去写已经失效的key,都能保证结果是一致的。
1 | void propagateExpire(redisDb *db, robj *key, int lazy) { |
propagate机制
在 expire 中,提到了 propagate
函数,因此这里也顺便介绍一些 propagate 机制。
propagate 机制是 Redis 主从复制逻辑的一部分。通常来说,Redis主从复制包含两个机制:
- sync/psync
用来处理 sync 和 psync 指令,也就是刚开始来个全量同步,将 Slave 的状态更新至 Master 当前状态。 - propagate
将指令从 Master 同步到 Slave 或者 AOF 文件。
propagate 机制将特定的指令传播给 AOF 或者 Slave,这些指令有下面几种:
PROPAGATE_NONE
压根就不传播。PROPAGATE_AOF
如果开启了 AOF,就传播给 AOF。此时就会调用 AOF 的主入口函数feedAppendOnlyFile
。RDB 和 AOF 机制在专门的文章介绍。PROPAGATE_REPL
传播给 Slave。同样调用replicationFeedSlaves
函数。
根据注释,不能够在各个 command 的实现代码中使用这个函数,因为它不会 wrap the resulting commands in MULTI/EXEC,如果需要,应该用 alsoPropagate
、preventCommandPropagation
、forceCommandPropagation
等。
However for functions that need to (also) propagate out of the context of a command execution, for example when serving a blocked client, you want to use propagate().
1 | void propagate(struct redisCommand *cmd, int dbid, robj **argv, int argc, |
evict实现
介绍
Redis对当前执行环节的判断
server.masterhost == NULL
常常被用来判断是不是 Master 服务器server.current_client != server.master
根据注释,这是指的服务器的当前客户端,仅用于崩溃报告。sentinelRedisInstance->flags & (SRI_MASTER|SRI_SLAVE)
sentinelRedisInstance->slave_master_host
大家都知道,Redis里面有下面几种evict policy:
- noeviction
这是默认情况。
内存爆了,就直接报错。 - allkeys-lru
对所有的键做LRU。 - allkeys-lfu
对所有的键做LFU。 - allkeys-random
对所有的key做随机删除。 - volatile-lru/volatile-lfu/volatile-random
这是对有expire的键做对应的操作。 - volatile-ttl
删除剩余生命最短的键。
而对应的实现,就在freeMemoryIfNeeded
中。根据注释,这个函数被定时调用,当发现超出最大使用内存后,就会释放相关内存。如果释放内存成功,或者我们不需要释放内存,那么返回C_OK
;如果我们没有能够释放足够的内存,那么返回C_ERR
。总之一堆废话。。。其实想了解的是这几个问题:
- 如何计算现在已经使用了多少内存?
- 如何实现LFU和LRU?
- 释放内存会对其他模块产生什么影响?
LRU和LFU的一般实现及优缺点讨论
LRU
对于 LRU,一个队列就行了,把最近用到的元素放到队列尾部,需要 evict 的时候就弹出头部,一般用双向队列就行。但这样查找一个 Key 就变成 O(n)
的了,但这也不难,只需要用一个 map 记录一下对应元素在队列中的位置就行。也就是说,用hash+双向链表来维护。hash 用来实现 O(1)
查询,双向链表用来维护顺序。
Redis并没有采用这个办法来维护一个LRU,显然内存开销很大,这是值得的么?Redis 有专门的文章中讨论这个事情。他们的结论是当 maxmemory-samples
数为10的时候,也就是随机选10个里面去掉一个,这样的近似 LRU 算法的性能已经很好了。
因此,Redis 实际上是记录了最后一次访问某个 key 的时间戳的。当然这倒不是因为复用 LFU 的空间,毕竟 LRU 是先有的。此外,还有一个 evictionPoolEntry
。这个 pool 的容量是16,里面的 key 是按照 LRU 有序排列的。
Redis 构造了一个负载。顺序地从头到尾访问,此时第一个 key 就是理想 LRU 的 candidate。然后再加入 50% 的 key,从而让 50% 的老 key 被 eveit 掉。比对四种算法。图中浅灰色点为被 evict 的 key,灰色点为没有被 evict 掉的 key,绿色点是新增加的 key。
- 理论上的 LRU 中,前一半的 old key 全部被 evict 掉了。后一半的没有被 evict 掉,保持深灰色。
- Redis 3.0 因为使用了 pool,所以比 2.8 好一些。
LFU
实现 LFU 时需要记录对应的访问次数。在淘汰时选择最少访问次数的键值对。此时队列的性质就不够用了,但可以考虑下面的方案
- 用优先队列,把访问次数作为 key,大不了手动实现一个二叉堆嘛。
- 用一个双层链表,第一层是从0开始的访问次数,第二层是具有这个访问次数的所有键值对的开链表。为了节约空间,第一层可以是哈希表的形式。
Redis的LRU和LFU实现
本章介绍了 Redis 对 LRU 和 LFU 数据结构的维护,这是必要的前置知识。
这一部分实现在先前介绍过的 lookupKey
函数中。Redis 对每个 robj
对象去维护了一个 lru:LRU_BITS
字段。在3.0版本,这个字段被用来存储当前秒级别的时间戳,服务于 LRU,后续版本还会服务 LFU。具体选择 LRU 还是 LFU 是根据 server.maxmemory_policy
来定的。
1 | // In lookUpKey |
真正的 evict 的时刻是 freeMemoryIfNeeded
函数。
维护了的LRU或者是LFU在evictionPoolPopulate
中起作用,会分别根据estimateObjectIdleTime
和255-LFUDecrAndReturn(e)
进行排序。
LRU
LRU_CLOCK
这里会选择是直接用server.lruclock
(也是在serverCron
里面调用getLRUClock
设置的),或者直接自己调用一次getLRUClock
。这个比较是怎么来的呢?有必要介绍一下,毕竟诸如run_with_period
里面也有这样的比较。
首先,在文章中已经介绍过,server.hz
指的是表示一秒钟被触发多少次。那么1000/server.hz
就表示触发1次要多少毫秒。LRU_CLOCK_RESOLUTION
的默认值是1000,表示时钟精度是1000毫秒调用一次。所以只要LRU的精度小于server调用的精度,就可以复用server.lruclock,从而少调用一次getLRUClock。
【Q】岂不是大多数情况下都可以复用server的时钟?毕竟hz不会为0.5啊。
1 | // server.h |
LFU
在访问一个对象的时候,用 updateLFU
更新 lru
字段。这个函数会在高16位存一个分钟级别的时间戳 ldt,在低8位存访问计数 counter。这两个值被存放在一个字段中完全是为了节省空间和复用字段,其组合后的值整体上没有排序意义。
执行 LFU 策略时更新 lru
字段需要注意两点,即要根据时间衰减,但也要根据访问次数增长:
- 首先,通过
LFUDecrAndReturn
,减少counter
。
减少的值与当前时间和记录的 ldt 的差值有关。也就是隔得时间越长,减的越多。 - 然后,通过
LFULogIncr
以一定概率增加 counter。 - 最后,将最新的 counter 和 ldt 重新组装起来存入
lru
中。
1 | // db.c |
衰减counter
LFUDecrAndReturn
返回 counter
,和当前对象的 frequency 正相关。这个函数在返回前会先根据当前访问时刻和上次记录的 ldt 的差值来减少 counter 的值。counter
具体减少多少由 server.lfu_decay_time
决定。它是个衰变因子,默认是1,这时候对 counter
的减少就是经过的分钟数。如果将它设为更大的值,则 counter 减少的量会变少。它还有个特殊值0,表示 counter 只能加不能减。
每次衰减会对 counter 减去 num_periods
,知道减少到0为止。
1 | // evict.c |
简单介绍 LFUTimeElapsed
,用来计算从 ldt
开始经过了多少分钟。
1 | // 获得从ldt开始经过了多少分钟 |
【Q】其实我觉得这里有个优化点,可能每次访问的时候 num_periods 都是 0.9 这样的数,结果导致每次都不衰减。我想对于这种情况,最好就不要更新 ldt,让它和下一次的攒起来一起算。
增加counter
介绍 counter
随访问次数的增长 LFULogIncr
。
每次访问都需要增加访问计数,但未必每次都自增,而是随机出一个 r
,当它小于阈值 p
后才会自增 counter
。
其中p
的值是1.0/(baseval*lfu_log_factor+1)
,其中 baseval
为max(0,counter-LFU_INIT_VAL)
。
可以发现 p 是在 0 和 1 之间的。但进一步讨论下 baseval 和 p 的关系:
- baseval=0,p=1
- baseval=1,p=0.09
- baseval=10,p=0.009
可以看到:
counter
越大,counter
的自增概率就越小。lfu_log_factor
越大,counter
的自增概率就越小
其实counter
的增长和实际的访问次数是成对数关系的。如果我们需要存储很高的访问频次,可以设置更大的lfu_log_factor
。
这样的设计使得区区 8 bits 足够存储很大的命中次数。
在更新版本的redis.conf中,列出了不同 lfu_log_factor
取值下,若干次 hit 之后,counter 增加的数量。
因为r是随机取的,所以可能用数学计算挺困难的。
1 | +--------+------------+------------+------------+------------+------------+ |
还需要特别介绍下 LFU_INIT_VAL
,每个对象在初始化时,对应的 counter 是 LFU_INIT_VAL
即 5。没有这个,新生对象就会在 LFUDecrAndReturn
的时候,因为 counter 很小被很快淘汰掉。
当然,在 LFULogIncr
时需要将它还原回实际的值来计算 p。
1 | // server.h |
evictionPoolEntry和evictionPoolPopulate
来看 evictionPoolPopulate
这个函数,它作用是往 evictionPool
里面加一些 evictionPoolEntry
。一个 evictionPoolEntry
表示数据库中的某个 key。一系列 evictionPoolEntry
组成一个 evictionPool
。在 evictionPool
中的 entry 都是按照 idle
排序的,从小到大升序排列,最左边的 idle time 最小。因此 evict 的时候可以只看最左边的 pool。
1 |
|
idle
表示每个对象的空闲时间。pool 里面只能加入具有更大 idle time 的键。如果还有空余空间,就始终加入。cached
这是一个有趣的优化。
如果 key 的长度比较小,它就会被存在预分配好空间的 cached 结构中,从而避免在 key 中分配空间的开销。dbid
表示这个键所属的数据库。
如何根据 LRU 或者 LFU 计算 idle 呢?
如果采用LRU
调用estimateObjectIdleTime
函数计算,实际上就是乘以一个LRU_CLOCK_RESOLUTION
。这里实现上还处理了一下回绕 wrap 的情况。1
2
3
4
5
6
7
8
9unsigned long long estimateObjectIdleTime(robj *o) {
unsigned long long lruclock = LRU_CLOCK();
if (lruclock >= o->lru) {
return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
} else {
return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
LRU_CLOCK_RESOLUTION;
}
}如果采用LFU
这里要反向一下,就是用255减一下LFUDecrAndReturn(o)
。因为idle和访问频率是相反的。
下面是 evictionPoolPopulate
的具体实现。
输入参数:
sampledict
表示从哪个 dict 里面进行采样,根据策略不同,可能是 dict(allkeys 策略) 或者 expire(volatile 策略)。keydict
只能是对应的 dict。因为 expire 里面只是存一个”引用”。
1 | void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) { |
dictGetSomeKeys
函数从 dict 里面任意取出若干个entry。server.maxmemory_samples
默认被设置成5个,对应于前几章的论述,后面应该会改成10个吧。在取出这些 entry 到 samples
后,挨个尝试将它们插入到 pool 中。
1 | ... |
下面的一段代码计算这个对象的 idle
时间。具体的策略在前面讲过了,但首先需要考虑当前的 dict 可能是 expire,这样就要回 dict 表再查一次。得到对应的 keydict
中的 entry 即 de
,以及 key 对应的 val 即 o
:
- 回表的情况
显然,只要sampledict
不等于keydict
就需要回表。因为此时sampledict
肯定是 expire。 - 不回表的情况就可以直接取 val
- 特殊情况:volatile-ttl 策略
前面两种情况都是用的keydict
中对应的 entry、key 和 val,但这里直接用sampledict
。
1 | ... |
上面计算出了当前 key 对应的 idle
时间。接下来将元素插入到 pool 中。这是一个类似插入排序的过程。
首先,找到第一个空 bucket,或者找到第一个 idle <= pool[k].idle
,可以插到它前面。
下面的循环能够跳过所有不满足以上条件的情况。【Q】这里能直接二分么?
1 | ... |
下面处理两种特殊情况:
- 我们在处理最左边的桶,此时待插入 key 的
idle
比 pool 里面所有的 idle 都要小,但没有空余的格子了。
这说明这个 entry 非常新,我们没必要将它插入到 pool 中,所以直接诶 continue 掉。 - bucket 完全空的情况,可以直接用最左边的格子。
1 | ... |
注意,key
和 cache
有点类似于自引用结构的关系,但其实不是。因为 key 和 cached 实际上都是 sds,也就是个 char*
。移动或者复制 sds,只是移动指针,并没有改变指向的内容。所以只需要保证只要它不释放就行。
当然,发散一下,如果 key
是一个指向 cached
的 sds*
,那就真的是自引用结构了。但也并不需要绑定 key
和它可能指向的 cached
在一个结构中。因为如下所示,它们可以是一一对应的
1 | | key 1 | key 2 | key 3 | nul k | |
接下来看最一般的情况。此时 k
是第一个满足 idle <= pool[k].idle
,我们的新 entry 应该插入在 k
之前。下面就插入排序,把待插入的 de
插入,然后把原来 k
以及之后的数字往右边移动。这里使用了 memmove,它能自动检测 src 内存和 dest 内存重叠的情况并处理,是更安全的 memcpy。
又可以细分为两种情况:
- 最右边还有空位,将
[k,)
整体右移一格,新 entry 预计插入k
处
尽管这时候最右边的 cached 是空,但还是要备份。否则就会泄露掉那一块内存。 - 最右边没有空位,将整个数组右移一格,新entry预计插入在
k-1
处
此时最左边是idle最小的,将它从pool里面去掉,换成idle更大的。
但同时,也要保证最左边的cached不被意外释放。
1 | ... |
下面,将新entry插入pool中。这里说明下cached的使用:
- 如果
key
的长度大于EVPOOL_CACHED_SDS_SIZE
则复制key
到pool[k].key
- 如果
key
的长度较小,就可以尝试做优化,将它放在cached
中,然后让把cache
赋值给key
,从而避免复制底层的字符串。
1 | ... |
dictGetSomeKeys
dictGetSomeKeys
这个函数,是对一个dict来说的,而不是对db来说的。
它不保证一定返回正好count个,也不保证返回的元素都不重复。返回值被存到des
里面,需要保证这个数组至少能容纳count
个。
取出来的指针存在des
中返回。des
必须预分配至少count
个空间,尽管函数可能未必能取到count
个。其原因可能是本来就没那么多个,或者我们经过多轮迭代没添加完。
1 | /* This function samples the dictionary to return a few keys from random |
首先,执行一点渐进式rehash。然后将maxsizemask
设置为所有ht(没有rehash是1个,有是2个)的最大容量。
将i
设置为随机一个位置。
1 | ... |
下面进入主循环,循环条件有两个,一个是取满count个,一个是执行最多maxsteps=count*10
次。
在每一次迭代中,对所有的ht(1或2个)进行处理。
1 | ... |
涉及到rehashidx相关的逻辑,表示在每次进入dictRehash
函数的时候,首先ht[0].table[rehashidx]
这个桶。如果现在在rehash过程中,到d->rehashidx
为止的所有index都已经被访问过了。实际上这些桶里面都空(not populated)了,因此我们可以跳过ht[0]
里面$[0,idx-1]$这个区间的关卡,直接去看ht[1]
里面的。这其实是一个优化,在dictRehash
实现中,也有对空桶跳过的优化。
特别地,如果i
在ht[1]
里面也已经超了,这就表示截止到rehashidx
两个表里面都没有了。【Q】为什么可以认为ht[1]
中的rehashidx
之前的也不需要判定了呢?或者说,为啥两个ht可以共享一个i
呢?
1 | ... |
在主循环结束后,会自增i
的值。
1 | ... |
主逻辑freeMemoryIfNeeded
freeMemoryIfNeeded
函数是evict的主要逻辑。
首先,如果是从服务器,并且配置了server.repl_slave_ignore_maxmemory
就忽略。
1 | int freeMemoryIfNeeded(void) { |
下面就来计算占用了多少内存mem_reported
,主要函数getMaxmemoryState
我们放在后面单独讲解。mem_reported
表示总共用了多少内存,mem_tofree
表示应该释放多少内存(不算Slave和AOF的缓存)。clientsArePaused
的检查,有点奇怪。根据注释,它的意思是,如果client都被pause了,那么数据就是静止的。不仅对于所有的client是这样,对于还没有做expire和evict的所有key也是这样。我觉得这应该是一个优化,防止在这种情况下再走下面的逻辑。
1 | ... |
latencyStartMonitor
这个宏和stopwatch一样。
1 | ... |
下面开始根据淘汰政策maxmemory_policy
进行讨论,如果是noeviction,那就直接返回。
1 | ... |
整个内存释放过程是多次的,因此用一个循环来。
1 | ... |
处理要排序的情况
第一个 if 用来处理所有需要排序的情况。查看代码,要用 while 循环去找 bestkey
,原因是可能从 pool 里面找到的 key 不存在了,【Q】可是究竟什么情况下会发生这个情况呢?
循环里面的过程就是我们去遍历整个数据库里面的所有db,如果它的dict
或者expires
不为空,则调用evictionPoolPopulate
。这个函数会往pool里面加入一些key。
1 | ... |
下面遍历整个 pool,找到最合适的一个。解释几个问题:
- 为什么要从尾往头遍历?
在对evictionPool的介绍中提到,它是有序的,最左边的idle time最小,最右边的最大,因此优先淘汰右边的。 - 为什么要有bestdbid?将key之间的比较转化为数据库之间的比较么?
server.db[pool[k].dbid]
是什么鬼?
实际上是要选择pool[k].dbid
这个db。
1 | ... |
处理随机情况
第二个if,用来处理随机的情况。这个很简单,直接调用dictGetRandomKey
就行,和eviction pool也没啥关系了。
1 | ... |
删除元素
如果我们找到了要删除的元素bestkey
,就执行删除元素过程。
首先,调用老朋友propagateExpire
,这个会发送一条删除指令给AOF/Slave。
1 | ... |
接着,我们统计这次evict释放了多少内存,就是首尾两个zmalloc_used_memory
相减。这个有点粗略了,就在刚才我们还将AOF/Slave缓存单独拿出来算的呢,现在直接总内存相减了。在注释中还提到,有可能用来propagateExpire
的内存比我们释放的db内存还多呢,但我们是管不了的,否则mem_freed < mem_tofree
这个循环条件永远达不到了。并且,这些缓存终究会被释放的。
这里还统计了一下调用dictSyncDelete等的时间,并且通过latencyAddSampleIfNeeded
放到统计里面。
1 | ... |
下面是一些统计性的工作。
1 | ... |
我们在循环中就强制往Slave发送数据,确保即使在要传的数据都很大的情况下,我们仍然能够快速传递。
特别地,我们在while (mem_freed < mem_tofree)
这个循环的最后,还会有条件地检查一下内存是不是达标。这个主要是对异步删除来说的,在这种情况下,dbAsyncDelete
流程中对内存的释放未必能和我们循环这边同步起来。所以我们每释放16个键,就检查一次。
1 | ... |
异常情况
1 | ... |
getMaxmemoryState
这个函数获得内存的使用情况,包括:
- total
总共使用的内存。
来自zmalloc_used_memory
。 - logical
即mem_used
,表示出了Slave/AOF buffer之外的内存。
这个计算就是要减去overhead
,也就是Slave/AOF buffer的内存,用freeMemoryGetNotCountedMemory
计算得到的。 - level
表示内存使用率
1 | int getMaxmemoryState(size_t *total, size_t *logical, size_t *tofree, float *level) { |
上面获得了总内存量,如果没有设置最大内存,或者总内存量都没有操作,也不需要计算比例,那么就直接返回了
1 | ... |
计算两个缓冲区占用的内存
1 | ... |
Redis事件
在前面的代码中可以看到下面的语句,实际上是对主数据库c->db
进行修改后,需要进行事件通知,我们将在下面介绍这几个语句的作用。
1 | signalModifiedKey(c,c->db,c->argv[j]); |
signalModifiedKey
signalModifiedKey
是key被修改的钩子函数,每当数据库c->db
里面的key被改动时,会调用这个函数。这里的key发生改动也包括key对应的值发生改动,这是因为从genericSetKey
的实现可以看到,SET指令也会导致signalModifiedKey
被调用。
此外,根据注释,每一次DB被flush时,signalFlushDb
会被调用。
1 | // db.c |
touchWatchedKey
touchWatchedKey
字如其名,它的作用是让WATCH这个键的事务失效。
1 | /* "Touch" a key, so that if this key is being WATCHed by some client the |
这里先特判一下,如果db->watched_keys
为空就直接返回,这个用法在redis中非常常见,我猜想可能是dictFind
的开销还是比较大的。
1 | ... |
下面从db->watched_keys
上拿到WATCH这个key的所有的client,并且对这个链表上的每一个client设置CLIENT_DIRTY_CAS
这个flag。
1 | ... |
trackingInvalidateKey
下面看另一个函数trackingInvalidateKey
。这个系列的函数是在Redis6.0左右被引入的,主要用途是维护客户端缓存。
1 | /* Wrapper (the one actually called across the core) to pass the key |
当key的值被改变后,在keys tracking的逻辑下,我们的任务是给每一个有可能缓存了当前keys的client发送通知。如果传入的c
为空,表示这个不是一个client的场景,而是例如服务器内部做expire。bcast
参数的作用是是否要将这个key通过BCAST模式广播给client们。
1 | /* |
notifyKeyspaceEvent
函数notifyKeyspaceEvent
用来触发数据库事件,这个对应了Redis中的叫“键空间通知”/“键事件通知”的特性。这个特性是通过PUBLISH机制实现的。
简单来说,对0
号数据库的键mykey
执行DEL key [key ...]
命令时,系统将分发两条消息,相当于执行以下两个PUBLISH channel message命令。其中__keyspace
系列命令称为键空间通知(key-space notification),__keyevent
系列命令称为键事件通知(key-event notification)。订阅第一个PUBLISH命令,可以接收0
号数据库中所有修改键mykey
的事件。订阅第二个PUBLISH命令,可以接收0
号数据库中所有执行del
命令的键
1 | PUBLISH __keyspace@0__:mykey del |
下面看看这个函数的具体实现。
1 | void notifyKeyspaceEvent(int type, char *event, robj *key, int dbid) { |
内存管理
Redis内存管理zmalloc
Redis基于zmalloc系列函数进行内存分配。
zmalloc是为了解决什么问题呢?主要是为了做到异常处理和内存统计的功能。
下面看zmalloc
的实现。
可以看到,它会额外分配一个PREFIX_SIZE
,用来存储额外信息。zmalloc
最终返回的是(char*)ptr+PREFIX_SIZE
,这个有点类似于SDS的骚操作。PREFIX_SIZE
的大小是由宏来定义的,并且可以通过HAVE_MALLOC_SIZE
禁用内存统计的功能。
1 | // zmalloc.c |
下面仔细查看一下update_zmalloc_stat_alloc
函数的实现,不出所料的话,应该是通过一个原子操作来实现更新的。实际上也果不其然,atomicIncr
的实现在后面会讲到。
1 | static size_t used_memory = 0; |
还可以看到的是一个用来处理oom的函数zmalloc_oom_handler
。对于C语言来说,malloc
在内存分配失败后会返回一个0指针,然后我们在进行后续操作的时候要自行判断。基本上对于oom的处理就是打印一条日志然后abort了。
1 | // zmalloc.c |
总结
总结一下本章节中比较有意思的实现:
- LFU算法
- evictPoolEntry中,key和cached的维护
- 诸如keyptrDictType和dbDictType这种C形式的OOP的实现
- Redis对OOM的管理
Reference
- https://my.oschina.net/lscherish/blog/4467394
对Redis中的LRU和LFU进行了讲解。本文吸纳了其中的部分内容并进行了修订。 - https://juejin.cn/post/6844903454654087182