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,实验结束后清理自己的资源,不接入已有业务连接池:
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
backend=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import itertools,json,threading
ids=itertools.count(1);lock=threading.Lock();opened=set();active=set();release=threading.Event()
class Handler(BaseHTTPRequestHandler):
protocol_version='HTTP/1.1'
def setup(self):
super().setup();self.ident=next(ids)
with lock:opened.add(self.ident)
def finish(self):
try:super().finish()
finally:
with lock:opened.discard(self.ident)
def log_message(self,*args):pass
def do_GET(self):
if self.path=='/hold':
with lock:active.add(self.ident)
assert release.wait(15)
with lock:active.remove(self.ident)
elif self.path=='/release':release.set()
with lock:data=json.dumps({'connection':self.ident,'active':sorted(active),'other_open':sorted(opened-{self.ident}),
'host':self.headers.get('Host'),'connection_header':self.headers.get('Connection')}).encode()
self.send_response(200);self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data)
ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever()
'''
locations='''
location /reuse/ { proxy_http_version 1.1; proxy_set_header Connection ""; proxy_pass http://pool/; }
location /close/ { proxy_http_version 1.1; proxy_set_header Connection close; proxy_pass http://pool/; }
location /host-only/ { proxy_http_version 1.1; proxy_set_header Host service.example; proxy_pass http://pool/; }
location /control/ { proxy_set_header Connection close; proxy_pass http://backend:8081/; }
'''
extra='upstream pool { server backend:8081; keepalive 2; keepalive_timeout 30s; }'
with lab(config(locations,extra),backend=backend) as (_,request):
def get(path):
status,_,body=request(path);assert status==200;return json.loads(body)
results={}
for mode in ['reuse','close','host-only']:
first=get('/'+mode+'/id');second=get('/'+mode+'/id')
assert (first['connection']==second['connection'])==(mode=='reuse')
if mode=='host-only':assert first['host']=='service.example' and first['connection_header']=='close'
results[mode]={'connections':[first['connection'],second['connection']],'host':first['host'],'connection_header':first['connection_header']}
with ThreadPoolExecutor(max_workers=4) as pool:
futures=[pool.submit(get,'/reuse/hold') for _ in range(4)]
wait(lambda:len(get('/control/state')['active'])==4)
active=get('/control/state')['active'];assert len(set(active))==4
get('/control/release');replies=[f.result(timeout=10) for f in futures]
assert len({r['connection'] for r in replies})==4
wait(lambda:len(get('/control/state')['other_open'])==2)
results['concurrent_active']=active;results['idle_after_burst']=get('/control/state')['other_open']
print(json.dumps(results,sort_keys=True))
连续两次 /reuse/id 返回相同后端连接身份。客户端辅助函数每次请求都新建并关闭自己的 HTTP 连接,因此观察到的是跨不同下游连接的上游复用。该 location 显式使用上游 HTTP/1.1,并且不发送 Connection 头。每个响应完整读完后,后端交换才具备进入空闲状态的条件,不能在正文尚未消费完时仅凭状态成功判断连接可复用。
/close/id 对照发送 Connection: close,连续请求得到不同后端连接身份。/host-only/id 对照修改 Host,却保留关闭行为,响应同样使用不同连接。这些比较说明:即使所有路径连接同一个应用,用于路由的头也不能替代正确的持久连接设置。观察请求头和连接身份,比只看两次请求都成功更有解释力。
接着,四个客户端请求由后端故意保持未完成的响应。控制端等到后端报告四个活动连接身份后,才放行任何响应。这直接展示 keepalive 2 下仍有四条并发上游连接,不是根据耗时猜测并发,也不是数四个可能顺序完成的请求。受控等待使活动连接数量成为可以直接检查的中间状态。
放行后,四个响应全部完成。实验随后观察到恰好两条剩余空闲后端连接,计数排除了用于查询状态的短期控制连接。最终保留哪些身份会随完成顺序变化,断言关心的是数量。这样就能区分配置规定的空闲保留上限,与突发期间实际使用的更大活动连接集合,避免把两种阶段混成一个总上限。
让复用设置匹配后端行为
应结合连接建立成本、预期再次使用概率、后端限制和 nginx worker 数量确定空闲保留规模。连接即使没有承载请求,保留期间也会占用资源。过大的缓存可能挤占新请求可用连接,过小的缓存则可能反复丢弃马上就会复用的连接。选择规模需要真实访问分布和后端预算,而不是一律把值调大。
请求次数、空闲时间和连接总寿命控制,会因为不同原因回收持久连接。不要看到重连就同时提高所有限制。应先观察后端主动关闭、有效配置、响应分帧完成情况,以及真实连接身份。本次空闲超时设为三十秒,短暂突发没有测量长期连接老化,也没有测量 TLS 会话行为,因此不能据此推断那些不同机制的效果。
如果目标是限制后端工作量,应采用真实并发或准入策略;keepalive 负责复用策略。示例明确选择协议和缓存设置,因为滚动文档还包含较新 nginx 的默认值与功能。应用时要遵循实际部署版本的语义,同时验证连续复用和并发突发,不能认为一次重复请求成功复用,就已经证明容量边界受到控制。
延伸阅读
来源
- nginx upstream module,空闲连接缓存、worker 范围与 keepalive 限制,已核验 2026-10-15。
- nginx 1.28.0 upstream keepalive source,被测版本的空闲缓存与连接复用实现,已核验 2026-10-15。
- RFC 9112 HTTP/1.1,持久连接、消息分帧与 Connection close,已核验 2026-10-15。