perf(embedding,memory): 批上限 4→8 削掉请求放大,审查预算 20s→90s 覆盖实测链长
实测(2026-10-13,本机空闲,活库 cyrene/user 分区 201 条、均长 34.7 汉字): - 一条入库审查召回链 = 51 个「4 条」请求 = 23.070 / 21.860 s;旧审查预算 20s 比链长还短 ⇒ 每一条候选满额的审查都超时,且超时后 **一行留痕都不写** (7 天里 92.1% 的审查 recall_mode 为空、2274 条超时降级的根因) - 同一条链改成「8 条」= 26 个请求 = 21.435 / 21.597 s:请求数减半,墙钟几乎不变 —— 链路耗时由「条数 × 每条成本」主导,批次只改请求数 - 「16 条」在**真实代码路径**上 3 次里 2 次让单请求越过 5s 单次超时(1.818 / 5.016 超时 / 5.001 超时)⇒ 批上限不越过 8;隔离测量里 12 条 0.996~1.017 s 仍在界内 改动: - embedding: perItemBudget 500ms→300ms(实测摊销 70~107ms/条的约 3 倍) ⇒ DefaultBatchLimit 由 (5s/2−100ms)/300ms = **8**,同一套推演、两维都成立 (条数 100ms+8×300ms=2.5s;字符 100ms+480×5ms=2.5s,均在超时的一半内) - embedding: 新增 maxSafeBatchLimit=12(实测外沿)+ 编译期断言(越界即 uint 溢出、 编译失败),与 tidy_config.go 那条同手法 - memory: defaultReviewTimeout 20s→**90s**(> 实测最坏链:空闲 21.6s⇒4.2 倍余量、 与整理链并发 56.7s + T1 8s ⇒ 1.4 倍;不再大的理由=单 worker 有界队列 256 的 最坏积压 256×90s≈6.4h、服务真挂死时第一片 5s 就降级用不到这条预算) 测试(不留过时断言):budget_test.go 加「钉值 8/480 + 两维不等式 + ≤12 外沿 + 16 条必须仍判越界」四层;api_url_test.go 的 reviewBudgetRef 20s→90s; preinsert_recall_test.go 新增跨包断言「预算 > 实测最坏链 + defaultT1Timeout」。 变异自证:perItemBudget 回 500ms ⇒ 钉值断言红;reviewTimeout 回 20s ⇒ 跨包断言红; perItemBudget 150ms(⇒16)⇒ 编译失败(constant -4 overflows uint)。三者均已逐字还原。 遗留(如实登记,未修):① 整理轮每轮重嵌整个分区(user 一轮仍 250 个请求), 解法方向是复用已入库的 vec_items 或给整理轮一个 embedding 请求预算;② 链路对单点 慢分片没有韧性 —— 一个分片失败即整条 Embed 失败、丢掉其余分片的工作量,且实测 任何批次形状都会偶发 >5s 尖峰(4 条 7.139s / 8 条 9.711s,与批次无关),方向是 重试/分片级容错,调常量解决不了。
This commit is contained in:
1 parent
20051f0168
commit
f3fb097630
8 files changed
+280
-53
No files matched your search
@@ -19,16 +19,21 @@ import (
|
||||
// - 「批内耗时 < 超时」⇒ 不降级:DerivedBatchCompletesWithinTimeout;
|
||||
// - 「批内耗时 ≥ 超时」⇒ 超时(本次故障形态):LegacyBatchOf64TimesOut;
|
||||
//
|
||||
// 以及**变异自证**:把批上限调回旧值 64(或把推演调松)会让哪条测试红,
|
||||
// 见 TestBatchBudget_InvariantHolds 与 TestAPIEmbedder_LegacyBatchOf64TimesOut。
|
||||
// 以及**变异自证**:把批上限调回旧值(4 或 64)会让哪条测试红,见
|
||||
// TestBatchBudget_InvariantHolds(钉值 + 两层上界)与
|
||||
// TestAPIEmbedder_LegacyBatchOf64TimesOut。
|
||||
//
|
||||
// 假客户端用的是**实测曲线**(不是随口编的常数):单次请求耗时
|
||||
// ≈ measuredOverhead + 字符总量 × measuredPerChar,取值见下面两个常量
|
||||
// (2026-10-13 本机实测:固定开销 ~50ms、每字符 2.7~3.9ms,此处取上界)。
|
||||
// (2026-10-13 本机实测:固定开销 ~50ms、每字符 3.4ms,此处取上界)。
|
||||
const (
|
||||
measuredOverhead = 50 * time.Millisecond
|
||||
measuredPerChar = 4 * time.Millisecond
|
||||
latencyModelChars = 33 // 活库短正文的平均长度(实测 33 汉字)
|
||||
latencyModelChars = 33 // 活库短正文的平均长度(实测 33~35 汉字)
|
||||
// measuredPathologicalItems 是**实测**已经出界的批次形状(文件头:单请求
|
||||
// 11.043s > 5s 超时)。它只用于说明「上界不该往哪边放」,不是断言对象 ——
|
||||
// 断言对象是推出来的 DefaultBatchLimit 与它必须落在的 maxSafeBatchLimit 之内。
|
||||
measuredPathologicalItems = 16
|
||||
)
|
||||
|
||||
// latencyTripper 是一个按上面那条曲线**真的睡够时间**的 RoundTripper:
|
||||
@@ -115,7 +120,18 @@ func shortTexts(n int) []string {
|
||||
// TestBatchBudget_InvariantHolds 是**推演本身**的断言(不连任何网络):
|
||||
// 由超时推出来的一对上限,必须使「一次请求的耗时上界 ≤ 超时的一半」成立。
|
||||
//
|
||||
// 变异自证:把 DefaultBatchLimit 的推演改成旧口径(条数写回 64)⇒ 第一条断言红;
|
||||
// 四层,各自的语义不同(改坏任一层都必须是红的):
|
||||
//
|
||||
// 1. **钉值**:推出来的条数上限必须是 8、字符上限必须是 480。它是「常量变了必须
|
||||
// 连带复核链长/病态区」的那根 tripwire —— 例如把 perItemBudget 调回 500ms
|
||||
// (⇒ 4)或 200ms(⇒ 12)都会让这一条红,而不是静默改掉整条链的请求数。
|
||||
// 2. **不变式(条数维)**:requestOverhead + 批次 × perItemBudget ≤ 超时的一半。
|
||||
// 3. **不变式(字符维)**:requestOverhead + 字符上限 × perCharBudget ≤ 超时的一半。
|
||||
// 4. **实测外沿**:条数上限必须 ≤ maxSafeBatchLimit(12),且**实测已知出界的形状**
|
||||
// (16 条/片,单请求 11.043s)在推演下必须仍然判为越界 —— 即推演不许松到
|
||||
// 把 16 条当成一个批次。
|
||||
//
|
||||
// 变异自证:把 DefaultBatchLimit 的推演改成旧口径(条数写回 64)⇒ 第 1 层红;
|
||||
// 若改成「把超时放大到盖住 64 条」⇒ 最后一条断言红(旧配对必须仍是越界的)。
|
||||
func TestBatchBudget_InvariantHolds(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -123,19 +139,42 @@ func TestBatchBudget_InvariantHolds(t *testing.T) {
|
||||
if DefaultBatchLimit < 1 || DefaultBatchChars < 1 {
|
||||
t.Fatalf("推出来的上限必须 ≥ 1:条数=%d 字符=%d", DefaultBatchLimit, DefaultBatchChars)
|
||||
}
|
||||
// 1. 钉值:值变了就必须连带复核「审查链请求数」与「病态区上界」两件事。
|
||||
if DefaultBatchLimit != 8 {
|
||||
t.Fatalf("DefaultBatchLimit = %d, want 8(值变了要连带复核审查链请求数 ⌈201/批次⌉ 与 maxSafeBatchLimit)",
|
||||
DefaultBatchLimit)
|
||||
}
|
||||
if DefaultBatchChars != 480 {
|
||||
t.Fatalf("DefaultBatchChars = %d, want 480(字符维本次刻意未动;改了要复核长正文分片)",
|
||||
DefaultBatchChars)
|
||||
}
|
||||
share := DefaultRequestTimeout / callBudgetShare
|
||||
// 2. 条数维:批次上限 × 每条预算 + 固定开销 ≤ 超时的一半。
|
||||
byItems := requestOverhead + time.Duration(DefaultBatchLimit)*perItemBudget
|
||||
if byItems > share {
|
||||
t.Fatalf("条数上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
|
||||
DefaultBatchLimit, byItems, share)
|
||||
}
|
||||
// 3. 字符维:同一个不等式换成每字符预算。
|
||||
byChars := requestOverhead + time.Duration(DefaultBatchChars)*perCharBudget
|
||||
if byChars > share {
|
||||
t.Fatalf("字符上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
|
||||
DefaultBatchChars, byChars, share)
|
||||
}
|
||||
// 4. 实测外沿:条数上限不得越过实测划出来的那条线(12),而且**实测已出界的
|
||||
// 形状**(16 条/片)在推演下必须仍是越界的 —— 否则说明推演松到把已知的病态
|
||||
// 形状也当成合法批次了。
|
||||
if DefaultBatchLimit > maxSafeBatchLimit {
|
||||
t.Fatalf("条数上限 %d 越过实测外沿 %d(16 条/片实测单请求 11.043s > 单次超时 %s)",
|
||||
DefaultBatchLimit, maxSafeBatchLimit, DefaultRequestTimeout)
|
||||
}
|
||||
if pathological := requestOverhead + time.Duration(measuredPathologicalItems)*perItemBudget; pathological <= share {
|
||||
t.Fatalf("%d 条/片的耗时上界 %s 竟落在超时的一半 %s 之内 —— "+
|
||||
"推演已经松到把这个实测出界的形状当成合法批次了",
|
||||
measuredPathologicalItems, pathological, share)
|
||||
}
|
||||
// 旧配对(批次 64 + 5s 超时)必须仍被判为越界 —— 那正是 2026-10-13 的故障形态:
|
||||
// 实测 64 条 7.5~8.5s > 5s。这里用同一套预算常量复核它仍然出界。
|
||||
// 实测 64 条 6.872s > 5s。这里用同一套预算常量复核它仍然出界。
|
||||
legacy := requestOverhead + 64*perItemBudget
|
||||
if legacy <= DefaultRequestTimeout {
|
||||
t.Fatalf("旧配对(64 条)的耗时上界 %s 竟然落在单次超时 %s 之内 —— "+
|
||||
|
||||
Reference in new issue
Block a user