阅读视图

发现新文章,点击刷新页面。

[译] SSD 是如何工作的:固态硬盘内部结构与工作原理的动画展示(2020)

译者序

本文翻译自 2020 年 Branch Education 的一个科普视频 How do SSDs Work? How does your Smartphone store data? Insanely Complex Nanoscopic Structures!, 强烈推荐观看原视频。本文整理个图文版方便查阅与思考。

水平及维护精力所限,译文不免存在错误或过时之处,如有疑问,请查阅原视频。 传播知识,尊重劳动,年满十八周岁,转载请注明出处

以下是译文。



手机的存储、平板电脑的存储、SSD 硬盘,其实都类似,核心都是一个固态(Solid State) 存储芯片

称为“固态”是相对于旋转(rotational)磁盘(也就是普通 HDD 硬盘)那种“动态”而言的。

本文将深入到这个芯片内部,看看它是如何工作的。

1 存储材料 & 结构:Charge Trap

将 SSD 芯片放大到纳米级,就能看到它存储电荷的基本结构

  • 根据技术路线的不同,存储结构/材料的选择也不同,
  • 本文介绍的是比较新的一种,称为 Charge Trap(电荷捕获,或电荷陷阱), 它使用的是氮化矽(silicon nitride),这是一种绝缘体

下图中的“工”字结构就是 Charge Trap,它的基本原理是将电子吸附到氮化矽上, 吸附的电子数量不一样,电荷的高低就不一样,从而可以用于表示不同的数字,

图中黄色部分就是吸附的电子,

  • 较老的技术只能存储2 个不同的电荷级别,即电子很多或很少, 因此只能表示两种数值,也就是 1bit 01
  • 较新的 Charge Trap 可以存储 8 个或 16 个电荷级别, 也就是每个 Charge Trap 可以表示 3bit 或 4bit

被吸附的电荷可以保持几十年之久,这也是它被称为电荷陷阱的原因。

2 SSD 芯片硬件组成

下面从小到大,看看是如何基于 Charge Trap 这样一个最基本单元构建出一个最终的 SSD 芯片的。

2.1 Charge Trap -> 基本存储单元 Memory Cell

Charge Trap 是 SSD 的基本存储单元 —— memory cell —— 的核心。

在本文接下来的内容中,我们假设一个 charge trap 支持 8 个不同的电荷级别,也就是说可以表示 3bit, 比如吸附的电子很少对应 111,吸附的电子很多对应 000

下面简单介绍下读取和删除数据对应的底层操作。

2.1.1 读取数据

读取一个 memory cell 存储的数据,就是测量这个 Charge Trap 上的的电荷量

这需要先通过 control gate 锁定该 Charge Trap,然后信息就可以从中间的传输线送上去。 后面会详细介绍。

2.1.2 删除数据

删除一个 memory cell 存储的数据,就是清除这个 Charge Trap 上的的电荷量, 使其回到最低电平(111)。

2.2 纵向堆叠 Memory Cell -> String

有了能表示 3bit 的基本单元,接下来我们将 N 个 cell 垂直堆叠起来, 就得到一个称为 String(“串”)的结构。

下图是 10 个 memory cell 堆叠成的 string,

一个 String 内的所有 cell 共享顶部的 bit line(“bit 传输线”,读取或写入 cell 数据的线),

一个 String 有很多 cell,但它们共享同一根 bit line, 因此,在任一时间只能激活 String 中的一个 cell。为此,需要引入了 control gate

  • control gate 控制 String 上的哪个 cell 可以读写数据,此时称为“激活”状态; 如上图所示,读取第 10 层的 cell 信息时,就激活第 10 层的 control gate:
  • 但注意,control gate 只是用来激活 cell,而不是用来读取 cell 的信息: 比如在读数据场景,被激活的 cell 会将它保存的信息通过 String 中心的数据线(每个“工”字的中心线)发送给顶部的 bit line

2.3 横向堆叠 Memory Cell -> Page

将多个 String 水平连到一起,就得到一个二维 cell 空间。

横向的每一排 memory cell,称为一个 Page(“页”),如下图所示:

2.4 String+Page 组成 2D 存储矩阵 -> Row

String+Page 组成的 2D 存储矩阵,称为 Row(虽然在这里直觉上叫“Page”更合适,后面会看到这个名称的由来),

2.4.1 bit line 和 control gate

再来看下 bit-line/control-gate 和 String/Row 的关系,

  • 每个 String 有独立的 bit line;
  • 每个 Row 上的所有 cell 共享一个 control gate,

2.4.2 读写一个 Page:仅需一次 control gate 操作

由上图可知,向 Row 写入或读取数据时,横向的 cell 能同时被激活,它们能通过顶上的 bit lines 并行传输。

换句话说,一个 Page 内的数据仅需一次操作就能全部读出或写入

2.5 多个 Row(2D)堆叠成 3D 存储模块 -> Block

将 N 个 Row 并排连起来,就得到一个 block。下面是 6 个 Row 组成的 block,

下面是 12 个 Row 组成的 block,

2.5.1 渲染图(3D-NAND / V-NAND)

这种立体的 Block 有个专业名词叫 3D-NANDV-NAND(垂直堆叠 NAND), 以为以前的芯片都是二维的,

NAND 本身是 Not AND(“与非”门)的缩写,是一种逻辑门,后来泛指一类存储技术。

2.5.2 Block 能存储多少数据:~1.5KB

现在让我们来算一下,一个 block 能存储多少数据。

  • 3bit/cell
  • 10 cells/string
  • 32 cells/page
  • 6 rows/block
  • 2 block

最终是 3,840 个 memory cell, 总共能够存储 11,520 bit,约 1.4KB

2.6 小结

回顾下我们目前为止介绍的所有概念,

从小到大的结构是:cell -> String / Page -> Row -> Block。 这里还有 Column 和 Layer 的概念,这个图加上这俩概念,就不难理解为什么一个 2D cell 矩阵叫 Row 而不叫 Page 了。

3 真实 SSD 产品的参数

3.1 Block

3.1.1 高度(Cells per String):100~200 cells

图中画的是 96~136 层高,右边是一张纸,可以直观理解 100~200 层大概是什么概念。

3.1.2 宽度(Cells per Page): 30K~60K cells

一个 Page 的宽度约为 30,000~60,000 个 memory cell。

这意味着有 30,000~60,000 可并行读写的 bit lines

3.1.3 深度(Rows per Block):4~8 Rows

4~8 个 Row 组成一个 Block,

3.2 Blocks per Chip Unit: 4K~6K

一个最基础的芯片单元有大约 4000~6000 个 Block(后面还将重复这个基础单元很多次,最终封装成一个芯片)。

3.3 Row decoder, Page Buffer

  • 两侧的 control gate & bit line selector 组成了所谓的行解码器,通过这两组选择器就可以访问任意 Page
  • 一个 Page(约 45,000 个 memory cell)能同时使用上方并行的 bit line 来读取或写入信息;
  • 上万条 bit line 将 Page 中的数据送到 Page cache

下图是对应到实际芯片的结构,

图中的产品为了提高存储容量,将 3.2 介绍的模块复制了一倍。 这样一个模块的读写速度约为 500MB/s

3.4 多层 Chip Unit,封装到最终的一块 SSD 芯片

为了进一步提高存储容量,在一个芯片中放 8 个(层)上一节那样的子芯片, 然后通过外围接口芯片(下图最左侧)来协调这 8 个子芯片,

这样一个结构再加个外壳封装,才是我们拆开 SSD 时在电路板上看到的芯片


Written by Human, Not by AI Written by Human, Not by AI

[译] HDD 是如何工作的:旋转硬盘内部结构与工作原理的动画展示(2022)

译者序

本文翻译自 2022 年 Branch Education 的一个科普视频 How do Hard Disk Drives Work? (Youtube), 强烈推荐观看原视频(上不了油管的,B 站也有搬运)。本文整理个图文版方便查阅与思考,

水平及维护精力所限,译文不免存在错误或过时之处,如有疑问,请查阅原视频。 传播知识,尊重劳动,年满十八周岁,转载请注明出处

以下是译文。



原视频由 PCBWay 赞助,感谢赞助商。

1 硬盘拆解

1.1 盘片(platter)

盘片是存储数据的地方,

Disk/platter

  • 根据存储容量的不同,硬盘可能会有多个盘片堆叠,如上面右图所示;
  • 磁盘由铝镁合金(aluminum magnesium alloy)和其他合金的多个涂层组成,

    Disk/platter

  • 磁性功能层是 120nm 的钴铬钽合金薄层(cobalt chromium tantalum alloy), 它由磁性微块组成,磁极方向能变,

    Disk/platter

  • 盘片安装在主轴上,主轴使用中心的无刷直流电机(brushless DC motor)以 7200rpm 的等速度旋转。

1.2 机械臂装置

机械臂装置包括好几个组成部分,分别来看下。

1.2.1 机械臂(arm)

每个盘(platter)上下各有一个臂(arm),

1.2.2 滑橇(slider)和读写头(read/write head)

每个臂的末端有一个称为 slider(滑橇、滑块)的模块,它里面又包括了一个读/写头 (注意,读头和写头是分开的两个部件,后面会详细介绍),

磁盘高速旋转产生的气流能使这个滑块(和读写头)浮起来, 稳定运行在离磁盘表面 15nm(约 100 个原子)的地方,如下面的动图所示,

Fig. 高速旋转的盘片产生的气流使滑橇和读写头飘起来

1.2.3 读写头停靠装置

只有当盘片全速旋转时(有数据读写任务),机械臂才会转到磁盘表面上。 平时盘片不旋转时(没有读写任务),机械臂会停在磁盘边上的一个小塑料装置上。

1.2.4 尾部音圈电机(马达)

机械臂的尾部有一个 音圈电机(voice coil motor),或称音圈马达,它由线圈(coil of wire)和上下两个强钕磁铁(strong neodymium magnets)组成,

VCM(Voice Coil Motor)一种特殊形式的直接驱动电机,原理和扬声器类似,固得名。 通电线圈在磁场内就会产生力,力的大小与施加在线圈上的电流成比例,运动轨迹可以是直线也可以是弧线。 具有结构简单、体积小、速度快、响应快等特点。译注。

线圈通电之后会产生一个力,使机械臂在磁盘上移动(可以正向也可以反向),

这种马达的速度和精度:

  • 速度:读/写头能够在不同磁道上来回移动 ~20 次/秒
  • 精度:读/写头位置精度 ~30nm

1.3 机械臂-电路板之间的数据线

如下图所示,一条柔性电线(a flexible ribbon of wires)沿着机型臂的侧面布线,

  • 一边连接到读/写头
  • 一边连接到一个连接器(connector),该 connector 进一步连接到硬盘的主板,或称印刷电路板(PCB)。

1.4 PCB 和上面的芯片

PCB 上面的东西如下图所示,

这里主要介绍三个芯片:

  1. 主处理器芯片
  2. 内存芯片,作为主处理器的 cache;
  3. 控制音圈马达磁盘主轴电机的芯片。

1.5 数据线接口(e.g. SATA)和电源线接口

PCB 边缘还有两个硬件接口,

  • 数据接口:例如 SATA 接口,用于和电脑主板相连传输数据;
  • 电源接口:用于给 HDD 供电。

1.6 防尘装置

再看一下硬盘的两个防尘装置,

  1. 垫圈:将磁盘密封起来;
  2. 灰尘过滤器:用于捕获灰尘颗粒。

密封和过滤都是非常必要的,因为读写头距离盘片仅 15nm, 而灰尘颗粒的大小可达 10,000nm, 如果与 7200rpm 高速旋转磁盘碰撞,可能会造成严重损坏,

Fig. 读写头正常运行时,距离盘片仅 15nm。

2 盘片的微观组成

了解了粗粒度的硬件构成之后,现在让来深入到盘片的内部,看看它的微观组成。

2.1 磁盘(disk) -> 磁道(track)

首先,每个磁盘以同心圆的方式分割为多个磁道(concentric circles of tracks),

Fig. 磁盘分割为大量磁道。

每个磁盘的磁道数量能达到 500,000 个甚至更多。

2.2 磁道(track) -> 扇区(sector)

然后,沿着直径的方向,所有磁道又被分割为多个扇区,

Fig. 磁道进一步分割为扇区。

2.3 扇区内

现在看一下每个扇区内的结构,

Fig. 每个扇区的内部结构。

如上图所示,每个扇区中,依次包含五部分。

2.3.1 前导/同步区(preamble or synchronization zone)

记录这个旋转磁盘的确切速度每个比特位的长度(length of each bit of data)。

2.3.2 地址区

帮助读/写头确定当前位于哪个磁道和扇区

2.3.3 数据区

扇区大小

扇区的大小因盘而异,例如老一些的盘是 512 字节或 2KB,新一些的通常是 4KB。

查看磁盘扇区大小(译注)

有很多工具可以查看,lsblk 指定显示磁盘名字、物理扇区大小和逻辑扇区大小:

$ lsblk -o NAME,PHY-SeC,LOG-SeC
NAME                   PHY-SEC LOG-SEC
sda                       4096     512  # 这块是 SATA SSD
sdb                        512     512  # 这块是 SATA HDD

fdisk -l,这个命令好记:

$ fdisk -l
Disk /dev/sdb: 2.18 TiB, 2399276105728 bytes, 4686086144 sectors
Disk model: XXX                                                    # 硬盘型号
Units: sectors of 1 * 512 = 512 bytes                              # 当前扇区大小
Sector size (logical/physical): 512 bytes / 512 bytes              # 逻辑值 & 物理支持的最大值
I/O size (minimum/optimal): 512 bytes / 512 bytes

iostat 磁盘读写带宽(译注)

可以通过 cat /proc/diskstats 查看磁盘的读写情况,其中就包括了每个磁盘已经读写的 sectors 数量:

$ cat /proc/diskstats
#                            r_sectors               w_sectors
   8       0 sda 31663 10807 2928442     8471 203024 106672     6765800 ...

这个数量乘以 sector 大小,就是已经读写的字节数,iostat 等工具显示的磁盘读写带宽,就是根据这个来计算(估算)的。

一个扇区只会属于一个文件(译注)

根据 wikipedia Disk sector, 对于绝大部分文件系统来说,任何一个文件都是占用整数个扇区的 —— 也就是说一个扇区只会属于一个文件, 如果没用满,后面的就空着。所以在调整扇区大小时,这是一个需要考虑的因素。

扇区与 block 的关系(译注)

这里说的 block 是文件系统的概念,比如常见的一个 block 是 4KB,如果磁盘格式化的时候,扇区大小选择的 512B,那一个 block 就对应 8 个扇区。 对操作系统屏蔽了底层的硬件细节。

2.3.4 纠错码(ECC)区

Fig. 每个扇区的内部结构。

用于校验存储在块中的数据。

2.3.5 扇区之间的间隔区

给了读/写磁头一定的容错能力。

3 写数据

现在让我们进一步看看读/写磁头的内部机制,以及写头(write head)是是如何写数据的。

3.1 磁场微块和磁化

扇区是由一个个磁场微块组成的, 写头通过改变磁盘微块的磁化方向来实现数据写入,

每个磁盘微块大小约为 90nm x 100nm x 125nm

磁化之外,微块内原子的南北极是随机的; 磁化之,微块所有原子的北南极都指向同一方向,

每个微块对应的就是一个 bit 数据,

3.2 写入 1bit 的过程

下面具体看一下如何磁化一个微块(相当于写入 1bit 数据)。

电流施加到 write head 的线圈之后,就会在此处产生一个强磁场,

这个磁场沿着 write head 向下,聚焦到尖端的一个小点,改变它正下方的磁盘微块极性 (中间的缝隙就是前面提到过的读写头 15nm 悬浮高度),

磁化之后的微块变成永磁体,能保持这个状态很多年,也就是数据已经持久化, 以后可以重复用读头感应这个永久磁场,读出存储的数据。

3.3 覆盖写

原理跟上面一样,也是逐 bit 来。 如果新写入的 bit 跟已经存储的一样,磁极就不变,否则就改变一下方向。

4 读数据

再来看看如何从磁盘读数据。

4.1 如何表示 0 和 1

4.1.1 不是用南北极指向表示

前面我们假设了不同南北极的磁块分别表示 0 和 1,

这在概念上非常简单,但实际实现并非如此。

4.1.2 用南北极指向的变化表示

实际的 read head,检测的是相邻两个微块的磁极变化, 这是因为磁极变化的强度单个微块的磁场强度要大得多,所以这种方式的检测准确率非常高

所以,如上图所示,

  • 相邻微块磁场方向变化,表示 1;
  • 相邻微块磁场方向不变,表示 0。

4.2 读头(read head)内部结构

那么,检测这些磁场的读头内部结构是怎样的呢?

如上图所示,

  • 读头里面是多层导电材料,由铁磁材料和非磁性材料的交替组成。
  • 这种多层材料具有一种称为巨磁阻(giant magnetoresistance, GMR)的特性, 简单来说,穿过它的磁场强度发生变化时,它的电阻率就会变化

4.3 读取数据:GMR 和读头电阻率

基于 GMR 特性,根据读头的电阻率就能判断下面存储的 0 还是 1,

  • 电阻率较低时,表示读取头下方磁场变化强,对应存储的是 bit 1
  • 电阻率较高且无磁场时,对应存储的是 bit 0

4.4 连续 0 的问题

以上过程有一个问题:如果较长连续区域的磁极都一样,对应的就是一长串的 0,由于读头的精度,有可能会导致多读或少读几个 0,导致数据错乱。

解决方少:利用每个 sector 的前导区和纠错码区中的信息。

5 致谢

原作者 Branch Education 感谢所有个人赞助者和会员赞助商,让他们制作了如此精良的科普视频。

6 Linux 存储相关的子系统和软件栈(译注)

6.1 从进程 read/write 请求到 HDD 读写数据

来自 Linux Storage Stack Diagram, 涵盖了 3.x ~ 6.x 多个内核版本,这里先贴一个 3.x 的,因为简单, 方便看出从用户进程发出 read/write 请求到 HDD 读写数据的内核模块链路:

虚拟文件系统(VFS)里面分为几类:

  1. 常规文件系统(ext4, xfs, btrfs, …);
  2. 网络文件系统(NFS, CIFS, …);
  3. 伪文件系统(procfs, sysfs, …);
  4. 特殊文件系统(tmpfs, devtmpfs, …)。

再贴一个 kernel v6.9 的,

6.2 内核 block layer 深入解读

  1. A block layer introduction part 1: the bio layer, LWN.net, 2017
  2. A block layer introduction part 2: the request layer, LWN.net, 2017

6.3 其他优质文章

  1. How does a hard drive work, https://www.explainthatstuff.com/, 2024

    除了硬件拆解和介绍工作原理,还对比了 HDD 和 SDD,并且更重要的,介绍了 IBM 发明硬盘的历史

  2. How a Hard Drive Works, cs.stanford.edu, 2012

    斯坦福的一个老师实物教学,开盖展示读写数据时,硬盘的工作过程(然后这个盘就报废了)。

  3. HDD from Inside: Hard Drive Main Parts, https://hddscan.com/

    硬件拆解部分比本文更详细,想了解更多硬件细节的,可作为补充。


Written by Human, Not by AI Written by Human, Not by AI

直观解读 JuiceFS 的数据和元数据设计(三):看山还是山(2024)

本系列分为三篇文章,试图通过简单的实地环境来直观理解 JuiceFS 的数据(data)和元数据(metadata)设计。

Fig. JuiceFS object key naming and the objects in MinIO.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 如何从数据和元数据中恢复文件

1.2 理论步骤

对于一个给定的 JuiceFS 文件,我们在上一篇中已经看到两个正向的过程:

  1. 文件本身被切分成 Chunk、Slice、Block,然后写入对象存储;
  2. 文件的元数据以 inode、slice、block 等信息组织,写入元数据引擎。

有了对正向过程的理解,我们反过来就能从对象存储和元数据引擎中恢复文件: 对于一个给定的 JuiceFS 文件,

  1. 首先扫描元数据引擎,通过文件名、inode、slice 等等信息,拼凑出文件的大小、位置、权限等等信息;
  2. 然后根据 slice_id/block_id/block_size 拼凑出对象存储中的 object key;
  3. 依次去对象存储中根据这些 keys 读取数据拼到一起,得到的就是这个文件,然后写到本地、设置文件权限等等。

但这个恢复过程不是本文重点。本文主要看几个相关的问题,以加深对 JuiceFS 数据/元数据 设计的理解。 更多信息见官方文档 [2]。

1.2 juicefs info 查看文件 chunk/slice/block 信息

JuiceFS 已经提供了一个命令行选项,能直接查看文件的 chunk/slice/block 信息,例如:

$ ./juicefs info foo-dev/file2_5MB
foo-dev/file2_5MB :
  inode: 3
  files: 1
   dirs: 0
 length: 5.00 MiB (5242880 Bytes)
   size: 5.00 MiB (5242880 Bytes)
   path: /file2_5MB
 objects:
+------------+--------------------------------+---------+--------+---------+
| chunkIndex |           objectName           |   size  | offset |  length |
+------------+--------------------------------+---------+--------+---------+
|          0 | foo-dev/chunks/0/0/3_0_4194304 | 4194304 |      0 | 4194304 |
|          0 | foo-dev/chunks/0/0/3_1_1048576 | 1048576 |      0 | 1048576 |
+------------+--------------------------------+---------+--------+---------+

和我们在 MinIO 中看到的一致。

2 如何判断 {volume}/chunks/ 中的数据是否是合法

bucket 中的数据是 JuiceFS 写入的,还是其他应用写入的呢? 另外即使是 JuiceFS 写入的,也可能有一些数据是无效的,比如 size 为 0 的 block、超出所属 slice 范围的 block 等等。 我们来看看基于哪些规则,能对这些非法数据进行判断。

2.1 原理

准备工作:

  1. 从 JuiceFS 的元数据引擎中读取所有 slice size,这对应的是元数据信息
  2. 从 object storage 中读取所有 object key,这对应的数据信息

接下来,根据几条标准,判断 bucket 中 {volume}/chunks/ 内的数据是否是合法的 JuiceFS 数据:

  1. 如果 object 不符合命名规范 {volume}/chunks/{slice_id/1000/1000}/{slice_id/1000}/{slice_id}_{block_id}_{block_size}, 那么这个 object 就不是 JuiceFS 写入的;
  2. 如果符合以上命名规范,,那么这个 object 就是 JuiceFS 写入的,接下来,
    1. 如果 object 大小为零,那可以清理掉,因为这种 object 留着没意义;
    2. 如果 object 大小不为零,根据元数据内记录的 slice/block 信息计算这个 block 应该是多大,
      1. 如果大小跟 object 一致,那这个 object 就是一个合法的 JuiceFS 数据(Block);
      2. 否则,说明这个 object 有问题。

