跳转至

缓存一致性策略

🧩 1. 常见的缓存一致性策略有哪些?

缓存一致性策略决定了当数据发生变化时,如何同步缓存和数据库。根据写入路径的不同,业界沉淀出五种经典模式,各有其适用疆域。

image.png

① Cache-Aside (旁路缓存)

最常用的模式。应用层直接控制缓存,数据库与缓存之间没有直接联系。

  • 读流程:先查缓存,命中直接返回;未命中则查数据库,并将结果写入缓存后再返回。

  • 写流程:先更新数据库,成功后再删除(或更新)缓存。

  • 一致性:需依赖删除缓存的操作,存在短暂的不一致窗口。可通过延迟双删等手段缓解。

  • 适合:读多写少,对一致性要求非强实时,需要灵活控制缓存内容。这是 Agent 会话缓存最常见的模式。

② Read-Through / Write-Through (穿透读/写)

缓存层作为一个代理,应用层只与缓存交互,缓存负责与数据库同步。

  • Read-Through:读请求只经过缓存。缓存 miss 时,缓存自身去查库并写回,然后返回给应用。应用逻辑简单。

  • Write-Through:写请求先写缓存,缓存再同步写数据库。应用逻辑依然简单,且缓存数据总是最新。

  • 一致性:较好,缓存数据几乎实时与数据库保持一致。

  • 适合:需要简化应用数据访问层,对一致性要求较高的场景。但实现复杂度较高(需扩展缓存驱动或引入中间件)。

③ Write-Behind (Write-Back, 回写)

应用先写缓存,缓存快速返回,然后异步批量将数据写入数据库。

  • 一致性:极低,极端情况下可能丢失未刷入数据库的缓存数据。

  • 优势:写入延迟极低,吞吐量极大,适合写密集、可容忍少量数据丢失的场景(如日志、计数器、用户行为记录)。

④ Refresh-Ahead (预刷新)

预测性缓存。在热点数据过期前,提前异步刷新缓存,避免缓存击穿。

  • 适合:访问频率极高、过期时间可以预测的热点数据(如热门新闻、爆款商品)。

一致性-性能光谱:

强一致性 ←──────────────────────────→ 高性能/最终一致
Write-Through  Cache-Aside  Write-Behind

⚖️ 2. Cache-Aside 和 Write-Through 有什么区别?在 Agent 会话缓存场景如何选择?

这两种模式的核心区别在于 “谁负责维护缓存” 以及 “数据库和缓存的耦合程度”。

维度 Cache-Aside Write-Through
应用层角色 全权负责读写缓存和数据库,逻辑复杂 只操作缓存,缓存负责同步数据库,逻辑简洁
数据流 应用 → 缓存(读取/删除) + 数据库 应用 → 缓存 → 数据库
首次读延迟 缓存 Miss 时应用亲自查库,稍高 缓存自动查库,对应用无感
数据一致性 存在短暂不一致窗口,可容忍 更好,缓存总是与 DB 保持同步
实现难度 低,纯应用层逻辑 高,需要缓存层支持或额外中间件
缓存穿透风险 需自行处理 缓存代理可统一拦截,容易防护

在 Agent 会话缓存场景的选择:

Agent 会话缓存通常包含 用户上下文、对话历史、工具调用状态 等数据。这类场景的特点是 读非常频繁,写相对低频但要求及时生效,且对短暂的不一致有一定的容忍度(例如,用户最新一条消息缓存失效,下一次就能从 DB 拉取到正确历史)。

  • 对话历史读取:典型的读多写少。用 Cache-Aside 最灵活,应用完全控制缓存的粒度(例如只缓存最近 50 条消息),并在写入新消息时主动删除旧缓存。无需引入重型的 Write-Through 代理。

  • 会话状态更新(如 status: "thinking" → "answering"):如果希望状态立即可见且应用逻辑简单,可以局部使用 Write-Through 的思想:更新状态时同步更新 Redis,同时异步写 DB。这实质上是一种基于 Redis 的轻量 Write-Through。

  • LLM 调用结果缓存:强烈推荐 Cache-Aside。大模型回复一旦生成就不会再变,不存在更新问题,只需处理过期和淘汰。

示例代码:Agent 对话历史缓存的 Cache-Aside 实践

