大宗商品销采运储系统・六层架构连载(数据持久层)
上一篇我们完成了数据持久层总览,明确了从单机到海量数据架构演进的必经之路。
今天进入第一个硬核实战:读写分离。
在销采运储系统初期,一台 MySQL 单机抗下了所有。但随着业务量上涨,矛盾很快爆发:白天大宗商品集中开盘,大量用户同时刷新行情、查询库存、提交采购/销售单据。
数据库的 CPU 和磁盘 IO 被两件事抢占:高频的写(开单、状态流转) 和 海量的读(列表查询、报表统计)。
为了不让"读"把"写"拖死,我们引入了读写分离:主库抗写,从库抗读。但随之而来的,是一个极其经典且致命的线上灾难------主从延迟。
一句话定调:
读写分离解决的是数据库的"并发瓶颈",主从延迟解决的是业务体验的"可用底线"。只做分离不做延迟治理,就是耍流氓。
一、为什么大宗商品系统必须做读写分离?
如果不做读写分离,单机 MySQL 会遇到三个死结:
-
锁竞争加剧:大量的报表统计(如月度进销存汇总)带有复杂的联表查询,消耗大量 CPU 并占用 Buffer Pool,导致开单的写请求被阻塞,出现锁等待超时。
-
IOPS 瓶颈:核心单据表每天新增上百万行,写操作极其频繁,磁盘 IO 很快达到上限。
-
连接数打满:前端多、报表查询慢,数据库连接池被耗尽,新的交易请求无法建立连接。
读写分离的标准架构:
应用服务
→ 读写分离中间件(ShardingSphere / MyCat / 应用层路由)
→ 写请求(INSERT / UPDATE / DELETE) → 主库(Master)
→ 读请求(SELECT) → 从库(Slave 1 / Slave 2)
业务拆分规则:
核心交易链路(写):开单、扣减库存、修改状态,走主库。
报表与列表(读):历史单据查询、报表导出,走从库。
强一致读:刚提交完单据要跳转详情页,必须走主库。

二、致命灾难:刚开完单,列表却查不到
读写分离架构最容易踩的坑,就是主从延迟。
真实场景回放:
业务员 A 提交了一张 100 吨的采购单。
系统写主库成功,返回"开单成功"。
前端自动跳转到"采购单列表"。
列表接口走从库查询,由于主从复制有延迟(哪怕只有 200ms)。
从库还没收到刚才的写入数据,查询结果为空。
业务员 A 懵了:"单子呢?是不是没保存成功?"
业务员 A 再次点击"提交",导致重复开单。
在大宗商品场景,这种重复开单不仅会导致库存虚增,还会引发后续的财务结算错乱。业务人员不懂什么是主从延迟,他们只知道"系统出 Bug 了"。
主从延迟到底怎么产生的?
-
网络延迟:主库到从库的专线抖动。
-
大事务阻塞:主库执行了一个耗时 5 秒的批量更新,从库必须等这个事务全部执行完才能继续同步(MySQL 并行复制虽能缓解,但无法根除)。
-
从库压力过大:从库不仅要同步数据,还要抗住海量的报表查询,CPU 打满导致回放 binlog 慢。
-
主库写入并发高:大宗商品开盘瞬间,主库大量并发写入,从库单线程或多线程回放跟不上。

