Skip to content

案例研究:Redis 如何组合三种模式在单线程上保持高速

这是什么。 大多数模式文档孤立地讲一个模式。本案例研究反其道而行:它剖析 一个真实系统——Redis 这个内存数据存储——如何组合三个模式,让一个单线程 服务器既能每秒处理数十万次操作,又能把数据快照到磁盘而不暂停,还能保持内存有界。 每条按模式的论断都链接到锁定提交的源码;组合论证则有 Redis 自己的文档支撑。

Redis 解决的问题

Redis 把整个数据集放在内存里,并在一个线程上执行命令。这听起来像瓶颈,但 Redis 却常常胜过多线程、磁盘后端的数据库。单线程恰恰是它速度的来源:没有锁、没有 上下文切换、核心间没有缓存行争用。一条命令就是一次内存操作,而内存操作是纳秒级的。

Redis 6+ 可以用额外线程做网络 I/O(读取和解析请求、写回复——io-threads 选项)。但真正修改数据的部分——命令执行——仍然严格单线程。本案例研究讲的 正是这个线程。

但单线程也是个软肋。一旦任何事情阻塞了这个线程——等待一个慢客户端套接字、把 数 GB 的快照写入磁盘、或扫描数百万个 key 来释放内存——整个服务器就会停顿。所以 Redis 真正的工程难题是:如何在永不阻塞那唯一执行命令的线程的前提下,完成数据库 该做的一切(I/O、持久化、内存管理)? 三个模式回答了这个问题的三个侧面。

问题模式Redis 如何回答
一个线程如何在不被 I/O 阻塞的情况下服务上千客户端?事件循环aeMain 循环,向内核(epoll/kqueue)询问哪些套接字就绪
如何把 GB 级数据快照到磁盘而不暂停命令执行?写时复制fork() 出一个子进程;OS 共享内存页,只复制被修改的页
如何在不做昂贵全局扫描的情况下限定内存?LRU 淘汰采样若干 key,估算空闲时间,在 maxmemory 下淘汰最冷的

模式 1 —— 事件循环:一个线程,多个套接字

Redis 的核心是 aeMain:一个单一的 while 循环,运行到服务器停止为止。每一轮都 调用 aeProcessEvents,它向内核询问"我的哪些套接字就绪了?"并分发就绪的那些。

c
void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        aeProcessEvents(eventLoop, AE_ALL_EVENTS|
                                   AE_CALL_BEFORE_SLEEP|
                                   AE_CALL_AFTER_SLEEP);
    }
}

"哪些套接字就绪"这个问题由 I/O 多路复用 回答。在 aeProcessEvents 内部, Redis 调用 aeApiPoll——一层对 epoll(Linux)、kqueue(BSD/macOS)或 select 的薄封装。这些都是操作系统提供的设施,让一个线程能同时盯住成千上万个 套接字,并阻塞到其中任意一个有数据为止。内核在至少有一个套接字就绪时才阻塞 线程,随后返回就绪集合。线程从不空转,也从不在单个慢客户端上阻塞。

c
int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
    int processed = 0, numevents;
    // ...
    numevents = aeApiPoll(eventLoop, tvp);  // epoll/kqueue: who's ready?
    // ...dispatch each ready fd to its handler...
}

心智模型

想象整个餐厅只有一个服务员。他不会站在一桌前等它吃完(阻塞),而是扫视所有桌, 只走向那些举手的桌。aeApiPoll 就是那次扫视;内核负责"举手"。一个线程能服务所有人, 正是因为它从不为任何单独一桌干等。

→ 单独看这个模式,见 事件循环

模式 2 —— 写时复制:不暂停就能快照

Redis 通过把内存快照到 .rdb 文件来持久化。把数 GB 写入磁盘要花几秒——对命令线程 而言是一个世纪。阻塞它会冻结所有客户端。Redis 的答案是根本不从主线程写:它调用 fork(),让子进程写快照,而父进程继续服务命令。

c
int rdbSaveBackground(int req, char *filename, rdbSaveInfo *rsi, int rdbflags) {
    pid_t childpid;
    // ...
    if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) {
        /* Child */
        int retval = rdbSave(req, filename, rsi, rdbflags);
        if (retval == C_OK) {
            sendChildCowInfo(CHILD_INFO_TYPE_RDB_COW_SIZE, "RDB");
        }
        exitFromChild((retval == C_OK) ? 0 : 1);
    } else {
        /* Parent keeps serving clients... */
    }
}

