读写分离这四个字,凡是做过后端架构或数据库运维的人都不陌生。绝大多数业务系统本质是读多写少,用户查询、列表刷新、报表统计这类操作占掉数据库请求的大头,主库既要写数据又要扛读压力,CPU和磁盘IO很容易先到瓶颈。我最近在一套Spring Boot项目里配了金仓数据库的读写分离,正好把这类方案从选型、配置到踩坑的完整路径重新梳理了一遍。这里不搞教科书式罗列,只讲团队落地时真正会遇到的取舍和操作细节。
先提醒一句:读写分离不是单纯加一台从库、把查询分出去就完事。它牵扯主从复制、路由策略、事务边界、延迟补偿、高可用切换,任何一环没处理好,都会出现“数据对不上”“刚写完刷新没变化”“一个慢查询拖垮从库”这类问题。下面分四块来讲:方案怎么选、应用层怎么配、中间件怎么玩、以及最常见的坑怎么排。
1. 读写分离的整体思路与方案选型
1.1 读写分离到底解决什么问题
在没上读写分离之前,业务数据通常集中在一台主数据库上。开发早期数据量小、并发低,单库完全够用。一旦用户量上来,首页查询、移动端列表、管理后台统计都打到同一台库上,问题就出现了:
- 主库的CPU因为大量耗时的SELECT查询持续飙高,写事务的响应时间被拖长。
- 在高并发下,连接池被慢查询占满,新的写请求排队超时。
- 部分报表类查询要扫描大数据量,和线上OLTP操作抢磁盘吞吐。
读写分离的核心思路是把“写”(INSERT/UPDATE/DELETE)定向到主库,把“读”(SELECT)分发到一台或多台只读从库。主库通过复制机制把数据变更同步到从库,从而保证从库上的数据在延迟可接受的时间窗口内接近主库。
但这个方案要成立,有几个前提条件。第一,业务确实读远大于写,否则分离以后主库闲置、从库负载低,反而增加运维复杂度。第二,能接受秒级或毫秒级的数据延迟,只要不是实时强一致场景都可以。第三,团队有能力和精力去维护主从链路,包括监控复制状态、处理延迟告警、切换故障节点。如果这三条都不满足,那么先优化SQL、上缓存可能比直接上读写分离更划算。
1.2 现在主流的三类实现形态
“大家一般采用怎样的读写分离方案”,其实绕不开三种形态,各有各的适用环境。先看一张总览表:
| 方案形态 | 实现位置 | 典型代表 | 对应用侵入性 | 适用场景 |
|---|---|---|---|---|
| 应用层动态数据源 | 业务代码内部 | Spring开发的数据源路由、MyBatis插件 | 中,需要代码接入 | 中小团队、单一业务、从库数量不多 |
| 数据库中间件代理 | 应用与数据库之间 | ShardingSphere、MyCat | 低,应用无感知 | 多服务共享数据库、需要分库分表、复杂路由 |
| 云数据库/专有方案 | 数据库平台侧 | 云RDS读写分离地址、数据库厂商方案 | 低 | 已上云、希望减少自建运维成本 |
第一种形态最常见,也是我本篇要展开讲的。它的原理很简单:服务启动时同时创建主库和从库的数据源,对外暴露一个动态数据源。每次数据库操作前,根据方法标识或者请求上下文决定要使用哪个数据源。Spring框架的AbstractRoutingDataSource相当于一个路由器,它内部维护一个目标数据源Map,实际执行时通过determineCurrentLookupKey()返回当前应该走的数据库Key。
第二种形态就是把路由和连接管理从应用里抽出来,放到一个独立的中间件上。应用的内容操作仍然只连一个逻辑数据源,中间件负责解析SQL、识别读写类型、把连接分发到背后的具体物理数据库。这种方式的好处是应用代码可以保持“无感知”,坏处是多了一层网络跳转,排查链路变长,而且中间件自身的资源消耗也要算清楚。
第三种形态是商业产品兜底,对于没有专职DBA的小团队、或者不想关心主从细节的场景很有价值。云数据库通常直接提供一个读写分离地址,底层帮你搞定主从复制和健康检查,应用侧配置一个JDBC URL就行。代价是绑定厂商生态,跨云迁移成本高。
选型的时候我会先回答三个问题:从库有几台?SQL都需要走同一个数据源吗?团队有没有能力维护中间件?从库只有一台且团队小,优先应用层方案;从库多、多个下游系统都要复用同一套读写分离能力,优先中间件代理。
1.3 复制是读写分离的底层前提
无论哪种形态,读写分离都建立在主从复制可用之上。MySQL用binlog做逻辑复制,PostgreSQL系用WAL日志做物理流复制。我这边重点说金仓数据库,因为它自身兼容PostgreSQL生态,所以物理流复制的原理和配置思路和PostgreSQL高度一致。
主库把变更写入WAL日志,从库通过持续的WAL接收和回放进程,把日志中的变更应用到本地数据文件。理论上每一条写事务在提交之后,经过网络传输和从库重放才算完成同步。网络越快、从库配置越强、回放进程没有瓶颈,延迟就越低。如果从库磁盘IO慢或者有大事务回放,延迟就会明显上升。
这里要特别强调一个认知:读写分离的目标不是让从库和主库完全实时,而是保证延迟处在一个业务可接受的范围。正因为有延迟,才会出现后文要讲的“先写后读一致性”问题。理解了复制链路,也就理解了为什么要给强制走主库留一个后门。
2. Spring Boot + 金仓数据库读写分离配置实操
2.1 金仓数据库主从复制环境准备
金仓数据库(KingbaseES)在国产化环境里用得越来越多,它的JDBC驱动类名是com.kingbase8.Driver,连接串格式是jdbc:kingbase8://host:port/database,这些和PostgreSQL的接入习惯很像。
主从搭建的前提是先确认主库开启了归档和复制所需的配置。以金仓为例,需要在kingbase.conf里打开wal_level,保证主库生成了可供从库回放的WAL信息。然后配置从库的连接信息,让从库知道去哪个主库拉取日志。实际操作中,我习惯先确认两边的版本一致,再通过金仓自带的备份恢复工具把主库基线数据同步到从库,之后从库以standby模式启动,开始持续接收并回放WAL。
需要注意一个细节:主库初始化时不要用太弱的磁盘,WAL写入和业务写入是同一份IO路径,日志盘性能和主库TPS直接挂钩。从库的硬件规格也不能明显低于主库,否则一旦主库写入量上来,从库的回放速度会跟不上,延迟会越积越大。
因为金仓在不同版本上的具体命令细节有差异,我的建议是配置过程以官方文档为准,不要只靠“PostgreSQL可以所以金仓也行”的惯性操作。我踩过一次坑,就是照搬了PG的复制配置,结果金仓的某个参数默认关闭,从库起不来。先看对应版本的配置手册,再动命令行。
2.2 多数据源基础配置
应用层读写分离的第一步是在Spring Boot工程里创建两个真实的数据源。表面上看只是写两个@Bean,但实际上要解决“让业务代码在同一个事务边界内自然切换数据源”的难题,需要自己包一层动态数据源。
先看依赖层面,工程里要引入金仓数据库驱动:
<dependency> <groupId>cn.com.kingbase</groupId> <artifactId>kingbase8</artifactId> <version>8.6.0</version> </dependency>然后在配置文件里定义主从库连接参数:
spring: datasource: write: jdbc-url: jdbc:kingbase8://192.168.1.10:54321/business username: app_write password: xxxx driver-class-name: com.kingbase8.Driver read: jdbc-url: jdbc:kingbase8://192.168.1.11:54321/business username: app_read password: xxxx driver-class-name: com.kingbase8.Driver这里要解释一个坑:配置数据源的时候不要用Spring Boot默认的spring.datasource.url配置项,而是分别放到自定义的write和read节点下,再手动创建两个DataSourceBean,否则框架会因为同时存在多个数据源配置而报启动错误。
接着创建动态数据源类:
public class DynamicDataSource extends AbstractRoutingDataSource { private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void setDataSource(String key) { CONTEXT.set(key); } public static void clearDataSource() { CONTEXT.remove(); } @Override protected Object determineCurrentLookupKey() { return CONTEXT.get(); } }AbstractRoutingDataSource的核心机制是,执行数据库操作时会调用determineCurrentLookupKey()拿到一个Key,再用这个Key从内部的targetDataSources里取出对应的真实数据源。所以我们需要在每次方法调用前把当前应该走的库标记到ThreadLocal,方法结束后清理掉,避免线程池复用导致路由串线。
动态数据源Bean可以这样配置:
@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties("spring.datasource.write") public DataSource writeDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("spring.datasource.read") public DataSource readDataSource() { return DataSourceBuilder.create().build(); } @Bean public DynamicDataSource dataSource() { DynamicDataSource ds = new DynamicDataSource(); Map<Object, Object> target = new HashMap<>(); target.put("write", writeDataSource()); target.put("read", readDataSource()); ds.setTargetDataSources(target); ds.setDefaultTargetDataSource(writeDataSource()); return ds; } }setDefaultTargetDataSource的作用很关键,默认走主库。这也是我推荐的做法,因为写操作是主路径,万一路由判断漏掉某个方法,最多是从库读到了稍旧的数据,不会把写操作错误地发到从库导致数据丢失。
2.3 注解加AOP实现自动路由
有了动态数据源,还需要一个简单易用的路由规则。我推荐的做法是定义@ReadOnly注解,标注在方法上表示这个方法只需要走从库。没有标注的方法,全部默认走主库。
定义注解:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface ReadOnly { }定义AOP切面,在方法执行前把数据源Key设为read,执行后清空:
@Aspect @Component public class DataSourceAspect { @Around("@annotation(readOnly)") public Object switchDataSource(ProceedingJoinPoint joinPoint, ReadOnly readOnly) throws Throwable { DynamicDataSource.setDataSource("read"); try { return joinPoint.proceed(); } finally { DynamicDataSource.clearDataSource(); } } }配置完后,业务方法就可以这样使用:
@ReadOnly public List<OrderVO> queryOrderList(String userId) { return orderMapper.selectByUser(userId); }这个方法框架会自动让SQL走从库。
但这里有几个非常实际的注意点。第一,同一个事务里如果既有写又有读,不要在方法上直接加@ReadOnly。Spring的@Transactional事务是在连接上开启的,数据源一旦在事务启动时就绑定,方法中间再切数据源是无效的。我见过最多的问题就是有人把主库写操作和从库读操作放在一个事务方法里,结果从库那条查询也走主库了,这倒不会出错,但读写分离就失去了意义。第二,AOP切面不能只认方法上的注解,如果一个Service类上标了@ReadOnly,但是内部某个方法需要强制走主库,需要额外的优先级控制。我的处理思路是让AOP识别“方法上的注解优先于类上的注解”,或者干脆加一个@ForceMaster注解,路由优先级更高。
2.4 强制走主库的后门
读写分离上线以后,最怕的就是业务里出现“写后读”场景。典型例子是支付成功后立刻查询订单状态给用户展示,如果查询落到了从库,主从同步还没来得及把最新状态同步过去,用户就会看到“订单还是待支付”,造成体验问题。
应对办法是提供强制走主库的标记位:
public class DynamicDataSource { private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); private static final ThreadLocal<Boolean> FORCE_MASTER = new ThreadLocal<>(); public static void forceMaster() { FORCE_MASTER.set(Boolean.TRUE); } public static void clearForceMaster() { FORCE_MASTER.remove(); } protected Object determineCurrentLookupKey() { boolean force = Boolean.TRUE.equals(FORCE_MASTER.get()); return force ? "write" : CONTEXT.get(); } }然后在写操作完成后、紧接着的查询前调用forceMaster(),让这条链路明确走主库。这个后门在业务里非常重要,尤其是下单、支付确认、修改密码、更新资料后的即时回显,都应该优先保一致。正常情况下从库承担大流量,特殊操作才打到主库,能把主库压力控制在可接受范围。
3. 中间件代理方案:ShardingSphere与MyCat
3.1 ShardingSphere的读写分离规则
如果不想在业务代码里散落路由注解,或者多个应用需要共享同一套数据库基础设施,可以考虑中间件方案。ShardingSphere是目前使用率比较高的开源产品,它有JDBC和Proxy两种使用形态。
ShardingSphere-JDBC其实是一个增强版客户端,它以jar包的形式集成进应用,通过配置规则把逻辑数据源解析成物理主从数据源。它和应用层动态数据源的区别在于:路由逻辑完全由框架内置,不需要自己写ThreadLocal和AOP。
一个简单的配置示例如下:
dataSources: write_ds: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.kingbase8.Driver jdbcUrl: jdbc:kingbase8://192.168.1.10:54321/business username: app_write password: xxxx read_ds: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.kingbase8.Driver jdbcUrl: jdbc:kingbase8://192.168.1.11:54321/business username: app_read password: xxxx rules: - !READWRITE_SPLITTING dataSources: user_db: writeDataSourceName: write_ds readDataSourceNames: - read_ds loadBalancerName: round_robin loadBalancers: round_robin: type: ROUND_ROBINShardingSphere里的!READWRITE_SPLITTING规则会分析SQL语句类型,SELECT默认分发到从库,INSERT、UPDATE、DELETE分发到主库。它的负载均衡策略支持轮询、随机和权重,可以应对有多台从库的场景。
使用ShardingSphere-JDBC有一个好处是事务管理器不需要改动,@Transactional仍然有效,框架会保证事务内的SQL都走同一个主数据源。我实测下来,对于中小型项目,ShardingSphere-JDBC的侵入性其实很低,只需要替换DataSource注入,业务代码的Mapper和Service几乎不用动。
ShardingSphere-Proxy则是一个独立部署的代理服务,应用把代理当作一个普通数据库连上去。应用不会感知底层有几个库,代理做完SQL解析和路由再把结果返回。缺点是查问题时需要经过一层代理,而且Proxy本身的连接管理能力会决定整个系统的并发上限,需要单独评估。
3.2 MyCat的读写分离配置要点
MyCat也是大家经常提到的中间件,它和ShardingSphere是同一类东西,都是“代理模式+配置文件驱动”。MyCat做读写分离主要改schema.xml和server.xml。
schema.xml里可以这样配置:
<dataHost name="localhost1" maxCon="1000" minCon="10" balance="1" writeType="0" dbType="kingbase" dbDriver="jdbc"> <writeHost host="hostM1" url="jdbc:kingbase8://192.168.1.10:54321/business" user="app_write" password="xxx"> <readHost host="hostS1" url="jdbc:kingbase8://192.168.1.11:54321/business" user="app_read" password="xxx" weight="1" /> </writeHost> </dataHost>balance参数是MyCat读写分离的核心:设置为1时,读请求会随机分发到从库;设置为3时,读请求在主库和从库之间都做负载均衡。writeType="0"表示所有写操作都发给配置的第一个writeHost,这也是最常见的配置。
MyCat的好处是规则变更集中在XML里,多个应用连同一个MyCat就能统一控制。但它的版本迭代节奏和生态活跃度相对保守,新项目里我见得更多的是ShardingSphere。如果你们团队对中间件比较熟悉、运维能力强,选MyCat或者ShardingSphere都行;如果只是想快速解决读压力,用应用层方案会更轻。
3.3 两种方案的取舍对比
| 对比项 | 应用层动态数据源 | 中间件代理 |
|---|---|---|
| 代码侵入性 | 需要引入注解和AOP | 低,应用基本无感 |
| 路由粒度 | 方法级、请求级 | SQL级,由中间件自动判断 |
| 事务支持 | 需要自己处理事务内路由 | 中间件保证事务内SQL统一走主库 |
| 从库增多时扩展 | 每次要改代码配置 | 改中间件或配置文件即可 |
| 复杂SQL兼容性 | 取决于应用本身 | 解析器需要兼容各种SQL,复杂SQL可能踩坑 |
| 排查问题复杂度 | 低,日志直连数据库 | 高,多一跳 |
| DBA运维要求 | 中 | 高 |
从我个人的项目经验看,如果公司有超过三个以上应用系统都要访问同一套主从库,我会优先推荐中间件。如果只有一个核心服务,或者团队还在快速迭代阶段,应用层方案改动更小、更好控制。
4. 主从延迟、事务一致性与故障排查
4.1 主从延迟的根源与影响面
读写分离带来的最典型问题就是主从延迟。刚在主库写入的数据,过了一小会还没出现在从库,导致读请求拿到的是旧数据。延迟的根源一般有三个:
- 从库硬件性能弱,磁盘IO跟不上WAL回放速度。
- 主库执行了大事务,比如一次性更新几十万条记录,这类操作的WAL日志量巨大,从库回放需要很长时间。
- 从库自身背负了很多慢查询,这些查询占用IO资源,拖慢了WAL回放线程。
延迟的容忍度完全取决于业务。像用户查看历史订单、商品评价列表这种场景,延迟几百毫秒用户感知不到;但支付结果、库存扣减、消息已读状态这类场景,延迟一两秒就不可接受。
针对于此,我建议在监控上做两件事。一是定期抓取从库的复制延迟状态,金仓环境下通过sys_stat_replication视图可以看到每个standby节点的WAL接收位置,对比主库当前WAL写入位置就能算出延迟。二是设置告警,当延迟超过阈值(比如5秒)就通知DBA,避免问题在线上被用户先发现。
4.2 事务边界与路由规则的正确姿势
前面提到了事务和数据源绑定的问题,这里再展开说清楚。Spring事务管理器的默认行为是:一个事务方法开始后,它会从DataSource中获取一个连接并绑定到当前线程。即使你再调用DynamicDataSource.setDataSource("read"),这个事务仍然使用已经绑定的主库连接。
所以读写分离的AOP切面和事务切面,执行顺序很关键。标准做法是:对于@Transactional修饰的方法,不管方法里是不是只有查询,都应该强制让连接走主库,因为事务内一旦出现写操作,走从库就会直接报错或者产生不可预知的数据不一致。我常用的策略是:
- 只有查询、且不开启事务的方法,才用
@ReadOnly走从库。 - 所有事务方法,一律不标
@ReadOnly,保持默认主库。 - 特殊情况需要事务内做只读查询并且想走从库,就不要依赖Spring事务,而是改用编程式事务手动控制,或者单独查询。
如果确实希望事务内的一些查询走从库,需要把读操作和写操作拆到不同的事务方法,或者使用Propagation.NOT_SUPPORTED挂起外层事务再执行查询。这个方案比较绕,非必要不推荐。
4.3 常见问题速查表
| 现象 | 可能原因 | 排查与处理 |
|---|---|---|
| 刚写完的数据查询不到 | 查询走了从库且主从延迟 | 对写后读场景加forceMaster(),确认延迟监控 |
| 偶发出现死锁或锁等待 | 一个事务内读写混用 | 检查事务方法是否被切面误判路由,事务内统一走主库 |
| 从库数据缺失但复制状态正常 | 从库回放延迟或初始化基线不完整 | 对比WAL位点,重建从库 |
| Spring启动时报数据源循环依赖 | 两个数据源Bean互相引用 | 使用@Primary和@Qualifier明确注入顺序 |
| 金仓驱动报找不到数据源 | 连接串或驱动类没有匹配 | 确认依赖坐标和driver-class-name,检查版本 |
| 中间件路由后复杂SQL报语法错误 | 解析器不兼容某些SQL | 简化SQL或改写为兼容写法,必要时强制走主库 |
这个表我按线上排查的顺序排了一下,先看现象,再定位原因,不要一上来就改代码。最麻烦的是那些偶发问题,比如数据库连接池里的连接被线程复用,如果路由标记没有及时清理,就会出现“本该走从库的请求偶尔走到了主库、或者走了从库却拿错数据源Key”。所以清理ThreadLocal的finally块一定要加,这个细节看着小,线上踩过的人都知道有多痛。
4.4 从库切换与高可用注意
读写分离不能只关心“平时怎么分”,还要考虑“主库挂了怎么办”。如果主库发生故障,所有写操作都会失败,这时需要尽快把某台从库提升为新的主库,然后修改路由配置。
如果是应用层方案,比较土但有效的做法是:动态数据源里内置一个健康状态开关,通过监控程序探测主库连通性,一旦不可用就把默认数据源Key临时切到备用从库。这种方案需要对写请求做保护,避免主库恢复前数据丢失。
如果是中间件方案,ShardingSphere和MyCat本身提供了组节点概念,主节点故障时可以通过配置文件或管理接口重新选举写节点。
但我要提醒一句:自动切换是把双刃剑。切换本身不难,难的是切换后主从数据是否一致、旧主库恢复后怎么重新挂回集群、应用连接池里残留的连接是否透明迁移到新主库。在没有完善的自动化运维工具条件下,我的经验是宁可让系统先“降级为只读”,让写操作快速失败,也不要盲目自动切换造成脑裂或数据错乱。
4.5 一点额外的建议
按我最近在Spring Boot加金仓数据库上做读写分离的体会,这套东西最怕的不是方案本身难,而是业务边界没有理清楚。启动前花半天列一下所有核心接口,哪些是强一致读、哪些能容忍延迟,哪些是必须事务的写链路,这个清单比分析代码更有效。列表做好之后,代码层面的改动反而很简单。
还有一个小技巧:从库连接池和主库连接池建议分开设置参数。从库连接数可以给得大一些,因为读请求量大;主库连接数不要盲目给高,过量连接会造成数据库层线程切换开销。金仓连接串里maxPoolSize按实际并发设置,从库甚至可以比主库大两到三倍。
最后说一句,这套配置上线后,一定要留出盯监控的时间。主从延迟、连接池水位、慢查询变化,这些指标至少观察一周。等曲线稳定了,再根据实际压力调整从库数量,或者把部分非核心查询迁到独立的报表库去。读写分离从来不是终点,它只是让数据库负载趋于合理的第一道闸门。