A. 如何處理資料庫並發問題
想要知道如何處理數據並發,自然需要先了解數據並發。
什麼是數據並發操作呢?
就是同一時間內,不同的線程同時對一條數據進行讀寫操作。
在互聯網時代,一個系統常常有很多人在使用,因此就可能出現高並發的現象,也就是不同的用戶同時對一條數據進行操作,如果沒有有效的處理,自然就會出現數據的異常。而最常見的一種數據並發的場景就是電商中的秒殺,成千上萬個用戶對在極端的時間內,搶購一個商品。針對這種場景,商品的庫存就是一個需要控制的數據,而多個用戶對在同一時間對庫存進行重寫,一個不小心就可能出現超賣的情況。
針對這種情況,我們如何有效的處理數據並發呢?
第一種方案、資料庫鎖
從鎖的基本屬性來說,可以分為兩種:一種是共享鎖(S),一種是排它鎖(X)。在Mysql的資料庫中,是有四種隔離級別的,會在讀寫的時候,自動的使用這兩種鎖,防止數據出現混亂。
這四種隔離級別分別是:
讀未提交(Read Uncommitted)
讀提交(Read Committed)
可重復讀(Repeated Read)
串列化(Serializable)
當然,不同的隔離級別,效率也是不同的,對於數據的一致性保證也就有不同的結果。而這些可能出現的又有哪些呢?
臟讀(dirty read)
當事務與事務之間沒有任何隔離的時候,就可能會出現臟讀。例如:商家想看看所有的訂單有哪些,這時,用戶A提交了一個訂單,但事務還沒提交,商家卻看到了這個訂單。而這時就會出現一種問題,當商家去操作這個訂單時,可能用戶A的訂單由於部分問題,導致數據回滾,事務沒有提交,這時商家的操作就會失去目標。
不可重復讀(unrepeatable read)
一個事務中,兩次讀操作出來的同一條數據值不同,就是不可重復讀。
例如:我們有一個事務A,需要去查詢一下商品庫存,然後做扣減,這時,事務B操作了這個商品,扣減了一部分庫存,當事務A再次去查詢商品庫存的時候,發現這一次的結果和上次不同了,這就是不可重復讀。
幻讀(phantom problem)
一個事務中,兩次讀操作出來的結果集不同,就是幻讀。
例如:一個事務A,去查詢現在已經支付的訂單有哪些,得到了一個結果集。這時,事務B新提交了一個訂單,當事務A再次去查詢時,就會出現,兩次得到的結果集不同的情況,也就是幻讀了。
那針對這些結果,不同的隔離級別可以干什麼呢?
「讀未提(Read Uncommitted)」能預防啥?啥都預防不了。
「讀提交(Read Committed)」能預防啥?使用「快照讀(Snapshot Read)」方式,避免「臟讀」,但是可能出現「不可重復讀」和「幻讀」。
「可重復讀(Repeated Red)」能預防啥?使用「快照讀(Snapshot Read)」方式,鎖住被讀取記錄,避免出現「臟讀」、「不可重復讀」,但是可能出現「幻讀」。
「串列化(Serializable)」能預防啥?有效避免「臟讀」、「不可重復讀」、「幻讀」,不過運行效率奇差。
好了,鎖說完了,但是,我們的資料庫鎖,並不能有效的解決並發的問題,只是盡可能保證數據的一致性,當並發量特別大時,資料庫還是容易扛不住。那解決數據並發的另一個手段就是,盡可能的提高處理的速度。
因為數據的IO要提升難度比較大,那麼通過其他的方式,對數據進行處理,減少資料庫的IO,就是提高並發能力的有效手段了。
最有效的一種方式就是:緩存
想要減少並發出現的概率,那麼讀寫的效率越高,讀寫的執行時間越短,自然數據並發的可能性就變小了,並發性能也有提高了。
還是用剛才的秒殺舉例,我們為的就是保證庫存的數據不出錯,賣出一個商品,減一個庫存,那麼,我們就可以將庫存放在內存中進行處理。這樣,就能夠保證庫存有序的及時扣減,並且不出現問題。這樣,我們的資料庫的寫操作也變少了,執行效率也就大大提高了。
當然,常用的分布式緩存方式有:Redis和Memcache,Redis可以持久化到硬碟,而Memcache不行,應該怎麼選擇,就看具體的使用場景了。
當然,緩存畢竟使用的范圍有限,很多的數據我們還是必須持久化到硬碟中,那我們就需要提高資料庫的IO能力,這樣避免一個線程執行時間太長,造成線程的阻塞。
那麼,讀寫分離就是另一種有效的方式了
當我們的寫成為了瓶頸的時候,讀寫分離就是一種可以選擇的方式了。
我們的讀庫就只需要執行讀,寫庫就只需要執行寫,把讀的壓力從主庫中分離出去,讓主庫的資源只是用來保證寫的效率,從而提高寫操作的性能。
B. 資料庫為什麼要分庫分表
1 基本思想之什麼是分庫分表?
從字面上簡單理解,就是把原本存儲於一個庫的數據分塊存儲到多個庫上,把原本存儲於一個表的數據分塊存儲到多個表上。
2 基本思想之為什麼要分庫分表?
數
據庫中的數據量不一定是可控的,在未進行分庫分表的情況下,隨著時間和業務的發展,庫中的表會越來越多,表中的數據量也會越來越大,相應地,數據操作,增
刪改查的開銷也會越來越大;另外,由於無法進行分布式式部署,而一台伺服器的資源(CPU、磁碟、內存、IO等)是有限的,最終資料庫所能承載的數據量、
數據處理能力都將遭遇瓶頸。
3 分庫分表的實施策略。
分庫分表有垂直切分和水平切分兩種。
3.1
何謂垂直切分,即將表按照功能模塊、關系密切程度劃分出來,部署到不同的庫上。例如,我們會建立定義資料庫workDB、商品資料庫payDB、用戶數據
庫userDB、日誌資料庫logDB等,分別用於存儲項目數據定義表、商品定義表、用戶數據表、日誌數據表等。
3.2
何謂水平切分,當一個表中的數據量過大時,我們可以把該表的數據按照某種規則,例如userID散列,進行劃分,然後存儲到多個結構相同的表,和不同的庫
上。例如,我們的userDB中的用戶數據表中,每一個表的數據量都很大,就可以把userDB切分為結構相同的多個userDB:part0DB、
part1DB等,再將userDB上的用戶數據表userTable,切分為很多userTable:userTable0、userTable1等,
然後將這些表按照一定的規則存儲到多個userDB上。
3.3 應該使用哪一種方式來實施資料庫分庫分表,這要看資料庫中數據量的瓶頸所在,並綜合項目的業務類型進行考慮。
如果資料庫是因為表太多而造成海量數據,並且項目的各項業務邏輯劃分清晰、低耦合,那麼規則簡單明了、容易實施的垂直切分必是首選。
而
如果資料庫中的表並不多,但單表的數據量很大、或數據熱度很高,這種情況之下就應該選擇水平切分,水平切分比垂直切分要復雜一些,它將原本邏輯上屬於一體
的數據進行了物理分割,除了在分割時要對分割的粒度做好評估,考慮數據平均和負載平均,後期也將對項目人員及應用程序產生額外的數據管理負擔。
在現實項目中,往往是這兩種情況兼而有之,這就需要做出權衡,甚至既需要垂直切分,又需要水平切分。我們的游戲項目便綜合使用了垂直與水平切分,我們首先對資料庫進行垂直切分,然後,再針對一部分表,通常是用戶數據表,進行水平切分。
4 分庫分表存在的問題。
4.1 事務問題。
在執行分庫分表之後,由於數據存儲到了不同的庫上,資料庫事務管理出現了困難。如果依賴資料庫本身的分布式事務管理功能去執行事務,將付出高昂的性能代價;如果由應用程序去協助控制,形成程序邏輯上的事務,又會造成編程方面的負擔。
4.2 跨庫跨表的join問題。
在執行了分庫分表之後,難以避免會將原本邏輯關聯性很強的數據劃分到不同的表、不同的庫上,這時,表的關聯操作將受到限制,我們無法join位於不同分庫的表,也無法join分表粒度不同的表,結果原本一次查詢能夠完成的業務,可能需要多次查詢才能完成。
4.3 額外的數據管理負擔和數據運算壓力。
額
外的數據管理負擔,最顯而易見的就是數據的定位問題和數據的增刪改查的重復執行問題,這些都可以通過應用程序解決,但必然引起額外的邏輯運算,例如,對於
一個記錄用戶成績的用戶數據表userTable,業務要求查出成績最好的100位,在進行分表之前,只需一個order
by語句就可以搞定,但是在進行分表之後,將需要n個order
by語句,分別查出每一個分表的前100名用戶數據,然後再對這些數據進行合並計算,才能得出結果。
C. 分庫分表技術及技術方案
一、分庫分表的必要性
分庫分表技術的使用,主要是資料庫產生了瓶頸,如單庫的並發訪問或單表的查詢都超出了閾值。對系統使用造成一定的影響,不得已而產生的技術。
通過分庫分表技術來解決此類問題,但正因為使用此技術,會產生ACID一系列的問題,各類中間件解決此類問題各有各的優勢。
提示:如場景無必要,千萬不要使用分庫分表。
二、分庫分表的思路
1、垂直區分
垂直分庫:從業務角度,一個庫分成多個庫,如把訂單和用戶信息分成兩個庫來存儲。這樣的好處就是可以微服務了。每塊的業務單獨部署,互不影響,通過介面去調用。
垂直分表:把大表分成多個小表,如熱點數據和非熱點數據分開,提高查詢速度。
2、水平區分
水平分表:同一業務如數據量大了以後,根據一定的規則分為不同的表進行存儲。
水平分庫:如訂單分成多個庫存儲,分解伺服器壓力。
以上一般來說,垂直分庫和水平分表用的會多些。
三、分庫分表的原理分析
分庫分表常用的方案:Hash取模方案和range范圍方案;
路由演算法為最主要的演算法,指得是把路由的Key按照指定的演算法進行存放;
1、Hash取模方案
根據取余分配到不同的表裡。要根據實際情況確認模的大小。此方案由於平均分配,不存在熱點問題,但數據遷移很復雜。
2、Range范圍方案
range根據范圍進行劃分,如日期,大小。此方案不存在數據遷移,但存在熱點問題。
四、分庫分表的技術選型
1、技術選型
解決方案主要分為4種:MySQL的分區技術、NoSql、NewSQL、MySQL的分庫分表。
(1)mysql分區技術:把一張表存放在不同存儲文件。由於無法負載,使用較少。
(2)NoSQL(如MongoDB):如是訂單等比較重要數據,強關聯關系,需約束一致性,不太適應。
(3)NewSql(具有NoSQL對海量數據的存儲管理能力,還保持了傳統資料庫支持ACID和SQL等特性):如TiDB可滿足需求。
(4)MySQL的分庫分表:如使用mysql,此種方案為主流方式。
2、中間件
解決此類問題的中間件主要為:Proxy模式、Client模式。
(1)Proxy模式
(2)Client模式
把分庫分表相關邏輯存放在客戶端,一版客戶端的應用會引用一個jar,然後再jar中處理SQL組合、資料庫路由、執行結果合並等相關功能。
(3)中間件的比較
由於Client模式少了一層,運維方便,相對來說容易些。
五、分庫分表的實踐
根據容量(當前容量和增長量)評估分庫或分表個數 -> 選key(均勻)-> 分表規則(hash或range等)-> 執行(一般雙寫)-> 擴容問題(盡量減少數據的移動)。
在這里我們選用中間件share-jdbc。
1、引入maven依賴
2、spring boot規則配置
行表達式標識符可以使用${...}或$->{...},但前者與Spring本身的屬性文件佔位符沖突,因此在Spring環境中使用行表達式標識符建議使用$->{...}。
3、創建DataSource
通過ShardingDataSourceFactory工廠和規則配置對象獲取ShardingDataSource,ShardingDataSource實現自JDBC的標准介面DataSource。然後即可通過DataSource選擇使用原生JDBC開發,或者使用JPA, MyBatis等ORM工具。
D. 如何保證緩存與資料庫雙寫時的數據一致性
一般來說,就是如果系統不是嚴格要求緩存+資料庫必須一致性的話,緩存可以稍微的跟資料庫偶爾有不一致的情況,最好不要做這個方案,讀請求和寫請求串列化,串到一個內存隊列里去,這樣就可以保證一定不會出現不一致的情況
串列化之後,就會導致系統的吞吐量會大幅度的降低,用比正常情況下多幾倍的機器去支撐線上的一個請求。
E. 分庫分表 VS newsql資料庫
最近與同行 科技 交流,經常被問到分庫分表與分布式資料庫如何選擇,網上也有很多關於中間件+傳統關系資料庫(分庫分表)與NewSQL分布式資料庫的文章,但有些觀點與判斷是我覺得是偏激的,脫離環境去評價方案好壞其實有失公允。
本文通過對兩種模式關鍵特性實現原理對比,希望可以盡可能客觀、中立的闡明各自真實的優缺點以及適用場景。
首先關於「中間件+關系資料庫分庫分表」算不算NewSQL分布式資料庫問題,國外有篇論文pavlo-newsql-sigmodrec,如果根據該文中的分類,Spanner、TiDB、OB算是第一種新架構型,Sharding-Sphere、Mycat、DRDS等中間件方案算是第二種(文中還有第三種雲資料庫,本文暫不詳細介紹)。
基於中間件(包括SDK和Proxy兩種形式)+傳統關系資料庫(分庫分表)模式是不是分布式架構?我覺得是的,因為存儲確實也分布式了,也能實現橫向擴展。但是不是"偽"分布式資料庫?從架構先進性來看,這么說也有一定道理。"偽"主要體現在中間件層與底層DB重復的SQL解析與執行計劃生成、存儲引擎基於B+Tree等,這在分布式資料庫架構中實際上冗餘低效的。為了避免引起真偽分布式資料庫的口水戰,本文中NewSQL資料庫特指這種新架構NewSQL資料庫。
NewSQL資料庫相比中間件+分庫分表的先進在哪兒?畫一個簡單的架構對比圖:
這些大多也是NewSQL資料庫產品主要宣傳的點,不過這些看起來很美好的功能是否真的如此?接下來針對以上幾點分別闡述下的我的理解。
這是把雙刃劍。
CAP限制
想想更早些出現的NoSQL資料庫為何不支持分布式事務(最新版的mongoDB等也開始支持了),是缺乏理論與實踐支撐嗎?並不是,原因是CAP定理依然是分布式資料庫頭上的頸箍咒,在保證強一致的同時必然會犧牲可用性A或分區容忍性P。為什麼大部分NoSQL不提供分布式事務?
那麼NewSQL資料庫突破CAP定理限制了嗎?並沒有。NewSQL資料庫的鼻主Google Spanner(目前絕大部分分布式資料庫都是按照Spanner架構設計的)提供了一致性和大於5個9的可用性,宣稱是一個「實際上是CA」的,其真正的含義是 系統處於 CA 狀態的概率非常高,由於網路分區導致的服務停用的概率非常小 ,究其真正原因是其打造私有全球網保證了不會出現網路中斷引發的網路分區,另外就是其高效的運維隊伍,這也是cloud spanner的賣點。詳細可見CAP提出者Eric Brewer寫的《Spanner, TrueTime 和CAP理論》。
完備性 :
兩階段提交協議是否嚴格支持ACID,各種異常場景是不是都可以覆蓋?
2PC在commit階段發送異常,其實跟最大努力一階段提交類似也會有部分可見問題,嚴格講一段時間內並不能保證A原子性和C一致性(待故障恢復後recovery機制可以保證最終的A和C)。完備的分布式事務支持並不是一件簡單的事情,需要可以應對網路以及各種硬體包括網卡、磁碟、CPU、內存、電源等各類異常,通過嚴格的測試。之前跟某友商交流,他們甚至說目前已知的NewSQL在分布式事務支持上都是不完整的,他們都有案例跑不過,圈內人士這么篤定,也說明了 分布式事務的支持完整程度其實是層次不齊的。
但分布式事務又是這些NewSQL資料庫的一個非常重要的底層機制,跨資源的DML、DDL等都依賴其實現,如果這塊的性能、完備性打折扣,上層跨分片SQL執行的正確性會受到很大影響。
性能
傳統關系資料庫也支持分布式事務XA,但為何很少有高並發場景下用呢? 因為XA的基礎兩階段提交協議存在網路開銷大,阻塞時間長、死鎖等問題,這也導致了其實際上很少大規模用在基於傳統關系資料庫的OLTP系統中。
NewSQL資料庫的分布式事務實現也仍然多基於兩階段提交協議,例如google percolator分布式事務模型,
採用原子鍾+MVCC+ Snapshot Isolation(SI),這種方式通過TSO(Timestamp Oracle)保證了全局一致性,通過MVCC避免了鎖,另外通過primary lock和secondary lock將提交的一部分轉為非同步,相比XA確實提高了分布式事務的性能。
但不管如何優化,相比於1PC,2PC多出來的GID獲取、網路開銷、prepare日誌持久化還是會帶來很大的性能損失,尤其是跨節點的數量比較多時會更加顯著,例如在銀行場景做個批量扣款,一個文件可能上W個賬戶,這樣的場景無論怎麼做還是吞吐都不會很高。
雖然NewSQL分布式資料庫產品都宣傳完備支持分布式事務,但這並不是說應用可以完全不用關心數據拆分,這些資料庫的最佳實踐中仍然會寫到,應用的大部分場景盡可能避免分布式事務。
既然強一致事務付出的性能代價太大,我們可以反思下是否真的需要這種強一致的分布式事務?尤其是在做微服務拆分後,很多系統也不太可能放在一個統一的資料庫中。嘗試將一致性要求弱化,便是柔性事務,放棄ACID(Atomicity,Consistency, Isolation, Durability),轉投BASE(Basically Available,Soft state,Eventually consistent),例如Saga、TCC、可靠消息保證最終一致等模型,對於大規模高並發OLTP場景,我個人更建議使用柔性事務而非強一致的分布式事務。關於柔性事務,筆者之前也寫過一個技術組件,最近幾年也涌現出了一些新的模型與框架(例如阿里剛開源的Fescar),限於篇幅不再贅述,有空再單獨寫篇文章。
HA與異地多活
主從模式並不是最優的方式,就算是半同步復制,在極端情況下(半同步轉非同步)也存在丟數問題,目前業界公認更好的方案是基於paxos分布式一致性協議或者其它類paxos如raft方式,Google Spanner、TiDB、cockcoachDB、OB都採用了這種方式,基於Paxos協議的多副本存儲,遵循過半寫原則,支持自動選主,解決了數據的高可靠,縮短了failover時間,提高了可用性,特別是減少了運維的工作量,這種方案技術上已經很成熟,也是NewSQL資料庫底層的標配。
當然這種方式其實也可以用在傳統關系資料庫,阿里、微信團隊等也有將MySQL存儲改造支持paxos多副本的,MySQL也推出了官方版MySQL Group Cluster,預計不遠的未來主從模式可能就成為 歷史 了。
需要注意的是很多NewSQL資料庫廠商宣傳基於paxos或raft協議可以實現【異地多活】,這個實際上是有前提的,那就是異地之間網路延遲不能太高 。以銀行「兩地三中心」為例,異地之間多相隔數千里,延時達到數十毫秒,如果要多活,那便需異地副本也參與資料庫日誌過半確認,這樣高的延時幾乎沒有OLTP系統可以接受的。
資料庫層面做異地多活是個美好的願景,但距離導致的延時目前並沒有好的方案。 之前跟螞蟻團隊交流,螞蟻異地多活的方案是在應用層通過MQ同步雙寫交易信息,異地DC將交易信息保存在分布式緩存中,一旦發生異地切換,資料庫同步中間件會告之數據延遲時間,應用從緩存中讀取交易信息,將這段時間內涉及到的業務對象例如用戶、賬戶進行黑名單管理,等數據同步追上之後再將這些業務對象從黑名單中剔除。由於雙寫的不是所有資料庫操作日誌而只是交易信息,數據延遲隻影響一段時間內數據,這是目前我覺得比較靠譜的異地度多活方案。
另外有些系統進行了單元化改造,這在paxos選主時也要結合考慮進去,這也是目前很多NewSQL資料庫欠缺的功能。
Scale橫向擴展與分片機制
paxos演算法解決了高可用、高可靠問題,並沒有解決Scale橫向擴展的問題,所以分片是必須支持的。NewSQL資料庫都是天生內置分片機制的,而且會根據每個分片的數據負載(磁碟使用率、寫入速度等)自動識別熱點,然後進行分片的分裂、數據遷移、合並,這些過程應用是無感知的,這省去了DBA的很多運維工作量。以TiDB為例,它將數據切成region,如果region到64M時,數據自動進行遷移。
分庫分表模式下需要應用設計之初就要明確各表的拆分鍵、拆分方式(range、取模、一致性哈希或者自定義路由表)、路由規則、拆分庫表數量、擴容方式等。相比NewSQL資料庫,這種模式給應用帶來了很大侵入和復雜度,這對大多數系統來說也是一大挑戰。
這里有個問題是NewSQL資料庫統一的內置分片策略(例如tidb基於range)可能並不是最高效的,因為與領域模型中的劃分要素並不一致,這導致的後果是很多交易會產生分布式事務。 舉個例子,銀行核心業務系統是以客戶為維度,也就是說客戶表、該客戶的賬戶表、流水表在絕大部分場景下是一起寫的,但如果按照各表主鍵range進行分片,這個交易並不能在一個分片上完成,這在高頻OLTP系統中會帶來性能問題。
分布式SQL支持
常見的單分片SQL,這兩者都能很好支持。NewSQL資料庫由於定位與目標是一個通用的資料庫,所以支持的SQL會更完整,包括跨分片的join、聚合等復雜SQL。中間件模式多面向應用需求設計,不過大部分也支持帶拆分鍵SQL、庫表遍歷、單庫join、聚合、排序、分頁等。但對跨庫的join以及聚合支持就不夠了。
NewSQL資料庫一般並不支持存儲過程、視圖、外鍵等功能,而中間件模式底層就是傳統關系資料庫,這些功能如果只是涉及單庫是比較容易支持的。
NewSQL資料庫往往選擇兼容MySQL或者PostgreSQL協議,所以SQL支持僅局限於這兩種,中間件例如驅動模式往往只需做簡單的SQL解析、計算路由、SQL重寫,所以可以支持更多種類的資料庫SQL。
SQL支持的差異主要在於分布式SQL執行計劃生成器,由於NewSQL資料庫具有底層數據的分布、統計信息,因此可以做CBO,生成的執行計劃效率更高,而中間件模式下沒有這些信息,往往只能基於規則RBO(Rule-Based-Opimization),這也是為什麼中間件模式一般並不支持跨庫join,因為實現了效率也往往並不高,還不如交給應用去做。
存儲引擎
傳統關系資料庫的存儲引擎設計都是面向磁碟的,大多都基於B+樹。B+樹通過降低樹的高度減少隨機讀、進而減少磁碟尋道次數,提高讀的性能,但大量的隨機寫會導致樹的分裂,從而帶來隨機寫,導致寫性能下降。NewSQL的底層存儲引擎則多採用LSM,相比B+樹LSM將對磁碟的隨機寫變成順序寫,大大提高了寫的性能。不過LSM的的讀由於需要合並數據性能比B+樹差,一般來說LSM更適合應在寫大於讀的場景。當然這只是單純數據結構角度的對比,在資料庫實際實現時還會通過SSD、緩沖、bloom filter等方式優化讀寫性能,所以讀性能基本不會下降太多。NewSQL數據由於多副本、分布式事務等開銷,相比單機關系資料庫SQL的響應時間並不佔優,但由於集群的彈性擴展,整體QPS提升還是很明顯的,這也是NewSQL資料庫廠商說分布式資料庫更看重的是吞吐,而不是單筆SQL響應時間的原因。
成熟度與生態
分布式資料庫是個新型通用底層軟體,准確的衡量與評價需要一個多維度的測試模型,需包括發展現狀、使用情況、社區生態、監控運維、周邊配套工具、功能滿足度、DBA人才、SQL兼容性、性能測試、高可用測試、在線擴容、分布式事務、隔離級別、在線DDL等等,雖然NewSQL資料庫發展經過了一定時間檢驗,但多集中在互聯網以及傳統企業非核心交易系統中,目前還處於快速迭代、規模使用不斷優化完善的階段。
相比而言,傳統關系資料庫則經過了多年的發展,通過完整的評測,在成熟度、功能、性能、周邊生態、風險把控、相關人才積累等多方面都具有明顯優勢,同時對已建系統的兼容性也更好。
對於互聯網公司,數據量的增長壓力以及追求新技術的基因會更傾向於嘗試NewSQL資料庫,不用再考慮庫表拆分、應用改造、擴容、事務一致性等問題怎麼看都是非常吸引人的方案。
對於傳統企業例如銀行這種風險意識較高的行業來說,NewSQL資料庫則可能在未來一段時間內仍處於 探索 、審慎試點的階段。基於中間件+分庫分表模式架構簡單,技術門檻更低,雖然沒有NewSQL資料庫功能全面,但大部分場景最核心的訴求也就是拆分後SQL的正確路由,而此功能中間件模式應對還是綽綽有餘的,可以說在大多數OLTP場景是夠用的。
限於篇幅,其它特性例如在線DDL、數據遷移、運維工具等特性就不在本文展開對比。
總結
如果看完以上內容,您還不知道選哪種模式,那麼結合以下幾個問題,先思考下NewSQL資料庫解決的點對於自身是不是真正的痛點:
如果以上有2到3個是肯定的,那麼你可以考慮用NewSQL資料庫了,雖然前期可能需要一定的學習成本,但它是資料庫的發展方向,未來收益也會更高,尤其是互聯網行業,隨著數據量的突飛猛進,分庫分表帶來的痛苦會與日俱增。當然選擇NewSQL資料庫你也要做好承擔一定風險的准備。
如果你還未做出抉擇,不妨再想想下面幾個問題:
如果這些問題有多數是肯定的,那還是分庫分表吧。在軟體領域很少有完美的解決方案,NewSQL資料庫也不是數據分布式架構的銀彈。相比而言分庫分表是一個代價更低、風險更小的方案,它最大程度復用傳統關系資料庫生態,通過中間件也可以滿足分庫分表後的絕大多數功能,定製化能力更強。 在當前NewSQL資料庫還未完全成熟的階段,分庫分表可以說是一個上限低但下限高的方案,尤其傳統行業的核心系統,如果你仍然打算把資料庫當做一個黑盒產品來用,踏踏實實用好分庫分表會被認為是個穩妥的選擇。
很多時候軟體選型取決於領域特徵以及架構師風格,限於筆者知識與所屬行業特點所限,以上僅為個人粗淺的一些觀點,歡迎討論。
F. 如何保證資料庫緩存的最終一致性
對於互聯網業務來說,傳統的直接訪問資料庫方式,主要通過數據分片、一主多從等方式來扛住讀寫流量,但隨著數據量的積累和流量的激增,僅依賴資料庫來承接所有流量,不僅成本高、效率低、而且還伴隨著穩定性降低的風險。
鑒於大部分業務通常是讀多寫少(讀取頻率遠遠高於更新頻率),甚至存在讀操作數量高出寫操作多個數量級的情況。因此, 在架構設計中,常採用增加緩存層來提高系統的響應能力 ,提升數據讀寫性能、減少資料庫訪問壓力,從而提升業務的穩定性和訪問體驗。
根據 CAP 原理,分布式系統在可用性、一致性和分區容錯性上無法兼得,通常由於分區容錯無法避免,所以一致性和可用性難以同時成立。對於緩存系統來說, 如何保證其數據一致性是一個在應用緩存的同時不得不解決的問題 。
需要明確的是,緩存系統的數據一致性通常包括持久化層和緩存層的一致性、以及多級緩存之間的一致性,這里我們僅討論前者。持久化層和緩存層的一致性問題也通常被稱為雙寫一致性問題,「雙寫」意為數據既在資料庫中保存一份,也在緩存中保存一份。
對於一致性來說,包含強一致性和弱一致性 ,強一致性保證寫入後立即可以讀取,弱一致性則不保證立即可以讀取寫入後的值,而是盡可能的保證在經過一定時間後可以讀取到,在弱一致性中應用最為廣泛的模型則是最終一致性模型,即保證在一定時間之後寫入和讀取達到一致的狀態。對於應用緩存的大部分場景來說,追求的則是最終一致性,少部分對數據一致性要求極高的場景則會追求強一致性。
為了達到最終一致性,針對不同的場景,業界逐步形成了下面這幾種應用緩存的策略。
— 1 —
Cache-Aside
Cache-Aside 意為旁路緩存模式,是應用最為廣泛的一種緩存策略。下面的圖示展示了它的讀寫流程,來看看它是如何保證最終一致性的。在讀請求中,首先請求緩存,若緩存命中(cache hit),則直接返回緩存中的數據;若緩存未命中(cache miss),則查詢資料庫並將查詢結果更新至緩存,然後返回查詢出的數據(demand-filled look-aside )。在寫請求中,先更新資料庫,再刪除緩存(write-invalidate)。
1、為什麼刪除緩存,而不是更新緩存?
在 Cache-Aside 中,對於讀請求的處理比較容易理解,但在寫請求中,可能會有讀者提出疑問,為什麼要刪除緩存,而不是更新緩存?站在符合直覺的角度來看,更新緩存是一個容易被理解的方案,但站在性能和安全的角度,更新緩存則可能會導致一些不好的後果。
首先是性能 ,當該緩存對應的結果需要消耗大量的計算過程才能得到時,比如需要訪問多張資料庫表並聯合計算,那麼在寫操作中更新緩存的動作將會是一筆不小的開銷。同時,當寫操作較多時,可能也會存在剛更新的緩存還沒有被讀取到,又再次被更新的情況(這常被稱為緩存擾動),顯然,這樣的更新是白白消耗機器性能的,會導致緩存利用率不高。
而等到讀請求未命中緩存時再去更新,也符合懶載入的思路,需要時再進行計算。刪除緩存的操作不僅是冪等的,可以在發生異常時重試,而且寫-刪除和讀-更新在語義上更加對稱。
其次是安全 ,在並發場景下,在寫請求中更新緩存可能會引發數據的不一致問題。參考下面的圖示,若存在兩個來自不同線程的寫請求,首先來自線程 1 的寫請求更新了資料庫(step 1),接著來自線程 2 的寫請求再次更新了資料庫(step 3),但由於網路延遲等原因,線程 1 可能會晚於線程 2 更新緩存(step 4 晚於 step 3),那麼這樣便會導致最終寫入資料庫的結果是來自線程 2 的新值,寫入緩存的結果是來自線程 1 的舊值,即緩存落後於資料庫,此時再有讀請求命中緩存(step 5),讀取到的便是舊值。
2、為什麼先更新資料庫,而不是先刪除緩存?
另外,有讀者也會對更新資料庫和刪除緩存的時序產生疑問,那麼為什麼不先刪除緩存,再更新資料庫呢?在單線程下,這種方案看似具有一定合理性,這種合理性體現在刪除緩存成功。
但更新資料庫失敗的場景下,盡管緩存被刪除了,下次讀操作時,仍能將正確的數據寫回緩存,相對於 Cache-Aside 中更新資料庫成功,刪除緩存失敗的場景來說,先刪除緩存的方案似乎更合理一些。那麼,先刪除緩存有什麼問題呢?
問題仍然出現在並發場景下,首先來自線程 1 的寫請求刪除了緩存(step 1),接著來自線程 2 的讀請求由於緩存的刪除導致緩存未命中,根據 Cache-Aside 模式,線程 2 繼而查詢資料庫(step 2),但由於寫請求通常慢於讀請求,線程 1 更新資料庫的操作可能會晚於線程 2 查詢資料庫後更新緩存的操作(step 4 晚於 step 3),那麼這樣便會導致最終寫入緩存的結果是來自線程 2 中查詢到的舊值,而寫入資料庫的結果是來自線程 1 的新值,即緩存落後於資料庫,此時再有讀請求命中緩存( step 5 ),讀取到的便是舊值。
另外,先刪除緩存,由於緩存中數據缺失,加劇資料庫的請求壓力,可能會增大緩存穿透出現的概率。
3、如果選擇先刪除緩存,再更新資料庫,那如何解決一致性問題呢?
為了避免「先刪除緩存,再更新資料庫」這一方案在讀寫並發時可能帶來的緩存臟數據,業界又提出了延時雙刪的策略,即在更新資料庫之後,延遲一段時間再次刪除緩存,為了保證第二次刪除緩存的時間點在讀請求更新緩存之後,這個延遲時間的經驗值通常應稍大於業務中讀請求的耗時。
延遲的實現可以在代碼中 sleep 或採用延遲隊列。顯而易見的是,無論這個值如何預估,都很難和讀請求的完成時間點准確銜接,這也是延時雙刪被詬病的主要原因。
4、那麼 Cache-Aside 存在數據不一致的可能嗎?
在 Cache-Aside 中,也存在數據不一致的可能性。在下面的讀寫並發場景下,首先來自線程 1 的讀請求在未命中緩存的情況下查詢資料庫(step 1),接著來自線程 2 的寫請求更新資料庫(step 2),但由於一些極端原因,線程 1 中讀請求的更新緩存操作晚於線程 2 中寫請求的刪除緩存的操作(step 4 晚於 step 3),那麼這樣便會導致最終寫入緩存中的是來自線程 1 的舊值,而寫入資料庫中的是來自線程 2 的新值,即緩存落後於資料庫,此時再有讀請求命中緩存(step 5),讀取到的便是舊值。
這種場景的出現,不僅需要緩存失效且讀寫並發執行,而且還需要讀請求查詢資料庫的執行早於寫請求更新資料庫,同時讀請求的執行完成晚於寫請求。足以見得,這種 不一致場景產生的條件非常嚴格,在實際的生產中出現的可能性較小 。
除此之外,在並發環境下,Cache-Aside 中也存在讀請求命中緩存的時間點在寫請求更新資料庫之後,刪除緩存之前,這樣也會導致讀請求查詢到的緩存落後於資料庫的情況。
雖然在下一次讀請求中,緩存會被更新,但如果業務層面對這種情況的容忍度較低,那麼可以採用加鎖在寫請求中保證「更新資料庫&刪除緩存」的串列執行為原子性操作(同理也可對讀請求中緩存的更新加鎖)。 加鎖勢必會導致吞吐量的下降,故採取加鎖的方案應該對性能的損耗有所預期。
— 2 —
補償機制
我們在上面提到了,在 Cache-Aside 中可能存在更新資料庫成功,但刪除緩存失敗的場景,如果發生這種情況,那麼便會導致緩存中的數據落後於資料庫,產生數據的不一致的問題。
其實,不僅 Cache-Aside 存在這樣的問題,在延時雙刪等策略中也存在這樣的問題。針對可能出現的刪除失敗問題,目前業界主要有以下幾種補償機制。
1、刪除重試機制
由於同步重試刪除在性能上會影響吞吐量,所以常通過引入消息隊列,將刪除失敗的緩存對應的 key 放入消息隊列中,在對應的消費者中獲取刪除失敗的 key ,非同步重試刪除。這種方法在實現上相對簡單,但由於刪除失敗後的邏輯需要基於業務代碼的 trigger 來觸發 ,對業務代碼具有一定入侵性。
鑒於上述方案對業務代碼具有一定入侵性,所以需要一種更加優雅的解決方案,讓緩存刪除失敗的補償機制運行在背後,盡量少的耦合於業務代碼。一個簡單的思路是通過後台任務使用更新時間戳或者版本作為對比獲取資料庫的增量數據更新至緩存中,這種方式在小規模數據的場景可以起到一定作用,但其擴展性、穩定性都有所欠缺。
一個相對成熟的方案是基於 MySQL 資料庫增量日誌進行解析和消費,這里較為流行的是阿里巴巴開源的作為 MySQL binlog 增量獲取和解析的組件 canal(類似的開源組件還有 Maxwell、Databus 等)。
canal sever 模擬 MySQL slave 的交互協議,偽裝為 MySQL slave,向 MySQL master 發送 mp 協議,MySQL master 收到 mp 請求,開始推送 binary log 給 slave (即 canal sever ),canal sever 解析 binary log 對象(原始為 byte 流),可由 canal client 拉取進行消費,同時 canal server 也默認支持將變更記錄投遞到 MQ 系統中,主動推送給其他系統進行消費。
在 ack 機制的加持下,不管是推送還是拉取,都可以有效的保證數據按照預期被消費。當前版本的 canal 支持的 MQ 有 Kafka 或者 RocketMQ。另外, canal 依賴 ZooKeeper 作為分布式協調組件來實現 HA ,canal 的 HA 分為兩個部分:
那麼,針對緩存的刪除操作便可以在 canal client 或 consumer 中編寫相關業務代碼來完成。這樣,結合資料庫日誌增量解析消費的方案以及 Cache-Aside 模型,在讀請求中未命中緩存時更新緩存(通常這里會涉及到復雜的業務邏輯),在寫請求更新資料庫後刪除緩存,並基於日誌增量解析來補償資料庫更新時可能的緩存刪除失敗問題,在絕大多數場景下,可以有效的保證緩存的最終一致性。
另外需要注意的是,還應該隔離事務與緩存,確保資料庫入庫後再進行緩存的刪除操作。 比如考慮到資料庫的主從架構,主從同步及讀從寫主的場景下,可能會造成讀取到從庫的舊數據後便更新了緩存,導致緩存落後於資料庫的問題,這就要求對緩存的刪除應該確保在資料庫操作完成之後。所以,基於 binlog 增量日誌進行數據同步的方案,可以通過選擇解析從節點的 binlog,來避免主從同步下刪除緩存過早的問題。
3、數據傳輸服務 DTS
— 3 —
Read-Through
Read-Through 意為讀穿透模式,它的流程和 Cache-Aside 類似,不同點在於 Read-Through 中多了一個訪問控制層,讀請求只和該訪問控制層進行交互,而背後緩存命中與否的邏輯則由訪問控制層與數據源進行交互,業務層的實現會更加簡潔,並且對於緩存層及持久化層交互的封裝程度更高,更易於移植。
— 4 —
Write-Through
Write-Through 意為直寫模式,對於 Write-Through 直寫模式來說,它也增加了訪問控制層來提供更高程度的封裝。不同於 Cache-Aside 的是,Write-Through 直寫模式在寫請求更新資料庫之後,並不會刪除緩存,而是更新緩存。
這種方式的 優勢在於讀請求過程簡單 ,不需要查詢資料庫更新緩存等操作。但其劣勢也非常明顯,除了上面我們提到的更新資料庫再更新緩存的弊端之外,這種方案還會造成更新效率低,並且兩個寫操作任何一次寫失敗都會造成數據不一致。
如果要使用這種方案, 最好可以將這兩個操作作為事務處理,可以同時失敗或者同時成功,支持回滾,並且防止並發環境下的不一致 。另外,為了防止緩存擾動的頻發,也可以給緩存增加 TTL 來緩解。
站在可行性的角度,不管是 Write-Through 模式還是 Cache-Aside 模式,理想狀況下都可以通過分布式事務保證緩存層數據與持久化層數據的一致性,但在實際項目中,大多都對一致性的要求存在一些寬容度,所以在方案上往往有所折衷。
Write-Through 直寫模式適合寫操作較多,並且對一致性要求較高的場景,在應用 Write-Through 模式時,也需要通過一定的補償機制來解決它的問題。首先,在並發環境下,我們前面提到了先更新資料庫,再更新緩存會導致緩存和資料庫的不一致,那麼先更新緩存,再更新資料庫呢?
這樣的操作時序仍然會導致下面這樣線程 1 先更新緩存,最後更新資料庫的情況,即由於線程 1 和 線程 2 的執行不確定性導致資料庫和緩存的不一致。這種由於線程競爭導致的緩存不一致,可以通過分布式鎖解決,保證對緩存和資料庫的操作僅能由同一個線程完成。對於沒有拿到鎖的線程,一是通過鎖的 timeout 時間進行控制,二是將請求暫存在消息隊列中順序消費。
在下面這種並發執行場景下,來自線程 1 的寫請求更新了資料庫,接著來自線程 2 的讀請求命中緩存,接著線程 1 才更新緩存,這樣便會導致線程 2 讀取到的緩存落後於資料庫。同理,先更新緩存後更新資料庫在寫請求和讀請求並發時,也會出現類似的問題。面對這種場景,我們也可以加鎖解決。
另在,在 Write-Through 模式下,不管是先更新緩存還是先更新資料庫,都存在更新緩存或者更新資料庫失敗的情況,上面提到的重試機制和補償機制在這里也是奏效的。
— 5 —
Write-Behind
Write behind 意為非同步回寫模式,它也具有類似 Read-Through/Write-Through 的訪問控制層,不同的是,Write behind 在處理寫請求時,只更新緩存而不更新資料庫,對於資料庫的更新,則是通過批量非同步更新的方式進行的,批量寫入的時間點可以選在資料庫負載較低的時間進行。
在 Write-Behind 模式下,寫請求延遲較低,減輕了資料庫的壓力,具有較好的吞吐性。但資料庫和緩存的一致性較弱,比如當更新的數據還未被寫入資料庫時,直接從資料庫中查詢數據是落後於緩存的。同時,緩存的負載較大,如果緩存宕機會導致數據丟失,所以需要做好緩存的高可用。顯然,Write behind 模式下適合大量寫操作的場景,常用於電商秒殺場景中庫存的扣減。
— 6 —
Write-Around
如果一些非核心業務,對一致性的要求較弱,可以選擇在 cache aside 讀模式下增加一個緩存過期時間,在寫請求中僅僅更新資料庫,不做任何刪除或更新緩存的操作,這樣,緩存僅能通過過期時間失效。這種方案實現簡單,但緩存中的數據和資料庫數據一致性較差,往往會造成用戶的體驗較差,應慎重選擇。
— 7 —
總結
在解決緩存一致性的過程中,有多種途徑可以保證緩存的最終一致性,應該根據場景來設計合適的方案,讀多寫少的場景下,可以選擇採用「Cache-Aside 結合消費資料庫日誌做補償」的方案,寫多的場景下,可以選擇採用「Write-Through 結合分布式鎖」的方案 ,寫多的極端場景下,可以選擇採用「Write-Behind」的方案。