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,但业务无感、监控不报
这类降级必须同时做三件事,否则等于没做:
- 打点:
semi_sync_off计数器 + 告警(不是只看Semi_sync_status); - 限时:退化超过 N 分钟自动摘除坏副本,而不是长期异步;
- 对账:退化期间产生的写,标记为“待确认”,切换后核对(超时与重试预算见 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 仍等于 “未落盘的那一段”。