分享一次 Sharding JDBC 億級數據分表真實經驗
前言
從入職以來寫了一年的業務代碼,突然接到來自領導的技術需求,說要給我們的借款、還款申請單分表。查看了一下借款表,只有幾千萬數據,再看還款表已經兩億多了,爲了提高數據查詢效率,降低數據庫的壓力。確實可以考慮分表了。另外...... 這是入職一年以來第一個非業務需求!
設計方案
開始編碼實現之前我們需要先做系統設計,主要是以下幾塊內容要跟領導開會對齊顆粒度!
分表數據庫基本信息
-
分表數據庫使用新的實例,獨立數據源
-
是否需要分庫:不需要,只分表,
50
張表足矣 -
分表數據庫名
tcbiz_ins_cash_mas_split
(領導定的名字,無特殊含義) -
分表總數
50
張表 -
分表表名
CashRepayApplySplit0${0..9}
,CashRepayApplySplit${10..49}
(對,你沒看錯,我司數據庫表名是駝峯,字段也是駝峯) -
分表列
memberId
,分表策略memberId
截取後兩位% 50
,餘數小於10
左邊補0
,因爲我們表名是兩位數字後綴。 -
表結構,和原表結構完全一致,包括字段、索引等。哎等等,發現原表
uptTime
更新時間字段竟然沒有索引,這裏我們分表需要加上,注意主表不要動,幾個億數據的表不能隨便加索引。
歷史數據同步
這一點是非常重要的,分了五十張表之後,不僅新的業務數據要根據分表策略落入分表,也要使用手段將存量數據以分表策略優先寫入 CashRepayApplySplit0${0..9}
,CashRepayApplySplit${10..49}
。
有一件比較尷尬的事情,我司早幾年已經做過一次還款申請單表的數據遷移,最原始的表名是 CashRepayInfo
,四年前遷移後的表名是 CashRepayApply
,也就是當前主業務表,目前我們的業務數據都是進行雙寫,先寫到 CashRepayApply
,然後同步到 CashRepayInfo
,保證 CashRepayInfo
裏面是最全的數據,因爲雖然 CashRepayApply
是主業務表,但是很多歷史業務代碼、報表等查詢包括外部部分還是使用 CashRepayInfo
的。
這是我們當前的雙寫方式。但是有一部分最古老的歷史數據存在於 CashRepayInfo
,不在 CashRepayApply
。當時遷移的時候沒有完全同步,三者的關係如下圖
上圖可以知道,CashRepayInfo
是全量數據的表,包含 CashRepayApply
,我們的目標是將全量數據同步到分表。所以現在要實現的是
-
先把
CashRepayApply
缺少的那部分最早的CashRepayInfo
數據同步到CashRepayApplySplit
-
將
CashRepayApply
剩餘的數據全部同步到CashRepayApplySplit
疑問解答
這裏可能會有一個疑問,既然 CashRepayInfo
是全量的數據,爲什麼不直接從 CashRepayInfo
同步到分表 CashRepayApplySplit
呢?這是因爲數據量有兩億多,我們不可能全部讓研發用代碼同步,那就只能交給 DBA
。但是 DBA
同步的話存在一個問題就是他需要兩邊表的字段結構一致,但是 CashRepayInfo
和 CashRepayApply
是存在字段差異的,字段名稱不同、字段個數也有略微差異。綜合考慮之後使用上述方案。
具體細節
我們把下面這張圖的兩個箭頭看做兩張表的自增數據,如果要實現上述第一點,就需要找到垂直的黑色虛線與 CashRepayInfo
交點的 id
是多少。 CashRepayInfo
表這個 id
之前的數據就是我們需要使用代碼完全同步到 CashRepayApplySplit
的數據。
用這個 id
來找到對應的 CashRepayApply
表的主鍵 id
。CashRepayApply.id
以後的全部數據就是 DBA
需要幫我們同步的。
現有後臺查詢改造
目前現在公司的客服 / 運營後臺管理系統全都是用單表去直接 join
的,如果分表之後,肯定沒辦法再以原來的展示維度去 join
查詢了,那麼需要定一個方案來解決這個問題。通過與領導溝通,暫定的方案是老表 CashRepayApply
只保留兩~ 三年的數據,這部分數據可以像原來一樣不指定 memberId
去 join
查詢。
再歷史(三年以前)的數據必須通過 memberId
查詢,管理系統提供新的查詢頁面,必填條件 memberId
。
外部部門通知
全量數據同步到分表之後,最老的 CashRepayInfo
逐步等待下線、廢棄。所以要和其他部門比如風控、大數據部門溝通,告知他們後續報表等邏輯要用新的表 CashRepayApplySplit
查詢,現在可以開始逐步切換了。
DBA 操作過程中新產生業務數據同步方案
前面我們已經定好了同步步驟,第一步是研發自己同步一部分,第二步給到一個起始 id
給 DBA
,DBA
從這個 id
開始同步 CashRepayApply
表剩餘的數據到分表。這裏有個問題就是 DBA
的結束 id
是不確定的,因爲 CashRepayApply
這張表在 DBA
操作同步的過程中一直都有新的業務數據寫入。DBA
同學在開始操作之前必須要給定一個結束 id
給到同步工具,但是新業務的一直寫入導致 DBA
同步必定會漏一部分數據。
我們總不能爲了這個數據同步,停止用戶的還款對不對,所以我給 DBA
的方案是,讓 DBA
同步的最晚數據是 operatorDate - 5 Day
。篩選數據庫 uptTime < 操作時間減去五天
的數據,這樣得到一個確定性的結束 id
。當 DBA
操作結束後還會剩一部分剛剛操作過程中產生的最新的業務數據(下圖最右邊的虛線數據,我色弱不太認識顏色),那這部分數據依然是研發自己用代碼同步,等晚上 23:00
關閉還款之後研發用功能代碼同步。
這樣一來我們所有存量數據就按照創建時間排序,全量的同步到分表了。然後就可以開啓三寫的開關,完美完成這次數據庫分表遷移!
數據三寫同步
表的下線需要時間,其他部門改造業務切換分表也需要時間,所以在未來的一段時間內,我們仍然要保證 CashRepayInfo
數據的完整性,我們三張表要同步三寫,先寫 CashRepayApply
、再寫 CashRepayInfo
、再寫 CashRepayApplySplit
同步的時候要注意,由於分表的庫是不同的數據源,需要聲明指定的事務管理器。
@Transactional(propagation = Propagation.REQUIRES_NEW, transactionManager = "transactionManagerSplit")
另外,不要問我爲什麼在代碼中實時同步數據,而不用一些中間件?比如監聽 MySQL
的 binlog
去同步?因爲 DBA
告訴我不好實現(我嚴重懷疑是這個 DBA
小姐姐不想幫我弄...... 對,我司 DBA
是個小姐姐)那就只能研發自己來了。
同步數據的動態開關
注意我們需要提供一個動態開關去控制開啓和關閉新的業務數據從 CashRepayApply
同步到 CashRepayApplySplit
分表,也就是雙寫的開關,因爲需求上線之後肯定是先同步一階段古老的數據,再同步二階段 DBA
可以同步的數據,然後三階段研發同步新產生的部分業務數據,全部完畢之後開啓同這個開關完成無縫對接。
最終的目的除了完全完成數據同步之外,還有一點就是讓越早的數據越在表的前面。
定時任務掃描
由於我們是在代碼中去雙寫數據到分表,分表數據庫是新的實例,和原業務表的的操作不能控制在一個事務中,所以這就有潛在的隱患導致數據寫到 CashRepayApply
表,未成功 / 正確寫入到 CashRepayApplySplit
分表。儘管概率很小,我們也要預防。
所以前面在我們雙寫的時候一定要捕捉寫入到分表的異常,確保即使寫入分表失敗,也不能影響主業務流程。然後每天用定時任務掃描今日產生的還款申請單數據,CashRepayApply
和 CashRepayApplySplit
做比對,是否存在差異字段,如果有,推送告警出來研發排查。
艱難的 Demo 之路
因爲公司的項目比較老,shardingsphere
的版本也比較低,爲了緊貼社會潮流,這篇文章的 Demo
我是自己選了一個相對比較新的版本 SpringBoot3
去整合,然後呢發現官方有 sharding-jdbc-spring-boot-starter
我就拿過來用了,二話不說直接上了最新版本,我想着最新版本肯定能兼容 SpringBoot3
呀。
然而不出意外的話意外就出現了,啓動一直報錯,於是我去 github
上找 issue
。 第一次沒找到類似的報錯。於是我專門提了一個 issue
,第二天我又去看 issue
發現了有人提過的 # Is ShardingSphere 4.1.1 version compatible with Spring Boot 3.0.5 version?[1] 在這裏可以看到官方回覆說因爲 SpringBoot
版本的迭代導致他們爲了維護 starter
會消耗很多人力財力。所以關於 shardingsphere
的 spring-boot-starter
從 5.3.0
版本就不更新了。官方推薦使用 5.3.0
以上的版本去適配 SpringBoot3
,並且使用 ShardingSphereDriver
的方式去集成 shardingsphere
。官網也有配置示例。
於是我按照官方的文檔引入 Maven
座標,照着文檔配置,但中途還是遇到了很多問題...... 各種版本兼容問題,我想吐槽一下官網的配置文檔,下面會一一列出,這個 Demo
做的還是蠻艱難的!
分表策略 & 代碼實現
上一節已經和領導開會評審了我們的設計,領導給出讚賞的目光,設計考慮的很全面很周到!那麼接下來我們開始示例代碼整合 shardingsphere
完成數據庫分表。
各技術組件版本
SpringBoot 3.2.4 + JDK19 + MySQL8 + shardingsphere 5.4.1 + MyBatisPlus 3.5.5
Maven 依賴
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.19</version>
</dependency>
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core</artifactId>
<version>5.4.1</version>
</dependency>
<!--mybatisplus3-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
<!--缺少會報錯-->
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.3.3</version>
</dependency>
application.yml 配置文件
spring:
application:
name: sharding-jdbc-demo
datasource:
driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
url: jdbc:shardingsphere:classpath:sharding-config.yaml #分表配置文件
profiles:
active: default
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
sharding-config.yaml 文件
dataSources:
ds0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://localhost:3306/tcbiz_ins?characterEncoding=utf8&useAffectedRows=true&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 你的數據庫密碼
rules:
- !SHARDING
tables:
#表名
cash_repay_apply:
#數據節點,所有表名
actualDataNodes: ds0.cash_repay_apply_0$->{0..9},ds0.cash_repay_apply_$->{10..49}
#分表策略
tableStrategy:
#分表類型,單列作爲分表鍵
standard:
shardingColumn: member_id #分表列
shardingAlgorithmName: memberId-suffix-mode-50 #分表算法名稱,與下面對應
shardingAlgorithms:
memberId-suffix-mode-50:
type: INLINE
props:
#分片表達式,groovy 腳本
algorithm-expression: cash_repay_apply_$->{String.valueOf(Long.parseLong(String.valueOf(member_id).substring(String.valueOf(member_id).size() - 2)) % 50 ).padLeft(2,'0')}
props:
sql-show: true
到這其實配置就結束了,接下來我們寫個方法訪問數據庫
@Mapper
public interface CashRepayApplyMapper extends BaseMapper<CashRepayApply> {
@Select("select * from cash_repay_apply where member_id = #{memberId}")
List<CashRepayApply> testSharding(@Param("memberId") String memberId);
}
調用這個方法即可,shardingsphere
會自動幫我們創建分表數據源,路由對應的分表。
實測發現這裏 SpringBoot3.2.4
會報以下錯
java.lang.NoSuchMethodError: org.yaml.snakeyaml.representer.Representer: method 'void <init>()' not found
這是因爲 jar 包版本問題導致的,從網上看到一個很簡單的解決辦法,直接把報錯的這個類拷貝,然後粘貼到本項目中,包名要和它完全一致,然後添加一個無參構造方法覆蓋原 jar 包中的類
public Representer( ) {
super(new DumperOptions());
this.representers.put(null, new RepresentJavaBean());
}
再次啓動發現已經可以自動根據 member_id
路由到分表了。
踩坑之路
第一個坑是當前 SpringBoot 3.2.4
版本 Maven
需要引入
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.3.3</version>
</dependency>
第二個坑是 org.yaml.snakeyaml.representer.Representer
的無參構造不存在
第三個坑是 algorithm-expression
分片表達式的腳本,在使用 sharing-column % 50
的時候可能會報一個錯
java.sql.SQLException: Inline sharding algorithms expression `cash_repay_apply_${member_id % 50}` and sharding column `member_id` do not match.
這是因爲 groovy
腳本工具解析這個表達式報錯了,斷點打過去發現異常裏面給我們提示一句話
groovy.lang.MissingMethodException: No signature of method: java.lang.String.mod() is applicable for argument types: (Integer) values: [50]
Possible solutions: md5(), drop(int), drop(int), any(), find(), any(groovy.lang.Closure)
猜測可能是 groovy
的解析工具版本有什麼升級,去官方 github
下找到了別人提的 issue
官方讓用 Long
解析一下 member_id
的類型 。改成下面的寫法
algorithm-expression: cash_repay_apply_$->{Long.parseLong(member_id) % 50}
因爲我這裏數據庫裏面 member_id
是 varchar
,我們是後兩位對 50
取模,可能會存在小於 10
的數據,所以爲了映射表名要在前面補 0
。使用 groovy
腳本就是我上面的配置代碼。
Demo 源碼下載
Demo
已分享到 github
點擊去 GitHub 下載 [2]
研發同步數據的代碼
上面咱們已經說過了有一部分數據是需要研發自己去同步的,這部分同步數據的代碼應該如何寫呢。最早我是想用 ForkJoinPool
工具類實現的,因爲這種大數據量的分治太適合了。可以參考這篇文章 記錄一次發送千萬級別數量消息的定時任務優化 [3]。
但是考慮到以下三點:
-
我們覈銷業務交易項目比較古老了,使用的還是架構組提供的自研的定時任務,沒法做分片參數
-
核心交易服務不能出現差錯,儘量不要在這個項目中過多使用線程池,消耗
CPU
資源 -
儘量讓產生的數據落入到分表時,創建時間早的分佈在表的前面
最終我還是選擇了使用樸實無華的方式,只用一個線程去跑批同步數據,每次跑 500
條,依次循環往下跑,直到結束。
/**
* 同步數據示例代碼
*/
public void dataHandle(Long startId, Long endId) throws AppBizException {
log.info("CashRepayApplyServiceImpl#dataHandle start startId-{},endId-{}", startId, endId);
if (endId <= 0L) {
endId = null;
} else if (startId > endId) {
throw new AppBizException(ErrorCodeEnum.PARAM_CHECK_ERROR.getCode(), "起始id不能大於結束id");
}
//查詢條件
QueryCashRepayInfoCondition condition = new QueryCashRepayInfoCondition();
condition.setGteId(startId);
condition.setLteId(endId);
condition.setOrders("id+");//id正序排序
List<CashRepayInfo> infoList = cashRepayInfoDao.query(condition, 0, -1);//公司內部持久層框架,最多查詢條數 500
long lastId; //結束 id
while (CollectionUtil.isNotEmpty(infoList)) {
lastId = infoList.get(infoList.size() - 1).getId() + 1;//下次循環的起始id
infoList.forEach(history -> {
try {
if(StringUtil.isBlank(history.getMemberId()) || StringUtil.isBlank(history.getRepayNo())){
log.error("CashRepayApplyServiceImpl#dataHandle error memberId or repayNo is null id-{}",history.getId());
return;
}
//分表查詢條件
QueryCashRepayApplySplitCond splitCond = new QueryCashRepayApplySplitCond();
splitCond.setMemberId(history.getMemberId());
splitCond.setRepayApplyNo(history.getRepayNo());
CashRepayApplySplit exist = cashRepayApplySplitDao.getUnique(splitCond);
CashRepayApplySplit splitData = buildCashRepayApplySplit(history);
if (exist == null) {
cashRepayApplySplitDao.add(splitData);
} else {
splitData.setId(exist.getId());
cashRepayApplySplitDao.update(splitData);
}
} catch (Exception e) {
log.error("CashRepayApplyServiceImpl#dataHandle error id-{},repayNo-{}",history.getId(),history.getRepayNo());
throw new RuntimeException(e);
}
});
LOG.info("dataHandle finish startId-{},endId-{}",condition.getGteId(),endId);
//每 500 條查詢一次緩存是否要終止循環,因爲這是一個幾千萬數據的接口,爲了可控,要提供一個能近乎實時結束的功能
String redisCache = RedisCacheUtils.getRedisCache(CashApplyRedisKeyConsts.TERMINATE_SPLIT_SYNC_DATA);
if(StringUtil.isNotEmpty(redisCache)){
//說明我們人爲的要終止這次數據跑批處理
LOG.info("CashRepayApplyServiceImpl#dataHandle need terminate loop startId-{}",condition.getGteId());
break;
}
//更新起始 id,繼續循環跑批
condition.setGteId(lastId);
infoList = cashRepayInfoDao.query(condition, 0, -1);
}
}
組裝分表實體代碼
/**
* 將 CashRepayInfo 轉換成 CashRepayApplySplit 實體
* */
private CashRepayApplySplit buildCashRepayApplySplit(CashRepayInfo history) {
CashRepayApplySplit split = new CashRepayApplySplit();
CashRepayApply apply = cashRepayApplyDao.get(history.getRepayNo());
if(apply != null){
//CashRepayApply 表如果已經有的話直接用
BeanUtils.copyProperties(apply, split, "id");
return split;
}
//...省略把 CashRepayInfo 組裝成 CashRepayApplySplit 的代碼
return split;
}
結語
回想在進公司之前,面試的時候經常會有問到分庫分表的面試官,問的完全不知道怎麼回答。因爲沒有過實際的經驗,那時候總感覺分庫分表是個很難很難,很高大上的東西。不知道是不是我們公司的分庫分表太簡單了,實際經歷之後發現其實也就是看看官方文檔配一些配置,調用 API 即可。
其實自己親身經歷之後才發現這種需求難得根本就不是代碼,而是給到我們這樣一個需求之後,我們怎樣去設計方案。抽象到更大的一個團隊業務架構層面、甚至公司級別的業務架構層面,協調外部多部門,保證方案不影響現有業務,又能較好的完成需求。
最後,不管是不是我司分表業務簡單,但是至少咱也算有了億級數據分表經驗是不?
作者:暮色妖嬈丶
來源:https://juejin.cn/post/7371423114381557760
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/dMmA4Za9VhuUYiBloCDdIw