数据库基础体系 · 第 12/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

数据库复制、分片与高可用:一致性、路由、故障转移和扩容

在单机数据库中,数据、事务日志、连接入口和故障处理通常集中在一个实例内。复制、分片和高可用把这些职责拆到多个节点后,系统获得了更高的可用性和容量,却也引入了新的问题:

  • 副本上的数据什么时候可见?
  • 客户端应该访问哪个节点?
  • 主节点故障时谁可以接管?
  • 故障切换后,旧主节点恢复上线是否会造成脑裂?
  • 分片后一个事务涉及多个分片怎么办?
  • 增加节点时,已有数据如何迁移且不破坏路由?
  • “写成功”究竟意味着数据已经存储在哪里?

这些问题不能仅靠“部署主从”“加一个代理”解决。需要先区分复制、分片和高可用的目标,再分析它们对一致性、事务和运维流程的影响。


一、三个概念解决三类不同问题

1. 复制:同一份逻辑数据保存多份

**数据库复制(replication)**是把一个数据集或其变更传播到多个数据库实例,使多个实例拥有相同或近似相同的数据副本。

最常见的形式是:

客户端
   |
   v
主库(接受写入)
   |
   | 复制日志、事务变更
   v
副本库(通常提供读取或故障接管)

复制主要解决:

  1. 节点故障时保留数据副本;
  2. 通过副本分担部分读取;
  3. 跨可用区或跨地域保存数据;
  4. 在某些系统中支持在线升级或备份。

复制不等于自动高可用。只有副本并不能自动完成:

  • 故障检测;
  • 选主;
  • 修改访问入口;
  • 防止旧主节点继续接收写入;
  • 确认新主节点拥有足够完整的数据;
  • 让旧主节点安全地重新加入。

2. 分片:把一份逻辑数据拆成多份

**分片(sharding)**是按照分片键,把一个逻辑数据集拆分到多个独立的数据分区中。

例如有四个分片:

shard-0: user_id % 4 = 0
shard-1: user_id % 4 = 1
shard-2: user_id % 4 = 2
shard-3: user_id % 4 = 3

与复制不同,分片节点通常保存的是不同数据,而不是同一数据的完整副本。分片主要解决:

  • 单节点存储容量不够;
  • 单节点写入吞吐不够;
  • 单节点索引、锁或连接资源成为瓶颈。

分片会把原来由单个数据库内部保证的事情,部分转移给应用、代理或分布式数据库层:

  • 路由;
  • 跨分片事务;
  • 全局唯一 ID;
  • 跨分片查询;
  • 数据迁移;
  • 分片故障恢复。

3. 高可用:在故障下维持可接受的服务

**高可用(high availability,HA)**是系统在部分组件故障时,仍能在规定时间和功能范围内提供服务。

高可用至少涉及四个状态:

  1. 正常提供服务;
  2. 发现故障;
  3. 选择或确认接管者;
  4. 将客户端流量切换到接管者,并处理旧节点恢复。

因此:

复制 = 数据如何有多个副本
分片 = 数据如何拆开存储
高可用 = 故障时如何继续提供服务

三者可以组合。例如,每个分片内部采用一主两副本:

逻辑数据库
├── shard-0
│   ├── primary-0
│   ├── replica-0a
│   └── replica-0b
└── shard-1
    ├── primary-1
    ├── replica-1a
    └── replica-1b

这不是“分片复制二选一”,而是两个维度的组合。


二、复制传播的核心:变更从哪里来,什么时候算完成

1. 物理复制与逻辑复制

数据库复制通常有两种抽象。

物理复制

物理复制传播数据库存储层或预写日志(WAL、redo log 等)的变化。副本按照日志重放,使存储页或内部结构达到与源库相同的状态。

特点:

  • 通常复制整个实例或数据库集群;
  • 对表结构和 SQL 透明;
  • 常用于高可用热备;
  • 副本一般不能独立写入;
  • 主库和副本的数据库版本、平台能力通常有较强约束。

PostgreSQL 的流复制属于典型的物理复制机制:主库产生 WAL,备用服务器接收并重放 WAL。备用服务器可以在 hot standby 模式下提供只读查询,但不能像普通主库一样接受写事务。

