SpringBoot配置主從數據庫實現讀寫分離
一、前言
現在的 Web 應用大都是讀多寫少。除了緩存以外還可以通過數據庫 “主從復制” 架構,把讀請求路由到從數據庫節(jié)點上,實現讀寫分離,從而大大提高應用的吞吐量。
通常,我們在 Spring Boot 中只會用到一個數據源,即通過 spring.datasource 進行配置。前文 《在 Spring Boot 中配置和使用多個數據源》 介紹了一種在 Spring Boot 中定義、使用多個數據源的方式。但是這種方式對于實現 “讀寫分離” 的場景不太適合。首先,多個數據源都是通過 @Bean 定義的,當需要新增額外的從數據庫時需要改動代碼,非常不夠靈活。其次,在業(yè)務層中,如果需要根據讀、寫場景切換不同數據源的話只能手動進行。
對于 Spring Boot “讀寫分離” 架構下的的多數據源,我們需要實現如下需求:
- 可以通過配置文件新增數據庫(從庫),而不不需要修改代碼。
- 自動根據場景切換讀、寫數據源,對業(yè)務層是透明的。
幸運的是,Spring Jdbc 模塊類提供了一個 AbstractRoutingDataSource 抽象類可以實現我們的需求。
它本身也實現了 DataSource 接口,表示一個 “可路由” 的數據源。
核心的代碼如下:
public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean { // 維護的所有數據源 @Nullable private Map<Object, DataSource> resolvedDataSources; // 默認的數據源 @Nullable private DataSource resolvedDefaultDataSource; // 獲取 Jdbc 連接 @Override public Connection getConnection() throws SQLException { return determineTargetDataSource().getConnection(); } @Override public Connection getConnection(String username, String password) throws SQLException { return determineTargetDataSource().getConnection(username, password); } // 獲取目標數據源 protected DataSource determineTargetDataSource() { Assert.notNull(this.resolvedDataSources, "DataSource router not initialized"); // 調用 determineCurrentLookupKey() 抽象方法,獲取 resolvedDataSources 中定義的 key。 Object lookupKey = determineCurrentLookupKey(); DataSource dataSource = this.resolvedDataSources.get(lookupKey); if (dataSource == null && (this.lenientFallback || lookupKey == null)) { dataSource = this.resolvedDefaultDataSource; } if (dataSource == null) { throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]"); } return dataSource; } // 抽象方法,返回 resolvedDataSources 中定義的 key。需要自己實現 @Nullable protected abstract Object determineCurrentLookupKey(); }
核心代碼如上,它的工作原理一目了然。它在內部維護了一個 Map<Object, DataSource> 屬性,維護了多個數據源。
當嘗試從 AbstractRoutingDataSource 數據源獲取數據源連接對象 Connection 時,會調用 determineCurrentLookupKey() 方法得到一個 Key,然后從數據源 Map<Object, DataSource> 中獲取到真正的目標數據源,如果 Key 或者是目標數據源為 null 則使用默認的數據源。
得到目標數據數據源后,返回真正的 Jdbc 連接。這一切對于使用到 Jdbc 的組件(Repository、JdbcTemplate 等)來說都是透明的。
了解了 AbstractRoutingDataSource 后,我們來看看如何使用它來實現 “讀寫分離”。
二、實現思路
首先,創(chuàng)建自己的 AbstractRoutingDataSource 實現類。把它的默認數據源 resolvedDefaultDataSource 設置為主庫,從庫則保存到 Map<Object, DataSource> resolvedDataSources 中。
在 Spring Boot 應用中通常使用 @Transactional 注解來開啟聲明式事務,它的默認傳播級別為 REQUIRED,也就是保證多個事務方法之間的相互調用都是在同一個事務中,使用的是同一個 Jdbc 連接。它還有一個 readOnly 屬性表示是否是只讀事務。
于是,我們可以通過 AOP 技術,在事務方法執(zhí)行之前,先獲取到方法上的 @Transactional 注解從而判斷是讀、還是寫業(yè)務。并且把 “讀寫狀態(tài)” 存儲到線程上下文(ThreadLocal)中!
在 AbstractRoutingDataSource 的 determineCurrentLookupKey 方法中,我們就可以根據當前線程上下文中的 “讀寫狀態(tài)” 判斷當前是否是只讀業(yè)務,如果是,則返回從庫 resolvedDataSources 中的 Key,反之則返回 null 表示使用默認數據源也就是主庫。
三、初始化數據庫
首先,在本地創(chuàng)建 4 個不同名稱的數據庫,用于模擬 “MYSQL 主從” 架構。
-- 主庫 CREATE DATABASE `demo_master` CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_general_ci'; -- 從庫 CREATE DATABASE `demo_slave1` CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_general_ci'; -- 從庫 CREATE DATABASE `demo_slave2` CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_general_ci'; -- 從庫 CREATE DATABASE `demo_slave3` CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_general_ci';
如上,創(chuàng)建了 4 個數據庫。1 個主庫,3 個從庫。它們本質上毫無關系,并不是真正意義上的主從架構,這里只是為了方便演示。
接著,在這 4 個數據庫下依次執(zhí)行如下 SQL 創(chuàng)建一張名為 test 的表。
該表只有 2 個字段,1 個是 id 表示主鍵,一個是 name 表示名稱。
CREATE TABLE `test` ( `id` int NOT NULL COMMENT 'ID', `name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '名稱', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
最后,初始化數據。往不同的數據庫插入對應的記錄。
INSERT INTO `demo_master`.`test` (`id`, `name`) VALUES (1, 'master'); INSERT INTO `demo_slave1`.`test` (`id`, `name`) VALUES (1, 'slave1'); INSERT INTO `demo_slave2`.`test` (`id`, `name`) VALUES (1, 'slave2'); INSERT INTO `demo_slave3`.`test` (`id`, `name`) VALUES (1, 'slave3');
不同數據庫節(jié)點下 test 表中的 name 字段不同,用于區(qū)別不同的數據庫節(jié)點。
四、創(chuàng)建應用
創(chuàng)建 Spring Boot 應用,添加 spring-boot-starter-jdbc 和 mysql-connector-j (MYSQL 驅動)依賴:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> </dependency>
五、配置定義
我們需要在 application.yaml 中定義上面創(chuàng)建好的所有主、從數據庫。
app: datasource: master: # 唯一主庫 jdbcUrl: jdbc:mysql://127.0.0.1:3306/demo_master?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2b8&allowMultiQueries=true username: root password: root slave: # 多個從庫 slave1: jdbcUrl: jdbc:mysql://127.0.0.1:3306/demo_slave1?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2b8&allowMultiQueries=true username: root password: root slave2: jdbcUrl: jdbc:mysql://127.0.0.1:3306/demo_slave2?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2b8&allowMultiQueries=true username: root password: root slave3: jdbcUrl: jdbc:mysql://127.0.0.1:3306/demo_slave3?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2b8&allowMultiQueries=true username: root password: root
在 app.datasource.master 下配置了唯一的一個主庫,也就是寫庫。然后在 app.datasource.slave 下以 Map 形式配置了多個從庫(也就是讀庫),每個從庫使用自定義的名稱作為 Key。
數據源的實現使用的是默認的 HikariDataSource,并且數據源的配置是按照 HikariConfig 類定義的。也就是說,你可以根據 HikariConfig 的屬性在配置中添加額外的設置。
有了配置后,還需要定義對應的配置類,如下:
package cn.springdoc.demo.db; import java.util.Map; import java.util.Objects; import java.util.Properties; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.bind.ConstructorBinding; @ConfigurationProperties(prefix = "app.datasource") // 配置前綴 public class MasterSlaveDataSourceProperties { // 主庫 private final Properties master; // 從庫 private final Map<String, Properties> slave; @ConstructorBinding // 通過構造函數注入配置文件中的值 public MasterSlaveDataSourceProperties(Properties master, Map<String, Properties> slave) { super(); Objects.requireNonNull(master); Objects.requireNonNull(slave); this.master = master; this.slave = slave; } public Properties master() { return master; } public Map<String, Properties> slave() { return slave; } }
還需要在 main 類上使用 @EnableConfigurationProperties 注解來加載我們的配置類:
package cn.springdoc.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.EnableAspectJAutoProxy; import cn.springdoc.demo.db.MasterSlaveDataSourceProperties; @SpringBootApplication @EnableAspectJAutoProxy @EnableConfigurationProperties(value = {MasterSlaveDataSourceProperties.class}) // 指定要加載的配置類 public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }
這里還使用 @EnableAspectJAutoProxy 開啟了 AOP 的支持,后面會用到。
六、創(chuàng)建 MasterSlaveDataSourceMarker
創(chuàng)建一個 MasterSlaveDataSourceMarker 類,用于維護當前業(yè)務的 “讀寫狀態(tài)”。
package cn.springdoc.demo.db; public class MasterSlaveDataSourceMarker { private static final ThreadLocal<Boolean> flag = new ThreadLocal<Boolean>(); // 返回標記 public static Boolean get() { return flag.get(); } // 寫狀態(tài),標記為主庫 public static void master() { flag.set(Boolean.TRUE); } // 讀狀態(tài),標記為從庫 public static void slave() { flag.set(Boolean.FALSE); } // 清空標記 public static void clean() { flag.remove(); } }
通過 ThreadLocal<Boolean> 在當前線程中保存當前業(yè)務的讀寫狀態(tài)。
如果 get() 返回 null 或者 true 則表示非只讀,需要使用主庫。反之則表示只讀業(yè)務,使用從庫。
七、創(chuàng)建 MasterSlaveDataSourceAop
創(chuàng)建 MasterSlaveDataSourceAop 切面類,在事務方法開始之前執(zhí)行。
package cn.springdoc.demo.db; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @Aspect @Component @Order(Ordered.HIGHEST_PRECEDENCE) // 在事務開始之前執(zhí)行 public class MasterSlaveDataSourceAop { static final Logger log = LoggerFactory.getLogger(MasterSlaveDataSourceAop.class); @Pointcut(value = "@annotation(org.springframework.transaction.annotation.Transactional)") public void txMethod () {} @Around("txMethod()") public Object handle (ProceedingJoinPoint joinPoint) throws Throwable { // 獲取當前請求的主從標識 try { // 獲取事務方法上的注解 Transactional transactional = ((MethodSignature) joinPoint.getSignature()).getMethod().getAnnotation(Transactional.class); if (transactional != null && transactional.readOnly()) { log.info("標記為從庫"); MasterSlaveDataSourceMarker.slave(); // 只讀,從庫 } else { log.info("標記為主庫"); MasterSlaveDataSourceMarker.master(); // 可寫,主庫 } // 執(zhí)行業(yè)務方法 Object ret = joinPoint.proceed(); return ret; } catch (Throwable e) { throw e; } finally { MasterSlaveDataSourceMarker.clean(); } } }
首先,通過 @Order(Ordered.HIGHEST_PRECEDENCE) 注解保證它必須比聲明式事務 AOP 更先執(zhí)行。
該 AOP 會攔截所有聲明了 @Transactional 的方法,在執(zhí)行前從該注解獲取 readOnly 屬性從而判斷是否是只讀業(yè)務,并且在 MasterSlaveDataSourceMarker 標記。
八、創(chuàng)建 MasterSlaveDataSource
現在,創(chuàng)建 AbstractRoutingDataSource 的實現類 MasterSlaveDataSource:
package cn.springdoc.demo.db; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; public class MasterSlaveDataSource extends AbstractRoutingDataSource { static final Logger log = LoggerFactory.getLogger(MasterSlaveDataSource.class); // 從庫的 Key 列表 private List<Object> slaveKeys; // 從庫 key 列表的索引 private AtomicInteger index = new AtomicInteger(0); @Override protected Object determineCurrentLookupKey() { // 當前線程的主從標識 Boolean master = MasterSlaveDataSourceMarker.get(); if (master == null || master || this.slaveKeys.isEmpty()) { // 主庫,返回 null,使用默認數據源 log.info("數據庫路由:主庫"); return null; } // 從庫,從 slaveKeys 中選擇一個 Key int index = this.index.getAndIncrement() % this.slaveKeys.size(); if (this.index.get() > 9999999) { this.index.set(0); } Object key = slaveKeys.get(index); log.info("數據庫路由:從庫 = {}", key); return key; } public List<Object> getSlaveKeys() { return slaveKeys; } public void setSlaveKeys(List<Object> slaveKeys) { this.slaveKeys = slaveKeys; } }
其中,定義了一個 List<Object> slaveKeys 字段,用于存儲在配置文件中定義的所有從庫的 Key。
在 determineCurrentLookupKey 方法中,判斷當前業(yè)務的 “讀寫狀態(tài)”,如果是只讀則通過 AtomicInteger 原子類自增后從 slaveKeys 輪詢出一個從庫的 Key。反之則返回 null 使用主庫。
九、創(chuàng)建 MasterSlaveDataSourceConfiguration 配置類
最后,需要在 @Configuration 配置類中,創(chuàng)建 MasterSlaveDataSource 數據源 Bean。
package cn.springdoc.demo.db; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import java.util.Properties; import javax.sql.DataSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; @Configuration public class MasterSlaveDataSourceConfiguration { @Bean public DataSource dataSource(MasterSlaveDataSourceProperties properties) { MasterSlaveDataSource dataSource = new MasterSlaveDataSource(); // 主數據庫 dataSource.setDefaultTargetDataSource(new HikariDataSource(new HikariConfig(properties.master()))); // 從數據庫 Map<Object, Object> slaveDataSource = new HashMap<>(); // 從數據庫 Key dataSource.setSlaveKeys(new ArrayList<>()); for (Map.Entry<String,Properties> entry : properties.slave().entrySet()) { if (slaveDataSource.containsKey(entry.getKey())) { throw new IllegalArgumentException("存在同名的從數據庫定義:" + entry.getKey()); } slaveDataSource.put(entry.getKey(), new HikariDataSource(new HikariConfig(entry.getValue()))); dataSource.getSlaveKeys().add(entry.getKey()); } // 設置從庫 dataSource.setTargetDataSources(slaveDataSource); return dataSource; } }
首先,通過配置方法注入配置類,該類定義了配置文件中的主庫、從庫屬性。
使用 HikariDataSource 實例化唯一主庫數據源、和多個從庫數據源,并且設置到 MasterSlaveDataSource 對應的屬性中。
同時還存儲每個從庫的 Key,且該 Key 不允許重復。
十、測試
1、創(chuàng)建 TestService
創(chuàng)建用于測試的業(yè)務類。
package cn.springdoc.demo.service; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class TestService { final JdbcTemplate jdbcTemplate; public TestService(JdbcTemplate jdbcTemplate) { super(); this.jdbcTemplate = jdbcTemplate; } // 只讀 @Transactional(readOnly = true) public String read () { return this.jdbcTemplate.queryForObject("SELECT `name` FROM `test` WHERE id = 1;", String.class); } // 先讀,再寫 @Transactional public String write () { this.jdbcTemplate.update("UPDATE `test` SET `name` = ? WHERE id = 1;", "new name"); return this.read(); } }
通過構造函數注入 JdbcTemplate(spring jdbc 模塊自動配置的)。
Service 類中定義了 2 個方法。
- read():只讀業(yè)務,從表中檢索 name 字段返回。
- write:可寫業(yè)務,先修改表中的 name 字段值為: new name,然后再調用 read() 方法讀取修改后的結果、返回。
2、創(chuàng)建測試類
創(chuàng)建測試類,如下:
package cn.springdoc.demo.test; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import cn.springdoc.demo.service.TestService; @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) public class DemoApplicationTests { static final Logger log = LoggerFactory.getLogger(DemoApplicationTests.class); @Autowired TestService testService; @Test public void test() throws Exception { // 連續(xù)4次讀 log.info("read={}", this.testService.read()); log.info("read={}", this.testService.read()); log.info("read={}", this.testService.read()); log.info("read={}", this.testService.read()); // 寫 log.info("write={}", this.testService.write()); } }
在測試類方法中,連續(xù)調用 4 次 TestService 的 read() 方法。由于這是一個只讀方法,按照我們的設定,它會在 3 個從庫之間輪詢使用。由于我們故意把三個從庫 test 表中 name 的字段值設置得不一樣,所以這里可以通過返回的結果看出來是否符合我們的預期。
最后調用了一次 write() 方法,按照設定會路由到主庫。先 UPDATE 修改數據,再調用 read() 讀取數據,雖然 read() 設置了 @Transactional(readOnly = true),但因為入口方法是 write(),所以 read() 還是會從主庫讀取數據(默認的事務傳播級別)。
執(zhí)行測試,輸出的日志如下:
[ main] c.s.demo.db.MasterSlaveDataSourceAop : 標記為從庫
[ main] c.s.demo.db.MasterSlaveDataSource : 數據庫路由:從庫 = slave1
[ main] c.s.demo.test.DemoApplicationTests : read=slave1
[ main] c.s.demo.db.MasterSlaveDataSourceAop : 標記為從庫
[ main] c.s.demo.db.MasterSlaveDataSource : 數據庫路由:從庫 = slave2
[ main] c.s.demo.test.DemoApplicationTests : read=slave2
[ main] c.s.demo.db.MasterSlaveDataSourceAop : 標記為從庫
[ main] c.s.demo.db.MasterSlaveDataSource : 數據庫路由:從庫 = slave3
[ main] c.s.demo.test.DemoApplicationTests : read=slave3
[ main] c.s.demo.db.MasterSlaveDataSourceAop : 標記為從庫
[ main] c.s.demo.db.MasterSlaveDataSource : 數據庫路由:從庫 = slave1
[ main] c.s.demo.test.DemoApplicationTests : read=slave1
[ main] c.s.demo.db.MasterSlaveDataSourceAop : 標記為主庫
[ main] c.s.demo.db.MasterSlaveDataSource : 數據庫路由:主庫
[ main] c.s.demo.test.DemoApplicationTests : write=new name
你可以看到,對于只讀業(yè)務。確實輪詢了三個不同的從庫,符合預期。最后的 write() 方法也成功地路由到了主庫,執(zhí)行了修改并且返回了修改后的結果。
十一總結
通過 AbstractRoutingDataSource 可以不使用任何第三方中間件就可以在 Spring Boot 中實現數據源 “讀寫分離”,這種方式需要在每個業(yè)務方法上通過 @Transactional 注解明確定義是讀還是寫。
到此這篇關于SpringBoot配置主從數據庫實現讀寫分離的文章就介紹到這了,更多相關SpringBoot 讀寫分離內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Java的Synchronized關鍵字學習指南(全面 & 詳細)
這篇文章主要給大家介紹了關于Java的Synchronized關鍵字的相關資料,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2021-03-03springboot項目打成war包部署到tomcat遇到的一些問題
這篇文章主要介紹了springboot項目打成war包部署到tomcat遇到的一些問題,需要的朋友可以參考下2017-06-06