FEATURED / SYSTEMS

缓存的艺术:一次请求的多级分流之旅

沿一次请求穿过浏览器、DNS、CDN 与服务端,理解多级缓存的取舍。

SOURCE / Cshiyuan/article / 成品/凤凰架构系列/04-缓存的艺术.md

引言

当你在浏览器中输入一个网址,按下回车的那一刻,一场精心编排的”分流之旅”便悄然开始。

这个请求会经过客户端缓存、DNS解析、CDN节点、负载均衡,最终才可能抵达应用服务器和数据库。每一层都像是一道关卡,尽可能地将请求”就地解决”,只有那些真正需要后端处理的请求,才会继续向前。

现代的企业级互联网系统,分流是必须要考虑的设计。分流所使用手段数量之多,涉及场景之广,可能连它的开发者都未必能全部意识到。

这种机制对用户来说是”透明”的——用户无需感知请求经过了哪些中间层。这就是《凤凰架构》中所说的透明多级分流系统

一次请求依次经过浏览器缓存、DNS、CDN、网关、应用缓存与数据库

可以用金字塔形的医疗分诊系统来类比:社区卫生站处理头疼脑热,区医院处理常规疾病,市医院处理专科病症,三甲医院只接疑难杂症。如果所有人不管大病小病都直接去三甲,整个医疗系统就会瘫痪。互联网系统也是同理。

本文将沿着一次请求的旅程,逐站剖析缓存在每一层的工作机制。阅读本文,你将了解:

  • HTTP缓存的三种机制(状态/强制/协商)及其协作方式
  • DNS从域名到IP的完整解析链路与安全问题
  • CDN如何实现就近分发与边缘计算
  • 服务端缓存的设计权衡与淘汰策略
  • 四种缓存风险(穿透/击穿/雪崩/污染)及其应对方案

一、第一站:客户端缓存

请求离开浏览器之前,首先要过的第一关就是客户端缓存。如果浏览器本地已经有了需要的资源,何必再跑一趟网络呢?

HTTP协议从设计之初就确定了无状态交互的原则——服务端不会记住客户端是谁,每次请求都是独立的。但这并不意味着所有内容每次都要重新传输,HTTP缓存机制就是在无状态的前提下实现”智能复用”。

状态缓存:最简单的缓存

状态缓存是最简单的缓存形式,典型代表是 HTTP 301 永久重定向。当服务器告诉浏览器”这个地址永久搬家了”,浏览器会记住这个信息,下次直接访问新地址,不再询问服务器。

常见使用场景包括:网站更换域名、URL结构调整、HTTP升级为HTTPS。但状态缓存的应用非常有限,因为绝大多数资源的地址不会改变,改变的是内容。

强制缓存:完全不问服务器

强制缓存是效率最高的缓存方式——浏览器在一定时间内直接使用本地缓存,完全不与服务器通信

HTTP/1.0 的 Expires 指定一个绝对的过期时间点,但存在三个问题:

  1. 依赖客户端时间:如果用户电脑的时间不准,缓存判断就会出错
  2. 无法处理私有资源:无法区分共享资源和用户专属资源
  3. **无法描述”不缓存”**:只能指定过期时间,无法表达”完全不能缓存”

HTTP/1.1 的 Cache-Control 使用相对时间和多种指令组合,解决了上述问题:

指令 含义 使用场景
max-age=N 资源在N秒内有效 设置缓存有效期
public 可被任何缓存存储(包括CDN) 公共资源,如JS、CSS
private 只能被浏览器缓存,不能被代理缓存 用户私有数据
no-cache 可以缓存,但每次使用前必须验证 需要保证实时性的资源
no-store 完全不缓存 敏感数据,如银行信息
s-maxage=N 共享缓存(CDN)的有效期 CDN特定配置

如果 Cache-Control 和 Expires 同时存在,并且语义冲突,则必须以 Cache-Control 为准。

打个比方:Expires 像食品包装上的”保质期至2026年1月1日”,依赖你的日历是否准确;而 Cache-Control: max-age=3600 像是”开封后1小时内食用”,从收到的那一刻开始计时,更加精准。

