通用 nginx 技术笔记标题卡,带有 Observe、Decide、Act 三个面板。

请求缓冲决定后端何时收到上传正文

一次上传经过两条连接:客户端到 nginx,以及 nginx 到应用。proxy_request_buffering on 会先读完客户端请求正文,再把该请求发送给上游。关闭它后,nginx 可以随着正文到达逐步转发。这个选择改变应用何时开始占用资源,却不会创造无限容量,也不会使慢发送方消失。应先明确应用是否需要边收边处理,再决定正文在哪一层等待。 坏掉的样子 常见错误是关错指令。proxy_buffering 控制响应,proxy_request_buffering 控制上传请求。仅关闭响应缓冲,流式上传仍可能停在代理处。反过来,如果应用原本应接收完整的小型提交,却在全站关闭请求缓冲,又没有考虑后端并发能力,慢客户端就可能长时间占住应用处理资源。两个方向的策略不能因为名称相似就混为一谈。 缓冲把资源压力转移到 nginx。请求正文超过配置的内存缓冲后,可以使用客户端正文临时存储。因此,大量并发上传会占用代理连接,并可能消耗磁盘空间或磁盘读写能力。增大某个缓冲值不能代替正文大小、上传超时、并发限制和临时文件系统容量规划。缓冲区也不需要一律扩大到能在内存中容纳完整上传。 流式转发还改变失败处理。nginx 一旦已经开始向上游发送请求正文,就不能简单把这个流式请求交给下一台服务器。应用可能已经处理前缀数据,甚至产生副作用。缓冲可以为某些重试路径保留正文,但不能让原本不安全的业务操作突然变得可安全重放。是否允许重试,还取决于实际方法、应用语义以及已经完成的工作。 快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。 将脚本保存为 check-upload.py,先取得其中固定摘要的镜像,再用宿主 Python 和 Docker 执行。记录环境为 Linux arm64 上的 nginx 1.28.0、Python 3.11.16 容器。脚本建立专用后端网络,只把 nginx 发布到宿主回环地址,最后清理自己的容器、镜像标签和网络;已有服务不参与实验: 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 import hashlib,json,threading state={};lock=threading.Lock() class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def log_message(self,*args):pass def reply(self,data): data=json.dumps(data).encode();self.send_response(200) self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data) def do_GET(self): with lock:self.reply(dict(state)) def do_POST(self): length=int(self.headers['Content-Length']) with lock:state[self.path]={'entered':True,'bytes':0} first=self.rfile.read(1) with lock:state[self.path]['bytes']=len(first) body=first+self.rfile.read(length-1) with lock:state[self.path]['bytes']=len(body) self.reply({'length':len(body),'sha256':hashlib.sha256(body).hexdigest()}) ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' locations=''' location /on { proxy_request_buffering on; proxy_pass http://backend:8081; } location /off { proxy_request_buffering off; proxy_pass http://backend:8081; } location /state { proxy_pass http://backend:8081; } ''' with lab(config(locations,'client_max_body_size 1m; proxy_http_version 1.1;'),backend=backend) as (_,request): payload=b'x'*65536;results={} def state():return json.loads(request('/state')[2]) for mode in ('on','off'): connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=10) connection.putrequest('POST','/'+mode);connection.putheader('Content-Length',str(len(payload))) connection.endheaders();connection.send(payload[:1024]) if mode=='off':wait(lambda:state().get('/off',{}).get('bytes')==1) else: until=time.monotonic()+1 while time.monotonic()<until: assert '/on' not in state();time.sleep(.05) before=state().get('/'+mode) connection.send(payload[1024:]);response=connection.getresponse();data=json.loads(response.read());connection.close() assert response.status==200 and data=={'length':65536,'sha256':hashlib.sha256(payload).hexdigest()} results[mode]={'before_completion':before,'result':data} connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=5) connection.putrequest('POST','/off-too-large');connection.putheader('Content-Length',str(2*1024*1024)) connection.endheaders();response=connection.getresponse();response.read();connection.close() assert response.status==413 and '/off-too-large' not in state() results['oversize_status']=response.status print(json.dumps(results,sort_keys=True)) 客户端声明正文共 65,536 字节,起初只发送 1,024 字节。开启请求缓冲时,在受控暂停期间反复读取后端状态,都看不到上传处理器被进入。客户端已经开始发送,但应用还没有被要求消费不完整正文。直接观察后端状态,比只比较整个请求耗时更能说明等待发生在哪一层,也避免把一次快速响应误当成流式处理证据。 ...

四月 13, 2027 · 2 分钟 · XB Field Notes
HTTP/3 与 QUIC 标题卡旁边连接着观察、决策与行动三个面板。

HTTP/3 分离流的交付顺序,不隔离全部恢复机制

HTTP/3 把 HTTP 放在 QUIC 上传输,不再依赖 TCP。QUIC 提供逐流有序交付:一条流缺少某段内容时,另一条流已经完整收到的数据不一定必须跟着等待。它解除的是特定的传输顺序依赖,并没有给每个请求配备独立的拥塞控制器、丢包检测器或确定的完成期限。先区分交付顺序和恢复机制,才能准确理解收益。 交付顺序不等于丢包检测 HTTP/2 的帧共享 TCP 的一个有序字节流。中间缺少一段时,后续字节即使属于另一个响应,也要等缺口补齐才能交给 HTTP。已经交付的内容不会被收回,应用也不会因为出现一次丢包就全部停止。究竟影响哪些请求,要看它们的字节相对缺口处于什么位置,不能笼统说整条连接上的所有工作都同时冻结。 QUIC 在每条流内维护偏移量。当 A 流仍缺少前面的字节时,B 流完整收到的数据可以交付。不过,一个 QUIC 包可以携带多条流的帧,因此丢掉一个包也可能同时影响多条流。需要恢复的信息会装进新包再次发送;包编号与流偏移量承担不同职责,并不是每个流各自重新传送一份独立的旧包。 RFC 9002 按包编号空间区分丢包检测,而不是按 HTTP 请求或流划分。往返时间估计与拥塞控制面向网络路径。逐流流控之外还存在连接级额度,因此共享容量不足或连接额度耗尽,仍会同时拖慢多个流。不能把独立排序扩写成独立带宽,也不能说某条流发生问题时邻居在任何条件下都不受影响。 HTTP 语义与部署边界仍然存在 方法、状态码和表示语义仍属于 HTTP。HTTP/3 改变消息分帧,并使用 QPACK 压缩字段。QUIC 集成 TLS 1.3 认证与包保护;调度和优先级策略仍要决定先发送哪些已经可用的流数据,并不会凭空增加带宽。如果某个字段区段依赖尚未到达的 QPACK 动态表项,它仍然可能等待。因此,去掉 TCP 的跨流顺序约束,并没有消灭协议内部或应用内部的全部依赖,共享数据库锁和服务端工作队列也不会随之消失。 丢包路径可能受益,但应测量真实工作负载,而不是保证所有网页必然加速。处理器成本、握手成功率、调度、可用带宽和客户端实现都会影响结果。比较时要核对同一被测链路实际协商的协议及正文完成情况;边缘节点使用 HTTP/3,不能证明它连接源站时也采用同样协议,更不能凭协议名称推算生产收益。 ...

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

读 EXPLAIN 估算时要带上执行上下文

EXPLAIN 描述 PostgreSQL 打算执行的计划,EXPLAIN ANALYZE 则真正执行它并补充测量结果。比较这两组数字之前,必须先理解每个数字究竟在数什么。表面上的估计偏差,既可能来自统计过时,也可能来自节点重复执行,或者父节点提前停止取行。行数吻合只能增强我们对模型某一部分的信任,不能证明这个计划已经是所有可能方案里最快的一个。 把节点放回执行上下文 估计的 rows 是节点输出行数,不一定是它实际检查的全部元组数。一个扫描可以检查几千条记录,过滤后只交出十条。启动成本与总成本使用规划器自己的单位,不是毫秒。父节点的成本已经包含子节点工作,因此把整棵树所有成本相加会重复计算。实际时间也通常包含子节点耗时,不能把它当成彼此独立的一组费用来求和。 对于重复执行的节点,实际行数和耗时按每次执行的平均值报告,执行次数由 loops 表示。一个实际输出两行、循环三次的节点,总共提供六行。过滤掉的行数同样按每轮平均值展示,四舍五入可能影响小数字。应当从底部扫描向上追踪,找到实际数据规模第一次明显偏离估计的位置,同时遵守这些执行边界,而不是挑出整棵树中最显眼的一对数字就下结论。 LIMIT、EXISTS 等上层操作还可能提前停止请求子节点。此时子节点的估计描述完整执行时可能产生的输出,实际值却只记录父节点真正取走的部分。这不自动意味着统计错误。排序也可能已经读完整个输入,却只向上交出所需的前几行;“输出少了”与“底层工作少了”不是一回事。 坏掉的样子 给写语句加上 EXPLAIN ANALYZE 会真的执行写入。外层事务回滚可以保护事务性的表变更,但不能撤销所有副作用,例如序列已经发出去的号码,或函数调用触发的外部行为。在执行测量前,应使用可丢弃的夹具,或者明确语句及其函数、触发器的完整副作用。不能因为输出长得像报告,就把它视为只读操作。 另一个常见误判是仅凭 BUFFERS 诊断磁盘。共享缓冲区命中表示没有发生 PostgreSQL 缓冲区未命中;报告中的读取仍可能由操作系统缓存满足,不能直接等同于设备读取次数,访问计数也不是不同数据块的数量。下面的临时表使用 local 缓冲区;计划中的 temp 块则指排序溢写等工作文件,不能因两者都叫“临时”就混为一谈。即使估计行数完全准确,仍可能存在锁等待、内存不足引发的临时文件、配置问题,或没有被选中的更合适访问路径。 快速验证 在 PostgreSQL 18 的 psql 中执行下面事务。它建立三个父记录、三十个子记录,分别观察重复的横向子查询和单独的前三名查询。数据全部放在临时表中,最后回滚清理,避免把演示对象留在业务表里。 \set ON_ERROR_STOP on BEGIN; CREATE TEMP TABLE parents (id integer PRIMARY KEY); CREATE TEMP TABLE children (parent_id integer,value integer); INSERT INTO parents VALUES (1),(2),(3); INSERT INTO children SELECT 1+(g-1)%3,g FROM generate_series(1,30) AS g; ANALYZE parents; ANALYZE children; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT p.id,c.value FROM parents p CROSS JOIN LATERAL ( SELECT value FROM children WHERE parent_id=p.id ORDER BY value DESC LIMIT 2 ) c; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT value FROM children ORDER BY value DESC LIMIT 3; SELECT p.id,c.value FROM parents p CROSS JOIN LATERAL ( SELECT value FROM children WHERE parent_id=p.id ORDER BY value DESC LIMIT 2 ) c ORDER BY p.id,c.value DESC; DO $$ BEGIN IF (SELECT array_agg(value ORDER BY parent_id,value DESC) FROM ( SELECT p.id AS parent_id,c.value FROM parents p CROSS JOIN LATERAL ( SELECT value FROM children WHERE parent_id=p.id ORDER BY value DESC LIMIT 2 ) c ) s) IS DISTINCT FROM ARRAY[28,25,29,26,30,27] THEN RAISE EXCEPTION 'wrong lateral result'; END IF; END $$; ROLLBACK; PostgreSQL 18.6 实测的横向查询采用嵌套循环。内层 Limit 显示实际行数为二、loops=3,因此整个连接输出六行。其下的扫描每轮返回十个符合条件的孩子,并过滤掉二十个,所以它在三轮里每轮都检查了三十条子记录。把这些数字放在一起读,才不会误以为整个查询只看过十行。 ...

三月 19, 2027 · 2 分钟 · XB Field Notes
通用 nginx 技术笔记标题卡,带有 Observe、Decide、Act 三个面板。

上游 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 握手节省写成已测结果。 快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。 ...

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

ANALYZE 更新规划器使用的分布估计

ANALYZE 更新的是 PostgreSQL 用来估算查询结果的统计模型。它不会重写查询、重建索引、回收死元组,也不承诺执行计划一定变快。它回答的具体问题是:规划器是否足够了解当前数据分布,能够合理比较不同访问路径?即使表里的有效行总数完全没变,一次批量状态更新也足以让原来的分布信息过时。先明确这个边界,才能判断刷新统计是否解决了眼前的问题。 统计描述的是什么 对于普通列,pg_stats 可以展示空值比例、常见值及其频率、不同值数量的估计和直方图等信息。规划器把它们与表的大小、成本参数一起使用。任务表中只有百分之一处于就绪状态,与百分之九十已经就绪,意味着完全不同的读取工作量。前者可能适合通过索引定位少量行,后者直接顺序读取整张表可能更合适。因此,“没有走索引”本身不等于优化失败,关键仍然是这个条件究竟会取出多少数据。 大表上的分析通常抽样,而不是逐行检查全部内容。更详细的统计需要更大的样本和更多工作,不能由“抽样”推导出“任何时候都很便宜”。即使业务数据没有变化,不同轮抽样产生的估计也可能略有波动。针对特定列提高统计目标,有助于更好地表达偏斜分布,但普通的单列统计不会因此自动学会所有列之间的关联。样本精度不足和模型缺少相关性信息,是两种不同的问题。 坏掉的样子 批量更新以后,旧的常见值频率可能严重低估过滤结果。规划器于是安排了实际需要重复很多次的嵌套循环,或者选择一个最终要读取大部分表内容的索引扫描。不过,查询本来就要返回大多数行时,顺序扫描是合理候选,不应该为了恢复过去的节点名称而强制索引。 还有一些变慢根本不是统计导致的。查询正在等待另一个事务释放锁时,重复分析不会把锁释放;统计刷新也不会消除应用连接池排队或凭空建立缺失的索引;所需顺序仍须通过排序或已有的有序路径提供。应当先找出计划中最早出现的有效行数偏差,再将它与真正耗时的环节对应起来,而不是把所有延迟都归结为统计陈旧。 快速验证 在可丢弃的 PostgreSQL 18 数据库中使用 psql 执行下面代码。实验把临时表放在事务里,最后回滚清理。这里特意使用临时表:自动清理进程不能分析其他会话的临时表,因此不会在两次受控观察之间悄悄刷新本实验的统计。 \set ON_ERROR_STOP on BEGIN; CREATE TEMP TABLE jobs (id integer,ready boolean,payload text); INSERT INTO jobs SELECT g,g<=100,repeat('x',100) FROM generate_series(1,10000) AS g; CREATE INDEX jobs_ready ON jobs(ready); ANALYZE jobs; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT id FROM jobs WHERE ready; UPDATE jobs SET ready=true WHERE id<=9000; SELECT most_common_vals,most_common_freqs FROM pg_stats WHERE schemaname=(SELECT nspname FROM pg_namespace WHERE oid=pg_my_temp_schema()) AND tablename='jobs' AND attname='ready'; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT id FROM jobs WHERE ready; ANALYZE jobs; SELECT most_common_vals,most_common_freqs FROM pg_stats WHERE schemaname=(SELECT nspname FROM pg_namespace WHERE oid=pg_my_temp_schema()) AND tablename='jobs' AND attname='ready'; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT id FROM jobs WHERE ready; DO $$ BEGIN IF (SELECT count(*) FROM jobs WHERE ready) <> 9000 THEN RAISE EXCEPTION 'data changed during analysis'; END IF; END $$; ROLLBACK; 在 PostgreSQL 18.6 的实测中,第一次查询通过 jobs_ready 估计并返回一百个就绪任务。接着的更新把就绪任务增加到九千个。再次分析之前,pg_stats 仍然给出 {f,t} 和 {0.99,0.01},说明统计模型没有学到这次分布变化。表中存活记录的总数仍然是一万,变化的是满足条件的比例。 ...

三月 12, 2027 · 1 分钟 · XB Field Notes
标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

连接池用少量 Postgres 连接应付大量请求

连接池让许多应用请求在不同时间借用较少的 PostgreSQL 会话。复用能够摊薄建立连接的成本,也能限制同时借出的数量,但不会让已经过载的数据库自动变快。池本身又增加了一条队列,它的容量、等待时间和取消行为,都属于请求预算的一部分。应把这条队列作为服务行为来设计,而不是把它隐藏在一个“复用更快”的标签后面。 坏掉的样子 数据库后端处于 ClientRead,表示正在等待客户端发数据,并不表示应用请求正在等待借用连接。真正的池内等待者可能还没有数据库连接,因此服务器根本没有对应的后端可供观察。获取连接的等待应在池里测量,查询执行和锁等待应在服务器测量;把它们混成一个所谓数据库池等待事件,会掩盖实际瓶颈的位置。 归还连接也不保证抹掉所有会话状态。Psycopg 的连接上下文在正常退出时提交,在异常离开上下文时回滚;额外重置取决于配置。已经提交的会话级设置,可以继续留给下一个借用者,即使没有泄漏未结束的事务。临时对象和其他会话资源同样需要明确策略,不能因为使用了连接池,就假定所有内容都会自动恢复到初始状态。 快速验证 准备 Python 3.10 或更新版本、PostgreSQL 18 可丢弃数据库,以及允许创建实验模式的角色。原演练使用以下依赖;本轮审阅没有重新执行并发数据库实验。安装方式为:python3 -m pip install "psycopg[binary]==3.3.5" "psycopg-pool==3.3.1"。使用已有认证配置,将 PG_DSN 设置为该测试数据库的连接串。把程序保存为 pool-demo.py,在安装依赖的同一个 Python 环境中运行: import os import time from concurrent.futures import ThreadPoolExecutor import psycopg from psycopg_pool import ConnectionPool, PoolTimeout, TooManyRequests dsn = os.environ['PG_DSN'] with psycopg.connect(dsn, autocommit=True) as admin: admin.execute('CREATE SCHEMA pool_demo') try: admin.execute('CREATE TABLE pool_demo.counter (n integer)') admin.execute('INSERT INTO pool_demo.counter VALUES (0)') with ConnectionPool( dsn, min_size=1, max_size=1, max_waiting=1, timeout=3, open=True, kwargs={'application_name': 'pool_example'}, ) as pool: pool.wait(timeout=3) def queued_request(): try: with pool.connection(timeout=1): raise AssertionError('holder should still own the only connection') except PoolTimeout: return 'PoolTimeout' with ThreadPoolExecutor(max_workers=1) as workers: with pool.connection() as held: original_pid = held.info.backend_pid waiting = workers.submit(queued_request) deadline = time.monotonic() + 0.5 while pool.get_stats()['requests_waiting'] != 1: if time.monotonic() >= deadline: raise TimeoutError('request did not enter the pool queue') time.sleep(0.005) state = admin.execute( 'SELECT state,wait_event_type,wait_event ' 'FROM pg_stat_activity WHERE pid=%s', (original_pid,) ).fetchone() assert state == ('idle', 'Client', 'ClientRead'), state print('waiting_in_pool', 1, 'server', state) try: with pool.connection(timeout=0.1): raise AssertionError('queue limit was not applied') except TooManyRequests: print('queue_limit', 'TooManyRequests') assert waiting.result(timeout=2) == 'PoolTimeout' print('queued_request', 'PoolTimeout') with pool.connection() as conn: assert conn.info.backend_pid == original_pid conn.execute("SET application_name='previous_borrower'") try: with pool.connection() as conn: conn.execute('UPDATE pool_demo.counter SET n=99') raise ValueError('simulated request failure') except ValueError as error: assert str(error) == 'simulated request failure' with pool.connection() as conn: value = conn.execute('SELECT n FROM pool_demo.counter').fetchone()[0] setting = conn.execute('SHOW application_name').fetchone()[0] assert value == 0 and setting == 'previous_borrower' assert conn.info.backend_pid == original_pid print('same_backend', True, 'rollback_value', value, 'retained_setting', setting) conn.execute('RESET application_name') finally: admin.execute('DROP SCHEMA pool_demo CASCADE') 池里只有一条连接,最多允许一个请求排队。主线程借走连接但不执行查询,工作线程尝试借用它。池报告一个等待者以后,独立观察连接看到被借走的数据库后端正空闲等待客户端读取事件。两种状态可以同时存在:应用已经排队,数据库会话却没有正在执行的查询。这正好说明为什么不能用服务器的空闲状态代替池的等待指标。 ...

二月 13, 2027 · 2 分钟 · XB Field Notes
标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

按所需操作选择 B-tree 或哈希索引

PostgreSQL 默认的 B-tree 索引支持多种常见需求,包括等值、范围、有序访问和唯一性约束;哈希索引提供的则是更窄的等值访问路径。两者的选择应先从需要支持的运算符与数据约束开始,再进入性能测量。说哈希“只要是等值就一定更快”,或者规划器“几乎从来不会选它”,都是用未经负载验证的概括代替具体问题。先确认能力,再比较成本,顺序不能颠倒。 先检查能力,再比较耗时 B-tree 按运算符类和排序规则保留键的顺序,因此既能定位单个等值键,也可能支持有界范围与 ORDER BY,省掉额外排序。它还可以为 IS NULL 和 IS NOT NULL 提供索引访问。不过,存在索引不等于强制使用索引;查询需要读取很大一部分表时,规划器仍可能选择其他路径。这并不改变索引在语义上支持哪些操作。 文本模式另有条件。像 LIKE 'abc%' 这样由常量构成、锚定开头的前缀,可以利用合适的 B-tree 路径,但排序规则和运算符类会影响适用性。非 C 区域设置下,可能需要专门支持模式匹配的运算符类。开头带通配符的匹配,也不会因为创建了普通 B-tree 就自动变成高选择性的有序范围。不能把“支持部分前缀匹配”写成“所有 LIKE 都能一趟索引解决”。 PostgreSQL 哈希索引保存四字节哈希码,而不是完整列值。它支持等值,不保留键顺序,只支持单个索引列,也不能检查唯一性。对于较大值,较小的索引条目可能有利于某些等值负载,但读取堆记录进行复核、访问桶和溢出页仍有成本。数据结构的名字本身不能证明某次真实查询谁会胜出。 坏掉的样子 把 B-tree 换成哈希索引,可能失去原有的范围或有序访问路径,也会失去 IS NULL 的索引访问能力:PostgreSQL 哈希索引不支持这个条件。更严重的是,把承担唯一性要求的索引替换成哈希索引,因为后者根本无法提供对应约束。即使点查偶尔更快,也不能用性能收益替代数据完整性契约。 空值语义需要单独核对。默认唯一 B-tree 允许多个空值,因为唯一性检查把它们视为彼此不同。如果应用要求最多只能出现一个空值,应在唯一约束或索引上明确使用 NULLS NOT DISTINCT,而不能从“唯一”两个字直接推导。索引支持怎样访问,与它强制哪些数据规则,是关联但不同的两项决定。验证时必须分别看查询结果和约束行为。 ...

二月 7, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三步示意图,旁边是复用连接、限制借用的英文标题。

连接池摊薄缓慢的建连

连接池把一次物理建连的成本分摊到多次使用中,但不会让建连成本消失,也不保证创建池时就已经打开连接。先分清两个对象:业务代码短期持有的借用句柄,以及底层真正连接数据库的连接。关闭前者,通常只是把后者归还池中,等待下一次复用,而不是立即断开数据库连接。 复用是一段生命周期,不是免费执行的承诺 远程数据库建连可能包含 TCP 握手、TLS、认证和会话初始化。复用能够减少每个请求重复执行这些步骤的次数,但 SQL 本身仍然需要时间。连接过期、断线、显式失效或进程替换之后,建连成本还会重新出现。一个进程内应长期复用其拥有的 SQLAlchemy Engine;如果每次请求都重新创建 Engine,那么每次也在重新创建自己的池,原本可以复用的资源就被拆散了。 SQLAlchemy 的池按需增长。配置 pool_size=2 的 QueuePool 起初是空的,并不意味着已经预热两条连接。先后执行的两次借用可以一直使用同一条物理连接;同时发生的借用才可能促使池增长。本例允许保留两条连接,再临时溢出一条,所以有限的 max_overflow=1 对应最多三条同时借用。全部归还后,最多留下两条空闲连接,而不是一直维持三条。 这些参数语义属于具体实现,不应套用到所有连接池。QueuePool 的 pool_size=0 表示取消大小限制,而不是关闭池化;max_overflow=-1 则允许无限溢出。需要限制数据库连接预算时,必须核对所用库的准确契约,并写出明确、有限的值,不能看到零就自行理解为禁用。 坏掉的样子 池被占满,可能是实际并发超过容量,也可能是长事务或借用后未归还。业务看到的总延迟因此需要拆成获取连接的等待与执行查询的时间。pool_timeout 限制的是等待可用槽位,不是新建物理连接的完整耗时,也不是 SQL 执行时间。实验用了很短的浮点数超时,便于触发失败,但这不是精确到几十毫秒的硬实时承诺,不能据此推导整个请求的截止时间。 归还也不等于抹掉上一个借用者留下的所有状态。SQLAlchemy 的默认归还重置通过回滚清理事务状态,并与 Connection.close() 协同完成;已经提交的临时表等会话状态仍可能保留。不要仅凭“用了上下文管理器,连接已经还回去”就推断租户隔离成立。应用需要哪些会话初始化与清理,应按具体数据库的能力和自身契约明确规定。 断线则是另一个边界。显式 invalidate 会丢弃当前底层 DBAPI 连接,之后使用逻辑 Connection 时,再从池取得替代连接。这个替代品可能是另一条现成空闲连接,不一定刚刚新建。如果失效时已有事务,必须先结束该事务才能重连;重连也不是自动恢复一个可能已经产生效果的事务,不能把连接生命周期管理当成业务重试保证。 快速验证 在 macOS 或 Linux 的空目录执行 python3 -m venv .venv,再执行 .venv/bin/python -m pip install SQLAlchemy==2.0.52。把代码保存为 pool_probe.py,用 .venv/bin/python pool_probe.py 运行。本次验证环境是 Python 3.14.4 与 SQLite 3.53.0。脚本自行创建并清理临时数据库,不连接任何数据库服务器,也不使用生产凭据。 ...

一月 28, 2027 · 2 分钟 · XB Field Notes