Language:Chinese VersionEnglish Version

每位网页开发者最终都会撞上同一面墙。应用程序在开发环境中运行良好,演示过程也很顺畅,但一旦真实用户出现就崩溃了。响应时间从200毫秒攀升到2秒。数据库开始不堪重负。有人建议”只需添加缓存”,突然间你面对的是一个表面上简单但实则隐藏了十年工程陷阱的问题。

缓存不是单一技术,而是一堆相互依赖的层次,每个层次都有自己的行为、故障模式和失效难题。做对了,你的应用程序可以处理100倍的流量而游刃有余;做错了,你将周末都花在调试那些仅在生产环境中出现的数据过期bug上。

本文将详细介绍2026年现代Web应用的完整缓存栈——从浏览器一直到数据库查询缓存——包括具体的实现细节、真实的配置示例,以及如果不小心就会让你头疼的bug。

多层缓存模型

将缓存想象为用户浏览器和数据库之间的一系列检查点。每一层都会拦截请求并尝试直接提供响应,而不将请求传递到更深的层次:

浏览器缓存 -> CDN边缘节点 -> 反向代理(Nginx/Varnish) -> 应用缓存(Redis) -> ORM/查询缓存 -> 数据库缓冲池

每一层都有不同的特点。浏览器缓存是每个用户独有的,由HTTP头控制。CDN缓存是跨用户共享的,但按地理位置分布。像Redis这样的应用缓存提供了编程控制,但需要明确管理。数据库缓存大多是自动的,但在优化方面有限制。

关键洞见是,每一层都有不同的用途,跳过任何一层都会造成其他层无法补偿的瓶颈。如果每个用户每次页面加载都下载相同的500KB JavaScript包,Redis缓存也无济于事。如果你的API响应是个性化的且在边缘节点无法缓存,CDN也无能为力。

浏览器缓存:最被低估的层次

浏览器缓存是免费的性能提升。你不需要基础设施,不需要Redis集群,也不需要CDN合同。你只需要正确的HTTP头。

最重要的两个头部是 Cache-ControlETag。以下是它们在实际工作中的原理:

# 静态资源的 Nginx 配置
location ~* .(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Vary "Accept-Encoding";
}

# 不常变化的 API 响应
location /api/v1/categories {
    add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400";
    add_header ETag $upstream_http_etag;
}

# 用户特定内容 -- 在共享层永不缓存
location /api/v1/me {
    add_header Cache-Control "private, no-store";
}

immutable 指令是许多团队仍然忽视的一点。没有它,即使对于设置了远期过期日期的资源,浏览器也会发送条件请求(If-None-Match)——特别是在页面重新加载时。添加 immutable 告诉浏览器:”此资源在该 URL 永远不会改变。甚至不要询问。” 结合内容哈希文件名(如 app.3f8a2b1c.js),这完全消除了不必要的网络往返。

stale-while-revalidate 指令已在 RFC 5861 中标准化,现在所有主要浏览器都支持它,对于 API 响应同样强大。它告诉浏览器:”立即提供缓存版本,但在后台获取新副本。” 用户看到即时响应,同时缓存保持合理的新鲜度。这对于定期变化但不需要实时数据的产品目录、博客列表、配置数据等非常理想。

常见浏览器缓存错误

错误 #1:为 HTML 页面设置远期过期时间。 如果你在 index.html 上设置 max-age=31536000,除非用户强制刷新,否则他们将永远不会看到更新。HTML 文档应使用 no-cache(仍允许缓存但强制重新验证)或短 TTL。

错误 #2:缺少 Vary 头部。 如果你的服务器根据 Accept-EncodingAccept-Language 头提供不同内容,你必须包含 Vary 以防止缓存提供错误版本。在提供压缩响应时忘记 Vary: Accept-Encoding 是一个经典错误,会导致用户收到乱码内容。

