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 显然不可接受,要读主库或加失效);③ 设计降级(超时后回源哪一层、返回多旧的数据)。没有这个数,团队只能在事故时争论“是不是缓存的问题”。