节点每轮把消息随机转告给邻居,最终人人皆知。

流言协议最终会收敛

节点不断与随机伙伴交换并转发状态。最终状态一致,也容忍消息在中途被丢弃。 各转各、互转告 每一轮每个节点挑一个随机的同伴交换已知状态,唯一的事实靠反复传播抵达所有人,而不是借一次单点广播。因为每次甩到的伙伴不同,信息像几何级数一样铺开:一轮往往能让知道的人数约翻一倍,于是一个小型集群里一条新成员条目——往往只要几轮就能扩散到每个人。交换通常是双向的,两条互为镜像的对话一次带两边,这让网络的成本对称又划算。它换来的是「最终一定周到」的随机保证,而不是「这一条必须按序送到」的强承诺。 没有中心也收敛 收敛不是由某个协调者来保证,而是全体随机接触涌现出来的结果,所以系统没有一个必须存活的单点。成员数和集合规模随时可变也能扛:节点加入、离开,无需有人更新一本中心目录。代价是「最终」而不是「立即」:足够轮次之后大家才看到同一份状态,而收敛时间大致取决于一轮消息来返的耗时。集群越大,越要保持每一轮的接触成本跟集群规模近似成对数而不是线性,才不至于把传播变成内耗。 丢一条消息并不致命 丢一条消息不会终止协议——这份事实还会在之后的随机配对里被另一个节点重新拾起,并再次向前转。一次失败只是把收敛拖延到下一个握手轮,而不是把状态的传播掐灭。因为状态是被不断往前复制,而不是被某个点消耗掉,所以单个节点的失陪无损全局。这也解释了为什么平时把心跳轮做得足够短很有用:收敛更快,而且一个迟到加入的节点,很快就能被任意一个熟知的邻居重新带起来。 坏掉的样子 网络分区不会让流言停下来,只会把它劈成两半。两侧各自在内部继续交换、各自收敛出不同的成员视图,于是两边都相信自己才是健康的那一半。链路恢复时,真正决定哪些写入能活下来的是合并规则:如果只比物理时钟、谁新听谁的,在失败侧接受的写入会被悄悄丢掉。生产系统因此要用版本向量或墓碑(tombstone)来配合流言:分区愈合时做调和,而不是截肢。 另一种坏法正好相反:网络没有分区,但一个慢节点看起来像死了。一次长时间的垃圾回收停顿,或者被打满的链路,会让心跳迟到超过怀疑超时,集群于是把活节点判成死节点,把它负责的数据范围复制一遍,等它再次应答又要把这些活儿撤销。SWIM 式的 suspicion 机制正是为此而生:先扩散怀疑、迟宣布死亡,并给被怀疑者用更高的 incarnation 编号反驳的机会,而不是把一次错过的心跳当成判决。 第三种坏法是修复流量本身。当大批节点同时重新加入,或者心跳轮间隔设得太激进,每个节点都向所有人拉全量状态,修复流量就会变成反熵风暴。正确做法是先对摘要:先比较版本摘要,只补缺失的条目,再加上抖动和有界扇出。从磁盘镜像恢复的老节点绝不能带着清零的计数器重新加入:版本号更小的旧副本会正确落败,但计数器被重置的旧副本反而可能覆盖更新的状态,所以版本号在重启之间必须单调递增。 快速验证 把下面程序保存为 check_gossip.py,运行 python3 check_gossip.py。它模拟 20 个节点,每个节点维护一份带版本号的键值映射;更新先写入 0 号节点,之后每一轮每个节点随机挑选一个对等节点做 push-pull 交换,双方按 key 保留版本号更高的一方。固定随机种子只让每次运行的配对可复现,不改变协议本身。程序逐轮打印知道更新的节点数,并在 12 轮后断言全部收敛。 import random NODES = 20 ROUNDS = 12 SEED = 7 random.seed(SEED) # Each node holds a versioned map: key -> (version, value). nodes = [{} for _ in range(NODES)] nodes[0]["config"] = (1, "blue") def merge(a, b): merged = dict(a) for key, (version, value) in b.items(): if key not in merged or version > merged[key][0]: merged[key] = (version, value) return merged history = [] for round_no in range(1, ROUNDS + 1): for i in range(NODES): peer = random.randrange(NODES) if peer == i: continue # Push-pull: both sides adopt the union, highest version wins. united = merge(nodes[i], nodes[peer]) nodes[i] = dict(united) nodes[peer] = dict(united) informed = sum(1 for state in nodes if state.get("config") == (1, "blue")) history.append(informed) print(f"round {round_no}: {informed}/{NODES} nodes know the update") assert history[-1] == NODES, f"gossip did not converge: {history}" assert all(state.get("config") == (1, "blue") for state in nodes) print(f"PASS: all {NODES} nodes converged within {ROUNDS} rounds: {history}") 在这个种子下,传播接近几何级数:第一轮约四分之一节点知道,第三轮接近全部,第四轮 20 个节点全部收敛。如果把每轮配对改成只联系固定邻居,收敛会明显变慢甚至停滞,这正好对应分区或配置错误的集群。上述断言在 macOS、CPython 3.12 下运行通过。这个模型省略了 suspicion 超时、墓碑过期和带宽限制,只能用来理解收敛,不能当作成员关系的实现。 ...

