searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

PostgreSQL jsonb数据类型的应用和内核优化

2024-10-17 09:34:27
1
0

提纲

  1. PostgreSQL json数据类型介绍 及 与MongoDB 的对比
  2. Toast 设计原则和存在的问题
  3. json 场景下Toast设计缺陷的放大
  4. 内核中的2种优化思路及我们的选择

PostgreSQL json数据类型介绍及与MongoDB 的对比

传统关系型数据库需要用户事先定义表结构, 而事后修改表结构也比较麻烦。 在SaaS 场景下,不同的也有很多的用户属性就不同的, 此时jsonb 就可以有很好的应用场景

CREATE TABLE customer
(
  id int primary key,
  name varchar(50), 
  industry varchar(50),
  size int,
  location varchar(40),
  extra_info jsonb
)

通过这种方式, jsonb 列就可以存放任意的信息。 比如:

{
"partners": ["Company A", "Company B", "Company C"],
"customer_feedback": { "positive_reviews": 85, "negative_reviews": 5}
},
  
{
"annual_revenue": "$10 million",
"awards": ["Best Employer 2020", "Top Innovator Award"]
}

json 数据类型上面有非常灵活的操作符。 MongoDB也是以json数据类型而闻名的。

从用户最明显可感知的领域看, 最本质的区别在于: MongoDB 只能使用 json, 而PG 可以使用 jsonb 和 其他的数据数据类型。 明显的差别是: 传统表结构 可以有更好的网络传输效率, 因为 数据描述信息 是固定的。 而 jsonb 不仅仅要发送Value, 也要发送Key.

从技术层面来看, 数据库是一个极其复杂的产品(优化器、执行器,事务引擎,存储引擎等), 产品需要大量的人力/时间/场景去磨练。 PG 的json 是在 既定框架下的一个小的改动, 这个改动和各个组件可以自由组合。 而 MongoDB 则是从零又开发了一套新的系统。 解决了一些问题, 也为这些便利做了很好地推销,但也埋下了一些大坑需要长期去填补。

部分对比如下:

MongoDB PostgreSQL
丰富的操作符 Y Y
优化器 真实尝试多种执行计划 * 基于代价的* 完整的plan cache.
执行器 * 全表扫描* Index [Only]Scan* 有限的表连接 * 全表扫描* Index [Only]Scan* Bitmap Index Scan* BitmapAnd & BimapOr* 灵活的表连接和3种Join算子* 语句级别并行执行
建模能力 只能使用json类型 json 类型和其他数据类型混合使用 * 更高效的存储效率* 更高效的提取效率* 更高效的网络传输效率
SQL 兼容 不兼容, 需要新学一种语法。 有些聚合 分析相关的语法还很复杂 完全兼容
事务特性 4.2+ 支持部分事务特性 完整的ACID 支持
压缩能力 较强地压缩比 压缩比很弱
Sharding 能力 自带sharing 能力 TeledbX, Citus 等

上面仅仅是列了一些特性的对比, 我们可以发现 数据库是一个非常复杂的系统,为了解决建模的灵活性问题。MongoDB 和PostgreSQL 做出了完全不同的选择:

  • PostgreSQL 引入了一个【数据类型 + 与之对应的操作符】,共用了大部分 优化器,执行器, 存储引擎上的积累。
  • MongoDB 开发了一个【全新的数据库】,所有的核心组件都要重新开发,这需要大量的【人力/时间/场景的磨炼】。

而对于一个云公司来说, 我们也更应该去推荐PostgreSQL 数据库, 因为:

  • PostgreSQL 是纯社区运行, 协议友好的
  • MongoDB 背后有商业公司, 云企业需要支付昂贵的许可费。

测试数据: 10GB 的tpch 数据,将其中的lineitem表的数据以3种形式保存。

a). 传统表结构 @ PostgreSQL

b). 单个jsonb 列 @ PostgreSQL

c). 单个jsonb 列 @ MongoDB

查询了一个返回行数较多的Index Scan 的场景:

性能: a) 优于 c) 的4倍。 b) 优于 c) 的 1.4 倍。

存储: c) 优于 a) 1.2 倍, c) 优于 b) 3+ 倍. MongoDB 的压缩性能要比PostgreSQL 好太多了 .

Toast 设计原则和存在的问题

