PostgreSQL的应用技巧和示例分享

这里就不用什么标题党了,本文会总结一些Postgres中,从应用需求和场景出发,不太常见,但比较常用并且有用的SQL语句,并进行简单的说明和分析。这些技巧和操作

这里就不用什么标题党了,本文会总结一些Postgres中,从应用需求和场景出发,不太常见,但比较常用并且有用的SQL语句,并进行简单的说明和分析。这些技巧和操作的主要目的,是通过简化操作,更高效的处理数据,或者提高开发效率。

本文并不是什么体系化的材料,可以看成一些使用经验和感想,觉得有必要分享出来的内容集锦。由于这部分内容和组织可能会比较零散,本文会长期持续更新完善。

Why Postgres

主要是有人总是纠结这个问题。笔者的理解简单说来就是喜欢和合适(仅针对笔者和所在的应用场景)。当然其实世界上既没有一种完美无瑕,也没有一无是处的技术,如果不能理解这一点,可能就不用往下看了。但任何一种技术或者产品,除了它本身的功能和特性之外,它会体现一些构建者所秉持的想法和价值观,理解和认同这些,会建构和使用者之间更深层次的契合。

从历史说起吧。PG源自伯克利的Postgres软件包(关系型数据库模型设计和实现,1986年)项目,具有相对独立和长期的技术发展过程,现已经发展成为世界上最先进,特性最完善的开源关系型数据库系统(你深入了解后就知道这不是吹牛),并在开源社区和信息技术行业内得到了非常广泛的应用。作为长期稳定的开源软件系统PG拥有众多的衍生系统,比如GreenPlum等,阿里和华为等很多技术公司都基于或者参考PG开发了相关的衍生产品。

PG诞生之初就定位于企业级的数据库系统,注重设计严谨,系统健壮,易用性和功能丰富。PG对于SQL标准的支持是在所有数据库系统中最完备的。PG技术社区也非常活跃,开放性也非常好,技术发展也比较快,从而能够尽早提供丰富和先进的功能特性,文档和资料详实丰富,能够很好的帮助程序员快速开发和改进他们的应用程序。

在整个技术栈中,PG也是一个完善开放的生态环境中的一个良好的成员,PG可以运行在所有主流操作系统之上,并通过开放源码方式可以方便的移植到其他架构和操作系统上。PG完善的生态环境提供所有主流语言和开发环境的支持。在笔者现有使用的nodejs体系中,可以非常方便的移植和集成。无论是数据库连接,参数化语句和执行,查询结果处理,异常处理等,都非常方便和直观。

修改操作返回记录(returning)

PG提供了修改记录时返回数据的功能,在需要判断操作结果的场景中非常有用,特别是SQL执行操作和调用语言一起进行操作的情况下。这个操作语句也非常简单,使用returning。

insert into students(id,name) values ('id1','name1') returing id,name;
update students set status = 1 where id = 'id1' returning id,name, status;
delete from students where id = 'id1' returning id,name ;

复制表结构(createLike)

PG提供了在create table中,new字句来帮助复制一个表,还包括一些扩展选项。在数据管理工作中会经常用到。

create table new (
    like old
    including defaults
    including constraints
    including indexes
);

插入或更新(upsert)

在业务应用开发中,经常会遇到"如果没有就插入,如果存在就更新"的数据处理需求。传统方式是先进行查询,如果有记录,则执行update语句,否则执行insert语句。在规则明确的情况下,这样的操作显然效率比较低,因为需要多次的客户端处理逻辑和服务器交互。这时可以使用insert onconflict的模式来进行处理,从而简化执行代码,并可以支持批量处理数据。

示例代码和要点如下:

insert into students (id,name,status) values ( 1, 's1', 10)
on conflict(id) do update set (name,status) = (excluded.name, excluded.status) 
returning id,name,status;
  • 本质上,是一个插入操作,但遇到了某种逻辑冲突,然后进行后续处理的逻辑
  • 需要确定一个冲突标准,通常可以用主键,或者唯一索引(或复合索引)
  • 可以选择进行更新操作,也可以选择不做处理如 do nothing
  • 更新记录时,可以选择使用excluded逻辑记录,来进行数据操作
  • 可以通过returning判断记录的存在性

PG15中,已经提供了类似于Oracle的Merge into的功能(也叫Merge),也可以实现这种业务需求了。

