更大的页面粒度,把更多地址空间塞进更小的 TLB。

巨型页用灵活性换更少的 TLB 缺失

数据库及高吞吐服务往往因此受益,但要背负需要连续内存、池子要预先算好大小的成本——这是一笔架构账,不是免费的。 为什么 4 KB 会吃亏 默认页是 4 KiB,TLB 能容纳的条目又很有限。管理百万级工作集的大型应用,很容易在访问一个页面时就 miss 掉一个 TLB 表项,于是不得不到页表里去翻。这里的成本不是页本身,而是反复的页表遍历;一旦工作集的错杀超过 TLB 回填的速度,大应用就会明显吃到痛。更大的页让每条表项覆盖更多内存,同样大小的工作集约只需要很少的表项,这正是吃内存沉重的负载最需要的缓解。 hugetlb 与 THP 的区别 想要大页有两条路。hugetlb 让你从一块预留的内存里自己分配,页的大小是确定的(常见 2 MiB 或更大),行为可预期、由你掌控;transparent(THP)则在后台自动把映射整合成大页,省心,却可能在某些负载上带来延迟尖峰、碎片与写时复制的问题。对延迟敏感的业务,hugetlb 让你自己抱住那份池和那份 trade-off,而不是把决定权丢给一个你不知道用不用得着的通用策略。选哪条路,其实是在决定「可预测性由谁掌握」——你,还是内核。 池子要提前量 大页的真正收益,建立在它被真用满、并且真的常驻内存的前提上。一页 2 MiB 若只用了很小一部分,却仍占着一个整页表项,被锁住的那块内存就等同于浪费,而池子到底定多大,才是真正的功夫。给池子只留那份最诚实、最稳定使用的内存,而不是凭感觉很轻率地划一大块,并要为「稀疏大页」附带的多占 RAM 留出余量。当这些都成立——页被用满、工作集收得小、居住集成一两个表项——大页的收益才完整兑现;要是把它喂给几乎填不满的负载,那就只是一笔平白消耗。 坏掉的样子 池子给小了,失败多在启动瞬间。数据库要的共享缓冲超过预留大页能覆盖的范围,进程会直接退出,常见报错是无法分配内存;此时系统空闲内存可能还很充足,容易误判。关键是显式大页不能自动回退到小页,缺几页就是缺几页。做法是按应用文档的需求加少量余量定池子,重启后核对总页数与已预留数,而不是只看启动参数。 启动正常、运行中分配失败,多半是碎片。大页要求物理连续的整块区间,机器跑久之后,空闲总量充足,却拼不出完整区间,新映射会被拒绝。典型信号是空闲大页数停在大于零不再下降,同时内核日志出现大页分配失败;此时重试映射没有意义,应回到启动阶段预留或用启动参数固定池子。 更隐蔽的是位置放错与自动整理。池子落在远端节点上,每次命中都要跨互联访问,省下的表项优势会被延迟吃掉,绑定前要先看分节点分布。透明大页则是另一种风险:后台整理与写时复制拆分会把平稳的尾延迟打成尖峰,从合并等待与整理线程占用能看到痕迹。延迟敏感服务建议设为按需启用,只在实测有效的地方打开。 快速验证 下面脚本只读内存信息与系统页大小,不做任何分配,也不需要特权,因此在无特权容器里同样可直接运行。若环境没有内存信息文件或不支持大页,它会打印跳过提示并继续给出页表项换算,而不是抛异常退出。 把下面代码保存为大页检查脚本并运行,即可看到本机覆盖率与固定工作集的表项对比。脚本先解析总页数、空闲数、已预留数、溢出数与单页大小,算出预留覆盖总量;再用系统调用得到的基准页大小,对比同样工作集在基准页与大页下各需多少表项,估算省下的数量。 #!/usr/bin/env python3 """Report hugepage coverage and estimated TLB entries saved (read-only).""" import os import re from pathlib import Path WORKING_SET_BYTES = 8 * 1024**3 # 8 GiB example working set def parse_meminfo(text): vals = {} for key in ("HugePages_Total", "HugePages_Free", "HugePages_Rsvd", "HugePages_Surp", "Hugepagesize"): m = re.search(r"^" + key + r":\s+(\d+)", text, re.M) if m: vals[key] = int(m.group(1)) return vals def main(): vals = {} try: text = Path("/proc/meminfo").read_text(encoding="utf-8") except OSError as exc: print(f"SKIP: /proc/meminfo unavailable ({exc}); showing page-table math only") text = "" if text: try: vals = parse_meminfo(text) if "HugePages_Total" not in vals or "Hugepagesize" not in vals: print("SKIP: no HugePages fields in /proc/meminfo (hugepages unsupported or disabled)") else: size_kb = vals["Hugepagesize"] total = vals["HugePages_Total"] free = vals.get("HugePages_Free", 0) rsvd = vals.get("HugePages_Rsvd", 0) surp = vals.get("HugePages_Surp", 0) cov_mb = total * size_kb / 1024 print(f"Hugepagesize: {size_kb} kB") print(f"HugePages total/free/rsvd/surp: {total}/{free}/{rsvd}/{surp}") print(f"Reserved coverage: {cov_mb:.1f} MiB ({cov_mb/1024:.2f} GiB)") except Exception as exc: print(f"SKIP: failed to parse /proc/meminfo ({exc})") try: base = os.sysconf("SC_PAGE_SIZE") except (AttributeError, ValueError, OSError): base = 4096 huge = vals.get("Hugepagesize", 2048) * 1024 base_entries = WORKING_SET_BYTES // base huge_entries = WORKING_SET_BYTES // huge print(f"Base page size (sysconf): {base} bytes") print(f"8 GiB needs ~{base_entries} base-page entries vs ~{huge_entries} huge-page entries") print(f"Estimated TLB entries saved: ~{base_entries - huge_entries}") if __name__ == "__main__": main() 在支持大页的系统上,预期能看到单页大小、各类计数与预留覆盖容量,以及两种粒度各自的表项数和节省量;在笔记本或极简容器里,则先看到一行跳过提示,再看到同样的表项换算,且本地基准页可能不是常见的四千字节。该脚本已在缺少内存信息文件的环境验证通过。它只衡量预留量与潜在节省,不能证明真实缺失下降、碎片或节点距离,调池子前还要用内核日志、分节点计数与延迟分位交叉确认。 ...

