一、问题从哪儿来
大家在微服务架构里摸爬滚打,一定都听过“连接资源耗尽”这个词。很多人一开始觉得,连接不就是TCP吗?用完就关,有什么好怕的?可当你的服务真正跑到高并发场景,比如双十一大促、限时秒杀、直播间放量抢购,你就会发现,连接资源耗尽不是玩笑。它一旦发生,整个服务集群可能瞬间雪崩,一个接口都调不动。
打个比方,你家楼下的便利店平时只有几个顾客,收银台完全够用。可一旦来了一千个人排队,收银员忙不过来,收银台也会被堵死。更糟糕的是,后面的人不停往里挤,门都推不开,最后谁都买不到东西。服务连接池耗尽就是这样的场景:你服务的每个实例能打开的连接数量是有限的,一旦请求量超过这个上限,新来的请求就只能在门口排队等待,一直等到超时,然后报错。这种错误还会不断往上传播,把调你的上游服务也拖垮。
那服务网格里的Envoy为什么特别容易遇到这个问题呢?因为Envoy作为数据面的代理,所有流量都要经过它。它既要处理上游的连接池,也要处理下游的连接池。你每个服务实例旁都要挂一个Envoy,它就像你家门口的门卫,每个进来的人都要通过它。如果门卫的登记本只剩最后几页,新来的人就只能等。如果等太久,人就越积越多,最后连门卫室都被围得水泄不通。
二、Envoy的连接池到底是怎么回事
2.1 连接池不是什么神秘东西
先别被“连接池”这个术语吓住。说白了,它就是Envoy在转发请求时,把发往后端服务的TCP连接预先建好,然后重复使用。这样每次请求来临时,不用重新经历一遍TCP三次握手,省时间也省资源。这就像你经常去同一家快递点寄快递,快递员认识你了,直接拿单子就填,不用每次证明你是谁。
Envoy的连接池是按“上游集群”划分的。什么叫上游集群?就是你通过Envoy访问的那个后端服务组。比如你有个订单服务,只要配置好,Envoy就会为这个订单服务维护一组连接。每个连接就像是快递点的一个窗口,多个窗口同时处理业务。
2.2 高并发时为什么会耗尽
想象一下,你有100个请求同时到达Envoy,但Envoy到订单服务的连接池里只有50个连接。那么有多少请求能立刻被处理?当然只有50个。剩下50个请求只能排队。如果排队等待的时间超出了请求的超时时间,请求就会失败。这还没完,如果请求继续疯狂涌入,Envoy还会尝试创建更多连接。但创建连接是要消耗文件描述符的,每个连接还要占用内存。文件描述符是有限资源,一旦被连接占满,连日志、健康检查这类最基础的功能都会出问题。到那时,整个代理直接瘫痪,这就是“连接资源耗尽”。
更隐蔽的情况是,某些后端服务响应很慢,一个请求要花几秒才能返回。这时候连接池里的连接都被占着,新的请求进不来。就算你不断加大连接池上限,后端服务也扛不住那么多并发连接,反而会把它自己拖死。
三、连接池配置的核心参数
Envoy的连接池配置主要用在上游集群的circuit_breakers旋钮下面。别被这个英文名吓到,它就是“熔断器”的意思。这里面的参数不少,但真正需要关心的就那么几个。
3.1 max_connections
这是单个上游集群最多能建立多少TCP连接。默认值是1024。听起来很多对吧?可如果你的服务有几百个副本,每个副本的Envoy都建1024个连接到同一个后端,那后端瞬间就能收到几万个连接,直接崩溃。所以这个值必须结合实际后端能力来设。
3.2 max_pending_requests
当连接池里的连接全部被占用时,新请求会进入“等待队列”。这个队列的长度上限就是max_pending_requests。默认值也是1024。如果队列也满了,新的请求就会立刻被拒绝,返回503。有人会问,为什么不把队列设得很大,让请求多等等?答案是:等待的请求多了,会占用内存,而且等太久的请求就算转发出去,客户端也早就超时了,毫无意义。
3.3 max_requests_per_connection
这个参数的含义是:一条连接最多能复用多少次请求。默认是0,也就是不限制。为什么会有这个限制?因为某些老旧的HTTP库或后端服务,对长连接有内存泄漏问题,连接用得越久,内存占用越大。通过定期让连接关闭重建,可以规避这个问题。代价是什么呢?会多消耗一些连接建立的开销。这个值不是必须设的,除非你知道自己服务有什么特殊毛病。
3.4 max_retries
Envoy会自动重试一些失败的请求。但是重试也不是无限制的,否则一个本来不靠谱的接口,会被Envoy反复打call,造成更大的压力。max_retries就是控制重试次数的。默认值也是1024。在高并发场景下,我建议调小一点,比如3或者5。重试一两次还能接受,重试三次以上基本属于雪上加霜。
四、直接上配置示例
为了让大家看得清楚,本文所有示例都使用同一套技术栈:Bash脚本配合Envoy的YAML配置。你在命令行里跑一跑就能看到效果。
4.1 一个基础的上游集群配置
我们先用一个最简单的例子,创建一个Envoy配置文件,里面定义一个后端集群叫order_service,它指向两个后端地址。然后用Bash命令启动Envoy。
#!/bin/bash
# 定义一个老的Envoy配置文件,展示默认的连接池
cat > /tmp/envoy-basic.yaml <<'EOF'
# 这是Envoy的静态配置,不用xDS时候最直接的方式
static_resources:
listeners:
- name: http_listener
address:
socket_address:
address: 0.0.0.0
port_value: 8080
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: http
route:
dynamic_stats: true
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: order_service
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: order_service
connect_timeout: 0.25s
type: STRICT_DNS
lb_policy: LEAST_REQUEST
load_assignment:
cluster_name: order_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: order-service-1
port_value: 8080
- endpoint:
address:
socket_address:
address: order-service-2
port_value: 8080
# 这里完全没有配置circuit_breakers,全部走默认值
EOF
# 使用envoy命令启动,指定配置文件路径
envoy -c /tmp/envoy-basic.yaml
在这个配置里,我们没有写任何circuit_breakers,Envoy会用默认的1024。如果你的后端服务只能撑住同时20个连接,那么这样的配置就是一颗定时炸弹。所以我们要在集群下面加上熔断配置。
4.2 加上连接池限制的配置
我们重新创建一个配置文件,把circuit_breakers加进去。这次我们故意把数值调得保守一点,方便大家看到效果。
#!/bin/bash
# 生成一个带有连接池限制的Envoy配置
cat > /tmp/envoy-throttled.yaml <<'EOF'
static_resources:
listeners:
- name: http_listener
address:
socket_address:
address: 0.0.0.0
port_value: 8080
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: http
route:
dynamic_stats: true
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: order_service
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: order_service
connect_timeout: 0.25s
type: STRICT_DNS
lb_policy: LEAST_REQUEST
load_assignment:
cluster_name: order_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: order-service-1
port_value: 8080
- endpoint:
address:
socket_address:
address: order-service-2
port_value: 8080
circuit_breakers:
thresholds:
- priority: DEFAULT
# 最多10个TCP连接
max_connections: 10
# 最多排队5个请求
max_pending_requests: 5
# 最多同时转发20个请求
max_requests: 20
# 最多重试2次
max_retries: 2
EOF
# 启动这个严格限制版Envoy
envoy -c /tmp/envoy-throttled.yaml
这个配置的意思是:Envoy到order_service集群的连接最多只有10个。当10个连接都在忙的时候,新的请求会先排队,队里最多放5个。如果队也满了,第6个请求会直接收到503响应。每个连接上同时能跑的请求数量也有上限,最多20个。超过的话,请求也会排队或直接失败。
你可能会问,为什么max_requests比max_connections大?因为HTTP/2允许多路复用,一条物理连接上可以同时跑很多逻辑请求。如果后端支持HTTP/2,这个值可以相对大一些。
五、示例:压测看看连接池到底怎么工作
光说不练假把式。我们用Bash写一个简单的压测脚本,模拟并发请求,看看连接池限制生效后的表现。这个脚本不需要额外安装工具,只要你有curl和seq就能跑。
#!/bin/bash
# 向本地Envoy发送20个并发请求,观察成功和失败比例
echo "开始压测,模拟20个并发请求..."
# 临时保存环境变量
OUT_FILE=/tmp/curl-output.txt
# 空文件
> $OUT_FILE
# 用seq生成1到20,然后每个数字都后台启动一个curl
for i in $(seq 1 20); do
# -s静默模式,-o把响应体丢弃,-w只输出HTTP状态码
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/order/$i >> $OUT_FILE &
done
# 等待所有后台任务结束
wait
# 统计各个状态码出现的次数
echo "状态码统计结果:"
sort $OUT_FILE | uniq -c
脚本跑完之后,你会看到类似这样的结果:
状态码统计结果:
5 200
5 503
10 200
这个输出只是示例。真正结果取决于你后端的处理能力。但核心思想不变:当你没有配置连接池限制时,20个请求全都会尝试建立新连接。当你配置了限制,一部分请求就会在连接池穷尽时被快速拒绝,而不是无限堆积。
六、关联技术:熔断、限流与重试
6.1 连接池和熔断的关系
很多开发者把连接池耗尽和熔断混为一谈。其实它们是两码事。连接池耗尽是一种状态,熔断是一种保护机制。Envoy的circuit_breakers就是一个熔断器工具。你把max_connections调到10,当达到10时,熔断器开始拒绝部分请求,避免后续流量继续冲击后端。这就像是滑坡前建了一道防护网,而不是等人都掉下去后再去拉人。
6.2 限流是怎么配合的
限流是在请求进入Envoy之前就控制其速率,比如每秒最多放1000个请求。连接池是在发现后端撑不住后,把新请求挡在队列之外。两者一个在门口限速,一个在窗口控制办理人数。如果只限流,但是每个请求都处理得很慢,连接池还是会满。如果只调连接池,但不限制流量,排队时间依然会很长。所以高并发场景要把它们配合使用。
下面是一个在Envoy中配置限流的简单示例,同样用Bash生成YAML。注意这里只演示过滤器配置的一部分:
#!/bin/bash
# 生成一个带限流的Envoy HTTP过滤器配置
cat > /tmp/envoy-rate-limit.yaml <<'EOF'
static_resources:
listeners:
- name: http_listener
address:
socket_address:
address: 0.0.0.0
port_value: 8080
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: http
route:
dynamic_stats: true
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: order_service
http_filters:
# 这里插入限流过滤器
- name: envoy.filters.http.local_ratelimit
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
stat_prefix: local_rate_limit
# 每秒最多放行10个令牌
token_bucket:
max_tokens: 10
tokens_per_fill: 10
fill_interval: 1s
filter_enabled:
default_value:
numerator: 100
denominator: HUNDRED
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: order_service
connect_timeout: 0.25s
type: STATIC
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: order_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 127.0.0.1
port_value: 9090
EOF
# 用同一个配置文件跑Envoy
envoy -c /tmp/envoy-rate-limit.yaml
这个配置会让Envoy每秒钟最多放入10个请求。就算外面有10000个请求,也只有前10个能进,其他全被快速拒绝。这样后端服务几乎不会感受到压力。现实中的限流算法更复杂,但这张概念图已经说明问题。
6.3 重试带来的风险
重试机制在分布式系统里非常常见。但重试也是连接池耗尽的一大帮凶。假设你的后端服务已经故障了,响应逐渐变慢。Envoy看到某个请求超时,会根据配置自动重试。如果重试也超时,再重试。每次重试都会占用连接池里的一个连接,让剩下能服务正常请求的连接更少。最后,正常用户请求被挤掉,重试请求反而把后端打垮。
所以我们设置连接池时,max_retries一定要小。特别是高并发场景,宁可一次失败也不要反复重试。下面是一个设置了重试策略的路由配置,你可以看到我们手动把重试次数定得很死:
route:
cluster: order_service
retry_policy:
retry_on: connect-failure
num_retries: 2
retry_host_predicate:
- name: envoy.retry_host_predicates.previous_hosts
这里num_retries: 2表示最多重试两次,加上原始请求,总共有三次机会。这就够了,再多就是在给连接池添乱。
七、连接池配置的应用场景
7.1 保护依赖了外部第三方服务的场景
假设你的服务要调用一个第三方的支付接口。这个接口有严格的QPS和并发限制。如果你不设置Envoy连接池,你的服务可能会因为一瞬间的流量高峰期,把连接数发到几百甚至几千,直接触发第三方封禁。但是配置了max_connections: 20之后,你的服务最多只会有20个并发的支付请求,完全在第三方允许范围内。
7.2 应对突发流量时隔离故障
服务网格中通常一个Envoy会代理多个集群。比如你的订单服务同时调用户服务和库存服务。如果用户服务因为故障变慢,它的连接池会很快占满。如果用户服务的连接池没有限制,它可能会大量建立新连接,占用Envoy所在实例的文件描述符,最后影响库存服务的正常运行。这种问题叫“雪崩”或“级联故障”。给每个集群都配上连接池上限,等于给每个集群设了一道墙,一个坏掉不会拖累其他类。
7.3 节省资源,让单个Pod能跑更多的服务实例
Kubernetes环境里,每个Pod的资源是有限的。文件描述符、内存都有限。如果你把Envoy的连接池调得非常大,比如每个后端集群10000,一旦真有那么多连接,内存直接爆炸。合理的连接池配置能让一个Pod在服务几十个后端集群时依然稳定运行。
八、技术优缺点详细分析
8.1 好处
- 防止连接数无限上涨,保护后端服务
- 快速失败,避免请求堆积造成长时间呆滞
- 给每个服务隔离出独立资源限额,防止级联故障
- 配合熔断和限流可以形成一套完整的保护策略
8.2 坏处
- 设置得太小,会误杀正常请求,降低系统吞吐量
- 设置得太大,又等于没设置,资源依然会被耗尽
- 别人通过观察连接池满时的503状态码,能推断出你的内部容量,可能被恶意探测
- 需要对后端服务的真实并发能力做压测,不然很难找到合适的阈值
8.3 一个需要注意的坑
max_pending_requests默认是1024,很多人在压测时发现排队时间很长,就把这个值调大,结果请求全堆在队列里,内存飙升,最终服务还是被熬死。正确做法是:宁可快速失败,也不要让请求无休止地排队。因为一个等待了5秒的请求,即使后端终于空出连接,这个请求也大概率已经被客户端放弃了。转发一个已经没人要的请求,只会浪费资源。
九、实际操作时的注意事项
9.1 先把后端压出真实容量,再定参数
不要闭着眼睛把max_connections设成100。你需要拿着压测工具,比如wrk、ab或者落地的k6,打后端服务本身,看看它在CPU、内存、延迟都可接受的范围内,最多能承受多少个并发连接。这个数字就是你的初始值。然后再留出10%到20%的余量,防止突发。
9.2 时刻关注Envoy的统计指标
Envoy会暴露很多连接池相关的监控指标,比如upstream_cx_active(活跃连接数)、upstream_cx_overflow(连接池满被拒绝的次数)、upstream_rq_503(503状态码总数)。你可以在配置里打开envoy.stat_sinks,用Prometheus格式输出这些指标。看到upstream_cx_overflow持续大于0,说明连接池经常被打满,需要调大或优化后端。
9.3 在Kubernetes环境要注意Pod数量
如果你的后端服务有很多个Pod,每个Pod旁边的Envoy都会维护各自到同一个后端的连接池。比如你的订单服务有10个Pod,每个Pod的Envoy限制max_connections: 10,那后端的服务实例最多可能收到100个连接。所以在设置数字之前,一定要算清楚“总的连接数上限 = 每个Envoy的上限 × 上游Envoy数量”。否则你以为是限制了,实际上总连接数依然很庞大。
9.4 不要忘记TLS连接池
如果你的Envoy和后端之间加了TLS,连接池的管理逻辑会略有不同。TLS握手比普通TCP握手更消耗CPU。因此,在开启TLS时,连接池的复用率就变得更重要。你可以通过配置transport_socket里的common_tls_context,同时把max_connections设得比没开TLS时更大一些,虽然也会占用更多内存,但总比频繁重新握手好。
十、调优实战:一个完整的Bash脚本示例
下面我给出一个完整的调优流程示例。这个脚本会先创建一个带连接池限制的Envoy配置,然后启动Envoy,再用一个简单的并发请求脚本观察503变化。整个流程你可以在本地复现,只需要有Docker或者直接装了Envoy。
#!/bin/bash
# 设置变量
ENVOY_CONFIG=/tmp/envoy-tuning.yaml
UPSTREAM_PORT=9090
GATEWAY_PORT=8080
# 启动一个最简单的本地echo后端,只是用Python模拟,只是为了演示
# 注意:实际生产环境不会这样用
python3 -m http.server $UPSTREAM_PORT &
UPSTREAM_PID=$!
# 生成Envoy配置
cat > $ENVOY_CONFIG <<'EOF'
static_resources:
listeners:
- name: gateway
address:
socket_address:
address: 0.0.0.0
port_value: 8080
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: gateway
route:
dynamic_stats: true
virtual_hosts:
- name: backend
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: backend
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: backend
connect_timeout: 0.25s
type: STATIC
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: backend
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 127.0.0.1
port_value: 9090
# 核心连接池配置
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 5
max_pending_requests: 2
max_requests: 8
max_retries: 1
EOF
# 启动Envoy
envoy -c $ENVOY_CONFIG &
ENVOY_PID=$!
# 等待服务起来
sleep 2
# 发送15个并发请求,每个请求故意延迟响应
# 因为python后端会正常返回,连接池只有5个,所以排队失败很容易模拟
for i in $(seq 1 15); do
curl -s -o /dev/null -w "请求${i}状态码: %{http_code}\n" http://localhost:$GATEWAY_PORT/req-$i &
done
# 等待curl全部结束
wait
# 清理进程
kill $ENVOY_PID $UPSTREAM_PID 2>/dev/null
这个脚本会在你本地启动一个Python HTTP服务器作为后端,然后启动Envoy代理,再用15个并发请求去压。因为连接池上限是5,所以一部分请求会被快速拒绝,你会看到类似于“请求6状态码: 503”的输出。这就是连接池在起作用。
十一、文章总结
说到底,Envoy的连接池不是一项炫技的技术,它是一门保命的功夫。在高并发场景下,连接资源耗尽就像家门口的堵车,越堵越急,越急越堵。通过合理配置max_connections、max_pending_requests、max_requests和max_retries,你可以把不可控的流量洪峰,变成可控的排队和快速失败。快速失败比无限等待好很多,因为等待并不能解决问题,只会浪费资源。
服务网格给了我们很多花哨的功能,但最基础、最核心的永远是流量管理的高可用。很多团队在生产环境里跑了很多服务,却没有认真调过连接池,只用了默认值。这就像买了一辆性能跑车,却始终挂着低速挡,你觉得路上一切正常,可一旦遇到爬坡就会熄火。希望这篇文章能帮你在真正遇到“连接资源耗尽”之前,做好防御工事。
最后记住:连接池设置不是一成不变的,要随着业务情况、后端容量、流量模型动态调整。多看看监控,多压压测,你会对系统有更深的掌控感。
评论
围绕“服务网格中Envoy的连接池配置:解决高并发场景下的连接资源耗尽问题”参与讨论