协商缓存:问一句再决定

当强制缓存失效,浏览器并不会直接放弃,而是向服务器”协商确认”:资源变了吗?

  • 如果没变,服务器返回 304 Not Modified,浏览器继续使用本地缓存
  • 如果变了,服务器返回 200 OK 和新内容

协商缓存有两种机制:

基于时间:Last-Modified / If-Modified-Since

# 首次响应
Last-Modified: Wed, 21 Oct 2024 07:28:00 GMT

# 再次请求
If-Modified-Since: Wed, 21 Oct 2024 07:28:00 GMT

基于内容指纹:ETag / If-None-Match

# 首次响应
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

# 再次请求
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

为什么 ETag 比 Last-Modified 更可靠?

对比项 Last-Modified ETag
精度 秒级 任意精度
内容敏感 否(文件改后又改回会误判) 是(基于内容hash)
性能开销 较高(需计算hash)

如果一个文件在100毫秒内被修改了两次,Last-Modified 无法感知(因为时间戳相同),但 ETag 可以正确检测到变化。

两种缓存的协作

协商缓存和强制缓存是一起并行工作的。强制缓存失效时,协商缓存仍旧可以工作。

完整的工作流程:

  1. 浏览器检查强缓存(Cache-Control/Expires)
  2. 如果强缓存有效 → 直接使用,状态码显示 200 (from cache)
  3. 如果强缓存失效 → 发起协商请求
  4. 服务器验证 → 返回 304(未变)或 200(已变)
缓存类型 网络请求 一致性 性能
强制缓存 较弱 最佳
协商缓存 有(轻量) 较强 良好
无缓存 有(完整) 最强 最差

二、第二站:DNS解析

如果客户端缓存未命中,请求正式踏上网络之旅。但在建立连接之前,必须先知道目标服务器的IP地址——这就是DNS的工作。

DNS本身就是示范性的透明多级分流系统。

DNS(Domain Name System)是互联网的”电话簿”,负责将人类可读的域名转换为机器可识别的IP地址。

2MB的震撼

一个令人惊叹的事实:

世界根域名服务器的zone文件大小只有2MB,甚至可以打印出来物理备份。

就是这区区2MB的数据,支撑着整个互联网的域名解析体系。这背后的秘密就是分级授权多级缓存

DNS的分级结构

  [根域名服务器]  ← 全球仅13组
       ↓
[顶级域名服务器]  ← .com, .cn, .org 等
       ↓
[权威域名服务器]  ← example.com 的管理者
       ↓
[本地DNS服务器]  ← ISP提供或用户配置
       ↓
  [客户端缓存]   ← 浏览器 + 操作系统

根域名服务器:全球一共有13组(注意是13组,不是13台)。

选择13是由于DNS主要采用UDP传输协议,未分片的UDP数据包在IPv4下最大有效值为512字节,最多可以存放13组地址记录。

每一组根服务器背后都是由多台服务器通过Anycast技术组成的集群。截至2025年,全球实际运行着约1960个根服务器实例,分布在全球各地。

权威域名服务器:负责翻译特定域名的DNS服务器。这里的”权威”意味着:域名应该怎么翻译、翻译出怎样的结果,由这个服务器决定。就像户籍管理一样,只有你户籍所在地的派出所才能出具户籍证明。

本地DNS服务器:用户设备首先联系的DNS服务器,来源有三种:

  • 自动获取:DHCP分配时由运营商指定(最常见)
  • 手工配置:用户主动设置(如8.8.8.8、114.114.114.114)
  • 拨号获取:通过PPP协议从服务商获取

DNS记录类型

DNS的”地址记录”并不只是IP地址:

记录类型 全称 用途 示例
A Address IPv4地址 93.184.216.34
AAAA IPv6 Address IPv6地址 2606:2800:220:1:…
CNAME Canonical Name 别名 www → example.com
MX Mail Exchange 邮件服务器 mail.example.com
NS Name Server 域名服务器 ns1.example.com
TXT Text 文本信息 “v=spf1 include:…”

当你访问 www.example.com 时,DNS可能先返回CNAME记录指向 example.com,再返回A记录给出最终IP。这种设计允许灵活的域名管理——比如你可以将 www 指向CDN,将根域名指向源站。

