返回首页

Golang项目实践:4.7 监控与可观测性

在微服务和云原生场景里,系统稳定性不再只是“程序能跑起来”这么简单。一个服务上线后,我们还要回答下面这些问题:

  • 当前 QPS 是多少?
  • 错误率有没有突然升高?
  • 哪个接口变慢了?
  • 某个 Pod 为什么一直重启?
  • 用户报错时,能不能快速定位到具体请求和日志?

这就是“监控与可观测性”要解决的问题。

对 Golang 服务来说,最常见的一套落地方案是:

  • Prometheus 负责指标采集与告警
  • Grafana 负责看板可视化
  • ELK 或 Loki 负责日志聚合与检索
  • Kubernetes 探针 负责健康检查与流量摘除
  • 错误追踪系统 负责异常聚合、报警与定位

本文会围绕一个可运行的 Go HTTP 服务,完整演示这套能力如何接入与落地。

1. 可观测性的核心组成

从工程实践看,可观测性通常由三类信号构成:

信号类型 解决的问题 常见工具
Metrics(指标) 系统是否健康、趋势是否异常 Prometheus、Alertmanager、Grafana
Logs(日志) 某一次请求到底发生了什么 ELK、Loki、Promtail、Filebeat
Traces / Errors(链路与错误) 哪个调用链、哪段代码出了问题 Sentry、OpenTelemetry、Jaeger

如果只有日志,没有指标,你会很难快速发现“系统已经整体变差”;如果只有指标,没有日志,你又无法定位具体请求;如果没有错误追踪,线上偶发异常通常只能靠人工翻日志碰运气。

因此,真正实用的方案不是单点工具,而是一套彼此关联的观测体系。

2. 示例项目结构

本文示例使用一个简单的订单服务,目录结构如下:

observability-demo/
├── go.mod
├── main.go
├── prometheus/
│   ├── prometheus.yml
│   └── alert_rules.yml
├── promtail/
│   └── config.yml
└── k8s/
    └── deployment.yaml

其中:

  • main.go:Go 服务本体,接入指标、日志、健康检查和错误追踪
  • prometheus.yml:Prometheus 抓取配置
  • alert_rules.yml:Prometheus 告警规则
  • promtail/config.yml:Loki 日志采集示例配置
  • k8s/deployment.yaml:Kubernetes 中的探针配置示例

3. 完整可运行的 Go 服务示例

3.1 go.mod

module github.com/example/observability-demo

go 1.22

require (
	github.com/getsentry/sentry-go v0.31.1
	github.com/prometheus/client_golang v1.20.5
	go.uber.org/zap v1.27.0
)

3.2 main.go

这个示例服务实现了以下能力:

  • 暴露 /metrics 给 Prometheus 抓取
  • 暴露 /livez/readyz 给 Kubernetes 探针使用
  • 为业务接口记录请求总数、响应耗时、错误数、并发数
  • 输出结构化 JSON 日志,便于 ELK / Loki 收集
  • 将异常上报到 Sentry,便于做错误追踪与告警
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"sync/atomic"
	"syscall"
	"time"

	"github.com/getsentry/sentry-go"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"
	"github.com/prometheus/client_golang/prometheus/promhttp"
	"go.uber.org/zap"
)

var (
	requestTotal = promauto.NewCounterVec(
		prometheus.CounterOpts{
			Name: "http_requests_total",
			Help: "Total number of HTTP requests",
		},
		[]string{"route", "method", "status"},
	)

	requestDuration = promauto.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "http_request_duration_seconds",
			Help:    "HTTP request latency in seconds",
			Buckets: prometheus.DefBuckets,
		},
		[]string{"route", "method", "status"},
	)

	inflightRequests = promauto.NewGauge(
		prometheus.GaugeOpts{
			Name: "http_inflight_requests",
			Help: "Current inflight HTTP requests",
		},
	)

	appErrorsTotal = promauto.NewCounterVec(
		prometheus.CounterOpts{
			Name: "app_errors_total",
			Help: "Total number of application errors",
		},
		[]string{"route", "type"},
	)

	dependencyHealth = promauto.NewGaugeVec(
		prometheus.GaugeOpts{
			Name: "dependency_health_status",
			Help: "Dependency health status, 1 means healthy, 0 means unhealthy",
		},
		[]string{"dependency"},
	)
)

type contextKey string

const requestIDKey contextKey = "request_id"

type App struct {
	ready  atomic.Bool
	logger *zap.Logger
}

