16.6 会话保证 (读己之写、单调读、单调写)
一句话
会话保证是以客户端会话为单位的四条承诺:读己之写、单调读、单调写、写跟随读;它们能让用户在最终一致的系统上「不看到自己的操作消失」,但不会让其他客户端看到更新。
四条保证,各管一件事
| 保证 | 定义 | 没有它时用户看到什么 |
|---|---|---|
| 读己之写(read-your-writes, RYW) | 客户端写 W 完成之后发起的读,必须返回 W 或更新的值 | 刚保存的资料,刷新后变回旧值 |
| 单调读(monotonic reads, MR) | 客户端读到值 v 之后,后续读不会返回比 v 更旧的版本 | 刷新一次有动态,再刷新动态消失 |
| 单调写(monotonic writes, MW) | 同一客户端的多个写,按发起顺序生效 | 先改 A 再改 B,结果 B 被 A 覆盖 |
| 写跟随读(writes-follow-reads, WFR) | 客户端读 v 之后发起的写,必须排在「产生 v 的状态」之后 | 看了帖子再评论,评论却挂到旧版本/不存在的帖子上 |
四条合起来,Vogels(2008)称为 strong session guarantees:它等价于「以会话为单位的顺序一致」,但仍然不是全局线性一致——别的客户端可能仍看到旧值。
🔑 会话保证是客户端视角的补丁:它不改变数据层的全局一致性,只保证「同一个用户的连续操作不出现反直觉的倒退」。
为什么需要它:客户端不是一个「进程」
在 16.3 顺序一致性 里说过:理论模型把客户端当成一个进程,程序顺序天然包含「读己之写」。工程里这个假设很容易破:
| 破坏方式 | 例子 |
|---|---|
| 写走 leader、读走 follower | 写完立刻读,follower 还没追上 |
| 负载均衡换副本 | 第一次读副本 A,第二次读副本 B(更旧) |
| 连接重建 / 会话漂移 | 移动端切网、长连接断开重连 |
| 多端登录 | 手机写完,平板读不到 |
| 缓存层 | 读请求命中本地/边缘缓存(41.7 多级缓存) |
| 主从切换 | 新 leader 的日志可能比客户端见过的版本旧(丢写) |
所以「顺序一致/线性一致」解决不了「客户端跨副本」的问题,需要显式的会话状态。
实现:四种手段与它们的边界
| 手段 | 做法 | 能保证 | 失效场景 |
|---|---|---|---|
| 粘性路由 | 同一会话固定到同一副本 | MR(单副本版本只增) | 该副本宕机/摘除;RYW 仍不保证(写走 leader) |
| 会话令牌 / 版本号 | 客户端带回「我见过的最新版本」,副本等自己追上再返回 | RYW + MR | 令牌丢失、令牌不可跨地域比较、缓存绕过 |
| 写后读主 | 写完成后 T 秒内强制读 leader | RYW | TTL 过期、leader 切换、跨地域延迟 |
| 因果令牌 | 写请求携带读到的依赖(因果上下文) | WFR + RYW | 元数据裁剪、跨服务传递丢失 |
会话令牌怎么工作
写:client → leader,返回 version=42;client 保存 token=42
读:client → 任意副本,带 token=42
副本 version ≥ 42 → 直接返回
副本 version < 42 → 等待/转发给更新的副本/读 leader
读返回 version=45 → client 更新 token=45
关键点:
- 令牌必须在读之后也更新,否则单调读没有依据;
- 令牌必须能比较(单主用递增版本号;多主用版本向量/混合逻辑时钟,见 23.5 混合逻辑时钟 (HLC),待补);
- 令牌要服务端持久化(至少按用户会话存),否则换设备、重连就丢——这是多端同步的坑(66.3 多端同步,待补)。
模拟:三种路由下的违例率
设定:3 个副本(0 为 leader),客户端 400 次「写 leader → 立刻读」,写通过随机延迟 0–3 步复制到两个 follower。三种读路由:
random:随机选副本;sticky:固定读 follower 1;token:选第一个版本 ≥ token 的副本,否则读 leader。
package main
import (
"fmt"
"math/rand"
)
type repl struct{ from, to, ver int }
func run(mode string, seed int64) (ryw, mr int, stale float64) {
rng := rand.New(rand.NewSource(seed))
const nrep = 3
const iters = 400
vers := [nrep]int{}
queue := []repl{}
token, maxSeen, totalGap := 0, 0, 0
for step := 0; step < iters; step++ {
vers[0]++ // 客户端写 leader
w := vers[0]
for r := 1; r < nrep; r++ {
queue = append(queue, repl{0, r, w})
}
var next []repl // 复制:每步 1/4 概率落地
for _, m := range queue {
if rng.Intn(4) == 0 {
if m.ver > vers[m.to] {
vers[m.to] = m.ver
}
} else {
next = append(next, m)
}
}
queue = next
r := 0
switch mode {
case "random":
r = rng.Intn(nrep)
case "sticky":
r = 1
case "token":
r = -1
for i := 0; i < nrep; i++ {
if vers[i] >= token {
r = i
break
}
}
if r < 0 {
r = 0
}
}
got := vers[r]
if got < w {
ryw++
}
if got < maxSeen {
mr++
}
if got > maxSeen {
maxSeen = got
}
if got > token {
token = got
}
if w > token {
token = w
}
totalGap += w - got
}
return ryw, mr, float64(totalGap) / iters
}
func main() {
for _, mode := range []string{"random", "sticky", "token"} {
var ryw, mr int
var stale float64
for i := 0; i < 50; i++ {
a, b, c := run(mode, int64(i)+1)
ryw += a
mr += b
stale += c
}
n := float64(50 * 400)
fmt.Printf("%-8s 读己之写违例 %5.1f%% 单调读违例 %5.1f%% 平均落后主副本 %.2f 个版本\n",
mode, float64(ryw)*100/n, float64(mr)*100/n, stale/50)
}
}
输出(本机运行,50 次试验 × 400 轮):
random 读己之写违例 49.9% 单调读违例 15.7% 平均落后主副本 0.95 个版本
sticky 读己之写违例 75.0% 单调读违例 0.0% 平均落后主副本 1.41 个版本
token 读己之写违例 0.0% 单调读违例 0.0% 平均落后主副本 0.00 个版本
三个结果各自说明一件事:
| 观察 | 原因 |
|---|---|
random 有一半读不到自己的写 | 写后立即读,两个 follower 都没追上,只有 1/3 概率命中 leader |
sticky 单调读 0 违例,但读己之写更差(75%) | 固定在同一个 follower,版本只增不减 → 单调读成立;但该 follower 持续落后 → 更常读旧值 |
token 两项都 0 | 读之前先确认副本版本 ≥ 令牌,不满足就换副本/读 leader——用延迟换保证 |
注意 token 的「平均落后 0.00」不是因为它读得快,而是它拒绝读没追上的副本:保证不是免费的,代价是等待或回源。
会话保证不等于全局一致
| 说法 | 对不对 | 说明 |
|---|---|---|
| 「有 RYW,所以是线性一致」 | ❌ | 别的客户端可能读到旧值;RYW 只管自己 |
| 「有 RYW + MR,所以不会读到脏数据」 | ❌ | 保证的是「不回退」,不是「最新」 |
| 「会话保证能替代因果一致」 | ❌ | 它只覆盖一个会话;跨客户端的因果(评论依赖帖子)管不到 |
| 「最终一致 + 会话保证 = 强一致」 | ❌ | 全局仍是最终一致,只是单用户视角顺滑 |
| 「令牌能跨设备」 | ⚠️ | 只有服务端持久化、且设备共享同一会话状态时才成立 |
一个有用的定位:会话保证把「因果一致」的适用范围从「整个系统」缩到「一个客户端」。它便宜、有效、不改变数据层,但不能拿来做跨用户的不变式。
反模式
| 反模式 | 后果 |
|---|---|
| 只做粘性会话,不做令牌 | 副本宕机/扩容重哈希后,RYW 与 MR 同时失效 |
| 令牌只在写后更新,读后不更新 | 单调读没有依据,读到旧值后再也回不到新值 |
| 令牌 TTL 太短 | 长事务/慢请求中途丢令牌,保证断档 |
| 读请求绕过令牌(缓存、CDN) | 命中缓存直接返回旧值,RYW 失效(9.1 边缘缓存原理) |
| 主从切换后仍用旧令牌 | 新 leader 没有该版本(丢写),永远等不到 → 超时或错误 |
| 多端各存各的令牌 | 手机写完平板读不到,用户以为没保存 |
| 把会话保证写进「全局一致性」宣传 | 跨用户读到旧值,投诉时无法解释 |
自检
Q1 读己之写(RYW)与单调读(MR)的区别是什么?举一个只违反其中一个的例子。
▸ 答案:RYW 管「自己的写能不能被自己读到」,MR 管「读到的版本会不会倒退」。只违反 RYW 的例子:客户端写完 v=42,去读 follower(version=40),读到 40;之后一直读同一个 follower,版本只增,所以 MR 没违反,但每次读都落后于自己的写。只违反 MR 的例子:客户端先读副本 A(version=50),再读副本 B(version=45),读到更旧的值;如果客户端这段时间没写,RYW 不涉及,但读版本倒退了。
Q2 为什么「粘性会话」能保证单调读,却常常让读己之写更糟?
▸ 答案:粘性到单个副本后,该副本的状态只会随复制推进而前进,所以读版本单调不降——MR 成立。但写通常走 leader,follower 持续落后;粘性把这个「落后量」固定下来,客户端每次读都落后同样的距离,反而比随机路由(有 1/3 概率读到 leader)更容易读不到自己的写。模拟里 sticky 的 RYW 违例 75%,高于 random 的 49.9%。
Q3 会话令牌为什么能同时解决 RYW 和 MR?它的代价是什么?
▸ 答案:令牌记录「这个会话已经见过的最新版本」。读之前要求副本版本 ≥ 令牌:满足则读到的版本不低于已见版本(MR),且不低于自己的写(RYW);不满足则等待、换副本或读 leader。代价是延迟与可用性:副本落后时要等或回源,跨地域时可能要多一次 RTT;令牌丢失或不可比较时保证失效。
Q4 主从切换后,为什么会话保证可能无法兑现?怎么处理?
▸ 答案:如果新 leader 没有客户端已见过的版本(异步复制丢写),那么「副本版本 ≥ 令牌」永远无法满足——令牌指向一个不存在的历史。处理:① 切换前用同步/半同步复制保证 RPO=0;② 令牌带版本向量/任期号,检测到回退时显式失败并让客户端重试/提示;③ 降级为「读新 leader 当前状态」,同时接受该会话的 RYW 被打破并记录。绝不能假装满足然后返回旧值。
Q5 一个多端产品(手机 + 平板)要求「手机写的,平板立刻能读到」,用客户端令牌能解决吗?
▸ 答案:单靠客户端本地令牌不行——令牌存在手机上,平板没有。要么把会话状态放到服务端(按用户维度存「已确认版本」),平板读时带上服务端会话令牌;要么走跨端同步通道(推送/长连接)把版本推给其他端;要么直接读 leader(付延迟)。这属于多端同步问题(66.3 多端同步,待补),会话保证只是其中一层。
跨篇相关:全局顺序与实时约束见 16.2 线性一致性、16.3 顺序一致性;跨客户端的因果保证见 16.4 因果一致性;最终一致的收敛与冲突见 16.5 最终一致性;复制延迟与读一致性见 18.4 复制延迟与读一致性;主从切换的丢写风险见 18.5 故障检测与切换;读写分离下的读旧值见 37.4 读写分离、44.13 主从复制与读写分离;缓存绕过令牌见 41.7 多级缓存;多端同步见 66.3 多端同步(待补);登录态与令牌生命周期见 49.5 登录态与令牌生命周期;逻辑时钟用于令牌比较见 23.5 混合逻辑时钟 (HLC);谱系定位见 16.7 一致性模型谱系图。