對分佈式系統課程電商秒殺項目的再思考

項目源碼: https://github.com/AlexanderChiuluvB/DistrubutedSystemProject

本項目經歷過一次架構的迭代,核心思想是把保證數據安全的機制從基於mysql樂觀鎖機制遷移到redis/zookeeper基於分佈式鎖機制,默認只要redis減庫存成功就給用戶返回秒殺成功的消息,但在實際應用場景中這種方法是有問題的:一是並沒有考慮到如何保證緩存和數據庫的最終一致性,可能redis減庫存成功了,把消息發送給kafka失敗了或者mysql消費失敗了,最終mysql並沒有成功創建訂單,也沒有做庫存的持久化。

這種方法默認Redis減庫存成功之後就秒殺成功,或者說是"預減庫存成功",那麼接下來的事情只是如何
來保證mysql接收到這個減庫存的消息。但如果Redis減庫存之後的操作失敗了呢?Redis事務是不支持回滾的!我們這裏的做法是Redis變化影響導致Mysql發生變化,但更爲理想的做法應該是Mysql的變化導致Redis的變化,因爲其實保存在Redis的數據其實是很不穩定的。萬一Redis崩了,很容易發生數據的丟失。比較保險的是隻有保證Mysql減庫存成功才能返回給用戶秒殺成功。

而且Redis減庫存成功了,但是Mysql還沒有落單,如果減庫存成功而支付失敗了,這時候同樣會造成緩存和Mysql數據不一致的情況,而Redis事務是不支持回滾的,怎麼辦? 或許可以Mysql定時異步更新Redis的庫存量,或許Redis可以訂閱Mysql的binlog?

但這也並不意味着原來的方案是錯誤的,其實秒殺架構並沒有唯一解,可以有多種思路。所以我的想法只是拋磚引玉而已。

於是本文從性能角度,業務邏輯角度,保持緩存數據庫一致性角度,架構耦合性,可擴展性角度分析2019秋復旦大學分佈式系統秒殺課程項目的一些可改進的地方。

核心解決問題:

一般Redis和Mysql的數據一致性是怎麼保證的?

如何解決Kafka消費失敗導致的緩存數據庫不一致?

如何解決Kafka重複消費(冪等性)問題?

可以從哪些角度提高系統性能?

在進入正文之前,不妨可以分析一下,設計一個大併發,高性能,高可用的分佈式秒殺系統應該要考慮什麼?

架構原則:“4要1不要”

以下規則參考的是許令波的《設計秒殺系統》極客時間課。

1.數據要儘量少

用戶請求的數據儘量能少就少,這裏的數據指的是上傳給系統的數據以及系統返回給用戶的數據。

首先,數據在網絡上傳輸需要時間,服務器處理數據的時候通常還要做壓縮和字符編碼操作,這些操作都是十分消耗CPU的,所以應該儘可能減少傳輸的數據量來減少CPU的使用。具體做法可以是簡化秒殺頁面的大小,去掉不必要的頁面裝修效果

其次,儘可能減少系統依賴的數據。調用服務會涉及到數據的序列化和反序列化,這也是CPU的一大殺手,同樣會增加延時。所以儘可能減少和數據庫打交道的機會。

2.請求數量要儘量少

對於秒殺系統來說,HTTP請求大多是短請求(建立的都是非持久性鏈接)。瀏覽器每發出一個請求多少都會有一些消耗:如建立連接時候TCP三次握手,如DNS域名解析。

減少請求數量最常用一個實踐是合併CSS和JavaScript文件。

