返回文章列表

PostgreSQL 容量规划、读扩展、分片与水平扩展

从可持续容量、容量余量、读副本、分区、应用层分片、分片键、Fan-Out、跨分片事务、重分片与 Go Shard Router 出发,理解 PostgreSQL 水平扩展设计。

第 19 章:容量规划、读扩展、分片与水平扩展

技术基线:PostgreSQL 18、Go、pgx/v5pgxpool。截至 2026 年 6 月,PostgreSQL 18 是当前稳定主版本,PostgreSQL 19 仍属于开发版本。(PostgreSQL)

版本与本章直接相关的能力
PostgreSQL 14in_hot_standby 可直接判断当前会话是否处于 Hot Standby
PostgreSQL 15逻辑复制 Publication 支持列列表和行过滤
PostgreSQL 16引入 pg_stat_io;支持从物理 Standby 进行逻辑解码
PostgreSQL 17引入 pg_stat_checkpointer;增强逻辑复制槽 Failover,加入 pg_createsubscriber
PostgreSQL 18引入 AIO 子系统、uuidv7()、按字节统计的 pg_stat_io、每 Backend I/O/WAL 统计

这些版本差异分别来自各主版本的官方 Release Notes。(PostgreSQL)


1. 本章定位

本章解决的是:当单个 Primary 的容量、延迟或故障域已经不能满足业务要求时,系统应该如何扩展。

真正的生产问题通常不是“表有十亿行,所以要分片”,而是以下某一个可测量边界已经逼近:

  • CPU 长时间饱和,排队持续增长;
  • 热数据集无法被内存和 Page Cache 有效容纳;
  • 数据盘、WAL 盘、网络带宽或同步写延迟达到边界;
  • 单个热点行、索引页或租户限制了写吞吐;
  • Primary 的读取负载挤占写入、Vacuum 和复制资源;
  • 连接池等待已经成为主要延迟来源;
  • 单个故障域的 RPO、RTO 或爆炸半径不可接受;
  • 即使经过 SQL、索引、数据模型、缓存和垂直扩展,容量余量仍不足。

本章依赖:

  • 第 6~8 章的执行计划和索引能力;
  • 第 11~13 章的锁、热点、WAL 和 Checkpoint;
  • 第 14 章的分区表;
  • 第 16~18 章的 pgx、连接池、Outbox 和性能工程。

本章不详细展开:

  • 备份与 PITR 的具体操作;
  • 物理复制参数;
  • 逻辑复制协议实现;
  • Patroni、Fencing 和自动故障转移。

这些内容将在后续章节深入讨论,但本章会分析它们在多分片环境中的系统影响。


2. 可验证的学习目标

完成本章后,应能够:

  1. 根据 P95/P99、TPS、连接池等待、WAL、I/O 和复制延迟定义单节点的可持续容量。
  2. 证明瓶颈究竟位于 CPU、内存、I/O、WAL、锁、连接还是应用排队。
  3. 按照“优化、垂直扩展、缓存、读副本、分区、拆分、分片”的顺序完成选型。
  4. 解释为什么读副本不能增加 Primary 的写吞吐。
  5. 准确区分分区、应用层分片和数据库级分片。
  6. 为 Tenant、Hash、Range 和 Directory-Based Sharding 选择分片键。
  7. 使用负载而不是行数判断数据倾斜和 Hot Shard。
  8. 设计有界 Fan-Out、跨分片排序、聚合和 Keyset Pagination。
  9. 判断跨分片事务应使用本地事务、2PC、Saga 还是 Outbox。
  10. 设计包含 Backfill、CDC、校验、Fencing、Cutover 和 Rollback 的 Resharding 流程。
  11. 为每个分片设计连接池预算、复制、备份、Failover 和可观测性。
  12. 使用 Go 和 pgxpool 实现一个有界、可取消、支持部分失败的 Shard Router。

3. 核心术语

中文名称英文名称准确定义易混淆概念层次
可持续容量Sustainable Capacity在代表性工作负载下,长期满足延迟、错误率和稳定性 SLO 的最大负载瞬时峰值系统
容量余量Headroom可持续容量与当前峰值需求之间的安全空间空闲 CPU 百分比系统
垂直扩展Vertical Scaling增加单个节点的 CPU、内存、存储或网络能力水平扩展基础设施
读扩展Read Scaling将可容忍一致性延迟的读取转移到缓存或副本写扩展架构
分区Partitioning在同一 PostgreSQL 集群内将一个逻辑表拆为多个物理子表分片数据库
分片Sharding将数据分布到多个独立数据库或故障域分区分布式系统
分片键Shard Key决定一行数据应位于哪个分片的业务属性主键、分区键数据模型
虚拟分片Virtual Shard位于业务键和物理分片之间的稳定逻辑桶PostgreSQL 分区路由层
租户分片Tenant Sharding将同一租户的关联数据放到同一分片Schema-per-tenant数据模型
哈希分片Hash Sharding对分片键执行稳定哈希后映射到分片Hash Partitioning路由层
范围分片Range Sharding按连续键范围分配数据时间分区路由层
目录分片Directory-Based Sharding通过显式元数据表查询实体所在分片一致性哈希控制面
热分片Hot Shard请求、CPU、WAL、锁或数据增长显著高于其他分片大分片运行时
数据倾斜Data Skew数据量或负载在分片间分布不均热点键运行时
扇出查询Fan-Out Query将一个逻辑查询发送到多个分片并合并结果并行查询查询层
全局标识Global ID在所有分片中唯一的业务标识全局顺序数据模型
双路由Dual Routing迁移期间同时保留源和目标位置的路由信息双写控制面
双写Dual Write一个业务请求直接向两个独立数据库提交写入CDC数据路径
两阶段提交Two-Phase Commit先 Prepare 所有参与者,再统一 Commit 或 RollbackSaga事务
补偿事务Saga将全局业务拆为多个本地事务,并通过补偿处理失败数据库回滚业务层
事务发件箱Transactional Outbox在本地业务事务中原子写入业务数据和待发布事件直接双写数据路径
重分片Resharding在线改变数据到物理分片的映射Rebalance运维
路由世代Route Epoch用于识别和拒绝陈旧路由写入的单调递增版本Schema Version控制面

4. 整体心智模型

4.1 扩展决策树

PostgreSQL 容量规划、读扩展、分片与水平扩展 flow 1

顺序非常重要:

SQL/索引优化 → 数据模型优化 → 垂直扩展 → 缓存 → 读副本 → 分区 → 工作负载拆分 → 分片

分片不是性能调优的第一步,而是将单机数据库问题转化为分布式系统问题。它可能增加总容量,但同时引入路由、部分失败、跨分片查询、全局约束、迁移和多故障域恢复等复杂度。

4.2 分片系统整体结构

PostgreSQL 容量规划、读扩展、分片与水平扩展 flow 2

4.3 四类流

数据流

  1. 请求带上 tenant_id 或其他 Shard Key。
  2. Router 查询本地元数据缓存。
  3. Router 选择分片和该分片的连接池。
  4. 强一致读取和写入进入 Primary。
  5. 可接受陈旧数据的读取进入 Replica。
  6. Fan-Out 查询进入多个分片,结果在应用层归并。

控制流

  • Metadata Service 决定逻辑键到物理分片的映射。
  • Route Epoch 决定某个写入是否仍使用有效路由。
  • Resharding Controller 驱动 Backfill、CDC、校验和 Cutover。
  • Admission Control 限制总请求、单租户请求和 Fan-Out 并发。

状态变化

典型租户迁移状态为:

HOME_OLD
→ COPYING
→ CATCHING_UP
→ VALIDATING
→ FENCING
→ HOME_NEW
→ FINALIZING

故障路径

  • Replica 延迟:读取陈旧或回退 Primary。
  • 单分片 Primary 故障:仅该分片不可写,但 Router 必须快速识别拓扑变化。
  • Metadata 不可用:已有缓存路由可短时工作;未知或迁移中的写入应 Fail Closed。
  • Fan-Out 部分失败:根据 API 契约返回部分结果、整体失败或降级结果。
  • CDC 失败:源分片继续作为唯一写入权威,目标不得提前 Cutover。
  • 陈旧 Router 写源分片:必须通过 Route Epoch 或 Fencing 拒绝。

5. 使用方式

5.1 不要按表行数定义容量

“表有多少行”不能直接回答是否需要分片。

一张数十亿行、只追加、按时间分区、查询高度可裁剪的表,可能长期运行良好。另一张只有几百万行、所有请求都更新同一个租户余额或索引末端页的表,可能已经达到并发边界。

应定义工作负载向量:

W = {
    读取 QPS,
    写入 TPS,
    每事务 SQL 数量,
    每操作行数,
    平均与 P99 行宽,
    随机/顺序 I/O 比例,
    WAL bytes/s,
    活跃事务数,
    Fan-Out 宽度,
    热租户占比,
    复制因子,
    SLO
}

可持续容量不是压测中出现过的最高 TPS,而是:

在代表性数据量、数据分布、缓存冷热状态和并发下,经历多个 Checkpoint、Autovacuum 和业务峰谷周期后,仍满足 P95/P99、错误率、复制延迟和队列稳定性要求的最高持续负载。

容量余量可表示为:

Headroom = 1 - PeakDemand / SustainableCapacity

该公式必须分别应用于:

  • CPU;
  • 主数据盘读写;
  • WAL 写入;
  • 网络;
  • 连接;
  • 每个热分片;
  • CDC 和复制带宽;
  • 恢复带宽。

不能只看整套集群的平均值。平均利用率 30% 可能掩盖某个热分片已经达到 95%。

对于可重新分配负载的 N 个同构节点,简单的 N+1 估算为:

FailoverUtilization ≈ NormalUtilization × N / (N - 1)

这只是容量检查公式,不是统一的安全阈值。最终边界必须由硬件、工作负载、排队曲线和 SLO 实测确定。


5.2 证明真正瓶颈

5.2.1 版本和数据规模

SELECT version();

SELECT
    datname,
    pg_size_pretty(pg_database_size(datname)) AS database_size
FROM pg_database
WHERE datallowconn
ORDER BY pg_database_size(datname) DESC;

SELECT
    relid::regclass AS relation,
    pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
    n_live_tup,
    n_dead_tup
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 30;

5.2.2 数据库级吞吐和临时文件

SELECT
    datname,
    numbackends,
    xact_commit,
    xact_rollback,
    blks_read,
    blks_hit,
    temp_files,
    temp_bytes,
    deadlocks,
    stats_reset
FROM pg_stat_database
WHERE datname = current_database();

容量判断必须使用固定时间窗口内的增量,不能直接比较两个不同重置时间的累计值。

5.2.3 Top SQL

pg_stat_statements 需要加入 shared_preload_libraries,首次启用通常需要重启,然后在目标数据库执行 CREATE EXTENSION pg_stat_statements。它能记录调用次数、执行时间、Buffer、Temporary Block 和 WAL 等指标。(PostgreSQL)

SELECT
    queryid,
    calls,
    round(total_exec_time::numeric, 2) AS total_exec_ms,
    round(mean_exec_time::numeric, 2) AS mean_exec_ms,
    rows,
    shared_blks_hit,
    shared_blks_read,
    temp_blks_written,
    wal_records,
    wal_fpi,
    wal_bytes,
    left(query, 160) AS sample
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 30;

重点观察:

  • total_exec_time:总体资源消耗;
  • mean_exec_time:平均延迟,不等于 P99;
  • shared_blks_read:进入 PostgreSQL Buffer 的块读取;
  • temp_blks_written:排序或 Hash 溢出;
  • wal_bytes:该 SQL 产生的写放大;
  • calls:低延迟但高频 SQL 也可能是首要成本。

5.2.4 I/O

pg_stat_io 从 PostgreSQL 16 开始提供;PostgreSQL 18 增加 read_byteswrite_bytesextend_bytes 和 WAL I/O 行。该视图不能区分数据来自物理磁盘还是操作系统 Page Cache,因此必须结合操作系统指标。(PostgreSQL)

-- [PG18]
SELECT
    backend_type,
    object,
    context,
    reads,
    read_bytes,
    round(read_time::numeric, 2) AS read_ms,
    writes,
    write_bytes,
    round(write_time::numeric, 2) AS write_ms,
    evictions,
    fsyncs,
    round(fsync_time::numeric, 2) AS fsync_ms,
    stats_reset
FROM pg_stat_io
ORDER BY
    COALESCE(read_time, 0) + COALESCE(write_time, 0) DESC;

5.2.5 WAL

-- [PG18]
SELECT
    wal_records,
    wal_fpi,
    wal_bytes,
    wal_buffers_full,
    stats_reset
FROM pg_stat_wal;

wal_bytes 是累计 WAL 字节数,wal_fpi 是 Full Page Image 数量,wal_buffers_full 上升表示 WAL Buffer 曾因填满而触发写出。(PostgreSQL)

5.2.6 Checkpoint

-- [PG18]
SELECT
    num_timed,
    num_requested,
    num_done,
    write_time,
    sync_time,
    buffers_written,
    stats_reset
FROM pg_stat_checkpointer;

pg_stat_checkpointer 从 PostgreSQL 17 开始独立提供;PostgreSQL 18 增加了 num_done。(PostgreSQL)

5.2.7 连接与等待

SELECT
    datname,
    application_name,
    state,
    wait_event_type,
    wait_event,
    count(*) AS sessions
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY
    datname,
    application_name,
    state,
    wait_event_type,
    wait_event
ORDER BY sessions DESC;

必须区分:

  • active:正在执行 SQL;
  • idle:占有 Backend,但未执行 SQL;
  • idle in transaction:事务未结束,可能持有锁和旧 Snapshot;
  • wait_event_type = 'Lock':重量级锁等待;
  • wait_event_type = 'IO':等待 I/O;
  • wait_event_type = 'Client':数据库通常在等应用。

5.2.8 复制延迟

