进阶 docs.memcached.org 2026-10-13 18:51:48 · 19 阅读

第21章 Flash Storage(闪存存储)扩展

Flash Storage

该功能用于将 memcached 的内存空间扩展到闪存(或类似)存储上。要求 memcached 1.6.0 或更高版本。

快速开始

Extstore 是 memcached 的一个扩展,它将哈希表和键保留在内存中,但将值移至外部存储(通常是闪存)。

何时使用?

如果你只有一两个 memcached 实例,可能不需要此功能。

如果存储的值远大于键,且延长数据保留时间能提高命中率,那么你可以考虑使用。如果你有富余的网络带宽,改用闪存存储还能节省成本(云环境中的 RAM 可能很昂贵)。

虽然网上有分析 memcached 统计输出的有用工具,但你也可以直接通过 telnet 连接并运行 stats 和 stats slabs 命令来查看输出。如果拥有较大 chunk_size 的 slab 类同时也具有较高的 total_pages 数量,且你观察到驱逐(evictions)现象,可以尝试此方案。

你还需要有闪存存储。此功能不适用于旋转介质(机械硬盘)。

如何使用?

获取最新版的 memcached。

使用以下命令构建:./configure && make && make test && sudo make install

使用你常规的启动选项(如 -m, -c 等),并添加:

-o ext_path=/path/to/a/datafile:5G

这会初始化 extstore,最大存储量为 5GB。内部将存储空间划分为页面(pages)。默认页面大小为 64MB。

如何调优?

除非服务器非常繁忙,否则通常不需要进一步调优。尝试监控 stats 输出以及写入磁盘的数据量。

如果向 memcached 写入过多,可能导致闪存写入过多,从而烧毁驱动器或造成过高延迟!有关调优选项的完整描述见下文。

调优指南

在调优之前,你应该对操作系统有充分的了解,知道如何监控延迟、磁盘使用率、CPU 使用率等。你应该已建立图形化监控系统,以便长期监控趋势,包括 memcached 统计信息。你应当能够监控命中率,并通过 stats 命令对 extstore_ 相关的统计项进行绘图分析。

为什么要调优?

如果你拥有大容量系统(数百 GB 到 TB 级),并且吞吐量达到数百 Mbps 到 Gbps 级别,你可能会看到较差的延迟或命中率。你也可能看到过度的磁盘写入,这会导致设备老化或烧毁。大多数服务器级闪存设备的额定写入寿命为每天仅 1-5+ 次覆盖。

主要权衡

  • 项目从 LRU 的尾部(通常是活动度最低的项目)刷写到闪存存储。键和项结构(时间戳、标志等)加上 12 字节的闪存位置信息保留在 RAM 中。这些的存储方式与普通项相同。
  • 这意味着,使用 100KB RAM 的 slab class 50 中的项被刷出后,会根据键的长度重新出现在 slab class 1-6 中的任意位置。
  • 如果你的值只有 60 字节,那么刷出它们并不能节省多少(甚至不节省)RAM。
  • 项首先写入 RAM 中的“写缓冲区”,然后刷入页面。一旦页面填满,即被封顶,随后使用新页面。
  • 由于项是从 LRU 尾部提取的,稍后再次命中闪存的概率自然较低。
  • 如果你向 memcached 写入新数据的速度快于其刷写至闪存的速度,系统会优先从 LRU 尾部驱逐,而不是等待闪存存储刷写。这是为了防止许多故障场景,因为闪存驱动器容易出现瞬时挂起。
  • Extstore 明确不是分层存储系统,后者常用于管理写入负载。为了在闪存介质上支持缓存工作负载,需要在 RAM 中保留更多数据,并直接访问闪存以最小化延迟。

性能预期

  • 许多操作不触及闪存。例如:touch、delete、覆盖已在闪存上的值(包括 add)、TTL 过期。它们的执行速度应与没有 extstore 时相同。
  • 只有读取被刷出的项、从 LRU 尾部写入项、以及压缩现有的已刷出项,才会触及存储。
  • 在大多数部署中,位于 LRU 尾部的项很少被命中,但如果减少未命中对命中率有重大影响,这仍然很有帮助。
  • 你可能只想刷写非常大的项(64k+),这能释放最多的 RAM,且闪存设备很容易利用它们来饱和网络带宽。存储在闪存上的项越小,你越容易在达到网络带宽限制前就饱和闪存设备。

