深入理解高併發下的MySQL與Redis快取一致性問題(增刪改查資料快取的一致性、Canal、分散式系統CAP定理、BASE理論、強、弱一致性、順序、線性、因果、最終一致性)

小松聊PHP进阶發表於2024-03-20

前置概念

無併發的解決方案

一些小型專案,或極少有併發的專案,這些策略在無併發情況下,不會有什麼問題。

  • 讀資料策略:有快取則讀快取,然後介面返回。沒有快取,查詢出資料,載入快取,然後介面返回。
  • 寫資料策略:資料發生了變動,先刪除快取,再更新資料,等下次讀取的時候載入快取,或一步到位更新資料後直接更新快取。
  • 以上這種方案,有個高大上的名字,叫Cache Aside Pattern。

併發情況下的分散式快取一致性問題

  • 併發:無論是Java的多執行緒,還是PHP的多程序(預設的單執行緒),使用者量或請求量一上來,就可能有併發問題,正確的應對併發,保證資料不出錯,顯得尤為重要。
  • 快取:任何元件(不僅是Redis與MySQL),只要源資料並非一成不變,且有快取機制,就會有一致性的問題。
  • 單體架構:強一致性若在單機上,並不是一個問題,例如MySQL的非冗餘欄位的變動,關聯另一個冗餘它的欄位,這等強一致性的快取問題,在一個事務裡就能維護。
  • 分散式:一致性的難點主要在分散式環境下,例如MySQL主從,就新增了bin log與redo log的兩階段提交策略來防止主從資料不一致。針對MySQL與Redis,可以理解成分散式系統,如果沒有併發,則按照文章開頭的方案正常處理,如果有併發,就需要一些策略保證讀取的是最新的快取資料,因為目前沒有一些機制,讓MySQL和Redis共同在一個事務內,能一起提交或者回滾,保持強一致。
  • 注意:快取一致性問題是個機率問題,不是一定出現或一定不出現,併發情況下,如果不加鎖,MySQL與Redis讀寫時序是不可控的。

併發與並行,同步與非同步

經常聽到併發,可真的理解這個概念嗎?

  • 併發:多過分任務同時進行,但這些任務是交替執行(分配不同的時間片,程序或者執行緒的上下文切換),好比排n個隊去1個視窗辦事。
  • 並行:多個任務在同一時刻同時執行,通常需要多個或多核處理器,好比排n個隊去n個視窗辦事。
  • 同步:上個任務執行完畢後再執行下一個任務,所以同步沒有併發或並行的概念。
  • 非同步:下一個任務不用等待上個任務執行完。

分散式必知的CAP定理

  • 一致性(Consistency):每個節點的資料需要與源資料保持一致,這裡往往指的是強一致性。
  • 可用性(Availability):這指的是系統能夠在任何時刻都對外提供服務,所謂的穩定高可用。
  • 分割槽容錯性(Partition Tolerance):分散式系統下,某節點掛掉,還能夠對外提供服務的能力。

為什麼CAP只能3選2

  • CP捨去A:意味著分散式環境保證強一致,一個資料的變動必須及時通知所有的節點,每個節點為了保證強一致,不得不加鎖,此時一個併發過來請求上鎖的資料,不是失敗就是被阻塞,高可用就達不到。常見於銀行,金融,支付系統。

  • AP捨去C:意味著分散式系統捨棄掉一致性,一個資料的變動必須不一定會通知所有的節點,每個節點也不一定加鎖,所以就不會出現阻塞或者失敗的情況。常見的DNS、CDN系統就是這樣,修改源資料不會立即生效,儘管短時間讀取還是老資料,但是它不會因為你修改就加鎖或者阻塞,它還讓你用。

  • CA捨去P:意味著分散式系統下不具有分割槽容錯性,但是是個分散式就有小機率會出問題,儘管很低,想杜絕分割槽容錯性,只能是單體架構。常見於單機系統。

  • 架構的設計是根據當前業務特性權衡而來的結果。一個兜底的策略,要看當前業務不能接受哪些缺點,而不是看有哪些優點,然後去一步步演進,改善它。

