技术贴 | 深度解析 PostgreSQL Protocol v3.0(二)— 扩展查询

技术贴 | 深度解析 PostgreSQL Protocol v3.0(二)— 扩展查询_第1张图片

引言

PostgreSQL 使用基于消息的协议在前端(客户端)和后端(服务器)之间进行通信。该协议通过 TCP/IP 和 Unix 域套接字支持。

《深度解析 PostgreSQL Protocol v3.0》系列技术贴,将带大家深度了解 PostgreSQL Protocol v3.0(在 PostgreSQL 7.4 及更高版本中实现,有关早期协议版本的描述请参考 PostgreSQL 文档的早期版本,该系列文章不予赘述)相关的消息传输格式和格式码、消息支持的数据类型、消息的格式、协议交互流程、错误消息和通知消息、支持的子协议等,相关的代码解读基于 PostgreSQL 代码仓库的 REL_14_STABLE 分支。

本期是《深度解析 PostgreSQL Protocol v3.0》系列技术贴的第二期文章,在第一期文章中带大家解读了 PostgreSQL Protocol v3.0(一)—概述,本期将为大家分享 PostgreSQL Protocol v3.0(二) — 扩展查询功能的内容。

一、扩展查询介绍

扩展查询(Extended Query)协议将简单查询协议分解为多个步骤,为提高效率,可多次重复使用准备(Prepare)步骤的结果。此外,扩展查询协议还提供了其他功能,例如可以将数据值作为单独的参数提供,而不必将它们直接插入到查询字符串中。

在扩展查询协议中,客户端首先发送一条 Parse 消息,其中包含文本查询字符串、可选的参数占位符的数据类型信息以及目标准备语句对象的名称(目标准备语句对象名称为空字符串,则选择未命名的准备语句)。响应为 ParseComplete 或 ErrorResponse 消息。参数数据类型可以由 OID 指定;如果没有指定参数数据类型,解析器将尝试以与无类型的文本字符串常量相同的方式推断数据类型。

Parse 过程需要注意:

(1)参数的数据类型可以不指定,此时设置参数数据类型的 OID 为 0,或者使参数数据类型 OID 的数组比查询字符串中使用的参数符号的数量($n)短。另一种特殊情况是参数的数据类型可以指定为 void(即 void 伪类型的 OID)。

这意味着允许实际上是输出 OUT 使用的参数符号,作为函数的入参使用。通常情况下,没有可以使用 void 参数的上下文,但如果函数的参数列表中出现了这样的参数符号,则实际上会忽略它。例如,如果将 $3 和 $4 指定为具有 void 类型,则诸如 foo($1, $2, $3, $4) 之类的函数调用可以匹配具有两个 IN 和两个 OUT 参数的函数。

(2)Parse 消息中包含的查询字符串不能包含多个 SQL 语句,否则报告语法错误。这种限制在简单查询协议中不存在,但在扩展查询协议中确实存在,因为允许准备语句或门户包含多个 SQL 命令会使协议过度复杂化。

如果成功创建命名的准备语句对象,除非明确销毁它,否则它将持续到当前会话结束。未命名的准备语句只持续到处理下一个指定未命名语句为目标的 Parse 语句为止。

需要特别注意的是,简单查询消息 Query 也会销毁未命名的准备语句。命名的准备语句必须显式关闭,然后才能被另一个 Parse 消息重新定义,但这对于未命名语句来说不是必需的。还可以使用 PREPARE 和 EXECUTE 在 SQL 命令级别创建和访问命名的准备语句。

一旦准备语句存在,就可以使用 Bind 消息为执行做好准备。Bind 消息提供源准备语句的名称(空字符串表示未命名的准备语句)、目标门户的名称(空字符串表示未命名的门户)以及准备语句中每个参数占位符的值。提供的参数集必须与准备语句所需的参数集匹配。

如果在 Parse 消息中声明了任何 void 参数,在 Bind 消息中为它们传递 NULL 值。Bind 还指定用于查询返回数据的格式;返回数据格式可以整体指定,也可以按列指定。Bind 消息的响应为 BindComplete 或 ErrorResponse 消息。

Bind 过程需要注意:文本和二进制输出之间的选择取决于 Bind 中给出的格式代码,而不考虑所涉及的 SQL 命令。当使用扩展查询协议时,游标(CURSOR)声明中的 BINARY 属性无关紧要。

