数据库增加副本后,为什么仍会读到旧数据:主从复制、读写分离与 Quorum

从短链接创建后立即访问却返回 404 的问题出发,讲清复制日志、复制延迟、读己之写、半同步复制、主从切换,以及 Quorum 读写集合的保证与边界。

用户刚创建短链接,接口已经返回成功,下一秒访问短码却得到 404。刷新几次后,链接又能正常跳转。创建记录没有回滚,短码也没有冲突;原因是写请求落在主库,跳转请求被负载均衡到一个还没重放这笔事务的从库。

增加副本能分担读请求,也能在主库故障时提供候选数据,但副本之间存在传播与应用过程。一次 COMMIT 返回成功,可能只表示主库本地持久化;也可能表示至少一个副本收到日志;某些系统还能等待副本把日志应用到可查询状态。这些确认点对应不同的延迟、可用性、旧读窗口和故障丢失窗口。

本文回答一个问题:数据库存在多个副本后,应用怎样理解“写成功”和“随后能读到”,并为不同请求选择合适的一致性成本。MySQL 部分以 8.4 Replication 文档为依据;同步复制的确认层级参照 PostgreSQL Hot Standby;Quorum 部分参考 Dynamo 论文与 Apache Cassandra 一致性文档。

先分清副本数量、读写分离与一致性保证

三台数据库机器并不自动组成一种固定语义。系统可以是一主两从,所有写入主库、从库异步追赶;也可以由一个共识复制组在多数节点确认后提交;还可以允许任意节点协调读写,再通过版本合并副本差异。机器数相同,客户端能得到的保证可能完全不同。

概念 它决定什么 它没有自动保证什么
复制 同一份数据保存在哪些节点 提交确认点、读取新鲜度
读写分离 哪类请求发往主库或从库 读己之写、单调读、故障零丢失
高可用切换 主库故障后由谁接管 新主一定包含所有已返回成功的写入
Quorum 一次操作等待多少副本参与 自动选出最新版本、自动解决并发冲突
一致性模型 并发历史允许出现哪些结果 具体产品怎样实现和运维

上一篇分库分表把不同 Key 放到不同 shard;这一篇只看一个 shard 内的多个副本。可以把拓扑写成:

route_key -> shard-7 -> primary + replica-a + replica-b

route_key 决定访问 shard-7。进入复制组后,读路由再决定访问 primary、replica-a 还是 replica-b。分片解决容量与并行度,复制解决数据冗余和部分读扩展,它们是连续的两层路由。

一、一笔写入怎样从主库到达从库

MySQL 传统复制以 binary log 为源。主库把事务变化记录为 binlog event;从库的 receiver thread 拉取日志并写入 relay log;applier thread 再把 relay log 中的事件应用到本地数据。官方基于 binlog position 的复制说明明确区分“从源读取日志”与“在副本执行日志”。

异步复制中写成功与副本可见之间的时间窗口

图里的事务 T42 在主库提交后,客户端立即得到 200。此时从库可能处在四个位置:还没收到日志;已经收到但只写入 relay log;正在应用;已经应用并可被查询。把这些状态都叫“同步了”会掩盖最重要的差别。

接收进度和应用进度是两条水位线

从库网络正常时,receiver 可能很快追上主库,relay log 中却积压大量未应用事务。原因包括从库磁盘慢、并行 apply 配置不足、长事务、DDL、锁等待,或者某条复制事件报错。只监控主从网络连接无法证明查询已经看到最新数据。

用 GTID 表达时,可以把进度看成两个集合:

received_gtid_set = 已到达副本日志的事务
executed_gtid_set = 已在副本执行完成的事务

读请求关心第二个集合。主库事务的 GTID 已经出现在副本接收集合里,只说明副本拥有日志;直到它进入 executed 集合,对普通查询才可见。MySQL 的 WAIT_FOR_EXECUTED_GTID_SET()等待指定 GTID 集合在当前服务器执行完成,恰好利用了这个边界。

