Skip to content

Go

本页从服务开发视角讲 Go:语言本身的语法与并发原语(切片/map、channel、worker pool、fan-in/out)见 语言模块的 Go 页,本页聚焦"用 goroutine 写出来的高并发 HTTP 服务"——每连接一个 goroutine 的并发模型、中间件链、context 贯穿请求实现取消与超时、错误处理最佳实践,以及 pprof 剖析与调优。全部实测基于 go1.26.5 / macOS(12 逻辑核),框架为 Gin v1.12.0。

一、net/http:内置的高并发服务

每连接一个 goroutine

Go 的 HTTP 服务器模型极简:每个连接由一个 goroutine 处理。goroutine 初始栈仅几 KB、可动态伸缩,所以同时挂几万个"等待中的连接"也毫无压力——这与 Node 的事件循环(见 Node.js / NestJS)是两种殊途同归的路线,Go 用"海量轻量线程 + 阻塞式写法",Node 用"单线程 + 事件回调"。用 Go 写服务时不需要也不应该自己搞线程池/连接池,标准库已经替你做了。

一个最小服务只需要几十行:

go
// 运行环境:go ≥ 1.22(实测 go1.26.5)
package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		name := r.URL.Query().Get("name")
		if name == "" {
			name = "world"
		}
		fmt.Fprintf(w, `{"greet":"hello %s"}`, name)
	})
	log.Println("listen :8080")
	log.Fatal(http.ListenAndServe(":8080", nil)) // nil → 使用 http.DefaultServeMux
}
bash
$ curl "http://127.0.0.1:8080/hello?name=Go世界"
{"greet":"hello Go世界"}

http.HandleFunc + nil Handler 用的是 DefaultServeMux;想更显式可以 mux := http.NewServeMux() 再传给 ListenAndServe。Go 1.22+ 的 ServeMux 还支持"方法 + 路径"模式:mux.HandleFunc("GET /users/{id}", …)r.PathValue("id"),路由能力已接近框架基础款。

实测:300 个并发连接 = 300 个 goroutine

让 300 个慢请求(每个 sleep 1.2s)同时打进来,服务内用原子计数器统计当前并发处理的 goroutine 数,/stats 实时查看:

text
$ # 300 个 curl 并发请求 /slow?ms=1200,0.7s 后查 stats
current=192 peak=300 total=2304     ← peak 打到 300:每个连接一个 goroutine,互不阻塞

结论:并发量只受内存与文件描述符限制,不受"线程池大小"约束。相比线程池模型要精确估算池大小,Go 默认就能吃满 CPU 扛住海量连接。

二、中间件:横切逻辑的组装方式

中间件本质是 func(http.Handler) http.Handler 的包装:外层拿到请求做前置处理,调用内层,回来再做后置处理。标准库没有框架的中间件语法,用闭包包一层即可:

go
// 运行环境:go ≥ 1.22
func wrap(h http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// 前置:统计当前并发 goroutine
		cur := atomic.AddInt64(&active, 1)
		if cur > atomic.LoadInt64(&peak) {
			atomic.StoreInt64(&peak, cur)
		}
		defer atomic.AddInt64(&active, -1) // 后置:请求结束释放
		h(w, r)                            // 调用内层真正的 handler
	}
}

常见中间件:请求日志、耗时、鉴权(从 Header/Cookie 取凭据)、限流、panic 恢复(defer recover + 返回 500)、链路追踪 ID。多个中间件叠加时执行顺序是洋葱模型A(B(C(handler))),请求先进 A、再 B、再 C,响应逆序返回。

三、context:取消与超时贯穿整个请求

传递规则

context 的价值在于把请求级信息沿调用链传递:截止时间、取消信号、trace 值。规则很简单:

  • 每个请求进来就有一个 r.Context()一路往下传,不要用 context.Background()/context.TODO() 顶替(它们没有取消语义);
  • 需要超时用 context.WithTimeout(ctx, d),需要手动取消用 context.WithCancel(ctx)
  • 下游函数用 select { case <-ctx.Done(): … } 响应取消,提前释放资源返回。

实测:客户端断连 → 服务端提前取消

