博客 / 詳情

返回

分庫分表數據源ShardingSphereDataSource的Connection元數據誤用問題分析

背景

對於分庫分表應用來説,使用org.apache.shardingsphere.driver.jdbc.core.datasource.ShardingSphereDataSource是一個不錯的解決方案,你可以通過配置文件編寫分庫分表規則,從而在編碼時透明地使用分表(當然,路由規則的相關字段還是要傳的,之前也有文章分析過這些字段的處理過程:深入理解Mybatis分庫分表執行原理)。

但是,在一些場景中是需要繞過mybatis直接做一些操作的,特別是和數據庫元數據相關的操作(包括表的結構變更)。

比如我遇到的場景:先查詢各個分庫中有哪些前綴為table_的表,並給這些表加一列col_x

我結合現有代碼和大語言模型,先寫了一版,線下運行良好,但是線上的某些分庫死活找不到對應的分表,沒法進行後續的處理。這個問題查了很久,昨天終於解決了,因此分享出來。

存在問題的代碼

@Componet
public class TableAlterHandler {
    @Resource private ShardingSphereDataSource dataSource;
    
    public List<String> findTablesByPrefix(String prefix, String physicalSchemaName) {
        if (StringUtils.isBlank(prefix) || StringUtils.isBlank(physicalSchemaName)) {
            throw new RuntimeException("分表前綴或分庫名為空");
        }
        List<String> tableNames = Lists.newArrayList();
        try (HintManager hintManager = HintManager.getInstance();
             Connection conn = dataSource.getConnection()) {
             hintManager.setDataSourceName(DBUtil.queryLogicalSchemaName(physicalSchemaName));
            DatabaseMetaData metaData = conn.getMetaData();
            try (ResultSet rs = metaData.getTables(physicalSchemaName, null, prefix + "%", new String[] {"TABLE"})) {
                while (rs.next()) {
                    String tableName = rs.getString("TABLE_NAME");
                    tableNames.add(tableName);
                }
            }
        } catch (SQLException e) {
            throw new RuntimeException("處理大結果集失敗", e);
        }
        return tableNames;
    }
}

邏輯庫和物理庫

在分析問題之前,首先要明確兩個概念:物理庫名physicalSchemaName和邏輯庫名logicalSchemaName,如果用錯了,可能會讓你沒辦法發現後續問題的本質原因。上面的代碼,hintManager必須用邏輯庫名,而metaData.getTables必須用物理庫名。

所謂物理庫和邏輯庫,可以看作是我定義的概念。正如其名,物理庫名就是你jdbcUrl裏的庫名,比如一個典型的阿里雲Mysql的JDBC鏈接jdbc:mysql://``rm-bpxxxx.mysql.rds.aliyuncs.com/bizcenter_1?useSSL=false&autoReconnect=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

其中的bizcenter_1就是物理庫名。這個名稱也會在MySql元數據中出現,比如

select * from information_schema.tables;

可以看到這個庫的所有表,而TABLE_SCHEMA列就是物理庫名。

而邏輯庫,則是ShardingSphereDataSource對物理庫的映射,在application的配置文件(properties或yml)裏,spring.shardingsphere.datasource配置下,你需要配置邏輯庫名以及對應的JDBC鏈接。

你可以將多個邏輯庫配置為同一個物理庫,這是一種實踐方式,可以用於後續的擴容。

問題現象

線下的三個邏輯庫分別對應一個物理庫,而這三個物理庫在同一個阿里雲RDS實例上,可以找到每個庫對應的表。

而線上的仍然是三個邏輯庫對應各自的物理庫,每個物理庫在不同的阿里雲RDS實例上,會出現有時候能找到某個庫對應的表,而另外兩個庫一個表都找不到的情況。

排查

遇到問題後,百思不得其解,因為線下環境一切正常,線上卻總能復現問題。由於線上環境管控比較嚴,既不能遠程debug,又不能直連線上庫,很難定位原因。

第一階段排查,我反覆確認了上面代碼中需要傳數據庫名的地方到底是邏輯庫還是物理庫。中間某個版本的確搞錯了,但是為何在寫錯的前提下還能運行,沒有做記錄。

然後,我刪掉了connection.setAutoCommit(false)resultSet.setFetchSize(batchSize)這樣的用於降低每次查詢元數據結果數量的代碼,也沒效果。

最後,我把代碼移到了另一個連接同樣數據庫的應用中,因為那個應用有我之前類似的代碼。移過去以後倒是歪打正着地解決了。

第二階段的排查,是在一段時間後,我在原先的應用中開發新的功能,對原先代碼進行改動,自以為修復好了,但是上線後發現還是和之前一樣。

分析

