DNS 可以通过说明数据缺失提供有效信息。NXDOMAIN 表示名称错误,NODATA 则描述成功响应中没有所请求类型的数据。对于没有别名的直接查询,两者区分名称不存在,以及名称存在但缺少该类数据。缓存这些结果,可以避免每次客户端请求都重新进行相同的无结果上游查询。负向回答也是有内容、有寿命的状态,并不是“没有收到任何东西”。
坏掉的样子
部署已经创建正确记录,先前缓存过缺失结果的客户端却可能仍然失败。权威返回新数据,缓存则在寿命结束前继续返回旧否定结果。反复删除重建记录,不会使远端缓存自动失效。这种情况常出现在应用提前查询尚未配置完成的服务名称时;排障应对照查询发生时间和缓存寿命,而不是只检查现在的区域文件是否包含记录。
仅凭回答区为空,不能完整分类 DNS 结果。需要检查响应码、权威区和可能存在的别名链。转介与权威否定回答不同,NXDOMAIN 也可能指向 CNAME 最终目标,而非最初请求的别名本身。把所有空结果都说成“域名不存在”,可能让调查去改错误的记录。应保留原始问答的类型、名称及各区数据,避免过早简化成一句错误描述。
对于 RFC 2308 规定的权威 NXDOMAIN 和 NODATA,负向寿命取 SOA.MINIMUM 与 SOA 记录 TTL 中的较小值。该值放在否定回答的 SOA TTL 上,再由缓存继续递减。MINIMUM 不是所有正向记录 TTL 的统一下限;只改变将来正向记录的 TTL,也不会缩短已经缓存的不存在结论。操作时应确认正在调整哪类数据的哪一代寿命。
快速验证
历史解析器集成示例保持原样,本次没有重跑;当前没有 Docker,也不能建立网络监听。
将脚本保存为 check-negative-dns.py,准备 Docker 后执行 python3 check-negative-dns.py。固定环境使用 BIND 9.18.49 和 dnsmasq 2.90,只有隔离回环网络。第一个区域的 SOA TTL 为八秒、minimum 为四秒,并有一条现存 A 记录。实验在新增缺失数据前后检查真实响应与权威查询次数,不通过模拟字典代替解析器缓存行为:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends bind9=1:9.18.49-1~deb12u2 bind9-dnsutils=1:9.18.49-1~deb12u2 dnsmasq-base=2.90-4~deb12u2 python3-dnspython=2.3.0-1 && rm -rf /var/lib/apt/lists/*\nCMD ["/usr/bin/python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,re,signal,subprocess,time
import dns.exception,dns.message,dns.query,dns.rcode,dns.rdatatype
def run(*args,check=True):
result=subprocess.run(args,capture_output=True,text=True,timeout=15)
if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
return result
def query(name,kind='A',port=1053):
return dns.query.udp(dns.message.make_query(name,kind),'127.0.0.1',port=port,timeout=1)
def values(response,kind='A'):
wanted=dns.rdatatype.from_text(kind)
return [item.to_text() for rrset in response.answer if rrset.rdtype==wanted for item in rrset]
def show(response):
def section(records):return [{'name':str(rr.name),'type':dns.rdatatype.to_text(rr.rdtype),'ttl':rr.ttl,'data':[item.to_text() for item in rr]} for rr in records]
return {'rcode':dns.rcode.to_text(response.rcode()),'answer':section(response.answer),'authority':section(response.authority)}
def wait(predicate,timeout=15):
deadline=time.monotonic()+timeout
while time.monotonic()<deadline:
try:
if predicate():return
except (OSError,dns.exception.Timeout):pass
time.sleep(.05)
raise AssertionError('DNS fixture condition did not become true')
def stop(process):
if process.poll() is None:
process.terminate()
try:process.wait(timeout=5)
except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5)
@contextmanager
def lab(records,soa_ttl=8,minimum=4,extra=''):
with TemporaryDirectory(prefix='xbcatm-dns-') as directory,ExitStack() as stack:
root=Path(directory);zone=root/'zone';conf=root/'named.conf';log=root/'named.log';serial=1
def save(body):
zone.write_text(f'$ORIGIN lab.test.\n$TTL 30\n@ {soa_ttl} IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {minimum})\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\n'+body+'\n')
run('named-checkzone','lab.test',str(zone))
save(records)
conf.write_text(f'''options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}}; listen-on-v6 {{none;}};
recursion no; dnssec-validation no; notify no; querylog yes; max-cache-size 8m; pid-file "{root}/named.pid"; session-keyfile none;
{extra} }};
controls {{}};
zone "lab.test" {{ type primary; file "{zone}"; }};
''')
run('named-checkconf',str(conf))
output=stack.enter_context(log.open('w'))
authority=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
stack.callback(stop,authority)
def ready():
assert authority.poll() is None,log.read_text()
response=query('lab.test.','SOA');return response.rcode()==0 and response.answer[0][0].serial==serial
wait(ready)
def update(body):
nonlocal serial
serial+=1;save(body);authority.send_signal(signal.SIGHUP);wait(ready)
def cache(port=1054,upstream=1053,size=150,options=()):
logfile=root/('cache-'+str(port)+'.log');stream=stack.enter_context(logfile.open('w'))
command=['dnsmasq','--no-daemon','--conf-file=/dev/null','--no-resolv','--no-hosts','--bind-interfaces',
'--listen-address=127.0.0.1','--port='+str(port),'--server=127.0.0.1#'+str(upstream),
'--cache-size='+str(size),'--log-queries','--log-facility=-',*options]
process=subprocess.Popen(command,stdout=stream,stderr=subprocess.STDOUT);stack.callback(stop,process)
def cache_ready():
assert process.poll() is None,logfile.read_text()
return query('lab.test.','SOA',port).rcode()==0
wait(cache_ready);return process,logfile
def queries(name,kind='A'):
return sum(bool(re.search(r'query: '+re.escape(name.rstrip('.'))+r' IN '+kind+r'\b',line)) for line in log.read_text().splitlines())
yield root,update,cache,queries
with lab('exists 30 IN A 127.0.0.2',soa_ttl=8,minimum=4) as (_,update,cache,queries):
cache();missing=query('new.lab.test.',port=1054);nodata=query('exists.lab.test.','AAAA',1054)
assert missing.rcode()==dns.rcode.NXDOMAIN and not missing.answer
assert nodata.rcode()==dns.rcode.NOERROR and not nodata.answer
for answer in (missing,nodata):assert len(answer.authority)==1 and answer.authority[0].rdtype==dns.rdatatype.SOA and answer.authority[0].ttl==4
wait(lambda: queries('new.lab.test')==1 and queries('exists.lab.test','AAAA')==1)
again=query('new.lab.test.',port=1054);again_nodata=query('exists.lab.test.','AAAA',1054)
assert again.rcode()==dns.rcode.NXDOMAIN and again_nodata.rcode()==0 and not again_nodata.answer
assert queries('new.lab.test')==1 and queries('exists.lab.test','AAAA')==1
positive=query('exists.lab.test.','A',1054);assert values(positive)==['127.0.0.2']
update('exists 30 IN A 127.0.0.2\nexists 30 IN AAAA ::1\nnew 30 IN A 127.0.0.3')
assert values(query('new.lab.test.'))==['127.0.0.3'] and values(query('exists.lab.test.','AAAA'),'AAAA')==['::1']
stale_missing=query('new.lab.test.',port=1054);stale_nodata=query('exists.lab.test.','AAAA',1054)
assert stale_missing.rcode()==dns.rcode.NXDOMAIN and stale_nodata.rcode()==0 and not stale_nodata.answer
wait(lambda: values(query('new.lab.test.',port=1054))==['127.0.0.3'],timeout=7)
wait(lambda: values(query('exists.lab.test.','AAAA',1054),'AAAA')==['::1'],timeout=7)
result={'NXDOMAIN':show(missing),'NODATA':show(nodata),'repeat_upstream_queries':{'NXDOMAIN':1,'NODATA':1},'existing_A_unaffected':show(positive),
'immediately_after_creation':{'NXDOMAIN':show(stale_missing),'NODATA':show(stale_nodata)},'new_records_observed_after_expiry':True}
with lab('exists 30 IN A 127.0.0.2',soa_ttl=2,minimum=9) as (_,update,cache,queries):
smaller=query('new.lab.test.');assert smaller.rcode()==dns.rcode.NXDOMAIN and smaller.authority[0].ttl==2
result['smaller_SOA_TTL']=show(smaller)
print(json.dumps(result,sort_keys=True))
"""
name='xbcatm-dns-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'Dockerfile').write_text(recipe)
built=docker('build','-t',name,str(root));assert built.returncode==0,built.stderr
result=docker('run','--rm','-i','--name',name,'--network','none','--memory','384m','--pids-limit','128',name,
'/usr/bin/python3','-',input=program)
assert result.returncode==0,result.stderr
print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
docker('rm','-f',name);docker('image','rm',name)
查询 new.lab.test 的 A,得到 NXDOMAIN;查询已有 A 地址 127.0.0.2 的 exists.lab.test 的 AAAA,得到 NOERROR,但没有回答数据。两个否定回答都包含 TTL 为四秒的 SOA。经缓存重复执行两类查询后,脚本确认各自仍只产生一次上游查询,从而证明结果被复用,而不是因为权威每次都返回相同内容就误判命中。
查询现存名称的 A 仍然成功,说明缓存中缺少 AAAA 的结论,并不表示这个名称所有类型都不存在。程序随后给新名称添加 A,并为现存名称添加 AAAA,重载权威区域,再直接确认新数据。此时权威分别返回 127.0.0.3 与 ::1。先确认权威修改真正生效,才能把下一步差异归因于缓存,而不是尚未加载的区域内容。
立即经 dnsmasq 查询,仍得到原来的 NXDOMAIN 与 NODATA;负向条目到期后才返回新的正向数据。另一个隔离区域把限制值反过来:SOA TTL 为两秒,minimum 为九秒,否定回答 TTL 就是两秒。两组案例共同验证取较小值的两个方向,而不是只用一组恰好通过的数字,便推断 minimum 永远单独决定所有负向寿命。
区分权威缺失与解析失败
SERVFAIL、超时和 DNSSEC 验证失败,并不能证明名称不存在,它们描述无法取得或接受回答。但它们也不是一概不可缓存:RFC 9520 更新了解析失败缓存要求,其中包括验证失败。这类失败缓存的寿命规则,不同于本实验中权威否定回答由 SOA 决定的寿命。排障时既不能把失败当缺失,也不能假定每次失败都会重新查询上游。
解析器能正确缓存 NXDOMAIN,不代表它实现了所有当前建议。本实验没有测试失败缓存或 DNSSEC 验证,应针对部署版本、配置和诊断信息检查那些行为。能取得扩展错误信息时也应保留,以免把缓存中的验证问题误判成刚删除的主机名。是否修复了权威数据与是否解决了验证链,是不同的问题,不能只看最终都表现为查询失败。
发布新服务前,考虑名称在不存在时是否已经被查询过。部署允许时可提前创建必需记录,并在要求发现成功前让已知负向窗口退出。操作者可以清理自己控制的缓存,却通常无法清除所有客户端使用的独立解析器。验证应同时覆盖权威发布状态和受影响解析器的当前回答,而不是用自己机器刷新后的结果代表全部用户已经恢复。
选择负向寿命时,同时考虑恢复速度与重复未命中负载。针对同一个缺失名称的重复查询能受益于缓存,大量互不相同的名称则具有不同成本,仍会消耗资源。指标中应区分正向、否定和解析失败。真正需要回答的是:缓存了什么结论、对应哪个名称与类型、保存多久,以及什么新证据说明客户端视图已经变化。
延伸阅读
来源
- RFC 2308 negative DNS caching,NXDOMAIN、NODATA、缓存键与 SOA 决定的寿命,已核验 2026-09-12。
- RFC 9520 caching DNS resolution failures,解析失败与验证失败缓存的更新要求,已核验 2026-09-12。
- dnsmasq manual,负向缓存行为及相关选项,已核验 2026-09-12。