SELECT
    application_name,
    client_addr,
    state,
    sync_state,
    sent_lsn,
    write_lsn,
    flush_lsn,
    replay_lsn,
    pg_size_pretty(
        pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)::bigint
    ) AS replay_lag_bytes,
    write_lag,
    flush_lag,
    replay_lag
FROM pg_stat_replication;

replay_lsn 表示 Standby 已经重放到的位置。(PostgreSQL)


5.3 每一种扩展手段解决什么问题

阶段适用证据不能解决的问题
SQL/索引优化少数 SQL 占主要执行时间、读块或临时文件根本性的单写域边界
数据模型优化行宽过大、主键随机、约束缺失、过度更新总数据和请求持续增长
垂直扩展CPU、RAM、I/O 可通过更高规格经济扩展单机故障域、物理上限
缓存重复读取、高命中、允许有限陈旧强一致写入和热点更新
读副本Primary 主要被只读查询占用写吞吐
分区查询可裁剪、历史数据维护成本高跨机器写扩展
工作负载拆分OLTP、Analytics、Search 相互干扰单一 OLTP 域内部热点
分片单机写入、容量或故障域已被证实全局事务和热点键复杂度

5.4 垂直扩展

垂直扩展通常是分片前成本最低的手段。

CPU

增加 CPU 适用于:

  • 查询计算和 Join 已经优化;
  • 并行查询确实能利用更多 Core;
  • 高并发下 CPU Run Queue 持续升高;
  • 锁和 I/O 不是主要等待。

增加 CPU 不能修复:

  • 单热点行;
  • 单索引页竞争;
  • WAL Flush 延迟;
  • 无界连接数;
  • Fan-Out 放大。

内存

更多内存可能扩大:

  • PostgreSQL shared_buffers
  • 操作系统 Page Cache;
  • Sort 和 Hash 的可用工作内存;
  • 连接和后台进程的总体容纳能力。

但必须注意:

潜在工作内存
≈ 活跃查询数
× 每查询同时活动的 Sort/Hash 节点数
× work_mem

不能把 work_mem × max_connections 当作精确内存,也不能忽视一个查询含多个可同时分配内存的执行节点。

存储

需要分别识别:

  • 随机读取延迟;
  • 顺序扫描带宽;
  • WAL 顺序写入和 Flush 延迟;
  • Checkpoint 写入峰值;
  • Vacuum 和 Backfill 带宽;
  • Resharding 的源读、目标写和 CDC 叠加。

PostgreSQL 18 的 AIO 能让 Backend 排队多个读取请求,并改善顺序扫描、Bitmap Heap Scan、Vacuum 等操作,但它不会消除底层设备的 IOPS、带宽和延迟上限。(PostgreSQL)


5.5 读副本与读写分离

Hot Standby 连接是严格只读的,连临时表都不能写。Standby 数据只有在提交记录被重放后,才对新的 Snapshot 可见,因此它与 Primary 是最终一致的。(PostgreSQL)

因此:

读副本能够转移读取、报表和备份负载,但不能扩展 Primary 的业务写入。

它可能间接释放 Primary 的 CPU 和数据盘读取能力,但写事务仍必须在 Primary 上:

  • 生成 Tuple;
  • 修改索引;
  • 获取锁;
  • 产生 WAL;
  • 完成提交持久化。

一致性路由策略

读取类型推荐路由
余额、权限、刚提交订单Primary
用户刚写后立即读Primary Stickiness 或 LSN 等待
商品目录、历史订单Replica
报表、导出、离线计算专用 Replica 或分析库
Replica Lag 超标回退 Primary、返回陈旧标记或拒绝

LSN Read-Your-Writes Token

在成功提交后的同一 Primary 连接上取得一个保守的 WAL 位置:

SELECT pg_current_wal_insert_lsn();

在 Replica 上检查:

SELECT COALESCE(
    pg_last_wal_replay_lsn() >= $1::pg_lsn,
    false
);

应用在有限时间内轮询;超时后回退 Primary 或明确返回“数据仍在同步”。

该位置可能晚于当前事务的精确 Commit LSN,因此可能多等待一小段 WAL,但不会少等。

Recovery Conflict

Standby 上的长查询可能与 Primary 已经发生的 DDL、Vacuum Cleanup 或其他 WAL 重放动作冲突。PostgreSQL 必须在“延迟 WAL 重放”和“取消 Standby 查询”之间选择,不能无限等待。(PostgreSQL)

因此,分析型副本需要同时监控:

  • Replay Lag;
  • 被取消查询数;
  • 长事务;
  • pg_stat_database_conflicts
  • Primary Bloat;
  • hot_standby_feedback 带来的清理延迟风险。

5.6 缓存、Materialized View 与汇总表

Cache-Aside

1. 读取缓存
2. Miss 后读取 PostgreSQL
3. 将结果写入缓存
4. 写事务提交后,通过 Outbox/CDC 失效缓存

生产设计必须包括:

  • TTL;
  • TTL 抖动;
  • Singleflight 或 Request Coalescing;
  • Negative Cache;
  • 最大对象大小;
  • 租户级缓存配额;
  • 缓存不可用时的回源限流;
  • 删除、更新和事务提交后的失效语义。

缓存不能把最终一致读取伪装成强一致读取。

Materialized View

Materialized View 保存查询结果,可通过刷新重新计算:

CREATE MATERIALIZED VIEW tenant_daily_revenue AS
SELECT
    tenant_id,
    created_at::date AS business_date,
    sum(amount_cents) AS revenue_cents
FROM orders
GROUP BY tenant_id, created_at::date;

CREATE UNIQUE INDEX tenant_daily_revenue_uq
ON tenant_daily_revenue (tenant_id, business_date);

REFRESH MATERIALIZED VIEW CONCURRENTLY tenant_daily_revenue;

REFRESH MATERIALIZED VIEW 会替换物化视图内容。CONCURRENTLY 可以避免阻塞普通查询,但要求物化视图已经填充,并存在覆盖所有行、仅由普通列组成的 UNIQUE 索引;同一个物化视图一次只能运行一个 Refresh。(PostgreSQL)

汇总表

高频增量统计更适合显式汇总表:

INSERT INTO tenant_daily_revenue (
    tenant_id,
    business_date,
    revenue_cents
)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, business_date)
DO UPDATE
SET revenue_cents =
    tenant_daily_revenue.revenue_cents
    + EXCLUDED.revenue_cents;

但这一行本身可能成为热点。可进一步设计为:

tenant_id + business_date + bucket

读取时再 SUM 多个 Bucket。


5.7 分区不是分片

PostgreSQL 分区表的父表本身是没有存储的虚拟表,数据位于普通的子表中,插入时由分区键路由到目标分区。(PostgreSQL)