奥妙在于 OS 提供的写时复制redisFork()(对 fork() 系统调用的薄封装) 复制数据集;父子进程共享同一批物理内存页,标记为只读。子进程读这些页来写快照。 只有当父进程修改某个 key 时,内核才复制那一个页——于是 Redis 会跟踪有多少页被 复制,并报告为 RDB_COW_SIZE,这是衡量保存期间数据集改动量的有用信号。对一个 10 GB 数据集的快照在微秒内就启动,且只复制保存期间发生变化的那些页,而不是全部 10 GB。

心智模型

fork() 并不给子进程一份真正的内存拷贝——它给的是同一批页,共享且被冻结为只读。 子进程从容地把这些共享页写到磁盘;而父进程一旦修改某个 key,内核就悄悄复制那一个页, 好让子进程仍看到旧值。命令线程从不为 I/O 暂停。

→ 单独看这个模式,见 写时复制

模式 3 —— LRU 淘汰:不做全局扫描就能限定内存

RAM 是有限的。当配置了淘汰策略、且 Redis 触及 maxmemory 上限时,必须先释放空间 才能接受新写入。(默认策略是 noeviction,此时它只会以错误拒绝写入;但当设置了 allkeys-lru 这类策略时,Redis 就必须淘汰。)它承担不起把所有 key 按访问时间 排序(那是 O(n) 且会阻塞线程)。于是它改为采样:挑出一小撮 key,估算每个空闲了 多久,淘汰最冷的。

c
int performEvictions(void) {
    if (!isSafeToPerformEvictions()) return EVICT_OK;
    int keys_freed = 0;
    size_t mem_reported, mem_tofree;
    // ...while over maxmemory: sample keys into a pool, evict the best candidate...
}

"这个 key 有多冷"这个问题用近似 LRU 时钟回答。每个对象存一个粗粒度时间戳; estimateObjectIdleTime 用一个全局时钟减去它来估算空闲时间——热路径上无需逐次访问 记账,也无需维护链表。(这个全局时钟是一个定宽计数器,会绕回到零,所以下面的 else 分支处理的是回绕的情况。)

c
unsigned 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;
    }
}

Redis 用精确性换速度:它不保证淘汰全局最冷的 key,只保证从一个小样本里淘汰一个 非常冷的。默认采样若干 key,外加一个跨调用保留的最佳候选池,这种近似已经很接近 真正的 LRU——而每次淘汰是 O(1) 而非 O(n)。

心智模型

真正的 LRU 维护一个有序链表,每次访问都要付出代价。Redis 则随手抓一把随机 key, 淘汰这些里面最冷的。这就像清理桌面时瞥一眼随机五张纸、扔掉积灰最多的那张——不完美, 但快,而且在大规模下足够好。采样把 O(n) 的排序变成了 O(1) 的活儿。

→ 单独看这个模式,见 LRU 缓存

三者如何组合

这些模式每一个都为了保护同一个稀缺资源:那唯一的命令线程。它们在三条不同战线上 守护它。

text
            ┌──────────────────────────────────────────────┐
            │           the one command thread             │
            └──────────────────────────────────────────────┘
                 ▲                ▲                  ▲
   I/O won't ────┘   persistence ─┘    memory growth ┘ won't
   block it:         won't block it:    block it:
   EVENT LOOP        COPY-ON-WRITE      LRU EVICTION
   (aeApiPoll waits  (fork(): child     (sample + evict,
    in the kernel,    writes snapshot;   O(1), never an
    never on one      parent keeps       O(n) global scan)
    slow socket)      serving)
  1. 事件循环I/O 挡在关键路径之外:线程在内核里阻塞,等待任意就绪的套接字, 而非某个慢客户端。
  2. 写时复制持久化挡在关键路径之外:fork 出的子进程对着一张廉价的、共享页的 内存定格照片写快照,父线程则继续执行命令。
  3. LRU 淘汰内存管理挡在关键路径之外:采样让"释放一些内存"成为 O(1) 操作, 而非会拖停线程的 O(n) 扫描。

