围绕中心:数据库读写分离。
想象一下你家只有一个水龙头,全家人都要排队洗手、洗菜、洗澡,那场面简直灾难现场。数据库也一样,当用户暴增的时候,所有读写操作都挤在一个数据库里,系统就会卡成PPT。
聪明的做法是搞一个主库负责写数据,再搞几个从库专门负责读数据,就像你家装了多个水龙头一样。Spring框架提供了一套神级解决方案,通过AbstractRoutingDataSource这个类,配合@Transactional注解的readOnly标志,自动判断当前事务是读还是写,然后把请求路由到正确的数据库。整个过程不需要你写一堆if-else判断,Spring在背后默默帮你搞定一切。
这篇文章会手把手教你从实体类创建到配置数据源,再到编写路由逻辑,最后还能用测试验证效果。学完这套组合拳,你的应用就能像高速公路一样畅通无阻,读操作走辅路,写操作走主道,各走各的道,谁也不堵车。
为什么你的数据库需要分身术
数据库瓶颈是每个程序员职业生涯中都会遇到的噩梦。想象一下双十一零点,几百万用户同时下单,你的数据库就像一个被挤爆的便利店,收银台前排着长队,后面还有人不断往里挤。这时候如果所有查询、插入、更新都堆在一个数据库上,系统响应时间会从毫秒级直接飙升到秒级,用户体验瞬间崩塌。读写分离就是解决这个问题的银弹,它的核心思想很简单:把读操作和写操作分开,让不同的数据库各司其职。
主数据库就像一家餐厅的后厨,专门负责做菜(写入数据),所有的订单修改、新菜品添加都在这里完成。从数据库则像前厅的服务员,专门负责给客人上菜(读取数据),顾客想看菜单、查询订单状态都找他们。这种分工带来的好处立竿见影,读操作可以横向扩展到多个从库,系统整体的读取性能呈线性增长。Spring框架深谙此道,它提供了一套优雅的机制让这种路由变得自动化,你不需要在每个方法里手动判断该连哪个数据库,只需要在方法上加个注解,Spring就像一位尽职的交通警察,自动把请求引导到正确的车道上。
搭建舞台:实体与仓库的诞生
在实现读写分离之前,咱们得先把基础设施搭起来。这就好比开餐厅之前得先确定卖什么菜、用什么餐具。
@Entity |
在Spring的世界里,实体类(Entity)就是你要卖的菜品,仓库接口(Repository)就是存放这些菜品的冰箱。
咱们先创建一个Order实体类,它代表一个订单,里面有id和description两个字段。id用@GeneratedValue(strategy = GenerationType.IDENTITY)注解,意思是让数据库自动帮你生成主键,就像餐厅给每道菜自动编号一样,你不需要操心编号重复的问题。
接下来创建OrderRepository接口,它继承了JpaRepository。这个接口堪称魔法般的存在,你只需要定义接口,Spring Data JPA就会自动帮你实现增删改查的所有方法。这就像你请了一个万能服务员,你不需要教他怎么端盘子、怎么记菜单,他天生就会这些技能。
public interface OrderRepository |
有了实体和仓库这两样法宝,咱们就可以开始搞读写分离的核心配置了。记住,这一层完全不需要特殊配置,Spring的优雅之处就在于它把复杂性都封装在底层,你只需要关注业务逻辑本身。
路由大脑:TransactionRoutingDataSource的逆袭
现在进入本文的核心环节,也是整个读写分离架构的大脑——路由数据源。Spring提供了一个抽象类叫AbstractRoutingDataSource,翻译过来就是"抽象路由数据源"。这个名字听起来很学术,其实它的作用就像你家小区的门卫大爷,每次有快递小哥(数据库请求)要进来,大爷都会问一句:"你是送外卖的还是来拜访业主的?"然后根据回答决定放行到哪个楼栋。在咱们的场景里,这个"问话"的过程就是判断当前事务是不是只读事务。
public class TransactionRoutingDataSource extends AbstractRoutingDataSource { |
咱们创建一个TransactionRoutingDataSource类,继承AbstractRoutingDataSource,然后重写determineCurrentLookupKey()方法。这个方法会在每次获取数据库连接时被调用,它通过TransactionSynchronizationManager.isCurrentTransactionReadOnly()来判断当前事务是不是只读的。
如果是只读事务,就返回DataSourceType.READ_ONLY,让请求去从库;如果不是只读事务,就返回DataSourceType.READ_WRITE,让请求去主库。
整个过程就像自动导航系统,你输入目的地(发起查询),系统自动选择最优路线(选择数据源),完全不需要你手动干预。
这种设计的美妙之处在于它的透明性,业务代码完全感知不到底层有多个数据库存在,就像你用手机导航时并不需要知道背后有多少个卫星在为你服务。
配置双数据源:主从数据库的安家落户
有了路由逻辑,接下来得给主库和从库安排住处。
spring.datasource.readwrite.url=jdbc:h2:mem:primary;DB_CLOSE_DELAY=-1 |
在application.properties文件里,咱们用spring.datasource.readwrite前缀配置主库,用spring.datasource.readonly前缀配置从库。这里用的是H2内存数据库,主库的URL是jdbc:h2:mem:primary,从库的URL是jdbc:h2:mem:replica。DB_CLOSE_DELAY=-1这个参数很重要,它告诉H2数据库别急着关门,等程序结束再关闭,这样连接就不会频繁断开重连。
@Configuration |
basePackageClasses属性从我们为组件扫描提供的类中提取包名。为了扫描我们需要的所有内容,OrderRepository和Order必须在同一个包中。
我们首先从各自的属性前缀创建两个DataSource bean。
首先,读“spring.datasource.readwrite.”属性:
@Bean |
然后,阅读“spring.source.readonly. *”属性:
@Bean |
接下来,我们构建实际的DataSource实例。首先,对于读写源:
@Bean |
然后,对于只读源:
@Bean |
配置TransactionRoutingDataSource
现在,我们在TransactionRoutingDataSource bean中连接路由DataSource数据源:
@Bean |
我们将把这两个目标注册到一个映射表中,在调用setTargetDataSources()时会用到这个映射表:
Map<Object, Object> dataSourceMap = new HashMap<>(); |
我们还调用了setDefaultTargetDataSource(),它定义了当例如代码在事务之外运行时要使用的回退数据源。
总结:在DataSourceConfiguration配置类里,咱们要做几件大事。
- 首先用@Bean和@ConfigurationProperties注解创建两个DataSourceProperties对象,分别读取主库和从库的配置。
- 然后用initializeDataSourceBuilder().build()方法构建实际的数据源对象。
- 接下来创建TransactionRoutingDataSource实例,把两个数据源放进一个Map里,调用setTargetDataSources()方法注册它们。
- 最后调用setDefaultTargetDataSource()设置默认数据源,这样当代码不在事务中执行时,也有一个安全的 fallback 选项。
整个过程就像给两个数据库办理入住手续,告诉路由器:"这是主库的房间号,这是从库的房间号,以后有人来敲门记得引对路。"
延迟连接的魔法:LazyConnectionDataSourceProxy登场
这里有一个容易踩的坑,也是很多初学者会卡住的地方。JPA在获取连接的时候,事务的readOnly标志还没设置好,如果直接让路由数据源去判断,它会一脸懵逼,不知道该走主库还是从库。
解决办法是用LazyConnectionDataSourceProxy把路由数据源包装起来。这个代理类就像一个聪明的秘书,它不会立即去敲数据库的门,而是等到真正要执行SQL语句的时候才去获取连接。这时候事务的readOnly标志已经设置好了,路由逻辑就能正确判断该走哪条路。
@Bean |
咱们创建一个dataSource()方法,返回new LazyConnectionDataSourceProxy(routingDataSource()),并且加上@Primary注解:这个注解很关键,它告诉Spring:"当需要自动注入DataSource的时候,优先选我,别选那个没包装过的routingDataSource()。"
接下来配置EntityManagerFactory,用LocalContainerEntityManagerFactoryBean作为实现类,把咱们的延迟代理数据源传进去,并指定扫描OrderRepository所在的包。
@Bean |
最后配置TransactionManager,返回JpaTransactionManager实例,这样@Transactional注解的readOnly标志就能正确传播到路由逻辑中。
@Bean |
这一套组合拳打下来,整个读写分离的基础设施就搭建完成了,就像一座大桥的各个桥墩都稳固地扎进了河床。
创建服务层
我们将创建一个使用@Transactional注解来控制路由的服务:
@Service |
带有readOnly = true注解的方法会被路由到副本,而其他事务则会被路由到主数据源:
@Transactional |
在这个例子中,我们为每个数据源都定义了一个查找方法。这在后面的测试中会很有用。
业务层的优雅:注解驱动的一切
基础设施搭好了,轮到业务层登场表演。咱们创建一个OrderService服务类,在里面注入OrderRepository。这里最精彩的部分来了:你只需要在方法上加@Transactional注解,就能控制数据路由的方向。save()方法上加@Transactional,不加readOnly参数,表示这是一个读写事务,所有操作都会路由到主库。findAllReadOnly()方法上加@Transactional(readOnly = true),表示这是一个只读事务,查询会路由到从库。findAllReadWrite()方法上加@Transactional,虽然也是查询操作,但因为它不是只读事务,所以还是会走主库。
这种设计简直是程序员的福音,你不需要写任何路由逻辑,不需要在代码里判断"如果是读操作就连从库,如果是写操作就连主库",Spring在背后帮你搞定一切。你的业务代码保持干净整洁,就像一篇没有冗余修饰的好文章。更妙的是,这种路由对调用方完全透明,其他服务调用OrderService的方法时,根本不需要知道底层有主从两个数据库的存在。这种封装的艺术正是Spring框架的精髓所在,它把复杂性藏在底层,给开发者呈现一个简洁优雅的接口。
测试的艺术:验证路由是否真管用
代码写完了,怎么知道读写分离真的生效了呢?咱们来写几个集成测试验证一下。在TransactionRoutingIntegrationTest测试类里,注入OrderService。第一个测试方法whenSaveAndReadWithReadWrite_thenFindsOrder()演示了读写事务的行为:先调用save()保存一个订单,然后调用findAllReadWrite()查询,断言能查到刚才保存的订单。这说明读写事务确实走了主库,数据写入后立即可见。
第二个测试方法whenSaveAndReadWithReadOnly_thenOrderNotFound()演示了只读事务的行为:先调用save()保存一个订单,然后调用findAllReadOnly()查询,断言查不到刚才保存的订单。这是因为从库和主库是两个独立的数据库,数据没有同步,从库里根本没有那条记录。这个测试巧妙地利用了主从库数据不一致的特点,反向证明了只读查询确实走了从库。如果路由逻辑有bug,所有查询都走了主库,这个测试就会失败,因为主库里是有那条记录的。这种测试设计堪称神来之笔,用数据的隔离性来验证路由的正确性,既简单又可靠。
那些你必须知道的坑
读写分离虽好,但也有一些坑需要你提前知道。
首先是复制延迟/复制滞后问题。
在真实的生产环境中,主库的数据会异步复制到从库,这个复制过程需要时间,可能毫秒级,也可能秒级,取决于你的网络状况和数据量。这意味着你刚在主库写入一条数据,立即去从库查询,可能查不到,因为数据还没复制过去。这种"写了马上读"的场景在业务中很常见,比如用户刚下单成功就跳转到订单详情页。解决办法是把这两个操作放在同一个读写事务里,确保读操作也走主库,拿到最新的数据。
其次是嵌套事务的问题。
Spring默认的事务传播行为是Propagation.REQUIRED,意思是如果当前有事务就加入当前事务,没有就新建一个。假设你有一个读写事务方法A,里面调用了只读事务方法B,因为A已经开启了事务,B会加入A的事务,而不是开启一个新的只读事务。这时候B的readOnly=true标志会被忽略,查询还是会走主库。
如果你真的想让B走从库,需要用Propagation.REQUIRES_NEW,让B挂起A的事务,开启自己的新事务。
另外要注意,如果A和B在同一个类里,Spring的代理机制会失效,内部调用不会走代理,事务注解也不会生效,这时候你需要把B拆到另一个类里。
从单库到多库的进化之路
当你的业务继续增长,一个从库可能扛不住读请求了,这时候就需要多个从库来分担压力。扩展咱们现在的方案有两种思路。第一种是在determineCurrentLookupKey()方法里做文章,维护一个从库列表,用轮询(Round Robin)算法每次选择不同的从库。比如你有三个从库,第一次查询走从库1,第二次走从库2,第三次走从库3,第四次再回到从库1,以此类推。这种方案实现简单,但健壮性一般,如果某个从库挂了,你的代码需要能感知并跳过它。
第二种更健壮的方案是引入数据库中间件,比如PgBouncer(PostgreSQL专用)或ProxySQL(MySQL专用)。这些中间件就像数据库前面的负载均衡器,它们维护多个从库的连接池,自动做健康检查,自动把请求分发到健康的从库上。你的应用只需要连接中间件,就像只连接一个数据库一样,中间件在背后帮你做路由和负载均衡。这种方案的好处是应用层完全无感知,增加或移除从库不需要改代码,只需要在中间件里配置。对于生产环境,强烈推荐用这种成熟的中间件方案,而不是自己写路由逻辑。
写在最后
咱们今天聊的这套Spring读写分离方案,核心就是AbstractRoutingDataSource配合@Transactional注解,再加上LazyConnectionDataSourceProxy解决连接时序问题。这套组合拳打下来,你的应用就能实现自动化的读写分离,写操作走主库,读操作走从库,系统吞吐量瞬间翻倍。配置过程虽然涉及好几个类,但每一步都有明确的职责划分,逻辑清晰,维护起来也不费劲。
这套方案最适合读多写少的应用场景,比如电商网站的商品浏览、新闻网站的文章阅读、社交平台的信息流展示。在这些场景里,读请求可能是写请求的几十倍甚至上百倍,把读请求分散到多个从库,能极大缓解主库的压力。当然,如果你的应用写操作很频繁,或者对数据一致性要求极高(比如金融交易系统),读写分离可能不是最佳选择,这时候你需要考虑其他架构方案。
技术选型永远要基于业务场景Context,没有银弹,只有最适合的方案。