Skip to content
架构与核心概念

架构与核心概念

本章解决一件事:建立 PG 的进程、存储、内存、WAL 四条主线的心智模型——后面所有关于连接池、索引膨胀、vacuum、复制的讨论,都挂在这张图上。


进程模型:为什么一个连接就是一个进程

PostgreSQL 的主进程叫 postmaster:它绑定端口、按配置分配共享内存、拉起后台进程家族,然后专职监督——每来一个客户端连接,fork 出一个 backend 进程专门服务这条连接,连接断开进程退出。这跟 MySQL 的"每连接一线程"是根本性差异:线程共享地址空间所以轻,但一个线程跑飞可能带倒整个进程;进程各自独立所以重,换来的是故障隔离和更简单的内存管理。两种模型没有绝对优劣,但决定了你日常打交道的东西不一样——在 PG 这边,“连接"这件事天生就贵。

贵的具体构成,以及它带来的三个工程后果:

  1. 连接贵。一个 backend 进程有自己的内核栈、独立的内存上下文(PG 自己的分配器)和共享内存里的进程槽位;空闲状态基线开销就有几 MB,跑起查询再各自叠加 work_mem(注意那是节点级的乘号,见 09-查询优化与EXPLAIN.md)。500 个空闲连接,什么都没干就已经是两个 GB 量级的内存和 500 个调度实体。
  2. 会话状态天然隔离。backend 进程活多久,会话就活多久:临时表、会话级 SET、prepared statement、advisory lock、listen/notify 订阅,持有者都是这个进程。好处是干净,坏处是进程复用会串状态——pgbouncer 做事务级池化时,多个客户端轮流复用同一个 backend,上面这些会话级特性就全乱了。上池子之前先盘点应用有没有依赖它们(server_reset_query 只是遮羞布,不是解药)。
  3. 连接池因此是标配。正确姿势是两层收敛:应用自己的池(pgx 的 pgxpool,Go 侧见 08-Go客户端pgx.md)把请求收敛成几十条真连接,必要时再前置 pgbouncer;max_connections 保持 100–300 的量级,而不是追着连接数调大(配置理由见 00-安装与配置.md)。

postmaster 之外,后台常驻着一族进程。不必背全,但每个的名字要能在日志和 pg_stat_activity 里认出来:

进程 职责 你需要知道的事
checkpointer 定期把脏页刷盘、推进 WAL 重放起点 周期性的刷盘延迟尖刺找它(见下文 WAL 节)
background writer 日常替 backend 分担脏页刷写 让后端查询少做同步写;PG 14 起按需启动
walwriter 把 WAL 缓冲批量落盘 高并发短事务的 commit 延迟下限跟它和 fsync 有关
autovacuum launcher → workers 回收死元组、防事务 ID 回卷、更新统计 “表越删越大"都归它管(见 06-MVCC与Vacuum.md)
parallel workers 一条查询内的并行扫描/聚合 预算见 max_parallel_workers_per_gather(见 09-查询优化与EXPLAIN.md)
walsender / walreceiver 物理/逻辑复制的发送端与接收端 备库和 CDC 的通道(见 10-复制与高可用.md)
archiver 把写满的 WAL 段复制去归档目录 PITR 的前提(见 11-备份恢复与运维.md)
io worker(PG 18 新增) 执行异步 I/O 请求 io_method=worker(默认)时按 io_workers 拉起,默认 3 个

这些"编制外"进程共享 max_worker_processes(默认 8)这个公共池:parallel workers、autovacuum workers、logical replication workers 都从这里领人。调大任何一路并发之前,先看公共池还剩多少——SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'parallel worker'; 是个快速体检。

一条查询的旅程(进程视角)

把各角色串起来,一条 SELECT 在路上经历的是:

  1. 客户端经 TCP 进来,postmaster 完成认证(按 pg_hba 的规则表),fork backend;
  2. backend 解析 SQL、生成执行计划(优化器读统计信息,09-查询优化与EXPLAIN.md);
  3. 需要数据时先查 shared_buffers,未命中向内核读,读回的页进 shared_buffers——两层缓存的关系见下文;
  4. 若涉及修改:先生成 WAL 记录进 WAL 缓冲,commit 时 fsync;脏页不立即落数据文件,留给 checkpointer/bgwriter 之后慢慢刷;
  5. 返回结果,backend 继续活到连接断开,然后进程退出、资源回收。

