跳转到主要内容

37 先纵向扩容,再横向扩展

每次升级机器都要有数据依据。找出真正的瓶颈,先做成本低的结构性改进,再判断单台大机器何时真的到达极限。

八个客户端各自在每个事务中插入一个事件,在我们的测试容器上每秒能执行约 5,000 个事务。给每个事务加上一行 update account_totals set events = events + 1 where account_id = 42,作为仪表盘的“实时计数器”,吞吐量就降到 390 至 600。只剩原来的十分之一。

只读副本解决不了这个问题,分片、更大的实例或换个数据库也不行。八个会话正排队等待同一行,解决办法是别再每插入一条数据就更新它。

Postgres 数据库吃力时,人们的本能反应是买一台更大的机器。单台大机器确实能撑很久。但先要改进工作本身,每次升级都得有数据依据。

先改进工作,再升级硬件

操作指南已经给各种手段排了顺序:测量、修查询、改变工作的方式,然后才是硬件。更大的机器之后,还有副本、分区和分片,每一项都会带来无法消除的成本。证据要求你走到哪一步,就走到哪一步。

用数据争取更大的机器

有人向我们申请更大的数据库时,我们的回答是“不”。不是永远不行,而是在用尽眼前成本更低的修复手段、并拿出数据证明之前不行。升级机器是第一个会每月花钱的台阶,因此也是第一个设有门槛的台阶。

设门槛不是为了省钱。有三个理由值得坚持。

**更大的机器会掩盖查询的问题,却不会修好它。**一条读取 35,249 页才返回 30 行的查询,换到规格翻倍的实例上,仍然要读取 35,249 页,只是快了一点,直到表的大小也翻倍。此时账单更高,同一条语句仍排在榜首。硬件能换来时间。修好查询,才能把时间拿回来,并一直省下去。

**成本会叠加。**升级规格之后,每张账单上都会多出一笔费用;副本的规格又要与主库相配,因此主库越大,副本也越大。解决查询问题的索引则没有月费。

**大多数慢数据库都不缺硬件。**只是其中几条语句读取了远超所需的页面。实证:一个应用,八次改动在同一个应用上走完了这套步骤:同一台笔记本,吞吐量达到原来的约一千倍。

所以,申请必须附带证据,这样讨价还价就变成了审查。如果数据表明机器确实耗尽了工作所需的某项资源,我们就买,不再争论,而这些证据也会成为下次申请的基线。

升级规格的申请要附上六项证据

前面的章节已经介绍过如何收集每一项:

  1. **排在前面的语句,以及每条语句做了什么处理。**对 pg_stat_statements 按总时间和共享块数排序(四种排名),取繁忙时段的统计差值。在前五条语句旁分别注明:已修复、为何无法修复,或为何已经足够便宜。
  2. 每项修复前后每次调用的页面数。用页面数,不用毫秒数,因为页面数不会随负载变化。第一个索引:35,249 页降到 91 页就是一条好的记录。
  3. 能指明某项资源的等待分布,在出现问题的负载下采样得到。更大的机器能帮助正在使用 CPU、等待 IO:DataFileRead 或缺少内存的会话。对于 Lock 等待、连接耗尽或工作负载的组织方式不当,它无能为力。
  4. 工作集(working set)与 RAM 的对比(根据工作集配置 RAM),以及读取量的变化趋势。如果工作集已经放得下,增加 RAM 没有收益。
  5. 耗尽的资源与资源余量:峰值离上限还有多远,以你的增长速度,新规格能撑几个月,都要如实测量。
  6. 已经试过并排除的办法:索引、改写、汇总表、连接池、配置、清理,以及等待分布指向 CPU 时,把逐行处理的工作移出主库。每项写一句话,不能只打个勾。

把申请写成工单

把这个模板粘进工单并填好。每个空白都是上面清单中的一个数字或一句话。

UPSIZE REQUEST: <database>  <current size> -> <requested size>

Bound by    <CPU | I/O | memory>, because <evidence> shows <number>
Window      <dates, peak hours>; stats diffed, not reset

Top statements (by total time, then by shared blocks)
  <queryid, short text>  <% of time>  <blocks/call>  <done / why not>
  ...

