GO [ 标准库 · HTTP 高可用 ]

HTTP 服务

net/http 是 Go 标准库里最容易"几行代码跑起来"的包:创建一个 handler,调用 ListenAndServe,服务就能接受请求。但能启动不等于能长期运行。

一个高可用 HTTP 服务还要回答这些问题:

  • 请求如何路由到正确的插件?
  • 一个中间件发生 panic,是否会拖垮整个进程?
  • 客户端迟迟不发完请求,服务端是否一直占着连接?
  • 上游服务卡住时,当前请求什么时候结束?
  • 发布新版本时,如何停止接收新请求并等待旧请求完成?
  • /healthz 和 /readyz 分别表达什么状态?
  • 如何用 httptest 测试 handler,而不是启动真实端口?

Go 官方将 net/http 描述为 HTTP 客户端和服务端实现。它提供 Handler、ServeMux、Server、Client、Transport 等核心类型。本文会从最小服务讲起,最后实现一个可插拔的高可用服务骨架:插件注册路由,中间件统一处理请求 ID、日志、恢复和超时,服务支持健康检查、就绪状态和优雅关闭。

本文的核心判断是:高可用 HTTP 代码首先要控制生命周期和边界,其次才是添加业务路由。

本文代码按 Go 1.27 标准库整理。端口、退出耗时和日志时间会因运行环境不同而变化。

最小 HTTP 服务

复制代码
package main

import (
	"fmt"
	"net/http"
)

func hello(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "hello, http")
}

func main() {
	http.HandleFunc("/", hello)
	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

HandleFunc 把函数转换成 HandlerFunc,默认注册到 http.DefaultServeMux。ListenAndServe 创建服务并阻塞等待请求。

简单程序可以这样写,但实际服务更建议显式创建 ServeMux 和 Server:

复制代码
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", hello)

server := &http.Server{
	Addr:    ":8080",
	Handler: mux,
}

显式对象更容易测试,也避免全局路由表被不同包偷偷修改。

Handler 和请求作用域

Handler 接口只有一个方法:

复制代码
type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

自定义 handler:

复制代码
type HealthHandler struct{}

func (HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("ok\n"))
}

handler 返回后,请求生命周期通常就结束了。不要在 ServeHTTP 返回后继续读写 Request.Body 或 ResponseWriter。需要异步任务时,应先复制必要数据,或者把任务提交给独立的队列。

方法和路径匹配

Go 1.22 之后,ServeMux 支持带 HTTP 方法的模式:

复制代码
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)

路径参数可以从 Request.PathValue 获取:

复制代码
func getUser(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	fmt.Fprintf(w, "user=%s", id)
}

路由冲突会在注册时 panic。程序启动阶段注册固定路由,能尽早发现配置错误。

请求、响应和资源关闭

一个请求包含方法、URL、headers、body 和 context:

复制代码
func echo(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "read body failed", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	_, _ = w.Write(body)
}

读取请求体时需要设置上限。io.ReadAll(r.Body) 没有大小边界,客户端可以用一个超大 body 消耗内存。更直接的写法是:

复制代码
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

响应头必须在 WriteHeader 或第一次 Write 前设置:

复制代码
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"ok":true}`))

写入响应后不要再尝试修改状态码。HTTP 状态一旦写出,header 就提交了。

中间件

中间件接收一个 handler,返回另一个 handler:

复制代码
type Middleware func(http.Handler) http.Handler

请求 ID

复制代码
type requestIDKey struct{}

func requestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			id = strconv.FormatInt(time.Now().UnixNano(), 10)
		}
		w.Header().Set("X-Request-ID", id)
		ctx := context.WithValue(r.Context(), requestIDKey{}, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

真实项目可以使用 UUID 或雪花 ID。这里用时间戳只是为了展示 context 数据如何向下游传递。

日志中间件

复制代码
func accessLog(logger *log.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			next.ServeHTTP(w, r)
			logger.Printf("method=%s path=%s duration=%s", r.Method, r.URL.Path, time.Since(start))
		})
	}
}

如果要记录状态码,需要包装 ResponseWriter 捕获 WriteHeader 和 Write:

复制代码
type statusWriter struct {
	http.ResponseWriter
	status int
}

func (w *statusWriter) WriteHeader(status int) {
	w.status = status
	w.ResponseWriter.WriteHeader(status)
}

func (w *statusWriter) Write(body []byte) (int, error) {
	if w.status == 0 {
		w.status = http.StatusOK
	}
	return w.ResponseWriter.Write(body)
}

包装 ResponseWriter 时要注意 Flusher、Hijacker、ReaderFrom 等可选接口。只实现最小方法可能让流式响应或 WebSocket 行为改变。对于普通 JSON API,最小包装通常足够;需要代理、流式下载或 WebSocket 时应该使用 http.NewResponseController 或完整转发接口。

恢复 panic

复制代码
func recoverPanic(logger *log.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if value := recover(); value != nil {
					logger.Printf("panic=%v", value)
					http.Error(w, "internal server error", http.StatusInternalServerError)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

恢复 panic 只能保护当前请求。它不是修复 bug 的方式,panic 仍然应该记录堆栈并触发告警。

组合中间件

复制代码
func chain(handler http.Handler, middlewares ...Middleware) http.Handler {
	for i := len(middlewares) - 1; i >= 0; i-- {
		handler = middlewares[i](handler)
	}
	return handler
}

执行顺序从左到右包裹,从右到左进入:

复制代码
handler := chain(mux,
	recoverPanic(logger),
	requestID,
	accessLog(logger),
)

超时和取消

HTTP 服务至少要考虑四种超时:

配置 作用
ReadHeaderTimeout 读取请求头的最大时间
ReadTimeout 读取完整请求的最大时间
WriteTimeout 写响应的最大时间
IdleTimeout keep-alive 空闲连接时间

服务端配置:

复制代码
server := &http.Server{
	Addr:              ":8080",
	Handler:           handler,
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       15 * time.Second,
	WriteTimeout:      15 * time.Second,
	IdleTimeout:       60 * time.Second,
}

handler 内部还可以通过 http.TimeoutHandler 设置单个路由超时:

复制代码
slow := http.TimeoutHandler(handler, 2*time.Second, "timeout\n")

更推荐让业务代码监听 r.Context().Done():

复制代码
func query(w http.ResponseWriter, r *http.Request) {
	select {
	case result := <-doWork(r.Context()):
		fmt.Fprintln(w, result)
	case <-r.Context().Done():
		return
	}
}

客户端断开、服务器关闭或上游超时后,context 会被取消。业务函数必须把 context 传给数据库、HTTP client 和下游调用,取消才有机会真正传递。

HTTP 客户端

http.Get 适合一次性示例,但生产代码应该复用 http.Client。官方文档特别强调,Client 和 Transport 内部维护连接缓存,应该复用而不是每次请求创建。

复制代码
client := &http.Client{
	Timeout: 5 * time.Second,
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
	return err
}
resp, err := client.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
	return fmt.Errorf("upstream status: %s", resp.Status)
}

无论状态码是什么,都要关闭 response body。读取完 body 后再关闭,Transport 才更容易复用连接。

不要只设置客户端总超时就结束。连接建立、TLS 握手、读取 header、读取 body 可能需要不同边界;复杂客户端可以自定义 Transport 和 context deadline。

可插拔项目:插件式高可用 HTTP 服务

现在实现一个服务框架。插件只需要实现一个接口:

复制代码
type Plugin interface {
	Name() string
	Register(*http.ServeMux)
}

主程序负责:

  • 创建 ServeMux;
  • 注册插件;
  • 加载中间件;
  • 暴露 /healthz 和 /readyz;
  • 启动 HTTP server;
  • 接收 SIGINT、SIGTERM;
  • 调用 Shutdown 优雅退出。

插件注册

复制代码
type UserPlugin struct{}

func (UserPlugin) Name() string { return "users" }

func (UserPlugin) Register(mux *http.ServeMux) {
	mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "plugin=users id=%s\n", r.PathValue("id"))
	})
}

再写一个 metrics 插件:

复制代码
type MetricsPlugin struct{}

func (MetricsPlugin) Name() string { return "metrics" }

func (MetricsPlugin) Register(mux *http.ServeMux) {
	mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; version=0.0.4")
		fmt.Fprintln(w, "http_plugin_up 1")
	})
}

插件不需要知道 server 如何启动,也不应该自己创建监听端口。端口、生命周期和中间件属于宿主程序。

健康和就绪

健康检查回答"进程是否还活着";就绪检查回答"实例是否可以接收流量"。两者不能混为一谈。

复制代码
type State struct {
	mu    sync.RWMutex
	ready bool
}

func (s *State) SetReady(value bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.ready = value
}

func (s *State) Ready() bool {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.ready
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("ok\n"))
}

func readyHandler(state *State) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if !state.Ready() {
			http.Error(w, "not ready", http.StatusServiceUnavailable)
			return
		}
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ready\n"))
	}
}

优雅关闭

Server.Shutdown 停止接受新连接,并等待已有连接结束,直到 context 到期:

复制代码
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

serverErr := make(chan error, 1)
go func() {
	logger.Printf("listen on %s", server.Addr)
	serverErr <- server.ListenAndServe()
}()

select {
case err := <-serverErr:
	if !errors.Is(err, http.ErrServerClosed) {
		logger.Fatal(err)
	}
case <-ctx.Done():
		state.SetReady(false)
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		if err := server.Shutdown(shutdownCtx); err != nil {
			logger.Printf("shutdown failed: %v", err)
		}
}

先把 readiness 设为 false,再调用 Shutdown,给负载均衡器一点时间停止转发新请求。真正的部署系统还应该配合 preStop、连接排空和进程退出时间配置。

完整项目

复制代码
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"sync"
	"syscall"
	"time"
)

type Middleware func(http.Handler) http.Handler

type Plugin interface {
	Name() string
	Register(*http.ServeMux)
}

type State struct { mu sync.RWMutex; ready bool }
func (s *State) SetReady(v bool) { s.mu.Lock(); s.ready = v; s.mu.Unlock() }
func (s *State) Ready() bool { s.mu.RLock(); defer s.mu.RUnlock(); return s.ready }

type UserPlugin struct{}
func (UserPlugin) Name() string { return "users" }
func (UserPlugin) Register(mux *http.ServeMux) {
	mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "plugin=users id=%s\n", r.PathValue("id"))
	})
}

type MetricsPlugin struct{}
func (MetricsPlugin) Name() string { return "metrics" }
func (MetricsPlugin) Register(mux *http.ServeMux) {
	mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "http_plugin_up 1")
	})
}

func chain(h http.Handler, ms ...Middleware) http.Handler {
	for i := len(ms)-1; i >= 0; i-- { h = ms[i](h) }
	return h
}

func recoverPanic(logger *log.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() { if value := recover(); value != nil { logger.Printf("panic=%v", value); http.Error(w, "internal error", 500) } }()
			next.ServeHTTP(w, r)
		})
	}
}

func accessLog(logger *log.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			next.ServeHTTP(w, r)
			logger.Printf("method=%s path=%s duration=%s", r.Method, r.URL.Path, time.Since(start))
		})
	}
}

func main() {
	logger := log.New(os.Stdout, "http-demo ", log.Ldate|log.Ltime|log.Lmicroseconds)
	state := &State{}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
	mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
		if !state.Ready() { http.Error(w, "not ready", http.StatusServiceUnavailable); return }
		fmt.Fprintln(w, "ready")
	})
	for _, plugin := range []Plugin{UserPlugin{}, MetricsPlugin{}} { logger.Printf("register plugin=%s", plugin.Name()); plugin.Register(mux) }

	handler := chain(mux, recoverPanic(logger), accessLog(logger))
	server := &http.Server{Addr: ":8080", Handler: handler, ReadHeaderTimeout: 5*time.Second, ReadTimeout: 15*time.Second, WriteTimeout: 15*time.Second, IdleTimeout: 60*time.Second}
	state.SetReady(true)

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	serverErr := make(chan error, 1)
	go func() { serverErr <- server.ListenAndServe() }()
	select {
	case err := <-serverErr:
		if !errors.Is(err, http.ErrServerClosed) { logger.Fatal(err) }
	case <-ctx.Done():
		state.SetReady(false)
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		if err := server.Shutdown(shutdownCtx); err != nil { logger.Printf("shutdown failed: %v", err) }
	}
}

测试插件路由

不需要真实端口:

复制代码
func TestUserPlugin(t *testing.T) {
	mux := http.NewServeMux()
	(UserPlugin{}).Register(mux)

	req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
	recorder := httptest.NewRecorder()
	mux.ServeHTTP(recorder, req)

	if recorder.Code != http.StatusOK { t.Fatalf("status=%d", recorder.Code) }
	if got := recorder.Body.String(); got != "plugin=users id=42\n" { t.Fatalf("body=%q", got) }
}

httptest.NewRecorder 适合断言状态码、响应头和 body;httptest.NewServer 适合测试完整的 client/server 交互。

高可用项目的边界

  • Server 的 timeout 是第一层防护,业务 context 是第二层防护;
  • Shutdown 只负责服务生命周期,不负责等待你自己启动的后台 goroutine;
  • readiness 变为 false 后,仍然需要给流量系统留出排空时间;
  • 复用 http.Client 和 Transport,不要每次请求创建一个 client;
  • 请求 body、response body 都要关闭,并限制可读大小;
  • 中间件包装 ResponseWriter 时要注意可选接口;
  • 插件只注册能力,不掌管端口和进程生命周期;
  • 测试 handler 时优先使用 httptest,避免依赖真实网络。

官方资料

相关推荐
用户1202687863861 小时前
过审了,国区搜不到
ios
用户38034165882972 小时前
八个私有 SPM 包撑起六款 App:分支当版本、钻石依赖,和一次谁都没报错的失效
ios·swift
老李IT笔记2 小时前
远程取设备日志:iOS、tvOS 和 macOS 三端的同意方式不一样
macos·ios
茶底世界之下3 小时前
食物照片有点偏绿?用 Awaking 把色调往回调一点
ios
茉莉玫瑰花茶3 小时前
GO [ context 原理解析 ]
数据库·oracle·iphone
2601_966563523 小时前
分享3个 OpenWrt手机管理客户端 支持安卓+ios 中文开源免费无广告
ios·智能手机
普通人63 小时前
iPhone Duo 介绍
ios·iphone
2501_915921434 小时前
iOS应用从开发到上架App Store的完整步骤详解
android·ios·小程序·https·uni-app·iphone·webview