本案例發生在別人身上,覺得有學習借鑑的意義特轉載過來記錄一下。
PM 說有一個類似於搶購的小需求,我們第一反應就想到是典型的防止庫存超賣場景,於是理所因當地選用了 Redis 方案。只要保證是原子操作,即可防止庫存超賣,自然想到使用 Incr/Decr 這類原子操作。
查看 PHP 的 Redis 擴展關於 Incr 方法的說明:
/**
* Increment the number stored at key by one.
*
* @param string $key
* @return int the new value
* @link http://redis.io/commands/incr
*
*/
public function incr( $key ) {}
可見,Incr 方法返回的是 key 操作後的新值,即 ++1 後的值,於是我們寫出瞭如下代碼:
$num = $redis->incr($key);
if ($num < $max) {
//入搶購成功隊列,異步去執行搶購成功邏輯
} else {
//不好意思呢,已經被搶完了
}
不知道你有沒有聞到這段代碼的壞味道,在大部分情況下會如你所想地運行,但是特殊場景下會 出現判斷失效 的邏輯問題,例如:
1、key 由於某些原因失效了;
2、Incr 操作失敗了,不會拋異常並返回 false;
上述兩種情況,都會導致$num < $max條件成立,進而導致更嚴重的邏輯問題,最終超賣。
問題描述與分析
我們就搶購開始後就遇到了上述的第二種情況,下面描述整個過程。先通過 Cat 監控平臺觀察到訪問量急劇上升,開始擔心應用服務坑不住,隨後日誌平臺報警 Incr 操作存在異常機率,再然後就出現超賣情況,緊急情況只能關閉業務開關。是什麼原因導致判斷條件成立?
通過日誌定位到 Incr 操作問題,便 Telnet 連接到線上 Redis 服務,發現了異常情況:
\# 查看值
GET key
100
# 嘗試修改
INCR key
READONLY You can't write against a read only slave
INFO
# Replication
role:slave
可以看出來,該連接的機器目前處於從機狀態,不可寫操作,所以 Incr 操作返回 false,同時 PHP 不同類型比較會存在隱式轉化,所以false < $num恆成立,導致計數器失效。而這一切又是由於 Redis 高可用不完善,當主從切換後,VIP 未能成功漂移,這部分是運維的鍋,研發代碼不夠健壯,這鍋同樣要背 >﹏<。
優化方案
首先,修改代碼使其更加健壯,增加計數器容錯處理:
$num = $redis->incr($key);
if ($num > 0 && $num < $max) {
//入搶購成功隊列,異步去執行搶購成功邏輯
} else {
//不好意思呢,已經被搶完了
}
然後,切換 Redis 源到高可用集羣(Codis),測試並重新上線,第二日的搶購已經正常,看着 Cat 上流量逐漸平穩,心裏也踏實了。