这个过程是没问题的,但需要对所有 object 和所有元数据进行遍历和比对,效率比较低。 有没有更快的方法呢?

2.2 改进:pending delete slices

回忆上一篇,在元数据引擎中其实已经记录了待删除的 slice/block 信息, 这里“待删除”的意思是 JuiceFS 中已经把文件删掉了(用户看不到了,volume usage 统计也不显示了), 但还没有从对象存储中删掉,

  • D 开头的记录:deleted inodes
  • 格式:D{8bit-inode}{8bit-length}

这种记录是 JuiceFS 在从 object storage 删除文件之前插入到元数据引擎中的, 所以扫描所有 D 开头的记录,可以找到所有待删除的 slice/block 信息。

2.3 工具:juicefs gc

结合 2.1 & 2.2,就可以快速判断 bucket 中的数据是否是 JuiceFS 合法数据,不是就删掉; 基于 juicefs 已有的代码库,就可以写一个工具 —— 但用不着自己写 —— JuiceFS 已经提供了。

2.3.1 核心代码

完整代码见 pkg/cmd/gc.go

从元数据引擎 list 所有 slice 信息

func (m *kvMeta) ListSlices(ctx Context, slices map[Ino][]Slice, delete bool, showProgress func()) syscall.Errno {
    if delete
        m.doCleanupSlices()

    // 格式:A{8digit-inode}C{4digit-blockID}   file chunks
    klen := 1 + 8 + 1 + 4
    result := m.scanValues(m.fmtKey("A"), -1, func(k, v []byte) bool { return len(k) == klen && k[1+8] == 'C' })

    for key, value := range result {
        inode := m.decodeInode([]byte(key)[1:9])
        ss := readSliceBuf(value) // slice list
        for _, s := range ss
            if s.id > 0
                slices[inode] = append(slices[inode], Slice{Id: s.id, Size: s.size})
    }

    if m.getFormat().TrashDays == 0
        return 0

    return errno(m.scanTrashSlices(ctx, func(ss []Slice, _ int64) (bool, error) {
        slices[1] = append(slices[1], ss...)
        if showProgress != nil
            for range ss
                showProgress()
        return false, nil
    }))
}

从对象存储 list 所有 objects 信息

    // Scan all objects to find leaked ones
    blob = object.WithPrefix(blob, "chunks/")
    objs := osync.ListAll(blob, "", "", "", true) // List {vol_name}/chunks/ 下面所有对象

