跳到正文
知识库 / Go 博客教程 / 第 10 课返回主站 ↗

Go 博客教程 · 第 10 课 / 10

第 10 课:配置、日志、测试与上线

九课下来你写出了一个功能完整的博客后端。但它现在还是个「在我机器上能跑」的东西:配置写死在代码里、日志是 fmt.PrintlnCtrl+C 会把正在处理的请求砍断、没有一行测试。

这一课把它变成一个能交付出去的服务

教程日期:实测环境:Go 1.25.0 · MySQL 8.4.5 · Redis← 上一课:第 9 课:Redis 缓存与限流下一课:回到课程总览 →

① 本课目标

学完能独立写出:

  • 环境变量驱动的 Config,启动即校验、缺关键配置直接崩(fail fast)
  • log/slog 结构化日志,带 request_id 贯穿各层
  • 优雅关闭:收到信号后停止接收新请求、等在途请求做完、按序关掉 DB 和 Redis
  • 表驱动测试 + httptest用第 4 课的接口写 fake repo 测 service,不需要真数据库
  • 博客场景真实需要的那部分并发:errgroupsync.OnceRWMutex、goroutine 泄漏防范
  • 交叉编译出单个静态二进制、多阶段 Dockerfile、systemd unit、/healthz
  • 一个把前 9 课全部组件装配起来的 main()

② 前置检查

cd ~/go-blog
go build ./... && echo BUILD_OK
redis-cli ping
go version
BUILD_OK
PONG
go version go1.25.0 darwin/arm64

本课新增依赖只有一个——log/slognet/http/httptestos/signal 全是标准库:

go get golang.org/x/sync   # 第 9 课已装,errgroup 和 singleflight 在同一模块里
golang.org/x/sync v0.22.0

③ 核心概念

3.1 「能交付」意味着什么

维度 本地能跑 能交付
配置 硬编码在 main.go 环境变量注入,启动时校验
密钥 提交进 git 只在运行环境里,代码库搜不到
日志 fmt.Println("err:", err) 结构化 JSON,可被检索聚合
关闭 Ctrl+C 直接杀 等在途请求做完再退,退出码 0
正确性 手动 curl 一遍 go test ./... 一条命令
部署 go run . 单个二进制 / 镜像 + systemd / k8s
可观测 出事了登机器看 /healthz + 结构化日志

3.2 fail fast

配置错误应该在启动的那一秒暴露,而不是在第一个用户触发那条代码路径时。

secret := os.Getenv("BLOG_JWT_SECRET")  // ❌ 空字符串没人管,某个凌晨用户注册时才炸

cfg, err := config.Load()               // ✅ 进程根本起不来,发布流程立刻回滚
if err != nil {
    log.Fatalf("配置加载失败: %v", err)
}

代价是「配置写错就上不了线」——这正是你要的。一个起不来的服务,比一个悄悄用错密钥的服务安全得多。

④ 函数逐个精讲

4.1 config.Load —— 环境变量 + fail fast

// internal/config/config.go
package config

import (
    "fmt"
    "os"
    "strings"
    "time"
)

type Config struct {
    Env           string // dev | staging | prod
    Addr          string
    MySQLDSN      string
    RedisAddr     string
    RedisPassword string
    JWTSecret     string
    LogLevel      string // debug | info | warn | error
    ShutdownGrace time.Duration
}

// getenv 取环境变量,空则用默认值。
// 判空用 == "" 而不是 os.LookupEnv:显式设成空字符串通常也是"没配"的意思
func getenv(key, def string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return def
}

// Load 从环境变量装配配置,并在这里做完所有校验。
// ★ 设计原则:这个函数返回之后,Config 每个字段都可以被无条件信任 ——
//   后面的代码不需要再写 if cfg.XXX == "" 这种防御性判断
func Load() (*Config, error) {
    cfg := &Config{
        Env:           getenv("BLOG_ENV", "dev"),
        Addr:          getenv("BLOG_ADDR", ":8080"),
        MySQLDSN:      os.Getenv("BLOG_MYSQL_DSN"), // 无默认值 = 必填
        RedisAddr:     getenv("BLOG_REDIS_ADDR", "127.0.0.1:6379"),
        RedisPassword: os.Getenv("BLOG_REDIS_PASSWORD"), // 可为空(本机开发)
        JWTSecret:     os.Getenv("BLOG_JWT_SECRET"),     // 必填
        LogLevel:      getenv("BLOG_LOG_LEVEL", "info"),
    }

    // 类型转换的错误也要在这里拦住
    graceStr := getenv("BLOG_SHUTDOWN_GRACE", "10s")
    grace, err := time.ParseDuration(graceStr)
    if err != nil {
        // 错误信息里带上实际读到的值。只说"格式错误"的报错等于没说
        return nil, fmt.Errorf("BLOG_SHUTDOWN_GRACE=%q 不是合法时长(如 10s/1m): %w", graceStr, err)
    }
    cfg.ShutdownGrace = grace

    // ★ 一次收集全部缺失项,别让用户改一个报一个
    var missing []string
    if cfg.MySQLDSN == "" {
        missing = append(missing, "BLOG_MYSQL_DSN")
    }
    if cfg.JWTSecret == "" {
        missing = append(missing, "BLOG_JWT_SECRET")
    }
    if len(missing) > 0 {
        return nil, fmt.Errorf("缺少必填环境变量: %s", strings.Join(missing, ", "))
    }

    // 环境相关的额外约束
    if cfg.Env == "prod" {
        if len(cfg.JWTSecret) < 32 {
            return nil, fmt.Errorf("生产环境 BLOG_JWT_SECRET 至少 32 字节,当前 %d", len(cfg.JWTSecret))
        }
        if strings.Contains(cfg.MySQLDSN, "root:") {
            return nil, fmt.Errorf("生产环境不要用 root 连数据库")
        }
    }
    return cfg, nil
}

// String 实现 fmt.Stringer,让 Config 可以安全地打日志。
// ★ 不实现的话,某天有人写 slog.Info("config", "cfg", cfg),密码就进日志了 ——
//   而日志通常会转发到第三方系统、被更多人看到。
//   实现一个脱敏的 String() 是把"不要打印密码"这条规则固化进类型里
func (c *Config) String() string {
    return fmt.Sprintf("Config{Env:%s Addr:%s MySQL:%s Redis:%s JWTSecret:%s LogLevel:%s Grace:%s}",
        c.Env, c.Addr, maskDSN(c.MySQLDSN), c.RedisAddr, "***", c.LogLevel, c.ShutdownGrace)
}

// maskDSN 抹掉 "user:pass@tcp(host)/db" 的凭据部分,保留 host 和库名(排查要用)
func maskDSN(dsn string) string {
    at := strings.LastIndex(dsn, "@")
    if at < 0 {
        return "***"
    }
    return "***" + dsn[at:]
}

// MustLoad 给 main 用:失败直接退出。
// 名字带 Must 是 Go 惯例,表示"失败就 panic/exit",调用方看名字就知道
func MustLoad() *Config {
    cfg, err := Load()
    if err != nil {
        // 用 stderr + os.Exit(1) 而不是 panic:配置错误不需要 goroutine 栈,那只是噪音
        fmt.Fprintf(os.Stderr, "配置加载失败: %v\n", err)
        os.Exit(1)
    }
    return cfg
}

实测三种情况:

✅ 缺配置时 Load 报错: 缺少必填环境变量: BLOG_MYSQL_DSN, BLOG_JWT_SECRET
✅ Load ok, String() 脱敏: Config{Env:dev Addr::8080 MySQL:***@tcp(127.0.0.1:3306)/blog_dev JWTSecret:***}
✅ prod 弱密钥被拦: 生产环境 BLOG_JWT_SECRET 至少 32 字节,当前 15

密钥绝不进代码库

git 是只追加的。密码一旦 commit 进去,即使下一个 commit 删掉,它永远在历史里——任何人 git log -p 都能翻出来。清理要 git filter-repo 重写整个历史,所有协作者都得重新 clone,而且推过 GitHub 的话很可能早被爬虫扫走了。

唯一正确的做法是:泄露了就轮换密钥,而不是试图从历史里删掉。所以一开始就别提交。

# .gitignore
.env
# .env(本地开发,不提交)
BLOG_ENV=dev
BLOG_MYSQL_DSN=root:123456@tcp(127.0.0.1:3306)/blog_dev?charset=utf8mb4&parseTime=True&loc=Local
BLOG_REDIS_ADDR=127.0.0.1:6379
BLOG_JWT_SECRET=dev-only-not-for-production
BLOG_LOG_LEVEL=debug

同时提交一个 .env.example(只有 key 没有 value),告诉别人需要配哪些变量。

生产环境的密钥从哪来? 不是 .env 文件,而是 k8s Secret 挂成环境变量、systemd 的 EnvironmentFile=(权限 600)、或云厂商的密钥管理服务。共同点是密钥只存在于运行环境,代码库和构建产物里都没有

要不要用 caarlos0/env 这类库? 它能让你写 `env:"BLOG_ADDR,required"` 这样的 tag,字段多了确实清爽。但本课坚持手写 os.Getenv,因为你要先看清楚它到底做了什么——一个你不理解的库省下的 20 行代码,会在它行为不符合预期时让你花两小时。等 Load() 超过 100 行了再换。


4.2 log/slog —— 标准库的结构化日志

Go 1.21 把结构化日志收进了标准库。本课不教 zap / logrusslog 够用、是标准库(不会有依赖冲突、不会停止维护),而且它定义的 slog.Handler 接口正在成为生态的公共地基。

fmt.Println 式日志的问题不是丑,是不可检索

2026/09/06 10:00:00 用户 alice 发布文章失败: connection refused

