有界令牌额度按经过时间回填,只允许成本能够被当前额度支付的工作。

令牌桶平滑请求速率

令牌桶限制获准进入的工作量,同时允许积累的额度在短时间内集中使用。它不会自动把请求均匀隔开,不保证成功完成的吞吐,也不限制仍在执行的操作数量;这些分别属于调度和容量问题。桶的两个参数描述的是预算,不是服务一定能够履行的处理能力承诺。 先补充额度,再扣除工作成本 设桶容量 B 为最多能保存的令牌数,回填速率 r 为每秒新增令牌数。每次请求到来,先将额度更新为 min(B, 上次额度 + r × 经过时间)。只有工作成本不超过当前额度才准入,然后扣除成本。本文采用的严格规则下,被拒绝的工作不消耗令牌。 这种计算可以在请求到达时按需进行,不需要后台定时器不断实际投放令牌。长时间空闲也不能积累无限额度,因为容量会截断回填结果。初始额度是一项策略选择:本例从满桶开始,因此允许立即突发;从空桶开始则会得到不同的初始行为,不能漏写这个条件。 突发额度不是峰值速率 对于这个理想严格模型,在长度为 t 的时间区间内,获准工作的总成本不超过 B + r × t。时间越长,突发项的相对影响越小。这不意味着每个一秒窗口都最多允许 r 个请求,也没有额外给出一个独立的瞬时峰值速率上限。 例如容量为三、每秒回填二时,三个单位成本请求可以在同一时刻全部通过,第四个被拒绝;半秒之后又有一枚令牌可用。如果已获准的工作必须均匀离开入口,还需要实际调度器、有界等待队列或其他节奏控制,不能指望令牌桶自动将同时到达的请求排成匀速流。 准入速率也不等于执行并发量。慢请求尚未结束时,令牌仍会继续回填,允许更多工作进入。需要通过合适的并发限制和超时策略保护工作线程、数据库等稀缺资源。同样,已经准入的请求仍可能在下游失败,所以回填速率不能被写成保证成功完成的吞吐。 预算单位和作用范围要一致 一枚令牌不一定对应一个请求,也可以用加权成本代表字节或预计工作量,但容量与回填速率必须使用相同单位。一个成本超过 B 的请求,无论等待多久都装不进这个严格模型的桶。应明确处理这种情况,而不是给出一个实际上永远不能兑现的重试时间。 还要明确预算按谁区分、由谁持有。四个互相独立的满桶,每个保存三枚令牌,可以同时允许十二个请求;它们不是一个全局三令牌桶。共享限流器必须把回填、比较和扣减协调成一次原子决定,仅共享一个计数器而分开执行这些步骤,并不足以保证配额正确。 本地回填应依据经过时间的时钟,不能让墙上时钟校准凭空增加额度,或使记账时间倒退。Python 的单调时钟适合比较两次读取的差值,但其参考原点不是可以在无关机器之间交换的通用时间戳。分布式时钟和状态归属仍需要自己的明确契约。 坏掉的样子 即使长期速率看起来保守,满桶释放的一次突发仍可能压垮脆弱的下游。无界等待队列会把超额需求转化为内存增长与延迟;直接拒绝和延迟整形是两种不同结果,接口应说明调用者会得到哪一种,不能把所有行为都笼统叫作“平滑”。 应一起观察准入成本、拒绝成本、排队、并发量和下游延迟。重试需要遵守限流策略,而不是立即重新制造被拒绝的负载。按尝试工作计费还是按完成工作计费,也是需要明确的选择;失败后退还令牌会改变模型,并引入额外记账要求,不能悄悄当成同一套公式。 托管产品的限制还可能弱于理想算法。Amazon API Gateway 明确说明限流和配额是尽力而为的目标,可能被超过。因此,不能把这里的数学上界宣传成该服务已经验证的硬保证、费用上限,或者任何未经测试部署的严格承诺。 快速验证 保存为 bucket_check.py,用 Python 3.10 以上版本执行 python3 bucket_check.py。本例已在 Python 3.14.4 上运行。标准库 Fraction 与注入的非递减时间戳让边界计算保持精确,不依赖真实等待。这是单线程准入模型,不是网络限流服务或分布式存储实现。 ...

