文档目录

6.6 配套代码:数据与算法类瓶颈诊断

对应小节:6.6 瓶颈模式库(二):数据与算法类 八种模式各有对应的诊断查询——这份文件把它们集中起来,一条命令全查一遍。

一、八合一诊断脚本

#!/usr/bin/env bash
# tools/diagnose-data-bottlenecks.sh <PG_CONN> [METRICS_URL] [PID]
set -uo pipefail

PG="${1:-${PG_CONN:-}}"
URL="${2:-http://127.0.0.1:8080/metrics}"
PID="${3:-$(jcmd 2>/dev/null | grep app.jar | awk '{print $1}')}"

echo "═══════════════════════════════════════════════════════════════"
echo "数据与算法类瓶颈诊断"
echo "═══════════════════════════════════════════════════════════════"
echo

# ═══ 模式一:N+1 查询 ═══════════════════════════════════════
echo "【模式一:N+1 查询】"
echo "  判据:某简单查询的 calls 远大于接口 QPS"
if [ -n "$PG" ]; then
  psql "$PG" -tAc "
  SELECT '  calls=' || calls || ' | mean=' || round(mean_exec_time::numeric,2) || 'ms'
         || ' | total=' || round(total_exec_time::numeric,0) || 'ms | '
         || left(regexp_replace(query,'\s+',' ','g'), 55)
  FROM pg_stat_statements
  WHERE query NOT LIKE '%pg_stat_statements%'
  ORDER BY calls DESC LIMIT 8;" 2>/dev/null || echo "  (无法查询)"
  echo
  echo "  ⭐ 关键:如果某个「单次很快」的查询 calls 极高 → N+1"
  echo "     修法:改成批量查询(where id = any(?))"
else
  echo "  (未提供 PG_CONN,跳过)"
fi
echo

# ═══ 模式二:缺索引 / 全表扫描 ══════════════════════════════
echo "【模式二:缺索引 / 全表扫描】"
if [ -n "$PG" ]; then
  psql "$PG" -tAc "
  SELECT '  ' || relname || ': seq_scan=' || seq_scan || ' idx_scan=' || COALESCE(idx_scan,0)
         || ' rows=' || n_live_tup
  FROM pg_stat_user_tables
  WHERE seq_scan > 1000 AND n_live_tup > 10000
  ORDER BY seq_scan DESC LIMIT 5;" 2>/dev/null || echo "  (无法查询)"
  echo
  echo "  ⭐ 高 seq_scan + 大表 → 可能有查询没走索引"
  echo "     验证:EXPLAIN (ANALYZE, BUFFERS) <你的查询>"
  echo "     危险信号:Seq Scan / Rows Removed by Filter 很大 / 行数估计偏差大"
else
  echo "  (未提供 PG_CONN,跳过)"
fi
echo

# ═══ 模式三:锁竞争 ═════════════════════════════════════════
echo "【模式三:锁竞争】"
if [ -n "$PG" ]; then
  echo "  数据库侧的阻塞链:"
  psql "$PG" -tAc "
  SELECT '    blocked=' || blocked.pid || ' by=' || blocking.pid
         || ' | ' || left(regexp_replace(blocked.query,'\s+',' ','g'), 40)
  FROM pg_stat_activity blocked
  JOIN pg_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
  LIMIT 5;" 2>/dev/null || echo "    (无法查询)"
fi
if [ -n "$PID" ]; then
  TMP=$(mktemp)
  jcmd "$PID" Thread.print > "$TMP" 2>/dev/null
  BLOCKED=$(grep -c "java.lang.Thread.State: BLOCKED" "$TMP" || echo 0)
  echo "  应用侧的 BLOCKED 线程数: $BLOCKED"
  [ "$BLOCKED" -gt 5 ] && echo "    ❌ 大量 BLOCKED → 应用内锁竞争" || echo "    ✅ 无明显锁竞争"
  rm -f "$TMP"
fi
echo