需关注的指标

  • Evictions:意味着 RAM 不足,或者写入速度过快导致 extstore 无法跟上。
  • 非常低的 age(通过 stats items 查看)在较高的 slab 类中,可能意味着小项占用了过多内存。你可以减少闪存存储、缩短键、或为服务器增加 RAM。
  • 过量的磁盘写入、磁盘延迟尖峰、磁盘挂起:截至本文撰写时,系统内部没有针对磁盘延迟的测量工具。
  • 可以通过增加 RAM 并按照下文设置调优压缩选项来缓解过多的写入。最近设置的对象最可能被重写或删除,因此它们在 RAM 中停留的时间越长,最终在闪存上进行的写入就越少。

页面桶(Page Buckets)

存储“页面”被划分为不同的“桶”,允许 extstore 协同存储项。目前桶包括:

  • Default:所有非特殊项都归于此类。
  • Large items:大于 512k 的项(如果最大项大小被增加,则阈值也会相应提高)。
  • Compacted items:重写后的项存储在一起以减少碎片。
  • “Low” TTL items:此桶默认未使用。Low 桶使用的页面会被压缩过程忽略,这可以减少磁盘写入。通过将这些对象与具有较长 TTL 的对象分开存储,可以显著减少碎片。

调优选项

(参见 --help 或 stats settings 获取最新默认值。版本 1.5.4 的输出中可能尚未包含所有这些默认值。)

  • ext_page_size:默认为 64M。除非你存储非常大的值(多兆字节),否则无需更改。
  • ext_wbuf_size:默认为 8M。每个桶需要一个写缓冲区,默认保留 32MB 内存用于写缓冲区。此值不能大于页面大小。较大的写缓冲区有助于提高写入速率性能。
  • ext_threads:默认为 1。如果你有很高的读取延迟但驱动器处于空闲状态,可以增加此数值。请保持在较低值,不超过 8 个线程。
  • ext_item_size:默认为 512 字节。大于此值的项可以被刷出。如果你想节省额外的 RAM 且键较短,可以降低此值。如果你只想刷出非常大的对象,也可以提高此值,这是一个很好的起点。
  • ext_low_ttl:低于此值的 TTL 将在磁盘上协同存储。默认关闭。如果你混合使用高和低 TTL,此选项很有用。Extstore 必须在闪存满时压缩或完全驱逐页面。如果将高低 TTL 对象混合在一个页面中,一段时间后页面就会部分空闲。例如,如果你只存储 TTL 为 86400(约一天)或 2400s 的对象。如果将 ext_low_ttl 设置为 2400 或更高,所有后者对象将聚集在一起。当这些对象全部过期后,它们所在的页面将被完全回收。这避免了与较长 TTL 对象放在一起时产生的空洞和所需的压缩。此选项未来可能会改变;甚至更多桶可能更有用。
  • ext_max_frag:压缩调优标志。如果剩余空闲页面很少,至少空到该程度的页面将被重写以回收空间。例如:ext_max_frag=0.5 将重写至少一半为空的页面。如果没有一半为空的页面,最旧的页面将被驱逐。
  • ext_drop_unread:压缩调优标志。如果启用,且没有页面匹配 ext_max_frag 过滤器,仍将进行压缩。但只有新的或频繁访问的项(即不在 COLD LRU 中的项)会被重写。这可能会导致比直接驱逐更多的写入,但会保留活动项并提高命中率。
  • ext_compact_under:压缩调优设置。如果空闲页面少于该数值,检查 ext_max_frag 过滤器并开始压缩。默认值为总页面数的 1/4。
  • ext_drop_under:压缩调优设置。如果启用了 ext_drop_unread,且空闲页面少于该数值,则进行压缩并丢弃 COLD 数据。默认值与 ext_compact_under 相同。
  • ext_recache_rate:如果存储在闪存上的项在过去一分钟内被访问超过一次,它将有 1/N 的概率被重新缓存到 RAM 并从闪存中移除。默认为 2000。最好保持此值较高;重新缓存到 RAM 会导致磁盘碎片,且闪存中的对象很少会变得频繁访问。如果确实如此,它们最终会被重新缓存。

运行时可调参数

上述许多值可以在运行时调整。

通过连接到 memcached 的 socket:执行 extstore [command] [optional value]。截至本文撰写时,命令包括:

item_size item_age low_ttl recache_rate compact_under drop_under max_frag drop_unread

效果与上文描述相同。

未来计划

TODO:以下草稿笔记及 doc/storage.txt 中包含了一些信息。

草稿笔记

有关 API 和一些实现细节,请参见 doc/storage.txt。有关启动参数,请参见 memcached -h。

