Files
tamakyi c9ebb3aebd 修性能回归:把「已并入 / 子段」判据做成派生列(visits.is_merged)
昨天给「已并入 / 子段」做的收口写成每行去 entity_raw 判
`raw->>'ZMERGEDTO' IS NOT NULL OR raw->>'ZPARENT' IS NOT NULL`,而 entity_raw.raw 是
**TOAST 过的整行 Core Data JSON**:一次探针要取回并解析整行。地点库「每个地点 × 5 个子查询」
的量级下 —— 实测 /places 70.1 秒、首页 43.6 秒才出页面(而库里没有任何一条 SQL 超过 1 秒:
时间全花在成千上万次「取整行再解析」上,进程 CPU 只用了 3s/135s)。

这批「渲染若干页面」的布局用例因此从 20 秒涨到 226~309 秒,整套验收从 6 分钟变 20+ 分钟。

- schema.sql:加派生列 visits.is_merged + 回填 + 给回填用的部分索引
  (只索引确实带合并标记的那几十行,不用取回 raw)。
- stats.SoloVisitSQL 改成读这一列(判据仍然只此一份,读侧全部经由它)。
- 写入侧跟着刷:ingest 在 insertEntityRaw 之后、backup_merge 的 recountPlaces 里,
  共用 stats.RefreshMergedFlagsSQL。
- 直接改留档里的合并标记(工具脚本 / 用例)之后也要刷一次,schema.sql 与函数注释都写明了。

实测:/places 70084ms → 206ms,/ 43633ms → 698ms(比「关掉判据」的对照还快)。
新增 page_render_budget_test.go:给 7 个关键页卡 10 秒上限的灾难探测器
(功能全对、只是慢的那种退化,之前没有任何用例会红)。
2026-09-30 16:18:52 +08:00

186 lines
7.4 KiB
Go