查询插入(select insert)

--  从一个表复制记录
insert into students_bak
select *, 100 as flag from students;

将一个查询的结果集,插入一张表,是一个非常常见的操作,需要注意的是插入和选择字段的数量和类型都必须匹配,可能有时需要用到类型转换。

联合更新和删除(join update/delete)

联合更新的意思是更新数据不是简单的基于一个条件,可能是基于一个查询结果,并且需要参考其和这个结果之间的关联关系。 这个在实际业务场景中非常常见,如果这个功能支持的不好,可能需要在客户端进行多次查询,处理和操作。

笔者感觉,虽然在逻辑上是没有问题的,但类似的操作,在Oracle和PG的实现和运行,是有比较大的差别的。PG对这种操作模式的支持显然更好更稳定。

--  从一个表复制记录
insert into students_bak
select *, 100 as flag from students;
-- 基于查询和关联更新和删除记录
with I as (select idhash id2 from students where region = 1)
update students set status = 1 from I where id2 = idhash

CTE和虚表(with as)

使用现成的数据和信息,构造一个标准SQL记录集,然后进行后续的操作,如联合查询,更新操作等等,是比较常用的操作方式。在Postgres中,经常使用CTE(Common Table Expression 公共表表达式,通常也指With字句)来进行处理。

我们来看一个简单的例子

with 
A(id,name) as (values ('id1','name1'),('id2','name2'),('id3','name3')),
B as (select row_number() over () rn, A.* from A)
select * from B;

这个语句可以使用一组数据,创建一个虚表。可以看到如何定义虚表的结构,并且可以使用多个虚表或者记录集的情况。

实际上,CTE的使用非常灵活和强大,它可以连接多个CTE,链接使用,结合记录修改操作等等,合理使用可以帮助开发人员组织数据处理流程,提前过滤和准备数据,同时操作不同的数据集合等等。当然,如果是比较大的数据集合或者比较复杂的操作,需要在编写SQL的时候,注意其执行的效率。

子查询(subquery)

在比较简单的情况下(比如只嵌套一层),可以直接使用子查询。

select iorder, split_part(l, ',', n) city from 
(select 'lz,wh,cd' l , generate_series(1, 3) n) V;
select iorder, vl[vi] city from 
( SELECT generate_series(1,3) iorder, string_to_array('lz,wh,cd', ',') vl) V ;

上面的两个例子,都没有使用row_number,但使用了generate_series和子查询来从一个字符串形式的数组中,构造了一个排序记录集。

使用字段编号替代字段名称

在聚会函数和排序方法中,有时可以使用字段编号来替换字段名词,可以用于简化SQL语句。

select region, count(1) rcount from students group by 1 order by 2 desc;

这个语句,可以使用第一个字段进行聚会计数计算,并且将记录集使用计数的结果进行倒排序。

bit操作和计算

PG提供了标准的bit运算符,可以直接在SQL中使用,不需要使用扩展函数。在一些场景中使用这个功能,可以大幅度简化代码和执行。一般在实际中的使用,这类操作都是围绕着标签化的状态管理来使用的,无非就是以下几种操作。

  • 设置某些状态或者组合
  • 在原来的状态上,增加一个状态(无论原来是否具备此状态)
  • 在原来的状态上,去除一个状态(无论原来是否具备此状态)
  • 查找具备某些状态,或者状态组合的记录(使用状态标签作为查询条件)
  • 查找不具备状态,或者状态组合的记录
  • 查找具备某些状态,但不包括另一些状态的记录

下面的SQL语句,可以实现这些要求:

-- 更新状态字段,增加一种状态2
update students set status = status | 2 where id = 1;  
-- 更新状态字段,增加两种状态(2,4)的同时,去除一种状态1
update students set status = status | (2+4) - (status & 1) where id = 1; 
-- 查找状态中有2的记录
select id from students where status & 2 = 2; 
-- 查找状态中只为2的记录(如果有8种状态)
select id from students where status & (2**8-1) = 2; 
-- 查找状态中没有2的记录
select id from students where status & 2 = 0; 
-- 查找状态中有2和1的记录
select id from students where status & 3 = 3; 
-- 查找状态中有2,或者1的记录
select id from students where status & 3 > 0; 
-- 查找状态中既没有2,也没有1的记录
select id from students where status & 3 = 0; 

