LabHub
学习 学习路径 课程

PostgreSQL 故障处理

起着但不对劲 — 先看什么

在 LabHub 中继续学习

一句话总结

故障响应中昂贵的不是修复,而是辨别真正原因在哪里。数据库通常不会直接死亡,而是保持运行却行为异常;症状出现的位置与根因所在的位置几乎总不相同。

概念图: 辨别真正原因在哪里 · 为空 · 没有人在工作 · 等待一个发起工作后消失的客户端

为什么需要它

两个订单 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 transactionClientRead 同时出现,表示会话正在等待一个发起工作后消失的客户端。从服务器角度看没有任何错误,因此不会触发告警。

只沿阻塞链走一层,会找到受害者

下面是本课程实验环境中的真实截图。

 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_timeoutalter 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 为何不是第一步。