隨着系統數據量的日益增加,在提及數據庫架構和數據庫優化的時候,咱們不免會經常聽到分庫分表這樣的名詞。git
固然,分庫分表有不少的方法論,好比垂直拆分、水平拆分;也有不少的中間件產品,好比MyCat、ShardingJDBC。github
根據業務場景選擇合適的拆分方法,再選擇一個熟悉的開源框架,就能幫助咱們完成項目中所涉及到的數據拆分工做。算法
本文並不打算就這些方法論和開源框架展開深刻的探討,筆者想討論另一個場景:sql
若是系統中須要拆分的表並很少,只是1個或者少許的幾個,咱們是否值得引入一些相對複雜的中間件產品;特別是,若是咱們對它們的原理不甚瞭解,是否有信心駕馭它們 ?數據庫
基於此,若是你的系統中有少許的表須要拆分,也沒有專門的資源去研究開源組件,那麼咱們能夠本身來實現一個簡單的分庫分表插件;固然,若是你的系統比較複雜,業務量較大,仍是採用開源組件或者團隊自研組件來解決這事較爲穩妥。bash
分庫分表這事說簡單也簡單,說複雜那也挺複雜...架構
簡單是由於它的核心流程比較明確。就是解析SQL語句,而後根據預先配置的規則,重寫或路由到真實的數據庫表中去;框架
複雜在於,SQL語句複雜且靈活,好比分頁、去重、排序、分組、聚合、關聯查詢等操做,如何正確的解析它們。ide
因此就算是ShardingJDBC
,在官網中也明確了支持項和不支持項。post
相對於複雜的配置文件,咱們採用較爲輕便的註解式配置,它的定義以下:
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Sharding {
String tableName(); //邏輯表名
String field(); //分片鍵
String mode(); //算法模式
int length() default 0; //分表數量
}
複製代碼
那麼,在哪裏使用它呢 ? 好比咱們的用戶表須要分表,那就在User這個實體對象上標註。
@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;
}
複製代碼
這就說明了,我一共有 16 張用戶表,根據用戶ID,使用Hash算法來計算它的位置。
固然,咱們不止有Hash算法,還能夠根據日期範圍來定義。
@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;
}
複製代碼
在這裏,筆者實現了兩種分片方式,就是HashAlgorithm和RangeAlgorithm
。
若是你的系統中有使用冷熱數據分離,咱們能夠按照日期將不一樣月的數據分散到不一樣的表中。
好比車輛的建立時間是2019-12-10 15:30:00
,這條數據將會被分配到car_201912
這張表中去。
咱們經過截取時間的年月部分,而後再加上邏輯表名便可。
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("時間格式不符合要求!傳入參數:{},正確格式:{}",value.toString(),"yyyy-MM-dd HH:mm:ss");
return tableName;
}
}
return tableName;
}
}
複製代碼
在Hash分片算法中,咱們能夠先判斷表的數量,是否是2的冪次方。若是不是,就經過算數方式獲取下標,若是是呢,就經過位運算的方式獲取下標。固然了,這是在HashMap源碼中學到的哦。
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;
}
}
}
複製代碼
配置和分片算法都有了,接下來就是重頭戲了。在這裏,咱們使用Mybatis攔截器
將它們派上用場。
常年CRUD的咱們,都知道一條業務SQL確定逃不出它們的範圍。其中,在業務上咱們的刪除功能通常都是邏輯刪除,因此,基本上不會有DELETE操做。
相較而言,新增和修改SQL都比較簡單且格式固定,查詢SQL每每比較靈活且複雜。因此,在這裏筆者定義了兩個攔截器。
不過,在介紹攔截器以前,咱們有理由要了解另外兩個東西:SQL語法解析器和分片算法處理器。
JSqlParser
負責解析SQL語句,並轉化爲Java類的層次結構。咱們能夠先看個簡單的例子來認識它。
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的語法信息。相應的,咱們也能夠更改對象內容,從而達到修改SQL語句的目的。
咱們的分片算法有多個,具體應該調用哪個是在程序運行期來決定的。因此,咱們使用一個Map先將算法註冊起來,而後根據分片模式來調用它。這也是策略模式的體現。
@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);
}
}
複製代碼
咱們知道,MyBatis容許你在已映射語句執行過程當中的某一點進行攔截調用。
若是你對它的原理還不熟悉,那麼能夠先看看筆者的文章:Mybatis攔截器的原理。
總體來看,它的流程以下:
Mybatis
攔截待執行的SQL;JSqlParser
解析SQL,獲取邏輯表名等;BoundSql
;Mybatis
執行修改後的SQL,達成目的。好比,對於insert
語句,它的核心代碼以下:
String sql = boundSql.getSql();
Statement statement = CCJSqlParserUtil.parse(sql);
Insert insert = (Insert) statement;
Table table = insert.getTable();
String newName = this.handler.handler(mode, table.getName(), value,length);
table.setName(newName);
ReflectionUtil.setField(boundSql,"sql",insert.toString());
複製代碼
事實上,新增和修改都比較簡單,較爲複雜的是查詢語句。
可是,咱們的插件並不在於要知足全部的查詢語句,而是能夠根據真實的業務場景來擴展修改。
不過度頁功能基本上是逃不開的。拿PageHelper
爲例,它的原理也是經過Mybatis
攔截器來實現的。若是它和咱們的分表插件在一塊兒,可能會產生衝突。
因此在分表插件中,筆者也集成了分頁功能,基本上和PageHelper
同樣,但並未直接使用它。另外,對於查詢來講,在查詢條件中是否帶有分片鍵,也是很關鍵的地方。
在範圍算法中,在業務上咱們要求只查詢特定某一個月或者近幾個月的數據便可;在Hash算法中,咱們則要求每次都帶有主鍵。
但第二個條件每每不能成立,業務方也知足不了每次都必須帶有主鍵。
針對這種狀況,咱們只能遍歷全部的表,查詢符合條件的數據,而後再彙總返回;
for (int i=0;i<sharding.length();i++){
Statement parse = CCJSqlParserUtil.parse(boundSql.getSql());
sqlParser.processSelect(parse,i);
cacheKey.update(new Object());
List<E> query = ExecutorUtil.query(parse.toString());
result.addAll(query);
}
複製代碼
這種方式的缺點顯而易見,性能較差。還有一種方式就是能夠將經常使用的查詢條件與分片鍵創建映射關係,在查詢時先根據查詢條件找到分片鍵的字段值,而後再根據分片鍵查詢。
如上所言,插件中集成了分頁功能,實現流程與PageHelper
同樣,但考慮到衝突,並未直接使用。
private <E> List<E> queryPage(){
Long count = this.getCount();
page.setTotal(count.intValue());
page.setCountPage();
String limitSql = getLimitSql(page,sql);
List<E> query = ExecutorUtil.query();
page.addAll(query);
return page;
}
複製代碼
事實上,筆者在想本文的標題時,着實比較苦惱。由於分庫分表
在業界是一個詞,但本文插件並不涉及分庫,僅有的只是分表操做而已,不過本文的重點是思路,最終仍是叫了分庫分表
,還請盆友們見諒,不要叫我標題黨~
因爲篇幅所限,文中只有少許的代碼,若是感興趣的盆友能夠去https://github.com/taoxun/sharding
獲取完整Demo。
筆者的代碼中,包含了一些測試用例和建表SQL,建立完表後直接運行項目便可。