错误 #3:当需要 no-store 时设置 no-cache 这两者不是一回事。no-cache 仍会存储响应但每次都会重新验证。no-store 防止任何存储。对于敏感数据(认证令牌、个人信息),你需要 no-store

CDN 缓存:大规模边缘性能

CDN 将您内容的缓存副本放置在全球分布的服务器上。当东京的用户请求您的页面时,他们会访问东京的服务器,而不是您位于弗吉尼亚的源服务器。仅从物理角度而言——将往返时间从 150ms 减少到 5ms——就能产生显著差异。

到 2026 年,CDN 市场已围绕几家主要参与者整合。Cloudflare 仍然是大多数应用的默认选择,其免费层级能够处理相当可观的流量。当您需要 VCL 级别的控制或实时日志流式传输时,Fastly 仍然占据主导地位。AWS CloudFront 与 AWS 生态系统紧密集成。Bunny CDN 通过透明的定价以及在主要参与者存在空白区域的强劲性能,找到了自己的利基市场。

CDN 缓存的重要配置决策包括:

在边缘缓存什么: 静态资源(图片、JS、CSS、字体)是显而易见的。但如果您正确构建缓存键,也可以缓存 API 响应、HTML 页面,甚至是 GraphQL 查询。Cloudflare 的缓存规则和 Fastly 的 VCL 都允许您基于 URL 模式、标头、cookie 和查询参数定义自定义缓存逻辑。

// Cloudflare 缓存规则(通过 API)
// 在边缘缓存产品页面的 API 响应 10 分钟
{
  "expression": "(http.request.uri.path.matches "^/api/v1/products/[0-9]+$")",
  "action": "set_cache_settings",
  "action_parameters": {
    "cache": true,
    "edge_ttl": {
      "mode": "override_origin",
      "default": 600
    },
    "cache_key": {
      "custom_key": {
        "query_string": {
          "include": ["fields", "lang"]
        }
      }
    }
  }
}

缓存键设计: 缓存键决定了什么算作”相同的请求”。默认情况下,CDN 使用包含查询参数的完整 URL。但如果您的 URL 中包含跟踪参数(如 utm_sourcefbclid),则来自社交媒体的每个链接都会导致缓存未命中。从缓存键中剥离无关的查询参数。这一单一更改通常可将缓存命中率从 40% 提高到 80% 以上。

清除策略: 当内容发生变化时,您需要使 CDN 缓存失效。大多数 CDN 提供三种方法:按 URL 清除、按标签/代理键清除以及清除所有内容。基于标签的清除对于动态网站最为实用。为您的缓存响应标记逻辑标识符(例如 product-123category-electronics),当产品更新时,清除所有标记了该产品 ID 的响应。

应用级缓存:2026 年的 Redis

应用级缓存是您拥有最多控制权和责任的地方。Redis 在这里仍然是主导选择,尽管过去几年情况发生了显著变化。

Redis 8.0(预计2025年底发布)带来了重大变化。2024年的重新许可争议——当时 Redis Labs 从 BSD 许可证转向 SSPL/RSALv2 双许可证——已经基本平息。社区分叉版本(由 Linux Foundation 支持的 Valkey,以及严格兼容 Redis 的 Redict)已经发展为可行的替代方案。如果您在 AWS 上运行,ElastiCache 现在底层默认使用 Valkey。Azure Cache for Redis 和 GCP Memorystore 仍使用上游 Redis。

Redis 与 Memcached:比较是否还有意义?

在2026年,选择 Memcached 而不是 Redis 越来越少见,但仍然有充分的理由。Memcached 的多线程架构意味着对于简单的键值查找,单个实例就可以充分利用现代 CPU。尽管 Redis 在 7.x 和 8.x 版本中改进了 I/O 线程,但命令执行本质上仍然是单线程的。如果您的工作负载是纯缓存——只有简单的 GET/SET 操作,无需数据结构——并且您在拥有 32+ 核心的机器上运行,Memcached 会提供更好的每节点吞吐量。

