
“先把它塞进 JSONB 吧。”
第一次这么做时,我们觉得这个方法又快又聪明。
产品要新功能开关?→ JSONB。
实验需要加额外字段?→ JSONB。
功能未来可能变化?→ JSONB。
PostgreSQL 默默接受。
数据库迁移“看起来”很轻松。
功能交付速度“看起来”也很快。
然而,仅仅六个月后,问题开始浮现:
- 服务性能表现怪异
- Bug 像幽灵一样难以捕捉
- 没人能说清数据库里到底存了什么结构的数据
问题的根源,并非 Go 语言本身,也不是 PostgreSQL 数据库。
而是我们把一个“灵活字段”当成了整个系统的架构基石。
🧨 灵活字段如何悄悄接管你的系统?
故事的开始通常看似无害。
假设你有一张这样的表:
events
------
id (uuid)
user_id (uuid)
type (text)
created_at (timestamptz)
data (jsonb)
这看起来相当合理:
- 不同的事件类型需要不同的字段
- 你不想创建一堆可空(nullable)列
- JSONB 看起来非常适合存储“事件负载(payload)”
真正的问题不在于使用了 JSONB,而在于它逐渐成为了所有场景的默认解决方案。
随后,团队开始慢慢地做这些事情:
- 将功能开关(Feature flags)放入
data 字段
- 把核心业务决策逻辑也塞进
data
- 使用
data->>‘key’ 进行数据过滤
- 报表查询甚至使用
data::text ILIKE ‘%something%’
第一天,你感受到了灵活性。
第二百天,你发现整个系统变得像一团模糊的影子,难以维护。
🩹 JSONB 在哪些具体场景伤害了我们?
我们决定放弃滥用 JSONB,并非为了追求“架构的纯洁性”,而是因为它在几个非常具体的方面持续地给我们制造麻烦。
1. 隐形的“契约漂移”(Contract Drift)
当两个团队开始向同一个 data 字段写入数据时,他们对数据结构的理解可能完全不同。
旧版本写入:
{"status": "active"}
新版本写入:
{"status": {"code": "active", "source": "beta"}}
没有数据迁移脚本,没有结构校验。
消费这些数据的服务端代码,只能被迫编写大量的“防御性分支逻辑”来适配不同格式:“如果是字符串就走这里,如果是对象就走那里,如果字段不存在就继续推测……”
根本原因在于,系统中缺少一个中心化的、明确的数据契约。
2. 无处不在的无法索引查询(Unindexable Queries)
我们一度允许这样的查询:
SELECT *
FROM events
WHERE data->>'country' = 'IN'
AND data->>'experiment' = 'pricing_v2';
在数据量较小时,这或许可行。
一旦数据量增长,查询优化器便会陷入困境:
- 执行计划变得不稳定
- 查询速度显著下降
- 性能优化几乎只能靠猜测
因为此时的“数据模式(Schema)”变成了:“过去六个月里,大家随便往 data 字段里塞了些什么东西。”
3. Debug 变成“考古挖掘”
当错误日志中出现:
- “Invalid payload”
- “Missing field in JSONB”
没有人能立刻回答:
- 这个字段是谁、在什么时候添加的?
- 数据格式在哪个版本发生过变更?
- 是哪个服务写入的这条记录?
我们不仅仅是在调试系统,更像是在挖掘地质层中的沉积物,试图还原历史。
🧱 如何清晰划分“固定 Schema”与“灵活字段”的边界?
解决方案绝非“永远不要使用 JSONB”。
核心原则是:关键业务字段使用真实的、有类型的列(typed columns)。其他真正不确定的、可变的细节部分,留给 JSONB。
因此,我们的表结构演进成了这样:
events
------
id (uuid)
user_id (uuid)
type (text)
country (text)
experiment_key (text)
created_at (timestamptz)
payload (jsonb)
我们将常用的过滤字段晋升为真实的列,例如 country 和 experiment_key。
而把真正“可变的部分”放入 JSONB 字段,命名为 payload。
随后,我们确立了一条清晰、看似无聊但极其有效的规则:
凡是需要被用于 JOIN、WHERE、GROUP BY 操作的字段,都必须有单独的列和相应的索引。
这条简单的规则迫使团队:
- 与产品经理对齐真正的核心业务字段
- 与其他团队协商并确立明确的数据契约
- 不再将 JSONB 用作回避设计的“偷懒工具”
🧩 利用 Go 的 Struct 明确数据边界
过去,我们在代码中大量使用 map[string]interface{} 来处理 JSONB 数据,导致到处充斥着类型试探和解码逻辑。
现在我们将其替换为具有明确类型的 Go struct:
type Event struct {
ID uuid.UUID `db:"id"`
UserID uuid.UUID `db:"user_id"`
Type string `db:"type"`
Country string `db:"country"`
ExperimentKey string `db:"experiment_key"`
CreatedAt time.Time `db:"created_at"`
Payload json.RawMessage `db:"payload"`
}
func (e *Event) CountryOrDefault() string {
if e.Country == "" {
return "unknown"
}
return e.Country
}
func (e *Event) HasExperiment(key string) bool {
return e.ExperimentKey == key
}
这带来了两个显著变化:
1. 数据的“真实结构”在代码中得以暴露
在代码审查时,团队成员可以清晰地看到:
- 哪些字段是必须存在的(非空)
- 哪些字段是业务关键(用于查询和关联)
- 哪些字段才是真正的“额外负载(payload)”
2. JSONB 被明确限定为“负载区”
它不再是“我们懒得创建新列时的堆放场”。
这使得测试也变得简单明了:
- 可以直接对
Country、ExperimentKey 等字段进行断言
- 仅在必要时才去解析
Payload 字段
越多核心字段被转化为强类型,就越少有人将业务逻辑偷偷塞进 JSONB。
🔧 可执行的渐进式重构计划(非大停机式)
我们没有进行停机式的重写,而是按照普通的迭代节奏一点点推进:
1. 识别热点查询(Hot Queries)
- 找出系统中执行最频繁的、涉及 JSONB 字段的查询。
- 使用
EXPLAIN ANALYZE 分析其真实的执行代价。不依赖猜测。
2. 将高频字段晋升为真实列
对于频繁出现在 WHERE、JOIN、GROUP BY 子句中的 JSONB 内字段 → 必须为其创建单独的列和索引。
迁移策略:
- 编写可重复执行的数据回填(backfill)脚本
- 采用小批量、可回滚的部署方式
3. 遵循“代码先行,数据库后变”的顺序
- 先在 Go 的 struct 中添加新字段
- 更新代码逻辑以支持新老两种数据格式
- 随后执行数据库迁移(添加新列、回填数据)
- 最后通过灰度发布切换流量
- 整个过程完全可逆,风险可控
4. 确立并执行一条铁律
禁止新增直接对 JSONB 字段进行条件过滤的查询。
任何提出此类需求的人,必须解释为什么不能为该字段创建单独的列。
“为了节省开发时间”不再被视为一个合理的理由。
这条规则极大地减少了新增技术债务。
📌 给所有正在考虑使用 JSONB 的后端工程师的建议
JSONB 是一个强大的特性,但它不应成为你的系统架构设计本身。
JSONB 应该用于:
- 真正可变、结构不定的负载数据
- 低风险、辅助性的元数据
- 你很少进行过滤、排序或关联查询的部分
JSONB 不应被用于:
- 回避关于数据模型(Schema)的必要讨论
- 隐藏核心业务规则和逻辑
- 支撑系统的核心查询链路
那条最朴实却最有效的黄金法则是:
为重要的业务字段建立明确的数据模型。为常用的查询字段建立索引。将灵活性留给那些真正需要灵活性的地方。