import redis, json, time
from datetime import datetime

r = redis.Redis()
CACHE_TTL = 300  # 5分钟

def get_session_history(session_id: str) -> list:
    cache_key = f"session:{session_id}:history"
    # 1. 查缓存
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    # 2. 缓存未命中,查数据库
    history = db.fetch_messages(session_id)  # 从 MongoDB 读取
    # 3. 写入缓存
    r.setex(cache_key, CACHE_TTL, json.dumps(history))
    return history

def add_message(session_id: str, message: dict):
    # 1. 先写数据库
    db.insert_message(session_id, message)
    # 2. 删除缓存,下一次读取时会重建
    r.delete(f"session:{session_id}:history")

选择策略:

如果团队需要快速迭代,Agent 数据模型变化频繁,选择 Cache-Aside 会更灵活。如果会话状态强依赖实时一致性,且有成熟的缓存中间件支持,可局部引入 Write-Through。


🛡️ 3. 缓存穿透、缓存击穿、缓存雪崩分别是什么?如何防护?

这三大缓存灾难,它们的本质是 “缓存层失效” 的不同表现形式。

请求洪流
    ├─→ 穿透:查一个根本不存在的 key,直接打到数据库
    ├─→ 击穿:一个热点 key 刚过期,海量并发请求同时涌入数据库
    └─→ 雪崩:大量 key 在同一时刻过期,或 Redis 宕机,数据库被瞬间压垮

① 缓存穿透 现象:大量请求查询一个数据库和缓存中都永远不存在的 Key(如 id=-1,或恶意构造的随机 ID)。由于缓存中始终没有,请求全部穿透到数据库。

防护手段:

  • 布隆过滤器:在缓存前加一层,快速判断 Key 是否一定不存在。内存占用极小,可拦截绝大多数无效 Key。

  • 缓存空对象:对查不到的 Key,缓存一个带有较短过期时间的空值,防止重复穿透。

  • 接口入口校验:业务层直接拦截非法参数(如负数 ID、过长字符串)。

from pybloom_live import BloomFilter

bf = BloomFilter(capacity=1000000, error_rate=0.001)
# 初始化所有可能存在的合法会话 ID
for sid in db.get_all_session_ids():
    bf.add(sid)

def get_session(session_id):
    if session_id not in bf:   # 一定不存在
        return None
    # 存在或误判,继续查缓存和数据库...

② 缓存击穿

现象:某一个极度热门的 Key(如明星 Agent 的配置),在缓存过期的瞬间,大量并发请求同时涌入,直接击穿缓存层打到数据库。

防护手段:

  • 互斥锁:缓存失效时,只允许一个请求去加载数据库,其他请求等待锁释放并读取缓存。

  • 永不过期 + 异步刷新:将热点 Key 设置为物理永不过期,但内部存储一个逻辑过期时间。当逻辑过期时,由后台线程异步更新缓存,用户请求始终读取旧值(快速返回)。

import threading

def get_hot_config(config_id):
    data = r.get(f"config:{config_id}")
    if data:
        return data
    # 尝试获取锁
    lock_key = f"lock:config:{config_id}"
    if r.setnx(lock_key, "1"):
        r.expire(lock_key, 10)
        try:
            # 再次检查缓存
            data = r.get(f"config:{config_id}")
            if data:
                return data
            data = db.get_config(config_id)
            r.setex(f"config:{config_id}", 3600, data)
            return data
        finally:
            r.delete(lock_key)
    else:
        time.sleep(0.1)
        return get_hot_config(config_id)  # 递归重试

③ 缓存雪崩

现象:大量缓存在同一时间窗口内集中过期,或者 Redis 集群宕机,导致所有请求直接冲向数据库,引发连锁崩溃。

防护手段:

  • 过期时间加随机偏移:避免大量 Key 在同一时刻失效。例如基础 1 小时,随机增加 0-300 秒。

  • 多级缓存:本地缓存 (Caffeine) → Redis → 数据库。Redis 挂了本地缓存还能撑一阵。

  • 熔断降级:用 Hystrix/Sentinel 包裹数据库调用,当数据库压力过大时,直接返回降级数据或友好提示,保证系统不崩。

  • 限流:在网关或缓存层限制到达数据库的请求速率。

  • 高可用架构:Redis 采用主从/哨兵/集群,避免单点故障。

