《后端架构设计·高性能与高可用》11.2 分页、批处理与连接管理

在 arch-lab 订单列表上实现 offset 与 keyset 分页并真跑 20 万行深分页对照,报告扫描行数与内存耗时;实现 N+1 检测器和批量加载,把 21 次查询降到 2 次;给出批大小、连接池、超时、重试的约束关系及订单接口参数上限,并说明内存基准为什么不能替代数据库测量。

分页问题本质是“要跳过多少行”

offset 分页写起来最省事,代价却随页码线性增长:数据库必须先定位并跳过 offset 行,再返回 limit 行。第 1 页和第 5000 页的 SQL 长得一样,执行成本差三个数量级。keyset 分页把“第几页”换成“上一页最后一行的排序键”,用 (created_at, id) < (?, ?) 直接定位起点,成本变成 log(n) + limit。

本节在 arch-lab 的内存订单数据集上真实跑这两种分页,报告扫描行数和耗时。需要先说明:内存切片没有磁盘页、网络往返和行解码,所以本地耗时只反映切片复制与二分查找,不能直接外推成数据库延迟;可迁移的结论是扫描行数比例。本节没有连接 PostgreSQL,所有数据库行为都是模型描述。

Offset 分页:第二返回值是扫描行数

OffsetPage 返回 (Page, scanned)。scanned 是数据库需要检查的行数,不是复制进页面的行数:offset=100000, limit=20 时它是 100020,即使页面只有 20 条。这个数字是后续容量估算和接口限额的依据,必须和响应体分开记录。

// file: internal/perf/paging/pagination.go
// tier: L1
// verify: go test ./internal/perf/paging/ -count=1 -v
package paging

import "time"

// Item is one order row. CreatedAt+ID is the unique sort key: two orders can
// share a timestamp, so ID breaks the tie and keeps the cursor total.
type Item struct {
	ID         int64
	CustomerID int64
	CreatedAt  time.Time
	TotalCents int64
}

type Cursor struct {
	CreatedAt time.Time
	ID        int64
}

type Page struct {
	Items  []Item
	Cursor Cursor
	More   bool
}

// NewDataset returns items sorted newest first. The generator is deterministic
// so scan counts in tests do not depend on time or randomness.
func NewDataset(n int) []Item {
	base := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
	out := make([]Item, n)
	for i := range out {
		out[i] = Item{
			ID:         int64(n - i),
			CustomerID: int64(i%1000 + 1),
			CreatedAt:  base.Add(-time.Duration(i) * time.Second),
			TotalCents: int64(1000 + i%500),
		}
	}
	return out
}

// OffsetPage must skip offset rows before returning anything. The second
// return value is the number of rows a database would examine, not the number
// copied into the page.
func OffsetPage(items []Item, offset, limit int) (Page, int) {
	if offset < 0 {
		offset = 0
	}
	if limit < 1 {
		limit = 1
	}
	start := min(offset, len(items))
	end := min(start+limit, len(items))
	p := Page{Items: append([]Item(nil), items[start:end]...), More: end < len(items)}
	if end > start {
		last := items[end-1]
		p.Cursor = Cursor{CreatedAt: last.CreatedAt, ID: last.ID}
	}
	return p, start + (end - start)
}

// KeysetPage binary-searches the first row older than the cursor, so its cost
// grows with log(n)+limit instead of offset+limit.
func KeysetPage(items []Item, after Cursor, limit int) (Page, int) {
	if limit < 1 {
		limit = 1
	}
	lo, hi, scanned := 0, len(items), 0
	for lo < hi {
		mid := (lo + hi) / 2
		scanned++
		if older(items[mid], after) {
			hi = mid
		} else {
			lo = mid + 1
		}
	}
	end := min(lo+limit, len(items))
	p := Page{Items: append([]Item(nil), items[lo:end]...), More: end < len(items)}
	if end > lo {
		last := items[end-1]
		p.Cursor = Cursor{CreatedAt: last.CreatedAt, ID: last.ID}
	}
	scanned += end - lo
	return p, scanned
}

