0
点赞
收藏
分享

微信扫一扫

高并发下的缓存架构:从原理到实战,我是如何设计百万QPS系统的

这不是一篇堆砌概念的文章,而是源自一次真实的系统重构之旅。记录了我们从数据库濒临崩溃,到最终支撑起百万QPS的缓存架构演进全过程。

一、序幕:崩溃的边缘

“又挂了!” 运营同学的喊声成了那段时间的日常背景音。

我们的核心业务是一个热门内容的 feeds 流。随着用户量暴增,每晚高峰时段,数据库的 CPU 持续 99%,响应时间飙升到数秒,整个应用岌岌可危。核心瓶颈一目了然:单个热点数据(如一条爆款内容)被每秒数万次查询,直接击穿了数据库。

是时候对缓存进行一场彻底的架构升级了。

二、战术选择:缓存模式之争(Cache-Aside vs. Read-Through)

首先面临的是经典选择:缓存模式。

  1. Cache-Aside (旁路缓存): 这是我们最初采用的,也是应用最广的模式。
  • 读流程: 先读缓存,命中则返回;未命中则读数据库,写入缓存,再返回。
  • 写流程: 直接更新数据库,然后**删除(!)**缓存。
  • 优点: 简单、直观,缓存仅存储实际被请求的数据。
  • 缺点: 首次请求必然穿透;存在数据不一致的窗口期(在更新DB后、删除缓存前,读请求可能将旧数据写入缓存)。
  1. Read-Through (读穿透): 我们将缓存作为主要的数据源,缓存未命中时,缓存系统自身负责从数据库加载数据。
  • 优点: 对应用层更简单,将逻辑封装在了缓存库(如Redis)或独立的缓存服务中。更容易保证数据一致性。
  • 缺点: 需要成熟的缓存组件或自定义开发支持。

我们的选择:由于团队规模和时间压力,我们选择了优化后的 Cache-Aside 模式,因为它改造成本最低,并能快速上线缓解问题。但我们为未来向 Read-Through(例如采用 Redis Module 或 Proxy)演进留下了架构空间。

三、实战突围:解决三大“缓存恶魔”

采用 Cache-Aside 只是第一步,我们必须解决随之而来的三个经典难题:

1. 缓存穿透 (Cache Penetration)

问题:查询一个根本不存在的数据(比如不存在的用户ID)。每次请求都会穿透缓存直达数据库,相当于一场针对DB的。

解决方案

  • 缓存空值 (Cache Null Object): 即使从DB没查到,也在缓存中设置一个短暂的过期时间(如 30-60秒)的空值或特殊标记。后续请求在缓存层就被拦截。
  • 布隆过滤器 (Bloom Filter): 在缓存之前,设置一个布隆过滤器。所有可能存在的 Key 都存储在过滤器中。请求来时,先查过滤器:
  • 若过滤器说“不存在”,则直接返回空,完美拦截。
  • 若过滤器说“存在”,则继续后续的缓存/DB查询流程。
  • 注意: 布隆过滤器有微小的误判率(认为不存在的一定不存在,认为存在的可能不存在),但对于此类场景是完全可接受的。

我们的实践: 对于ID查询类请求,我们采用了 “缓存空值” 的方案,因为它实现简单,效果立竿见影。我们为这些空值设置了随机化的较短过期时间,避免大量空值占用过多内存。

2. 缓存击穿 (Cache Breakdown)

问题:某个热点Key在缓存过期的一瞬间,海量请求再也找不到数据,全部并发地冲向数据库,造成数据库瞬时压力过大。

解决方案

  • 永不过期 (Logical Expiration): 缓存值物理上不设置过期时间,而是将过期时间存储在value中。业务逻辑判断是否过期。然后由一个后台线程或单个请求去异步刷新缓存。这保证了始终有数据可用。
  • 互斥锁 (Mutex Lock): 当缓存失效时,不是所有线程都去读数据库,而是用分布式锁(如Redis的 SETNX)保证只有一个线程去重建缓存,其他线程等待或返回旧数据(如果允许)。

我们的实践: 我们选择了 “互斥锁” 方案。因为我们的热点Key是明确的,且我们宁愿让部分用户稍微等待一下,也绝不能接受数据库被冲垮。我们用 Redis 的一个 SET lock_key unique_value NX PX 3000 命令轻松实现了分布式锁,unique_value(通常用UUID)用于安全地释放锁。

3. 缓存雪崩 (Cache Avalanche)

问题:大量Key在同一时间点大规模集中过期,导致所有请求同时无法命中缓存,全部转向数据库,引起数据库压力激增甚至宕机。

解决方案

  • 随机化过期时间: 这是最简单有效的办法。在为缓存数据设置过期时间时,增加一个随机值(如 base_time + random(0, 300)s),让Key的过期时间均匀分布,避免同时失效。

我们的实践: 我们在所有设置缓存的地方,都封装了一个工具方法,将过期时间从固定的 3600s 改为 3600 + random.nextInt(300),问题迎刃而解。

四、架构升华:多级缓存与热点探测

解决了基本问题后,我们开始追求极致性能。

  • 多级缓存 (Multi-Level Cache): 我们引入了本地缓存(如Caffeine/Guava Cache)作为 L1 缓存,Redis 作为 L2 缓存。
  • 请求先读本地内存,极快。
  • 本地未命中再读Redis。
  • 这极大地减少了网络IO,对商品详情页等极致性能场景效果显著。
  • 挑战: 数据一致性更难保证。我们通过发布订阅机制,在数据更新时广播消息,让所有节点的本地缓存失效。
  • 热点Key探测 (Hot Key Detection): 我们自研了一个简单的热点发现系统。通过监控Redis的CPU和单个Key的QPS,当某个Key的访问频率在短时间内异常升高时,系统会报警,并自动将该Key推送到所有应用节点的本地缓存中,用内存来扛住最热点的请求,实现对Redis的保护。

五、总结与展望

经过这一系列改造,我们的系统终于平稳扛住了百万QPS的流量。回顾整个过程,我的心得是:

  1. 理解原理是基础: 透彻理解穿透、击穿、雪崩的区别和根源,才能选择最合适的解决方案。
  2. 简单即美: “缓存空值”、“随机过期时间”这些看似简单的方案,在实践中往往最有效。
  3. 工具化与自动化: 将解决方案封装成SDK或平台(如分布式锁、缓存客户端),降低业务方使用门槛,并为后续架构演进(如向Read-Through迁移)打下基础。
  4. 监控是眼睛: 没有完善的监控(QPS、命中率、慢查询、CPU),优化就是盲人摸象。

下一步,我们正在探索将缓存层彻底服务化,提供更强大的 Read-Through/Write-Through 能力,并更深度地集成热点探测与自动容灾。

架构之路没有银弹,唯有持续迭代,深入思考。希望我们这次“踩坑”和“填坑”的经验,能对你有所帮助。

举报

相关推荐

0 条评论