PostgreSQL 18 史诗级特性AIO & DIO顶层设计
PostgreSQL 18 AIO & DIO 顶层设计
PostgreSQL 18 即将支持IO method的选择, 支持AIO, 那么什么是AIO? AIO在数据库中的好处?特别是云盘存储普遍具有延迟高(相对本地nvme ssd)的特点,为什么就需要AIO了?
什么是AIO
Asynchronous I/O (AIO): 异步 I/O
异步 I/O (AIO) 描述的是以非阻塞方式(异步地)执行 I/O 操作,与同步 I/O 形成对比,后者会在整个 I/O 期间阻塞。
使用 AIO,启动 I/O 操作与等待操作结果是分离的,从而允许并发启动多个 I/O 操作,以及并发执行 CPU 密集型操作和 I/O 操作。 这种提高的并发性的代价是复杂性增加。
Input/Output (I/O): 输入/输出
输入/输出 (I/O) 描述的是程序与外围设备之间的通信。 在数据库系统的上下文中,I/O 通常(但不限于)指的是与存储设备或网络的交互。
AIO在数据库中的好处
异步 I/O (AIO) 在数据库中有很多好处,可以显著提升性能和资源利用率。以下是一些主要的好处:
提高并发性: AIO 允许数据库服务器在等待 I/O 操作完成时继续处理其他请求。这意味着数据库可以同时处理更多的并发连接和查询,而不会因为 I/O 阻塞而降低响应速度。
减少延迟: 通过避免阻塞,AIO 可以减少单个查询或事务的延迟。数据库服务器不必等待 I/O 完成才能继续处理,从而更快地返回结果。
提高吞吐量: 由于数据库可以同时处理更多的 I/O 操作和请求,因此 AIO 可以提高整体吞吐量。这意味着数据库可以在相同的时间内处理更多的数据和事务。
更好地利用 CPU 资源: 在同步 I/O 中,CPU 在等待 I/O 完成时可能会处于空闲状态。AIO 允许 CPU 在等待 I/O 时执行其他任务,从而更好地利用 CPU 资源。
优化存储性能: AIO 可以与存储系统的优化技术(如磁盘调度和缓存)结合使用,以进一步提高 I/O 性能。
改进用户体验: 通过减少延迟和提高吞吐量,AIO 可以改善用户体验。用户可以更快地获得查询结果,并且数据库可以更有效地处理大量的并发请求。
具体例子:
读取数据: 当数据库需要从磁盘读取数据时,AIO 允许数据库服务器异步地发出读取请求,并在数据可用时继续处理其他查询。这避免了数据库服务器在等待数据从磁盘读取时被阻塞。
写入数据: 类似地,当数据库需要将数据写入磁盘时,AIO 允许数据库服务器异步地发出写入请求,并在写入操作在后台进行时继续处理其他事务。
日志记录: 数据库通常需要将事务日志写入磁盘以进行恢复。AIO 允许数据库服务器异步地写入日志,而不会阻塞事务处理。
双重缓冲: 避免操作系统缓存和 Postgres 的 shared_buffers 之间的双重缓冲。
写回控制: 更好地控制脏数据写回的时间和速度。
需要注意的是,AIO 的实现和使用可能会增加数据库系统的复杂性。 开发人员需要仔细考虑如何正确地使用 AIO,以确保数据一致性和可靠性。此外,AIO 的性能优势取决于底层操作系统和存储系统的支持。
AIO & DIO 顶层设计
src/backend/storage/aio/README.md
以上设计文章翻译:
异步 & 直接 I/O
动机
为什么需要异步 I/O
在引入异步 I/O 之前,Postgres 依赖于操作系统来隐藏同步 I/O 的成本。虽然这在很多工作负载下表现得相当好,但在预取和受控写回方面,它并没有达到我们期望的水平。
有一些重要的、开销很大的操作,比如 fdatasync(),操作系统无法隐藏存储延迟。这对于 WAL 写入尤其重要,因为异步地发出 fdatasync() 或 O_DSYNC 写入可以显著提高吞吐量。
为什么需要直接/非缓冲 I/O
使用直接 I/O 的主要原因有:
更低的 CPU 使用率/更高的吞吐量。 特别是在现代存储上,缓冲写入受到操作系统的限制,因为它必须使用 CPU 将数据从内核的页缓存复制到 Postgres 的缓冲池。而直接 I/O 通常可以使用 DMA 直接在存储设备和 Postgres 的缓冲缓存之间移动数据。在传输过程中,CPU 可以自由地执行其他工作。 降低延迟。 直接 I/O 的延迟可能比缓冲 I/O 低得多,这对于受 WAL 写入延迟限制的 OLTP 工作负载可能产生重大影响。 避免操作系统缓存和 Postgres 的 shared_buffers 之间的双重缓冲。 更好地控制脏数据写回的时间和速度。
不使用直接 I/O 的主要原因有:
如果没有 AIO,直接 I/O 对于大多数用途来说都慢得无法使用。 即使有了 AIO,也需要修改 Postgres 的许多部分以执行显式预取。 在 shared_buffers无法设置得足够大的情况下,例如,因为在共享硬件上托管了许多不同的 Postgres 实例,性能通常会比使用缓冲 I/O 更差。
AIO 使用示例
在许多情况下,可以从 AIO 中受益的代码不必直接与 AIO 接口交互,而是可以通过更高级别的抽象来使用 AIO。请参阅
在此示例中,将把一个缓冲区读入共享缓冲区。
/*
* 操作的结果,只能在此后端访问。
*/
PgAioReturn ioret;/*
* 获取 AIO 句柄,ioret 将在完成后获得结果。
*
* 请注意,ioret 需要保持活动状态,直到 IO 完成或
* CurrentResourceOwner 被释放(即抛出错误)。
*/
PgAioHandle *ioh = pgaio_io_acquire(CurrentResourceOwner, &ioret);
/*
* 可用于等待我们下面启动的 IO 的引用。此
* 引用可以驻留在本地或共享内存中,并由任何
* 进程等待。可以为每个 IO 创建任意数量的引用。
*/
PgAioWaitRef iow;
pgaio_io_get_wref(ioh, &iow);
/*
* 安排在 IO 完成后调用共享缓冲区完成回调。
* 此回调将更新与
* AioHandle 关联的缓冲区描述符,例如,允许其他后端访问缓冲区。
*
* 可以将一小段数据传递给回调,例如,指示是否
* 在缓冲区无效时将其清零。
*
* 可以为每个句柄注册多个完成回调。
*/
pgaio_io_register_callbacks(ioh, PGAIO_HCB_SHARED_BUFFER_READV, 0);
/*
* 完成回调需要知道在 IO
* 完成时要更新哪些缓冲区。由于 AIO 子系统不知道缓冲区,因此我们必须
* 将此信息与 AioHandle 关联,以供上面注册的完成回调使用。
*
* 在此示例中,我们只读取一个缓冲区,因此为 1。
*/
pgaio_io_set_handle_data_32(ioh, (uint32 *) buffer, 1);
/*
* 将 AIO 句柄传递给较低级别的函数。当在缓冲区级别上操作时,我们不知道
* IO 究竟是如何执行的,这是存储管理器实现的责任。
*
* 例如,md.c 需要将块号转换为段中的偏移量。
*
* 一旦 IO 句柄被传递给 smgstartreadv(),就不能
* 再使用它,因为 IO 可能会立即在
* smgrstartreadv() 下执行,并且该句柄被重用于另一个 IO。
*
* 为了以有效的方式发出多个 IO,调用者可以在启动多个 IO 之前调用
* pgaio_enter_batchmode(),并使用 pgaio_exit_batchmode() 结束该批处理。
* 请注意,当可能存在未提交的 IO 时,需要小心,因为另一个后端可能需要等待其中一个
* 未提交的 IO。如果此后端随后不得不等待另一个后端,
* 它将最终导致未检测到的死锁。有关更多详细信息,请参阅 pgaio_enter_batchmode()。
*
* 请注意,即使在批处理模式下,IO 也可能会立即提交,
* 例如,由于达到未提交 IO 的数量限制,甚至在 smgrstartreadv() 返回之前完成。
*/
smgrstartreadv(ioh, operation->smgr, forknum, blkno,
BufferGetBlock(buffer), 1);
/*
* 为了从 AIO 中受益,最好在等待 IO 完成之前执行其他工作,包括
* 提交其他 IO。否则,
* 我们可以只使用同步阻塞 IO。
*/
perform_other_work();
/*
* 我们做了一些其他工作,现在需要 IO 操作完成才能
* 继续。
*/
pgaio_wref_wait(&iow);
/*
* 此时 IO 已完成。但是,我们还不知道它是否成功
* 或失败。缓冲区的状态已更新,这允许其他
* 后端使用该缓冲区(如果 IO 成功),或重试 IO(如果它
* 失败)。
*
* 请注意,如果 IO 失败,则可能已发出 LOG 消息,
* 但未引发 ERROR。这至关重要,因为等待
* 此 IO 的另一个后端不应看到 ERROR。
*
* 要检查操作是否成功,并引发 ERROR,或者如果更
* 适当的 LOG,则使用我们传递给 pgaio_io_acquire() 的 PgAioReturn。
*/
if (ioret.result.status == PGAIO_RS_ERROR)
pgaio_result_report(ioret.result, &ioret.target_data, ERROR);
/*
* 除了完全成功之外,IO 还可能 a) 部分
* 完成或 b) 成功但带有警告(例如,由于 zero_damaged_pages)。
* 如果我们例如尝试一次读取多个块,则读取可能
* 仅对前几个块成功。
*
* 如果 IO 部分成功,并且此后端需要所有块都已
* 完成,则此后端需要为剩余的缓冲区重新发出 IO。
* AIO 子系统无法透明地处理此重试。
*
* 由于此示例已经很长,并且我们只读取一个块,因此如果存在部分读取或警告,我们将
* 报错。
*/
if (ioret.result.status != PGAIO_RS_OK)
pgaio_result_report(ioret.result, &ioret.target_data, ERROR);
/*
* IO 成功,因此我们现在可以使用缓冲区。
*/
设计标准和动机
由于 AIO 导致的死锁和饥饿危险
以一种天真的方式使用 AIO 很容易导致死锁,尤其是在 AIO 的源/目标是共享资源的环境中,例如 Postgres 的 shared_buffers 中的页面。
考虑一个后端对表执行预读,为当前“扫描位置”之前的多个缓冲区启动 IO。如果该后端随后执行一些阻塞的操作,或者只是很慢,则可能不会处理异步启动的读取的 IO 完成。
此 AIO 实现通过要求 AIO 方法要么允许系统中的任何后端处理 AIO 完成(例如,io_uring),要么保证即使在发出 AIO 的后端被阻塞时也会发生 AIO 处理(例如,worker 模式,它将完成处理卸载到 AIO 工作进程)来解决此问题。
可以在关键部分启动 IO
使用 AIO 进行 WAL 写入可以显著降低 WAL 日志记录的开销:
AIO 允许尽早启动 WAL 写入,以便它们在需要等待之前完成 AIO 允许同时进行多个 WAL 刷新 AIO 使使用 O_DIRECT + O_DSYNC 更加现实,这可以减少某些操作系统和存储硬件上的存储往返次数(缓冲 IO 和没有 O_DSYNC 的直接 IO 需要发出写入,并在写入完成后刷新缓存,而 O_DIRECT + O_DSYNC 可以使用单个强制单元访问 (FUA) 写入)。
能够在关键部分执行 IO 的需求对 AIO 子系统具有重要的设计意义。主要是因为完成 IO(参见上一节)需要在关键部分内完成,即使要完成的 IO 本身不是在关键部分中发出的。例如,考虑一个后端首先从共享缓冲区启动多个写入,然后开始刷新 WAL 的情况。由于一次只能进行有限数量的 IO,因此启动用于刷新 WAL 的 IO 可能需要首先完成之前启动的 IO。
AIO 的状态需要驻留在共享内存中
因为 Postgres 使用进程模型,并且因为 AIO 需要能够由任何后端完成,所以 AIO 子系统的大部分状态需要驻留在共享内存中。
在 EXEC_BACKEND 构建中,由于 ASLR,后端的执行代码和其他进程本地状态不一定映射到每个进程中的相同地址。这意味着共享内存不能包含指向回调的指针。
AIO 子系统的设计
AIO 方法
为了实现可移植性和性能,实现了多种执行 AIO 的方法,并且将来可能值得添加其他方法。
同步模式
io_method=sync 实际上并不执行 AIO,但允许在使用同步 IO 时使用 AIO API。这对于调试很有用。同步模式的代码也被例如worker 模式用作后备,它使用它来执行无法由 worker 执行的 IO。
Worker
io_method=worker 在 Postgres 运行的每个平台上都可用,并且通过将 IO 分派给多个以同步方式执行 IO 的 worker 进程来实现异步 IO(从发出进程的角度来看)。
io_uring
io_method=io_uring 在 Linux 5.1+ 上可用。与 worker 模式相比,它从进程内部分派所有 IO,从而降低了上下文切换率/延迟。
AIO 句柄
Postgres 的 AIO 抽象的核心 API 是 AIO 句柄。要执行 IO,首先必须获取一个 IO 句柄 (pgaio_io_acquire()),然后“定义”它,即将 IO 操作与该句柄关联。
通常,AIO 句柄是在更高级别上获取的,然后传递到更低级别以进行完全定义。例如,对于与共享缓冲区的 IO,bufmgr.c 例程获取句柄,然后通过 smgr.c、md.c 传递到 fd.c 中以最终完全定义。
在最低级别用于定义操作的函数是 pgaio_io_start_*()。
因为 IO 句柄的获取必须始终成功,并且 AIO 句柄的数量必须受到限制,所以 AIO 句柄一旦完成就可以重用。显然,代码需要能够对 IO 完成做出反应。可以使用 AIO 完成回调 更新状态,并且发出后端可以提供一个后端本地变量来接收 IO 的结果,如 AIO 结果 中所述。可以使用 AIO 引用 等待 IO,发出后端和任何其他后端都可以等待。
因为 AIO 句柄在调用 pgaio_io_acquire() 后不能立即执行,并且因为 pgaio_io_acquire() 需要始终成功(除非发生 PANIC),所以只能获取一个 AIO 句柄(即由 pgaio_io_acquire() 返回),而不会导致 IO 被定义(通过可能间接导致 pgaio_io_start_*() 被调用)。否则,后端可能会通过耗尽所有 AIO 句柄而无法等待某些 IO 完成来轻松地自我死锁。
如果发现不需要 AIO 句柄,例如,因为该句柄是在持有争用锁之前获取的,则可以使用 pgaio_io_release() 在未定义的情况下释放它。
AIO 回调
通常,多个层需要对 IO 的完成做出反应。例如,对于读取,md.c 需要检查 IO 是否完全失败或短于所需,bufmgr.c 需要验证页面看起来是否有效,并且 bufmgr.c 需要更新 BufferDesc 以更新缓冲区的状态。
多个层/子系统需要对 IO 完成做出反应这一事实带来了一些挑战:
上层不应需要知道下层的详细信息。例如,bufmgr.c 不应假设 IO 将通过 md.c。因此,上层无法知道下层会认为什么是错误。
下层不应需要知道上层的信息。例如,smgr API 用于通过共享缓冲区,但也用于绕过共享缓冲区。这意味着例如 md.c 无法验证校验和。
在 AIO 子系统中为每种可能的层组合编写代码会导致大量重复。
对此的“解决方案”是能够将多个完成回调与一个句柄关联。例如,bufmgr.c 可以有一个回调来更新 BufferDesc 状态并验证页面,而 md.c 可以有另一个回调来检查 IO 操作是否成功。
正如前面提到的那样,共享内存当前不能包含函数指针。因此,完成回调不是直接由函数指针标识,而是由 ID (PgAioHandleCallbackID) 标识。一个重要的额外好处是,这允许使用更少的内存量(目前是单个字节)来标识回调。
除了完成之外,AIO 回调还被调用来“暂存” IO。例如,这用于增加缓冲区引用计数,以考虑 AIO 子系统引用缓冲区的情况,这对于处理发出后端出错并在 IO 仍在进行时释放其自己的 pin 的情况是必需的。
正如前面解释的那样,IO 完成需要在关键部分中安全地执行。为了允许发出 IO 的后端在发生故障时出错,可以使用 AIO 结果。
AIO 目标
除了上面描述的完成回调之外,每个 AIO 句柄都只有一个“目标”。每个目标在 AIO 句柄中都有一些空间,其中包含特定于目标的信息,并且可以提供回调以允许重新打开底层文件(worker 模式需要)并描述 IO 操作(用于调试日志记录和错误消息)。
也就是说,如果 AIO 的两个不同用途可以用相同的方式描述正在操作的文件的标识,那么使用相同的目标可能是有意义的。例如,不同的 smgr 实现可以使用 RelFileLocator、ForkNumber 和 BlockNumber 描述 IO,因此可以共享一个目标。相反,WAL 文件的 IO 将使用 TimeLineID 和 XLogRecPtr 描述,并且对 smgr 和 WAL 使用相同的目标是没有意义的。
AIO 等待引用
正如上面描述的那样,AIO 句柄可以在完成后立即重用,因此不能用于等待 IO 完成。使用 AIO 等待引用启用等待,AIO 等待引用不仅标识 AIO 句柄,还包括句柄的“生成”。
可以使用 pgaio_io_get_wref() 获取对 AIO 句柄的引用,然后使用 pgaio_wref_wait() 等待。
AIO 结果
由于 AIO 完成回调在关键部分中执行并且可能由任何后端执行,因此不能使用完成回调来例如使触发 IO 的查询 ERROR 退出。
为了允许对失败的 IO 做出反应,发出后端可以传递一个指向后端本地内存中的 PgAioReturn 的指针。在重用 AIO 句柄之前,PgAioReturn 填充了有关 IO 的信息。这包括有关 IO 是否成功的信息(作为 PgAioResultStatus 的值)以及在发生故障时引发错误的足够信息(通过 pgaio_result_report(),错误详细信息编码在 PgAioResult 中)。
AIO 错误
如果共享完成回调将错误的详细信息编码为可以在以后引发的 ErrorData,那将非常方便。不幸的是,这样做需要分配内存。虽然 elog.c 可以保证(好吧,有点)记录消息不会耗尽内存,但这仅是因为正在记录的消息数量非常有限。使用 AIO,可能会有大量并发发出的 AIO 失败。
为了避免预先分配可能大量的内存(更不用说在共享内存中!),完成回调必须以更紧凑的格式编码错误,该格式可以转换为错误消息。
助手
使用低级 AIO API 引入了太多的复杂性,无法在整个树中都这样做。AIO 的大多数使用应该通过可重用的、更高级别的助手来完成。
读取流
AIO 的一个常见且非常有益的用途是读取,其中预先知道大量要读取的位置。例如,对于顺序扫描,需要读取的块集可以通过仅知道当前位置并检查缓冲区映射表来确定。
读取流接口使得在这种用例中使用 AIO 相对容易。