在缓存设计里,大多数人第一反应是把存在的数据缓存起来:查到了用户资料就放进 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:你刚刚删了一个域名解析,或刚刚新增记录,但客户端就是“死活不生效”。这背后经常就是NXDOMAIN或NODATA被负缓存了。
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
"









网友评论