高级后端 · 应用与存储层性能

请求在每一站排队: 锁 / 池 / SQL / 缓存 / 队列 — 连接满不可怕, 等待时间上涨才是真危险

一个请求的排队地图: 每一站都可能成为瓶颈 App worker 池 线程 / goroutine 无界队列 → OOM 连接池 max=100 active=100 wait 时间↑ 才是真危险 DB 慢 SQL / 锁等待 rows_examined/returned 锁竞争 全局 mutex: 999 排队 1 执行 Cache hit 98% miss 时 DB QPS ×50 miss 风暴直打 DB 队列: 重要的不是堆积 10 万条, 而是 lag 是否持续增长 produce 10000/s vs consume 9000/s → 每秒积压 1000, 迟早爆 没有 backpressure 的雪崩链 (每一环都在放大) 流量 ↑ 无界队列堆积 → 内存涨 GC 变慢 → 延迟↑ → timeout retry ×3 → 流量 ×4 再打回来 雪崩: 整个系统被自己打死 backpressure 工具箱 bounded queue (有界队列) semaphore / rate limit 连接池即背压阀门 宁可快速拒绝, 不无限堆积 下游处理不过来时, 上游必须减速 — 这是成熟系统的第一公理 缓存三兄弟: 同一个结局, 三种病因 穿透 Penetration — 查根本不存在的数据 每次都 miss → 每次都打 DB; 常被恶意扫描利用 解: 空值缓存(短TTL) + BloomFilter + 参数校验 击穿 Breakdown — 一个热点 key 突然过期 瞬间 10 万请求同时回源打 DB (全在同一毫秒 miss) 解: singleflight/互斥重建 + 逻辑过期提前刷新 雪崩 Avalanche — 大量 key 同一时刻过期 批量预热的缓存同时到期 → DB 被围殴, 连带全站 解: TTL 随机抖动 + 多级缓存 + 限流熔断兜底

池的真话筒是等待

  • • active=max 不可怕, wait 时间↑才可怕
  • • wait↑ = 请求还没执行就在门口排队
  • • 池是背压阀门: 宁可拒绝不放大
  • • 监控五指标: active/idle/max/wait_n/wait_t

DB 侧三个比值

  • • rows_examined / rows_returned: 返回 10 行扫 100 万行必有问题
  • • N+1: 单条 SQL 都健康, 页面却 2 秒
  • • 大事务: 事务只包 DB 一致性真正需要的部分

缓存与队列

  • • 穿透/击穿/雪崩: 三种病因三组药方
  • • hit rate 骤降 = DB 事故的前兆
  • • 队列看 lag 趋势, 不看存量数字
  • • 没有背压的系统等于定时炸弹

💡 一句话理解

应用层性能就是一张游乐园排队地图: 入口闸机 (worker 池)、热门项目门口 (连接池)、项目本身 (DB/SQL)、快速通道 (缓存) 各有自己的队伍。新手只盯着"项目好不好玩" (SQL 快不快), 高手先看队伍在哪个环节变长 — 因为连接池满本身不死人, 等待时间上涨才死人。而所有队伍共享一条铁律: 下游消化不了时上游必须减速 (backpressure), 否则等待会以 retry 为杠杆放大成雪崩。

🧠 必知必会 必考 & 必会

