一个生产级别的Go API应该怎么写?
你写了一个 Go HTTP 服务http.ListenAndServe(:8080,nil)启动成功。接口能访问。Postman 点起来啪啪响。测试全部通过。于是你满意地点了点头可以上线了。先别急着庆祝。因为这行代码最大的优点就是能跑。而生产环境最大的特点是它从来不按照你的测试用例运行。测试环境里客户端都很礼貌生产环境里有人会慢慢发请求有人会突然发几个 GB 的 Body有人会让下游接口卡 30 秒还有人会在凌晨 3 点把流量打到你昨晚刚部署的服务上。然后你会发现这个 HTTP Server 没有超时、没有优雅退出、没有请求体限制、没有并发保护甚至出了 panic你都不知道发生了什么。它不是不能工作它只是还没经历过真正的线上世界。下面这 7 件事决定了一个 Go HTTP Server 究竟是“Demo”还是“生产服务”。一、超时没有 Timeout 的服务器像一间永远不关门的酒店http.Server默认并不会替你设置完整的读、写、空闲超时。这意味着一个客户端如果慢吞吞地和你建立连接、发送数据甚至故意一直吊着连接你的服务可能就一直陪它耗。CPU 不一定先死内存也不一定先死通常先死的是连接数、文件描述符以及你的耐心。生产环境应该显式设置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 个正在等用户返回。正确做法是Shutdownctx,cancel:context.WithTimeout(context.Background(),15*time.Second)defercancel()iferr:srv.Shutdown(ctx);err!nil{log.Printf(forced shutdown: %v,err)}Shutdown 会停止接受新连接并等待正在处理的请求完成直到超过你设定的期限。这就是所谓的优雅退出新客不进门老客吃完饭再走。还有一个容易踩坑的地方不要因为想“脱离请求生命周期”就无脑使用context.Background()。如果确实需要让某段后台工作脱离请求取消可以考虑context.WithoutCancel这样不会顺手把原来的上下文信息也一刀切掉。三、Panic Recovery一个请求炸了不应该拖全家下水业务代码里总会有一些意外数组越界、空指针、某个你发誓“这里绝对不会为空”的变量偏偏为空。如果 panic 没有被合理处理整个服务可能直接进入事故现场。可以在 HTTP Middleware 层统一兜底funcRecover(next http.Handler)http.Handler{returnhttp.HandlerFunc(func(w http.ResponseWriter,r*http.Request){deferfunc(){iferr: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。你的服务照单全收然后内存爆了。不要让客户端决定你的服务愿意吃多少数据constmaxBodyBytes120// 1 MBfuncLimitBody(next http.Handler)http.Handler{returnhttp.HandlerFunc(func(w http.ResponseWriter,r*http.Request){r.Bodyhttp.MaxBytesReader(w,r.Body,maxBodyBytes)next.ServeHTTP(w,r)})}很多系统不是被“高并发”打死的而是被一个请求撑死的。并发攻击像一群人抢着进门超大 Body 则像一个人扛着冰箱进来。五、Context客户端都走了你还在忙什么HTTP 请求自带 Context当客户端断开连接时这个 Context 会被取消。所以ctx : r.Context()不是装饰品应该把它一路传下去order,err:fetchOrder(ctx,db,r.PathValue(id))这样数据库查询、gRPC 调用、下游 HTTP 请求等都有机会随着请求取消而停止。否则会出现一种荒谬的情况用户早就关掉页面了你的服务还在“别急我马上给你查”。Context 的价值不只是传递数据它本质上是在告诉整个调用链这个工作现在还有没有必要继续。六、结构化日志事故现场不要靠肉眼考古开发环境里log.Println(request failed)挺好的。但生产环境一上规模日志很快就会变成一堆无法关联的散乱信息然后你面对几千行日志分不清哪条对应哪次请求。一个简单的做法是注入 Request IDid:r.Header.Get(X-Request-ID)ifid{iduuid.NewString()}然后把它放进 Context同时返回给客户端。再配合log/slog做结构化日志。这样一次请求从 HTTP → Handler → DB → RPC → 下游服务都可以通过同一个 ID 串起来。发生事故的时候输入一个 ID整条调用链自动浮现。七、并发限制不要让服务器死得太有礼貌默认情况下服务器会不断接收请求。流量上来以后HTTP 请求增加数据库查询增加连接池开始排队内存开始上涨Goroutine 开始增加最后大家一起去世。可以增加一个简单的并发限制funcThrottle(maxint)func(http.Handler)http.Handler{sem:make(chanstruct{},max)returnfunc(next http.Handler)http.Handler{returnhttp.HandlerFunc(func(w http.ResponseWriter,r*http.Request){select{casesem-struct{}{}:deferfunc(){-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 的目标是“请求能进来”生产服务的目标是在世界开始发疯的时候它依然知道该怎么活下去。