nginx 代理缓存需要分别作出几项决定:怎样识别已存响应,什么时候允许读取,什么时候允许保存新响应,以及过期副本还能怎样使用。如果把这些都看成一个 TTL 设置,故障就很难解释。验证时应同时观察请求实际经过的路径与交付字节,而不只是确认 nginx 接受了配置文件。

存储容量与新鲜度分开看

proxy_cache_path 声明磁盘存储位置及保存元数据的共享内存 keys_zone,proxy_cache 再为请求启用这个区域。元数据区大小不等于正文缓存容量。max_size 由缓存维护机制管理,不是瞬时硬磁盘配额;inactive 保留策略也不是响应的新鲜度寿命。已经过期的响应仍可能留在存储中,用于后续重新验证。

默认缓存键为 $scheme$proxy_host$request_uri,其中 request_uri 已经包含查询串,后面再加 $is_args$args 就重复拼接了参数。也不能随意替换成规范化后的 URI,或遗漏会改变表示的输入。默认启用 HEAD 转换,因此 GET 与 HEAD 共享相应缓存行为;关闭转换时,就需要在缓存键中考虑请求方法。

核对真正生效的响应策略

proxy_cache_valid 按状态码提供配置寿命,但正常处理的源站新鲜度头具有更高优先级。X-Accel-Expires 还有自己的优先顺序;它不存在时,Cache-Control 和 Expires 才参与相应设置。忽略这些头会改变实际契约。下面的实验里,源站 max-age 为一秒,覆盖了配置的六十秒;no-store 则阻止响应进入存储。

proxy_cache_revalidate 让过期条目使用 ETag、Last-Modified 等验证器发送条件请求。匹配的 304 可以保留正文并更新元数据,变化后的 200 则提供替代字节。但重新验证不等于异步刷新。后台更新是另一项指令,还需要允许在更新期间返回旧内容,不能仅凭某一个开关推断整套刷新方式。

坏掉的样子

proxy_cache_bypass 控制不从缓存读取,proxy_no_cache 控制不保存新响应。要求两者都成立的路由,需要分别表达两个决策。一次绕过也不意味着已经清除了原有条目。实验让这两项指令使用同一个专用测试头,并验证绕过之后,原有副本依然可用。这个头只是实验开关,不能当作真实身份认证或权限策略。

proxy_cache_use_stale http_500 允许在上游返回 500 时使用已有陈旧副本;完全冷的键没有副本可用,因此不会凭空获得保护。这项指令本身没有规定最大陈旧秒数。源站 stale-if-error 时间窗的优先级低于这些指令参数,额外加上它也不能限制这里的设置。启用之前必须先决定旧内容在业务上是否仍然可接受。

否则,一个表面成功的响应可能正在隐藏源站故障,或者继续交付已经不该使用的数据。应把上游结果、nginx 缓存状态和返回正文一起记录。配置语法检查不能证明账户隔离,一次热缓存命中也不代表并发冷加载正常。缓存锁、容量压力下的保留行为和多工作进程模式,都需要单独验证,不能从本次顺序实验推导出来。

快速验证

下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。

需要 POSIX 环境、C 编译器、make、curl、tar 以及 Python 3.11 或更新版本。本次在 macOS 上使用 clang 21.0.0、Python 3.14.4 和 nginx 1.30.4 执行。下列命令只编译一个独立的 HTTP 实验程序,不安装到系统,也不用 sudo;关闭 rewrite 和 gzip 模块以避免它们的外部依赖。这不是部署 TLS 服务的配置教程。

nginx_lab=$(mktemp -d)
curl --fail --location --output "$nginx_lab/nginx.tar.gz" https://nginx.org/download/nginx-1.30.4.tar.gz
tar -xzf "$nginx_lab/nginx.tar.gz" -C "$nginx_lab"
cd "$nginx_lab/nginx-1.30.4"
./configure --prefix="$nginx_lab/install" --without-http_rewrite_module --without-http_gzip_module
make -j2
export NGINX_BIN="$nginx_lab/nginx-1.30.4/objs/nginx"

在同一个 shell 中用 python3 执行 Python 代码。程序创建自己的配置和缓存目录,先检查语法,再在选定的回环端口启动源站与 nginx;两次短暂等待用于跨过真实到期时间。结束时关闭自己启动的代理进程和源站,仅移除临时运行目录。源码下载需要外网,实验请求全部留在本机,不操作现有 nginx 服务。

import hashlib
import os
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import signal
import socket
import subprocess
from tempfile import TemporaryDirectory
from threading import Thread
import time

state = {"body": b"A", "fail": False}
seen = []


class Origin(BaseHTTPRequestHandler):
    def log_message(self, *_):
        pass

    def do_GET(self):
        tag = '"' + hashlib.sha256(state["body"]).hexdigest() + '"'
        conditional = self.headers.get("If-None-Match")
        if self.path == "/short" and state["fail"]:
            status, body = 500, b"failure"
        elif conditional == tag:
            status, body = 304, b""
        else:
            status, body = 200, state["body"]
        directive = "max-age=1" if self.path == "/short" else "max-age=60"
        if self.path == "/no-store" or status == 500:
            directive = "no-store"
        seen.append((self.path, status, conditional))
        self.send_response(status)
        self.send_header("Cache-Control", directive)
        if status != 500:
            self.send_header("ETag", tag)
        if status != 304:
            self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