锁竞争指纹
CPU 不高 + QPS 上不去 + P99 高 + 线程多 = 大家都在等锁。1000 个 goroutine 过一把全局 mutex, 并发系统退化成单线程。
# Go mutex profile:
$ go tool pprof -top http://svc:6060/debug/pprof/mutex
# 4.8s  configmu.Lock  ← 全局锁持有 4.8s/样本周期
goroutine 泄漏四源
channel 没人读/没人写、context 未取消、HTTP body 未关、timer 未停。指纹: goroutine 数与 QPS 脱钩单调上涨。
# 正常 500 → 20000, QPS 没变 → 泄漏实锤
$ go tool pprof http://svc:6060/debug/pprof/goroutine
# ch := make(chan int)   // 只发不收, 接收者早退了
连接池五指标
active/idle/max 只是存量, wait_count 与 wait_duration 才是体感。请求还没执行 SQL 就在门口排队了。
# HikariCP/Go sql.DB 判定口径:
# active=100 max=100 idle=0   ← 满了但未必有事
# wait_duration ↑↑           ← 这才是 P99 元凶
池即背压
"DB 慢就把连接池 100 调到 1000"= 让 1000 个请求围殴 DB。池的本职是限制并发保护下游, 宁可排队拒绝。
# 错: DB 慢 → max_conn 100→1000   # 围殴
# 对: 找慢 SQL + 池保持 2×核数×单连接效率
慢 SQL 比值
单看执行时间会骗人, rows_examined / rows_returned 才暴露坏索引: 返回 10 行扫描 100 万行 = 索引没用上。
# EXPLAIN: type=ALL rows=1024000
# WHERE nickname = 'abc'  ← nickname 无索引全表扫
索引三件套
最左前缀 (联合索引 a,b 用不上 b)、覆盖索引 (免回表)、索引下推。别只问"有没有索引", 要问"用对了吗"。
-- 联合索引 idx(a,b): 查 b 不走索引
-- 对: WHERE a = ? AND b = ?   -- 全命中
-- 覆盖: SELECT a,b FROM t WHERE a=?  -- 免回表
N+1 查询
1 次列表 + N 次详情 = 1+N 次 SQL。每条都 2ms 很健康, 加起来 2 秒 — 应用层性能问题, DB 指标全是绿的。
# 错: for u := range users { getOrder(u.ID) }  # 1000 次
# 对: WHERE user_id IN (...) 一次取回, 内存组装
大事务之害
事务里查数据、调 HTTP、sleep、算业务 — 数据库资源 (锁/undo/连接) 全程被持有。事务只包 DB 一致性真正需要的部分。
-- 错: 事务横跨外部调用
BEGIN; SELECT ...; call_http(); UPDATE ...; COMMIT;
-- 对: 算好业务 → 短事务只做两次写
DB lock wait
CPU 正常 + DB latency 暴涨 + 连接池耗尽 = 锁等待。看 lock wait/deadlock/transaction duration, 元凶常是大事务或热点行。
-- MySQL: 谁在持锁/等锁
SELECT * FROM information_schema.innodb_trx
ORDER BY trx_started;        -- 最老的事务先审
缓存三兄弟
穿透 (查不存在)、击穿 (热点 key 过期)、雪崩 (批量同时过期) — 结局都是 DB 被打, 病因与药方不同。
# hit_rate 98% → 20% 的瞬间:
# DB QPS ×50 → 连接池满 → API P99 爆 — Redis 没挂, 系统挂了
队列看 lag
堆积 10 万条不可怕 (消费快就好), lag 持续增长才可怕: consume < produce 意味着系统长期不可持续。
# produce=10000/s  consume=9000/s
# lag += 1000/s → 一小时 360 万条 → 迟早爆盘/OOM
futex 到应用锁
strace 看到 futex 海量等待只是"在等锁", 定罪要靠 runtime: Go mutex/block profile、Java jstack 死锁检测、py-spy。
# strace -c: futex 92%  → 只算立案
# mutex profile: configmu.Lock 累计 4.8s → 逮捕
无界队列炸弹
make(chan T) 无缓冲或不设上限的内存队列: 消费跟不上时队列无上限吃内存, 最后 GC/OOM 全线崩。
# 错: ch := make(chan Job)          # 生产∞消费慢
# 对: make(chan Job, 10000) + 满则拒绝/降级

🏭 生产实战 real world

场景 1 · 连接池 wait 上涨定位慢 SQL: 从池到 EXPLAIN

P50=50ms P99=4s, CPU 30%。池指标一出, 方向立刻从"代码"转向"SQL"。

# 池指标: 请求没执行 SQL 就在排队
# active=100 max=100  wait_count=4200  wait_duration avg=180ms

# 谁占着连接? 慢 SQL 日志按扫描比排序
-- 慢查询: 1.8s, rows_examined=1024000, rows_returned=12
EXPLAIN SELECT * FROM orders WHERE seller_note = 'x';
-- type=ALL ← seller_note 无索引, 全表扫, 1.8s 占着连接

# 修复: 加索引 + SELECT 只取需要的列
# 效果: wait_duration 180ms→2ms, P99 4s→80ms

链条: pool.wait → 慢 SQL → rows 比值 → EXPLAIN → 索引。

场景 2 · mutex profile 定罪全局锁: 500 并发串行成 1

CPU 15% 但服务 5 秒一响应。strace 立案, mutex profile 逮捕。

$ strace -c -p 1234   # 10 秒
# futex  92%   ← 全在等锁, 立案
$ go tool pprof -top http://svc:6060/debug/pprof/mutex
#      flat  cum
#     4.8s  config.RLock  ← 全局配置锁, 读多却互斥