十二月 1, 2027 · 2 分钟 · XB Field Notes
被保持的连接会把新工作导向另一端,权重则改变节点之间的比较。

最少连接衡量连接占用,不代表所有负载

最少连接均衡根据活动连接数选择节点,并遵循具体实现的权重和平局规则。在 nginx 中,least_conn 考虑服务器权重,对于同样合适的节点使用加权轮询。这有助于处理占用连接时间不同的请求,但仍然只是连接维度的测量,不直接知道 CPU、内存压力、排队任务或数据库竞争。算法是否合适,取决于该信号与实际瓶颈有多大关联。 坏掉的样子 把选中的节点称为“最空的服务器”,夸大了信号含义。一条连接可能承载昂贵操作,另一条只是便宜的等待。HTTP 响应关闭后,后台工作还可能继续;多路复用协议也可能把多个操作放在一条连接上。因此,连接少并不能证明后端有足够余量接收下一个昂贵请求。需要保护哪种资源,必须先明确,而不能让算法名称替代容量分析。 计数还是均衡器观察到的上游状态,不是对后端全部客户端的全知统计。通过另一个代理或内部直连到来的工作,可能不在当前视野中。在 nginx 中,worker 范围和上游共享区域配置影响哪些运行状态共享;独立均衡器也不会自动合并观察,形成一个全局最少连接决定。忽略计数范围,会让局部正确的选择被误解成整个集群的最优分配。 权重使只比较原始计数不够准确。权重二的节点可以合理承载比权重一节点更多的活动连接。修改权重表达一种容量假设,并没有测量该假设是否真实。空闲 keepalive 缓存又是另一种数量,不应与这里参与选择的活动连接混淆。应分别识别正在占用的交换、为复用保留的空闲连接,以及真正消耗计算资源的工作。 快速验证 将脚本保存为 check-least-connections.py,准备 Python、Docker 和固定镜像后执行。记录环境使用单个 nginx 1.28.0 worker,没有配置上游 keepalive 缓存,并连接不同端口上的两个 HTTP 节点。后端提供受控响应开关与活动计数,让保持中的工作可以直接观察,不依赖猜测某个睡眠时间是否足够长: 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,extra_ports=()): 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') published=[arg for value in (8080,*extra_ports) for arg in ('-p','127.0.0.1::'+str(value))] docker('run','-d','--name',containers[0],'--network',name,*published,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;request.network=name request.ports={int(key.split('/')[0]):int(value[0]['HostPort']) for key,value in details['NetworkSettings']['Ports'].items() if value} 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,ThreadingHTTPServer from urllib.parse import urlsplit,parse_qs import itertools,json,threading lock=threading.Lock();ids=itertools.count(1);counts={'A':0,'B':0};active={'A':0,'B':0};faults={'A':200,'B':200};gates={} class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def setup(self):super().setup();self.ident=next(ids) def log_message(self,*args):pass def do_GET(self): parsed=urlsplit(self.path);label=self.server.label;code=200 if label=='control': query=parse_qs(parsed.query) with lock: if parsed.path=='/fail':faults[query['peer'][0]]=int(query['status'][0]) if parsed.path=='/release':gates[query['path'][0]].set() data={'counts':dict(counts),'active':dict(active),'faults':dict(faults),'held':list(gates)} else: with lock: counts[label]+=1;active[label]+=1;code=faults[label] if parsed.path.startswith('/hold/'):gates[parsed.path]=threading.Event() if parsed.path.startswith('/hold/'):assert gates[parsed.path].wait(20) data={'peer':label,'path':self.path,'connection':self.ident} body=json.dumps(data,sort_keys=True).encode();self.send_response(code) self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(body))) self.end_headers();self.wfile.write(body) if label!='control': with lock:active[label]-=1 for port,label in [(8081,'A'),(8082,'B'),(8083,'control')]: server=ThreadingHTTPServer(('0.0.0.0',port),Handler);server.label=label threading.Thread(target=server.serve_forever,daemon=True).start() threading.Event().wait() ''' from concurrent.futures import ThreadPoolExecutor extra=''' upstream least { least_conn; server backend:8081; server backend:8082; } upstream weighted_least { least_conn; server backend:8081 weight=2; server backend:8082; } ''' locations=''' location /least/ { proxy_pass http://least/; } location /weighted/ { proxy_pass http://weighted_least/; } location /control/ { proxy_pass http://backend:8083/; } ''' with lab(config(locations,extra),backend=backend) as (_,request): def get(path): status,_,body=request(path);assert status==200;return json.loads(body) def state():return get('/control/state') results={} with ThreadPoolExecutor(max_workers=3) as pool: held=pool.submit(get,'/least/hold/first');wait(lambda:sum(state()['active'].values())==1) occupied=next(peer for peer,value in state()['active'].items() if value==1) quick=[get('/least/quick')['peer'] for _ in range(6)] assert all(peer!=occupied for peer in quick),quick get('/control/release?path=/hold/first');assert held.result()['peer']==occupied wait(lambda:sum(state()['active'].values())==0) results['held_peer']=occupied;results['quick_while_held']=quick futures=[] for index in range(3): futures.append(pool.submit(get,'/weighted/hold/w'+str(index))) wait(lambda:sum(state()['active'].values())==index+1) snapshot=state()['active'];assert snapshot=={'A':2,'B':1},snapshot for index in range(3):get('/control/release?path=/hold/w'+str(index)) results['weighted_active']=snapshot;results['weighted_completed']=[f.result()['peer'] for f in futures] wait(lambda:sum(state()['active'].values())==0) print(json.dumps(results,sort_keys=True)) 第一个请求进入同权最少连接组,并在某个后端处理器中等待。控制端先观察恰好一个活动请求,并识别被占用节点,然后才发送其他请求。初始平局究竟由哪台节点胜出不是主要断言;实验从后端真实状态发现该身份,不把某个偶然的初始顺序写成算法必须遵守的规则。 ...