CREATE TABLE events (
    tenant_id  bigint      NOT NULL,
    event_id   uuid        NOT NULL,
    created_at timestamptz NOT NULL,
    payload    jsonb       NOT NULL,
    PRIMARY KEY (tenant_id, event_id, created_at)
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_06
PARTITION OF events
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

分区主要改善:

  • Partition Pruning;
  • 历史数据快速 Detach/Drop;
  • 单个索引尺寸;
  • Vacuum 和维护范围;
  • 分层存储和生命周期管理。

它仍然共享:

  • 同一个 Primary;
  • 同一组 Backend;
  • 同一份 WAL;
  • 同一个 Buffer Pool;
  • 同一个故障域;
  • 同一个写入协调域。

因此:

分区可以让大表更易管理,但不是跨机器水平写扩展。

分区表上的 PRIMARY KEYUNIQUE 约束通常必须包含所有分区键,因为单个子分区索引只能直接保证分区内部唯一。(PostgreSQL)


5.8 分片模型

模型路由方式优点主要风险
Tenant Shardingtenant_id → shard租户内事务和 Join 易保持本地大租户成为 Hot Shard
Hash Shardinghash(key) → bucket → shard通常较均匀范围查询 Fan-Out,扩容需迁移
Range Shardingrange(key) → shard范围查询局部化最新范围、单地域可能过热
Directory-Based查元数据目录任意迁移和隔离元数据服务成为关键控制面
Schema-BasedSchema 映射到节点适合租户 Schema 隔离Schema 数和迁移管理复杂
Database-per-Tenant一个或一组租户一个 Database隔离强Pool、DDL、备份和运维对象爆炸

分片键选择标准

优秀的 Shard Key 应尽量满足:

  1. 高频查询通常携带该键。
  2. 高频事务中的相关数据能够共置。
  3. 键值稳定,不会频繁改变。
  4. 请求、CPU、WAL 和容量增长而不是仅按行数分布。
  5. 高基数且不存在少量超大键。
  6. 数据迁移可以按该键独立完成。
  7. 满足地域、合规和数据驻留要求。
  8. 能通过 Route Epoch 识别陈旧写入。

Hot Shard 与 Hot Key

  • Hot Shard:某个物理分片总体过热。
  • Hot Key:某一个租户、账户、商品或计数器过热。
  • Hot Row:并发事务争用同一个 Tuple。
  • Hot Index Page:大量插入或更新争用同一索引页。

增加物理分片只能解决部分 Hot Shard。

它不能自动拆开:

hash(tenant_id = 42)

因为同一个键始终映射到同一个逻辑桶。

虚拟分片

tenant_id
   ↓ stable_hash
virtual_bucket
   ↓ metadata mapping
physical_shard

虚拟分片的优点是迁移粒度更小:扩容时移动 Bucket,而不必改变所有键的哈希结果。

但它仍然无法拆分一个单独的热租户,除非:

  • 将该租户迁移到专用分片;
  • 使用二级键,如 (tenant_id, entity_id)
  • 对热点业务做 Bucket 化;
  • 将读、写或分析工作负载拆开。

5.9 Global ID、UUID 与 Snowflake 类 ID

本地 Sequence

每个分片独立使用:

GENERATED BY DEFAULT AS IDENTITY

会产生跨分片冲突。可改为:

(shard_id, local_sequence)

但业务主键会暴露物理拓扑,并增加迁移难度。

UUIDv4

优点:

  • 无需中心服务;
  • 跨分片碰撞概率极低;
  • 客户端可提前生成。

缺点:

  • 随机插入对 B-tree 局部性不友好;
  • 本身不携带路由信息;
  • 不能表示全局提交顺序。

UUIDv7

PostgreSQL 18 内置 uuidv7()。UUIDv7 按时间有序,包含毫秒、亚毫秒和随机部分。(PostgreSQL)

SELECT uuidv7();

它能改善时间局部性,但仍然:

  • 不是严格的全局事务顺序;
  • 不代表数据库 Commit 顺序;
  • 可能暴露大致创建时间;
  • 不能自动确定分片位置。

Snowflake 类 ID

典型结构包含:

时间戳 + Worker ID + 每时间片序列

必须解决:

  • Worker ID 唯一租约;
  • 时钟回拨;
  • 单时间片序列耗尽;
  • 位宽升级;
  • 多地域时钟偏差;
  • ID 生成服务降级;
  • Worker ID 重用;
  • 时间溢出策略。

Snowflake 类 ID 只解决“分布式唯一且大致有序”,不自动解决:

  • 数据路由;
  • 全局唯一约束;
  • 跨分片事务;
  • 全局分页的一致 Snapshot。

5.10 路由、Fan-Out、排序、聚合与分页

路由元数据

生产级路由记录至少包含:

routing_key
write_shard
read_shards
shadow_shard
state
route_epoch
updated_at

Router 应:

  • 本地缓存路由;
  • 使用单调 Route Epoch;
  • 对迁移中写入进行 Fencing;
  • 对未知写路由 Fail Closed;
  • 记录实际访问的分片;
  • 限制缓存陈旧时间;
  • 在元数据变更后主动失效本地缓存。

Fan-Out 查询

假设一个请求访问 N 个分片,每个分片独立失败概率为 p,至少一个失败的近似概率为:

1 - (1 - p)^N

实际分片故障并不完全独立,但该公式说明:Fan-Out 越宽,遇到慢节点或失败节点的机会越大。

全局延迟通常接近:

最慢分片延迟 + 应用归并时间 + 网络开销

必须限制:

  • 单请求 Fan-Out 宽度;
  • 单请求并行查询数;
  • 全局 Fan-Out 并发;
  • 每分片并发;
  • 每分片超时;
  • 返回行数;
  • 内存中的合并结果大小。

跨分片排序

对于全局 Top K:

  1. 每个分片使用完全相同的过滤和排序。
  2. 每个分片返回本地 Top K。
  3. Router 使用 (sort_key, global_id) 作为全序键进行 K 路归并。
  4. 只保留全局前 K 条。
SELECT ...
FROM orders
WHERE created_at < $1
ORDER BY created_at DESC, order_id DESC
LIMIT $2;

order_id 用来打破 created_at 相同的 Tie。

跨分片聚合

可分解聚合适合本地计算后合并:

聚合合并方法
COUNT各分片相加
SUM各分片相加
MIN/MAX对局部结果再次取最值
AVG合并 SUMCOUNT
DISTINCT COUNT精确合并成本高,可考虑集中计算或 Sketch
Percentile需合并原始值、直方图或可合并 Sketch

跨分片分页

全局 OFFSET 会导致:

  • 每个分片读取 OFFSET + LIMIT
  • 页码越深,读放大越严重;
  • 并发写入下容易重复或遗漏;
  • Router 需要合并大量无用行。

更适合使用 Keyset Cursor:

{
    last_sort_key,
    last_global_id,
    route_epoch,
    per_shard_cursor
}

迁移期间还需要按 Global ID 去重,防止源和目标同时出现在读取集合中。


5.11 跨分片事务

最佳方案不是先选分布式事务协议,而是先修改数据模型,使高频事务保持单分片。

本地事务

tenant_id=42 的订单、库存预留、支付状态
全部共置于同一分片

优点:

  • 使用 PostgreSQL 原生事务;
  • 锁、Snapshot、约束和 Commit 都在一个节点;
  • 故障处理最简单。

2PC

PostgreSQL 支持:

BEGIN;
-- 本地修改
PREPARE TRANSACTION 'global-tx-123';

COMMIT PREPARED 'global-tx-123';
-- 或 ROLLBACK PREPARED

官方明确说明,PREPARE TRANSACTION 主要用于外部事务管理器,不适合普通应用直接使用。Prepared Transaction 会继续持有锁,并妨碍 Vacuum 清理;没有可靠事务管理器时,通常应保持 max_prepared_transactions = 0。(PostgreSQL)

2PC 需要处理:

  • Coordinator 日志持久化;
  • Coordinator 故障恢复;
  • Prepared Transaction 泄漏;
  • 参与者不可达;
  • 超时后不能自行猜测 Commit 或 Rollback;
  • 跨节点死锁;
  • Failover 后 Prepared 状态恢复;
  • 人工处置 Runbook。

Saga

将全局操作拆为:

本地事务 A
→ 事件
→ 本地事务 B
→ 事件
→ 本地事务 C

失败时运行补偿:

Compensate C
→ Compensate B
→ Compensate A

补偿不是物理回滚。已发送通知、已产生外部费用或已被用户看到的状态,可能无法完全撤销。

Outbox

推荐模式:

同一个本地事务:
    更新业务表
    写 Outbox

提交后:
    Worker/CDC 读取 Outbox
    幂等写目标系统
    标记事件完成

它提供本地原子性和 At-Least-Once 传递,不提供跨数据库瞬时原子可见性。


5.12 分片级备份、复制、Failover 与元数据服务

每个物理分片都应被视为独立 PostgreSQL 故障域:

Shard A Primary
  ├── Synchronous/Asynchronous Replica
  ├── Base Backup
  ├── WAL Archive
  └── PITR Runbook

需要分别定义:

  • Shard RPO;
  • Shard RTO;
  • 复制模式;
  • Backup Retention;
  • 恢复带宽;
  • Failover 目标;
  • 旧 Primary Fencing;
  • Router 拓扑刷新;
  • 应用重连。

全局恢复难点

每个分片自己的 LSN 只在该 PostgreSQL 集群和 Timeline 中有意义。

因此不能说:

将所有分片恢复到同一个 LSN

跨分片一致恢复通常需要:

  • 业务写入屏障;
  • 全局 Backup Epoch;
  • 每分片记录屏障对应位置;
  • 或接受分片间最终一致,并通过业务事件修复。

元数据服务

路由元数据的错误可能把写入发送到错误分片,因此其要求通常高于普通缓存:

  • 强一致写入;
  • 高可用;
  • 单调 Route Epoch;
  • CAS 更新;
  • 审计记录;
  • 只读快照;
  • 明确 Fencing;
  • 故障时已有路由与未知路由采用不同策略。

5.13 Resharding

安全状态机

PostgreSQL 容量规划、读扩展、分片与水平扩展 flow 3

具体过程

  1. Prepare

    • 创建目标表、索引、约束和配额;
    • 验证目标版本和 Schema;
    • 建立迁移记录和 Route Epoch。
  2. 确定唯一写入权威

    • 源分片仍接收业务写入;
    • 同一事务写源业务数据和 Outbox;
    • 不让应用直接向两个数据库盲目双写。
  3. Backfill

    • 按稳定主键分批;
    • 控制每批行数和事务时长;
    • 设置速率上限;
    • 使用幂等 Upsert;
    • 避免长事务和全表锁。
  4. CDC Catch-Up

    • 重放 Backfill 期间的新增和修改;
    • 按实体或事务保持必要顺序;
    • 使用事件 ID 去重;
    • 删除操作使用 Tombstone;
    • 记录复制游标。
  5. Validation

    • 按 Bucket 比较行数;
    • 比较业务聚合;
    • 比较版本号;
    • 抽样或精确比较行;
    • 检查目标约束;
    • 检查 Outbox Lag。
  6. 双路由

    • 写入仍只去源分片;
    • 目标可用于 Shadow Read;
    • 比较源和目标查询结果;
    • 不能把双路由等同于直接双写。
  7. Fencing

    • Route Epoch 递增;
    • 陈旧 Router 的写入被拒绝;
    • 短暂暂停该租户写入;
    • 等待在途事务结束;
    • 重放最终增量。
  8. Cutover

    • CAS 更新路由到目标;
    • 目标成为唯一写入权威;
    • Router 主动刷新;
    • 继续监控延迟、错误和差异。
  9. Rollback Window

    • 如果目标已接受新写入,仅翻转元数据是不安全的;
    • 必须保持反向 CDC,或先把目标新增变化同步回源。
  10. Finalize

    • 过观察期后停止反向同步;
    • 归档旧数据;
    • 释放旧分片容量;
    • 保留迁移审计记录。

PostgreSQL 逻辑复制通常先复制初始 Snapshot,再持续发送变化;在同一 Subscription 内,Subscriber 按 Publisher 顺序应用,以保持该订阅范围内的事务一致性。(PostgreSQL)


5.14 Citus 作为可选方案

Citus 可以作为数据库级水平扩展方案,但不是“安装扩展即可自动解决所有分布式问题”。

Citus 支持:

  • 按 Distribution Column 对行进行分布;
  • Tenant ID 等键的共置;
  • Row-Based Sharding;
  • Schema-Based Sharding;
  • Coordinator 路由;
  • Shard Rebalancing。

Distribution Column 决定数据分布以及相关行能否共置。选择错误的 Distribution Column 会导致网络传输、跨节点操作和 SQL 能力受限。(Citus Documentation)

增加 Worker 后,现有 Shard 不会自动全部迁移;需要显式 Rebalance。Citus 的在线 Shard Rebalancing 使用 PostgreSQL 逻辑复制进行迁移,并在元数据切换阶段对 Shard 获取短暂写锁。(Citus Documentation)

使用前仍需评估:

  • Coordinator 高可用;
  • Distribution Column;
  • Co-location;
  • 跨 Shard SQL;
  • Shard 数量和连接放大;
  • Rebalance 带宽;
  • Worker 备份与恢复;
  • 扩展升级;
  • 与原生 PostgreSQL 行为的差异。

6. 底层原理

6.1 单 Primary 写入路径

应用 goroutine
→ pgxpool Acquire
→ PostgreSQL Backend
→ 获取 Tuple/Relation/Transaction Lock
→ 修改 Buffer 中的 Page
→ 生成 Heap/Index WAL Record
→ 写入 Commit Record
→ 根据 synchronous_commit 等待持久化条件
→ 返回 Commit 结果
→ WAL Sender 发送
→ Standby Write / Flush / Replay

单 Primary 并不意味着所有写入完全串行,但所有写入最终共享同一组资源:

  • Buffer Manager;
  • Lock Manager;
  • WAL 插入和写出;
  • Checkpointer;
  • Storage;
  • Catalog;
  • Vacuum;
  • 单节点 CPU、内存和网络。

读副本只消费这些 WAL,不会替 Primary 执行业务写入。

6.2 分区写入路径

INSERT 父表
→ 根据 Partition Key 选择子分区
→ 修改子分区 Heap
→ 修改子分区 Index
→ 在同一个 Primary 产生 WAL

分区减少单个对象尺寸和维护范围,但没有新增独立写入节点。

6.3 分片写入路径

请求携带 tenant_id
→ Router 查询 Route
→ 选定一个 Pool
→ 在目标 PostgreSQL 执行本地事务
→ 返回结果

一旦请求涉及两个分片,就不再共享:

  • Snapshot;
  • Transaction ID;
  • Lock Table;
  • WAL Stream;
  • Commit Point;
  • Sequence;
  • Constraint Enforcement。

此时需要由应用或分布式数据库层重新建立全局语义。

6.4 直接双写为什么危险

BEGIN Source
写 Source
COMMIT Source   -- 成功

BEGIN Target
写 Target
COMMIT Target   -- 网络超时

应用无法仅凭超时判断 Target 是否提交。

可能结果:

SourceTarget应用看到
CommitCommitTarget Commit 超时
CommitRollbackTarget Commit 超时
Commit未执行网络错误
RollbackCommit重试或编排错误

Outbox 将第一个远程写转换为源数据库内部的本地原子写,从而减少状态组合。


7. 内部数据结构和状态

对象单分片语义跨分片后
Heap Page仅存在于一个 PostgreSQL 实例无全局 Page
Tuple使用本地 xmin/xmax事务 ID 不能跨实例比较
Index Tuple仅索引本地行无原生全局 B-tree
Snapshot由一个实例构造无天然全局一致 Snapshot
Row Lock本地 Lock Manager 管理无原生跨分片行锁
WAL Record写入本地 WAL每分片独立 WAL
LSN本地 WAL 位置不能跨独立集群比较
Buffer本地 shared_buffers每个分片各自缓存
OS Page Cache每台机器独立热数据可能分散
Sequence本地对象可能产生跨分片重复值
Unique Constraint本地表或分区树范围不能自动保证全局唯一
Prepared Transaction记录于本地实例需外部 Coordinator 管理所有参与者
Route Record不属于 PostgreSQL MVCC 全局事务需要独立控制面和 Epoch
CDC Cursor源到目标的复制位置每条迁移链独立
Outbox Event本地事务的一部分目标端 At-Least-Once 应用
Virtual Bucket Map路由元数据改映射前必须先移动数据
Statistics每实例累计需要统一标签和时间窗口聚合

8. 场景和选型决策

业务场景推荐方案不推荐方案性能代价一致性代价HA/运维代价
少数慢 SQL 占主要负载SQL、索引、统计信息优化直接分片索引维护增加通常无
重复读取且允许短暂陈旧Cache-Aside所有读打 Primary缓存失效和空间最终一致缓存成为新组件
Primary 被报表拖慢专用读副本或分析库增加写节点假象复制和网络开销Replica Lag副本恢复和冲突
大量历史分区删除Range Partitioning每行 DELETE分区规划和索引无额外跨节点一致性分区生命周期管理
OLTP 与 Analytics 相互干扰CDC 到分析库在 Primary 跑全量分析CDC 写放大最终一致新故障域
大量小租户且事务租户内Tenant Sharding按时间 Shard路由开销租户内强一致租户迁移
单个租户占主要流量专用分片或二级 Shard Key单纯增加虚拟 Bucket路由和聚合增加可能需要跨子分片定制迁移
时间范围查询为主时间分区;必要时按实体分片时间 Hash Sharding分区管理通常无生命周期自动化
全局报表汇总表、分析系统在线 Fan-Out 所有分片ETL/CDC 成本有刷新延迟数据管道
高频跨租户事务重构边界或集中该数据大量 2PC协调和锁开销可强一致但可用性下降很高
少量跨分片工作流Saga/Outbox直接双写事件和补偿开销最终一致
需要数据库级分片能力评估 Citus 等方案自研全部分布式 SQL扩展调度开销依实现而定Coordinator/Worker 运维

9. 高性能分析

9.1 各资源的水平扩展效果

资源分片后的收益新增代价
CPU每个分片独立执行和规划Router、序列化、合并消耗
内存每个节点拥有独立 Buffer 和 Page Cache相同参考数据、Catalog 和连接重复占用
随机 I/O热数据分散到多设备Fan-Out 增加总随机读
顺序 I/OBackfill 和扫描可跨节点并行网络汇总和尾延迟
WAL每个分片独立生成和 Flush总 WAL、复制和归档带宽增加
Checkpoint写入分散所有分片同时 Checkpoint 可能形成集群级峰值
Vacuum每个分片处理更小数据集需要监控更多实例
Temporary File分片本地排序可减少单点压力Router 可能再次排序
网络本地事务网络较少跨分片查询和复制显著增加
存储空间单节点数据集缩小Replica、Outbox、双份迁移数据增加

9.2 放大效应

读放大

全局查询读放大
≈ 分片数 × 每分片扫描数据

写放大

业务写
+ 索引
+ WAL
+ Replica
+ Outbox
+ CDC
+ Cache Invalidation
+ 迁移期间目标副本

空间放大

Primary 数据
+ Secondary Index
+ TOAST
+ Replica
+ Backup
+ WAL Archive
+ Materialized View
+ Resharding 双份数据

9.3 性能实验记录要求

任何分片或扩容结论必须记录:

PostgreSQL 版本:
配置差异:
硬件:
分片数:
复制因子:
数据量:
平均/P99 行宽:
数据分布:
热租户占比:
缓存状态:
并发数:
Pool 大小:
测试时长:
P50/P95/P99:
TPS/QPS:
错误率:
Pool Acquire Wait:
Buffers:
WAL bytes/s:
CPU:
磁盘 IOPS/带宽/延迟:
Wait Event:
Checkpoint:
Vacuum:
Replica/CDC Lag:

不能把单次短压测的平均 TPS 当作容量上限。


10. 高并发分析

10.1 五个容易混淆的指标

指标含义
应用 goroutine 数应用中准备或等待执行的并发任务
连接数已建立到 PostgreSQL 的 Backend 数
活跃查询数当前实际执行 SQL 的 Backend 数
TPS单位时间完成的事务数
排队请求数等待 Semaphore、Pool 或数据库资源的请求数

例如:

10,000 goroutine
100 个 Pool 连接
40 个活跃 SQL
2,000 TPS
9,700 个请求在应用中排队

它们完全可以同时成立。

10.2 每分片连接池预算

对每个物理 PostgreSQL 集群分别计算:

应用实例数 × 该分片每实例 MaxConns
+ 后台任务连接
+ 管理连接
+ 迁移连接
+ 监控连接
≤ 可供业务使用的数据库连接数

如果多个逻辑分片只是同一 PostgreSQL 实例中的不同 Database,则必须把这些 Pool 加总,因为它们共享同一个 max_connections

pgxpool.Stat() 能提供 AcquireDurationAcquiredConnsEmptyAcquireCountEmptyAcquireWaitTimeCanceledAcquireCountMaxConns 等指标。(Go Packages)

10.3 Backpressure

推荐顺序:

HTTP/RPC Admission
→ 单租户限流
→ 全局请求 Semaphore
→ Fan-Out Semaphore
→ pgxpool
→ PostgreSQL

请求必须在进入数据库前拥有:

  • Deadline;
  • 最大 Fan-Out;
  • 最大结果集;
  • 最大重试次数;
  • 幂等键;
  • 优先级或工作负载类别。

10.4 重试风暴

只有明确可重试的完整事务才重试,例如:

  • 40001:Serialization Failure;
  • 40P01:Deadlock Detected。

必须:

  • 重试完整事务;
  • 指数退避;
  • 随机抖动;
  • 最大次数;
  • 服从 Context;
  • 保持幂等;
  • 限制总体重试预算。

Commit 返回网络错误时不能武断认为事务未提交,应通过幂等键查询最终状态。

10.5 热点不会因分片自动消失

以下语句即使位于独立分片,仍可能串行:

UPDATE tenant_balance
SET balance = balance - $1
WHERE tenant_id = $2;

如果所有请求都更新同一行,增加 Shard 数、连接数甚至 CPU 都不会解除该行锁竞争。

可选修复:

  • 将不可交换业务改为预留和结算;
  • 对可交换计数使用 Bucket;
  • 缩短事务;
  • 合并批量更新;
  • 限制单租户并发;
  • 将热点租户迁移到专用分片;
  • 重新设计 Shard Key。

11. 高可用分析

主题分片环境要求
RPO每个 Shard 独立定义;异步 Replica 可能丢失已确认写入
RTORouter 识别新 Primary 的时间也是 RTO 的一部分
备份必须覆盖所有 Shard、元数据服务和 Schema 版本
PITR每个 Shard 有独立 Timeline 和恢复位置
物理复制适合分片内部完整复制
逻辑复制适合迁移、CDC、工作负载拆分
同步复制降低 RPO,但增加提交延迟和不可用风险
异步复制延迟较低,但 Failover 可能丢数据
Planned Switchover先确认 Lag,再切拓扑和 Router
Unplanned Failover必须 Fence 旧 Primary
Failback应按重新加入和重新同步处理,不应直接反向切换
脑裂Router、代理和数据库层都必须拒绝旧 Primary
应用重连旧连接不会自动变成新拓扑连接
Commit 不确定使用幂等键和状态查询处理
元数据 HARoute 更新需要强一致、CAS 和审计
恢复验证恢复后必须验证业务不变量,而非只验证实例启动

读副本的 HA 和读扩展角色不能混为一谈:

  • 一个副本可以为读取服务;
  • 也可以作为 Failover 候选;
  • 但长查询、复制延迟和恢复设置会影响它是否适合作为快速 Failover 候选。

12. 三维影响矩阵

维度相关度核心收益主要风险关键指标
高性能增加总 CPU、内存、I/O 和缓存容量Fan-Out、网络、读写放大、尾延迟P95/P99、TPS、WAL/s、I/O、Shard Skew
高并发将独立键的竞争分散到多个写域Hot Key、Pool 爆炸、重试风暴、跨分片事务Active SQL、Pool Wait、Lock Wait、Retry Rate
高可用缩小单分片故障爆炸半径故障域增多、元数据故障、全局恢复困难RPO、RTO、Lag、Failover Time、Route Epoch

13. 实验

13.1 实验一:Tenant Sharding 与连接池预算

实验目标

  • 使用两个 Database 模拟两个 Shard;
  • 验证 tenant_id 路由;
  • 验证一个租户不会创建一个独立 Pool;
  • 观察 Pool 满时请求等待和超时;
  • 验证一个 Shard 的 Pool 饱和不会自动耗尽另一个 Shard 的 Pool。

版本与扩展

  • PostgreSQL 14~18;
  • Go 和 pgx/v5
  • 无必需扩展。

创建 Database

createdb shard_a
createdb shard_b

在两个 Database 中分别执行:

CREATE TABLE orders (
    tenant_id       bigint      NOT NULL,
    order_id        uuid        NOT NULL,
    idempotency_key text        NOT NULL,
    amount_cents    bigint      NOT NULL CHECK (amount_cents >= 0),
    route_version   bigint      NOT NULL,
    created_at      timestamptz NOT NULL DEFAULT clock_timestamp(),
    PRIMARY KEY (tenant_id, order_id),
    UNIQUE (tenant_id, idempotency_key)
);

CREATE TABLE shard_outbox (
    event_id        uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       bigint      NOT NULL,
    order_id        uuid        NOT NULL,
    idempotency_key text        NOT NULL,
    amount_cents    bigint      NOT NULL,
    route_version   bigint      NOT NULL,
    target_shard    text        NOT NULL,
    created_at      timestamptz NOT NULL DEFAULT clock_timestamp(),
    lease_until     timestamptz,
    attempts        integer     NOT NULL DEFAULT 0,
    published_at    timestamptz,
    UNIQUE (target_shard, tenant_id, idempotency_key)
);

CREATE INDEX shard_outbox_pending_idx
ON shard_outbox (created_at, event_id)
WHERE published_at IS NULL;

CREATE TABLE applied_events (
    event_id   uuid PRIMARY KEY,
    applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

Pool 配置

export DATABASE_URL='{
  "shard-a-primary": {
    "url": "postgres://app:[email protected]:5432/shard_a?sslmode=disable&application_name=router",
    "max_conns": 2,
    "role": "primary"
  },
  "shard-b-primary": {
    "url": "postgres://app:[email protected]:5432/shard_b?sslmode=disable&application_name=router",
    "max_conns": 2,
    "role": "primary"
  }
}'

Session A

通过应用 Pool 在 shard-a 执行:

SELECT pg_sleep(10);

该调用占用第一个 Pool 连接。

Session B

立即在同一个 Pool 执行:

SELECT pg_sleep(10);

该调用占用第二个连接。

Session C

使用 500ms Context 再向 shard-a 发起请求。

时间线

T0       Session A 获取连接
T0+50ms  Session B 获取连接
T0+100ms Session C 请求第三个连接
T0+600ms Session C 因 Context Deadline 失败
T0+700ms 同一应用请求 shard-b,正常获取连接
T0+10s   A/B 返回,连接回到 Pool

Session C 等待发生在应用 Pool,不会在 PostgreSQL 中出现第三个执行中的 SQL。

诊断 SQL

shard_a 中:

SELECT
    application_name,
    state,
    wait_event_type,
    wait_event,
    count(*)
FROM pg_stat_activity
WHERE datname = 'shard_a'
GROUP BY
    application_name,
    state,
    wait_event_type,
    wait_event;

应用侧记录:

MaxConns
TotalConns
AcquiredConns
EmptyAcquireCount
EmptyAcquireWaitTime
CanceledAcquireCount

预期结果

  • shard-a 的应用连接最多为 2;
  • 第三个请求在 Pool 中排队并因 Context 超时;
  • EmptyAcquireCountCanceledAcquireCount 上升;
  • shard-b 的独立 Pool 仍可工作;
  • 增加 goroutine 不会增加数据库连接上限,只会增加排队。

清理

dropdb shard_a
dropdb shard_b

生产安全警告

  • 不要通过无限增加 MaxConns 消除 Pool 等待;
  • 不要为每个租户创建 Pool;
  • 不要在生产库使用 pg_sleep 制造连接耗尽;
  • 管理和故障处理连接必须保留独立预算。

13.2 实验二:Hot Shard、虚拟分片和租户迁移

实验目标

比较四种路由方式:

  1. tenant_id 哈希;
  2. order_id 哈希;
  3. tenant_id → virtual bucket → physical shard
  4. 将热租户迁移到专用分片。

准备数据

CREATE SCHEMA hotlab;

CREATE TABLE hotlab.workload AS
SELECT
    CASE
        WHEN g <= 60000 THEN 42
        ELSE 100000 + g
    END::bigint AS tenant_id,
    gen_random_uuid() AS order_id
FROM generate_series(1, 100000) AS g;

ANALYZE hotlab.workload;

这里模拟租户 42 占 60% 请求。

比较路由分布

以下哈希函数只用于实验。生产 Router 应使用由应用明确版本化的稳定哈希,不能依赖数据库内部哈希结果永久不变。

WITH routed AS (
    SELECT
        'tenant_hash' AS strategy,
        mod(
            hashtextextended(tenant_id::text, 0)
                & 9223372036854775807::bigint,
            4::bigint
        )::integer AS shard
    FROM hotlab.workload

    UNION ALL

    SELECT
        'order_hash',
        mod(
            hashtextextended(order_id::text, 0)
                & 9223372036854775807::bigint,
            4::bigint
        )::integer
    FROM hotlab.workload

    UNION ALL

    SELECT
        'virtual_bucket',
        mod(
            mod(
                hashtextextended(tenant_id::text, 0)
                    & 9223372036854775807::bigint,
                128::bigint
            ),
            4::bigint
        )::integer
    FROM hotlab.workload

    UNION ALL

    SELECT
        'tenant_override',
        CASE
            WHEN tenant_id = 42 THEN 4
            ELSE mod(
                hashtextextended(tenant_id::text, 0)
                    & 9223372036854775807::bigint,
                4::bigint
            )::integer
        END
    FROM hotlab.workload
)
SELECT
    strategy,
    shard,
    count(*) AS requests,
    round(
        100.0 * count(*) /
        sum(count(*)) OVER (PARTITION BY strategy),
        2
    ) AS request_percent
FROM routed
GROUP BY strategy, shard
ORDER BY strategy, shard;

预期解释

  • tenant_hash:租户 42 的全部请求落入一个 Shard;
  • virtual_bucket:租户 42 仍然只有一个 Virtual Bucket,热点不消失;
  • order_hash:请求更均匀,但租户查询和事务可能需要 Fan-Out;
  • tenant_override:其他租户不再与 42 共享故障域,但 42 内部热点仍存在。

并发热点行

CREATE TABLE hotlab.tenant_counter (
    tenant_id bigint PRIMARY KEY,
    value     bigint NOT NULL
);

INSERT INTO hotlab.tenant_counter VALUES (42, 0);

Session A

BEGIN;

UPDATE hotlab.tenant_counter
SET value = value + 1
WHERE tenant_id = 42;

SELECT pg_sleep(15);

COMMIT;

Session B

在 Session A 更新后立即执行:

BEGIN;

UPDATE hotlab.tenant_counter
SET value = value + 1
WHERE tenant_id = 42;

COMMIT;

Session B 会等待 Session A 的行锁。

Session C

SELECT
    pid,
    state,
    wait_event_type,
    wait_event,
    pg_blocking_pids(pid) AS blockers,
    query
FROM pg_stat_activity
WHERE datname = current_database()
  AND pid <> pg_backend_pid();

Bucket 化对比

CREATE TABLE hotlab.tenant_counter_bucket (
    tenant_id bigint   NOT NULL,
    bucket    smallint NOT NULL,
    value     bigint   NOT NULL,
    PRIMARY KEY (tenant_id, bucket)
);

INSERT INTO hotlab.tenant_counter_bucket
SELECT 42, g, 0
FROM generate_series(0, 15) AS g;

Session A:

BEGIN;

UPDATE hotlab.tenant_counter_bucket
SET value = value + 1
WHERE tenant_id = 42
  AND bucket = 0;

SELECT pg_sleep(15);

COMMIT;

Session B:

UPDATE hotlab.tenant_counter_bucket
SET value = value + 1
WHERE tenant_id = 42
  AND bucket = 1;

第二条更新不再等待第一行的行锁。

读取总值:

SELECT sum(value)
FROM hotlab.tenant_counter_bucket
WHERE tenant_id = 42;

EXPLAIN

以下 UPDATE 会真实执行,因此必须在可丢弃事务中测试:

BEGIN;

EXPLAIN (
    ANALYZE,
    BUFFERS,
    WAL,
    SETTINGS,
    VERBOSE,
    SUMMARY
)
UPDATE hotlab.tenant_counter_bucket
SET value = value + 1
WHERE tenant_id = 42
  AND bucket = 3;

ROLLBACK;

即使事务回滚,Sequence、外部调用或某些触发器副作用也未必完全可逆。

性能记录

使用 pgbench --log 或应用直方图记录:

  • 并发数;
  • 测试时长;
  • P50/P95/P99;
  • TPS;
  • Lock Wait;
  • WAL bytes;
  • CPU;
  • Pool Wait;
  • 每分片请求占比。

禁止写入没有实际测量得到的固定性能数字。

清理

DROP SCHEMA hotlab CASCADE;

13.3 实验三:Resharding 的 Backfill、CDC、校验和切流

实验目标

在一个 Database 中使用不同 Schema 模拟两个 Shard,复现:

  • 源分片权威写入;
  • Backfill;
  • Outbox CDC;
  • 幂等应用;
  • 数据校验;
  • Fencing;
  • Cutover;
  • Rollback 边界。

同一 Database 中的 Schema 不提供真实故障隔离,本实验只用于展示状态机。

准备结构

CREATE SCHEMA control;
CREATE SCHEMA old_shard;
CREATE SCHEMA new_shard;

CREATE TABLE control.tenant_route (
    tenant_id     bigint PRIMARY KEY,
    source_shard  text        NOT NULL,
    target_shard  text,
    state         text        NOT NULL CHECK (
        state IN (
            'HOME_OLD',
            'COPYING',
            'CATCHING_UP',
            'VALIDATING',
            'FENCING',
            'HOME_NEW'
        )
    ),
    route_epoch   bigint      NOT NULL,
    updated_at    timestamptz NOT NULL DEFAULT clock_timestamp()
);

CREATE TABLE old_shard.orders (
    tenant_id     bigint      NOT NULL,
    order_id      uuid        NOT NULL,
    amount_cents  bigint      NOT NULL,
    row_version   bigint      NOT NULL,
    updated_at    timestamptz NOT NULL,
    PRIMARY KEY (tenant_id, order_id)
);

CREATE TABLE old_shard.outbox (
    event_id      uuid        PRIMARY KEY,
    tenant_id     bigint      NOT NULL,
    order_id      uuid        NOT NULL,
    amount_cents  bigint      NOT NULL,
    row_version   bigint      NOT NULL,
    created_at    timestamptz NOT NULL DEFAULT clock_timestamp(),
    applied_at    timestamptz
);

CREATE INDEX old_outbox_pending_idx
ON old_shard.outbox (created_at, event_id)
WHERE applied_at IS NULL;

CREATE TABLE new_shard.orders (
    tenant_id     bigint      NOT NULL,
    order_id      uuid        NOT NULL,
    amount_cents  bigint      NOT NULL,
    row_version   bigint      NOT NULL,
    updated_at    timestamptz NOT NULL,
    PRIMARY KEY (tenant_id, order_id)
);

CREATE TABLE new_shard.applied_events (
    event_id   uuid PRIMARY KEY,
    applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

INSERT INTO control.tenant_route (
    tenant_id,
    source_shard,
    target_shard,
    state,
    route_epoch
)
VALUES (42, 'old_shard', 'new_shard', 'COPYING', 1);

INSERT INTO old_shard.orders
SELECT
    42,
    gen_random_uuid(),
    g * 100,
    1,
    clock_timestamp()
FROM generate_series(1, 10000) AS g;

Session A:源分片继续写入

BEGIN;

WITH route AS (
    SELECT route_epoch
    FROM control.tenant_route
    WHERE tenant_id = 42
      AND source_shard = 'old_shard'
      AND state IN ('COPYING', 'CATCHING_UP', 'VALIDATING')
    FOR SHARE
),
changed AS (
    INSERT INTO old_shard.orders AS o (
        tenant_id,
        order_id,
        amount_cents,
        row_version,
        updated_at
    )
    SELECT
        42,
        $1::uuid,
        $2::bigint,
        1,
        clock_timestamp()
    FROM route
    ON CONFLICT (tenant_id, order_id)
    DO UPDATE
    SET amount_cents = EXCLUDED.amount_cents,
        row_version  = o.row_version + 1,
        updated_at   = clock_timestamp()
    RETURNING
        tenant_id,
        order_id,
        amount_cents,
        row_version,
        updated_at
)
INSERT INTO old_shard.outbox (
    event_id,
    tenant_id,
    order_id,
    amount_cents,
    row_version
)
SELECT
    gen_random_uuid(),
    tenant_id,
    order_id,
    amount_cents,
    row_version
FROM changed;

COMMIT;

业务行和 Outbox 在同一事务中提交。

Session B:分批 Backfill

WITH batch AS (
    SELECT
        tenant_id,
        order_id,
        amount_cents,
        row_version,
        updated_at
    FROM old_shard.orders
    WHERE tenant_id = 42
      AND order_id > $1::uuid
    ORDER BY order_id
    LIMIT 1000
)
INSERT INTO new_shard.orders AS target (
    tenant_id,
    order_id,
    amount_cents,
    row_version,
    updated_at
)
SELECT
    tenant_id,
    order_id,
    amount_cents,
    row_version,
    updated_at
FROM batch
ON CONFLICT (tenant_id, order_id)
DO UPDATE
SET amount_cents = EXCLUDED.amount_cents,
    row_version  = EXCLUDED.row_version,
    updated_at   = EXCLUDED.updated_at
WHERE target.row_version < EXCLUDED.row_version;

不断更新 $1 为上一批最后一个 order_id,直到影响行数为 0。

row_version 防止较旧的 Backfill 覆盖较新的 CDC 结果。

Session C:应用 Outbox

BEGIN;

WITH picked AS (
    SELECT
        event_id,
        tenant_id,
        order_id,
        amount_cents,
        row_version
    FROM old_shard.outbox
    WHERE applied_at IS NULL
    ORDER BY created_at, event_id
    FOR UPDATE SKIP LOCKED
    LIMIT 100
),
accepted AS (
    INSERT INTO new_shard.applied_events (event_id)
    SELECT event_id
    FROM picked
    ON CONFLICT DO NOTHING
    RETURNING event_id
),
upserted AS (
    INSERT INTO new_shard.orders AS target (
        tenant_id,
        order_id,
        amount_cents,
        row_version,
        updated_at
    )
    SELECT
        p.tenant_id,
        p.order_id,
        p.amount_cents,
        p.row_version,
        clock_timestamp()
    FROM picked AS p
    JOIN accepted AS a USING (event_id)
    ON CONFLICT (tenant_id, order_id)
    DO UPDATE
    SET amount_cents = EXCLUDED.amount_cents,
        row_version  = EXCLUDED.row_version,
        updated_at   = EXCLUDED.updated_at
    WHERE target.row_version < EXCLUDED.row_version
    RETURNING 1
)
UPDATE old_shard.outbox AS o
SET applied_at = clock_timestamp()
FROM picked AS p
WHERE o.event_id = p.event_id;

COMMIT;

applied_events 使事件可以安全重放。

等待条件

SELECT count(*) AS pending_events
FROM old_shard.outbox
WHERE applied_at IS NULL;

进入 Cutover 前必须为 0,或符合明确、可解释的迁移水位。

分 Bucket 校验

WITH source AS (
    SELECT
        mod(
            hashtextextended(order_id::text, 0)
                & 9223372036854775807::bigint,
            64::bigint
        ) AS bucket,
        count(*) AS row_count,
        sum(amount_cents) AS amount_sum,
        sum(row_version) AS version_sum
    FROM old_shard.orders
    WHERE tenant_id = 42
    GROUP BY 1
),
target AS (
    SELECT
        mod(
            hashtextextended(order_id::text, 0)
                & 9223372036854775807::bigint,
            64::bigint
        ) AS bucket,
        count(*) AS row_count,
        sum(amount_cents) AS amount_sum,
        sum(row_version) AS version_sum
    FROM new_shard.orders
    WHERE tenant_id = 42
    GROUP BY 1
)
SELECT *
FROM source
FULL JOIN target USING (bucket)
WHERE source.row_count  IS DISTINCT FROM target.row_count
   OR source.amount_sum IS DISTINCT FROM target.amount_sum
   OR source.version_sum IS DISTINCT FROM target.version_sum;

最终 Fencing 后做精确差异检查:

SELECT count(*) AS differing_rows
FROM (
    (
        SELECT tenant_id, order_id, amount_cents, row_version
        FROM old_shard.orders
        WHERE tenant_id = 42

        EXCEPT

        SELECT tenant_id, order_id, amount_cents, row_version
        FROM new_shard.orders
        WHERE tenant_id = 42
    )

    UNION ALL

    (
        SELECT tenant_id, order_id, amount_cents, row_version
        FROM new_shard.orders
        WHERE tenant_id = 42

        EXCEPT

        SELECT tenant_id, order_id, amount_cents, row_version
        FROM old_shard.orders
        WHERE tenant_id = 42
    )
) AS differences;

Fencing

UPDATE control.tenant_route
SET state       = 'FENCING',
    route_epoch = route_epoch + 1,
    updated_at  = clock_timestamp()
WHERE tenant_id = 42
  AND state = 'VALIDATING'
  AND route_epoch = 1;

此时:

  1. 拒绝持有旧 Epoch 的写入;
  2. 等待在途源事务结束;
  3. 重放最终 Outbox;
  4. 再做精确校验。

Cutover

UPDATE control.tenant_route
SET source_shard = 'new_shard',
    target_shard = NULL,
    state        = 'HOME_NEW',
    route_epoch  = route_epoch + 1,
    updated_at   = clock_timestamp()
WHERE tenant_id = 42
  AND state = 'FENCING'
  AND route_epoch = 2;

Rollback 边界

  • COPYINGCATCHING_UPVALIDATING:停止迁移并恢复旧路由通常较简单。
  • FENCING 且目标未接收新业务写入:可恢复旧路由。
  • HOME_NEW 且目标已接收新写入:必须先反向同步,不能只修改路由表。

删除事件的生产要求

本实验只演示 Upsert。生产系统还必须:

  • 为 DELETE 产生 Tombstone;
  • Tombstone 携带版本;
  • 防止较旧 Backfill 复活已删除数据;
  • 为 Schema 变更建立兼容窗口。

清理

DROP SCHEMA control CASCADE;
DROP SCHEMA old_shard CASCADE;
DROP SCHEMA new_shard CASCADE;

14. Go:有界 Shard Router

以下示例具备:

  • DATABASE_URL 环境变量;
  • 每 Endpoint 一个 pgxpool
  • Pool 总数上限;
  • Virtual Bucket;
  • Tenant Override;
  • Primary/Replica 角色;
  • Read Fallback;
  • 有界 Fan-Out;
  • 部分 Shard 失败;
  • 事务完整重试;
  • 4000140P01 分类;
  • Commit 结果不确定;
  • 幂等写入;
  • Outbox 和目标去重;
  • Resharding Shadow Route;
  • Pool 指标;
  • Context 超时;
  • 优雅停机。

配置格式:

export DATABASE_URL='{
  "shard-a-primary": {
    "url": "postgres://app:secret@db-a-primary/shard_a",
    "max_conns": 8,
    "role": "primary"
  },
  "shard-a-replica": {
    "url": "postgres://app:secret@db-a-replica/shard_a",
    "max_conns": 8,
    "role": "replica"
  },
  "shard-b-primary": {
    "url": "postgres://app:secret@db-b-primary/shard_b",
    "max_conns": 8,
    "role": "primary"
  }
}'
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"hash/fnv"
	"log"
	"math/rand"
	"os"
	"os/signal"
	"sort"
	"sync"
	"syscall"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgxpool"
)