查询的计划生成通常在处理 Bind 消息时进行。如果准备语句没有参数,或者被重复执行,服务器可能会缓存创建的计划,并在同一准备语句的后续 Bind 消息中重用它。但是,只有当服务器发现可以创建的通用计划的效率比依赖于提供的特定参数值的计划高得多时,它才会这样做。但是就扩展协议而言,这个过程是透明的。

如果成功创建命名门户对象,除非明确销毁它,否则它将持续到当前事务结束。在事务结束时,或在发出指定未命名门户为目标的下一个 Bind 语句时,将销毁未命名门户。需要特别注意的,简单查询的消息 Query 也会销毁未命名的门户。在可以通过另一个 Bind 消息重新定义命名门户之前,必须显式关闭命名门户,但这对于未命名门户不是必需的。命名门户也可以使用 DECLARE CURSOR 和 FETCH 在 SQL 命令级别创建和访问。

一旦门户存在,就可以使用 Execute 消息执行它。Execute 消息指定门户名称(空字符串表示未命名的门户)和最大结果行计数(0 表示“获取所有行”)。结果行计数仅对包含返回行数据集的命令的门户有意义;在其他情况下,命令始终执行直至完成,并且忽略行计数。对 Execute 消息的可能响应与通过简单查询协议发出的 Query 消息的响应相同,但 Execute 不会导致 Server 端发出 ReadyForQuery 和 RowDescription 消息。

如果 Execute 在门户执行完成之前终止(由于达到非 0 结果行计数),服务器端将发送 PortalSuspended 消息。PortalSuspended 消息的出现告诉客户端,应针对同一门户发出另一个 Execute 消息以完成操作。在门户执行完成之前,不会发送指示源 SQL 命令执行完成的 CommandComplete 消息。因此,Execute 阶段总是由以下消息之一的出现而终止:CommandComplete、EmptyQueryResponse(如果门户是从空查询字符串创建的)、ErrorResponse 或 PortalSuspended。

在完成每个扩展查询消息系列时,客户端应发出一条 Sync 消息。如果当前事务不在 BEGIN/COMMIT 显式事务块内,则无参数 Sync 消息会导致服务器端关闭当前事务(这里的“关闭”表示如果没有错误则提交,如果错误则回滚)。然后发出 ReadyForQuery 响应。Sync 消息的目的是为错误恢复提供新的同步点。

当在处理任何扩展查询消息时检测到错误,服务器端会发出 ErrorResponse 消息,然后读取并丢弃消息,直至收到 Sync 消息,然后发出 ReadyForQuery 消息,返回正常消息处理状态。但是需要注意,如果在处理 Sync 消息时检测到错误,则不会跳过该消息处理,这确保每个 Sync 消息都有且只有一个 ReadyForQuery 响应消息发送到客户端。

另外,Sync 消息不会导致用 BEGIN 打开的事务块关闭。ReadyForQuery 消息包含事务状态信息,因此可以检测到这种情况。

除了上述必备的基本操作之外,还有几个可选操作可用于扩展查询协议:

Describe 消息(门户描述变体)指定现有门户的名称(或空字符串指示未命名门户)。响应是一条 RowDescription 消息,描述门户执行将返回的数据行;如果门户不包含将返回数据行的查询,则返回 NoData 消息;如果指定的门户不存在,则返回 ErrorResponse 消息。

Describe 消息(语句描述变体)指定现有准备语句的名称(或空字符串指示未命名准备语句)。响应是一条 ParameterDescription 消息,描述指定语句所需的参数,紧跟着是一条 RowDescription 消息,描述最终执行语句时将返回的数据行(如果该语句不返回数据行,则返回 NoData 消息)。

如果指定的准备语句不存在,则会响应 ErrorResponse 消息。需要注意的是,由于尚未收到 Bind 消息,因此服务器端还不知道用于数据返回的列格式;在这种情况下,RowDescription 消息中的格式代码字段将为 0。

在大多数情况下,客户端应该在发送 Execute 消息之前发送一个门户或语句 Describe 变体消息,以确保它知道如何解释将返回的结果。

Close 消息关闭现有的准备语句或门户,并释放资源。对不存在的语句或门户名称发出 Close 消息不会引起错误。Close 消息的响应通常是 CloseComplete,但如果在释放资源时遇到一些困难,则可能是 ErrorResponse 消息。需要注意的是,关闭准备语句会隐式关闭由该语句构建的所有打开的门户。

