数据库增加副本后,为什么仍会读到旧数据:主从复制、读写分离与 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。版本 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 又由谁实现。
副本让系统拥有更多数据副本,也让“当前值”变成带位置与时间的问题。一次正确读取需要知道自己最低必须看到哪个版本,再选择已经达到该水位的节点。把这条约束写进协议,读写分离才从流量规则变成可解释的一致性设计。
参考资料
- MySQL 8.4 Reference Manual:Replication
- MySQL:Binary Log File Position Based Replication
- MySQL:Replication Functions
- MySQL:Semisynchronous Replication Monitoring
- PostgreSQL:Log-Shipping Standby Servers
- Dynamo: Amazon’s Highly Available Key-value Store
- Apache Cassandra:Dynamo
- Apache Cassandra:Read Repair
- In Search of an Understandable Consensus Algorithm
如果这篇文章对你有帮助