file/position 与 GTID 都是进度坐标。GTID 把事务身份从某个 binlog 文件位置中抽出来,更方便自动定位和验证某笔事务是否执行,但不会让复制本身变成同步,也不会降低 apply 成本。

复制延迟不是一个固定的毫秒数

延迟至少有三段:主库产生日志到从库收到日志的传输延迟,从库收到到持久化 relay log 的接收延迟,以及从 relay log 到事务应用完成的 apply 延迟。最后一段还会受事务依赖、锁冲突和从库本地查询负载影响。

平均延迟 20 毫秒无法推出“睡 100 毫秒再读一定安全”。一次大事务会让后续事务排队,网络抖动会让 receiver 停顿,从库重启后还要追赶积压。固定 sleep 只能降低撞上延迟的概率,无法建立读己之写保证。

按时间表达的 lag 也可能误导。时钟偏差、日志中没有新事件、并行 apply 与单个阻塞事务都会让一个数字难以描述真实状态。更可靠的监控同时记录源端当前位置、从库 received 与 applied 位置、待应用字节或事务数、最老未应用事务年龄,以及复制线程错误。

二、读写分离只改变流量去向

最常见的路由规则是:INSERT/UPDATE/DELETE 发往主库,SELECT 分发到从库。它能把一部分读 CPU 和连接从主库移走,却没有规定一次写之后的下一次读必须去哪,也没有让从库在响应读请求前自动追到某个版本。

DataSource choose(RouteContext ctx, SqlKind kind) {
    if (ctx.inTransaction() || kind.isWrite()) {
        return primary;
    }
    return replicas.pickHealthy();
}

这段代码还缺少业务语义。事务里的 SELECT 必须留在同一主库连接,否则看不到当前事务的未提交修改,也破坏数据库事务隔离。SELECT ... FOR UPDATE 是读语法却承担锁定写路径,不能发往只读副本。存储过程、临时表、会话变量和连接级状态也可能要求连接粘性。

创建后立即查询暴露了 read-your-writes 缺口

短链接创建接口写入主库:

INSERT INTO short_link(link_id, short_code, target_url)
VALUES (300001, '1G2j', 'https://example.com/article');

提交成功后,前端立刻请求 GET /1G2j。若跳转服务随机选择 replica-b,而 T42 尚未应用,它会把“副本当前没有这行”解释成“短码不存在”,返回 404 并可能把空结果缓存下来。复制延迟从几十毫秒扩大成负缓存 TTL。

这里需要的是 read-your-writes,也叫读己之写:一个会话完成写入后,后续读取至少能看到自己的写。它弱于线性一致,因为其他用户未必立即看见;也强于普通最终一致,因为同一因果链不能倒退。

读己之写不会由 HTTP Session 自动获得。创建请求和跳转请求可能落到不同应用实例,浏览器还可能把短链接分享给另一个用户。系统需要明确传播“至少读到版本 T42”的证据,或者让相关读请求暂时走主库。

单调读解决同一用户来回看到新旧版本

用户第一次查询命中 replica-a,看到了链接标题 v3;第二次查询被分到更落后的 replica-b,只看到 v2。即使没有刚刚写入,观察结果仍然像时间倒流。Monotonic reads 要求一个会话一旦看到某个版本,之后不能看到更旧版本。

固定把用户哈希到同一副本可以减少这种跳变,但副本故障、扩容和负载均衡重映射会打破粘性;同一副本恢复后也可能暂时落后。稳妥做法仍是携带最低版本水位,候选副本只有追到该水位后才能服务,或者回退主库。

不同保证服务不同路径:

请求 常见需求 可接受策略
创建后展示结果 读己之写 主库读、版本水位等待、写穿缓存
后台列表连续翻页 单调读与稳定快照 固定版本游标、同一数据源
公共短链跳转 已确认创建的数据应可读 缓存优先,未命中按新鲜度回源
离线报表 有界陈旧即可 从库读取并标记数据水位
余额、库存确认 强一致或条件写 主库/共识组,不随机读副本

三、四种处理旧读的方法

