G14第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

日志、健康与优雅退出

日志、健康与优雅退出 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

这课把服务从「能跑」升级到「可运维」:用 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 默认信号)行为一致,容器编排靠它停服。

动手任务

  1. 把 withLogging 换成 slog 输出,字段:request_id、method、path、duration_ms、status。
  2. 实现 store.Ping(转发 db.PingContext),把 DSN 改成坏路径再打 healthz,确认返回 503。
  3. 按上面方式验证慢接口在 Ctrl+C 后被等完,把观察结果写进项目 README 的退出行为一节。

常见错误

  • Shutdown 不给超时:一个卡死的在途连接让服务永远退不出去,必须带 deadline。
  • 用 log.Fatal 退出主流程:它直接 os.Exit(1),defer 全部跳过,资源清理全失效。
  • 日志自己写文件加轮转:进程只写 stdout,轮转交给 logrotate 或容器运行时。
  • healthz 只回 200 不探依赖:探活永远绿,数据库挂了没人知道,就绪探针形同虚设。

检查与复盘

完成下方检查题。自查清单:

  • 退出顺序固定:停收新请求 → 等在途 → cancel 后台 → Wait → Close
  • 日志是带字段的 JSON,能按 request_id 检索单条请求
  • healthz 探到数据库,故障时 503 而不是假 200

参考资料

检查题共 1 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1单选中等

书签服务收到 SIGTERM(容器停止)时,希望「停止接收新请求 → 处理完在途请求 → 释放数据库连接」。标准做法是?

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...