PostgreSQL与MySQL的JSON类型及Go语言处理

关系型数据库存 JSON 早就不稀奇了,但把它用对没那么简单。你得先分清 PostgreSQL 里 json 和 jsonb 到底差在哪、什么时候该建什么索引,再决定 Go 代码里怎么读写才不别扭。这篇就把这几件事讲清楚,代码都能直接抄进项目。


一、三种类型的本质区别

维度PostgreSQL jsonPostgreSQL jsonbMySQL json
存储形式原始文本(逐字节保存)分解后的二进制分解后的二进制
保留空格/键序保留不保留不保留
保留重复键保留只留最后一个只留最后一个
写入速度快(不解析)稍慢(要解析)稍慢(要解析)
查询速度慢(每次重解析)快快
支持索引否(需表达式索引)GIN 索引函数索引 / 多值索引
去重/规范化否是是
一句话选型:
  • PostgreSQL 绝大多数场景直接用 jsonb。只有当你需要"原样存回、包括空格和键顺序"这种审计类需求时才用 json。
  • MySQL 只有 json 一种(内部实现类似 jsonb,二进制存储、支持部分更新)。

二、PostgreSQL 中的 json 与 jsonb

2.1 建表与写入

2.2 常用操作符

PostgreSQL 处理 JSON 靠的是一套操作符,比函数调用简洁得多:

两个高频踩坑点:? 系列操作符只能匹配顶层键,判断嵌套键必须先用 -> 下钻到对应层级;->> 取出的值始终是 text,参与数值/时间比较前要 ::numeric、::timestamptz 等强制转换,否则要么报错、要么变成字符串比较。

2.3 索引

2.4 修改与聚合


三、MySQL 中的 json 类型

MySQL 5.7 引入 json,8.0 大幅增强(多值索引、->> 语法糖、部分更新优化)。

3.1 建表与写入

3.2 常用函数与路径

MySQL 走的是"函数 + JSONPath"路线,而非操作符:

3.3 修改

3.4 索引(虚拟列 / 多值索引)

MySQL 不能直接给 JSON 列建索引,需借助生成列或 8.0 多值索引:


四、Go 语言处理 JSON 列

Go 没有内建的 JSON 列类型,但也不需要。database/sql 留了两个口子:写的时候走 driver.Valuer,读的时候走 sql.Scanner。任何类型只要实现这两个接口,就能当 JSON 列用。下面从最省事的写法开始,逐步过渡到类型安全的方案。

4.1 最简单:字符串进出

JSON 列本质是文本,可以直接用 []byte / string 读写:

写入同理,把 json.Marshal 的结果作为参数传进去即可。简单,但不够类型安全。

小技巧:用 json.RawMessage(本质就是 []byte)替代裸 []byte 更语义化,还能延迟解析——先原样 Scan 出来,需要时再 json.Unmarshal 到目标 struct:

4.2 推荐:自定义类型 + Scanner/Valuer

把某个结构体或 map 直接映射成 JSON 列,最通用的写法是定义一个泛型包装器。下面的 jsontype 是我们自己写的包(不是第三方库),你把它放进项目里任意一个目录即可;若不想自己维护,可直接跳到 4.6 用 GORM 的 datatypes.JSONType[T]。

使用:

说明:Value() 返回 []byte。lib/pq、jackc/pgx 和 go-sql-driver/mysql 都会把 []byte 正确地作为 JSON/JSONB 参数处理。若用 pgx 且想让服务端按 jsonb 类型校验,可显式转换:... VALUES ($1::jsonb)。一个关键坑:Scan 里千万别只断言 []byte。lib/pq 把 jsonb 作为 []byte 传入,而 pgx 传入的是 string;MySQL 驱动同样可能给 string。上面的 switch 同时处理了这两种情况——很多只抄了 []byte 分支的代码一换驱动就 panic,原因就在这里。

4.3 处理 NULL:用指针或 sql.Null 包装

列可能为 NULL 时,让包装类型支持空值:

4.4 结构未知时:映射到 map

如果 JSON 的键是用户自定义的、编译期无法确定结构(典型的"属性袋 / attributes bag"场景),可以把包装器的类型参数直接设成 map[string]any:

好处是灵活、无需预定义 struct;代价是每次取值都要做类型断言,且 JSON 里的数字统一被解成 float64。结构固定就用 struct,结构自由才用 map。

在 Go 1.18 泛型之前,更常见的是定义一个具名 map 类型并直接在其上挂 Value/Scan(coussej 称之为 PropertyMap),一处定义即可被 orders、customers、books 等各种实体复用:泛型 JSON[T] 本质是它的通用化版本——一份代码同时覆盖 struct 与 map,无需为每种形态各写一个类型。

4.5 部分查询:在 SQL 侧抽取字段