三、生产级主从延迟治理方案
只靠 MySQL 原生参数调优(如开启半同步复制)是不够的,必须在架构和代码层面进行治理。
方案 1:应用层强制走主库(核心业务跳转)
对于"写完立刻读"的场景(如提交订单后跳转详情页),强制将读请求路由到主库。
实现思路:使用 ThreadLocal 记录当前线程是否强制走主库,配合 AOP 或注解实现。
// 1. 定义注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MasterRoute {
}
// 2. AOP 切面
@Aspect
@Component
public class MasterRouteAspect {
@Around("@annotation(masterRoute)")
public Object routeToMaster(ProceedingJoinPoint pjp, MasterRoute masterRoute) throws Throwable {
try {
MasterRouteContext.setForceMaster(true);
return pjp.proceed();
} finally {
MasterRouteContext.clear();
}
}
}
// 3. 在需要强一致读的接口上打注解
@MasterRoute
@GetMapping("/order/detail/{orderNo}")
public Result<OrderVO> getOrderDetail(@PathVariable String orderNo) {
return Result.success(orderService.getByOrderNo(orderNo));
}
⚠️ 注意: 强制走主库只能用于小范围的"刚写完立刻读"场景,绝对不能滥用。大宗商品的高并发查询如果全部走主库,主库依然会被打崩。
方案 2:缓存双写兜底(工业级最佳实践)
写主库成功后,立刻将这条新数据写入 Redis,并设置较短的过期时间(如 30 秒)。
后续的列表查询或详情查询,优先查 Redis;查不到再走从库。
@Transactional
public void createOrder(OrderDTO dto) {
// 1. 落主库
orderMapper.insert(dto);
// 2. 写入 Redis,防主从延迟期间查不到
String key = "order:detail:" + dto.getOrderNo();
redisTemplate.opsForValue().set(key, dto, 30, TimeUnit.SECONDS);
// 3. 缓存双删(防止并发导致的脏数据)
// ...
}
方案 3:数据库层配置半同步复制(Semi-Sync)
MySQL 默认的异步复制,主库写完就返回,不管从库。半同步复制要求主库必须收到至少一个从库的 ACK,才向客户端返回成功。
主库开启半同步插件
INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
SET GLOBAL rpl_semi_sync_master_enabled = 1;
超时时间(毫秒),超时后退化为异步复制
SET GLOBAL rpl_semi_sync_master_timeout = 1000;
评价: 半同步复制能大幅降低延迟导致的"丢读"概率,但当从库全部宕机或网络极差时,会退化为异步复制,不能完全依赖。
方案 4:业务妥协设计(用户体验层)
对于非核心的列表展示,如果实在查不到,可以在前端做一层体验优化:
"单据同步中,请稍后刷新"。
或者在前端提交成功后,直接使用接口返回的数据渲染列表第一行,而不是立刻发一次全量列表查询。
方案 5:延迟监控与告警
必须监控 Seconds_Behind_Master。
SHOW SLAVE STATUS\G;
-- 重点看 Seconds_Behind_Master
在系统中接入 Prometheus + MySQL Exporter,如果延迟超过阈值(如 2 秒),立刻触发告警。
四、读写分离落地避坑指南
坑点 1:事务内的读请求走从库
现象 :在同一个事务里,先 update,再 select,结果查出了旧数据。
原因 :Spring 的 @Transactional 保证的是主库事务,如果 MyBatis 拦截器把事务内的 select 发给了从库,由于隔离性,从库读不到主库未提交的数据。
规范 :同一个事务内,所有 SQL 必须强制走主库。 可以在动态数据源路由逻辑中判断,如果当前处于事务中(TransactionSynchronizationManager.isActualTransactionActive()),一律走主库。
坑点 2:大报表查询压垮从库
现象 :财务导出百万级数据,从库 CPU 瞬间 100%,导致后续所有读请求超时。
规范:导出类、超重度的报表查询,必须带 Sentinel 限流,或者直接下沉到专用的报表库/数仓(离线 T+1 查询)。
坑点 3:主库宕机后的读操作
现象 :主库挂了,但业务还在继续发读请求,从库也被拖死。
规范:配置 Sentinel,对访问数据库的所有接口做熔断降级。主库挂掉时,写操作全部降级提示,读操作走缓存。
五、本篇小结
读写分离是数据库架构演进的第一步,它能解决"读"把"写"拖死的问题,但也会引入"主从延迟"这个新的挑战。
记住三句话:
-
写走主,读走从,写完立刻读必须走主。
-
半同步复制保底,缓存双写兜底,延迟监控告警。
-
重查询(报表)不仅走从库,还要上限制,防止从库被拖垮。
下篇预告:我们进入数据持久层最硬核的实战:分库分表(上),拆解切分策略、基因法路由设计,以及如何做到不锁表平滑扩容。