# ═══ 模式四:GC 停顿 ════════════════════════════════════════
echo "【模式四:GC 停顿】"
M=$(curl -s --max-time 5 "$URL" 2>/dev/null || echo "")
if [ -n "$M" ]; then
  GC_P99=$(echo "$M" | awk '/jvm_gc_pause_seconds/{print $2}' | sort -rn | head -1)
  ALLOC=$(echo "$M" | awk '/jvm_gc_memory_allocated_bytes_total/{printf "%.1f", $2/1048576; exit}')
  echo "  GC 暂停相关指标: ${GC_P99:-n/a}"
  echo "  累计分配: ${ALLOC:-n/a} MB"
  echo
  echo "  ⭐ 唯一确定的验证:把 gc.log 的停顿时间戳与 P99 尖刺时间戳【对齐】"
  echo "     对齐 → GC 是根因;不对齐 → 排除 GC(能省大量时间)"
else
  echo "  (无法获取指标)"
fi
echo

# ═══ 模式五:缓存击穿/雪崩 ══════════════════════════════════
echo "【模式五:缓存击穿 / 雪崩】"
if command -v redis-cli > /dev/null 2>&1; then
  redis-cli INFO stats 2>/dev/null | grep -E "keyspace_(hits|misses)" | sed 's/^/  /' || true
  echo "  ⭐ 判据:DB QPS 出现周期性脉冲,且与缓存过期时间吻合"
  echo "     修法:TTL 加抖动(防雪崩)+ 单飞(防击穿)+ 缓存空值(防穿透)"
else
  echo "  (redis-cli 不可用)"
fi
echo

# ═══ 模式六:序列化开销 ═════════════════════════════════════
echo "【模式六:序列化开销】"
echo "  验证:采 CPU 火焰图,看顶部是否有很宽的序列化栈"
echo "    asprof -d 60 -e cpu -f cpu.html ${PID:-<pid>}"
echo "  常见栈帧:Jackson.writeValue / StringBuilder / String.format / Integer.valueOf"
echo "  ⭐ 优先考虑「减少传输的数据量」,而不是「换序列化器」"
echo

# ═══ 模式七:日志同步 IO ════════════════════════════════════
echo "【模式七:日志同步 IO】"
if [ -n "$PID" ]; then
  TMP=$(mktemp)
  jcmd "$PID" Thread.print > "$TMP" 2>/dev/null
  FILEIO=$(grep -c -E "FileOutputStream.write|FileDispatcherImpl.write" "$TMP" || echo 0)
  echo "  停在文件写入的线程数: $FILEIO"
  [ "$FILEIO" -gt 0 ] && echo "    ⚠️  有线程在写文件 → 检查是否同步日志" || echo "    ✅ 无"
  rm -f "$TMP"
fi
echo "  ⭐ 最确定的验证:JFR 的 jdk.FileWrite 事件"
echo "    jfr print --events jdk.FileWrite recording.jfr | grep -c app.log"
echo "  对照实验:把日志级别降到 ERROR,看 P99 是否回落"
echo

# ═══ 模式八:无界队列 OOM ═══════════════════════════════════
echo "【模式八:无界队列 OOM】"
if [ -n "$M" ]; then
  Q=$(echo "$M" | awk '/executor_queue_depth/{printf "%.0f", $2; exit}')
  HEAP=$(echo "$M" | awk '/jvm_memory_used_bytes.*heap/{printf "%.0f", $2/1048576; exit}')
  echo "  队列深度: ${Q:-n/a}"
  echo "  堆使用  : ${HEAP:-n/a} MB"
  echo "  ⭐ 判据:堆的「GC 后基线」单调上升(不是瞬时值)"
  echo "     用第 3 章 3.5 的 analyze_soak.py 判断趋势"
fi
echo

echo "═══════════════════════════════════════════════════════════════"
echo "八种模式的「一个指标区分」速查:"
echo "  N+1 vs 缺索引     :看 calls(高频)还是 mean(单次慢)"
echo "  锁竞争 vs CPU 饱和 :sys 占比高 + BLOCKED 多 → 锁"
echo "  GC vs 其他周期性   :时间戳对齐(唯一确定的方法)"
echo "  缓存击穿 vs 雪崩   :单 key 脉冲 vs 大批 key 同时失效"
echo "  日志 IO vs 网络 IO :JFR 的 FileWrite vs SocketWrite"
echo "═══════════════════════════════════════════════════════════════"

二、N+1 的专项检测(代码侧)

// src/main/kotlin/metrics/QueryCounter.kt
package metrics