TOAST是“The Oversized-Attribute Storage Technique”(超尺寸属性存储技术)的缩写, 即 【变长】数据类型。 如 int / float 的长度是固定的, 所以和 toast 无关。 但text, jsonb 的长度都是可变的, 和toast 有关。 简单地说, toast 会将 完整的大的数据 存放到 在外部, heap 表内仅仅保留一个指针(chunk#, seq#), 在需要的时候 会去还原出完成的数据。 Toast 更严谨地介绍可以参考知乎文档。 Toast 的设计主要是为了以下几个场景做优化:

create table t1 (a int, bigtext text, c int); 
create table t2 (like t1); 
  1. 查询对toast 列不感兴趣, 比如 SELECT a, c FROM t1; ==> 减少IO.
  2. 查询对toast 列感兴趣,但有其他的过滤条件。 比如 SELECT * FROM t1 WHERE a > 10; ==》 减少IO
  3. toast 列不能被过滤,但它们需要被放入 Hash Table 或者 Sort Memory. ==> 减少内存占用

SELECT * FROM t1 JOIN t2 using(c);

Hash Join
    Seq Scan on t1
    Hash
      Seq Scan on t2

Sort Merge
    Sort
      Seq Scan on t1
    Sort
      Seq Scan on t2

如果仔细了解TOAST,我们可以知道 从一个 指针 组装出来一个 完整的数据 的代价是很大, 这个和原始数据的大小正相关。 从指针组装回完整的数据的过程我们称为 detoast.

Toast系统设计的核心在于:

  1. 什么时候去 detoast (组装回完整的数据)?
  2. 组装后的数据存放到哪里?
  3. 组装后的数据所占用的内存何时去释放

PostgreSQL 的设计原则

  1. detaost 到了不得不做的时候才会进行。 比如 SELECT bigtext FROM t WHERE a > 3;
  2. 组装后的数据 存放在对应函数的私有内存,使用后立马释放。 对于上述操作 就是 textout 函数内部
  3. 特定操作符使用完立马释放 / 至少外界不可访问。

上述设计存在的问题: 当查询语句中需要对同一个属性进行多次detoast 时, 结果不能共享,从而导致性能浪费。

比如 SELECT bigtext FROM t WHERE length(bigtext) > 100; 在这里面 第一次detoast 发生在 length(bigtext) 操作符, 如果发现 length(bigtext) > 100, 第二个detoast 发生在 SELECT <span data-type="text">bigtext</span>的 text_out 操作。

json 场景下Toast设计缺陷的放大

对于jsonb 数据类型, 因为它内置了较多的属性,通常大小比较大, 并且客户更容易对里面的 1+ 个属性进行过滤 或者 提取操作。 比如

SELECT jsoncol->'x', jsoncol->'y' FROM t WHERE jsoncol->'kind' = 'X' AND jsoncol->'size' > 100;

这个查询理论上可能对 同一个数据 进行4次 detoast 操作。

内核中的2种优化思路及我们的选择

有2种完全不同的思路来解决问题, 涉及到的模块也完全没有交集。 我们先看一下整体流程:

  1. 优化器生成计划
  2. 执行器执行计划。 迭代器将 TupleTableSlot 中的数据从最底层节点拉取到最上层。
  3. 当运行到特定操作符时, 特定操作符从 TupleTableSlot 中获取数据,调用 toast 子系统进行 detoast.

方案1: Toast Cache

在Toast子系统里面维护一个 Hash Cache, 上层将它用作 Query 级别的Cache. Query 内生成, Query 接受释放。 (chunk#, seq# -> detoast result) 的设计。 该设计的好处是 逻辑非常容易理解, 失效也无需处理, 主要改动都控制在了 Toast 子系统内。 但作为一个Cache系统,最大的问题在于不能感知数据的生命周期, 继而有以下影响:

  1. Cache 大小的设置:
    1. 作为 Query 内Cache, 考虑到Query 的并发量, Cache 的大小不可能设置太大。
    2. Cache 的是用户数据(vs 元数据), Cache 的大小很容易被写满。
    3. 写满以后,就会涉及到淘汰逻辑。

进一步带来的运行期开销包括:

  1. Cache 查找对应的性能开销。
  2. Cache 寻找失效对象性能开销。
  3. Cache 失效对象的准确性问题。

为什么不考虑【全局】【长生命周期的】Cache, 类似不同的 buffer cache: 大量数据会Cache 双份 (buffer cache 和 toast cache), 占用的空间太大。

方案2: 在TupleTableSlot 层面中共享detoast 数据

如上所述,TupleTableSlot 是多个Plan 节点用于传递数据的结构,它有以下特点:

  1. 数量仅仅和Plan节点的个数有关,和用户业务数据的大小无关。 简单理解,下面的Plan 仅仅有4个TableTupleSlot (实际上更多一些,) 数据的生命周期在Plan 中非常清晰,这很好地控制了Cache 大小,失效开销,失效准确性的问题。
  2. TupleTableSlot 是所有 操作符数据 获取数据的入口,所以没有任何的性能 查找开销。
          QUERY PLAN         
-------------------------------
 Hash Join
   Hash Cond: (t1.c0 = t2.c0)
   ->  Seq Scan on t1
   ->  Hash
         ->  Seq Scan on t1 t2
(5 rows)

如果不假思索地将数据存放到 TupleTableSlot 中,就会破坏了 Toast 设计的 最初设计, 最终我们选择:

在优化器生成 Plan以后, 判断某个 toast 列是否需要被放到 Hash Table 或者 Sort 节点, 如果不需要,我们会在需要首次访问detoast 属性的节点上, 第一次填充这个属性的时候, 会进行detoast 操作, 然后将detoast 版本写入到 TupleTableSlot 内部。 最终方案2 涉及到了 优化器,执行器 , 表达式(解释执行,JIT 执行), toast 模块 等,能够避免方案 a) 中的所有问题。

最终效果

  1. 对于非目标场景的影响可以忽略不计 (任何人为构造的案例都不能测试出可量化的性能回退)
  2. 对于目标场景,性能提升幅度和 可变长度数据 的大小有关, 和 同一个属性 被访问的次数有关。
  3. 真实的业务场景: 某IaaS 客户存在大量 10MB ~ 40MB 的jsonb 数据,对同一个json 的不同属性有多次访问。 性能提升90%。
0条评论
作者已关闭评论
樊****辉
2文章数
0粉丝数
樊****辉
2 文章 | 0 粉丝
樊****辉
2文章数
0粉丝数
樊****辉
2 文章 | 0 粉丝
原创

PostgreSQL jsonb数据类型的应用和内核优化

2024-10-17 09:34:27
1
0

提纲

  1. PostgreSQL json数据类型介绍 及 与MongoDB 的对比
  2. Toast 设计原则和存在的问题
  3. json 场景下Toast设计缺陷的放大
  4. 内核中的2种优化思路及我们的选择

PostgreSQL json数据类型介绍及与MongoDB 的对比

传统关系型数据库需要用户事先定义表结构, 而事后修改表结构也比较麻烦。 在SaaS 场景下,不同的也有很多的用户属性就不同的, 此时jsonb 就可以有很好的应用场景

CREATE TABLE customer
(
  id int primary key,
  name varchar(50), 
  industry varchar(50),
  size int,
  location varchar(40),
  extra_info jsonb
)

通过这种方式, jsonb 列就可以存放任意的信息。 比如:

{
"partners": ["Company A", "Company B", "Company C"],
"customer_feedback": { "positive_reviews": 85, "negative_reviews": 5}
},
  
{
"annual_revenue": "$10 million",
"awards": ["Best Employer 2020", "Top Innovator Award"]
}

json 数据类型上面有非常灵活的操作符。 MongoDB也是以json数据类型而闻名的。

从用户最明显可感知的领域看, 最本质的区别在于: MongoDB 只能使用 json, 而PG 可以使用 jsonb 和 其他的数据数据类型。 明显的差别是: 传统表结构 可以有更好的网络传输效率, 因为 数据描述信息 是固定的。 而 jsonb 不仅仅要发送Value, 也要发送Key.

从技术层面来看, 数据库是一个极其复杂的产品(优化器、执行器,事务引擎,存储引擎等), 产品需要大量的人力/时间/场景去磨练。 PG 的json 是在 既定框架下的一个小的改动, 这个改动和各个组件可以自由组合。 而 MongoDB 则是从零又开发了一套新的系统。 解决了一些问题, 也为这些便利做了很好地推销,但也埋下了一些大坑需要长期去填补。

部分对比如下:

MongoDB PostgreSQL
丰富的操作符 Y Y
优化器 真实尝试多种执行计划 * 基于代价的* 完整的plan cache.
执行器 * 全表扫描* Index [Only]Scan* 有限的表连接 * 全表扫描* Index [Only]Scan* Bitmap Index Scan* BitmapAnd & BimapOr* 灵活的表连接和3种Join算子* 语句级别并行执行
建模能力 只能使用json类型 json 类型和其他数据类型混合使用 * 更高效的存储效率* 更高效的提取效率* 更高效的网络传输效率
SQL 兼容 不兼容, 需要新学一种语法。 有些聚合 分析相关的语法还很复杂 完全兼容
事务特性 4.2+ 支持部分事务特性 完整的ACID 支持
压缩能力 较强地压缩比 压缩比很弱
Sharding 能力 自带sharing 能力 TeledbX, Citus 等

上面仅仅是列了一些特性的对比, 我们可以发现 数据库是一个非常复杂的系统,为了解决建模的灵活性问题。MongoDB 和PostgreSQL 做出了完全不同的选择:

  • PostgreSQL 引入了一个【数据类型 + 与之对应的操作符】,共用了大部分 优化器,执行器, 存储引擎上的积累。
  • MongoDB 开发了一个【全新的数据库】,所有的核心组件都要重新开发,这需要大量的【人力/时间/场景的磨炼】。

而对于一个云公司来说, 我们也更应该去推荐PostgreSQL 数据库, 因为:

  • PostgreSQL 是纯社区运行, 协议友好的
  • MongoDB 背后有商业公司, 云企业需要支付昂贵的许可费。

测试数据: 10GB 的tpch 数据,将其中的lineitem表的数据以3种形式保存。

a). 传统表结构 @ PostgreSQL

