前置概念 無併發的解決方案 一些小型項目,或極少有併發的項目,這些策略在無併發情況下,不會有什麼問題。 讀數據策略:有緩存則讀緩存,然後介面返回。沒有緩存,查詢出數據,載入緩存,然後介面返回。 寫數據策略:數據發生了變動,先刪除緩存,再更新數據,等下次讀取的時候載入緩存,或一步到位更新數據後直接更新 ...
前置概念
無併發的解決方案
一些小型項目,或極少有併發的項目,這些策略在無併發情況下,不會有什麼問題。
- 讀數據策略:有緩存則讀緩存,然後介面返回。沒有緩存,查詢出數據,載入緩存,然後介面返回。
- 寫數據策略:數據發生了變動,先刪除緩存,再更新數據,等下次讀取的時候載入緩存,或一步到位更新數據後直接更新緩存。
- 以上這種方案,有個高大上的名字,叫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:不存在一致性問題,緩存中沒有這塊數據,刪除MySQL數據後沒有其它副本。
- 情況2:存在一致性問題,緩存中有這塊數據,此時就有2種策略:
-
先刪除緩存再刪除數據:
不行。如果請求A緩存刪除成功,此時一個過來一個讀請求B,會查詢到即將要刪掉的MySQL數據,並將其重新載入緩存,請求A執行MySQL delete後,會造成MySQL無數據,Redis有數據的情況,緩存不一致。 -
先刪除數據再刪除緩存:
也有問題。請求A發現緩存數據不存在,讀取了MySQL數據,此時請求B刪除MySQL數據,接著請求B刪除緩存數據,請求A將老數據寫入緩存。此時資料庫里沒數據,Redis里有數據,緩存不一致。
併發情況下,沒辦法控制執行順序問題,所以這就是個概率問題。
改數據後,保證Redis讀取的是最新的數據
- 情況1:不存在一致性問題,緩存中沒有這塊數據,更新MySQL數據後沒有其它副本。
- 情況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的動作。