feat(gateway): 全局串行化(并发=1) + 记录入参/出参/耗时日志

并发模型从「每worker并发1 + 多worker并行」改为全局串行:同一时间
只处理1个请求,其余排队;多 worker 仅作热备(主 worker 坏了才用备机)。
接口4(face/features) 走豆包、不占 GPU,不纳入串行。

- pool: asyncio.Semaphore(max_global_concurrency=1) + acquire/release_global_slot
  (依赖单进程 uvicorn 部署,已在注释中标注)
- forward: proxy_request 最外层 acquire 全局槽、try/finally 全路径释放;
  入参 multipart 解析挂 request.state;原重试/故障转移逻辑抽到 _dispatch_with_retries
- reqlog(新): 标量入参保留;图片(file/base64)存盘转URL,绝不内嵌base64;
  出参递归摘要截断(landmarks/长串/大数组)
- logging_middleware: RequestLogEntry 加 request_params/response_data,
  jsonl 全量记录;_load_from_logfile 同步映射防重启丢字段;get_stats recent 暴露
- /gateway-health 暴露 global_max/global_busy/global_waiting
- config: dispatch 新增 max_global_concurrency / max_queue_wait_seconds
- tests: test_reqlog + test_gateway_serialization(9 用例)

顺带提交此前未提交的网关统计(daily stats 接口)与耗时看板(api_timing_dashboard.html)。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ubuntu
2026-07-23 22:56:48 +08:00
co-authored by Claude
parent 98b9108837
commit bb9f55e93c
10 changed files with 1037 additions and 7 deletions
+39
View File
@@ -20,6 +20,8 @@ from fastapi.staticfiles import StaticFiles
from gateway.config import load_config
from gateway.logging_middleware import (
get_available_dates,
get_daily_stats,
get_stats,
init_logging as _init_req_logging,
request_logging_middleware,
@@ -185,6 +187,9 @@ async def gateway_health():
"workers_total": status["total"],
"workers_healthy": status["healthy"],
"workers_busy": status["busy"],
"global_max": status.get("global_max", 1),
"global_busy": status.get("global_busy", 0),
"global_waiting": status.get("global_waiting", 0),
}
@@ -201,6 +206,7 @@ async def index():
"version": "0.1.0",
"docs": "/docs",
"stats": "/admin/stats",
"timing_dashboard": "/static/api_timing_dashboard.html",
"integration_guide": "/static/integration.html",
"test_pages": {
"if1_measure": "/static/test_interface1.html",
@@ -470,6 +476,18 @@ async def admin_stats_json():
return get_stats()
@app.get("/admin/stats/dates", include_in_schema=False)
async def admin_stats_dates():
"""返回日志中出现过的所有本地日期(供耗时看板日期选择器使用)。"""
return {"dates": get_available_dates()}
@app.get("/admin/stats/daily", include_in_schema=False)
async def admin_stats_daily(date: str):
"""按天统计各接口调用耗时(含多发型接口的单次换发型耗时拆解)。"""
return get_daily_stats(date)
# ---------------------------------------------------------------------------
# 代理路由
# ---------------------------------------------------------------------------
@@ -529,6 +547,7 @@ async def hair_grow_b(request: Request):
@app.post("/api/v1/face/features", tags=["人脸分析"])
async def face_features(
request: Request,
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
@@ -559,6 +578,26 @@ async def face_features(
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
# 入参日志(接口4 不走 forward.py,自行记录;不获取全局串行槽,因不占 GPU)
try:
from gateway.config import get_config as _get_cfg
from gateway.reqlog import save_image_bytes
_cfg = _get_cfg()
_params: dict = {}
if image_file:
_url = save_image_bytes(img_bytes, _cfg["static_dir"], _cfg["public_base_url"]) if img_bytes else None
_params["image_file"] = {"_image": True, "source": "file",
"size": len(img_bytes) if img_bytes else 0, "url": _url}
elif image_base64:
_url = save_image_bytes(img_bytes, _cfg["static_dir"], _cfg["public_base_url"]) if img_bytes else None
_params["image_base64"] = {"_image": True, "source": "base64",
"size": len(img_bytes) if img_bytes else 0, "url": _url}
elif image_url:
_params["image_url"] = image_url
request.state.log_request_params = _params
except Exception: # noqa: BLE001
request.state.log_request_params = {"_parse_error": "iface4"}
from fastapi.concurrency import run_in_threadpool
from face_features import analyze_features