原文:https://www.liaoxuefeng.com/article/00151054582348974482c20f7d8431ead5bc32b30354705000
在大型應用程序中,配置主從數據庫並使用讀寫分離是常見的設計模式。在Spring應用程序中,要實現讀寫分離,最好不要對現有代碼進行改動,而是在底層透明地支持。
Spring內置了一個AbstractRoutingDataSource
,它可以把多個數據源配置成一個Map,然后,根據不同的key返回不同的數據源。因為AbstractRoutingDataSource
也是一個DataSource接口,因此,應用程序可以先設置好key, 訪問數據庫的代碼就可以從AbstractRoutingDataSource
拿到對應的一個真實的數據源,從而訪問指定的數據庫。它的結構看起來像這樣:
┌───────────────────────────┐
│ controller │
│ set routing-key = "xxx" │
└───────────────────────────┘
│
▼
┌───────────────────────────┐
│ logic code │
└───────────────────────────┘
│
▼
┌───────────────────────────┐
│ routing datasource │
└───────────────────────────┘
│
┌─────────┴─────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ read-write │ │ read-only │
│ datasource │ │ datasource │
└─────────────┘ └─────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ │ │ │
│ Master DB │ │ Slave DB │
│ │ │ │
└─────────────┘ └─────────────┘
第一步:配置多數據源
首先,我們在SpringBoot中配置兩個數據源,其中第二個數據源是ro-datasource
:
spring: datasource: jdbc-url: jdbc:mysql://localhost/test username: rw password: rw_password driver-class-name: com.mysql.jdbc.Driver hikari: pool-name: HikariCP auto-commit: false ... ro-datasource: jdbc-url: jdbc:mysql://localhost/test username: ro password: ro_password driver-class-name: com.mysql.jdbc.Driver hikari: pool-name: HikariCP auto-commit: false ...
在開發環境下,沒有必要配置主從數據庫。只需要給數據庫設置兩個用戶,一個rw
具有讀寫權限,一個ro
只有SELECT權限,這樣就模擬了生產環境下對主從數據庫的讀寫分離。
在SpringBoot的配置代碼中,我們初始化兩個數據源:
@SpringBootApplication public class MySpringBootApplication { /** * Master data source. */ @Bean("masterDataSource") @ConfigurationProperties(prefix = "spring.datasource") DataSource masterDataSource() { logger.info("create master datasource..."); return DataSourceBuilder.create().build(); } /** * Slave (read only) data source. */ @Bean("slaveDataSource") @ConfigurationProperties(prefix = "spring.ro-datasource") DataSource slaveDataSource() { logger.info("create slave datasource..."); return DataSourceBuilder.create().build(); } ... }
第二步:編寫RoutingDataSource
然后,我們用Spring內置的RoutingDataSource,把兩個真實的數據源代理為一個動態數據源:
public class RoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return "masterDataSource"; } }
對這個RoutingDataSource
,需要在SpringBoot中配置好並設置為主數據源:
@SpringBootApplication public class MySpringBootApplication { @Bean @Primary DataSource primaryDataSource( @Autowired @Qualifier("masterDataSource") DataSource masterDataSource, @Autowired @Qualifier("slaveDataSource") DataSource slaveDataSource ) { logger.info("create routing datasource..."); Map<Object, Object> map = new HashMap<>(); map.put("masterDataSource", masterDataSource); map.put("slaveDataSource", slaveDataSource); RoutingDataSource routing = new RoutingDataSource(); routing.setTargetDataSources(map); routing.setDefaultTargetDataSource(masterDataSource); return routing; } ... }
現在,RoutingDataSource配置好了,但是,路由的選擇是寫死的,即永遠返回"masterDataSource"
,
現在問題來了:如何存儲動態選擇的key以及在哪設置key?
在Servlet的線程模型中,使用ThreadLocal存儲key最合適,因此,我們編寫一個RoutingDataSourceContext,來設置並動態存儲key:
public class RoutingDataSourceContext implements AutoCloseable { // holds data source key in thread local: static final ThreadLocal<String> threadLocalDataSourceKey = new ThreadLocal<>(); public static String getDataSourceRoutingKey() { String key = threadLocalDataSourceKey.get(); return key == null ? "masterDataSource" : key; } public RoutingDataSourceContext(String key) { threadLocalDataSourceKey.set(key); } public void close() { threadLocalDataSourceKey.remove(); } }
然后,修改RoutingDataSource,獲取key的代碼如下:
public class RoutingDataSource extends AbstractRoutingDataSource { protected Object determineCurrentLookupKey() { return RoutingDataSourceContext.getDataSourceRoutingKey(); } }
這樣,在某個地方,例如一個Controller的方法內部,就可以動態設置DataSource的Key:
@Controller public class MyController { @Get("/") public String index() { String key = "slaveDataSource"; try (RoutingDataSourceContext ctx = new RoutingDataSourceContext(key)) { // TODO: return "html... www.liaoxuefeng.com"; } } }
到此為止,我們已經成功實現了數據庫的動態路由訪問。
這個方法是可行的,但是,需要讀從數據庫的地方,就需要加上一大段try (RoutingDataSourceContext ctx = ...) {}
代碼,使用起來十分不便。有沒有方法可以簡化呢?
有!
我們仔細想想,Spring提供的聲明式事務管理,就只需要一個@Transactional()
注解,放在某個Java方法上,這個方法就自動具有了事務。
我們也可以編寫一個類似的@RoutingWith("slaveDataSource")
注解,放到某個Controller的方法上,這個方法內部就自動選擇了對應的數據源。代碼看起來應該像這樣:
@Controller public class MyController { @Get("/") @RoutingWith("slaveDataSource") public String index() { return "html... www.liaoxuefeng.com"; } }
這樣,完全不修改應用程序的邏輯,只在必要的地方加上注解,自動實現動態數據源切換,這個方法是最簡單的。
想要在應用程序中少寫代碼,我們就得多做一點底層工作:必須使用類似Spring實現聲明式事務的機制,即用AOP實現動態數據源切換。
實現這個功能也非常簡單,編寫一個RoutingAspect
,利用AspectJ實現一個Around
攔截:
@Aspect @Component public class RoutingAspect { @Around("@annotation(routingWith)") public Object routingWithDataSource(ProceedingJoinPoint joinPoint, RoutingWith routingWith) throws Throwable { String key = routingWith.value(); try (RoutingDataSourceContext ctx = new RoutingDataSourceContext(key)) { return joinPoint.proceed(); } } }
注意方法的第二個參數RoutingWith
是Spring傳入的注解實例,我們根據注解的value()
獲取配置的key。編譯前需要添加一個Maven依賴:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>
到此為止,我們就實現了用注解動態選擇數據源的功能。最后一步重構是用字符串常量替換散落在各處的"masterDataSource"
和"slaveDataSource"
。
使用限制
受Servlet線程模型的局限,動態數據源不能在一個請求內設定后再修改,也就是@RoutingWith
不能嵌套。此外,@RoutingWith
和@Transactional
混用時,要設定AOP的優先級。
本文代碼需要SpringBoot支持,JDK 1.8編譯並打開-parameters
編譯參數。