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

① Cache-Aside (旁路缓存)
最常用的模式。应用层直接控制缓存,数据库与缓存之间没有直接联系。
-
读流程:先查缓存,命中直接返回;未命中则查数据库,并将结果写入缓存后再返回。
-
写流程:先更新数据库,成功后再删除(或更新)缓存。
-
一致性:需依赖删除缓存的操作,存在短暂的不一致窗口。可通过延迟双删等手段缓解。
-
适合:读多写少,对一致性要求非强实时,需要灵活控制缓存内容。这是 Agent 会话缓存最常见的模式。
② Read-Through / Write-Through (穿透读/写)
缓存层作为一个代理,应用层只与缓存交互,缓存负责与数据库同步。
-
Read-Through:读请求只经过缓存。缓存 miss 时,缓存自身去查库并写回,然后返回给应用。应用逻辑简单。
-
Write-Through:写请求先写缓存,缓存再同步写数据库。应用逻辑依然简单,且缓存数据总是最新。
-
一致性:较好,缓存数据几乎实时与数据库保持一致。
-
适合:需要简化应用数据访问层,对一致性要求较高的场景。但实现复杂度较高(需扩展缓存驱动或引入中间件)。
③ Write-Behind (Write-Back, 回写)
应用先写缓存,缓存快速返回,然后异步批量将数据写入数据库。
-
一致性:极低,极端情况下可能丢失未刷入数据库的缓存数据。
-
优势:写入延迟极低,吞吐量极大,适合写密集、可容忍少量数据丢失的场景(如日志、计数器、用户行为记录)。
④ Refresh-Ahead (预刷新)
预测性缓存。在热点数据过期前,提前异步刷新缓存,避免缓存击穿。
- 适合:访问频率极高、过期时间可以预测的热点数据(如热门新闻、爆款商品)。
一致性-性能光谱:
⚖️ 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 监听哪个更好?¶
缓存与数据库的双写一致性问题,本质上是“两个独立系统间的数据同步”。操作数据库和操作缓存不是原子的,任何中间状态中断都可能导致缓存与数据库长期不一致。
为何会有不一致?
假设更新数据库后删除缓存:
即使第一步成功,第二步也可能失败。如果反过来,先删缓存再更新数据库:
因此必须引入补偿机制。
方案一:延时双删
原理:先删缓存 → 更新数据库 → 等待一小段时间(如 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 │
└───────┘
各组件职责:
-
MySQL:开启 Binlog,格式设为 ROW(Canal 只能解析 ROW 格式)。
-
Canal Server:模拟 Slave,连接 MySQL 主库,拉取 Binlog 并解析为
CanalEntry事件。可配置为将事件投递到 MQ(如 Kafka)。 -
消息队列(Kafka):解耦 Canal 和消费者,提供消息持久化和回溯能力。
-
缓存更新服务:订阅 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:位于 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 等手段主动失效,但散布在多台机器上的本地缓存如何感知变更?
常见解决方案:
-
设置短过期时间 + 被动失效 本地缓存设置较短的 TTL(如 5-30 秒),依赖过期自动淘汰,容忍短暂的不一致。这是最简单、最常用的方式,适合一致性要求不高的场景。
-
基于发布/订阅的主动失效 当 Redis 数据更新时,通过 Redis Pub/Sub 或 MQ 广播一个缓存失效消息,所有应用实例收到后立即删除对应的本地缓存项。
// 缓存更新服务在删除 Redis key 后,发布失效消息
redis.publish("cache:invalidate", "product:123");
// 每个应用实例订阅该频道,收到消息后删除本地缓存
@Subscribe
public void onInvalidate(String key) {
caffeineCache.invalidate(key);
}
- 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打满,影响整个集群:
- 热点Key的危害
- 单点压力:大量请求集中在单个Redis节点,CPU、内存、网络打满,导致该节点响应变慢甚至宕机。
-
集群雪崩:Redis集群使用一致性哈希,单个节点故障会导致请求路由到其他节点,引发连锁反应,导致整个集群不可用。
-
识别方案
- Redis monitor命令:实时监控Redis命令,统计访问频率最高的Key(生产环境慎用,影响性能)。
- hotkeys参数:Redis 4.0+支持
hotkeys命令,统计热点Key。 -
业务埋点统计:在应用层统计缓存访问频率,识别访问量超过阈值的Key。
-
解决方案矩阵
- 本地缓存兜底:识别到热点Key后,在应用层使用Caffeine本地缓存该Key,减少对Redis的请求。本地缓存设置较短TTL(如1分钟),避免数据不一致。
- Key打散:将热点Key复制多份,加随机后缀分散到多个Redis节点(如
hotkey:1、hotkey:2...hotkey:10)。读时并发查多个Key,取第一个返回的结果。写时同时更新所有副本。 - 读写分离:将热点Key复制到多个Redis节点的只读副本,读请求随机选择一个副本,写请求同步到所有副本。适用于读多写少的场景。
-
限流降级:对热点Key的请求进行限流,超过阈值的请求返回默认值或空,避免Redis被打垮。
-
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检索成本的关键,核心是权衡命中率和检索质量:
- 为什么需要Embedding缓存
- 成本问题:Embedding API调用成本高(如OpenAI ada-002约$0.0001/1K tokens),相同Query重复计算浪费资源。
-
性能问题:Embedding计算耗时(约100-500ms),缓存可以大幅提升响应速度。
-
精确命中缓存
- 缓存Key:使用Query文本的Hash(如MD5或SHA256)作为缓存Key。
- 命中逻辑:新Query的Hash与缓存中完全匹配时,直接复用缓存的Embedding。
- 优势:实现简单,零误差,保证检索质量。
-
劣势:命中率低,只有完全相同的Query才能命中,无法应对同义改写。
-
语义相似命中
- 缓存Key:将Query的Embedding存入向量数据库(如Milvus、Pinecone)。
- 命中逻辑:新Query先计算Embedding,然后在向量数据库中做ANN检索,找到相似度>阈值(如0.95)的缓存Embedding,直接复用。
- 优势:命中率高,同义改写、轻微改写的Query都能命中。
-
劣势:相似Query的Embedding可能有细微差异,影响检索质量;需要额外维护向量数据库。
-
权衡分析
- 精确命中:零误差,保证检索质量,但命中率低(通常<30%),适合对质量要求极高的场景。
-
语义命中:命中率高(可达70%+),但相似Query的Embedding差异可能导致检索结果偏差,适合成本敏感场景。
-
推荐方案(两级缓存)
- L1缓存(精确命中):使用Redis存储Query Hash → Embedding,TTL设置为24小时。优先查询L1,命中则直接返回。
- L2缓存(语义相似):L1 miss后,在向量数据库中检索相似Query Embedding,相似度>0.95则复用,否则调用API计算新Embedding并回填L1和L2。
-
成本优化:通过两级缓存,可以将Embedding API调用减少70%以上,同时保证检索质量。
-
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检索场景给出完整落地方案 |
| 面试官印象 | 知道缓存但不会设计 | 有成本优化思维,能权衡命中率和质量 |