func older(it Item, c Cursor) bool {
	if c.CreatedAt.IsZero() {
		return true // zero cursor means "before the first page"
	}
	if it.CreatedAt.Equal(c.CreatedAt) {
		return it.ID < c.ID
	}
	return it.CreatedAt.Before(c.CreatedAt)
}

游标必须包含排序的最后一列。只传 created_at 时,同一秒创建的多条订单会在页边界重复或丢失;Cursor{CreatedAt, ID} 用 ID 做决胜列,排序键才唯一。测试里的 TestOffsetAndKeysetAgree 正是逐页比较两种实现返回的 ID,确保翻页不重不漏。

深分页真实对照

数据集 200,000 行,取 offset=100000, limit=20。offset 路径扫描 100,020 行,keyset 路径从上一页最后一行开始,只做 18 次二分比较加 20 行读取,共 37 行。命令为 go test ./internal/perf/paging/ -run TestDeepPaginationScanCount -count=1 -v,原始输出在 .local/reports/be-arch/be-arch-perf-11-2-paging-tests.txt。

n=200000 offset=100000 limit=20 offset_scanned=100020 keyset_scanned=37
指标offsetkeyset比例
扫描行数(模型)100,02037约 2,703 倍
返回行数2020相同
内存耗时(3 次运行)137.1–137.8 ns/op259.2–267.2 ns/opkeyset 约 1.9 倍慢
分配1024 B/op, 1 alloc1024 B/op, 1 alloc相同

内存基准里 keyset 反而更慢,因为 offset 实现只是切了 20 个元素的切片,并没有真的逐行读取被跳过的 10 万行;keyset 却要做 18 次比较。这恰好说明本地基准测的是 Go 切片成本,不是数据库成本。真实数据库里被跳过的行要读页、解码、检查可见性,扫描行数才会转化成延迟。于是可迁移的结论只有扫描行数比例;要证明 keyset 在数据库上更快,必须用 PostgreSQL 的 EXPLAIN (ANALYZE, BUFFERS) 对比,本节没有做,也不声称做过。基准命令为 go test ./internal/perf/paging/ -run '^$' -bench . -benchmem -count=3,机器为 macOS darwin/arm64、Apple M1 Pro、Go 1.26.0,原始输出在 .local/reports/be-arch/be-arch-perf-11-2-paging-bench.txt。三次运行只代表本机单次会话,不是稳定基准。

N+1 检测与批量加载

订单详情需要客户信息时,最直接的写法是查出 20 个订单,再为每个订单查一次客户:1+20=21 次查询。数据量一大,往返延迟就把接口拖垮。检测器把字面量归一化成 ?,统计同一形状在当前请求内出现的次数;批量加载把 N 次客户查询换成一次 IN 查询,总共 2 次。

// file: internal/perf/paging/batch.go
// tier: L1
// verify: go test ./internal/perf/paging/ -count=1 -v
package paging

import (
	"errors"
	"fmt"
	"regexp"
	"strings"
	"time"
)

type Customer struct {
	ID   int64
	Name string
}

// Store is an in-memory stand-in for the order and customer tables. Queries
// counts logical round trips so an N+1 pattern is measurable without a DB.
type Store struct {
	items     []Item
	customers map[int64]Customer
	Queries   int
}

func NewStore(items []Item) *Store {
	customers := make(map[int64]Customer, 1000)
	for i := int64(1); i <= 1000; i++ {
		customers[i] = Customer{ID: i, Name: fmt.Sprintf("customer-%d", i)}
	}
	return &Store{items: items, customers: customers}
}

func (s *Store) OrdersByCustomer(cid int64) []Item {
	s.Queries++
	var out []Item
	for _, it := range s.items {
		if it.CustomerID == cid {
			out = append(out, it)
		}
	}
	return out
}

func (s *Store) Customer(id int64) Customer {
	s.Queries++
	return s.customers[id]
}

func (s *Store) CustomersByIDs(ids []int64) map[int64]Customer {
	s.Queries++
	out := make(map[int64]Customer, len(ids))
	for _, id := range ids {
		if c, ok := s.customers[id]; ok {
			out[id] = c
		}
	}
	return out
}

