redis 源码学习

一直实践以来, redis 的性能很高,且内存占用很小。一直想知道是为什么?这次看 redis 源码,首先就奔着这一点去看了。

redis 底层存储原理

使用了 sds 进行存储,其实他就是 c 的 char 指针,所以说其和 c 是无缝贴合的。
但是它不在由 c 语言进行管理,而是自定义结构体,里面是长度、可用大小,flag 以及数据。

1
2
3
4
5
6
7
8
9
10
typedef char *sds;

struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};

#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))

它的结构很简单,长度,分配的空间,标示和内容。redis 里面涉及到 char 的一般都会用指针找到位置,然后赋值。

实际存储在 db dic 里面的 是 redisObject。里面存储了类型、编码、过期算法、连接数和内容即 sds。

1
2
3
4
5
6
7
8
9
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;

看起来很简单,但它通过一系列措施来确保高效。
比如如果长度小于等于 44,那么就会保存不变区域的 string。具体是因为 1+3+4+8 是 16 个字节,再加上 sds 的最小 3 个字节,还有一个 ‘\0’ 结尾字节,在 64 字节(小字节下避免空间浪费)下得出 44。内部实现会让 redisObject 和 sds 内存一块申请。
append 的时候会检查,如果没有,正常创建。有的话会尝试转为 raw 类型。
总结来看:

  1. 扩容。凭借指针操作和记录的当前 len 和申请 alloc ,可以快速进行判断决定是扩展内存还是直接就申请新的内存。
  2. 内容利用。redis 对小数据做了很多的优化,比如申请一次内存、int 类型直接存储、共享创建等等
  3. 精准内存操作。会尽量到位到具体的内存地址,然后直接操作赋值或者复制等等。

启动流程

官方文档写的挺清晰的,server.c main 内部主要方法是 initServerConfig() -> initServer() -> aeMain(server.el) 。redis 内部真的大量封装了原生的方法,利用宏编译来尽可能地提高在各个平台的性能。比如在 zmalloc 内部进行自己的内存管理,根据各个平台函数提供调用来确保分配的内存和内存大小明确。

注册 file event 回调

initServer -> createSocketAcceptHandler -> aeCreateFileEvent 里面会注册 event handler,比如 acceptTcpHandler 内部会取出数据,然后注册 acceptCommonHandler,conn 状态变为 CONN_STATE_ACCEPTING

1
2
3
4
5
6
7
8
typedef enum {
CONN_STATE_NONE = 0,
CONN_STATE_CONNECTING,
CONN_STATE_ACCEPTING,
CONN_STATE_CONNECTED,
CONN_STATE_CLOSED,
CONN_STATE_ERROR
} ConnectionState;

开始 eventloop

aeMain(server.el) -> aeProcessEvents 内部会 aeApiPoll -> rfileProc 即前面注册的回调调用处理 file event。 以 acceptTcpHandler 为例,先连接 client socket fd,然后进行读取且 conn 状态变为 CONN_STATE_ACCEPTINGacceptTcpHandler 之后调用 acceptCommonHandler 创建 client,将状态变为 CONN_STATE_CONNECTED,执行 clientAcceptHandler 方法,读取成功后调用 moduleFireServerEvent 来进行执行之前注册到 RedisModule_EventListeners 的 callback。
那么它们是怎么和命令处理关联起来的。
acceptCommonHandler 方法创建 client 的时候会创建 createClient,在创建过程中会通过 connSetReadHandler 调用 connSocketSetReadHandler 注册 readQueryFromClient 方法。 而 connSetReadHandler 方法内部是关键,它会readQueryFromClient 方法赋值 conn->read_handler,然后调用 aeCreateFileEvent 创建事件,mark 是 AE_READABLE,回调是 .ae_handler(connSocketEventHandler),其内部会根据连接情况和最后的处理分别调用方法。
InitServerLast -> initThreadedIO -> IOThreadMain -> readQueryFromClient -> processInputBuffer -> processCommandAndResetClient
总结一下,可以分为几个步骤:

  1. socket 监听,调用系统的方法进行监听,然后进行封装打标记,并赋值 eventLoop->fired,返回数量。
  2. eventloop 根据数量遍历,从 fired 拿到相应 fd、mark 信息,然后回调一开始初始化设置的监听 accept aeFileProc,不管哪种如 ip 或者 tls 等,最后都会调用 acceptCommonHandler,该方法作用在上面详细说明了。它最终会产生 readQueryFromClient 事件。
  3. readQueryFromClient 会解析客户端发起的参数,解析,然后进行命令的调用,它会调用一开始 populateCommandTable 方法注册的命令(redisCommand redisCommandTable[] 里读取)。
  4. 以 get 为例,会从 db 的 dic 查询 key 的值(db 默认为 0 ,除非 select 切换)。

redis 内部举例

list 用的 quicklist,本质上就是双向链表,但是它内部还兼容了 ziplist,quicklist 内部的 node 会有 char 数组来存储压缩的内容。

结论

  1. redis 主要是充分利用内存会让缓存达到很高的速度,在辅助一些内存优化即可实现高速缓存。
  2. redis 内部的单线程来确保避免了线程的切换损耗,网络 IO 这块用多线程会有更好的性能。
  3. 启发,其实很多时候程序的瓶颈都在内存、网络、存储等等,cpu 存在不够的情况很低,cpu 不够如果不是因为高计算量,其实优化空间很大。所以在我们开发的时候重点关注那些会产生 IO 的操作。