分享一次 Sharding JDBC 億級數據分表真實經驗

前言

從入職以來寫了一年的業務代碼,突然接到來自領導的技術需求,說要給我們的借款、還款申請單分表。查看了一下借款表,只有幾千萬數據,再看還款表已經兩億多了,爲了提高數據查詢效率,降低數據庫的壓力。確實可以考慮分表了。另外...... 這是入職一年以來第一個非業務需求!

設計方案

開始編碼實現之前我們需要先做系統設計,主要是以下幾塊內容要跟領導開會對齊顆粒度!

分表數據庫基本信息

歷史數據同步

這一點是非常重要的,分了五十張表之後,不僅新的業務數據要根據分表策略落入分表,也要使用手段將存量數據以分表策略優先寫入 CashRepayApplySplit0${0..9},CashRepayApplySplit${10..49}

有一件比較尷尬的事情,我司早幾年已經做過一次還款申請單表的數據遷移,最原始的表名是 CashRepayInfo,四年前遷移後的表名是  CashRepayApply,也就是當前主業務表,目前我們的業務數據都是進行雙寫,先寫到 CashRepayApply,然後同步到 CashRepayInfo ,保證 CashRepayInfo 裏面是最全的數據,因爲雖然 CashRepayApply 是主業務表,但是很多歷史業務代碼、報表等查詢包括外部部分還是使用 CashRepayInfo 的。

這是我們當前的雙寫方式。但是有一部分最古老的歷史數據存在於 CashRepayInfo,不在 CashRepayApply。當時遷移的時候沒有完全同步,三者的關係如下圖

上圖可以知道,CashRepayInfo 是全量數據的表,包含 CashRepayApply ,我們的目標是將全量數據同步到分表。所以現在要實現的是

  1. 先把 CashRepayApply 缺少的那部分最早的 CashRepayInfo 數據同步到 CashRepayApplySplit

  2. 將 CashRepayApply 剩餘的數據全部同步到 CashRepayApplySplit

疑問解答

這裏可能會有一個疑問,既然 CashRepayInfo 是全量的數據,爲什麼不直接從 CashRepayInfo 同步到分表 CashRepayApplySplit 呢?這是因爲數據量有兩億多,我們不可能全部讓研發用代碼同步,那就只能交給 DBA 。但是 DBA 同步的話存在一個問題就是他需要兩邊表的字段結構一致,但是 CashRepayInfo 和 CashRepayApply 是存在字段差異的,字段名稱不同、字段個數也有略微差異。綜合考慮之後使用上述方案。

具體細節

我們把下面這張圖的兩個箭頭看做兩張表的自增數據,如果要實現上述第一點,就需要找到垂直的黑色虛線與 CashRepayInfo 交點的 id 是多少。 CashRepayInfo 表這個 id之前的數據就是我們需要使用代碼完全同步到 CashRepayApplySplit 的數據。

用這個 id 來找到對應的 CashRepayApply 表的主鍵 idCashRepayApply.id 以後的全部數據就是 DBA 需要幫我們同步的。

現有後臺查詢改造

目前現在公司的客服 / 運營後臺管理系統全都是用單表去直接 join 的,如果分表之後,肯定沒辦法再以原來的展示維度去 join 查詢了,那麼需要定一個方案來解決這個問題。通過與領導溝通,暫定的方案是老表 CashRepayApply 只保留兩~ 三年的數據,這部分數據可以像原來一樣不指定 memberId 去 join 查詢。

再歷史(三年以前)的數據必須通過 memberId 查詢,管理系統提供新的查詢頁面,必填條件 memberId

外部部門通知

全量數據同步到分表之後,最老的 CashRepayInfo 逐步等待下線、廢棄。所以要和其他部門比如風控、大數據部門溝通,告知他們後續報表等邏輯要用新的表 CashRepayApplySplit 查詢,現在可以開始逐步切換了。

DBA 操作過程中新產生業務數據同步方案

前面我們已經定好了同步步驟,第一步是研發自己同步一部分,第二步給到一個起始 id 給 DBADBA 從這個 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]。

但是考慮到以下三點:

最終我還是選擇了使用樸實無華的方式,只用一個線程去跑批同步數據,每次跑 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