十一月 11, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三步示意图,旁边是在失败路径上也归还每份借用的英文标题。

泄漏的连接会把连接池榨干

连接池泄漏首先是所有权问题:业务工作已经结束,但它借走的连接仍然处于借出状态。此时数据库可能仍然正常响应其他客户端,只有这个应用拿不到连接。应把归还资源视为操作契约的一部分,覆盖异常和提前返回,而不是只在成功路径末尾顺手清理一下。 一份借用应当有明确的主人和终点 SQLAlchemy Connection 表示从 Engine 所属池中取得的一次借用。关闭这个 Connection,通常是把底层 DBAPI 连接还回池里,而不一定真正断开数据库连接。归还借用与提交事务是两个独立决定:池可以运转正常,但业务修改已经回滚;池也可能耗尽,而底层数据库连接全部仍然存活。只检查连接是否活着,无法回答谁还占着它。 资源作用域应尽量短,但不能破坏事务要求的原子性。with engine.begin() 在代码块成功时提交,失败时回滚,并释放连接;with engine.connect() 管理的是连接作用域,不承诺自动提交写入,关闭时尚未结束的事务会被回滚。两者看起来都用了 with,但持久化语义不同,不能为了“统一写法”随意互换,否则资源不再泄漏了,数据却可能没有保存。 与当前事务无关的远程等待、用户交互,不应长时间占着事务和连接。这并不意味着可以把必须一起提交的变更拆开。若连接由调用方拥有,辅助函数不应私自关闭;若辅助函数自己借用,就应负责归还,或者在接口中明确把所有权交给谁。只有打开次数的指标,却说不清每份借用由谁结束,仍然没有定位真正的生命周期边界。 坏掉的样子 漏掉一次归还,就减少一份可用容量,后来者开始排队或超时。对于配置了有限容量和获取超时的 QueuePool,新调用并非一定永远挂住,而是可能在执行 SQL 之前就收到池超时。把上限调大可以暂时推迟复发,却也增加数据库压力;缺失的归还路径依然存在,不能算修复。 不过,“池满了”不是泄漏的充分证据。真实并发、长事务、数据库锁等待或应用自身死锁,都可能占住全部借用。数据库 CPU 很低也不决定原因,因为应用可能握着连接等待别的事情。需要把借出数量、获取等待、持有年龄、事务状态和仍在进行的业务工作关联起来,判断这些连接是否还处于合理的使用范围。 一个有说服力的泄漏复现,应证明已经结束的工作仍然保留借用,并且归还这些具体借用后容量恢复。不要依赖垃圾回收及时释放;对象何时被回收不是可靠的资源归还契约。Engine.dispose() 也不能强制收回仍然借出的连接,它处理的是池内已经空闲的连接。重启进程可能暂时清空症状,但不会改变下一次请求重新走进同一遗漏分支的事实。 快速验证 在 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,数据库文件只存在于脚本自己的临时目录,结束后自动清理,不会访问生产数据库。 ...