这个视角解释了很多现象:为什么"连接建立"有真实开销(fork + 认证 + 内存上下文初始化)、为什么 commit 延迟基本等于磁盘 fsync 延迟、为什么空闲大连接也要收进池子——它就是一个活着的进程。

顺带把"关库"讲清楚,因为它同样是进程模型的一部分:pg_ctl stop -m fast(默认)先拒新连接、掐掉现有 backend、刷 WAL 再退——Docker 的 docker stop 发的 SIGTERM 就是 fast 模式;-m smart 会等到最后一个连接断开,有长连接池的实例千万别用;-m immediate 是拔电源式关停,下次启动必走 WAL 恢复。单个会话可以精准杀:SELECT pg_terminate_backend(pid);,杀的是进程,干净利落。


存储层次:实例 > 数据库 > schema > 表

从大到小四层,用操作系统的类比会很顺:实例(cluster)是一台机器——一个 PGDATA 目录加一个 postmaster,仅此而已;database 是目录,物理上就是 base/<数据库OID>/;schema 是目录里的子目录,默认有个 public;表和索引等对象最终住在 schema 里。

PG 的 database 边界是硬隔离:backend 进程一启动就绑定了某一个库,此后没有任何办法在一条 SQL 里 JOIN 两个库的表。试了会得到:

SELECT * FROM db1.public.t1 JOIN db2.public.t2 ON ...;
-- ERROR:  cross-database references are not implemented: "db1.public.t1"

这不是缺陷而是设计:每个库有自己的系统表(pg_database 行、自己的 OID 目录、自己的编码与 collation),模板库机制(template1)和多租户隔离都建立在这堵墙上。要跨库取数,正解是把数据"搬进来"而不是找语法后门:要么合并到一个库用不同 schema 区分,要么用 postgres_fdw 把远端库映射成本地外部表:

CREATE EXTENSION postgres_fdw;
CREATE SERVER remotedb FOREIGN DATA WRAPPER postgres_fdw
       OPTIONS (host '10.0.0.5', dbname 'otherdb', port '5432');
CREATE USER MAPPING FOR app SERVER remotedb OPTIONS (user 'app_ro');
CREATE SCHEMA foreign_app;
IMPORT FOREIGN SCHEMA public LIMIT TO (orders) FROM SERVER remotedb INTO foreign_app;
SELECT l.id, r.status FROM local_orders l JOIN foreign_app.orders r ON r.id = l.id;

外部表走的是网络上的一条独立普通连接,性能按远程查询算——它解决"能不能”,不解决"快不快”。

实例里自带的三个库也是这层结构的一部分:template1(新建库的默认拷贝模板)、template0(保持出厂原样的只读模板,恢复跨版本 dump 时指定它)、postgres(约定俗成的运维入口库)。记一条硬规则:库的编码和排序规则(lc_collate/lc_ctype,或 ICU)在 CREATE DATABASE 时定死,之后不能改——“文本列的索引顺序和 ORDER BY 预期不一致"这类经典坑,根源就是建库 collation 与运行环境(glibc/ICU 版本漂移)之间的错配,属于 02-数据类型与DDL.md 和运维章要展开的坑。

database 之内,schema 才是命名与权限的主要单位。不带 schema 前缀的对象名按 search_path 从左到右解析:

SHOW search_path;                    -- 典型值: "$user", public
SET search_path = app, public;       -- 会话级;角色级用 ALTER ROLE app SET search_path = app, public
CREATE TABLE orders (id bigint);     -- 落在 app.orders("$user" 没有同名 schema 时向后匹配)

