💡《从"读多写少"到"读多写多":一次架构演进的深度复盘》
本文记录真实的业务演进过程:系统从"读多写少"的简单场景,逐渐演化为夜间任务的"读多写多"场景,导致原设计框架失效。
最终,我们通过 CQRS + 双轨模型 + 分段缓存 重新构建架构,使系统在性能、一致性、可扩展性上重新平衡。
目录
- 背景:业务为何突然"突变"?
- V1 架构:典型"读多写少"系统
- 业务变化:夜间裂变任务导致写多
- 问题分析:环依赖 + 缓存不合理 + 写压
- 架构重建:CQRS + 双轨模型
- 分段缓存:缓存不是一锅炖
- 最终架构(附全链路流程图)
- 总结:系统架构必须服务业务变化
1. 背景:业务为何突然"突变"?
最初我们的业务只有一个特点:
✔ 白天大量读取
✔ 写极少
(读多写少的典型场景)
例如基础档案、BOM 属性树、价格基础表等
然而业务方后期引入一个模型:
🌙 夜间裂变任务
某条基础数据变更后,会向上级、关联业务单据、关联子 BOM 全量传递引起 N 层价格链条变化。
在业务上,这就像原子链式反应 ------ 裂变。
结果:
- 白天依旧读多写少
- 夜间突然变成写多写爆
这导致原本的架构从根上失效。
2. V1 架构:典型"读多写少"缓存架构
Hit Miss Hit Miss Client Request JVM 缓存 Return Redis DB 回填 Redis + JVM
特点:
- 本地缓存 + Redis 二级缓存
- 更新事件少,缓存结构简单
- DB 只承担少量写
这套体系在读多写少场景非常稳。当然在集群环境内我们需要MQ进行Topic广播进行触达集群变更。
3. 业务变化:夜间裂变任务导致瞬时写爆
❗ 一个小小的基础价格变化 → 会触发 N 层业务数据回写
例如:
-
改了一个 BOM 的价格
→ 它是别的 BOM 的子集
→ 所有上级 BOM 也要重算
→ 它们再触达更上层 BOM
→ ......
这不是简单写,而是"传染式写",数量可以从 1 变成 10,000。
夜间定时任务开始执行后:
- DB 写压力骤增
- Redis 写入大量 key
- JVM 缓存瞬间失效密集
- 更新链路反复触发
当然实际上的触达源有很多:账务、成本、工艺、工时、薪资调整等业务都会触发类似的回写
原有"读多写少"架构被彻底击穿。
4. 问题分析:本质是三大错误假设
(1) ❌ 有向有环的业务结构
业务数据修改基础数据 → 基础数据又影响业务数据
形成有向环 → 极易出现数据动荡、难以维护一致性。
最终我们做了正确选择:
必须打断环,引入中间层表单(中间结果)。
(2) ❌ 写被低估
早期架构完全按"写少"设计,没有考虑高并发写。
夜间任务一来,写量瞬间放大数百倍。
(3) ❌ 缓存策略不适配
白天缓存读写少 → ok
夜间缓存大量写入 → 缓存层来不及同步、更新量太大、全量刷新困难
5. 最终架构解决方案:CQRS + 双轨模型
这两个是架构升级的核心。
✔ CQRS:Command Query Responsibility Segregation
核心思想:读和写是两条完全不同的业务轨道。
写模型(Command Model)
- 最终一致即可
- 允许异步操作
- 允许队列缓冲
- 数据结构适合写优化
读模型(Query Model)
- 为"读性能"设计
- 可高度缓存化
- 可预计算、可反范式化
可以用一张图概括:
事件 Command 接口 写库 Query 接口 读库/缓存
✔ 双轨模型(Two-Track Model)
我的理解如下:
Track A(白天在线业务)
- 稳定
- 缓存主导
- 响应实时用户
Track B(夜间离线计算)
- 不走缓存
- 直接操作 DB
- 集中计算、批处理
- 计算完成后 → 统一刷新缓存
架构图如下:
夜间轨道->离线计算 直接写 DB 定时任务批处理 计算完成后触发全量缓存刷新 白天轨道->在线 读模型缓存层 用户请求 响应
这样就实现了:
✔ 白天响应快(缓存驱动)
✔ 夜间计算准(DB直写)
✔ 两条轨道互不干扰
6. 分段缓存:缓存不是一锅炖,而是结构化缓存
为什么需要分段缓存?
因为"缓存整张表"是最愚蠢的做法:
- 写入一个字段,需要刷新整份缓存
- 修改一条数据,会 invalid 全表缓存
- 放在 Redis 时会造成巨大 key
- JVM 缓存容易被大对象撑爆
正确做法是:
✔ 缓存拆成"段"(Segment)
例如:
| 段名 | 领域 |
|---|---|
| S1 | BOM 基础信息(变化少) |
| S2 | BOM 价格段(变化中等) |
| S3 | 裂变需要的中间结果(变化多) |
| S4 | 业务计算依赖的临时缓存(变化快) |
结构如下:
Redis BOM 基础段 价格段 裂变中间段 临时高速段
这就如图业务中台内的大量数据,进行清洗切割分段后,形成细碎的指标,进入数据中台。
为什么这样划分?
✔ 不同变化频率 → 不同缓存策略
✔ 不同冷热程度 → 不同 TTL
✔ 不同业务用途 → 不同数据结构
🔹 Segment 1:基础段(稳定)
特点:
- 变化极少
- 可永久缓存
- 可 JVM + Redis 双缓存
数据结构:
json
{
"bom_id": 1001,
"name": "发动机模块",
"category": "机械",
"version": 3
}
🔹 Segment 2:价格段(中等变动)
适合:
- Map 结构(可更新单字段)
- Redis Hash
- 分字段更新
例:
tex
HSET price:1001 material_cost 12.3 labor_cost 8.1 overhead_cost 4.0
🔹 Segment 3:裂变段(变化频繁)
特点:
- 写多
- 夜间高并发
- 用 Redis 的必要性一般
- 更适合不缓存或使用短 TTL(比如 10 分钟)
数据结构:
json
{
"bom_id": 1001,
"fission_pending": true,
"dependents": [1005, 1006, 1010]
}
🔹 Segment 4:临时高速段(极高变化 / 瞬时热点)
适合:
- W-TinyLFU 思路
- 用来缓存 1 小时内的热点 key
- 防止夜间裂变任务"扫库"带来的缓存污染
数据结构:
json
{
"bom_id": 1001,
"recent_access": 182
}
6.5一些拓展:🆚 秒杀系统 vs 12306:同样高 QPS,为何架构完全不同?
在理解缓存之前,必须理解一个事实:
高并发不是架构的本质,本质是 "业务形态" 决定架构。
💥 秒杀业务:
目标:顶住流量 → 提前准备 → 不求数据强一致,允许最终一致
典型特征:
- 读远大于写
- 数据高度可缓存(商品详情、库存标识)
- 允许"先冻结库存 → 后异步扣减"
- 允许失败(未抢到)
- 高峰集中持续时间短
🚄 12306 业务:
目标:绝对一致 → 一个位置不能卖给两个人
典型特征:
- 强一致(不能超卖)
- 查询高、写高、锁竞争高
- 庞大的路径规划
- 动态路由分流
- 不可用缓存票信息
- 请求分散但总量巨大
所以两种架构完全不同。
🧩 秒杀架构(缓存 + 削峰 + 异步)
库存充足 库存不足 用户请求 CDN 缓存静态页 Nginx 限流 Token桶/漏斗限流 Redis 预库存 MQ 异步扣减库存 DB 最终落库 抢购失败
设计思想:
- 一切往缓存中推:页面、库存、活动状态......
- 强烈依赖 Redis + 预库存
- DB 只承担最终写(削峰)
- "一致性"可以接受延迟(最终一致)
🧩 12306 架构(强一致 + 路由 + 锁)
节点3 节点2 节点1 座位查询/加锁 节点3 订单系统 DB 集群 座位查询/加锁 节点2 订单系统 DB 集群 座位查询/加锁 节点1 订单系统 DB 集群 用户请求 LVS/负载均衡 全国路由调度层
设计思想:
- "不可缓存"票务数据
- 必须锁,必须串行,必须强一致
- 通过 OSFP/LVS 等路由把请求打散
- 分布式事务(TCC/2PC)补偿
- 查询大,写也大,是典型强一致高并发系统
🧠 设计思想对比:为什么架构不同?
业务形态决定架构 秒杀 12306 可缓存 最终一致 读多写少 可接受失败 异步削峰 Redis/缓存为核心 不可缓存 强一致 读多写多 不可失败 分布式锁 路由/调度为核心
一句话总结:
秒杀在"扛流量",12306 在"保一致性"。
一个靠缓存,一个靠调度与锁。
所以架构路径完全不同。
7. 最终架构图
夜间_离线轨 白天_在线轨 直接写 DB 裂变任务 计算完成 统一刷新分段缓存 读模型 用户请求 S1 基础段 S2 价格段 S4 临时热点段 业务响应
8. 总结:架构必须服务业务,而不是业务迁就架构
本次演进体现三个关键点:
① 架构必须跟随业务模式
读多写少 → 读多写多 → 架构必须变化。
② CQRS + 双轨模型是应对业务突变的最佳实践
白天高性能,夜间高一致性,两条路分开走。
③ 缓存不是越大越好,而是越"精细分段"越稳定
不同段不同策略,缓存才真正有价值。