短新鲜度寿命与缓存填充锁,分别处理突发流量中的不同问题。

微缓存短暂复用新鲜条目,并显式合并并发未命中

微缓存让可复用响应保持很短的新鲜度寿命,常见尺度是几秒。在这个窗口里,请求同一选中表示的后续访问可以复用新鲜条目。响应可能落后于源站刚发生的更新,但不能仅因为它来自缓存,就称它在 HTTP 意义上已经过期。何时过期、是否允许提供过期内容、如何协调并发填充,是三个不同策略,需要分别定义。 坏掉的样子 TTL 短并不意味着共享响应对所有用户都安全。如果个性化请求读取或填充通用缓存条目,一个用户的表示就可能在另一个上下文里被交付。proxy_cache_bypass 根据条件禁止从缓存读取;proxy_no_cache 根据条件禁止保存响应。只控制其中一侧,可能让另一条路径不符合预期隐私边界。读取已有公开条目和保存新私有响应都需要考虑。 缓存键决定哪些请求共享响应。忽略有意义的查询参数或主机维度,可能合并不同表示;把任意变化数据全部放进键,又可能让复用失效。下面实验只有一个后端 authority,并保留包含参数的完整请求 URI。真实服务如果按租户、语言或其他输入改变内容,就必须确定自己的表示身份,不能把示例键当成适用于所有应用的通用公式。 仅仅启用缓存,也不保证冷启动突发只产生一次回源。在任何响应保存完成前,多个请求可能同时未命中同一条目。proxy_cache_lock 负责协调新条目的填充,并有有限的超时和年龄控制。这个锁不是 TTL,缩短新鲜度寿命也不会隐式开启锁。新鲜条目复用与并发未命中合并,需要通过不同对照才能验证。 快速验证 将脚本保存为 check-microcache.py,准备 Python、Docker 和固定镜像后执行。记录环境为 Linux arm64 上的 nginx 1.28.0。可丢弃后端报告每条路径的请求计数,nginx 使用两秒有效期,并显式设置 Cookie 与 Authorization 绕过条件。监听仅发布到宿主回环,使用的会话和授权值都是公开测试标记,不连接真实账户: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,json,socket,subprocess,time,uuid NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def wait(predicate): deadline=time.monotonic()+15 while time.monotonic()<deadline: try: if predicate():return except OSError:pass time.sleep(.1) raise AssertionError('fixture did not become ready') @contextmanager def lab(configuration,files=None,backend=None): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration) for path,content in (files or {}).items(): target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True) target.write_bytes(content if isinstance(content,bytes) else content.encode()) docker('network','create',name) if backend is not None: (root/'backend.py').write_text(backend) (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n') docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root)) docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1]) wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--network','none','-t',tags[0],str(root)) docker('run','--rm','--network',name,tags[0],'nginx','-t') docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort']) def ready(): connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True wait(ready) def request(path,method='GET',headers=None,body=None): connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5) try: connection.request(method,path,body=body,headers=headers or {}) response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read() finally:connection.close() request.port=port yield containers[0],request finally: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False) def config(locations,extra=''): return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n' from concurrent.futures import ThreadPoolExecutor import threading backend=r''' from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer from urllib.parse import urlsplit,parse_qs import json,threading lock=threading.Lock();counts={};gates={'/lock':threading.Event(),'/nolock':threading.Event()} class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def log_message(self,*args):pass def do_GET(self): parsed=urlsplit(self.path) if parsed.path=='/control/state': with lock:data=dict(counts) elif parsed.path=='/control/release': gates[parse_qs(parsed.query)['path'][0]].set();data={'released':True} else: with lock:counts[self.path]=counts.get(self.path,0)+1;count=counts[self.path] if parsed.path in gates:assert gates[parsed.path].wait(15) data={'path':self.path,'count':count,'cookie':self.headers.get('Cookie'),'authorization_present':bool(self.headers.get('Authorization'))} body=json.dumps(data,sort_keys=True).encode();self.send_response(200) if parsed.path=='/cookie':self.send_header('Set-Cookie','fixture=public-example; Path=/') if parsed.path=='/private':self.send_header('Cache-Control','private') if parsed.path=='/no-store':self.send_header('Cache-Control','no-store') self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body) ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' extra=''' proxy_cache_path /tmp/cache levels=1:2 keys_zone=pages:1m inactive=30s max_size=10m; map $http_cookie $skip_cookie { default 1; "" 0; } map $http_authorization $skip_auth { default 1; "" 0; } proxy_cache pages; proxy_cache_key "$scheme|$proxy_host|$request_uri"; proxy_cache_valid 200 2s; proxy_cache_bypass $skip_cookie $skip_auth; proxy_no_cache $skip_cookie $skip_auth; proxy_cache_lock on; proxy_cache_lock_timeout 5s; proxy_cache_lock_age 5s; add_header X-Cache $upstream_cache_status always; ''' locations=''' location /control/ { proxy_cache off; proxy_pass http://backend:8081; } location = /nolock { proxy_cache_lock off; proxy_pass http://backend:8081; } location / { proxy_pass http://backend:8081; } ''' with lab(config(locations,extra),backend=backend) as (_,request): def get(path,headers=None): status,h,b=request(path,headers=headers);assert status==200;return h.get('x-cache'),json.loads(b) results={} first=get('/item?x=1');second=get('/item?x=1');assert first[0]=='MISS' and second==('HIT',first[1]) other=get('/item?x=2');assert other[0]=='MISS' and other[1]['path']!=first[1]['path'] results['fresh']=[first[0],second[0]];results['different_query']=other[0] for name,headers in [('cookie',{'Cookie':'session=public-fixture'}),('authorization',{'Authorization':'Bearer public-fixture'})]: private=get('/item?x=1',headers);assert private[0]=='BYPASS' and private[1]!=first[1] assert get('/item?x=1')==('HIT',first[1]) new='/new-'+name;assert get(new,headers)[0]=='BYPASS' public=get(new);assert public[0]=='MISS' and public[1]['cookie'] is None and not public[1]['authorization_present'] results[name]={'warm_key':'BYPASS','public_after_private_miss':public[0]} for path in ['/cookie','private','no-store']: path='/'+path.lstrip('/');a=get(path);b=get(path) assert a[0]==b[0]=='MISS' and a[1]['count']==1 and b[1]['count']==2 results[path]=[a[0],b[0]] deadline=time.monotonic()+6 while time.monotonic()<deadline: value=get('/item?x=2') if value[0]=='EXPIRED':break assert value[0]=='HIT';time.sleep(.15) else:raise AssertionError('TTL did not expire') assert value[1]['count']==2;results['after_ttl']={'status':value[0],'count':value[1]['count']} for path,number,origin_count in [('/lock',6,1),('/nolock',4,4)]: barrier=threading.Barrier(number+1) def client():barrier.wait();return get(path) with ThreadPoolExecutor(max_workers=number) as pool: futures=[pool.submit(client) for _ in range(number)];barrier.wait() wait(lambda:get('/control/state')[1].get(path)==origin_count) time.sleep(.3);before=get('/control/state')[1][path] assert before==origin_count and not any(f.done() for f in futures) get('/control/release?path='+path);responses=[f.result(timeout=10) for f in futures] assert get('/control/state')[1][path]==origin_count statuses=sorted(r[0] for r in responses) if path=='/lock':assert statuses==['HIT']*5+['MISS'] and all(r[1]==responses[0][1] for r in responses) else:assert statuses==['MISS']*4 results[path]={'origin_requests_while_held':before,'cache_statuses':statuses} print(json.dumps(results,sort_keys=True)) 首次公开请求 /item?x=1 报告 MISS,下一次报告 HIT,并保留相同后端结果。/item?x=2 有自己的 MISS,响应中的路径也不同。持续检查第二个条目,最终观察到 EXPIRED,后端计数变为二。这依据服务器时钟观察实际过期,不假定精确到亚秒的切换时刻;测试关注状态变化及重新计算,而不是某个固定睡眠值。 ...

三月 12, 2028 · 3 分钟 · XB Field Notes
一个独立于任何自然值、专司身份的自动稳定 id。

代理键把身份与含义分开

代理主键把一行记录的身份与可能变化的业务值分开。客户修改邮箱时,订单仍引用同一个客户编号,而不必跟着邮箱一起改引用。这对于易变或较宽的业务键很有用,但稳定性来自数据库约束与应用规则,不是“自动生成”四个字本身。一个生成的数字不会天然不可修改、保密、全局唯一,也不能代替访问授权。 生成身份与约束唯一性 PostgreSQL identity 列从关联序列取得默认值。GENERATED ALWAYS AS IDENTITY 会拒绝普通显式赋值,GENERATED BY DEFAULT 则允许调用方提供值。即使使用 ALWAYS,导入时仍可通过 OVERRIDING SYSTEM VALUE 显式覆盖。两种形式都不会单独保证值唯一;需要拒绝重复标识时,仍须声明 PRIMARY KEY 或 UNIQUE 约束。 主键同时保证唯一与非空,并提供可供引用使用的索引。Identity 负责怎样生成默认值,约束负责哪些最终值允许存储,这两项职责不能混淆。导入时尤其如此:显式写入一个很大的编号,不会自动把序列推进到那个位置。若没有协调序列状态,后续生成值可能最终与导入行相撞,直到主键报错才暴露问题。 单独保留业务唯一规则 代理编号不会阻止相同业务实体重复出现。如果邮箱是唯一登录标识,应继续保留明确的唯一约束,并先决定规范化规则。普通 text 的 UNIQUE 使用该列的比较语义,不会暗中替应用处理大小写折叠、空白或各种邮箱等价规则。迁移之前就要明确这些行为,否则数据库认为不同的值,应用可能认为是同一账号,或者反过来。 子表如果不能出现悬空引用,就需要真实的外键约束。邮箱不是引用键时,修改它不会改变订单指向的客户,因为订单保存的是 id。这缩小了这一项修改的范围,却不能承诺所有业务改名都变成简单操作:搜索索引、审计记录、外部集成和缓存仍可能依赖邮箱,需要按实际调用链更新。数据模型只解决它真正覆盖的关系。 坏掉的样子 把序列编号当成创建顺序或提交顺序,在并发下会产生错误结果。多个事务可能先后取号却反向提交,失败语句也可能留下空洞。展示排序应使用明确的时间字段并补充稳定决胜字段,不能根据编号连续性推断是否丢单。顺序编号也容易被猜到;即使另加不透明的公开标识,授权仍必须检查请求者与资源之间的关系,不能靠“不容易猜”保护数据。 导入时覆盖 identity 是另一种陷阱。插入的显式编号远大于序列当前位置,序列仍可能继续从较小值生成,最终碰到已有主键。应在受控导入流程中协调序列状态,并排除或正确协调并发写入。另开一个会话查询 max(id) 再加一,不是并发安全的分配算法;两个调用方可能同时得到相同结果。 迁移还可能意外删掉原自然键承担的唯一规则。换键之前要检查重复值与 null,保留真正需要的业务唯一约束,并沿所有真实引用更新。也不必仅因代理键常见就给每张表增加一列和一个索引;稳定自然键如果已经准确表达契约,也可能是合适选择。决策依据应是业务值的可变性、引用范围和数据语义。 快速验证 把下面代码保存为 identity.sql,运行 psql -X -v ON_ERROR_STOP=1 -f identity.sql,连接一个可丢弃的 PostgreSQL 18 数据库。实验使用临时表并在末尾回滚,不依赖已有 users 表,也不需要读取真实用户邮箱。所有地址都采用保留的 example.test 域名,仅用于构造可重复的数据。 ...

二月 2, 2028 · 2 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调建立隧道之后仍要验证目标端点。

CONNECT 方法打开一条裸隧道

CONNECT 请求代理建立隧道,并不让代理从数据路径上消失。准入成功之后,代理仍然向两个方向转发字节,并管理套接字、缓冲区和故障处理。CONNECT 本身不提供加密;HTTPS 是在这条隧道内部另行建立并验证 TLS 会话,不能把两个阶段的成功混为一谈。 在正确的边界切换语义 HTTP/1.1 的 CONNECT 目标是 server.example:443 这样的主机与端口组合,而不是普通路径或完整 HTTPS URL。端口必须明确给出。成功的 2xx 响应在响应头结束后立即切换到隧道模式;非成功响应则表示隧道尚未建立,此时不能直接开始把后续内容当作目标服务的数据。 成功的 CONNECT 响应不能携带 Content-Length 或 Transfer-Encoding。随后出现的字节属于隧道,而不是一份用长度界定的 HTTP 响应正文。自己实现中继时,这一点尤其重要:HTTP 解析器已经缓冲的字节也可能属于隧道,不能因为切换到原始套接字,就把此前预读的数据丢掉。 客户端随后可以穿过中继,与目标协商 TLS。代理返回 200 只说明允许并建立了隧道,不负责证明目标证书有效,也不证明应用请求成功。客户端仍须检查信任链、主机名与应用响应。TLS 拦截属于另一种部署方式,需要客户端信任会终止 TLS 的中间实体,不能把它与普通盲转发混为同一种安全模型。 看不见正文,仍然占用资源 盲中继不必解释加密的应用内容,但仍能观察目标地址、字节量、时序和关闭行为。它可以落实连接预算、超时、流量限制,以及撤销准入之后的断开操作。无法读取 TLS 内部的 HTTP 消息,不等于失去所有运营控制,更不等于不再承担带宽和描述符成本。 真正的正向代理应验证调用方身份,并限制目的地。任意主机和端口访问,可能暴露内部服务,或被用于其他协议的滥用。按主机名放行时,还必须考虑地址解析策略:通过名称检查,并不自动证明最终拨号的 IP 地址符合约束。本地实验只允许一个预先确定的环回地址与端口,因此没有引入任意目标的解析和转发能力。 ...

十二月 13, 2027 · 3 分钟 · XB Field Notes
nginx 可以一边缓冲一边转发尚未完成的响应,有限存储决定它能领先客户端多远。

响应缓冲在资源预算内吸收两端速度差

响应缓冲允许 nginx 以一种速度读取上游,同时以另一种速度向客户端交付数据。开启 proxy_buffering on 后,nginx 使用配置的内存缓冲区,也可以把大响应的一部分写入临时文件。它不必先收齐完整响应才发送第一批字节。两条连接能在多大程度上独立推进,取决于可用缓冲容量,而不是“开启”这两个字就能无限解耦。 坏掉的样子 “慢客户端永远不会阻塞后端”是错误承诺。内存缓冲、临时文件上限、磁盘吞吐和套接字缓冲都有限。当 nginx 无法继续吸收数据,上游仍然可能受到反压。禁用临时文件缓冲,也不等于关闭响应缓冲;它只是移除了一个等待数据的存储位置,使容量边界更容易到达。应明确哪个容量耗尽,而不是把所有等待都叫作后端慢。 缓冲还可能影响端点的延迟预期。虽然不要求收完整份响应,小事件仍可能先累积,之后才对客户端可见。流式端点应明确选择响应缓冲策略,并通过完整链路验证事件可见时间。即使关闭 nginx 响应缓冲,应用自身缓冲、压缩、TLS 和其他中间层仍会影响交付。因此,修改一条指令不能代替实际观察用户什么时候收到每个事件。 资源交换发生在代理层。把响应写入临时文件,可以让后端较早结束工作,但在客户端慢慢读取期间会占用临时存储和磁盘读写能力。为每条连接扩大内存缓冲,则可能在高并发下产生另一类容量问题。真正需要决定的是:面对实际工作负载,代理应该吸收多少数据、承担多少等待,而不是只凭单个缓冲值看起来更大来判断性能。 快速验证 将脚本保存为 check-response-buffering.py,准备 Python、Docker、固定镜像和足够的临时磁盘空间后运行。记录环境为 Linux arm64 上的 nginx 1.28.0。专用后端提供受控响应开关和进度计数,nginx 仅通过宿主回环监听。实验会传输三份 32 MiB 正文,完成后清理自己的资源,不使用现有业务后端: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,json,socket,subprocess,time,uuid NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def wait(predicate): deadline=time.monotonic()+15 while time.monotonic()<deadline: try: if predicate():return except OSError:pass time.sleep(.1) raise AssertionError('fixture did not become ready') @contextmanager def lab(configuration,files=None,backend=None): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration) for path,content in (files or {}).items(): target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True) target.write_bytes(content if isinstance(content,bytes) else content.encode()) docker('network','create',name) if backend is not None: (root/'backend.py').write_text(backend) (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n') docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root)) docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1]) wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--network','none','-t',tags[0],str(root)) docker('run','--rm','--network',name,tags[0],'nginx','-t') docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort']) def ready(): connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True wait(ready) def request(path,method='GET',headers=None,body=None): connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5) try: connection.request(method,path,body=body,headers=headers or {}) response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read() finally:connection.close() request.port=port yield containers[0],request finally: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False) def config(locations,extra=''): return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n' import hashlib backend=r''' from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer from urllib.parse import urlsplit,parse_qs import json,socket,threading lock=threading.Lock();state={};gates={};chunk=b'x'*65536;total=32*1024*1024 class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def log_message(self,*args):pass def do_GET(self): parsed=urlsplit(self.path);path=parsed.path if path=='/state': with lock:data=json.dumps(state).encode() self.send_response(200);self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data);return if path=='/release': target=parse_qs(parsed.query)['path'][0] with lock:gates[target].set() self.send_response(204);self.end_headers();return gated=path.startswith('/early-');size=2*len(chunk) if gated else total with lock:state[path]={'written':0,'done':False};gates[path]=threading.Event() self.connection.setsockopt(socket.SOL_SOCKET,socket.SO_SNDBUF,65536) self.send_response(200);self.send_header('Content-Length',str(size));self.end_headers() for offset in range(0,size,len(chunk)): self.wfile.write(chunk) with lock:state[path]['written']=offset+len(chunk) if gated and offset==0:assert gates[path].wait(15) with lock:state[path]['done']=True ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' locations=''' location /early-on { proxy_buffering on; proxy_pass http://backend:8081; } location /early-off { proxy_buffering off; proxy_pass http://backend:8081; } location /large-on { proxy_buffering on; proxy_pass http://backend:8081; } location /large-off { proxy_buffering off; proxy_pass http://backend:8081; } location /large-nodisk { proxy_buffering on; proxy_max_temp_file_size 0; proxy_pass http://backend:8081; } location / { proxy_pass http://backend:8081; } ''' extra=''' proxy_buffer_size 4k; proxy_buffers 4 4k; proxy_busy_buffers_size 8k; proxy_temp_file_write_size 8k; proxy_max_temp_file_size 64m; proxy_temp_path /tmp/proxy; ''' with lab(config(locations,extra),backend=backend) as (proxy,request): results={} def state():return json.loads(request('/state')[2]) for mode in ('on','off'): path='/early-'+mode;connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=10) connection.request('GET',path);response=connection.getresponse();first=response.read(1024) snapshot=state()[path];assert response.status==200 and first==b'x'*1024 and not snapshot['done'] assert request('/release?path='+path)[0]==204 rest=response.read();connection.close();assert first+rest==b'x'*(2*65536) results[path]={'first_bytes_before_backend_completion':len(first),'backend_written_at_first_read':snapshot['written']} total=32*1024*1024;expected=hashlib.sha256(b'x'*total).hexdigest() for mode in ('on','off','nodisk'): path='/large-'+mode;sock=socket.socket();sock.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,65536) sock.settimeout(20);sock.connect(('127.0.0.1',request.port)) connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=20);connection.sock=sock connection.request('GET',path);response=connection.getresponse();assert response.status==200 wait(lambda:state().get(path,{}).get('written',0)>0) if mode=='on':wait(lambda:state()[path]['done']) else: previous=-1;stable_since=time.monotonic();deadline=time.monotonic()+10 while time.monotonic()<deadline: current=state()[path];assert not current['done'],current if current['written']!=previous:previous=current['written'];stable_since=time.monotonic() if time.monotonic()-stable_since>1:break time.sleep(.05) else:raise AssertionError('backend did not reach observable backpressure') before=state()[path] if mode!='on':assert 0<before['written']<total sock.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,1024*1024) digest=hashlib.sha256();received=0 while block:=response.read(1024*1024):digest.update(block);received+=len(block) connection.close();wait(lambda:state()[path]['done']) assert received==total and digest.hexdigest()==expected results[path]={'before_client_drain':before,'received_bytes':received,'sha256':digest.hexdigest()} log=docker('exec',proxy,'cat','/tmp/error.log').stdout spills=[line for line in log.splitlines() if 'buffered to a temporary file' in line] assert any('/large-on ' in line for line in spills),log assert not any('/large-off ' in line or '/large-nodisk ' in line for line in spills) results['temporary_file_used_for_large_on']=True print(json.dumps(results,sort_keys=True)) 首先,后端为一个 131,072 字节响应写出 65,536 字节,然后等待显式放行。无论响应缓冲开启还是关闭,客户端都能在后端尚未完成时读到 1,024 字节正文。脚本随后才放行后一半,并检查完整响应。这直接否定了“开启响应缓冲就一定等上游整份响应完成才交付”的说法;受控开关把响应尚未完成这一事实固定下来。 ...

十二月 7, 2027 · 3 分钟 · XB Field Notes
代理明确处理客户端提供的头部后,应用才能正确解释连接上下文。

反向代理需要明确的请求头信任边界

反向代理接受客户端连接,再单独请求上游应用。因此,应用套接字看到的对端是代理,较早客户端的信息则可能通过请求头传入。这些头部只有在部署明确谁可以设置、如何验证时才有可信含义。请求经过代理,不会自动让所有转发身份或协议声明变得可靠;需要把网络连接事实与请求携带的文本声明区分开。 坏掉的样子 客户端到达代理前就可以提供 X-Forwarded-For。普通转发可能保留它,$proxy_add_x_forwarded_for 也只是追加当前对端地址,不会移除不可信前缀。如果应用盲信第一个地址,就可能把请求归属到客户端自行选择的值。正确解释取决于真实可信代理链,不能因为字段名包含 Forwarded 就赋予整条列表相同信任。 Host 处理是独立决策。nginx 默认上游 Host 基于代理目标,不会自动采用客户端访问的公共 Host。因此,构造绝对跳转或选择虚拟主机的应用,在代理后可能表现不同。应只转发预期主机上下文,并应用服务自己的可接受主机名策略;复制头部不等于验证其中名字,也不能替代虚拟服务器入口限制。 代理也不保证后端细节完全不暴露。响应头、跳转、正文、诊断信息或其他公开配置,都可能披露源站信息。限制直接访问和控制特定响应字段很有价值,但这不同于声称一条 proxy_pass 就能隐藏全部上游身份,或提供所需的所有传输保护。地址隐藏、访问控制和加密需要分别依据真实部署判断。 快速验证 使用宿主 Python、Docker,以及固定 nginx 和 Python 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Python 3.11.16、Docker 29.3.1。它创建私有 Docker 网络与真实回显后端,只把代理发布到宿主回环,验证配置并清理自己的资源,不涉及生产端点或凭据。实验中的伪造值使用文档专用地址: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,json,socket,subprocess,time,uuid NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def wait(predicate): deadline=time.monotonic()+15 while time.monotonic()<deadline: try: if predicate():return except OSError:pass time.sleep(.1) raise AssertionError('fixture did not become ready') @contextmanager def lab(configuration,files=None,backend=None): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration) for path,content in (files or {}).items(): target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True);target.write_text(content) docker('network','create',name) if backend is not None: (root/'backend.py').write_text(backend) (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n') docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root)) docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1]) wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--network','none','-t',tags[0],str(root)) docker('run','--rm','--network',name,tags[0],'nginx','-t') docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort']) def ready(): connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True wait(ready) def request(path,method='GET',headers=None,body=None): connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5) try: connection.request(method,path,body=body,headers=headers or {}) response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read() finally:connection.close() yield containers[0],request finally: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False) def config(locations,extra=''): return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n' backend=r'''from http.server import BaseHTTPRequestHandler,HTTPServer import json class Handler(BaseHTTPRequestHandler): def do_GET(self): body=self.rfile.read(int(self.headers.get('Content-Length','0'))) document={'path':self.path,'method':self.command,'body':body.decode(),'headers':{key:self.headers.get(key) for key in ['Host','X-Forwarded-For','X-Real-IP','X-Forwarded-Proto','X-Forwarded-Host']}} data=json.dumps(document).encode() self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)));self.send_header('X-Backend-Info','fixture-node');self.end_headers();self.wfile.write(data) do_POST=do_GET def log_message(self,*args):pass HTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' locations=r''' location /default { proxy_pass http://backend:8081; } location /append { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://backend:8081; } location /edge { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_hide_header X-Backend-Info; proxy_pass http://backend:8081; } ''' with lab(config(locations,'server_tokens off;'),backend=backend) as (container,request): forged={'Host':'public.example','X-Forwarded-For':'198.51.100.77','X-Forwarded-Proto':'forged-scheme','X-Real-IP':'198.51.100.88'} status,headers,body=request('/default',headers=forged);plain=json.loads(body) assert status==200 and plain['headers']['Host']=='backend:8081' assert plain['headers']['X-Forwarded-For']=='198.51.100.77' and plain['headers']['X-Forwarded-Proto']=='forged-scheme' assert headers['x-backend-info']=='fixture-node' status,headers,body=request('/append',headers=forged);appended=json.loads(body)['headers']['X-Forwarded-For'].split(', ') assert status==200 and len(appended)==2 and appended[0]=='198.51.100.77' status,headers,body=request('/edge',headers=forged);edge=json.loads(body)['headers'] assert status==200 and edge['Host']==edge['X-Forwarded-Host']=='public.example' assert edge['X-Forwarded-For']==edge['X-Real-IP']==appended[-1] assert edge['X-Forwarded-For'] not in ['198.51.100.77','198.51.100.88'] and edge['X-Forwarded-Proto']=='http' assert 'x-backend-info' not in headers and headers['server']=='nginx' print('ordinary forwarding preserves forged forwarding headers and defaults Host to the upstream authority') print('append retains the untrusted prefix; the direct-client edge configuration replaces it with the actual socket peer and scheme') print('explicit response-header suppression removes one backend field, while server_tokens off still exposes the nginx product name') 每个请求都把 Host 设为 public.example,并携带特意伪造的转发值。默认路由向后端发送 Host: backend:8081,同时保留伪造的 X-Forwarded-For 和 X-Forwarded-Proto。这直接证明普通代理转发不是身份清洗步骤,也不会自动保留公共主机名。后端回显让这些差异可见,不需要仅凭配置推理。 ...

八月 27, 2027 · 2 分钟 · XB Field Notes
从磁盘返回的一份后端应答短命副本。

nginx 代理缓存权衡陈旧与新鲜

nginx 代理缓存需要分别作出几项决定:怎样识别已存响应,什么时候允许读取,什么时候允许保存新响应,以及过期副本还能怎样使用。如果把这些都看成一个 TTL 设置,故障就很难解释。验证时应同时观察请求实际经过的路径与交付字节,而不只是确认 nginx 接受了配置文件。 存储容量与新鲜度分开看 proxy_cache_path 声明磁盘存储位置及保存元数据的共享内存 keys_zone,proxy_cache 再为请求启用这个区域。元数据区大小不等于正文缓存容量。max_size 由缓存维护机制管理,不是瞬时硬磁盘配额;inactive 保留策略也不是响应的新鲜度寿命。已经过期的响应仍可能留在存储中,用于后续重新验证。 默认缓存键为 $scheme$proxy_host$request_uri,其中 request_uri 已经包含查询串,后面再加 $is_args$args 就重复拼接了参数。也不能随意替换成规范化后的 URI,或遗漏会改变表示的输入。默认启用 HEAD 转换,因此 GET 与 HEAD 共享相应缓存行为;关闭转换时,就需要在缓存键中考虑请求方法。 核对真正生效的响应策略 proxy_cache_valid 按状态码提供配置寿命,但正常处理的源站新鲜度头具有更高优先级。X-Accel-Expires 还有自己的优先顺序;它不存在时,Cache-Control 和 Expires 才参与相应设置。忽略这些头会改变实际契约。下面的实验里,源站 max-age 为一秒,覆盖了配置的六十秒;no-store 则阻止响应进入存储。 proxy_cache_revalidate 让过期条目使用 ETag、Last-Modified 等验证器发送条件请求。匹配的 304 可以保留正文并更新元数据,变化后的 200 则提供替代字节。但重新验证不等于异步刷新。后台更新是另一项指令,还需要允许在更新期间返回旧内容,不能仅凭某一个开关推断整套刷新方式。 ...

六月 24, 2027 · 3 分钟 · XB Field Notes
代理目标中的 URI 部分决定公共路径哪一部分会到达后端。

proxy_pass 既选择上游,也映射请求 URI

nginx proxy_pass 选择上游目标,也影响实际发送的请求 URI。关键区别是指令是否包含 URI 部分,而不只是文本末尾有没有斜杠。在普通前缀 location 中,字面目标 URI 会替换匹配到的规范化前缀;仅含地址的目标,在请求没有被重写时,可以传递原始请求 URI。理解这两个分支,才能准确预测后端看到的路径。 坏掉的样子 应用只处理 /users,代理却转发 /api/users,即使网络连接成功,也可能得到 404。相反,应用期待公共前缀,而代理把它移除,也会出现错误。只比较客户端状态和上游可达性,无法定位这类问题;需要检查后端真正收到的方法、路径、查询和主机名。成功建立连接并不等于协议内容符合应用契约。 斜杠边界属于字面替换的一部分。以 /v1 结尾的目标仍然包含 URI,所以在 /join/item 中替换 /join/ 后会得到 /v1item,不会因为目标没有末尾斜杠就保留原路径。按被替换前缀和剩余后缀推演,比“有没有末尾斜杠”这句通用口诀更准确,也能解释多段内部路径的映射。 变量会引入另一条文档规定的分支。变量提供的代理 URL 包含 URI 时,使用的是给定 URI,而不是普通字面前缀替换规则。正则和命名 location 也没有同样明确的前缀边界。在普通前缀中工作的配置,不能不检查契约就直接复制到这些上下文,否则语法或运行行为都可能发生变化。 快速验证 使用宿主 Python、Docker,以及固定 nginx 和 Python 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Python 3.11.16、Docker 29.3.1。它启动真正回显请求字段的 HTTP 后端,验证 nginx 配置,只把代理发布到宿主回环,结束时清理自己的镜像、容器和网络,不访问真实业务后端: ...

四月 2, 2027 · 2 分钟 · XB Field Notes
即使 nginx 最后只保留两个空闲连接,四个请求仍可以同时使用四条活动连接。

上游 keepalive 缓存空闲连接,不限制活动容量

nginx 上游 keepalive 缓存会保留符合条件的空闲后端连接,供后续请求复用。在本次配置中,keepalive 2 允许每个 worker 为该上游缓存保留两条空闲连接,并不把 worker 的活动后端连接限制为两条。复用可以减少重复建立连接的开销,却不会自动施加应用并发预算,也不能保证每个请求的延迟都下降。空闲保留与活动容量需要分别理解。 坏掉的样子 把空闲缓存大小误当成连接上限,会让后端同时承受的工作量超过预期。忙碌 worker 可以继续新建活动连接,而保留的空闲缓存仍很小。存在多个 worker 时,各自的空闲缓存也需要计入资源。将 nginx 配置与后端真实连接容量比较时,应分别统计活动与空闲资源,不能只拿一个 keepalive 数字作为全部连接预算。 复用要求协议交换本身可以继续使用。在这个 nginx 1.28.0 HTTP 示例中,上游 HTTP/1.1 和清除默认 Connection: close 都是显式配置。仅设置 Host 并不能建立持久连接。后端仍可能主动关闭连接,不完整或分帧错误的消息,也不能仅因为配置了缓存,就被当成干净可复用的交换。连接是否适合归还空闲缓存,需要实际通信满足条件。 客户端持久连接属于另一组连接关系。新的客户端连接可以使用之前缓存的上游连接;长时间存在的客户端也可能触发多条后端连接。假设两者生命周期总是一致,会产生误导性测量。建立连接的节省也取决于后端使用明文 HTTP 还是 TLS;本次 nginx 后面采用明文 HTTP,因此没有把 TLS 握手节省写成已测结果。 快速验证 将脚本保存为 check-upstream-keepalive.py,准备 Python、Docker 和固定镜像后执行。实验使用单个 nginx 1.28.0 worker,以及专用网络里的 Python HTTP/1.1 后端。后端为每条接收连接分配身份,每个响应都提供明确长度。客户端通过宿主回环进入 nginx,实验结束后清理自己的资源,不接入已有业务连接池: ...

三月 15, 2027 · 2 分钟 · XB Field Notes