第 46 章

数据库访问:SQL、ORM 与连接池

数据库访问:SQL、ORM 与连接池

数据库是绝大多数后端服务的核心瓶颈。应用程序可以水平扩展,数据库通常不能(至少不能无限扩展)。这意味着每一次数据库交互的效率,都直接决定了整个系统的吞吐量上限。

Go 的 database/sql 包设计于 2011 年,在 ORM 横行的 Java 和 Python 生态之外,做出了一个截然不同的选择:提供一个薄薄的、面向接口的标准层,而不是一个完整的对象-关系映射框架。这个设计决策在今天依然有争议,但它背后的工程哲学值得深入理解。本章从 database/sql 的内部机制出发,覆盖 ORM、连接池调优、事务、迁移和测试的完整实践。

Level 1 · Go 的数据库访问格局

database/sql 的设计哲学

Go 的 database/sql 是一个驱动接口层,不是一个数据库功能实现。它定义了 driver.Driverdriver.Conndriver.Stmtdriver.Rows 等接口,由具体的数据库驱动(pqgo-sqlite3mysql 等)实现。应用代码通过 database/sql 的统一 API 与任何数据库交互,不需要关心底层驱动的细节。

这与 Java 的 JDBC 是同一种思路,但 Go 的实现更轻。database/sql 在接口层之上提供了连接池上下文感知,这是它相比直接使用驱动接口最重要的附加价值。

为什么 Go 没有在标准库里内置 ORM?这是一个有意识的取舍。ORM 的核心价值是减少 CRUD 样板代码,但它的代价是一层额外的抽象——而这层抽象在高性能场景下经常成为障碍:生成的 SQL 难以优化,N+1 问题隐藏在便利的 API 之后,对事务和批量操作的支持往往不如手写 SQL 精细。Go 社区有一句话:"如果你不知道你的 ORM 生成的是什么 SQL,你就不应该用它。"

ORM 的争议

ORM 的支持者和反对者都有充分的理由:

支持 ORM 的理由

反对 ORM 的理由

Go 社区的现状是:GORM 是最流行的 ORM(GitHub 35k+ stars),适合快速开发;sqlc 代表了一种全新的方向——把 SQL 作为第一公民,从 SQL 查询生成类型安全的 Go 代码;sqlxdatabase/sql 的薄包装,只添加了扫描和命名参数等便利功能,不隐藏 SQL。

Level 2 · 原理:database/sql 的内部机制

驱动接口

database/sql 与具体数据库的交互完全通过 database/sql/driver 包中定义的接口完成。核心接口:

// 驱动本身:创建连接
type Driver interface {
    Open(name string) (Conn, error)
}

// 单个数据库连接
type Conn interface {
    Prepare(query string) (Stmt, error)  // 创建预处理语句
    Close() error
    Begin() (Tx, error)                  // 开启事务
}

// 预处理语句
type Stmt interface {
    Close() error
    NumInput() int
    Exec(args []Value) (Result, error)
    Query(args []Value) (Rows, error)
}

驱动通过 sql.Register("postgres", &pq.Driver{}) 注册自身(通常在驱动包的 init() 函数中),应用代码通过 sql.Open("postgres", dsn) 获取 *sql.DB

*sql.DB 不代表单个连接,它代表一个连接池。这是理解 database/sql 最关键的一点。

连接池的内部实现

database/sql 内置了一个连接池,其状态由以下字段描述(简化版):

type DB struct {
    // 驱动和 DSN
    driver driver.Driver
    dsn    string

    // 连接池状态
    mu           sync.Mutex
    freeConn     []*driverConn  // 空闲连接列表
    connRequests map[uint64]chan connRequest  // 等待连接的请求
    numOpen      int            // 当前打开的连接总数(含使用中)
    maxOpen      int            // 最大连接数(0 = 无限)
    maxIdle      int            // 最大空闲连接数
    maxLifetime  time.Duration  // 连接最大存活时间
    maxIdleTime  time.Duration  // 连接最大空闲时间
}

