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 一致性模型谱系图。