國際財務系統基於ShardingSphere的資料分片和一主多從實踐

京東雲開發者發表於2023-02-16

作者:京東物流 張廣治

1 背景

傳統的將資料集中儲存至單一資料節點的解決方案,在效能和可用性方面已經難於滿足海量資料的場景,系統最大的瓶頸在於單個節點讀寫效能,許多的資源受到單機的限制,例如連線數、網路IO、磁碟IO等,從而導致它的併發能力不高,對於高併發的要求不滿足。

每到月初國際財務系統壓力巨大,因為月初有大量補全任務,重算、計算任務、賬單生成任務、推送整合等都要趕在月初1號完成,顯然我們需要一個支援高效能、高併發的方案來解決我們的問題。

2 我們的目標

  1. 支援每月接單量一億以上。
  2. 一億的單量補全,計算,生成賬單在24小時內完成(支援前面說的月初大資料量計算的場景)

3 資料分配規則

現實世界中,每一個資源都有其提供服務能力的上限,當某一個資源達到最大上限後就無法及時處理溢位的需求,這樣就需要使用多個資源同時提供服務來滿足大量的任務。當使用了多個資源來提供服務時,最為關鍵的是如何讓每一個資源比較均勻的承擔壓力,而不至於其中的某些資源壓力過大,所以分配規則就變得非常重要。

制定分配規則:要根據查詢和儲存的場景,一般按照型別、時間、城市、區域等作為分片鍵。

財務系統的租戶以業務線為單位,缺點為拆分的粒度太大,不能實現打散資料的目的,所以不適合做為分片鍵,事件定義作為分片鍵,缺點是非常不均勻,目前2C進口清關,一個事件,每月有一千多萬資料,鯤鵬的事件,每月單量很少,如果按照事件定義拆分,會導致資料極度傾斜。

目前最適合作為分片鍵的就是時間,因為系統中計算,賬單,彙總,都是基於時間的,所以時間非常適合做分片鍵,適合使用月、周、作為Range的週期。目前使用的就是時間分割槽,但只按照時間分割槽顯然已經不能滿足我們的需求了。

經過篩選,理論上最適合的分割槽鍵就剩下時間收付款物件了。

最終我們決定使用收付款物件分庫,時間作為表分割槽。

資料拆分前結構(圖一):

資料水平拆分後結構(圖二):

分配規則

(payer.toUpperCase()+"_"+payee.toUpperCase()).hashCode().abs()%128

收款物件大寫加分隔符加付款物件大寫,取HASH值的絕對值模分庫數量

重要:payer和payee字母統一大寫,因為大小寫不統一,會導致HASH值不一致,最終導致路由到不同的庫。

4 讀寫分離一主多從

4.1ShardingSphere對讀寫分離的解釋

對於同一時刻有大量併發讀操作和較少寫操作型別的資料來說,將資料庫拆分為主庫和從庫,主庫負責處理事務性的增刪改操作,從庫負責處理查詢操作,能夠有效的避免由資料更新導致的行鎖,使得整個系統的查詢效能得到極大的改善。

透過一主多從的配置方式,可以將查詢請求均勻的分散到多個資料副本,能夠進一步的提升系統的處理能力。 使用多主多從的方式,不但能夠提升系統的吞吐量,還能夠提升系統的可用性,可以達到在任何一個資料庫當機,甚至磁碟物理損壞的情況下仍然不影響系統的正常執行。

把資料量大的大表進行資料分片,其餘大量併發讀操作且寫入小的資料進行讀寫分離,如(圖三)

左側為主從結構,右側為資料分片

4.2 讀寫分離+資料分片實戰

當我們實際使用sharding進行讀寫分離+資料分片時遇到了一個很大的問題,官網文件中的實現方式只適合分庫和從庫在一起時的場景如(圖四)

而我們的場景為(圖三)所示,從庫和分庫時徹底分開的,參考官網的實現方法如下:

https://shardingsphere.apache.org/document/4.1.1/cn/manual/sharding-jdbc/configuration/config-spring-boot/#資料分片--讀寫分離