注:

1 这里的单个状态,通常为一个简单的2^n整数,复合状态为其算数和,可表示的状态,使用普通正整数时有32个

2 要去除一个状态,不能直接使用-,或者xor,其实是使用-和&的复合操作,即先使用&判断这个状态是否存在,然后再减去这个结果。

聚合过滤(filter)

PG提供了filter方法,可以在聚合计算时,使用某些条件,这样可以使用单个SQL语句,使用一条记录,来实现多种条件聚会的查询。如果不这样操作,可能需要使用Unionall等语句,将多个条件查询的结果合并起来。按照PG的文档,这种操作在执行的时候,可能只对记录集进行一次,是比较高效的。

下面的例子,可以帮助我们来理解这一点:

select region,
count(1) iall, 
count(1) filter (where status & 1 = 1) i1, 
count(1) filter (where status & 2 = 2) i2,
count(1) filter (where status & 3 = 3) i3,
count(1) filter (where status & 3 > 0) i31,
count(1) filter (where status & 3 = 0) i32
from students group by 1 ;

这个filter过滤的功能设计和使用的是非常巧妙的,但个人觉得过滤条件语句中的where完全可以省略。

重复记录处理

这里有两类重复的记录,一类是逻辑的重复记录。如由于错误的输入,导致的身份证号相同,需要在数据维护的工作中,进行查找和处理:

-- CTE count
with I as (
select name n2,icount from (select name, count(1) icount from students group by 1 )C 
where icount > 1)
select name, idnumber from students join I on name = n2 order by 1;
-- having filter
select name, count(1) from students group by 1 having count(1) > 1;
-- row_number
select * from (
select idnumber,name, row_number() over(partition by idnumber order by name ) rn from students) S where rn > 1;

以上语句可以查询这些重复的记录,可以有聚合查询计数和窗口函数过滤两种方法。理论上前一种性能好点,但如果有排序条件确定重复信息或者需要直接获得细节,也可以使用后者。

还有一类是完全重复,比如不小心数据库操作插入了两条完全一样的记录。如果要清除其中的一条,使用常规逻辑操作是无效的,因为所有条件都一样,这时可以使用postgres中的ctid来进行处理。

delete from students where ctid not in (
select ctid from students where idnumber = 'xxx' limit 1
) and idnumber = 'xxx' ;

上面的语句应该可以将重复的学生只保留一个。

ctid是PG的"系统字段",笔者觉得可以将其理解为这条记录在磁盘上的位置,可以作为一条记录在系统中的绝对唯一标识。当然可能在不同的系统状态下,这个信息可能会变动,所以要作为一种特殊的处理方案,谨慎使用。

随机查询(randomQuery)

使用order by random(),可以在查询时随机返回查询结果:

select idnumber, name from students order by random() limit 10;

密码学扩展和简单应用(pgcrypto)

PG通过扩展提供了密码学相关的功能和操作方式。这里只是简单列举一下常用的功能和方式。

create extension pgcrypto;
-- sha256摘要
SELECT encode(digest('China中国', 'sha256'), 'hex');
-- hmac256
SELECT encode(hmac('China中国', 'key','sha256'), 'hex');
-- 存储密码
UPDATE ... SET pswhash = crypt('new password', gen_salt('md5'));
-- 检查密码 返回值是一个布尔值
SELECT (pswhash = crypt('entered password', pswhash)) AS pswmatch FROM ... ; -- false
SELECT (pswhash = crypt('new password', pswhash)) AS pswmatch FROM ... ; --true
-- 生成一个盐
select gen_salt('md5');
-- encrypt and decrypt
with 
V(passwd,otext) as (values ('t0pSecret'::bytea,'china中国')),
E as (select encrypt(otext::bytea, passwd,'aes') econtent from V),
D as (select encode(econtent,'base64') b64, 
convert_from(decrypt(econtent, passwd,'aes'),'utf8') dtext from V,E)
select V.otext,V.passwd, D.* from V,D;

