46.3 数据一致性分级

一句话

一致性要按数据集分级,而不是全站一个级别:余额必须强一致、商品列表可以最终一致、报表可以 T+1——分级的产物是一张“每个数据集允许多旧”的表,而不是一句“我们最终一致”。

五个级别,五种预算

级别 保证 实现 代价
强一致(线性) 读到的永远是最新提交 读主库 / 同步复制 / 分布式共识 延迟高、可用性降
读己之写 自己写完能读到 写后读主 / 会话粘滞 / 版本号 少量路由逻辑
单调读 不会读到比之前更旧的 固定副本 / 按版本过滤 需会话状态
最终一致 一段时间后一致 异步复制 有陈旧窗口
弱一致(T+1) 隔天一致 批处理 最便宜

🔑 分级的判据是“读旧数据的业务后果”:会导致资损/越权 → 强一致;只是体验差一点 → 最终一致;只用于分析 → T+1。

代码:把陈旧预算分到每一跳

package main

import "fmt"

type hop struct {
	name    string
	maxLagS float64
}

type dataset struct {
	name          string
	sloMaxLagS    float64 // 业务能容忍的最坏陈旧
	hops          []hop
}

var datasets = []dataset{
	{"账户余额", 0.1, []hop{{"主库读", 0}, {"缓存(不启用)", 0}}},
	{"订单状态(用户自己的)", 1.0, []hop{{"写后读主", 0}, {"列表页缓存", 0.4}, {"搜索索引", 1.5}}},
	{"商品价格", 5.0, []hop{{"主库", 0}, {"CDN 缓存", 3.0}, {"搜索索引", 1.5}}},
	{"销量看板", 300.0, []hop{{"数仓 DWD", 60}, {"DWS 汇总", 180}}},
	{"运营报表", 86400.0, []hop{{"T+1 批处理", 86400}}},
}

func main() {
	fmt.Printf("%-24s %-14s %-14s %-10s %s\n", "数据集", "业务容忍", "链路累计", "判定", "瓶颈跳")
	for _, d := range datasets {
		sum := 0.0
		worst := ""
		worstLag := -1.0
		for _, h := range d.hops {
			sum += h.maxLagS
			if h.maxLagS > worstLag {
				worstLag = h.maxLagS
				worst = h.name
			}
		}
		verdict := "✓ 达标"
		if sum > d.sloMaxLagS {
			verdict = "✗ 超预算"
		}
		fmt.Printf("%-24s %-14s %-14s %-10s %s\n", d.name,
			fmt.Sprintf("%.1fs", d.sloMaxLagS), fmt.Sprintf("%.1fs", sum), verdict, worst)
	}

	fmt.Println("\n=== 超预算时先砍哪一跳 ===")
	fmt.Println("优先级:缓存 TTL > 搜索索引延迟 > 数仓分层数;主库读尽量不动(它最贵但最准)")
}

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

数据集                      业务容忍           链路累计           判定         瓶颈跳
账户余额                     0.1s           0.0s           ✓ 达标       主库读
订单状态(用户自己的)              1.0s           1.9s           ✗ 超预算      搜索索引
商品价格                     5.0s           4.5s           ✓ 达标       CDN 缓存
销量看板                     300.0s         240.0s         ✓ 达标       DWS 汇总
运营报表                     86400.0s       86400.0s       ✓ 达标       T+1 批处理

=== 超预算时先砍哪一跳 ===
优先级:缓存 TTL > 搜索索引延迟 > 数仓分层数;主库读尽量不动(它最贵但最准)

读数:

数据集 预算 实际 结论
账户余额 0.1s 0s 达标:直接读主库,不挂缓存
订单状态 1.0s 1.9s 超预算:搜索索引 1.5s 是瓶颈,要么降索引延迟,要么订单列表绕过索引
商品价格 5.0s 4.5s 达标:CDN 3s 是最大头
销量看板 300s 240s 达标:分层要控制在 5 分钟内
运营报表 1 天 1 天 达标:批处理

🔑 分级不是写文档,是做预算:每一跳(缓存、索引、数仓分层)都在花“陈旧预算”,加起来不能超过业务容忍值。超了就要砍一跳——通常先砍缓存 TTL,再合并数仓分层,最后才动主库读。

三种“看起来一致”的坑

坑 现象 根因
写后读不一致 刚保存,刷新看不到 读请求被路由到副本/缓存
越读越旧 刷新几次,数据在版本间跳 读到了不同延迟的副本(无单调读)
缓存与库“轮流旧” 有时新有时旧 缓存 TTL 与复制延迟量级接近

⚠️ 第三个最常见:复制延迟 1s、缓存 TTL 1s,两者量级相同 → 用户看到的数据在“新/旧”之间随机跳。修法是让 TTL 远大于复制延迟(如 10 倍),或读路径绕过缓存。

反模式

反模式 后果
全站“最终一致”一句话 支付/余额类出现资损
账户余额挂 5 分钟缓存 用户看到旧余额,超额消费
缓存 TTL 与复制延迟同量级 数据新旧随机跳,无法复现
不为每个数据集写“允许多旧” 事故时无法判断是否“符合预期”
用强一致兜所有查询 主库被打满,延迟飙升
只做最终一致却不做对账 错了不知道,知道时已过很久

自检

Q1 为什么“账户余额”不能挂缓存,而“商品价格”可以挂 3 秒?

▸ 答案:因为后果不同。余额读旧了会导致资损与越权(用户按旧余额下单、超额消费),而且这种错误不可逆;商品价格读旧 3 秒最多是“显示价与结算价不一致”,结算时以主库为准即可(可以设计成“展示价仅供参考”)。分级看的是“读旧数据的业务后果”,不是“数据重不重要”。

Q2 复制延迟 1 秒、缓存 TTL 1 秒,为什么会出现“数据新旧随机跳”?

▸ 答案:因为两者量级相同:缓存可能在副本还没追上时回填旧值,然后旧值在缓存里存活 1 秒,而副本可能已经追上;下次 miss 又回填新值。用户连续刷新就会看到新旧交替。解法是让 TTL 远大于复制延迟(如 10s vs 1s),或读路径固定走主库(读己之写),使“旧值”不可能被回填。

Q3 一个报表系统要求“T+1”,但业务方抱怨“早上 8 点看不到昨天的数据”。问题通常出在哪?

▸ 答案:出在批处理窗口与依赖:T+1 不是“第二天早上就有”,而是“昨天的数据在某个时间点前跑完”。要满足 8 点前可用,需要倒推:上游几点前必须就绪、ETL 跑多久、失败重试留多少余量。常见问题是“串行依赖太多”或“大表全量重算”,解法是增量/分区重算 + 依赖分级(关键报表先跑)。

关联