最近在做SaaS應用,數據庫采用了單實例多schema的架構(詳見參考資料1),每個租戶有一個獨立的schema,同時整個數據源有一個共享的schema,因此需要解決動態增刪、切換數據源的問題。
在網上搜了很多文章后,很多都是講主從數據源配置,或都是在應用啟動前已經確定好數據源配置的,甚少講在不停機的情況如何動態加載數據源,所以寫下這篇文章,以供參考。
使用到的技術
- Java8
- Spring + SpringMVC + MyBatis
- Druid連接池
- Lombok
- (以上技術并不影響思路實現,只是為了方便瀏覽以下代碼片段)
思路
當一個請求進來的時候,判斷當前用戶所屬租戶,并根據租戶信息切換至相應數據源,然后進行后續的業務操作。
代碼實現
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
TenantConfigEntity(租戶信息) @EqualsAndHashCode (callSuper = false ) @Data @FieldDefaults (level = AccessLevel.PRIVATE) public class TenantConfigEntity { /** * 租戶id **/ Integer tenantId; /** * 租戶名稱 **/ String tenantName; /** * 租戶名稱key **/ String tenantKey; /** * 數據庫url **/ String dbUrl; /** * 數據庫用戶名 **/ String dbUser; /** * 數據庫密碼 **/ String dbPassword; /** * 數據庫public_key **/ String dbPublicKey; } DataSourceUtil(輔助工具類,非必要) public class DataSourceUtil { private static final String DATA_SOURCE_BEAN_KEY_SUFFIX = "_data_source" ; private static final String JDBC_URL_ARGS = "?useUnicode=true&characterEncoding=UTF-8&useOldAliasMetadataBehavior=true&zeroDateTimeBehavior=convertToNull" ; private static final String CONNECTION_PROPERTIES = "config.decrypt=true;config.decrypt.key=" ; /** * 拼接數據源的spring bean key */ public static String getDataSourceBeanKey(String tenantKey) { if (!StringUtils.hasText(tenantKey)) { return null ; } return tenantKey + DATA_SOURCE_BEAN_KEY_SUFFIX; } /** * 拼接完整的JDBC URL */ public static String getJDBCUrl(String baseUrl) { if (!StringUtils.hasText(baseUrl)) { return null ; } return baseUrl + JDBC_URL_ARGS; } /** * 拼接完整的Druid連接屬性 */ public static String getConnectionProperties(String publicKey) { if (!StringUtils.hasText(publicKey)) { return null ; } return CONNECTION_PROPERTIES + publicKey; } } |
DataSourceContextHolder
使用 ThreadLocal 保存當前線程的數據源key name,并實現set、get、clear方法;
1
2
3
4
5
6
7
8
9
10
11
12
|
public class DataSourceContextHolder { private static final ThreadLocal<String> dataSourceKey = new InheritableThreadLocal<>(); public static void setDataSourceKey(String tenantKey) { dataSourceKey.set(tenantKey); } public static String getDataSourceKey() { return dataSourceKey.get(); } public static void clearDataSourceKey() { dataSourceKey.remove(); } } |
DynamicDataSource(重點)
繼承 AbstractRoutingDataSource (建議閱讀其源碼,了解動態切換數據源的過程),實現動態選擇數據源;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
public class DynamicDataSource extends AbstractRoutingDataSource { @Autowired private ApplicationContext applicationContext; @Lazy @Autowired private DynamicDataSourceSummoner summoner; @Lazy @Autowired private TenantConfigDAO tenantConfigDAO; @Override protected String determineCurrentLookupKey() { String tenantKey = DataSourceContextHolder.getDataSourceKey(); return DataSourceUtil.getDataSourceBeanKey(tenantKey); } @Override protected DataSource determineTargetDataSource() { String tenantKey = DataSourceContextHolder.getDataSourceKey(); String beanKey = DataSourceUtil.getDataSourceBeanKey(tenantKey); if (!StringUtils.hasText(tenantKey) || applicationContext.containsBean(beanKey)) { return super .determineTargetDataSource(); } if (tenantConfigDAO.exist(tenantKey)) { summoner.registerDynamicDataSources(); } return super .determineTargetDataSource(); } } |
DynamicDataSourceSummoner(重點中的重點)
從數據庫加載數據源信息,并動態組裝和注冊spring bean,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
|
@Slf4j @Component public class DynamicDataSourceSummoner implements ApplicationListener<ContextRefreshedEvent> { // 跟spring-data-source.xml的默認數據源id保持一致 private static final String DEFAULT_DATA_SOURCE_BEAN_KEY = "defaultDataSource" ; @Autowired private ConfigurableApplicationContext applicationContext; @Autowired private DynamicDataSource dynamicDataSource; @Autowired private TenantConfigDAO tenantConfigDAO; private static boolean loaded = false ; /** * Spring加載完成后執行 */ @Override public void onApplicationEvent(ContextRefreshedEvent event) { // 防止重復執行 if (!loaded) { loaded = true ; try { registerDynamicDataSources(); } catch (Exception e) { log.error( "數據源初始化失敗, Exception:" , e); } } } /** * 從數據庫讀取租戶的DB配置,并動態注入Spring容器 */ public void registerDynamicDataSources() { // 獲取所有租戶的DB配置 List<TenantConfigEntity> tenantConfigEntities = tenantConfigDAO.listAll(); if (CollectionUtils.isEmpty(tenantConfigEntities)) { throw new IllegalStateException( "應用程序初始化失敗,請先配置數據源" ); } // 把數據源bean注冊到容器中 addDataSourceBeans(tenantConfigEntities); } /** * 根據DataSource創建bean并注冊到容器中 */ private void addDataSourceBeans(List<TenantConfigEntity> tenantConfigEntities) { Map<Object, Object> targetDataSources = Maps.newLinkedHashMap(); DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory(); for (TenantConfigEntity entity : tenantConfigEntities) { String beanKey = DataSourceUtil.getDataSourceBeanKey(entity.getTenantKey()); // 如果該數據源已經在spring里面注冊過,則不重新注冊 if (applicationContext.containsBean(beanKey)) { DruidDataSource existsDataSource = applicationContext.getBean(beanKey, DruidDataSource. class ); if (isSameDataSource(existsDataSource, entity)) { continue ; } } // 組裝bean AbstractBeanDefinition beanDefinition = getBeanDefinition(entity, beanKey); // 注冊bean beanFactory.registerBeanDefinition(beanKey, beanDefinition); // 放入map中,注意一定是剛才創建bean對象 targetDataSources.put(beanKey, applicationContext.getBean(beanKey)); } // 將創建的map對象set到 targetDataSources; dynamicDataSource.setTargetDataSources(targetDataSources); // 必須執行此操作,才會重新初始化AbstractRoutingDataSource 中的 resolvedDataSources,也只有這樣,動態切換才會起效 dynamicDataSource.afterPropertiesSet(); } /** * 組裝數據源spring bean */ private AbstractBeanDefinition getBeanDefinition(TenantConfigEntity entity, String beanKey) { BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(DruidDataSource. class ); builder.getBeanDefinition().setAttribute( "id" , beanKey); // 其他配置繼承defaultDataSource builder.setParentName(DEFAULT_DATA_SOURCE_BEAN_KEY); builder.setInitMethodName( "init" ); builder.setDestroyMethodName( "close" ); builder.addPropertyValue( "name" , beanKey); builder.addPropertyValue( "url" , DataSourceUtil.getJDBCUrl(entity.getDbUrl())); builder.addPropertyValue( "username" , entity.getDbUser()); builder.addPropertyValue( "password" , entity.getDbPassword()); builder.addPropertyValue( "connectionProperties" , DataSourceUtil.getConnectionProperties(entity.getDbPublicKey())); return builder.getBeanDefinition(); } /** * 判斷Spring容器里面的DataSource與數據庫的DataSource信息是否一致 * 備注:這里沒有判斷public_key,因為另外三個信息基本可以確定唯一了 */ private boolean isSameDataSource(DruidDataSource existsDataSource, TenantConfigEntity entity) { boolean sameUrl = Objects.equals(existsDataSource.getUrl(), DataSourceUtil.getJDBCUrl(entity.getDbUrl())); if (!sameUrl) { return false ; } boolean sameUser = Objects.equals(existsDataSource.getUsername(), entity.getDbUser()); if (!sameUser) { return false ; } try { String decryptPassword = ConfigTools.decrypt(entity.getDbPublicKey(), entity.getDbPassword()); return Objects.equals(existsDataSource.getPassword(), decryptPassword); } catch (Exception e) { log.error( "數據源密碼校驗失敗,Exception:{}" , e); return false ; } } } |
spring-data-source.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
|
<!-- 引入jdbc配置文件 --> <context:property-placeholder location= "classpath:data.properties" ignore-unresolvable= "true" /> <!-- 公共(默認)數據源 --> <bean id= "defaultDataSource" class = "com.alibaba.druid.pool.DruidDataSource" init-method= "init" destroy-method= "close" > <!-- 基本屬性 url、user、password --> <property name= "url" value= "${ds.jdbcUrl}" /> <property name= "username" value= "${ds.user}" /> <property name= "password" value= "${ds.password}" /> <!-- 配置初始化大小、最小、最大 --> <property name= "initialSize" value= "5" /> <property name= "minIdle" value= "2" /> <property name= "maxActive" value= "10" /> <!-- 配置獲取連接等待超時的時間,單位是毫秒 --> <property name= "maxWait" value= "1000" /> <!-- 配置間隔多久才進行一次檢測,檢測需要關閉的空閑連接,單位是毫秒 --> <property name= "timeBetweenEvictionRunsMillis" value= "5000" /> <!-- 配置一個連接在池中最小生存的時間,單位是毫秒 --> <property name= "minEvictableIdleTimeMillis" value= "240000" /> <property name= "validationQuery" value= "SELECT 1" /> <!--單位:秒,檢測連接是否有效的超時時間--> <property name= "validationQueryTimeout" value= "60" /> <!--建議配置為 true ,不影響性能,并且保證安全性。申請連接的時候檢測,如果空閑時間大于timeBetweenEvictionRunsMillis,執行validationQuery檢測連接是否有效--> <property name= "testWhileIdle" value= "true" /> <!--申請連接時執行validationQuery檢測連接是否有效,做了這個配置會降低性能。--> <property name= "testOnBorrow" value= "true" /> <!--歸還連接時執行validationQuery檢測連接是否有效,做了這個配置會降低性能。--> <property name= "testOnReturn" value= "false" /> <!--Config Filter--> <property name= "filters" value= "config" /> <property name= "connectionProperties" value= "config.decrypt=true;config.decrypt.key=${ds.publickey}" /> </bean> <!-- 事務管理器 --> <bean id= "txManager" class = "org.springframework.jdbc.datasource.DataSourceTransactionManager" > <property name= "dataSource" ref= "multipleDataSource" /> </bean> <!--多數據源--> <bean id= "multipleDataSource" class = "a.b.c.DynamicDataSource" > <property name= "defaultTargetDataSource" ref= "defaultDataSource" /> <property name= "targetDataSources" > <map> <entry key= "defaultDataSource" value-ref= "defaultDataSource" /> </map> </property> </bean> <!-- 注解事務管理器 --> <!--這里的order值必須大于DynamicDataSourceAspectAdvice的order值--> <tx:annotation-driven transaction-manager= "txManager" order= "2" /> <!-- 創建SqlSessionFactory,同時指定數據源 --> <bean id= "mainSqlSessionFactory" class = "org.mybatis.spring.SqlSessionFactoryBean" > <property name= "dataSource" ref= "multipleDataSource" /> </bean> <!-- DAO接口所在包名,Spring會自動查找其下的DAO --> <bean id= "mainSqlMapper" class = "org.mybatis.spring.mapper.MapperScannerConfigurer" > <property name= "sqlSessionFactoryBeanName" value= "mainSqlSessionFactory" /> <property name= "basePackage" value= "a.b.c.*.dao" /> </bean> <bean id= "defaultSqlSessionFactory" class = "org.mybatis.spring.SqlSessionFactoryBean" > <property name= "dataSource" ref= "defaultDataSource" /> </bean> <bean id= "defaultSqlMapper" class = "org.mybatis.spring.mapper.MapperScannerConfigurer" > <property name= "sqlSessionFactoryBeanName" value= "defaultSqlSessionFactory" /> <property name= "basePackage" value= "a.b.c.base.dal.dao" /> </bean> <!-- 其他配置省略 --> |
DynamicDataSourceAspectAdvice
利用AOP自動切換數據源,僅供參考;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
@Slf4j @Aspect @Component @Order ( 1 ) // 請注意:這里order一定要小于tx:annotation-driven的order,即先執行DynamicDataSourceAspectAdvice切面,再執行事務切面,才能獲取到最終的數據源 @EnableAspectJAutoProxy (proxyTargetClass = true ) public class DynamicDataSourceAspectAdvice { @Around ( "execution(* a.b.c.*.controller.*.*(..))" ) public Object doAround(ProceedingJoinPoint jp) throws Throwable { ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = sra.getRequest(); HttpServletResponse response = sra.getResponse(); String tenantKey = request.getHeader( "tenant" ); // 前端必須傳入tenant header, 否則返回400 if (!StringUtils.hasText(tenantKey)) { WebUtils.toHttp(response).sendError(HttpServletResponse.SC_BAD_REQUEST); return null ; } log.info( "當前租戶key:{}" , tenantKey); DataSourceContextHolder.setDataSourceKey(tenantKey); Object result = jp.proceed(); DataSourceContextHolder.clearDataSourceKey(); return result; } } |
總結
以上所述是小編給大家介紹的Spring動態注冊多數據源的實現方法,希望對大家有所幫助,如果大家有任何疑問請給我留言,小編會及時回復大家的。在此也非常感謝大家對服務器之家網站的支持!
原文鏈接:https://juejin.im/post/5a5e262d5188257328217036