下游接收剩余 TTL,而不是重新获得完整权威寿命。

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

三月 1, 2028 · 2 分钟 · XB Field Notes
到点就自动忘记某个键的过期机制。

Redis TTL 让每个键自带时钟

Redis 过期机制回答的是一个键什么时候应当停止可见,而不是整个服务最多使用多少内存。截止时间属于键,后续命令可能保留、替换或者移除它。因此,可靠地使用 TTL,既要检查首次写入,也要覆盖更新路径。看到写命令成功,不能证明之前设置的截止时间仍然存在,更不能直接推断数据最终会按原计划消失。 截止时间不是自动滑动计时器 EXPIRE 使用秒,PEXPIRE 使用毫秒。Redis 将过期信息保存为绝对 Unix 时间戳,所以服务器停止运行期间,时间仍然继续流逝。系统时钟必须稳定,跨机器恢复数据时尤其需要注意时钟差异。它不是独立于系统时间变化的单调时长承诺。下面的实验只使用正常流逝的时间,不调整本机系统时钟。 GET 不会刷新过期时间。INCR 这类原地修改保留键已有的超时;普通成功的 SET 则替换值,并清除原来的超时。SET KEEPTTL 可以保留原期限,再次显式设置过期命令则会改变截止时间。需要滑动会话的应用必须主动实现续期策略,不能以为用户每次读取数据都会自然延长访问窗口。 当值和初始寿命属于同一次操作时,应使用 SET EX 或 SET PX 一起写入。分成 SET、EXPIRE 两条命令,会留下一个值已经存在、期限却尚未写入的失败窗口。客户端如果恰好在两条命令之间崩溃,就可能留下持久条目。这是写入协议缺陷,不是后台过期周期执行得太慢,调整清理频率也修复不了它。 检查真正存在的状态 TTL 返回剩余秒数;键存在但没有过期时间时返回 -1,键不存在时返回 -2。PTTL 提供毫秒分辨率,PEXPIRETIME 则返回绝对截止时间。要验证一次读取有没有续期,比较绝对期限比比较两次倒计时更可靠,因为两条查询之间时间正常流逝,本来就可能让剩余数值减少。查询结果只是当时的快照,不保证键一定能存活到那个时刻。 EXPIRE NX 只在没有期限时添加期限。GT 只接受更晚的截止时间,其中持久键被当成无限寿命处理。PERSIST 则显式移除过期设置。调用方需要检查命令返回的结果,不能把“命令被接受”理解成“条件成立且状态已经改变”。相对过期时间为零或负数时,键会立即被删除,并不是改成永久保存的另一种写法。 坏掉的样子 被动过期在访问时检查截止时间,主动过期负责处理那些过期后没有再被访问的键。物理清理延迟,不等于普通主节点 GET 可以持续交付已经过期的值。反过来,条目被移除,也不保证分配器马上把页面交还操作系统。读取可见性、数据结构清理与进程 RSS 是不同观察对象,不能只看其中一个就替其他两个下结论。 ...

六月 29, 2027 · 2 分钟 · XB Field Notes
权威已经返回新数据,缓存仍可能在有效期内保留先前的不存在结论。

负向 DNS 回答会把不存在的数据记到缓存寿命结束

DNS 可以通过说明数据缺失提供有效信息。NXDOMAIN 表示名称错误,NODATA 则描述成功响应中没有所请求类型的数据。对于没有别名的直接查询,两者区分名称不存在,以及名称存在但缺少该类数据。缓存这些结果,可以避免每次客户端请求都重新进行相同的无结果上游查询。负向回答也是有内容、有寿命的状态,并不是“没有收到任何东西”。 坏掉的样子 部署已经创建正确记录,先前缓存过缺失结果的客户端却可能仍然失败。权威返回新数据,缓存则在寿命结束前继续返回旧否定结果。反复删除重建记录,不会使远端缓存自动失效。这种情况常出现在应用提前查询尚未配置完成的服务名称时;排障应对照查询发生时间和缓存寿命,而不是只检查现在的区域文件是否包含记录。 仅凭回答区为空,不能完整分类 DNS 结果。需要检查响应码、权威区和可能存在的别名链。转介与权威否定回答不同,NXDOMAIN 也可能指向 CNAME 最终目标,而非最初请求的别名本身。把所有空结果都说成“域名不存在”,可能让调查去改错误的记录。应保留原始问答的类型、名称及各区数据,避免过早简化成一句错误描述。 对于 RFC 2308 规定的权威 NXDOMAIN 和 NODATA,负向寿命取 SOA.MINIMUM 与 SOA 记录 TTL 中的较小值。该值放在否定回答的 SOA TTL 上,再由缓存继续递减。MINIMUM 不是所有正向记录 TTL 的统一下限;只改变将来正向记录的 TTL,也不会缩短已经缓存的不存在结论。操作时应确认正在调整哪类数据的哪一代寿命。 快速验证 将脚本保存为 check-negative-dns.py,准备 Docker 后执行 python3 check-negative-dns.py。固定环境使用 BIND 9.18.49 和 dnsmasq 2.90,只有隔离回环网络。第一个区域的 SOA TTL 为八秒、minimum 为四秒,并有一条现存 A 记录。实验在新增缺失数据前后检查真实响应与权威查询次数,不通过模拟字典代替解析器缓存行为: ...

十月 22, 2026 · 3 分钟 · XB Field Notes