type OrderView struct {
	OrderID  int64
	Customer Customer
}

// LoadNaive issues one customer query per order: the classic N+1.
func LoadNaive(s *Store, cid int64) []OrderView {
	orders := s.OrdersByCustomer(cid)
	out := make([]OrderView, 0, len(orders))
	for _, o := range orders {
		out = append(out, OrderView{OrderID: o.ID, Customer: s.Customer(o.CustomerID)})
	}
	return out
}

// LoadBatch keeps the order query and replaces N customer queries with one
// IN-style lookup. Two round trips regardless of N.
func LoadBatch(s *Store, cid int64) []OrderView {
	orders := s.OrdersByCustomer(cid)
	ids := make([]int64, 0, len(orders))
	for _, o := range orders {
		ids = append(ids, o.CustomerID)
	}
	byID := s.CustomersByIDs(ids)
	out := make([]OrderView, 0, len(orders))
	for _, o := range orders {
		out = append(out, OrderView{OrderID: o.ID, Customer: byID[o.CustomerID]})
	}
	return out
}

var reValue = regexp.MustCompile(`'[^']*'|\b\d+\b`)

// Normalize replaces literals so "WHERE id = 7" and "WHERE id = 8" share a
// shape. It is intentionally simple and does not parse SQL.
func Normalize(query string) string { return reValue.ReplaceAllString(query, "?") }

type Detector struct {
	limit int
	count map[string]int
	order []string
}

func NewDetector(limit int) *Detector {
	if limit < 1 {
		limit = 1
	}
	return &Detector{limit: limit, count: make(map[string]int)}
}

func (d *Detector) Record(query string) {
	k := Normalize(query)
	if _, ok := d.count[k]; !ok {
		d.order = append(d.order, k)
	}
	d.count[k]++
}

// Repeated returns shapes seen at least limit times in the current request.
func (d *Detector) Repeated() map[string]int {
	out := make(map[string]int)
	for _, k := range d.order {
		if d.count[k] >= d.limit {
			out[k] = d.count[k]
		}
	}
	return out
}

var (
	ErrLimitTooLarge = errors.New("paging: limit exceeds 100")
	ErrOffsetTooDeep = errors.New("paging: offset exceeds 10000, use keyset")
)

// ValidateListParams is the order-list API contract. Deep offsets are rejected
// instead of silently becoming slower every page.
func ValidateListParams(limit, offset int) error {
	if limit < 1 || limit > 100 {
		return ErrLimitTooLarge
	}
	if offset < 0 || offset > 10000 {
		return ErrOffsetTooDeep
	}
	return nil
}

// Budget ties batch size, connection pool, timeout and retries together. A
// valid plan keeps retries inside the request budget and every concurrent
// batch inside the pool.
type Budget struct {
	PoolSize      int
	Workers       int
	QueryTimeout  time.Duration
	RequestBudget time.Duration
	Retries       int
	BatchSize     int
	Concurrency   int
}

func (b Budget) Validate() []error {
	var errs []error
	if b.PoolSize < 1 {
		errs = append(errs, errors.New("paging: pool size must be positive"))
	}
	if b.Workers < 1 || (b.PoolSize > 0 && b.Workers > 4*b.PoolSize) {
		errs = append(errs, errors.New("paging: workers exceed 4x pool size"))
	}
	if b.QueryTimeout <= 0 || b.RequestBudget <= 0 {
		errs = append(errs, errors.New("paging: timeouts must be positive"))
	}
	if b.Retries < 0 || b.Retries > 3 {
		errs = append(errs, errors.New("paging: retries must be within 0..3"))
	}
	if b.QueryTimeout*time.Duration(b.Retries+1) > b.RequestBudget {
		errs = append(errs, errors.New("paging: retries cannot fit request budget"))
	}
	if b.BatchSize < 1 || b.BatchSize > 500 {
		errs = append(errs, errors.New("paging: batch size must be within 1..500"))
	}
	if b.Concurrency > 0 && b.BatchSize > 0 && b.Concurrency*b.BatchSize > b.PoolSize {
		errs = append(errs, errors.New("paging: concurrent batches exceed pool"))
	}
	return errs
}