旧读没有一种免费修复。工程方案是在一致性、延迟、主库压力和实现复杂度之间选择,并把选择绑定到请求,而不是给整个数据库贴一个“强一致”或“最终一致”的标签。

方法一:需要新鲜度的读直接访问主库

创建成功后的详情页、修改密码后的身份校验、支付状态确认,可以直接走主库。规则容易验证,代价是这部分读流量回到主库。通常没有必要把所有 SELECT 都送到副本;按 endpoint 或业务状态标记强读,能保留大部分读扩展收益。

Link getLink(String code, Consistency consistency) {
    if (consistency == Consistency.STRONG) {
        return primaryRepo.findByCode(code);
    }
    return replicaRepo.findByCode(code);
}

“主库读”假定当前主库仍是唯一有效写主。故障切换期间若存在旧主继续服务,应用连接池没有及时更新,主库标签本身也可能失真。因此拓扑管理还需要 epoch 或 lease,阻止旧主接受新写,后文会单独讨论。

方法二:写后在一段时间内粘主

应用在写成功后记录 pin_primary_until=now+2s,同一用户两秒内的读都走主库。这种方法部署简单,适合对绝大多数复制延迟有效的体验优化。

它没有确定性。延迟超过两秒仍会旧读;两秒内全部读主可能造成流量尖峰;跨设备或匿名分享无法继承标记。若使用 Cookie 或 Session 保存粘主状态,还要防止客户端篡改和服务间丢失上下文。应把它描述为概率优化,不能写进“保证读己之写”的接口契约。

方法三:把提交版本传给读请求

主库提交后返回本次事务的 GTID 或逻辑版本,调用链把它作为 min_read_version 传给读服务。读路由选择副本后,先确认副本 applied 水位覆盖该版本:

POST /links
<- 201 Created
   short_code: 1G2j
   commit_token: source-uuid:1-982731

GET /1G2j
X-Min-Read-Version: source-uuid:1-982731

副本已经覆盖 token 时直接查询;尚未覆盖时可以短暂等待、换一个更快的副本,或回退主库。MySQL 场景可用 WAIT_FOR_EXECUTED_GTID_SET(gtid, timeout) 作为机制之一,但不要无限等待:请求有 deadline,复制线程也可能已经停止。

伪代码需要把等待结果纳入路由:

Link causalRead(String code, String minGtid, Duration budget) {
    Replica replica = replicas.pickMostAdvanced();
    if (replica.waitUntilApplied(minGtid, budget.minusMillis(20))) {
        return replica.findByCode(code);
    }
    return primary.findByCode(code);
}

提交 token 提供了可验证的因果边界,比“睡一会儿”准确。成本是 token 要跨 HTTP、消息和线程传播,读服务要理解版本格式,拓扑切换时还要处理来自旧 source UUID 的 GTID 集合。

方法四:写成功时同步更新读取层

短链接创建成功后可以把 short_code -> target_url 写入缓存,跳转请求优先读缓存。这样新链接不必等待数据库副本。缓存写失败时,接口是整体失败、继续成功并让读取回主库,还是投递补偿任务,需要明确规定。

先提交数据库再写缓存会出现数据库成功、缓存失败;先写缓存再提交数据库会暴露尚未提交或最终回滚的数据。Outbox、事务后事件和幂等消费能缩小不一致窗口,不能让两个独立系统自动成为一个事务。短链接若把数据库视为真相,缓存未命中或可疑负缓存应根据记录年龄和版本选择主库回源。

负缓存要格外谨慎。查询旧副本得到“不存在”并缓存 5 分钟,比返回一份旧标题更严重。可以不给刚创建的短码写负缓存;也可以让不存在结果经过主库二次确认;若短码含时间或版本信息,还能在创建后的保护窗口内禁用负缓存。

四、“写成功”究竟确认到了哪一步

复制策略的名字不如确认点重要。评审时可以连续追问:主库是否落盘,几个副本收到,副本是否持久化,是否已经应用,查询是否可见。不同产品的“同步”可能停在不同位置。