逻辑复制

逻辑复制传播“插入一行”“更新某些列”“删除一行”等逻辑变更,而不是直接复制底层页面。

特点:

  • 可以选择表或数据集;
  • 可能支持不同实例承担不同职责;
  • 适合数据集成、版本迁移、部分表同步;
  • 需要处理表结构、主键、DDL 和初始数据同步;
  • 不能简单等同于高可用备库。

逻辑复制的一个常见误解是:“订阅端数据最终一致,所以它就是热备。”实际上,订阅端是否能完整接管源库,取决于未复制的对象、DDL、序列、权限、扩展、未确认事务以及切换流程,不能只看表数据是否相同。

2. 同步复制与异步复制

设客户端提交事务 TT,主库先将日志写入本地。若日志尚未复制到其他节点,主库立即返回成功,这通常是异步复制

定义:

  • LpL_p:主库本地持久化完成;
  • LrL_r:副本接收到日志;
  • ArA_r:副本已应用日志;
  • CC:客户端收到提交成功响应。

异步复制可能满足:

LpCCLrL_p \rightarrow C \quad \text{且} \quad C \rightarrow L_r

也就是说,客户端已经看到成功,但副本还没有收到该事务。若随后主库永久损坏,事务可能丢失。

同步复制要求在返回成功前等待指定副本达到某个确认点。概念上可以表示为:

C(LpLr1Lrk)C \Rightarrow (L_p \land L_{r_1} \land \cdots \land L_{r_k})

其中 kk 是要求确认的副本数量。注意“副本收到日志”和“副本已经执行并对查询可见”不是同一件事。不同产品、不同配置对同步确认点的定义不同,必须以具体数据库语义为准。

同步复制降低了故障丢失数据的概率或窗口,但会增加提交延迟,并且同步副本不可用时可能影响写入可用性。它没有自动解决网络分区下的双主问题。

3. PostgreSQL 示例:观察复制状态

在 PostgreSQL 主库上,可以查看发送端状态:

SELECT
    application_name,
    client_addr,
    state,
    sync_state,
    sent_lsn,
    write_lsn,
    flush_lsn,
    replay_lsn
FROM pg_stat_replication;

这些字段反映不同进度:

  • sent_lsn:主库已经发送到副本的位置;
  • write_lsn:副本已经写入的位置;
  • flush_lsn:副本已经刷盘的位置;
  • replay_lsn:副本已经重放的位置;
  • sync_state:该副本是否被配置为同步副本。

可以用 WAL 位置估算滞后:

SELECT
    application_name,
    pg_size_pretty(
        pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)
    ) AS replay_lag
FROM pg_stat_replication;

这段查询只能说明主库与副本的 WAL 位置差距,不能直接证明所有业务查询都已经获得相同结果。副本还可能存在查询快照、长事务或恢复延迟。

在副本上:

SELECT
    pg_is_in_recovery() AS is_standby,
    pg_last_wal_receive_lsn() AS received_lsn,
    pg_last_wal_replay_lsn() AS replayed_lsn;

pg_is_in_recovery() 返回 true,说明当前实例仍处于恢复状态,通常是备用节点。received_lsnreplayed_lsn 的差距表示已接收但尚未应用的日志。

4. MySQL 示例:复制位置与 GTID

MySQL 8.4 的 InnoDB 复制通常以 source/replica 术语描述。使用 GTID 时,事务由全局事务标识表示,副本可以根据已执行的 GTID 判断哪些事务已经应用,较容易支持断点恢复和拓扑切换。

副本状态可使用:

SHOW REPLICA STATUS\G

应重点关注:

  • Replica_IO_Running:复制接收线程是否运行;
  • Replica_SQL_Running:复制应用线程是否运行;
  • Seconds_Behind_Source:估算的应用延迟;
  • Retrieved_Gtid_Set:已从源端取得的 GTID 集合;
  • Executed_Gtid_Set:已执行的 GTID 集合;
  • Last_IO_ErrorLast_SQL_Error:接收或应用错误。

