不是“拒绝 ORM”,而是在高性能、高可控、高可维护的后端系统中,重新把 SQL 放回架构中心。
一、为什么这套组合正在成为 Go 后端的重要范式
在 Go 服务端开发里,数据库访问层长期存在两条路线:
- 一条是 ORM 路线,强调模型映射、快速 CRUD、降低 SQL 编写门槛。
- 一条是显式 SQL 路线,强调查询可控、性能可预测、问题可定位。
在中小项目、后台管理系统、快速验证阶段,ORM 依然非常高效;但一旦进入下面这些场景,ORM 往往会开始显露边界:
- 读写链路复杂,包含聚合、窗口函数、CTE、JSONB、批量操作
- 请求量大,P99 延迟和连接池稳定性比“开发省几行代码”更重要
- 数据库已经成为核心基础设施,需要可观测、可调优、可压测
- 团队需要对 SQL 执行计划、索引命中、事务隔离级别有明确控制权
这正是 sqlc + pgx + PostgreSQL 的优势区间。
它的核心思路很直接:
- SQL 由工程师显式编写。
sqlc 在编译前解析 SQL,生成类型安全的 Go 代码。
pgx 负责高性能 PostgreSQL 连接、事务、批处理和协议层能力。
- 应用层围绕查询、事务、索引、连接池、可观测性做工程化治理。
这套架构的价值,不在于“更潮”,而在于它把数据库访问从“黑盒调用”还原成“可设计、可审查、可验证的系统能力”。
二、Zero-ORM 的本质:把运行时问题前移到设计期和编译期
2.1 ORM 的主要问题,不只是性能
很多文章一提 ORM,就只谈反射开销。其实在真实项目里,ORM 更大的问题往往是以下三类。
2.1.1 抽象泄漏
数据库本身是声明式系统,索引、锁、隔离级别、执行计划、排序、聚合、回表、并发控制都是真实存在的。ORM 只能抽象简单 CRUD,抽象不了复杂查询本质。
一旦业务走向复杂:
- 你还是要写 SQL
- 你还是要看
EXPLAIN ANALYZE
- 你还是要理解死锁、锁等待、慢查询
也就是说,复杂性没有消失,只是先被隐藏,后被放大。
2.1.2 运行时不确定性
典型 ORM 风格代码如下:
var user User
err := db.Where("email = ?", email).First(&user).Error
这段代码看起来很简洁,但许多风险只会在运行时暴露:
- 字段名与数据库不一致
- 生成 SQL 与预期不一致
- 关联加载导致 N+1 查询
- 某个条件触发全表扫描
NULL/零值映射不符合业务预期
而 sqlc 的思路是:让 SQL 先被解析,再生成 Go 代码,尽量把错误前移。
2.1.3 性能不可预测
高并发系统最怕的不是“慢一点”,而是“某些场景突然慢很多”。
ORM 常见性能问题包括:
- 反射和对象映射增加 CPU 与内存分配
- 自动关联加载带来不可见的额外查询
- 动态 SQL 生成导致调试和复盘困难
- 批量写入、批量更新能力弱,容易退化为循环执行
对于以 API 延迟、吞吐和稳定性为核心目标的系统来说,可预测性通常比语法糖更重要。
三、sqlc 的原理:它为什么能做到“类型安全 + 原生 SQL 控制力”
sqlc 不是 ORM,也不是 query builder。它本质上是一个 SQL 到 Go 的编译器。
工作链路如下:
schema.sql / migration files
+
queries.sql
|
v
sqlc analyze
|
v
生成 Go structs / params / methods / interfaces
它做的事情主要有三件:
- 解析数据库 schema
- 解析你写的 SQL 语句
- 根据查询结果集和参数列表生成静态类型代码
比如这段 SQL:
-- name: GetUserByID :one
SELECT id, email, name, status, created_at
FROM users
WHERE id = $1;
会生成类似这样的代码:
func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (User, error)
这意味着:
- 参数类型在 Go 层是确定的
- 返回结构在 Go 层是确定的
- 列数与扫描顺序由生成器固化
- SQL 和代码之间不再依赖人工维护
Scan(...)
所以它的优势不是“少写代码”,而是:
- 让 SQL 仍然是第一公民
- 让访问层仍然是静态类型
- 让调优仍然回到数据库本身
四、为什么 pgx 而不是只用 database/sql
database/sql 是优秀的标准接口,但如果核心数据库就是 PostgreSQL,那么 pgx 往往更适合作为生产驱动。
原因主要有四点:
4.1 PostgreSQL 能力暴露更完整
pgx 对 PostgreSQL 特性支持更直接,包括:
- 更好的类型系统支持
- Copy 协议
- Batch
- 事务控制
- 通知监听
- 更贴近 PostgreSQL 的错误码与连接能力
4.2 连接池能力成熟
pgxpool 提供了高可控的连接池参数:
- 最大连接数
- 最小空闲连接
- 最大连接生命周期
- 空闲超时
- 健康检查周期
这对高并发服务至关重要。
4.3 错误信息更适合生产排障
例如唯一约束冲突、死锁、序列化失败等 PostgreSQL 错误,在 pgx 下通常更容易根据错误码做业务映射。
4.4 和 sqlc 配合自然
sqlc 可以直接为 pgx/v5 生成代码,不需要额外适配层。
五、生产级架构设计:不是“会写 SQL”就够了
如果把 sqlc 仅仅理解为“替代 ORM 的代码生成器”,其实只用了它 30% 的价值。真正的升级在于架构层的治理。
一个面向高并发场景的 Zero-ORM 服务,建议采用下面的分层:
HTTP / gRPC Handler
|
v
Application Service
|
v
Repository / Store(sqlc generated queries)
|
v
pgxpool / pgx.Tx
|
v
PostgreSQL
5.1 Handler 层职责
- 参数校验
- 鉴权与租户识别
- 请求超时与取消信号透传
- 响应编码
不要在这一层直接拼 SQL 或写事务逻辑。
5.2 Service 层职责
- 编排业务流程
- 决定事务边界
- 执行业务规则
- 组合多个 Query 方法
事务应该尽量在 Service 层开启,因为事务本质上是业务一致性的边界,而不只是数据库操作的边界。
5.3 Repository / Store 层职责
- 持有
sqlc 生成的 Queries
- 暴露领域友好的读写方法
- 屏蔽复杂 SQL 的调用细节
- 统一错误转换
5.4 数据库层职责
- schema 设计
- 索引设计
- 慢查询治理
- 锁冲突治理
- 连接数和资源隔离
Zero-ORM 的关键思想是:每一层都只做自己该做的事,尤其不要让“数据库访问细节”散落在整个代码库里。
六、文章级升级后的完整实战:构建一个用户域服务
下面给出一套更接近生产环境的示例,而不是只停留在“单个 handler 调一个查询”。
6.1 目标业务场景
假设我们要做一个 B2C 用户中心,支持:
- 用户注册
- 按邮箱查询用户
- 用户资料更新
- 标签化搜索
- 用户状态软删除
- 高并发分页查询
额外要求:
- 邮箱唯一约束
- 支持 JSONB 扩展属性
- 查询必须可分页、可索引、可压测
- 服务层可以扩展为订单、风控、营销等多域协同
6.2 推荐项目结构
user-service/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── app/
│ │ └── server.go
│ ├── config/
│ │ └── config.go
│ ├── db/
│ │ ├── migrations/
│ │ │ └── 001_init.sql
│ │ ├── queries/
│ │ │ └── users.sql
│ │ ├── sqlc/
│ │ │ ├── db.go
│ │ │ ├── models.go
│ │ │ ├── querier.go
│ │ │ └── users.sql.go
│ │ └── postgres.go
│ ├── repository/
│ │ └── user_repository.go
│ ├── service/
│ │ └── user_service.go
│ ├── transport/
│ │ └── http/
│ │ └── user_handler.go
│ └── observability/
│ └── metrics.go
├── sqlc.yaml
└── go.mod
这个结构的关键点在于:
queries/ 只放 SQL
sqlc/ 只放生成代码
repository/ 做数据库访问封装
service/ 编排事务与业务规则
transport/ 负责协议接入
七、Schema 设计:高并发系统首先输在表结构,而不是 ORM
7.1 用户表设计
-- internal/db/migrations/001_init.sql
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
age INTEGER NOT NULL CHECK (age >= 0),
status VARCHAR(20) NOT NULL DEFAULT 'active',
profile JSONB NOT NULL DEFAULT '{}'::jsonb,
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL
);
CREATE UNIQUE INDEX uk_users_email_active
ON users (email)
WHERE deleted_at IS NULL;
CREATE INDEX idx_users_status_created_at
ON users (status, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX idx_users_profile_gin
ON users USING GIN (profile);
7.2 为什么这样设计
邮箱唯一索引使用部分索引
如果用软删除,直接对 email 建唯一索引会导致已删除用户占用邮箱。使用部分唯一索引可以只约束“未删除数据”。
分页索引走 (status, created_at DESC)
绝大多数后台列表页都会按状态过滤并按创建时间倒序排序,这种复合索引通常比“单列索引堆一堆”更有效。
JSONB 只用于扩展字段,不用于核心主键条件
profile 适合存放偏弱约束、变动频繁、需要灵活扩展的用户属性,例如标签、渠道、地区信息。核心过滤条件仍然应该尽量落在结构化列上。
version 字段用于乐观锁
高并发更新场景下,乐观锁是非常实用的工程策略,尤其在资料修改、状态流转、额度更新等场景。
八、SQL 设计:显式表达业务,而不是依赖框架猜测
8.1 查询定义
-- internal/db/queries/users.sql
-- name: CreateUser :one
INSERT INTO users (
email,
name,
age,
status,
profile
) VALUES (
$1, $2, $3, $4, $5
)
RETURNING id, email, name, age, status, profile, version, created_at, updated_at, deleted_at;
-- name: GetUserByID :one
SELECT id, email, name, age, status, profile, version, created_at, updated_at, deleted_at
FROM users
WHERE id = $1
AND deleted_at IS NULL;
-- name: GetUserByEmail :one
SELECT id, email, name, age, status, profile, version, created_at, updated_at, deleted_at
FROM users
WHERE email = $1
AND deleted_at IS NULL;
-- name: ListUsersByStatus :many
SELECT id, email, name, age, status, profile, version, created_at, updated_at, deleted_at
FROM users
WHERE status = $1
AND deleted_at IS NULL
AND created_at < $2
ORDER BY created_at DESC
LIMIT $3;
-- name: UpdateUserProfile :one
UPDATE users
SET
name = $2,
age = $3,
profile = $4,
version = version + 1,
updated_at = NOW()
WHERE id = $1
AND version = $5
AND deleted_at IS NULL
RETURNING id, email, name, age, status, profile, version, created_at, updated_at, deleted_at;
-- name: SoftDeleteUser :execrows
UPDATE users
SET
status = 'deleted',
deleted_at = NOW(),
updated_at = NOW(),
version = version + 1
WHERE id = $1
AND deleted_at IS NULL;
-- name: SearchUsersByProfile :many
SELECT id, email, name, age, status, profile, version, created_at, updated_at, deleted_at
FROM users
WHERE deleted_at IS NULL
AND profile @> $1::jsonb
ORDER BY created_at DESC
LIMIT $2;
-- name: CountUsersByStatus :one
SELECT COUNT(*)::bigint
FROM users
WHERE status = $1
AND deleted_at IS NULL;
许多示例文章默认用 LIMIT/OFFSET 分页,但在高并发、大表场景下,深分页的性能会越来越差。
因此这里使用:
AND created_at < $2
ORDER BY created_at DESC
LIMIT $3
这是一种典型的 keyset pagination 思路,优势是:
- 更容易命中排序索引
- 深分页不会随着 offset 增大而线性退化
- 适合时间倒序型业务列表
如果需要更严格的翻页稳定性,可进一步改为 (created_at, id) 双游标。
九、sqlc 配置:让生成代码成为“稳定接口”而不是脚手架垃圾
version: "2"
sql:
- engine: "postgresql"
schema:
- "internal/db/migrations"
queries:
- "internal/db/queries"
gen:
go:
package: "sqlc"
out: "internal/db/sqlc"
sql_package: "pgx/v5"
emit_json_tags: true
emit_interface: true
emit_empty_slices: true
emit_result_struct_pointers: false
emit_methods_with_db_argument: false
overrides:
- db_type: "uuid"
go_type: "github.com/google/uuid.UUID"
- db_type: "pg_catalog.timestamptz"
go_type: "time.Time"
- db_type: "jsonb"
go_type: "[]byte"
9.1 配置建议
emit_interface: true
便于 Service 层做接口依赖和单测 mock。
emit_empty_slices: true
避免空查询返回 nil slice,简化 JSON 编码和调用方判断。
jsonb -> []byte
最稳妥,保留数据库原始值;业务层可以决定是否映射为结构体。
十、数据库初始化:连接池配置决定系统上限
10.1 pgxpool 初始化
// internal/db/postgres.go
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Config struct {
DSN string
MaxConns int32
MinConns int32
MaxConnLifetime time.Duration
MaxConnIdleTime time.Duration
HealthCheckPeriod time.Duration
}
func NewPool(ctx context.Context, cfg Config) (*pgxpool.Pool, error) {
poolCfg, err := pgxpool.ParseConfig(cfg.DSN)
if err != nil {
return nil, fmt.Errorf("parse pg config: %w", err)
}
poolCfg.MaxConns = cfg.MaxConns
poolCfg.MinConns = cfg.MinConns
poolCfg.MaxConnLifetime = cfg.MaxConnLifetime
poolCfg.MaxConnIdleTime = cfg.MaxConnIdleTime
poolCfg.HealthCheckPeriod = cfg.HealthCheckPeriod
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return nil, fmt.Errorf("new pg pool: %w", err)
}
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
return pool, nil
}
10.2 连接池不是越大越好
很多系统一遇到慢请求,就把连接池调大,这是常见误区。
连接数过大可能导致:
- PostgreSQL 进程上下文切换上升
- 锁竞争放大
- 内存占用升高
- 慢 SQL 被并发放大,整体雪崩更快
更合理的思路是:
- 先控制单请求 SQL 数量和执行时间
- 再根据数据库实例 CPU、业务峰值、SQL 类型设定池大小
- 用压测数据反推最佳区间
经验上,OLTP 型服务的应用连接池通常应远小于很多团队的直觉值。
十一、Repository 封装:避免 sqlc 生成代码直接泄漏到业务层
11.1 Repository 实现
// internal/repository/user_repository.go
package repository
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
dbsqlc "your-project/internal/db/sqlc"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrEmailAlreadyExist = errors.New("email already exists")
ErrVersionConflict = errors.New("version conflict")
)
type UserRepository struct {
pool *pgxpool.Pool
q *dbsqlc.Queries
}
func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
return &UserRepository{
pool: pool,
q: dbsqlc.New(pool),
}
}
func (r *UserRepository) Create(ctx context.Context, arg dbsqlc.CreateUserParams) (dbsqlc.User, error) {
user, err := r.q.CreateUser(ctx, arg)
if err != nil {
if isUniqueViolation(err) {
return dbsqlc.User{}, ErrEmailAlreadyExist
}
return dbsqlc.User{}, fmt.Errorf("create user: %w", err)
}
return user, nil
}
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (dbsqlc.User, error) {
user, err := r.q.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbsqlc.User{}, ErrUserNotFound
}
return dbsqlc.User{}, fmt.Errorf("get user by email: %w", err)
}
return user, nil
}
func (r *UserRepository) UpdateProfile(ctx context.Context, arg dbsqlc.UpdateUserProfileParams) (dbsqlc.User, error) {
user, err := r.q.UpdateUserProfile(ctx, arg)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbsqlc.User{}, ErrVersionConflict
}
return dbsqlc.User{}, fmt.Errorf("update user profile: %w", err)
}
return user, nil
}
func (r *UserRepository) Delete(ctx context.Context, id uuid.UUID) error {
rows, err := r.q.SoftDeleteUser(ctx, id)
if err != nil {
return fmt.Errorf("soft delete user: %w", err)
}
if rows == 0 {
return ErrUserNotFound
}
return nil
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
11.2 为什么不要让 Handler 直接依赖 sqlc.Queries
因为生成代码是“数据库接口”,不是“业务接口”。
如果业务层直接依赖:
- 错误码会四处散落
- 事务逻辑会下沉到 Controller
- 领域语言会被 SQL 参数结构污染
- 后续做缓存、审计、重试、熔断会很难统一
Repository 的价值在于隔离数据库实现细节,而不是重复造轮子。
十二、Service 层:事务边界、并发一致性、业务编排
12.1 业务服务实现
// internal/service/user_service.go
package service
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
dbsqlc "your-project/internal/db/sqlc"
"your-project/internal/repository"
)
type UserService struct {
pool *pgxpool.Pool
repo *repository.UserRepository
}
type RegisterUserRequest struct {
Email string
Name string
Age int32
Profile map[string]any
}
type UpdateUserRequest struct {
ID string
Name string
Age int32
Version int64
Profile map[string]any
}
func NewUserService(pool *pgxpool.Pool, repo *repository.UserRepository) *UserService {
return &UserService{
pool: pool,
repo: repo,
}
}
func (s *UserService) Register(ctx context.Context, req RegisterUserRequest) (dbsqlc.User, error) {
profileBytes, err := json.Marshal(req.Profile)
if err != nil {
return dbsqlc.User{}, fmt.Errorf("marshal profile: %w", err)
}
return s.repo.Create(ctx, dbsqlc.CreateUserParams{
Email: req.Email,
Name: req.Name,
Age: req.Age,
Status: "active",
Profile: profileBytes,
})
}
func (s *UserService) UpdateProfile(ctx context.Context, req UpdateUserRequest) (dbsqlc.User, error) {
profileBytes, err := json.Marshal(req.Profile)
if err != nil {
return dbsqlc.User{}, fmt.Errorf("marshal profile: %w", err)
}
userID, err := uuid.Parse(req.ID)
if err != nil {
return dbsqlc.User{}, fmt.Errorf("parse user id: %w", err)
}
return s.repo.UpdateProfile(ctx, dbsqlc.UpdateUserProfileParams{
ID: userID,
Name: req.Name,
Age: req.Age,
Profile: profileBytes,
Version: req.Version,
})
}
上面这段代码展示了两个关键点:
- JSONB 的业务结构在 Service 层进行序列化,不耦合到 HTTP 层
- 乐观锁版本号作为业务参数显式进入更新语句,而不是依赖“最后写入覆盖”
12.2 乐观锁为什么是高并发写场景的常用方案
当多个请求并发更新同一用户资料时,如果没有版本控制,最后提交的事务可能覆盖前面的结果,造成隐蔽的数据丢失。
使用:
WHERE id = $1 AND version = $5
的好处是:
- 不会长时间持有行锁
- 冲突可以快速失败并提示前端刷新重试
- 更适合“读多写少、用户级并发冲突有限”的典型业务
如果是账户扣减、库存扣减这类强一致、高竞争写场景,则要根据模型考虑:
SELECT ... FOR UPDATE
- 原子更新条件
- 幂等表
- 事件表 / outbox
十三、HTTP 接入层:超时、错误映射、响应结构
13.1 Handler 示例
// internal/transport/http/user_handler.go
package http
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"your-project/internal/repository"
"your-project/internal/service"
)
type UserHandler struct {
svc *service.UserService
}
func NewUserHandler(svc *service.UserService) *UserHandler {
return &UserHandler{svc: svc}
}
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Name string `json:"name"`
Age int32 `json:"age"`
Profile map[string]any `json:"profile"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
user, err := h.svc.Register(ctx, service.RegisterUserRequest{
Email: req.Email,
Name: req.Name,
Age: req.Age,
Profile: req.Profile,
})
if err != nil {
switch {
case errors.Is(err, repository.ErrEmailAlreadyExist):
writeJSON(w, http.StatusConflict, map[string]string{"error": "email already exists"})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
return
}
writeJSON(w, http.StatusCreated, user)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
13.2 为什么每个请求都要有超时
数据库访问不是纯内存函数调用,它受到很多外部因素影响:
- 网络抖动
- 锁等待
- 连接池排队
- 慢查询
- 数据库负载飙升
如果不设置超时,请求会无边界占用 goroutine、连接、内存和上游调用资源,最后把局部慢故障放大成系统性阻塞。
十四、事务模型:sqlc 在复杂业务里怎么落地
14.1 通用事务封装
// internal/db/sqlc_tx.go
package db
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
dbsqlc "your-project/internal/db/sqlc"
)
func ExecTx(
ctx context.Context,
pool *pgxpool.Pool,
fn func(q *dbsqlc.Queries) error,
) error {
tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
_ = tx.Rollback(ctx)
}()
q := dbsqlc.New(tx)
if err := fn(q); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
14.2 实际业务案例:注册用户并写审计日志
func (s *UserService) RegisterWithAudit(ctx context.Context, req RegisterUserRequest) error {
profileBytes, err := json.Marshal(req.Profile)
if err != nil {
return fmt.Errorf("marshal profile: %w", err)
}
return db.ExecTx(ctx, s.pool, func(q *dbsqlc.Queries) error {
user, err := q.CreateUser(ctx, dbsqlc.CreateUserParams{
Email: req.Email,
Name: req.Name,
Age: req.Age,
Status: "active",
Profile: profileBytes,
})
if err != nil {
return err
}
return q.CreateAuditLog(ctx, dbsqlc.CreateAuditLogParams{
AggregateID: user.ID,
Action: "user_registered",
})
})
}
这个例子体现了两个生产实践原则:
- 一个业务动作里,必须一起成功的写操作要落在同一事务中
- 事务里只做必要数据库操作,不做外部 HTTP/RPC 调用
如果还要发消息给 Kafka/RabbitMQ,不要直接在事务里发,而应考虑 Outbox Pattern。
十五、高并发治理:真正影响吞吐的不是“用没用 ORM”一个变量
Zero-ORM 能带来性能收益,但高并发系统的上限从来不是一个工具单点决定的,而是整条链路共同决定。
15.1 影响吞吐的关键因素
- 单请求 SQL 数量
- SQL 是否走对索引
- 事务持续时间
- 连接池大小与数据库实例容量匹配度
- 是否有热点行更新
- 是否存在大 JSON 读写
- 是否做深分页
- 是否存在 N+1 查询
- 慢 SQL 比例
15.2 典型优化策略
减少往返次数
把能在一条 SQL 完成的过滤、聚合、排序尽量放到数据库完成,避免多次往返。
批量写入代替循环执行
对于导入、同步、批量创建等操作,优先考虑:
INSERT ... SELECT FROM UNNEST
pgx.Batch
COPY
缩短事务长度
事务内只做必要查询和更新,避免在事务中进行:
- 网络调用
- 大对象序列化
- 重 CPU 计算
- 非必要日志拼接
减少热点竞争
如果某个字段频繁被更新,例如登录计数、库存、额度、排名,可以考虑:
- 分片计数
- 异步聚合
- 缓存层吸收读压力
- 事件驱动更新
15.3 高并发下的数据库访问禁忌
- 在循环中一条条查用户详情
- 用
OFFSET 500000 做深分页
- 事务里串行访问多个外部服务
- 缺少请求超时和取消
- 只看平均延迟,不看 P95/P99
- 遇到慢请求先把连接池翻倍
十六、可扩展性设计:从单服务到多模块、多租户、多团队协作
16.1 按领域拆分 SQL 文件
推荐方式:
internal/db/queries/
├── users.sql
├── orders.sql
├── billing.sql
└── audits.sql
而不是把所有 SQL 都塞进一个大文件。
这样做的好处是:
- SQL review 更清晰
- 模块边界更自然
- 生成代码也更容易按领域理解
16.2 为大团队建立 SQL Review 机制
当 SQL 成为第一公民后,代码评审标准也要升级。建议在 Review 中检查:
- 是否走索引
- 是否会产生全表扫描
- 是否存在隐式类型转换
- 是否可能造成行锁竞争
- 返回列是否过多
- 是否存在重复查询
这比只看 Go 代码风格更重要。
16.3 多租户场景的设计建议
如果系统是 SaaS 多租户模型,强烈建议在核心表中显式加入 tenant_id,并遵循:
- 所有核心查询都带
tenant_id
- 复合索引优先考虑
(tenant_id, business_key)
- 不要把租户隔离只寄托在应用逻辑习惯上
这时 sqlc 的显式 SQL 反而更安全,因为租户过滤条件是明确写出来的,而不是依赖隐式 hook。
十七、观测与运维:没有可观测性,再好的 SQL 都只是“盲飞”
17.1 你至少要观察这些指标
应用侧:
- 请求 QPS
- 请求错误率
- P50 / P95 / P99 延迟
- 连接池活跃连接数
- 连接池等待时间
- 超时请求数
数据库侧:
- 慢查询数量
- 平均事务时长
- 锁等待
- 死锁次数
- buffer cache hit ratio
- 每秒提交/回滚事务数
17.2 建议开启的 PostgreSQL 能力
pg_stat_statements
- 慢查询日志
- 自动 vacuum 监控
- 锁等待监控
17.3 在文章和项目里都应该强调的一点
SQL 性能问题不要靠“感觉”判断,要靠:
- 指标
EXPLAIN (ANALYZE, BUFFERS)
- 压测回归
这也是 Zero-ORM 架构的工程价值之一:问题更容易直接定位到真实 SQL。
十八、压测方法:比“某框架快 2 倍”更重要的是测试方法可信
许多数据库框架对比文章的问题,不在结果,而在方法。
要做可信压测,建议固定这些变量:
- 相同 schema
- 相同索引
- 相同 SQL 语义
- 相同连接池参数
- 相同并发度
- 相同数据规模
- 分开测试读、写、混合场景
18.1 建议的压测维度
- 单点查询
- 分页查询
- 批量插入
- 乐观锁更新
- 事务写入
- JSONB 检索
18.2 你应该重点看什么
不是只看峰值 QPS,而是综合看:
- P99 是否稳定
- 错误率是否随并发上升
- 数据库 CPU 是否先打满
- 连接池是否开始排队
- 内存分配是否异常增大
因为生产系统真正关心的是“稳定吞吐”,不是 benchmark 截图。
十九、sqlc 与 ORM 的决策边界:不是非黑即白
下面给出更工程化的选型建议。
19.1 更适合 sqlc 的场景
- 核心交易链路
- 用户中心、订单中心、库存中心等核心服务
- SQL 复杂、索引敏感的系统
- 需要严格控制事务和查询的场景
- 团队愿意把 SQL 作为核心工程资产维护
19.2 更适合 ORM 的场景
- 后台管理系统
- 早期验证项目
- 简单 CRUD 为主
- 团队 SQL 基础薄弱,且短期不准备补齐
- 对数据库性能和执行计划控制要求不高
19.3 很多团队的现实解法是混合架构
实际项目里,并不一定要“全量去 ORM”。很常见的做法是:
- 核心链路使用
sqlc + pgx
- 非核心后台使用 ORM
- 迁移工具独立管理
- 报表类查询可单独优化
这种方式通常更务实。
二十、最佳实践清单
如果你准备在生产环境采用 Go + PostgreSQL + sqlc,建议至少做到下面这些事:
- 把 SQL 文件按领域拆分,而不是按“是否复杂”拆分。
- 所有数据库请求都透传
context.Context。
- 每个入口请求都设置超时。
- 明确事务边界,把事务放在 Service 层。
- 所有高频列表查询都设计专用索引。
- 深分页改为 keyset pagination。
- 针对热点更新引入乐观锁或行级锁策略。
- 用错误码映射业务错误,不把底层数据库错误直接暴露给上层。
- 接入慢查询、连接池和锁等待监控。
- 在 PR Review 中审 SQL,而不是只审 Go 代码。
二十一、常见坑与修复建议
21.1 NULL 处理不清晰
如果列允许为空,就要明确决定 Go 层表达方式:
- 指针类型
pgtype
- SQL 中
COALESCE
不要混用,否则 JSON 输出和业务判断会变得混乱。
21.2 把 JSONB 当万能字段
JSONB 很强,但不是逃避建模的理由。核心过滤字段和高频排序字段,仍然应该尽量结构化。
21.3 把 sqlc 生成代码当 Service 用
生成代码只是数据库访问层,不应该直接承载完整业务流程。
21.4 忽视 migration 管理
sqlc 不负责迁移。生产上建议配合:
golang-migrate
goose
atlas
并建立:
21.5 只关注查询,不关注更新竞争
大量系统线上问题不是查慢,而是写冲突、死锁、热点更新、事务堆积。
所以数据库架构设计必须同时关注:
二十二、总结:Zero-ORM 不是“回到原始时代”,而是进入成熟工程阶段
Go + PostgreSQL + sqlc 真正代表的,不是简单的“不要 ORM”,而是一种更成熟的后端工程观:
- 承认 SQL 是核心能力,而不是实现细节
- 承认数据库性能要靠索引、事务、执行计划和连接治理来解决
- 承认编译时类型安全比运行时碰运气更可靠
- 承认高并发系统需要的是可预测、可观测、可调优
如果你的系统只是简单 CRUD,ORM 依然是非常合理的选择。
但如果你的系统已经进入以下阶段:
- 数据量上升
- 并发量上升
- 查询复杂度上升
- 线上故障需要快速定位
- 团队希望建立稳定的数据库工程规范
那么 Zero-ORM 很可能不是“更复杂的选择”,而是“长期总成本更低的选择”。
一句话总结:
在高并发、重查询、重稳定性的 Go 服务中,sqlc + pgx + PostgreSQL 的价值,不只是性能更好,而是让数据库访问重新变得透明、可控、可验证、可工程化。
二十三、延伸阅读建议
sqlc 官方文档:关注 query annotations、type override、pgx 支持
pgx 官方文档:重点阅读 pool、tx、batch、copy protocol
- PostgreSQL 官方文档:重点阅读 index、locking、isolation、jsonb、explain
pg_stat_statements 和慢查询治理最佳实践
- keyset pagination、outbox pattern、optimistic locking 相关工程实践
如果把这套体系真正落到生产里,你会发现最重要的收获不是“少了 ORM”,而是团队终于开始把数据库访问当作架构能力来建设。