读写分离下的数据一致性问题与解决方案
概述
读写分离架构可以实现读能力水平扩展,但引入了数据一致性问题:刚写入的数据,从库可能还没同步到。 如果不能容忍读到旧数据,就需要做额外处理。
问题的根源
时间线:
T1: 客户端 1 写入主库 → UPDATE SET balance = 100 WHERE id = 1 → ✅ 成功
T2: 主库写入 Binlog → 等待从库同步(异步复制,不阻塞)
T3: 客户端 2 从从库读取 → SELECT balance FROM users WHERE id = 1
→ 可能读到旧的 balance!
↑ 从库还没来得及同步
T4: 从库终于同步完成 → balance 变成 100
问题:T2 到 T4 之间,从库数据是"过期"的
常见场景
场景一:刚创建即查询
-- 用户注册后立即登录
-- 1. 写入主库
INSERT INTO users(id, name) VALUES(100, 'Alice');
-- 返回成功
-- 2. 立即跳转到首页,从库读取
SELECT * FROM users WHERE id = 100;
-- ❌ 可能查不到!从库还没同步
场景二:修改后马上查看
-- 用户修改个人信息
-- 1. 写主库
UPDATE users SET nickname = 'NewName' WHERE id = 1;
-- 2. 刷新页面,从库读取
SELECT nickname FROM users WHERE id = 1;
-- ❌ 可能还是旧的 nickname
场景三:先读后写再读
-- 电商下单
-- 1. 从从库读库存(SELECT stock FROM products WHERE id = 1)
-- 2. 主库扣减库存(UPDATE products SET stock = stock - 1 WHERE id = 1)
-- 3. 立即从从库读验证 → 可能读到旧的库存数量
解决方案
方案一:写后读强一致
强制读主库(对一致性要求高的请求):
// 方案 A:标记需要强一致的查询
public User getUserById(int id, boolean forceMaster) {
if (forceMaster) {
// 走主库
return masterMapper.selectById(id);
}
// 走从库
return slaveMapper.selectById(id);
}
// 使用场景
User user = getUserById(1, true); // 强一致场景
-- 方案 B:在查询中添加注释,让中间件识别
SELECT /* master */ * FROM users WHERE id = 1;
方案二:等待一定时间
写后休眠一小段时间再读从库:
// 写操作后
userMapper.update(...);
// 等 100ms(经验值,取决于主从延迟)
Thread.sleep(100);
// 再从从库读
User user = userMapper.selectById(1); // 大概率已同步
问题:不精确,要么等多了(性能浪费),要么等少了(还是读到旧数据)。
方案三:缓存中间表
写主库时同时更新缓存:
// 1. 写主库
userMapper.updateName(1, "NewName");
// 2. 同时更新 Redis
redisTemplate.opsForValue().set("user:1:name", "NewName");
// 3. 读从库时先查缓存
String name = redisTemplate.opsForValue().get("user:1:name");
if (name != null) {
return name; // 缓存命中,不用查从库
}
// 缓存未命中,查从库
方案四:在从库查询最新事务
从库检查是否已经同步到指定位置:
-- 应用端记录写入时的 GTID
-- WRITE: 记录当前主库的 GTID
-- 从 SHOW MASTER STATUS 获取 GTID
-- READ:等从库执行到这个 GTID 再返回
SELECT WAIT_FOR_EXECUTED_GTID_SET('3e11fa47-61ca:42', 5);
-- 等待 GTID:42 执行到,最多等 5 秒
这是 MySQL 5.7+ 提供的原生方案:
-- 主库写入后,获取当前 GTID
SELECT @@GLOBAL.gtid_executed;
-- 得到:3e11fa47-61ca-11e8-9e70-00163e114761:42
-- 从库读取前,等待该 GTID 执行
SELECT WAIT_UNTIL_SQL_THREAD_AFTER_GTIDS(
'3e11fa47-61ca-11e8-9e70-00163e114761:42', 5
);
-- 等待成功 → 继续读从库 ✅
-- 超时(5秒)→ 走主库读取作为兜底
方案五:写操作后读主库(短时间)
设置”写后读窗口期”:
public class UserService {
// 记录每个用户的最后一次写操作时间
private Cache<Integer, Long> lastWriteTime = new Cache<>();
public void updateUser(int userId, String name) {
masterMapper.update(userId, name);
lastWriteTime.put(userId, System.currentTimeMillis());
}
public User getUser(int userId) {
Long lastWrite = lastWriteTime.get(userId);
if (lastWrite != null &&
System.currentTimeMillis() - lastWrite < 1000) {
// 1 秒内写过该用户 → 读主库
return masterMapper.selectById(userId);
}
// 1 秒以上没写过 → 正常走从库
return slaveMapper.selectById(userId);
}
}
方案对比
| 方案 | 实现难度 | 性能影响 | 一致性保证 | 推荐度 |
|---|---|---|---|---|
| 强制读主库 | 低 | 增加主库压力 | 强一致 | ⭐⭐⭐⭐ |
| 写后等时间 | 低 | 增加等待时间 | 弱一致 | ⭐⭐ |
| 缓存中间表 | 中 | 好(缓存加速) | 最终一致 | ⭐⭐⭐⭐ |
| WAIT_GTID | 中 | 可能等待 | 强一致 | ⭐⭐⭐ |
| 写后窗口期 | 中 | 好 | 强一致 | ⭐⭐⭐⭐⭐ |
业务层面的权衡
不同业务对一致性的容忍度不同:
# 需要强一致的场景(读主库):
- 支付结果查询
- 用户刚修改的昵称(立即显示)
- 秒杀库存查询
# 可接受最终一致的场景(正常走从库):
- 历史订单列表
- 排行榜数据
- 推荐内容
- 统计分析报表
面试要点
- 读写分离的核心矛盾:主从延迟导致”写后读”可能读到旧数据
- 最简单的解决方案:对一致性要求高的查询强制走主库
WAIT_UNTIL_SQL_THREAD_AFTER_GTIDS:MySQL 原生方案,等从库同步完再读- 写后窗口期:记录用户最近写入时间,窗口期内走主库
- 不是所有读都需要强一致:不同业务场景区分对待
- 最终一致性是大多数业务的现实选择
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END


暂无评论内容