最近一直有在做一些搶購,限流方面的工作,這兩週就打算寫 業務中的鎖與php、golang 實踐方面的東西。
實踐一 基於 Redis 鎖的限流操作
針對於 傳送驗證碼場景
- 要求是 單個手機號碼 60秒內,只允許傳送一次,來防止簡訊介面被無限次的惡意呼叫
先看看 一般邏輯寫法
測試相關工具
- 測試框架為 Hyperf2.0 + php 7.2
- mysql 連結池 10-100
- redis 連結池 10-100
- 併發測試 工具為 Apache JMter 5.2.1
- 測試環境 為 contOS 7.7
- MySQL 5.6
- 比如有一張 send_records 表
id
code
mobile
created_at
updated_at
欄位
不加鎖 DB 插入判定
public function testSend()
{
$mobile = $this->request->input('mobile', '13711111111');
$sendModel = DB::table('send_records');
$ok = $sendModel->where('mobile', $mobile)->where('created_at', '>', date('Y-m-d H:i:s', time() - 60))->exists();
// 存在則說明60秒內已經傳送
if ($ok) {
return ['data' => [], 'msg' => 'send fail'];
}
$code = mt_rand(1000, 9999);
$sendModel->insert([
'mobile' => $mobile,
'code' => $code,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
//send msg doing
//發簡訊 一些耗時操作
for ($i = 0; $i < 10000000; $i++) {
//....
}
return ['data' => [], 'msg' => 'ok'];
}
採用 JMter 100 執行緒 1秒內 請求一次的 方式進行壓測 ,
結果不出所料,在高併發下,資料庫出現了重複的資料
根據程式碼 邏輯來看, 就是在 第一步判定與第二步插入資料之間,出現了併發的請求,從而導致多個請求進入了傳送的邏輯。
那這種寫法有沒有改良做法呢?
有呀, 那就是利用鎖的機制 !
先看看改動最小的 MySQL 的行鎖
加 MySQL行鎖 再進行判定
public function testSendWithDBLock()
{
$mobile = $this->request->input('mobile', '13711111111');
Db::beginTransaction();
$sendModel = DB::table('send_records');
$ok = $sendModel->where('mobile', $mobile)
->where('created_at', '>', date('Y-m-d H:i:s', time() - 60))
->lockForUpdate()->first();
if ($ok) {
Db::rollBack();
return ['data' => [], 'msg' => 'send fail'];
}
$code = mt_rand(1000, 9999);
$sendModel->insert([
'mobile' => $mobile,
'code' => $code,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
Db::commit();
//send msg doing
//發簡訊 一些耗時操作
for ($i = 0; $i < 10000000; $i++) {
//....
}
return ['data' => [], 'msg' => 'ok'];
}
上述程式碼加入了MySQL 行鎖,成功解決了併發問題!
那麼這種寫法有沒有問題呢?
優點是程式碼改動下,也很清晰,但是這種寫法的缺點也很明顯
一個是依賴MySQL的鎖機制,在高併發時這會消耗MySQL大量的資源,而MySQL作為底層儲存,一旦掛掉勢必會影響到其它業務,當鎖住的表為核心表時,那就更加會是整個系統的瓶頸了
第二個是,在使用 行鎖後,介面的TPS 出現了 50% 以上的跌幅
所以在預期有高併發時候,不推薦這種寫法。
我們驗證了鎖的好處,那麼就是想辦法讓 Redis 去模擬鎖就好了,高效能的Redis 正好可以來滿足效能與降低DB負載兩方面的要求
Redis 模擬鎖的錯誤寫法
public function testSendByRedisLock()
{
$mobile = $this->request->input('mobile', '13711111111');
$redis = \Hyperf\Utils\ApplicationContext::getContainer()->get(\Hyperf\Redis\Redis::class);
$existKey = "serve:mobile:{$mobile}";
if ($redis->exists($existKey)) {
return ['data' => [], 'msg' => 'send fail'];
}
if (!$redis->setex($existKey, 60, "ok")) {
return ['data' => [], 'msg' => 'send fail'];
}
$code = mt_rand(1000, 9999);
DB::table('send_records')->insert([
'mobile' => $mobile,
'code' => $code,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
//send msg doing
//發簡訊 一些耗時操作
for ($i = 0; $i < 10000000; $i++) {
//....
}
return ['data' => [], 'msg' => 'ok'];
}
這個寫法雖然併發上去了,但是會出現跟未使用DB 鎖時候的一樣,未能成功阻止併發的請求,因為 exist 與 setnx 兩個操作 未能合併成原子的寫法。
redis 在 2.6.12 版本 對 set 進行了優化,增加了 EX
, PX
, NX
與 XX
可選項來處理 這種情況
Redis 模擬鎖的改良寫法
$existKey = "serve:mobile:{$mobile}";
# 原來寫法
if ($redis->exists($existKey)) {
return ['data' => [], 'msg' => 'send fail'];
}
if (!$redis->setex($existKey, 60, "ok")) {
return ['data' => [], 'msg' => 'send fail'];
}
# 改良寫法
if (!$redis->set($existKey, "ok", ['NX', 'EX' => 60])) {
return ['data' => [], 'msg' => 'send fail'];
}
自此 就使用 redis 就完美的模擬了鎖的機制。至少能最大限度的解決我們最開始提出來的問題。
但是 上述寫法還存在一些問題,如超時問題,還有 Redis 叢集間的鎖同步問題
實踐二 基於 Redis 鎖的 亂序轉順序執行操作
超時問題
問題描述為 : 如果一次只能有一個客戶端去處理,上個客戶端處理完成,下個客戶端才能進來
模擬程式碼如下
$redis = \Hyperf\Utils\ApplicationContext::getContainer()->get(\Hyperf\Redis\Redis::class);
$existKey = "serve:mobile";
if (!$redis->set($existKey, "ok", ['NX', 'EX' => 2])) {
return ['data' => [], 'msg' => 'send fail'];
}
DB::table('send_records')->insert([
'mobile' => $mobile,
'code' => time(),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
//doing something 大約執行3秒
for ($i = 0; $i < 100000000; $i++) {
//....
}
// down
$redis->del($existKey);
那如果使用上述的Redis鎖,一個客戶的處理 完成後會去解鎖,因為使用的是同一個Key,如果A客戶進入超時那麼就會解掉B的?
這樣無限套下去鎖就失效了,同時可能會存在多個程式/執行緒/協程 同時在處理。
我將壓測時間延長,就得到了上圖,349 的鎖 被 348 給解了 所以 出現了 349 跟 351 同時執行的問題。
所以對於應用來說,解鎖應該判斷是否是當初自己設定的鎖,通過設定的唯一值來判定
如:
$redis = \Hyperf\Utils\ApplicationContext::getContainer()->get(\Hyperf\Redis\Redis::class);
$existKey = "serve:mobile";
$value = \Hyperf\Utils\Coroutine::id() . mt_rand(1000, 9999);
if (!$redis->set($existKey, $value, ['NX', 'EX' => 2])) {
return ['data' => [], 'msg' => 'send fail'];
}
DB::table('send_records')->insert([
'mobile' => $mobile,
'code' => time(),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
//doing something 大約執行3秒
for ($i = 0; $i < 100000000; $i++) {
//....
}
// down
$script = 'if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end';
$redis->eval($script, $existKey, $value);
這裡用到了 lua 指令碼 來保證原子性, 通過協程號來生成唯一值(這個也不是唯一的,只是相對來說,比較安全,分散式部署的需要加入機器的mac地址之類的唯一編號來做進一步區分),但是這種操作也只是相對安全,當 A超時,A、B 客戶端同時執行時,其實就不是序列化執行了,需要評估對於業務的影響。
Redis 叢集與 Redlock 問題
上面我們討論了超時問題,那麼最後一個問題就是,當 Redis 不是單機而是叢集時,上述的鎖機制就可能因為同步機制的不穩定而導致 出現多臺Redis 上 存在 不同的?的情況。針對於 這種情況,Redis 作者提出了 Redlock 演算法來保證叢集的一致性。
當前有N個完全獨立的 Redis master 節點, 分別部署在不同的主機上,客戶端設定鎖的操作:
- 使用相同key和唯一值同時向這N個 Redis 節點請求鎖, 鎖的超時時間應該 遠大於 整個請求的耗時時間
- 計算步驟1消耗的時間, 若總消耗時間超過超時時間, 則認為鎖失敗. 客戶端需在大多數(超過一半)的節點上成功獲取鎖, 才認為是鎖成功
- 如果鎖成功了, 則該鎖有效時間 = 鎖原始有效時間 - 步驟1消耗的時間
- 如果鎖失敗了(超時或無法獲取超過一半例項的鎖), 客戶端會到每個之前加鎖失敗節點釋放鎖
當然這個演算法也有很大的爭議,分散式設計比起單機來說,都存在一定的缺陷,就目前來說單機的Redis 鎖已經能滿足業務的需求,當單機 Redis 扛不住的時候,再考慮 其它的分散式?。
本來以為寫起來應該很快,但實際上草稿都快寫了半天,這大概就是關於 Redis 鎖的相關操作,下次我們可以聊一下基於ETCD的分散式鎖 跟鎖的演算法相關的問題。
本作品採用《CC 協議》,轉載必須註明作者和本文連結