起着但不对劲 — 先看什么
一句话总结
故障响应中昂贵的不是修复,而是辨别真正原因在哪里。数据库通常不会直接死亡,而是保持运行却行为异常;症状出现的位置与根因所在的位置几乎总不相同。
为什么需要它
两个订单 API 不再响应。既没有错误,也没有超时,只是迟迟不返回。
第一步通常是查看慢查询,但列表却为空。CPU 很空闲,磁盘没有活动,连接数也和平时相同,所有指标都是绿色。如果因此断定“数据库没问题”并开始排查应用,一整天都可能浪费掉。
指标之所以全绿,是因为没有人在工作,所有会话都在等待。等待中的会话不消耗 CPU、不访问磁盘,也不会进入慢查询列表;而阻塞它们的会话甚至没有在执行查询。
它如何工作
pg_stat_activity 这一个视图几乎包含全部所需信息,并且有固定阅读顺序。
| 列 | 含义 |
|---|---|
state |
active 表示正在执行查询,idle 表示在事务外空闲,idle in transaction 表示事务保持开启但会话空闲 |
wait_event_type · wait_event |
正在等待什么。Lock 表示等待锁,Client · ClientRead 表示客户端没有发送下一条命令 |
xact_start |
事务开始时间。仅仅“已经持续很久”这一事实,就能解释大多数事故 |
pg_blocking_pids(pid) |
阻塞该会话的 pid 列表 |
第三种 state 最关键。idle in transaction 与 ClientRead 同时出现,表示会话正在等待一个发起工作后消失的客户端。从服务器角度看没有任何错误,因此不会触发告警。
只沿阻塞链走一层,会找到受害者
下面是本课程实验环境中的真实截图。
pid | application_name | state | wait_event_type | wait_event | blocked_by
------+------------------+---------------------+-----------------+---------------+-----------
784 | nightly-batch | idle in transaction | Client | ClientRead | {}
796 | api-cart | active | Lock | transactionid | {784}
795 | api-order | active | Lock | tuple | {796}
api-order 被阻塞,直接阻塞它的是 796;但 796 自己也被阻塞。即使终止 796,795 也只会继续等待下一位,真正根因 784 仍然存在。
所以不应只问“谁阻塞了我”,而要问:“阻塞链上谁没有被任何人阻塞?”
select distinct b as root_pid
from pg_stat_activity a, unnest(pg_blocking_pids(a.pid)) b
where cardinality(pg_blocking_pids(b)) = 0;
正在阻塞别人、自己却未被阻塞的 pid,就是链的终点。上例只会返回 784。
wait_event 也不能忽略。transactionid 表示等待前一个事务结束,tuple 表示在争用同一行的队列中等待前一位。两者同时存在,说明队列已经叠了两层。
连接去了哪里
每个等待会话都会占住一条连接,故障正是这样扩散。
lock_timeout 默认值为 0,即无限等待。设置限制后会这样结束:
ERROR: 55P03: canceling statement due to lock timeout
一秒后返回错误,连接立即归还连接池。没有限制时,连接会永久占用;同一行上的请求越多,被占用的连接就越多。连接池耗尽后,连与该行毫无关系的请求也无法获取连接而失败。
这就是症状表现为“服务器不响应”而不是“数据库变慢”的原因,也因此很容易被误判为应用问题。
提高 max_connections 不是第一步。问题不是连接数量不足,而是连接没有归还;提高上限只会增加可被占用的连接数量。每个 backend 都对应一个进程,内存与上下文切换成本会如实增长。应先找出连接为何没有归还。
实际现场中的表现
**第一,在监控指标中加入“最老事务的年龄”。**只看慢查询的监控永远发现不了 idle in transaction,因为它没有执行查询。一条语句即可:
select max(now() - xact_start) as oldest
from pg_stat_activity where state <> 'idle';
仅这个值,就能提前提示本课程所讨论事故的一半。
**第二,把限制设置在连接上,而不是散落在代码中。**为每条查询执行 set lock_timeout 迟早会遗漏。让连接池在借出连接时设置会话默认值,就不会有遗漏位置。lock_timeout 限制“等待”,idle_in_transaction_session_timeout 限制“事务开启后空闲”,二者防止不同事故。在实验环境中把后者设为三秒后,空闲事务会话被直接断开并消失。
第三,迁移必须设置 lock_timeout。alter table 需要 ACCESS EXCLUSIVE。在同一环境中占住该锁后执行 select count(*),连读取也被阻塞。
ERROR: canceling statement due to lock timeout
LINE 1: select count(*) from products
只要前方有一个长查询,迁移就会进入等待队列,而迁移之后的所有查询都会排在它后面,包括读取。设置限制只会让部署失败;不设置则会让服务停止。失败的部署比停止的服务便宜。
下一项实验将做什么
我们会阅读真实形态的 pg_stat_activity 快照,区分被阻塞者与阻塞者,并用数字确认如何寻找阻塞链终点、为何 idle in transaction 不会被常规监控发现,以及提高 max_connections 为何不是第一步。