美文网首页
把不存在也缓存起来:计算机应用中的 negative cache

把不存在也缓存起来:计算机应用中的 negative cache

作者: 华山令狐冲 | 来源:发表于2026-02-14 09:58 被阅读0次

在缓存设计里,大多数人第一反应是把存在的数据缓存起来:查到了用户资料就放进 Redis,命中了就直接返回,省掉数据库开销。可是在真实系统里,压力往往并不只来自命中,反而经常来自查不到:大量请求追着一个根本不存在的对象反复问,导致每次都穿透缓存打到后端,数据库与下游服务被迫做无意义的工作。negative cache(也常被称作negative caching,中文常译为负缓存缓存未命中结果)解决的就是这个问题:把不存在本身当成一种可以缓存的知识保存起来。

RFC 2308 对negative caching给了一个非常经典、也非常工程化的定义:不只是存储某条记录有某个值,也可以反过来存储某条记录不存在,乃至不能或不会给出答案的事实。它强调负缓存能降低负面回答的响应时间、减少解析器与服务器之间的消息数量,从而显著减少网络流量。([IETF Datatracker][1])


negative cache 的本质:缓存否定事实

把缓存看成一个知识库会更容易理解:

  • 正缓存:保存肯定事实,比如user:42 -> {name: ...}
  • 负缓存:保存否定事实,比如user:42 -> 不存在,或者/static/a.js -> 404

它并不是把 cache miss 记下来这么简单,因为 miss 的原因可能完全不同:

  • 真的不存在(应该缓存):对象从未创建过;域名记录不存在;静态文件确实没有
  • 暂时不可用(通常不该缓存,或要极短 TTL):数据库超时、下游 503、网络抖动
  • 权限与策略导致的不可见(谨慎缓存):403、灰度策略、按用户分流的结果

因此,negative cache更准确的工程含义是:缓存层对某个查询结果为“负”的结论进行结构化存储,并为它设计独立的 TTL、失效与一致性策略。如果把它做成和正缓存同等公民,你会获得两个直接收益:

  • cache penetration:对不存在 key 的洪泛请求不再打爆后端
  • 尾延迟:大量“查无此物”的请求能被快速挡在边缘或本地

场景一:DNS 负缓存是教科书级范例

很多人第一次在生产上被负缓存教育,来自 DNS:你刚刚删了一个域名解析,或刚刚新增记录,但客户端就是“死活不生效”。这背后经常就是NXDOMAINNODATA被负缓存了。

RFC 2308 明确指出:DNS 里的负缓存处理的是RRset 不存在域名不存在,并强调它不该再被视作可选能力。([IETF Datatracker][1])

NXDOMAIN 与 NODATA:两种不同的“不存在”

  • NXDOMAIN:名字不存在(域名层面不存在)
  • NODATA:名字存在,但请求类型不存在(比如有 A 记录但没有 AAAA)

RFC 2308 还规定了权威服务器在返回 NXDOMAIN 或 NODATA 时必须带上该 zone 的 SOA,目的是让解析器获得一个可用的负缓存 TTL。([IETF Datatracker][1])

负缓存 TTL 从哪里来

DNS 的负缓存 TTL 不是拍脑袋,而是从 SOA 携带:RFC 2308 说明权威服务器创建 SOA 时,其 TTL 取SOA.MINIMUM 字段SOA 自身 TTL的较小值,并且 TTL 递减到 0 之后该负缓存必须停止使用。([IETF Datatracker][1])

这条规则非常关键,因为它把“负缓存多久”交回给 zone 管理者与协议约束,避免解析器无限期记住不存在,导致“新建记录但用户永远看不到”的灾难。

DNS 负缓存的工程价值

  • 大幅减少无效查询流量:RFC 2308 直说 Internet 上相当比例的 DNS 流量如果实现负缓存就能消除。([IETF Datatracker][1])
  • 提升用户侧体验:对拼写错误域名、追踪像素、历史链接的访问不必每次递归查询
  • 抵御某些攻击流量:大量随机子域名查询(典型的 NXDOMAIN 洪泛)会被更快地吸收在递归缓存层

场景二:HTTP 与 CDN 的 negative caching,本质是缓存错误响应

