Jaeger仅记录SQL耗时与调用链,无法识别分页逻辑;真正瓶颈是OFFSET导致数据库扫描行数爆炸,需通过Jaeger中高OFFSET值、长duration及sql.query内容定位慢查询,结合执行计划分析优化。

Jaeger 本身不感知 GORM 分页逻辑,它只记录 SQL 执行耗时和调用链路;真正卡住的不是 Go 代码,而是 OFFSET 导致数据库扫描行数爆炸——Jaeger 只会告诉你 “这条 SELECT 耗了 1.2s”,但不会告诉你为什么。定位分页瓶颈,得让 Jaeger 暴露出那条慢 SQL 的真实参数和执行计划。
Jaeger 中如何识别分页慢查询
分页相关的慢 Span 在 Jaeger UI 里通常有这几个特征:
- Operation Name 是
SELECT FROM users这类泛化名,而非带业务语义的名称(如GetUserList),说明没用db.WithContext()注入 span 名 - Tag
sql.query显示类似SELECT * FROM users ORDER BY id ASC LIMIT 20 OFFSET 99980—— 看到大OFFSET值基本就能判定是问题源头 - Tag
db.statement和sql.query不一致?说明 GORM 日志插件和 Jaeger 插件用了不同 SQL 格式化逻辑,此时以db.statement为准(它更接近实际执行语句) - Span duration 高但 CPU 占用低?大概率是 I/O 等待,对应 MySQL 的
Handler_read_next指标飙升
GORM + Jaeger 联合排查必须加的两层埋点
默认 GORM 不会把分页参数透出到 Jaeger,你需要手动补全关键上下文:
- 在调用
db.Offset().Limit()前,用ctx = otel.Tracer("app").Start(ctx, "list_users_paginated")创建新 span,并 set tag:span.SetAttributes(attribute.Int64("page", page), attribute.Int("page_size", pageSize)) - 给 GORM 配置
logger时,启用Config.LogLevel = logger.Info,并确保日志中包含rows_affected和duration,再用logrus或zerolog把 trace_id 注入每条日志,方便和 Jaeger 关联 - 别依赖
gorm.io/plugin/opentelemetry自动插件:它对Offset和Limit参数不打 tag,且 v2.2+ 后对复合排序字段(如ORDER BY status, created_at DESC)的 SQL 解析不稳定
看到 OFFSET > 5000 就该立刻干预
Jaeger 不会警告你“这个 OFFSET 太大”,但它暴露的数据足够触发人工判断:
- 当
OFFSET值超过 5000,且 span duration > 300ms,基本可确认是数据库扫描瓶颈,不是网络或 GC 问题 - 同一接口多个 trace 中
OFFSET值离散(比如 99978、99982、99991),说明前端传的是页码而非游标,且用户正在猛翻页 - 对比两个 trace:一个
OFFSET 100耗时 8ms,一个OFFSET 10000耗时 1100ms → 线性增长趋势成立,证明是数据库引擎级缺陷 - 如果 Jaeger 显示该 span 下游还有子 span(如 Redis 缓存查询),但子 span 耗时几乎为 0,而主 span 占比 99%,说明瓶颈 100% 在 SQL 执行层
游标分页改造后,Jaeger 上怎么验证有效
换游标分页不是改个 WHERE 条件就完事,Jaeger 是唯一能验证是否真生效的工具:
- 上线后看新 trace 的
sql.query:应变成SELECT * FROM users WHERE id > 123456 ORDER BY id ASC LIMIT 20,不再出现OFFSET - 对比旧版和新版 P99 duration:游标分页的耗时应稳定在 10–25ms 区间,且不随“页深”变化
- 检查 span tag
db.statement中的id > ?参数值是否来自上一页最后一条记录——如果全是id > 0或重复值,说明后端没正确提取last_id - 若游标请求仍出现高耗时,立即查 Jaeger 中该 trace 的 error tag:常见是
invalid cursor: non-numeric last_id,说明前端传了非法值,后端没校验就拼进 SQL
最易被忽略的一点:Jaeger 能告诉你哪条 SQL 慢,但没法告诉你 COUNT 查询是否拖累整体响应。如果你在同一个 handler 里既查列表又查 Count(&total),这两个 span 会并列出现在 trace 中——但前端根本不需要 total 时,这个额外的 COUNT 就是纯性能负债。











