基於 Apache ShardingSphere 構建高可用分散式資料庫

SphereEx發表於2022-03-08

趙錦超,SphereEx 中介軟體研發工程師,Apache ShardingSphere Committer。目前專注於 ShardingSphere 高可用的設計和研發。

什麼是資料庫高可用

在這個以“資料”為王的時代,對現代業務系統可用性、可靠性以及穩定性要求極其高。資料庫又作為現代業務系統的基石,因此資料庫高可用就顯得尤為重要。同時資料庫高可用應具備主從切換以及自動選主的能力,當我們主庫當機時,可以快速地在從節點中選舉最佳節點來作為主庫。

MySQL 高可用介紹

MySQL 高可用方案比較多,且每個高可用方案都有各自的優缺點。在這裡我們列舉幾個常用的高可用方案。

  • Orchestrator 是一款 Go 語言編寫的 MySQL 高可用性和複製拓撲管理工具,優點是支援通過 Web 視覺化控制檯手動的調整主從拓撲結構、自動故障轉移、自動或手動恢復主節點。缺點則是需要獨立部署該程式,學習成本較高配置複雜;

  • MHA 也是一款相對成熟的 MySQL 高可用軟體。支援故障切換和主從提升,其優點是在切換的過程中可以最大程度保證資料不丟失,同時可以工作在半同步複製或非同步複製叢集下。缺點則是 MHA 啟動後只對主庫進行監控,並且讀庫沒有提供負載均衡功能;

  • MGR 是基於分散式 Paxos 協議實現組複製,從而保證資料的一致性。同時也是 MySQL 官方提供解決 MySQL 高可用的元件,使用它我們不需要單獨的部署程式,只需要在每個資料來源節點中安裝 MGR Plugin,其特點具有高一致性、高容錯性、高擴充套件性、高靈活性。

Apache ShardingSphere 高可用

Apache ShardingSphere 採用存算分離架構體系,儲存節點代表底層資料庫,如 MySQL、PostgreSQL、openGauss 等,計算節點則是指 ShardingSphere-JDBC 或 ShardingSphere-Proxy,並且儲存節點和計算節點的高可用方案是不同的。對於無狀態的計算節點來說,需要感知儲存節點變化的同時,還需要獨立架設負載均衡器,並具備服務發現和請求分發的能力。對於有狀態的儲存節點來說,需要其自身具備資料一致性同步、探活、主節點選舉等能力。

ShardingSphere 雖然本身並不提供資料庫的高可用能力,但可以藉助資料庫高可用能力,並通過自身資料庫發現及動態感知的能力,幫助使用者整合主從切換、故障發現、流量切換治理等一體化的資料庫高可用方案。特別是在分散式場景下搭配主從流量控制的特性,可以提供更為完善的高可用讀寫分離方案。 同時我們在使用高可用功能時,再搭配使用 DistSQL 動態調整高可用規則的動態,獲取主從的節點資訊等,使我們更能方便地運維管控 ShardingSphere 叢集。

最佳實踐

Apache ShardingSphere 採用可插拔架構,所有增量的功能能單獨使用也能相互組合。高可用功能通常和讀寫分離配合使用,在保證系統高可用的同時,配合讀寫分離將查詢請求根據負載均衡演算法分散到從庫中,減少主庫的壓力,提升業務系統的吞吐量。以下實踐內容將採用高可用 + 讀寫分離的配置,再配合使用   ShardingSphere DistSQL RAL語句進行演示。

需要注意的是,ShardingSphere 高可用的實現依賴於分散式治理的能力,所以目前只支援在叢集模式下使用。同時讀寫分離規則在 ShardingSphere 5.1.0 版本中也進行了調整,詳細內容請參考 官方文件讀寫分離

配置參考

schemaName: database_discovery_db