十二月 21, 2027 · 2 分钟 · XB Field Notes
一个规定响应能否被缓存、缓存多久、为谁缓存的状态头。

Cache-Control 是缓存与源站之间的约定

缓存策略需要行为测试,而不只是检查头部是否存在。两个响应即使都带 max-age,也可能因为其中一个要求验证、另一个禁止存储而表现不同。请求本身同样影响结果:浏览器可以绕过已有副本,却不修改源站的响应策略,也不删除更早保存的缓存条目。要知道用户真正拿到什么,必须观察完整的重复请求过程。 双方都要读同一份约定 Cache-Control 指令具有方向性。请求指令不会自动复制到响应里,RFC 9111 还明确将请求指令描述为建议性的要求;响应指令则约束遵守协议的缓存。中间层即使自己不实现缓存,也必须在转发中保留相应缓存指令。部署配置可能另有覆盖规则,但“代理按自己的配置做了”不等于已经证明源站声明的策略得到执行。 应拆开三个问题:这条响应能否存储,当前年龄是否仍在适用的新鲜度寿命内,这次请求是否允许不经验证直接复用。no-store 禁止 HTTP 缓存存储;no-cache 允许存储,但复用前必须成功验证。max-age 设定新鲜度,must-revalidate 则禁止响应过期后未经验证就复用。响应仍然新鲜时,must-revalidate 本身并不要求每次回源,不能把它误解成 no-cache。 private 将存储限制到单用户缓存,public 在其他条件允许时明确授予缓存许可,并不是每一条可缓存响应都必须写 public。s-maxage 覆盖共享缓存的新鲜度,不适用于浏览器私有缓存。Vary 则提供请求匹配维度。这些机制都不会识别应用当前登录的是哪个账户,也不会因为有效期很短,就自动让个人数据变得适合共享。 坏掉的样子 测试时每次添加新的查询参数,会改变缓存键,可能完全掩盖原本的复用行为。在开发者工具中禁用缓存,也可能造成相同误判;某些自动化请求拦截机制同样会禁用 HTTP 缓存。有效测试应在一个隔离上下文中重复访问完全相同的网址,一边统计源站实际收到的请求,一边断言返回正文,而不是仅凭页面看起来没变化就判断命中。 Fetch 的 cache 选项属于浏览器接口,不是响应头。它的 no-store 模式跳过缓存查找,也不把新响应写回 HTTP 缓存;reload 跳过查找,但可以更新缓存。两者都不等于抹除全部旧副本。源站修改响应头,也不能迫使尚未过期的条目立刻回源。浏览器历史恢复、Service Worker 自行保存的内容又是其他机制,不属于下面实验的覆盖范围。 no-cache 也不保证获得 304。只有存在合适的验证器,并且所选表示仍然有效,才有机会节省正文传输。没有 ETag 或 Last-Modified 时,每次访问可能仍返回完整的 200 响应。因此要同时测量回源次数和传输正文,不能看到“每次都请求了源站”就认为重验证一定已经发挥性能收益。 ...