你没法查「所有 user_id=42 的错误」,也没法统计「过去一小时 publish 失败率」——这行字符串对机器来说是一整块不透明的文本。

{"time":"...","level":"ERROR","msg":"发布文章失败","request_id":"a1b2","user_id":42,"post_id":7,"err":"connection refused"}

现在每个字段都能被索引、过滤、聚合。

// internal/logging/logger.go
package logging

import (
    "context"
    "log/slog"
    "os"
)

// New 构造 logger。dev 用 TextHandler(人眼友好),prod 用 JSONHandler(机器友好)。
func New(level, env string) *slog.Logger {
    // slog.Level 实现了 encoding.TextUnmarshaler,直接解析 "debug"/"info"/"warn"/"error",
    // 不用自己写一堆 switch
    var lv slog.Level
    if err := lv.UnmarshalText([]byte(level)); err != nil {
        lv = slog.LevelInfo // 日志级别不是关键配置,写错了退回 info 而不是让服务起不来
    }

    opts := &slog.HandlerOptions{
        Level: lv,
        // AddSource 记录文件和行号,但要走 runtime.Callers 有开销,只在 debug 开
        AddSource: lv <= slog.LevelDebug,
    }

    var h slog.Handler
    if env == "dev" {
        h = slog.NewTextHandler(os.Stdout, opts)
    } else {
        h = slog.NewJSONHandler(os.Stdout, opts)
    }
    return slog.New(h)
}

// ---------- 把 logger 放进 context(呼应第 5 课) ----------

// ⚠️ 用私有的空结构体做 key,绝不要用字符串 —— 别的包用了同名字符串就会互相覆盖,
//    而且这种冲突不报错,只会让你拿到别人的值。空结构体类型是全局唯一的
type ctxKey struct{}

func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
    return context.WithValue(ctx, ctxKey{}, l)
}

// From 取 logger,取不到返回全局默认的。
// ★ 永远不返回 nil:调用方写 logging.From(ctx).Info(...) 时不用判空,
//   否则每个调用点都要加一个 if —— 那种 API 没人会好好用
func From(ctx context.Context) *slog.Logger {
    if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok && l != nil {
        return l
    }
    return slog.Default()
}

为什么写 os.Stdout 而不是文件? 十二要素应用的原则:进程只管把日志吐到 stdout,收集、切割、轮转、归档是运行环境的事。systemd 有 journald、Docker 有 log driver、k8s 有 DaemonSet 采集。你自己实现文件轮转只会和这些机制打架。

service / repository 里就变成一行,自动带上 request_id:

logging.From(ctx).Info("开始发布", "post_id", id)

slog 版 Logger 中间件(改写第 5 课)

// internal/middleware/logger.go
func Logger(base *slog.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()

        // 沿用上游传来的 request_id(网关可能已经生成了),没有就自己造
        rid := c.GetHeader("X-Request-Id")
        if rid == "" {
            rid = uuid.NewString()
        }
        c.Header("X-Request-Id", rid) // 回给客户端:用户报障时提供这个 id,你能直接定位

        // With 派生带固定字段的 logger。它返回新 logger 不修改原来的,所以并发安全
        l := base.With("request_id", rid, "method", c.Request.Method, "path", c.Request.URL.Path)
        // 塞进 request 的 context,让 handler/service/repository 都拿得到
        c.Request = c.Request.WithContext(logging.WithLogger(c.Request.Context(), l))

        c.Next()

        // 级别随状态码变:5xx→ERROR(要告警),4xx→WARN(通常是客户端的锅),其余 INFO
        lvl := slog.LevelInfo
        switch {
        case c.Writer.Status() >= 500:
            lvl = slog.LevelError
        case c.Writer.Status() >= 400:
            lvl = slog.LevelWarn
        }

        // LogAttrs 是零分配写法:预先构造 slog.Attr,避免 any 装箱。
        // 访问日志是每个请求都走的热路径,这点开销值得省
        l.LogAttrs(c.Request.Context(), lvl, "http request",
            slog.Int("status", c.Writer.Status()),
            slog.Duration("latency", time.Since(start)),
            slog.Int("bytes", c.Writer.Size()),
            slog.String("ip", c.ClientIP()),
        )
    }
}

实测输出(handler 内部的 DEBUG 和中间件的访问日志共享同一个 request_id):

{"level":"DEBUG","msg":"handler 拿到 logger","request_id":"1788712280532093000","method":"GET","path":"/api/v1/posts/1"}
{"level":"INFO","msg":"http request","request_id":"1788712280532093000","method":"GET","path":"/api/v1/posts/1","status":200,"latency":467583,"bytes":10,"ip":"127.0.0.1"}

实测级别解析:UnmarshalText("warn")WARN;非法值 → slog: level string "verbose": unknown name

级别怎么选,什么不该记

级别 什么时候用 例子
Debug 只在排查时开,生产默认关 SQL 语句、缓存命中详情
Info 正常业务事件 服务启动、用户注册、访问日志
Warn 不正常但还能工作,降级路径都该记 缓存降级、限流器不可用、重试成功
Error 需要人来看的失败 DB 写失败、panic 恢复、依赖不可用

绝对不能记进日志的:密码(明文和 hash 都不行)、token / API key / session id、完整请求体、身份证手机号等 PII、整个 struct

最后一条最容易犯:

slog.Info("用户登录", "user", user)  // ❌ user 里有 PasswordHash,全序列化进日志了
slog.Info("用户登录", "user_id", user.ID, "username", user.Username)  // ✅ 只挑需要的

防御手段和 Config.String() 一样——给含敏感字段的类型实现 LogValue(),把脱敏固化进类型:

// slog.LogValuer 接口:slog 序列化这个类型时调它,而不是反射整个 struct
func (u User) LogValue() slog.Value {
    return slog.GroupValue(slog.Int64("id", u.ID), slog.String("username", u.Username))
}

4.3 优雅关闭

进程被 kill 时,正在处理的请求会被硬生生截断。用户看到「连接被重置」,如果那是个 POST,他不知道数据到底写没写进去。

发布是常态:每次滚动更新,所有实例都会被 SIGTERM 一遍。没有优雅关闭 = 每次发布都在给一批用户返回错误

时间轴 ──────────────────────────────────────────────────────►

  收到 SIGTERM
      │
      ├─ ① stop() 取消信号监听(第二次 Ctrl+C 能强杀,不然卡死了没法退)
      │
      ├─ ② srv.Shutdown(ctx)
      │     ├─ 立刻关闭 listener ── 新连接被拒绝(LB 探测失败会把流量摘走)
      │     ├─ 关闭所有 idle 连接
      │     └─ 等待在途请求自然结束 ─────┐
      │                                 │ 最多等 ShutdownGrace
      │        请求 A ──────►完成        │
      │        请求 B ──────────►完成    │
      │                                 ▼
      ├─ ③ Shutdown 返回(全做完 → nil;超时 → context.DeadlineExceeded)
      ├─ ④ 停后台任务,等它们的 goroutine 退出
      ├─ ⑤ 关 Redis
      ├─ ⑥ 关 DB ← 必须最后。反过来的话,在途请求写库会拿到 "sql: database is closed"
      └─ ⑦ 进程退出,退出码 0

顺序原则:按依赖的反方向关。 HTTP server 依赖 service,service 依赖 DB 和 Redis,所以先关 HTTP、最后关 DB。

signal.NotifyContext

// 老写法:能用,但要管 channel、要管 goroutine
sigCh := make(chan os.Signal, 1)  // ⚠️ 缓冲必须 ≥1,否则信号可能丢失
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh

// ✅ 现代写法(Go 1.16+):信号变成 context,可以直接传给任何接受 ctx 的函数
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()

好处是信号和 ctx 统一了:把这个 ctx 传给后台任务,信号一到它们全部自动取消。

os.InterruptCtrl+C(SIGINT),syscall.SIGTERMkill、systemd stop、Docker stop、k8s 删 Pod 发的信号。两个都要监听——只听 SIGINT 的话,线上部署时的优雅关闭完全不会触发。

SIGKILLkill -9捕获不了,这是操作系统层面的硬规定。所以关闭逻辑必须在宽限期内跑完:k8s 默认 terminationGracePeriodSeconds: 30,超了就 SIGKILL。你的 ShutdownGrace 必须小于它。

ErrServerClosed 是正常的,不是错误

本节最容易犯的错:

// ❌ 每次优雅关闭都会在日志里报一条 ERROR
go func() {
    if err := srv.ListenAndServe(); err != nil {
        slog.Error("服务器异常", "err", err)   // 关闭时必然打印 "http: Server closed"
    }
}()

Shutdown 被调用后 ListenAndServe 一定会返回 http.ErrServerClosed——这是它在说「我是被正常关掉的」。不排除的话,日志里天天躺着一条假 ERROR,久而久之就没人看 ERROR 了。

// ✅ 用 errors.Is 排除
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
    // 只有真正的启动失败会到这里,比如:listen tcp :8080: bind: address already in use
    serverErr <- err
}

实测(真的 kill -TERM 一个正在处理 1.5 秒慢请求的进程):

listening
收到信号,开始优雅关闭
关闭完成,等在途请求用了 1.6s

那 1.6 秒就是它在等慢请求做完。单元测试里也验证了在途请求完整拿到响应体没被截断:Shutdown 等了 270ms 才返回,在途请求完整拿到 "done"


4.4 main() —— 全教程的收官函数

// cmd/server/main.go
package main