dataSources:
  ds_0:
    url: jdbc:mysql: //127.0.0.1:1231/demo_primary_ds?serverTimezone=UTC&useSSL=false
    username: root
    password:  123456
    connectionTimeoutMilliseconds:  3000
    idleTimeoutMilliseconds:  60000
    maxLifetimeMilliseconds:  1800000
    maxPoolSize:  50
    minPoolSize:  1
  ds_1:
    url: jdbc:mysql: //127.0.0.1:1232/demo_primary_ds?serverTimezone=UTC&useSSL=false
    username: root
    password:  123456
    connectionTimeoutMilliseconds:  3000
    idleTimeoutMilliseconds:  60000
    maxLifetimeMilliseconds:  1800000
    maxPoolSize:  50
    minPoolSize:  1
  ds_2:
    url: jdbc:mysql: //127.0.0.1:1233/demo_primary_ds?serverTimezone=UTC&useSSL=false
    username: root
    password:  123456
    connectionTimeoutMilliseconds:  3000
    idleTimeoutMilliseconds:  50000
    maxLifetimeMilliseconds:  1300000
    maxPoolSize:  50
    minPoolSize:  1

rules:
  - !READWRITE_SPLITTING
    dataSources:
      replication_ds:
         typeDynamic
        props:
          auto-aware-data-source- name: mgr_replication_ds
  - !DB_DISCOVERY
    dataSources:
      mgr_replication_ds:
        dataSourceNames:
          - ds_0
          - ds_1
          - ds_2
        discoveryHeartbeatName: mgr-heartbeat
        discoveryTypeName: mgr
    discoveryHeartbeats:
      mgr-heartbeat:
        props:
          keep-alive-cron:  '0/5 * * * * ?'
    discoveryTypes:
      mgr:
         type: MGR
        props:
          group- name: b13df29e- 90b6- 11e8- 8d1b- 525400fc3996

前置條件

  • ShardingSphere-Proxy 5.1.0 (Cluster mode + 高可用+動態讀寫分離規則)

  • Zookeeper 3.7.0

  • MySQL MGR 叢集

SQL 指令碼


