日志、健康与优雅退出
日志、健康与优雅退出 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
这课把服务从「能跑」升级到「可运维」:用 slog 输出结构化日志,healthz 真正探到数据库,Ctrl+C 触发优雅退出——停止收新请求、等在途请求收尾、再关资源,顺序一步不乱。
问题场景
部署到服务器后,日志是 printf 大杂烩没法过滤;健康检查只会说「进程活着」却不知道数据库挂没挂;Ctrl+C 一按,在途请求被腰斩,用户提交到一半的书签丢了。这三件事平时看不出来,出了事故全是证据。
核心解释
日志升级成 slog,结构化输出可直接进日志系统检索:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
slog.Info("server started", "addr", ":8080")
// {"time":"2026-10-26T10:00:00+08:00","level":"INFO","msg":"server started","addr":":8080"}
键值对让每条日志自带检索维度,G07 的 withLogging 顺手升级:用 slog 输出,字段带请求 ID、方法、路径、耗时、状态码,之后按 req=xxx 一条请求全链路即出。日志写 stdout 而不是文件:收集是部署环境(容器、systemd、日志代理)的职责,进程只管往标准输出写。级别按影响用:常规运行 Info,需要留意才出现的进 Warn,意味着人要介入的才用 Error——全站 Error 泛滥等于没有告警。
健康检查从「能回话」升级到「真健康」:
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
defer cancel()
if err := store.Ping(ctx); err != nil { // 真正探数据库
slog.Error("health check failed", "err", err)
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.Write([]byte("ok"))
})
优雅退出的完整编排,顺序是本课核心:
func main() {
store, err := NewSQLiteStore("file:bookmark.db")
if err != nil {
slog.Error("init store", "err", err)
os.Exit(1)
}
defer store.Close() // 兜底:所有正常退出路径最后关库
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{
Addr: ":8080",
Handler: withLogging(withRequestID(newMux(store))),
ReadHeaderTimeout: 5 * time.Second, // 慢连接攻击防护
}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server exited", "err", err)
}
}()
slog.Info("server started", "addr", ":8080")
<-ctx.Done() // 等 Ctrl+C 或 kill
slog.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil { // 1. 停收新请求,等在途
slog.Error("forced shutdown", "err", err)
}
cancel() // 2. 通知后台任务收工(G09 的 janitor)
wg.Wait() // 3. 确认后台任务全部结束
slog.Info("bye")
}
三步顺序不可乱:先 Shutdown 停止接受新连接并等在途请求(最多等 10 秒),再 cancel 通知后台任务,最后 wg.Wait 确认无残留,defer store.Close 兜底。顺序错了会怎样?先 cancel 再 Shutdown,在途请求依赖的后台工作可能已经放弃;先关库再等在途请求,所有查询直接报错。
示例拆解
go run .
# {"level":"INFO","msg":"server started","addr":":8080"}
curl http://127.0.0.1:8080/healthz # ok
curl http://127.0.0.1:8080/bookmarks # 正常数据
# 终端按 Ctrl+C:
# {"level":"INFO","msg":"shutting down"}
# {"level":"INFO","msg":"bye"}
进阶验证「在途请求被等完」:加一个 sleep 3 秒的慢接口,后台发 curl,1 秒时 Ctrl+C——观察到 curl 在 3 秒处拿到完整响应,进程在其后才退出。SIGTERM(kill 默认信号)行为一致,容器编排靠它停服。
动手任务
- 把 withLogging 换成 slog 输出,字段:request_id、method、path、duration_ms、status。
- 实现 store.Ping(转发 db.PingContext),把 DSN 改成坏路径再打 healthz,确认返回 503。
- 按上面方式验证慢接口在 Ctrl+C 后被等完,把观察结果写进项目 README 的退出行为一节。
常见错误
- Shutdown 不给超时:一个卡死的在途连接让服务永远退不出去,必须带 deadline。
- 用 log.Fatal 退出主流程:它直接 os.Exit(1),defer 全部跳过,资源清理全失效。
- 日志自己写文件加轮转:进程只写 stdout,轮转交给 logrotate 或容器运行时。
- healthz 只回 200 不探依赖:探活永远绿,数据库挂了没人知道,就绪探针形同虚设。
检查与复盘
完成下方检查题。自查清单:
- 退出顺序固定:停收新请求 → 等在途 → cancel 后台 → Wait → Close
- 日志是带字段的 JSON,能按 request_id 检索单条请求
- healthz 探到数据库,故障时 503 而不是假 200
参考资料
- 标准库 log/slog —— 结构化日志的官方方案
- 标准库 http.Server.Shutdown —— 优雅退出语义
- 标准库 os/signal —— NotifyContext 信号转取消
读完这一课了?
标记完成后,工作台会安排接下来的学习。