DNS TTL 在重复回源查询与保留旧回答之间取舍
DNS TTL 是附着于缓存数据的寿命,不是每秒执行的刷新任务。只要条目仍被保留且有效,缓存可以复用它;过期后的下一次请求通常需要重新解析,除非启用了其他明确行为。无人查询的记录,并不一定在 TTL 归零时主动产生上游请求。因此讨论成本时,要同时看请求到达方式与缓存状态,不能把 TTL 直接当成定时查询频率。 坏掉的样子 持续查询相同数据时,缩短 TTL 可能增加上游工作,却不会让所有权威流量按同一个固定倍数增长。查询频率、解析器共享程度、缓存容量、预取和名称多样性都会影响结果。一个热门名称使用短 TTL,与大量只问一次的名称流,行为不同。没有这些负载条件,就无法仅凭 TTL 数字预测真实查询量或容量需求。 长 TTL 允许在权威数据修改后继续使用先前回答。该缓存仍可能符合最初收到的寿命要求,尽管内容已经不同于最新区域。把所有不一致都叫成过期或损坏,会混淆实际契约。TTL 表示允许保留多久,不承诺持续与权威同步。修改 TTL 同样不能追溯改变已经保存的更长寿命,需要等那些旧副本自然退出普通有效期。 多层转发通常不会让每一跳重新获得原始完整 TTL。缓存返回剩余寿命,下一层再继续计时。按层数累加完整 TTL,会虚构普通合规转发并不产生的传播延迟。本地 TTL 覆盖、提供陈旧回答等是另外的配置行为,存在时应明确识别。不能先假设所有解析器都遵守同一种特殊策略,再用这个假设解释实际观测。 快速验证 将脚本保存为 check-ttl-cost.py,准备 Docker 后执行 python3 check-ttl-cost.py。固定容器使用 BIND 9.18.49 与 dnsmasq 2.90,运行时没有外部网络。权威服务器记录实际查询;最初一个转发器服务两个名称,随后在它后面启动第二个转发器,检查交给下游的 TTL。所有服务和日志都属于临时实验,不修改现有解析基础设施: 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('short 1 IN A 127.0.0.2\nlong 20 IN A 127.0.0.3\nchain 6 IN A 127.0.0.4') as (_,update,cache,queries): cache();samples=[] for i in range(3): pair={name:show(query(name+'.lab.test.',port=1054)) for name in ('short','long')};samples.append(pair) if i<2:time.sleep(1.2) wait(lambda: queries('short.lab.test')==3 and queries('long.lab.test')==1) first=query('chain.lab.test.',port=1054);assert first.answer[0].ttl==6 wait(lambda: queries('chain.lab.test')==1) time.sleep(2.2);cache(port=1055,upstream=1054) downstream=query('chain.lab.test.',port=1055);upstream=query('chain.lab.test.',port=1054) assert values(downstream)==['127.0.0.4'] and 0<downstream.answer[0].ttl<6 assert abs(downstream.answer[0].ttl-upstream.answer[0].ttl)<=1 assert queries('chain.lab.test')==1 update('short 1 IN A 127.0.0.2\nlong 20 IN A 127.0.0.3\nchain 6 IN A 127.0.0.5') wait(lambda: values(query('chain.lab.test.',port=1055))==['127.0.0.5'],timeout=8) wait(lambda: queries('chain.lab.test')==2) print(json.dumps({'three_lookups_each':samples,'authority_query_counts':{'short':queries('short.lab.test'),'long':queries('long.lab.test')}, 'cache_chain':{'original':show(first),'downstream_first':show(downstream),'upstream_remaining':show(upstream),'authority_queries_after_expiry':queries('chain.lab.test')}},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) short.lab.test 的 TTL 为一秒,long.lab.test 为二十秒。程序各查询三次,轮次间等待一点二秒。权威日志显示短寿命记录收到三次查询,长寿命记录只有一次,而每一轮客户端都得到成功回答。这个差异说明受控请求节奏下的上游工作量,不是应用延迟提升的基准测试,更不能据此推导公网解析性能增加多少倍。 ...