18.2 同步、半同步、异步

一句话

三种模式的分界线不是快慢,而是**“提交成功”的定义**:同步等全部副本(丢不了,但一个慢副本拖死写),半同步等至少一个(折中,但仍有窗口),异步立即返回(快,但故障切换会丢已提交的写)。

复制模型(单主 / 多主 / 无主)决定“谁能写”,同步方式决定“何时算写成”,两者要一起看(18.1 复制模型:单主 / 多主 / 无主)。

统一时序:三者只差“在哪一步返回”

客户端        主库                      副本
  │── 写 ─────▶│                         │
  │            │── 写日志(本地)         │
  │            │── 复制 ────────────────▶│
  │            │◀───────── ACK ──────────│
  │◀── 成功 ───│  ← 返回点在这里决定了一切 │
模式 返回点 提交时保证 主库故障后 写延迟
异步 本地写完就返回 副本可能还没收到(只承诺最终一致,16.5 最终一致性) 丢未复制的写(RPO>0) 本地落盘(<1ms)
半同步 等到 ≥1 个副本 ACK 至少一个副本有 若那个副本也挂/未落盘,仍可能丢 等最快的副本(约 1 个 RTT)
全同步 等到 全部 副本 ACK 所有副本都有 不丢(前提:不整组同时挂) 等最慢的副本(约 1 个 RTT + 尾部)

🔑 关键不是名字,是返回点:同一个产品叫“半同步”,返回点放在 commit 之后(AFTER_COMMIT)还是之前(AFTER_SYNC),丢数据的性质完全不同。

实测:尾延迟决定一切

模型:每次写要等 3 个副本,每个副本的“网络 + 落盘”延迟服从对数正态(中位数 2ms,σ=1),本地落盘 0.5ms。异步只算本地;半同步等最快的 1 个;全同步等最慢的 1 个。

package main

import (
	"fmt"
	"math"
	"math/rand"
	"sort"
)

func pct(xs []float64, p float64) float64 {
	return xs[int(float64(len(xs)-1)*p)]
}

func main() {
	rnd := rand.New(rand.NewSource(11))
	const n = 100000
	const replicas = 3
	sample := func() float64 { // 每个副本的延迟:中位数 2ms 的对数正态
		return math.Exp(math.Log(2) + rnd.NormFloat64()*1.0)
	}
	const local = 0.5

	async := make([]float64, 0, n)
	semi := make([]float64, 0, n)
	sync := make([]float64, 0, n)
	for i := 0; i < n; i++ {
		delays := make([]float64, replicas)
		for j := range delays {
			delays[j] = sample()
		}
		sort.Float64s(delays)
		async = append(async, local)                  // 只等本地
		semi = append(semi, local+delays[0])          // 等最快的副本
		sync = append(sync, local+delays[replicas-1]) // 等最慢的副本
	}
	fmt.Println("=== 提交延迟(10 万次写)===")
	fmt.Printf("%-8s %8s %8s %8s %10s\n", "模式", "p50", "p99", "p99.9", "最大")
	for i, name := range []string{"异步", "半同步", "全同步"} {
		xs := [][]float64{async, semi, sync}[i]
		sort.Float64s(xs)
		fmt.Printf("%-8s %7.2fms %7.2fms %7.2fms %8.2fms\n",
			name, pct(xs, 0.50), pct(xs, 0.99), pct(xs, 0.999), xs[len(xs)-1])
	}
}

本机实测(macOS,Apple M1,Go 1.27):

=== 提交延迟(10 万次写)===
模式            p50      p99    p99.9         最大
异步          0.50ms    0.50ms    0.50ms     0.50ms
半同步         1.38ms    4.88ms    7.85ms    14.95ms
全同步         5.04ms   31.19ms   62.02ms   216.10ms

三个数字背后是三件事:

现象 原因
半同步 p50 1.38ms,接近“最快副本” 等第一个 ACK,等于对 3 个延迟取 min
全同步 p50 5.04ms,是半同步的 3.6 倍 等最后一个,等于取 max;3 个样本的 max 天然偏大
全同步 p99 31ms、最大 216ms 尾部被放大:只要有一个副本抖动,这次提交就慢

⚠️ 同步复制的问题不是“平均慢”,而是“p99 由最慢的副本决定”。副本越多,等到的“最慢”越慢——这就是“加副本反而更不稳”的数学原因。

半同步的两个实现细节

1. 返回点在 commit 之前还是之后

以 MySQL 为例(细节见 44.13 主从复制与读写分离):

变体 顺序 客户端看到提交时 主库崩溃丢不丢
AFTER_COMMIT 提交 → 等 ACK 从库可能还没有 仍可能丢
AFTER_SYNC 等 ACK → 提交 至少一个从库已收到 不丢(副本别一起挂)

名字一样,性质不同:这就是“开了半同步还是丢数据”的最常见原因。

2. “≥1 个副本”是哪个副本

策略 丢数据条件 写延迟
等任意 1 个副本 那个副本和主一起挂(同机架/同 AZ) 最低
等 1 个异地副本 异地副本也挂 高(跨城 RTT)
等多数派(quorum) 多数派同时挂 中等(第 2 近的副本)