递归查询与迭代查询

递归查询:客户端只问一次,本地DNS服务器负责”跑腿”

  • 客户端 → 本地DNS:”www.example.com 的IP是多少?”
  • 本地DNS 代替客户端去问根服务器、顶级域服务器、权威服务器
  • 最终返回结果给客户端

迭代查询:本地DNS服务器一级级地问

  • 本地DNS → 根服务器:”www.example.com?"
  • 根服务器:”我不知道,但.com服务器在这里…”
  • 本地DNS → .com服务器:”www.example.com?"
  • .com服务器:”我不知道,但example.com服务器在这里…”

极端情况下,各级服务器均无缓存时,解析可能导致每个域名都必须递归多次才能查询到结果,明显影响传输的响应速度。

这就是为什么缓存如此重要。热门网站的DNS记录会被各级缓存命中,解析时间可以从数百毫秒降低到几毫秒。

DNS的多级缓存

  1. 浏览器缓存:固定时间(Chrome约60秒)
  2. 操作系统缓存:参考TTL值
  3. 本地DNS服务器缓存:遵循TTL值
  4. 上级DNS服务器缓存:逐级缓存

TTL(Time To Live) 决定了缓存时间,设置是一门艺术:太短会增加DNS查询压力,太长则域名变更生效慢。

DNS的安全问题

DNS的分级查询,意味着每一级都可能受到中间人攻击。

攻击类型 原理 危害
DNS劫持 篡改DNS响应,返回错误IP 用户被导向钓鱼网站
DNS污染 在查询路径中注入伪造响应 特定域名无法访问
中间人攻击 窃听或篡改DNS通信 隐私泄露、流量劫持
DNS放大攻击 利用DNS进行DDoS 服务不可用

解决方案包括:DNSSEC(数字签名验证)、DoH(DNS over HTTPS)、DoT(DNS over TLS)。

HTTPDNS:另辟蹊径

HTTPDNS,将原本的DNS解析服务开发为一个基于HTTPS协议的查询服务。

特性 传统DNS HTTPDNS
协议 UDP/明文 HTTPS/加密
防劫持
精准调度 依赖运营商 可自主控制
实时生效 受TTL限制 实时更新

HTTPDNS常用于:移动应用绕过运营商污染、游戏加速精准选择服务器、CDN调度、灾备快速切换。


三、第三站:CDN

DNS解析完成后,请求可能并不会直接到达源站,而是被导向离用户最近的CDN节点

一个运作良好的内容分发网络能为互联网系统解决跨运营商、跨地域、物理距离所导致的时延问题,能为网络流量带宽起到分流减负的作用。

CDN就像连锁便利店的物流体系:没有CDN时,所有商品都从总仓发货,偏远地区要等很久;有了CDN,商品预先铺货到各地便利店,用户就近购买。

CDN接入流程

  1. 架设好服务器,去CDN服务商注册源站,注册后得到CNAME
  2. 在你的域名DNS服务商上将CNAME配置为一条CNAME记录
  3. 用户首次访问时,DNS查询返回CDN服务商的CNAME
  4. 之后的链路解析主导权由CDN调度服务接管

完整请求流程:

[1] 用户访问 www.example.com
         ↓
[2] DNS解析,返回CNAME: www.example.com.cdn.provider.com
         ↓
[3] 继续解析CNAME,CDN权威DNS根据策略选择最优节点
    (考虑因素:拓扑结构、容量、时延等)
         ↓
[4] 返回CDN节点IP给用户
         ↓
[5] 用户请求CDN节点
         ↓
[6] CDN节点检查缓存
    ├─ 命中 → 直接返回
    └─ 未命中 → 回源获取,缓存后返回

内容分发方式

方式 原理 适用场景
主动分发(Push) 源站主动推送到各节点 大文件、预热内容
被动回源(Pull) 用户请求触发,按需拉取 常规内容

主动分发像是超市提前备货,被动回源像是顾客点单后现做。

CDN的现代应用

CDN最初是为了快速分发静态资源而设计,但今天的CDN所能做的事情已经远远超越了最初的目标。

