分布式缓存设计
分布式缓存把可重复使用的数据副本放到多台机器上,通过按键定位、内存访问和并发请求合并,减少后端查询。设计的核心不是把一个 map 部署多份,而是规定每份副本存在哪里、什么时候有效、丢失后怎样重建,以及重建流量超过后端能力时怎么办。
本文以“数据库保存权威记录,缓存加速允许短暂旧值的查询”为场景。缓存不承担库存扣减、余额和幂等记录的唯一存储责任。把缓存改成唯一数据源后,持久化、复制确认和恢复目标就变成另外一组必须满足的要求。
1. 面试核心
先记住这几条,再读正文- 缓存适合重复读取且允许一定新鲜度窗口的数据。先明确后端查询成本与数据有效期,才能判断缓存有没有收益。卡 · 缓存保存什么
- 容量要同时计算工作集字节数、请求量、网络与故障回源量,不能由总数据条数直接推出应有多少缓存节点。卡 · 综合题
- 节点内部需要查找索引、条目元信息、淘汰与过期管理;并发安全的键值表只是其中一部分。 卡 · 缓存组件与节点内结构卡 · 查找、过期与淘汰
- 分片分担不同键,副本保存同一份数据。副本带来复制与一致性成本,不能直接按副本数增加可缓存的不同数据量。卡 · 综合题
- 读路径需要有界回源,写路径需要可靠失效。同键合并不限制所有不同键的回源总量,删除成功也不阻止旧查询再次回填。
- 单键热点不能靠增加普通分片自动拆开。读扩展、局部副本、加载合并与限流要按读写语义选择。卡 · 单键热点
- 缓存节点故障和扩容都可能造成冷数据。迁移速度与恢复速度受后端余量约束,不能把全量回源当作免费的降级。卡 · 工作集与回源量
2. 使用场景与容量
2.1 缓存保存什么
缓存保存的是某次查询的可复用结果。例如商品详情按商品 ID 读取,数据库仍保存完整记录;访问同一商品时,缓存可以减少数据库查询。是否缓存要看重复访问的频率、原查询的成本和允许返回旧值的时间,数据量大本身不是充分理由。
如果一次查询几乎不会重复,写入缓存后还没被再次读取就淘汰,缓存增加了序列化、网络和内存成本,却没有省下查询。对于频繁变化、每次都必须读取最新状态的数据,也不能靠不断增加 TTL 获得收益。
缓存键应包含决定结果的必要条件。例如不同租户、语言或权限范围会得到不同结果,就不能仅用商品 ID 合并它们。值中可以保存序列化内容、数据版本和业务有效期;应用要能区分命中、未命中、确定不存在与加载失败,不能把后端超时缓存成“这条记录不存在”。
2.2 工作集与回源量
工作集是在所选时间窗口内,值得保留以服务后续访问的数据集合。数据库有十亿条记录,不表示每一条都要同时进入缓存;如果主要访问最近一部分记录,就应先测量这部分的大小和访问分布。反过来,随机均匀扫描大量数据时,小缓存的命中率可能很低。
假设有效工作集为 1000 万条,每条平均占用 800 字节,先按条目及必要元信息估算;系统峰值 10 万次读取/秒,正常命中率 98%。这些都是演算假设:
| 项目 | 计算 | 含义 |
|---|---|---|
| 单份工作集 | 10,000,000 × 800 | 8 GB,十进制 |
| 每份数据保留主副两份 | 8 × 2 | 合计约 16 GB 数据及假设内的元信息 |
| 正常回源速率 | 100,000 × (1 - 98%) | 约 2000 次/秒 |
| 全部缓存失效后的直接回源 | 最多接近原读取速率 | 可能接近 10 万次/秒 |
实际预算还要加上对象分配与碎片、连接和复制缓冲、进程运行开销,并留出迁移与故障余量。Redis 的 maxmemory 也不能直接等同于进程 RSS 或机器物理内存上限,部分复制等缓冲不计入淘汰比较范围。
若数据库在当前条件下只能稳定处理 5000 次回源/秒,正常情况下仅剩约 3000 次/秒余量可用于额外预热等工作。这个数字不是永久配额,仍要扣除其他业务、重试和负载变化。系统需要在缓存失效时限制回源、排队与等待者,并允许一部分请求明确失败;缓存层再快也不会增加数据库的物理容量。估算方法见容量规划。
3. 缓存组件与节点内结构
3.1 组件职责
应用使用缓存组件时,需要明确它负责的是远程键值访问,还是还包括结果加载与失效。简单的访问接口可以提供查询、写入和删除,并返回明确的未命中或错误;带加载能力的组件还要规定加载函数、超时、空结果策略和回源并发限制。
组件应集中处理 key 命名、序列化版本、大小上限、TTL、连接复用和指标,但业务仍需决定什么数据允许缓存、多久算过期、权限是否影响结果。不能让一个通用组件悄悄把失败变成旧值,或替库存业务决定可以接受多旧的数量。
多进程需要共享副本时,再通过客户端或代理把请求路由到远程节点。本地缓存、远程缓存和数据库是不同状态,不应隐藏成“无论哪一层失败都自动无限重试”。
3.2 查找、过期与淘汰
一个有容量限制的内存缓存至少要协调下面几类结构:
| 职责 | 一种可选结构 | 要维护的条件 |
|---|---|---|
| 按 key 查找 | 哈希表,保存完整 key 与条目引用 | 哈希碰撞后仍比较完整 key |
| 条目内容 | 值、大小、版本、过期时间 | 序列化与版本语义一致 |
| 访问顺序 | 哈希表配双向链表实现精确 LRU | 命中时移动节点,删除时同步移除索引 |
| 过期管理 | 访问时检查,加定时或抽样清理 | 不返回已过期值,清理延迟不改变业务有效性 |
| 空间核算 | 条目字节计数与预算 | 替换、删除、过期和淘汰都更新计数 |
| 并发控制 | 锁、分段锁或明确的所有权模型 | 索引、链表与计数不能各自正确却整体不一致 |
这是一种设计分工,不代表 Redis 就用一条全局双向链表实现 LRU。Redis 6、7 的 LRU、LFU 淘汰使用近似算法,以采样等方式权衡元信息和处理成本。精确 LRU 的访问会修改链表,所以“读多”也不代表整个读取路径可以只持有读锁。
过期是条目超过有效期,淘汰是在容量压力下提前移除某些条目。未到 TTL 的缓存也可能被淘汰;数据库里的原记录不会因此被删除。allkeys-lru 可以从全部键中选候选,noeviction 则在内存不足时拒绝相应的写命令,并不代表容量可以无限增长。策略细节见 Redis 内存淘汰。
写多时,要评估锁竞争、序列化、复制和失效成本,必要时缩小缓存范围或直接访问权威存储。把缓存更新改成异步,会改变读取的新鲜度与失败恢复要求,不是并发正确性的替代品。
4. 分片、副本与路由
分片让不同节点保存不同键,主要用于扩大数据和请求的承载范围。副本在其他节点保存同一份数据,用于故障接替或符合一致性要求的读取扩展。假设两个分片各保存一半工作集,每个分片各有一个副本,四台节点保存的是两份完整工作集,不是四份互不重复的工作集。
路由可以由客户端完成,也可以由代理承担。客户端直连减少一个转发环节,但需要正确处理拓扑变化;代理可以集中路由规则,却引入自己的容量、延迟和可用性要求。无论放在哪一层,都要让 key 编码、节点身份和路由版本保持一致。
分配键可以使用固定逻辑分片,也可以使用一致性哈希。一致性哈希控制成员变化时的映射变化,不自动复制数据,也不保证负载严格均匀。Redis Cluster 使用 16384 个哈希槽,不应把它说成哈希环加虚拟节点。
副本也不自动等于最新数据。Redis 的复制通常是异步的,主节点确认写入与副本已经应用不是同一个时刻;切换时还可能丢失已确认的更新。缓存丢掉一份可重建值通常可以回源,但丢掉一条失效或版本水位,可能让旧值重新被接受。因此复制与新鲜度约定必须一起看,不能用“反正是缓存”省略一致性边界。
5. 读取、更新与多级缓存
5.1 有界回源
读取时先查缓存,命中且仍有效就返回;未命中再加载数据库,并按策略回填。这个流程需要区分“缓存里没有”与“缓存服务不可用”:两者都可能需要后端数据,但后者通常意味着更多请求同时进入回源路径。
同一个 key 的并发加载可以合并,真正加载前再次检查缓存,避免上一轮已经完成却又重复查询。Go 的 singleflight提供同一个 Group 内的同键合并;多个进程各有自己的 Group,不会自动变成全局只查一次。
不同 key 的工作仍会并行发生,所以还需要整体回源并发上限、有限等待队列和加载期限。跨实例的限额应合起来满足数据库预算,不能每个实例都按照数据库全部容量设置。调用者放弃等待后,底层查询也应遵守相应的取消或工作时限,避免过期工作继续堆积。
5.2 更新与失效
更新以数据库为准:提交权威记录后,再删除相关缓存,删除失败通过持久化任务重试。任务可以与业务更新一起写入 Outbox,也可以通过可靠的数据库变更流产生,具体机制见缓存一致性。
删除成功仍有旧值回填窗口。例如读取者拿到版本 1 后暂停,写入者提交版本 2 并删缓存,读取者恢复后又把版本 1 写回去。只在缓存值内部带版本,删除后就没有可比较的版本,不一定拦得住这次写入。需要按新鲜度要求选择有限 TTL、加载期限、独立版本水位或更强的读取协议,不能把“先更新再删除”说成跨系统原子操作。
如果缓存只接受异步回写数据库,数据在写回前就可能只有缓存一份,那已经改变了本文的权威来源假设。必须额外保证待写状态可靠保存、顺序与故障恢复,不能仅因它叫缓存就允许丢失。
5.3 本地副本
热点查询可以在应用进程保存短期副本,减少远程访问。但新增一层就新增一份要失效的状态:只删除 Redis,不能清除所有进程中的旧值。
使用 Redis 6、7 的客户端跟踪时,客户端仍要正确处理失效通知;失去接收失效消息的连接后,应清除相关本地缓存并重新建立有效状态。自己实现广播时,也要处理断线、乱序与漏消息,并保留适当 TTL。TTL 控制的是本地副本寿命,不证明它在这段时间里一直是数据库最新值。
6. 热点、扩容与故障恢复
6.1 单键热点
单键热点指一个 key 占据大量请求。增加普通分片后,这个 key 仍归某个分片负责,不会自动分成多份;虚拟节点更多也不能改变这一点。
读热点可以结合本地副本、允许旧值的读取副本、预热和同键加载合并。每新增副本,都要处理更新传播与有效期。写热点则要先判断业务能否合并、按时间或维度拆成子键;严格顺序更新不能随意拆开后再假装已经保持原语义。
热点识别可以观察各节点的请求分布、key 访问采样和高频集合,同时控制采集成本。处理热点时应看命中时的服务能力和失效后的重建压力,两者不是同一个问题。具体防护见缓存穿透、击穿、雪崩与热点 Key。
6.2 迁移与冷启动
扩容改变路由后,新节点可能没有旧数据。对于允许丢弃并回源的缓存,可以分批切换、有限预热,再逐步增加新路由流量;如果选择搬迁旧副本,就要确认搬来的版本与过期时间仍有效。不能给一个已接近过期的旧值重新延长整段 TTL。
迁移期间旧客户端还可能继续访问旧节点。普通缓存要防止旧查询结果被长时间保留,承载版本水位等协调状态时还需更严格的迁移协议。新路由的发布与旧路由退场必须有明确边界,不能只更换成员列表就宣称扩容完成。
| 故障 | 缓存侧动作 | 后端与业务边界 |
|---|---|---|
| 一个分片不可用 | 有合适副本时接替,否则按策略回源 | 限制受影响范围的重建流量 |
| 缓存整体丢失 | 分批预热、按需重建、限制并发 | 超出后端余量的请求排队或失败 |
| 后端不可用 | 只在合同允许时返回仍可用的旧副本 | 不能把加载错误当成“无记录” |
| 失效消费者积压 | 重试、告警,限制旧值继续使用 | 对新鲜度要求高的接口绕过缓存或失败 |
| 内存耗尽或大对象涌入 | 限制条目大小、淘汰或拒绝写入 | 写缓存失败不能演变成无限重试 |
缓存冷启动时的“可恢复”不等于“立即恢复全部吞吐”。恢复速度受数据库、网络和新节点写入能力共同限制,必须把缓存命中率爬升过程纳入容量验证。
7. 验证与观测
验证要围绕设计中的承诺进行。缓存命中、过期、内存淘汰和节点重启之后,权威记录是否仍存在、是否能受控重建;更新与慢读交错时,旧值可能停留多久;单个热点或大量不同 key 未命中时,回源是否超过预算,都应该有明确结果。
以 Redis 7 为例,可以在隔离测试实例中设置 maxmemory 与 allkeys-lru,写入超过容量的数据,确认发生淘汰但权威存储未被删除;再使用 noeviction,确认相应缓存写入失败时应用没有伪造写入成功。这样的单节点实验只验证局部行为,不足以证明集群切换、跨节点失效或生产吞吐。
指标应按租户、接口或键空间观察,包括命中与未命中、条目大小分布、过期与淘汰数量、节点请求偏斜、回源并发、等待者数量、加载时延、失效延迟和副本延迟。整体 99% 命中率仍可能掩盖一个低命中接口已经耗尽数据库容量。
8 · 面试题卡片
本页题目与参考回答
8.1 怎样设计高并发的分布式缓存系统?
题型:综合题
考察重点:数据语义、容量、节点内部结构、路由、复制和故障保护。
相关内容:使用场景与容量、分片、副本与路由、读取、更新与多级缓存。
参考回答
**先明确缓存的权威来源和新鲜度要求,再按工作集与峰值负载设计分片、复制和回源。**节点内部提供按 key 查询、过期、淘汰和容量限制,路由层保持稳定的分片归属,副本负责适当的故障接替。
读路径合并重复加载并限制总回源,写路径提交权威数据后可靠失效,还要处理旧读回填。最后验证单键热点、扩容冷启动、缓存丢失和数据库不可用时的行为,不能只给出“哈希环加 Redis 集群”的组件清单。
8.2 分布式缓存项目的数据结构怎样设计?
考察重点:节点定位与节点内部结构的分工,查找、过期、淘汰及并发不变量。
相关内容:缓存组件与节点内结构、分片、副本与路由。
参考回答
**需要分开说明集群路由结构和每个节点内部的缓存结构。**路由结构把 key 定位到节点;节点内部可以用哈希表查找条目,另存大小、版本和过期时间,并维护淘汰与容量状态。
精确 LRU 可以用哈希表配双向链表,但命中也会改变顺序,需要同步维护索引、链表和计数。采用近似淘汰或其他结构时,应说明省下什么成本、放弃什么精确性,不能把并发安全 map 等同于完整缓存。
8.3 分布式缓存一般在业务中怎样使用,解决什么问题?
考察重点:缓存收益、应用场景、重复读取与新鲜度代价。
相关内容:缓存保存什么、读取、更新与多级缓存。
参考回答
**它通过复用可重建的查询结果,减少重复后端访问,并让多个应用实例共享缓存容量。**例如按 ID 读取商品详情,命中缓存后可以省下数据库查询。
收益取决于访问复用程度、原查询成本和允许的旧值窗口。几乎不重复的查询或必须每次读取最新状态的判断未必适合缓存;还要承担失效、网络和故障回源成本。
8.4 大量数据下缓存怎么做?
题型:综合题
考察重点:工作集、访问分布、查询模型与不同扩容手段的职责。
参考回答
**先分析查询模式和热点工作集,再决定缓存哪些结果,不能按总条数把全部数据搬进内存。**数据库仍需合适的模型和索引,读副本、分片与缓存分别解决不同的容量问题,也各有一致性代价。
估算缓存占用、复制开销、正常回源量和失效后的回源峰值。命中率低时先确认访问是否有复用,扩容时控制预热与迁移速度;缓存不是无索引扫描或不合理分库设计的通用补丁。
8.5 缓存组件应该怎样设计,提供哪些功能?
考察重点:通用接口、业务策略与底层缓存能力之间的边界。
参考回答
**先确定组件只封装键值访问,还是还负责加载与失效。**基础能力包括查询、写入、删除、未命中与错误区分,以及 key 命名、序列化、TTL、大小限制和指标。
若提供加载能力,还需控制同键合并、总并发、超时与空结果。缓存什么、权限如何参与 key、新鲜度允许多大仍由业务决定,组件不能把任意错误自动转换成可用旧值。
8.6 自己实现简单缓存时,读多写少与写多分别要考虑什么?
考察重点:并发访问、淘汰元信息、写入成本与一致性。
参考回答
**先保证键值内容、淘汰元信息和空间计数一起正确,再根据负载减少竞争。**读多写少可以评估读写锁或局部副本,但精确 LRU 命中会改链表,不能把所有查询都当纯读。
写多时还要计算序列化、失效和复制成本,必要时减少缓存数据范围。异步更新会引入旧值与恢复窗口,不会自动解决并发写入正确性。
8.7 一个热点 key 的流量承载不了,怎样处理?
考察重点:读写语义、热点识别、局部副本和回源保护。
参考回答
**先区分读热点、写热点与缓存失效后的集中回源,再选择扩展方式。**读热点可用合适的副本、本地缓存和请求合并,但仍需有效期与失效机制。
写热点只有在业务允许合并或拆分时才能改成多个子键;严格顺序写入需要保留相应约束并限流。增加普通分片或虚拟节点不会自动分散同一个 key。
8.8 缓存可以随时丢弃,为什么整体故障仍可能拖垮数据库?
考察重点:数据可重建与恢复容量的区别,回源预算和失败行为。
参考回答
**可重建只说明数据能找回,不说明后端能同时处理全部重建请求。**10 万次/秒读取、98% 命中时平常只回源约 2000 次/秒,整体失效后可能接近 10 万次/秒。
需要整体限额、有限队列、分批预热和明确失败行为。同键合并能减少重复工作,却不能限制大量不同 key 的加载总量。
9. 参考资料
- Redis 7.4.9:内存上限、淘汰策略与副本配置
- Redis:淘汰、内存统计与近似 LRU、LFU
- Redis:复制及故障切换的数据安全范围
- Redis:Cluster 的哈希槽与迁移
- Redis:客户端缓存、失效竞争与连接丢失
- golang.org/x/sync v0.10.0:singleflight 实现
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明