异步、半同步、远端落盘与远端应用的确认层级

异步复制优先保证主库提交不等待副本

MySQL 默认复制是异步的。主库提交不需要从库在线,也不等待从库接收或应用。好处是副本网络抖动不会直接进入每次事务延迟;坏处是主库返回成功后、日志传到副本前发生不可恢复故障,提升落后的从库可能丢失这批已确认事务。

异步不等于一定丢数据。正常运行时复制会追上,主库短暂重启也可从自身持久化日志恢复。风险窗口出现在原主数据无法恢复且必须从副本接管时,窗口大小由副本实际落后位置决定。

MySQL 半同步确认“至少一个副本已收到并记录日志”

MySQL 8.4 文档说明,半同步模式下主库在返回提交前,等待至少一个副本确认已经接收并记录该事务事件。这个确认提高故障时保留日志的机会,但不代表副本 applier 已经执行,也不代表立刻从该副本查询就能看到数据。

半同步还存在运行时降级。rpl_semi_sync_source_timeout 到期后,主库可以退回异步复制;官方监控说明提供 Rpl_semi_sync_source_status 等状态观察是否仍在半同步。只在配置文件写了 enabled=1,却不监控实际状态,业务可能在不知情时运行于更弱保证。

等待几个副本也可配置。等待数量增加会缩小同时失去已确认日志的风险,但写延迟受第 W 快副本影响,可用性也依赖足够多副本在线。半同步中的 W 是提交确认数量,不应直接等同于 Dynamo 风格的任意节点 Quorum 读写协议。

收到、落盘、应用和可见仍有区别

PostgreSQL 把区别暴露得很直观:同步提交可以等待 standby 写入持久存储;remote_write 只等远端操作系统接收写入,不等落盘;remote_apply 则等待 standby 重放事务,使它对用户查询可见。官方文档指出,remote_apply 在简单场景可支持具有因果一致性的负载均衡读取。

这组概念可以反过来检查任何复制系统:

确认点 能说明什么 仍不能说明什么
主库内存接受 请求进入数据库 宕机后可恢复
主库日志落盘 主库可从本地恢复 任一副本拥有该事务
副本收到日志 网络传输已到达 副本崩溃后仍保留、查询可见
副本日志落盘 副本可恢复日志 applier 已执行
副本应用完成 本地查询可以看到 其他副本也已看到
多数副本提交 后续多数集合会相交 读取实现一定选择最新版本

同步等待会把网络 RTT、远端磁盘和排队时间加入提交路径。它还会延长事务锁持有时间,增加并发竞争。是否值得等待取决于数据丢失成本与写延迟预算,而不是“金融就同步、内容就异步”这样的粗略标签。同一系统中的资金变更和点击统计也可以采用不同 durability level。

五、故障切换决定已确认写入能否保留

复制只有在切换协议里才能完成高可用。主库故障后,控制面要判断故障、选择候选、阻止旧主写入、提升新主、更新路由并处理落后副本。任一步骤模糊,都可能把“有多个副本”变成双主或数据倒退。

选择最先进副本仍要服从提交保证

异步复制下,replica-a 可能应用到 GTID 100,replica-b 到 98。通常优先提升 a,但“最先进”不等于包含所有客户端收到成功的事务,原主可能已经提交到 103。若原主磁盘还能恢复,贸然从 a 接管会把 101~103 从新历史中丢掉。

RPO 描述可接受的数据丢失窗口,RTO 描述恢复服务所需时间。等待原主恢复可能有更小 RPO、更大 RTO;快速提升落后副本则相反。自动切换策略必须知道业务允许哪一种,不能只以“最快恢复连接”为目标。

计划内 switchover 可以先停止或排空写入,等候选副本追到最终位置,再切换 owner。非计划 failover 没有这个机会,只能依据持久化进度、复制确认和故障域判断。所有成功响应是否落在候选副本上,取决于此前采用的同步级别。

旧主必须被 fencing