import (
    "context"
    "errors"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gin-gonic/gin"

    "blog/internal/cache"      // 第 9 课
    "blog/internal/config"     // 第 10 课
    "blog/internal/handler"    // 第 4 课
    "blog/internal/job"        // 第 9 课
    "blog/internal/logging"    // 第 10 课
    "blog/internal/middleware" // 第 5、6 课
    "blog/internal/repository" // 第 3、4、8 课
    "blog/internal/service"    // 第 4 课
)

// 编译时由 -ldflags -X 注入,见 4.7。
// 必须是包级 string 变量 —— -X 只能改这种
var (
    version   = "dev"
    buildTime = "unknown"
)

func main() {
    // ---------- ① 配置:最先做,错了立刻退出 ----------
    cfg := config.MustLoad()

    // ---------- ② 日志:第二个做,后面所有步骤才有地方报错 ----------
    logger := logging.New(cfg.LogLevel, cfg.Env)
    // SetDefault 让没拿到 logger 的地方(第三方库、老代码)也走同一套格式
    slog.SetDefault(logger)
    logger.Info("服务启动中", "version", version, "build_time", buildTime,
        "env", cfg.Env, "config", cfg.String()) // String() 已脱敏,见 4.1

    // ---------- ③ 基础设施:DB → Redis ----------
    db, err := repository.NewGormDB(cfg.MySQLDSN) // 第 8 课
    if err != nil {
        logger.Error("连接 MySQL 失败", "err", err)
        os.Exit(1)
    }
    // GORM 把连接池藏在底层,要拿 *sql.DB 才能关。这个引用留到最后关闭时用
    sqlDB, err := db.DB()
    if err != nil {
        logger.Error("获取底层 sql.DB 失败", "err", err)
        os.Exit(1)
    }

    rdb, err := cache.NewRedis(cfg.RedisAddr, cfg.RedisPassword, 0) // 第 9 课
    if err != nil {
        logger.Error("连接 Redis 失败", "err", err)
        os.Exit(1)
    }

    // ---------- ④ 依赖注入:repo → service → handler ----------
    // 这是第 4 课分层的兑现:每层只认下一层的接口,组装全部集中在这里。
    // 想把 MySQL 换成 Postgres,只改这几行,业务代码一行不动
    postRepo := repository.NewPostRepo(db)
    userRepo := repository.NewUserRepo(db)
    postCache := cache.NewPostCache(rdb)

    postSvc := service.NewPostService(postRepo, postCache) // 指针!含 singleflight.Group(有锁)
    authSvc := service.NewAuthService(userRepo, rdb, []byte(cfg.JWTSecret))

    postHandler := handler.NewPostHandler(postSvc)
    authHandler := handler.NewAuthHandler(authSvc)

    // ---------- ⑤ 路由 ----------
    if cfg.Env != "dev" {
        gin.SetMode(gin.ReleaseMode) // 关掉 debug 输出和路由打印
    }
    // 不用 gin.Default():它自带的 Logger 是文本格式,我们要自己的 slog 版
    r := gin.New()
    r.Use(
        middleware.Recover(logger),                // 第 5 课:最外层,panic 转 500,必须第一个
        middleware.Logger(logger),                 // 第 10 课:slog 版,注入 request_id
        middleware.RequestTimeout(30*time.Second), // 第 5 课:给每个请求的 ctx 加 deadline
        middleware.CORS(),                         // 第 5 课
    )

    // 健康检查放在限流之前 —— 探针被限流会导致实例被误判不健康然后重启
    r.GET("/healthz", handler.Healthz(version))
    r.GET("/readyz", handler.Readyz(sqlDB, rdb)) // 见 4.7

    api := r.Group("/api/v1")
    api.Use(middleware.RateLimit(rdb, 300, time.Minute)) // 第 9 课
    {
        api.POST("/auth/register", authHandler.Register) // 第 6 课
        api.POST("/auth/login", middleware.RateLimit(rdb, 5, time.Minute), authHandler.Login)
        api.GET("/posts", postHandler.List)    // 第 4 课
        api.GET("/posts/:id", postHandler.Get) // 第 9 课:走缓存

        auth := api.Group("", middleware.Auth([]byte(cfg.JWTSecret), rdb)) // 第 6+9 课:带黑名单
        {
            auth.POST("/auth/logout", authHandler.Logout)
            auth.POST("/posts", postHandler.Create)
            auth.PUT("/posts/:id", postHandler.Update)
            auth.DELETE("/posts/:id", postHandler.Delete)
        }
    }

    // ---------- ⑥ 信号监听:必须在启动服务之前设置好 ----------
    // 否则"服务已起来、信号处理还没就绪"的窗口里收到 SIGTERM,就是默认行为(直接死)
    rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    // ---------- ⑦ 后台任务:阅读量回写(第 9 课) ----------
    // 传 rootCtx:信号一到它自己就会退出
    jobDone := job.StartViewFlusher(rootCtx, rdb, db, time.Minute)

    // ---------- ⑧ 启动 HTTP 服务 ----------
    srv := &http.Server{
        Addr:    cfg.Addr,
        Handler: r,
        // ⚠️ 这几个超时不设 = 慢连接可以永久占住一个 goroutine,最经典的 DoS 面。
        //    net/http 的默认值全是 0(无限)
        ReadHeaderTimeout: 5 * time.Second, // 防 Slowloris
        ReadTimeout:       15 * time.Second,
        WriteTimeout:      30 * time.Second, // 必须大于最慢的正常请求
        IdleTimeout:       60 * time.Second, // keep-alive 空闲多久回收
    }

    // ⚠️ 缓冲必须是 1:如果主 goroutine 已走到关闭流程不再读这个 channel,
    //    无缓冲的发送会永久阻塞,这个 goroutine 就泄漏了
    serverErr := make(chan error, 1)
    go func() {
        logger.Info("HTTP 服务已启动", "addr", cfg.Addr)
        // ★ ErrServerClosed 是优雅关闭的正常返回,必须排除
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            serverErr <- err
            return
        }
        serverErr <- nil
    }()

    // ---------- ⑨ 等待:要么启动失败,要么收到信号 ----------
    select {
    case err := <-serverErr:
        if err != nil {
            // 典型:listen tcp :8080: bind: address already in use
            logger.Error("HTTP 服务启动失败", "err", err)
            os.Exit(1)
        }
    case <-rootCtx.Done():
        logger.Info("收到关闭信号,开始优雅关闭", "grace", cfg.ShutdownGrace)
    }

    // ---------- ⑩ 优雅关闭:严格按依赖反序 ----------
    // 先 stop() 解除信号监听:这样再按一次 Ctrl+C 就是默认行为(强杀),
    // 关闭卡住时用户还有退路。不做的话用户只能开另一个终端 kill -9
    stop()

    // ★ 用新的 context:rootCtx 已被信号取消,拿它做 Shutdown 会立刻超时
    shutCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownGrace)
    defer cancel()

    // ① 停止接收新请求 + 等在途请求做完
    if err := srv.Shutdown(shutCtx); err != nil {
        logger.Error("HTTP 优雅关闭超时,强制退出", "err", err) // 超时的请求会被强制断开
    } else {
        logger.Info("HTTP 服务已停止,在途请求已全部完成")
    }

    // ② 等后台任务退出。它监听 rootCtx,信号一到就开始收尾
    select {
    case <-jobDone:
        logger.Info("后台任务已停止")
    case <-shutCtx.Done():
        logger.Warn("后台任务未在宽限期内退出")
    }

    // ③ 关 Redis
    if err := rdb.Close(); err != nil {
        logger.Error("关闭 Redis 失败", "err", err)
    }

    // ④ 最后关 DB。★ 顺序不能反 ——
    // DB 先关的话,还在收尾的在途请求会拿到 "sql: database is closed"
    if err := sqlDB.Close(); err != nil {
        logger.Error("关闭 MySQL 失败", "err", err)
    }

    logger.Info("服务已完全停止", "version", version)
    // 正常走到这里,退出码 0 —— systemd 和 k8s 会认为这是一次干净的退出
}

对应的后台任务:

// internal/job/view_flusher.go

// StartViewFlusher 启动定时回写任务,返回一个 channel;它关闭时表示任务已完全退出。
// ★ 这个"完成信号 channel"是可关闭后台 goroutine 的标准模式:
//   光有 ctx 只能通知它该退了,拿不到"它真的退了"的确认
func StartViewFlusher(ctx context.Context, rdb *redis.Client, db *gorm.DB, interval time.Duration) <-chan struct{} {
    done := make(chan struct{})
    go func() {
        defer close(done) // ★ 无论怎么退出都要关,否则 main 会一直等到超时

        ticker := time.NewTicker(interval)
        defer ticker.Stop() // ⚠️ 不 Stop 的话底层计时器不会被回收 —— 典型泄漏

        for {
            select {
            case <-ticker.C:
                // 独立 ctx:这次 flush 该跑完就跑完,别被外面的取消打断到一半
                runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
                n, err := FlushViews(runCtx, rdb, db)
                cancel()
                if err != nil {
                    slog.Error("回写阅读量失败", "err", err)
                } else if n > 0 {
                    slog.Info("回写阅读量", "count", n)
                }

            case <-ctx.Done():
                // 退出前最后再刷一次,把攒着的数据落盘,少丢一点
                finalCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
                if n, err := FlushViews(finalCtx, rdb, db); err == nil && n > 0 {
                    slog.Info("退出前最后一次回写", "count", n)
                }
                cancel()
                return
            }
        }
    }()
    return done
}

4.5 测试

go test 基础

  • 文件名以 _test.go 结尾。它不会被编进最终二进制,测试代码不影响发布产物。
  • 函数名 TestXxx(t *testing.T)Xxx 首字母大写。
  • 测试和被测代码放同一目录、同一包package service)——这样能测未导出的函数。想强制只测公开 API 就用 package service_test