两个经典坑:同名遮蔽——app.orders 和 public.orders 并存时,不带 schema 的语句默默打在排在前面的那张上,迁移脚本和报表工具常在这里埋雷;pg_temp 永远在最前——临时表会遮蔽同名真实表,“查询怎么凭空多了张空表"的事故就是它。便宜的保险是把 schema 显式写进 SQL,或由连接层统一固定 search_path。search_path 还有安全面(被恶意构造的解析顺序可利用),点到为止,详见 12-安全与权限管理.md。


页与元组:数据在磁盘上的形状

PG 的表在磁盘上就是一串 8KB 页(默认 BLCKSZ,编译期常量,绝大多数部署都是 8K)。页的骨架:

┌─────────────────────────┐
│ PageHeader(含 pd_lower/pd_upper 空闲边界)│
├─────────────────────────┤
│ 行指针数组 itemid 1..n   │ ← 每个指针指向页内一个元组偏移
├─────────────────────────┤
│        (空闲区)         │ ← pd_upper 向低地址生长
├─────────────────────────┤
│ 元组区(从页尾向前增长)    │ ← 新元组插在尾部,指针表在头部追加
└─────────────────────────┘

行指针(item pointer)与元组本体分离是关键设计:删除/回收时指针可以先置为"废弃"状态而元组本体原地不动,页内碎片整理和 HOT 更新都靠这层间接性(细节见 06-MVCC与Vacuum.md)。每个元组里最要紧的是头部的两个事务 ID:

  • xmin:创建本版本的事务 ID——“谁把我写进来的”;
  • xmax:删除或更新(作废旧版本)本版本的事务 ID——“谁把我判了死刑”,刚插入时是 0。

可见性就是拿这两个 ID 和事务快照比较——“写我的事务我看得见吗?删我的事务开始了没?",这就是 MVCC 的物理基础(判定规则见 06-MVCC与Vacuum.md)。行指针、xmin/xmax、快照三者合起来还解释了一个常见困惑:表里明明只剩 1 万行,count(*) 却要扫很久——死元组在被 vacuum 之前物理上还在页里,计数必须把它们都翻一遍(性能后果见 09-查询优化与EXPLAIN.md)。

一行装不进一页怎么办?超过约 2KB 的字段值被搬进该表专属的 TOAST 表(一张隐藏的附属表,chunk 成一行 2KB 左右),主表位置只留指针。TOAST 表本身也是普通表,有自己的 xmin/xmax,也会膨胀,也归 vacuum 管。代价清单:

  • 大 text/jsonb/bytea 的读取多一层间接,SELECT * 在带 TOAST 的宽表上明显变贵;
  • TOAST 值更新时:改小字段没动大字段可以复用旧 TOAST 行;但把 jsonb 整个重写一遍,TOAST 记录也整个换血;
  • TOAST 压缩默认走 default_toast_compression 参数(PG 14 起默认 lz4),单个列可以用 ALTER TABLE ... ALTER COLUMN ... SET (compression = ...) 覆盖;完全不想被 TOAST 搬走的列可以设 STORAGE PLAIN/MAIN。

页的填充密度是表级可调的:fillfactor(默认 100)。给 90 就是每页留 10% 余量,让 UPDATE 的新版本有机会就地安放在同一页——HOT 的"同页有空位"条件正是在这里兑现,高频更新表用它对抗膨胀(见 06-MVCC与Vacuum.md)。

顺带一个常见误区:字段声明 varchar(N) / char(n) 只是校验长度,存储上和 text 一样,char(n) 还自带补空格语义的坑。PG 社区的主流实践是全用 text,长度约束交给 CHECK——见 02-数据类型与DDL.md。


内存与缓存:双层 cache 的真实关系

PG 的内存布局是"一大块共享 + 每进程一小块私有”。共享内存里住的是:shared_buffers(页缓存主体)、进程槽数组、锁表(容量随 max_locks_per_transaction 和 max_connections 弹性伸缩)、WAL 发送缓冲、以及事务状态缓存(CLOG 等,一般不用动);私有内存则是每个 backend 自己的工作区——排序用的 work_mem、维护操作用的 maintenance_work_mem(参数逻辑见 00-安装与配置.md)。

