32.3 读写模型分离

一句话

读写模型分离的本质是承认 “一个模型服务不了两种访问形态”:写要的是规则与一致性(范式化、聚合),读要的是形状与速度(宽表、预聚合);分离的代价是 “两套模型 + 一条同步链路”。

一个模型 vs 两个模型

维度 单一模型 读写分离模型
写 范式化、约束完整 同左(写侧不变)
读 复杂 JOIN / 聚合,慢 宽表/文档,点查
一致性 强 读侧最终一致
变更成本 改一处影响读写 写侧改 schema,读侧改投影
典型实现 关系库 + 索引 关系库 + ES/Redis/宽表

决策表

你的情况 选择
查询 < 5 个索引可覆盖、QPS 不高 单一模型,别上分离
多条件组合查询、列表页、搜索 读写分离(读侧 ES/宽表)
报表/大屏、聚合口径多 分离 + 物化视图
强一致(账户余额、库存) 读走写库/主库,别用读侧

代码:读侧 DO 独立,别复用实体

// 写侧实体:带行为与不变量
type Order struct {
    ID, Status string
    Items []Item
}
func (o *Order) Pay(at time.Time) error { /* 状态机校验 */ }

// 读侧 DTO:只为 “查出来给前端”,不共享业务方法
type OrderListItem struct {
    ID, Status, BuyerName string
    Amount int64
    CreatedAt time.Time
}

// 读侧查询:一条 SQL 打平,避免 N+1
SELECT o.id, o.status, u.name AS buyer_name, o.amount, o.created_at
FROM order_view o JOIN user_view u ON u.id = o.buyer_id
WHERE o.buyer_id = ? ORDER BY o.created_at DESC LIMIT 20;

实测:N+1 与预连接的差距(含真实 DB 模型)

用 5000 单、500 买家:

方式 内存字典 真实 DB(每次 0.2ms)
N+1(每单查一次买家) 0.77ms 约 1000ms
预连接 / 批量取 0.47ms 约 0.2ms

内存里只差 1.6 倍,掩盖了 N+1 的真实代价;换成数据库,5000 次查询约 1000ms,而一次批量取只要 0.2ms——差距 5000 倍。这也是 “读侧要打平查询、不要循环查” 的量化依据。

// ❌ N+1:循环里查库
for _, o := range orders { u := repo.GetUser(o.BuyerID); ... }
// ✅ 批量:一次取回,再内存拼装
users := repo.GetUsersByIDs(idsOf(orders)); m := index(users)

反模式

反模式 后果
读写共用同一个 “万能 Entity” 读侧字段污染写侧模型,边界消失
读侧直接查写库的复杂 JOIN 写库被报表拖慢,影响交易
读侧模型无 owner 字段含义无人解释,越加越乱
读侧做业务校验 校验发生在投影上,写侧漏校验
一致性靠 “重试读” 用户反复刷新,延迟不可控
分离后不做延迟监控 用户看到旧数据却无人知道

| 只在内存/测试数据上验证性能 | N+1 被字典查找掩盖,上线才暴露 | | 读侧分页后又逐条补字段 | 分页 20 条也可能触发 20 次查询 |

三条铁律

🔑 写侧对规则负责,读侧对形状负责;不要让读侧反向约束事务。

🔑 读侧可以冗余,但不能有业务校验。

🔑 一致性用 “读己之写” 显式处理,不要靠运气。

自检

Q1 为什么 “读侧复用写侧实体” 是常见但危险的做法?

▸ 答案:因为写侧实体会不断加业务方法、校验、关联,读侧被迫加载不需要的数据(甚至触发懒加载/大对象);更糟的是读侧开始调用写侧方法,边界被打穿。读侧应该有自己的 DTO,只含 “查询需要的字段”。

Q2 什么情况下 “读写分离” 反而更慢?

▸ 答案:当写多读少或读侧需要强一致时:同步链路本身有成本,读侧还要等投影;如果读请求必须回查写库,等于多了一跳。读写比 < 1 时,先优化写路径,别急着分离。

Q3 “读己之写” 的三种实现怎么选?

▸ 答案:等版本(读侧等到 ≥ 本次写入版本,最通用)、写后短窗读主库(简单,但主库压力上升)、前端乐观更新(体验好,但可能显示错误后回退)。选择依据是 “业务能否容忍短暂不一致” 与 “主库余量”。

Q4 读侧用 ES,索引与写库如何保持一致?

▸ 答案:靠事件/CDC 投递 + 幂等 upsert + 位点监控:写库变更 → 消息 → 消费者更新文档;文档用主键 upsert(幂等),并用版本号丢弃旧消息(乱序防护)。另需 “全量重建” 兜底(见 32.5 投影与重建)。

Q5 读侧模型要不要保留历史版本?

▸ 答案:通常不要:读侧是可重建的派生数据,保留历史只会让 storage 与一致性复杂化。需要历史查询时,用写侧的事件/归档表,而不是在读侧堆版本。

Q6 怎么判断 “该分离” 了?

▸ 答案:看三个信号:慢查询集中在聚合/多条件(而非缺索引)、报表查询影响交易(CPU/IO 抢占)、查询形状与写模型严重不匹配(大量 JOIN 与字段裁剪)。三者出现两个,就值得分离。

Q7 为什么 “分页 20 条” 仍可能是一次 N+1 灾难?

▸ 答案:因为分页只限制了主表行数,没限制 “每行要补多少关联”:20 条订单各自查买家、商品、优惠、物流,就是 80 次查询;再乘以 QPS,数据库连接池很快被打满。解法是把关联改成批量接口或读侧宽表,让 “每页固定 N 次查询”。

故障复盘(典型模式,脱敏)

阶段 现象 判断 动作
大促前 订单列表页超时率飙升 循环里查买家/商品(N+1) 限流 + 降级
T+10m 连接池耗尽,影响写交易 5000 单 × 每单多次查询 批量接口
T+1h 批量接口上线,P99 从 3s 降到 120ms 实测模型:5000×0.2ms=1000ms 常态化检查
复盘 只在测试数据上验证过性能 N+1 被小数据掩盖 压测用真实数据量

根因:读侧 N+1 查询 + 无批量接口。代价:大促前 1 小时故障。根治:读侧打平/宽表;压测数据量必须接近生产。

关联