并发 vs. 吞吐量:为什么更多并行性反而会让数据库变慢

Lobsters Hottest 新闻

摘要

PlanetScale 的一篇工程博客分析了由长事务和高并发导致的 MySQL 宕机,解释了并行性如何降低吞吐量,以及 Vitess 的事务池如何处理(并放大)了该问题。

<p><a href="https://lobste.rs/s/pe58bh/concurrency_vs_throughput_why_more">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/08 00:27

# 并发与吞吐:为什么更多并行会让数据库更慢 —— PlanetScale 来源:https://planetscale.com/blog/concurrency-vs-throughput-vitess-mysql 不久前,我们眼睁睁看着一个生产环境中的 MySQL 数据库熔毁了整整十六分钟。 错误一开始只是涓涓细流,每分钟只有几个,然后开始自我加剧: ``` 分钟 错误/分钟 查询/秒 0 5 15,000 <- 一批工作请求到达 2 60 3,900 4 300 2,500 6 550 2,000 8 900 1,900 10 1,400 1,500 <- 错误峰值 = 吞吐低谷 12 700 1,700 14 250 2,000 16 0 2,500 <- 锁已释放,积压耗尽 18 0 8,500 <- 完全恢复,吞吐量跃升 5 倍 ``` 触发原因相当普通:一个批处理作业在热表上开启了一个事务,获取了行锁,然后在十五分钟内都没有提交。这是一个常见的应用 bug,最终会潜入许多大型代码库的那种。 这个长事务周围发生的事情才是关键!堆积在该事务后面的查询大多根本没有被它的锁阻塞。它们只是简单的读取,InnoDB 不需要等待行锁;它读取的是一致性快照。构建该快照意味着要沿着已打开事务触碰过的每一行的版本历史回溯,而该历史在整整十五分钟内不断增长。 这导致通常只需毫秒级的读取开始冲破它们 90 秒的执行上限。应用程序在一个紧循环中不断重试它们。几分钟内,超过一万个请求堆积在存储引擎内部。处理每个请求都需要重建越来越长的版本链,由此产生的页读取超出了缓冲池释放内存的能力。 现在,与锁定行毫无关系、访问完全不同表的请求也开始失败。原本大小合适的缓冲池突然变得太小,无法服务成群结队的查询。 在 PlanetScale,我们使用 Vitess 运行 MySQL 数据库,Vitess 配置的事务超时最终杀死了这个长期运行的事务。它的锁被释放,十六分钟的积压大约在三十秒内排空。 一个慢事务不应该让数据库宕机,Vitess 的许多管道设计正是为了避免这种情况。那么是什么出了问题?原因是单个长时间运行的查询以及随后允许进入的一万个请求的组合。 ## 从 Cloud SQL 迁移到 Vitess 首先提供一些背景,说明一个看似健康的数据库最初怎么会陷入这种状况。 这个工作负载最近从 Cloud SQL 迁移到了 PlanetScale。Cloud SQL 在数据库前运行了托管连接池(Managed Connection Pooling),这是一种线程池风格的层。线程池限制同时执行的语句数量,通常在一千左右,并将其余语句排队。当池已满时到达的客户端会等待一个槽位,通常只需毫秒,偶尔几百毫秒。这个上限即使在极端负载下也能保护 InnoDB 的内部机制。 在 PlanetScale 上,每个 MySQL 分片都位于一个名为 vttablet(https://vitess.io/docs/reference/programs/vttablet/)的 Vitess 代理后面。这些代理有一个事务池,限制同时针对 MySQL 打开的事务数量。与线程池不同,当这个池满时,请求会等待一段设定时间,然后以错误失败。 对于这个工作负载,这个微小的差异让问题严重得多。错误向上传播到了一个从未需要处理它们的应用程序栈(因为 Cloud SQL 线程池会对请求排队,池满错误在那里实际上不存在)。 最初尝试的解决方案是提高事务池上限并增加超时时间。 池被增加到一万,这个数字不是通过容量规划选择的,而是因为它让错误停止了。然而,这现在限制了 Vitess 在应用程序和数据库存储引擎之间控制背压的能力。 ## 为什么高并发会降低 MySQL 吞吐量 把数据库事务想象成在传送带上流动的物品(有谁玩过 Factorio 吗?)。如果它们从不相互影响,吞吐量将呈直线扩展:在途物品翻倍,完成的工作也翻倍。这是一个完美的无共享(https://en.wikipedia.org/wiki/Shared-nothing_architecture)架构的梦想,但很少能实现。在传送带的某个地方,有多条传送带交汇的节点。在 MySQL 中,这相当于热行、闩锁、CPU 运行队列等等。 下面是一个简单的游乐场,用于可视化请求速率和排队如何影响延迟。调整 `ARRIVALS / S` 滑块来查看影响。 利特尔定律(https://en.wikipedia.org/wiki/Little%27s_law)很好地描述了这一点。在稳定状态下,`N = X \* W`。在途工作等于吞吐量乘以每个请求在数据库内部花费的时间。重新排列,`X = N / W`。 只有在查询执行时间保持稳定的情况下,增加在途请求才会提高吞吐量。如果每个新到达的请求都拉长了数据库内所有其他查询的执行/等待时间,那么分母就会和分子一起增长。 究竟能糟糕到什么程度?Gunther 的通用扩展定律(https://www.perfdynamics.com/Manifesto/USLscalability.html)将并发的成本分解为三项: ``` γN X(N) = ─────────────────────────── 1 + α(N−1) + βN(N−1) ``` - `N` 是允许同时运行的工作量。 - `γ` 是理想情况下的单请求吞吐量:如果请求从不交互时的线性扩展系数。 - `α`(争用)是为共享资源轮流等待的成本。 - `β`(一致性)是请求让*彼此*变慢的成本。换句话说,数据库为了保持所有在途请求的共享状态一致而必须做的工作。 注意乘数 `N(N−1)`,它随请求的*对数*增长。当 `N` 翻倍时,这个一致性成本大约翻两番(变为四倍)。 过了临界点 `N_max = √((1−α)/β)` 之后,`β` 占主导地位,总吞吐量实际上开始逆转。Gunther 称之为逆行扩展:超过峰值后,你每允许一个请求都需要协调开销,并让一切都变慢。 让我们看看之前的问题如何映射到这个方程。行锁是 `α`。想要这些行的事务必须轮流进行,无论多少并发都无法改变队列的排空速率。`β`,真正的问题,是 InnoDB 试图一次处理如此多的查询:一万个并发快照读,每个都因每个*其他*在途事务正在生成的版本历史而变得更加昂贵。每请求成本随着在途请求的增加而上升。 这就是为什么仅仅关注“锁争用”无法解释这个问题。锁只是许多潜在节点之一。正是节点*周围*的损害,即所有通过它的请求相互拖慢,才随并发的平方扩展,并将一个慢批处理作业变成更大范围的故障。 ## 缩小池大小并实现排队 为了解决这个问题,我们在两个维度上同时实施了与最初设置相反的改变: 1. 将 Vitess 事务池大小从一万减少到旧线程池所用的大约一千。我们现在是有意识地这样做,并且有证据支持:那个工作负载在如此大小的池下已经成功运行了多年。 2. 到达满池的事务现在不再在等待后报错,而是以更长的超时排队等待槽位。 换句话说,我们配置了 Vitess 的 vttablet 层来模拟旧线程池的行为。 为了安全起见,必须满足两件事。客户端必须容忍突发期间的周期性等待,而且等待本身必须有界,这样真正容量不足的系统会在队列处以超时的方式宣告自己,而不是永远累积延迟。对于这个工作负载,这两点都成立。 推出这个新配置后的第二天早上,它迎来了第一次测试。一个流量突发到达了同一数据库的另一个 Vitess 分片,该分片处理的稳态流量高出一个数量级。在高峰时,该池以一千多个可用槽位每秒处理了大约两万五千个事务池槽位请求。以下是我们开篇时使用的同一视角的结果: ``` 分钟 槽位请求/秒 错误/分钟 查询/秒 0 3,000 0 58,000 10 9,000 0 61,000 20 26,000 0 60,000 <- 峰值压力,吞吐量持平 30 17,000 0 59,000 40 8,000 0 62,000 50 4,000 0 60,000 60 2,000 0 57,000 <- 突发耗尽,队列为空 ``` 在之前的事故中,错误攀升到每分钟 1,400 个,吞吐量下降到工作负载所要求的一成。 使用新的改进配置,应用程序没有经历中断,QPS 也没有下降。 在整整一天中,数据库的健康状况好得多: - **一个被拒绝的事务。** 尽管槽位请求/秒有时飙升至 40,000,队列实际上吸收了所有请求。等待时间轻松落在超时范围内。 - **任意时刻 MySQL 内执行的语句少于两百条。** InnoDB 很好地管理了突发。每秒六万查询通过不到两百个并发槽位,这又是利特尔定律:每个约三毫秒。 - **锁争用没有改变。** 热行模式在这里只是一个小因素。我们能够在不改变锁争用动态的情况下解决问题。 这让数据库一次做的工作更少,从而在总体上完成更多工作。 下面是另一个游乐场,用于可视化开放式请求流和门控式请求流之间的区别。发送突发流量,模拟锁定事件,并通过下面的按钮查看门控和非门控解决方案之间的区别。 ## 何时限制数据库并发 这并不是普遍建议严格限制并发。它的适用性必须结合你的工作负载来理解。 - 基于悲观锁和争用共享状态构建的工作负载。 - 由许多工作者更新的热行。 - 在热门键上执行 `SELECT \.\.\. FOR UPDATE`。 - 持有锁的同时做无关工作的长事务。 - 计数器、余额、任务队列。 如果你的工作负载符合这些描述,增加并发可能反而会降低你的吞吐量

相似文章

SQL:设计上的缺陷

Hacker News Top

本文分析了 SQL 中固有的并发缺陷,如原子性失效、TOCTOU 问题和死锁,并通过资金转账示例展示了正确的锁机制和事务实践。

使用 Postgres 作为作业队列的潜在后果

Lobsters Hottest

文章分析了使用 PostgreSQL 作为作业队列的可扩展性限制,特别强调了高并发下 MultiXact SLRU 争用导致的性能瓶颈。文章解释了为什么这种架构在开发环境中表现良好,但在生产环境中却会失败,并建议考虑替代方案。

让Postgres队列实现可扩展性

Hacker News Top

一篇详细的技术博文,解释如何使用SKIP LOCKED和适当的事务隔离级别来扩展基于PostgreSQL的队列,实现每秒3万次工作流执行。