知识广场
按学科筛选:计算机科学 / 后端开发 / Go
«计算机科学 / 后端开发 / Go» 分类下共 6 篇帖子
## 起因 Go 服务上线后偶发"connection pool exhausted" 和 PG "too many connections" 错误。debug 后发现 Go `database/sql` 默认连接池配置 极宽松(无 max),高并发下能瞬间打开几百个 DB 连接。 下面讲怎么正确配 + 排查问题。 ## 默认行为陷阱 ```go db, _ := sql.Open("postgres", dsn) // db 默认 MaxOpenConns = 0(无限制) // MaxIdleConns = 2 // ConnMaxLifetime = 0(永不过期) ``` 并发 1000 请求 + 每个 query 50ms → 瞬间 1000 个 DB 连接 → PG max_connections 100 撞墙 → 报错。 ## 正确配置 ```go db, err := sql.Open("postgres", dsn) if err != nil { ... } db.SetMaxOpenConns(25) // 同时最多 25 个连接 db.SetMaxIdleConns(10) // 池里 idle 保留 10 个 db.SetConnMaxIdleTime(5 * time.Minute) // idle 超 5 分钟关闭 db.SetConnMaxLifetime(30 * time.Minute) // 连接最长存活 30 分钟(轮换) ``` 四个参数: | | 作用 | 推荐 | |---|---|---| | MaxOpenConns | 并发上限 | DB max_connections / 进程数 | | MaxIdleConns | 闲置池上限 | MaxOpenConns 的 1/2 | | ConnMaxIdleTime | 闲置多久关 | 5-10 min | | ConnMaxLifetime | 总寿命 | 30-60 min(防 DB 重启 / NAT timeout) | ## 计算 MaxOpenConns 设 PG `max_connections = 100`,集群跑 4 个 Go 服务进程: ``` 每进程 MaxOpenConns = (100 - 10 reserved) / 4 = 22 ``` 留 10 个 superuser / 监控 / DBA 用。 如果你的应用还会跑 background worker 进程也吃连接,进一步分配。 ## 测试当前配置 ```go import "fmt" import "time" func printPoolStats(db *sql.DB) { for range time.Tick(5 * time.Second) { s := db.Stats() fmt.Printf( "[db] open=%d in_use=%d idle=%d wait_count=%d wait_dur=%s\n", s.OpenConnections, s.InUse, s.Idle, s.WaitCount, s.WaitDuration, ) } } go printPoolStats(db) ``` `WaitCount > 0` 持续增长 = MaxOpenConns 太小(请求在等池)。 `InUse` 接近 MaxOpenConns 时常 = 业务高峰;偶尔正常,持续要扩。 Prometheus exporter: ```go import "github.com/prometheus/client_golang/prometheus" func collectDBMetrics(db *sql.DB) { s := db.Stats() openGauge.Set(float64(s.OpenConnections)) inUseGauge.Set(float64(s.InUse)) waitCountCounter.Add(float64(s.WaitCount)) } ``` ## ctx 超时控制 ```go ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() rows, err := db.QueryContext(ctx, "SELECT ...") ``` `QueryContext` 而不是 `Query` —— query 卡死时 ctx cancel 让 query 立刻断 + 连接归还池。 否则连接被卡住一直占。 ## 长事务陷阱 ```go // ❌ tx, _ := db.BeginTx(ctx, nil) sendEmail(...) // 慢 IO(几秒) tx.Commit() ``` 事务期间一直占一个连接。1000 请求 × 3 秒邮件 = 池被压爆。 ```go // ✅ sendEmail(...) // 先发邮件 tx, _ := db.BeginTx(ctx, nil) defer tx.Rollback() // 短的 DB 操作 tx.Commit() ``` 事务内只做 DB;非 DB 操作放外面。 ## rows 没 Close → 连接泄露 ```go // ❌ panic 时 rows.Close() 没跑 rows, _ := db.Query("...") for rows.Next() { ... } ``` ```go // ✅ defer rows, err := db.Query("...") if err != nil { return err } defer rows.Close() for rows.Next() { ... } return rows.Err() // 别忘 Err 检查 ``` 或者用更高级的 ORM(sqlx / GORM)封装这些细节。 ## prepared statement cache ```go stmt, err := db.PrepareContext(ctx, "SELECT * FROM users WHERE id = $1") // stmt 可以跨多次 query 复用 for _, id := range ids { rows, _ := stmt.QueryContext(ctx, id) // ... } stmt.Close() ``` 但 PG 的 prepared statement 是 per-connection 的。`database/sql` 自动处理(每次 PrepareContext 实际 ad-hoc prepared per connection)。 Postgres 推荐用 prepared statement 提升性能。或者用 `pgx` 替代 (更原生)。 ## pgx vs database/sql ```bash go get github.com/jackc/pgx/v5 ``` ```go // pgx 直接连接池 import "github.com/jackc/pgx/v5/pgxpool" pool, err := pgxpool.New(ctx, dsn) // 配置: config, _ := pgxpool.ParseConfig(dsn) config.MaxConns = 25 config.MinConns = 5 config.MaxConnLifetime = 30 * time.Minute pool, _ = pgxpool.NewWithConfig(ctx, config) // query rows, _ := pool.Query(ctx, "SELECT id FROM users WHERE x = $1", x) ``` pgx 优势: - 直接 PostgreSQL,性能比 database/sql 包 lib/pq 快 30-50% - 原生 prepared statement cache - 支持 PG 特有类型(jsonb / array / range) 如果你只用 PG,直接 pgx 替代。 ## PgBouncer:连接池中间层 如果有 N 个进程,每个开 25 连接 = N × 25 连接到 PG。 PG 单连接 5-10 MB → 100 连接 = 1 GB+ RAM。 PgBouncer 在 PG 前面挡: ``` app1 ─┐ app2 ─┤ ... ─┼→ PgBouncer (在 app 端,pool transaction-level) → PG (少量真连接) appN ─┘ ``` ```bash sudo apt install -y pgbouncer ``` `/etc/pgbouncer/pgbouncer.ini`: ```ini [databases] mydb = host=pg-server dbname=mydb [pgbouncer] listen_addr = 127.0.0.1 listen_port = 6432 auth_type = scram-sha-256 auth_file = /etc/pgbouncer/userlist.txt pool_mode = transaction # 关键:transaction-level pooling max_client_conn = 1000 default_pool_size = 25 reserve_pool_size = 5 server_idle_timeout = 60 ``` 应用端连 `localhost:6432` 替代 PG 5432。 `pool_mode=transaction` 让连接事务级共享,1000 client → ~25 PG 连接。 极适合 short-lived query 场景。 注意:transaction mode 不能用 prepared statement / SET LOCAL(跨事务 状态丢)。pgx 5.0+ 有 PgBouncer 兼容模式。 ## DB 端配置 PostgreSQL `postgresql.conf`: ``` max_connections = 200 # 集群总上限 shared_buffers = 8GB # 通常 RAM 25% effective_cache_size = 24GB # RAM 75% work_mem = 64MB # per query maintenance_work_mem = 1GB ``` `max_connections = 200` × `work_mem = 64MB` = 最差 12.8 GB(每 query 峰值)。可能 OOM。 工业上: - max_connections 100-200 - 应用端 PgBouncer pool 共享 - 单连接 work_mem 16-32MB ## 实战调参流程 1. 启动配 `MaxOpenConns = 25`,pprof 看实际使用 2. `WaitCount` 持续上涨 → 增大池或加 PG 3. PG 端 `pg_stat_activity` 看实际并发: ```sql SELECT count(*) FROM pg_stat_activity WHERE state = 'active'; ``` 4. 加 PgBouncer 减少 PG 物理连接数 ## 踩过的坑 1. **每个请求 `sql.Open` 新连接** → 没池化,几秒内打满。 `sql.Open` 一次性 + db 全局共享 + DI 注入。 2. **死锁 + 长事务**:事务 A 持有 row 锁 + 等连接(池满), 事务 B 持有连接 + 等 A 的 row 锁。 解决:短事务 + ctx timeout。 3. **MaxIdleConns > MaxOpenConns**:参数互相矛盾,实际生效的是 MaxOpenConns。Idle 上限被 cap。 4. **NAT 后面 DB**:长连接经过几小时 NAT 表项过期 → 应用以为还活, 实际 DB 已断 → 报 "broken pipe"。设 ConnMaxLifetime < NAT timeout (通常 30 min 安全)。 5. **使用 `Ping()` 测连接**:高频 Ping 浪费连接 + 是 round-trip 开销。 只在启动时验证一次;运行时用 ConnMaxLifetime + 实际 query 检测。
## 起因 要并发处理 10 万个 URL 抓取。原生 goroutine + channel 写起来麻烦: - 限制并发数(不能开 10 万 goroutine 抓) - 错误传播(一个失败要么 cancel 其它要么继续) - 等所有任务完成 - 收集结果 `sync/errgroup` + `golang.org/x/sync/semaphore` 标准库组合解决, 不需要 ants / workerpool 第三方。 ## 解决方案 ### 1. 简单 errgroup ```go package main import ( "context" "fmt" "net/http" "time" "golang.org/x/sync/errgroup" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() g, ctx := errgroup.WithContext(ctx) urls := []string{ "https://example.com", "https://golang.org", "https://github.com", } results := make([]int, len(urls)) for i, u := range urls { i, u := i, u // capture range vars g.Go(func() error { req, err := http.NewRequestWithContext(ctx, "GET", u, nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() results[i] = resp.StatusCode return nil }) } if err := g.Wait(); err != nil { fmt.Println("error:", err) } fmt.Println(results) } ``` 关键点: - `g.Go(func() error)` 启动一个 goroutine - 任一 goroutine 返回 error → `errgroup` 自动 cancel ctx → 其它 goroutine 收到 cancel signal 退出 - `g.Wait()` 等所有完成,返回第一个 error ### 2. 限并发:SetLimit (Go 1.20+) ```go g, ctx := errgroup.WithContext(ctx) g.SetLimit(50) // 最多 50 个 goroutine 并发 for _, u := range urls { u := u g.Go(func() error { return fetch(ctx, u) }) } g.Wait() ``` `SetLimit(50)` + `Go()` 会阻塞 caller 直到有空位。 10 万 URL 排队,永远不超过 50 个并发。 ### 3. 老 Go 版本:semaphore ```go import "golang.org/x/sync/semaphore" sem := semaphore.NewWeighted(50) g, ctx := errgroup.WithContext(ctx) for _, u := range urls { u := u if err := sem.Acquire(ctx, 1); err != nil { break } g.Go(func() error { defer sem.Release(1) return fetch(ctx, u) }) } g.Wait() ``` `semaphore` 是计数信号量,权重可以不是 1(如内存敏感任务每个占 4)。 ### 4. 结果收集:channel ```go type result struct { url string code int err error } results := make(chan result, len(urls)) g, ctx := errgroup.WithContext(ctx) g.SetLimit(50) for _, u := range urls { u := u g.Go(func() error { code, err := fetch(ctx, u) results <- result{u, code, err} return nil // 不让 errgroup 因单个 fetch 失败 cancel 其它 }) } go func() { g.Wait() close(results) }() for r := range results { fmt.Println(r) } ``` 注意: - 用 buffered channel 避免阻塞 - 单个 fetch 错误不让 errgroup cancel(包成 result 一起传) - 单独 goroutine wait + close channel ### 5. 真正的 worker pool 如果 task 是 stream 进来(不预知数量),用 channel + worker: ```go type Task struct{ URL string } func RunPool(ctx context.Context, tasks <-chan Task, workers int) <-chan error { out := make(chan error, workers) var wg sync.WaitGroup for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() for t := range tasks { select { case <-ctx.Done(): return default: } if err := process(ctx, t); err != nil { out <- err } } }() } go func() { wg.Wait() close(out) }() return out } // 用 tasks := make(chan Task, 100) go func() { defer close(tasks) for _, u := range urls { tasks <- Task{URL: u} } }() errors := RunPool(ctx, tasks, 50) for err := range errors { log.Printf("err: %v", err) } ``` stream 处理 + back-pressure(producer 阻塞在 tasks 满时)。 ### 6. context cancel 在 worker 里 worker 函数必须**响应 ctx.Done()**,否则 cancel 没用: ```go func fetch(ctx context.Context, url string) error { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) // 关键 if err != nil { return err } resp, err := http.DefaultClient.Do(req) // ctx cancel 时立刻断 // ... } ``` `http.NewRequestWithContext` / `db.QueryContext` / `redis.Get(ctx)` 等 所有 IO 都用 ctx 版本。 ### 7. 实际 benchmark 10 万 URL,50 并发: | 写法 | 总时长 | 内存峰值 | |---|---|---| | 串行 | 30 min+ | 50 MB | | 无限制 goroutine | 1.5 min(多数失败) | 800 MB | | errgroup SetLimit(50) | 4 min(全成功) | 80 MB | | worker pool channel | 4 min | 70 MB | 无限制 goroutine 看似快但实际:DNS 解析失败、连接被 ban、 socket fd 耗尽。**永远要限并发**。 ## 效果 - 10 万 task 在 5 分钟内可控完成 - 任意 task 失败 ctx cancel,剩余资源不浪费 - 内存控制在 50-100 MB,不会 OOM - 代码 50 行,比引第三方库少依赖 ## 何时还是要第三方库 - **panicking worker 自动 recover**:errgroup 不 recover panic。 生产建议每个 worker 函数 `defer recover()`。 - **复杂 retry / backoff 策略**:用 `github.com/cenkalti/backoff/v4` - **分布式(跨机器)worker pool**:用 Asynq / Machinery - **动态 worker 数**:errgroup SetLimit 启动后不能改 ## 踩过的坑 1. **range var 没 capture**: ```go for _, u := range urls { g.Go(func() error { return fetch(u) // ❌ 所有 goroutine 看到 last u }) } ``` Go 1.22 才修了 range var 默认 scope。1.21- 必须 `u := u` 显式 capture。 2. **g.Wait 不调**:goroutine leak。 ```go if err := g.Wait(); err != nil { ... } ``` 永远要 Wait。 3. **`g.Go` 里 panic** → 整个程序 crash(errgroup 不 recover)。 生产 worker 函数 wrap: ```go g.Go(func() (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } }() return fetch(ctx, u) }) ``` 4. **共享 slice 写**:上面 `results[i] = ...` 不需要锁(每个 worker 写 不同 index),但如果是 `results = append(results, ...)` 则要 sync.Mutex 或 channel。 5. **HTTP client default reuse**:`http.DefaultClient` 全局共享 + connection pool。多个 goroutine 用 OK。但默认无超时——务必传 ctx 或自己 `Client{ Timeout: 30 * time.Second }`。
`context.Context` 是 Go 标准库里跨 API 边界传递取消信号 + 超时 + 请求值的 统一方式。所有"可能长时间运行 + 可能需要被取消"的函数都该接 ctx 作为 **第一个参数**。 ## 1. 基础 ```go ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 必须 defer cancel 避免 goroutine 泄露 go doWork(ctx) time.Sleep(2 * time.Second) cancel() // 显式取消 ``` `cancel()` 触发后所有继承这个 ctx 的下游都收到信号。 ## 2. 超时 ```go ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() result, err := http.NewRequestWithContext(ctx, "GET", url, nil) // 5 秒后自动 cancel;HTTP 客户端会立即中断请求 ``` `WithTimeout` 是 `WithDeadline(time.Now().Add(d))` 的简写。 ## 3. 在函数里响应 ctx ```go func doWork(ctx context.Context) error { for i := 0; i < 100; i++ { select { case <-ctx.Done(): return ctx.Err() // context.Canceled 或 context.DeadlineExceeded case <-time.After(100 * time.Millisecond): process(i) } } return nil } ``` 关键模式:每次能 block 的地方都 `select { case <-ctx.Done(): ... }`。 对 I/O 操作通常用接 ctx 的版本: ```go req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) resp, err := http.DefaultClient.Do(req) row := db.QueryRowContext(ctx, "SELECT ...", args...) ``` ## 4. 链式派生 ```go ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // 派生:更短超时 ctxShort, cancel2 := context.WithTimeout(ctx, 2*time.Second) defer cancel2() callRemote(ctxShort) ``` 子 ctx 永远比父 ctx 早结束。取较短的 timeout 生效。 父 cancel → 所有子 ctx 都被 cancel。 ## 5. ctx 传值 ```go type traceIDKey struct{} ctx = context.WithValue(ctx, traceIDKey{}, "abc-123") // 下游取 tid, ok := ctx.Value(traceIDKey{}).(string) ``` 约定: - key 用未导出的私有 type,避免不同包冲突 - 只传"跨 API 边界的请求作用域元数据"(trace ID / user ID / locale), 不传业务参数 - 业务参数走显式函数签名 ## 6. context.Background() vs context.TODO() - `Background()`:根 context,main / init 用 - `TODO()`:当你不知道用哪个 ctx 时用(让 linter / 自己后续修) ```go ctx := context.TODO() // I'll come back to wire this ``` ## 7. HTTP 服务端:取请求 ctx ```go func handler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // 客户端断开连接 → ctx.Done() result, err := fetchFromDB(ctx, ...) if err == context.Canceled { // 客户端已经断开,没必要返回响应 return } json.NewEncoder(w).Encode(result) } ``` 客户端 close 连接时 r.Context 自动 cancel,所有下游 query 应自动中断。 省服务器 CPU / DB 连接。 ## 8. errgroup + ctx 并发 `sync/errgroup` 比手写 channel + WaitGroup 简洁: ```go import "golang.org/x/sync/errgroup" func fetchAll(ctx context.Context, urls []string) ([]string, error) { g, ctx := errgroup.WithContext(ctx) results := make([]string, len(urls)) for i, u := range urls { i, u := i, u // capture g.Go(func() error { data, err := fetch(ctx, u) if err != nil { return err // 任一失败 ctx 自动 cancel 其它 } results[i] = data return nil }) } if err := g.Wait(); err != nil { return nil, err } return results, nil } ``` `errgroup.WithContext` 返回的 ctx 在任一 goroutine 返回 error 时 自动 cancel。 限并发: ```go g, ctx := errgroup.WithContext(ctx) g.SetLimit(10) // 最多 10 个 goroutine 并发 ``` ## 9. 不要把 ctx 存结构体 反模式: ```go type Server struct { ctx context.Context // ❌ } ``` ctx 是请求 / 操作的属性,不是对象的属性。每次方法显式传: ```go type Server struct{} func (s *Server) Handle(ctx context.Context, req Req) (Resp, error) { ... } ``` 例外:长生命周期的"管理者"对象(如 server 自身的 shutdown ctx) 可以存——但要清楚标注。 ## 10. cancel() 必须 defer ```go ctx, cancel := context.WithTimeout(..., 5*time.Second) // 忘 defer cancel() → 即使函数正常返回,定时器还在 → goroutine leak defer cancel() ``` go vet 会警告"the cancel function is not used on all paths", 认真处理。 ## 11. ctx 的零成本约定 约定 ctx 永远作为第一个参数: ```go // ✅ func doWork(ctx context.Context, args Args) (Result, error) // ❌ func doWork(args Args, ctx context.Context) (Result, error) ``` 阅读 / IDE 补全 / linter 都基于这个约定。 ## 12. 实际生产 pattern ```go func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() srv := &http.Server{Addr: ":8080", Handler: setupHandler()} go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server error: %v", err) } }() <-ctx.Done() log.Info("shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { log.Errorf("forced shutdown: %v", err) } } ``` `signal.NotifyContext`(Go 1.16+)一行处理信号 → ctx 模式: - 收 SIGINT / SIGTERM → ctx.Done() - main 等 done - `srv.Shutdown(timeout ctx)` 拒新连接 + 等老请求完,30 秒超时强制退出 ## 踩过的坑 - ctx 没传到下游:所有 hop 都要传 ctx。中间某个函数没接 → cancel 信号 断了,超时不生效。 - 用 `time.Sleep(d)` 而不是 `select { case <-time.After: case <-ctx.Done():}`: cancel 后还在 sleep,不响应。 - `errgroup.Go` 里的 goroutine panic → 整个 group 崩。在 Go 函数内部 recover 自保。 - `ctx.Value(string)` 用字符串作 key → 跨包冲突。永远用私有 type。
Go 标准库的 `net/http` + `http.FileServer` 五行就能起一个静态文件服务, 但缺生产里几个关键能力:ETag、Range(断点续传 / 视频拖动)、压缩、 正确的 Cache-Control。下面写一个完整版。 ## 五行起步 ```go package main import "net/http" func main() { http.Handle("/", http.FileServer(http.Dir("./public"))) http.ListenAndServe(":8080", nil) } ``` 这就行了——`http.FileServer` 已经支持 Range、Last-Modified、 If-Modified-Since、自动 Content-Type 推断。 但 ETag / 压缩 / 自定义 Cache-Control 要自己加。 ## 完整版 ```go package main import ( "compress/gzip" "crypto/sha256" "encoding/hex" "io" "log" "mime" "net/http" "os" "path/filepath" "strings" "time" ) const root = "./public" func main() { h := http.HandlerFunc(serve) log.Fatal(http.ListenAndServe(":8080", h)) } func serve(w http.ResponseWriter, r *http.Request) { // 1. 安全:阻止 ../ 跳出 root clean := filepath.Clean(r.URL.Path) if strings.HasPrefix(clean, "..") { http.Error(w, "forbidden", http.StatusForbidden) return } full := filepath.Join(root, clean) // 2. 默认页:目录请求映射到 index.html info, err := os.Stat(full) if err != nil { http.NotFound(w, r) return } if info.IsDir() { full = filepath.Join(full, "index.html") info, err = os.Stat(full) if err != nil { http.NotFound(w, r) return } } // 3. ETag(用 mtime + size 计算,足够稳) etag := computeETag(info) if match := r.Header.Get("If-None-Match"); match == etag { w.WriteHeader(http.StatusNotModified) return } w.Header().Set("ETag", etag) // 4. Cache-Control if isAsset(full) { // 带 hash 的资源:长期缓存 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "public, max-age=300") } // 5. Content-Type ct := mime.TypeByExtension(filepath.Ext(full)) if ct != "" { w.Header().Set("Content-Type", ct) } // 6. 真正发文件 —— ServeFile 已包含 Range 支持 if acceptsGzip(r) && isCompressible(full) { w.Header().Set("Content-Encoding", "gzip") w.Header().Add("Vary", "Accept-Encoding") // 注意:开 gzip 后 Range 不再好用(gzip stream 不能任意定位) // 静态文件如果要支持 Range(音视频),别 gzip 它 gz := gzip.NewWriter(w) defer gz.Close() f, _ := os.Open(full) defer f.Close() io.Copy(gz, f) return } http.ServeFile(w, r, full) } func computeETag(info os.FileInfo) string { h := sha256.New() h.Write([]byte(info.Name())) h.Write([]byte(info.ModTime().UTC().Format(time.RFC3339Nano))) h.Write([]byte(string(rune(info.Size())))) return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"` } func acceptsGzip(r *http.Request) bool { return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") } func isCompressible(p string) bool { switch strings.ToLower(filepath.Ext(p)) { case ".html", ".css", ".js", ".json", ".svg", ".txt", ".xml": return true } return false } func isAsset(p string) bool { // 文件名含 8+ 位 hex 视为带 hash 的资源 base := filepath.Base(p) for _, part := range strings.Split(base, ".") { if len(part) >= 8 && isHex(part) { return true } } return false } func isHex(s string) bool { for _, c := range s { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { return false } } return true } ``` ## Range / 视频拖动 `http.ServeFile` 已经处理 Range 请求(206 Partial Content)。 不需要自己写。 校验: ```bash curl -I -H 'Range: bytes=0-100' http://localhost:8080/video.mp4 # HTTP/1.1 206 Partial Content # Content-Range: bytes 0-100/1234567 # Content-Length: 101 ``` 如果你不用 ServeFile 而自己 io.Copy,Range 就失效了。 ## ETag 的边界 我用 mtime + size 算 ETag —— 简单但有边界: - 同内容不同文件(不同 mtime)会返回不同 ETag - 改完没改大小且 mtime 精度不够(FAT32 只到 2 秒)会被认为没变 更稳的方案:第一次访问时算文件 SHA256,写到 sidecar 文件 `foo.css.etag` 作为缓存。代价是写文件 / 维护成本。 ## 测试 ```bash # ETag ETAG=$(curl -sI http://localhost:8080/main.css | grep -i etag | cut -d' ' -f2 | tr -d '\r\n') curl -I -H "If-None-Match: $ETAG" http://localhost:8080/main.css # 应该返回 304 # gzip curl -I -H 'Accept-Encoding: gzip' http://localhost:8080/main.css # Content-Encoding: gzip ``` ## 为什么不用 nginx nginx 这事做得更好(C 写的 + sendfile + zero-copy)。这个 Go 实现的 价值是: - 单二进制可执行,跨平台 - 嵌入到现有 Go 后端里 - 配合 `embed.FS` 直接把静态资源打包到二进制(go embed) ```go //go:embed public var staticFS embed.FS http.Handle("/", http.FileServer(http.FS(staticFS))) ``` 整个网站打包成一个 `myapp` 二进制丢服务器跑。CD 流程极简。 ## 踩过的坑 - 没做 `filepath.Clean` + 阻止 `..` → 经典目录穿越漏洞,攻击者请求 `/../../../etc/passwd` 把文件读走。 - `http.ServeFile` 在 path 含编码的 `%2E%2E` 时可能仍接受(早期 Go 版本 有 CVE)。升级到 Go 1.22+ 已修。 - 写自己的 io.Copy 而不用 ServeFile:失去 Range、失去 Last-Modified 比较、效率也差。能用 ServeFile 就用。 - 跨平台 mtime 精度差异:Windows 是 100ns、macOS 是 1ns、Linux 通常 ns。 Build 时给 docker COPY 文件 mtime 都被改为 build 时间,结果所有文件 ETag 一样。CI 里用 `touch -d` 还原 mtime 或换 SHA-based ETag。
## 起因 10 个 Go 微服务之间互通: - REST + JSON:易调试 / 浏览器友好 / 工具多 - gRPC:类型严格 / 性能好 / 双向 streaming 之前都用 REST。后来内部 service 间改 gRPC,对外仍 REST, 靠 grpc-gateway 一份 proto 自动生成两套。 ## gRPC 优势(service 间) ### 1. 强类型 ```protobuf // user.proto syntax = "proto3"; service UserService { rpc GetUser(GetUserRequest) returns (User); rpc CreateUser(CreateUserRequest) returns (User); rpc ListUsers(ListUsersRequest) returns (ListUsersResponse); } message User { int64 id = 1; string email = 2; string name = 3; int64 created_at = 4; } message GetUserRequest { int64 id = 1; } ``` `protoc` 生成 Go / Python / TypeScript / Java client + server stub。 **改字段类型 → 客户端编译报错**。 vs REST 改 JSON schema → 客户端运行时炸。 ### 2. 性能 - HTTP/2 + protobuf binary:3-5x 比 JSON 快 + 小 - 长连接复用:避免每请求 TCP / TLS 握手 - 服务端 streaming / 双向 streaming 原生支持 我们一个高 QPS 服务从 REST + JSON 改 gRPC: | | REST | gRPC | |---|---|---| | P50 延迟 | 5ms | 1.5ms | | 单连接 RPS | 500 | 5000 | | CPU 占用 | 35% | 12% | 3-10x 提升。 ### 3. 双向 streaming ```protobuf service ChatService { rpc Chat(stream Message) returns (stream Message); } ``` 客户端 / 服务端都能持续发消息。WebSocket-like 但带类型。 适合:实时通知、聊天、log tail、bidirectional sync。 ## REST 优势(对外 API) - 浏览器直接调用(不需要特殊 client) - curl / Postman / 任意 HTTP tool 调试 - HTTP cache friendly(GET / If-Modified-Since 等) - OpenAPI / Swagger 文档丰富 - 简单 SDK 自动生成(openapi-generator 覆盖语言广) 对外暴露给"未知客户端" → 必须 REST 或 GraphQL。 ## 两者结合:grpc-gateway `grpc-gateway` 让一份 .proto 同时生成 gRPC server + REST proxy: ```protobuf import "google/api/annotations.proto"; service UserService { rpc GetUser(GetUserRequest) returns (User) { option (google.api.http) = { get: "/v1/users/{id}" }; } rpc CreateUser(CreateUserRequest) returns (User) { option (google.api.http) = { post: "/v1/users" body: "*" }; } } ``` `protoc` + `grpc-gateway` 插件生成: - `UserServiceServer` interface(你实现 gRPC server) - `RegisterUserServiceHandlerServer`(注册 REST → gRPC proxy) ```go // main.go func main() { // gRPC server grpcServer := grpc.NewServer() userpb.RegisterUserServiceServer(grpcServer, &userServer{}) // 启 gRPC :9090 lis, _ := net.Listen("tcp", ":9090") go grpcServer.Serve(lis) // REST gateway :8080 ctx := context.Background() mux := runtime.NewServeMux() err := userpb.RegisterUserServiceHandlerServer(ctx, mux, &userServer{}) http.ListenAndServe(":8080", mux) } ``` 客户端两种方式调: ```bash # REST curl http://localhost:8080/v1/users/42 # gRPC grpcurl -plaintext localhost:9090 user.UserService/GetUser -d '{"id": 42}' ``` 服务端逻辑写一遍,两套 API 自动并存。 **对外 REST,对内 gRPC**。 ## connect-rpc:现代替代 `buf.build` 出的 `connect-go`: - 兼容 gRPC 协议 - 同时支持 REST + JSON + gRPC-Web(无需 grpc-gateway) - 浏览器直接调 - 比 grpc-gateway 简洁 ```go type UserService struct{} func (s *UserService) GetUser(ctx context.Context, req *connect.Request[userv1.GetUserRequest]) (*connect.Response[userv1.User], error) { return connect.NewResponse(&userv1.User{Id: req.Msg.Id, Name: "..."}), nil } mux := http.NewServeMux() mux.Handle(userv1connect.NewUserServiceHandler(&UserService{})) http.ListenAndServe(":8080", mux) ``` ```bash curl -X POST http://localhost:8080/user.v1.UserService/GetUser \ -H 'Content-Type: application/json' \ -d '{"id": 42}' # {"id": 42, "name": "..."} ``` JSON over HTTP/1 / HTTP/2 / gRPC 同 endpoint 自动适配。 **2024 后新项目推荐**。 ## gRPC client(Go) ```go import "google.golang.org/grpc" conn, err := grpc.Dial("localhost:9090", grpc.WithTransportCredentials(insecure.NewCredentials())) defer conn.Close() client := userpb.NewUserServiceClient(conn) resp, err := client.GetUser(ctx, &userpb.GetUserRequest{Id: 42}) fmt.Println(resp.Name) ``` 调 client 跟调本地函数一样。 ### 配置 keep-alive ```go conn, _ := grpc.Dial(addr, grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: 10 * time.Second, Timeout: 3 * time.Second, PermitWithoutStream: true, }), ) ``` NAT 后面长连接 ping 保活。 ### 连接池 gRPC 单 connection 默认多路复用(HTTP/2 stream)→ 一般不需要 pool。 高 QPS 时 `grpc.WithDefaultServiceConfig('{"loadBalancingPolicy":"round_robin"}')` + DNS 解析多 IP 自动负载均衡。 ## 错误处理 ```go // 服务端 return nil, status.Errorf(codes.NotFound, "user %d not found", id) // 客户端 resp, err := client.GetUser(...) if err != nil { if status.Code(err) == codes.NotFound { // handle 404 equivalent } } ``` gRPC 错误码标准化(NotFound / Unauthenticated / PermissionDenied / etc)。 比 REST 的"HTTP status + 自定义 body" 类型严格。 ## interceptor(middleware) ```go func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { start := time.Now() resp, err := handler(ctx, req) log.Printf("%s took %v err=%v", info.FullMethod, time.Since(start), err) return resp, err } server := grpc.NewServer(grpc.UnaryInterceptor(loggingInterceptor)) ``` 或者用现成 middleware 库: - `github.com/grpc-ecosystem/go-grpc-middleware/v2` - prometheus / opentelemetry / auth / recovery / rate-limit ## 实战 case:微服务架构 我们的架构: ``` [mobile app / web] [外部 partner API] ↓ ↓ └─────── REST + JSON ───┘ ↓ [API Gateway (Go)] ↓ ┌─────────────┼─────────────┐ ↓ ↓ ↓ [User svc] [Order svc] [Payment svc] ↑ ↑ └─── gRPC ────┘ ``` - 对外 REST + OpenAPI 文档 - 内部 service 间 gRPC(性能 + 类型) - gateway 翻译 ## proto 仓库化 Monorepo / 单独 repo 存所有 .proto: ``` proto-repo/ ├── user/v1/user.proto ├── order/v1/order.proto └── buf.yaml ``` `buf` 工具 lint + 兼容性检查: ```bash buf lint buf breaking --against '.git#branch=main' # PR 不能破坏兼容 buf generate # 生成 Go / TS / Python ``` 防止"改 proto 删字段把客户端搞挂"。 ## 何时不用 gRPC - 客户端是浏览器 + 没 backend proxy → 用 REST / GraphQL(gRPC-Web 也是 选项但复杂) - 极简内部 tool → REST 更友好 - 团队不愿学 protobuf → 强推有阻力 ## 踩过的坑 1. **proto 字段 reserved**:删字段要 `reserved 5` 占位防 future wire-incompatible。 2. **enum 默认 0 值**:proto3 必须有 ENUM_UNSPECIFIED = 0;不写 迁移问题大。 3. **timestamp / duration 用 google.protobuf.Timestamp / Duration**: 不要用 int64 自己定义。Well-known types 跨语言兼容。 4. **streaming RPC 错误处理**:mid-stream error 客户端要正确退出。 ctx cancel + 关 stream。 5. **gRPC client 不复用 connection**:每次 NewClient 新 connection → DoS PG / DB。client 在应用启动时建一次 + 全局共享。
## 起因 Go 写数据库代码两条路: - 用 ORM(GORM):自动 query 但隐藏 SQL;复杂 join 难表达 - 手写 sql.DB:每条 query 自己写、scanner 自己 typed、容易写错 `sqlc` 是另一条路:写纯 SQL,工具生成完全类型化的 Go 函数。 两者优点都有:SQL 完全可控 + Go 调用类型安全。 ## 解决方案 ### 1. 装 ```bash go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest # 或 brew install sqlc / Docker ``` ### 2. 配 sqlc.yaml ```yaml version: "2" sql: - engine: "postgresql" queries: "db/query.sql" schema: "db/schema.sql" gen: go: package: "db" out: "internal/db" sql_package: "pgx/v5" # 或 "database/sql" ``` ### 3. 写 schema.sql ```sql -- db/schema.sql CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE, nickname TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE posts ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, title TEXT NOT NULL, body TEXT NOT NULL, published_at TIMESTAMPTZ ); ``` ### 4. 写 query.sql ```sql -- db/query.sql -- name: GetUser :one SELECT * FROM users WHERE id = $1; -- name: GetUserByEmail :one SELECT * FROM users WHERE email = $1; -- name: ListUsers :many SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2; -- name: CreateUser :one INSERT INTO users (email, nickname) VALUES ($1, $2) RETURNING *; -- name: UpdateUserNickname :exec UPDATE users SET nickname = $2 WHERE id = $1; -- name: DeleteUser :exec DELETE FROM users WHERE id = $1; -- name: ListUserPosts :many SELECT p.*, u.nickname AS author_nickname FROM posts p JOIN users u ON u.id = p.user_id WHERE p.user_id = $1 ORDER BY p.published_at DESC LIMIT $2 OFFSET $3; -- name: CountPostsByUser :one SELECT count(*) FROM posts WHERE user_id = $1; ``` `-- name: X :one|many|exec|execrows` 告诉 sqlc 生成什么类型函数: - `:one` 返回单行 - `:many` 返回多行 - `:exec` 不返回(INSERT/UPDATE/DELETE) - `:execrows` 返回影响行数 ### 5. 生成 ```bash sqlc generate ``` `internal/db/` 下生成: ``` internal/db/ ├── db.go # 接口 + Queries struct ├── models.go # 表 → Go struct └── query.sql.go # 每个 query → Go 函数 ``` `models.go`: ```go type User struct { ID int64 Email string Nickname string CreatedAt time.Time } type Post struct { ID int64 UserID int64 Title string Body string PublishedAt pgtype.Timestamptz } ``` `query.sql.go`: ```go const getUser = `-- name: GetUser :one SELECT id, email, nickname, created_at FROM users WHERE id = $1 ` func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) { row := q.db.QueryRow(ctx, getUser, id) var u User err := row.Scan(&u.ID, &u.Email, &u.Nickname, &u.CreatedAt) return u, err } ``` 类型完全推断自 schema。 ### 6. 用 ```go import ( "context" "github.com/jackc/pgx/v5/pgxpool" "myapp/internal/db" ) func main() { ctx := context.Background() pool, _ := pgxpool.New(ctx, "postgresql://localhost/myapp") defer pool.Close() queries := db.New(pool) // 创建 user, err := queries.CreateUser(ctx, db.CreateUserParams{ Email: "[email protected]", Nickname: "Alice", }) // 查 u, _ := queries.GetUserByEmail(ctx, "[email protected]") // 列表 posts, _ := queries.ListUserPosts(ctx, db.ListUserPostsParams{ UserID: u.ID, Limit: 20, Offset: 0, }) for _, p := range posts { fmt.Println(p.Title, p.AuthorNickname) } } ``` 写错列名 / 类型 / 参数 → 编译报错。重构 schema 后 `sqlc generate` 所有不兼容的 query 调用都立刻被编译器抓出。 ### 7. JOIN 出来的"虚拟表"自动生成 struct ```sql -- name: ListUserPosts :many SELECT p.*, u.nickname AS author_nickname FROM posts p JOIN users u ON u.id = p.user_id WHERE p.user_id = $1; ``` 生成: ```go type ListUserPostsRow struct { ID int64 UserID int64 Title string Body string PublishedAt pgtype.Timestamptz AuthorNickname string } func (q *Queries) ListUserPosts(...) ([]ListUserPostsRow, error) { ... } ``` 新结构体自动产生,不需要自己定义 DTO。 ### 8. transaction ```go tx, err := pool.Begin(ctx) defer tx.Rollback(ctx) qtx := queries.WithTx(tx) user, err := qtx.CreateUser(ctx, ...) _, err = qtx.CreateProfile(ctx, ...) return tx.Commit(ctx) ``` `WithTx(tx)` 返回绑定到事务的 queries 实例。所有调用都在同事务。 ### 9. 跑迁移 sqlc 不管迁移。配合 [goose](https://github.com/pressly/goose) / [golang-migrate](https://github.com/golang-migrate/migrate) / [atlas](https://atlasgo.io): ```bash goose -dir migrations postgres "postgresql://..." up ``` `migrations/0001_init.sql`: ```sql -- +goose Up CREATE TABLE users (...); -- +goose Down DROP TABLE users; ``` sqlc 的 schema.sql 通常是迁移结果的"快照"(开发时方便)。 生产以 migration 序列为准。 ### 10. 动态 query sqlc 主要服务于"静态 SQL"。动态 WHERE / ORDER BY 灵活性差。 解决: - 简单分页 / 排序:参数化 `LIMIT $1 OFFSET $2` - 可选 filter:`WHERE (@email::text IS NULL OR email = @email)` - 真正动态:用 squirrel / goqu 等 query builder,sqlc 处理静态部分 ## 与 GORM / ent 对比 | | GORM | ent | sqlc | |---|---|---|---| | 哲学 | ORM 全自动 | schema-first ORM | 写 SQL,生成 type-safe 代码 | | 学习曲线 | 低 | 中 | 极低(你已经会 SQL) | | 性能 | 中(reflection) | 高 | 高(直接 SQL) | | 复杂 JOIN | 难表达 | 好 | 自然写 SQL | | 类型安全 | 弱 | 强 | 强 | | migration | 内置 | 内置 | 需配合工具 | 我的取向:业务大量 SQL → sqlc;CRUD 简单 → GORM 也行;schema 变化 频繁 + 业务复杂 → ent。 ## 效果 我们 5 万行 Go 代码用 sqlc 替代手写 sql.DB.Query: - DB 相关 bug 减少 80%(编译期捕获) - 重构 schema 时编译器告诉所有要改的地方 - 团队新人 onboarding:会 SQL 就能写,不需要学 ORM 语法 - 性能比 GORM 快 ~30%(无 reflection / 直接 prepared statement) ## 踩过的坑 1. **nullable 列变 pgtype**:column `NULL` 在 Go 是 `pgtype.Text` / `pgtype.Int4` 等,不是 string / int。 `String.Valid` 字段判 null。 `SET sqlc.go.emit_pointers_for_null_types = true` 改用 `*string` 等。 2. **改 schema 后忘 sqlc generate**:编译失败但不知道为啥。 把 `sqlc generate` 加进 `go generate` + Makefile / justfile。 3. **复杂 query plan 看不见**:写了个 SQL 跑很慢,没人在 ORM 层 优化。`EXPLAIN ANALYZE` 是基本功 —— ORM 怎么也帮不上忙。 4. **time.Time vs pgtype.Timestamptz**:默认 timestamptz 列生成 `pgtype.Timestamptz`。`Time` 字段拿值,`Valid` 判 null。 设 `emit_exact_table_names = true` 让生成名跟列对应。 5. **JOIN 列名冲突**:两表都有 `id` 列 → 生成 struct 字段冲突。 SQL 里手动 alias:`SELECT p.id AS post_id, u.id AS user_id ...`。