42 检查清单
检查清单,就是去掉论证的这本书:每个习惯一行,都链接到说明理由的小节。各节遵循实战手册的顺序,先用成本最低的办法(顺序就是方法)。找出昂贵的工作,修好计划、索引和模式,做好日常维护、连接和配置,之后才考虑换更大的机器。
从上往下做。只有测量证明某项没问题,才跳过它;不能因为后面的项目看起来更容易就先跳过去。最后一节留给数据库已经很慢的那一天。
动手修之前,先找出昂贵的工作
你想到的那条慢查询,很少就是成本最高的那条。先测量,每次改动后继续测量。
- 安装
pg_stat_statements并开启track_io_timing = on(设置方法),在繁忙时段取两份快照,比较差值(快照与差值)。 - 按总耗时和每次调用访问的共享块数给语句排名(四种排名)。
- 在造成问题的负载下采样等待事件,说清楚受限的资源是什么(等待采样)。
- 改动前先写一句话:瓶颈是 X,因为 Y 显示 Z(找出昂贵的工作)。
- 每次只改一件事,在具有生产数据特征的数据上,用相同负载测量改动前后的结果(端到端测量)。
- 每次修复都记录前后每次调用访问的页数;从 35,249 到 91,就是一条好记录(第一个索引)。
审查的是计划,不是代码
一条看似无害的 ORM 调用,可能读取整张表。每条随请求执行的查询,上线前都跑一次 explain (analyze, buffers)。
- 在具有生产数据特征的数据上运行计划;写入语句用
begin和rollback包起来(EXPLAIN 负责猜测;EXPLAIN ANALYZE 负责运行)。 - 先读
Buffers行,比较访问的页数与返回的行数(缓冲区才是证据)。 - 评价一个节点前,先把
actual time和rows乘以它的loops:70 ms 乘以loops=20,就是 1.4 秒(乘上循环次数;缓冲区已经是总数)。 - 自下而上比较估算行数与实际行数;找到最底层偏差达到十倍或以上的节点,先修它的估算,再管上面的节点;相关列用扩展统计信息(扩展统计信息)。
- 索引列保持裸列:不套函数或类型转换,参数采用该列的类型(列上的函数、隐式类型转换)。
- 时间范围写成半开区间(日期范围)。
- 用匹配索引的键集分页,绝不做很深的
OFFSET(OFFSET 会读取它跳过的每一行)。 - 把 N+1 循环换成连接、一个
= any($1)数组,或lateral(N+1 是一个循环,不是慢查询)。 - 在代码里搜索
not in (select,逐个改成not exists,这样子查询返回 NULL 时也正确(NOT IN 是陷阱;使用 NOT EXISTS)。 - 不要每个请求都对大表做
count(*):用估算、设上限,或读取汇总表(精确计数)。 - 若跨两个连接表的
or让计划任意一侧出现顺序扫描,就改写为两个走索引的查询的union(跨连接使用 OR 会击败两个索引)。 - 用 CTE 改善可读性,只有明确需要时才加
materialized(只使用一次的 CTE 会被内联,而不是设栅栏)。 - 应用的插入或更新用
on conflict,批处理作业用merge,重试写入用幂等键(幂等键)。 - 读—改—写的算术放在 SQL 中做,不要在应用里算(读已提交)。
- 按批提交:在线表每几千至几十万行一批,绝不逐行提交;大批数据用
COPY加载(每批提交一次)。 - 只选择要展示的列;大值放在 TOAST 中(大值会先压缩,再移出行外)。
- 只有逻辑能缩减数据量、保持规则的原子性或省下一次往返,才留在主库;格式化放在应用里(我们的立场)。
- 预备查询在调用五次后变慢,就怀疑通用计划(预备语句)。
- 索引扫描丢弃了大部分读到的数据,其上的
Limit就值得怀疑;为过滤条件建索引(Limit 押注于尽早找到行)。 - 绝不让事务跨着网络调用或用户思考时间保持打开(长事务)。
吃过一次亏才会记住的边界
下面这些语句都能运行,返回看似合理的结果,或规规矩矩地报错,却往往让团队吃过一次亏才记住。在审查时抓住它们,让意外发生在测试里。
能运行,却不正确的 SQL
- 要的是实际时钟时间,就用
clock_timestamp();now()是事务开始的时间(时间戳)。 - 整数相除前,把一侧转换为
numeric;7/2是 3(除法、|| 与 NULL 排序)。 - 拼接可能含 NULL 的字符串,用
concat_ws()或coalesce(),不要直接用||(同节)。 - 可空列按最新在前排序时,加上
nulls last,服务这个查询的索引也要加(同节)。 - 按所有选出的列或主键分组;任意值都行时,明确写
any_value()(其他小差异)。 - 按键选取批次来更新;没有
UPDATE ... LIMIT(回填)。 - 跳过没有任何变化的更新;空操作仍会写入一个行版本及其 WAL(WAL 从哪里来)。
事务
- 记住:一个错误会中止整个事务;用
on conflict避免它,或用保存点限制影响(一个错误就会中止)。 - 不要在循环里放保存点、异常块和 ORM 嵌套事务;一个事务里超过 64 个,所有会话都要付出代价(子事务)。
- 只对
40001和40P01重试整个事务,使用有上限、带抖动的退避;其他错误不重试(重试)。 - 锁父行用
for no key update,除非要删除它或修改它的键;for update会阻塞子行插入(行锁)。 -
truncate只留给暂存表和测试;旧快照会看到空表(删除只给行盖章)。
索引要对得起写入成本,否则就删掉
每个索引都是一场押注:换来某些查询更快的读取,但只要它还在,每次写入就得交税。每个索引都要说清自己服务的是哪条查询。
- 从查询出发设计:等值条件列在前,接着一个范围列或排序列,并说清服务的是哪条查询(逐步设计)。
- 只有少数取值的列,不要单独建索引(为什么 2% 的行会读到 68% 的表)。
- 对热点子集用部分索引,把谓词写成字面量(部分索引)。
- 查询必须用某个表达式时,就建表达式索引,例如
lower(email)(表达式索引)。 - 日期表达式固定时区,例如
((created_at at time zone 'UTC')::date);绝不为通过检查就把函数标为immutable(为什么 created_at::date 不能建索引)。 - 在语言学排序规则下,
like 'abc%'用text_pattern_ops索引,'%abc%'用三字符组(前缀搜索)。 - 用
include覆盖热点查询,不求覆盖所有查询(覆盖索引)。 - 每个
Index Only Scan都要比较Heap Fetches与返回行数;若接近行数,说明清理没有让可见性映射保持最新(仅索引扫描)。 - 按运算符选择索引类型:GIN、三字符组、GiST、BRIN 或哈希(选择索引类型)。
- 包含查询用
jsonb_path_ops;没有哪种 GIN 能服务payload->>'key' = 'x'(jsonb 包含查询)。 - 找出没有索引的外键,为它们建索引(查询语句)。
- 新增索引前先算写入税:每次插入都会访问每个索引,它还可能让更新变成非 HOT 更新(写入税)。
- 长时间窗口内
idx_scan = 0的索引,在每个副本上都检查过后就删除,支撑约束的除外;重复索引也删除(未使用与重复索引)。 - 在大表上动工前先问 HypoPG(假设性问题)。
- 用
reindex concurrently重建膨胀索引(测量膨胀)。 - 普通
create index只用于新表、空表或没有写入的表;所有有写入的表都用concurrently(判断规则)。 - 并发构建后,检查是否留下
INVALID索引;重试前先删除它(构建失败时)。 - 每次升级操作系统、glibc 或 ICU 后,重建文本索引,然后才能刷新记录的排序规则版本(排序规则、发现问题)。
添搜索引擎之前,先在 PostgreSQL 内搜索
全文搜索和向量搜索也是索引,有各自的成本。像测量其他查询那样,测量召回率和页数。
- 每次调用都传入文本搜索配置,例如
'english';按一种配置建的索引不能服务另一种配置(配置)。 - 把
tsvector存在生成列里,建 GIN 索引;通过表达式索引做排名耗时 170 ms,而前者只需 4.6 ms(存储 tsvector)。 - 用户键入的任何内容都用
websearch_to_tsquery解析(用户输入)。 - 排名前先按租户或日期缩小范围,只对返回的行运行
ts_headline(摘要片段)。 - 信任近似向量索引之前,先用自己的查询,对照精确搜索测量 recall@10(精确搜索)。
- HNSW 的
hnsw.ef_search保持在 40 或以上(我们测试中的召回率为 0.97);漏召代价高的查询,单独提高它(ef_search 用延迟换召回率)。 - IVFFlat 的
ivfflat.probes绝不能停在 1:探测一次时 recall@10 是 0.636,五次时是 1.000(IVFFlat 的默认一次探测是陷阱)。 - 向量搜索有过滤条件时,开启
hnsw.iterative_scan,或让 B-树在高选择性过滤下完成精确搜索(过滤条件)。 - 用倒数排名融合合并关键词与向量结果,不要把分数直接相加(混合搜索)。
第一行插入前,先审查模式
只要表还在,模式就决定着每一行的成本。事后修改它是一场模式变更,不是发个版就行。
类型
- 每个 ID 及引用它的列都用
bigint(ID 使用 bigint)。 - 每个时刻都用
timestamptz,会话采用 UTC(始终用 timestamptz)。 - 用
text加检查约束,不用varchar(n),更不用char(n)(就用 text)。 - 金额用
numeric或以分为单位的bigint存储,旁边放货币列,绝不用浮点数(金额)。 - 用
archived_at代替is_archived;将来可能改变的枚举,改用text加检查约束(小型取值集合)。 - 每列选定一种不区分大小写的策略,并在模式中强制执行(选定一种)。
- 新数据库默认采用内置的
C.UTF-8排序规则,只有人要看排序结果的地方才选语言学排序规则(三种提供者)。 - 标识符绝不加引号:用不带引号的
snake_case,第一次模式变更前就设置好 ORM 的命名策略(其他小差异)。 - 用标识列,不用
serial;避开money、char(n)和timetz(应避开的类型)。
键与公开 ID
- 每张表都给一个
bigint generated always as identity代理键;自然键用unique(自然键会变)。 - 绝不用序列生成发票号码或构建变更流:序列会有空缺,也不按提交排序(空缺)。
- 需要 UUID 时,用
uuidv7(),存为uuid;随机 v4 键产生的 WAL 是其 3.8 倍(随机键)。 - 客户端生成 ID 时,保留服务端的
created_at,校验每个 ID;发生冲突,用do nothing再在调用者账户范围内查询,绝不用do update(客户端的 UUIDv7 是输入,不是时钟)。 - 绝不对外暴露计数器:另设公开 ID,存为
uuid,输出时加前缀(存储 uuid,打印前缀)。 - 每张租户表都带
account_id,键也以它开头(带上租户)。 - 用复合外键,让引用无法跨越租户(让跨租户引用不可能发生)。
有意选择约束
- 每个事实只存一次,每个有意保留的副本都恰好指定一个写入者(一个写入者)。
- 账户、用户、项目、成员关系和钱款保留外键(我们保留外键的地方)。
- 事件、日志、指标、审计记录、跨边界引用和导入,删除外键或声明为
not enforced(同节)。 - 每条停止强制检查的引用,都安排孤立记录检查(检查你不再强制执行的规则)。
- 每个强制执行的外键,都在引用方建索引(引发扫描的删除)。
- 级联删除只用于数量少的依赖行(谨慎使用级联)。
- 除非“未知”确实是一种状态,否则列都设为
not null,其余规则用check表达(检查约束)。 - 不要信任可空列上的
unique,因为它允许任意多个 NULL:把列设为not null,或写unique nulls not distinct(NULL 默认互不相同)。 - “不允许重叠”用排他约束,不用应用代码(排他约束)。
- 采用软删除的表,配上部分唯一索引(软删除)。
- 把多态
type/id对换成真正的外键列(多态关联)。 - 跨行规则用约束、可串行化隔离或父行锁来保证,绝不用读取其他行的检查约束(约束看不见的规则)。
布局与 HOT
- 定长列按 8 字节到 1 字节的顺序排列,变长列放最后(列顺序就是空闲空间)。
- 更新密集的表设
fillfactor = 90;仅追加写入的表保持 100(留出空间)。 - 不要为
updated_at或大多数更新都会修改的列建索引(不要给 updated_at 建索引)。 - 不停变化的计数器移到窄表里(热点计数器)。
- 监控更新密集表的 HOT 比例,下降时查清原因(测量 HOT)。
jsonb 还是列
- 文档用
jsonb,绝不用json(jsonb)。 - 任何用于过滤、连接、排序或约束的字段,都提升为有明确类型的列;规划器看不到
jsonb内部(何时提升为列)。 - 绝不建实体—属性—值表(一个陷阱)。
函数与触发器
- 只有一条查询的函数,用 SQL 编写,标为
stable或immutable,让规划器能将其内联(内联)。 - 易变性标注在符合事实的前提下尽可能严格,绝不能比事实更严格(易变性)。
- 给要跑批处理作业的表加触发器前,先按每行约一微秒估算行触发器成本,审计触发器则约两微秒(触发器成本)。
- 绝不通过子表触发器维护父表计数器;这会让所有写入者在同一个父行上串行排队(计数器触发器)。
- 只有工作必须与写入保持原子性,且对每个写入者都必须执行时,才添加触发器(证明它值得存在)。
- 开启
track_functions,让函数出现在pg_stat_user_functions中;设置pg_stat_statements.track = all,让函数内部语句也有自己的条目(跟踪)。
每次模式变更,都检查它在等什么锁
模式变更不会让整个站点排在它后面等,才算安全。ALTER 本身很少慢;等待它的锁才会造成故障。
- 每个模式变更会话都把
lock_timeout设为 1 至 5 秒,配合重试,并设置较宽裕的statement_timeout(第一条规则)。 - 绝不让 DDL 在锁队列里等待:一个等待中的
ALTER会阻塞该表上后来的所有查询,包括读取(锁队列)。 - 开始前检查长事务和事务中空闲的会话(长事务)。
- 执行前,给每一步标明:只改系统目录、扫描,还是重写(元数据、扫描或重写)。
- 添加列时用常量默认值;大表绝不一步加入带易变默认值的列(默认值)。
- 添加
check约束时用not valid,再在另一个事务中validate(检查约束)。 - 外键先
not valid,再validate;两步都会锁住子表和父表(外键)。 - 通过已验证的检查约束实现
not null,或在 PostgreSQL 18 上用not null ... not valid(先通过 CHECK,再设置 NOT NULL)。 - 根据表的访问流量选择索引构建方式(选择构建方式)。
- 并发构建在事务块外单独运行,关闭
lock_timeout,然后检查indisvalid(四条规则)。 - 用并发构建的唯一索引建立唯一约束和主键(唯一约束)。
- 在线表绝不运行普通
reindex(用 REINDEX CONCURRENTLY 重建;普通 REINDEX 也会阻塞读取)。 - 除扩宽以外,类型变更都按重写来对待;
int转bigint按在线替换规划(int 转 bigint 的问题)。 - 重命名按先扩展、后收缩的方式跨多个版本发布(先扩展,后收缩)。
- 回填按主键范围分批,在模式变更之外执行,每批一个事务(回填)。
- 先删除代码,再删除列(先删代码)。
- 每个模式变更事务都保持简短;DDL 的所有锁都持有到提交(事务性 DDL)。
- 挂接分区时带上已经验证的
check,分离时用concurrently(改造在线表)。 - 在与生产规模相当的克隆上演练(复制生产环境)。
批量加载与分区
- 加载用
COPY,或用unnest数组(如果必须insert);绝不每行执行一次自动提交的插入(COPY 是批量加载路径)。 - 先加载到没有索引和外键的表,再建索引,以
not valid加上validate添加外键:我们的加载实验快了六倍,WAL 减半(加载之后)。 - 对同一事务内创建或截断的表用
copy ... freeze,让首次读取不必写入(COPY FREEZE 会写入已完成的行)。 - 大批加载前提高
max_wal_size;结束后,在业务访问表之前,执行vacuum (freeze, analyze),至少也要执行analyze(开放访问之前)。 - 从去重后的暂存表出发,用一条语句批量插入或更新(批量插入或更新)。
- 按删除数据所用的键分区,通常是时间;分区数量保持在几十到小几百(选择键)。
- 对分区表的每条热点查询运行
explain,确认发生剪枝:只出现匹配的分区,或Subplans Removed大于零(剪枝)。 - 提前创建未来的分区,剩余数量不足时告警(提前创建)。
- 加载和按保留期限清理后,自己对分区父表做分析;自动清理从不做这件事(其他小陷阱)。
生产就绪检查
本书中的多数故障,都是悄悄积累几个月,再突然爆发。上线前检查这些项目,之后数据每增长十倍,再检查一次。
备份与恢复
- 先写下 RPO 和 RTO;副本不是备份(先写下 RPO 和 RTO)。
- 做物理基础备份,并持续归档 WAL,存到数据库主机无法删除的存储上(使用备份工具)。
- 在另一台服务器上演练时间点恢复,设置
recovery_target_action = 'pause'(恢复到时间点)。 - 自动执行恢复测试,记录真实 RTO(测试恢复)。
- 托管服务也要实际恢复一次并计时;它会创建新服务器(恢复时间)。
监控与告警
连接与超时
- 应用都要用连接池;进程很多或采用 serverless 时,再加外部连接池(两处都用连接池)。
- 按核心与磁盘设置连接池大小,不按用户数(连接池大小)。
-
max_connections保持在小几百,并给管理员预留连接槽位(max_connections)。 - 在应用角色上设置超时,从
statement_timeout = '5s'、lock_timeout = '3s'、idle_in_transaction_session_timeout = '30s'开始,PostgreSQL 17+ 再加transaction_timeout = '5min';模式变更和批处理作业使用独立角色,各设自己的限制(超时)。 - 应用要能在故障切换过程中重连并重试(故障切换就是 DNS)。
- 连接池改为事务模式前,替换会话级
set、会话级咨询锁、listen、SQLprepare和临时表(事务连接池)。 - 事务模式要支持预备语句,运行 PgBouncer 1.21 或更高版本;1.24 默认开启这项支持(新版 PgBouncer)。
- 用表构建队列,以
for update skip locked领取任务,与业务写入一起提交;listen/notify的使用保持克制(队列)。
配置与机器
- 调优前先算内存预算:先算
shared_buffers,再算work_mem乘以计划节点数、再乘以连接数(内存)。 - 按角色为报表提高
work_mem,绝不全局提高(work_mem)。 - SSD 上设置
random_page_cost = 1.1(代价模型)。 - 提高
max_wal_size,让检查点按时间触发,并开启wal_compression(错开检查点,并按 WAL 速率设置 max_wal_size)。 - 别让 OOM killer 杀掉 postmaster(OOM killer)。
- 绝不在
fsync = off或full_page_writes = off下运行(持久性)。
清理与回卷
- 大表逐表把缩放因子设为 0.01 至 0.05,并把代价上限提高到远高于 200(默认值不合适)。
- 找出阻碍清理界限前进的因素:会话、复制槽、反馈、预备事务(什么会阻碍清理)。
-
age(datfrozenxid)达到 5 亿至 10 亿时告警,绝不杀掉防回卷清理(回卷)。 - 把序列值与其类型最大值画在图上比较(int 转 bigint 的问题)。
复制与故障切换
- 用
max_slot_wal_keep_size限制每个复制槽,对非活跃复制槽告警(复制槽)。 - 决定故障切换最多允许丢失多少已确认的提交(异步是默认方式)。
- 使用故障切换管理器,绝不用脚本自行实现,并定期演练(不要自己拼凑故障切换)。
- 把故障切换候选节点与报表副本分开(副本查询会与重放冲突)。
- 需要读取自身写入时,路由到主库,或等待该次写入的 LSN(读取自身写入)。
安全与租户隔离
- 应用连接所用的角色既不是超级用户,也没有
BYPASSRLS,更不拥有表(应用永远不拥有自己的数据表)。 - 设置默认权限,让新表得到正确授权(默认权限)。
- 每张租户表都启用并强制执行行级安全(除非强制执行,否则 RLS 会跳过表所有者)。
- 通过统一的辅助函数,在每个事务中设置租户(连接池会泄露租户)。
- 索引以租户列开头;策略就是谓词(RLS 性能:策略就是谓词)。
- 固定
search_path,每个security definer函数都要如此(SECURITY DEFINER 函数需要固定的 search_path)。 - 强制使用
hostssl、scram-sha-256和sslmode=verify-full(使用 scram-sha-256 和 verify-full,绝不用 trust)。
托管还是自建
- 在预算内尽量多买托管服务;只有说得清理由、又有人负责时才自建(我们的选择)。
- 记下存储卷和实例两种 I/O 上限中的较低者(两道上限)。
- 围绕某个扩展做设计前,先检查扩展允许列表(允许列表)。
- 在克隆环境上演练大版本升级(升级)。
- 用到之前,先规划退路(规划退路)。
申请更大的数据库之前
在前面各节做完、数据也证明有必要之前,我们都不会批准扩容申请(用数据争取更大的机器)。在一个应用上,实战手册让每个事务访问的页数在同一台笔记本上从 91,448 降到 38,没有增加任何硬件(证据)。
- 在排名前五的每条语句旁写清:已修复,为什么无法修复,或为什么已经足够便宜(扩容申请要带什么)。
- 性能画像若显示
Lock、LWLock或Client,就停下来:更大的机器不会缩短队列(购买之前先测量)。 - 买更多核心前,先找找是否所有事务都在更新同一个热点行(不要用热点行计数)。
- 测量工作集并与内存比较;已经放得下的话,更多内存毫无收益(根据工作集配置 RAM)。
- 花钱买更快的存储前,先证明读取占据主要开销(存储是你的瓶颈吗?)。
- CPU 是瓶颈时,先把逐行格式化和副作用移出主库(主库是你最昂贵的 CPU)。
- 逐一排除索引、改写、往返、连接池、配置、清理和汇总表,每项写一句话(预先算好要展示的内容)。
- 说明余量:目前的峰值用量、新规格提供什么,以及按当前增长能撑几个月(只问一个问题)。
- 按模板提交申请(把申请写成工单)。
- 不要为修一条慢查询就上副本、分区或分片;它们各自回应不同的信号(上限)。
如果证据表明,机器已经耗尽负载所需的某项资源,就不必争论,直接升级;把证据留下,作为下一次的基线。
横向扩展或引入第二个系统之前
跨出单个主库后的每一步,都回应一个信号,也都会成倍增加运维工作。迈步前,先说清信号和数字。
- 为读取负载或故障切换增加只读副本,绝不为一条慢查询加副本;必须看到自身写入的读取,路由到主库(读取自身写入)。
- 为保留期限和维护而分区,不为查找速度;不按分区键过滤的查询会搜索每个分区(查找不会更快)。
- 只有真正出现信号时才分片:调优和批处理后,写入仍让最大主库饱和;数据多到无法在 RTO 内恢复;或有数据驻留要求(何时分片)。
- 在此之前,留好余地:每张租户表、每个键、每条查询都带
account_id(留好余地)。 - 选分片方案之前,检查负载最高的查询的计划中是否有扇出(跨分片查询)。
- 分析负载先试汇总表、报表副本或 pg_duckdb,再考虑列式存储;整表要读 35,173 页,汇总表只读 12 页(折中办法)。
- 只有能说清特性、又有实测数字,才增加第二个系统,同时让 PostgreSQL 继续作为权威数据源(实测理由)。
故障排查:数据库现在就很慢
先看,再动手。重启会把证据和缓存一起清掉,流量恢复后,原因也会回来(重启后预热缓存)。按顺序执行下面的步骤;每个答案都会告诉你,接下来哪一步才重要。
**1. 按状态和等待事件统计会话。**遇到任何慢服务器,先跑这条查询:
- 解读结果:
active且没有等待事件,表示在用 CPU,要与核心数比较;Lock表示转到第 2 步;IO的DataFileRead是读页;LWLock表示内部结构拥挤;大量idle in transaction指向应用问题(等待事件)。 - 将总数与
max_connections比较:select count(*), current_setting('max_connections') from pg_stat_activity;(统计连接)。
**2. 找出阻塞链的根源。**列出每个等待锁的会话,以及每个阻塞别人的会话:
- 找根源:
blocked_by为空的那一行,往往处于idle in transaction状态(找到根阻塞者)。
**3. 列出最老的事务。**它们持有锁,也阻碍清理:
- 在
idle in transaction状态下停留几分钟就是 bug;query是它的上一条语句,不是当前语句(寻找长事务)。
4. 结束正确的会话。select pg_cancel_backend(pid) 会停止正在运行的语句;事务中空闲的会话没有正在运行的语句,因此用 select pg_terminate_backend(pid),它会回滚该会话的事务。
- 记下结束了什么、为什么结束,再修复那个让事务一直开着的问题(同节)。
**5. 对等待采样一分钟。**看一眼只是照片;每秒十次、持续一分钟,才是性能画像。
**6. 给问题发生后运行的语句排名。**对 pg_stat_statements 取快照,等一分钟,再算差值:
- 找那些语句很简单、平均耗时却很长的:它在等,不是在干活(购买之前先测量)。
- 用真实参数,对排在最前的语句运行
explain (analyze, buffers)(排查方法)。 - 怀疑计划发生了切换:通用计划,或加载后过时的统计信息(计划出错时)。
7. 检查查询背后还在运行什么。
- 检查清理进度:
select pid, relid::regclass, phase from pg_stat_progress_vacuum;(监控清理)。 - 检查副本延迟:
select application_name, state, replay_lag from pg_stat_replication;(监控复制)。 - 检查归档:
select failed_count, last_failed_time from pg_stat_archiver;(归档)。 - 检查回卷:
select datname, age(datfrozenxid) from pg_database order by 2 desc;(回卷)。 - 检查主机:CPU 使用量与核心数的关系、磁盘延迟,以及 IOPS 是否平在存储卷的上限(I/O 被节流时,会表现为 IOPS 持平而延迟上升)。
8. 收尾闭环。