Flush 消息不会导致生成任何特定的输出,但会强制服务器端发送其输出缓冲区中已经存在的任何数据。如果客户端希望在发出更多命令之前检查该命令的结果,则必须在除 Sync 之外的任何扩展查询命令之后发送 Flush。如果没有 Flush 消息,服务器端返回的消息将被组合成尽可能少的数据包数量,以最大限度地减少网络开销。

因此,简单查询消息 Query 大致相当于 Parse、Bind、portal-Description、Execute、Close、Sync 系列命令,使用未命名的准备语句和门户的对象,不使用参数。

不同之处在于:

(1)简单查询的 Query 消息将接受查询字符串中的多个 SQL 语句,自动为每个语句连续执行 bind/describe/execute 命令序列;

(2)简单查询的 Query 消息的响应不会是 ParseComplete、BindComplete、CloseComplete 和 NoData 消息。

二、扩展查询支持流水线操作

扩展查询协议允许流水线(Pipelining)操作,这意味着可以发送一系列查询语句,而无需等待较早的查询完成。这减少了完成一系列给定操作所需的网络往返次数。但是,如果其中一个步骤失败,用户必须仔细考虑处理错误所需的行为,因为后续的查询已经在向服务器发送。

处理这一问题的一种方法是使整个查询系列成为一个单独的事务,即将查询序列封装在 BEGIN…COMMIT 中。然而,如果希望某些命令独立于其他命令进行单独提交,该方法并没有帮助。

扩展查询协议提供了另一种管理此问题的方法,即省略在依赖步骤之间发送同步消息。由于在发生错误后,服务器端将跳过命令消息,直至找到 Sync 消息,这允许在前一个命令失败时自动跳过流水线中的后一个命令,而客户端不必使用 BEGIN 和 COMMIT 显式管理命令序列。流水线中可独立提交的段可以通过 Sync 消息来分隔。

如果客户端没有发出显式的 BEGIN,那么如果前面的步骤成功,则每个 Sync 通常会导致事务隐式的 COMMIT,如果失败,则会引起事务隐式的 ROLLBACK。但是,有一些 DDL 命令(如 CREATE DATABASE)无法在事务块内执行。如果一个这种 DDL 命令在流水线中执行,除非它是流水线中的第一个命令,否则都将失败。

此外,这种 DDL 命令一旦执行成功,它将强制立即提交以保持数据库一致性。因此,在其中一个命令之后立即发送 Sync 消息除了使用 ReadyForQuery 进行响应之外没有任何效果。使用此方法时,必须通过 ReadyForQuery 消息计数并等待达到发送的 Sync 消息数来确定流水线的完成情况。计算命令完成响应数量是不可靠的,因为一些命令可能会被跳过,因此不会产生完成消息。

三、扩展查询可能出现的消息

技术贴 | 深度解析 PostgreSQL Protocol v3.0(二)— 扩展查询_第2张图片
四、扩展查询涉及消息的格式

以下是在扩展查询过程中可能出现的消息,下面进行详细介绍。

1.Parse(F:‘P’)
Parse 消息格式如下:

图片

Byte1(‘P’)
将消息标识为 Parse 命令。

Int32
消息内容的字节长度,包括自身 4 个字节。

String
目标准备语句的名称(空字符串选择未命名的准备语句)。

String
要解析的查询字符串。

int16
指定的参数数据类型的数目(可以为 0)。请注意,这并不是可能出现在查询字符串中的参数数量的指示,只是前端希望为其预先指定数据类型的数量。

然后,对于每一个参数都有以下数据类型 OID

Int32
指定参数数据类型的 OID。在此处放置 0 相当于未指定类型。
2. ParseComplete(B:‘1’)
ParseComplete 消息格式如下:

在这里插入图片描述

Byte1(‘1’)
将消息标识为 ParseComplete 消息。

Int32(4)
消息内容的字节长度,包括自身 4 个字节。始终为 4。

3. Bind(F:‘B’)
Bind 消息格式如下:

图片
Byte1(‘B’)
将消息标识为 Bind 消息。

Int32
消息内容的字节长度,包括自身 4 个字节。

String
目标门户的名称(空字符串选择未命名的门户)。