// 修复: 配置读时无锁 (atomic.Value 快照), 写时整体替换
var cfg atomic.Value        // 存不可变 Config 快照
func Get() Config          { return cfg.Load().(Config) }
func Reload(c Config)      { cfg.Store(c) }   // 只在写时替换
# 效果: 500 并发从"排队"恢复真并行, P99 5s→60ms

读多写少的数据结构, atomic.Value / COW 快照比读写锁更干净。

场景 3 · goroutine 泄漏: 发出去没人收的 channel

goroutine 从 500 涨到 20000, RSS 同步爬升。goroutine profile 一眼点名。

$ go tool pprof -top http://svc:6060/debug/pprof/goroutine
# 19800 goroutines over: sync.runtime_Semacquire
#   app/fanout.go:42  out <- result   ← 发送阻塞

// 错: 下游出错提前 return, 没人读 out 了
go func() { out <- slowWork(ctx) }()

// 对: select 双路, ctx 取消能退出
go func() {
    select {
    case out <- slowWork(ctx):
    case <-ctx.Done():            // 泄漏闸门
    }
}()

上线后 goroutine 稳定 480±20, RSS 曲线同步走平。

场景 4 · N+1: 订单页 2 秒到 20 毫秒的批量化

单条 SQL 全部健康 (2ms), 页面却 2 秒 — 1+1000 次 SQL 的经典复合账单。

// 错: 每个用户一次查询
for _, u := range users {          // 1000 人
    orders[u.ID] = repo.ListByUser(ctx, u.ID)   // 1+1000 次 SQL
}

// 对: 一次 IN + 内存分组
ids := make([]int64, 0, len(users))
for _, u := range users { ids = append(ids, u.ID) }
rows := repo.ListByUsers(ctx, ids)      // WHERE user_id IN (?...)
for _, o := range rows {
    orders[o.UserID] = append(orders[o.UserID], o)
}
// 1001 次 → 2 次, 页面 2.0s → 20ms

给 ORM 关掉懒加载或显式批量预载, N+1 在代码评审就该拦住。

场景 5 · 大事务: 事务里调 HTTP 的拆分手术

结算事务横跨第三方支付调用, 第三方一慢, DB 连接和行锁全程被持有。

-- 错: 事务内含 800ms 的外部调用, 高峰锁等待 300ms+
BEGIN;
  SELECT stock FROM sku WHERE id=1 FOR UPDATE;
  -- call_http(payment) 800ms ← 锁全程被持有
  UPDATE sku SET stock=stock-1 WHERE id=1;
COMMIT;

-- 对: 事务只包 DB 一致性
-- 1) 先调支付拿到凭证 (无事务)
-- 2) 短事务扣减: SELECT FOR UPDATE → UPDATE → COMMIT (3ms)
-- 3) 支付失败走补偿 (幂等扣减/退单)

铁律: 事务内不许有网络调用、sleep、重计算; 跨系统一致性用 saga/补偿。

场景 6 · 热点行: 秒杀库存的"单行围殴"分桶改造

1000 QPS 打同一行 UPDATE, InnoDB 行锁把吞吐锁死在单行串行。

-- 错: 热点行, 所有请求串行等锁
UPDATE seckill SET stock = stock - 1 WHERE id = 1;

-- 对: 分 16 桶, 请求随机打散 (0..15)
UPDATE seckill
SET stock = stock - 1
WHERE id = ? AND bucket = ? AND stock > 0;

# 读时 SUM 合并, 卖完任一桶再向相邻桶借量
# 效果: 单行 1k/s → 16 桶 12k/s, 锁等待消失

热点行的兄弟还有热点 user / 热点 shard — 思路都是打散 + 合并读。

场景 7 · 缓存击穿: singleflight 让 10 万回源变 1 次

首页热点 key 过期瞬间, 10 万请求同时回源 — 一个进程内合并回源即可。

var g singleflight.Group

func GetHot(ctx context.Context, key string) (*Item, error) {
    if v, ok := cache.Get(key); ok {
        return v, nil
    }
    // 同 key 并发只放一个去 DB, 其余共享结果
    v, err, _ := g.Do(key, func() (interface{}, error) {
        return loadFromDB(ctx, key)   // 10万次 → 1次
    })
    cache.Set(key, v, 5*time.Minute+jitter(30*time.Second))
    return v.(*Item), err
}