十一月 5, 2027 · 2 分钟 · XB Field Notes
64 MiB 容器内存边界终止分配,另一个测试 Pod 在最后观察时仍然就绪。

资源限制约束运行,请求参与放置

Kubernetes 的资源 request 与 limit 属于不同决策。请求参与调度和资源共享,限制则配置运行时及操作系统支持的执行约束。把 limit 当成调度器预留的容量,会同时混淆这两个机制。有效的资源策略需要说明放置需求、可以接受的争用,以及运行中的工作负载到达边界后应该发生什么,不能只在清单上填写一组看起来合理的数字。 CPU 与内存具有不同失败方式 在典型 Linux 环境中,CPU 限制会形成带宽约束,繁忙控制组可能被节流,而进程仍然存活。内存限制依赖内存记账和回收;受约束的分配无法满足时,可能触发控制组 OOM 处理。内核不会像推迟 CPU 使用那样,把所有超限内存申请都温和地放慢。两种资源因此需要不同的容量指标和故障判断方式。 内存执行机制具有反应性,不能描述成恰好达到某个字节就调用应用回调,也不能保证使用量绝对不会短暂超过配置值。应用可能收到分配失败,OOM 处理也可能终止进程。应先观察真实终止原因、容器状态和可用的内存事件,再给故障命名,避免仅因为配置了内存限制,就把所有异常退出都解释成越界。 限制覆盖的不只是应用打算保存的业务数据。运行时开销、其他进程,以及被记账的缓存或其他内存,都可能占用控制组额度。容器限制为六十四 MiB,并不意味着它能在已有开销之外,再安全创建同样大小的数据结构。内存支持的卷也可能贡献内存消耗,表现为文件存储并不会让这部分成本消失。 坏掉的样子 退出码 137 对应常见的 SIGKILL 终止表示,本身不足以证明 OOM。应与容器的 OOMKilled 原因以及相关运行时或内核证据结合。节点压力驱逐又是另一种机制,需要检查 Pod 级状态和驱逐决策,不能把一个容器超过自身控制组预算,与 kubelet 为缓解节点压力而移除 Pod 混成同一个过程。 终止后发生什么,由重启策略继续决定。采用 Always 的泄漏程序可能不断重启,而根因始终存在。采用 Never 时,已终止容器保持终止,Pod 可以进入失败状态。限制只约束一种资源维度,既不会修复应用,也不保证邻居在失败发生前完全没有经历争用。是否影响业务延迟,还需要对应观测,不能由限制字段直接推断。 ...

六月 14, 2027 · 3 分钟 · XB Field Notes
需求预测与损失一个可用区后的可用容量进行比较。

容量规划是一个数字,不是猜测

