转载 | 为什么数据库应绕过Linux页面缓存

Jun 20, 2025

来看一下为什么 ScyllaDB 在读取时会完全绕过 Linux 缓存,并使用其自身高效的基于行的集成内部缓存。

Linux 页缓存(也称为磁盘缓存)是一种通用类型的缓存。尽管它可以进行调优以更好地服务于数据库类的工作负载,但从根本上讲它并不适合数据库的实现。

页缓存无法针对数据库所需的关键需求提供上下文,这对于实现最佳性能和控制是必须的。考虑到页缓存对内存的使用效率低下、负向缓存效果差、冗余的缓冲区、高昂的读操作 CPU 开销以及缓存过早淘汰(以及其他一些挑战),对于以性能为导向的数据库来说,采用不同的方法是合理的。

让我们来看一下 ScyllaDB(一款面向低延迟应用的数据库)是如何在读取时完全绕过 Linux 缓存,并使用其自身高效的基于行的集成内部缓存的。

Linux页面缓存的问题

Linux 缓存在数据库实现方面效率低下,原因有以下几点。

Linux 页缓存通过将文件的页面大小分块存储在内存中,以减少昂贵的磁盘读取,从而提升操作系统性能。Linux 内核默认将文件视为 4KB 大小的分块。这确实能加快性能,但前提是数据量达到 4KB 或更大。问题在于,许多常见的数据库操作所涉及的数据量往往小于 4KB。在这些场景下,Linux 的 4KB 最小分块导致了较高的读取放大。

读取放大(Read Amplification)是数据库与存储系统中的一个常用术语,意思是:为了获得一小部分所需的数据,系统实际上不得不读取远多于实际需要的数据。

假设你只需要 100 字节,而存储系统每次最小只能按 4096 字节读取,那么实际上就发生了读放大:你为获取少量数据“被迫”读取很多无用的数据。

读放大的本质是因为存储系统的最小读取单元(比如数据库页、操作系统页、SSD页)大于你的实际业务数据单元。

读放大的带来问题

  • 浪费带宽和IO资源。
  • 增加内存压力,可能把不相关数据装进缓存。
  • 降低查询效率,尤其在高并发/大量小请求场景下格外明显。

举例

  • MySQL/InnoDB:一行几百字节,底层按16KB页读;
  • Linux页缓存:只想访问一小行,但一次最少4KB;
  • SSD:每次读出一整个页面(比如4KB),但只用了几十字节。

更糟糕的是,这些额外读取到内存的数据通常对后续查询没有用(因为其“空间局部性”很差)。在大多数情况下,这只是带宽的浪费。Cassandra 试图通过增加键缓存和行缓存来缓解读放大问题,这两种缓存能直接存储经常被访问的对象。然而,Cassandra 的额外缓存带来了更高的整体复杂性,而且很难配置得当。运维人员需要为每个缓存分配内存,不同的分配比例会产生不同的性能特性,不同的工作负载又适合不同的设置。此外,运维人员还必须决定为 JVM 堆内存以及堆外内存结构分配多少内存。由于所有内存分配都是在启动时完成的,因此几乎不可能一次性配置得完全合理,特别是对于工作负载随时间动态变化的场景来说更是如此。

另一个问题:在底层实现中,Linux 页缓存还会执行同步阻塞操作,从而影响系统性能和可预测性。由于 Cassandra 并不知道所请求的对象是否存在于 Linux 页缓存中,所以对不在缓存中的页面的访问会导致页面错误和上下文切换,从磁盘读取数据。接着系统会再次进行上下文切换以运行另一个线程,原始线程会被暂停。最终,在磁盘数据就绪后(又一次中断上下文切换),内核才会把调度切回到最初的线程。

下图展示了 Cassandra 缓存体系结构,包括分层的键缓存、行缓存和底层的 Linux 页缓存。

我们的目标:更好的性能(和控制)

ScyllaDB 是一个高性能数据库,专注于提供超低延迟,即使在每秒操作量超过百万的负载下也能如此。由于 ScyllaDB 设计时就完全兼容 Apache Cassandra(后续又扩展了对 DynamoDB 的兼容性),它充分借鉴了 Cassandra 设计中的最佳元素。不过,Cassandra 对默认 Linux 页缓存的依赖并不在其内。

我们意识到,专用缓存比 Linux 的默认缓存可以带来更好的性能,因此我们实现了自己的缓存。我们的统一缓存能够根据任何类型的负载动态调整自身,免去了像 Apache Cassandra 那样需要手动调优多个不同缓存的麻烦。由于我们能清楚地知道哪些对象被缓存,因此可以精细控制缓存项目的加载和驱逐。此外,缓存还能根据不同的负载以及内存压力动态扩展或收缩。

当进行读取时,如果数据已经不在缓存中,ScyllaDB 会启动一个继续任务来异步地从磁盘读取数据。ScyllaDB 基于的 Seastar 框架会在微秒级(每核每秒可执行100万任务)内执行这个继续任务,并立即运行下一个任务。整个过程没有阻塞、没有繁重的上下文切换,也不会造成资源浪费。