type Endpoint struct {
	URL      string `json:"url"`
	MaxConns int32  `json:"max_conns"`
	Role     string `json:"role"` // primary(默认)或 replica
}

type Route struct {
	Write   string
	Reads   []string
	Shadow  string // 只写源分片;Shadow 由 Outbox/CDC 填充
	Version int64
}

type Order struct {
	TenantID       int64
	OrderID        string
	IdempotencyKey string
	AmountCents    int64
	RouteVersion   int64
	CreatedAt      time.Time
}

type Event struct {
	EventID        string
	TenantID       int64
	OrderID        string
	IdempotencyKey string
	AmountCents    int64
	RouteVersion   int64
	Target         string
}

type Router struct {
	mu        sync.RWMutex
	pools     map[string]*pgxpool.Pool
	roles     map[string]string
	buckets   []string
	overrides map[int64]Route
	fanoutSem chan struct{}
}

type CommitUnknownError struct{ Err error }

func (e *CommitUnknownError) Error() string {
	return "commit result is unknown: " + e.Err.Error()
}

func (e *CommitUnknownError) Unwrap() error {
	return e.Err
}

func NewRouter(
	ctx context.Context,
	maxPools int,
	bucketCount int,
	fanoutLimit int,
) (*Router, error) {
	var specs map[string]Endpoint
	if err := json.Unmarshal(
		[]byte(os.Getenv("DATABASE_URL")),
		&specs,
	); err != nil {
		return nil, fmt.Errorf("parse DATABASE_URL JSON: %w", err)
	}

	if len(specs) == 0 ||
		len(specs) > maxPools ||
		bucketCount < 1 ||
		fanoutLimit < 1 {
		return nil, errors.New(
			"invalid endpoint, bucket, or fan-out limit",
		)
	}

	all := make([]string, 0, len(specs))
	primaries := make([]string, 0, len(specs))

	for name, spec := range specs {
		all = append(all, name)
		if spec.Role == "" || spec.Role == "primary" {
			primaries = append(primaries, name)
		}
	}

	sort.Strings(all)
	sort.Strings(primaries)

	if len(primaries) == 0 {
		return nil, errors.New("no primary endpoint")
	}

	r := &Router{
		pools:     make(map[string]*pgxpool.Pool, len(specs)),
		roles:     make(map[string]string, len(specs)),
		buckets:   make([]string, bucketCount),
		overrides: make(map[int64]Route),
		fanoutSem: make(chan struct{}, fanoutLimit),
	}

	for _, name := range all {
		spec := specs[name]
		role := spec.Role
		if role == "" {
			role = "primary"
		}

		if spec.URL == "" ||
			spec.MaxConns < 1 ||
			(role != "primary" && role != "replica") {
			r.Close()
			return nil, fmt.Errorf(
				"invalid endpoint %q",
				name,
			)
		}

		cfg, err := pgxpool.ParseConfig(spec.URL)
		if err != nil {
			r.Close()
			return nil, fmt.Errorf(
				"parse endpoint %s: %w",
				name,
				err,
			)
		}

		cfg.MaxConns = spec.MaxConns
		cfg.MinConns = 0
		cfg.MaxConnIdleTime = 5 * time.Minute

		pool, err := pgxpool.NewWithConfig(ctx, cfg)
		if err != nil {
			r.Close()
			return nil, fmt.Errorf(
				"create pool %s: %w",
				name,
				err,
			)
		}

		pingCtx, cancel := context.WithTimeout(
			ctx,
			2*time.Second,
		)
		err = pool.Ping(pingCtx)
		cancel()

		if err != nil {
			pool.Close()
			r.Close()
			return nil, fmt.Errorf(
				"ping %s: %w",
				name,
				err,
			)
		}

		r.pools[name] = pool
		r.roles[name] = role
	}

	for i := range r.buckets {
		r.buckets[i] = primaries[i%len(primaries)]
	}

	return r, nil
}

