目錄
- 基礎準備:ThinkPHP 集成 Redis
- Redis 核心數據結構(ThinkPHP 用法)
- 秒殺場景核心:Redis 原子性與事務
- ThinkPHP + Redis 實戰場景(秒殺 / 緩存 / 限流)
- 常見問題與面試避坑
一、基礎準備:ThinkPHP 集成 Redis
1.1 環境要求
- ThinkPHP 5.1+/6.0+(推薦 6.0+,緩存擴展更完善)
- PHP Redis 擴展(
php_redis.dll,需在php.ini中啓用) - Redis 服務(本地 / 服務器部署,默認端口 6379)
1.2 配置 Redis(ThinkPHP)
步驟 1:修改配置文件
在 config/cache.php 中配置 Redis 緩存驅動:
return [
// 默認緩存驅動
'default' => env('cache.driver', 'redis'),
// 緩存連接配置
'stores' => [
'redis' => [
'type' => 'redis',
'host' => env('redis.host', '127.0.0.1'),
'port' => env('redis.port', 6379),
'password' => env('redis.password', ''), // 無密碼留空
'select' => env('redis.select', 0), // 數據庫索引(0-15)
'timeout' => 0, // 超時時間
'persistent' => false, // 是否長連接
'prefix' => 'tp_seckill_', // 緩存前綴(避免鍵名衝突)
],
],
];
步驟 2:環境變量配置(.env 文件)
在項目根目錄 .env 中添加 Redis 配置(可選,優先級更高):
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_SELECT=0
步驟 3:測試連接
在 ThinkPHP 控制器中測試 Redis 是否可用:
namespace app\controller;
use think\facade\Cache;
class RedisTest
{
public function test()
{
// 寫入緩存
Cache::set('test_key', 'hello redis', 3600); // 有效期1小時
// 讀取緩存
$value = Cache::get('test_key');
echo $value; // 輸出:hello redis
// 直接操作 Redis 原生方法(獲取 Redis 句柄)
$redis = Cache::store('redis')->handler();
$redis->set('native_key', '原生方法測試');
echo $redis->get('native_key'); // 輸出:原生方法測試
}
}
二、Redis 核心數據結構(ThinkPHP 用法)
Redis 5 種核心數據結構,對應 ThinkPHP 緩存操作,重點掌握秒殺常用的 String Hash List。
2.1 String(字符串)- 秒殺庫存存儲
用途:存儲商品庫存、用户 token、計數器等
ThinkPHP 操作示例:
// 1. 設置值(庫存初始化:商品ID=1001,庫存=100)
Cache::set('seckill_stock_1001', 100, 86400);
// 2. 讀取值
$stock = Cache::get('seckill_stock_1001'); // 100
// 3. 原子自減(核心:秒殺扣庫存,天然原子性)
$remainStock = Cache::decr('seckill_stock_1001'); // 99(返回減後的值)
// 原子自增(比如統計秒殺參與人數)
Cache::incr('seckill_count_1001'); // 1
// 4. 設置過期時間(單獨設置)
Cache::expire('seckill_stock_1001', 3600); // 1小時後過期
// 5. 原生方法(比如批量設置)
$redis = Cache::store('redis')->handler();
$redis->mset([
'seckill_stock_1002' => 200,
'seckill_stock_1003' => 150
]);
2.2 Hash(哈希)- 存儲商品詳情、用户信息
用途:存儲結構化數據(比如商品信息,避免多個 String 鍵)
ThinkPHP 操作示例:
// 1. 存儲商品信息(商品ID=1001)
Cache::hSet('seckill_goods_1001', 'name', 'iPhone 14');
Cache::hSet('seckill_goods_1001', 'price', 5999);
Cache::hSet('seckill_goods_1001', 'stock', 100);
// 2. 讀取單個字段
$price = Cache::hGet('seckill_goods_1001', 'price'); // 5999
// 3. 讀取所有字段
$goodsInfo = Cache::hGetAll('seckill_goods_1001');
// 輸出:['name' => 'iPhone 14', 'price' => 5999, 'stock' => 100]
// 4. 原子自減(直接操作哈希中的庫存字段)
Cache::hDecr('seckill_goods_1001', 'stock'); // 庫存99
2.3 List(列表)- 異步隊列、秒殺訂單排隊
用途:實現異步隊列(比如秒殺成功後,異步同步訂單到 MySQL)
ThinkPHP 操作示例:
// 1. 入隊(秒殺成功後,將訂單信息加入隊列)
$orderInfo = [
'order_id' => uniqid(),
'goods_id' => 1001,
'user_id' => 10086,
'create_time' => time()
];
Cache::lpush('seckill_order_queue', json_encode($orderInfo)); // 左入隊
// 2. 出隊(消費隊列:同步訂單到 MySQL)
$orderJson = Cache::rpop('seckill_order_queue'); // 右出隊(FIFO隊列)
$order = json_decode($orderJson, true);
// 執行 MySQL 插入訂單邏輯...
// 3. 查看隊列長度
$queueLen = Cache::llen('seckill_order_queue'); // 隊列中的訂單數
2.4 Set(集合)- 去重、抽獎
用途:防止重複秒殺(存儲已秒殺用户 ID,天然去重)
ThinkPHP 操作示例:
// 1. 添加用户到已秒殺集合(用户ID=10086,商品ID=1001)
$isAdd = Cache::sAdd('seckill_user_1001', 10086);
// 返回1:添加成功(用户未秒殺過);返回0:添加失敗(用户已秒殺)
// 2. 判斷用户是否已秒殺
$hasSeckill = Cache::sIsMember('seckill_user_1001', 10086); // true/false
// 3. 獲取已秒殺用户總數
$userCount = Cache::sCard('seckill_user_1001'); // 1
// 4. 移除用户(退款場景)
Cache::sRem('seckill_user_1001', 10086);
2.5 ZSet(有序集合)- 排行榜
用途:秒殺銷量排行榜、積分排名
ThinkPHP 操作示例:
// 1. 添加商品銷量到有序集合(商品ID=1001,銷量=50)
Cache::zAdd('seckill_sales_rank', 50, 1001);
Cache::zAdd('seckill_sales_rank', 30, 1002);
// 2. 按銷量降序排列(取前10名)
$rank = Cache::zRevRange('seckill_sales_rank', 0, 9, true);
// 輸出:[1001 => 50, 1002 => 30](鍵=商品ID,值=銷量)
// 3. 增加商品銷量(原子操作)
Cache::zIncrBy('seckill_sales_rank', 1, 1001); // 商品1001銷量變為51
三、秒殺場景核心:Redis 原子性與事務
3.1 為什麼秒殺必須保證原子性?
- 秒殺核心痛點:高併發下超賣、庫存不一致
- 反例(非原子操作,會超賣):
// 錯誤代碼:先查庫存,再扣減(兩步非原子,高併發下超賣)
$stock = Cache::get('seckill_stock_1001');
if ($stock > 0) {
Cache::set('seckill_stock_1001', $stock - 1); // 高併發下多個請求同時執行,導致庫存為負
}
- 核心解決方案:用 Redis 原子命令或 Lua 腳本,將 “查庫存 + 扣庫存” 封裝為不可分割的操作
3.2 Redis 原子性實現方式(ThinkPHP 實戰)
方式 1:使用 Redis 原子命令(推薦簡單場景)
Redis 單個命令天然原子性,比如 decr hDecr,直接用於扣庫存:
// 秒殺扣庫存核心代碼(原子操作,無超賣)
public function seckill($goodsId, $userId)
{
$stockKey = "seckill_stock_{$goodsId}";
$userKey = "seckill_user_{$goodsId}";
// 1. 先判斷用户是否已秒殺(Set去重)
if (Cache::sIsMember($userKey, $userId)) {
return ['code' => 0, 'msg' => '已參與秒殺,請勿重複提交'];
}
// 2. 原子扣減庫存(decr返回減後的值,庫存不足時返回-1)
$remainStock = Cache::decr($stockKey);
if ($remainStock {
return ['code' => 0, 'msg' => '庫存不足'];
}
// 3. 扣減成功,添加用户到已秒殺集合
Cache::sAdd($userKey, $userId);
// 4. 加入異步隊列,同步訂單到MySQL
$orderInfo = [/* 訂單數據 */];
Cache::lpush('seckill_order_queue', json_encode($orderInfo));
return ['code' => 1, 'msg' => '秒殺成功'];
}
方式 2:Lua 腳本(複雜邏輯原子性,推薦秒殺場景)
當需要 “判斷庫存> 0 + 扣庫存 + 記錄用户” 多步邏輯時,用 Lua 腳本保證原子性:
// ThinkPHP 中調用 Lua 腳本扣庫存
public function seckillByLua($goodsId, $userId)
{
$redis = Cache::store('redis')->handler();
$stockKey = "seckill_stock_{$goodsId}";
$userKey = "seckill_user_{$goodsId}";
// Lua 腳本:判斷庫存+扣庫存+記錄用户(原子操作)
$lua = << stockKey = KEYS[1]
local userKey = KEYS[2]
local userId = ARGV[1]
-- 1. 判斷用户是否已秒殺
if redis.call('sismember', userKey, userId) == 1 then
return 0 -- 已秒殺
end
-- 2. 判斷庫存是否充足
local stock = tonumber(redis.call('get', stockKey))
if not stock or stock
return -1 -- 庫存不足
end
-- 3. 扣減庫存 + 記錄用户
redis.call('decr', stockKey)
redis.call('sadd', userKey, userId)
return 1 -- 秒殺成功
LUA;
// 執行 Lua 腳本(KEYS參數:2個鍵;ARGV參數:用户ID)
$result = $redis->eval($lua, [$stockKey, $userKey, $userId], 2);
switch ($result) {
case 1:
// 加入訂單隊列...
return ['code' => 1, 'msg' => '秒殺成功'];
case 0:
return ['code' => 0, 'msg' => '已參與秒殺'];
case -1:
return ['code' => 0, 'msg' => '庫存不足'];
}
}
3.3 Redis 事務(MULTI/EXEC)- 瞭解即可
Redis 事務不支持回滾,適合無邏輯依賴的批量操作,秒殺場景用得少:
$redis = Cache::store('redis')->handler();
$redis->multi(); // 開啓事務
$redis->decr('seckill_stock_1001');
$redis->sAdd('seckill_user_1001', 10086);
$redis->exec(); // 提交事務(批量執行,原子性)
3.4 Redis 三大特性在秒殺中的體現
| 特性 | 説明(ThinkPHP 秒殺場景) |
|---|---|
| 原子性 | 用 decr 或 Lua 腳本,保證 “扣庫存 + 記錄用户” 不可分割,避免超賣 |
| 一致性 | 庫存從 Redis 預扣減,再異步同步到 MySQL;Redis 拒絕非法操作(比如對字符串庫存 decr) |
| 隔離性 | Redis 單線程執行命令,秒殺高併發請求按順序執行,不會出現 “同時讀庫存、同時扣減” 的併發問題 |
注意:Redis 不保證持久性(秒殺場景可接受,因為 MySQL 是最終數據源,Redis 宕機可從 MySQL 恢復庫存)
四、ThinkPHP + Redis 實戰場景擴展
4.1 緩存穿透解決方案(秒殺場景:惡意查詢不存在的商品)
- 問題:用户頻繁查詢不存在的商品 ID,導致請求穿透 Redis 直接訪問 MySQL
- 解決方案:緩存空值 + 布隆過濾器(推薦)
// 緩存空值示例
public function getGoodsInfo($goodsId)
{
$cacheKey = "goods_info_{$goodsId}";
$info = Cache::get($cacheKey);
if ($info === false) {
// 緩存未命中,查詢MySQL
$info = Db::name('goods')->find($goodsId);
if (!$info) {
// 存儲空值,設置短期過期(比如10分鐘)
Cache::set($cacheKey, json_encode(null), 600);
return ['code' => 0, 'msg' => '商品不存在'];
}
// 緩存商品信息(1小時過期)
Cache::set($cacheKey, json_encode($info), 3600);
} else {
$info = json_decode($info, true);
if ($info === null) {
return ['code' => 0, 'msg' => '商品不存在'];
}
}
return ['code' => 1, 'data' => $info];
}
4.2 緩存擊穿解決方案(秒殺場景:熱點商品緩存過期)
- 問題:秒殺熱點商品的緩存過期瞬間,大量請求直接打 MySQL
- 解決方案:互斥鎖 + 緩存預熱
// 互斥鎖示例(ThinkPHP 結合 Redis setnx 實現)
public function getHotGoodsInfo($goodsId)
{
$cacheKey = "hot_goods_{$goodsId}";
$lockKey = "lock_hot_goods_{$goodsId}";
$info = Cache::get($cacheKey);
if ($info) {
return json_decode($info, true);
}
// 緩存未命中,獲取互斥鎖
$lock = Cache::setnx($lockKey, 1);
if ($lock) {
// 獲得鎖,查詢MySQL並更新緩存
$info = Db::name('goods')->find($goodsId);
Cache::set($cacheKey, json_encode(\$info), 3600); // 延長緩存時間
Cache::del($lockKey); // 釋放鎖
return $info;