import random

def set_with_random_ttl(key, value, base_ttl=3600):
    ttl = base_ttl + random.randint(0, 600)  # 加 0-10 分钟随机偏移
    r.setex(key, ttl, value)

总结:

这三个问题,本质上都是并发洪峰在缓存层被撕开了一道口子。穿透的口子是“数据真空”,击穿的口子是“热点失效”,雪崩则是整个“缓存堤坝”的溃堤。工程上解决它们,需要从前置过滤(布隆)、并发控制(锁)、时间打散(随机过期)、架构冗余(多级+熔断) 四个维度编织出一张立体的防护网。


🧩 4. Redis 和数据库双写一致性如何保证?延时双删 vs Binlog 监听哪个更好?

缓存与数据库的双写一致性问题,本质上是“两个独立系统间的数据同步”。操作数据库和操作缓存不是原子的,任何中间状态中断都可能导致缓存与数据库长期不一致。

为何会有不一致?

假设更新数据库后删除缓存:

1. 更新数据库成功
2. 删除缓存失败(网络闪断)
→ 缓存中仍是旧数据,下次读取直接返回脏数据。

即使第一步成功,第二步也可能失败。如果反过来,先删缓存再更新数据库:

1. 删除缓存成功
2. 更新数据库 → 但此时另一并发读请求发现缓存未命中,将旧数据写入缓存
→ 缓存又被污染。

因此必须引入补偿机制。

方案一:延时双删

原理:先删缓存 → 更新数据库 → 等待一小段时间(如 200ms) → 再次删除缓存。

第二次删除是为了清除“并发读请求在更新数据库期间写入缓存的旧数据”。

