Appearance
I/O 与网络优化
Go 程序的性能瓶颈往往不在 CPU 而在 I/O:网络往返、磁盘读写、数据库查询、序列化开销。本篇从 I/O 模型讲起,覆盖 Go 的 netpoller、HTTP 服务调优、数据库连接池、文件 I/O 零拷贝、JSON/序列化优化,最后给出 HTTP API 性能优化的完整案例。
一、I/O 模型:阻塞 I/O vs 非阻塞 I/O
1. 阻塞 I/O
传统模型:每个连接一个线程,线程在 read() 时阻塞直到数据到达。
连接1 ──► 线程1 (阻塞在 read)
连接2 ──► 线程2 (阻塞在 read)
连接3 ──► 线程3 (阻塞在 read)问题:线程昂贵(栈 1-8MB),上下文切换开销大,难以支撑高并发。
2. 非阻塞 I/O + 多路复用
现代方案:线程在单个 fd 上 epoll_wait/kqueue 等待多个连接的事件,事件就绪再处理。
所有连接 ──► epoll ──► 单个线程轮询事件
│
▼
就绪的连接处理 I/O一个线程可处理数万连接,是 Nginx、Redis、Node.js 的核心模型。
二、Go 的 netpoller:epoll/kqueue
Go 把非阻塞 I/O 封装在 runtime 的 netpoller 里,对开发者暴露阻塞式 API,但底层不阻塞 OS 线程。
1. 工作机制
goroutine 调用 conn.Read()
↓
runtime 把 fd 加入 epoll,挂起 goroutine(不阻塞 M)
↓
M 去跑其他 G
↓
数据到达,epoll 通知
↓
runtime 唤醒 goroutine,继续 Read()关键:goroutine 阻塞,但 M 不阻塞。一个 M 可以服务无数个等待 I/O 的 goroutine。这就是 Go 写 HTTP 服务「一个连接一个 goroutine」也不崩的原因。
go
package main
import (
"fmt"
"net"
"time"
)
func handleConn(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 1024)
for {
// 这里 goroutine 阻塞,但底层 M 不阻塞
n, err := conn.Read(buf)
if err != nil {
return
}
conn.Write(buf[:n])
}
}
func main() {
ln, _ := net.Listen("tcp", ":8080")
fmt.Println("listening on :8080")
for {
conn, err := ln.Accept()
if err != nil {
continue
}
// 一个连接一个 goroutine,得益于 netpoller 不会爆 M
go handleConn(conn)
}
}2. 验证 netpoller 的效果
go
package main
import (
"fmt"
"net"
"runtime"
"time"
)
func main() {
ln, _ := net.Listen("tcp", ":9090")
// 启动 10000 个空闲连接
for i := 0; i < 10000; i++ {
conn, err := net.Dial("tcp", "localhost:9090")
if err != nil {
continue
}
// 每个 conn 一个 goroutine 阻塞读
go func(c net.Conn) {
buf := make([]byte, 1)
c.Read(buf) // 阻塞,但 M 不阻塞
}(conn)
// 服务端也接受
c, _ := ln.Accept()
go func(c net.Conn) {
buf := make([]byte, 1)
c.Read(buf)
}(c)
}
time.Sleep(time.Second)
// M 数远小于 goroutine 数(通常 = GOMAXPROCS)
fmt.Printf("goroutines=%d, threads=%d\n",
runtime.NumGoroutine(),
runtime.NumCgoCall(), // 仅示意,实际用 runtime/pprof 看 M 数
)
time.Sleep(time.Hour)
}三、HTTP 服务优化
1. 连接复用:keep-alive
HTTP/1.1 默认开启 keep-alive,复用 TCP 连接避免反复握手。Go 的 http.Server 默认开启。
go
package main
import (
"net/http"
"time"
)
func main() {
srv := &http.Server{
Addr: ":8080",
// IdleTimeout 控制 keep-alive 空闲连接的保持时间
IdleTimeout: 120 * time.Second,
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
})
srv.ListenAndServe()
}客户端必须用 http.Client 且复用同一个实例, Transport 才会复用连接。
go
package main
import (
"fmt"
"net/http"
"time"
)
// 全局复用 client,Transport 缓存连接池
var httpClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20, // 关键:每个 host 的空闲连接数
IdleConnTimeout: 90 * time.Second,
},
}
func main() {
for i := 0; i < 10; i++ {
resp, err := httpClient.Get("http://localhost:8080/")
if err != nil {
fmt.Println(err)
continue
}
resp.Body.Close() // 必须关闭 Body 才能复用连接
}
}坑:
resp.Body不 Close 或不读完就 Close,连接无法复用,每次都新建 TCP。
2. 超时配置
生产服务必须配置超时,否则慢客户端会拖垮连接池。
go
package main
import (
"net/http"
"time"
)
func main() {
srv := &http.Server{
Addr: ":8080",
ReadTimeout: 5 * time.Second, // 读请求超时(含 body)
WriteTimeout: 10 * time.Second, // 写响应超时
IdleTimeout: 120 * time.Second,
// HeaderTimeout: 2 * time.Second, // Go 1.23+,读 header 超时
// 注意:WriteTimeout 在写出第一个字节前就开始计时,长连接要小心
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
srv.ListenAndServe()
}各超时含义:
ReadTimeout:从接受连接到读完请求的总时长。WriteTimeout:从读完 header 到写完响应的总时长。IdleTimeout:keep-alive 空闲连接保持时长。
3. 最大连接数
http.Server 没有直接限制连接数,可用 Listener 包装实现。
go
package main
import (
"net"
"net/http"
"sync"
)
type LimitListener struct {
net.Listener
sem chan struct{}
}
func NewLimitListener(l net.Listener, n int) *LimitListener {
return &LimitListener{Listener: l, sem: make(chan struct{}, n)}
}
func (l *LimitListener) Accept() (net.Conn, error) {
l.sem <- struct{}{} // 占一个槽位,满则阻塞
c, err := l.Listener.Accept()
if err != nil {
<-l.sem
return nil, err
}
return &limitConn{Conn: c, sem: l.sem}, nil
}
type limitConn struct {
net.Conn
sem chan struct{}
once sync.Once
}
func (c *limitConn) Close() error {
c.once.Do(func() { <-c.sem })
return c.Conn.Close()
}
func main() {
ln, _ := net.Listen("tcp", ":8080")
limited := NewLimitListener(ln, 1000) // 最多 1000 并发连接
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
http.Serve(limited, nil)
}4. gin.Mode():Release 模式
Gin 在 debug 模式会打印路由表、警告等,有额外开销。生产用 gin.ReleaseMode。
go
package main
import (
"net/http"
"os"
"github.com/gin-gonic/gin"
)
func main() {
// 方式1:环境变量(推荐,早于 gin 初始化)
os.Setenv("GIN_MODE", "release")
// 方式2:代码设置
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery()) // 生产保留 Recovery,去掉 Logger 用更快的日志
r.GET("/", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
r.Run(":8080")
}四、数据库优化
1. 连接池配置
database/sql 的连接池配置至关重要。
go
package main
import (
"database/sql"
"log"
"time"
_ "github.com/go-sql-driver/mysql"
)
func main() {
db, err := sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(50) // 最大连接数(应略小于 DB 的 max_connections/客户端数)
db.SetMaxIdleConns(10) // 最大空闲连接(避免频繁建连)
db.SetConnMaxLifetime(30 * time.Minute) // 连接最大寿命(避免长连接累积问题)
db.SetConnMaxIdleTime(5 * time.Minute) // 空闲连接最大存活时间
// 验证连接
if err := db.Ping(); err != nil {
log.Fatal(err)
}
log.Println("db ready")
}经验值:
MaxOpenConns:CPU 核数 × 2 ~ 4,或按 DB 容量定。过大导致 DB 端连接耗尽。MaxIdleConns:MaxOpenConns的 1/4 ~ 1/2。ConnMaxLifetime:30 分钟到 2 小时,避免长连接因 DB 侧wait_timeout被断。
2. 预编译语句
预编译(prepared statement)让 DB 只解析一次 SQL,多次执行。Go 的 sql.DB.Prepare 自动连接级缓存。
go
package main
import (
"database/sql"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
db, _ := sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/db")
defer db.Close()
stmt, err := db.Prepare("SELECT name FROM users WHERE id = ?")
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
// 多次复用同一 stmt
for _, id := range []int{1, 2, 3} {
var name string
if err := stmt.QueryRow(id).Scan(&name); err != nil {
log.Println(err)
continue
}
log.Println(name)
}
}注意:
Prepare在连接池中每个连接各准备一份,连接数多时内存有开销。GORM 等库会智能管理。
3. 批量操作
逐条 INSERT 慢,批量 INSERT 快 10-100 倍。
go
package main
import (
"database/sql"
"fmt"
"strings"
_ "github.com/go-sql-driver/mysql"
)
type User struct {
Name string
Email string
}
func batchInsert(db *sql.DB, users []User) error {
if len(users) == 0 {
return nil
}
// 构造批量 SQL:INSERT INTO users (name,email) VALUES (?),(?),...
valueStrings := make([]string, 0, len(users))
valueArgs := make([]interface{}, 0, len(users)*2)
for _, u := range users {
valueStrings = append(valueStrings, "(?,?)")
valueArgs = append(valueArgs, u.Name, u.Email)
}
stmt := fmt.Sprintf("INSERT INTO users (name,email) VALUES %s",
strings.Join(valueStrings, ","))
_, err := db.Exec(stmt, valueArgs...)
return err
}
func main() {
db, _ := sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/db")
defer db.Close()
users := make([]User, 1000)
for i := range users {
users[i] = User{Name: fmt.Sprintf("u%d", i), Email: fmt.Sprintf("u%d@e.com", i)}
}
if err := batchInsert(db, users); err != nil {
fmt.Println(err)
}
}4. 查询优化:SELECT 只取需要的字段
SELECT * 拖慢查询且浪费内存。只取需要的列。
go
package main
import (
"database/sql"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
db, _ := sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/db")
defer db.Close()
// Bad: SELECT *
// rows, _ := db.Query("SELECT * FROM users WHERE id = ?", 1)
// Good: 只取需要的列
var name, email string
err := db.QueryRow("SELECT name, email FROM users WHERE id = ?", 1).Scan(&name, &email)
if err != nil {
log.Fatal(err)
}
log.Println(name, email)
}五、文件 I/O 优化
1. bufio.Reader / bufio.Writer
直接 os.File.Read 每次系统调用开销大。bufio 加缓冲区,减少系统调用次数。
go
package main
import (
"bufio"
"fmt"
"io"
"os"
)
func main() {
f, _ := os.Open("input.txt")
defer f.Close()
// 4KB 缓冲,按行读
r := bufio.NewReader(f)
for {
line, err := r.ReadString('\n')
fmt.Print(line)
if err == io.EOF {
break
}
if err != nil {
break
}
}
}写文件同理:
go
package main
import (
"bufio"
"os"
)
func main() {
f, _ := os.Create("output.txt")
defer f.Close()
w := bufio.NewWriterSize(f, 64*1024) // 64KB 缓冲
defer w.Flush() // 必须 Flush
for i := 0; i < 1000000; i++ {
w.WriteString("hello\n")
}
}2. 零拷贝:io.Copy、sendfile
io.Copy 把数据从 Reader 拷到 Writer,内部用 32KB 缓冲。当 Reader 是 *os.File、Writer 是 *net.TCPConn 时,runtime 自动用 sendfile 系统调用,数据不经过用户态。
go
package main
import (
"io"
"net/http"
"os"
)
func main() {
http.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
f, err := os.Open("largefile.bin")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer f.Close()
// 自动用 sendfile,零拷贝
io.Copy(w, f)
})
http.ListenAndServe(":8080", nil)
}零拷贝路径:磁盘 → 内核 → 网卡,省去「内核→用户→内核」的两次拷贝。
3. 内存映射:mmap
mmap 把文件映射到进程地址空间,读写文件像读写内存,适合大文件随机访问。
go
package main
import (
"fmt"
"os"
"syscall"
)
func main() {
f, _ := os.Open("data.bin")
defer f.Close()
info, _ := f.Stat()
size := info.Size()
// 映射整个文件
data, err := syscall.Mmap(int(f.Fd()), 0, int(size),
syscall.PROT_READ, syscall.MAP_SHARED)
if err != nil {
fmt.Println(err)
return
}
defer syscall.Munmap(data)
// 像访问内存一样访问文件
fmt.Println("first 4 bytes:", data[:4])
fmt.Println("size:", len(data))
}实际项目可用 golang.org/x/exp/mmap 封装。mmap 适合「大文件 + 随机读」,不适合频繁写(要处理页错误和同步)。
六、JSON 处理优化
1. sonic:高性能 JSON 库
github.com/bytedance/sonic 基于 JIT 编译,比 encoding/json 快 3-5 倍,API 兼容。
go
package main
import (
"fmt"
"github.com/bytedance/sonic"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Tags []string `json:"tags"`
}
func main() {
u := User{ID: 1, Name: "alice", Tags: []string{"a", "b"}}
// Marshal
b, _ := sonic.Marshal(u)
fmt.Println(string(b))
// Unmarshal
var u2 User
sonic.Unmarshal(b, &u2)
fmt.Println(u2)
}2. jsoniter:json-iterator/go
github.com/json-iterator/go 也是标准库的替代,兼容性好,速度中等。
go
package main
import (
"fmt"
jsoniter "github.com/json-iterator/go"
)
var json = jsoniter.ConfigCompatibleWithStandardLibrary
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func main() {
u := User{ID: 1, Name: "alice"}
b, _ := json.Marshal(u)
fmt.Println(string(b))
var u2 User
json.Unmarshal(b, &u2)
fmt.Println(u2)
}3. streaming API
大 JSON 用 streaming API(json.Decoder/json.Encoder)避免一次性载入全部到内存。
go
package main
import (
"encoding/json"
"fmt"
"strings"
)
type Item struct {
ID int `json:"id"`
}
func main() {
// 模拟一个含大量元素的 JSON 数组
var sb strings.Builder
sb.WriteString("[")
for i := 0; i < 1000000; i++ {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, `{"id":%d}`, i)
}
sb.WriteString("]")
// streaming 解码,逐个元素处理,不全载入
dec := json.NewDecoder(strings.NewReader(sb.String()))
dec.Token() // 读 [
count := 0
for dec.More() {
var item Item
dec.Decode(&item)
count++
}
fmt.Println("parsed items:", count)
}七、压缩与序列化
1. Protobuf vs JSON
Protobuf 二进制格式,体积小 3-10 倍,解析快 5-20 倍,但需 schema。
go
package main
import (
"encoding/json"
"fmt"
)
// 同样的数据,JSON 和 Protobuf 体积对比(示意)
type User struct {
ID int32 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func main() {
u := User{ID: 12345, Name: "alice", Email: "alice@example.com"}
jsonBytes, _ := json.Marshal(u)
fmt.Printf("JSON size: %d bytes\n", len(jsonBytes))
// Protobuf 等价结构约 30 字节,JSON 通常 50+ 字节
}Protobuf 适合内部 RPC(gRPC 默认用),JSON 适合对外 API。
2. gzip / snappy / zstd
压缩算法权衡:压缩率 vs 速度。
- gzip:标准库
compress/gzip,压缩率好但慢,适合静态资源。 - snappy:
github.com/golang/snappy,极快但压缩率一般,适合内网传输。 - zstd:
github.com/klauspost/compress/zstd,现代算法,速度和压缩率都优。
go
package main
import (
"bytes"
"compress/gzip"
"fmt"
)
func main() {
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
data := bytes.Repeat([]byte("hello world "), 10000)
gw.Write(data)
gw.Close()
fmt.Printf("raw=%d, gzip=%d, ratio=%.1f%%\n",
len(data), buf.Len(), float64(buf.Len())*100/float64(len(data)))
}网络传输中,压缩减少带宽但增加 CPU。对 > 1KB 的响应开 gzip 通常划算。
八、完整示例:HTTP API 性能优化全流程
需求:一个返回用户列表的 API,初始版性能差,逐步优化。
1. 初始版
go
package main
import (
"encoding/json"
"net/http"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Tags []string `json:"tags"`
}
func makeUsers(n int) []*User {
users := make([]*User, n)
for i := 0; i < n; i++ {
users[i] = &User{
ID: i,
Name: "user",
Email: "user@example.com",
Tags: []string{"a", "b", "c"},
}
}
return users
}
func main() {
http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
users := makeUsers(1000)
json.NewEncoder(w).Encode(users)
})
http.ListenAndServe(":8080", nil)
}问题:每请求分配 1000 个堆对象、无超时、无连接限制、用 encoding/json 反射。
2. 优化版
go
package main
import (
"net/http"
"time"
"github.com/bytedance/sonic"
"github.com/gin-gonic/gin"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Tags []string `json:"tags"`
}
// 预分配用户池
var userPool = make([]User, 1000)
func init() {
for i := range userPool {
userPool[i] = User{
Name: "user",
Email: "user@example.com",
Tags: []string{"a", "b", "c"},
}
}
}
func main() {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
r.GET("/users", func(c *gin.Context) {
// 复用预分配对象,只更新 ID
users := make([]User, 1000)
for i := range users {
users[i] = userPool[i]
users[i].ID = i
}
// 用 sonic 序列化
c.Header("Content-Type", "application/json")
c.Status(http.StatusOK)
enc := sonic.ConfigDefault.NewEncoder(c.Writer)
enc.Encode(users)
})
srv := &http.Server{
Addr: ":8080",
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
r.Run()
srv.ListenAndServe()
}3. benchmark 对比
bash
# 用 wrk 压测
wrk -t4 -c100 -d10s http://localhost:8080/users
# 初始版:
# Requests/sec: 5000
# Latency: 20ms
#
# 优化版:
# Requests/sec: 25000
# Latency: 4ms优化点总结:
gin.ReleaseMode去掉 debug 开销。- 预分配用户对象,避免每请求 1000 次堆分配。
- 用
sonic替代encoding/json,序列化快 3-5 倍。 - 配置超时,避免慢客户端拖垮服务。
- 值切片
[]User替代指针切片[]*User,减少 GC 压力。
九、小结
- netpoller 是 Go I/O 的基石:goroutine 阻塞不阻塞 M,一个 M 服务万千连接。
- HTTP 客户端复用:全局
http.Client,关闭 Body,配置MaxIdleConnsPerHost。 - 服务端必配超时:ReadTimeout/WriteTimeout/IdleTimeout,缺一不可。
- DB 连接池要调参:MaxOpen/MaxIdle/ConnMaxLifetime,按 DB 容量和负载定。
- 批量优于逐条:批量 INSERT 快 10-100 倍。
- bufio 减少系统调用:文件 I/O 必加缓冲。
- io.Copy 触发 sendfile:文件→网络零拷贝,自动生效。
- mmap 适合大文件随机读:映射到内存,避免 read 系统调用。
- JSON 选 sonic/jsoniter:标准库之外的高性能替代,API 兼容。
- streaming 处理大 JSON:
Decoder/Encoder避免全量载入。 - 压缩按场景选:内网 snappy,外网 gzip/zstd,> 1KB 才划算。
下一篇是实战案例集,把前六篇的方法论应用到三个真实场景:日志服务、数据管道、API 网关。