要使用这个扩展,必须先进行安装。安装完成后,pgcrypto提供了一系列自定义函数可以进行密码学操作。和普通的语言一样,pgcrypot操作数据的标准格式是bytea(字节数组),所以在实际使用时,需要注意进行编码和格式的转换。

  • encode: 编码,将bytea转换为指定格式如hex
  • digest: 摘要函数,支持sha1和sha256
  • hmac: 摘要消息验证码
  • crypto: pg实现的一种密码存储和验证方式,没有解密过程,只有匹配验证
  • gen_salt: 随机盐生成,但好像只是pgcryto设置的格式
  • encrypt/decrypt: 标准AES加解密,但好像可选设置不多,简单使用
  • pgp密码学相关函数,这部分比较复杂,内容比较多,使用也不广泛,不在这里讨论

数组操作(Array)

PG可以很方便的进行数组的相关操作。使用时,需要注意的是PG的数组索引是从1开始的,另外定义数组时需要指定类型。合理的使用数组,可以简化程序开发和数据库维护。比如可以使用一个数组,来记录相关的操作时间线,如创建、修改、完成时间等等。也可以记录如标签等数组类型的数据。

-- 增加一个数组字段
alter table students add column otimes integer[]; 
-- 插入数据
insert into students (id,otimes[1]) values (1, 200);
-- 查询数据
select id,name from students where otimes[1] = 100;
-- 使用any和all函数
select id,name from students where any(otimes) > 100;
select id,name from students where all(otimes) > 100;
-- 扩展数组 ||操作符, array_cat方法
select array[1, 2, 3] || 4 as element_append;
select array_cat('{1, 2}', ARRAY[3, 4]) as concatenated_arrays;
-- 删除元素 数组,位置
select array_remove(ARRAY[1,2,3,2,5], 2) as removed_2s;
-- 替换元素 数组,查找值,新值
select array_replace(ARRAY[1,2,3,2,5], 2, 10) as two_becomes_ten;
-- 填充数组 填充值,填充数量,开始位置
select array_fill(90,array[5],array[3])

JSON操作(JSON)

PG内置JSON支持,为Web应用开发,提供了很大的方便。这部分内容比较多,有机会另外撰文说明。

字符串、数组和记录互转(Agg)

可以使用的方法包括 string_to_array, unnest, string_agg, array_agg等。也可以在聚合场景下使用。

-- string 2 array to row
select  unnest(string_to_array('a,b,c',','));
-- array agg, string agg
with A as (select unnest(string_to_array('a,b,c',',')) c)
select array_agg(A.c), string_agg(A.c,';') from A;
  • string_to_array: 使用分隔符,将字符串转成数组
  • unnest: 将数组转成记录
  • string_agg: 使用分隔符链接记录,并转成字符串
  • array_agg: 将记录转成数组

带行号的记录集(Row Number)

有时候需要强行将两个记录集在横向合并起来,就可能需要使用附加的关联字段,如可以使用一个行编号来实现这个。下面的语句可以给一个记录集增加一个行号的字段,从而构造一个新的记录集。

with 
S as (select name from students limit 100),
N as (select row_number() over() rn,name from S)
select * from N;

自定义排序规则(Custom Order)

一般情况下,在关系数据库系统,对记录集排序时非常简单的,只需要对列值进行计算作为排序依据即可。但这些排序都遵循固定的排序规则。

有时候会遇到需要自己定义排序的次序,而这个规则和默认文本或者数组排序规则又有点冲突,就需要使用一些特别的处理方式,有下面几个思路。

  • UnionALL强行将记录集按顺序输出
  • 增加一个排序字段,增加一个排序用的字段,并且手段设置和维护排序用的数值,这种方式可以处理任意的排序要求
  • 前面的思路,使用计算列
  • case when设置排序顺序,将排序规则使用case when 返回排序序号来进行表达,然后以此进行排序
  • 虚表关联,将排序用的规则,写到一个虚拟记录集中,里面有排序数值,查询时关联此记录集,并使用里面的排序序号进行排序

计算列 (Computed Column)

PG12以上的版本支持计算列,也称为生成字段(generated column),定义方式如下:

CREATE TABLE Students (
  Id INTEGER PRIMARY KEY,
  FirstName VARCHAR(50),
  LastName VARCHAR(50),
  FullName VARCHAR(101) GENERATED ALWAYS AS (FirstName || ' ' || LastName) STORED
);

和视图一样,计算列的合理规划和使用,可能可以优化业务逻辑,简化编程,并减少数据操作和维护等工作。

