Go 博客教程 · 第 10 课 / 10
第 10 课:配置、日志、测试与上线
九课下来你写出了一个功能完整的博客后端。但它现在还是个「在我机器上能跑」的东西:配置写死在代码里、日志是 fmt.Println、Ctrl+C 会把正在处理的请求砍断、没有一行测试。
这一课把它变成一个能交付出去的服务。
① 本课目标
学完能独立写出:
- 环境变量驱动的
Config,启动即校验、缺关键配置直接崩(fail fast) log/slog结构化日志,带 request_id 贯穿各层- 优雅关闭:收到信号后停止接收新请求、等在途请求做完、按序关掉 DB 和 Redis
- 表驱动测试 +
httptest;用第 4 课的接口写 fake repo 测 service,不需要真数据库 - 博客场景真实需要的那部分并发:
errgroup、sync.Once、RWMutex、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/slog、net/http/httptest、os/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 / logrus:slog 够用、是标准库(不会有依赖冲突、不会停止维护),而且它定义的 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.Interrupt 是 Ctrl+C(SIGINT),syscall.SIGTERM 是 kill、systemd stop、Docker stop、k8s 删 Pod 发的信号。两个都要监听——只听 SIGINT 的话,线上部署时的优雅关闭完全不会触发。
SIGKILL(kill -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.Fatal:Fatal 立刻停掉当前测试(后续断言依赖前面结果时用),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.ErrNotFound、ErrForbidden…)到 HTTP 状态码的映射,第 4 课已经收敛在 internal/handler/errors.go 的 httpStatusFor(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。
WaitGroup 与 errgroup
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 := <-ch 的 ok 才是判断 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
三个验证点:
- 那个
curl必须拿到完整响应,而不是curl: (52) Empty reply from server。单元测试里也确认了:Shutdown 等了 270ms 才返回,在途请求完整拿到 "done"。 - 日志里不应该有
http: Server closed的 ERROR。有的话就是errors.Is(err, http.ErrServerClosed)那个判断漏了。 - 退出码必须是 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.Interrupt和syscall.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)(MySQLDATETIME是四舍五入不是截断) - 负向用例断言的是「没产生副作用」,不只是「返回了错误」
- 时间等外部依赖被注入(
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.mod的go指令 -
wg.Add在go之前,wg.Done用defer - 需要错误传播或取消时用
errgroup;批量任务用g.SetLimit(n) - 并发访问 map 一定加锁(
concurrent map writes是recover拦不住的 fatal error) -
Unlock一律defer;RLock配RUnlock - 我知道
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/http 和 ServeMux 手写路由,知道了 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 没做完。去把它们做完——你敲出来的每一个编译错误,都比读十遍这份文档更有用。
然后把这个博客真正部署出去,写第一篇文章。
祝顺利。