应用场景 说明
静态资源分发 图片、JS、CSS等
安全防御 DDoS防护、WAF
协议升级 SSL证书管理、HTTP/2升级
状态缓存 动态内容缓存
修改资源 实时图片处理、格式转换
访问控制 防盗链、地域限制
边缘计算 CloudFlare Workers等

现代CDN已经从单纯的”缓存分发”演变为”边缘计算平台”。你可以在CDN节点上运行代码,实现A/B测试、个性化内容、API网关等功能。以Cloudflare Workers为例,冷启动时间仅约2ms,足以支撑实时业务逻辑。

HTTP/3与CDN:CDN也是推动协议升级的重要力量。HTTP/3基于QUIC协议,将TCP三次握手与TLS握手合并,首次连接延迟可降低至1-RTT甚至0-RTT。目前约31%的网站已支持HTTP/3,CDN厂商是推动这一升级的主力军。


四、第四站:服务端缓存

经过层层过滤,真正需要后端处理的请求终于抵达应用服务器。但这并不意味着每次都要查询数据库——服务端缓存是保护数据库的最后一道防线。

为什么需要服务端缓存

冒着风险,仍能说明你引入缓存的理由,总结起来无外乎以下两种:为缓解CPU压力引入缓存,为缓解I/O压力而引入缓存。

作者用”冒着风险”来形容,说明缓存不是银弹。它带来性能提升的同时,也引入了数据一致性、缓存失效等复杂性。

缓解CPU压力的场景:复杂计算结果、模板渲染结果、序列化/反序列化结果

缓解I/O压力的场景:数据库查询结果、远程服务调用结果、文件读取结果

缓存就像便利店的货架:空间有限,只能摆放热销商品。货架缺货时才去仓库补货。

缓存的核心属性

通常我们设计或选择缓存,至少会考虑以下四个维度属性:吞吐量、命中率、扩展功能、分布式支持。

吞吐量涉及复杂的并发控制问题:

缓存中最主要的数据竞争源于读取数据,同时也会伴随着对数据状态的写入操作。比如读取时要同时更新数据的最近访问时间和访问计数器,以实现缓存的淘汰策略。

机制 代表实现 原理 特点
同步处理 Guava Cache 访问时一并完成状态变更 实现简单,并发性能有上限
异步日志 Caffeine 状态变更记录到日志,异步批量处理 实现复杂,并发性能更高

Caffeine采用的异步日志机制借鉴了数据库的设计思想:写日志是顺序I/O,比随机更新Map快得多;日志可以批量处理,减少锁竞争。这也是Caffeine在高并发场景下性能显著优于Guava Cache的原因。

淘汰策略

有限的物理存储决定了任何缓存的容量都不可能是无限的,所以缓存需要在消耗空间与节约时间之间取得平衡。

策略 全称 原理 优缺点
FIFO First In First Out 先淘汰最早进入的 简单,不考虑访问频率
LRU Least Recently Used 淘汰最久未访问的 常用,不考虑访问频次
LFU Least Frequently Used 淘汰访问次数最少的 考虑频次,维护开销高

LRU的实现:通常采用 HashMap + LinkedList 的双重结构。HashMap提供O(1)的查找能力,LinkedList维护访问顺序。访问数据时将节点移到头部,淘汰时删除尾部节点。

LFU的问题

需要对每一个缓存的数据专门维护一个计数器,每次访问都要更新。另一个问题是不便于处理随时间变化的热度变化,如某个曾经频繁访问的数据现在不需要了,但很难自动将它清理出缓存。

W-TinyLFU是Caffeine采用的进阶算法,结合了LRU和LFU的优势:

  • Window Cache(LRU):新数据先放这里,给”新人”一个观察期
  • Main Cache(LFU):通过频率筛选后才能进入主缓存
  • 准入过滤:新数据要替换主缓存中的数据,必须证明自己更”热门”

这种设计既保护了稳定的热点数据,又给新数据机会证明自己。

分布式缓存

当单机缓存容量不足时,需要引入分布式缓存。

