Redis 批量发送请求以减少等待,同时保留每条命令各自的结果。

Redis 流水线减少逐条命令等待

Redis 流水线允许客户端连续发送多条命令,不必等前一条回应以后再发下一条。它减少了请求与响应之间的等待,也能分摊套接字输入输出开销。但服务端仍需要执行每条命令并生成回复;批处理不代表这些命令并行运行,也不自动形成事务边界。 少等几次,而不是少执行几条 同步循环不断重复发送一条、等待一条。流水线则先发送一个序列,再按对应顺序收取回复。把一批近似理解为一次往返,可以帮助估算延迟,但不能据此承诺只有一个 TCP 数据包、一次系统调用,或所有回应在同一时刻到达。实际传输仍受缓冲区、数据量和协议处理影响。 收益也不只出现在远程链路。把套接字读写集中起来,可以减少系统调用和调度开销,所以即使本地连接,也可能在不分片的情况下提高实际吞吐。反过来,流水线消除不了昂贵命令本身的工作和带宽上限。应先定位真实瓶颈,而不是看到单节点就断言下一步必须加分片。 普通流水线没有“不被插队”的保证。其它客户端可能在批次的命令之间执行操作,但具体某一次运行也可能碰巧全部连续完成。观察到一次连续执行,并不能把它上升为事务隔离承诺。正确性不能依赖一次测试里没有出现的调度交错。 区分顺序依赖与回复依赖 同一批命令不必完全无关。先 SET 计数器,再 INCR,最后 GET,可以放在一个流水线中,因为每条请求的参数在发送前都已确定;命令顺序本身就表达了服务端的数据依赖。真正无法放进同一个预先构造批次的,是必须根据前面回复计算键名或参数的下一条请求。 这种回复依赖应拆成不同阶段,或使用合适的原生命令、有界脚本。如果应用内的条件判断还需要并发保护,可以考虑 WATCH。MULTI/EXEC 提供执行隔离,却不会在运行期错误之后自动撤销其它已成功命令,这与减少往返是不同的问题。 还要检查客户端默认行为。redis-py 8.1.0 的 pipeline() 默认使用事务,显式传 transaction=False 才是普通流水线。因此拿默认包装器做测量,会同时测到批处理与 MULTI/EXEC。下面不仅写出选项,还读取服务端命令计数,核对实际有没有调用事务指令。 坏掉的样子 普通流水线中一条命令报错,不会取消已经提交的后续命令。错误回复仍占据结果序列中的对应位置,必须逐项检查;仅仅收到一个数组,不表示整个业务动作成功。忽略中间失败的写入,可能留下形状看似完整却不一致的应用数据。 redis-py 默认可能在读完回复后抛出 ResponseError,execute(raise_on_error=False) 则把命令错误放在结果元素里,两者都不会撤销成功写入。连接中断是另一类问题:缺少回应可能让已执行的前缀无法确定。即使从最后收到确认的命令之后继续,也可能重复一条已经执行、只是回应丢了的写入。 因此不能盲目重试递增或其它非幂等效果。即便反复写同一个固定值,如果中途另一个写入方已经更新数据,也需要并发约定来避免覆盖。可靠恢复是业务操作的职责,不是流水线传输优化顺带提供的能力。 批量应同时限制命令条数和字节数。客户端缓冲请求与结果,Redis 也可能暂存客户端还没读取的回应。大负载或慢读取方会增加内存与延迟成本;只按命令数量给出一个通用批量上限,忽略了不同命令和回复大小的差异。 快速验证 原有实验记录使用 macOS、Python 3.14.4、Redis 8.10.1 和 redis-py 8.1.0;本轮审阅没有重新执行服务实验,下面的结果说明保留原有观察范围。准备 POSIX 环境、C 编译器、make、curl、tar 与 Python 3.10 或更新版本。安装步骤仅用 libc 构建 Redis 核心,并把客户端装入临时虚拟环境。 ...

一月 20, 2027 · 3 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调取消单条流后还要验证其余请求。

HTTP/2 多路复用消除排队