ACID中的C與CAP中的C

不是一個概念。

  • ACID中的C:是事務內的資料,從一個合法狀態轉換到另一個合法的狀態(這裡的合法,指符合業務,符合事務的變化規律,A轉賬B 50元,雙方餘額加減的過程,數只能是50,不會是60)。
  • CAP中的C,快取的資料需要與源資料保持一致。

分散式必知的BASE理論

可以理解為CAP的寬鬆方案,透過犧牲資料的強一致性,來獲得高可用性。
BASE理論最經典的場景,就是支付回撥,支付狀態允許存在幾秒鐘的延遲,而不是支付後實時獲取。

  • 基本可用性(Basic Availability):允許系統中的某些部分出現故障,保證核心功能的正常執行。常見的負載均衡、服務降級、限流等方式。

  • 軟狀態(Soft state):允許資料存在中間狀態,或者說是遊離態,允許資料在某些時刻不一致,但是最終達到一致性的狀態。支付回撥前支付狀態的場景。

  • 最終一致性(Eventually Consistency):系統中的資料在經過一段時間後,最終會達到一致的狀態。不需要強制性的實時保持一致,只需要最後保持一致性。支付回撥後支付狀態的場景。

強一致性、弱一致性、順序一致性、線性一致性、因果一致性、最終一致性

  • 強一致性:等價嚴格、原子、線性一致,所有節點操作順序都與全域性時鐘下的幾乎一致,需加鎖,常見於金融、銀行場景。
  • 弱一致性:能容忍資料短時間內不一致,或能容忍部分資料不一致。常見於CDN和DNS場景。
  • 順序一致性:強一致,在不同的節點上保持一致的操作執行順序,需要加鎖。常見於分散式佇列場景。
  • 最終一致性:弱一致,最終一致性就屬於弱一致性,概念相似。常見於支付回撥,離線下載,非同步同步大檔案的場景。
  • 線性一致性:強一致,強一致性、嚴格一致性、原子一致性一回事,同上。
  • 因果一致性:弱一致,屬於事件觸發型別,因為觸發,果為執行,常見於MySQL非同步主從(弱一致)、分散式評論系統。

詳解順序一致性:
假設有兩個節點,在一個分散式系統中執行寫操作。如果 節點A 在時間點 1 執行了寫操作 W1,然後 節點B 在時間點 2 執行了寫操作 W2。那麼順序一致性要求在分散式系統的其它節點上,讀取資料的時候:
應該先看到 W1 的效果,然後才能看到 W2 的效果,而不是先看到W2再看到W1的結果。
並且保證,再同一時間,不能出現節點1看到 W1 的效果,節點2看到 W2 的效果。

全域性時鐘

分散式下的全域性時鐘,指的是分散式的每個節點,都有著一致的時間基準,就像共用一個時鐘一樣,讓每個節點在一致的時間線上處理各自的資料,這個非常重要。

實操

增資料後,保證Redis讀取的是最新的資料

不存在一致性問題。
請求A新增資料時,有併發讀請求B,此時B是查不到快取的的,就會查詢MySQL。
如果B查到資料就載入快取。
如果B沒查到資料,就等後面的請求查詢到資料後再載入快取。
無論B能否查詢到快取,都不影響A的插入,或C的讀取,因此不影響資料一致性。

刪資料後,保證Redis讀取的是最新的資料

  1. 情況1:不存在一致性問題,快取中沒有這塊資料,刪除MySQL資料後沒有其它副本。
  2. 情況2:存在一致性問題,快取中有這塊資料,此時就有2種策略:
  • 先刪除快取再刪除資料:
    不行。如果請求A快取刪除成功,此時一個過來一個讀請求B,會查詢到即將要刪掉的MySQL資料,並將其重新載入快取,請求A執行MySQL delete後,會造成MySQL無資料,Redis有資料的情況,快取不一致。

  • 先刪除資料再刪除快取:
    也有問題。請求A發現快取資料不存在,讀取了MySQL資料,此時請求B刪除MySQL資料,接著請求B刪除快取資料,請求A將老資料寫入快取。此時資料庫裡沒資料,Redis裡有資料,快取不一致。
    併發情況下,沒辦法控制執行順序問題,所以這就是個機率問題。

