读写分离模式:主从复制、读写路由与数据一致性

读写分离,说白了就是把数据库的读操作和写操作分开。写操作交给主库,读操作交给从库。这样做的好处很明显——减轻主库压力,提升系统吞吐量。

我记得刚入行那会儿,带我的老工程师跟我说过一句话:「读写分离是数据库扩展的第一道门槛」。当时不太理解,直到自己亲手搭了一套MySQL主从架构,才真正体会到这句话的分量。

为什么需要读写分离?

你想想看,一个典型的业务系统,读请求往往比写请求多得多。我见过不少项目,读写的比例能达到10:1甚至更高。如果所有请求都压在同一台数据库上,CPU和IO很快就扛不住了。

读写分离的核心思路就是:把读流量分散到多个从库上。主库专心处理写操作,从库负责响应查询。这样主库的压力就降下来了,系统的整体吞吐量也能上去。

适用场景:

  • 读多写少的业务(新闻、电商商品页、社交动态)
  • 对数据实时性要求不高的查询
  • 需要水平扩展读能力的系统

主从复制:读写分离的基础

读写分离的前提是主从复制。没有复制,从库的数据就是空的,读个寂寞。

MySQL的主从复制流程大致是这样的:

  1. 主库提交事务后,把变更写入binlog
  2. 从库的IO线程拉取binlog,写入relay log
  3. 从库的SQL线程回放relay log,应用到本地数据

嗯,这里有个坑。我在项目中遇到过主从延迟的问题——从库复制跟不上主库的写入速度。高峰期的时候,延迟能到好几秒。这时候如果读从库,用户看到的就是旧数据。

避坑指南:我曾经在一个电商项目中,用户下单后立刻查看订单状态,结果从库还没同步到这条记录,页面显示「订单不存在」。用户差点投诉我们。后来我们加了一个策略:刚写入的数据,30秒内强制读主库。

读写路由:怎么决定走主库还是从库?

读写路由,就是代码层面决定SQL发往哪个数据库。常见的实现方式有几种:

  • 代码硬编码:在DAO层手动指定数据源。简单粗暴,但维护成本高。
  • AOP切面:通过注解或方法名规则,自动切换数据源。我比较推荐这种方式。
  • 中间件代理:比如MyCat、ShardingSphere、ProxySQL。对应用透明,但引入额外组件。

我个人习惯用AOP的方式。给读方法加个@ReadOnly注解,切面里自动切换到从库数据源。写方法默认走主库。这样代码侵入性小,也容易控制。

// 一个简单的读写路由注解实现
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ReadOnly {
    // 标记为只读查询
}

// AOP切面
@Aspect
@Component
public class ReadWriteAspect {
    
    @Around("@annotation(readOnly)")
    public Object route(ProceedingJoinPoint pjp, ReadOnly readOnly) throws Throwable {
        try {
            // 切换到从库数据源
            DataSourceContextHolder.set(DataSourceType.SLAVE);
            return pjp.proceed();
        } finally {
            // 清理,避免线程污染
            DataSourceContextHolder.clear();
        }
    }
}

小技巧:使用ThreadLocal保存当前线程的数据源选择。记得在finally块里清理,不然线程池复用时会出问题。我吃过这个亏。

数据一致性:读写分离最大的难题

读写分离带来的最大挑战就是数据一致性。主从复制有延迟,从库的数据可能落后于主库。怎么处理?

常见的策略有:

策略 说明 适用场景
强制读主 关键数据直接读主库 支付、订单等强一致性场景
延迟容忍 接受短暂的不一致 新闻列表、评论等弱一致性场景
缓存标记 写入后标记时间戳,读时判断 用户个人数据
半同步复制 至少一个从库确认后才返回 对一致性要求较高的业务

我个人的经验是:不要试图在所有场景下都保证强一致性。成本太高,收益有限。把核心链路(比如支付、库存)走主库,非核心链路(比如浏览记录、推荐内容)走从库,这样性价比最高。

MySQL读写分离架构实战

下面这张图是我常用的读写分离架构,大家可以参考一下:

应用服务 读写路由层(AOP / 中间件) 主库(Master) 写操作 从库1(Slave) 读操作 从库2(Slave) 读操作 binlog 复制

这个架构里,应用服务通过路由层判断SQL类型。写操作走主库,读操作走从库。主库通过binlog把数据同步到从库。从库可以横向扩展,加机器就能提升读能力。

代码示例:Spring Boot + 读写分离

下面是一个完整的配置示例,基于Spring Boot和Druid数据源:

// 数据源配置
@Configuration
public class DataSourceConfig {
    
    @Bean
    @ConfigurationProperties("spring.datasource.master")
    public DataSource masterDataSource() {
        return DruidDataSourceBuilder.create().build();
    }
    
    @Bean
    @ConfigurationProperties("spring.datasource.slave")
    public DataSource slaveDataSource() {
        return DruidDataSourceBuilder.create().build();
    }
    
    @Bean
    public DataSource routingDataSource() {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DataSourceType.MASTER, masterDataSource());
        targetDataSources.put(DataSourceType.SLAVE, slaveDataSource());
        
        RoutingDataSource routing = new RoutingDataSource();
        routing.setDefaultTargetDataSource(masterDataSource());
        routing.setTargetDataSources(targetDataSources);
        return routing;
    }
}

// 动态数据源
public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContextHolder.get();
    }
}

// 使用示例
@Service
public class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    @ReadOnly  // 走从库
    public List<Order> listOrders(Long userId) {
        return orderMapper.selectByUserId(userId);
    }
    
    // 没有@ReadOnly,默认走主库
    public void createOrder(Order order) {
        orderMapper.insert(order);
    }
}

核心要点总结:

  • 读写分离的本质是流量拆分,不是数据拆分
  • 主从复制是基础,但延迟问题必须处理
  • 路由策略要灵活,关键数据强制读主
  • 不要追求100%强一致性,成本太高
  • 从库可以水平扩展,但注意复制压力

最后说一句,读写分离不是银弹。如果业务写压力本身就很大,或者对一致性要求极高,那可能需要考虑分库分表或者分布式数据库方案。但大多数场景下,读写分离是性价比最高的扩展手段。

个人建议:刚开始做读写分离时,先从一主一从开始。跑稳定了再加从库。别一上来就搞一主多从,复制延迟和运维复杂度会把你搞疯的。我见过太多项目死在过度设计上。


公众号:蓝海资料掘金营,微信deep3321