Ceph 底层存储引擎经过了数次变迁,目前最常用的是 BlueStore,在 Jewel 版本中引入,用来取代 FileStore。与 FileStore 相比,Bluesore 越过本地文件系统,直接操控裸盘设备,使得 I/O 路径大大缩短,提高了数据读写效率。并且,BlueStore 在设计之初就是针对固态存储,对目前主力的 SATA SSD 有着更好的支持(相比 FileStore),同时也支持 Nvme SSD 超高速固态。在数据的处理上,BlueStore 选择把元数据和对象数据分开存储,使用高速设备来保存元数据,能够起到性能优化作用。
1. BlueStore 架构总览
- BlockDevice:物理块设备,使用 Libaio 操作裸设备,AsyncIO。
- RocksDB:存储 WAL 、对象元数据、对象扩展属性 Omap、磁盘分配器元数据。
- BlueRocksEnv:抛弃了传统文件系统,封装 RocksDB 文件操作的接口。
- BlueFS:小型的 Append 文件系统,实现了 RocksDB::Env 接口,给 RocksDB 用。
- Allocator:磁盘分配器,负责高效的分配磁盘空间。
根据上图,可以很直观的了解到:BlueStore 把数据分成两条路径。一条是 data 直接通过 Allocator(磁盘空间分配器)分配磁盘空间,然后写入 BlockDevice。另一条是 metadata 先写入 RocksDB(内存数据库),通过 BlueFs(BlueStore 专用文件系统)来管理 RocksDB 数据,经过 Allocator 分配磁盘空间后落入 BlockDevice。
先树立第一层的概念:BlueStore 把元数据和对象数据分开写,对象数据直接写入硬盘,而元数据则先写入超级高速的内存数据库,后续再写入稳定的硬盘设备,这个写入过程由 BlueFS 来控制。
为什么这样设计呢?背后的原因令人暖心。
这里直接引入《Ceph 设计原理与实现》一文中给出的观点(concept)。
在所有的存储系统中,读操作一般都是同步的(前端发出读指令,后端必须返回待读取的数据后,才算读成功)。写操作则可以是异步的(前端发出写指令,后端先接收数据后,直接返回成功写,后续再慢慢把数据写到磁盘中),一般为了性能考虑,所有写操作都会先写内存缓存 Page-Cache 便返回客户端成功,然后由文件系统批量刷新。但是内存是易失性存储介质,掉电后数据便会丢失,所以为了数据可靠性,我们不能这么做。
通常,将数据先写入性能更好的非易失性存储介质(SSD、NVME等)充当的中间设备,然后再将数据写入内存缓存,便可以直接返回客户端成功,等到数据写入到普通磁盘的时候再释放中间设备对应的空间。
传统文件系统中将数据先写入高速盘,再同步到低速盘的过程叫做“双写”,高速盘被称为日志盘,低速盘被成为数据盘。每个写操作实际都要写两遍,这大大影响了效率,同时浪费了存储空间。
BlueStore 针对上述这一问题重新设计了自身的日志系统,尽可能避免双写,但同时也要保证数据的一致性。其使用增量日志,即针对大范围的覆盖写,只在前后非磁盘块大小对齐的部分使用日志,其他部分不需要改写的(RMW),则使用重定向写(不写入日志)。
书中介绍了块、COW、RWM 等内容,这里也直接拿来主义。
BlockSize:磁盘IO操作的最小单元(原子操作)。HDD为512B,SSD为4K。即读写的数据就算少于 BlockSize,磁盘I/O的大小也是 BlockSize,是原子操作,要么写入成功,要么写入失败,即使掉电不会存在部分写入的情况。
RWM(Read-Modify-Write):指当覆盖写发生时,如果本次改写的内容不足一个 BlockSize,那么需要先将对应的块读上来,然后再内存中将原内容和待修改内容合并Merge,最后将新的块写到原来的位置。但是 RMW 也带来了两个问题:一是需要额外的读开销;二是 RMW 不是原子操作,如果磁盘中途掉电,会有数据损坏的风险。为此我们需要引入 Journal,先将待更新数据写入 Journal,然后再更新数据,最后再删除 Journal对应的空间。
COW(Copy-On-Write):指当覆盖写发生时,不是更新磁盘对应位置已有的内容,而是新分配一块空间,写入本次更新的内容,然后更新对应的地址指针,最后释放原有数据对应的磁盘空间。理论上 COW 可以解决 RMW的两个问题,但是也带来了其他的问题:一是 COW 机制破坏了数据在磁盘分布的物理连续性。经过多次 COW 后,读数据的顺序读将会便会随机读。二是针对小于块大小的覆盖写采用 COW 会得不偿失。是因为:一是将新的内容写入新的块后,原有的块仍然保留部分有效内容,不能释放无效空间,而且再次读的时候需要将两个块读出来 Merge操作,才能返回最终需要的数据,将大大影响读性能。二是存储系统一般元数据越多,功能越丰富,元数据越少,功能越简单。而且任何操作必然涉及元数据,所以元数据是系统中的热点数据。COW 涉及空间重分配和地址重定向,将会引入更多的元数据,进而导致系统元数据无法全部缓存在内存里面,性能会大打折扣。
基于以上设计,BlueStore 的写策略综合运用直接写、COW 和 RMW 策略。
注意:
- 非覆盖写直接分配空间写入即可;
- 块大小对齐的覆盖写采用 COW 策略;小于块大小的覆盖写采用 RMW 策略。
再结合图1.1 ceph_bluestore 架构图,我们需要引入第二层观念:
- 非覆盖写直接通过 Allocater 分配空间后写入硬盘设备。
-
覆盖写分为两种:一种是可以 COW,也是直接通过 Allocater 分配空间后写入硬盘设备。另一种是需要 RMW,先把数据写入 Journal,在 BlueStore 中就是 RocksDB,再后续通过 BlueFS 控制,刷新写入硬盘设备。
RMW路径
在 ceph 配置中,有 bluestore_prefer_deferred_size 可以设定:当对象大小小于该值时,该对象总是使用延迟写(即先写入 Rocks DB,再落入 BlockDevice)。
2. BlockDevice
Ceph 较新版本中,把 设备 模块单独放到 blk 文件夹中。
[root@localhost ceph]# cd src/blk
[root@localhost blk]# tree
.
├── aio
│ ├── aio.cc # 封装 aio 操作
│ └── aio.h
├── BlockDevice.cc # 块设备基类
├── BlockDevice.h
├── CMakeLists.txt
├── kernel # kernel 设备,目前常用的 HDD、SATA 都是此类设备
│ ├── io_uring.cc # bluestore 或者 存在 libaio 的情况下,不适用 libio_uring
│ ├── io_uring.h
│ ├── KernelDevice.cc
│ └── KernelDevice.h
...
后续还有 pmem、spdk、zoned 设备,这里不再列出
2.1 aio
aio 封装了 libaio 相关操作,具体有三个结构:
• aio_t:一次 io 操作,pwrite,pread,不写到磁盘
• io_queue_t:io 提交队列,基本不使用,只用作基类
• aio_queue_t:继承自 io_queue_t,提交 io 操作,真正在此写入磁盘
简单介绍 ceph 中 libaio 使用流程:
- init():io_setup(int maxevents, io_context_t *ctxp);
- 构造 aio_t
- 调用 aiot_t->pwritev 或者 aio_t->preadv
- 把 aio_t 加入队列:std::list
- int submit_batch(aio_iter begin, aio_iter end, uint16_t aios_size, void *priv, int *retries)
- shutdown: io_destroy(io_context_t ctx)
相关方法有:
init():初始化
shutdown():关闭
pwritev():预备写,后续通过 submit_batch() 完成实际写入磁盘操作
preadv():预备读,后续通过 submit_batch() 完成实际写入磁盘操作
submit_batch():按批次提交 io 任务
get_next_completed():获取已经完成 IO 的 aio_t,用于 aio_thread() 回调函数
2.2 从 BlockDevice 到 KernelDevice
BlockDevice 是所有块设备的基类,定义了块设备必备的一些接口,比如 read、write等。在 src/blk/BlockDevice.h 中还定义了 IOContext,用作封装传递 io 具体操作,其中保存着每次 aio 操作的list:pending_aios 和 running_aios,前者表示等待队列,后者表示正在 io 操作的队列,同时还创建两个原子数分别用于表示这两个队列中 aio_t 的数量。
BlockDevice提供创建块设备实例的接口,通过识别 device_type 来创建不同的块设备,如 kernel device。
BlockDevice BlockDevice::create(
CephContext cct, const string& path, aio_callback_t cb,
void *cbpriv, aio_callback_t d_cb, void *d_cbpriv);
->
create_with_type(device_type, cct, path, cb, cbpriv, d_cb, d_cbpriv);
-> 根据类型创建 device
new KernelDevice(cct, cb, cbpriv, d_cb, d_cbpriv)
-> 创建以下线程
aio_thread:
discard_thread
aio_thread:作用1. 感知已经完成的 io。2. 唤醒等待的 io 或者调用回调函数,具体根据IOContext创建时是否提供了 priv 参数(BlueStore 指针)来决定使用哪种操作,如果提供了,则调用回调函数BlueStore::TransContext::aio_finish()或者BlueStore::DeferredBatch::aio_finish()(根据AioContext 类型)。
discard_thread:这是针对 SSD discard 操作的线程,可以通过 cat /sys/block/sda/queue/discard_granularity 命令查看设备是否支持 discard 操作。discard 在 bluestore 中 分为两步:第一步:BlkDev{fd_directs[WRITE_LIFE_NOT_SET]}.discard((int64_t) offset, (int64_t) len); 调用系统函数对指定位置做 discard。第二步:discard_callback() 回调 shared_alloc.a->release(),告诉 Allocater 需要回收的部分(标记为 free)。
KernelDevice 支持同步、异步读写操作,具体实现方式:在 open操作时,对同一个设备首先创建两种文件描述符fd_direct、fd_buffered(通过O_DIRECT,O_DIRECT不同打开权限来实现),同时告知内核fd_buffered的数据以随机形式访问。后续顺序读写操作统一用 fd_direct,随机读写使用 fd_buffered;同步读写操作调用系统函数pwrite()和pread(),异步读写操作使用aio_t::preadv()和pwritev()。open 块设备的同时也会开启 aio_thread 和 discard_thread。
以下简单介绍KernelDevice部分关键操作:
-
同步读写:
- read:同步读要求块对齐,调用系统函数 pread() 实现,使用fd_directed。
- read_random:分为对齐和不对齐两种情况。对齐使用fd_buffered(open()时告知内核,数据以随机形式访问)。不对齐则强制转成对齐操作,再使用同步读的方式,使用fd_directed+pread(),后续返回时再裁剪为原本不对齐的bl大小。
- write:同步写要求块对齐。同时支持使用fd_direct或者fd_buffered,每次写完后,立刻调用系统函数sync_file_range()(仅刷新offset~length的数据,不更新元数据,提高性能)。
同步写需要刷新脏页和元数据。 - flush():调用系统函数fdatasync()。
-
异步读写:
异步读写时一般只用 fd_direct,因为libaio要求打开方式为O_DIRECT。- aio_read:要求使用!buffered方式(使用fd_direct),否则转为同步读。调用aio_t->preadv()函数。
- aio_write:要求块对齐。要求使用!buffered方式(使用fd_direct),否则转为同步写。调用aio_t->pwritev()函数。
异步读写需要通过submit()把io操作提交到磁盘。 - aio_submit():调用io_queue_t::submit_batch()。
3 BlueStore
3.1 mkfs
mkfs 作用是在磁盘第一次使用 bluestore 时,写入一些用户指定的配置到磁盘第一个块——超级块(大小可配置,一般为: BDEV_LABEL_BLOCK_SIZE 4096),这样后续使用该磁盘时,可以直接读取配置项。 之所以需要固化这些配置项,是因为 bluestore 使用不同的配置项对于磁盘数据的组织形式不同,如果前后两次上电使用不同的配置项访问磁盘数据有可能导致数据发生永久损坏。对已经 mkfs 过的磁盘再次使用该函数,则会对磁盘做一次 meta 数据检查。
以下为 LABEL_BLOCK 块中存储的数据,主要有 osd id、设备大小、生日时间、设备描述以及一组元数据 map。
/// label for block device
struct bluestore_bdev_label_t {
uuid_d osd_uuid; ///< osd uuid
uint64_t size; ///< device size
utime_t btime; ///< birth time
string description; ///< device description
map meta; ///< {read,write}_meta() content from ObjectStore
...
};
mkfs() 主要是初始化 bluestore_bdev_label_t,并写入到磁盘的第一个4K块中,同时建立块设备链并预分配指定大小的空间。同时BlueStore层次的mkfs()里面调用了Bluefs的Bluefs::mkfs(),并且也固化了bluefs的信息位于磁盘的第二个4K块。
以下给出mkfs详细步骤。
- read_meta() 读取 "mkfs_done" value,验证是否曾经完成过mkfs,如果之前做过 mkfs 则做一次 fsck 然后 return。
- 验证 type 是否是 bluestore,如果 meta 中没有 tpye,说明是第一次 mkfs,需要写入 {type,bluestore}。
- 指定了 freelist_type。
- 获取路径的 fd:path_fd = TEMP_FAILURE_RETRY(::open(path.c_str(), O_DIRECTORY|O_CLOEXEC));
- 创建 fsid 文件 /var/lib/ceph/osd/ceph-0/fsid
- 文件锁 fsid: int r = ::fcntl(fsid_fd, F_SETLK, &l);
- 读取fsid文件中的 fsid 号,如果等于0,则生成 fsid
- 建立 block 和实际块设备的软连接,并截取 block_size 大小,这里调用系统函数::symlinkat()和::ftruncate()。
- 根据配置,创建 wal 和 db 的软链接,这个可以没有
- 创建设备实例,详见 3.1.1 BlueStore::_open_bdev
- 设置 min_alloc_size,默认 4K
- 验证 block_size > round_up_to(max(SUPER_RESERVED, min_alloc_size),min_alloc_size)
- 保证 min_alloc_size 是2的倍数
- 创建 Allocator,详见 Allocator::create
- 在 Allocator 中指定 预留区域 为 free 未使用,一般就是 SUPER_RESERVED 大小,默认 8K
- 打开 kvdb,_open_db()
- 向 kvdb 写入配置数据:{S, nid_max, 0},{S, blobid max, 0},{S, min_alloc_size, { lastest_ondisk_format = 4}},{S, min_compat_ondisk_format, 3}
- 向 path/block superblock 区域写入 meta 数据:{kv_backend, bluestore_kvbackend(默认 rocksdb)}
- meta 写入 {bluefs,1|0},是否启用 bluefs,1为启用,0为不启用。
- 更新fsid
-
关闭上文打开的所有文件或设备
mkfs
3.1.1 _open_bdev
开启块设备。此函数用于创建block设备实例(slow设备),并初始化某些配置。WAL和DB设备由BlueFS自行管理,不在此处开启。
BlockDevice:create()函数在2.2节中详细介绍过了,主要作用是打开块设备,获取句柄,用于后续的数据读写操作。
discard用于SSD设备的空间回收,其作用在2.2节中介绍过。
_set_max_defer_interval()设置了max_defer_interval参数,此参数的作用是在MempoolThread线程中定时提交延迟写(try->deferred_try_submit()),默认为3。
set_cache_size()从配置中获取所有cache相关的参数,包括cache总空间,onode内存使用比率、kvdb内存使用比率、hdd、ssd内存使用比率等。