多实例部署时再叠加分布式互斥 (SETNX) 或提前逻辑过期, 双保险。

场景 8 · 缓存穿透: 空值缓存 + BloomFilter 双闸

被恶意 ID 扫描: 99% 请求查不存在的数据, 全部穿透到 DB。

func GetUser(id int64) (*User, error) {
    if !bloom.Test([]byte(strconv.FormatInt(id, 10))) {
        return nil, ErrNotFound      // 第一闸: 布隆挡掉 99% 假 ID
    }
    if v, ok := cache.Get(id); ok {
        return v, nil
    }
    u, err := db.Load(id)
    if err == ErrNotFound {
        cache.SetNX(id, nil, 60*time.Second)  // 第二闸: 空值短 TTL
        return nil, ErrNotFound
    }
    cache.Set(id, u, 10*time.Minute)
    return u, nil
}

布隆过滤器随用户增量重建; 空值 TTL 不宜长, 防新注册用户查不到。

场景 9 · 缓存雪崩: TTL jitter + 多级缓存的组合拳

凌晨批量预热 20 万 key 全部 2 小时过期, 0 点整 DB 被精确围殴。

// TTL 必加抖动: 基础 2h ± 10min 随机
ttl := 2*time.Hour + time.Duration(rand.Int63n(1200))*time.Second
cache.Set(key, v, ttl)

// 热点再加本地一级缓存, miss 冲击先被本地吸收
type TwoLevel struct {
    local *lru.Cache      // 进程内, 容量 1 万, TTL 10s
    redis *redis.Client   // 共享层
}
// 兜底: Redis 不可用时限流读 DB (令牌桶), 绝不放全量

修复后 0 点 DB QPS 从 8 万回落到 9000, 与白天水位一致。

场景 10 · 队列 lag 告警: 把"迟早爆"变成提前扩容

大盘看着平静, 消费速率悄悄落后生产 — lag 趋势是唯一诚实的仪表。

# Prometheus 规则: lag 1 小时线性外推将突破阈值
- alert: ConsumerLagGrowing
  expr: |
    deriv(kafka_consumergroup_lag[30m]) > 0
    and predict_linear(kafka_consumergroup_lag[30m], 3600) > 100000
  for: 10m

# 处置 SOP: 扩消费者 (分区数内) → 批量优化 (fetch.min.bytes)
# → 还压不住: 评估降级/丢弃策略, 而不是硬扛