Seconds_Behind_Source 不是精确的端到端延迟指标。例如源端长事务、时钟不准确、复制线程停止时,它可能无法准确反映业务读到旧数据的程度。诊断时应结合 GTID 集合、事务提交时间、应用查询延迟和副本负载。


三、一致性不是一个开关

“副本一致”至少有三种不同含义。

1. 数据最终一致

若停止新的写入并等待足够长时间,所有健康副本最终达到同一数据状态,可以称为最终一致的一个基本表现。

它不保证:

  • 写入成功后立即从任意副本读到;
  • 每次读取都单调前进;
  • 读取结果反映最近一次提交;
  • 主库故障后副本一定包含最后提交的事务。

2. 读己之写

**读己之写(read-your-writes)**要求:

客户端成功写入 x
随后该客户端读取 x
不能读到比自己的写入更旧的状态

异步主从架构中,最常见的违反方式是:

1. 写请求发送到主库,返回成功
2. 读取请求被负载均衡到延迟中的副本
3. 读取不到刚写入的数据

这不是数据库事务隔离级别失效,而是跨节点路由和复制时序造成的可见性问题。

可行的解决方式包括:

  • 写后的一段时间内固定读主库;
  • 将会话绑定到主库;
  • 携带复制位置或 GTID,读取时等待副本追上该位置;
  • 对关键读取始终访问主库。

“等待固定 1 秒”不是严格保证,因为复制延迟可能超过 1 秒。

3. 线性一致性与串行化

**线性一致性(linearizability)**要求每个操作看起来在其调用和返回之间某一时刻原子发生,并且 real-time 顺序得到保持。

若写操作 WW 已经向客户端返回成功,之后开始的读操作 RR 必须看到 WW 的结果:

W returns before R startsR observes WW \text{ returns before } R \text{ starts} \Rightarrow R \text{ observes } W

**串行化(serializability)**则是事务执行结果等价于某个串行执行顺序。它主要描述并发事务之间的结果,不自动保证跨副本读取的时间顺序。

因此:

  • 数据库可以提供串行化事务,但异步副本仍可能读到旧数据;
  • 数据库可以有同步复制,但跨分片事务仍可能没有全局串行化;
  • “副本延迟为 0”也不等于所有客户端请求都满足线性一致性。

四、事务边界:复制不扩大事务,分片会改变事务范围

1. 单库事务的边界

在同一个数据库实例、同一个事务上下文内:

BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE account_id = 1;

UPDATE accounts
SET balance = balance + 100
WHERE account_id = 2;

COMMIT;

数据库可以保证两条更新作为一个事务提交或回滚。具体可见性和并发行为还取决于隔离级别。

如果 account_id=1account_id=2 被分到不同数据库,原来的本地事务就不能自然覆盖两个节点。此时有三种典型选择:

  1. 禁止跨分片事务:要求业务让一次事务只访问一个分片;
  2. 两阶段提交(2PC):引入协调者和准备、提交两个阶段;
  3. 业务级最终一致性:用消息、状态机、补偿操作表达跨分片流程。

2. 两阶段提交的故障点

两阶段提交大致经过:

协调者 -> 分片 A:PREPARE
协调者 -> 分片 B:PREPARE
分片 A、B:持久化“已准备”状态
协调者 -> 分片 A、B:COMMIT

如果协调者在所有分片准备完成后、发送 COMMIT 前崩溃,参与者可能长期处于 prepared 状态。此时参与者不能自行判断应该提交还是回滚,必须恢复协调者状态或由人工依据事务日志处理。

因此 2PC 可以提供较强的原子性,但代价是:

  • 协调者可用性影响事务完成;
  • 锁或 prepared 状态可能长期占用资源;
  • 延迟增加;
  • 故障恢复流程复杂。

“分片后仍然使用一个全局事务”不是免费能力。


五、路由:请求如何找到正确的数据节点

复制和分片都需要路由,但路由依据不同。

1. 复制路由

复制架构中的典型角色:

写入      -> 当前主库
强一致读取 -> 当前主库,或满足读取条件的同步副本
允许旧数据的读取 -> 只读副本
故障切换 -> 新主库

常见错误是只配置一个读写代理,却没有定义它如何判断主库身份。代理如果只看到 TCP 端口可连接,并不能证明节点有资格接受写入。