遍历所有 objects,跟元数据引擎中的 slice 信息比对

    for obj := range objs {
        // key 格式:{slice_id/1000/1000}/{slice_id/1000}/{slice_id}_{index}_{size}
        parts := strings.Split(obj.Key(), "/")     // len(parts) == 3
        parts = strings.Split(parts[2], "_")       // len(parts) == 3

        sliceID, _ := strconv.Atoi(parts[0])       // slice id, JuiceFS globally unique
        blockID, _ := strconv.Atoi(parts[1])       // blockID in this slice
        blockSize, _ := strconv.Atoi(parts[2])     // block size, <= 4MB
        sliceSizeFromMetaEngine := sliceSizesFromMetaEngine[uint64(sliceID)]       // tikv 中记录的 slice size

        var isEmptySize bool
        if sliceSizeFromMetaEngine == 0 {
            sliceSizeFromMetaEngine = sliceSizesFromTrash[uint64(sliceID)]
            isEmptySize = true
        }
        if sliceSizeFromMetaEngine == 0 {
            foundLeaked(obj)
            continue
        }

        if blockSize == chunkConf.BlockSize { // exactly 4MB
            if (blockID+1)*blockSize > sliceSizeFromMetaEngine
                foundLeaked(obj)
        } else {                              // < 4MB
            if blockID*chunkConf.BlockSize+blockSize != sliceSizeFromMetaEngine 
                foundLeaked(obj)
        }
  1. slice size 为 0,说明这个 slice 在元数据引擎中被 compact 过了;
  2. slice size 非零,
    • block size == 4MB,可能是也可能不是最后一个 block;
    • block size != 4MB,说明这个 block 是最后一个 block;

2.3.2 使用方式

$ ./juicefs gc -h
NAME:
   juicefs gc - Garbage collector of objects in data storage

USAGE:
   juicefs gc [command options] META-URL

大致效果:

$ ./juicefs gc tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev
<INFO>: TiKV gc interval is set to 3h0m0s [tkv_tikv.go:138]
<INFO>: Data use minio://localhost:9000/juicefs-bucket/foo-dev/ [gc.go:101]

Pending deleted files: 0                             0.0/s
 Pending deleted data: 0.0 b     (0 Bytes)           0.0 b/s
Cleaned pending files: 0                             0.0/s
 Cleaned pending data: 0.0 b     (0 Bytes)           0.0 b/s
        Listed slices: 6                             327.3/s
         Trash slices: 0                             0.0/s
           Trash data: 0.0 b     (0 Bytes)           0.0 b/s
 Cleaned trash slices: 0                             0.0/s
   Cleaned trash data: 0.0 b     (0 Bytes)           0.0 b/s
      Scanned objects: 37/37 [=================================]  8775.9/s used: 4.268971ms
        Valid objects: 37                            11416.0/s
           Valid data: 134.0 MiB (140509216 Bytes)   41.0 GiB/s
    Compacted objects: 0                             0.0/s
       Compacted data: 0.0 b     (0 Bytes)           0.0 b/s
       Leaked objects: 0                             0.0/s
          Leaked data: 0.0 b     (0 Bytes)           0.0 b/s
      Skipped objects: 0                             0.0/s
         Skipped data: 0.0 b     (0 Bytes)           0.0 b/s

<INFO>: scanned 37 objects, 37 valid, 0 compacted (0 bytes), 0 leaked (0 bytes), 0 delslices (0 bytes), 0 delfiles (0 bytes), 0 skipped (0 bytes) [gc.go:379]

3 问题讨论

3.1 chunk id 和 slice id 的分配

  1. 每个文件都是从 chunk0 开始的;
  2. 实际上没有 chunk id 的概念,只是在查找文件的过程中动态使用,并没有存储到数据和元数据中;

代码里就是直接根据 64MB 计算下一个 chunk id,接下来的读写都是 slice 维度的, slice id 是全局唯一的,会存储到数据(object key)和元数据(tikv keys/values)中。

下一个可用的 sliceID 和 inodeID 记录在 global unique 变量中,初始化:

Register("tikv", newKVMeta)                  // pkg/meta/tkv_tikv.go
                 |-newBaseMeta(addr, conf)   // pkg/meta/tkv.go
                   |-newBaseMeta(addr, conf) // pkg/meta/base.go
                     |-.freeInodes // initialized as default value of type `freeID`
                     |-.freeSlices // initialized as default value of type `freeID`

然后,以写文件为例,调用栈:

Write(off uint64, data)
  |-if f.totalSlices() >= 1000 {
  |     wait a while
  | }
  |-chunkID := uint32(off / meta.ChunkSize) // chunk index, or chunk id
  |-pos := uint32(off % meta.ChunkSize)     // position inside the chunk for writing
  |-for len(data) > 0 {
  |   |-writeChunk
  |       |-c := f.findChunk(chunkID)
  |       |-s := c.findWritableSlice(off, uint32(len(data)))
  |       |-if no wriatable slice {
  |       |     s = &sliceWriter{chunk: c, off: off, }
  |       |     go s.prepareID(meta.Background, false) // pkg/vfs/writer.go
  |       |           |-NewSlice
  |       |               |-*id = m.freeSlices.next    // globally unique ID
  |       |
  |       |     c.slices = append(c.slices, s)
  |       |     if len(c.slices) == 1 {
  |       |         f.refs++
  |       |         go c.commitThread()
  |       |     }
  |       |-}
  |       |-return s.write(ctx, off-s.off, data)
  |         NewSlice // pkg/meta/base.go
  |-}

3.2 JuiceFS pending delete slices 和 background job

3.2.1 设计初衷

引入 pending delete slices 主要是大批量删除场景的性能优化

  1. 每个 JuiceFS 客户端只允许并发 100 的删除操作;
  2. 超过 100 时,自动放入后台队列,由 background job 异步删除;

3.2.2 代码

// pkg/meta/base.go

func (m *baseMeta) fileDeleted(opened, force bool, inode Ino, length uint64) {
    if opened
        m.removedFiles[inode] = true
    else
        m.tryDeleteFileData(inode, length, force)
}

func (m *baseMeta) tryDeleteFileData(inode Ino, length uint64, force bool) {
    if force {
        m.maxDeleting <- struct{}{}
    } else {
        select {
        case m.maxDeleting <- struct{}{}: // maxDeleting 没满,直接删
        default:                          // maxDeleting 满了之后走到这里,直接返回,靠后台任务删
            return // will be cleanup later
        }
    }

    go func() {
        m.en.doDeleteFileData(inode, length)
        <-m.maxDeleting
    }()
}

这个 maxDeleting 初始为一个 100 的 buffered channel,每次删除文件时,会尝试往里面放一个元素,

// pkg/meta/base.go

func newBaseMeta(addr string, conf *Config) *baseMeta {
    return &baseMeta{
        sid:          conf.Sid,
        removedFiles: make(map[Ino]bool),
        compacting:   make(map[uint64]bool),
        maxDeleting:  make(chan struct{}, 100), // 代码里写死了 100
        ...

3.2.3 潜在的问题

后台删除是 JuiceFS client 中的 background job 做的,这个 background job 的开关是可配置的,

$ ./juicefs mount --no-bgjob ... # 关闭 background job

这个开关的控制有点 tricky:

  1. 打开:如果一个 volume 的客户端太多,大家都会去做后台清理,都获取文件锁,对元数据引擎的压力非常大;
  2. 关闭:没有客户端去做后台清理,导致这些文件一直存在于对象存在中,也可以称为文件泄露,使用成本上升。

一种折中的做法:

  • 客户端不太多的 volumes:默认启用 bgjob;
  • 客户端太多的 volumes,默认关闭 bgjob,然后指定特定的 client 开启 bgjob,代表这个 volume 的所有客户端执行清理操作。

3.3 JuiceFS 支持的单个最大文件 128PiB 是怎么来的

从以上定义可以看到,理论上 JuiceFS 支持的单个文件大小是 maxSliceID (int64) * maxChunkSize, 以默认的 maxChunkSize=64MB(2^26 Byte)为例,

  • 理论上限:2^63 * 2^26 = 2^(63+26) Byte
  • 实际上限:2^31 * 2^26 = 2^(31+26) Byte = 128PiB,这个数字来自官方文档

实际上限是 128PiB 的原因也很简单,在代码里写死了

// pkg/vfs/vfs.go

const (
    maxFileSize = meta.ChunkSize << 31
)

3.4 为什么 JuiceFS 写入对象存储的文件,不能通过对象存储直接读取?

这里说的“不能读取”,是指不能直接读出原文件给到用户,而不是说不能读取 objects。

看过本文应该很清楚了,JuiceFS 写入对象存储的文件是按照 Chunk、Slice、Block 进行切分的, 只有数据内容,且保护重复数据,还没有文件信息元信息(文件名等)。

所以,以对象的存储的方式只能读这些 objects,是无法恢复出原文件给到用户的。

3.5 JuiceFS 不会对文件进行合并

Highlight:JuiceFS 不会文件进行合并写入对象存储, 这是为了避免读放大

4 总结

至此,我们对 JuiceFS 数据和元数据设计的探索学习就告一段落了。希望有了这些知识, 用户和工程师在日常的使用和维护 JuiceFS 过程中,看问题和解决问题能更加得心应手。

参考资料

  1. 官方文档:JuiceFS 如何存储文件, juicefs.com
  2. 官方文档:文件数据格式, juicefs.com

Written by Human, Not by AI Written by Human, Not by AI

直观解读 JuiceFS 的数据和元数据设计(二):看山不是山(2024)

本系列分为三篇文章,试图通过简单的实地环境来直观理解 JuiceFS 的数据(data)和元数据(metadata)设计。

Fig. JuiceFS object key naming and the objects in MinIO.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 引言

上一篇从功能的角度体验了下 JuiceFS,这一篇我们深入到背后,看看 JuiceFS 分别在数据和元数据上做了哪些设计,才给到用户和本地文件系统一样的体验的。

2 对象存储中 JuiceFS 写入的文件

本篇以 MinIO 为例,来看 JuiceFS 写入到对象存储中的文件是怎样组织的。 其他云厂商的对象存储(AWS S3、阿里云 OSS 等)也都是类似的。

2.1 Bucket 内:每个 volume 一个“目录”

可以用上一篇介绍的 juicefs format 命令再创建两个 volume,方便观察它们在 bucket 中的组织关系,

Fig. MinIO bucket browser: volume list.

如上图所示,bucket 内的顶层“目录”就是 JuiceFS 的 volumes

我们这里提到“目录”时加双引号,是因为对象存储是扁平的 key-value 存储,没有目录的概念, 前端展示时模拟出目录结构(key 前缀一样的,把这个前缀作为一个“目录”)是为了查看和理解方便。 简单起见,后文不再加双引号。

2.2 每个 volume 的目录: {chunks/, juicefs_uuid, meta/, ...}

每个 volume 目录内的结构如下:

{volume_name}/
  |-chunks/         # 数据目录,volume 中的所有用户数据都放在这里面
  |-juicefs_uuid    
  |-meta/           # `juicefs mount --backup-meta ...` 产生的元数据备份存放的目录

2.2.1 juicefs_uuid:JuiceFS volume 的唯一标识

可以把这个文件下载下来查看内容,会发现里面存放的就是 juicefs format 输出里看到的那个 uuid, 也就是这个 volume 的唯一标识。

删除 volume 时需要用到这个 uuid。

2.2.2 meta/:JuiceFS 元数据备份

如果在 juicefs mount 时指定了 --backup-meta,JuiceFS 就会定期把元数据(存在在 TiKV 中)备份到这个目录中, 用途:

  1. 元数据引擎故障时,可以从这里恢复;
  2. 在不同元数据引擎之间迁移元数据。

详见 JuiceFS 元数据引擎五探:元数据备份与恢复(2024)

2.2.3 chunks/

Fig. MinIO bucket browser: files in a bucket.

chunks/ 内的目录结构如下,

{volume_name}/
  |-chunks/
  |   |-0/                # <-- id1 = slice_id / 1000 / 1000
  |   |  |-0/             # <-- id2 = slice_id / 1000
  |   |     |-1_0_16      # <-- {slice_id}_{block_id}_{size_of_this_block}
  |   |     |-3_0_4194304 #
  |   |     |-3_1_1048576 #
  |   |     |-...
  |-juicefs_uuid    
  |-meta/

如上,所有的文件在 bucket 中都是用数字命名和存放的,分为三个层级:

  1. 第一层级:纯数字,是 sliceID 除以 100 万得到的;
  2. 第二层级:纯数字,是 sliceID 除以 1000 得到的;
  3. 第三层级:纯数字加下划线,{slice_id}_{block_id}_{size_of_this_block},表示的是这个 chunk 的这个 slice 内的 block_id 和 block 的大小。

不理解 chunk/slice/block 这几个概念没关系,我们马上将要介绍。

2.3 小结

通过以上 bucket 页面,我们非常直观地看到了一个 JuiceFS volume 的所有数据在对象存储中是如何组织的

接下来进入正题,了解一下 JuiceFS 的数据和元数据设计。

3 JuiceFS 数据的设计

3.1 顶层切分:一切文件先切 chunk

对于每个文件,JuiceFS 首先会按固定大小(64MB)切大块, 这些大块称为「Chunk」。

  • 这是为了读或修改文件内容时,方便查找和定位
  • 不管是一个只有几字节的文本文件,还是一个几十 GB 的视频文件, 在 JuiceFS 中都是切分成 chunk,只是 chunk 的数量不同而已。

3.1.1 示意图

Fig. JuiceFS: split each file into their respective chunks (with max chunk size 64MB).

3.1.2 对象存储:不存在 chunk 实体

结合上一节在对象存储中看到的目录结构,

{volume_name}/
  |-chunks/
  |   |-0/                # <-- id1 = slice_id / 1000 / 1000
  |   |  |-0/             # <-- id2 = slice_id / 1000
  |   |     |-1_0_16      # <-- {slice_id}_{block_id}_{size_of_this_block}
  |   |     |-3_0_4194304 #
  |   |     |-3_1_1048576 #
  |   |     |-...
  |-juicefs_uuid    
  |-meta/
  1. Chunk 在对象存储中 没有对应任何实际文件,也就是说在对象存储中没有一个个 64MB 的 chunks
  2. 用 JuiceFS 的话来说,Chunk 是一个逻辑概念。暂时不理解没关系,接着往下看。

3.2 Chunk 内的一次连续写入:Slice

chunk 只是一个“框”,在这个框里面对应文件读写的,是 JuiceFS 称为「Slice」 的东西。

  • chunk 内的一次连续写入,会创建一个 slice,对应这段连续写入的数据;
  • 由于 slice 是 chunk 内的概念,因此它不能跨 Chunk 边界,长度也不会超 max chunk size 64M。
  • slice ID 是全局唯一的;

3.2.1 Slice 的重叠问题

根据写入行为的不同,一个 Chunk 内可能会有多个 Slice,

  • 如果文件是由一次连贯的顺序写生成,那每个 Chunk 只包含一个 Slice
  • 如果文件是多次追加写,每次追加均调用 flush 触发写入上传,就会产生多个 Slice

Fig. JuiceFS: chunks are composed of slices, each slice corresponds to a continues write operation.

拿 chunk1 为例,

  1. 用户先写了一段 ~30MB 数据,产生 slice5
  2. 过了一会,从 ~20MB 的地方重新开始写 45MB(删掉了原文件的最后一小部分,然后开始追加写),
    • chunk1 内的部分产生 slice6
    • 超出 chunk1 的部分,因为 slice 不能跨 chunk 边界,因此产生 chunk2slice7
  3. 过了一会,从 chunk1 ~10MB 的地方开始修改(覆盖写),产生 slice8

由于 Slice 存在重叠,因此引入了几个字段标识它的有效数据范围,

// pkg/meta/slice.go

type slice struct {
    id    uint64
    size  uint32
    off   uint32
    len   uint32
    pos   uint32
    left  *slice // 这个字段不会存储到 TiKV 中
    right *slice // 这个字段不会存储到 TiKV 中
}

3.2.2 读 chunk 数据时的多 slice 处理:碎片化和碎片合并

Fig. JuiceFS: chunks are composed of slices, each slice corresponds to a continues write operation.

对 JuiceFS 用户来说,文件永远只有一个,但在 JuiceFS 内部,这个文件对应的 Chunk 可能会有多个重叠的 Slice,

  • 有重叠的部分,以最后一次写入的为准。
  • 直观上来说,就是上图 chunk 中的 slices 从上往下看,被盖掉的部分都是无效的

因此,读文件时,需要查找「当前读取范围内最新写入的 Slice」,

  • 在大量重叠 Slice 的情况下,这会显著影响读性能,称为文件「碎片化」。
  • 碎片化不仅影响读性能,还会在对象存储、元数据等层面增加空间占用。
  • 每当写入发生时,客户端都会判断文件的碎片化情况,并异步地运行碎片合并,将一个 Chunk 内的所有 Slice 合并。

3.2.3 对象存储:不存在 slice 实体

跟 chunk 类似,在对象存储中 slice 也没有 没有对应实际文件

{volume_name}/
  |-chunks/
  |   |-0/                # <-- id1 = slice_id / 1000 / 1000
  |   |  |-0/             # <-- id2 = slice_id / 1000
  |   |     |-1_0_16      # <-- {slice_id}_{block_id}_{size_of_this_block}
  |   |     |-3_0_4194304 #
  |   |     |-3_1_1048576 #
  |   |     |-...
  |-juicefs_uuid    
  |-meta/

3.3 Slice 切分成固定大小 Block(e.g. 4MB):并发读写对象存储

为了加速写到对象存储,JuiceFS 将 Slice 进一步拆分成一个个「Block」(默认 4MB),多线程并发写入。

Fig. JuiceFS: slices are composed of blocks (4MB by default), each block is an object in object storage.

Block 是 JuiceFS 数据切分设计中最后一个层级,也是 chunk/slice/block 三个层级中唯一能在 bucket 中看到对应文件的

Fig. MinIO bucket browser: objects in a bucket.

  • 连续写:前面 Block 默认都是 4MB,最后一个 Block 剩多少是多少。
  • 追加写:数据不足 4MB 时,最终存入对象存储的也会是一个小于 4M 的 Block。

从上图的名字和大小其实可以看出分别对应我们哪个文件:

  1. 1_0_16:对应我们的 file1_1KB
    • 我们上一篇的的追加写 echo "hello" >> file1_1KB 并不是写入了 1_0_16, 而是创建了一个新对象 7_0_16,这个 object list 最后面,所以在截图中没显示出来;
    • 换句话说,我们的 file1_1KB 虽然只有两行内容,但在 MinIO 中对应的却是两个 object,各包含一行。
    • 通过这个例子,大家可以体会到 JuiceFS 中连续写和追加写的巨大区别
  2. 3_0_4194304 + 3_1_1048576:总共 5MB,对应我们的 file2_5MB
  3. 4_*:对应我们的 file3_129MB

3.4 object key 命名格式(及代码)

格式:{volume}/chunks/{id1}/{id2}/{slice_id}_{block_id}_{size_of_this_block},对应的代码,

// pkg/chunk/cached_store.go

func (s *rSlice) key(blockID int) string {
    if s.store.conf.HashPrefix  // false by default
        return fmt.Sprintf("chunks/%02X/%v/%v_%v_%v", s.id%256, s.id/1000/1000, s.id, blockID, s.blockSize(blockID))

    return fmt.Sprintf("chunks/%v/%v/%v_%v_%v", s.id/1000/1000, s.id/1000, s.id, blockID, s.blockSize(blockID))
}

3.5 将 chunk/slice/block 对应到对象存储

最后,我们将 volume 的数据切分和组织方式对应到 MinIO 中的路径和 objects,

Fig. JuiceFS object key naming and the objects in MinIO.

3.6 小结:光靠对象存储数据和 slice/block 信息无法还原文件

至此,JuiceFS 解决了数据如何切分和存放的问题,这是一个正向的过程:用户创建一个文件,我们能按这个格式切分、命名、上传到对象存储。

对应的反向过程是:给定对象存储中的 objects,我们如何将其还原成用户的文件呢? 显然,光靠 objects 名字中包含的 slice/block ID 信息是不够的,例如,

  1. 最简单情况下,每个 chunk 都没有任何 slice 重叠问题,那我们能够根据 object 名字中的 slice_id/block_id/block_size 信息拼凑出一个文件, 但仍然无法知道这个文件的文件名、路径(父目录)、文件权限(rwx)等等信息;
  2. chunk 一旦存在 slice 重叠,光靠对象存储中的信息就无法还原文件了;
  3. 软链接、硬链接、文件属性等信息,更是无法从对象存储中还原。

解决这个反向过程,我们就需要文件的一些元数据作为辅助 —— 这些信息在文件切分和写入对象存储之前,已经记录到 JuiceFS 的元数据引擎中了。

4 JuiceFS 元数据的设计(TKV 版)

JuiceFS 支持不同类型的元数据引擎,例如 Redis、MySQL、TiKV/etcd 等等,每种类型的元数据引擎都有自己的 key 命名规则。 本文讨论的是 JuiceFS 使用 transactional key-value(TKV)类型的元数据引擎时的 key 命名规则。

更具体地,我们将拿 TiKV 作为元数据引擎来研究。

4.1 TKV 类型 key 列表

这里的 key 是 JuiceFS 定义元数据 key,key/value 写入元数据引擎; 请注意跟前面提到的对象存储 key 区别开,那个 key/value 是写入对象存储的

key 是一个字符串,所有 key 的列表,

// pkg/meta/tkv.go

  setting                           format
  C{name}                           counter
  A{8byte-inode}I                   inode attribute
  A{8byte-inode}D{name}             dentry
  A{8byte-inode}P{8byte-inode}      parents // for hard links
  A{8byte-inode}C{4byte-blockID}    file chunks
  A{8byte-inode}S                   symlink target
  A{8byte-inode}X{name}             extented attribute
  D{8byte-inode}{8byte-length}      deleted inodes
  F{8byte-inode}                    Flocks
  P{8byte-inode}                    POSIX locks
  K{8byte-sliceID}{8byte-blockID}   slice refs
  Ltttttttt{8byte-sliceID}          delayed slices
  SE{8byte-sessionID}               session expire time
  SH{8byte-sessionID}               session heartbeat // for legacy client
  SI{8byte-sessionID}               session info
  SS{8byte-sessionID}{8byte-inode}  sustained inode
  U{8byte-inode}                    usage of data length, space and inodes in directory
  N{8byte-inode}                    detached inde
  QD{8byte-inode}                   directory quota
  R{4byte-aclID}                    POSIX acl

在 TKV 的 Keys 中,所有整数都以编码后的二进制形式存储 [2]:

  • inode 和 counter value 占 8 个字节,使用小端编码
  • SessionID、sliceID 和 timestamp 占 8 个字节,使用大端编码

setting 是一个特殊的 key,对应的 value 就是这个 volume 的设置信息。 前面的 JuiceFS 元数据引擎系列文章中介绍过 [3],这里不再赘述。

其他的,每个 key 的首字母可以快速区分 key 的类型,

  • C:counter,这里面又包含很多种类,例如 name 可以是:
    • nextChunk
    • nextInode
    • nextSession
  • A:inode attribute
  • D:deleted inodes
  • F:Flocks
  • P:POSIX lock
  • S:session related
  • K:slice ref
  • L: delayed (to be deleted?) slices
  • U:usage of data length, space and inodes in directory
  • N:detached inode
  • QD:directory quota
  • R:POSIX acl

需要注意的是,这里是 JuiceFS 定义的 key 格式,在实际将 key/value 写入元数据引擎时, 元数据引擎可能会对 key 再次进行编码,例如 TiKV 就会在 key 中再插入一些自己的字符。 前面的 JuiceFS 元数据引擎系列文章中也介绍过,这里不再赘述。

4.2 元数据引擎中的 key/value

4.2.1 扫描相关的 TiKV key

TiKV 的 scan 操作类似 etcd 的 list prefix,这里扫描所有 foo-dev volume 相关的 key,

$ ./tikv-ctl.sh scan --from 'zfoo-dev' --to 'zfoo-dew'
key: zfoo-dev\375\377A\000\000\000\020\377\377\377\377\177I\000\000\000\000\000\000\371
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile1_\3771KB\000\000\000\000\000\372
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile2_\3775MB\000\000\000\000\000\372
...
key: zfoo-dev\375\377SI\000\000\000\000\000\000\377\000\001\000\000\000\000\000\000\371
        default cf value: start_ts: 453485726123950084 value: 7B225665727369...33537387D
key: zfoo-dev\375\377U\001\000\000\000\000\000\000\377\000\000\000\000\000\000\000\000\370
key: zfoo-dev\375\377setting\000\376
        default cf value: start_ts: 453485722598113282 value: 7B0A224E616D65223A202266...0A7D

4.2.2 解码成 JuiceFS metadata key

tikv-ctl --decode <key> 可以解码出来,注意去掉最前面的 z,得到的就是 JuiceFS 的原始 key,看着会更清楚一点,

foo-dev\375A\000\000\000\020\377\377\377\177I
foo-dev\375A\001\000\000\000\000\000\000\000Dfile1_1KB
foo-dev\375A\001\000\000\000\000\000\000\000Dfile2_5MB
foo-dev\375A\001\000\000\000\000\000\000\000Dfile3_129MB
foo-dev\375A\001\000\000\000\000\000\000\000I
foo-dev\375A\002\000\000\000\000\000\000\000C\000\000\000\000
foo-dev\375A\002\000\000\000\000\000\000\000I
foo-dev\375A\003\000\000\000\000\000\000\000C\000\000\000\000
foo-dev\375A\003\000\000\000\000\000\000\000I
foo-dev\375A\004\000\000\000\000\000\000\000C\000\000\000\000
foo-dev\375A\004\000\000\000\000\000\000\000C\000\000\000\001
foo-dev\375A\004\000\000\000\000\000\000\000C\000\000\000\002
foo-dev\375A\004\000\000\000\000\000\000\000I
foo-dev\375ClastCleanupFiles
foo-dev\375ClastCleanupSessions
foo-dev\375ClastCleanupTrash
foo-dev\375CnextChunk
foo-dev\375CnextCleanupSlices
foo-dev\375CnextInode
foo-dev\375CnextSession
foo-dev\375CtotalInodes
foo-dev\375CusedSpace
foo-dev\375SE\000\000\000\000\000\000\000\001
foo-dev\375SI\000\000\000\000\000\000\000\001
foo-dev\375U\001\000\000\000\000\000\000\000
foo-dev\375setting

从上面的 keys,可以看到我们创建的三个文件的元信息了, 这里面是用 slice_id 等信息关联的,所以能和对象存储里的数据 block 关联上

可以基于上一节的 key 编码规则进一步解码,得到更具体的 sliceID/inode 等等信息,这里我们暂时就不展开了。

5 总结

这一篇我们深入到 JuiceFS 内部,从数据和元数据存储中的东西反观 JuiceFS 切分数据和记录元数据的设计。 站在这个层次看,已经跟前一篇的理解程度全然不同。

如果说第一篇是“见自己”(功能如所见),这第二篇就是“见天(元数据引擎)地(对象存储)”, 那必然还得有一篇“见众生”。

参考资料

  1. 官方文档:JuiceFS 如何存储文件, juicefs.com
  2. 官方文档:JuiceFS 开发:内部实现, juicefs.com
  3. JuiceFS 元数据引擎初探:高层架构、引擎选型、读写工作流(2024)

Written by Human, Not by AI Written by Human, Not by AI

直观解读 JuiceFS 的数据和元数据设计(一):看山是山(2024)

本系列分为三篇文章,试图通过简单的实地环境来直观理解 JuiceFS 的数据(data)和元数据(metadata)设计。

Fig. MinIO bucket browser: one object was created ({volume}/juicefs_uuid) on a new juicefs volume creation.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



本篇首先快速了解下 JuiceFS 架构和组件,然后将搭建一个极简 JuiceFS 集群, 并以 JuiceFS 用户的身份来体验下它的基本功能。

1 JuiceFS 高层架构与组件

JuiceFS 的高层架构和组件,

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

三大组件:

  1. 元数据引擎:存储文件元数据,例如文件名、权限等。JuiceFS 支持多种元数据引擎,比如 TiKV、sqlite、redis 等。
  2. 对象存储:存储文件本身。JuiceFS 支持多种对象存储,比如 MinIO、AWS S3、阿里云 OSS 等。
  3. JuiceFS 客户端:将 JuiceFS volume 挂载到机器上,提供文件系统视图给用户。

更多架构信息,见 [1]。

2 搭建极简 JuiceFS 集群

接下来搭建一个极简 JuiceFS 环境,方便我们做一些功能测试。 按上一节提到的,只需要搭建以下 3 个组件:

  1. 元数据引擎,这里我们用 TiKV
  2. 对象存储,这里我们用 MinIO
  3. JuiceFS 客户端。

2.1 搭建元数据集群

对于功能测试来说,使用哪种元数据引擎都无所谓,比如最简单的 sqlite 或 redis。

不过,本系列第二篇会介绍 TiKV 相关的一些设计,所以本文用的 TiKV 集群作为元数据引擎, 相关的搭建步骤见社区文档。

本篇假设搭建的是三节点的 TiKV 集群,IP 地址分别是 192.168.1.{1,2,3}

2.2 搭建对象存储(MinIO)

这里我们用 MinIO 搭建一个对象存储服务,主要是空集群方便观察其中的文件变化

2.2.1 启动 MinIO server

MinIO 是一个兼容 S3 接口的开源对象存储产品,部署非常简单,就一个可执行文件,下载执行就行了。

也可以用容器,一条命令启动:

$ sudo docker run -p 9000:9000 -p 8080:8080 \
    quay.io/minio/minio server /data --console-address "0.0.0.0:8080"

访问 http://localhost:8080/ 就能看到 MinIO 的管理界面了。默认账号密码都是 minioadmin

2.2.2 创建 bucket

通过 MinIO 管理界面创建一个 bucket,这里我们命名为 juicefs-bucket

Fig. MinIO bucket list: an empty bucket.

可以看到现在里面一个对象也没有,已使用空间也是 0 字节

2.3 下载 juicefs 客户端

从 https://github.com/juicedata/juicefs/releases 下载一个可执行文件就行了,

$ wget https://github.com/juicedata/juicefs/releases/download/v1.2.1/juicefs-1.2.1-linux-amd64.tar.gz
$ tar -xvf juicefs-1.2.1-linux-amd64.tar.gz
$ chmod +x juicefs

2.4 创建 JuiceFS volume

接下来就可以创建一个 JuiceFS volume 了,这里命名为 foo-dev

2.4.1 创建/格式化 volume:juicefs format

$ juicefs format --storage minio --bucket http://localhost:9000/juicefs-bucket \
        --access-key minioadmin \
        --secret-key minioadmin \
        tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev  \
        foo-dev

<INFO>: Meta address: tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev [interface.go:504]
<INFO>: Data use minio://localhost:9000/juicefs-bucket/foo-dev/ [format.go:528]
<INFO>: Volume is formatted as {
  "Name": "foo-dev",
  "UUID": "3b4e509b-a7c8-456f-b726-cb8395cf8eb6",
  "Storage": "minio",
  "Bucket": "http://localhost:9000/juicefs-bucket",
  "AccessKey": "minioadmin",
  "SecretKey": "removed",
  "BlockSize": 4096,
  "UploadLimit": 0,
  "DownloadLimit": 0,
  ...
}

2.4.2 查看 MinIO bucket:多了一个 juicefs_uuid 文件

再查看 MinIO bucket,会发现多了一个 object,

Fig. MinIO bucket browser: one object was created on a new juicefs volume creation.

点进去,发现是一个叫 juicefs_uuid 的文件,

Fig. MinIO bucket browser: one object was created after juicefs format.

可以把这个文件下载下来,其内容就是上面 juicefs format 命令输出的 uuid 信息,也就是说 juicefs client 会把 volume 的 uuid 上传到对象存储中。

3 将 JuiceFS volume 挂载到本地路径

这么我们将这个 volume 挂载到本地路径 /tmp/foo-dev

$ ./juicefs mount --debug --backup-meta 0 \
     tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev /tmp/foo-dev

[INFO] [client.go:405] ["[pd] create pd client with endpoints"] [component=tikv] [pid=2881678] [pd-address="[192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379]"]
[INFO] [base_client.go:378] ["[pd] switch leader"] [component=tikv] [pid=2881678] [new-leader=https://192.168.1.3:2379] [old-leader=]
[INFO] [base_client.go:105] ["[pd] init cluster id"] [component=tikv] [pid=2881678] [cluster-id=7418858894192002550]
[INFO] [client.go:698] ["[pd] tso dispatcher created"] [component=tikv] [pid=2881678] [dc-location=global]
<INFO>: Data use minio://localhost:9000/juicefs-bucket/foo-dev/ [mount.go:650]
...

进入目录:

$ cd /tmp/foo-dev
$ ls -ahl
-r--------  1 root root    0 Oct 26 10:45 .accesslog
-r--------  1 root root 2.9K Oct 26 10:45 .config
-r--r--r--  1 root root    0 Oct 26 10:45 .stats
dr-xr-xr-x  2 root root    0 Oct 26 10:45 .trash

可以看到几个隐藏文件,

  • 这些是 JuiceFS 的元数据文件,在 [1] 系列文章中有过详细介绍。
  • 这些都是 volume 本地文件,不会上传到 MinIO。此时,MinIO juicefs-bucket 里面还是只有一个 uuid 文件。

4 在 JuiceFS volume 挂载的本地路径内读写

接下来进行一些 POSIX 操作测试。

4.1 创建和写入文件

创建三个文件,一个只有几十字节(但命名为 file1_1KB), 一个 5MB,一个 129MB

$ cd /tmp/foo-dev

$ echo "Hello, JuiceFS!" > file1_1KB

$ dd if=/dev/zero of=file2_5MB bs=1M count=5
5+0 records in
5+0 records out
5242880 bytes (5.2 MB, 5.0 MiB) copied, 0.0461253 s, 114 MB/s

$ dd if=/dev/zero of=file3_129MB bs=1M count=129
129+0 records in
129+0 records out
135266304 bytes (135 MB, 129 MiB) copied, 0.648757 s, 209 MB/s

4.2 查看文件属性

$ ls -ahl file*
-rw-r----- 1 root root   16  file1_1KB
-rw-r----- 1 root root 5.0M  file2_5MB
-rw-r----- 1 root root 129M  file3_129MB

$ file file2_5MB
file2_5MB: data

4.3 读取和追加文件

$ cat file1_1KB
Hello, JuiceFS!

$ echo "Hello, JuiceFS!" >> file1_1KB
$ cat file1_1KB
Hello, JuiceFS!
Hello, JuiceFS!

4.4 查找文件

$ find /tmp -name file1_1KB
/tmp/foo-dev/file1_1KB

4.5 删除文件

直接用 rm 删除就行了,不过这几个文件我们还有用,先不删。

4.6 目录操作

目录的创建、移动、修改权限、删除等待也是一样的,大家可以自己试试,这里不再赘述。

4.7 小结

根据以上测试,在 JuiceFS 挂载路径里创建/读写/查找/删除文件,都跟本地目录没什么区别 —— 这也正是「分布式“文件系统”」的意义所在 —— 兼容 POSIX 语义,用户无需关心数据存在哪, 当本地目录使用就行了(性能另当别论)。

5 总结

本篇中,我们作为 JuiceFS 用户对它进行了一些最基本的功能测试,结论是和本地文件系统没什么区别。

对于普通用户来说,了解到这一层就够了; 但对于高阶用户以及 JuiceFS 的开发/运维来说,这只是表象,必有第二重境界等着他们。

参考资料

  1. JuiceFS 元数据引擎初探:高层架构、引擎选型、读写工作流(2024)

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎五探:元数据备份与恢复(2024)

Fig. TiKV backup with different CLI tools (and their problems).

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 JuiceFS 元数据备份方式

再复习下 JuiceFS 架构,如下图所示:

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

JuiceFS 的元数据都存储在元数据引擎(例如,TiKV)里, 因此元数据的备份有两种实现方式:

  1. 从上层备份:JuiceFS client 扫描 volume,将 volume 内所有元数据备份;
  2. 元数据引擎(例如 TiKV)备份。

下面分别看看这两种方式。

2 JuiceFS 自带方式(volume 级别)

2.1 juicefs dump 手动备份 volume metadata

对指定 volume 进行备份,

$ juicefs dump tikv://ip:2379/foo-dev foo-dev-dump.json
<INFO>: Meta address: tikv://<ip>:2379/foo-dev [interface.go:406]
<WARNING>: Secret key is removed for the sake of safety [tkv.go:2571]
           Scan keys count: 357806 / 357806 [===========================]  done
      Dumped entries count: 122527 / 122527 [===========================]  done
<INFO>: Dump metadata into dump succeed [dump.go:76]

生成的是一个 JSON 文件,包含了 volume 的所有元数据信息,

{  "Setting": {
    "Name": "foo-dev",
    "UUID": "ca95c258",
    "Storage": "OSS",
    "Bucket": "http://<url>",
    "AccessKey": "ak",
    "BlockSize": 4096,
    "Capacity": 0,
    "Inodes": 0,
    "MetaVersion": 0,
    "MinClientVersion": "",
    "MaxClientVersion": "",
  },
  "Counters": {
    "usedSpace": 6164512768,
    "usedInodes": 5010,
    "nextInodes": 10402,
    "nextChunk": 25001,
    "nextSession": 118,
  },
  "FSTree": {
    "attr": {"inode":1,"type":"directory","mode":511,"atime":1645791488,"mtime":1652433235,"ctime":1652433235,"mtimensec":553010494,"ctimensec":553010494,"nlink":2,"length":0},
    "xattrs": [{"name":"lastBackup","value":"2024-05-30T13:50:25+08:00"}],
    "entries": {
      "001eb8b": {
        "attr": {...},
        "chunks": [{"index":0,"slices":[{"chunkid":15931,"size":32,"len":32}]}]
        ...
      }
   }
  },
}

其中,volume 中的所有文件和目录信息都描述在 FSTree 字段中。

2.2 juicefs mount --backup-meta <duration> 自动备份

juicefs client 默认会自动备份 volume 的元数据,

  • 备份间隔通过 --backup-meta {duration} 选项控制,默认 1h
  • 备份文件在对象存储的 meta 特殊目录中,该目录在挂载点中不可见,用对象存储的文件浏览器可以查看和管理,
  • 多 client 挂载同一个 volume 也不会发生备份冲突,因为 JuiceFS 维护了一个全局的时间戳,确保同一时刻只有一个客户端执行备份操作,但是,
  • 当文件数太多(默认达到 100w)且备份频率为默认值 1h 时,为避免备份开销太大,JuiceFS 会自动停止元数据备份,并打印相应的告警。

2.3 juicefs load 从元数据备份文件恢复

$ juicefs load tikv://<ip>:2379/foo-dev-new foo-dev-dump.json

2.4 限制及问题

根据官方文档,以上两种方式都有一些限制或问题:

  • 导出过程中如果业务仍在写入,导出的文件可能不可用。如果对一致性有更高要求,需要在导出前停写。
  • 对规模较大的 volume,直接在线上进行导出可能会影响业务稳定性。

另外,以上方式都是 volume 级别的备份,如果要备份整个 JuiceFS 集群,需要逐个 volume 备份,比较麻烦。 下面再看看直接从元数据引擎进行备份的方式。

3 从 TiKV 层面对 JuiceFS 元数据进行备份

这里假设 JuiceFS 的元数据引擎是 TiKV。

3.1 TiKV backup/restore 原理

从上层来说,很简单:

  1. 发请求给 TiKV 集群的管理者 PD,让它对集群的所有数据进行备份;

  2. 接下来,PD 会发请求给集群的所有 TiKV 节点,通知它们各自进行备份

    • TiKV 是按 region 进行多副本存储的,因此只需要一个副本进行备份就行了,
    • 在当前的设计里面就是让每个 region 的 leader 副本进行备份,
  3. TiKV region leaders 把这个 region 内的数据写到指定位置。可以是本地磁盘或分布式存储。

3.2 备份工具 TiDB br 和 TiKV tikv-br

理论上,有两个工具可能实现以上效果,它们分别来自 TiDB 和 TiKV 社区,

Fig. TiKV backup with different CLI tools (and their problems).

  1. br:以前是个独立项目(图中 A.1),后来合到 tidb 仓库里了(图中 B.1),

    这个工具主要是给 TiDB 备份用的(虽然底层备份的是 TiDB 的 TiKV), 所以需要一些 TiDB 知识(上下文),例如 db/table 都是 TiDB 才有的概念。 理论上,它也能备份独立部署的 TiKV 集群(“不依赖 TiDB 的 TiKV”), 所以加了 raw/txn 支持,但不是 TiDB 社区的重点,所以目前还是 experimental 特性,且用下来有 bug。

  2. tikv-br:是个独立项目,应该是当时 TiKV 作为独立项目推进时,想搞一个配套的独立备份工具, 但目前看起来跟 TiKV 社区一样已经不活跃了,它也没法对 txn 进行备份(JuiceFS 用的 txnkv 接口)。

至少对于 5.x TiKV 集群,测试下来以上哪个工具都无法完成备份: 有的工具备份和恢复都提示完成,看起来是成功的,但实际上是失败的,JuiceFS 挂载时才能发现。

最后,我们是基于目前(2024.09)最新的 TiDB br,修改了两个地方,才成功完成 TiKV 的备份与恢复。

3.3 基于 TiDB br 对 JuiceFS TiKV 集群进行备份与恢复的步骤

之所以要强调 “JuiceFS TiKV 集群”,是因为 JuiceFS 用的 txnkv 接口,这个比较特殊; 如果是 rawkv 接口,那 tikv 自带的备份工具 tikv-br 也许就能用了(没测过)。

  1. (可选)关闭 TiKV MVCC GC;
  2. br 执行备份,

    br-dev 是我们基于最新 master(202409)改过的版本。

     $ ./br-dev backup txn \
             --ca /tmp/pki/root.crt --cert /tmp/pki/pd.crt --key /tmp/pki/pd.key \
             --pd https://$pd_addr \
             --s3.endpoint $s3_addr \
             --storage $storage_path \
             --log-file /var/log/tikv/br.log \
             --ratelimit $bw_limit_per_node \
             --log-level debug \
             --check-requirements=false
    

    可以设置限速等参数,避免备份占用的 CPU/Memory/DiskIO/… 过大。根据 db size 等等因素,备份的耗时是可估算的,下面拿一个真实集群的备份为例:

    • 每个 TiKV 的 DB size:监控能看到,一般每个节点的 DB size 都差不太多,这里是 25GB per TiKV node;
    • MVCC 保留了数据的多个版本:假设平均保留两个版本,那就是 DB size * 2
    • 限速带宽:设置为 30MB/s,这个带宽不算大,不会是磁盘和网络瓶颈,因此可以全速运行

    根据以上参数,估算耗时:25GB * 2 / 30MBps = 1700s = 28min

    Fig. TiKV backup resource usage with br --ratelimit=30MB/s.

    可以看到跟预估的差不多。资源销毁方面:

    • CPU 利用率比平时翻倍
    • 其中两台机器的 CPU 数量比较少,所以会比其他节点更明显。
  3. 检查备份

    如果是备份到 S3,可以用 s3cmd 或 web 控制台查看,

     $ s3cmd du s3://{bucket}/<backup>/
     295655971082   18513 objects s3://{bucket}/<backup>/
    

    290GB 左右,比监控看到的 DB size 大一倍,因为保留了 MVCC 多版本。 大多少倍与 MVCC GC 间隔有密切关系, 比如写或更新很频繁的场景,1h 和 3h 的 MVCC 数据量就差很多了。

  4. br 恢复:将备份数据恢复到一个新的 JuiceFS TiKV 集群,

     $ ./br-dev restore txn \
             --ca /tmp/pki/root.crt --cert /tmp/pki/pd.crt --key /tmp/pki/pd.key \
             --pd https://$pd_addr \
             --s3.endpoint $s3_addr \
             --storage $storage_path \
             --log-file /var/log/tikv/br.log \
             --ratelimit $bw_limit_per_node \
             --log-level debug \
             --check-requirements=false
    

    可能的问题:ratelimit 好像不起作用,全速恢复,网络带宽打的很高。

  5. JuiceFS client 挂载,验证恢复成功

    用 juicefs 挂载目录,指定新 TiKV 集群的 PD 地址,

     $ juicefs mount tikv://<new-pd-ip>:2379/<volume name> /tmp/test
    
     $ cd /tmp/test && ls
     # 原来 volume 内的文件都在
    

3.4 TiDB br 备份逻辑

感兴趣的可以看看 br 源码的备份逻辑,

3.4.1 RunBackupTxn()

// tidb br/pkg/task/backup_txn.go

// RunBackupTxn starts a backup task inside the current goroutine.
func RunBackupTxn(c context.Context, g glue.Glue, cmdName string, cfg *TxnKvConfig) error {
    mgr := NewMgr(ctx, g, cfg.PD, cfg.TLS, GetKeepalive(&cfg.Config), cfg.CheckRequirements, false)
    client := backup.NewBackupClient(ctx, mgr)

    backupRanges := make([]rtree.Range, 0, 1)

    // current just build full txn range to support full txn backup
    minStartKey := []byte{}
    maxEndKey := []byte{}
    backupRanges = append(backupRanges, rtree.Range{
        StartKey: minStartKey,
        EndKey:   maxEndKey,
    })

    // Backup
    req := backuppb.BackupRequest{
        ClusterId:        client.GetClusterID(),
        StartVersion:     0,
        EndVersion:       client.GetCurrentTS(ctx), // gets a new timestamp (TSO) from PD
        RateLimit:        cfg.RateLimit,
        Concurrency:      cfg.Concurrency,
        StorageBackend:   client.GetStorageBackend(),
        IsRawKv:          false,
    }

    ranges, schemas, policies := client.BuildBackupRangeAndSchema(mgr.GetStorage(), cfg.TableFilter, backupTS, isFullBackup(cmdName))

    // StartWriteMetasAsync writes four kind of meta into backupmeta.
    // 1. file
    // 2. schema
    // 3. ddl
    // 4. rawRange( raw kv )
    metaWriter := metautil.NewMetaWriter(client.GetStorage(), metautil.MetaFileSize, false, metautil.MetaFile, &cfg.CipherInfo)
    metaWriter.StartWriteMetasAsync(ctx, metautil.AppendDataFile)

    // Start TiKV backup
    client.BackupRanges(ctx, backupRanges, req, 1, nil, metaWriter, progressCallBack)

    // Backup has finished
    metaWriter.Update(func(m *backuppb.BackupMeta) {
        m.StartVersion = req.StartVersion
        m.EndVersion = req.EndVersion
        m.IsRawKv = false
        m.IsTxnKv = true
        m.ClusterId = req.ClusterId
        m.ClusterVersion = mgr.GetClusterVersion(ctx)
        m.BrVersion = brVersion
        m.ApiVersion = client.GetApiVersion()
    })
    metaWriter.FinishWriteMetas(ctx, metautil.AppendDataFile)
    metaWriter.FlushBackupMeta(ctx)
}

几点说明:

  1. KV 的 backup range 是全量(start/end key 都是空);
  2. MVCC 的 start/end version 分别是 0 和当前 PD 最新的 TSO;

3.4.2 调用栈

BackupRanges // make a backup of the given key ranges.
  |-mainBackupLoop := &MainBackupLoop
  |     BackupSender:       &MainBackupSender{},
  |     BackupReq:          request,
  |     Concurrency:        concurrency,
  |     GlobalProgressTree: &globalProgressTree,
  |     ReplicaReadLabel:   replicaReadLabel,
  |     GetBackupClientCallBack: func(ctx , storeID uint64, reset bool) (backuppb.BackupClient, error) {
  |         return bc.mgr.GetBackupClient(ctx, storeID)
  |     },
  | }
  |-bc.RunLoop(ctx, mainBackupLoop) // infinite loop to backup ranges on all tikv stores
      |-for {
          inCompleteRanges = iter.GetIncompleteRanges() // 还未完成备份的 key 范围
          loop.BackupReq.SubRanges = getBackupRanges(inCompleteRanges)
          allStores := bc.getBackupStores(mainCtx, loop.ReplicaReadLabel)
          for _, store := range allStores {
            cli := loop.GetBackupClientCallBack(mainCtx, storeID, reset)
            loop.SendAsync(round, storeID, loop.BackupReq, loop.Concurrency, cli, ch, loop.StateNotifier)
                  |-go startBackup(storeID, request, cli, concurrency, respCh)
                        |-for i, req := range reqs {
                            doSendBackup(ectx, backupCli, bkReq, ...)
                              |-ctx, timerecv := StartTimeoutRecv(pctx, TimeoutOneResponse)
                              |-bCli := client.Backup(ctx, &req) // protobuf grpc method
                              |-for {
                              |-    resp := bCli.Recv()
                              |-    timerecv.Refresh()
                              |-    respFn(resp)
                              |-}
          }
        }

3.4.3 tikv-server 备份代码

// components/backup/src/service.rs

impl<H> Backup for Service<H>
{
    fn backup(
        req: BackupRequest,
        mut sink: ServerStreamingSink<BackupResponse>,
    ) {
        if let Err(status) = match Task::new(req, tx) {
            Ok((task, c)) => {
                self.scheduler.schedule(task)
            }
        }

        let send_task = async move {
            let mut s = rx.map(|resp| Ok((resp, WriteFlags::default())));
            sink.send_all(&mut s).await?;
        }

        ctx.spawn(send_task);
    }
}
/// Backup Task.
pub struct Task {
    request: Request,
    pub(crate) resp: UnboundedSender<BackupResponse>,
}

// components/backup/src/endpoint.rs
impl Task {
    /// Create a backup task based on the given backup request.
    pub fn new(
        req: BackupRequest,
        resp: UnboundedSender<BackupResponse>,
    ) -> Result<(Task, Arc<AtomicBool>)> {
        let speed_limit = req.get_rate_limit();
        let limiter = Limiter::new(if speed_limit > 0  else f64::INFINITY });
        let cf = name_to_cf(req.get_cf())

        let task = Task {
            request: Request {
                start_key: req.get_start_key().to_owned(),
                end_key: req.get_end_key().to_owned(),
                sub_ranges: req.get_sub_ranges().to_owned(),
                start_ts: req.get_start_version().into(),
                end_ts: req.get_end_version().into(),
                backend: req.get_storage_backend().clone(),
                limiter,
                is_raw_kv: req.get_is_raw_kv(),
                dst_api_ver: req.get_dst_api_version(),
                cf,
                replica_read: req.get_replica_read(),
                resource_group_name: .get_resource_group_name().to_owned(),
                }),
            },
            resp,
        };
    }
}

// components/backup/src/endpoint.rs
BackupRanges -> BackupWriterBuilder -> S3Uploader

self.writer.put(&data_key_write, value) -> s3 put

参考资料

  1. 官方文档:元数据备份和恢复, juicefs.com

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎四探:元数据大小评估、限流与限速的设计思考(2024)

Fig. JuiceFS upload/download data bandwidth control.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 元数据存储在哪儿?文件名到 TiKV regions 的映射

1.1 pd-ctl region 列出所有 region 信息

$ pd-ctl.sh region | jq .
{
  "regions": [
    {
      "id": 11501,
      "start_key": "6161616161616161FF2D61692D6661742DFF6261636B7570FD41FFCF68030000000000FF4900000000000000F8",
      "end_key": "...",
      "epoch": {
        "conf_ver": 23,
        "version": 300
      },
      "peers": [
        {
          "id": 19038,
          "store_id": 19001,
          "role_name": "Voter"
        },
        ...
      ],
      "leader": {
        "id": 20070,
        "store_id": 20001,
        "role_name": "Voter"
      },
      "written_bytes": 0,
      "read_bytes": 0,
      "written_keys": 0,
      "read_keys": 0,
      "approximate_size": 104,
      "approximate_keys": 994812
    },
  ]
}

1.2 tikv-ctl region-properties 查看 region 属性详情

$ ./tikv-ctl.sh region-properties -r 23293
mvcc.min_ts: 438155461254971396
mvcc.max_ts: 452403302095650819
mvcc.num_rows: 1972540
mvcc.num_puts: 3697509
mvcc.num_deletes: 834889
mvcc.num_versions: 4532503
mvcc.max_row_versions: 54738
num_entries: 4549844
num_deletes: 17341
num_files: 6
sst_files: 001857.sst, 001856.sst, 002222.sst, 002201.sst, 002238.sst, 002233.sst
region.start_key: 6e6772...
region.end_key: 6e6772...
region.middle_key_by_approximate_size: 6e6772...

1.3 tikv-ctl --to-escaped:从 region 的 start/end key 解码文件名范围

如上,每个 region 都会有 start_key/end_key 两个属性, 这里面编码的就是这个 region 内存放是元数据的 key 范围。我们挑一个来解码看看:

$ tikv-ctl.sh --to-escaped '6161616161616161FF2D61692D6661742DFF6261636B7570FD41FFCF68030000000000FF4900000000000000F8'
aaaaaaaa\377-ai-fat-\377backup\375A\377\317h\003\000\000\000\000\000\377I\000\000\000\000\000\000\000\370

再 decode 一把会更清楚:

$ tikv-ctl.sh --decode 'aaaaaaaa\377-ai-fat-\377backup\375A\377\317h\003\000\000\000\000\000\377I\000\000\000\000\000\000\000\370'
aaaaaaaa-ai-fat-backup\375A\317h\003\000\000\000\000\000I

对应的是一个名为 aaaaaaa-ai-fat-backup 的 volume 内的一部分元数据。

1.4 filename -> region:相关代码

这里看一下从文件名映射到 TiKV region 的代码。

PD 客户端代码,

    // GetRegion gets a region and its leader Peer from PD by key.
    // The region may expire after split. Caller is responsible for caching and
    // taking care of region change.
    // Also, it may return nil if PD finds no Region for the key temporarily,
    // client should retry later.
    GetRegion(ctx , key []byte, opts ...GetRegionOption) (*Region, error)

// GetRegion implements the RPCClient interface.
func (c *client) GetRegion(ctx , key []byte, opts ...GetRegionOption) (*Region, error) {
    options := &GetRegionOp{}
    for _, opt := range opts {
        opt(options)
    }
    req := &pdpb.GetRegionRequest{
        Header:      c.requestHeader(),
        RegionKey:   key,
        NeedBuckets: options.needBuckets,
    }
    serviceClient, cctx := c.getRegionAPIClientAndContext(ctx, options.allowFollowerHandle && c.option.getEnableFollowerHandle())
    resp := pdpb.NewPDClient(serviceClient.GetClientConn()).GetRegion(cctx, req)
    return handleRegionResponse(resp), nil
}

PD 服务端代码,

func (h *regionHandler) GetRegion(w http.ResponseWriter, r *http.Request) {
    rc := getCluster(r)
    vars := mux.Vars(r)
    key := url.QueryUnescape(vars["key"])
    // decode hex if query has params with hex format
    paramsByte := [][]byte{[]byte(key)}
    paramsByte = apiutil.ParseHexKeys(r.URL.Query().Get("format"), paramsByte)

    regionInfo := rc.GetRegionByKey(paramsByte[0])
    b := response.MarshalRegionInfoJSON(r.Context(), regionInfo)

    h.rd.Data(w, http.StatusOK, b)
}

// GetRegionByKey searches RegionInfo from regionTree
func (r *RegionsInfo) GetRegionByKey(regionKey []byte) *RegionInfo {
    region := r.tree.search(regionKey)
    if region == nil {
        return nil
    }
    return r.getRegionLocked(region.GetID())
}

返回的是 region info,

// RegionInfo records detail region info for api usage.
// NOTE: This type is exported by HTTP API. Please pay more attention when modifying it.
// easyjson:json
type RegionInfo struct {
    ID          uint64              `json:"id"`
    StartKey    string              `json:"start_key"`
    EndKey      string              `json:"end_key"`
    RegionEpoch *metapb.RegionEpoch `json:"epoch,omitempty"`
    Peers       []MetaPeer          `json:"peers,omitempty"` // https://github.com/pingcap/kvproto/blob/master/pkg/metapb/metapb.pb.go#L734

    Leader            MetaPeer      `json:"leader,omitempty"`
    DownPeers         []PDPeerStats `json:"down_peers,omitempty"`
    PendingPeers      []MetaPeer    `json:"pending_peers,omitempty"`
    CPUUsage          uint64        `json:"cpu_usage"`
    WrittenBytes      uint64        `json:"written_bytes"`
    ReadBytes         uint64        `json:"read_bytes"`
    WrittenKeys       uint64        `json:"written_keys"`
    ReadKeys          uint64        `json:"read_keys"`
    ApproximateSize   int64         `json:"approximate_size"`
    ApproximateKeys   int64         `json:"approximate_keys"`
    ApproximateKvSize int64         `json:"approximate_kv_size"`
    Buckets           []string      `json:"buckets,omitempty"`

    ReplicationStatus *ReplicationStatus `json:"replication_status,omitempty"`
}

// GetRegionFromMember implements the RPCClient interface.
func (c *client) GetRegionFromMember(ctx , key []byte, memberURLs []string, _ ...GetRegionOption) (*Region, error) {
    for _, url := range memberURLs {
        conn := c.pdSvcDiscovery.GetOrCreateGRPCConn(url)
        cc := pdpb.NewPDClient(conn)
        resp = cc.GetRegion(ctx, &pdpb.GetRegionRequest{
            Header:    c.requestHeader(),
            RegionKey: key,
        })
        if resp != nil {
            break
        }
    }

    return handleRegionResponse(resp), nil
}

2 JuiceFS 集群规模与元数据大小(engine size)

2.1 二者的关系

一句话总结:并没有一个线性的关系

2.1.1 文件数量 & 平均文件大小

TiKV engine size 的大小,和集群的文件数量每个文件的大小都有关系。 例如,同样是一个文件,

  1. 小文件可能对应一条 TiKV 记录;
  2. 大文件会被拆分,对应多条 TiKV 记录。

2.1.2 MVCC GC 快慢

GC 的勤快与否也会显著影响 DB size 的大小。第三篇中有过详细讨论和验证了,这里不再赘述,

Fig. TiKV DB size soaring in a JuiceFS cluster, caused by TiKV GC lagging.

2.2 两个集群对比

  • 集群 1:~1PB 数据,以小文件为主,~30K regions,~140GB TiKV engine size (3 replicas);
  • 集群 2:~7PB 数据,以大文件为主,~800 regions,~3GB TiKV engine size (3 replicas);

如下面监控所示,虽然集群 2 的数据量是前者的 7 倍,但元数据只有前者的 1/47

Fig. TiKV DB sizes and region counts of 2 JuiceFS clusters: cluster-1 with ~1PB data composed of mainly small files, cluster-2 with ~7PB data composed of mainly large files.

3 限速(上传/下载数据带宽)设计

限速(upload/download bandwidth)本身是属于数据平面(data)的事情,也就是与 S3、Ceph、OSS 等等对象存储关系更密切。

但第二篇中已经看到,这个限速的配置信息是保存在元数据平面(metadata)TiKV 中 —— 具体来说就是 volume 的 setting 信息; 此外,后面讨论元数据请求限流(rate limiting)时还需要参考限速的设计。所以,这里我们稍微展开讲讲。

3.1 带宽限制:--upload-limit/--download-limit

  • --upload-limit,单位 Mbps
  • --download-limit,单位 Mbps

3.2 JuiceFS 限速行为

  1. 如果 juicefs mount 挂载时指定了这两个参数,就会以指定的参数为准;
  2. 如果 juicefs mount 挂载时没指定,就会以 TiKV 里面的配置为准,

    • juicefs client 里面有一个 refresh() 方法一直在监听 TiKV 里面的 Format 配置变化,
    • 当这俩配置发生变化时(可以通过 juicefs config 来修改 TiKV 中的配置信息),client 就会把最新配置 reload 到本地(本进程)
    • 这种情况下,可以看做是中心式配置的客户端限速,工作流如下图所示,

Fig. JuiceFS upload/download data bandwidth control.

3.3 JuiceFS client reload 配置的调用栈

juicefs mount 时注册一个 reload 方法,

mount
 |-metaCli.OnReload
    |-m.reloadCb = append(m.reloadCb, func() {
                                        updateFormat(c)(fmt) //  fmt 是从 TiKV 里面拉下来的最新配置
                                        store.UpdateLimit(fmt.UploadLimit, fmt.DownloadLimit)
                                      })

然后有个后台任务一直在监听 TiKV 里面的配置,一旦发现配置变了就会执行到上面注册的回调方法,

refresh()
  for {
        old := m.getFormat()
        format := m.Load(false) // load from tikv

        if !reflect.DeepEqual(format, old) {
            cbs := m.reloadCb
            for _, cb := range cbs {
                cb(format)
        }
  }

4 限流(metadata 请求)设计

4.1 为什么需要限流?

如下图所示,

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

  • 限速保护的是 5;
  • 限流保护的是 3 & 4

下面我们通过实际例子看看可能会打爆 3 & 4 的几种场景。

4.2 打爆 TiKV API 的几种场景

4.2.1 mlocate (updatedb) 等扫盘工具

一次故障复盘

下面的监控,左边是 TiKV 集群的请求数量,右边是 node CPU 利用率(主要是 PD leader 在用 CPU),

Fig. PD CPU soaring caused by too much requests.

大致时间线,

  • 14:30 开始,kv_get 请求突然飙升,导致 PD leader 节点的 CPU 利用率大幅飙升;
  • 14:40 介入调查,确定暴增的请求来自同一个 volume,但这个 volume 被几十个用户的 pod 挂载, 能联系到的用户均表示 14:30 没有特殊操作;
  • 14:30~16:30 继续联系其他用户咨询使用情况 + 主动排查;期间删掉了几个用户暂时不用的 pod,减少挂载这个 volume 的 juicefs client 数量,请求量有一定下降;
  • 16:30 定位到请求来源
    • 确定暴增的请求不是用户程序读写导致的
    • 客户端大部分都 ubuntu 容器(AI 训练),
    • 使用的是同一个容器镜像,里面自带了一个 daily 的定时 mlocate 任务去扫盘磁盘,

这个扫盘定时任务的时间是每天 14:30,因此把挂载到容器里的 JuiceFS volume 也顺带扫了。 确定这个原因之后,

  • 16:40 开始,逐步强制停掉(pkill -f updatedb.mlocate) 并禁用(mv /etc/cron.daily/mlocate /tmp/)这些扫盘任务, 看到请求就下来了,PD CPU 利用率也跟着降下来了;
  • 第二天早上 6:00 又发生了一次(凌晨 00:00 其实也有一次),后来排查发生是还有几个基础镜像也有这个任务,只是 daily 时间不同。

juicefs mount 时会自动禁用 mlocate,但 CSI 部署方式中部分失效

其实官方已经注意到了 mlocate,所以 juicefs mount 的入口代码就专门有检测,开了之后就自动关闭,

// cmd/mount_unix.go

func mountMain(v *vfs.VFS, c *cli.Context) {
    if os.Getuid() == 0 {
        disableUpdatedb()
          |-path := "/etc/updatedb.conf"
          |-file := os.Open(path)
          |-newdata := ...
          |-os.WriteFile(path, newdata, 0644)
    }
    ...

但是,在 K8s CSI 部署方式中,这个代码是部分失效的

Fig. JuiceFS K8s CSI deployment

JuiceFS per-node daemon 在创建 mount pod 时,会把宿主机的 /etc/updatedb.conf 挂载到 mount pod 里面, 所以它能禁掉宿主机上的 mlocate,

  volumes:
  - hostPath:
      path: /etc/updatedb.conf
      type: FileOrCreate
    name: updatedb

但正如上一小结的例子看到的,业务 pod 里如果开了 updatedb,它就管不到了。 而且业务容器很可能是同一个镜像启动大量 pod,挂载同一个 volume,所以扫描压力直线上升

4.2.2 版本控制工具

类似的工具可能还有版本控制工具(git、svn)、编程 IDE(vscode)等等,威力可能没这么大,但排查时需要留意。

4.3 需求:对元数据引擎的保护能力

以上 case,包括上一篇看到的用户疯狂 update 文件的 case,都暴露出同一个问题: JuiceFS 缺少对元数据引擎的保护能力。

4.3.1 现状:JuiceFS 目前还没有

社区版目前(2024.09)是没有的,企业版不知道有没有。

下面讨论下如果基于社区版,如何加上这种限流能力。

4.4 客户端限流方案设计

Fig. JuiceFS upload/download data bandwidth control.

基于 JuiceFS 已有的设计,再参考其限速实现,其实加上一个限流能力并不难,代码也不多:

  1. 扩展 Format 结构体,增加限流配置;
  2. juicefs format|config 增加配置项,允许配置具体限流值;这会将配置写到元数据引擎里面的 volume setting
  3. juicefs mount 里面解析 setting 里面的限流配置,传给 client 里面的 metadata 模块;
  4. metadata 模块做客户端限流,例如针对 txnkv 里面的不到 10 个方法,在函数最开始的地方增加一个限流检查,allow 再继续,否则就等待。

这是一种(中心式配置的)客户端限流方案。

4.5 服务端限流方案设计

在 TiKV 集群前面挡一层代理,在代理上做限流,属于服务端限流

参考资料

  1. 图解 JuiceFS CSI 工作流:K8s 创建带 PV 的 Pod 时,背后发生了什么(2024)

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎三探:从实践中学习 TiKV 的 MVCC 和 GC(2024)

Fig. TiKV MVCC GC mechanisms.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 概念与实测

1.1 MVCC(多版本并发控制)

来自 wikipedia 的定义

Multiversion concurrency control (MCC or MVCC), is a non-locking concurrency control method commonly used by database management systems to provide concurrent access to the database and in programming languages to implement transactional memory.

TiKV 支持 MVCC,当更新数据时,旧的数据不会被立即删掉,而是新老同时保留,以时间戳来区分版本。 官方有几篇很不错的博客 [1,3]。

下面进行一个简单测试来对 MVCC 有一个初步的直观认识。

1.1.2 TiKV MVCC 测试

参考上一篇,新创建一个新 volume,里面什么文件都没有,有 8 条记录

$ tikv-ctl.sh scan --from 'zfoo' --to 'zfop' | grep "key:" | wc -l
8

然后进入这个 volume 的挂载目录,在里面创建一个文件

$ cd <mount dir>
$ echo 1 > foo.txt

再次扫描这个 volume 对应的所有 keys,

$ tikv-ctl.sh scan --from 'zfoo' --to 'zfop' | grep "key:" | wc -l
16

可以看到变成 16 条记录,比之前多了 8 条。内容如下,依稀能看出大部分条目的用途 (行末的注释是本文加的),

key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfoo.tx\377t\000\000\000\000\000\000\000\370 # foo.txt
key: zfoo-dev\375\377A\002\000\000\000\000\000\000\377\000C\000\000\000\000\000\000\375
key: zfoo-dev\375\377A\002\000\000\000\000\000\000\377\000I\000\000\000\000\000\000\371
key: zfoo-dev\375\377ClastCle\377anupFile\377s\000\000\000\000\000\000\000\370                         # lastCleanupFile
key: zfoo-dev\375\377ClastCle\377anupSess\377ions\000\000\000\000\373                                  # lastCleanupSessions
key: zfoo-dev\375\377CtotalIn\377odes\000\000\000\000\373                                              # totalInodes
key: zfoo-dev\375\377CusedSpa\377ce\000\000\000\000\000\000\371                                        # UsedSpace
key: zfoo-dev\375\377U\001\000\000\000\000\000\000\377\000\000\000\000\000\000\000\000\370

接下来继续更新这个文件 1000 次(每次都是一个整数,由于文件内容极小,不会导致 TiKV 的 region split 等行为),

$ for n in {1..1000}; do echo $n > bar.txt; done

再次查看元数据条目数量:

$ tikv-ctl.sh scan --from 'zfoo' --to 'zfop' | grep key | wc -l
59

多了 43 条。多的条目大致长这样:

key: zfoo-dev\375\377L\000\000\000\000f\356\221\377\231\000\000\000\000\000\000\000\3777\000\000\000\000\000\000\000\370
key: zfoo-dev\375\377L\000\000\000\000f\356\221\377\233\000\000\000\000\000\000\000\377j\000\000\000\000\000\000\000\370
key: zfoo-dev\375\377L\000\000\000\000f\356\221\377\234\000\000\000\000\000\000\000\377\235\000\000\000\000\000\000\000\370
...
key: zfoo-dev\375\377L\000\000\000\000f\356\221\377\271\000\000\000\000\000\000\003\377\362\000\000\000\000\000\000\000\370

TiKV supports MVCC, which means that there can be multiple versions for the same row stored in RocksDB. All versions of the same row share the same prefix (the row key) but have different timestamps as a suffix.

https://tikv.org/deep-dive/key-value-engine/rocksdb/

下面我们再看看执行以上文件更新操作期间,juicefs 客户端的日志。

1.1.2 JuiceFS client 日志

在执行以上 for 循环期间,JuiceFS client 的日志,

$ juicefs mount ...
...
<DEBUG>: PUT chunks/0/0/170_0_4 (req_id: "xx", err: <nil>, cost: 32.002516ms) [cached_store.go:669]
<DEBUG>: PUT chunks/0/0/171_0_4 (req_id: "xx", err: <nil>, cost: 32.002516ms) [cached_store.go:669]
<DEBUG>: PUT chunks/0/0/172_0_4 (req_id: "xx", err: <nil>, cost: 32.002516ms) [cached_store.go:669]
...

这个似乎对应的就是以上多出来的条目。

1.1.3 小结

本节的例子让我们看到,虽然 volume 里面从头到尾只有一个文件, 但随着我们不断覆盖这个文件内的值,元数据引擎 TiKV 内的条目数量就会持续增加。 多出来的这些东西,对应的就是这份数据的多个版本,也就是 MVCC 里面 multi-version 的表现。

显然,没有冲突的话,只保留最后一个版本就行了,其他版本都可以删掉 —— 这就是垃圾回收(GC)的作用。

1.2 GC(垃圾回收)

垃圾回收 (GC) 的功能是清理 MVCC 留下的旧版本。比如同一份数据保存了 1000 个版本,那原则上前面大部分版本都可以清掉了,只保留最新的一个或几个。

那如何判断哪些版本可以安全地清掉呢?TiKV 引入了一个时间戳概念: safepoint

GC is a process to clean up garbage versions (versions older than the configured lifetime) of each row.

https://tikv.org/deep-dive/key-value-engine/rocksdb/

1.3 Safepoint(可安全删除这个时间戳之前的版本)

In order to ensure the correctness of all read and write transactions, and make sure the GC mechanism works, TiKV/TiDB introduced the concept of safe-point. There is a guarantee that all active transactions and future transactions’ timestamp is greater than or equal to the safe-point. It means old versions whose commit-ts is less than the safe-point can be safely deleted by GC. [3]

2 TiKV MVCC GC

以上看到,TiKV 有 GC 功能,但由于其“历史出身”,也存在一些限制。

2.1 历史:从 TiDB 里面拆分出来,功能不完整

TiKV 是从 TiDB 里面拆出来的一个产品,并不是从一开始就作为独立产品设计和开发的。 这导致的一个问题是:MVCC GC 功能在使用上有点蹩脚:

  1. 默认情况下,靠底层 RocksDB 的 compaction 触发 GC,这周触发周期不确定且一般比较长;
  2. TiKV+PD 也内置了另一种 GC 方式,但并不会自己主动去做,而是将 GC 接口暴露出来,靠 TiDB 等在使用 TiKV 的更上层组件来触发(见下节的图);
  3. tikv-ctl/pd-ctl 等等命令行工具也都没有提供 GC 功能,这导致 TiKV 的运维很不方便,比如有问题想快速手动触发时用不了。

下面具体看看 TiKV 中的 GC 设计。

2.2 TiKV GC 设计和配置项

Fig. TiKV MVCC GC mechanisms.

2.2.1 设计:两种 GC 触发方式

  1. 被动 GC:TiKV 底层的 RocksDB compact 时进行垃圾回收。
    • 通过 tikv-server 的 enable-compaction-filter 配置项控制;
    • 默认启用
    • 触发 RocksDB compaction 时才能进行 GC。
    • tikv-ctl compact/compact-cluster 可以手动触发这种 compact,进而 GC。
  2. 半主动 GC:内置了 GC worker,
    • 定期获取 PD 里面的 gc safepoint,然后进行 GC;会占用一些 CPU/IO 资源;
    • PD 不会主动更新这个 gc safepoint,一般是由在使用 TiKV 的更外围组件来更新的,例如 TiDB、JuiceFS 等等;
    • 所以本文把这种方式称为“半主动”。

2.2.2 tikv-server 启动日志中的 GC 配置信息

tikv-server.log

[INFO] [server.rs:274] ["using config"] [config="{..., "enable-compaction-filter":true, ...}"]
[INFO] [compaction_filter.rs:138] ["initialize GC context for compaction filter"]
[INFO] [gc_worker.rs:786] ["initialize compaction filter to perform GC when necessary"]

2.2.3 tikv-ctl compact/compact-cluster 触发被动 GC 例子

# compact-cluster 必须要指定 --pd 参数,因为针对是整个集群。指定 --host 会失败,但没有提示错在哪,TiKV 的命令行工具经常这样
$ tikv-ctl.sh compact-cluster --from 'zfoo' --to 'zfop' 

$ tikv-ctl.sh compact --from 'zfoo' --to 'zfop'
store:"192.168.1.1:20160" compact db:Kv cf:default range:[[122, 122, 121, 110], [122, 122, 121, 111]) success!

$ tikv-ctl.sh compact --from 'zfoo' --to 'zfop' -c default  # 很快
$ tikv-ctl.sh compact --from 'zfoo' --to 'zfop' -c lock     # 很快
store:"192.168.1.1:20160" compact db:Kv cf:lock range:[[122, 122, 121, 110], [122, 122, 121, 111]) success!
$ tikv-ctl.sh compact --from 'zfoo' --to 'zfop' -c write    # 非常慢
store:"192.168.1.1:20160" compact db:Kv cf:write range:[[122, 122, 121, 110], [122, 122, 121, 111]) success!

# 还可以指定本地 TiKV 数据路径直接 compact
# -d: specify the RocksDB that performs compaction. default: kv. Valid values: {kv, raft}
$ tikv-ctl --data-dir /path/to/tikv compact -d kv

2.2.4 小结

“半主动方式”需要外围组件去更新 PD 中的 gc safepoint 信息,这样下面的 TiKV 才会去执行 GC 操作。作为两个具体例子,我们接下来看看 TiDB 和 JuiceFS 在使用 TiKV 时,分别是怎么去更新这个信息的。

2.3 TiDB 中触发 TiKV GC 的方式

TiDB 有 GC 相关的配置和 worker,会按照配置去触发底层的 TiKV GC,

Fig. TiDB SQL layer overview. GC worker is outside of TiKV. Image Source: pingcap.com

更多信息可以参考 [3,4]。

2.4 JuiceFS 触发 TiKV GC 的方式

TiKV 作为元数据引擎时,JuiceFS 并没有使用 TiDB,而是直接使用的 TiKV(和 PD), 所以就需要 JuiceFS client 来触发这个 GC (因为不考虑 CSI 部署方式的话,JuiceFS 就一个客户端组件,也没有其他 long running 服务来做这个事情了)。

Fig. Typical JuiceFS cluster.

2.4.1 定期更新 gc safepoint 的代码

JuiceFS v1.0.4+ 客户端会周期性地设置 PD 中的 gc safepoint,默认是 now-3h,也就是可以删除 3 小时之前的旧版本数据,

// pkg/meta/tkv_tikv.go

func (c *tikvClient) gc() {
    if c.gcInterval == 0 {
        return
    }

    safePoint := c.client.GC(context.Background(), oracle.GoTimeToTS(time.Now().Add(-c.gcInterval)))
}

接下来的调用栈:

gc                                          // github.com/juicedata/juicefs  pkg/meta/tkv_tikv.go
 |-c.client.GC                              // github.com/tikv/client-go     tikv/gc.go
     |-s.pdClient.UpdateGCSafePoint         // github.com/tikv/pd            client/client.go
        |-ctx = grpcutil.BuildForwardContext(ctx, c.GetLeaderAddr())
        |-c.getClient().UpdateGCSafePoint(ctx, req)
                        /
               gRPC    /
          /----<--<----/
         /
UpdateGCSafePoint                           // github.com/tikv/pd  server/grpc_service.go
  |-rc := s.GetRaftCluster()
  |-oldSafePoint := s.storage.LoadGCSafePoint()
  |-s.storage.SaveGCSafePoint(newSafePoint)
              |-key := path.Join(gcPath, "safe_point")  // gcPath = "gc"
              |-value := strconv.FormatUint(safePoint, 16)
              |-return s.Save(key, value)

2.4.2 配置:META URL \?gc-interval=1h

这个 gc-interval 可在 juicefs 挂载卷时加到 TiKV URL 中,

  • 默认值:3h
  • 最小值:1h,设置的值小于这个值会打印一条 warning,然后强制设置为 1h。

juicefs client 挂载时显式设置 gc-interval

$ juicefs mount tikv://localhost:2379\?gc-interval=1h ~/mnt/jfs
<INFO>: Meta address: tikv://localhost:2379?gc-interval=1h [interface.go:491]
<INFO>: TiKV gc interval is set to 1h0m0s [tkv_tikv.go:84]
...

2.4.3 juicefs gc 手动触发 TiKV GC

还可以通过 juicefs gc 子命令来主动触发 TiKV GC。这个例子中设置的时间太短,可以看到被强制改成了允许的最小值 1h

$ juicefs gc tikv://<ip>:2379/foo-dev\?gc-interval=1m --delete
...
<WARNING>: TiKV gc-interval (1m0s) is too short, and is reset to 1h [tkv_tikv.go:133]
<INFO>: TiKV gc interval is set to 1h0m0s [tkv_tikv.go:138]
Cleaned pending slices: 0                      0.0/s
 Pending deleted files: 0                      0.0/s
  Pending deleted data: 0.0 b   (0 Bytes)      0.0 b/s
 Cleaned pending files: 0                      0.0/s
  Cleaned pending data: 0.0 b   (0 Bytes)      0.0 b/s
         Cleaned trash: 0                      0.0/s
Cleaned detached nodes: 0                      0.0/s
         Listed slices: 2047                   4930.4/s
          Trash slices: 2026                   55423.8/s
            Trash data: 7.7 KiB (7883 Bytes)   211.8 KiB/s
  Cleaned trash slices: 0                      0.0/s
    Cleaned trash data: 0.0 b   (0 Bytes)      0.0 b/s
       Scanned objects: 2047/2047 [===========================================]  18138.6/s used: 113.115519ms
         Valid objects: 21                     187.2/s
            Valid data: 85.0 b  (85 Bytes)     758.0 b/s
     Compacted objects: 2026                   18064.2/s
        Compacted data: 7.7 KiB (7883 Bytes)   68.6 KiB/s
        Leaked objects: 0                      0.0/s
           Leaked data: 0.0 b   (0 Bytes)      0.0 b/s
       Skipped objects: 0                      0.0/s
          Skipped data: 0.0 b   (0 Bytes)      0.0 b/s
<INFO>: scanned 2047 objects, 21 valid, 2026 compacted (7883 bytes), 0 leaked (0 bytes), 0 delslices (0 bytes), 0 delfiles (0 bytes), 0 skipped (0 bytes) [gc.go:379]

2.5 外挂组件 github.com/tikv/migration/gc-worker

代码仓库,是个在 TiKV 之上的组件, 从 PD 获取 service safepoint 信息,然后计算 gc safepoint 并更新到 PD,从而触发 TiKV GC。

3 GC 不及时导致的问题一例

这里挑一个典型的问题讨论下。

3.1 问题现象

3.1.1 监控:TiKV db size 暴增,磁盘空间不断减小

如下面监控所示,

Fig. TiKV DB size soaring in a JuiceFS cluster, caused by TiKV GC lagging.

  • TiKV DB size 暴增;
  • TiKV region 分布出现显著变量,总数量也有一定程度上升;
  • TiKV node 可用磁盘空间不断下降。

3.1.2 tikv-server 错误日志:failed to split region

查看 tikv-server 日志,看到一直在刷下面这样的 warning/error:

[WARN] [split_observer.rs:73] ["invalid key, skip"] [err="\"key 6E677... should be in (6E677..., 6E677...)\""] [index=0] [region_id=39179938]
[ERROR] [split_observer.rs:136] ["failed to handle split req"] [err="\"no valid key found for split.\""] [region_id=39179938]
[WARN] [peer.rs:2971] ["skip proposal"] [error_code=KV:Raftstore:Coprocessor] [err="Coprocessor(Other(\"[components/raftstore/src/coprocessor/split_observer.rs:141]: no valid key found for split.\"))"] [peer_id=39179939] [region_id=39179938]

也就是 region split 失败。

3.2 问题排查

  1. 根据日志报错,网上搜到一些帖子,初步了解问题背景(JuiceFS/TiKV 新人,接触没多久);
  2. 对报错日志进行分析,发现:

    • 报错集中在几十个 regiongrep "failed to handle split req" tikv.log | awk '{print $NF}' | sort | uniq -c | sort -n -k1,1),相对总 region 数量很少;
    • pd-ctl region-properties -r <region> 看,发现 start/end key 都来自同一个 volume(命令行操作见下一篇);
    • 根据 volume 监控看,只有一个客户端 set 请求非常高,每秒 400 次请求,而这个 volume 只有几个 GB,可以说非常小;
  3. tikv-ctl mvcc -k <key> 查看有问题的 key,发现超时了,报错说文件(元数据)太大

结合以上三点,判断是某个或少数几个文件的 MVCC 版本太多,导致 TiKV split region 失败,进而不断累积垃圾数据。

3.3 问题根因

以上,猜测直接原因是这个用户 非正常使用 JuiceFS疯狂更新文件,也就是我们 1.1 中例子的极端版。 这导致部分文件的历史版本极其多,TiKV 在 auto split region 时失败。网上也有一些类似的 case(大部分是 TiDB 用户)。

但本质上,还是因为 TiKV 的 GC 太滞后,

  1. 被动 GC(RocksDB compact 方式)的频率不可控,跟集群所有客户端的总 write/update/delete 行为有关;
  2. JuiceFS 的主动 GC 频率太慢,跟不上某些文件的版本增长速度。

    • JuiceFS 默认 now-3h,最小 now-1h,也就是至少会保留一个小时内的所有版本(实际上我们是有个外部服务在定期更新 PD 的 gc safepoint,但也是设置的 now-1h);
    • 根据监控看,异常的 juicefs client 每秒有 400+ set 请求,一个小时就是 144w 次的更新(这些请求更新的文件很集中)。

3.4 解决方式

  1. 写了个程序,允许以非常小的粒度去更新 PD 的 gc safepoint,例如 now-5m, 也就是最多保留最近 5 分钟内的版本,其他的都删掉;这一步下去就有效果了,先稳住了,DB 不再增长,开始缓慢下降;
  2. 通知用户去处理那个看起来异常的客户端(我们没权限登录用户的机器,客户端不可控,这是另一个问题了)。

1+2,DB 开始稳步下降,最终完全恢复正常。

3.5 问题小结

对于 TiKV 这种 MVCC 的元数据引擎来说,JuiceFS 的一条元数据可能会保留多个版本,老版本什么时候删掉很大程度上依赖外部 GC 触发。 如果 GC 间隔太长 + 文件更新太频繁,单条元数据极端情况下就可以占几个 GB,这时候不仅 DB size 暴大,还会导致 TiKV split region 工作不正常。

4 问题讨论

前面看到,JuiceFS 支持配置 TiKV 的 GC 间隔,但从管理和运维层面,这里面也有几个问题可以探讨。

4.1 允许的最小 GC 间隔太大

目前最小是 now-1h,极端情况会导致第 3 节中的问题,TiKV DB size 暴增,集群被打爆。

4.2 GC 配置放在客户端,增加了用户的认知负担和学习成本

  • 用户必需感知 TiKV gc 这个东西,增加认知成本和使用负担;

    用户只是用 JuiceFS volume 读写文件,原则上没有必要去知道 JuiceFS 集群用什么元数据引擎, 甚至还必现了解这种元数据引擎的 GC 知识,后者都是 JuiceFS 集群管理员需要关心和解决的;

  • 用户如果没有配置,就只完全依赖 RocksDB compaction 来 GC,更容易触发版本太多导致的问题。

4.3 管理员运维困境

用户一旦没有显式配置 gc-interval(使用很大的默认值),TiKV 可能就被打爆, 这种情况下用户不知道,管理员知道但可能没短平快的解决办法(不一定有权限管理用户的机器)。

4.4 小结

对集群管理员来说,更好的方式可能是,

  1. 有个(内部或外部)服务,可以按管理员的需求随时和/或定时去 GC;
  2. 用户侧完全不用感知这个事情;
  3. 有 Meta 操作的限流能力(可以隔离有问题的 volume 或 client),下一篇讨论。

参考资料

  1. MVCC in TiKV, pingcap.com, 2016
  2. JuiceFS 元数据引擎最佳实践:TiKV, juicefs.com
  3. Deep Dive into Distributed Transactions in TiKV and TiDB, medium.com, 2024
  4. MVCC garbage collection, TiDB doc, 2024

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎再探:开箱解读 TiKV 中的 JuiceFS 元数据(2024)

Fig. JuiceFS upload/download data bandwidth control.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



有了第一篇的铺垫,本文直接进入正题。

  • 首先创建一个 volume,然后在其中做一些文件操作,然后通过 tikv-ctl 等工具在 TiKV 中查看对应的元数据。
  • 有了这些基础,我们再讨论 JuiceFS metadata key 和 TiKV 的编码格式。

之前有一篇类似的,开箱解读 etcd 中的 Cilium 元数据: What’s inside Cilium Etcd (kvstore)

1 创建一个 volume

创建一个名为 foo-dev 的 JuiceFS volume。

1.1 JuiceFS client 日志

用 juicefs client 的 juicefs format 命令创建 volume,

$ juicefs format --storage oss --bucket <bucket> --access-key <key> --secret-key <secret key> \
  tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev foo-dev

<INFO>: Meta address: tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev
<INFO>: Data use oss://xxx/foo-dev/
<INFO>: Volume is formatted as {
  "Name": "foo-dev",
  "UUID": "ec843b",
  "Storage": "oss",
  "BlockSize": 4096,
  "MetaVersion": 1,
  "UploadLimit": 0,
  "DownloadLimit": 0,
  ...
}
  • 对象存储用的是阿里云 OSS;
  • TiKV 地址指向的是 PD 集群地址,上一篇已经介绍过,2379 是 PD 接收客户端请求的端口;

1.2 JuiceFS client 中的 TiKV/PD client 初始化/调用栈

下面我们进入 JuiceFS 代码,看看 JuiceFS client 初始化和连接到元数据引擎的调用栈:

mount
 |-metaCli = meta.NewClient
 |-txnkv.NewClient(url)                                          // github.com/juicedata/juicefs: pkg/meta/tkv_tikv.go
 |  |-NewClient                                                  // github.com/tikv/client-go:    txnkv/client.go
 |     |-pd.NewClient                                            // github.com/tikv/client-go:    tikv/kv.go
 |     |    |-NewClient                                          // github.com/tikv/pd:           client/client.go
 |     |       |-NewClientWithContext                            // github.com/tikv/pd:           client/client.go
 |     |          |-createClientWithKeyspace                     // github.com/tikv/pd:           client/client.go
 |     |             |-c.pdSvcDiscovery = newPDServiceDiscovery  // github.com/tikv/pd:           client/pd_xx.go
 |     |             |-c.setup()                                 // github.com/tikv/pd:           client/pd_xx.go
 |     |                 |-c.pdSvcDiscovery.Init()
 |     |                 |-c.pdSvcDiscovery.AddServingURLSwitchedCallback
 |     |                 |-c.createTokenDispatcher()
 |     |-spkv, err := tikv.NewEtcdSafePointKV
 |     |-tikv.NewRPCClient
 |     |-tikv.NewKVStore(uuid, pdClient, spkv, rpcClient)        // github.com/tikv/client-go:    tikv/kv.go
 |         |-oracles.NewPdOracle
 |         |-store := &KVStore{}
 |         |-go store.runSafePointChecker()
 |         |     |-check key "/tidb/store/gcworker/saved_safe_point" from etcd every 10s
 |         |-go store.safeTSUpdater()
 |-metaCli.NewSession
    |-doNewSession
       |-m.setValue(m.sessionKey(m.sid), m.expireTime())  // SE
       |-m.setValue(m.sessionInfoKey(m.sid), sinfo)       // SI

这里面连接到 TiKV/PD 的代码有点绕,

  • 传给 juicefs client 的是 PD 集群地址
  • 但代码使用的是 tikv 的 client-go 包,创建的是一个 tikv transaction client
  • 这个 tikv transaction client 里面会去创建 pd client 连接到 PD 集群,

所以,架构上看 juicefs 是直连 PD,但实现上并没有直接创建 pd client, 也没有直接使用 pd 的库。

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

1.3 tikv-ctl 查看空 volume 的系统元数据

现在再把目光转到 TiKV。看看这个空的 volume 在 TiKV 中对应哪些元数据:

$ ./tikv-ctl.sh scan --from 'zfoo' --to 'zfop'
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000I\000\000\000\000\000\000\371  # attr?
key: zfoo-dev\375\377ClastCle\377anupSess\377ions\000\000\000\000\373                    # lastCleanupSessions
key: zfoo-dev\375\377CnextChu\377nk\000\000\000\000\000\000\371                          # nextChunk
key: zfoo-dev\375\377CnextIno\377de\000\000\000\000\000\000\371                          # nextInode
key: zfoo-dev\375\377CnextSes\377sion\000\000\000\000\373                                # nextSession
key: zfoo-dev\375\377SE\000\000\000\000\000\000\377\000\001\000\000\000\000\000\000\371  # session
key: zfoo-dev\375\377SI\000\000\000\000\000\000\377\000\001\000\000\000\000\000\000\371  # sessionInfo
key: zfoo-dev\375\377setting\000\376                                                     # setting

以上就是我们新建的 volume foo-dev 的所有 entry 了。 也就是说一个 volume 创建出来之后,默认就有这些 JuiceFS 系统元数据

TiKV 中的每个 key 都经过了两层编码(JuiceFS 和 TiKV),我们后面再介绍编码规则。 就目前来说,根据 key 中的字符还是依稀能看出每个 key 是干啥用的, 为方便起见直接注释在上面每行的最后了。比如,下面两个 session 相关的 entry 就是上面调用栈最后两个创建的:

  • session
  • sessionInfo

1.4 例子:tikv-ctl mvcc 解码 volume setting 元数据

TiKV 中的每个 entry 都是 key/value。现在我们尝试解码最后一个 entry,key 是 zfoo-dev\375\377setting\000\376, 我们来看看它的 value —— 也就是它的内容 —— 是什么

$ value_hex=$(./tikv-ctl.sh mvcc -k 'zfoo-dev\375\377setting\000\376' --show-cf=default | awk '/default cf value:/ {print $NF}')
$ value_escaped=$(./tikv-ctl.sh --to-escaped $value_hex)
$ echo -e $value_escaped | sed 's/\\"/"/g' | jq .

输出:

{
  "Name": "foo-dev",
  "UUID": "1ce2973b",
  "Storage": "S3",
  "Bucket": "http://xx/bucket",
  "AccessKey": "xx",
  "SecretKey": "xx",
  "BlockSize": 4096,
  "MetaVersion": 1,
  "UploadLimit": 0,
  "DownloadLimit": 0,
  ...
}

可以看到是个 JSON 结构体。这其实就是这个 volume 的配置信息。如果对 JuiceFS 代码有一定了解, 就会看出来它对应的其实就是 type Format 这个 struct。

1.4.1 对应 JuiceFS Format 结构体

// https://github.com/juicedata/juicefs/blob/v1.2.0/pkg/meta/config.go#L72

type Format struct {
    Name             string
    UUID             string
    Storage          string
    StorageClass     string `json:",omitempty"`
    Bucket           string
    AccessKey        string `json:",omitempty"`
    SecretKey        string `json:",omitempty"`
    SessionToken     string `json:",omitempty"`
    BlockSize        int
    Compression      string `json:",omitempty"`
    Shards           int    `json:",omitempty"`
    HashPrefix       bool   `json:",omitempty"`
    Capacity         uint64 `json:",omitempty"`
    Inodes           uint64 `json:",omitempty"`
    UploadLimit      int64  `json:",omitempty"` // Mbps
    DownloadLimit    int64  `json:",omitempty"` // Mbps
    ...
}

2 将 volume 挂载(mount)到机器

接下来我们找一台机器,把这个 volume 挂载上去,这样就能在这个 volume 里面读写文件了。

2.1 JuiceFS client 挂载日志

$ juicefs mount --verbose --backup-meta 0 tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev /tmp/foo-dev
<INFO>:  Meta address: tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev [interface.go:406]
<DEBUG>: Creating oss storage at endpoint http://<url> [object_storage.go:154]
<INFO>:  Data use oss://xx/foo-dev/ [mount.go:497]
<INFO>:  Disk cache (/var/jfsCache/ec843b85/): capacity (10240 MB), free ratio (10%), max pending pages (15) [disk_cache.go:94]
<DEBUG>: Scan /var/jfsCache/ec843b85/raw to find cached blocks [disk_cache.go:487]
<DEBUG>: Scan /var/jfsCache/ec843b85/rawstaging to find staging blocks [disk_cache.go:530]
<DEBUG>: Found 8 cached blocks (32814 bytes) in /var/jfsCache/ec843b85/ with 269.265µs [disk_cache.go:515]
<INFO>:  Create session 4 OK with version: 1.2.0 [base.go:279]
<INFO>:  Prometheus metrics listening on 127.0.0.1:34849 [mount.go:165]
<INFO>:  Mounting volume foo-dev at /tmp/foo-dev ... [mount_unix.go:203]
<INFO>:  OK, foo-dev is ready at /tmp/foo-dev [mount_unix.go:46]

可以看到成功挂载到了本机路径 /tmp/foo-dev/

2.2 查看挂载信息

$ mount | grep juicefs
JuiceFS:foo-dev on /tmp/foo-dev type fuse.juicefs (rw,relatime,user_id=0,group_id=0,default_permissions,allow_other)

$ cd /tmp/foo-dev
$ ls # 空目录

2.3 查看 JuiceFS 隐藏(系统)文件

新建的 volume 里面其实有几个隐藏文件:

$ cd /tmp/foo-dev
$ ll
-r-------- 1 root root  .accesslog
-r-------- 1 root root  .config
-r--r--r-- 1 root root  .stats
dr-xr-xr-x 2 root root  .trash/

2.3.1 .accesslog

可以通过 cat 这个文件看到一些 JuiceFS client 底层的操作日志,我们一会会用到。

2.3.2 .config

包括 Format 在内的一些 volume 配置信息:

$ cat .config
{
 "Meta": {
  "Strict": true,
  "Retries": 10,
  "CaseInsensi": false,
  "ReadOnly": false,
  "NoBGJob": false,
  "OpenCache": 0,
  "Heartbeat": 12000000000,
  "MountPoint": "/tmp/foo-dev",
  "Subdir": "",
  "CleanObjFileLever": 1
 },
 "Format": {
  "Name": "foo-dev",
  "UUID": "ec843b85",
  "Storage": "oss",
  "Bucket": "http://<url>",
  "UploadLimit": 0,
  "DownloadLimit": 0,
  ...
 },
 "Chunk": {
  "CacheDir": "/var/jfsCache/ec843b85",
  "CacheMode": 384,
  "CacheSize": 10240,
  "FreeSpace": 0.1,
  "AutoCreate": true,
  "Compress": "none",
  "MaxUpload": 20,
  "MaxDeletes": 2,
  "MaxRetries": 10,
  "UploadLimit": 0,
  "DownloadLimit": 0,
  "Writeback": false,
  "UploadDelay": 0,
  "HashPrefix": false,
  "BlockSize": 4194304,
  "GetTimeout": 60000000000,
  "PutTimeout": 60000000000,
  "CacheFullBlock": true,
  "BufferSize": 314572800,
  "Readahead": 0,
  "Prefetch": 1,
  "UseMountUploadLimitConf": false,
  "UseMountDownloadLimitConf": false
 },
 "Version": "1.2.0",
 "AttrTimeout": 1000000000,
 "DirEntryTimeout": 1000000000,
 "EntryTimeout": 1000000000,
 "BackupMeta": 0,
 "HideInternal": false
}

2.3.3 .stats

cat 能输出一些 prometheus metrics:

$ cat .stats
...
juicefs_uptime 374.021754516
juicefs_used_buffer_size_bytes 0
juicefs_used_inodes 7
juicefs_used_space 28672

用 prometheus 采集器把这个数据收上去,就能在 grafana 上展示 volume 的各种内部状态。

2.3.4 .trash

类似于 Windows 的垃圾箱。如果启用了,删掉的文件会在里面保存一段时间再真正从对象存储删掉。

3 创建、更新、删除文件

接下来做一些文件操作,看看 TiKV 中对应元数据的变化。

3.1 创建文件

3.1.1 创建文件

$ cd /tmp/foo-dev
$ echo test3 > file3.txt

3.1.2 JuiceFS .accesslog

$ cat .accesslog
[uid:0,gid:0,pid:169604] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585251,1725585251,4096]) <0.001561>
[uid:0,gid:0,pid:169604] lookup (1,file3.txt): no such file or directory <0.000989>
[uid:0,gid:0,pid:169604] create (1,file3.txt,-rw-r-----:0100640): OK (103,[-rw-r-----:0100640,1,0,0,1725585318,1725585318,1725585318,0]) [fh:27] <0.003850>
[uid:0,gid:0,pid:169604] flush (103,27): OK <0.000005>
[uid:0,gid:0,pid:169604] write (103,6,0,27): OK <0.000048>
[uid:0,gid:0,pid:169604] flush (103,27): OK <0.026205>
[uid:0,gid:0,pid:0     ] release (103): OK <0.000006>
[uid:0,gid:0,pid:169749] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585318,1725585318,4096]) <0.000995>
[uid:0,gid:0,pid:169750] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585318,1725585318,4096]) <0.001219>