shared_buffers 和操作系统 page cache 是两层都真实存在的缓存。PG 默认用内核的 buffered I/O(不是 O_DIRECT):backend 读页时先查 shared_buffers,未命中就向内核读,读回的页进 shared_buffers;同一份热数据完全可能两层各存一份。这就是"25% 内存、超过 40% 很难更好"建议的由来——不是 PG 不想独占缓存,而是它根本管不到 OS 那层;而 OS 的全局回收策略(LRU 一族)在内存压力下通常比 PG 的 clock-sweep 更聪明。两层分工是互补,不是浪费——浪费的只是重复的那部分副本,这也是为什么不建议把 shared_buffers 当唯一缓存去堆。

effective_cache_size 不分配任何内存。名字里带 cache 却只是给优化器的一个情报:“这台机器上数据大概率在缓存里,总量约这么多”,影响的是索引扫描 vs 顺序扫描的成本估算。设成 shared_buffers 加上预计 OS 可用缓存之和即可,实践常取内存的 70–80%。它配 random_page_cost(00-安装与配置.md)一起理解:两个都是"让优化器正确认识你的缓存和 SSD"的旋钮。

写入路径同样经内核:PG 把脏页标在 shared_buffers 里,由 checkpointer/bgwriter 走内核 writeback,再 fsync 确认——持久性完全依赖 fsync 语义,所以云盘/NFS 的 fsync 行为要提前验证(运维细节见 11-备份恢复与运维.md)。


WAL:崩溃安全的根

规则一句话:任何修改,先把 WAL 记录写好并 fsync,才允许对应脏页落数据文件。 WAL 里记的是"物理重放指令”(页级 delta 为主,外加整页镜像),崩溃后把 WAL 重放一遍就能把数据文件拉到一致状态——脏页可能丢,已提交的事实丢不了。类比记账:现金(数据文件)可以慢慢归位,流水(WAL)必须先写完;只要流水在,账本随时能重建。

崩溃那一刻会发生什么——这是"重启"真正走的路径:

  1. 断电或 kill -9 之后,PGDATA 处于"可能不一致"状态;
  2. 下次 postmaster 启动先恢复:从上一次 checkpoint 记录的起点开始,向后重放 WAL,把已提交、未落盘的修改补齐;
  3. 重放到尽头,做一次 shutdown checkpoint,新起点前移,开放服务;
  4. 没提交完的事务,它的 WAL 没等到 fsync 就被丢了——原子性也顺带由 WAL 保证。

所以 crash 后的启动时间取决于"距上次 checkpoint 攒了多少 WAL”——这也反过来提醒你:max_wal_size/checkpoint_timeout 设得越激进(攒得越多),恢复越久。批量导入宕机后"数据库半天起不来",多半是在等你重放 WAL。

WAL 中每个字节有单调递增的位置号 LSN(形如 0/1E57A28)。它同时充当整个系统的"时间轴":备份点、复制进度、恢复目标、pg_current_wal_lsn() 的对比,全是 LSN。事务 commit 返回给客户端的那一刻,就是该事务最后一条 WAL 记录 fsync 完成的时刻——commit 慢,第一反应看磁盘的 fsync 延迟,不是看 SQL。

checkpoint 做的事:把脏页批量刷盘,并把 WAL 里"崩溃恢复还要从这里开始重放"的起点向前推进(否则恢复要重放的 WAL 越来越长、启动越来越慢)。麻烦在于它是个周期性的集中写:脏页在两个检查点之间持续攒,边界处集中刷;调大 max_wal_size/checkpoint_timeout 只是把风暴摊得更稀疏,不是取消。检查点过于频繁时,你会看到与它同周期共振的延迟尖刺(默认 5min 一档,日志里 checkpoint complete 那行自带写缓冲数和耗时,log_checkpoints 默认已开)。缓解方向:把 checkpoint_timeout(默认 5min)和 max_wal_size(默认 1GB,软上限)适当调大,并给 checkpoint_completion_target(默认 0.9)留足摊开空间——量化方法见 09-查询优化与EXPLAIN.md。

