項目裏面的一個分表用到了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版本,每個版本都踩到了坑(有些是官方的, 有些是因爲咱們項目依賴的),
最終不得已改動了一下源碼才趟過去(其實就是註釋了一行代碼).算法
今天就來聊一下其中的一個坑--分表分頁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條):網絡
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. 分頁查詢的接口直接查詢彙總表
另外還要注意這個方案對業務來講確定是有損的, 具體表現爲:
```
總的來講, 就是報表系統的數據由數據倉庫系統來生成, 但只能生成用戶非要不可的數據,其餘的都砍掉. 寫這篇總結在找資料的時候, 看到一句話:
其實分表的根本目的是分攤寫負載, 而不是分攤讀負載
實際上是有必定道理的, 若是讀負載太高, 咱們能夠增長緩存, 增長數據節點等不少方法, 而寫負載太高的話, 分表基本就是勢在必行了. 從這個理論來講, 分頁是一個讀操做, 根本就沒有必要去讀取分表, 從其餘地方讀取(咱們這裏是數據倉庫)便可 #### 不分表(分區 tidb mongoDb ES) 其實大多數mysql的表都沒有必要分表的 在mysql5.5以前, 表數量大概在在500W以後就要進行優化, 在mysql5.5以後, 表數量在1KW到2KW左右才須要作優化. 在這個性能拐點以前, 能夠認爲mysql是徹底有能力扛得住的.固然, 具體還要看qps以及讀寫衝突等的頻率的. 到了性能拐點以後呢? 那就要考慮對mysql的表進行拆分了. 表拆分的手段能夠是分表分庫, 或者就簡單的分區. 基原本說, 分區和分錶帶來的性能提高是同樣的, 因爲分區實際上就能夠認爲是mysql底層來幫咱們實現分表的邏輯了, 因此相對來講分表會比分區帶來更高的編碼複雜度(分區就根本不用考慮多表分頁查詢的問題了). 從這個角度來講, 通常的業務直接分區就能夠了. 固然, 選擇分區仍是分表仍是須要作一點權衡的:
綜上所述, 若是分區表就足夠知足咱們的話, 那其實就沒有必要進行分表了增長編程的複雜度了. 另外, 若是不想將數據表進行拆分, 而表的數據量又的確很大的話, nosql也是一個替代方案. 特別是那些不須要強事務的表操做, 就很適合放在nosql, 從而能夠避免編程的複雜度, 同時性能上也沒有過多的損耗. nosql的方案也有不少:
固然也可使用mysql+nosql結合的方式, 例如常規讀寫操做mysql, 分頁查詢走ES等等.
今天就先寫到這, 有機會再寫寫mysql和nosql