本文是《PostgreSQL技术问答》系列文章中的一篇。关于这个系列的由来,可以参阅开篇文章:
《PostgreSQL技术问答00 - Why Postgres》
文章的编号只是一个标识,在系列中没有明确的逻辑顺序和意义。读者进行阅读时,不用太关注这个方面。
本文讨论的内容是PostgreSQL对于JSON的支持。
什么是JSON
这里简单复习一下。JSON,全意为JavaScript Object Notation,即JavaScrip对象表示。顾名思义,它原来就是JavaScript语言使用的一种对象的呈现方式。它使用一种结构化的字符串形式,来对数据对象进行表达数据。所以,对人类而言,它同样易于阅读和编写。下面是一个简单的例子(来自JSON WIKI):
sql
{
"first_name": "John",
"last_name": "Smith",
"is_alive": true,
"age": 27,
"address": {
"street_address": "21 2nd Street",
"city": "New York",
"state": "NY",
"postal_code": "10021-3100"
},
"phone_numbers": [
{
"type": "home",
"number": "212 555-1234"
},
{
"type": "office",
"number": "646 555-4567"
}
],
"children": [
"Catherine",
"Thomas",
"Trevor"
],
"spouse": null
}
完整的JSON格式定义,我们可以参考下列官方技术文档:www.json.org/json-zh.htm...
JSON的定义和规则如此简单,只使用一张图就可以概括和总结(来自Wiki):
这里稍微用文字可以总结一下:
- JSON使用结构化的字符串来表达对象和数组
- 对象的基本形式是大括号 "{}"围起来的键值对,键值使用冒号分隔,键值对以逗号分隔
- 对象的键必须是一个字符串
- 对象的值可以是各种数据类型包括null、布尔、数值、字符串、对象和数组等
- 字符串和键使用""包括来表示
- 数值可以包括正负整数和浮点数
- 布尔包括true和false关键字来表示
- 数组的基本形式是中括号 "[]"围起来的有序的对象或者值列表,以逗号分隔
- 数组的内容,可以是对象的值的各种形式
- 可以在对象和数组之间进行嵌套
- JSON字符串使用UTF-8编码
相比传统编程语言的二进制数据对象结构和同样基于字符串文本的XML等表示方式,它具有以下特点:
- 完全公开的定义和标准
- 简单直观,轻量化
- 同时易于人类的编写阅读,和程序的自动化解析和处理
- 方便进行跨平台的存储和网络的传输
- 原生的JavaScript支持
基于上面的形式和特点,以及它在Web应用开发中的广泛使用,JSON已经成为Web应用开发的一个事实上的标准,被广泛的应用。
当然,世界上没有完美的事物,JSON也不能例外。JSON在技术上的不足之处主要是:
- 字符串解析和处理,相对二进制结构体效率稍低
- 缺乏对时间日期、二进制数据的原生支持(虽然可以使用数值和Base64字符串处理)
- 没有对原生大数值的支持
- 一般的JSON解析实现,不保证键的次序
- 数据压缩不好实现或者处理
- 不能在内部保证数据和格式的完备,和冗余处理
- 不支持注释信息
为什么Postgres要支持JSON
笔者认为,简单的说,就是适应Web应用开发技术的发展。现代化的网络业务应用系统,基本上都是Web应用系统。就是整个系统大体体现为客户端和服务器的范式。而客户端基本上已经完全标准化,就是使用Web浏览器技术作为其标准客户端和技术基础。而在浏览器中,则使用JavaScript作为主要编程和应用语言。JSON可以看成是JS语言的一个组成部分,所以,JSON就是Web技术中,事实上的用于结构化数据表示的技术标准,被广泛的应用到数据表示、数据存储、传输和交换、配置信息等各种各样的应用场景当中。
顺应这个Web应用发展的潮流,主流的关系型数据库系统,都逐渐的实现了对JSON数据结构和处理的支持,来提高数据存储和处理的包容性,提升系统的开发效率和兼容性。PG是其中发展的比较早的,从9.2版本(大约是2012年)开始,我们就看到了相关特性的支持。我们现在还可以在技术文档中看到相关的内容,当然那时候的特性还是非常简陋,现在对JSON比较完善和丰富的特性,也是在后续长期的演进过程中发展起来的。
除了对Web应用的支持之外,对于JSON的支持,对于关系数据库之外,可能还意味着一个重大的范式转换,就是打破和扩展了传统关系数据基于"表",就是行和列的基本结构的限制,引入了可变的数据结构。这样显然可以提高对于业务和应用的支持的灵活性,更好的适应了现在应用程序敏捷开发和快速演进的发展和应用模式。
例如,在原来的数据库应用系统系统,需要在开发前,定义好数据结构。比如一个用户,它有标识、名称和联系方式等属性,通常使用对应的数据字段来标识。但问题是,不同的用户,他可能具有的联系方式差异很大,如不同的社交平台用户、很多种电话号码等等,JSON可以按照需求设计这个结构,而且很容易修改和扩展,就比传统的数据库字段定义要方便灵活很多。
在最近的几个版本中,JSON已经成为SQL的标准。同样,有一个发展的过程,而且仍然在发展过程当中。
什么是JSONB
我们在Postgres数据库中,更常见和推荐使用的JSON格式,并不是原生的JSON字符串,而是JSONB,即JSON的Binary(二进制)形式。PostgreSQL支持使用JSONB,可能主要出于以下考量:
- 二进制格式存储数据,而不是文本格式,可以使存储更加紧凑,处理更加高效
- JSONB支持GIN (Generalized Inverted Index) 索引,可以显著提高查询性能
- JSONB自动去除重复的对象键,保证无重复键
- JSONB可以保留键顺序
- PG专门针对JSONB数据,进行相关操作和处理的优化
为此,PostgreSQL还提供了响应的JSONB的信息存储和处理的机制,例如提供了多种操作符和函数来处理JSONB数据,让用户可以之间使用SQL来访问和操作JSONB数据,并且支持很大复杂的数据查询和分析操作。
对于使用和开发而言,使用JSONB几乎是透明的,用户基本无法感知使用JSONB和JSON对象之间的区别。例如Nodejs配套的PG程序库,可以直接处理JSONB对象,比如查询时,可以直接从JSONB数据中,转换到JS环境中的JS对象。大大提高了开发效率。同样,我们下面的讨论内容和示例,如果不是特别说明,JSON和JSONB在应用方面,是基本没有什么差异的。
PostgreSQL如何实现对JSON的支持
在PostgreSQL中,对于JSON(JSONB)的应用,包括下列常用的场景和用法:
字段数据类型
在创建表时,可以为字段定义和使用JSON的数据类型。
sql
CREATE TABLE jusers (
id SERIAL PRIMARY KEY,
data JSONB
);
CREATE TABLE
\d jusers;
Table "public.jusers"
Column | Type | Collation | Nullable | Default
--------+---------+-----------+----------+------------------------------------
id | integer | | not null | nextval('jusers_id_seq'::regclass)
data | jsonb | | |
Indexes:
"jusers_pkey" PRIMARY KEY, btree (id)
插入数据
下面的SQL语句,可以像插入普通记录一样,插入JSON数据,注意这里使用字符串(单引号包围)来表达JSON对象。
sql
defaultdb=> INSERT INTO jusers (id,data) VALUES (1001, '{"name": "John", "age": 30}');
INSERT 0 1
defaultdb=> INSERT INTO jusers (id,data) VALUES (1002, '{"name": "Tom", "gender": 1}');
INSERT 0 1
defaultdb=> select * from jusers;
id | data
------+------------------------------
1001 | {"age": 30, "name": "John"}
1002 | {"name": "Tom", "gender": 1}
(2 rows)
数据查询
当然,JSON数据可以作为普通字符串进行查询,但这样就没有什么意义了。PG提供了一套语法和机制,来方便开发者使用和查询JSON数据。下面是一个简单的例子:
sql
> select data->>'name' name, data->>'gender' gender, data->'age' age from jusers;
name | gender | age
------+--------+-----
John | | 30
Tom | 1 |
(2 rows)
可能由于SQL语义的一些限制,PG不能使用普通编程语言中处理JSON的"."和方括号等的表示和引用方法,而是重新构造了一套操作符来处理,确实也是带来了一些学习曲线,我们后面有更详细的列举和说明。
除此之外,JSON信息和普通的数据库字段的使用方式差异不大,可以正常用在字段列表、条件检查等常用的数据查询场合。
数据修改
前面探讨了PG中,对应JSON字段属性的引用方式和规则。很直观的,我们就可以想象可以使用这个方式来进行属性的修改。但很遗憾,Postgres无法支持这个想法,它不能直接对JSON字段的属性进行修改,而只能处理整个JSON对象。示例如下:
sql
-- 错误的修改
update jusers set data->>'gender' = 1 where id = 1001;
ERROR: syntax error at or near "->>"
LINE 1: update jusers set data->>'gender' = 1 where id = 1001;
-- 正确的修改
update jusers set data = data || '{"gender":1}'::jsonb
where data->>'name' = 'John' returning *;
id | data
------+------------------------------------------
1001 | {"age": 30, "name": "John", "gender": 1}
(1 row)
UPDATE 1
-- 删除属性 - 使用操作符
update jusers set data = data - 'gender'
where data->>'name' = 'John' returning *;
id | data
------+-----------------------------
1001 | {"age": 30, "name": "John"}
(1 row)
UPDATE 1
-- jsonb_set函数,增加或修改路径上的对象
update jusers set data = jsonb_set( data, '{address}', '"ChengDu"' )
where data->>'name' = 'John' returning *;
id | data
------+---------------------------------------------------
1001 | {"age": 30, "name": "John", "address": "ChengDu"}
(1 row)
UPDATE 1
-- insert函数,插入值到JSON数组中
select jsonb_insert('{"a": [0,1,2]}', '{a, 1}', '"new_value"') ;
jsonb_insert
-------------------------------
{"a": [0, "new_value", 1, 2]}
(1 row)
要修改一个JSON字段的属性,它的基本处理方式是整个修改这个字段完整的JSON字符串。当然我们一般不会这样做,通常只修改其中某个属性的内容,这时可以使用"合并"的方式,即引用原来的对象,合并新的属性。这个操作方法可以处理增加或者修改属性的情况,因为JSON当遇到重复的键的时候,它只接收最后的键值定义。
如果要删除JSON的键或者数组值,可以使用 "-" 操作符。如果要增加键,或者修改键上的值,还可以使用json_set函数。如果要增加值到JSON数组中,可以使用json_insert函数。
构造和转换
前面了解了使用字符串来表示JSON的方法。除此之外,PG还支持很多方法用不同的方式来构造JSON数据。下面是一些简单的例子:
sql
-- json_build_object方法, 键-值数组转换为JSON
defaultdb=> SELECT json_build_object('name', 'John', 'age', 30);
json_build_object
-------------------------------
{"name" : "John", "age" : 30}
(1 row)
-- json_object,由JSON对象,灵活构建
select json_object('{a,b}', '{1,2}')
union all
select json_object('{a, 1, b, "def", c, 3.5}')
union all
select json_object('{{a, 1}, {b, "def"}, {c, 3.5}}') ;
json_object
---------------------------------------
{"a" : "1", "b" : "2"}
{"a" : "1", "b" : "def", "c" : "3.5"}
{"a" : "1", "b" : "def", "c" : "3.5"}
(3 rows)
-- json_build_array,列表值构造JSON数组
select json_build_array(1, 2, 'foo', 4, 5);
json_build_array
---------------------
[1, 2, "foo", 4, 5]
(1 row)
-- to_json/to_jsonb方法,
select to_jsonb(row(42, 'Fred said "Hi."'::text));
to_jsonb
---------------------------------------
{"f1": 42, "f2": "Fred said \"Hi.\""}
(1 row)
-- row_to_json, 将一个行对象或者记录转换为JSON
select row_to_json(row(1,'foo'));
row_to_json
---------------------
{"f1":1,"f2":"foo"}
(1 row)
select row_to_json(jusers) from jusers where id = 1001;
row_to_json
-------------------------------------------------------------
{"id":1001,"data":{"age": 30, "name": "John", "gender": 2}}
(1 row)
-- array_to_json, 数组转换为JSON
select array_to_json('{{1,5},{99,100}}'::int[]);
array_to_json
------------------
[[1,5],[99,100]]
(1 row)
-- json_agg, JSON对象的聚合,返回的是一条记录,字段内容是JSON数组
select json_agg(jusers), json_agg(id) from jusers;
json_agg | json_agg
---------------------------------------------------------------+--------------
[{"id":1002,"data":{"name": "Tom", "gender": 1}}, +| [1002, 1001]
{"id":1001,"data":{"age": 30, "name": "John", "gender": 2}}] |
(1 row)
除了构造之外,常见的操作,就是将JSON转换为标准记录集,如下面的操作:
sql
-- JSON转换为记录
select * from json_to_recordset('[{"a":1,"b":"foo"}, {"a":"2","c":"bar"}]') as x(a int, b text);
a | b
---+-----
1 | foo
2 |
(2 rows)
PG中,关于JSON特性的内容很多,这里由于篇幅的限制,只能挑选一些基础和常用的方面进行探讨。完整的JSON功能特性和使用方式,可以参见PG官方技术文档的相关章节:
www.postgresql.org/docs/15/fun...
JSON有那些引用操作符
前面我们已经看到了JSON可以使用"->>"操作符,来对JSON对象的属性进行引用。但实际上,PG定义了很多类似的操作符,来满足不同场景的需求,而且它们之间可能只有很细微的差别,需要开发者在使用中熟悉和区分。
- json -> integer → json/jsonb
用于JSON数组,可以使用 -> 符号,结合一个索引数字,来引用JSON数组的元素。引用的索引,从0开始,结果是一个JSON对象或者值。
sql
with J(v) as (values ('[{"a":"foo"},{"b":"bar"},{"c":"baz"}, "some value"]'::json))
select v -> 2, v->3, v->5 from J;
?column? | ?column? | ?column?
-------------+--------------+----------
{"c":"baz"} | "some value" |
(1 row)
- json/jsonb ->> integer → text
要特别注意这个 "->>" 操作符,它的意思是,返回值是一个标量,如text或者interger等。
- json -> text → json/jsonb
用于JSON对象,使用字符串作为键进行引用匹配的值,结果是一个JSON或者值。
sql
with J(id, jdata) as (values
(1001, '{"name": "John", "gender": 1, "contact": { "email": "user@mail.com" }}'::jsonb),
(1002, '{"name":"Mary", "gender":2, "age": 28 , "contact":{ "mobile": "13800138000" }}'::jsonb))
select id, jdata ->> 'name', jdata -> 'contact' ->> 'email', jdata ->> 'contact' from J;
id | ?column? | ?column? | ?column?
------+----------+---------------+----------------------------
1001 | John | user@mail.com | {"email": "user@mail.com"}
1002 | Mary | | {"mobile": "13800138000"}
(2 rows)
这里面的要点是,如果结果是一个JSON,它还可以继续引用,而如果是一个字符串,显然是不行的。
- json/jsonb ->> text → text
同理, 使用 "->>" 操作符,得到的是一个标量(字符串)。
- json #> text[] → json/jsonb
这个用于使用路径进行引用,结果是一个JSON对象或者值。这里使用一个JSON格式的对象,来表示路径层次。
sql
select data #> '{a,b,1}',data #>> '{a,b,1}'
from lateral (values ( '{"a": {"b": ["foo","bar"]}}'::json)) as D(data);
?column? | ?column?
----------+----------
"bar" | bar
(1 row)
这里的 a-b-1,就是引用路径,对于对象而言就是键字符串,对于数组而言就是索引(从零开始)。注意这里的数据类型和表达方式是Postgres Array而非JSON Array,这个写法的好处是可以简化深层次引用,并且可以使用标准的JSON方式来构造路径。
- json/jsonb #>> text[] → text
和上面的例子相似,但得到的是一个标量。例子代码中,也可以看到两者的细微区别(注意结果中的双引号,表示它是一个JSON类型的字符串)。
除了上面常用的引用操作符之外,其他常见使用操作符,对JSON字段进行的操作还包括:
- jsonb @> jsonb → boolean, 数据包含检查
检查JSON是否包含或者被包含(<@)某个属性。如:
'{"a":1, "b":2}'::jsonb @> '{"b":2}'::jsonb → t
- jsonb ? text → boolean
检查JSON对象是否包含某个键,或者JSON数组是否包括某个值,针对第一层。
'{"a":1, "b":2}'::jsonb ? 'b' → t ; '["a", "b", "c"]'::jsonb ? 'b' → t
- jsonb ?| text[] → boolean
检查JSON对象或数组是否包括文本数组中相同的键或者元素。也是针对第一层。
'{"a":1, "b":2, "c":3}'::jsonb ?| array['b', 'd'] → t
- jsonb ?& text[] → boolean
检查是否文本数组中,所有的元素,都是JSON对象的键,或者都在JSON数组中(第一层)。
'["a", "b", "c"]'::jsonb ?& array['a', 'b'] → t
- jsonb || jsonb → jsonb
合并两个JSON对象或者数组。需要注意,操作的元素都是JSON对象,如果是标量,可能需要先转换一下。这个功能经常用作给JSON对象添加属性,或者扩展JSON数组。
sql
defaultdb=> select '[1,2]'::jsonb || '3'::jsonb || '[5,6]'::jsonb;
?column?
-----------------
[1, 2, 3, 5, 6]
(1 row)
- jsonb - text → jsonb/ jsonb - text[] → jsonb
从对象或数组中删除键或者值。如
'{"a": "b", "c": "d"}'::jsonb - 'a' → {"c": "d"}
'["a", "b", "c", "b"]'::jsonb - 'b' → ["a", "c"]
'{"a": "b", "c": "d"}'::jsonb - '{a,c}'::text[] → {}
- jsonb - integer → jsonb
从JSON数组中,删除索引所在的值。
'["a", "b", "c"]'::jsonb - 1 → ["a","c"]
- jsonb #- text[] → jsonb
删除JSON路径上的元素。
'["a", {"b":1}]'::jsonb #- '{1,b}' → ["a", {}]
- jsonb @? jsonpath → boolean
检查JSON对象中,json路径上是否存在有效值。这里使用jsonpath表达形式
'{"a":[1,2,3,4,5]}'::jsonb @? '$.a[*] ? (@ > 2)' → t
- jsonb @@ jsonpath → boolean
对JSON对象执行JSON路径谓词检查,并返回第一项的结果。如果结果不是布尔值,则返回NULL。
'{"a":[1,2,3,4,5]}'::jsonb @@ '$.a[*] > 2' → t
在PG中,如何处理JSON数据
前一章节的内容,主要从操作符的角度,来讨论对于JSON对象的操作。除了操作符之外,PG还提供了很多相关的函数。 这里处理的意思是基于数据库中的JSON字段和对象,进行相关的计算和转换,来满足一些常见的业务需求。这里的内容比较多,只简单列举函数的形式和功能,不再举例展开说明。
其中比较重要和常见的函数包括:
- json_array_elements, 将JSON数组,展开为数据记录集,类似unnest,字段类型为JSON
- json_array_elements_text,和前面类似,但数据类型为TEXT
- json_array_length, 获得JSON数组长度
- json_each/json_each_text,将JSON数组,展开(遍历)成为key/value的记录集形式,text为其值文本版本
- json_extract_path /json_extract_path_text, 使用路径数组的方式,访问JSON对象路径中的值
- json_object_keys/ json_object_keys_text, 获取第一层的键值,并展开为多条记录
- json_populate_record, 将JSON对象,展开为键作为字段名的一套记录,记录字段值为键对应的值
- json_to_record/json_to_recordset,JSON对象转换为记录和记录集
- jsonb_insert,在JSON对象或数组中,指定路径位置上,插入JSON对象或者值
- jsonb_set/jsonb_set_lax, 替换JSON对象,路径位置上,元素的值
- json_strip_nulls/jsonb_strip_nulls, 可以清理对象中,值为null的项目,但不会影响数组中的null值
- jsonb_pretty, 按照换行和缩进方式显示JSON字符串,可读性更强
- json_typeof/json_typeof: 检查JSON值的数据类型,输出为字符串,如null, string, number...
- jsonb_path_xxx, JSON Path相关功能,这部分的内容,笔者会另行撰文讨论
这些JSON函数,一般都有JSONB的版本,可以处理JSONB类型的数据。
什么是JSON路径
我们在很多JSON方法中,都可以看到这两个参数: path text[], 和 path jsonpath,这两个其实都是表示在JSON对象中,进行属性值查询的方式,称为path路径。
但在不同的函数中,需要的路径的类型不同,一类是 path text[],就是一个字符串类型的数组。例如下面这个jsonb_set函数,就使用text[]作为路径表示来进行属性值的设置:
sql
select jsonb_set('[{"f1":1,"f2":null},2]', '{0,f3}', '[2,3,4]');
jsonb_set
---------------------------------------------
[{"f1": 1, "f2": null, "f3": [2, 3, 4]}, 2]
(1 row)
上面这个例子要进行的操作的意思是, 将JSON对象(这里是数组),第0个元素的"f3"的键值设为 [2,3,4]。因为原来这个元素没有f3键,本操作会增加一个键并且赋值。这里的 0-f3就是路径数组的值。
另一类的数据类型是jsonpath,它是使用一个字符串表示的寻址方式,在PG中有专门设计的语法和格式,也可以提供更强大的寻址功能如模式匹配和条件判断等等。也被称为SQL/JSON Path Language,这一部分内容比较多,笔者有另行撰文讨论。
Postgers如何保证JSON处理的效率和性能
在数据库中支持JSON数据类型,一个显而易见的问题就是对性能带来的负面影响。虽然本质上JSON就是一个字符串,但由于不能使用结构化的形式进行处理,就不能像普通的标量字段一样,来保证查询和操作的性能。
简单而言,Postgres为提高JSON信息的处理性能,应用了如下一些优化措施和方法:
- JSONB存储格式
PG可以选择使用JSONB作为JSON的数据存储格式,这通常不会对应用产生影响。但JSONB数据可以通过合理的压缩和数据去重而更加紧凑,节省存储的空间。同时JSONB可能可以减少数据的编解码转换工作,从而提高处理效率。
- 索引支持
Postgres中,可以支持直接对JSON属性设置索引。在这里,和普通的字段索引没有太大区别。
作为普遍的解决方案,除了对于特定属性之外,PostgreSQL还支持对JSON和JSONB数据使用GIN或者GiST索引,来提高数据访问和查询的性能。一般认为,GIN索引适用于包含多个键的JSON文档,而GiST索引则适用于包含少量键的JSON文档。开发者应当根据自己的使用场景进行评估和使用。
- 并行处理
从PostgreSQL 9.6版本开始,PG支持并行序列化执行和并行工作进程,从而实现了JSON数据的并行处理,以提高查询性能。
即便如此,由于需要处理额外的结构和信息,数据库系统对于JSON数据的处理,也会付出相对标量数据更高的代价,所以在使用JSON时,需要额外注意数据和结构的设计,避免不必要的信息和处理过程,做好性能评估和优化工作。
在Postgres中使用JSON有什么需要注意的问题
根据笔者在Postgres中研究和使用JSON的经验,觉得在这个过程中,需要注意下列问题:
- 学习曲线
由于Postgres对JSON的设计实现主要通过两种方式,就是操作符和函数,而且由于功能的复杂性和完善性,Postgres引入了很多新的表示方式,所以看起来这方面的内容是很多的。这些给开发者的记忆、学习、理解和应用都带来了一些负担。例如,要用好这些JSON的功能,就需要开发者记忆和理解很多规则性的符号和函数名称,并且理解和辨识这些操作之间的差异,以及它们合适的应用场景。
- 要特别注意数据类型
Postgres相关JSON的操作符和函数的定义比较严格和复杂,要特别注意其参数的数据类型,否则可能不能得到期望的结果。但这个问题并不是特别难处理,一般情况下合理的进行数据类型转换就可以了。
- 性能问题
JSON带来的性能问题更加复杂,特别是数据规模比较大的时候,需要更加细致的分析和解决。如如果数据结构和属性项目相对稳定,可以考虑选择一般索引,或者GIN索引。
小结
本文探讨了Postgres作为一个强大的Web应用支撑系统,所实现和具备的一个丰富和强大的功能集合-JSON。研究了在PG应用中是如何实现对JSON的支持的,其基本的形态和应用场景,相关的操作符和函数,以及需要注意的问题等等。