缓存系统-基本概述

目录

一、系统概述

二、名词解释

三、淘汰策略

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 # 超过阈值启动防御

相关推荐
扫地的小何尚8 小时前
NVIDIA Dynamo深度解析:如何优雅地解决LLM推理中的KV缓存瓶颈
开发语言·人工智能·深度学习·机器学习·缓存·llm·nvidia
野犬寒鸦8 小时前
多级缓存架构:性能与数据一致性的平衡处理(原理及优势详解+项目实战)
java·服务器·redis·后端·缓存
回忆哆啦没有A梦8 小时前
Vue页面回退刷新问题解决方案:利用pageshow事件实现缓存页面数据重置
前端·vue.js·缓存
洲覆19 小时前
Redis 64字节分界线与跳表实现原理
数据结构·数据库·redis·缓存
code1231319 小时前
redis升级方法
数据库·redis·缓存
铜峰叠翠1 天前
Redis安装配置
数据库·redis·缓存
Liquad Li1 天前
Salesforce 生态中的缓存、消息队列和流处理
缓存·架构·salesforce
哲Zheᗜe༘1 天前
了解学习Nginx反向代理与缓存功能
学习·nginx·缓存
梅孔立1 天前
基于 Service Worker 的图书馆资源缓存技术研究
缓存
小哈里1 天前
【后端开发】golang部分中间件介绍(任务调度/服务治理/数据库/缓存/服务通信/流量治理)
数据库·缓存·中间件·golang·后端开发