行业资讯
📅 2026/9/7 10:51:12
分布式缓存架构设计与实践:从选型到故障排查
分布式缓存是软件架构里绕不开的一环。只要系统到了多实例、高并发、数据库扛不住读压力的阶段就要直面它。这篇按我做架构设计时的思路来拆先讲分布式缓存解决什么问题再讲选型、读写策略、经典故障、一致性处理和落地监控。适合正在学软件架构的开发者也适合准备做缓存方案评审的工程师。我会把每个方案背后的取舍说清楚不吹哪个组件万能也不回避缓存本身带来的新问题。1. 分布式缓存到底是解决哪一类问题1.1 先从数据库压力说起大部分业务系统最开始都是单库单表。用户量不大时SQL 直接查库响应时间在 10 毫秒以内看起来没有任何问题。但流量上来之后尤其是资讯、商品、订单这类读多写少的场景数据库的瓶颈会很快暴露。数据库的瓶颈分两层。第一层是连接数。一个 MySQL 实例默认连接数上限通常在 100 到 500 之间业务实例一多连接池很快就占满后面进来的请求只能排队等待。第二层是磁盘和查询复杂度。高频查询如果走不上索引或者需要聚合多张表单次耗时可能从几十毫秒涨到几百毫秒再叠加并发数据库 CPU 和 IO 直接被打满。分布式缓存解决的就是这个层面的问题把热点数据放到内存里查询时先读缓存命中就不再打到数据库。关键指标就两个命中率和读吞吐。命中率决定缓存到底拦住了多少数据库请求读吞吐决定缓存层本身能不能支撑业务峰值。1.2 本地缓存怎么就不够用了有人会问我在每个应用进程里放一个 ConcurrentHashMap 或者 Caffeine 本地缓存不是更简单吗单机跑确实简单但一旦扩展到多个实例问题就来了。本地缓存每个实例各存一份数据更新时你没法同时通知所有实例。A 实例改了数据B 实例还在用旧值。更麻烦的是每个实例都缓存一份缓存的总容量等于实例数量 × 单机容量热点请求会分散到不同实例命中率反而受负载均衡策略影响。假设三台机器轮询转发同一个用户的不同请求可能落到不同实例每个实例都要缓存一遍相同的数据内存利用率很浪费。分布式缓存的本质是所有实例共享一份缓存服务。数据只存一份任何一个实例写入后其他实例读到的就是最新值。一致性问题的维度从多实例之间降到了缓存服务和数据库之间这个关系更容易控制。1.3 分布式缓存的核心能力边界分布式缓存不是万能的。它能加速的是读多写少、可容忍短期不一致的数据。不是所有数据都适合进缓存。适合的典型数据有三类。第一类是不常变的基础数据比如省份、类目、配置项。第二类是读频率极高、写频率低的业务数据比如商品详情、用户资料。第三类是计算结果比如排行榜、统计报表的中间结构。不适合的也有三类。强一致要求极高的数据比如账户余额、库存扣减这类数据在缓存里出现短暂不一致可能直接导致资损。频繁修改且每次修改都要立即生效的数据缓存更新成本会超过收益。大体积数据比如几十 MB 的图片或文件内容放缓存会很快撑爆内存应该走对象存储加 CDN。判断一个数据适不适合进缓存我一般就回答三个问题读频率高不高写频率低不低不一致容忍窗口有多大。三个答案都符合缓存收益才明显。架构评审时最该较真的不是 Redis 跑得多快而是这份数据到底该不该进缓存。2. 选型先搞清楚 Redis、Memcached 和其他方案的区别2.1 Redis 为什么是多数团队的第一选择现在聊分布式缓存几乎默认是 Redis。原因不只是快而是它的数据结构丰富。String、Hash、List、Set、ZSet每种结构都能对应一类业务场景。排行榜用 ZSet标签关系用 Set对象缓存用 Hash队列可以用 List。这些能力让 Redis 不只是缓存还能兼职做分布式锁、限流计数器和简单的消息队列。Redis 单实例的读写性能通常在每秒十万级别这个数字足够覆盖绝大多数中大型业务。最关键的是它支持持久化虽然缓存的语义是丢一点数据可接受但 Redis 的 RDB 和 AOF 机制能让你在进程重启后快速恢复热点数据避免缓存全空导致数据库瞬间被打穿。需要提醒的是Redis 的快建立在单线程模型之上。好处是完全没有并发竞争坏处是一个慢命令会阻塞后面所有命令。线上常见的KEYS *、大集合排序、超大 value 读取都可能成为事故源头。所以用 Redis 不只是会写命令还要知道哪些命令在什么数据规模下不能碰。2.2 Memcached 适合什么场景Memcached 是纯缓存方案不支持持久化数据结构只有简单的 key-value但它在某些场景下依然有优势。优势之一是内存利用率。Memcached 的内存分配器对固定大小的数据块管理更精细内存碎片控制得比早期 Redis 好。优势之二是纯多线程模型在单机多核环境下Memcached 的吞吐在某些基准里能达到甚至超过 Redis。但 Memcached 的功能太简单了。不能排序做不了复杂逻辑没有成熟的主从复制方案数据重启即丢。在今天的工程环境里选它意味着你还要另外搭一套组件去弥补缺失能力。我的建议是除非团队对 Memcached 有历史依赖否则新项目不需要考虑它。2.3 企业级方案和自研缓存的判断维度再往上还有 Codis、Twemproxy、各类云托管服务。它们解决的是 Redis 集群运维和资源隔离问题适合需要大规模集群、跨机房部署和统一运维平台的团队。中小团队直接使用 Redis Cluster 或者云厂商的托管 Redis 就够不必一开始就上自研。自研缓存则是完全另一条路。除非你的场景有特殊的数据结构、超大规模或者独有的淘汰算法需求我不建议从零写缓存服务。缓存本身的难点不在单机存储而在集群一致性、故障转移、数据迁移、持久化和运维监控每一样都要耗费大量时间。大多数团队在这个问题上都应该选择成熟组件。选型时可以列一张表对照维度Redis Cluster云托管 RedisMemcached自研数据结构丰富丰富单一看实现持久化支持支持不支持看实现运维成本中等低中高性能十万级 QPS十万级 QPS高看实现适合阶段中大型自建中小团队起步极简场景特殊需求选型没有绝对答案。先看团队有没有运维能力再看业务需要的数据结构复杂度最后才是性能。性能不够可以加节点运维能力不足才是真正的坑。3. 缓存读写策略没有银弹只有取舍3.1 Cache Aside最常用也最容易理解Cache Aside 是目前最流行的缓存读写模式流程就是查缓存没命中就查库再把结果写回缓存。更新时先更新数据库再删除缓存。// Cache Aside 读逻辑 public String get(String key) { String value cache.get(key); if (value ! null) { return value; // 缓存命中直接返回 } value db.query(key); // 缓存未命中查数据库 if (value ! null) { cache.set(key, value, 300); // 设置过期时间单位秒 } return value; } // Cache Aside 写逻辑 public void update(String key, String data) { db.update(data); // 先更新数据库 cache.del(key); // 再删除缓存 }为什么更新时不直接改缓存而是删除缓存因为直接写缓存很难保证和数据库操作的原子性。如果先写缓存再更新数据库缓存写成功而数据库失败缓存里就是脏数据。而删除缓存则简单得多下次读请求自然会把新值放回去。Cache Aside 的问题在于并发。线程 A 更新数据库并删缓存线程 B 在这之前读了旧缓存并查询旧数据写回缓存最终缓存里可能还是旧值。这个窗口很小但真实存在后面会单独讲怎么补偿。3.2 Read Through 和 Write Through缓存为主角Read Through 模式下读请求只面向缓存缓存没命中时由缓存组件自己去数据库加载数据并写回缓存。对业务代码来说只有一个缓存接口实现细节被封装了。请求 - 缓存 - 未命中 - 缓存组件加载数据库 - 写回缓存 - 返回Write Through 类似写请求也只面向缓存缓存组件把数据同时写入缓存和数据库。这个模式的好处是业务代码非常干净坏处是写入路径多了一次缓存操作写入延迟会高一些。这两个模式适合内部系统、管理后台这类代码结构追求简洁的场景。对高并发互联网业务来说Cache Aside 因为实现直观、可控性强反而更常用。架构设计里经常有个误区总觉得封装得越漂亮越好。实际上在高并发场景下缓存和数据库之间的时序控制远比代码美观重要。3.3 Write Behind性能换一致性Write Behind 的做法是写请求只更新缓存立刻返回成功之后由后台线程异步把缓存中的改动批量刷新到数据库。写请求 - 更新缓存 - 返回成功 └- 后台线程异步批量写数据库它的优点是写入性能极高能把大量随机写合并成批次写适合点赞数、访问量、积分这类允许短期延迟落库的计数场景。但代价很重如果缓存服务在批量刷新前宕机数据会直接丢失如果业务逻辑依赖数据库事务Write Behind 很难保证。Write Behind 不适合订单、支付这类事务性强的数据。用来做计数器缓存、用户行为统计这类数据收益就很明显。使用之前必须评估一个数字数据丢失的最坏场景能接受吗。3.4 策略选型对照策略读性能写性能一致性实现复杂度适用场景Cache Aside高中弱一致可控低通用业务数据Read Through高中弱一致中封装缓存层Write Through高中低更一致中后台管理系统Write Behind高极高弱一致有丢失风险高计数、统计选策略没有绝对正确关键是提前设好预期。Cache Aside 是最稳妥的起点遇到性能瓶颈再考虑在特定数据上引入 Write Behind。我见过不少团队一上来就想用最高级的 Write Behind结果数据丢了才回头补方案代价非常大。4. 穿透、击穿、雪崩分布式缓存三大故障4.1 缓存穿透缓存穿透是指查询一个根本不存在的数据。缓存里没有数据库里也没有每次请求都打到数据库。典型的恶意扫描就是用大量不存在的 ID 请求你的接口。如果一个商品 ID 是自增的调用方从 1 到 1 亿遍历每次都绕过缓存直接查库数据库很快就会被打垮。应对穿透有三个常用手段。第一是缓存空值查数据库发现没有也把 key 写入缓存值设为 null设置一个较短的过期时间比如 30 到 60 秒。第二是布隆过滤器启动时把所有存在的 ID 加载进过滤器请求先查布隆过滤器不存在直接返回。第三是参数校验非法的 ID 格式直接拒绝不做数据库查询。# 缓存空值示例 def get_user(uid): value cache.get(fuser:{uid}) if value is not None: return value user db.query_user(uid) if user is None: cache.set(fuser:{uid}, , ttl30) # 空值也缓存短 TTL return None cache.set(fuser:{uid}, user, ttl300) return user我一般建议先用缓存空值因为它简单改动小。布隆过滤器适合 ID 规模极大、内存能接受一定误差的场景但它有误判率误判结果只是多打一次数据库不会造成逻辑错误。4.2 缓存击穿缓存击穿指的是一个热点 key 在过期瞬间大量请求同时发现缓存没有命中一起冲进数据库。数据库承受的是瞬时的数万 QPS很可能直接被打挂。击穿的解决思路是让它只查一次。最常用的是互斥锁缓存未命中时先获取分布式锁拿到锁的线程才允许查数据库其他线程等待锁释放后再查缓存。// 互斥锁防击穿伪代码 public String getHot(String key) { String value cache.get(key); if (value ! null) { return value; } String lockKey lock: key; if (lock.tryLock(lockKey, 3, 10)) { // 尝试获取锁 try { value db.query(key); cache.set(key, value, 600); } finally { lock.unlock(lockKey); } } else { Thread.sleep(50); value cache.get(key); // 等待后重查缓存 } return value; }另一种思路是逻辑过期key 不设置真正的物理过期时间而是在 value 里存一个过期时间戳后台异步刷新数据。读到已过期逻辑数据的请求先返回旧值同时触发一个线程去更新缓存这样用户无感知但实现复杂度高。具体用哪种取决于你能不能接受旧数据。能接受用逻辑过期不能接受用互斥锁但锁的设计要小心别把分布式锁本身变成新的性能瓶颈。4.3 缓存雪崩缓存雪崩有两种情况。一种是大面积 key 在同一时刻集中过期导致大量请求同时冲击数据库。另一种是整个缓存服务宕机所有流量全部打到数据库。应对集中过期办法是给过期时间加随机值。比如基础过期时间 10 分钟再随机加 0 到 60 秒这样 key 不会在同一个时间点集体失效。这个操作成本极低是必须做的。import random ttl 600 random.randint(0, 60) # 基础600秒 随机0-60秒 cache.set(key, value, ttl)应对服务宕机需要从架构层面做几件事。缓存服务做主从和高可用避免单点。业务侧做熔断和降级缓存不可用时直接返回兜底数据或者限流而不是让请求打到数据库。准备好缓存预热脚本重启后先把核心热点数据加载好避免冷启动导致数据库被打穿。4.4 故障排查顺序真出问题时我的排查顺序一般是先确认现象是读超时、连接失败还是数据不一致。再看监控缓存命中率、慢查询、连接数、内存使用率、数据库 QPS 有没有异常波动。再看 key 分布是不是有热点 key 被打满单分片是不是大量 key 同时过期。再看代码最近有没有新增批量操作、通配符删除或者大 key 写入。先看数据和流量再改代码。很多缓存问题看起来像是代码 bug实际上是容量规划或者过期策略的问题。排查时不要一上来就 dump 内存或者重启服务先把监控曲线拉出来看一眼方向对了再动手。5. 缓存一致性为什么先删缓存再更新数据库也会出事5.1 缓存与数据库不一致是怎么产生的缓存和数据库是两套存储双写永远做不到绝对原子。不一致的根源就是两个操作之间有时间差这个时间差里任何一个请求读到旧数据都会造成不一致。常见的不一致场景有四种。第一种是先更新数据库再删缓存删除失败缓存就一直是旧值。第二种是先删缓存再更新数据库更新数据库期间有请求读缓存未命中查到了旧数据库值并写回缓存。第三种是更新过程中有并发读读请求写回了旧值。第四种是缓存服务超时重试操作顺序错乱。认识到不一致是常态是处理一致性问题的第一步。你只能设计机制把不一致窗口压缩到业务可接受的范围不可能完全消除。架构评审时如果有人承诺绝对一致你要警惕那大概率是没想清楚。5.2 延迟双删延迟双删是针对 Cache Aside 模式的一种补偿写法。流程是先删除缓存再更新数据库休眠一小段时间再删除一次缓存。public void updateWithDelay(String key, String data) { cache.del(key); // 第一次删除 db.update(data); // 更新数据库 Thread.sleep(500); // 等待并发读请求完成写回 cache.del(key); // 第二次删除 }第二次删除的目的是清掉更新期间并发读请求写回缓存的旧值。休眠时间要大于读请求写回缓存的最长时间这个值没有通用标准要根据业务实际压测得出。50 到 500 毫秒之间是常见范围。延迟双删不是银弹。如果删除缓存的操作本身失败了还是需要重试机制。工程上可以引入一个简单的删除队列把失败的删除操作丢进队列异步重试。这个队列可以用内存队列也可以用已有的消息中间件。5.3 基于日志的异步同步方案更可靠的方案是让数据库变化驱动缓存更新。比如订阅 MySQL 的 binlog通过 Canal 这类组件捕获数据变更再异步更新或者删除缓存。这种方案的好处是业务代码里完全没有缓存操作不会出现业务逻辑正确但缓存删除顺序错误的问题。缓存更新变成了数据库事件流的一个消费者顺序由 binlog 保证一致性窗口完全可控。劣势是需要额外维护一套数据同步基础设施对于小团队来说成本偏高。通常在大规模系统、要求缓存一致性更严格的核心数据场景才值得上。我见过很多团队一开始就引入 Canal结果业务规模根本用不上还要单独维护一套集群得不偿失。5.4 一致性问题的正确心态我的观点很明确不是所有数据都需要和数据库强一致。缓存本来就是用来扛读压力的如果要求每份缓存都必须和数据库完全一致那就失去了缓存的意义。设计一致性方案前先和业务确认一个指标允许不一致的窗口是多少秒。如果业务说差十几秒无所谓那用 Cache Aside 加延迟双删完全够。如果业务说差 1 秒就会出事故那这个数据根本不应该用缓存直接查数据库更安全。把一致性要求分级是架构师在缓存设计里最重要的一项判断。6. 高并发场景下的缓存实践细节6.1 Key 设计和过期时间Key 设计的第一个原则是可读、可管理、可批量操作。规范写法一般用业务前缀加冒号分隔比如user:info:12345、order:detail:20250101:8899。有了前缀排查问题和做后台管理时会轻松很多。没有规范的系统Redis 里几百个无规律 key出了问题你根本不知道哪个属于哪个业务。第二个原则是统一过期策略。不要每个 key 都写一个自己想的过期时间。先约定基础过期时间的分档比如短 TTL 5 分钟、中 TTL 30 分钟、长 TTL 2 小时每个业务根据自己的数据变更频率选档再叠加随机偏移。第三个原则是避免无界缓存。有些团队图省事把 TTL 设为永久时间一长内存爆掉问题排查起来很麻烦。所有缓存都要有兜底过期时间哪怕设置成 7 天也比永久好。6.2 热点 Key 和 Big Key热点 Key 是某个 key 的访问量远高于其他 key单分片被打满。比如一场直播的详情页可能几百万用户同时访问同一个 key。处理热点 Key 有两种思路。一种是在客户端做本地缓存把 Redis 里的热点数据复制一份到应用内存大幅减少对 Redis 的访问。另一种是给热点 key 做多副本把访问分散到多个 key 上比如hot:1、hot:2、hot:3请求按随机值选择副本。多副本会牺牲一点一致性适合可容忍短期延迟的数据。Big Key 是指 value 非常大的 key比如几 MB 的字符串或者几百万成员的集合。Big Key 的坏处是网络传输耗时长、阻塞 Redis 单线程、删除时也可能阻塞很多线上事故就是删了一个大 key 导致的。处理 Big Key 的原则很简单拆。大字符串拆成多个小 key 或者用 Hash 分片大集合用分批删除写入时控制单个 key 的规模。Redis 官方建议单个 value 不要超过几十 KB虽然实际情况可以宽松一些但至少要有意识控制。6.3 多级缓存结构高并发系统的缓存通常不是一层而是分级设计。第一层是客户端缓存比如浏览器缓存、App 内存缓存离用户最近延迟最低但数据更新最慢只能用于变更极低的数据。第二层是应用本地缓存采用 Caffeine 这类高性能组件适合热点数据但存在多实例一致性问题不适合频繁变更的数据。第三层才是分布式缓存 Redis它承担全局共享数据的缓存任务。多级缓存的价值是把请求尽量拦在离用户最近的一层。每多拦一层Redis 压力和数据库压力都会降一个数量级。代价是数据一致性链路变长每一级都要设计合理的失效通知机制。实践时先从单层 Redis 起步命中率达到 95% 以上再考虑加本地缓存层否则引入过早反而增加复杂度。多级缓存的每一层都要有明确的过期和淘汰策略不然最大的问题不是数据不一致而是内存被大量重复数据占满。6.4 监控指标和日常巡检缓存上线后不等于结束监控才是长期稳定运行的关键。我最关注的指标有四个。第一个是命中率。Redis 命中率长期低于 80%说明 key 设计或者淘汰策略有问题缓存没有起到应有的作用。第二个是内存使用率。接近上限时要提前扩容或者调整淘汰策略不要让缓存服务在内存满的时候靠淘汰算法硬扛。第三个是慢查询和阻塞事件。Redis 是单线程模型慢命令会阻塞所有请求必须重点盯。第四个是连接数和命令错误数。连接数突然上涨可能是有代码循环调用或者连接泄漏。常用巡检命令很简单# 查看内存、命中率、连接数等核心指标 redis-cli INFO # 扫描大 key注意在低峰期执行 redis-cli --bigkeys # 查看当前执行的慢命令 redis-cli SLOWLOG GET 20日常巡检建议每周做一次。缓存出问题往往是渐进的不会像数据库宕机那么明显所以日常数据对比比临时排障更重要。把命中率、内存曲线和慢查询记录下来连续看几周很多隐患在爆发前就能发现。写到这我平时做分布式缓存设计时最常遇到的问题基本都覆盖了。这套思路不依赖特定组件只要架构里有缓存层就值得花时间把选型、读写策略、故障处理和一致性四件事想清楚。比起不断追新的缓存组件先把这几件事做扎实收益要大得多。