PG 9.4版本里面,增强了对json数据的支持,受到了很大关注。9.4之前,PG已经原生支持json数据类型了,但只是用字符串的形式存储和处理。这样做天然有性能上的缺点:每次对json字符串里面的数据进行查询,一般需要全表扫描加字符串匹配,效率很低。当然也可以在存储json的字符串字段上创建GIN索引,但需要对查询中用到的json的key或value创建单独索引,造成要被动维护很多索引。所以,这种json类型,只适用于把PG单纯作为数据存储,只读入读出数据,不对数据进行限定key或value查询的场景。
PG 9.4中引入了jsonb类型。其特点是,将json数据中的key和value进行解析,转换为PG的基本数据类型,包括数字,字符串和布尔类型等;同时,增加了对应的GIN处理函数,可以将json中的所有key和value转换为GIN索引的key。这样,只用一个GIN索引,即可实现对所有key或value的条件查询。下面我们分析一下jsonb的使用方法和内核实现。
使用
创建含jsonb类型的表方法如下所示:
创建GIN索引的方法如下:
可以使用下面的查询得到含有<product, ProgreSQL>键值对的行:
内核实现
先分析一下jsonb是如何从字符串,变成特殊的二进制形式存入磁盘的。追踪一下jsonb插入的过程,可以看到PG所调用的函数流程如下。
其中,pg_parse_json先把用户输入的字符串,通过编译器转换为一个树形结构(每个节点的类型为JsonbValue)。然后JsonbValueToJsonb在这个结构基础上,转换为存入磁盘的格式。从convertJsonbObject函数可以看出,转换为磁盘格式的策略为:从树形结构的根部开始遍历,递归进行广度优先遍历。对于同一父亲下面的子键值,将所有键名(字符串)长度写入buffer中预留的头部,随后将键名依次写入buffer中。最后再以相似的方式写入键所对应的所有值(值如果是json对象,则递归调用)。这样,读入buffer的头部,就可以遍历出所有键名的位置,得到键名。再从读第一个键值开始,读入对应的值或子键,最终得到整个树(见JsonbIteratorNext)。
采用这种存储方式,jsonb所占用的存储空间比原来支持的json类型要多一些。其实,jsonb的核心优势在于快速和灵活的索引。从前面创建index的语句可以看到,jsonb支持两种特有的GIN索引jsonb_ops和jsonb_path_ops。我们知道,GIN索引建立时,会先通过内建函数从表中每行数据的索引字段的值中,抽取键(key),一个字段值一般可抽取多个key。然后,将每个key与含有此key的所有行的ID组成键值对,再将它们插入b树索引供查询。那么这两种GIN索引有什么区别呢?
它们的区别在于,生成GIN key的方式不同。jsonb_ops调用gin_extract_jsonb函数生成key,这样每个字段的json数据中的所有键和值都被转成GIN的key;而jsonb_path_ops使用函数gin_extract_jsonb_path抽取:如果将一个jsonb类型的字段值看做一颗树,叶子节点为具体的值,中间节点为键,则抽取的每个键值实际上时每个从根节点到叶子节点的路径对应的hash值。
不难推测,jsonb_path_ops索引的key的数目和jsonb的叶子节点数有关,用叶子节点的路径做查询条件时会比较快(这也是这种索引唯一支持的查询方式);而jsonb_ops索引的key的数目与jsonb包含的键和值(即树形结构的所有节点)的总数有关,可以用于路径查询之外的其他查询。