官網給出的讀寫分離+資料分片方案不能配置
spring.shardingsphere.sharding.default-data-source-name預設資料來源,如果配置了,所有讀操作將全部指向主庫,無法達到讀寫分離的目的。

當我們困擾在讀從庫的查詢會被輪詢到分庫中,我們實際的場景從庫和分庫是分離的,分庫中根本就不存在從庫中的表。此問題困擾了我近兩天的時間,我閱讀原始碼發現
spring.shardingsphere.sharding.default-data-source-name可以被賦值一個DataNodeGroup,不僅僅支援配置datasourceName,sharding原始碼如下圖:

由此
spring.shardingsphere.sharding.default-data-source-name配置為讀寫分離的groupname1,問題解決

從庫和分庫不在一起的場景下,讀寫分離+資料分配的配置如下:

#資料來源名稱
spring.shardingsphere.datasource.names= defaultmaster,ds0,ds1,ds2,ds3,ds4,ds5,ds6,ds7,ds8,ds9,ds10,ds11,ds12,ds13,ds14,ds15,ds16,ds17,ds18,ds19,ds20,ds21,ds22,ds23,ds24,ds25,ds26,ds27,ds28,ds29,ds30,ds31,slave0,slave1
#未配置分片規則的表將透過預設資料來源定位,注意值必須配置為讀寫分離的分組名稱groupname1
spring.shardingsphere.sharding.default-data-source-name=groupname1
#主庫
spring.shardingsphere.datasource.defaultmaster.jdbc-url=jdbc:mysql:
spring.shardingsphere.datasource.defaultmaster.type= com.zaxxer.hikari.HikariDataSource
spring.shardingsphere.datasource.defaultmaster.driver-class-name= com.mysql.jdbc.Driver
#分庫ds0
spring.shardingsphere.datasource.ds0.jdbc-url=jdbc:mysql:
spring.shardingsphere.datasource.ds0.type= com.zaxxer.hikari.HikariDataSource
spring.shardingsphere.datasource.ds0.driver-class-name= com.mysql.jdbc.Driver
#從庫slave0
spring.shardingsphere.datasource.slave0.jdbc-url=jdbc:mysql:
spring.shardingsphere.datasource.slave0.type= com.zaxxer.hikari.HikariDataSource
spring.shardingsphere.datasource.slave0.driver-class-name= com.mysql.jdbc.Driver
#從庫slave1
spring.shardingsphere.datasource.slave1.jdbc-url=jdbc:mysql:
spring.shardingsphere.datasource.slave1.type= com.zaxxer.hikari.HikariDataSource
spring.shardingsphere.datasource.slave1.driver-class-name= com.mysql.jdbc.Driver


#由資料來源名 + 表名組成,以小數點分隔。多個表以逗號分隔,支援inline表示式。預設表示使用已知資料來源與邏輯表名稱生成資料節點,用於廣播表(即每個庫中都需要一個同樣的表用於關聯查詢,多為字典表)或只分庫不分表且所有庫的表結構完全一致的情況
spring.shardingsphere.sharding.tables.incident_ar.actual-data-nodes=ds$->{0..127}.incident_ar
#行表示式分片策略 分庫策略,預設表示使用預設分庫策略
spring.shardingsphere.sharding.tables.incident_ar.database-strategy.inline.sharding-column= dept_no
#分片演算法行表示式,需符合groovy語法
spring.shardingsphere.sharding.tables.incident_ar.database-strategy.inline.algorithm-expression=ds$->{dept_no.toUpperCase().hashCode().abs() % 128}
#讀寫分離配置
spring.shardingsphere.sharding.master-slave-rules.groupname1.master-data-source-name=defaultmaster
spring.shardingsphere.sharding.master-slave-rules.groupname1.slave-data-source-names[0]=slave0
spring.shardingsphere.sharding.master-slave-rules.groupname1.slave-data-source-names[1]=slave1
spring.shardingsphere.sharding.master-slave-rules.groupname1.load-balance-algorithm-type=round_robin

可以看到讀操作可以被均勻的路由到slave0、slave1中,分片的讀會被分配到ds0,ds1中如下圖:

4.3 實現自己的讀寫分離負載均衡演算法