主要特性:

  • 速度极快。CPU 开销最小。
  • 写入是缓冲并顺序刷新的。这对改善大多数设备的读取工作负载非常有利。
  • 键和头部保留在内存中,数据写入磁盘。(见下文)
  • 通过页面压缩随时间减少碎片。
  • 多键读取可以批量处理,以减少从多次交换到 IO 线程的延迟。
  • 简单。使用基本的线程池进行 IO,挂起/恢复连接。
  • 如果项被 SET 的速度快于刷新到闪存的速度,不会阻塞。相反,对象将从内存尾部驱逐以腾出空间。(可靠、速度可预测)
  • 可实时控制哪些内容被刷写到闪存,例如:“大小 >= 1024 字节且空闲 3600 秒的对象”。

权衡:

  • 所有数据都在内存中跟踪。重启 memcached 实际上会清空闪存。
  • 对象会在内存中留下小部分 + 原始键。这意味着非常小的对象从刷出中获益甚微。
  • 不支持刷出到磁盘的对象的 UDP。
  • 不是分层(或任何复杂)数据库。它希望快速并忘记事物,而不是高效利用 100% 的磁盘空间。最好情况下预期 80-90%。

高层级细节

单个文件被分割为 N 个逻辑页面,大小为 Y(默认 64M)。写缓冲区(8M+)用于存储稍后刷入页面的对象。

IO 线程用于异步刷写缓冲区和读回项。

项与其键一起留在哈希表中,带有小型(截至本文撰写时为 12 字节)头部,详细说明数据在闪存中的位置。

存储引擎通过改变版本号来回收页面。项头部记得对象刷出时的页面 ID 和版本号。使用错误版本进行的读取尝试将导致未命中。

如果你拥有不同大小项的分布,此系统最有用:例如混合了大小项的池,或者你可以分离出一个小型机群,使用闪存设备来存储大得多的项。在小项池中释放更多内存可以显著改善命中率,或者仅仅降低整体成本。

性能

这是为最大性能设计的。它整合到现有的哈希表数据结构中,以避免因将项移动到二级系统中而消耗 CPU。这也大大减少了维护数据所需的 IOPS。

例如:重写对象不会触及闪存。它只是“忘记”之前对象在磁盘上的位置,并通知引擎该特定页面存储的字节数较少。删除操作同理。

回收页面也不需要向它们写入数据。

然而,压缩确实需要重写页面。更多细节见 doc/storage.txt。

如何使用?

-o ext_path=/data/file:5G 这就足以让你开始了。参见 memcached --help 了解其他选项。此 wiki 将更新更多关于选项作用的详细信息,但大多数用户应该能够使用最小选项子集。如果你有高性能设置,请随时联系我们,我们可以指导你进行配置。

上述命令创建一个 5GB 的外部缓存。由于 shim 的工作原理,由于随时间产生的碎片,你实际上无法存储完整的 5GB。后台任务将尝试控制碎片。

这真的安全吗?

在开发功能时,我进行了非常激进的测试。memcached 的一般稳定性应证明其高质量。此功能仍处于实验阶段,但对于其支持的操作,已经过了一段时间的“烘焙”。请记住,许多功能对发送到磁盘的项是禁用的(incr/decr/append/prepend)。

技术分解

本节详细介绍外部存储的架构和权衡。

目标

  • 主要目标:牢记“缓存模式”,通过最小化对存储设备的访问来充分利用它。
  • 较大的值(通常 1k+)可以使用闪存。小值保留在 RAM 中。
  • 单次读取服务单个键。
  • 异步批量写入驱动器。
  • 主哈希表是权威的:未命中/删除/覆盖必须不使用驱动器。
  • 向缓存写入新项绝不能阻塞在闪存设备上。
  • 如果 extstore 写入器滞后,项应从 RAM 中的 LRU 尾部驱逐。
  • 从存储中遗忘/驱逐数据必须不使用驱动器。

假设

为了将闪存用作缓存介质,必须对访问模式做出一些假设。写入是昂贵的(对驱动器具有破坏性),且缓存通常比数据库有更高的换档率。闪存还会增加获取每个键的延迟,这使得在闪存上缓存小值相比对高端数据库的范围获取来说效率较低。

  • 对于触及磁盘的数据,具有较长的 TTL(以及良好的重用)。
  • 对于小项,延迟开销相对于存储它们的好处来说很高。
  • 对于较大的项,延迟开销比未命中要好(在合理范围内)。
  • 少数数据是热的(高访问),其余是长尾的冷数据。

架构

对 memcached 中的主要线程和数据结构的回顾:

THREADS 和 STRUCTURES 部分展示了 Listen、Worker、LRU Maintainer、LRU Crawler、Slab Page Mover 等线程与 Hash Table、LRU、Slab Allocator 等结构的交互。

  • Listen 线程接受新连接,然后向 pipe 写入字节将 socket 传递给 workers。每个 worker 都有自己的“notifier pipe”,通过 libevent 轮询接收事件。此机制被 extstore 复用,允许 IO 请求在 IO 线程之间传递。
  • 客户端连接在其生命周期内固定在某个 worker 线程上。每个 CPU 一个 worker 线程是理想的。
  • LRU maintainer 线程在 sub-LRU(hot|warm|cold|etc)之间移动项。它还决定何时安排页面移动或 LRU 爬取。
  • LRU crawler 收割过期/无效项,包括 extstore 使无效的项。
  • 内存被划分为具有固定块大小的 slab 类。固定大小(1M)的页面被分配给每个 slab-class,并按需划分为块。随着时间推移,平均项大小或访问模式的偏移可能会“饥饿”某个类。Page mover 线程能够安全地在 slab 类之间重新分配页面以避免饥饿。
  • 其他线程:hash-table-expander, logger。这些不与 extstore 交互。

结构

  • 哈希表是链式分桶的。“next”嵌入在项内存中。
  • LRU 是双向链式的,next/prev 嵌入在项内存中。
  • 每个 slab 类有自己的 LRU。
  • 每个 LRU 分为 HOT|WARM|COLD|TEMP。

现在,结合 extstore:

THREADS 部分展示了 Storage、Compaction、IO 1/2 等线程与 Hash Table、LRU、Slab Allocator、Extstore Page Memory、Extstore Write Buffers 等结构的交互。

  • Storage 线程类似于 LRU maintainer(它们稍后可能会合并)。它检查每个 slab 类的 LRU 尾部。它向 extstore 请求写缓冲区,对项数据进行 CRC32,然后将写缓冲区交还 extstore。
  • Compaction 线程针对准备好压缩的页面。它从存储中读回页面数据,遍历其中的项,要么丢弃它们,要么重写到新页面。更多细节见下文。
  • 存在多个 IO 线程,它们代表 worker、storage 和 compaction 线程向底层存储发出 pread/pwrite 等调用。每个 IO 线程有一个简单的堆栈队列和互斥锁。
  • 还有一个“extstore maintenance thread”,仅为其他线程排序页面数据。它可能会消失并成为 LRU maintainer 的 API 调用。

结构

  • Extstore 有一些自己的数据结构。主要是统计计数器和页面数据。
  • 磁盘上的页面(默认 64M)是文件或设备的子章节。每个页面在内存中有一个小型结构。内存中的结构用于避免在对象从页面删除时写入闪存。一旦页面完全为空,即可回收,因此页面可以回收而无需读回或写回。
  • Extstore 有写缓冲区,项由 storage 或 compaction 线程写入其中。当写缓冲区满时,写入会暂时失败,直到缓冲区发送给 IO 线程。一旦刷写完成,写入再次被接受到缓冲区中。这用于对存储写入进行限速。

项如何流向存储:

  • 从 LRU 尾部开始。判断项是否足够大?内存是否低?是否有可用的写缓冲区?
  • 写入缓冲区后:分配新的小项,将 key+metadata 复制到新项,新项还包含指向闪存的指针 [page, offset, version]。如果写缓冲区已满:用新项替换原项。
  • 写缓冲区发送给 IO 线程。刷写期间所有写入等待。
  • 如果写入存储期间所有内存耗尽,项将从内存中的 LRU 尾部驱逐。例如:slab class 10 中等待写入存储的项将被直接驱逐。缓存的 Sets 绝不能阻塞在存储上。

项如何从存储读取:

  • 哈希表查找。如果项是闪存头部:为 extstore 创建 IO 请求,继续解析请求中的其他键。
  • 在向客户端写入响应之前,如果它包含 IO 对象:Worker 线程 -> Libevent poll -> IO thread -> Worker notify pipe。
  • 在多获取中,可以同时向 IO 线程发送许多 IO 对象。典型的闪存设备内部并行,且对批量请求具有相似的延迟。
  • Worker 在解析请求中的每个键时,用响应数据填充 iovec。对于每个项头部,在数据所在的位置添加空白 iovec。
  • 从 slab 内存中分配一个临时项来保存完整项。如果项已失效(页面被驱逐等),IO 线程将在返回给 worker 前在 IO 对象中记录一次未命中。命中时,临时项填充 iovec 中的空洞;未命中时,受影响的 iovec 条目被置为 NULL,清除响应。请求结束时释放临时内存。
  • 从存储读取的项如果在不到一分钟内被多次读取,有机会被重新缓存到内存中。这允许偶尔访问的项保留在存储上。当它们被重新缓存到主内存时,其闪存条目被删除。

