首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
ORM层实现数据库读写分离:动态数据源路由核心原理与实战
📅 2026/9/21 5:07:22
✍️ 爱科研究院
👁 阅读 3,247
开头直接从上个月的一次故障说起。凌晨两点线上主库的CPU被打到100%慢查询日志刷屏DBA在群里喊了一句“赶紧看下你的订单接口怎么在这会儿全走主库了”我打开监控一看业务量翻了十倍但手里这套系统压根就没做过数据库读写分离所有流量不分青红皂白全怼到一个主库上不炸才怪。后来花了两个通宵把读写分离从ORM层做了出来顺手还把核心源码捋了一遍。这篇文章就是想把这段经验完整记录下来——数据库读写分离在ORM里到底是怎么实现的动态数据源路由的核心机制是什么以及你在实际接入时最容易踩到哪些坑。适用人群是Java后端、用MyBatis/MyBatis-Plus的团队以及那些听说过读写分离但一直没搞懂“代码层落地方式”的朋友。1. 读写分离为什么需要ORM参与很多人的第一反应是读写分离不是加一台从库把binlog同步过去就完事了吗事实上加从库是最简单的一步难的是让应用自己知道“这条SQL该走主库、那条该走从库”。这个决策如果不在应用层做就得在数据库前面加代理但代理方案不是所有团队都愿意接受的。1.1 从一次主库被打爆的故障说起当时的情况是这样的系统上线了一个促销活动订单查询量瞬间上来。代码里所有查询都走同一个数据源也就是主库。主库既要处理写事务又要扛住每秒几千次的读请求连接池很快就被打满业务线程全部阻塞在获取连接上。随后DBA架了一台只读从库binlog同步也配好了但问题仍然存在——因为代码里压根没有“读从库”的逻辑。你以为加一台从库流量就会自动分流吗不会。只有ORM层把“读请求路由到从库数据源”这件事做出来从库才会真正被用到。1.2 ORM层在读写分离中究竟扮演什么角色先理清一个概念ORM全称是Object Relational Mapping在Java生态里最常见的实现是MyBatis、MyBatis-Plus、Hibernate。它在读写分离里扮演的角色简单说就是“决定你的SQL到底从哪个数据源拿连接”。你要知道一条SQL要执行第一步永远是Connection conn dataSource.getConnection()。这个dataSource是谁在Spring环境中它是一个被注入到MyBatis配置里的DataSource对象。如果这个DataSource是个“路由器”那么getConnection()返回的连接就是主库的或从库的。反过来如果这个DataSource是写死的单一连接池那读写分离就无从谈起。所以ORM层的关键作用是提供一层“动态选择数据源”的能力。读写分离在ORM层落地技术上的本质是让DataSource拥有路由能力而不是让每个业务方法自己去判断该用哪个数据源。1.3 为什么中小团队更青睐应用层方案数据库读写分离的常见实现有三类中间件代理方案、数据库驱动层方案、应用层ORM方案。中间件代理比如ShardingSphere、MyCat确实强大支持分库分表、读写分离、数据脱敏但你需要额外维护一套代理节点所有SQL都要经过代理转发延迟和运维成本都上来了。对于大部分中小团队尤其是业务量还没到必须上代理架构的阶段在ORM层做读写分离是最轻量、最容易维护的选择。它不需要额外部署组件不需要改数据库连接方式只需要在Spring容器里多配置几个数据源Bean再加上一个路由数据源和一个AOP切面问题就解决了。而且这套方案对现有业务代码的侵入非常小能在不动SQL的前提下完成读写流量的拆分。2. 动态数据源路由的核心实现ORM层读写分离的关键类是Spring框架里一个容易被忽略的抽象类AbstractRoutingDataSource。它本身不直接创建连接而是根据一个key值从一组目标数据源中选出一个再调用选中的数据源的getConnection()方法。这就是“动态路由”的基石。2.1 AbstractRoutingDataSource路由的关键类看一下Spring源码里最核心的两个方法。AbstractRoutingDataSource实现了javax.sql.DataSource接口它内部维护了一个MapObject, DataSource targetDataSources而getConnection()的调用链是这样的public Connection getConnection() throws SQLException { return determineTargetDataSource().getConnection(); } protected DataSource determineTargetDataSource() { Object lookupKey determineCurrentLookupKey(); DataSource dataSource this.targetDataSources.get(lookupKey); if (dataSource null) { throw new IllegalStateException(Cannot determine target DataSource for lookup key [ lookupKey ]); } return dataSource; } protected abstract Object determineCurrentLookupKey();明白了吗determineCurrentLookupKey()返回一个lookupKey比如字符串master或slave然后determineTargetDataSource()从Map中取出对应的数据源。也就是说我们只要控制住determineCurrentLookupKey()返回什么就能决定每条SQL走哪个库。这个方案比起在业务代码里手写DataSource判断干净太多了。你在写业务时压根不用关心数据源切换只需要保证在调用前这个lookupKey被设置到正确的值即可。实际的key值我是这样设计的public enum DBType { MASTER, SLAVE }MASTER对应主库SLAVE对应从库。如果你有多台从库完全可以扩展成SLAVE1、SLAVE2在路由决策时按权重或者轮询选择不过本文先从一台从库讲起。2.2 路由上下文是如何传递到各个线程的在Web应用里每个请求都由一个独立的线程处理。你肯定不能用一个全局变量来存“当前该走哪个库”因为全局变量的值是所有线程共享的一个请求把它设置成SLAVE另一个并发请求读到的也是SLAVE这会出大问题。这里需要用到ThreadLocal它相当于线程的私有存储区。每个线程往ThreadLocal里写入的值只有当前线程自己能读到其他线程互不干扰。这个特性完美契合数据源路由场景。我的实现里会创建一个DataSourceHolder作为路由上下文的持有者public class DataSourceHolder { private static final ThreadLocalDBType CONTEXT new ThreadLocal(); public static void set(DBType dbType) { CONTEXT.set(dbType); } public static DBType get() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }为什么最后一定有一个clear()方法因为Tomcat的工作线程是复用的线程处理完一个请求后不会销毁而是归还到线程池里。如果不清空ThreadLocal下一个请求复用这个线程时会接着上一次的SLAVE标记导致本该走主库的写操作被错误路由到从库造成数据不一致。这个坑我在上线初期踩过后面第5章会专门展开。3. 手写一个最小可用的ORM读写分离模块原理说完了接下来直接进入实操环节。我用的是Spring Boot 2.7.x MyBatis-Plus 3.5.x这套组合这套组合目前中小团队用得最多。完整的项目结构我会在第6章给出来这里先把核心代码逐个拆开讲。3.1 依赖引入与配置准备pom.xml里最关键的几个依赖如下别多引够用就行dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency然后需要一份数据库配置文件。主库和从库的地址我建议分开配置属于两个不同的数据源Beanspring: datasource: master: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.10:3306/shop?useSSLfalsecharacterEncodingutf8 username: root password: master123 hikari: maximum-pool-size: 20 slave: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.11:3306/shop?useSSLfalsecharacterEncodingutf8 username: root password: slave123 hikari: maximum-pool-size: 20这里有个注意点公用的spring.datasource.url最好不要写了。一旦你写了spring.datasource.urlSpring Boot自动装配会优先创建一个默认数据源可能会干扰你自定义的路由数据源。最好的做法是只保留master和slave两个自定义配置块然后排除默认的数据源自动配置。3.2 动态数据源类实现路由逻辑接下来写DynamicDataSource它是整个模块的心脏继承了AbstractRoutingDataSourcepublic class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceHolder.get(); } }是不是很简单determineCurrentLookupKey()把当前线程的路由key返回给AbstractRoutingDataSource由它从Map中找到对应的真实数据源。这就是ORM层路由的全部秘密。然后写一个配置类把主库数据源、从库数据源组装进DynamicDataSourceConfiguration public class DataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(prefix spring.datasource.slave) public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } Bean public DynamicDataSource dynamicDataSource( Qualifier(masterDataSource) DataSource master, Qualifier(slaveDataSource) DataSource slave) { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(DBType.MASTER, master); targetDataSources.put(DBType.SLAVE, slave); DynamicDataSource dynamicDataSource new DynamicDataSource(); dynamicDataSource.setTargetDataSources(targetDataSources); // 设置默认数据源在没有设置路由key时走主库保证写操作安全 dynamicDataSource.setDefaultTargetDataSource(master); return dynamicDataSource; } }注意setDefaultTargetDataSource(master)这一步。如果不设置默认数据源当ThreadLocal里没有值时determineTargetDataSource()会因为lookupKey为null而抛出异常。设置默认走主库能保证在没有显式路由的情况下行为还是和原来一样安全。3.3 自定义注解与AOP切面绑定路由规则数据源有了路由key也有了那谁来设置这个key呢最简单直观的方案是自定义一个注解DS标在方法上表示这个方法该走哪个库。然后通过Spring AOP在执行方法前设置路由在方法执行后清空路由。先给注解Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DS { DBType value() default DBType.SLAVE; }切面类是核心逻辑所在。我用的是Around通知切点拦截带有DS注解的方法Aspect Component public class DataSourceAspect { Around(annotation(ds)) public Object switchDataSource(ProceedingJoinPoint joinPoint, DS ds) throws Throwable { DataSourceHolder.set(ds.value()); try { return joinPoint.proceed(); } finally { DataSourceHolder.clear(); } } }这段代码的逻辑是进入方法前把指定的数据源类型写入ThreadLocal执行目标方法等执行完毕后无论成功还是异常都清空ThreadLocal。用finally来清空是必须的不然出现异常时线程复用会出问题。这样一个最简单的ORM读写分离模块就已经成形了。在你的Service方法上标DS(DBType.SLAVE)这条查询就会走从库标DS(DBType.MASTER)就走主库。比如这样Service public class OrderService { Autowired private OrderMapper orderMapper; DS(DBType.SLAVE) public ListOrder queryTodayOrders(String userId) { return orderMapper.selectList(...); } }3.4 方法名匹配路由无侵入的替代方案不过让每个查询方法都加DS注解业务团队的开发可能会觉得繁琐而且容易漏标。有一个更“聪明”的做法约定方法名规则。比如以select、get、find、list、page开头的方法默认走从库其余方法走主库。这个方案不用改任何业务代码只需要改切面逻辑Aspect Component public class MethodNameDataSourceAspect { Around(execution(* com.example.service..*.*(..))) public Object switchByMethodName(ProceedingJoinPoint joinPoint) throws Throwable { String methodName joinPoint.getSignature().getName(); if (methodName.startsWith(select) || methodName.startsWith(get) || methodName.startsWith(find) || methodName.startsWith(list)) { DataSourceHolder.set(DBType.SLAVE); } else { DataSourceHolder.set(DBType.MASTER); } try { return joinPoint.proceed(); } finally { DataSourceHolder.clear(); } } }我个人建议把两种方案结合默认按方法名路由遇到特殊场景再用DS手工指定。这样既降低了开发成本又保留了灵活调优的空间。4. 只读事务与强制走主库在写读写分离时读流量从主库分流到从库确实能减轻主库压力但这里隐藏着一个很容易被忽略的问题事务与数据源路由的关系。4.1 Transactional(readOnly true)背后的路由逻辑Spring的Transactional注解里有一个readOnly属性很多开发会把它简单地理解为“数据库层面的只读约束”实际上它只是一个提示并不强制。在动态数据源方案里我们可以把readOnly true当作一个路由信号。思路是这样的Spring事务管理器在开启事务时会从DataSource里获取连接。如果我们能在事务真正开启之前根据事物属性决定路由key那么“只读事务走从库、读写事务走主库”就能成立。但这需要你在事务切面执行顺序上做文章。这里稍微展开一下。Spring事务的切面顺序默认是最低的我们是希望数据源路由切面在事务切面之前执行这样路由先设置好事务管理器再拿连接时拿到的就是正确数据源。可以通过实现Ordered接口并设置较小的顺序值来保证Component public class TransactionAwareDataSourceAspect implements Ordered { Around(annotation(transactional)) public Object switchByTransaction(ProceedingJoinPoint joinPoint, Transactional transactional) throws Throwable { if (transactional.readOnly()) { DataSourceHolder.set(DBType.SLAVE); } else { DataSourceHolder.set(DBType.MASTER); } try { return joinPoint.proceed(); } finally { DataSourceHolder.clear(); } } Override public int getOrder() { return 0; } }把order设为0让它优先于事务切面执行。这样一个标了Transactional(readOnly true)的方法会自动走从库一个普通的增删改方法会自动走主库。从库的压力分担就不再依赖业务人员自觉加DS了。4.2 事务混用主从库的严重问题明确一个原则主库和从库之间的事务不能混。比如在一个事务里你先执行了一条插入操作再执行一条查询操作而且查询被路由到了从库。由于主从复制有延迟从库可能还没同步到这刚插入的数据于是你读到的就是旧数据这在业务上可能是致命的。更为致命的是Transactional本身是和“单个连接”绑定的。Spring在事务开始时拿到了主库连接这个事务内所有SQL都绑定在这条连接上。但如果你在事务内动态切换了路由key后续SQL却可能继续使用线程上下文里那个错误的连接导致操作落到了错误库甚至出现跨库事务的假象实际Spring单数据源事务根本管不住跨库的一致性。我的建议是事务内的路由必须在事务启动前确定且中途不能变更。如果一个方法既需要写主库、又需要查询从库那最好拆分成两个独立事务的方法不要让它们在同一个事务上下文里互相切换。比如“保存订单后查询订单列表”这种场景保存走saveOrder()查询走queryOrderList()两个方法各自开各自的数据源路由。4.3 强制走主库的几种实用方案有些场景下主从延迟完全不可容忍。比如支付回调改了订单状态前端立刻来查这个订单如果走了从库可能还是“待支付”。这时候就需要强制走主库。我的实践经验有三种方案按优先级排列。第一方法级强制在目标方法上直接标注DS(DBType.MASTER)DS(DBType.MASTER) public Order queryOrderFromMaster(String orderId) { return orderMapper.selectById(orderId); }第二全局开关在配置文件中控制是否开启读写分离。如果业务处于大促高峰期或者从库延迟普遍较高可以临时关闭只读路由所有流量都走主库readwrite: separation: enabled: true切面里判断一下开关状态就行了。这个开关我在实际运维中用过非常救命当从库同步延迟飙到好几秒时直接一键切回主库全量支撑。第三当前线程强制用于一个线程内后续操作都必须走主库的特殊场景。通过扩展DataSourceHolder提供一个forceMaster()方法public static void forceMaster() { CONTEXT.set(DBType.MASTER); }这三个方案组合起来能覆盖几乎所有的路由强制需求。5. 读写分离上线后的三个经典问题真正上生产之后你才会发现读写分离的难点不在“怎么实现”而在于“怎么在复杂场景下不出问题”。这里我把自己踩过的坑和比较隐蔽的三个问题整理出来边排查边讲链路。5.1 主从延迟导致刚写入的数据读不到这个问题几乎每个做读写分离的团队都会遇到。现象很典型用户提交订单后页面跳转到订单详情结果提示“订单不存在”刷新一下又出现了——这是因为写操作走了主库读操作走了从库而主从复制还没来得及把数据同步过去。复现思路是先检查从库的Seconds_Behind_Master在MySQL里执行SHOW SLAVE STATUS看延迟值。如果延迟很高说明从库读的实时性得不到保障。在代码层我常用的兜底策略有三层写操作完成后把订单号没有太大的必要加一层基于本地缓存的标记短时间内同一订单号的查询强制走主库。查询详情时如果查不到数据做一次“二次查询从主库读”。关键业务查询订单详情、支付结果直接从代码规则上就默认主库只把列表页、聚合搜索、报表统计这类对延迟容忍度高的查询放到从库。5.2 多数据源下的事务边界混乱当系统里有多个数据源时Spring的Transactional并不能保证跨数据源的原子性。很多同学误以为一个事务方法里可以随便切换数据源实际上一旦事务切面先拿到连接整个事务就绑死在这条连接上了后续的路由切换只对接下来新获取的连接有效而事务内的SQL还是走最初那条连接。排查时可以用一个简单的方式打日志在数据源路由切面里输出当前线程的route key和事务状态。你会发现很多“切换失败”其实不是切面没执行而是执行了但事务内连接已经被锁定了。避免这个问题的最好方式就是事务方法里不写任何数据源路由切换逻辑通过独立方法拆解事务边界而不是在事务方法内部靠切面动态切。另外多数据源环境下建议使用Transactional时明确指定事务管理器Transactional(transactionManager masterTransactionManager, readOnly true)这样至少能保证路由的确定性和可预期性。5.3 连接池耗尽与路由穿透还有一个问题很隐蔽你以为DS(DBType.SLAVE)只影响当前方法但如果在方法里调用了另一个没有被切面拦截的内部方法或者通过this.xxx()自调用那AOP代理就不生效。因为你调用的是this对象本身的方法而不是代理对象的方法。这个现象叫“路由穿透”会使设置的ThreadLocal在进入内部方法时保留上一个数据源上下文一旦内部方法是一条写操作它就可能落到从库上导致写入失败或者数据不一致。排查方式很直接在切面里把方法签名和当前路由key打印出来。如果发现某个内部方法压根没被切面拦截那就说明问题出在“自调用”。解决方式是把内部方法移到另一个Bean里通过Spring容器注入后调用或者在业务上约定不允许自调用绕过代理。连接池耗尽则是另一种表象多数据源下如果每个库都配置了连接池大小而健康检查、读写流量比预估不准某个库的连接池也可能被打满。比如主库连接池最大20写流量占了30%原本的读流量也因为是事务内查询被绑在主库导致主库连接池被打满。这种场景的排查链路是看监控里主库连接数——看代码里事务边界——看路由决策是否把没有显式标注的读操作默认丢给了主库。6. 给业务方的接入方式与最终源码结构最后把完整的源码目录整理一遍并且说一下其他团队接入这套模块时怎么用最低的改动量来适配。6.1 完整源码文件清单整个读写分离模块代码量并不大。核心文件大概就是下面这些src/main/java/com/example/datasource/ ├── annotation/ │ └── DS.java ├── config/ │ └── DataSourceConfig.java ├── context/ │ └── DataSourceHolder.java ├── dynamic/ │ └── DynamicDataSource.java ├── aspect/ │ ├── DataSourceAspect.java │ ├── MethodNameDataSourceAspect.java │ └── TransactionAwareDataSourceAspect.java └── enums/ └── DBType.java接入方拿到这套代码后需要做以下几个操作把DBType枚举里的MASTER、SLAVE对应到自己的数据库实例。修改application.yml里的主从库连接信息。保留或者调整方法名路由规则按自己团队的命名习惯来。确保业务Service里的增删改方法名不以select、get开头否则会被切面误判到从库。6.2 从一个小功能扩展到整套体系的思考读写分离只是数据库扩展的第一步。当你真正把DataSource路由跑通之后后面的能力都是水到渠成的读写分离、多租户隔离、同库不同表的业务分片本质上都只是路由key的扩展。你唯一需要做的是保证路由决策层的灵活性不要在核心路由逻辑里硬编码某种业务语义。我在给其他团队做技术方案评审时通常会建议他们在路由切面里预留一个扩展点比如一个DataSourceRouter接口把“按什么规则路由”从切面里抽出去。业务方只需要实现自己的路由规则不用改框架代码。具体接口可以设计成public interface DataSourceRouter { DBType route(Method method, Object[] args); }这个设计的好处是你们团队以后如果接入了分库分表中间件或者引入了读写分离中间件只需要新增一个实现类原有代码不受影响。附上源码的目的不只是让你“能跑”更是让你在后续演进时知道在哪一处做扩展。6.3 我自己的使用体会和后续可以扩展的方向最后谈谈使用体会。这套手写方案我已经在两个项目里落地了一个日活几十万的商城系统一个内部工单系统。商城系统改造完成后主库的CPU峰值从90%降到40%左右连接池的等待时间也明显缩短了。内部工单系统规模不大但从库挂上之后报表查询和大批量导出操作再也没影响过主库的在线事务。不过我也要提醒一句如果你的系统已经发展到了需要分库分表、读写分离规则复杂到按用户维度路由、跨库事务频繁出现的阶段那就不要继续在ORM层面死磕了。直接考虑引入成熟的中间件或者分布式事务方案因为应用层路由方案的优势在于轻量和可控但它的上限就在于“单机应用、数据源数量有限”这个假设上。功能上后续可以扩展的方向包括从库负载均衡多从库轮询/权重、基于配置中心的动态路由规则热更新、主从切换时的数据源自动故障转移。这些都是在现有DynamicDataSource基础上做增量开发路由核心类完全不需要重写。动手做一次你会对整个Spring数据源体系有完全不一样的理解。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/9/21 5:07:22
STM32智能婴儿床毕设全攻略:从架构设计到答辩避坑
2026/9/21 5:07:22
GNN如何解决气道分割漏检难题:从原理到工程落地实践
2026/9/21 5:07:22
C语言学习避坑指南:从习题到实战的参考答案使用法
2026/9/21 5:42:25
Simulink与FlightGear联合仿真:飞行器控制算法三维可视化验证平台搭建
2026/9/21 5:37:24
BrewUI 详解:macOS 上 Homebrew 的图形化包管理利器
2026/9/21 5:37:24
AI芯片设计入门的三道硬门槛:NPU、编译器与验证闭环
2026/9/21 5:37:24
反激电源TL431补偿器设计与波特图调试实战
2026/9/21 5:37:24
MATLAB配置MinGW编译器全指南:从安装到排错一次搞定
2026/9/21 5:37:24
测序数据可视化:从BAM到bigWig的UCSC工具链实战指南
2026/9/21 0:02:00
Unity ML-Agents 工具包完整安装指南:从 Unity 2022.3 到 Python 训练环境的逐步搭建
2026/9/21 0:02:00
OneUptime 自定义探针(Custom Probe)部署实战:私网监控、代理配置与断连排障全指南
2026/9/21 0:02:00
大众TL52625前端框架材料要求详解:从性能测试到落地执行
2026/9/21 1:46:28
深入解析Transformer多头注意力机制与工程优化
2026/9/21 1:46:31
OpenClaw 的 Skills 跑学习任务,模型通道改到 TaoToken 通道行不行?
2026/9/21 1:46:33
ChatGPT报错Oops, an error occurred! 全链路排查指南