做后端服务的人迟早都会撞上这么一堵墙:平时接口响应快得很,QPS 也不低,结果一搞线上活动,或者对接方突然开启大批并发连接,服务直接卡死,报错全是 Connection timed out 或者 too many open files。我最早遇到这个场景,是在做一个物联网设备接入网关,设备端几十万台,大部分时间在线但零散请求,高峰期一拥而上发心跳,十几秒内连接数从几千冲到几万,这才真正意识到 Go HTTP Server 在高并发场景下的连接优化不是可选项,而是必选项。
这篇文章把我在实际项目中做 Go HTTP Server 高并发连接优化的完整过程梳理出来,包括 net/http 连接模型的理解、服务端和系统层参数怎么调、用什么工具压测、压测结果怎么看、线上常见故障怎么排查。适合正在用 Go 写 API 服务、接入网关、长连接推送服务的同学参考,也适合服务已经跑起来但一到高并发就抓瞎的团队。文中没有玄学优化,全是能直接抄的参数、命令和排查思路。
1. 连接模型先搞清楚:Go net/http 一个连接一个 goroutine 的得与失
1.1 一个连接一个 goroutine:模型本身的逻辑和代价
Go 标准库 net/http 在处理 TCP 连接时,采用的是一个连接对应一个 goroutine 的模型。只要有一个客户端把 TCP 连接建立起来,服务端就会调起一个新的 goroutine 去专门伺候这个连接,读取请求、解析请求、调用 handler、写回响应,全部在这个 goroutine 里顺序完成。
这个模型最大的优点是开发心智负担极低。你不需要像写 Java NIO 或者 C 的 epoll 那样自己维护 reactor 线程和状态机,每个连接都是顺序执行的代码,看起来就像同步编程一样。goroutine 初始栈很小,可以随用随扩,在 8 核 16G 的机器上跑几万个 goroutine 并不罕见。这也是为什么很多人说 Go 天生适合写高并发网络服务。
但注意,goroutine 再轻,也不是零成本。每个连接至少吃一个 goroutine,如果连接上有半死不活的请求挂着(客户端开了连接不发数据,或者正在极慢地发送请求头),这个 goroutine 就要一直占着,等超时或者等数据。在没有任何超时设置的情况下,这种等待是没有上限的,goroutine 只会越来越多,内存跟着涨。高并发连接优化,本质上就是在管理这笔"每个连接都要付的账"。
补充一个容易忽略的点:net/http 在接收完一个请求之后,会尝试从连接里读下一个请求,也就是 keep-alive 循环。这个循环里的 goroutine 并不是只有在请求处理时才存在,而是在整个 TCP 连接生命周期内都存在。连接优化调的是超时和容量,本质上调的也是这个生命周期。
1.2 高并发连接下,按这四条路线崩下去
根据我的经验,连接数上来之后,服务崩溃通常不是一瞬间的事,而是按"资源耗尽四步曲"来的。
第一步是文件描述符耗尽。TCP 连接在 Linux 上本质是一个 socket 文件描述符,每个进程都有 nofile 限制,默认经常只有 1024。你代码里把并发连接数调到十万,系统根本不会让你建那么多 socket,直接报 too many open files。这是最常被忽略、也最先炸的一环。
第二步是内存上涨。每个连接都占一个 goroutine,就算都阻塞在读取上,goroutine 栈加相关缓冲,按几十 KB 算,五万连接就是几个 GB 级别的内存开销。如果 handler 里还有请求体缓冲、response buffer,数字会更夸张。
第三步是 TCP 层面的问题。比如 TIME_WAIT 堆积、SYN 队列溢出、本地端口耗尽。这些通常不会在应用层直接报错,但会让新连接建立速度变慢,或者表现为偶发的 connection reset。
第四步才是应用层 CPU 飙升。连接太多导致频繁的 epoll 事件调度和 goroutine 上下文切换,如果 handler 里还有锁竞争、日志写入这种高开销操作,CPU 会先被打爆。
这四个点里,前两个主要靠应用层配置控制,后两个要配合系统参数调优。后面就是按这个顺序逐个解决的。
2. 五个关键参数决定连接上限:超时、Header、keep-alive
2.1 超时参数的正确配置姿势
http.Server 里几个超时参数特别容易搞混,我直接列一张表,把作用和推荐值写清楚。
| 参数 | 默认值 | 作用 | 建议值 |
|---|---|---|---|
| ReadHeaderTimeout | 0(无限) | 限制读取请求头的时间,防慢速连接拖死 goroutine | 5s ~ 10s |
| ReadTimeout | 0(无限) | 读取整个请求(含 body)的超时 | 15s ~ 30s |
| WriteTimeout | 0(无限) | 从请求读完到响应写完的超时 | 30s ~ 60s |
| IdleTimeout | 0(跟随 ReadTimeout) | keep-alive 连接等待下一个请求的时间 | 60s ~ 120s |
| MaxHeaderBytes | 1MB | 请求头最大字节数 | 1MB ~ 4MB |
为什么 ReadHeaderTimeout 和 ReadTimeout 必须设?因为这两个参数默认是 0,也就是无限等待。客户端把 TCP 连接建立好之后,只发一个字节或者干脆不发,服务端这个 goroutine 就会一直阻塞在读取上。几百个这样的连接就能把 goroutine 池撑爆,这比 QPS 高更可怕。ReadHeaderTimeout 单独存在,就是为了在慢速请求攻击时,不用等满整个 ReadTimeout 就能把连接断掉,响应更快。
WriteTimeout 很多人以为是"写响应超时",实际上涵盖的范围是"从读取完请求头到写完响应"的整个生命周期,包括 handler 的执行时间。如果你有个接口内部调第三方服务要好几秒,WriteTimeout 设太短会导致正常请求也被掐断。我一般先给 30s,压测时再根据 p99 耗时调整。
IdleTimeout 是 keep-alive 状态下,两个请求之间的最大间隔。客户端连上之后长时间不发请求,到点就会被服务端关闭,对应 goroutine 也就释放了。注意文档里写的逻辑:如果 IdleTimeout 为 0,那么会用 ReadTimeout 的值,如果两个都是 0,那就没有空闲超时。
一个完整的最小配置长这样:
srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 90 * time.Second, MaxHeaderBytes: 1 << 20, // 1MB } log.Fatal(srv.ListenAndServe())2.2 keep-alive 用还是不用,得看业务形态
keep-alive 对高并发连接有两面性。HTTP/1.1 默认开启 keep-alive,一个 TCP 连接可以连续发多个请求,省去 TCP 三次握手和慢启动的开销,这对大量短请求的服务是很大的性能收益。
但问题在于,连接一旦开启 keep-alive,服务端就得一直保留这个连接和对应的 goroutine,即使客户端已经很久没发请求了。如果你的业务是"每个客户端只偶尔发一次请求,发完就扔",那么大量在线空闲连接会白白吃掉内存和 fd。这种场景下,在服务端直接关掉 keep-alive 反而更划算:
srv.SetKeepAlivesEnabled(false)或者在响应头里加Connection: close,让客户端处理完当前请求就主动关连接。这里要说清楚,关掉 keep-alive 之后,单个请求的处理效率会下降,每次都要重新握手和慢启动,但连接总量和 goroutine 总量会显著下降,适合低频长尾请求的场景。如果你的业务是高频 API 调用,比如网关转发、RPC 平替,那 keep-alive 必须保留,配合 IdleTimeout 控制空闲连接的生命周期。
2.3 别只盯着服务端,客户端连接池也要管
高并发服务很少有纯收不发的,多数情况是:A 服务接收上游十万个请求,每个请求里又需要调下游的几个服务。这时候下行的 HTTP 客户端(http.Transport)如果不配置连接池,等于把瓶颈从入口挪到了出口。
http.Client 默认的 Transport 有几个关键的池化参数:
| 参数 | 作用 | 建议值 |
|---|---|---|
| MaxIdleConns | 所有 host 的空闲连接总数上限 | 100 ~ 500 |
| MaxIdleConnsPerHost | 单个下游 host 的空闲连接上限 | 50 ~ 100 |
| MaxConnsPerHost | 单个 host 的连接总数硬上限 | 100 ~ 200 |
| IdleConnTimeout | 空闲连接保留时间 | 90s |
| TLSHandshakeTimeout | TLS 握手超时 | 5s ~ 10s |
| DialContext.Timeout | 建立 TCP 连接的超时 | 5s |
我踩过的坑是:默认的 MaxIdleConnsPerHost 只有 2,这意味着同一时刻对同一个下游服务,最多只有 2 条连接是活的。高并发下所有请求都在这两条连接上排队,延迟直接起飞,而 CPU 和内存都看着正常,特别难排查。把连接池放大之后,整个服务的时延立刻下降一个量级。
transport := &http.Transport{ MaxIdleConns: 200, MaxIdleConnsPerHost: 50, MaxConnsPerHost: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, DialContext: (&net.Dialer{ Timeout: 5 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, } client := &http.Client{Transport: transport}3. 实操:用压测驱动调优,把服务从 1k 扛到 10k 连接
3.1 压测前的基线准备
调优不能靠拍脑袋,先要跑一轮基线压测,把"当前水平"量化出来。我常用的压测工具是 wrk、hey 和 vegeta。wrk 适合测纯吞吐和延迟,hey 胜在简单,vegeta 适合生成持续负载并输出报告。
以 wrk 为例,压测一个本地服务的命令大概是这样的:
wrk -t8 -c1000 -d30s http://127.0.0.1:8080/api/ping参数含义:-t8 表示 8 个线程发起压测,-c1000 表示总连接数是 1000,-d30s 表示持续 30 秒。输出里重点看 Requests/sec(QPS)、Latency 的 p50/p99 以及 Socket errors。如果本机没有 wrk,用 hey 也可以:
hey -z 30s -c 1000 http://127.0.0.1:8080/api/ping压测之前,把 Go 标准库自带的 pprof 也打开,方便后面定位问题:
import _ "net/http/pprof" func main() { // 独立端口,避免和业务端口互相影响 go func() { log.Println(http.ListenAndServe("127.0.0.1:6060", nil)) }() // ... 启动业务服务 }基线测试能跑就跑完整一点,别只看 QPS。我建议记录三组数:QPS、p99 延迟、错误率。错误率哪怕只有 0.1%,在高并发下也意味着大量请求失败,是要重点盯的指标。
3.2 系统层调整:fd、TCP 参数、端口范围
拿 Linux 来说,Go 应用层的连接数上限,第一道门槛就是进程的文件描述符限制。先看当前值:
ulimit -n生产环境一般要调到 100 万级别。临时生效可以这样:
ulimit -n 1048576如果要永久生效,改 /etc/security/limits.conf:
* soft nofile 1048576 * hard nofile 1048576这里有个坑要特别提醒:如果你的服务是用 systemd 管理的,limits.conf 里的*可能不生效,必须在 service 文件里单独加:
[Service] LimitNOFILE=1048576改完记得 systemctl daemon-reload 再重启服务。我见过不止一次,开发在 limits.conf 里配了,重启后一查 /proc/PID/limits 还是老的 65535,就是因为 systemd 接管了进程限制。
接着是 TCP 内核参数。跟连接数关系比较大的有这么几个:
# 监听队列长度,高并发建连时重要 sysctl -w net.core.somaxconn=65535 # 未完成握手的 SYN 队列长度 sysctl -w net.ipv4.tcp_max_syn_backlog=65535 # 本地端口范围,主动外呼连接多时要放大 sysctl -w net.ipv4.ip_local_port_range="1024 65535" # TIME_WAIT 快速回收,主要对主动关闭方有效 sysctl -w net.ipv4.tcp_tw_reuse=1 # TIME_WAIT 最多保留的秒数 sysctl -w net.ipv4.tcp_fin_timeout=30逐个解释一下。somaxconn 是 listen backlog,也就是内核里排队等待 accept 的连接数量,设小了高并发建连时会直接丢弃新连接,客户端表现为 Connection refused。tcp_tw_reuse 只对发起连接的一方有效,它允许内核复用处于 TIME_WAIT 状态的 socket 来发起新连接,对外呼请求多的服务很有用。ip_local_port_range 决定了本机作为客户端时能用的源端口范围,默认可能只有 32768~61000,如果是短连接高并发外呼,很快就能打满,表现出"连接建立失败"但 CPU 内存都正常。
3.3 应用层调整:完整 Server 配置与连接数上限控制
系统层调完,回到 Go 代码里把服务配置写得规范一点。除了前面说的超时参数,我还会用 ConnState 回调做一层连接数上限控制,防止极端情况下服务被打到资源耗尽。逻辑很简单:统计当前处于 New 状态的连接数,超过阈值就直接关掉新连接。
var ( connCount int64 maxConns int64 = 50000 ) srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 90 * time.Second, } srv.ConnState = func(c net.Conn, state http.ConnState) { switch state { case http.StateNew: if atomic.AddInt64(&connCount, 1) > maxConns { atomic.AddInt64(&connCount, -1) c.Close() } case http.StateClosed: atomic.AddInt64(&connCount, -1) } }注意 ConnState 里不要做耗时操作,回调频率很高,锁和日志都会成为瓶颈,用原子操作就够了。关闭连接这种操作在这里面做没问题,因为 net/http 会自己处理并发关闭时的错误。
另外,优雅关停在高并发场景下不是可选而是必须。没有优雅关停,发布时存量连接瞬间全部断开,客户端会看到大量 reset。用 http.Server.Shutdown 可以做到:停止接收新连接,等存量请求处理完,超时后强制退出。
srv := &http.Server{ /* 配置省略 */ } go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("listen: %v", err) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Printf("shutdown error: %v", err) }3.4 压测数据对比:调优前后差多少
我拿一个 4 核 8G 的 Linux 实例做过一次典型的调整,接口是个简单的 JSON 返回,handler 里没有任何外部调用。下面是调优前后的对比记录。
| 项目 | 调优前 | 调优后 |
|---|---|---|
| 服务超时配置 | 默认全 0 | ReadHeader 5s / Read 15s / Write 30s / Idle 90s |
| 文件描述符限制 | 65535 | 1048576 |
| 压测连接数 | 1000 | 10000 |
| 压测命令 | wrk -t8 -c1000 -d30s | wrk -t8 -c10000 -d30s |
| QPS | 约 48000 | 约 43000 |
| p99 延迟 | 8ms | 60ms |
| 错误率 | 0.00% | 0.03% |
| goroutine 峰值 | 1100 左右 | 10500 左右 |
这里的数据很有意思:连接数从 1000 提到 10000,QPS 反而略降了,p99 延迟从 8ms 涨到 60ms。这正好印证了我在开头说的,高并发连接优化不等于追求更高的 QPS。连接数翻十倍,goroutine 上下文切换、epoll 事件分发、内存占用都在涨,单机吞吐的边际收益会递减。如果你的目标只是 QPS,可能 2000 连接就够了;但如果你要支撑的是十万设备的长连接接入,那你就得接受 QPS 的下降,换来的是连接容量的提升。
这张表不是说调优没用,而是说明调优的收益是有方向的。你要先想清楚核心指标是"连接数上限"还是"单连接请求吞吐",再决定怎么调。下面的常见问题,也基本都是从这条路线上实践出来的。
4. 线上高并发故障:常见问题与排查技巧实录
4.1 too many open files 反复出现
这个报错最常见有两种形态:一种是服务启动后跑一段时间,日志里出现 accept tcp: too many open files;另一种是压测到某个连接数时突然大量失败,dmesg 里能看到类似信息。
排查步骤是固定的:
# 1. 看进程当前的 fd 数量 ls /proc/PID/fd | wc -l # 2. 看进程当前的实际限制 cat /proc/PID/limits | grep -i "open files" # 3. 看系统整体 fd 使用情况 cat /proc/sys/fs/file-nr如果 fd 数量接近 limits 里的 soft 值,说明要么是限制没调到位,要么是连接泄漏。限制问题按 3.2 节的方法调。连接泄漏的话,配合 pprof 看 goroutine 数量,具体做法放到 4.3 节。这里说一个容易被忽略的现象:ls /proc/PID/fd | wc -l数出来很大,但 ss 里 ESTABLISHED 的连接数并不多,这时候要看看是不是有大量管道、事件 fd 或者定时器 fd,通常是代码里创建了资源没有释放,不是连接的问题。
4.2 TIME_WAIT 堆积导致新连接异常
TIME_WAIT 是主动关闭连接的一方进入的状态,正常情况下会保留 60 秒(2MSL)。如果你服务的外呼请求都是短连接,每次请求都会新建 TCP 连接,那么这个状态会铺满你所有的本地端口,新连接就只能排队等端口释放。
判断方法很简单:
ss -tan | awk '{print $1}' | sort | uniq -c如果 TIME_WAIT 数量上万甚至十几万,优先做三件事:打开 tcp_tw_reuse,调小 tcp_fin_timeout,然后检查下游调用是否都是短连接。如果能把外呼连接改成 keep-alive 的 HTTP 客户端,这个问题的收益是最大化的,因为你直接从源头减少了主动关闭的连接数量。
4.3 goroutine 数量异常上涨:用 pprof 定位
高并发服务里,goroutine 数量是最该盯的指标之一。之前我处理过一个 case:压测到 5000 连接时,服务内存不断上涨,但 QPS 几乎不动。用 pprof 一看,goroutine 数量远超连接数,栈全部停留在 net/http 的 readRequest 或者 bufio 读取上。
定位命令:
curl -s http://127.0.0.1:6060/debug/pprof/goroutine?debug=1 | head -200或者进入交互式:
go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine # 进入后输入 top 或 web看到大量 goroutine 阻塞在 conn.Read 或 bufio.Reader.Read 时,基本就是连接层面的读取超时没设好,或者客户端建立了连接但不发送数据。解决办法是我在前面反复强调的 ReadHeaderTimeout 和 ReadTimeout。如果 goroutine 阻塞在等待 channel、等待锁上,那是业务代码的问题,需要看具体的调用栈。
再补一个排除技巧:用GODEBUG=http2debug=1或直接关闭 HTTP/2 测试,确认问题是出在 HTTP/1 还是 HTTP/2 的连接管理上。某些时候 HTTP/2 的流控制和连接复用策略会让并发连接行为变得不一样。
4.4 压测时 QPS 上不去,CPU 却很高
连接数很大、QPS 却上不去,优先怀疑两件事:上下文切换和锁竞争。先看系统上下文切换:
vmstat 1cs 列就是每秒上下文切换次数,如果几万甚至几十万,说明调度开销已经吃掉太多 CPU。这时候最有效的办法是减少连接总数(比如用更合理的 keep-alive 策略),而不是继续堆配置。Go 的 goroutine 调度本身很优秀,但如果每个连接都配一个 goroutine,而连接又大多空闲,调度的成本是实实在在的。
锁竞争可以通过 pprof 的 profile 看:
go tool pprof http://127.0.0.1:6060/debug/pprof/profile火焰图里如果大量时间在 runtime.lock2 或者 sync.(*Mutex).Lock,那就是锁的问题。常见的解法是分片、原子操作替换、减少共享状态。这块不属于连接优化,但高并发排查时经常会撞上,顺手提一下。
4.5 最后一招:什么时候考虑换掉 net/http
net/http 在高并发连接场景下,最大的软肋是每个连接一个 goroutine 加上标准库的通用性设计。性能压榨到极限还不够时,业界常用的替代方案是 fasthttp。它通过连接和请求对象的复用,避免了很多内存分配,在极端吞吐下确实能快不少。
但我的建议是把它当最后一张牌。fasthttp 的 API 和 net/http 不兼容,很多第三方库、中间件、链路追踪方案要重新适配,HTTP/2 的支持也一直没有补全。除非你用 net/http 调优后依然扛不住业务峰值,或者明确知道瓶颈就在内存分配上,否则不建议迁移。对绝大多数服务来说,先把 3.2 和 3.3 的参数配好,性能已经足够了。
5. 调优之外,我还想说的几句大实话
5.1 连接模型决定优化方向
如果你问我调优这件事最重要的是什么,我的回答不是参数、不是工具,而是先想清楚你的连接模型到底长什么样。高频 API 调用、低频长连接、短连接突发,这三种场景的优化方向是反的。前两种要保 keep-alive、调 IdleTimeout,第三种反而要主动关掉 keep-alive,减少连接总量。没有"一套配置走天下"的调优方案。
5.2 压测数据存档与复盘
另外一个体会是压测数据一定要存档。每次调参之前记录当时的 QPS、延迟、错误率、连接数峰值,调完之后再记一轮。没有基线数据,线上出问题你根本说不清是这次变更导致的,还是早就存在。我习惯把这个存成一份简单的表格放在项目仓库里,后面再调优直接翻历史记录,比回忆靠谱得多。
5.3 最被低估的两个超时参数
最后再分享一个细节:Go 的 http.Server 里 ReadHeaderTimeout 和 IdleTimeout 这两个参数,是我在实际事故里救过命的。很多新人只关心 QPS 和并发数,忽略了慢连接和空闲连接对 goroutine 的吞噬。高并发的反面其实是"高闲置",把空闲连接管理好,比单纯调大并发上限更实在。这个道理,我在线上被教育了好几次才真正记住。