网络分区时,控制面可能联系不到旧主,却无法证明旧主已经停止。若直接提升新主,同时一部分应用仍连接旧主,两边都会接受写入。分区恢复后,两个历史无法靠 binlog position 自动合并。

新主接管应伴随更高 epoch、term 或 lease,代理和存储端只接受当前代次。能关闭旧主电源、撤销磁盘挂载或隔离网络时,运维系统应执行 STONITH 类 fencing;仅修改服务发现地址不能终止已经存在的连接和正在执行的事务。

这个问题与上一篇迁移 cutover 相同:系统必须在某个资源边界验证写 owner。复制协议负责数据副本,选主与 fencing 负责同一时刻只有一个权威写入点。

提交超时仍然可能结果未知

同步提交等待副本确认时,事务可能已经在主库和副本持久化,但响应在返回客户端前超时。客户端不能把超时解释成回滚并换一个业务 ID 重试。应沿用前文的幂等键与状态查询:用同一创建请求 ID 重试,数据库唯一约束返回既有短码。

主库在等待同步副本期间重启,也可能在恢复后把事务判定为已提交,而客户端只看到了连接断开。复制提高 durability,不会消除远程调用的结果未知。接口层仍要把“明确失败、明确成功、结果未知”分开处理。

六、Quorum 用集合相交约束读写

主从模型通常由唯一主库决定写入顺序。Dynamo 风格系统则把一个 Key 复制到 N 个节点,一次写等待 W 个副本响应,一次读查询 R 个副本。若 W + R > N,任意成功写集合与后续读集合至少相交一个副本。

N=3、W=2、R=2 时读写集合必然相交

例如 N=3, W=2, R=2。版本 v7 写入 A、B 后返回,随后无论读取 A+B、A+C 还是 B+C,都至少碰到一个保存 v7 的节点。协调者收集版本并选择较新的值,再可能把旧副本修复到 v7。

N=3, W=1, R=1 延迟低、可用节点要求少,但一次读可能只访问仍停在 v6 的 C。W=3, R=1 让成功写到达所有副本,读一个就可能得到新值,但任一副本不可用都会阻塞写。参数把延迟与可用性成本分配到读或写路径。

W + R > N 只证明集合相交

交集节点拥有某个版本,不代表协调者一定返回它。系统还需要可比较的版本信息、正确的冲突解析,并确保读取的 R 个响应确实参与决策。若只取最快响应就立即返回,其余响应异步丢弃,公式没有被完整实现。

并发写也可能产生两个互不支配的版本。依赖物理时钟的 last-write-wins 会受时钟偏差影响,还可能让一次业务更新静默覆盖另一次。Dynamo 使用 vector clock 表达因果关系,在无法自动判定时保留冲突版本交给业务合并。不同产品可能使用时间戳、逻辑时钟、LWW 或共识排序,Quorum 数量本身没有规定冲突规则。

网络分区期间的 sloppy quorum 还会把副本临时写到原 preference list 之外的健康节点。它提高可用性,却可能让一次读写所使用的节点集合不再按静态 N 简单相交,需要 hinted handoff、版本比较和 repair 把数据送回目标副本。公式成立依赖系统对成员集合和替代副本的具体定义。

多数写入与多数读取不自动等于线性一致

线性一致要求每个操作看起来在调用与返回之间某个时刻原子发生,并尊重真实时间顺序。W + R > N 能让读触及成功写的一个副本,但仍要处理并发协调者、版本排序、失败写入留下的少数新版本、成员变更和读修复。

Raft 这类共识协议解决的是另一层问题:Leader 把日志条目复制到多数节点后提交,并以 term、日志匹配和选举约束后续 Leader 包含已提交历史。多数派只是其中一个机制,安全性还来自完整协议。不能看到“多数节点确认”就把普通 Quorum KV、MySQL 半同步和 Raft 视为同一种保证。

LOCAL_QUORUM 改变了保证的地理范围

多机房部署中,跨洲等待全局多数会显著增加延迟。Cassandra 提供 LOCAL_QUORUM,只在本地数据中心的副本中形成多数。官方文档把它描述为较弱但实用的保证:本地后续读取可看见本地最新写入。