有时不想把整个 JSON 拉回 Go 再解析,直接在数据库里取标量字段效率更高:

4.6 各驱动 / ORM 的原生支持

工具用法要点
lib/pq直接把 []byte 作为参数写入 jsonb;读回也是 []byte。
jackc/pgx一等公民支持,可直接 Scan 进 map[string]any 或结构体;pgtype.JSONB 可用。
go-sql-driver/mysqlJSON 列以 []byte 返回,写入接受 []byte/string。
GORM用 gorm.io/datatypes 的 datatypes.JSON 或 datatypes.JSONType[T](泛型),自动处理两库差异。
sqlx结合上面的 JSON[T] 包装器,或用 types.JSONText。
GORM 泛型示例(同一份代码兼容 PG 与 MySQL):

五、为什么要用 JSON 类型,而不是拆成多个字段

一句话:字段适合"结构稳定、要被频繁查询和约束"的核心数据;JSON 适合"结构多变、整体存取的边缘数据"。 拆字段不是不行,而是很多场景下拆了更难受。

5.1 什么场景下 JSON 更合理

  • 结构因行而异:事件表里 click 事件的 payload 是 {x, y, target},purchase 事件是 {sku, qty, price}。拆字段意味着要么建一堆稀疏列,要么每来一种新事件就 ALTER TABLE。JSON 天然容纳异构结构。
  • 免 DDL 演进:上游加一个属性,数据库什么都不用改。大表 ALTER TABLE 在锁表/重建上有真实成本,老数据回填新列又是一堆问题。
  • 避免 EAV 反模式:JSON 流行前的标准解法是属性表 (entity_id, key, value):查一个对象要几十行自连接/pivot,索引分散、缓存局部性差。jsonb + GIN 一条 @> 查询解决,性能和可维护性都更好。
  • 你根本不拥有 schema:第三方 API 回包、webhook、设备上报、审计快照——上游说变就变。拆字段意味着每次上游变动都要改解析代码和表结构;JSON 就是"原样存下、需要时用路径查询取",把 schema 决策权留在应用侧。
  • 稀疏属性:100 个可选属性、每行只填三五个,JSON 只存出现的键;拆列就是一大片 NULL,列定义也膨胀。
  • 行数放大:一个 payload 一行,拆进属性表就是几十行,对缓存、备份、复制的开销都不同。

5.2 反过来,什么时候必须拆字段

JSON 的代价在于它是优化器和约束系统的"黑盒":

需求为什么 JSON 不行
高频 WHERE / JOIN / ORDER BY / 聚合内部无统计信息,优化器估行数不准;MySQL 尤其明显
NOT NULL / CHECK / UNIQUE / 外键JSON 内部字段没有这些约束(CHECK + JSON Schema 能做但很别扭)
单字段频繁 UPDATE整个 jsonb 文档重写,WAL 放大、行膨胀(MySQL 的部分更新只覆盖部分场景)
存储紧凑jsonb 每行都重复存键名;列名只存一次
强类型JSON 内部数字/字符串类型弱,(payload->>'score')::numeric 这种转换就是补课

5.3 实践折中:热字段提拔、冷数据留在 JSON

不是二选一。常见做法是把被频繁查询的字段"提升"出来,其余留在 JSON 里:

还有一个实用的演进视角:JSON 是起步成本最低的方案。前期不知道哪些字段重要,先整个塞 JSONB;等某个键真的被频繁查询、或需要约束时,再把它"提拔"成生成列/独立列。这个迁移是平滑的,反过来(把拆好的字段合并回 JSON)要痛苦得多。

所以答案不是"JSON 更好",而是:把 schema 决策从建表那一刻推迟到真正需要约束它的那一刻。核心业务实体(订单、用户)老老实实拆字段;外围的、多变的、只是存着备查的,用 JSON。

六、实战建议

  1. PG 默认选 jsonb,配 GIN 索引;只有审计留痕才用 json。
  2. 高频过滤字段应"提升"为独立列:PG 用表达式索引,MySQL 用生成列 / 多值索引,避免全表扫。
  3. Go 侧统一用 JSON[T] 泛型包装器,一处定义处处复用,兼顾类型安全与 NULL 处理。
  4. 能在 SQL 里过滤就别拉回内存:@>(PG)和 MEMBER OF(MySQL 8)都能命中索引,远快于把整表捞回 Go 再筛。
  5. 跨库项目优先 GORM datatypes.JSONType[T],屏蔽两库语法差异。
  6. 注意大字段的写放大:MySQL 8 的 JSON_SET 支持部分更新(in-place),但字段变长时仍会整体重写;PG 的 jsonb 更新总是整值重写,超大文档要谨慎。

七、参考文档


适用版本:PostgreSQL 12+、MySQL 8.0+、Go 1.18+(泛型)。

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论