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 工具
343 lines
9.5 KiB
Go
343 lines
9.5 KiB
Go
//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
|
||
}
|