Goblin Core 是一款兼容 Redis 的数据库。Redis 的核心命令执行采用单线程,因此延迟低、同步成本小,事务语义也简单。Goblin Core 延续了这一传统。

Goblin Core 性能很高,在许多方面都胜过现有实现,但它真正出彩的地方是内存占用。面对真实数据集,它所需的内存少得多。在一项使用 143 亿局 Lichess 对局的基准测试中,Goblin Core 的内存用量仅为次优对手的 64%,速度则超过除一家之外的所有对手。

我们认真对待内存,因为我们的承诺很简单:用更少的硬件做更多的事。

这种认真也延伸到细枝末节,包括我们如何保存你的数据。

SAVE 与 BGSAVE

Redis 提供两个把数据保存到磁盘的命令:SAVEBGSAVE

别忘了,Redis 以单线程执行命令,Goblin Core 也一样。这种单线程设计让我们能够以很低的成本完成低延迟同步。

执行 SAVE 时,数据库会停止处理请求,把内存中所有内容的快照写入磁盘,然后才继续工作。对于小型数据库,这可以接受。对于包含数百万乃至数十亿个对象的大型数据库,暂停可能持续几秒,甚至几分钟。

这就不太能接受了。

BGSAVE 通过派生进程来解决这个问题。Linux 创建一个子进程,让它看起来像是拥有父进程内存的一份副本。子进程负责写快照,主进程则继续向前,照常提供服务。

这种办法之所以有效,是因为 Linux 支持写时复制,也就是 COW。

进程派生时,操作系统不会立刻复制它的全部内存。它只做一些管理工作,为两个进程分别建立虚拟地址空间,再让它们指向相同的物理页。这些页面会被标记为只读。

如果任一进程试图修改其中一页,操作系统便介入,复制该页面,让两个进程各自拥有自己的版本。

只有发生写入时,才会真正复制。

当这头 COW 开始吞噬内存

后台保存期间,写入会消耗内存,但通常不成问题。对于长期运行的 Redis 兼容数据库,在几秒或几分钟的保存窗口中到来的写入量,与整个数据集相比通常很小。

SETZADDHSET 修改先前共享的页面时,对每个页面的第一次写入都会促使 Linux 创建一份私有副本。随着越来越多的页面被写脏,内存占用也会不断上升。

通常,这套机制运转得非常漂亮。

直到一张大型哈希表需要增长。

Goblin Core 使用 Swiss 哈希表。与大多数哈希表一样,它们最终会耗尽空间,必须分配一张更大的表。在增长过程中,所有条目都要重新分布到新表中。

在我们的实现里,表中保存的是指针,键和值本身位于单独管理的内存 Arena 中。我们并不会搬动所有键和值的数据。即便如此,一个大型结构仍可能包含数 MiB 必须重写的元数据和指针。

重建表时,大量页面会在短时间内被写入。在 BGSAVE 期间,每个新近写脏的页面都可能需要一次 COW 复制。操作系统突然忙碌起来,四处寻找内存来容纳所有新近变脏的页面。

设想一下:数据库的大部分内容都装在一个巨大的 Redis 哈希中,而 Goblin Core 已经使用了机器 80% 或 90% 的内存。这并不离谱,使用内存高效的 Redis 兼容数据库,本来就是为了省钱。

一次表重排就可能吃光剩余内存。Linux 会启动 OOM killer。它可能杀死服务器进程、快照进程,或将两者一并杀死。

内存耗尽。

COW 变成了 OOM。

不靠翻倍来增长

Goblin Core 通过占用率阈值控制哈希表增长。

通常,当 Swiss 表中 97% 的槽位已被占用时,我们会把表扩大约 19%。

为什么是 19%?

许多库会在 vector 或哈希表装满时直接把容量翻倍。调整大小是 O(n) 操作,但由于发生频率很低,插入的摊销成本仍为 O(1)。

我们不喜欢让表直接翻倍。刚刚翻倍的表有一半是空的,而空槽位照样占用内存。

Goblin Core 改为按 24 倍增长,约等于 1.19。经过四次增长,表的大小正好变为原来的两倍。

这样一来,表内浪费的内存少得多,但增长和复制会更加频繁。摊销成本仍是 O(1),只是常数更大。

这就是取舍。

你很可能是因为云内存账单太高,才使用 Goblin Core。现代 CPU 也非常擅长重建表时涉及的连续内存复制。与顺序复制相比,随机内存访问的相对成本已经变得更高。

这正是 Goblin Core 会做出不同于 Redis 历史设计之取舍的原因之一。

BGSAVE 期间会发生什么变化

与 2× 的替换表相比,1.19× 的替换表所需的新分配空间少约 40%,但它依然会触及大量页面。在 BGSAVE 期间,这会造成可观的 COW 负担,并带来让 COW 走向 OOM 的风险。

我们的解决办法很简单:推迟增长。

BGSAVE 运行期间,Goblin Core 会把默认增长阈值从 97% 提高到 99%。这样,在必须重建之前,表还可以多使用两个百分点的容量。

这会付出性能代价。表越拥挤,寻找空槽位所需的时间就会稍长。一次未命中的 HGET 也可能需要探测更多位置。

但 COW 负担仍可维持在合理范围。

如果你正在疯狂写入,最终还是达到 99%,会怎样?

如果一次插入会迫使表在 BGSAVE 期间增长,Goblin Core 会返回错误,而不是开始一次可能带来灾难的扩容。

Goblin Core 的一项指导原则是:与可靠性有关的事务,包括持久日志、持久化策略、保存时机和恢复,都应交给专门处理这些问题的层。

Goblin Core 永远不会让你选择 AOF 的 fsync 间隔。我们把持久日志委托给 Kafka 或 Redpanda。Goblin Core 可以在变更发生时将其写入日志,并在恢复过程中重放。

后台保存期间,保护正在运行的数据库,比接受一笔可能杀死两个进程的额外写入更重要。

Arena 压缩整理

没错,Goblin Core 在 BGSAVE 期间也会限制内存 Arena 的压缩整理。

压缩整理没有重建巨型哈希表那么危险,但仍可能弄脏大量页面。推迟它,可能暂时降低内存效率,或迫使我们再分配一个内存块;但这总比在保存期间引发 COW 爆炸要好。

所以没错:Goblin Core 会竭尽全力,不让 COW 变成 OOM,也不让这头牛真的发出“哞”的一声。