perf: IoT 卡 30M 行分页查询优化(P95 17.9s → <500ms)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m6s

- 新增 is_standalone 物化列 + 触发器自动维护(迁移 056)
- 并行查询拆分:多店铺 IN 查询拆为 per-shop goroutine 并行 Index Scan
- 两阶段延迟 Join:深度分页(page≥50)走覆盖索引 Index Only Scan 取 ID 再回表
- COUNT 缓存:per-shop 并行 COUNT + Redis 30 分钟 TTL
- 索引优化:删除有害全局索引、新增 partial composite indexes(迁移 057/058)
- ICCID 模糊搜索路径隔离:trigram GIN 索引走独立查询路径
- 慢查询阈值从 100ms 调整为 500ms
- 新增 30M 测试数据种子脚本和 benchmark 工具
This commit is contained in:
2026-02-24 16:23:02 +08:00
parent c665f32976
commit f32d32cd36
20 changed files with 2705 additions and 50 deletions

342
scripts/perf_query/bench.go Normal file
View File

@@ -0,0 +1,342 @@
//go:build ignore
// IoT 卡分页查询性能基准测试
// 用法:
// go run ./scripts/perf_query/bench.go
// go run ./scripts/perf_query/bench.go -base-url http://localhost:3000 -n 20
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"sort"
"strings"
"time"
)
var (
baseURL = flag.String("base-url", "", "API 服务地址 (默认读取 JUNHONG_SERVER_ADDRESS 环境变量)")
username = flag.String("username", "perf_test_agent", "登录用户名")
password = flag.String("password", "PerfTest@123456", "登录密码")
n = flag.Int("n", 20, "每个场景执行次数")
warmup = flag.Int("warmup", 3, "预热请求次数")
)
type apiResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
type loginData struct {
AccessToken string `json:"access_token"`
}
type listData struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type scenario struct {
Name string
Path string
Params string
}
func main() {
flag.Parse()
if *baseURL == "" {
addr := os.Getenv("JUNHONG_SERVER_ADDRESS")
if addr == "" {
addr = ":3000"
}
if strings.HasPrefix(addr, ":") {
addr = "http://localhost" + addr
}
*baseURL = addr
}
fmt.Println("╔══════════════════════════════════════════════════════════════╗")
fmt.Println("║ IoT 卡分页查询性能基准测试 ║")
fmt.Println("╚══════════════════════════════════════════════════════════════╝")
fmt.Printf(" 服务地址: %s\n", *baseURL)
fmt.Printf(" 用户: %s\n", *username)
fmt.Printf(" 每场景次数: %d (预热 %d 次)\n", *n, *warmup)
fmt.Println()
token := login()
fmt.Printf("✅ 登录成功 (token: %s...)\n\n", token[:20])
scenarios := []scenario{
{
Name: "A: 无过滤分页 (第1页)",
Path: "/api/admin/iot-cards/standalone",
Params: "page=1&page_size=20",
},
{
Name: "B: status+carrier 过滤",
Path: "/api/admin/iot-cards/standalone",
Params: "page=1&page_size=20&status=3&carrier_id=2",
},
{
Name: "C: 深分页 (第500页)",
Path: "/api/admin/iot-cards/standalone",
Params: "page=500&page_size=20",
},
{
Name: "D: ICCID 模糊搜索",
Path: "/api/admin/iot-cards/standalone",
Params: "page=1&page_size=20&iccid=12345",
},
{
Name: "E: 批次号精确过滤",
Path: "/api/admin/iot-cards/standalone",
Params: "page=1&page_size=20&batch_no=PERF-TEST-30M",
},
{
Name: "F: 运营商+状态+分页",
Path: "/api/admin/iot-cards/standalone",
Params: "page=10&page_size=50&status=1&carrier_id=1",
},
}
results := make([]benchResult, 0, len(scenarios))
for _, sc := range scenarios {
r := runScenario(token, sc)
results = append(results, r)
}
printSummary(results)
}
type benchResult struct {
Name string
Total int64
Timings []time.Duration
Min time.Duration
Max time.Duration
Avg time.Duration
P50 time.Duration
P95 time.Duration
P99 time.Duration
Pass bool
ErrorMsg string
}
func runScenario(token string, sc scenario) benchResult {
fmt.Printf("🔄 %s\n", sc.Name)
fmt.Printf(" URL: %s?%s\n", sc.Path, sc.Params)
url := fmt.Sprintf("%s%s?%s", *baseURL, sc.Path, sc.Params)
result := benchResult{Name: sc.Name}
for i := 0; i < *warmup; i++ {
dur, _, err := doRequest(url, token)
if err != nil {
result.ErrorMsg = fmt.Sprintf("预热失败: %v", err)
fmt.Printf(" ❌ %s\n\n", result.ErrorMsg)
return result
}
fmt.Printf(" 预热 %d/%d: %v\n", i+1, *warmup, dur.Round(time.Millisecond))
}
timings := make([]time.Duration, 0, *n)
var totalCount int64
for i := 0; i < *n; i++ {
dur, data, err := doRequest(url, token)
if err != nil {
result.ErrorMsg = fmt.Sprintf("第 %d 次请求失败: %v", i+1, err)
fmt.Printf(" ❌ %s\n\n", result.ErrorMsg)
return result
}
timings = append(timings, dur)
if i == 0 {
totalCount = data.Total
}
if (i+1)%5 == 0 || i == 0 {
fmt.Printf(" [%d/%d] %v\n", i+1, *n, dur.Round(time.Millisecond))
}
}
result.Total = totalCount
result.Timings = timings
result.Pass = true
calcStats(&result)
passStr := "✅"
if result.P95 > 1500*time.Millisecond {
passStr = "❌"
result.Pass = false
} else if result.P95 > 1000*time.Millisecond {
passStr = "⚠️"
}
fmt.Printf(" 匹配行数: %d\n", result.Total)
fmt.Printf(" %s P50=%v P95=%v P99=%v (min=%v max=%v)\n\n",
passStr, result.P50, result.P95, result.P99, result.Min, result.Max)
return result
}
func doRequest(url, token string) (time.Duration, *listData, error) {
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+token)
start := time.Now()
resp, err := http.DefaultClient.Do(req)
dur := time.Since(start)
if err != nil {
return dur, nil, fmt.Errorf("HTTP 请求失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return dur, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body[:min(len(body), 200)]))
}
var apiResp apiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return dur, nil, fmt.Errorf("JSON 解析失败: %w", err)
}
if apiResp.Code != 0 {
return dur, nil, fmt.Errorf("API 错误 code=%d: %s", apiResp.Code, apiResp.Msg)
}
var data listData
json.Unmarshal(apiResp.Data, &data)
return dur, &data, nil
}
func login() string {
url := *baseURL + "/api/auth/login"
payload := fmt.Sprintf(`{"username":"%s","password":"%s","device":"web"}`, *username, *password)
resp, err := http.Post(url, "application/json", strings.NewReader(payload))
if err != nil {
log.Fatalf("❌ 登录请求失败: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
log.Fatalf("❌ 登录 HTTP %d: %s", resp.StatusCode, string(body[:min(len(body), 500)]))
}
var apiResp apiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
log.Fatalf("❌ 登录响应解析失败: %v", err)
}
if apiResp.Code != 0 {
log.Fatalf("❌ 登录失败 code=%d: %s", apiResp.Code, apiResp.Msg)
}
var data loginData
if err := json.Unmarshal(apiResp.Data, &data); err != nil {
log.Fatalf("❌ 解析 token 失败: %v", err)
}
if data.AccessToken == "" {
log.Fatalf("❌ 登录成功但 token 为空")
}
return data.AccessToken
}
func calcStats(r *benchResult) {
sorted := make([]time.Duration, len(r.Timings))
copy(sorted, r.Timings)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
var sum time.Duration
for _, d := range sorted {
sum += d
}
n := len(sorted)
r.Min = sorted[0]
r.Max = sorted[n-1]
r.Avg = sum / time.Duration(n)
r.P50 = percentile(sorted, 50)
r.P95 = percentile(sorted, 95)
r.P99 = percentile(sorted, 99)
}
func percentile(sorted []time.Duration, pct int) time.Duration {
if len(sorted) == 0 {
return 0
}
idx := (pct * len(sorted)) / 100
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return sorted[idx]
}
func printSummary(results []benchResult) {
fmt.Println("╔══════════════════════════════════════════════════════════════════════════════╗")
fmt.Println("║ 测试结果汇总 ║")
fmt.Println("╠══════════════════════════════════════════════════════════════════════════════╣")
fmt.Printf("║ %-32s │ %8s │ %8s │ %8s │ %8s ║\n", "场景", "匹配行数", "P50", "P95", "结果")
fmt.Println("╠══════════════════════════════════════════════════════════════════════════════╣")
allPass := true
for _, r := range results {
status := "✅ PASS"
if r.ErrorMsg != "" {
status = "❌ ERROR"
allPass = false
} else if !r.Pass {
status = "❌ FAIL"
allPass = false
} else if r.P95 > 1000*time.Millisecond {
status = "⚠️ SLOW"
}
p50Str := "-"
p95Str := "-"
totalStr := "-"
if r.ErrorMsg == "" {
p50Str = r.P50.Round(time.Millisecond).String()
p95Str = r.P95.Round(time.Millisecond).String()
totalStr = fmt.Sprintf("%d", r.Total)
}
fmt.Printf("║ %-32s │ %8s │ %8s │ %8s │ %8s ║\n",
r.Name, totalStr, p50Str, p95Str, status)
}
fmt.Println("╚══════════════════════════════════════════════════════════════════════════════╝")
if allPass {
fmt.Println("\n🎉 所有场景 P95 < 1.5s,满足性能要求!")
} else {
fmt.Println("\n⚠ 部分场景未达标,建议:")
fmt.Println(" 1. 运行 go run ./scripts/perf_query/seed.go -action add-index 创建优化索引")
fmt.Println(" 2. 重新运行本测试对比效果")
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}