十二月 20, 2027 · 2 分钟 · XB Field Notes
真正执行查询以查看每一层的实际行数、耗时与缓冲区。

EXPLAIN ANALYZE 把查询真的跑一遍

EXPLAIN 展示 PostgreSQL 的执行计划,EXPLAIN ANALYZE 还会真正执行语句并测量工作量。最有用的比较,是同一个节点的估计行数与实际行数。两者都不能单独当成结论:估计来自模型,实测则只描述特定数据、缓存、并发和插桩条件下的一次执行。不能把前者称为谎言,也不能把后者当成脱离环境的永恒事实。 先比较同一种量 计划中的 cost 是相对成本单位,不是毫秒。两个成本值分别描述启动成本与完成成本,带 LIMIT 的查询可能更重视尽早得到前几行,因此选择不同计划。actual time 才使用毫秒。即使成本和耗时恰好都打印成一百,也不能把它们直接相减或求比例;它们本来就不表示同一件事。 一个节点被重复执行时,actual rows 和节点耗时是每次循环的平均值,需要结合 loops 理解总工作量。但不能把所有节点耗时简单相加,因为父节点已经包含子节点的工作,重复相加会重复计算。并行执行时更不能用这种方式猜总延迟。先沿树看输入输出、循环次数、过滤条件和行数流向,再决定哪个位置值得调查。 BUFFERS 区分 shared、local 和临时块活动。shared hit 表示 PostgreSQL 在共享缓冲区里找到了块;shared read 只表示需要读入,操作系统仍可能从页缓存满足它,不能直接断言每次都访问了物理磁盘。临时表使用本地缓冲区,排序或哈希溢出则可能读写临时文件。父节点计数包含子节点,逐行把所有计数加起来同样会夸大 I/O。 找到有意义的估计偏差 某个选择性条件估计只返回十行,实际却返回一万行,通常值得先调查,再考虑重写其上方的连接。统计信息陈旧、分布倾斜、列间相关、参数值变化或选择性模型能力不足,都可能造成偏差。ANALYZE 可以刷新统计信息,却不能保证每个估计精确,也不会自动修好所有慢计划。先看真实数据和缺失的信息,再决定是否调整统计目标或增加扩展统计。 行数估计准确也不能证明查询高效。缺少访问路径、昂贵表达式、排序溢出、锁等待和并发争用仍可能存在。反过来,小表或需要返回大量行的查询采用顺序扫描可能正合适,不应把“没用索引”一律判为错误。分析时应保留应用实际使用的参数和查询形状,避免用一个稀有值的漂亮计划替代整个生产分布。 坏掉的样子 最危险的误解,是把 ANALYZE 当成只读预演。UPDATE 会改变行,触发器会执行,锁会被获取,SELECT 也可能调用带副作用的函数。事务末尾执行 ROLLBACK 可以恢复事务性表变化,却不能一般性撤销序列前进或外部系统副作用。对语句影响范围不确定时,应使用可丢弃的数据集,不能靠“最后会回滚”就放心在生产运行。 ...

十二月 16, 2027 · 2 分钟 · XB Field Notes
Postgres 里按归一化查询汇总统计的视图。

pg_stat_statements 找出最贵的查询