路由层至少要知道:

  • 当前主库是谁;
  • 节点是否处于恢复状态;
  • 节点是否已经被隔离;
  • 副本延迟是否超过业务阈值;
  • 当前连接中的事务是否需要保持在同一节点。

连接池会放大路由问题。应用切换主库后,已有连接可能仍指向旧主库,因此故障转移不仅是修改 DNS 或 VIP,还需要:

  • 关闭或驱逐旧连接;
  • 让应用重建连接;
  • 处理事务中断;
  • 对提交结果未知的请求进行重试决策。

2. 分片路由

分片路由函数可以抽象为:

S=f(K,M)S = f(K, M)

其中:

  • KK:分片键;
  • MM:当前分片映射;
  • SS:目标分片。

取模分片:

S=KmodNS = K \bmod N

优点是简单,缺点是改变 NN 时大量数据映射改变。

例如从 4 个分片扩展到 5 个分片:

user_id = 6
6 % 4 = 2
6 % 5 = 1

同一个用户会被路由到不同分片,若没有迁移和双读策略,数据就会“消失”在旧路由或产生重复写入。

更常见的做法是使用一致性哈希、范围分片或显式分片表。以范围分片为例:

range-0: user_id < 1,000,000
range-1: 1,000,000 <= user_id < 2,000,000
range-2: user_id >= 2,000,000

范围分片便于按范围迁移,但可能出现热点。例如新用户 ID 持续增长时,最大的范围分片承受大部分写入。

3. 分片键的选择

一个合适的分片键通常具有以下性质:

  • 查询经常携带它;
  • 数据分布较均匀;
  • 写入不会集中到单个分片;
  • 相关事务尽量共享它;
  • 业务生命周期内不经常变化。

例如订单系统按 user_id 分片,以下查询可以定向到一个分片:

SELECT *
FROM orders
WHERE user_id = 1001
  AND order_id = 90001;

但以下查询无法天然定向:

SELECT *
FROM orders
WHERE status = 'pending'
ORDER BY created_at
LIMIT 100;

它可能需要访问所有分片,再进行合并排序。随着分片数量增长,查询成本和结果不确定性都会增加。

4. 路由元数据本身也需要高可用

如果业务依赖一张映射表:

逻辑分片  -> 物理数据库节点

那么这张表不能只保存在某个应用进程的内存中。路由元数据需要:

  • 版本号;
  • 原子发布;
  • 变更审计;
  • 回滚能力;
  • 与数据迁移状态关联。

迁移时不能简单地先修改路由再搬数据。否则新路由已经指向目标分片,但数据仍在源分片,会造成读不到数据。


六、故障转移:从发现故障到安全接管

高可用切换不是一个动作,而是一条状态机。

1. 正常状态

primary P 接受写入
replica A、B 接收并应用复制日志
路由层将写请求发送到 P

系统需要持续记录:

  • 主库是否可连接;
  • 主库是否仍可写;
  • 副本复制是否正常;
  • 副本落后多少;
  • 副本是否具备接管所需的日志;
  • 监控节点之间是否能够互相通信。

2. 故障检测

如果监控节点无法连接 P,不能立即断言 P 已故障。可能的情况包括:

  • P 真故障;
  • 监控节点到 P 的网络中断;
  • 监控节点自身故障;
  • 认证、DNS 或代理异常;
  • P 仍在运行但暂时阻塞。

因此自动选主通常需要仲裁或多数派。设有 nn 个投票成员,法定人数为:

q=n2+1q = \left\lfloor \frac{n}{2} \right\rfloor + 1

只有获得多数派的候选者才允许改变集群领导权,可以降低网络分区导致双主的风险。

不过,数据库复制本身未必提供这样的共识机制。外部编排系统、数据库集群协议或人工流程必须明确谁有权提升副本。

3. 选择接管者

接管者至少要考虑:

  • 已接收并持久化的日志;
  • 已应用的事务位置;
  • 是否存在未完成恢复;
  • 与其他候选者的连通性;
  • 节点所在故障域;
  • 数据丢失目标(RPO)。

异步复制下,候选副本可能缺少主库最后提交的事务。若强行提升,则系统进入新任期,但这些事务可能永远丢失。