func (r *Router) Close() {
	if r == nil {
		return
	}

	for _, pool := range r.pools {
		pool.Close()
	}
}

func bucket(tenantID int64, count int) int {
	h := fnv.New64a()
	_, _ = fmt.Fprintf(h, "%d", tenantID)
	return int(h.Sum64() % uint64(count))
}

func (r *Router) RouteFor(tenantID int64) Route {
	r.mu.RLock()
	defer r.mu.RUnlock()

	if route, ok := r.overrides[tenantID]; ok {
		return route
	}

	name := r.buckets[bucket(
		tenantID,
		len(r.buckets),
	)]

	return Route{
		Write:   name,
		Reads:   []string{name},
		Version: 1,
	}
}

func (r *Router) SetRoute(
	tenantID int64,
	next Route,
) error {
	r.mu.Lock()
	defer r.mu.Unlock()

	if current, ok := r.overrides[tenantID]; ok &&
		next.Version <= current.Version {
		return fmt.Errorf(
			"route version must increase: %d <= %d",
			next.Version,
			current.Version,
		)
	}

	if r.roles[next.Write] != "primary" {
		return fmt.Errorf(
			"write endpoint %q is not a primary",
			next.Write,
		)
	}

	for _, name := range next.Reads {
		if r.pools[name] == nil {
			return fmt.Errorf(
				"unknown read endpoint %q",
				name,
			)
		}
	}

	if next.Shadow != "" &&
		r.roles[next.Shadow] != "primary" {
		return fmt.Errorf(
			"shadow endpoint %q is not a primary",
			next.Shadow,
		)
	}

	r.overrides[tenantID] = next
	return nil
}