go test ./...                                   # 跑全部
go test -v -run TestPublish ./internal/service  # -run 接正则
go test -race ./...                             # 竞态检测
go test -cover ./...                            # 覆盖率

表驱动测试 —— Go 社区的标准范式

不好的写法是四个用例四个函数、逻辑重复四遍,加一个用例要复制粘贴一整段。表驱动把「用例数据」和「测试逻辑」分开:数据是一个切片,逻辑只写一遍,加用例 = 加一行。

// internal/service/post_service_test.go
func TestPostService_Publish(t *testing.T) {
    t.Parallel() // 这个测试可以和别的测试并行跑

    // 表:每行一个用例。字段名要能自解释,别用 in1/in2/want1
    tests := []struct {
        name      string // 会显示在 -v 输出和失败信息里
        seed      *model.Post
        actorID   int64
        wantErr   error
        wantStat  model.Status
        wantPubAt bool
    }{
        {"正常发布草稿", &model.Post{ID: 1, AuthorID: 10, Content: "正文", Status: model.StatusDraft}, 10, nil, model.StatusPublished, true},
        {"不是作者本人", &model.Post{ID: 1, AuthorID: 10, Content: "正文", Status: model.StatusDraft}, 99, ErrForbidden, model.StatusDraft, false},
        {"正文为空", &model.Post{ID: 1, AuthorID: 10, Content: "   \n\t", Status: model.StatusDraft}, 10, ErrEmptyContent, model.StatusDraft, false},
        {"重复发布", &model.Post{ID: 1, AuthorID: 10, Content: "正文", Status: model.StatusPublished}, 10, ErrAlreadyPub, model.StatusPublished, false},
    }

    for _, tt := range tests {
        // t.Run 创建子测试:每个用例独立报告成败,
        // 能用 -run 'TestPublish/正文为空' 单独跑一个
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()

            // ★ 每个用例一份全新的 fake —— 用例间绝不能共享状态,
            //   否则前一个的写入会影响后一个,并行时直接是数据竞争
            repo := newFakeRepo(tt.seed)
            svc := newTestSvc(t, repo)

            got, err := svc.Publish(context.Background(), tt.seed.ID, tt.actorID)

            // 用 errors.Is 而不是 ==:被测代码可能用 %w 包装过错误
            if !errors.Is(err, tt.wantErr) {
                t.Fatalf("err = %v, want %v", err, tt.wantErr) // Fatal:后面的断言没意义了
            }
            if tt.wantErr != nil {
                // ★ 负向用例最该断言的不是"返回了错误",而是"没产生副作用"
                if len(repo.updates) != 0 {
                    t.Errorf("失败路径不该落库,却写了 %d 次", len(repo.updates))
                }
                return
            }
            if got.Status != tt.wantStat {
                t.Errorf("Status = %d, want %d", got.Status, tt.wantStat) // Error:继续跑,一次看到全部问题
            }
            if tt.wantPubAt && got.PublishedAt == nil {
                t.Error("PublishedAt 应被填充")
            }
            if len(repo.updates) != 1 {
                t.Fatalf("Update 调用 %d 次, want 1", len(repo.updates))
            }
        })
    }
}

实测(子测试独立报告、并行执行):

--- PASS: TestPostService_Publish (0.00s)
    --- PASS: TestPostService_Publish/正常发布草稿 (0.00s)
    --- PASS: TestPostService_Publish/不是作者本人 (0.00s)
    --- PASS: TestPostService_Publish/正文为空 (0.00s)
    --- PASS: TestPostService_Publish/重复发布 (0.00s)

t.Error vs t.FatalFatal 立刻停掉当前测试(后续断言依赖前面结果时用),Error 记录失败但继续(独立断言用,一次看到所有问题)。Fatal 不能在 go 启动的 goroutine 里调用——它内部是 runtime.Goexit(),在别的 goroutine 里调只会杀掉那个 goroutine,测试会假装通过。

fake repo —— 接口设计的最终兑现

这是第 4 课那个 PostRepository 接口的最终兑现。 当时把 repository 定义成接口而不是具体类型,理由是「解耦」,听起来有点抽象。现在具体的好处出现了:PostService 只依赖接口,所以我可以塞任何满足它的东西进去——包括一个纯内存的假实现。

结果是 service 层测试完全不需要数据库:不用建表、不用清数据、不用担心用例互相污染、不用装 Docker,go test 在任何机器上都能跑。第 4 课就是用这个办法跑通了 4 条 service 单测,总耗时 0.389 秒——换成真连 MySQL,光建表清表就不止这个数。

// internal/service/fake_repo_test.go

// fakePostRepo 是 PostRepository 的纯内存实现,只在测试里用。
// 文件名以 _test.go 结尾,所以它不会被编进生产二进制
type fakePostRepo struct {
    mu      sync.Mutex // t.Parallel 下会有并发访问,必须加锁
    posts   map[int64]*model.Post
    updates []*model.Post // ★ 记录调用历史,让测试能断言"副作用发生了没有"
    getErr  error         // ★ 可注入的错误,用来测失败路径
    updErr  error
}

func newFakeRepo(seed ...*model.Post) *fakePostRepo {
    r := &fakePostRepo{posts: make(map[int64]*model.Post)}
    for _, p := range seed {
        cp := *p // ★ 存副本。存指针的话测试修改 seed 会连带改到 repo 里,变成幽灵 bug
        r.posts[p.ID] = &cp
    }
    return r
}

func (r *fakePostRepo) GetByID(ctx context.Context, id int64) (*model.Post, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    if r.getErr != nil {
        return nil, r.getErr // 注入的错误优先,用来模拟 DB 挂了
    }
    p, ok := r.posts[id]
    if !ok {
        return nil, model.ErrNotFound
    }
    cp := *p // 返回副本:调用方改了返回值不该影响 repo 内部,真 DB 也是这个语义
    return &cp, nil
}

func (r *fakePostRepo) Update(ctx context.Context, p *model.Post) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    if r.updErr != nil {
        return r.updErr
    }
    cp := *p
    r.posts[p.ID] = &cp
    r.updates = append(r.updates, &cp)
    return nil
}

// ★ 编译期断言:接口加了新方法而 fake 忘了实现时,
//   这一行直接编译失败,而不是等某个测试莫名跑不起来
var _ repository.PostRepository = (*fakePostRepo)(nil)

// newTestSvc 是测试辅助函数。
// t.Helper() 告诉 testing:报错时指向调用它的那一行,而不是这个函数内部 ——
// 否则所有失败都显示在 helper 里,你完全不知道是哪个用例挂了
func newTestSvc(t *testing.T, repo repository.PostRepository) *PostService {
    t.Helper()
    // ★ 注入固定时钟。用 time.Now() 的话"发布时间是否正确"根本没法断言。
    //   把时间做成依赖是可测试代码的通用手法
    fixed := time.Date(2026, 9, 6, 10, 0, 0, 0, time.UTC)
    return &PostService{repo: repo, now: func() time.Time { return fixed }}
}

注入固定时钟还能帮你避开第 3 课那个坑。 MySQL 的 DATETIME(精度 0)在写入时是四舍五入不是截断——.500 及以上会进位到下一秒。本机实测:

写入 10:00:00.300  → 读回 10:00:00.000
写入 10:00:00.499  → 读回 10:00:00.000
写入 10:00:00.500  → 读回 10:00:01.000   ⚠️ 进位到下一秒
写入 10:00:00.700  → 读回 10:00:01.000   ⚠️ 进位到下一秒

所以只要你在集成测试里写 time.Now() 然后断言「写进去的等于读回来的」,就有大约一半的概率随机失败——这种「本地跑十次挂一次」的测试最消耗信任。 修法是写库前 time.Now().Truncate(time.Second)(实测 in.Equal(out) 为 true)。 本节的 service 单测用 fake repo 不碰数据库,所以不会撞上;但 newTestSvc 里的固定时钟已经是整秒,顺手也就免疫了。

注入错误测失败路径(实测通过):

// internal/service/post_service_test.go
func TestPostService_Publish_RepoDown(t *testing.T) {
    repo := newFakeRepo(&model.Post{ID: 1, AuthorID: 10, Content: "x", Status: model.StatusDraft})
    repo.updErr = errors.New("connection refused") // 模拟 DB 挂了
    svc := newTestSvc(t, repo)
    if _, err := svc.Publish(context.Background(), 1, 10); err == nil {
        t.Fatal("repo 写失败时 service 必须把错误上抛")
    }
}

分工:service 层测业务规则(用 fake,快、稳、无外部依赖);repository 层测 SQL 正确性(用真库,慢但必要,属于集成测试)。集成测试用 testcontainers-go 起临时 MySQL,或者打上 if testing.Short() { t.Skip() } 让 CI 的快速检查阶段能跳过。

httptest —— 测 handler

httptest.NewRecorder() 是一个假的 http.ResponseWriter,把响应记在内存里而不是发到网络上。不用起服务器、不用占端口