Fixes made (blocks per call)
  <statement>  <before> -> <after>  <index / rewrite / rollup>

Wait profile at peak (average active sessions)
  CPU <n>  IO <n>  Lock <n>  LWLock <n>  Client <n>  cores <n>

Working set vs RAM
  hot data <size>  RAM <size>  shared_buffers <size>
  read trend <rising / flat>

Headroom
  peak use of <resource> today: <percent>
  new size gives <x>; at <growth>/month it lasts <months>
  measured with <script, data shape, duration, runs>

Tried and ruled out
  indexes / rewrites / rollups / pooling / config / vacuum
  <one line each>

这个模板最大的作用,往往在提交之前就体现出来了:填到第二节左右,填表的人就找到了那条有问题的查询。而一份填写完整的申请很容易获批,因为论证已经做完了。

买任何东西之前,先测量

“数据库很慢”至少有五种原因:CPU、I/O、锁、连接,或一条热点查询。每种原因都有不同的解法,所以选择之前先测量。在开头的基准测试中,pg_stat_statements 显示,计数器更新是在一张 1,000 行的表上按主键更新,平均耗时却达 14 ms,几乎占了全部时间。

一条简单语句平均耗时很长,说明它在等待,而不是在干活。等待事件(wait event)采样告诉了我们在等什么。先看仅插入,再看加上计数器后的情况:

    192 LWLock WALWrite
     30 IO WalSync
     14 CPU running
     ...
    161 Lock transactionid
     48 Lock tuple
     29 IO WalSync
     ...

这是两个不同的问题。前者在等 WAL 刷新(提交意味着刷新):应该批量提交,增加核心没用。后者在等行锁:Lock transactionid 表示“等另一个事务结束,我才能动它的行”,Lock tuple 则是在后面排队。更快的磁盘没用,改变工作的方式才有用。

动手验证 · lab 36-scale
-- s1 updated the counter row and hasn't committed; s2, then s3, send the same update
select lab.prove(
  'the second writer of the counter row waits on Lock transactionid (the first writer''s transaction)',
  (select wait_event_type = 'Lock' and wait_event = 'transactionid'
   from pg_stat_activity where pid = :s2_pid));
select lab.prove(
  'the third waits on Lock tuple, queued behind the second',
  (select wait_event_type = 'Lock' and wait_event = 'tuple'
   from pg_stat_activity where pid = :s3_pid));

完整实验脚本

深入阅读:这些数字背后的语句排名与等待采样器

各种瓶颈的表现及常见解法:

瓶颈看到的现象常见解法
CPU核心满载,等待事件大多为空先修执行工作的查询,再增加核心
I/ODataFileRead 等待,缓存未命中索引、减少每次查询的数据量、增加 RAM
锁Lock 等待:transactionid、tuple改变工作方式,让会话不再互相争用
连接几百个空闲后端进程,连接池超时使用连接池器,缩小连接池
一条热点查询一条语句占据了大部分总时间修好那条查询
表 37-1 买任何东西之前,先测量

开头基准测试中的排名:

select left(regexp_replace(query, '\s+', ' ', 'g'), 29) as query,
       calls,
       round(total_exec_time) as total_ms,
       round(mean_exec_time::numeric, 2) as mean_ms,
       shared_blks_hit + shared_blks_read as blks,
       wal_bytes
from pg_stat_statements
where dbid = (select oid from pg_database where datname = current_database())
order by total_exec_time desc
limit 5;
             query             | calls  | total_ms | mean_ms |  blks   | wal_bytes
