PHP面试题(Redis核心知识篇)
四、Redis核心知识(结构+场景+缓存)
1. Redis的5种核心数据结构(String/Hash/List/Set/ZSet),各自底层实现与典型使用场景?(举例:用Hash存用户信息、ZSet做排行榜)
答案解析
Redis的核心优势在于"基于内存+高效数据结构",5种基础结构底层实现不同,适配不同业务场景,核心区别如下:
| 数据结构 | 底层实现(Redis 6.0+) | 核心特性 | 典型使用场景(结合PHP业务) |
|---|---|---|---|
| String(字符串) | 简单动态字符串(SDS);长度≤44字节用embstr编码,>44字节用raw编码 | 可存字符串、数字(自增自减)、二进制;最大512MB;支持原子操作 | 1. 缓存单个值(如用户Token、商品详情缓存);2. 计数器(文章阅读量、接口请求次数);3. 分布式锁的value存储;4. 验证码存储(设置过期时间) |
| Hash(哈希) | 字段少(≤512个)用ziplist编码,字段多用hashtable编码 | 键值对集合,适合存储对象;可单独操作单个字段,节省空间 | 1. 存储用户信息(如user:1001 → {name:张三, phone:138..., age:25});2. 存储商品属性(如goods:100 → {price:99, stock:1000, category:家电});3. 缓存Excel导入的单条数据详情 |
| List(列表) | 元素少(≤512个、元素长度≤64字节)用ziplist编码,否则用quicklist编码 | 有序(插入顺序)、可重复;支持首尾插入/删除,查询慢(需遍历) | 1. 消息队列(如excel:import:queue → 存储待导入的Excel数据ID,PHP消费者从队首弹出处理);2. 最新消息列表(如news:list → 存储最近10条公告);3. 分页展示Excel导入的历史记录 |
| Set(集合) | 元素少用ziplist编码,元素多用intset(整数元素)/hashtable(字符串元素)编码 | 无序、不可重复;支持交集、并集、差集运算 | 1. 去重(如excel:import:ids → 存储已导入的用户ID,避免重复导入);2. 用户标签(如user:tags:1001 → {学生, 男性, 喜欢运动});3. 共同好友查询 |
| ZSet(有序集合) | 元素少用ziplist编码,元素多用skiplist(跳表)+hashtable编码 | 有序(按score排序)、不可重复;score可修改,查询排序高效 | 1. 排行榜(如excel:import:rank → 按导入数量排序,member是用户ID,score是导入条数);2. 延时任务(如delay:queue → score是时间戳,PHP定时查询score≤当前时间的任务);3. 商品销量排名 |
场景延伸
PHP业务中,优先用Hash存储对象(比String存JSON更灵活,可单独更新字段);高并发消息队列优先用List(简单易用),复杂延时队列用ZSet;Excel导入去重必用Set,避免重复插入数据库。
2. 高并发场景下redis怎么解决秒杀问题?(减库存+队列+分布式锁)
答案解析
秒杀核心痛点:高并发(万级QPS)、超卖(库存负数)、重复下单、服务雪崩;Redis解决核心思路:用Redis预存库存+原子操作减库存+队列削峰+分布式锁防并发,避免直接操作数据库,降低DB压力。
完整流程(结合PHP+Redis):
-
预存库存:秒杀活动开始前,将商品库存写入Redis(如set goods:stock:100 100);
-
队列削峰:用户秒杀请求先进入Redis List队列,PHP消费者进程异步处理(避免瞬间冲击服务);
-
原子减库存:消费者从队列取出请求,用Redis原子命令(decr)减库存,避免超卖;
-
分布式锁:防止多个消费者同时处理同一商品,确保减库存逻辑唯一执行;
-
兜底校验:减库存成功后,异步写入数据库,同时校验Redis与DB库存一致性(避免Redis宕机导致数据丢失)。
PHP代码示例(核心环节)
1. 预存库存(活动初始化)
php
<?php
// 连接Redis(PHP用predis扩展,实际项目封装成单例)
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
// 商品ID=100,秒杀库存=100
$goodsId = 100;
$stockKey = "seckill:stock:$goodsId";
$redis->set($stockKey, 100);
// 设置库存过期时间(活动结束后自动清理)
$redis->expire($stockKey, 86400);
?>
2. 秒杀请求入队(前端请求接口)
php
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$goodsId = 100;
$userId = $_POST['user_id']; // 登录用户ID
$queueKey = "seckill:queue:$goodsId";
// 先判断用户是否已秒杀(避免重复下单,用Set存储已秒杀用户)
$userSetKey = "seckill:users:$goodsId";
if ($redis->sismember($userSetKey, $userId)) {
echo json_encode(['code' => 0, 'msg' => '已参与秒杀,请勿重复提交']);
exit;
}
// 请求入队(LPUSH:从队首插入,RPOP:消费者从队尾取出)
$redis->lpush($queueKey, json_encode(['goods_id' => $goodsId, 'user_id' => $userId]));
echo json_encode(['code' => 1, 'msg' => '秒杀请求已提交,请等待结果']);
?>
3. 消费者异步处理(PHP脚本,后台常驻)
php
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$goodsId = 100;
$stockKey = "seckill:stock:$goodsId";
$queueKey = "seckill:queue:$goodsId";
$userSetKey = "seckill:users:$goodsId";
$lockKey = "seckill:lock:$goodsId"; // 分布式锁key
// 常驻进程,循环处理队列
while (true) {
// 从队列取出请求(RPOP:无数据时阻塞1秒,避免空轮询)
$request = $redis->brpop($queueKey, 1);
if (!$request) {
continue;
}
$data = json_decode($request[1], true);
$userId = $data['user_id'];
// 1. 获取分布式锁(SET NX EX,防止死锁)
$lock = $redis->set($lockKey, $userId, ['NX', 'EX' => 5]);
if (!$lock) {
// 未获取到锁,重新入队,重试
$redis->lpush($queueKey, json_encode($data));
continue;
}
try {
// 2. 原子减库存(decr返回减后的值,负数表示库存不足)
$currentStock = $redis->decr($stockKey);
if ($currentStock < 0) {
// 库存不足,恢复库存,返回失败
$redis->incr($stockKey);
echo "用户{$userId}秒杀失败:库存不足\n";
continue;
}
// 3. 标记用户已秒杀(Set去重)
$redis->sadd($userSetKey, $userId);
// 4. 异步写入数据库(用消息队列/定时任务,避免阻塞)
// 这里简化处理,实际项目用RabbitMQ/Redis List单独做DB写入队列
$orderData = [
'goods_id' => $goodsId,
'user_id' => $userId,
'create_time' => time(),
'status' => 1
];
saveOrderToDb($orderData); // 自定义DB写入方法
echo "用户{$userId}秒杀成功,剩余库存:{$currentStock}\n";
} catch (Exception $e) {
// 异常处理,恢复库存
$redis->incr($stockKey);
echo "秒杀异常:" . $e->getMessage() . "\n";
} finally {
// 释放分布式锁(避免死锁,仅释放自己的锁)
if ($redis->get($lockKey) == $userId) {
$redis->del($lockKey);
}
}
// 控制处理速度,避免压垮Redis/DB
usleep(1000);
}
// 模拟DB写入方法
function saveOrderToDb($data) {
// 实际项目中用PDO/MySQLi写入订单表
$pdo = new PDO("mysql:host=127.0.0.1;dbname=test;charset=utf8", "root", "123456");
$sql = "INSERT INTO seckill_order (goods_id, user_id, create_time, status) VALUES (?, ?, ?, ?)";
$stmt = $pdo->prepare($sql);
$stmt->execute([$data['goods_id'], $data['user_id'], $data['create_time'], $data['status']]);
}
?>
场景延伸
-
超卖兜底:Redis减库存后,数据库写入前,需校验Redis库存与DB库存一致性(定时任务对比),避免Redis宕机导致超卖;
-
高并发优化:多个商品秒杀时,用商品ID作为锁的后缀(如seckill🔒100),避免全局锁导致的性能瓶颈;
-
Excel批量秒杀:若秒杀活动需通过Excel导入用户名单,可先将用户ID存入Redis Set,消费者处理时先校验是否在Set中,避免非法请求。
3. 缓存三大问题(穿透/击穿/雪崩)的定义及PHP+Redis解决方案(代码示例优先)?
答案解析
缓存三大问题是PHP高并发项目中必遇场景,核心原因是"缓存未命中"导致大量请求穿透到数据库,引发DB雪崩,三者区别及解决方案如下:
一、缓存穿透
定义:请求查询的key在Redis和数据库中都不存在(如查询不存在的用户ID、不存在的Excel导入记录),导致所有请求都穿透到数据库,压垮DB。
PHP+Redis解决方案:缓存空值(核心)+ 布隆过滤器(海量无效key场景)
php
<?php
/**
* 缓存穿透解决方案:缓存空值
* 场景:查询用户信息(用户ID可能不存在)
*/
function getUserInfo($userId) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$cacheKey = "user:info:$userId";
// 1. 先查Redis缓存
$cacheData = $redis->get($cacheKey);
if ($cacheData !== false) {
// 缓存命中(包括空值),直接返回(空值需反序列化)
return $cacheData === 'null' ? null : json_decode($cacheData, true);
}
// 2. 缓存未命中,查数据库
$userData = getUserFromDb($userId); // 自定义DB查询方法
// 3. 无论是否查到数据,都写入Redis(空值设置短期过期,避免缓存膨胀)
if ($userData) {
$redis->set($cacheKey, json_encode($userData), 3600); // 有效数据缓存1小时
} else {
$redis->set($cacheKey, 'null', 60); // 空值缓存1分钟,短期失效
}
return $userData;
}
// 模拟DB查询方法
function getUserFromDb($userId) {
$pdo = new PDO("mysql:host=127.0.0.1;dbname=test;charset=utf8", "root", "123456");
$sql = "SELECT id, name, phone FROM user WHERE id = ? LIMIT 1";
$stmt = $pdo->prepare($sql);
$stmt->execute([$userId]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
?>
补充:布隆过滤器(海量无效key场景):
若有大量无效key(如恶意请求1000万不存在的用户ID),缓存空值会导致Redis缓存膨胀,此时用布隆过滤器(Redis自带bloom模块)提前过滤无效key:
php
<?php
// 1. 初始化布隆过滤器(存入所有有效用户ID)
$redis->bf()->add('user:id:bloom', 1001, 1002, 1003); // 有效用户ID
// 2. 查询前先校验布隆过滤器
function getUserInfoWithBloom($userId) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
// 布隆过滤器校验:不存在则直接返回null,不查缓存和DB
if (!$redis->bf()->exists('user:id:bloom', $userId)) {
return null;
}
// 后续流程和"缓存空值"一致...
}
?>
二、缓存击穿
定义:某个热点key(如热门商品、高频访问的Excel导入记录)过期失效,此时大量请求同时查询该key,缓存未命中,全部穿透到数据库,压垮DB。
PHP+Redis解决方案:分布式锁(核心)+ 热点key永不过期(备选)
php
<?php
/**
* 缓存击穿解决方案:分布式锁(确保只有一个请求查DB,重建缓存)
* 场景:查询热门商品信息(商品ID=100,热点key)
*/
function getGoodsInfo($goodsId) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$cacheKey = "goods:info:$goodsId";
$lockKey = "goods:lock:$goodsId";
// 1. 先查Redis缓存
$cacheData = $redis->get($cacheKey);
if ($cacheData !== false) {
return json_decode($cacheData, true);
}
// 2. 缓存未命中,获取分布式锁(只有一个请求能获取到锁,去查DB)
$lock = $redis->set($lockKey, 1, ['NX', 'EX' => 5]); // 锁过期5秒,避免死锁
if ($lock) {
try {
// 3. 再次查缓存(防止其他请求释放锁后,缓存已重建)
$cacheData = $redis->get($cacheKey);
if ($cacheData !== false) {
return json_decode($cacheData, true);
}
// 4. 查数据库,重建缓存(设置较长过期时间,如1小时)
$goodsData = getGoodsFromDb($goodsId);
if ($goodsData) {
$redis->set($cacheKey, json_encode($goodsData), 3600);
}
return $goodsData;
} catch (Exception $e) {
return null;
} finally {
// 5. 释放锁
$redis->del($lockKey);
}
} else {
// 6. 未获取到锁,休眠100ms后重试(避免频繁请求)
usleep(100000);
return getGoodsInfo($goodsId); // 递归重试
}
}
// 模拟DB查询方法
function getGoodsFromDb($goodsId) {
$pdo = new PDO("mysql:host=127.0.0.1;dbname=test;charset=utf8", "root", "123456");
$sql = "SELECT id, name, price, stock FROM goods WHERE id = ? LIMIT 1";
$stmt = $pdo->prepare($sql);
$stmt->execute([$goodsId]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
?>
备选方案:热点key永不过期:
将热点key的过期时间删除(不设置EX),同时用定时任务(如crontab)定期更新缓存内容,避免过期失效;适合热点key数量少、更新频率低的场景(如首页Banner、固定Excel模板)。
三、缓存雪崩
定义:大量热点key在同一时间过期(如凌晨0点批量更新缓存),或Redis服务宕机,导致所有请求都穿透到数据库,DB瞬间被压垮,引发服务雪崩。
PHP+Redis解决方案:过期时间随机化(核心)+ Redis集群(高可用)+ 降级熔断(兜底)
php
<?php
/**
* 缓存雪崩解决方案1:过期时间随机化(避免大量key同时过期)
* 场景:批量缓存Excel导入的商品数据
*/
function batchSetGoodsCache($goodsList) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
foreach ($goodsList as $goods) {
$cacheKey = "goods:info:{$goods['id']}";
// 过期时间 = 基础时间(1小时)+ 随机时间(0-300秒),分散过期时间
$expireTime = 3600 + mt_rand(0, 300);
$redis->set($cacheKey, json_encode($goods), $expireTime);
}
}
/**
* 缓存雪崩解决方案2:降级熔断(Redis宕机时,避免压垮DB)
* 场景:Redis连接失败,返回默认数据/提示,不查DB
*/
function getGoodsInfoWithFallback($goodsId) {
$redis = new Redis();
try {
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
} catch (Exception $e) {
// Redis宕机,降级处理:返回默认数据,或提示"服务繁忙"
return ['code' => 0, 'msg' => '服务繁忙,请稍后再试', 'data' => []];
}
// 后续流程和正常查询一致...
}
?>
补充:Redis集群(高可用):
部署Redis主从集群+哨兵模式(或Redis Cluster),当主库宕机时,哨兵自动将从库切换为主库,避免Redis单点故障;PHP代码连接哨兵集群,自动切换主从,无需修改代码。
场景延伸
-
Excel导入场景:批量导入的数据缓存时,务必用"过期时间随机化",避免所有导入记录缓存同时过期,引发雪崩;
-
兜底策略:缓存三大问题的核心是"保护数据库",无论哪种方案,都需加入降级熔断机制,即使Redis宕机,也能保证服务不崩溃;
-
监控告警:实时监控Redis缓存命中率、DB请求量,当命中率骤降、DB请求量骤升时,立即告警,快速排查问题(如缓存穿透、Redis宕机)。
4. Redis缓存更新策略(Cache-Aside/Read-Through/Write-Through/Write-Back),高并发场景下如何选择?
答案解析
Redis缓存更新策略的核心是"保证缓存与数据库数据一致性",4种策略各有优劣,适配不同并发场景,PHP项目中重点掌握前2种(最常用):
| 策略名称 | 核心逻辑(PHP+Redis场景) | 优点 | 缺点 | 适用场景(高并发适配) |
|---|---|---|---|---|
| Cache-Aside(旁路缓存,最常用) | 1. 读:先查Redis → 缓存未命中查DB → 写入Redis;2. 写:先写DB → 再删Redis(或更新Redis) | 实现简单、灵活;无锁竞争;适配高并发读 | 写后读可能有短暂不一致(如写DB后,Redis未删除完成,读旧数据);需手动维护缓存 | PHP高并发核心场景(如商品详情、用户信息、Excel导入数据查询);读多写少场景 |
| Read-Through(读穿透) | PHP不直接查DB,仅查Redis;Redis未命中时,由Redis自身(或中间件)查DB,写入Redis后返回给PHP | PHP代码解耦(无需关注DB查询);缓存命中率高 | Redis需集成中间件(如Redis Cluster),复杂度高;PHP无法自定义DB查询逻辑 | 缓存逻辑复杂、需解耦的场景;不适合Excel导入等需自定义查询的业务 |
| Write-Through(写穿透) | PHP不直接写DB,仅写Redis;Redis写入后,由Redis自身同步写入DB,再返回成功给PHP | 缓存与DB强一致;PHP代码简单(无需关注DB写入) | 写入性能低(Redis同步写DB,阻塞请求);Redis宕机导致数据丢失 | 数据一致性要求极高、写并发低的场景(如财务数据);不适合高并发写(如Excel批量导入) |
| Write-Back(写回,异步写) | PHP写Redis → 立即返回成功;Redis异步(批量)写入DB(如定时100ms写一次) | 写入性能极高;适配高并发写 | 数据一致性差(Redis宕机丢失数据);实现复杂(需处理异步写入失败) | 高并发写、数据一致性要求低的场景(如Excel导入的临时数据、访问日志) |
PHP代码示例(常用策略)
1. Cache-Aside(旁路缓存,读多写少,最常用)
php
<?php
// 读操作(Cache-Aside)
function getExcelData($dataId) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$cacheKey = "excel:data:$dataId";
// 先查缓存
$cacheData = $redis->get($cacheKey);
if ($cacheData !== false) {
return json_decode($cacheData, true);
}
// 缓存未命中,查DB
$dbData = getExcelDataFromDb($dataId);
// 写入缓存
if ($dbData) {
$redis->set($cacheKey, json_encode($dbData), 3600);
}
return $dbData;
}
// 写操作(Cache-Aside:先写DB,再删缓存)
function updateExcelData($dataId, $newData) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$cacheKey = "excel:data:$dataId";
try {
// 1. 先写数据库(保证DB数据正确)
updateExcelDataToDb($dataId, $newData);
// 2. 再删除缓存(避免缓存与DB不一致,下次读自动重建)
$redis->del($cacheKey);
return ['code' => 1, 'msg' => '更新成功'];
} catch (Exception $e) {
return ['code' => 0, 'msg' => '更新失败:' . $e->getMessage()];
}
}
// 模拟DB读写方法
function getExcelDataFromDb($dataId) { /* 省略DB查询逻辑 */ }
function updateExcelDataToDb($dataId, $newData) { /* 省略DB更新逻辑 */ }
?>
2. Write-Back(异步写,高并发写场景,如Excel批量导入)
php
<?php
/**
* 写操作(Write-Back:先写Redis,异步写DB)
* 场景:Excel批量导入数据,高并发写入
*/
function batchImportExcelData($dataList) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$batchKey = "excel:import:batch:" . uniqid(); // 批量写入标记key
try {
// 1. 批量写入Redis(立即返回,不阻塞)
foreach ($dataList as $data) {
$cacheKey = "excel:data:{$data['id']}";
$redis->set($cacheKey, json_encode($data), 86400);
// 加入批量写入队列,供异步脚本处理
$redis->lpush($batchKey, $data['id']);
}
// 2. 触发异步写入DB(用定时任务/守护进程处理队列)
// 这里简化:调用异步脚本(实际用crontab每分钟执行一次)
exec("php async_write_db.php $batchKey &");
return ['code' => 1, 'msg' => '导入成功,数据正在同步'];
} catch (Exception $e) {
return ['code' => 0, 'msg' => '导入失败:' . $e->getMessage()];
}
}
// 异步写入DB脚本(async_write_db.php)
$batchKey = $argv[1];
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
// 从队列取出数据,批量写入DB
$dataIds = $redis->lrange($batchKey, 0, -1);
$batchData = [];
foreach ($dataIds as $dataId) {
$cacheKey = "excel:data:$dataId";
$data = json_decode($redis->get($cacheKey), true);
if ($data) {
$batchData[] = $data;
}
}
// 批量写入数据库
batchInsertToDb($batchData); // 自定义批量写入方法
// 删除批量标记key
$redis->del($batchKey);
?>
高并发场景选择建议
-
读多写少(90%读,10%写):优先选 Cache-Aside(如商品详情、Excel导入数据查询),简单高效,适配高并发读;
-
高并发写(如Excel批量导入、实时日志):优先选 Write-Back(异步写),牺牲少量一致性,提升写入性能;
-
数据一致性要求极高(如财务、订单支付):优先选 Write-Through(写穿透),或 Cache-Aside+分布式锁;
-
代码解耦需求高:选 Read-Through(读穿透),但需部署Redis中间件,增加复杂度。
场景延伸
Excel导入场景特殊:若导入的数据是"临时数据"(如待审核),用Write-Back异步写DB,提升导入速度;若导入的数据是"正式数据"(如用户信息),用Cache-Aside,先写DB再删缓存,保证数据一致性。
5. Redis的过期键删除策略是什么?(惰性删除+定期删除)如何避免过期键导致的内存溢出?
答案解析
Redis过期键删除策略的核心是"平衡内存占用与CPU消耗",单一删除策略无法满足需求,Redis采用 惰性删除+定期删除 结合的方式,配合内存淘汰策略,避免过期键导致内存溢出。
一、核心过期键删除策略(2种结合)
-
惰性删除(Lazy Expiration)
-
核心逻辑:Redis不主动删除过期键,只有当用户请求查询该过期键时,才检查是否过期,若过期则删除该键,返回null;
-
优点:CPU消耗极低(不主动扫描),只在查询时处理;
-
缺点:过期键长期不被查询,会一直占用内存,导致"内存泄漏"(如Excel导入的临时数据,过期后未被查询,一直占内存)。
-
-
定期删除(Periodic Expiration)
-
核心逻辑:Redis每隔一段时间(默认100ms),随机扫描一部分过期键(不是全部),删除其中已过期的键;
-
优点:主动清理部分过期键,减少内存泄漏风险;
-
缺点:若扫描频率过高,占用CPU;频率过低,仍会有大量过期键占用内存。
-
补充:Redis 6.0+ 对定期删除做了优化,可通过配置 hz 参数(默认10)调整扫描频率,hz值越大,扫描越频繁,CPU占用越高。
二、避免过期键导致内存溢出的解决方案(PHP+Redis实践)
核心思路:定期删除+内存淘汰策略+业务层主动清理,三者结合,彻底解决内存溢出问题。
1. 配置Redis内存淘汰策略(核心)
当Redis内存达到最大限制(配置 maxmemory)时,自动触发内存淘汰策略,删除部分键释放内存,PHP项目中常用以下2种:
| 淘汰策略 | 核心逻辑 | 适用场景 |
|---|---|---|
| allkeys-lru(最常用) | 删除所有键中"最近最少使用"的键(LRU算法) | PHP高并发场景(如商品缓存、Excel数据缓存),适配大部分业务 |
| volatile-lru | 只删除"设置了过期时间"的键中,最近最少使用的键 | 有永久键(如配置信息)和过期键(如临时数据)共存的场景 |
配置方法:修改Redis配置文件redis.conf,添加 maxmemory 10GB(设置最大内存)、maxmemory-policy allkeys-lru(设置淘汰策略),重启Redis生效。 |
2. 业务层主动清理过期键(PHP代码)
针对Excel导入等场景,过期键可能长期不被查询,需在业务层主动清理:
php
<?php
/**
* 主动清理Excel导入的过期临时数据(定时任务执行,如每小时一次)
*/
function clearExpiredExcelData() {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
// 1. 方式1:模糊匹配所有Excel临时数据key,逐个检查过期
$iterator = null;
$pattern = "excel:temp:*"; // 临时数据key前缀
while ($keys = $redis->scan($iterator, $pattern, 1000)) { // 每次扫描1000个key,避免阻塞
foreach ($keys as $key) {
// 检查key是否过期(ttl返回-2表示已过期)
if ($redis->ttl($key) == -2) {
$redis->del($key);
}
}
}
// 2. 方式2:用Redis的Sorted Set存储过期时间,批量删除(更高效)
$expireKey = "excel:temp:expire";
$currentTime = time();
// 删除score≤当前时间的key(score是过期时间戳)
$expiredKeys = $redis->zrangebyscore($expireKey, 0, $currentTime);
if (!empty($expiredKeys)) {
$redis->del($expiredKeys); // 批量删除过期key
$redis->zremrangebyscore($expireKey, 0, $currentTime); // 删除Sorted Set中的记录
}
echo "清理完成,删除过期临时数据" . count($expiredKeys) . "条\n";
}
// 存入临时数据时,同步写入Sorted Set(记录过期时间)
function setExcelTempData($dataId, $data, $expireTime) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$cacheKey = "excel:temp:$dataId";
$expireKey = "excel:temp:expire";
$expireTimestamp = time() + $expireTime;
// 存入临时数据,设置过期时间
$redis->set($cacheKey, json_encode($data), $expireTime);
// 写入Sorted Set,score是过期时间戳
$redis->zadd($expireKey, $expireTimestamp, $cacheKey);
}
?>
3. 优化过期键设置(业务层优化)
-
避免设置"永久过期"的临时键:Excel导入的临时数据、验证码等,必须设置合理的过期时间(如10分钟、1小时);
-
过期时间随机化:批量设置过期键时,添加随机时间(如3600±300秒),避免大量键同时过期,导致定期删除扫描压力增大;
-
拆分大key:若某个过期键是大key(如存储10万条Excel数据),删除时会阻塞Redis,需拆分为小key(如按数据ID分段),分批删除。
场景延伸
PHP项目中,Redis内存溢出的常见诱因是"过期的大key未被清理"(如Excel批量导入的大文件缓存),建议:1. 定时任务优先用"Sorted Set批量清理"方式,效率更高;2. 监控Redis内存使用率,当使用率超过80%时,立即告警,排查过期键和大key。
6. 保证缓存和数据一致 怎么解决?(双写模式/失效模式)
答案解析
缓存与数据库一致性的核心痛点:数据库数据更新后,缓存数据未及时同步,导致查询返回旧数据(脏数据)。PHP高并发场景中,无法实现"强一致性"(需牺牲性能),核心追求"最终一致性",主要通过 失效模式(Cache-Aside,旁路缓存) 和 双写模式 解决,两者可单独使用或结合优化。
核心原则:写操作优先保证数据库正确性,再同步处理缓存;读操作优先查缓存,未命中再查数据库并重建缓存。
一、两种核心解决方案(PHP+Redis代码示例,可直接用于面试作答)
1. 失效模式(Cache-Aside,最常用、推荐)
核心逻辑:写操作→先写数据库→再删除缓存;读操作→先查缓存→未命中查DB→写入缓存。
优势:实现简单、性能优(删除缓存比更新缓存耗时少);避免高并发下缓存覆盖问题。
劣势:写后读有短暂不一致(如写DB后、缓存未删除完成,读请求命中旧缓存),可通过业务优化规避。
php
<?php
/**
* 读操作(失效模式核心流程)
* 场景:查询订单信息(缓存与DB一致性场景)
*/
function getOrderInfo($orderId) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456'); // 实际项目封装成配置,避免硬编码
$cacheKey = "order:info:$orderId";
// 1. 优先查缓存
$cacheData = $redis->get($cacheKey);
if ($cacheData !== false) {
return json_decode($cacheData, true); // 缓存命中,直接返回
}
// 2. 缓存未命中,查询数据库
$dbData = getOrderFromDb($orderId); // 自定义DB查询方法(PDO实现)
// 3. 写入缓存(重建缓存,设置合理过期时间)
if ($dbData) {
$redis->set($cacheKey, json_encode($dbData), 3600); // 缓存1小时
}
return $dbData;
}
/**
* 写操作(失效模式核心流程:先写DB,再删缓存)
*/
function updateOrderStatus($orderId, $status) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$cacheKey = "order:info:$orderId";
$pdo = new PDO("mysql:host=127.0.0.1;dbname=test;charset=utf8", "root", "123456");
try {
// 1. 开启DB事务,保证数据库数据正确性(核心)
$pdo->beginTransaction();
$sql = "UPDATE `order` SET status = ?, update_time = NOW() WHERE id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$status, $orderId]);
$pdo->commit();
// 2. 删除缓存(关键:不更新缓存,下次读自动重建,避免并发问题)
$redis->del($cacheKey);
return ['code' => 1, 'msg' => '订单状态更新成功', 'data' => ['order_id' => $orderId, 'status' => $status]];
} catch (Exception $e) {
$pdo->rollBack(); // 异常回滚,保证DB数据一致
return ['code' => 0, 'msg' => '更新失败:' . $e->getMessage()];
}
}
// 模拟DB查询方法(实际项目封装成DB工具类)
function getOrderFromDb($orderId) {
$pdo = new PDO("mysql:host=127.0.0.1;dbname=test;charset=utf8", "root", "123456");
$sql = "SELECT id, order_no, status, create_time FROM `order` WHERE id = ? LIMIT 1";
$stmt = $pdo->prepare($sql);
$stmt->execute([$orderId]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
?>
2. 双写模式(适配实时性要求高的场景)
核心逻辑:写操作→先写数据库→再更新缓存;读操作与失效模式一致。
优势:写后读无短暂不一致(缓存及时同步),适合实时性要求高的场景(如财务数据、用户余额)。
劣势:高并发写时,会出现"缓存覆盖"问题(如两个请求同时更新,A写DB→B写DB→A更缓存→B更缓存,顺序错乱),需用分布式锁解决。
php
<?php
/**
* 写操作(双写模式+分布式锁,解决缓存覆盖问题)
*/
function updateOrderStatusWithDoubleWrite($orderId, $status) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$cacheKey = "order:info:$orderId";
$lockKey = "order:lock:$orderId"; // 分布式锁key(按订单ID区分,避免全局锁)
$pdo = new PDO("mysql:host=127.0.0.1;dbname=test;charset=utf8", "root", "123456");
// 1. 获取分布式锁(SET NX EX,防止死锁,仅当前订单加锁)
$lock = $redis->set($lockKey, 1, ['NX', 'EX' => 5]);
if (!$lock) {
return ['code' => 0, 'msg' => '系统繁忙,请稍后再试'];
}
try {
// 2. 写数据库(事务保证一致性)
$pdo->beginTransaction();
$sql = "UPDATE `order` SET status = ?, update_time = NOW() WHERE id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$status, $orderId]);
// 3. 双写模式:更新缓存(重新查DB,确保缓存数据最新)
$dbData = getOrderFromDb($orderId);
$redis->set($cacheKey, json_encode($dbData), 3600);
$pdo->commit();
return ['code' => 1, 'msg' => '更新成功', 'data' => $dbData];
} catch (Exception $e) {
$pdo->rollBack();
return ['code' => 0, 'msg' => '更新失败:' . $e->getMessage()];
} finally {
// 4. 释放锁(必须执行,避免死锁)
$redis->del($lockKey);
}
}
// 读操作(与失效模式一致,无需修改)
function getOrderInfoDoubleWrite($orderId) {
return getOrderInfo($orderId);
}
?>
二、一致性优化补充(面试加分点)
-
延迟双删:写DB→删缓存→延迟100ms→再删缓存(解决"写DB后,缓存刚好被重建"的极端情况);
-
缓存过期时间兜底:无论哪种模式,缓存必须设置过期时间(如1小时),避免缓存永久不一致;
-
异步同步(高并发写场景):写DB后,将缓存更新/删除操作放入Redis List队列,PHP异步脚本处理,提升写入性能。
场景延伸(面试高频追问)
-
Excel导入场景适配:批量导入数据(写操作)时,用"失效模式+异步删缓存",先批量写入DB,再批量删除对应缓存key(如excel:data:*),避免单次删除耗时过长;导入后立即查询的单条数据,强制读DB(避免短暂不一致)。
-
选择建议:90%的PHP高并发场景(商品、订单、用户)优先用失效模式 (简单高效);实时性要求高的场景(余额、财务)用双写模式+分布式锁;数据一致性要求极高的场景(支付),可结合消息队列(如RabbitMQ)实现"最终一致性"。
7. 高并发下Redis分布式锁的实现逻辑(PHP代码示例),为什么要用SET NX+EX命令?如何避免死锁?
答案解析
分布式锁核心作用:在分布式系统(如多台PHP服务器部署)中,保证同一时刻只有一个进程/请求执行某段临界代码(如减库存、更新缓存),避免并发安全问题(超卖、缓存覆盖)。
Redis分布式锁是PHP高并发面试必考点,核心实现依赖Redis的原子命令,首选**SET NX EX** 命令(Redis 2.6.12+支持),而非单独用NX、EX命令(非原子操作,有并发风险)。
一、核心实现逻辑(三步法)
-
获取锁:用
SET lock_key 唯一标识 NX EX 过期时间命令,原子性完成"判断锁是否存在+设置锁+设置过期时间"; -
执行业务:获取锁成功后,执行临界代码(如减库存、更新缓存);获取失败则重试或返回"系统繁忙";
-
释放锁:执行完业务后,删除锁(需校验锁的唯一标识,避免释放别人的锁)。
二、PHP代码示例(可直接运行,面试标准写法)
php
<?php
/**
* 高并发下Redis分布式锁完整实现(PHP+Redis)
* 场景:秒杀减库存、缓存更新等临界代码保护
*/
class RedisDistributedLock {
private $redis;
private $lockKey; // 锁的key(如seckill:lock:1001)
private $lockValue; // 锁的唯一标识(避免释放别人的锁,用uuid生成)
private $expireTime; // 锁的过期时间(秒,默认5秒)
private $retryTimes; // 重试次数(获取锁失败后,重试次数)
private $retryInterval; // 重试间隔(毫秒,默认100ms)
// 初始化(实际项目封装成单例,避免重复连接Redis)
public function __construct($lockKey, $expireTime = 5, $retryTimes = 3, $retryInterval = 100) {
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
$this->redis->auth('123456');
$this->lockKey = $lockKey;
$this->expireTime = $expireTime;
$this->retryTimes = $retryTimes;
$this->retryInterval = $retryInterval;
$this->lockValue = uniqid() . '-' . getmypid(); // 唯一标识(进程ID+随机字符串)
}
/**
* 获取分布式锁(核心方法)
* @return bool true=获取成功,false=获取失败
*/
public function acquireLock() {
$retry = 0;
while ($retry <= $this->retryTimes) {
// 核心命令:SET NX EX (原子操作)
$result = $this->redis->set(
$this->lockKey,
$this->lockValue,
['NX', 'EX' => $this->expireTime]
);
if ($result === true) {
return true; // 获取锁成功
}
// 获取失败,重试
$retry++;
usleep($this->retryInterval * 1000); // 毫秒转微秒
}
return false; // 多次重试失败
}
/**
* 释放分布式锁(核心方法,必须校验唯一标识)
* @return bool true=释放成功,false=释放失败
*/
public function releaseLock() {
// 校验锁的唯一标识:只有当前锁的持有者才能释放(避免释放别人的锁)
$currentValue = $this->redis->get($this->lockKey);
if ($currentValue === $this->lockValue) {
// 释放锁(删除key)
return $this->redis->del($this->lockKey) > 0;
}
return false;
}
/**
* 强制释放锁(异常场景兜底,如进程崩溃后手动清理)
*/
public function forceReleaseLock() {
return $this->redis->del($this->lockKey) > 0;
}
}
// -------------- 使用示例(秒杀减库存场景)--------------
function seckillReduceStock($goodsId, $userId) {
// 1. 初始化分布式锁(按商品ID区分锁,避免全局锁,提升并发)
$lockKey = "seckill:lock:$goodsId";
$lock = new RedisDistributedLock($lockKey, 5, 3, 100);
try {
// 2. 获取锁
if (!$lock->acquireLock()) {
return ['code' => 0, 'msg' => '系统繁忙,请稍后再试'];
}
// 3. 执行业务(临界代码:减库存,避免超卖)
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$stockKey = "seckill:stock:$goodsId";
$currentStock = $redis->decr($stockKey); // 原子减库存
if ($currentStock < 0) {
$redis->incr($stockKey); // 库存不足,恢复库存
return ['code' => 0, 'msg' => '秒杀失败,库存不足'];
}
// 4. 写入订单(异步写入DB,提升性能)
saveSeckillOrder($goodsId, $userId);
return ['code' => 1, 'msg' => '秒杀成功', 'data' => ['goods_id' => $goodsId, 'surplus_stock' => $currentStock]];
} catch (Exception $e) {
return ['code' => 0, 'msg' => '秒杀异常:' . $e->getMessage()];
} finally {
// 5. 释放锁(必须执行,无论业务成功/失败,避免死锁)
$lock->releaseLock();
}
}
// 模拟订单写入方法
function saveSeckillOrder($goodsId, $userId) {
// 实际项目用消息队列异步写入DB,避免阻塞
$pdo = new PDO("mysql:host=127.0.0.1;dbname=test;charset=utf8", "root", "123456");
$sql = "INSERT INTO seckill_order (goods_id, user_id, create_time) VALUES (?, ?, NOW())";
$stmt = $pdo->prepare($sql);
$stmt->execute([$goodsId, $userId]);
}
// 调用示例
var_dump(seckillReduceStock(1001, 10001));
?>
三、关键问题解答(面试必问)
1. 为什么要用 SET NX + EX 命令?
核心原因:保证"获取锁+设置过期时间"的原子性,避免并发安全问题,单独用NX、EX命令会有漏洞:
-
NX命令(Set if Not Exists):只有锁不存在时,才能设置成功(实现"互斥"),但无法设置过期时间;
-
EX命令:设置锁的过期时间,但无法判断锁是否存在;
-
单独使用风险:若执行"NX设置锁"后,PHP进程崩溃/服务器宕机,未执行EX设置过期时间,会导致锁永久存在(死锁);SET NX EX 是原子命令,Redis会一次性完成"判断锁是否存在+设置锁+设置过期时间",避免上述问题。
2. 如何避免死锁?(4个核心方案,面试加分)
-
设置锁的过期时间(核心):通过EX参数设置过期时间(如5秒),即使进程崩溃,锁也会自动过期释放,避免永久死锁;
-
校验锁的唯一标识:释放锁时,必须校验锁的value(唯一标识),避免释放其他进程持有的锁(如进程A获取锁后超时,锁被自动释放,进程B获取锁,此时A执行完业务释放锁,会误删B的锁);
-
锁的过期时间兜底(避免业务执行超时):若临界代码执行时间可能超过锁的过期时间,可开启"锁续期"(如用定时任务,每隔2秒检查锁是否持有,若持有则续期3秒);
-
异常场景强制释放:部署定时任务,定期清理"过期但未释放"的锁(如查询锁的ttl,若ttl为-1(永久有效),则强制删除);进程崩溃后,手动执行forceReleaseLock()方法清理锁。
场景延伸(面试高频追问)
-
锁的粒度优化:避免用全局锁(如lock:global),需按业务维度拆分锁(如秒杀场景用seckill🔒goodsId,订单场景用order🔒orderId),提升并发能力;
-
Excel导入场景适配:批量导入数据时,按"导入批次ID"加锁(如excel:import🔒batch123),避免多台服务器同时处理同一批次导入,导致数据重复;
-
替代方案:高并发极致场景(如10万QPS),可使用Redlock(Redis集群分布式锁),避免单台Redis宕机导致锁失效;PHP中可通过predis扩展实现Redlock。
8. Redis的持久化机制(RDB vs AOF)如何选择?结合PHP高并发项目,说明持久化对业务的影响及优化方案。
答案解析
Redis是基于内存的数据库,断电/宕机会丢失数据,持久化机制的核心作用:将内存中的数据持久化到磁盘,重启后恢复数据,保证数据可靠性。Redis提供两种持久化方式:RDB(快照持久化) 和AOF( Append Only File,日志持久化),两者各有优劣,PHP高并发项目中需结合业务场景选择,或混合使用。
一、RDB vs AOF 核心区别(面试必背表格)
| 对比维度 | RDB(快照持久化) | AOF(日志持久化) |
|---|---|---|
| 核心原理 | 在指定时间间隔内,将内存中所有数据生成快照(.rdb文件),写入磁盘 | 将每一条写操作(SET、DEL等)记录到日志文件(.aof文件),重启后重新执行日志中的命令恢复数据 |
| 持久化频率 | 默认:900秒内1次写操作、300秒内10次写操作、60秒内10000次写操作(可配置),非实时 | 默认:每执行1条写操作就写入磁盘(实时);可配置为每秒写入(折中) |
| 文件大小 | 小(二进制压缩存储,占用磁盘空间少) | 大(文本格式,记录每一条命令,长期运行会膨胀) |
| 恢复速度 | 快(直接加载快照文件,无需执行命令) | 慢(需逐条执行日志中的命令,文件越大恢复越慢) |
| 数据安全性 | 低(非实时,宕机可能丢失最后一次快照后的所有数据) | 高(实时/准实时,宕机最多丢失1秒内的数据) |
| 对性能影响 | 小(生成快照时会fork子进程,仅fork瞬间阻塞主进程,不影响正常写操作) | 大(实时写入会频繁IO,高并发写场景下会阻塞主进程) |
| 适用场景 | 数据一致性要求低、追求性能、需要快速恢复的场景 | 数据一致性要求高、可接受性能损耗的场景 |
二、如何选择?(PHP高并发项目实战建议)
核心原则:根据数据一致性要求和并发量选择,优先推荐"RDB+AOF混合持久化"(Redis 4.0+支持),兼顾性能和数据安全性。
-
仅用RDB:适合非核心业务(如Excel导入的临时数据、访问日志缓存),数据丢失不影响核心流程,追求高并发写入性能;
-
仅用AOF:适合核心业务(如用户余额、订单数据),数据一致性要求高,可接受轻微性能损耗;
-
RDB+AOF混合(推荐):核心业务首选,优势:
- 持久化:AOF保证数据安全性(实时),RDB作为兜底(快照备份); - 恢复:重启时先加载RDB文件(快速恢复大部分数据),再执行AOF日志中RDB之后的命令(补充少量丢失的数据),兼顾恢复速度和数据安全性。
三、PHP高并发项目中,持久化对业务的影响及优化方案(面试重点)
1. 持久化对业务的负面影响(高频问题)
-
RDB的影响:fork子进程生成快照时,若Redis内存较大(如10GB),fork操作会消耗大量CPU和内存,导致主进程短暂阻塞(毫秒级),高并发写场景下(如Excel批量导入),会出现请求超时;
-
AOF的影响:实时写入(appendfsync always)会导致频繁磁盘IO,高并发写时,Redis主进程阻塞,请求响应变慢;AOF文件长期运行会膨胀(如几十GB),恢复速度慢,且占用大量磁盘空间。
2. 优化方案(可落地,面试加分)
php
// -------------- 1. RDB优化(PHP高并发场景适配)--------------
// 核心配置(修改redis.conf)
# 调整快照触发频率(降低fork子进程频率,避免频繁阻塞)
save 3600 1 # 3600秒内1次写操作(1小时)
save 1800 10 # 1800秒内10次写操作(30分钟)
save 600 100 # 600秒内100次写操作(10分钟)
# 禁止手动触发快照(避免误操作)
stop-writes-on-bgsave-error yes
# 开启压缩(减少RDB文件大小,节省磁盘空间)
rdbcompression yes
// 业务层优化(PHP代码)
// 批量写入场景(如Excel导入),避免集中触发RDB快照
function batchImportExcelData($dataList) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
// 关闭RDB自动触发(导入完成后恢复)
$redis->config('set', 'save', '');
try {
// 批量写入Redis(高并发)
foreach ($dataList as $data) {
$key = "excel:data:{$data['id']}";
$redis->set($key, json_encode($data), 86400);
}
return ['code' => 1, 'msg' => '导入成功'];
} catch (Exception $e) {
return ['code' => 0, 'msg' => '导入失败:' . $e->getMessage()];
} finally {
// 恢复RDB配置(导入完成后)
$redis->config('set', 'save', '3600 1 1800 10 600 100');
// 手动触发一次快照(兜底,避免数据丢失)
$redis->bgSave(); // 后台生成快照,不阻塞主进程
}
}
// -------------- 2. AOF优化(PHP高并发场景适配)--------------
// 核心配置(修改redis.conf)
# 调整AOF写入策略(折中方案:每秒写入,兼顾性能和安全性)
appendfsync everysec
# 开启AOF重写(自动压缩AOF文件,避免文件膨胀)
auto-aof-rewrite-percentage 100 # 当AOF文件大小超过上次重写后100%(即翻倍)触发重写
auto-aof-rewrite-min-size 64mb # 当AOF文件大小超过64MB时,才触发重写
# 开启AOF重写时,不阻塞写操作(Redis 4.0+支持)
aof-rewrite-incremental-fsync yes
// 业务层优化(PHP代码)
// 1. 批量写操作合并(减少AOF日志条数,降低IO压力)
function batchSetData($dataList) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
// 关闭自动提交(批量操作完成后一次性提交)
$redis->multi();
foreach ($dataList as $key => $value) {
$redis->set($key, $value, 3600);
}
$redis->exec(); // 一次性提交所有写操作,仅记录1条批量命令到AOF
return ['code' => 1, 'msg' => '批量设置成功'];
}
// 2. 定时清理过期AOF日志(PHP定时任务,如每天凌晨执行)
function cleanAofLog() {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
// 手动触发AOF重写(压缩文件)
$redis->bgRewriteAof();
// 检查AOF文件大小,若超过阈值(如100GB),手动清理(需备份)
$aofSize = $redis->info('persistence')['aof_current_size'];
if ($aofSize > 1024 * 1024 * 1024 * 100) { // 100GB
// 备份AOF文件(避免清理失败导致数据丢失)
copy('/var/lib/redis/appendonly.aof', '/var/lib/redis/appendonly.aof.bak');
// 触发重写,清理无效命令
$redis->bgRewriteAof();
}
echo "AOF日志清理完成,当前大小:" . round($aofSize / 1024 / 1024, 2) . "MB\n";
}
// -------------- 3. 混合持久化配置(推荐)--------------
// 修改redis.conf,开启混合持久化(Redis 4.0+)
aof-use-rdb-preamble yes
// 作用:AOF文件开头包含RDB快照内容,重启时先加载RDB,再执行AOF命令,兼顾恢复速度和数据安全性
?>
场景延伸(面试高频追问)
-
Excel批量导入场景优化:导入时关闭RDB自动触发,批量写入完成后手动触发bgSave(后台快照),避免导入过程中fork子进程阻塞主进程;同时开启AOF everysec策略,确保导入数据不丢失。
-
高并发写场景(如秒杀):优先用RDB+AOF混合持久化,AOF设置为everysec,RDB降低触发频率;同时部署Redis主从集群,主库关闭持久化(提升性能),从库开启持久化(备份数据),避免主库持久化影响并发性能。
-
数据恢复实战:Redis宕机后,优先用混合持久化的AOF文件恢复(先加载RDB,再执行AOF命令);若AOF文件损坏,用redis-check-aof工具修复,修复失败则用RDB文件兜底恢复。
9. 用PHP实现Redis的"限流功能"(如接口每秒最多100次请求),常见算法(令牌桶/漏桶)的代码实现
答案解析
限流核心作用:在PHP高并发场景中,限制接口的请求频率(如每秒最多100次请求),避免接口被恶意请求/高并发请求压垮,保护后端服务(如Redis、数据库)。
Redis实现限流的优势:基于内存,性能高、支持分布式(多台PHP服务器部署时,可实现全局限流);
常见限流算法:**令牌桶算法** 和 **漏桶算法**,两者适配不同场景,面试需掌握两种算法的实现和区别。
一、两种核心算法(定义+区别,面试必背)
-
令牌桶算法:
-
核心:系统按固定频率(如每秒100个)向桶中放入令牌,每个请求需从桶中获取1个令牌,获取到则允许访问,无令牌则拒绝/限流;
-
特点:支持突发流量(桶中可积累一定数量的令牌,突发请求可一次性获取多个令牌),适合API接口限流(如Excel导入接口、查询接口);
-
适用场景:接口请求频率不稳定,允许短暂突发流量的场景。
-
-
漏桶算法:
-
核心:请求像水流一样进入漏桶,漏桶按固定频率(如每秒100个)将请求漏出(允许访问),请求超过漏桶容量则溢出(拒绝/限流);
-
特点:不允许突发流量,严格控制请求的输出频率,适合写入类接口(如订单提交、Excel数据入库);
-
适用场景:请求频率波动大,需严格控制后端处理压力的场景。
-
二、PHP+Redis代码实现(可直接运行,面试标准写法)
1. 令牌桶算法(最常用,适配API接口限流,如每秒最多100次请求)
php
<?php
/**
* Redis令牌桶算法实现限流
* @param string $key 限流key(如接口路径、IP地址,区分不同限流维度)
* @param int $rate 令牌生成频率(每秒生成多少个令牌,如100)
* @param int $capacity 令牌桶容量(最多可积累多少个令牌,如200)
* @return bool true=允许访问,false=限流
*/
function redisTokenBucketLimit($key, $rate = 100, $capacity = 200) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
// 定义Redis中的key(存储令牌桶相关数据)
$tokenKey = "limit:token:$key"; // 存储当前桶中令牌数量
$timeKey = "limit:time:$key"; // 存储上次生成令牌的时间戳
// 1. 获取当前令牌数量和上次生成时间
$currentToken = $redis->get($tokenKey) ?: 0;
$lastTime = $redis->get($timeKey) ?: time();
// 2. 计算当前时间与上次生成时间的差值,生成新的令牌
$now = time();
$timeDiff = $now - $lastTime; // 时间差(秒)
// 生成新令牌数量 = 时间差 * 令牌生成频率(最多不超过桶容量)
$newToken = min($capacity, $currentToken + $timeDiff * $rate);
// 3. 更新令牌数量和上次生成时间(原子操作,避免并发问题)
$redis->multi();
$redis->set($tokenKey, $newToken);
$redis->set($timeKey, $now);
$redis->exec();
// 4. 判断是否有可用令牌(有则消耗1个令牌,允许访问)
if ($newToken > 0) {
$redis->decr($tokenKey); // 消耗1个令牌
return true;
}
// 无令牌,限流
return false;
}
// -------------- 使用示例(接口限流)--------------
// 场景1:按接口路径限流(如Excel导入接口,每秒最多100次请求)
$apiKey = "api:excel:import";
if (!redisTokenBucketLimit($apiKey, 100, 200)) {
echo json_encode(['code' => 429, 'msg' => '请求过于频繁,请稍后再试', 'retryAfter' => 1]);
exit;
}
// 接口正常逻辑(Excel导入)
echo json_encode(['code' => 200, 'msg' => '导入请求已接收,正在处理']);
// 场景2:按IP地址限流(单个IP每秒最多10次请求,防止恶意请求)
$ip = $_SERVER['REMOTE_ADDR'];
$ipKey = "limit:ip:$ip";
if (!redisTokenBucketLimit($ipKey, 10, 20)) {
echo json_encode(['code' => 429, 'msg' => 'IP请求过于频繁,请稍后再试']);
exit;
}
?>
2. 漏桶算法(适配写入类接口,如Excel数据入库,严格控制每秒100次请求)
php
<?php
/**
* Redis漏桶算法实现限流
* @param string $key 限流key(如接口路径、用户ID)
* @param int $rate 漏桶流出频率(每秒允许多少个请求,如100)
* @param int $capacity 漏桶容量(最多可容纳多少个请求,如200)
* @return bool true=允许访问,false=限流
*/
function redisLeakyBucketLimit($key, $rate = 100, $capacity = 200) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
// 定义Redis中的key
$bucketKey = "limit:leaky:$key"; // 存储当前漏桶中的请求数量
// 1. 获取当前漏桶中的请求数量(原子操作,避免并发问题)
$currentCount = $redis->incr($bucketKey); // 先将请求数量+1(尝试入桶)
// 2. 若为第一次入桶,设置漏桶过期时间(控制流出频率)
if ($currentCount == 1) {
// 过期时间 = 漏桶容量 / 流出频率(向上取整),确保所有请求都能被处理
$expireTime = ceil($capacity / $rate);
$redis->expire($bucketKey, $expireTime);
}
// 3. 判断请求是否超过漏桶容量(超过则限流)
if ($currentCount > $capacity) {
return false;
}
// 4. 控制流出频率(核心:通过过期时间和请求数量,实现固定频率流出)
// 原理:漏桶中的请求数量会随时间减少(Redis自动过期),确保每秒流出$rate个请求
return true;
}
// -------------- 使用示例(写入类接口限流)--------------
// 场景:Excel数据入库接口,严格控制每秒100次请求,避免压垮数据库
$apiKey = "api:excel:insert";
if (!redisLeakyBucketLimit($apiKey, 100, 200)) {
echo json_encode(['code' => 429, 'msg' => '请求过于频繁,请稍后再试', 'retryAfter' => 1]);
exit;
}
// 接口正常逻辑(Excel数据入库)
insertExcelDataToDb($_POST['data']);
echo json_encode(['code' => 200, 'msg' => '数据入库成功']);
// 模拟数据入库方法
function insertExcelDataToDb($data) {
// 实际项目中用PDO批量写入数据库
$pdo = new PDO("mysql:host=127.0.0.1;dbname=test;charset=utf8", "root", "123456");
// 省略批量写入逻辑...
}
?>
三、限流优化(面试加分,可落地)
-
限流维度优化:支持多维度限流(IP+接口路径+用户ID),如"单个用户+单个接口,每秒最多10次请求",避免单一维度限流的漏洞;
-
限流降级策略:限流时返回友好提示(如429状态码、重试时间),而非直接拒绝,提升用户体验;核心接口限流后,可返回缓存数据兜底;
-
分布式限流适配:多台PHP服务器部署时,用Redis的全局key(如limit:api:excel:import)实现全局限流,避免单台服务器单独限流导致的整体超量;
-
动态调整限流参数:通过PHP定时任务,根据接口请求量动态调整 r a t e 和 rate和 rate和capacity(如高峰期提升容量,低峰期降低容量)。
场景延伸(面试高频追问)
-
Excel导入场景适配:批量导入接口用令牌桶算法 (允许短暂突发流量,如用户同时上传多个Excel);单条数据入库接口用漏桶算法(严格控制写入频率,避免压垮数据库);
-
恶意请求拦截:结合IP限流+令牌桶算法,单个IP每秒最多10次请求,防止恶意爬虫/攻击请求压垮接口;
-
替代方案:高并发极致场景(如10万QPS),可使用Redis Cluster+Lua脚本实现限流(Lua脚本保证原子性,提升性能);PHP中可通过eval()方法执行Lua脚本。