前言:
在實際開發項目中,產品一旦推廣開來,總能遇到一些小問題。比如某個接口突然就請求崩掉了,某個提交接口明明做了限制爲什麼就多出了好多重複的記錄。還有是某個記錄超過限制進行修改了,以下就以這幾個小問題總結一下平時採取的解決方法。
場景:
1. 緩存失效場景,就比如某個接口做了數據緩存,緩存過期導致突然某個時刻大量請求直接讀數據庫。解決方法設置redis緩存回調事件,訂閱失效頻道。所以這個也可以用來處理某些業務場景到期處理方式。
2. 接口冪等性場景,就比如註冊接口,通過手機號查詢是否存在記錄。但有時出現網絡延遲用戶連點等情況,會出現數據庫出現幾條一樣的用戶數據記錄。
3. 商品庫存超賣場景,比如某個活動商品下單,多個用戶同時下一個商品的訂單,從而導致庫存超賣的現象。解決方法可以使用樂觀鎖或者悲觀鎖解決此問題。
場景一,緩存失效回調。
1. 設置Redis回調事件方法。
(1). 打開Redis客戶終端,輸入命令
非持久性的回調事件設置
config set notify-keyspace-events Ex
(2). windows平臺打開Redis安裝目錄中找到"redis.windows-service.conf",然後打開編輯找到notify-keyspace-events那一行,去掉"#",改爲notify-keyspace-events “Ex"。
(3). 其中Redis還可以設置訂閱鍵名的回調,比如訂閱某個鍵名的del操作等,可以在conf中設置不同的,方法網上也有的。
2. 訂閱redis某個庫的鍵失效的頻道名,可以在命令測試,也可以通過PHP代碼訂閱然後cli環境下運行腳本。
命令:
subscribe __keyevent@0__:expired
3. 重新打開一個新的redis客戶終端輸入一個帶有效期的鍵值對,如下
(鍵名test_key_name, 時間30s, 值ceshi)命令:
setex test_key_name 30 ceshi
4. 查看鍵失效回調訂閱的命令窗口是否出現失效的鍵名。
5. 代碼實現鍵名的失效事件訂閱。
<?php
//設置php腳本執行時間
set_time_limit(0);
//設置socket連接超時時間
ini_set('default_socket_timeout', -1);
class redisSubscribe
{
protected $config = [
"host" => "127.0.0.1",
"password" => "6379"
];
protected $redis;
public function __construct()
{
try {
$this->redis = new \Redis();
$this->redis->pconnect($this->config['host'],$this->config['password']);
} catch(\Exception $e) {
echo "redis錯誤:".$e->getMessage().PHP_EOL;
}
}
// 普通消息訂閱
public function normal()
{
//聲明頻道名稱
$channelName = "test";
try {
$this->redis->subscribe([$channelName], function ($redis, $channel, $msg)
{
echo 'channel:' . $channel . ',message:' . $msg . PHP_EOL;
file_put_contents('subscribe.log',"\n-".$msg."-\n",FILE_APPEND);
});
} catch (\Exception $e) {
echo $e->getMessage();
}
}
// 訂閱Key失效事件的頻道
public function keyNotify()
{
echo "wathc keyNotify start~~".PHP_EOL;
// Key事件回調
//$channel = "__keyevent@0__:expired"; // 0號庫的Key過期事件頻道名
$channel = "__keyevent@*__:expired"; // 所有庫的Key過期事件頻道名
try {
$this->redis->subscribe([$channel], function ($redis, $channel, $msg)
{
echo 'channel:' . $channel . '===========' . ',message:' . $msg . PHP_EOL;
file_put_contents('subscribe.log',"\n-".$msg."-\n",FILE_APPEND);
});
} catch (\Exception $e) {
echo $e->getMessage();
}
}
}
(new redisSubscribe())->keyNotify();
?>
6. 通過PHP-cli運行該腳本,然後也可以setex一個短時間的鍵,然後查看命令是否輸出該失效的鍵名。
7. 實際項目中的緩存失效的應用就可以展開了。
(1). 代碼中設置的所有鍵名都配置到項目的全局配置文件中。
(2). 服務器中開一個守護進程(持續運行訂閱某個庫或者所有庫的鍵失效回調事件腳本)。
(3). 當該腳本有回調時,取出鍵名去全局緩存鍵名數組中匹配。
(4). 規則業務可以自行設計。
(5). 比如取出一個"cate5"的鍵名,則可以取資訊表中查詢分類ID爲5的所有數據然後再進行緩存。
(6). 緩存失效事件還一個高端玩法,就是取代某些定時任務。比如可以將某個訂單作爲鍵名緩存,當該鍵名失效就可以取出鍵名拿到ID去數據庫中將訂單狀態修改爲失效。
場景二,接口冪等性。
接口重複數據也就是在高併發下的數據添加場景。最典型的是註冊接口,用戶在網絡延遲大或者信號不穩定的情況下。並且同時大量用戶在進行註冊操作,用戶點擊了一次沒反應然後再次點擊多個。
在沒有做冪等性處理只是拿到手機號查詢數據庫是否存在,用戶表又沒分庫分表,查詢緩慢,查詢出來後,多條併發的請求都繞過了手機號已經存在的條件判斷,所以就出現了ID不同,但是其他字段一樣的記錄。
1. 對於高併發數據添加,可以使用Redis的setnx。
2. setnx是設置鍵並且在有效期內有值時,再次對該鍵名進行重複賦值無法進行,會返回0。
3. 可以代碼在對某些條件查詢是否存在時,可以將條件組成鍵名賦值。添加記錄時再次對鍵名重新賦值,返回null則表示已經存在。
4. 以下代碼是項目中的一個測試方法,使用的redis是封裝的,借鑑需要修改。
/**
* @Notes: 高併發防止重複提交(插入數據) 【保證接口的冪等性】
* @Interface preventRepeatSubmit
* @return mixed
* @author: bqs
* @Time: 2020/6/19 14:56
*/
public function preventRepeatSubmit()
{
/* 比如查詢某條(什麼條件)記錄是否存在,分佈式鎖機制[redis的原子性setnx]
* 1. 通過條件拼接爲唯一的鍵名,將鍵名setnx設置一個30s有效期的值
* 2. setnx設置鍵名不成功(返回0)表示已經存在,接口則直接返回記錄已經存在
* 3. 根據該條件查詢數據庫記錄,如果存在,接口再返回記錄已經存在
* 【只要添加記錄前需要查詢什麼是否存在則都需要考慮高併發情況,則通過此方案】
*/
$redis = Redis::db(0);
$no = date('YmdHis',time()).mt_rand(1000,9999);
//$no = 202006191537447811;
// 是否添加鎖表
$addLock = false;
if ($redis->setnx($no,1)) {
$redis->expire($no,30); //設置30s過期時間
} else {
$addLock = true; // 訂單已經存在則鎖住
}
// 數據庫查詢是否存在
$isExist = Db::name('ztest')->where(['no'=>$no])->find();
if ($isExist) {
$addLock = true;
}
if (!$addLock) {
$data = [
"no" => $no,
"tab_num" => 2,
"stock" => 20,
"create_time" => time()
];
$res = Db::name('ztest')->insertGetId($data);
}
return "添加數據成功";
}
場景三,庫存超賣。
庫存超賣是一個很常見的秒殺或者其他高併發場景下的數據更新問題。網絡上的解決方法也是多種多樣,對該問題延伸的數據庫樂觀鎖,悲觀鎖的知識點也是數不勝數。
所以,這裏我也不再介紹數據庫的存儲引擎機制,事務,表鎖等概念。直接以代碼展現,以下是以樂觀鎖實現的數據庫更新問題。
1. 高併發下,對單條記錄的修改。一般修改前會對某字段進行判斷,但是併發情況下,拿查詢的結果進行攔截是極其的不靠譜。不過也可以對查詢進行加鎖,但是需要在同一事務中。
2. 庫存字段添加無符號的字段約束,所以再大的併發在修改爲0之後也不會出現負數了,在修改的操作時捕捉修改爲負數時的數據庫異常。
3. 表中添加version字段,這個也是網上盛傳的樂觀鎖經典實例了,後面的原理和流程我就不介紹了,代碼也是這樣寫的,所以直接貼代碼了。
/**
* @Notes: 高併發樂觀鎖 - (更新數據)
* @Interface testConcurrence
* @return mixed
* @author: bqs
* @Time: 2020/6/19 14:25
*/
public function testConcurrence()
{
// 開啓事務
Db::startTrans();
// 查詢ID25當前的庫存和版本號
$curr = Db::name('ztest')->field('stock,version')->where(['id'=>25])->find();
// 判斷庫存是否小於0
if ($curr && $curr['stock'] <= 0) {
throw new \Exception('物品已售罄',302);
}
try {
// 修改庫存 - 獲取ID25的行瑣
$updateRes = Db::name('ztest')->where(['id'=>25,'version'=>$curr['version']])->update(['stock'=>$curr['stock']-1,'version'=>$curr['version']+1]);
// 標識併發過來修改的,拿到的version太舊,事務回滾重新回到查詢再走一遍
if (!$updateRes) {
Db::rollback();
}
} catch(\Exception $e) {
Db::rollback();
// 記錄日誌,或者返回
}
// 事務提交
Db::commit();
return '購買成功了';
}