这种缓存设计使每个 ScyllaDB 节点能够服务更多数据,从而用户可以用更少数量但更强大的节点和更大磁盘来组建更小的集群。同时,它还简化了运维工作,因为不再有多个相互竞争的缓存,并且能在运行时根据不同的工作负载动态调整缓存。此外,高效的内部缓存也让独立的外部缓存变得不再必要,从而带来了更高效、更可靠、更安全且更具性价比的统一解决方案。

更深入Scylladb缓存设计

有了上述概述后,让我们更深入地了解 ScyllaDB 缓存实现的细节。首先,我们从副本端来看数据流。

当写入操作到达副本时,它首先会进入一个内存中的数据结构——memtable(内存表),它存储在内存中。为了使写入被认为是成功的,还必须将数据写入 commitlog(提交日志)以便恢复,但这里 commitlog 本身并不是重点。

当 memtable(内存表)变得足够大时,我们会将其刷新到 SSTable(一种存储在磁盘上的不可变数据结构)中。此时会创建一个新的 memtable 来接收后续的写入,已刷写的 memtable 内容会与缓存进行合并,然后该 memtable 会从内存中移除。这个过程会不断循环,SSTable 也会不断积累。

当有读操作到来时,我们需要将 memtable 和所有累积的 SSTable 中的数据合并起来,以便获得所有已有写入的一个一致视图。

在这里,实现读一致性相对比较简单。例如,可以通过对内存表(memtables)进行快照并将其固定在内存中,同时对 SSTable 进行快照,然后将所有来源的数据合并起来,从而实现一致性。不过,这里有一个问题:速度很慢。每次都要访问磁盘,还要从多个部分读取数据。而缓存则可以加快这个过程。

为了避免每次读取都访问磁盘,我们需要一个读通缓存(read-through cache),它在语义上能够代表磁盘上的所有内容(即 SSTable),并在内存中缓存其中的一部分数据。传统的实现方式是使用缓冲区缓存,将从 SSTable 文件读取的数据缓存在缓冲区中。这些缓冲区通常为 4 KB,这也是使用 Linux 页缓存时会得到的大小。

为什么不使用 Buffer Cache?

如前所述,这种方法存在一些问题,这就是为什么Scylladb不使用它的原因。

内存利用率低下

如果你只想缓存一行数据,你需要缓存一个完整的缓冲区。一个缓冲区为4KB,但一行数据可能要小得多(比如只有300字节)。如果你的数据集大于可用内存,访问的局部性就不会很高,这就导致了内存的低效使用。

负缓存效果不佳

这种方法在实现负缓存时表现也很差。由于你没有具体的键,你需要缓存整个数据缓冲区来表示数据不存在。这进一步削弱了内存效率。

由于 LSM(日志结构合并树)导致的冗余缓冲区

这种方法还存在更多问题:因为读取操作可能需要访问多个 SSTable,缓存某一行可能需要缓存多个缓冲区。这再次导致内存使用低效,同时还增加了 CPU 的开销。

读操作的高CPU开销

当某一行被缓存到多个缓冲区时,每次读取都必须将这些缓冲区中的数据进行合并,这会消耗CPU周期。除了CPU开销外,存储缓冲区还要求我们解析这些缓冲区。SSTable格式并不是为读取速度优化的,而是为紧凑存储优化。你需要顺序解析索引缓冲区以理解索引,然后再去解析数据文件。这会消耗更多的CPU资源。

因SSTable压缩导致缓存被过早驱逐

SSTable压缩会重新写入SSTable,因为可能存在冗余或过期数据,这可能导致缓存被过早驱逐。压缩过程会写入一个新的SSTable,并删除旧的文件。删除旧文件意味着相关的缓冲区必须失效,这实际上也会使缓存失效。这样一来,读取性能会因此受到影响,因为缓存会出现未命中。

使用对象缓存作为替代

很明显,在这种情况下,缓冲区缓存方法存在许多问题。因此,我们选择了另一种方法:实现对象缓存。这种专用的缓存存储实际的行对象,比如 memtable 中的行对象,而不是与磁盘上的文件关联。你可以把它看作是另一个用来保存行数据的树结构。

这种数据结构没有我上面提到的那些问题。更具体地说,它针对快速读取和低 CPU 开销进行了优化。每一行只有一个版本,结合所有相关的 SSTable 数据。并且缓存是以行粒度完成的:如果你愿意,甚至可以只在缓存中保留一行数据。

内存管理

我们完全不使用 Linux 的页缓存。我们将每个节点绝大部分可用内存都保留给 ScyllaDB,只为操作系统(比如用于套接字缓冲区)预留极小的一部分。ScyllaDB 所分配的内存主要用于缓存。缓存被分配为使用所有可用内存,并且在系统其他部分(比如 memtable 或运维任务)有压力时,会按需收缩。我们还实现了控制器,确保其他压力源不会从缓存中抢走太多内存。

CPU 分片