HTTP/2 通过多路复用解除 HTTP 响应的顺序约束,但不同流仍然共享 TCP 连接及其故障边界。取消就是一个值得单独验证的场景:终止某个请求,不一定需要破坏相邻请求或可复用连接。这是一项有明确作用范围的能力,不能扩写成所有流在任何条件下都互不影响。 流不是底层套接字 HTTP/2 在连接内部标识单条流。RFC 9113 定义的 RST_STREAM 用于终止某条流,既可以表达取消,也可以报告流错误;它并不是重置整个套接字的 TCP reset。只要连接本身仍然可用,实现就可以停止一次交换,同时让其他流继续推进,而不必为每次取消重新建立连接。 这与 HTTP/1.1 的持久连接并不矛盾,后者本来就允许一条连接承载多次交换。HTTP/2 新增的是交错传输与流级生命周期控制,而不是首次发明连接复用。TCP 的有序交付、拥塞控制、连接级 DATA 流控以及服务端资源仍然约束进度;即使一次请求取消没有影响其他流,连接故障也可能同时中断多个请求。 取消还不等于撤销已经完成的业务。服务器可能早已提交事务,或调用了另一个服务。客户端读取正文时得到取消错误,无法证明这些操作没有发生。因此,是否重试仍须依据业务语义、幂等保证或可查询的执行结果,不能仅凭网络层已经取消,就把再次提交认定为安全操作。 收到响应头不代表正文成功 成功状态码出现在正文之前。服务端可以先发送并刷新 HTTP 200,再长时间等待,或者在传输内容时失败。状态、正文完整性和业务结果应分别记录。Client.Do 返回了响应对象,只说明客户端已经拿到响应头,不能据此把整个响应标为读取成功,更不能省略后续读取错误的处理。 Go 的出站请求上下文控制取消;对于入站 HTTP/2 请求,一次取消可以让服务端对应请求的上下文结束,而不需要关闭整条连接。处理函数在等待或访问依赖时,应观察并传递该上下文。运行时不会自动撤销任意业务动作。Client.Timeout 还覆盖响应头到达之后的正文读取,不能把它理解成只约束握手或等候首字节。 坏掉的样子 如果几个请求共用一个父上下文,取消父上下文本来就会影响它们;关闭底层连接也比取消单个请求的范围更大。请求自己的时限应由请求自己的取消范围管理。不能对共享套接字设置整体期限后,又声称产生的超时只作用于一条 HTTP/2 流;排障时必须核对取消信号的真正拥有者。 遗弃正文可能保留资源或占用流控额度。适合完整消费的响应应读完,已经结束或取消的正文也必须关闭。未读数据是否妨碍相邻流,与库的缓冲和窗口更新策略有关。流数量上限、共享连接额度和后端容量仍然存在;一个小实验能成功取消,不代表高负载时也没有相互影响。 快速验证 保存为 cancel_probe.go,使用 Go 1.23 或更新版本运行 go run cancel_probe.go。本地服务使用经过验证的 TLS 1.3 协商 HTTP/2。两个处理函数先刷新 HTTP 200 响应头,再让正文等待。客户端随后只取消第一个请求;另一个响应保持打开,双方都使用同一条被编号的 TCP 连接。 ...

一月 16, 2027 · 3 分钟 · XB Field Notes
观察、决策、行动三个面板,旁边的标题说明CDN 把缓存移得更近。

内容分发网络把缓存拉近