String
源准备语句的名称(空字符串选择未命名的准备语句)。

Int16
后续的参数格式代码的数量(下面用C表示)。值可以是:
(1)0,表示没有参数或参数都使用默认格式(text);
(2)1,在这种情况下,指定的格式代码被应用于所有参数;
(3)值等于参数的实际数量。

Int16[C]
参数格式代码。目前数组每个元素的值必须为 0(text)或 1(binary)。

Int16
后面的参数值的数目(可能为 0)。值必须与查询所需的参数数量相匹配。

接下来,将为每个参数构建以下字段对:

Int32
参数值的长度,以字节为单位(此计数不包括其自身)。值可以为零。作为一种特殊情况,-1 表示 NULL 参数值。值为 NULL 情况下,后面没有值字节。

Byten
参数的值,格式由关联的格式代码指示。n 是上述参数值的长度。
在最后一个参数之后,将显示以下两个字段:

Int16
后面的结果列格式代码的数量(下面用 R 表示)。值可以是:
(1)0,表示没有结果列,或者结果列都应该使用默认格式(text);
(2)1,在这种情况下,指定的格式代码被应用于所有结果列(如果有结果列的话);
(3)等于查询的结果列的实际数量。

Int16[R]
结果列格式代码。目前数组每个元素的值必须为0(text)或1(binary)。
4. BindComplete(B:‘2’)
BindComplete 消息格式如下:

在这里插入图片描述

Byte1(‘2’)
将消息标识为 BindComplete 消息。

Int32(4)
消息内容的字节长度,包括自身 4 个字节。始终为 4。

5. Describe(F:‘D’)
Describe 消息格式如下:

在这里插入图片描述

Byte1(‘D’)
将消息标识为 Describe 消息。

Int32
消息内容的字节长度,包括自身 4 个字节。

Byte1
指示 Describe 描述的对象类型。可能的值为:‘S’,表示描述准备语句;‘P’,表示描述门户。

String
要描述的准备语句或门户的名称(空字符串选择未命名的准备语句或门户)。

描述门户 Portal 的消息格式如下:
在这里插入图片描述
描述准备语句的消息格式如下:

图片

6. RowDescription(B:‘T’)
RowDescription 消息格式如下:

图片
Byte1(‘T’)
将消息标识为行描述。

Int32
消息内容的字节长度,包括自身 4 个字节。

Int16
指定一行中的字段数(可以为 0)。

对于行描述中的每个字段,都有以下 7 部分内容:

String
字段名称

Int32
如果字段可以被标识为特定表的列,则值为该表的对象 ID;否则为 0。

Int16
如果该字段可以标识为特定表的列,则值为该列的属性编号;否则为 0。

Int32
字段数据类型的对象 ID。

Int16
数据类型大小(可以参考 pg_type.typlen)。需要注意的是,负值表示可变宽度类型。

Int32
类型修饰符(可以参考 pg_attribute.atttypmod)。修饰符的含义是特定于数据类型的。

Int16
字段的格式代码。目前,只能是 0(文本)或 1(二进制)。在从 Describe 语句请求返回的 RowDescription 中,格式代码还未知,并且始终为零。

蓝色背景的部分,是每个列的详细描述。浅灰色背景的部分是可选的,格式是蓝色部分的重复。

7. NoData(B:‘n’)
NoData 消息格式如下:

图片
Byte1(‘n’)
将消息标识为 NoData 消息。

Int32(4)
消息内容的字节长度,包括自身 4 个字节。值始终为 4。

8. ParameterDescription(B:‘t’)
ParameterDescription 消息格式如下:

图片
Byte1(‘t’)
将消息标识为 ParameterDescription 消息。

Int32
消息内容的字节长度,包括自身 4 个字节。

Int16
语句使用的参数数量(可以为 0)。

然后,对于每个参数,都有以下内容:

Int32
指定参数数据类型的对象 ID。

如果语句使用的参数数量为 0,则没有后续的参数数据类型的内容。蓝色背景的部分,是每个参数的数据类型对象 ID,是可选的。浅灰色背景的部分是可选的,格式是蓝色部分的重复。

9. Execute(F:‘E’)
Execute 消息格式如下:

在这里插入图片描述

Byte1(‘E’)
将消息标识为 Execute 消息。

Int32
消息内容的字节长度,包括自身 4 个字节。

