前言
隨著系統(tǒng)數(shù)據(jù)量的日益增長(zhǎng),在說(shuō)起數(shù)據(jù)庫(kù)架構(gòu)和數(shù)據(jù)庫(kù)優(yōu)化的時(shí)候,我們難免會(huì)常常聽(tīng)到分庫(kù)分表這樣的名詞。
當(dāng)然,分庫(kù)分表有很多的方法論,比如垂直拆分、水平拆分;也有很多的中間件產(chǎn)品,比如MyCat、ShardingJDBC。
根據(jù)業(yè)務(wù)場(chǎng)景選擇合適的拆分方法,再選擇一個(gè)熟悉的開(kāi)源框架,就能幫助我們完成項(xiàng)目中所涉及到的數(shù)據(jù)拆分工作。
本文并不打算就這些方法論和開(kāi)源框架展開(kāi)深入的探討,筆者想討論另外一個(gè)場(chǎng)景:
如果系統(tǒng)中需要拆分的表并不多,只是1個(gè)或者少量的幾個(gè),我們是否值得引入一些相對(duì)復(fù)雜的中間件產(chǎn)品;特別是,如果我們對(duì)它們的原理不甚了解,是否有信心駕馭它們 ?
基于此,如果你的系統(tǒng)中有少量的表需要拆分,也沒(méi)有專門的資源去研究開(kāi)源組件,那么我們可以自己來(lái)實(shí)現(xiàn)一個(gè)簡(jiǎn)單的分庫(kù)分表插件;當(dāng)然,如果你的系統(tǒng)比較復(fù)雜,業(yè)務(wù)量較大,還是采用開(kāi)源組件或者團(tuán)隊(duì)自研組件來(lái)解決這事較為穩(wěn)妥。
一、原理
分庫(kù)分表這事說(shuō)簡(jiǎn)單也簡(jiǎn)單,說(shuō)復(fù)雜那也挺復(fù)雜...
簡(jiǎn)單是因?yàn)樗暮诵牧鞒瘫容^明確。就是解析SQL語(yǔ)句,然后根據(jù)預(yù)先配置的規(guī)則,重寫(xiě)或路由到真實(shí)的數(shù)據(jù)庫(kù)表中去;
復(fù)雜在于,SQL語(yǔ)句復(fù)雜且靈活,比如分頁(yè)、去重、排序、分組、聚合、關(guān)聯(lián)查詢等操作,如何正確的解析它們。
所以就算是ShardingJDBC,在官網(wǎng)中也明確了支持項(xiàng)和不支持項(xiàng)。
二、注解式配置
相對(duì)于復(fù)雜的配置文件,我們采用較為輕便的注解式配置,它的定義如下:
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Sharding {
String tableName(); //邏輯表名
String field(); //分片鍵
String mode(); //算法模式
int length() default 0; //分表數(shù)量
}
那么,在哪里使用它呢 ? 比如我們的用戶表需要分表,那就在User這個(gè)實(shí)體對(duì)象上標(biāo)注。
@Data
@Sharding(tableName = "user",field = "id",mode = "hash",length = 16)
public class User {
private Long id;
private String name;
private String address;
private String tel;
private String email;
}
這就說(shuō)明了,我一共有 16 張用戶表,根據(jù)用戶ID,使用Hash算法來(lái)計(jì)算它的位置。
當(dāng)然,我們不止有Hash算法,還可以根據(jù)日期范圍來(lái)定義。
@Data
@Sharding(tableName = "car",field = "creatTime",mode = "range")
public class Car {
private long id;
private String number;
private String brand;
private String creatTime;
private long userId;
}
三、分片算法
在這里,筆者實(shí)現(xiàn)了兩種分片方式,就是HashAlgorithm和RangeAlgorithm 。
1、范圍分片
如果你的系統(tǒng)中有使用冷熱數(shù)據(jù)分離,我們可以按照日期將不同月的數(shù)據(jù)分散到不同的表中。
比如車輛的創(chuàng)建時(shí)間是2019-12-10 15:30:00,這條數(shù)據(jù)將會(huì)被分配到car_201912這張表中去。
我們通過(guò)截取時(shí)間的年月部分,然后再加上邏輯表名即可。
public class RangeAlgorithm implements Algorithm {
@Override
public String doSharding(String tableName, Object value,int length) {
if (value!=null){
try{
DateUtil.parseDateTime(value.toString());
String replace = value.toString().substring(0, 7).replace("-", "");
String newName = tableName+"_"+replace;
return newName;
}catch (DateException ex){
logger.error("時(shí)間格式不符合要求!傳入?yún)?shù):{},正確格式:{}",value.toString(),"yyyy-MM-dd HH:mm:ss");
return tableName;
}
}
return tableName;
}
}
2、Hash分片
在Hash分片算法中,我們可以先判斷表的數(shù)量,是不是2的冪次方。如果不是,就通過(guò)算數(shù)方式獲取下標(biāo),如果是呢,就通過(guò)位運(yùn)算的方式獲取下標(biāo)。當(dāng)然了,這是在HashMap源碼中學(xué)到的哦。
public class HashAlgorithm implements Algorithm {
@Override
public String doSharding(String tableName, Object value,int length) {
if (this.isEmpty(value)){
return tableName;
}else{
int h;
int hash = (h = value.hashCode()) ^ (h >>> 16);
int index;
if (is2Power(length)){
index = (length - 1) & hash;
}else {
index = Math.floorMod(hash, length);
}
return tableName+"_"+index;
}
}
}
四、攔截器
配置和分片算法都有了,接下來(lái)就是重頭戲了。在這里,我們使用Mybatis攔截器將它們派上用場(chǎng)。
常年CRUD的我們,都知道一條業(yè)務(wù)SQL肯定逃不出它們的范圍。其中,在業(yè)務(wù)上我們的刪除功能一般都是邏輯刪除,所以,基本上不會(huì)有DELETE操作。
相較而言,新增和修改SQL都比較簡(jiǎn)單且格式固定,查詢SQL往往比較靈活且復(fù)雜。所以,在這里筆者定義了兩個(gè)攔截器。
不過(guò),在介紹攔截器之前,我們有理由要了解另外兩個(gè)東西:SQL語(yǔ)法解析器和分片算法處理器。
1、JSqlParser
JSqlParser負(fù)責(zé)解析SQL語(yǔ)句,并轉(zhuǎn)化為Java類的層次結(jié)構(gòu)。我們可以先看個(gè)簡(jiǎn)單的例子來(lái)認(rèn)識(shí)它。
public static void main(String[] args) throws JSQLParserException {
String insertSql = "insert into user (id,name,age) value(1001,'范閑',20)";
Statement parse = CCJSqlParserUtil.parse(insertSql);
Insert insert = (Insert) parse;
String tableName = insert.getTable().getName();
List<Column> columns = insert.getColumns();
ItemsList itemsList = insert.getItemsList();
System.out.println("表名:"+tableName+" 列名:"+columns+" 屬性:"+itemsList);
}
輸出: 表名:user 列名:[id, name, age] 屬性:(1001, '范閑', 20)
我們可以看到,JSqlParser可以解析出SQL的語(yǔ)法信息。相應(yīng)的,我們也可以更改對(duì)象內(nèi)容,從而達(dá)到修改SQL語(yǔ)句的目的。
2、算法處理器
我們的分片算法有多個(gè),具體應(yīng)該調(diào)用哪一個(gè)是在程序運(yùn)行期來(lái)決定的。所以,我們使用一個(gè)Map先將算法注冊(cè)起來(lái),然后根據(jù)分片模式來(lái)調(diào)用它。這也是策略模式的體現(xiàn)。
@Component
public class AlgorithmHandler {
private Map<String, Algorithm> algorithm = new HashMap<>();
@PostConstruct
public void init(){
algorithm.put("range",new RangeAlgorithm());
algorithm.put("hash",new HashAlgorithm());
}
public String handler(String mode,String name,Object value,int length){
return algorithm.get(mode).doSharding(name, value,length);
}
}
3、攔截器
我們知道,MyBatis允許你在已映射語(yǔ)句執(zhí)行過(guò)程中的某一點(diǎn)進(jìn)行攔截調(diào)用。
如果你對(duì)它的原理還不熟悉,那么可以先看看筆者的文章:Mybatis攔截器的原理。
整體來(lái)看,它的流程如下:
- 通過(guò)
Mybatis攔截待執(zhí)行的SQL; - 通過(guò)
JSqlParser解析SQL,獲取邏輯表名等; - 調(diào)用分片算法獲取真實(shí)表名;
- 修改SQL,并修改
BoundSql; -
Mybatis執(zhí)行修改后的SQL,達(dá)成目的。
比如,對(duì)于insert語(yǔ)句和update語(yǔ)句,它的核心代碼如下:

五、查詢及分頁(yè)
事實(shí)上,新增和修改都比較簡(jiǎn)單,較為復(fù)雜的是查詢語(yǔ)句。
但是,我們的插件并不在于要滿足所有的查詢語(yǔ)句,而是可以根據(jù)真實(shí)的業(yè)務(wù)場(chǎng)景來(lái)擴(kuò)展修改。
不過(guò)分頁(yè)功能基本上是逃不開(kāi)的。拿PageHelper為例,它的原理也是通過(guò)Mybatis攔截器來(lái)實(shí)現(xiàn)的。如果它和我們的分表插件在一起,可能會(huì)產(chǎn)生沖突。
所以在分表插件中,筆者也集成了分頁(yè)功能,基本上和PageHelper一樣,但并未直接使用它。另外,對(duì)于查詢來(lái)說(shuō),在查詢條件中是否帶有分片鍵,也是很關(guān)鍵的地方。
1、查詢
在范圍算法中,在業(yè)務(wù)上我們要求只查詢特定某一個(gè)月或者近幾個(gè)月的數(shù)據(jù)即可;在Hash算法中,我們則要求每次都帶有主鍵。
但第二個(gè)條件往往不能成立,業(yè)務(wù)方也滿足不了每次都必須帶有主鍵。
針對(duì)這種情況,我們只能遍歷所有的表,查詢符合條件的數(shù)據(jù),然后再匯總返回;

這種方式的缺點(diǎn)顯而易見(jiàn),性能較差。還有一種方式就是可以將常用的查詢條件與分片鍵建立映射關(guān)系,在查詢時(shí)先根據(jù)查詢條件找到分片鍵的字段值,然后再根據(jù)分片鍵查詢。
2、分頁(yè)
如上所言,插件中集成了分頁(yè)功能,實(shí)現(xiàn)流程與PageHelper一樣,但考慮到?jīng)_突,并未直接使用。

六、其他
事實(shí)上,筆者在想本文的標(biāo)題時(shí),著實(shí)比較苦惱。因?yàn)?code>分庫(kù)分表在業(yè)界是一個(gè)詞,但本文插件并不涉及分庫(kù),僅有的只是分表操作而已,不過(guò)本文的重點(diǎn)是思路,最終還是叫了分庫(kù)分表,還請(qǐng)盆友們見(jiàn)諒,不要叫我標(biāo)題黨~
由于篇幅所限,文中只有少量的代碼,如果感興趣的盆友可以去https://github.com/taoxun/sharding獲取完整Demo。
筆者的代碼中,包含了一些測(cè)試用例和建表SQL,創(chuàng)建完表后直接運(yùn)行項(xiàng)目即可。