CDN 可以缩短读者与可复用响应之间的路径,也能减少源站请求量。但这些收益取决于实际路由、缓存里已有的内容和生效策略。域名指向 CDN,不等于某个对象已经缓存,也不证明服务它的是地理上最近的机器,更不能保证每次请求都会绕开源站。应检查真正发生的请求链路,而不是把网络示意图当作验证结果。 沿着真实请求检查 访客会到达服务商路由选出的边缘。如果那里存在可以复用的响应,就能直接返回;否则请求可能经过另一层缓存,也可能抵达源站。因此某个位置的第一次访问,常常与紧接着的第二次不同。发布文件也不代表它会立刻完整复制到所有边缘,不能把“源站可读取”和“所有节点已预热”混为一谈。 即使不能缓存,流量仍可能受益于连接处理或网络路由优化,但它不会取得缓存命中带来的同等回源减免。测量时要分开看建连、首字节等待和正文传输。一次距离源站很近的快速请求,不能推断远方读者、冷节点或源站故障时的表现;需要按真实访问位置和缓存状态分别取证。 明确哪些请求可以共享字节 缓存键决定哪些请求允许共享同一份响应。路径、选定的查询参数、请求头和 Cookie 都可能影响这个身份。凡是会改变所选表示的输入,都必须在复用决策中得到区分;只有确实不改变响应的追踪参数,才适合排除。维度太多会碎片化缓存,遗漏必要维度则可能返回错误内容,甚至跨越敏感数据边界。 “转发给源站”不等于“纳入缓存键”。CloudFront 区分缓存策略和源站请求策略,后者可以转发不进入缓存键的值。如果币种会传给源站、源站也据此生成金额,但缓存键忽略币种,那么一份美元响应就可能用于欧元请求。此时命中率上升并不是优化成果,而是在掩盖内容正确性缺陷。 检查最终生效的策略 源站的 Cache-Control、验证器和 Vary 都很重要,但服务商规则决定它们最终如何处理。必须确认实际绑定的缓存行为保留了每个必要变体。CloudFront 明确警告:最小 TTL 大于零时,即使源站发送 no-cache、no-store 或 private,也可能仍按最小 TTL 缓存内容。敏感路由需要同时检查源站和分发配置,不能只截图一个响应头就认定安全。 在遵守标准的共享缓存中,s-maxage 可以给出不同于浏览器 max-age 的寿命。新鲜度与重新验证仍是两个问题:ETag 能减少验证时的正文传输,却不能修复错误的缓存键。版本化资产需要配套更新引用文档,而边缘清理也不能召回浏览器自己保存、仍被认为新鲜的响应。这些层次要分别验收。 坏掉的样子 遗漏语言或币种会混用公开变体,遗漏访问授权边界则可能更严重。相反,把无关的唯一追踪标识放进缓存键,会让每次访问都变成冷请求。验证时需要对比不同输入下的实际正文,不能只检查“命中”标记。单独缺少 Age 也不足以证明未命中,不同服务商的缓存状态字段不能当作完全通用的标准来解释。 还要考虑发布清理和冷启动的回源压力。多个边缘位置可以各自正确地独立回填,却同时增加数据源流量。应确认源站能承受这类负载,并同时观察错误和回源请求计数。只在一个地点重复请求成功,既不证明全球所有节点已预热,也不证明清理已经传播完成,不能据此扩大性能或一致性结论。 快速验证 需要 Node.js 与 npm;本次使用 Node 26.0.0 和 http-cache-semantics 4.2.0。安装固定版本后,用 NODE_PATH="$cdn_lab/node_modules" node 执行 JavaScript。实验以固定时钟检查一份已存响应的合成请求元数据,安装后不进行网络请求。它不是 CDN 实现,也不模拟任何服务商的完整行为,目的仅是把缓存键和策略边界变成可断言的结果。 ...

一月 4, 2027 · 2 分钟 · XB Field Notes
关于Unix 套接字名称与访问规则的标题卡,配有 OBSERVE、DECIDE、ACT(观察、判断、行动)面板。

Unix 套接字提供本地通信,名称与访问规则仍须明确