-------------------------------+--------+----------+---------+---------+-----------
 update account_totals set eve |  10612 |   149895 |   14.13 |  260308 |   1378255
 insert into events (account_i | 120225 |     6450 |    0.05 | 3059854 |  72265853
 ...

插入执行了 120,225 次,每次 0.05 ms;更新执行了 10,612 次,每次 14 ms。这些样本是每秒从 pg_stat_activity 读取几次得到的;活跃会话的 wait_event 为 null,表示它正在 CPU 上运行(把 application_name 筛选条件换成你的应用):

for i in $(seq 1 30); do
  psql -Atc "select coalesce(wait_event_type, 'CPU') || ' '
                    || coalesce(wait_event, 'running')
             from pg_stat_activity
             where datname = current_database()
               and application_name = 'pgbench' and state = 'active'"
  sleep 0.25
done | sort | uniq -c | sort -rn

省略的末尾几项:仅插入时为 1 LWLock BufferContent 和 1 Client ClientRead,加上计数器后为 2 CPU running。

修好找出来的查询

测量通常会指向一条查询,修法就在本书前面。我们的九月仪表盘查询,为找出某个账户的 154 行数据,读取了 35,249 页;在 (account_id, created_at) 上加索引后,只需 156 页,缩减至原来的 1/226。

但其中有 149 个堆页,每个事件一个,只为生成 30 行结果;每次查看仪表盘又要再读一遍。这个成本随账户规模增长,而最繁忙的账户最常查看仪表盘。

深入阅读:加索引前后的九月图表
explain (analyze, costs off, timing off)
select (created_at at time zone 'UTC')::date as day,
       count(*) as events,
       count(*) filter (where kind = 'deploy') as deploys,
       count(*) filter (where kind = 'deploy'
                        and payload->>'status' = 'failed') as failed_deploys
from events
where account_id = 42
  and created_at >= '2026-09-01' and created_at < '2026-10-01'
group by 1 order by 1;
 GroupAggregate (actual rows=30.00 loops=1)
   Buffers: shared hit=2124 read=33125
   ...
               ->  Parallel Seq Scan on events (actual rows=51.33 loops=3)
                     Rows Removed by Filter: 666615

加上索引后:

 GroupAggregate (actual rows=30.00 loops=1)
   Buffers: shared hit=117 read=39
   ->  Sort (actual rows=154.00 loops=1)
         ->  Bitmap Heap Scan on events (actual rows=154.00 loops=1)
               Heap Blocks: exact=149
               ->  Bitmap Index Scan on events_account_created_idx ...
                     Buffers: shared hit=3 read=4
动手验证 · lab 36-scale
select lab.prove(
  'with no index, one account''s month reads the whole table: over 35,000 pages',
  :raw_pages > 35000);
create index events_account_created_idx on events (account_id, created_at);
select lab.buffers(:'dashboard') as indexed_pages \gset
select lab.prove(
  'with (account_id, created_at), the same chart reads under 300 pages',
  :indexed_pages < 300);

完整实验脚本

预先算好要展示的内容

仪表盘、用量计量器和计费汇总,反复对写入后就不再改变的数据提出同样的聚合问题。汇总表(rollup)存的是答案,而不是输入。统计一次,存下计数,再读取计数。

粒度(grain),也就是分组依据的列,决定了汇总表是否值得建。我们的两百万个事件形成了 175 万个项目日,几乎没起到汇总作用;但只有 363,496 个账户日,约每行汇总五个事件。因此选择 account_daily,每个账户每天一行,主键为 (account_id, day),它同时也是仪表盘所需的索引。

查询原始事件汇总表
一个账户,30 天(无索引)35,249 页4 页
一个账户,30 天(有索引)156 页4 页
一个季度中部署失败次数最多的 10 个账户14,846 页4,218 页
表 37-2 预先算好要展示的内容

不论这个账户有 154 个还是 154,000 个事件,30 天都是 30 行。原始数据会一直增长,汇总表则每天只增加几千行。

动手验证 · lab 36-scale
select lab.prove(
  'the rollup answers the same month in under 10 pages',
  :rollup_pages < 10);
select lab.prove(
  'the cross-tenant quarterly report touches under a third of the raw pages on the rollup',
  :q_rollup < :q_raw / 3.0);

完整实验脚本

深入阅读:构建汇总表,对比两种季度报表
create table account_daily (
  account_id      bigint not null,
  day             date   not null,
  events          int    not null,
  deploys         int    not null,
  failed_deploys  int    not null,
  builds          int    not null,
  alerts          int    not null,
  primary key (account_id, day)
);

insert into account_daily
select account_id,
       (created_at at time zone 'UTC')::date,
       count(*),
       count(*) filter (where kind = 'deploy'),
       count(*) filter (where kind = 'deploy' and payload->>'status' = 'failed'),
       count(*) filter (where kind = 'build'),
       count(*) filter (where kind = 'alert')
from events
group by 1, 2;

它有 363,496 行:2,673 个堆页(21 MB),加上主键共 32 MB。这里特意用 UTC 定义日期:汇总表需要统一的“天”的定义,而会话的时区并不稳定。如果客户需要各自的时区,就按小时存储,再求和。(粒度检查比较的是 count(distinct (project_id, created_at::date)) 与把列换成 account_id 后的结果。)

九月图表变成了 Index Scan using account_daily_pkey,缓冲区统计为 Buffers: shared hit=2 read=2。批量插入按 (account_id, day) 顺序写入行,所以一个月的数据只占一两页;逐日填充的汇总表会把 30 行分散到最多 30 页,但也不过几十页。

季度“谁的部署失败最多”报表,先看原始数据,再看汇总表:

 Limit (actual rows=10.00 loops=1)
   Buffers: shared hit=3565 read=11281 written=8
   ...
         ->  Parallel Bitmap Heap Scan on events (actual rows=10543.00 loops=3)
               Rows Removed by Filter: 157493
 Limit (actual rows=10.00 loops=1)
   Buffers: shared hit=1427 read=2791 written=11
   ...
               ->  Bitmap Heap Scan on account_daily (actual rows=91600.00 loops=1)
                     Heap Blocks: exact=1662

规划器对每个账户遍历一次汇总表的主键索引(PostgreSQL 18 的跳跃扫描),读取了 2,673 个堆页中的 1,662 个;普通扫描的代价也差不多。

用插入或更新保持新鲜

汇总表好不好,取决于数据够不够新。最稳妥的刷新方式,是定时任务从昨天零点开始重算最近的时间窗口,再用 on conflict (account_id, day) do update ... where (...) is distinct from (excluded...) 插入或更新(upsert)。它是幂等的(idempotent):执行两次,得到的表相同。午夜前后新增 5,000 个事件后,它用约 16 ms 重算了 1,998 个账户日:新增 1,000 行,改变 299 行,相同而跳过的有 699 行。再执行一次,就不会重写任何行。

那段 is distinct from 虽长,却值得写:少了它,每次刷新都会重写窗口中的每一行,死元组和 WAL 一个不少(更新就是插入)。要重算完整的天,而不是“上次看到的 id 之后的所有数据”,后者会漏掉晚提交的事务。每次只运行一个刷新任务,并显示“更新于一分钟前”。

深入阅读:刷新语句,以及执行两次的计划

它需要在 created_at 上建索引,以查找最近的事件:

insert into account_daily as d
select account_id,
       (created_at at time zone 'UTC')::date,
       count(*),
       count(*) filter (where kind = 'deploy'),
       count(*) filter (where kind = 'deploy' and payload->>'status' = 'failed'),
       count(*) filter (where kind = 'build'),
       count(*) filter (where kind = 'alert')
from events
where created_at >= date_trunc('day', now() - interval '1 day', 'UTC')
group by 1, 2
on conflict (account_id, day) do update
set events         = excluded.events,
    deploys        = excluded.deploys,
    failed_deploys = excluded.failed_deploys,
    builds         = excluded.builds,
    alerts         = excluded.alerts
where (d.events, d.deploys, d.failed_deploys, d.builds, d.alerts)
      is distinct from
      (excluded.events, excluded.deploys, excluded.failed_deploys,
       excluded.builds, excluded.alerts);

我们在第二天早上执行它,把窗口起点写成 '2026-10-05',这样无论何时运行演示,结果都一样:

 Insert on account_daily d (actual rows=0.00 loops=1)
   Conflict Resolution: UPDATE
   Rows Removed by Conflict Filter: 699
   Tuples Inserted: 1000
   Conflicting Tuples: 998
   ...
     ->  Index Scan using events_created_idx on events (actual rows=10480.00 ...)
 Execution Time: 15.769 ms

第二次执行:Rows Removed by Conflict Filter: 1998、Tuples Inserted: 0。每个冲突行仍然会被锁住,产生少量 WAL,但没有新的行版本。如果事件可能晚几天到达(移动端同步、导入),就扩大窗口,或由导入程序重算受影响的日期;用 pg_try_advisory_lock 保护任务(咨询锁)。

动手验证 · lab 36-scale
select lab.plan(:'refresh') -> 'Plan' as second_run \gset
select lab.prove(
  'run it again and nothing is written: 0 rows out of the Insert node',
  (:'second_run'::jsonb ->> 'Actual Rows')::numeric = 0
  and (:'second_run'::jsonb ->> 'Tuples Inserted')::numeric = 0);
select lab.prove(
  'every conflicting row was skipped by the is distinct from filter',
  (:'second_run'::jsonb ->> 'Rows Removed by Conflict Filter')::numeric
  = (:'second_run'::jsonb ->> 'Conflicting Tuples')::numeric);

完整实验脚本

不要用热点行计数

开头的基准测试展示了另一种很诱人的做法:在每次插入的事务里递增计数器,让总数始终精确。代价是串行化:每个写入者都要取得同一个行锁,等待前一个写入者提交(计数器触发器会让写入者串行化)。每秒约 5,000 个事务就这样变成了不到 600 个。应该改为批量聚合,或把计数器分散到多行中(account_id, slot,读取时求和)。

物化视图会全部重算

物化视图(materialized view)把查询结果存成一张表。普通 refresh 在执行期间会阻塞所有读取者;refresh ... concurrently 则通过与旧结果做差异比较来避免阻塞,但需要唯一索引。对于一份 30 天的汇总,并发刷新耗时 181 ms,普通刷新为 41 ms;写出的 WAL 分别是 27 KB 和 2.1 MB,因为自上次刷新以来,几乎没有行发生变化。当大多数行都改变时,普通刷新在这两方面都更省。

无论哪种方式,都要全部重算。如果源数据会不断增长,就用汇总表。

深入阅读:普通刷新与并发刷新,各自花在哪里
create materialized view project_activity_30d as
select project_id,
       count(*)                                as events,
       count(*) filter (where kind = 'deploy') as deploys,
       max(created_at)                         as last_event_at
from events
-- now() in a live system
where created_at >= timestamptz '2026-10-06' - interval '30 days'
group by project_id;

refresh materialized view concurrently project_activity_30d;
ERROR:  cannot refresh materialized view "public.project_activity_30d" concurrently
HINT:  Create a unique index with no WHERE clause on one or more columns of the
       materialized view.

执行 create unique index on project_activity_30d (project_id) 后,pg_stat_statements 中的记录如下:

                            query                            | mean_ms | wal_bytes
-------------------------------------------------------------+---------+-----------
 refresh materialized view project_activity_30d              |      41 |   2152086
 refresh materialized view concurrently project_activity_30d |     181 |     27601

如果每次刷新大多数行都会改变,只要能忍受锁,普通刷新就更快、更省。汇总表还允许你自行选择索引,保留源查询会丢弃的历史数据。

动手验证 · lab 36-scale
select lab.prove(
  'a plain refresh holds ACCESS EXCLUSIVE on the view until it commits',
  exists (select 1 from pg_locks where pid = :s1_pid
          and relation = 'project_activity_30d'::regclass and mode = 'AccessExclusiveLock'));
select lab.prove(
  'the concurrent refresh wrote under a tenth of the plain refresh''s WAL',
  (select sum(wal_bytes) filter (where query like '%concurrently%')
        < sum(wal_bytes) filter (where query not like '%concurrently%') / 10
   from pg_stat_statements
   where dbid = (select oid from pg_database where datname = current_database())
     and query like 'refresh materialized view%'));

完整实验脚本

在应用中缓存,算清失效成本

对于读取次数远多于修改次数的数据,缓存可以完全免去这些读取。成本在于缓存失效:每条写入路径都得知道自己会让哪些键过时。“写入时删除缓存”能一直有效,直到某次写入发生在你遗忘的地方。短 TTL 能限制数据过时的程度,带版本的键则能精确处理。还有,如果一次主键查找的成本与访问缓存的往返差不多,就别缓存它。

把工作移出请求路径

发送邮件、webhook 和重算汇总,在执行期间都会占着连接。把它们放进任务队列(Postgres 就能很好地胜任:Postgres 队列与业务写入一起提交),它们就会按固定速率执行,而不是跟着用户的点击频率跑。

能重新生成的数据,可以不写 WAL

即使数据能从源头重建,仍要支付完整的 WAL 成本。unlogged 表会省去 WAL(完整权衡见崩溃后,不记日志表会被清空):同样的 476,712 行,写入普通表产生了 77 MB 的 WAL,写入不记日志表则没有;这些字节既不必传给副本,也不必存进归档。它适合可重跑导入任务的暂存表。

不记日志意味着崩溃后会被清空

它在崩溃后会被截断清空,而且不参与复制,因此故障切换后也是空的。只用于能重新生成的数据。事后再改成记日志表,会把全部数据写入 WAL(先使用不记日志表,再 SET LOGGED,只是把 WAL 开销后移)。

深入阅读:测量不记日志表省下的 WAL
create table import_staging_logged   (like events);
create unlogged table import_staging (like events);

insert into import_staging_logged
  select * from events where created_at < '2026-01-01';
insert into import_staging
  select * from events where created_at < '2026-01-01';
                       query                        |  rows  |   wal
----------------------------------------------------+--------+---------
 insert into import_staging_logged select * from ev | 476712 | 77 MB
 insert into import_staging        select * from ev | 476712 | 0 bytes
动手验证 · lab 36-scale
select lab.prove(
  'the same rows into the same shape: tens of megabytes of WAL logged',
  :logged_wal > 20000000);
select lab.prove(
  'and next to none unlogged (under 0.1 percent of it)',
  :unlogged_wal < :logged_wal / 1000.0);

完整实验脚本

归档冷数据行

再也没人读取的行仍有成本:索引更大、清理更多、恢复更慢。控制节奏,分批把它们迁出去,偶尔做一次没问题;如果每晚都做,就永远都得逐行删除。如果数据按时间过期,就分区,一次直接删除一个月。

深入阅读:像回填一样控制归档批次的节奏
create table events_archive (like events including defaults);

with moved as (
  delete from events
  where id in (
    select id from events
    where created_at < '2025-11-01'
    order by id
    limit 10000
  )
  returning *
)
insert into events_archive
select * from moved;

在我们的容器上,每批 10,000 行耗时 70 至 400 ms(第一批缓存是冷的,所以最慢)。像回填那样,在循环中短暂停顿,让副本和清理跟得上。

问题是哪项资源先耗尽

有了充分依据,换台更大的机器就是不错的一步:不用投入工程时间,可以撤回,还能腾出空间从容走后面的台阶。在写作时,主要云厂商已经提供远超 100 vCPU、数 TB RAM 的单机供租用(迁移之前,先证明问题出在磁盘)。所以,问题很少是“单机能装下我们吗”,而是哪项资源先耗尽。

所有写入都汇入同一条 WAL 流

读取能随核心数增加而扩展;所有写入却都汇入同一条 WAL 流。我们对 events 的单行插入,每次产生约 600 字节 WAL:每秒 50,000 次,就是 30 MB/s,每天约 2.6 TB,全部都要刷新、传输和归档。每个备库都用一个进程重放全部 WAL,因此主库只要足够忙,就会把所有副本甩在后面,无论副本多大(重放只有一个进程)。

深入阅读:每次插入 600 字节是怎么来的

pg_stat_statements 记录了开头基准测试中 120,225 次单行插入产生的 72,265,853 字节 WAL,平均每次约 601 字节。每次插入都记录一条携带该行的堆记录、一次主键索引插入和一条提交记录;不时还会遇到检查点后某页的第一次修改,需要记录整个 8 KB 页面,这也算进了平均值。每次提交多行,可以分摊提交记录,但行和索引记录仍在。实验用 1,000 次插入重复了这项测量。

动手验证 · lab 36-scale
-- 1,000 single-row inserts, each its own statement, then:
select lab.prove(
  'a single-row insert into events writes a few hundred bytes of WAL (we saw about 600)',
  (select sum(wal_bytes) / sum(calls) from pg_stat_statements
   where dbid = (select oid from pg_database where datname = current_database())
     and query like 'insert into events (account_id, project_id, user_id, kind) values%')
  between 200 and 1200);

完整实验脚本

一个进程清理整张表

自动清理用一个进程扫描一张表:50 GB 要几分钟,5 TB 要几天,而死行和事务 ID 年龄还在不断增加。冻结我们那张 275 MB 的 events 产生了 278 MB WAL,因为检查点后对每页的第一次修改都要记录整页,所以一张巨表第一次冻结时,会写出约等于自身大小的 WAL(回卷正是不能跳过这项工作的原因)。分区冻结一次后,后续清理就会跳过它们的页面。

深入阅读:vacuum verbose 如何描述整表冻结
frozen: 35029 pages from table (99.56% of total) had 1996020 tuples frozen
visibility map: 4962 pages set all-visible, 35008 pages set all-frozen
                (30046 were all-visible)
...
WAL usage: 70383 records, 35190 full page images, 291082046 bytes,
           2126 buffers full
system usage: CPU: user: 0.22 s, system: 0.24 s, elapsed: 6.01 s

约 35,200 页,产生了 35,190 个整页镜像:几乎每页都完整记入了日志。

动手验证 · lab 36-scale
vacuum (freeze) events;
select lab.prove(
  'freezing every page of events writes WAL on the order of the table''s own size (over half of it)',
  (select sum(wal_bytes) from pg_stat_statements
   where dbid = (select oid from pg_database where datname = current_database())
     and query like 'vacuum%') > :events_bytes / 2);

完整实验脚本

更大的机器解决不了连接和恢复问题

更大的机器解决不了连接数问题:无论有多少核心,几千个进程都要消耗内存,所以要用连接池。人们还常忘记运维的上限:以 500 MB/s 恢复 10 TB,在开始重放 WAL 之前,就得花五个半小时。如果恢复时间目标(RTO)是一小时,那么对于从备份恢复的单个实例而言,这个数据库已经太大了。应在恢复时间决定你的架构之前,测量恢复。

证据说到头了,纵向扩容才算到头

下面每个信号都能测量。“账单看着有点大”不在其中。

  • WAL 的产生速度超过消费速度:副本在正常负载下就落后,而且延迟还在增长。
  • 最大的机器仍以写入等待为主,即使用了快速存储和批量提交。
  • 最大的表无法完成清理。
  • 工作集超出了你买得到的最大 RAM 容量。
  • 恢复时间超过了 RTO。
  • 硬性要求迫使数据分开:数据驻留要求,或合同规定必须隔离某个租户。
  • 对数十亿行做分析,这是另一种引擎的工作(行存与列存)。

不在清单上的理由:CPU 很高,却没人看过 pg_stat_statements;或是“以后也许用得上”。当信号确实出现时,根据耗尽的资源选择下一步:读取或故障切换用副本;保留期限和清理用分区;单个主库的写入或存储不够用,就分片。

本章要点

  • 用数据争取更大的机器:先修好排名靠前的语句,再在申请中指出耗尽了哪项资源并附上证据,才能升级。
  • 简单语句平均耗时很长,说明它在等待,而不是在干活。应该解决等待问题,而不是换硬件。
  • 预先算好仪表盘要展示的内容:汇总表把 35,249 页变成了 4 页,幂等的插入或更新能让它保持新鲜。
  • 绝不要靠每个事务都更新同一行,来维护精确计数器。
  • 不记日志表不写 WAL,崩溃或故障切换后为空。
  • 单台大机器可以撑很久。真正的上限是 WAL 与副本重放、巨表清理,以及恢复时间。

延伸阅读

  • pg_stat_statements,PostgreSQL 文档。所有字段,包括 wal_bytes,以及语句如何归一化。
  • The cumulative statistics system,PostgreSQL 文档。等待事件类型与事件的完整列表,帮助理解会话在等什么。
  • REFRESH MATERIALIZED VIEW,PostgreSQL 文档。CONCURRENTLY 的确切要求,以及何时普通刷新成本更低。
  • Materialized views,PostgreSQL 文档。物化视图的工作原理,以及用它生成汇总的完整示例。
  • CREATE TABLE,PostgreSQL 文档。明确说明 UNLOGGED 的保证:崩溃后截断清空,不参与复制。
  • Table partitioning,PostgreSQL 文档。下一个台阶,以及分区数量方面的最佳实践。

表格阅读