把多個 JavaScript 文件合併成一個文件,在 URL 中用逗號隔開(https://g.xxx.com/tm/xx-b/4.0.94/mods/??module-preview/index.xtpl.js,module-jhs/index.xtpl.js,module-focus/index.xtpl.js)。這種方式在服務端仍然是單個文件各自存放,只是服務端會有一個組件解析這個 URL,然後動態把這些文件合併起來一起返回。

3.路徑要儘量短

儘可能減少用戶發出請求到返回數據過程中需要經歷的中間的節點數。

一方面儘可能減少RPC的序列化&反序列化,而是可以減少網絡傳輸的延時

另外,每增加一個鏈接都會增加新的不確定性,舉個例子:假如每個節點的可用性是99.9%,那麼經過五個節點,整個請求的可用性就是 99.9%的五次方。

具體做法: 可以把多個強相互依賴的應用合併部署在一起,把RPC變成JVM內部之間的方法調用。

4.依賴要儘量少

所謂依賴,指的是要完成一次用戶請求必須依賴的系統或者服務,這裏的依賴指的是強依賴。

例如一個秒殺頁面,頁面必須強依賴商品信息,用戶信息,還有其他如優惠券,成交列表這些弱依賴的信息。

可以通過系統的重要程度給系統進行分級,儘可能減少高級系統對低級系統(如減少支付系統對優惠券系統)的依賴。

5.不要有單點

單點意味着沒有備份,設計分佈式系統一個重要考量就是避免單點。

避免單點:服務的無狀態化——避免把服務的狀態和機器綁定。

那如何把服務的狀態和機器解耦?

例如把和機器相關的配置動態化,這些參數可以通過配置中心來動態推送。在服務啓動的時候拉取下來,就可以在這些配置中心設置一些規則來方便改變這些映射關係。

先給出文章中給的一個系統架構思路:
在這裏插入圖片描述

1.動靜數據的分離,不需要每次點擊秒殺的時候重新刷新頁面,每一次刷新頁面肯定要涉及數據的傳輸,網絡的開銷
2.引入秒殺答題提交系統,類似於限流的思想
3.然後讀Redis,如果是空的話從Mysql填庫存,然後之後熱點商品都讀Redis,這裏Redis更像是保護Mysql,只是起一個判斷庫存是否充足的作用。這時Redis應該設置超時時間,然後可以通過主動更新或者定時更新的方法來更新Redis庫存,這時候的庫存數據應該設置超時時間,通過主動失效來失效緩存。
4.最後Mysql減庫存成功才能算是下單成功,推測靠樂觀鎖來防止超賣。

正文開始

1.性能角度

我們主要討論的是系統服務端的性能:判斷秒殺系統性能的一個關鍵指標就是QPS(Query Per Second,每秒請求數)

還有一個影響也和QPS密切相關,即服務器的響應時間,可以理解爲服務器處理相應的耗時。

對於Web系統而言,響應時間一般由CPU的執行時間和線程等待時間(如RPC,IO,線程的休眠,等待)
組成。

但一般真正影響性能的還是CPU的執行時間。因爲CPU真正消耗了服務器的資源。

其次,考慮線程數量對QPS的影響。
並不是線程數目越多越好,因爲線程切換會有成本,線程切換涉及到用戶態和內核態的轉換,本質上就是進行上下文切換,保存線程上下文現場。

然後多線程場景你還要分析是CPU密集型還是IO密集型

如果是前者: 線程數 = CPU核數+1
如果是後者: 線程數 = 2*CPU核數+1

那如何判斷髮現哪些函數CPU耗時比較多?

例如可以用jstack定時地打印調用棧,如果某些函數調用十分頻繁或者耗時比較多,那麼這些函數就會多次出現在系統調用棧裏。

如何簡單判斷CPU是否是瓶頸,一個辦法是看QPS是否到達極限。即CPU利用率是否超過了95%.如果沒有超過,說明CPU還有提升空間。

1.0 從編碼,序列化,併發讀優化的角度思考

1.減少編碼

Java的編碼運行會比較慢,只要涉及到字符串的操作如輸入輸出,IO操作都會比較消耗CPU的資源。因此不管是磁盤IO還是網絡IO,都需要把字符轉換成字節。

字符的編碼是需要查表的,十分消耗資源,因此如果能夠減少字符到字節或者相反的轉換,就可以提高性能。

把靜態的字符串提前編碼成字節並緩存,然後直接輸出字節內容到頁面。

2.減少序列化

序列化往往也伴隨着編碼,序列化大部分是在RPC發生的,那麼可以採取合併部署的方式,不僅僅把原來在不同機器上的不同應用合併部署到一臺機器上,還要在同一個Tomcat容器中,不走本機的socket,避免序列化產生。

3.併發讀優化

可以在秒殺系統的單機上緩存商品相關的數據。分別劃分爲靜態數據和動態數據做處理。

  • 像標題和描述這類不變的靜態數據,秒殺開始前可以全部推送到機器上,一直緩存到秒殺結束。

  • 像庫存這類動態數據,會採用"被動失效"的方式緩存一段時間。由Mysql定時異步更新Redis的庫存量。

那你也許會問,萬一庫存數據不一致怎麼辦?秒殺保證最終一致性即可,允許少量讀請求把無請求誤判爲有請求。以等到真正寫數據時再保證最終的一致性,通過在數據的高可用性和一致性之間的平衡。

1.1 從分佈式鎖的性能角度思考

初始版本的解決超賣關鍵是: Mysql基於版本號的樂觀鎖機制

Redis預存放各種商品的庫存,實際場景是不可能的,比較淘寶有千千萬萬種商品對吧。實際中最多存放熱點商品的數據,一開始Redis都是空的,要自己去Mysql發起一個查詢請求再把商品初始庫存寫到Redis。

基本業務邏輯: 秒殺訂單到達Redis->判斷庫存是否足夠->消息隊列入隊->Mysql基於樂觀鎖更新

缺點: Redis的作用只是判斷庫存是否足夠,訪問Redis的時候沒有保證原子性,導致大量的無用請求獲得的版本號是一樣的(例如同時有5000個HTTP請求訪問Redis,這5000個都從Redis得知庫存充足,假設5000個請求出隊,同時訪問Mysql,得知的版本號都是x,那麼其實只有第一個得到訪問到Mysql的請求能夠更新成功,其他都會更新失敗),大量無效請求最終會經過Kafka而落到Mysql,性能瓶頸是在Kafka的消費速度和Mysql的訪問速度(基於磁盤IO,肯定慢)

改進版本解決超賣關鍵是: 基於Redis的分佈式鎖orZooKeeper的分佈式鎖

可以嘗試比較Redis分佈式鎖和Zookeeper分佈式鎖的性能,先簡單介紹一下其原理:

1.基於setnx()和expire()設置分佈式鎖

基於setnx(set if not exist)的特點,當緩存裏key不存在時,纔會去set,否則直接返回false。如果返回true則獲取到鎖,否則獲取鎖失敗。

以上實現方式存在幾個問題:

1、這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在Redis中,其他線程無法再獲得到鎖。

2、這把鎖只能是非阻塞的,無論成功還是失敗都直接返回。

3、這把鎖是非重入的,一個線程獲得鎖之後,在釋放鎖之前,無法再次獲得該鎖,因爲使用到的key在Redis中已經存在。無法再執行put操作。

當然,同樣有方式可以解決。

  • 沒有失效時間?
    我們再用expire命令對這個key設置一個超時時間來避免。但是這裏看似完美,實則有缺陷,當我們setnx成功後,線程發生異常中斷,expire還沒來的及設置,那麼就會產生死鎖。

解決方法:官方推薦lua腳本來實現setnx()和expire()實現原子性

  • 非阻塞?
    while重複執行。

  • 非可重入?
    在一個線程獲取到鎖之後,把當前主機信息和線程信息保存起來,下次再獲取之前先檢查自己是不是當前鎖的擁有者。

但是,失效時間我設置多長時間爲好?如何設置的失效時間太短,方法沒等執行完,鎖就自動釋放了,那麼就會產生併發問題。如果設置的時間太長,其他獲取鎖的線程就可能要平白的多等一段時間。

2.Redission 實現分佈式鎖原理

https://mp.weixin.qq.com/s?__biz=MzU0OTk3ODQ3Ng==&mid=2247483893&idx=1&sn=32e7051116ab60e41f72e6c6e29876d9&chksm=fba6e9f6ccd160e0c9fa2ce4ea1051891482a95b1483a63d89d71b15b33afcdc1f2bec17c03c&scene=21#wechat_redirect

3.基於RedLock算法

假設有5個Redis客戶端:

(1) 獲取當前時間;

(2) 嘗試從5個相互獨立redis客戶端獲取鎖;

(3) 計算獲取所有鎖消耗的時間,當且僅當客戶端從多數節點獲取鎖,並且獲取鎖的時間小於鎖的有效時間,認爲獲得鎖;

(4) 重新計算有效期時間,原有效時間減去獲取鎖消耗的時間;

(5) 刪除所有實例的鎖

如果5個節點有2個宕機,此時鎖的可用性會極大降低,首先必須等待這兩個宕機節點的結果超時才能返回,另外只有3個節點,客戶端必須獲取到這全部3個節點的鎖才能擁有鎖,難度也加大了。

如果出現網絡分區,那麼可能出現客戶端永遠也無法獲取鎖的情況,介於這種情況,下面我們來看一種更可靠的分佈式鎖zookeeper鎖。

4.ZooKeeper實現分佈式鎖算法原理

Zookeeper簡單介紹下特性:

數據模型:

永久節點:節點創建後,不會因爲會話失效而消失

臨時節點:與永久節點相反,如果客戶端連接失效,則立即刪除節點

順序節點:與上述兩個節點特性類似,如果指定創建這類節點時,zk會自動在節點名後加一個數字後綴,並且是有序的。

監視器(watcher):

當創建一個節點時,可以註冊一個該節點的監視器,當節點狀態發生改變時,watch被觸發時,ZooKeeper將會向客戶端發送且僅發送一條通知,因爲watch只能被觸發一次。

根據zookeeper的這些特性,我們來看看如何利用這些特性來實現分佈式鎖:

創建一個鎖目錄lock

希望獲得鎖的線程A就在lock目錄下,創建臨時順序節點

獲取鎖目錄下所有的子節點,然後獲取比自己小的兄弟節點,如果不存在,則說明當前線程順序號最小,獲得鎖

線程B獲取所有節點,判斷自己不是最小節點,設置監聽(watcher)比自己次小的節點(只關注比自己次小的節點是爲了防止發生“羊羣效應”)

線程A處理完,刪除自己的節點,線程B監聽到變更事件,判斷自己是最小的節點,獲得鎖。

分佈式鎖總結:

1.Zk不依靠超時時間釋放鎖;可靠性高;系統要求高可靠性時,建議採用zookeeper鎖,但是性能會比緩存鎖性能要差一些,因爲要頻繁地創建刪除節點。

2.實現一個分佈式鎖,要考慮的因素有:
高效地上鎖解鎖性能
故障時能夠保證成功解鎖
可重入
最好阻塞(但是阻塞的話性能會下降,最好結合業務考慮)
獲取鎖釋放鎖保證原子性,不能多個線程同時獲得鎖

3.基於分佈式鎖的優化,可以借鑑ConcurrentHashMap使用分段鎖來保證併發安全來思考這個問題,就是可以採取分段加鎖的策略,那麼可以支持多個線程併發訪問分佈式鎖,缺點就是業務邏輯會變得更復雜。

具體可參考 基於分佈式鎖的分段鎖優化思路

1.2 從消息隊列來思考

1.2.1 如何避免消息積壓?即如何提高消費的速度?

一切中間件的調優,都可以從CPU性能,磁盤緩存IO性能以及網絡傳輸和網絡IO三大方面來做發散性的思考。

這裏直接給出我當時的做法:

1.增大消費線程數量

2.提高分區數目,提高吞吐率

3.發送消息的確認方式採取異步,acks=1,只要leader收到,不丟即可。

4.提高配置文件網絡IO線程數量,套接字數量

5.分批消費,增大單批數據的消費量,減少IO次數

6.增大Page Cache容量

7.提高磁盤刷寫頻率

1.2.2 如何解決消息隊列冪等性問題?避免重複消費?

1.用mysql保證主鍵ID唯一性,每一條秒殺成功的消息都會對應一條訂單業務消息,那麼這個訂單消息肯定有一個訂單號,那麼可以以訂單號作爲mysql訂單表的主鍵,通過唯一主鍵來保證不會發生重複消費的可能性。

2.給每一條消息引入一個全局ID,並增加消息應用狀態表(message_apply),通俗來說就是個賬本,用於記錄消息的消費情況,每次來一個消息,在真正執行之前,先去消息應用狀態表中查詢一遍,如果找到說明是重複消息,丟棄即可,如果沒找到才執行,同時插入到消息應用狀態表(同一事務)。

1.3 從最終的性能瓶頸Mysql來思考

主要解決問題:

如何減少Mysql的流量壓力?

優化思路: 分區->分表->庫垂直拆分->庫水平拆分->庫讀寫分離

分區

分區指的是分散數據到不同的磁盤,查詢數據的時候只需要查詢指定的分區.一張表的數據分成N個物理區塊來負責.

但是當單個數據庫的請求太多而影響性能的時候,這就需要分庫和讀寫分離了.

MySQL支持什麼分區操作?

mysql支持的分區類型包括Range、List、Hash、Key,其中Range比較常用:

RANGE分區:基於屬於一個給定連續區間的列值,把多行分配給分區。
LIST分區:類似於按RANGE分區,區別在於LIST分區是基於列值匹配一個離散值集合中的某個值來進行選擇。
HASH分區:基於用戶定義的表達式的返回值來進行選擇的分區,該表達式使用將要插入到表中的這些行的列值進行計算。這個函數可以包含MySQL 中有效的、產生非負整數值的任何表達式。
KEY分區:類似於按HASH分區,區別在於KEY分區只支持計算一列或多列,且MySQL服務器提供其自身的哈希函數。必須有一列或多列包含整數值
CREATE TABLE sales (

id INT AUTO_INCREMENT,

amount DOUBLE NOT NULL,

order_day DATETIME NOT NULL,

PRIMARY KEY(id, order_day)

) ENGINE=Innodb

PARTITION BY RANGE(YEAR(order_day)) (

PARTITION p_2010 VALUES LESS THAN (2010),

PARTITION p_2011 VALUES LESS THAN (2011),

PARTITION p_2012 VALUES LESS THAN (2012),

PARTITION p_catchall VALUES LESS THAN MAXVALUE);

爲什麼要分庫分表?

1.數據庫表的數據量會不斷增大,查詢所需要的時間會越來越長,另外由於MySQL對寫操作會加鎖,會阻塞讀操作
2.對於數據量的問題,可以用分庫分表解決.然後對於寫操作,可以使用讀寫分離來解決
3.讀寫分離需要用到MySQL提供的主從機制
4.還可以使用分區概念,那什麼是垂直和水平分區?

分表

把一張表按照一定規則分解成N個具有獨立存儲空間的實體表,讀寫的時候根據定義好的規則得到對應的表聲明.

把一張大表拆分爲多個小表,不同分表中數據不重複.

分庫

分表和分區都是基於同一個數據庫裏面的數據分離技巧.但其實所有網絡IO和文件IO還是集中在一個數據庫上的

因此CPU,內存,文件和網絡IO都可能會成爲性能瓶頸.

常見分庫分表策略:

  • 垂直拆分

本質是字段拆分到不同的表
在這裏插入圖片描述

  • 水平拆分

把數據分散在多個表中
在這裏插入圖片描述
1.平均分配hash(Object)%N哈希取模,這種做法缺點在於如果集羣涉及到節點增加和刪除,進行擴縮容的時候需要數據遷移

2.按照業務進行分配

3.按照權重進行分配並且均勻輪詢

4.按照一致性哈希算法進行分配,方便數據遷移,這樣的好處是集羣機器增添或者刪減的時候,不會造成數據丟失,不需要重新劃分集羣

讀寫分離原理與實現

  • 增刪改操作讓主數據庫處理
  • 讀操作讓從數據庫處理

數據庫複製用於把事務性操作導致的變更同步到從數據庫

根本是解決數據庫的寫入影響查詢效率問題,所以當數據庫的讀遠遠大於寫的時候,可以讓主數據庫負責寫操作,從數據庫負責讀操作.當然也可以用Redis來降低後端數據庫的壓力.

1.4 從Redis緩存角度來思考

1.可以在服務端對靜態數據進行本地緩存如利用Hashmap,ConcurrnetHashmap,不需要每次都依賴系統的後臺服務獲取數據,甚至也不需要去公共的緩存集羣中查詢數據。這樣可以大幅度減少系統調用

2.使用令牌桶算法/答題做限流保護,篩選掉部分請求。

有沒有做緩存預熱?
怎麼解決緩存雪崩?
怎麼解決緩存穿透?
怎麼解決緩存擊穿?

1.5 從動靜分離的角度來思考

動態數據”和“靜態數據”的主要區別就是看頁面中輸出的數據是否和 URL、瀏覽者、時間、地域相關,以及是否含有 Cookie 等私密數據。

所謂動態還是靜態,並不是說數據本身是否動靜,而是數據中是否包含有與訪問者相關的個性化數據。

例如:
很多媒體類的網站,某一篇文章的內容不管是你訪問還是我訪問,它都是一樣的。所以它就是一個典型的靜態數據,但是它是個動態頁面。

我們如果現在訪問淘寶的首頁,每個人看到的頁面可能都是不一樣的,淘寶首頁中包含了很多根據訪問者特徵推薦的信息,而這些個性化的數據就可以理解爲動態數據了。

核心思想就是對靜態數據做緩存

1.應該把靜態數據緩存到離用戶最近的地方。緩存到哪裏?

可以是瀏覽器緩存,CDN,或者服務端的Cache

2.靜態化改造就是直接緩存HTTP鏈接

靜態化改造是直接緩存 HTTP 連接而不是僅僅緩存數據,如下圖所示,Web 代理服務器根據請求 URL,直接取出對應的 HTTP 響應頭和響應體然後直接返回,這個響應過程簡單得連 HTTP 協議都不用重新組裝,甚至連 HTTP 請求頭也不需要解析。
在這裏插入圖片描述

3.讓誰來緩存靜態數據也很重要。

2.Redis,Kafka,Mysql數據保持一致性(怎麼保證數據的一致性)

我們使用Redis分佈式鎖來解決超賣的項目業務邏輯:
1.Redis減庫存,用分佈式鎖保證不會超賣,減庫存成功後發送訂單消息到Kafka
2.Mysql消費Kafka發來的消息,進行減庫存的持久化和插入訂單表的操作

也就是說,Redis在這裏作爲外界讀取數據的唯一地方,Redis是作爲存儲來使用的。本質上是先更新緩存再異步更新數據庫,好處是實時性比較好,壞處是不能保證DB和Cache的雙寫一致性。

問題來了:

1.如果減庫存成功了,發送到消息隊列之前宕機了,即怎麼保證減庫存後一定發送到消息隊列?

我的解決思路1:要保證消息成功發送到消息隊列的時候,Redis再執行減庫存操作。

把訪問Redis拆分爲:

請求減庫存: 請求確認是否可以減庫存,然後嘗試發送消息到Kafka,Kafka提供發送消息確認機制,只有阻塞等待收到發送成功通知之後,才執行減庫存操作。也就是說把異步發送改爲同步發送

執行減庫存: 只有在Kafka收到發送消息的確認之後,Redis纔可以執行減庫存(搶分佈式鎖)操作。

問題:

1.有大量請求進隊,對Kafka消費壓力比較大
2.異步改同步,吞吐量會下降

我的解決思路2: 把減庫存成功生成的訂單請求寫到一個本地消息表中,通過定時掃描來查看這個表是否有消息沒有被髮送

我的解決思路3: Mysql端開一個異步線程定時更新Redis庫存。這種方案存在一定的時間差,但是秒殺系統並不需要保證強一致性。因此無所謂。

或者Redis來訂閱Mysql的binlog來實時更新也是可以的。

2.如果mysql成功從kafka拉取數據即消費成功,然後進行mysql本地事務操作的時候宕機了,或者寫mysql的線程斷了,這就導致了緩存庫存減了,但是數據庫庫存沒有減。那麼重新啓動之後如何繼續正確地消費數據?

我的解決思路: 也可以建立一個本地消息表,消費成功的時候把訂單消息寫入本地消息表,然後mysql定時去查詢這個本地消息表看有沒有消息被遺漏持久化。

總結而言,我認爲這種架構關鍵在於如何保證kafka消息隊列的消息不丟失不重複,從而保證Mysql和Redis的數據一致性。

那麼Kafka是如何保證消息隊列的消息不丟失不重複呢

消費端或者生產端重複消費: 可以建立一個本地消息表(可以理解爲去重表)。通過定時掃描的方法

消費端丟失數據:關閉自動提交偏移量的做法,改爲手動提交。

enable.auto.commit=false
只有在消息被完整處理之後再手動提交位移。

生產端丟失數據

ack機制能夠保證數據的不丟失。

如果ack=0,說明是異步發送,不理會消息是否發送成功。

如果ack=1,說明是同步發送,只保證leader符本接收到了數據的確認消息,replica異步拉取消息

如果ack=-1,也就是讓消息寫入leader和所有的副本,ISR列表中的所有replica都返回確認消息

ack確認機制設置爲 “all” 即所有副本都同步到數據時send方法才返回, 以此來完全判斷數據是否發送成功, 理論上來講數據不會丟失。

Possible Solution——分佈式事務

ref:分佈式事務

解決思路1: 利用Kafka事務特性,提供exactly-once語義支持 即事務消息解決方案

這裏借鑑了這篇文章介紹柔性事務

如何保證如果業務操作成功,那麼由這個業務操作所產生的消息一定要成功投遞出去。

在這裏插入圖片描述

1.首先發送一個事務消息,消息隊列把這個消息狀態標記爲prepared,注意這個時候這條消息消費者是無法消費到的。

2.執行業務代碼邏輯,如本地數據庫事務操作,本項目指的是競爭分佈式鎖減庫存

3.確認發送消息,這個時候,RocketMQ將消息狀態標記爲可消費,這個時候消費者,才能真正的保證消費到這條數據。

如果確認消息發送失敗了怎麼辦?RocketMQ會定期掃描消息集羣中的事務消息,如果發現了Prepared消息,它會向消息發送端(生產者)確認。RocketMQ會根據發送端設置的策略來決定是回滾還是繼續發送確認消息。這樣就保證了消息發送與本地事務同時成功或同時失敗。

如果消費失敗了怎麼辦? 消費消息涉及到提交offset到zookeeper那裏,那麼可以選擇手動提交,只有在成功消費數據的時候才提交offset。如果消費失敗允許重試。如果是消費成功了,但是執行後續的mysql更新失敗了,那就用本地消息表來解決。

解決思路2: 使用2PC,3PC,TCC分佈式事務,用用戶代碼來補償一致性 TCC事務解決方案

對每一個操作,註冊一個對應的確認和撤銷操作。

Try 階段主要是對業務系統做檢測及資源預留

Confirm 階段主要是對業務系統做確認提交,Try階段執行成功並開始執行 Confirm階段時,默認 Confirm階段是不會出錯的。即:只要Try成功,Confirm一定成功。

Cancel 階段主要是在業務執行錯誤,需要回滾的狀態下執行的業務取消,預留資源釋放。

以本項目爲例:

[Redis減庫存]
Try:
   檢查Redis庫存,即查看庫存是否充足;
   從庫存中扣減,並將狀態置爲“處理中”;
   預留扣減資源,將從Redis發送一個成功下單的請求到Mysql這個事件存入消息或者日誌中;
Confirm:
 不做任何操作;
Cancel:
   恢復庫存;
  從日誌或者消息中,釋放扣減資源。

[Mysql處理業務]
Try:
 檢查訪問庫存;
Confirm:
   讀取日誌或者消息,mysql減庫存,插入訂單消息;
   從日誌或者消息中,釋放扣減資源;
Cancel:
 不做任何操作。

TCC模型對業務的侵入強,改造的難度大

解決思路3: 參考本地事務表,在生產消費端都建立去重表,通過定時掃描來找到發送或者消費失敗的消息,即本地消息表解決方案

核心思想是將分佈式事務拆分成本地事務進行處理

消息生產方,需要額外建一個消息表,並記錄消息發送狀態。消息表和業務數據要在一個事務裏提交,也就是說他們要在一個數據庫裏面。然後消息會經過MQ發送到消息的消費方。如果消息發送失敗,會進行重試發送。

消息消費方,需要處理這個消息,並完成自己的業務邏輯。此時如果本地事務處理成功,表明已經處理成功了,如果處理失敗,那麼就會重試執行。如果是業務上面的失敗,可以給生產方發送一個業務補償消息,通知生產方進行回滾等操作。

生產方和消費方定時掃描本地消息表,把還沒處理完成的消息或者失敗的消息再發送一遍。

Cache aside

下面是網上參考的其他解決cache和DB雙寫不一致的解決方案,cache側重作爲"緩存",解決超賣應該還是要靠DB的樂觀鎖。以下方案也叫"Cache aside"

1 讀:先判斷是否有緩存數據,如果沒有從數據庫加載數據到緩存
2.寫:無論是先刪緩存再更新數據庫,還是先更新數據庫再刪緩存都會存在問題。

假如是先刪緩存,在更新數據庫:

如果刪緩存失敗了就不更新數據庫,如果刪緩存成功了,但是在更新數據庫前一個讀請求來了,他就會去讀數據庫,導致把一個髒的數據寫入到緩存。

解決方法可以採取延時雙刪:

(1)先淘汰緩存
(2)再寫數據庫(這兩步和原來一樣)
(3)休眠一定時間,再次淘汰緩存 這麼做,可以將休眠時間內所造成的緩存髒數據,再次刪除。

如果Mysql採用的是讀寫分離怎麼辦?
同樣採用延時雙刪,只不過休眠時間要加上主從同步的時間。

但是假如雙刪也失敗了,那麼可以給數據加一個超時的時間,那麼至少可以保證在這個超時時間內Cache和DB是不一致的。

如果先更新數據庫,再刪緩存:
如果更新數據庫成功了,刪緩存失敗了,也會導致數據不一致。

3.成本

4.項目的耦合性,可擴展性

5.系統性能優化要考慮的方面

三大方面:
1.資源

  • CPU
    調整線程數量和線程調度的優先級,可以調整分配給計算任務的CPU資源

  • 內存
    可以從JVM入手
    -Xms,Xmx 配置堆得大小
    -Xss 配置線程得棧
    配置GC策略,G1,CMS
    編碼時候即使釋放無用對象,避免內存泄漏,設置緩存TTL

  • 網絡IO
    異步和批次操作

  • 硬盤IO
    異步和批次操作
    使用緩存
    用SSD

2.算法

3.併發和竟態

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章