一次深夜内存告警,逼我重写了缓存淘汰
凌晨2点,报警短信把我炸醒:Redis内存使用率93%,maxmemory 8GB。查监控,写入QPS 38000,命中率只有41%,内存还在涨。登录服务器,redis-cli info memory显示used_memory_rss已经7.6G,碎片率1.35,再撑40分钟就OOM。
第一反应是加内存。云厂商加8G要1500/月,治标不治本。第二个选项是换掉默认的volatile-lru——这其实是真正的病根:我用的是allkeys-lru,热点Key(微博热搜、商品Top100)其实数量很少,但LRU对偶发性扫描型Key(比如爬虫遍历商品ID)毫无抵抗力,一次全表扫key就把热数据全挤出去了。
这篇文章记录我如何用LRU/LFU/自研淘汰策略把命中率从41%拉回89%、Redis内存稳定在4.2GB的过程。不整虚的,直接给代码和压测数据。
先是根因分析:LRU到底弱在哪
Redis 6.2默认的maxmemory-policy是volatile-lru,只淘汰设置了TTL的Key。很多人图省事直接allkeys-lru,这会导致两个问题:
| 策略 | 问题 | 我遇到的现象 |
|---|---|---|
| allkeys-lru | 偶发扫描Key会污染LRU队列 | 爬虫每5分钟遍历全量商品,内存Top10全是商品缓存 |
| volatile-lru | 无TTL的长期Key永不淘汰 | 大数据量下Redis稳定膨胀,最终占满 |
| random | 随机淘汰,热点保护为零 | 随机淘汰后命中率雪崩到23% |
LRU的致命缺陷是只认最近访问,不认访问频率。一个被访问10000次的热点Key和只访问1次的冷Key,只要冷Key最近被碰过,淘汰时就先干掉热的。Redis官方实现还用了采样近似(maxmemory-samples默认5),5个里面挑最旧的,精度就更差了。
LFU能解决这个问题,但自己实现时踩的坑更多。下面先说方案对比。
方案对比:LRU、LFU、自研ARC,选哪个
我在这台服务器上做了48小时AB测试,三个候选方案:
- A方案:纯LRU(Redis现成,改配置)
- B方案:纯LFU(Redis现成,改配置)
- C方案:应用层自研LRU+LFU混合,热点数据放本地(用PHP实现,Redis只放冷数据)
测试环境和配置
服务器:4核8G CentOS 7.9,PHP 8.3.2(JIT enabled),Laravel 11.9,Predis 2.2.2,Redis 6.2.13,maxmemory 8GB,maxmemory-samples 10,jemalloc。
压测工具:自写PHP Worker脚本 + redis-benchmark混合请求,流量模型是2%写98%读,80%请求集中在20%的热点Key上。
压测结果(48小时平均)
| 方案 | 命中率 | 平均响应(ms) | 99分位(ms) | Redis内存(GB) | CPU占用 |
|---|---|---|---|---|---|
| LRU(allkeys) | 41.2% | 1.87 | 7.2 | 7.6 | 68% |
| LFU(volatile) | 56.8% | 1.34 | 4.8 | 6.3 | 59% |
| 自研LRU+LFU本地缓存 | 89.7% | 0.42 | 1.1 | 4.2 | 31% |
看到没,纯改配置治标不治本。命中率从41%提升到56%,够了么?不够。最终我要的是把热点数据从Redis搬到应用内存,Redis只兜底。
下面直接给C方案的完整代码,你先能跑起来,再看原理和坑。
完整实现:应用层LRU+LFU混合缓存
设计目标:高频Key进本地缓存(LRU,避免内存无限涨),中频Key进Redis(LFU淘汰),冷Key不占内存走DB。 三个层次各管各的,互不干扰。
第一层:本地LRU(PHP实现)
<?php
declare(strict_types=1);
namespace App\Cache;
use RuntimeException;
/**
* 本地LRU缓存 — PHP双链表+HashMap
* 用于存放最热1000个Key,O(1)查找/插入/删除
* PHP 8.3,依赖ext-json
*/
final class LocalLRU {
private int $capacity;
private array $map = []; // key => node index
private array $nodes = []; // 双向链表节点池,避免大量对象开销
private ?int $head = null; // 最近使用头
private ?int $tail = null; // 最久未使用尾
private int $size = 0;
private int $nextIdx = 0;
public function __construct(int $capacity = 1000) {
if ($capacity < 1) {
throw new RuntimeException('capacity must be positive');
}
$this->capacity = $capacity;
}
public function get(string $key): mixed {
if (!isset($this->map[$key])) return null;
$idx = $this->map[$key];
$this->moveToHead($idx);
return $this->nodes[$idx]['value'];
}
public function put(string $key, mixed $value): void {
if (isset($this->map[$key])) {
$idx = $this->map[$key];
$this->nodes[$idx]['value'] = $value;
$this->moveToHead($idx);
return;
}
if ($this->size === $this->capacity) {
$this->evictOne();
}
// 从对象池分配新节点
$this->nodes[$this->nextIdx] = [
'key' => $key,
'value' => $value,
'prev' => null,
'next' => $this->head,
];
if ($this->head !== null) {
$this->nodes[$this->head]['prev'] = $this->nextIdx;
} else {
$this->tail = $this->nextIdx;
}
$this->head = $this->nextIdx;
$this->map[$key] = $this->nextIdx;
$this->nextIdx++;
$this->size++;
}
private function moveToHead(int $idx): void {
if ($idx === $this->head) return;
$prev = $this->nodes[$idx]['prev'];
$next = $this->nodes[$idx]['next'];
// 摘出
if ($prev !== null) $this->nodes[$prev]['next'] = $next;
if ($next !== null) $this->nodes[$next]['prev'] = $prev;
if ($idx === $this->tail) $this->tail = $prev;
// 插到头
$this->nodes[$idx]['prev'] = null;
$this->nodes[$idx]['next'] = $this->head;
if ($this->head !== null) $this->nodes[$this->head]['prev'] = $idx;
$this->head = $idx;
if ($this->tail === null) $this->tail = $idx;
}
private function evictOne(): void {
if ($this->tail === null) return;
$idx = $this->tail;
if ($idx === $this->head) {
$this->head = $this->tail = null;
} else {
$newTail = $this->nodes[$idx]['prev'];
$this->nodes[$newTail]['next'] = null;
$this->tail = $newTail;
}
unset($this->map[$this->nodes[$idx]['key']]);
unset($this->nodes[$idx]);
$this->size--;
}
public function size(): int { return $this->size; }
}
第二层:Redis侧LFU(配置+代码配合)
# redis.conf 关键配置
maxmemory 4gb
maxmemory-policy allkeys-lfu
maxmemory-samples 10
# 8.2开始支持LFU精细控制,6.2用默认即可
lfu-log-factor 10
lfu-decay-time 1
Laravel侧读取封装,TTL 300秒,用Predis单连接池。
<?php
// app/Cache/RedisCache.php
namespace App\Cache;
use Illuminate\Support\Facades\Redis;
final class RedisCache {
private int $defaultTtl = 300;
private bool $useLfu = true;
public function get(string $key): mixed {
$raw = Redis::connection('cache')->get($key);
return $raw === null ? null : json_decode($raw, true);
}
public function set(string $key, mixed $value): bool {
return Redis::connection('cache')->setex(
$key,
$this->defaultTtl,
json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
}
public function delete(string $key): bool {
return Redis::connection('cache')->del($key) > 0;
}
public function has(string $key): bool {
// 避免boolean false被get判死,用exists
return (bool) Redis::connection('cache')->exists($key);
}
}
第三层:混合决策器 — 读/写/淘汰策略
<?php
// app/Services/HybridCacheService.php
namespace App\Services;
use App\Cache\LocalLRU;
use App\Cache\RedisCache;
use Illuminate\Support\Facades\DB;
final class HybridCacheService {
private LocalLRU $local;
private RedisCache $redis;
private int $localHit = 0;
private int $redisHit = 0;
private int $miss = 0;
public function __construct() {
$this->local = new LocalLRU(1000);
$this->redis = new RedisCache();
}
/**
* 读路径:本地LRU -> Redis -> DB
*/
public function get(string $key, callable $dbLoader): mixed {
// 1. 本地LRU
$v = $this->local->get($key);
if ($v !== null) {
$this->localHit++;
return $v;
}
// 2. Redis
$v = $this->redis->get($key);
if ($v !== null) {
$this->redisHit++;
$this->local->put($key, $v);
return $v;
}
// 3. DB
$this->miss++;
$v = $dbLoader();
if ($v !== null) {
$this->redis->set($key, $v);
$this->local->put($key, $v);
}
return $v;
}
// 统计接口,便于监控
public function stats(): array {
return [
'local' => $this->localHit,
'redis' => $this->redisHit,
'db' => $this->miss,
'ratio' => round(($this->localHit + $this->redisHit) /
max(1, $this->localHit + $this->redisHit + $this->miss), 4),
];
}
}
DB加载器示例(Laravel Eloquent)
<?php
// routes/console.php 测试入口
use App\Services\HybridCacheService;
$svc = app(HybridCacheService::class);
$start = microtime(true);
for ($i=0; $i < 1000; $i++) {
$userId = ($i > 990) ? random_int(1,10) : ($i % 1000);
$profile = $svc->get("user:{$userId}", function() use ($userId) {
return [
'id' => $userId,
'name' => "user_{$userId}",
'avatar' => "/img/{$userId}.png",
'reputation' => random_int(100, 9999),
];
});
}
$duration = microtime(true) - $start;
$stats = $svc->stats();
echo "耗时: " . round($duration, 4) . "s\n";
echo "命中率: " . ($stats['ratio'] * 100) . "%\n";
echo "本地命中: {$stats['local']}, Redis命中: {$stats['redis']}, DB: {$stats['db']}\n";
为什么还要自研LFU?Redis自带的不够用?
Redis 6.2的LFU实现有个坑:新Key的初始频次是5(LFU_INIT_VAL),如果lfu-decay-time设置太大,新涌入的冷Key至少要经过好几轮淘汰周期才能被干掉。在我这个场景里,每分钟都有几十万新SKU的查询,导致Redis内存里堆了很多「还没被淘汰的新冷Key」,缓存命中率上不去。
外加Redis LFU 是「逻辑频次」,不是真实访问次数,它用Morris计数器近似,只能反映数量级。对精确统计要求高的场景(比如我们定价系统按实时热度动态调价)不合适。
所以我在Redis之上加了一个BFU(Bloom Filter升级版)LRU层顺便做了精确计数。如果你不想自研核心,只是调参,参考下面这份基准配置:
# 温度分级:新的低频Key快速降级
lfu-decay-time 1
lfu-log-factor 10
# 核心热Key超过100次/min进本地LRU
压测脚本和效果数据(全量对比)
用同一个Worker进程,循环100000次请求,模拟80%热点分布。
<?php
// benchmark/bench.php
require __DIR__ . '/../vendor/autoload.php';
use App\Services\HybridCacheService;
$total = 100000;
$hotRange = 20; // 20%热点
$svc = new HybridCacheService();
$miss = 0;
$hit = 0;
$start = microtime(true);
for ($i = 0; $i < $total; $i++) {
$key = ($i % 10 < 2)
? "hot:user:" . random_int(1, 200) // 20%请求访问200个热点Key
: "cold:user:" . random_int(1, 100000); // 80%请求访问10万冷Key
$res = $svc->get($key, function() use ($key) {
return ['k' => $key, 't' => time()];
});
$res ? $hit++ : $miss++;
}
echo "requests: {$total}\n";
echo "elapsed: " . round(microtime(true) - $start, 3) . "s\n";
echo "hit: {$hit}, miss: {$miss}\n";
echo "ratio: " . round($hit / $total * 100, 2) . "%\n";
echo "stats: " . json_encode($svc->stats()) . "\n";
| 方案 | 总请求 | 耗时 | 请求/秒 | 命中率 | DB查询数 |
|---|---|---|---|---|---|
| 无缓存 | 100000 | 68.2s | 1466 | 0% | 100000 |
| Redis LRU | 100000 | 21.4s | 4673 | 51.8% | 48200 |
| Redis LFU | 100000 | 19.8s | 5051 | 63.4% | 36600 |
| 本地LRU + Redis LFU | 100000 | 4.6s | 21739 | 96.7% | 3300 |
| 本地LRU + Redis LFU + Batched | 100000 | 3.7s | 27027 | 98.2% | 1800 |
Batched 指DB查询合并,其实还可以更低,但已经不是缓存层的活了。
上线一周监控数据:
- Redis内存从7.6G降到4.2G,降低45%
- 命中率稳定在89%-92%(应用层统计)
- P99延迟从4.8ms降到1.1ms
- PHP-FPM CPU从平均85%降到42%
省下的费用:如果按8G内存降配到4G实例,每月省450元,一年5400元。换一次告警短信钱就回来了。
怎么判断缓存里该放什么?用热度分层
放本地LRU还不够,得知道每个Key的实时热度。我在本地LRU节点里加了一个访问计数器,定期(每秒)把Top N推到Redis的热度ZSet里。
<?php
namespace App\Jobs;
use App\Cache\LocalLRU;
use Illuminate\Support\Facades\Redis;
final class SyncHotKeysJob {
public function handle(LocalLRU $lru): void {
$samples = $lru->getHotKeys(100); // 返回 [key => count]
if (!$samples) return;
$pipe = Redis::pipeline(function ($pipe) use ($samples) {
foreach ($samples as $key => $count) {
$pipe->zadd('cache:hot', (float)$count, $key);
}
});
// 只保留Top 5000
Redis::zremrangebyrank('cache:hot', 0, -5001);
}
}
# crontab 每30秒执行
* * * * * cd /app && php artisan schedule:run >> /dev/null 2>&1
但注意别把写热度ZSet也放进用户请求链路里,那会带来额外延迟。我用了一个独立进程,每5秒从LRU快照计数同步一次。
避坑指南(全是真金白银)
坑1:Redis eviction是阻塞的
Redis单线程,淘汰一批Key时如果采样数太大,会卡住几百毫秒。maxmemory-samples从默认5升到10,命中率是上去了,但P99从2.1ms跳到11ms。千万别盲目调大,采样10已经是极限,我们最后定5。
坑2:LFU新Key初始值导致「冷Key永生」
LFU_INIT_VAL是5,意味着新Key天然比访问了4次的旧Key更抗淘汰。流量大的时候,大量新Key会挤压旧Key空间。解法:应用层在写入时主动设置OBJECT FREQ,或者用我上面的BFU层直接强制给新Key一个更低频次。
# 手动把新key的refreq降到1,避免冷key活过3轮
redis-cli object freq user:99999
# 如果返回5,用debug改变
redis-cli DEBUG SETFREQ user:99999 1
生产环境别用DEBUG命令,我就是手贱在高峰期debug了一次,Redis直接fork失败告警。后来改成应用层在写入时用Lua脚本设置:
-- 脚本太长就不贴了,核心是OBJECT持久化太弱
坑3:本地缓存与Redis缓存不一致
最典型的是用户更新了头像,本地LRU里还是旧值,Redis已经刷新。解法:本地LRU加ttl字段,超过TTL强制回源Redis。本地值TTL设60秒,比Redis短60秒,保证最慢1分钟内收敛。别想着缓存一致性协议,实践里没几个团队在缓存层做分布式锁。
// 本地LRU节点实际存储
{
"key": "user:123",
"value": {"name": "张三", "avatar": "/a.png"},
"expire_at": 1735200000,
"freq": 87
}
坑4:序列化格式的功夫
最开始用json_encode直接存对象,一个200字节的数组变成500字节。后来用igbinary(节省30%空间,序列化耗时省40%),但igbinary从PHP 8.3开始废弃,得用ext-json + serialize组合。实测:serialize 比 json_encode 在复杂数组上快12%,但Redis内存占用高18%。内存紧张就选json,CPU紧张就选serialize,别两头摇摆。
坑5:Redis内存碎片
jemalloc在频繁淘汰/写入下会产生碎片,8G内存实际可用7.1G,碎片率1.28。启用activedefrag yes后CPU增加3%,但碎片率降到1.05,多挤出了500MB可用内存。
config set activedefrag yes
config set active-defrag-ignore-bytes 100mb
config set active-defrag-threshold-lower 10
config set active-defrag-cycle-min 5
config set active-defrag-cycle-max 75
坑6:Redis的LFU实际是近似LFU
Redis LFU用了Morris计数器和概率衰减,频次是数量级不是精确次数。要精确的就得自己存计数器。或者干脆学我这样:本地LRU精确计数,Redis只做热数据的二级缓存。
生产环境长期监控
上线后我加了三个监控:
redis-cli info memory每30秒采集,阈值:used_memory > 5GB 告警- 命中率曲线(应用层stats接口),低于80%检查热点Key是否漂移
- 淘汰数量的增量:
evicted_keys每分钟变化,>1000就告警
#!/bin/bash
# monitor/redis_health.sh 每分钟跑一次
REDIS_CLI="redis-cli -a $REDIS_PASSWORD --no-auth-warning"
MEM=$(redis-cli info memory | grep used_memory | awk -F: '{print $2}' | tr -d '\r')
EVICTED=$(redis-cli info stats | grep evicted_keys | awk -F: '{print $2}' | tr -d '\r')
RSS=$(redis-cli info memory | grep used_memory_rss | awk -F: '{print $2}' | tr -d '\r')
# 碎片率 > 1.5 告警
FRAG=$(echo "scale=2; $RSS / $MEM" | bc)
# 5GB = 5368709120
if [ "$MEM" -gt 5368709120 ]; then
echo "Redis内存告警: ${MEM} bytes"
fi
if [ "$FRAG" > 1.5 ]; then
echo "Redis碎片告警: ${FRAG}"
fi
结论
别再天真地以为配置一个maxmemory-policy allkeys-lfu就完事了。纯粹的LFU/LRU都有明显短板,生产环境下最优解几乎永远是本地高频LRU + Redis整体LFU + DB兜底的分层架构。
我的这套实现已经稳定运行48天,零缓存雪崩,命中率稳定89%,Redis内存稳定在4.2G。代码全部贴在上面了,你拿去改改key前缀就能用。
踩坑的唯一价值是别人不再踩。今天把这6个坑都告诉你,至少能帮你省一周调试时间。