在 Web 领域,negative caching这个词被云厂商与 CDN 明确写进产品能力,含义就是:把非 2xx 的响应按状态码配置 TTL 缓存起来

Google Cloud CDN 的文档把它说得非常直接:negative caching允许你为每个状态码设置不同 TTL,用来对常见错误或重定向做细粒度缓存控制,从而减少回源压力、降低延迟。(Google Cloud Documentation)

Cloudflare 也给出了默认行为:在缺少显式缓存头时,边缘会对部分状态码套默认 TTL,其中 404 与 410 默认 3 分钟。(Cloudflare Docs)

为什么 HTTP 的 404 能被缓存

很多人以为缓存只缓存 200,其实协议语义允许更多。RFC 9110 明确指出:有一组状态码被定义为heuristically cacheable,例如 200、301、404、410、501 等,它们可以在没有显式新鲜度信息时被缓存用启发式过期策略复用。([IETF Datatracker][4])

这意味着:就算你的源站没写 Cache-Control,某些中间缓存也可能“好心”帮你缓存 404,一旦你后面把资源补上,就会出现刚上传文件却仍然返回 404的典型故障现象。这不是玄学,是协议 + CDN 策略叠加后的必然结果。

典型用法:按状态码设置负缓存 TTL

1) NGINX 反向代理缓存 404

NGINX 的 proxy_cache_valid可以按响应码配置缓存时间,官方文档直接举了 404 缓存 1 分钟的例子。(Nginx)

# 示例:把 404 也缓存一小段时间,避免不存在资源被反复回源
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;

这类配置非常适合“静态资源缺失但短期内不会变”的场景,例如旧版本前端代码还在请求已下线的文件,缓存 404 能显著减少后端日志与磁盘开销。

2) 云 CDN 明确提供 negative caching 策略

Cloud CDN 给出了默认的负缓存 TTL 列表,例如 404 默认 120 秒、405 默认 60 秒,并允许你覆盖。(Google Cloud Documentation)

你会注意到它默认 TTL 都比较短,这其实是一条经验法则:负缓存要带来收益,又要避免“资源刚刚出现但仍被判不存在”的窗口太长

3) CloudFront 也会缓存部分 4xx 与 5xx

CloudFront 文档说明它会缓存若干 4xx 与 5xx(例如 404、500、503 等),并且对部分 4xx 还会依赖源站返回的 Cache-Control。(AWS Documentation)

这对抗源站抖动很有用,但也更危险:如果你把 503 这种“暂时不可用”缓存太久,就等于把一次短暂故障放大成持续故障。因此在实践里常把 5xx 的负缓存 TTL 配得极短,甚至禁用。


场景三:操作系统 VFS 的负缓存:negative dentry

很多人没意识到:负缓存并不是云时代才有,Linux 内核早就靠它优化文件路径解析。

在 VFS 的 dentry cache(dcache)里存在一种negative dentry:它的 d_inode 为 NULL,表示这个路径名没有对应 inode(可能从未存在,也可能被删了)。内核仍然把它保留在缓存中,让未来对同一路径的查找更快。(litux.nl)

Linux Kernel Labs 的讲义也把 dentry 的状态列得很清楚:Negative状态表示 d_inode无效,文件未加载或已被删除。(linux-kernel-labs.github.io)

这就是非常纯粹的negative cache:缓存“这个路径解析到这里是死路”的结论,避免每次都去磁盘与目录项结构里重新走一遍。

从工程直觉上看,它特别像 DNS 的 NXDOMAIN:都在缓存名字解析失败这件事,只是 DNS 解析的是域名树,VFS 解析的是目录树。


场景四:应用层缓存穿透与缓存空值,就是 negative cache

在业务系统里,negative cache最常见的落地形态叫缓存空值缓存 null。典型事故是:攻击者或异常客户端持续请求随机 id,数据库被迫做大量 SELECT ... WHERE id = ?,每次都返回空,缓存层却记不住这些空结果,于是全部穿透到数据库。

Harness 的文档在描述Redis cache penetration故障时就明确指出:持续对不存在 key 发请求会降低应用性能。(developer.harness.io)

为什么很多框架要提供 NullValue 之类的占位符