当应用调用 db.QueryContext(ctx, sql, args...) 时,内部流程是:

  1. 加锁,从 freeConn 取出一个空闲连接
  2. 如果没有空闲连接:
    • 如果 numOpen < maxOpen(或 maxOpen == 0),新建一个连接(调用 driver.Open
    • 否则,把当前请求加入 connRequests,释放锁,阻塞等待
  3. 检查连接是否超过 maxLifetimemaxIdleTime,如果是则关闭并重新获取
  4. 在该连接上执行查询,返回结果
  5. 查询完成后,把连接归还给 freeConn(或关闭,如果空闲连接已达 maxIdle

这个设计的关键优化是避免了每次查询都建立新的 TCP 连接——建立数据库连接的代价非常高(TCP 握手 + TLS 握手 + 数据库认证,通常需要几毫秒到几十毫秒)。

连接池的四个关键参数

db.SetMaxOpenConns(25)         // 最大打开连接数
db.SetMaxIdleConns(5)          // 最大空闲连接数
db.SetConnMaxLifetime(5 * time.Minute)  // 连接最大存活时间
db.SetConnMaxIdleTime(1 * time.Minute)  // 连接最大空闲时间

SetMaxOpenConns:这是最重要的参数。设置太高会耗尽数据库服务器的连接资源(PostgreSQL 的默认 max_connections 是 100);设置太低会导致应用等待连接,增加延迟。一个经验法则是:对于 CPU 密集的工作负载,maxOpenConns 大约等于 CPU 核心数;对于 I/O 密集的工作负载,可以适当更高。

SetMaxIdleConns:空闲连接保持 TCP 连接打开,减少新建连接的延迟,但也消耗数据库服务器的资源。maxIdleConns 应该小于等于 maxOpenConns。如果 maxIdleConns > maxOpenConns,多余的设置会被忽略。

SetConnMaxLifetime:连接的最大存活时间,超过后连接会被关闭并重建。这对于处理数据库重启(如 PostgreSQL primary failover)很重要——超过 maxLifetime 的连接会在归还时被关闭,而不是继续使用。

SetConnMaxIdleTime(Go 1.15+):连接最大空闲时间,超过后空闲连接会被清理。这有助于减少低峰期的资源占用,避免数据库服务器抱怨过多的空闲连接。

预处理语句与 SQL 注入

database/sql 的预处理语句(prepared statements)有两个作用:第一,防止 SQL 注入——参数化查询把 SQL 语句和参数分开传输到数据库,数据库驱动保证参数不会被解析为 SQL 代码;第二,提高性能——数据库可以缓存查询的执行计划,对于重复执行的相同查询,只需要传参数,不需要重新解析 SQL。

// 永远不要这样拼接 SQL:
query := "SELECT * FROM users WHERE name = '" + name + "'"

// 应该用参数化查询:
rows, err := db.QueryContext(ctx, "SELECT * FROM users WHERE name = $1", name)

database/sql 在使用 db.Query / db.QueryContext 时内部会创建和复用预处理语句(具体行为取决于驱动),但你也可以显式使用 db.PrepareContext 创建持久的预处理语句并复用。

事务与 Context 取消

database/sql 的事务通过 db.BeginTx(ctx, opts) 开启,返回 *sql.Tx,它绑定到一个固定的数据库连接(不经过连接池)。这意味着在事务期间,这个连接是独占的,无法被其他查询使用。

context.Context 的取消会传播到正在执行的数据库操作。当 HTTP 请求的 context 被取消(客户端断开连接),数据库查询也应该被取消,避免浪费数据库资源:

func getUserByID(ctx context.Context, db *sql.DB, id int64) (*User, error) {
    var u User
    err := db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = $1", id,
    ).Scan(&u.ID, &u.Name, &u.Email)
    if err == sql.ErrNoRows {
        return nil, ErrNotFound
    }
    return &u, err
}

如果 ctx 在查询执行期间被取消,QueryRowContext 会返回 context.Canceledcontext.DeadlineExceeded 错误。

Level 3 · 代码实践

仓储模式(Repository Pattern)

package repository

import (
    "context"
    "database/sql"
    "errors"
    "time"
)

var ErrNotFound = errors.New("record not found")

type User struct {
    ID        int64
    Name      string
    Email     string
    CreatedAt time.Time
    UpdatedAt time.Time
}

type UserRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
    var u User
    err := r.db.QueryRowContext(ctx,
        `SELECT id, name, email, created_at, updated_at
         FROM users WHERE id = $1`,
        id,
    ).Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt, &u.UpdatedAt)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, ErrNotFound
    }
    return &u, err
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
    var u User
    err := r.db.QueryRowContext(ctx,
        `SELECT id, name, email, created_at, updated_at
         FROM users WHERE email = $1`,
        email,
    ).Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt, &u.UpdatedAt)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, ErrNotFound
    }
    return &u, err
}