Unix 域套接字提供本地进程间通信,无须 IP 地址、TCP 端口或外部网络路由。它仍使用内核套接字设施、缓冲区、调度和应用协议。路径套接字具有文件系统名称,但 Unix 套接字也可以没有名称,Linux 还支持抽象名称。这些形式具有不同访问规则,因此“Unix 套接字就是一个受权限保护的文件”并不是完整解释。 坏掉的样子 应用找得到套接字路径,却可能因为监听已经退出而无法连接。关闭路径套接字,不会自动删除对应文件系统条目。重启时直接绑定相同路径,便可能得到 EADDRINUSE,即使那里已经没有服务接受连接。文件存在是有用诊断信息,但不能作为健康检查;要知道服务是否可用,仍需要真实通信与预期应用响应。 Linux 连接路径流套接字,需要对套接字拥有写权限,同时能够搜索沿途目录。套接字文件上的执行位,与目录遍历所需执行位并不是同一用途。宽松的套接字权限无法补偿不可访问的父目录。反过来,让敏感控制套接字对所有本地进程可写,也可能授予很强的应用能力,不能因为没有 TCP 端口就忽略其权限。 这些规则不会自动适用于所有名称形式与操作系统。Linux 抽象套接字不依赖文件路径,修改某个路径的权限无法保护抽象名称。可移植程序也不能假定每种 Unix 实现都同样执行套接字文件权限。对端凭据和应用授权,应按照实际平台与身份模型设计,不能只根据一个看起来像文件的名字推测安全属性。 快速验证 将脚本保存为 check-unix-socket.py,准备 Docker 后执行 python3 check-unix-socket.py。固定 Linux 环境使用 Python 3.11.16,服务器在一次性目录中建立路径流套接字。独立客户端进程能够降低到用户号和组号六万五千五百三十四;实验不会修改宿主账号、现有套接字或权限,所有改动随容器清理: import json,subprocess,uuid image="python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84" program=r""" from pathlib import Path from tempfile import TemporaryDirectory import errno,json,socket,socketserver,stat,struct,subprocess,sys,threading class Handler(socketserver.StreamRequestHandler): def handle(self): self.request.settimeout(1);assert self.rfile.readline()==b'ping\n' pid,uid,gid=struct.unpack('3i',self.request.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,struct.calcsize('3i'))) self.wfile.write(json.dumps({'uid':uid,'gid':gid,'pid_positive':pid>0}).encode()+b'\n') client_code=r''' import json,os,socket,sys if sys.argv[2]=='drop':os.setgroups([]);os.setgid(65534);os.setuid(65534) try: with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as s: s.settimeout(1);s.connect(sys.argv[1]);s.sendall(b'ping\n');out=s.makefile('rb') with out:print(out.readline().decode().strip()) except PermissionError:print(json.dumps({'error':'EACCES','uid':os.geteuid()})) ''' def client(path,drop=False): p=subprocess.run([sys.executable,'-c',client_code,str(path),'drop' if drop else 'keep'],capture_output=True,text=True,timeout=3) assert p.returncode==0,p.stderr return json.loads(p.stdout) with TemporaryDirectory(prefix='unix-socket-') as directory: root=Path(directory);root.chmod(0o755);path=root/'service.sock' server=socketserver.UnixStreamServer(str(path),Handler) path.chmod(0o600);thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() try: owner=client(path);denied=client(path,True) assert owner['uid']==0 and denied=={'error':'EACCES','uid':65534} path.chmod(0o666);allowed=client(path,True);assert allowed['uid']==65534 and allowed['gid']==65534 root.chmod(0o700);directory_denied=client(path,True);assert directory_denied==denied root.chmod(0o755) finally:server.shutdown();server.server_close();thread.join(timeout=2);assert not thread.is_alive() assert stat.S_ISSOCK(path.stat().st_mode) with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as probe: try:probe.connect(str(path));raise AssertionError('stale socket accepted') except ConnectionRefusedError:stale='ECONNREFUSED' with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as duplicate: try:duplicate.bind(str(path));raise AssertionError('stale path reused') except OSError as error:assert error.errno==errno.EADDRINUSE path.unlink() with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as replacement,socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as probe: replacement.bind(str(path));replacement.listen(1);replacement.settimeout(1);probe.settimeout(1);probe.connect(str(path)) peer,_=replacement.accept() with peer,probe.makefile('rb') as incoming: peer.sendall(b'restarted');assert incoming.read(9)==b'restarted' print(json.dumps({'owner_peer':owner,'mode_0600_denied':denied,'mode_0666_peer':allowed, 'directory_0700_denied':directory_denied,'closed_path_remains_socket':True,'stale_connect':stale, 'stale_bind':'EADDRINUSE','restarted_after_owned_path_unlink':True})) """ 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) 父目录允许搜索、套接字权限为零六零零时,root 客户端能够连接,并收到服务器读取的对端凭据;降权客户端得到 EACCES。服务器通过 SO_PEERCRED 取得已经连接的进程身份,没有相信请求自行声明的用户字符串。脚本核对连接边界实际报告的有效用户号,以真实结果验证身份读取,而不只是检查选项名称出现过。 ...

十一月 28, 2026 · 1 分钟 · XB Field Notes
关于写回、可见性与刷写的标题卡,配有 OBSERVE、DECIDE、ACT(观察、判断、行动)面板。

写回把缓冲可见性与存储完成分开