<p但对于几乎所有其他人来说,Redis 因其数据结构而胜出。排行榜和速率限制的有序集合。事件队列的流。基数估算的 HyperLogLog。存储结构化对象而无需序列化开销的哈希类型。这些不仅仅是锦上添花的功能——它们从根本上改变了您设计缓存层的方式。

实用的 Redis 缓存模式

缓存旁路(延迟加载):最常见的模式。您的应用程序首先检查 Redis。如果未命中,则查询数据库,将结果写入 Redis,然后返回结果。简单有效,但在冷启动时容易受到缓存雪崩的影响。

# Python 示例,使用 redis-py 5.x
import redis
import json

r = redis.Redis(host='redis-primary', port=6379, decode_responses=True)

def get_product(product_id: int) -> dict:
    cache_key = f"product:{product_id}:v3"

    # 检查缓存
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    # 缓存未命中 -- 查询数据库
    product = db.query("SELECT * FROM products WHERE id = %s", product_id)
    if product is None:
        # 缓存负结果以防止重复的数据库查询
        r.setex(f"product:{product_id}:null", 300, "1")
        return None

    # 写入缓存并设置 TTL
    r.setex(cache_key, 3600, json.dumps(product))
    return product

直写缓存:当数据更新时,在同一操作中同时写入数据库和缓存。这使缓存保持温暖和一致,但会增加写入操作的延迟。对于读取频率远高于写入频率的数据非常有效。

缓存雪崩预防:当热门缓存键过期时,数百个并发请求可能同时全部未命中缓存,而全部访问数据库。标准解决方案是使用分布式锁:

def get_product_safe(product_id: int) -> dict:
    cache_key = f"product:{product_id}:v3"
    lock_key = f"lock:product:{product_id}"

    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    # 尝试获取锁(SET NX with expiry)
    acquired = r.set(lock_key, "1", nx=True, ex=10)

    if acquired:
        # 获取到锁 -- 重建缓存
        product = db.query("SELECT * FROM products WHERE id = %s", product_id)
        r.setex(cache_key, 3600, json.dumps(product))
        r.delete(lock_key)
        return product
    else:
        # 另一个请求正在重建 -- 等待并重试
        time.sleep(0.05)
        return get_product_safe(product_id)

版本化缓存键

最可靠的失效策略之一是键版本控制。当数据发生变化时,不是删除缓存条目,而是在缓存键中递增版本号。旧条目通过 TTL 自然过期,新请求立即获取最新数据。

# 为每个实体存储版本计数器
def invalidate_product(product_id: int):
    r.incr(f"product:{product_id}:version")

def get_product_versioned(product_id: int) -> dict:
    version = r.get(f"product:{product_id}:version") or "0"
    cache_key = f"product:{product_id}:v{version}"

    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    product = db.query("SELECT * FROM products WHERE id = %s", product_id)
    r.setex(cache_key, 3600, json.dumps(product))
    return product

这种方法避免了显式缓存删除带来的”惊群”问题,并且在分布式系统中也能自然工作,因为缓存删除消息可能乱序到达。

缓存失效:困难的部分

Phil Karlton 的名言 —”计算机科学中只有两件困难的事情:缓存失效和给事物命名”— 至今仍然令人痛苦地准确。缓存失效之所以困难,是因为它要求你回答一个根本性的难题:”数据何时会变得过时,我该如何处理?”

有三种广泛使用的策略:

基于 TTL 的过期:为每个缓存条目设置生存时间,并接受在该时间段内数据可能过时。这是最简单的方法,对许多应用来说效果出奇地好。关键是选择合适的 TTL。太短,你就无法获得太多缓存收益。太长,用户就会看到过时的数据。对于大多数应用,API 响应设置 5 分钟 TTL,不常变化的参考数据设置 1 小时 TTL,是一个合理的起点。

事件驱动的失效机制:当数据库中的数据发生变化时,发布一个触发缓存失效的事件。这能提供接近实时的数据一致性,但需要事件传递的基础设施。在实践中,这意味着使用数据库触发器、应用级别的事件发布(例如,在成功写入后),或使用 Debezium 等变更数据捕获(CDC)工具读取数据库的预写日志。