统一的思想是永不阻塞那唯一的线程。单线程执行正是 Redis 快的原因(无锁、无争用), 但它能成立的前提是:每一个潜在的慢操作要么被委派(I/O 交给内核、快照交给子进程), 要么被近似(淘汰用采样而非排序)。去掉任何一个,模型就崩了:没有事件循环,一个慢 套接字会冻结所有人;没有 COW,每次保存都会暂停服务器数秒;没有采样淘汰,当淘汰 策略触及 maxmemory 时会触发拖停命令的 O(n) 扫描。

架构层面的推断

把这些模式诠释为一个刻意组合的设计——以"保护单线程"为统一主线——依据的是 Redis 自己的文档(见延伸阅读),而非任何单个源文件。按模式的代码链接是直接的源码证据; "按设计组合"这一论断由那些设计层面的材料支撑。

生产验证

所有源码链接都锁定到 Redis 提交 e91a340e241cf0abe3c6a0c254214fbe4aa1d95f(标签 8.0.0)。按模式的论断是 source-code(L1);组合关系由官方文档(official-doc)支撑。

模式 / 论断来源证据在 Redis 中的角色
事件循环ae.c#L492-L499source-codeaeMain——驱动整个服务器的那个单一 while 循环
事件循环(I/O 多路复用)ae.c#L360-L398source-codeaeProcessEvents 调用 aeApiPoll(epoll/kqueue)找出就绪套接字
写时复制rdb.c#L1642-L1662source-coderdbSaveBackground——redisFork() 让子进程借 OS 写时复制做快照
LRU 淘汰evict.c#L521-L530source-codeperformEvictions——在 maxmemory 下通过采样候选释放内存
LRU 淘汰(空闲估算)evict.c#L73-L79source-codeestimateObjectIdleTime——用对象上粗粒度时钟近似 LRU
单线程设计Redis FAQ — single threadedofficial-doc官方解释为何命令执行是单线程的

要点

  • 模式很少单独出现。 在单线程上保持高速需要同时用到一个 I/O 模式(事件循环)、 一个持久化模式(写时复制)和一个内存模式(LRU 淘汰)——每一个都消除了线程可能 被阻塞的一种途径。
  • 一个约束能统一整个系统。 "永不阻塞单线程"是把 I/O 委派给内核、把快照委派给 子进程、把淘汰交给采样这三件事背后的同一原则。在三个子系统背后认出同一个思想, 正是深度读源码的回报。
  • 要么委派、要么近似——别阻塞。 慢活儿要么被交出去(I/O 交给内核、保存交给 fork 出的子进程),要么靠近似变廉价(采样淘汰)。Redis 快是因为线程总有此刻就能做的事。
  • 这呼应了 Node.js。 两者都建立在基于 epoll/kqueue 的单线程事件循环之上。把 Redis(一个数据库)与 Node(一个运行时)对比,能看到同一个 reactor 模式在解决不同 问题。

延伸阅读

一条从"我读过这个"到"我能在任何地方认出这些模式"的路径:

  1. 先理解模型—— Redis 关于单线程的 FAQ 解释了为何命令执行用一个线程,这是其他一切所保护的前提。先读这个;源码随后 会展示如何保护它。
  2. 理解持久化—— Redis 持久化文档 描述了 RDB 快照,以及为何由 fork 出的子进程(写时复制)来负责写入。
  3. 理解淘汰—— key 淘汰文档 涵盖了 maxmemory、LRU/LFU 策略以及采样近似。
  4. 然后按这个顺序读源码——循环 (aeMain) → 快照 fork (rdbSaveBackground) → 内存限定 (performEvictions)。
  5. 跨系统对比——读 Node.js 请求案例研究, 把它的事件循环和 Redis 的对比。同一个 reactor 模式,不同用途(一个服务回调的运行时 vs. 一个服务命令的数据库)。
  6. 练习辨认——打开下面三个模式页,在另一个系统里寻找"把慢活儿委派出去、保持热 路径空闲"的影子。

延伸学习这些模式

  • 事件循环 —— 一个线程靠多路复用服务多个套接字
  • 写时复制 —— 共享页,仅在修改时复制
  • LRU 缓存 —— 淘汰最冷的,用采样做近似

基于 MIT 许可证发布。