相比缓存数据在进程内存中读写的速度,一旦涉及网络访问,由网络传输、数据复制、序列化和反序列化等操作所导致的延迟要比内存访问高得多。

架构 读取性能 写入性能 适用场景
复制式缓存 极高(本地内存) 随节点数增加急剧下降 读多写少、节点数少
集中式缓存 依赖网络 可通过分片水平扩展 大多数分布式系统

如今Redis广为流行,基本上已经打败了Memcached以及其他集中式缓存框架,成为集中式缓存的首选。

对比维度 Redis Memcached
数据结构 String/Hash/List/Set/ZSet等 仅String
持久化 RDB + AOF
集群模式 Redis Cluster原生支持 需客户端分片
内存效率 较低(每个key有额外开销) 较高(slab分配)
多线程 6.0+支持I/O多线程 天然多线程

Redis胜出的原因在于:丰富的数据结构可以减少网络往返,原生集群支持简化运维,持久化能力提供数据安全保障。Memcached则在极简KV场景下仍有其价值。

从数据一致性角度来说,Redis集群是典型的AP式。对于能够保证强一致性的ZooKeeper、etcd等,通常不会用来当缓存,而是用来实现分布式锁等功能。


五、缓存的风险与应对

缓存虽好,但也带来了新的风险。可以用银行柜台来类比这四种风险:

  • 缓存穿透:有人不断来问”我账户里有一个亿吗?”(不存在的查询),每次都要去查总账
  • 缓存击穿:VIP客户的缓存资料刚好过期,此时这位VIP来办业务,所有窗口都要去查档案室
  • 缓存雪崩:系统升级后所有客户资料缓存同时失效,早高峰时全部客户都需要重新查档案
  • 缓存污染:客户改了手机号,但柜员改完系统没更新缓存,下次还是显示旧号码

缓存穿透:查询不存在的数据

如果查询的数据在数据库中不存在,缓存中自然也不会有。这类请求每次都不会命中,且每次都会触及末端的数据库,缓存就起不到缓解压力的作用。

解决方案一:缓存空值——将”不存在”也作为结果缓存,适用于正常业务中的空查询。

解决方案二:布隆过滤器——一种空间效率极高的概率型数据结构:

  • 可以判断”一定不存在”
  • 不能判断”一定存在”(有误判率)
  • 空间占用极小(相比存储所有key)

查询流程:请求到达 → 布隆过滤器判断 → 如果”一定不存在”直接返回空 → 如果”可能存在”正常查询缓存/数据库。

布隆过滤器在工业界有广泛应用:

  • 爬虫URL去重:判断URL是否已爬取,避免重复抓取
  • 垃圾邮件过滤:快速判断发件人是否在黑名单
  • 分布式数据库:HBase/Cassandra/LevelDB用于判断数据是否在某个SSTable中,避免无效磁盘读取
  • 推荐系统:判断用户是否已看过某内容,避免重复推荐

缓存击穿:热点数据突然失效

如果缓存中某些热点数据突然失效了,此时又有多个针对该数据的请求同时发过来,这些请求将全部未能命中缓存,到达真实数据源中,导致其压力剧增。

解决方案

  • 加锁同步:以请求的key值为锁,只有第一个请求穿透,其他请求等待
  • 热点数据手动管理:由代码有计划地更新热点数据,避免自动失效

缓存雪崩:大量数据同时失效

出现这种情况,往往是因为系统有专门的缓存预热功能,或大量公共数据是由某一次操作加载的。

缓存雪崩的根本原因是过期时间过于集中

解决方案

  • 随机化过期时间TTL = baseTime + random(0, spread)
  • 多级缓存:本地缓存 + 分布式缓存,即使远程缓存失效,本地仍有保护
  • 限流降级:即使雪崩发生,也要保护数据库

缓存污染:数据不一致

缓存污染是指缓存中的数据与真实数据源中的数据不一致的现象。缓存污染多数由开发者更新缓存不规范导致。

缓存更新有三种模式:

模式 读操作 写操作
Cache Aside 先读缓存,未命中则读数据源并回填 先写数据源,再失效缓存
Read/Write Through 缓存层代理所有读操作 缓存层代理所有写操作
Write Behind 同上 先写缓存,异步写数据源