// internal/handler/post_handler_test.go
func TestPostHandler_Create(t *testing.T) {
    r := newTestRouter() // 装配好路由的 *gin.Engine

    tests := []struct {
        name     string
        body     string
        wantCode int
        wantSub  string
    }{
        {"正常创建", `{"title":"Hello Go","content":"正文"}`, http.StatusCreated, `"id":42`},
        {"标题缺失", `{"content":"正文"}`, http.StatusBadRequest, "INVALID_ARGUMENT"},
        {"标题过短", `{"title":"A","content":"正文"}`, http.StatusBadRequest, "INVALID_ARGUMENT"},
        {"body 不是 JSON", `not json`, http.StatusBadRequest, "INVALID_ARGUMENT"},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            w := httptest.NewRecorder() // 假的 ResponseWriter
            // httptest.NewRequest 专为测试设计:URL 可写相对路径,
            // 构造失败直接 panic(测试里本来就该立刻失败)
            req := httptest.NewRequest(http.MethodPost, "/api/v1/posts", strings.NewReader(tt.body))
            req.Header.Set("Content-Type", "application/json") // ⚠️ 忘了这行 ShouldBindJSON 会拒绝

            // ★ 直接调 ServeHTTP —— 走完整的中间件链 + 路由匹配 + handler,
            //   但完全不碰网络。这是 Go 测 HTTP 层的标准做法
            r.ServeHTTP(w, req)

            if w.Code != tt.wantCode {
                t.Fatalf("status = %d, want %d; body=%s", w.Code, tt.wantCode, w.Body.String())
            }
            if !strings.Contains(w.Body.String(), tt.wantSub) {
                t.Errorf("body %q 不含 %q", w.Body.String(), tt.wantSub)
            }
        })
    }
}

这里断言的 INVALID_ARGUMENT 就是第 2 课定稿的 APIError{Code, Message, Field} 里的 Code 字段。断言 Code 而不是 Message —— Code 是给机器看的稳定契约,Message 是给人看的文案,随时可能改。拿 Message 做断言的测试会在某次纯文案调整时集体飘红。

同理,service 返回的领域错误(model.ErrNotFoundErrForbidden…)到 HTTP 状态码的映射,第 4 课已经收敛在 internal/handler/errors.gohttpStatusFor(err) int 里。handler 测试要覆盖的正是这个映射:给 fake service 注入 model.ErrNotFound,断言拿到 404 而不是 500——这条断言守着的是「新加一个领域错误时别忘了在 httpStatusFor 里登记」,忘了的话它会默默落进 500 分支。

要测完整链路(真实 TCP、真实 HTTP 客户端行为)就用 httptest.NewServer

// internal/handler/fullstack_test.go
func TestFullStack(t *testing.T) {
    ts := httptest.NewServer(newTestRouter()) // 起一个真服务,随机端口
    // ★ t.Cleanup 比 defer 好:它在子测试全部结束后才执行,
    //   而且多个清理动作按后进先出执行,不会乱
    t.Cleanup(ts.Close)

    resp, err := http.Get(ts.URL + "/healthz") // ts.URL 形如 http://127.0.0.1:55541
    if err != nil {
        t.Fatalf("GET /healthz: %v", err)
    }
    defer resp.Body.Close()

    b, _ := io.ReadAll(resp.Body)
    if resp.StatusCode != 200 || string(b) != "ok" {
        t.Fatalf("healthz = %d %q", resp.StatusCode, b)
    }
}

实测:--- PASS: TestFullStack (0.00s)httptest.NewServer 起在 http://127.0.0.1:55541

-race-cover

go test -race 在运行时监控内存访问,发现两个 goroutine 无同步地访问同一块内存(且至少一个是写)就报告。它只能发现实际执行到的竞态,所以要配合并发测试用例。开销是 5~10 倍慢、内存翻倍——本地和 CI 开,生产不开。本课全部测试在 -race 下跑过,无报告。

go test -cover 实测输出:ok covtest 0.395s coverage: 25.0% of statements

不要追求 100% 覆盖率。 覆盖率只告诉你「哪些代码没被执行过」,不告诉你「断言写得对不对」——一个把返回值全扔掉的测试也能刷到 100%。

该测的:业务规则(谁能发布文章)、边界条件(空正文、超长标题、id=0)、错误路径(DB 挂了会怎样)、并发正确性。 不该花时间测的:getter/setter、纯数据结构体、只是转发调用的一行 handler、String() 方法。

务实的目标:service 层 70%+,repository 和 handler 有主路径覆盖就行。


4.6 并发基础

只讲博客场景真正会用到的部分。

goroutine 的成本与泄漏

一个 goroutine 初始栈只有 2KB(对比 OS 线程的 1~8MB),按需增长,起十万个是可行的

但「便宜」不等于「免费」。实测起 100 个永久阻塞的 goroutine:

泄漏演示: NumGoroutine 2 → 102

它们永远不会被回收——Go 的 GC 不回收阻塞中的 goroutine,runtime 无法判断它是「卡死了」还是「在等一个很久以后才来的事件」。

循环变量闭包坑:Go 1.22 是分水岭

Go 历史上最著名的坑,Go 1.22 改了语言规范修掉了它。但网上老代码和老教程满天飞,你必须知道版本差异:

for i := 0; i < 3; i++ {
    go func() { fmt.Println(i) }()   // 打印什么?
}
Go 版本 行为 原因
≤ 1.21 大概率 3 3 3 整个循环共用同一个 i,goroutine 真正执行时循环早跑完了
≥ 1.22 0 1 2(顺序不定) 规范改了:每轮迭代新建一个 i,闭包捕获各自那一份

本课环境实测:

✅ Go 1.25.0 循环变量每轮新建, 闭包捕获到 [2 0 1](排序前)

三个值都在、顺序随机——正是 1.22+ 的行为。老写法(i := i 影子变量,或当参数传进去)现在已无必要。

这个变化的生效条件是 go.mod 里的 go 指令 ≥ 1.22,不是你装的 Go 版本。 用 Go 1.25 编译一个 go 1.19 的模块,依然是老行为——Go 的向后兼容承诺。所以看到老代码里的 i := i 别急着删,先看 go.mod

WaitGrouperrgroup

var wg sync.WaitGroup
for _, id := range ids {
    wg.Add(1)              // ⚠️ 必须在 go 之前 Add。放进 goroutine 里的话,
    go func() {            //    Wait 可能在 Add 执行前就返回了
        defer wg.Done()    // ⚠️ 用 defer,panic 时也能减到零,否则 Wait 永久阻塞
        process(id)
    }()
}
wg.Wait()

WaitGroup 不传播错误也不支持取消——一个失败了其余照样跑完。要错误和取消就用 errgroup

场景:文章详情页要同时拿标签和评论,两个查询互不依赖,串行等于白白多等一倍时间。

// internal/service/post_detail.go
import "golang.org/x/sync/errgroup"

type Detail struct {
    Post     *model.Post
    Tags     []model.Tag
    Comments []model.Comment
}

func (s *PostService) fetchPostDetail(ctx context.Context, id int64) (*Detail, error) {
    // ★ WithContext 返回一个派生 ctx:任何一个 g.Go 返回非 nil error,
    //   这个 ctx 立刻被取消 —— 其他还在跑的任务能提前退出,不用白白等它们跑完
    g, ctx := errgroup.WithContext(ctx)
    var d Detail

    g.Go(func() error {
        tags, err := s.tagRepo.ListByPost(ctx, id) // 传派生 ctx,兄弟失败时这里会被取消
        if err != nil {
            return fmt.Errorf("查标签: %w", err)
        }
        // ★ 为什么不用加锁:每个 goroutine 只写自己那个字段,
        //   不同字段是不同的内存地址,没有数据竞争。
        //   都往同一个 slice append 的话就必须加锁了
        d.Tags = tags
        return nil
    })

    g.Go(func() error {
        comments, err := s.commentRepo.ListByPost(ctx, id, 20)
        if err != nil {
            return fmt.Errorf("查评论: %w", err)
        }
        d.Comments = comments
        return nil
    })

    // Wait 阻塞到全部返回,给出第一个非 nil 的 error。
    // ★ Wait 之后读 d 是安全的:它建立了 happens-before 关系
    if err := g.Wait(); err != nil {
        return nil, fmt.Errorf("拉取文章详情 %d: %w", id, err)
    }
    return &d, nil
}

实测成功路径和错误传播:

✅ errgroup 并发拉取: tags=[go redis] comments=[好文 学到了] err=<nil>
✅ errgroup 错误传播: err=标签服务挂了, 耗时 0s(不是 2s → 兄弟被取消了)

第二条是关键:兄弟任务本来要跑 2 秒,因为标签服务立刻失败、ctx 被取消,它 0 秒就退出了。这就是 WithContext 的价值,WaitGroup 做不到。

g.SetLimit(n) 限制并发数——批量处理时必须设,否则「给一万篇文章生成摘要」会一口气起一万个 goroutine 把下游打挂。

Mutex / RWMutex / Once

map 不是并发安全的。 两个 goroutine 同时写会直接 fatal error: concurrent map writes。注意这是 fatal error 不是普通 panic,recover() 拦不住,整个进程直接死。

type counter struct {
    mu sync.RWMutex // 读多写少用 RWMutex:多个读可同时持有,写独占
    m  map[string]int
}

func (c *counter) Inc(k string) {
    c.mu.Lock()
    defer c.mu.Unlock() // ⚠️ 一律 defer。中间任何 return 或 panic 忘了解锁 = 全站死锁
    c.m[k]++
}

func (c *counter) Get(k string) int {
    c.mu.RLock()
    defer c.mu.RUnlock() // ⚠️ 是 RUnlock 不是 Unlock,配错会 panic
    return c.m[k]
}

实测 200 并发 Inc-race 无报告,结果精确等于 200。

RWMutex 不是无脑更好:它的加锁开销比 Mutex 大,临界区很短时(比如就一次 map 读)Mutex 反而更快。读远多于写、且临界区不是纳秒级才用它。

sync.Map 呢? 只在两种特定场景更快:key 基本只写一次然后大量读、或多个 goroutine 各读写不相交的 key 集合。普通场景它更慢,而且没有泛型、取值要类型断言。默认用 RWMutex + map

sync.Once 做单例初始化(实测 50 并发,初始化函数只执行 1 次,其余 49 个阻塞到它完成才返回,不会拿到半初始化的对象):

