# 先纵向扩容，再横向扩展

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

---

LLMS 索引： [llms.txt](/llms.txt)

---

<span id="basics-chapter-37"></span>

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

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

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

## 先改进工作，再升级硬件 {#hardware-comes-after-the-work}

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

## 用数据争取更大的机器 {#earn-the-bigger-box}

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

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

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

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

**大多数慢数据库都不缺硬件。**只是其中几条语句读取了远超所需的页面。[实证：一个应用，八次改动](/36-playbook/#the-proof-one-app-eight-changes)在同一个应用上走完了这套步骤：同一台笔记本，吞吐量达到原来的约一千倍。

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

### 升级规格的申请要附上六项证据 {#an-upsize-request-brings-six-pieces-of-evidence}

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

1. **排在前面的语句，以及每条语句做了什么处理。**对 `pg_stat_statements` 按总时间和共享块数排序（[四种排名](/31-observability/#rank-the-same-view-four-ways-total-time-first)），取繁忙时段的统计差值。在前五条语句旁分别注明：已修复、为何无法修复，或为何已经足够便宜。
2. 每项修复**前后每次调用的页面数**。用页面数，不用毫秒数，因为页面数不会随负载变化。[第一个索引：35,249 页降到 91 页](/17-btree/#the-first-index-35249-pages-to-91)就是一条好的记录。
3. **能指明某项资源的等待分布**，在出现问题的负载下[采样](/31-observability/#sample-waits-to-see-where-the-time-goes)得到。更大的机器能帮助正在使用 CPU、等待 `IO:DataFileRead` 或缺少内存的会话。对于 `Lock` 等待、连接耗尽或工作负载的组织方式不当，它无能为力。
4. **[工作集](/43-glossary/#working-set)（working set）与 RAM 的对比**（[根据工作集配置 RAM](/03-latency/#size-ram-to-the-working-set)），以及读取量的变化趋势。如果工作集已经放得下，增加 RAM 没有收益。
5. **耗尽的资源与资源余量**：峰值离上限还有多远，以你的增长速度，新规格能撑几个月，都要[如实测量](/29-benchmarking/#ask-one-question)。
6. **已经试过并排除的办法**：索引、改写、[汇总表](#precompute-what-you-show)、连接池、配置、清理，以及等待分布指向 CPU 时，[把逐行处理的工作移出主库](/24-functions/#the-primary-is-your-most-expensive-cpu)。每项写一句话，不能只打个勾。

### 把申请写成工单 {#the-request-as-a-ticket}

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

```text
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>
```

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

## 买任何东西之前，先测量 {#measure-before-you-buy-anything}

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

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

```text
    192 LWLock WALWrite
     30 IO WalSync
     14 CPU running
     ...
```

```text
    161 Lock transactionid
     48 Lock tuple
     29 IO WalSync
     ...
```

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

> [!EXAMPLE]- 动手验证 · lab `36-scale`
>
> ```sql
> -- 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));
> ```
>
> [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/36-scale.sql)

<span id="the-statement-ranking-and-the-wait-sampler-behind-those-numbers" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-37-the-statement-ranking-and-the-wait-sampler-behind-those-numbers" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：这些数字背后的语句排名与等待采样器
>
> 各种瓶颈的表现及常见解法：
>
> | 瓶颈 | 看到的现象 | 常见解法 |
> |---|---|---|
> | CPU | 核心满载，等待事件大多为空 | 先修执行工作的查询，再增加核心 |
> | I/O | `DataFileRead` 等待，缓存未命中 | 索引、减少每次查询的数据量、增加 RAM |
> | 锁 | `Lock` 等待：`transactionid`、`tuple` | 改变工作方式，让会话不再互相争用 |
> | 连接 | 几百个空闲后端进程，连接池超时 | 使用连接池器，缩小连接池 |
> | 一条热点查询 | 一条语句占据了大部分总时间 | 修好那条查询 |
> 
> <span id="tbl-37-1"></span>
> 
> **Table 37-1.** 买任何东西之前，先测量

>
> 开头基准测试中的排名：
>
> ```sql
> 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;
> ```
>
> ```text
>              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` 筛选条件换成你的应用）：
>
> ```bash
> 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`。

## 修好找出来的查询 {#fix-the-query-you-found}

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

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

<span id="the-september-chart-before-and-after-the-index" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-37-the-september-chart-before-and-after-the-index" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：加索引前后的九月图表
>
> ```sql
> 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;
> ```
>
> ```text
>  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
> ```
>
> 加上索引后：
>
> ```text
>  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
> ```
>
> > [!EXAMPLE]- 动手验证 · lab `36-scale`
> >
> > ```sql
> > 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);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/36-scale.sql)

## 预先算好要展示的内容 {#precompute-what-you-show}

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

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

| 查询 | 原始事件 | 汇总表 |
|---|---|---|
| 一个账户，30 天（无索引） | 35,249 页 | 4 页 |
| 一个账户，30 天（有索引） | 156 页 | 4 页 |
| 一个季度中部署失败次数最多的 10 个账户 | 14,846 页 | 4,218 页 |

<span id="tbl-37-2"></span>

**Table 37-2.** 预先算好要展示的内容


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

> [!EXAMPLE]- 动手验证 · lab `36-scale`
>
> ```sql
> 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);
> ```
>
> [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/36-scale.sql)

<span id="building-the-rollup-and-the-quarterly-report-on-both-sides" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-37-building-the-rollup-and-the-quarterly-report-on-both-sides" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：构建汇总表，对比两种季度报表
>
> ```sql
> 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 页，但也不过几十页。
>
> 季度“谁的部署失败最多”报表，先看原始数据，再看汇总表：
>
> ```text
>  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
> ```
>
> ```text
>  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 个；普通扫描的代价也差不多。

### 用插入或更新保持新鲜 {#keep-it-fresh-with-an-upsert}

汇总表好不好，取决于数据够不够新。最稳妥的刷新方式，是定时任务从昨天零点开始重算最近的时间窗口，再用 `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 一个不少（[更新就是插入](/06-mvcc/#basics-chapter-6)）。要重算完整的天，而不是“上次看到的 id 之后的所有数据”，后者会漏掉晚提交的事务。每次只运行一个刷新任务，并显示“更新于一分钟前”。

<span id="the-refresh-statement-and-its-plan-run-twice" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-37-the-refresh-statement-and-its-plan-run-twice" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：刷新语句，以及执行两次的计划
>
> 它需要在 `created_at` 上建索引，以查找最近的事件：
>
> ```sql
> 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'`，这样无论何时运行演示，结果都一样：
>
> ```text
>  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` 保护任务（[咨询锁](/25-concurrency/#take-advisory-locks-per-transaction-not-per-session)）。
>
> > [!EXAMPLE]- 动手验证 · lab `36-scale`
> >
> > ```sql
> > 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);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/36-scale.sql)

### 不要用热点行计数 {#dont-count-with-a-hot-row}

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

## 物化视图会全部重算 {#a-materialized-view-recomputes-everything}

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

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

<span id="plain-refresh-or-concurrent-what-each-costs" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-37-plain-refresh-or-concurrent-what-each-costs" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：普通刷新与并发刷新，各自花在哪里
>
> ```sql
> 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;
> ```
>
> ```text
> 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` 中的记录如下：
>
> ```text
>                             query                            | mean_ms | wal_bytes
> -------------------------------------------------------------+---------+-----------
>  refresh materialized view project_activity_30d              |      41 |   2152086
>  refresh materialized view concurrently project_activity_30d |     181 |     27601
> ```
>
> 如果每次刷新大多数行都会改变，只要能忍受锁，普通刷新就更快、更省。汇总表还允许你自行选择索引，保留源查询会丢弃的历史数据。
>
> > [!EXAMPLE]- 动手验证 · lab `36-scale`
> >
> > ```sql
> > 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%'));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/36-scale.sql)

## 在应用中缓存，算清失效成本 {#cache-in-the-app-and-price-the-invalidation}

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

## 把工作移出请求路径 {#move-work-off-the-request-path}

发送邮件、webhook 和重算汇总，在执行期间都会占着连接。把它们放进任务队列（Postgres 就能很好地胜任：[Postgres 队列与业务写入一起提交](/25-concurrency/#a-postgres-queue-commits-with-the-business-write)），它们就会按固定速率执行，而不是跟着用户的点击频率跑。

## 能重新生成的数据，可以不写 WAL {#skip-the-wal-for-data-you-can-regenerate}

即使数据能从源头重建，仍要支付完整的 WAL 成本。`unlogged` 表会省去 WAL（完整权衡见[崩溃后，不记日志表会被清空](/15-models/#an-unlogged-table-is-emptied-after-a-crash)）：同样的 476,712 行，写入普通表产生了 77 MB 的 WAL，写入不记日志表则没有；这些字节既不必传给副本，也不必存进归档。它适合可重跑导入任务的暂存表。

> [!WARNING] 不记日志意味着崩溃后会被清空
>
> 它在崩溃后会被截断清空，而且不参与复制，因此故障切换后也是空的。只用于能重新生成的数据。事后再改成记日志表，会把全部数据写入 WAL（[先使用不记日志表，再 SET LOGGED，只是把 WAL 开销后移](/27-loading/#unlogged-then-set-logged-moves-the-wal)）。

<span id="measuring-the-wal-an-unlogged-table-saves" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-37-measuring-the-wal-an-unlogged-table-saves" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：测量不记日志表省下的 WAL
>
> ```sql
> 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';
> ```
>
> ```text
>                        query                        |  rows  |   wal
> ----------------------------------------------------+--------+---------
>  insert into import_staging_logged select * from ev | 476712 | 77 MB
>  insert into import_staging        select * from ev | 476712 | 0 bytes
> ```
>
> > [!EXAMPLE]- 动手验证 · lab `36-scale`
> >
> > ```sql
> > 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);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/36-scale.sql)

## 归档冷数据行 {#archive-cold-rows}

再也没人读取的行仍有成本：索引更大、清理更多、恢复更慢。控制节奏，分批把它们迁出去，偶尔做一次没问题；如果每晚都做，就永远都得逐行删除。如果数据按时间过期，就[分区](/39-partitioning/#basics-chapter-39)，一次直接删除一个月。

<span id="one-archive-batch-paced-like-a-backfill" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-37-one-archive-batch-paced-like-a-backfill" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：像回填一样控制归档批次的节奏
>
> ```sql
> 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（第一批缓存是冷的，所以最慢）。像[回填](/26-migrations/#backfill-in-short-batches-that-walk-the-primary-key)那样，在循环中短暂停顿，让副本和清理跟得上。

## 问题是哪项资源先耗尽 {#the-question-is-which-resource-runs-out-first}

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

### 所有写入都汇入同一条 WAL 流 {#every-write-funnels-into-one-wal-stream}

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

<span id="where-the-600-bytes-per-insert-came-from" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-37-where-the-600-bytes-per-insert-came-from" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：每次插入 600 字节是怎么来的
>
> `pg_stat_statements` 记录了开头基准测试中 120,225 次单行插入产生的 72,265,853 字节 WAL，平均每次约 601 字节。每次插入都记录一条携带该行的堆记录、一次主键索引插入和一条提交记录；不时还会遇到检查点后某页的第一次修改，需要记录整个 8 KB 页面，这也算进了平均值。每次提交多行，可以分摊提交记录，但行和索引记录仍在。实验用 1,000 次插入重复了这项测量。
>
> > [!EXAMPLE]- 动手验证 · lab `36-scale`
> >
> > ```sql
> > -- 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);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/36-scale.sql)

### 一个进程清理整张表 {#one-process-vacuums-the-whole-table}

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

<span id="what-vacuum-verbose-says-about-freezing-a-whole-table" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-37-what-vacuum-verbose-says-about-freezing-a-whole-table" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：vacuum verbose 如何描述整表冻结
>
> ```text
> 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 个整页镜像：几乎每页都完整记入了日志。
>
> > [!EXAMPLE]- 动手验证 · lab `36-scale`
> >
> > ```sql
> > 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);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/36-scale.sql)

### 更大的机器解决不了连接和恢复问题 {#a-bigger-box-fixes-neither-connections-nor-restores}

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

## 证据说到头了，纵向扩容才算到头 {#youre-done-scaling-up-when-the-evidence-says-so}

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

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

不在清单上的理由：CPU 很高，却没人看过 `pg_stat_statements`；或是“以后也许用得上”。当信号确实出现时，根据耗尽的资源选择下一步：读取或故障切换用[副本](/38-replication/#basics-chapter-38)；保留期限和清理用[分区](/39-partitioning/#basics-chapter-39)；单个主库的写入或存储不够用，就[分片](/40-sharding/#basics-chapter-40)。

## 本章要点 {#the-short-version}

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

## 延伸阅读 {#further-reading}

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