如果写在北京以 LOCAL_QUORUM 返回,立刻从新加坡 LOCAL_ONE 读取,仍可能看到旧数据。API 文档需要说明 consistency level 的作用域;“用了 QUORUM”如果省略 LOCAL、EACH 或全局副本布局,信息仍然不足。

七、读修复和反熵处理已经存在的副本差异

副本系统必须接受一个事实:节点会短暂落后,失败写入也可能只到达部分节点。后台修复负责让它们最终收敛,前台读取则决定本次请求是否等待修复。

Cassandra 的 read repair 在 Quorum 读发现参与副本不一致时,组合最新数据并修复落后副本。官方Read Repair 文档还说明,blocking read repair 用于提供 monotonic quorum reads。选择 ONE 时没有足够响应比较差异,也不会触发同样的读修复流程。

Anti-entropy repair 周期性比较副本数据,例如用 Merkle Tree 找出不一致范围,再流式修复。Hinted handoff 则在目标副本临时不可用时替它保存一份带目的地的写,恢复后转交。三者分别在读取、后台巡检和短故障写入路径工作,不能相互完全替代。

墓碑与删除会让修复更难。某副本记录“Key 已删除”,另一个副本长期离线还保留旧值;如果删除标记在修复前被清理,旧值可能重新出现。删除保留窗口、最长故障时间和 repair 周期必须相互匹配。旧数据并不总是一行较小的 version,也可能是一条本应永久消失的记录。

八、短链接系统怎样选择读取策略

把前面的机制放回短链接,可以按路径分配成本,而不是把所有读都改成强读。

创建链路写主库并获得 commit_token。事务成功后写入短码缓存,同时把 token 返回给管理端。创建结果页携带 token,读取时优先选择已经 applied 的副本;等待预算耗尽则回主库。用户复制短链立即在另一个设备打开时没有 token,但缓存已经由创建链路填充,通常仍可命中。

公共跳转路径以缓存为第一层。缓存未命中时,老短码可以读健康副本;根据 link_id 时间位或创建元数据判断为新短码时,回主库或禁止负缓存。删除、封禁等安全状态需要更严格策略,不能让落后副本继续返回已下线 URL。可以把封禁列表做成独立强一致检查,或让读取要求最低状态版本。

后台列表允许秒级陈旧,可以读从库并在页面标注数据更新时间。编辑链接后的详情页则粘主或携带版本 token。点击统计天然接受延迟,可从分析存储读取。一个产品内部同时存在多种一致性需求,这比统一声明“读写分离最终一致”更可操作。

一次创建后立即访问的完整时序

假设主库提交 T42,replica-a 已应用,replica-b 只接收日志:

1. POST /links -> primary 提交 T42
2. primary 返回 short_code=1G2j, commit_token=T42
3. 创建服务写入 cache[1G2j]
4. GET /1G2j 先查缓存
5. 缓存未命中时,路由器比较副本 applied_position
6. replica-a >= T42,允许读取;replica-b < T42,不参与本次请求
7. 没有合格副本或等待超时,回退 primary

若缓存写入失败,步骤 5~7 仍能保证本次因果读取。若客户端也没有携带 token,系统只能依靠主库、创建时间保护窗口或较弱的最终一致。保证强度取决于请求带来的证据。

删除比创建更不能容忍旧读

新建链接在旧副本上表现为 404,通常是短暂可用性问题;已经因钓鱼或权限撤销而删除的链接,在旧副本上继续跳转可能成为安全事故。相同的复制延迟,对两类操作的错误成本完全不同。

删除接口可以同步更新封禁缓存,在跳转入口先检查 denylist;也可以要求状态变更达到远端应用确认后再返回。若副本读取用于跳转,返回 ACTIVE 前还可比较状态版本。设计一致性策略时要从错误结果出发,不能只按表或 SQL 类型分类。

九、监控复制不能只看一个 lag

