項目裏面的一個分表用到了sharding-jdbchtml
當時糾結過是用mycat仍是用sharding-jdbc的, 可是最終仍是用了sharding-jdbc, 緣由以下:mysql
1. mycat比較重, 相對於sharding-jdbc只需導入jar包就行, mycat還須要部署維護一箇中間件服務.因爲咱們只有一個表須要分表, 直接用輕量級的sharding-jdbc便可.
2. mycat做爲一箇中間代理服務, 不免有性能損耗
3. 其餘組用mycat的時候出現過生產BUG
複製代碼
然而sharding-jdbc也一樣是坑坑窪窪不斷的, 咱們從2.x版本改爲4.x版本, 又從4.x版本降到了3.x版本,每個版本都踩到了坑(有些是sharding-jdbc的, 有些是因爲咱們項目依賴的), 最終不得已改動了一下源碼才趟過去(其實就是註釋了一行代碼).算法
今天就來聊一下其中的一個坑--分表分頁sql
CREATE TABLE `order_00` (
`id` bigint(18) NOT NULL AUTO_INCREMENT COMMENT '邏輯主鍵',
`orderId` varchar(32) NOT NULL COMMENT '訂單ID',
`CREATE_TM` datetime DEFAULT NULL COMMENT '訂單建立時間',
PRIMARY KEY (`ID`) USING BTREE,
UNIQUE KEY `IDX_ORDER_POSTID` (`orderId`) USING BTREE,
KEY `IDX_ORDER_CREATE_TM` (`CREATE_TM`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT COMMENT='訂單表';
CREATE TABLE `order_01` (
`id` bigint(18) NOT NULL AUTO_INCREMENT COMMENT '邏輯主鍵',
`orderId` varchar(32) NOT NULL COMMENT '訂單ID',
`CREATE_TM` datetime DEFAULT NULL COMMENT '訂單建立時間',
PRIMARY KEY (`ID`) USING BTREE,
UNIQUE KEY `IDX_ORDER_POSTID` (`orderId`) USING BTREE,
KEY `IDX_ORDER_CREATE_TM` (`CREATE_TM`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT COMMENT='訂單表';
CREATE TABLE `order_02` (
`id` bigint(18) NOT NULL AUTO_INCREMENT COMMENT '邏輯主鍵',
`orderId` varchar(32) NOT NULL COMMENT '訂單ID',
`CREATE_TM` datetime DEFAULT NULL COMMENT '訂單建立時間',
PRIMARY KEY (`ID`) USING BTREE,
UNIQUE KEY `IDX_ORDER_POSTID` (`orderId`) USING BTREE,
KEY `IDX_ORDER_CREATE_TM` (`CREATE_TM`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT COMMENT='訂單表';
複製代碼
假設有以上三個分表, 分表邏輯用orderId取模, 即orderId=0的寫到order_00,orderId=1的寫到order_01,orderId=2的寫到order_02.數據庫
備註: 這裏爲啥不用時間分表而用orderId作hash, 當時也是很有爭議的. 理論上訂單表更適合使用時間作分表, 這樣一來時間越老的數據訪問的頻率越小, 舊的分表逐漸就會成爲冷表, 再也不被訪問到. 當時負責人的說法是, 因爲這個表讀寫頻率都高(並且場景中常常須要讀主庫), 用orderId分表能夠均衡寫負載和讀負載. 雖然是有點牽強, 但也有必定道理, 就先這麼實現了apache
業務上須要根據orderId或CREATE_TM進行分頁查詢, 即查詢sql的mybatis寫法大概以下:編程
<select id="queryPage" parameterType="xxx" resultMap="BaseResultMap">
select
<include refid="Base_Column_List"/>
from ORDER
<if test="orderId !=null and orderId !='' ">
AND orderId=#{orderId , jdbcType=VARCHAR}
</if>
<if test="createTmStartStr!=null and createTmStartStr!='' ">
AND create_tm >= concat(#{createTmStartStr, jdbcType=VARCHAR},' 00:00:00')
</if>
<if test="createTmEndStr!=null and createTmEndStr!='' ">
AND create_tm <= concat(#{createTmEndStr, jdbcType=VARCHAR},' 23:59:59')
</if>
limit #{page.begin}, #{page.pageSize}
</select>
複製代碼
用過sharding-jdbc的都知道, sharding-jdbc一共有5種分片策略,以下圖所示. 沒用過的能夠參考官網json
除了Hint分片策略, 其餘的分片策略都要求sql的where條件須要包含分片列(在咱們的表中是orderId), 很明顯咱們的業務場景中不能保證sql的where條件中必定會包含有orderId, 因此咱們只能使用HintShardingStrategy,將頁面的查詢條件傳遞給分片策略算法中, 再判斷查詢哪一個表, 大概代碼以下緩存
public class OrderHintShardingAlgorithm implements HintShardingAlgorithm {
public static final String ORDER_TABLE = "ORDER";
@Override
public Collection<String> doSharding(Collection<String> availableTargetNames, ShardingValue shardingValue) {
ListShardingValue<String> listShardingValue = (ListShardingValue<String>) shardingValue;
List<String> list = Lists.newArrayList(listShardingValue.getValues());
List<String> actualTable = Lists.newArrayList();
// 頁面上的查詢條件會以json的方式傳到shardingValue變量中
String json = list.get(0);
OrderQueryCondition req = JSON.parseObject(json, OrderQueryCondition.class);
String orderId = req.getOrderId();
// 查詢條件沒有orderId, 要查全部的分表
if(StringUtils.isEmpty(orderId)){
// 全部的分表
for(int i = 0 ; i< 3; i++){
actualTable.add(ORDER_TABLE + "_0" + i);
}
}else{
// 若是指定了orderId, 只查orderId所在的分表便可
long tableSuffix = ShardingUtils.getHashInteger(orderId);
actualTable.add(ORDER_TABLE + "_0" + tableSuffix);
}
// actualTable中包含sharding-jdbc實際會查詢的表
return actualTable;
}
}
複製代碼
這樣子, 若是咱們根據orderId來查詢的話, sharding-jdbc最終執行的sql就是(假設每頁10條):bash
select * from ORDER_XX where orderId = ? limit 0 ,10
複製代碼
若是查詢條件沒有orderId, 那麼最終執行的sql就是3條(假設每頁10條):
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit 0 ,10 ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit 0 ,10 ;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit 0 ,10 ;
複製代碼
注意在有多個分表的狀況下, 每一個表都取前10條數據出來(一共30條), 而後再排序取前10條, 這樣的邏輯是不對的. sharding-jdbc給了個例子, 若是下圖:
圖中的例子中,想要取得兩個表中共同的按照分數排序的第2條和第3條數據,應該是95和90。 因爲執行的SQL只能從每一個表中獲取第2條和第3條數據,即從t_score_0表中獲取的是90和80;從t_score_0表中獲取的是85和75。 所以進行結果歸併時,只能從獲取的90,80,85和75之中進行歸併,那麼結果歸併沒有論怎麼實現,都不可能得到正確的結果.
那怎麼辦呢?
sharding-jdbc的作法就改寫咱們的sql, 先查出來全部的數據, 再作歸併排序
例如查詢第2頁時
原sql是:
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit 10 ,10 ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit 10 ,10 ;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit 10 ,10 ;
會被改寫成:
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit 0 ,20 ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit 0 ,20 ;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit 0 ,20 ;
複製代碼
查詢第3頁時
原sql是:
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit 20 ,10 ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit 20 ,10 ;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit 20 ,10 ;
會被改寫成:
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit 0 ,30 ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit 0 ,30 ;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit 0 ,30 ;
複製代碼
固然, 你們確定會以爲這樣處理性能會不好, 其實事實上也的確是, 不過sharing-jdbc是在這個基礎上作了優化的,就是上面提到的"歸併", 具體歸併過程能夠戳這裏查看官網的說明.篇幅比較長, 我這裏就再也不貼出來了
大概的邏輯就是先查出全部頁的數據, 而後經過流式處理跳過前面的頁,只取最終須要的頁,最終達到分頁的目的
既然sharding-jdbc都已經優化好了, 那麼咱們踩到的坑究竟是什麼呢?
聽我慢慢道來
在io.shardingsphere.shardingjdbc.jdbc.core.statement.ShardingPreparedStatement#getResultSet()中有個邏輯,
若是查詢的分表數只有一個的話, 就不會作歸併的邏輯(然而就算只查一個分表, sql的limit子句也會被改寫了), 如圖:
回到咱們的業務場景, 若是查詢條件包含了orderId的話, 由於能夠定位到具體的表, 因此最終須要查詢的分表就只有一個.
那麼問題就來了, 因爲sharding-jdbc把咱們的sql的limit子句給改寫了, 後面卻因爲只查一個分表而沒有作歸併(也就是沒有跳過前面的頁),因此最終無論是查詢第幾頁,執行的sql都是(假設頁大小是10000):
select * from ORDER_XX where orderId = ? limit 0 ,10000
select * from ORDER_XX where orderId = ? limit 0 ,20000
select * from ORDER_XX where orderId = ? limit 0 ,30000
select * from ORDER_XX where orderId = ? limit 0 ,40000
......
複製代碼
這樣就致使了一個問題, 無論我傳的頁碼是什麼, sharding-jdbc都會給我返回同一條數據. 很明顯這樣是不對的.
固然, 心細的朋友可能會發現了, 因爲orderId是個惟一索引, 因此確定只有一條數據, 因此永遠不會存在查詢第二頁的狀況.
正常來講的確是這樣, 然而在咱們的代碼裏面, 還有個老邏輯: 導出查詢結果(就是導出全部頁的數據)時, 會異步地在後臺一頁一頁地 導出, 直到導出了全部的頁或者達到了查詢次數上限(假設是查詢1萬次).
因此在根據orderId導出的時候, 由於每一頁都返回相同的數據, 因此判斷不了何時是"導完了全部的頁", 因此正確結果本應該是隻有一條數據的, 可是在sharding-jdbc下卻執行了一萬次, 導出了一萬條相同的數據, 你說這個是否是坑呢?
知道問題所在, 那解決就簡單了. 可是本文並非想聊怎麼解決這個問題的, 而是想聊聊經過這個問題引發的思考:
在mysql分表環境下, 如何高效地作分頁查詢?
複製代碼
在討論分表環境下的分頁性能以前, 咱們先來看一下單表環境下應該實現分頁.
衆所周知, 在mysql裏面實現分頁只須要使用limit子句便可, 即
select * from order limit (pageNo-1) * pageSize, pageSize
複製代碼
因爲在mysql的實現裏面, limit offset, size是先掃描跳過前面的offset條數據,再取size條數據. 當pageNo越大的時候, offset也會越大, mysql掃描的數據也越大, 因此性能會急劇降低.
所以, 分頁第一個要解決的問題就是當pageNo過大時, 怎麼優化性能.
第一個方案是這篇文章介紹的索引覆蓋的方案. 總結來講就是把sql改寫成這樣:
select * from order where id >= (select id from order limit (pageNo-1) * pageSize, 1) limit pageSize
複製代碼
利用索引覆蓋的原理, 先直接定位當前頁的第一條數據的最小id, 而後再取須要的數據.
這樣的確能夠提升性能, 可是我認爲仍是沒有完全解決問題, 由於當pageNo過大的時候, mysql仍是會須要掃描不少的行來找到最小的id. 而掃描的那些行都是沒有意義.
遊標查詢是elasticSearch裏面的一個術語, 可是我這裏並非指真正的scroll查詢, 而是借鑑ES裏面的思想來實現mysql的分頁查詢.
所謂的scroll就是滾動, 一頁一頁地查. 大概的思想以下:
1. 查詢第1頁
select * from order limit 0, pageSize;
2. 記錄第1頁的最大id: maxId
3. 查詢第2頁
select * from order where id > maxId limit pageSize
4. 把maxId更新爲第2頁的最大id
... 以此類推
複製代碼
能夠看到這種算法對於mysql來講是毫無壓力的, 由於每次都只須要掃描pageSize條數據就能達到目的. 相對於上面的索引覆蓋的方案, 能夠極大地提升查詢性能.
固然它也有它的侷限性:
1. 性能的提升帶來的代價是代碼邏輯的複雜度提升. 這個分頁邏輯實現起來比較複雜.
2. 這個算法對業務數據是有要求的, 例如id必須是單調遞增的,並且查詢的結果須要是用Id排序的.
若是查詢的結果須要按其餘字段(例如createTime)排序, 那就要求createTime也是單調的, 並把算法中的id替換成createTime.
有某些排序的場景下, 這種算法會不適用.
3. 這個算法是須要業務上作妥協的, 你必須說服你的產品經理放棄"跳轉到特定頁"的功能, 只能經過點擊"下一頁"來進行翻頁.
(這纔是scroll的含義, 在手機或平板上,只能經過滾動來翻頁,而沒法直接跳轉到特定頁)
複製代碼
如上面討論, 在單表環境下, 想要實現高效的分頁, 仍是相對比較簡單的.
那若是在分表環境下, 分頁的實現會有什麼不一樣呢?
正如上面提到的, sharding-jdbc中已經論證過了, 分表環境的分頁查詢, 若是不把
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit (pageNo-1) * pageSize ,pageSize ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit (pageNo-1) * pageSize ,pageSize;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit (pageNo-1) * pageSize ,pageSize ;
複製代碼
改寫成
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit 0 , (pageNo-1) * pageSize + pageSize ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit 0 , (pageNo-1) * pageSize + pageSize;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit 0 , (pageNo-1) * pageSize + pageSize ;
複製代碼
那麼最終查出來的數據, 頗有可能不是正確的數據. 因此在分表環境下, 上面所說的"索引覆蓋法"和"遊標查詢法"確定是都不適用了的. 由於必須查出全部節點的數據,再進行歸併, 那纔是正確的數據.
所以, 要在分表環境下實現分頁功能, 基本上是要對limit子句進行改寫了的.
先來看sharing-jdbc的解決方案, 改寫後的limit 0 , (pageNo-1) * pageSize + pageSize 和原來的limit (pageNo-1) * pageSize, pageSize對比, 數據庫端的查詢壓力都是差很少的, 由於都是要差很少要 掃描(pageNo-1) * pageSize 行才能取獲得數據. 不一樣的是改寫sql後, 客戶端的內存消耗和網絡消耗變大了.
sharding-jdbc巧妙地利用流式處理和優先級隊列結合的方式, 消除了客戶端內存消耗的壓力, 可是網絡消耗的影響依然是沒法消除.
因此真的沒有更好的方案了?
那確定是有的, 在業界難題-「跨庫分頁」的四種方案這篇文章中, 做者提到了一種"二次查詢法", 就很是巧妙地解決了這個分頁查詢的難題. 你們能夠參考一下.
可是仔細思考一下, 仍是有必定的侷限性的:
1. 當分表數爲N時, 查一頁數據要執行N*2條sql.(這個無解, 只要分表了就必須這樣)
2. 當offset很大的時候, 第一次查詢中掃描offset行數據依然會很是的慢, 若是隻分表不分庫的話, 那麼一次查詢會在一個庫中產生N條慢sql
3. 算法實現起來代碼邏輯應該不簡單, 若是爲了一個分頁功能寫這麼複雜的邏輯, 是否是划不來,
並且後期也很差維護
複製代碼
若是算法原做者看到我這裏的雞蛋挑骨頭, 會不會有點想鄙視我~~
其實我想表達的意思是, 既然分表環境下的分頁查詢沒有完美的解決方案的話,或者實現起來成本過大的話, 那是否是能夠認爲: 分表環境下就不該該作分頁查詢?
上面說到, 其實分表環境下就不適宜再作分頁查詢的功能. 可是業務上的需求並非說去掉就去掉的, 不少狀況下分頁功能是必須的, 然而分頁查詢的存在一般也是爲了保護數據庫, 去掉了分頁功能, 數據庫的壓力反而更大.
因此分表和分頁只能二選一?
不, 我全都要, 分表我要, 分頁我也要!
可是分頁功能不在分表環境裏面作, 而是在另一張彙總表裏面作分頁查詢的功能.
大概的方案就是:
1. 正常的業務讀寫分表
2. 根據具體的業務需求,例如實時計算/離線計算技術(spark, hadoop,hive, kafka等)生成各分表的一張彙總表
3. 分頁查詢的接口直接查詢彙總表
複製代碼
另外還要注意這個方案對業務來講確定是有損的, 具體表現爲:
1. 無論是離線計算仍是實時計算, 都不能保證明時性, 查詢結果確定是有時延的
2. 因爲彙總表是不可能包含分表的全部數據的, 因此彙總表確定是只包含部分數據的,例如只有一個月內的,具體根據業務場景而定
複製代碼
總的來講, 就是報表系統的數據由數據倉庫系統來生成, 但只能生成用戶非要不可的數據,其餘的都去掉.
寫這篇總結在找資料的時候, 看到一句話:
其實分表的根本目的是分攤寫負載, 而不是分攤讀負載
複製代碼
實際上是有必定道理的, 若是讀負載太高, 咱們能夠增長緩存, 增長數據節點等不少方法, 而寫負載太高的話, 分表基本就是勢在必行了.
從這個理論來講, 分頁是一個讀操做, 根本就沒有必要去讀取分表, 從其餘地方讀取(咱們這裏是數據倉庫)便可
其實大多數mysql的表都沒有必要分表的
在mysql5.5以前, 表數量大概在在500W以後就要進行優化, 在mysql5.5以後, 表數量在1KW到2KW左右才須要作優化. 在這個性能拐點以前, 能夠認爲mysql是徹底有能力扛得住的.固然, 具體還要看qps以及讀寫衝突等的頻率的.
到了性能拐點以後呢? 那就要考慮對mysql的表進行拆分了. 表拆分的手段能夠是分表分庫, 或者就簡單的分區.
基原本說, 分區和分錶帶來的性能提高是同樣的, 因爲分區實際上就能夠認爲是mysql底層來幫咱們實現分表的邏輯了, 因此相對來講分表會比分區帶來更高的編碼複雜度(分區就根本不用考慮多表分頁查詢的問題了). 從這個角度來講, 通常的業務直接分區就能夠了.
固然, 選擇分區仍是分表仍是須要作一點權衡的:
1. 表中的數據只有部分熱點數據常常訪問, 其餘的不常訪問的話, 適合用分區表
2. 分區表相對容易維護, 能夠針對單獨一個分區進行檢查,優化, 批量刪除大量數據時, 分區表會比通常的表更快
3. 分區表能夠分佈在不一樣的物理設備上, 從而能夠高效地利用多個硬盤
4. 若是查詢條件不包含partition key的話, 分區表不必定有分表效率高
5. 若是分區表中絕對的熱點數據, 每一條數據都有可能被訪問到, 也不太適合分區
6. 若是數據量超大, 因爲mysql只能分1024個分區, 若是1024個分區的數據都是千萬以上, 那確定是也不適合分區的了
複製代碼
綜上所述, 若是分區表就足夠知足咱們的話, 那其實就沒有必要進行分表了增長編程的複雜度了.
另外, 若是不想將數據表進行拆分, 而表的數據量又的確很大的話, nosql也是一個替代方案. 特別是那些不須要強事務的表操做, 就很適合放在nosql, 從而能夠避免編程的複雜度, 同時性能上也沒有過多的損耗.
nosql的方案也有不少:
1. mongoDb
2. hbase
3. tidb
4. elasticSearch
複製代碼
固然也可使用mysql+nosql結合的方式, 例如常規讀寫操做mysql, 分頁查詢走ES等等.
今天就先寫到這, 有機會再寫寫mysql和nosql