CREATE 
TABLE 
`t_user` (

   `id`  int( 8NOT  NULL,
   `mobile`  char( 20NOT  NULL,
   `idcard`  varchar( 18NOT  NULL,
  PRIMARY  KEY ( `id`)
ENGINE= InnoDB  DEFAULT  CHARSET=utf8mb4;

檢視主從關係

mysql> SHOW READWRITE_SPLITTING RULES;

+----------------+-----------------------------+------------------------+------------------------+--------------------+---------------------+
| name           | auto_aware_data_source_name  | write_data_source_name | read_data_source_names  | load_balancer_type | load_balancer_props  |
+----------------+-----------------------------+------------------------+------------------------+--------------------+---------------------+
|
 replication_ds  | mgr_replication_ds          | ds_ 0                    | ds_1,ds_2              | NULL                |                     |
+----------------+-----------------------------+------------------------+------------------------+--------------------+---------------------+
1 row  in set ( 0.09 sec)

檢視從庫狀態

mysql> SHOW READWRITE_SPLITTING READ RESOURCES;

+----------+---------+
| resource | status   |
+----------+---------+
|
 ds_1      | enabled |
| ds_2     | enabled  |
+----------+---------+

從上面的結果得知,當前我們的主庫是   ds 0 ,從庫是 s 1    ds 2

我們測試 INSERT 一條資料:

mysql> 
INSERT INTO 
t_user(
id, mobile, idcardvalue (

10000'13718687777''141121xxxxx');

Query OK,  1  row  affected ( 0.10 sec)

檢視 ShardingSphere-Proxy 日誌,檢視路由的節點是否為主庫   ds_0

[INFO ] 2022-02-28 15:28:21.495 [ShardingSphere-Command-2] ShardingSphere-SQL - Logic SQL: 
INSERT 
INTO t_user(
id, mobile, idcard) 
value (
10000'13718687777''141121xxxxx')

[INFO ]  2022 -02 -28  15: 28: 21.495 [ShardingSphere-Command -2] ShardingSphere- SQL - SQLStatement: MySQLInsertStatement(setAssignment=Optional.empty, onDuplicateKeyColumns=Optional.empty)
[INFO ]  2022 -02 -28  15: 28: 21.495 [ShardingSphere-Command -2] ShardingSphere- SQL - Actual  SQL: ds_0 :::  INSERT  INTO t_user( id, mobile, idcard)  value ( 10000'13718687777''141121xxxxx')

我們測試 SELECT 一條資料(重複執行兩次):

mysql> 
SELECT id, mobile, idcard 
FROM t_user 
WHERE id = 
10000;

檢視 ShardingSphere-Proxy 日誌,檢視路由的節點是否為   ds 1    ds 2

[INFO ] 2022-02-28 15:34:07.912 [ShardingSphere-Command-4] ShardingSphere-SQL - Logic SQL: 
SELECT 
id, mobile, idcard 
FROM t_user 
WHERE 
id = 
10000

[INFO ]  2022 -02 -28  15: 34: 07.913 [ShardingSphere-Command -4] ShardingSphere- SQL - SQLStatement: MySQLSelectStatement( table=Optional.empty,  limit=Optional.empty,  lock=Optional.empty, window=Optional.empty)
[INFO ]  2022 -02 -28  15: 34: 07.913 [ShardingSphere-Command -4] ShardingSphere- SQL - Actual  SQL: ds_1 :::  SELECT  id, mobile, idcard  FROM t_user  WHERE  id =  10000
[INFO ]  2022 -02 -28  15: 34: 21.501 [ShardingSphere-Command -4] ShardingSphere- SQL - Logic  SQLSELECT  id, mobile, idcard  FROM t_user  WHERE  id =  10000
[INFO ]  2022 -02 -28  15: 34: 21.502 [ShardingSphere-Command -4] ShardingSphere- SQL - SQLStatement: MySQLSelectStatement( table=Optional.empty,  limit=Optional.empty,  lock=Optional.empty, window=Optional.empty)
[INFO ]  2022 -02 -28  15: 34: 21.502 [ShardingSphere-Command -4] ShardingSphere- SQL - Actual  SQL: ds_2 :::  SELECT  id, mobile, idcard  FROM t_user  WHERE  id =  10000

切換主庫

關閉主庫   ds_0

通過   DistSQL 檢視主庫是否發生改變,從庫狀態是否正確。

mysql> SHOW READWRITE_SPLITTING RULES;

+----------------+-----------------------------+------------------------+------------------------+--------------------+---------------------+
| name           | auto_aware_data_source_name  | write_data_source_name | read_data_source_names  | load_balancer_type | load_balancer_props  |
+----------------+-----------------------------+------------------------+------------------------+--------------------+---------------------+
|
 replication_ds  | mgr_replication_ds          | ds_1                    | ds_2                   | NULL                |                     |
+----------------+-----------------------------+------------------------+------------------------+--------------------+---------------------+
1 row  in set ( 0. 01 sec)

mysql> SHOW READWRITE_SPLITTING READ RESOURCES;
+----------+----------+
| resource | status    |
+----------+----------+
|
 ds_2      | enabled  |
| ds_0     | disabled  |
+----------+----------+
2 rows  in set (0.01 sec)

此時我們再次 INSERT 一條資料:

mysql> 
INSERT INTO 
t_user(
id, mobile, idcardvalue (

10001'13521207777''110xxxxx');

Query OK,  1  row  affected ( 0.04 sec)

檢視 ShardingSphere-Proxy 日誌,檢視路由的節點是否為主庫   ds_1

[INFO ] 2022-02-28 15:40:26.784 [ShardingSphere-Command-6] ShardingSphere-SQL - Logic SQL: 
INSERT 
INTO t_user(
id, mobile, idcard) 
value (
10001'13521207777''110xxxxx')

[INFO ]  2022 -02 -28  15: 40: 26.784 [ShardingSphere-Command -6] ShardingSphere- SQL - SQLStatement: MySQLInsertStatement(setAssignment=Optional.empty, onDuplicateKeyColumns=Optional.empty)
[INFO ]  2022 -02 -28  15: 40: 26.784 [ShardingSphere-Command -6] ShardingSphere- SQL - Actual  SQL: ds_1 :::  INSERT  INTO t_user( id, mobile, idcard)  value ( 10001'13521207777''110xxxxx')

最後我們測試 SELECT 一條資料(重複執行兩次):

mysql> 
SELECT id, mobile, idcard 
FROM t_user 
WHERE id = 
10001;

檢視 ShardingSphere-Proxy 日誌,檢視路由的節點是否為   ds_2

[INFO ] 2022-02-28 15:42:00.651 [ShardingSphere-Command-7] ShardingSphere-SQL - Logic SQL: 
SELECT 
id, mobile, idcard 
FROM t_user 
WHERE 
id = 
10001

[INFO ]  2022 -02 -28  15: 42: 00.651 [ShardingSphere-Command -7] ShardingSphere- SQL - SQLStatement: MySQLSelectStatement( table=Optional.empty,  limit=Optional.empty,  lock=Optional.empty, window=Optional.empty)
[INFO ]  2022 -02 -28  15: 42: 00.651 [ShardingSphere-Command -7] ShardingSphere- SQL - Actual  SQL: ds_2 :::  SELECT  id, mobile, idcard  FROM t_user  WHERE  id =  10001
[INFO ]  2022 -02 -28  15: 42: 02.148 [ShardingSphere-Command -7] ShardingSphere- SQL - Logic  SQLSELECT  id, mobile, idcard  FROM t_user  WHERE  id =  10001
[INFO ]  2022 -02 -28  15: 42: 02.149 [ShardingSphere-Command -7] ShardingSphere- SQL - SQLStatement: MySQLSelectStatement( table=Optional.empty,  limit=Optional.empty,  lock=Optional.empty, window=Optional.empty)
[INFO ]  2022 -02 -28  15: 42: 02.149 [ShardingSphere-Command -7] ShardingSphere- SQL - Actual  SQL: ds_2 :::  SELECT  id, mobile, idcard  FROM t_user  WHERE  id =  10001

上線從庫

通過    DistSQL 檢視最新的主從關係是否變化, ds 節點狀態恢復為啟用狀態, ds 加入  read data source_names:

mysql> SHOW READWRITE_SPLITTING RULES;

+----------------+-----------------------------+------------------------+------------------------+--------------------+---------------------+
| name           | auto_aware_data_source_name  | write_data_source_name | read_data_source_names  | load_balancer_type | load_balancer_props  |
+----------------+-----------------------------+------------------------+------------------------+--------------------+---------------------+
|
 replication_ds  | mgr_replication_ds          | ds_1                    | ds_0,ds_2              | NULL                |                     |
+----------------+-----------------------------+------------------------+------------------------+--------------------+---------------------+
1 row  in set ( 0. 01 sec)

mysql> SHOW READWRITE_SPLITTING READ RESOURCES;
+----------+---------+
| resource | status   |
+----------+---------+
|
 ds_ 0      | enabled |
| ds_2     | enabled  |
+----------+---------+
2 rows  in set (0.00 sec)

通過上面的實踐,相信大家已經對 ShardingSphere 高可用和動態讀寫分離有了一定的認識。接下來為大家介紹基於底層資料庫的儲存節點的高可用方案實現原理。

實現原理

ShardingSphere 提供了高可用方案,允許使用者進行二次定製開發及實現擴充套件。目前實現了基於 MGR 的 MySQL 高可用方案以及由社群同學開發貢獻的 openGauss 資料庫高可用方案。兩種方案的實現思路整體一致,下文將以 MySQL 為例,詳細介紹 ShardingSphere 實現資料庫高可用的底層原理以及最佳實踐。

前置檢查

ShardingSphere 通過執行以下 SQL 驗證底層 MySQL 叢集環境是否準備完成,未滿足其中任意一個檢查 ShardingSphere 均無法正常啟動。

  • 檢查是否安裝了 MGR 外掛;

SELECT * 
FROM information_schema.PLUGINS 
WHERE PLUGIN_NAME=
'group_replication'

  • 檢視 MGR 組成員數量;

  1. 底層 MGR 叢集最低要求是由三個節點組成;

SELECT 
count(*) 
FROM performance_schema.replication_group_members

  • 校驗 MGR 叢集的 group name 是否與配置中的 group name 一致;

  1. group name 是 MGR 組的標識,一組 MGR 叢集對應同一個 group name;

SELECT * 
FROM performance_schema.global_variables 
WHERE VARIABLE_NAME=
'group_replication_group_name' 

  • 檢視當前 MGR 是否設定為單主模式;

  1. ShardingSphere 不支援雙寫或多寫場景,只能是單寫模式;

SELECT * 
FROM performance_schema.global_variables 
WHERE VARIABLE_NAME=
'group_replication_single_primary_mode'

  • 查詢 MGR 組叢集中所有的節點地址,埠及狀態。用於校驗我們配置的資料來源是否正確;

SELECT MEMBER_HOST, MEMBER_PORT, MEMBER_STATE 
FROM performance_schema.replication_group_members

動態發現主庫

  • ShardingSphere 根據 MySQL 提供的查詢主庫 SQL 命令獲取主庫 URL。
private 
String findPrimaryDataSourceURL(
final 
Map<
String, DataSource> dataSourceMap) {

     String result =  "";
     String sql =  "SELECT MEMBER_HOST, MEMBER_PORT FROM performance_schema.replication_group_members WHERE MEMBER_ID = "
            +  "(SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'group_replication_primary_member')";
     for (DataSource each : dataSourceMap.values()) {
         try (Connection connection = each.getConnection();
             Statement statement = connection.createStatement();
             ResultSet resultSet = statement.executeQuery(sql)) {
             if (resultSet.next()) {
                 return  String.format( "%s:%s", resultSet.getString( "MEMBER_HOST"), resultSet.getString( "MEMBER_PORT"));
            }
        }  catch ( final SQLException ex) {
            log.error( "An exception occurred while find primary data source url", ex);
        }
    }
     return result;
}
  • 上面查詢出來的主庫 URL 與我們配置的 dataSources URL 逐一對比。命中的資料來源則為主庫,將主庫更新至 ShardingSphere 當前記憶體中並持久化至註冊中心,通過註冊中心分發至叢集內其它計算節點中。


動態發現從庫

ShardingSphere 從庫狀態分為啟動與禁用,並且從庫的狀態實時地同步至 ShardingSphere 記憶體中,以保證讀流量可以被正確的路由。

  • 獲取 MGR 組中所有的節點

SELECT MEMBER_HOST, MEMBER_PORT, MEMBER_STATE 
FROM performance_schema.replication_group_members

  • 禁用從庫
private 
void determineDisabledDataSource(
final 
String schemaName, 
final 
Map<
String, DataSource> activeDataSourceMap,

                                          final  List< String> memberDataSourceURLs,  final  Map< StringString> dataSourceURLs) {
     for (Entry< String, DataSource> entry : activeDataSourceMap.entrySet()) {
        boolean disable =  true;
         String url =  null;
         try (Connection connection = entry.getValue().getConnection()) {
            url = connection.getMetaData().getURL();
             for ( String each : memberDataSourceURLs) {
                 if ( null != url && url.contains(each)) {
                    disable =  false;
                     break;
                }
            }
        }  catch ( final SQLException ex) {
            log.error( "An exception occurred while find data source urls", ex);
        }
         if (disable) {
            ShardingSphereEventBus.getInstance().post( new DataSourceDisabledEvent(schemaName, entry.getKey(),  true));
        }  else  if (!url.isEmpty()) {
            dataSourceURLs.put(entry.getKey(), url);
        }
    }
}

從庫的禁用,依據的是我們配置的資料來源以及 MGR 組中所有的節點。ShardingSphere 會逐一檢查我們配置的資料來源是否可以正常的獲取 Connection,並校驗資料來源 URL 是否包含 MGR 組中的節點。無法正常獲取 Connection 或校驗失敗,ShardingSphere 會事件驅動禁用資料來源,以及同步註冊中心。

圖片

  • 啟用從庫
private 
void determineEnabledDataSource(
final 
Map<
String, DataSource> dataSourceMap, 
final 
String schemaName,

                                         final  List< String> memberDataSourceURLs,  final  Map< StringString> dataSourceURLs) {
     for ( String each : memberDataSourceURLs) {
        boolean enable =  true;
         for (Entry< StringString> entry : dataSourceURLs.entrySet()) {
             if (entry.getValue().contains(each)) {
                enable =  false;
                 break;
            }
        }
         if (!enable) {
             continue;
        }
         for (Entry< String, DataSource> entry : dataSourceMap.entrySet()) {
             String url;
             try (Connection connection = entry.getValue().getConnection()) {
                url = connection.getMetaData().getURL();
                 if ( null != url && url.contains(each)) {
                    ShardingSphereEventBus.getInstance().post( new DataSourceDisabledEvent(schemaName, entry.getKey(),  false));
                     break;
                }
            }  catch ( final SQLException ex) {
                log.error( "An exception occurred while find enable data source urls", ex);
            }
        }
    }
}

恢復當機從庫,重新加入 MGR 組後,檢查我們配置中是否使用了被恢復的資料來源。如果使用則會事件驅動告訴 ShardingSphere 需要該資料來源恢復成啟用狀態。

心跳檢測機制

上面帶大家瞭解了 ShardingSphere 動態發現和更新主從資料庫狀態的詳細流程,同時,為了保證主從狀態同步的實時性,高可用模組引入心跳檢測機制,通過整合 ShardingSphere 子專案 ElasticJob,在高可用模組初始化時將上述的流程以 Job 的方式交由 ElasticJob 排程框架執行,實現了功能開發和作業排程的分離,開發者如果需要擴充套件高可用的功能,也無需關心作業如何開發執行的問題。

private 
void initHeartBeatJobs(
final 
String schemaName, 
final 
Map<
String, DataSource> dataSourceMap) {

    Optional<ModeScheduleContext> modeScheduleContext = ModeScheduleContextFactory.getInstance(). get();
     if (modeScheduleContext.isPresent()) {
         for (Entry< String, DatabaseDiscoveryDataSourceRule> entry : dataSourceRules.entrySet()) {
             Map< String, DataSource> dataSources = dataSourceMap.entrySet().stream().filter(dataSource -> !entry.getValue().getDisabledDataSourceNames().contains(dataSource.getKey()))
                    .collect(Collectors.toMap(Entry::getKey, Entry::getValue));
            CronJob job =  new CronJob(entry.getValue().getDatabaseDiscoveryType().getType() +  "-" + entry.getValue().getGroupName(),
                each ->  new HeartbeatJob(schemaName, dataSources, entry.getValue().getGroupName(), entry.getValue().getDatabaseDiscoveryType(), entry.getValue().getDisabledDataSourceNames())
                            .execute( null), entry.getValue().getHeartbeatProps().getProperty( "keep-alive-cron"));
            modeScheduleContext. get().startCronJob(job);
        }
    }
}

結語

Apache ShardingSphere 高可用目前支援 MySQL 和 openGauss 高可用方案,未來將整合更多 MySQL 高可用產品以及支援更多其它資料庫高可用方案,歡迎大家一起參與。同時 ShardingSphere 高可用也是 SphereEx 中文社群 Governance 興趣小組長期維護的模組之一,對分散式治理和高可用感興趣的同學,也請關注 SphereEx 中文社群並加入我們的興趣小組。

GitHub issue:

貢獻指南:

中文社群興趣小組:


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70001955/viewspace-2867631/,如需轉載,請註明出處,否則將追究法律責任。

相關文章