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 小时故障。根治:读侧打平/宽表;压测数据量必须接近生产。