Redis + ThinkPHP 实战学习手册(含秒杀场景)
目录
-
基础准备: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 缓存驱动:
dart
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 配置(可选,优先级更高):
ini
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_SELECT=0
步骤 3:测试连接
在 ThinkPHP 控制器中测试 Redis 是否可用:
php
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 操作示例:
php
// 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 操作示例:
css
// 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 操作示例:
php
// 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 操作示例:
php
// 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 操作示例:
php
// 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 为什么秒杀必须保证原子性?
-
秒杀核心痛点:高并发下超卖、库存不一致
-
反例(非原子操作,会超卖):
php
// 错误代码:先查库存,再扣减(两步非原子,高并发下超卖)
$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,直接用于扣库存:
php
// 秒杀扣库存核心代码(原子操作,无超卖)
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 脚本保证原子性:
php
// 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 事务不支持回滚,适合无逻辑依赖的批量操作,秒杀场景用得少:
scss
$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
-
解决方案:缓存空值 + 布隆过滤器(推荐)
php
// 缓存空值示例
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
-
解决方案:互斥锁 + 缓存预热
php
// 互斥锁示例(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;
随便学一下这个东西,理解一下该怎么做就好