func (r *UserRepository) Create(ctx context.Context, u *User) error {
    return r.db.QueryRowContext(ctx,
        `INSERT INTO users (name, email, created_at, updated_at)
         VALUES ($1, $2, NOW(), NOW())
         RETURNING id, created_at, updated_at`,
        u.Name, u.Email,
    ).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
}

func (r *UserRepository) Update(ctx context.Context, u *User) error {
    result, err := r.db.ExecContext(ctx,
        `UPDATE users SET name = $1, email = $2, updated_at = NOW()
         WHERE id = $3`,
        u.Name, u.Email, u.ID,
    )
    if err != nil {
        return err
    }
    rows, err := result.RowsAffected()
    if err != nil {
        return err
    }
    if rows == 0 {
        return ErrNotFound
    }
    return nil
}

处理可空列:sql.NullString 等类型

数据库中的 NULL 是一个常见的痛点。Go 的基础类型(stringint64)无法表示 NULL,必须使用 sql.Null* 类型:

type Order struct {
    ID          int64
    UserID      int64
    Note        sql.NullString  // 可能为 NULL
    CompletedAt sql.NullTime    // 可能为 NULL
    Discount    sql.NullFloat64 // 可能为 NULL
}

func (r *OrderRepository) FindByID(ctx context.Context, id int64) (*Order, error) {
    var o Order
    err := r.db.QueryRowContext(ctx,
        `SELECT id, user_id, note, completed_at, discount
         FROM orders WHERE id = $1`, id,
    ).Scan(&o.ID, &o.UserID, &o.Note, &o.CompletedAt, &o.Discount)
    // ...

    // 使用时检查 Valid 字段
    if o.Note.Valid {
        fmt.Println("备注:", o.Note.String)
    }
    return &o, err
}

对于更复杂的情况,可以自定义实现 driver.Valuersql.Scanner 接口:

// 自定义类型:JSON 字段
type JSONMap map[string]interface{}

func (m JSONMap) Value() (driver.Value, error) {
    if m == nil {
        return nil, nil
    }
    b, err := json.Marshal(m)
    return string(b), err
}

func (m *JSONMap) Scan(src interface{}) error {
    if src == nil {
        *m = nil
        return nil
    }
    var b []byte
    switch v := src.(type) {
    case string:
        b = []byte(v)
    case []byte:
        b = v
    default:
        return fmt.Errorf("cannot scan type %T into JSONMap", src)
    }
    return json.Unmarshal(b, m)
}

批量插入优化

逐条插入大量数据时,每次 INSERT 都是一次网络往返,效率极低。批量插入(bulk insert)把多行数据打包成一条 SQL 语句:

func (r *OrderRepository) BulkCreate(ctx context.Context, orders []*Order) error {
    if len(orders) == 0 {
        return nil
    }

    // 构建参数化的批量 INSERT
    // INSERT INTO orders (user_id, amount) VALUES ($1,$2),($3,$4),...
    valueStrings := make([]string, 0, len(orders))
    valueArgs := make([]interface{}, 0, len(orders)*2)

    for i, o := range orders {
        valueStrings = append(valueStrings,
            fmt.Sprintf("($%d, $%d)", i*2+1, i*2+2))
        valueArgs = append(valueArgs, o.UserID, o.Amount)
    }

    query := fmt.Sprintf(
        "INSERT INTO orders (user_id, amount) VALUES %s",
        strings.Join(valueStrings, ","),
    )

    _, err := r.db.ExecContext(ctx, query, valueArgs...)
    return err
}

对于 PostgreSQL,还可以使用 COPY 协议(通过 pgx 库的 CopyFrom),它比 INSERT 快 5-10 倍,适合大批量数据导入:

import "github.com/jackc/pgx/v5"