# 使用简单的发布/订阅方法实现事件驱动的失效机制
# 发布者(在您的写入路径中)
def update_product(product_id: int, data: dict):
    db.execute("UPDATE products SET ... WHERE id = %s", product_id)
    r.publish("cache_invalidation", json.dumps({
        "entity": "product",
        "id": product_id,
        "action": "updated"
    }))

# 订阅者(作为后台工作进程运行)
def cache_invalidation_listener():
    pubsub = r.pubsub()
    pubsub.subscribe("cache_invalidation")
    for message in pubsub.listen():
        if message["type"] == "message":
            event = json.loads(message["data"])
            pattern = f"{event['entity']}:{event['id']}:*"
            keys = r.keys(pattern)
            if keys:
                r.delete(*keys)

版本化键(如上所述):混合方法。使用 TTL 进行自然过期,但通过增加版本计数器实现即时失效。这结合了 TTL 的简单性和事件驱动失效的响应性。

数据库查询缓存

大多数数据库都有某种形式的内部缓存。PostgreSQL 的 shared_buffers 设置控制了多少内存专门用于缓存表和索引数据。MySQL 曾有一个内置的查询缓存,但在 8.0 版本中被移除,因为它在多核系统上引起的争用比带来的好处更多。到 2026 年,如果您使用 MySQL,查询级别的缓存应该发生在 Redis 或您的应用层,而不是在数据库本身。

PostgreSQL 的缓冲缓存更加复杂。它缓存的是磁盘页面,而不是查询结果,因此重复执行相同的查询仍然会产生解析和规划成本。对于频繁执行的复杂查询,考虑使用定期刷新的物化视图:

-- 为复杂聚合创建物化视图
CREATE MATERIALIZED VIEW product_stats AS
SELECT
    p.id,
    p.name,
    COUNT(r.id) as review_count,
    AVG(r.rating) as avg_rating,
    MAX(r.created_at) as latest_review
FROM products p
LEFT JOIN reviews r ON r.product_id = p.id
GROUP BY p.id, p.name;

-- 在物化视图上创建索引
CREATE UNIQUE INDEX idx_product_stats_id ON product_stats(id);

-- 定期刷新(例如通过 pg_cron)
SELECT cron.schedule('refresh_product_stats', '*/5 * * * *',
    'REFRESH MATERIALIZED VIEW CONCURRENTLY product_stats');

整合所有内容:一个真实世界的例子

让我们追踪一个电子商务产品页面请求通过所有缓存层的过程。

首次访问:浏览器没有缓存任何内容。请求到达 CDN,但 CDN 也没有缓存。请求到达您的源服务器。应用程序检查 Redis — 缓存未命中。它查询数据库,构建 HTML 响应,将结果存储在 Redis 中(TTL: 10 分钟),然后返回响应。CDN 缓存该响应(TTL: 5 分钟)。浏览器使用 stale-while-revalidate=300 缓存它。总时间:约 400 毫秒。

第二次访问(5 分钟内):浏览器立即提供其缓存的副本。如果使用 stale-while-revalidate,它还会向 CDN 发起后台请求,CDN 提供其缓存的副本。总时间:0 毫秒(从用户角度看)。

CDN TTL 过期但 Redis TTL 内的访问:CDN 将请求转发到源服务器。应用程序在 Redis 中找到数据并快速返回。CDN 重新缓存响应。总时间:约 50 毫秒。

产品数据已更新:写入路径发布一个失效事件。此产品的 Redis 键被删除。在下一次 CDN 未命中时,应用程序从数据库获取新数据并重新填充 Redis。如果您在 CDN 中使用代理键,也可以立即清除边缘缓存。

监控您的缓存

您不监控的缓存是一种负债。您应该跟踪的最低指标:

命中率:从缓存提供请求的百分比。对于 Redis,使用 INFO stats 并查看 keyspace_hitskeyspace_misses。健康的应用程序缓存命中率应高于 90%。低于 80% 时,说明有问题 — 要么您的 TTL 太短,要么您的缓存键太具体,要么您的工作集不适合内存。

驱逐率:如果 Redis 正在驱逐键,则您的 maxmemory 对于您的工作集来说太低。检查 INFO stats 中的 evicted_keys。对于应该长期保存数据的缓存出现非零驱逐,意味着您需要更多内存,或者需要更谨慎地选择缓存内容。

延迟百分位:Redis 很快,但网络延迟、连接池问题或慢速命令可能导致尾部延迟。使用 redis-cli --latency-history 进行快速诊断,或使用 Prometheus 和 Redis 导出器设置适当的监控。关注 p99 延迟 — 如果它在 p50 保持平稳的情况下激增,您可能遇到了慢速命令或连接问题。

按 URL 模式的 CDN 命中率:大多数 CDN 提供分析仪表板。查找命中率低的 URL 模式 — 这些是配置修复的候选对象(缓存键剥离、更长的 TTL 或头部调整)。

常见缓存错误(及如何避免)

过期的会话错误:你在 Redis 中缓存用户会话数据,设置了 30 分钟的 TTL。用户登出后,他们的会话仍在缓存中。另一个请求带着旧的会话令牌进来,获取了缓存的(现已失效的)会话。解决方案:登出时,明确删除缓存中的会话——不要仅依赖 TTL 来处理安全敏感数据。

热点键问题:某个缓存键的流量远超其他键(例如,一个病毒式传播的产品页面)。单个 Redis 节点每秒处理数百万个请求,成为瓶颈。解决方案:使用客户端路由将热点键复制到多个 Redis 节点,或在 Redis 前端使用本地进程内缓存(应用程序中的小型 LRU 缓存)作为缓冲。

缓存-数据库竞争条件:请求 A 从数据库读取一个产品(价格:10 美元)。请求 B 将价格更新为 15 美元并使缓存失效。请求 A 将过期的 10 美元价格写入缓存。现在缓存中的数据已过时,直到 TTL 过期。解决方案:使用版本化键,或在写入缓存前检查版本/时间戳。

序列化不匹配:你向缓存对象添加了一个新字段,部署到生产环境,但现有的缓存条目没有这个字段。你的应用程序因 KeyError 崩溃。解决方案:在反序列化时始终优雅地处理缺失字段,并考虑使用版本化键模式,这样部署会自然刷新缓存。

负缓存缺失:你的应用程序缓存成功的数据库查询,但不缓存失败的查询。攻击者或错误向不存在的 ID 发送数百万个请求。每个请求都未命中缓存而直接访问数据库。解决方案:也缓存负结果(使用较短的 TTL),并实施速率限制作为额外的保护措施。

总结

缓存不是在性能成为问题时才添加的附加功能。它应该作为架构决策的一部分,从设计之初就纳入考量。每一层——浏览器、CDN、应用程序、数据库——解决的是不同类型的性能问题,而最佳应用程序会刻意地使用所有这些层。

从浏览器缓存开始,因为它免费且有效。为静态资源和公共内容添加 CDN。使用 Redis 处理不适合 HTTP 缓存语义的应用特定数据。并根据你的一致性要求选择失效策略,而不是基于最容易实现的方案。

缓存困难的部分不在于实现,而在于维护的纪律——监控命中率、调查异常、随着使用模式变化更新 TTL,以及编写能够优雅处理缓存故障的代码。将你的缓存层视为架构中的一等组件,它将在未来多年为你带来回报。

By Michael Sun

Founder and Editor-in-Chief of NovVista. Software engineer with hands-on experience in cloud infrastructure, full-stack development, and DevOps. Writes about AI tools, developer workflows, server architecture, and the practical side of technology. Based in China.

Leave a Reply

Your email address will not be published. Required fields are marked *

You missed