public void updateWithDelayDoubleDelete(String key, Object newValue) {
    redis.del(key);                    // 1. 先删缓存
    db.update(newValue);               // 2. 更新数据库
    try {
        Thread.sleep(200);             // 3. 等待一会
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    redis.del(key);                    // 4. 再次删缓存
}

优点:实现非常简单,不依赖外部组件,适合并发量不高的场景。

缺点:

  • 第二次删除的延迟时间难以精确设定,太短清不掉,太长影响用户体验。

  • 两次删除中间仍可能有脏读,且第二次删除失败需要重试机制。

  • 侵入性强,每个写接口都要写这段代码。

方案二:基于 Binlog 监听的缓存主动失效(推荐)

原理:利用数据库自身的变更日志 Binlog,通过 Canal 等组件实时监听,一旦监听到数据更新,就自动清理或更新对应的缓存。应用程序无需关心缓存操作,完全解耦。

架构链路:

应用 → 更新MySQL → MySQL产生Binlog → Canal模拟Slave拉取Binlog
→ 解析为事件 → 投递给MQ(Kafka)或直接推送 → 缓存更新服务 → 删除/更新Redis

优点:

  • 完全解耦:应用代码零侵入,只关心数据库,无需操作缓存。

  • 实时性高:Binlog 是准实时推送,延迟在秒级以下。

  • 可靠:Canal 支持 HA,MQ 保证消息不丢失,可重试。

  • 统一缓存管理:所有缓存失效逻辑集中到一个服务,方便治理。

哪个更好?

对于绝大多数项目,Binlog 监听方案明显优于延时双删。延时双删只能作为小项目或遗留系统的临时方案。一旦进入高并发或微服务阶段,Canal + MQ 的架构是生产级标准。

维度 延时双删 Binlog 监听 (Canal)
实现复杂度 极低,几行代码 需部署 Canal + MQ,运维稍重
可靠性 低,易遗漏,需额外重试 高,MQ 保证至少一次投递
实时性 受延时设置影响,有脏读窗口 Binlog 准实时,延迟 < 1s
代码侵入 强,每个写操作需重复代码 无侵入,应用只管数据库
扩展性 差,无法统一监控 强,集中管理所有缓存失效

一句话收束:延时双删是应急的创可贴,Binlog 监听是根治的疫苗。选择哪个,取决于你系统的规模和运维能力,但终局一定是走向解耦的监听模式。


📡 5. 如何用 Canal 监听 MySQL Binlog 实现缓存主动失效?架构链路是什么?

Canal 是阿里开源的一款 MySQL Binlog 增量订阅和消费组件。它伪装成 MySQL 的 Slave,向主库请求 Binlog,然后解析为结构化的事件流,推送给下游消费者。

完整架构链路

┌─────────┐   写操作   ┌─────────┐  Binlog   ┌─────────┐
│ 应用服务 │ ────────→ │  MySQL  │ ───────→ │  Canal  │
└─────────┘           └─────────┘          └────┬────┘
                                                │ 解析后的 JSON 事件
                                         ┌──────────┐
                                         │  Kafka /  │
                                         │  RocketMQ │
                                         └─────┬────┘
                                               │ 消费
                                    ┌──────────────────┐
                                    │ 缓存更新服务       │
                                    │ (消费Binlog事件)   │
                                    └────┬─────────────┘
                                         │ 删除/更新 key
                                      ┌───────┐
                                      │ Redis │
                                      └───────┘

各组件职责:

  1. MySQL:开启 Binlog,格式设为 ROW(Canal 只能解析 ROW 格式)。

  2. Canal Server:模拟 Slave,连接 MySQL 主库,拉取 Binlog 并解析为 CanalEntry 事件。可配置为将事件投递到 MQ(如 Kafka)。

  3. 消息队列(Kafka):解耦 Canal 和消费者,提供消息持久化和回溯能力。

  4. 缓存更新服务:订阅 Kafka Topic,根据事件的表名、操作类型(INSERT/UPDATE/DELETE)和变更的数据,构造对应的缓存 key,执行删除或更新操作。

示例代码:缓存更新服务消费 Binlog 事件

假设我们监听 products 表的变更,缓存 key 为 product:{id}

// Kafka 消费者监听 Canal 发送的 JSON 消息
@KafkaListener(topics = "canal_topic")
public void consume(String message) {
    CanalMessage canalMsg = JSON.parseObject(message, CanalMessage.class);
    for (CanalEntry entry : canalMsg.getEntries()) {
        String table = entry.getHeader().getTableName();
        if (!"products".equals(table)) continue;

        String eventType = entry.getHeader().getEventType();
        for (CanalEntry.RowData rowData : entry.getRowDataList()) {
            String productId;
            // 根据操作类型获取 ID
            if ("DELETE".equals(eventType)) {
                productId = getColumnValue(rowData.getBeforeColumnsList(), "id");
            } else {
                productId = getColumnValue(rowData.getAfterColumnsList(), "id");
            }
            // 删除缓存
            redis.del("product:" + productId);
        }
    }
}

为什么选择 ROW 格式的 Binlog?

ROW 格式记录了每一行数据变更前后的完整内容,而不是 SQL 语句。这样我们就能精确知道哪个字段变了,从而精准失效对应缓存,甚至实现缓存的部分更新。

收束:Canal 主动失效机制把缓存的“手动档”升级为“自动档”——应用无需再关心何时删缓存,只需要专注写数据库。这一架构是构建稳定、可扩展缓存系统的核心基础。


🏗️ 6. 本地缓存(Caffeine)和分布式缓存(Redis)如何配合?多级缓存架构怎么设计?

当系统的 QPS 达到一定量级时,单靠远程 Redis 也不够,因为网络往返本身就会引入毫秒级延迟。此时需要在应用实例内部引入本地缓存,形成“本地 + 分布式”的多级缓存架构。

多级缓存架构蓝图

请求 → 本地缓存(Caffeine) → Redis(分布式缓存) → 数据库
        hit 直接返回         hit 直接返回,并回填本地         miss 查库并回填
  • Caffeine:位于 JVM 堆内,微秒级响应,但容量有限(受堆内存制约),且各实例独立。

  • Redis:毫秒级响应,容量大,数据共享,但每次调用有网络开销。

读取流程代码实现

public Product getProduct(String productId) {
    String localKey = "product:" + productId;

    // 1. 查本地缓存
    Product product = caffeineCache.getIfPresent(localKey);
    if (product != null) {
        return product;
    }

    // 2. 查 Redis
    product = redis.get(localKey);
    if (product != null) {
        // 回填本地缓存,设置较短的过期时间(如 5s)
        caffeineCache.put(localKey, product);
        return product;
    }

    // 3. 查数据库
    product = db.getProduct(productId);
    if (product != null) {
        // 回填 Redis 和本地缓存
        redis.setex(localKey, 300, product);      // Redis 5分钟
        caffeineCache.put(localKey, product);      // 本地 30秒
    } else {
        // 缓存空对象防穿透
        redis.setex(localKey, 60, EMPTY_PRODUCT);
    }
    return product;
}

多级缓存的一致性问题

多级缓存的难点在于 本地缓存的失效。Redis 数据可以通过 Canal 等手段主动失效,但散布在多台机器上的本地缓存如何感知变更?

常见解决方案:

  1. 设置短过期时间 + 被动失效 本地缓存设置较短的 TTL(如 5-30 秒),依赖过期自动淘汰,容忍短暂的不一致。这是最简单、最常用的方式,适合一致性要求不高的场景。

  2. 基于发布/订阅的主动失效 当 Redis 数据更新时,通过 Redis Pub/Sub 或 MQ 广播一个缓存失效消息,所有应用实例收到后立即删除对应的本地缓存项。

// 缓存更新服务在删除 Redis key 后,发布失效消息
redis.publish("cache:invalidate", "product:123");

// 每个应用实例订阅该频道,收到消息后删除本地缓存
@Subscribe
public void onInvalidate(String key) {
    caffeineCache.invalidate(key);
}
  1. Canal + 本地 MQ 直接让缓存更新服务在消费 Binlog 后,同时删除 Redis 并通过 MQ 通知所有应用实例删除本地缓存,形成统一管控。

Agent 场景下的多级缓存设计

在 AI Agent 系统中,多级缓存特别适合以下场景:

  • LLM 调用结果缓存:同一个问题被频繁问到,本地缓存可以避免重复调用昂贵的 LLM。此时可用 Caffeine 本地缓存,TTL 设置 5-10 分钟。

  • Embedding 向量缓存:相同文本的向量化结果可以缓存在 Redis 中,本地缓存不常命中但也可加一层。

  • 会话状态:不应使用本地缓存!因为会话状态绑定到特定用户,需要跨实例共享,只能放在 Redis 中。

总结:

多级缓存架构的本质是用空间换时间,用冗余换延迟。Caffeine 是急先锋,把最热的数据挡在最前面;Redis 是主力军,承载大规模共享数据;Binlog 监听则是信使,确保每一层数据在变更时都能及时得到通知。设计时牢记:本地缓存要短,分布式缓存要稳,变更通知要快。


7.缓存热点 Key 问题如何解决?

难度级别:⭐⭐⭐⭐(热点Key识别、本地缓存兜底、Key打散、读写分离、限流)

1️⃣ Common Answer

热点Key就是访问量特别大的Key,会打满Redis。可以用本地缓存缓存热点Key,或者把Key打散到多个节点。

2️⃣ Impressive Answer

热点Key是分布式缓存的性能杀手,会导致单个节点CPU打满,影响整个集群:

  1. 热点Key的危害
  2. 单点压力:大量请求集中在单个Redis节点,CPU、内存、网络打满,导致该节点响应变慢甚至宕机。
  3. 集群雪崩:Redis集群使用一致性哈希,单个节点故障会导致请求路由到其他节点,引发连锁反应,导致整个集群不可用。

  4. 识别方案

  5. Redis monitor命令:实时监控Redis命令,统计访问频率最高的Key(生产环境慎用,影响性能)。
  6. hotkeys参数:Redis 4.0+支持hotkeys命令,统计热点Key。
  7. 业务埋点统计:在应用层统计缓存访问频率,识别访问量超过阈值的Key。

  8. 解决方案矩阵

  9. 本地缓存兜底:识别到热点Key后,在应用层使用Caffeine本地缓存该Key,减少对Redis的请求。本地缓存设置较短TTL(如1分钟),避免数据不一致。
  10. Key打散:将热点Key复制多份,加随机后缀分散到多个Redis节点(如hotkey:1hotkey:2...hotkey:10)。读时并发查多个Key,取第一个返回的结果。写时同时更新所有副本。
  11. 读写分离:将热点Key复制到多个Redis节点的只读副本,读请求随机选择一个副本,写请求同步到所有副本。适用于读多写少的场景。
  12. 限流降级:对热点Key的请求进行限流,超过阈值的请求返回默认值或空,避免Redis被打垮。

  13. Agent场景实践 公共知识库文档(如"如何使用Agent")被所有用户共享,访问量巨大。识别到热点文档后,使用本地缓存兜底(Caffeine缓存1分钟),同时将文档Key打散到10个Redis节点。读时并发查10个Key,取第一个返回,将Redis请求分散到多个节点,避免单点压力。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 仅罗列方案,无决策矩阵 从危害→识别→解决方案矩阵→场景实践
技术深度 不知道Key打散、读写分离机制 明确各种方案的核心原理和适用场景
实践经验 无具体业务案例 结合公共知识库场景给出完整落地方案
面试官印象 知道热点Key但不会解决 有系统性防护思维,能应对极端场景

8.在 Agent 场景下,Embedding 向量缓存如何设计?语义相似命中 vs 精确命中的权衡?

难度级别:⭐⭐⭐⭐(Embedding缓存、语义相似度命中、精确匹配、缓存Key设计、成本优化)

1️⃣ Common Answer

Embedding调用成本高,可以缓存Query的Embedding。精确命中就是Query完全一样才命中,语义相似就是意思差不多就命中。

2️⃣ Impressive Answer

Embedding缓存是降低RAG检索成本的关键,核心是权衡命中率和检索质量:

  1. 为什么需要Embedding缓存
  2. 成本问题:Embedding API调用成本高(如OpenAI ada-002约$0.0001/1K tokens),相同Query重复计算浪费资源。
  3. 性能问题:Embedding计算耗时(约100-500ms),缓存可以大幅提升响应速度。

  4. 精确命中缓存

  5. 缓存Key:使用Query文本的Hash(如MD5或SHA256)作为缓存Key。
  6. 命中逻辑:新Query的Hash与缓存中完全匹配时,直接复用缓存的Embedding。
  7. 优势:实现简单,零误差,保证检索质量。
  8. 劣势:命中率低,只有完全相同的Query才能命中,无法应对同义改写。

  9. 语义相似命中

  10. 缓存Key:将Query的Embedding存入向量数据库(如Milvus、Pinecone)。
  11. 命中逻辑:新Query先计算Embedding,然后在向量数据库中做ANN检索,找到相似度>阈值(如0.95)的缓存Embedding,直接复用。
  12. 优势:命中率高,同义改写、轻微改写的Query都能命中。
  13. 劣势:相似Query的Embedding可能有细微差异,影响检索质量;需要额外维护向量数据库。

  14. 权衡分析

  15. 精确命中:零误差,保证检索质量,但命中率低(通常<30%),适合对质量要求极高的场景。
  16. 语义命中:命中率高(可达70%+),但相似Query的Embedding差异可能导致检索结果偏差,适合成本敏感场景。

  17. 推荐方案(两级缓存)

  18. L1缓存(精确命中):使用Redis存储Query Hash → Embedding,TTL设置为24小时。优先查询L1,命中则直接返回。
  19. L2缓存(语义相似):L1 miss后,在向量数据库中检索相似Query Embedding,相似度>0.95则复用,否则调用API计算新Embedding并回填L1和L2。
  20. 成本优化:通过两级缓存,可以将Embedding API调用减少70%以上,同时保证检索质量。

  21. Agent场景实践 在RAG知识库检索中,用户Query"如何使用Agent"和"Agent怎么用"语义相似但文本不同。通过两级缓存,L1缓存"如何使用Agent"的Embedding,L2缓存向量库中相似Query的Embedding。新Query"Agent怎么用"先查L1 miss,再查L2命中(相似度0.97),直接复用Embedding,避免调用API,节省成本且响应速度提升10倍。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 仅描述两种方案,无对比分析 从成本→方案对比→权衡→两级缓存→场景
技术深度 不知道向量数据库、相似度阈值机制 明确语义相似命中的核心原理和两级缓存设计
实践经验 无具体业务案例 结合RAG检索场景给出完整落地方案
面试官印象 知道缓存但不会设计 有成本优化思维,能权衡命中率和质量