4. 提升与路由切换

典型顺序是:

1. 确认旧主库不能继续接受写入,或将其隔离
2. 选择副本 P'
3. 让 P' 完成恢复并提升为可写节点
4. 更新服务发现、VIP、代理或连接串
5. 驱逐旧连接
6. 让应用重连并执行健康检查
7. 记录新拓扑和数据位置

“先切流、后隔离旧主库”可能产生双主:

客户端 A -> 新主库 P'
客户端 B -> 旧主库 P

如果两边都接受写入,冲突可能无法通过普通 WAL 重放解决。即使两边写入的是不同记录,也可能在唯一键、余额、库存等业务约束上产生不可逆分歧。

5. PostgreSQL 手动提升示例

在确认旧主库已经停止写入并且不会重新接收客户端流量后,可在 PostgreSQL 备用节点上执行提升:

pg_ctl -D /var/lib/postgresql/data promote

也可以使用:

SELECT pg_promote();

预期结果是该节点结束恢复状态,成为可写主库。验证:

SELECT pg_is_in_recovery();

返回 false 只表示该实例不再处于恢复状态,并不等于:

  • 应用流量已经切换;
  • 旧主库已经隔离;
  • 所有副本都跟随新主库;
  • 数据丢失符合 RPO;
  • 业务事务可以安全重试。

提升前应保存并比较主库和副本的 WAL 位置。提升之后,旧主库通常不能直接继续作为新主库的副本,因为两者历史已经分叉,需要重新同步,例如重新构建备用库或使用适当的时间线跟随流程。

6. MySQL 复制切换的关键风险

MySQL 复制切换也不能只执行“停止旧源、把 replica 指向新源”。需要确认:

  • 新源是否已执行旧源的最后事务;
  • 复制使用的 GTID 集合是否连续;
  • 旧源是否已被隔离;
  • 新源是否能被所有应用连接;
  • 其他副本是否需要重新指向新源;
  • 提交结果未知的客户端事务如何处理。

使用 GTID 可以减少基于文件名和位置手工计算的错误,但不自动保证应用层请求没有重复执行。比如客户端发送 COMMIT 后连接断开,客户端无法知道服务器是否已经提交。重试前必须依赖幂等键、业务唯一约束或事务状态查询。


七、RPO、RTO 与复制配置的关系

**RPO(Recovery Point Objective)**表示故障后最多允许丢失多近的数据时间点。

**RTO(Recovery Time Objective)**表示从故障发生到恢复服务允许经过的最长时间。

异步复制中,若主库在事务 TT 提交后、该事务到达副本前永久损坏,则:

T 已对客户端成功,但副本不存在 TT \text{ 已对客户端成功,但副本不存在 } T

这意味着 RPO 至少覆盖这段复制延迟,实际还取决于是否有其他日志归档、备份或恢复手段。

同步复制可以缩小“已确认但副本没有”的窗口,但需要注意:

  • 同步确认的节点数量;
  • 确认是写入内核、刷盘还是应用完成;
  • 同步副本是否位于同一故障域;
  • 网络分区时是否阻塞写入;
  • 数据库提交和外部系统副作用是否同一事务。

复制不能代替备份。复制错误会被复制:

误删数据 -> 主库产生删除日志 -> 副本也执行删除

因此全量备份、增量备份、WAL/binlog 归档和 PITR 仍然用于处理逻辑错误、误操作、勒索和时间点恢复;复制主要用于节点级故障下的快速接管。


八、分片后的查询和数据模型

1. 定向查询与广播查询

定向查询能够根据分片键确定一个分片:

请求:查询 user_id = 1001 的订单
路由:f(1001) -> shard-2
访问:只访问 shard-2

广播查询必须访问多个分片:

请求:查询所有用户中最近创建的 100 个订单
路由:shard-0、shard-1、shard-2、shard-3
每个分片取候选结果
协调层合并、排序、截断

如果每个分片都执行:

SELECT order_id, user_id, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 100;

协调层不能简单拼接四个结果,因为全局前 100 条可能集中在某个分片,也可能出现在各分片的局部候选中。常见合并步骤是:

  1. 每个分片返回局部前 100;
  2. 协调层按 created_at 做归并排序;
  3. 取全局前 100。

