cloudflare在多租户数据库环境中遭遇的问题与挑战


以 Cloudflare 规模运营意味着我们在整个技术堆栈中花费大量时间来处理不同的负载条件。在这篇博文中,我们讨论如何使用 Postgres 集群解决性能难题。这些集群支持大量租户和高度可变的负载条件,导致需要隔离活动以防止租户占用其他租户太多时间。欢迎来到现实世界的大型数据库集群管理!

Cloudflare 在数据中心的多个区域运行生产 Postgres 集群。我们最早的一些服务产品,例如我们的 DNS 解析器、防火墙和 DDoS 保护,依赖于我们的 Postgres 集群对 OLTP 工作负载的高可用性。高可用性集群管理器Stolon应用于所有集群,以独立控制和复制 Postgres 实例之间的数据,并在高负载场景下选举 Postgres 领导者和故障转移。

PgBouncer 和 HAProxy 充当每个集群中的网关层。每个租户从 PgBouncer 获取客户端连接,而不是直接从 Postgres 获取。PgBouncer 拥有一个与 Postgres 的最大服务器端连接池,将这些连接分配给多个租户,以防止 Postgres 连接匮乏。从这里,PgBouncer 将查询转发到 HAProxy,后者在 Postgres 主副本和只读副本之间进行负载平衡。

问题
我们的多租户 Postgres 实例在非容器化环境中的裸机服务器上运行。

由于每个集群服务于多个租户,所有租户共享并争夺每个集群机器上的可用系统资源,例如 CPU 时间、内存、磁盘 IO,以及有限的数据库资源,例如服务器端 Postgres 连接和表锁。

每个租户都有一个独特的工作负载,其系统级资源消耗各不相同,因此无法使用全局值强制执行限制。

这在生产中已成为影响邻近租户的问题:

  • 吞吐量。租户可能会发出大量事务,从而耗尽其他租户的共享资源并降低其性能。
  • 延迟:单个租户可能会同时发出非常长或昂贵的查询,例如用于 ETL 提取的大型表扫描或具有较长表锁的查询。

这两种情况都可能导致相邻租户的查询执行性能下降。由于 CPU 共享时间减少,或者由于行为不当的租户进行多次查找而导致磁盘 IO 操作变慢,他们的事务可能会挂起或需要更长的时间来执行(更高的延迟)。此外,由于现有连接在长时间且昂贵的查询期间被保留,其他租户可能无法从数据库代理级别(PgBouncer)获取数据库连接。

解决方案
当数据库集群负载显着增加时,找到哪些租户负责是第一个挑战。一些技术包括在典型系统负载下搜索所有租户以前的查询,并确定在 Postgres 的 pg_stat_activity 视图下是否引入了任何新的昂贵查询。

1、限制并发
一旦识别出行为不当的租户,就会使用 Postgres 查询手动实施 Postgres 服务器端连接限制。
ALTER USER "some_bad-user" WITH CONNECTION LIMIT 123;
这本质上限制或“挤压”单个用户的并发吞吐量,其中每个租户只能耗尽他们的连接份额。
手动并发(连接)限制在高生产工作负载期间减少了 Postgres 中的负载

虽然我们已经看到这种方法取得了成功,但它并不完美,而且需要手动操作。它还受到以下问题的困扰:

  • 当设置新的用户限制时,Postgres 不会立即终止现有的租户连接;用户可能会继续发出突发或昂贵的查询。
  • 即使并发性(连接池大小)减少,租户仍可能发出非常昂贵的资源密集型查询(影响相邻租户)。
  • 对行为不端的租户手动应用连接限制是一项艰巨的任务;可以在一天中的任何时间呼叫 SRE 以实际应用新的限制。
  • 基于查询手动分析和检测行为不当的租户可能非常耗时且压力很大,尤其是在发生事件期间,需要生产 SQL 分析经验。
  • 此外,为每个用户/池应用新的限制(例如分配的连接计数)可以是任意的和实验性的,同时需要对租户工作负载有广泛的了解。
  • 通常,Postgres 可能会承受过大的负载,以至于开始挂起(CPU 饥饿)。一旦发生高负载情况,SRE 可能无法通过本机接口手动限制租户。

