46.2 数据角色 (主数据 / 副本 / 派生数据)

一句话

每一份数据都要能回答三个问题:谁是权威(可写)、谁是副本(可重建)、谁是派生(可重算)——答不出“丢了能不能重建”,这份数据就不该存在。

四种角色

角色 能否写 丢失后果 怎么恢复 例子
主数据(source of truth) ✅ 唯一可写 灾难性,只能靠备份 备份 + binlog 回放 订单库、账户库
副本 ❌ 只读 可恢复 从主库重新同步 只读从库、跨地域副本
派生数据 ❌ 由计算产生 可重算 从主数据/事件重放 数仓宽表、ES 索引、推荐特征
缓存 ❌ 由读回填 无影响 删掉重建 Redis、CDN

🔑 判断口径:“这份数据丢了,业务能不能靠别的数据算回来?” 能 → 副本/派生/缓存;不能 → 主数据,必须上备份与审计。

代码:一条链路上的陈旧上界与重建成本

package main

import "fmt"

type layer struct {
	name      string
	role      string
	lagSec    float64 // 相对上游的延迟
	rebuildGB float64 // 全量重建要处理的数据量(GB)
}

var layers = []layer{
	{"订单主库(MySQL)", "主数据", 0, 0},
	{"同城只读副本", "副本", 0.5, 800},
	{"跨地域副本", "副本", 2.0, 800},
	{"数仓明细层(DWD)", "派生", 60, 1200},
	{"数仓汇总层(DWS)", "派生", 300, 200},
	{"搜索索引", "派生", 5, 150},
	{"Redis 缓存", "缓存", 600, 20},
}

const replayGBPerSec = 0.5 // 重算/回放的吞吐(GB/s,示意)

func main() {
	fmt.Printf("%-22s %-8s %-10s %-14s %s\n", "层", "角色", "上游延迟", "累计陈旧上界", "全量重建耗时")
	cum := 0.0
	for _, l := range layers {
		cum += l.lagSec
		rebuild := "—"
		if l.role != "主数据" {
			rebuild = fmt.Sprintf("%.0f 分钟", l.rebuildGB/replayGBPerSec/60)
		}
		fmt.Printf("%-22s %-8s %-10s %-14s %s\n", l.name, l.role,
			fmt.Sprintf("%.1fs", l.lagSec), fmt.Sprintf("%.0fs", cum), rebuild)
	}

	fmt.Println("\n=== 三个必须写进文档的问题 ===")
	fmt.Println("1) 这份数据谁可写?—— 只有主数据可写,其余层必须只读")
	fmt.Println("2) 丢了怎么恢复?—— 副本重新同步、派生重放、缓存清空")
	fmt.Println("3) 最坏能旧多久?—— 累计延迟之和(上面最后一列)")
	fmt.Printf("\n最坏情况:用户在主库写入后,缓存层可能仍展示 %.0f 秒前的数据\n", cum)
}

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

层                      角色       上游延迟       累计陈旧上界         全量重建耗时
订单主库(MySQL)            主数据      0.0s       0s             —
同城只读副本                 副本       0.5s       0s             27 分钟
跨地域副本                  副本       2.0s       2s             27 分钟
数仓明细层(DWD)             派生       60.0s      62s            40 分钟
数仓汇总层(DWS)             派生       300.0s     362s           7 分钟
搜索索引                   派生       5.0s       368s           5 分钟
Redis 缓存               缓存       600.0s     968s           1 分钟

=== 三个必须写进文档的问题 ===
1) 这份数据谁可写?—— 只有主数据可写,其余层必须只读
2) 丢了怎么恢复?—— 副本重新同步、派生重放、缓存清空
3) 最坏能旧多久?—— 累计延迟之和(上面最后一列)

最坏情况:用户在主库写入后,缓存层可能仍展示 968 秒前的数据

读数:

结论 说明
陈旧是累加的 主库 → 副本 0.5s → 数仓 60s → 汇总 300s → 缓存 600s,最坏近 16 分钟
派生层重建有成本 DWD 1200GB 按 0.5GB/s 重放要 40 分钟,不是“随时能重建”
主数据没有重建路径 它自己就是源头,只能靠备份——所以备份要单独演练

🔑 “能重建”不等于“能随时重建”:派生层重建要时间、要资源、要上游可回放(binlog 保留期够不够)。重建 SLA 要写进设计:出问题时是“等 40 分钟重算”还是“先降级用旧数据”。

三道防线

防线 做法 防什么
写入口唯一 只有主数据可写,其余层通过 CDC/事件同步 多处写导致数据打架
可重放 binlog/事件保留 ≥ 重算窗口 派生层出错无法回滚
可校验 定期对账(行数/金额/抽样) 同步丢数据、算错

反模式

反模式 后果
数仓表被业务直接写 主数据与派生数据混在一起,无法重算
副本被应用写入 主从不一致,切换后数据丢失
派生层没有重放能力 算错只能手工修数
缓存当数据源 缓存一清空业务就“丢数据”
不写陈旧上界 用户看到旧数据,团队互相甩锅
备份存在但从不演练恢复 真出事时发现备份不可用

自检

Q1 搜索索引属于哪一类数据?丢了怎么办?

▸ 答案:派生数据。它由主数据(商品/内容)经 CDC 或定时任务构建,丢了可以从主数据 + CDC 重放重建(重建时间取决于数据量与索引构建速度)。因此它不能作为唯一数据源(不能只写 ES 不写库),也不该被应用直接写(写入必须走主数据再同步)。

Q2 为什么“副本”与“派生数据”要分开叫?

▸ 答案:因为恢复方式与一致性语义不同。副本是逐字节/逐行同步的镜像(延迟通常秒级,可用 binlog 位点追平),恢复 = 重新同步;派生是计算产物(可能聚合、join、改写),延迟可能分钟级,恢复 = 重跑计算逻辑。把派生当副本会导致“重同步就能恢复”的误判——实际上重跑逻辑可能因为口径变化产出不同结果。

Q3 “最坏能旧多久”这个数怎么用?

▸ 答案:三个用途:① 对业务承诺(“报表 T+1,实时看板延迟 ≤ 5 分钟”);② 判断能否容忍(支付成功页读缓存 600s 显然不可接受,要读主库或加失效);③ 设计降级(超时后回源哪一层、返回多旧的数据)。没有这个数,团队只能在事故时争论“是不是缓存的问题”。

关联