传输层均衡与 HTTP 均衡的实用区别,在于什么位置可以作出路由决定。TCP 转发可以为一条连接选择后端,然后不解释 HTTP 请求,直接转发字节流。HTTP 代理则解析请求,并能根据路径或请求头选择上游。因此,同一个客户端连接里的多个请求,可能获得不同路由结果。选择策略前,应先明确需要按连接还是按请求决定目标。
坏掉的样子
如果期待 TCP 连接选择还能分别路由内部每个 URL,请求就可能到达错误应用。字节流已经转发到一个后端后,后续 HTTP 路径不会自动触发重新选节点。反过来,如果把 HTTP 路由当成永久连接亲和,也可能让应用误以为同一客户端连接的每个请求都会到达同一份本地状态。两种假设都需要通过实际连接复用案例验证。
层级名称也不能完整说明 TLS 行为。TLS 终止与 HTTP 解释是不同能力。stream 代理可以终止 TLS,却不成为 HTTP 路径路由器;也可以不解密应用数据,只查看部分 ClientHello 元数据。按 SNI 选择目标,并不等于能看到加密 URL、请求头或响应正文。应描述实际配置行为,而不是认为产品标签已经证明所有这些性质。
性能结论同样需要谨慎。用户态 TCP 转发与内核包转发具有不同资源路径,即使两者都被称为传输层均衡。HTTP 代理会执行解析和策略工作,但相对吞吐取决于实现与负载。仅选择 L4 或 L7 这两个术语,不能推出通用的线速能力或固定额外开销;需要以真实路径和代表性流量测量,而不是用层级名称代替基准。
快速验证
本轮复核没有重新运行这些 Docker 监听器;文中记录的路由结果仍属于此前观察。
将脚本保存为 check-routing-layers.py,准备 Python、Docker 和固定镜像后执行。记录服务器为具有 stream 模块的 nginx 1.28.0。两个 HTTP 节点暴露不同身份和后端连接编号。nginx 分别提供 HTTP 路由监听与 TCP 转发监听,都只发布到宿主回环,使相同请求序列可以通过两条路径比较:
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 collections import Counter
locations='''
location /a { proxy_http_version 1.1; proxy_set_header Connection ""; proxy_pass http://backend:8081; }
location /b { proxy_http_version 1.1; proxy_set_header Connection ""; proxy_pass http://backend:8082; }
'''
configuration=config(locations)+'''
stream {
upstream transport { server backend:8081; server backend:8082; }
server { listen 8084; proxy_pass transport; }
}
'''
with lab(configuration,backend=backend,extra_ports=(8084,)) as (_,request):
def exchange(connection,path):
connection.request('GET',path);response=connection.getresponse();data=json.loads(response.read())
assert response.status==200;return data
l7_connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=5)
a=exchange(l7_connection,'/a');client_socket=l7_connection.sock;assert client_socket is not None
b=exchange(l7_connection,'/b')
assert a['peer']=='A' and b['peer']=='B' and l7_connection.sock is client_socket;l7_connection.close()
results={'http_one_client_connection':[a,b]};transport=[]
for _ in range(4):
raw=http.client.HTTPConnection('127.0.0.1',request.ports[8084],timeout=5)
a=exchange(raw,'/a');client_socket=raw.sock;assert client_socket is not None
b=exchange(raw,'/b')
assert a['peer']==b['peer'] and a['connection']==b['connection'] and raw.sock is client_socket
transport.append([a,b]);raw.close()
assert dict(Counter(pair[0]['peer'] for pair in transport))=={'A':2,'B':2}
results['tcp_connection_choices']=transport
print(json.dumps(results,sort_keys=True))
在 HTTP 监听上,客户端通过一个持久 HTTP/1.1 连接先发送 /a,再发送 /b。/a 的 location 选择 A,/b 选择 B。实验确认两次交换之间客户端套接字对象保持相同,因此上游目标变化并不需要客户端重新连接 nginx。这直接展示客户端连接生命周期与后端选择粒度可以不同。
在 TCP 监听上,每个新客户端连接由 stream 上游组选中一个节点。客户端仍先发 /a、再发 /b,但两个响应都来自同一节点,并带有相同后端连接身份。HTTP 路径仍到达应用,只是在这个 TCP 代理配置中不参与路由决策。观察后端连接编号,有助于确认并非两次独立选择碰巧落在同一节点。
四条独立 TCP 客户端连接在本次运行中各有两条选择 A、两条选择 B;每条连接内部的两个请求始终保持在一起。因此实验区分了连接之间的分布与 HTTP 请求之间的分布。TCP 连接数看起来均匀,不代表内部请求数量、成本或延迟也均匀,尤其是不同连接携带的工作量可能差异很大。
两个节点共享一个可丢弃容器的不同端口,便于观察身份和路由,不是在比较两台机器。实验使用 TCP 上的明文持久 HTTP/1.1,没有测试 TLS 协商、UDP 流处理、HTTP/2 多路复用或 HTTP/3。那些能力需要各自配置和观察,不能仅因为 nginx 编译时包含相关模块,就把它们写成这里已经使用或验证。
把策略放在能取得所需信息的位置
如果决定依赖路径、方法或 HTTP 头,就采用能解释 HTTP 的路由。对于 HTTPS,执行这种策略的组件需要获得解密后的请求。如果契约要求保持整条连接的字节流,且不需要逐请求 HTTP 检查,就使用相应传输路径。同时明确证书归属和加密边界,不能认为选择其中任一种方式就天然安全,或者自动完成了后端身份验证。
多层代理组合时,应记录每一跳的角色:哪个组件接受客户端连接、哪个解密、哪个解释 HTTP、哪个选择应用节点。超时、源地址传播、节点健康资格和重试行为,都不会由图上的层级名称自动保证。尤其是把已经建立的字节流移动到另一后端,与重新尝试一个新的 HTTP 请求并不等价,不能共享同一套未经验证的恢复假设。
最后,观测方式要匹配决策单位。传输路由记录连接身份,HTTP 路由记录请求与上游身份。测试时复用同一客户端连接,否则每次都新建连接,会隐藏这里的核心区别。在把均匀分布当成正确证据之前,先验证路由行为是否保留应用的状态假设,以及后续请求是否真的到达预期处理者。
延伸阅读
来源
- nginx stream proxy module,TCP 连接转发与 stream 代理行为,已核验 2026-09-12。
- nginx HTTP proxy module,HTTP 请求转发与按 location 选择上游,已核验 2026-09-12。
- nginx stream SSL preread module,不终止 TLS 时读取 ClientHello 元数据,已核验 2026-09-12。
- nginx stream SSL module,作为独立 stream 能力的 TLS 终止,已核验 2026-09-12。