最近項目中不少表的數(shù)據(jù)量越來越大,并且導致了一些數(shù)據(jù)庫的性能問題婴栽。因此想借助一些分庫分表的中間件,實現(xiàn)自動化分庫分表實現(xiàn)辈末。調研下來愚争,發(fā)現(xiàn)Sharding-JDBC
目前成熟度最高并且應用最廣的Java分庫分表的客戶端組件
。本文主要介紹一些Sharding-JDBC核心概念以及生產環(huán)境下的實戰(zhàn)指南挤聘,旨在幫助組內成員快速了解Sharding-JDBC并且能夠快速將其使用起來轰枝。Sharding-JDBC官方文檔
核心概念
在使用Sharding-JDBC
之前,一定是先理解清楚下面幾個核心概念檬洞。
邏輯表
水平拆分的數(shù)據(jù)庫(表)的相同邏輯和數(shù)據(jù)結構表的總稱狸膏。例:訂單數(shù)據(jù)根據(jù)主鍵尾數(shù)拆分為10張表,分別是t_order_0
到t_order_9
添怔,他們的邏輯表名為t_order
湾戳。
真實表
在分片的數(shù)據(jù)庫中真實存在的物理表。即上個示例中的t_order_0
到t_order_9
广料。
數(shù)據(jù)節(jié)點
數(shù)據(jù)分片的最小單元砾脑。由數(shù)據(jù)源名稱和數(shù)據(jù)表組成,例:ds_0.t_order_0
艾杏。
綁定表
指分片規(guī)則一致的主表和子表韧衣。例如:t_order
表和t_order_item
表,均按照order_id
分片购桑,則此兩張表互為綁定表關系畅铭。綁定表之間的多表關聯(lián)查詢不會出現(xiàn)笛卡爾積關聯(lián),關聯(lián)查詢效率將大大提升勃蜘。舉例說明,如果SQL為:
SELECT i.* FROM t_order o JOIN t_order_item i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);
假設t_order
和t_order_item
對應的真實表各有2個硕噩,那么真實表就有t_order_0
、t_order_1
缭贡、t_order_item_0
炉擅、t_order_item_1
辉懒。在不配置綁定表關系時,假設分片鍵order_id
將數(shù)值10路由至第0片谍失,將數(shù)值11路由至第1片眶俩,那么路由后的SQL應該為4條,它們呈現(xiàn)為笛卡爾積:
SELECT i.* FROM t_order_0 o JOIN t_order_item_0 i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);
SELECT i.* FROM t_order_0 o JOIN t_order_item_1 i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);
SELECT i.* FROM t_order_1 o JOIN t_order_item_0 i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);
SELECT i.* FROM t_order_1 o JOIN t_order_item_1 i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);
在配置綁定表關系后快鱼,路由的SQL應該為2條:
SELECT i.* FROM t_order_0 o JOIN t_order_item_0 i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);
SELECT i.* FROM t_order_1 o JOIN t_order_item_1 i ON o.order_id=i.order_id WHERE o.order_id in (10, 11);
廣播表
指所有的分片數(shù)據(jù)源中都存在的表颠印,表結構和表中的數(shù)據(jù)在每個數(shù)據(jù)庫中均完全一致喇嘱。適用于數(shù)據(jù)量不大且需要與海量數(shù)據(jù)的表進行關聯(lián)查詢的場景捞烟,例如:字典表。
數(shù)據(jù)分片
分片鍵
用于分片的數(shù)據(jù)庫字段,是將數(shù)據(jù)庫(表)水平拆分的關鍵字段柒莉。例:將訂單表中的訂單主鍵的尾數(shù)取模分片,則訂單主鍵為分片字段沽翔。 SQL 中如果無分片字段兢孝,將執(zhí)行全路由,性能較差仅偎。 除了對單分片字段的支持跨蟹,Sharding-JDBC 也支持根據(jù)多個字段進行分片。
分片算法
通過分片算法將數(shù)據(jù)分片橘沥,支持通過=窗轩、>=、<=座咆、>痢艺、<、BETWEEN和IN
分片介陶。 分片算法需要應用方開發(fā)者自行實現(xiàn)堤舒,可實現(xiàn)的靈活度非常高。
目前提供4種分片算法哺呜。 由于分片算法和業(yè)務實現(xiàn)緊密相關舌缤,因此并未提供內置分片算法,而是通過分片策略將各種場景提煉出來某残,提供更高層級的抽象国撵,并提供接口讓應用開發(fā)者自行實現(xiàn)分片算法。
精確分片算法
對應 PreciseShardingAlgorithm
玻墅,用于處理使用單一鍵作為分片鍵的 = 與 IN 進行分片的場景介牙。需要配合 StandardShardingStrategy
使用。
范圍分片算法
對應 RangeShardingAlgorithm
椭豫,用于處理使用單一鍵作為分片鍵的 BETWEEN AND耻瑟、>旨指、<、>=喳整、<=進行分片的場景谆构。需要配合 StandardShardingStrategy 使用。
復合分片算法
對應 ComplexKeysShardingAlgorithm
框都,用于處理使用多鍵作為分片鍵進行分片的場景搬素,包含多個分片鍵的邏輯較復雜,需要應用開發(fā)者自行處理其中的復雜度魏保。需要配合 ComplexShardingStrategy
使用熬尺。
Hint分片算法
對應 HintShardingAlgorithm
,用于處理通過Hint指定分片值而非從SQL中提取分片值的場景谓罗。需要配合 HintShardingStrategy
使用粱哼。
分片策略
包含分片鍵和分片算法,由于分片算法的獨立性檩咱,將其獨立抽離揭措。真正可用于分片操作的是分片鍵 + 分片算法,也就是分片策略刻蚯。目前提供 5 種分片策略绊含。
標準分片策略
對應 StandardShardingStrategy
。提供對 SQ L語句中的 =, >, <, >=, <=, IN 和 BETWEEN AND
的分片操作支持炊汹。 StandardShardingStrategy
只支持單分片鍵躬充,提供 PreciseShardingAlgorithm
和 RangeShardingAlgorithm
兩個分片算法。 PreciseShardingAlgorithm
是必選的讨便,用于處理 = 和 IN 的分片充甚。 RangeShardingAlgorithm
是可選的,用于處理 BETWEEN AND, >, <, >=, <=
分片器钟,如果不配置 RangeShardingAlgorithm津坑,SQL 中的 BETWEEN AND 將按照全庫路由處理。
復合分片策略
對應 ComplexShardingStrategy
傲霸。復合分片策略疆瑰。提供對 SQL 語句中的 =, >, <, >=, <=, IN 和 BETWEEN AND
的分片操作支持。 ComplexShardingStrategy
支持多分片鍵昙啄,由于多分片鍵之間的關系復雜穆役,因此并未進行過多的封裝,而是直接將分片鍵值組合以及分片操作符透傳至分片算法梳凛,完全由應用開發(fā)者實現(xiàn)耿币,提供最大的靈活度。
行表達式分片策略
對應 InlineShardingStrategy
韧拒。使用 Groovy 的表達式淹接,提供對 SQL 語句中的 = 和 IN
的分片操作支持十性,只支持單分片鍵。 對于簡單的分片算法塑悼,可以通過簡單的配置使用劲适,從而避免繁瑣的Java代碼開發(fā),如: t_user_$->{u_id % 8}
表示 t_user 表根據(jù) u_id 模 8厢蒜,而分成 8 張表霞势,表名稱為 t_user_0
到 t_user_7
。 可以認為是精確分片算法的簡易實現(xiàn)
Hint分片策略
對應 HintShardingStrategy斑鸦。通過 Hint 指定分片值而非從 SQL 中提取分片值的方式進行分片的策略愕贡。
分布式主鍵
用于在分布式環(huán)境下,生成全局唯一的id巷屿。Sharding-JDBC 提供了內置的分布式主鍵生成器固以,例如 UUID
、SNOWFLAKE
攒庵。還抽離出分布式主鍵生成器的接口嘴纺,方便用戶自行實現(xiàn)自定義的自增主鍵生成器败晴。為了保證數(shù)據(jù)庫性能浓冒,主鍵id還必須趨勢遞增,避免造成頻繁的數(shù)據(jù)頁面分裂尖坤。
讀寫分離
提供一主多從的讀寫分離配置稳懒,可獨立使用,也可配合分庫分表使用慢味。
- 同一線程且同一數(shù)據(jù)庫連接內场梆,如有寫入操作,以后的讀操作均從主庫讀取纯路,用于保證數(shù)據(jù)一致性
- 基于Hint的強制主庫路由或油。
- 主從模型中,事務中讀寫均用主庫驰唬。
執(zhí)行流程
Sharding-JDBC 的原理總結起來很簡單: 核心由 SQL解析 => 執(zhí)行器優(yōu)化 => SQL路由 => SQL改寫 => SQL執(zhí)行 => 結果歸并
的流程組成顶岸。
項目實戰(zhàn)
spring-boot項目實戰(zhàn)
引入依賴
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>sharding-jdbc-spring-boot-starter</artifactId>
<version>4.0.1</version>
</dependency>
數(shù)據(jù)源配置
如果使用sharding-jdbc-spring-boot-starter
, 并且數(shù)據(jù)源以及數(shù)據(jù)分片都使用shardingsphere進行配置,對應的數(shù)據(jù)源會自動創(chuàng)建并注入到spring容器中叫编。
spring.shardingsphere.datasource.names=ds0,ds1
spring.shardingsphere.datasource.ds0.type=org.apache.commons.dbcp.BasicDataSource
spring.shardingsphere.datasource.ds0.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds0.url=jdbc:mysql://localhost:3306/ds0
spring.shardingsphere.datasource.ds0.username=root
spring.shardingsphere.datasource.ds0.password=
spring.shardingsphere.datasource.ds1.type=org.apache.commons.dbcp.BasicDataSource
spring.shardingsphere.datasource.ds1.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds1.url=jdbc:mysql://localhost:3306/ds1
spring.shardingsphere.datasource.ds1.username=root
spring.shardingsphere.datasource.ds1.password=
# 其它分片配置
但是在我們已有的項目中辖佣,數(shù)據(jù)源配置是單獨的。因此要禁用sharding-jdbc-spring-boot-starter
里面的自動裝配搓逾,而是參考源碼自己重寫數(shù)據(jù)源配置卷谈。需要在啟動類上加上@SpringBootApplication(exclude = {org.apache.shardingsphere.shardingjdbc.spring.boot.SpringBootConfiguration.class})
來排除。然后自定義配置類來裝配DataSource
霞篡。
@Configuration
@Slf4j
@EnableConfigurationProperties({
SpringBootShardingRuleConfigurationProperties.class,
SpringBootMasterSlaveRuleConfigurationProperties.class, SpringBootEncryptRuleConfigurationProperties.class, SpringBootPropertiesConfigurationProperties.class})
@AutoConfigureBefore(DataSourceConfiguration.class)
public class DataSourceConfig implements ApplicationContextAware {
@Autowired
private SpringBootShardingRuleConfigurationProperties shardingRule;
@Autowired
private SpringBootPropertiesConfigurationProperties props;
private ApplicationContext applicationContext;
@Bean("shardingDataSource")
@Conditional(ShardingRuleCondition.class)
public DataSource shardingDataSource() throws SQLException {
// 獲取其它方式配置的數(shù)據(jù)源
Map<String, DruidDataSourceWrapper> beans = applicationContext.getBeansOfType(DruidDataSourceWrapper.class);
Map<String, DataSource> dataSourceMap = new HashMap<>(4);
beans.forEach(dataSourceMap::put);
// 創(chuàng)建shardingDataSource
return ShardingDataSourceFactory.createDataSource(dataSourceMap, new ShardingRuleConfigurationYamlSwapper().swap(shardingRule), props.getProps());
}
@Bean
public SqlSessionFactory sqlSessionFactory() throws SQLException {
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
// 將shardingDataSource設置到SqlSessionFactory中
sqlSessionFactoryBean.setDataSource(shardingDataSource());
// 其它設置
return sqlSessionFactoryBean.getObject();
}
}
分布式id生成器配置
Sharding-JDBC提供了UUID
世蔗、SNOWFLAKE
生成器端逼,還支持用戶實現(xiàn)自定義id生成器。比如可以實現(xiàn)了type為SEQ
的分布式id生成器污淋,調用統(tǒng)一的分布式id服務
獲取id裳食。
@Data
public class SeqShardingKeyGenerator implements ShardingKeyGenerator {
private Properties properties = new Properties();
@Override
public String getType() {
return "SEQ";
}
@Override
public synchronized Comparable<?> generateKey() {
// 獲取分布式id邏輯
}
}
由于擴展ShardingKeyGenerator
是通過JDK的serviceloader
的SPI機制實現(xiàn)的,因此還需要在resources/META-INF/services
目錄下配置org.apache.shardingsphere.spi.keygen.ShardingKeyGenerator
文件芙沥。 文件內容就是SeqShardingKeyGenerator
類的全路徑名诲祸。這樣使用的時候,指定分布式主鍵生成器的type為SEQ
就好了而昨。
至此救氯,Sharding-JDBC就整合進spring-boot項目中了,后面就可以進行數(shù)據(jù)分片相關的配置了歌憨。
數(shù)據(jù)分片實戰(zhàn)
如果項目初期就能預估出表的數(shù)據(jù)量級着憨,當然可以一開始就按照這個預估值進行分庫分表處理。但是大多數(shù)情況下务嫡,我們一開始并不能準備預估出數(shù)量級甲抖。這時候通常的做法是:
- 線上數(shù)據(jù)某張表查詢性能開始下降,排查下來是因為數(shù)據(jù)量過大導致的心铃。
- 根據(jù)歷史數(shù)據(jù)量預估出未來的數(shù)據(jù)量級准谚,并結合具體業(yè)務場景確定分庫分表策略。
- 自動分庫分表代碼實現(xiàn)去扣。
下面就以一個具體事例柱衔,闡述具體數(shù)據(jù)分片實戰(zhàn)。比如有張表數(shù)據(jù)結構如下:
CREATE TABLE `hc_question_reply_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`reply_text` varchar(500) NOT NULL DEFAULT '' COMMENT '回復內容',
`reply_wheel_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '回復時間',
`ctime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創(chuàng)建時間',
`mtime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
PRIMARY KEY (`id`),
INDEX `idx_reply_wheel_time` (`reply_wheel_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='回復明細記錄';
分片方案確定
先查詢目前目標表月新增趨勢:
SELECT count(*), date_format(ctime, '%Y-%m') AS `日期`
FROM hc_question_reply_record
GROUP BY date_format(ctime, '%Y-%m');
目前月新增在180w左右愉棱,預估未來達到300w(基本以2倍計算)以上唆铐。期望單表數(shù)據(jù)量不超過1000w,可使用reply_wheel_time
作為分片鍵按季度歸檔奔滑。
分片配置
spring:
# sharing-jdbc配置
shardingsphere:
# 數(shù)據(jù)源名稱
datasource:
names: defaultDataSource,slaveDataSource
sharding:
# 主從節(jié)點配置
master-slave-rules:
defaultDataSource:
# maser數(shù)據(jù)源
master-data-source-name: defaultDataSource
# slave數(shù)據(jù)源
slave-data-source-names: slaveDataSource
tables:
# hc_question_reply_record 分庫分表配置
hc_question_reply_record:
# 真實數(shù)據(jù)節(jié)點 hc_question_reply_record_2020_q1
actual-data-nodes: defaultDataSource.hc_question_reply_record_$->{2020..2025}_q$->{1..4}
# 表分片策略
table-strategy:
standard:
# 分片鍵
sharding-column: reply_wheel_time
# 精確分片算法 全路徑名
preciseAlgorithmClassName: com.xx.QuestionRecordPreciseShardingAlgorithm
# 范圍分片算法艾岂,用于BETWEEN,可選朋其。王浴。該類需實現(xiàn)RangeShardingAlgorithm接口并提供無參數(shù)的構造器
rangeAlgorithmClassName: com.xx.QuestionRecordRangeShardingAlgorithm
# 默認分布式id生成器
default-key-generator:
type: SEQ
column: id
分片算法實現(xiàn)
-
精確分片算法:
QuestionRecordPreciseShardingAlgorithm
public class QuestionRecordPreciseShardingAlgorithm implements PreciseShardingAlgorithm<Date> { /** * Sharding. * * @param availableTargetNames available data sources or tables's names * @param shardingValue sharding value * @return sharding result for data source or table's name */ @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Date> shardingValue) { return ShardingUtils.quarterPreciseSharding(availableTargetNames, shardingValue); } }
-
范圍分片算法:
QuestionRecordRangeShardingAlgorithm
public class QuestionRecordRangeShardingAlgorithm implements RangeShardingAlgorithm<Date> { /** * Sharding. * * @param availableTargetNames available data sources or tables's names * @param shardingValue sharding value * @return sharding results for data sources or tables's names */ @Override public Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<Date> shardingValue) { return ShardingUtils.quarterRangeSharding(availableTargetNames, shardingValue); } }
-
具體分片實現(xiàn)邏輯:
ShardingUtils
@UtilityClass public class ShardingUtils { public static final String QUARTER_SHARDING_PATTERN = "%s_%d_q%d"; /** * logicTableName_{year}_q{quarter} * 按季度范圍分片 * @param availableTargetNames 可用的真實表集合 * @param shardingValue 分片值 * @return */ public Collection<String> quarterRangeSharding(Collection<String> availableTargetNames, RangeShardingValue<Date> shardingValue) { Range<Date> valueRange = shardingValue.getValueRange(); Date lower = valueRange.lowerEndpoint(); Date upper = valueRange.upperEndpoint(); LocalDate lowerLocalDate = lower.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); LocalDate upperLocalDate = upper.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); int lowerYear = lowerLocalDate.getYear(); int lowerMonth = lowerLocalDate.getMonthValue(); int lowerQuarter = getQuarter(lowerMonth); int upperYear = upperLocalDate.getYear(); int upperMonth = upperLocalDate.getMonthValue(); int upperQuarter = getQuarter(upperMonth); String logicTableName = shardingValue.getLogicTableName(); List<String> actualTableNames = new ArrayList<>(); if (lowerYear == upperYear) { // 不跨年 for (int quarter = lowerQuarter; quarter <= upperQuarter; quarter++) { String actualTableName = String.format(QUARTER_SHARDING_PATTERN, logicTableName, lowerYear, quarter); actualTableNames.add(actualTableName); } } else { // 跨年 for (int quarter = lowerQuarter; quarter <= Constant.INT_4; quarter++) { String actualTableName = String.format(QUARTER_SHARDING_PATTERN, logicTableName, lowerYear, quarter); actualTableNames.add(actualTableName); } for (int year = lowerYear + 1; year < upperYear; year++) { for (int quarter = 1; quarter <= Constant.INT_4; quarter++) { String actualTableName = String.format(QUARTER_SHARDING_PATTERN, logicTableName, year, quarter); actualTableNames.add(actualTableName); } } for (int quarter = 1; quarter <= upperQuarter; quarter++) { String actualTableName = String.format(QUARTER_SHARDING_PATTERN, logicTableName, upperYear, quarter); actualTableNames.add(actualTableName); } } if (!availableTargetNames.containsAll(actualTableNames)) { throw new IllegalArgumentException("分片異常!shardingValue=" + shardingValue + "; availableTargetNames=" + availableTargetNames); } return actualTableNames; } /** * logicTableName_{year}_q{quarter} * 按季度精確分片 * @param availableTargetNames 可用的真實表集合 * @param shardingValue 分片值 * @return */ public static String quarterPreciseSharding(Collection<String> availableTargetNames, PreciseShardingValue<Date> shardingValue) { String logicTableName = shardingValue.getLogicTableName(); Date value = shardingValue.getValue(); LocalDate localDate = value.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); int year = localDate.getYear(); int month = localDate.getMonthValue(); int quarter = getQuarter(month); String actualTableName = String.format(QUARTER_SHARDING_PATTERN, logicTableName, year, quarter); if (availableTargetNames.contains(actualTableName)) { return actualTableName; } throw new IllegalArgumentException("分片異常令宿!shardingValue=" + shardingValue + "; availableTargetNames=" + availableTargetNames); } private static int getQuarter(int month) { if (month >= Constant.INT_1 && month <= Constant.INT_3) { return Constant.INT_1; } else if (month >= Constant.INT_4 && month <= Constant.INT_6) { return Constant.INT_2; } else if (month >= Constant.INT_7 && month <= Constant.INT_9) { return Constant.INT_3; } else if (month >= Constant.INT_10 && month <= Constant.INT_12) { return Constant.INT_4; } else { throw new IllegalArgumentException("month非法叼耙!month=" + month); } } }
到這里,針對hc_question_reply_record
表粒没,使用reply_wheel_time
作為分片鍵筛婉,按照季度分片的處理就完成了。還有一點要注意的就是,分庫分表之后爽撒,查詢的時候最好都帶上分片鍵作為查詢條件入蛆,否則就會使用全庫路由,性能很低硕勿。 還有就是Sharing-JDBC對mysql的全文索引支持的不是很好哨毁,項目有使用到的地方也要注意一下≡次洌總結來說整個過程還是比較簡單的扼褪,后續(xù)碰到其它業(yè)務場景,相信大家按照這個思路肯定都能解決的粱栖。