type responseRecorder struct {
	http.ResponseWriter
	status int
}

func (r *responseRecorder) WriteHeader(code int) {
	r.status = code
	r.ResponseWriter.WriteHeader(code)
}

func main() {
	logger, err := zap.NewProduction()
	if err != nil {
		panic(err)
	}
	defer logger.Sync()

	if dsn := os.Getenv("SENTRY_DSN"); dsn != "" {
		err = sentry.Init(sentry.ClientOptions{
			Dsn:              dsn,
			Environment:      env("APP_ENV", "dev"),
			TracesSampleRate: 1.0,
		})
		if err != nil {
			logger.Fatal("init sentry failed", zap.Error(err))
		}
		defer sentry.Flush(2 * time.Second)
	}

	app := &App{logger: logger}
	app.ready.Store(false)
	dependencyHealth.WithLabelValues("database").Set(0)

	mux := http.NewServeMux()
	mux.Handle("/metrics", promhttp.Handler())
	mux.HandleFunc("/livez", app.handleLiveness)
	mux.HandleFunc("/readyz", app.handleReadiness)
	mux.Handle("/hello", app.observe("/hello", http.HandlerFunc(app.handleHello)))
	mux.Handle("/orders", app.observe("/orders", http.HandlerFunc(app.handleCreateOrder)))

	server := &http.Server{
		Addr:              ":" + env("PORT", "8080"),
		Handler:           requestIDMiddleware(loggingMiddleware(logger, mux)),
		ReadHeaderTimeout: 3 * time.Second,
	}

	go app.bootstrap()

	go func() {
		logger.Info("server starting", zap.String("addr", server.Addr))
		if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			logger.Fatal("server exited unexpectedly", zap.Error(err))
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	app.ready.Store(false)
	if err := server.Shutdown(ctx); err != nil {
		logger.Error("graceful shutdown failed", zap.Error(err))
	}

	logger.Info("server stopped")
}

func (a *App) bootstrap() {
	a.logger.Info("bootstrap started")
	time.Sleep(3 * time.Second)

	dependencyHealth.WithLabelValues("database").Set(1)
	a.ready.Store(true)
	a.logger.Info("application is ready")
}

func (a *App) observe(route string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		inflightRequests.Inc()
		defer inflightRequests.Dec()

		start := time.Now()
		recorder := &responseRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(recorder, r)

		status := strconv.Itoa(recorder.status)
		requestTotal.WithLabelValues(route, r.Method, status).Inc()
		requestDuration.WithLabelValues(route, r.Method, status).Observe(time.Since(start).Seconds())

		if recorder.status >= 500 {
			appErrorsTotal.WithLabelValues(route, "http_5xx").Inc()
		}
	})
}

func (a *App) handleHello(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]any{
		"message":    "hello, observability",
		"request_id": requestIDFromContext(r.Context()),
		"time":       time.Now().Format(time.RFC3339),
	})
}

func (a *App) handleCreateOrder(w http.ResponseWriter, r *http.Request) {
	if !a.ready.Load() {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "service not ready"})
		return
	}

	delayMS, _ := strconv.Atoi(r.URL.Query().Get("delay_ms"))
	if delayMS > 0 {
		time.Sleep(time.Duration(delayMS) * time.Millisecond)
	}

	if r.URL.Query().Get("fail") == "true" {
		err := fmt.Errorf("create order failed, request_id=%s", requestIDFromContext(r.Context()))
		a.captureError(r.Context(), err, "/orders")
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}

	writeJSON(w, http.StatusCreated, map[string]any{
		"order_id":   fmt.Sprintf("ord-%d", time.Now().UnixNano()),
		"status":     "created",
		"request_id": requestIDFromContext(r.Context()),
	})
}

func (a *App) handleLiveness(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "alive"})
}

func (a *App) handleReadiness(w http.ResponseWriter, r *http.Request) {
	if !a.ready.Load() || dependencyHealth.WithLabelValues("database") == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not ready"})
		return
	}

	writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}

func (a *App) captureError(ctx context.Context, err error, route string) {
	appErrorsTotal.WithLabelValues(route, "business_error").Inc()
	a.logger.Error("business error",
		zap.String("route", route),
		zap.String("request_id", requestIDFromContext(ctx)),
		zap.Error(err),
	)

	if hub := sentry.CurrentHub(); hub != nil {
		hub.WithScope(func(scope *sentry.Scope) {
			scope.SetTag("route", route)
			scope.SetTag("request_id", requestIDFromContext(ctx))
			hub.CaptureException(err)
		})
	}
}

func requestIDMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requestID := r.Header.Get("X-Request-ID")
		if requestID == "" {
			requestID = strconv.FormatInt(time.Now().UnixNano(), 36)
		}

		ctx := context.WithValue(r.Context(), requestIDKey, requestID)
		w.Header().Set("X-Request-ID", requestID)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func loggingMiddleware(logger *zap.Logger, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		recorder := &responseRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(recorder, r)

		logger.Info("http request",
			zap.String("method", r.Method),
			zap.String("path", r.URL.Path),
			zap.String("query", r.URL.RawQuery),
			zap.Int("status", recorder.status),
			zap.Duration("duration", time.Since(start)),
			zap.String("request_id", requestIDFromContext(r.Context())),
			zap.String("remote_addr", r.RemoteAddr),
		)
	})
}

func requestIDFromContext(ctx context.Context) string {
	if v, ok := ctx.Value(requestIDKey).(string); ok {
		return v
	}
	return ""
}

func writeJSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(data)
}

func env(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

3.3 本地运行方式

先安装依赖,然后启动服务:

go mod tidy
go run .

启动后可以手动访问几个接口:

curl http://localhost:8080/hello
curl http://localhost:8080/livez
curl http://localhost:8080/readyz
curl -X POST "http://localhost:8080/orders?delay_ms=300"
curl -X POST "http://localhost:8080/orders?fail=true"
curl http://localhost:8080/metrics

这一步完成后,一个具备基础可观测能力的 Go 服务就已经跑起来了。

4. Prometheus 指标采集与告警

Prometheus 的核心工作模式很简单:

  1. 应用暴露 /metrics
  2. Prometheus 定时抓取
  3. 通过 PromQL 做查询、聚合和统计
  4. 告警规则命中后,把告警发送给 Alertmanager 或 Grafana Alerting

4.1 Prometheus 抓取配置

prometheus/prometheus.yml 示例:

global:
  scrape_interval: 15s
  evaluation_interval: 15s

rule_files:
  - /etc/prometheus/alert_rules.yml

scrape_configs:
  - job_name: golang-observability-demo
    static_configs:
      - targets:
          - host.docker.internal:8080

如果你的 Prometheus 和应用部署在 Kubernetes 中,通常会改成基于 ServiceMonitor 或 Pod 服务发现,而不是 static_configs

4.2 我们到底应该采集哪些指标

对于一个典型 Web 服务,最值得优先关注的指标通常有:

指标 作用 示例
请求总量 观察流量趋势 http_requests_total
错误数 / 错误率 判断服务是否异常 app_errors_total、5xx 比例
请求耗时 判断接口是否变慢 http_request_duration_seconds
并发请求数 判断是否存在瞬时压力 http_inflight_requests
依赖健康度 判断外部资源是否可用 dependency_health_status

在 Go 里,Prometheus 官方客户端非常成熟。实际开发时有两个经验特别重要:

  • 标签不要滥用,尤其不要把 user_idorder_id 这种高基数字段放到 label 里
  • 指标命名要稳定,一旦看板和告警依赖它们,频繁改名会导致整套观测链断掉

4.3 常用 PromQL 查询示例

查看最近 5 分钟的请求速率:

sum(rate(http_requests_total[5m]))

按状态码查看请求速率:

sum by (status) (rate(http_requests_total[5m]))

查看 95 分位响应耗时:

histogram_quantile(
  0.95,
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route)
)

查看最近 5 分钟的错误率:

sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))

4.4 Prometheus 告警规则

prometheus/alert_rules.yml 示例:

groups:
  - name: golang-observability-demo
    rules:
      - alert: ServiceDown
        expr: up{job="golang-observability-demo"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "服务不可用"
          description: "Prometheus 已连续 1 分钟抓取不到 golang-observability-demo。"

      - alert: HighErrorRate
        expr: |
          (
            sum(rate(http_requests_total{job="golang-observability-demo", status=~"5.."}[5m]))
            /
            sum(rate(http_requests_total{job="golang-observability-demo"}[5m]))
          ) > 0.05
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: "服务错误率过高"
          description: "最近 5 分钟错误率超过 5%,且持续 3 分钟。"

      - alert: HighLatencyP95
        expr: |
          histogram_quantile(
            0.95,
            sum(rate(http_request_duration_seconds_bucket{job="golang-observability-demo"}[5m])) by (le, route)
          ) > 0.8
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "接口延迟过高"
          description: "P95 响应时间超过 800ms,持续 5 分钟。"

      - alert: DependencyUnhealthy
        expr: dependency_health_status{dependency="database"} == 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "数据库依赖异常"
          description: "应用检测到数据库依赖处于不健康状态。"

4.5 告警设计的实践建议

很多团队的告警系统并不是“太少”,而是“太吵”。真正有效的告警,需要满足两个条件:

  • 告警触发后,值班人员需要立刻处理
  • 告警内容可以直接指向问题,而不是只告诉你“有点不对劲”

因此建议:

告警级别 适用场景 处理建议
critical 服务不可用、核心依赖不可用、数据错误 立即通知电话 / IM / 值班系统
warning 延迟升高、错误率波动、容量接近阈值 通过群消息或仪表盘关注
info 发布完成、实例扩缩容、探针波动 记录即可,不建议打扰值班人

告警规则本身不是目标,减少无效告警、提升问题定位效率 才是目标。

5. Grafana 可视化看板搭建

Prometheus 擅长存储和查询指标,但日常排查时,人更适合看图。Grafana 的职责就是把 PromQL 查询结果变成可快速识别的问题看板。

5.1 一个最小可用的服务看板应该看什么

一个 Go 服务的基础看板,建议至少包含下面这些面板:

面板名称 目的 推荐查询
QPS / RPS 观察请求量趋势 sum(rate(http_requests_total[5m]))
错误率 判断服务是否异常 sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))
P95 延迟 发现慢接口 histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route))
Inflight Requests 判断流量尖峰与积压 http_inflight_requests
依赖健康状态 判断外部依赖是否可用 dependency_health_status

5.2 看板搭建步骤

  1. 在 Grafana 中添加 Prometheus 数据源
  2. 新建 Dashboard
  3. 为每个关键指标创建 Panel
  4. 配置变量,例如 routeinstancejob
  5. 配置阈值颜色,例如错误率超过 5% 标黄,超过 10% 标红
  6. 配置告警联动或跳转日志链接

5.3 面板查询示例

请求量趋势:

sum(rate(http_requests_total{job="golang-observability-demo"}[5m]))

按路由查看吞吐:

sum by (route) (rate(http_requests_total{job="golang-observability-demo"}[5m]))

按路由查看 P95 延迟:

histogram_quantile(
  0.95,
  sum(rate(http_request_duration_seconds_bucket{job="golang-observability-demo"}[5m])) by (le, route)
)

错误率单值面板:

sum(rate(http_requests_total{job="golang-observability-demo", status=~"5.."}[5m]))
/
sum(rate(http_requests_total{job="golang-observability-demo"}[5m]))

5.4 Grafana 看板设计经验

Grafana 看板不是“图越多越专业”,而是“越能回答问题越专业”。实际搭建时建议遵循这几个原则:

  • 首页看全局:流量、错误率、延迟、实例状态要一眼可见
  • 二级看细节:按路由、实例、区域拆分
  • 统一时间窗口:默认 15m、1h、24h 三种常用粒度
  • 指标和日志打通:从面板直接跳转到 Loki / Elasticsearch 对应请求日志
  • 颜色有语义:绿色正常、黄色警告、红色危险,不要滥用彩色

如果你们已经进入 Kubernetes 和多服务阶段,还可以把 Dashboard 按层次拆分为:

  • 平台总览
  • 业务服务总览
  • 单服务深度排障页
  • 数据库 / MQ / 缓存依赖页

6. 分布式日志系统(ELK / Loki)

指标擅长回答“哪里不正常”,日志擅长回答“到底发生了什么”。线上排障时,二者通常是联动使用的。

6.1 为什么日志一定要结构化

对于 Go 服务,最常见的问题不是“没有日志”,而是“日志很多,但没法检索”。

例如这类日志:

2026-06-08 10:00:01 create order failed user=12 error=timeout

人能勉强看懂,但日志平台很难稳定分析字段。相比之下,结构化 JSON 更适合机器处理:

{"level":"error","msg":"create order failed","route":"/orders","request_id":"abc123","error":"timeout"}

这样在 ELK 或 Loki 里,就可以基于 routerequest_idlevel 等字段做过滤、聚合和告警。

6.2 Go 日志落地建议

在容器环境里,推荐实践通常是:

  • 应用直接输出 JSON 到标准输出
  • 采集器从 stdout 收集日志
  • 不在业务代码里自己轮转日志文件
  • 必须打印 request_idtrace_idserviceenvlevel 等关键字段

