优化 I/O 吞吐量

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / Java 学习路线 / 一对一提问 / 学习打卡/ 赠书活动

目前,正在 星球 内带小伙伴们做第一个项目:全栈前后端分离博客项目,采用技术栈 Spring Boot + Mybatis Plus + Vue 3.x + Vite 4手把手,前端 + 后端全栈开发,从 0 到 1 讲解每个功能点开发步骤,1v1 答疑,陪伴式直到项目上线,目前已更新了 204 小节,累计 32w+ 字,讲解图:1416 张,还在持续爆肝中,后续还会上新更多项目,目标是将 Java 领域典型的项目都整上,如秒杀系统、在线商城、IM 即时通讯、权限管理等等,已有 870+ 小伙伴加入,欢迎点击围观

我们收到了客户关于他们在一组特定机器上启动时看到的性能问题的请求。

那些机器在云环境中运行,它们具有……奇特的,有人可能会说 异常的 I/O 特性。特别是,这些机器上的 I/O 管道很宽,但速度很慢。那是什么意思?我的意思是,任何特定的 i/o 操作都可能很慢,但我的想法是,如果你发出并发 i/o,你可以获得更好的性能。该系统应该能够更好地处理这个问题,总体而言,您会看到与其他地方相同的相对性能。

这对我们来说是个大问题,因为对于很多事情,我们确实关心串行 I/O 性能。例如,如果我们正在提交一个事务,除了等待 i/o 完全完成之外,我们真的没有其他方法来处理它。

也就是说,我们遇到问题的特定场景是启动。如果数据库在关闭时负载很重,恢复日志将已满,数据库将需要重放最近发生的操作。请注意,关闭性能很重要,因为在很多情况下,我们在关闭时带有滴答时钟(在 iis 中或作为 Windows 服务)的环境中运行。

在启动时,我们通常有更多的时间,预计我们将需要一些时间来加快速度。如果没有别的,仅仅将足够的数据库放入内存将需要时间,因此在大型数据库上,启动时间预计是非常重要的。

也就是说,这些机器的启动时间非常糟糕。为了弄清楚发生了什么,我取出进程监视器并查看文件 i/o。我们这样做:

我们正在从日记中读取,这是非常串行的 I/O(在图像中,我正在运行远程网络驱动器,以模拟缓慢的响应)。请注意,我们需要以串行方式读取日志,而操作系统读取内容的方式是一次读取 32kb。

请记住,我们正在以串行方式读取内容,这意味着我们有很多页面错误,并且我们有一个缓慢的 I/O 系统,并且我们串行执行它们。

是的,那是性能的杀手。顺便说一句,当我谈论慢速 I/O 系统时,我说的是大多数请求每次磁盘读取 > 0.5 毫秒(理想情况下,我们会有 0.05 – 0.15 的延迟)。正如您可以想象的那样,我们有很多这样的人。

因为我知道我们将要阅读整个日志,所以我使用了 prefetchvirtualmemory() 方法并将整个文件传递给它(最大为 64mb,无论如何我们都需要阅读它)。这让操作系统在读取数据时拥有最大的自由度,并且它会产生大量的并发 I/O。这是它的样子:

这也为宽输入/输出带宽提供了发挥的机会。我们用很多东西加载 I/O 子系统,它可以尝试以优化的方式去做。

下一个成本高昂的部分是我们需要将日志文件中的数据应用到数据文件,并同步它。

不幸的是,同步文件的性能与文件的大小有关。并且有问题的文件很大,超过 45gb。特别是在这样的系统上,我们在这里看到了 很多 延迟,如 几分钟 。一个明显的优化是不同步每个日志文件,而是在整个恢复过程中同步一次。这有帮助,但它仍然太贵了。

接下来,我们尝试了几乎所有我们能想到的方法。

  • 切换到 writefile(从使用 mmap 然后调用 flushviewofffile)
  • 使用异步 i/o (writefileex)
  • 在没有缓冲的情况下使用分散/收集 i/o(省去了最后进行同步的需要)
  • 完成端口
  • 问一个 4 个月大的女婴她怎么想的(她吐在键盘上,这是我当时想做的,然后她哭了,我加入了她)

似乎没有任何效果。主要问题是在这个工作负载中,我们有一个大文件(如我所说的 45gb),我们在有效随机的地方写入 4kb 的页面。在我们尝试处理的工作负载中,大约有 256,000 次单独的 4kb 写入(其中大部分不是连续的,因此我们无法从中受益)。这大约是 1 GB 的写作量。

我们无能为力使我们超过 3mb/秒左右。用数十万次小写入使 i/o 子系统饱和是行不通的,我们不知所措。请注意,我们进行了一个小测试,仅手动复制数据就在这些机器上产生了大约 10 毫秒/秒的窥视性能。这是一个非常 蹩脚的 数字,所以我们无能为力。

然后我想问,为什么我们只在启动时看到这个?当然,这也经常发生。为什么我们没有注意到?

原因很简单,我们没有注意到,因为我们摊销了成本。只有在启动时,我们才不得不真正坐下来等待它完成。所以我们放弃了这个要求。我们过去常常读取所有日志,将它们应用到数据文件,同步数据文件,然后删除日志。现在我们读取日志,将它们(通过内存映射)应用到数据文件,并且只记住我们在内存中应用的最后一个日志文件是什么。

有一个后台进程正在运行,负责同步数据文件(并删除旧日志)。如果我们再次崩溃,我们只需要重播我们不确定之前是否同步过的日志。这样可以节省更多时间。

但我们还有另一个问题。写入内存映射文件需要操作系统将相关页面分页到内存中。同样,我们的 i/o 很慢,操作系统只会对我们接触的内容进行分页,所以这又是一个串行过程,这次需要我们以 3mb/秒的速度加载大约 1gb 的数据。那是……不是一个好地方。所以下一步是弄清楚我们将写入的所有地址,并让操作系统知道我们将获取它们。我们做了一些工作来确保我们将这些值(和相邻页面)加载到内存中,然后我们可以写入它们而无需为每个页面单独分页。

这样做的一个很好的副作用是,因为它是在系统中的最新更改上运行的,所以它具有将可能在数据库启动后使用的页面预加载到内存中的效果。

这是很多工作,但坦率地说,这主要是在恶劣的环境中进行优化。客户不能轻易离开他们当前的机器,但这些机器的 i/o 速率会使任何数据库都坐在角落里哭泣。