一个生产级别的Go API应该怎么写?
你写了一个 Go HTTP 服务: ```go http.ListenAndServe(":8080", nil) ``` 启动成功。接口能访问。Postman 点起来啪啪响。测试全部通过。于是你满意地点了点头:可以上线了。 先别急着庆祝。因为这行代码最大的优点,就是**能跑**。而生产环境最大的特点,是它从来不按照你的测试用例运行。 测试环境里,客户端都很礼貌;生产环境里,有人会慢慢发请求,有人会突然发几个 GB 的 Body,有人会让下游接口卡 30 秒,还有人会在凌晨 3 点把流量打到你昨晚刚部署的服务上。然后你会发现:这个 HTTP Server 没有超时、没有优雅退出、没有请求体限制、没有并发保护,甚至出了 panic,你都不知道发生了什么。 它不是不能工作,它只是还没经历过真正的线上世界。 下面这 7 件事,决定了一个 Go HTTP Server 究竟是“Demo”还是“生产服务”。 --- ## 一、超时:没有 Timeout 的服务器,像一间永远不关门的酒店 `http.Server` 默认并不会替你设置完整的读、写、空闲超时。这意味着,一个客户端如果慢吞吞地和你建立连接、发送数据,甚至故意一直吊着连接,你的服务可能就一直陪它耗。CPU 不一定先死,内存也不一定先死,通常先死的是连接数、文件描述符,以及你的耐心。 生产环境应该显式设置: ```go srv := &http.Server{ Addr: ":8080", Handler: router, ReadTimeout: 5 * time.Second, ReadHeaderTimeout: 3 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, } ``` 其中 `ReadHeaderTimeout` 对防御 Slowloris 一类慢请求尤其重要。一个简单原则是:HTTP 请求可以慢,但不能无限慢。如果某个 Handler 真需要执行几分钟,那通常不是“把 Timeout 调成 10 分钟”这么简单,而应该问一句:**这件事为什么还在同步 HTTP 请求里干?** 也许它真正应该进入任务队列。 --- ## 二、优雅退出:服务器下班,也要讲职业道德 生产环境最常见的一幕:部署新版本,Kubernetes 发来 SIGTERM,进程收到信号,然后——没了。刚好有 20 个请求正在处理,其中 5 个写数据库,3 个调用支付服务,剩下 12 个正在等用户返回。 正确做法是 `Shutdown`: ```go ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Printf("forced shutdown: %v", err) } ``` Shutdown 会停止接受新连接,并等待正在处理的请求完成,直到超过你设定的期限。这就是所谓的优雅退出:**新客不进门,老客吃完饭再走。** 还有一个容易踩坑的地方:不要因为想“脱离请求生命周期”,就无脑使用 `context.Background()`。如果确实需要让某段后台工作脱离请求取消,可以考虑 `context.WithoutCancel`,这样不会顺手把原来的上下文信息也一刀切掉。 --- ## 三、Panic Recovery:一个请求炸了,不应该拖全家下水 业务代码里总会有一些意外:数组越界、空指针、某个你发誓“这里绝对不会为空”的变量偏偏为空。如果 panic 没有被合理处理,整个服务可能直接进入事故现场。 可以在 HTTP Middleware 层统一兜底: ```go func Recover(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { log.Printf("panic recovered: %v\n%s", err, debug.Stack()) http.Error(w, "internal server error", http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) } ``` 重点不只是“别让进程死”,更重要的是**留下完整现场**。否则第二天你看到日志里只有一行 `500 Internal Server Error`,然后开始和同事一起猜数据库、猜网络、猜 Redis、猜人生。有 Stack Trace,至少还能知道凶手是谁。 --- ## 四、限制 Request Body:别让别人拿你的服务器当硬盘 假设你的接口只需要接收 `{"name": "Tom"}`,结果客户端发过来一个 5GB 的 JSON。你的服务照单全收,然后内存爆了。 不要让客户端决定你的服务愿意吃多少数据: ```go const maxBodyBytes = 1 << 20 // 1 MB func LimitBody(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) next.ServeHTTP(w, r) }) } ``` 很多系统不是被“高并发”打死的,而是被一个请求撑死的。**并发攻击像一群人抢着进门,超大 Body 则像一个人扛着冰箱进来。** --- ## 五、Context:客户端都走了,你还在忙什么? HTTP 请求自带 Context,当客户端断开连接时,这个 Context 会被取消。所以 `ctx := r.Context()` 不是装饰品,应该把它一路传下去: ```go order, err := fetchOrder(ctx, db, r.PathValue("id")) ``` 这样数据库查询、gRPC 调用、下游 HTTP 请求等都有机会随着请求取消而停止。否则会出现一种荒谬的情况:用户早就关掉页面了,你的服务还在“别急,我马上给你查”。Context 的价值不只是传递数据,它本质上是在告诉整个调用链:**这个工作,现在还有没有必要继续。** --- ## 六、结构化日志:事故现场不要靠肉眼考古 开发环境里 `log.Println("request failed")` 挺好的。但生产环境一上规模,日志很快就会变成一堆无法关联的散乱信息,然后你面对几千行日志,分不清哪条对应哪次请求。 一个简单的做法是注入 Request ID: ```go id := r.Header.Get("X-Request-ID") if id == "" { id = uuid.NewString() } ``` 然后把它放进 Context,同时返回给客户端。再配合 `log/slog` 做结构化日志。这样一次请求从 HTTP → Handler → DB → RPC → 下游服务,都可以通过同一个 ID 串起来。发生事故的时候,输入一个 ID,整条调用链自动浮现。 --- ## 七、并发限制:不要让服务器死得太有礼貌 默认情况下,服务器会不断接收请求。流量上来以后,HTTP 请求增加,数据库查询增加,连接池开始排队,内存开始上涨,Goroutine 开始增加,最后大家一起去世。 可以增加一个简单的并发限制: ```go func Throttle(max int) func(http.Handler) http.Handler { sem := make(chan struct{}, max) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case sem <- struct{}{}: defer func() { <-sem }() next.ServeHTTP(w, r) default: http.Error(w, "server busy", http.StatusServiceUnavailable) } }) } } ``` 超过容量时返回 **503 Service Unavailable**。这听起来不太优雅,但比起“先把所有请求排队,然后 30 秒后 OOM”好多了。**可控的失败,远比失控的成功更可靠。** --- ## 真正的生产级,不是多装几个框架 有意思的是,上面这 7 件事,没有一个需要你引入新框架。Timeout、Shutdown、Recovery、Body Limit、Context、Structured Logging、Concurrency Limit——全是 Go 和 `net/http` 已经给你的基础能力。 所以 Go 服务真正的“生产化”,很多时候不是“再加一个框架”,而是**把标准库里那些你懒得配置的东西认真配置起来**。一个没有这些保护措施的服务,照样可以启动、通过测试、跑在 Staging,甚至连续稳定运行三个月。然后某天凌晨 3:17,流量突然上涨,下游突然变慢,一个客户端突然发来超大请求——这时候你才会知道:“能跑”和“扛得住”是两回事。 所以,如果你的代码现在还是 `http.ListenAndServe(":8080", nil)`,别急着删。它没有错,只是它更像是一个起点,而不是终点。Demo 的目标是“请求能进来”,生产服务的目标是**在世界开始发疯的时候,它依然知道该怎么活下去。**