// Chunks splits IDs into batches; the last batch may be shorter.
func Chunks(ids []int64, size int) [][]int64 {
	if size < 1 {
		size = 1
	}
	var out [][]int64
	for len(ids) > 0 {
		n := min(size, len(ids))
		out = append(out, append([]int64(nil), ids[:n]...))
		ids = ids[n:]
	}
	return out
}

func JoinQuery(ids []int64) string {
	parts := make([]string, len(ids))
	for i, id := range ids {
		parts[i] = fmt.Sprint(id)
	}
	return "SELECT id, name FROM customers WHERE id IN (" + strings.Join(parts, ",") + ")"
}

测试结果:20 个订单的朴素加载 naive_queries=21,批量加载 batch_queries=2;检测器把 20 条只有 ID 不同的查询归并为一个形状并计数 20。原始输出在 .local/reports/be-arch/be-arch-perf-11-2-paging-tests.txt,命令为 go test ./internal/perf/paging/ -count=1 -v。

批大小、连接池、超时与重试的约束

四个参数不能分别调。设查询超时 T、重试次数 R、请求预算 B、连接池 P、并发批数 C、批大小 S,必须同时满足:

T × (R + 1) ≤ B          重试链不能超过上游预算
C × S ≤ P                每个并发批占用一个连接
S ≤ 500                  IN 列表和单次事务的上限
R ≤ 3                    重试预算的上限

第一条最容易被忽略:T=300ms, R=2, B=500ms 时,最坏情况 900ms,请求注定在上游超时后才返回,还占着一个连接。第二条决定并发批不会把连接池打满;C=3, S=10, P=20 就是无效配置。Validate 把这两条写成测试断言,配置加载时直接拒绝,而不是等线上排队。工作线程数是另一层:Workers 远大于 P 只会把等待从数据库搬到应用队列。建议目标 Workers ≤ P,代码里的 4×P 只是拒绝病态配置的硬护栏,不是推荐值。数据库总连接数还要按实例分摊:数据库 max_connections=100、应用 4 个实例、预留 20%,则每实例池上限约 100×0.8/4=20。

订单列表接口的参数上限

arch-lab 的 GET /orders?limit=&offset= 采用硬上限,而不是“尽量支持”:limit 1–100(默认 20),offset 0–10000;超过 10000 返回参数错误并提示改用 before_created_at + before_id 游标。10000 不是理论最优值,而是当前 SLO 下“最坏扫描 10020 行仍能在 50ms 查询预算内完成”的示例值,必须在目标数据库上压测确认,不能照抄。批量接口的上限独立于分页:单次最多 500 个 ID,超过就由 Chunks 拆分;每个批次仍然受 C×S≤P 约束。重试只用于幂等读,订单写入的重试必须依赖幂等键,不能套用同一套预算。

常见错误与边界

  • 只传 created_at 做游标。同一时间戳的多条订单会重复或丢失,必须带唯一决胜列。
  • 把内存基准当数据库基准。本地 137ns 对 260ns 的差异不说明 keyset 更慢;扫描行数才是可迁移指标。
  • 把 offset 上限设成 100 万。深分页的真正成本是扫描,不是返回;上限应和查询预算绑定。
  • 重试次数和请求预算脱钩。T×(R+1)>B 的配置必须在启动时拒绝。

小结

本节把分页和批处理都落到可运行代码:offset 与 keyset 在 20 万行数据上分别扫描 100,020 和 37 行,后者少约 2,703 倍;内存耗时反而是 keyset 更慢,说明本地基准不能替代数据库测量。N+1 检测器把 20 条同形状查询归并计数,批量加载把 21 次查询降到 2 次。批大小、连接池、超时和重试通过 T×(R+1)≤B 与 C×S≤P 绑定,订单列表的 limit/offset 上限则必须由查询预算反推。

阅读导航:上一节:11.1 索引、查询与慢 SQL · 下一节:11.3 读写分离的延迟预算与一致性兜底 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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