有些缓存后端不支持直接存 null,或者需要区分缓存里没有这个 key这个 key 的值就是 null。Spring Framework 提供了一个 NullValue类,专门作为null replacement,服务于那些不支持存 null 的 cache store。(Home)

这就是把不存在编码成一个可存储的哨兵值(sentinel),它在语义上等同于负缓存条目。


负缓存的关键设计:什么时候该缓存不存在

负缓存做得好是减压神器,做得不好会变成“自我制造的黑洞”。判断的核心不在于有没有负结果,而在于负结果的稳定性

值得负缓存的负结果

  • 稳定的不存在:用户 id 根本不存在;静态文件确实没有;DNS 的 NXDOMAIN 与 NODATA(协议上就为此设计了机制)([IETF Datatracker][1])
  • 可预测的错误:某些长期下线资源的 404;永久重定向 301 这类也常被归到非 2xx里一起做策略(云厂商把它放进 negative caching 列表)(Google Cloud Documentation)

需要极谨慎的负结果

  • 5xx:通常代表暂时失败,缓存等于放大故障。即使 CDN 支持缓存 503,也更建议 TTL 极短或禁用,并配合 stale-while-revalidate 之类策略在边缘兜底。(AWS Documentation)
  • 与用户身份强相关的 403:若放到共享缓存,可能造成权限穿越式的信息泄露或误伤
  • 强一致业务下的“刚创建就要可见”:例如注册后立刻查询,负缓存 TTL 过长会让用户以为创建失败

TTL、失效与并发:负缓存要像正缓存一样讲究

负缓存 TTL 通常应短于正缓存 TTL

经验上常见组合是:

  • 正缓存 TTL:几十秒到数小时(看业务更新频率)
  • 负缓存 TTL:几秒到几分钟(看“从不存在到出现”的可能性)

Cloudflare 对 404 默认只给 3 分钟,Cloud CDN 对 404 默认 120 秒,这其实体现了同一个经验:负结果更容易变。(Cloudflare Docs)

失效方式:能主动 purge 就别全靠等 TTL

在业务系统里,最舒服的做法是:

  • 新建对象时,主动删除对应负缓存 key
  • 删除对象时,删除正缓存 key,并允许负缓存重新建立

这能把“负缓存导致新对象不可见”的窗口从TTL缩小到一次写操作的延迟

并发与 dogpile:负缓存同样会雪崩

很多团队只对正缓存做singleflight,却忘了不存在也会被并发请求打爆。你要把负缓存 miss也当成会触发后端访问的事件,照样需要:

  • 请求合并(同一个 key 同一时刻只允许一个线程去查 DB)
  • TTL 加随机抖动,避免一堆负缓存同时过期

一份可运行的完整代码:用 Python 演示 negative cache 的收益与陷阱

下面这段代码是一个最小但工程味很足的示例:

  • 后端用 SQLite 模拟数据库
  • 前端用一个带 TTL 的内存缓存模拟 Redis 或本地缓存
  • 同时缓存命中数据不存在(负缓存)
  • 负缓存 TTL 很短,并支持主动失效
  • singleflight风格的 per-key 锁,避免并发穿透

把它保存为 negative_cache_demo.py,用 python3 negative_cache_demo.py 运行即可。

import sqlite3
import threading
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple

@dataclass
class CacheEntry:
    value: Any
    expire_at: float
    is_negative: bool

class TTLNegativeCache:
    def __init__(self) -> None:
        self._data: Dict[str, CacheEntry] = {}
        self._lock = threading.Lock()
        self._key_locks: Dict[str, threading.Lock] = {}

    def _now(self) -> float:
        return time.monotonic()

    def get(self, key: str) -> Optional[CacheEntry]:
        now = self._now()
        with self._lock:
            ent = self._data.get(key)
            if ent is None:
                return None
            if ent.expire_at <= now:
                del self._data[key]
                return None
            return ent

    def set(self, key: str, value: Any, ttl_seconds: float, is_negative: bool) -> None:
        expire_at = self._now() + ttl_seconds
        with self._lock:
            self._data[key] = CacheEntry(value=value, expire_at=expire_at, is_negative=is_negative)

    def delete(self, key: str) -> None:
        with self._lock:
            self._data.pop(key, None)

    def _get_key_lock(self, key: str) -> threading.Lock:
        with self._lock:
            lk = self._key_locks.get(key)
            if lk is None:
                lk = threading.Lock()
                self._key_locks[key] = lk
            return lk

    def singleflight(self, key: str) -> threading.Lock:
        return self._get_key_lock(key)