PG的计算列定义语法是固定的,即只支持ALWAYS和STORED,实体字段。理论上计算列的使用,和普通列是完全一样的。现在PG的计算列的使用还有一些限制,如不能嵌套参照其他计算列,不能使用参照当前行的子查询,不能作为分区字段,不能作为主键等等。

时间整数 (TimeInt)

PG提供了一系列时间函数和保留字用于处理时间。

-- 时间戳秒
select extract(epoch from current_timestamp)::integer; 
-- 时间类型
select current_timestamp,now(),current_time, current_date;
-- 时间戳转成格式化字符串
SELECT TO_CHAR(to_timestamp((28070418 + 480) * 60), 'YYYY-MM-DD HH:MI');

在实际项目使用中,在时间格式化的时候,可能需要注意系统的时区,因为默认都是UTC。

表继承(Table Inherits)

PG支持表之间有继承关系,类似于面向对象编程的继承。合理规划和使用的话,可以简化编程和数据管理。下面的例子可以帮助我们理解表继承的实现和使用:

-- 城市信息表
CREATE TABLE cities (
    name            text,
    population      float,
    altitude        int     -- 英尺
);
-- 省会城市继承自城市,多了所属省的属性
CREATE TABLE capitals (
    state char(2)
) INHERITS (cities); 
insert into cities (name,population, altitude) 
values ('苏州', 100,50),('绵阳', 100,50),('深圳', 100,50),('洛阳', 100,50);
insert into capitals (name,population, altitude, state) 
values ('广州', 2100,50,'gd'),('成都', 1600,500,'sc');
select * from cities;
select * from capitals;
-- 所有城市,标识是否省会
select C.*, nullif(P.state,null) capOfState 
from cities C left join capitals P on P.name = C.name;

默认情况下,查询是包括继承表的。如果要指定查询范围,可以使用only关键字。另外,继承表的数据修改和索引使用都有一些限制。如果确实要使用继承表的业务结构,需要确认了解这些限制,并仔细评估和业务需求之间的冲突。

Having

HAVING字句可以用于在对分组后的结果进行过滤时使用。它通常与GROUP BY子句一起使用,用于筛选符合特定条件的分组结果。笔者理解,Having就是group by配套的where字句,可能考虑到和普通查询的where有逻辑方面的冲突,才使用单独的关键字和处理方式。

SELECT country, COUNT(*) AS total_customers
FROM customers GROUP BY country
HAVING COUNT(*) > 5;

Exists

exists,是使用一个子查询,将其查询结果作为主查询的查询条件之一,如下:

SELECT * FROM orders
WHERE EXISTS (
  SELECT 1 FROM customers
  WHERE orders.customer_id = customers.customer_id
);

合理使用exists,可以通过设置预选条件,缩小查询备选结果集,提高查询效率。或者建立查询之间的关联关系。

需要注意,exists子查询,和主查询可以逻辑上没有任何关系。对于主查询结果的每一条结果,都会以子查询的结果进行检查,所以如果是关联查询,就需要特别小心其使用条件,否则可能会有比较严重的性能问题。

COALESCE, NULLIF

PG提供了函数colaesce,它接受多个参数,并返回第一个不为空的值。经常用于,“如果字段内容为空时,则使用默认值”的应用场景。

SELECT COALESCE(null, 5, null, 10); -- 返回 5

"COALESCE"这个单词由词根co-(表示共同或一起),和拉丁语 "alescere"(意为变长、增长)构成。因此,其原义可以理解为将多个元素或选项合并为一个,或者将多个部分融合、组合成一个整体。在计算机科学领域,"COALESCE" 是一个通用的术语,用于描述将多个值合并为一个值的操作。在PostgreSQL中的COALESCE函数也是以此意义使用,作用类似于将多个值"合并"成一个值的概念,它可以合并多个参数并返回第一个非NULL值。

PG中还有一个比较常用的函数nullif,它可以比较两个输入的参数,如果相等,则会返回null;否则返回第一个值。

SELECT NULLIF(10, 10); -- 返回 NULL
SELECT NULLIF('abc', 'def'); -- 返回 'abc'

nullif比较常见的场景包括检查0,检查默认值,错误处理和数据替换等等。

case when

其实,PG提供的caseWhen功能是相当强大的。即它可以以简单方式和搜索方式工作。开发者可以根据需求灵活选用。

-- 简单方式
CASE
  WHEN condition1 THEN result1
  WHEN condition2 THEN result2
  ...
  ELSE result
