SaaS 系統架構,Spring Boot 動態數據源實現!
這段時候在準備從零開始做一套SaaS
系統,之前的經驗都是開發單資料庫系統並沒有接觸過SaaS系統,所以接到這個任務的時候也有也些頭疼,不過辦法部比困難多,難得的機會。
在網上找了很多關於SaaS
的資料,看完後使我受益匪淺,寫文章之前也一直在關注SaaS
系統的開發,通過幾天的探索也有一些方向。
多租戶系統首先要解決的問題就是如何組織租戶的數據問題
,通常情況有三種解決方案:
按數據的隔離級別依次為:
- 一個租戶一個資料庫實例(資料庫級)
- 一個租戶一個
Schema
(Schema) - 每個租戶都存儲在一個資料庫 (行級)
以上三種數據組織方案網上都有一些介紹,就不多啰嗦了。理解三種隔離模式後,起初覺得還是蠻簡單的真正開始實施的時候困難不少。
租戶標識介面
定義一個TenantInfo
來標識租戶資訊,關於獲取當前租戶的方式,後面會再提到。
public interface TenantInfo {
/**
* 獲取租戶id
* @return
*/
Long getId();
/**
* 租戶數據模式
* @return
*/
Integer getSchema();
/**
* 租戶資料庫資訊
* @return
*/
TenantDatabase getDatabase();
/**
* 獲取當前租戶資訊
* @return
*/
static Optional<TenantInfo> current(){
return Optional.ofNullable(
TenantInfoHolder.get()
);
}
}
DataSource 路由
以前開發的系統基本都是一個DataSource
,但是切換為多租戶後我暫時分了兩種數據源:
- 租戶數據源(TenantDataSource)
- 系統數據源(SystemDataSource)
起初我的設想是使用Schema級
但是由於是使用的Mysql
中的Schema
和Database
是差不多的概念,所以後來的實現是基於資料庫級
的。使用資料庫級
的因為是系統是基於企業級用戶的,數據都比較重要,企業客戶很看重數據安全性方面的問題。
下面來一步步的解決動態數據源的問題。
DataSource 枚舉
public enum DataSourceType {
/**
* 系統數據源
*/
SYSTEM,
/**
* 多租戶數據源
*/
TENANT,
}
DataSource 註解
定義DataSourceType
枚舉後,然後定義一個DataSource
註解,名稱可以隨意,一時沒想到好名稱,大家看的時候不要跟javax.sql.DataSource
類混淆了:
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface DataSource {
/**
* 數據源key
* @return
*/
com.csbaic.datasource.core.DataSourceType value() default com.csbaic.datasource.core.DataSourceType.SYSTEM;
}
處理 SpringBoot 自動裝配的 DataSource
如果你熟悉SpringBoot
,應該知道有一個DataSourceAutoConfiguration
配置會自動創建一個javax.sql.DataSource
,由於在多租戶環境下隨時都有可能要切換數據源,所以需要將自動裝配的javax.sql.DataSource
替換掉:
@Slf4j
public class DataSourceBeanPostProcessor implements BeanPostProcessor {
@Autowired
private ObjectProvider<RoutingDataSourceProperties> dataSourceProperties;
@Autowired
private ObjectProvider<TenantDataSourceFactory> factory;
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if(bean instanceof DataSource){
log.debug("process DataSource: {}", bean.getClass().getName());
return new RoutingDataSource((DataSource) bean, factory, dataSourceProperties);
}
return bean;
}
}
基於BeanPostProcessor
的處理,將自動裝配的數據源替換成RoutingDataSource
,關於RoutingDataSource
後面會再提到。這樣可將自動裝配的數據源直接作為系統數據源
其他需要使用數據源的地方不用特殊處理,也不需要在每個服務中排除DataSourceAutoConfiguration
的自動裝配。
使用 ThreadLocal 保存數據源類型
數據源的切換是根據前面提到的數據源類型枚舉DataSourceType
來的,當需要切換不到的數據源時將對應的數據源類型設置進ThreadLocal
中:
public class DataSourceHolder {
private static final ThreadLocal<Stack<DataSourceType>> datasources = new ThreadLocal<>();
/**
* 獲取當前執行緒數據源
* @return
*/
public static DataSourceType get(){
Stack<DataSourceType> stack = datasources.get();
return stack != null ? stack.peek() : null;
}
/**
* 設置當前執行緒數據源
* @param type
*/
public static void push(DataSourceType type){
Stack<DataSourceType> stack = datasources.get();
if(stack == null){
stack = new Stack<>();
datasources.set(stack);
}
stack.push(type);
}
/**
* 移除數據源配置
*/
public static void remove(){
Stack<DataSourceType> stack = datasources.get();
if(stack == null){
return;
}
stack.pop();
if(stack.isEmpty()){
datasources.remove();
}
}
}
在DataSourceHolder.datasources
是使用的Stack
而不是直接持有DataSource
這樣會稍微靈活一點,試想一下從方法A
中調用方法B
,A,B方法中各自要操作不同的數據源,當方法B
執行完成後,回到方法A
中,如果是在ThreadLocal
直接持有DataSource
的話,A方法
繼續操作就會對數據源產生不確定性。
AOP 切換數據源
要是在每個類方法都需要手機切換數據源,那也太不方便了,得益於AOP編程可以在調用需要切換數據源的方法的時候做一些手腳:
@Slf4j
@Aspect
public class DataSourceAspect {
@Pointcut(value = "(@within(com.csbaic.datasource.annotation.DataSource) || @annotation(com.csbaic.datasource.annotation.DataSource)) && within(com.csbaic..*)")
public void dataPointCut(){
}
@Before("dataPointCut()")
public void before(JoinPoint joinPoint){
Class<?> aClass = joinPoint.getTarget().getClass();
// 獲取類級別註解
DataSource classAnnotation = aClass.getAnnotation(DataSource.class);
if (classAnnotation != null){
com.csbaic.datasource.core.DataSourceType dataSource = classAnnotation.value();
log.info("this is datasource: "+ dataSource);
DataSourceHolder.push(dataSource);
}else {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
DataSource methodAnnotation = method.getAnnotation(DataSource.class);
if (methodAnnotation != null){
com.csbaic.datasource.core.DataSourceType dataSource = methodAnnotation.value();
log.info("this is dataSource: "+ dataSource);
DataSourceHolder.push(dataSource);
}
}
}
@After("dataPointCut()")
public void after(JoinPoint joinPoint){
log.info("執行完畢!");
DataSourceHolder.remove();
}
}
DataSourceAspect
很簡單在有com.csbaic.datasource.annotation.DataSource
註解的方法或者類中切換、還原使用DataSourceHolder
類切換數據源。
動態獲取、構造數據源
前面說了那麼多都是在為獲取、構建數據源做準備工作,一但數據源切換成功,業務服務獲取數據時就會使用javax.sql.DataSource
獲取資料庫連接,這裡就要說到RoutingDataSource
了:
@Slf4j
public class RoutingDataSource extends AbstractDataSource {
/**
* 已保存的DataSource
*/
private final DataSource systemDataSource;
/**
* 租戶數據源工廠
*/
private final ObjectProvider<TenantDataSourceFactory> factory;
/**
* 解析數據源
* @return
*/
protected DataSource resolveDataSource(){
DataSourceType type = DataSourceHolder.get();
RoutingDataSourceProperties pros = properties.getIfAvailable();
TenantDataSourceFactory tenantDataSourceFactory = factory.getIfAvailable();
if(tenantDataSourceFactory == null){
throw new DataSourceLookupFailureException("租戶數據源不正確");
}
if(pros == null){
throw new DataSourceLookupFailureException("數據源屬性不正確");
}
if(type == null){
log.warn("沒有顯示的設置數據源,使用默認數據源:{}", pros.getDefaultType());
type = pros.getDefaultType();
}
log.warn("數據源類型:{}", type);
if(type == DataSourceType.SYSTEM){
return systemDataSource;
}else if(type == DataSourceType.TENANT){
return tenantDataSourceFactory.create();
}
throw new DataSourceLookupFailureException("解析數據源失敗");
}
}
在resolveDataSource
方法中,首先獲取數據源類型:
DataSourceType type = DataSourceHolder.get();
然後根據數據源類型獲取數據源:
if(type == DataSourceType.SYSTEM){
return systemDataSource;
}else if(type == DataSourceType.TENANT){
return tenantDataSourceFactory.create();
}
系統類型的數據源較簡單直接返回,在租戶類型的數據時就要作額外的操作,如果是資料庫級
的隔離模式就需要為每個租戶創建數據源,這裡封裝了一個TenantDataSourceFactory
來構建租戶數據源:
public interface TenantDataSourceFactory {
/**
* 構建一個數據源
* @return
*/
DataSource create();
/**
* 構建一個數據源
* @return
*/
DataSource create(TenantInfo info);
}
實現方面大致就是從系統數據源中獲取租戶的數據源配置資訊,然後構造一個javax.sql.DataSource
。
注意:租戶數據源一定要快取起來,每次都構建太浪費。。。
小結
經過上面的一系統配置後,相信切換數據已經可以實現了。業務程式碼不關心使用的數據源,後續切換成隔離模式也比較方便。但是呢,總覺得只支援一種隔離模式又不太好,隔離模式更高的模式也可以作為收費項的麻。。。
使用 Mybatis Plus 實現行級隔離模式
上前提到動態數據源都是基於資料庫級的,一個租戶一個資料庫消耗還是很大的,難達到SaaS的規模效應,一但租戶增多資料庫管理、運維都是成本。
比如有些試用用戶不一定用購買只是想試用,直接開個資料庫也麻煩,況且前期開發也麻煩的很,數據備份、還原、欄位修改都要花時間和人力的,所以能不能同時支援多種數據隔離模式呢?答案是肯定的,利益於Mybatis Plus
可的多租戶 SQL 解析器
以輕鬆實現,詳細文檔可參考:
多租戶 SQL 解析器://mp.baomidou.com/guide/tenant.html
只需要配置TenantSqlParser
和TenantHandler
就可以實現行級的數據隔離模式:
public class RowTenantHandler implements TenantHandler {
@Override
public Expression getTenantId(boolean where) {
TenantInfo tenantInfo = TenantInfo.current().orElse(null);
if(tenantInfo == null){
throw new IllegalStateException("No tenant");
}
return new LongValue(tenantInfo.getId());
}
@Override
public String getTenantIdColumn() {
return TenantConts.TENANT_COLUMN_NAME;
}
@Override
public boolean doTableFilter(String tableName) {
TenantInfo tenantInfo = TenantInfo.current().orElse(null);
//忽略系統表或者沒有解析到租戶id,直接過濾
return tenantInfo == null || tableName.startsWith(SystemInfo.SYS_TABLE_PREFIX);
}
}
回想一下上面使用的TenantDataSourceFactory
介面,對於行級的隔離模式,構造不同的數據源就可以了。
如何解析當前租戶資訊?
多租戶環境下,對於每一個http請求可能是對系統數據或者租戶數據的操作,如何區分租戶也是個問題。
以下列舉幾種解析租戶的方式:
- 系統為每個用戶生成一個二級域名如:
tenant-{id}.csbaic.com
業務系統使用Host
、Origin
、X-Forwarded-Host
等請求頭按指定的模式解析租戶 - 前端攜帶租戶id參數如:
www.csbaic.com?tenantId=xxx
- 根據請求uri路徑獲取如:
www.csbaic.com/api/{tenantId}
- 解析前端傳遞的token,獲取租戶資訊
- 租戶自定義域名解析,有些功能租戶可以綁定自己的域名
解析方式現在大概只知道這些,如果有好的方案歡迎大家補充。為了以為擴展方便定義一個TenantResolver
介面:
/**
* 解析租戶
*/
public interface TenantResolver {
/**
* 從請求中解析租戶資訊
* @param request 當前請求
* @return
*/
Long resolve(HttpServletRequest request);
}
然後可以將所有的解析方式都聚合起來統一處理:
/**
*
* @param domainMapper
* @return
*/
@Bean
public TenantResolver tenantConsoleTenantResolver(TenantDomainMapper domainMapper, ITokenService tokenService){
return new CompositeTenantResolver(
new SysDomainTenantResolver(),
new RequestHeaderTenantResolver(),
new RequestQueryTenantResolver(),
new TokenTenantResolver(tokenService),
new CustomDomainTenantResolver(domainMapper)
);
}
最後再定義一個Filter
來調用解析器,解析租戶:
public class UaaTenantServiceFilter implements Filter {
private final TenantInfoService tenantInfoService;
public UaaTenantServiceFilter(TenantInfoService tenantInfoService) {
this.tenantInfoService = tenantInfoService;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
//從request解析租戶資訊
try{
TenantInfo tenantInfo = tenantInfoService.getTenantInfo((HttpServletRequest) request);
TenantInfoHolder.set(tenantInfo);
chain.doFilter(request,response);
}finally {
TenantInfoHolder.remove();
}
}
}
TenantInfoService
是獲取租戶資訊的介面,內部還是通過TenantResolver
來解析租戶Id,然後通過id從系統資料庫獲取當前租戶的資訊。
總結
解決完動態數據源、租戶資訊獲取兩個問題後,只是一小步,後續還有很多問題需要處理如:系統許可權和租戶許可權、統一登陸和鑒權、數據統計等等。。。,相信這些問題都會解決的,後續再來分享。
推薦閱讀
- 十分鐘入門RocketMQ
- Spring Boot 構建多租戶 SaaS 平台核心技術指南
- Redis 快取和MySQL數據一致性方案詳解
- Nginx 限流配置
- 深入探秘 Netty、Kafka中的零拷貝技術!
學習資料分享
12 套 微服務、Spring Boot、Spring Cloud 核心技術資料,這是部分資料目錄:
- Spring Security 認證與授權
- Spring Boot 項目實戰(中小型互聯網公司後台服務架構與運維架構)
- Spring Boot 項目實戰(企業許可權管理項目))
- Spring Cloud 微服務架構項目實戰(分散式事務解決方案)
- …
公眾號後台回復arch028
獲取資料::