另一个重要的设计元素是分片。通过我们每核一分片(shard-per-core)的架构,ScyllaDB 中的每个 CPU 都负责管理一部分数据,并且每个 CPU 都有独立的数据结构来进行管理。因此,每个 CPU 都有独立的缓存和独立的 memtable,并且这些数据结构只能由拥有它们的 CPU 访问。

每核单线程(Thread-per-core)是这一设计的另一个重要组成部分。所有处理都在每个 CPU 的单独线程中完成。执行被划分为短小的任务,这些任务按顺序运行,并采用协作式抢占,这意味着我们的代码能够精确地控制任务的边界。如果有抢占信号(通常来自定时器),那么任务就必须协作式地让出执行权。它不会在任意地方被抢占,而是可以确定可能的抢占点。

缓存一致性

所有这些设计让我们能够在单个任务内对数据进行复杂操作,而无需处理真正的并发问题。如果我们采用多线程并从多个 CPU 访问数据,就会遇到这些问题。这意味着我们不需要使用锁,能够避免锁竞争,也无需实现复杂的无锁算法。我们的数据结构和算法因此可以很简单。例如,在读取时,可以在单个任务中同时访问缓存和 memtable,并且在二者上获得一致的视图。这是在没有任何同步机制参与的情况下实现的,这意味着一切都能高效运行,性能也非常可预测。

范围查询和范围删除

支持我们的查询语言和数据模型,对于对象缓存来说可能是一个问题。ScyllaDB 不是一个简单的键值存储。我们支持更复杂的操作,如范围查询和范围删除,而这些操作会影响缓存的实现方式。这可能会导致对象缓存出现缓冲区缓存所没有的复杂情况。

例如,考虑范围查询。假设你有一个要扫描一组行的查询。如果你的缓存只是键值对缓存,你就无法利用缓存来加速读取,因为你永远无法知道缓存条目之间的数据是否也存储在磁盘上。结果就是,这种读取总是不得不去磁盘获取那些缺失的数据。

我们的缓存专门设计用来处理这种情况。我们会存储有关范围连续性的信息,表明缓存中的某个范围是完整的(因此无需再去读取磁盘以确认是否还有更多条目)。如果你重新扫描,系统就不会再访问磁盘了。

此外,范围删除操作需要特殊处理。由于 ScyllaDB 的最终一致性模型,删除不仅仅是移除数据。它还会留下一个标记,以便将来进行数据校对。这个标记被称为“墓碑”(tombstone),而缓存则必须能够存储这个标记。我们的缓存对此已经做好了准备:它会利用前面提到的范围连续性机制——本质上就是在范围连续性信息中附加上墓碑标记。

其他两个独特的缓存要素

在结束之前,让我们简要看看在 ScyllaDB 缓存中我们实现的另外两项有趣内容。

Cache Bypass

缓存绕过

ScyllaDB 属于读穿透型缓存,这意味着——默认情况下——你执行的每一次读取操作,都会将数据加入缓存,并随后为用户提供服务。然而,这并不总是用户所期望的。例如,如果你需要扫描大量数据,或者偶尔查询一些将来很可能不会再被读取的数据,这可能会导致缓存中重要的数据项被淘汰。

为防止这种情况,我们在 CQL 协议中扩展了 BYPASS CACHE 功能。这个扩展会告知数据库不要将本次查询过程中读取到的数据项加入缓存,从而避免重要记录被覆盖或淘汰。BYPASS CACHE 也经常与 ScyllaDB 的工作负载优先级(Workload Prioritization)功能配合使用,尤其适用于经常需要批量扫描数据的分析型业务场景。

SSTable Index Caching

我们还会缓存 SSTable 的索引组件,以进一步加快必须访问磁盘时的读取速度。SSTable 索引会在被访问时自动加入缓存;当内存压力较大时,索引会以不会影响缓存性能的方式被逐出。

SSTable 索引缓存的一个优势在于它对大分区性能的提升。下面,你可以看到我们在大分区直接从磁盘读取冷数据时的前后测量结果。请注意,在引入 SSTable 索引后,吞吐量提升了三倍以上,使得磁盘查找速度更快。

总结

总的来说,ScyllaDB 拥有一个快速缓存系统,专门为提升读取速度进行优化。它非常高效,因为数据和缓存被部署在同一个 CPU 上。此外,它还能完整地支持查询语言和数据模型的语义,同时遵循 ScyllaDB 的一致性保证。

该缓存系统完全绕过了 Linux 内核缓存,从而支持低延迟读取,同时避免了外部缓存的复杂性。采用这种方法,使我们能够实现可控且可预测的低延迟(比如百万级 OPS 下 P99 延迟仅为个位数毫秒)。同时,这也让我们能够完全洞察诸如缓存命中与未命中、缓存逐出和缓存大小等细节,用户可以据此更好地理解并优化性能。我们的专用缓存系统通常可以将性能提升到足以让用户用它来替换外部缓存的程度。


  • https://www.p99conf.io/2025/05/22/databases-linux-page-cache
https://inasa.dev/posts/rss.xml