因为 pg_stat_statements 会把同一条查询形态的每一次执行都聚合起来,「这个页面慢」就变成了一张排好序的表。代价只是一行配置加一次重启;回报是你能知道上万种查询形态里,到底是哪一种在占用时钟。 真正重要的决定只有一个:按哪一列排序。按平均耗时排,只是把慢日志已经告诉过你的事再说一遍;按总耗时排,才能找出那个一直悄悄吃掉数据库四分之一时间的查询。 先归一化,再聚合 这个扩展并不原样保存你的 SQL。常量会被折叠成参数符号,于是 WHERE tenant_id = 42 和 WHERE tenant_id = 8712 合并成同一条记录,展示出来的文本是第一个产生该 queryid 的查询。编号规则是位置式的——符号从当前文本里最大 $n 的下一个数字开始,所以 PL/pgSQL 里的 SELECT i + 1 INTO j 会显示成 SELECT i + $2。列表的行为一样:只差 IN 元素个数的两条查询会被压成 IN ($1 /*, ... */),调用次数记为 2。 ...

十二月 12, 2027 · 1 分钟 · XB Field Notes
每端通告自己的缩放因子,SYN 报文中的窗口字段仍不缩放。

TCP 窗口缩放分别扩展两个方向的接收窗口编码

TCP 头部的接收窗口字段只有十六位。窗口缩放让已建立连接依据握手期间确定的移位解释这个字段,从而允许接收端通告超过65535字节的空间。两个方向可以采用不同移位值。它扩展的是接收窗口信息的编码,不会直接扩大每个缓冲区,也不保证发送端始终让那么多数据处于未确认状态。 坏掉的样子 高带宽且 RTT 较长的路径,可能需要远多于未缩放窗口所能描述的在途数据。例如每秒一百兆比特、往返八十毫秒,对应大约一百万字节在途数据;65535字节窗口会形成明显更低的理想速率上限。带宽时延积用于容量估算,不是吞吐量实测,也不保证链路不存在其他约束,不能用一个乘法直接宣布应用应达到某个速度。 发送端还受到拥塞窗口限制,并且必须有应用数据可发。扩大接收窗口编码,无法修复过小的拥塞预算、停止读取的应用、丢包或被阻断的路由。零窗口经过缩放仍然是零。应判断究竟哪个资源限制进展,不能仅因握手里存在缩放选项,就断言连接理应跑满链路,也不能把所有低吞吐问题都归因于缓冲配置。 对协商的误解还会导致抓包解释错误。双方都必须提供选项,缩放才能生效;每个因子用于解释提供它的端点所通告的窗口。SYN 与 SYN-ACK 自身的窗口字段从不缩放。使用反方向的移位值,或把 SYN 窗口也乘上因子,即使数据包字节完全正确,也会算出错误容量。报文值需要结合方向和连接阶段解释。 快速验证 将脚本保存为 check-window-scaling.py,准备 Docker 后运行 python3 check-window-scaling.py。一次性环境使用 Python 3.11.16、iproute2 6.1.0 和 tcpdump 4.99.3,在 Linux 6.12.76 上验证。NET_ADMIN 与 SYS_ADMIN 支持嵌套命名空间及临时 proc 挂载。端点通过新建 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 socket,json def read_exact(stream,length): data=b'' while len(data)<length: chunk=stream.recv(length-len(data));assert chunk,'premature EOF';data+=chunk return data listener=socket.socket();listener.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) listener.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,262144) listener.bind(('192.0.2.2',18080));listener.listen();print('listening',flush=True) with listener.accept()[0] as connection: connection.settimeout(4);print(json.dumps({'rcvbuf':connection.getsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF)}),flush=True) assert read_exact(connection,4)==b'ping';connection.sendall(b'pong') assert connection.recv(1)==b'' ''' client_code=r''' import socket,json,sys def read_exact(stream,length): data=b'' while len(data)<length: chunk=stream.recv(length-len(data));assert chunk,'premature EOF';data+=chunk return data s=socket.socket();s.settimeout(4);s.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,65536) s.bind(('192.0.2.1',40400+int(sys.argv[1])));s.connect(('192.0.2.2',18080));s.sendall(b'ping') assert read_exact(s,4)==b'pong' print(json.dumps({'rcvbuf':s.getsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF),'reply':'pong'}),flush=True) assert sys.stdin.readline().strip()=='close';s.close() ''' outcomes={} for enabled in [1,0]: setting('client','net.ipv4.tcp_window_scaling',enabled);setting('target','net.ipv4.tcp_window_scaling',enabled) service=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code]);assert line(service).strip()=='listening' tap,path=capture(stack,root,'client','c0','tcp port 18080','window'+str(enabled)) client=spawn(stack,['ip','netns','exec','client','python3','-u','-c',client_code,str(enabled)]) client_result=json.loads(line(client));server_result=json.loads(line(service)) diagnostics=ns('client','ss','-tin','state','established').stdout client.stdin.write('close\n');client.stdin.flush();client.wait(timeout=2);service.wait(timeout=2) assert client.returncode==service.returncode==0 observed=packets(tap,path);syn=next(p for p in observed if p['flags']&0x12==0x02) synack=next(p for p in observed if p['flags']&0x12==0x12) advertised=next(p for p in observed if p['sport']==18080 and p['length']==4) if enabled: assert 'wscale' in syn['options'] and 'wscale' in synack['options'] effective=advertised['window']<<synack['options']['wscale'];assert effective>65535 else: assert 'wscale' not in syn['options'] and 'wscale' not in synack['options'];effective=advertised['window'];assert effective<=65535 outcomes[enabled]={'client':client_result,'server':server_result,'syn':syn,'synack':synack, 'server_post_handshake_window_field':advertised['window'],'effective_receive_window':effective,'ss':diagnostics} print(json.dumps(outcomes)) """ 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) 连接建立之前,客户端请求65536字节接收缓冲,服务器监听套接字请求262144字节。脚本读取最终套接字选项值,不假定请求值会原样使用。Linux 的缓冲记账和上限会影响结果,本次客户端报告131072,服务器接受后的套接字报告425984。二者均不能直接等同于线上通告窗口。 ...

十二月 2, 2027 · 3 分钟 · XB Field Notes
多个缓存键同时过期、客户端一次性回源补齐而引发的流量尖峰。

缓存雪崩姗姗于同一时刻过期

缓存雪崩或击穿所造成的一个关键风险,是并发未命中对同一依赖发起大量重复工作。同时到期只是触发条件之一:容量淘汰、刚部署的空缓存、缓存服务不可用,都可能造成类似突发。即使只有一把热门键,也足以压垮后端。判断风险时应数清楚:一份结果尚未计算完的这段时间内,到底有多少次重建真正抵达数据源。 在真实边界内合并工作 Go 的 singleflight Group 对同一键,在这个 Group 内最多保持一次在途函数执行;重复调用者共享结果,也共享错误。不同 Group 之间不会协调,即使它们位于同一个进程;不同服务实例通常也各有自己的 Group。因此它既不是分布式锁,也不是保存已完成结果的缓存。把 Group 放在每个请求内部重新创建,无法实现跨请求合并。 实际使用时,应让请求共享同一个 Group,并使键准确表示等价工作。不同租户、权限、币种或响应表示如果错误地使用同一个键,可能拿到不属于自己的结果。对于缓存回填,在共享函数内部应再次检查缓存,确实仍缺失才访问数据源,并在函数返回前完成成功结果的缓存写入。这些存储操作都不是 singleflight 自动提供的能力。 错开时间不能改变新鲜度承诺 随机抖动可以打散不同条目或独立副本的到期时间,却不能阻止访问同一过期热键的人一起未命中。如果最大 TTL 是业务规定的新鲜度上限,在有界范围内随机缩短寿命,不会偷偷越过这个上限;任意增加正向抖动则改变了原有承诺。不能脱离请求量、计算成本和允许陈旧程度,宣称几个百分点或几秒钟就普遍足够。 提前刷新和允许使用旧响应,有机会避免用户等待,但必须有明确业务语义。暂时展示上一版公开目录可能可接受,已经撤销的权限或当前余额则未必可以。并发合并只减少重复计算,不负责判断陈旧内容能否使用;如果缓存完全是空的,系统仍需要取得一份数据源结果,不能把后台刷新当作无条件的可用性保证。 坏掉的样子 共享加载函数一旦卡住,可能同时拖住很多等待者。要为数据源调用设置执行期限,也让每个调用者能限制自己的等待时间。DoChan 返回的是结果通道,不是取消机制:某个等待者离开,不会自动停止底层执行。Forget 允许同一键重新启动一次执行,但并不取消原有执行,用它解除等待可能重新制造两次并行加载。 错误只在本次执行仍处于在途状态时共享。执行结束后立刻重试,会向故障依赖发起一轮又一轮新请求。应根据依赖的实际约束设置重试与准入策略,对可以缓存的“不存在”和瞬时失败分别处理。另一个常见误判是把进程内的数字推广到全局:一百个各有 Group 的实例,仍可能同时产生一百次重建,本地只执行一次不等于源站只收到一次。 快速验证 需要 Go 1.25 或更新版本;本次使用 Go 1.27.0 和 x/sync v0.22.0。在下面创建的独立模块目录中,将 Go 代码放入 main.go,再运行 go run -race main.go。只有依赖安装需要外网。程序从“已经发生冷缓存未命中”的边界开始,用有计数器的加载函数观察行为,没有连接真实数据库、缓存服务,也没有实现 TTL 定时器。 ...

十一月 22, 2027 · 2 分钟 · XB Field Notes
服务器等待请求的第二个字节,而 Nagle 等待第一个字节被确认。

Nagle 与延迟 ACK 可能让小请求的第二部分等待

Nagle 算法减少 TCP 小报文:当前面数据尚未确认时,它会暂缓额外的小块数据,除非已经积累到足以构成完整段。延迟确认则是接收端的另一种行为,可以短暂推迟 ACK。当应用把一个请求拆成多个很小的写入,而对端必须取得后续字节才能生成响应时,两者的相互作用就会影响延迟。需要明确是哪一部分数据已经发出、哪一部分仍在等待。 坏掉的样子 单独一个小请求,不会自动被 Nagle 延迟。如果没有更早的未确认数据,第一个小段可以直接发送。问题场景是第一部分已在途中,第二个短小部分排在后面等待。如果接收端延迟确认,同时应用又在等完整请求,进展可能暂停,直到确认计时器到期。不能把所有“小请求慢”都归到这个机制,也不能省略前面存在未确认数据这一条件。 这属于有边界的延迟,不是协议无限死锁。ACK 可以释放排队数据,积累足够多数据也可能改变发送决策。观察到的等待既不统一等于额外一个 RTT,也不固定为四十毫秒。接收策略、已有确认状态、请求分帧和网络条件,共同决定真正的报文顺序与时间。把一次实验数值当成所有系统的常数,会误导后续故障判断。 TCP_NODELAY 关闭指定套接字上的 Nagle,却不会关闭所有缓冲、拥塞控制、接收窗口流控或调度延迟。Linux TCP_QUICKACK 请求改变确认模式,但这种模式不是永久设置。把任意一个选项当作万能延迟开关,可能掩盖应用缓冲或其他传输限制;这些问题本来就不在该选项职责内,需要通过对应层次的证据定位。 快速验证 将脚本保存为 check-nagle.py,准备 Docker 后运行 python3 check-nagle.py。一次性环境使用 Python 3.11.16、iproute2 6.1.0 和 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']) setting('client','net.ipv4.tcp_autocorking',0) server_code=r''' import socket,json def read_exact(stream,length): data=b'' while len(data)<length: chunk=stream.recv(length-len(data));assert chunk,'premature EOF';data+=chunk return data listener=socket.socket();listener.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) listener.bind(('192.0.2.2',18080));listener.listen();print('listening',flush=True) for trial in range(2): with listener.accept()[0] as connection: connection.settimeout(3);connection.setsockopt(socket.IPPROTO_TCP,socket.TCP_QUICKACK,0) print('configured',flush=True) request=read_exact(connection,2);assert request==b'ab',request connection.sendall(b'OK');print(json.dumps({'request':request.decode()}),flush=True) ''' service=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code]);assert line(service).strip()=='listening' tap,path=capture(stack,root,'client','c0','tcp port 18080','nagle') client_code=r''' import json,socket,sys,time def read_exact(stream,length): data=b'' while len(data)<length: chunk=stream.recv(length-len(data));assert chunk,'premature EOF';data+=chunk return data nodelay=int(sys.argv[1]);s=socket.socket();s.settimeout(3) s.setsockopt(socket.IPPROTO_TCP,socket.TCP_NODELAY,nodelay);s.bind(('192.0.2.1',40300+nodelay));s.connect(('192.0.2.2',18080)) print('connected',flush=True);assert sys.stdin.readline().strip()=='go' start=time.monotonic();s.sendall(b'a');time.sleep(.002);s.sendall(b'b');reply=read_exact(s,2) assert reply==b'OK';print(json.dumps({'nodelay':nodelay,'reply':reply.decode(),'elapsed_ms':(time.monotonic()-start)*1000}),flush=True);s.close() ''' outcomes=[] for nodelay in [0,1]: client=spawn(stack,['ip','netns','exec','client','python3','-u','-c',client_code,str(nodelay)]) assert line(client).strip()=='connected' and line(service).strip()=='configured' client.stdin.write('go\n');client.stdin.flush();outcomes.append(json.loads(line(client))) assert json.loads(line(service))['request']=='ab';client.wait(timeout=2);assert client.returncode==0 observed=packets(tap,path);timelines={} for nodelay in [0,1]: port=40300+nodelay;flow=[p for p in observed if port in (p['sport'],p['dport'])] first=next(p for p in flow if p['sport']==port and p['preview']=='61') second=next(p for p in flow if p['sport']==port and p['preview']=='62') assert (second['seq']-first['seq'])%2**32==1 acks=[p for p in flow if p['dport']==port and p['flags']&0x10 and 0<(p['ack']-first['seq'])%2**32<=2] assert acks if not nodelay: intermediate=next(p for p in acks if (p['ack']-first['seq'])%2**32==1) assert first['t']<intermediate['t']<=second['t'] and second['t']-first['t']>.01 else:assert second['t']<=acks[0]['t'] timelines[nodelay]={'first_byte':first,'second_byte':second,'first_ack_covering_data':acks[0], 'data_segment_gap_ms':(second['t']-first['t'])*1000} print(json.dumps({'trials':outcomes,'packet_timelines':timelines,'receiver_quickack_requested':0,'client_autocorking':0})) """ 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) 服务器等待完整请求 ab 才回复 OK。每次试验之前,它在已接受连接上请求非快速确认模式。客户端关闭本命名空间自动 corking,以排除另一种合并机制对比较的影响。父子进程通过明确消息同步,确保接收端先完成套接字设置,再让客户端开始发送应用字节,避免把配置时序竞争误认为算法差异。 ...

十月 31, 2027 · 3 分钟 · XB Field Notes
数据库因表很小而按顺序把每一行都读一遍。

小表上全表扫描反而赢

索引是一场下注:你先付出写入与存储的代价,换来读取时能跳过表的大部分。可当一条查询真的需要表的大部分时,这场下注就输了——引擎会为每一行都下一次 B 树,而不是把那些页按顺序读一遍。 优化器给两条路线都算了账,所以真正有意思的问题不是"原则上谁更快",而是交叉点落在哪里。表小并不等于工作量小,你在 1000 行时看到的计划,也不是 1000 万行时该期待的计划。 真正的货币是页,不是行 顺序扫描按物理顺序读表的页,一页能拿到很多行。PostgreSQL 用 seq_page_cost(默认 1.0)对 random_page_cost(默认 4.0)来给这件事定价:非顺序取到的页按顺序页的四倍计价,而把 random_page_cost 相对 seq_page_cost 调低,正是让优化器开始偏好索引扫描的那个开关。文档也指出,如果整个数据库都能装进缓存,把两者设成相等是合理的。 机械盘上一次随机寻道以毫秒计,而顺序读完一整条磁道几乎免费;NVMe 把这个差距压到微秒级,但页数本身并没有变,所以代价模型里「页数」这一项始终在。既然货币是页,行数就是一个很差的规模代理。同样是 1000 行的两张表:4 字节负载的那张是 12 页,4000 字节负载的那张是 1010 页。在 sqlite 3.53 上配 2 MB 页缓存,命中约 90% 行的宽谓词查询在窄表上 0.02 ms 跑完,在宽表上要 1.30 ms——行数完全一样,差了 65 倍。只有其中一张算"小"。 ...

九月 16, 2027 · 2 分钟 · XB Field Notes