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
+231
View File
@@ -0,0 +1,231 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API 耗时看板</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 4px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 18px; }
.nav { margin-bottom: 18px; }
.nav a { color: #2563eb; text-decoration: none; font-size: 13px; margin-right: 14px; }
.nav a:hover { text-decoration: underline; }
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.toolbar label { font-size: 13px; font-weight: 600; color: #374151; }
.toolbar select, .toolbar button { padding: 8px 14px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; background: #fff; cursor: pointer; }
.toolbar select { min-width: 160px; }
.toolbar button { background: #2563eb; color: #fff; border: none; font-weight: 600; }
.toolbar button:hover { background: #1d4ed8; }
#loadingHint { font-size: 12px; color: #9ca3af; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { background: #fff; border-radius: 12px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.stat-card .value { font-size: 26px; font-weight: 700; color: #111827; }
.stat-card .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
.stat-card.ok .value { color: #059669; }
.hourly-wrap { background: #fff; border-radius: 12px; padding: 16px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 24px; }
.hourly-wrap h2 { font-size: 14px; color: #374151; margin-bottom: 12px; }
.hourly-bars { display: flex; align-items: flex-end; gap: 4px; height: 80px; }
.hourly-bar { flex: 1; background: #93c5fd; border-radius: 3px 3px 0 0; position: relative; min-height: 2px; }
.hourly-bar .cnt { position: absolute; top: -16px; left: 0; right: 0; text-align: center; font-size: 10px; color: #6b7280; }
.hourly-labels { display: flex; gap: 4px; margin-top: 4px; }
.hourly-labels span { flex: 1; text-align: center; font-size: 10px; color: #9ca3af; }
.ep-card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 16px; overflow: hidden; }
.ep-header { padding: 14px 18px; background: #fafafa; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.ep-badge { background: #2563eb; color: #fff; padding: 3px 12px; border-radius: 12px; font-size: 12px; font-weight: 700; white-space: nowrap; }
.ep-name { font-size: 15px; font-weight: 700; color: #111827; }
.ep-path { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; color: #9ca3af; }
.ep-success { margin-left: auto; font-size: 12px; }
.ep-body { padding: 16px 18px; }
.metrics-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; margin-bottom: 4px; }
.metric { text-align: center; padding: 10px 4px; background: #f9fafb; border-radius: 8px; }
.metric .v { font-size: 17px; font-weight: 700; color: #1f2937; }
.metric .l { font-size: 10px; color: #9ca3af; margin-top: 2px; text-transform: uppercase; letter-spacing: .3px; }
.style-box { margin-top: 14px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 10px; padding: 14px 16px; }
.style-box .title { font-size: 13px; font-weight: 700; color: #1e40af; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.style-box .formula { font-size: 12px; color: #1e3a8a; margin-bottom: 10px; font-family: "SF Mono", "Fira Code", monospace; background: #dbeafe; padding: 6px 10px; border-radius: 6px; display: inline-block; }
.style-metrics { display: flex; gap: 24px; margin-bottom: 10px; flex-wrap: wrap; }
.style-metrics .sm { }
.style-metrics .sm .v { font-size: 22px; font-weight: 700; color: #1d4ed8; }
.style-metrics .sm .l { font-size: 11px; color: #3b82f6; margin-top: 2px; }
.cluster-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 6px; }
.cluster-table th, .cluster-table td { padding: 5px 10px; text-align: center; border-bottom: 1px solid #dbeafe; }
.cluster-table th { color: #1e40af; font-weight: 700; }
.no-breakdown { font-size: 12px; color: #9ca3af; margin-top: 10px; }
.empty-state { text-align: center; padding: 60px 20px; color: #9ca3af; }
.footer-note { font-size: 12px; color: #9ca3af; margin-top: 20px; line-height: 1.8; }
.footer-note b { color: #6b7280; }
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.metrics-row { grid-template-columns: repeat(3, 1fr); }
}
</style>
</head>
<body>
<div class="container">
<h1>⏱️ API 耗时看板</h1>
<p class="subtitle">按天统计各接口调用次数与耗时分布,多发型接口额外拆解「单次换发型耗时」与「整体耗时」</p>
<div class="nav">
<a href="/">← 返回首页</a>
<a href="/admin/stats">实时请求统计</a>
<a href="/docs">API 文档</a>
</div>
<div class="toolbar">
<label for="dateSelect">选择日期</label>
<select id="dateSelect" onchange="loadDate()"></select>
<button onclick="loadDate()">🔄 刷新</button>
<span id="loadingHint"></span>
</div>
<div id="content"></div>
<div class="footer-note">
<div><b>说明:</b>「整个 API 耗时」为网关实测的请求总耗时(收到请求到返回响应),已排除网络传输本身的额外延迟。</div>
<div><b>单次换发型耗时</b>为估算值:C端生发 / C端生发v2 / 发际线PNG生成 这几个接口,对每个勾选的发型会串行调用一次 ComfyUI 重绘,
总耗时 ≈ 固定开销(人脸检测等,通常 &lt; 1s)+ 单次换发型耗时 × 勾选的发型数量。日志未记录具体勾选了几个发型,
这里基于当天所有请求耗时的分布,用迭代最小二乘回归拆解出「单次换发型耗时」与「固定开销」两个分量,供参考。</div>
</div>
</div>
<script>
function fmtSeconds(ms) {
if (ms == null) return '—';
const s = ms / 1000;
if (s < 60) return s.toFixed(1) + 's';
return (s / 60).toFixed(1) + 'min';
}
function fmtSecondsRaw(s) {
if (s == null) return '—';
if (s < 60) return s.toFixed(1) + 's';
return (s / 60).toFixed(1) + 'min';
}
async function loadDates() {
const r = await fetch('/admin/stats/dates');
const data = await r.json();
const sel = document.getElementById('dateSelect');
sel.innerHTML = '';
if (!data.dates || data.dates.length === 0) {
sel.innerHTML = '<option>暂无数据</option>';
return;
}
data.dates.forEach((d, i) => {
const opt = document.createElement('option');
opt.value = d;
opt.textContent = d + (i === 0 ? '(今天)' : '');
sel.appendChild(opt);
});
loadDate();
}
async function loadDate() {
const date = document.getElementById('dateSelect').value;
if (!date) return;
document.getElementById('loadingHint').textContent = '⏳ 加载中...';
try {
const r = await fetch('/admin/stats/daily?date=' + encodeURIComponent(date));
const data = await r.json();
render(data);
document.getElementById('loadingHint').textContent = '✅ 已更新 ' + new Date().toLocaleTimeString();
} catch (e) {
document.getElementById('loadingHint').textContent = '❌ 加载失败: ' + e.message;
}
}
function render(data) {
const el = document.getElementById('content');
if (data.error) {
el.innerHTML = '<div class="empty-state">⚠ ' + data.error + '</div>';
return;
}
if (!data.summary || data.summary.total === 0) {
el.innerHTML = '<div class="empty-state">📭 ' + data.date + ' 当天没有调用记录</div>';
return;
}
const s = data.summary;
let html = '';
// 汇总卡片
html += '<div class="stats-grid">' +
'<div class="stat-card"><div class="value">' + s.total + '</div><div class="label">当天请求总数</div></div>' +
'<div class="stat-card ok"><div class="value">' + s.success_rate + '%</div><div class="label">成功率</div></div>' +
'<div class="stat-card"><div class="value">' + fmtSeconds(s.avg_duration_ms) + '</div><div class="label">平均耗时/次</div></div>' +
'<div class="stat-card"><div class="value">' + fmtSecondsRaw(s.total_duration_seconds) + '</div><div class="label">累计耗时</div></div>' +
'<div class="stat-card"><div class="value">' + fmtSeconds(s.max_duration_ms) + '</div><div class="label">最长单次耗时</div></div>' +
'</div>';
// 按小时分布
if (data.hourly && data.hourly.length > 0) {
const maxCnt = Math.max(...data.hourly.map(h => h.count));
html += '<div class="hourly-wrap"><h2>📅 按小时请求量分布</h2><div class="hourly-bars">';
data.hourly.forEach(h => {
const pct = Math.max(4, Math.round(h.count / maxCnt * 100));
html += '<div class="hourly-bar" style="height:' + pct + '%"><div class="cnt">' + h.count + '</div></div>';
});
html += '</div><div class="hourly-labels">';
data.hourly.forEach(h => {
html += '<span>' + String(h.hour).padStart(2, '0') + '时</span>';
});
html += '</div></div>';
}
// 按接口
data.endpoints.forEach(ep => {
const badge = ep.interface_num != null ? ('接口' + ep.interface_num) : '未知接口';
html += '<div class="ep-card">' +
'<div class="ep-header">' +
'<span class="ep-badge">' + badge + '</span>' +
'<span class="ep-name">' + ep.interface_name + '</span>' +
'<span class="ep-path">' + ep.path + '</span>' +
'<span class="ep-success">调用 <b>' + ep.count + '</b> 次 · 成功率 <b style="color:' + (ep.success_rate >= 99 ? '#059669' : '#d97706') + '">' + ep.success_rate + '%</b></span>' +
'</div>' +
'<div class="ep-body">' +
'<div class="metrics-row">' +
'<div class="metric"><div class="v">' + fmtSeconds(ep.avg_duration_ms) + '</div><div class="l">平均耗时</div></div>' +
'<div class="metric"><div class="v">' + fmtSeconds(ep.median_duration_ms) + '</div><div class="l">中位数</div></div>' +
'<div class="metric"><div class="v">' + fmtSeconds(ep.p95_duration_ms) + '</div><div class="l">P95</div></div>' +
'<div class="metric"><div class="v">' + fmtSeconds(ep.min_duration_ms) + '</div><div class="l">最快</div></div>' +
'<div class="metric"><div class="v">' + fmtSeconds(ep.max_duration_ms) + '</div><div class="l">最慢</div></div>' +
'<div class="metric"><div class="v">' + fmtSecondsRaw(ep.total_duration_seconds) + '</div><div class="l">累计耗时</div></div>' +
'</div>';
if (ep.per_style) {
const ps = ep.per_style;
html += '<div class="style-box">' +
'<div class="title">✂️ 单次换发型耗时拆解(估算)</div>' +
'<div class="style-metrics">' +
'<div class="sm"><div class="v">' + ps.per_style_seconds.toFixed(1) + 's</div><div class="l">单次换发型耗时</div></div>' +
'<div class="sm"><div class="v">' + ps.fixed_overhead_seconds.toFixed(1) + 's</div><div class="l">固定开销</div></div>' +
'</div>' +
'<div class="formula">整体 API 耗时 ≈ ' + ps.fixed_overhead_seconds.toFixed(1) + 's + ' + ps.per_style_seconds.toFixed(1) + 's × 发型数量</div>' +
'<table class="cluster-table"><thead><tr><th>推断发型数</th><th>样本数</th><th>平均耗时</th></tr></thead><tbody>';
ps.clusters.forEach(c => {
html += '<tr><td>' + c.styles + '</td><td>' + c.count + '</td><td>' + c.avg_seconds.toFixed(1) + 's</td></tr>';
});
html += '</tbody></table></div>';
} else if (['/api/v1/hair/grow', '/api/v1/hair/grow-v2', '/api/v1/hairline/generate'].includes(ep.path)) {
html += '<div class="no-breakdown">️ 当天该接口耗时分布过于集中,样本不足以拆解出单次换发型耗时(换个天试试,或等积累更多调用)</div>';
}
html += '</div></div>';
});
el.innerHTML = html;
}
loadDates();
</script>
</body>
</html>