并发一高就超时,通常不是单一原因:服务端限流、客户端连接池上限、超时时间设置、重试策略其中任何一环不合理,都会表现为“请求超时”。判断顺序建议是:先从响应头和错误码确认是否被限流,再看本地连接池的活跃连接数与等待队列,最后用分级并发脚本把拐点找出来。这三步都能靠日志、配置和脚本验证,不需要猜测。
GLM-5.3-FlashX 并发升高后频繁超时,先看响应里有没有 429、Retry-After 或限流相关响应头,这能区分“服务端拒绝”与“连接卡住”。如果没有限流信号,重点查客户端连接池最大连接数、连接超时和读超时是否过小,以及活跃连接是否长期打满。用分级加压脚本从低并发逐步抬高,记录成功率和超时率,找到超时开始出现的并发区间,再对照限流信号调整池大小和重试退避。注意:并发能力受服务端配额与本地资源双重约束,调整连接池不会突破服务端限额。
在响应头或错误码里查找限流信号
限流引起的超时和连接池不足引起的超时,表现不完全一样。限流往往在服务端有明确反馈,而连接池问题通常卡在本地等待建立或复用连接。要判断是不是限流,先让客户端把 HTTP 状态码、响应头和错误体完整记录下来,而不是只看一个“请求超时”异常。
常见的限流标识包括:
- HTTP 429 Too Many Requests,这是最直接的限流信号。
- 响应头
Retry-After,表示建议等待多少秒后再发。 - 类似
X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset的字段,用来看当前配额和剩余量(字段名各平台不同,需要以实际返回为准)。 - 错误体里出现
rate limit、too many requests、quota exceeded之类的关键词。
从日志中提取时,建议把每次调用的这几项固定打出来:请求时间、耗时、状态码、关键响应头、错误体前若干字符。下面是一个通用日志片段示例,语言无关,替换成你用的 HTTP 客户端即可:
[call] ts=... cost=1234ms status=429
[call] header Retry-After=2
[call] body={"error":{"type":"rate_limit","message":"..."}}
[call] ts=... cost=10002ms status=- error=read timeout如果大量并发超时里夹杂 429,或用抓包/日志能看到限流头,那服务端限流大概率是主因之一。如果超时记录里完全没有 429、没有限流头,错误都是连接建立超时或读超时,那就要优先怀疑本地连接池和超时配置。需要结合环境确认:有些网关会把 429 吞掉直接返回 5xx 或断开连接,这时要同时看网关日志。
检查客户端连接池最大连接数与超时设置
连接池太小,并发请求会排在池外等连接;等不到就抛超时。它的典型特征是没有 429,但有大量请求在“获取连接”阶段耗时很长。先用监控确认活跃连接数是否长期贴着上限、等待队列是否在涨,再决定要不要调大。
常见需要关注的参数:
max_connections/max_idle_connections:池里最多允许多少连接、保留多少空闲连接。connection_timeout:从池里拿连接或新建连接的最长等待时间。read_timeout/total_timeout:读响应的上限和整个请求的上限,避免单请求无限拖住连接。idle_timeout:空闲连接多久回收,防止连接被服务端先关掉。
一个通用配置骨架(参数名按你用的库替换):
pool:
max_connections: 50
max_idle_connections: 20
connection_timeout: 2s
read_timeout: 30s
total_timeout: 35s
idle_timeout: 60s监控活跃连接数的方式取决于技术栈:连接池一般自带 metrics(活跃数、空闲数、等待数),也可以周期性打印池状态到日志。重点看三点:活跃连接是否等于 max_connections、等待获取连接的请求数是否持续大于 0、连接创建失败或超时是否集中在并发高峰。调整 max_connections 时不要一次拉太大,要同时考虑对端配额和本机文件描述符上限。注意,调大连接池只能缓解本地排队,不能突破服务端的并发限制。
用分级并发脚本逐步加压定位拐点
只靠线上观察很难定位“从多少并发开始超时”。建议用脚本从低并发开始,逐级加人,每级记录成功率、超时率和平均耗时,找到超时开始明显上升的那一级。脚本骨架如下,替换请求地址、鉴权头和并发梯度即可:
import time, asyncio, aiohttp
URL = "https://<your-endpoint>"
HEADERS = {"Authorization": "Bearer <token>"}
LEVELS = [1, 5, 10, 20, 40]
async def one(session):
t0 = time.time()
try:
async with session.post(URL, headers=HEADERS, json={"prompt":"ping"}) as r:
await r.text()
return "ok" if r.status < 400 else f"http_{r.status}", time.time()-t0
except asyncio.TimeoutError:
return "timeout", time.time()-t0
except Exception as e:
return f"err_{type(e).__name__}", time.time()-t0
async def run_level(n):
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=35)) as s:
results = await asyncio.gather(*[one(s) for _ in range(n)])
ok = sum(1 for r,_ in results if r=="ok")
to = sum(1 for r,_ in results if r=="timeout")
lat = sorted(t for _,t in results)
print(f"concurrency={n} ok={ok} timeout={to} p50={lat[len(lat)//2]:.3f}s")
async def main():
for n in LEVELS:
await run_level(n)
await asyncio.sleep(2)
asyncio.run(main())运行前把并发梯度改成你关心的区间,从明显不会超时的低并发起步。如果低并发全部成功、某级开始出现超时,那一级附近就是拐点。接着在两个场景下各跑一遍:只调大连接池、只加退避重试,对比成功率与超时率的变化。若调大连接池后拐点没变,而日志里开始出现 429,说明瓶颈在服务端配额;若始终没有 429 但活跃连接打满,说明本地池仍是瓶颈。
调整重试策略与退避算法
遇到限流或瞬时超时,盲目立即重试会放大压力。建议用指数退避加随机抖动,并给最大重试次数和单次总超时设上限,防止请求无限堆积。
max_retries = 3
base = 0.5 # 首次等待秒数
total_timeout = 35
def should_retry(status, exc):
return status in (429, 500, 502, 503, 504) or isinstance(exc, TimeoutError)
async def call_with_retry(req, deadline):
for i in range(max_retries + 1):
if time.time() > deadline:
raise TimeoutError("total timeout")
try:
resp = await req()
if not should_retry(resp.status, None):
return resp
wait = min(base * (2 ** i), 8) # 上限 8 秒
wait += random.uniform(0, wait * 0.3) # 抖动
await asyncio.sleep(wait)
except Exception as e:
if i == max_retries:
raise
wait = min(base * (2 ** i), 8) + random.uniform(0, base)
await asyncio.sleep(wait)几点取舍:最大重试次数通常设 2-3 次就够,重试太多会把超时请求拖到总超时上限;若响应里有 Retry-After,优先按它给的等待时间,再叠加抖动;抖动是为了避免大量客户端在同一时刻同时重试。重试只能缓解瞬时失败,解决不了持续限流或连接池长期打满,这两类问题还是要回到配额与池大小上处理。