PostgreSQL的应用技巧和示例分享
作者:JohnYan
这里就不用什么标题党了,本文会总结一些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应用的资料请关注脚本之家其它相关文章!