API接口的响应速度直接影响用户体验,而大多数接口的数据并不需要每次都实时查询数据库。比如商品详情、文章列表、配置信息这类数据,短时间内变化不大,完全可以缓存起来。PHP配合Redis实现API请求缓存是最常见的做法,核心就两件事:一是给缓存设置合理的过期时间让它自动失效,二是建立一套主动刷新机制,让数据在用户无感知的情况下更新。本文结合实际代码,把这两件事讲透。

一、Redis过期时间的基本设置与缓存封装
Redis提供了EXPIRE、SETEX等命令来设置键的生存时间(TTL),时间一到Redis会自动删除该键,这就是缓存自动过期的基础。在PHP中使用phpredis扩展时,最常用的是setex方法,它把写入和设置过期时间合并成一次原子操作,避免了先set再expire中间出现异常导致键永不过期的风险。
先封装一个简单的缓存工具类,所有API接口的缓存读写都走这一个入口,方便后续统一控制过期策略:
<?php
class ApiCache
{
private $redis;
public function __construct()
{
$this->redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
$this->redis->auth('your_password');
$this->redis->select(0); // 选择数据库
}
/**
* 写入缓存,带过期时间
* @param string $key 缓存键
* @param mixed $data 任意可序列化的数据
* @param int $ttl 过期时间(秒)
*/
public function set(string $key, $data, int $ttl = 300): bool
{
return $this->redis->setex($key, $ttl, serialize($data));
}
/**
* 读取缓存,不存在返回null
*/
public function get(string $key)
{
$raw = $this->redis->get($key);
return $raw === false ? null : unserialize($raw);
}
/**
* 获取剩余生存时间
*/
public function ttl(string $key): int
{
$result = $this->redis->ttl($key);
return $result === false ? -1 : (int)$result;
}
}这个类看似简单,但有几个细节值得注意。数据用serialize序列化后存储,能完整保留数组和对象的类型结构;如果只缓存JSON字符串给前端直接输出,也可以跳过序列化直接存,省一次解码开销。TTL的默认值设为300秒只是一个参考,实际要根据业务对数据实时性的容忍度来定,比如热搜榜可以设60秒,商品详情可以设10分钟。
二、过期时间的进阶策略:随机TTL与逻辑过期
固定TTL有一个隐藏的隐患:如果一批缓存键在同一时刻写入,它们也会在同一时刻过期,瞬时大量请求直接打到数据库,这就是缓存雪崩。解决办法很简单,在基准TTL上加一个随机偏移量,让过期时间分散开:
<?php
// 基准过期时间600秒,随机上下浮动120秒
$baseTtl = 600;
$randomOffset = mt_rand(0, 120);
$realTtl = $baseTtl + $randomOffset;
$cache->set('goods:detail:1001', $goodsData, $realTtl);另一个思路是逻辑过期,也叫软过期。物理上不设置Redis的TTL,或者设置一个很长的兜底TTL,把过期时间作为字段存进缓存数据里。读取时判断逻辑时间是否到期:没到期直接返回;到期了先返回旧数据,同时触发一次异步刷新。这样用户永远能秒级拿到响应,刷新的延迟由后台消化。
<?php
// 逻辑过期的数据结构
$cacheData = [
'expire_at' => time() + 300, // 逻辑过期时间戳
'data' => $goodsData,
];
$cache->set('goods:detail:1001', $cacheData, 86400); // 物理 TTL 设一天兜底
// 读取时判断
public function getOrRefresh(string $key, callable $rebuildFunc)
{
$cached = $this->get($key);
if ($cached === null) {
return $rebuildFunc(); // 冷启动,直接重建
}
if ($cached['expire_at'] > time()) {
return $cached['data']; // 未到期,直接返回
}
// 已逻辑过期:返回旧数据,同时尝试异步刷新
$this->tryAsyncRefresh($key, $rebuildFunc);
return $cached['data'];
}逻辑过期特别适合高并发的热点接口,代价是用户可能短暂看到稍旧的数据。对价格、库存这种强一致性要求高的场景,还是应该用固定短TTL加互斥锁重建的方式,避免把过期数据当新数据卖出去。
三、主动刷新机制的完整实现
主动刷新的核心思想是:不要等到缓存过期后让用户请求触发重建,而是在缓存即将过期或者已经逻辑过期时,由系统主动去更新。触发方式有两种,一种是定时任务兜底,另一种是请求触发式刷新,两者可以结合使用。
请求触发式刷新需要一个互斥锁防止缓存击穿:缓存失效瞬间成百上千个请求同时发现缓存不存在,如果都去查数据库就出事了。用SET key value NX EX命令实现分布式锁,只有抢到锁的那个请求去重建缓存,其他请求短暂等待或直接返回旧值:
<?php
public function getWithLock(string $key, callable $rebuildFunc, int $ttl = 600)
{
$cached = $this->redis->get($key);
if ($cached !== false) {
return unserialize($cached);
}
$lockKey = $key . ':lock';
// 尝试加锁,10秒自动释放防止死锁
$locked = $this->redis->set($lockKey, getmypid(), ['nx', 'ex' => 10]);
if ($locked) {
try {
// 双重检查:拿到锁后再查一次,可能别人已重建好
$cached = $this->redis->get($key);
if ($cached !== false) {
return unserialize($cached);
}
$data = $rebuildFunc();
$this->redis->setex($key, $ttl + mt_rand(0, 60), serialize($data));
return $data;
} finally {
// 释放锁前先校验是不是自己的锁,防止误删
if ($this->redis->get($lockKey) == getmypid()) {
$this->redis->del($lockKey);
}
}
}
// 没抢到锁:短暂自旋等待,最多200毫秒
for ($i = 0; $i < 4; $i++) {
usleep(50000);
$cached = $this->redis->get($key);
if ($cached !== false) {
return unserialize($cached);
}
}
// 兜底:直接查库(或返回降级数据)
return $rebuildFunc();
}释放锁时的校验逻辑虽然不是严格原子的(生产环境建议用Lua脚本包一层),但已经能覆盖绝大多数误删场景。等待循环里的usleep(50000)表示每次等50毫秒,最多重试4次,总计200毫秒,对一般接口来说可以接受。
定时任务兜底则更简单粗暴:用crontab每分钟跑一个PHP脚本,扫描剩余TTL小于阈值的键提前刷新。比如设置一个辅助的ZSet记录所有缓存键的下次过期时间戳,定时任务从中取出即将到期的键逐个重建:
<?php
// 写缓存时登记过期时间
$redis->zAdd('cache:expire:index', time() + $ttl, $key);
// 定时任务:刷新2分钟内即将到期的缓存
$now = time();
$expireList = $redis-&rangingzRangeByScore('cache:expire:index', 0, $now + 120);
foreach ($expireList as $key) {
rebuildCacheForKey($key); // 具体业务重建逻辑
$redis->zRem('cache:expire:index', $key);
}四、场景选择与注意事项
不同业务对缓存的诉求不同,方案也应该灵活组合。低频访问的长尾数据,用固定TTL加互斥锁就够用了,代码简单维护成本低;高并发热点数据,用逻辑过期加异步刷新,把重建开销彻底从用户请求链路里剥离;对一致性敏感的数据,缩短TTL并在写操作时主动删除缓存,走Cache Aside模式。
还有几个容易踩的坑需要提醒。第一,缓存键的命名要有统一规范,建议按业务模块:实体:ID的格式,比如goods:detail:1001,方便排查问题和批量清理。第二,数据更新后记得删缓存而不是改缓存,删除更简单且不易出现并发写覆盖。第三,Redis的内存淘汰策略要确认是allkeys-lru或volatile-lru,如果配成了noeviction,内存满了会直接报错。第四,序列化方式建议统一,混用serialize和json_encode存同一个键会出乱子。
最后,缓存不是银弹,上线前要想清楚数据不一致窗口期能接受多长。把过期时间、刷新机制、降级策略这三件事设计好,一套稳定的API缓存体系就基本成型了。