读写分离下的数据一致性问题与解决方案

读写分离下的数据一致性问题与解决方案

概述

读写分离架构可以实现读能力水平扩展,但引入了数据一致性问题:刚写入的数据,从库可能还没同步到。 如果不能容忍读到旧数据,就需要做额外处理。

问题的根源

时间线:
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 可能等待 强一致 ⭐⭐⭐
写后窗口期 强一致 ⭐⭐⭐⭐⭐

业务层面的权衡

不同业务对一致性的容忍度不同:

# 需要强一致的场景(读主库):
  - 支付结果查询
  - 用户刚修改的昵称(立即显示)
  - 秒杀库存查询

# 可接受最终一致的场景(正常走从库):
  - 历史订单列表
  - 排行榜数据
  - 推荐内容
  - 统计分析报表

面试要点

  1. 读写分离的核心矛盾:主从延迟导致”写后读”可能读到旧数据
  2. 最简单的解决方案:对一致性要求高的查询强制走主库
  3. WAIT_UNTIL_SQL_THREAD_AFTER_GTIDS:MySQL 原生方案,等从库同步完再读
  4. 写后窗口期:记录用户最近写入时间,窗口期内走主库
  5. 不是所有读都需要强一致:不同业务场景区分对待
  6. 最终一致性是大多数业务的现实选择
© 版权声明
THE END
喜欢就支持一下吧
点赞13 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容