一个可运维的复制面板需要回答:日志有没有产生、有没有传到副本、有没有持久化、有没有应用、应用后是否可读、当前读请求实际去了哪里。

层次 需要观察的信号 常见故障
主库提交 commit rate、binlog/WAL 生成速率、fsync 延迟 磁盘抖动、事务堆积
传输 receiver 状态、received position、网络吞吐 断连、带宽不足、日志被清理
应用 applied position、队列长度、最老事务年龄 长事务、锁等待、SQL 错误
读取 每种 consistency 路由比例、等待与回退次数 强读过多、旧副本被选中
切换 term/epoch、新主位置、旧主隔离状态 双主、路由未刷新
Quorum 每次收到的 ack 数、慢副本、冲突与 repair 降级为弱一致、修复积压

时间 lag 适合展示趋势,位置差更适合判断某个 token 是否已经执行。两者都要看:位置落后一个巨型事务可能比落后一万个小事务更慢;时间看似为零也可能只是主库此刻没有新写入,复制线程早已停止。

指标还要按副本和 shard 展开。平均 lag 20 毫秒可能掩盖一个副本落后 30 分钟;负载均衡器继续把 5% 读流量发给它,就会产生少量但稳定的旧读。健康检查应包含 applied 水位与错误状态,不能只检查 TCP 端口可连。

用故障注入验证承诺

测试环境可以主动暂停 replica applier,再执行“创建后立即读取”,验证强读是否等待或回主库,普通读是否按预期出现陈旧。恢复 applier 后检查 token 等待、缓存负结果和指标是否收敛。

半同步复制要测试所有确认副本断开后的行为:写请求会阻塞多久,超时后拒绝写入还是退回异步,监控能否报警。MySQL 默认配置可能在超时后回退异步,若业务不允许这种降级,就要由代理或应用在状态改变时停止关键写入。

failover 演练至少覆盖三种位置:事务只在原主;日志已到副本但未应用;事务已在候选副本应用。分别记录客户端看到的响应、新主最终数据、重试结果和旧主隔离证据。只有这样才能把 RPO 从文档里的“理论零丢失”变成可验证行为。

Quorum 系统则应暂停不同数量的副本,验证每个 consistency level 的成功与失败边界;制造并发写,检查版本选择和冲突指标;停止 repair 后再恢复,确认旧副本不会长期返回被删除值。

十、从业务保证反推复制与读取方式

业务要求 写确认 读策略 主要代价
允许短暂旧读,优先低延迟 主库本地提交 任意健康副本 failover 可能丢最近写入
自己写完必须看见 主库提交并返回 token 等 applied 水位或回主 token 传播与等待开销
主库故障不丢已确认写 至少远端持久化或多数提交 从满足版本的节点读 写延迟与可用副本要求上升
任意节点协调、可调一致性 N/W/R 按操作选择 读取 R 份并版本合并 冲突、repair 与成员管理
安全状态不能陈旧 更强确认或独立强一致状态 主库/共识读、版本校验 读扩展能力下降
报表允许分钟级延迟 异步复制 标记水位的分析副本 结果不是实时状态

如果目标只是分担报表或后台查询,异步从库通常足够;接口要在写后立即读取时,先实现主库读或 commit token,没有必要直接更换整套数据库。业务要求在主库不可恢复后仍保留所有已确认交易,才需要把远端持久化或多数提交放进写成功定义,并承担对应延迟。

设计文档应写具体行为:COMMIT 等待到哪个节点的哪个阶段;读请求默认去哪;哪些接口要求读己之写;版本 token 怎样传播;等待超时是否回主;半同步降级后关键写是否继续;故障切换怎样选择新主并 fencing 旧主;Quorum 的 N/W/R、版本比较和 repair 又由谁实现。

副本让系统拥有更多数据副本,也让“当前值”变成带位置与时间的问题。一次正确读取需要知道自己最低必须看到哪个版本,再选择已经达到该水位的节点。把这条约束写进协议,读写分离才从流量规则变成可解释的一致性设计。

参考资料