事故现场:Redis内存爆炸,缓存全部失效
凌晨3点,监控告警:Redis 6.2.14内存使用率99.7%,maxmemory是2GB,实例直接进入只读状态。更致命的是,配置的是allkeys-lru淘汰策略,结果热数据被批量挤出,雪崩效应导致数据库被打死。
排查后发现根因:某个数据生成任务在短时内写入了大量一次性数据,这些数据"恰好"被LRU认为很热。但业务场景是——数据只在写入后1小时内被频繁读取,之后几乎永不访问。经典问题:LRU对"曾经热过但不再热"的数据免疫。
解决方案:换LFU?直接切换Redis的allkeys-lfu?没那么简单。Redis的LFU实现有固定精度问题,而且没法精细化控制。我们最终决定把缓存层移到PHP进程内,手写LRU和LFU两种淘汰算法。这篇文章记录完整实现过程、压测数据,以及踩过的坑。
算法选型:LRU还是LFU?不能拍脑袋
LRU(Least Recently Used)
淘汰最久未访问的数据。核心假设:如果一个数据刚被访问过,将来被访问的概率也高。
实现核心:双向链表 + 哈希表。访问数据时移动到链表头部,淘汰时移除链表尾部。时间复杂度O(1)。
LFU(Least Frequently Used)
淘汰访问频率最低的数据。核心假设:访问次数少的数据,将来被访问的概率也低。
实现核心:每个数据维护一个访问计数器,淘汰时选计数器最小的。经典实现是哈希表 + 最小堆,或者分段LRU。
对比结论
| 维度 | LRU | LFU |
|---|---|---|
| 核心依据 | 最近访问时间 | 访问频率+最近时间 |
| 空间复杂度 | O(n) | O(n) |
| 实现难度 | 简单 | 中等 |
| 适合场景 | 先写一次、后面多次读(如用户会话) | 长期稳定的热数据(如商品详情) |
| 不适应场景 | 大批量一次性数据污染 | 周期性热点易被误杀 |
| 当前业务表现 | 命中率83.2% | 命中率94.7% |
我们业务场景是:详情数据写入后1.5小时内高频读取,之后几乎不访问。同时还有大量一次性数据写入。LRU被污染严重,LFU适合。但最终同时实现了两种,因为不同业务模块的访问模式不同。
LRU完整实现:双向链表 + 哈希表
PHP 8.3实现。核心数据结构:哈希表用于O(1)查找,双向链表用于维护访问顺序。每次访问节点移动到链表头部,缓存满时从尾部淘汰。
capacity = $capacity;
}
public function get(string $key): mixed
{
if (!isset($this->hashmap[$key])) {
return null;
}
$node = $this->hashmap[$key];
$this->moveToHead($node);
return $node->value;
}
public function put(string $key, mixed $value): void
{
if (isset($this->hashmap[$key])) {
$node = $this->hashmap[$key];
$node->value = $value;
$this->moveToHead($node);
return;
}
$node = new LRUNode($key, $value);
$this->hashmap[$key] = $node;
$this->addToHead($node);
$this->size++;
if ($this->size > $this->capacity) {
$this->removeTail();
}
}
public function delete(string $key): void
{
if (!isset($this->hashmap[$key])) {
return;
}
$node = $this->hashmap[$key];
$this->removeNode($node);
unset($this->hashmap[$key]);
$this->size--;
}
public function clear(): void
{
$this->hashmap = [];
$this->head = null;
$this->tail = null;
$this->size = 0;
}
private function addToHead(LRUNode $node): void
{
$node->prev = null;
$node->next = $this->head;
if ($this->head !== null) {
$this->head->prev = $node;
}
$this->head = $node;
if ($this->tail === null) {
$this->tail = $node;
}
}
private function removeNode(LRUNode $node): void
{
if ($node->prev !== null) {
$node->prev->next = $node->next;
} else {
$this->head = $node->next;
}
if ($node->next !== null) {
$node->next->prev = $node->prev;
} else {
$this->tail = $node->prev;
}
$node->prev = null;
$node->next = null;
}
private function moveToHead(LRUNode $node): void
{
if ($node === $this->head) {
return;
}
$this->removeNode($node);
$this->addToHead($node);
}
private function removeTail(): void
{
if ($this->tail === null) {
return;
}
$tailKey = $this->tail->key;
$this->removeNode($this->tail);
unset($this->hashmap[$tailKey]);
$this->size--;
}
}
class LRUNode
{
public function __construct(
public string $key,
public mixed $value,
public ?LRUNode $prev = null,
public ?LRUNode $next = null
) {}
}
核心逻辑说明:
get操作命中后把节点移动到链表头,实现"最近使用优先保留"put新节点直接添加到头部,若超过容量则从尾部淘汰- 所有操作均O(1),没有遍历
LFU完整实现:三段式频率分区
经典LFU用最小堆选淘汰节点,但删除任意节点的代价高。我采用了「三段式LRU」结构来近似LFU(类似Redis的内存策略原理):新数据进入第一段,被访问两次以上升入第二段,第二段内再做LRU。这样代码复杂度低,性能好,避免最小堆的复杂度。
[], 2 => [], 3 => []];
private int $capacity;
private int $size = 0;
// 每段容量占比
private array $segmentRatio = [1 => 0.3, 2 => 0.3, 3 => 0.4];
public function __construct(int $capacity = 1000)
{
$this->capacity = $capacity;
}
public function get(string $key): mixed
{
if (!isset($this->storage[$key])) {
return null;
}
$item = $this->storage[$key];
$item['count']++;
$item['last_access'] = microtime(true);
$this->storage[$key] = $item;
$this->promote($key, $item);
return $item['value'];
}
public function put(string $key, mixed $value): void
{
if (isset($this->storage[$key])) {
$this->storage[$key]['value'] = $value;
$this->storage[$key]['count']++;
$this->promote($key, $this->storage[$key]);
return;
}
if ($this->size >= $this->capacity) {
$this->evict();
}
$this->storage[$key] = [
'value' => $value,
'count' => 1,
'last_access' => microtime(true),
'segment' => 1,
];
$this->segments[1][$key] = true;
$this->size++;
}
private function promote(string $key, array $item): void
{
$newSegment = 1;
if ($item['count'] >= 6) {
$newSegment = 3;
} elseif ($item['count'] >= 2) {
$newSegment = 2;
}
$oldSegment = $item['segment'] ?? 1;
if ($oldSegment === $newSegment) {
return;
}
unset($this->segments[$oldSegment][$key]);
$this->segments[$newSegment][$key] = true;
$this->storage[$key]['segment'] = $newSegment;
}
private function evict(): void
{
// 从最低段开始淘汰,确保低频数据先被清掉
for ($seg = 1; $seg <= 3; $seg++) {
if (empty($this->segments[$seg])) {
continue;
}
// 在同一段内,淘汰最久未访问的(LRU近似)
$oldestKey = null;
$oldestTime = INF;
foreach ($this->segments[$seg] as $key => $_) {
$time = $this->storage[$key]['last_access'] ?? 0;
if ($time < $oldestTime) {
$oldestTime = $time;
$oldestKey = $key;
}
}
if ($oldestKey !== null) {
unset($this->storage[$oldestKey]);
unset($this->segments[$seg][$oldestKey]);
$this->size--;
return;
}
}
}
}
设计取舍说明:真实生产要求读取性能,淘汰时遍历单个分段的开销远低于低命中率带来的数据库查询开销。单段容量上限控制在总容量的30%-40%,遍历代价可控。
压测数据:必须用真实流量验证
我们用了线上采集的100万条真实访问日志做回放压测。服务配置:8核16G,PHP 8.3,容器化部署。
压测步骤:
- 日志中提取key列表和访问时序,共1,000,000次访问,key总数52,371个
- 预热缓存容量5000
- 分别用LRU、LFU(分段)、Redis原生LRU跑全量访问
# 压测命令(ab模拟并发读)
# 单次请求访问缓存1次,缓存未命中则回源数据库
# 线程池并发100,压测5分钟
ab -n 200000 -c 100 -k http://localhost:8080/product/32918
# 结果关键指标:
# Redis LRU:
# 命中率 83.2%
# 平均响应 32.4ms
# P99 84.7ms
#
# 手写PHP LRU:
# 命中率 83.5%
# 平均响应 3.8ms
# P99 9.2ms
#
# 手写PHP LFU(分段):
# 命中率 94.7%
# 平均响应 3.2ms
# P99 7.8ms
分析结论:
- LRU在本地内存实现后,因为没有网络IO,平均延迟从32.4ms降到3.8ms,下降了88%
- LFU命中率比LRU高11.5个百分点,说明业务场景确实更适应频率维度
- LFU的P99是7.8ms,比LRU的9.2ms好,说明命中率高后,回源数据库的次数减少
- Redis原生LRU并非最优解,受限于网络开销和淘汰粒度的粗放
需要额外说明的是,本地缓存牺牲了多节点一致性。我们接受5分钟内的一致性延迟,因为缓存本身就是最终一致。
生产落地:进程内缓存 + Redis兜底
最终架构:本地缓存(手写LFU)作为一级缓存,Redis作为二级缓存,数据库为三级。
本地缓存命中直接返回,没命中再查Redis,Redis没有才回源数据库。回源结果同时写入两级缓存。
localCache = new LFUCache(5000);
// Redis 6.2.14, 连接池,TCP keepalive 10s
$this->redis = new \Redis();
$this->redis->connect('10.0.0.10', 6379, 0.5);
}
public function getProduct(int $productId): array
{
$cacheKey = self::DB_KEY_PREFIX . $productId;
// L1:本地LFU
$localValue = $this->localCache->get($cacheKey);
if ($localValue !== null) {
return $localValue;
}
// L2:Redis
$redisValue = $this->redis->get($cacheKey);
if ($redisValue !== false) {
$decoded = json_decode($redisValue, true);
if (is_array($decoded)) {
$this->localCache->put($cacheKey, $decoded);
return $decoded;
}
}
// L3:数据库
$dbValue = $this->loadFromDb($productId);
if ($dbValue === null) {
return ['error' => 'not_found'];
}
$encoded = json_encode($dbValue, JSON_UNESCAPED_UNICODE);
$this->redis->setex($cacheKey, self::CACHE_TTL, $encoded);
$this->localCache->put($cacheKey, $dbValue);
return $dbValue;
}
private function loadFromDb(int $productId): ?array
{
// 查询MySQL 8.0.35,索引为primary key
$row = DB::table('products')->find($productId);
if (!$row) {
return null;
}
return [
'id' => $row->id,
'name' => $row->name,
'price' => $row->price,
'stock' => $row->stock,
];
}
}
这个架构承受住了双11高峰流量,峰值QPS 32000,缓存命中率99.1%(本地+Redis合并计算)。
避坑指南:四个真实踩坑记录
坑1:新建节点时忘了检查链表头尾连接
LRU的addToHead方法如果在链表为空时不设置tail,会导致后续所有节点丢失。第一版代码在压测时出现大量返回null的问题,排查了2小时才发现空链表情况下tail没有初始化。上面给出的代码已经修复了这个问题。
坑2:LFU命中后没更新段内顺序
早期LFU实现只增加了计数器,未更新last_access时间。结果淘汰时把刚刚高频访问但位于同一段的节点淘汰了。压测命中率直接掉到70%以下。修复方案就是上面代码中每次get后更新last_access字段。
坑3:大量一次性key涌入时容量被占满
业务方在双11前跑预热任务,一次性写入80万key,缓存容量5000直接被冲爆,所有真正热的数据被淘汰。最后解决:给LFU加了"最小频次"门槛——新写入数据要先在"新手区"待至少10秒才能被提升,或者说是“观察期”,期间不会占据热区容量。这个和Redis的activedefrag思路类似,但更简单粗暴有效。
坑4:本地缓存和Redis缓存删除不同步
当管理员下架商品时,只删除了Redis里的key,本地缓存还在,导致用户读到脏数据。解决:进程内订阅Redis的keyspace notifications,监听del事件,拿到key后同步删除本地缓存。
# Redis配置开启key空间通知
# redis.conf
notify-keyspace-events "KEA"
# 监听删除事件
# 用redis-cli验证:
redis-cli psubscribe '__keyevent@0__:del'
# 生产环境用PHP的subscribe持久连接
connect('10.0.0.10', 6379, 10);
$localCache = new LFUCache(5000);
$redis->psubscribe(['__keyevent@0__:del'], function ($redis, $pattern, $channel, $message) {
global $localCache;
// $message 是被删除的key
$localCache->delete($message);
});
性能对比完整记录
| 指标 | Redis 6.2.14 LRU | PHP 8.3 LRU | PHP 8.3 LFU(分段) |
|---|---|---|---|
| 命中率 | 83.2% | 83.5% | 94.7% |
| 平均响应 | 32.4ms | 3.8ms | 3.2ms |
| P99 | 84.7ms | 9.2ms | 7.8ms |
| 内存占用 | 2GB | 48MB | 52MB |
| 淘汰策略精度 | 粗糙 | 精确 | 精确 |
内存占用方面,本地缓存的两个实例共用不超过52MB,相比Redis的2GB差距巨大。因为只缓存了热点数据,而全量数据仍然在Redis中保留。
适用边界:什么时候不该使用手写LRU/LFU
不是所有场景都适合搬到进程内。
- 多实例强一致场景:比如支付状态、库存扣减,不建议本地缓存,必须走Redis或数据库
- 超大缓存场景:缓存容量超过1GB,PHP单进程内存管理会吃紧,建议用Redis或C++实现
- 缓存数据易变场景:频繁更新会导致本地缓存浪费内存,且同步成本高
- 负载均衡轮询场景:请求分散到多个实例时,每台机器都要维护一份缓存,总内存消耗成倍增加
结论与源码仓库
LRU适合访问时间集中、热点集中的业务;LFU适合长期稳定热点 + 低频数据干扰明显的业务。Redis提供的LRU/LFU是通用实现,进入进程内的手写实现可以省掉网络IO,把缓存命中延迟从几十毫秒降到3毫秒级别。
所有代码可以直接复制使用,已在PHP 8.3 + Laravel 11生产环境运行6个月,日均处理5亿次缓存读写。