缓冲文件 I/O 可以在不同时间经过语言缓冲区、内核页缓存和存储栈。被修改的缓存页需要写回,使数据进入后端文件系统与设备。分开这些阶段可以合并工作、提高吞吐,但也意味着另一读取者看到新字节,本身不能证明已经完成可经历崩溃的存储边界。理解性能和持久性时,需要先确定观察的是哪一层完成。 坏掉的样子 缓冲写入不保证瞬间返回。分配、内存压力、脏页限制或文件系统活动,都可能让写者等待。如果生产速度持续超过存储处理能力,脏数据不能无限增长,压力最终会反映到工作负载。把每次 write 都描述为单纯内存复制、所有 I/O 都在之后发生,会错误解释持续负载下的延迟尖峰。 语言层 flush 与文件系统同步含义不同。flush 把应用缓冲数据提交到下一层,fsync 则请求文件同步边界。普通关闭文件,或者从另一个描述符读到相同字节,不能替代这一显式契约。因此只测成功写入的基准,可能测到的是缓存接受速度,而不是稳定、持久的存储吞吐,需要与实际工作要求对应。 写回设置也不是逐文件持久性截止时间。后台阈值促使刷新线程工作,更高的脏页阈值影响生成写入的进程,周期参数则控制唤醒或较老脏数据何时符合处理条件。设备进展、文件系统行为和竞争负载仍会影响结果。不能仅设一个时间间隔,就证明每次已确认更新在这么多秒后必定安全。 快速验证 把脚本保存为 writeback.py,在装有 Python 和 strace 的 Linux 环境中运行 python3 writeback.py,并让临时目录位于 ext4。实测使用 Linux 6.12.76、Python 3.11.2 和 strace 6.1。样本写入约四 MiB、读取记账信息,不改变全局脏内存参数或缓存控制: from pathlib import Path from tempfile import TemporaryDirectory import subprocess,sys program=r''' from pathlib import Path import json,os,subprocess,sys root=Path(sys.argv[1]);path=root/'data' def io():return {key:int(value) for key,value in (line.split(':',1) for line in Path('/proc/self/io').read_text().splitlines())} def dirty():return int(next(line.split()[1] for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith('Dirty:'))) with path.open('wb',buffering=65536) as handle: handle.write(b'buffered') assert path.stat().st_size==0 and path.read_bytes()==b'' before=io();handle.flush();after=io() assert after['wchar']-before['wchar']>=8 reader='from pathlib import Path;import sys;assert Path(sys.argv[1]).read_bytes()==b"buffered"' subprocess.run([sys.executable,'-c',reader,str(path)],check=True) print('visible before explicit fsync',flush=True) before=io();chunk=b'x'*(4*1024*1024);handle.write(chunk);handle.flush();after=io() assert after['wchar']-before['wchar']>=len(chunk) assert after['write_bytes']-before['write_bytes']>=len(chunk) dirty_before=dirty();os.fsync(handle.fileno());dirty_after=dirty() assert path.stat().st_size==len(chunk)+8 print(json.dumps({'wchar_delta':after['wchar']-before['wchar'],'write_bytes_delta':after['write_bytes']-before['write_bytes'],'system_dirty_kib_before':dirty_before,'system_dirty_kib_after':dirty_after})) ''' with TemporaryDirectory(prefix='xbcatm-writeback-') as directory: root=Path(directory);trace=root/'trace' result=subprocess.run(['strace','-f','-yy','-s','200','-e','trace=write,fsync,fdatasync','-o',str(trace),'--',sys.executable,'-c',program,str(root)],capture_output=True,text=True,timeout=10) assert result.returncode==0,result.stderr lines=trace.read_text().splitlines() visible=next(i for i,line in enumerate(lines) if 'visible before explicit fsync' in line) syncs=[i for i,line in enumerate(lines) if 'fsync(' in line and str(root/'data') in line] assert len(syncs)==1 and visible<syncs[0] and '= 0' in lines[syncs[0]] print(result.stdout,end='');print(lines[syncs[0]]) print('another process read flushed bytes before the only explicit file synchronization; global Dirty is observational, not an isolated-device counter') 子进程用六万五千五百三十六字节的 Python 缓冲打开文件,并写入八字节。flush 前,路径报告长度零,独立打开也读不到内容,因为数据仍在应用缓冲里。这个对照直接建立最早的边界,没有假定每次语言级 write 都已经发出内核写入。应用接口返回与系统调用发生,需要分别确认。 ...