改資料後,保證Redis讀取的是最新的資料

  1. 情況1:不存在一致性問題,快取中沒有這塊資料,更新MySQL資料後沒有其它副本。
  2. 情況2:存在一致性問題,快取中有這塊資料,此時就有5種策略:
  • 先更新資料再更新快取:
    不行。v初始值為0,更新請求A,將值改為1,此時過來更新請求B,將值改為2,確定MySQL最終的值是2。但是受redis網路連線卡頓等影響,更新請求B先將快取中的v值改為2,更新請求A再將快取中的值改為1。此時MySQL的值為2,快取中的值為1,快取不一致。

  • 先更新快取再更新資料:
    不行。v初始值為0,更新請求A修改快取資料,v值改為1,然後更新MySQL,此時MySQL更新失敗,或事務回滾,雖然後續的讀快取的是新資料,資料庫的是老資料,但這種髒資料再某些場景下是不允許發生的,快取不一致。如果非要使用,可以再更新完快取後,透過訊息中介軟體非同步更新資料庫。

  • 先更新資料再刪除快取:
    不行。v初始值為0,更新請求A將v值改為1,更新請求B將v值改為2,更新請求A刪除快取,更新請求B刪除快取,然後A、B都將值寫入MySQL。此時查詢請求C過來,獲取的可以使最新的資料並載入快取。
    另一種情況:
    v初始值為0,更新請求A將v值改為1,更新請求B將v值改為2,更新請求A刪除快取,更新請求B刪除快取,然後更新請求A將1寫入MySQL,此時查詢請求C過來,沒有快取,查MySQL發現是1,載入快取。更新請求B將MySQL值改為2,此時快取不一致。
    另一種情況:
    v初始值為0,讀請求X查詢不到快取的資料,於是讀MySQL,獲取值為0,此時過來一個更新請求Y,將MySQL中的0改為1,然後刪除快取,請求X又將老資料0載入快取。
    此時MySQL值為1,Redis值為0,快取不一致。

  • 先刪除快取再更新資料:
    不行。v初始值為0,更新請求A先刪除快取,此時過來一個讀請求B,發現沒有快取,讀取MySQL,獲取值為0,此時更新請求A將MySQL的0改為1,讀請求B將Redis v的值改為0,快取不一致。

  • 延時雙刪:
    可以。是最終一致性的方案。
    延遲:更新MySQL後,隔一段時間再刪除快取,一般間隔0.3-~1.5秒左右,略大於一個讀請求週期的耗時即可。
    雙刪:更新資料庫的前後都刪除一遍快取。
    v初始值為0,更新請求A先刪除快取,此時讀請求B過來,發現沒有快取,去查MySQL後載入快取,然後更新請求A更新MySQL更新v值為1,再等一段時間,再次刪除快取。此時讀請求B查詢的是0(為了保證全域性的最終一致性,只能犧牲查詢請求B),更新請求A中,MySQL和快取資料一致,都是1。
    延遲雙刪的延遲,是為了保證查詢請求B走完流程,如果刪除的早,更新請求A先走完流程,那還是會被讀請求B的將老資料載入快取。
    延時可透過用Laravel queue或者其它訊息中介軟體去實現。
    那如何保證,第二次刪除成功呢?
    新增重試機制,如果刪除失敗,可再刪除3次。

查資料後,保證Redis讀取的是最新的資料

不存在一致性問題。
資料沒有寫操作。

小結

