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 缓存驱动:
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 缓存操作,重点掌握秒杀常用的StringHashList。
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'); // 库存992.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 单个命令天然原子性,比如decrhDecr,直接用于扣库存:
// 秒杀扣库存核心代码(原子操作,无超卖) 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;这里是不务正业的程序员