wal_level 三档(PG 18 文档现状):

  • minimal:只记崩溃恢复所需的信息,WAL 量最小。截至 PG 18 官方文档它仍然保留(社区确有精简它的讨论,但以文档为准);代价是与归档和物理复制互斥——archive_mode 无法开启,max_wal_senders 不为 0 时服务器直接拒绝启动。省下的那点 WAL,买不回 PITR 和副本,绝大多数场景别碰。
  • replica:默认值。多记录恢复页面所需的信息,支撑归档 + 流复制。
  • logical:再额外记录足够的行级变更历史,支撑逻辑复制和 CDC(debezium 一类)。额外开销是持续性的,没有订阅端就别常开。

归档伏笔:物理流复制保证"副本跟着同步",不解决"时间可以倒回"。把满段的 WAL 复制去别处(archive_command 或 PG 14+ 的 archive_library)才构成时间点恢复(PITR)和基础备份的闭环——机制见 11-备份恢复与运维.md。

三种持久化级别:permanent / unlogged / temp

WAL 也有"豁免通道",CREATE TABLE 的三个级别本质是用耐久性换速度和自动清理:

  • 默认(permanent):全量 WAL,崩溃安全,照常流复制到备库。
  • UNLOGGED:数据不走 WAL,插入明显更快(官方文档原话 considerably faster);代价是官方明确列出的三条——崩溃或非干净关机后自动清空(truncate,不是回滚到某个状态——根本没有日志可重放)、内容不复制到备库(failover 后备库上只剩空表壳)、挂在它上面的索引和 identity/serial 序列连坐变 unlogged,且不支持分区表。适用判断只有一条:丢了只赔时间、不赔钱的中间数据(ETL staging、可重算的缓存表)。业务表用 unlogged,等于把可用性押在"这台机器永不崩溃"上。
  • TEMP / TEMPORARY:只在当前会话可见,同样不写 WAL;ON COMMIT {PRESERVE ROWS | DELETE ROWS | DROP} 决定事务结束时的行为,默认会话结束自动消失。它住在 pg_temp schema,而 pg_temp 永远排在 search_path 最前——前文"同名遮蔽"讲过的事故(真实表被同名临时表盖住)根源就在这。迁移脚本、多步计算的中转结果用它最干净。

顺带一提:临时/unlogged 表快在省 WAL,但MVCC、vacuum、锁这些机制一分不少——它不是"旁路数据库",膨胀和长事务的教训照常适用(见 06-MVCC与Vacuum.md)。

PG 18 的异步 I/O(AIO)子系统

PG 18 引入了新的 AIO 框架:backend 可以一次提交多个读请求并行等待,而不是排队逐个等 IO 完成。顺序扫描、位图堆扫描、vacuum 这些"大块读"场景直接受益(官方 release notes 点名的就是这几类)。相关参数:

  • io_method(默认 worker,restart 生效):worker 用一组 I/O worker 进程代执行;io_uring 需要构建时链接 liburing(--with-liburing,发行版包是否带要看打包方);sync 退回同步执行,等价于不用这个特性。
  • io_workers(默认 3,受 io_method=worker 约束);io_max_concurrency(默认 -1,按 shared_buffers、max_connections 等自动推导,上限 64)。
  • io_combine_limit / io_max_combine_limit(默认 128kB):把相邻的小读合并成大请求再提交。
  • 附带红利:effective_io_concurrency 和 maintenance_io_concurrency 以前在缺少 fadvise() 的平台上只能设 0,18 起这些平台也能用正值了。
  • 新视图 pg_aios 列出各 backend 持有的异步 I/O 句柄,排查"AIO 到底有没有生效"看它。

收益的压测与调参在 09-查询优化与EXPLAIN.md 展开。


MVCC 概览:UPDATE 为什么不"改"行

一句话版快照可见性:每个事务启动时拿到一张"当时谁已提交"的名单;一行数据的当前版本是否可见,取决于它的 xmin 事务在名单上(或就是你自己的)、xmax 要么为 0 要么不在名单上。