class UserRepo:
    def __init__(self, conn: sqlite3.Connection) -> None:
        self._conn = conn
        self.query_count = 0
        self._lock = threading.Lock()

    def get_user_name(self, user_id: int) -> Optional[str]:
        with self._lock:
            self.query_count += 1
        cur = self._conn.execute('select name from users where id = ?', (user_id,))
        row = cur.fetchone()
        return None if row is None else row[0]

    def insert_user(self, user_id: int, name: str) -> None:
        self._conn.execute('insert into users(id, name) values(?, ?)', (user_id, name))
        self._conn.commit()

def cached_get_user_name(
    repo: UserRepo,
    cache: TTLNegativeCache,
    user_id: int,
    positive_ttl: float,
    negative_ttl: float,
) -> Optional[str]:
    key = f'user:{user_id}'

    ent = cache.get(key)
    if ent is not None:
        return None if ent.is_negative else ent.value

    lk = cache.singleflight(key)
    with lk:
        ent2 = cache.get(key)
        if ent2 is not None:
            return None if ent2.is_negative else ent2.value

        name = repo.get_user_name(user_id)
        if name is None:
            cache.set(key, value=None, ttl_seconds=negative_ttl, is_negative=True)
            return None

        cache.set(key, value=name, ttl_seconds=positive_ttl, is_negative=False)
        return name

def main() -> None:
    conn = sqlite3.connect(':memory:', check_same_thread=False)
    conn.execute('create table users(id integer primary key, name text not null)')
    conn.execute('insert into users(id, name) values(?, ?)', (1, 'alice'))
    conn.commit()

    repo = UserRepo(conn)
    cache = TTLNegativeCache()

    positive_ttl = 30.0
    negative_ttl = 3.0

    print('阶段 A:反复查询不存在的 user:42')
    for _ in range(10):
        v = cached_get_user_name(repo, cache, 42, positive_ttl, negative_ttl)
        print('user:42 ->', v)
        time.sleep(0.2)

    print('数据库查询次数 ->', repo.query_count)
    print('解释:第 1 次打到 DB,后续在 negative cache TTL 内直接返回空')

    print('\n阶段 B:在 negative cache 仍有效时创建 user:42')
    repo.insert_user(42, 'bob')
    v = cached_get_user_name(repo, cache, 42, positive_ttl, negative_ttl)
    print('创建后立刻查询 user:42 ->', v)
    print('提示:如果你没有主动失效,短 TTL 内可能仍然读到负缓存')

    print('\n阶段 C:主动失效 negative cache,再查一次')
    cache.delete('user:42')
    v = cached_get_user_name(repo, cache, 42, positive_ttl, negative_ttl)
    print('失效后查询 user:42 ->', v)
    print('数据库查询次数 ->', repo.query_count)

    print('\n阶段 D:等待 negative TTL 过期后再演示自然恢复')
    cache.delete('user:99')
    for _ in range(3):
        _ = cached_get_user_name(repo, cache, 99, positive_ttl, negative_ttl)
        time.sleep(0.2)
    print('查询不存在 user:99 后的数据库查询次数 ->', repo.query_count)
    print('等待 4 秒让 negative TTL 过期')
    time.sleep(4.0)
    _ = cached_get_user_name(repo, cache, 99, positive_ttl, negative_ttl)
    print('negative TTL 过期后再次查询 user:99,数据库查询次数 ->', repo.query_count)

if __name__ == '__main__':
    main()

你运行后会观察到两个现象:

  • 查询不存在对象的数据库次数被显著压缩,说明负缓存确实能挡住穿透
  • 如果在负缓存 TTL 内创建了对象,不做主动失效就可能短时间读不到新对象,这正是负缓存最常见的“副作用窗口”,因此需要短 TTL 或写路径 purge