九月 20, 2027 · 1 分钟 · XB Field Notes
确认被过滤时,客户端可进入已建立状态,而服务器仍停留在 SYN-RECV。

TCP 握手建立序列号空间,不是同步两台机器的时钟

TCP 三次握手建立字节流的两个方向。每端提供自己的初始序列号,并确认另一端的序列号。这是两个独立序列空间,不是共享墙上时钟;应用建立连接不需要两台机器时钟同步。之后的序列号用于标识字节,支持连接内部的排序、重复处理、确认和重传。把序列号与时间混为一谈,会误解握手真正建立的协议状态。 坏掉的样子 客户端发送带初始序列号的 SYN,服务器回复 SYN-ACK,给出自己的初始序列号并确认客户端 SYN,客户端再确认服务器响应。即使没有应用数据,SYN 也占用序列空间中的一个位置,所以对应确认号增加一。将这些数值当成时间戳,会错误解释报文,还可能把排障引向无关的时钟同步问题,忽略真正缺失的确认或路由。 两端不会在完全相同的时刻改变状态。客户端收到 SYN-ACK 后,connect 可能成功返回,但最后一个确认在到达服务器前丢失。服务器仍可能停留在 SYN-RECEIVED,Linux 工具显示为 SYN-RECV。后续携带适当确认的数据也能完成交换,因此单独第三个报文缺失,并不必然导致永久失败,不能把抓包中的固定三包模式当成唯一合法过程。 TCP 建立成功也不代表应用健康或对端身份可信。服务可能接受连接却无法生成有效响应,代理可能完成客户端这一侧连接,却无法建立另一个独立上游连接。初始序列号选择有助于区分连接实例、抵御预测攻击,但不是身份认证协议,也不会加密字节流。传输状态、应用结果和安全身份需要各自对应的验证,不能由一项成功代替全部。 快速验证 将脚本保存为 check-tcp-handshake.py,准备 Docker 后运行 python3 check-tcp-handshake.py。一次性容器使用 Python 3.11.16、iproute2 6.1.0、nftables 1.0.6 和 tcpdump 4.99.3,在 Linux 6.12.76 上验证。NET_ADMIN 与 SYS_ADMIN 支持嵌套网络命名空间。客户端和目标由新建 veth 相连,不移动宿主接口或发布端口: 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 iproute2=6.1.0-3 iputils-ping=3:20221126-1+deb12u1 nftables=1.0.6-2+deb12u2 tcpdump=4.99.3-1 traceroute=1:2.1.2-1 procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*\nCMD ["python3"]\n' program=r""" from contextlib import ExitStack,contextmanager from pathlib import Path from tempfile import TemporaryDirectory import json,os,select,signal,subprocess,time def run(*args,check=True,input=None,timeout=8): result=subprocess.run(args,input=input,capture_output=True,text=True,timeout=timeout) if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr) return result def ns(name,*args,**kwargs):return run('ip','netns','exec',name,*args,**kwargs) def ip(name,*args,**kwargs):return ns(name,'ip',*args,**kwargs) def stop(process): if process.poll() is None: process.terminate() try:process.wait(timeout=2) except subprocess.TimeoutExpired:process.kill();process.wait(timeout=2) def line(process,timeout=3): assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded' value=process.stdout.readline();assert value,('child exited without a response',process.stderr.read()) return value def spawn(stack,command): process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True) stack.callback(stop,process);return process @contextmanager def lab(names): with TemporaryDirectory(prefix='net-path-') as directory,ExitStack() as stack: root=Path(directory);proc=root/'proc';proc.mkdir();run('mount','-t','proc','proc',str(proc)) stack.callback(run,'umount',str(proc)) for name in names: run('ip','netns','add',name);stack.callback(run,'ip','netns','delete',name,check=False) ip(name,'link','set','lo','up') def setting(name,key,value): assert name in names path=proc/'sys'/key.replace('.','/') return ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r}); p.write_text({str(value)!r}); print(p.read_text().strip())').stdout.strip() yield root,stack,setting def wire(left,left_if,right,right_if,left_addresses,right_addresses): run('ip','link','add',left_if,'type','veth','peer','name',right_if) for name,interface,addresses in [(left,left_if,left_addresses),(right,right_if,right_addresses)]: run('ip','link','set',interface,'netns',name) for address in addresses:ip(name,'address','add',address,'dev',interface,*(['nodad'] if ':' in address else [])) ip(name,'link','set',interface,'up') def nft(name,script):return ns(name,'nft','-f','-',input=script) def counters(name): data=json.loads(ns(name,'nft','-j','list','ruleset').stdout);found={} for entry in data['nftables']: rule=entry.get('rule',{}) if 'comment' in rule: for expression in rule['expr']: if 'counter' in expression:found[rule['comment']]=expression['counter']['packets'] return found def capture(stack,root,name,interface,expression,label): path=root/(label+'.pcap') process=subprocess.Popen(['ip','netns','exec',name,'tcpdump','--immediate-mode','-Z','root','-U','-n','-i',interface,'-w',str(path),expression], stdout=subprocess.DEVNULL,stderr=subprocess.PIPE,text=True) stack.callback(stop,process) deadline=time.monotonic()+3 while time.monotonic()<deadline: assert process.poll() is None,'tcpdump exited before listening' if select.select([process.stderr],[],[],.1)[0] and 'listening on' in process.stderr.readline():return process,path raise AssertionError('tcpdump did not become ready') def packets(process,path): import struct,socket process.send_signal(signal.SIGINT);_,diagnostic=process.communicate(timeout=3) assert process.returncode==0,diagnostic raw=path.read_bytes();assert raw[:4]==bytes.fromhex('d4c3b2a1') and struct.unpack_from('<I',raw,20)[0]==1 offset=24;result=[] while offset<len(raw): seconds,micros,size,original=struct.unpack_from('<IIII',raw,offset);offset+=16 frame=raw[offset:offset+size];offset+=size if frame[12:14]!=b'\x08\x00' or frame[23]!=6:continue ihl=(frame[14]&15)*4;length=struct.unpack_from('!H',frame,16)[0];tcp=14+ihl sport,dport,seq,ack=struct.unpack_from('!HHII',frame,tcp);header=(frame[tcp+12]>>4)*4 opts={};cursor=tcp+20 while cursor<tcp+header: kind=frame[cursor] if kind==0:break if kind==1:cursor+=1;continue option_length=frame[cursor+1];data=frame[cursor+2:cursor+option_length];assert option_length>=2 if kind==2:opts['mss']=struct.unpack('!H',data)[0] elif kind==3:opts['wscale']=data[0] elif kind==4:opts['sack_ok']=True elif kind==5:opts['sack']=[list(pair) for pair in struct.iter_unpack('!II',data)] cursor+=option_length body=frame[tcp+header:14+length] result.append({'t':seconds+micros/1e6,'src':socket.inet_ntoa(frame[26:30]),'dst':socket.inet_ntoa(frame[30:34]), 'sport':sport,'dport':dport,'seq':seq,'ack':ack,'flags':frame[tcp+13],'window':struct.unpack_from('!H',frame,tcp+14)[0], 'length':len(body),'options':opts,'preview':body[:24].hex()}) return result with lab(['client','target']) as (root,stack,setting): wire('client','c0','target','t0',['192.0.2.1/30'],['192.0.2.2/30']) server_code=r''' import socketserver class Handler(socketserver.StreamRequestHandler): def handle(self): data=self.rfile.readline();self.wfile.write(b'echo:'+data);self.wfile.flush() class Server(socketserver.ThreadingTCPServer):allow_reuse_address=True;daemon_threads=True server=Server(('192.0.2.2',18080),Handler);print('ready',flush=True);server.serve_forever() ''' service=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code]);assert line(service).strip()=='ready' tap,path=capture(stack,root,'client','c0','tcp port 18080','handshake') normal=ns('client','python3','-c', 'import socket; s=socket.socket();s.settimeout(2);s.bind(("192.0.2.1",40100));s.connect(("192.0.2.2",18080));s.sendall(b"normal\\n");print(s.makefile("rb").readline().decode().strip());s.close()') assert normal.stdout.strip()=='echo:normal' nft('client','''table ip missing_ack { chain output { type filter hook output priority 0; policy accept; tcp dport 18080 tcp flags & (syn|ack|rst|fin) == ack counter drop comment "missing_final_ack" } } ''') client_code=r''' import json,socket,sys s=socket.socket();s.settimeout(.25);s.bind(('192.0.2.1',40101));s.connect(('192.0.2.2',18080)) print(json.dumps({'connect_returned':True}),flush=True);s.sendall(b'deferred\n') try:s.recv(64);raise AssertionError('unexpected application response') except TimeoutError:print(json.dumps({'application_timeout':True}),flush=True) assert sys.stdin.readline().strip()=='restore';s.settimeout(5) received=b'' while not received.endswith(b'\n'): chunk=s.recv(64);assert chunk,'premature EOF';received+=chunk assert received==b'echo:deferred\n',received print(json.dumps({'recovered':received.decode().strip()}),flush=True);s.close() ''' client=spawn(stack,['ip','netns','exec','client','python3','-u','-c',client_code]) connected=json.loads(line(client));failure=json.loads(line(client));assert connected['connect_returned'] and failure['application_timeout'] pending=ns('target','ss','-nHt','state','syn-recv').stdout;assert '192.0.2.1:40101' in pending,pending blocked=counters('client');assert blocked['missing_final_ack']>0 nft('client','delete table ip missing_ack');client.stdin.write('restore\n');client.stdin.flush() recovery=json.loads(line(client,6));assert recovery['recovered']=='echo:deferred' client.wait(timeout=2);assert client.returncode==0 observed=packets(tap,path);flow=[p for p in observed if 40100 in (p['sport'],p['dport'])] syn=next(p for p in flow if p['flags']&0x12==0x02) synack=next(p for p in flow if p['flags']&0x12==0x12) third=next(p for p in flow if p['sport']==40100 and p['flags']&0x12==0x10) assert synack['ack']==(syn['seq']+1)%2**32 assert third['seq']==(syn['seq']+1)%2**32 and third['ack']==(synack['seq']+1)%2**32 print(json.dumps({'three_messages':[syn,synack,third],'normal_reply':normal.stdout.strip(), 'client_connect_returned':connected['connect_returned'],'application_timeout':failure['application_timeout'], 'server_still_syn_recv':pending.strip(),'drop_counters':blocked,'after_ack_filter_removed':recovery})) """ name='xbcatm-tcpflow-'+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) build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN','--cap-add','SYS_ADMIN', '--memory','256m','--pids-limit','128',name,'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) 第一条连接使用客户端端口 40100 和目标端口 18080,发送换行结束的请求,收到预期回显。抓包提供真实 SYN、SYN-ACK 和最终 ACK 数值。断言检查服务器确认号等于客户端序列号加一,第三条消息确认号等于服务器序列号加一,并对三十二位序列空间使用取模运算。这样验证的是协议关系,而不是要求每次随机数相同。 ...

九月 10, 2027 · 3 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现 well-known 端点验证。

Well-known 路径提供的是发现约定

Well-known URI 为软件发现某个源站的信息提供约定位置。保留的 /.well-known/ 前缀,减少了不同协议各自设计路径时发生名称冲突的机会;注册后缀则指向定义它的具体规范。这个前缀是一种命名机制,不是自动实现,更不是内容可信的保证。服务器仍需正确路由所支持的端点,客户端也必须按照该端点自己的契约解释响应。 按具体后缀的规范实现 RFC 8615 为支持的 URI 方案保留这一命名空间,不要求每个网站发布所有已注册资源,也没有给所有后缀定义统一响应格式。确定媒体类型、认证、跳转处理、缓存或传输条件之前,应查看 IANA 注册及其引用规范。路径看起来熟悉,不能替代协议规则;不同自动化客户端也不会因为共用前缀,就自然采用相同的读取和验证方式。 对于通过网站发布漏洞联系信息,RFC 9116 规定 security.txt 位于 /.well-known/security.txt,并要求通过 HTTPS 获取。旧位置 /security.txt 可以重定向到标准位置。文档使用 UTF-8 纯文本和规定字段,包括至少一个 Contact 以及唯一的 Expires 字段。Contact 提供报告渠道,不代表邮箱一定有人值守,更不能证明某份报告已经被接收或处理。 文档中的 Expires 字段用时间戳限制信息的新鲜度,它与 HTTP 响应中的 Expires 头不是同一个东西。传输层返回二百,不会让已经过期的联系信息重新变得有效。地址和政策需要持续维护,并在内容过时之前更新。使用 Canonical、Policy 等可选字段时,也应遵守各自规则,不能把文档里任意一个网址都当成已经验证的可信声明。 坏掉的样子 宽泛的应用回退路由可能对任何未知地址都返回首页或登录页,并统一使用二百状态。浅层健康检查认为路径可达,真正的协议客户端却得不到可用文档。因此要保留准确路由,同时检查媒体类型与内容。意外跳转到另一个源站,还可能改变信任上下文,而不只是移动一个文件。仅检查最终状态成功,可能遗漏这些协议层面的错误。 命名空间本身不会认证内容。HTTPS 校验帮助建立到目标主机的连接,文档验证及协议要求的其他检查,才进一步判断内容是否可用。存在安全联系信息,不等于授权外部人员扫描或攻击服务。部署也不能因为一个标准资源计划公开,就不加区分地暴露整个目录。应围绕实际支持的资源配置路由和权限,而不是把前缀当成自动开放所有内容的规则。 ...

六月 22, 2027 · 2 分钟 · XB Field Notes
TC 标志说明回答不完整,对应 TCP 交换才能取得完整记录。

UDP 回答被截断时,DNS 需要可工作的 TCP 路径

DNS 同时支持 UDP 和 TCP。服务器因可用消息空间而截断响应时,会设置 TC 标志。客户端收到该标志,需要处理结果不完整的情况,通常通过 TCP 重新查询。这涉及当前使用的 DNS 响应限制,并不直接证明物理网络无法承载任何更大的 UDP 数据报。排障时应先确认协议层发生了什么,再讨论传输路径的实际容量。 坏掉的样子 允许 UDP 五十三端口却阻止 TCP 五十三端口的网络,可能小查询正常,大回答失败。DNSSEC 数据和较大记录集容易暴露这种差异,但 TCP 并不只用于这些特殊情况。通用 DNS 实现需要支持 TCP,客户端也可以直接选择它。因此,一次小型 UDP 查询成功,不能证明整个 DNS 服务的传输路径完整,尤其不能代替对 TCP 监听和访问规则的检查。 被截断的回答仍可能包含部分记录。如果忽略 TC,把这些记录当成完整集合,就可能漏掉有效目的地或必需数据。NOERROR 不会抵消截断标志。当应用看到的记录比另一种传输方式下的权威查询少时,应保留完整问题、响应标志和记录数量,不要只提取一个地址或文本值。仅凭有内容返回,并不能判断这次 DNS 交换已经完整结束。 TCP 也没有取消所有 DNS 消息大小限制。TCP 流中,每个 DNS 消息之前都有两字节长度字段,因此单个消息最多六万五千五百三十五字节;区域传送可以由多个消息组成。流读取可能拆开长度字段或正文,程序应循环读取所需字节数,不能假定一次 recv 就返回整个回答。传输可靠与应用消息边界明确,是两项不同责任。 ...

十一月 19, 2026 · 3 分钟 · XB Field Notes
阻断 IPv6 邻居通告后,即使 TCP 服务仍监听,客户端也无法解析目标链路地址。

IPv6 先用 ICMPv6 解析邻居,TCP 才能正常通信

以太网链路上的 IPv6 主机,需要先取得邻居的链路层目的地址,才能发送相应帧。邻居发现通过 ICMPv6 的邻居请求和邻居通告完成地址解析,同时还涵盖路由器发现、邻居可达性检测等超出 IPv4 ARP 的功能。把它简单理解为“地址更长的 ARP”,会掩盖配置 IPv6 网络时必须考虑的依赖关系,尤其是控制报文与应用通信之间的联系。 坏掉的样子 拥有有效 IPv6 地址和路由,不代表能够解析下一跳的以太网地址。如果必要的请求或通告被过滤,TCP 服务可以一直正常监听,客户端却在交换应用数据前就失败。只开放应用 TCP 端口,无法修复这个较低层依赖。排查时应把邻居表、相关 ICMPv6 流量和服务监听一起观察,不能只从端口是否开放判断整条路径。 缓存状态还可能隐藏错误。已有可用邻居条目的主机,在破坏性规则安装后可能继续通信;新客户端或条目失效之后,故障才暴露。反过来,STALE 状态也不自动意味着损坏:邻居发现可以利用已有链路地址,随后再检查可达性。应理解状态转换,不能要求每个正常条目永远显示 REACHABLE,否则会把正常缓存生命周期误判为故障。 另一类混淆,是在本地以太网段上解析远端应用服务器地址。目的地位于链路之外时,主机通常解析选中的下一跳路由器,而不是远端服务器。判断邻居表应出现哪个地址之前,需要先确认选路。捕获接口错误,或者已有缓存使本次无需重新发送请求,也能解释空白抓包结果,不能据此证明邻居发现根本没有发生。 快速验证 将脚本保存为 check-neighbor-discovery.py,准备 Docker 后运行 python3 check-neighbor-discovery.py。一次性 Linux 环境使用 Python 3.11.16、iproute2 6.1.0、nftables 1.0.6 和 tcpdump 4.99.3。NET_ADMIN 与 SYS_ADMIN 支持嵌套命名空间和临时 proc 挂载。两个端点通过新建 veth 相连,不修改宿主邻居表或防火墙规则: ...

十一月 16, 2026 · 3 分钟 · XB Field Notes
数据报保留消息边界,缺失、重复或乱序消息仍需要应用定义处理方式。

UDP 保留数据报边界,恢复和时效由应用负责

UDP 在传输端点之间携带独立数据报,八字节首部包含来源端口、目的端口、长度和校验和字段。它不会加入 TCP 的可靠有序字节流或拥塞控制行为,让应用可以选择自己的交付策略,但不承诺所有负载下延迟都更低。应用确实需要某条内容时,丢失的数据报可能比建立连接的开销更昂贵,必须按完整请求过程评估。 坏掉的样子 发送成功不证明目标应用已经收到或处理消息。UDP 没有内建确认、重传、去重或顺序保证,因此超时不能告诉支付客户端操作是否发生。允许重试时,应用需要能够处理重复请求和结果不确定的协议,不能把没有响应直接解释成业务失败。消息是否到达、动作是否执行、结果是否持久化,也应具有不同含义。 即使本地路径健康,接收缓冲区也能制造失败。数据报大于本次调用提供的缓冲区时,内容可能截断,缺失后缀不会像字节流那样留给下一次读取。Linux 的 recvmsg 可以通过 MSG_TRUNC 报告这一情况。如果把缩短的字节当成完整命令或测量值,就可能在没有明显进程错误的情况下改变业务含义,因此必须检测协议要求的完整性。 UDP 没有内建拥塞控制,意味着应用承担责任,并不意味着可以无限制发送。需要根据部署选择发送节奏、拥塞行为、消息大小和截止时间。分片可能降低大数据报的交付稳健性,过了截止时间的数据也可能已经没有价值。QUIC 等协议能够在 UDP 之上提供可靠流,说明底层载体本身不能决定全部应用可靠性。 快速验证 将脚本保存为 check-udp.py,准备 Docker 后运行 python3 check-udp.py。固定 Python 3.11.16 Linux 容器没有外部网络路径和宿主端口发布。第一部分检查真实套接字接收语义;第二部分使用刻意编写的中继扣留、重复和重排消息,不声称测量自然发生的网络丢包。实验的每个故障输入都在代码中明确可见: import json,subprocess,uuid image="python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84" program=r""" import json,socket with socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as sender,socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as receiver: receiver.bind(('127.0.0.1',0));receiver.settimeout(.25);sender.settimeout(1);target=receiver.getsockname() sender.connect(target) for body in [b'A',b'BC',b'']:sender.send(body) boundaries=[receiver.recvfrom(16)[0] for _ in range(3)] assert boundaries==[b'A',b'BC',b''] sender.send(b'abcdefgh');data,ancillary,flags,peer=receiver.recvmsg(3) assert data==b'abc' and flags&socket.MSG_TRUNC try:receiver.recvfrom(16);raise AssertionError('truncated suffix survived') except TimeoutError:tail='discarded' sender.send(b'next');assert receiver.recvfrom(16)[0]==b'next' # A deliberate application relay changes delivery; this is not measured network loss. received=[];applied=[];seen=set();forwarded=[] with socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as relay,socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as output: relay.bind(('127.0.0.1',0));relay.settimeout(1);output.settimeout(1) sender.connect(relay.getsockname()) for seq in [1,2,3]:sender.send(str(seq).encode()) inputs=[relay.recvfrom(16)[0] for _ in range(3)] for index in [2,0,0]: output.sendto(inputs[index],target);forwarded.append(int(inputs[index])) value=int(receiver.recvfrom(16)[0]);received.append(value) if value not in seen:seen.add(value);applied.append(value) assert received==[3,1,1] and applied==[3,1] and 2 not in seen try:receiver.recvfrom(16);raise AssertionError('missing message arrived by itself') except TimeoutError:no_retransmit=True print(json.dumps({'datagram_lengths':[len(x) for x in boundaries],'empty_datagram_is_message':boundaries[2]==b'', 'short_buffer':data.decode(),'truncation_flag':bool(flags&socket.MSG_TRUNC),'unread_tail':tail, 'relay_inputs':[int(x) for x in inputs],'relay_outputs':forwarded,'receiver':received,'deduplicated':applied, 'missing_sequence':2,'no_automatic_retransmission_during_deadline':no_retransmit})) """ name='xbcatm-socket-'+uuid.uuid4().hex[:10] try: result=subprocess.run(['docker','run','--rm','-i','--name',name,'--network','none', '--memory','128m','--pids-limit','64',image,'python3','-'],input=program, capture_output=True,text=True,timeout=60) assert result.returncode==0,result.stderr print(json.dumps(json.loads(result.stdout),ensure_ascii=False,sort_keys=True)) finally: subprocess.run(['docker','rm','-f',name],capture_output=True,text=True,timeout=10) 三次发送分别包含 A、BC 和空载荷,三次接收得到长度一、二和零。每次读取消费一条数据报,没有把相邻发送合并成字节流。空结果是合法零长度消息,不代表发送方关闭连接。发送套接字使用 UDP connect 选定对端,这个接口调用不会把 UDP 变成 TCP,也不会凭空增加确认和重传机制。 ...

十一月 11, 2026 · 1 分钟 · XB Field Notes
OPT 允许更大的 UDP 回答,服务器限制和网络交付仍然约束实际交换。

EDNS 宣告 UDP 容量,不保证沿途网络交付

EDNS 通过 OPT 伪记录扩展 DNS,其中一项作用是让请求发送方宣告自己准备接收的 UDP 载荷大小,从而允许回答超过传统五百一十二字节 DNS 限制。这是 DNS 交换中的容量宣告,不是网络路径 MTU 发现协议,也不承诺所有中间设备都会交付相同大小的数据报。理解这一区别,才能准确解释大回答为何仍可能失败。 坏掉的样子 客户端宣告四千零九十六字节,不会强迫服务器发送同样大小的响应。服务器可以实施更小的发送上限;需要的回答放不下时,可以返回 TC,客户端仍需可工作的 TCP 路径。增加客户端数字,不能修复服务器有意采用较小预算的策略。应查看两端实际配置与返回内容,而不是把请求里出现的大数值当成双方已承诺一定使用的大小。 反过来,即使回答没有超过宣告容量,也可能在途中丢失。分片、隧道、包过滤和本地网络约束,与 DNS 参与者宣告的能力是不同因素。客户端若根本没有收到响应,也就可能看不到 TC 提示。载荷配置需要按互通性验证,不能当作越大越好的统一性能参数。一次超时无法仅凭宣告值就归因于某一台服务器或某个固定网络环节。 EDNS 还承载不能当作普通缓存记录解释的信息。OPT 是描述本次交换的伪记录,其字段编码载荷容量、版本、扩展响应码和标志;外形对应 TTL 的字段,并不是普通记录寿命。DNSSEC OK 标志 DO 用于请求相关 DNSSEC 数据,却不证明任何一方完成了验证。诊断工具若丢弃这些元信息,可能把不同故障简化成相同空回答。 快速验证 将脚本保存为 check-edns.py,准备 Docker 后执行 python3 check-edns.py。固定 BIND 9.18.49 权威运行在隔离回环网络,明确把 edns-udp-size 和 max-udp-size 都设为一千二百三十二。区域中分别准备四条和八条 TXT 的记录集,以便独立观察容量与截断。实验不依赖公网默认值,也不修改现有解析器的载荷策略: ...

十月 28, 2026 · 3 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调多路复用不改变 TCP 的有序交付。

HTTP/2 在连接层消除队头阻塞

这里的“消除队头阻塞”,指解除同一条连接上的 HTTP 响应顺序约束,而不是消除 TCP 队头阻塞。较晚发起的响应可以在较早的处理函数仍然等待时完成,但共享的传输容量、连接级流控和应用瓶颈,依然可能把不同流的进度关联起来。先说明这一层边界,才能准确理解标题里的“连接层”。 分清两种排队 HTTP/1.1 本来就支持持久连接,并非一个请求必须新建一条连接。多个请求可以顺序复用套接字;流水线还允许同时存在多个未完成请求,服务器也可以并行处理安全方法,但同一条连接上的响应必须按照请求顺序发送。因此,第一份响应过慢,会延迟后续响应的交付。客户端经常通过多开连接缓解这个限制。 HTTP/2 给帧关联流标识,在同一条连接上交错传递消息。服务器可以发送已经准备好的响应,不必先完成另一份无关响应。这是 HTTP 层的独立推进能力,不代表每个处理函数获得独占处理器、数据库、带宽或无限调度机会,也不意味着应用的共享资源自动变成互不影响。 TCP 仍然提供一个有序字节流。某段字节缺失时,后面已经收到的字节可能必须等待补齐缺口,才能交付应用,其中也可能包含其他 HTTP/2 流的帧。此前已经交付的字节并不会被收回。QUIC 则在每条流内保持有序,不要求不同流之间统一排序;不能把它解释成普通流数据可以任意乱序交付,也不能据此忽略共享的拥塞控制和流控预算。 并发仍然受到预算约束 对端通过 SETTINGS_MAX_CONCURRENT_STREAMS 限制活动流数量。DATA 帧还同时消耗流级和连接级的流控额度。每条流拥有单独窗口,不等于无限独立:连接额度耗尽时,其他流的 DATA 也可能停住。接收方如何消费正文、何时更新窗口,以及共享工作池、锁和上游连接是否紧张,都会改变实际推进情况。 应检查所测那一跳真正协商出的协议。浏览器到代理使用 HTTP/2,不能证明代理到源站也是如此;TLS 握手成功也不足以证明使用了 HTTP/2。Go 配置自定义 TLS 参数后,可能需要 ForceAttemptHTTP2 才会尝试 HTTP/2,但最终证据仍是响应协议和协商出的 h2,而不是配置字段已经存在。 坏掉的样子 快请求先完成,可能只是客户端另开了一条 TCP 连接;单连接 HTTP/1.1 客户端则可能在请求抵达服务器之前,就已经让它等待。应同时核对服务端接受的连接数量和客户端获取连接的事件,再判断排队发生在哪里。仅凭两条访问日志的结束时间,无法区分这些情况。 增加流并发量,不能修复过载数据库,也不能修复未读数据造成的共享窗口紧张。应记录正文完成情况、活动流、共享资源等待以及逐跳协议。不要把每次慢请求都称为 TCP 队头阻塞,更不要把升级协议直接写成已经实测的页面提速;性能结论需要独立的负载与网络测量支持。 ...

九月 15, 2026 · 3 分钟 · XB Field Notes