分片、复制与故障切换
分片把不同记录放到不同位置,复制为同一批记录保留多个运行副本,备份保存可以恢复的历史。一个订单系统可以同时有多个分片、每片多个副本,以及独立保存的备份;这三种安排改变的是不同问题。
数据放在哪里,决定请求经过哪些节点
分片与副本是两个维度
订单集合
├── 分片 A:一部分租户
│ ├── 主副本 A1:接收写入
│ └── 备副本 A2:接收 A1 的变更
└── 分片 B:另一部分租户
├── 主副本 B1:接收写入
└── 备副本 B2:接收 B1 的变更
独立备份:基线数据 + 可恢复到目标位置的日志增加分片可以分摊数据量和部分写负载,但一次跨租户查询可能同时访问 A、B,再合并结果。增加 A 的备副本能提供故障接替或符合契约的读取,却仍要接收 A 的全部写入。若瓶颈是一条热点记录的并发修改,把它再复制两份通常不会消除写竞争。
复制也会传播误删除、错误更新与数据格式问题。备份应有与在线副本不同的保留策略、访问权限和恢复入口;持续运行的备库与一份已验证可恢复的备份承担不同职责。物理备份、WAL 归档和时间点恢复的关系见 PostgreSQL 连续归档。
哈希、范围与目录路由
| 方式 | 请求怎样找到数据 | 有利的访问 | 主要代价 |
|---|---|---|---|
| 哈希分片 | 对分片键计算哈希,再映射到分片 | 带完整键的点查询,较均匀的分布 | 范围查询扇出;改变取模数可能移动大量记录 |
| 范围分片 | 按键值区间查找所属分片 | 范围扫描、按时间归档 | 单调增长的键容易把新写集中到末端 |
| 目录分片 | 查询租户/逻辑分片到物理节点的映射 | 大租户单独迁移、按业务调整位置 | 目录自身需要一致性、缓存更新和故障处理 |
分片键要同时考虑基数、倾斜、单调性和访问共现。按 tenant_id 分片能把同一租户的订单与明细放在一起,但最大租户仍可能独占一片容量;按 order_id 分片可摊开订单,却使“查询租户全部订单”变成扇出。MongoDB 分片说明展示了分片键、路由和数据块共同工作的实现方式。
虚拟分片把大量逻辑单元映射到较少物理节点,扩容时只改变一部分映射。一致性哈希通过环或其他稳定映射减少成员变化时的搬迁量;它改善的是分布和迁移粒度,不会让同一个热点键自动分散。把一个账户的余额拆成多个子键,还需要重新定义扣减、汇总和透支限制。
客户端拿到目标地址后,路由工作也没有结束。应用连接池、网关和后台任务都可能缓存旧映射,迁移后的数据入口应识别 mapping_version,让旧客户端刷新或明确失败。只降低缓存 TTL,仍会留下新旧路由并存的窗口。
事务、唯一性与查询的范围随分片改变
在同一数据库里,订单表的 (tenant_id, order_id) 唯一约束可拒绝重复订单;拆到两台独立数据库后,各自只能检查自己的数据。若同一订单被旧、新路由写到两个地方,两边的局部唯一约束都可能成功。
常见做法是把唯一性限定在业务归属范围,并让同一归属在某个迁移阶段只有一个可写入口;确实需要全局唯一索引时,索引本身会成为跨片协调对象。全局 ID 解决标识冲突,也不能防止同一业务请求换一个 ID 后重复执行,相关区别见时间、逻辑时钟与全局 ID。
跨片查询还要处理部分失败和合并顺序:一个分片超时,是整次查询失败、返回带缺失标记的部分结果,还是延迟重查?排序分页需要稳定的排序键和游标,直接拼接各片的 LIMIT/OFFSET 并不能得到正确的全局页。跨片事务则必须选择本地化、原子提交或异步补偿,见2PC、TCC 与 Saga。
一条写入怎样到达副本
接收、持久化和重放分别发生
PostgreSQL 物理流复制从主库传输 WAL。WAL sender 发送日志,备库 WAL receiver 接收并保存,恢复进程再重放日志,改变备库页面。收到某段日志和已经应用它之间,可以存在明显间隔。流复制机制
主库事务提交
│
├─ 本地 WAL 持久化
└─ WAL sender ──网络──► WAL receiver
│
├─ write:写到备库操作系统
├─ flush:刷到备库持久存储
└─ replay:重放后供新查询看见数据库允许在不同位置等待确认。PostgreSQL 中,下表的远端等待还要求配置了 synchronous_standby_names;只有把 synchronous_commit 设为 on,而同步备库集合为空,提交仍不等待备库。
synchronous_commit | 本地 WAL 等待 | 同步备库确认到哪里 |
|---|---|---|
off | 不等本地刷盘完成 | 不等待 |
local | 本地刷盘 | 不等待 |
remote_write | 本地刷盘 | 备库操作系统已写入,尚未保证备库刷盘 |
on | 本地刷盘 | 备库 WAL 已刷盘 |
remote_apply | 本地刷盘 | 备库已重放,新的查询可看见该事务 |
这是提交等待位置,不是所有存储设备故障下的统一保证。remote_write 在备库数据库进程重启与备库操作系统崩溃时的风险不同;remote_apply 也不会让一个已经持有旧快照的事务突然看到新版本。WAL 提交配置
配置 synchronous_standby_names = 'ANY 1 (s1,s2)',含义是从列出的两个备库中等待任意一个确认;主库不计入这里的 1。FIRST 按名单优先级选择,ANY 按所需数量收集确认。同步备库不足时,提交可能等待;临时解除同步等待会改变后续提交的丢失窗口,不能当作无影响的性能开关。
复制确认与选主是两套动作
单领导者复制让写入先由一个主节点排序;多领导者与无领导者系统则需要额外处理并发版本、冲突和修复。多个节点返回成功,仍需解释哪些副本必须相交、版本怎样比较、成员集合如何变化,见 CAP 与一致性模型。
Raft 把选举、日志匹配和提交规则结合起来。新任期中的领导者必须满足日志选举约束,不能随便选一个在线节点覆盖已经提交的数据;成员变化也要保持法定集合的安全交接。Raft 原论文
PostgreSQL 的 ANY n 同步复制没有自动增加一套 Raft 选主协议。故障管理器仍要决定谁接替、如何隔离旧主,以及客户端写入口怎样切换。心跳超时只表明暂时没收到响应:旧主可能已经宕机,也可能还在另一侧接受写入。直接提升备库而不阻断旧主,会形成两个可写主库。PostgreSQL 故障切换
观察真实流复制中的旧读
启动独立主备
使用 Linux、Docker Engine、Compose v2、Bash、unzip 和 OpenSSL。下载复制与分片实验包,保存为 distributed-replication-lab.zip,在新目录解压:
mkdir ds14-repl-work
unzip distributed-replication-lab.zip -d ds14-repl-work
cd ds14-repl-work/distributed-replication
docker version
docker compose version
export LAB_DB_PASSWORD=$(openssl rand -hex 18)
export LAB_APP_PASSWORD=$(openssl rand -hex 18)
export LAB_REPLICATION_PASSWORD=$(openssl rand -hex 18)
docker compose pull
docker compose up -d --wait --wait-timeout 90
docker compose psDocker 应同时显示客户端和服务端,两个服务最终为 healthy。脚本只使用 ds14-repl 项目;如已有同名资源,换独立 Docker 环境,不接管他人的容器。宿主操作者须有已批准的 Docker daemon 访问权限,这项权限本身较高。
镜像固定 postgres:18.6,主、备进程均以 999:999 运行,无构建容器;数据和 socket 目录使用指定该 UID/GID 的可写 tmpfs。容器停止或重建后数据丢失,不用该环境保存业务数据。两个容器没有映射宿主端口,仅通过项目专用网络通信;复制连接使用独立账号和密码,未启用 TLS,不应接入共享网络。官方 PostgreSQL 镜像
docker compose exec -T primary postgres --version
docker inspect ds14-repl-primary --format '{{.Config.User}}'
docker inspect ds14-repl-standby --format '{{.Config.User}}'预期版本包含 18.6,两个身份均为 999:999。镜像不可达时使用企业批准的仓库、代理,或在可信联网环境 docker save 后连同校验和搬运并 docker load;不关闭证书验证。密码由当前 shell 传入容器环境,有 Docker 管理权限的人仍能读取容器配置。这只适合独立实验;不要公开完整环境变量或 compose config 输出,操作结束后清理容器并清除变量。
初始化的关键路径是:主库启用 wal_level=replica 和足够的 max_wal_senders,创建 lab_replicator,在 pg_hba.conf 允许复制连接;备库在空目录执行:
pg_basebackup -d "host=primary user=lab_replicator application_name=standby" \
-D "$PGDATA" -R -X stream -c fast -w这条命令由包内 standby.sh 以备库的 postgres 身份执行。PGDATA 与 PGPASSWORD 已由 Compose 提供,不是在宿主直接粘贴执行;-R 写入 standby.signal 和恢复连接信息,-X stream 在基线备份期间同时传输 WAL,-w 避免交互式密码等待。起点必须包含能让基线恢复一致的日志,直接复制一份正在变化的数据目录不具备这个性质。pg_basebackup
先识别角色,再读复制位置
后续命令在解压目录的同一个 Bash 中执行。定义一个仅缩短 psql 入口的函数:
sql() {
docker compose exec -T "$1" psql -X -qAt -v ON_ERROR_STOP=1 \
-U lab_owner -d lab -c "$2"
}
sql primary 'SELECT pg_is_in_recovery()'
sql standby 'SELECT pg_is_in_recovery()'
sql primary "SELECT application_name,state,sync_state FROM pg_stat_replication"
sql standby 'SELECT value FROM lab.probe WHERE id=1'-X 忽略个人 psql 启动配置,ON_ERROR_STOP=1 让 SQL 错误导致命令失败;该入口通过容器内 socket 使用实验管理账号。正常结果依次为 f、t、standby|streaming|async、v1。如果备库尚未 streaming,先看 docker compose logs --tail=80 standby,不要把初始化未完成当作复制延迟。
主库的 pg_stat_replication 每行对应一条 WAL sender 连接;sent_lsn、write_lsn、flush_lsn、replay_lsn 分别帮助定位发送、接收写入、刷盘和应用的位置。备库则可直接查询接收与重放位点。复制统计视图
sql primary 'SELECT application_name,sent_lsn,write_lsn,flush_lsn,replay_lsn FROM pg_stat_replication'
sql standby 'SELECT pg_last_wal_receive_lsn(),pg_last_wal_replay_lsn()'LSN 是 WAL 中的位置,形如两个十六进制数,中间用 / 分隔;它不是订单数量,也不是耗时。相减应使用 pg_wal_lsn_diff 得到字节差,比较应转换为 pg_lsn,不要按字符串大小排序。
暂停应用,但继续接收
先暂停备库重放,并确认状态已经进入 paused。暂停请求可能需要等待当前工作到达可停位置,所以不能只看函数返回。恢复控制函数
sql standby 'SELECT pg_wal_replay_pause()'
for attempt in $(seq 1 60); do
state=$(sql standby 'SELECT pg_get_wal_replay_pause_state()')
[ "$state" = paused ] && break
sleep 0.2
done
test "$state" = paused状态检查成功后写入主库,再取一个提交后的目标位置:
sql primary "UPDATE lab.probe SET value='v2' WHERE id=1"
target=$(sql primary 'SELECT pg_current_wal_lsn()')
for attempt in $(seq 1 60); do
received=$(sql standby "SELECT pg_last_wal_receive_lsn() >= '$target'::pg_lsn")
[ "$received" = t ] && break
sleep 0.2
done
test "$received" = t
sql standby "SELECT pg_last_wal_replay_lsn() < '$target'::pg_lsn"
sql standby 'SELECT value FROM lab.probe WHERE id=1'预期接收检查成功,重放仍落后返回 t,业务查询返回 v1。目标位置取自写成功之后,可能还包括其他 WAL,但达到它就覆盖了这次已提交更新。备库已经收到新日志,旧读来自重放暂停;把网络重连一遍不会解除暂停。
任一 test 失败都应停止该组实验并检查角色、连接和暂停状态;首先执行 sql standby 'SELECT pg_wal_replay_resume()' 解除人工暂停,再重新开始。包内 replication.sh 用同样的 SQL 自动检查这些结果,异常退出也会尝试恢复重放。
等到应用完成,再确认新值
sql standby 'SELECT pg_wal_replay_resume()'
for attempt in $(seq 1 60); do
applied=$(sql standby "SELECT pg_last_wal_replay_lsn() >= '$target'::pg_lsn")
[ "$applied" = t ] && break
sleep 0.2
done
test "$applied" = t
sql standby 'SELECT value FROM lab.probe WHERE id=1'重放检查应成功,新查询读到 v2。生产中的“读己之写”可以采用同类最低位点机制:写响应返回可关联的位点,读路径选择已追到该位点的副本,超时后回主或报错。位点必须关联正确集群和复制历史;跨分片、切换后的不同时间线之间不能只比较两个裸 LSN。
该实验人为暂停的是应用阶段。复制延迟也可能来自网络、备库刷盘慢、重放冲突或主库生成 WAL 过快,后面的排障顺序按这些阶段区分。
分片迁移时拦住旧路由
用实际表保存订单和位置
实验主库另有三张表:
lab.route:tenant_id → shard、mapping_version
lab.orders_a:物理表 A,主键 (tenant_id, order_id)
lab.orders_b:物理表 B,主键 (tenant_id, order_id)租户 42 初始指向 A,版本为 1。应用账号 lab_app 只能读这些表,写入通过 lab.place_order 函数完成;它不能绕开函数直接改订单表。该函数采用 SECURITY DEFINER,固定安全 search_path,限制授权并使用明确表名,避免把提权入口暴露给任意调用者。CREATE FUNCTION 安全要求
定义实际使用应用账号的入口:
app() {
docker compose exec -T -e PGPASSWORD="$LAB_APP_PASSWORD" primary \
psql -X -qAt -v ON_ERROR_STOP=1 -v VERBOSITY=verbose \
-h 127.0.0.1 -U lab_app -d lab -c "$1"
}
app 'SELECT * FROM lab.route WHERE tenant_id=42'
app 'SELECT lab.place_order(42,1001,80,1,1)'
app 'SELECT * FROM lab.orders_a'这是全新实验数据上的第一次订单写入。路由为 42|1|1,订单为 42|1001|80。相同调用再次执行,会以 SQLSTATE 23505 失败:唯一约束拒绝同一租户的重复订单,不会把重复执行当作幂等成功。
函数的关键过程是先锁住路由行,再检查版本,最后在同一事务写表:
SELECT * INTO STRICT current_route FROM lab.route
WHERE tenant_id = p_tenant FOR SHARE;
IF current_route.shard <> p_shard
OR current_route.mapping_version <> p_version THEN
RAISE EXCEPTION 'stale mapping' USING ERRCODE = 'P0001';
END IF;FOR SHARE 在事务结束前持有行锁。迁移更新同一条路由时必须等待已开始的订单事务结束,新写入则等待迁移完成后再判断位置。这项锁关系由数据库执行;单独在应用内比较一个缓存版本,无法封闭“检查通过后、写表之前”的并发窗口。PostgreSQL 显式锁
同库短事务搬迁
在没有其他实验调用的情况下,以管理账号把租户搬到 B:
sql primary 'BEGIN;
SELECT tenant_id FROM lab.route WHERE tenant_id=42 FOR UPDATE;
INSERT INTO lab.orders_b SELECT * FROM lab.orders_a WHERE tenant_id=42;
UPDATE lab.route SET shard=2, mapping_version=2 WHERE tenant_id=42;
DELETE FROM lab.orders_a WHERE tenant_id=42;
COMMIT;'这是一台数据库内的短事务:路由变更与实际记录移动一起提交,失败则一起回滚。它适合解释写入口与迁移互斥,但大租户搬迁会长时间持锁、生成大量 WAL;跨数据库也不能直接沿用这项原子性。
旧客户端继续带 A/版本 1 发送订单,应该被明确拒绝:
if error=$(app 'SELECT lab.place_order(42,1002,90,1,1)' 2>&1); then
printf '%s\n' '异常:旧路由写入成功'
false
else
printf '%s\n' "$error"
printf '%s\n' "$error" | grep -q 'P0001:.*stale mapping'
fi预期非零退出且包含 P0001 和 stale mapping。认证失败或网络错误不算这个负例成功。应用刷新路由后再发:
app 'SELECT * FROM lab.route WHERE tenant_id=42'
app 'SELECT lab.place_order(42,1002,90,2,2)'
app 'SELECT count(*),sum(amount) FROM lab.orders_b'
app 'SELECT count(*) FROM lab.orders_a'路由应为 42|2|2,B 表为 2|170,A 表为 0。应用若尝试 INSERT INTO lab.orders_a VALUES (42,1003,10),应得到权限错误 42501。版本检查只有覆盖所有写入口才有效;拥有表写权限的旁路脚本仍能破坏这条约束。
需要从初始化状态复跑时执行 bash sharding.sh。它会重置本实验的两张订单表和租户路由,不能接到业务数据库;自动检查包括重复主键、旧版本拒绝、禁止直接写表,以及迁移后真实行数与金额。
跨库在线迁移需要额外阶段
数据大到无法短暂停写时,搬迁通常分成快照复制、增量追赶、短暂切换和旧位置回收。每个阶段都要有失败后可继续使用的起点:
快照期间的新建、更新和删除都必须进入增量通道;初始快照与日志起点如果没有配对,会漏掉两者之间的修改。校验除了行数和金额摘要,还要按主键及版本比较差异,防止两条错误记录刚好抵消。路由切换前让目标追到明确位置,不能以“延迟显示接近零”代替切换条件。
回退也随阶段改变。目标尚未接收新写时,可以继续使用源库;目标已经接受独占新写后,直接把路由拨回源库会丢掉这部分结果,需要反向追赶或另一次受控迁移。旧数据的保留期覆盖观察与修复窗口,清理之前确认旧路由调用已经停止。
成熟系统会为迁移补锁、日志保留、路由刷新和进度记录。MongoDB 的 range migration是可查询的实现参照;本机两表实验不包含这套跨库迁移协议。
从延迟定位到安全切换
备库旧读:先找停在哪一段
主备仍在运行时,先查看角色、暂停状态和连接,不直接重启:
sql standby 'SELECT pg_is_in_recovery(),pg_get_wal_replay_pause_state()'
sql standby 'SELECT status,written_lsn,flushed_lsn FROM pg_stat_wal_receiver'
sql primary 'SELECT application_name,state,sent_lsn,write_lsn,flush_lsn,replay_lsn FROM pg_stat_replication'
docker compose logs --tail=80 primary standby
docker stats --no-stream ds14-repl-primary ds14-repl-standbypaused 表明人为暂停尚未解除,执行 pg_wal_replay_resume() 后按目标 LSN 复测。pg_stat_wal_receiver 没有行,先检查复制进程日志中的身份验证、主机解析、网络和所需 WAL 是否仍存在;收到 WAL 却重放不动,再看备库 CPU、磁盘和查询冲突。
sql standby 'SELECT datname,confl_tablespace,confl_lock,confl_snapshot,confl_bufferpin,confl_deadlock FROM pg_stat_database_conflicts'
sql standby "SELECT pid,state,wait_event_type,wait_event FROM pg_stat_activity WHERE backend_type='client backend'"长查询可能与重放清理操作冲突。缩短查询、处理阻塞或调整读任务的位置后,再观察冲突增量与 replay 位点。hot_standby_feedback 可减少部分清理冲突,但会增加主库保留旧版本的压力,不能只为消除备库报错而永久开启。Hot Standby 冲突处理
空闲主库没有新事务时,当前时间 − 最后重放事务时间 会继续增长;它不自动表示仍有日志积压。字节差、时间差和业务版本差分别回答传输了多少、等待了多久、读者缺了哪个结果,监控应结合实际写负载。
日志保留不足或主库空间持续增长
实验设置 wal_keep_size=64MB 为短暂断连保留一定 WAL,没有配置永久复制槽。生产可使用槽或归档保留备库所需日志,但保留机制本身消耗存储。
sql primary 'SELECT slot_name,slot_type,active,restart_lsn,wal_status,safe_wal_size FROM pg_replication_slots'
docker compose exec -T primary df -h /var/lib/postgresql槽长时间不活跃且 restart_lsn 停滞,可能持续占用 WAL。先判断消费者是否仍需恢复、日志缺口能否补齐,再恢复消费者或批准回收槽;删除槽可能让消费者无法从原位点继续。max_slot_wal_keep_size 限制槽可保留的量,代价是落后过多时需要重建消费者或备库。复制参数
若日志已被回收且归档也不能补齐,备库需要重新从可信基线创建,反复重启不会重新生成缺失 WAL。pg_basebackup 应写入新的空目录,确认新备库追上后再移除旧目录;不要对仍承担读写的目录执行清空命令。
心跳丢失后,先限制旧写入者
一次切换至少涉及故障检测、备库选择、旧主隔离、角色提升和客户端切流。检测器允许误判:网络抖动、GC 暂停、CPU 饱和都可能使活着的进程错过心跳。缩短超时会更快进入切换,也可能增加不必要切换和连接重建。
物理隔离可以是停止旧实例、撤销其存储或写入权限、阻断全部写入口;具体方案要覆盖应用、批处理和管理旁路。仅改 DNS,不能关闭旧连接池里仍保持的连接。新任期或 fencing token 也必须由实际资源检查,标识本身不会停止旧进程。
先隔离再提升的动作在本机可按以下方式观察。只有完成前面的主备与分片实验,并确认没有其他写入者时执行;它会停止旧主,且旧主 tmpfs 数据随之消失。
sql standby 'SELECT pg_wal_replay_resume()'
target=$(sql primary 'SELECT pg_current_wal_lsn()')
for attempt in $(seq 1 60); do
applied=$(sql standby "SELECT pg_last_wal_replay_lsn() >= '$target'::pg_lsn")
[ "$applied" = t ] && break
sleep 0.2
done
test "$applied" = t目标检查失败就停止,不提升。成功后停止旧写入者并检查实际状态:
docker compose stop primary
test "$(docker inspect ds14-repl-primary --format '{{.State.Running}}')" = false
sql standby 'SELECT pg_promote(true,30)'
sql standby 'SELECT pg_is_in_recovery()'
sql standby "UPDATE lab.probe SET value='after-switch' WHERE id=1"
sql standby 'SELECT value FROM lab.probe WHERE id=1'停止检查应成功,提升返回 t,恢复状态为 f,最后读到 after-switch。pg_promote(true,30) 最多等待指定秒数完成提升;如果失败或超时,先确认实际角色,不能直接重新启用旧主写入。包内 failover.sh 自动执行相同顺序,并检查切换前的值被保留。
这是停止其他写入后的受控切换,不包含真实宕机时未复制尾部的丢失、故障管理器选举或业务客户端切流。生产 RPO 取决于已确认写的同步位置与被选择的备库;RTO 还包含检测、追赶、提升、连接重建与缓存预热。只测新主端口打开,会漏掉多数业务恢复时间。
新主出现后,旧主怎样重新加入
提升后会形成新的时间线。旧主若在分叉处拥有新主没有的写,不能简单改连接地址后继续当备库。应保留现场,判断分叉与需要对账的业务;符合条件时使用 pg_rewind 同步差异,否则重新制作基线备库。pg_rewind 有数据校验和或 wal_log_hints 等前提,操作前必须具备可恢复副本。pg_rewind
本实验旧主数据位于 tmpfs,受控停止后不再有可供 rewind 的旧数据;不要在此环境模仿磁盘级恢复。角色切换完成后也不要直接重跑启动和迁移脚本,先整体清理,再创建全新主备。
容量安排应留出追赶余量。在线业务生成 WAL 的速率长期大于备库接收或应用速率时,积压不会自行减少;增加查询流量或同时迁移大分片,可能进一步压低重放能力。跨故障域放置副本改善可承受的失效范围,但增加网络往返;同宿主上的多个容器只能用来观察机制,不能抵御宿主整体失效。
清理本机实验
所有观察完成后,在解压目录执行:
docker compose down
unset LAB_DB_PASSWORD LAB_APP_PASSWORD LAB_REPLICATION_PASSWORD该操作只删除 ds14-repl 的两个容器和专用网络,实验临时数据无法保留;不需要删除全局镜像缓存、其他项目的卷或容器。