把这些场景统一起来看:negative cache 是“名字解析失败”的缓存

把 DNS、HTTP、VFS、业务缓存放在一张脑内图里,你会发现它们惊人一致:

  • DNS:缓存这个名字不存在(NXDOMAIN / NODATA),TTL 由 SOA 规则决定([IETF Datatracker][1])
  • HTTP/CDN:缓存这个 URL 当前返回某个错误码,TTL 可按状态码配置(Google Cloud Documentation)
  • Linux VFS:缓存这个路径分量解析不到 inode(negative dentry),加速未来 lookup(litux.nl)
  • 应用缓存:缓存这个 key 在 DB 里没有对应对象,避免 cache penetration(developer.harness.io)

它们共同的本质是:把“否定结论”作为一等数据缓存。一旦你接受了这点,负缓存就不再是某个“技巧”,而是缓存系统里必备的对称结构:正负两类事实都要有存储与治理策略。


实战建议:负缓存上线前该做的检查清单

  • 为负缓存单独设计 TTL,并且默认比正缓存更短(参考 Cloud CDN、Cloudflare 对 404 的分钟级默认值)(Google Cloud Documentation)
  • 明确区分不存在暂时失败:把 5xx 当作负缓存对象时要格外克制(CloudFront 虽支持缓存 5xx,但更适合短 TTL 与兜底策略)(AWS Documentation)
  • 写路径尽量 purge:创建、恢复、回滚时主动删除负缓存 key,缩短不可见窗口
  • 对负缓存也做请求合并与抖动:不存在的热点 key 同样会被并发打爆
  • 监控负缓存命中率与“误伤率”:负缓存命中率高不一定是好事,可能意味着大量无效请求或攻击流量;误伤率高通常意味着 TTL 过长或 purge 缺失

如果你愿意把你的具体业务(对象创建频率、读写比、是否强一致、是否有攻击面、是否走 CDN)说一下,我可以按你的约束把负缓存策略细化到TTL 范围状态码白名单purge 时机观测指标,并给出一份更贴近生产的实现方案。

[1]: https://datatracker.ietf.org/doc/html/rfc2308 "

            RFC 2308 - Negative Caching of DNS Queries (DNS NCACHE)
        
    "

[4]: https://datatracker.ietf.org/doc/html/rfc9110 "

            RFC 9110 - HTTP Semantics
        
    "

相关文章

  • # Java DNS Cache

    Cache Type DNS的缓存类型分为两种:Positive和Negative,Positive用于缓存可以正...

  • 19.LRU Cache的实现、应用和题解

    19.LRU Cache的实现、应用和题解 Cache缓存 我们先来认识一下cache以及cache在现实中的应用...

  • Hibernate(十四)二级缓存

    一、Hibernate 缓存 缓存(Cache): 计算机领域非常通用的概念。它介于应用程序和永久性数据存储源(如...

  • ecjia_cache函数使用

    通过ecjia_cache函数获取缓存实例 调用缓存方法 get 方法可以用来取出缓存中的项目,缓存不存在的话返回...

  • 数据一致性(7.10)

    在计算机科学中,缓存一致性(英语:Cache coherence,或cache coherency),又译为缓...

  • 对NSURLRequestUseProtocolCachePol

    默认的缓存策略, 如果缓存不存在,直接从服务端获取。如果缓存存在,会根据response中的Cache-Contr...

  • rails中的cache

    缓存指南总结 Web 应用中常用的各种 Cache 最常用的cache应该是页面的片段缓存和底层缓存。 片段缓存A...

  • H5 离线缓存及 Nginx 服务器配置

    欢迎移步 什么是Application Cache HTML5 的应用缓存(application cache),...

  • beego cache模块源码解析

    缘起.什么是cache? cache的中文名叫缓存,缓存在计算机的世界里无处不在,比如cpu的多级缓存,比如类似e...

  • Hibernate--->缓存

    一、缓存 缓存(Cache):计算机领域非常通用的概念。他介于应用程序和永久性数据存储源(如硬盘上的文件或者数据库...

网友评论

      本文标题:把不存在也缓存起来:计算机应用中的 negative cache

      本文链接:https://www.haomeiwen.com/subject/biborstx.html