Go 基础体系 · 第 60/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go XORM 与 Bun:常见 ORM 方案、查询风格和选型比较
本文以 Go 1.26.4、XORM v1.3.11、Bun v1.2.15、PostgreSQL 17 和 pgx v5 为基准。升级时应检查生成 SQL,并在真实数据库验证事务、类型与 hook。
XORM 以 Engine、链式条件和 Session 为中心;Bun 建立在 database/sql 上,以 Query Builder、关系和 hook 组织查询。选型要比较 SQL 可预测性、事务审查和诊断能力。
1. ORM 解决什么,又不解决什么
ORM 能减少重复扫描、占位符处理和 CRUD 样板,并集中列映射。它不能替你设计约束、索引、隔离级别和租户权限,也不能保证 SQL 使用正确计划。
领域操作 ──► 仓储/服务 ──► ORM 查询对象 ──► database/sql/driver ──► PostgreSQL
│
└── SQL 日志、hook、扫描映射
框架对象并发安全不等于业务操作原子。“先读再写”仍会丢更新,须用事务、行锁、唯一约束或版本条件维护不变量。
2. 用数据库约束定义共同模型
为了公平比较,两套示例使用同一订单表。金额存最小货币单位,状态受 CHECK 限制,version 用于乐观并发,租户与外部订单号共同唯一。
CREATE TABLE orders (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
external_no text NOT NULL,
customer_id bigint NOT NULL,
amount_cents bigint NOT NULL CHECK (amount_cents >= 0),
status text NOT NULL CHECK (status IN ('pending', 'paid', 'cancelled')),
note text,
version bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, external_no)
);
CREATE INDEX orders_customer_time_idx
ON orders (tenant_id, customer_id, created_at DESC, order_id DESC);
自动建表适合原型,不应成为生产 schema 的唯一来源。独立迁移更容易评审锁、回滚和多版本兼容;tag 应匹配迁移。
3. XORM Engine 的生命周期
xorm.NewEngine 创建长期复用的 Engine,内部使用 database/sql 连接池。创建成功不代表数据库可达,启动时要 Ping;退出时 Close。每个请求重新创建 Engine 会制造多个池并耗尽数据库连接。
func OpenXORM(dsn string) (*xorm.Engine, error) {
engine, err := xorm.NewEngine("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("create xorm engine: %w", err)
}
engine.SetMaxOpenConns(20)
engine.SetMaxIdleConns(5)
engine.SetConnMaxLifetime(30 * time.Minute)
if err := engine.Ping(); err != nil {
_ = engine.Close()
return nil, fmt.Errorf("ping xorm database: %w", err)
}
return engine, nil
}
驱动在 main 明确导入。Engine 可并发调用;Session 状态不能跨请求复用。
4. XORM 映射、TableName 与 tag
结构体名受 mapper 影响,生产代码应明确表名和关键列。可空列使用指针或 nullable 类型,不能把 NULL 当空字符串。
type XOrder struct {
OrderID int64 `xorm:"pk autoincr 'order_id'"`
TenantID int64 `xorm:"notnull index 'tenant_id'"`
ExternalNo string `xorm:"notnull 'external_no'"`
CustomerID int64 `xorm:"notnull 'customer_id'"`
AmountCents int64 `xorm:"notnull 'amount_cents'"`
Status string `xorm:"notnull 'status'"`
Note *string `xorm:"'note'"`
Version int64 `xorm:"version 'version'"`
CreatedAt time.Time `xorm:"created 'created_at'"`
UpdatedAt time.Time `xorm:"updated 'updated_at'"`
}
func (XOrder) TableName() string { return "orders" }
created、updated 和 version 是框架行为,不是数据库约束。若 trigger 也更新相同列会形成双重规则,必须选定权威来源。
5. XORM 查询与存在性语义
Get 返回 (has, err),has == false 是正常零行;Find 填充切片;Insert、Update、Delete 返回影响行数。调用方不能只检查 error。
func LoadXOrder(ctx context.Context, engine *xorm.Engine, tenantID, orderID int64) (XOrder, error) {
var order XOrder
has, err := engine.Context(ctx).
Where("tenant_id = ? AND order_id = ?", tenantID, orderID).
Get(&order)
if err != nil {
return XOrder{}, fmt.Errorf("get xorm order: %w", err)
}
if !has {
return XOrder{}, ErrNotFound
}
return order, nil
}
始终使用 Context(ctx) 传播取消。条件值使用参数,列名和排序来自程序白名单。调试生成 SQL 时不能记录真实 DSN、支付信息或个人数据。
6. XORM 零值更新是高风险边界
XORM 的结构体更新可能默认忽略零值,因此把状态改为空串、金额改为零或布尔值改为 false 可能没有写入。应使用 Cols 明确列,或使用 map 表达确实要更新的值;同时保留租户和版本条件。
affected, err := engine.Context(ctx).
Table(new(XOrder)).
Where("tenant_id = ? AND order_id = ? AND version = ?", tenantID, orderID, version).
Cols("amount_cents", "note", "updated_at", "version").
Update(map[string]any{
"amount_cents": int64(0),
"note": nil,
"updated_at": time.Now().UTC(),
"version": version + 1,
})
if err != nil {
return fmt.Errorf("update xorm order: %w", err)
}
if affected != 1 {
return ErrConflict
}
AllCols 容易把未加载字段覆盖成零值。补丁 API 要区分“未出现”“设置零值”和“设为 NULL”,不能直接更新请求结构体。
7. XORM Session 与事务所有权
显式 NewSession 后由创建者 Close。Begin 成功后所有事务操作都通过同一个 Session;任何 Engine 调用都可能跑到事务外。错误路径先返回,让 defer 兜底回滚。
func PayWithXORM(ctx context.Context, engine *xorm.Engine, tenantID, orderID int64) error {
session := engine.NewSession()
defer session.Close()
session = session.Context(ctx)
if err := session.Begin(); err != nil {
return fmt.Errorf("begin xorm payment: %w", err)
}
defer func() { _ = session.Rollback() }()
result, err := session.Exec(`
UPDATE orders SET status = 'paid', version = version + 1, updated_at = now()
WHERE tenant_id = ? AND order_id = ? AND status = 'pending'`, tenantID, orderID)
if err != nil {
return fmt.Errorf("mark xorm order paid: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read xorm affected rows: %w", err)
}
if affected != 1 {
return ErrConflict
}
if err := session.Commit(); err != nil {
return fmt.Errorf("commit xorm payment: %w", err)
}
return nil
}
不要让多个 goroutine 并发操作同一 Session。事务会独占连接并持有锁,应保持短小;外部支付调用放在事务外,通过幂等键和 outbox 协调。
8. Bun DB 的生命周期和底层池
Bun 包装现有 *sql.DB。应用负责打开驱动、设置池、Ping 和关闭;bun.DB 长期复用。方言必须与服务端一致,否则标识符、占位符和 RETURNING 语义可能错误。
func OpenBun(ctx context.Context, dsn string) (*bun.DB, error) {
connector, err := pgx.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse bun database config: %w", err)
}
sqldb := stdlib.OpenDB(*connector)
sqldb.SetMaxOpenConns(20)
sqldb.SetMaxIdleConns(5)
sqldb.SetConnMaxLifetime(30 * time.Minute)
db := bun.NewDB(sqldb, pgdialect.New())
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping bun database: %w", err)
}
return db, nil
}
bun.NewDB 不建立独立连接池,底层 sql.DB 才是池。不要同时关闭同一底层池的多个包装对象,也不要按请求创建 DB。
9. Bun 模型、别名与显式列
Bun 通过 BaseModel 声明表名和别名。别名会出现在生成 SQL 中,关系和自定义查询应保持一致。API/领域模型与数据库模型分开能避免 tag、NULL 和内部列泄漏。
type BunOrder struct {
bun.BaseModel `bun:"table:orders,alias:o"`
OrderID int64 `bun:"order_id,pk,autoincrement"`
TenantID int64 `bun:"tenant_id,notnull"`
ExternalNo string `bun:"external_no,notnull"`
CustomerID int64 `bun:"customer_id,notnull"`
AmountCents int64 `bun:"amount_cents,notnull"`
Status string `bun:"status,notnull"`
Note *string `bun:"note"`
Version int64 `bun:"version,notnull"`
CreatedAt time.Time `bun:"created_at,notnull"`
UpdatedAt time.Time `bun:"updated_at,notnull"`
}
tag 不会替代数据库 CHECK 和 UNIQUE。明确 Column 可以减少无用传输,并防止新增敏感列后被 SELECT * 意外扫描。
10. Bun 查询构造器与 Scan
Bun Query Builder 保留 SQL 形状,同时对模型和占位符做映射。Scan(ctx) 执行并扫描;零行通常按具体 API 返回 sql.ErrNoRows,应使用 errors.Is 分类。
func ListBunOrders(ctx context.Context, db *bun.DB, tenantID, customerID int64, limit int) ([]BunOrder, error) {
if limit < 1 || limit > 100 {
return nil, errors.New("limit must be between 1 and 100")
}
orders := make([]BunOrder, 0, limit)
err := db.NewSelect().
Model(&orders).
Column("o.order_id", "o.external_no", "o.amount_cents", "o.status", "o.created_at").
Where("o.tenant_id = ?", tenantID).
Where("o.customer_id = ?", customerID).
OrderExpr("o.created_at DESC, o.order_id DESC").
Limit(limit).
Scan(ctx)
if err != nil {
return nil, fmt.Errorf("list bun orders: %w", err)
}
return orders, nil
}
动态列、方向和表达式不能接受用户字符串。排序键映射到程序常量,值仍用参数;真实数据库测试才验证语法和计划。
11. Bun 更新、零值与 RETURNING
Bun 的 Column、Set 和 OmitZero 行为必须明确选择。更新补丁时优先列出列,不要依赖结构体零值猜测。PostgreSQL 可用 RETURNING 一次取得新版本。
var current struct {
Version int64 `bun:"version"`
}
result, err := db.NewUpdate().
Model((*BunOrder)(nil)).
Set("amount_cents = ?", int64(0)).
Set("note = NULL").
Set("version = version + 1").
Set("updated_at = now()").
Where("tenant_id = ?", tenantID).
Where("order_id = ?", orderID).
Where("version = ?", version).
Returning("version").
Exec(ctx, ¤t)
if err != nil {
return fmt.Errorf("update bun order: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read bun affected rows: %w", err)
}
if affected != 1 {
return ErrConflict
}
表达式中的列名是代码常量;用户值用参数。更新整模型前要确认哪些字段已加载,否则部分查询得到的结构体可能覆盖未加载列。
12. Bun 事务与 RunInTx
RunInTx 把 begin、rollback 和 commit 组织成闭包,闭包返回错误即回滚。闭包内只使用传入的 bun.Tx,不能误用外层 DB。
func PayWithBun(ctx context.Context, db *bun.DB, tenantID, orderID int64) error {
err := db.RunInTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable},
func(ctx context.Context, tx bun.Tx) error {
result, err := tx.NewUpdate().
Model((*BunOrder)(nil)).
Set("status = 'paid'").
Set("version = version + 1").
Set("updated_at = now()").
Where("tenant_id = ? AND order_id = ?", tenantID, orderID).
Where("status = 'pending'").
Exec(ctx)
if err != nil {
return fmt.Errorf("mark bun order paid: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read bun affected rows: %w", err)
}
if affected != 1 {
return ErrConflict
}
return nil
})
if err != nil {
return fmt.Errorf("run bun payment transaction: %w", err)
}
return nil
}
Commit 网络错误同样可能产生未知结果。支付等外部副作用使用幂等键和 outbox,不在事务闭包内直接调用远程服务。
13. Relation、N+1 与批量加载
Bun 的 Relation 可声明 belongs-to、has-one 和 has-many,但预加载不保证每种关系都只发一条 SQL,生成 join 也可能放大行数。XORM 的关联通常更依赖手写查询或扩展。无论框架,都要统计一次请求的查询数。
订单列表可先批量取订单,再收集去重 customer ID 批量查询并映射。不要循环逐条 Get。has-many 分页要防止 LIMIT 作用于展开行;复杂报表宜用显式 SQL/CTE。
批量写设置上限并考虑参数数、报文和锁时间。超大批次占用连接、内存和 WAL,应分块并记录进度。
14. Context、并发和重试
每次 XORM 操作调用 Context(ctx),每次 Bun 执行传入 ctx。上游取消应贯穿等待连接、SQL 执行和扫描。数据库端 statement_timeout 可作为第二道边界,通常略短于请求预算以留出错误响应时间。
Engine、Bun DB 和底层池可并发使用,Session/Tx 不作为共享并发容器。死锁、序列化失败只重试整个事务;唯一冲突可能是业务结果。重试必须检查结构化数据库错误码、限制次数、加入带抖动退避,并受原 context 总 deadline 约束。
若超时发生在写入确认阶段,先按业务键查询最终状态。盲目重试非幂等 INSERT 可能创建重复订单,唯一约束和幂等 external number 是必要防线。
15. Hook、日志与失败模式
Hook 可统一观测查询,但影响全部路径。其中不得做慢 I/O、意外修改 SQL 或递归调用同一 DB。日志记录 operation、表、耗时、行数和错误类别,不记录完整参数。
启用 XORM 缓存时,缓存器随 Engine 复用并在关闭时释放;更新只能在事务提交后失效,否则回滚会留下假状态。还要定义 TTL、容量和跨实例一致性。Bun 默认不提供二级缓存,应用缓存应放在仓储外并显式失效。
常见故障是零值未更新、Session 泄漏、事务外写、N+1、NULL 扫描和丢失 context。诊断时联合查看 sql.DB.Stats()、慢查询、锁、goroutine profile 和 trace,不先扩大池。
SQL debug 只短期开启。DSN、token 和个人信息必须脱敏。错误用 %w 保留原因,由请求边界记录一次。
16. 测试策略与迁移验证
表驱动单元测试覆盖输入验证、领域映射、错误分类和零值补丁。真实 PostgreSQL 集成测试覆盖 tag 映射、NULL、RETURNING、唯一冲突、事务回滚、锁与取消。Mock 只能证明预期调用,不能证明 ORM 生成 SQL 能运行。
go test ./...
go test -race ./...
go test -run Integration -count=1 ./internal/store/...
go test -bench=. -benchmem ./internal/store/...
每次测试从迁移建立 schema;既测试空库全量迁移,也测试上一发布版本升级。升级 ORM/driver 时保存关键 SQL 快照用于人工比较,但避免把空格和别名等非语义格式锁成大量脆弱断言。
17. 性能、安全与生产部署
性能比较使用相同数据库、数据、索引、连接池和负载,测量分位延迟、查询数、池等待、CPU/WAL 和锁。复杂查询若 ORM 难以表达,应局部使用参数化 SQL。
应用账户只授予需要的 DML 权限,不拥有 schema;迁移使用独立身份。所有租户查询含 tenant 条件,敏感列显式选择。连接启用 TLS,凭据由秘密系统提供并轮换。发布采用 expand/contract,先迁移兼容结构,再部署应用,最后删除旧列。
新项目重视 SQL 可见性且需要 PostgreSQL 特性,可优先评估 Bun;维护 XORM 服务时先补测试和观测,再收紧 Cols、Session 与事务规则。选型应记录数据库、维护活跃度、升级成本、团队经验和退出方案。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go sqlc 实战:从 SQL 生成类型安全的数据访问代码
- 下一篇:Go 数据库迁移:golang-migrate、Atlas、回滚与零停机变更
- 延伸:Go GORM 完整指南:模型、查询、事务、关联与性能边界
- 延伸:Go Ent 实战:Schema、代码生成、关系、事务与隐私策略
- 延伸:Go sqlx 使用指南:保留 SQL 控制力并减少扫描样板
- 延伸:Go database/sql 基础:连接池、事务、Context 与 NULL
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论