線下線上最大的區別就是線下幾個庫是同一個MySql實例,而線上分屬三個。我的代碼裏,疑點最大的是查詢元數據metaData.getTables()這段。

好巧不巧,在我排查的第一階段和第二階段中間,我寫了一個遷移表的功能,完全新寫了查詢表名的代碼,並且為了不再犯物理庫和邏輯庫搞混的錯誤,特別地寫了對應的工具類:

/**
 * 數據源持有組件,便於應用直接訪問數據源
 *
 */
@Component
public class DataSourceHolder {

  @Resource protected ShardingSphereDataSource dataSource;

  /** 物理庫名(jdbc鏈接裏的庫名)和數據源的關係 */
  private Map<String, HikariDataSource> hikariDataSourceMap;

  /** 邏輯庫名-物理庫名關係 多個邏輯庫可能對應同一個物理庫 */
  private Map<String, String> dsNameMap;

  /**
   * 通過物理庫名獲取ds
   *
   * @param physicalSchemaName
   * @return
   */
  public HikariDataSource getDataSourceByPhysicalSchemaName(String physicalSchemaName) {
    return hikariDataSourceMap.get(physicalSchemaName);
  }

  /**
   * 通過邏輯庫名獲取對應物理庫名
   *
   * @param dsName
   * @return
   */
  public String getPhysicalSchemaName(String dsName) {
    return dsNameMap.get(dsName);
  }

  /**
   * 通過邏輯庫名獲取ds
   *
   * @param logicalSchemaName
   * @return
   */
  public HikariDataSource getDataSourceByLogicalSchemaName(String logicalSchemaName) {
    String physicalSchemaName = getPhysicalSchemaName(logicalSchemaName);
    if (StringUtils.isBlank(physicalSchemaName)) {
      throw new RuntimeException("邏輯庫名找不到對應物理庫, logicalSchemaName=" + logicalSchemaName);
    }
    return hikariDataSourceMap.get(physicalSchemaName);
  }

  @PostConstruct
  public void initHikariDataSourceMap() {
    dsNameMap = Maps.newHashMap();
    hikariDataSourceMap = Maps.newHashMap();
    Map<String, DataSource> dataSourceMap =
        dataSource.getContextManager().getDataSourceMap(dataSource.getSchemaName());
    dataSourceMap.forEach(
        (dsName, ds) -> {
          HikariDataSource hds = (HikariDataSource) ds;
          try (Connection connection = hds.getConnection(); ) {
            hikariDataSourceMap.put(connection.getCatalog(), hds);
            dsNameMap.put(dsName, connection.getCatalog());
          } catch (SQLException e) {
            throw new RuntimeException("組裝數據源map失敗", e);
          }
        });
  }
}

對應地,獲取數據庫Connection的方法是:

HikariDataSource hikariDataSource = dataSourceHolder.getDataSourceByPhysicalSchemaName(dsName);
Connection connection = hikariDataSource.getConnection();

並且也不再使用HintManager指定邏輯庫。

聯想到線上線下MySql實例的差異,我猜測是因為:

線下三個庫是同一個MySql實例,那麼元數據information_schema.tables是一樣的,在哪個庫都能查到對應表。

線上則是不同的實例,直接使用ShardingSphereDataSource對應Connection的元數據,並不總是預期的庫。

這個猜測原因,也在之前第一階段的排查吻合,能正常工作的代碼所在應用,獲取數據源的方式實際是

Map<String, DataSource> allDataSource =
    shardingSphereDataSource.getContextManager().getDataSourceMap("logic_db");
Connection connection = allDataSouce.get(logicalSchemaName).getConnection();

雖然它也用了HintManger,但我認為是沒有意義的。

修復

綜合以上的分析,最終的修復代碼如下

    List<String> tableNames = Lists.newArrayList();
    try (Connection conn =
        dataSourceHolder.getDataSourceByPhysicalSchemaName(physicalSchemaName).getConnection()) {
      DatabaseMetaData metaData = conn.getMetaData();
      try (ResultSet rs =
          metaData.getTables(physicalSchemaName, null, prefix + "%", new String[] {"TABLE"})) {
        while (rs.next()) {
          String tableName = rs.getString("TABLE_NAME");
          tableNames.add(tableName);
        }
      }
    } catch (SQLException e) {
      throw new RuntimeException("處理大結果集失敗", e);
    }
    return tableNames;

這次再部署到生產環境,運行符合預期。

可見,當你需要實際分庫對應的元數據時,不要用shardingSphereDataSource,而是應該用它關聯具體分庫的dataSource,也即shardingSphereDataSource.getContextManager().getDataSourceMap(),再用這個分庫dataSource獲取元數據。

否則,線下線上不同的MySql實例配置,會導致不同的現象,難以排查真正的原因。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.