十一月 26, 2026 · 2 分钟 · XB Field Notes
不可变资源与内容指纹标题卡旁边排列观察、决策与行动三个面板。

不可变资源与内容指纹

资源指纹把文件字节的摘要放进地址。样式表发生变化,构建就为新内容生成另一个地址,页面因此能够选择新版本,而不用等待所有旧缓存同时过期。这项方法依赖发布流程遵守承诺:某个地址一旦对外提供,就不能随意替换成与名称摘要不符的内容。文件名看起来像摘要,不代表服务器已经落实这种约束。 名称配合新鲜度契约 构建程序可以对生成文件计算完整的 SHA-256,再把页面中的逻辑名称替换成相应地址。同样的字节保留相同名称,变化的字节通常得到不同名称;摘要截断越短,碰撞风险越需要认真评估。浏览器不会因为名称含有一串十六进制字符,就自动计算文件摘要验证完整性。实际缓存键还受到请求上下文和 Vary 等因素影响,命名方法不能取代 HTTP 缓存规则。 公开指纹资源常用的响应头是 Cache-Control: public, max-age=31536000, immutable。前者给出一年新鲜期,后者承诺该表示在新鲜期内不会改变,因此 RFC 8246 建议客户端避免不必要的条件请求。这不是永久保存许可:缓存可能被淘汰,用户可以强制刷新,客户端也可能不支持扩展。规范还建议在 HTTPS 等经过认证的上下文之外忽略 immutable,不能把它当作内容可信性的替代品。 负责引用这些资源的 HTML 需要独立缓存策略,例如较短新鲜期或合适的重新验证,让用户能够发现新版本。发布顺序应先让新资源可用,再暴露引用它们的页面或清单。旧页面、旧标签页和回退版本仍可能使用旧地址,资源保留期限必须覆盖这些真实使用方式。仅有指纹,不能让整个多文件发布自动成为原子事务。 public 只适合允许共享的表示。某份个性化下载即使带有内容摘要,也不会因此变成可供共享缓存保存的公开资源。响应策略仍然必须服从身份鉴别与保密要求;摘要只是命名依据,不能充当授权或隔离用户数据的措施。 坏掉的样子 覆盖旧指纹路径会制造两套互相矛盾的事实:已有用户继续从缓存读取红色样式,新客户端却在同一个地址读到蓝色字节。清理一个 CDN 节点,也不能把所有浏览器里的私有副本同步删除。发布检查应核对真实文件字节和记录的摘要,在开放流量之前拒绝这种变更,而不是依赖上线后提醒用户刷新。 立即删除上一版资源会产生另一类故障。缓存中的页面还可能引用昨日脚本,回退后的 HTML 也可能依赖已经移除的文件。保留源码提交不等于保留可直接恢复的构建产物。即使所有摘要都正确,先发布页面、后上传文件仍会造成暂时的缺失资源;多节点传播期间也需要检查依赖是否已经就绪。 快速验证 把下面代码保存为 immutable.py,使用 Python 3 执行 python3 immutable.py。程序启动回环服务,检查样式字节的摘要和缓存响应头,故意改写旧地址背后的内容,确认摘要不匹配,然后恢复原文并关闭服务。预期三个契约检查标志为真,而 browser_cache_tested_by_cli 为假。这个命令读取了真实 HTTP 响应,但没有实现浏览器缓存,因此不能单凭命令结果宣称已经验证客户端复用行为。 ...

十一月 25, 2026 · 2 分钟 · XB Field Notes
三个相连的方块,依次标注观察、决策与行动。

Server-Timing 向浏览器暴露具名测量结果