容量规划把需求预测转化成一个有依据的运行范围。真正有用的问题,不是机器在崩溃之前还能接收多少请求,而是服务能以满足延迟和正确性要求的方式持续处理多少工作。答案受到请求成本、共享依赖和故障假设影响。只有把这些假设的来源、适用范围与负责人写清楚,表格里的实例数量才能支持采购与可靠性决策,而不只是把直觉包装成一个数字。 测出能够接受的运行点 压测应使用具有代表性的请求组合、数据规模、缓存状态、并发方式和软件配置。在增加压力时,同时记录延迟、错误、处理器、内存、队列、存储与连接使用情况。满足要求的最高已测试点,是针对该工作负载的证据,并不证明所有中间点或下一版本都表现相同。只有吞吐量而没有结果质量的测试,甚至可能把快速拒绝请求的服务器评成性能优秀。 不同瓶颈需要分别统计。增加应用副本可能带来更多计算资源,却没有扩大共享数据库的连接额度。请求变贵时,每秒请求数与资源消耗的关系也会改变。因此,按请求速率规划只在成本分布可比时才有意义,还需要监测底层资源,并在工作负载变化后修订模型。不能拿安静时段的平均负载,直接推导高峰时的服务承诺,也不能假设所有资源会同时到达上限。 预测需求,并主动减掉会失去的容量 从实际峰值出发,明确准备应对的是增长、季节波动还是某次活动,再把采购或扩容提前期和预测误差写入计划。运行余量应该对应具体原因,例如负载不均、需求低估或新增实例需要时间,而不是沿用旧表格里来源不明的百分比。出现有意义的版本、数据量或流量结构变化后,应同时复查单机测量和需求假设,避免只更新其中一个数字。 接着按可靠性设计减去可能失效的容量。服务分布在三个可用区,如果要求失去一个区后仍能承接同样峰值,所需实例数就不同于只考虑健康集群的方案。还要核对共享依赖是否幸存、流量如何迁移,以及新增实例能否在队列失控前就绪。均匀部署不等于请求必然均匀分配,自动扩缩容也无法瞬间提供资源,这些都应进入故障演练与容量边界。 坏掉的样子 常见错误是用预测流量除以单机最大吞吐量,向上取整后便结束计算。这样的结果可能通过健康状态检查,却在损失一个可用区后立刻超载。另一个问题是重复计算预留容量:必须区分压测已经验证的速率,与规划时主动采用的更低运行速率。汇报剩余容量时也应说明相对于哪个边界,避免把规划额度内的余量误称为硬件物理吞吐上限。 线性扩展可能在共享锁、数据库、网络链路或外部配额处失效。冷缓存和同步重试还可能使故障切换后的请求比原压测更昂贵。应在真实约束下验证失效场景,并记录模型何时不再成立。如果现有资源无法安全承接预测需求,可能需要经过验证的准入控制或降级方式,等待资源到位;在表格里加入乐观假设,不能改变实际排队和超时行为。 快速验证 保存为 capacity.py 后,用 Python 3 执行。输入是固定工作负载下的人造压测记录,实验用于核对实例计算与故障假设,不代表当前机器的真实性能。示例把可接受测试点的百分之七十作为规划速率,这是为本例明确选择的余量规则,不是适用于所有系统的利用率建议,实际团队应解释自己的预留原因。 from math import ceil # Synthetic load-test results for one fixed request mix and machine size. samples = [ {"rps": 150, "p99_seconds": 0.11, "error_ratio": 0.0001}, {"rps": 200, "p99_seconds": 0.22, "error_ratio": 0.0003}, {"rps": 250, "p99_seconds": 0.50, "error_ratio": 0.0020}, ] acceptable = [s["rps"] for s in samples if s["p99_seconds"] <= 0.30 and s["error_ratio"] <= 0.001] tested_rps = max(acceptable) forecast_rps = ceil(1600 * 1.25) zones = 3 surviving_zones = zones - 1 planning_rps = tested_rps * 0.70 naive_per_zone = ceil(ceil(forecast_rps / tested_rps) / zones) planned_per_zone = ceil(forecast_rps / (planning_rps * surviving_zones)) naive_survivor_rps = naive_per_zone * surviving_zones * tested_rps planned_survivor_rps = planned_per_zone * surviving_zones * planning_rps assert tested_rps == 200 and forecast_rps == 2000 assert naive_per_zone * zones == 12 and naive_survivor_rps == 1600 assert planned_per_zone * zones == 24 and planned_survivor_rps == 2240 assert naive_survivor_rps < forecast_rps <= planned_survivor_rps print(f"tested={tested_rps} rps/node; forecast={forecast_rps} rps") print(f"naive={naive_per_zone * zones} nodes; zone-loss capacity={naive_survivor_rps} rps") print(f"planned={planned_per_zone * zones} nodes; zone-loss planning capacity={planned_survivor_rps:.0f} rps") print(f"zone-loss spare={planned_survivor_rps - forecast_rps:.0f} rps") 已列出的记录中,每节点每秒二百次请求满足要求,二百五十次的记录则未达到延迟和错误标准。需求经过增长假设后变为每秒二千次。只按测试速率计算并均匀分配的方案共有十二个节点,失去一个区后只剩八个,即使每个节点都运行在已接受的测试速率,也只能承接每秒一千六百次请求,低于预测需求。 ...