func bulkInsertWithCopy(ctx context.Context, conn *pgx.Conn, orders []*Order) error {
    rows := make([][]interface{}, len(orders))
    for i, o := range orders {
        rows[i] = []interface{}{o.UserID, o.Amount}
    }

    _, err := conn.CopyFrom(ctx,
        pgx.Identifier{"orders"},
        []string{"user_id", "amount"},
        pgx.CopyFromRows(rows),
    )
    return err
}

分页:键集分页 vs 偏移分页

偏移分页(LIMIT x OFFSET y)在小数据量时简单好用,但在大偏移量时性能极差:

-- 数据库必须扫描并丢弃前 100000 行,然后返回 20 行
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 100000;

键集分页(Keyset pagination,也叫游标分页)通过记住上一页的最后一个元素,完全避免了大偏移扫描:

type PostPage struct {
    Posts      []*Post
    NextCursor string // base64 编码的游标
}

func (r *PostRepository) FindPage(ctx context.Context, cursor string, limit int) (*PostPage, error) {
    var query string
    var args []interface{}

    if cursor == "" {
        // 第一页,没有游标
        query = `SELECT id, title, created_at FROM posts
                 ORDER BY created_at DESC, id DESC
                 LIMIT $1`
        args = []interface{}{limit + 1} // 多取一条判断是否有下一页
    } else {
        // 解码游标,获取上一页最后一项的时间和 ID
        cursorTime, cursorID, err := decodeCursor(cursor)
        if err != nil {
            return nil, err
        }
        query = `SELECT id, title, created_at FROM posts
                 WHERE (created_at, id) < ($1, $2)
                 ORDER BY created_at DESC, id DESC
                 LIMIT $3`
        args = []interface{}{cursorTime, cursorID, limit + 1}
    }

    rows, err := r.db.QueryContext(ctx, query, args...)
    // ... 扫描结果,生成下一页游标

    page := &PostPage{Posts: posts}
    if len(posts) > limit {
        page.Posts = posts[:limit]
        last := posts[limit-1]
        page.NextCursor = encodeCursor(last.CreatedAt, last.ID)
    }
    return page, nil
}

键集分页的约束:不支持跳到任意页(只能前后翻),排序字段的组合必须唯一(这里用 (created_at, id) 保证唯一性)。

数据库迁移:golang-migrate

生产环境的数据库 schema 变更必须通过迁移工具管理,保证变更可追踪、可回滚。golang-migrate 是 Go 生态最成熟的迁移工具:

migrations/
├── 000001_create_users.up.sql
├── 000001_create_users.down.sql
├── 000002_add_user_roles.up.sql
└── 000002_add_user_roles.down.sql
-- 000001_create_users.up.sql
CREATE TABLE users (
    id         BIGSERIAL PRIMARY KEY,
    name       VARCHAR(255) NOT NULL,
    email      VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 000001_create_users.down.sql
DROP TABLE IF EXISTS users;

在 Go 代码中集成(通过嵌入迁移文件):

import (
    "embed"
    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/postgres"
    "github.com/golang-migrate/migrate/v4/source/iofs"
)

//go:embed migrations/*.sql
var migrationsFS embed.FS

func runMigrations(dsn string) error {
    source, err := iofs.New(migrationsFS, "migrations")
    if err != nil {
        return err
    }
    m, err := migrate.NewWithSourceInstance("iofs", source, dsn)
    if err != nil {
        return err
    }
    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        return err
    }
    return nil
}

使用 embed.FS 把 SQL 文件嵌入二进制,生产环境部署时不需要单独携带迁移文件。

testcontainers-go 做集成测试

Mock 数据库连接可以测试业务逻辑,但无法测试 SQL 的正确性(特别是复杂的 JOIN、子查询、数据库特定的行为)。testcontainers-go 在测试时启动一个真实的数据库 Docker 容器,测试完成后自动清理:

package repository_test

import (
    "context"
    "testing"

    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
)