package web
import (
"encoding/json"
"net/http/httptest"
"testing"
"rond-with-you/internal/stats"
)
// 地图上的城市圈气泡(/api/cities)必须「先按地点聚合、再按城市汇总」。
//
// 旧写法把聚合打在 places ⋈ visits 上,于是每个地点的 visit_count / dwell_minutes
// 会被按它的到访条数重复累加,count(*) 又被当成「地点数」——实测最大那个城市显示
// 「到访 72661 次 · 568 个地点」,真值是 568 次 / 86 个地点。这里按同一套口径手写一遍对账。
func TestCityAggCountsOncePerPlace(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
rec := httptest.NewRecorder()
f.srv.apiCityAgg(rec, httptest.NewRequest("GET", "/api/cities", nil).WithContext(ctx))
if rec.Code != 200 {
t.Fatalf("取城市聚合失败: code=%d body=%s", rec.Code, rec.Body.String())
}
var payload struct {
Cities []struct {
City string `json:"city"`
Visits int `json:"visits"`
Places int `json:"places"`
Dwell int64 `json:"dwell"`
First string `json:"first,omitempty"`
Last string `json:"last,omitempty"`
} `json:"cities"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("解析城市聚合失败: %v", err)
}
if len(payload.Cities) == 0 {
t.Skip("夹具没有带城市的到访")
}
// 手写一份同一口径的对照(判据独立写,不复用 stats.SoloVisitSQL):
// 不含已并入 / 子段,也不含「家 / 工作」地点——与 apiCityAgg 保持一致。
rows, err := pg.QueryContext(ctx, `SELECT p.city, count(*), count(DISTINCT p.id),
COALESCE(sum(LEAST(COALESCE(v.duration_min,0),10080)),0),
to_char(min(v.arrival), 'YYYY-MM-DD'), to_char(max(v.arrival), 'YYYY-MM-DD')
FROM places p JOIN visits v ON v.place_id = p.id AND v.dataset_id = p.dataset_id
WHERE p.dataset_id = $1 AND p.city IS NOT NULL AND p.city <> '' AND p.lat IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM entity_raw er
WHERE er.dataset_id=v.dataset_id AND er.entity='ZVISIT' AND er.src_pk=v.src_pk
AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL))
AND NOT EXISTS (SELECT 1 FROM visits vh WHERE vh.place_id = p.id
AND (vh.is_home OR vh.is_work))
GROUP BY p.city`, dsID)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
type want struct {
visits, places int
dwell int64
first, last string
}
truth := map[string]want{}
for rows.Next() {
var city string
var w want
if err := rows.Scan(&city, &w.visits, &w.places, &w.dwell, &w.first, &w.last); err != nil {
t.Fatal(err)
}
truth[city] = w
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
for _, c := range payload.Cities {
w, ok := truth[c.City]
if !ok {
t.Errorf("城市「%s」不该出现(手写 SQL 里没有)", c.City)
continue
}
if c.Visits != w.visits || c.Places != w.places || c.Dwell != w.dwell {
t.Errorf("城市「%s」聚合对不上:到访 %d/%d、地点 %d/%d、停留 %d/%d",
c.City, c.Visits, w.visits, c.Places, w.places, c.Dwell, w.dwell)
}
// 首末到访必须按「可见到访的到达时间」算,不能拿 places 的全量列值
if c.First != w.first || c.Last != w.last {
t.Errorf("城市「%s」首末到访对不上:%s~%s,应为 %s~%s(按可见到访算)",
c.City, c.First, c.Last, w.first, w.last)
}
}
// 保底的不变量:地点数不可能多于到访数(旧写法里这两个数就换位了)
for _, c := range payload.Cities {
if c.Places > c.Visits {
t.Errorf("城市「%s」的地点数 %d 多于到访数 %d,说明又按到访条数重复累加了", c.City, c.Places, c.Visits)
}
}
// 把某个地点**最晚的那次到访**标成「已并入」:该城市的最近到访必须跟着退到更早那条。
// 这一条专治「偷懒直接取 places.last_visit_at」——那是全量列值,不会跟着退。
// (光比对相同口径还不够:被并入的行大多落在父段区间内,两种算法在夹具数据上结果一样,
// 不做这一步就是一条假绿的断言。)
var placeID int64
var latestVisit int
if err := pg.QueryRowContext(ctx, `SELECT p.id, v.src_pk
FROM visits v JOIN places p ON p.id = v.place_id
WHERE v.dataset_id=$1 AND p.city IS NOT NULL AND p.city <> '' AND p.lat IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM visits vh WHERE vh.place_id = p.id AND (vh.is_home OR vh.is_work))
AND (SELECT count(*) FROM visits v2 WHERE v2.place_id = p.id) >= 2
ORDER BY v.arrival DESC LIMIT 1`, dsID).Scan(&placeID, &latestVisit); err != nil {
t.Skipf("挑不出「同一地点有多条到访」的城市:%v", err)
}
var cityName string
if err := pg.QueryRowContext(ctx, `SELECT city FROM places WHERE id=$1`, placeID).Scan(&cityName); err != nil {
t.Fatal(err)
}
lastBefore := ""
lastAfterWant := ""
for _, c := range payload.Cities {
if c.City == cityName {
lastBefore = c.Last
}
}
// 真值(按城市取,标记前后各来一次):与上面那条对账同一套口径,只是限定到某个城市
truthLast := func() string {
var s string
if err := pg.QueryRowContext(ctx, `SELECT to_char(max(v.arrival),'YYYY-MM-DD')
FROM places p JOIN visits v ON v.place_id = p.id AND v.dataset_id = p.dataset_id
WHERE p.dataset_id = $1 AND p.city = $2 AND p.city <> '' AND p.lat IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM entity_raw er
WHERE er.dataset_id=v.dataset_id AND er.entity='ZVISIT' AND er.src_pk=v.src_pk
AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL))
AND NOT EXISTS (SELECT 1 FROM visits vh WHERE vh.place_id = p.id
AND (vh.is_home OR vh.is_work))`, dsID, cityName).Scan(&s); err != nil {
t.Fatal(err)
}
return s
}
lastBeforeWant := truthLast()
if lastBeforeWant != lastBefore {
t.Fatalf("标记前该城市的最近到访就对不上(%s vs %s),用例前提不成立", lastBefore, lastBeforeWant)
}
if _, err := pg.ExecContext(ctx, `UPDATE entity_raw
SET raw = jsonb_set(raw, '{ZMERGEDTO}', to_jsonb($3::int), true)
WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, dsID, latestVisit, latestVisit); err != nil {
t.Fatal(err)
}
// 读侧取的是派生列 visits.is_merged,直接改留档之后要按生产路径刷一次
if _, err := pg.ExecContext(ctx, stats.RefreshMergedFlagsSQL, dsID); err != nil {
t.Fatal(err)
}
lastAfterWant = truthLast()
if lastAfterWant == lastBeforeWant {
t.Skipf("城市「%s」里最晚那次不是唯一的最晚记录,换一个再测", cityName)
}
rec2 := httptest.NewRecorder()
f.srv.apiCityAgg(rec2, httptest.NewRequest("GET", "/api/cities", nil).WithContext(ctx))
if rec2.Code != 200 {
t.Fatalf("取城市聚合失败: code=%d", rec2.Code)
}
var after struct {
Cities []struct {
City string `json:"city"`
Last string `json:"last,omitempty"`
} `json:"cities"`
}
if err := json.Unmarshal(rec2.Body.Bytes(), &after); err != nil {
t.Fatalf("解析城市聚合失败: %v", err)
}
gotLast := ""
for _, c := range after.Cities {
if c.City == cityName {
gotLast = c.Last
}
}
if gotLast == lastBeforeWant {
t.Errorf("把 #%d 标成已并入后,城市「%s」的最近到访仍是 %s(应退到 %s)—— 说明取的是 places 的全量列值而不是可见到访",
latestVisit, cityName, gotLast, lastAfterWant)
}
if gotLast != lastAfterWant {
t.Errorf("城市「%s」的最近到访应为 %s,实际 %s", cityName, lastAfterWant, gotLast)
}
}