读写分离架构下,主从延迟是绕不开的硬伤。当你刚在主库完成一笔支付订单写入,应用层立刻跳转查询订单状态,结果读请求落到了从库,从库还没同步过来,返回订单不存在——这就是典型的读取脏数据。问题本质在于主从复制存在时间窗口,在这个窗口期内从库数据是过期的,而应用层如果对实时性有要求却无差别地读从库,必然踩坑。

强制读主库,最简单暴力的解法

对于写完必须立刻读到最新数据的场景,最直接的方案就是让这类读请求直接命中主库。在代码层面做路由标记,比如利用线程变量透传一个“写后即读”的标识,ORM层识别到这个标识后自动将读操作路由到主数据源。以Spring Boot + MyBatis为例,可以自定义一个注解配合AOP实现:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ForceMaster {
}

@Aspect
@Component
public class DataSourceAspect {
    @Around("@annotation(forceMaster)")
    public Object around(ProceedingJoinPoint point, ForceMaster forceMaster) throws Throwable {
        DynamicDataSourceContextHolder.setDataSourceKey("master");
        try {
            return point.proceed();
        } finally {
            DynamicDataSourceContextHolder.clearDataSourceKey();
        }
    }
}

在Service层方法上加@ForceMaster注解,该方法内的所有数据库操作都会走主库。这个方案实现成本极低,但要注意粒度控制,如果大范围使用会削弱读写分离的减压效果。关键原则是只对核心写后读场景使用,比如订单支付后立即查询、用户注册后立即登录等,普通列表查询、历史记录查询完全没必要走主库。

半同步复制,从源头缩小延迟窗口

MySQL默认的异步复制模式下,主库提交事务后不等待从库确认就直接返回客户端,从库IO线程异步拉取binlog,延迟在秒级甚至更高是常态。半同步复制则要求至少一个从库将binlog写入relay log并刷盘后,主库才向客户端返回提交成功。开启半同步需要主从两端都安装插件:

-- 主库
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;

-- 从库
INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';
SET GLOBAL rpl_semi_sync_slave_enabled = 1;
STOP SLAVE IO_THREAD; START SLAVE IO_THREAD;

rpl_semi_sync_master_timeout参数很关键,它定义了主库等待从库ACK的超时时间,单位毫秒。超过这个时间从库还没确认,主库会自动降级为异步复制,避免主库写入被从库拖死。半同步能保证提交成功的事务至少在一台从库上已经落盘,读从库时读到脏数据的概率大幅降低,但不能完全消除——因为从库SQL线程回放relay log还需要时间,而且如果超时降级发生,又退回到异步模式。

GTID与同步位点校验,读前先确认

更精细的做法是在读从库之前,先判断从库是否已经同步到主库刚写入的那个位点。开启GTID模式后,每个事务都有全局唯一的标识符。主库写入成功后,应用层可以拿到本次事务的GTID,然后在读从库前执行WAIT_FOR_EXECUTED_GTID_SET函数,让从库等待直到该GTID被执行完毕或超时:

-- 主库写入后获取GTID
SELECT @@global.gtid_executed;

-- 从库等待同步
SELECT WAIT_FOR_EXECUTED_GTID_SET('3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5', 2);

返回值0表示等待成功,1表示超时,此时应用层可以选择切到主库重试或者抛出异常让用户稍后刷新。这个方案的精妙之处在于它把“是否可读从库”的判断逻辑从基础设施层提到了应用层,由业务代码根据实际需求决定容忍多长的等待时间。不过要注意,每次读从库前都做GTID校验会增加一次网络往返,对性能敏感的场景需要权衡。

缓存旁路,用Redis扛住读压力

如果主库压力大,强制读主库会雪上加霜,这时候可以引入缓存层作为缓冲。写入流程变为:先写主库,同时将最新数据写入Redis,读请求优先读Redis,命中直接返回,未命中再读从库并回填缓存。关键在于缓存写入的时机和过期时间设置。写入缓存必须在主库事务提交之后进行,否则可能出现缓存有数据但主库回滚的情况。过期时间建议设置为略大于主从延迟的P99值,比如监控显示延迟P99为3秒,缓存TTL设5秒,这样既能保证缓存内的数据大概率是最新的,又不会让脏数据在缓存中停留太久。

更激进的方案是采用“双写+延迟双删”策略:先删除缓存,再写主库,等待主从复制完成后再次删除缓存。第二次删除的延迟时间需要根据实际主从延迟动态调整。这种方案实现复杂,一般只在缓存穿透风险高且主从延迟较大的场景下使用。

Proxy层智能路由,ShardingSphere实战

如果不想在每个业务方法里硬编码路由逻辑,可以在数据库中间件层统一处理。ShardingSphere提供了Hint强制路由和主从延迟感知的能力。通过HintManager可以强制指定某条SQL走主库:

try (HintManager hintManager = HintManager.getInstance()) {
    hintManager.setPrimaryRouteOnly();
    // 执行SQL
    orderMapper.selectById(orderId);
}

ShardingSphere 5.x版本还支持从库延迟检测,配置read-write-splitting规则时设置load-balancer为轮询或随机,同时开启从库状态检查,当从库延迟超过阈值时自动将其标记为不可用,读请求会路由到其他从库或主库。这种方案的好处是业务代码无感知,改造成本集中在配置层。

应用层自旋等待,简单场景的轻量方案

对于并发不高、延迟通常很小的系统,可以在读从库未获取到预期数据时,进行短暂的自旋重试。逻辑是:先读从库,如果结果不符合预期(比如订单状态未更新),sleep一小段时间后重试,重试2-3次仍不成功则抛异常或切主库。sleep时间建议采用递增策略,比如第一次50ms,第二次100ms,第三次200ms。这个方案不依赖任何中间件改造,几行代码就能实现,但只适合延迟通常在百毫秒级别的场景,延迟秒级的话自旋会严重阻塞请求线程。

读写分离的边界,什么时候不该用

不是所有业务都适合读写分离。强一致性的金融交易、库存扣减、账户余额查询等场景,读从库本身就是设计上的妥协。对于这类业务,应该直接读写主库,通过分库分表来横向扩展主库的承载能力,而不是寄希望于读写分离。另外,如果业务中写后读的比例超过30%,读写分离带来的收益会被强制读主库的流量抵消,这时候需要重新评估架构选型。一个判断标准是:如果你的系统P99主从延迟是1秒,而业务要求99%的请求在200ms内看到最新数据,那么读写分离基本不可行,必须走主库读写或引入缓存。

监控先行,数据驱动优化

无论采用哪种方案,主从延迟的监控都是基础。在从库上部署监控脚本,定期计算Seconds_Behind_Master,接入Prometheus + Grafana做可视化。同时监控半同步复制的降级次数、GTID等待超时率、强制读主库的比例等指标。当延迟频繁超过阈值时,排查方向包括:从库硬件性能是否跟得上、是否有大事务阻塞SQL线程回放、网络带宽是否打满、是否可以通过并行复制提升回放速度。MySQL 5.7以上的基于逻辑时钟的并行复制(slave_parallel_type=LOGICAL_CLOCK)能显著降低SQL线程回放延迟,在延迟问题严重的场景下值得开启。

最终,解决主从延迟脏读没有银弹,是多种策略的组合运用。核心思路是根据业务对实时性的要求做分级:强实时走主库或缓存,准实时走GTID校验或自旋等待,弱实时直接读从库。把有限的开发资源投入到真正需要强一致性的核心链路,才是工程上的最优解。