func retryable(err error) bool {
	var pgErr *pgconn.PgError
	return errors.As(err, &pgErr) &&
		(pgErr.Code == "40001" ||
			pgErr.Code == "40P01")
}

func (r *Router) CreateOrder(
	ctx context.Context,
	order Order,
) error {
	const maxAttempts = 4

	for attempt := 0; attempt < maxAttempts; attempt++ {
		err := r.createOrderOnce(ctx, order)
		if err == nil {
			return nil
		}

		var unknown *CommitUnknownError
		if errors.As(err, &unknown) ||
			!retryable(err) ||
			attempt == maxAttempts-1 {
			return err
		}

		base := 25 *
			time.Millisecond *
			time.Duration(1<<attempt)

		jitter := time.Duration(
			rand.Int63n(int64(base/2) + 1),
		)

		timer := time.NewTimer(base + jitter)

		select {
		case <-ctx.Done():
			timer.Stop()
			return ctx.Err()
		case <-timer.C:
		}
	}

	return errors.New("unreachable")
}

func (r *Router) createOrderOnce(
	ctx context.Context,
	order Order,
) error {
	route := r.RouteFor(order.TenantID)

	queryCtx, cancel := context.WithTimeout(
		ctx,
		2*time.Second,
	)
	defer cancel()

	tx, err := r.pools[route.Write].BeginTx(
		queryCtx,
		pgx.TxOptions{},
	)
	if err != nil {
		return err
	}

	defer func() {
		_ = tx.Rollback(queryCtx)
	}()

	tag, err := tx.Exec(queryCtx, `
		INSERT INTO orders
		    (
		        tenant_id,
		        order_id,
		        idempotency_key,
		        amount_cents,
		        route_version
		    )
		VALUES ($1, $2, $3, $4, $5)
		ON CONFLICT (
		    tenant_id,
		    idempotency_key
		)
		DO NOTHING`,
		order.TenantID,
		order.OrderID,
		order.IdempotencyKey,
		order.AmountCents,
		route.Version,
	)
	if err != nil {
		return err
	}

	if tag.RowsAffected() == 0 {
		var existingID string
		var existingAmount int64

		err = tx.QueryRow(queryCtx, `
			SELECT
			    order_id::text,
			    amount_cents
			FROM orders
			WHERE tenant_id = $1
			  AND idempotency_key = $2`,
			order.TenantID,
			order.IdempotencyKey,
		).Scan(
			&existingID,
			&existingAmount,
		)
		if err != nil {
			return err
		}

		if existingID != order.OrderID ||
			existingAmount != order.AmountCents {
			return errors.New(
				"idempotency key reused " +
					"with different payload",
			)
		}
	}

	if route.Shadow != "" {
		_, err = tx.Exec(queryCtx, `
			INSERT INTO shard_outbox
			    (
			        tenant_id,
			        order_id,
			        idempotency_key,
			        amount_cents,
			        route_version,
			        target_shard
			    )
			VALUES ($1, $2, $3, $4, $5, $6)
			ON CONFLICT (
			    target_shard,
			    tenant_id,
			    idempotency_key
			)
			DO NOTHING`,
			order.TenantID,
			order.OrderID,
			order.IdempotencyKey,
			order.AmountCents,
			route.Version,
			route.Shadow,
		)
		if err != nil {
			return err
		}
	}

	if err = tx.Commit(queryCtx); err != nil {
		if retryable(err) {
			return err
		}

		return &CommitUnknownError{Err: err}
	}

	return nil
}

func (r *Router) ReadOrders(
	ctx context.Context,
	tenantID int64,
	limit int,
) ([]Order, error) {
	if limit < 1 || limit > 1000 {
		return nil, errors.New("invalid limit")
	}

	var lastErr error

	for _, endpoint := range r.RouteFor(tenantID).Reads {
		queryCtx, cancel := context.WithTimeout(
			ctx,
			800*time.Millisecond,
		)

		rows, err := r.pools[endpoint].Query(
			queryCtx,
			`
			SELECT
			    tenant_id,
			    order_id::text,
			    idempotency_key,
			    amount_cents,
			    route_version,
			    created_at
			FROM orders
			WHERE tenant_id = $1
			ORDER BY
			    created_at DESC,
			    order_id DESC
			LIMIT $2`,
			tenantID,
			limit,
		)

		if err != nil {
			cancel()
			lastErr = err
			continue
		}

		orders := make([]Order, 0, limit)

		for rows.Next() {
			var order Order

			if err := rows.Scan(
				&order.TenantID,
				&order.OrderID,
				&order.IdempotencyKey,
				&order.AmountCents,
				&order.RouteVersion,
				&order.CreatedAt,
			); err != nil {
				rows.Close()
				cancel()
				return nil, err
			}

			orders = append(orders, order)
		}

		err = rows.Err()
		rows.Close()
		cancel()

		if err == nil {
			return orders, nil
		}

		lastErr = err
	}

	return nil, fmt.Errorf(
		"all read endpoints failed: %w",
		lastErr,
	)
}

type FanoutResult struct {
	CountByShard map[string]int64
	Failures     map[string]error
}

func (r *Router) FanoutCount(
	ctx context.Context,
	since time.Time,
) FanoutResult {
	type item struct {
		name  string
		count int64
		err   error
	}

	r.mu.RLock()

	names := make([]string, 0, len(r.pools))
	for name, role := range r.roles {
		if role == "primary" {
			names = append(names, name)
		}
	}

	r.mu.RUnlock()
	sort.Strings(names)

	results := make(chan item, len(names))
	var wg sync.WaitGroup

	for _, name := range names {
		name := name
		wg.Add(1)

		// Goroutine 数量受 maxPools 上限约束。
		go func() {
			defer wg.Done()

			select {
			case r.fanoutSem <- struct{}{}:
				defer func() {
					<-r.fanoutSem
				}()
			case <-ctx.Done():
				results <- item{
					name: name,
					err:  ctx.Err(),
				}
				return
			}

			queryCtx, cancel := context.WithTimeout(
				ctx,
				time.Second,
			)
			defer cancel()

			var count int64
			err := r.pools[name].QueryRow(
				queryCtx,
				`
				SELECT count(*)
				FROM orders
				WHERE created_at >= $1`,
				since,
			).Scan(&count)

			results <- item{
				name:  name,
				count: count,
				err:   err,
			}
		}()
	}

	wg.Wait()
	close(results)

	result := FanoutResult{
		CountByShard: map[string]int64{},
		Failures:     map[string]error{},
	}

	for current := range results {
		if current.err != nil {
			result.Failures[current.name] =
				current.err
			continue
		}

		result.CountByShard[current.name] =
			current.count
	}

	return result
}

func (r *Router) LeaseEvent(
	ctx context.Context,
	source string,
) (Event, error) {
	var event Event

	queryCtx, cancel := context.WithTimeout(
		ctx,
		time.Second,
	)
	defer cancel()

	err := r.pools[source].QueryRow(queryCtx, `
		WITH picked AS (
		    SELECT event_id
		    FROM shard_outbox
		    WHERE published_at IS NULL
		      AND (
		          lease_until IS NULL
		          OR lease_until < clock_timestamp()
		      )
		    ORDER BY created_at, event_id
		    FOR UPDATE SKIP LOCKED
		    LIMIT 1
		)
		UPDATE shard_outbox AS o
		SET lease_until =
		        clock_timestamp() + interval '30 seconds',
		    attempts = attempts + 1
		FROM picked
		WHERE o.event_id = picked.event_id
		RETURNING
		    o.event_id::text,
		    o.tenant_id,
		    o.order_id::text,
		    o.idempotency_key,
		    o.amount_cents,
		    o.route_version,
		    o.target_shard`,
	).Scan(
		&event.EventID,
		&event.TenantID,
		&event.OrderID,
		&event.IdempotencyKey,
		&event.AmountCents,
		&event.RouteVersion,
		&event.Target,
	)

	return event, err
}

func (r *Router) ApplyEvent(
	ctx context.Context,
	source string,
	event Event,
) error {
	target := r.pools[event.Target]
	if target == nil {
		return fmt.Errorf(
			"unknown target %q",
			event.Target,
		)
	}

	queryCtx, cancel := context.WithTimeout(
		ctx,
		2*time.Second,
	)
	defer cancel()

	tx, err := target.BeginTx(
		queryCtx,
		pgx.TxOptions{},
	)
	if err != nil {
		return err
	}

	defer func() {
		_ = tx.Rollback(queryCtx)
	}()

	tag, err := tx.Exec(queryCtx, `
		INSERT INTO applied_events (event_id)
		VALUES ($1)
		ON CONFLICT DO NOTHING`,
		event.EventID,
	)
	if err != nil {
		return err
	}

	if tag.RowsAffected() == 1 {
		_, err = tx.Exec(queryCtx, `
			INSERT INTO orders AS o
			    (
			        tenant_id,
			        order_id,
			        idempotency_key,
			        amount_cents,
			        route_version
			    )
			VALUES ($1, $2, $3, $4, $5)
			ON CONFLICT (
			    tenant_id,
			    idempotency_key
			)
			DO UPDATE
			SET amount_cents =
			        EXCLUDED.amount_cents,
			    route_version =
			        EXCLUDED.route_version
			WHERE
			    o.route_version
			    <= EXCLUDED.route_version`,
			event.TenantID,
			event.OrderID,
			event.IdempotencyKey,
			event.AmountCents,
			event.RouteVersion,
		)
		if err != nil {
			return err
		}
	}

	if err = tx.Commit(queryCtx); err != nil {
		return &CommitUnknownError{Err: err}
	}

	markCtx, markCancel := context.WithTimeout(
		ctx,
		time.Second,
	)
	defer markCancel()

	_, err = r.pools[source].Exec(markCtx, `
		UPDATE shard_outbox
		SET published_at = clock_timestamp(),
		    lease_until = NULL
		WHERE event_id = $1`,
		event.EventID,
	)

	// 标记失败时可以重新租约和重放。
	// Target 端通过 applied_events 保证幂等。
	return err
}

func (r *Router) PoolStats() map[string]map[string]int64 {
	result := make(
		map[string]map[string]int64,
		len(r.pools),
	)

	for name, pool := range r.pools {
		stats := pool.Stat()

		result[name] = map[string]int64{
			"max":
				int64(stats.MaxConns()),
			"total":
				int64(stats.TotalConns()),
			"acquired":
				int64(stats.AcquiredConns()),
			"idle":
				int64(stats.IdleConns()),
			"acquire_ms":
				stats.AcquireDuration().Milliseconds(),
			"empty_acquire_count":
				stats.EmptyAcquireCount(),
			"empty_acquire_wait_ms":
				stats.EmptyAcquireWaitTime().
					Milliseconds(),
			"canceled_acquires":
				stats.CanceledAcquireCount(),
		}
	}

	return result
}

func run(ctx context.Context) error {
	router, err := NewRouter(
		ctx,
		16,  // 所有 Primary/Replica Pool 总上限
		256, // Virtual Bucket
		8,   // 全局 Fan-Out 并发上限
	)
	if err != nil {
		return err
	}
	defer router.Close()

	// Catch-Up 阶段:
	// shard-a-primary 仍是唯一写入权威;
	// shard-b-primary 仅由 Outbox/CDC 填充。
	if err := router.SetRoute(42, Route{
		Write: "shard-a-primary",
		Reads: []string{
			"shard-a-replica",
			"shard-a-primary",
		},
		Shadow:  "shard-b-primary",
		Version: 2,
	}); err != nil {
		return err
	}

	result := router.FanoutCount(
		ctx,
		time.Now().Add(-24*time.Hour),
	)

	log.Printf(
		"counts=%v partial_failures=%v pools=%v",
		result.CountByShard,
		result.Failures,
		router.PoolStats(),
	)

	return nil
}

func main() {
	ctx, stop := signal.NotifyContext(
		context.Background(),
		syscall.SIGINT,
		syscall.SIGTERM,
	)
	defer stop()

	if err := run(ctx); err != nil {
		log.Fatal(err)
	}
}

该示例中的路由表是进程内存结构。生产环境必须把路由元数据放入高可用控制面,并使用 CAS、审计和 Route Epoch。修改 Virtual Bucket 映射之前,必须先完成数据迁移和校验,不能只修改数组。


15. 生产排障 Runbook

15.1 首先确认什么

  1. SLO 从什么时间开始违约;
  2. 只有一个租户、一个 Shard 还是全局异常;
  3. 最近是否发生发布、DDL、Failover、Resharding 或批处理;
  4. 是读、写、Fan-Out 还是 Metadata 请求异常;
  5. 错误是超时、连接获取失败、SQLSTATE、复制延迟还是部分 Shard 失败。