import io.micrometer.core.instrument.Counter
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Timer
import kotlinx.coroutines.ThreadContextElement
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext

/**
 * 统计"每个请求执行了多少次数据库查询"。
 * 这是发现 N+1 最直接的手段——比看 pg_stat_statements 更早、更准。
 *
 * 用法:
 *   withQueryCounting { handleRequest() }   // 返回 (结果, 查询次数)
 *   或者把计数作为指标暴露出去
 */
class QueryCounter(registry: MeterRegistry) {

    private val queriesPerRequest = Timer.builder("app.db.queries.per.request")
        .description("每个请求的数据库查询次数(N+1 检测)")
        .publishPercentileHistogram(false)
        .register(registry)

    // 用一个可变的 holder 在协程/线程上下文中传递
    class Holder : AbstractCoroutineContextElement(Holder) {
        companion object Key : CoroutineContext.Key<Holder>
        var count: Int = 0
    }

    /** 在数据库调用处调用这个方法 */
    fun countQuery() {
        // 在协程场景下,通过上下文传递;在线程场景下用 ThreadLocal
    }

    /** 包装一次请求处理,记录查询次数 */
    suspend fun <T> countQueries(block: suspend () -> T): Pair<T, Int> {
        val holder = Holder()
        val result = kotlinx.coroutines.withContext(holder) { block() }
        queriesPerRequest.record(holder.count.toDouble())
        return result to holder.count
    }
}

/**
 * 更简单的实现:用 ThreadLocal(如果不用协程)
 */
object SimpleQueryCounter {
    private val counter = ThreadLocal.withInitial { intArrayOf(0) }

    fun reset() { counter.get()[0] = 0 }
    fun increment() { counter.get()[0]++ }
    fun get(): Int = counter.get()[0]
}

在 Repository 层埋点:

class OrderRepository(private val ds: DataSource) {

    fun findById(id: Long): Order? {
        SimpleQueryCounter.increment()      // ← 每执行一次查询就 +1
        return ds.connection.use { c -> /* ... */ }
    }

    fun findByIds(ids: List<Long>): Map<Long, Order> {
        SimpleQueryCounter.increment()      // ← 批量查询只 +1
        return ds.connection.use { c -> /* ... */ }
    }
}

在接口层验证:

get("/orders") {
    SimpleQueryCounter.reset()
    val orders = orderService.list()      // 内部可能触发 N+1
    val queryCount = SimpleQueryCounter.get()

    if (queryCount > 10) {
        log.warn("可能的 N+1:本次请求执行了 {} 次查询", queryCount)
    }
    call.respond(orders)
}

最佳实践:把这个计数做成指标(app_db_queries_per_request),设一个 P99 告警——一旦某次改动引入了 N+1,指标立刻能看出来。

三、GC 时间戳对齐(脚本化)

# tools/align-gc-with-latency.py <gc.log> <latency.csv>
"""
把 GC 停顿时间戳与延迟尖刺时间戳对齐。

这是判断"GC 是不是根因"的【唯一确定性方法】。

输入:
  gc.log        PostgreSQL/JVM 的 GC 日志(含时间戳与停顿时长)
  latency.csv   时间戳,延迟(ms) 两列
"""
import csv
import re
import sys
from datetime import datetime


def parse_gc_pauses(path):
    """从 GC 日志提取 (时间戳秒, 停顿毫秒)"""
    pauses = []
    with open(path, errors="ignore") as f:
        for line in f:
            if "Pause Young" not in line and "Pause Full" not in line:
                continue
            # 提取 uptime(形如 [12.456s])或时间戳
            m = re.search(r"\[([\d.]+)s\]", line)
            if not m:
                continue
            uptime = float(m.group(1))
            # 提取末尾的停顿时间(形如 4.231ms)
            pm = re.search(r"([\d.]+)ms\s*$", line.strip())
            pause_ms = float(pm.group(1)) if pm else 0.0
            pauses.append((uptime, pause_ms))
    return pauses


def parse_latency(path):
    """从 CSV 读取 (时间戳秒, 延迟ms)"""
    rows = []
    with open(path) as f:
        reader = csv.reader(f)
        for row in reader:
            if len(row) < 2:
                continue
            try:
                rows.append((float(row[0]), float(row[1])))
            except ValueError:
                continue
    return rows