于是 UPDATE 的真实动作是:给旧元组盖上 xmax,再插入一个新元组版本。新版本对写者立即可见,对其他事务按各自的名单判定。好处是读不阻塞写、写不阻塞读,代价就是死元组:旧版本留在页里占空间、拖慢扫描,直到没有事务再需要它、并且被 vacuum 回收。HOT(仅堆内优化)能在"索引列没变且同页有空位"时免更新索引项,但索引里已存在的、指向死元组的表项仍需 vacuum 顺路清理——膨胀与清理的完整机制见 04-索引实战.md 和 06-MVCC与Vacuum.md。

还有一个只在 PG 里存在的隐患:事务 ID 是 32 位循环使用的。今天还"看得见"的老元组,32 位绕一圈回来就可能被误判成未来事务。所以 autovacuum 会在表的冻结年龄逼近阈值时强制 vacuum(freeze),不 vacuum = 最终停摆,这是 PG 独有的运维死线。看年龄的实用查询:

SELECT datname, age(datfrozenxid) AS xid_age
FROM pg_database ORDER BY xid_age DESC LIMIT 5;
-- autovacuum 默认 2 亿就强制清理;逼近 20 亿上限时数据库会拒绝普通写入(wraparound)

理解"UPDATE = 标记旧版本 + 插入新版本"这一条,后面几乎所有 PG 行为都能自洽推导:为什么表越删越大、为什么索引膨胀、为什么 count(*) 慢、为什么 vacuum 是生命线、为什么长事务拖垮一切。


系统目录:向数据库问问题

PG 把所有元数据本身存成可读的表/视图,这是它最实用的工程福利之一。常用的目录对象:

  • pg_class:每个表/索引/视图/序列一行。relkind 区分类型(r 普通表、i 索引、p 分区主表、v 视图、m 物化视图);relpages/reltuples 是 ANALYZE 留下的估算值,不是实时值。
  • pg_namespace:schema 清册;pg_attribute:列清册(attnum > 0 排掉系统列,attisdropped 排掉被 DROP COLUMN 留下的墓碑列)。
  • pg_indexes / pg_tables / pg_views:pg_catalog 提供的便捷视图,定义文本直接可读,脚本里首选。
  • pg_settings:所有参数的当前值、单位、context(生效方式)、sourcefile/sourceline(被哪个文件哪一行定的)——reload/restart 判断、“谁改了我的参数"都查它。
  • pg_stat_activity:每个 backend 一行:哪个用户、哪条 SQL、什么状态、等了什么事件、跑了多久。

再认识一下统计计数那半边:pg_stat_* 系列视图(pg_stat_user_tables、pg_stat_statements、上面的 pg_stat_activity)是共享内存里的计数器,重启后由 pg_stat/ 目录下的统计文件续上;但 pg_stat_database 里的 stats_reset 字段会告诉你计数器是从什么时候起算的——用统计表征做容量或热度分析前先看它一眼,别把"从三天前起算"当成全历史。

十条日常 introspection SQL,均可直接跑:

-- 1. 最占空间的表(pg_total_relation_size 含 TOAST 与全部索引)
SELECT n.nspname AS schema, c.relname AS tbl,
       pg_size_pretty(pg_total_relation_size(c.oid)) AS total
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p')
ORDER BY pg_total_relation_size(c.oid) DESC LIMIT 10;

-- 2. 某表的索引定义(等价于 \d 的信息量,脚本里用得上)
SELECT indexname, indexdef FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'orders';

-- 3. 谁在跑、跑了多久、在等什么(排除空闲连接)
SELECT pid, usename, state, now() - query_start AS runtime,
       wait_event_type, wait_event, left(query, 80) AS query
FROM pg_stat_activity
WHERE backend_type = 'client backend' AND state <> 'idle'
ORDER BY runtime DESC NULLS LAST;

-- 4. 最老的事务/最长空闲事务——膨胀和锁等待的头号嫌疑人
SELECT pid, usename, state, xact_start, now() - xact_start AS age
FROM pg_stat_activity
WHERE xact_start IS NOT NULL ORDER BY age DESC NULLS FIRST LIMIT 5;