服务端 /slow?ms=8000 会睡 8s,但监听 r.Context().Done();客户端用 curl --max-time 1 只等 1s 就断开:

go
select {
case <-time.After(time.Duration(ms) * time.Millisecond):
	fmt.Fprintf(w, "done after %dms", ms)
case <-r.Context().Done(): // 客户端断开/超时 → 这里立刻触发
	log.Printf("context 取消: %v —— 提前释放,无需等完整 %dms", r.Context().Err(), ms)
}

实测服务端日志:

text
2026/09/02 19:58:33 context 取消: context canceled —— 提前释放,无需等完整 8000ms

线上价值:数据库/下游 RPC 全都接同一个 ctx 时,一个请求超时会像多米诺骨牌一样把取消信号传到所有子调用,不会出现"客户端早就走了、服务端还在傻傻跑 8s 重活"的资源浪费。

  • context.WithTimeout 返回的 cancel 函数必须 defer 调用,否则每次请求泄漏一个计时器;
  • ctx 取消后 ctx.Err()context.Canceledcontext.DeadlineExceeded,用 errors.Is 判断;
  • 别把 context 存进 struct 字段(官方明确反对),要作为函数第一个参数显式传递;
  • 只有调用链"需要取消/超时/传值"才必须传 ctx;纯本地计算函数可以不用,别为传而传。

四、错误处理最佳实践

Go 的错误是,函数返回 (T, error) 显式处理——写起来啰嗦,但错误路径和正常路径一样被测试覆盖到。

go
// 运行环境:go ≥ 1.22
// 1. 哨兵错误 + %w 包装:errors.Is 穿透多层包装判断
var ErrNotFound = errors.New("user not found")

func findUser(id int) (string, error) {
	if id != 1 {
		return "", fmt.Errorf("query user %d: %w", id, ErrNotFound) // 包装,保留原错误链
	}
	return "alice", nil
}

// 2. 类型错误用 errors.As 取出具体类型;errors.Join 聚合多个错误
// 3. 不吞错误:能处理就处理,处理不了就 return 给上层并加上下文
func handler() error {
	u, err := findUser(2)
	if err != nil {
		return err // 加了包名/函数名上下文更易排查
	}
	_ = u
	return nil
}

func main() {
	err := handler()
	if errors.Is(err, ErrNotFound) {
		fmt.Println("识别为 not found → 上层可映射 404")
	}
}

HTTP 层映射:把业务错误分类成"客户端错(4xx)/服务端错(5xx)",在入口处统一转状态码 + 结构化错误体,而不是每层都写 log.Fatal 或打印堆栈。panic 只留给"真不可恢复"(如配置缺失、端口被占),业务异常一律 error。

五、pprof:性能剖析与调优

接入

标准库自带剖析器,只要匿名导入 net/http/pprof(它会注册到 DefaultServeMux):

go
import _ "net/http/pprof" // 自动挂载 /debug/pprof/*

服务启动后自带这些端点:/debug/pprof/(总览)、profile?seconds=30(CPU)、heap(堆)、goroutineblockmutex。抓取与分析:

bash
# CPU:采样 4 秒,需要让服务处于负载下(否则采不到热点)
curl -s "http://localhost:8080/debug/pprof/profile?seconds=4" -o /tmp/cpu.prof
go tool pprof -top /tmp/cpu.prof

# 堆:看内存分配热点(-sample_index=alloc_space 看累计分配)
curl -s "http://localhost:8080/debug/pprof/heap" -o /tmp/heap.prof
go tool pprof -top -sample_index=alloc_space /tmp/heap.prof

实测:找出 CPU 热点

让 40 个 CPU 密集请求(大量 math.Sqrt 循环)同时跑,采样 4 秒:

text
File: server
Type: cpu
Duration: 4.01s, Total samples = 14.41s (359.65%)   ← 多核并行:4s 采到 14.4s 的样本
Dropped 23 nodes (cum <= 0.07s)
      flat  flat%   sum%        cum   cum%
    11.26s 78.14% 78.14%     14.34s 99.51%  main.busy (inline)
     2.05s 14.23% 92.37%      2.26s 15.68%  math.Sqrt (inline)
     1.03s  7.15% 99.51%      1.03s  7.15%  runtime.asyncPreempt