Sharding提供了SPI形式的介面
org.apache.shardingsphere.spi.masterslave.MasterSlaveLoadBalanceAlgorithm實現讀寫分離多個從的具體負載均衡規則,程式碼如下:

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import org.apache.shardingsphere.spi.masterslave.MasterSlaveLoadBalanceAlgorithm;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Properties;


@Component
@Getter
@Setter
@RequiredArgsConstructor
public final class LoadAlgorithm implements MasterSlaveLoadBalanceAlgorithm {


    private Properties properties = new Properties();


    @Override
    public String getType() {return "loadBalance";}


    @Override
    public String getDataSource(final String name, final String masterDataSourceName, final List<String> slaveDataSourceNames) {
        //自己的負載均衡規則
        return slaveDataSourceNames.get(0);
    

RoundRobinMasterSlaveLoadBalanceAlgorithm 實現為所有從輪詢負載
RandomMasterSlaveLoadBalanceAlgorithm 實現為所有從隨機負載均衡

4.4 關於某些場景下必須讀主庫的解決方案

某些場景比如分散式場景下寫入馬上讀取的場景,可以使用hint方式進行強制讀取主庫,Sharding原始碼使用ThreadLocal實現強制路由標記。

下面封裝了一個註解可以直接使用,程式碼如下:

@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface SeekMaster {
}


import lombok.extern.slf4j.Slf4j;
import org.apache.shardingsphere.api.hint.HintManager;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
/**
 * ShardingSphere >讀寫分離自定義註解>用於實現讀寫分離時>需要強制讀主庫的場景(註解實現類)
 *
 * @author zhangguangzhi1
 **/
@Slf4j
@Aspect
@Component
public class SeekMasterAnnotation {
    
    @Around("@annotation(seekMaster)")
    public Object doInterceptor(ProceedingJoinPoint joinPoint, SeekMaster seekMaster) throws Throwable {


        Object object = null;
        Throwable t = null;
        try {
            HintManager.getInstance().setMasterRouteOnly();
            log.info("強制查詢主庫");


            object = joinPoint.proceed();


        } catch (Throwable throwable) {
            t = throwable;
        } finally {


            HintManager.clear();


            if (t != null) {
                throw t;
            }
        }
        return object;
  

使用時方法上打SeekMaster註解即可,方法下的所有讀操作將自動路由到主庫中,方法外的所有查詢還是讀取從庫,如下圖:

4.5 關於官網對讀寫分離描述不夠明確的補充說明

版本4.1.1

經實踐補充說明為:

同一執行緒且同一資料庫連線且一個事務中,如有寫入操作,以後的讀操作均從主庫讀取,只限存在寫入的表,沒有寫入的表,事務中的查詢會繼續路由至從庫中,用於保證資料一致性。

5 關於分庫的JOIN操作

方法1

使用default-data-source-name配置預設庫,即沒有配置資料分片策略的表都會使用預設庫。預設庫中表禁止與拆分表進行JOIN操作,此處需要做一些改造,目前系統有一些JOIN操作。(推薦使用此方法)

方法2

使用全域性表,廣播表,讓128個庫中冗餘基礎庫中的表,並實時改變。

方法3

分庫表中冗餘需要JOIN表中的欄位,可以解決JOIN問題,此方案單個表欄位會增加。

6 分散式事務

6.1 XA事務管理器引數配置

XA是由X/Open組織提出的分散式事務的規範。 XA規範主要定義了(全域性)事務管理器(TM)和(局 部)資源管理器(RM)之間的介面。主流的關係型 資料庫產品都是實現了XA介面的。

分段提交

XA需要兩階段提交: prepare 和 commit.

第一階段為 準備(prepare)階段。即所有的參與者準備執行事務並鎖住需要的資源。參與者ready時,向transaction manager報告已準備就緒。

第二階段為提交階段(commit)。當transaction manager確認所有參與者都ready後,向所有參與者傳送commit命令。

ShardingSphere預設的XA事務管理器為Atomikos,在專案的logs目錄中會生成xa_tx.log, 這是XA崩潰恢復時所需的日誌,請勿刪除。

6.2 BASE柔性事務管理器(SEATA-AT配置)

Seata是一款開源的分散式事務解決方案,提供簡單易用的分散式事務服務。隨著業務的快速發展,應用單體架構暴露出程式碼可維護性差,容錯率低,測試難度大,敏捷交付能力差等諸多問題,微服務應運而生。微服務的誕生一方面解決了上述問題,但是另一方面卻引入新的問題,其中主要問題之一就是如何保證微服務間的業務資料一致性。Seata 註冊配置服務中心均使用 Nacos。Seata 0.2.1+ 開始支援 Nacos 註冊配置服務中心。

  1. 按照seata-work-shop中的步驟,下載並啟動seata server。
  2. 在每一個分片資料庫例項中執建立undo_log表(以MySQL為例)
CREATE TABLE IF NOT EXISTS `undo_log`
(
  `id`            BIGINT(20)   NOT NULL AUTO_INCREMENT COMMENT 'increment id',
  `branch_id`     BIGINT(20)   NOT NULL COMMENT 'branch transaction id',
  `xid`           VARCHAR(100) NOT NULL COMMENT 'global transaction id',
  `context`       VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
  `rollback_info` LONGBLOB     NOT NULL COMMENT 'rollback info',
  `log_status`    INT(11)      NOT NULL COMMENT '0:normal status,1:defense status',
  `log_created`   DATETIME     NOT NULL COMMENT 'create datetime',
  `log_modified`  DATETIME     NOT NULL COMMENT 'modify datetime',
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';

3.在classpath中增加seata.conf

client {
    application.id = example    ## 應用唯一id
    transaction.service.group = my_test_tx_group   ## 所屬事務組
}

6.3 Sharding-Jdbc預設提供弱XA事務

官方說明:

完全支援非跨庫事務,例如:僅分表,或分庫但是路由的結果在單庫中。

完全支援因邏輯異常導致的跨庫事務。例如:同一事務中,跨兩個庫更新。更新完畢後,丟擲空指標,則兩個庫的內容都能回滾。

不支援因網路、硬體異常導致的跨庫事務。例如:同一事務中,跨兩個庫更新,更新完畢後、未提交之前,第一個庫當機,則只有第二個庫資料提交。

6.4 分散式事務場景

1.儲存場景

推薦使用第三種弱XA事務,儘量設計時避免跨庫事務,目前設計為事件和事件資料為同庫(分庫時,將一個線索號的事件和事件資料HASH進入同一個分庫),儘量避免跨庫事務。

事件和計費結果本身設計為非同步,非同一事務,所以事件和對應的結果不涉及跨庫事務。

儲存多個計費結果,每次儲存都屬於一個事件,一個事件的計費結果都屬於一個收付款物件,天然同庫。

弱XA事務的效能最佳。

2.更新場景

對一些根據ID IN的更新場景,根據收付款物件分組執行,可以避免在所有分庫執行更新。

3.刪除場景

無,目前都是邏輯刪除,實際為更新。

7 總結

1.推薦使用Sharding-Sphere進行分庫,分表可以考慮使用MYSQL分割槽表,對於研發來講完全是透明的,可以規避JOIN\分散式事務等問題。(分割槽表需要為分割槽鍵+ID建立了一個聯合索引)MYSQL分割槽得到了大量的實踐印證,沒有BUG,包括我在新計費初期,一直堅持推動使用的分表方案,不會引起一些難以發現的問題,在同庫同磁碟下效能與分表相當。

2.對於同一時刻有大量併發讀操作和較少寫操作型別的資料來說,適合使用讀寫分離,增加多個讀庫,緩解主庫壓力,要注意的是必須讀主庫的場景使用SeekMaster註解來實現。

3.資料分庫選擇合適的分片鍵非常重要,要根據業務需求選擇好分庫鍵,盡力避免資料傾斜,資料不均勻是目前資料拆分的一個共同問題,不可能實現資料的完全均勻;當查詢條件沒有分庫鍵時會遍歷所有分庫,查詢儘量帶上分庫鍵。

4.在我們使用中介軟體時,不要只看官網解釋,要多做測試,用實際來驗證,有的時候官網解釋話術可能存在歧義或表達不夠全面的地方,分析原始碼和實際測試可以清晰的獲得想要的結果。

相關文章