3.1.3 TiKV 元数据

$ ./tikv-ctl.sh scan --from 'zfoo' --to 'zfop' --limit 100
...
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile3.\377txt\000\000\000\000\000\372
...

可以看到 meta 中多了几条元数据,依稀可以分辨出对应的就是我们创建的文件,

  1. 这个 key 经过了 juicefs 和 tikv 两次编码,
  2. 简单来说,它是 volume + 0xFD(8 进制的 \375)+ 文件名 + tikv 编码,最终得到的就是上面看到的这个 key。

对应的 value 一般长这样:

$ ./tikv-ctl.sh mvcc -k 'zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile3.\377txt\000\000\000\000\000\372' --show-cf default,lock,write
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile3.\377txt\000\000\000\000\000\372
         write cf value: start_ts: 452330816414416901 commit_ts: 452330816414416903 short_value: 010000000000000002

先粗略感受一下,后面再具体介绍 key/value 的编解码规则。

3.2 删除文件操作

3.2.1 删除文件

rm file4.txt

3.2.2 JuiceFS .accesslog

$ cat .accesslog
[uid:0,gid:0,pid:169604] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585532,1725585532,4096]) <0.001294>
[uid:0,gid:0,pid:169902] lookup (1,file4.txt): OK (104,[-rw-r-----:0100640,1,0,0,1725585532,1725585532,1725585532,6]) <0.001631>
[uid:0,gid:0,pid:169902] unlink (1,file4.txt): OK <0.004206>
[uid:0,gid:0,pid:169904] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585623,1725585623,4096]) <0.000718>
[uid:0,gid:0,pid:169905] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585623,1725585623,4096]) <0.000843>

