前言
一般來說,影響資料庫最大的效能問題有兩個,一個是對資料庫的操作,一個是資料庫中的資料太大。對於前者我們可以藉助快取來減少一部分讀操作,針對一些複雜的報表分析和搜尋可以交給 Hadoop 和ElasticSearch 。對於後者,我們就只能分庫分表,讀寫分離。
網際網路行業隨著業務的複雜化,大多數應用都會經歷資料的垂直分割槽。一個複雜的流程會按照領域拆分成不同的服務,每個服務中心都擁有自己獨立的資料庫。拆分後服務共享,業務更清晰,系統也更容易擴充套件,同時減少了單庫資料庫連線數的壓力,也在一定程度上提高了單表大資料量下索引查詢的效率。當然業務隔離,也可以避免一個業務把資料庫拖死導致所有業務都死掉,我們將這種按照業務維度,把一個庫拆分為多個不同的庫的方式叫做垂直拆分。
垂直拆分也包含針對長表(屬性很多)做冷熱分離的拆分。例如,在商品系統設計中,一個商品的生產商、供銷商以及特有屬性,這些欄位變化頻率低,查詢次數多,叫做冷資料;而商品的份額,關注量等類似的統計資訊變化頻率較高,叫做活躍資料或者熱資料。在 MySQL 中,冷資料查詢多更新少,適合用 MyISAM 儲存引擎,而熱資料更新比較頻繁適合用 InnoDB,這也是垂直拆分的一種。
當單表資料量隨著業務發展繼續膨脹,在 MySQL 中當資料量達到千萬級時,就需要考慮進行水平拆分了,這樣資料就分散到不同的表上,單表的索引大小得到控制,可以提升查詢效能。當資料庫的例項吞吐量達到效能瓶頸後,我們需要水平擴充套件資料庫的例項,讓多個資料庫例項分擔請求,這種根據分片演算法,將一個庫拆分成多個一樣結構的庫,將多個表拆分成多個結構相同的表就叫做水平拆分。
資料拆分也有很多缺點,資料分散,資料庫的 Join 操作變得更加複雜,分片後資料的事務一致性很難保證,同時資料的擴容和維護難度增加,拆分規則也可能導致某個業務需要同時查詢所有的表然後進行聚合。如果需要排序和函式計算則更加複雜,所以不到萬不得已可以先不必拆分。
根據分庫分表方案中實施切片邏輯的層次不同,我們將分庫分表的實現方案分成以下4種:
1. 在應用層直接分片
這種方式將分片規則直接放在應用層,雖然侵入了業務,開發人員不僅既需要實現業務邏輯也需要實現分庫分表的配置的開發,但是實現起來簡單,適合快速上線,通過編碼方式也更容易實現跨表遍歷的情況。後期故障也更容易定位,大多數公司都會在業務早期採用此種方式過渡,後期分表需求增多,則會尋求中介軟體來解決,以下程式碼為銅板街早期訂單表在 DAO 層將分片資訊以引數形式傳到 mybatis 的 mapper 檔案中的實現方案。
1 @Override
2 public OrderDO findByPrimaryKey(String orderNo) {
3
4 Assert.hasLength(orderNo, "訂單號不能為空");
5
6 Map<String, Object> map = new HashMap<String, Object>(3);
7 map.put("tableSuffix", orderRouter.routeTableByOrderNo(orderNo));
8 map.put("dbSuffix", orderRouter.routeDbByOrderNo(orderNo));
9 map.put("orderNo", orderNo);
10
11 Object obj = getSqlSession().selectOne("NEW_ORDER.FIND_BY_PRIMARYKEY", map);
12 if (obj != null && obj instanceof OrderDO) {
13 return (OrderDO) obj;
14 }
15 return null;
16 }
複製程式碼
2. 在ORM層直接分片
這種方式通過擴充套件第三方 ORM 框架,將分片規則和路由機制嵌入到 ORM 框架中,如hibernate 和 mybatis,也可以基於 spring jdbctemplate 來實現,目前實現方案較少。
3. 客戶端定製 JDBC 協議
這種方式比較常見對業務侵入低。通過定製 JDBC 協議,針對業務邏輯層提供與 JDBC一致的介面,讓開發人員不必要關心分庫分表的具體實現,分庫分表在 JDBC 內部搞定,對業務層透明。目前流行的 ShardingJDBC、TDDL 便採用了這種方案。這種方案需要開發人員熟悉 JDBC 協議,研發成本較低,適合大多數中型企業。
4. 代理分片
此種分片方式,是在應用層和資料庫層增加一個代理,把分片的路由規則配置在代理層,代理層提供與 JDBC 相容的介面給應用層,開發人員不用關心分片邏輯實現,只需要在代理層配置即可。增加代理伺服器,需要解決代理的單點問題增加硬體成本,同時所有的資料庫請求增加了一層網路傳輸影響效能,當然維護也需要更資深的專家,目前採用這種方式的框架有 cobar 和 mycat。
切片演算法
選取分片欄位
分片後,如果查詢的標準是根據分片的欄位,則根據切片演算法,可以路由到對應的表進行查詢。如果查詢條件中不包含分片的欄位,則需要將所有的表都掃描一遍然後在進行合併。所以在設計分片的時候我們一般會選擇一個查詢頻率較高的欄位作為分片的依據,後續的分片演算法會基於該欄位的值進行。例如根據建立時間欄位取對應的年份,每年一張表,取電話號碼裡面的最後一位進行分表等,這個分片的欄位我們一般會根據查詢頻率來選擇。例如在互金行業,使用者的持倉資料,我們一般選擇使用者 id 進行分表,而使用者的交易訂單也會選擇使用者 id 進行分表,但是如果我們要查詢某個供應商下在某段時間內的所有訂單就需要遍歷所有的表,所以有時候我們可能會需要根據多個欄位同時進行分片,資料進行冗餘儲存。
分片演算法
分片規則必須保證路由到每張物理表的資料量大致相同,不然上線後某一張表的資料膨脹的特別快,而其他表資料相對很少,這樣就失去了分表的意義,後期資料遷移也有很高的複雜度。通過分片欄位定位到對應的資料庫和物理表有哪些演算法呢?(我們將分表後在資料庫上物理儲存的表名叫物理表,如 trade_order_01,trade_order_02,將未進行切分前的表名稱作邏輯表如 trade_order)大致可以有以下分類:
按日期 如年份,季度,月進行分表,這種維度分表需要注意在邊緣點的垮表查詢。例如如果是根據建立時間按月進行分片,則查詢最近3天的資料可能需要遍歷兩張表,這種業務比較常見,但是放中介軟體層處理起來就比較複雜,可能在應用層特殊處理會簡單點。
雜湊 ,這種是目前比較常用的演算法,但是這裡謹慎推薦,因為他的後期擴容是件很頭痛的事情,例如根據使用者 ID 對64取模,得到一個0到63的數字,這裡最多可以切分64張表{0,1,2,3,4… 63},前期可能用不到這麼多,我們可以藉助一致性雜湊的演算法,每4個連續的數字分成放到一張表裡。例如 0,1,2,3 分到00這張表,4,5,6,7分到04這張表,用演算法表示 floor(userID % 64 / 4) * 4 假設 floor為取整的效果。
按照一致性雜湊演算法,當需要進行擴容一倍時需要遷移一半的資料量,雖然不至於遷移所有的資料,如果沒有工具也是需要很大的開發量。下圖中根據分表欄位對16取餘後分到4張表中,後面如果要擴容一倍則需要遷移一半的資料。
擷取 這種演算法將欄位中某一段位置的資料擷取出來,例如取電話號碼裡面的尾數,這種方式實現起來簡單,但在上線前一定要預測最終的資料分佈是否會平均。比如地域,姓氏可能並不平均等,以4結尾的電話號碼也相對偏少。
特別注意點
分庫和分表演算法需要保證不相關,上線前一定要用線上資料做預測。例如分庫演算法用“使用者id%64 分64個庫” 分表演算法也用 “使用者id%64 分64張表”,總計 64 * 64 張表,最終資料都將落在 以下 64張表中 00庫00表,01庫01表… 63庫63表, 其他 64 * 63張表則沒有資料。這裡可以推薦一個演算法,分庫用 使用者ID/64 % 64 , 分表用 使用者ID%64 測試1億筆使用者id發現分佈均勻。
在分庫分表前需要規劃好業務增長量,以預備多大的空間,計算分表後可以支援按某種資料增長速度可以維持多久。
如何實現客戶端分片
客戶端需要定製 JDBC 協議,在拿到待執行的 sql 後,解析 sql,根據查詢條件判斷是否存在分片欄位。如果存在,再根據分片演算法獲取到對應的資料庫例項和物理表名,重寫 sql,然後找到對應的資料庫 datasource 並獲取物理連線,執行 sql,將結果集進行合併篩選後返回。如果沒有分片欄位,則需要查詢所有的表,注意,即使存在分片欄位,但是分片欄位在一個範圍內,可能也需要查詢多個表,針對 select 以外的 sql 如果沒有傳分片欄位建議直接丟擲異常。
JDBC 協議
我們先回顧下一個完整的通過 JDBC 執行一條查詢 sql 的流程,其實 druid 也是在 JDBC 上做增強來做監控的,所以我們也可以適當參考 druid 的實現。
1 @Test
2 public void testQ() throws SQLException,NamingException{
3 Context context = new InitialContext();
4 DataSource dataSource = (DataSource)context.lookup("java:comp/env/jdbc/myDataSource");
5 Connection connection = dataSource.getConnection();
6 PreparedStatement preparedStatement = connection.prepareStatement("select * from busi_order where id = ?");
7 preparedStatement.setString(1,"1");
8 ResultSet resultSet = preparedStatement.executeQuery();
9 while(resultSet.next()){
10 String orderNo = resultSet.getString("order_no");
11 System.out.println(orderNo);
12 }
13
14 preparedStatement.close();
15 connection.close();
16 }
複製程式碼
- datasource 需要提供根據分片結果獲取對應的資料來源的datasource,返回的connection應該是定製後的 connection,因為在執行 sql 前還無法知道是哪個庫哪個表,所以只能返回一個邏輯意義上的 connection。
- 雜湊 connection 定製的 connection,需要實現獲取 statement,執行 sql 關閉。設定auto commit 等方法,在執行 sql 和獲取 statement 的時候應該進行路由找到物理表後 在執行操作。由於該 connection 是邏輯意義上的,針對關閉,設定 auto commit 等需要將關聯的多個物理 connection 一起設定。
- statement 定製化的 statement,由於和 connection 都提供了執行 sql 的方法,所以我們可以將執行 sql 都交給一個執行器執行,connection 和 statement 中都通過這個執行器執行sql。在執行器重解析 sql 獲取物理連線,結果集處理等操作。
- resultset resultset 是一個迭代器,遍歷的時候資料來源由資料庫提供,但我們在某些有排序和 limit 的查詢中,可能迭代器直接在記憶體中遍歷資料。
SQL解析
sql 解析一般藉助 druid 框架裡面的 SQLStatementParser 類。解析好的資料都在 SQLStatement 中,當然有條件的可以自己研究 SQL 解析,不過可能工作量有點大。
解析出 sql 型別,目前生成環境主要還是4中 sql 型別: SELECT DELETE UPDATE INSERT ,目前是直接解析 sql 是否以上面4個單詞開頭即可,不區分大小寫。
insert 型別需要區分,是否是批量插入,解析出 insert 插入的列的欄位名稱和對應的值,如果插入的列中不包含分片欄位,將無法定位到具體插入到哪個物理表,此時應該丟擲異常。
delete 和 update 都需要解析 where 後的條件,根據查詢條件裡的欄位,嘗試路由到指定的物理表,注意此時可能會出現 where 條件裡面 分片欄位可能是一個範圍,或者分片欄位存在多個限制。
select 和其他型別不同的是,返回結果是一個 list,而其他三種 sql 直接返回狀態和影響行數即可。同時 select 可能出現關聯查詢,以及針對查詢結果進行篩選的操作,例如where 條件中除了普通的判斷表示式,還可能存在 limit,order by,group by,having等,select 的結果中也可能包含聚合統計等資訊,例如 sum,count,max,min,avg等,這些都需要解析出來方便後續結果集的處理,後續重新生成 sql 主要是替換邏輯表名為物理表名,並獲取對應的資料庫物理連線。
針對 avg 這種操作,如果涉及查詢多個物理表的,可能需要改寫 sql 去查詢 sum 和count 的資料或者 avg 和 count 的資料,改寫需要注意可能原 sql 裡面已經包含了count,sum等操作了。
分片路由演算法
分片演算法,主要通過一個表示式,從分片欄位對應的值獲取到分片結果,可以提供簡單地 EL表示式,就可以實現從值中擷取某一段作為分表資料,也可以提供通用的一致性雜湊演算法的實現,應用方只需要在 xml 或者註解中配置即可,以下為一致性雜湊在銅板街的實現。
1 /**
2 * 最大真實節點數
3 */
4 private int max;
5
6 /**
7 * 真實節點的數量
8 */
9 private int current;
10
11 private int[] bucket;
12
13 private Set suffixSet;
14
15 public void init() {
16 bucket = new int[max];
17 suffixSet = new TreeSet();
18
19 int length = max / current;
20 int lengthIndex = 0;
21
22 int suffix = 0;
23
24 for (int i = 0; i < max; i++) {
25 bucket[i] = suffix;
26 lengthIndex ++;
27 suffixSet.add(suffix);
28 if (lengthIndex == length){
29 lengthIndex = 0;
30 suffix = i + 1;
31 }
32 }
33 }
34
35 public VirtualModFunction(int max, int current){
36 this.current = current;
37 this.max = max;
38 this.init();
39 }
40
41
42 @Override
43 public Integer execute(String columnValue, Map<String, Object> extension) {
44 return bucket[((Long) (Long.valueOf(columnValue) % max)).intValue()];
45 }
複製程式碼
這裡也可以順帶做一下讀寫分離,配置一些讀操作路由到哪個例項,寫操作路由到哪個例項,並且做到負載均衡,對應用層透明。
結果集合並
如果需要在多個物理表上執行查詢,則需要對結果集進行合併處理,此處需要注意返回是一個迭代器 resultset。
統計類 針對 sum count,max,min 只需要將每個結果集的返回結果在做一個 max 和min,count 和 sum 直接相加即可,針對 avg 需要通過上面改寫的 sql 獲取 sum 和count 然後相除計算平均值。
排序類大部分的排序都伴隨著 limit 限制查詢條數。例如返回結果需要查詢最近的2000條記錄,並且根據建立時間倒序排序,根據路由結果需要查詢所有的物理表,假設是4張表,如果此時4張表的資料沒有時間上的排序關係,則需要每張表都查詢2000條記錄,並且按照建立時間倒序排列,現在要做的就是從4個已經排序好的連結串列,每個連結串列最多2000條資料,重新排序,選擇2000條時間最近的,我們可以通過插入排序的演算法,每次分別從每個連結串列中取出時間最大的一個,在新的結果集裡找到位置並插入,直到結果集中存在2000條記錄,但是這裡可能存在一個問題,如果某一個連結串列的資料普遍比其他連結串列資料偏大,這樣每個連結串列取500條資料肯定排序不準確,所以我們還需要保證當前所有連結串列中剩下的資料的最大值比新結果集中的資料小。 而實際上業務層的需求可能並不是僅僅取出2000條資料,而是要遍歷所有的資料,這種要遍歷所有資料集的情況,建議在業務層控制一張表一張表的遍歷,如果每次都要去每張表中查詢在排序嚴重影響效率,如果在應用層控制,我們在後面在聊。
聚合類 group by 應用層需要儘量避免這種操作,這些需求最好能交給搜尋引擎和資料分析平臺進行,但是作為一箇中介軟體,對於group by 這種我們經常需要統計資料的型別還是應該儘量支援的,目前的做法是 和統計類處理類似,針對各個子集進行合併處理。
優化
以上流程基本可以實現一個簡易版本的資料庫分庫分表中介軟體,為了讓我們的中介軟體更方便開發者使用,為日常工作提供更多地遍歷性,我們還可以從以下幾點做優化。
和 spring 整合
針對哪些表需要進行分片,分片規則等,這些需要定製化的配置,我們可以在程式裡面手工編碼,但是這樣業務層又耦合了分表的邏輯,我們可以藉助 spring 的配置檔案,直接將 xml 裡的內容對映成對應的 bean 例項。
我們首先要設計好對應的配置檔案的格式,有哪些節點,每個節點包含哪些屬性,然後設計自己名稱空間,和對應的 XSD 校驗檔案,XSD 檔案放在 META-INF下。
編寫 NamespaceHandlerSupport 類,註冊每個節點元素對應的解析器
在 META-INF 檔案中增加配置檔案 spring.handlers 中配置 spring遇到某個namespace下的節點後 通過哪個解析器解析,最終返回配置例項。
1http\://baymax.tongbanjie.com/schema/baymax-3.0=com.tongbanjie.baymax.spring.BaymaxNamespaceHandler 複製程式碼
在 META-INF 檔案中增加配置檔案 spring.schema 中配置 spring遇到某個namespace下的節點後 通過哪個XSD檔案進行校驗。
可以藉助ListableBeanFactory的getBeansOfType(Class clazz) 來獲取某個class型別的所有例項,從而獲得所有的配置資訊。
1 public class BaymaxNamespaceHandler extends NamespaceHandlerSupport {
2
3 //com.alibaba.dubbo.config.spring.schema.DubboNamespaceHandler
4 @Override
5 public void init() {
6 registerBeanDefinitionParser("table", new BaymaxBeanDefinitionParser(TableConfig.class, false));
7 registerBeanDefinitionParser("context", new BaymaxBeanDefinitionParser(BaymaxSpringContext.class, false));
8 registerBeanDefinitionParser("process", new BaymaxBeanDefinitionParser(ColumnProcess.class, false));
9 }
10
11 }
複製程式碼
1http\://baymax.tongbanjie.com/schema/baymax-3.0.xsd=META-INF/baymax-3.0.xsd
複製程式碼
當然也可以通過自定義註解進行申明,這種方式我們可以藉助 BeanPostProcessor 的時候判斷類上是否包含指定的註解,但是這種方式比較笨重,而且所加註解的類必須在spring 容器管理中,也可以藉助 ClassPathScanningCandidateComponentProvider 和 AnnotationTypeFilter 實現,或者直接通過 classloader 掃描指定的包路徑。
如何支援分散式事務
由於框架本身通過定製 JDBC 協議實現,雖然最終執行 sql 的是通過原生 JDBC,但是對上層應用透明,同時也對上層基於 JDBC 實現的事物透明,spring 的事物管理器可以直接使用。
我們考慮下以下問題,如果我們針對多張表在一個執行緒池內併發的區執行 sql,然後在合併結果,這是否會影響 spring的 事物管理器?
首先 spring 的宣告式事物是通過 aop 在切面做增強,事物開始先獲取 connection 並設定 setAutocommit 為 fasle,事物結束呼叫 connection 進行 commit 或者 rollback,通過 threadlocal 儲存事物上下文和所使用的 connection 來保證事物內多個 sql共用一個 connection 操作。但是如果我們在解析 sql 後發現要執行多條 sql 語句,我們通過執行緒池併發執行,然後等所有的結果返回後進行合併,(這裡先不考慮,多個 sql 可能需要在不同的資料庫例項上執行),雖然通過執行緒池將導致 threadlocal 失效,但是我們在 threadlocal 維護的是我們自己定製的 connection,並不是原生的 JDBC 裡的 connection ,而且這裡併發執行並不會讓事物處理器沒辦法判斷是否所有的執行緒都已經結束,然後進行 commit 或者 rollback 。因為這裡的執行緒池是在我們定製的 connection 執行 sql 過程中運用的,肯定會等到所有執行緒處理結束後並且合併資料集才會返回。所以在本地事物層面,通過定製化 JDBC 可以做到對上層事物透明。
如果我們進行了分庫,同一個表可能在多個資料庫例項裡,這種如果要對不同例項裡的表進行更新,那麼將無法在使用本地事物,這裡我們不在討論分散式事物的實現,由於二階段提交的各種缺點,目前很少有公司會基於二階段做分散式事物,所以我們的中介軟體也可以根據自己的具體業務考慮是否要實現 XA,目前銅板街大部分分散式事物需求都是通過基於 TCC 的事物補償做的,這種方式對業務冪等要求較高,同時要基於業務層實現回滾邏輯。
提供一個通用發號器
為什麼要提供一個發號器,我們在單表的時候,可能會用到資料庫的自增ID,但當分成多表後,每個表都進行單獨的ID自增,這樣一個邏輯表內的ID 就會出現重複。
我們可以提供一個基於邏輯表自增的主鍵ID 獲取方式,如果沒有分庫只分表,可以在資料庫中增加一個表維護每張邏輯表對應的自增ID。每次需要獲取ID 的時候都先查詢這個標當前的ID 然後加一返回,然後在寫入資料庫,為了併發獲取的情況,我們可以採用樂觀鎖,類似於CAS,update的時候傳人以前的ID。如果被人修改過則重新獲取,當然我們也可以一次性獲取一批ID例如一次獲取100個,等這100個用完了在重新獲取,為了避免這100個還沒用完,程式正常或非正常退出,在獲取這100個值的時候就將資料庫通過CAS更新為已經獲取了100個值之和的值。
不推薦用 UUID,無序,太長佔記憶體影響索引效果,不攜帶任何業務含義。
藉助 ZOOKEEPER 的 zone 的版本號來做序列號。
藉助 REDIS 的 INCR 命令,進行自增,每臺 redis 設定不同的初始值,但是設定相同的歩長。
1A:1,6,11,16,21
2B:2,7,12,17,22
3C:3,8,13,18,23
4D:4,9,14,19,24
5E:5,10,15,20,25
複製程式碼
snowflake演算法:其核心思想是:使用41bit作為毫秒數,10bit作為機器的ID(5個bit是資料中心,5個bit的機器ID),12bit作為毫秒內的流水號(意味著每個節點在每毫秒可以產生 4096 個 ID)。
銅板街目前所使用的訂單號規則: - 15位時間戳,4位自增序列,2位區分訂單型別,7位機器ID,2位分庫字尾,2位分表字尾 共32位 - 7位機器ID 通過IP來獲取 - 15位時間戳精確到毫秒,4位自增序列,意味著單JVM1毫秒可以生成9999個訂單 。
最後4位可以方便的根據訂單號定位到物理表,這裡需要注意分庫分表如果是根據一致性雜湊演算法,這個地方最好存最大值, 例如 使用者id % 64 取餘 最多可以分64張表,而目前可能用不到這麼多,每相鄰4個數字分配到一張表,共16張表,既 userID % 64 / 4 * 4 ,而這個地方儲存 userID % 64 即可,不必存最終分表的結果,這種方式方便後續做擴容,可能分表的結果變更了,但是訂單號卻無法進行變更。
1 @Override
2 public String routeDbByUserId(String userId) {
3 Assert.hasLength(userId, "使用者ID不能為空");
4
5 Integer userIdInteger = null;
6 try {
7 userIdInteger = Integer.parseInt(userId);
8 } catch (Exception ex) {
9 logger.error("解析使用者ID為整數失敗" + userId, ex);
10 throw new RuntimeException("解析使用者ID為整數失敗");
11 }
12
13 //根據路由規則確定,具體在哪個庫哪個表 例如根據分庫公式最終結果在0到63之間 如果要分兩個庫 mod為32 分1個庫mod為64 分16個庫 mod為4
14 //規律為 64 = mod * (最終的分庫數或分表數)
15 int mod = orderSplitConfig.getDbSegment();
16
17 Integer dbSuffixInt = userIdInteger / 64 % 64 / mod * mod ;
18
19 return StringUtils.leftPad(String.valueOf(dbSuffixInt), 2, '0');
20}
21
22
23 @Override
24 public String routeTableByUserId(String userId) {
25
26 Assert.hasLength(userId, "使用者ID不能為空");
27
28 Integer userIdInteger = null;
29 try {
30 userIdInteger = Integer.parseInt(userId);
31 } catch (Exception ex) {
32 logger.error("解析使用者ID為整數失敗" + userId, ex);
33 throw new RuntimeException("解析使用者ID為整數失敗");
34 }
35
36 //根據路由規則確定,具體在哪個庫哪個表 例如根據分表公式最終結果在0到63之間 如果要分兩個庫 mod為32 分1個庫mod為64 分16個庫 mod為4
37 //規律為 64 = mod * (最終的分庫數或分表數)
38 int mod = orderSplitConfig.getTableSegment();
39
40 Integer tableSuffixInt = userIdInteger % 64 / mod * mod;
41
42 return StringUtils.leftPad( String.valueOf(tableSuffixInt), 2, '0');
43 }
複製程式碼
如何實現跨表遍歷
如果業務需求是遍歷所有滿足條件的資料,而不是隻是為了取某種條件下前面一批資料,這種建議在應用層實現,一張表一張表的遍歷,每次查詢結果返回下一次查詢的起始位置和物理表名,查詢的時候建議根據 大於或小於某一個 ID 進行分頁,不要 limit500,500這種,以下為銅板街的實現方式。
1 public List<T> select(String tableName, SelectorParam selectorParam, E realQueryParam) {
2
3 List<T> list = new ArrayList<T>();
4
5 // 定位到某張表
6 String suffix = partitionManager.getCurrentSuffix(tableName, selectorParam.getLocationNo());
7
8 int originalSize = selectorParam.getLimit();
9
10 while (true) {
11
12 List<T> ts = this.queryByParam(realQueryParam, selectorParam, suffix);
13
14 if (!CollectionUtils.isEmpty(ts)) {
15 list.addAll(ts);
16 }
17
18 if (list.size() == originalSize) {
19 break;
20 }
21
22 suffix = partitionManager.getNextSuffix(tableName, suffix);
23
24 if (StringUtils.isEmpty(suffix)) {
25 break;
26 }
27
28 // 查詢下一張表 不需要定位單號 而且也只需要查剩下的size即可
29 selectorParam.setLimit(originalSize - list.size());
30 selectorParam.setLocationNo(null);
31 }
32
33 return list;
34 }複製程式碼
提供一個擴容工具和管理控制檯做配置視覺化和監控
監控可以藉助 druid,也可以在定製的 JDBC 層自己做埋點,將資料以報表的形式進行展示,也可以針對特定的監控指標進行配置,例如執行次數,執行時間大於某個指定時間。
管理控制檯,由於目前配置是在應用層,當然也可以把配置獨立出來放在獨立的伺服器上,由於分片配置基本上無法線上修改,每次修改可能都伴隨著資料遷移,所以基本上只能做展示,但是分表後我們在測試環境執行 sql 去進行邏輯查詢的時候,傳統的 sql 工具無法幫忙做到自動路由,這樣我們每次查詢可能都需要手工計算下分片結果,或者要連續寫好幾個 sql 之後在聚合,通過這個管理控制檯我們就可以直接根據邏輯表名寫 sql,這樣我們在測試環境或者線上上核對資料的時候,就提高了效率。
擴容工具,笨辦法只能先從老表查詢在 insert 到新表,等到新表資料完全同步完後,在切換到新的切片規則,所以我們設計分片演算法的時候,需要考慮到後面擴容,例如一致性雜湊就需要遷移一半的資料(擴容一倍的話) 資料遷移如果出現故障,那將是個災難,如果我們要在不停機的情況下完成擴容,可以通過配置檔案按以下流程來。
準備階段.將截至到某一刻的歷史表資料同步到新表 例如截至2017年10月1日之前的歷史資料,這些歷史資料最好不會在被修改;
階段一.訪問老表,寫入老表;
階段二.訪問老表,寫入老表同時寫入新表 (插入和修改);
階段三.將10月1日到首次寫入新表之間的資料同步到新表 需要保證此時被遷移的資料全部都是終態;
階段四.訪問新表,寫入老表和新表;
階段五.訪問新表,寫入新表。
以上流程適用於,訂單這種歷史資料在達到終態後將不會在被修改,如果歷史資料也可能被修改,則可能需要停機,或者通過 canel 進行資料同步。
作者簡介
小強,銅板街資金端後臺開發工程師,2015年6月加入銅板街。目前負責銅板街資金端清結算相關的開發。
更多精彩內容,請掃碼關注 “銅板街技術” 微信公眾號。