← 返回 💻 编程 & 软件工程

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

鸟窝 smallnest 9月8日 colobu.com

Go by smallnest

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


一、三种类型的本质区别

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

二、PostgreSQL 中的 json 与 jsonb

2.1 建表与写入

1

2

3

4

5

6

7

CREATE TABLE events (

id BIGSERIAL PRIMARY KEY,

payload JSONB NOT NULL

);

INSERT INTO events (payload) VALUES

('{"user": "alice", "tags": ["go", "db"], "score": 42}');

2.2 常用操作符

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

-- -> 取子对象(返回 jsonb),->> 取文本(返回 text)

SELECT payload -> 'user' FROM events; -- "alice"(带引号,jsonb)

SELECT payload ->> 'user' FROM events; -- alice(纯文本)

-- 路径取值 #> / #>>

SELECT payload #>> '{tags, 0}' FROM events; -- go

-- 包含判断 @>(jsonb 专属,配合 GIN 索引极快)

SELECT * FROM events WHERE payload @> '{"user": "alice"}';

-- 键是否存在 ?(注意:? 只作用于「顶层」键)

SELECT * FROM events WHERE payload ? 'score';

-- 判断嵌套键,要先下钻到那一层再 ?

SELECT * FROM events WHERE payload -> 'dimensions' ? 'weight';

-- 任一键存在 ?| / 所有键存在 ?&

SELECT * FROM events WHERE payload ?| array['score', 'level'];

-- 用 JSON 内的值做过滤:->> 取出的是 text,需显式类型转换

SELECT * FROM events WHERE (payload ->> 'score')::numeric < 100;

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

2.3 索引

1

2

3

4

5

6

7

8

-- 通用 GIN 索引,支持 @> ? ?| ?& 等

CREATE INDEX idx_payload ON events USING GIN (payload);

-- jsonb_path_ops:更小更快,但只支持 @>

CREATE INDEX idx_payload_path ON events USING GIN (payload jsonb_path_ops);

-- 针对单个字段的表达式 B-Tree 索引

CREATE INDEX idx_user ON events ((payload ->> 'user'));

2.4 修改与聚合

1

2

3

4

5

6

7

8

9

10

11

12

13

14

-- 更新某个键(jsonb_set)

UPDATE events SET payload = jsonb_set(payload, '{score}', '100') WHERE id = 1;

-- 删除键

UPDATE events SET payload = payload - 'tags' WHERE id = 1;

-- 合并两个 jsonb(|| 后者覆盖前者)

SELECT '{"a":1}'::jsonb || '{"b":2}'::jsonb; -- {"a":1,"b":2}

-- 展开数组元素

SELECT jsonb_array_elements_text(payload -> 'tags') FROM events;

-- JSONPath(PG12+)

SELECT jsonb_path_query(payload, '$.tags[*] ? (@ == "go")') FROM events;


三、MySQL 中的 json 类型

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

3.1 建表与写入

1

2

3

4

5

6

7

CREATE TABLE events (

id BIGINT AUTO_INCREMENT PRIMARY KEY,

payload JSON NOT NULL

);

INSERT INTO events (payload) VALUES

('{"user": "alice", "tags": ["go", "db"], "score": 42}');

3.2 常用函数与路径

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

-- 取值:JSON_EXTRACT 或 -> 语法糖

SELECT JSON_EXTRACT(payload, '$.user') FROM events; -- "alice"

SELECT payload -> '$.user' FROM events; -- "alice"

SELECT payload ->> '$.user' FROM events; -- alice(去引号,等价 JSON_UNQUOTE)

-- 数组元素

SELECT payload ->> '$.tags[0]' FROM events; -- go

-- 是否包含

SELECT * FROM events WHERE JSON_CONTAINS(payload, '"alice"', '$.user');

-- 路径是否存在

SELECT * FROM events WHERE JSON_CONTAINS_PATH(payload, 'one', '$.score');

-- 键集合 / 长度

SELECT JSON_KEYS(payload), JSON_LENGTH(payload -> '$.tags') FROM events;

3.3 修改

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

-- 设置(存在则改,不存在则加)

UPDATE events SET payload = JSON_SET(payload, '$.score', 100) WHERE id = 1;

-- 仅新增(已存在不动) / 仅替换(不存在不加)

UPDATE events SET payload = JSON_INSERT(payload, '$.level', 5);

UPDATE events SET payload = JSON_REPLACE(payload, '$.score', 0);

-- 删除

UPDATE events SET payload = JSON_REMOVE(payload, '$.tags');

-- 数组追加

UPDATE events SET payload = JSON_ARRAY_APPEND(payload, '$.tags', 'sql');

-- 合并(保留两边所有值)

SELECT JSON_MERGE_PRESERVE('{"a":1}', '{"a":2}'); -- {"a":[1,2]}

SELECT JSON_MERGE_PATCH('{"a":1}', '{"a":2}'); -- {"a":2}(RFC 7396)

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

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

-- 方式一:生成列 + 普通索引

ALTER TABLE events

ADD COLUMN user_name VARCHAR(64)

AS (payload ->> '$.user') STORED,

ADD INDEX idx_user (user_name);

-- 方式二:函数索引(MySQL 8.0.13+)

ALTER TABLE events ADD INDEX idx_score ((CAST(payload ->> '$.score' AS UNSIGNED)));

-- 方式三:多值索引,专为 JSON 数组设计(8.0.17+)

ALTER TABLE events

ADD INDEX idx_tags ((CAST(payload -> '$.tags' AS CHAR(32) ARRAY)));