配套: 消费者扩容上限 = 分区数, 分区规划要预留消费力余量。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · DB 慢就把连接池调大 — 1000 连接围殴 DB, 缓存命中率再降, 全面恶化. 原因: 误把背压当瓶颈. 正解: 治慢 SQL, 池保持适度并发。
# 错: pool 100→1000 (DB CPU 直接 100%)
# 对: 慢 SQL 修复 + 池 100 + wait 监控
坑 2 · 连接不还 — 漏 defer rows.Close() 或 panic 路径漏 Release, 池被慢慢吃光. 原因: 借还不对称. 正解: 借还写在一起, 全路径 defer。
# 错: rows := db.Query(...)  # 没有 defer Close
# 对: rows, err := ...; defer rows.Close()
坑 3 · 事务里调 RPC / sleep — 800ms 的外部调用让行锁全程被持有. 原因: 图省事包大事务. 正解: 事务只包 DB 一致性, 外部调用移出。
# 错: BEGIN → call_http() → UPDATE → COMMIT
# 对: 先 RPC 拿凭证 → 短事务写库 → 失败补偿
坑 4 · 一把全局锁 — config/stats 一把大锁, 并发退化单线程. 原因: 锁粒度懒得设计. 正解: 分桶锁 / 原子快照 / 无锁读。
# 错: var mu sync.Mutex 包住所有方法
# 对: 读走 atomic.Value 快照, 写整体替换
坑 5 · channel 只发不收 — 下游提前 return, 发送方永久阻塞 = goroutine 泄漏. 原因: 生命周期没对齐. 正解: select + ctx.Done 双路。
# 错: out <- result   # 没人收就永远阻塞
# 对: select { case out <- r: case <-ctx.Done(): }
坑 6 · context 不往下传 — 上游取消了, 下游还在全量查询, 资源白烧. 原因: ctx 当摆设. 正解: ctx 一路传递, DB/HTTP 都带。
# 错: db.Query(sql)               # 无 ctx
# 对: db.QueryContext(ctx, sql)
坑 7 · HTTP body 不 close — fd 与连接池双泄漏, CLOSE_WAIT 堆积. 原因: 只用码不看 body. 正解: 无条件 defer Body.Close(), 错误也要读完 body。
# 错: if code != 200 { return err }  # 没 close
# 对: defer resp.Body.Close() + io.Copy(io.Discard, body)
坑 8 · N+1 不被察觉 — 单 SQL 全绿, 页面 2 秒, 监控无法归因. 原因: 按 SQL 视角统计. 正解: 按请求统计 SQL 次数, >20 告警。
# 错: 只监控 avg(sql_time)
# 对: middleware 计数 queries/request, 阈值告警
坑 9 · SELECT * 回表拖大字段 — 列表页把 8KB 的 detail blob 全拖出来, IO/CPU/网络三倍付. 原因: 手写省事. 正解: 列表只取列表列, 详情按需查。
# 错: SELECT * FROM goods LIMIT 20
# 对: SELECT id,title,price FROM goods LIMIT 20
坑 10 · offset 深分页 — LIMIT 100 OFFSET 1000000 要扫过前 100 万行. 原因: 页码直翻. 正解: keyset 分页 WHERE id > ?。
# 错: LIMIT 100 OFFSET 1000000  # 越翻越慢
# 对: WHERE id > last_id ORDER BY id LIMIT 100
坑 11 · 索引列上做函数/隐式转换 — DATE(created)=... 或字符串列传数字, 索引直接失效. 原因: 破坏最左原样匹配. 正解: 改成范围查询, 类型对齐。
# 错: WHERE DATE(created) = '2026-09-26'
# 对: WHERE created >= '2026-09-26' AND created < '2026-09-27'
坑 12 · 无界内存队列 — 生产快消费慢, 队列吃光内存触发 GC 风暴. 原因: 默认 channel 无界心态. 正解: 有界 + 满时拒绝/降级。
# 错: jobs := make(chan Job)          # 无界
# 对: make(chan Job, 10000) + default 拒绝
坑 13 · 穿透不设空值缓存 — 恶意假 ID 每次都打到 DB. 原因: 想当然"缓存没值就查库". 正解: 空值短 TTL + BloomFilter。
# 错: miss → db.Load → 还是 nil → 不缓存
# 对: cache.SetNX(id, nil, 60s) + bloom 前置
坑 14 · TTL 全员统一 — 批量预热 + 相同 TTL = 定点雪崩. 原因: 整齐好管理. 正解: TTL 加随机抖动 ±10%。
# 错: Set(k, v, 2*time.Hour) 万条同刷
# 对: 2h + rand(0,10min) jitter
坑 15 · 热点 key 无并发合并 — 过期瞬间全量回源. 原因: 每请求独立 miss. 正解: singleflight / 分布式互斥 / 逻辑过期。
# 错: miss → loadDB() 每请求都执行
# 对: g.Do(key, loadDB) 同 key 合并为 1 次
坑 16 · 先更新缓存再更新 DB — 并发下缓存留旧值, 脏读到天荒地老. 原因: 顺序想反. 正解: 先更新 DB, 再删除缓存 (失效而非更新)。
# 错: cache.Set(v) → db.Update(v)
# 对: db.Update(v) → cache.Del(k)
坑 17 · 重试无退避无预算 — 失败 3 连发把小故障放大成 4 倍流量. 原因: 重试只有次数. 正解: 指数退避 + jitter + retry budget。
# 错: for i := 0; i < 3; i++ { call() }
# 对: backoff 100ms×2^n + jitter + 只重试幂等
坑 18 · 队列 lag 无告警 — 积压静静涨到磁盘爆/消费超时. 原因: 只监控消费错误率. 正解: lag + 增长斜率双告警。
# 错: alert: consumer_error_rate > 1%
# 对: predict_linear(lag[30m], 3600) > 10万
坑 19 · 池 wait 不监控 — active/max 看着满但没告警, 等到 P99 爆才知道. 原因: 只盯存量. 正解: wait_count/wait_duration 做成一等公民指标。
# 错: 面板只有 active connections
# 对: 加 wait_duration avg/max 曲线
坑 20 · 慢 SQL 阈值 10 秒 — 能跑进"慢日志"的早就把池吃空了. 原因: 阈值拍脑袋. 正解: 阈值 200ms 起并按接口分层。
# 错: long_query_time = 10
# 对: long_query_time = 0.2 + pt-query-digest 日报