Skip to content

缓存策略概览

缓存是提升系统性能和吞吐量的核心技术手段。通过将频繁访问的数据存储在高速存储介质中,缓存可以大幅降低数据库压力、减少响应延迟。本节系统性地讲解缓存的类型、淘汰策略、常见问题(穿透、击穿、雪崩)及解决方案,为后续学习具体缓存实现奠定理论基础。

前置知识

阅读本节前,建议先了解:HTTP 缓存基础Redis 基础

缓存基础概念

什么是缓存

缓存(Cache)是一种将数据存储在高速存储介质中的技术,目的是加速数据读取速度、降低后端系统负载。缓存的本质是用空间换时间的权衡策略。

请求流程:
  Client → [缓存层] → 命中 → 直接返回(毫秒级)
                ↓ 未命中
              [数据库] → 读取数据 → 写入缓存 → 返回数据

缓存的核心指标:

指标说明理想值
命中率(Hit Rate)缓存命中次数 / 总请求次数> 90%
命中时间(Hit Time)从缓存读取数据的耗时< 1ms
未命中时间(Miss Time)从数据库读取并写入缓存的耗时取决于数据库
缓存大小(Size)缓存可存储的数据量根据内存和业务决定
过期策略(TTL)缓存数据的生命周期根据数据更新频率

缓存分类

按存储位置分类

缓存类型存储位置速度容量说明
CPU CacheCPU 内部极快(ns级)KB~MBL1/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-KLRU 的改进版,考虑最近 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 + 消息队列实现自动同步。

最佳实践

  1. 缓存粒度:选择合适的缓存粒度,过粗导致更新频繁,过细增加网络开销
  2. TTL 设计:设置合理的过期时间,热点数据较长,普通数据较短,加随机偏移防止雪崩
  3. 缓存预热:系统启动或活动前提前加载热点数据到缓存
  4. 监控告警:监控缓存命中率、内存使用率、异常告警
  5. 降级方案:缓存服务不可用时有降级策略,避免数据库被压垮
  6. Key 命名:使用冒号分隔的层级命名,如 app:user:1001:profile

下一节

继续学习:Memcached PHP 客户端

参考链接