找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

554

积分

0

好友

76

主题
发表于 昨天 03:40 | 查看: 6| 回复: 0

图片

“先把它塞进 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)

我们将常用的过滤字段晋升为真实的列,例如 countryexperiment_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 被明确限定为“负载区” 它不再是“我们懒得创建新列时的堆放场”。 这使得测试也变得简单明了:

  • 可以直接对 CountryExperimentKey 等字段进行断言
  • 仅在必要时才去解析 Payload 字段

越多核心字段被转化为强类型,就越少有人将业务逻辑偷偷塞进 JSONB。

🔧 可执行的渐进式重构计划(非大停机式)

我们没有进行停机式的重写,而是按照普通的迭代节奏一点点推进:

1. 识别热点查询(Hot Queries)

  • 找出系统中执行最频繁的、涉及 JSONB 字段的查询。
  • 使用 EXPLAIN ANALYZE 分析其真实的执行代价。不依赖猜测。

2. 将高频字段晋升为真实列 对于频繁出现在 WHEREJOINGROUP BY 子句中的 JSONB 内字段 → 必须为其创建单独的列和索引。 迁移策略:

  • 编写可重复执行的数据回填(backfill)脚本
  • 采用小批量、可回滚的部署方式

3. 遵循“代码先行,数据库后变”的顺序

  • 先在 Go 的 struct 中添加新字段
  • 更新代码逻辑以支持新老两种数据格式
  • 随后执行数据库迁移(添加新列、回填数据)
  • 最后通过灰度发布切换流量
  • 整个过程完全可逆,风险可控

4. 确立并执行一条铁律 禁止新增直接对 JSONB 字段进行条件过滤的查询。 任何提出此类需求的人,必须解释为什么不能为该字段创建单独的列。 “为了节省开发时间”不再被视为一个合理的理由。 这条规则极大地减少了新增技术债务。

📌 给所有正在考虑使用 JSONB 的后端工程师的建议

JSONB 是一个强大的特性,但它不应成为你的系统架构设计本身。

JSONB 应该用于:

  • 真正可变、结构不定的负载数据
  • 低风险、辅助性的元数据
  • 你很少进行过滤、排序或关联查询的部分

JSONB 不应被用于:

  • 回避关于数据模型(Schema)的必要讨论
  • 隐藏核心业务规则和逻辑
  • 支撑系统的核心查询链路

那条最朴实却最有效的黄金法则是: 为重要的业务字段建立明确的数据模型。为常用的查询字段建立索引。将灵活性留给那些真正需要灵活性的地方。




上一篇:PyTorch实战:20维到1维的MLP建模与低误差回归任务解析
下一篇:Java线程Condition深度剖析:AQS等待队列机制与实战应用
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-10 20:58 , Processed in 0.083241 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表