3.2.3 TiKV 元数据

对应的元数据就从 TiKV 删掉了。

3.3 更新(追加)文件

3.3.1 更新文件

$ echo test3 >> file3.txt

3.3.2 JuiceFS .accesslog

$ cat .accesslog
[uid:0,gid:0,pid:169604] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585623,1725585623,4096]) <0.001767>
[uid:0,gid:0,pid:169604] lookup (1,file3.txt): OK (103,[-rw-r-----:0100640,1,0,0,1725585318,1725585318,1725585318,6]) <0.001893>
[uid:0,gid:0,pid:169604] open (103): OK [fh:51] <0.000884>
[uid:0,gid:0,pid:169604] flush (103,51): OK <0.000011>
[uid:0,gid:0,pid:169604] write (103,6,6,51): OK <0.000068>
[uid:0,gid:0,pid:169604] flush (103,51): OK <0.036778>
[uid:0,gid:0,pid:0     ] release (103): OK <0.000024>

3.3.3 TiKV 元数据

  • 如果追加的内容不多,TiKV 中还是那条元数据,但 value 会被更新;
  • 如果追加的内容太多(例如几百兆),文件就会被切分,这时候元数据就会有多条了。

4 元数据操作和 TiKV key/value 编码规则

上一节简单看了下创建、更新、删除 volume 中的文件,TiKV 中对应的元数据都有什么变化。 我们有意跳过了 key/value 是如何编码的,这一节就来看看这块的内容。

