想知道 MySQL 每秒可以处理多少事务或写入吗? 虽然这取决于许多因素,但从根本上说,这取决于 MySQL 每秒可以提交到磁盘的事务数量。 现代磁盘可以执行大约 ~1000 fsyncs 每秒,但 MySQL 会将多次写入分组到每次 fsync 中。 一个大致的经验法则是每秒 5000-15,000 次写入,具体取决于每次事务的写入次数、索引数量、硬件、写入大小等因素。 阅读本文以更深入地了解这一点!

目录

  1. 问题 10:MySQL 的最大每秒事务数是否等同于每秒 fsyncs 数?
  2. 问题 9:倒排索引

各位 Napkin 的朋友们,来自四面八方,现在是另一个 Napkin 问题的时间了!

从本 newsletter 开始以来,我一直提出问题供大家尝试回答。 然后在下个月的版本中,您会听到我的答案。 与你们中的一些人交谈后,似乎很多人都将这些文章视为帖子,而不管它们的问题-答案格式如何。

这就是为什么我决定尝试一种更简单的格式:帖子中我一次性提出问题和解决方案。 这一篇会很长,因为它将包括上个月的答案。

希望您喜欢这种格式! 与往常一样,欢迎您提出反馈。

问题 10:MySQL 的最大每秒事务数是否等同于每秒 fsyncs 数?

MySQL 每秒能够处理多少事务(“写入”)?

对于一个像 MySQL 这样的符合 ACID 规范的数据库的写入(SQL insert/update/delete)操作,一个简单的模型可能是这样的(这同样适用于 Postgres 或任何其他关系型/符合 ACID 规范的数据库,但我们将以 MySQL 为例,因为我最了解它):

  1. 客户端通过现有连接向 MySQL 发送查询:INSERT INTO products (name, price) VALUES ('Sneaker', 100)
  2. MySQL 将新记录插入到 write-ahead-log (WAL) 中,并调用 fsync(2) 来告诉操作系统,告诉文件系统,告诉磁盘_确保_此数据_一定_、绝对可靠地提交到磁盘。 这一步是最复杂的,如下图所示。
  3. MySQL 将记录插入到后备存储引擎 (InnoDB) 的内存页中,以便后续查询可以看到该记录。 为什么要提交到存储引擎_和_ WAL? 存储引擎针对提供数据查询结果进行了优化,而 WAL 针对以安全的方式写入数据进行了优化 - 我们无法从 WAL 中高效地提供 SELECT
  4. MySQL 向客户端返回 OK
  5. MySQL 最终调用 fsync(2) 以确保 InnoDB 将页面提交到磁盘。

Napkin_10

如果在任何这些点发生断电,则可以在没有令人讨厌的意外的情况下定义行为,从而维护我们亲爱的 ACID 兼容性。

太棒了! 现在我们已经构建了一个关系数据库如何安全处理写入的简单模型,我们可以考虑将新记录插入到数据库中的延迟。 当我们查阅 参考 Napkin 数字 时,我们看到步骤 (2) 中的 fsync(2) 是阻塞链中最慢的操作,为 1 毫秒。

例如,步骤 (1) 中的网络处理大约需要 ~10 微秒(TCP Echo Server 是我们可以归类为“TCP 开销”的)。 在 fsync(2) 之前的 write(2) 本身也可以忽略不计,约为 ~10 微秒,因为此系统调用本质上只是写入内核中的内存缓冲区(“页面缓存”)。 这并不能保证实际的位已提交到磁盘,这意味着意外断电会擦除数据,从而使我们的 ACID 兼容性下降。 调用 fsync(2) 可以保证位持久保存在磁盘上,这将在意外的系统关闭中幸免。 缺点是它慢了 100 倍。

有了这些,我们应该能够形成一个关于 MySQL 最大吞吐量的简单假设:

MySQL 的最大理论吞吐量等同于每秒 fsync(2) 的最大数量。

我们知道 fsync(2) 需要 1 毫秒,这意味着我们天真地期望 MySQL 能够执行大约:1s / 1ms/fsync = 1000 fsyncs/s = 1000 transactions/s

很棒。 我们遵循了 Napkin 数学的最初三个步骤:(1) 对系统建模,(2) 确定相关的延迟,(3) 进行 Napkin 数学计算,(4) 根据现实验证 Napkin 计算。

开始第 (4) 步:验证! 我们将用 Rust 编写一个简单的基准测试,该基准测试使用 16 个线程写入 MySQL,每个线程执行 1,000 次插入:

for i in 0..16 {
  handles.push(thread::spawn({
    let pool = pool.clone();
    move || {
      let mut conn = pool.get_conn().unwrap();
      // TODO: we should ideally be popping these off a queue in case of a stall
      // in a thread, but this is likely good enough.
      for _ in 0..1000 {
        conn.exec_drop(
          r"INSERT INTO products (shop_id, title) VALUES (:shop_id, :title)",
          params! { "shop_id" => 123, "title" => "aerodynamic chair" },
        )
        .unwrap();
      }
    }
  }));
  for handle in handles {
   handle.join().unwrap();
  }
  // 3 seconds, 16,000 insertions
}

这需要大约 3 秒钟才能执行 16,000 次插入,即每秒大约 5,300 次插入。 这比我们的 Napkin 数学告诉我们的理论最大事务吞吐量 1,000 次 fsync 每秒多 5 倍

通常,使用 Napkin 数学,我们的目标是在一个数量级内,我们确实如此。 但是,当我进行 Napkin 数学计算时,它通常会建立系统的下限,即从第一原理出发,在理想情况下,该系统的速度_可以_达到多快?

系统很少比 Napkin 数学快 5 倍。 当我们发现实际性能和预期性能之间存在显着差距时,我将其称为“第一原理差距”。 这是好奇心开始的地方。 它通常意味着 (1) 有机会改进系统,或 (2) 我们的系统模型存在缺陷。 在这种情况下,只有 (2) 有意义,因为系统比我们预测的更快。

我们的系统工作方式的模型有什么问题? 为什么每秒 fsyncs 不等于每秒事务数?

首先,我检查了基准测试……有什么问题吗? 没有,SELECT COUNT(*) FROM products 显示 16,000。 我使用的 MySQL 是否配置为不在每次写入时 fsync? 没有,它处于 安全默认值

然后我坐下来思考。 也许 MySQL _不是_在每个_单个_写入时都执行 fsync? 如果它每秒处理 5,300 次插入,也许它会将多个写入批量处理到一起,作为写入 WAL 的一部分,如上面的步骤 (2) 所示? 由于每个事务都很短,因此 MySQL 可以通过等待几微秒来查看其他事务是否想要参与,然后再调用昂贵的 fsync(2)

我们可以通过编写一个简单的 bpftrace 脚本来观察 ~16,000 次插入的 fsync(1) 的数量来测试这个假设:

tracepoint:syscalls:sys_enter_fsync,tracepoint:syscalls:sys_enter_fdatasync
/comm == "mysqld"/
{
    @fsyncs = count();
}

在插入 16,000 条记录所花费的 ~3 秒钟内运行此脚本,我们得到 ~8,000 个 fsync 调用:

$ sudo bpftrace fsync_count.d
Attaching 2 probes...
^C
@fsyncs: 8037

这是一个特殊的数字。 如果 MySQL 正在批量处理 fsync,我们期望看到的数字要低得多。 这个数字意味着我们平均以 ~0.4 毫秒的延迟执行 ~2,500 个 fsync 每秒。 这比我们预期的 fsync 延迟快两倍,即前面提到的 1 毫秒。 为了确保正确性,我再次运行该脚本以对 MySQL 外部的 fsync 进行基准测试,没有,仍然是 1 毫秒查看了分布,它始终约为 ~1 毫秒。

因此,我们可以从中得出两件事:(1) 我们可以比我们预期的更快地 fsync 两倍以上,(2) 我们的假设是正确的,即 MySQL 比每个事务执行一次 fsync 更聪明,但是,由于 fsync 也比预期的快,因此这并不能解释一切。

如果您还记得上面的内容,虽然从理论上讲,提交事务可能只需要一个 fsync,但 MySQL 的其他功能也可能会调用 fsync。 也许它们正在增加噪音?

我们需要按文件描述符对 fsync 进行分组,以更好地了解 MySQL 如何使用 fsync。 但是,原始文件描述符编号并没有告诉我们太多信息。 我们可以使用 readlinkproc 文件系统来获取文件描述符指向的文件名。 让我们编写一个 bpftrace 脚本,看看正在 fsync 'ed 什么:

tracepoint:syscalls:sys_enter_fsync,tracepoint:syscalls:sys_enter_fdatasync
/comm == str($1)/
{
 @fsyncs[args->fd] = count();
 if (@fd_to_filename[args->fd]) {
 } else {
  @fd_to_filename[args->fd] = 1;
  system("echo -n 'fd %d -> ' &1>&2 | readlink /proc/%d/fd/%d",
      args->fd, pid, args->fd);
 }
}
END {
 clear(@fd_to_filename);
}

在将 16,000 个事务插入到 MySQL 中时运行此脚本会得到:

personal@napkin:~$ sudo bpftrace --unsafe fsync_count_by_fd.d mysqld
Attaching 5 probes...
fd 5 -> /var/lib/mysql/ib_logfile0 # redo log, or write-ahead-log
fd 9 -> /var/lib/mysql/ibdata1 # shared mysql tablespace
fd 11 -> /var/lib/mysql/#ib_16384_0.dblwr # innodb doublewrite-buffer
fd 13 -> /var/lib/mysql/undo_001 # undo log, to rollback transactions
fd 15 -> /var/lib/mysql/undo_002 # undo log, to rollback transactions
fd 27 -> /var/lib/mysql/mysql.ibd # tablespace 
fd 34 -> /var/lib/mysql/napkin/products.ibd # innodb storage for our products table
fd 99 -> /var/lib/mysql/binlog.000019 # binlog for replication
^C
@fsyncs[9]: 2
@fsyncs[12]: 2
@fsyncs[27]: 12
@fsyncs[34]: 47
@fsyncs[13]: 86
@fsyncs[15]: 93
@fsyncs[11]: 103
@fsyncs[99]: 2962
@fsyncs[5]: 4887

我们可以在这里观察到的是,大多数写入都写入到“redo log”,我们将其称为“write-ahead-log” (WAL)。 有一些 fsync 调用来提交 InnoDB 表空间,但不如 WAL 那么频繁,因为如果我们在它们之间崩溃,我们始终可以从 WAL 中恢复它。 在 fsync 之前,读取工作正常,因为查询可以直接从 InnoDB 的内存中提供服务。

这里唯一令人惊讶的是大量写入 binlog,我们之前没有提到过它。 您可以将 binlog 视为“复制流”。 它是诸如 行 a 从 x 更改为 y行 b 已删除表 u 添加了列 c 之类的事件流。 主副本将此流式传输到读取副本,后者使用它来更新自己的数据。

当您考虑它时,binlog 和 WAL 需要完全同步。 我们不能在主副本上提交一些东西,但没有提交到副本。 如果它们不同步,这可能会由于读取副本中的漂移而导致数据丢失。 主副本可能会将更改提交到 WAL,断电,恢复,但永远不会将其写入 binlog。

由于 fsync(1) 一次只能同步一个文件描述符,因此您如何才能确保 binlog 和 WAL 包含事务?

一种解决方案是将 binlogWAL 合并为一个日志。 我不太确定为什么不是这种情况,但原因可能是历史性的。 如果您知道,请告诉我!

MySQL 采用的解决方案是使用双因素提交。 这需要三个 fsync 才能提交事务。 此参考 更详细地解释了这个过程。 因为 WAL 作为双因素提交的一部分被触摸两次,所以它解释了为什么我们看到对它的 fsync 数量大约是上面 bpftrace 输出中 bin-log 的两倍。 在 MySQL 中将多个事务分组为一个双因素提交的过程称为“组提交”。

我们可以从这些数字中收集到的是,由于组提交,似乎 ~16,000 个事务已减少为 ~2885 个提交,或者平均每个提交 ~5.5 个事务。

但还有一件事仍然存在……为什么每个 fsync 的平均延迟比我们的基准测试快两倍? 再次,我们编写一个简单的 bpftrace 脚本:

tracepoint:syscalls:sys_enter_fsync,tracepoint:syscalls:sys_enter_fdatasync
/comm == "mysqld"/
{
    @start[tid] = nsecs;
}
tracepoint:syscalls:sys_exit_fsync,tracepoint:syscalls:sys_exit_fdatasync
/comm == "mysqld"/
{
    @bytes = lhist((nsecs - @start[tid]) / 1000, 0, 1500, 100);
    delete(@start[tid]);
}

它向我们抛出了这个直方图,确认我们看到了一些_非常_快的 fsync

personal@napkin:~$ sudo bpftrace fsync_latency.d
Attaching 4 probes...
^C
@bytes:
[0, 100)       439 |@@@@@@@@@@@@@@@                   |
[100, 200)       8 |                          |
[200, 300)       2 |                          |
[300, 400)      242 |@@@@@@@@                      |
[400, 500)     1495 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[500, 600)      768 |@@@@@@@@@@@@@@@@@@@@@@@@@@             |
[600, 700)      376 |@@@@@@@@@@@@@                    |
[700, 800)      375 |@@@@@@@@@@@@@                    |
[800, 900)      379 |@@@@@@@@@@@@@                    |
[900, 1000)     322 |@@@@@@@@@@@                     |
[1000, 1100)     256 |@@@@@@@@                      |
[1100, 1200)     406 |@@@@@@@@@@@@@@                   |
[1200, 1300)     690 |@@@@@@@@@@@@@@@@@@@@@@@@              |
[1300, 1400)     803 |@@@@@@@@@@@@@@@@@@@@@@@@@@@             |
[1400, 1500)     582 |@@@@@@@@@@@@@@@@@@@@                |
[1500, ...)     1402 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@  |

