在 Go 工程里用 PostgreSQL,迟早会在 JSONB 字段上卡一下。关系模型设计得再干净,总有那么一两个表需要存一些“说不清楚”的结构,比如用户自定义属性、事件埋点、第三方回调的原始载荷。JSONB 提供了灵活性,但当你试图用 Go 的静态类型去驾驭这种灵活性时,就发现两边像隔着一层玻璃,看得见摸不着。

为什么 JSONB 在 Go 里总显得别扭
database/sql 的标准模型假设列是确定类型的。一个字段要么是字符串,要么是时间,要么是数字。而 JSONB 天然是动态的,虽然底层是二进制 JSON,但驱动读出来基本就两种形态:[]byte 或 string。于是大部分代码都会经历这个循环:查询时把 JSONB 读成 []byte,然后 json.Unmarshal 到 map 或 struct;写入时反过来,json.Marshal 成 []byte 再塞进参数。
这个流程单看没什么问题,真正麻烦的是查询条件。比如你要找 payload 里 event_type 等于 click 的记录,SQL 里只能写 payload->>’event_type’ = $1。注意,payload->>’event_type’ 只是一个字符串表达式,它不在 Go 的类型系统里。如果你把 event_type 拼错了,或者某个 key 改了名,编译期不会报任何错误。更隐蔽的是,如果这个 key 在某些行里不存在,返回的是 NULL,某些行存在但值为 null,返回的是 JSON null,这两种在 Go 里如果都映射成同一类型,就会把语义搞混。
还有一个让很多人踩坑的细节:PostgreSQL 的 ? 操作符和 libpq 的参数占位符有冲突。早期版本中,如果你写 payload ? $1,可能会被识别成参数占位符而报错。pgx 做了处理,但如果你仍然使用 database/sql 加 libpq 驱动,遇到这种查询时就要特别注意转义。这类问题让 JSONB 查询看起来“很危险”,下意识就想绕开。
常见误区:把 JSONB 当成 TEXT 用
我见过不少项目,JSONB 字段的唯一用途就是整体存取。写入时把整个结构体序列化塞进去,读取时拉出来反序列化。查询但凡要过滤某个子字段,就把整行数据加载到内存,在 Go 里 for 循环过滤。这种做法在数据量小的时候没问题,但一旦表到了几十万行,问题就来了:你等于把数据库的索引和查询计划全部变成了摆设。
- 把 JSONB 当整体存取,查询时在应用层过滤,等于把数据库当成缓存。
- 用 LIKE 搜索 JSONB,既看不清结构也走不了索引。
- 以为 GIN 索引对任何 JSONB 查询都有效,忽略路径表达式和操作符差异。
另一个被反复讨论的坑:把 GIN 索引当成万能加速。实际上 GIN 索引的生效条件取决于你的查询操作符和路径。payload @> ‘{“a”:1}’ 可以走索引,但 payload->>’a’ = ‘1’ 这种普通表达式,如果没有额外建表达式索引,GIN 是帮不上忙的。建索引时最好用 EXPLAIN ANALYZE 确认,而不是想当然。
类型安全方案,从哪一步开始
要谈类型安全,先要明确我们到底想保护什么。Go 的编译器不可能知道某个 JSONB 里有没有你期望的字段,因为数据库不是类型系统的一部分。因此“类型安全”实际的可行目标是:在应用层将 JSONB 与明确的 Go struct 绑定,让结构变化在序列化和反序列化边界暴露;同时把 SQL 中的 JSON 路径集中在可维护的代码中,而不是散落各处。
最简单的做法是定义自己的 JSONB 类型,实现 sql.Scanner 和 driver.Valuer。比如:
type EventData map[string]interface{}
func (d *EventData) Scan(value interface{}) error {
if value == nil {
*d = nil
return nil
}
data, ok := value.([]byte)
if !ok {
return fmt.Errorf("unexpected type %T", value)
}
return json.Unmarshal(data, d)
}
func (d EventData) Value() (driver.Value, error) {
if d == nil {
return nil, nil
}
return json.Marshal(d)
}
这样在大多数 ORM 或 database/sql 里,读写不再需要手动 marshal/unmarshal。但注意,这个自定义类型只解决“整体存取”的问题,查询条件里的 payload->>’event_type’ 仍然是裸的 SQL 字符串。
进一步是在 repository 层做一个映射:把数据库返回的 JSONB 字段先变成一个中间结构,再转换成业务结构体。同时,把查询条件需要的 JSON 路径封装成函数的输入参数,而不是直接暴露 SQL 片段。
一个更实用:利用生成列把高频字段“提升”出来
很多团队最后会发现,JSONB 里真正需要作为查询条件的字段,其实只有那么几个。与其让业务代码到处写路径表达式,不如直接在 PostgreSQL 里用生成列把这些字段提取出来,变成普通列。例如:
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
payload JSONB NOT NULL,
event_type TEXT GENERATED ALWAYS AS (payload->>'event_type') STORED
);
CREATE INDEX idx_events_event_type ON events (event_type);
这样 Go 代码里 event_type 就是一个普通的 text 列,可以绑定到 struct 的 string 字段,查询时直接用 WHERE event_type = $1。类型安全、索引友好、代码干净,一举三得。原始 payload 仍然保留,用于读取完整数据。
这个方案的代价是:每当 JSON 结构里新增一个需要查询的 key,就要执行一次 ALTER TABLE 添加生成列。在频繁变动的业务里,这可能会成为运维负担。我的建议是:只提升真正的核心查询字段,例如 event_type、user_id、status 这类稳定且高频的键。低频或完全动态的查询需求,依旧走 JSONB 操作符。
方案对比:手写 SQL、pgx、sqlc 与 ORM
抛开具体业务谈方案没有意义。先列出几条主要路径,从类型安全、易用性和维护成本几个角度做一个简单对比。
| 方案 | 类型安全 | JSONB 操作支持 | 学习成本 | 适用场景 |
|---|---|---|---|---|
| database/sql + 手写 SQL | 低 | 一般,需处理占位符冲突 | 低 | 小项目或一次性脚本 |
| pgx + pgtype.JSONB | 中 | 好,原生支持大部分操作 | 中 | 对性能和底层控制要求高的项目 |
| sqlc + pgx | 较高 | 较好,需自定义参数类型 | 中 | 希望编译期发现 SQL 错误的团队 |
| GORM/ent 等 ORM | 中 | 有限,复杂路径查询常需回退 | 低到中 | 业务以 CRUD 为主,JSON 查询简单 |
| 生成列 + repository 层 | 高 | 仅覆盖提升字段,原始 JSONB 仍需操作 | 中高 | JSONB 中有稳定核心查询字段的系统 |
注意,这里的“类型安全”是相对的。sqlc 能把 SQL 中的列映射成 Go 类型,但 JSONB 参数本身还是 []byte 或自定义类型,JSON 内部的路径依旧要靠约定维护。真正的类型安全需要你额外用代码生成或者严格的结构体定义来补充。
落地建议:怎么选
项目已经上了 pgx 且团队熟悉 database/sql,就没必要为了“类型安全”硬切到 sqlc。可以先用 pgx + 自定义 JSONB 类型,把整体存取固化下来,然后再对关键查询字段使用生成列。这样改动最小,收益最直接。
如果项目还在初期,并且 SQL 量会持续增长,我倾向于推荐 sqlc。它会把你的 SQL 变成编译期检查过的代码,JSONB 字段可以手动配置成更适合的 Go 类型。虽然 JSONB 内部路径依然动态,但至少 SQL 语法和列名错误不会拖到运行时。
对于 JSONB 内的查询条件,统一封装在一个独立文件里,例如 queries.go 中只允许通过函数构建条件。避免业务层直接写 payload->>’xxx’ 这种字符串。这比任何库都更重要——类型安全的第一道防线是人为的边界,而不是工具。
- 先确认 JSONB 中的高频查询字段,通常不超过 3 到 5 个。
- 为这些字段添加生成列并建索引。
- 剩余动态查询统一集中在一个 repository 层,禁止散落。
- 对整体读写的 JSONB 定义自定义类型,避免到处 marshal。
别忘了错误处理和索引检查
JSONB 解析失败是常见的线上问题。当数据库里混入了脏数据,json.Unmarshal 可能会返回错误,如果上层代码没有处理,直接 panic 会导致整个请求崩溃。建议在所有 JSONB 扫描的路口都留 error 处理,至少记录日志并返回可理解的错误。
索引方面,如果确实需要按任意键查询,可以建 GIN 索引。对于路径查询,使用 jsonb_path_ops 操作符类通常比默认的更节省空间且查询更快。但注意它只支持 @> 操作符。另一个实用建议是:对生成列建普通 B-tree 索引,因为生成列已经有具体类型,查询优化器更稳定。
总结
PostgreSQL 的 JSONB 和 Go 的类型系统之间的张力不会消失。追求 100% 的类型安全等于放弃 JSONB 的灵活性,而放任自由则会让代码在运行时代价高昂。务实的做法是把“会变的”和“不变的”分开:核心字段用生成列或明确 struct 固化,边缘字段保留在 JSONB 中,并用 repository 层作为唯一出入口。这样,你既能享受 JSONB 的好处,又不至于让 Go 代码退回到动态语言的混乱状态。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/854/