解读:359.65% 表示请求期间吃满了近 4 个核;热点一目了然——main.busy 自耗 78%(flat 列),其中大头是 math.Sqrt优化动作应指向 flat 最高的节点(它自己烧的 CPU),而不是 cum 高的包装函数。

实测:找出内存分配点

/alloc 发两次请求(每次常驻分配 2500 × 64KB),抓 heap:

text
Type: alloc_space
Showing nodes accounting for 99.09MB, 100% of 99.09MB total
      flat  flat%   sum%        cum   cum%
   82.98MB 83.74% 83.74%    82.98MB 83.74%  main.main.func4   ← 分配全部发生在 /alloc 的闭包里

常见调优项

现象排查入口常见修复
CPU 高CPU profile找 flat 热点;字符串拼接换 strings.Builder;正则预编译
内存持续涨heap(inuse)找常驻分配点;修泄漏的 goroutine(未退出 channel/定时器)
分配频繁heap(alloc_space)热路径对象复用、切片 make([]T, 0, n) 预分配容量
请求变慢但 CPU 低看 goroutine 转储 / 外部依赖多半阻塞在锁、DB、下游 RPC;查 block/mutex profile

六、Gin 与优雅退出:工程化收尾

标准库还是 Gin

维度标准库 net/httpGin
路由Go 1.22+ 方法+通配模式,够用更丰富(分组、通配、重定向规则)
中间件生态自己写(闭包包装)内置 Logger/Recovery + 社区中间件
参数绑定/校验手写解析c.ShouldBindJSON + binding tag
性能原生极接近原生(封装很薄)
适合轻服务、微服务内部 API需要路由组织/校验/中间件生态的常规服务

简单接口标准库足够;路由多、要 JSON 绑定与校验、要快速拼中间件时用 Gin。Gin 底层仍是 net/http,不是另一个网络实现。

go
// 运行环境:go ≥ 1.22,go get github.com/gin-gonic/gin(实测 v1.12.0)
package main

import (
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default() // 自带 Logger + Recovery 中间件

	// 自定义中间件:计时
	r.Use(func(c *gin.Context) {
		t0 := time.Now()
		c.Next()
		// log 请求耗时…
	})

	r.GET("/hello", func(c *gin.Context) {
		name := c.DefaultQuery("name", "world")
		c.JSON(http.StatusOK, gin.H{"greet": "hello " + name})
	})

	api := r.Group("/api") // 路由分组:/api/users/:id
	{
		api.GET("/users/:id", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"id": c.Param("id"), "name": "user-" + c.Param("id")})
		})
	}

	r.Run(":8081") // 默认 http.ListenAndServe
}

实测(Gin v1.12.0):

text
$ curl "http://127.0.0.1:8081/hello?name=Gin"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"greet":"hello Gin"}

$ curl http://127.0.0.1:8081/api/users/42
{"id":"42","name":"user-42"}

生产模式记得 GIN_MODE=release(debug 模式有额外开销并打印警告);用 gin.New() + 自行挂 gin.Logger()/gin.Recovery() 可以精确控制中间件,比 gin.Default() 更克制。

优雅退出:发布不断请求

容器发布/kill 服务时,要给存量请求留出完成时间。标准写法:监听信号 → server.Shutdown(ctx)(停止接新连接,等存量处理完再退出):

go
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server.Shutdown(ctx) // 最多等 5s,超时强制退出

实测 kill -TERM 的日志:

text
2026/09/02 19:59:20 收到退出信号,优雅关闭…
2026/09/02 19:59:20 已退出

结合部署:多阶段构建一个几十 MB 的静态二进制(CGO_ENABLED=0 时纯静态)丢进镜像即可,相关实践见容器与部署

七、状态与参考

下一步

  • [ ] gRPC + protobuf 服务与 net/http 服务的适用边界对比
  • [ ] 压测脚本沉淀:ab/k6/wrk 测吞吐与延迟分布(含 GC 停顿观察)
  • [ ] 结构化日志(slog)+ OpenTelemetry 链路追踪接入样例

基于 VitePress 构建 · 内容以知识共享方式沉淀