存储页面的生命周期:

存储页面是文件中的偏移量,每页默认大小为 64MB。未来版本应允许多个文件(从而多个设备)用于相同的页面池。截至本文撰写时,仅使用缓冲 IO。O_DIRECT 和异步 IO 正在计划中。每个页面在使用时被分配一个版本号。当页面被回收或驱逐时,它会获得一个新的版本号。内存中的项头部存储:[PAGE ID, OFFSET IN PAGE, VERSION NUMBER]。版本号用于验证从存储获取的对象是否仍然有效。

页面组织到逻辑“桶”中。在 extstore shim 中,桶是任意数字。Memcached 的高层赋予桶意义:

  • OPEN [BUCKETS] FULL
  • P9 -> DEFAULT -> P0,P1,P2,P5,P6
  • P10 -> COMPACTED -> P3,P4
  • P11 -> LOW TTL -> P7,P8
  • FREE: P12,P13...
  • 桶用于将数据合并到相关的页面中。默认情况下,所有项写入桶 0 [DEFAULT]。
  • 如果项在压缩期间被重写(见下文),它被写入 [COMPACTED],这些为经受住压缩的项保留。
  • 如果项在刷写时具有较低的剩余 TTL(参见 ext_low_ttl 选项),它将被写入 [LOW TTL] 桶。此桶被排除在压缩之外。随着项过期,页面定期回收。
  • [COMPACTED] 页面往往包含生命周期很长的对象,因此这些页面的碎片较少,需要重写的频率较低。
  • [LOW TTL] 页面从不被压缩,这以随时间损失一些空间为代价避免了写放大。
  • 在未来,[LOW TTL] 可能会分裂成多个桶。例如分裂为 low, med, high,将允许非常低的页面更快回收,同时避免对较高 TTL 的写放大。

删除和覆盖如何工作:

  • 页面在内存中跟踪几个计数器。存储的字节数和存储的对象数。页面无从获取实际存储在其中项的方法。
  • 从页面删除对象会减少与该页面关联的内存计数器中的对象和字节计数。当页面首次填充时同样如此,计数器递增。
  • 当页面中的字节和对象达到零时,它被立即收割并放到自由列表上。删除或覆盖永远不会导致对存储的新写入。

压缩线程:

  • 首先,找到一个压缩候选项。例如:页面空余超过 50%。(由 ext_max_frag 调整)。
  • 由于页面按写缓冲区大小对齐写入,线程以写缓冲区大小为块从页面读回数据(例如:8M)。
  • 缓冲区线性遍历。由于项被完全写入存储,读缓冲区可以被解析为项结构。检查完一项后,通过按项大小推进指针找到下一项。
  • 当检查一项时:从存储中检索预先计算的键哈希,加快读回速度。获取项锁,检查哈希表中的原始项。如果项存在,且仍然是指向同一页面同一版本的项头部,则排队重写。如果启用了 ext_drop_unread,只有最近访问过的有效项(具体而言;不在 COLD LRU 中)才会被救回。如果大量数据正通过存储写入,drop_unread 很重要。这确保那些正在被请求但频率不足以重新缓存到内存的对象不会丢失。

内存阈值:

  • Slab page balancer 具有针对 extstore 配置的特定算法。供参考,存在该算法的 Python 和内部 C 版本。
  • 在调整该算法更改时,可以轻松禁用内部算法并使用外部脚本。
  • 由于 extstore 系统设计为在项从内存移除前将其发送到存储,因此它需要管理一个空闲空间缓冲区。1% 或 0.5%。
  • 在每个 slab 类中,分配给该类的 0.5% 块“应该空闲”。
  • 总体而言,(1M) 内存页面中的 0.5% 应保留在全局页面池中,按需分配。
  • 这些目标允许 storage 线程对突发流量做出反应而不必丢弃内存。如果空闲内存降至阈值以下,storage 线程将努力赶上。
  • 项头部使用全局页面池中的内存,确保当项被推送到存储时内存可用。
  • 如果全局池低,页面从较大的 slab 类中回收。注意:当前算法不阻止小项占用所有内存。如果项相对较小,或者与 RAM 相比有大量存储空间可用,将没有内存来保存其他项。这将导致闪存受到更严厉的访问,因为非常近期的项被写出。未来可能会引入上限。

权衡

[TODO]

  • 读放大。
  • 压缩期间的完整读回。
  • 写放大。
  • 无小项。
  • TLS 支持。

评论 (0)