当查询包含过滤、聚合、分页和 JOIN 时,协调层需要承担更多计算,且错误处理从单节点错误变成部分分片成功、部分分片失败。

2. 跨分片 JOIN

若两张表按同一分片键、同一映射规则分片:

users(user_id)
orders(user_id)

那么:

SELECT *
FROM users u
JOIN orders o ON o.user_id = u.user_id
WHERE u.user_id = 1001;

可以路由到同一分片,接近本地 JOIN。

如果一张表按 user_id 分片,另一张表按 merchant_id 分片,JOIN 就需要:

  • 广播;
  • 数据重分布;
  • 复制维度表;
  • 在应用层分步查询;
  • 使用支持分布式执行的数据库。

这也是为什么数据模型和分片键必须一起设计,而不是建表后再由代理自动解决所有问题。

3. 全局唯一 ID

分片后,数据库自增列不再天然保证全局唯一。可选方案包括:

  • 使用带时间和节点信息的 ID;
  • 使用独立 ID 服务;
  • 预分配号段;
  • 使用 UUID 等随机或半随机标识。

选择时要考虑:

  • 是否需要按时间排序;
  • 是否会导致索引页热点;
  • 是否需要隐藏业务规模;
  • 是否必须短整数;
  • 时钟回拨如何处理。

不能仅因为字段类型是 BIGINT,就认为多个分片自动共享一个自增序列。


九、扩容:增加节点不等于增加容量

扩容至少有两种完全不同的情况。

1. 复制副本扩容

增加一个只读副本通常不会增加写入容量。主库仍然需要:

  • 产生全部日志;
  • 发送日志;
  • 承担全部写事务;
  • 维护主库索引和锁。

副本扩容主要改善读取能力和故障冗余,但复制链路、主库网络和存储仍可能成为瓶颈。

2. 分片扩容

分片扩容需要同时改变:

数据位置 + 路由映射 + 迁移状态 + 读写行为

一个安全的范围迁移流程可以是:

阶段一:建立目标分片

创建目标表、索引、权限和复制配置,验证空目标分片可用。

阶段二:复制存量数据

按主键范围分批复制:

INSERT INTO target.orders (...)
SELECT ...
FROM source.orders
WHERE order_id >= :low
  AND order_id < :high;

实际系统应使用应用参数绑定,而不是把输入直接拼接进 SQL。批量复制还要记录进度、处理唯一键冲突,并控制对源库的读压力。

阶段三:追踪增量变更

在存量复制期间,源分片仍可能发生 INSERT、UPDATE、DELETE。需要使用数据库日志、变更数据捕获或业务双写机制追踪增量。

“双写”并不天然可靠:

写源成功
写目标失败

如果没有重试、幂等键和对账机制,两边就会产生分歧。

阶段四:短暂切换或双读校验

先让新路由继续读源,后台比较源和目标;确认增量追平后,执行短暂切换,使新写入进入目标。

若必须双写,切换期间应明确哪个副本是权威来源,避免两个方向互相覆盖。

阶段五:更新路由并验证

更新带版本的路由映射:

range [0, 1,000,000) -> shard-0
range [1,000,000, 1,500,000) -> shard-1
range [1,500,000, 2,000,000) -> shard-2

验证内容包括:

  • 新旧边界附近的记录;
  • 写入是否只进入目标;
  • 读取是否命中新分片;
  • 全局计数和局部计数是否符合预期;
  • 失败重试是否产生重复数据。

阶段六:保留回滚窗口

不要刚切换就删除源数据。应在确认应用、报表、异步任务和补偿流程都已使用新路由后,再清理旧数据。清理前必须有备份和可验证的恢复方案。


十、常见误解与失败表现

误解一:副本延迟低,所以读副本等于强一致

失败表现:

写主库成功
紧接着读副本,读不到新数据

原因是低延迟不是零延迟,也不是线性一致性保证。需要使用主库路由、会话粘滞或基于复制位置的等待。

误解二:有两个数据库节点,就能避免脑裂

如果两个节点都能继续接受写入,网络分区时可能出现:

