读写分离模式:主从复制、读写路由与数据一致性
读写分离,说白了就是把数据库的读操作和写操作分开。写操作交给主库,读操作交给从库。这样做的好处很明显——减轻主库压力,提升系统吞吐量。
我记得刚入行那会儿,带我的老工程师跟我说过一句话:「读写分离是数据库扩展的第一道门槛」。当时不太理解,直到自己亲手搭了一套MySQL主从架构,才真正体会到这句话的分量。
为什么需要读写分离?
你想想看,一个典型的业务系统,读请求往往比写请求多得多。我见过不少项目,读写的比例能达到10:1甚至更高。如果所有请求都压在同一台数据库上,CPU和IO很快就扛不住了。
读写分离的核心思路就是:把读流量分散到多个从库上。主库专心处理写操作,从库负责响应查询。这样主库的压力就降下来了,系统的整体吞吐量也能上去。
适用场景:
- 读多写少的业务(新闻、电商商品页、社交动态)
- 对数据实时性要求不高的查询
- 需要水平扩展读能力的系统
主从复制:读写分离的基础
读写分离的前提是主从复制。没有复制,从库的数据就是空的,读个寂寞。
MySQL的主从复制流程大致是这样的:
- 主库提交事务后,把变更写入binlog
- 从库的IO线程拉取binlog,写入relay log
- 从库的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读写分离架构实战
下面这张图是我常用的读写分离架构,大家可以参考一下:
这个架构里,应用服务通过路由层判断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