本文示例里已经通过 zap.NewProduction() 输出了结构化日志,这也是生产环境常见做法。

6.3 Loki + Promtail 配置示例

promtail/config.yml 示例:

server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://localhost:3100/loki/api/v1/push

scrape_configs:
  - job_name: golang-observability-demo
    static_configs:
      - targets:
          - localhost
        labels:
          job: golang-observability-demo
          service: golang-observability-demo
          __path__: /var/log/golang-observability-demo/*.log

如果应用写入文件,Promtail 可以直接采集指定路径;如果应用跑在 Kubernetes 中,更常见的是从容器标准输出采集。

在 Grafana + Loki 中,可以用 LogQL 检索错误日志:

{job="golang-observability-demo"} |= "error"

按请求 ID 精确定位:

{job="golang-observability-demo"} |= "request_id=ddx4s9k2"

统计最近 5 分钟错误日志条数:

sum(count_over_time({job="golang-observability-demo"} |= "error" [5m]))

6.4 ELK 方案示例

如果你们团队用的是 ELK,也可以采用类似思路:

  • 应用输出 JSON 日志
  • Filebeat / Fluent Bit 负责采集
  • Elasticsearch 负责索引与查询
  • Kibana 负责检索和看板

一个简单的 Filebeat 配置示例如下:

filebeat.inputs:
  - type: filestream
    id: golang-observability-demo
    paths:
      - /var/log/golang-observability-demo/*.log
    parsers:
      - ndjson:
          overwrite_keys: true
          add_error_key: true

output.elasticsearch:
  hosts: ["http://localhost:9200"]
  index: "golang-observability-demo-%{+yyyy.MM.dd}"

6.5 ELK 与 Loki 怎么选

方案 优势 注意点
ELK 生态成熟、全文检索强、适合复杂日志分析 资源占用相对更高,运维成本也更高
Loki 与 Grafana 集成自然、成本较低、适合云原生场景 不适合无限制高基数 label,日志建模要克制

如果团队已经广泛使用 Grafana 和 Prometheus,那么 Loki 往往是更轻量、整合度更高的选择;如果日志检索需求非常复杂,且已有成熟 Elasticsearch 体系,ELK 也依然非常合适。

7. 健康检查与 Readiness / Liveness Probe

健康检查不是“多暴露两个接口”这么简单,它直接影响流量调度、实例重启和发布稳定性。

7.1 Liveness 和 Readiness 的区别

探针类型 作用 失败后影响
Liveness Probe 判断进程是否活着 失败时容器会被重启
Readiness Probe 判断实例是否能接流量 失败时实例会被从 Service 后端摘除

一个常见误区是把两者写成完全一样的逻辑。这样做会导致依赖短时抖动时,实例被反复重启,问题反而更严重。

7.2 设计原则

  • Liveness 要保守:只判断“进程是否卡死、主循环是否存活”
  • Readiness 要严格:需要确认服务初始化完成,关键依赖可用
  • 不要把瞬时失败直接等价为致命失败
  • 优先给 Readiness 失败,让实例先摘流量,而不是直接重启

7.3 本文示例里的探针逻辑

在本文示例中:

  • /livez:始终返回进程活着
  • /readyz:只有初始化完成后才返回 ready

这就是最基础但非常实用的写法。

7.4 Kubernetes 部署示例

k8s/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: golang-observability-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: golang-observability-demo
  template:
    metadata:
      labels:
        app: golang-observability-demo
    spec:
      containers:
        - name: app
          image: golang-observability-demo:latest
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080
            initialDelaySeconds: 3
            periodSeconds: 5
            timeoutSeconds: 1
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /livez
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 10
            timeoutSeconds: 1
            failureThreshold: 3

7.5 生产环境中的进一步优化

如果服务启动特别慢,或者依赖初始化过程复杂,可以继续加入 startupProbe,避免应用还没完全启动就被 livenessProbe 误杀。

startupProbe:
  httpGet:
    path: /readyz
    port: 8080
  periodSeconds: 3
  failureThreshold: 20

这在 Go 服务启动时需要加载配置、预热缓存、建立连接池的场景里非常常见。

8. 错误追踪与报警

仅靠日志检索错误,最大的问题是:你通常是“知道出问题之后才去搜”。而错误追踪系统的价值,在于它会主动帮你聚合、归类、采样并报警。

8.1 为什么错误追踪非常重要

线上错误往往有这些特点:

  • 发生频率不高,但影响真实用户
  • 单靠 error log 很难知道影响范围
  • 同一类错误会在多个实例重复出现
  • 开发同学希望看到堆栈、上下文、用户信息、请求路径

错误追踪系统可以把离散错误聚合成“问题事件”,并附带:

  • 错误堆栈
  • 请求上下文
  • 环境信息
  • 标签和用户维度
  • 首次发生时间、最近发生时间、影响次数

8.2 在 Go 中接入 Sentry

本文示例已经在 main.go 中接入了 sentry-go,当请求 POST /orders?fail=true 时,会执行:

a.captureError(r.Context(), err, "/orders")

初始化依赖环境变量 SENTRY_DSN

export SENTRY_DSN="https://<your-dsn>@sentry.io/<project-id>"
go run .

一旦业务错误被上报,在 Sentry 中你可以看到:

  • 错误信息
  • 路由标签 route=/orders
  • 请求 ID
  • 发生频次
  • 错误聚合情况

8.3 错误报警应该如何做

错误报警通常有两条链路:

  1. 基于指标报警:例如 5xx 错误率飙升
  2. 基于错误事件报警:例如某类 panic 或数据库异常突然大量出现

实际生产中,两者最好同时具备。

8.4 panic 兜底恢复示例

对于 HTTP 服务,建议加一层 recover 中间件,避免 panic 直接把整个请求链打断,并确保错误被记录和上报。

func recoveryMiddleware(logger *zap.Logger, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				err := fmt.Errorf("panic recovered: %v", rec)
				logger.Error("panic recovered",
					zap.Any("panic", rec),
					zap.String("request_id", requestIDFromContext(r.Context())),
				)

				sentry.CurrentHub().Recover(rec)
				sentry.Flush(2 * time.Second)

				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusInternalServerError)
				_ = json.NewEncoder(w).Encode(map[string]string{
					"error": err.Error(),
				})
			}
		}()

		next.ServeHTTP(w, r)
	})
}

如果要启用它,只需要在组装 server.Handler 时包一层:

handler := requestIDMiddleware(
	recoveryMiddleware(logger,
		loggingMiddleware(logger, mux),
	),
)

server := &http.Server{
	Addr:    ":" + env("PORT", "8080"),
	Handler: handler,
}

8.5 错误追踪的实践建议

  • 对外部依赖错误打标签,例如 dbredismq
  • 保留 request_id / trace_id,方便串联日志与错误平台
  • 不要把所有业务校验失败都上报为高优先级异常
  • 需要区分“用户输入错误”和“系统故障”
  • 对重复错误做聚合,避免报警风暴

9. 将指标、日志、探针、错误串成一条排障链路

真正成熟的可观测性,不是把多个工具各自装好,而是让它们之间形成闭环。

一个典型排障路径通常如下:

  1. Grafana 看板发现错误率升高
  2. 点击面板跳转到 Loki / Kibana 查看错误日志
  3. 根据 request_id 找到具体请求
  4. 在 Sentry 中查看同一类错误的堆栈与聚合事件
  5. 结合 Readiness / Liveness 状态,判断是依赖问题、代码 bug,还是实例故障
  6. 通过 Prometheus 历史趋势判断异常从何时开始出现,是否与发布相关

这一整套流程的关键,不在于“工具多”,而在于:

  • 指标命名统一
  • 日志字段统一
  • 错误标签统一
  • 请求 ID / Trace ID 能贯穿全链路

一旦这些基础打好,排障效率会比“手工 SSH 登录机器翻日志”高很多。

10. 小结

对 Golang 服务来说,监控与可观测性建设可以按照下面的顺序落地:

  1. 先给服务补齐基础指标:请求量、错误率、耗时、并发
  2. 用 Prometheus 统一抓取,并配置核心告警
  3. 用 Grafana 搭建首页看板和服务深度页
  4. 输出结构化日志,接入 ELK 或 Loki
  5. 实现 Readiness / Liveness 探针,保障发布与调度稳定
  6. 接入错误追踪系统,让异常可以被自动聚合和报警

这样做的结果是:

  • 你能更早发现问题
  • 你能更快定位问题
  • 你能更稳定地发布和运维 Go 服务

监控的终点不是“看见数据”,而是让系统出现异常时,你知道它为什么出问题、影响了谁、应该先处理什么


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Golang项目实践:4.6 容器化与云原生部署

下一篇

Golang工程化:5.3 错误处理、日志与配置规范