perf(memory): Introspect 507ms→11ms、ListPersons 130ms→3ms

继续清「非常卡」。上次修完 pulse 端点后,`/kernel` 与 `/runtime` 仍各约
600ms(每 15s 一次)。这次**逐项量**而不是猜(临时探针量各子收集器):

    memDB.Introspect      507ms   ← 主凶
    social.ListPersons    129ms
    textMem.FileCount     0.1ms
    docStore.Stats        0.0ms
    knowledge.List/Stats  0.0ms

## ① Introspect 507ms → 11ms(43×)

hotspots 的度数写成**相关标量子查询**:

    COALESCE((SELECT COUNT(*) FROM memory_block_edges e
               WHERE (e.source_id = b.id OR e.target_id = b.id) ...), 0)

EXPLAIN 是 `SCAN b`(3364 块)→ `CORRELATED SCALAR SUBQUERY` → `SCAN e`
(2829 边),即 **3364 × 2829 ≈ 950 万次行扫描**,且 ORDER BY 前必须算完
全部块的度数。

改成边表按端点 UNION ALL + GROUP BY 聚合一次,再 LEFT JOIN 回块表。
边表只扫两遍而不是 3364 遍。**新旧查询输出逐字节一致**(已 diff 验证)。

## ② ListPersons 130ms → 3ms(38×)

它走 `Recall(nil, nil, 1, "")`,而那条 full-recall 路径为了填
`result.Entities` 会加载**全部块(blockColumns 含稠密向量)**:

    Recall 全程 130ms:实体 3266 个、关系 30 条

即为了 30 条关系反序列化了 3266 个块的向量,而 ListPersons
**只看 result.Relations**。

新增 `GraphDB.RecentRelations(limit)`:与 full-recall 分支里的关系子查询
**逐字对齐**(同一 WHERE/ORDER/LIMIT,含 trait 边——过滤掉它会让特质
主体漏出人物表)。实测该查询单独 4ms。

等价性验证:新旧路径的人物列表 + 关系集**逐字节 diff 完全一致**
(56 人、同样的边)。旧路径输出是先 git stash 采样的,不是凭记忆比对。

## 验证

- `TestIntrospect_HotspotsQueryIsNotCorrelated`(源码形状)+
  `TestIntrospect_LargeGraphIsFast`(2000 块/1500 边的耗时门)。
  **两者各自独立可杀变异**(实测):
  - 源码断言:退回相关子查询 → FAIL「必须用一次聚合」
  - 耗时门:同变异 → FAIL「149ms 超过 60ms 门槛」(修复后 11ms)

  为何源码断言要锚在返回值行而非 `Index`:注释里也提到 memory_hotspots,
  用 Index 会截到注释处、取到的片段根本没有查询——第一版就这么写错了,
  自己先红了一次才发现。
- 全仓 `go test ./... -count=1` → 48 包 ok / 0 FAIL
This commit is contained in:
JianFeeeee
2026-10-08 16:41:03 +08:00
parent aaf833a687
commit df865518e6
3 changed files with 235 additions and 6 deletions

View File