var once sync.Once
func GetClient() *SomeClient {
    once.Do(func() { client = newSomeClient() })
    return client
}

本项目其实不需要它:所有依赖都在 main() 里顺序初始化好再注入。能在 main 里显式初始化就别用懒加载——启动时初始化能 fail fast,懒加载把失败推迟到第一次调用。

channel:信号 vs 队列

用作信号(一对多广播):done := make(chan struct{}) + close(done)chan struct{} 不占内存,纯粹表达「事件发生了」;close 会让所有 <-done 同时解除阻塞。

用作队列(传数据):jobs := make(chan Job, 100),带缓冲让生产者不必等消费者,close(jobs)for range jobs 会自然结束。

两个必死的 panic(实测确认):

close 两次 → panic: close of closed channel
向已关闭 channel 发送 → panic: send on closed channel

规避原则:永远由发送方关闭 channel,且只有一个发送方。 接收方关闭必然导致「向已关闭 channel 发送」。多个发送方时不要关,用 context 或独立的 done channel 传达结束。

从已关闭 channel 接收是安全的(这是不对称的):

从已关闭 channel 接收: (7,true) 然后 (0,false) —— 不 panic

缓冲区里剩的值会先读完,读空后返回零值 + ok=false。所以 v, ok := <-chok 才是判断 channel 是否关闭的正确方式,不能靠 v 是不是零值。

nil channel 永远阻塞(不 panic)。这在 select 里是个特性:把某个 case 的 channel 设成 nil 等于动态禁用这个分支

goroutine 泄漏的两个成因

成因一:忘了 context 取消。

// ❌ 泄漏:异步写日志的 goroutine 永远不会退出
go func() {
    for {
        m := <-s.logCh   // 没有退出条件。服务关闭后它还在这儿等
        writeToFile(m)
    }
}()

// ✅ 给它一个退出路径
func StartLogWriter(ctx context.Context, ch <-chan string) <-chan struct{} {
    done := make(chan struct{})
    go func() {
        defer close(done) // ★ 告诉外面"我真的退了"
        for {
            select {
            case m := <-ch:
                writeToFile(m)
            case <-ctx.Done():
                // 退出前把 channel 里剩的排干,别丢日志
                for {
                    select {
                    case m := <-ch:
                        writeToFile(m)
                    default:
                        return
                    }
                }
            }
        }
    }()
    return done
}

实测这个模式:cancel() 之后 goroutine 在 1 秒内退出,done 被关闭。

成因二:向没人读的 channel 发送。

result := make(chan int)              // ❌ 无缓冲
go func() { result <- expensive() }()
select {
case r := <-result:
    use(r)
case <-time.After(time.Second):
    return   // ← 超时返回后再没人读 result,那个 goroutine 永久卡在发送上
}

result := make(chan int, 1)           // ✅ 一个缓冲位,发送方永远不会阻塞

main() 里的 serverErr := make(chan error, 1) 就是这个道理。

排查手段runtime.NumGoroutine() 打点看数量是否单调增长;import _ "net/http/pprof" 后访问 /debug/pprof/goroutine?debug=2 看每个 goroutine 卡在哪一行。


4.7 构建与部署

注入版本号

go build -trimpath \
  -ldflags "-s -w -X main.version=$(git rev-parse --short HEAD) -X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -o bin/blog ./cmd/server
参数 作用
-X main.version=abc1234 给包级 string 变量赋值。只对 string 有效,不能注入 int/bool;变量名要写完整包路径
-s -w 去掉符号表和 DWARF 调试信息
-trimpath 去掉本机绝对路径(否则 /Users/yourname/... 会被打进二进制,既泄露信息又破坏可重现构建)

实测:

$ ./blog
blog abc1234 (built 2026-09-06T10:00:00Z)

不加 -s -w: 2421074 字节
加了 -s -w: 1606386 字节     ← 小了 34%

代价:panic 栈里没有行号了,dlv 也调不了。生产建议保留调试信息(去掉 -s -w),800KB 换排障能力是划算的。

交叉编译:Go 的杀手锏

GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/blog-linux ./cmd/server

在 macOS ARM 上直接产出 Linux x86 二进制,实测:

blog-linux: ELF 64-bit LSB executable, x86-64, statically linked, stripped

statically linked 就是关键。 对比一下:

Go Python PHP / Node
部署产物 1 个文件 源码 + venv + 系统依赖 源码 + 运行时 + 扩展 + node_modules
目标机需要 什么都不需要 对应版本解释器 + pip 装包 对应版本运行时
「本地能跑线上不行」 几乎不存在 版本/依赖/glibc 差异是日常 同左
回滚 换回旧文件重启 重建整个环境 同左

scp bin/blog-linux server:/opt/blog/ && ssh server systemctl restart blog —— 部署完了。

CGO_ENABLED=0 必须写。 默认开启时 net 包会用系统 C 库做 DNS 解析,产物就动态链接了 glibc。在 Ubuntu 22.04 编译、拿到 CentOS 7 上跑会报:

./blog: /lib64/libc.so.6: version `GLIBC_2.32' not found

关掉 CGO 用纯 Go 的 DNS 解析器,产物完全静态,任何 Linux 都能跑(也是能塞进 scratch/distroless 镜像的前提)。

什么时候不能关:用了 mattn/go-sqlite3 等依赖 C 库的包。本项目的 go-sql-driver/mysql 是纯 Go 实现,可以放心关。

多阶段 Dockerfile

# Dockerfile
# ---------- 构建阶段 ----------
FROM golang:1.25-alpine AS builder
WORKDIR /src

# 先只拷依赖声明再 download —— Docker 层缓存的关键:
# 只要 go.mod/go.sum 没变这层就复用缓存,不用每次改代码都重下依赖
COPY go.mod go.sum ./
RUN go mod download

COPY . .

ARG VERSION=dev
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \
      -ldflags "-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" \
      -o /out/blog ./cmd/server

# ---------- 运行阶段 ----------
# distroless/static 只有 CA 证书、时区数据和 /etc/passwd,约 2MB。
# 没有 shell、没有包管理器、没有 curl —— 攻击者拿到 RCE 也没有工具可用。
# 需要 shell 调试就换成 alpine:3.20(约 8MB)
FROM gcr.io/distroless/static-debian12:nonroot

# 只拷二进制过来。源码、Go 工具链、依赖缓存全留在 builder,不进最终镜像
COPY --from=builder /out/blog /blog

USER nonroot:nonroot   # ★ 绝不要用 root 跑应用容器
EXPOSE 8080

# ★ 必须用 exec 形式(JSON 数组)。shell 形式 CMD blog 会套一层 /bin/sh,
#   结果 PID 1 是 sh 而不是你的程序 —— 它不转发 SIGTERM,优雅关闭直接失效
ENTRYPOINT ["/blog"]
docker build --build-arg VERSION=$(git rev-parse --short HEAD) -t blog:latest .
docker run --rm -p 8080:8080 \
  -e BLOG_ENV=prod \
  -e BLOG_MYSQL_DSN='blog:secret@tcp(mysql:3306)/blog?parseTime=True' \
  -e BLOG_JWT_SECRET='至少32字节的随机字符串xxxxxxxxxxxxx' \
  blog:latest

最终镜像约 10~15MB(distroless 基础层 2MB + 二进制)。对比典型的 Python 应用镜像(150MB+),这就是静态二进制的红利。

本课的 Dockerfile 没有在本机构建验证(本机 Docker daemon 未运行)。其中的 Go 构建命令(CGO_ENABLED=0 GOOS=linux go build + -ldflags)已单独实测通过,其余部分按标准多阶段构建写法编写——你第一次用之前请自己 docker build 一遍。

systemd unit

# /etc/systemd/system/blog.service
[Unit]
Description=Blog API Server
After=network-online.target mysql.service redis.service
Wants=network-online.target

[Service]
Type=simple
User=blog
Group=blog
WorkingDirectory=/opt/blog
ExecStart=/opt/blog/bin/blog

# ★ 这个文件必须 chmod 600 且属主是 root —— 里面有 DSN 和 JWT 密钥
EnvironmentFile=/etc/blog/blog.env

# ---- 优雅关闭的关键三行 ----
KillSignal=SIGTERM      # 默认就是它,写出来是为了明确意图
TimeoutStopSec=30       # ★ 必须大于代码里的 ShutdownGrace(10s)
KillMode=mixed          # 先只给主进程发信号,超时后才对整个 cgroup 发 SIGKILL

Restart=on-failure      # 只在非零退出码时重启。优雅关闭退出码是 0,不会触发重启
RestartSec=5s

# ---- 安全加固 ----
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/blog

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload && sudo systemctl enable --now blog
sudo journalctl -u blog -f -o cat | jq .   # slog 输出到 stdout,journald 自动收

/healthz 该检查什么

这是最容易做错的一件事,做错的代价是级联雪崩。 必须区分两种探针:

探针 问的问题 失败后果 该检查什么
存活 /healthz 「进程死了吗?」 重启容器 什么都不检查,能返回 200 就说明进程活着
就绪 /readyz 「能接流量吗?」 摘掉流量(不重启) DB、Redis 等关键依赖
// internal/handler/health.go

// Healthz 存活探针。★ 绝不检查外部依赖。
// 灾难场景:MySQL 抖动 30 秒 → 所有实例存活探针失败 → k8s 把全部实例重启
//          → 重启后一起冲击刚恢复的 MySQL → 又挂 → 无限重启循环。
// 这叫级联雪崩。数据库慢不是你的进程死了,重启解决不了任何问题
func Healthz(version string) gin.HandlerFunc {
    return func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"status": "ok", "version": version})
    }
}

// Readyz 就绪探针:检查关键依赖。失败只是被摘流量,不会被重启
func Readyz(sqlDB *sql.DB, rdb *redis.Client) gin.HandlerFunc {
    return func(c *gin.Context) {
        // ⚠️ 必须给短超时。探针本身卡住会让判定悬空,比直接失败更糟
        ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
        defer cancel()

        checks := gin.H{}
        ok := true

        if err := sqlDB.PingContext(ctx); err != nil {
            checks["mysql"] = err.Error()
            ok = false
        } else {
            checks["mysql"] = "ok"
        }

        if err := rdb.Ping(ctx).Err(); err != nil {
            // ★ Redis 只是缓存,挂了服务应该降级不是下线(见第 9 课)。
            //   所以这里记状态但不影响 ok —— 依赖的重要性不同,判定就该不同
            checks["redis"] = "degraded: " + err.Error()
        } else {
            checks["redis"] = "ok"
        }

        if !ok {
            c.JSON(http.StatusServiceUnavailable, gin.H{"status": "not ready", "checks": checks})
            return
        }
        c.JSON(http.StatusOK, gin.H{"status": "ready", "checks": checks})
    }
}

另外两点:健康检查端点不要走鉴权和限流(探针没有 token;被限流会导致实例被误判为不健康而重启),不要在健康检查里写数据库(每秒几次的探针会产生可观的写入)。

⑤ 跑起来验证

5.1 配置 fail fast

unset BLOG_MYSQL_DSN BLOG_JWT_SECRET
go run ./cmd/server; echo "退出码: $?"
配置加载失败: 缺少必填环境变量: BLOG_MYSQL_DSN, BLOG_JWT_SECRET
退出码: 1
set -a && source .env && set +a && go run ./cmd/server

日志里的配置必须是脱敏的:

{"level":"INFO","msg":"服务启动中","env":"dev","config":"Config{Env:dev Addr::8080 MySQL:***@tcp(127.0.0.1:3306)/blog_dev JWTSecret:***}"}

看到明文密码就说明 String() 没写对,或者哪里直接打了 cfg 的字段。

5.2 结构化日志与 request_id

curl -s -H 'X-Request-Id: my-trace-001' http://localhost:8080/api/v1/posts/1 > /dev/null

同一个请求产生的所有日志行都带着 my-trace-001

{"level":"DEBUG","msg":"查缓存","request_id":"my-trace-001","post_id":1}
{"level":"INFO","msg":"http request","request_id":"my-trace-001","status":200,"latency":467583}

这就是可观测性的起点:用户报障时给你一个 request_id,grep 一下就能看到那次请求的完整轨迹。不传 header 时服务会自己生成一个并通过响应头回给客户端。

5.3 优雅关闭(真发信号)

go run ./cmd/server > /tmp/blog.log 2>&1 &
APP=$!
sleep 1
curl -s http://localhost:8080/api/v1/slow &   # 一个 1.5 秒的慢接口
sleep 0.3
kill -TERM $APP
wait $APP; echo "退出码: $?"
cat /tmp/blog.log

实测输出:

listening
收到信号,开始优雅关闭
关闭完成,等在途请求用了 1.6s
退出码: 0

三个验证点:

  1. 那个 curl 必须拿到完整响应,而不是 curl: (52) Empty reply from server。单元测试里也确认了:Shutdown 等了 270ms 才返回,在途请求完整拿到 "done"
  2. 日志里不应该有 http: Server closed 的 ERROR。有的话就是 errors.Is(err, http.ErrServerClosed) 那个判断漏了。
  3. 退出码必须是 0,否则 systemd 的 Restart=on-failure 会认为它崩了然后重启。

5.4 测试

go test ./... && go test -race ./... && go test -cover ./...

本课全部测试在 -race 下实测通过:

--- PASS: TestPostService_Publish (0.00s)
    --- PASS: TestPostService_Publish/正常发布草稿 (0.00s)
    --- PASS: TestPostService_Publish/不是作者本人 (0.00s)
    --- PASS: TestPostService_Publish/正文为空 (0.00s)
    --- PASS: TestPostService_Publish/重复发布 (0.00s)
--- PASS: TestPostService_Publish_NotFound (0.00s)
--- PASS: TestPostService_Publish_RepoDown (0.00s)
--- PASS: TestPostHandler_Create (0.00s)
--- PASS: TestFullStack (0.00s)
--- PASS: TestGracefulShutdown_InFlight (0.42s)
--- PASS: TestSyncOnce (0.00s)
--- PASS: TestCounterRace (0.00s)
--- PASS: TestNoGoroutineLeak (0.00s)
PASS
ok      v10 2.036s

故意造一个竞态确认 -race 真的在工作(学会信任工具的前提是见过它报警):

// internal/service/race_demo_test.go
func TestRaceDemo(t *testing.T) {
    m := map[string]int{}
    var wg sync.WaitGroup
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() { defer wg.Done(); m["k"]++ }() // 无锁并发写 map
    }
    wg.Wait()
}
WARNING: DATA RACE
Write at 0x00c000123456 by goroutine 8:
Previous write at 0x00c000123456 by goroutine 7:

看到这个再把它删掉。

5.5 构建产物

GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/blog-linux ./cmd/server
file bin/blog-linux
bin/blog-linux: ELF 64-bit LSB executable, x86-64, statically linked, stripped

statically linked 就对了。出现 dynamically linked 说明 CGO_ENABLED=0 没生效。

5.6 健康检查(本课的最终验收)

curl -s http://localhost:8080/healthz | jq
curl -s http://localhost:8080/readyz  | jq
{"status":"ok","version":"abc1234"}
{"status":"ready","checks":{"mysql":"ok","redis":"ok"}}

把 Redis 停掉(本机才能这么干):

redis-cli SHUTDOWN NOSAVE
curl -s -o /dev/null -w "healthz=%{http_code}\n" http://localhost:8080/healthz
curl -s http://localhost:8080/readyz | jq
curl -s -o /dev/null -w "业务接口=%{http_code}\n" http://localhost:8080/api/v1/posts/1
healthz=200                                            ← 存活探针不受影响,不会被重启
{"status":"ready","checks":{"mysql":"ok","redis":"degraded: dial tcp ..."}}
业务接口=200                                            ← 第 9 课的降级生效

这三个数字是本课所有设计的最终验收:一个非关键依赖挂掉,服务降级但不下线、不重启、不报错。

⑥ TODO 练习

练习 1:给 handler 层补测试(★☆☆)

// internal/handler/post_handler_test.go
// TODO(练习1): 用表驱动 + httptest 测 GET /api/v1/posts/:id
//   1. 至少 4 个用例:正常、id 不存在(404)、id=abc(400)、id=0(400)
//   2. service 用 fake(不要真连 DB/Redis),给它注入 model.ErrNotFound 造 404
//   3. 断言 status code 和 APIError.Code —— ★ 断 Code 不要断 Message
//      (这一条实际测的是第 4 课 httpStatusFor 的领域错误→状态码映射)
//   4. 用 t.Run 子测试,失败信息要能一眼看出是哪个用例
func TestPostHandler_Get(t *testing.T) { panic("TODO") }

验收go test -v ./internal/handler 显示 4 个子测试全 PASS;-race 无报告;把 handler 里的错误码故意改错,测试必须失败(能捕获回归才是有效的测试)。

练习 2:panic 恢复中间件改用 slog(★☆☆)

// internal/middleware/recover.go
// TODO(练习2): 把第 5 课的 Recover 改写成 slog 版
//   1. 用 logging.From(c.Request.Context()) 拿带 request_id 的 logger
//   2. 记 Error 级别,带上 panic 值和完整调用栈(debug.Stack())
//   3. 返回统一的 500 响应,★ 绝不能把 panic 信息或栈泄露给客户端
//   4. 客户端主动断开导致的 http.ErrAbortHandler 不该当成错误刷屏
func Recover(base *slog.Logger) gin.HandlerFunc { panic("TODO") }

验收:加一个 /panic 测试路由,curl 返回 500 且响应体里没有任何栈信息;日志里有完整栈和 request_id;连打 3 次 panic 服务不退出。

练习 3:并发预热热门文章(★★☆)

// internal/job/warmup.go
// TODO(练习3): 用 errgroup 并发预热 Top 20 热门文章的缓存
//   1. 从第 9 课的热榜 ZSet 取 Top 20 的 id
//   2. errgroup 并发查 DB 并写缓存
//   3. ★ 必须 g.SetLimit(5) —— 别让预热本身把 DB 打挂
//   4. 单篇失败只记 warn 不中断整体(预热是尽力而为,不是关键路径)
//   5. 整体 30 秒超时;在后台 goroutine 里跑,不能拖慢服务启动
func WarmupCache(ctx context.Context, rdb *redis.Client, svc *service.PostService) error { panic("TODO") }

验收:启动后 redis-cli KEYS 'blog:v1:post:*' 有 20 个 key;日志显示预热耗时;把 DB 停掉再启动服务,服务必须正常起来(只是预热失败记 warn);-race 无报告。

练习 4:优雅关闭的自动化测试(★★☆)

// cmd/server/shutdown_test.go
// TODO(练习4): 写测试证明优雅关闭真的不截断请求
//   1. 起一个 sleep 500ms 的 handler
//   2. goroutine 发请求,100ms 后调 srv.Shutdown(ctx)
//   3. 断言:① 请求拿到完整响应体 ② Shutdown 返回 nil(不是超时)
//          ③ ListenAndServe 返回 ErrServerClosed ④ Shutdown 耗时 ≥400ms(证明它真的等了)
//   4. 再写反例:宽限期设 100ms,断言 Shutdown 返回 context.DeadlineExceeded
func TestGracefulShutdown(t *testing.T) { panic("TODO") }

验收go test -race -run TestGracefulShutdown ./cmd/server 通过;srv.Shutdown 换成 srv.Close(),测试必须失败Close 是硬关,会截断在途请求)。

练习 5:Prometheus 指标端点(★★★)

日志能查单次请求,但回答不了「过去一小时 p99 延迟是多少」。

// internal/middleware/metrics.go
// TODO(练习5): 加 /metrics 端点(可以只用标准库手写,不引 prometheus client)
//   1. 至少三个指标:
//      http_requests_total{method,path,status}    计数器
//      http_request_duration_seconds{method,path} 直方图(桶:.005 .01 .025 .05 .1 .25 .5 1 2.5 5)
//      cache_hits_total / cache_misses_total      呼应第 9 课
//   2. ★ path 必须用 c.FullPath() 路由模板 —— 用真实路径会产生无限多 label 值,
//      这叫指标基数爆炸,会把监控系统打挂
//   3. 用 sync/atomic 或 RWMutex 保证并发安全
//   4. ★ /metrics 不能对公网暴露(它泄露内部结构),用单独端口或内网限制
func Metrics() gin.HandlerFunc { panic("TODO") }

验收:打 100 次请求后 curl localhost:8080/metrics | grep http_requests_total 计数正确;-race 无竞态;curl /api/v1/posts/1/posts/2 只产生一个 label 组合(证明用的是路由模板);打 100 万次请求后 label 组合数不增长。

⑦ 自检清单

配置

  • 所有配置来自环境变量,代码里没有硬编码的地址和密钥
  • Load() 一次收集全部缺失项;缺关键配置时进程启动就退出(退出码 1)
  • .env.gitignore 里,同时提交了 .env.example
  • Config 实现了脱敏的 String(),日志里搜不到明文密码
  • 我知道密码 commit 进 git 后正确的做法是轮换密钥,不是删历史

日志

  • log/slog,输出到 stdout,不自己写文件轮转
  • context key 是私有的空结构体类型(不是字符串);From(ctx) 永不返回 nil
  • 每条日志带 request_id,同一请求的所有日志能串起来
  • 日志级别随状态码变(5xx→Error、4xx→Warn)
  • 不记密码、token、完整请求体、PII、整个 struct;敏感类型实现了 LogValue()

优雅关闭

  • signal.NotifyContext同时监听 os.Interruptsyscall.SIGTERM
  • ListenAndServe 的返回值用 errors.Is 排除了 http.ErrServerClosed
  • Shutdown新的 context(不是被信号取消的那个),带超时
  • 收到信号后先 stop(),让用户能再按一次 Ctrl+C 强杀
  • 关闭顺序是 HTTP → 后台任务 → Redis → DB 最后,我知道反过来的后果
  • 后台任务能通过 context 退出,并有 done channel 让 main 确认它真的退了
  • http.Server 设了 ReadHeaderTimeout/ReadTimeout/WriteTimeout/IdleTimeout
  • 优雅关闭的退出码是 0;传启动错误的 channel 有缓冲

测试

  • 会写表驱动测试 + t.Run 子测试;每个用例用独立的 fake,不共享状态
  • 我用接口 + fake repo 测 service,不需要真数据库
  • fake 里有 var _ Interface = (*fake)(nil) 编译期断言
  • 断言 APIError.Code 不断 Message;handler 测试覆盖了 httpStatusFor 的映射
  • 集成测试里的时间先 Truncate(time.Second)(MySQL DATETIME 是四舍五入不是截断)
  • 负向用例断言的是「没产生副作用」,不只是「返回了错误」
  • 时间等外部依赖被注入(now func() time.Time),不直接调 time.Now()
  • 我知道 t.Error/t.Fatal/t.Helper/t.Cleanup/t.Parallel 各自的用途
  • 我知道 t.Fatal 不能在子 goroutine 里调用
  • 会用 httptest.NewRecorder(测 handler)和 httptest.NewServer(测全链路)
  • go test -race ./... 是绿的,而且我见过它报警长什么样
  • 我不追求 100% 覆盖率,测业务规则和边界而不是 getter

并发

  • 我知道 Go 1.22 起循环变量每轮新建,也知道这取决于 go.modgo 指令
  • wg.Addgo 之前,wg.Donedefer
  • 需要错误传播或取消时用 errgroup;批量任务用 g.SetLimit(n)
  • 并发访问 map 一定加锁(concurrent map writesrecover 拦不住的 fatal error)
  • Unlock 一律 deferRLockRUnlock
  • 我知道 RWMutex 不是无脑更好,sync.Map 只适合两种特定场景
  • 我知道 close 两次、向已关闭 channel 发送都会 panic;接收是安全的
  • channel 由发送方关闭,且只有一个发送方
  • 每个长期运行的 goroutine 都有明确的退出路径;time.Ticker 用完 Stop()
  • 我知道「向没人读的 channel 发送」也是泄漏,缓冲位能规避

构建部署

  • 会用 -ldflags -X 注入版本号(只对 string 变量有效);会用 -trimpath
  • 会交叉编译,且知道 CGO_ENABLED=0 是静态链接的前提
  • Dockerfile 是多阶段的;容器用非 root;ENTRYPOINT 用 exec 形式
  • systemd 的 TimeoutStopSec 大于代码里的 ShutdownGrace
  • 存活探针不检查外部依赖,我能说清级联雪崩是怎么发生的
  • 就绪探针有短超时,且区分「关键依赖」和「可降级依赖」
  • 健康检查端点不走鉴权、不走限流、不写数据库

收尾:这个博客系统还缺什么

「能上线」和「成熟」之间还有距离。诚实列一下缺口,这就是你的下一步路线图:

缺什么 为什么需要 从哪开始
游标分页 LIMIT offset, n 在 offset 大时全表扫描;翻页期间插入新数据会重复或漏项 改成 WHERE id < last_id ORDER BY id DESC LIMIT n
全文搜索 LIKE '%词%' 用不上索引 先试 MySQL FULLTEXT + ngram;再大上 Meilisearch(轻)/ Elasticsearch(重)
图片上传 现在只能写纯文本 直传对象存储,后端只签发预签名 URL —— 千万不要让图片流经你的服务
异步任务 注册验证邮件、评论提醒 队列(先 Redis List,再 NATS/Kafka)。绝不在 HTTP 请求里同步发邮件
API 文档 前端只能问你或看代码 swaggo/swag 从注释生成 OpenAPI
CI 现在靠你记得跑 go test GitHub Actions:go vet + golangci-lint + go test -race -cover,PR 不过不许合
数据库迁移工具 现在靠手动跑 schema.sql golang-migrate / goose,把结构变更做成有版本号、能回滚的迁移文件
监控指标 日志能查单次请求,答不了「p99 多少」 Prometheus(练习 5)+ Grafana。四个黄金信号:延迟、流量、错误率、饱和度
链路追踪 服务一多,慢请求慢在哪层说不清 OpenTelemetry。你的 request_id 已经是雏形,换成标准 trace_id 即可
告警 出事了靠用户告诉你 基于指标配:错误率 > 1%、p99 > 1s、就绪探针连续失败
配置热更新 改个日志级别要重启 先别做。重启一个无状态服务的成本,远低于热更新引入的复杂度

一条选择原则:上面每一项都有「引入的复杂度」和「解决的问题」两侧。在你真的遇到那个问题之前,不要引入对应的复杂度。 博客一天 100 个请求时不需要 Elasticsearch,也不需要链路追踪。

十课回顾

第 1 课 你写下第一个 go.mod 和 20 行的 HTTP 服务,学会了 Go 最反直觉也最重要的约定——错误是值,if err != nil 要立即处理,不吞不藏

第 2 课net/httpServeMux 手写路由,知道了 Handler 只是一个单方法接口,「框架」这个词开始祛魅。

第 3 课 连上 MySQL,理解了 database/sql 的连接池不是你想的那样,学会了 Scan、预处理、事务,以及 NULL 在 Go 里为什么这么麻烦。

第 4 课 把代码切成 repository / service / handler 三层,用接口而不是具体类型做依赖。当时你可能觉得这是多余的仪式——直到第 10 课,你用一个 fake repo 测完了整个 service 层,一次数据库都没连。

第 5 课 中间件、context、统一错误。横切关注点该怎么组织。

第 6 课 bcrypt 和 JWT。密码为什么不能可逆加密,以及 JWT 无状态的代价——那个伏笔在第 9 课用 Redis 黑名单填上了。

第 7 课 迁移到 Gin。因为先手写过一遍,你看得懂它每个便利背后做了什么。

第 8 课 GORM。关联、预加载、软删除,以及 ORM 什么时候帮你、什么时候挡你的路。

第 9 课 Redis。缓存的三种经典失效、限流、分布式锁。更重要的是学会了先问「该不该缓存」,以及每一个「方案」都同时是一个「新的故障点」。

第 10 课 配置、日志、优雅关闭、测试、并发、部署。代码从「能跑」变成了「能交付」。


如果这十课只留下一件事,那应该是:你现在能读懂别人的 Go 代码,也能解释自己每一行的取舍。

框架会换(Gin 可能被别的取代),库会变(GORM 有很多替代品),但下面这些不会:

  • 错误是值,显式处理
  • 接口定义在使用方,小接口优于大接口
  • context 贯穿调用链,超时和取消是一等公民
  • 并发要有明确的退出路径
  • 每一个「方案」都要说清楚它的代价

这套教程留了 40 多个 TODO 没做完。去把它们做完——你敲出来的每一个编译错误,都比读十遍这份文档更有用。

然后把这个博客真正部署出去,写第一篇文章。

祝顺利。