Go 网络编程:从 TCP 到生产级 HTTP 服务
面向有 Java 经验的开发者,基于 Go 1.26。
Go 的网络库分层很清楚:net.Conn 表示双向字节流,net.Listener 接受连接,net/http 在它们之上实现 HTTP 协议,crypto/tls 再提供安全传输。goroutine 让“一连接一 goroutine”或“一请求一 goroutine”写起来很自然,但并发代码容易写,并不意味着网络程序天然可靠。到了生产环境,真正难处理的是超时、取消、半关闭、连接复用、响应体释放、限流、优雅停机和不可信输入。
下面从地址和 socket 模型讲起,一步步写到具备超时、结构化错误、中间件和优雅停机的 HTTP 服务。示例尽量只使用标准库,以便看清协议边界和资源所有权。
目录
1. 先建立网络分层
可以把常用 API 看成:
应用协议 HTTP、RPC、自定义协议
协议实现 net/http
安全传输 crypto/tls
双向连接 net.Conn
监听与接入 net.Listener
地址与名称解析 net/netip、net、net/url
操作系统 socket、TCP/IP、UDPnet.Conn 的核心接口很小:
type Conn interface {
Read(b []byte) (n int, err error)
Write(b []byte) (n int, err error)
Close() error
LocalAddr() Addr
RemoteAddr() Addr
SetDeadline(t time.Time) error
SetReadDeadline(t time.Time) error
SetWriteDeadline(t time.Time) error
}它同时实现 io.Reader 和 io.Writer,所以 bufio、io.Copy、encoding/json 等工具可以直接工作在网络连接上。
网络 I/O 的基本事实:
- TCP 是字节流,没有消息边界;
- 一次
Write不保证对端一次Read收到; Read返回数据和错误可以同时发生,先处理n > 0的数据;- goroutine 被阻塞在 I/O 上不会自动响应普通 channel;
- 连接、response body、listener 都需要明确关闭;
- 超时是每个外部调用的必要契约。
2. 地址、IP 与 DNS
2.1 netip.Addr
新代码处理 IP 值优先考虑 net/netip:
addr, err := netip.ParseAddr("2001:db8::1")
if err != nil {
return err
}
fmt.Println(addr.Is6())
fmt.Println(addr.IsPrivate())
fmt.Println(addr.Unmap())netip.Addr 是紧凑、不可变且可比较的值,可以直接作为 map key:
counts := map[netip.Addr]int{}带端口:
ap, err := netip.ParseAddrPort("[2001:db8::1]:443")
fmt.Println(ap.Addr(), ap.Port())拼接主机和端口不要手写冒号:
endpoint := net.JoinHostPort(host, port)IPv6 地址本身含冒号,必须用方括号包裹。JoinHostPort 会正确处理。
2.2 前缀
prefix := netip.MustParsePrefix("10.0.0.0/8")
fmt.Println(prefix.Contains(netip.MustParseAddr("10.1.2.3")))Go 1.26 增加 netip.Prefix.Compare,便于对前缀建立稳定顺序。
2.3 DNS
resolver := net.DefaultResolver
addrs, err := resolver.LookupNetIP(ctx, "ip", "example.com")DNS 结果可能有多个 IPv4/IPv6 地址,顺序和生命周期都不是永久契约。不要把启动时解析出的一个 IP 永久缓存。
net.Dialer 会负责名称解析和地址尝试,普通客户端通常不需要先手工 Lookup:
dialer := net.Dialer{Timeout: 3 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", "example.com:443")3. net.Conn 与超时
3.1 Deadline 是绝对时间
conn.SetReadDeadline(time.Now().Add(5 * time.Second))deadline 到期后,未来操作仍然会超时,直到设置新的时间或用零值清除:
conn.SetReadDeadline(time.Time{})它不是“每次读取自动获得 5 秒”。长连接协议通常在每次成功读写或每个消息周期刷新 deadline。
3.2 识别超时
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
// 超时
}
}更上层的 HTTP/context API 常返回 context.DeadlineExceeded,应使用 errors.Is。
3.3 context 不能直接中断任意 Read
DialContext 能在拨号阶段响应 context。连接建立后,普通 conn.Read 不会因为某个 context 被取消就自动返回。常见做法:
- 取消时关闭连接;
- 根据 context deadline 设置连接 deadline;
- 使用本身支持 context 的更高层 API。
不要额外启动一个 goroutine 调用 Read,主流程超时后直接丢下它;这会泄漏 goroutine 和连接。
4. TCP 服务端
一个最小但有资源边界的 echo server:
func serveTCP(ctx context.Context, addr string) error {
var lc net.ListenConfig
ln, err := lc.Listen(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
defer ln.Close()
go func() {
<-ctx.Done()
_ = ln.Close() // 让 Accept 返回
}()
var wg sync.WaitGroup
defer wg.Wait()
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return nil
}
var ne net.Error
if errors.As(err, &ne) && ne.Temporary() {
continue
}
return fmt.Errorf("accept: %w", err)
}
wg.Go(func() {
defer conn.Close()
handleConn(ctx, conn)
})
}
}处理连接:
func handleConn(ctx context.Context, conn net.Conn) {
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
for {
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
} else {
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
}
line, err := reader.ReadString('\n')
if len(line) > 0 {
if _, werr := writer.WriteString("echo: " + line); werr != nil {
return
}
if werr := writer.Flush(); werr != nil {
return
}
}
if err != nil {
return
}
}
}生产服务还需要:
- 最大连接数;
- 每连接最大并发或请求数;
- 消息长度上限;
- idle timeout;
- panic 边界;
- 连接级日志和指标;
- 停机时等待现有连接的上限;
- 防止慢客户端长期占用资源。
“每连接一个 goroutine”通常没有问题,问题是连接数无上限以及每个连接分配了无上限缓冲。
5. TCP 客户端与消息边界
连接:
dialer := net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}
conn, err := dialer.DialContext(ctx, "tcp", "127.0.0.1:9000")TCP 不保留 Write 边界:
conn.Write([]byte("hello"))
conn.Write([]byte("world"))对端可能一次读到 helloworld,也可能拆成多次。因此应用协议必须自行定义 framing:
- 固定长度;
- 分隔符,如每行一条;
- 长度前缀;
- 自描述格式;
- 关闭写端表示消息结束。
长度前缀示例:
func writeFrame(w io.Writer, payload []byte) error {
if len(payload) > 1<<20 {
return errors.New("frame too large")
}
if err := binary.Write(w, binary.BigEndian, uint32(len(payload))); err != nil {
return err
}
_, err := w.Write(payload)
return err
}
func readFrame(r io.Reader) ([]byte, error) {
var size uint32
if err := binary.Read(r, binary.BigEndian, &size); err != nil {
return nil, err
}
if size > 1<<20 {
return nil, errors.New("frame too large")
}
buf := make([]byte, size)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
return buf, nil
}必须先验证长度再分配,否则攻击者可以发送巨大长度导致内存耗尽。
6. 半关闭、保活与异常断开
TCP 允许只关闭一个方向:
tcpConn, ok := conn.(*net.TCPConn)
if ok {
_ = tcpConn.CloseWrite()
}这可以表示“请求已发送完,但我还要读取响应”。CloseRead 则关闭读取方向。是否使用取决于应用协议。
KeepAlive 用于探测长时间无流量的失效连接,不是应用层健康检查,也不能替代请求超时。NAT、防火墙和负载均衡器还有自己的 idle timeout,长连接协议通常需要应用心跳。
对端异常退出时,本端可能直到下一次读写才发现。一次成功 Write 也不表示应用已经处理数据,只说明数据被本机网络栈接受。需要业务确认时必须在协议层增加 ACK 或请求响应。
7. UDP
UDP 是数据报协议,保留消息边界,但不保证:
- 到达;
- 顺序;
- 不重复;
- 不被截断;
- 对端确实存在。
服务端:
pc, err := net.ListenPacket("udp", ":9001")
if err != nil {
return err
}
defer pc.Close()
buf := make([]byte, 64<<10)
for {
n, addr, err := pc.ReadFrom(buf)
if err != nil {
return err
}
payload := append([]byte(nil), buf[:n]...) // 异步处理前复制
go handleDatagram(pc, addr, payload)
}不要把复用的 buf[:n] 直接交给异步 goroutine,否则下一次读取会覆盖它。
UDP 适合 DNS、遥测、实时媒体或应用自己实现可靠性的协议。普通业务 RPC 不要因为“UDP 快”就跳过丢包、拥塞控制、MTU、放大攻击和顺序处理。
8. HTTP handler 模型
核心接口:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}函数适配:
func health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
io.WriteString(w, `{"status":"ok"}`)
}
handler := http.HandlerFunc(health)每个请求通常在独立 goroutine 中处理,因此 handler 共享的对象必须满足并发安全。请求对象只属于当前请求;不要在 handler 返回后继续读它,也不要把 ResponseWriter 交给后台 goroutine。
依赖通过 struct 注入:
type API struct {
users UserService
logger *slog.Logger
}
func (a *API) getUser(w http.ResponseWriter, r *http.Request) {
// ...
}避免 package 级数据库、logger 和可变配置。显式依赖更容易测试和优雅关闭。
9. 路由、参数与 Go 1.22+ ServeMux
现代 http.ServeMux 支持方法和通配符:
mux := http.NewServeMux()
mux.HandleFunc("GET /health", health)
mux.HandleFunc("GET /users/{id}", api.getUser)
mux.HandleFunc("POST /users", api.createUser)读取路径参数:
idText := r.PathValue("id")
id, err := strconv.ParseInt(idText, 10, 64)通配符:
mux.HandleFunc("GET /files/{path...}", serveFile)主机也可进入 pattern:
mux.HandleFunc("GET api.example.com/health", health)路由冲突在注册时可能 panic,应在启动阶段尽早暴露。对外 API 仍要明确:
- 允许的方法;
- 末尾斜杠和重定向行为;
- URL 解码后的参数语义;
- 404 与 405 格式;
- API 版本策略。
10. 读取请求与写响应
10.1 限制请求体
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var input CreateUserRequest
if err := dec.Decode(&input); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
writeProblem(w, http.StatusBadRequest, "request must contain one JSON value")
return
}限制必须在读取前设置。只在解码后检查 len,巨大 body 已经消耗了资源。
10.2 响应头和状态码
WriteHeader 之后再修改 header 已经太晚:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)第一次 Write 若此前未调用 WriteHeader,会隐式发送 200。
业务代码不要在已经写了一半响应后才返回结构化错误。能失败的校验和查询尽量在写 header 前完成。流式响应必须接受“已经无法更换状态码”的协议事实。
10.3 JSON 错误
统一错误结构:
type Problem struct {
Code string `json:"code"`
Message string `json:"message"`
}客户端错误和内部错误分开:日志可以记录底层错误、request ID 和堆栈,但响应不要泄漏 SQL、文件路径、token 或内部主机名。
11. 中间件
中间件是 Handler -> Handler:
type Middleware func(http.Handler) http.Handler
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 = newRequestID()
}
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}组合顺序很重要:
handler := recoverPanic(
requestID(
accessLog(
mux,
),
),
)外层先进入、最后退出。通常 request ID 要早于日志和 panic 恢复建立。
panic 恢复:
func recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if p := recover(); p != nil {
slog.ErrorContext(r.Context(), "panic",
"value", p,
"stack", string(debug.Stack()),
)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}如果响应已部分写出,恢复中间件无法撤回内容。可以包装 ResponseWriter 缓冲小响应,但会影响 streaming、flush 和额外接口,不能无条件使用。
自定义 wrapper 要考虑 http.Flusher、http.Hijacker、io.ReaderFrom 等可选能力。现代代码也可使用 http.NewResponseController 访问 flush、deadline 等功能,避免手写大量类型断言。
12. HTTP Server 的生产配置
不要直接把 http.ListenAndServe(":8080", mux) 当作完整生产配置:
server := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}各超时的含义并不完全相同:
ReadHeaderTimeout:读取请求头的上限,防 slowloris;ReadTimeout:读取完整请求的上限,上传接口要谨慎;WriteTimeout:写响应的上限,流式接口不能照搬普通值;IdleTimeout:keep-alive 等待下一个请求的时间。
网络基础设施也应设置匹配的 upstream/downstream timeout。应用 30 秒、反向代理 10 秒会让应用继续做最终无人接收的工作。
Server 还支持:
BaseContext:为连接派生根 context;ConnContext:把连接信息放进 context;ConnState:观察连接状态,适合指标,不要做重业务;ErrorLog:协议层错误日志。
13. 优雅停机
停机目标:
- 收到信号后停止接受新请求;
- 让就绪探针先失败,给负载均衡器摘流量时间;
- 等待在途请求完成;
- 超时后强制结束;
- 最后关闭数据库、消息客户端等共享依赖。
func run() error {
mux := http.NewServeMux()
// register routes
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
ctx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
errCh := make(chan error, 1)
go func() {
err := server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
return
}
errCh <- nil
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
_ = server.Close()
return fmt.Errorf("shutdown: %w", err)
}
return <-errCh
}Go 1.26 中 signal.NotifyContext 使用带 cause 的取消,并让错误指明触发的信号。需要时可以通过 context.Cause(ctx) 记录。
Shutdown 不会替你等待自行启动的后台 goroutine,也不会自动处理被 hijack 的连接。后台 worker、WebSocket 等长连接需要应用自己的生命周期管理。
14. HTTP Client 与连接复用
14.1 复用 Client
http.Client 和 http.Transport 可并发使用,应长期复用:
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
MaxConnsPerHost: 50,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
client := &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
}每次请求新建 client/transport 会丢失连接池,带来额外 DNS、TCP、TLS 成本,严重时耗尽临时端口。
14.2 请求 context
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
res, err := client.Do(req)14.3 关闭响应体
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()为了让 HTTP/1.1 连接更可能复用,应把响应体读到 EOF;但不能无上限丢弃未知巨大 body:
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))如果只需放弃一个已知很小的错误体,可以在限制内读取。超出限制时接受该连接不能复用。
14.4 状态码不是 transport error
client.Do 对 404、500 通常返回 err == nil。必须自行检查:
if res.StatusCode < 200 || res.StatusCode >= 300 {
return decodeRemoteError(res)
}15. context、超时与重试
超时可以分层:
- 拨号超时;
- TLS 握手超时;
- 响应头超时;
- 整个请求超时;
- 上层业务 deadline。
最外层 context deadline 应沿调用链传递,不要在每层无条件重新给更长超时。
15.1 重试条件
只有满足这些条件时才考虑自动重试:
- 操作幂等,或带可靠幂等键;
- 错误是暂时性的;
- 请求体可以重放;
- 仍有总时间预算;
- 有最大次数;
- 使用指数退避和 jitter;
- 尊重
Retry-After; - 不把过载服务打得更重。
GET 通常幂等,但“方法名是 GET”不保证服务实现无副作用。POST 也可以通过业务幂等键安全重试。
请求体重放可用 Request.GetBody;http.NewRequest 对部分内存 reader 会自动设置。流式 body 通常不能重试。
不要同时让 SDK、业务层、服务网格和负载均衡器各重试三次;组合后请求量会指数放大。
15.2 取消后清理
收到 ctx.Done() 后应尽快:
- 停止新工作;
- 让阻塞 API 使用 context;
- 回收 semaphore;
- 停止 timer;
- 关闭当前操作独占的连接;
- 返回
ctx.Err()或带上下文的包装错误。
不要把 context 存在长期 struct 字段中;通常作为函数第一个参数传递。
16. TLS 与证书
服务端可以:
server.ListenAndServeTLS("cert.pem", "key.pem")更复杂场景使用 tls.Config:
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
server.TLSConfig = tlsConfigGo 1.26 默认启用新的混合后量子密钥交换组合。不要为了“兼容”随意手工固定过时 cipher suite 或曲线;优先采用安全默认值,只在明确互操作问题下调整。
客户端:
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: "api.example.com",
}不要在生产代码设置:
InsecureSkipVerify: true这会关闭主机名和证书链验证,允许中间人攻击。私有 CA 应加入独立 CertPool。mTLS 则同时配置客户端证书和服务端对客户端证书的校验。
证书热更新可以通过 GetCertificate 或外部终止 TLS。更新过程要保证并发安全,并保留最后一份有效证书,避免短暂读取失败把服务打挂。
17. 流式传输、SSE 与双向协议
17.1 流式 HTTP
func stream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
rc := http.NewResponseController(w)
for item := range items(r.Context()) {
if err := json.NewEncoder(w).Encode(item); err != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
}
}流式响应需要:
- 定期 flush;
- 监听请求 context;
- 限制每客户端缓冲;
- 处理慢消费者;
- 与反向代理缓冲配置协调;
- 使用适合的
WriteTimeout策略。
17.2 SSE
SSE 响应类型为 text/event-stream,每条事件用空行结束:
event: update
data: {"id":1}数据中每行都要按 data: 规则编码。定期发送注释心跳可避免中间设备因 idle 关闭连接。
17.3 WebSocket 与 HTTP/2
标准库提供 HTTP/2 支持,但没有面向应用的完整 WebSocket API。需要 WebSocket 时选维护良好的库,并重点处理:
- 单写者或写锁;
- ping/pong 和 read deadline;
- 消息大小;
- 压缩炸弹;
- 关闭握手;
- 停机时连接登记和排空。
不要把 WebSocket 连接从生命周期管理中“放飞”。
18. 代理、反向代理与真实客户端地址
httputil.ReverseProxy 提供基础反向代理能力。现代 API 可通过 rewrite 明确设置目标:
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(target)
pr.SetXForwarded()
},
}生产代理还要设置:
- transport timeout 和连接池;
- 错误处理;
- flush 策略;
- header 白名单;
- WebSocket/upgrade;
- body 上限;
- 后端选择和健康检查;
- 指标与追踪。
客户端地址
r.RemoteAddr 通常是直接 TCP 对端。经过可信反向代理时,真实地址可能在 Forwarded 或 X-Forwarded-For。
绝不能无条件相信客户端自己发来的转发头。正确策略:
- 判断直接对端是否在可信代理网段;
- 只有可信时解析转发链;
- 从约定方向移除可信代理;
- 对格式和数量设置限制;
- IP 只作为风控信号,不当作强身份。
19. 性能、背压与限流
19.1 先看指标和 profile
网络慢可能来自:
- DNS;
- 建连/TLS;
- 连接池等待;
- 上游响应;
- 本地排队;
- 序列化;
- 锁竞争;
- 慢客户端;
- GC 和分配。
先拆分延迟,再优化。盲目把 goroutine 数量加大经常只会把瓶颈推到数据库。
19.2 限制并发
简单 semaphore:
slots := make(chan struct{}, 100)
func limited(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case slots <- struct{}{}:
defer func() { <-slots }()
next.ServeHTTP(w, r)
case <-r.Context().Done():
http.Error(w, "request canceled", 499)
default:
http.Error(w, "busy", http.StatusServiceUnavailable)
}
})
}HTTP 没有标准 499,公开 API 通常不应随意使用非标准码;上例只是说明取消分支,实际可直接返回不写响应,或采用团队约定。
限流可以按全局、租户、用户、IP 或成本权重进行。队列必须有长度和等待上限。无界队列只是把过载从失败变成内存增长和长尾延迟。
19.3 缓冲池
sync.Pool 可减少高频临时 buffer 分配,但要谨慎:
- 归还前重置;
- 不在归还后继续引用;
- 限制大 buffer 回池;
- 不把敏感内容泄漏给下一个使用者;
- 先用 profile 证明确有价值。
19.4 零拷贝和 io.Copy
文件到连接可使用:
_, err := io.Copy(w, file)标准库会在可能时使用更高效路径。手写 4 KB 循环未必更快。仍需限制长度和处理 context/timeout。
20. 可观测性与诊断
每个请求建议记录:
- request ID / trace ID;
- 方法、路由模板、状态码;
- 延迟和响应字节;
- 经过脱敏的租户或用户标识;
- 错误类别;
- 上游目标类别和耗时;
- 不记录 token、完整 cookie、密码和大 body。
指标常见维度:
- 请求数、错误数、延迟分布;
- 在途请求;
- 连接状态;
- 上游重试与超时;
- DNS/建连/TLS/TTFB 分段;
- 请求/响应大小;
- 限流和队列等待。
诊断工具:
import _ "net/http/pprof"不要把 pprof 无认证暴露到公网。可以单独绑定管理地址,并通过网络策略或认证保护。
客户端追踪可使用 net/http/httptrace 观察 DNS、连接获取、TLS 和首字节时间。它适合诊断分段延迟,不应在每个请求上无采样地记录海量事件。
21. 安全清单
输入与资源
- 限制 header、body、表单、multipart、消息和解压后大小;
- 设置读取、写入、idle 和总 deadline;
- 限制连接数、并发和队列;
- JSON 是否拒绝未知字段由协议明确决定;
- 上传文件不信任文件名和 Content-Type。
URL 与外连
- 防 SSRF:限制 scheme、host、端口和解析后的 IP;
- 重定向后也要重新验证;
- 防 DNS rebinding:连接目标与校验策略一致;
- Go 1.26 的
net/url.Parse默认拒绝 host 子组件中未加括号的异常冒号,不要依赖旧的宽松解析。
HTTP
- 防响应拆分和 header 注入;
- cookie 设置
Secure、HttpOnly、适当的SameSite; - CORS 精确列出 origin,不把凭据和
*混用; - 状态改变操作处理 CSRF;
- 不信任任意代理头;
- 错误响应不泄漏内部信息。
TLS
- 使用受支持 Go 补丁版本;
- 不关闭证书验证;
- 私钥权限最小化并支持轮换;
- mTLS 身份还要映射到应用授权;
- 不自行实现密码协议。
22. 与 Java 网络栈的差异
| Java 常见概念 | Go 对应 |
|---|---|
Socket | net.Conn / net.TCPConn |
ServerSocket | net.Listener |
| NIO Selector | 多数业务直接用 goroutine + runtime netpoll;动态 channel select 不是 socket selector |
| Servlet | http.Handler |
| Filter | func(http.Handler) http.Handler 中间件 |
HttpClient | http.Client |
| Executor/线程池 | goroutine 很轻,但仍需并发上限 |
| try-with-resources | defer Close(),同时关注循环内 defer 范围 |
Go runtime 在底层使用网络轮询器,阻塞式 API 不等于每个连接永久占一个操作系统线程。不要照搬 Java NIO 的复杂状态机,除非经过 profile 确认普通模型不满足需求。
23. 常见故障排查
connection refused
目标主机可达但端口没有 listener,或防火墙主动拒绝。检查监听地址、容器端口映射和 IPv4/IPv6。
i/o timeout / context deadline exceeded
区分 DNS、dial、TLS、response header、body 读取和上层 deadline。使用 httptrace 和分段指标。
connection reset by peer
对端或中间设备异常关闭。检查 idle timeout、请求大小、协议不匹配和服务端日志。
大量 TIME_WAIT
通常意味着没有复用 HTTP transport、连接被频繁短开,或服务主动关闭很多连接。不要通过危险内核参数掩盖客户端生命周期错误。
goroutine 持续增长
检查:
- 没有 deadline 的
Read; - 未关闭 response body;
- channel 发送者无人接收;
- 重试 goroutine 没有 context;
- hijack/长连接没有登记;
- 无界后台任务。
Go 1.26 提供实验性的 goroutine leak profile,可通过 GOEXPERIMENT=goroutineleakprofile 尝试;它不是所有泄漏的完美检测器。
too many open files
检查文件描述符上限之外,更要找:
- response body/conn 未关闭;
- listener 泄漏;
- 每次请求新建 transport;
- 重试未回收旧连接;
- 长连接数无上限。
24. 速查表
| 目标 | API |
|---|---|
| TCP 监听 | net.Listen("tcp", addr) |
| 带 context 监听 | net.ListenConfig.Listen |
| 带 context 拨号 | net.Dialer.DialContext |
| 设置读超时 | conn.SetReadDeadline |
| 精确读取 N 字节 | io.ReadFull |
| IP 值 | netip.Addr |
| 主机端口拼接 | net.JoinHostPort |
| HTTP 路由 | http.ServeMux |
| 路径参数 | r.PathValue |
| 限制 body | http.MaxBytesReader / io.LimitReader |
| 测试服务 | httptest.NewServer |
| 优雅关闭 | server.Shutdown(ctx) |
| 信号 context | signal.NotifyContext |
| HTTP 客户端 | 长期复用 http.Client |
| 请求取消 | http.NewRequestWithContext |
| TLS | crypto/tls |
| 反向代理 | httputil.ReverseProxy |
| 分段追踪 | net/http/httptrace |
| 网络 profile | net/http/pprof |
可运行示例
网络示例很容易被固定端口、外部域名或缺失的截止时间拖成不稳定测试。这里统一使用回环地址和随机端口,并为所有可能阻塞的操作设置时间边界。
示例一:本地 JSON 服务与有超时的客户端
第一个程序只用标准库实现一条返回 JSON 的路由,再通过真实 HTTP 客户端调用它。整个过程发生在本机,不依赖公网服务。
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"time"
)
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
}
func newHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "42" {
http.Error(w, "item not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
// Encoder 直接写入响应流,避免先构造一份额外的 []byte。
if err := json.NewEncoder(w).Encode(Item{ID: 42, Name: "Go 手册"}); err != nil {
// 响应可能已部分写出,此时不能可靠改写状态码;真实服务应记录该错误。
return
}
})
return mux
}
func main() {
// httptest.Server 使用本机随机端口,示例不依赖公网,也不会占用固定端口。
server := httptest.NewServer(newHandler())
defer server.Close()
// 生产客户端必须有超时。默认 http.Client 没有整体截止时间,
// 当对端不返回时,调用可能无限等待并耗尽 goroutine。
client := &http.Client{Timeout: time.Second}
resp, err := client.Get(server.URL + "/items/42")
if err != nil {
panic(err)
}
defer resp.Body.Close()
var item Item
if err := json.NewDecoder(resp.Body).Decode(&item); err != nil {
panic(err)
}
fmt.Printf("状态=%d\n", resp.StatusCode)
fmt.Printf("商品=%+v\n", item)
}运行:
go run ./examples/ch19/json-service预期输出:
状态=200
商品={ID:42 Name:Go 手册}拆解:
httptest.NewServer启动真实本地 HTTP 服务并分配随机端口,适合示例和集成测试。- Go 1.22 之后的 ServeMux 模式可以同时约束方法和路径;
PathValue读取路径参数。 - 服务端用
json.Encoder流式写响应,客户端用json.Decoder按结构解码,双方通过 JSON 字段而非 Go 字段名耦合。 - 客户端设置整体超时;真正的服务还可通过自定义
Transport分别约束拨号、TLS 握手、响应头和连接池。
修改实验:请求不存在的 ID 并检查 404;再添加 DisallowUnknownFields 的 POST 解码路径,观察未知 JSON 字段如何被拒绝。
示例二:用 Context 截止时间中止慢请求
第二个程序故意制造超时:上游在 100 毫秒后才响应,而当前调用只有 20 毫秒预算。客户端必须及时取消,不能一直占用 goroutine。
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"time"
)
func main() {
slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 模拟处理速度超过客户端预算的上游服务。
time.Sleep(100 * time.Millisecond)
_, _ = w.Write([]byte("too late"))
}))
defer slowServer.Close()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, slowServer.URL, nil)
if err != nil {
panic(err)
}
_, err = slowServer.Client().Do(req)
// 不比较错误字符串:其中常含动态 URL,而且不同 Go 版本的包装文本可能变化。
// errors.Is 会沿错误链识别稳定的语义原因。
fmt.Println("请求已超时:", errors.Is(err, context.DeadlineExceeded))
}运行:
go run ./examples/ch19/client-timeout预期输出:
请求已超时: true拆解:
NewRequestWithContext把取消信号传入 HTTP 栈;截止时间到达后,等待连接或响应的操作都会被中止。- 用
errors.Is(err, context.DeadlineExceeded)判断语义,不比较包含动态 URL 的错误字符串。 defer cancel()即使请求提前完成也会释放计时器资源。- 超时只限制等待时间,不会自动让上游处理函数停止。真实服务端也必须监听
r.Context().Done(),避免客户端离开后继续做昂贵工作。
修改实验:把服务端处理改为 select 监听 r.Context().Done() 并记录取消;再比较 Client.Timeout 与单请求 context 哪个错误更适合携带调用链预算。
示例三:带分帧和截止时间的 TCP Echo
最后回到 TCP。它只提供字节流,消息边界需要应用自己定义;同时还要保证 Accept、Read 或 Write 不会永久卡住。
package main
import (
"bufio"
"fmt"
"io"
"net"
"time"
)
func main() {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
defer listener.Close()
serverDone := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
serverDone <- err
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(time.Second))
// ReadString 说明协议用换行符分帧;TCP 本身只是字节流,
// 一次 Write 与一次 Read 并没有天然的一一对应关系。
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
serverDone <- err
return
}
_, err = io.WriteString(conn, "echo:"+line)
serverDone <- err
}()
conn, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
if err != nil {
panic(err)
}
_ = conn.SetDeadline(time.Now().Add(time.Second))
if _, err := io.WriteString(conn, "hello\n"); err != nil {
panic(err)
}
reply, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
panic(err)
}
_ = conn.Close()
if err := <-serverDone; err != nil {
panic(err)
}
fmt.Printf("响应=%q\n", reply)
}运行:
go run ./examples/ch19/tcp-echo预期输出:
响应="echo:hello\n"拆解:
- 监听
127.0.0.1:0表示仅绑定回环地址并让系统选择空闲端口,避免端口冲突和外部访问。 - 协议以换行符分帧。一次
Write的内容可能被多次Read取回,不能把 TCP 当消息队列。 - 客户端用
DialTimeout控制连接建立,连接双方再用SetDeadline约束后续读写。 - 容量为 1 的
serverDone让服务 goroutine 即使主 goroutine 尚未接收,也能报告一个结果并退出,避免泄漏。
修改实验:连续发送两行并在服务端循环读取;随后设置最大行长,防止攻击者一直发送不含换行的内容耗尽内存。
25. 练习
- 实现长度前缀 TCP 协议,限制 1 MiB,支持 context 取消、idle timeout 和优雅停机。
- 写一个 HTTP JSON API,使用标准库路由、统一 Problem 错误、1 MiB body 上限和 request ID 中间件。
- 为 HTTP client 增加总超时、状态码错误、限量响应体和只针对幂等请求的指数退避。
- 使用
httptest.Server验证 client 在 500、429、慢响应、断开连接和非法 JSON 时的行为。 - 写 SSE 服务并实现每客户端有界队列;慢客户端不能阻塞生产者。
- 实现可信代理地址解析,并为伪造
X-Forwarded-For、IPv6 和多级代理链写测试。 - 使用
httptrace输出一次请求的 DNS、connect、TLS 和 TTFB 时间。