可見若資料發生了變動,無論以上方案怎麼搞,都可能會有不一致的情況。
即使是延遲雙刪,也會增加運維成本,多了一些工序,它們的高可用又是一類問題。

如果有MySQL+Redis的鎖機制,那麼其它請求就會阻塞,效能就下降。反過來就影響一致性,這也是CAP三選二的體現。

換個角度講,對於快取一致性問題,刪除快取,比更新快取相對可靠。

  • 如果用更新快取策略:兩個更新的併發請求,更新MySQL的順序是一種順序,受網路波動和卡機的影響,更新快取可能又一種順序,這可能導致快取與MySQL值不一致,快取內部的可能是個錯值。
  • 如果用刪除快取策略:兩個更新的併發請求,更新MySQL的順序是一種順序,受網路波動和卡機的影響,快取也是被刪除。最多其它讀請求把舊值又給快取了進去,但至少是個舊值,而不是個錯值。

簡單的兜底策略

加上快取過期時間。避免MySQL與快取長期不一致,對實時性要求越高,則快取過期時間越少。
一些粒度更細的自定義儲存方式,用不了Redis對key的自動過期功能,可新增時間戳欄位,用程式邏輯控制過期。

Canal元件的策略

  • 官網:https://github.com/alibaba/canal
  • 簡介:Canal是用於解決快取一致性問題的元件。由阿里巴巴開源,Java編寫的C/S架構的軟體。它的服務端可以偽裝成MySQL從機,實時捕獲 MySQL 主機的bin log,並將變更事件推送到訊息佇列或者其它儲存中,以實現實時資料同步、對資料倉儲的實時分析等應用場景。
  • 客戶端支援:支援Java、C#、Go、PHP、Python、Rust、NodeJs客戶端。
  • 支援同步Kafka、ElasticSearch、HBase、RocketMQ、RabbitMQ、pulsarMQ、不支援直連Redis。
  • 前置知識:一文讀懂MySQL7大日誌(slow、redo、undo、bin、relay、general、error)簡單搭建MySQL主從複製
  • Linux環境,MySQL主機配置
可參考https://github.com/alibaba/canal/wiki/QuickStart

vim /etc/my.cnf
在[mysqld]下寫入以下配置
server-id=180     //主機標識,得有一個唯一編號
log-bin=mysql-bin //bin log日誌名
binlog_format=row //注意這裡一定要用row,用statement或mixed,canal將無法解析
binlog-do-db=test //資料庫名


service mysql restart 儲存後重啟

確認bin log是否開啟
select @@sql_log_bin;
+---------------+
| @@sql_log_bin |
+---------------+
|             1 |
+---------------+


登入mysql命令列
CREATE USER canal IDENTIFIED BY 'canal';  
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO '從機使用者名稱'@'%';
alter user 'canal'@'%' identified with mysql_native_password by '從機密碼,這裡設定成canal';
FLUSH PRIVILEGES;
  • Linux環境,Canal配置
不管是不是Java開發者,不需要安裝JDK或者JRE
mkdir /usr/local/cancl
cd /usr/local/cancl
wget https://github.com/alibaba/canal/releases/download/canal-1.1.7/canal.deployer-1.1.7.tar.gz
tar zxf canal.deployer-1.1.7.tar.gz

修改配置檔案
vim /usr/local/canal/conf/example/instance.properties
canal.instance.mysql.slaveId=1 //去掉註釋,並修改為非主庫server-id的資料
canal.instance.master.address=127.0.0.1:3306 //主庫的IP:Port
canal.instance.dbUsername=從機使用者名稱
canal.instance.dbPassword=從機密碼


啟動,改配置後記得重啟。
/usr/local/canal/bin/startup.sh


檢視
ps aux | grep canal
  • Java或其它語言配置canal客戶端:https://github.com/alibaba/canal
  • PHP canal客戶端配置:
git clone https://github.com/xingwenge/canal-php.git
cd canal-php
composer install
php src/sample/client.php
只要沒提示Socket error: Connection refused (SOCKET_ECONNREFUSED),就說明連線成功。

