重复读取多时,怎么用缓存、ETag 和 HTTP 缓存控制换效率

4 阅读13分钟

你可能见过这种接口:商品分类、地区列表、帮助中心目录、站点公共配置。它们改动不算频繁,但页面一打开请求一次,切回来又请求一次,换个标签页再请求一次。服务器像被叫去搬同一箱东西,明明箱子没变,腿却跑细了。

这类问题的核心,往往不是“接口太慢”,而是“同一份数据被反复拿”。这时常见的优化思路,不是继续把每次请求做得更快,而是换个算盘:让客户端多记一点事,少走几次网络

先讲人话:以前是“每次都去仓库拿一份新的”,现在变成“我先看看手边有没有、还新不新、版本变没变”。专业一点说,就是用客户端状态复杂度,换网络效率

好处是请求数下降、带宽占用下降、页面更稳。代价是客户端要开始管理缓存副本、过期时间、版本标记和失效规则,一致性治理会明显变复杂。

这篇文章就围着三个工具讲明白:

  • 客户端缓存:把常看的数据先放手边

  • ETag/条件请求:不用整份重拿,先问一句“它变了吗”

  • HTTP 缓存控制:给数据写上“多久内可以直接用”

问题一:这到底在优化什么?

适合这套思路的数据,通常长这样:

  • 静态数据:很久不变,比如国家列表、图标资源、文档目录

  • 半静态数据:会变,但不是每分钟都变,比如商品分类、课程标签、公共配置、帮助中心栏目

  • 读取很多:多个页面要读,很多用户要读,同一个用户还会反复读

你可以把它理解成“公共讲义”。如果每个同学每节课都跑去老师办公室重新拿一份,老师肯定先累趴。更聪明的做法是:先发一份到每个人桌上,只有讲义改版时再更新。

下面这条流程,就是这笔交易的核心:


用户打开页面

-> 客户端先看本地有没有这份数据

-> 没有:请求服务器,拿到数据 + 缓存规则

-> 有且还新鲜:直接使用本地数据

-> 有但过期:带上版本标记去问服务器

-> 服务器说没变(304):继续用本地数据

-> 服务器说变了(200):收下新数据并更新缓存

看到这里,如果你的接口属于“重复读取多、变化不频繁”,先别急着给服务端加班,先检查自己有没有把这条缓存链路补完整。

问题二:三个手段分别在干嘛?

1. 客户端缓存:把常看的东西先放手边

先说人话:客户端缓存,就是前端自己留一份副本,下次先用这份,不着急重新去要。它可以放在页面内存里,也可以放在 localStorageIndexedDB 这类本地存储里。

生活类比:像把常用证件复印件先放进包里。下次办事先掏包,不用每次都回家翻原件。

小案例:电商网站的“商品分类树”一天只改几次,但首页、搜索页、详情页都要用。第一次请求后,前端把它存起来,后面多个页面直接复用,页面切换时就不会反复拉同一份数据。

它的优点很直接:命中时最快,因为连请求都不发

它的麻烦也很直接:什么时候该信缓存,什么时候该扔缓存,要你自己管

2. ETag/条件请求:先对版本,不急着重拿

先说人话:ETag 可以把它理解成“这份数据当前版本的标签”。客户端下次请求时,不是直接说“再给我一份”,而是说“我手里是 v15,这版还有效吗?”

如果服务端一看,版本没变,就返回 304 Not Modified,意思是“没变,你继续用手里的旧副本”。

如果变了,就返回 200 和新正文,顺手把新的 ETag 也发回来。

条件请求不只一种做法,也可以按修改时间判断,但对初学者来说,先把 ETag 理解成“版本小纸条”最直观。

生活类比:像去图书馆借书前,先问管理员:“我手里这本是不是最新修订版?”如果是,你就不用再领一本新的。

小案例:帮助中心栏目结构一个星期只改一次。用户第二次打开时,客户端带着旧 ETag 去问。服务端发现没变,就返回 304,没有正文,传输量立刻变小。

很多初学者会误会:304 不是“没请求”,而是“请求发了,但正文不用重传”

所以如果你的目标是“彻底减少网络往返”,光有 ETag 还不够,通常要配合缓存控制。


第一次请求:

客户端 -> GET /categories

服务端 -> 200 + 数据正文 + ETag: "cat-v15"

  


过一段时间后再次请求:

客户端 -> GET /categories + If-None-Match: "cat-v15"

服务端 -> 304 Not Modified

  


客户端动作:

继续使用本地那份 categories 数据

看完这段顺序图,你可以记住一句最实用的话:ETag 更像“先验版本”,它减少的是重复传输,不一定减少请求次数。

3. HTTP 缓存控制:给数据贴“保质期”

先说人话:HTTP 缓存控制,就是服务端在响应里告诉客户端:“这份东西多久内可以直接用,多久后必须重新确认。”

最常见的是 Cache-Control

比如 max-age=600,意思就是“这份数据 600 秒内算新鲜,可以直接用”。

如果过了 600 秒,再去做校验,这时 ETag 就派上用场了。

生活类比:像牛奶盒上的保质期。没过期时你可以放心喝;过期后,不代表一定坏了,但你最好先确认一下。

小案例:地区列表每天最多更新一次。服务端返回 Cache-Control: max-age=3600,前端一个小时内可以直接复用;一个小时后,再通过 ETag 确认有没有更新。

这里还有一个初学者高频坑:

no-cache 不是“不能缓存”,而是“可以存,但用之前必须重新验证”;

no-store 才更接近“别存”。

问题三:它们是三选一吗?通常怎么配合?

多数时候,它们不是互相替代,而是分工合作。

| 手段 | 先讲人话 | 最适合的场景 | 最大好处 | 主要代价 |

|---|---|---|---|---|

| 客户端缓存 | 前端自己留副本 | 页面内高频复用、跨页面共用的数据 | 命中后最快,连请求都省 | 失效规则、用户隔离、刷新策略要自己管 |

| ETag/条件请求 | 先问版本变没变 | 数据偶尔更新,但不想每次都传整包 | 减少重复传输,正确性较稳 | 仍然有一次网络往返 |

| HTTP 缓存控制 | 服务端给保鲜期 | 静态或半静态资源、公共接口 | 规则统一,浏览器就能帮你缓存 | 规则配错容易太旧或太频繁回源 |

| 组合使用 | 先本地命中,过期后再校验 | 读多写少的公共数据 | 请求数和传输量都能降 | 一致性治理复杂度最高 |

如果你现在要先落地一个接口,就用这张表判断自己要省的是“请求次数”还是“传输体积”,再决定优先上哪一层。

问题四:能不能给一个能复现的完整例子?

可以。假设你在做一个商城,接口是 /api/categories,它提供商品分类树。

这个接口的特点很典型:

  • 首页要用

  • 搜索页要用

  • 后台偶尔会改

  • 普通用户一天可能看很多次

这时可以这样设计。

第 1 次访问

客户端请求 /api/categories

服务端返回:

  • 分类正文

  • Cache-Control: max-age=600

  • ETag: "cat-v15"

意思是:10 分钟内,这份分类树可以直接用;10 分钟后,拿着 cat-v15 再来问我。

第 2 次访问:10 分钟内

客户端再次进入首页或搜索页。

因为缓存还在新鲜期内,直接用本地数据,不发请求。

第 3 次访问:10 分钟后,但后台没改分类

客户端发现缓存过期,于是带上 If-None-Match: "cat-v15" 去请求。

服务端比较后发现还是 v15,于是返回 304 Not Modified

客户端继续用本地那份分类树。

第 4 次访问:后台把分类改成了 v16

客户端再次发条件请求。

服务端发现版本不同,返回 200、新正文、ETag: "cat-v16"

客户端覆盖旧缓存,后续页面全部使用新版本。

| 时刻 | 客户端怎么做 | 服务端怎么回 | 网络成本 | 客户端手里有什么 |

|---|---|---|---|---|

| 第一次打开 | 直接请求 | 200 + 正文 + ETag | 高 | 首次副本 |

| 10 分钟内再打开 | 直接读缓存 | 无请求 | 最低 | 仍是旧副本,但还在新鲜期 |

| 10 分钟后再打开,数据没变 | 带 ETag 请求 | 304 | 中 | 继续用旧副本 |

| 10 分钟后再打开,数据已变 | 带 ETag 请求 | 200 + 新正文 + 新 ETag | 较高 | 更新为新副本 |

如果你手头正好有“商品分类、地区列表、帮助栏目、字典配置”这类接口,可以直接用这张表对照你当前的请求流程,看看自己是卡在“没缓存”,还是“有缓存但不会校验”。

问题五:什么场景值得上,什么场景别乱上?

不是所有数据都适合这套玩法。缓存不是银弹,乱用会把自己绕晕。

| 数据场景 | 客户端缓存 | ETag/条件请求 | HTTP 缓存控制 | 建议 |

