读写分离的隐患——主从延迟导致的数据一致性问题实录
读写分离的隐患——主从延迟导致的数据一致性问题实录
适读人群:系统中用了主从读写分离的工程师 | 阅读时长:约14分钟 | 核心价值:读写分离不是万能的,它引入了主从延迟这个隐性问题,不处理会出生产事故
先说一次真实的线上事故
那是我们电商系统的一个工作日下午,客服突然报告:有用户投诉下单成功但商品已下架,页面显示"商品不存在"。
我们第一反应是商品被下架了,但查了后台,商品是"上架"状态。
排查了一个小时,发现了原因:
- 用户点击商品,触发了一次商品详情查询 → 查从库 → 从库里商品是"上架",正常显示
- 用户点击下单,触发了下单接口 → 写主库 → 下单成功,订单创建
- 下单成功后,页面跳转到订单确认页,查询订单详情 → 查从库 → 从库里订单还没同步过来(主从延迟 300ms),返回"订单不存在"
- 前端报错,用户以为下单失败,又点了一次
- 这次从库同步到了,查到了第一个订单,但用户的第二次点击又创建了一个新订单
用户支付了两次。
这是主从延迟引发的数据一致性问题。
主从延迟是怎么产生的
MySQL 主从复制的流程:
主库写完数据,写到 binlog;从库的 I/O 线程拉取 binlog 到 relay log;从库的 SQL 线程回放 relay log,更新从库数据。
这个过程是异步的,主库提交完事务后不等从库确认。因此,从库的数据总是落后主库一段时间——这就是主从延迟。
正常情况下延迟是多少? 在网络良好、从库没有压力的情况下,延迟通常是几毫秒到几十毫秒。
什么情况下延迟会变大?
- 从库执行了大的 DDL(ALTER TABLE)
- 从库的写入压力大(慢于主库)
- 网络问题
- 主库大批量写入时,从库来不及追
主从延迟的几种常见坑
场景一:写后立即读
用户注册成功后,马上查询自己的用户信息。
写操作写主库,读操作查从库,如果延迟存在,从库里还没有这条数据,返回"用户不存在"。
场景二:写后发异步消息,异步任务读从库
订单支付成功 → 写主库订单状态为"已支付" → 发 MQ 消息 → MQ 消费者读从库查订单 → 延迟导致查到的还是"未支付"状态 → 消费逻辑出错。
场景三:两次读之间发生了写
第一次读:从库查到库存 = 1,判断有货。
其他请求:主库扣减库存 → 0。
第二次读(从库同步了):从库查到库存 = 0,但前面的判断已经通过了。
这和超卖问题有关联,但根因是主从延迟导致读到了旧数据。
解决方案
方案一:关键操作强制读主库
对于"写完必须立即读到最新值"的场景,强制走主库读取。
// 注解方式控制读哪个库
@ReadOnlyDataSource // 读从库(默认)
public List<Product> listProducts(ProductQuery query) { ... }
@MasterDataSource // 强制读主库
public OrderVO getOrderById(Long orderId) { ... }
// 基于 ThreadLocal 实现动态数据源切换
public class DataSourceContextHolder {
private static final ThreadLocal<DataSourceType> CONTEXT = new ThreadLocal<>();
public static void setMaster() {
CONTEXT.set(DataSourceType.MASTER);
}
public static void clearMaster() {
CONTEXT.remove();
}
public static boolean isMaster() {
return DataSourceType.MASTER.equals(CONTEXT.get());
}
}
// AOP 拦截注解,切换数据源
@Aspect
@Component
public class DataSourceAspect {
@Around("@annotation(MasterDataSource)")
public Object masterRead(ProceedingJoinPoint pjp) throws Throwable {
DataSourceContextHolder.setMaster();
try {
return pjp.proceed();
} finally {
DataSourceContextHolder.clearMaster();
}
}
}问题: 如果大量接口都强制读主库,读写分离的意义就丧失了,主库压力回升。
所以只在真正需要强一致的场景用,不要滥用。
方案二:写请求在短时间内绑定读主库
用户做了一次写操作后,在接下来的 X 秒内(比如 3 秒),这个用户的所有读操作都走主库。这段时间足够让主从同步完成。
// 写操作完成后,标记这个用户在3秒内读主库
public void placeOrder(OrderCreateRequest request) {
orderService.createOrder(request);
// 标记:接下来3秒内这个用户读主库
String key = "read_master:" + request.getUserId();
redisTemplate.opsForValue().set(key, "1", 3, TimeUnit.SECONDS);
}
// 读操作前检查
public boolean shouldReadMaster(String userId) {
return redisTemplate.hasKey("read_master:" + userId);
}这个方案的代价是:写操作后 3 秒内,这个用户的读请求走主库。3 秒后恢复从库读。是在性能和一致性之间的折中。
方案三:等待从库同步(半同步复制)
MySQL 的半同步复制(semi-sync replication):主库写完 binlog 后,等至少一个从库确认收到(relay log 写完),才返回给客户端成功。
这比异步复制延迟稍高,但可以保证至少有一个从库同步到了数据。
# 主库开启半同步
SET GLOBAL rpl_semi_sync_master_enabled = 1;
SET GLOBAL rpl_semi_sync_master_timeout = 1000; # 等待超时1秒
# 从库开启半同步
SET GLOBAL rpl_semi_sync_slave_enabled = 1;注意:半同步只保证 binlog 到达从库,不保证从库已经执行完(从库的 SQL Thread 回放还需要时间)。所以仍然可能有延迟。
方案四:接受最终一致,在 UI 上处理
有时候最好的解决方案不是技术方案,而是 UX 方案。
写操作成功后,前端直接用本地状态更新 UI,不依赖后端查询刷新。
比如下单成功后,前端直接展示"下单成功"页面,不需要立刻查一次后端确认。等用户主动刷新"我的订单"时,延迟已经消除了。
踩坑记录
踩坑一:报表统计走从库,但有延迟,报表数据不准
我们有个实时报表,每分钟刷新一次,显示当前订单数量和销售额。
从库延迟导致报表数据总是比实际值少 1-2 分钟的数据量。
对报表来说,1-2 分钟的延迟在业务上是可接受的,但需要告知用户:"数据可能存在约1分钟的延迟"。这是最务实的解法——不改技术,改预期。
踩坑二:主从切换时,从库瞬间变主库,但有数据差
主库宕机,从库提升为主库。但从库可能比主库少了最后几条还没同步的数据。
这个数据丢失是很难完全避免的(除非用全同步复制,但性能不可接受)。
关键: 这个丢失要能被检测到,并有告警。换新主库上线后,通过对账发现丢失的数据范围,人工或自动补偿。
踩坑三:慢查询从库,延迟雪球
有段时间从库延迟突然从几十毫秒变成了几十秒。排查发现:某个业务在从库上跑了一个大型全表扫描的 SQL,占满了从库的 CPU,导致 SQL Thread 执行速度变慢,延迟越来越大(滚雪球效应)。
修复: 在从库上限制非 SELECT 类型的操作(通过 super_read_only),防止误操作在从库写入;同时,对慢查询从库的 SQL 设置超时,防止一条慢 SQL 拖慢整个从库。
最后说一点
读写分离是一个性能优化手段,不是银弹。引入它的同时,必须意识到它带来了主从延迟这个副作用,并且为关键业务场景制定相应的处理策略。
"所有读都走从库"是错误的默认策略,正确的策略是"默认走从库,关键路径强制走主库"。