END
-- 搜索方式
CASE expression
  WHEN value1 THEN result1
  WHEN value2 THEN result2
  ...
  ELSE result
END

默认查询结果(Default Row)

下面的语句,可以在结果集为空的情况下,返回一条默认记录。可能在客户端就不需要特别的处理逻辑了。

SELECT column1, column2, ... FROM table WHERE condition
UNION ALL
SELECT default_value1, default_value2, ...
LIMIT 1

但这里的问题是,无论这个查询是否有结果,都会输出一条默认的记录? 也许可以在后面那个结果集加上exists字句来进行检查,那样显然比较麻烦,如果确有这个需求,可以考虑。

判断查询结果是否为空(Query Empty)

如果只关心满足查询条件的记录是否存在,最简单和高效的方式应该是:

SELECT true from students where ...
union all 
select false limit 1;

窗口函数(Window Function)

PG提供了窗口函数系列功能,极大的方便了各种统计查询的功能开发。这部分内容非常多,笔者会另行撰文讨论。这里只简单的说明一下其中的重点。

首先需要理解窗口函数和聚合函数的区别。 虽然它们都是常用的统计函数,但聚合函数只能处理简单的数据分类和相关计算,比如,记录在分区中的排名,聚合函数就无法处理,因为它不关心个体和群体之间的关系。

而窗口函数不仅能将数据进行分区(窗口函数的名字由来),并且可以基于分区进行统计方面的处理,更重要的是,它可以更细致的分析个体和群体之间的关系,将统计属性,直接附加到原有个体记录上(聚合函数会丢失个体信息),从而不丢失细节信息,提供了更强大的数据分析和呈现的可能性。当然,我们也可以想见,这样的操作势必会造成一些性能方面的问题,因为需要处理的数据和细节更多了,所以在实际使用需要进行考虑和平衡。

窗口函数和统计相关函数也不同,那些函数主要处理一组可以进行统计计算的数据,窗口函数用于处理一组记录,当然可以结合起来使用,达到分组统计分析的目的。

窗口函数可以直接写在普通查询语句中(不使用group by 字句),这个函数系列的标准形式是:

f() over (partition by ... order by ... )

分区和排序都可以使用多个字段,也可以同时返回记录的其他字段。

常用的窗口函数包括:

  • row_number: 分区内按照排序规则的序号,绝对不会有重复值
  • rank, dense_rank, percent_rank: 分区内按照排序规则的排名,要注意这个排名可以是并列的,中间可以有中断或者不中断,常用作成绩排名;dense_rank是紧密排名(并列不跳过位次); percent_rank是百分比排名。
  • firstvalue,lastvalue: 分区中的第一个和最后一个记录,指定字段的值
  • cume_dist: 当前行在分区中的累计分布值
  • lag, lead: 当前分区中,当前记录之前或者之后,指定偏移量的值
  • ntile: 返回在对分区平均分片后,当前行所属分片的编号;
  • nth_value: 获取分区中,指定字段,指定位置的值

延迟模拟(Sleep)

不知道什么地方会用到,也许是在存储过程中的定时或延迟执行吧:

pg_sleep(3); // 休眠3秒

延迟引用(Lateral)

对这个语句的理解,笔者也比较模糊,还没有找到其特别有用的应用场合。 先来看一个例子

select pledged_usd, avg_pledge_usd, amt_from_goal, duration, (usd_from_goal / duration) 
as usd_needed_daily from kickstarter_data, 
lateral (select pledged / fx_rate as pledged_usd) pu 
lateral (select pledged_usd / backers_count as avg_pledge_usd) apu 
lateral (select goal / fx_rate as goal_usd) gu 
lateral (select goal_usd - pledged_usd as usd_from_goal) ufg 
lateral (select (deadline - launched_at)/86400.00 as duration) dr;

有人这样解释: "lateral关键字,允许访问在from后面定义的字段,同时引用在此之前定义的字段"。 这样设计的原因,大概是由于SQL语句查询执行的次序是 from 和 join,一般情况下,就无法处理之后的字段了,所以需要一个关键字特此声明。在某些场景之下,提高了SQL语句编写的灵活性。

以上就是PostgreSQL的应用技巧和示例分享的详细内容,更多关于PostgreSQL应用的资料请关注好代码网其它相关文章!

标签: PostgreSQL 技巧