二月 21, 2027 · 1 分钟 · XB Field Notes
观察、决策、行动三步示意图,旁边是为所有副本的每个连接池编制预算的英文标题。

连接上限守护共享数据库

连接上限约束的是某个资源所有者的需求,不会自动约束共享数据库的所有应用。真正有用的预算,是每台后端可能接收到的物理连接总和,其中也包括连接池里暂时空闲的连接。先确认后端可供普通业务使用的名额,再分配到所有调用方与可能重叠的部署状态,而不是给每个服务随手填一个相同数字。 先数清有多少池,再决定每个池多大 对有限配置的 SQLAlchemy QueuePool,同时连接数上限是 pool_size + max_overflow,但这个数字只属于一个池。各自创建独立 QueuePool 的 Engine 才有独立上限;多个工作进程、多个副本,还会继续放大总数。应按真实池计数,派生的 Engine 视图可能仍然共用原池。如果配置相同,可用副本数乘每副本进程数,再乘每进程池数和单池上限;不同调用方的配置不同,则应逐项相加,不能假设它们共享同一份限额。 部署预算应使用最大重叠数量,而不是滚动更新结束后的就绪副本数。新副本已经建连时,旧副本可能仍在排空业务,也可能还保留空闲连接。后台任务、迁移工具、监控连接和其他服务也必须进入清单。不同数据库服务器需要各自计算预算;把主库、其他目标的连接数合成一个看起来宽裕的总量,会掩盖真正受限的后端。 PostgreSQL 的 max_connections 是服务器总上限,并不全部属于某个应用。没有预留名额权限的普通角色,应当为 superuser_reserved_connections 和 reserved_connections 都留出空间;后者提供给具有 pg_use_reserved_connections 权限的角色。还要扣除其他普通客户端和额外运维余量,不应让日常流量依赖紧急预留容量才能运行。这里区分的是权限与预算,不能仅凭连接名字叫“运维”就认定它可以使用某种预留名额。 坏掉的样子 服务完全遵守每个本地池的上限,仍然可能把数据库用满,因为实际池数量超过了预算表里的假设。若允许无限溢出,有限预算更是直接失去依据。反过来,上限过小也可能让应用在后端仍有余量时排队;看到这种等待,并不能直接断定必须增大数据库服务器的总上限。 更多连接不会创造额外 CPU、加速存储,也不会消除事务之间的锁冲突。PostgreSQL 的部分资源分配还直接取决于配置的连接上限,调高它也有资源代价。应同时观察获取连接的等待、实际物理连接数、活跃工作量和事务时长。连接数量受到约束,不代表吞吐一定提高,也不保证服务之间公平,更不能保证锁等待变短。 外部连接池代理会再增加一层计数边界。PgBouncer 的会话池化让服务器连接随客户端会话保留;事务池化则在事务结束后归还服务器连接。因此客户端连接数不能直接当成后端会话数。预算清单需要包含代理的服务器侧分配与池化模式。事务模式会改变部分会话特性的使用前提,引入它不是只修改一个容量数字,还需要核对应用是否依赖那些特性。 快速验证 在 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。这里的三个真实连接池位于同一个进程,共用临时本地数据库文件,并没有创建三个生产副本。 ...

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