
Bun 的使用经验
Bun 的入门文档很好懂:定义结构体,写几个 bun tag,然后 NewSelect、NewInsert、NewUpdate。照着示例做,半小时就能把一张表增删改查一遍。
可项目真正复杂起来以后,问题通常不再是「左连接怎么写」,而是另外一组东西:事务里的 Repository 为什么突然不能复用?PATCH 请求中的零值到底表示更新成零,还是根本没传?一份用户结构为什么要在 Protobuf、业务层和数据库层复制三遍?PostgreSQL 的数组、JSONB、INET 扫描到自定义类型时,为什么总差最后一点?筛选条件越来越多以后,怎样避免每个接口手写一组 if,又不把 SQL 注入送到家门口?
这些问题很少出现在 ORM 的 Quick Start 里,因为它们不是某个函数怎么调用,而是你准备把边界画在哪里。这里不再重复模型、基础 CRUD、关联和事务的常规写法,只讨论一些其他的问题。
先换一个角度看 Bun
把 Bun 叫作 ORM 没有错,但这个名字很容易让人先想到 Active Record:一个模型包揽表结构、验证、关联、保存和业务行为。Bun 更有价值的一面其实是 SQL-first。它并不试图藏住 SQL,而是提供四个可以组合的能力:
- 从 Go 类型建立表和字段元数据
- 用 Query Builder 组合 SQL,并正确处理标识符和值
- 把数据库返回值扫描到 Go 值
- 通过
database/sql执行查询,并在前后挂接 Hook
Bun 不必成为架构中心。它可以只占据数据库边界:上面接 Repository 和业务对象,下面接 PostgreSQL 驱动。这样理解以后,后面的写法就不会显得是在和 ORM 较劲。
例如,筛选条件不是 Select 独有的东西。Bun 的 QueryBuilder 抽出 Where、WhereGroup、WherePK 等公共操作,同一个作用域可以用于查询、更新和删除。事务也不应该迫使所有方法接收具体的 *bun.DB,因为 bun.IDB 同时由数据库、事务和连接实现。自定义类型不用侵入查询代码,因为 database/sql 已经提供 sql.Scanner 与 driver.Valuer。
链式调用能写多长没什么要紧。更有用的是看清这些接缝,知道重复代码应该从哪里切开。
Repository 接收 bun.IDB
很多项目的 Repository 最初都长这样:
type ClientRepository struct {
db *bun.DB
}
func (r *ClientRepository) Create(ctx context.Context, client *Client) error {
_, err := r.db.NewInsert().Model(client).Exec(ctx)
return err
}平时没有问题,等业务要求创建用户、创建初始配置、写一条审计事件必须一起成功,麻烦就来了。RunInTx 传进来的是 bun.Tx,Repository 却把 *bun.DB 焊死在结构体里。有人会临时给每个方法加一个 tx 参数,有人会在事务中重新构造 Repository,还有人一不留神在事务回调里调用了原来的 r.db,结果其中一条 SQL 根本不在事务内。
Bun 已经提供了更窄的接口 bun.IDB。让数据方法依赖它,而不是依赖具体数据库:
type ClientRepository struct{}
func (ClientRepository) Create(
ctx context.Context,
db bun.IDB,
client *Client,
) error {
_, err := db.NewInsert().
Model(client).
Returning("*").
Exec(ctx)
return err
}调用方平时传 *bun.DB,事务中传 bun.Tx:
err := db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
if err := clients.Create(ctx, tx, client); err != nil {
return err
}
if err := audit.Append(ctx, tx, event); err != nil {
return err
}
return nil
})这个变化很小,却明确了一个重要边界:Repository 只需要能创建 Bun 查询并执行的对象,不负责开启或提交事务。事务属于业务用例,因为只有业务层知道哪些写操作必须一起成功。
还要小心闭包捕获。事务回调里所有数据库访问都必须沿参数 tx 传下去,不能偷偷使用外层 db。代码审查时可以把事务回调内禁止直接引用外层数据库变量当作明确规则。否则代码看起来包在 RunInTx 里,实际保证却已经破了。
不要把查询条件写成一片 if
后台列表一开始可能只有 status,后来逐渐增加用户 ID、名称模糊匹配、创建时间范围、标签、租户、排序和分页。最直接的写法是:
q := db.NewSelect().Model(&users)
if req.Status != 0 {
q.Where("status = ?", req.Status)
}
if req.Name != "" {
q.Where("name ILIKE ?", "%"+req.Name+"%")
}
// 后面还有十几个 if一两个条件时它最清楚,十几个接口各抄一份以后就不是了。问题并不止是因为 if 难看,而是租户隔离、软删除、时间边界和列名白名单开始散落。某个列表漏了一条 tenant_id,代码仍能正确运行,只是顺便把别人的数据也查了出来。
第一步可以把稳定的业务约束写成查询修饰器:
type Scope func(bun.QueryBuilder) bun.QueryBuilder
func Tenant(id string) Scope {
return func(q bun.QueryBuilder) bun.QueryBuilder {
return q.Where("? = ?", bun.Ident("tenant_id"), id)
}
}
func Alive() Scope {
return func(q bun.QueryBuilder) bun.QueryBuilder {
return q.Where("deleted_at IS NULL")
}
}
func ApplyScopes(q bun.QueryBuilder, scopes ...Scope) bun.QueryBuilder {
for _, scope := range scopes {
q = scope(q)
}
return q
}go.x2ox.com/sorbifolia/bunpgd@v1.1.0/builder 又往前走了一步:通过反射读取筛选结构体上的 query tag,把字段转换成 QueryBuilder 操作。同一套生成结果可以交给 Select、Update 和 Delete,分页这类只适用于 Select 的能力,则通过自定义 BunQueryBuilder 方法拿到具体查询后再处理。
可以借鉴这个思路,但我不建议把 URL 参数中的列名和操作符直接拼进 SQL。安全的设计应该是代码声明允许查询的列与操作符,请求只提供值:
type UserFilter struct {
IDs []string `query:"ids,op:in,key:id"`
Name string `query:"name,op:like,attr:right,key:name"`
Status *int32 `query:"status,op:eq,key:status"`
CreatedAt time.Time `query:"createdAfter,op:geq,key:created_at"`
Page *Page
}这里 key:id 和 op:in 是编译期写在代码里的白名单,HTTP 请求没有机会把 id 换成任意 SQL 表达式。列名使用 bun.Ident,值仍走占位符。只有操作符需要经过固定映射,例如 eq -> =、overlap -> &&,不能接受原始字符串。
这种查询构造器最容易栽在零值上。reflect.Value.IsZero() 很方便,但它会同时忽略 false、0 和空字符串。于是「筛选未启用用户」会悄悄变成「不筛选启用状态」。筛选结构应优先使用指针或显式 Optional 类型:nil 表示没传,&false 才表示筛选 false。这不是反射技巧,而是接口存在性语义。
共用的是字段,不是 Bun 表模型
典型 Go 服务里,同一个用户经常有三份结构:
userpb.User // gRPC / API
domain.User // 业务层
model.User // PostgreSQL / Bun分层本身没有错。当接口结构、领域结构和持久化结构确实不同,三份类型可以保护边界。问题在于很多项目只是机械复制:三边都有 ID、Name、Status、CreatedAt,每加一个字段就改三处,再补两组转换函数。转换代码没有承载业务规则,只负责把左手搬到右手。
若服务主要通过 Protobuf 定义接口,而且数据库行与接口消息高度重合,没有必要再复制一遍所有字段。但 Protobuf message 本身不是完整的 Bun 表模型。它没有 bun.BaseModel,也不应该为了数据库把 Bun 依赖塞进生成代码。
实用的做法是在数据层加一层很薄的壳:
type Client struct {
bun.BaseModel `bun:"table:client,alias:client"`
*serverv1.Client
}serverv1.Client 保存实际字段,外层 Client 只补表名、别名,以及数据库系统列或 Relation。读取单行时可以这样初始化:
row := &Client{Client: new(serverv1.Client)}
err := db.NewSelect().
Model(row).
Column("client.*").
Where("client.id = ?", id).
Scan(ctx)
if err != nil {
return nil, err
}
return row.Client, nil这层壳依然是一份数据库类型,但它没有复制 id、name、status 等字段,也不需要维护转换函数。它只描述 Protobuf 无法表达、也不该表达的数据库信息。
职责可以按下面的方式拆:
.proto决定字段身份、编号和接口存在性protoc-gen-gotag把 Bun 字段 tag 加到生成字段- 外层结构决定表名、别名、系统列和 Relation
- Repository 决定投影、条件、事务和锁
- 自定义扫描器处理 PostgreSQL 与 Protobuf 类型的差异
- 业务层仍然决定哪些字段允许写、哪些数据允许返回
这么做主要是为了减少字段漂移。接口新增 display_name 后,生成代码里已经有这个字段,数据库迁移与查询投影若没有同步,测试会更容易暴露问题。手写三份模型时,常见的情况是数据库已经有列,转换函数却忘了赋值,接口只能默默返回零值。
但共用类型有一个硬前提,它们表达的必须真是同一种东西。密码哈希、内部风控分数、删除审计字段通常不应进入公共 API;聚合响应、跨表视图也不等于单表记录。不要为了少写字段,把私有列塞进 Protobuf 再指望调用者自觉不看。外层薄壳解决的是表语义,不会替你解决数据边界。
Protobuf 的 Bun tag 怎么生成
protoc-gen-go 本身没有任意追加 Go struct tag 的开关,这件事可以交给 protoc-gen-gotag。它提供 tagger/tagger.proto,字段写法是:
syntax = "proto3";
package account.v1;
import "tagger/tagger.proto";
option go_package = "example.com/project/gen/account/v1;accountv1";
message Client {
uint64 id = 1 [(tagger.tags) = "bun:\",pk,autoincrement\""];
string name = 2 [(tagger.tags) = "bun:\"name,notnull\""];
optional int32 status = 3 [(tagger.tags) = "bun:\"status,notnull\""];
repeated string roles = 4 [(tagger.tags) = "bun:\"roles,array\""];
ClientPreferences preferences = 5 [(tagger.tags) = "bun:\"preferences,type:jsonb\""];
}使用 Buf 时,在 buf.yaml 里直接引入 BSR 上的模块:
version: v2
deps:
- buf.build/srikrsna/protoc-gen-gotag生成顺序仍然重要。protoc-gen-gotag 会修改已经存在的 Go 生成文件,所以要先运行 protoc-gen-go,再运行 gotag;不能指望两个插件在同一次并行生成中互相看到输出。Buf 的生成配置也应把这两个阶段拆开。
处理后的字段大致如下:
type Client struct {
Id uint64 `Protobuf:"..." json:"id,omitempty" bun:",pk,autoincrement"`
Name string `Protobuf:"..." json:"name,omitempty" bun:"name,notnull"`
Status *int32 `Protobuf:"..." json:"status,omitempty" bun:"status,notnull"`
Roles []string `Protobuf:"..." json:"roles,omitempty" bun:"roles,array"`
Preferences *ClientPreferences `Protobuf:"..." json:"preferences,omitempty" bun:"preferences,type:jsonb"`
}gotag 只负责把字符串准确放进 Go tag,不会检查 Bun 语义。重复列名、错误的 array、把 message 标成普通标量等问题,仍要靠生成测试和真实数据库测试发现。它也会改写生成文件,所以工具版本必须固定;CI 重新生成后检查工作区,避免开发者本机版本不同造成 tag 漂移。
数据库默认值和 Protobuf 默认值也不是一回事。autoincrement 表示 INSERT 后由数据库产生 ID,message 中的 0 只表示当前还没有这个值。插入时使用 Returning("*") 扫回外层模型,再把其中的 Protobuf message 返回给接口层,才能保证调用方拿到数据库最终保存的内容。
别忘了 Protobuf 正在走向 Opaque API
上面的嵌入方案仍依赖 Bun 通过反射访问 Protobuf 中导出的 Go 字段。外面包一层 Client 不会改变这一点。在 Protobuf 的 Open Struct API 下可行,到了 Opaque API 就不成立。
Protobuf 官方现在区分 Open、Hybrid 和 Opaque 三种 Go API。proto2、proto3 与 Edition 2023 默认仍生成 Open Struct API;Edition 2024 及以后默认使用 Opaque API,消息字段被隐藏,只能通过 getter、setter 和 builder 访问。Bun 无法靠普通结构体反射把一行数据扫描进隐藏字段。
如果要让 Bun 继续通过外层结构扫描嵌入的 Protobuf 字段,需要显式使用 API_OPEN 或 API_HYBRID,并把这个选择写进生成配置与兼容性测试。Hybrid 保留导出字段,同时提供迁移到 Opaque 的访问器,是相对现实的过渡方案。官方文档也明确说明,Edition 2024+ 默认 Opaque,而 Hybrid 仍暴露结构字段。
这是一笔真实的架构债。共享模型减少了转换,却让数据库层依赖 Protobuf 的具体 Go 生成布局。将来若全面迁移 Opaque API,要么放弃直接扫描,生成专用 row model;要么写一个实现 Bun Model 扫描协议的适配层,通过 setter 填字段。决定共用结构之前,应该把这项迁移成本写进设计文档,而不是假设 .pb.go 永远长现在这样。
gotag 修改生成文件是流水线的一部分,不等于允许手工修改 .pb.go。protoc-gen-go 和 protoc-gen-gotag 都要固定版本,并在 CI 中重新生成后检查工作区是否干净。否则某次本机工具升级,字段 tag 顺序或默认 API Level 改了,数据库映射也会跟着漂移。
sql.Scanner 与 driver.Valuer 只解决字段值
Bun 建立在 database/sql 之上,因此自定义数据库类型最稳定的接缝仍是两个标准接口:
sql.Scanner:把驱动返回的nil、[]byte、string、time.Time等值写入对象driver.Valuer:把对象转换成驱动能够发送的基础值
假设 UserPreferences 在 PostgreSQL 中保存为 JSONB,可以在 Protobuf 生成包中另写一个文件,也可以由项目自己的小生成器产生:
var _ sql.Scanner = (*UserPreferences)(nil)
var _ driver.Valuer = (*UserPreferences)(nil)
func (m *UserPreferences) Scan(src any) error {
if m == nil {
return errors.New("UserPreferences.Scan on nil receiver")
}
switch src := src.(type) {
case nil:
proto.Reset(m)
return nil
case []byte:
return protojson.Unmarshal(bytes.Clone(src), m)
case string:
return protojson.Unmarshal([]byte(src), m)
default:
return fmt.Errorf("scan UserPreferences from %T", src)
}
}
func (m *UserPreferences) Value() (driver.Value, error) {
if m == nil {
return nil, nil
}
b, err := protojson.MarshalOptions{
UseProtoNames: true,
}.Marshal(m)
if err != nil {
return nil, err
}
return b, nil
}这让同一个 UserPreferences 既能作为 Protobuf 消息在接口中传输,又能作为 User 的字段进出 JSONB。注意,它解决的是 preferences 这一列如何编解码,不是让顶层 User 自动知道表名、主键和所有列。
实现时有几个细节不能省:
Scan(nil)必须定义清楚。上例把 SQLNULL复位为空消息;如果业务需要区分NULL与空对象,应让外层字段保持*UserPreferences,并在扫描器或字段分配逻辑中保留 nil;- 驱动给出的
[]byte生命周期可能只到下一次Scan,要保存原始字节就必须复制; protojson使用 Protobuf JSON 语义,不等同于普通encoding/json,字段名、枚举和 64 位整数表示都要纳入兼容测试;Value应返回驱动支持的类型,不要直接返回任意 struct;- 错误必须包含目标类型和源类型,否则批量扫描时很难找到坏数据。
如果数据库只把嵌套消息当不透明载荷,也可以用 proto.Marshal 存入 BYTEA。二进制更紧凑,保留 Protobuf 的字段编号演进语义,却失去 PostgreSQL 对 JSONB 的索引与查询能力。选 JSONB 还是 Protobuf binary,不是性能小技巧,而是你后续真的不需要查询它吗?
三方类型不能加方法,就从 Dialect 下手
自定义 Protobuf message 属于自己的 Go 包,可以让生成器补 Scan 和 Value。google.Protobuf.Timestamp、netip.Addr、UUID 库类型等属于别人的包,Go 不允许为非本地类型增加方法。此时通常有三条路:
- 定义本地包装类型,在包装上实现
Scanner/Valuer; - Repository 显式转换,数据库模型和接口模型分开;
- 在 Bun 建表元数据时替换字段的扫描器和 Appender。
bunpgd 选择了第三条,实现自己的 PostgreSQL Dialect,在 OnTable 中遍历 schema.Table 的字段,根据 Go 反射类型重新判断 PostgreSQL 类型,再替换 schema.Field.Scan 与 schema.Field.Append。这样 net.IP 可以自动映射 INET,net.IPNet 映射 CIDR,slice 映射一维数组,实现了接口的类型还能优先走 sql.Scanner、encoding.TextUnmarshaler 或 JSON 接口。
这个设计展示了 Bun 很深的一条扩展缝:模型元数据被缓存之前,Dialect 有机会改变每个字段如何建表、编码和扫描。它可以让大量生成模型免写重复方法,尤其适合同一套 PostgreSQL 类型规则覆盖整个项目。
代价也很明确 schema.Field、Appender、Scanner 和 Dialect Feature 属于比普通 CRUD 更底层的 API,升级 Bun 时必须跑完整兼容测试;自定义 PostgreSQL Dialect 还会失去官方 pgdialect 后续增加的行为,除非持续同步。
还有安全问题,Bun 的 schema.QueryAppender 可以直接向 SQL 缓冲区追加字节,它比 driver.Valuer 更接近 SQL 编译器内部。如果自己写字符串、数组或 HSTORE 转义,只漏一个引号就是数据错误甚至注入问题。能用参数与 driver.Valuer 解决时,优先走驱动;只有确实需要生成 PostgreSQL 特殊语法时才写 Appender,并用包含引号、反斜杠、空字符串、NULL、多字节文本的测试向量围住它。
数组、JSONB、枚举和时间别追求一套万能映射
Protobuf 与 PostgreSQL 的类型形状并不一一对应。
repeated string 可以存 PostgreSQL TEXT[],Bun 官方 pgdialect 支持 bun:",array",查询数组参数时也可用 pgdialect.Array。但 repeated Message 若映射成 PostgreSQL 复合类型数组,扫描、迁移和跨语言兼容会迅速变重,通常放进 JSONB 更简单。
Protobuf map<string, string> 可以选择 JSONB 或 HSTORE。HSTORE 只表达字符串到可空字符串,运算符简单;JSONB 能表达嵌套结构,但索引与更新策略更复杂。不要仅凭 Go 里都是 map 就自动决定类型。bunpgd 的类型发现把普通 map 默认推成 JSONB,这适合作为项目约定,却不是真理。
枚举也有两种常见落库方式。存数字可以稳定保留 Protobuf wire value,改枚举名称不影响旧数据,但直接查库可读性差;存名称更易读,却让重命名变成数据库迁移。无论选哪种,都不要复用已经删除的枚举编号,也不要把未识别枚举悄悄改成默认值。
时间尤其需要克制。Protobuf 常用 google.Protobuf.Timestamp,PostgreSQL 常用 TIMESTAMPTZ,驱动通常返回 time.Time。若坚持共享结构,就需要 Dialect Scanner 在二者间转换,并验证 Timestamp 的合法范围;否则单独使用 time.Time 数据库模型反而更清楚。数据库的 TIMESTAMPTZ 不保存原始时区名称,接口如果要返回用户时区,还得有另一列。类型名相似,不代表丢失的信息相同。
最后是 oneof。它在生成代码中通常表现为接口和若干包装类型,很不适合直接映射多列。可以把完整 oneof 存 JSONB,也可以在数据库中拆成判别列加若干值列,但两种方案都需要显式约束。让反射自动猜,得到的只会是难以迁移的偶然行为。
PATCH 时更推荐 FieldMask
更新是共享 Protobuf 模型最容易出错的地方。假设请求把 status 设为 0,这是明确更新成零,还是客户端根本没提交这个字段?仅凭生成 struct 的零值无法判断。OmitZero() 可以避免误更新,却也让用户永远无法把数字改成 0、布尔改成 false、字符串改成空。
bunpgd 的 Updater 提供 Ignore 和 Select 两种模式,调用 PB() 后还会读取 Protobuf tag 中的 JSON 字段名。这种接口对普通 Go 结构体和 Protobuf 都能用,所以它接收的是字符串列表,而不是 Protobuf 类型。
只看 Protobuf 接口,这套模式不够规范。Protobuf 已经定义了 google.Protobuf.FieldMask,校验路径、JSON 编码和嵌套字段都有统一语义,没有必要再从 struct tag 猜一套字段选择协议。PATCH 请求直接携带 mask,数据层再把 proto 字段路径映射到允许更新的列:
var clientColumns = map[string]string{
"name": "name",
"status": "status",
"roles": "roles",
"preferences": "preferences",
}Repository 只接受映射中存在的字段:
func UpdateClient(
ctx context.Context,
db bun.IDB,
row *Client,
mask *fieldmaskpb.FieldMask,
) error {
if mask == nil || len(mask.Paths) == 0 {
return errors.New("empty update mask")
}
if !mask.IsValid(row.Client) {
return fmt.Errorf("invalid update mask: %v", mask.Paths)
}
mask.Normalize()
q := db.NewUpdate().
Model(row).
Where("client.id = ?", row.GetId())
for _, path := range mask.Paths {
column, ok := clientColumns[path]
if !ok {
return fmt.Errorf("field %q is not updateable", path)
}
q.Column(column)
}
_, err := q.Exec(ctx)
return err
}FieldMask.Paths 在 Protobuf 内部使用 proto 字段名;它的 JSON 表示会按 FieldMask 规则转成 lowerCamelCase,服务端不该把 JSON 展示形式直接当数据库列名。IsValid 负责确认路径确实存在于 message,业务白名单再决定该字段能不能从这个接口修改。两层校验缺一不可。
嵌套路径还要决定是整体覆盖 JSONB,还是更新其中一个键。preferences.theme 若最终对应一整列 preferences,简单地调用 Column("preferences") 会把客户端没传的其他键一起覆盖。遇到这种情况,要么规定 mask 只能选顶层字段,要么为允许的嵌套路径生成明确的 jsonb_set 表达式。FieldMask 描述调用者想改什么,并不会替数据层生成正确 SQL。
另外,列白名单不能只服务于安全。它也是业务规则:普通用户不能更新 role,管理员不能通过通用 PATCH 修改 password_hash,状态迁移可能必须走单独命令。共用结构不等于所有字段都可由所有入口写入。
PostgreSQL 已经给了一个乐观锁字段
一份 Protobuf message 从接口进来,经过若干业务处理再写回数据库,很容易覆盖别人刚刚提交的修改。先 SELECT 再 UPDATE 不能解决问题,因为两条语句之间仍可能发生并发写。
在 PostgreSQL 中,不必为了这个用途额外增加 version 列。每个表行都有系统列 xmin,它记录当前行版本由哪个事务产生。查询时把它扫描到前面的外层 Client,更新时再放进 WHERE:
var xmin uint32
err := db.NewUpdate().
Model(row).
Column("name", "status").
Where("id = ?", row.GetId()).
Where("xmin = ?", xmin).
Returning("xmin").
Scan(ctx, &xmin)
if errors.Is(err, sql.ErrNoRows) {
return ErrConcurrentUpdate
}
if err != nil {
return err
}若行在读取后被其他事务更新,它的 xmin 已经变化,UPDATE 就匹配不到记录。RETURNING xmin 顺手取回新行版本,避免成功更新后还握着旧令牌。没有返回行时,记录不存在和并发冲突仍然无法仅靠这条语句区分;多数 PATCH 接口可以统一返回冲突,需要区分时再补一次查询。
xmin 很方便,但不要把它解释成业务版本号。事务 ID 会回卷,冻结、备份恢复和逻辑复制也不保证它是跨数据库稳定的单调序列。它适合在同一 PostgreSQL 数据库里做短生命周期的并发令牌,不适合进入事件协议或作为永久排序字段。若业务本身需要可审计、可跨系统传播的版本号,再单独建 version 列。
需要悲观锁时,Bun 可以表达 FOR UPDATE、NOWAIT、SKIP LOCKED 等 PostgreSQL 语句。比如任务队列通常在事务中选取一批待处理任务并 SKIP LOCKED,让多个 worker 各拿各的。但锁必须与后续状态更新处于同一事务,而且事务里不要执行慢速网络调用。数据库行锁不是分布式任务的永久租约,进程崩溃后的重试与幂等仍要单独设计。
批量操作要保住输入顺序和错误边界
Bun 可以插入 slice,也可以使用 NewValues 构造 VALUES CTE。难点不在于「一次插很多行」,而在于批量输入如何和返回结果重新对应。
假设接口提交一组 Protobuf 消息,数据库使用自增 ID。只按数据库返回顺序赋值,通常依赖了没有写出来的假设。更稳妥的方式是给 VALUES 添加输入序号,或让每条请求带客户端生成的稳定 ID,然后用这个键关联结果。Bun 的 ValuesQuery.WithOrder() 会增加顺序列,适合需要保持原输入顺序的 CTE。
批量更新也不要循环发 N 条 SQL。Bun v1.2.18 已经提供 Bulk(),它会基于 slice 生成 VALUES CTE 和 UPDATE ... FROM:
_, err := db.NewUpdate().
Model(&users).
Column("display_name", "status").
Bulk().
Exec(ctx)Bulk() 要求模型是 slice,并使用主键关联待更新行。这里的元素应当是带 bun.BaseModel 的外层结构,而不是裸 Protobuf message。若项目覆盖目标表或别名,必须让 UPDATE 的别名与 Bun 为主键条件生成的模型别名一致。若每一行要更新的列集合不同,可以按列集合分组,也可以手写经过测试的 VALUES CTE。无论哪种方式,都要检查当前 Bun 版本生成的 SQL。批量大小也应受控:一百万行塞进一个 VALUES,参数数量、SQL 解析时间、WAL、锁持有时间和内存峰值都会一起增长。生产代码应分块,并明确「每块一个事务」还是「整批原子」。
多租户约束放在哪里
多租户系统最危险的 Bug 往往不是 SQL 注入,而是少写一个 tenant_id。
查询修饰器可以减少遗漏:
func ByTenant(tenantID string) Scope {
return func(q bun.QueryBuilder) bun.QueryBuilder {
return q.Where("tenant_id = ?", tenantID)
}
}但它仍依赖每个调用者记得应用。Query Hook 很诱人:似乎可以在所有查询前自动加租户条件。问题是 Hook 也会看到迁移、后台任务、跨租户管理查询和原始 SQL,而且查询到了 Hook 阶段未必还适合安全改写。把安全边界寄托在「Hook 尝试猜这条 SQL 要不要加条件」上,很难审计。
更可靠的组合通常是三层:Repository API 要求显式租户上下文;公共查询构造函数默认应用租户 Scope;PostgreSQL 使用行级安全策略 (Row-Level Security) 作为最后防线。若通过事务内 SET LOCAL 设置租户变量,必须确保查询真的使用同一个 bun.Tx,事务结束后变量不会泄漏回连接池。
管理员跨租户查询则走单独 Repository 或数据库角色,不要在普通方法里增加一个随处可传的 skipTenant bool。布尔逃生舱用久了,迟早会有人在不该传 true 的地方传 true。
Hook 用来观测,不要偷偷改变业务语义
bunpgd.Open 把 bunslog 和 bunotel 包装成 bun.DBOption,初始化时统一挂载。这个用法比到处手写 SQL 日志实用得多,因为 Bun 的 Query Hook 能看到操作类型、查询模板、参数、耗时、错误和模型。
生产环境至少应记录或统计:
- 查询操作与归一化模板,而不是把每个参数值都变成一条新指标;
- 总耗时,以及连接池等待与数据库执行能否区分;
- 错误类别,如超时、取消、唯一约束、序列化失败;
- 受影响行数,特别是乐观锁和批量更新;
- trace ID、租户与业务操作,但不能把密码、令牌和个人敏感信息写入日志。
慢查询阈值也不该只有一个全局数字。后台报表跑 2 秒可能正常,登录请求跑 500 毫秒已经不可接受。Hook 可以给原始数据,SLO 应由业务操作解释。必要时通过 Comment 给查询附上稳定的操作名,方便在 pg_stat_statements、日志与 Trace 之间关联,但注释内容不要来自不可信请求。
我不建议在通用 Hook 中悄悄修改查询、自动补租户条件或吞掉某类错误。观测代码一旦改变业务语义,关闭日志可能改变结果,测试环境和生产环境也会走不同路径。Hook 最适合做计时、记录、Trace 与指标;授权、路由和重试应放在显式层次。
Relation 必须放在外层结构里
Bun 的 Relation 依赖模型上的 bun:"rel:..." tag。Protobuf message 即使已经包含嵌套 message,也没有 bun.BaseModel,更没有办法在生成类型上随意补一组关系字段。gotag 能改已有字段的 tag,不能凭空添加评论、附件等关系字段。
所以详情查询只能再定义一个外层结构,把 Protobuf 的基础字段和 Bun 的关系字段放在一起:
type ArticleDetail struct {
bun.BaseModel `bun:"table:article,alias:article"`
*v1.Article
Comments []*ArticleComment `bun:"rel:has-many,join:id=article_id"`
}查询时对这个结构调用 Relation("Comments")。若 API 的 v1.Article 也定义了对应嵌套字段,查询结束后再把关系结构中的 Protobuf message 填回响应;若接口形状不同,就构造专门的详情响应。这里不存在一种 tag 能让 Bun 自动识别 Protobuf 内部所有嵌套字段的关系语义。
列表查询通常不该使用 ArticleDetail。has-many 加载会增加关系查询,返回数据量也容易失控。列表使用只有 BaseModel 和 *v1.Article 的简单外层结构;详情需要哪条关系,就在查询中明确写哪条。外层结构不是重复模型,它记录的正是 Protobuf 没有的查询形状。
复杂 SQL 不必伪装成普通模型查询
Bun 的链式构造器适合组合条件,也能表达 CTE、子查询和窗口函数,但这不意味着所有 SQL 都应该被拆成几十次方法调用。一条包含递归 CTE、窗口帧、LATERAL、JSON 路径和 PostgreSQL 特有运算符的报表 SQL,硬改成链式代码后往往比原 SQL 更难读,也更难拿到 psql 中分析。
这时可以保留手写 SQL,让 Bun 负责参数格式化和扫描。真正需要坚持的边界只有两条:标识符来自代码白名单,外部值使用参数;返回列与目标结构有明确契约。不要因为用了 NewRaw 就回到 fmt.Sprintf 拼接用户输入,也不要把一条稳定的统计查询拆成 ORM 小积木,只为了形式上的统一。
复杂读取尤其适合专用的只读 Protobuf message:
message TenantUsage {
string tenant_id = 1;
int64 active_users = 2;
int64 storage_bytes = 3;
google.Protobuf.Timestamp sampled_at = 4;
}它不是某张表的模型,不需要主键和更新 tag。Repository 可以用 CTE 算出结果,再扫描到这个 message。这样仍然实现了接口层与读取层共用结构,却没有假装聚合结果能够被 UPDATE。读写模型在语义上不同,代码就应该允许它们拥有不同的能力。
动态排序也属于容易越界的地方。排序方向、NULL 位置和列名都不能原样接受请求字符串。生成器可以为可排序字段生成枚举:USER_SORT_CREATED_AT 映射到 u.created_at,DESC 映射到固定关键字。值参数能保护 WHERE name = ?,却不能替你保护 ORDER BY ?,因为参数位置代表值,不代表 SQL 标识符或语法。这里必须使用白名单,而不是幻想占位符万能。
CTE 也不要被当成性能优化开关。PostgreSQL 版本、物化策略和查询形状都会影响计划。Bun 只负责生成你要求的 SQL,不会判断某个 CTE 应不应该物化,也不会替你创建索引。重要查询要保存 EXPLAIN (ANALYZE, BUFFERS) 的基线,升级 Bun 或 PostgreSQL 后比较行数估计、扫描方式、排序是否落盘。所谓 SQL-first,包含对执行计划负责,而不只是允许在 Go 里写 SQL 字符串。
连接池不是一条永远不变的连接
*bun.DB 底下通常是 *sql.DB。后者是连接池,不是一条持久连接。这个事实会影响所有依赖 PostgreSQL 会话状态的写法:SET search_path、临时表、advisory lock、会话变量、LISTEN,以及依赖当前连接的 prepared statement 行为。
如果先通过 db.ExecContext 执行 SET app.tenant_id = ...,再通过同一个 db 查询,第二条语句可能拿到另一条物理连接;更糟的是,会话级设置还可能留在池中的旧连接上,被后来的请求继承。多租户变量应该在事务里用 SET LOCAL,并让后续调用一路接收同一个 bun.Tx。确实需要连接级状态时,通过 Conn 固定连接,结束后清理状态,再把连接交还池。
超时也有三层,不要混成一个数字。Go context 控制调用方愿意等多久;PostgreSQL statement_timeout 控制服务端语句最多执行多久;lock_timeout 控制等待锁的时间。只设置 context,客户端取消后数据库驱动通常会尝试取消查询,但连接异常与取消传播仍要观测;只设置服务端超时,上层可能已经不再关心结果,goroutine 却继续等。常见做法是在事务内按业务用例设置局部超时,同时保留略短或略长、经过设计的 context 截止时间,并区分超时错误与普通 SQL 错误。
连接池大小也不能照 CPU 核数随手乘一个系数。每个应用副本的 MaxOpenConns 乘以副本数,再加迁移任务、管理脚本和其他服务,才是数据库看到的连接总量。池开得过大,会让 PostgreSQL 在大量活跃后端之间争抢 CPU 与内存;池开得过小,请求时间又消耗在等待连接。Query Hook 只能看到拿到连接后的部分时,更应该同时采集 database/sql.DBStats,观察等待次数、等待时长、使用中与空闲连接。
长事务是共享模型方案里格外容易出现的事故。接口收到一个很大的 Protobuf 请求,在事务中扫描、校验、调用远程服务,再批量写回,看似「保证一致」,实际让连接和行锁长时间占用,还妨碍 vacuum 清理旧版本。事务里只保留必须原子执行的数据库步骤;远程调用放在事务外,通过幂等键、outbox 或状态机连接两个系统。Bun 能帮你开始事务,不能把跨系统操作自动变成分布式事务。
从双模型迁移到共用模型,不要一次换完
一个已经运行的服务通常已有 model.User 和 userpb.User。直接删除数据库模型、把所有查询切到 Protobuf 类型,风险会集中爆发:tag 漏列、NULL 语义变化、时间类型扫描失败、默认值不一致、JSON 字段名称变化,都可能混在同一次发布中。
更稳的过程是先把共用结构当作第二条读取路径。选择一两个简单接口,用相同条件分别扫描旧模型和 Protobuf 模型,在测试或灰度环境比较归一化后的结果。时间精度、nil 与空集合、JSON 对象键顺序需要先规范化,避免把表示差异误报成数据差异。只有读取稳定以后,再迁移写入;写入阶段可以短期双写并回读比较,但必须明确哪条路径是权威,失败时不能含糊地留下半份数据。
字段也可以逐个迁移。CI 可以读取 descriptor 和 gotag 处理后的 Go 字段,对照旧模型的列映射;先覆盖标量与时间,再处理数组、JSONB、oneof 等复杂字段。每一批都保留回退开关,使旧 Repository 还能读取同一 schema。回退开关应有删除日期,否则两套路径会永久存在,下一次改字段还得维护两边。
上线前需要检查的不只是代码:
- 旧数据中是否存在新 Scanner 无法解析的历史值;
- SQL
NULL与 Protobuf presence 是否保持原业务含义; - INSERT 后由数据库生成的字段是否通过
RETURNING回填; - 读副本版本是否已经包含新列,新旧应用能否同时运行;
- 日志和 Trace 是否会因为直接记录 Protobuf message 而泄露敏感字段;
- 回滚到旧版本时,它是否认识新枚举、新 JSON 字段和新列默认值。
生成代码的发布也要可复现。固定 protoc、protoc-gen-go 和 protoc-gen-gotag 版本,将生成命令放进仓库,不依赖某位开发者机器上的全局工具。生成器升级单独提交,先审查生成差异,再和业务字段变更合并。否则一次看似普通的 proto 修改,可能夹带整个仓库 tag 格式和 API Level 的变化。
观察期内可以记录两种模型转换后的摘要哈希,而不是把完整用户数据打进日志。发生差异时,保留主键、字段路径、旧值与新值的类型信息,敏感值只记录是否为空或不可逆摘要。迁移的目的不是证明新方案在纸面上更整洁,而是用可回退、可比较的方式证明它没有改变线上语义。
迁移值得自己管
生成结构带了 bun tag 以后,很容易继续走到 NewCreateTable().IfNotExists(),甚至让应用启动时按模型同步数据库。我不推荐这么做。模型只描述现在的结构,迁移描述数据库怎样从旧版本走到现在,两者不是同一种东西。
把字段从 name 改成 display_name,模型差异无法知道这是重命名,可能理解成删一列再加一列;把 int64 改成字符串,真实过程需要增加新列、回填、切换读写,最后才能删除旧列。表大一些以后,还要考虑锁表时间、索引并发创建和发布顺序。自动同步看到的是两个静态快照,看不到这些过程。
我更愿意在项目里自己实现一个很小的迁移器。它不需要理解 Go 模型,只需要做好几件事:按版本注册迁移;在数据库里记录已执行版本、时间和校验值;执行前取得 PostgreSQL advisory lock,避免多个副本同时迁移;失败时记录 dirty 状态;支持事务的 DDL 放在事务里,CREATE INDEX CONCURRENTLY 这类操作则显式标记为非事务迁移。
迁移内容可以直接写 SQL,也可以在确实方便时使用 Bun Query Builder。关键是每个文件都表达一次有名字的历史变化,而不是根据当前 struct 猜 SQL。已经在环境中执行过的迁移不再修改;发现错误时新增修复版本。校验值能及时发现某个旧文件被人悄悄改过。
Protobuf schema 与数据库 migration 应分别版本化:
- Protobuf 字段编号删除后保留
reserved,避免 wire number 被复用; - 数据库列先扩展、回填,再切换读写,最后收缩;
- tag 变化必须和迁移在同一次变更中审查;
- CI 可以从模型生成期望 schema 做差异提醒,但迁移仍是人工确认的历史步骤;
- 回滚不仅是写一条 Down,涉及数据丢失的变更必须先设计恢复来源。
自己管理迁移很快会遇到另一个现实问题:一年以后,目录里可能堆了几百个文件。新环境从第一版逐条运行,既慢,也会撞上已经不再支持的扩展、函数或历史数据假设。版本管理不能只管有没有执行,还要管迁移集合怎样退役。
可以按发布周期制作 baseline。比如服务进入 v3 后,把当时完整 schema 导出为 baseline_v3.sql,新部署直接建立基线,再执行 v3 之后的增量迁移;已经运行的数据库仍按原版本表继续升级,不能把旧迁移从它的历史中抹掉。旧文件可以移入 migrations/archive/v2,由单独的历史升级测试使用,不必让每次新部署都重新跑一遍。
baseline 也要纳入版本控制,记录它对应的最后迁移版本和 PostgreSQL 大版本。CI 至少测试两条路径:从最新 baseline 建一个空库;从仍在支持的最老生产版本升级到最新。两条路径最终得到的 schema 应一致。这样既保留审计和旧环境升级能力,也避免迁移目录无限增长后没人敢碰。
共用 Protobuf 字段可以让 schema 差异更早暴露,但它不会替你保存数据库的演进历史。迁移器越简单越好,版本、锁、校验、基线和测试却不能省。
错误不要在数据库层被磨平
Repository 返回原始 PostgreSQL 错误,业务层到处检查字符串,同样是一种泄漏。相反,如果 Repository 把所有错误都包成 ErrDatabase,唯一约束、并发冲突、未找到和真正的数据库故障又无法区分。
比较实用的做法是只翻译稳定且有业务含义的错误:
sql.ErrNoRows转成ErrNotFound- 指定约束名的唯一冲突转成
ErrAlreadyExists - 带
xmin条件的更新没有返回行时转成ErrConcurrentUpdate - context 取消和超时保留
errors.Is链 - 其他错误带上操作名后原样向上返回,由统一边界记录
不要只判断 PostgreSQL 错误码 23505 就说「用户名重复」,因为同一条 INSERT 可能撞到邮箱、外部 ID 或其他唯一约束。约束名才对应具体业务含义。数据库迁移中要稳定命名关键约束,否则错误翻译会依赖 PostgreSQL 自动生成名称。
Protobuf 接口层再把领域错误映射为 gRPC status 或 HTTP 状态。这样共用的是数据结构,不是错误协议。数据库错误不会直接穿过网络,API 也不用知道使用 Bun 还是别的 SQL 库。
测试重点不是起一个 Mock
查询生成器、字段映射和 Scanner 都很适合单元测试,但数据库语义不能只靠 Mock。
先测试生成 SQL。对每个筛选结构给定输入,验证 SQL 模板和参数,尤其覆盖 false、0、空字符串、nil、单元素 IN、多元素 IN、LIKE 中的 % 与 _、数组和非法字段。不要断言所有空格,否则升级 Bun 会制造无意义失败;应关注列、操作符、括号和参数绑定。
再为 Scanner/Valuer 做往返测试:SQL NULL、空 JSON、未知 Protobuf 字段、未知枚举、非法 UTF-8、大整数、空数组和 nil 数组都要出现。只测试 Marshal 后立刻 Unmarshal 不够,因为真实驱动可能返回 string 或 []byte,PostgreSQL 也会规范化表示。
再用真实 PostgreSQL 做集成测试。数组、HSTORE、JSONB、TIMESTAMPTZ、约束错误和事务隔离不是内存 Mock 能模拟的。测试容器或临时数据库跑得慢一点,却能发现类型推导、扩展安装、时区和驱动返回类型的问题。
生成一致性也要测试:运行 Protobuf 与 gotag 生成流程,然后检查 Git 工作区没有变化;遍历 message descriptor,确认所有可持久化字段都有 Bun tag;将数据库 schema 与期望模型比对,至少在 CI 中提示未迁移字段。
bunpgd 的代码适合拿来研究这些接缝,却不应被当成不用测试的标准答案。观察提交中的 builder、类型发现和数组扫描都是项目级实现,使用前应结合自己的 Bun、Go、驱动与 PostgreSQL 版本验证。参考源码的意义是看清可扩展点,而不是把维护责任转交给另一个仓库。
目录怎么摆
如果决定采用 Protobuf 与 Bun 共用模型,可以把生成物和手写边界摆清楚:
api/account/v1/
├── user.proto # 字段身份、API 与 tagger.tags
gen/account/v1/
├── user.pb.go # protoc-gen-go 生成,再由 gotag 写入 Bun tag
├── user_grpc.pb.go # protoc-gen-go-grpc
└── user_database.go # Scanner、Valuer 等同包扩展
internal/user/
├── model.go # BaseModel、Protobuf 嵌入、xmin 与 Relation
├── repository.go # Bun 查询、投影与锁
├── scopes.go # 租户与稳定查询条件
├── service.go # 事务边界与业务规则
└── errors.go # 领域错误
migrations/
├── baseline_v3.sql # 新环境使用的版本基线
├── archive/v2/ # 仍需保留的旧升级链
└── 20260911_create_users.go # 基线之后的增量迁移依赖方向也要保持单向:生成的 Protobuf 包最好只依赖标准库、Protobuf runtime 与 database/sql 接口,不直接依赖业务 Repository;若生成 schema.QueryAppender 才需要依赖 Bun,但能用 driver.Valuer 时尽量避免。业务层可以使用 Protobuf 类型,Repository 使用 Bun 操作它们,接口层不应该反过来 import 数据库包。
表名、Relation、xmin、租户 Scope、软删除策略和查询投影留在数据层;字段的 Bun tag 跟着 Protobuf 生成。边界很朴素:proto 知道列如何表示,外层结构知道它属于哪张表,Repository 知道这次要怎样查询。
什么时候不该这么做
共享 Protobuf 与 Bun 模型不是架构竞赛的终点。以下情况分开模型通常更划算:
- 数据库表包含大量不能出现在接口定义中的敏感字段
- 一个接口 message 聚合多张表,和任何单表都不像
- 领域对象有强不变量,不能允许调用方随意构造
- 团队准备迁移 Protobuf Opaque API,不愿维护反射适配层
- 同一 Protobuf 被多个服务使用,而数据库 schema 只属于其中一个服务
- 数据库需要频繁重构,但公共协议必须长期稳定
- 需要同时支持多种数据库,PostgreSQL 类型 tag 会污染通用模型
共用结构可以理解成一种按字段计算的优化,而不是全项目开关。用户 ID、状态、时间等稳定字段可以复用;密码、内部审计与聚合字段各归其位。少一次无意义转换是收益,为了少一个 struct 模糊安全边界则是亏损。
结语
写 bunpgd 的目的是 Bun 当时的 Scan 逻辑比较奇怪,疑似因为历史包袱。而写完之后最有价值的地方,不是提供了一个可以替换官方 pgdialect 的成品,而是把 Bun 的几个隐蔽扩展点串了起来。
它用 Dialect 的 OnTable 改写字段元数据,说明类型系统可以集中扩展;用反射缓存 Scanner 与 Appender,说明昂贵判断不必发生在每一行;用 QueryBuilder 统一 Select、Update、Delete 的条件,说明查询结构可以独立于最终动作;用 Protobuf tag 中的 JSON 名与 Go json tag 做两套名称映射,说明接口字段和 SQL 列不必碰巧同名;用 Query Hook 接入结构化日志和 OpenTelemetry,说明观测可以在执行边界统一完成。
这些思路比复制某一段代码更有用。具体实现仍要逐个核对:自定义 Dialect 与 Bun 内部 API 紧耦合,反射生成查询容易吞掉零值,手写 PostgreSQL 数组和 HSTORE 编解码需要大量边界测试。Updater 为了兼容普通结构体而使用字符串列表也说得通;当入口已经是 Protobuf 时,直接换成 FieldMask 会更顺手。
使用一个库,不在于调用多少 API,而在于哪些地方由它负责,哪些地方仍由项目自己控制。Bun 已经把 SQL、模型、扫描、事务和 Hook 留出了接缝,没有必要再包一层万能 ORM。
Protobuf 与 Bun 共用字段,生成 tag 和实现 Scan 只是起点。字段存在性、更新白名单、数据库类型、迁移历史、Opaque API、敏感信息和事务边界,都会决定这套写法能维持多久。
这套方案最后得到的也不是全项目只有一份结构体。Protobuf message 承担公共字段,带 BaseModel 的薄壳承担表语义,ArticleDetail 这类结构承担关系查询。少掉的是重复字段和搬运代码,保留下来的是本来就不同的边界。若两边数据含义并不相同,多写一份结构体仍然是便宜而清楚的隔离。
