慢 SQL 不只是数据库问题。一次查询变慢,可能先拖慢接口,再占满连接池,最终演变成超时、重试和级联故障。传统做法通常是“收集更多遥测数据”,但更多日志、链路和指标不一定带来更多理解。
更有效的方向,是把慢查询直接转换成可靠性指标:哪些操作变慢、影响了哪些服务、慢查询发生得多频繁、它是否已经推高错误率或延迟。OpenTelemetry 可以提供统一的追踪和指标采集基础,真正的价值在于围绕故障决策设计数据。
不要只记录“这条 SQL 很慢”
单独记录一条 SQL 耗时,通常只能回答“发生了什么”。生产排障还需要回答几个问题:
- 慢查询影响了哪个服务和接口?
- 是偶发长尾,还是整体性能退化?
- 哪种数据库操作最容易变慢?
- 慢查询出现后,错误率和请求延迟是否同步上升?
- 是否应该报警、限流、回滚,或者扩容数据库?
因此,慢查询观测至少应包含两类数据:
- 追踪数据:定位某次请求中的具体数据库调用,并关联上游 HTTP span。
- 聚合指标:观察查询耗时分布、慢查询数量和错误数量,避免依赖逐条日志搜索。
查询文本本身也需要谨慎处理。不要把完整 SQL 或用户输入直接作为高基数指标标签。可以在 span 中保留经过脱敏或参数化的语句信息,同时只把稳定的操作名、数据库类型和服务名放入指标维度。
用两个维度描述慢查询
一个实用的设计是把查询耗时记录为 histogram,再单独记录超过阈值的慢查询计数。
耗时 histogram 用于观察 p50、p95 和 p99;慢查询计数用于快速回答“问题是否正在发生”。两者结合后,指标不再只是一个平均值:平均耗时正常时,p99 仍可能暴露出严重长尾。
推荐优先使用低基数维度,例如:
service.namedb.systemdb.operation.namedb.namespace或数据库逻辑名称deployment.environment.name
应避免把原始 SQL、请求 ID、用户 ID 或完整 URL 放入指标标签。这些字段适合放在 trace span 或日志中,而不是聚合指标中。
一个可运行的 Python 示例
下面的示例使用 SQLite 演示手工埋点。它会:
- 为每次数据库调用创建 OpenTelemetry span;
- 记录查询耗时 histogram;
- 对超过阈值的查询增加慢查询计数;
- 通过
ConsoleSpanExporter和ConsoleMetricExporter输出结果。
示例假设使用 Python 3.9 及以上。先安装依赖:
python -m pip install \
opentelemetry-api \
opentelemetry-sdk
保存为 slow_query_metrics.py 后运行:
import sqlite3
import time
from contextlib import contextmanager
from opentelemetry import metrics, trace
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
SLOW_QUERY_SECONDS = 0.05
resource = Resource.create({
"service.name": "orders-api",
"deployment.environment.name": "production",
})
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(tracer_provider)
tracer = trace.get_tracer("orders-api.database")
metric_reader = PeriodicExportingMetricReader(
ConsoleMetricExporter(),
export_interval_millis=5_000,
)
metrics.set_meter_provider(MeterProvider(resource=resource, metric_readers=[metric_reader]))
meter = metrics.get_meter("orders-api.database")
query_duration = meter.create_histogram(
"db.client.operation.duration",
unit="s",
description="Database operation duration in seconds",
)
slow_queries = meter.create_counter(
"db.client.slow_queries",
unit="{query}",
description="Number of database operations over the slow-query threshold",
)
@contextmanager
def traced_query(connection, operation_name, sql, parameters=()):
attributes = {
"db.system": "sqlite",
"db.operation.name": operation_name,
}
start = time.perf_counter()
with tracer.start_as_current_span("sqlite " + operation_name) as span:
span.set_attributes(attributes)
# Keep SQL out of metric labels. In production, store only a sanitized
# or parameterized statement in the span when it is needed for debugging.
span.set_attribute("db.query.summary", operation_name)
try:
cursor = connection.execute(sql, parameters)
yield cursor
except Exception as exc:
span.record_exception(exc)
span.set_status(trace.StatusCode.ERROR, str(exc))
raise
finally:
duration = time.perf_counter() - start
query_duration.record(duration, attributes)
if duration >= SLOW_QUERY_SECONDS:
slow_queries.add(1, attributes)
span.set_attribute("db.slow_query", True)
span.set_attribute("db.slow_query.threshold_seconds", SLOW_QUERY_SECONDS)
span.set_attribute("db.duration_seconds", duration)
if __name__ == "__main__":
connection = sqlite3.connect(":memory:")
connection.execute("create table orders (id integer primary key, status text)")
connection.executemany(
"insert into orders(status) values (?)",
[("paid",), ("pending",), ("paid",)],
)
connection.commit()
with traced_query(
connection,
"select_orders_by_status",
"select id, status from orders where status = ?",
("paid",),
) as cursor:
print(cursor.fetchall())
# Keep the process alive long enough for the periodic metric reader to export.
time.sleep(6)
connection.close()
真实服务中,数据库驱动的自动 instrumentation 通常可以提供基础 span。手工埋点仍然适合补充业务操作名、慢查询阈值和团队约定的指标。示例中的阈值只是演示值,实际应根据数据库类型、接口预算和历史分布调整。
从指标到告警决策
有了数据之后,重点不是建立更多面板,而是让指标对应明确动作。可以这样实践:
- 用查询耗时 histogram 观察 p95、p99,并按
db.operation.name分组。 - 用慢查询计数观察单位时间内的慢查询比例,而不是只看绝对数量。
- 将数据库 span 与 HTTP span 关联,确认慢查询是否贡献了接口尾延迟。
- 将慢查询趋势与连接池使用率、数据库 CPU、锁等待和错误率放在同一时间窗口比较。
- 为持续超标的操作建立告警,并链接到查询计划、索引检查或回滚流程。
告警条件可以从简单版本开始,例如“某操作过去 10 分钟的 p95 超过接口预算”,再加入请求量和错误率条件,降低低流量场景下的误报。具体查询语法取决于使用的 OpenTelemetry Collector 和后端,不能把某个厂商的 PromQL、TraceQL 或 SQL 查询直接当作通用标准。
采集边界与成本
慢查询观测也有边界。完整 SQL 可能包含密码、令牌、个人信息或业务参数;高基数标签会增加指标后端成本,并让面板变得难以聚合。生产环境应明确脱敏策略、采样策略和数据保留周期。
一个稳妥的落地顺序是:
- 先统一服务名、环境名和数据库操作名。
- 再为关键查询记录耗时 histogram 和慢查询计数。
- 用 trace 验证指标是否能回溯到具体请求和数据库调用。
- 最后根据历史分布设置 p95、p99 和慢查询率告警。
慢查询只有在能推动行动时才是可靠性信号。OpenTelemetry 负责把 trace 和 metric 以统一方式送出去,而阈值、维度、告警和排障流程,仍需要结合服务的延迟预算与数据库运行特征设计。