探探PostgreSQL开发规约
0x00背景
没有规矩,不成方圆。
PostgreSQL的功能非常强大,但是要把PostgreSQL用好,需要后端、运维、DBA的协力配合。
本文针对PostgreSQL数据库原理与特性,整理了一份开发规范,希望可以减少大家在使用PostgreSQL数据库过程中遇到的困惑。 你好我也好,大家都好。
0x01 命名规范
无名,万物之始,有名,万物之母。
【强制】 通用命名规则
本规则适用于所有对象名,包括:库名、表名、表名、列名、函数名、视图名、序列号名、别名等。
对象名务必只使用小写字母,下划线,数字,但首字母必须为小写字母,常规表禁止以
_
打头。对象名长度不超过63个字符,命名统一采用
snake_case
。禁止使用SQL保留字,使用
select pg_get_keywords();
获取保留关键字列表。禁止出现美元符号,禁止使用中文,不要以
pg
开头。提高用词品味,做到信达雅;不要使用拼音,不要使用生僻冷词,不要使用小众缩写。
【强制】 库命名规则
库名最好与应用或服务保持一致,必须为具有高区分度的英文单词。
命名必须以
<biz>-
开头,<biz>
为具体业务线名称,如果是分片库必须以-shard
结尾。多个部分使用
-
连接。例如:<biz>-chat-shard
,<biz>-payment
等,总共不超过三段。
【强制】 角色命名规范
数据库
su
有且仅有一个:postgres
,用于流复制的用户命名为replication
。生产用户命名使用
<biz>-
作为前缀,具体功能作为后缀。所有数据库默认有三个基础角色:
<biz>-read
,<biz>-write
,<biz>-usage
,分别拥有所有表的只读,只写,函数的执行权限。生产用户,ETL用户,个人用户通过继承相应的基础角色获取权限。
更为精细的权限控制使用独立的角色与用户,依业务而异。
【强制】 模式命名规则
业务统一使用
<*>
作为模式名,<*>
为业务定义的名称,必须设置为search_path
首位元素。dba
,monitor
,trash
为保留模式名。分片模式命名规则采用:
rel_<partition_total_num>_<partition_index>
。无特殊理由不应在其他模式中创建对象。
【推荐】 关系命名规则
关系命名以表意清晰为第一要义,不要使用含混的缩写,也不应过分冗长,遵循通用命名规则。
表名应当使用复数名词,与历史惯例保持一致,但应尽量避免带有不规则复数形式的单词。
视图以
v_
作为命名前缀,物化视图使用mv_
作为命名前缀,临时表以tmp_
作为命名前缀。继承或分区表应当以父表表名作为前缀,并以子表特性(规则,分片范围等)作为后缀。
【推荐】 索引命名规则
创建索引时如有条件应当指定索引名称,并与PostgreSQL默认命名规则保持一致,避免重复执行时建立重复索引。
用于主键的索引以
_pkey
结尾,唯一索引以_key
结尾,用于EXCLUDED
约束的索引以_excl
结尾,普通索引以_idx
结尾。
【推荐】 函数命名规则
以
select
,insert
,delete
,update
,upsert
打头,表示动作类型。重要参数可以通过
_by_ids
,_by_user_ids
的后缀在函数名中体现。避免函数重载,同名函数尽量只保留一个。
禁止通过
BIGINT/INTEGER/SMALLINT
等整型进行重载,调用时可能产生歧义。
【推荐】 字段命名规则
不得使用系统列保留字段名:
oid
,xmin
,xmax
,cmin
,cmax
,ctid
等。主键列通常命名为
id
,或以id
作为后缀。创建时间通常命名为
created_time
,修改时间通常命名为updated_time
布尔型字段建议使用
is_
,has_
等作为前缀。其余各字段名需与已有表命名惯例保持一致。
【推荐】 变量命名规则
存储过程与函数中的变量使用命名参数,而非位置参数。
如果参数名与对象名出现冲突,在参数后添加
_
,例如user_id_
。
【推荐】 注释规范
尽量为对象提供注释(
COMMENT
),注释使用英文,言简意赅,一行为宜。对象的模式或内容语义发生变更时,务必一并更新注释,与实际情况保持同步。
0x02 设计规范
Suum cuique
【强制】 字符编码必须为UTF8
禁止使用其他任何字符编码。
【强制】 容量规划
单表记录过亿,或超过10GB的量级,可以考虑开始进行分表。
单表容量超过1T,单库容量超过2T。需要考虑分片。
【强制】 不要滥用存储过程
存储过程适用于封装事务,减少并发冲突,减少网络往返,减少返回数据量,执行少量自定义逻辑。
存储过程不适合进行复杂计算,不适合进行平凡/频繁的类型转换与包装。
【强制】 存储计算分离
移除数据库中不必要的计算密集型逻辑,例如在数据库中使用SQL进行WGS84到其他坐标系的换算。
例外:与数据获取、筛选密切关联的计算逻辑允许在数据库中进行,如PostGIS中的几何关系判断。
【强制】 主键与身份列
每个表都必须有身份列,原则上必须有主键,最低要求为拥有非空唯一约束。
身份列用于唯一标识表中的任一元组,逻辑复制与诸多三方工具有赖于此。
【强制】 外键
不建议使用外键,建议在应用层解决。使用外键时,引用必须设置相应的动作:
SET NULL
,SET DEFAULT
,CASCADE
,慎用级联操作。
【强制】 慎用宽表
字段数目超过15个的表视作宽表,宽表应当考虑进行纵向拆分,通过相同的主键与主表相互引用。
因为MVCC机制,宽表的写放大现象比较明显,尽量减少对宽表的频繁更新。
【强制】 配置合适的默认值
有默认值的列必须添加
DEFAULT
子句指定默认值。可以在默认值中使用函数,动态生成默认值(例如主键发号器)。
【强制】 合理应对空值
字段语义上没有零值与空值区分的,不允许空值存在,须为列配置
NOT NULL
约束。
【强制】 唯一约束通过数据库强制。
唯一约束须由数据库保证,任何唯一列须有唯一约束。
EXCLUDE
约束是泛化的唯一约束,可以在低频更新场景下用于保证数据完整性。
【强制】 注意整数溢出风险
注意SQL标准不提供无符号整型,超过
INTMAX
但没超过UINTMAX
的值需要升格存储。不要存储超过
INT64MAX
的值到BIGINT
列中,会溢出为负数。
【强制】 注意时区问题
使用
TIMESTAMPTZ
或TIMESTAMP
存储时间,避免使用UNIX Epoch或自定义的Epoch存储秒数。使用
TIMESTAMPTZ
时,采用GMT/UTC时间,0时区标准时。
【强制】 及时清理过时函数
不再使用的,被替换的函数应当及时下线,避免与未来的函数发生冲突。
【推荐】 主键类型
主键通常使用整型,建议使用
BIGINT
,允许使用不超过64字节的字符串。主键允许使用
Serial
自动生成,建议使用Default next_id()
发号器函数。
【推荐】 选择合适的类型
能使用专有类型的,不使用字符串。(数值,枚举,网络地址,货币,JSON,UUID等)
使用正确的数据类型,能显著提高数据存储,查询,索引,计算的效率,并提高可维护性。
【推荐】 使用枚举类型
较稳定的,取值空间较小(十几个内)的字段应当使用枚举类型,不要使用整型与字符串表示。
使用枚举类型有性能、存储、可维护性上的优势。
【推荐】 选择合适的文本类型
PostgreSQL的文本类型包括
char
,char(n)
,varchar
,varchar(n)
,text
。除char(n)
外并无本质存储区别。带有
(n)
修饰符的类型会检查字符串长度,会导致微小的额外开销,对字符串长度有限制时应当使用varchar(n)
,避免插入过长的脏数据。避免使用
char(n)
,为了与SQL标准兼容,该类型存在不合直觉的行为表现,且并没有存储和性能优势。
【推荐】 选择合适的数值类型
常规数值字段使用
INTEGER
。主键、容量拿不准的数值列使用BIGINT
。无特殊理由不要用
SMALLINT
,性能与存储提升很小,会有很多额外的问题。REAL
表示4字节浮点数,FLOAT
表示8字节浮点数浮点数仅可用于末尾精度无所谓的场景,例如地理坐标,不要对浮点数使用等值判断。
精确数值类型使用
NUMERIC
。货币数值类型使用
MONEY
。
【推荐】 使用统一的函数创建语法
签名单独占用一行(函数名与参数),返回值单启一行,语言为第一个标签。
一定要标注函数易变性等级:
IMMUTABLE
,STABLE
,VOLATILE
。添加确定的属性标签,如:
RETURNS NULL ON NULL INPUT
,PARALLEL SAFE
,ROWS 1
,注意版本兼容性。
nspname.myfunc(arg1_ TEXT, arg2_ INTEGER)
RETURNS VOID
LANGUAGE SQL
STABLE
PARALLEL SAFE
ROWS 1
RETURNS NULL ON NULL INPUT
AS $function$
SELECT 1;
$function$;
【推荐】 针对可演化性而设计
在设计表时,应当充分考虑未来的扩展需求,可以在建表时适当添加1~3个保留字段。
对于多变的非关键字段可以使用JSON类型。
【推荐】 选择合理的规范化等级
允许适当降低规范化等级,减少多表连接以提高性能。
【推荐】 使用新版本
新版本有无成本的性能提升,稳定性提升,有更多新功能。
充分利用新特性,降低设计复杂度。
【推荐】 慎用触发器
触发器会提高系统的复杂度与维护成本,不鼓励使用。
0x03 索引规范
Wer Ordnung hält, ist nur zu faul zum Suchen.
—German proverb
【强制】 在线查询必须有配套索引
所有在线查询必须针对其访问模式设计相应索引,除极个别小表外不允许全表扫描。
索引有代价,不允许创建不使用的索引。
【强制】 禁止在大字段上建立索引
被索引字段大小无法超过2KB(1/3的页容量),原则上禁止超过64个字符。
如有大字段索引需求,可以考虑对大字段取哈希,并建立函数索引。或使用其他类型的索引(GIN)。
【强制】 明确空值排序规则
如在可空列上有排序需求,需要在查询与索引中明确指定
NULLS FIRST
还是NULLS LAST
。注意,
DESC
排序的默认规则是NULLS FIRST
,即空值会出现在排序的最前面,通常这不是期望行为。索引的排序条件必须与查询匹配,如:
create index on tbl (id desc nulls last);
【强制】 利用GiST索引应对近邻查询问题
传统B树索引无法提供对KNN问题的良好支持,应当使用GiST索引。
【推荐】 利用函数索引
任何可以由同一行其他字段推断得出的冗余字段,可以使用函数索引替代。
对于经常使用表达式作为查询条件的语句,可以使用表达式或函数索引加速查询。
典型场景:建立大字段上的哈希函数索引,为需要左模糊查询的文本列建立reverse函数索引。
【推荐】 利用部分索引
查询中查询条件固定的部分,可以使用部分索引,减小索引大小并提升查询效率。
查询中某待索引字段若只有有限几种取值,也可以建立几个相应的部分索引。
【推荐】 利用范围索引
对于值与堆表的存储顺序线性相关的数据,如果通常的查询为范围查询,建议使用BRIN索引。
最典型场景如仅追加写入的时序数据,BRIN索引更为高效。
【推荐】 关注联合索引的区分度
区分度高的列放在前面
0x04 查询规范
The limits of my language mean the limits of my world.
—Ludwig Wittgenstein
【强制】 读写分离
原则上写请求走主库,读请求走从库。
例外:需要读己之写的一致性保证,且检测到显著的复制延迟。
【强制】 快慢分离
生产中1毫秒以内的查询称为快查询,生产中超过1秒的查询称为慢查询。
慢查询必须走离线从库,必须设置相应的超时。
生产中的在线普通查询执行时长,原则上应当控制在1ms内。
生产中的在线普通查询执行时长,超过10ms需修改技术方案,优化达标后再上线。
在线查询应当配置10ms数量级或更快的超时,避免堆积造成雪崩。
Master与Slave角色不允许大批量拉取数据,数仓ETL程序应当从Offline从库拉取数据
【强制】 主动超时
为所有的语句配置主动超时,超时后主动取消请求,避免雪崩。
周期性执行的语句,必须配置小于执行周期的超时。
【强制】 关注一致性/复制延迟
应用必须意识到主从之间的同步延迟并妥善处理好复制延迟超出合理范围的情况
平时在0.1ms的延迟,在极端情况下可能达到十几分钟甚至小时量级。应用可以选择从主库读取,稍后再度,或报错。
【强制】 使用连接池
应用必须通过连接池访问数据库,连接6432端口的pgbouncer而不是5432的postgres。
注意使用连接池与直连数据库的区别,一些功能可能无法使用(比如Notify/Listen),也可能存在连接污染的问题。
【强制】 禁止修改连接状态
使用公共连接池时禁止修改连接状态,包括修改连接参数,修改搜索路径,更换角色,更换数据库。
万不得已修改后必须彻底销毁连接,将状态变更后的连接放回连接池会导致污染扩散。
【强制】 重试失败的事务
查询可能因为并发争用,管理员命令等原因被杀死,应用需要意识到这一点并在必要时重试。
应用在数据库大量报错时可以触发断路器熔断,避免雪崩。但要注意区分错误的类型与性质。
【强制】 掉线重连
连接可能因为各种原因被中止,应用必须有掉线重连机制。
可以使用
SELECT 1
作为心跳包查询,检测连接的有消息,并定期保活。
【强制】 在线服务应用代码禁止执行DDL
不要在应用代码里搞大新闻。
【强制】 显式指定列名
避免使用
SELECT *
,或在RETURNING
子句中使用*
。请使用具体的字段列表,不要返回用不到的字段。当表结构发生变动时(例如,新值列),使用列通配符的查询很可能会发生列数不匹配的错误。例外:当存储过程返回具体的表行类型时,允许使用通配符。
【强制】 禁止在线查询全表扫描
例外情况:常量极小表,极低频操作,表/返回结果集很小(百条记录/百KB内)。
在首层过滤条件上使用诸如
!=
,<>
的否定式操作符会导致全表扫描,必须避免。
【强制】 禁止在事务中长时间等待
开启事务后必须尽快提交或回滚,超过10分钟的
IDEL IN Transaction
将被强制杀死。应用应当开启AutoCommit,避免
BEGIN
之后没有配对的ROLLBACK
或COMMIT
。尽量使用标准库提供的事务基础设施,不到万不得已不要手动控制事务。
【强制】 使用游标后必须及时关闭
【强制】 科学计数
count(*)
是统计行数的标准语法,与空值无关。count(col)
统计的是col
列中的非空记录数。该列中的NULL值不会被计入。count(distinct col)
对col
列除重计数,同样忽视空值,即只统计非空不同值的个数。count((col1, col2))
对多列计数,即使待计数的列全为空也会被计数,(NULL,NULL)
有效。count(distinct (col1, col2))
对多列除重计数,即使待计数列全为空也会被计数,(NULL,NULL)
有效。
【强制】 注意聚合函数的空值问题
空值与聚合函数:聚合函数当输入值全部为
NULL
时,返回结果为NULL
。但
count(col)
在全是空值时返回结果为0
,需要注意。使用
SELECT coalesce(sum(col), 0) FROM tbl;
为聚合函数配置类似的默认值。
【强制】谨慎处理空值
明确区分零值与空值,空值使用
IS NULL
进行等值判断,零值使用常规的=
运算符进行等值判断。空值作为函数输入参数时应当带有类型修饰符,否则对于有重载的函数将无法识别使用何者。
空值与任何值的逻辑判断,其结果都为空值,例如
NULL=NULL
返回结果是NULL
而不是TRUE/FALSE
。涉及空值与非空值的等值比较,请使用
`IS DISTINCT FROM
`进行比较,保证比较结果非空。空值与聚合函数:聚合函数当输入值全部为NULL时,返回结果为NULL。
【强制】 注意序列号空缺
当使用
Serial
类型时,INSERT
,UPSERT
等操作都会消耗序列号,该消耗不会随事务失败而回滚。当使用整型作为主键,且表存在频繁插入冲突时,需要关注整型溢出的问题。
【推荐】 重复查询使用准备语句
重复的查询应当使用准备语句(Prepared Statement),消除数据库硬解析的CPU开销。
准备语句会修改连接状态,请注意连接池对于准备语句的影响。
【推荐】 选择合适的事务隔离等级
默认隔离等级为读已提交,适合大多数简单读写事务,普通事务选择满足需求的最低隔离等级。
需要事务级一致性快照的写事务,请使用可重复读隔离等级。
对正确性有严格要求的写入事务请使用可序列化隔离等级。
在RR与SR隔离等级出现并发冲突时,应当视错误类型进行积极的重试。
【推荐】 判断结果存在性不要使用count
使用
SELECT 1 FROM tbl WHERE xxx LIMIT 1
判断是否存满足条件的列,要比Count快。可以使用
select exists(select * FROM app.sjqq where xxx limit 1)
将存在性结果转换为布尔值。
【推荐】 使用RETURNING子句
如果用户需要在插入数据和,删除数据前,或者修改数据后马上拿到插入或被删除或修改后的数据,建议使用
RETURNING
子句,减少数据库交互次数。
【推荐】 使用UPSERT简化逻辑
当业务出现插入-失败-更新的操作序列时,考虑使用
UPSERT
替代。
【推荐】 利用咨询锁应对热点并发。
针对单行记录的极高频并发写入(秒杀),应当使用咨询锁对记录ID进行锁定。
如果能在应用层次解决高并发争用,就不要放在数据库层面进行。
【推荐】优化IN操作符
使用
EXISTS
子句代替IN
操作符,效果更佳。使用
=ANY(ARRAY[1,2,3,4])
代替IN (1,2,3,4)
,效果更佳。
【推荐】 不建议使用左模糊搜索
左模糊搜索
WHERE col LIKE '%xxx'
无法充分利用B树索引,如有需要,可用reverse
表达式函数索引。
【推荐】 使用数组代替临时表
考虑使用数组替代临时表,例如在获取一系列ID的对应记录时。
=ANY(ARRAY[1,2,3])
要比临时表JOIN好。