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 为二十秒。程序各查询三次,轮次间等待一点二秒。权威日志显示短寿命记录收到三次查询,长寿命记录只有一次,而每一轮客户端都得到成功回答。这个差异说明受控请求节奏下的上游工作量,不是应用延迟提升的基准测试,更不能据此推导公网解析性能增加多少倍。

长寿命回答的剩余 TTL 随轮次下降,短寿命回答每次刷新后重新从一秒开始。脚本直接检查权威日志,不以“响应很快”假定命中缓存。回环网络里没有命中的查询也可能很快,单看耗时无法证明是否联系权威。先确认真实查询计数,再解释缓存复用,才能让实验结论对应实际执行路径,而非依赖肉眼感受。

在双层案例中,第一个转发器缓存 chain.lab.test,初始 TTL 为六秒。等待两点二秒后,新启动的第二层通过它查询。实测下游回答 TTL 为三秒,与上游剩余值在整数秒计时误差范围内一致,并没有重新获得六秒。权威计数仍为一次,证明下游填充复用了第一层现有条目,而不是恰好重新取得另一份新鲜权威回答。

随后权威修改该记录地址。经第二层查询最终得到新地址,权威次数增加为两次。这验证已有数据到期后,两层转发都能正常获得更新。精确到期小数受实现和调度影响;断言关注剩余寿命被传递、没有提前增加额外权威查询,以及最终取得修改后的值。这个实验反对简单累加 TTL,并不声称所有应用缓存都自动具有相同行为。

依据真实需求选择寿命

选择 TTL 时,同时考虑变更频率、可接受的旧回答重叠期和查询量。共享繁忙解析器可以让很多客户端复用一次回答,彼此隔离的缓存则会重复填充。条目可能因容量淘汰而在 TTL 之前消失,因此长寿命不保证命中。应测量命中、未命中、上游请求和可用容量,不能把 TTL 当作缓存大小控制,也不能把提前淘汰误判为协议错误。

计划迁移时,应提前降低 TTL,让更早获得的长寿命副本有时间过期,并据真实保留数据安排端点重叠。稳定后提高 TTL,可减少热门记录的重复解析,同时也延长下一次变更的普通等待窗口。配置单位应关注记录集:名称、类别和类型相同的记录,应使用一致 TTL,而不是给各个地址配置不同寿命来模拟优先级或权重。

最后,对照应用实际使用的各个解析器,查看回答内容和剩余寿命。直接权威查询与递归缓存回答展示的是不同状态,不能拿其中一个覆盖另一个。应用自有缓存和已经建立的连接,还可能具有实验之外的生命周期。估算用户多久发现新端点、以及 TTL 调整需要多少权威容量前,先把这些寿命与 DNS 缓存寿命区分清楚。

延伸阅读

来源