Server-Timing 响应头允许服务端给响应附加具名测量结果,浏览器开发者工具和性能接口随后可以把这些结果与对应资源请求关联起来。当网络瀑布图只能显示等待很久,却无法说明后端是否在查询数据库、渲染页面或执行其他操作时,这类信息很有帮助。但它提供的是服务端选择上报的观测结果,并不是浏览器独立测得这些内部操作的耗时,因此仍然需要理解测量范围和产生位置。 正确的时长语法是 db;dur=42, render;dur=81,建议使用毫秒作为单位。浏览器解析数值,但无法验证发送方实际采用的单位。直接写 db=42,并不是这项协议表达时长的方式。每个指标有名称,也可以带时长和描述参数。生产环境应采用含义稳定的名称,从实际插桩中得到数值。描述可以解释测量对象,但不应包含响应本来不该披露的原始查询、身份编号或内部细节。浏览器能够展示字段,不代表所有调试内容都适合跟随响应对外发送。 让数值对应明确的时间区间 服务端的多个测量值不一定能加成浏览器看到的请求总耗时。它们可能覆盖嵌套操作、相互重叠的工作,也可能只覆盖处理路径的一部分。只有数据库时间与渲染时间的定义允许相加时,加总才有意义。网络传输、测量区间之外的排队、缓存和客户端处理,都可能解释两侧数字的差异,并不一定说明某个时钟有错误。应先弄清每个数值从哪里开始、在哪里结束。 这个响应头也不会自动生成分布式链路。它在单个响应上命名测量结果,并没有自动提供父子关系、跨服务传播或采集器存储。可以用它展示少量对请求有帮助的测量,再通过日志或链路关联获取更深的解释。当响应经过缓存和代理时,还应检查最终交付的响应头,确定这些数字由哪个组件产生。读取一个字段与证明当前源站执行了相应工作,是不同层次的证据。 坏掉的样子 跨源资源存在两个容易混淆的权限边界。CORS 允许脚本读取响应,而 Access-Control-Expose-Headers 可以让 response.headers 暴露 Server-Timing 字段。Timing-Allow-Origin 则控制相应的详细资源计时信息是否可见。安全上下文与浏览器隐私规则还可能进一步限制计时暴露。因此,脚本成功读取响应头,并不能证明 PerformanceResourceTiming.serverTiming 数组一定包含同样的数据。排查时需要分别验证,不能只用其中一个接口有值就推断另一项能力已经成立。 另一个错误,是把解析演示当成真实性能测量。服务端发送固定时长,只能证明浏览器怎样解析这个数字,不能说明处理器实际运行了那么久。同样,计时数组为空,也不能证明服务端没有做任何工作。应先核对来源授权与真实线上响应头,再检查测量代码放置的位置。公开名称与说明还要符合接收对象的范围,避免为了排障而把不该公开的后端结构随响应一起暴露出去。 快速验证 将代码保存为 server-timing.py,执行 python3 server-timing.py –serve;此前记录使用 Python 3.14.4;本轮没有重跑套接字或浏览器测试。脚本在两个不同的临时端口上启动本机回环 HTTP 服务,因此它们是不同来源,然后输出待打开的页面地址。页面会请求两份资源,把响应头内容与浏览器解析出的性能条目同时显示出来。结束后按控制键加 C 关闭两个服务;不带服务参数执行时,只检查响应头并退出,不会自动运行浏览器。 import json import sys from http.server import BaseHTTPRequestHandler, HTTPServer from threading import Event, Thread from urllib.request import Request, urlopen timing = "db;dur=42, render;dur=81" class API(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): body = b"synthetic timing fixture\n" self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.send_header("Access-Control-Allow-Origin", app_origin) self.send_header("Access-Control-Expose-Headers", "Server-Timing") self.send_header("Server-Timing", timing) if self.path.split("?", 1)[0] == "/allow": self.send_header("Timing-Allow-Origin", app_origin) self.end_headers() self.wfile.write(body) class App(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): body = ("""<!doctype html><meta charset="utf-8"><title>Server-Timing lab</title> <h1>Cross-origin Server-Timing</h1><pre id="result">Running...</pre> <script> const api = """ + json.dumps(api_origin) + """; async function run() { performance.clearResourceTimings(); const results = {}; for (const path of ['deny', 'allow']) { const url = api + '/' + path; const response = await fetch(url, {cache:'no-store'}); await response.text(); await new Promise(resolve => requestAnimationFrame(resolve)); const entry = performance.getEntriesByName(url).at(-1); results[path] = {header:response.headers.get('Server-Timing'), timing:entry.serverTiming.map(item => ({name:item.name, duration:item.duration}))}; } window.labResult = results; document.getElementById('result').textContent = JSON.stringify(results, null, 2); } run().catch(error => {window.labError=String(error); document.getElementById('result').textContent=String(error);}); </script>""").encode() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) api = HTTPServer(("127.0.0.1", 0), API) app = HTTPServer(("127.0.0.1", 0), App) api_origin = f"http://127.0.0.1:{api.server_port}" app_origin = f"http://127.0.0.1:{app.server_port}" threads = [Thread(target=server.serve_forever, daemon=True) for server in (api, app)] for thread in threads: thread.start() try: for path in ("deny", "allow"): with urlopen(Request(api_origin + "/" + path, headers={"Origin": app_origin}), timeout=5) as response: assert response.headers["Server-Timing"] == timing assert response.headers["Access-Control-Allow-Origin"] == app_origin assert response.headers["Access-Control-Expose-Headers"] == "Server-Timing" assert response.headers.get("Timing-Allow-Origin") == (app_origin if path == "allow" else None) response.read() print(json.dumps({"app_url": app_origin, "api_url": api_origin, "wire_checks": "PASS", "durations_are_synthetic": True}), flush=True) if "--serve" in sys.argv: Event().wait() except KeyboardInterrupt: pass finally: for server in (api, app): server.shutdown() server.server_close() for thread in threads: thread.join(timeout=5) 两份响应都通过 CORS 允许页面来源,都显式暴露 Server-Timing,也都携带同样的合成数据库与渲染时长。只有 allow 路径额外提供 Timing-Allow-Origin。在此前 Chrome 152 的观察记录中,两侧脚本都读到 db;dur=42, render;dur=81,但未授权的计时数组为空,已授权数组则包含名为 db、时长四十二,以及名为 render、时长八十一的两项结果。这份浏览器证据与 Python 的线上响应头断言分别检查,避免用非浏览器客户端代替实际可见性验证。 ...

