《后端架构设计·高性能与高可用》11.1 索引、查询与慢 SQL

在 arch-lab 订单表上实现 pg_stat_statements 风格慢查询解析器与等值-范围-排序索引候选启发式,用 5 行自制 fixture 做确定性测试;分析三条典型订单查询的索引取舍、写放大模型与「索引不是越多越好」的退出条件,并明确所有执行计划样例均为 fixture 而非 PostgreSQL 18.4 实测。

先固定口径:本章没有连接 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查询形状候选索引判定
101customer_id = $1 ORDER BY created_at DESC LIMIT 20(customer_id, created_at)recommend,但 rows 是 LIMIT 后的值,需复核
102status = $1 AND created_at BETWEEN ... ORDER BY created_at(status, created_at)recommend,等值+范围+排序同列
103order_no = $1(order_no)covered_by_existing,不新增
104status = $1(status)prefer_seq_scan,选择率 25%
105customer_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 分页、批处理与连接管理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查