記錄后面再仔細學習
作者介紹
楊彪,螞蟻金服技術專家,《分布式服務架構:原理、設計與實戰》和《可伸縮服務架構:框架與中間件》作者。近10年互聯網和游戲行業工作經驗,曾在酷我音樂盒、人人游戲和掌趣科技等上市公司擔任核心研發職位,做過日活躍用戶量達千萬的項目,也做過多款月流水千萬以上的游戲。
本文節選自《可伸縮服務架構:框架與中間件》一書,作者:李艷鵬、楊彪、李海亮、賈博岩、劉淏
這里介紹設計分庫分表框架時應該考慮的設計要點,並給出相應的解決方案,為后面實現分庫分表框架dbsplit提供理論支撐。
一、整體的切分方式
簡單來說,數據的切分就是通過某種特定的條件,將我們存放在同一個數據庫中的數據分散存放到多個數據庫(主機)中,以達到分散單台設備負載的效果,即分庫分表。
數據的切分根據其切分規則的類型,可以分為如下兩種切分模式。
-
垂直(縱向)切分:把單一的表拆分成多個表,並分散到不同的數據庫(主機)上。
-
水平(橫向)切分:根據表中數據的邏輯關系,將同一個表中的數據按照某種條件拆分到多台數據庫(主機)上。
1. 垂直切分
一個數據庫由多個表構成,每個表對應不同的業務,垂直切分是指按照業務將表進行分類,將其分布到不同的數據庫上,這樣就將數據分擔到了不同的庫上(專庫專用)。
案例如下:
#有如下幾張表
--------------+--------------+------------------
用戶信息(User)+ 交易記錄(Pay)+ 商品(Commodity)|
--------------+--------------+------------------
針對以上案例,垂直切分就是根據每個表的不同業務進行切分,比如User表、Pay表和Commodity表,將每個表切分到不同的數據庫上。
垂直切分的優點如下:
-
拆分后業務清晰,拆分規則明確。
-
系統之間進行整合或擴展很容易。
-
按照成本、應用的等級、應用的類型等將表放到不同的機器上,便於管理。
-
便於實現動靜分離、冷熱分離的數據庫表的設計模式。
-
數據維護簡單。
垂直切分的缺點如下:
-
部分業務表無法關聯(Join),只能通過接口方式解決,提高了系統的復雜度。
-
受每種業務的不同限制,存在單庫性能瓶頸,不易進行數據擴展和提升性能。
-
事務處理復雜。
垂直切分除了用於分解單庫單表的壓力,也用於實現冷熱分離,也就是根據數據的活躍度進行拆分,因為對擁有不同活躍度的數據的處理方式不同。
我們可將本來可以在同一個表中的內容人為地划分為多個表。所謂“本來”,是指按照關系型數據庫第三范式的要求,應該在同一個表中,將其拆分開就叫作反范化(Denormalize)。
例如,對配置表的某些字段很少進行修改時,將其放到一個查詢性能較高的數據庫硬件上;對配置表的其他字段更新頻繁時,則將其放到另一個更新性能較高的數據庫硬件上。
這里我們再舉一個例子:在微博系統的設計中,一個微博對象包括文章標題、作者、分類、創建時間等屬性字段,這些字段的變化頻率低,查詢次數多,叫作冷數據。而博客的瀏覽量、回復數、點贊數等類似的統計信息,或者別的變化頻率比較高的數據,叫作活躍數據或者熱數據。
我們把冷熱數據分開存放,就叫作冷熱分離,在MySQL的數據庫中,冷數據查詢較多,更新較少,適合用MyISAM引擎,而熱數據更新比較頻繁,適合使用InnoDB存儲引擎,這也是垂直拆分的一種。
我們推薦在設計數據庫表結構時,就考慮垂直拆分,根據冷熱分離、動靜分離的原則,再根據使用的存儲引擎的特點,對冷數據可以使用MyISAM,能更好地進行數據查詢;對熱數據可以使用InnoDB,有更快的更新速度,這樣能夠有效提升性能。
其次,對讀多寫少的冷數據可配置更多的從庫來化解大量查詢請求的壓力;對於熱數據,可以使用多個主庫構建分庫分表的結構,請參考下面關於水平切分的內容,后續的三四五章提供了不同的分庫分表的具體實施方案。
注意,對於一些特殊的活躍數據或者熱點數據,也可以考慮使用Memcache、Redis之類的緩存,等累計到一定的量后再更新數據庫,例如,在記錄微博點贊數量的業務中,點贊數量被存儲在緩存中,每增加1000個點贊,才寫一次數據。
2. 水平切分
與垂直切分對比,水平切分不是將表進行分類,而是將其按照某個字段的某種規則分散到多個庫中,在每個表中包含一部分數據,所有表加起來就是全量的數據。
簡單來說,我們可以將對數據的水平切分理解為按照數據行進行切分,就是將表中的某些行切分到一個數據庫表中,而將其他行切分到其他數據庫表中。
這種切分方式根據單表的數據量的規模來切分,保證單表的容量不會太大,從而保證了單表的查詢等處理能力,例如將用戶的信息表拆分成User1、User2等,表結構是完全一樣的。我們通常根據某些特定的規則來划分表,比如根據用戶的ID來取模划分。
例如,在博客系統中,當讀取博客的量很大時,就應該采取水平切分來減少每個單表的壓力,並提升性能。
以微博表為例,當同時有100萬個用戶在瀏覽時,如果是單表,則單表會進行100萬次請求,假如是單庫,數據庫就會承受100萬次的請求壓力;假如將其分為100個表,並且分布在10個數據庫中,每個表進行1萬次請求,則每個數據庫會承受10萬次的請求壓力,雖然這不可能絕對平均,但是可以說明問題,這樣壓力就減少了很多,並且是成倍減少的。
水平切分的優點如下:
-
單庫單表的數據保持在一定的量級,有助於性能的提高。
-
切分的表的結構相同,應用層改造較少,只需要增加路由規則即可。
-
提高了系統的穩定性和負載能力。
水平切分的缺點如下:
-
切分后,數據是分散的,很難利用數據庫的Join操作,跨庫Join性能較差。
-
拆分規則難以抽象。
-
分片事務的一致性難以解決。
-
數據擴容的難度和維護量極大。
綜上所述,垂直切分和水平切分的共同點如下:
-
存在分布式事務的問題。
-
存在跨節點Join的問題。
-
存在跨節點合並排序、分頁的問題。
-
存在多數據源管理的問題。
在了解這兩種切分方式的特點后,我們就可以根據自己的業務需求來選擇,通常會同時使用這兩種切分方式,垂直切分更偏向於業務拆分的過程,在技術上我們更關注水平切分的方案。
二、水平切分方式的路由過程和分片維度
這里講解水平切分的路由過程和分片維度。
1. 水平切分的路由過程
我們在設計表時需要確定對表按照什么樣的規則進行分庫分表。例如,當有新用戶時,程序得確定將此用戶的信息添加到哪個表中;同理,在登錄時我們需要通過用戶的賬號找到數據庫中對應的記錄,所有這些都需要按照某一規則進行路由請求,因為請求所需要的數據分布在不同的分片表中。
針對輸入的請求,通過分庫分表規則查找到對應的表和庫的過程叫作路由。例如,分庫分表的規則是user_id % 4,當用戶新注冊了一個賬號時,假設用戶的ID是123,我們就可以通過123 % 4 = 3確定此賬號應該被保存在User3表中。當ID為123的用戶登錄時,我們可通過123 % 4 = 3計算后,確定其被記錄在User3中。
2. 水平切分的分片維度
對數據切片有不同的切片維度,可以參考Mycat提供的切片方式(見本書3.4節),這里只介紹兩種最常用的切片維度。
1)按照哈希切片
對數據的某個字段求哈希,再除以分片總數后取模,取模后相同的數據為一個分片,這樣的將數據分成多個分片的方法叫作哈希分片。
按照哈希分片常常應用於數據沒有時效性的情況,比如所有數據無論是在什么時間產生的,都需要進行處理或者查詢,例如支付行業的客戶要求可以對至少1年以內的交易進行查詢和退款,那么1年以內的所有交易數據都必須停留在交易數據庫中,否則就無法查詢和退款。
如果這家公司在一年內能做10億條交易,假設每個數據庫分片能夠容納5000萬條數據,則至少需要20個表才能容納10億條交易。在路由時,我們根據交易ID進行哈希取模來找到數據屬於哪個分片,因此,在設計系統時要充分考慮如何設計數據庫的分庫分表的路由規則。
這種切片方式的好處是數據切片比較均勻,對數據壓力分散的效果較好,缺點是數據分散后,對於查詢需求需要進行聚合處理。
2)按照時間切片
與按照哈希切片不同,這種方式是按照時間的范圍將數據分布到不同的分片上的,例如,我們可以將交易數據按照月進行切片,或者按照季度進行切片,由交易數據的多少來決定按照什么樣的時間周期對數據進行切片。
這種切片方式適用於有明顯時間特點的數據,例如,距離現在1個季度的數據訪問頻繁,距離現在兩個季度的數據可能沒有更新,距離現在3個季度的數據沒有查詢需求。
針對這種情況,可以通過按照時間進行切片,針對不同的訪問頻率使用不同檔次的硬件資源來節省成本:假設距離現在1個季度的數據訪問頻率最高,我們就用更好的硬件來運行這個分片;假設距離現在3個季度的數據沒有任何訪問需求,我們就可以將其整體歸檔,以方便DBA操作。
在實際的生產實踐中,按照哈希切片和按照時間切片都是常用的分庫分表方式,並被廣泛使用,有時可以結合使用這兩種方式,例如:對交易數據先按照季度進行切片,然后對於某一季度的數據按照主鍵哈希進行切片。
三、分片后的事務處理機制
本節講解分片后的事務處理機制。
1. 分布式事務
由於我們將單表的數據切片后存儲在多個數據庫甚至多個數據庫實例中,所以依靠數據庫本身的事務機制不能滿足所有場景的需要。
但是,我們推薦在一個數據庫實例中的操作盡可能使用本地事務來保證一致性,跨數據庫實例的一系列更新操作需要根據事務路由在不同的數據源中完成,各個數據源之間的更新操作需要通過分布式事務處理。
這里只介紹實現分布式操作一致性的幾個主流思路,保證分布式事務一致性的具體方法請參考《分布式服務架構:原理、設計與實戰》中第2章的內容。
主流的分布式事務解決方案有三種:兩階段提交協議、最大努力保證模式和事務補償機制。
1)兩階段提交協議
兩階段提交協議將分布式事務分為兩個階段,一個是准備階段,一個是提交階段,兩個階段都由事務管理器發起。
基於兩階段提交協議,事務管理器能夠最大限度地保證跨數據庫操作的事務的原子性,是分布式系統環境下最嚴格的事務實現方法。符合J2EE規范的AppServer(例如:Websphere、Weblogic、Jboss等)對關系型數據庫數據源和消息隊列都實現了兩階段提交協議,只需在使用時配置即可。如圖3-9所示。
圖3-9
但是,兩階段提交協議也帶來了性能方面的問題,難於進行水平伸縮,因為在提交事務的過程中,事務管理器需要和每個參與者進行准備和提交的操作的協調,在准備階段鎖定資源,在提交階段消費資源。
但是由於參與者較多,鎖定資源和消費資源之間的時間差被拉長,導致響應速度較慢,在此期間產生死鎖或者不確定結果的可能性較大。因此,在互聯網行業里,為了追求性能的提升,很少使用兩階段提交協議。
另外,由於兩階段提交協議是阻塞協議,在極端情況下不能快速響應請求方,因此有人提出了三階段提交協議,解決了兩階段提交協議的阻塞問題,但仍然需要事務管理器在參與者之間協調,才能完成一個分布式事務。
2)最大努力保證模式
這是一種非常通用的保證分布式一致性的模式,很多開發人員一直在使用,但是並未意識到這是一種模式。最大努力保證模式適用於對一致性要求並不十分嚴格但是對性能要求較高的場景。
具體的實現方法是,在更新多個資源時,將多個資源的提交盡量延后到最后一刻處理,這樣的話,如果業務流程出現問題,則所有的資源更新都可以回滾,事務仍然保持一致。
唯一可能出現問題的情況是在提交多個資源時發生了系統問題,比如網絡問題等,但是這種情況是非常罕見的,一旦出現這種情況,就需要進行實時補償,將已提交的事務進行回滾,這和我們常說的TCC模式有些類似。
下面是使用最大努力保證模式的一個樣例,在該樣例中涉及兩個操作,一個是從消息隊列消費消息,一個是更新數據庫,需要保證分布式的一致性。
(1)開始消息事務。
(2)開始數據庫事務。
(3)接收消息。
(4)更新數據庫。
(5)提交數據庫事務。
(6)提交消息事務。
這時,從第1步到第4步並不是很關鍵,關鍵的是第5步和第6步,需要將其放在最后一起提交,盡最大努力保證前面的業務處理的一致性。
到了第5步和第6步,業務邏輯處理完成,這時只可能發生系統錯誤,如果第5步失敗,則可以將消息隊列和數據庫事務全部回滾,保持一致。如果第5步成功,第6步遇到了網絡超時等問題,則這是唯一可能產生問題的情況。
在這種情況下,消息的消費過程並沒有被提交到消息隊列,消息隊列可能會重新發送消息給其他消息處理服務,這會導致消息被重復消費,但是可以通過冪等處理來保證消除重復消息帶來的影響。
當然,在使用這種模式時,我們要充分考慮每個資源的提交順序。我們在生產實踐中遇到的一種反模式,就是在數據庫事務中嵌套遠程調用,而且遠程調用是耗時任務,導致數據庫事務被拉長,最后拖垮數據庫。
因此,上面的案例涉及的是消息事務嵌套數據庫事務,在這里必須進行充分評估和設計,才可以規避事務風險。
3)事務補償機制
顯然,在對性能要求很高的場景中,兩階段提交協議並不是一種好方案,最大努力保證模式也會使多個分布式操作互相嵌套,有可能互相影響。這里,我們給出事務補償機制,其性能很高,並且能夠盡最大可能地保證事務的最終一致性。
在數據庫分庫分表后,如果涉及的多個更新操作在某一個數據庫范圍內完成,則可以使用數據庫內的本地事務保證一致性;對於跨庫的多個操作,可通過補償和重試,使其在一定的時間窗口內完成操作,這樣就可以實現事務的最終一致性,突破事務遇到問題就滾回的傳統思路。
如果采用事務補償機制,則在遇到問題時,我們需要記錄遇到問題的環境、信息、步驟、狀態等,后續通過重試機制使其達到最終一致性,詳細內容可以參考《分布式服務架構:原理、設計與實戰》第2章,徹底理解ACID原理、CAP理論、BASE原理、最終一致性模式等內容。
2. 事務路由
無論使用上面哪種方法實現分布式事務,都需要對分庫分表的多個數據源路由事務,一般通過對Spring環境的配置,為不同的數據源配置不同的事務管理器(TransactionManager)。
這樣,如果更新操作在一個數據庫實例內發生,便可以使用數據源的事務來處理。對於跨數據源的事務,可通過在應用層使用最大努力保證模式和事務補償機制來達成事務的一致性。
當然,有時我們需要通過編寫程序來選擇數據庫的事務管理器,根據實現方式的不同,可將事務路由具體分為以下三種。
1)自動提交事務路由
自動提交事務通過依賴JDBC數據源的自動提交事務特性,對任何數據庫進行更新操作后會自動提交事務,不需要開發人員手工操作事務,也不需要配置事務,實現起來很簡單,但是只能滿足簡單的業務邏輯需求。
在通常情況下,JDBC在連接創建后默認設置自動提交為true,當然,也可以在獲取連接后手工修改這個屬性,代碼如下:
connnection conn = null;
try{
conn = getConnnection();
conn.setAutoCommit(true);
// 數據庫操作
……………………………
conn.commit();
}catch(Throwable e){
if(conn!=null){
try {
conn.rollback();
} catch (SQLException e1) {
e1.printStackTrace();
}
}
throw new RuntimeException(e);
}finally{
if(conn!=null){
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
我們基本不需要使用原始的JDBC API來改變這些屬性,這些操作一般都會被封裝在我們使用的框架中。本書3.6節介紹的開源數據庫分庫分表框架dbsplit默認使用的就是這種模式。
2)可編程事務路由
我們在應用中通常采用Spring的聲明式的事務來管理數據庫事務,在分庫分表時,事務處理是個問題,在一個需要開啟事務的方法中,需要動態地確定開啟哪個數據庫實例的事務,也就是說在每個開啟事務的方法調用前就必須確定開啟哪個數據源的事務。下面使用偽代碼來說明如何實現一個可編程事務路由的小框架。
首先,通過Spring配置文件展示可編程事務小框架是怎么使用的:
<?xml version="1.0?>
<beans>
<bean id="sharding-db-trx0"class="org.springframework.jdbc.datasource.Data SourceTransactionManager">
<property name="dataSource">
<ref bean="sharding-db0" />
</property>
</bean>
<bean id="sharding-db-trx1"
class="org.springframework.jdbc.datasource.DataSourceTransactionMana ger">
<property name="dataSource">
<ref bean="sharding-db1" />
</property>
</bean>
<bean id="sharding-db-trx2"
class="org.springframework.jdbc.datasource.DataSourceTransactionMana ger">
<property name="dataSource">
<ref bean="sharding-db2" />
</property>
</bean>
<bean id="sharding-db-trx3"
class="org.springframework.jdbc.datasource.DataSourceTransactionMana ger">
<property name="dataSource">
<ref bean="sharding-db3" />
</property>
</bean>
<bean id="shardingTransactionManager" class="com.robert.dbsplit.core. ShardingTransactionManager">
<property name="proxyTransactionManagers">
<map value-type="org.springframework.transaction.PlatformTran sactionManager">
<entry key="sharding0" value-ref="sharding-db-trx0" />
<entry key="sharding1" value-ref="sharding-db-trx1" />
<entry key="sharding2" value-ref="sharding-db-trx2" />
<entry key="sharding3" value-ref="sharding-db-trx3" />
</map>
</property>
</bean>
<aop:config>
<aop:advisor advice-ref="txAdvice" pointcut="execution(* com.robert.biz.*insert(..))"/>
<aop:advisor advice-ref="txAdvice" pointcut="execution(* com.robert.biz.*update(..))"/>
<aop:advisor advice-ref="txAdvice" pointcut="execution(* com.robert.biz.*delete(..))"/>
</aop:config>
<tx:advice id="txAdvice" transaction-manager="shardingTransactionManager">
<tx:attributes>
<tx:method name="*" rollback-for="java.lang.Exception"/>
</tx:attributes>
</tx:advice>
</beans>
這里使用Spring環境的aop和tx標簽來攔截com.robert.biz包下的所有插入、更新和刪除的方法,當指定的包的方法被調用時,就會使用Spring提供的事務Advice,Spring的事務Advice(tx:advice)會使用事務管理器來控制事務,如果某個方法發生了異常,那么Spring的事務Advice就會使shardingTransactionManager回滾相應的事務。
我們看到shardingTransactionManager的類型是ShardingTransactionManager,這個類型是我們開發的一個組合的事務管理器,這個事務管理器聚合了所有分片數據庫的事務管理器對象,然后根據某個標記來路由到不同的事務管理器中,這些事務管理器用來控制各個分片的數據源的事務。
這里的標記是什么呢?我們在調用方法時,會提前把分片的標記放進ThreadLocal中,然后在ShardingTransactionManager的getTransaction方法被調用時,取得ThreadLocal中存的標記,最后根據標記來判斷使用哪個分片數據庫的事務管理器對象。
為了通過標記路由到不同的事務管理器,我們設計了一個專門的ShardingContextHolder類,在該類的內部使用了一個ThreadLocal類來指定分片數據庫的關鍵字,在ShardingTransaction Manager中通過取得這個標記來選擇具體的分片數據庫的事務管理器對象。
因此,這個類提供了setShard和getShard的方法,setShard用於使用者編程指定使用哪個分片數據庫的事務管理器,而getShard用於ShardingTransactionManager獲取標記並取得分片數據庫的事務管理器對象。相關代碼如下:
public class ShardingContextHolder<T> {
private static final ThreadLocal shardHolder = new ThreadLocal();
public static <T> void setShard(T shard) {
Validate.notNull(shard, "請指定某個分片數據庫!");
shardHolder.set(shard);
}
public static <T> T getShard() {
return (T) shardHolder.get();
}
}
有了ShardingContextHolder類后,我們就可以在ShardingTransactionManager中根據給定的分片配置將事務操控權路由到不同分片的數據庫的事務管理器上,實現很簡單,如果在ThreadLocal中存儲了某個分片數據庫的事務管理器的關鍵字,就使用那個分片的數據庫的事務管理器:
public class ShardingTransactionManager implements PlatformTransactionManager {
private Map<Object, PlatformTransactionManager> proxyTransactionManagers =
new HashMap<Object, PlatformTransactionManager>();
protected PlatformTransactionManager getTargetTransactionManager() {
Object shard = ShardingContextHolder.getShard();
Validate.notNull(shard, "必須指定一個路由的shard!");
return targetTransactionManagers.get(shard);
}
public void setProxyTransactionManagers(Map<Object, PlatformTransaction Manager> targetTransactionManagers) {
this.targetTransactionManagers = targetTransactionManagers;
}
public void commit(TransactionStatus status) throws TransactionException {
getProxyTransactionManager().commit(status);
}
public TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException {
return getProxyTransactionManager().getTransaction(definition);
}
public void rollback(TransactionStatus status) throws TransactionException
{
getProxyTransactionManager().rollback(status);
}
}
有了這些使用類,我們的可編程事務路由小框架就實現了,這樣在某個具體的服務開始之前,我們就可以使用如下代碼來控制使用某個分片的數據庫的事務管理器了:
RoutingContextHolder.setShard("sharding0");
return userService.create(user);
3)聲明式事務路由
在上一小節實現了可編程事務路由的小框架,這個小框架通過讓開發人員在ThreadLocal中指定數據庫分片並編程實現。
大多數分庫分表框架會實現聲明式事務路由,也就是在實現的服務方法上直接聲明事務的處理注解,注解包含使用哪個數據庫分片的事務管理器的信息,這樣,開發人員就可以專注於業務邏輯的實現,把事務處理交給框架來實現。
下面是筆者在實際的線上項目中實現的聲明式事務路由的一個使用實例:
@TransactionHint(table = "INVOICE", keyPath = "0.accountId")
public void persistInvoice(Invoice invoice) {
// Save invoice to DB
this.createInvoice(invoice);
for (InvoiceItem invoiceItem : invoice.getItems()) {
invoiceItem.setInvId(invoice.getId());
invoiceItemService.createInvoiceItem(invoice.getAccountId(), invoiceItem);
}
// Save invoice to cache
invoiceCacheService.set(invoice.getAccountId(), invoice.getInvPeriodStart().getTime(), invoice.getInvPeriodEnd().getTime(),
invoice);
// Update last invoice date to Account
Account account = new Account();
account.setId(invoice.getAccountId());
account.setLstInvDate(invoice.getInvPeriodEnd());
accountService.updateAccount(account);
}
在這個實例中,我們開發了一個持久發票的服務方法。持久發票的服務方法用來保存發票信息和發票項的詳情信息,這里,發票與發票項這兩個領域對象具有父子結構關系。
由於在設計過程中通過賬戶ID對這個父子表進行分庫分表,因此在進行事務路由時,也需要通過賬戶ID控制使用哪個數據庫分片的事務管理器。在這個實例中,我們配置了 TransactionHint,TransactionHint的聲明如下:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface TransactionHint {
String table() default "";
String keyPath() default "";
}
可以看到,TransactionHint包含了兩個屬性,第1個屬性table指定這次操作涉及分片的數據庫表,第2個屬性指定這次操作根據哪個參數的哪個字段進行分片路由。該實例通過table指定了INVOICE表,並通過keyPath指定了使用第1個參數的字段accountId作為路由的關鍵字。
這里的實現與可編程事務路由的小框架實現類似,在方法persistInvoice被調用時,根據TransactionHint提供的操作的數據庫表名稱,在Spring環境的配置中找到這個表的分庫分表的配置信息,例如:一共分了多少個數據庫實例、數據庫和表。
下面是在Spring環境中配置的INVOICE表和INVOICE_ITEM表的具體信息,我們看到它們一共使用了兩個數據庫實例,每個實例有兩個庫,每個庫有8個表,使用水平下標策略。配置如下:
<bean name="billingInvSplitTable" class="com.robert.dbsplit.core.Split Table"init-method="init">
<property name="dbNamePrefix" value="billing_inv"/>
<property name="tableNamePrefix" value="INVOICE"/>
<property name="dbNum" value="2"/>
<property name="tableNum" value="8"/>
<property name="splitStrategyType" value="HORIZONTAL"/>
<property name="splitNodes">
<list>
<ref bean="splitNode0"/>
<ref bean="splitNode1"/>
</list>
</property>
<property name="readWriteSeparate" value="true"/>
</bean>
<bean name="billingInvItemSplitTable" class="com.robert.dbsplit.core.SplitTable"
init-method="init">
<property name="dbNamePrefix" value="billing_inv"/>
<property name="tableNamePrefix" value="INVOICE_ITEM"/>
<property name="dbNum" value="2"/>
<property name="tableNum" value="8"/>
<property name="splitStrategyType" value="HORIZONTAL"/>
<property name="splitNodes">
<list>
<ref bean="splitNode0"/>
<ref bean="splitNode1"/>
</list>
</property>
<property name="readWriteSeparate" value="true"/>
</bean>
然后,在方法被調用時通過AOP進行攔截,根據TransactionHint配置的路由的主鍵信息keyPath = "0.accountId",得知這次根據第0個參數Invoice的accountID字段來路由,根據Invoice的accountID的值來計算這次持久發票表具體涉及哪個數據庫分片,然后把這個數據庫分片的信息保存到ThreadLocal中。具體的實現代碼如下:
SimpleSplitJdbcTemplate simpleSplitJdbcTemplate =
(SimpleSplitJdbcTemplate) ReflectionUtil.getFieldValue(field SimpleSplitJdbcTemplate, invocation.getThis());
Method method = invocation.getMethod();
// Convert to th method of implementation class
method = targetClass.getMethod(method.getName(), method.getParameter Types());
TransactionHint[] transactionHints = method.getAnnotationsByType (TransactionHint.class);
if (transactionHints == null || transactionHints.length < 1)
throw new IllegalArgumentException("The method " + method + " includes illegal transaction hint.");
TransactionHint transactionHint = transactionHints[0];
String tableName = transactionHint.table();
String keyPath = transactionHint.keyPath();
String[] parts = keyPath.split("\.");
int paramIndex = Integer.valueOf(parts[0]);
Object[] params = invocation.getArguments();
Object splitKey = params[paramIndex];
if (parts.length > 1) {
String[] paths = Arrays.copyOfRange(parts, 1, parts.length);
splitKey = ReflectionUtil.getFieldValueByPath(splitKey, paths);
}
SplitNode splitNode = simpleSplitJdbcTemplate.decideSplitNode(tableName, splitKey);
ThreadContextHolder.INST.setContext(splitNode);
ThreadContextHolder是一個單例的對象,在該對象里封裝了一個ThreadLocal,用來存儲某個方法在某個線程下關聯的分片信息:
public class ThreadContextHolder<T> {
public static final ThreadContextHolder<SplitNode> INST = new ThreadContextHolder<SplitNode>();
private ThreadLocal<T> contextHolder = new ThreadLocal<T>();
public T getContext() {
return contextHolder.get();
}
public void setContext(T context) {
contextHolder.set(context);
}
}
接下來與可編程式事務路由類似,實現一個定制化的事務管理器,在獲取目標事務管理器時,通過我們在ThreadLocal中保存的數據庫分片信息,獲得這個分片數據庫的事務管理器,然后返回:
public class RoutingTransactionManager implements PlatformTransactionManager {
protected PlatformTransactionManager getTargetTransactionManager() {
SplitNode splitNode = ThreadContextHolder.INST.getContext();
return splitNode.getPlatformTransactionManager();
}
public void commit(TransactionStatus status) throws TransactionException {
getTargetTransactionManager().commit(status);
}
public TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException {
return getTargetTransactionManager().getTransaction(definition);
}
public void rollback(TransactionStatus status) throws TransactionException
{
getTargetTransactionManager().rollback(status);
}
}
本書3.6節介紹的開源數據庫分庫分表框架dbsplit是一個分庫分表的簡單示例實現,在筆者所工作的公司內部有內部版本,在內部版本中實現了聲明式事務路由,但是這部分功能並沒有開源到dbsplit項目,原因是有些與業務結合的邏輯無法分離。如果感興趣,則可以加入我們的開源項目開發中。
四、讀寫分離
在實際應用中的絕大多數情況下讀操作遠大於寫操作。MySQL提供了讀寫分離的機制,所有寫操作必須對應到主庫(Master),讀操作可以在主庫(Master)和從庫(Slave)機器上進行。
主庫與從庫的結構完全一樣,一個主庫可以有多個從庫,甚至在從庫下還可以掛從庫,這種一主多從的方式可以有效地提高數據庫集群的吞吐量。
在DBA領域一般配置主-主-從或者主-從-從兩種部署模型。
所有寫操作都先在主庫上進行,然后異步更新到從庫上,所以從主庫同步到從庫機器有一定的延遲,當系統很繁忙時,延遲問題會更加嚴重,從庫機器數量的增加也會使這個問題更嚴重。
此外,主庫是集群的瓶頸,當寫操作過多時會嚴重影響主庫的穩定性,如果主庫掛掉,則整個集群都將不能正常工作。
根據以上特點,我們總結一些最佳實踐如下。
-
當讀操作壓力很大時,可以考慮添加從庫機器來分解大量讀操作帶來的壓力,但是當從庫機器達到一定的數量時,就需要考慮分庫來緩解壓力了。
-
當寫壓力很大時,就必須進行分庫操作了。
可能會因為種種原因,集群中的數據庫硬件配置等會不一樣,某些性能高,某些性能低,這時可以通過程序控制每台機器讀寫的比重來達到負載均衡,這需要更加復雜的讀寫分離的路由規則。
五、分庫分表引起的問題
分庫分表按照某種規則將數據的集合拆分成多個子集合,數據的完整性被打破,因此在某種場景下會產生多種問題。
1. 擴容與遷移
在分庫分表后,如果涉及的分片已經達到了承載數據的最大值,就需要對集群進行擴容。擴容是很麻煩的,一般會成倍地擴容。
通用的擴容方法包括如下5個步驟:
Step1:按照新舊分片規則,對新舊數據庫進行雙寫。
Step2:將雙寫前按照舊分片規則寫入的歷史數據,根據新分片規則遷移寫入新的數據庫。
Step3:將按照舊的分片規則查詢改為按照新的分片規則查詢。
Step4:將雙寫數據庫邏輯從代碼中下線,只按照新的分片規則寫入數據。
Step5:刪除按照舊分片規則寫入的歷史數據。
這里,在第2步遷移歷史數據時,由於數據量很大,通常會導致不一致,因此,先清洗舊的數據,洗完后再遷移到新規則的新數據庫下,再做全量對比,對比后評估在遷移的過程中是否有數據的更新,如果有的話就再清洗、遷移,最后以對比沒有差距為准。
如果是金融交易數據,則最好將動靜數據分離,隨着時間的流逝,某個時間點之前的數據是不會被更新的,我們就可以拉長雙寫的時間窗口,這樣在足夠長的時間流逝后,只需遷移那些不再被更新的歷史數據即可,就不會在遷移的過程中由於歷史數據被更新而導致代理不一致。
在數據量巨大時,如果數據遷移后沒法進行全量對比,就需要進行抽樣對比,在進行抽樣對比時要根據業務的特點選取一些具有某類特征性的數據進行對比。
在遷移的過程中,數據的更新會導致不一致,可以在線上記錄遷移過程中的更新操作的日志,遷移后根據更新日志與歷史數據共同決定數據的最新狀態,來達到遷移數據的最終一致性。
2. 分庫分表維度導致的查詢問題
在分庫分表以后,如果查詢的標准是分片的主鍵,則可以通過分片規則再次路由並查詢;但是對於其他主鍵的查詢、范圍查詢、關聯查詢、查詢結果排序等,並不是按照分庫分表維度來查詢的。
例如,用戶購買了商品,需要將交易記錄保存下來,那么如果按照買家的緯度分表,則每個買家的交易記錄都被保存在同一表中,我們可以很快、很方便地查到某個買家的購買情況,但是某個商品被購買的交易數據很有可能分布在多張表中,查找起來比較麻煩。
反之,按照商品維度分表,則可以很方便地查找到該商品的購買情況,但若要查找到買家的交易記錄,則會比較麻煩。
所以常見的解決方式如下:
-
在多個分片表查詢后合並數據集,這種方式的效率很低。
-
記錄兩份數據,一份按照買家緯度分表,一份按照商品維度分表。
-
通過搜索引擎解決,但如果實時性要求很高,就需要實現實時搜索。
實際上,在高並發的服務平台下,交易系統是專門做交易的,因為交易是核心服務,SLA的級別比較高,所以需要和查詢系統分離,查詢一般通過其他系統進行,數據也可能是冗余存儲的。
這里再舉個例子,在某電商交易平台下,可能有買家查詢自己在某一時間段的訂單,也可能有賣家查詢自己在某一時間段的訂單,如果使用了分庫分表方案,則這兩個需求是難以滿足的。
因此,通用的解決方案是,在交易生成時生成一份按照買家分片的數據副本和一份按照賣家分片的數據副本,查詢時分別滿足之前的兩個需求,因此,查詢的數據和交易的數據可能是分別存儲的,並從不同的系統提供接口。
另外,在電商系統中,在一個交易訂單生成后,一般需要引用到訂單中交易的商品實體,如果簡單地引用,若商品的金額等信息發生變化,則會導致原訂單上的商品信息也會發生變化,這樣買家會很疑惑。
因此,通用的解決方案是在交易系統中存儲商品的快照,在查詢交易時使用交易的快照,因為快照是個靜態數據,永遠都不會更新,所以解決了這個問題。
可見查詢的問題最好在單獨的系統中使用其他技術來解決,而不是在交易系統中實現各類查詢功能;當然,也可以通過對商品的變更實施版本化,在交易訂單中引用商品的版本信息,在版本更新時保留商品的舊版本,這也是一種不錯的解決方案。
最后,關聯的表有可能不在同一數據庫中,所以基本不可能進行聯合查詢,需要借助大數據技術來實現,也就是上面所說的第3種方法,即通過大數據技術統一聚合和處理關系型數據庫的數據,然后對外提供查詢操作,請參考第5章的內容。
通過大數據方式來提供聚合查詢的方式如圖3-10所示。
圖3-10
3. 跨庫事務難以實現
要避免在一個事務中同時修改數據庫db0和數據庫db1中的表,因為操作起來很復雜,對效率也會有一定的影響。請參考第三章的內容。
4. 同組數據跨庫問題
要盡量把同一組數據放到同一台數據庫服務器上,不但在某些場景下可以利用本地事務的強一致性,還可以使這組數據自治。
以電商為例,我們的應用有兩個數據庫db0和db1,分庫分表后,按照id維度,將賣家A的交易信息存放到db0中。當數據庫db1掛掉時,賣家A的交易信息不受影響,依然可以正常使用。也就是說,要避免數據庫中的數據依賴另一數據庫中的數據。