2、网关并发限制
通常,一旦提交到服务器或数据库系统执行,查询的系统级资源消耗就很难控制和隔离。然而,一种常见的方法是在网关层拦截和限制连接或查询,根据系统资源消耗控制每个用户/池的流量特征。
我们在数据库代理服务器/连接池 PgBouncer 上实现了连接限制。以前,PgBouncer 的用户级别连接限制不会终止现有连接,而只会防止超过它。我们现在支持通过配置静态地或通过新的管理命令在运行时限制和终止每个用户或每个用户的连接池拥有的现有连接的能力。

PgBouncer 配置

[users] dns_service_user = max_user_connections=60 firewall_service_user = max_user_connections=80 [pools] user1.database1 = pool_size=90

PgBouncer 运行时命令
SET USER dns_service_user = ‘max_user_connections=40’; SET POOL dns_service_user.dns_db = ‘pool_size=30’;

这需要在我们的 PgBouncer 分支中进行重大错误修复、重构和实施工作。我们还提出了多个拉取请求,将我们的所有功能贡献给 PgBouncer 开源。要了解我们在 PgBouncer 中的所有工作,请阅读此博客

这些新功能现在允许针对行为不当的租户并发(连接池、用户和数据库对)进行更快、更细粒度的“负载卸载”,同时实现更严格的性能隔离。

3、拥塞避免
拥塞避免不是被动地减轻负载,而是在负载引起的性能下降成为问题之前预防性地或“平滑地”限制流量。该算法旨在防止数据库服务器资源匮乏而导致其他查询挂起。
理论上,如果一个租户行为不当并导致其他租户出现负载引起的延迟,则该 TCP 拥塞算法可能会错误地盲目限制所有租户。因此,当系统性能下降时,可能有必要仅针对具有高 CPU 延迟相关性的租户应用这种自适应限制。

4、租户资源配额
可以为每个租户引入可配置的资源配额。上游应用程序服务租户受限于其分配的资源份额,以每秒使用的 CPU % 和最大内存表示。如果租户过度使用其共享,数据库网关 (PgBouncer) 应限制其并发性、每秒查询数和入口字节数,以强制在其分配的切片内进行消耗。

对租户的资源限制不得“溢出”或影响访问同一集群的其他租户。否则,这可能会降低其他面向客户的应用程序的可用性并违反 SLO(服务级别目标)。资源限制必须隔离到每个租户。

每个租户都有独特且可变的工作负载,这可能随时降低多租户性能。快速检测需要近乎实时地根据每个本地 Postgres 服务器(后端 pid)分析每个租户(或租户的连接池)工作负载的基线资源消耗。从这里,我们可以将“基线”流量特征与每个数据库实例的系统级资源消耗相关联。

5、网关查询排队
用户查询可以优先提交到网关层的 Postgres (PgBouncer)。在一个或多个全局优先级队列中,所有租户的查询提交根据租户连接池或租户本身的当前资源消耗进行排序。或者,排序可以基于每个查询的历史资源消耗,其中每个查询都是独立分析的。根据从每个 Postgres 实例的服务器捕获的租户资源消耗的变化,每次调度程序转发要提交的查询时,所有排队的查询都可以重新排序。

为了防止优先级队列饥饿(一个租户的查询位于队列末尾并且永远不会执行),可以将网关级查询队列配置为仅在 Postgres 实例出现峰值负载/流量时启用。或者,可以将查询入队的时间考虑到优先级排序中。

这种方法将通过允许非违规租户继续保留连接并执行查询(例如关键运行状况监控查询)来隔离租户性能。只有使用更多资源(来自许多/昂贵的交易)的租户才会观察到更高的延迟。这种方法很容易理解,在应用程序中通用(可以根据其他输入指标对事务进行排队),并且是非破坏性的,因为它不会终止客户端/服务器连接,并且只应在内存中优先级队列达到容量时丢弃查询。

结论
我们的多租户存储环境中的性能隔离仍然是一个非常有趣的挑战,涉及操作系统资源管理、数据库内部结构、排队论、拥塞算法甚至统计等领域。