@ -0,0 +1,129 @@
package memory
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
func readSource(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// Introspect 曾是内核状态页最大的单点开销(2026-10-08 修)。
//
// 生产实测(3364 块 / 2829 边):**507ms**。
// 根因:hotspots 的度数写成**相关标量子查询**——对每个块执行一次
//
// SELECT COUNT(*) FROM memory_block_edges
// WHERE source_id = b.id OR target_id = b.id
//
// EXPLAIN 是 SCAN b → CORRELATED SCALAR SUBQUERY → SCAN e,
// 即 3364 × 2829 ≈ **950 万次行扫描**,且 ORDER BY 前必须算完全部块的度数。
//
// 修法:边表按端点 UNION ALL + GROUP BY 聚合一次,再 LEFT JOIN 回块表。
// 实测同数据 **507ms → 11ms**,且新旧输出逐字节一致。
//
// 为何用源码断言兜住「形状」而不只靠耗时:小库上两种写法都快,
// 只有大库才暴露;而形状(有没有相关子查询)是**静态可判定**的,
// 回归时立刻红,不必等库长大才发现。
func TestIntrospect_HotspotsQueryIsNotCorrelated(t *testing.T) {
src := readSource(t, "graph.go")
// 定位 hotspots 那条查询:用其返回值行的**最后**一次出现(前面的注释里
// 也提到 memory_hotspots,用 Index 会截到注释处,取到的片段里根本没有查询)。
i := strings.LastIndex(src, `"memory_hotspots": hotspots`)
if i < 0 {
t.Fatal("找不到 memory_hotspots 返回值")
}
seg := src[:i]
q := strings.LastIndex(seg, "g.db.Query(")
if q < 0 {
t.Fatal("找不到 hotspots 查询")
}
// 从查询起点到返回值:这段里就应当只有 hotspots 那一条 SELECT。
query := seg[q:]
if len(query) > 4000 {
t.Fatalf("提取的查询片段异常长(%d),定位可能错了", len(query))
}
// ① 度数必须来自**预聚合**(GROUP BY),不是逐行子查询。
if !strings.Contains(query, "GROUP BY id") {
t.Error("hotspots 度数必须用一次聚合(GROUP BY)算出,不能是相关子查询:\n" +
" 相关子查询 = 每块一次边表扫描 = 3364 × 2829 ≈ 950 万次行访问\n" +
" 实测 Introspect 507ms → 11ms 就是这一处的差别")
}
// ② 明确禁止那个形态:括号里直接嵌 COUNT(*) 子查询。
if strings.Contains(query, "COALESCE((SELECT COUNT(*) FROM memory_block_edges e") {
t.Error("hotspots 又退回相关子查询了(COALESCE((SELECT COUNT(*) ... WHERE (e.source_id = b.id OR ...))")
}
// ③ 聚合形态必须双边都算(source 与 target 都是端点)。
if !strings.Contains(query, "SELECT source_id AS id") ||
!strings.Contains(query, "SELECT target_id AS id") {
t.Error("度数聚合必须同时含 source_id 与 target_id 两个端点,缺一个就会漏度")
}
}
// TestIntrospect_LargeGraphIsFast 用真实规模的库钉住耗时上限。
//
// 源码断言只能守形状;这条守「形状对了但仍慢」的情况(比如聚合子查询里
// 又嵌了别的全表扫)。规模按生产量级造(2000 块 / 1500 边),
// 上限留足余量但足以抓住相关子查询那档(2000×1500 ≈ 300 万次访问)。
func TestIntrospect_LargeGraphIsFast(t *testing.T) {
g := newPerfGraph(t, 2000, 1500)
start := time.Now()
if _, err := g.Introspect(); err != nil {
t.Fatal(err)
}
elapsed := time.Since(start)
// 相关子查询版在这个规模约 250-500ms;聚合版 < 20ms。
// 60ms 的门槛既能抓住退化,又不会因 CI 机器慢而误报。
if elapsed > 60*time.Millisecond {
t.Errorf("Introspect 在 2000 块/1500 边上耗时 %v,超过 60ms 门槛。\n"+
" 相关子查询版本会落到几百毫秒;若本条变红,检查 hotspots 是否退回逐块计数。",
elapsed)
}
}
// newPerfGraph 造一个指定规模的图:nBlocks 个块、nEdges 条边(互相引用)。
func newPerfGraph(t *testing.T, nBlocks, nEdges int) *GraphDB {
t.Helper()
g, err := NewGraphDB(filepath.Join(t.TempDir(), "perf.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { g.Close() })
ids := make([]string, 0, nBlocks)
for i := 0; i < nBlocks; i++ {
id := "blk_" + strconv.Itoa(i)
ids = append(ids, id)
if err := g.PutMemoryBlocks([]MemoryBlock{{
ID: id, Modality: BlockText, Text: "实体 " + strconv.Itoa(i),
Source: "triple", SemanticType: "Concept",
}}); err != nil {
t.Fatal(err)
}
}
for i := 0; i < nEdges; i++ {
a := ids[i%len(ids)]
b := ids[(i*7+3)%len(ids)]
if a == b {
continue
}
if err := g.AddMemoryBlockEdge("block", a, "block", b, "关联"); err != nil {
t.Fatal(err)
}
}
return g
}