4.1 JuiceFS key 编码规则

4.1.1 每个 key 的公共前缀:<vol_name> + 0xFD

TiKV 客户端初始化:每个 key 的 base 部分<vol_name> + 0xFD

// pkg/meta/tkv_tikv.go

func init() {
    Register("tikv", newKVMeta)
    drivers["tikv"] = newTikvClient
}

func newTikvClient(addr string) (tkvClient, error) {
    client := txnkv.NewClient(strings.Split(tUrl.Host, ","))
    prefix := strings.TrimLeft(tUrl.Path, "/")
    return withPrefix(&tikvClient{client.KVStore, interval}, append([]byte(prefix), 0xFD)), nil
}

4.1.2 每个 key 后面的部分

根据对应的是文件、目录、文件属性、系统元数据等等,会有不同的编码规则:

// pkg/meta/tkv.go

/**
  Ino     iiiiiiii
  Length  llllllll
  Indx    nnnn
  name    ...
  sliceId cccccccc
  session ssssssss
  aclId   aaaa

All keys:
  setting            format
  C...               counter
  AiiiiiiiiI         inode attribute
  AiiiiiiiiD...      dentry
  AiiiiiiiiPiiiiiiii parents // for hard links
  AiiiiiiiiCnnnn     file chunks
  AiiiiiiiiS         symlink target
  AiiiiiiiiX...      extented attribute
  Diiiiiiiillllllll  delete inodes
  Fiiiiiiii          Flocks
  Piiiiiiii          POSIX locks
  Kccccccccnnnn      slice refs
  Lttttttttcccccccc  delayed slices
  SEssssssss         session expire time
  SHssssssss         session heartbeat // for legacy client
  SIssssssss         session info
  SSssssssssiiiiiiii sustained inode
  Uiiiiiiii          data length, space and inodes usage in directory
  Niiiiiiii          detached inde
  QDiiiiiiii         directory quota
  Raaaa                 POSIX acl
*/

具体可以再看看这个文件中的代码。

4.1.3 最终格式:字节序列

// pkg/meta/tkv.go

func (m *kvMeta) fmtKey(args ...interface{}) []byte {
    b := utils.NewBuffer(uint32(m.keyLen(args...)))
    for _, a := range args {
        switch a := a.(type) {
        case byte:
            b.Put8(a)
        case uint32:
            b.Put32(a)
        case uint64:
            b.Put64(a)
        case Ino:
            m.encodeInode(a, b.Get(8))
        case string:
            b.Put([]byte(a))
        default:
            panic(fmt.Sprintf("invalid type %T, value %v", a, a))
        }
    }
    return b.Bytes()
}

4.2 TiKV 对 JuiceFS key 的进一步编码

JuiceFS client 按照以上规则拼好一个 key 之后,接下来 TiKV 会再进行一次编码:

  1. 加一些 TiKV 的前缀,例如给文件 key 加个 z 前缀;

  2. 转义,例如 8 个字节插入一个 \377(对应 0xFF),不够 8 字节的补全等等;

最终得到的就是我们用 tikv-ctl scan 看到的那些 key。

4.3 例子:查看特殊元数据:volume 的 setting/format 信息

JuiceFS 的 Format 配置保存在 tikv 中,原始 key 是 setting,经过以上两层编码就变成了下面的样子:

$ ./tikv-ctl.sh scan --from 'zfoo' --to 'zfop' --limit 100
key: zfoo-dev\375\377setting\000\376
        default cf value: start_ts: 452330324173520898 value: 7B0A22...

其中的 value 是可以解码出来的,

