在微服务和云原生场景里,系统稳定性不再只是“程序能跑起来”这么简单。一个服务上线后,我们还要回答下面这些问题:
- 当前 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 的核心工作模式很简单:
- 应用暴露
/metrics - Prometheus 定时抓取
- 通过 PromQL 做查询、聚合和统计
- 告警规则命中后,把告警发送给 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_id、order_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 看板搭建步骤
- 在 Grafana 中添加 Prometheus 数据源
- 新建 Dashboard
- 为每个关键指标创建 Panel
- 配置变量,例如
route、instance、job - 配置阈值颜色,例如错误率超过 5% 标黄,超过 10% 标红
- 配置告警联动或跳转日志链接
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 里,就可以基于 route、request_id、level 等字段做过滤、聚合和告警。
6.2 Go 日志落地建议
在容器环境里,推荐实践通常是:
- 应用直接输出 JSON 到标准输出
- 采集器从 stdout 收集日志
- 不在业务代码里自己轮转日志文件
- 必须打印
request_id、trace_id、service、env、level等关键字段
本文示例里已经通过 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 错误报警应该如何做
错误报警通常有两条链路:
- 基于指标报警:例如 5xx 错误率飙升
- 基于错误事件报警:例如某类 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 错误追踪的实践建议
- 对外部依赖错误打标签,例如
db、redis、mq - 保留
request_id/trace_id,方便串联日志与错误平台 - 不要把所有业务校验失败都上报为高优先级异常
- 需要区分“用户输入错误”和“系统故障”
- 对重复错误做聚合,避免报警风暴
9. 将指标、日志、探针、错误串成一条排障链路
真正成熟的可观测性,不是把多个工具各自装好,而是让它们之间形成闭环。
一个典型排障路径通常如下:
- Grafana 看板发现错误率升高
- 点击面板跳转到 Loki / Kibana 查看错误日志
- 根据
request_id找到具体请求 - 在 Sentry 中查看同一类错误的堆栈与聚合事件
- 结合 Readiness / Liveness 状态,判断是依赖问题、代码 bug,还是实例故障
- 通过 Prometheus 历史趋势判断异常从何时开始出现,是否与发布相关
这一整套流程的关键,不在于“工具多”,而在于:
- 指标命名统一
- 日志字段统一
- 错误标签统一
- 请求 ID / Trace ID 能贯穿全链路
一旦这些基础打好,排障效率会比“手工 SSH 登录机器翻日志”高很多。
10. 小结
对 Golang 服务来说,监控与可观测性建设可以按照下面的顺序落地:
- 先给服务补齐基础指标:请求量、错误率、耗时、并发
- 用 Prometheus 统一抓取,并配置核心告警
- 用 Grafana 搭建首页看板和服务深度页
- 输出结构化日志,接入 ELK 或 Loki
- 实现
Readiness/Liveness探针,保障发布与调度稳定 - 接入错误追踪系统,让异常可以被自动聚合和报警
这样做的结果是:
- 你能更早发现问题
- 你能更快定位问题
- 你能更稳定地发布和运维 Go 服务
监控的终点不是“看见数据”,而是让系统出现异常时,你知道它为什么出问题、影响了谁、应该先处理什么。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!