数据库访问:SQL、ORM 与连接池
数据库访问:SQL、ORM 与连接池
数据库是绝大多数后端服务的核心瓶颈。应用程序可以水平扩展,数据库通常不能(至少不能无限扩展)。这意味着每一次数据库交互的效率,都直接决定了整个系统的吞吐量上限。
Go 的 database/sql 包设计于 2011 年,在 ORM 横行的 Java 和 Python 生态之外,做出了一个截然不同的选择:提供一个薄薄的、面向接口的标准层,而不是一个完整的对象-关系映射框架。这个设计决策在今天依然有争议,但它背后的工程哲学值得深入理解。本章从 database/sql 的内部机制出发,覆盖 ORM、连接池调优、事务、迁移和测试的完整实践。
Level 1 · Go 的数据库访问格局
database/sql 的设计哲学
Go 的 database/sql 是一个驱动接口层,不是一个数据库功能实现。它定义了 driver.Driver、driver.Conn、driver.Stmt、driver.Rows 等接口,由具体的数据库驱动(pq、go-sqlite3、mysql 等)实现。应用代码通过 database/sql 的统一 API 与任何数据库交互,不需要关心底层驱动的细节。
这与 Java 的 JDBC 是同一种思路,但 Go 的实现更轻。database/sql 在接口层之上提供了连接池和上下文感知,这是它相比直接使用驱动接口最重要的附加价值。
为什么 Go 没有在标准库里内置 ORM?这是一个有意识的取舍。ORM 的核心价值是减少 CRUD 样板代码,但它的代价是一层额外的抽象——而这层抽象在高性能场景下经常成为障碍:生成的 SQL 难以优化,N+1 问题隐藏在便利的 API 之后,对事务和批量操作的支持往往不如手写 SQL 精细。Go 社区有一句话:"如果你不知道你的 ORM 生成的是什么 SQL,你就不应该用它。"
ORM 的争议
ORM 的支持者和反对者都有充分的理由:
支持 ORM 的理由:
- 大量减少样板代码:CRUD 操作不需要手写 SQL
- 数据库无关性:切换数据库时不需要重写 SQL
- 类型安全:对象字段直接映射到数据库列,减少
rows.Scan时的错误 - 模式迁移工具:许多 ORM 自带迁移功能
反对 ORM 的理由:
- 隐藏了 SQL 的复杂性,导致工程师不理解底层发生了什么
- 在复杂查询(JOIN、子查询、窗口函数)上生成的 SQL 质量参差不齐
- 性能敏感场景下,ORM 生成的 SQL 几乎总是需要手动优化
- 大型团队中,ORM 的 "magic" 让代码审查变得困难
Go 社区的现状是:GORM 是最流行的 ORM(GitHub 35k+ stars),适合快速开发;sqlc 代表了一种全新的方向——把 SQL 作为第一公民,从 SQL 查询生成类型安全的 Go 代码;sqlx 是 database/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...) 时,内部流程是:
- 加锁,从
freeConn取出一个空闲连接 - 如果没有空闲连接:
- 如果
numOpen < maxOpen(或maxOpen == 0),新建一个连接(调用driver.Open) - 否则,把当前请求加入
connRequests,释放锁,阻塞等待
- 如果
- 检查连接是否超过
maxLifetime或maxIdleTime,如果是则关闭并重新获取 - 在该连接上执行查询,返回结果
- 查询完成后,把连接归还给
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.Canceled 或 context.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 的基础类型(string、int64)无法表示 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.Valuer 和 sql.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 特有的功能:
CopyFrom:COPY 协议批量导入,速度远超 INSERTpgxpool:比database/sql更精细的连接池控制- Listen/Notify:PostgreSQL 的发布/订阅机制,用于实时通知
- Array 类型:原生支持
int[]、text[]等 PostgreSQL 数组类型 pgtype:更丰富的类型映射,包括hstore、jsonb、UUID 等
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() 获取连接池统计信息,特别是 WaitCount 和 WaitDuration。如果 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),加上扎实的性能调优知识,才能在不同规模的系统中都做出正确的数据库访问决策。