15.2 查看哪些指标

按 Shard、租户和 Endpoint 拆分:

  • TPS/QPS;
  • P50/P95/P99;
  • 错误率;
  • Pool Acquire Wait;
  • Pool Canceled Acquire;
  • Active/Idle Connections;
  • CPU;
  • 内存和 Swap;
  • 数据盘和 WAL 盘延迟;
  • WAL bytes/s;
  • Checkpoint;
  • Temporary Files;
  • Lock Wait;
  • Deadlock;
  • Vacuum Debt;
  • Replica Lag;
  • CDC Lag;
  • Fan-Out 宽度;
  • 路由缓存命中率;
  • Route Epoch 冲突;
  • 每租户请求占比。

15.3 查询哪些系统视图

pg_stat_activity
pg_locks
pg_stat_database
pg_stat_statements
pg_stat_io
pg_stat_wal
pg_stat_checkpointer
pg_stat_user_tables
pg_stat_user_indexes
pg_stat_replication
pg_stat_database_conflicts
pg_prepared_xacts

15.4 如何找到 Blocker

SELECT
    blocked.pid AS blocked_pid,
    blocked.usename AS blocked_user,
    blocked.wait_event_type,
    blocked.wait_event,
    blocked.query AS blocked_query,
    blocker.pid AS blocker_pid,
    blocker.state AS blocker_state,
    blocker.xact_start AS blocker_xact_start,
    blocker.query AS blocker_query
FROM pg_stat_activity AS blocked
CROSS JOIN LATERAL
    unnest(pg_blocking_pids(blocked.pid))
    AS blocking_pid
JOIN pg_stat_activity AS blocker
  ON blocker.pid = blocking_pid
ORDER BY blocker.xact_start;

15.5 如何找到最早的执行计划估算错误

  1. 从 SLO 开始恶化的最早时间定位 queryid
  2. 对比发布前后调用次数、平均时间、读块、临时文件和 WAL;
  3. 查看表数据分布或统计信息是否变化;
  4. 在副本或测试环境复现参数;
  5. 使用:
EXPLAIN (
    ANALYZE,
    BUFFERS,
    WAL,
    SETTINGS,
    VERBOSE,
    SUMMARY
)
SELECT ...;

比较每个节点的:

estimated rows
actual rows
loops
buffers
temporary I/O

15.6 判断资源类型

现象优先判断
CPU 高、无明显等待执行计划、表达式、Join、并发过高
IO Wait 高数据盘、WAL、Checkpoint、缓存失效
Lock Wait 高热点行、DDL、长事务
Pool Wait 高、DB Active 低Pool 太小、连接泄漏或慢外部调用占用事务
DB Active 高、CPU 低锁、I/O、同步复制或客户端写阻塞
WAL/s 激增批量更新、索引、FPI、Backfill、重试
Dead Tuple 增长Vacuum 落后、长事务、Replica Feedback
Replica Lag 增长网络、WAL 生成、重放 I/O、Standby 查询冲突
单 Shard 异常Hot Tenant、数据倾斜、局部硬件或计划问题
所有 Shard 同时异常应用发布、Metadata、共享网络、全局 Fan-Out

15.7 可在线执行的低风险动作

  • 暂停非关键批处理;
  • 降低 Fan-Out 并发;
  • 对热租户限流;
  • 将强一致读取临时路由 Primary;
  • 将可陈旧读取路由健康 Replica;
  • 取消明确无用的长查询;
  • 刷新 Router 路由缓存;
  • 停止新的 Resharding;
  • 临时增加应用层队列和超时保护;
  • 在已有能力内垂直扩容。

15.8 高风险动作

  • 大规模终止 Backend;
  • 直接修改 Shard Mapping;
  • 未 Fencing 就 Failover;
  • 未校验就 Cutover;
  • VACUUM FULL
  • 阻塞式 REINDEX
  • 大表同步 DDL;
  • 一次性 Backfill 整个租户;
  • 随意增加 max_connections
  • 随意改变同步复制语义;
  • 清理仍在使用的复制槽;
  • 删除旧 Shard 数据。

绝不能为了测试或止损随意关闭:

  • fsync
  • full_page_writes
  • Autovacuum;
  • 数据校验;
  • 同步复制保护。

15.9 临时止损

优先级通常为:

停止放大源
→ 保护核心写入
→ 限制热租户
→ 暂停 Fan-Out/报表/迁移
→ 回退最近变更
→ 隔离 Hot Tenant
→ 扩容

15.10 根本修复

可能包括:

  • 修复 SQL 和索引;
  • 修改数据模型;
  • 对热点计数 Bucket 化;
  • 调整 Pool 和 Admission Control;
  • 增加读副本;
  • 拆分分析工作负载;
  • 迁移热租户;
  • 调整 Shard Key;
  • 增加 Virtual Bucket;
  • 重构跨分片事务;
  • 修复 CDC 幂等和 Fencing。

15.11 验证修复

必须验证:

  • P95/P99 恢复;
  • 队列不再无界增长;
  • Pool Wait 恢复;
  • Hot Shard 利用率下降;
  • 没有把问题转移到 WAL、Replica 或其他 Shard;
  • 错误率和重试率下降;
  • 数据校验通过;
  • 在代表性峰值和 Failover 场景下仍保留余量。

15.12 告警

至少包括:

  • 每 Shard P99;
  • 每租户请求占比;
  • Shard Utilization Skew;
  • Pool Empty Acquire;
  • Fan-Out 宽度;
  • Partial Shard Failure;
  • Route Epoch Rejection;
  • Replica Lag;
  • CDC Lag;
  • Outbox Age;
  • Prepared Transaction Age;
  • WAL Archive Lag;
  • Backup Age;
  • Resharding Validation Failure。

16. 常见错误与反模式

  1. 仅根据表行数决定分片。
  2. 把读副本当成写扩展。
  3. 把 PostgreSQL 分区称为跨节点分片。
  4. 只按数据量而不按请求、WAL 和锁竞争选择 Shard Key。
  5. 假设哈希一定能消除大租户热点。
  6. 为每个租户创建独立 pgxpool
  7. 应用实例扩容后不重新计算全局连接预算。
  8. 为每个 Fan-Out Shard 启动无界 goroutine。
  9. 全局分页使用大 OFFSET。
  10. 将 UUIDv7 当作严格全局事务顺序。
  11. 使用本地 Sequence 却假设跨分片唯一。
  12. 应用直接双写源和目标数据库。
  13. CDC 未追平、未校验就 Cutover。
  14. Cutover 后直接翻回旧路由进行 Rollback。
  15. 不使用 Route Epoch,允许陈旧 Router 写旧 Shard。
  16. 比较两个独立 PostgreSQL 集群的 LSN 大小。
  17. 在没有事务管理器时启用并长期保留 Prepared Transaction。
  18. 所有错误都自动重试。
  19. Commit 超时后直接重新创建业务对象。
  20. Fan-Out 遇到部分失败时静默返回不完整数据。
  21. 增加 Citus Worker 后假设旧 Shard 会自动均衡。
  22. 只备份数据 Shard,不备份路由元数据。
  23. 把 Replica 当作备份。
  24. 忽略迁移期间双份数据、WAL 和网络的容量需求。

17. 模拟生产事故案例

17.1 模拟生产案例一:哈希分片后仍然只有一个 Shard 过载

系统背景

  • 8 个 Tenant Shard;
  • hash(tenant_id) 路由;
  • 每个 Shard 有一个 Primary 和一个 Replica;
  • 一个企业租户占总写入的 46%。

故障现象

  • 集群平均 CPU 仅 35%;
  • 某个 Shard CPU 达到 95%;
  • 该 Shard Pool Acquire P99 急剧上升;
  • 订单写入出现超时;
  • 其他 7 个 Shard 基本空闲。

错误假设

“已经使用 Hash Sharding,所以请求会均匀分布。”

排查过程

  1. 按 Shard 查看 CPU 和 WAL;
  2. 确认只有 Shard 3 异常;
  3. tenant_id 聚合请求和 WAL;
  4. 发现租户 42 占 Shard 3 的 82% 写入;
  5. 检查锁,发现该租户的每日计数行存在持续行锁等待;
  6. Virtual Bucket 映射显示该租户只占一个 Bucket。

根因

  • 分片均衡的是键,不是键内部负载;
  • 单租户是 Hot Key;
  • 租户内部还有 Hot Row;
  • 增加物理 Shard 无法拆开同一个 Tenant Key。

临时止损

  • 对租户 42 启用独立限流;
  • 暂停该租户的大型导入;
  • 将租户迁移到专用 Shard;
  • 将非关键读取转移 Replica;
  • 暂停全局报表。

最终修复

  • 热租户使用专用 Shard;
  • 高频计数改为 Bucket;
  • 部分实体按 (tenant_id, entity_id) 二级分片;
  • 对跨子分片查询使用预聚合;
  • 增加每租户容量配额和迁移自动化。

监控补充

  • 每租户 QPS/TPS;
  • 每租户 WAL;
  • 每租户锁等待;
  • 最大租户占 Shard 比例;
  • Shard Skew;
  • Virtual Bucket 热度。

防止复发

容量评审必须同时包含:

平均租户
P95 租户
最大租户
最大租户增长速度
最大单键写并发

17.2 模拟生产案例二:Resharding 直接双写导致数据分叉

系统背景

团队将租户 77 从 Shard A 迁移到 Shard B。

应用代码采用:

写 Shard A
写 Shard B

没有 Outbox、幂等事件、版本号和 Fencing。

故障现象

  • Cutover 后部分订单在 B 中不存在;
  • 部分订单金额不同;
  • 应用日志只显示少量 Target Timeout;
  • 重试后出现重复扣减。

错误假设

“写 B 超时就代表 B 没有提交,重试即可。”

排查过程

  1. 对比 A/B 行数;

  2. 按订单 ID 做精确差异;

  3. 发现三类状态:

    • A 有、B 无;
    • A/B 都有但版本不同;
    • B 已提交但应用认为失败并重试;
  4. 检查日志,发现 Source Commit 成功后 Target Commit 超时;

  5. 无事件 ID,无法判断 Target 是否已应用;

  6. Cutover 期间旧 Router 仍向 A 写入。

根因

  • 直接双写不能原子提交;
  • Commit 超时结果不确定;
  • 无幂等键;
  • 无唯一写入权威;
  • 无 Route Epoch;
  • 无最终校验。

临时止损

  • 暂停租户 77 写入;
  • 将权威路由恢复到 A;
  • 从审计日志构造修复事件;
  • 对 B 做幂等补偿;
  • 精确校验后恢复服务。

最终修复

  • 源业务事务原子写 Outbox;
  • CDC 幂等应用 B;
  • 使用 row_version
  • 建立 Shadow Read;
  • Cutover 前 Fencing;
  • Cutover 后保持反向 CDC 窗口;
  • Commit 不确定通过幂等键查询。

监控补充

  • Outbox 最老事件年龄;
  • CDC Lag;
  • Duplicate Event;
  • Version Conflict;
  • Route Epoch Rejection;
  • 每次迁移的 Bucket 校验结果。

防止复发

禁止上线不具备以下条件的迁移:

Single Writer
Idempotency
CDC Cursor
Validation
Fencing
Cutover CAS
Rollback Plan

18. 面试题

18.1 核心概念题

题 1:什么情况下应该开始分片?

30 秒回答: 只有在 SQL、索引、数据模型、垂直扩展、缓存、读副本、分区和工作负载拆分之后,仍能证明单 Primary 的写入、容量或故障域无法满足 SLO 时,才应分片。

深入回答: 证据应包含持续 P99、CPU、I/O、WAL、Pool Wait、Hot Key、Replica Lag 和容量余量。表行数不是充分条件。分片的收益是增加总资源并缩小局部故障域,代价是路由、Fan-Out、全局约束、跨分片事务和恢复复杂度。

考察: 是否会先证明瓶颈。 常见错误: “超过一亿行就分片。” 追问: CPU 95% 是否足以证明要分片? 答案: 不足。还需判断是否由低效 SQL、计划错误、无界并发或单热点造成。


题 2:分区与分片有什么区别?

30 秒回答: 分区是在同一 PostgreSQL 集群内把一个逻辑表拆成多个子表;分片是把数据分到多个独立数据库或节点。

深入回答: 分区共享 Primary、WAL、Buffer、连接和故障域,适合裁剪和生命周期管理。分片增加独立资源和写域,但丢失天然全局 Snapshot、锁、Sequence 和唯一约束。

考察: 是否理解故障域和写入域。 常见错误: “创建 Hash Partition 就是分库分表。” 追问: 分区能否提高写吞吐? 答案: 可能减少局部索引和维护成本,但不会新增跨机器 Primary 写能力。


题 3:为什么读副本不能扩展写入?

30 秒回答: Hot Standby 只重放 Primary 产生的 WAL,并接受只读查询;业务写仍在 Primary 执行和提交。

深入回答: 副本可以转移读取,从而间接释放 Primary CPU 和 I/O,但 Tuple、Index、Lock、WAL 和 Commit 仍由 Primary 完成。多主写入属于不同架构,需要冲突和全局事务语义。

考察: 是否混淆读扩展和写扩展。 常见错误: “一主三从就是四倍数据库能力。” 追问: 副本何时反而伤害 Primary? 答案: 复制槽、反馈、同步复制等待、WAL 保留和网络带宽可能增加 Primary 压力。


题 4:如何选择 Shard Key?

30 秒回答: 选择高频查询携带、事务可共置、稳定、高基数、按负载均匀且可迁移的键。

深入回答: 需分析最大租户而非平均租户,还要考虑地域合规、全局查询、未来增长和热点。Tenant ID 适合租户内事务,但对 Whale Tenant 需要专用 Shard 或二级键。