func TestUserRepository(t *testing.T) {
    ctx := context.Background()

    // 启动 PostgreSQL 容器
    pgContainer, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:16-alpine"),
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2),
        ),
    )
    if err != nil {
        t.Fatal(err)
    }
    defer pgContainer.Terminate(ctx)

    // 获取连接字符串
    dsn, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        t.Fatal(err)
    }

    db, err := sql.Open("postgres", dsn)
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()

    // 运行迁移
    if err := runMigrations(dsn); err != nil {
        t.Fatal(err)
    }

    repo := NewUserRepository(db)

    // 测试 Create
    t.Run("Create and FindByID", func(t *testing.T) {
        user := &User{Name: "Alice", Email: "[email protected]"}
        if err := repo.Create(ctx, user); err != nil {
            t.Fatal(err)
        }
        if user.ID == 0 {
            t.Error("expected non-zero ID after create")
        }

        found, err := repo.FindByID(ctx, user.ID)
        if err != nil {
            t.Fatal(err)
        }
        if found.Email != user.Email {
            t.Errorf("got email %q, want %q", found.Email, user.Email)
        }
    })
}

testcontainers-go 的测试比 go test 慢(容器启动通常需要 2-5 秒),应该放在 integration build tag 下,与单元测试分开运行:

go test -tags integration ./...

Level 4 · 高级话题与边界情况

N+1 查询问题与 DataLoader 模式

N+1 问题是 ORM 最常见的性能陷阱,但在手写 SQL 时同样可能发生:

// 错误示例:N+1 查询
posts, _ := repo.FindAllPosts(ctx)          // 1 次查询
for _, post := range posts {
    author, _ := repo.FindUserByID(ctx, post.AuthorID)  // N 次查询
    post.Author = author
}

解决方案一:JOIN 查询,一次性获取所有数据:

SELECT p.id, p.title, u.id, u.name
FROM posts p
JOIN users u ON u.id = p.author_id
WHERE p.id = ANY($1)

解决方案二:DataLoader 模式——将多个对同一实体的单次查询合并(batch)为一次批量查询:

type UserLoader struct {
    db      *sql.DB
    mu      sync.Mutex
    batch   []*loadRequest
    timer   *time.Timer
}

type loadRequest struct {
    id     int64
    result chan *userResult
}

type userResult struct {
    user *User
    err  error
}

// Load 是调用方的接口,它返回一个 channel,结果会异步填充
func (l *UserLoader) Load(ctx context.Context, id int64) (*User, error) {
    req := &loadRequest{
        id:     id,
        result: make(chan *userResult, 1),
    }
    l.mu.Lock()
    l.batch = append(l.batch, req)
    // 设置一个短暂的延迟(如 1ms),在此期间收集更多请求
    if l.timer == nil {
        l.timer = time.AfterFunc(1*time.Millisecond, l.executeBatch)
    }
    l.mu.Unlock()

    res := <-req.result
    return res.user, res.err
}

func (l *UserLoader) executeBatch() {
    l.mu.Lock()
    requests := l.batch
    l.batch = nil
    l.timer = nil
    l.mu.Unlock()

    // 收集所有请求的 ID
    ids := make([]int64, len(requests))
    for i, r := range requests {
        ids[i] = r.id
    }

    // 一次性查询所有用户
    users, err := l.findByIDs(context.Background(), ids)
    userMap := make(map[int64]*User)
    for _, u := range users {
        userMap[u.ID] = u
    }

    // 分发结果
    for _, req := range requests {
        if err != nil {
            req.result <- &userResult{err: err}
        } else {
            req.result <- &userResult{user: userMap[req.id]}
        }
    }
}

DataLoader 是 GraphQL 生态中的经典模式(Facebook 最早在 Node.js 中实现),在 Go 的 GraphQL 服务(如使用 gqlgen)中同样广泛使用。

读写分离与连接路由

高流量场景下,通常有一个 primary 数据库(处理写操作)和多个 replica(处理读操作)。在 Go 中实现读写分离:

type DBCluster struct {
    primary  *sql.DB
    replicas []*sql.DB
    mu       sync.Mutex
    counter  int
}

// 写操作始终走 primary
func (c *DBCluster) Primary() *sql.DB {
    return c.primary
}

// 读操作轮询 replica(轮转负载均衡)
func (c *DBCluster) Replica() *sql.DB {
    if len(c.replicas) == 0 {
        return c.primary // 没有 replica 时降级到 primary
    }
    c.mu.Lock()
    idx := c.counter % len(c.replicas)
    c.counter++
    c.mu.Unlock()
    return c.replicas[idx]
}