要准确了解这里发生了什么,我们必须深入研究我们正在使用的文件系统。 这将超出范围(否则我永远不会发送任何东西)。 但是,为了不让您完全悬而未决,大概 ext4 正在使用类似于 MySQL 的组提交的技术来批量处理日记帐中的写入(相当于 MySQL 的 write-ahead-log)。 在 ext4 的词汇表中,这似乎被称为 max_batch_time ,但是关于此的文档充其量是稀疏的。 磁盘也可能在文件系统之外/代替文件系统执行此操作。 如果您对此有更多了解,请启发我!

最重要的是,在实际工作负载中,fsync 的执行速度可能比我在这台机器上通过重复写入和 fsync 文件所获得的 1 毫秒快。 很可能是由于 ext4 等效的组提交,这在我们从未并行执行多个 fsync 的基准测试中看不到。

这使我们回到了解释现实生活与 MySQL 理论最大吞吐量的 Napkin 数学之间的差异。 由于以下原因,我们可以从原始 fsync 调用中获得至少 5 倍的吞吐量增长:

  1. MySQL 通过“组提交”将多个事务合并为更少的 fsync
  2. 文件系统和/或磁盘通过其自身的“组提交”合并并行执行的多个 fsync,从而产生更快的性能。

本质上,批量处理的相同技术用于每一层以提高性能。

虽然我们没有设法解释这里发生的一切,但我肯定从这次调查中学到了很多东西。 根据这一点,调整 组提交设置 以优化 MySQL 的吞吐量而不是延迟可能会很有趣。 这也可以在文件系统级别进行调整。

问题 9:倒排索引

上个月,我们研究了倒排索引。 这种数据结构是全文搜索的基础,并且文档的打包方式非常适合集合交集。

(A) 您估计使用 200 万个 title 的 id 和 100 万个 see 的 id 获取 title AND see 的 id 需要多长时间?

让我们假设每个文档 id 都存储为 64 位整数。 然后我们处理的是 1 * 10^6 * 64bit = 8 Mb2 * 10^6 * 64 bit = 16 Mb。 如果我们使用一个非常简单的集合交集算法,本质上是两个嵌套的 for 循环,我们需要扫描 ~24Mb 的顺序内存。 根据 参考,我们可以在 1Mb/100us * 24Mb = 2.4ms 中完成此操作。

奇怪的是,Lucene 每晚基准测试 以大约 22 QPS 的速度执行这些查询,或者每个查询 1000ms/22 = 45ms。 这比我们的预测差很多。 我准备解释为什么 Lucene 可能_更快_(例如,通过将 postings 压缩到小于 64 位),而不是解释为什么它可能慢 20 倍! 我们又有了一个第一原理差距。

一些缓慢可能是由于从磁盘读取造成的,但是由于访问模式是顺序的,因此它应该只慢 2-3 倍。 硬件可能与参考不同,但很难解释 20 倍的差异。 将数据发送到客户端可能会导致很大的损失,但是同样,20 倍看起来很大。 这种类型的差距指向缺少一些基本的东西(正如我们在 MySQL 中看到的那样)。 不幸的是,这个月我没有时间深入研究,因为我优先考虑了 MySQL 的帖子。

(B) 那么 title OR see 呢?

在这种情况下,我们必须扫描大致相同数量的内存,但是处理更多的文档,并可能将更多的文档传输回客户端。 我们预计性能大约在同一数量级 ~2.4ms

在这种情况下,Lucene 吞吐量大约减半,这与我们的相对期望相符。 但是同样,就绝对值而言,Lucene 处理这些查询的时间约为 ~100ms,这比我们预期的要高得多。

(C) Lucene 每晚的基准测试对于 (A) 和 (B) 相比如何? 此文件显示了一些实际使用的术语。 如果它们不一致,您将如何解释这种差异?

与 (A) 和 (B) 一起回答。

(D) 让我们想象一下,我们想要 title AND see 并按每个文档的最后修改日期对结果进行排序。 您预计这需要多长时间?

如果 postings 未按该顺序存储,我们天真地期望在最坏的情况下,我们需要对大约 ~24Mb 的内存进行排序,以 5 毫秒/Mb 的速度。 这会将我们置于 5mb/mb * 24mb ~= 120ms 的查询时间范围内。

实际上,这似乎是一个无意的技巧问题。 如果按最后修改日期排序,则它们已经大致按该顺序排序,因为新文档插入到列表的末尾。 这意味着它们已经按_大致_正确的顺序存储,这意味着我们的排序必须移动的位数要少得多。 即使情况并非如此,我们也可以仅为此列存储排序列表,例如 Lucene 允许使用 doc values。

通过电子邮件、RSSTwitter 订阅新文章! 3,637 位订阅者

您可能也喜欢...

← 返回博客