考察: 数据局部性与倾斜。 常见错误: 只看字段基数。 追问: 时间戳适合做 Hash Shard Key 吗? 答案: 通常不适合时间范围查询;时间更适合作为分区键,分片键常选租户或实体。


题 5:什么是 Hot Shard?

30 秒回答: 请求、CPU、I/O、WAL、锁或增长速度显著高于其他分片的物理 Shard。

深入回答: Hot Shard 不一定数据最多。原因可能是 Hot Tenant、Hot Row、索引页、慢 SQL 或硬件异常。Virtual Bucket 便于搬迁负载,但不能拆分单一 Hot Key。

考察: 是否按负载而非容量判断。 常见错误: “最大 Shard 就是 Hot Shard。” 追问: 第一止损措施是什么? 答案: 限制放大源、保护核心写入、识别热租户,再决定迁移或修改键。


18.2 原理与排障题

题 6:如何定义数据库可持续容量?

30 秒回答: 在代表性负载和数据分布下,经历 Checkpoint、Vacuum 和业务周期后,仍满足延迟、错误率、复制延迟和队列稳定性的最大持续负载。

深入回答: 需同时检查 CPU、I/O、WAL、连接、锁、Pool、热租户和恢复余量。瞬时峰值和平均 TPS 都不能代表可持续容量。

考察: 性能工程方法。 常见错误: 只看 CPU 或压测最高 TPS。 追问: 为什么队列稳定性重要? 答案: 到达率长期大于服务率时,即使短期错误率低,排队也会最终耗尽内存和超时预算。


题 7:Fan-Out 为什么容易恶化 P99?

30 秒回答: 整体请求通常要等待最慢分片,并且分片越多,遇到慢节点或失败节点的概率越大。

深入回答: 还会增加连接、网络、排序、内存和读取放大。应限制宽度和并发,设置每分片 Deadline,并为部分失败定义明确契约。

考察: Tail Latency。 常见错误: “并行就一定更快。” 追问: 什么时候可以返回部分结果? 答案: 只有 API 明确允许近似或不完整结果,并携带失败 Shard 和完整性标记时。


题 8:如何做跨分片分页?

30 秒回答: 使用 (sort_key, global_id) 的 Keyset Cursor,每个分片读取局部下一页,再在 Router 做 K 路归并。

深入回答: 全局 OFFSET 会让每个 Shard 扫描大量无用行,且并发写入下不稳定。迁移期间 Cursor 还应携带 Route Epoch,并按 Global ID 去重。

考察: 全序与分页稳定性。 常见错误: 每个 Shard 都执行相同 OFFSET。 追问: 为什么需要 Global ID Tie-Breaker? 答案: 排序键相同时,没有唯一 Tie-Breaker 就无法构造稳定全序。


题 9:2PC、Saga 和 Outbox 如何选?

30 秒回答: 高频事务优先保持单分片;必须原子提交且有可靠协调器时才考虑 2PC;长业务流程使用 Saga;跨系统事件传递使用 Outbox。

深入回答: 2PC 强一致但降低可用性并可能保留锁。Saga 最终一致且需要补偿。Outbox解决本地事务与事件发布原子性,但不提供瞬时跨系统原子可见性。

考察: 一致性与可用性权衡。 常见错误: “Outbox 等于分布式事务。” 追问: Prepared Transaction 泄漏有什么风险? 答案: 长期持锁、阻塞 Vacuum,并可能推动事务 ID 风险。


题 10:如何计算分片连接池预算?

30 秒回答: 按每个物理 PostgreSQL 实例计算:应用实例数乘每实例 Pool 上限,再加后台、监控、迁移和管理连接,不得超过可用连接预算。

深入回答: 应用水平扩容会成倍增加连接。多个 Database 位于同一 PostgreSQL 实例时必须合计。Pool Wait 比简单连接利用率更能反映排队。

考察: goroutine、Pool 和 Backend 的关系。 常见错误: 每个应用实例都把 Pool 设到数据库上限。 追问: Pool 越大 TPS 越高吗? 答案: 不一定;过多活跃查询会增加调度、内存、锁和缓存竞争。


题 11:Resharding 的最关键安全点是什么?

30 秒回答: 保持唯一写入权威、使用幂等 CDC、完成校验,并在 Cutover 前通过 Route Epoch Fencing 拒绝旧路由。

深入回答: 安全流程是 Backfill、Catch-Up、Validation、Fence、Final Delta、CAS Cutover、观察和 Finalize。直接双写会产生无法自动判定的部分提交。

考察: 在线迁移状态机。 常见错误: CDC Lag 为零就立即改路由。 追问: Cutover 后如何 Rollback? 答案: 先将目标新写反向同步到源,再 Fence 并切回;不能只改元数据。


18.3 架构设计题

题 12:设计全局 ID

30 秒回答: 根据索引局部性、信息暴露、生成依赖和路由需求,在 UUIDv4、UUIDv7、Snowflake 类 ID 或复合 ID 中选择。

深入回答: UUIDv7 大致按时间排序但不是 Commit 顺序;Snowflake 需要 Worker Lease 和时钟处理;复合 Shard ID 会暴露拓扑。全局 ID 与 Shard Key 应独立设计。

考察: 唯一性、顺序和路由的区别。 常见错误: 认为 UUID 自动解决分片。 追问: 如何保证用户邮箱全局唯一? 答案: 使用独立唯一性目录或 Home Shard,而不是依赖各 Shard 的本地 Unique Index。


题 13:设计多租户分片

30 秒回答: 以 Tenant ID 为一级路由,使用 Virtual Bucket 到物理 Shard,并为超大租户提供 Override 或二级分片。

深入回答: 租户内表必须共置,所有关键 SQL 应携带 Tenant ID。路由元数据使用 Epoch。热租户可以迁移到专用 Shard,跨租户报表通过 CDC 和汇总系统处理。

考察: 共置、热点和可迁移性。 常见错误: 只做 hash(tenant_id) % N追问: 扩容时为何不能直接改 N? 答案: 大量键会重新映射,导致数据位置和路由不一致;应使用 Virtual Bucket 或一致性迁移流程。


题 14:设计分片级高可用

30 秒回答: 每个 Shard 使用 Primary、Replica、备份和 PITR,并由高可用元数据服务向 Router 发布带 Epoch 的拓扑。

深入回答: Failover 必须 Fence 旧 Primary,Router 关闭旧连接并重连。每 Shard 独立定义 RPO/RTO。全局恢复需要 Backup Epoch 或业务一致性屏障。

考察: 数据面和控制面 HA。 常见错误: 只给数据 Shard 做 HA。 追问: Metadata Service 不可用怎么办? 答案: 已缓存稳定路由可短时继续;未知或迁移中的写入应拒绝,避免错误路由。


题 15:如何设计全局分析查询?

30 秒回答: 优先通过 Outbox/CDC 将各 Shard 数据汇入分析库或汇总表,而不是在线 Fan-Out 所有 OLTP Shard。

深入回答: 可分解指标在 Shard 内预聚合,再合并。精确 Distinct、Percentile 和复杂 Join 更适合分析系统。需要明确数据新鲜度、重放、去重和校验。

考察: OLTP 与 Analytics 隔离。 常见错误: 在用户请求路径实时扫描全部 Shard。 追问: 如何处理 CDC 重复事件? 答案: 使用稳定 Event ID、目标去重表和幂等 Upsert。


19. 练习与参考答案

19.1 理论题

1. 为什么“CPU 还有 50% 空闲”不能证明容量安全?

答案: 可能存在单 Shard、单 Core、WAL、I/O、锁、连接池或 Replica Lag 边界;平均 CPU 也可能掩盖热点。还要检查 P99 和排队是否随负载持续增长。

2. 为什么 Virtual Bucket 不能拆分一个 Hot Tenant?

答案: 当 Virtual Bucket 仍由 hash(tenant_id) 决定时,同一租户只有一个 Bucket。要拆分其内部负载,需要二级键、Bucket 化或业务拆分。

3. 为什么分片后不能直接使用全局 Sequence?

答案: 独立 PostgreSQL 实例的 Sequence 不协调,可能产生重复值。需要 UUID、Snowflake 类 ID、复合 ID 或中心分配服务。

4. 为什么跨分片 Snapshot 困难?

答案: 各实例拥有独立事务 ID、Snapshot、WAL 和 Commit 时间点。分别开启 REPEATABLE READ 只能获得每个实例各自一致的 Snapshot,不能天然保证同一全局业务时刻。

5. 为什么直接双写比 Outbox 风险高?

答案: 两个独立 Commit 不能由普通应用原子完成,网络超时还会产生 Commit 结果不确定。Outbox 将业务修改和待发送事件纳入一个本地事务。


19.2 实验题

1. 将实验一扩展为 4 个 Shard,并验证应用扩容后的连接预算

参考答案:

记录:

应用实例数 × 每 Shard MaxConns

在每次增加应用实例后查询 pg_stat_activity,并记录 pgxpool.Stat()。确认所有实例的 Pool 总和仍低于每个 PostgreSQL 集群的可用连接预算。

2. 将实验二中的热租户拆为 16 个实体 Bucket

参考答案:

使用:

bucket = stable_hash(entity_id) % 16

主键和路由包含:

tenant_id, bucket, entity_id

比较写等待和读取 Fan-Out。说明锁竞争下降的同时,租户级聚合成本上升。

3. 为实验三增加 DELETE Tombstone

参考答案:

Outbox 增加:

operation = DELETE
row_version

目标保留 Tombstone 或删除版本记录。Backfill Upsert 必须检查目标 Tombstone Version,不能用旧 Snapshot 复活数据。


19.3 排障题

1. 所有 Shard CPU 都不高,但 P99 和 Pool Wait 上升

参考答案:

依次检查:

  1. Pool 大小和应用实例数;
  2. idle in transaction
  3. 应用是否在事务内调用外部服务;
  4. Lock Wait;
  5. 同步复制等待;
  6. Client Write;
  7. 请求是否在应用 Semaphore 前排队;
  8. Fan-Out 是否占用所有连接。

2. 新增 Shard 后总体性能变差

参考答案:

检查:

  • 是否真的完成数据 Rebalance;
  • Fan-Out 宽度是否增加;
  • Router 网络路径;
  • Pool 总数和连接预算;
  • Metadata 查询;
  • 跨 Shard Join;
  • CDC 和复制带宽;
  • 数据倾斜是否仍存在;
  • 新节点硬件和配置是否一致。

19.4 系统设计题

题目

设计一个全球 SaaS 订单系统:

  • 100 万租户;
  • 最大租户占总写入 20%;
  • 租户内事务要求强一致;
  • 跨租户报表允许 5 分钟延迟;
  • 单租户迁移不能停机超过数秒;
  • 单 Shard 故障不能影响其他 Shard。

参考设计

一级键:tenant_id
路由:tenant_id → virtual bucket → regional shard
超大租户:Directory Override → dedicated shard
事务:租户内本地 PostgreSQL 事务
读取:Primary + 每 Shard Replica
报表:Outbox/CDC → 分析系统
ID:UUIDv7 或可靠 Snowflake 类 ID
迁移:Backfill + CDC + Validation + Epoch Fencing
HA:每 Shard 独立 Primary/Replica/PITR
控制面:强一致 Metadata Service
限流:全局、区域、Shard、Tenant 四级

最大租户不能只依赖普通 Tenant Hash,应预留:

  • 专用 Shard;
  • 二级实体分片;
  • 热点计数 Bucket;
  • 独立容量和限流策略。

20. 检查清单

  • 我能够用 SLO、排队和资源增量定义可持续容量。
  • 我能够证明瓶颈位于 CPU、内存、I/O、WAL、锁、Pool 或复制。
  • 我不会仅凭表行数决定分片。
  • 我能够解释为什么读副本不能扩展写入。
  • 我能够准确区分分区和分片。
  • 我能够比较 Tenant、Hash、Range 和 Directory-Based Sharding。
  • 我能够识别 Hot Shard、Hot Key、Hot Row 和数据倾斜。
  • 我能够说明 Virtual Bucket 的收益和边界。
  • 我能够设计 Global ID,但不会把 ID 与路由或提交顺序混淆。
  • 我能够实现有界 Fan-Out 和部分失败策略。
  • 我能够设计跨分片排序、聚合和 Keyset Pagination。
  • 我能够判断何时使用本地事务、2PC、Saga 或 Outbox。
  • 我能够设计 Backfill、CDC、Validation、Fencing 和 Cutover。
  • 我能够解释 Cutover 后 Rollback 为什么需要反向同步。
  • 我能够为每个 Shard 计算连接池预算。
  • 我不会为每个租户创建独立 Pool。
  • 我能够在 Go 中处理 SQLSTATE、完整事务重试和 Commit 不确定。
  • 我能够设计分片级备份、复制、Failover 和 Metadata HA。
  • 我能够分析分片带来的读、写、网络和空间放大。
  • 我能够用实验而不是固定参数证明扩展方案有效。

官方资料来源

  • PostgreSQL 18 版本状态、支持周期与 Release Notes。(PostgreSQL)
  • PostgreSQL 累计统计系统、pg_stat_iopg_stat_walpg_stat_replicationpg_stat_checkpointer。(PostgreSQL)
  • pg_stat_statements 官方文档。(PostgreSQL)
  • Hot Standby 只读语义和 Recovery Conflict。(PostgreSQL)
  • PostgreSQL 声明式分区及唯一约束限制。(PostgreSQL)
  • Materialized View 与并发刷新条件。(PostgreSQL)
  • PostgreSQL 两阶段提交。(PostgreSQL)
  • PostgreSQL 逻辑复制。(PostgreSQL)
  • PostgreSQL 18 UUIDv7。(PostgreSQL)
  • pgxpool API 与 Pool Statistics。(Go Packages)
  • Citus Distribution Column、Sharding Model 和在线 Rebalancing。(Citus Documentation)