在仓储层,SELECT 查询用 c.Replica(),INSERT/UPDATE/DELETE 用 c.Primary()。注意:replica 有复制延迟(通常几毫秒到几秒),如果应用需要"写后立即读"的一致性,刚写入的数据应该从 primary 读取。

乐观锁与版本号

乐观锁(optimistic locking)用于处理并发更新冲突,而不使用数据库锁(避免死锁和性能问题)。实现方式是在表中添加 version 列:

CREATE TABLE products (
    id      BIGSERIAL PRIMARY KEY,
    name    VARCHAR(255) NOT NULL,
    price   NUMERIC(10,2) NOT NULL,
    version INT NOT NULL DEFAULT 0
);

更新时,在 WHERE 子句中包含当前 version,如果另一个事务已经修改了这条记录,version 会不匹配,RowsAffected 为 0:

func (r *ProductRepository) Update(ctx context.Context, p *Product) error {
    result, err := r.db.ExecContext(ctx,
        `UPDATE products
         SET name = $1, price = $2, version = version + 1
         WHERE id = $3 AND version = $4`,
        p.Name, p.Price, p.ID, p.Version,
    )
    if err != nil {
        return err
    }
    rows, err := result.RowsAffected()
    if err != nil {
        return err
    }
    if rows == 0 {
        return ErrConflict // 并发修改冲突,调用方应重试
    }
    p.Version++ // 更新本地版本号
    return nil
}

乐观锁适合冲突概率低的场景(大多数时候只有一个客户端在修改同一条记录)。如果冲突频率很高,大量重试反而会带来更差的性能,此时应该使用悲观锁(SELECT ... FOR UPDATE)。

pgx:PostgreSQL 原生特性

pgx 是 PostgreSQL 的原生 Go 驱动,相比 lib/pq 提供了更多 PostgreSQL 特有的功能:

import (
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/jackc/pgx/v5/pgtype"
)

// 使用 pgxpool 而非 database/sql
pool, err := pgxpool.New(ctx, dsn)

// 监听 PostgreSQL 通知
conn, _ := pool.Acquire(ctx)
defer conn.Release()

_, err = conn.Exec(ctx, "LISTEN user_events")
for {
    notification, err := conn.Conn().WaitForNotification(ctx)
    if err != nil {
        break
    }
    fmt.Printf("Channel: %s, Payload: %s\n",
        notification.Channel, notification.Payload)
}

连接池调优:负载下的实践

理论上的连接池参数和实际生产环境往往有出入。以下是几个在负载下调优连接池的实践准则:

监控等待时间:通过 db.Stats() 获取连接池统计信息,特别是 WaitCountWaitDuration。如果 WaitDuration / WaitCount 超过 10ms,说明连接池太小,需要增加 MaxOpenConns

stats := db.Stats()
slog.Info("db pool stats",
    "open", stats.OpenConnections,
    "in_use", stats.InUse,
    "idle", stats.Idle,
    "wait_count", stats.WaitCount,
    "wait_duration", stats.WaitDuration,
    "max_idle_closed", stats.MaxIdleClosed,
    "max_lifetime_closed", stats.MaxLifetimeClosed,
)

不要把 MaxOpenConns 设为 0(无限):在突发流量下,这会让应用尝试打开成百上千个数据库连接,很可能超过 PostgreSQL 的 max_connections 限制,导致大量连接错误,形成雪崩。

连接池大小 ≠ 最大并发查询数:如果每个查询都很快(< 1ms),你可以用很少的连接支持很高的 QPS;如果查询很慢(> 100ms),你需要更多的连接才能支持相同的并发。公式:需要的连接数 ≈ 每秒查询数 × 平均查询时间(秒)

数据库访问是后端工程中最需要细心对待的领域之一。database/sql 提供了一个正确的抽象层级——足够低以让你控制 SQL,足够高以处理连接生命周期。理解它的内部机制,配合适合场景的工具(sqlx、sqlc、GORM),加上扎实的性能调优知识,才能在不同规模的系统中都做出正确的数据库访问决策。

本章评分
4.5  / 5  (3 评分)

💬 留言讨论