String
待执行门户的名称(空字符串选择未命名的门户)。

Int32
如果门户包含返回行的查询,值为返回的最大行数;否则忽略该值。0 表示不限制返回行数。

10. DataRow(B:‘D’)
DataRow 消息格式如下:

图片

Byte1(‘D’)
将消息标识为数据行。

Int32
消息内容的字节长度,包括自身 4 个字节。

Int16
后续列值的数量(可能为 0)。

接下来,为每列的值显示以下一对字段:

Int32
列值的长度,以字节为单位(此计数不包括长度字段自身的 4 个字节)。该字段值可以为零。作为一种特殊情况,-1 表示列值为 NULL。在列值为 NULL 的情况下,后面没有值字节的字段。

Byten
列的值,格式由关联的格式代码指示。n 是上述字段的长度值。

蓝色背景的部分,是每个列值的字节长度和列值,是可选的。浅灰色背景的部分是可选的,格式是蓝色部分的重复。

11. CommandComplete(B:‘C’)
CommandComplete 消息格式如下:

在这里插入图片描述

Byte1(‘C’)
将消息标识为命令完成。

Int32
消息内容的字节长度,包括自身 4 个字节。

String
命令标记。这通常是一个单词,用于标识完成了哪个 SQL 命令。
(1)对于 INSERT 命令,标记是 INSERT oid rows,其中 rows 是插入的行数。如果 rows 为 1 并且目标表具有 oid,则 oid 表示插入行的对象 ID。但 OIDs 系统列不再受支持;因此 oid 总是 0;
(2)对于 DELETE 命令,标记为 DELETE rows,其中 rows 是删除的行数;
(3)对于 UPDATE 命令,标记是 UPDATE rows,其中 rows 是更新的行数;
(4)对于 SELECT 或 CREATE TABLE AS 命令,标记是 SELECT rows,其中 rows 是检索的行数;
(5)对于 MOVE 命令,标记为 MOVE rows,其中 rows 是游标位置已更改的行数;
(6)对于 FETCH 命令,标记是 FETCH rows,其中 rows 是从游标检索的行数;
(7)对于 COPY 命令,标记是 COPY rows,其中 rows 是复制的行数。(注意:行计数仅出现在 PostgreSQL 8.2 及更高版本中。)

12. PortalSuspended(B:‘s’)
PortalSuspended 消息格式为:

图片
Byte1(‘s’)
将消息标识为门户已挂起消息。请注意,只有当达到 Execute 消息的行计数限制时,才会出现此信息。

Int32(4)
消息内容的字节长度,包括自身 4 个字节。值始终为 4。

13. EmptyQueryResponse(B:‘I’)
EmptyQueryResponse 消息格式如下:

在这里插入图片描述

Byte1(‘I’)
将消息标识为对空查询字符串的响应。(EmptyQueryResponse 消息将替代 CommandComplete 消息)。

Int32(4)
消息内容的字节长度,包括自身 4 个字节。值始终为 4。

  1. Close(F:‘C’)

Close 消息格式如下:

图片
Byte1(‘C’)
将消息标识为 Close 消息。

Int32
消息内容的字节长度,包括自身 4 个字节。

Byte1
指示 Close 的对象类型。可能的值为:‘S’,表示关闭准备语句;‘P’,表示关闭门户。

String
要关闭的准备语句或门户的名称(空字符串选择未命名的准备语句或门户)。

关闭门户 Portal 的消息格式如下:
在这里插入图片描述
关闭准备语句的消息格式如下:
在这里插入图片描述
15. CloseComplete(B:‘3’)
CloseComplete 消息格式如下:

图片
Byte1(‘3’)
将消息标识为 CloseComplete 消息。

Int32(4)
消息内容的字节长度,包括自身 4 个字节。始终为 4。

16.Flush(F:‘H’)
Flush 消息格式如下:

图片
Byte1(‘H’)
将消息标识为 Flush 消息。

Int32(4)
消息内容的字节长度,包括自身 4 个字节。始终为 4。

17.Sync(F:‘S’)
Sync 消息格式如下:

图片
Byte1(‘S’)
将消息标识为 Sync 消息。

Int32(4)

消息内容的字节长度,包括自身 4 个字节。值始终为 4。

18.ErrorResponse(B:‘E’)
ErrorResponse 消息格式如下:

图片

Byte1(‘E’)
将消息类型标识为错误。

