缓存策略概览
缓存是提升系统性能和吞吐量的核心技术手段。通过将频繁访问的数据存储在高速存储介质中,缓存可以大幅降低数据库压力、减少响应延迟。本节系统性地讲解缓存的类型、淘汰策略、常见问题(穿透、击穿、雪崩)及解决方案,为后续学习具体缓存实现奠定理论基础。
缓存基础概念
什么是缓存
缓存(Cache)是一种将数据存储在高速存储介质中的技术,目的是加速数据读取速度、降低后端系统负载。缓存的本质是用空间换时间的权衡策略。
请求流程:
Client → [缓存层] → 命中 → 直接返回(毫秒级)
↓ 未命中
[数据库] → 读取数据 → 写入缓存 → 返回数据缓存的核心指标:
| 指标 | 说明 | 理想值 |
|---|---|---|
| 命中率(Hit Rate) | 缓存命中次数 / 总请求次数 | > 90% |
| 命中时间(Hit Time) | 从缓存读取数据的耗时 | < 1ms |
| 未命中时间(Miss Time) | 从数据库读取并写入缓存的耗时 | 取决于数据库 |
| 缓存大小(Size) | 缓存可存储的数据量 | 根据内存和业务决定 |
| 过期策略(TTL) | 缓存数据的生命周期 | 根据数据更新频率 |
缓存分类
按存储位置分类
| 缓存类型 | 存储位置 | 速度 | 容量 | 说明 |
|---|---|---|---|---|
| CPU Cache | CPU 内部 | 极快(ns级) | KB~MB | L1/L2/L3 缓存,硬件层面 |
| 本地缓存 | 进程内存 | 极快(ns~us级) | 受进程内存限制 | APCu、OPcache、进程内数组 |
| 分布式缓存 | 独立内存服务 | 快(ms级) | 可扩展(GB~TB) | Redis、Memcached |
| 浏览器缓存 | 客户端浏览器 | 极快 | 取决于磁盘 | HTTP 缓存头(ETag/Last-Modified) |
| CDN 缓存 | 边缘节点 | 快(ms级) | 大 | 静态资源、图片、视频 |
| 数据库缓存 | 数据库内部 | 较快 | 中 | MySQL Buffer Pool、Query Cache(已移除) |
按读写策略分类
Cache Aside(旁路缓存) Read Through(读穿透)
Client ──→ Cache ──→ DB Client ──→ Cache ──→ DB
↓ miss ↑ 自动加载
DB │
Write Through(写穿透) Write Behind(异步写)
Client ──→ Cache ──→ DB Client ──→ Cache(立即返回)
↑ 同步写 ↓ 异步写
DB缓存读写策略
Cache Aside(旁路缓存模式)
Cache Aside 是最常用的缓存模式,由应用程序负责维护缓存。
php
<?php
declare(strict_types=1);
namespace App\Cache;
use App\Repository\UserRepository;
use Redis;
class CacheAsideService
{
public function __construct(
private readonly Redis $redis,
private readonly UserRepository $userRepo
) {}
/**
* Cache Aside 读取
* 流程:先读缓存 → 缓存未命中则读数据库 → 写入缓存 → 返回
*/
public function getUser(int $userId): ?array
{
$cacheKey = "user:{$userId}";
// 1. 先读缓存
$cached = $this->redis->get($cacheKey);
if ($cached !== false) {
return json_decode($cached, true);
}
// 2. 缓存未命中,读数据库
$user = $this->userRepo->findById($userId);
if ($user === null) {
// 可选:缓存空值防止缓存穿透
$this->redis->setex($cacheKey, 300, json_encode(null));
return null;
}
// 3. 写入缓存
$this->redis->setex($cacheKey, 3600, json_encode($user));
return $user;
}
/**
* Cache Aside 写入
* 策略一:先更新数据库,后删除缓存(推荐)
*/
public function updateUser(int $userId, array $data): bool
{
// 1. 先更新数据库
$result = $this->userRepo->update($userId, $data);
if ($result) {
// 2. 删除缓存(而非更新缓存)
// 删除缓存更安全,避免并发导致的数据不一致
$this->redis->del("user:{$userId}");
}
return $result;
}
/**
* Cache Aside 写入
* 策略二:先删除缓存,后更新数据库
*/
public function updateUserDeleteFirst(int $userId, array $data): bool
{
// 1. 先删除缓存
$this->redis->del("user:{$userId}");
// 2. 更新数据库
$result = $this->userRepo->update($userId, $data);
// 3. 如果数据库更新失败,需要重新加载缓存(或等待下次读取时自然加载)
if (!$result) {
$user = $this->userRepo->findById($userId);
if ($user !== null) {
$this->redis->setex("user:{$userId}", 3600, json_encode($user));
}
}
return $result;
}
}缓存更新 vs 缓存删除
- 更新缓存:在并发场景下可能导致数据不一致(两个线程先后更新DB和缓存,顺序不同导致脏数据)
- 删除缓存(推荐):下次读取时再加载最新数据,虽然多一次缓存未命中,但保证了一致性
- 延迟双删:先删缓存 → 更新DB → 睡眠N毫秒 → 再删缓存(应对极端并发场景)
Read/Write Through 策略
php
<?php
declare(strict_types=1);
namespace App\Cache;
use Redis;
/**
* Read Through 缓存策略
* 缓存层自动负责从数据源加载数据
*/
class ReadThroughCache
{
public function __construct(
private readonly Redis $redis,
private readonly string $prefix = 'cache:'
) {}
/**
* 读取数据(缓存自动加载)
*/
public function get(
string $key,
callable $loader,
int $ttl = 3600
): mixed {
$fullKey = $this->prefix . $key;
$cached = $this->redis->get($fullKey);
if ($cached !== false) {
return unserialize($cached);
}
// 缓存未命中,通过 loader 回调加载数据
$data = $loader();
if ($data !== null) {
$this->redis->setex($fullKey, $ttl, serialize($data));
}
return $data;
}
}
/**
* Write Through 缓存策略
* 写入时同步更新缓存和数据源
*/
class WriteThroughCache
{
public function __construct(
private readonly Redis $redis,
private readonly string $prefix = 'cache:'
) {}
/**
* 写入数据(同步更新缓存和数据库)
*/
public function set(string $key, mixed $data, int $ttl = 3600, ?callable $persist = null): void
{
$fullKey = $this->prefix . $key;
// 先写入缓存
$this->redis->setex($fullKey, $ttl, serialize($data));
// 同步写入数据源
if ($persist !== null) {
$persist($data);
}
}
/**
* 删除数据
*/
public function delete(string $key, ?callable $removePersist = null): void
{
$fullKey = $this->prefix . $key;
$this->redis->del($fullKey);
if ($removePersist !== null) {
$removePersist();
}
}
}缓存淘汰策略
常见淘汰算法
| 策略 | 说明 | 适用场景 |
|---|---|---|
| FIFO(先进先出) | 最先放入缓存的数据最先被淘汰 | 简单场景 |
| LRU(最近最少使用) | 淘汰最长时间未被访问的数据 | 通用场景 |
| LFU(最不经常使用) | 淘汰访问频率最低的数据 | 热点数据保护 |
| LRU-K | LRU 的改进版,考虑最近 K 次访问 | 抗缓存污染 |
| TinyLFU | 结合 LFU 和 LRU 优点 | 高命中率场景 |
| TTL 过期 | 基于生存时间自动淘汰 | 有明确时效的数据 |
Redis 和 Memcached 的淘汰策略
bash
# Redis 淘汰策略(maxmemory-policy)
# noeviction(默认):内存满时不淘汰,写入直接报错
# allkeys-lru:从所有键中淘汰最近最少使用的
# allkeys-lfu:从所有键中淘汰使用频率最低的(Redis 4.0+)
# allkeys-random:从所有键中随机淘汰
# volatile-lru:从设置了过期时间的键中淘汰最近最少使用的
# volatile-lfu:从设置了过期时间的键中淘汰频率最低的
# volatile-random:从设置了过期时间的键中随机淘汰
# volatile-ttl:从设置了过期时间的键中淘汰 TTL 最小的
# 推荐配置
maxmemory-policy allkeys-lru # 通用场景
# maxmemory-policy volatile-lru # 只缓存有过期时间的键bash
# Memcached 淘汰策略
# Memcached 使用 LRU(最近最少使用)算法
# 当内存满时,自动淘汰最久未被访问的项
# 无法配置其他策略(这是 Memcached 的设计选择)手动实现 LRU 缓存(PHP)
php
<?php
declare(strict_types=1);
namespace App\Cache;
use SplDoublyLinkedList;
/**
* 本地 LRU 缓存(进程内,非分布式)
* 适用于单进程场景,如 CLI 脚本、Worker 进程
*/
class LRUCache
{
private int $maxSize;
private array $cache = [];
private int $hitCount = 0;
private int $missCount = 0;
public function __construct(int $maxSize = 1000)
{
$this->maxSize = $maxSize;
}
/**
* 读取缓存
*/
public function get(string $key): mixed
{
if (!isset($this->cache[$key])) {
$this->missCount++;
return null;
}
// 命中:移到最新位置
$value = $this->cache[$key];
unset($this->cache[$key]);
$this->cache[$key] = $value;
$this->hitCount++;
return $value;
}
/**
* 写入缓存
*/
public function set(string $key, mixed $value): void
{
if (isset($this->cache[$key])) {
unset($this->cache[$key]);
}
$this->cache[$key] = $value;
// 超出容量时淘汰最旧的项
while (count($this->cache) > $this->maxSize) {
array_shift($this->cache);
}
}
/**
* 删除缓存
*/
public function delete(string $key): void
{
unset($this->cache[$key]);
}
/**
* 获取命中率
*/
public function getHitRate(): float
{
$total = $this->hitCount + $this->missCount;
return $total === 0 ? 0.0 : $this->hitCount / $total;
}
public function size(): int
{
return count($this->cache);
}
public function clear(): void
{
$this->cache = [];
}
}缓存常见问题与解决方案
缓存穿透(Cache Penetration)
问题:查询一个数据库和缓存中都不存在的数据,每次请求都会穿透缓存直接打到数据库。
场景:恶意攻击者请求大量不存在的 ID(如 -1、UUID 随机值),导致数据库压力暴增。
解决方案:
php
<?php
declare(strict_types=1);
namespace App\Cache;
use Redis;
class CachePenetrationGuard
{
public function __construct(
private readonly Redis $redis
) {}
/**
* 方案一:缓存空值(Null Cache)
* 查询不到数据时,缓存一个空值(带较短的 TTL)
*/
public function getWithNullCache(string $key, callable $dbQuery, int $nullTTL = 300): mixed
{
// 先查缓存
$cached = $this->redis->get($key);
if ($cached !== false) {
$data = json_decode($cached, true);
// 判断是否为空值标记
if ($data === null && $this->redis->get("{$key}:null_flag")) {
return null;
}
return $data;
}
// 查数据库
$data = $dbQuery();
if ($data === null) {
// 缓存空值,防止穿透
$this->redis->setex("{$key}:null_flag", $nullTTL, '1');
$this->redis->setex($key, $nullTTL, json_encode(null));
return null;
}
// 正常缓存
$this->redis->setex($key, 3600, json_encode($data));
return $data;
}
/**
* 方案二:布隆过滤器(Bloom Filter)
* 在缓存层之前加一层布隆过滤器,快速判断 key 是否可能存在
*/
public function getWithBloomFilter(string $key, callable $dbQuery): mixed
{
// 1. 布隆过滤器检查(O(1) 时间复杂度)
$bfKey = 'bloom:user';
if (!$this->redis->sIsMember($bfKey, $key)) {
// 布隆过滤器说不存在,直接返回
return null;
}
// 2. 布隆过滤器说可能存在,继续查缓存
$cached = $this->redis->get("cache:{$key}");
if ($cached !== false) {
return json_decode($cached, true);
}
// 3. 查数据库
$data = $dbQuery();
if ($data !== null) {
$this->redis->setex("cache:{$key}", 3600, json_encode($data));
}
return $data;
}
}布隆过滤器
布隆过滤器是一种空间效率很高的概率型数据结构,用于判断一个元素是否在集合中:
- 判断"不存在"时,一定不存在
- 判断"存在"时,可能存在(有误判率)
- 适合大规模数据的存在性检查,可使用 RedisBloom 模块或 PHP 的 bloom-filter 库
缓存击穿(Cache Breakdown)
问题:一个热点 Key 在某个时间点过期,大量并发请求同时到达,全部穿透到数据库。
场景:秒杀活动商品信息缓存过期瞬间。
解决方案:
php
<?php
declare(strict_types=1);
namespace App\Cache;
use Redis;
class CacheBreakdownGuard
{
public function __construct(
private readonly Redis $redis
) {}
/**
* 方案一:互斥锁(Mutex Lock)
* 只允许一个线程重建缓存,其他线程等待
*/
public function getWithMutex(
string $key,
callable $dbQuery,
int $ttl = 3600,
int $lockTimeout = 10
): mixed {
$cached = $this->redis->get($key);
if ($cached !== false) {
return json_decode($cached, true);
}
$lockKey = "lock:{$key}";
$lockValue = uniqid(more_entropy: true);
// 尝试获取锁(SETNX + EXPIRE)
$locked = $this->redis->set($lockKey, $lockValue, ['NX', 'EX' => $lockTimeout]);
if ($locked) {
try {
// 获取到锁,查询数据库并重建缓存
$data = $dbQuery();
if ($data !== null) {
$this->redis->setex($key, $ttl, json_encode($data));
}
return $data;
} finally {
// 释放锁(使用 Lua 脚本保证原子性)
$script = <<<LUA
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
LUA;
$this->redis->eval($script, [$lockKey, $lockValue], 1);
}
}
// 未获取到锁,短暂等待后重试(或返回旧数据/降级数据)
usleep(100000); // 100ms
return $this->get($key, $dbQuery, $ttl);
}
/**
* 方案二:逻辑过期(Logical Expiration)
* 缓存永不过期,但在数据中记录逻辑过期时间
* 过期后异步更新,不阻塞请求
*/
public function getWithLogicalExpire(
string $key,
callable $dbQuery,
int $logicalTTL = 3600
): mixed {
$cached = $this->redis->get($key);
if ($cached !== false) {
$data = json_decode($cached, true);
$expireTime = $data['_expire_time'] ?? 0;
if ($expireTime > time()) {
// 未过期,返回数据
unset($data['_expire_time']);
return $data;
}
// 逻辑过期,尝试异步更新(非阻塞)
$lockKey = "refresh:{$key}";
if ($this->redis->set($lockKey, '1', ['NX', 'EX' => 10])) {
// 异步刷新(可投递到队列)
// async_refresh_queue($key, $dbQuery);
$freshData = $dbQuery();
if ($freshData !== null) {
$freshData['_expire_time'] = time() + $logicalTTL;
$this->redis->setex($key, $logicalTTL * 2, json_encode($freshData));
}
$this->redis->del($lockKey);
}
// 返回过期但可用的数据
unset($data['_expire_time']);
return $data;
}
// 缓存不存在(首次加载),同步查询
$data = $dbQuery();
if ($data !== null) {
$data['_expire_time'] = time() + $logicalTTL;
$this->redis->setex($key, $logicalTTL * 2, json_encode($data));
}
return $data;
}
}缓存雪崩(Cache Avalanche)
问题:大量缓存 Key 在同一时间失效,或缓存服务宕机,导致大量请求同时打到数据库。
场景:缓存统一在凌晨过期,而凌晨正好有流量高峰;或 Redis 集群整体故障。
解决方案:
php
<?php
declare(strict_types=1);
namespace App\Cache;
use Redis;
class CacheAvalancheGuard
{
public function __construct(
private readonly Redis $redis
) {}
/**
* 方案一:TTL 随机化
* 给缓存 TTL 加上随机偏移,避免大量 Key 同时过期
*/
public function setWithRandomTTL(string $key, mixed $data, int $baseTTL = 3600): void
{
// 在基础 TTL 上加 0~20% 的随机偏移
$randomOffset = random_int(0, (int) ($baseTTL * 0.2));
$ttl = $baseTTL + $randomOffset;
$this->redis->setex($key, $ttl, json_encode($data));
}
/**
* 方案二:多级缓存
* 本地缓存 + 分布式缓存,分布式缓存不可用时降级到本地缓存
*/
public function getWithMultiLevel(
string $key,
callable $dbQuery,
int $localTTL = 60,
int $redisTTL = 3600
): mixed {
// Level 1: 本地缓存(APCu)
$localKey = "local:{$key}";
$localData = apcu_fetch($localKey);
if ($localData !== false) {
return $localData;
}
// Level 2: 分布式缓存(Redis)
try {
$cached = $this->redis->get($key);
if ($cached !== false) {
$data = json_decode($cached, true);
apcu_store($localKey, $data, $localTTL);
return $data;
}
} catch (\Throwable $e) {
// Redis 不可用,继续走本地缓存或数据库
}
// Level 3: 数据库
$data = $dbQuery();
if ($data !== null) {
apcu_store($localKey, $data, $localTTL);
try {
$this->redis->setex($key, $redisTTL, json_encode($data));
} catch (\Throwable $e) {
// Redis 不可用,只存本地缓存
}
}
return $data;
}
/**
* 方案三:熔断降级
* 当数据库压力过大时,直接返回降级数据
*/
public function getWithCircuitBreaker(
string $key,
callable $dbQuery,
mixed $fallback = null
): mixed {
// 检查熔断器状态
$breakerKey = "circuit_breaker:db";
$breakerState = $this->redis->get($breakerKey);
if ($breakerState === 'open') {
// 熔断器打开,直接返回降级数据
return $fallback;
}
try {
$data = $this->getWithRandomTTL($key, $dbQuery);
// 请求成功,重置熔断器
$this->redis->del($breakerKey);
return $data;
} catch (\Throwable $e) {
// 请求失败,增加失败计数
$failKey = "fail_count:db";
$failCount = (int) $this->redis->incr($failKey);
$this->redis->expire($failKey, 60);
// 失败次数达到阈值,触发熔断
if ($failCount >= 5) {
$this->redis->setex($breakerKey, 30, 'open'); // 30秒后自动恢复
}
return $fallback;
}
}
}注意事项
缓存一致性问题
缓存与数据库之间的数据一致性是分布式系统的经典难题。常用的策略如下:
| 策略 | 一致性级别 | 复杂度 | 说明 |
|---|---|---|---|
| 先更新DB后删缓存 | 最终一致 | 低 | 推荐,有极小概率不一致 |
| 延迟双删 | 最终一致 | 中 | 先删缓存 → 更新DB → 延迟再删 |
| 订阅 Binlog | 最终一致 | 高 | 监听数据库变更自动更新缓存 |
| 分布式锁 | 强一致 | 高 | 读写都加锁,性能差 |
| 消息队列 | 最终一致 | 中 | 更新DB后发消息,消费者更新缓存 |
实践建议
大多数业务场景使用先更新DB后删缓存即可满足需求。对于一致性要求极高的场景(如金融交易),使用分布式锁实现强一致。对于大型系统,推荐订阅 Binlog + 消息队列实现自动同步。
最佳实践
- 缓存粒度:选择合适的缓存粒度,过粗导致更新频繁,过细增加网络开销
- TTL 设计:设置合理的过期时间,热点数据较长,普通数据较短,加随机偏移防止雪崩
- 缓存预热:系统启动或活动前提前加载热点数据到缓存
- 监控告警:监控缓存命中率、内存使用率、异常告警
- 降级方案:缓存服务不可用时有降级策略,避免数据库被压垮
- Key 命名:使用冒号分隔的层级命名,如
app:user:1001:profile
下一节
继续学习:Memcached PHP 客户端