先固定口径:本章没有连接 PostgreSQL
这一章不声称跑过 PostgreSQL 18.4。本节没有启动数据库,也没有执行 EXPLAIN;文中出现的执行计划、选择性和写放大数字,要么来自自制 fixture,要么明确标注为模型估算。慢查询解析器的输入是一段按 pg_stat_statements 视图列排布的文本,不是线上日志文件,也不是从某个版本导出的真实快照。
这样做是为了让结论可复现:fixture 只有 5 行,测试是确定性的,换一台机器结果相同。真实数据库的 mean_exec_time、rows 和计划会随版本、统计信息、缓存和并发变化,不能把 fixture 的数值当容量证据。站内 性能与可伸缩性
讨论过整体方法,本节只处理慢 SQL 到索引决策这一段。
慢查询解析器:先把列口径写死
pg_stat_statements 的 rows 是“该语句所有调用累计返回的行数”,不是每次调用返回的行数;mean_exec_time 才是每次调用的均值。把这两个口径混用,会得到错误的每条查询选择性和错误的优化排序。解析器只做三件事:按制表符切列、跳过 # 注释、把四列数字解析成强类型;查询文本保留原样,供后面的形状提取使用。
// file: internal/perf/slowquery/slowquery.go
// tier: L1
// verify: go test ./internal/perf/slowquery/ -count=1 -v
package slowquery
import (
"encoding/csv"
"errors"
"io"
"regexp"
"sort"
"strconv"
"strings"
)
// Entry mirrors the five pg_stat_statements columns this heuristic needs.
// Rows is the total across calls, not rows per call.
type Entry struct {
QueryID int64
Calls int64
MeanExecMs float64
Rows int64
Query string
}
func (e Entry) TotalMs() float64 { return float64(e.Calls) * e.MeanExecMs }
func (e Entry) RowsPerCall() float64 {
if e.Calls == 0 {
return 0
}
return float64(e.Rows) / float64(e.Calls)
}
// Parse reads tab-separated, pg_stat_statements-like rows. A leading # marks
// a fixture comment; quoted fields are accepted.
func Parse(r io.Reader) ([]Entry, error) {
cr := csv.NewReader(r)
cr.Comma, cr.LazyQuotes, cr.FieldsPerRecord = '\t', true, -1
var out []Entry
header := false
for {
rec, err := cr.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
if len(rec) == 0 || strings.HasPrefix(strings.TrimSpace(rec[0]), "#") {
continue
}
if !header {
header = len(rec) >= 5 && strings.TrimSpace(rec[0]) == "queryid"
if !header {
return nil, errors.New("slowquery: bad header")
}
continue
}
if len(rec) < 5 {
return nil, errors.New("slowquery: short row")
}
id, e1 := strconv.ParseInt(strings.TrimSpace(rec[0]), 10, 64)
calls, e2 := strconv.ParseInt(strings.TrimSpace(rec[1]), 10, 64)
mean, e3 := strconv.ParseFloat(strings.TrimSpace(rec[2]), 64)
rows, e4 := strconv.ParseInt(strings.TrimSpace(rec[3]), 10, 64)
if err := errors.Join(e1, e2, e3, e4); err != nil {
return nil, err
}
out = append(out, Entry{id, calls, mean, rows, strings.TrimSpace(rec[4])})
}
if !header {
return nil, errors.New("slowquery: empty input")
}
return out, nil
}
// Rank orders by total execution time, not mean: 3ms x 40k calls costs more
// than 150ms x 800 calls.
func Rank(entries []Entry) []Entry {
out := append([]Entry(nil), entries...)
sort.SliceStable(out, func(i, j int) bool {
ti, tj := out[i].TotalMs(), out[j].TotalMs()
if ti == tj {
return out[i].QueryID < out[j].QueryID
}
return ti > tj
})
return out
}
// ShapeOf is a heuristic over normalized SQL, not a parser: OR, BETWEEN and
// CTEs are out of scope and need human review.
var (
reTable = regexp.MustCompile(`(?i)\bfrom\s+([a-z_][a-z0-9_]*)`)
reWhere = regexp.MustCompile(`(?i)\bwhere\s+(.*?)(?:\bgroup\s+by\b|\border\s+by\b|\blimit\b|;|$)`)
reOrder = regexp.MustCompile(`(?i)\border\s+by\s+([^;]*?)(?:\blimit\b|;|$)`)
reLimit = regexp.MustCompile(`(?i)\blimit\s+(\d+)`)
reAnd = regexp.MustCompile(`(?i)\s+and\s+`)
rePred = regexp.MustCompile(`(?i)^\s*(?:[a-z_][a-z0-9_]*\.)?([a-z_][a-z0-9_]*)\s*(=|>=|<=|<>|!=|>|<|\bin\b)`)
)
type Shape struct {
Table string
Equal []string
Range []string
Sort []string
Limit int64
}
func ShapeOf(query string) (Shape, error) {
var s Shape
if m := reTable.FindStringSubmatch(query); m != nil {
s.Table = strings.ToLower(m[1])
}
if s.Table == "" {
return s, errors.New("slowquery: no FROM table")
}
if m := reLimit.FindStringSubmatch(query); m != nil {
s.Limit, _ = strconv.ParseInt(m[1], 10, 64)
}
if m := reWhere.FindStringSubmatch(query); m != nil {
for _, pred := range reAnd.Split(m[1], -1) {
pm := rePred.FindStringSubmatch(pred)
if pm == nil {
continue
}
col, op := strings.ToLower(pm[1]), strings.ToLower(pm[2])
if op == "=" || op == "in" {
s.Equal = addCol(s.Equal, col)
} else {
s.Range = addCol(s.Range, col)
}
}
}
if m := reOrder.FindStringSubmatch(query); m != nil {
for _, part := range strings.Split(m[1], ",") {
if f := strings.Fields(part); len(f) > 0 {
col := strings.ToLower(f[0])
if i := strings.LastIndexByte(col, '.'); i >= 0 {
col = col[i+1:]
}
s.Sort = addCol(s.Sort, col)
}
}
}
return s, nil
}
func addCol(dst []string, c string) []string {
for _, x := range dst {
if x == c {
return dst
}
}
return append(dst, c)
}
func uniq(cols []string) []string {
seen := make(map[string]bool, len(cols))
out := cols[:0]
for _, c := range cols {
if c != "" && !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out
}
type Candidate struct {
Columns []string
Reason string
}
// Candidates follows equality, then range, then sort. A range column and a
// different sort column cannot both be served by one index, so two
// alternatives are returned.
func Candidates(s Shape) []Candidate {
eq := append([]string(nil), s.Equal...)
var out []Candidate
add := func(cols []string, reason string) {
cols = uniq(cols)
if len(cols) == 0 {
return
}
for _, c := range out {
if strings.Join(c.Columns, ",") == strings.Join(cols, ",") {
return
}
}
out = append(out, Candidate{cols, reason})
}
if len(s.Range) > 0 && len(s.Sort) > 0 && s.Range[0] != s.Sort[0] {
add(append(append([]string(nil), eq...), s.Range[0]), "equality+range")
add(append(append([]string(nil), eq...), s.Sort...), "equality+sort")
return out
}
cols, reason := append([]string(nil), eq...), "equality"
if len(s.Range) > 0 {
cols = append(cols, s.Range[0])
reason = "equality+range"
}
cols = append(cols, s.Sort...)
if len(s.Sort) > 0 {
reason += "+sort"
}
add(cols, reason)
return out
}
// TableStats is fixture input. Rows is the table row count; write/read rates
// are optional and only drive the write-amplification guard.
type TableStats struct {
Rows int64
WritesPerSec float64
ReadsPerSec float64
Existing [][]string
}
const (
Recommend = "recommend"
Covered = "covered_by_existing"
Defer = "defer_write_cost"
PreferSeq = "prefer_seq_scan"
)
type Assessment struct {
Columns []string
Reason string
QueryCount int
TotalMs float64
Selectivity float64
Amplify float64
Decision string
}
type Advisor struct{ Stats TableStats }
// Recommend aggregates one column shape across queries, then applies the rules
// in a fixed order so the output is reproducible.
func (a Advisor) Recommend(entries []Entry) []Assessment {
if a.Stats.Rows <= 0 {
a.Stats.Rows = 1
}
byKey := make(map[string]*Assessment)
var order []string
for _, e := range entries {
s, err := ShapeOf(e.Query)
if err != nil {
continue
}
sel := e.RowsPerCall() / float64(a.Stats.Rows)
if s.Limit > 0 && e.RowsPerCall() <= float64(s.Limit) {
// pg_stat_statements reports post-LIMIT rows: a floor, not an
// estimate. Real selectivity needs pg_stats or EXPLAIN.
sel = 0
}
for _, c := range Candidates(s) {
k := strings.Join(c.Columns, ",")
as := byKey[k]
if as == nil {
as = &Assessment{Columns: c.Columns, Reason: c.Reason}
byKey[k] = as
order = append(order, k)
}
as.QueryCount++
as.TotalMs += e.TotalMs()
if sel > as.Selectivity {
as.Selectivity = sel
}
}
}
out := make([]Assessment, 0, len(order))
for _, k := range order {
as := *byKey[k]
as.Amplify = float64(len(a.Stats.Existing)+2) / float64(len(a.Stats.Existing)+1)
as.Decision = decide(as, a.Stats)
out = append(out, as)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].TotalMs > out[j].TotalMs })
return out
}
func decide(a Assessment, st TableStats) string {
if a.Selectivity >= 0.20 {
return PreferSeq
}
prefix := strings.Join(a.Columns, ",") + ","
for _, idx := range st.Existing {
if strings.HasPrefix(strings.Join(idx, ",")+",", prefix) {
return Covered
}
}
if a.Amplify > 1.50 && a.TotalMs < 1000 {
return Defer
}
if st.WritesPerSec > 0.25*st.ReadsPerSec && a.QueryCount < 2 {
return Defer
}
return Recommend
}
排序口径:先看总耗时
解析之后第一步不是看 mean_exec_time 最大的语句,而是按 calls × mean_exec_time 排序。fixture 里 queryid 104 是单次 150ms、调用 800 次的低选择性查询,总耗时 120,000ms;queryid 103 单次只有 0.3ms,但调用 40,000 次,总耗时 12,000ms。只按均值排序,103 会排在最后,可它每天累计消耗的时间是 queryid 105 的三倍多(3,600ms)。排序还必须稳定:总耗时相同则按 queryid 升序,否则同一份输入在不同运行里会给出不同顺序,评审时无法比对。
索引候选:等值、范围、排序
候选生成遵循“等值列在前、范围列居中、排序列在后”的经验顺序。原因是 B-tree 只能沿最后一列保持有序:(status, created_at) 可以先用 status = $1 定位,再在 created_at 区间内有序扫描;反过来 (created_at, status) 在时间范围跨多个状态时无法直接满足 status = $1。范围列与排序列不是同一列时,一个索引不能同时做两件事。WHERE customer_id = $1 AND created_at >= $2 ORDER BY total_cents 要么建 (customer_id, created_at) 快速缩小范围,要么建 (customer_id, total_cents) 避免排序;Candidates 返回两个备选,由人根据选择性决定,而不是自动二选一。
还有一个容易忽略的口径:带 LIMIT 的查询,pg_stat_statements.rows 只统计返回给客户端的行数,不是扫描行数。客户订单列表 LIMIT 20 的 rows/calls 恒为 20,用它除以表行数会得到一个虚假的极低选择性。代码在检测到 Limit 且 rows/call <= limit 时把选择性置 0 并留给 pg_stats/EXPLAIN 复核,避免启发式把“返回少”误判成“过滤强”。
三条订单查询与索引取舍
fixture 对应 arch-lab 订单表 orders 的三类真实访问,表行数 1,000,000,已有索引 (id) 和 (order_no)。下面所有“选择率”都是 fixture 计算值,不是 PostgreSQL 实测。
| queryid | 查询形状 | 候选索引 | 判定 |
|---|---|---|---|
| 101 | customer_id = $1 ORDER BY created_at DESC LIMIT 20 | (customer_id, created_at) | recommend,但 rows 是 LIMIT 后的值,需复核 |
| 102 | status = $1 AND created_at BETWEEN ... ORDER BY created_at | (status, created_at) | recommend,等值+范围+排序同列 |
| 103 | order_no = $1 | (order_no) | covered_by_existing,不新增 |
| 104 | status = $1 | (status) | prefer_seq_scan,选择率 25% |
| 105 | customer_id = $1 AND status = $2 | (customer_id, status) | recommend,两个等值列 |
101 的索引同时服务“按客户查最近订单”和“按客户统计订单数”时,要注意第二种查询没有 created_at 条件,仍能用到索引前缀。102 的选择率约 0.02%,(status, created_at) 比单列 status 更有价值,因为范围条件把扫描限制在时间窗内。103 已经被现有唯一索引覆盖,再加索引只会增加写路径。104 返回约四分之一的行,索引扫描后还要回表,优化器通常选顺序扫描;这里不应为了“有过滤条件”就建索引。
写放大:新增索引不是免费的
每增加一个二级索引,插入、更新和删除都要多维护一棵 B-tree。用一个可解释的模型:写成本与“堆 + 索引数”成正比,已有 E 个索引、新增 1 个时,相对成本为 (E+2)/(E+1)。订单表已有 2 个索引,新增 1 个的模型倍率是 (2+2)/(2+1)=1.333,即写路径约多 33%。这是模型估算,不是 PostgreSQL 实测;真实值要用写压测或 pg_stat_io 采样。写放大还有两个不体现在这个公式里的代价:更新非索引列之外的索引列会破坏 HOT 更新,增加表和索引膨胀;索引越多,autovacuum 要处理的对象越多,长事务期间膨胀更快。订单表是写多读多的表,索引预算应按写吞吐和 P99 共同确定,而不是按查询数量。
真实运行结果
测试在 macOS darwin/arm64、Go 1.26.0 上单次运行,fixture 5 行,命令为 go test ./internal/perf/slowquery/ -count=1 -v,原始输出保存在 .local/reports/be-arch/be-arch-perf-11-1-slowquery-tests.txt。关键输出如下:
top queryid=104 total_ms=120000
cols=status reason=equality queries=1 total_ms=120000 sel=0.250000 amp=1.333 decision=prefer_seq_scan
cols=status,created_at reason=equality+range+sort total_ms=102000 sel=0.000200 amp=1.333 decision=recommend
cols=customer_id,created_at reason=equality+sort total_ms=62000 sel=0.000000 amp=1.333 decision=recommend
cols=order_no reason=equality queries=1 total_ms=12000 sel=0.000001 amp=1.333 decision=covered_by_existing
cols=customer_id,status reason=equality queries=1 total_ms=3600 sel=0.000001 amp=1.333 decision=recommend
这组数字证明的是解析、聚合和判定逻辑,不是数据库性能。0.436s 包含编译和测试启动,不能当查询耗时。gofmt -l . 无输出,go vet ./... 无告警。
索引不是越多越好:退出条件
一条索引建议只有在满足全部条件时才应进入迁移:选择率低于 20%;不是现有索引的前缀;预计节省的总耗时高于写放大成本;并且有明确的回滚方式。任一条件不满足,建议就应降级为“观察”,而不是直接 CREATE INDEX。上线后的退出条件同样要写死:连续两周 pg_stat_user_indexes.idx_scan = 0 且没有约束用途,删除;写 P99 相对基线上升超过 10%,回滚最近新增的索引;单表索引数超过 5 个,必须逐个人工复核;pg_stat_all_indexes 显示某索引的 idx_tup_read / idx_tup_fetch 比例长期接近 1,说明它没有减少回表,收益可疑。这些查询本章没有连接 PostgreSQL 执行,它们是运维检查项,不是本节实测结果。
常见错误与边界
- 把
rows当每次调用的返回行数。它是累计值,必须先除以calls,否则选择性会被放大成千上万倍。 - 只看均值排序。低频慢查询和高频中等查询的优化顺序不同,容量和用户体验要按总耗时与 P99 分别判断。
- 给每个过滤列都建单列索引。多条件查询通常需要一个复合索引,单列索引只能命中其中一个条件,还会增加写放大。
- 把 fixture 的执行计划当成生产计划。统计信息、数据分布、版本和参数都会改变计划;本章的所有计划样例都标注为 fixture,不能引用为 PostgreSQL 18.4 的实测结论。
- 忽略
LIMIT对rows的截断。带分页的查询必须回到EXPLAIN (ANALYZE, BUFFERS)或pg_stats才能判断真实选择性。
小结
本节把慢 SQL 处理拆成三步:用固定口径解析 queryid/calls/mean_exec_time/rows,按总耗时排序,再用等值-范围-排序规则生成索引候选。fixture 的 5 条查询给出 120,000ms 到 3,600ms 的总耗时分布,以及 recommend、covered、prefer_seq_scan 三类判定。写放大用 (E+2)/(E+1) 模型估算为 1.333 倍,但这不是 PostgreSQL 实测;索引建议必须经过选择率、现有前缀、写成本和上线后 idx_scan 四道退出条件的复核。
阅读导航:上一节:10.3 故障复盘与改进闭环 · 下一节:11.2 分页、批处理与连接管理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。