-- 5. 关键参数现在是什么、从哪来、怎么生效
SELECT name, setting, unit, context, sourcefile, sourceline
FROM pg_settings
WHERE name IN ('shared_buffers', 'work_mem', 'max_connections', 'wal_level');

-- 6. shared_buffers 层面的缓存命中率(长期 99% 以下才值得担心)
SELECT datname, blks_hit, blks_read,
       round(100.0 * blks_hit / nullif(blks_hit + blks_read, 0), 2) AS hit_pct
FROM pg_stat_database WHERE datname NOT LIKE 'template%';

-- 7. 从没被扫过的索引——删之前先看清名单(见 `04-索引实战.md`)
SELECT s.relname AS tbl, s.indexrelname AS idx, s.idx_scan,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
WHERE s.schemaname = 'public' AND s.indexrelname NOT LIKE '%pkey%'
ORDER BY s.idx_scan ASC;

-- 8. 列清单(format_type 给出人类可读类型名)
SELECT a.attnum, a.attname AS col,
       format_type(a.atttypid, a.atttypmod) AS type, a.attnotnull AS notnull
FROM pg_attribute a
WHERE a.attrelid = 'public.orders'::regclass
  AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum;

-- 9. 死元组排行榜——膨胀诊断的头号入口(见 `06-MVCC与Vacuum.md`)
SELECT relname, n_live_tup, n_dead_tup,
       round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
       last_autovacuum
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10;

-- 10. 谁在等谁——锁阻塞链(waiter 被 blocker 挡住了)
SELECT w.pid AS waiter, left(w.query, 60) AS waiting_query,
       b.pid AS blocker, left(b.query, 60) AS blocking_query
FROM pg_stat_activity w
JOIN LATERAL unnest(pg_blocking_pids(w.pid)) AS bl(pid) ON true
JOIN pg_stat_activity b ON b.pid = bl.pid;

'public.orders'::regclass 这种写法值得记住:::regclass 按 search_path 解析对象名、顺带天然防注入。


实战建议

第一次在这台机器/容器上把 PG 跑起来之后,按顺序做三件事,每件都能省掉将来一次事故:

  1. 确认"谁能进来”。 SHOW listen_addresses; 加上 SELECT line_number, type, address, auth_method FROM pg_hba_file_rules; 看实际生效规则。Docker 镜像默认监听全部网卡且允许任意 IP 以 scram 口令登录——本机开发可以接受,云主机不行。顺手确认 password_encryption = scram-sha-256,存量 md5 口令尽快轮换(原因见 00-安装与配置.md,认证细节见 12-安全与权限管理.md)。
  2. 按机器内存定参数。 shared_buffers = 内存 25%(restart)、effective_cache_size ≈ 内存 75%(reload)、SSD 上 random_page_cost 降到 1.1–1.2(reload)、log_min_duration_statement 从 500ms 起步(reload);max_connections 保持默认量级,连接收敛交给应用池。改之前用 pg_settings.context 再确认一遍是 reload 还是 restart。
  3. 把可观测底座装上。 shared_preload_libraries = 'pg_stat_statements'(restart 生效,见 00-安装与配置.md 的说明),然后 CREATE EXTENSION pg_stat_statements;;同时确认校验和状态(pg_controldata $PGDATA | grep -i checksum,PG 18 新实例默认开启)。此后排障、优化、容量评估全靠这一层数据(见 09-查询优化与EXPLAIN.md 和 11-备份恢复与运维.md)。

还有一个成本接近零、回报极高的习惯:连接串里带上 application_name(Go 侧见 08-Go客户端pgx.md)。pg_stat_activity 里一眼分辨"这是订单服务还是报表脚本",排障时少猜很多轮。

最后留一个检验标准:如果本章的图景你已经能顺嘴讲出"连接为什么贵、UPDATE 为什么不原地改行、checkpoint 为什么会带来尖刺"这三个因果链,后面的索引、事务、优化章节基本不会迷路;讲不顺就回头再读对应小节——这张图值得读三遍。