Int32
消息内容的字节长度,包括自身 4 个字节。

消息体
消息体由一个或多个标识字段组成,后跟一个’\0’字节作为终止符。字段可以按任何顺序出现。对于每个字段,都有以下内容:

(1)Byte1
标识字段类型的代码。如果为零,则这是消息终止符,后面没有字符串。由于将来可能会添加更多的字段类型,客户端应默默忽略无法识别类型的字段;

(2)String
字段值。

蓝色背景的部分,是消息体的一个字段类型及其值以及结束符’\0’。一个 ErrorResponse 消息至少包含一个消息体,浅灰色背景的部分是可选的,格式是蓝色部分的重复。消息体的最后,有一个’\0’作为消息的结束。

19.ReadyForQuery(B:‘Z’)
ReadyForQuery 消息格式如下:

图片
Byte1(‘Z’)
将消息标识为服务器端准备好接收客户端的查询请求类型。每当服务器为新的查询周期做好准备时,就会发送 ReadyForQuery。

Int32(5)
消息内容的字节长度,包括自身 4 个字节。值总是 5。

Byte1
当前服务器端事务状态指示器。可能的值为:
(1)‘I’: 处于空闲状态(不在事务块中);
(2)‘T’: 在事务块中;
(3)‘E’: 在失败的事务块中(查询将被拒绝,直到事务块结束)。

五、扩展查询交互流程

扩展查询协议将简单查询协议分解为多个步骤,可以多次重复使用准备步骤的结果,以此提高效率。此外,扩展查询还提供了其他功能,例如可以将数据值作为单独的参数提供,而不必将其直接插入查询字符串中。

扩展查询交互流程,主要有两种场景:
(1)扩展查询的必要交互流程;
(2)扩展查询带描述准备语句的交互流程。

1. 扩展查询的必要交互流程

扩展查询必要的交互流程主要是 Parse、Bind、portal-Describe、Execute、Sync 等。扩展查询的主要流程如下:
(1)先发送包含单条 SQL 语句的 Parse 消息,创建准备语句和 Portal,方便后续步骤重复利用;
(2)通过 Bind 消息提供准备语句中参数占位符需要的参数值;
(3)通过 Describe 消息,获取 Portal 执行将要返回的数据行的信息。该步骤是可选的;
(4)发送 Execute 消息,执行当前的 Portal,并返回执行结果的数据;
(5)执行完上述流程,发送 Sync 消息提交数据;
(6)可以继续重复执行步骤 (2)~(5)的 Bind、Describe、Execute、Sync 流程,进行数据的读写。

具体的交互消息和交互流程,如下图所示:

技术贴 | 深度解析 PostgreSQL Protocol v3.0(二)— 扩展查询_第3张图片

2. 扩展查询带描述准备语句的交互流程

扩展查询带描述准备语句的交互流程主要是 Parse、准备语句 Describe、Bind、Execute、Sync 等,主要是在 Parse 和 Bind 之间,发送准备语句的描述请求消息。主要流程如下:
(1) 先发送包含单条 SQL 语句的 Parse 消息,创建准备语句和 Portal,方便后续步骤重复利用;
(2)发送准备语句 Describe,服务器端响应 ParameterDescription 消息和 RowDescription 消息。ParameterDescription 消息描述准备语句需要的参数信息,RowDescription 消息描述返回数据行的列布局信息;
(3)通过 Bind 消息提供准备语句中参数占位符需要的参数值;
(4)通过 Describe 消息,获取 Portal 执行将要返回的数据行的信息。该步骤是可选的;
(5)发送 Execute 消息,执行当前的 Portal,并返回执行结果的数据;
(6)执行完上述流程,发送 Sync 消息提交数据;
(7)可以继续重复执行步骤(3)~(6)的 Bind、portal-Describe、Execute、Sync 流程,进行数据的读写。

具体的交互消息和交互流程,如下图所示:

技术贴 | 深度解析 PostgreSQL Protocol v3.0(二)— 扩展查询_第4张图片

END

关于技术流系列博客
开物成务,厚积薄发。技术流系列博客-以“短小精悍”的形式普及数据库硬核技术。相信大家的每一次阅读,都会距离数据库内核更近一步。每一份来自你们的关注,都是我们坚持输出的满满能量!

你可能感兴趣的:(postgresql,数据库)