-- 之后可用 MEMBER OF / JSON_CONTAINS 命中索引

SELECT * FROM events WHERE 'go' MEMBER OF (payload -> '$.tags');


四、Go 语言处理 JSON 列

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

4.1 最简单:字符串进出

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

1

2

3

var raw []byte

err := db.QueryRow("SELECT payload FROM events WHERE id = $1", 1).Scan(&raw)

// raw 里就是 {"user":"alice",...},再自行 json.Unmarshal

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

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

1

2

3

4

var raw json.RawMessage

db.QueryRow("SELECT payload FROM events WHERE id = ?", 1).Scan(&raw)

var p Payload

_ = json.Unmarshal(raw, &p)

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

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

package jsontype

import (

"database/sql/driver"

"encoding/json"

"fmt"

)

// JSON[T] 让任意类型 T 可直接作为 JSON/JSONB 列读写。

type JSON[T any] struct {

Val T

}

func New[T any](v T) JSON[T] { return JSON[T]{Val: v} }

// Value 实现 driver.Valuer:写入数据库时调用。

func (j JSON[T]) Value() (driver.Value, error) {

b, err := json.Marshal(j.Val)

if err != nil {

return nil, err

}

return b, nil // 返回 []byte,pq / mysql 驱动都接受

}

// Scan 实现 sql.Scanner:从数据库读取时调用。

func (j *JSON[T]) Scan(src any) error {

if src == nil {

var zero T

j.Val = zero

return nil

}

var b []byte

switch v := src.(type) {

case []byte:

b = v

case string:

b = []byte(v)

default:

return fmt.Errorf("jsontype: 不支持的类型 %T", src)

}

return json.Unmarshal(b, &j.Val)

}

使用:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

type Payload struct {

User string `json:"user"`

Tags []string `json:"tags"`

Score int `json:"score"`

}

// 写入

p := jsontype.New(Payload{User: "alice", Tags: []string{"go", "db"}, Score: 42})

_, err := db.Exec("INSERT INTO events (payload) VALUES ($1)", p)

// 读取

var got jsontype.JSON[Payload]

err = db.QueryRow("SELECT payload FROM events WHERE id = $1", 1).Scan(&got)

fmt.Println(got.Val.User) // alice

说明: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 时,让包装类型支持空值:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

type NullJSON[T any] struct {

Val T

Valid bool // 为 false 表示数据库中是 NULL

}

func (j NullJSON[T]) Value() (driver.Value, error) {

if !j.Valid {

return nil, nil

}

return json.Marshal(j.Val)

}

func (j *NullJSON[T]) Scan(src any) error {

if src == nil {

j.Valid = false

return nil

}

j.Valid = true

b, ok := src.([]byte)

if !ok {

b = []byte(src.(string))

}

return json.Unmarshal(b, &j.Val)

}

4.4 结构未知时:映射到 map

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

1

2

3

4

5

// 复用 4.2 的泛型包装器,T = map[string]any

var attrs jsontype.JSON[map[string]any]

err = db.QueryRow("SELECT payload FROM events WHERE id = $1", 1).Scan(&attrs)

weight, ok := attrs.Val["weight"].(float64) // 代价:取值要逐个类型断言

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

在 Go 1.18 泛型之前,更常见的是定义一个具名 map 类型并直接在其上挂 Value/Scan(coussej 称之为 PropertyMap),一处定义即可被 orders、customers、books 等各种实体复用:

1

2

3

4

5

6

7

8

9

10

type PropertyMap map[string]any

func (p PropertyMap) Value() (driver.Value, error) { return json.Marshal(p) }

func (p *PropertyMap) Scan(src any) error {

b, ok := src.([]byte)

if !ok {

return errors.New("type assertion to []byte failed")

}

return json.Unmarshal(b, p)

}

泛型 JSON[T] 本质是它的通用化版本——一份代码同时覆盖 struct 与 map,无需为每种形态各写一个类型。

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

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

1

2

3

4

5

6

// PostgreSQL

var user string

db.QueryRow(`SELECT payload ->> 'user' FROM events WHERE id = $1`, 1).Scan(&user)

// MySQL

db.QueryRow(`SELECT payload ->> '$.user' FROM events WHERE id = ?`, 1).Scan(&user)

4.6 各驱动 / ORM 的原生支持

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

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

import "gorm.io/datatypes"

type Event struct {

ID uint

Payload datatypes.JSONType[Payload] // 建表时自动映射为 jsonb / json

}

// 写

db.Create(&Event{Payload: datatypes.NewJSONType(Payload{User: "alice"})})

// 读

var e Event

db.First(&e, 1)

fmt.Println(e.Payload.Data().User)

// 按 JSON 字段查询(跨库表达式)

db.Where(datatypes.JSONQuery("payload").Equals("alice", "user")).Find(&events)


五、为什么要用 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 里:

1

2

3

4

5

6

7

-- PG:生成列,把热字段暴露成普通列(可建 B-Tree、有统计信息)

ALTER TABLE events

ADD COLUMN user_name text

GENERATED ALWAYS AS (payload ->> 'user') STORED;

-- 或者只在查询侧建表达式索引,不动表结构

CREATE INDEX idx_user ON events ((payload ->> 'user'));

还有一个实用的演进视角: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+(泛型)。

在原文站打开 ↗

Cloudflare Workers 每 3 分钟抓一批,9 批轮完最快约 27 分钟 · 点右上 ↻ 立刻全量抓一次