b). 单个jsonb 列 @ PostgreSQL

c). 单个jsonb 列 @ MongoDB

查询了一个返回行数较多的Index Scan 的场景:

性能: a) 优于 c) 的4倍。 b) 优于 c) 的 1.4 倍。

存储: c) 优于 a) 1.2 倍, c) 优于 b) 3+ 倍. MongoDB 的压缩性能要比PostgreSQL 好太多了 .

Toast 设计原则和存在的问题

TOAST是“The Oversized-Attribute Storage Technique”(超尺寸属性存储技术)的缩写, 即 【变长】数据类型。 如 int / float 的长度是固定的, 所以和 toast 无关。 但text, jsonb 的长度都是可变的, 和toast 有关。 简单地说, toast 会将 完整的大的数据 存放到 在外部, heap 表内仅仅保留一个指针(chunk#, seq#), 在需要的时候 会去还原出完成的数据。 Toast 更严谨地介绍可以参考知乎文档。 Toast 的设计主要是为了以下几个场景做优化:

create table t1 (a int, bigtext text, c int); 
create table t2 (like t1); 
  1. 查询对toast 列不感兴趣, 比如 SELECT a, c FROM t1; ==> 减少IO.
  2. 查询对toast 列感兴趣,但有其他的过滤条件。 比如 SELECT * FROM t1 WHERE a > 10; ==》 减少IO
  3. toast 列不能被过滤,但它们需要被放入 Hash Table 或者 Sort Memory. ==> 减少内存占用

SELECT * FROM t1 JOIN t2 using(c);

Hash Join
    Seq Scan on t1
    Hash
      Seq Scan on t2

Sort Merge
    Sort
      Seq Scan on t1
    Sort
      Seq Scan on t2

如果仔细了解TOAST,我们可以知道 从一个 指针 组装出来一个 完整的数据 的代价是很大, 这个和原始数据的大小正相关。 从指针组装回完整的数据的过程我们称为 detoast.

Toast系统设计的核心在于:

  1. 什么时候去 detoast (组装回完整的数据)?
  2. 组装后的数据存放到哪里?
  3. 组装后的数据所占用的内存何时去释放

PostgreSQL 的设计原则

  1. detaost 到了不得不做的时候才会进行。 比如 SELECT bigtext FROM t WHERE a > 3;
  2. 组装后的数据 存放在对应函数的私有内存,使用后立马释放。 对于上述操作 就是 textout 函数内部
  3. 特定操作符使用完立马释放 / 至少外界不可访问。

上述设计存在的问题: 当查询语句中需要对同一个属性进行多次detoast 时, 结果不能共享,从而导致性能浪费。

比如 SELECT bigtext FROM t WHERE length(bigtext) > 100; 在这里面 第一次detoast 发生在 length(bigtext) 操作符, 如果发现 length(bigtext) > 100, 第二个detoast 发生在 SELECT <span data-type="text">bigtext</span>的 text_out 操作。

json 场景下Toast设计缺陷的放大

对于jsonb 数据类型, 因为它内置了较多的属性,通常大小比较大, 并且客户更容易对里面的 1+ 个属性进行过滤 或者 提取操作。 比如

SELECT jsoncol->'x', jsoncol->'y' FROM t WHERE jsoncol->'kind' = 'X' AND jsoncol->'size' > 100;

这个查询理论上可能对 同一个数据 进行4次 detoast 操作。

内核中的2种优化思路及我们的选择

有2种完全不同的思路来解决问题, 涉及到的模块也完全没有交集。 我们先看一下整体流程:

  1. 优化器生成计划
  2. 执行器执行计划。 迭代器将 TupleTableSlot 中的数据从最底层节点拉取到最上层。
  3. 当运行到特定操作符时, 特定操作符从 TupleTableSlot 中获取数据,调用 toast 子系统进行 detoast.

方案1: Toast Cache

在Toast子系统里面维护一个 Hash Cache, 上层将它用作 Query 级别的Cache. Query 内生成, Query 接受释放。 (chunk#, seq# -> detoast result) 的设计。 该设计的好处是 逻辑非常容易理解, 失效也无需处理, 主要改动都控制在了 Toast 子系统内。 但作为一个Cache系统,最大的问题在于不能感知数据的生命周期, 继而有以下影响:

  1. Cache 大小的设置:
    1. 作为 Query 内Cache, 考虑到Query 的并发量, Cache 的大小不可能设置太大。
    2. Cache 的是用户数据(vs 元数据), Cache 的大小很容易被写满。
    3. 写满以后,就会涉及到淘汰逻辑。

进一步带来的运行期开销包括:

  1. Cache 查找对应的性能开销。
  2. Cache 寻找失效对象性能开销。
  3. Cache 失效对象的准确性问题。

为什么不考虑【全局】【长生命周期的】Cache, 类似不同的 buffer cache: 大量数据会Cache 双份 (buffer cache 和 toast cache), 占用的空间太大。

方案2: 在TupleTableSlot 层面中共享detoast 数据

如上所述,TupleTableSlot 是多个Plan 节点用于传递数据的结构,它有以下特点:

  1. 数量仅仅和Plan节点的个数有关,和用户业务数据的大小无关。 简单理解,下面的Plan 仅仅有4个TableTupleSlot (实际上更多一些,) 数据的生命周期在Plan 中非常清晰,这很好地控制了Cache 大小,失效开销,失效准确性的问题。
  2. TupleTableSlot 是所有 操作符数据 获取数据的入口,所以没有任何的性能 查找开销。
          QUERY PLAN         
-------------------------------
 Hash Join
   Hash Cond: (t1.c0 = t2.c0)
   ->  Seq Scan on t1
   ->  Hash
         ->  Seq Scan on t1 t2
(5 rows)

如果不假思索地将数据存放到 TupleTableSlot 中,就会破坏了 Toast 设计的 最初设计, 最终我们选择:

在优化器生成 Plan以后, 判断某个 toast 列是否需要被放到 Hash Table 或者 Sort 节点, 如果不需要,我们会在需要首次访问detoast 属性的节点上, 第一次填充这个属性的时候, 会进行detoast 操作, 然后将detoast 版本写入到 TupleTableSlot 内部。 最终方案2 涉及到了 优化器,执行器 , 表达式(解释执行,JIT 执行), toast 模块 等,能够避免方案 a) 中的所有问题。

最终效果

  1. 对于非目标场景的影响可以忽略不计 (任何人为构造的案例都不能测试出可量化的性能回退)
  2. 对于目标场景,性能提升幅度和 可变长度数据 的大小有关, 和 同一个属性 被访问的次数有关。
  3. 真实的业务场景: 某IaaS 客户存在大量 10MB ~ 40MB 的jsonb 数据,对同一个json 的不同属性有多次访问。 性能提升90%。
文章来自个人专栏
postgresql技术分享
2 文章 | 1 订阅
0条评论
作者已关闭评论
作者已关闭评论
0
0