🔑 半同步保护的是持久性,不是读一致性:副本收到了日志,不代表已经回放,从库照样可能读到旧值。

超时退化:最危险的“静默降级”

几乎所有半同步实现都有一个超时:等不到 ACK 就退化成异步,避免一个坏副本拖死整个库。

rpl_semi_sync_master_timeout = 10s(MySQL 默认)
→ 10 秒内等不到 ACK:本次及后续写按异步返回
→ 此时 RPO > 0,但业务无感、监控不报

这类降级必须同时做三件事,否则等于没做:

  1. 打点:semi_sync_off 计数器 + 告警(不是只看 Semi_sync_status);
  2. 限时:退化超过 N 分钟自动摘除坏副本,而不是长期异步;
  3. 对账:退化期间产生的写,标记为“待确认”,切换后核对(超时与重试预算见 35.5 超时、重试与退避)。

不同系统的“提交”定义

系统 旋钮 语义差异
MySQL sync_binlog、innodb_flush_log_at_trx_commit、半同步模式 落盘点与 ACK 点分开,两者都要看
PostgreSQL synchronous_commit = off / local / remote_write / on / remote_apply remote_write 只保证对端收到,remote_apply 才保证可读
Kafka acks=0 / 1 / all + min.insync.replicas acks=all 且 ISR ≥ min 才不丢;ISR 缩小会退化
etcd / Raft 多数派持久化后才算提交 天然以多数派为提交点

结论:不要问“这是同步还是异步”,要问“返回给客户端时,数据在几个副本的磁盘上”。

决策表

场景 选择 代价
支付、账务(RPO=0) 全同步(或多数派),跨城 写 p99 由最慢副本决定;跨城 30ms
用户资料、内容(可容忍秒级丢失) 异步 切换丢最近几秒写
单地域、要 RPO=0 且延迟敏感 同城半同步(AFTER_SYNC,等异地 1 个) 1–3ms;异地副本挂则退化
日志/埋点(可丢) 异步 + 批量 丢一些无所谓,吞吐优先
跨洲强一致 先问“真的需要吗” 写延迟 180ms 起,几乎必须批量化;这就是 PACELC 的 EC 代价(见 17.4 PACELC)

反模式

反模式 后果
开了半同步就认为 RPO=0 AFTER_COMMIT 仍会丢;副本可能同机架
同步副本与主库同 AZ 机房故障时一起挂,等于异步
超时退化没有告警 长期跑在异步模式,切换时才发现丢数据
把 remote_write 当 remote_apply 以为“从库能读到”,其实还没回放
全同步 + 跨城 + 高 QPS 写 p99 被异地副本拖爆,吞吐掉一个数量级
只压测平均延迟 同步复制的风险在尾部(p99/p99.9)

自检

Q1 全同步复制的平均延迟只比异步高几毫秒,为什么生产上仍要谨慎?用实测数字说明。

▸ 答案:因为风险在尾部,不在均值。实测里全同步 p50 5.04ms,但 p99 31.19ms、最大 216ms;同步写要等最慢的那个副本,副本越多、网络越抖,尾延迟越被放大。对 P99 SLO 敏感的业务,一次抖动就会让一批请求超时;而异步的 p99 几乎是常数(本地落盘)。所以关键指标是 p99/p99.9,不是平均。

Q2 “半同步”为什么可能仍然丢数据?列出两个独立的原因。

▸ 答案:① 返回点在 commit 之后(AFTER_COMMIT):客户端看到成功时,从库可能还没收到日志,主库崩溃就丢;② ACK 的副本不可靠:它可能与主库同机架/同 AZ,一起断电;或者只写进了页缓存(sync_relay_log 未落盘),操作系统崩溃仍丢。另外还有第三个常见原因:超时退化成异步,且没有告警。

Q3 为什么“加副本”可能让同步写的 p99 变差,而不是变好?

▸ 答案:全同步要等所有副本,延迟是 N 个样本的最大值;N 越大,取到的最大值越大(极值统计)。半同步等“最快的 1 个”时,N 越大反而越好(取最小值)。所以:同步复制加副本会让尾延迟变差,半同步加副本会让尾延迟变好——同一个“加副本”的动作,在两种模式下的效果相反。

Q4 PostgreSQL 的 synchronous_commit=remote_write 与 remote_apply 有什么区别?业务上怎么选?

▸ 答案:remote_write 只保证备库收到了 WAL 并写入(可能还在页缓存),不保证已回放,所以备库读不到这笔提交;remote_apply 要等备库回放完成,读备库能看到。要让“读己之写”成立、或做主备切换后立刻可读,需要 remote_apply;只是防丢数据,remote_write 就够。选错会导致“切换后数据在但查询不到”的诡异现象。

Q5 半同步的 “确认” 到底是 “收到” 还是 “落盘”,为什么这个区别决定 RPO?

▸ 答案:因为 “收到” 只保证消息进了备库内存/网络缓冲,备库崩溃即丢;“落盘” 才保证崩溃后可恢复。所以 “半同步 = 不丢数据” 必须追问确认层级;只确认收到时,RPO 仍等于 “未落盘的那一段”。

关联