|---|---|---|---|---|

| 国家/地区列表、商品分类、帮助目录 | 很适合 | 很适合 | 很适合 | 优先上,收益通常很高 |

| 公共配置、页脚导航、课程标签 | 适合 | 适合 | 适合 | 先给合理保鲜期,再做版本校验 |

| 用户头像、个人资料 | 适合但要分用户 | 适合 | 适合但要注意私有性 | 记得按用户维度隔离缓存 |

| 库存、余额、未读消息数 | 谨慎 | 可短期校验 | 通常不做长时间强缓存 | 一致性比省请求更重要 |

| 秒杀状态、支付结果、实时行情 | 不建议 | 仅能做很短校验 | 一般不适合 | 优先保证实时性,不要贪缓存 |

拿不准时,先用这张表把接口归类;如果落在后两行,就别为了“看起来高级”硬上重缓存。

问题六:顺手答三个常见误区

误区 1:有了客户端缓存,就不需要 ETag 了?

不对。客户端缓存解决的是“能不能先用本地副本”,ETag 解决的是“本地副本过期后,怎么用更小成本确认它还准不准”。只有前者没有后者,旧数据风险会更大。

误区 2:304 已经很省了,为什么还要 max-age

因为 304 依然有一次网络往返。它省的是“正文传输”,不一定省“请求次数”。如果一份数据在 10 分钟内几乎不可能变化,那先用 max-age 直接不请求,通常更划算。

误区 3:数据包很小,就没必要缓存?

也不对。重复请求的成本不只是一点点字节,还包括连接、往返时间、服务端处理、前端等待和页面抖动。小包裹跑一千次,照样能把人累够呛。

问题七:为什么说代价是“客户端缓存一致性治理更复杂”?

因为从这一刻开始,客户端不再只是“展示数据的人”,还变成了“保管数据的人”。

这会多出至少五类治理问题:

  1. 过期规则谁定?

是 1 分钟、10 分钟还是 1 小时?太短,缓存收益不大;太长,旧数据容易误导用户。

  1. 谁来触发失效?

比如运营后台改了分类,前台什么时候知道?只等自然过期,还是管理端发布后主动清理?

  1. 缓存按谁隔离?

公共数据可以共用,但用户资料、权限菜单、购物车不能串号。A 用户的缓存绝不能借给 B 用户。

  1. 多标签页怎么办?

你在一个标签页里更新了配置,另一个标签页还握着旧副本不撒手,这就是典型的一致性问题。

  1. 手动刷新和兜底怎么办?

当缓存错了,用户能不能一键重新拉?当本地副本损坏时,能不能自动回退到全量请求?

说白了,你把服务器每次都重新给答案的责任,部分搬到了客户端自己维护答案

速度是快了,但客户端也从“只会拿水杯的人”,进化成“还得盯保质期的仓管”。

问题八:初学者怎么落地最稳?

如果你刚接触这类优化,别一上来就把所有接口都缓存起来。稳一点的顺序是:

  1. 先挑对数据

从静态、半静态、重复读取多的公共数据开始,比如分类、地区、配置字典。

  1. 先上 HTTP 缓存控制

这是最容易形成统一规则的一层。先给接口合理的 Cache-Control,把“多久内可直接用”说清楚。

  1. 再加 ETag 做校验

这样缓存过期后,不用每次都传全量正文,能明显减少无效流量。

  1. 最后再做应用层客户端缓存

比如跨页面共享、预取、页面返回秒开,这些体验优化通常依赖应用自己管理副本。

  1. 补上失效策略

至少想清楚:用户切换账号怎么办、后台改数据怎么办、手动刷新怎么办、异常兜底怎么办。

你会发现,真正难的往往不是“存起来”,而是“什么时候该不信它”。缓存最怕的不是没命中,最怕的是命中了过期数据,你还以为自己优化成功了

最后,用一句话把它记住

当数据静态或半静态、重复读取很多时,让客户端多记一点东西,通常能换来更少请求、更少传输和更顺的页面体验。

但这不是白赚:你省下的是网络,新增的是客户端状态管理和缓存一致性治理。

所以最实用的判断方式不是“能不能缓存”,而是:

  • 这份数据值不值得反复拿?

  • 它旧一点会不会出大事?

  • 我有没有能力管住它什么时候失效?

如果这三问里,前两问答案偏“是”,最后一问也能接得住,那么客户端缓存、ETag 和 HTTP 缓存控制,基本就是一套很划算的组合拳。