节点 A 认为 B 已故障
节点 B 认为 A 已故障
A、B 都被提升为主库

必须有明确的仲裁、隔离和任期机制。复制通道本身只能传播数据,不能总是决定谁拥有唯一写入权。

误解三:切换主库只需要改 DNS

DNS 缓存、连接池和已有 TCP 连接可能使客户端继续访问旧主库。并且 DNS 变更不负责:

  • 回收旧连接;
  • 中断旧事务;
  • 处理客户端提交结果未知;
  • 修复复制拓扑。

服务发现只是流量入口的一部分。

误解四:复制能代替备份

主库误删数据后,副本会忠实复制删除。必须通过备份、归档日志和 PITR 恢复到删除前的时间点。

误解五:把表拆到多个库就是分布式数据库

如果应用自己决定分片、拼接查询、实现重试和跨库事务,它确实构成了分布式数据访问层,但不代表数据库自动提供:

  • 全局事务;
  • 全局约束;
  • 全局唯一索引;
  • 全局一致快照;
  • 自动重平衡。

这些能力必须明确由哪个组件负责。


十一、诊断方法:先判断是数据问题、路由问题还是拓扑问题

1. 先确认客户端实际连接的节点

在 PostgreSQL 中:

SELECT
    inet_server_addr(),
    inet_server_port(),
    pg_is_in_recovery(),
    current_setting('transaction_read_only');

在 MySQL 中可以查看:

SELECT
    @@hostname,
    @@read_only,
    @@super_read_only,
    @@server_uuid;

不要只根据代理配置推断请求去了哪里。

2. 再确认复制进度

需要区分:

已产生日志
-> 已发送
-> 已接收
-> 已刷盘
-> 已应用
-> 查询可见

只看一个“延迟秒数”通常不够。应结合日志位置、GTID、长事务、磁盘 I/O、复制线程状态和错误信息。

3. 最后确认路由和业务重试

若数据库显示事务已提交,但客户端报连接错误,结果属于“提交状态未知”。此时直接重试可能造成:

  • 重复订单;
  • 重复扣款;
  • 重复消息;
  • 唯一键冲突。

正确处理需要业务幂等键。例如:

CREATE TABLE payment_requests (
    request_id  varchar(64) PRIMARY KEY,
    payment_id  bigint NOT NULL,
    status      varchar(16) NOT NULL
);

同一个 request_id 重试时,应用先查询已有状态,而不是无条件再次执行扣款。


十二、如何把复制、分片和高可用组合起来

一个实际系统可以按以下层次理解:

应用
  |
  | 业务路由、幂等、重试、跨分片协调
  v
分片路由层
  |
  +--> shard-0 的 HA 集群
  |      primary + replicas
  |
  +--> shard-1 的 HA 集群
         primary + replicas

一次请求的完整路径可能是:

1. 应用提取 user_id
2. 路由层根据当前映射选择 shard-1
3. shard-1 内部将写请求发送给当前 primary
4. primary 提交本地事务并复制日志
5. 返回提交结果
6. 读取请求根据一致性要求选择 primary 或合适副本

任何一层都可能失败:

  • 路由映射过期;
  • 分片主库故障;
  • 副本延迟;
  • 复制链路中断;
  • 代理仍缓存旧主库;
  • 客户端在提交后断开;
  • 迁移期间新旧分片数据不一致。

所以高可用设计必须同时描述:

  1. 数据副本如何产生;
  2. 提交成功的确认语义;
  3. 请求如何路由;
  4. 故障由谁判定;
  5. 谁拥有提升权限;
  6. 旧节点如何隔离和重新加入;
  7. 跨分片事务如何处理;
  8. 扩容迁移如何验证和回滚;
  9. 备份如何覆盖复制无法处理的逻辑错误。

最终,复制回答“数据还有没有另一份”,分片回答“数据如何分布以获得容量”,高可用回答“节点故障时谁继续提供服务”。一致性则回答“客户端在这些变化中究竟能观察到什么”。只有把这四个问题分别定义清楚,数据库集群的架构、路由和故障流程才具有可验证的行为,而不是依赖“主从正常”“代理在线”这类过于粗略的状态描述。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。