线上故障排查的痛点,往往不在于问题本身有多复杂,而在于缺乏排查所需的线索。没有日志,出了异常只能靠猜;没有性能数据,接口变慢只能靠感觉。监控体系的核心目标,就是把系统运行过程中的关键信息持续记录下来,让每一次故障都有迹可循。本文从日志规范、日志采集、性能指标和链路追踪四个层面,聊一聊如何搭建一套实用的代码监控体系。

一、先把日志写对:结构化与分级规范
日志监控的第一步不是引入什么高级工具,而是把日志本身写规范。很多项目的日志一团糟,根源在于两点:一是格式随意,一个方法一个写法;二是级别滥用,正常流程打error,真正的错误反而被淹没。
日志级别应该有明确的语义边界。debug用于开发调试信息,生产环境默认关闭;info用于记录关键业务动作,如订单创建、支付完成;warn表示可自动恢复的异常情况,比如接口重试成功;error则必须留给需要人工介入的问题。遵循这个约定,运维在告警时才能做到error级别零噪音。
结构化日志是比纯文本更值得投入的做法。所谓结构化,就是让每条日志都包含固定字段,比如时间戳、级别、服务名、请求ID、耗时等。这样后续无论是检索还是统计,都不用依赖脆弱的正则匹配。以Python的logging为例,配合JSON格式化器可以轻松实现:
import logging
import json
from datetime import datetime, timezone
class JsonFormatter(logging.Formatter):
def format(self, record):
log_data = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"service": "order-service",
"trace_id": getattr(record, "trace_id", "-"),
"message": record.getMessage(),
}
if record.exc_info:
log_data["exception"] = self.formatException(record.exc_info)
return json.dumps(log_data, ensure_ascii=False)
logger = logging.getLogger("app")
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.info("订单创建成功", extra={"trace_id": "abc-123"})
这样做的好处立竿见影:日志平台可以直接按trace_id字段过滤出一次请求的完整轨迹,按level字段统计错误率,完全不需要写复杂的解析规则。此外还要注意控制日志量,循环里打日志、每条请求打十几个info,都会让存储成本失控。原则是:关键路径必有日志,高频路径只记异常。
二、集中式日志:从单机文件到统一平台
单机看文件的方式在微服务时代已经完全不够用了。一次用户请求可能经过网关、订单服务、库存服务、数据库代理等多个节点,日志分散在几十台机器上,靠ssh登录逐台grep是不现实的。集中式日志方案就是把所有日志统一收集、统一存储、统一查询。
经典的技术组合是ELK体系:Filebeat或Fluentd负责在每台机器上采集日志文件,传输给Logstash做解析和富化,最终写入Elasticsearch建立索引,前端用Kibana查询和可视化。这个链路对中小团队来说成熟稳定,部署资料也多。如果日志量特别大,可以在中间加一层Kafka做削峰,避免日志洪峰冲垮存储。
轻量级的替代方案也有不少选择。Loki是Grafana推出的日志系统,特点是只索引标签不索引全文,存储成本远低于Elasticsearch,配合Vector或Promtail采集,非常适合已经用Grafana做监控面板的团队。选型时可以参考这个简单的判断标准:
| 方案 | 存储成本 | 查询能力 | 适合场景 |
|---|---|---|---|
| ELK | 高 | 全文检索强 | 日志量中等、需要复杂搜索 |
| Loki | 低 | 标签过滤为主 | 已有Grafana、日志量极大 |
| ClickHouse | 中 | 聚合分析强 | 需要大量统计分析 |
无论选哪种方案,都建议在日志里带上统一的trace_id,并让所有服务透传这个ID。没有关联ID的日志平台,只是把分散在各机器的混乱集中到了一处而已。
三、性能指标监控:让慢无处藏身
日志回答的是“发生了什么”,指标回答的是“系统现在什么状态”。性能监控的核心是把系统的运行状态量化成一组可对比的数字,主要包括三大类:资源指标(CPU、内存、磁盘IO、网络)、应用指标(QPS、响应时间P99、错误率)和业务指标(下单量、支付成功率)。
响应时间的统计特别要注意分位数。平均值是最容易误导人的指标,如果1000个请求里有10个耗时5秒,其余都是50毫秒,平均值看起来仍然健康,但那10个用户的体验已经崩塌。P99分位数(99%的请求都快于这个值)才能真实反映长尾体验。Prometheus配合Histogram类型指标是当前的主流做法:
// 使用 Micrometer 在Spring Boot中记录接口耗时分布
@RestController
public class OrderController {
private final MeterRegistry registry;
public OrderController(MeterRegistry registry) {
this.registry = registry;
}
@PostMapping("/api/order")
public Result createOrder(@RequestBody OrderRequest req) {
Timer.Sample sample = Timer.start(registry);
try {
Order order = orderService.create(req);
return Result.ok(order);
} finally {
sample.stop(registry.timer("order.create.duration",
"status", "success"));
}
}
}
指标暴露出来之后,还需要配套告警规则。一个实用的经验法则是:资源类指标看趋势,比如CPU使用率连续10分钟超过85%才报警,避免瞬时尖峰造成误报;业务类指标看突变,比如错误率5分钟内从0.1%涨到5%就应该立刻告警。告警的关键不是多,而是每条告警都有明确的处理预案,否则很快就会被人忽略。
四、链路追踪与瓶颈定位实战
有了日志和指标,剩下的问题是:一次慢请求,到底慢在哪个环节?这就需要分布式链路追踪(APM)。主流标准是OpenTelemetry,它通过在请求入口生成trace上下文,并在服务间调用时透传,把一次请求经过的所有节点串成一条链路,每个节点的耗时一目了然。
以Go服务为例,接入OpenTelemetry的核心代码并不复杂:
package main
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
)
var tracer trace.Tracer
func init() {
tracer = otel.Tracer("order-service")
}
func handleOrder(ctx context.Context, req *OrderRequest) error {
ctx, span := tracer.Start(ctx, "handleOrder")
defer span.End()
// 数据库查询作为子span记录
_, dbSpan := tracer.Start(ctx, "queryInventory")
err := queryInventory(req.SkuID)
dbSpan.End()
if err != nil {
span.RecordError(err)
return err
}
return nil
}
拿到链路数据后,定位性能瓶颈就有了明确路径。先看链路总耗时,再找占比最大的span。如果慢在外部HTTP调用,考虑加超时控制和熔断降级;如果慢在数据库查询,用慢查询日志定位具体SQL,通常会发现是缺少索引或者一次循环里发起了N次查询(N+1问题);如果慢在锁等待,就要审查代码里的临界区范围。这套排查顺序可以复用在绝大多数场景。
最后想强调的是,监控体系不是一次性的工程,而是随业务演进持续迭代的过程。建议从小处着手:先统一日志格式,再接入指标采集,最后补上链路追踪,每一步都让下一次故障的排查时间更短一些。当你在凌晨被告警叫醒时,能打开面板五分钟内定位问题,这些投入就都值回来了。