Skip to content

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 端连接耗尽。
  • MaxIdleConnsMaxOpenConns 的 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,压缩率好但慢,适合静态资源。
  • snappygithub.com/golang/snappy,极快但压缩率一般,适合内网传输。
  • zstdgithub.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 处理大 JSONDecoder/Encoder 避免全量载入。
  • 压缩按场景选:内网 snappy,外网 gzip/zstd,> 1KB 才划算。

下一篇是实战案例集,把前六篇的方法论应用到三个真实场景:日志服务、数据管道、API 网关。