Cache Aside模式是最常用的,需要特别注意两点:

为什么是”先写数据源,后失效缓存”?

错误顺序(先失效缓存):
1. 请求A写数据,先失效缓存 ✓
2. 请求B读数据,缓存未命中,读数据源(旧值)
3. 请求B将旧值写入缓存
4. 请求A写数据源(新值)
结果:数据源是新值,缓存是旧值 ❌

为什么是”失效”而不是”更新”缓存?

更新缓存的问题:
1. 请求A更新数据为值1
2. 请求B更新数据为值2
3. 请求B先完成,更新缓存为值2
4. 请求A后完成,更新缓存为值1
结果:数据源是值2,缓存是值1 ❌

失效缓存避免了这个问题:无论谁先完成,缓存都被失效,下次读取时从数据源获取最新值。

但依然不能保证一致性上绝对不出问题,否则就无需设计出Paxos这样复杂的共识算法了。但这个模式已经是以低成本更新缓存,并且获得相对可靠结果的方案。

进阶方案:延迟双删

如果业务允许先删缓存,可以使用”延迟双删”策略:

  1. 先删除缓存
  2. 更新数据库
  3. 延迟N毫秒后,再次删除缓存

延迟时间 = 读请求耗时 + 若干毫秒。第二次删除可以清理掉在数据库更新期间被回填的旧数据。

进阶方案:Binlog异步同步

更可靠的方案是监听数据库Binlog,异步更新缓存:

  • Canal(阿里开源):伪装为MySQL从库,解析Binlog后推送变更事件
  • Debezium:Kafka生态的CDC工具,支持多种数据库

这种方案将缓存更新与业务代码完全解耦,代价是引入最终一致性延迟(通常毫秒级)。

风险对比总结

风险类型 触发条件 影响范围 核心解决思路
缓存穿透 查询不存在的数据 持续性压力 缓存空值、布隆过滤器
缓存击穿 单个热点数据失效 瞬时高压力 加锁、手动管理热点
缓存雪崩 大量数据同时失效 大规模瞬时压力 随机过期时间、多级缓存
缓存污染 更新策略不当 数据不一致 Cache Aside模式

六、总结:缓存的哲学

回顾一次请求的旅程,我们可以画出完整的缓存层次和各层的分流效果:

层级 典型拦截率 响应时间 说明
浏览器缓存 40-60% <1ms 静态资源几乎零延迟
DNS缓存 90%+ 1-50ms 避免完整DNS查询
CDN缓存 60-80% 10-50ms 就近返回静态内容
应用缓存 30-50% 50-200ms 热点数据内存化
数据库 最终承接 100-500ms 只处理必须持久化的操作

多级分流的核心原则:

  1. 就近处理:能在前端解决的不要到后端
  2. 分级承载:不同特性的请求由不同层处理
  3. 保护核心:像保护心脏一样保护数据库
  4. 优雅降级:某一层失效时,整体仍可工作

在能满足需求的前提下,最简单的系统就是最好的系统。不是每一个系统都要追求高并发高可用的,根据系统用户量、峰值流量和团队本身的技术能力来考虑,如何部署这些设施才是合理的做法。

这是非常务实的观点。一个日活100人的内部系统,不需要也不应该按照淘宝双十一的标准去设计。过度设计是另一种形式的浪费


后记

《凤凰架构》这本书让我印象最深的一点是:它不只是在讲技术,更是在讲设计哲学

缓存的本质是空间换时间,而多级分流的本质是用最小的代价解决最多的问题。每一层缓存都在问同一个问题:这个请求能在这里解决吗?如果能,就不必劳烦后面的系统了。

这种思想不仅适用于技术架构,也适用于很多现实问题的解决——把问题分层,让每一层各司其职,把复杂性封装起来,对外呈现简单的接口。

希望这篇文章能帮你建立起对缓存体系的整体认知。下次当你按下回车键时,或许会想起这趟请求背后的精彩旅程。


参考资料:

相关阅读推荐:

  • 《高性能MySQL》
  • 《Redis设计与实现》
  • 《Web性能权威指南》