# hex -> escaped string
$ ./tikv-ctl.sh --to-escaped '7B0A22...'
{\n\"Name\": \"foo-dev\",\n\"UUID\": \"8cd1ac73\",\n\"Storage\": \"S3\",\n\"Bucket\": \"http://xxx\",\n\"AccessKey\": \"...\",\n\"BlockSize\": 4096,\n\"Compression\": \"none\",\n\"KeyEncrypted\": true,\n\"MetaVersion\": 1,\n\"UploadLimit\": 0,\n\"DownloadLimit\": 0,\n\"\": \"\"\n}

对应的就是 pkg/meta/config.go 中的 Format 结构体。

5 总结

本文结合一些具体 JuiceFS 操作,分析了 TiKV 内的元数据格式与内容。

参考资料

  1. What’s inside Cilium Etcd (kvstore)

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎初探:高层架构、引擎选型、读写工作流(2024)

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 JuiceFS 高层架构与组件

Fig. JuiceFS components and architecutre.

如图,最粗的粒度上可以分为三个组件。

1.1 JuiceFS client

  • juicefs format ... 可以创建一个 volume;
  • juicefs config ... 可以修改一个 volume 的配置;
  • juicefs mount ... 可以把一个 volume 挂载到机器上,然后用户就可以在里面读写文件了;

1.2 Metatdata engine(元数据引擎)

  • 用于存储 JuiceFS 的元数据,例如每个文件的文件名、最后修改时间等等;
  • 可选择 etcd、TiKV 等等;

1.3. Object store

实际的对象存储,例如 S3、Ceph、阿里云 OSS 等等,存放 JuiceFS volume 内的数据。

2 JuiceFS 元数据存储引擎对比:tikv vs. etcd

2.1 设计与优缺点对比

  TiKV as metadata engine etcd as metadata engine
管理节点(e.g. leader election) PD (TiKV cluster manager) etcd server
数据节点(存储 juicefs metadata) TiKV server etcd server
数据节点对等 无要求 完全对等
数据一致性粒度 region-level (TiKV 的概念,region < node) node-level
Raft 粒度 region-level (multi-raft,TiKV 的概念) node-level
缓存多少磁盘数据在内存中 一部分 所有
集群支持的最大数据量 PB 级别 几十 GB 级别
性能(JuiceFS 场景) 高(猜测是因为 raft 粒度更细,并发读写高)
维护和二次开发门槛 高(相比 etcd)
流行度 & 社区活跃度 低(相比 etcd)
适用场景 大和超大 JuiceFS 集群 中小 JuiceFS 集群

2.2 几点解释

etcd 集群,

  • 每个节点完全对等,既负责管理又负责存储数据;
  • 所有数据全部缓存在内存中,每个节点的数据完全一致。 这一点限制了 etcd 集群支持的最大数据量和扩展性, 例如现在官网还是建议不要超过 8GB(实际上较新的版本在技术上已经没有这个限制了, 但仍受限于机器的内存)。

TiKV 方案可以可以理解成把管理和数据存储分开了,

  • PD 可以理解为 TiKV cluster manager,负责 leader 选举、multi-raft、元数据到 region 的映射等等;
  • 节点之间也不要求对等,PD 按照 region(比如 96MB)为单位,将 N(默认 3)个副本放到 N 个 TiKV node 上,而实际上 TiKV 的 node 数量是 M,M >= N
  • 数据放在 TiKV 节点的磁盘,内存中只缓存一部分(默认是用机器 45% 的内存,可控制)。

2.3 例子:TiKV 集群 engine size 和内存使用监控

TiKV 作为存储引擎,总结成一句话就是:根据硬件配置干活,能者多劳 —— 内存大、磁盘大就多干活,反之就少干活。

下面的监控展示是 7 台 TiKV node 组成的一个集群,各 node 内存不完全一致: 3 台 256GB 的,2 台 128GB 的,2 台 64GB 的, 可以看到每个 TiKV server 确实只用了各自所在 node 一半左右的内存:

Fig. TiKV engine size and memory usage of a 7-node (with various RAMs) cluster.

3 JuiceFS + TiKV:集群启动和宏观读写流程

3.1 架构

用 TiKV 作为元数据引擎,架构如下(先忽略其中的细节信息,稍后会介绍):

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

3.2 TiKV 集群启动

3.2.1 TiKV & PD 配置差异

两个组件的几个核心配置项,

$ cat /etc/tikv/pd-config.toml
name = "pd-node1"
data-dir = "/var/data/pd"

client-urls = "https://192.168.1.1:2379" # 客户端(例如 JuiceFS)访问 PD 时,连接这个地址
peer-urls   = "https://192.168.1.1:2380" # 其他 PD 节点访问这个 PD 时,连接这个地址,也就是集群内互相通信的地址

# 创建集群时的首批 PD
initial-cluster-token = "<anything you like>"
initial-cluster = "pd-node1=https://192.168.1.3:2380,pd-node2=https://192.168.1.2:2380,pd-node3=https://192.168.1.1:2380"

可以看到,PD 的配置和 etcd 就比较类似,需要指定其他 PD 节点地址,它们之间互相通信。

TiKV 节点(tikv-server)的配置就不一样了,

$ cat /etc/tikv/tikv-config.toml
[pd]
endpoints = ["https://192.168.1.1:2379", "https://192.168.1.2:2379", "https://192.168.1.3:2379"]

[server]
addr = "192.168.1.1:20160"        # 服务地址,JuiceFS client 会直接访问这个地址读写数据
status-addr = "192.168.1.1:20180" # prometheus 

可以看到,

  1. TiKV 会配置所有 PD 节点的地址,以便自己注册到 PD 作为一个数据节点(存储JuiceFS 元数据);
  2. TiKV 还会配置一个地址的 server 地址,这个读写本节点所管理的 region 内的数据用的; 正常流程是 JuiceFS client 先访问 PD,拿到 region 和 tikv-server 信息, 然后再到 tikv-server 来读写数据(对应 JuiceFS 的元数据);
  3. TiKV 不会配置其他 TiKV 节点的地址,也就是说 TiKV 节点之间不会 peer-to-peer 互连。 属于同一个 raft group 的多个 region 通信,也是先通过 PD 协调的,最后 region leader 才发送数据给 region follower。 详见 [1]。

3.2.2 服务启动

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

对应图中 step 1 & 2:

  • step 1. PD 集群启动,选主;
  • step 2. TiKV 节点启动,向 PD 注册;每个 TiKV 节点称为一个 store,也就是元数据仓库。

3.3 宏观读写流程

对应图中 step 3~5:

  • step 3. JuiceFS 客户端连接到 PD;发出读写文件请求;

    • JuiceFS 客户端中会初始化一个 TiKV 的 transaction kv client,这里面又会初始化一个 PD client,
    • 简单来说,此时 JuiceFS 客户端就有了 PD 集群的信息,例如哪个文件对应到哪个 region,这个 region 分布在哪个 TiKV 节点上,TiKV 服务端连接地址是多少等等;
  • step 4. JuiceFS (内部的 TiKV 客户端)直接向 TiKV 节点(准确说是 region leader)发起读写请求;
  • step 5. 元数据处理完成,JuiceFS 客户端开始往对象存储里读写文件。

4 TiKV 内部数据初探

TiKV 内部存储的都是 JuiceFS 的元数据。具体来说又分为两种:

  1. 用户文件的元数据:例如用户创建了一个 foo.txt,在 TiKV 里面就会对应一条或多条元数据来描述这个文件的信息;
  2. JuiceFS 系统元数据:例如每个 volume 的配置信息,这些对用户是不可见的。

TiKV 是扁平的 KV 存储,所以以上两类文件都放在同一个扁平空间,通过 key 访问。 本文先简单通过命令看看里面的元数据长什么样,下一篇再结合具体 JuiceFS 操作来深入解读这些元数据。

4.1 简单脚本 tikv-ctl.sh/pd-ctl.sh

简单封装一下对应的命令行工具,使用更方便,

$ cat pd-ctl.sh
tikv-ctl \
        --ca-path /etc/tikv/pki/root.crt --cert-path /etc/tikv/pki/tikv.crt --key-path /etc/tikv/pki/tikv.key \
        --host 192.168.1.1:20160 \
        "$@"

$ cat pd-ctl.sh
pd-ctl \
        --cacert /etc/tikv/pki/root.crt --cert /etc/tikv/pki/pd.crt --key /etc/tikv/pki/pd.key \
        --pd https://192.168.1.1:2379  \
        "$@"

4.2 tikv-ctl scan 扫描 key/value

tikv-ctl 不支持只列出所有 keys,所以只能 key 和 value 一起打印(扫描)。

扫描前缀是 foo 开头的所有 key:

$ ./tikv-ctl.sh scan --from 'zfoo' --to 'zfop' --limit 100
...
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile3.\377txt\000\000\000\000\000\372
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile4.\377txt\000\000\000\000\000\372
...
key: zfoo-dev\375\377setting\000\376
        default cf value: start_ts: 452330324173520898 value: 7B0A22...

扫描的时候一定要在 key 前面加一个 z 前缀,这是 TiKV 的一个设计

The raw-scan command scans directly from the RocksDB. Note that to scan data keys you need to add a ‘z’ prefix to keys.

代码出处 components/keys/src/lib.rs。 但对用户来说不是太友好,暴露了太多内部细节,没有 etcdctl 方便直接。

4.3 tikv-ctl mvcc 查看给定 key 对应的 value

$ ./tikv-ctl.sh mvcc -k 'zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile1.\377txt\000\000\000\000\000\372' --show-cf default,lock,write
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile1.\377txt\000\000\000\000\000\372
         write cf value: start_ts: 452330816414416901 commit_ts: 452330816414416903 short_value: 010000000000000002

CF 是 column family 的缩写,进一步了解,可参考 Google bigtable 中关于 CF 的定义 译 | Bigtable: A Distributed Storage System for Structured Data (OSDI, 2006)

4.4 tikv-ctl --decode <key> 解除字符转义

# tikv escaped format -> raw format
./tikv-ctl.sh --decode 'foo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile4.\377txt\000\000\000\000\000\372'
foo-dev\375A\001\000\000\000\000\000\000\000Dfile4.txt

4.5 tikv-ctl --to-hex:转义表示 -> 十六进制表示

$ ./tikv-ctl.sh --to-hex '\375'
FD

4.6 tikv-ctl --to-escaped <value>:十六进制 value -> 带转义的字符串

./tikv-ctl.sh scan --from 'zfoo' --to 'zfop' --limit 100
key: zfoo-dev\375\377setting\000\376
        default cf value: start_ts: 452330324173520898 value: 7B0A22...

其中的 value 是可以解码出来的,

# hex -> escaped string
$ ./tikv-ctl.sh --to-escaped '7B0A22...'
{\n\"Name\": \"...\",\n\"UUID\": \"8cd1ac73\",\n\"Storage\": \"S3\",\n\"Bucket\": \"http://xxx\",\n\"AccessKey\": \"...\",\n\"BlockSize\": 4096,\n\"Compression\": \"none\",\n\"KeyEncrypted\": true,\n\"MetaVersion\": 1,\n\"UploadLimit\": 0,\n\"DownloadLimit\": 0,\n\"\": \"\"\n}

5 总结

本文介绍了一些 JuiceFS 元数据引擎相关的内容。

参考资料

  1. A Deep Dive into TiKV, 2016, pincap.com

Written by Human, Not by AI Written by Human, Not by AI

图解 JuiceFS CSI 工作流:K8s 创建带 PV 的 Pod 时,背后发生了什么(2024)

JuiceFS 是一个架设在对象存储(S3、Ceph、OSS 等)之上的分布式文件系统, 简单来说,

  • 对象存储:只能通过 key/value 方式使用;
  • 文件系统:日常看到的文件目录,能执行 ls/cat/find/truncate 等等之类的文件读写操作。

本文从 high-level 梳理了 JuiceFS CSI 方案中,当创建一个带 PV 的 pod 以及随后 pod 读写 PV 时, k8s/juicefs 组件在背后都做了什么,方便快速了解 K8s CSI 机制及 JuiceFS 的基本工作原理。

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 背景知识

简单列几个基础知识,有背景的可直接跳过。

1.1 K8s CSI (Container Storage Interface )

The Container Storage Interface (CSI) is a standard for exposing arbitrary block and file storage systems to containerized workloads on Container Orchestration Systems (COs) like Kubernetes.

https://kubernetes-csi.github.io/docs/

CSI 是 K8s 支持的一种容器存储机制,扩展性非常好, 各存储方案只要根据规范实现一些接口,就能集成到 k8s 中提供存储服务。

一般来说,存储方案需要在每个 node 上部署一个称为 “CSI plugin” 的服务, kubelet 在创建带 PV 容器的过程中会调用这个 plugin。但要注意,

  • K8s 的网络插件 CNI plugin 是一个可执行文件, 放在 /opt/cni/bin/ 下面就行了,kubelet 在创建 pod 网络时直接运行 这个可执行文件;
  • K8s 的存储插件 CSI plugin 是一个服务(某种程度上, 称为 agent 更好理解),kubelet 在初始化 PV 时通过 gRPC 调用这个 plugin;

1.2 FUSE (Filesystem in Userspace)

FUSE 是一种用户态文件系统,使得用户开发自己的文件系统非常方便。

懒得再重新画图, 这里借 lxcfs(跟 juicefs 没关系,但也是一种 FUSE 文件系统)展示一下 FUSE 的基本工作原理

Linux 容器底层工作机制:从 500 行 C 代码到生产级容器运行时(2023)

Fig. lxcfs/fuse workflow: how a read operation is handled [2]

JuiceFS 基于 FUSE 实现了一个用户态文件系统。

来自社区文档的一段内容,简单整理:

传统上,实现一个 FUSE 文件系统,需要基于 Linux libfuse 库,它提供两种 API:

  • high-level API:基于文件名和路径

    libfuse 内部做了 VFS 树的模拟,对外暴露基于路径的 API。

    适合元数据本身是基于路径提供的 API 的系统,比如 HDFS 或者 S3 之类。 如果元数据本身是基于 inode 的目录树,这种 inode → path →inode 的转换就会 影响性能。

  • low-level API:基于 inode。内核的 VFS 跟 FUSE 库交互就使用 low-level API。

JuiceFS 的元数据基于 inode 组织,所以用 low-level API 实现( 依赖 go-fuse 而非 libfuse),简单自然,性能好。

1.3 JuiceFS 三种工作模式

JuiceFS 有几种工作或部署方式:

  1. 进程挂载模式

    JuiceFS client 运行在 CSI Node plugin 容器中,所有需要挂载的 JuiceFS PV 都会在这个容器内以进程模式挂载。

  2. CSI 方式,又可分为两种:

    1. mountpod 方式:在每个 node 上,CSI plugin 动态为每个被 local pod 使用的 PV 创建一个保姆 pod,

      • 这个 mount pod 是 per-PV 而非 per-business-pod 的, 也就是说如果 node 上有多个业务 pod 在使用同一 PV,那只会有一个 mount pod, 下图可以看出来,

        Fig. JuiceFS as K8s CSI solution: workflow when a business pod is created (JuiceFS mountpod mode).

      • mount pod 里面装了 juicefs client,替业务 pod 完成 juicefs 相关的读写操作; 为了从字面上更容易理解,本文接下来把 mount pod 称为 dynamic client pod 或 client pod。
      • 这是 JuiceFS CSI 的默认工作方式
      • FUSE 需要 mount pod 具有 privilege 权限;
      • client pod 重启会导致业务 pod 一段时间读写不可用,但 client pod 好了之后业务 pod 就能继续读写了。
    2. . CSI sidecar 方式:给每个使用 juicefs PV 的业务 pod 创建一个 sidecar 容器。

      • per-pod 级别的 sidecar;
      • 注意 sidecar 就不是 JuiceFS plugin 创建的了,CSI Controller 会注册一个 Webhook 来监听容器变动,在创建 pod 时, webhook 给 pod yaml 自动注入一个 sidecar,跟 Istio 自动给 pod 注入 Envoy 容器类似;
      • Sidecar 重启需要重建业务 Pod 才能恢复。
      • 也依赖 FUSE,所以 sidecar 需要 privilege 权限。这会导致每个 sidecar 都能看到 node 上所有设备,有风险,所以不建议;

1.4 小结

有了以上基础,接下来看 k8s 中创建一个业务 pod 并且它要求挂载一个 PV 时,k8s 和 juicefs 组件都做了什么事情。

2 创建一个使用 PV 的 pod 时,k8s 和 juicefs 组件都做了什么

Fig. JuiceFS as K8s CSI solution: workflow when a business pod is created (JuiceFS mountpod mode).

Step 1:kubelet 启动,监听集群的 pod 资源变化

kubelet 作为 k8s 在每个 node 上的 agent,在启动后会监听整个 k8s 集群中的 pod 资源变化。 具体来说就是,kube-apiserver 中有 pod create/update/delete events 发生时,kubelet 都会立即收到。

Step 2:kubelet 收到业务 pod 创建事件,开始创建 pod

kubelet 收到一条 pod create 事件后,首先判断这个 pod 是否在自己的管辖范围内(spec 中的 nodeName 是否是这台 node), 是的话就开始创建这个 pod

Step 2.1 创建业务 pod:初始化部分

kubelet.INFO 中有比较详细的日志:

10:05:57.410  Receiving a new pod "pod1(<pod1-id>)"
10:05:57.411  SyncLoop (ADD, "api"): "pod1(<pod1-id>)"
10:05:57.411  Needs to allocate 2 "nvidia.com/gpu" for pod "<pod1-id>" container "container1"
10:05:57.411  Needs to allocate 1 "our-corp.com/ip" for pod "<pod1-id>" container "container1"
10:05:57.413  Cgroup has some missing paths: [/sys/fs/cgroup/pids/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/systemd/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpuset/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/memory/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/hugetlb/kubepods/burstable/pod<pod1-id>]
10:05:57.413  Cgroup has some missing paths: [/sys/fs/cgroup/memory/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/systemd/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/hugetlb/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/pids/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpuset/kubepods/burstable/pod<pod1-id>]
10:05:57.413  Cgroup has some missing paths: [/sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/pids/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpuset/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/systemd/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/memory/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/hugetlb/kubepods/burstable/pod<pod1-id>]
10:05:57.415  Using factory "raw" for container "/kubepods/burstable/pod<pod1-id>"
10:05:57.415  Added container: "/kubepods/burstable/pod<pod1-id>" (aliases: [], namespace: "")
10:05:57.419  Waiting for volumes to attach and mount for pod "pod1(<pod1-id>)"

10:05:57.432  SyncLoop (RECONCILE, "api"): "pod1(<pod1-id>)"

10:05:57.471  Added volume "meminfo" (volSpec="meminfo") for pod "<pod1-id>" to desired state.
10:05:57.471  Added volume "cpuinfo" (volSpec="cpuinfo") for pod "<pod1-id>" to desired state.
10:05:57.471  Added volume "stat" (volSpec="stat") for pod "<pod1-id>" to desired state.
10:05:57.480  Added volume "share-dir" (volSpec="pvc-6ee43741-29b1-4aa0-98d3-5413764d36b1") for pod "<pod1-id>" to desired state.
10:05:57.484  Added volume "data-dir" (volSpec="juicefs-volume1-pv") for pod "<pod1-id>" to desired state.
...

可以看出里面会依次处理 pod 所需的各种资源:

  1. 设备:例如 GPU
  2. IP 地址;
  3. cgroup 资源隔离配置;
  4. volumes

本文主要关注 volume 资源。

Step 2.2 处理 pod 依赖的 volumes

上面日志可以看到,业务 pod 里面声明了一些需要挂载的 volumes。几种类型

  1. hostpath 类型:直接把 node 路径挂载到容器内;
  2. lxcfs 类型:为了解决资源视图问题 [2];
  3. 动态/静态 PV 类型

本文的 JuiceFS volume 就属于 PV 类型,继续看 kubelet 日志:

# kubelet.INFO
10:05:57.509  operationExecutor.VerifyControllerAttachedVolume started for volume "xxx"
10:05:57.611  Starting operationExecutor.MountVolume for volume "xxx" (UniqueName: "kubernetes.io/host-path/<pod1-id>-xxx") pod "pod1" (UID: "<pod1-id>") 
10:05:57.611  operationExecutor.MountVolume started for volume "juicefs-volume1-pv" (UniqueName: "kubernetes.io/csi/csi.juicefs.com^juicefs-volume1-pv") pod "pod1" (UID: "<pod1-id>") 
10:05:57.611  kubernetes.io/csi: mounter.GetPath generated [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:57.611  kubernetes.io/csi: created path successfully [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv]
10:05:57.611  kubernetes.io/csi: saving volume data file [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/vol_data.json]
10:05:57.611  kubernetes.io/csi: volume data file saved successfully [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/vol_data.json]
10:05:57.613  MountVolume.MountDevice succeeded for volume "juicefs-volume1-pv" (UniqueName: "kubernetes.io/csi/csi.juicefs.com^juicefs-volume1-pv") pod "pod1" (UID: "<pod1-id>") device mount path "/var/lib/k8s/kubelet/plugins/kubernetes.io/csi/pv/juicefs-volume1-pv/globalmount"
10:05:57.616  kubernetes.io/csi: mounter.GetPath generated [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:57.616  kubernetes.io/csi: Mounter.SetUpAt(/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount)
10:05:57.616  kubernetes.io/csi: created target path successfully [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:57.618  kubernetes.io/csi: calling NodePublishVolume rpc [volid=juicefs-volume1-pv,target_path=/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:57.713  Starting operationExecutor.MountVolume for volume "juicefs-volume1-pv" (UniqueName: "kubernetes.io/csi/csi.juicefs.com^juicefs-volume1-pv") pod "pod1" (UID: "<pod1-id>") 
...
10:05:59.506  kubernetes.io/csi: mounter.SetUp successfully requested NodePublish [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:59.506  MountVolume.SetUp succeeded for volume "juicefs-volume1-pv" (UniqueName: "kubernetes.io/csi/csi.juicefs.com^juicefs-volume1-pv") pod "pod1" (UID: "<pod1-id>") 
10:05:59.506  kubernetes.io/csi: mounter.GetPath generated [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]

对于每个 volume,依次执行,

  1. operationExecutor.VerifyControllerAttachedVolume() 方法,做一些检查;
  2. operationExecutor.MountVolume() 方法,将指定的 volume 挂载到容器目录;
  3. 对于 CSI 存储,还会调用到 CSI plugin 的 NodePublishVolume() 方法,初始化对应的 PV,JuiceFS 就是这种模式。

接下来 kubelet 会不断检测所有 volumes 是否都挂载好,没好的话不会进入下一步(创建 sandbox 容器)。

Step 3:kubelet --> CSI plugin(juicefs):setup PV

下面进一步看一下 node CSI plugin 初始化 PV 挂载的逻辑。调用栈

         gRPC NodePublishVolume()
kubelet ---------------------------> juicefs node plugin (also called "driver", etc)

Step 4:JuiceFS CSI plugin 具体工作

看一下 JuiceFS CSI node plugin 的日志,这里直接在机器上看:

(node) $ docker logs --timestamps k8s_juicefs-plugin_juicefs-csi-node-xxx | grep juicefs-volume1
10:05:57.619 NodePublishVolume: volume_id is juicefs-volume1-pv

10:05:57.619 NodePublishVolume: creating dir /var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount

10:05:57.620 ceFormat cmd: [/usr/local/bin/juicefs format --storage=OSS --bucket=xx --access-key=xx --secret-key=${secretkey} --token=${token} ${metaurl} juicefs-volume1]
10:05:57.874 Format output is juicefs <INFO>: Meta address: tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1
10:05:57.874 cefs[1983] <INFO>: Data use oss://<bucket>/juicefs-volume1/

10:05:57.875 Mount: mounting "tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1" at "/jfs/juicefs-volume1-pv" with options [token=xx]

10:05:57.884 createOrAddRef: Need to create pod juicefs-node1-juicefs-volume1-pv.
10:05:57.891 createOrAddRed: GetMountPodPVC juicefs-volume1-pv, err: %!s(<nil>)
10:05:57.891 ceMount: mount tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1 at /jfs/juicefs-volume1-pv
10:05:57.978 createOrUpdateSecret: juicefs-node1-juicefs-volume1-pv-secret, juicefs-system
10:05:59.500 waitUtilPodReady: Pod juicefs-node1-juicefs-volume1-pv is successful

10:05:59.500 NodePublishVolume: binding /jfs/juicefs-volume1-pv at /var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount with options []
10:05:59.505 NodePublishVolume: mounted juicefs-volume1-pv at /var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount with options []

可以看到确实执行了 NodePublishVolume() 方法, 这个方法是每个 CSI plugin 方案各自实现的,所以里面做什么事情就跟存储方案有很大关系。 接下来具体看看 JuiceFS plugin 做的什么。

Step 4.1 给 pod PV 创建挂载路径,初始化 volume

默认配置下,每个 pod 会在 node 上对应一个存储路径,

(node) $ ll /var/lib/k8s/kubelet/pods/<pod-id>
containers/
etc-hosts
plugins/
volumes/

juicefs plugin 会在以上 volumes/ 目录内给 PV 创建一个对应的子目录和挂载点,

/var/lib/k8s/kubelet/pods/{pod1-id}/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount

然后用 juicefs 命令行工具格式化

$ /usr/local/bin/juicefs format --storage=OSS --bucket=xx --access-key=xx --secret-key=${secretkey} --token=${token} ${metaurl} juicefs-volume1

例如,如果 JuiceFS 对接的是阿里云 OSS,上面就对应阿里云的 bucket 地址及访问秘钥。

Step 4.2 volume 挂载信息写入 MetaServer

此外,还会把这个挂载信息同步到 JuiceFS 的 MetaServer,这里用的是 TiKV,暂不展开:

Fig. JuiceFS as K8s CSI solution: workflow when a business pod is created (JuiceFS mountpod mode).

Step 4.3 JuiceFS plugin:如果 client pod 不存在,就创建一个

JuiceFS CSI plugin 判断这个 PV 在 node 上是否已经存在 client pod,如果不存在,就创建一个;存在就不用再创建了。

当 node 上最后一个使用某 PV 的业务 pod 销毁后,对应的 client pod 也会被 juicefs CSI plugin 自动删掉。

我们这个环境用的是 dynamic client pod 方式,因此会看到如下日志:

(node) $ docker logs --timestamps <csi plugin container> | grep 
...
10:05:57.884 createOrAddRef: Need to create pod juicefs-node1-juicefs-volume1-pv.
10:05:57.891 createOrAddRed: GetMountPodPVC juicefs-volume1-pv, err: %!s(<nil>)
10:05:57.891 ceMount: mount tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1 at /jfs/juicefs-volume1-pv
10:05:57.978 createOrUpdateSecret: juicefs-node1-juicefs-volume1-pv-secret, juicefs-system
10:05:59.500 waitUtilPodReady:

JuiceFS node plugin 会去 k8s 里面创建一个名为 juicefs-{node}-{volume}-pv 的 dynamic client pod。

Fig. JuiceFS as K8s CSI solution: workflow when a business pod is created (JuiceFS mountpod mode).

Step 5:kubelet 监听到 client pod 创建事件

这时候 kubelet 的业务 pod 还没创建好,“伺候”它的 juicefs client pod 又来“请求创建”了:

(node) $ grep juicefs-<node>-<volume>-pv /var/log/kubernetes/kubelet.INFO | grep "received "
10:05:58.288 SyncPod received new pod "juicefs-node1-volume1-pv_juicefs-system", will create a sandbox for it

所以接下来进入创建 juicefs dynamic client pod 的流程。

兵马未动,粮草先行。juicefs client pod 没有好,业务 pod 即使起来了也不能读写 juicefs volume

Step 6:kubelet 创建 client pod

创建 client pod 的流程跟业务 pod 是类似的,但这个 pod 比较简单,我们省略细节,认为它直接就拉起来了。

查看这个 client pod 内运行的进程

(node) $ dk top k8s_jfs-mount_juicefs-node1-juicefs-volume1-pv-xx
/bin/mount.juicefs ${metaurl} /jfs/juicefs-volume1-pv -o enable-xattr,no-bgjob,allow_other,token=xxx,metrics=0.0.0.0:9567

/bin/mount.juicefs 其实只是个 alias,指向的就是 juicefs 可执行文件

(pod) $ ls -ahl /bin/mount.juicefs
/bin/mount.juicefs -> /usr/local/bin/juicefs

Step 7:client pod 初始化、FUSE 挂载

查看这个 client pod 干了什么:

root@node:~  # dk top k8s_jfs-mount_juicefs-node1-juicefs-volume1-pv-xx
<INFO>: Meta address: tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1
<INFO>: Data use oss://<oss-bucket>/juicefs-volume1/
<INFO>: Disk cache (/var/jfsCache/<id>/): capacity (10240 MB), free ratio (10%), max pending pages (15)
<INFO>: Create session 667 OK with version: admin-1.2.1+2022-12-22.34c7e973
<INFO>: listen on 0.0.0.0:9567
<INFO>: Mounting volume juicefs-volume1 at /jfs/juicefs-volume1-pv ...
<INFO>: OK, juicefs-volume1 is ready at /jfs/juicefs-volume1-pv
  1. 初始化本地 volume 配置
  2. 与 MetaServer 交互
  3. 暴露 prometheus metrics
  4. 以 juicefs 自己的 mount 实现(前面看到的 /bin/mount.juicefs),将 volume 挂载到 /jfs/juicefs-volume1-pv,默认对应的是 /var/lib/juicefs/volume/juicefs-volume1-pv

此时在 node 上就可以看到如下的挂载信息

(node) $ cat /proc/mounts | grep JuiceFS:juicefs-volume1
JuiceFS:juicefs-volume1 /var/lib/juicefs/volume/juicefs-volume1-pv fuse.juicefs rw,relatime,user_id=0,group_id=0,default_permissions,allow_other 0 0
JuiceFS:juicefs-volume1 /var/lib/k8s/kubelet/pods/<pod-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount fuse.juicefs rw,relatime,user_id=0,group_id=0,default_permissions,allow_other 0 0

可以看到是 fuse.juicefs 方式的挂载。 忘了 FUSE 基本工作原理的,再来借 lxcfs 快速回忆一下:

Fig. lxcfs/fuse workflow: how a read operation is handled [2]

这个 dynamic client pod 创建好之后, 业务 pod(此时还不存在)的读写操作都会进入 FUSE 模块, 然后转发给用户态的 juicefs client 处理。juicefs client 针对不同的 object store 实现了对应的读写方法。

Step 8:kubelet 创建业务 pod:完成后续部分

至此,Pod 所依赖的 volumes 都处理好了,kubelet 就会打印一条日志:

# kubelet.INFO
10:06:06.119  All volumes are attached and mounted for pod "pod1(<pod1-id>)"

接下来就可以继续创建业务 pod 了:

# kubelet.INFO
10:06:06.119  No sandbox for pod "pod1(<pod1-id>)" can be found. Need to start a new one
10:06:06.119  Creating PodSandbox for pod "pod1(<pod1-id>)"
10:06:06.849  Created PodSandbox "885c3a" for pod "pod1(<pod1-id>)"
...

小结

更详细的 pod 创建过程,可以参考 [1]。

3 业务 pod 读写 juicefs volume 流程

juicefs dynamic client pod 先于业务 pod 创建,所以业务 pod 创建好之后,就可以直接读写 juicefs PV (volume) 了,

Fig. JuiceFS as K8s CSI solution: workflow when a business pod reads/writes (JuiceFS mountpod mode).

这个过程可以大致分为四步。

Step 1:pod 读写文件(R/W operations)

例如在 pod 内进入 volume 路径(e.g. cd /data/juicefs-pv-dir/),执行 ls、find 等等之类的操作。

Step 2:R/W 请求被 FUSE 模块 hook,转给 juicefs client 处理

直接贴两张官方的图略作说明 [3],这两张图也透露了随后的 step 3 & 4 的一些信息:

读操作:

Fig. JuiceFS Internals: read operations.

写操作:

Fig. JuiceFS Internals: write operations.

Step 3:juicefs client pod 从 meta server 读取(文件或目录的)元数据

上面的图中已经透露了一些 JuiceFS 的元数据设计,例如 chunk、slice、block 等等。 读写操作时,client 会与 MetaServer 有相关的元信息交互。

Step 4:juicefs client pod 从 object store 读写文件

这一步就是去 S3 之类的 object store 去读写文件了。

4 总结

以上就是使用 JuiceFS 作为 k8s CSI plugin 时,创建一个带 PV 的 pod 以及这个 pod 读写 PV 的流程。 限于篇幅,省略了很多细节,感兴趣的可移步参考资料。

参考资料

  1. 源码解析:K8s 创建 pod 时,背后发生了什么(系列)(2021)
  2. Linux 容器底层工作机制:从 500 行 C 代码到生产级容器运行时(2023)
  3. 官方文档:读写请求处理流程, juicefs.com
  4. kubernetes-csi.github.io/docs/, K8s CSI documentation

Written by Human, Not by AI Written by Human, Not by AI

Practical Storage Hierarchy and Performance: From HDDs to On-chip Caches(2024)

This post summarizes bandwidths for local storage media, networking infra, as well as remote storage systems. Readers may find this helpful when identifying bottlenecks in IO-intensive applications (e.g. AI training and LLM inference).

Fig. Peak bandwidth of storage media, networking, and distributed storage solutions.

Note: this post may contain inaccurate and/or stale information.



1 Fundamentals

Before delving into the specifics of storage, let’s first go through some fundamentals about data transfer protocols.

1.1 SATA

From wikepedia SATA:

SATA (Serial AT Attachment) is a computer bus interface that connects host bus adapters to mass storage devices such as hard disk drives, optical drives, and solid-state drives.

1.1.2 Real world pictures

Fig. SATA interfaces and cables on a computer motherboard. Image source wikipedia

1.1.1 Revisions and data rates

The SATA standard has evolved through multiple revisions. The current prevalent revision is 3.0, offering a maximum IO bandwidth of 600MB/s:

Table: SATA revisions. Data source: wikipedia

Spec Raw data rate Data rate Max cable length
SATA Express 16 Gbit/s 1.97 GB/s 1m
SATA revision 3.0 6 Gbit/s 600 MB/s 1m
SATA revision 2.0 3 Gbit/s 300 MB/s 1m
SATA revision 1.0 1.5 Gbit/s 150 MB/s 1m

1.2 PCIe

From wikipedia PCIe (PCI Express):

PCI Express is high-speed serial computer expansion bus standard.

PCIe (Peripheral Component Interconnect Express) is another kind of system bus, designed to connect a variety of peripheral devices, including GPUs, NICs, sound cards, and certain storage devices.

1.1.2 Real world pictures

Fig. Various slots on a computer motherboard, from top to bottom:
PCIe x4 (e.g. for NVME SSD)
PCIe x16 (e.g. for GPU card)
PCIe x1
PCIe x16
Conventional PCI (32-bit, 5 V)
Image source wikipedia

As shown in the above picture, PCIe electrical interface is measured by the number of lanes. A lane is a single data send+receive line, functioning similarly to a “one-lane road” with traffic in both directions.

1.2.2 Generations and data rates

Each new PCIe generation doubles the bandwidth of a lane than the previous generation:

Table: PCIe Unidirectional Bandwidth. Data source: trentonsystems.com

Generation Year of Release Data Transfer Rate Bandwidth x1 Bandwidth x16
PCIe 1.0 2003 2.5 GT/s 250 MB/s 4.0 GB/s
PCIe 2.0 2007 5.0 GT/s 500 MB/s 8.0 GB/s
PCIe 3.0 2010 8.0 GT/s 1 GB/s 16 GB/s
PCIe 4.0 2017 16 GT/s 2 GB/s 32 GB/s
PCIe 5.0 2019 32 GT/s 4 GB/s 64 GB/s
PCIe 6.0 2021 64 GT/s 8 GB/s 128 GB/s

Currently, the most widely used generations are Gen4 and Gen5.

Note: Depending on the document you’re referencing, PCIe bandwidth may be presented as either unidirectional or bidirectional, with the latter indicating a bandwidth that is twice that of the former.

1.3 Summary

With the above knowledge, we can now proceed to discuss the performance characteristics of various storage devices.

2 Disk

2.1 HDD: ~200 MB/s

From wikipedia HDD:

A hard disk drive (HDD) is an electro-mechanical data storage device that stores and retrieves digital data using magnetic storage with one or more rigid rapidly rotating platters coated with magnetic material.

2.1.1 Real world pictures

A real-world picture is shown below:

Fig. Internals of a real world HDD. Image source hardwaresecrets.com

2.1.2 Supported interfaces (bus types)

HDDs connect to a motherboard over one of several bus types, such as,

  • SATA
  • SCSI
  • Serial Attached SCSI (SAS)

Below is a SATA HDD:

Fig. A real world SATA HDD. Image source hardwaresecrets.com

and how an HDD connects to a computer motherboard via SATA cables:

Fig. An HDD with SATA cables. Data source datalab247.com

2.1.3 Bandwidth: constrained by machanical factors

HDDs are machanical devices, and their peak IO performance is inherently limited by various mechanical factors, including the speed at which the actuator arm can function. The current upper limit of HDDs is ~200MB/s, which is significantly below the saturation point of a SATA 3.0 interface (600MB/s).

2.1.4 Typical latencies

Table. Latency characteristics typical of HDDs. Data source: wikipedia

Rotational speed (rpm) Average rotational latency (ms)
15,000 2
10,000 3
7,200 4.16
5,400 5.55
4,800 6.25

2.2 SATA SSD: ~600MB/s

What’s a SSD? From wikipedia SSD:

A solid-state drive (SSD) is a solid-state storage device. It provides persistent data storage using no moving parts.

Like HDDs, SSDs support several kind of bus types:

  • SATA
  • PCIe (NVME)

Let’s see the first one: SATA-interfaced SSD, or SATA SSD for short.

2.2.1 Real world pictures

SSDs are usually smaller than HDDs,

Fig. Size of different drives, left to right: HDD, SATA SSD, NVME SSD. Image source avg.com

2.2.2 Bandwidth: constrained by SATA bus

The absence of mechanical components (such as rotational arms) allows SATA SSDs to fully utilize the capabilities of the SATA bus. This results in an upper limit of 600MB/s IO bandwidth, which is 3x faster than that of SATA HDDs.

2.3 NVME SSD: ~7GB/s, ~13GB/s

Let’s now explore another type of SSD: the PCIe-based NVME SSD.

2.3.1 Real world pictures

NVME SSDs are even smaller than SATA SSDs, and they connect directly to the PCIe bus with 4x lanes instead of SATA cables,

Fig. Size of different drives, left to right: HDD, SATA SSD, NVME SSD. Image source avg.com

2.3.2 Bandwidth: contrained by PCIe bus

NVME SSDs has a peak bandwidth of 7.5GB/s over PCIe Gen4, and ~13GB/s over PCIe Gen5.

2.4 Summary

We illustrate the peak bandwidths of afore-mentioned three kinds of local storage media in a graph:

Fig. Peak bandwidths of different storage media.

These (HDDs, SSDs) are commonly called non-volatile or persistent storage media. And as the picture hints, in next chapters we’ll delve into some other kinds of storage devices.

3 DDR SDRAM (CPU Memory): ~400GB/s

DDR SDRAM nowadays serves mainly as the main memory in computers.

3.1 Real world pictures

Fig. Front and back of a DDR RAM module for desktop PCs (DIMM). Image source wikipedia

Fig. Corsair DDR-400 memory with heat spreaders. Image source wikipedia

DDR memory connects to the motherboard via DIMM slots:

Fig. Three SDRAM DIMM slots on a ABIT BP6 computer motherboard. Image source wikipedia

3.2 Bandwidth: contrained by memory clock, bus width, channel, etc

Single channel bandwidth:

  Transfer rate Bandwidth
DDR4 3.2GT/s 25.6 GB/s
DDR5 4–8GT/s 32–64 GB/s

if Multi-channel memory architecture is enabled, the peak (aggreated) bandwidth will be increased by multiple times:

Fig. Dual-channel memory slots, color-coded orange and yellow for this particular motherboard. Image source wikipedia

Such as [4],

  • Intel Xeon Gen5: up to 8 memory-channels running at up to 5600MT/s (358GB/s)
  • Intel Xeon Gen4: up to 8 memory-channels running at up to 4800MT/s (307GB/s)

3.3 Summary

DDR5 bandwidth in the hierarchy:

Fig. Peak bandwidths of different storage media.

4 GDDR SDRAM (GPU Memory): ~1000GB/s

Now let’s see another variant of DDR, commonly used in graphics cards (GPUs).

4.1 GDDR vs. DDR

From wikipedia GDDR SDRAM:

Graphics DDR SDRAM (GDDR SDRAM) is a type of synchronous dynamic random-access memory (SDRAM) specifically designed for applications requiring high bandwidth, e.g. graphics processing units (GPUs).

GDDR SDRAM is distinct from the more widely known types of DDR SDRAM, such as DDR4 and DDR5, although they share some of the same features—including double data rate (DDR) data transfers.

4.2 Real world pictures

Fig. Hynix GDDR SDRAM. Image Source: wikipedia

4.3 Bandwidth: contrained by lanes & clock rates

Unlike DDR, GDDR is directly integrated with GPU devices, bypassing the need for pluggable PCIe slots. This integration liberates GDDR from the bandwidth limitations imposed by the PCIe bus. Such as,

  • GDDR6: 1008GB/s. Peak per-pin data rate 16Gb/s, max memory bus width 384-bits.
  • GDDR6x: 1008GB/s, used by NVIDIA RTX 4090

4.4 Summary

With GDDR included:

Fig. Peak bandwidths of different storage media.

5 HBM: 1~5 TB/s

If you’d like to achieve even more higher bandwidth than GDDR, then there is an option: HBM (High Bandwidth Memory).

A great innovation but a terrible name.

5.1 What’s new

HBM is designed to provide a larger memory bus width than GDDR, resulting in larger data transfer rates.

Fig. Cut through a graphics card that uses HBM. Image Source: wikipedia

HBM sits inside the GPU die and is stacked – for example NVIDIA A800 GPU has 5 stacks of 8 HBM DRAM dies (8-Hi) each with two 512-bit channels per die, resulting in a total width of 5120-bits (5 active stacks * 2 channels * 512 bits) [3].

As another example, HBM3 (used in NVIDIA H100) also has a 5120-bit bus, and 3.35TB/s memory bandwidth,

Fig. Bandwidth of several HBM-powered GPUs from NVIDIA. Image source: nvidia.com

5.2 Real world pictures

The 4 squares in left and right are just HBM chips:

Fig. AMD Fiji, the first GPU to use HBM. Image Source: wikipedia

5.3 Bandwidth: contrained by lanes & clock rates

From wikipedia HBM

  Bandwidth Year GPU
HBM 128GB/s/package    
HBM2 256GB/s/package 2016 V100
HBM2e ~450GB/s 2018 A100, ~2TB/s; Huawei Ascend 910B
HBM3 600GB/s/site 2020 H100, 3.35TB/s
HBM3e ~1TB/s 2023 H200, 4.8TB/s

5.4 HBM-powered CPUs

HBM is not exclusive to GPU memory; it is also integrated into some CPU models, such as the Intel Xeon CPU Max Series.

5.5 Summary

This chapter concludes our exploration of dynamic RAM technologies, which includes

  • DDR DRAM
  • GDDR DRAM
  • HBM DRAM

Fig. Peak bandwidths of different storage media.

In the next, let’s see some on-chip static RAMs.

6 SRAM (on-chip): 20+ TB/s

The term “on-chip” in this post refers to memory storage that's integrated within the same silicon as the processor unit.

6.1 SRAM vs. DRAM

From wikipedia SRAM:

Static random-access memory (static RAM or SRAM) is a type of random-access memory that uses latching circuitry (flip-flop) to store each bit. SRAM is volatile memory; data is lost when power is removed.

The term static differentiates SRAM from DRAM:

  SRAM DRAM
data freshness stable in the presence of power decays in seconds, must be periodically refreshed
speed (relative) fast (10x) slow
cost (relative) high low
mainly used for cache main memory

SRAM requires more transistors per bit to implement, so it is less dense and more expensive than DRAM and also has a higher power consumption during read or write access. The power consumption of SRAM varies widely depending on how frequently it is accessed.

6.2 Cache hierarchy (L1/L2/L3/…)

In the architecture of multi-processor (CPU/GPU/…) systems, a multi-tiered static cache structure is usually used:

  • L1 cache: typically exclusive to each individual processor;
  • L2 cache: commonly accessible by a group of processors.

NVIDIA H100 chip layout (L2 cache in the middle, shared by many SM processors). Image source: nvidia.com

6.3 Groq LPU: eliminating memory bottleneck by using SRAM as main memory

From the official website: Groq is the AI infra company that builds the world’s fastest AI inference technology with both software and hardware. Groq LPU is designed to overcome two LLM bottlenecks: compute density and memory bandwidth.

  • An LPU has greater compute capacity than a GPU and CPU in regards to LLMs. This reduces the amount of time per word calculated, allowing sequences of text to be generated much faster.
  • Eliminating external memory bottlenecks (using on-chip SRAM instead) enables the LPU Inference Engine to deliver orders of magnitude better performance on LLMs compared to GPUs.

Regarding to the chip:

Fig. Die photo of 14nm ASIC implementation of the Groq TSP. Image source: groq paper [2]

The East and West hemisphere of on-chip memory module (MEM)

  • Composed of 44 parallel slices of SRAM and provides the memory concurrency necessary to fully utilize the 32 streams in each direction.
  • Each slice provides 13-bits of physical addressing of 16-byte memory words, each byte maps to a lane, for a total of 220 MiBytes of on-chip SRAM.

6.4 Bandwidth: contrained by clock rates, etc

6.5 Summary

This chapter ends our journey to various physical storage media, from machanical devices like HDDs all the way to on-chip cache. We illustrate their peak bandwidth in a picture, note that the Y-axis is log10 scaled:

Fig. Speeds of different storage media.

These are the maximum IO bandwidths when performing read/write operations on a local node.

Conversely, when considering remote I/O operations, such as those involved in distributed storage systems like Ceph, AWS S3, or NAS, a new bottleneck emerges: networking bandwidth.

7 Networking bandwidth: 400GB/s

7.1 Traditional data center: 2*{25,100,200}Gbps

For traditional data center workloads, the following per-server networking configurations are typically sufficient:

  • 2 NICs * 25Gbps/NIC, providing up to 6.25GB/s unidirectional bandwidth when operating in active-active mode;
  • 2 NICs * 100Gbps/NIC, delivering up to 25GB/s unidirectional bandwidth when operating in active-active mode;
  • 2 NICs * 200Gbps/NIC, achieving up to 50GB/s unidirectional bandwidth when operating in active-active mode.

7.2 AI data center: GPU-interconnect: 8*{100,400}Gbps

This type of networking facilitates inter-GPU communication and is not intended for general data I/O. The data transfer pathway is as follows:

            HBM <---> NIC <---> IB/RoCE <---> NIC <--> HBM
                Node1                            Node2

7.3 Networking bandwidths

Now we add networking bandwidths into our storage performance picture:

Fig. Speeds of different storage media, with networking bandwidth added.

7.4 Summary

If remote storage solutions (such as distributed file systems) is involved, and networking is fast enough, IO bottleneck would shift down to the remote storage solutions, that’s why there are some extremely high performance storage solutions dedicated for today’s AI trainings.

8 Distributed storage: aggregated 2+ TB/s

8.1 AlibabaCloud CPFS

AlibabaCloud’s Cloud Parallel File Storage (CPFS) is an exemplar of such high-performance storage solutions. It claims to offer up to 2TB/s of aggregated bandwidth.

But, note that the mentioned bandwidth is an aggregate across multiple nodes, no single node can achieve this level of IO speed. You can do some calcuatations to understand why, with PCIe bandwidth, networking bandwidth, etc;

8.2 NVME SSD powered Ceph clusters

An open-source counterpart is Ceph, which also delivers impressive results. For instance, with a cluster configuration of 68 nodes * 2 * 100Gbps/node, a user achieved aggregated throughput of 1TB/s, as documented.

8.3 Summary

Now adding distributed storage aggregated bandwidth into our graph:

Fig. Peak bandwidth of storage media, networking, and distributed storage solutions.

9 Conclusion

This post compiles bandwidth data for local storage media, networking infrastructure, and remote storage systems. With this information as reference, readers can evaluate the potential IO bottlenecks of their systems more effectively, such as GPU server IO bottleneck analysis [1]:

Fig. Bandwidths inside a 8xA100 GPU node

References

  1. Notes on High-end GPU Servers (in Chinese), 2023
  2. Think Fast: A Tensor Streaming Processor (TSP) for Accelerating Deep Learning Workloads, ISCA paper, 2020
  3. GDDR6 vs HBM - Defining GPU Memory Types, 2024
  4. 5th Generation Intel® Xeon® Scalable Processors, intel.com

Written by Human, Not by AI Written by Human, Not by AI

❌