十一月 7, 2026 · 2 分钟 · XB Field Notes
标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

预备计划必须证明部分索引谓词

一个任务队列可能只有一百条待处理记录,却保留了成千上万条已完成记录。如果主要请求都是领取待处理任务,只为这个子集建立索引就可能更合适。PostgreSQL 把它称为部分索引,索引谓词决定哪些表行拥有条目。收益仍取决于真实查询和状态变化,不能把“所有索引越小越好”当成脱离业务的优化目标。 坏掉的样子 条件之间的蕴含方向不能写反:查询条件必须蕴含索引谓词,也就是查询可能需要的每一行都在索引覆盖范围内。只收录待处理任务的索引,不能完整回答没有状态限制的全表查询。两个条件看上去不矛盾,也不代表规划器能证明它们之间的关系。PostgreSQL 不会识别所有数学上等价的写法,所以让谓词接近应用实际过滤表达式,也有助于人工审阅和计划识别。 哪些参数已经知道,同样由计划阶段决定。预备语句的定制计划,可以使用这次传入的状态值;通用计划却必须对所有可能的参数都成立,不能假定每次调用永远查询待处理状态。因此,手工测试常量查询时看到索引被使用,并不能直接证明应用长期执行的预备语句也会选择同一条访问路径。 快速验证 在 PostgreSQL 18 的可丢弃数据库中,使用一个全新的 psql -X -v ON_ERROR_STOP=1 会话按顺序执行。实验没有建立覆盖全表的其他索引,便于观察资格差异。先创建一万条任务,其中编号为一百整数倍的记录处于待处理状态: CREATE SCHEMA partial_demo; CREATE TABLE partial_demo.jobs (id integer,state text); INSERT INTO partial_demo.jobs SELECT g,CASE WHEN g%100=0 THEN 'ready' ELSE 'done' END FROM generate_series(1,10000) AS g; CREATE INDEX ready_jobs ON partial_demo.jobs(id) WHERE state='ready'; VACUUM (ANALYZE) partial_demo.jobs; SELECT state,count(*) FROM partial_demo.jobs GROUP BY state ORDER BY state; 分组结果应是已完成九千九百条、待处理一百条。这里索引键是任务编号,谓词检查的是状态,两者不要求是同一个字段。先检查带常量条件的计划: ...

十月 17, 2026 · 1 分钟 · XB Field Notes