系统设计 07:数据库、复制与一致性——提交成功意味着谁已收到
“主库已返回成功”和“备库此刻一定读得到”不是同一个承诺。加一个读副本前,要知道写请求究竟等谁确认,读请求允许多旧,主库失效后由谁确认它不会继续接写。否则把部分请求改路由到副本,可能让刚创建的分享消失在下一次读取里。
承接缓存与撤销边界:创建分享要把分享和幂等键映射原子写入,撤销确认后不能再公开返回目标。本文讨论单地域里一个权威 PostgreSQL 主库和一个只读热备库;不设计跨地域多主、自动选主或没有栅栏的“快速切换”。非关键的统计列表可以容忍短暂滞后,受保护的目标读取和撤销状态则不能偷偷改为读异步备库。
把读写语义写在接口上
POST /shares 接收幂等键,同一个事务中写 shares 与 requests;DELETE /shares/{id} 写状态并在权威库提交后才确认。GET /shares/{id} 与撤销后仍可访问的所有者列表在当前强撤销契约下都先读主库或由等价的强鉴权路径承接;例如只做非权限用途的公开统计 GET /stats?share_id=... 才可读允许滞后的副本。无视这个划分把所有 GET 都改到副本,优化的是吞吐数字,破坏的是可见性约束。网关重试写入仍要保留幂等键,不能让故障切换产生第二次创建。
本文设服务端受保护读取 p99 小于 200 ms、写提交 p99 小于 300 ms,统计副本读取允许至多 5 秒陈旧;它们只是产品练习目标。本地实验只观察到一次轮询内复制完成,不能证明任何百分位或五秒滞后上限。若要求故障后已确认写入的 RPO=0,也不能仅凭默认异步复制满足;需要明确定义同步确认和故障隔离,并对真实部署做试验。
独立容量假设:每天 400 万读、20 万写,读写比 20:1;共同峰值系数 8,则约 370.37 read/s、18.52 write/s。假设每次读有效载荷 1 KiB,读出口峰值约 0.362 MiB/s ≈ 3.03 Mbit/s;每次写的 WAL 预算取 400 B,复制链路在同一峰值窗口约 18.52 × 400 ≈ 7408 B/s,这是简化预算,未计 WAL 格式、索引写放大、压缩、TLS、流复制协议、备份或重发。存储按每次写入 800 B 元数据和 200 B 索引、保留 365 天、主备两份计 146 GB;每条写入 400 B WAL 保存 7 天、两份计 1.12 GB;每次读写一条 200 B 日志保存 7 天、一份计 5.88 GB;三项合计约 153 GB。以教学单价 0.02 货币单位/(GB·月) 估 3.06 货币单位/月,未含备份、CPU、复制流量和监控。多加一份完整备库将增加至少 73 GB 元数据/索引和约 0.56 GB WAL 预算,不能从“读 QPS 翻倍”直接推出新增副本就够。
两种确认策略回答不同问题
初版一主一备、异步流复制:主库事务提交后向客户端确认,备库随后接收并应用 WAL。PostgreSQL 16 官方文档明确指出流复制默认异步,主库崩溃时已提交的部分事务可能还没有复制到备库(PostgreSQL 16:Streaming Replication)。因此受保护读取走主库;副本只承接明确容忍滞后的查询,客户端不能把“写成功立即去副本读”当作不经校验的承诺。
若产品要求故障切换后绝不丢已确认写入,另一候选是配置同步备库、写确认等待备库达到约定的持久化/应用位置,并为备库不可用时的写可用性付出代价。PostgreSQL 的 synchronous_standby_names 和 synchronous_commit 共同决定确认边界,不同等级如 remote_write、remote_apply 含义不同(PostgreSQL 16:Synchronous Replication)。本篇没有配置同步备库,更未验证 RPO=0;选择哪种策略要让业务先回答“可用性优先还是零丢失优先”,并补故障与写停机试验。
flowchart LR
C[客户端] -->|POST 幂等键 / DELETE 撤销| A[应用]
A -->|事务写入并等待主库提交| P[(PostgreSQL 主库)]
P -->|WAL 流传输,默认异步| S[(只读热备库)]
S -->|只供允许陈旧的统计读| A
A -->|受保护读取再次核对主库状态| P
P -->|提交确认或失败| A
A -->|明确成功与否| C
这个图的“热备可读”不等于“故障后一键无损切换”。要提升备库,先隔离旧主库的写入,再决定用哪份 WAL 作为新的权威事实;旧主库重返集群须单独恢复/重建,而不能两边同时接收相同幂等键的写入。
用真实 PostgreSQL 走一遍停机和提升
本机已有 PostgreSQL 16.15,但实验不碰现有系统集群。examples/system-design/labs/07/replication.sh 用 /tmp 的私有数据与 Unix socket 目录启动两台独立实例;设置 wal_level=replica、max_wal_senders=4、hot_standby=on,pg_basebackup -R -X stream 建备库(-R 写入备库启动所需恢复配置;见 PostgreSQL 16 pg_basebackup)。没有 TCP 监听,进程结束会停两实例并清理临时目录。
先读 pg_is_in_recovery() 得真;主库写一条记录后轮询最多五秒直至备库读到,不能用这个等待之后的值冒充“提交瞬间已复制”。在提升前试图从备库写,PostgreSQL 返回 cannot execute INSERT in a read-only transaction;实验的 --strict-readonly 把此拒写当反例退出 2。先让主库 pg_ctl -m immediate stop,备库仍读到那条记录;手动提升后 pg_is_in_recovery() 为假,备库现在能写新记录。正常命令退出 0,原始输出、版本、配置见 examples/system-design/evidence/07/replication.md。
1 | |
sequenceDiagram
participant C as 客户端
participant P as 主库
participant S as 热备库
C->>P: INSERT id=1 并提交
P-->>C: 主库提交确认
P-->>S: 异步发送 WAL
C->>S: 轮询读到 id=1 后才继续
S-->>C: id=1
C->>S: 提升前试写 id=2
S-->>C: 只读错误
Note over P,S: 先停掉旧主库,再手工提升备库
C->>S: 提升后 INSERT id=2
S-->>C: 提交成功,最终两行
故障时间线的缺口同样重要:这里先等复制到位,再主动停主库,自然看不到未复制确认写入的丢失窗口;没有脑裂、网络分区、自动故障检测或主库回归。恢复过程中若旧主库未被隔离,任何自动提升方案都不应照搬这份脚本。面试里先划清强/弱读取,再画确认和 WAL 到达的先后,最后追问“主库提交后但备库应用前崩溃”与“旧主库是否已被栅栏隔离”;只有这样,“加一台读副本”才是一项有代价和适用范围的决策。
参考资料
- PostgreSQL 16:Warm Standby 与流复制、同步复制:默认异步的失效窗口与同步确认选项。
- PostgreSQL 16:pg_basebackup:
-R与备库恢复配置。本文实际运行版本为 16.15,不与系统已有 16/main 集群混用。



