- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 池明明调大了,为什么反而更慢
- 连接为什么是昂贵的资源
- 池越大越慢的原理 — 把队列排在数据库之外
- 常被引用的公式的依据与局限
- 实例数乘以池大小 — 微服务的典型事故
- PgBouncer 的三种模式,以及事务模式下用不了的东西
- 连接泄漏的诊断与无服务器环境的特殊性
- 结语 — 靠测量而不是公式,以及队列的位置
引言 — 池明明调大了,为什么反而更慢
压测中响应时间开始抖动,日志里出现了获取连接超时。自然的应对是把池调大:从 20 到 50,还不行就到 100。
可是吞吐量原地不动甚至下滑,p99 延迟反而更差。数据库 CPU 使用率贴在 100 个百分点上,每秒处理的请求数却没有增加。
这个现象不是调优失败,而是注定的结果。因为连接池不是提升性能的装置,而是限制并发的装置。把池调大就是把限制放松,而限制一放松,队列就从连接池搬进了数据库内部。变的只是队列的位置,工作总量没变,但数据库内部的队列要贵得多。
本文解释其中的道理,并梳理确定池大小的实际步骤、常被引用的公式的局限,以及在 PgBouncer 和无服务器环境下会发生哪些变化。
连接为什么是昂贵的资源
PostgreSQL 为每个连接创建一个操作系统进程。不是线程,是进程。这个设计在稳定性上有优势,但代价同样明确。
-- 确认一个连接是否真的对应一个进程
SELECT pid, backend_type, application_name, state
FROM pg_stat_activity
WHERE backend_type = 'client backend'
LIMIT 5;
# 同一个 pid 就实实在在地出现在 OS 进程列表里
ps -o pid,rss,command -p 24188
# PID RSS COMMAND
# 24188 11284 postgres: api shop 10.0.3.21(52144) idle
成本分为三个层次。
第一是创建成本。每建立一个新连接都要经历 fork、认证和目录缓存初始化。即使在本地也要几毫秒,算上网络和 TLS 握手就是几十毫秒。这是使用连接池的第一个理由。
第二是内存。即便是空闲的后端进程,也会因为目录缓存和执行计划缓存占掉几兆字节,而那些活得久、处理过各类查询的后端会大得多。在此之上还要乘以 work_mem。work_mem 不是按连接分配,而是按查询内的每一次排序或哈希运算分配的,所以 200 个连接、work_mem 为 64MB、每条查询两次排序的话,理论上可以吃到 25GB。
第三是与连接数成正比的内部成本。构建快照时要遍历正在运行的事务列表,锁管理的数据结构也会变大。PostgreSQL 14 大幅改进了获取快照的路径,空闲连接的负担减轻了不少,但随活跃连接数增长的成本依旧存在。再加上操作系统的上下文切换。当可运行的进程数远超核数时,CPU 就会把时间花在切换而不是干活上。
# 观察上下文切换是否在飙升
vmstat 1 5
# procs -----------memory---------- ---system-- ------cpu-----
# r b swpd free buff cache in cs us sy id wa st
# 68 0 0 2104928 88420 9821004 42118 318442 71 26 3 0 0
如果 r 列远超核数、cs 达到几十万,那么数据库正处在做调度而不是做工作的状态。
池越大越慢的原理 — 把队列排在数据库之外
原理用一条排队论就能解释。按照利特尔法则,平均并发处理数等于吞吐量乘以平均响应时间。
并发处理数(L) = 吞吐量(X) × 响应时间(W)
数据库的物理处理能力由核数和磁盘带宽决定。到达这个上限之后再增加并发请求,吞吐量 X 不会再涨,取而代之的是响应时间 W 成比例地拉长。也就是说,做的还是同样多的活,只有所有请求的延迟在变大。
在此之上,并发一旦升高,吞吐量实际上会开始下降。
- 上下文切换蚕食有效的 CPU 时间。
- 围绕同一批行和索引页的锁竞争加剧。竞争的增长接近并发度的平方。
- 争抢缓冲区缓存,导致缓存命中率下降。
- 死锁与直列化失败增多,再叠加上重试带来的负载。
所以队列一定会在某处形成,问题在于位置。在连接池里等待不消耗任何资源、按顺序处理,而且等待时间会作为指标暴露出来。在数据库内部等待,则是握着进程、内存和锁在等,并且互相拖慢。
用一句话概括核心就是:池大小应该是数据库能同时处理好的请求数,而不是应用想发出去的请求数。
实际的观察方法很简单。固定池大小、逐步加压,同时盯住两个指标。
-- 数据库实际上正在并发地做什么
SELECT state, wait_event_type, count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1, 2
ORDER BY 3 DESC;
state | wait_event_type | count
--------+-----------------+-------
active | LWLock | 41
active | | 8
active | Lock | 22
idle | Client | 64
如果状态是 active 却在等 LWLock 或 Lock 的后端,远多于真正在干活的后端,那说明池已经太大了。这时再把池调大,这个比例只会更难看。
常被引用的公式的依据与局限
被引用最广的式子是这个。
池大小 = (核数 × 2) + 有效磁盘轴(spindle)数
依据很明确。核数那一份是真正在做计算的,取两倍则是留出余量,好让某个核因磁盘或网络等待而闲下来时,别的请求能补上。最后一项反映的是并行磁盘读写能力。8 核服务器算出来大约在 20 附近。
这个式子传达的信息不是数字本身,而是数量级。答案是几十而不是几百。如果你给一台 8 核数据库挂了 200 个连接的池,那个配置几乎肯定是错的。
不过有几种情况下不能照搬公式。
第一,应用与数据库之间的往返延迟。在同一个可用区里大约是 0.2ms,跨区就会超过 1ms。一个事务里有十条语句,光往返就是 10ms,而这段时间连接一直被占着,数据库却在闲着。这类工作负载需要比公式更大的池。更准确地说,不该是把池调大,而是该减少往返次数。
第二,事务内部应用在做什么。如果外部 API 调用或文件处理夹在事务中间,连接占用时间就会暴涨。这种情况下所需的池大小与公式无关,解法也不是调整池大小。
第三,工作负载混杂。OLTP 和分析查询共用一个池时,几条重查询就会把整个池堵死。这时的答案不是调整大小,而是拆分连接池。
# 按用途拆分连接池
pools:
web: { size: 12, timeout: 3s } # 用户响应路径
batch: { size: 4, timeout: 60s } # 夜间批处理
readonly: { size: 8, timeout: 10s } # 只读副本查询
所以实际步骤必须以公式开头、以测量收尾。
- 用公式定初始值。8 核的话大约是 20。
- 施加目标负载,记录连接池等待时间的 p99 和整体吞吐量。
- 试着把池大小减半。如果吞吐量保持不变,说明原来的值太大了。
- 逐步调大,直到找到吞吐量不再增长的那个点。比吞吐量开始走平的值再小一点,就是合适的大小。
- 如果在那个值上连接池等待时间依然很大,那要修的是查询而不是池。
第 5 条尤其重要。连接等待时间长,说明连接占用时间长,而那通常意味着慢查询或长事务。池大小只是把这个症状暂时遮住而已。
实例数乘以池大小 — 微服务的典型事故
在单体应用里调得很好的配置,会随着服务数量增加而悄悄崩塌。
服务 A: 实例 12 个 × 池 20 = 240
服务 B: 实例 8 个 × 池 15 = 120
服务 C: 实例 6 个 × 池 10 = 60
批处理工作进程: 4 个 × 池 5 = 20
合计 = 440
max_connections = 200
每个服务的配置看起来都很合理。可加起来却超过了最大连接数的两倍。平时并非所有池都会占满,问题一直藏着;而当流量涌上来、或者滚动发布让实例数临时翻倍的那一刻,它就炸了。
FATAL: sorry, too many clients already
FATAL: remaining connection slots are reserved for non-replication superuser connections
更糟糕的是,这个错误连健康检查和监控代理也一并挡住了。故障发生时,观测手段跟着一起消失。
必须按预算来管理。有些项目在计算时很容易被漏掉。
SHOW max_connections; -- 200
SHOW superuser_reserved_connections; -- 3
SHOW max_wal_senders; -- 10 (副本用,与 max_connections 分开计算)
-- 现在谁用了多少
SELECT application_name,
count(*) AS total,
count(*) FILTER (WHERE state = 'active') AS active,
count(*) FILTER (WHERE state = 'idle') AS idle,
count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_tx
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1
ORDER BY 2 DESC;
application_name | total | active | idle | idle_in_tx
------------------+-------+--------+------+------------
order-service | 88 | 6 | 79 | 3
user-service | 42 | 3 | 39 | 0
datadog-agent | 6 | 1 | 5 | 0
flyway | 2 | 0 | 2 | 0
这份输出就是典型状态。握着 88 个连接,真正在干活的只有 6 个。剩下的 79 个只占着内存和进程槽位。
预算分配的原则是这样的。
- 优先分配给用户路径上的服务,批处理和管理工具按最小量给。
- 考虑到滚动发布期间实例数会临时增加,要留出余量。计算必须以最大实例数为基准。
- 给迁移工具、监控代理和管理员接入留几个。
- 按服务拆分数据库用户,并按用户设置上限。
-- 按用户设置连接上限。从结构上防止某一个服务把全部连接吃光。
ALTER ROLE batch_worker CONNECTION LIMIT 10;
ALTER ROLE order_service CONNECTION LIMIT 60;
如果池大小已经调小了但服务数量还在持续增加,那就到了引入连接池代理的时候。
PgBouncer 的三种模式,以及事务模式下用不了的东西
PgBouncer 站在应用与 PostgreSQL 之间,把大量客户端连接多路复用到少量服务端连接上。它有三种模式,区别在于何时回收服务端连接。
| 模式 | 服务端连接归还的时机 | 多路复用效率 | 无法使用的功能 |
|---|---|---|---|
| session | 客户端连接关闭 | 低 | 没有。只节省了连接创建成本 |
| transaction | 事务结束 | 高 | 会话变量、会话级 advisory lock、LISTEN 与 NOTIFY、WITH HOLD 游标、临时表 |
| statement | 语句结束 | 最高 | 由多条语句组成的事务本身 |
实务中真正有意义的选项其实只有 transaction 模式。它能用 20 个服务端连接接住 1000 个客户端。
[databases]
shop = host=10.0.1.10 port=5432 dbname=shop
[pgbouncer]
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 20
reserve_pool_size = 5
reserve_pool_timeout = 3
server_idle_timeout = 60
max_prepared_statements = 200
作为代价必须放弃的东西也很明确。事务一结束,那条服务端连接就会被别的客户端使用,所以一切跨越事务边界保持的会话状态都会被打断。
第一是会话变量。在事务外执行 SET search_path 或 SET timezone,到下一个请求时就不会留存。如果多租户是用 search_path 实现的,那么在事务模式下有可能悄悄读到另一个租户的模式。
-- 事务模式下安全的做法
BEGIN;
SET LOCAL search_path = tenant_42, public;
SELECT ...;
COMMIT;
SET LOCAL 会在事务结束时自动还原,所以是安全的。用于行级安全的 SET LOCAL app.current_user_id 之类的写法也是同理。
第二是 advisory lock。pg_advisory_lock 是会话级的,事务结束后并不会释放,而一旦那条连接被交给别的客户端,它就永远解不开了。
-- 危险: 会话级的锁
SELECT pg_advisory_lock(12345);
-- 安全: 事务结束时自动释放
SELECT pg_advisory_xact_lock(12345);
第三是 prepared statement。这曾长期是事务模式最大的限制,像 JDBC 或 asyncpg 这类默认使用预备语句的驱动都会报错。从 PgBouncer 1.21 开始,max_prepared_statements 设置支持协议级的 prepared statement,所以在较新的版本上这个限制已经消失了。如果还在用旧版本,就得在驱动侧关掉它。
# JDBC
prepareThreshold=0
# asyncpg
statement_cache_size=0
第四是 LISTEN 与 NOTIFY。订阅是绑定在会话上的,所以在事务模式下不起作用。想用通知功能,就得为这个用途单独准备一个 session 模式的池,或者绕过 PgBouncer。
还有一点。引入了 PgBouncer 并不意味着可以去掉应用侧的连接池。常见的做法是两层并用:应用侧的池开得小一些,由 PgBouncer 负责多路复用。
连接泄漏的诊断与无服务器环境的特殊性
连接泄漏有两种表现形式。
第一种是没有归还的连接。原因是异常路径上漏掉了 close 的代码,表现为连接池使用量随时间单调上升的曲线。
第二种更危险,就是 idle in transaction。这是应用开着事务却跑去做别的事的状态。它不止占着一条连接,更因为那个事务的快照,导致 VACUUM 无法清理死元组。表持续膨胀,整体性能缓慢恶化。
-- 找出陈旧的 idle in transaction
SELECT pid,
application_name,
state,
now() - xact_start AS tx_age,
now() - state_change AS idle_age,
left(query, 60) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - state_change > interval '30 seconds'
ORDER BY xact_start;
pid | application_name | state | tx_age | idle_age | last_query
-------+------------------+---------------------+--------------+--------------+-------------------------------
24193 | order-service | idle in transaction | 00:18:44.221 | 00:18:42.008 | SELECT * FROM orders WHERE ...
已经开了 18 分钟。last_query 指向了出问题的代码,所以从这里就能直接追下去。然后再挂上一张安全网。
ALTER SYSTEM SET idle_in_transaction_session_timeout = '30s';
ALTER SYSTEM SET idle_session_timeout = '10min'; -- PostgreSQL 14 以上
SELECT pg_reload_conf();
idle_session_timeout 在连接池代理后面要谨慎使用。如果服务器切断了连接池想要保持的空闲连接,连接池就会遇到意料之外的断开,所以这个值必须设得比连接池的空闲校验周期更长。
还应该一并观察最老的事务把 VACUUM 拦住了多久。
SELECT max(age(backend_xmin)) AS oldest_xmin_age
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL;
无服务器环境在此之上还多一个结构性问题。函数实例是随请求创建又销毁的,所以「每个实例维护自己的连接池」这一模型根本不成立。并发执行的实例有 500 个,连接请求就是 500 个。而且实例终止时并不保证会清理连接,服务端会残留幽灵连接。
应对办法有三个。
- 把外部连接池代理设为必选。PgBouncer、RDS Proxy 这类托管代理负责多路复用。在无服务器场景下这不是可选项。
- 在函数内部把池大小设为 1。既然一个实例同时只处理一个请求,一条连接就够了。
- 在冷启动之外建立连接,并把客户端放在处理函数之外的作用域里,以便实例复用时保持连接。
也可以选择使用基于 HTTP 的驱动。它不维护 TCP 连接和会话,而是每个请求都用 HTTP 发送查询,从根本上消除了连接管理问题。代价是事务和会话功能会受到限制。
结语 — 靠测量而不是公式,以及队列的位置
需要记住的有三件事。
第一,连接池的目的是限制并发,而不是提升并发。一旦发出去的请求超过数据库能同时处理好的数量,吞吐量不会增长,只有延迟在变大。队列反正都会形成,那么把它排在不占任何资源就能等待的连接池那一侧,永远是更好的选择。
第二,基于核数的公式是起点而不是答案。这个式子真正告诉你的信息是:答案的数量级是几十而不是几百。实际的值必须在目标负载下改变池大小、测量吞吐量与等待时间之后才能确定。而如果等待时间很长,通常不是池太小,而是事务太长。
第三,请务必计算实例数乘以池大小。即便每个服务的配置都合理,一旦总和超过 max_connections,发布过程中就会出故障。用按用户的 CONNECTION LIMIT 设上上限;如果服务还在不断增加,就引入 PgBouncer 的 transaction 模式,但要先把依赖会话状态的代码清理干净。