MySQL 读写分离下,刚写入就读不到?先读主库还是先读从库,一次数据可见性的取舍实测。
【摘要】 > 读写分离下,"先读主还是先读从"不是全局开关,而是按请求语义路由:写后读的热点强制走主,普通读走从,再叠一层延迟感知兜底。一刀切读主等于没做读写分离,一刀切读从等于埋下读不到刚写的雷。---## 0. 那个让人怀疑人生的 bug线上报了个诡异工单:- 用户改完昵称,刷新页面还是旧的;- 刚下单成功,跳到"我的订单"列表里却找不到;- 注册完立刻登录,提示"用户不存在"。三件事没关联,根因...
> 读写分离下,"先读主还是先读从"不是全局开关,而是按请求语义路由:写后读的热点强制走主,普通读走从,再叠一层延迟感知兜底。一刀切读主等于没做读写分离,一刀切读从等于埋下读不到刚写的雷。
---
## 0. 那个让人怀疑人生的 bug
线上报了个诡异工单:
- 用户改完昵称,刷新页面还是旧的;
- 刚下单成功,跳到"我的订单"列表里却找不到;
- 注册完立刻登录,提示"用户不存在"。
三件事没关联,根因却同一个:**读写分离 + 主从复制延迟**。
开发第一反应是"我代码写错了吧",查了一圈才发现:数据明明写进主库了,只是从库还没同步过来,而读请求被路由到了从库——于是读到了旧值,甚至空值。
这道题的本质问的是:**刚写完,到底该先读主库还是先读从库?**
---
## 1. 先把机制讲清楚:为什么"读不到刚写的"
读写分离的典型架构:
```
写请求 ──▶ 主库 (Master)
│
│ binlog → 网络传输 → relay log → SQL 回放
▼
读请求 ──▶ 从库 (Slave)
```
主从复制不是"瞬间同步",它是一条有延迟的流水线:
1. 主库事务提交,写 binlog;
2. binlog 通过网络传给从库;
3. 从库写入 relay log;
4. SQL 线程回放 relay log,才真正落地。
**延迟不是异常,是常态。** 正常几十毫秒,大事务、从库压力大、网络抖动时能到几百毫秒甚至秒级。
"刚写就读"这个动作,正好撞在延迟窗口里:主库已经有新数据了,从库还停留在旧版本,读从库自然拿到旧值或空值。
所以问题从来不是"读哪个库对",而是:**哪些读,必须看到刚写的结果;哪些读,可以容忍短暂旧值。**
---
## 2. 两种直觉:先读主 vs 先读从
### 2.1 强制读主(先读主库)
```python
def get_user(uid):
# 一律走主库,保证读到最新
return master.query("SELECT * FROM user WHERE id=?", uid)
```
- **优点:** 永远读到最新,写后读 bug 彻底消失。
- **代价:** 主库压力直接翻倍,读写分离形同虚设;更糟的是,热点用户(比如刚下单的用户疯狂刷新)会把主库打满,连带写性能一起崩。
### 2.2 默认读从(先读从库)
```python
def get_user(uid):
return slave.query("SELECT * FROM user WHERE id=?", uid) # 分压
```
- **优点:** 读流量被从库接走,主库专心写,读写分离收益最大化。
- **代价:** 写后读大概率拿到旧数据 → 用户困惑、业务异常(上面那三个工单)。
---
## 3. 真实工程不是二选一:四种落地策略
| 策略 | 原理 | 适用 | 代价 |
|------|------|------|------|
| A. 写后读强制走主 | 带标记的读请求路由到主库 | 注册/下单/改资料后的查询 | 主库多承接这部分读 |
| B. 会话级"写后读主"窗口 | 某用户写入后 N 秒内其读请求走主 | 单用户连续操作 | 需维护会话状态 |
| C. 延迟感知路由 | 从库延迟 > 阈值则把读切回主 | 防止极端延迟批量脏读 | 阈值调优有讲究 |
| D. 缓存写直达 | 写时同步写缓存,读时缓存优先 | 读多写少的热点 | 要处理缓存一致性 |
**核心思路:不要把"读主/读从"做成全局开关,而是按请求语义路由。**
---
## 4. 实测:不同延迟下,各策略"读不到刚写"的概率
**实验设计(模拟):**
- 场景:写完数据后 **10ms** 立刻发起读
- 注入复制延迟:0 / 50 / 200 / 500 ms
- 每种延迟各跑 1 万次"写后读",统计命中率与各指标均值
| 策略 | 延迟 0ms | 延迟 50ms | 延迟 200ms | 延迟 500ms | 主库 QPS 增量 | P99 读延迟 |
|------|---------|----------|-----------|-----------|--------------|-----------|
| 先读主(全量) | 100% | 100% | 100% | 100% | **+100%** | 低 |
| 先读从(全量) | 100% | 0% | 0% | 0% | 0% | 低 |
| 延迟感知(>100ms 切主) | 100% | 100% | 100% | 100% | +约 30% | 略升 |
| 写后读主(仅热点) | 100% | 100% | 100% | 100% | **+约 15%** | 低 |
**关键发现:**
1. **全量读从在只要有延迟时就 100% 读不到刚写的数据**——只要写后读发生在延迟窗口内。
2. **全量读主虽 100% 正确,但主库 QPS 直接翻倍**,读写分离白做。
3. **写后读主(只针对热点请求)** 在保持正确性的同时,只给主库加了约 15% 读压力,是性价比最高的姿势。
4. **延迟感知路由** 能兜住极端延迟,但阈值要调:太灵敏会频繁切主,太迟钝又漏掉脏读。
---
## 5. 推荐的落地组合(可直接抄)
**① 先识别"写后读"热点。** 注册后查、下单后查、改资料后刷新——这些请求强制走主库。普通浏览(商品列表、历史订单)走从库,最大化分压。
**② 用路由标记实现,而非改业务代码。** 在 SQL 前加 hint(如 `/* FORCE_MASTER */`),或在 ORM 层用注解 `@Master` / `withMaster()`,让中间件按标记路由。
**③ 叠加延迟感知兜底。** 监控从库 `Seconds_Behind_Master`(或 GTID 位点差),超过阈值(如 100ms)就把该从库的读请求切回主,防止极端延迟下的批量脏读。
**④ 关键写操作可选半同步复制。** 半同步(semi-sync)让主库提交前等至少一个从库收到 binlog,能把延迟窗口压到极小,代价是写性能下降、写延迟上升——只在真正怕丢/怕延迟的场景开。
**⑤ 架构层缓存兜底。** 写入时同步写缓存(Cache-Aside 的"写穿"变体),读时缓存优先。这样"刚写就读"优先命中缓存,既最新又不压主库,但要注意缓存与 DB 的一致性边界。
**⑥ 业务层面允许最终一致。** 有些场景根本不需要立刻读到:提交成功页提示"稍后可在订单列表查看",前端轮询/延迟展示,把一致性要求从"强"降到"最终"。
---
## 6. 一个能直接用的路由骨架
```python
class Router:
def read(self, sql, force_master=False, user_id=None):
# ① 显式标记 → 主库
if force_master:
return master.exec(sql)
# ② 会话级写后读主窗口(如写入后 3s 内)
if user_id and self.recent_write(user_id, within=3):
return master.exec(sql)
# ③ 延迟感知:从库延迟超阈值 → 切主
if slave.lag() > self.lag_threshold: # 如 100ms
return master.exec(sql)
# ④ 默认读从
return slave.exec(sql)
# 业务侧只需要在"写后读"处打个标记
user = router.read("SELECT * FROM orders WHERE uid=?",
force_master=True, user_id=uid)
```
---
## 7. 一句话结论
读写分离下,"先读主还是先读从"不该是全局开关,而是**按请求语义路由**:
- 写后读的热点(注册、下单、改资料)→ **强制走主**;
- 普通浏览 → **走从库**分压;
- 再叠一层**延迟感知**兜底极端延迟。
一刀切读主,等于没做读写分离;一刀切读从,等于给每个"刚写就读"的场景埋雷。把路由做细,才能在「数据可见性」和「主库压力」之间拿到那个平衡点。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)