origin = HTTPServer(("127.0.0.1", 0), Origin)
worker = Thread(target=origin.serve_forever)
worker.start()
try:
    with TemporaryDirectory(prefix="nginx-cache-lab-") as directory:
        root = Path(directory)
        with socket.socket() as reservation:
            reservation.bind(("127.0.0.1", 0))
            port = reservation.getsockname()[1]
        config = root / "nginx.conf"
        config.write_text(f"""
pid nginx.pid;
error_log stderr error;
events {{ worker_connections 64; }}
http {{
  access_log off;
  proxy_cache_path "{root}/cache" keys_zone=lab:1m max_size=10m;
  server {{
    listen 127.0.0.1:{port};
    location / {{
      proxy_pass http://127.0.0.1:{origin.server_port};
      proxy_cache lab;
      proxy_cache_valid 200 60s;
      proxy_cache_revalidate on;
      proxy_cache_use_stale http_500;
      proxy_cache_bypass $http_x_lab_bypass;
      proxy_no_cache $http_x_lab_bypass;
      proxy_connect_timeout 2s;
      proxy_read_timeout 2s;
      add_header X-Lab-Cache $upstream_cache_status always;
    }}
  }}
}}
""", encoding="utf-8")
        command = [os.environ["NGINX_BIN"], "-p", str(root) + "/",
                   "-c", str(config), "-e", "stderr"]
        checked = subprocess.run(command + ["-t"], capture_output=True, text=True)
        assert checked.returncode == 0, checked.stderr
        process = subprocess.Popen(command + ["-g", "daemon off; master_process off;"],
                                   stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
        try:
            deadline = time.monotonic() + 5
            while True:
                assert process.poll() is None, "nginx exited during startup"
                try:
                    with socket.create_connection(("127.0.0.1", port), timeout=0.1):
                        break
                except OSError:
                    if time.monotonic() >= deadline:
                        raise TimeoutError("nginx did not become ready")
                    time.sleep(0.05)

            def get(path, expected, body, bypass=False):
                connection = HTTPConnection("127.0.0.1", port, timeout=3)
                try:
                    connection.request("GET", path, headers={"X-Lab-Bypass": "1"} if bypass else {})
                    response = connection.getresponse()
                    actual = (response.status, response.getheader("X-Lab-Cache"), response.read())
                    assert actual == (200, expected, body), actual
                    print(path, expected, body.decode())
                finally:
                    connection.close()

            get("/long", "MISS", b"A")
            get("/long", "HIT", b"A")
            state["body"] = b"B"
            get("/long", "BYPASS", b"B", bypass=True)
            get("/long", "HIT", b"A")
            assert sum(path == "/long" for path, _, _ in seen) == 2

            get("/short", "MISS", b"B")
            time.sleep(2.1)
            get("/short", "REVALIDATED", b"B")
            assert seen[-1][1:] == (304, '"' + hashlib.sha256(b"B").hexdigest() + '"')
            state["fail"] = True
            time.sleep(2.1)
            get("/short", "STALE", b"B")
            assert seen[-1][1] == 500
            state.update(body=b"C", fail=False)
            get("/short", "EXPIRED", b"C")
            get("/short", "HIT", b"C")
            get("/no-store", "MISS", b"C")
            get("/no-store", "MISS", b"C")
            assert sum(path == "/no-store" for path, _, _ in seen) == 2
        finally:
            if process.poll() is None:
                process.send_signal(signal.SIGQUIT)
            try:
                _, errors = process.communicate(timeout=5)
            except subprocess.TimeoutExpired:
                process.kill()
                process.communicate()
                raise
            assert process.returncode == 0, errors
finally:
    origin.shutdown()
    worker.join()
    origin.server_close()

长寿命路径依次报告 MISS A、HIT A、BYPASS B、HIT A。源站在这条路径只收到两次请求,说明绕过请求获取了 B,却没有把它保存成新的共享副本,也没有删除之前的 A。短寿命路径先报告 MISS B;到期之后报告 REVALIDATED B,源站记录同时确认它确实收到正确的条件 ETag,并返回了 304。

注入源站 500 后,代理报告 STALE B,继续交付原来的正文。源站恢复并提供 C 后,EXPIRED C 替换缓存内容,接下来的访问成为 HIT C。两次 no-store 请求都保持 MISS,且都抵达源站。这里检查的是实际状态和字节,不把日志里出现一个状态词单独当作功能证明。

这些结果来自真实 nginx 的单进程隔离实验,不覆盖后台刷新、分布式失效、生产授权、磁盘压力处理或所有陈旧策略组合。真实发布还需要结合对应版本、完整配置和实际流量补齐这些范围。保留这种边界,才能区分“某条配置路径已经测过”和“整个代理部署已经验收”。

延伸阅读

来源