def main(gc_path, latency_path, window_s=1.0):
    pauses = parse_gc_pauses(gc_path)
    latencies = parse_latency(latency_path)

    if not pauses or not latencies:
        print(f"❌ 数据不足:GC 停顿 {len(pauses)} 条,延迟 {len(latencies)} 条")
        print("   提示:GC 日志需要包含 uptime(-Xlog:gc*:time,uptime)")
        return

    # 找出延迟尖刺(超过 P95 的点)
    vals = sorted(v for _, v in latencies)
    p95 = vals[int(len(vals) * 0.95)]
    spikes = [(t, v) for t, v in latencies if v > p95]

    print("═" * 74)
    print("GC 停顿 vs 延迟尖刺 对齐分析")
    print("═" * 74)
    print()
    print(f"GC 停顿数    : {len(pauses)}")
    print(f"延迟样本数    : {len(latencies)}")
    print(f"P95 阈值     : {p95:.1f} ms")
    print(f"延迟尖刺数    : {len(spikes)}")
    print()

    if not spikes:
        print("⚠️  没有延迟尖刺(所有样本都在 P95 以下)")
        return

    # 对齐检测
    matched = 0
    for st, sv in spikes[:20]:
        for pt, pv in pauses:
            if abs(st - pt) <= window_s:
                matched += 1
                break

    checked = min(20, len(spikes))
    ratio = matched / checked * 100 if checked else 0

    print(f"对齐检查(窗口 ±{window_s}s):")
    print(f"  检查了 {checked} 个尖刺,其中 {matched} 个与 GC 停顿时间吻合({ratio:.0f}%)")
    print()

    if ratio > 70:
        print("✅ 高度对齐 → GC 停顿是延迟尖刺的根因")
        print()
        print("下一步:")
        print("  ① 看停顿幅度({:.1f}ms 级别的停顿能不能解释尖刺高度)".format(
            max(p for _, p in pauses)))
        print("  ② 采 alloc 火焰图找分配热点")
        print("  ③ 检查堆大小与 GC 选型")
    elif ratio > 30:
        print("⚠️  部分对齐 → GC 可能是原因之一,但不是全部")
        print("   建议:把 GC 停顿的贡献占比单独算出来")
    else:
        print("❌ 不对齐 → 排除 GC")
        print()
        print("这是一个重要的排除结论 —— 能省下大量时间。")
        print("下一步转向:连接池、慢查询、锁竞争、下游调用、容器节流")

    print("═" * 74)


if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("usage: align-gc-with-latency.py <gc.log> <latency.csv> [window_s]")
        sys.exit(1)
    main(sys.argv[1], sys.argv[2],
         float(sys.argv[3]) if len(sys.argv) > 3 else 1.0)

四、动手改造

改动 观察什么
在 Lab 6 的三个接口上分别跑 diagnose-data-bottlenecks.sh 只有 /slow-db 会显示 calls 异常
故意去掉一个索引并压测 模式二的 seq_scan 会飙升
用 align-gc-with-latency.py 跑一份真实数据 体会「对齐 → 确认」「不对齐 → 排除」两种结论
把 QueryCounter 接进你的 Repository 层 得到一个能提前发现 N+1 的指标
故意让某个查询 sleep 3 秒并观察阻塞链查询 验证模式三的检测能力

五、这段代码的局限

  • diagnose-data-bottlenecks.sh 需要 pg_stat_statements 与 psql 权限:没有这些会跳过对应检查。
  • align-gc-with-latency.py 需要 GC 日志包含 uptime:用 -Xlog:gc*:time,uptime 才有;如果只有绝对时间戳,需要改解析逻辑。
  • 对齐的时间窗口(默认 ±1 秒)需要调整:如果延迟采样粒度是 10 秒,窗口要放大;如果是 1 秒,窗口可以缩小。
  • QueryCounter 的协程版本还不完整:Holder 需要在数据库调用处能拿到(用 coroutineContext[Holder]),实现比 ThreadLocal 复杂。简单场景用 ThreadLocal 版本更实用。
  • 脚本不能替代判断:它给出的是「哪些模式可能」,最终还要人来看数据、下结论。