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" }

createdupdatedversion 是框架行为,不是数据库约束。若 trigger 也更新相同列会形成双重规则,必须选定权威来源。

5. XORM 查询与存在性语义

Get 返回 (has, err)has == false 是正常零行;Find 填充切片;InsertUpdateDelete 返回影响行数。调用方不能只检查 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 的 ColumnSetOmitZero 行为必须明确选择。更新补丁时优先列出列,不要依赖结构体零值猜测。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, &current)
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 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。