读写分离这四个字凡是做过后端架构或数据库运维的人都不陌生。绝大多数业务系统本质是读多写少用户查询、列表刷新、报表统计这类操作占掉数据库请求的大头主库既要写数据又要扛读压力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 groupIdcn.com.kingbase/groupId artifactIdkingbase8/artifactId version8.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 ThreadLocalString 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(); MapObject, 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 ListOrderVO queryOrderList(String userId) { return orderMapper.selectByUser(userId); }这个方法框架会自动让SQL走从库。但这里有几个非常实际的注意点。第一同一个事务里如果既有写又有读不要在方法上直接加ReadOnly。Spring的Transactional事务是在连接上开启的数据源一旦在事务启动时就绑定方法中间再切数据源是无效的。我见过最多的问题就是有人把主库写操作和从库读操作放在一个事务方法里结果从库那条查询也走主库了这倒不会出错但读写分离就失去了意义。第二AOP切面不能只认方法上的注解如果一个Service类上标了ReadOnly但是内部某个方法需要强制走主库需要额外的优先级控制。我的处理思路是让AOP识别“方法上的注解优先于类上的注解”或者干脆加一个ForceMaster注解路由优先级更高。2.4 强制走主库的后门读写分离上线以后最怕的就是业务里出现“写后读”场景。典型例子是支付成功后立刻查询订单状态给用户展示如果查询落到了从库主从同步还没来得及把最新状态同步过去用户就会看到“订单还是待支付”造成体验问题。应对办法是提供强制走主库的标记位public class DynamicDataSource { private static final ThreadLocalString CONTEXT new ThreadLocal(); private static final ThreadLocalBoolean 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与MyCat3.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 namelocalhost1 maxCon1000 minCon10 balance1 writeType0 dbTypekingbase dbDriverjdbc writeHost hosthostM1 urljdbc:kingbase8://192.168.1.10:54321/business userapp_write passwordxxx readHost hosthostS1 urljdbc:kingbase8://192.168.1.11:54321/business userapp_read passwordxxx weight1 / /writeHost /dataHostbalance参数是MyCat读写分离的核心设置为1时读请求会随机分发到从库设置为3时读请求在主库和从库之间都做负载均衡。writeType0表示所有写操作都发给配置的第一个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按实际并发设置从库甚至可以比主库大两到三倍。最后说一句这套配置上线后一定要留出盯监控的时间。主从延迟、连接池水位、慢查询变化这些指标至少观察一周。等曲线稳定了再根据实际压力调整从库数量或者把部分非核心查询迁到独立的报表库去。读写分离从来不是终点它只是让数据库负载趋于合理的第一道闸门。