读写分离这个词几乎每个做后端的同学都听过也都答得上来一句“主库写、从库读”。但真把它放到一次技术方案评审里要回答的远不止这一句话你的数据一致性要求有多高复制延迟能接受多少毫秒主从切换由谁负责应用层要不要感知拓扑变化这些问题答不上来读写分离方案大概率会在评审阶段被打回来。最近我正好在一个基于 Spring Boot 金仓数据库KingbaseES的项目里把读写分离从方案选型到落地完整走了一遍期间还翻车了几次。这篇就聊点实在的主流方案有哪些、各自的取舍是什么、以及配置和排障过程中真正值得注意的细节。如果你正准备做读写分离或者方案评审时被问住了这篇应该能帮上忙。1. 先分清需求你的系统真的需要读写分离吗1.1 读写分离解决的是“读压力”不是所有性能问题读写分离的本质是把数据库的读流量和写流量拆到不同的节点上主库Primary负责 INSERT/UPDATE/DELETE从库Replica/Standby负责 SELECT主从之间通过复制机制同步数据。以 MySQL 为例主库把变更写入 binlog从库的 IO 线程拉取 binlog 并写到自己的 relay log再由 SQL 线程回放以金仓这类兼容 PostgreSQL 生态的数据库为例走的是基于 WAL 的流复制原理上是同一件事。所以读写分离真正解决的是“读压力的横向扩展”它把一台数据库的读能力变成两台、三台。如果你当前的瓶颈是大量并发 SELECT 把数据库 CPU 打满、连接数被占光读写分离大概率有效。如果瓶颈是单表数据量过大、写入并发过高、或者某个慢查询本身就要跑几十秒那读写分离帮不了你——那是分库分表、索引优化和查询重写的事。1.2 判断读写分离是否适用的几个硬指标我的习惯是先量化再动手别凭感觉。重点看三个指标读写比例如果系统里读请求占比低于 60%读写分离带来的收益非常有限反而增加了主从复制延迟、数据源切换这些复杂度。比较健康的分拆场景是读占比在 80% 甚至 90% 以上。主库负载构成观察主库的 CPU、IOPS 到底是被 SELECT 消耗的还是被写入消耗的。如果慢查询日志里全是 SELECT优化 SQL 可能比上从库更划算。一致性容忍度从库数据一定比主库有时间差哪怕只有几十毫秒。业务上如果接受不了“刚提交就查不到”需要做强制主库读这会稀释读写分离的效果。我见过不少项目主库性能不行领导一句话“上读写分离”结果从库加了两台主库慢查询没解决反而因为主从延迟出了线上事故。先花两天把监控指标拉出来看比什么都重要。1.3 读写分离和分库分表别混为一谈这两个概念经常被放在一起讨论但解决的是完全不同的问题。读写分离扩展的是“读并发能力”数据还是全量的一份只是复制成多份分库分表扩展的是“数据容量和写入吞吐”把数据按维度拆到不同库表里。一个数据量已经上亿、写入也很高的系统光做读写分离没用因为从库也要承受同样的数据量和写入复制压力。反过来一个纯读压力大但数据量可控的系统也没必要一上来就分库分表。实际项目中一般会先做读写分离等数据量和写入吞吐继续增长再叠加分库分表。我个人的建议是不要把这两个方案在设计阶段绑死中间件的选型上尽量选择两者都支持的后面演进会从容很多。2. 业界常用的三种落地方式应用层、中间件、基础设施2.1 应用层方案最灵活也最考验工程约束力应用层方案的核心思路是在应用内管理多个数据源根据业务方法类型或注解动态决定走主库还是从库。Spring 生态里最典型的做法是继承AbstractRoutingDataSource覆盖determineCurrentLookupKey()方法配合ThreadLocal或事务上下文完成数据源切换。这种方案的优点很明显不引入额外组件部署架构最简单路由规则完全由自己控制可以精确到某个方法走主库、某个方法走从库而且不依赖中间件对 SQL 的解析能力对数据库类型几乎没有限制。缺点也同样明显路由逻辑散落在代码里团队多了之后容易失控跨数据源的事务非常难处理必须靠设计约束主从切换、健康检查都要自己做。适合团队规模不大、数据库类型异构、或者暂时不想引入额外中间件的项目。2.2 中间件方案透明接入集中治理中间件方案是很多团队的首选它把读写分离的复杂度从业务代码里剥离出来由独立的代理层统一处理。这里又分两种形态客户端形态如 ShardingSphere-JDBC以 jar 包形式集成进应用应用拿到的是一个虚拟数据源SQL 由 ShardingSphere 解析后自动路由到主库或从库。这种形态没有额外的网络跳转性能损耗很小集成成本低。代理形态如 ShardingSphere-Proxy、MyCat独立部署一个数据库代理服务应用连接的是代理而不是真实的数据库。应用完全无感知但多了一跳网络性能和排障复杂度都有所增加。中间件方案最大的价值在于“规则集中管理”读写分离、分库分表、数据脱敏都可以统一配置。尤其当你有多个应用共享同一个数据库时代理形态能避免每个应用各自维护一套路由逻辑。2.3 基础设施方案让数据库自己搞定基础设施方案是最彻底的做法直接依赖数据库原生的高可用和读写能力。典型代表包括 MySQL 的 Group Replication MySQL RouterPostgreSQL/金仓的流复制 连接池/负载均衡器或者云数据库的只读实例 读写分离地址。这种方案对应用最友好连接地址都不用改数据库层面自动把流量分给主备节点。但它要求你深度信任数据库厂商或云服务商的实现主从切换、延迟监控、节点状态维护基本黑盒化。适合使用云数据库、或者公司有专职 DBA 能把这套基础设施维护得很好的场景。2.4 三种方案怎么选一张对比表维度应用层方案客户端中间件代理中间件基础设施方案代表AbstractRoutingDataSourceShardingSphere-JDBCShardingSphere-Proxy、MyCatMySQL Router、云只读实例应用侵入性需要自己写路由逻辑引入 jar 包少量配置无侵入无侵入额外部署组件无无需要独立部署代理需要部署 Router 或依赖云厂商事务支持需要手动约束较好支持事务内主库路由较好依赖数据库能力路由粒度方法级、注解级SQL 级SQL 级连接级运维复杂度低低中中最适合场景小团队、异构数据库Java 应用、需要精细管控多应用共享库、异构语言云环境、有专职 DBA我在大多数项目里的建议是纯 Java 技术栈、规模不大优先考虑应用层或 ShardingSphere-JDBC有多个语言的应用需要读同一个库或者想把读写分离和分库分表一起治理上 ShardingSphere-Proxy 或 MyCat如果数据库已经在云上直接买云数据库的读写分离能力别自己造轮子。3. 中间件选型实测ShardingSphere 与 MyCat 的取舍3.1 ShardingSphere 双模式JDBC 和 Proxy 怎么选ShardingSphere 是 Apache 基金会下的项目目前新版本统一叫 ShardingSphere5.x 之后的核心能力是数据分片、读写分离、数据加密等。它提供两种使用方式很多人会在选型时纠结ShardingSphere-JDBC以 jar 包嵌入 Java 应用应用配置一个逻辑数据源SQL 经过解析后路由到真实数据源。我实际用下来的感受是它和 Spring Boot 的整合非常顺滑启动快性能损耗几乎可以忽略适合对延迟敏感的 Java 服务。ShardingSphere-Proxy独立部署的代理服务兼容 MySQL、PostgreSQL 协议任何语言的客户端都可以连接它。它适合异构系统或者不想改应用代码的场景但要接受额外的网络开销和运维成本。如果你确定只用 Java我个人倾向 ShardingSphere-JDBC。原因有三个少一个部署节点排障链路短SQL 解析发生在应用内不会有代理层成为瓶颈Spring 事务集成上 JDBC 模式更自然。读写分离的最小配置如下# ShardingSphere 5.x YAML 配置 dataSources: primary_ds: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver jdbcUrl: jdbc:mysql://192.168.10.1:3306/business username: root password: change_me replica_ds: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver jdbcUrl: jdbc:mysql://192.168.10.2:3306/business username: root password: change_me rules: - !READWRITE_SPLITTING dataSources: readwrite_ds: writeDataSourceName: primary_ds readDataSourceNames: - replica_ds loadBalancerName: round_robin loadBalancers: round_robin: type: ROUND_ROBIN这段配置的意思是逻辑数据源readwrite_ds的写走primary_ds读走replica_ds多个从库之间用轮询负载均衡。配置完成后应用只依赖readwrite_ds这一个数据源即可。3.2 MyCat 的定位与使用感受MyCat 是国产老牌数据库中间件独立代理模式应用把 MyCat 当作 MySQL 来连。它兼容 MySQL 协议能处理读写分离和分库分表早期很多 Java 外的团队用它做透明代理。实际用下来MyCat 的优点是部署直观、配置采用 XML 或注解方式社区里资料很多缺点也很明显它对 SQL 的支持依赖解析器能力复杂 SQL、特定函数、跨库 JOIN 容易踩坑。另外 MyCat 1.x 和 2.x 差异较大升级时要注意兼容性。如果你只是做读写分离、分库分表规则也不复杂MyCat 够用但如果你想做得更精细比如读写分离和分库分表叠加时的事务保障ShardingSphere 的能力边界更清晰。3.3 我的一次选型决策过程前阵子做金仓数据库项目的方案时我对比了这两个中间件最后没有引入独立代理原因是项目本身是 Java 单体引入 MyCat 等于多加一层代理还要处理代理高可用运维成本上来不少。金仓兼容 PostgreSQL 协议ShardingSphere-Proxy 对 PostgreSQL 协议的支持不如 MySQL 那么顺JDBC 模式又有集成优势。所以最终用了应用层动态数据源配合后续要讲的强制主库读方案。选型这件事没有绝对的对错关键是把团队情况、部署拓扑、性能要求、数据库类型列清楚再拍板。别只看论坛里“XX 中间件吊打一切”的说法还是要跑一下自己的压测。4. Spring Boot 动态数据源应用层读写分离的完整实现4.1 AbstractRoutingDataSource 是核心Spring 内置的AbstractRoutingDataSource从 2.0.1 版本就存在设计目的就是解决多数据源动态切换问题。它内部维护一个目标数据源 Map调用getConnection()时会先执行determineCurrentLookupKey()根据返回的 key 选择对应的数据源。基于这个机制读写分离的实现思路是定义一个ThreadLocal保存当前线程应该走哪个数据源然后在 DAO 层或事务层切换 key。查询方法把 key 设为“从库”写方法设为“主库”。关键类如下。public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getKey(); } }public class DataSourceContextHolder { public static final String PRIMARY primary; public static final String REPLICA replica; private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void setKey(String key) { CONTEXT.set(key); } public static String getKey() { String key CONTEXT.get(); return key null ? PRIMARY : key; } public static void clear() { CONTEXT.remove(); } }这个ThreadLocal就是路由的“方向盘”。一定要注意在线程结束时调用clear()否则线程池复用时上一个请求的 key 会被下一个请求读到产生路由错乱。这是应用层方案最常见的事故源。4.2 关键实现步骤第一步配置两个真实数据源。我用的是 HikariCPSpring Boot 2.x 之后默认自带。spring: datasource: primary: jdbc-url: jdbc:mysql://192.168.10.1:3306/business username: root password: change_me driver-class-name: com.mysql.cj.jdbc.Driver replica: jdbc-url: jdbc:mysql://192.168.10.2:3306/business username: root password: change_me driver-class-name: com.mysql.cj.jdbc.Driver注意Spring Boot 自动配置会读取spring.datasource.url这里用自定义前缀后一定要手动创建数据源 Bean否则两个数据源都无法被正确初始化。第二步创建数据源配置类。Configuration public class DataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource.primary) public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(prefix spring.datasource.replica) public DataSource replicaDataSource() { return DataSourceBuilder.create().build(); } Bean public DataSource routingDataSource(Qualifier(primaryDataSource) DataSource primary, Qualifier(replicaDataSource) DataSource replica) { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(DataSourceContextHolder.PRIMARY, primary); targetDataSources.put(DataSourceContextHolder.REPLICA, replica); DynamicDataSource routingDataSource new DynamicDataSource(); routingDataSource.setDefaultTargetDataSource(primary); routingDataSource.setTargetDataSources(targetDataSources); return routingDataSource; } Bean Primary public DataSource dataSource(Qualifier(routingDataSource) DataSource routingDataSource) { return routingDataSource; } }第三步用 AOP 根据事务注解或自定义注解完成路由切换。这里分享一个稳定可靠的做法基于 Spring 事务的Transactional(readOnly true)自动走从库其余走主库。Aspect Component public class DataSourceRouteAspect { Around(annotation(org.springframework.transaction.annotation.Transactional)) public Object switchByTransaction(ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); Transactional transactional method.getAnnotation(Transactional.class); if (transactional ! null transactional.readOnly()) { DataSourceContextHolder.setKey(DataSourceContextHolder.REPLICA); } else { DataSourceContextHolder.setKey(DataSourceContextHolder.PRIMARY); } try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clear(); } } }这样写的好处是业务代码不需要感知读写分离只在查询方法上标注Transactional(readOnly true)语义清楚也方便代码审查。要注意Transactional只对 public 方法生效且需要经过 Spring 代理同类内部方法调用是不起作用的。4.3 事务内路由的坑我在这里踩过一个很深的坑。假设一个业务方法既写了数据又立刻读了同一份数据Transactional public void registerUser(User user) { userMapper.insert(user); User saved userMapper.selectById(user.getId()); // 期望返回刚插入的数据 }如果没有额外处理selectById会走从库而从库的复制还没来得及同步这条新插入的数据查询结果就是 null。这个问题在读写分离方案里非常经典处理方式一般有三种强制要求读写混合事务走主库不给readOnly true这样整个事务都在主库执行。在事务内第一个写操作之后把路由 key 钉在主库直到事务结束。使用中间件的事务内主库路由能力比如 ShardingSphere 可以通过HintManager或事务自动管理保证同一事务内的读写都在主库。最省心的方案是第一条直接约定凡是包含写操作的事务一律不标记readOnly走主库。但有些复杂场景比如先查再判再写开发者容易忘记约束。我在项目里加了一条代码评审规范所有标记了Transactional的方法必须在注释里说明是否包含写逻辑否则一律按读写混合处理。5. 金仓数据库KingbaseES的读写分离配置实践5.1 金仓的复制机制与 PostgreSQL 同源金仓数据库是人大金仓推出的关系型数据库主流版本兼容 PostgreSQL 生态这决定了它在主从复制上走的是 WAL 流复制路线思路和 PostgreSQL 非常接近。如果你熟悉 PostgreSQL 的主备搭建金仓的上手成本会低很多。金仓做读写分离底层依赖的是主备架构。主库写入时产生 WAL 日志备库通过网络持续拉取并回放这些日志从而保持数据同步。与 MySQL 基于 binlog 的异步复制不同金仓的流复制在默认配置下可以做到很高的同步及时性但仍存在延迟窗口不能假设“零延迟”。在部署层面金仓提供了一套集群管理工具常见的是sys_monitor.sh和配套的集群配置文件它们继承了 PostgreSQL 中pg_basebackup、pg_ctl的核心思路用来完成备库的初始化、集群启停、主备状态检查。具体工具名称在不同版本里会有差异以你手上的官方文档为准。我这边落地时主库需要调整kingbase.conf中的 WAL 级别和复制相关参数例如开启归档、设置最大 WAL 发送进程数然后创建具备复制权限的专用账号再用基础备份方式把主库数据文件拉到备库节点完成备库启动。这套流程只要在测试环境跑通一次后续就是运维标准化的事。如果你的项目里 DBA 资源充足建议让 DBA 把主备搭建、延迟监控、主从切换演练都规范化应用层只需要关注数据源配置即可。5.2 Spring Boot 连接金仓的驱动与数据源金仓的 JDBC 驱动格式与 PostgreSQL 类似但并不同连接时需要注意类名和 URL 前缀。实测中金仓的驱动类名是com.kingbase8.DriverURL 前缀是jdbc:kingbase8://具体端口要看部署常见的有 54321、5432 等。spring: datasource: primary: jdbc-url: jdbc:kingbase8://192.168.20.1:54321/business username: system password: change_me driver-class-name: com.kingbase8.Driver replica: jdbc-url: jdbc:kingbase8://192.168.20.2:54321/business username: system password: change_me driver-class-name: com.kingbase8.Driver代码层面数据源配置和第 4 节完全一样不需要针对性修改。动态路由、事务切库、强制主库读这些逻辑对数据库类型是透明的。唯一要注意的是驱动版本的兼容性金仓驱动要和应用连接的服务器版本匹配尽量使用官方推荐版本避免高版本驱动连低版本实例时出现协议兼容问题。5.3 金仓环境下的动态路由实践我在金仓项目里没有引入中间件直接用第 4 节的DynamicDataSource方案。实际跑下来查询走从库、写入走主库的路由符合预期压测时主库 CPU 从 80% 降到 45% 左右读流量被从库承担掉了。不过有一个细节要特别提醒金仓的流复制默认是异步的主备延迟在秒级以内通常没问题但一旦出现大事务或者备库回放慢延迟就可能放大。所以我在项目里加了一个“延迟兜底”逻辑通过一个定时任务监控主备延迟时间如果延迟超过阈值比如 2 秒就自动把读流量全部切到主库直到延迟恢复。这个开关用配置项控制不需要重启服务保证在极端情况下用户读不到超旧的数据。实现这个开关也不复杂在路由切面对判断条件加一层“路由熔断逻辑”即可。生产中“延迟过大自动切主”远比“延迟过大继续读从库”稳妥。6. 落地之后必须盯住的四个坑6.1 主从延迟导致读到旧数据这是读写分离上线后最容易被业务投诉的问题。典型场景用户提交订单刷新列表发现订单不见了因为列表查询走了从库而订单数据还没复制到从库。处理思路有几种关键接口强制主库读比如订单详情、支付结果、登录信息这类强一致业务查询方法直接走主库不做路由。延迟阈值熔断前面提到的延迟监控方案延迟超过阈值自动切主。半同步复制如果数据库层面支持可以打开半同步复制保证主库写成功后至少一个从库已收到日志缩小延迟窗口。实际操作中我通常用“关键接口强制主库 非关键接口读从库 全局延迟熔断”三件套。彻底禁止从库读不现实但可以让延迟的影响面收窄到可控范围。6.2 事务内“先写后读”的脏读问题这个问题第 4.3 节已经提过但值得单独强调因为它是读写分离上线后最常见的“偶发 bug”。排查起来很费劲因为不是每次都能复现只有从库复制延迟恰好大于事务执行时间时才出现问题。建议在代码层面做一个拦截同一事务内一旦检测到当前线程已经执行过写操作后续所有 SQL 强制走主库。这个拦截可以在切面里记录一个ThreadLocal标志位或者在 DAO 层统一封装。实现简单但能把脏读问题从根上杜绝。6.3 主从切换时应用层怎么办无论是手动切换还是故障自动切换主从切换对应用层都是一次拓扑变化。如果你用的是 ShardingSphere-Proxy 这类代理应用层完全无感知如果是应用层多数据源方案主从切换后应用里配置的 IP 并不会自动变化。我的做法是给应用层加一层“数据源拓扑感知”把主从库地址放到配置中心或数据库路由表中应用定时刷新同时依赖连接池的健康检查主库不可用时把写请求快速失败或降级而不是无限阻塞。这个方案虽然不像代理层那样透明但可控性强排障也比较直接。6.4 压测和监控要验证流量真的分开了有些项目配置完读写分离看着没问题就上线结果从库一直没被访问。原因往往是Transactional(readOnly true)没生效或者数据源 Bean 被 Spring 自动配置覆盖应用实际用的还是单数据源。验证方法很简单上线前在主从库分别打开 SQL 日志或性能监控跑一轮压测观察主库是否有 SELECT、从库是否收到读流量。另外在监控面板上主库的读 QPS 应该显著下降从库的读 QPS 应该提上来。如果压测时主库读 QPS 纹丝不动先查数据源 Bean 是否被正确注入再查事务注解是否被 AOP 拦截到最后确认连接池拿到的确实是路由数据源。金仓环境下还可以通过查询sys_stat_replication这类系统视图查看复制状态和延迟把它加到监控告警里比事后看数据对账要靠谱得多。说回开头那个问题读写分离到底该怎么做其实没有唯一答案。从我个人的经验看先搞清楚瓶颈是不是真的在“读”再决定用应用层、中间件还是基础设施方案比直接选一个热门中间件重要得多。Spring Boot 动态数据源虽然在代码里多绕了一层但透明可控配金仓数据库也没有额外障碍唯一要守住的原则是事务边界要清楚、延迟熔断要有、流量监控不能少。这套组合我在生产环境验证过至少比盲目上代理方案少踩一半的坑。