缓存系统-基本概述

目录

一、系统概述

二、名词解释

三、淘汰策略

1、LRU

2、LFU

3、FIFO

4、TTL

5、Random

四、读写模式

[1、Cache Aside(旁路缓存)](#1、Cache Aside(旁路缓存))

[2、Write Through(直写)](#2、Write Through(直写))

[3、Write Back(回写)](#3、Write Back(回写))

五、问题方案

1、缓存穿透

2、缓存雪崩

3、缓存击穿

4、最佳实践


一、系统概述

缓存(Cache)是通过临时存储高频访问数据来加速数据访问的技术组件,本质是"空间换时间"的典型实践。其核心价值体现在:

  • 性能加速:缩短数据访问路径(CPU缓存 vs 内存访问速度差达100倍)
  • 资源节约:减少对底层数据源的访问压力(数据库查询成本降低80%+)
  • 系统稳定:应对突发流量冲击(如秒杀场景QPS可达10万级)

二、名词解释

  1. **缓存命中率:**请求缓存时,数据存在的概率(命中次数 / 总请求次数),命中率>90%为高效,<70%需优化(如调整淘汰策略或容量);
  2. 缓存穿透:请求不存在的数据(如恶意攻击或无效ID),绕过缓存直击后端;
  3. 缓存击穿:热点数据过期瞬间,高并发请求压垮数据库;
  4. 缓存雪崩:大量缓存同时过期,导致请求洪峰冲击后端;
  5. 缓存污染:缓存中存储了大量非高频访问或无效数据,导致缓存命中率下降,进而降低系统整体性能的现象。本质是缓存资源被低价值数据占用,无法有效服务高频请求。
  6. TTL(Time To Live):缓存数据存活时间;
  7. 冷热数据分离:高频/低频数据分区存储
  8. 缓存预热:系统启动时加载热点数据

三、淘汰策略

缓存系统通过淘汰策略在容量不足时决定移除哪些数据,核心目标是最大化缓存命中率。

|------------|----------------|----------|---------------|---------|
| 策略 | 时间复杂度 | 空间开销 | 适用场景 | 命中率 |
| LRU | O(1) | 中 | 通用场景(Web缓存) | ★★★★☆ |
| LFU | O(1)~O(log n) | 高 | 热点数据集中(视频推荐) | ★★★★★ |
| FIFO | O(1) | 低 | 简单顺序访问(日志缓冲) | ★★☆☆☆ |
| TTL | O(log n) | 中 | 时效性数据(会话/验证码) | ★★★☆☆ |
| Random | O(1) | 低 | 低成本容忍场景 | ★★☆☆☆ |

1、LRU

核心思想 :优先淘汰最久未被访问的数据
数据结构 :哈希表 + 双向链表(哈希表存储键值对,链表维护访问顺序)
操作流程

  • 数据访问
    • 命中缓存:将节点移到链表头部
    • 未命中:从数据库加载,新节点插入链表头部
  • 淘汰触发
    • 链表尾部节点(最久未访问)被移除
    • 同步删除哈希表中对应键

优点

  • 高效反映时间局部性(最近访问的数据更可能再被访问)
  • 时间复杂度:O(1)(哈希表定位 + 链表移动)

缺点

  • 突发批量访问可能污染缓存(如全表扫描)
  • 链表维护增加内存开销

2、LFU

核心思想 :优先淘汰访问频率最低的数据
数据结构 :双哈希表(键值存储 + 频率-键列表) + 最小堆/双向链表
操作流程

  • 数据访问
    • 命中缓存:增加计数,调整在频率链表中的位置
  • 淘汰触发
    • 移除最低频率链表中的最早节点(LRU作为次级策略)
    • 更新最小频率值

优点

  • 精准保护高频访问数据
  • 适合长期热点数据场景(如电商热门商品)

缺点

  • 新数据易被淘汰(初始频率低)
  • 维护成本高(需频率排序)
  • 历史高频但不再访问的数据可能滞留

3、FIFO

核心思想 :按进入缓存的顺序淘汰
数据结构 :队列(数组/链表实现)
操作流程

(1)数据写入:新数据加入队尾

(2)淘汰触发:移除队首数据

优点

  • 实现简单(仅需队列)
  • 零额外内存开销

缺点

  • 忽略访问模式(可能淘汰热点数据)
  • 缓存命中率通常最低

4、TTL

核心思想 :基于过期时间自动淘汰
数据结构 :哈希表 + 时间堆(或轮询检查)
操作流程

  • 数据写入:设置过期时间戳(当前时间 + TTL)
  • 淘汰触发
    • 主动:定期扫描过期数据(定时器)
    • 被动:访问时检查过期并删除

优点

  • 保证数据时效性(适合会话缓存)
  • 避免手动清理

缺点

  • 可能提前移除仍有价值的数据
  • 扫描机制消耗CPU(大缓存需优化)

5、Random

核心思想 :随机选择数据淘汰
数据结构 :动态数组(如Python list)
操作流程

  • 淘汰触发:随机选择键删除
  • 数据维护:数组动态调整

优点

  • 实现极其简单
  • 无状态维护成本

缺点

  • 可能误删高频数据
  • 性能波动不可预测

四、读写模式

主要是要保证数据的一致性。

1、Cache Aside(旁路缓存)

旁路缓存模式,也称为懒加载(Lazy Loading),是最常见的缓存模式。

应用程序直接与缓存和数据库(或主数据存储)交互。

优点 :实现简单,缓存只保存实际被请求的数据,节省内存。
缺点:缓存未命中时,需要访问数据库,可能导致延迟。另外,在写操作后立即读,可能会因为缓存失效而读到旧数据(需要等到下次加载),但通常通过删除缓存保证一致性。

读流程

  • 缓存命中:应用程序首先检查缓存。如果数据存在(命中),则直接返回缓存数据。
  • 缓存未命中

(1)应用程序从数据库中读取数据。

(2)将读取到的数据写入缓存(以便后续读取命中)。

(3)返回数据。

写流程

  • 应用程序直接更新数据库。
  • 同时,使缓存中对应的数据失效(删除缓存项)。这样,下次读取时,会触发缓存未命中,从而从数据库加载最新数据并重新填充缓存。

2、Write Through(直写)

在直写模式中,缓存作为数据库的代理层。

写操作总是先经过缓存,然后由缓存同步更新到数据库。

优点 :缓存和数据库始终保持一致(强一致性)。读操作很少会访问数据库,因为写操作已经更新了缓存。
缺点:写操作延迟较高,因为需要等待数据库写入完成。如果数据不经常被读取,那么写入缓存可能造成资源浪费(因为每次写都更新缓存,即使很少读)。

读流程

  • 缓存命中:直接返回缓存数据。
  • 缓存未命中

(1)缓存从数据库中加载数据(或由应用程序触发加载)。

(2)将数据放入缓存。

(3)返回数据。

写流程

(1)应用程序更新缓存(如果数据在缓存中不存在,则创建缓存项)。

(2)缓存立即将数据同步写入数据库(通常在一个事务内)。

(3)只有在数据库写入成功后,写操作才算完成。


3、Write Back(回写)

回写模式(也称为Write-Behind)中,写操作首先写入缓存,然后异步批量写入数据库。缓存作为写操作的缓冲区。

优点 :写操作非常快,因为应用程序不需要等待数据库写入。可以合并多次写操作,减少数据库压力。
缺点:数据不一致的风险(缓存和数据库在异步同步前不一致)。如果缓存崩溃,尚未写入数据库的数据会丢失。因此,通常需要额外的机制(如写日志)来保证数据持久性。

读流程

  • 缓存命中:直接返回缓存数据。
  • 缓存未命中

(1)从数据库中加载数据到缓存。

(2)返回数据。

写流程

(1)应用程序更新缓存(如果数据不在缓存中,则先加载到缓存再更新,或者直接创建新的缓存项)。

(2)缓存标记数据为"脏"(dirty),表示需要同步到数据库。

(3)缓存立即返回成功给应用程序(无需等待数据库写入)。

(4)缓存会在之后的某个时间点(例如,缓存满时、定时任务、或者低负载时)将"脏"数据批量写入数据库。

五、问题方案

1、缓存穿透

问题原因

当查询不存在的数据时(如无效ID、不存在的用户名),每次请求都会穿透缓存层直接访问数据库。在恶意攻击场景下(如脚本批量请求随机ID),数据库会持续承受无效查询压力,导致性能急剧下降甚至崩溃。这种现象与正常缓存未命中的区别在于:正常未命中是偶发的,而穿透是持续性的无效查询。

复制代码
// 大量恶意调用
getData("invalid_id_1");
getData("invalid_id_2");
...

// 缓存访问接口
std::string getData(const std::string& key) 
{
    auto data = cache.get(key); // 缓存查询
    if (data.empty()) {
        data = db.query(key);   // 缓存未命中,查询数据库
        cache.set(key, data);   // 写入缓存
    }
    return data;
}

解决方案

(1)布隆过滤器(Bloom Filter)

  • 在缓存层前设置布隆过滤器作为屏障
  • 工作原理:使用多个哈希函数将键映射到位数组中,查询时:
    • 若键不在过滤器中 → 直接返回空(拦截非法请求)
    • 若键可能存在 → 继续查询缓存/数据库
  • 特点:存在误判率(通常<1%),但内存效率极高(1亿键仅需约100MB)

(2)空值缓存(Cache Null Object)

  • 对查询结果为空的键,缓存特殊标记(如"NULL")
  • 设置较短过期时间(5-30秒),防止恶意请求耗尽空间
  • 需配合监控清理机制,避免存储过多无效键
cpp 复制代码
// C++ 布隆过滤器+空值缓存实现
class BloomFilter {
private:
    std::vector<bool> bits;
    std::vector<std::hash<std::string>> hashers;
    
public:
    BloomFilter(size_t size, int hash_count) 
        : bits(size), hashers(hash_count) {}
    
    void add(const std::string& key) {
        for (auto& hash_fn : hashers) {
            size_t pos = hash_fn(key) % bits.size();
            bits[pos] = true;
        }
    }
    
    bool may_contain(const std::string& key) {
        for (auto& hash_fn : hashers) {
            size_t pos = hash_fn(key) % bits.size();
            if (!bits[pos]) return false;
        }
        return true;
    }
};

// 使用示例
BloomFilter filter(1000000, 3); // 100万位,3个哈希

std::string get_data(const std::string& key) {
    // 布隆过滤器拦截
    if (!filter.may_contain(key)) return "";
    
    // 缓存查询
    auto data = cache.get(key);
    if (data == "NULL") return ""; // 空值标识
    
    if (data.empty()) {
        data = db.query(key);
        if (data.empty()) {
            cache.set(key, "NULL", 15); // 缓存空值15秒
            return "";
        }
        cache.set(key, data, 3600); // 缓存有效数据
    }
    return data;
}

2、缓存雪崩

问题原因

当大量缓存在同一时间段集中过期(如缓存初始化时设置相同TTL),瞬时会有海量请求穿透到数据库。典型场景包括:

  • 系统启动时批量加载缓存
  • 定时任务刷新缓存
  • 缓存服务器重启

雪崩效应会导致数据库出现流量尖峰,引发连锁故障(如连接池耗尽、CPU过载)。

解决方案

(1)差异化过期时间

  • 基础过期时间 + 随机偏移(如30分钟 ± 5分钟)
  • 确保缓存失效时间均匀分布,避免集中失效

(2)双层缓存策略

  • 主缓存:设置较短TTL(30分钟),承担日常请求
  • 备份缓存:设置长TTL(24-48小时)
  • 当主缓存失效时:
    • 先返回备份缓存数据
    • 异步重建主缓存
  • 保证即使主缓存失效,仍有备份数据可用

(3)热数据永不过期

  • 对核心热数据(如首页推荐)采用逻辑过期:
    • 物理上永不过期
    • 后台线程定期更新数据
    • 数据对象包含逻辑过期时间戳
cpp 复制代码
// C++ 双层缓存+随机TTL实现
std::string get_data_avalanche_protected(const std::string& key) {
    // 随机数生成器
    static thread_local std::mt19937 rng(std::random_device{}());
    static std::uniform_int_distribution<int> dist(-300, 300);
    
    // 优先查询主缓存
    if (auto data = cache.get("primary_" + key); !data.empty()) 
        return data;
    
    // 查询备份缓存
    if (auto backup = cache.get("backup_" + key); !backup.empty()) {
        // 异步重建主缓存
        std::thread([key, backup] {
            int ttl = 1800 + dist(rng); // 30分钟基础+随机偏移
            cache.set("primary_" + key, backup, ttl);
        }).detach();
        return backup;
    }
    
    // 查询数据库
    auto data = db.query(key);
    if (!data.empty()) {
        int primary_ttl = 1800 + dist(rng);
        cache.set("primary_" + key, data, primary_ttl);
        cache.set("backup_" + key, data, 86400); // 备份24小时
    }
    return data;
}

3、缓存击穿

问题原因

当某个热点Key突然失效时,瞬时海量并发请求同时涌入数据库。与雪崩的区别在于:

  • 雪崩:大量不同Key同时失效
  • 击穿:单个热点Key失效引发风暴

核心危害

  • 单点数据库压力暴增(万级QPS)
  • 可能引发连接池耗尽
  • 重建缓存时的重复查询浪费资源

解决方案

(1)互斥锁重建(分布式锁)

  • 当缓存失效时,仅允许一个线程执行数据库查询
  • 其他线程阻塞等待或重试
  • 关键点:
    • 锁粒度:Key级别锁而非全局锁
    • 锁超时:防止死锁(通常5-10秒)
    • 双重检查:获取锁后再次验证缓存

(2)逻辑过期永不过期

  • 缓存永不物理删除
  • 数据结构包含逻辑过期时间戳
  • 请求处理流程:
    • 返回当前缓存数据(无论是否过期)
    • 异步检查过期状态
    • 过期则触发重建
  • 优点:零等待时间,保证高并发下的可用性

(3)热点数据监控+预加载

  • 实时监控Key访问频率
  • 识别热点Key后:
    • 延长其TTL
    • 提前异步刷新
    • 在多个缓存节点复制
cpp 复制代码
// C++ 互斥锁+逻辑过期实现
class HotspotProtection {
private:
    std::shared_mutex global_mutex;
    std::unordered_map<std::string, std::shared_ptr<std::mutex>> key_mutexes;
    
    struct CacheItem {
        int64_t logical_expire; // 逻辑过期时间戳(毫秒)
        std::string data;
    };
    
public:
    std::string get_data(const std::string& key) {
        // 获取当前缓存项
        auto item = cache.get<CacheItem>(key);
        
        // 未逻辑过期直接返回
        if (item.logical_expire > get_system_time_millis()) 
            return item.data;
            
        // 获取Key级别锁
        std::shared_ptr<std::mutex> key_mutex;
        {
            std::shared_lock read_lock(global_mutex);
            auto it = key_mutexes.find(key);
            if (it != key_mutexes.end()) key_mutex = it->second;
        }
        
        if (!key_mutex) {
            std::unique_lock write_lock(global_mutex);
            key_mutex = key_mutexes[key] = std::make_shared<std::mutex>();
        }
        
        // 锁定并重建
        std::unique_lock lock(*key_mutex);
        // 双重检查
        item = cache.get<CacheItem>(key);
        if (item.logical_expire > get_system_time_millis()) 
            return item.data;
            
        // 查询数据库
        auto new_data = db.query(key);
        int64_t new_expire = get_system_time_millis() + 3600000; // 1小时后过期
        
        // 更新缓存
        cache.set(key, CacheItem{new_expire, new_data});
        return new_data;
    }
};

4、最佳实践

|-----------|-----------|------------|-------------|
| 防御策略 | 适用场景 | 优点 | 缺点 |
| 布隆过滤器 | 防恶意请求/无效键 | 内存高效,拦截精确 | 存在误判率 |
| 空值缓存 | 处理不存在数据 | 简单易实现 | 可能存储大量无效键 |
| 随机TTL | 防批量缓存同时失效 | 有效分散压力 | 无法应对热点Key失效 |
| 双层缓存 | 高可用场景 | 主备切换平滑 | 增加内存开销 |
| 互斥锁重建 | 防热点Key击穿 | 保证数据一致性 | 增加请求延迟 |
| 逻辑过期 | 超高并发热点数据 | 零等待时间,极致性能 | 实现复杂度高 |

分层防御体系

  • 第一层:布隆过滤器拦截非法请求
  • 第二层:空值缓存处理无效查询
  • 第三层:随机TTL+双层缓存防雪崩
  • 第四层:互斥锁+逻辑过期防击穿

热点数据特殊处理

设置热点阈值,对于热点 key,延长 TTL、多节点复制

cpp 复制代码
// 热点Key识别与预加载
class HotspotManager {
public:
    void monitor_access(const std::string& key) {
        access_count[key]++;
        if (access_count[key] > 1000) {   // 达到热点阈值
            extend_ttl(key, 3600);        // 延长TTL
            preload_replica(key);        // 多节点复制
        }
    }
};

熔断降级机制

  • 当数据库压力超过阈值时:
    • 自动触发熔断
    • 返回降级内容(如默认推荐)
    • 记录日志异步补偿

持续监控指标

缓存命中率 > 95% # 低于阈值告警

数据库QPS < 3000 # 超过阈值扩容

穿透请求量 < 100/s # 超过阈值启动防御

相关推荐
忧郁的蛋~9 小时前
.NET Core 实现缓存的预热的方式
缓存·c#·.net·.netcore
珊瑚怪人11 小时前
Redis 核心数据类型及典型使用场景详解
数据库·redis·缓存
小盐巴小严1 天前
浏览器基础及缓存
前端·缓存
星垣矩阵架构师1 天前
六.架构设计之存储高性能——缓存
java·spring·缓存
Watermelo6172 天前
内存泄漏到底是个什么东西?如何避免内存泄漏
开发语言·前端·javascript·数据结构·缓存·性能优化·闭包
软件2052 天前
【redis——缓存雪崩(Cache Avalanche)】
数据库·redis·缓存
猕员桃3 天前
《高并发系统性能优化三板斧:缓存 + 异步 + 限流》
缓存·性能优化
float_六七3 天前
Redis:极速缓存与数据结构存储揭秘
数据结构·redis·缓存
blammmp3 天前
Redis : set集合
数据库·redis·缓存