示例程式碼如下:
需要注意兩個地方
$client->connect("127.0.0.1", 11111);
引數1的值是canal server的ip。
引數2的值是/usr/local/canal/conf/canal.properties檔案中的canal.port項,遠端連線記得要開放埠。
$client->subscribe("1", "example", ".*\\..*");
引數1是/usr/local/canal/conf/example/instance.properties檔案的canal.instance.mysql.slaveId項。
引數2是/usr/local/canal/conf/example,example的目錄名,一般不動他,可以配置多個。
引數3有個預設值,排除某個庫的某個表。

try {
    $client = CanalConnectorFactory::createClient(CanalClient::TYPE_SOCKET_CLUE);
    # $client = CanalConnectorFactory::createClient(CanalClient::TYPE_SWOOLE);

    $client->connect("127.0.0.1", 11111);
    $client->subscribe("1", "example", ".*\\..*");
    # $client->subscribe("1001", "example", "db_name.tb_name"); # 設定過濾

    while (true) {
        $message = $client->get(100);
        if ($entries = $message->getEntries()) {
            foreach ($entries as $entry) {
                Fmt::println($entry);
            }
        }
        sleep(1);
    }   

    $client->disConnect();
} catch (\Exception $e) {
    echo $e->getMessage(), PHP_EOL;
}


//當出現以下字樣時,說明聯調成功。
================> binlog[mysql-bin.000044 : 3130],name[test,cs], eventType: 2
-------> before
id : 1  update= false
num : 6  update= false
-------> after
id : 1  update= false
num : 7  update= true

效能:
官方給出的消費速度:sql insert 10000 事件,32秒消耗完成。消費速度 312.5 條/s。
實測消費速度遠高於官方的消費速度,1C1G的本地搭建的伺服器:
表中共10000條,不加where全部更新,全部同步耗時2.5秒。
實測平均4000/s的消費速度,意味著每秒有略低於4000個redis key被修改,配置更高的伺服器效能將會更好。
這速消費度,大部分的後端介面qps都趕不上,所以足以應對99%的業務場景。
  • 對於PHP cancel二次修改,整合到Laravel框架的思路
這種不怎麼參與業務,可以整合到框架,也可以不整合,在伺服器上單獨放一個目錄去,cli模式下直接跑也行。

方案1,粗略的整理:將這個包放進laravel的app/Libs中,所有目錄結構均不改動,跟框架邏輯無關,僅僅變動跟隨Git同步。
二開,redis的連線引數可以硬編碼,也可以讀取.env的配置,正則匹配獲取,要寫在while(true)的外面(指的是cancal-php/src/sample/client.php中的while(true))。

方案2,細緻的整理:因為目前專案用不上,所以暫時不準備實操,但是思路得有,示例:
composer中的配置,需要整合到框架的composer中,
    "require": {
        "google/protobuf": "^3.8",
        "php":  ">=5.6",
        "clue/socket-raw": "^1.4"
    },
    "autoload": {
        "psr-4": {
            "Com\\Alibaba\\Otter\\Canal\\Protocol\\": "src/protocol/Com/Alibaba/Otter/Canal/Protocol/",
            "GPBMetadata\\": "src/protocol/GPBMetadata/",
            "xingwenge\\canal_php\\": "src/"
        }
    }
cancal-php/src/sample/client.php的檔案也就不到40行。
這塊邏輯可以放到框架的app/Libs下,也可以放到app\Console\Commands,用php artisan xxx命令去執行。
不是依賴包內的其它檔案,放在app/Libs/cancal目錄下。

核心邏輯介面再src/Fmt.php檔案的printLn方法中提供了的可用引數,
有資料庫名、表名、操作主鍵、更改前的值、更改後的值、以及DML動作型別,可以根據這個二開,根據自定義的命名規則,配置自定義redis的動作。

相關文章