多页打印视图 点击此处打印.

返回常规视图.

Pigsty 博客

新闻

Pigsty 0.9 新功能介绍

Pigsty v0.9是1.0GA前的最后一个大版本,带来了很多新功能~。

v0.9 Highlight

Pigsty v0.9 已经于5月4日发布。v0.9是1.0GA前最后一个大版本,引入了一些有趣的新功能。

  • 部署简化:一键安装模式
  • 日志收集:基于Loki实时查阅分析数据库日志
  • 命令行工具:Pigsty CLI,使用Go编写的趁手好用的命令行工具。
  • Pigsty GUI:提供图形化的配置文件编辑生成功能(Beta)
  • 飞升模式:集成默认PostgreSQL实例,Bootstrap,Datalet
  • 界面改版:适配Grafana 7.5.4新特性,重制若干Dashboard。

易用性优化:一键安装

尽管Pigsty已经在封包,交付流程上进行了大量优化,不少用户仍然反馈安装遇到困难。所以在v0.9中,Pigsty针对安装部署又进行了再次改进。

总而言之,目前只要在装有全新CentOS 7.8的机器上使用root依次执行以下命令,即可完成单节点Pigsty的部署。

# 用免密ssh&sudo的用户执行,标准就是能成功执行: ssh 127.0.0.1 'sudo ls'
/bin/bash -c "$(curl -fsSL https://pigsty.cc/install)" # 下载源码
cd ~/pigsty                                     # 进入源码目录       
make pkg                                        # 下载离线包(可选)
bin/ipconfig   <ip_address>                     # 配置IP地址
make meta                                       # 开始初始化

一键安装可能有些言过其实,但你确实可以把这几行命令粘成一条一键执行……

这里有一个产品思路上的变化,Pigsty将默认的演示环境从4节点改为了单节点。原来的四节点沙箱,能够很好对演示监控系统相关功能,演示集群管理、创建、扩缩容、高可用故障切换等。3个嫌少5个太多4个刚刚好。标准的4节点沙箱至少需要4核6G内存,尽管一台树莓派就够了,但还是有使用门槛:一个是大家的笔记本配置不一定都像我的那么好,不一定能在本机跑起来,再者就是一台虚拟机还是会省事很多。

所以单节点作为Pigsty的默认配置,有助于降低使用试用的门槛。正所谓门槛低一分,用户多十分。最后为了避免跑命令都有人不会,我还特意录了一个教学视频,完整的演示了如何在一台新弄的云虚拟机上部署Pigsty。

B站地址:https://www.bilibili.com/video/BV1nK4y1P7E1

日志收集

监控系统收集的是指标(Metrics),但系统的可观测性还有另一个重要的部分就是日志。日志的重要性无需多提,系统出现问题时,如何最快的看到日志,搜索到关键信息十分重要。

传统上做日志收集分析通常是用ELK全家桶,不巧的是我个人很讨厌Java生态的东西。而且这玩意明显太重了,对于我来说,能以最快的速度在正确的日志上,正确的时间段走grep搜索就足够了。所以我选择了Loki,这个是Grafana官方出品的日志收集组件,它最大的特色就是采用与Promtheus一致的查询语法。

例如在Pigsty v0.9中新增的PG Instance Log Dashboard,就提供了一个简单但无比实用的功能。通过ins选择数据库实例,通过job选择日志类型(postgres|pgbouncer|patroni),然后在搜索框中填入(可选)的关键词。即可完成日志查询。

错误日志会自动按等级区分颜色并高亮搜索关键词。上方的Logs | Error Logs | Search Logs 面板分别显示了指定实例在指定时间段内的(所有,错误,搜索匹配)日志分布直方图。您可以在这里拉选快速定位出现错误(或包含指定关键词)的日志项,在几秒钟精确定位到任意一条错误日志。

此外,我们还可以通过LogQL,采用类似Prometheus的语法定义日志衍生指标。比如每分钟错误日志数就会是一个极其有用的监控报警指标。使用LogQL也可以迅速对日志进行分析,并通过相同的Label与Prometheus中其他的丰富监控指标进行Join。

Loki在设计上就是个大规模并行Grep,尽管没有索引,但是速度还是惊人的快。而且对于日志的压缩比相当高,很省空间。总的来说我相当满意。使用Loki与Promtail收集Postgres相关日志还是比较Tricky的,特别是CSV日志,用正则表达式去解析确实是比较蛋疼的一件事:

^(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \w+),"?(?P<user>[^"]*?)?"?,"?(?P<datname>[^"]*?)?"?,(?P<pid>\d+)?,"?(?P<conn>[^"]+)?"?,(?P<session_id>\w+\.\w+)?,(?P<line_num>\d+)?,"?(?P<cmdtag>[^"]*?)?"?,(?P<session_start>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \w+)?,(?P<vxid>[^"]*?)?,(?P<txid>\d+)?,(?P<level>\w+)?,(?P<code>\w{5})?,.*$

另外值得注意的就是,为了正确收集处理Patroni的日志,Pigsty v0.9针对Patroni的日志时间戳进行了修改,移除了畸形的毫秒时间戳并添加了时区,主要是Python默认的时间戳格式无法被Go标准库正确识别,这是一个我没想到的坑。所以为了收集查阅Patroni日志,Pigsty v0.8之间的用户也许需要重新调整一下Patroni的配置并移除已有Log。

最后 Pigsty v0.9 为了避免系统被垃圾日志淹没,对日志参数进行了精细的优化。特别是把健康检查,监控导致的垃圾信息都消除掉了,真是一件大好事啊。主要是修改了Consul Health Check Method 和 Haproxy的健康检查Method,使用旧版本的同志们重新执行 monitor & service 两个任务即可应用此变更。

命令行工具

Pigsty先前都是通过Ansible Playbook来完成各种部署任务。直接调用Ansible Playbook尽管可以执行非常精细的控制,但也需要对系统有一定的熟悉与了解才行。尽管ansible是个非常简单的傻瓜式部署工具,但它也是有使用门槛的。有没有办法消除这个门槛,让Pigsty用户根本不用知道什么是Ansible?当然也有办法,只要写一个命令行工具,在Ansible的原语外面再包装一层就好了。例如

pigsty infra init            #--> ./infra.yml            # 执行基础设施初始化
pigsty pgsql init -l pg-test #--> ./pgsql.yml -l pg-test # 初始化pg-test集群

当然如果只是一个命令翻译层,就没什么好说的了。Pigsty CLI还提供了很实用的信息查询功能。最后要达到的理想状态就是Pigsty CLI一个二进制往服务器上一丢,然后无论是下载安装,配置ssh sudo,查阅基础设施与数据库集群配置,巡检数据库状态, 执行Play,快捷ssh与psql至数据库,SQL发布,备份恢复,所有的东西都做到里面去。

关于Pigsty CLI本身,我一直在犹豫是用Python写还是用Go写。

Python的好处是写起来确实很快,能Hotfix,而且纯文本体积很小,坏处是一堆依赖不太好处理;比如解析yaml需要安装PyYAML,连接PG需要安装psycopg2,跑个webserver还要安装框架,有的包不一定有。

Go的好处是没有任何依赖,但是编译出来十几MB太大了,而且用Go解析YAML真的是一个粪活,Python几十行搞定的功能我要写千把行代码。但没有依赖这个特性确实太香了。我分别写了Go和Python两个版本,但最后还是决定用Go来做这个工具。

说起来Go 1.16有一个很棒的新功能 embed,可以直接把静态资源嵌入到二进制程序中。一个Web程序就是一个清清爽爽的二进制。这也是我使用Go的一个重要原因:开发Pigsty GUI

图形用户界面

Pigsty GUI

Pigsty CLI中还集成了一个Web UI。提供了一个配置文件编辑与跑脚本的API,并嵌入了一个图形界面。只要在Pigsty源码目录中执行 pigsty serve 就会启动它(不过目前还在Beta,且这个GUI开不开源还没定)。就我个人而言,还是比较习惯命令行,但图形界面确实是降低门槛的好东西。比如这个配置编辑的功能就很不错

这个GUI还会调用CLI中封装好的命令,执行各种任务(集群、实例初始化、销毁、扩缩容等),并实时返回日志信息。

也可以查阅先前执行的Job

为了安(tou)全(lan),我把Job做成了Singleton,一次只能通过UI运行一个Job,就是跑一个Playbook。

Pigsty监控系统的UI

此外,Pigsty监控系统的图形界面也有了一些改进。一些Panel改用Grafana新提供的Pie Chart和Time Series重新绘制,一些用户反馈的的Bug也得到了修复。开源版中新增加了非常实用的PG Instance Log Dashboard。同时原有的 PG Service Dashboard 彻底重制,更多的是反映 Haproxy 中对外”暴露“ 的Service以及Proxy的状态,而不再是展现根据人为划分的Role聚合的服务级指标。

新 PG Service Dashboard

原来的Dashboard适配Grafana 7.5.4 后,显示效果有了一些改善。

说句题外话,如果你觉的Demo中监控系统界面太单调,很可能是因为你的实例太少了,Pigsty的UI是针对大规模集群(几百+实例)设计的。如果实例太少,又没什么活动,就会显得空荡荡的。

关于监控系统,等到六月份Grafana 8.0 出来后,可能在界面上会有显著调整。但我现在并不确定这个变更是否应该引入到1.0 GA中。

飞升

默认配置文件中部署在元节点上的数据库集群pg-meta, 本来纯粹是一个空的演示数据库。在以前这个数据库实例本身并不属于元节点或基础设施的一部分,纯粹是出于元节点“废物利用”的目的加上的,用来配合pg-test集群测试一些数据库迁移的案例。但是现在我决定让它成为一个必选项而不是可选项,在执行infra初始化的时候一并完成这个pgsql数据库实例的创建。

这样做有两个考量,第一个是正确性验证。作为一个数据库部署方案,如果这套系统可以在元节点上部署基础设施时一并部署一套数据库,那么我们当然对它能在普通数据库节点上成功完成数据库集群部署更有信心。第二个是一个默认可用的PostgreSQL确实可以做非常非常多的事情:例如,自我管理。

CMDB

理想情况下,大规模生产集群应该使用CMDB进行管理。简单的说,就是把 pigsty.yml 文件拆分成一系列的数据库表。然后通过数据库DML来完成配置修改,执行脚本时通过查询来获取最新的配置。

不过这里就是一个先有鸡还是先有蛋的问题。为了使用CMDB管理集群,你先要部署一套DB。而没有CMDB,你就没法(使用基于CMDB的供给方案)部署这套DB。除非说你的CMDB不是Postgres,而是etcd,consul这样的分布式配置存储元数据库。

我确实认真考虑过用Consul或Etcd作为CMDB的可能性,例如,如果后面我想做Kubernetes的Operator,使用ETCD作为CMDB就是很自然的想法,特别是服务注册机制可以实时反馈每个实例每个服务的状态,可以省掉很多开发工作。但从另一方面讲,Pigsty的许多计划中的高级特性又确实需要一个Postgres来承载。所以最后我还是决定使用Postgres本身作为Pigsty的CMDB,即元节点上的那套数据库 pg-meta。这也是为什么我会在 Pigsty v0.9中将 pg-meta 作为基础设施的一部分。

快捷方式make upgrade (实际上是调用bin/upgrade 这个脚本)可以将当前的 pigsty.yml 配置文件解析为meta 数据库中的一系列表。然后将pigsty.yml 本身替换为一个从数据库中查询配置的动态Inventory脚本。这样就完成了一次“飞升”,从配置文件管理升级为CMDB管理。

PostgreSQL非常强大,我编写了一系列存储过程封装了配置文件的增删改查。将Pigsty CMDB功能做成了一个自包含的数据库应用。所以任何后端实现只需要简单的转发这些函数调用即可,甚至有一些现成的应用(PostGraphile,PRest,postgREST)可以直接根据数据库模式生成CRUD API。所以基于数据库的后端CURD实现我就不着急弄了。

这些表将作为Pigsty后续高级特性的基石,它们构成了一个自治应用的核心部分。这个应用也可以作为一个范例,演示一个典型的应用是如何使用Pigsty中的数据库的。

分析

pg-meta的存在不仅仅可以服务于Pigsty自我管理这种简单的应用场景,还可以做的更多。如果梳理一下Pigsty现在的形态就会发现:它现在可以一键拉起一个生产级PostgreSQL实例,同时还有一个Grafana。有了这两个东西,Pigsty变成了什么?它变成了一个开箱即用的数据分析IDE!

分析是数据产业的核心,是数据这种电子石油的炼化厂。SQL基本上是数据分析的事实标准,而PostgreSQL的数据分析功能绝对是所有关系型数据库中最先进的(之一,谦虚一下)。

分析的结果需要呈现,而Grafana提供了快速高效的可视化功能支持。用一个时髦的名词讲,Grafana是一个Low code数据产品开发平台。如果觉得Grafana的可视化功能不够丰富,还可以在Grafana中使用echarts。我之前有一个讲座专门介绍了如何使用这三者配合做出相当实用的数据可视化应用来。

参考视频: 使用PostgreSQL Grafana Echarts进行敏捷数据可视化https://www.bilibili.com/video/BV1Gh411o7FG

很多应用(特别是数据分析,内部数据报表展示)其实并不一定需要(数据消费端的)后端 ,Grafana + PostgreSQL可以一条龙式地解决很多此类需求。一个典型的例子就是在Apple时我曾经用两周时间,基于Grafana和PostgreSQL做出了一个与现有Trace系统类似功能的产品,除了界面丑了点,基本覆盖了核心功能,用半个人月实现了几个人年的功能。这背后隐藏的是一种敏捷方法论:抓主要矛盾,找出系统的不可替代点(用数据库写SQL分析,数据库可视化),然后把所有其他杂活全都用现有开源轮子解决掉。

Datalet

Pigsty在某种程度上就是这种方法论的体现,现在你有一个开箱即用的数据库+可视化平台。数据研发人员不需要关心什么前端后端接口,只需要把数据灌入数据库再用SQL查出来,最后在Grafana上画出来。添加一些交互导航,一个五脏俱全的数据应用就出炉了。所以说数据应用的分发是不是就可以浓缩成一个标准化的模式:database schema + grafana dashboards [+ data generator] ?

这里的Story是,也许可以把这种典型数据应用做成一种通用的 “Datalet”,把一个特定的数据功能点做成一个自包含的”数据小应用“,例如手机号身份证查询,漫游轨迹查询,行政区划查询,旅游照片打卡,实时疫情数据等等。而Pigsty则成为这种Datalet的标准Runtime。

上面那些应用其实我都做过,当然Talk is cheap,所以这里特别准备了一个典型Non-Trivial的例子:ISD数据分析案例,便是基于Pigsty开发的。这个应用拉取美国NOAA官方的全球地面气象站观测数据。便是采用的这种结构。任何用户完全可以在Pigsty环境中几分钟完这个应用的部署。

https://github.com/Vonng/isd

另外从某种意义上来说,Pigsty的监控系统也是这样一个典型的 数据应用,只不过它的主要数据源是Prometheus而不是Postgres罢了。数据库实例越多,活动越丰富,情况越复杂,数据也就越丰富,就越能展现出Pigsty监控系统的能力来。

绕的太远了,让我重新总结一下,这里的这个功能点之所以叫做”飞升“,就是它 Enable 了一种可能性,让Pigsty不再是一个 监控系统,一个管控软件,而且还是一个开箱即用的数据分析集成开发部署环境。尽管目前只有一个自我管理的应用,和一个演示用的气象站查询应用,但这里的Potential是无穷的。

展望未来

Pigsty 1.0 版本 大致将于 今年6~7月份释出。主要计划的更新包括:

  • Pigsty CLI 与 GUI 的完善,提供对 数据库动态 Inventory的支持
  • 进一步简化安装流程,最好能集成到 Pigsty CLI中。
  • 修改pg_exporter,提供单实例多DB的 DB内对象监控支持,支持PG14(如果需要)。
  • 彻底消除监控系统中的rolesvc 标签,涉及到不少监控面板的重制,报警规则调整等。
  • 如果可能,基于Grafana 8.0 重新调整监控系统的界面。
  • 以ISD为例,做出几个基于Pigsty的典型 “Datalet”

尽管Pigsty仍然处于早期阶段,但我相信它具有极大的Potential,能成为一个Gaming Changing的东西。因此也需要好好规划思考一下后续的打法。Pigsty以后的发展,大致有三个方向:

  • 做最顶尖最专业的 开源PostgreSQL 监控系统
  • 做门槛最低最好用的 开源PostgreSQL 供给方案
  • 做开箱即用的数据分析一条龙解决方案

当然我自己精力也有限,怎么加点还是需要审慎考虑的,毕竟一个人运营一个开源项目还是蛮吃力的。

Pigsty从最开始的 Idea (https://github.com/Vonng/pg/tree/master/test) 到现在已经快两年了。经过了近两年时间的打磨润色,已经基本成为了成熟的产品。但我一般喜欢做完了再说,所以1.0GA之前不会大张旗鼓的去宣传。但令人欣喜的是,已经有不少用户使用了起来。其中还有一些很给力的用户:互联网公司,部队,银行消费金融……,管理的PostgreSQL集群规模放在全国也绝对是排得上号的。可以说正是正是这些用户们的支持,才让我相信这是 the right thing,真正有动力继续去把这个事情推下去。

Pigsty现已加入阿里云PgSQL七天训练营课程体系

阿里云PgSQL7日训练营,现已提供Pigsty教程。

监控系统是智能化管理与自动化运维的基石,可以为资源规划, 故障排查,性能优化提供至关重要的数据支持。 本课程将以开源PostgreSQL监控解决方案Pigsty为基础,介绍生产级监控系统的部署实施落地详情, 以及如何在实际场景中利用监控数据进行系统水位评估,定位系统故障,优化查询性能,做到数据驱动。

PDF下载:《PostgreSQL监控实战——使用Pigsty解决实际问题》

PG Conf China 2020

《生产级PostgreSQL监控系统——Pigsty》@ 2020 PostgreSQL中国技术大会(2021/01/16 广州)

在2020 PostgreSQL中国技术大会(2021/01/16 广州),专场七:数据库内核及新特性(下),Pigsty作者冯若航将进行题为《生产级PostgreSQL监控系统—pigsty》的分享。欢迎各位朋友光临

分享内容

PDF下载:《生产级PostgreSQL监控系统——Pigsty》

大会议程

紧急通知

各位赞助商、各位嘉宾、各位伙伴、亲爱的PGer: 前天接到南京政府方面的通知,南京《2020 PostgreSQL中国技术大会》活动将会被取消(因为疫情防控)。此次变故肯定会对您的计划产生影响!我们也非常难过和紧张。在这里,深表歉意!!!

经过昨天1天的场地寻找,大会会务组正式决定,大会将继续,只是把大会搬到广州,时间不变(还是1月15号,16号),新的地址是:广州万富希尔顿酒店(广州市白云区云城东路515、517号),由此给您带来的损失,我们愿意协商解决,承担责任。更多详细信息,请联络我们的工作人员。真心希望大家能够继续支持本次大会。

2020数据库嘉年华分享

在2020年数据库嘉年华上,Pigsty作者冯若航分享了如何基于开源组件构建针对大规模生产数据库集群的监控系统。

监控系统是管理运维改进的基础,这次分享将介绍自研开源PostgreSQL监控全方位解决方案 —— pigsty。千类指标,几十种面板,覆盖PostgreSQL及其附属组件的方方面面;在不同层次上提供性能洞察:关系、数据库、实例、服务、集群、分片、全局汇总等等。以及如何在实践中使用这套系统。

分享内容

PDF下载:《PostgreSQL监控系统——Pigsty》 PDF下载

PG Conf China 2019

《FDW原理与应用》 2019 PostgreSQL中国技术大会

在2019 PostgreSQL中国技术大会(2019/11/16 北京) 进行了关于题为《FDW原理与应用》的分享

分享内容

PDF下载:《FDW原理与应用》

Pigsty在线直播:开源PG全家桶上手指南

开源PG全家桶上手指南

PostgreSQL中文社区又开始在线直播啦!本周日(5月23日晚7:30),由我带给大家来一段单口相声 —— 《开源PG全家桶上手指南》。

图片

Pigsty是开源的PostgreSQL一条龙解决方案,主要有以下三个目标:

  • 做最顶尖最专业的 开源PostgreSQL 监控系统
  • 做门槛最低最好用的 开源PostgreSQL 管控方案
  • 做开箱即用的数据分析可视化集成环境

谁会感兴趣?

PostgreSQL DBA,运维,DevOps:Pigsty提供了一体化的生产级监控系统部署方案,可以极大提高数据库的管理&使用水平下限。肯定比自己yum install & systemctl start 强大到不知道哪里去了。其监控系统指标覆盖率完爆市面上所有同类产品。

PostgreSQL用户,后端开发者,数据研发工程师,内核研发:Pigsty提供了与生产环境完全一致的本地沙箱,其中包含单节点的默认沙箱与4节点的集群沙箱。用户可以方便地在本地进行开发测试,并通过监控系统获得即时反馈。

数据分析数据可视化感兴趣的人:Pigsty提供了本地、单机即可运行,开箱即用的集成数据分析环境:包括分析功能强大的PostgreSQL(内嵌PostGIS,TimeScale地理空间时序数据等扩展),敏捷的数据库可视化平台Grafana,以及内建的Echarts可视化支持。

学生,新手程序员,以及任何有兴趣尝试PostgreSQL数据库的用户。Pigsty上手门槛极低:您只需要有一台虚拟机,或者一台笔记本即可运行。三行命令,一键安装,10分钟搞定。

相关分享

先前我在PostgreSQL中文社区做过三次相关的直播分享,分别关于监控系统,高可用架构,数据可视化:

其实这些分享中已经可以看到Pigsty的三个核心抓手:监控管控可视化。这次分享将浓缩三者精华,直接以实例介绍这些功能。并现手把手演示如何用10分钟从上手到交付,敬请期待。

需要准备什么?

·什么都不需要准备,但如果你想同步来一个实机部署,可以准备好一台CentOS 7.8的新虚拟机。最简单的方式当然是找个云厂商花几块钱买个几天啦。

版本

v1.5.1 发布注记

v1.5添加了Docker支持,ETCD作为DCS的能力,基础设施自我监控与CMDB改进

v1.5.0

亮点概述

  • 完善的Docker支持:在管理节点上默认启用并提供诸多开箱即用的软件模板:bytebase, pgadmin, pgweb, postgrest, minio等。
  • 基础设施自我监控:Nginx, ETCD, Consul, Prometheus, Grafana, Loki 自我监控
  • CMDB升级:兼容性改善,支持Redis集群/Greenplum集群元数据,配置文件可视化。
  • 服务发现改进:可以使用Consul自动发现所有待监控对象,并纳入Prometheus中。
  • 更好的冷备份支持:默认定时备份任务,添加pg_probackup备份工具,一键创建延时从库。
  • ETCD现在可以用作PostgreSQL/Patroni的DCS服务,作为Consul的备选项。
  • Redis剧本/角色改善:现在允许对单个Redis实例,而非整个Redis节点进行初始化与移除。

详细变更列表

监控系统

监控面板

  • CMDB Overview:可视化Pigsty CMDB Inventory。
  • DCS Overview:查阅Consul与ETCD集群的监控指标。
  • Nginx Overview:查阅Pigsty Web访问指标与访问日志。
  • Grafana Overview:Grafana自我监控
  • Prometheus Overview:Prometheus自我监控
  • INFRA Dashboard进行重制,反映基础设施整体状态

监控架构

  • 现在允许使用 Consul 进行服务发现(当所有服务注册至Consul时)
  • 现在所有的Infra组件会启用自我监控,并通过infra_register角色注册至Prometheus与Consul中。
  • 指标收集器 pg_exporter 更新至 v0.5.0,添加新功能,scaledefault,允许为指标指定一个倍乘因子,以及指定默认值。
  • pg_bgwriter, pg_wal, pg_query, pg_db, pgbouncer_stat 关于时间的指标,单位由默认的毫秒或微秒统一缩放至秒。
  • pg_table 中的相关计数器指标,现在配置有默认值 0,替代原有的NaN
  • pg_class指标收集器默认移除,相关指标添加至 pg_tablepg_index 收集器中。
  • pg_table_size 指标收集器现在默认启用,默认设置有300秒的缓存时间。

部署方案

  • 新增可选软件包 docker.tgz,带有常用应用镜像:Pgadmin, Pgweb, Postgrest, ByteBase, Kong, Minio等。

  • 新增角色ETCD,可以在DCS Servers指定的节点上自动部署ETCD服务,并自动纳入监控。

  • 允许通过 pg_dcs_type 指定PG高可用使用的DCS服务,Consul(默认),ETCD(备选)

  • 允许通过 node_crontab 参数,为节点配置定时任务,例如数据库备份、VACUUM,统计收集等。

  • 新增了 pg_checksum 选项,启用时,数据库集群将启用数据校验和(此前只有crit模板默认启用)

  • 新增了pg_delay选项,当实例为Standby Cluster Leader时,此参数可以用于配置一个延迟从库

  • 新增了软件包 pg_probackup,默认角色replicator现在默认赋予了备份相关函数所需的权限。

  • Redis部署现在拆分为两个部分:Redis节点与Redis实例,通过redis_port参数可以精确控制一个具体实例。

  • Loki 与 Promtail 现在使用 frpm 制作的 RPM软件包进行安装。

  • DCS3配置模板现在使用一个3节点的pg-meta集群,与一个单节点的延迟从库。

软件升级

  • 升级 PostgreSQL 至 14.3
  • 升级 Redis 至 6.2.7
  • 升级 PG Exporter 至 0.5.0
  • 升级 Consul 至 1.12.0
  • 升级 vip-manager 至 v1.0.2
  • 升级 Grafana 至 v8.5.2
  • 升级 Loki & Promtail 至 v2.5.0,使用frpm打包。

问题修复

  • 修复了Loki 与 Promtail 默认配置文件名的问题
  • 修复了Loki 与 Promtail 环境变量无法正确展开的问题
  • 对英文文档进行了一次完整的翻译与修缮,文档依赖的JS资源现在直接从本地获取,无需互联网访问。

API变化

新参数

  • node_data_dir : major data mount path, will be created if not exist.
  • node_crontab_overwrite : overwrite /etc/crontab instead of append
  • node_crontab: node crontab to be appended or overwritten
  • nameserver_enabled: enable nameserver on this meta node?
  • prometheus_enabled: enable prometheus on this meta node?
  • grafana_enabled: enable grafana on this meta node?
  • loki_enabled: enable loki on this meta node?
  • docker_enable: enable docker on this node?
  • consul_enable: enable consul server/agent?
  • etcd_enable: enable etcd server/clients?
  • pg_checksum: enable pg cluster data-checksum?
  • pg_delay: recovery min apply delay for standby leader

参数重制

Now *_clean are boolean flags to clean up existing instance during init.

And *_safeguard are boolean flags to avoid purging running instance when executing any playbook.

  • pg_exists_action -> pg_clean
  • pg_disable_purge -> pg_safeguard
  • dcs_exists_action -> dcs_clean
  • dcs_disable_purge -> dcs_safeguard

参数重命名

  • node_ntp_config -> node_ntp_enabled
  • node_admin_setup -> node_admin_enabled
  • node_admin_pks -> node_admin_pk_list
  • node_dns_hosts -> node_etc_hosts_default
  • node_dns_hosts_extra -> node_etc_hosts
  • node_dns_server -> node_dns_method
  • node_local_repo_url -> node_repo_local_urls
  • node_packages -> node_packages_default
  • node_extra_packages -> node_packages
  • node_packages_meta -> node_packages_meta
  • node_meta_pip_install -> node_packages_meta_pip
  • node_sysctl_params -> node_tune_params
  • app_list -> nginx_indexes
  • grafana_plugin -> grafana_plugin_method
  • grafana_cache -> grafana_plugin_cache
  • grafana_plugins -> grafana_plugin_list
  • grafana_git_plugin_git -> grafana_plugin_git
  • haproxy_admin_auth_enabled -> haproxy_auth_enabled
  • pg_shared_libraries -> pg_libs
  • dcs_type -> pg_dcs_type

v1.5.1

亮点

重要:修复了PG14.0-14.3中 CREATE INDEX|REINDEX CONCURRENTLY 可能导致索引数据损坏的问题。

Pigsty v1.5.1 升级默认PostgreSQL版本至 14.4 强烈建议尽快更新。

软件升级

  • postgres 升级至 to 14.4
  • haproxy 升级至 to 2.6.0
  • grafana 升级至 to 9.0.0
  • prometheus 升级至 2.36.0
  • patroni 升级至 2.1.4

问题修复

  • 修复了pgsql-migration.yml中的TYPO
  • 移除了HAProxy配置文件中的PID配置项
  • 移除了默认软件包中的 i686 软件包
  • 默认启用所有Systemd Redis Service
  • 默认启用所有Systemd Patroni Service

API变更

  • grafana_databasegrafana_pgurl 被标记为过时API,将从后续版本移除

New Apps

  • wiki.js : 使用Postgres搭建本地维基百科
  • FerretDB : 使用Postgres提供MongoDB API

v1.4.0 发布注记

Add matrixDB Support

v1.4.0

Architecture

  • Decouple system into 4 major categories: INFRA, NODES, PGSQL, REDIS, which makes pigsty far more clear and more extensible.
  • Single Node Deployment = INFRA + NODES + PGSQL
  • Deploy pgsql clusters = NODES + PGSQL
  • Deploy redis clusters = NODES + REDIS
  • Deploy other databases = NODES + xxx (e.g MONGO, KAFKA, … TBD)

Accessibility

  • CDN for mainland China.
  • Get the latest source with bash -c "$(curl -fsSL http://download.pigsty.cc/get)"
  • Download & Extract packages with new download script.

Monitor Enhancement

  • Split monitoring system into 5 major categories: INFRA, NODES, REDIS, PGSQL, APP
  • Logging enabled by default
    • now loki and promtail are enabled by default. with prebuilt loki-rpm
  • Models & Labels
    • A hidden ds prometheus datasource variable is added for all dashboards, so you can easily switch different datasource simply by select a new one rather than modifying Grafana Datasources & Dashboards
    • An ip label is added for all metrics, and will be used as join key between database metrics & nodes metrics
  • INFRA Monitoring
    • Home dashboard for infra: INFRA Overview
    • Add logging Dashboards : Logs Instance
    • PGLOG Analysis & PGLOG Session now treated as an example Pigsty APP.
  • NODES Monitoring Application
    • If you don’t care database at all, Pigsty now can be used as host monitoring software alone!
    • Consist of 4 core dashboards: Nodes Overview & Nodes Cluster & Nodes Instance & Nodes Alert
    • Introduce new identity variables for nodes: node_cluster and nodename
    • Variable pg_hostname now means set hostname same as postgres instance name to keep backward-compatible
    • Variable nodename_overwrite control whether overwrite node’s hostname with nodename
    • Variable nodename_exchange will write nodename to each other’s /etc/hosts
    • All nodes metrics reference are overhauled, join by ip
    • Nodes monitoring targets are managed alone under /etc/prometheus/targets/nodes
  • PGSQL Monitoring Enhancement
    • Complete new PGSQL Cluster which simplify and focus on important stuff among cluster.
    • New Dashboard PGSQL Databases which is cluster level object monitoring. Such as tables & queries among the entire cluster rather than single instance.
    • PGSQL Alert dashboard now only focus on pgsql alerts.
    • PGSQL Shard are added to PGSQL
  • Redis Monitoring Enhancement
    • Add nodes monitoring for all redis dashboards.

MatrixDB Support

  • MatrixDB (Greenplum 7) can be deployed via pigsty-matrix.yml playbook
  • MatrixDB Monitor Dashboards : PGSQL MatrixDB
  • Example configuration added: pigsty-mxdb.yml

Provisioning Enhancement

Now pigsty work flow works as this:

 infra.yml ---> install pigsty on single meta node
      |          then add more nodes under pigsty's management
      |
 nodes.yml ---> prepare nodes for pigsty (node setup, dcs, node_exporter, promtail)
      |          then choose one playbook to deploy database clusters on those nodes
      |
      ^--> pgsql.yml   install postgres on prepared nodes
      ^--> redis.yml   install redis on prepared nodes

infra-demo.yml = 
           infra.yml -l meta     +
           nodes.yml -l pg-test  +
           pgsql.yml -l pg-test +
           infra-loki.yml + infra-jupyter.yml + infra-pgweb.yml
 
  • nodes.yml to setup & prepare nodes for pigsty
    • setup node, node_exporter, consul agent on nodes
    • node-remove.yml are used for node de-register
  • pgsql.yml now only works on prepared nodes
    • pgsql-remove now only responsible for postgres itself. (dcs and node monitor are taken by node.yml)
    • Add a series of new options to reuse postgres role in greenplum/matrixdb
  • redis.yml now works on prepared nodes
    • and redis-remove.yml now remove redis from nodes.
  • pgsql-matrix.yml now install matrixdb (Greenplum 7) on prepared nodes.

Software Upgrade

  • PostgreSQL 14.2
  • PostGIS 3.2
  • TimescaleDB 2.6
  • Patroni 2.1.3 (Prometheus Metrics + Failover Slots)
  • HAProxy 2.5.5 (Fix stats error, more metrics)
  • PG Exporter 0.4.1 (Timeout Parameters, and)
  • Grafana 8.4.4
  • Prometheus 2.33.4
  • Greenplum 6.19.4 / MatrixDB 4.4.0
  • Loki are now shipped as rpm packages instead of zip archives

Bug Fix

  • Remove consul dependency for patroni , which makes it much more easier to migrate to a new consul cluster
  • Fix prometheus bin/new scripts default data dir path : /export/prometheus to /data/prometheus
  • Fix typos and tasks
  • Add restart seconds to vip-manager systemd service

API Changes

New Variable

  • node_cluster: Identity variable for node cluster
  • nodename_overwrite: If set, nodename will be set to node’s hostname
  • nodename_exchange : exchange node hostname (in /etc/hosts) among play hosts
  • node_dns_hosts_extra : extra static dns records which can be easily overwritten by single instance/cluster
  • patroni_enabled: if disabled, postgres & patroni bootstrap will not be performed during role postgres
  • pgbouncer_enabled : if disabled, pgbouncer will not be launched during role postgres
  • pg_exporter_params: extra url parameters for pg_exporter when generating monitor target url.
  • pg_provision: bool var to indicate whether perform provision part of role postgres (template, db,user)
  • no_cmdb: cli args for infra.yml and infra-demo.yml playbook which will not create cmdb on meta node.
MD5 (app.tgz) = f887313767982b31a2b094e5589a75ea
MD5 (matrix.tgz) = 3d063437c482d94bd7e35df1a08bbc84
MD5 (pigsty.tgz) = e143b88ebea1474f9ebaffddc6072c49
MD5 (pkg.tgz) = 73e8f5ce995b1f1760cb63c1904fb91b

v1.4.1

Routine bug fix / Docker Support / English Docs

Now docker is enabled on meta node by default. You can launch ton’s of SaaS with it

English document is available now.

Bug Fix

v1.3.0 发布注记

v1.1.0 Release

1.3.0

  • [ENHANCEMENT] Redis Deployment (cluster,sentinel,standalone)

  • [ENHANCEMENT] Redis Monitor

    • Redis Overview Dashboard
    • Redis Cluster Dashboard
    • Redis Instance Dashboard
  • [ENHANCEMENT] monitor: PGCAT Overhaul

    • New Dashboard: PGCAT Instance
    • New Dashboard: PGCAT Database Dashboard
    • Remake Dashboard: PGCAT Table
  • [ENHANCEMENT] monitor: PGSQL Enhancement

    • New Panels: PGSQL Cluster, add 10 key metrics panel (toggled by default)
    • New Panels: PGSQL Instance, add 10 key metrics panel (toggled by default)
    • Simplify & Redesign: PGSQL Service
    • Add cross-references between PGCAT & PGSL dashboards
  • [ENHANCEMENT] monitor deploy

    • Now grafana datasource is automatically registered during monly deployment
  • [ENHANCEMENT] software upgrade

    • add PostgreSQL 13 to default package list
    • upgrade to PostgreSQL 14.1 by default
    • add greenplum rpm and dependencies
    • add redis rpm & source packages
    • add perf as default packages

v1.3.1

[Monitor]

  • PGSQL & PGCAT Dashboard polish
  • optimize layout for pgcat instance & pgcat database
  • add key metrics panels to pgsql instance dashboard, keep consist with pgsql cluster
  • add table/index bloat panels to pgcat database, remove pgcat bloat dashboard.
  • add index information in pgcat database dashboard
  • fix broken panels in grafana 8.3
  • add redis index in nginx homepage

[Deploy]

  • New infra-demo.yml playbook for one-pass bootstrap
  • Use infra-jupyter.yml playbook to deploy optional jupyter lab server
  • Use infra-pgweb.yml playbook to deploy optional pgweb server
  • New pg alias on meta node, can initiate postgres cluster from admin user (in addition to postgres)
  • Adjust all patroni conf templates’s max_locks_per_transactions according to timescaledb-tune ’s advise
  • Add citus.node_conninfo: 'sslmode=prefer' to conf templates in order to use citus without SSL
  • Add all extensions (except for pgrouting) in pgdg14 in package list
  • Upgrade node_exporter to v1.3.1
  • Add PostgREST v9.0.0 to package list. Generate API from postgres schema.

[BugFix]

  • Grafana’s security breach (upgrade to v8.3.1 issue)
  • fix pg_instance & pg_service in register role when start from middle of playbook
  • Fix nginx homepage render issue when host without pg_cluster variable exists
  • Fix style issue when upgrading to grafana 8.3.1

v1.2.0 发布注记

Redis Support, PGCAT Overhaul

v1.2.0

  • [ENHANCEMENT] Use PostgreSQL 14 as default version
  • [ENHANCEMENT] Use TimescaleDB 2.5 as default extension
    • now timescaledb & postgis are enabled in cmdb by default
  • [ENHANCEMENT] new monitor-only mode:
    • you can use pigsty to monitor existing pg instances with a connectable url only
    • pg_exporter will be deployed on meta node locally
    • new dashboard PGSQL Cluster Monly for remote clusters
  • [ENHANCEMENT] Software upgrade
    • grafana to 8.2.2
    • pev2 to v0.11.9
    • promscale to 0.6.2
    • pgweb to 0.11.9
    • Add new extensions: pglogical pg_stat_monitor orafce
  • [ENHANCEMENT] Automatic detect machine spec and use proper node_tune and pg_conf templates
  • [ENHANCEMENT] Rework on bloat related views, now more information are exposed
  • [ENHANCEMENT] Remove timescale & citus internal monitoring
  • [ENHANCEMENT] New playbook pgsql-audit.yml to create audit report.
  • [BUG FIX] now pgbouncer_exporter resource owner are {{ pg_dbsu }} instead of postgres
  • [BUG FIX] fix pg_exporter duplicate metrics on pg_table pg_index while executing REINDEX TABLE CONCURRENTLY
  • [CHANGE] now all config templates are minimize into two: auto & demo. (removed: pub4, pg14, demo4, tiny, oltp )
    • pigsty-demo is configured if vagrant is the default user, otherwise pigsty-auto is used.

How to upgrade from v1.1.1

There’s no API change in 1.2.0 You can still use old pigsty.yml configuration files (PG13).

For the infrastructure part. Re-execution of repo will do most of the parts

As for the database. You can still use the existing PG13 instances. In-place upgrade is quite tricky especially when involving extensions such as PostGIS & Timescale. I would highly recommend performing a database migration with logical replication.

The new playbook pgsql-migration.yml will make this a lot easier. It will create a series of scripts which will help you to migrate your cluster with near-zero downtime.

v1.1.0 发布注记

v1.1.0 Release

v1.1.0

  • [ENHANCEMENT] add pg_dummy_filesize to create fs space placeholder
  • [ENHANCEMENT] home page overhaul
  • [ENHANCEMENT] add jupyter lab integration
  • [ENHANCEMENT] add pgweb console integration
  • [ENHANCEMENT] add pgbadger support
  • [ENHANCEMENT] add pev2 support, explain visualizer
  • [ENHANCEMENT] add pglog utils
  • [ENHANCEMENT] update default pkg.tgz software version:
    • upgrade postgres to v13.4 (with official pg14 support)
    • upgrade pgbouncer to v1.16 (metrics definition updates)
    • upgrade grafana to v8.1.4
    • upgrade prometheus to v2.2.29
    • upgrade node_exporter to v1.2.2
    • upgrade haproxy to v2.1.1
    • upgrade consul to v1.10.2
    • upgrade vip-manager to v1.0.1

API Changes

  • nginx_upstream now holds different structures. (incompatible)

  • new config entries: app_list, render into home page’s nav entries

  • new config entries: docs_enabled, setup local docs on default server.

  • new config entries: pev2_enabled, setup local pev2 utils.

  • new config entries: pgbadger_enabled, create log summary/report dir

  • new config entries: jupyter_enabled, enable jupyter lab server on meta node

  • new config entries: jupyter_username, specify which user to run jupyter lab

  • new config entries: jupyter_password, specify jupyter lab default password

  • new config entries: pgweb_enabled, enable pgweb server on meta node

  • new config entries: pgweb_username, specify which user to run pgweb

  • rename internal flag repo_exist into repo_exists

  • now default value for repo_address is pigsty instead of yum.pigsty

  • now haproxy access point is http://pigsty instead of http://h.pigsty

v1.1.1

  • [ENHANCEMENT] replace timescaledb apache version with timescale version
  • [ENHANCEMENT] upgrade prometheus to 2.30
  • [BUG FIX] now pg_exporter config dir’s owner are {{ pg_dbsu }} instead of prometheus

How to upgrade from v1.1.0 The major change in this release is timescaledb. Which replace old apache license version with timescale license version

stop/pause postgres instance with timescaledb
yum remove -y timescaledb_13

[timescale_timescaledb]
name=timescale_timescaledb
baseurl=https://packagecloud.io/timescale/timescaledb/el/7/$basearch
repo_gpgcheck=0
gpgcheck=0
enabled=1

yum install timescaledb-2-postgresql13 

v1.0.0 发布注记

v1.0.0 Release

v1.0.0

Highlights

  • Monitoring System Overhaul

    • New Dashboards on Grafana 8.0
    • New metrics definition, with extra PG14 support
    • Simplified labeling system: static label set: (job, cls, ins)
    • New Alerting Rules & Derived Metrics
    • Monitoring multiple database at one time
    • Realtime log search & csvlog analysis
    • Link-Rich Dashboards, click graphic elements to drill-down|roll-up
  • Architecture Changes

    • Add citus & timescaledb as part of default installation
    • Add PostgreSQL 14beta2 support
    • Simply haproxy admin page index
    • Decouple infra & pgsql by adding a new role register
    • Add new role loki and promtail for logging
    • Add new role environ for setting up environment for admin user on admin node
    • Using static service-discovery for prometheus by default (instead of consul)
    • Add new role remove to gracefully remove cluster & instance
    • Upgrade prometheus & grafana provisioning logics.
    • Upgrade to vip-manager 1.0 , node_exporter 1.2 , pg_exporter 0.4, grafana 8.0
    • Now every database on every instance can be auto-registered as grafana datasource
    • Move consul register tasks to role register, change consul service tags
    • Add cmdb.sql as pg-meta baseline definition (CMDB & PGLOG)
  • Application Framework

    • Extensible framework for new functionalities
    • core app: PostgreSQL Monitor System: pgsql
    • core app: PostgreSQL Catalog explorer: pgcat
    • core app: PostgreSQL Csvlog Analyzer: pglog
    • add example app covid for visualizing covid-19 data.
    • add example app isd for visualizing isd data.
  • Misc

    • Add jupyterlab which brings entire python environment for data science
    • Add vonng-echarts-panel to bring Echarts support back.
    • Add wrap script createpg , createdb, createuser
    • Add cmdb dynamic inventory scripts: load_conf.py, inventory_cmdb, inventory_conf
    • Remove obsolete playbooks: pgsql-monitor, pgsql-service, node-remove, etc….

API Change

Bug Fix

  • Fix default timezone Asia/Shanghai (CST) issue
  • Fix nofile limit for pgbouncer & patroni
  • Pgbouncer userlist & database list will be generated when executing tag pgbouncer

v1.0.1

2021-09-14

  • Documentation Update
    • Chinese document now viable
    • Machine-Translated English document now viable
  • Bug Fix: pgsql-remove does not remove primary instance.
  • Bug Fix: replace pg_instance with pg_cluster + pg_seq
    • Start-At-Task may fail due to pg_instance undefined
  • Bug Fix: remove citus from default shared preload library
    • citus will force max_prepared_transaction to non-zero value
  • Bug Fix: ssh sudo checking in configure:
    • now ssh -t sudo -n ls is used for privilege checking
  • Typo Fix: pg-backup script typo
  • Alert Adjust: Remove ntp sanity check alert (dupe with ClockSkew)
  • Exporter Adjust: remove collector.systemd to reduce overhead

v0.9.0 发布注记

v0.9极大简化了安装流程,进行了大量日志相关改进,开发了命令行工具(Beta),并修复了一系列问题。

新功能

  • 一键安装模式:

    /bin/bash -c "$(curl -fsSL https://pigsty.cc/install)"
    
  • 开发命令行工具 pigsty-cli封装常用Ansible命令,目前pigsty-cli处于Beta状态

  • 使用Loki与Promtail收集日志:

    • 默认收集Postgres,Pgbouncer,Patroni日志
    • 新增部署脚本infra-loki.ymlpgsql-promtail.yml
    • 定义基于日志的监控指标
    • 使用Grafana制作日志相关可视化面板。
  • 监控组件可以使用二进制安装,使用files/get_bin.sh下载监控二进制组件。

  • 飞升模式:

    当集群元节点初始化完成后,可以使用bin/upgrade升级为动态Inventory

    使用pg-meta上的数据库代替YAML配置文件。

问题修复

  • 集中修复日志相关问题:

    • 修复了HAProxy健康检查造成PG日志中大量 connection reset by peer的问题。
    • 修复了HAProxy健康检查造成Patroni日志中大量出现Connect Reset Exception的问题
    • 修复了Patroni日志时间戳格式,去除毫秒时间戳,附加完整时区信息。
    • dbuser_monitor配置1秒的log_min_duration_statement,避免监控查询出现在日志中。
  • 重构Grafana角色

    • 在保持API不变的前提下重构Grafana角色。
    • 使用CDN下载预打包的Grafana插件,加速插件下载
  • 其他问题修复

    • 修复了pgbouncer-create-user 未能正确处理 md5 密码的问题。

    • 完善了数据库与用户创建SQL模版中参数空置检查。

    • 修复了 NODE DNS配置时如果手工中断执行,DNS配置可能出错的问题。

    • 重构了Makefile快捷方式 Makefile 中的错别字

参数变更

  • node_disable_swap 默认为 False,默认不会关闭SWAP。
  • node_sysctl_params 不再有默认修改的系统参数。
  • grafana_plugin 的默认值install 现在意味着当插件缓存不存在时,从CDN下载。
  • repo_url_packages 现在从 Pigsty CDN 下载额外的RPM包,解决墙内无法访问的问题。
  • proxy_env.no_proxy现在将Pigsty CDN加入到NOPROXY列表中。
  • grafana_customize 现在默认为false,启用意味着安装Pigsty Pro版UI(默认不开源所以不要启用)
  • node_admin_pk_current,新增选项,启用后会将当前用户的~/.ssh/id_rsa.pub添加至管理员的Key中
  • loki_clean:新增选项,安装Loki时是否清除现有数据
  • loki_data_dir:新增选项,指明安装Loki时的数据目录
  • promtail_enabled 是否启用Promtail日志收集服务?
  • promtail_clean 是否在安装promtail时移除已有状态信息?
  • promtail_port promtail使用的默认端口,默认为9080
  • promtail_status_file 保存Promtail状态信息的文件位置
  • promtail_send_url 用于接收日志的loki服务endpoint

v0.8.0 发布注记

v0.8 重做了服务供给部分,提供了集成外部负载均衡器的扩展接口。

v0.8 针对**服务(Service)**接入部分进行了彻底的重做。现在除了默认的primary, replica服务外,用户可以自行定义新的服务。服务的接口可以支持多种不同的实现,例如L4 DPKG VIP可作为Haproxy的替代品与Pigsty集成。同时,针对用户反馈的一些问题进行了集中处理与改进。

改动内容

v0.8是供给方案定稿版本,此后供给系统的API将保持稳定。

API变更

原有viphaproxy角色的所有配置项,现在迁移至service角色中。

#------------------------------------------------------------------------------
# SERVICE PROVISION
#------------------------------------------------------------------------------
pg_weight: 100              # default load balance weight (instance level)

# - service - #
pg_services:                                  # how to expose postgres service in cluster?
  # primary service will route {ip|name}:5433 to primary pgbouncer (5433->6432 rw)
  - name: primary           # service name {{ pg_cluster }}_primary
    src_ip: "*"
    src_port: 5433
    dst_port: pgbouncer     # 5433 route to pgbouncer
    check_url: /primary     # primary health check, success when instance is primary
    selector: "[]"          # select all instance as primary service candidate

  # replica service will route {ip|name}:5434 to replica pgbouncer (5434->6432 ro)
  - name: replica           # service name {{ pg_cluster }}_replica
    src_ip: "*"
    src_port: 5434
    dst_port: pgbouncer
    check_url: /read-only   # read-only health check. (including primary)
    selector: "[]"          # select all instance as replica service candidate
    selector_backup: "[? pg_role == `primary`]"   # primary are used as backup server in replica service

  # default service will route {ip|name}:5436 to primary postgres (5436->5432 primary)
  - name: default           # service's actual name is {{ pg_cluster }}-{{ service.name }}
    src_ip: "*"             # service bind ip address, * for all, vip for cluster virtual ip address
    src_port: 5436          # bind port, mandatory
    dst_port: postgres      # target port: postgres|pgbouncer|port_number , pgbouncer(6432) by default
    check_method: http      # health check method: only http is available for now
    check_port: patroni     # health check port:  patroni|pg_exporter|port_number , patroni by default
    check_url: /primary     # health check url path, / as default
    check_code: 200         # health check http code, 200 as default
    selector: "[]"          # instance selector
    haproxy:                # haproxy specific fields
      maxconn: 3000         # default front-end connection
      balance: roundrobin   # load balance algorithm (roundrobin by default)
      default_server_options: 'inter 3s fastinter 1s downinter 5s rise 3 fall 3 on-marked-down shutdown-sessions slowstart 30s maxconn 3000 maxqueue 128 weight 100'

  # offline service will route {ip|name}:5438 to offline postgres (5438->5432 offline)
  - name: offline           # service name {{ pg_cluster }}_replica
    src_ip: "*"
    src_port: 5438
    dst_port: postgres
    check_url: /replica     # offline MUST be a replica
    selector: "[? pg_role == `offline` || pg_offline_query ]"         # instances with pg_role == 'offline' or instance marked with 'pg_offline_query == true'
    selector_backup: "[? pg_role == `replica` && !pg_offline_query]"  # replica are used as backup server in offline service

pg_services_extra: []        # extra services to be added

# - haproxy - #
haproxy_enabled: true                         # enable haproxy among every cluster members
haproxy_reload: true                          # reload haproxy after config
haproxy_policy: roundrobin                    # roundrobin, leastconn
haproxy_admin_auth_enabled: false             # enable authentication for haproxy admin?
haproxy_admin_username: admin                 # default haproxy admin username
haproxy_admin_password: admin                 # default haproxy admin password
haproxy_exporter_port: 9101                   # default admin/exporter port
haproxy_client_timeout: 3h                    # client side connection timeout
haproxy_server_timeout: 3h                    # server side connection timeout

# - vip - #
vip_mode: none                                # none | l2 | l4
vip_reload: true                              # whether reload service after config
# vip_address: 127.0.0.1                      # virtual ip address ip (l2 or l4)
# vip_cidrmask: 24                            # virtual ip address cidr mask (l2 only)
# vip_interface: eth0                         # virtual ip network interface (l2 only)

新增选项

# - localization - #
pg_encoding: UTF8                             # default to UTF8
pg_locale: C                                  # default to C
pg_lc_collate: C                              # default to C
pg_lc_ctype: en_US.UTF8                       # default to en_US.UTF8

pg_reload: true                               # reload postgres after hba changes
vip_mode: none                                # none | l2 | l4
vip_reload: true                              # whether reload service after config

移除选项

haproxy_check_port                            # Haproxy相关参数已经被Service定义覆盖
haproxy_primary_port
haproxy_replica_port
haproxy_backend_port
haproxy_weight
haproxy_weight_fallback
vip_enabled                                   # vip_enabled参数被vip_mode覆盖

服务管理

pg_servicespg_services_extra 定义了集群中的服务,每一个服务的定义结构如下例所示:

一个服务必须指定以下内容:

  • 名称:服务的完整名称以数据库集群名为前缀,以service.name为后缀,通过-连接。例如在pg-test集群中name=primary的服务,其完整服务名称为pg-test-primary

  • 端口:在Pigsty中,服务默认采用NodePort的形式对外暴露,因此暴露端口为必选项。但如果使用外部负载均衡服务接入方案,您也可以通过其他的方式区分服务。

  • 选择器:选择器指定了服务的成员,采用JMESPath的形式,从所有集群实例成员中筛选变量。默认的[]选择器会选取所有的集群成员。

    此外selector_backup会选择或标记用于backup的实例列表(当集群中所有其他成员失效时方才接管服务)

  # default service will route {ip|name}:5436 to primary postgres (5436->5432 primary)
  - name: default           # service's actual name is {{ pg_cluster }}-{{ service.name }}
    src_ip: "*"             # service bind ip address, * for all, vip for cluster virtual ip address
    src_port: 5436          # bind port, mandatory
    dst_port: postgres      # target port: postgres|pgbouncer|port_number , pgbouncer(6432) by default
    check_method: http      # health check method: only http is available for now
    check_port: patroni     # health check port:  patroni|pg_exporter|port_number , patroni by default
    check_url: /primary     # health check url path, / as default
    check_code: 200         # health check http code, 200 as default
    selector: "[]"          # instance selector
    haproxy:                # haproxy specific fields
      maxconn: 3000         # default front-end connection
      balance: roundrobin   # load balance algorithm (roundrobin by default)
      default_server_options: 'inter 3s fastinter 1s downinter 5s rise 3 fall 3 on-marked-down shutdown-sessions slowstart 30s maxconn 3000 maxqueue 128 weight 100'

数据库管理

数据库现在可以对locale的细分选项:lc_ctypelc_collate分别进行指定。支持这一功能的主要原因是PG的扩展插件pg_trgm需要在lc_ctype!=C的环境中才能正常支持中文。

旧接口定义

pg_databases:
  - name: meta                      # name is the only required field for a database
    owner: postgres                 # optional, database owner
    template: template1             # optional, template1 by default
    encoding: UTF8                  # optional, UTF8 by default
    locale: C                       # optional, C by default
    allowconn: true                 # optional, true by default, false disable connect at all
    revokeconn: false               # optional, false by default, true revoke connect from public # (only default user and owner have connect privilege on database)
    tablespace: pg_default          # optional, 'pg_default' is the default tablespace
    connlimit: -1                   # optional, connection limit, -1 or none disable limit (default)
    extensions:                     # optional, extension name and where to create
      - {name: postgis, schema: public}
    parameters:                     # optional, extra parameters with ALTER DATABASE
      enable_partitionwise_join: true
    pgbouncer: true                 # optional, add this database to pgbouncer list? true by default
    comment: pigsty meta database   # optional, comment string for database

新的接口定义

pg_databases:
  - name: meta                      # name is the only required field for a database
    # owner: postgres                 # optional, database owner
    # template: template1             # optional, template1 by default
    # encoding: UTF8                # optional, UTF8 by default , must same as template database, leave blank to set to db default
    # locale: C                     # optional, C by default , must same as template database, leave blank to set to db default
    # lc_collate: C                 # optional, C by default , must same as template database, leave blank to set to db default
    # lc_ctype: C                   # optional, C by default , must same as template database, leave blank to set to db default
    allowconn: true                 # optional, true by default, false disable connect at all
    revokeconn: false               # optional, false by default, true revoke connect from public # (only default user and owner have connect privilege on database)
    # tablespace: pg_default          # optional, 'pg_default' is the default tablespace
    connlimit: -1                   # optional, connection limit, -1 or none disable limit (default)
    extensions:                     # optional, extension name and where to create
      - {name: postgis, schema: public}
    parameters:                     # optional, extra parameters with ALTER DATABASE
      enable_partitionwise_join: true
    pgbouncer: true                 # optional, add this database to pgbouncer list? true by default
    comment: pigsty meta database   # optional, comment string for database

v0.7.0 发布注记

v0.7 新增了仅监控部署模式,改进了数据库与用户的供给接口。

v0.7 针对接入已有数据库实例进行了改进,现在用户可以采用 仅监控部署(Monly Deployment) 模式使用Pigsty。同时新增了专用于管理数据库与用户、以及单独部署监控的剧本,并对数据库与用户的定义进行改进。

改动内容

Features

Bug Fix

API变更

新增选项

prometheus_sd_target: batch                   # batch|single    监控目标定义文件采用单体还是每个实例一个
exporter_install: none                        # none|yum|binary 监控Exporter的安装模式
exporter_repo_url: ''                         # 如果设置,这里的REPO连接会加入目标的Yum源中
node_exporter_options: '--no-collector.softnet --collector.systemd --collector.ntp --collector.tcpstat --collector.processes'                          # Node Exporter默认的命令行选项
pg_exporter_url: ''                           # 可选,PG Exporter监控对象的URL
pgbouncer_exporter_url: ''                    # 可选,PGBOUNCER EXPORTER监控对象的URL

移除选项

exporter_binary_install: false                 # 功能被 exporter_install 覆盖

定义结构变更

pg_default_roles                               # 变化细节参考 用户管理。
pg_users                                       # 变化细节参考 用户管理。
pg_databases                                   # 变化细节参考 数据库管理。

重命名选项

pg_default_privilegs -> pg_default_privileges # 很明显这是一个错别字

仅监控模式

有时用户不希望使用Pigsty供给方案,只希望使用Pigsty监控系统管理现有PostgreSQL实例。

Pigsty提供了 仅监控部署(monly, monitor-only) 模式,剥离供给方案部分,可用于监控现有PostgreSQL集群。

仅监控模式的部署流程与标准模式大体上保持一致,但省略了很多步骤

  • 元节点上完成基础设施初始化的部分与标准流程保持一致,仍然通过./infra.yml完成。
  • 不需要在数据库节点上完成 基础设施初始化
  • 不需要在数据库节点上执行数据库初始化的绝大多数任务,而是通过专用的./pgsql-monitor.yml 完成仅监控系统部署。
  • 实际使用的配置项大大减少,只保留基础设施相关变量,与 监控系统 相关的少量变量。

数据库管理

Database provisioning interface enhancement #33

旧接口定义

pg_databases:                       # create a business database 'meta'
  - name: meta
    schemas: [meta]                 # create extra schema named 'meta'
    extensions: [{name: postgis}]   # create extra extension postgis
    parameters:                     # overwrite database meta's default search_path
      search_path: public, monitor

新的接口定义

pg_databases:
  - name: meta                      # name is the only required field for a database
    owner: postgres                 # optional, database owner
    template: template1             # optional, template1 by default
    encoding: UTF8                  # optional, UTF8 by default
    locale: C                       # optional, C by default
    allowconn: true                 # optional, true by default, false disable connect at all
    revokeconn: false               # optional, false by default, true revoke connect from public # (only default user and owner have connect privilege on database)
    tablespace: pg_default          # optional, 'pg_default' is the default tablespace
    connlimit: -1                   # optional, connection limit, -1 or none disable limit (default)
    extensions:                     # optional, extension name and where to create
      - {name: postgis, schema: public}
    parameters:                     # optional, extra parameters with ALTER DATABASE
      enable_partitionwise_join: true
    pgbouncer: true                 # optional, add this database to pgbouncer list? true by default
    comment: pigsty meta database   # optional, comment string for database

接口变更

  • Add new options: template , encoding, locale, allowconn, tablespace, connlimit
  • Add new option revokeconn, which revoke connect privileges from public for this database
  • Add comment field for database

数据库变更

在运行中集群中创建新数据库可以使用pgsql-createdb.yml剧本,在配置中定义完新数据库后,执行以下剧本。

./pgsql-createdb.yml -e pg_database=<your_new_database_name>

通过-e pg_datbase=告知需要创建的数据库名称,则该数据库即会被创建(或修改)。具体执行的命令参见集群主库/pg/tmp/pg-db-{{ database.name}}.sql文件。

用户管理

User provisioning interface enhancement #34

旧接口定义

pg_users:
  - username: test                  # example production user have read-write access
    password: test                  # example user's password
    options: LOGIN                  # extra options
    groups: [ dbrole_readwrite ]    # dborole_admin|dbrole_readwrite|dbrole_readonly
    comment: default test user for production usage
    pgbouncer: true                 # add to pgbouncer

新接口定义

pg_users:
  # complete example of user/role definition for production user
  - name: dbuser_meta               # example production user have read-write access
    password: DBUser.Meta           # example user's password, can be encrypted
    login: true                     # can login, true by default (should be false for role)
    superuser: false                # is superuser? false by default
    createdb: false                 # can create database? false by default
    createrole: false               # can create role? false by default
    inherit: true                   # can this role use inherited privileges?
    replication: false              # can this role do replication? false by default
    bypassrls: false                # can this role bypass row level security? false by default
    connlimit: -1                   # connection limit, -1 disable limit
    expire_at: '2030-12-31'         # 'timestamp' when this role is expired
    expire_in: 365                  # now + n days when this role is expired (OVERWRITE expire_at)
    roles: [dbrole_readwrite]       # dborole_admin|dbrole_readwrite|dbrole_readonly
    pgbouncer: true                 # add this user to pgbouncer? false by default (true for production user)
    parameters:                     # user's default search path
      search_path: public
    comment: test user

接口变更

  • username field rename to name

  • groups field rename to roles

  • options now split into separated configration entries:

    login, superuser, createdb, createrole, inherit, replication,bypassrls,connlimit

  • expire_at and expire_in options

  • pgbouncer option for user is now false by default

用户管理

在运行中集群中创建新数据库可以使用pgsql-createuser.yml剧本,在配置中定义完新数据库后,执行以下剧本。

./pgsql-createuser.yml -e pg_user=<your_new_user_name>

通过-e pg_user=告知需要创建的数据库名称,则该数据库即会被创建(或修改)。具体执行的命令参见集群主库/pg/tmp/pg-user-{{ user.name}}.sql文件。

v0.6.0 发布注记

v0.6 对数据库供给方案进行了大量改进

v0.6 对数据库供给方案进行了修改与调整,根据用户的反馈添加了一系列实用功能与修正。针对监控系统的移植性进行优化,便于与其他外部数据库供给方案对接,例如阿里云MyBase。

BUG修复

  • 修复了新版本Patroni重启后会重置PG HBA的问题
  • 修复了PG Overview Dashboard标题中的别字
  • 修复了沙箱集群pg-test的默认主库,原来为pg-test-2,应当为pg-test-1
  • 修复了过时代码注释

功能改进

  • 改造Prometheus与监控供给方式
    • 允许在无基础设施的情况下对已有PG集群进行监控部署,便于监控系统与其他供给方案集成。#11
    • 基于Inventory渲染所有监控对象的静态列表,用于静态服务发现。#11
    • Prometheus添加了静态对象模式,用于替代动态服务发现,集中进行身份管理#11
    • 监控Exporter现在添加了service_registry选项,Consul服务注册变为可选项 #13
    • Exporter现在可以通过拷贝二进制的方式直接安装:exporter_binary_install#14
    • Exporter现在具有xxx_enabled选项,控制是否启用该组件。
  • Haproxy供给重构与改进 #8
    • 新增了全局HAProxy管理界面导航,默认域名h.pigsty
    • 允许将主库加入只读服务集中,当集群中所有从库宕机时自动承接读流量。 #8
    • 允许位Haproxy实例管理界面启用认证 haproxy_admin_auth_enabled
    • 允许通过配置项调整每个服务对应后端的流量权重. #10
  • 访问控制模型改进。#7
    • 添加了默认角色dbrole_offline,用于慢查询,ETL,交互式查询场景。
    • 修改默认HBA规则,允许dbrole_offline分组的用户访问pg_role == 'offline'pg_offline_query == true的实例。
  • 软件更新 Release v0.6
    • PostgreSQL 13.2
    • Prometheus 2.25
    • PG Exporter 0.3.2
    • Node Exporter 1.1
    • Consul 1.9.3
    • 更新默认PG源:PostgreSQL现在默认使用浙江大学的镜像,加速下载安装

接口变更

新增选项

service_registry: consul                      # 服务注册机制:none | consul | etcd | both
prometheus_options: '--storage.tsdb.retention=30d'  # prometheus命令行选项
prometheus_sd_method: consul                  # Prometheus使用的服务发现机制:static|consul
prometheus_sd_interval: 2s                    # Prometheus服务发现刷新间隔
pg_offline_query: false                       # 设置后将允许dbrole_offline角色连接与查询该实例
node_exporter_enabled: true                   # 设置后将安装配置Node Exporter
pg_exporter_enabled: true                     # 设置后将安装配置PG Exporter
pgbouncer_exporter_enabled: true              # 设置后将安装配置Pgbouncer Exporter
dcs_disable_purge: false                      # 双保险,强制 dcs_exists_action = abort 避免误删除DCS实例
pg_disable_purge: false                       # 双保险,强制 pg_exists_action = abort 避免误删除数据库实例
haproxy_weight: 100                           # 配置实例的相对负载均衡权重
haproxy_weight_fallback: 1                    # 配置集群主库在只读服务中的相对权重

移除选项

prometheus_metrics_path                       # 与 exporter_metrics_path 重复
prometheus_retention                          # 功能被 prometheus_options 覆盖

v0.5.0 发布注记

v0.5.0对数据库内部的定制模板进行了大幅改进。

大纲

  • Pigsty官方文档站正式上线!
  • 添加了数据库模板的定制支持,用户可以通过配置文件定制所需的数据库内部对象。
  • 对默认访问控制模型进行了改进
  • 重构了HBA管理的逻辑,现在将由Pigsty替代Patroni直接负责生成HBA
  • 将Grafana监控系统的供给方案从sqlite改为JSON文件静态Provision
  • pg-cluster-replication面板加入Pigsty开源免费套餐。
  • 最新的经过测试的离线安装包:pkg.tgz (v0.5)

定制数据库

您是否烦恼过单实例多租户的问题?比如总有研发拿着PostgreSQL当MySQL使,明明是一个Schema就能解决的问题,非要创建一个新的数据库出来,在一个实例中创建出几十个不同的DB。 不要忧伤,不要心急。Pigsty已经提供数据库内部对象的Provision方案,您可以轻松地在配置文件中指定所需的数据库内对象,包括:

  • 角色
    • 用户/角色名
    • 密码
    • 用户属性
    • 用户备注
    • 用户所属的权限组
  • 数据库
    • 属主
    • 额外的模式
    • 额外的扩展插件
    • 数据库级的自定义配置参数
  • 数据库
    • 属主
    • 额外的模式
    • 额外的扩展插件
    • 数据库级的自定义配置参数
  • 默认权限
    • 默认情况下这里配置的权限会应用至所有由 超级用户 和 管理员用户创建的对象上。
  • 默认扩展
    • 所有新创建的业务数据库都会安装有这些默认扩展
  • 默认模式
    • 所有新创建的业务数据库都会创建有这些默认的模式

配置样例

# 通常是每个DB集群配置的变量
pg_users:
  - username: test
    password: test
    comment: default test user
    groups: [ dbrole_readwrite ]    # dborole_admin|dbrole_readwrite|dbrole_readonly
pg_databases:                       # create a business database 'test'
  - name: test
    extensions: [{name: postgis}]   # create extra extension postgis
    parameters:                     # overwrite database meta's default search_path
      search_path: public,monitor

# 通常是整个环境统一配置的全局变量
# - system roles - #
pg_replication_username: replicator           # system replication user
pg_replication_password: DBUser.Replicator    # system replication password
pg_monitor_username: dbuser_monitor           # system monitor user
pg_monitor_password: DBUser.Monitor           # system monitor password
pg_admin_username: dbuser_admin               # system admin user
pg_admin_password: DBUser.Admin               # system admin password

# - default roles - #
pg_default_roles:
  - username: dbrole_readonly                 # sample user:
    options: NOLOGIN                          # role can not login
    comment: role for readonly access         # comment string

  - username: dbrole_readwrite                # sample user: one object for each user
    options: NOLOGIN
    comment: role for read-write access
    groups: [ dbrole_readonly ]               # read-write includes read-only access

  - username: dbrole_admin                    # sample user: one object for each user
    options: NOLOGIN BYPASSRLS                # admin can bypass row level security
    comment: role for object creation
    groups: [dbrole_readwrite,pg_monitor,pg_signal_backend]

  # NOTE: replicator, monitor, admin password are overwritten by separated config entry
  - username: postgres                        # reset dbsu password to NULL (if dbsu is not postgres)
    options: SUPERUSER LOGIN
    comment: system superuser

  - username: replicator
    options: REPLICATION LOGIN
    groups: [pg_monitor, dbrole_readonly]
    comment: system replicator

  - username: dbuser_monitor
    options: LOGIN CONNECTION LIMIT 10
    comment: system monitor user
    groups: [pg_monitor, dbrole_readonly]

  - username: dbuser_admin
    options: LOGIN BYPASSRLS
    comment: system admin user
    groups: [dbrole_admin]

  - username: dbuser_stats
    password: DBUser.Stats
    options: LOGIN
    comment: business read-only user for statistics
    groups: [dbrole_readonly]


# object created by dbsu and admin will have their privileges properly set
pg_default_privilegs:
  - GRANT USAGE                         ON SCHEMAS   TO dbrole_readonly
  - GRANT SELECT                        ON TABLES    TO dbrole_readonly
  - GRANT SELECT                        ON SEQUENCES TO dbrole_readonly
  - GRANT EXECUTE                       ON FUNCTIONS TO dbrole_readonly
  - GRANT INSERT, UPDATE, DELETE        ON TABLES    TO dbrole_readwrite
  - GRANT USAGE,  UPDATE                ON SEQUENCES TO dbrole_readwrite
  - GRANT TRUNCATE, REFERENCES, TRIGGER ON TABLES    TO dbrole_admin
  - GRANT CREATE                        ON SCHEMAS   TO dbrole_admin
  - GRANT USAGE                         ON TYPES     TO dbrole_admin

# schemas
pg_default_schemas: [monitor]

# extension
pg_default_extensions:
  - { name: 'pg_stat_statements',  schema: 'monitor' }
  - { name: 'pgstattuple',         schema: 'monitor' }
  - { name: 'pg_qualstats',        schema: 'monitor' }
  - { name: 'pg_buffercache',      schema: 'monitor' }
  - { name: 'pageinspect',         schema: 'monitor' }
  - { name: 'pg_prewarm',          schema: 'monitor' }
  - { name: 'pg_visibility',       schema: 'monitor' }
  - { name: 'pg_freespacemap',     schema: 'monitor' }
  - { name: 'pg_repack',           schema: 'monitor' }
  - name: postgres_fdw
  - name: file_fdw
  - name: btree_gist
  - name: btree_gin
  - name: pg_trgm
  - name: intagg
  - name: intarray

# postgres host-based authentication rules
pg_hba_rules:
  - title: allow meta node password access
    role: common
    rules:
      - host    all     all                         10.10.10.10/32      md5

  - title: allow intranet admin password access
    role: common
    rules:
      - host    all     +dbrole_admin               10.0.0.0/8          md5
      - host    all     +dbrole_admin               172.16.0.0/12       md5
      - host    all     +dbrole_admin               192.168.0.0/16      md5

  - title: allow intranet password access
    role: common
    rules:
      - host    all             all                 10.0.0.0/8          md5
      - host    all             all                 172.16.0.0/12       md5
      - host    all             all                 192.168.0.0/16      md5

  - title: allow local read-write access (local production user via pgbouncer)
    role: common
    rules:
      - local   all     +dbrole_readwrite                               md5
      - host    all     +dbrole_readwrite           127.0.0.1/32        md5

  - title: allow read-only user (stats, personal) password directly access
    role: replica
    rules:
      - local   all     +dbrole_readonly                               md5
      - host    all     +dbrole_readonly           127.0.0.1/32        md5
pg_hba_rules_extra: []

# pgbouncer host-based authentication rules
pgbouncer_hba_rules:
  - title: local password access
    role: common
    rules:
      - local  all          all                                     md5
      - host   all          all                     127.0.0.1/32    md5

  - title: intranet password access
    role: common
    rules:
      - host   all          all                     10.0.0.0/8      md5
      - host   all          all                     172.16.0.0/12   md5
      - host   all          all                     192.168.0.0/16  md5
pgbouncer_hba_rules_extra: []

数据库模板

权限模型

v0.5 改善了默认的权限模型,主要是针对单实例多租户的场景进行优化,并收紧权限控制。

  • 撤回了普通业务用户对非所属数据库的默认CONNECT权限
  • 撤回了非管理员用户对所属数据库的默认CREATE权限
  • 撤回了所有用户在public模式下的默认创建权限。

供给方式

原先Pigsty采用直接拷贝Grafana自带的grafana.db的方式完成监控系统的初始化。 这种方式虽然简单粗暴管用,但不适合进行精细化的版本控制管理。在v0.5中,Pigsty采用了Grafana API完成了监控系统面板供给的工作。 您所需的就是在grafana_url中填入带有用户名密码的Grafana URL。 因此,监控系统可以背方便地添加至已有的Grafana中。

v0.4.0 发布注记

第二个公开测试版v0.4现已正式发行

第二个公开测试版v0.4现已正式发行

Pigsty v0.4对监控系统进行了整体升级改造,精心挑选了10个面板作为标准的Pigsty开源内容。同时,针对Grafana 7.3的不兼容升级进行了大量适配改造工作。使用升级的pg_exporter v0.3.1作为默认指标导出器,调整了监控报警规则的监控面板连接。

Pigsty开源版

Pigsty开源版选定了以下10个Dashboard作为开源内容。其他Dashboard作为可选的商业支持内容提供。

  • PG Overview
  • PG Cluster
  • PG Service
  • PG Instance
  • PG Database
  • PG Query
  • PG Table
  • PG Table Catalog
  • PG Table Detail
  • Node

尽管进行了少量阉割,这10个监控面板所涵盖的内容仍然可以吊打所有同类软件。

软件升级

Pigsty v0.4进行了大量软件适配工作,包括:

  • Upgrade to PostgreSQL 13.1, Patroni 2.0.1-4, add citus to repo.
  • Upgrade to pg_exporter 0.3.1
  • Upgrade to Grafana 7.3, Ton’s of compatibility work
  • Upgrade to prometheus 2.23, with new UI as default
  • Upgrade to consul 1.9

其他改进

  • Update prometheus alert rules
  • Fix alertmanager info links
  • Fix bugs and typos.
  • add a simple backup script

离线安装包

  • v0.4的离线安装包(CentOS 7.8)已经可以从Github下载:pkg.tgz

v0.3.0 发布注记

v0.3.0 第一个公开的试用版本现已释出!

首个Pigsty公开测试版本现在已经释出!

监控系统

Pigsty v0.3 包含以下8个监控面板作为开源内容:

  • PG Overview
  • PG Cluster
  • PG Service
  • PG Instance
  • PG Database
  • PG Table Overview
  • PG Table Catalog
  • Node

离线安装包

  • v0.3 离线安装包(CentOS 7.8)已经可以从Github下载:pkg.tgz

文章

关于PostgreSQL的文章

为什么PostgreSQL是最成功的数据库?

一句"PHP是最好"的编程语言,能让一群程序员吵起来;那么问题来了,最好的数据库是哪个?

当我们说一个数据库"成功"时,到底在说什么?是指功能性能易用性,还是成本生态复杂度?评价指标有很多,但这件事最终还得由用户来定夺。

数据库的用户是开发者,而开发者的意愿、喜好、选择又如何 ?StackOverflow 连续六年,向来自180个国家的七万多开发者问了这三个问题。

总览这六年的调研结果,不难看出在2022年,PostgreSQL 已经同时在这三项上登顶夺冠,成了字面意义上 “最成功的数据库”:

  • PostgreSQL 成为 专业开发者最常使用的数据库!(Used)
  • PostgreSQL 成为 开发者最为喜爱的数据库!(Loved)
  • PostgreSQL 成为开发者最想要用的数据库!(Wanted)

流行度反映当年势能,需求度预示来年动能,喜爱度代表长期潜能。

时与势都站在 PostgreSQL 一侧,让我们来看一看更具体的数据与结果。

最流行

PostgreSQL —— 专业开发者中最流行的数据库!(Used)

第一项调研,是关于开发者目前使用着什么样的数据库,即,流行度

过去几年,MySQL一直霸占着数据库流行榜的榜首,很符合其 ”世界上最流行的开源关系型数据库“ 这一口号。不过这一次,”最流行“的桂冠恐怕要让给 PostgreSQL 了。

专业开发者中,PostgreSQL 以 46.5% 的使用率第一次超过 MySQL 位居第一,而 MySQL 以 45.7% 的使用率降至第二名。 同为泛用性最好的开源关系型数据库,排名第一第二的 PGSQL 与 MySQL ,与其他的数据库远远拉开了距离。

TOP 9 数据库流行度演变(2017-2022)

PGSQL 与 MySQL 的流行度差别并不大。值得一提的是,在见习开发者群体中,MySQL 仍然占据显著使用率优势(58.4%),如果算上见习开发者,MySQL 甚至仍然保有 3.3% 的微弱整体领先优势。

但从下图中不难看出,PostgreSQL 有显著的增长动能,而其他数据库,特别是 MySQL、 SQL Server、Oracle 的使用率则在最近几年持续衰退。随着时间的推移,PostgreSQL 的领先优势将进一步拉大。

四大关系型数据库流行度对比

流行度反映的是当下数据库的规模势能,而喜爱度反映的是未来数据库的增长潜能。

最喜爱

PostgreSQL —— 开发者最为喜爱的数据库!(Loved)

第二个问题是关于开发者喜爱什么数据库,讨厌什么数据库。在此项调研中,PostgreSQL与Redis一骑绝尘,以70%+ 的喜爱率高居榜首,显著甩开其他数据库。

在过去几年,Redis一直是用户最喜欢的数据库。在 2022 年,形势发生了变化,PostgreSQL 第一次超过 Redis,成为最受开发者喜爱的数据库。 Redis是简单易用的数据结构缓存服务器,经常会与关系型数据库搭配使用,广受开发者喜爱。不过开发者明显更爱功能强大得多的 PostgreSQL 多一丢丢。

相比之下 MySQL 与 Oracle 的表现就比较拉胯了。喜欢和讨厌 MySQL 的人基本各占一半;而只有35%的用户喜欢 Oracle ,这也意味着近 2/3 的开发者反感 Oracle 。

TOP 9 数据库喜爱度演变(2017-2022)

从逻辑上讲,用户的喜爱将导致软件的流行,用户的厌恶将导致软件过气。 我们可以参照 净推荐指数(NPS,又称口碑,推荐者%-贬损者%)的构造方式, 设计一个净喜爱指数 NLS:即 喜爱人群% - 厌恶人群%, 而数据库流行度的导数应当与 NLS 呈现正相关性 。

数据很好的印证了这一点: PGSQL 有着全场最高的 NLS: 44% ,对应着最高的流行度增长率 每年 460个基点。 MySQL 的口碑刚好落在褒贬线上方 (2.3%),流行度平均增速为36个基点; 而 Oracle 的口碑则为负的 29%,对应平均每年44个基点的使用率负增长。 当然在这份榜单上, Oracle 只是倒数第三惨的,最不受人待见的是 IBM DB2 : 1/4的人喜欢,3/4的人讨厌,NLS = -48% ,对应46个基点的年平均衰退。

当然,并不是所有潜能,都可以转换为实打实的动能。 用户的喜爱并不一定会付诸行动,而这就是第三项调研所要回答的问题。

最想要

PostgreSQL —— 开发者 最想使用的数据库!(Wanted)

“在过去的一年中,你在哪些数据库环境中进行了大量开发工作?在未来一年,你想在哪些数据库环境中工作? ”

对于这个问题前半段的回答,引出了”最流行“数据库的调研结果;而后半段,则给出了”最想要“这个问题的答案。 如果说用户的喜爱代表的是未来增长的潜能,那么用户的需求(想要,Want)就代表了下一年实打实的增长动能。

在今年的调研中, PostgreSQL 毫不客气的挤开 MongoDB ,占据了开发者最想使用数据库的宝座。 高达 19% 的受访者表示,下一年中想要使用 PostgreSQL 环境进行开发。 紧随其后的是 MongoDB (17%) 与 Redis (14%),这三种数据库的需求程度与其他数据库显著拉开了一个台阶。

此前, MongoDB 一直占据”最想要“数据库榜首,但最近开始出现过气乏力的态势。 原因是多方面的:例如,MongoDB 本身也受到了 PostgreSQL 的冲击。 PostgreSQL 本身就包含了完整的 JSON 特性,可直接用作文档数据库,更有类似 FerretDB (原名 MangoDB)的项目可以直接在 PG 上对外提供 MongoDB 的 API。

MongoDB 与 Redis 都是 NoSQL 运动的主力军。但与 MongoDB 不同,Redis的需求在不断增长。PostgreSQL 与 Redis,分别作为 SQL 与 NoSQL 的领军者,保持着旺盛的需求与高速的增长,前途无量。

为什么?

PostgreSQL 在需求率, 使用率,喜爱率上都拔得头筹,天时地利人和齐备,动能势能潜能都有,足以称得起是最成功的数据库了。

但我们想知道的是,为什么 PostgreSQL 会如此成功 ?

其实,秘密就藏在它的 Slogan 里: ”世界上最先进开源 关系型数据库“。

关系型数据库

关系型数据库是如此的普及与重要,也许其他的数据库品类如键值,文档,搜索引擎,时序,图,向量加起来也比不上它的一个零头。以至于当大家谈起数据库时,如果没有特殊说明,默认隐指的就是”关系型数据库“。在它面前,没有其他数据库品类敢称自己为”主流“。

DB-Engine 为例,DB-Engine的排名标准包括搜索系统名称时的搜索引擎结果数,Google趋势,Stack Overflow讨论,Indeed 提及系统的工作机会,LinkedIn等专业网络中的个人资料数,Twitter等社交网络中的提及数等,可理解为数据库的“综合热度”。

数据库热度趋势:https://db-engines.com/en/ranking_trend

在 DB-Engine 的热度趋势图中我们可以看到一条鸿沟,前四名全都是 关系型数据库 ,加上排名第五的 MongoDB,与其他数据库在热度上拉开了 数量级上的差距。 我们只需要把关注点聚焦到这四种核心的关系型数据库 Oracle,MySQL,SQL Server,PostgreSQL 上即可。

关系型数据库的生态位高度重叠,其关系可以视作零和博弈。抛开微软生态关门自嗨相对独立的商业数据库 SQL Server不提。在关系型数据库世界里,上演的是一场三国演义。

Oracle有才无德,MySQL才浅德薄,唯有PostgreSQL德才兼备。

Oracle是老牌商业数据库,有着深厚的历史技术积淀,功能丰富,支持完善。稳坐数据库头把交椅,广受不差钱且需要背锅侠的企业喜爱。但Oracle费用昂贵,且以讼棍行径成为知名的业界毒瘤。Microsoft SQL Server性质与Oracle类似,都属于商业数据库。商业数据库整体受开源数据库冲击,处于缓慢衰退的状态。

MySQL流行度位居第二,但树大招风,处于前狼后虎,上有野爹下有逆子的不利境地:在严谨的事务处理和数据分析上,MySQL被同为开源生态位的PostgreSQL甩开几条街;而在糙猛快的敏捷方法论上,MySQL又不如新兴NoSQL好用;同时 MySQL 上有养父 Oracle 压制,中有兄弟 MariaDB 分家,下有诸如逆子 TiDB 等协议兼容NewSQL分羹,因此也在走下坡路。

作为老牌商业数据库,Oracle的毋庸质疑,但其作为业界毒瘤,“” ,亦不必多说,故曰:“有才无德”。MySQL 虽有开源之功德,奈何认贼作父;且才疏学浅,功能简陋,只能干干CRUD,故曰“才浅德薄”。唯有PostgreSQL,德才兼备,既占据了开源崛起之天时,又把握住功能先进之地利,还有着宽松BSD协议之人和。正所谓:藏器于身,因时而动。不鸣则已,一鸣惊人,一举夺冠!

而 PostgreSQL 德以致胜的秘密,就是 先进开源

开源之德

PG的“德”在于开源。祖师爷级的开源项目,全世界开发者群策群力的伟大成果。

协议友善BSD,生态繁荣扩展多。开枝散叶,子孙满堂,Oracle替代扛旗者

什么叫“德”,合乎于“道”的表现就是德。而这条“道”就是开源

PostgreSQL是历史悠久的祖师爷级开源项目,更是全世界开发者群策群力的典范成果。

生态繁荣,扩展丰富,开枝散叶,子孙满堂

很久很久以前,开发软件/信息服务需要使用非常昂贵的商业数据库软件:例如Oracle与SQL Server:单花在软件授权上的费用可能就有六七位数,加之相近的硬件成本与服务订阅成本。Oracle一个 CPU 核一年的软件授权费用便高达十几万,即使壕如阿里也吃不消要去IOE。以 PostgreSQL / MySQL 为代表的的开源数据库崛起,让用户有了一个新选择:软件不要钱。“不要钱” 的开源数据库可以让我们自由随意地使用数据库软件,而这一点深刻影响了行业的发展:从接近一万¥/ 核·月的商业数据库,到20块钱/核·月的纯硬件成本。数据库走入寻常企业中,让免费提供信息服务成为可能。

开源是有大功的。互联网的历史就是开源软件的历史,IT行业之所以有今天的繁荣,人们能享受到如此多的免费信息服务,核心原因之一就是开源软件。开源是一种真正成功的,以软件自由为目的,由开发者构成的 Communism(社区主义):软件这种IT业的核心生产资料变为全世界开发者公有,按需分配。开发者各尽所能,人人为我,我为人人。

一个开源程序员工作时,其劳动背后可能蕴含的是数以万计顶尖开发者的智慧结晶。程序员薪资高从原理上来说是因为,开发者本质上不是一个简单的工人,而是一个指挥软件和硬件干活的包工头。程序员自己就是核心生产资料;软件来自公有社区;服务器硬件更是唾手可得;因此一个或几个高级的软件工程师,就可以很轻松的利用开源生态快速解决领域问题。

通过开源,所有社区开发者形成合力,极大降低了重复造轮子的内耗。使得整个行业的技术水平以匪夷所思的速度向前迈进。开源的势头就像滚雪球,时至今日已经势不可挡。基本上除了一些特殊场景和路径依赖,软件开发中闭门造车搞自力更生几乎成了一个大笑话。

越是底层基础的软件,开源便越占优势。开源,也是 PostgreSQL 对阵 Oracle 的最大底气所在。

Oracle 先进,但 PostgreSQL 也不差。PostgreSQL 是 Oracle 兼容性最好的开源数据库,原生即支持 Oracle 85% 的功能,更有 96% 功能兼容的专业发行版。但更重要的是,Oracle价格高昂,而PG开源免费。压倒性的成本优势让PG拥有了巨大的生态位基础:它不一定要在功能先进性上超过 Oracle 才能成功 ,廉价9成正确已经足以干翻 Oracle 。

PostgreSQL 可以视作一个开源版的“Oracle”,是唯一能真正威胁到 Oracle 的数据库。作为 ”去O“ 抗旗者,PG 可谓子孙满堂, 36% 的 “国产数据库” 更是直接基于PG “开发”,养活了一大批 自主可控 的 数据库公司,可谓功德无量。更重要的是,PostgreSQL 社区并不反对这样的行为,BSD 协议允许这样做。这样开放的胸襟,是被Oracle收购的,使用GPL协议的MySQL所难以相比的。

先进之才

PG的“才”在于先进。一专多长的全栈数据库,一个打十个,天生就是 HTAP。

时空地理分布式,时序文档超融合,单一组件即可覆盖几乎所有数据库需求。

PG的“才”在于一专多长。PostgreSQL是一专多长的全栈数据库,天生就是HTAP,超融合数据库,一个打十个。基本单一组件便足以覆盖中小型企业绝大多数的数据库需求:OLTP,OLAP,时序数据库,空间GIS,全文检索,JSON/XML,图数据库,缓存,等等等等。

PostgreSQL是各种关系型数据库中性价比最高的选择:它不仅可以用来做传统的CRUD OLTP业务,数据分析更是它的拿手好戏。各种特色功能更是提供了切入多种行业以的契机:基于PostGIS的地理时空数据处理分析,基于Timescale的时序金融物联网数据处理分析,基于Pipeline存储过程触发器的流式处理,基于倒排索引全文检索的搜索引擎,FDW对接统一各式各样的外部数据源。可以说,PG是真正一专多长的全栈数据库,它可以实现的比单纯OLTP数据库要丰富得多的功能。

在一个很可观的规模内,PostgreSQL都可以独立扮演多面手的角色,一个组件当多种组件使。而单一数据组件选型可以极大地削减项目额外复杂度,这意味着能节省很多成本。它让十个人才能搞定的事,变成一个人就能搞定的事。 不是说PG要一个打十个把其他数据库的饭碗都掀翻:专业组件在专业领域的实力是毋庸置疑的。但切莫忘记,为了不需要的规模而设计是白费功夫,这属于过早优化的一种形式。如果真有那么一样技术可以满足你所有的需求,那么使用该技术就是最佳选择,而不是试图用多个组件来重新实现它。

以探探为例,在 250w TPS与 200TB 数据的量级下,单一PostgreSQL选型依然能稳定可靠地撑起业务。能在很可观的规模内做到一专多长,除了本职的OLTP,PG 还在相当长的时间里兼任了缓存,OLAP,批处理,甚至消息队列的角色。当然神龟虽寿,犹有竟时。最终这些兼职功能还是要逐渐分拆出去由专用组件负责,但那已经是近千万日活时的事了。

vs MySQL

PostgreSQL 的先进性有目共睹,这也是其对阵同为开源关系型数据库的老对手 —— MySQL 时,真正的核心竞争力。

MySQL的口号是“世界上最流行的开源关系型数据库”,它的核心特点是糙猛快,用户基本盘是互联网。互联网公司的典型特点是什么?追逐潮流糙猛快说的是互联网公司业务场景简单(CRUD居多);数据重要性不高,不像传统行业(例如银行)那样在意数据的一致性与正确性;可用性优先,相比停服务更能容忍数据丢乱错,而一些传统行业宁可停止服务也不能让账目出错。 说的则是互联网行业数据量大,它们需要的就是水泥槽罐车做海量CRUD,而不是高铁和载人飞船。 说的则是互联网行业需求变化多端,出活周期短,要求响应时间快,大量需求的就是开箱即用的软件全家桶(如LAMP)和简单培训就能上手干活的CRUD Boy。于是,糙猛快的互联网公司和糙猛快的MySQL一拍即合。

但时过境迁,PostgreSQL 进步神速,在”快“与”猛“上 MySQL 已经不占优了,现在能拿出手的只剩下”糙“了。举个例子,MySQL 的哲学可以称之为:“好死不如赖活着”,与 “我死后哪管洪水滔天”。 其“糙”体现在各种“容错”上,例如允许呆瓜程序员写出的错误的SQL也能跑起来。最离谱的例子就是MySQL竟然允许部分成功的事务提交,这就违背了关系型数据库的基本约束:原子性与数据一致性

图:MySQL默认竟然允许部分成功的事务提交

先进的因会反映为流行的果,流行的东西因为落后而过气,而先进的东西会因为先进变得流行。时代所赋予的红利,也会随时代过去而退潮。在这个变革的时代中,没有先进的功能打底,“流行”也也难以长久。在先进性上, PostgreSQL 丰富的功能已经甩开 MySQL 了几条街,而 MySQL 引以为豪的 ”流行度“ 也开始被 PostgreSQL 反超。

大势所趋,大局已定。正所谓:时来天地皆同力,运去英雄不自由。先进与开源,就是 PostgreSQL 最大的两样杀手锏。Oracle 先进, MySQL 开源,PostgreSQL 先进又开源。天时地利人和齐备,何愁大业不成?

展望未来

软件吞噬世界, 开源吞噬软件,而云吞噬开源。

看上去,数据库之争已经尘埃落定,一段时间内大概不会有其他数据库内核能威胁到 PostgreSQL 了。 但对 PostgreSQL 开源社区 真正的威胁,已经不再是其他数据库内核,而是软件使用范式的嬗变:云出现了。

最初,大家开发软件/信息服务需要使用昂贵的商业软件( Oracle,SQL Server,Unix)。而随着 Linux / PostgreSQL 这些开源软件的兴起,用户们有了新的选择。开源软件确实免费不要钱,但想用好开源软件,是一件门槛很高的事情,用户不得不雇佣开源软件专家来帮助自己用好开源软件。

当数据库上了规模,雇佣开源DBA自建始终是合算的,只是好DBA太稀缺了。

这便是开源的核心模式:开源软件开发者给开源软件做贡献;开源软件通过好用免费吸引大量用户;用户在使用开源软件时产生需求,创造更多开源软件相关就业岗位,创造更多的开源软件开发者。 这三步形成了一个正反馈循环:更多的开源贡献者让开源软件更好用,更省钱,从而吸引更多用户,并创造出更多的开源贡献者。开源生态的繁荣有赖于这个闭环,而公有云厂商的出现打破了这个循环。

公有云厂商将开源数据库套上壳,加上自己的硬件与管控软件,雇佣共享DBA提供支持,便成了云数据库。诚然这是一项很有价值的服务,但云厂商将开源软件放在自家的云平台售卖而鲜有回馈,实质上是一种通过“搭便车”吸血开源的行为。 这样的共享外包模式将导致开源软件的岗位向云厂商集中,最终形成少数巨头做大垄断,伤害到所有用户的软件自由。

世界已经被云改变了,闭源软件早已不是最重要的问题了。

在 2020 年,计算自由的敌人是云计算软件”。

这是 DDIA 作者 Martin Kleppmann 在其“本地优先软件”运动中提出的 宣言。云软件指的是运行在供应商服务器上的软件,例如:Google Docs、Trello、Slack、Figma、Notion 。以及最核心的云软件,云数据库

后云时代,开源社区如何应对云软件的挑战?Cloud Native 运动给出了答案。这是一场从公有云夺回软件自由的伟大运动,而数据库,则是其中的核心焦点。

Cloud Native 全景图,还缺少最后一块拼图:有状态的数据库!

这也是我们做 开箱即用的开源PostgreSQL 数据库发行版 —— Pigsty 想要解决的问题:做一个用户在本地即可使用的RDS服务,成为云数据库的开源替代!

Pigsty 带有开箱即用的 RDS / PaaS / SaaS 整合;一个无可比拟的PG监控系统与自动驾驶的高可用集群架构方案;一键安装部署,并提供 Database as Code 的易用体验;在体验比肩甚至超越云数据库的前提下,数据自主可控且成本减少 50% ~ 90%。我们希望它能极大降低 PostgreSQL 使用的门槛,让更多用户可以用 好数据库用好 数据库。

当然,限于篇幅,云数据库与后云时代的数据库未来,就是下一篇文章要介绍的故事了。

开箱即用的PG发行版:Pigsty

昨天在PostgreSQL中文社区做了一个直播分享,介绍了开源的PostgreSQL全家桶解决方案 —— Pigsty。

什么是Pigsty

Pigsty是开箱即用的生产级开源PostgreSQL发行版

所谓发行版(Distribution),指的是由数据库内核及其一组软件包组成的数据库整体解决方案。例如,Linux是一个操作系统内核,而RedHat,Debian,SUSE则是基于此内核的操作系统发行版。PostgreSQL是一个数据库内核,而Pigsty,BigSQL,Percona,各种云RDS,换皮数据库则是基于此内核的数据库发行版

Pigsty区别于其他数据库发行版的五个核心特性为:

  • 全面专业监控系统
  • 稳定可靠部署方案
  • 简单省心的用户界面
  • 灵活开放扩展机制
  • 免费友好开源协议

这五个特性,使得Pigsty真正成为开箱即用的PostgreSQL发行版。

谁会感兴趣?

Pigsty面向的用户群体包括:DBA,架构师,OPS,软件厂商、云厂商、业务研发、内核研发、数据研发;对数据分析与数据可视化感兴趣的人;学生,新手程序员,有兴趣尝试数据库的用户。

对于DBA,架构师等专业用户,Pigsty提供了独一无二的专业级PostgreSQL监控系统,为数据库管理提供不可替代的价值点。与此同时,Pigsty还带有一个稳定可靠,久经考验的生产级PostgreSQL部署方案,可在生产环境中自动部署带有监控报警,日志采集,服务发现,连接池,负载均衡,VIP,以及高可用的PostgreSQL数据库集群。

对于研发人员(业务研发、内核研发、数据研发),学生,新手程序员,有兴趣尝试数据库的用户,Pigsty提供了门槛极低,一键拉起,一键安装本地沙箱。本地沙箱除机器规格外与生产环境完全一致,包含完整的功能:带有开箱即用的数据库实例与监控系统。可用于学习,开发,测试,数据分析等场景。

此外,Pigsty提供了一种称为“Datalet”的灵活扩展机制 。对数据分析与数据可视化感兴趣的人可能会惊讶地发现,Pigsty还可以作为数据分析与可视化的集成开发环境。Pigsty集成了PostgreSQL与常用的数据分析插件,并带有Grafana和内嵌的Echarts支持,允许用户编写,测试,分发数据小应用(Datalet)。如:“Pigsty监控系统的额外扩展面板包”,“Redis监控系统”,“PG日志分析系统”,“应用监控”,“数据目录浏览器”等。

最后,Pigsty采用了免费友好的Apache License 2.0,可以免费用于商业目的。只要遵守Apache 2 License的显著声明条款,也欢迎云厂商与软件厂商集成与二次研发商用

全面专业的监控系统

You can’t manage what you don’t measure.

— Peter F.Drucker

Pigsty提供专业级监控系统,面向专业用户提供不可替代的价值点。

以医疗器械类比,普通监控系统类似于心率计、血氧计,普通人无需学习也可以上手。它可以给出患者生命体征核心指标:起码用户可以知道人是不是要死了,但对于看病治病无能为力。例如,各种云厂商软件厂商提供的监控系统大抵属于此类:十几个核心指标,告诉你数据库是不是还活着,让人大致有个数,仅此而已。

专业级监控系统则类似于CT,核磁共振仪,可以检测出对象内部的全部细节,专业的医师可以根据CT/MRI报告快速定位疾病与隐患:有病治病,没病健体。Pigsty可以深入审视每一个数据库中的每一张表,每一个索引,每一个查询,提供巨细无遗的全面指标(1155类),并通过几千个仪表盘将其转换为洞察:将故障扼杀在萌芽状态,并为性能优化提供实时反馈

Pigsty监控系统基于业内最佳实践,采用Prometheus、Grafana作为监控基础设施。开源开放,定制便利,可复用,可移植,没有厂商锁定。可与各类已有数据库实例集成。

稳定可靠的部署方案

A complex system that works is invariably found to have evolved from a simple system that works.

—John Gall, Systemantics (1975)

数据库是管理数据的软件,管控系统是管理数据库的软件。

Pigsty内置了一套以Ansible为核心的数据库管控方案。并基于此封装了命令行工具与图形界面。它集成了数据库管理中的核心功能:包括数据库集群的创建,销毁,扩缩容;用户、数据库、服务的创建等。Pigsty采纳“Infra as Code”的设计哲学使用了声明式配置,通过大量可选的配置选项对数据库与运行环境进行描述与定制,并通过幂等的预置剧本自动创建所需的数据库集群,提供近似私有云般的使用体验。

Pigsty创建的数据库集群是分布式高可用的数据库集群。Pigsty创建的数据库基于DCS、Patroni、Haproxy实现了高可用。数据库集群中的每个数据库实例在使用上都是幂等的,任意实例都可以通过内建负载均衡组件提供完整的读写服务,提供分布式数据库的使用体验。数据库集群可以自动进行故障检测与主从切换,普通故障能在几秒到几十秒内自愈,且期间只读流量不受影响。故障时。集群中只要有任意实例存活,就可以对外提供完整的服务。

Pigsty的架构方案经过审慎的设计与评估,着眼于以最小复杂度实现所需功能。该方案经过长时间,大规模的生产环境验证,已经被互联网/B/G/M/F多个行业内的组织所使用。

简单省心的用户界面

Pigsty旨在降低PostgreSQL的使用门槛,因此在易用性上做了大量工作。

安装部署

Someone told me that each equation I included in the book would halve the sales.

— Stephen Hawking

Pigsty的部署分为三步:下载源码,配置环境,执行安装,均可通过一行命令完成。遵循经典的软件安装模式,并提供了配置向导。您需要准备的只是一台CentOS7.8机器及其root权限。管理新节点时,Pigsty基于Ansible通过ssh发起管理,无需安装Agent,即使是新手也可以轻松完成部署。

Pigsty既可以在生产环境中管理成百上千个高规格的生产节点,也可以独立运行于本地1核1GB虚拟机中,作为开箱即用的数据库实例使用。在本地计算机上使用时,Pigsty提供基于Vagrant与Virtualbox的沙箱。可以一键拉起与生产环境一致的数据库环境,用于学习,开发,测试数据分析,数据可视化等场景。

用户接口

Clearly, we must break away from the sequential and not limit the computers. We must state definitions and provide for priorities and descriptions of data. We must state relation‐ ships, not procedures.

—Grace Murray Hopper, Management and the Computer of the Future (1962)

Pigsty吸纳了Kubernetes架构设计中的精髓,采用声明式的配置方式与幂等的操作剧本。用户只需要描述“自己想要什么样的数据库”,而无需关心Pigsty如何去创建它,修改它。Pigsty会根据用户的配置文件清单,在几分钟内从裸机节点上创造出所需的数据库集群。

在管理与使用上,Pigsty提供了不同层次的用户界面,以满足不同用户的需求。新手用户可以使用一键拉起的本地沙箱与图形用户界面,而开发者则可以选择使用pigsty-cli命令行工具与配置文件的方式进行管理。经验丰富的DBA、运维与架构师则可以直接通过Ansible原语对执行的任务进行精细控制。

灵活开放的扩展机制

PostgreSQL的 可扩展性(Extensible) 一直为人所称道,各种各样的扩展插件让PostgreSQL成为了最先进的开源关系型数据库。Pigsty亦尊重这一价值,提供了一种名为“Datalet”的扩展机制,允许用户和开发者对Pigsty进行进一步的定制,将其用到“意想不到”的地方,例如:数据分析与可视化。

当我们拥有监控系统与管控方案后,也就拥有了开箱即用的可视化平台Grafana与功能强大的数据库PostgreSQL。这样的组合拥有强大的威力 —— 特别是对于数据密集型应用而言。用户可以在无需编写前后端代码的情况下,进行数据分析与数据可视化,制作带有丰富交互的数据应用原型,甚至应用本身。

Pigsty集成了Echarts,以及常用地图底图等,可以方便地实现高级可视化需求。比起Julia,Matlab,R这样的传统科学计算语言/绘图库而言,PG + Grafana + Echarts的组合允许您以极低的成本制作出可分享可交付标准化的数据应用或可视化作品。

Pigsty监控系统本身就是Datalet的典范:所有Pigsty高级专题监控面板都会以Datalet的方式发布。Pigsty也自带了一些有趣的Datalet案例:Redis监控系统,新冠疫情数据分析,七普人口数据分析,PG日志挖掘等。后续还会添加更多的开箱即用的Datalet,不断扩充Pigsty的功能与应用场景。

免费友好的开源协议

Once open source gets good enough, competing with it would be insane.

Larry Ellison —— Oracle CEO

在软件行业,开源是一种大趋势,互联网的历史就是开源软件的历史,IT行业之所以有今天的繁荣,人们能享受到如此多的免费信息服务,核心原因之一就是开源软件。开源是一种真正成功的,由开发者构成的communism(译成社区主义会更贴切):软件这种IT业的核心生产资料变为全世界开发者公有,人人为我,我为人人。

一个开源程序员工作时,其劳动背后其实可能蕴含有数以万计的顶尖开发者的智慧结晶。通过开源,所有社区开发者形成合力,极大降低了重复造轮子的内耗。使得整个行业的技术水平以匪夷所思的速度向前迈进。开源的势头就像滚雪球,时至今日已经势不可挡。除了一些特殊场景和路径依赖,软件开发中闭门造车搞自力更生已经成了一个大笑话。

依托开源,回馈开源。Pigsty采用了友好的Apache License 2.0,可以免费用于商业目的只要遵守Apache 2 License的显著声明条款,也欢迎云厂商与软件厂商集成与二次研发商用

关于Pigsty

A system cannot be successful if it is too strongly influenced by a single person. Once the initial design is complete and fairly robust, the real test begins as people with many different viewpoints undertake their own experiments. — Donald Knuth

Pigsty围绕开源数据库PostgreSQL而构建,PostgreSQL是世界上最先进的开源关系型数据库,而Pigsty的目标就是:做最好用的开源PostgreSQL发行版

在最开始时,Pigsty并没有这么宏大的目标。因为在市面上找不到任何满足我自己需求的监控系统,因此我只好自己动手,丰衣足食,给自己做了一个监控系统。没有想到它的效果出乎意料的好,有不少外部组织PG用户希望能用上。紧接着,监控系统的部署与交付成了一个问题,于是又将数据库部署管控的部分加了进去;在生产环境应用后,研发希望能在本地也有用于测试的沙箱环境,于是又有了本地沙箱;有用户反馈ansible不太好用,于是就有了封装命令的pigsty-cli命令行工具;有用户希望可以通过UI编辑配置文件,于是就有了Pigsty GUI。就这样,需求越来越多,功能也越来越丰富,Pigsty也在长时间的打磨中变得更加完善,已经远远超出了最初的预期。

做这件事本身也是一种挑战,做一个发行版有点类似于做一个RedHat,做一个SUSE,做一个“RDS产品”。通常只有一定规模的专业公司与团队才会去尝试。但我就是想试试,一个人可不可以?实际上除了慢一点,也没什么不可以。一个人在产品经理、开发者,终端用户的角色之间转换是很有趣的体验,而“Eat dog food”最大的好处就是,你自己既是开发者也是用户,你了解自己需要什么,也不会在自己的需求上偷懒。

不过,正如高德纳所说:“带有太强个人色彩的系统无法成功”。 要想让Pigsty成为一个具有旺盛生命力的项目,就必须开源,让更多的人用起来。“当最初的设计完成并足够稳定后,各式各样的用户以自己的方式去使用它时,真正的挑战才刚刚开始”。

Pigsty很好的解决了我自己的问题与需求,现在我希望它可以帮助到更多的人,并让PostgreSQL的生态更加繁荣,更加多彩。

为什么PostgreSQL前途无量?

PG好处都有啥,我要给它夸一夸,为什么PG是世界上最先进的开源关系型数据库

最近做的事儿都围绕着PostgreSQL生态,因为我一直觉得这是一个前途无量的方向。

为什么这么说?因为数据库是信息系统的核心组件,关系型数据库是数据库中的绝对主力,而PostgreSQL是世界上最先进的开源关系型数据库。占据天时地利,何愁大业不成?

做一件事最重要的就是认清形势,时来天地皆同力,运去英雄不自由。

天下大势

今天下三分,然Oracle | MySQL | SQL Server 疲敝,日薄西山。PostgreSQL紧随其后,如日中天。前四的数据库中,前三者都在走下坡路,唯有PG增长势头不减,此消彼长,前途无量。

数据库流行度趋势:https://db-engines.com/en/ranking_trend

(注意这是对数坐标系)

在唯二两个头部开源关系型数据库 MySQL & PgSQL 中,MySQL (2nd) 虽占上风,但其生态位却在逐渐被PostgreSQL (4th) 和非关系型的文档数据库MongoDB (5th) 抢占。按照现在的势头,几年后PostgreSQL的流行度即将跻身前三,与Oracle、MySQL分庭抗礼。

竞争关系

关系型数据库的生态位高度重叠,其关系可以视作零和博弈。与PostgreSQL形成直接竞争关系的,就是OracleMySQL

Oracle流行度位居第一,是老牌商业数据库,有着深厚的历史技术积淀,功能丰富,支持完善。稳坐数据库头把交椅,广受不差钱的企业组织喜爱。但Oracle费用昂贵,且以讼棍行径成为知名的业界毒瘤。排名第三的SQL Server属于相对独立的微软生态,性质上与Oracle类似,都属于商业数据库。商业数据库整体受开源数据库冲击,流行度处于缓慢衰减的状态。

MySQL流行度位居第二,但树大招风,处于前有狼后有虎,上有野爹下有逆子的不利境地:在严谨的事务处理和数据分析上,MySQL被同为开源关系型数据库的PgSQL甩开几条街;而在糙猛快的敏捷方法论上,MySQL又不如新兴NoSQL。同时,MySQL上有养父Oracle的压制,中有MariaDB分家,下有诸如TiDB,OB之类的兼容性新数据库分羹,因而也止步不前。

唯有PostgreSQL迎头赶上,保持着近乎指数增长的势头。如果说几年前PG的势还是Potential,那么现在Potential已经开始兑现为Impact,开始对竞品构成强力挑战。

而在这场你死我活的斗争中,PostgreSQL占据了三个“”:

  1. 开源软件普及发展,蚕食商业软件市场

    在去IOE与开源浪潮的大背景下,凭借开源生态对商业软件(Oracle)形成压制。

  2. 满足用户日益增长的数据处理功能需求

    凭借地理空间数据的事实标准PostGIS处理立于不败之地,凭借对标Oracle的极为丰富的功能,对MySQL形成技术压制。

  3. 市场份额均值回归的势

    国内PG市场份额因历史原因,远低于世界平均水平,本身蕴含着巨大势能。

Oracle作为老牌商业软件,毋庸质疑,同时作为业界毒瘤,“”也不必多说,故曰:“有才无德”。MySQL有开源之功德,但它一来采用了GPL协议,比起使用无私宽松BSD协议的PgSQL还是差不少意思,二来认贼作父,被Oracle收购,三来才疏学浅,功能简陋,故曰“才浅德薄”。

德不配位,必有灾殃。唯有PostgreSQL,既占据了开源崛起之天时,又把握住功能强劲之地利,还有着宽松BSD协议之人和。正所谓:藏器于身,因时而动。不鸣则已,一鸣惊人。德才兼备,攻守之势易矣!

德才兼备

PostgreSQL的德

PG的“德”在于开源。什么叫“德”,合乎于“道”的表现就是德。而这条“道”就是开源

PG本身就是祖师爷级开源软件,是开源世界中的一颗明珠,是全世界开发者群策群力的成功典范。而且更重要的是它采用无私的BSD协议:除了打着PG的名号招摇撞骗外,基本可以说是百无禁忌:比如换皮改造为国产数据库出售。PG可谓无数数据库厂商们的衣食父母。子孙满堂,活人无数,功德无量。

数据库谱系图,若列出所有PgSQL衍生版,估计可以撑爆这张图

PostgreSQL的才

PG的“才”在于一专多长。PostgreSQL是一专多长的全栈数据库,天生就是HTAP,超融合数据库,一个打十个。基本单一组件便足以覆盖中小型企业绝大多数的数据库需求:OLTP,OLAP,时序数据库,空间GIS,全文检索,JSON/XML,图数据库,缓存,等等等等。

PostgreSQL在一个很可观的规模内都可以独立扮演多面手的角色,一个组件当多种组件使。而单一数据组件选型可以极大地削减项目额外复杂度,这意味着能节省很多成本。它让十个人才能搞定的事,变成一个人就能搞定的事。 如果真有那么一样技术可以满足你所有的需求,那么使用该技术就是最佳选择,而不是试图用多个组件来重新实现它。

参考阅读:PG好处都有啥

开源之德

开源是有大功的。互联网的历史就是开源软件的历史,IT行业之所以有今天的繁荣,人们能享受到如此多的免费信息服务,核心原因之一就是开源软件。开源是一种真正成功的,由开发者构成的communism(译成社区主义会更贴切):软件这种IT业的核心生产资料变为全世界开发者公有,人人为我,我为人人。

一个开源程序员干活时,其劳动背后其实可能蕴含有数以万计的顶尖开发者的智慧结晶。互联网程序员贵,因为从效果上来讲,其实程序员不是一个工人,而是一个指挥软件和机器来干活的包工头。 程序员自己就是核心生产资料,服务器很容易取得(相比其他行业的科研设备与实验环境),软件来自公有社区,一个或几个高级的软件工程师可以很轻松的利用开源生态快速解决领域问题。

通过开源,所有社区开发者形成合力,极大降低了重复造轮子的内耗。使得整个行业的技术水平以匪夷所思的速度向前迈进。开源的势头就像滚雪球,时至今日已经势不可挡。基本上除了一些特殊场景和路径依赖,软件开发中闭门造车搞自力更生几乎成了一个大笑话。

所以说,搞数据库也好,做软件也罢,要搞技术就要搞开源的技术,闭源的东西生命力太弱,没意思。开源之德,也是PgSQL与MySQL对Oracle的最大底气所在。

生态之争

开源的核心就在于生态(ECO),每一个开源技术都有自己的小生态。所谓生态就是各种主体及其环境通过密集相互作用构成的一个系统,而开源软件的生态模式大致可以描述为由以下三个步骤组成的正反馈循环:

  • 开源软件开发者给开源软件做贡献
  • 开源软件本身免费,吸引更多用户
  • 用户使用开源软件,产生需求,创造更多开源软件相关岗位

开源生态的繁荣有赖于这个闭环,而生态系统的规模(用户/开发者数量)与复杂度(用户/开发者质量)直接决定了这个软件的生命力,所以每一个开源软件都有天命去扩大自己的规模。而软件的规模通常取决于软件所占据的生态位,如果不同的软件的生态位重叠,就会发生竞争。在开源关系型数据库的生态位中,PgSQL与MySQL就是最直接的竞争者。

流行 vs 先进

MySQL的口号是“世界上最流行的开源关系型数据库”,而PostgreSQL的Slogan则是“世界上最先进的开源关系型数据库”,一看这就是一对老冤家了。这两个口号很好的反映出了两种产品的特质:PostgreSQL是功能丰富,一致性优先,高大上的严谨的学院派数据库;MySQL是功能粗陋,可用性优先,糙猛快的“工程派”数据库。

MySQL的主要用户群体集中在互联网公司,互联网公司的典型特点是什么?追逐潮流糙猛快说的是互联网公司业务场景简单(CRUD居多);数据重要性不高,不像传统行业(例如银行)那样在意数据的一致性(正确性);可用性优先(相比停服务更能容忍数据丢乱错,而一些传统行业宁可停止服务也不能让账目出错)。 说的则是互联网行业数据量大,它们需要的就是水泥槽罐车,而不是高铁和载人飞船。 说的则是互联网行业需求变化多端,出活周期短,要求响应时间快,大量需求的就是开箱即用的软件全家桶(如LAMP)和简单培训一下就能干活的CRUD Boy。于是糙猛快的互联网公司和糙猛快的MySQL一拍即合。

而PgSQL的用户则更偏向于传统行业,传统行业之所以称为传统行业,就是因为它们已经走过了野蛮生长的阶段,有着成熟的业务模型与深厚的底蕴积淀。它们需要的是正确的结果,稳定的表现,丰富的功能,对数据进行分析加工提炼的能力。所以在传统行业中,往往是Oracle、SQL Server、PostgreSQL的天下。特别是在地理相关的场景中更是有着不可替代的地位。与此同时,不少互联网公司的业务也开始成熟沉淀,已经一只脚迈入“传统行业”了,越来越多的互联网公司脱离了糙猛快的低级循环,将目光投向PostgreSQL 。

谁更正确?

最了解一个人的的往往是他的竞争对手,PostgreSQL与MySQL的口号都很精准地戳中了对手的痛点。PgSQL“最先进”的潜台词就是MySQL太落后,而MySQL”最流行“就是说PgSQL不流行。用户少但先进,用户多但落后。哪一个更”好“?这种价值判断的问题不好回答。

但我认为时间站在 先进 技术的一边:因为先进与落后是技术的核心度量,是因,而流行与否则是果;流行不流行是内因(技术是否先进)和外因(历史路径依赖)共同对时间积分的结果。当下的因会反映为未来的果:流行的东西因为落后而过气,而先进的东西会因为先进变得流行。

虽然很多流行的东西都是垃圾,但流行并不一定代表着落后。如果只是缺少一些功能,MySQL还不至于被称为“落后”。问题在于MySQL已经糙到连事务这种关系型数据库的基本功能都有缺陷,那就不是落后不落后能概括的问题,而是合格不合格的问题了。

ACID

​ 一些作者声称,支持通用的两阶段提交代价太大,会带来性能与可用性的问题。让程序员来处理过度使用事务导致的性能问题,总比缺少事务编程好得多。

​ ——James Corbett等,Spanner:Google的全球分布式数据库(2012)

在我看来, MySQL的哲学可以称之为:“好死不如赖活着”,以及,“我死后哪管洪水滔天”。 其“可用性”体现在各种“容错”上,例如允许呆瓜程序员写出的错误的SQL查询也能跑起来。最离谱的例子就是MySQL竟然允许部分成功的事务提交,这就违背了关系型数据库的基本约束:原子性与数据一致性

图:MySQL竟然允许部分成功的事务提交

这里在一个事务中插入了两条记录,第一条成功,第二条因为约束失败。根据事务的原子性,整个事务要么整个成功,要么整个失败(最终一条都没有插入)。结果MySQL的默认表现竟然是允许部分成功的事务提交,也就是事务没有原子性没有原子性就没有一致性,如果这个事务是一笔转账(先扣再加),因为某些原因失败,那这里的帐就做不平了。这种数据库如果用来记账恐怕是一笔糊涂账,所以说什么“金融级MySQL”恐怕就是一个笑话。

当然,滑稽的是还有一些MySQL用户将其称为“特性”,说这体现了MySQL的容错性。实际上,此类“特殊容错”需求在SQL标准中完全可以通过SAVEPOINT机制实现。PgSQL对此的实现就堪称典范,psql客户端允许通过ON_ERROR_ROLLBACK选项,隐式地在每条语句后创建SAVEPOINT,并在语句失败后自动ROLLBACK TO SAVEPOINT,以标准SQL的方式,以客户端可选项的形式,在不破坏事物ACID的情况下,同样实现这种看上去便利实则苟且的功能。相比之下,MySQL的这种所谓“特性”是以直接在服务端默认牺牲事务ACID为代价的(这意味着用户使用JDBC,psycopg等应用驱动也照样受此影响)。

如果是互联网业务,注册个新用户丢个头像、丢个评论可能不是什么大事。数据那么多,丢几条,错几条又算个什么?别说是数据,业务本身很可能都处于朝不保夕的状态,所以糙又如何?万一成功了,前人拉的屎反正也是后人来擦。所以一些互联网公司通常并不在乎这些。

PostgreSQL所谓“严格的约束与语法“可能对新人来说“不近人情”,例如,一批数据中如果有几条脏数据,MySQL可能会照单全收,而PG则会严格拒绝。尽管苟且妥协看上去很省事,但在其他地方卖下了雷:因为逻辑炸弹深夜加班排查擦屁股的工程师,和不得不天天清洗脏数据的数据分析师肯定对此有很大怨念。从长期看,要想成功,做正确的事最重要。

一个成功的技术,现实的优先级必须高于公关,你可以糊弄别人,但糊弄不了自然规律。

——罗杰斯委员会报告(1986)

MySQL的流行度并没有和PgSQL相差太远,然而其功能比起PostgreSQL和Oracle却是差距不小。Oracle与PostgreSQL算诞生于同一时期,再怎么斗,立场与阵营不同,也有点惺惺相惜的老对手的意思:都是扎实修炼了半个世纪内功,厚积薄发的老法师。而MySQL就像心浮气躁耍刀弄枪的二十来岁毛头小伙子,凭着一把蛮力,借着互联网野蛮生长的黄金二十年趁势而起,占山为王。

时代所赋予的红利,也会随时代过去而退潮。在这个变革的时代中,没有先进的功能打底,“流行”也恐怕也难以长久。

发展前景

从个人职业发展前景的角度看,很多数程序员学习一门技术的原因都是为了提高自己的技术竞争力(从而更好占坑赚钱)。PostgreSQL是各种关系型数据库中性价比最高的选择:它不仅可以用来做传统的CRUD OLTP业务,数据分析更是它的拿手好戏。各种特色功能更是提供了切入多种行业以的契机:基于PostGIS的地理时空数据处理分析,基于Timescale的时序金融物联网数据处理分析,基于Pipeline存储过程触发器的流式处理,基于倒排索引全文检索的搜索引擎,FDW对接统一各式各样的外部数据源。可以说,它是真正一专多长的全栈数据库,用它可以实现的功能要比单纯的OLTP数据库要丰富得多,更是为CRUD码农提供了转型和深入的进阶道路。

企业用户的角度来看,PostgreSQL在一个很可观的规模内都可以独立扮演多面手的角色,一个组件当多种组件使。而单一数据组件选型可以极大地削减项目额外复杂度,这意味着能节省很多成本。它让十个人才能搞定的事,变成一个人就能搞定的事。 当然这不是说PG要一个打十个把其他数据库的饭碗都掀翻,专业组件在专业领域的实力是毋庸置疑的。但切莫忘记,为了不需要的规模而设计是白费功夫,实际上这属于过早优化的一种形式。如果真有那么一样技术可以满足你所有的需求,那么使用该技术就是最佳选择,而不是试图用多个组件来重新实现它。

以探探为例,在250WTPS与200TB数据的量级下,单一PostgreSQL选型依然能稳如狗地支撑业务。能在很可观的规模内做到一专多长,除了本职的OLTP,Pg还在相当长的时间里兼任了缓存,OLAP,批处理,甚至消息队列的角色。当然神龟虽寿,犹有竟时。最终这些兼职功能还是要逐渐分拆出去由专用组件负责,但那已经是近千万日活时的事了。

商业生态的角度看,PostgreSQL也有巨大的优势。一来PG技术先进,可称为 “开源版Oracle”。原生的PG基本可以对Oracle的功能做到八九成兼容,EDB更是有96% Oracle兼容的专业PG发行版。因此在抢占去O腾退出的市场中,PostgreSQL及其衍生版本的技术优势是压倒性的。二来PG协议友善,采用了宽松的BSD协议。因此各种数据库厂商,云厂商出品的“自研数据库”,以及很多“云数据库”大体都是基于PgSQL改造的。例如最近HW基于PostgreSQL搞openGaussDB就是一个很明智的选择。不要误会,PG的协议确实允许这样做,而且这样做也确实让PostgreSQL的生态更加繁荣壮大。卖PostgreSQL衍生版是一个很成熟的市场:传统企业不差钱且愿意为此付费买单。开源天才之火有商业利益之油浇灌,因而源源不断地释放出旺盛的生命力。

vs MySQL

作为老对手,MySQL的处境就有些尴尬了。

从个人职业发展上来看,学MySQL主要就是干CRUD。学好增删改查成为一个合格的码农是没问题的,然而谁又愿意一直“数据矿工”的活呢?数据分析才是数据产业链上的暴利肥差。以MySQL孱弱的分析能力,很难支持CURD程序员升级转型发展。此外,PostgreSQL的市场需求摆在那里,但现在却面临供不应求的状况(以至于现在大量良莠不齐的PG培训机构如雨后春笋般冒了出来),MySQL的人确实比PgSQL的人好招,这是不假的。但反过来说MySQL界的内卷程度也要大的多,供不应求方才体现稀缺性,人太多了技能也就贬值了。

从企业用户的角度来看,MySQL就是专用于OLTP的单一功能组件,往往需要ES, Redis, Mongo等其他等等一起配合才能满足完整的数据存储需求,而PG基本就不会有这个问题。此外,MySQL和PgSQL都是开源数据库,都“免费”。免费的Oracle和免费的MySQL用户会选择哪个呢?

从商业生态来看,MySQL面临的最大问题是 叫好不叫座。叫好当然是因为越流行则声音越大,尤其主要的用户互联网企业本身就占据话语权高地。不叫座当然也是因为互联网公司本身对于这类软件付费的意愿是极弱的:怎么算都是养几个MySQL DBA直接用开源的更合算。此外,因为MySQL的GPL协议要求衍生软件也要开源,软件厂商基于MySQL研发的动机也不强,基本都是采用 兼容“MySQL” 协议来分MySQL的市场蛋糕,而不是基于MySQL的代码进行开发与回馈,让人对其生态健康程度产生怀疑。

当然MySQL最大的问题就在于:它的生态位越来越狭窄。论严谨的事务处理与数据分析,PostgreSQL甩开它几条街;论糙猛快,快速出原型,NoSQL全家桶又要比MySQL方便太多。论商业发财,上面有Oracle干爹压着;论开源生态,又不断出现MySQL兼容的新生代产品来尝试替代主体。可以说MySQL处在一种吃老本的位置上,只是凭籍历史积分存量维持着现状的地位。时间是否会站在MySQL这一边,我们拭目以待。

vs NewSQL

最近市场上当然也有一些很亮眼的NewSQL产品,例如TiDB,Cockroachdb,Yugabytedb等等。何如?我认为它们都是很好的产品,有一些不错的技术亮点,都是对开源技术的贡献。但是它们可能同样面临叫好不叫座的困局。

NewSQL的大体特征是:主打“分布式”的概念,通过“分布式”解决水平扩展性容灾高可用两个问题,并因分布式的内在局限性会牺牲许多功能,只能提供较为简单有限的查询支持。分布式数据库在高可用容灾方面与传统主从复制并没有质的区别,因此其特征主要可以概括为“以量换质”。

然而对很多企业而言,牺牲功能换取扩展性很可能是一个伪需求弱需求。在我接触过的为数不少的用户中,绝大多数场景下的的数据量和负载水平完全落在单机Postgres的处理范围内(目前弄过的记录是单库15TB,单集群40万TPS)。从数据量上来讲,绝大多数企业终其生命周期的数据量也超不过这个瓶颈;至于性能就更不重要了,过早优化是万恶之源,很多企业的DB性能余量足够让他们把所有业务逻辑用存储过程编写然后高高兴兴的跑在数据库里。

NewSQL的祖师爷Google Spanner就是为了解决海量数据扩展性的问题,但又有多少企业能有Google的业务数据量?恐怕还是只有典型的互联网公司,或者某些大企业的部分业务会有这种量级的数据存储需求。所以和MySQL一样,NewSQL的问题就回到了谁来买单这个根本问题上。恐怕到最后只能还是由投资人和国资委来买吧。

但最起码,NewSQL的这种尝试始终是值得赞扬的。

vs 云数据库

我想直率地说:多年来,我们就像个傻子一样,他们拿着我们开发的东西大赚了一笔”。

—— Ofer Bengal , Redis Labs 首席执行官

另一个值得关注的“竞争者”是所谓云数据库,包括两种,一种是放在云上托管的开源数据库。例如 RDS for PostgreSQL,另一种是自研的新一代云数据库。

针对前者,主要的问题是“云厂商吸血”。如果云厂商售卖开源软件,实际上会导致就会导致开源软件的相关岗位和利润向云厂商集中,而云厂商是否允许自己的程序员给开源项目做贡献,做多少贡献,其实是很难说的。负责人的大厂通常是会回馈社区,回馈生态的,但这取决于它们的自觉。开源软件还是应当将命运握在自己手中,防止云厂商过分做大形成垄断。相比少量垄断巨头,多数分散的小团体能提供更高的生态多样性,更有利于生态健康发展。

Gartner称2022年75%的数据库将部署至云平台,这个牛逼吹的太大了。(但也有圆的办法,毕竟用一台机器就可以轻松创建几亿个sqlite文件数据库,这算不算?)。因为云计算解决不了一个根本性的问题 —— 信任。实际上在商业活动中,技术牛逼不牛逼是很次要的因素,Trust才是最关键的。数据是很多企业的生命线,云厂商又不是真正的中立第三方,谁能保证数据不会被其偷窥,盗窃,泄漏,甚至直接被卡脖子关停(如各路云厂商锤Parler)?TDE之类的透明加密解决方案也属于鸡肋,充分的恶心了自己,但也防不住真正的有心人。也许要等真正实用的高效全同态加密技术成熟才能解决信任与安全这个问题吧。

另一个根本性的问题在于成本:就目前云厂商的定价策略,云数据库只有在小微规模下有优势。例如一台D740 64核|400G内存|3TB PCI-E SSD的高配机型四年综合成本撑死了十几万块。然而我能找到最大的规格RDS(比这差很多,32核|128GB)一年的价格就这个数了。只要数据量节点数稍微上那么点规模,雇个DBA自建就合算太多了。

云数据库的主要优势还是在于管控,说白了就是用起来方便,点点鼠标。日常运维功能已经覆盖的比较全面,也有一些基础的监控支持。总之下限是摆在那里,如果找不到靠谱的数据库人才,用云数据库起码不至于出太多幺蛾子。 不过这些管控软件虽好,基本都是闭源的,而且与供应商深度绑定。

如果你想找一个开源的PostgreSQL监控管控一条龙解决方案,不妨试试Pigsty。

后一种云数据库以AWS Aurora为代表,也包括一系列类似产品如阿里云PolarDB,腾讯云CynosDB。基本都是采用PostgreSQL与MySQL作为Base和协议层,基于云基础设施(共享存储,S3,RDMA)进行定制化,对扩容速度性能进行了优化。这类产品在技术上肯定是有新颖性和创造性的。但灵魂问题就是,这类产品相比直接使用原生PostgreSQL的收益到底在哪里呢?能看到立竿见影的好处就是集群扩容会快很多(从几小时级到5分钟),不过相比高昂的费用与供应商锁定的问题,实在是挠不到痛点和痒点。

总的来说,云数据库对原生PostgreSQL 构成的威胁是有限的。也不用太担心云厂商的问题,云厂商总的来说还开源软件生态的一份子,对社区和生态是有贡献的。赚钱嘛,不磕碜,大家都有钱赚了,才有余力去搞公益,对不对?

弃暗投明?

通常来说,Oracle的程序员转PostgreSQL不会有什么包袱,因为两者功能类似,大多数经验都是通用的。实际上,很多PostgreSQL生态的成员都是从Oracle阵营转投PG的。例如国内著名的Oracle服务商云和恩墨(由中国第一位Oracle ACE总监盖国强创办),去年就公开宣布“躬身入局”,拥抱PostgreSQL。

也有不少MySQL阵营转投PgSQL的,其实这类用户对两者的区别感受才是最深的:基本上都是一副相见恨晚,弃暗投明的样子。实际上我自己最开始也是先用MySQL😆,能自己选型后就拥抱了PgSQL。不过有些老程序员已经和MySQL形成了深度利益绑定,嚷嚷着MySQL多好多好,还要不忘来碰瓷喷一喷PgSQL(特指某人)。这个其实是可以理解的,触动利益比触动灵魂还难,看到自己擅长的技术日落西山那肯定是愤懑不平😠。毕竟一把年纪投在MySQL上,PostgreSQL🐘再好,让我抛弃我心爱的小海豚🐬,做不到啊。

不过,刚入行的年轻人还是有机会去选择一条更光明的道路的。时间是最公平的裁判,而新生代的选择则是最有代表性的标杆。据我个人观察,在新兴的极有活力的Golang开发者群体中,PostgreSQL的流行程度要显著高于MySQL,不少创业型、创新型的公司现在都选择Go+Pg作为自己的技术栈,例如Instagram,TanTan,Apple都是Go+PG。

我认为这一现象的主要原因就是新生代开发者的崛起,Go之于Java,就像PgSQL之于MySQL。长江后浪推前浪,这其实就是演化的核心机制 —— 新陈代谢。Go和PgSQL慢慢拍扁Java和MySQL,但Go和PgSQL当然也有可能在以后被诸如Rust和某些真正革命性的NewSQL数据库拍扁。但说到底,搞技术还是要搞那些前景光明的,不要去搞那些日暮西山的。(当然下海太早当烈士也不合适)。要去看新生代开发者在用什么,有活力的创业公司、新项目、新团队在用什么,弄这些是没有错的。

PG的问题

当然PgSQL有没有自己的问题?当然也有 —— 流行度

流行度关乎着着用户规模,信任水平,成熟案例数量,有效需求反馈量,开发者数量等等。尽管按目前的流行度发展趋势,PG将在几年后超过MySQL,所以从长期来看,我觉得这并不是问题。但作为PostgreSQL社区的一员,我觉得很有必要去进一步做一些事情,Secure this success,并加快这一进度。而要想让一样技术更加流行,效果最好的方式就是:降低门槛

所以,我做了一个开源软件Pigsty,要把PostgreSQL部署、监控、管理、使用的门槛从天花板砸到地板,它有三个核心目标:

  • 做最顶尖最专业的开源PostgreSQL 监控系统(类tidashboard)
  • 做门槛最低最好用的开源PostgreSQL管控方案(类tiup)
  • 做开箱即用的与数据分析&可视化集成开发环境(类minikube)

当然这里细节限于篇幅就不展开了,详情留待下篇分说。

容器化数据库是个好主意吗?

把数据库放入Docker是一个好主意吗?当然是个馊主意!

对于无状态的应用服务而言,容器是一个相当完美的开发运维解决方案。然而对于带持久状态的服务 —— 数据库来说,事情就没有那么简单了。生产环境的数据库是否应当放入容器中,仍然是一个充满争议的问题。

站在开发者的角度上,我非常喜欢Docker,并始终相信Docker是未来软件开发部署运维的标准方式,而Kubernetes则是事实上的下一代“操作系统”。但站在DBA的立场上,我认为就目前而言,将生产环境数据库放入Docker中仍然是一个馊主意。

Docker解决什么问题?

让我们先来看一看Docker对自己的描述。

docker-dev

docker-ops

Docker用于形容自己的词汇包括:轻量,标准化,可移植,节约成本,提高效率,自动,集成,高效运维。这些说法并没有问题,Docker在整体意义上确实让开发和运维都变得更容易了。因而可以看到很多公司都热切地希望将自己的软件与服务容器化。但有时候这种热情会走向另一个极端:将一切软件服务都容器化,甚至是生产环境的数据库

容器最初是针对无状态的应用而设计的,在逻辑上,容器内应用产生的临时数据也属于该容器的一部分。用容器创建起一个服务,用完之后销毁它。这些应用本身没有状态,状态通常保存在容器外部的数据库里,这是经典的架构与用法,也是容器的设计哲学。

但当用户想把数据库本身也放到容器中时,事情就变得不一样了:数据库是有状态的,为了维持这个状态不随容器停止而销毁,数据库容器需要在容器上打一个洞,与底层操作系统上的数据卷相联通。这样的容器,不再是一个能够随意创建,销毁,搬运,转移的对象,而是与底层环境相绑定的对象。因此,传统应用使用容器的诸多优势,对于数据库容器来说都不复存在。

可靠性

让软件跑起来,和让软件可靠地运行是两回事。数据库是信息系统的核心,在绝大多数场景下属于**关键(Critical)**应用,Critical Application可按字面解释,就是出了问题会要命的应用。这与我们的日常经验相符:Word/Excel/PPT这些办公软件如果崩了强制重启即可,没什么大不了的;但正在编辑的文档如果丢了、脏了、乱了,那才是真的灾难。数据库亦然,对于不少公司,特别是互联网公司来说,如果数据库被删了又没有可用备份,基本上可以宣告关门大吉了。

可靠性(Reliability)是数据库最重要的属性。可靠性是系统在困境(adversity)(硬件故障、软件故障、人为错误)中仍可正常工作(正确完成功能,并能达到期望的性能水准)的能力。可靠性意味着容错(fault-tolerant)与韧性(resilient),它是一种安全属性,并不像性能与可维护性那样的活性属性直观可衡量。它只能通过长时间的正常运行来证明,或者某一次故障来否证。很多人往往会在平时忽视安全属性,而在生病后,车祸后,被抢劫后才追悔莫及。安全生产重于泰山,数据库被删,被搅乱,被脱库后再捶胸顿足是没有意义的。

回头再看一看Docker对自己的特性描述中,并没有包含“可靠”这个对于数据库至关重要的属性。

可靠性证明与社区知识

如前所述,可靠性并没有一个很好的衡量方式。只有通过长时间的正确运行,我们才能对一个系统的可靠性逐渐建立信心。在裸机上部署数据库可谓自古以来的实践,通过几十年的持续工作,它很好的证明了自己的可靠性。Docker虽为DevOps带来一场革命,但仅仅五年的历史对于可靠性证明而言仍然是图样图森破。对关乎身家性命的生产数据库而言还远远不够:因为还没有足够的小白鼠去趟雷

想要提高可靠性,最重要的就是从故障中吸取经验。故障是宝贵的经验财富:它将未知问题变为已知问题,是运维知识的表现形式。社区的故障经验绝大多都基于裸机部署的假设,各式各样的故障在几十年里都已经被人们踩了个遍。如果你遇到一些问题,大概率是别人已经踩过的坑,可以比较方便地处理与解决。同样的故障如果加上一个“Docker”关键字,能找到的有用信息就要少的多。这也意味着当疑难杂症出现时,成功抢救恢复数据的概率要更低,处理紧急故障所需的时间会更长。

微妙的现实是,如果没有特殊理由,企业与个人通常并不愿意分享故障方面的经验。故障有损企业的声誉:可能暴露一些敏感信息,或者是企业与团队的垃圾程度。另一方面,故障经验几乎都是真金白银的损失与学费换来的,是运维人员的核心价值所在,因此有关故障方面的公开资料并不多。

额外失效点

开发关心Feature,而运维关注Bug。相比裸机部署而言,将数据库放入Docker中并不能降低硬件故障,软件错误,人为失误的发生概率。用裸机会有的硬件故障,用Docker一个也不会少。软件缺陷主要是应用Bug,也不会因为采用容器与否而降低,人为失误同理。相反,引入Docker会因为引入了额外的组件,额外的复杂度,额外的失效点,导致系统整体可靠性下降

举个最简单的例子,dockerd守护进程崩了怎么办,数据库进程就直接歇菜了。尽管这种事情发生的概率并不高,但它们在裸机上压根不会发生

此外,一个额外组件引入的失效点可能并不止一个:Docker产生的问题并不仅仅是Docker本身的问题。当故障发生时,可能是单纯Docker的问题,或者是Docker与数据库相互作用产生的问题,还可能是Docker与操作系统,编排系统,虚拟机,网络,磁盘相互作用产生的问题。可以参见官方PostgreSQL Docker镜像的Issue列表:https://github.com/docker-library/postgres/issues?q=。

此外,彼之蜜糖,吾之砒霜。某些Docker的Feature,在特定的环境下也可能会变为Bug。

隔离性

Docker提供了进程级别的隔离性,通常来说隔离性对应用来说是个好属性。应用看不见别的进程,自然也不会有很多相互作用导致的问题,进而提高了系统的可靠性。但隔离性对于数据库而言不一定完全是好事。

一个微妙的真实案例在同一个数据目录上启动两个PostgreSQL实例,或者在宿主机和容器内同时启动了两个数据库实例。在裸机上第二次启动尝试会失败,因为PostgreSQL能意识到另一个实例的存在而拒绝启动;但在使用Docker的情况下因其隔离性,第二个实例无法意识到宿主机或其他数据库容器中的另一个实例。如果没有配置合理的Fencing机制(例如通过宿主机端口互斥,pid文件互斥),两个运行在同一数据目录上的数据库进程能把数据文件搅成一团浆糊。

数据库需不需要隔离性?当然需要, 但不是这种隔离性。数据库的性能很重要,因此往往是独占物理机部署。除了数据库进程和必要的工具,不会有其他应用。即使放在容器中,也往往采用独占绑定物理机的模式运行。因此Docker提供的隔离性对于这种数据库部署方案而言并没有什么意义;不过对云数据库厂商来说,这倒真是一个实用的Feature,用来搞多租户超卖妙用无穷。

工具

数据库需要工具来维护,包括各式各样的运维脚本,部署,备份,归档,故障切换,大小版本升级,插件安装,连接池,性能分析,监控,调优,巡检,修复。这些工具,也大多针对裸机部署而设计。这些工具与数据库一样,都需要精心而充分的测试。让一个东西跑起来,与确信这个东西能持久稳定正确的运行,是完全不同的可靠性水准。

一个简单的例子是插件,PostgreSQL提供了很多实用的插件,譬如PostGIS。假如想为数据库安装该插件,在裸机上只要yum install然后create extension postgis两条命令就可以。但如果是在Docker里,按照Docker的实践原则,用户需要在镜像层次进行这个变更,否则下次容器重启时这个扩展就没了。因而需要修改Dockerfile,重新构建新镜像并推送到服务器上,最后重启数据库容器,毫无疑问,要麻烦的多。

再比如说监控,在传统的裸机部署模式下,机器的各项指标是数据库指标的重要组成部分。容器中的监控与裸机上的监控有很多微妙的区别。不注意可能会掉到坑里。例如,CPU各种模式的时长之和,在裸机上始终会是100%,但这样的假设在容器中就不一定总是成立了。再比方说依赖/proc文件系统的监控程序可能在容器中获得与裸机上涵义完全不同的指标。虽然这类问题最终都是可解的(例如把Proc文件系统挂载到容器内),但相比简洁明了的方案,没人喜欢复杂丑陋的work around。

类似的问题包括一些故障检测工具与系统常用命令,虽然理论上可以直接在宿主机上执行,但谁能保证容器里的结果和裸机上的结果有着相同的涵义?更为棘手的是紧急故障处理时,一些需要临时安装使用的工具在容器里没有,外网不通,如果再走Dockerfile→Image→重启这种路径毫无疑问会让人抓狂。

把Docker当成虚拟机来用的话,很多工具大抵上还是可以正常工作的,不过这样就丧失了使用的Docker的大部分意义,不过是把它当成了另一个包管理器用而已。有人觉得Docker通过标准化的部署方式增加了系统的可靠性,因为环境更为标准化更为可控。这一点不能否认。私以为,标准化的部署方式虽然很不错,但如果运维管理数据库的人本身了解如何配置数据库环境,将环境初始化命令写在Shell脚本里和写在Dockerfile里并没有本质上的区别。

可维护性

软件的大部分开销并不在最初的开发阶段,而是在持续的维护阶段,包括修复漏洞、保持系统正常运行、处理故障、版本升级,偿还技术债、添加新的功能等等。可维护性对于运维人员的工作生活质量非常重要。应该说可维护性是Docker最讨喜的地方:Infrastructure as code。可以认为Docker的最大价值就在于它能够把软件的运维经验沉淀成可复用的代码,以一种简便的方式积累起来,而不再是散落在各个角落的install/setup文档。在这一点上Docker做的相当出色,尤其是对于逻辑经常变化的无状态应用而言。Docker和K8s能让用户轻松部署,完成扩容,缩容,发布,滚动升级等工作,让Dev也能干Ops的活,让Ops也能干DBA的活(迫真)。

环境配置

如果说Docker最大的优点是什么,那也许就是环境配置的标准化了。标准化的环境有助于交付变更,交流问题,复现Bug。使用二进制镜像(本质是物化了的Dockerfile安装脚本)相比执行安装脚本而言更为快捷,管理更方便。一些编译复杂,依赖如山的扩展也不用每次都重新构建了,这些都是很爽的特性。

不幸的是,数据库并不像通常的业务应用一样来来去去更新频繁,创建新实例或者交付环境本身是一个极低频的操作。同时DBA们通常都会积累下各种安装配置维护脚本,一键配置环境也并不会比Docker慢多少。因此在环境配置上Docker的优势就没有那么显著了,只能说是Nice to have。当然,在没有专职DBA时,使用Docker镜像可能还是要比自己瞎折腾要好一些,因为起码镜像中多少沉淀了一些运维经验。

通常来说,数据库初始化之后连续运行几个月几年也并不稀奇。占据数据库管理工作主要内容的并不是创建新实例与交付环境,主要还是日常运维的部分。不幸的是在这一点上Docker并没有什么优势,反而会产生一些麻烦。

日常运维

Docker确实能极大地简化来无状态应用的日常维护工作,诸如创建销毁,版本升级,扩容等,但同样的结论能延伸到数据库上吗?

数据库容器不可能像应用容器一样随意销毁创建,重启迁移。因而Docker并不能对数据库的日常运维的体验有什么提升,真正有帮助的倒是诸如ansible之类的工具。而对于日常运维而言,很多操作都需要通过docker exec的方式将脚本透传至容器内执行。底下跑的还是一样的脚本,只不过用docker-exec来执行又额外多了一层包装,这就有点脱裤子放屁的意味了。

此外,很多命令行工具在和Docker配合使用时都相当尴尬。譬如docker exec会将stderrstdout混在一起,让很多依赖管道的命令无法正常工作。以PostgreSQL为例,在裸机部署模式下,某些日常ETL任务可以用一行bash轻松搞定:

psql <src-url> -c 'COPY tbl TO STDOUT' |\
psql <dst-url> -c 'COPY tdb FROM STDIN'

但如果宿主机上没有合适的客户端二进制程序,那就只能这样用Docker容器中的二进制:

docker exec -it srcpg gosu postgres bash -c "psql -c \"COPY tbl TO STDOUT\" 2>/dev/null" |\ docker exec -i dstpg gosu postgres psql -c 'COPY tbl FROM STDIN;'

当用户想为容器里的数据库做一个物理备份时,原本很简单的一条命令现在需要很多额外的包装:dockergosubashpg_basebackup

docker exec -i postgres_pg_1 gosu postgres bash -c 'pg_basebackup -Xf -Ft -c fast -D - 2>/dev/null' | tar -xC /tmp/backup/basebackup

如果说客户端应用psql|pg_basebackup|pg_dump还可以通过在宿主机上安装对应版本的客户端工具来绕开这个问题,那么服务端的应用就真的无解了。总不能在不断升级容器内数据库软件的版本时每次都一并把宿主机上的服务器端二进制版本升级了吧?

另一个Docker喜欢讲的例子是软件版本升级:例如用Docker升级数据库小版本,只要简单地修改Dockerfile里的版本号,重新构建镜像然后重启数据库容器就可以了。没错,至少对于无状态的应用来说这是成立的。但当需要进行数据库原地大版本升级时问题就来了,用户还需要同时修改数据库状态。在裸机上一行bash命令就可以解决的问题,在Docker下可能就会变成这样的东西:https://github.com/tianon/docker-postgres-upgrade。

如果数据库容器不能像AppServer一样随意地调度,快速地扩展,也无法在初始配置,日常运维,以及紧急故障处理时相比普通脚本的方式带来更多便利性,我们又为什么要把生产环境的数据库塞进容器里呢?

Docker和K8s一个很讨喜的地方是很容易进行扩容,至少对于无状态的应用而言是这样:一键拉起起几个新容器,随意调度到哪个节点都无所谓。但数据库不一样,作为一个有状态的应用,数据库并不能像普通AppServer一样随意创建,销毁,水平扩展。譬如,用户创建一个新从库,即使使用容器,也得从主库上重新拉取基础备份。生产环境中动辄几TB的数据库,用万兆网卡也需要个把钟头才能完成,也很可能还是需要人工介入与检查。相比之下,在同样的操作系统初始环境下,运行现成的拉从库脚本与跑docker run在本质上又能有什么区别?毕竟时间都花在拖从库上了。

使用Docker承放生产数据库的一个尴尬之处就在于,数据库是有状态的,而且为了建立这个状态需要额外的工序。通常来说设置一个新PostgreSQL从库的流程是,先通过pg_baseback建立本地的数据目录副本,然后再在本地数据目录上启动postmaster进程。然而容器是和进程绑定的,一旦进程退出容器也随之停止。因此为了在Docker中扩容一个新从库:要么需要先后启动pg_baseback容器拉取数据目录,再在同一个数据卷上启动postgres两个容器;要么需要在创建容器的过程中就指定好复制目标并等待几个小时的复制完成;要么在postgres容器中再使用pg_basebackup偷天换日替换数据目录。无论哪一种方案都是既不优雅也不简洁。因为容器的这种进程隔离抽象,对于数据库这种充满状态的多进程,多任务,多实例协作的应用存在抽象泄漏,它很难优雅地覆盖这些场景。当然有很多折衷的办法可以打补丁来解决这类问题,然而其代价就是大量非本征复杂度,最终受伤的还是系统的可维护性。

总的来说,不可否认Docker对于提高系统整体的可维护性是有帮助的,只不过针对数据库来说这种优势并不显著:容器化的数据库能简化并加速创建新实例或扩容的速度,但也会在日常运维中引入一些麻烦和问题。不过,我相信随着Docker与K8s的进步,这些问题最终都是可以解决克服的。

性能

性能也是人们经常关注的一个维度。从性能的角度来看,数据库的基本部署原则当然是离硬件越近越好,额外的隔离与抽象不利于数据库的性能:越多的隔离意味着越多的开销,即使只是内核栈中的额外拷贝。对于追求性能的场景,一些数据库选择绕开操作系统的页面管理机制直接操作磁盘,而一些数据库甚至会使用FPGA甚至GPU加速查询处理。

实事求是地讲,Docker作为一种轻量化的容器,性能上的折损并不大,这也是Docker相比虚拟机的优势所在。但毫无疑问的是,将数据库放入Docker只会让性能变得更差而不是更好。

总结

容器技术与编排技术对于运维而言是非常有价值的东西,它实际上弥补了从软件到服务之间的空白,其愿景是将运维的经验与能力代码化模块化。容器技术将成为未来的包管理方式,而编排技术将进一步发展为“数据中心分布式集群操作系统”,成为一切软件的底层基础设施Runtime。当越来越多的坑被踩完后,人们可以放心大胆的把一切应用,有状态的还是无状态的都放到容器中去运行。但现在,起码对于数据库而言,还只是一个美好的愿景。

最后需要再次强调的是,以上讨论仅限于生产环境数据库。换句话说,对于开发环境而言,我其实是很支持将数据库放入Docker中的,毕竟不是所有的开发人员都知道怎么配置本地测试数据库环境,使用Docker交付环境显然要比一堆手册简单明了的多。对于生产环境的无状态应用,甚至一些带有衍生状态的不甚重要衍生数据系统(譬如Redis缓存),Docker也是一个不错的选择。但对于生产环境的核心关系型数据库而言,如果里面的数据真的很重要,使用Docker前还望三思:我愿意当小白鼠吗?出了疑难杂症我能Hold住吗?真搞砸了这锅我背的动吗?

任何技术决策都是一个利弊权衡的过程,譬如这里使用Docker的核心权衡可能就是牺牲可靠性换取可维护性。确实有一些场景,数据可靠性并不是那么重要,或者说有其他的考量:譬如对于云计算厂商来说,把数据库放到容器里混部超卖就是一件很好的事情:容器的隔离性,高资源利用率,以及管理上的便利性都与该场景十分契合。这种情况下将数据库放入Docker中也许就是利大于弊的。但对于多数的场景而言,可靠性往往都是优先级最高的的属性,牺牲可靠性换取可维护性通常并不是一个可取的选择。更何况实际很难说运维管理数据库的工作会因为用了Docker而轻松多少:为了安装部署一次性的便利而牺牲长久的日常运维可维护性,并不是一个很好的生意。

综上所述,我认为就目前对于普通用户而言,将生产环境的数据库放入容器中恐怕并不是一个明智的选择。

理解时间:时间时区那些事

理解时间:时间时区那些事

时间是个很玄妙的东西,看不见也摸不着。我们都能意识到时间的存在,但要给它下个定义,很多人也说不上来。本文当然不是为了探讨哲学问题,但对时间的正确理解,对正确处理工作生活中的时间问题很有帮助(例如,计算机中的时间表示与时间处理,数据库,编程语言中对于时间的处理)。

0x01 秒与计时

时间的单位是秒,但秒的定义并不是一成不变的。它有一个天文学定义,也有一个物理学定义。

世界时(UT1)

在最开始,秒的定义来源于日。秒被定义为平均太阳日的1/86400。而太阳日,则是由天文学现象定义的:两次连续正午时分的间隔被定义为一个太阳日;一天有86400秒,一秒等于86400分之一天,Perfect!以这一标准形成的时间标准,就称为世界时(Univeral Time, UT1),或不严谨的说,格林威治标准时(Greenwich Mean Time, GMT),下面就用GMT来指代它了。 ​ 这个定义很直观,但有一个问题:它是基于天文学现象的,即地球与太阳的周期性运动。不论是用地球的公转还是自转来定义秒,都有一个很尴尬的地方:虽然地球自转与公转的变化速度很慢,但并不是恒常的,譬如:地球的自转越来越慢,而地月位置也导致了每天的时长其实都不完全相同。这意味着作为物理基本单位的秒,其时长竟然是变化的。在衡量时间段的长短上就比较尴尬,几十年的一秒可能和今天的一秒长度已经不是一回事了。

原子时(TAI)

为了解决这个问题,在1967年之后,秒的定义变成了:铯133原子基态的两个超精细能级间跃迁对应辐射的9,192,631,770个周期的持续时间。秒的定义从天文学定义升级成为了物理学定义,其描述由相对易变的天文现象升级到了更稳定的宇宙中的基本物理事实。现在我们有了真正精准的秒啦:一亿年的偏差也不超过一秒。 ​ 当然,这么精确的秒除了用来衡量时间间隔,也可以用来计时。从1958-01-01 00:00:00开始作为公共时间原点,国际原子钟开始了计数,每计数9,192,631,770这么多个原子能级跃迁周期就+1s,这个钟走的非常准,每一秒都很均匀。使用这定义的时间称为国际原子时(International Atomic Time, TAI),下文简称TAI。

冲突

在最开始,这两种秒是等价的:一天是86400天文秒,也等于86400物理秒,毕竟物理学这个定义就是特意去凑天文学的定义嘛。所以相应的,GMT也与国际原子时TAI也保持着同步。然而正如前面所说,天文学现象影响因素太多了,并不是真正的“天行有常”。随着地球自转公转速度变化,天文定义的秒要比物理定义的秒稍微长了那么一点点,这也就意味着GMT要比TAI稍微落后一点点。 ​ 那么哪种定义说了算,世界时还是原子时?如果理论与生活实践经验相违背,绝大多数人都不会选择反直觉的方案:假设一种极端场景,两个钟之间的差异日积月累,到最后出现了几分钟甚至几小时的差值:明明日当午,按GMT应当是12:00:00,但GMT走慢了,TAI显示的时间已经是晚上六点了,这就违背了直觉。在表示时刻这一点上,还是由天文定义说了算,即以GMT为准。 ​ 当然,就算是天文定义说了算,也要尊重物理规律,毕竟原子钟走的这么准不是?实际上世界时与原子时之间的差值也就在几秒的量级。那么我们会自然而然地想到,使用国际原子时TAI作为基准,但加上一些闰秒(leap second)修正到GMT不就行了?既有高精度,又符合常识。于是就有了新的协调世界时(Coordinated Universal Time, UTC)

协调世界时(UTC)

UTC是调和GMT与TAI的产物:

  • UTC使用精确的国际原子时TAI作为计时基础

  • UTC使用国际时GMT作为修正目标

  • UTC使用闰秒作为修正手段,

我们通常所说的时间,通常就是指世界协调时间UTC,它与世界时GMT的差值在0.9秒内,在要求不严格的实践中,可以近似认为UTC时间与GMT时间是相同的,很多人也把它与GMT混为一谈。

但问题紧接着就来了,按照传统,一天24小时,一小时60分钟,一分钟60秒,日和秒之间有86400的换算关系。以前用日来定义秒,现在秒成了基本单位,就要用秒去定义日。但现在一天不等于86400秒了。无论用哪头定义哪头,都会顾此失彼。唯一的办法,就是打破这种传统:一分钟不一定只有60秒了,它在需要的时候可以有61秒! ​ 这就是闰秒机制,UTC以TAI为基准,因此走的也比GMT快。假设UTC和GMT的差异不断变大,在即将超过一秒时,让UTC中的某一分钟变为61秒,续的这一秒就像UTC在等GMT一样,然后误差就追回来了。每次续一秒时,UTC时间都会落后TAI多一秒,截止至今,UTC已经落后TAI三十多秒了。最近的一次闰秒调整是在2016年跨年:

国际标准时间UTC将在格林尼治时间2016年12月31日23时59分59秒(北京时间2017年1月1日7时59分59秒)之后,在原子时钟实施一个正闰秒,即增加1秒,然后才会跨入新的一年。

所以说,GMT和UTC还是有区别的,UTC里你能看到2016-12-31 23:59:60的时间,但GMT里就不会。

0x02 本地时间与时区

刚才讨论的时间都默认了一个前提:位于本初子午线(0度经线)上的时间。我们还需要考虑地球上的其他地方:毕竟美帝艳阳高照时,中国还在午夜呢。 ​ 本地时间,顾名思义就是以当地的太阳来计算的时间:正午就是12:00。太阳东升西落,东经120度上的本地时间比起本初子午线上就早了120° / (360°/24) = 8个小时。这意味着在北京当地时间12点整时,UTC时间其实是12-8=4,早晨4:00。 ​ 大家统一用UTC时间好不好呢?可以当然可以,毕竟中国横跨三个时区,也只用了一个北京时间。只要大家习惯就行。但大家都已经习惯了本地正午算12点了,强迫全世界人民用统一的时间其实违背了历史习惯。时区的设置使得长途旅行者能够简单地知道当地人的作息时间:反正差不多都是朝九晚五上班。这就降低了沟通成本。于是就有了时区的概念。当然像新疆这种硬要用北京时间的结果就是,游客乍一看当地人11点12点才上班可能会有些懵。

但在大一统的国家内部,使用统一的时间也有助于降低沟通成本。假如一个新疆人和一个黑龙江人打电话,一个用的乌鲁木齐时间,一个用的北京时间,那就会鸡同鸭讲。都约着12点,结果实际差了两个小时。时区的选用并不完全是按照地理经度而来的,也有很多的其他因素考量(例如行政区划)。 ​ 这就引出了时区的概念:时区是地球上使用同一个本地时间定义的区域时区实际上可以视作从地理区域到时间偏移量的单射。 ​ 但其实有没有那个地理区域都不重要,关键在于时间偏移量的概念。UTC/GMT时间本身的偏移量为0,时区的偏移量都是相对于UTC时间而言的。这里,本地时间,UTC时间与时区的关系是:

本地时间 = UTC时间 + 本地时区偏移量。

比如UTC、GMT的时区都是+0,意味着没有偏移量。中国所处的东八区偏移量就是+8。意味着计算当地时间时,要在UTC时间的基础上增加8个小时。

夏令时(Daylight Saving Time, DST),可以视为一种特殊的时区偏移修正。指的是在夏天天亮的较早的时候把时间调快一个小时(实际上不一定是一个小时),从而节省能源(灯火)。我国在86年到92年之间曾短暂使用过夏令时。欧盟从1996年开始使用夏令时,不过欧盟最近的民调显示,84%的民众希望取消夏令时。对程序员而言,夏令时也是一个额外的麻烦事,希望它能尽快被扫入历史的垃圾桶。

0x03 时间的表示

那么,时间又如何表示呢?使用TAI的秒数来表示时间当然不会有歧义,但使用不便。习惯上我们将时间分为三个部分:日期,时间,时区,而每个部分都有多种表示方法。对于时间的表示,世界诸国人民各有各的习惯,例如,2006年1月2日,美国人就可能喜欢使用诸如January 2, 19991/2/1999这样的日期表示形式,而中国人也许会用诸如“2006年1月2日”,“2006/01/02”这样的表示形式。发送邮件时,首部中的时间则采用RFC2822中规定的Sat, 24 Nov 2035 11:45:15 −0500格式。此外,还有一系列的RFC与标准,用于指定日期与时间的表示格式。

ANSIC       = "Mon Jan _2 15:04:05 2006"
UnixDate    = "Mon Jan _2 15:04:05 MST 2006"
RubyDate    = "Mon Jan 02 15:04:05 -0700 2006"
RFC822      = "02 Jan 06 15:04 MST"
RFC822Z     = "02 Jan 06 15:04 -0700" // RFC822 with numeric zone
RFC850      = "Monday, 02-Jan-06 15:04:05 MST"
RFC1123     = "Mon, 02 Jan 2006 15:04:05 MST"
RFC1123Z    = "Mon, 02 Jan 2006 15:04:05 -0700" // RFC1123 with numeric zone
RFC3339     = "2006-01-02T15:04:05Z07:00"
RFC3339Nano = "2006-01-02T15:04:05.999999999Z07:00"

不过在这里,我们只关注计算机中的日期表示形式与存储方式。而计算机中,时间最经典的表示形式,就是Unix时间戳。

Unix时间戳

比起UTC/GMT,对于程序员来说,更为熟悉的可能是另一种时间:Unix时间戳。UNIX时间戳是从1970年1月1日(UTC/GMT的午夜,在1972年之前没有闰秒)开始所经过的秒数,注意这里的秒其实是GMT中的秒,也就是不计闰秒,毕竟一天等于86400秒已经写死到无数程序的逻辑里去了,想改是不可能改的。 ​ 使用GMT秒数的好处是,计算日期的时候根本不用考虑闰秒的问题。毕竟闰年已经很讨厌了,再来一个没有规律的闰秒,绝对会让程序员抓狂。当然这不代表就不需要考虑闰秒的问题了,诸如ntp等时间服务还是需要考虑闰秒的问题的,应用程序有可能会受到影响:比如遇到‘时光倒流’拿到两次59秒,或者获取到秒数为60的时间值,一些实现简陋的程序可能就直接崩了。当然,也有一种将闰秒均摊到某一天全天的顺滑手段。 ​ Unix时间戳背后的思想很简单,建立一条时间轴,以某一个纪元点(Epoch)作为原点,将时间表示为距离原点的秒数。Unix时间戳的纪元为GMT时间的1970-01-01 00:00:00,32位系统上的时间戳实际上是一个有符号四字节整型,以秒为单位。这意味它能表示的时间范围为:2^32 / 86400 / 365 = 68年,差不多从1901年到2038年。 ​ 当然,时间戳并不是只有这一种表示方法,但通常这是最为传统稳妥可靠的做法。毕竟不是所有的程序员都能处理好许多和时区、闰秒相关的微妙错误。使用Unix时间戳的好处就是时区已经固定死了是GMT了,存储空间与某些计算处理(比如排序)也相对容易。 ​ 在*nix命令行中使用date +%s可以获取Unix时间戳。而date -r @1500000000则可以反向将Unix时间戳转换为其他时间格式,例如转换为2017-07-14 10:40:00可以使用:

date -d @1500000000 '+%Y-%m-%d %H:%M:%S'	# Linux
date -r 1500000000 '+%Y-%m-%d %H:%M:%S'		# MacOS, BSD

在很久以前,当主板上的电池没电之后,系统的时钟就会自动重置成0;还有很多软件的Bug也会导致导致时间戳为0,也就是1970-01-01;以至于这个纪元时间很多非程序员都知道了。 ​

数据库中的时间存储

通常情况下,Unix时间戳是传递/存储时间的最佳方式,它通常在计算机内部以整型的形式存在,内容为距离某个特定纪元的秒数。它极为简单,无歧义,存储占用更紧实,便于比较大小,且在程序员之间存在广泛共识。不过,Epoch+整数偏移量的方式适合在机器上进行存储与交换,但它并不是一种人类可读的格式(也许有些程序员可读)。

PostgreSQL提供了丰富的日期时间数据类型与相关函数,它能以高度灵活的方式自动适配各种格式的时间输入输出,并在内部以高效的整型表示进行存储与计算。在PostgreSQL中,变量CURRENT_TIMESTAMP或函数now()会返回当前事务开始时的本地时间戳,返回的类型是TIMESTAMP WITH TIME ZONE,这是一个PostgreSQL扩展,会在时间戳上带有额外的时区信息。SQL标准所规定的类型为TIMESTAMP,在PostgreSQL中使用8字节的长整型实现。可以使用SQL语法AT TIME ZONE zone或内置函数timezone(zone,ts)将带有时区的TIMESTAMP转换为不带时区的标准版本。

通常(我认为的)最佳实践是,只要应用稍具规模或涉及到任何国际化的功能,存储时间就应当使用TIMESTAMP类型并存储GMT时间,当然,PostgreSQL Wiki中推荐的方式是使用PostgreSQL自己的TimestampTZ扩展类型,带时区的时间戳是12字节,而不带时区的则为8字节,在固定使用GMT时区的情况下,个人还是更倾向于使用不带时区的TIMESTAMP类型。

-- 获取本地事务开始时的时间戳
vonng=# SELECT now(), CURRENT_TIMESTAMP;
              now              |       current_timestamp
-------------------------------+-------------------------------
 2018-12-11 21:50:15.317141+08 | 2018-12-11 21:50:15.317141+08

-- now()/CURRENT_TIMESTAMP返回的是带有时区信息的时间戳
 vonng=# SELECT pg_typeof(now()),pg_typeof(CURRENT_TIMESTAMP);
        pg_typeof         |        pg_typeof
--------------------------+--------------------------
 timestamp with time zone | timestamp with time zone
 

-- 将本地时区+8时间转换为UTC时间,转化得到的是TIMESTAMP
-- 注意不要使用从TIMESTAMPTZ到TIMESTAMP的强制类型转换,会直接截断时区信息。
 vonng=# SELECT now() AT TIME ZONE 'UTC';
          timezone
----------------------------
 2018-12-11 13:50:25.790108

-- 再将UTC时间转换为太平洋时间
vonng=# SELECT (now() AT TIME ZONE 'UTC') AT TIME ZONE 'PST';
           timezone
-------------------------------
 2018-12-12 05:50:37.770066+08
 
 -- 查看PG自带的时区数据表
 vonng=# TABLE pg_timezone_names LIMIT 4;
       name       | abbrev | utc_offset | is_dst
------------------+--------+------------+--------
 Indian/Mauritius | +04    | 04:00:00   | f
 Indian/Chagos    | +06    | 06:00:00   | f
 Indian/Mayotte   | EAT    | 03:00:00   | f
 Indian/Christmas | +07    | 07:00:00   | f
...

-- 查看PG自带的时区缩写
vonng=# TABLE pg_timezone_abbrevs  LIMIT 4;
 abbrev | utc_offset | is_dst
--------+------------+--------
 ACDT   | 10:30:00   | t
 ACSST  | 10:30:00   | t
 ACST   | 09:30:00   | f
 ACT    | -05:00:00  | f
 ...

一个经常让人困惑的问题就是TIMESTAMPTIMESTAMPTZ之间的相互转化问题。

-- 使用::TIMESTAMP将TIMESTAMPTZ强制转换为TIMESTAMP,直接截断时区部分内容
-- 时间的其余"内容"保持不变
vonng=# SELECT now(), now()::TIMESTAMP;
             now               |           now
-------------------------------+--------------------------
 2018-12-12 05:50:37.770066+08 |  2018-12-12 05:50:37.770066+08

-- 对有时区版TIMESTAMPTZ使用AT TIME ZONE语法
-- 会将其转换为无时区版的TIMESTAMP,返回给定时区下的时间
vonng=# SELECT now(), now() AT TIME ZONE 'UTC';
              now              |          timezone
-------------------------------+----------------------------
 2019-05-23 16:58:47.071135+08 | 2019-05-23 08:58:47.071135
 
 
 -- 对无时区版TIMESTAMP使用AT TIME ZONE语法
-- 会将其转换为带时区版的TIMESTAMPTZ,即在给定时区下解释该无时区时间戳。
vonng=# SELECT now()::TIMESTAMP, now()::TIMESTAMP AT TIME ZONE 'UTC';
            now             |           timezone
----------------------------+-------------------------------
 2019-05-23 17:03:00.872533 | 2019-05-24 01:03:00.872533+08
 
 -- 这里的意思是,UTC时间的 2019-05-23 17:03:00

微信公众号原文

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首位元素。
  • dbamonitortrash为保留模式名。
  • 分片模式命名规则采用: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列中,会溢出为负数。

【强制】 统一时区

  • 使用TIMESTAMP存储时间,采用utc时区。
  • 统一使用ISO-8601格式输入输出时间类型:2006-01-02 15:04:05,避免DMY与MDY问题。
  • 使用TIMESTAMPTZ时,采用GMT/UTC时间,0时区标准时。

【强制】 及时清理过时函数

  • 不再使用的,被替换的函数应当及时下线,避免与未来的函数发生冲突。

【推荐】 主键类型

  • 主键通常使用整型,建议使用BIGINT,允许使用不超过64字节的字符串。
  • 主键允许使用Serial自动生成,建议使用Default next_id()发号器函数。

【推荐】 选择合适的类型

  • 能使用专有类型的,不使用字符串。(数值,枚举,网络地址,货币,JSON,UUID等)
  • 使用正确的数据类型,能显著提高数据存储,查询,索引,计算的效率,并提高可维护性。

【推荐】 使用枚举类型

  • 较稳定的,取值空间较小(十几个内)的字段应当使用枚举类型,不要使用整型与字符串表示。
  • 使用枚举类型有性能、存储、可维护性上的优势。

【推荐】 选择合适的文本类型

  • PostgreSQL的文本类型包括 char(n), varchar(n), text
  • 通常建议使用varchartext,带有(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,注意版本兼容性。
CREATE OR REPLACE FUNCTION
  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.

【强制】 在线查询必须有配套索引

  • 所有在线查询必须针对其访问模式设计相应索引,除极个别小表外不允许全表扫描。
  • 索引有代价,不允许创建不使用的索引。

【强制】 禁止在大字段上建立索引

  • 被索引字段大小无法超过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之后没有配对的ROLLBACKCOMMIT
  • 尽量使用标准库提供的事务基础设施,不到万不得已不要手动控制事务。

【强制】 使用游标后必须及时关闭

【强制】 科学计数

  • count(*)统计行数的标准语法,与空值无关。
  • count(col)统计的是col列中的非空记录数。该列中的NULL值不会被计入。
  • count(distinct col)col列除重计数,同样忽视空值,即只统计非空不同值的个数。
  • count((col1, col2))对多列计数,即使待计数的列全为空也会被计数,(NULL,NULL)有效。
  • a(distinct (col1, col2))对多列除重计数,即使待计数列全为空也会被计数,(NULL,NULL)有效。

【强制】 注意聚合函数的空值问题

  • 除了count之外的所有聚合函数都会忽略空值输入,因此当输入值全部为空时,结果是NULL。但count(col)在这种情况下会返回0,是一个例外。
  • 如果聚集函数返回空并不是期望的结果,使用coalesce来设置缺省值。

【强制】谨慎处理空值

  • 明确区分零值与空值,空值使用IS NULL进行等值判断,零值使用常规的=运算符进行等值判断。
  • 空值作为函数输入参数时应当带有类型修饰符,否则对于有重载的函数将无法识别使用何者。
  • 注意空值比较逻辑:任何涉及到空值比较运算结果都是unknown,需要注意unknown参与布尔运算的逻辑:
    • andTRUE or UNKNOWN会因为逻辑短路返回TRUE
    • orFALSE and UNKNOWN会因为逻辑短路返回FALSE
    • 其他情况只要运算对象出现UNKNOWN,结果都是UNKNOWN
  • 空值与任何值的逻辑判断,其结果都为空值,例如NULL=NULL返回结果是NULL而不是TRUE/FALSE
  • 涉及空值与非空值的等值比较,请使用``IS DISTINCT FROM 进行比较,保证比较结果非空。
  • 空值与聚合函数:聚合函数当输入值全部为NULL时,返回结果为NULL。

【强制】 注意序列号空缺

  • 当使用Serial类型时,INSERTUPSERT等操作都会消耗序列号,该消耗不会随事务失败而回滚。
  • 当使用整型作为主键,且表存在频繁插入冲突时,需要关注整型溢出的问题。

【推荐】 重复查询使用准备语句

  • 重复的查询应当使用准备语句(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好。

0x05 发布规范

【强制】 发布形式

  • 目前以邮件形式提交发布,发送邮件至dba@p1.com 归档并安排提交。
  • 标题清晰:xx项目需在xx库执行xx动作。
  • 目标明确:每个步骤需要在哪些实例上执行哪些操作,结果如何校验。
  • 回滚方案:任何变更都需要提供回滚方案,新建也需要提供清理脚本。

【强制】发布评估

  • 线上数据库发布需要经过研发自测,主管审核,(可选QA审核),DBA审核几个评估阶段。
  • 自测阶段应当确保变更在开发、预发环境执行正确无误。
    • 如果是新建表,应当给出记录数量级,数据日增量预估值,读写量级预估。
    • 如果是新建函数,应当给出压测报告,至少需要给出平均执行时间。
    • 如果是模式迁移,必须梳理清楚所有上下游依赖。
  • Team Leader需要对变更进行评估与审核,对变更内容负责。
  • DBA对发布的形式与影响进行评估与审核。

【强制】 发布窗口

  • 19:00 后不允许数据库发布,紧急发布请TL做特殊说明,抄送CTO。
  • 16:00点后确认的需求将顺延至第二天执行。(以TL确认时间为准)

0x06 管理规范

【强制】 关注备份

  • 每日全量备份,段文件持续归档

【强制】 关注年龄

  • 关注数据库与表的年龄,避免事物ID回卷。

【强制】 关注老化与膨胀

  • 关注表与索引的膨胀率,避免性能劣化。

【强制】 关注复制延迟

  • 监控复制延迟,使用复制槽时更必须十分留意。

【强制】 遵循最小权限原则

【强制】并发地创建与删除索引

  • 对于生产表,必须使用CREATE INDEX CONCURRENTLY并发创建索引。

【强制】 新从库数据预热

  • 使用pg_prewarm,或逐渐接入流量。

【强制】 审慎地进行模式变更

  • 添加新列时必须使用不带默认值的语法,避免全表重写
  • 变更类型时,必要时应当重建所有依赖该类型的函数。

【推荐】 切分大批量操作

  • 大批量写入操作应当切分为小批量进行,避免一次产生大量WAL。

【推荐】 加速数据加载

  • 关闭autovacuum,使用COPY加载数据。
  • 事后建立约束与索引。
  • 调大maintenance_work_mem,增大max_wal_size
  • 完成后执行vacuum verbose analyze table

微信公众号原文

PG好处都有啥

PG好处都有啥,我要给它夸一夸,为什么PG是世界上最先进的开源关系型数据库

PostgreSQL的Slogan是“世界上最先进的开源关系型数据库”,但我觉得这口号不够响亮,而且一看就是在怼MySQL那个“世界上最流行的开源关系型数据库”的口号,有碰瓷之嫌。要我说最能生动体现PG特色的口号应该是:一专多长的全栈数据库,一招鲜吃遍天嘛。

pggood

全栈数据库

​ 成熟的应用可能会用到许许多多的数据组件(功能):缓存,OLTP,OLAP/批处理/数据仓库,流处理/消息队列,搜索索引,NoSQL/文档数据库,地理数据库,空间数据库,时序数据库,图数据库。传统的架构选型呢,可能会组合使用多种组件,典型的如:Redis + MySQL + Greenplum/Hadoop + Kafuka/Flink + ElasticSearch,一套组合拳基本能应付大多数需求了。不过比较令人头大的就是异构系统集成了:大量的代码都是重复繁琐的胶水代码,干着把数据从A组件搬运到B组件的事情。

在这里,MySQL就只能扮演OLTP关系型数据库的角色,但如果是PostgreSQL,就可以身兼多职,One handle them all,比如:

  • OLTP:事务处理是PostgreSQL的本行

  • OLAP:citus分布式插件,ANSI SQL兼容,窗口函数,CTE,CUBE等高级分析功能,任意语言写UDF

  • 流处理:PipelineDB扩展,Notify-Listen,物化视图,规则系统,灵活的存储过程与函数编写

  • 时序数据:timescaledb时序数据库插件,分区表,BRIN索引

  • 空间数据:PostGIS扩展(杀手锏),内建的几何类型支持,GiST索引。

  • 搜索索引:全文搜索索引足以应对简单场景;丰富的索引类型,支持函数索引,条件索引

  • NoSQL:JSON,JSONB,XML,HStore原生支持,至NoSQL数据库的外部数据包装器

  • 数据仓库:能平滑迁移至同属Pg生态的GreenPlum,DeepGreen,HAWK等,使用FDW进行ETL

  • 图数据:递归查询

  • 缓存:物化视图

ext

以Extension作六器,礼天地四方。

以Greenplum礼天,

以Postgres-XL礼地,

以Citus礼东方,

以TimescaleDB礼南方,

以PipelineDB礼西方,

以PostGIS礼北方。

—— 《周礼.PG》

​ 在探探的旧版架构中,整个系统就是围绕PostgreSQL设计的。几百万日活,几百万全局DB-TPS,几百TB数据的规模下,数据组件只用了PostgreSQL。独立的数仓,消息队列和缓存都是后来才引入的。而且这只是验证过的规模量级,进一步压榨PG是完全可行的。

​ 因此,在一个很可观的规模内,PostgreSQL都可以扮演多面手的角色,一个组件当多种组件使。虽然在某些领域它可能比不上专用组件,至少都做的都还不赖。而单一数据组件选型可以极大地削减项目额外复杂度,这意味着能节省很多成本。它让十个人才能搞定的事,变成一个人就能搞定的事。

​ 为了不需要的规模而设计是白费功夫,实际上这属于过早优化的一种形式。只有当没有单个软件能满足你的所有需求时,才会存在分拆集成的利弊权衡。集成多种异构技术是相当棘手的工作,如果真有那么一样技术可以满足你所有的需求,那么使用该技术就是最佳选择,而不是试图用多个组件来重新实现它。

​ 当业务规模增长到一定量级时,可能不得不使用基于微服务/总线的架构,将数据库的功能分拆为多个组件。但PostgreSQL的存在极大地推后了这个权衡到来的阈值,而且分拆之后依然能继续发挥重要作用。

运维友好

当然除了功能强大之外,Pg的另外一个重要的优势就是运维友好。有很多非常实用的特性:

  • DDL能放入事务中,删表,TRUNCATE,创建函数,索引,都可以放在事务里原子生效,或者回滚。

    这就能进行很多骚操作,比如在一个事务里通过RENAME,完成两张表的王车易位。

  • 能够并发地创建、删除索引,添加非空字段,重整索引与表(不锁表)。

    这意味着可以随时在线上不停机进行重大的模式变更,按需对索引进行优化。

  • 复制方式多样:段复制,流复制,触发器复制,逻辑复制,插件复制等等。

    这使得不停服务迁移数据变得相当容易:复制,改读,改写三步走,线上迁移稳如狗。

  • 提交方式多样:异步提交,同步提交,法定人数同步提交。

    这意味着Pg允许在C和A之间做出权衡与选择,例如交易库使用同步提交,普通库使用异步提交。

  • 系统视图非常完备,做监控系统相当简单。

  • FDW的存在让ETL变得无比简单,一行SQL就能解决。

    FDW可以方便地让一个实例访问其他实例的数据或元数据。在跨分区操作,数据库监控指标收集,数据迁移等场景中妙用无穷。同时还可以对接很多异构数据系统。

生态健康

​ PostgreSQL的生态也很健康,社区相当活跃。

​ 相比MySQL,PostgreSQL的一个巨大的优势就是协议友好。PG采用类似BSD/MIT的PostgreSQL协议,差不多理解为只要别打着Pg的旗号出去招摇撞骗,随便你怎么搞,换皮出去卖都行。君不见多少国产数据库,或者不少“自研数据库”实际都是Pg的换皮或二次开发产品。

​ 当然,也有很多衍生产品会回馈主干,比如timescaledb, pipelinedb, citus 这些基于PG的“数据库”,最后都变成了原生PG的插件。很多时候你想实现个什么功能,一搜就能找到对应的插件或实现。开源嘛,还是要讲一些情怀的。

​ Pg的代码质量相当之高,注释写的非常清晰。C的代码读起来有种Go的感觉,代码都可以当文档看了。能从中学到很多东西。相比之下,其他数据库,比如MongoDB,看一眼我就放弃了读下去的兴趣。

​ 而MySQL呢,社区版采用的是GPL协议,这其实挺蛋疼的。要不是GPL传染,怎么会有这么多基于MySQL改的数据库开源出来呢?而且MySQL还在乌龟壳的手里,让自己的蛋蛋攥在别人手中可不是什么明智的选择,更何况是业界毒瘤呢?Facebook修改React协议的风波就算是一个前车之鉴了。

问题

当然,要说有什么缺点或者遗憾,那还是有几个的:

  • 因为使用了MVCC,数据库需要定期VACUUM,需要定期维护表和索引避免性能下降。
  • 没有很好的开源集群监控方案(或者太丑!),需要自己做。
  • 慢查询日志和普通日志是混在一起的,需要自己解析处理。
  • 官方Pg没有很好用的列存储,对数据分析而言算一个小遗憾。

当然都是些无关痛痒的小毛小病,不过真正的问题可能和技术无关……

​ 说到底,MySQL确实是最流行的开源关系型数据库,没办法,写Java的,写PHP的,很多人最开始用的都是MySQL…,所以Pg招人相对困难是一个事实,很多时候只能自己培养。不过看DB Engines上的流行度趋势,未来还是很光明的。

dbrank

其他

​ 学PostgreSQL是一件很有趣的事,它让我意识到数据库的功能远远不止增删改查。我学着SQL Server与MySQL迈进数据库的大门。但却是PostgreSQL真正向我展示了数据库的奇妙世界。

​ 之所以写本文,是因为在知乎上的老坟又被挖了出来,让笔者回想起当年邂逅PostgreSQL时的青葱岁月。(https://www.zhihu.com/question/20010554/answer/94999834 )当然,现在我干了专职的PG DBA,忍不住再给这老坟补几铲。“王婆卖瓜,自卖自夸”,夸一夸PG也是应该的。嘿嘿嘿……

全栈工程师就该用全栈数据库嘛。

​ 我自己比较选型过MySQL和PostgreSQL,难得地在阿里这种MySQL的世界中有过选择的自由。我认为单从技术因素上来讲,PG是完爆MySQL的。尽管阻力很大,最后还是把PostgreSQL用了起来,推了起来。我用它做过很多项目,解决了很多需求(小到算统计报表,大到给公司创收个小目标)。大多数需求PG单挑就搞定了,少部分也会再用些MQ和NoSQL(Redis,MongoDB,Cassandra/HBase)。Pg实在是让人爱不释手。

最后实在是对Pg爱不释手,以至于专职去研究PG了。

在我的第一份工作中就深刻尝到了甜头,使用PostgreSQL,一个人的开发效率能顶一个小团队:

  • 后端懒得写怎么办,PostGraphQL直接从数据库模式定义生成GraphQL API,自动监听DDL变更,生成相应的CRUD方法与存储过程包装,对于后台开发再方便不过,类似的工具还有PostgREST与pgrest。对于中小数据量的应用都还堪用,省了一大半后端开发的活。

  • 需要用到Redis的功能,直接上Pg,模拟普通功能不在话下,缓存也省了。Pub/Sub使用Notify/Listen/Trigger实现,用来广播配置变更,做一些控制非常方便。

  • 需要做分析,窗口函数,复杂JOIN,CUBE,GROUPING,自定义聚合,自定义语言,爽到飞起。如果觉得规模大了想scale out可以上citus扩展(或者换greenplum);比起数仓可能少个列存比较遗憾,但其他该有的都有了。

  • 用到地理相关的功能,PostGIS堪称神器,千行代码才能实现的复杂地理需求,一行SQL轻松高效解决

  • 存储时序数据,timescaledb扩展虽然比不上专用时序数据库,但百万记录每秒的入库速率还是有的。用它解决过硬件传感器日志存储,监控系统Metrics存储的需求。

  • 一些流计算的相关功能,可以用PipelineDB直接定义流式视图实现:UV,PV,用户画像实时呈现。

  • PostgreSQL的FDW是一种强大的机制,允许接入各种各样的数据源,以统一的SQL接口访问。它妙用无穷:

    • file_fdw这种自带的扩展,可以将任意程序的输出接入数据表。最简单的应用就是监控系统信息
    • 管理多个PostgreSQL实例时,可以在一个元数据库中用自带的postgres_fdw导入所有远程数据库的数据字典。统一访问所有数据库实例的元数据,一行SQL拉取所有数据库的实时指标,监控系统做起来不要太爽。
    • 之前做过的一件事就是用hbase_fdw和MongoFDW,将HBase中的历史批量数据,MongoDB中的当日实时数据包装为PostgreSQL数据表,一个视图就简简单单地实现了融合批处理与流处理的Lambda架构。
    • 使用redis_fdw进行缓存更新推送;使用mongo_fdw完成从mongo到pg的数据迁移;使用mysql_fdw读取MySQL数据并存入数仓;实现跨数据库,甚至跨数据组件的JOIN;使用一行SQL就能完成原本多少行代码才能实现的复杂ETL,这是一件多么美妙的事情。
  • 各种丰富的类型与方法支持:例如JSON,从数据库直接生成前端所需的JSON响应,轻松而惬意。范围类型,优雅地解决很多原本需要程序处理的边角情况。其他的例如数组,多维数组,自定义类型,枚举,网络地址,UUID,ISBN。很多开箱即用的数据结构让程序员省去了多少造轮子的功夫。

  • 丰富的索引类型:通用的Btree索引;大幅优化顺序访问的Brin索引;等值查询的Hash索引;GIN倒排索引;GIST通用搜索树,高效支持地理查询,KNN查询;Bitmap同时利用多个独立索引;Bloom高效过滤索引;能大幅减小索引大小的条件索引;能优雅替代冗余字段的函数索引。而MySQL就只有那么可怜的几种索引。

  • 稳定可靠,正确高效。MVCC轻松实现快照隔离,MySQL的RR隔离等级实现不完善,无法避免PMP与G-single异常。而且基于锁与回滚段的实现会有各种坑;PostgreSQL通过SSI能实现高性能的可序列化。

  • 复制强大:WAL段复制,流复制(v9出现,同步、半同步、异步),逻辑复制(v10出现:订阅/发布),触发器复制,第三方复制,各种复制一应俱全。

  • 运维友好:可以将DDL放在事务中执行(可回滚),创建索引不锁表,添加新列(不带默认值)不锁表,清理/备份不锁表。各种系统视图,监控功能都很完善。

  • 扩展众多、功能丰富、可定制程度极强。在PostgreSQL中可以使用任意的语言编写函数:Python,Go,Javascript,Java,Shell等等。与其说Pg是数据库,不如说它是一个开发平台。我就试过很多没什么卵用但很好玩的东西:**数据库里(in-db)**的爬虫/ 推荐系统 / 神经网络 / Web服务器等等。有着各种功能强悍或脑洞清奇的第三方插件:https://pgxn.org

  • PostgreSQL的License友好,BSD随便玩,君不见多少数据库都是PG的换皮产品。MySQL有GPL传染,还要被Oracle捏着蛋蛋。

微信公众号原文

区块链与分布式数据库

区块链的技术本质、提供的功能、及演化方向就是分布式数据库

区块链的本质,想提供的功能,及其演化方向,就是分布式数据库。

确切的讲,是拜占庭容错(抗恶意节点攻击)的分布式(无领导者复制)数据库

如果这种分布式数据库用来存储各种币的交易记录,这个系统就叫做所谓的“XX币”。例如以太坊就是这样一个分布式数据库,上面除了记载着各种山寨币的交易记录,还可以记载各种奇奇怪怪的内容。花一点以太币,就可以在这个分布式数据库里留下一条记录(一封信)。而所谓智能合约就是这个分布式数据库上的存储过程

从形式上看,区块链与**预写式日志(Write-Ahead-Log, WAL, Binlog, Redolog)**在设计原理上是高度一致的。

WAL是数据库的核心数据结构,记录了从数据库创建之初到当前时刻的所有变更,用于实现主从复制、备份回滚、故障恢复等功能。如果保留了全量的WAL日志,就可以从起点回放WAL,时间旅行到任意时刻的状态,如PostgreSQL的PITR。

区块链其实就是这样一份日志,它记录了从创世以来的每笔Transaction。回放日志就可以还原数据库任意时刻的状态(反之则不成立)。所以区块链当然可以算作某种意义上的数据库。

区块链的两大特性:去中心化与防篡改,用数据库的概念也很好理解:

  • 去中心化的实质就是无领导者复制(leaderless replication),核心在于分布式共识
  • 防篡改的实质就是拜占庭容错,即,使得篡改WAL的计算代价在概率上不可行

正如WAL分为日志段,区块链也被划分为一个一个**区块,**且每一段带有先前日志段的哈希指纹。

所谓挖矿就是一个公开的猜数字比快游戏(满足条件的数字才会被共识承认),先猜中者能获取下一个日志段的初夜权:向日志段里写一笔向自己转账的记录(就是挖矿的奖励),并广播出去(如果别人也猜中了,以先广播至多数为准)。所有节点通过共识算法,保证当前最长的链为权威日志版本。区块链通过共识算法实现日志段的无主复制

而如果想要修改某个WAL日志段中的一比交易记录,比如,转给自己一万个比特币,需要把这个区块以及其后所有区块的指纹给凑出来(连猜几次数字),并让多数节点相信这个伪造版本才行(拼一个更长的伪造版本,意味着猜更多次数字)。比特币中六个区块确认一个交易就是这个意思,篡改六个日志段之前的记录的算例代价,通常在概率上是不可行的。区块链通过这种机制(如Merkle树)实现拜占庭容错

区块链涉及到的相关技术中,除了分布式共识外都很简单,但这种应用方式机制设计确实是相当惊艳的。区块链可以算是一次数据库的演化尝试,长期来看前景广阔。但搞链能立竿见影起作用的领域,好像都是老大哥的地盘。而且不管怎么吹嘘,现在的区块链离真正意义上的分布式数据库还差的太远,所以现在入场搞应用的大概率都是先烈。

原文知乎链接

一致性:过载的术语

一致性这个词重载的很厉害,在不同的语境和上下文中,它其实代表着不同的东西:

一致性这个词重载的很厉害,在不同的语境和上下文中,它其实代表着不同的东西:

  • 在事务的上下文中,比如ACID里的C,指的就是通常的一致性(Consistency)
  • 在分布式系统的上下文中,例如CAP里的C,实际指的是线性一致性(Linearizability)
  • 此外,“一致性哈希”,“最终一致性”这些名词里的“一致性”也有不同的涵义。

这些一致性彼此不同却又有着千丝万缕的联系,所以经常会把人绕晕。

​ 在事务的上下文中,一致性(Consistency) 的概念是:对数据的一组特定陈述必须始终成立。即不变量(invariants)。具体到分布式事务的上下文中这个不变量是:所有参与事务的节点状态保持一致:要么全部成功提交,要么全部失败回滚,不会出现一些节点成功一些节点失败的情况。

​ 在分布式系统的上下文中,线性一致性(Linearizability) 的概念是:多副本的系统能够对外表现地像只有单个副本一样(系统保证从任何副本读取到的值都是最新的),且所有操作都以原子的方式生效(一旦某个新值被任一客户端读取到,后续任意读取不会再返回旧值)。

​ 线性一致性这个词可能有些陌生,但说起它的另一个名字大家就清楚了:强一致性(strong consistency) ,当然还有一些诨名:原子一致性(atomic consistency),立即一致性(immediate consistency)外部一致性(external consistency ) 说的都是它。

这两个“一致性”完全不是一回事儿,但之间其实有着微妙的联系,它们之间的桥梁就是共识(Consensus)

简单来说:

  • 分布式事务一致性会因为协调者单点引入可用性问题
  • 为了解决可用性问题,分布式事务的节点需要在协调者故障时就新协调者选取达成共识
  • 解决共识问题等价于实现一个线性一致的存储
  • 解决共识问题等价于实现全序广播(total order boardcast)
  • Paxos/Raft 实现了全序广播

具体来讲

为了保证分布式事务的一致性,分布式事务通常需要一个协调者(Coordinator)/事务管理器(Transaction Manager)来决定事务的最终提交状态。但无论2PC还是3PC,都无法应对协调者失效的问题,而且具有扩大故障的趋势。这就牺牲了可靠性、可维护性与可扩展性。为了让分布式事务真正可用,就需要在协调者挂点的时候能赶快选举出一个新的协调者来解决分歧,这就需要所有节点对谁是Boss达成共识(Consensus)

共识意味着让几个节点就某事达成一致,可以用来确定一些互不相容的操作中,哪一个才是赢家。共识问题通常形式化如下:一个或多个节点可以提议(propose)某些值,而共识算法决定采用其中的某个值。在保证分布式事务一致性的场景中,每个节点可以投票提议,并对谁是新的协调者达成共识。

​ 共识问题与许多问题等价,两个最典型的问题就是:

  • 实现一个具有线性一致性的存储系统
  • 实现全序广播(保证消息不丢失,且消息以相同的顺序传递给每个节点。)

Raft算法解决了全序广播问题。维护多副本日志间的一致性,其实就是让所有节点对同全局操作顺序达成一致,也其实就是让日志系统具有线性一致性。 因而解决了共识问题。(当然正因为共识问题与实现强一致存储问题等价,Raft的具体实现etcd 其实就是一个线性一致的分布式数据库。)

总结一下:

线性一致性是一个精确定义的术语,线性一致性是一种 一致性模型 ,对分布式系统的行为作出了很强的保证。

分布式事务中的一致性则与事务ACID中的C一脉相承,并不是一个严格的术语。(因为什么叫一致,什么叫不一致其实是应用说了算。在分布式事务的场景下可以认为是:所有节点的事务状态始终保持相同

分布式事务本身的一致性是通过协调者内部的原子操作与多阶段提交协议保证的,不需要共识;但解决分布式事务一致性带来的可用性问题需要用到共识。

推荐阅读:

为什么要学习数据库原理

计算机系为什么要学数据库原理和设计?

问题

计算机系为什么要学数据库原理和设计?

我们学校开了数据库系统原理课程。但是我还是很迷茫,这几节课老师一上来就讲一堆令人头大的名词概念,我以为我们知道“如何设计构建表”,“如何mysql增删改查”就行了……那为什么还要了解关系模式的表示方法,计算,规范化……概念模型……各种模型的相互转换,为什么还要了解什么关系代数,什么笛卡尔积……这些的理论知识。我十分困惑,通过这些理论概念,该课的目的或者说该书的目的究竟是想让学生学会什么呢?

回答

​ 只会写代码的是码农;学好数据库,基本能混口饭吃;在此基础上再学好操作系统和计算机网络,就能当一个不错的程序员。如果能再把离散数学、数字电路、体系结构、数据结构/算法、编译原理学通透,再加上丰富的实践经验与领域特定知识,就能算是一个优秀的工程师了。(前端算IO密集型应用就别抬杠了)

计算机其实就是存储/IO/CPU三大件; 而计算说穿了就是两个东西:数据与算法(状态与转移函数)。常见的软件应用,除了各种模拟仿真、模型训练、视频游戏这些属于计算密集型应用外,绝大多数都属于数据密集型应用。从最抽象的意义上讲,这些应用干的事儿就是把数据拿进来,存进数据库,需要的时候再拿出来。

​ 抽象是应对复杂度的最强武器。操作系统提供了对存储的基本抽象:内存寻址空间与磁盘逻辑块号。文件系统在此基础上提供了文件名到地址空间的KV存储抽象。而数据库则在其基础上提供了对应用通用存储需求的高级抽象

​ 在真实世界中,除非准备从基础组件的轮子造起,不然根本没那么多机会去摆弄花哨的数据结构和算法(对数据密集型应用而言)。甚至写代码的本事可能也没那么重要:可能只会有那么一两个Ad Hoc算法需要在应用层实现,大部分需求都有现成的轮子可以使用,主要的创造性工作往往是在数据模型设计上。实际生产中,数据表就是数据结构,索引与查询就是算法。而应用代码往往扮演的是胶水的角色,处理IO与业务逻辑,其他大部分的工作都是在数据系统之间搬运数据

​ 在最宽泛的意义上,有状态的地方就有数据库。它无所不在,网站的背后、应用的内部,单机软件,区块链里,甚至在离数据库最远的Web浏览器中,也逐渐出现了其雏形:各类状态管理框架与本地存储。“数据库”可以简单地只是内存中的哈希表/磁盘上的日志,也可以复杂到由多种数据系统集成而来。关系型数据库只是数据系统的冰山一角(或者说冰山之巅),实际上存在着各种各样的数据系统组件:

  • 数据库:存储数据,以便自己或其他应用程序之后能再次找到(PostgreSQL,MySQL,Oracle)
  • 缓存:记住开销昂贵操作的结果,加快读取速度(Redis,Memcached)
  • 搜索索引:允许用户按关键字搜索数据,或以各种方式对数据进行过滤(ElasticSearch)
  • 流处理:向其他进程发送消息,进行异步处理(Kafka,Flink)
  • 批处理:定期处理累积的大批量数据(Hadoop)

​ **架构师最重要的能力之一,就是了解这些组件的性能特点与应用场景,能够灵活地权衡取舍、集成拼接这些数据系统。**绝大多数工程师都不会去从零开始编写存储引擎,因为在开发应用时,数据库已经是足够完美的工具了。关系型数据库则是目前所有数据系统中使用最广泛的组件,可以说是程序员吃饭的主要家伙,重要性不言而喻。

了解意义(WHY)比了解方法(HOW)更重要。但一个很遗憾的现实是,以大多数学生,甚至相当一部分公司能够接触到的现实问题而言,拿几个文件甚至在内存里放着估计都能应付大多数场景了(需求简单到低级抽象就可以Handle)。没什么机会接触到数据库真正要解决的问题,也就难有真正使用与学习数据库的驱动力,更别提数据库原理了。当软硬件故障把数据搞成一团浆糊(可靠性);当单表超出了内存大小,并发访问的用户增多(可扩展性),当代码的复杂度发生爆炸,开发陷入泥潭(可维护性),人们才会真正意识到数据库的重要性。所以我也理解当前这种填鸭教学现状的苦衷:工作之后很难有这么大把的完整时间来学习原理了,所以老师只好先使劲灌输,多少让学生对这些知识有个印象。等学生参加工作后真正遇到这些问题,也许会想起大学好像还学了个叫数据库的东西,这些知识就会开始反刍。


​ 数据库,尤其是关系型数据库,非常重要。那为什么要学习其原理呢?

​ 对优秀的工程师来说,只会数据库是远远不够的。学习原理对于当CRUD BOY搬砖收益并不大,但当通用组件真的无解需要自己撸起袖子上时,没有金坷垃怎么种庄稼?设计系统时,理解原理能让你以最少的复杂度代价写出更可靠高效的代码;遇到疑难杂症需要排查时,理解原理能带来精准的直觉与深刻的洞察。

​ 数据库是一个博大精深的领域,存储I/O计算无所不包。其主要原理也可以粗略分为几个部分:数据模型设计原理(应用)、存储引擎原理(基础)、索引与查询优化器的原理(性能)、事务与并发控制的原理(正确性)、故障恢复与复制系统的原理(可靠性)。 所有的原理都有其存在意义:为了解决实际问题。

​ 例如数据模型设计中范式理论,就是为了解决数据冗余这一问题而提出的,它是为了把事情做漂亮(可维护)。它是模型设计中一个很重要的设计权衡:通常而言,冗余少则复杂度小/可维护性强,冗余高则性能好。比如用了冗余字段,那更新时原本一条SQL就搞定的事情,现在现在就要用两条SQL更新两个地方,需要考虑多对象事务,以及并发执行时可能的竞态条件。这就需要仔细权衡利弊,选择合适的规范化等级。数据模型设计,就是生产中的数据结构设计不了解这些原理,就难以提取良好的抽象,其他工作也就无从谈起。

​ 而关系代数与索引的原理,则在查询优化中扮演重要的角色,它是为了把事情做得快(性能,可扩展)。当数据量越来越大,SQL写的越来越复杂时,它的意义就会体现出来:**怎样写出等价但是更高效的查询?**当查询优化器没那么智能时,就需要人来干这件事。这种优化往往成本极小而收益巨大,比如一个需要几秒的KNN查询,如果知道R树索引的原理,就可以通过改写查询,创建GIST索引优化到1毫秒内,千倍的性能提升。**不了解索引与查询设计原理,就难以充分发挥数据库的性能。**​

​ 事务与并发控制的原理,是为了把事情做正确(可靠性)。事务是数据处理领域最伟大的抽象之一,它提供了很多有用的保证(ACID),但这些保证到底意味着什么?事务的原子性让你在提交前能随时中止事务并丢弃所有写入,相应地,事务的持久性则承诺一旦事务成功提交,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。这让错误处理变得无比简单:要么成功完事,要么失败重试。有了后悔药,程序员不用再担心半路翻车会留下惨不忍睹的车祸现场了。

​ 另一方面,事务的隔离性则保证同时执行的事务无法相互影响(Serializable), 数据库提供了不同的隔离等级保证,以供程序员在性能与正确性之间进行权衡。编写并发程序并不容易,在几万TPS的负载下,各种极低概率,匪夷所思的问题都会出现:事务之间相互踩踏,丢失更新,幻读与写入偏差,慢查询拖慢快查询导致连接堆积,单表数据库并发增大后的性能急剧恶化,甚至快慢查询都减少但因比例变化导致的灵异抽风。这些问题,在低负载的情况下会潜伏着,随着规模量级增长突然跳出来,给你一个大大的惊喜。现实中真正可能出现的各类异常,也绝非SQL标准中简单的几种异常能说清的。 不理解事务的原理,意味着应用的可靠性可能遭受不必要的损失。

​ 故障恢复与复制的原理,可能对于程序员没有那么重要,但架构师与DBA必须清楚。高可用是很多应用的追求目标,但什么是高可用,高可用怎么保证?读写分离?快慢分离?异地多活?x地x中心?说穿了底下的核心技术其实就是复制(Replication)(或再加上自动故障切换(Failover))。这里有无穷无尽的坑:复制延迟带来的各种灵异现象,网络分区与脑裂,存疑事务blahblah。不理解复制的原理,高可用就无从谈起。

​ 对于一些程序员而言,可能数据库就是“增删改查”,包一包接口,原理似乎属于“屠龙之技”。如果止步于此,那原理确实没什么好学的,但有志者应当打破砂锅问到底的精神。私认为只了解自己本领域知识是不够的,只有把当前领域赖以建立的上层领域摸清楚,才能称为专家。在数据库面前,后端也是前端;对于程序员知识而言,数据库是一个合适的栈底。


​ 上面讲了WHY,下面就说一下 HOW

​ 数据库教学的一个矛盾是:如果连数据库都不会用,那学数据库原理有个卵用呢?

​ 学数据库的原则是学以致用只有实践,才能带来对问题的深刻理解;只有先知其然,才有条件去知其所以然。教材可以先草草的过一遍,然后直接去看数据库文档,上手去把数据库用起来,做个东西出来。通过实践掌握数据库的使用,再去学习原理就会事半功倍(以及充满动力)。对于学习而言,有条件去实习当然最好,没有条件那最好的办法就是自己创造场景,自己挖掘需求。

​ 比如,从解决个人需求开始:管理个人密码,体重跟踪,记账,做个小网站、在线聊天小程序。当它演化的越来越复杂,开始有多个用户,出现各种蛋疼问题之后,你就会开始意识到事务的意义。

​ 再比如,结合爬虫,抓一些房价、股价、地理、社交网络的数据存在数据库里,做一些挖掘与分析。当你积累的数据越来越多,分析查询越来越复杂;SQL长得没法读,跑起来慢出猪叫,这时候关系代数的理论就能指导你进一步进行优化。

​ 当你意识到这些设计都是为了解决现实生产中的问题,并亲自遇到过这些问题之后,再去学习原理,才能相互印证,并知其所以然。当你发现查询时间随数据增长而指数增长时;当你遇到成千上万的用户同时读写为并发控制焦头烂额时;当你碰上软硬件故障把数据搅得稀巴烂时;当你发现数据冗余让代码复杂度快速爆炸时;你就会发现这些设计存在的意义。

​ 教材、书籍、文档、视频、邮件组、博客都是很好的学习资源。教材的话华章的黑皮系列教材都还不错,《数据库系统概念》这本就挺好的。但我推荐先看看这本书:《设计数据密集型应用》 ,写的非常好,我觉得不错就义务翻译了一下。纸上得来终觉浅,绝知此事要躬行。实践方能出真知,新手上路选哪家?个人推荐世界上最先进的开源关系型数据库PostgreSQL,设计优雅,功能强大。传教就有请德哥出场了:https://github.com/digoal/blog 。有时间的话可以再看看Redis,源码简单易读,实践中也很常用,非关系型数据库也应当多了解一下。

​ 最后,关系型数据库虽然强大,却绝非数据处理的终章,尽可能多地去尝试各种各样的数据库吧。

知乎原文链接

微信公众号原文

开发

关于使用PostgreSQL进行开发的经验

PostgreSQL高级模糊查询

如何在PostgreSQL中实现比较复杂的模糊查询逻辑?

PostgreSQL高级模糊查询

日常开发中,经常见到有模糊查询的需求。今天就简单聊一聊如何用PostgreSQL实现一些高级一点的模糊查询。

当然这里说的模糊查询,不是LIKE表达式前模糊后模糊两侧模糊,这种老掉牙的东西。让我们直接用一个具体的例子开始吧。

问题

现在,假设我们做了个应用商店,想给用户提供搜索功能。用户随便输入点什么,找出所有与输入内容匹配的应用,排个序返回给用户。

严格来说,这种需求其实是需要一个搜索引擎,最好还是用专用软件,例如ElasticSearch来搞。但实际上只要不是特别复杂的逻辑,也可以很好的用PostgreSQL实现。

数据

样例数据如下所示,一张应用表。抽除了所有无关字段,就留下一个应用名称name作为主键。

CREATE TABLE app(name TEXT PRIMARY KEY); 
-- COPY app FROM '/tmp/app.csv';

里面的数据差不多长这样,中英混杂,共计150万条。

Rome travel guide, rome italy map rome tourist attractions directions to colosseum, vatican museum, offline ATAC city rome bus tram underground train maps, 罗马地图,罗马地铁,罗马火车,罗马旅行指南"""
Urban Pics - 游戏俚语词典
世界经典童话故事大全(6到12岁少年儿童睡前故事英语亲子软件) 2 - 高级版
星征服者
客房控制系统
Santa ME! - 易圣诞老人,小精灵快乐的脸效果!

输入

用户在搜索框可能输入的东西,差不多就跟你自己在应用商店搜索框里会键入的东西差不多。“天气”,“外卖”,“交友”……

而我们想做到的效果,跟你对应用商店查询返回结果的期待也差不多。当然是越准确越好,最好还能按相关度排个序。

当然,作为一个生产级的应用,还必须能及时响应。不可以全表扫描,得用到索引。

那么,这类问题怎么解呢?

解题思路

针对这一问题,有三种解题思路。

  • 基于LIKE的模式匹配。
  • 基于pg_trgm的字符串相似度的匹配
  • 基于自定义分词与倒排索引的模糊查询

LIKE模式匹配

最简单粗暴的方式就是使用 LIKE '%' 模式匹配查询。

老生常谈,没啥技术含量。把用户输入的关键词前后加一个百分号,然后执行这种查询:

SELECT * FROM app WHERE name LIKE '%支付宝%';

前后模糊的查询可以通过常规的Btree索引进行加速,注意在PostgreSQL中使用 LIKE查询时不要掉到LC_COLLATE的坑里去了,详情参考这篇文章:PG中的本地化排序规则

CREATE INDEX ON app(name COLLATE "C");          -- 后模糊
CREATE INDEX ON app(reverse(name) COLLATE "C"); -- 前模糊

如果用户的输入非常精准清晰,这样的方式也不是不可以。响应速度也不错。但有两个问题:

  • 太机械死板,假设应用厂商发了个名字,在原来的关键词里面加了个空格或者什么符号,这种查询立刻就失效了。

  • 没有距离度量,我们没有一个合适的度量,来排序返回的结果。说如果返回几百个结果没有排序,那很难让用户满意的。

  • 有时候准确度还是不行,比如一些应用做SEO,把各种头部应用的名字都嵌到自己的名字中来提高搜索排名。

PG TRGM

PostgreSQL自带了一个名为pg_trgm的扩展,提供的基于三字符语素的模糊查询。

pg_trgm模块提供用于决定基于 trigram 匹配的字母数字文本相似度的函数和操作符,以及支持快速搜索相似字符串的索引操作符类。

使用方式

-- 使用trgm操作符提取关键词素,并建立gist索引
CREATE INDEX ON app USING gist (name gist_trgm_ops);

查询方式也很直观,直接使用% 运算符即可,比如从应用表中查到与支付宝相关的应用。

SELECT name, similarity(name, '支付宝') AS sim FROM app 
WHERE name % '支付宝'  ORDER BY 2 DESC;

         name          |     sim
-----------------------+------------
 支付宝 - 让生活更简单 | 0.36363637
 支付搜                | 0.33333334
 支付社                | 0.33333334
 支付啦                | 0.33333334
(4 rows)

Time: 231.872 ms

Sort  (cost=177.20..177.57 rows=151 width=29) (actual time=251.969..251.970 rows=4 loops=1)
"  Sort Key: (similarity(name, '支付宝'::text)) DESC"
  Sort Method: quicksort  Memory: 25kB
  ->  Index Scan using app_name_idx1 on app  (cost=0.41..171.73 rows=151 width=29) (actual time=145.414..251.956 rows=4 loops=1)
        Index Cond: (name % '支付宝'::text)
Planning Time: 2.331 ms
Execution Time: 252.011 ms

该方式的优点是

  • 提供了字符串的距离函数similarity,可以给出两个字符串之间相似程度的定性度量。因此可以排序。
  • 提供了基于3字符组合的分词函数show_trgm
  • 可以利用索引加速查询。
  • SQL查询语句非常简单清晰,索引定义也很简单明了,维护简单

该方式的缺点是:

  • 关键词很短的情况(1-2汉字)的情况下召回率很差,特别是只有一个字时,是无法查询出结果的
  • 执行效率较低,例如上面这个查询使用了200ms
  • 定制性太差,只能使用它自己定义的逻辑来定义字符串的相似度,而且这个度量对于中文的效果相当存疑(中文三字词频率很低)
  • LC_CTYPE有特殊的要求,默认LC_CTYPE = C 无法正确对中文进行分词。

特殊问题

pg_trgm的最大问题是,无法在LC_CTYPE = C的实例上针对中文使用。因为 LC_CTYPE=C 缺少一些字符的分类定义。不幸的是LC_CTYPE一旦设置,基本除了重新建库是没法更改的

通常来说,PostgreSQL的Locale应当设置为C,或者至少将本地化规则中的排序规则LC_COLLATE 设置为C,以避免巨大的性能损失与功能缺失。但是因为pg_trgm的这个“问题”,您需要在创建库时,即指定LC_CTYPE = <non-C-locale>。这里基于i18n的LOCALE从原理上应该都可以使用。常见的en_USzh_CN都是可以的。但注意特别注意,macOS上对Locale的支持存在问题。过于依赖LOCALE的行为会降低代码的可移植性。

高级模糊查询

实现一个高级的模糊查询,需要两样东西:分词倒排索引

高级模糊查询,或者说全文检索基于以下思路实现:

  • 分词:在维护阶段,每一个被模糊搜索的字段(例如应用名称),都会被分词逻辑加工处理成一系列关键词。
  • 索引:在数据库中建立关键词到表记录的倒排索引
  • 查询:将查询同样拆解为关键词,然后利用查询关键词通过倒排索引找出相关的记录来。

PostgreSQL内建了很多语言的分词程序,可以自动将文档拆分为一系列的关键词,是为全文检索功能。可惜中文还是比较复杂,PG并没有内建的中文分词逻辑,虽然有一些第三方扩展,诸如 pg_jieba, zhparser等,但也年久失修,在新版本的PG上能不能用还是一个问题。

但是这并不影响我们利用PostgreSQL提供的基础设施实现高级模糊查询。实际上上面说的分词逻辑是为了从一个很大的文本(例如网页)中抽取摘要信息(关键字)。而我们的需求恰恰相反,不仅不是抽取摘要进行概括精简,而且需要将关键词扩充,以实现特定的模糊需求。例如,我们完全可以在抽取应用名称关键词的过程中,把这些关键词的汉语拼音,首音缩写,英文缩写一起放进关键词列表中,甚至把作者,公司,分类,等一系列用户可能感兴趣的东西放进去。这样搜索的时候就可以使用丰富的输入了。

基本框架

我们先来构建整个问题解决的框架。

  1. 编写一个自定义的分词函数,从名称中抽取关键词(每个字,每个二字短语,拼音,英文缩写,放什么都可以)
  2. 在目标表上创建一个使用分词函数的函数表达式GIN索引。
  3. 通过数组操作或 tsquery 等方式定制你的模糊查询
-- 创建一个分词函数
CREATE OR REPLACE FUNCTION tokens12(text) returns text[] as $$....$$;

-- 基于该分词函数创建表达式索引
CREATE INDEX ON app USING GIN(tokens12(name));

-- 使用关键词进行复杂的定制查询(关键词数组操作)
SELECT * from app where split_to_chars(name) && ARRAY['天气'];

-- 使用关键词进行复杂的定制查询(tsquery操作)
SELECT * from app where to_tsvector123(name) @@ 'BTC &! 钱包 & ! 交易 '::tsquery;

PostgreSQL 提供了GIN索引,可以很好的支持倒排索引的功能,比较麻烦的是寻找一种比较合适的中文分词插件。将应用名称分解为一系列关键词。好在对于此类模糊查询的需求,也用不着像搞搜索引擎,自然语言处理那么精细的语义解析。只要参考pg_trgm的思路把中文也给手动一锅烩了就行。除此之外,通过自定义的分词逻辑,还可以实现很多有趣的功能。比如使用拼音模糊查询,使用拼音首字母缩写模糊查询

让我们从最简单的分词开始。

快速开始

首先来定义一个非常简单粗暴的分词函数,它只是把输入拆分成2字词语的组合。

-- 创建分词函数,将字符串拆为单字,双字组成的词素数组
CREATE OR REPLACE FUNCTION tokens12(text) returns text[] AS $$
DECLARE
    res TEXT[];
BEGIN
    SELECT regexp_split_to_array($1, '') INTO res;
    FOR i in 1..length($1) - 1 LOOP
            res := array_append(res, substring($1, i, 2));
    END LOOP;
    RETURN res;
END;
$$ LANGUAGE plpgsql STRICT PARALLEL SAFE IMMUTABLE;

使用这个分词函数,可以将一个应用名称肢解为一系列的语素

SELECT tokens2('艾米莉的埃及历险记');
-- {艾米,米莉,莉的,的埃,埃及,及历,历险,险记}

现在假设用户搜索关键词“艾米利”,这个关键词被拆分为:

SELECT tokens2('艾米莉');
-- {艾米,米莉}

然后,我们可以通过以下查询非常迅速地,找到所有包含这两个关键词素的记录:

SELECT * FROM app WHERE tokens2(name) @> tokens2('艾米莉');
 美味餐厅 - 艾米莉的圣诞颂歌
 美味餐厅 - 艾米莉的瓶中信笺
 小清新艾米莉
 艾米莉的埃及历险记
 艾米莉的极地大冒险
 艾米莉的万圣节历险记
 6rows / 0.38ms

这里通过关键词数组的倒排索引,可以快速实现前后模糊的效果。

这里的条件比较严格,应用需要完整的包含两个关键词才会匹配。

如果我们改用更宽松的条件来执行模糊查询,例如,只要包含任意一个语素:

SELECT * FROM app WHERE tokens2(name) && tokens2('艾米莉');

 AR艾米互动故事-智慧妈妈必备
 Amy and train 艾米和小火车
 米莉·马洛塔的涂色探索
 给利伴_艾米罗公司旗下专业购物返利网
 艾米团购
 记忆游戏 - 米莉和泰迪
 (56 row ) / 0.4 ms

那么可供近一步筛选的应用候选集就更宽泛了。同时执行时间也并没有发生巨大的变化。

更近一步,我们并不需要在查询中使用完全一致的分词逻辑,完全可以手工进行精密的查询控制。

我们完全可以通过数组的布尔运算,控制哪些关键词是我们想要的,哪些是不想要的,哪些可选,哪些必须。

-- 包含关键词 微信、红包,但不包含 ‘支付’ (1ms | 11 rows)
SELECT * FROM app WHERE tokens2(name) @> ARRAY['微信','红包'] 
AND NOT tokens2(name) @> ARRAY['支付'];

当然,也可以对返回的结果进行相似度排序。一种常用的字符串似度衡量是L式编辑距离,即一个字符串最少需要多少次单字编辑才能变为另一个字符串。这个距离函数levenshtein 在PG的官方扩展包fuzzystrmatch中提供。

-- 包含关键词 微信 的应用,按照L式编辑距离排序 ( 1.1 ms | 10 rows)
-- create extension fuzzystrmatch;
SELECT name, levenshtein(name, '微信') AS d 
FROM app WHERE tokens12(name) @> ARRAY['微信'] 
ORDER BY 2 LIMIT 10;

 微信           | 0
 微信读书       | 2
 微信趣图       | 2
 微信加密       | 2
 企业微信       | 2
 微信通助手     | 3
 微信彩色消息   | 4
 艺术微信平台网 | 5
 涂鸦画板- 微信 | 6
 手写板for微信  | 6

改进全文检索方式

接下来,我们可以对分词的方式进行一些改进:

  • 缩小关键词范围:将标点符号从关键词中移除,将语气助词(的得地,啊唔之乎者也)之类排除掉。(可选)
  • 扩大关键词列表:将已有关键词的汉语拼音,首字母缩写一并加入关键词列表。
  • 优化关键词大小:针对单字,3字短语,4字成语进行提取与优化。中文不同于英文,英文拆分为3字符的小串效果很好,中文信息密度更大,单字或双字就有很大的区分度了。
  • 去除重复关键词:例如前后重复出现,或者通假字,同义词之类的。
  • 跨语言分词处理,例如中西夹杂的名称,我们可以分别对中英文进行处理,中日韩字符采用中式分词处理逻辑,英文字母使用常规的pg_trgm处理逻辑。

实际上也不一定用得着这些逻辑,而这些逻辑也不一定非要在数据库里用存储过程实现。比较好的方式当然是在外部读取数据库然后使用专用的分词库和自定义业务逻辑来进行分词,分完之后再回写到数据表的另一列上。

当然这里出于演示目的,我们就直接用存储过程直接上了,实现一个比较简单的改进版分词逻辑。

CREATE OR REPLACE FUNCTION cjk_to_tsvector(_src text) RETURNS tsvector AS $$
DECLARE
    res TEXT[]:= show_trgm(_src);
    cjk TEXT; -- 中日韩连续文本段
BEGIN
    FOR cjk IN SELECT unnest(i) FROM regexp_matches(_src,'[\u4E00-\u9FCC\u3400-\u4DBF\u20000-\u2A6D6\u2A700-\u2B81F\u2E80-\u2FDF\uF900-\uFA6D\u2F800-\u2FA1B]+','g') regex(i) LOOP
            FOR i in 1..length(cjk) - 1 LOOP
                    res := array_append(res, substring(cjk, i, 2));
                END LOOP; -- 将每个中日韩连续文本段两字词语加入列表
        END LOOP;
    return array_to_tsvector(res);
end
$$ LANGUAGE PlPgSQL PARALLEL SAFE COST 100 STRICT IMMUTABLE;


-- 如果需要使用标签数组的方式,可以使用此函数。
CREATE OR REPLACE FUNCTION cjk_to_array(_src text) RETURNS TEXT[] AS $$
BEGIN
    RETURN tsvector_to_array(cjk_to_tsvector(_src));
END
$$ LANGUAGE PlPgSQL PARALLEL SAFE COST 100 STRICT IMMUTABLE;

-- 创建分词专用函数索引
CREATE INDEX ON app USING GIN(cjk_to_array(name));

基于 tsvector

除了基于数组的运算之外,PostgreSQL还提供了tsvectortsquery类型,用于全文检索。

我们可以使用这两种类型的运算取代数组之间的运算,写出更灵活的查询来:

CREATE OR REPLACE FUNCTION to_tsvector123(src text) RETURNS tsvector AS $$
DECLARE
    res TEXT[];
    n INTEGER:= length(src);
begin
    SELECT regexp_split_to_array(src, '') INTO res;
    FOR i in 1..n - 2 LOOP res := array_append(res, substring(src, i, 2));res := array_append(res, substring(src, i, 3)); END LOOP;
    res := array_append(res, substring(src, n-1, 2));
    SELECT array_agg(distinct i) INTO res FROM (SELECT i FROM unnest(res) r(i) EXCEPT SELECT * FROM (VALUES(' '),(','),('的'),('。'),('-'),('.')) c ) d; -- optional (normalize)
    RETURN array_to_tsvector(res);
end
$$ LANGUAGE PlPgSQL PARALLEL SAFE COST 100 STRICT IMMUTABLE;

-- 使用自定义分词函数,创建函数表达式索引
CREATE INDEX ON app USING GIN(to_tsvector123(name));

使用tsvector进行查询的方式也相当直观

-- 包含 '学英语' 和 '雅思'
SELECT * from app where to_tsvector123(name) @@ '学英语 & 雅思'::tsquery;

-- 所有关于 'BTC' 但不含'钱包' '交易'字样的应用
SELECT * from app where to_tsvector123(name) @@ 'BTC &! 钱包 & ! 交易 '::tsquery;

参考文章:

PostgreSQL 模糊查询最佳实践 - (含单字、双字、多字模糊查询方法)

https://developer.aliyun.com/article/672293

PgSQL JsonPath

有了JSONPATH,PostgreSQL用户就能以一种简洁而高效的方式操作JSON数据。

PostgreSQL 12 JSON

PostgreSQL 12 已经正式放出了Beta1测试版本。PostgreSQL12带来了很多给力的新功能,其中最有吸引力的特性之一莫过于新的JSONPATH支持。在以前的版本中,虽说PostgreSQL在JSON功能的支持上已经很不错了,但要实现一些特定的功能,还是需要写复杂难懂的SQL或者存储过程才能实现。

有了JSONPATH,PostgreSQL用户就能以一种简洁而高效的方式操作JSON数据。

8.14.6 jsonpath类型

jsonpath类型实现了对PostgreSQL中 SQL / JSON路径语言的支持,以有效地查询JSON数据。它提供了已解析的SQL / JSON路径表达式的二进制表示,该表达式指定路径引擎从JSON数据中检索的项目,以便使用SQL / JSON查询函数进行进一步处理。

SQL / JSON路径语言完全集成到SQL引擎中:其谓词和运算符的语义通常遵循SQL。同时,为了提供一种最自然的JSON数据处理方式,SQL / JSON路径语法使用了一些JavaScript约定:

  • Dot .用于成员访问。
  • 方括号[]用于数组访问。
  • SQL / JSON数组是0相对的,不像从1开始的常规SQL数组。

SQL / JSON路径表达式是SQL字符串文字,因此在传递给SQL / JSON查询函数时必须用单引号括起来。遵循JavaScript约定,路径表达式中的字符串文字必须用双引号括起来。此字符串文字中的任何单引号必须使用SQL约定的单引号进行转义。

路径表达式由一系列路径元素组成,可以是以下内容:

  • JSON基元类型的路径文字:Unicode文本,数字,true,false或null。
  • 路径变量列于表8.24
  • 表8.25中列出了访问者运算符。
  • jsonpath第9.15.1.2节中列出的运算符和方法
  • 括号,可用于提供过滤器表达式或定义路径评估的顺序。

有关在jsonpathSQL / JSON查询函数中使用表达式的详细信息,请参见第9.15.1节

表8.24。 jsonpath变量

变量 描述
$ 表示要查询的JSON文本的变量(上下文项)。
$varname 一个命名变量。其值必须在PASSINGSQL / JSON查询函数的子句中设置。详情。
@ 表示过滤器表达式中路径评估结果的变量。

表8.25。 jsonpath访问器

访问者操作员 描述
.*key*``."$*varname*" 成员访问器,返回具有指定键的对象成员。如果键名是以符号$或不符合标识符的JavaScript规则的命名变量,则必须将其括在双引号中作为字符串文字。
.* 通配符成员访问器,返回位于当前对象顶级的所有成员的值。
.** 递归通配符成员访问器,它处理当前对象的所有级别的JSON层次结构,并返回所有成员值,而不管其嵌套级别如何。这是SQL / JSON标准的PostgreSQL扩展。
.**{*level*}``.**{*lower_level* to*upper_level*}``.**{*lower_level* to last} .**,但是使用JSON层次结构的嵌套级别进行过滤。级别指定为整数。零级别对应于当前对象。这是SQL / JSON标准的PostgreSQL扩展。
[*subscript*, ...] 数组元素访问器。*subscript*可能有两种形式:*expr*或。第一种形式通过索引指定单个数组元素。第二种形式通过索引范围指定数组切片。零索引对应于第一个数组元素。*lower_expr* to *upper_expr*下标中的表达式可以包括整数,数值表达式或jsonpath返回单个数值的任何其他表达式。的last关键字可以在表达式表示在阵列中的最后一个下标来使用。这对处理未知长度的数组很有帮助。
[*] 返回所有数组元素的通配符数组元素访问器。

[6]为此,术语 “ 值 ”包括数组元素,尽管JSON术语有时会认为数组元素与对象内的值不同。

PgSQL前后端通信协议

了解PostgreSQL服务器与客户端通信使用的TCP协议

了解PostgreSQL服务器与客户端通信使用的TCP协议

启动阶段

基本流程

  • 客户端发送一条StartupMessage (F)向服务端发起连接请求

    载荷包括0x30000的Int32版本号魔数,以及一系列kv结构的运行时参数(NULL0分割,必须参数为user),

  • 客户端等待服务端响应,主要是等待服务端发送的ReadyForQuery (Z)事件,该事件代表服务端已经准备好接收请求。

上面是连接建立过程中最主要的两个事件,其他事件包括包括认证消息 AuthenticationXXX (R) ,后端密钥消息 BackendKeyData (K),错误消息ErrorResponse (E),一系列上下文无关消息(NoticeResponse (N)NotificationResponse (A)ParameterStatus(S)

模拟客户端

编写一个go程序模拟这一过程

package main

import (
	"fmt"
	"net"
	"time"

	"github.com/jackc/pgx/pgproto3"
)

func GetFrontend(address string) *pgproto3.Frontend {
	conn, _ := (&net.Dialer{KeepAlive: 5 * time.Minute}).Dial("tcp4", address)
	frontend, _ := pgproto3.NewFrontend(conn, conn)
	return frontend
}

func main() {
	frontend := GetFrontend("127.0.0.1:5432")

	// 建立连接
	startupMsg := &pgproto3.StartupMessage{
		ProtocolVersion: pgproto3.ProtocolVersionNumber,
		Parameters:      map[string]string{"user": "vonng"},
	}
	frontend.Send(startupMsg)

	// 启动过程,收到ReadyForQuery消息代表启动过程结束
	for {
		msg, _ := frontend.Receive()
		fmt.Printf("%T %v\n", msg, msg)
		if _, ok := msg.(*pgproto3.ReadyForQuery); ok {
			fmt.Println("[STARTUP] connection established")
			break
		}
	}

	// 简单查询协议
	simpleQueryMsg := &pgproto3.Query{String: `SELECT 1 as a;`}
	frontend.Send(simpleQueryMsg)
	// 收到CommandComplete消息代表查询结束
	for {
		msg, _ := frontend.Receive()
		fmt.Printf("%T %v\n", msg, msg)
		if _, ok := msg.(*pgproto3.CommandComplete); ok {
			fmt.Println("[QUERY] query complete")
			break
		}
	}
}

输出结果为:

*pgproto3.Authentication &{0 [0 0 0 0] [] []}
*pgproto3.ParameterStatus &{application_name }
*pgproto3.ParameterStatus &{client_encoding UTF8}
*pgproto3.ParameterStatus &{DateStyle ISO, MDY}
*pgproto3.ParameterStatus &{integer_datetimes on}
*pgproto3.ParameterStatus &{IntervalStyle postgres}
*pgproto3.ParameterStatus &{is_superuser on}
*pgproto3.ParameterStatus &{server_encoding UTF8}
*pgproto3.ParameterStatus &{server_version 11.3}
*pgproto3.ParameterStatus &{session_authorization vonng}
*pgproto3.ParameterStatus &{standard_conforming_strings on}
*pgproto3.ParameterStatus &{TimeZone PRC}
*pgproto3.BackendKeyData &{35703 345830596}
*pgproto3.ReadyForQuery &{73}
[STARTUP] connection established
*pgproto3.RowDescription &{[{a 0 0 23 4 -1 0}]}
*pgproto3.DataRow &{[[49]]}
*pgproto3.CommandComplete &{SELECT 1}
[QUERY] query complete

连接代理

可以在jackc/pgx/pgproto3的基础上,很轻松地编写一些中间件。例如下面的代码就是一个非常简单的“连接代理”:

package main

import (
	"io"
	"net"
	"strings"
	"time"

	"github.com/jackc/pgx/pgproto3"
)

type ProxyServer struct {
	UpstreamAddr string
	ListenAddr   string
	Listener     net.Listener
	Dialer       net.Dialer
}

func NewProxyServer(listenAddr, upstreamAddr string) *ProxyServer {
	ln, _ := net.Listen(`tcp4`, listenAddr)
	return &ProxyServer{
		ListenAddr:   listenAddr,
		UpstreamAddr: upstreamAddr,
		Listener:     ln,
		Dialer:       net.Dialer{KeepAlive: 1 * time.Minute},
	}
}

func (ps *ProxyServer) Serve() error {
	for {
		conn, err := ps.Listener.Accept()
		if err != nil {
			panic(err)
		}
		go ps.ServeOne(conn)
	}
}

func (ps *ProxyServer) ServeOne(clientConn net.Conn) error {
	backend, _ := pgproto3.NewBackend(clientConn, clientConn)
	startupMsg, err := backend.ReceiveStartupMessage()
	if err != nil && strings.Contains(err.Error(), "ssl") {
		if _, err := clientConn.Write([]byte(`N`)); err != nil {
			panic(err)
		}
		// ssl is not welcome, now receive real startup msg
		startupMsg, err = backend.ReceiveStartupMessage()
		if err != nil {
			panic(err)
		}
	}

	serverConn, _ := ps.Dialer.Dial(`tcp4`, ps.UpstreamAddr)
	frontend, _ := pgproto3.NewFrontend(serverConn, serverConn)
	frontend.Send(startupMsg)

	errChan := make(chan error, 2)
	go func() {
		_, err := io.Copy(clientConn, serverConn)
		errChan <- err
	}()
	go func() {
		_, err := io.Copy(serverConn, clientConn)
		errChan <- err
	}()

	return <-errChan
}

func main() {
	proxy := NewProxyServer("127.0.0.1:5433", "127.0.0.1:5432")
	proxy.Serve()
}

这里代理监听5433端口,并将消息解析并转发至在5432端口的真实的数据库服务器。在另一个Session中执行以下命令:

$ psql postgres://127.0.0.1:5433/data?sslmode=disable -c 'SELECT * FROM pg_stat_activity LIMIT 1;'

可以观察到这一过程中的消息往来:

[B2F] *pgproto3.ParameterStatus &{application_name psql}
[B2F] *pgproto3.ParameterStatus &{client_encoding UTF8}
[B2F] *pgproto3.ParameterStatus &{DateStyle ISO, MDY}
[B2F] *pgproto3.ParameterStatus &{integer_datetimes on}
[B2F] *pgproto3.ParameterStatus &{IntervalStyle postgres}
[B2F] *pgproto3.ParameterStatus &{is_superuser on}
[B2F] *pgproto3.ParameterStatus &{server_encoding UTF8}
[B2F] *pgproto3.ParameterStatus &{server_version 11.3}
[B2F] *pgproto3.ParameterStatus &{session_authorization vonng}
[B2F] *pgproto3.ParameterStatus &{standard_conforming_strings on}
[B2F] *pgproto3.ParameterStatus &{TimeZone PRC}
[B2F] *pgproto3.BackendKeyData &{41588 1354047533}
[B2F] *pgproto3.ReadyForQuery &{73}
[F2B] *pgproto3.Query &{SELECT * FROM pg_stat_activity LIMIT 1;}
[B2F] *pgproto3.RowDescription &{[{datid 11750 1 26 4 -1 0} {datname 11750 2 19 64 -1 0} {pid 11750 3 23 4 -1 0} {usesysid 11750 4 26 4 -1 0} {usename 11750 5 19 64 -1 0} {application_name 11750 6 25 -1 -1 0} {client_addr 11750 7 869 -1 -1 0} {client_hostname 11750 8 25 -1 -1 0} {client_port 11750 9 23 4 -1 0} {backend_start 11750 10 1184 8 -1 0} {xact_start 11750 11 1184 8 -1 0} {query_start 11750 12 1184 8 -1 0} {state_change 11750 13 1184 8 -1 0} {wait_event_type 11750 14 25 -1 -1 0} {wait_event 11750 15 25 -1 -1 0} {state 11750 16 25 -1 -1 0} {backend_xid 11750 17 28 4 -1 0} {backend_xmin 11750 18 28 4 -1 0} {query 11750 19 25 -1 -1 0} {backend_type 11750 20 25 -1 -1 0}]}
[B2F] *pgproto3.DataRow &{[[] [] [52 56 55 52] [] [] [] [] [] [] [50 48 49 57 45 48 53 45 49 56 32 50 48 58 52 56 58 49 57 46 51 50 55 50 54 55 43 48 56] [] [] [] [65 99 116 105 118 105 116 121] [65 117 116 111 86 97 99 117 117 109 77 97 105 110] [] [] [] [] [97 117 116 111 118 97 99 117 117 109 32 108 97 117 110 99 104 101 114]]}
[B2F] *pgproto3.CommandComplete &{SELECT 1}
[B2F] *pgproto3.ReadyForQuery &{73}
[F2B] *pgproto3.Terminate &{}

PgSQL事务隔离等级

PostgreSQL实际上只有两种事务隔离等级:读已提交(Read Commited)可序列化(Serializable)

PostgreSQL 事务隔离等级

基础

SQL标准定义了四种隔离级别,但PostgreSQL实际上只有两种事务隔离等级:读已提交(Read Commited)可序列化(Serializable)

SQL标准定义了四种隔离级别,但实际上这也是很粗鄙的一种划分。详情请参考并发异常那些事

查看/设置事务隔离等级

通过执行:SELECT current_setting('transaction_isolation'); 可以查看当前事务隔离等级。

通过在事务块顶部执行 SET TRANSACTION ISOLATION LEVEL { SERIALIZABLE | REPEATABLE READ | READ COMMITTED | READ UNCOMMITTED } 来设定事务的隔离等级。

或者为当前会话生命周期设置事务隔离等级:

SET SESSION CHARACTERISTICS AS TRANSACTION transaction_mode

Actual isolation level P4 G-single G2-item G2
RC(monotonic atomic views) - - - -
RR(snapshot isolation) - -
Serializable

隔离等级与并发问题

创建测试表 t ,并插入两行测试数据。

CREATE TABLE t (k INTEGER PRIMARY KEY, v int);
TRUNCATE t; INSERT INTO t VALUES (1,10), (2,20);

更新丢失(P4)

PostgreSQL的 读已提交RC 隔离等级无法阻止丢失更新的问题,但可重复读隔离等级则可以。

丢失更新,顾名思义,就是一个事务的写入覆盖了另一个事务的写入结果。

在读已提交隔离等级下,无法阻止丢失更新的问题,考虑一个计数器并发更新的例子,两个事务同时从计数器中读取出值,加1后写回原表。

T1 T2 Comment
begin;
begin;
SELECT v FROM t WHERE k = 1 T1读
SELECT v FROM t WHERE k = 1 T2读
update t set v = 11 where k = 1; T1写
update t set v = 11 where k = 1; T2因T1阻塞
COMMIT T2恢复,写入
COMMIT T2写入覆盖T1

解决这个问题有两种方式,使用原子操作,或者在可重复读的隔离等级执行事务。

使用原子操作的方式为:

T1 T2 Comment
begin;
begin;
update t set v = v+1 where k = 1; T1写
update t set v = v + 1 where k = 1; T2因T1阻塞
COMMIT T2恢复,写入
COMMIT T2写入覆盖T1

解决这个问题有两种方式,使用原子操作,或者在可重复读的隔离等级执行事务。

在可重复读的隔离等级

读已提交(RC)

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2

update t set v = 11 where k = 1; -- T1
update t set v = 12 where k = 1; -- T2, BLOCKS
update t set v = 21 where k = 2; -- T1

commit; -- T1. This unblocks T2
select * from t; -- T1. Shows 1 => 11, 2 => 21
update t set v = 22 where k = 2; -- T2


commit; -- T2
select * from test; -- either. Shows 1 => 12, 2 => 22
T1 T2 Comment
begin; set transaction isolation level read committed;
begin; set transaction isolation level read committed;
update t set v = 11 where k = 1;
update t set v = 12 where k = 1; T2会等待T1持有的锁
SELECT * FROM t 2:20, 1:11
update pair set v = 21 where k = 2;
commit; T2解锁
select * from pair; T2看见T1的结果和自己的修改
update t set v = 22 where k = 2
commit

提交后的结果

1

 relname | locktype | virtualtransaction |  pid  |       mode       | granted | fastpath
---------+----------+--------------------+-------+------------------+---------+----------
 t_pkey  | relation | 4/578              | 37670 | RowExclusiveLock | t       | t
 t       | relation | 4/578              | 37670 | RowExclusiveLock | t       | t
 relname | locktype | virtualtransaction |  pid  |       mode       | granted | fastpath
---------+----------+--------------------+-------+------------------+---------+----------
 t_pkey  | relation | 4/578              | 37670 | RowExclusiveLock | t       | t
 t       | relation | 4/578              | 37670 | RowExclusiveLock | t       | t
 t_pkey  | relation | 6/494              | 37672 | RowExclusiveLock | t       | t
 t       | relation | 6/494              | 37672 | RowExclusiveLock | t       | t
 t       | tuple    | 6/494              | 37672 | ExclusiveLock    | t       | f
 relname | locktype | virtualtransaction |  pid  |       mode       | granted | fastpath
---------+----------+--------------------+-------+------------------+---------+----------
 t_pkey  | relation | 4/578              | 37670 | RowExclusiveLock | t       | t
 t       | relation | 4/578              | 37670 | RowExclusiveLock | t       | t
 t_pkey  | relation | 6/494              | 37672 | RowExclusiveLock | t       | t
 t       | relation | 6/494              | 37672 | RowExclusiveLock | t       | t
 t       | tuple    | 6/494              | 37672 | ExclusiveLock    | t       | f

Testing PostgreSQL transaction isolation levels

These tests were run with Postgres 9.3.5.

Setup (before every test case):

create table test (id int primary key, value int);
insert into test (id, value) values (1, 10), (2, 20);

To see the current isolation level:

select current_setting('transaction_isolation');

Read Committed basic requirements (G0, G1a, G1b, G1c)

Postgres “read committed” prevents Write Cycles (G0) by locking updated rows:

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 12 where id = 1; -- T2, BLOCKS
update test set value = 21 where id = 2; -- T1
commit; -- T1. This unblocks T2
select * from test; -- T1. Shows 1 => 11, 2 => 21
update test set value = 22 where id = 2; -- T2
commit; -- T2
select * from test; -- either. Shows 1 => 12, 2 => 22

Postgres “read committed” prevents Aborted Reads (G1a):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
update test set value = 101 where id = 1; -- T1
select * from test; -- T2. Still shows 1 => 10
abort;  -- T1
select * from test; -- T2. Still shows 1 => 10
commit; -- T2

Postgres “read committed” prevents Intermediate Reads (G1b):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
update test set value = 101 where id = 1; -- T1
select * from test; -- T2. Still shows 1 => 10
update test set value = 11 where id = 1; -- T1
commit; -- T1
select * from test; -- T2. Now shows 1 => 11
commit; -- T2

Postgres “read committed” prevents Circular Information Flow (G1c):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 22 where id = 2; -- T2
select * from test where id = 2; -- T1. Still shows 2 => 20
select * from test where id = 1; -- T2. Still shows 1 => 10
commit; -- T1
commit; -- T2

Observed Transaction Vanishes (OTV)

Postgres “read committed” prevents Observed Transaction Vanishes (OTV):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
begin; set transaction isolation level read committed; -- T3
update test set value = 11 where id = 1; -- T1
update test set value = 19 where id = 2; -- T1
update test set value = 12 where id = 1; -- T2. BLOCKS
commit; -- T1. This unblocks T2
select * from test where id = 1; -- T3. Shows 1 => 11
update test set value = 18 where id = 2; -- T2
select * from test where id = 2; -- T3. Shows 2 => 19
commit; -- T2
select * from test where id = 2; -- T3. Shows 2 => 18
select * from test where id = 1; -- T3. Shows 1 => 12
commit; -- T3

Predicate-Many-Preceders (PMP)

Postgres “read committed” does not prevent Predicate-Many-Preceders (PMP):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
select * from test where value = 30; -- T1. Returns nothing
insert into test (id, value) values(3, 30); -- T2
commit; -- T2
select * from test where value % 3 = 0; -- T1. Returns the newly inserted row
commit; -- T1

Postgres “repeatable read” prevents Predicate-Many-Preceders (PMP):

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where value = 30; -- T1. Returns nothing
insert into test (id, value) values(3, 30); -- T2
commit; -- T2
select * from test where value % 3 = 0; -- T1. Still returns nothing
commit; -- T1

Postgres “read committed” does not prevent Predicate-Many-Preceders (PMP) for write predicates – example from Postgres documentation:

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
update test set value = value + 10; -- T1
delete from test where value = 20;  -- T2, BLOCKS
commit; -- T1. This unblocks T2
select * from test where value = 20; -- T2, returns 1 => 20 (despite ostensibly having been deleted)
commit; -- T2

Postgres “repeatable read” prevents Predicate-Many-Preceders (PMP) for write predicates – example from Postgres documentation:

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
update test set value = value + 10; -- T1
delete from test where value = 20;  -- T2, BLOCKS
commit; -- T1. T2 now prints out "ERROR: could not serialize access due to concurrent update"
abort;  -- T2. There's nothing else we can do, this transaction has failed

Lost Update (P4)

Postgres “read committed” does not prevent Lost Update (P4):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
select * from test where id = 1; -- T1
select * from test where id = 1; -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 11 where id = 1; -- T2, BLOCKS
commit; -- T1. This unblocks T2, so T1's update is overwritten
commit; -- T2

Postgres “repeatable read” prevents Lost Update (P4):

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where id = 1; -- T1
select * from test where id = 1; -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 11 where id = 1; -- T2, BLOCKS
commit; -- T1. T2 now prints out "ERROR: could not serialize access due to concurrent update"
abort;  -- T2. There's nothing else we can do, this transaction has failed

Read Skew (G-single)

Postgres “read committed” does not prevent Read Skew (G-single):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
select * from test where id = 1; -- T1. Shows 1 => 10
select * from test where id = 1; -- T2
select * from test where id = 2; -- T2
update test set value = 12 where id = 1; -- T2
update test set value = 18 where id = 2; -- T2
commit; -- T2
select * from test where id = 2; -- T1. Shows 2 => 18
commit; -- T1

Postgres “repeatable read” prevents Read Skew (G-single):

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where id = 1; -- T1. Shows 1 => 10
select * from test where id = 1; -- T2
select * from test where id = 2; -- T2
update test set value = 12 where id = 1; -- T2
update test set value = 18 where id = 2; -- T2
commit; -- T2
select * from test where id = 2; -- T1. Shows 2 => 20
commit; -- T1

Postgres “repeatable read” prevents Read Skew (G-single) – test using predicate dependencies:

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where value % 5 = 0; -- T1
update test set value = 12 where value = 10; -- T2
commit; -- T2
select * from test where value % 3 = 0; -- T1. Returns nothing
commit; -- T1

Postgres “repeatable read” prevents Read Skew (G-single) – test using write predicate:

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where id = 1; -- T1. Shows 1 => 10
select * from test; -- T2
update test set value = 12 where id = 1; -- T2
update test set value = 18 where id = 2; -- T2
commit; -- T2
delete from test where value = 20; -- T1. Prints "ERROR: could not serialize access due to concurrent update"
abort; -- T1. There's nothing else we can do, this transaction has failed

Write Skew (G2-item)

Postgres “repeatable read” does not prevent Write Skew (G2-item):

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where id in (1,2); -- T1
select * from test where id in (1,2); -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 21 where id = 2; -- T2
commit; -- T1
commit; -- T2

Postgres “serializable” prevents Write Skew (G2-item):

begin; set transaction isolation level serializable; -- T1
begin; set transaction isolation level serializable; -- T2
select * from test where id in (1,2); -- T1
select * from test where id in (1,2); -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 21 where id = 2; -- T2
commit; -- T1
commit; -- T2. Prints out "ERROR: could not serialize access due to read/write dependencies among transactions"

Anti-Dependency Cycles (G2)

Postgres “repeatable read” does not prevent Anti-Dependency Cycles (G2):

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where value % 3 = 0; -- T1
select * from test where value % 3 = 0; -- T2
insert into test (id, value) values(3, 30); -- T1
insert into test (id, value) values(4, 42); -- T2
commit; -- T1
commit; -- T2
select * from test where value % 3 = 0; -- Either. Returns 3 => 30, 4 => 42

Postgres “serializable” prevents Anti-Dependency Cycles (G2):

begin; set transaction isolation level serializable; -- T1
begin; set transaction isolation level serializable; -- T2
select * from test where value % 3 = 0; -- T1
select * from test where value % 3 = 0; -- T2
insert into test (id, value) values(3, 30); -- T1
insert into test (id, value) values(4, 42); -- T2
commit; -- T1
commit; -- T2. Prints out "ERROR: could not serialize access due to read/write dependencies among transactions"

Postgres “serializable” prevents Anti-Dependency Cycles (G2) – Fekete et al’s example with two anti-dependency edges:

begin; set transaction isolation level serializable; -- T1
select * from test; -- T1. Shows 1 => 10, 2 => 20
begin; set transaction isolation level serializable; -- T2
update test set value = value + 5 where id = 2; -- T2
commit; -- T2
begin; set transaction isolation level serializable; -- T3
select * from test; -- T3. Shows 1 => 10, 2 => 25
commit; -- T3
update test set value = 0 where id = 1; -- T1. Prints out "ERROR: could not serialize access due to read/write dependencies among transactions"
abort; -- T1. There's nothing else we can do, this transaction has failed

PgSQL变更数据捕获

数据变更捕获是一种很有趣的ETL替代方案。

在实际生产中,我们经常需要把数据库的状态同步到其他地方去,例如同步到数据仓库进行分析,同步到消息队列供下游消费,同步到缓存以加速查询。总的来说,搬运状态有两大类方法:ETL与CDC。

前驱知识

CDC与ETL

数据库在本质上是一个状态集合,任何对数据库的变更(增删改)本质上都是对状态的修改。

在实际生产中,我们经常需要把数据库的状态同步到其他地方去,例如同步到数据仓库进行分析,同步到消息队列供下游消费,同步到缓存以加速查询。总的来说,搬运状态有两大类方法:ETL与CDC。

  • ETL(ExtractTransformLoad)着眼于状态本身,用定时批量轮询的方式拉取状态本身。

  • CDC(ChangeDataCapture)则着眼于变更,以流式的方式持续收集状态变化事件(变更)。

ETL大家都耳熟能详,每天批量跑ETL任务,从生产OLTP数据库 拉取(E)转换(T) 格式, 导入(L) 数仓,在此不赘述。相比ETL而言,CDC算是个新鲜玩意,随着流计算的崛起也越来越多地进入人们的视线。

变更数据捕获(change data capture, CDC)是一种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。 CDC很有意思,特别是当变更能在被写入数据库后立刻用于后续的流处理时。

例如用户可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引(e.g elasticsearch)。如果变更日志以相同的顺序应用,则可以预期的是,搜索索引中的数据与数据库中的数据是匹配的。同理,这些变更也可以应用于后台刷新缓存(redis),送往消息队列(Kafka),导入数据仓库(EventSourcing,存储不可变的事实事件记录而不是每天取快照),收集统计数据与监控(Prometheus),等等等等。在这种意义下,外部索引,缓存,数仓都成为了PostgreSQL在逻辑上的从库,这些衍生数据系统都成为了变更流的消费者,而PostgreSQL成为了整个数据系统的主库。在这种架构下,应用只需要操心怎样把数据写入数据库,剩下的事情交给CDC即可。系统设计可以得到极大地简化:所有的数据组件都能够自动与主库在逻辑上保证(最终)一致。用户不用再为如何保证多个异构数据系统之间数据同步而焦头烂额了。

实际上PostgreSQL自10.0版本以来提供的逻辑复制(logical replication)功能,实质上就是一个CDC应用:从主库上提取变更事件流:INSERT, UPDATE, DELETE, TRUNCATE,并在另一个PostgreSQL主库实例上重放。如果这些增删改事件能够被解析出来,它们就可以用于任何感兴趣的消费者,而不仅仅局限于另一个PostgreSQL实例。

逻辑复制

想在传统关系型数据库上实施CDC并不容易,关系型数据库本身的预写式日志WAL 实际上就是数据库中变更事件的记录。因此从数据库中捕获变更,基本上可以认为等价于消费数据库产生的WAL日志/复制日志。(当然也有其他的变更捕获方式,例如在表上建立触发器,当变更发生时将变更记录写入另一张变更日志表,客户端不断tail这张日志表,当然也有一定的局限性)。

大多数数据库的复制日志的问题在于,它们一直被当做数据库的内部实现细节,而不是公开的API。客户端应该通过其数据模型和查询语言来查询数据库,而不是解析复制日志并尝试从中提取数据。许多数据库根本没有记录在案的获取变更日志的方式。因此捕获数据库中所有的变更然后将其复制到其他状态存储(搜索索引,缓存,数据仓库)中是相当困难的。

此外,仅有 数据库变更日志仍然是不够的。如果你拥有 全量 变更日志,当然可以通过重放日志来重建数据库的完整状态。但是在许多情况下保留全量历史WAL日志并不是可行的选择(例如磁盘空间与重放耗时的限制)。 例如,构建新的全文索引需要整个数据库的完整副本 —— 仅仅应用最新的变更日志是不够的,因为这样会丢失最近没有更新过的项目。因此如果你不能保留完整的历史日志,那么你至少需要包留一个一致的数据库快照,并保留从该快照开始的变更日志。

因此实施CDC,数据库至少需要提供以下功能:

  1. 获取数据库的变更日志(WAL),并解码成逻辑上的事件(对表的增删改而不是数据库的内部表示)

  2. 获取数据库的"一致性快照",从而订阅者可以从任意一个一致性状态开始订阅而不是数据库创建伊始。

  3. 保存消费者偏移量,以便跟踪订阅者的消费进度,及时清理回收不用的变更日志以免撑爆磁盘。

我们会发现,PostgreSQL在实现逻辑复制的同时,已经提供了一切CDC所需要的基础设施。

  • 逻辑解码(Logical Decoding),用于从WAL日志中解析逻辑变更事件
  • 复制协议(Replication Protocol):提供了消费者实时订阅(甚至同步订阅)数据库变更的机制
  • 快照导出(export snapshot):允许导出数据库的一致性快照(pg_export_snapshot
  • 复制槽(Replication Slot),用于保存消费者偏移量,跟踪订阅者进度。

因此,在PostgreSQL上实施CDC最为直观优雅的方式,就是按照PostgreSQL的复制协议编写一个"逻辑从库" ,从数据库中实时地,流式地接受逻辑解码后的变更事件,完成自己定义的处理逻辑,并及时向数据库汇报自己的消息消费进度。就像使用Kafka一样。在这里CDC客户端可以将自己伪装成一个PostgreSQL的从库,从而不断地实时从PostgreSQL主库中接收逻辑解码后的变更内容。同时CDC客户端还可以通过PostgreSQL提供的复制槽(Replication Slot)机制来保存自己的消费者偏移量,即消费进度,实现类似消息队列一至少次的保证,保证不错过变更数据。(客户端自己记录消费者偏移量跳过重复记录,即可实现"恰好一次 “的保证 )

逻辑解码

在开始进一步的讨论之前,让我们先来看一看期待的输出结果到底是什么样子。

PostgreSQL的变更事件以二进制内部表示形式保存在预写式日志(WAL)中,使用其自带的pg_waldump工具可以解析出来一些人类可读的信息:

rmgr: Btree       len (rec/tot):     64/    64, tx:       1342, lsn: 2D/AAFFC9F0, prev 2D/AAFFC810, desc: INSERT_LEAF off 126, blkref #0: rel 1663/3101882/3105398 blk 4
rmgr: Heap        len (rec/tot):    485/   485, tx:       1342, lsn: 2D/AAFFCA30, prev 2D/AAFFC9F0, desc: INSERT off 10, blkref #0: rel 1663/3101882/3105391 blk 139

WAL日志里包含了完整权威的变更事件记录,但这种记录格式过于底层。用户并不会对磁盘上某个数据页里的二进制变更(文件A页面B偏移量C追加写入二进制数据D)感兴趣,他们感兴趣的是某张表中增删改了哪些行哪些字段。逻辑解码就是将物理变更记录翻译为用户期望的逻辑变更事件的机制(例如表A上的增删改事件)。

例如用户可能期望的是,能够解码出等价的SQL语句

INSERT INTO public.test (id, data) VALUES (14, 'hoho');

或者最为通用的JSON结构(这里以JSON格式记录了一条UPDATE事件)

{
  "change": [
    {
      "kind": "update",
      "schema": "public",
      "table": "test",
      "columnnames": ["id", "data" ],
      "columntypes": [ "integer", "text" ],
      "columnvalues": [ 1, "hoho"],
      "oldkeys": { "keynames": [ "id"],
        "keytypes": ["integer" ],
        "keyvalues": [1]
      }
    }
  ]
}

当然也可以是更为紧凑高效严格的Protobuf格式,更为灵活的Avro格式,抑或是任何用户感兴趣的格式。

逻辑解码 所要解决的问题,就是将数据库内部二进制表示的变更事件,解码(Decoding)成为用户感兴趣的格式。之所以需要这样一个过程,是因为数据库内部表示是非常紧凑的,想要解读原始的二进制WAL日志,不仅仅需要WAL结构相关的知识,还需要系统目录(System Catalog),即元数据。没有元数据就无从得知用户可能感兴趣的模式名,表名,列名,只能解析出来的一系列数据库自己才能看懂的oid。

关于流复制协议,复制槽,事务快照等概念与功能,这里就不展开了,让我们进入动手环节。

快速开始

假设我们有一张用户表,我们希望捕获任何发生在它上面的变更,假设数据库发生了如下变更操作

下面会重复用到这几条命令

DROP TABLE IF EXISTS users;
CREATE TABLE users(id SERIAL PRIMARY KEY, name TEXT);

INSERT INTO users VALUES (100, 'Vonng');
INSERT INTO users VALUES (101, 'Xiao Wang');
DELETE FROM users WHERE id = 100;
UPDATE users SET name = 'Lao Wang' WHERE id = 101;

最终数据库的状态是:只有一条(101, 'Lao Wang')的记录。无论是曾经有一个名为Vonng的用户存在过的痕迹,抑或是隔壁老王也曾年轻过的事实,都随着对数据库的删改而烟消云散。我们希望这些事实不应随风而逝,需要被记录下来。

操作流程

通常来说,订阅变更需要以下几步操作:

  • 选择一个一致性的数据库快照,作为订阅变更的起点。(创建一个复制槽)
  • (数据库发生了一些变更)
  • 读取这些变更,更新自己的的消费进度。

那么, 让我们先从最简单的办法开始,从PostgreSQL自带的的SQL接口开始

SQL接口

逻辑复制槽的增删查API:

TABLE pg_replication_slots; -- 查
pg_create_logical_replication_slot(slot_name name, plugin name) -- 增
pg_drop_replication_slot(slot_name name) -- 删

从逻辑复制槽中获取最新的变更数据:

pg_logical_slot_get_changes(slot_name name, ...)  -- 消费掉
pg_logical_slot_peek_changes(slot_name name, ...) -- 只查看不消费

在正式开始前,还需要对数据库参数做一些修改,修改wal_level = logical,这样在WAL日志中的信息才能足够用于逻辑解码。

-- 创建一个复制槽test_slot,使用系统自带的测试解码插件test_decoding,解码插件会在后面介绍
SELECT * FROM pg_create_logical_replication_slot('test_slot', 'test_decoding');

-- 重放上面的建表与增删改操作
-- DROP TABLE | CREATE TABLE | INSERT 1 | INSERT 1 | DELETE 1 | UPDATE 1

-- 读取复制槽test_slot中未消费的最新的变更事件流
SELECT * FROM  pg_logical_slot_get_changes('test_slot', NULL, NULL);
    lsn    | xid |                                data
-----------+-----+--------------------------------------------------------------------
 0/167C7E8 | 569 | BEGIN 569
 0/169F6F8 | 569 | COMMIT 569
 0/169F6F8 | 570 | BEGIN 570
 0/169F6F8 | 570 | table public.users: INSERT: id[integer]:100 name[text]:'Vonng'
 0/169F810 | 570 | COMMIT 570
 0/169F810 | 571 | BEGIN 571
 0/169F810 | 571 | table public.users: INSERT: id[integer]:101 name[text]:'Xiao Wang'
 0/169F8C8 | 571 | COMMIT 571
 0/169F8C8 | 572 | BEGIN 572
 0/169F8C8 | 572 | table public.users: DELETE: id[integer]:100
 0/169F938 | 572 | COMMIT 572
 0/169F970 | 573 | BEGIN 573
 0/169F970 | 573 | table public.users: UPDATE: id[integer]:101 name[text]:'Lao Wang'
 0/169F9F0 | 573 | COMMIT 573

-- 清理掉创建的复制槽
SELECT pg_drop_replication_slot('test_slot');

这里,我们可以看到一系列被触发的事件,其中每个事务的开始与提交都会触发一个事件。因为目前逻辑解码机制不支持DDL变更,因此CREATE TABLEDROP TABLE并没有出现在事件流中,只能看到空荡荡的BEGIN+COMMIT。另一点需要注意的是,只有成功提交的事务才会产生逻辑解码变更事件。也就是说用户不用担心收到并处理了很多行变更消息之后,最后发现事务回滚了,还需要担心怎么通知消费者去会跟变更。

通过SQL接口,用户已经能够拉取最新的变更了。这也就意味着任何有着PostgreSQL驱动的语言都可以通过这种方式从数据库中捕获最新的变更。当然这种方式实话说还是略过于土鳖。更好的方式是利用PostgreSQL的复制协议直接从数据库中订阅变更数据流。当然相比使用SQL接口,这也需要更多的工作。

使用客户端接收变更

在编写自己的CDC客户端之前,让我们先来试用一下官方自带的CDC客户端样例——pg_recvlogical。与pg_receivewal类似,不过它接收的是逻辑解码后的变更,下面是一个具体的例子:

# 启动一个CDC客户端,连接数据库postgres,创建名为test_slot的槽,使用test_decoding解码插件,标准输出
pg_recvlogical \
	-d postgres \
	--create-slot --if-not-exists --slot=test_slot \
	--plugin=test_decoding \
	--start -f -

# 开启另一个会话,重放上面的建表与增删改操作
# DROP TABLE | CREATE TABLE | INSERT 1 | INSERT 1 | DELETE 1 | UPDATE 1

# pg_recvlogical输出结果
BEGIN 585
COMMIT 585
BEGIN 586
table public.users: INSERT: id[integer]:100 name[text]:'Vonng'
COMMIT 586
BEGIN 587
table public.users: INSERT: id[integer]:101 name[text]:'Xiao Wang'
COMMIT 587
BEGIN 588
table public.users: DELETE: id[integer]:100
COMMIT 588
BEGIN 589
table public.users: UPDATE: id[integer]:101 name[text]:'Lao Wang'
COMMIT 589

# 清理:删除创建的复制槽
pg_recvlogical -d postgres --drop-slot --slot=test_slot

上面的例子中,主要的变更事件包括事务的开始结束,以及数据行的增删改。这里默认的test_decoding插件的输出格式为:

BEGIN {事务标识}
table {模式名}.{表名} {命令INSERT|UPDATE|DELETE}  {列名}[{类型}]:{取值} ...
COMMIT {事务标识}

实际上,PostgreSQL的逻辑解码是这样工作的,每当特定的事件发生(表的Truncate,行级别的增删改,事务开始与提交),PostgreSQL都会调用一系列的钩子函数。所谓的逻辑解码输出插件(Logical Decoding Output Plugin),就是这样一组回调函数的集合。它们接受二进制内部表示的变更事件作为输入,查阅一些系统目录,将二进制数据翻译成为用户感兴趣的结果。

逻辑解码输出插件

除了PostgreSQL自带的"用于测试"的逻辑解码插件:test_decoding 之外,还有很多现成的输出插件,例如:

当然还有PostgreSQL自带逻辑复制所使用的解码插件:pgoutput,其消息格式文档地址

安装这些插件非常简单,有一些插件(例如wal2json)可以直接从官方二进制源轻松安装。

yum install wal2json11
apt install postgresql-11-wal2json

或者如果没有二进制包,也可以自己下载编译。只需要确保pg_config已经在你的PATH中,然后执行make & sudo make install两板斧即可。以输出SQL格式的decoder_raw插件为例:

git clone https://github.com/michaelpq/pg_plugins && cd pg_plugins/decoder_raw
make && sudo make install

使用wal2json接收同样的变更

pg_recvlogical -d postgres --drop-slot --slot=test_slot
pg_recvlogical -d postgres --create-slot --if-not-exists --slot=test_slot \
	--plugin=wal2json --start -f -

结果为:

{"change":[]}
{"change":[{"kind":"insert","schema":"public","table":"users","columnnames":["id","name"],"columntypes":["integer","text"],"columnvalues":[100,"Vonng"]}]}
{"change":[{"kind":"insert","schema":"public","table":"users","columnnames":["id","name"],"columntypes":["integer","text"],"columnvalues":[101,"Xiao Wang"]}]}
{"change":[{"kind":"delete","schema":"public","table":"users","oldkeys":{"keynames":["id"],"keytypes":["integer"],"keyvalues":[100]}}]}
{"change":[{"kind":"update","schema":"public","table":"users","columnnames":["id","name"],"columntypes":["integer","text"],"columnvalues":[101,"Lao Wang"],"oldkeys":{"keynames":["id"],"keytypes":["integer"],"keyvalues":[101]}}]}

而使用decoder_raw获取SQL格式的输出

pg_recvlogical -d postgres --drop-slot --slot=test_slot
pg_recvlogical -d postgres --create-slot --if-not-exists --slot=test_slot \
	--plugin=decoder_raw --start -f -

结果为:

INSERT INTO public.users (id, name) VALUES (100, 'Vonng');
INSERT INTO public.users (id, name) VALUES (101, 'Xiao Wang');
DELETE FROM public.users WHERE id = 100;
UPDATE public.users SET id = 101, name = 'Lao Wang' WHERE id = 101;

decoder_raw可以用于抽取SQL形式表示的状态变更,将这些抽取得到的SQL语句在同样的基础状态上重放,即可得到相同的结果。PostgreSQL就是使用这样的机制实现逻辑复制的。

一个典型的应用场景就是数据库不停机迁移。在传统不停机迁移模式(双写,改读,改写)中,第三步改写完成后是无法快速回滚的,因为写入流量在切换至新主库后如果发现有问题想立刻回滚,老主库上会丢失一些数据。这时候就可以使用decoder_raw提取主库上的最新变更,并通过一行简单的Bash命令,将新主库上的变更实时同步到旧主库。保证迁移过程中任何时刻都可以快速回滚至老主库。

pg_recvlogical -d <new_master_url> --slot=test_slot --plugin=decoder_raw --start -f - |
psql <old_master_url>

另一个有趣的场景是UNDO LOG。PostgreSQL的故障恢复是基于REDO LOG的,通过重放WAL会到历史上的任意时间点。在数据库模式不发生变化的情况下,如果只是单纯的表内容增删改出现了失误,完全可以利用类似decoder_raw的方式反向生成UNDO日志。提高此类故障恢复的速度。

最后,输出插件可以将变更事件格式化为各种各样的形式。解码输出为Redis的kv操作,或者仅仅抽取一些关键字段用于更新统计数据或者构建外部索引,有着很大的想象空间。

编写自定义的逻辑解码输出插件并不复杂,可以参阅这篇官方文档。毕竟逻辑解码输出插件本质上只是一个拼字符串的回调函数集合。在官方样例的基础上稍作修改,即可轻松实现一个你自己的逻辑解码输出插件。

CDC客户端

PostgreSQL自带了一个名为pg_recvlogical的客户端应用,可以将逻辑变更的事件流写至标准输出。但并不是所有的消费者都可以或者愿意使用Unix Pipe来完成所有工作的。此外,根据端到端原则,使用pg_recvlogical将变更数据流落盘并不意味着消费者已经拿到并确认了该消息,只有消费者自己亲自向数据库确认才可以做到这一点。

编写PostgreSQL的CDC客户端程序,本质上是实现了一个"猴版”数据库从库。客户端向数据库建立一条复制连接(Replication Connection) ,将自己伪装成一个从库:从主库获取解码后的变更消息流,并周期性地向主库汇报自己的消费进度(落盘进度,刷盘进度,应用进度)。

复制连接

复制连接,顾名思义就是用于复制(Replication) 的特殊连接。当与PostgreSQL服务器建立连接时,如果连接参数中提供了replication=database|on|yes|1,就会建立一条复制连接,而不是普通连接。复制连接可以执行一些特殊的命令,例如IDENTIFY_SYSTEM, TIMELINE_HISTORY, CREATE_REPLICATION_SLOT, START_REPLICATION, BASE_BACKUP, 在逻辑复制的情况下,还可以执行一些简单的SQL查询。具体细节可以参考PostgreSQL官方文档中前后端协议一章:https://www.postgresql.org/docs/current/protocol-replication.html

譬如,下面这条命令就会建立一条复制连接:

$ psql 'postgres://localhost:5432/postgres?replication=on&application_name=mocker'

从系统视图pg_stat_replication可以看到主库识别到了一个新的"从库”

vonng=# table pg_stat_replication ;
-[ RECORD 1 ]----+-----------------------------
pid              | 7218
usesysid         | 10
usename          | vonng
application_name | mocker
client_addr      | ::1
client_hostname  |
client_port      | 53420

编写自定义逻辑

无论是JDBC还是Go语言的PostgreSQL驱动,都提供了相应的基础设施,用于处理复制连接。

这里让我们用Go语言编写一个简单的CDC客户端,样例使用了jackc/pgx,一个很不错的Go语言编写的PostgreSQL驱动。这里的代码只是作为概念演示,因此忽略掉了错误处理,非常Naive。将下面的代码保存为main.go,执行go run main.go即可执行。

默认的三个参数分别为数据库连接串,逻辑解码输出插件的名称,以及复制槽的名称。默认值为:

dsn := "postgres://localhost:5432/postgres?application_name=cdc"
plugin := "test_decoding"
slot := "test_slot"
go run main.go postgres:///postgres?application_name=cdc test_decoding test_slot

代码如下所示:

package main

import (
	"log"
	"os"
	"time"

	"context"
	"github.com/jackc/pgx"
)

type Subscriber struct {
	URL    string
	Slot   string
	Plugin string
	Conn   *pgx.ReplicationConn
	LSN    uint64
}

// Connect 会建立到服务器的复制连接,区别在于自动添加了replication=on|1|yes|dbname参数
func (s *Subscriber) Connect() {
	connConfig, _ := pgx.ParseURI(s.URL)
	s.Conn, _ = pgx.ReplicationConnect(connConfig)
}

// ReportProgress 会向主库汇报写盘,刷盘,应用的进度坐标(消费者偏移量)
func (s *Subscriber) ReportProgress() {
	status, _ := pgx.NewStandbyStatus(s.LSN)
	s.Conn.SendStandbyStatus(status)
}

// CreateReplicationSlot 会创建逻辑复制槽,并使用给定的解码插件
func (s *Subscriber) CreateReplicationSlot() {
	if consistPoint, snapshotName, err := s.Conn.CreateReplicationSlotEx(s.Slot, s.Plugin); err != nil {
		log.Fatalf("fail to create replication slot: %s", err.Error())
	} else {
		log.Printf("create replication slot %s with plugin %s : consist snapshot: %s, snapshot name: %s",
			s.Slot, s.Plugin, consistPoint, snapshotName)
		s.LSN, _ = pgx.ParseLSN(consistPoint)
	}
}

// StartReplication 会启动逻辑复制(服务器会开始发送事件消息)
func (s *Subscriber) StartReplication() {
	if err := s.Conn.StartReplication(s.Slot, 0, -1); err != nil {
		log.Fatalf("fail to start replication on slot %s : %s", s.Slot, err.Error())
	}
}

// DropReplicationSlot 会使用临时普通连接删除复制槽(如果存在),注意如果复制连接正在使用这个槽是没法删的。
func (s *Subscriber) DropReplicationSlot() {
	connConfig, _ := pgx.ParseURI(s.URL)
	conn, _ := pgx.Connect(connConfig)
	var slotExists bool
	conn.QueryRow(`SELECT EXISTS(SELECT 1 FROM pg_replication_slots WHERE slot_name = $1)`, s.Slot).Scan(&slotExists)
	if slotExists {
		if s.Conn != nil {
			s.Conn.Close()
		}
		conn.Exec("SELECT pg_drop_replication_slot($1)", s.Slot)
		log.Printf("drop replication slot %s", s.Slot)
	}
}

// Subscribe 开始订阅变更事件,主消息循环
func (s *Subscriber) Subscribe() {
	var message *pgx.ReplicationMessage
	for {
		// 等待一条消息, 消息有可能是真的消息,也可能只是心跳包
		message, _ = s.Conn.WaitForReplicationMessage(context.Background())
		if message.WalMessage != nil {
			DoSomething(message.WalMessage) // 如果是真的消息就消费它
			if message.WalMessage.WalStart > s.LSN { // 消费完后更新消费进度,并向主库汇报
				s.LSN = message.WalMessage.WalStart + uint64(len(message.WalMessage.WalData))
				s.ReportProgress()
			}
		}
		// 如果是心跳包消息,按照协议,需要检查服务器是否要求回送进度。
		if message.ServerHeartbeat != nil && message.ServerHeartbeat.ReplyRequested == 1 {
			s.ReportProgress() // 如果服务器心跳包要求回送进度,则汇报进度
		}
	}
}

// 实际消费消息的函数,这里只是把消息打印出来,也可以写入Redis,写入Kafka,更新统计信息,发送邮件等
func DoSomething(message *pgx.WalMessage) {
	log.Printf("[LSN] %s [Payload] %s", 
             pgx.FormatLSN(message.WalStart), string(message.WalData))
}

// 如果使用JSON解码插件,这里是用于Decode的Schema
type Payload struct {
	Change []struct {
		Kind         string        `json:"kind"`
		Schema       string        `json:"schema"`
		Table        string        `json:"table"`
		ColumnNames  []string      `json:"columnnames"`
		ColumnTypes  []string      `json:"columntypes"`
		ColumnValues []interface{} `json:"columnvalues"`
		OldKeys      struct {
			KeyNames  []string      `json:"keynames"`
			KeyTypes  []string      `json:"keytypes"`
			KeyValues []interface{} `json:"keyvalues"`
		} `json:"oldkeys"`
	} `json:"change"`
}

func main() {
	dsn := "postgres://localhost:5432/postgres?application_name=cdc"
	plugin := "test_decoding"
	slot := "test_slot"
	if len(os.Args) > 1 {
		dsn = os.Args[1]
	}
	if len(os.Args) > 2 {
		plugin = os.Args[2]
	}
	if len(os.Args) > 3 {
		slot = os.Args[3]
	}

	subscriber := &Subscriber{
		URL:    dsn,
		Slot:   slot,
		Plugin: plugin,
	}                                // 创建新的CDC客户端
	subscriber.DropReplicationSlot() // 如果存在,清理掉遗留的Slot

	subscriber.Connect()                   // 建立复制连接
	defer subscriber.DropReplicationSlot() // 程序中止前清理掉复制槽
	subscriber.CreateReplicationSlot()     // 创建复制槽
	subscriber.StartReplication()          // 开始接收变更流
	go func() {
		for {
			time.Sleep(5 * time.Second)
			subscriber.ReportProgress()
		}
	}()                                    // 协程2每5秒地向主库汇报进度
	subscriber.Subscribe()                 // 主消息循环
}

在另一个数据库会话中再次执行上面的变更,可以看到客户端及时地接收到了变更的内容。这里客户端只是简单地将其打印了出来,实际生产中,客户端可以完成任何工作,比如写入Kafka,写入Redis,写入磁盘日志,或者只是更新内存中的统计数据并暴露给监控系统。甚至,还可以通过配置同步提交,确保所有系统中的变更能够时刻保证严格同步(当然相比默认的异步模式比较影响性能就是了)。

对于PostgreSQL主库而言,这看起来就像是另一个从库。

postgres=# table pg_stat_replication; -- 查看当前从库
-[ RECORD 1 ]----+------------------------------
pid              | 14082
usesysid         | 10
usename          | vonng
application_name | cdc
client_addr      | 10.1.1.95
client_hostname  |
client_port      | 56609
backend_start    | 2019-05-19 13:14:34.606014+08
backend_xmin     |
state            | streaming
sent_lsn         | 2D/AB269AB8     -- 服务端已经发送的消息坐标
write_lsn        | 2D/AB269AB8     -- 客户端已经执行完写入的消息坐标
flush_lsn        | 2D/AB269AB8     -- 客户端已经刷盘的消息坐标(不会丢失)
replay_lsn       | 2D/AB269AB8     -- 客户端已经应用的消息坐标(已经生效)
write_lag        |
flush_lag        |
replay_lag       |
sync_priority    | 0
sync_state       | async

postgres=# table pg_replication_slots;  -- 查看当前复制槽
-[ RECORD 1 ]-------+------------
slot_name           | test
plugin              | decoder_raw
slot_type           | logical
datoid              | 13382
database            | postgres
temporary           | f
active              | t
active_pid          | 14082
xmin                |
catalog_xmin        | 1371
restart_lsn         | 2D/AB269A80       -- 下次客户端重连时将从这里开始重放
confirmed_flush_lsn | 2D/AB269AB8       -- 客户端确认完成的消息进度

局限性

想要在生产环境中使用CDC,还需要考虑一些其他的问题。略有遗憾的是,在PostgreSQL CDC的天空上,还飘着两朵小乌云。

完备性

就目前而言,PostgreSQL的逻辑解码只提供了以下几个钩子:

LogicalDecodeStartupCB startup_cb;
LogicalDecodeBeginCB begin_cb;
LogicalDecodeChangeCB change_cb;
LogicalDecodeTruncateCB truncate_cb;
LogicalDecodeCommitCB commit_cb;
LogicalDecodeMessageCB message_cb;
LogicalDecodeFilterByOriginCB filter_by_origin_cb;
LogicalDecodeShutdownCB shutdown_cb;

其中比较重要,也是必须提供的是三个回调函数:begin:事务开始,change:行级别增删改事件,commit:事务提交 。遗憾的是,并不是所有的事件都有相应的钩子,例如数据库的模式变更,Sequence的取值变化,以及特殊的大对象操作。

通常来说,这并不是一个大问题,因为用户感兴趣的往往只是表记录而不是表结构的增删改。而且,如果使用诸如JSON,Avro等灵活格式作为解码目标格式,即使表结构发生变化,也不会有什么大问题。

但是尝试从目前的变更事件流生成完备的UNDO Log是不可能的,因为目前模式的变更DDL并不会记录在逻辑解码的输出中。好消息是未来会有越来越多的钩子与支持,因此这个问题是可解的。

同步提交

需要注意的一点是,有一些输出插件会无视BeginCommit消息。这两条消息本身也是数据库变更日志的一部分,如果输出插件忽略了这些消息,那么CDC客户端在汇报消费进度时就可能会出现偏差(落后一条消息的偏移量)。在一些边界条件下可能会触发一些问题:例如写入极少的数据库启用同步提交时,主库迟迟等不到从库确认最后的Commit消息而卡住)

故障切换

理想很美好,现实很骨感。当一切正常时,CDC工作流工作的很好。但当数据库出现故障,或者出现故障转移时,事情就变得比较棘手了。

恰好一次保证

另外一个使用PostgreSQL CDC的问题是消息队列中经典的恰好一次问题。

PostgreSQL的逻辑复制实际上提供的是至少一次保证,因为消费者偏移量的值会在检查点的时候保存。如果PostgreSQL主库宕机,那么重新发送变更事件的起点,不一定恰好等于上次订阅者已经消费的位置。因此有可能会发送重复的消息。

解决方法是:逻辑复制的消费者也需要记录自己的消费者偏移量,以便跳过重复的消息,实现真正的恰好一次 消息传达保证。这并不是一个真正的问题,只是任何试图自行实现CDC客户端的人都应当注意这一点。

Failover Slot

对目前PostgreSQL的CDC来说,Failover Slot是最大的难点与痛点。逻辑复制依赖复制槽,因为复制槽持有着消费者的状态,记录着消费者的消费进度,因而数据库不会将消费者还没处理的消息清理掉。

但以目前的实现而言,复制槽只能用在主库上,且复制槽本身并不会被复制到从库上。因此当主库进行Failover时,消费者偏移量就会丢失。如果在新的主库承接任何写入之前没有重新建好逻辑复制槽,就有可能会丢失一些数据。对于非常严格的场景,使用这个功能时仍然需要谨慎。

这个问题计划将于下一个大版本(13)解决,Failover Slot的Patch计划于版本13(2020)年合入主线版本。

在那之前,如果希望在生产中使用CDC,那么务必要针对故障切换进行充分地测试。例如使用CDC的情况下,Failover的操作就需要有所变更:核心思想是运维与DBA必须手工完成复制槽的复制工作。在Failover前可以在原主库上启用同步提交,暂停写入流量并在新主库上使用脚本复制复制原主库的槽,并在新主库上创建同样的复制槽,从而手工完成复制槽的Failover。对于紧急故障切换,即原主库无法访问,需要立即切换的情况,也可以在事后使用PITR重新将缺失的变更恢复出来。

小结一下:CDC的功能机制已经达到了生产应用的要求,但可靠性的机制还略有欠缺,这个问题可以等待下一个主线版本,或通过审慎地手工操作解决,当然激进的用户也可以自行拉取该补丁提前尝鲜。

PostgreSQL中的锁

详细介绍PostgreSQL中的各种锁

PostgreSQL的并发控制以 快照隔离(SI) 为主,以 两阶段锁定(2PL) 机制为辅。PostgreSQL对DML(SELECT, UPDATE, INSERT, DELETE等命令)使用SSI,对DDL(CREATE TABLE等命令)使用2PL。

PostgreSQL有好几类锁,其中最主要的是 表级锁行级锁,此外还有页级锁,咨询锁等,表级锁 通常是各种命令执行时自动获取的,或者通过事务中的LOCK语句显式获取;而行级锁则是由SELECT FOR UPDATE|SHARE语句显式获取的。执行数据库命令时,都是先获取表级锁,再获取行级锁。本文主要介绍PostgreSQL中的表锁。

表级锁

  • 表级锁通常会在执行各种命令执行时自动获取,或者通过在事务中使用LOCK语句显式获取。
  • 每种锁都有自己的冲突集合,在同一时刻的同一张表上,两个事务可以持有不冲突的锁,不能持有冲突的锁。
  • 有些锁是 自斥(self-conflict) 的,即最多只能被一个事务所持有。
  • 表级锁总共有八种模式,有着并不严格的强度递增关系(例外是Share锁不自斥)
  • 表级锁存在于PG的共享内存中,可以通过pg_locks系统视图查阅。

表级锁的模式

如何记忆这么多类型的锁呢?让我们从演化的视角来看这些锁。

表级锁的演化

最开始只有两种锁:ShareExclusive,共享锁与排它锁,即所谓读锁写锁。读锁的目的是阻止表数据的变更,而写锁的目的是阻止一切并发访问。这很好理解。

多版本并发控制

后来随着多版本并发控制技术的出现(PostgreSQL使用快照隔离实现MVCC),读不阻塞写,写不阻塞读(针对表的增删改查而言)。因而原有的锁模型就需要升级了:这里的共享锁与排他锁都有了一个升级版本,即前面多加一个ACCESSACCESS SHARE是改良版共享锁,即允许ACCESS(多版本并发访问)的SHARE锁,这种锁意味着即使其他进程正在并发修改数据也不会阻塞本进程读取数据。当然有了多版本读锁也就会有对应的多版本写锁来阻止一切访问,即连ACCESS(多版本并发访问)都要EXCLUSIVE的锁,这种锁会阻止一切访问,是最强的写锁。

引入MVCC后,INSERT|UPDATE|DELETE仍然使用原来的Exclusive锁,而普通的只读SELECT则使用多版本的AccessShare锁。因为AccessShare锁与原来的Exclusive锁不冲突,所以读写之间就不会阻塞了。原来的Share锁现在主要的应用场景为创建索引(非并发创建模式下,创建索引会阻止任何对底层数据的变更),而升级的多版本AccessExclusive锁主要用于除了增删改之外的排他性变更(DROP|TRUNCATE|REINDEX|VACUUM FULL等),这个模型如图(a)所示。

当然,这样还是有问题的。虽然在MVCC中读写之间相互不阻塞了,但写-写之间还是会产生冲突。上面的模型中,并发写入是通过表级别的Exclusive锁解决的。表级锁虽然可以解决并发写入冲突问题,但这个粒度太大了,会影响并发度:因为同一时刻一张表上只能有一个进程持有Exclusive锁并执行写入,而典型的OLTP场景是以单行写入为主。所以常见的DBMS解决写-写冲突通常都是采用行级锁来实现(下面会讲到)。

行级锁和表级锁不是一回事,但这两种锁之间仍然存在着联系,协调这两种锁之间的关系,就需要引入意向锁

意向锁

意向锁用于协调表锁与行锁之间的关系:它用于保护较低资源级别上的锁,即说明下层节点已经被加了锁。当进程想要锁定或修改某表上的某一行时,它会在这一行上加上行级锁。但在加行级锁之前,它还需要在这张表上加上一把意向锁,表示自己将会在表中的若干行上加锁。

举个例子,假设不存在意向锁。假设进程A获取了表上某行的行锁,持有行上的排他锁意味着进程A可以对这一行执行写入;同时因为不存在意向锁,进程B很顺利地获取了该表上的表级排他锁,这意味着进程B可以对整个表,包括A锁定对那一行进行修改,这就违背了常识逻辑。因此A需要在获取行锁前先获取表上的意向锁,这样后来的B就意识到自己无法获取整个表上的排他锁了(但B依然可以加一个意向锁,获取其他行上的行锁)。

因此,这里RowShare就是行级共享锁对应的表级意向锁(SELECT FOR SHARE|UPDATE命令获取),而RowExclusiveINSERT|UPDATE|DELETE获取)则是行级排他锁对应的表级意向锁。注意因为MVCC的存在,只读查询并不会在行上加锁。引入意向锁后的模型如图(c)所示。而合并MVCC与意向锁模型之后的锁模型如图(d)所示。

自斥锁

上面这个模型已经相当不错,但仍然存在一些问题,譬如自斥:这里RowExclusiveShare锁都不是自斥的。

举个例子,并发VACUUM不应阻塞数据写入,而且一个表上不应该允许多个VACUUM进程同时工作。因为不能阻塞写入,因此VACUUM所需的锁强度必须要比Share锁弱,弱于Share的最强锁为RowExclusive,不幸的是,该锁并不自斥。如果VACUUM使用该锁,就无法阻止单表上出现多个VACUUM进程。因此需要引入一个自斥版本的RowExclusive锁,即ShareUpdateExclusive锁。

同理,再比如执行触发器管理操作(创建,删除,启用)时,该操作不应阻塞读取和锁定,但必须禁止一切实际的数据写入,否则就难以判断某条元组的变更是否应该触发触发器。Share锁满足不阻塞读取和锁定的条件,但并不自斥,因此可能出现多个进程在同一个表上并发修改触发器。并发修改触发器会带来很多问题(譬如丢失更新,A将其配置为Replica Trigger,B将其配置为Always Trigger,都反回成功了,以谁为准?)。因此这里也需要一个自斥版本的Share锁,即ShareRowExclusive锁。

因此,引入两种自斥版本的锁后,就是PostgreSQL中的最终表级锁模型,如图(e)所示。

表级锁的命名与记忆

PostgreSQL的表级锁的命名有些诘屈聱牙,这是因为一些历史因素,但也可以总结出一些规律便于记忆。

  • 最初只有两种锁:共享锁(Share)与排他锁(Exclusive)。
    • 特征是只有一个单词,表示这是两种最基本的锁:读锁与写锁。
  • 多版本并发控制的出现,引入了多版本的共享锁与排他锁(AccessShareAccessExclusive)。
    • 特征是Access前缀,表示这是用于"多版本并发控制"的改良锁。
  • 为了处理并发写入之间的冲突,又引入了两种意向锁(RowShareRowExclusive
    • 特征是Row前缀,表示这是行级别共享/排他锁对应的表级意向锁。
  • 最后,为了处理意向排他锁与共享锁不自斥的问题,引入了这两种锁的自斥版本(ShareUpdateExclusive, ShareRowExclusive)。这两种锁的名称比较难记:
    • 都是以Share打头,以Exclusive结尾。表示这两种锁都是某种共享锁的自斥版本。
    • 两种锁强度围绕在Share前后,Update弱于ShareRow强于Share
    • ShareRowExclusive可以理解为Share + Row Exclusive,因为Share不排斥其他Share,但RowExclusive排斥Share,因此同时加这两种锁的结果等效于ShareRowExclusive,即SIX。
    • ShareUpdateExclusive可以理解为ShareUpdate + ExclusiveUPDATE操作持有RowExclusive锁,而ShareUpdate指的是本锁与普通的增删改(持RowExclusive锁)相容,而Exclusive则表示自己和自己不相容。
  • Share, ShareRowUpdate, Exclusive 这三种锁极少出现,基本可以无视。所以实际上主要用到的锁是:
    • 多版本两种:AccessShare, AccessExclusive
    • 意向锁两种:RowShare,RowExclusive
    • 自斥意向锁一种:ShareUpdateExclusive

显式加锁

通常表级锁会在相应命令执行中自动获取,但也可以手动显式获取。使用LOCK命令加锁的方式:

LOCK [ TABLE ] [ ONLY ] name [ * ] [, ...] [ IN lockmode MODE ] [ NOWAIT ]
  • 显式锁表必须在事务中进行,在事务外锁表会报错。
  • 锁定视图时,视图定义中所有出现的表都会被锁定。
  • 使用表继承时,默认父表和所有后代表都会加锁,指定ONLY选项则继承于该表的子表不会自动加锁。
  • 锁表或者锁视图需要对应的权限,例如AccessShare锁需要SELECT权限。
  • 默认获取的锁模式为AccessExclusive,即最强的锁。
  • LOCK TABLE只能获取表锁,默认会等待冲突的锁被释放,指定NOWAIT选项时,如果命令不能立刻获得锁就会中止并报错。
  • 命令一旦获取到锁, 会被在当前事务中一直持有。没有UNLOCK TABLE命令,锁总是在事务结束时释放。

例子:数据迁移

举个例子,以迁移数据为例,假设希望将某张表的数据迁移到另一个实例中。并保证在此期间旧表上的数据在迁移期间不发生变化,那么我们可以做的就是在复制数据前在表上显式加锁,并在复制结束,应用开始写入新表后释放。应用仍然可以从旧表上读取数据,但不允许写入。那么根据锁冲突矩阵,允许只读查询的锁要弱于AccessExclusive,阻止写入的锁不能弱于ShareRowExclusive,因此可以选择ShareRowExclusiveExclusive锁。因为拒绝写入意味着锁定没有任何意义,所以这里选择更强的Exclusive锁。

BEGIN;
LOCK TABLE tbl IN EXCLUSIVE MODE;
-- DO Something
COMMIT

锁的查询

PostgreSQL提供了一个系统视图pg_locks,包含了当前活动进程持锁的信息。可以锁定的对象包括:关系,页面,元组,事务标识(虚拟的或真实的),其他数据库对象(带有OID)。

CREATE TABLE pg_locks
(
    -- 锁针对的客体对象
    locktype           text, -- 锁类型:关系,页面,元组,事务ID,对象等
    database           oid,  -- 数据库OID
    relation           oid,  -- 关系OID
    page               integer, -- 关系内页号
    tuple              smallint, -- 页内元组号
    virtualxid         text,     -- 虚拟事务ID
    transactionid      xid,      -- 事务ID
    classid            oid,      -- 锁对象所属系统目录表本身的OID
    objid              oid,      -- 系统目录内的对象的OID
    objsubid           smallint, -- 列号
  
    -- 持有|等待锁的主体
    virtualtransaction text,     -- 持锁|等待锁的虚拟事务ID
    pid                integer,  -- 持锁|等待锁的进程PID
    mode               text,     -- 锁模式
    granted            boolean,  -- t已获取,f等待中
    fastpath           boolean   -- t通过fastpath获取
);
名称 类型 描述
locktype text 可锁对象的类型: relationextendpagetupletransactionidvirtualxidobjectuserlockadvisory
database oid 若锁目标为数据库(或下层对象),则为数据库OID,并引用pg_database.oid,共享对象为0,否则为空
relation oid 若锁目标为关系(或下层对象),则为关系OID,并引用pg_class.oid,否则为空
page integer 若锁目标为页面(或下层对象),则为页面号,否则为空
tuple smallint 若锁目标为元组,则为页内元组号,否则为空
virtualxid text 若锁目标为虚拟事务,则为虚拟事务ID,否则为空
transactionid xid 若锁目标为事务,则为事务ID,否则为空
classid oid 若目标为数据库对象,则为该对象相应系统目录的OID,并引用pg_class.oid,否则为空。
objid oid 锁目标在其系统目录中的OID,如目标不是普通数据库对象则为空
objsubid smallint 锁的目标列号(classidobjid指向表本身),若目标是某种其他普通数据库对象则此列为0,如果目标不是一个普通数据库对象则此列为空。
virtualtransaction text 持有或等待这个锁的虚拟ID
pid integer 持有或等待这个锁的服务器进程ID,如果此锁被一个预备事务所持有则为空
mode text 持有或者等待锁的模式
granted boolean 为真表示已经获得的锁,为假表示还在等待的锁
fastpath boolean 为真表示锁是通过fastpath获取的

样例数据

这个视图需要一些额外的知识才能解读。

  • 该视图是数据库集簇范围的视图,而非仅限于单个数据库,即可以看见其他数据库中的锁。
  • 一个进程在一个时间点只能等待至多一个锁,等待锁用granted=f表示,等待进程会休眠至其他锁被释放,或者系统检测到死锁。
  • 每个事务都有一个虚拟事务标识virtualtransaction(以下简称vxid),修改数据库状态(或者显式调用txid_current获取)的事务才会被分配一个真实的事务标识transactionid(简称txid),vxid|txid本身也是可以锁定的对象
  • 每个事务都会持有自己vxid上的Exclusive锁,如果有txid,也会同时持有其上的Exclusive锁(即同时持有txidvxid上的排它锁)。因此当一个事务需要等待另一个事务时,它会尝试获取另一个事务txid|vxid上的共享锁,因而只有当目标事务结束(自动释放自己事务标识上的Exclusive锁)时,等待事务才会被唤醒。
  • pg_locks视图通常并不会直接显示行级锁信息,因为这些信息存储在磁盘磁盘上(),如果真的有进程在等待行锁,显示的形式通常是一个事务等待另一个事务,而不是等待某个具体的行锁。
  • 咨询锁本质上的锁对象客体是一个数据库范畴内的BIGINT,classid里包含了该整数的高32bit,objid里包含有低32bit,objsubid里则说明了咨询锁的类型,单一Bigint则取值为1,两个int32则取值为2
  • 本视图并不一定能保证提供一个一致的快照,因为所有fastpath=true的锁信息是从每个后端进程收集而来的,而fastpath=false的锁是从常规锁管理器中获取的,同时谓词锁管理器中的数据也是单独获取的,因此这几种来源的数据之间可能并不一致。
  • 频繁访问本视图会对数据库系统性能产生影响,因为要对锁管理器加锁获取一致性快照。

虚拟事务

一个后端进程在整个生命周期中的每一个事务都会有一个自己的虚拟事务ID

PG中事务号是有限的(32-bit整型),会循环使用。为了节约事务号,PG只会为实际修改数据库状态的事务分配真实事务ID,而只读事务就不分配了,用虚拟事务ID凑合一下。txid是事务标识,全局共享,而vxid是虚拟事务标识,在短期内可以保证全局唯一性。因为vxid由两部分组成:BackendIDLocalTransactionId,前者是后端进程的标识符(本进程在内存中进程数组中的序号),后者是一个递增的事务计数器。因此两者组合即可获得一个暂时唯一的虚拟事务标识(之所以是暂时是因为这里的后端ID是有可能重复的)

typedef struct {
	BackendId	backendId;		/* 后端ID,初始化时确定,其实是后端进程数组内索引号 */
	LocalTransactionId localTransactionId;	/* 后端内本地使用的命令标ID,类似自增计数器 */
} VirtualTransactionId;

应用

常见操作的冲突关系

  • SELECTUPDATE|DELETE|INSERT不会相互阻塞,即使访问的是同一行。
  • I|U|D写入操作与I|U|D写入操作在表层面不会互斥,会在具体的行上通过RowExclusive锁实现。
  • SELECT FOR UPDATE锁定操作与I|U|D写入在表层级也不会互斥,仍然是通过具体元组上的行锁实现。
  • 并发VACUUM,并发创建索引等操作不会阻塞读写,但它们是自斥的,即同一时刻只会有一个(所以同时在一个表上执行两个CREATE INDEX CONCURRENTLY是没有意义的,不要被名字骗了)
  • 普通的索引创建CREATE INDEX,不带CONCURRENTLY会阻塞增删改,但不会阻塞查,很少用到。
  • 任何对于触发器的操作,或者约束类的操作,都会阻止增删改,但不会阻塞只读查询以及锁定。
  • 冷门的命令REFRESH MATERIALIZED VIEW CONCURRENTLY允许SELECT和锁定。
  • 大多数很硬的变更:VACUUM FULL, DROP TABLE, TRUNCATE, ALTER TABLE的大多数形式都会阻塞一切读取。

注意,锁虽有强弱之分,但冲突关系是对等的。一个持有AccessShare锁的SELECT会阻止后续的DROP TABLE获得AccessExclusive锁。后面的命令会进入锁队列中。

锁队列

PG中每个锁上都会有一个锁队列。如果事务A占有一个排他锁,那么事务B在尝试获取其上的锁时就会在其锁队列中等待。如果这时候事务C同样要获取该锁,那么它不仅要和事务A进行冲突检测,也要和B进行冲突检测,以及队列中其他的事务。这意味着当用户尝试获取一个很强的锁而未得等待时,已经会阻止后续新锁的获取。一个具体的例子是加列:

ALTER TABLE tbl ADD COLUMN mtime TIMESTAMP;

即使这是一个不带默认值的加列操作(不会重写整个表,因而很快),但本命令需要表上的AccessExclusive锁,如果这张表上面已经有不少查询,那么这个命令可能会等待相当一段时间。因为它需要等待其他查询结束并释放掉锁后才能执行。相应地,因为这条命令已经在等待队列中,后续的查询都会被它所阻塞。因此,当执行此类命令时的一个最佳实践是在此类命令前修改lock_timeout,从而避免雪崩。

SET lock_timeout TO '1s';
ALTER TABLE tbl ADD COLUMN mtime TIMESTAMP;

这个设计的好处是,命令不会饿死:不会出现源源不断的短小只读查询无限阻塞住一个排他操作。

加锁原则

  • 够用即可:使用满足条件的锁中最弱的锁模式
  • 越快越好:如果可能,可以用(长时间的弱锁+短时间的强锁)替换长时间的强锁
  • 递增获取:遵循2PL原则申请锁;越晚使用激进锁策略越好;在真正需要时再获取。
  • 相同顺序:获取锁尽量以一致的顺序获取,从而减小死锁的几率

最小化锁阻塞时长

除了手工锁定之外,很多常见的操作都会"锁表",最常见的莫过于添加新字段与添加新约束。这两种操作都会获取表上的AccessExclusive锁以阻止一切并发访问。当DBA需要在线维护数据库时应当最小化持锁的时间。

例如,为表添加新字段的ALTER TABLE ADD COLUMN子句,根据新列是否提供易变默认值,会重写整个表。

ALTER TABLE tbl ADD COLUMN mtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP;

如果只是个小表,业务负载也不大,那么也许可以直接这么干。但如果是很大的表,以及很高的负载,那么阻塞的时间就会很可观。在这段时间里,命令都会持有表上的AccessExclusive锁阻塞一切访问。

可以通过先加一个空列,再慢慢更新的方式来最小化锁等待时间:

ALTER TABLE tbl ADD COLUMN mtime TIMESTAMP;
UPDATE tbl SET mtime = CURRENT_TIMESTAMP; -- 可以分批进行

这样,第一条加列操作的锁阻塞时间就会非常短,而后面的更新(重写)操作就可以以不阻塞读写的形式慢慢进行,最小化锁阻塞。

同理,当想要为表添加新的约束时(例如新的主键),也可以采用这种方式:

CREATE UNIQUE INDEX CONCURRENTLY tbl_pk ON tbl(id); -- 很慢,但不阻塞读写
ALTER TABLE tbl ADD CONSTRAINT tbl_pk PRIMARY KEY USING INDEX tbl_pk;  -- 阻塞读写,但很快

替代单纯的

ALTER TABLE tbl ADD PRIMARY KEY (id); 

微信公众号原文

GIN搜索的O(n2)负载度

GIN索引如果使用很长的关键词列表进行搜索,会导致性能显著下降。本文解释了为什么GIN索引关键词搜索的时间复杂度为O(n^2)

Here is the detail of why that query have O(N^2) inside GIN implementation.

Details

Inspect the index example_keys_idx

postgres=# select oid,* from pg_class where relname = 'example_keys_idx';
-[ RECORD 1 ]-------+-----------------
oid                 | 20699
relname             | example_keys_idx
relnamespace        | 20692
reltype             | 0
reloftype           | 0
relowner            | 10
relam               | 2742
relfilenode         | 20699
reltablespace       | 0
relpages            | 2051
reltuples           | 300000
relallvisible       | 0
reltoastrelid       | 0
relhasindex         | f
relisshared         | f
relpersistence      | p
relkind             | i
relnatts            | 1
relchecks           | 0
relhasoids          | f
relhasrules         | f
relhastriggers      | f
relhassubclass      | f
relrowsecurity      | f
relforcerowsecurity | f
relispopulated      | t
relreplident        | n
relispartition      | f
relrewrite          | 0
relfrozenxid        | 0
relminmxid          | 0
relacl              |
reloptions          | {fastupdate=off}
relpartbound        |

Find index information via index’s oid

postgres=# select * from pg_index where indexrelid = 20699;
-[ RECORD 1 ]--+------
indexrelid     | 20699
indrelid       | 20693
indnatts       | 1
indnkeyatts    | 1
indisunique    | f
indisprimary   | f
indisexclusion | f
indimmediate   | t
indisclustered | f
indisvalid     | t
indcheckxmin   | f
indisready     | t
indislive      | t
indisreplident | f
indkey         | 2
indcollation   | 0
indclass       | 10075
indoption      | 0
indexprs       |
indpred        |

Find corresponding operator class for that index via indclass

postgres=# select * from pg_opclass where oid = 10075;
-[ RECORD 1 ]+----------
opcmethod    | 2742
opcname      | array_ops
opcnamespace | 11
opcowner     | 10
opcfamily    | 2745
opcintype    | 2277
opcdefault   | t
opckeytype   | 2283

Find four operator corresponding to operator faimily array_ops

postgres=# select * from pg_amop where amopfamily =2745;
-[ RECORD 1 ]--+-----
amopfamily     | 2745
amoplefttype   | 2277
amoprighttype  | 2277
amopstrategy   | 1
amoppurpose    | s
amopopr        | 2750
amopmethod     | 2742
amopsortfamily | 0
-[ RECORD 2 ]--+-----
amopfamily     | 2745
amoplefttype   | 2277
amoprighttype  | 2277
amopstrategy   | 2
amoppurpose    | s
amopopr        | 2751
amopmethod     | 2742
amopsortfamily | 0
-[ RECORD 3 ]--+-----
amopfamily     | 2745
amoplefttype   | 2277
amoprighttype  | 2277
amopstrategy   | 3
amoppurpose    | s
amopopr        | 2752
amopmethod     | 2742
amopsortfamily | 0
-[ RECORD 4 ]--+-----
amopfamily     | 2745
amoplefttype   | 2277
amoprighttype  | 2277
amopstrategy   | 4
amoppurpose    | s
amopopr        | 1070
amopmethod     | 2742
amopsortfamily | 0

https://www.postgresql.org/docs/10/xindex.html

Table 37.6. GIN Array Strategies

Operation Strategy Number
overlap 1
contains 2
is contained by 3
equal 4

When we access that index with && operator, we are using stragety 1 overlap, which corresponding operator oid is 2750.

postgres=# select * from pg_operator where oid = 2750;
-[ RECORD 1 ]+-----------------
oprname      | &&
oprnamespace | 11
oprowner     | 10
oprkind      | b
oprcanmerge  | f
oprcanhash   | f
oprleft      | 2277
oprright     | 2277
oprresult    | 16
oprcom       | 2750
oprnegate    | 0
oprcode      | arrayoverlap
oprrest      | arraycontsel
oprjoin      | arraycontjoinsel

The underlying C function to judge arrayoverlap is arrayoverlap in here

Datum
arrayoverlap(PG_FUNCTION_ARGS)
{
	AnyArrayType *array1 = PG_GETARG_ANY_ARRAY_P(0);
	AnyArrayType *array2 = PG_GETARG_ANY_ARRAY_P(1);
	Oid			collation = PG_GET_COLLATION();
	bool		result;

	result = array_contain_compare(array1, array2, collation, false,
								   &fcinfo->flinfo->fn_extra);

	/* Avoid leaking memory when handed toasted input. */
	AARR_FREE_IF_COPY(array1, 0);
	AARR_FREE_IF_COPY(array2, 1);

	PG_RETURN_BOOL(result);
}

It actually use array_contain_compare to test whether two array are overlap

static bool
array_contain_compare(AnyArrayType *array1, AnyArrayType *array2, Oid collation,
					  bool matchall, void **fn_extra)

Line 4177, we see a nested loop to iterate two array, which makes it O(N^2)

	for (i = 0; i < nelems1; i++)
	{
		Datum		elt1;
		bool		isnull1;

		/* Get element, checking for NULL */
		elt1 = array_iter_next(&it1, &isnull1, i, typlen, typbyval, typalign);

		/*
		 * We assume that the comparison operator is strict, so a NULL can't
		 * match anything.  XXX this diverges from the "NULL=NULL" behavior of
		 * array_eq, should we act like that?
		 */
		if (isnull1)
		{
			if (matchall)
			{
				result = false;
				break;
			}
			continue;
		}

		for (j = 0; j < nelems2; j++)

IP地理逆查询优化

在应用开发中,一个‘很常见’的需求就是GeoIP转换。将请求的来源IP转换为相应的地理坐标,或者行政区划(国家-省-市-县-乡-镇)

IP归属地查询的高效实现

​ 在应用开发中,一个‘很常见’的需求就是GeoIP转换。将请求的来源IP转换为相应的地理坐标,或者行政区划(国家-省-市-县-乡-镇)。这种功能有很多用途,譬如分析网站流量的地理来源,或者干一些坏事。使用PostgreSQL可以多快好省,优雅高效地实现这一需求。

0x01 思路方法

​ 通常网上的IP地理数据库的形式都是:start_ip, stop_ip , longitude, latitude,再缀上一些国家代码,城市代码,邮编之类的属性字段。大概长这样:

Column Type
start_ip text
end_ip text
longitude text
latitude text
country_code text
…… text

说到底,其核心是从IP地址段地理坐标点的映射。

典型查询实际上是给出一个IP地址,返回该地址对应的地理范围。其逻辑用SQL来表示差不多长这样:

SELECT longitude, latitude FROM geoip 
WHERE start_ip <= target_ip AND target_ip <= stop_ip;

不过,想直接提供服务,还有几个问题需要解决:

  • 第一个问题:虽然IPv4实际上是一个uint32,但我们已经完全习惯了123.123.123.123这种文本表示形式。而这种文本表示形式是无法比较大小的。
  • 第二个问题:这里的IP范围是用两个IP边界字段表示的范围,那么这个范围是开区间还是闭区间呢?是不是还需要一个额外字段来表示?
  • 第三个问题:想要高效地查询,那么在两个字段上的索引又该如何建立?
  • 第四个问题:我们希望所有的IP段相互之间不会出现重叠,但简单的建立在(start_ip, stop_ip)上的唯一约束并无法保证这一点,那又如何是好?

令人高兴的是,对于PostgreSQL而言,这些都不是问题。上面四个问题,可以轻松使用PostgreSQL的特性解决。

  • 网络数据类型:高性能,紧凑,灵活的网络地址表示。
  • 范围类型:对区间的良好抽象,对区间查询与操作的良好支持。
  • GiST索引:既能作用于IP地址段,也可以用于地理位置点。
  • Exclude约束:泛化的高级UNIQUE约束,从根本上确保数据完整性。

0x01 网络地址类型

​ PostgreSQL提供用于存储 IPv4、IPv6 和 MAC 地址的数据类型。包括cidrinet以及macaddr,并且提供了很多常见的操作函数,不需要再在程序中去实现一些繁琐重复的功能。

​ 最常见的网络地址就是IPv4地址,对应着PostgreSQL内建的inet类型,inet类型可以用来存储IPv4,IPv6地址,或者带上一个可选的子网。当然这些细节操作都可以参阅文档,在此不详细展开。

​ 一个需要注意的点就是,虽然我们知道IPv4实质上是一个Unsigned Integer,但在数据库中实际存储成INTEGER其实是不行的,因为SQL标准并不支持Unsigned这种用法,所以有一半的IP地址的表示就会被解释为负数,在比大小的时候产生令人惊异的结果,真要这么存请使用BIGINT。此外,直接面对一堆长长的整数也是相当令人头大的问题,inet是最佳的选择。

​ 如果需要将IP地址(inet类型)与对应的整数相互转换,只要与0.0.0.0做加减运算即可;当然也可以使用以下函数,并创建一个类型转换,然后就能直接在inetbigint之间来回转换:

-- inet to bigint
CREATE FUNCTION inet2int(inet) RETURNS bigint AS $$
SELECT $1 - inet '0.0.0.0';
$$ LANGUAGE SQL  IMMUTABLE RETURNS NULL ON NULL INPUT;

-- bigint to inet
CREATE FUNCTION int2inet(bigint) RETURNS inet AS $$
SELECT inet '0.0.0.0' + $1;
$$ LANGUAGE SQL  IMMUTABLE RETURNS NULL ON NULL INPUT;

-- create type conversion
CREATE CAST (inet AS bigint) WITH FUNCTION inet2int(inet);
CREATE CAST (bigint AS inet) WITH FUNCTION int2inet(bigint);

-- test
SELECT 123456::BIGINT::INET;
SELECT '1.2.3.4'::INET::BIGINT;

-- 生成随机的IP地址
SELECT (random() * 4294967295)::BIGINT::INET;

inet之间的大小比较也相当直接,直接使用大小比较运算符就可以了。实际比较的是底下的整数值。这就解决了第一个问题。

0x02 范围类型

​ PostgreSQL的Range类型是一种很实用的功能,它与数组类似,属于一种泛型。只要是能被B树索引(可以比大小)的数据类型,都可以作为范围类型的基础类型。它特别适合用来表示区间:整数区间,时间区间,IP地址段等等。而且对于开区间,闭区间,区间索引这类问题有比较细致的考虑。

​ PostgreSQL内置了预定义的int4range, int8range, numrange, tsrange, tstzrange, daterange,开箱即用。但没有提供网络地址对应的范围类型,好在自己造一个非常简单:

CREATE TYPE inetrange AS RANGE(SUBTYPE = inet)

当然为了高效地支持GiST索引查询,还需要实现一个距离度量,告诉索引两个inet之间的距离应该如何计算:

-- 定义基本类型间的距离度量
CREATE FUNCTION inet_diff(x INET, y INET) RETURNS FLOAT AS $$
  SELECT (x - y) :: FLOAT;
$$ LANGUAGE SQL IMMUTABLE STRICT;

-- 重新创建inetrange类型,使用新定义的距离度量。
CREATE TYPE inetrange AS RANGE(
  SUBTYPE = inet,
  SUBTYPE_DIFF = inet_diff
)

幸运的是,俩网络地址之间的距离定义天然就有一个很简单的计算方法,减一下就好了。

这个新定义的类型使用起来也很简单,构造函数会自动生成:

geo=# select misc.inetrange('64.60.116.156','64.60.116.161','[)');
inetrange | [64.60.116.156,64.60.116.161)

geo=# select '[64.60.116.156,64.60.116.161]'::inetrange;
inetrange | [64.60.116.156,64.60.116.161]

方括号和圆括号分别表示闭区间和开区间,与数学中的表示方法一致。

同时,检测一个IP地址是否落在给定的IP范围内也是很直接的:

geo=# select '[64.60.116.156,64.60.116.161]'::inetrange @> '64.60.116.160'::inet as res;
res | t

有了范围类型,就可以着手构建我们的数据表了。

0x03 范围索引

实际上,找一份IP地理对应数据花了我一个多小时,但完成这个需求只用了几分钟。

假设已经有了这样一份数据:

create table geoips
(
  ips          inetrange,
  geo          geometry(Point),
  country_code text,
  region_code  text,
  city_name    text,
  ad_code      text,
  postal_code  text
);

里面的数据大概长这样:

SELECT ips,ST_AsText(geo) as geo,country_code FROM geoips

 [64.60.116.156,64.60.116.161] | POINT(-117.853 33.7878) | US
 [64.60.116.139,64.60.116.154] | POINT(-117.853 33.7878) | US
 [64.60.116.138,64.60.116.138] | POINT(-117.76 33.7081)  | US

那么查询包含某个IP地址的记录就可以写作:

SELECT * FROM ip WHERE ips @> inet '67.185.41.77';

对于600万条记录,约600M的表,在笔者的机器上暴力扫表的平均用时是900ms,差不多单核QPS是1.1,48核生产机器也就差不多三四十的样子。肯定是没法用的。

CREATE INDEX ON geoips USING GiST(ips);

查询用时从1秒变为340微秒,差不多3000倍的提升。

-- pgbench
\set ip random(0,4294967295)
SELECT * FROM geoips WHERE ips @> :ip::BIGINT::INET;

-- result
latency average = 0.342 ms
tps = 2925.100036 (including connections establishing)
tps = 2926.151762 (excluding connections establishing)

折算成生产QPS差不多是十万QPS,啧啧啧,美滋滋。

如果需要把地理坐标转换为行政区划,可以参考上一篇文章:使用PostGIS高效解决行政区划归属地理编码问题。

一次地理编码也就是100微秒,从IP转换为省市区县整个的QPS,单机几万基本问题不大(全天满载相当于七八十亿次调用,根本用不满)。

0x04 EXCLUDE约束

​ 问题至此已经基本解决了,不过还有一个问题。如何避免一个IP查出两条记录的尴尬情况?

​ 数据完整性是极其重要的,但由应用保证的数据完整性并不总是那么靠谱:人会犯傻,程序会出错。如果能通过数据库约束来Enforce数据完整性,那是再好不过了。

​ 然而,有一些约束是相当复杂的,例如确保表中的IP范围不发生重叠,类似的,确保地理区划表中各个城市的边界不会重叠。传统上要实现这种保证是相当困难的:譬如UNIQUE约束就无法表达这种语义,CHECK与存储过程或者触发器虽然可以实现这种检查,但也相当tricky。PostgreSQL提供的EXCLUDE约束可以优雅地解决这个问题。修改我们的geoips表:

create table geoips
(
  ips          inetrange,
  geo          geometry(Point),
  country_code text,
  region_code  text,
  city_name    text,
  ad_code      text,
  postal_code  text,
  EXCLUDE USING gist (ips WITH &&) DEFERRABLE INITIALLY DEFERRED 
);

​ 这里EXCLUDE USING gist (ips WITH &&) 的意思就是ips字段上不允许出现范围重叠,即新插入的字段不能与任何现存范围重叠(&&为真)。而DEFERRABLE INITIALLY IMMEDIATE 表示在语句结束时再检查所有行上的约束。创建该约束会自动在ips字段上创建GIST索引,因此无需手工创建了。

0x05 小结

​ 本文介绍了如何使用PostgreSQL特性高效而优雅地解决IP归属地查询的问题。性能表现优异,600w记录0.3ms定位;复杂度低到发指:只要一张表DDL,连索引都不用显式创建就解决了这一问题;数据完整性有充分的保证:百行代码才能解决的问题现在只要添加约束即可,从根本上保证数据完整性。

​ PostgreSQL这么棒棒,快快学起来用起来吧~。

​ 什么?你问我数据哪里找?搜索MaxMind有真相,在隐秘的小角落能够找到不要钱的GeoIP数据。

PostgreSQL的触发器

详细了解PostgreSQL中触发器的管理与使用

概览

  • 触发器行为概述
  • 触发器的分类
  • 触发器的功能
  • 触发器的种类
  • 触发器的触发
  • 触发器的创建
  • 触发器的修改
  • 触发器的查询
  • 触发器的性能

触发器概述

触发器行为概述:英文中文

触发器分类

触发时机:BEFORE, AFTER, INSTEAD

触发事件:INSERT, UPDATE, DELETE,TRUNCATE

触发范围:语句级,行级

内部创建:用于约束的触发器,用户定义的触发器

触发模式:origin|local(O), replica(R),disable(D)

触发器操作

触发器的操作通过SQL DDL语句进行,包括CREATE|ALTER|DROP TRIGGER,以及ALTER TABLE ENABLE|DISABLE TRIGGER进行。注意PostgreSQL内部的约束是通过触发器实现的。

创建

CREATE TRIGGER 可以用于创建触发器。

CREATE [ CONSTRAINT ] TRIGGER name { BEFORE | AFTER | INSTEAD OF } { event [ OR ... ] }
    ON table_name
    [ FROM referenced_table_name ]
    [ NOT DEFERRABLE | [ DEFERRABLE ] [ INITIALLY IMMEDIATE | INITIALLY DEFERRED ] ]
    [ REFERENCING { { OLD | NEW } TABLE [ AS ] transition_relation_name } [ ... ] ]
    [ FOR [ EACH ] { ROW | STATEMENT } ]
    [ WHEN ( condition ) ]
    EXECUTE { FUNCTION | PROCEDURE } function_name ( arguments )

event包括
    INSERT
    UPDATE [ OF column_name [, ... ] ]
    DELETE
    TRUNCATE

删除

DROP TRIGGER 用于移除触发器。

DROP TRIGGER [ IF EXISTS ] name ON table_name [ CASCADE | RESTRICT ]

修改

ALTER TRIGGER 用于修改触发器定义,注意这里只能修改触发器名,以及其依赖的扩展。

ALTER TRIGGER name ON table_name RENAME TO new_name
ALTER TRIGGER name ON table_name DEPENDS ON EXTENSION extension_name

启用禁用触发器,修改触发模式是通过ALTER TABLE的子句实现的。

ALTER TABLE 包含了一系列触发器修改的子句:

ALTER TABLE tbl ENABLE TRIGGER tgname; -- 设置触发模式为O (本地连接写入触发,默认)
ALTER TABLE tbl ENABLE REPLICA TRIGGER tgname; -- 设置触发模式为R (复制连接写入触发)
ALTER TABLE tbl ENABLE ALWAYS TRIGGER tgname; -- 设置触发模式为A (总是触发)
ALTER TABLE tbl DISABLE TRIGGER tgname; -- 设置触发模式为D (禁用)

注意这里在ENABLEDISABLE触发器时,可以指定用USER替换具体的触发器名称,这样可以只禁用用户显式创建的触发器,不会把系统用于维持约束的触发器也禁用了。

ALTER TABLE tbl_name DISABLE TRIGGER USER; -- 禁用所有用户定义的触发器,系统触发器不变  
ALTER TABLE tbl_name DISABLE TRIGGER ALL;  -- 禁用所有触发器
ALTER TABLE tbl_name ENABLE TRIGGER USER;  -- 启用所有用户定义的触发器
ALTER TABLE tbl_name ENABLE TRIGGER ALL;   -- 启用所有触发器

查询

获取表上的触发器

最简单的方式当然是psql的\d+ tablename。但这种方式只会列出用户创建的触发器,不会列出与表上约束相关联的触发器。直接查询系统目录pg_trigger,并通过tgrelid用表名过滤

SELECT * FROM pg_trigger WHERE tgrelid = 'tbl_name'::RegClass;

获取触发器定义

pg_get_triggerdef(trigger_oid oid)函数可以给出触发器的定义。

该函数输入参数为触发器OID,返回创建触发器的SQL DDL语句。

SELECT pg_get_triggerdef(oid) FROM pg_trigger; -- WHERE xxx

触发器视图

pg_trigger (中文) 提供了系统中触发器的目录

名称 类型 引用 描述
oid oid 触发器对象标识,系统隐藏列
tgrelid oid pg_class.oid 触发器所在的表 oid
tgname name 触发器名,表级命名空间内不重名
tgfoid oid pg_proc.oid 触发器所调用的函数
tgtype int2 触发器类型,触发条件,详见注释
tgenabled char 触发模式,详见下。`O
tgisinternal bool 如果是内部用于约束的触发器则为真
tgconstrrelid oid pg_class.oid 参照完整性约束中被引用的表,无则为0
tgconstrindid oid pg_class.oid 支持约束的相关索引,没有则为0
tgconstraint oid pg_constraint.oid 与触发器相关的约束对象
tgdeferrable bool DEFERRED则为真
tginitdeferred bool INITIALLY DEFERRED则为真
tgnargs int2 传入触发器函数的字符串参数个数
tgattr int2vector pg_attribute.attnum 如果是列级更新触发器,这里存储列号,否则为空数组。
tgargs bytea 传递给触发器的参数字符串,C风格零结尾字符串
tgqual pg_node_tree 触发器WHEN条件的内部表示
tgoldtable name OLD TABLEREFERENCING列名称,无则为空
tgnewtable name NEW TABLEREFERENCING列名称,无则为空

触发器类型

触发器类型tgtype包含了触发器触发条件相关信息:BEFORE|AFTER|INSTEAD OF, INSERT|UPDATE|DELETE|TRUNCATE

TRIGGER_TYPE_ROW         (1 << 0)  // [0] 0:语句级 	1:行级
TRIGGER_TYPE_BEFORE      (1 << 1)  // [1] 0:AFTER 	1:BEFORE
TRIGGER_TYPE_INSERT      (1 << 2)  // [2] 1: INSERT
TRIGGER_TYPE_DELETE      (1 << 3)  // [3] 1: DELETE
TRIGGER_TYPE_UPDATE      (1 << 4)  // [4] 1: UPDATE
TRIGGER_TYPE_TRUNCATE    (1 << 5)  // [5] 1: TRUNCATE
TRIGGER_TYPE_INSTEAD     (1 << 6)  // [6] 1: INSTEAD OF 

触发器模式

触发器tgenabled字段控制触发器的工作模式,参数session_replication_role 可以用于配置触发器的触发模式。该参数可以在会话层级更改,可能的取值包括:origin(default),replica,local

(D)isable触发器永远不会被触发,(A)lways触发器在任何情况下触发, (O)rigin触发器会在origin|local模式触发(默认),而 (R)eplica触发器replica模式触发。R触发器主要用于逻辑复制,例如pglogical的复制连接就会将会话参数session_replication_role设置为replica,而R触发器只会在该连接进行的变更上触发。

ALTER TABLE tbl ENABLE TRIGGER tgname; -- 设置触发模式为O (本地连接写入触发,默认)
ALTER TABLE tbl ENABLE REPLICA TRIGGER tgname; -- 设置触发模式为R (复制连接写入触发)
ALTER TABLE tbl ENABLE ALWAYS TRIGGER tgname; -- 设置触发模式为A (始终触发)
ALTER TABLE tbl DISABLE TRIGGER tgname; -- 设置触发模式为D (禁用)

information_schema中还有两个触发器相关的视图:information_schema.triggers, information_schema.triggered_update_columns,表过不提。

触发器FAQ

触发器可以建在哪些类型的表上?

普通表(分区表主表,分区表分区表,继承表父表,继承表子表),视图,外部表。

触发器的类型限制

  • 视图上不允许建立BEFOREAFTER触发器(不论是行级还是语句级)
  • 视图上只能建立INSTEAD OF触发器,INSERTEAD OF触发器也只能建立在视图上,且只有行级,不存在语句级INSTEAD OF触发器。
  • INSTEAD OF` 触发器只能定义在视图上,并且只能使用行级触发器,不能使用语句级触发器。

触发器与锁

在表上创建触发器会先尝试获取表级的Share Row Exclusive Lock。这种锁会阻止底层表的数据变更,且自斥。因此创建触发器会阻塞对表的写入。

触发器与COPY的关系

COPY只是消除了数据解析打包的开销,实际写入表中时仍然会触发触发器,就像INSERT一样。

理解字符编码

如果不了解字符编码的基本原理,即使只是简单常规的字符串比较、排序、随机访问操作,都可能会一不小心栽进大坑中。尝试写这一篇科普文,希望能讲清楚这个问题。

​ 程序员,是与Code(代码/编码)打交道的,而字符编码又是最为基础的编码。 如何使用二进制数来表示字符,这个字符编码问题并没有看上去那么简单,实际上它的复杂程度远超一般人的想象:输入、比较排序与搜索、反转、换行与分词、大小写、区域设置,控制字符,组合字符与规范化,排序规则,处理不同语言中的特异需求,变长编码,字节序与BOM,Surrogate,历史兼容性,正则表达式兼容性,微妙与严重的安全问题等等等等。

​ 如果不了解字符编码的基本原理,即使只是简单常规的字符串比较、排序、随机访问操作,都可能会一不小心栽进大坑中。但根据我的观察,很多工程师与程序员却对字符编码本身几近一无所知,只是对诸如ASCII,Unicode,UTF这些名词有一些模糊的感性认识。因此尝试写这一篇科普文,希望能讲清楚这个问题。

0x01 基本概念

万物皆数 —— 毕达哥拉斯

为了解释字符编码,我们首先需要理解什么是编码,什么又是字符?

编码

​ 从程序员的视角来看,我们有着许许多多的基础数据类型:整数,浮点数,字符串,指针。程序员将它们视作理所当然的东西,但从数字计算机的物理本质来看,只有一种类型才是真正的基础类型:二进制数。

​ 而编码(Code)就是这些高级类型与底层二进制表示之间映射转换的桥梁。编码分为两个部分:编码(encode)解码(decode),以无处不在的自然数为例。数字42,这个纯粹抽象的数学概念,在计算机中可能就会表示为00101010的二进制位串(假设使用8位整型)。从抽象数字42到二进制数表示00101010的这个过程就是编码。相应的,当计算机读取到00101010这个二进制位串时,它会根据上下文将其解释为抽象的数字42,这个过程就是解码(decode)

​ 任何‘高级’数据类型与底层二进制表示之间都有着编码与解码的过程,比如单精度浮点数,这种看上去这么基础的类型,也存在着一套相当复杂的编码过程。例如在float32中,1.0和-2.0就表示为如下的二进制串:

0 01111111 00000000000000000000000 = 1
1 10000000 00000000000000000000000 = −2

​ 字符串当然也不例外。字符串是如此的重要与基础,以至于几乎所有语言都将其作为内置类型而实现。字符串,它首先是一个串(String),所谓串,就是由同类事物依序构成的序列。对于字符串而言,就是由**字符(Character)**构成的序列。字符串或字符的编码,实际上就是将抽象的字符序列映射为其二进制表示的规则。

​ 不过,在讨论字符编码问题之前,我们先来看一看,什么是字符

字符

​ 字符是指字母、数字、标点、表意文字(如汉字)、符号、或者其他文本形式的书写“原子”。它是书面语中最小语义单元的抽象实体。这里说的字符都是抽象字符(abstract character),其确切定义是:用于组织、控制、显示文本数据的信息单元。

​ 抽象字符是一种抽象的符号,与具体的形式无关:区分字符(character)字形(Glyph)是非常重要的,我们在屏幕上看到的有形的东西是字形(Glyph),它是抽象字符的视觉表示形式。抽象字符通过渲染(Render)呈现为字形,用户界面呈现的字形通过人眼被感知,通过人脑被认知,最终又在人的大脑中还原为抽象的实体概念。字形在这个过程中起到了媒介的作用,但决不能将其等价为抽象字符本身。

​ 要注意的是,虽然多数时候字形与字符是一一对应的,但仍然存在一些多对多的情况:一个字形可能由多个字符组合而成,例如抽象字符à(拼音中的第四声a),我们将其视作单个‘字符’,但它既可以真的是一个单独的字符,也可以由字符a与去声撇号字符 ̀组合而成。另一方面,一个字符也可能由多个字形组成,例如很多阿拉伯语印地语中的文字,由很多图元(字形)组成的符号,复杂地像一幅画,实际上却是单个字符。

>>> print u'\u00e9', u'e\u0301',u'e\u0301\u0301\u0301'
é é é́́

​ 字形的集合构成了字体(font),不过那些都属于渲染的内容:渲染是将字符序列映射为字形序列的过程。 那是另一个堪比字符编码的复杂主题,本文不会涉及渲染的部分,而专注于另一侧:将抽象字符转变为二进制字节序列的过程,即,字符编码(Character Encoding)

思路

​ 我们会想,如果有一张表,能将所有的字符一一映射到字节byte(s),问题不就解决了吗?实际上对于英文和一些西欧文字而言,这么做是很直观的想法,ASCII就是这样做的:它通过ASCII编码表,使用一个字节中的7位,将128个字符编码为相应的二进制值,一个字符正好对应一个字节(单射而非满射,一半字节没有对应字符)。一步到位,简洁、清晰、高效。

​ 计算机科学发源于欧美,因而文本处理的问题,一开始指的就是英文处理的问题。不过计算机是个好东西,世界各族人民都想用。但语言文字是一个极其复杂的问题:学一门语言文字已经相当令人头大,更别提设计一套能够处理世界各国语言文字的编码标准了。从简单的ASCII发展到当代的大一统Unicode标准,人们遇到了各种问题,也走过一些弯路。

​ 好在计算机科学中,有句俗语:“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”。字符编码的模型与架构也是随着历史而不断演进的,下面我们就先来概览一下现代编码模型中的体系结构。

0x02 模型概览

  • 现代编码模型自底向上分为五个层次:
  • 抽象字符表(Abstract Character Repertoire, ACR)
  • 编码字符集(Coded Character Set, CCS)
  • 字符编码表(Character Encoding Form, CEF)
  • 字符编码方案(Character Encoding Schema, CES)
  • 传输编码语法(Transfer Encoding Syntax, TES)

我们所熟悉的诸多名词,都可以归类到这个模型的相应层次之中。例如,Unicode字符集(UCS),ASCII字符集,GBK字符集,这些都属于编码字符集CCS;而常见的UTF8,UTF16,UTF32这些概念,都属于字符编码表CEF,不过也有同名的字符编码方案CES。而我们熟悉的base64URLEncode这些就属于传输编码语法TES

​ 这些概念之间的关系可以用下图表示:

​ 可以看到,为了将一个抽象字符转换为二进制,中间其实经过了几次概念的转换。在抽象字符序列与字节序列间还有两种中间形态:码位序列与码元序列。简单来说:

  • 所有待编码的抽象字符构成的集合,称为抽象字符集

  • 因为我们需要指称集合中的某个具体字符,故为每一个抽象字符指定一个唯一的自然数作为标识,这个被指定的自然数,就称作字符的码位(Code Point)

  • 码位与字符集中的抽象字符是一一对应的。抽象字符集中的字符经过编码就形成了编码字符集

  • 码位是正整数,但计算机的整数表示范围是有限的,因此需要调和无限的码位与有限的整型之间的矛盾。字符编码表码位映射为码元序列(Code Unit Sequence),将整数转变为计算机中的整型。

  • 计算机中多字节整型存在大小端字节序的问题,字符编码方案指明了字节序问题的解决方案。

Unicode标准为什么不像ASCII那样一步到位,直接将抽象字符映射为二进制表示呢?实际上如果只有一种字符编码方案,譬如UTF-8,那么确实是一步到位的。可惜因为一些历史原因(比如觉得65536个字符绝对够用了…),我们有好几种编码方案。但不论如何,比起各国自己搞的百花齐放的编码方案,Unicode的编码方案已经是非常简洁了。可以说,每一层都是为了解决一些问题而不得已引入的:

  • 抽象字符集到编码字符集解决了唯一标识字符的问题(字形无法唯一标识字符);
  • 编码字符集到字符编码表解决了无限的自然数有限的计算机整型的映射问题(调和无限与有限);
  • 字符编码方案则解决了字节序的问题(解决传输歧义)。

下面让我们来看一下每个层次之间的细节。

0x03 字符集

​ 字符集,顾名思义就是字符的集合。字符是什么,在第一节中已经解释过了。在现代编码模型中, 有两种层次不同的字符集:抽象字符集 ACR编码字符集 CCS

抽象字符集 ACR

​ 抽象字符集顾名思义,指的是抽象字符的集合。已经有了很多标准的字符集定义,US-ASCII, UCS(Unicode),GBK这些我们耳熟能详的名字,都是(或至少是)抽象字符集

​ US-ASCII定义了128个抽象字符的集合。GBK挑选了两万多个中日韩汉字和其他一些字符组成字符集,而UCS则尝试去容纳一切的抽象字符。它们都是抽象字符集。

  • 抽象字符 英文字母A同时属于US-ASCII, UCS, GBK这三个字符集。
  • 抽象字符 中文文字不属于US-ASCII,属于GBK字符集,也属于UCS字符集。
  • 抽象文字 Emoji 😂不属于US-ASCII与GBK字符集,但属于UCS字符集。

抽象字符集可以使用类似set的数据结构来表示:

# ACR
{"a","啊","あ","Д","α","å","😯"}

编码字符集 CCS

​ 集合的一个重要特性,就是无序性。集合中的元素都是无序的,所以抽象字符集中的字符都是无序的

​ 这就带来一个问题,如何指称字符集中的某个特定字符呢?我们不能抽象字符的字形来指代其实体,原因如前面所说,看上去一样的字形,实际上可能由不同的字符组合而成(如字形à就有两种字符组合方式)。对于抽象字符,我们有必要给它们分配唯一对应的ID,用关系型数据库的话来说,字符数据表需要一个主键。这个码位分配(Code Point Allocation)的操作就称为编码(Encode)。它将抽象字符与一个正整数关联起来。

​ 如果抽象字符集中的所有字符都有了对应的码位(Code Point/Code Position),这个集合就升级成了映射:类似于从set数据结构变成了dict。我们称这个映射为编码字符集 CCS

# CCS
{
  "a": 97,
  "啊": 21834,
  "あ": 12354,
  "Д": 1044,
  "α": 945,
  "å": 229,
  "😯": 128559
}

​ 注意这里的映射是单射,每个抽象字符都有唯一的正整数码位,但并不是所有的正整数都有对应的抽象字符。码位被分为七大类:图形,格式,控制,代理,非字符,保留。像代理(Surrogate, D800-DFFF)区中的码位,单独使用时就不对应任何字符。

​ 抽象字符集与编码字符集之间的区别通常是Trivial的,毕竟指定字符的同时通常也会指定一个顺序,为每个字符分配一个数字ID。所以我们通常就将它们统称为字符集。字符集解决的问题是,将抽象字符单向映射为自然数。那么既然计算机已经解决了整数编码的问题,是不是直接用字符码位的整型二进制表示就可以了呢?

​ 不幸的是,还有另外一个问题。字符集有开放与封闭之分,譬如ASCII字符集定义了128个抽象字符,再也不会增加。它就是一个封闭字符集。而Unicode尝试收纳所有的字符,一直在不断地扩张之中。截止至2016.06,Unicode 9.0.0已经收纳了128,237个字符,并且未来仍然会继续增长,它是一个开放的字符集。开放意味着字符的数量是没有上限的,随时可以添加新的字符,例如Emoji,几乎每年都会有新的表情字符被引入到Unicode字符集中。这就产生了一对内在的矛盾:无限的自然数与有限的整型值之间的矛盾

​ 而字符编码表,就是为了解决这个问题的。

0x04 字符编码表

​ 字符集解决了抽象字符到自然数的映射问题,将自然数表示为二进制就是字符编码的另一个核心问题了。字符编码表(CEF)会将一个自然数,转换为一个或多个计算机内部的整型数值。这些整型数值称为码元码元是能用于处理或交换编码文本的最小比特组合

​ 码元与数据的表示关系紧密,通常计算机处理字符的码元为一字节的整数倍:1字节,2字节,4字节。对应着几种基础的整型:uint8, uint16, uint32,单字节、双字节、四字节整型。整形的计算往往以计算机的字长作为一个基础单元,通常来讲,也就是4字节或8字节。

​ 曾经,人们以为使用16位短整型来表示字符就足够了,16位短整型可以表示2的十六次方个状态,也就是65536个字符,看上去已经足够多了。但是程序员们很少从这种事情上吸取教训:光是中国的汉字可能就有十万个,一个旨在兼容全世界字符的编码不能不考虑这一点。因此如果使用一个整型来表示一个码位,双字节的短整型int16并不足以表示所有字符。另一方面,四字节的int32能表示约41亿个状态,在进入星辰大海宇宙文明的阶段之前,恐怕是不太可能有这么多的字符需要表示的。(实际上到现在也就分配了不到14万个字符)。

​ 根据使用码元单位的不同,我们有了三种字符编码表:UTF8,UTF-16,UTF-32。

属性\编码 UTF8 UTF16 UTF32
使用码元 uint8 uint16 uint32
码元长度 1byte = 8bit 2byte = 16bit 4byte = 32bit
编码长度 1码位 = 1~4码元 1码位 = 1或2码元 1码位 = 1码元
独门特性 兼容ASCII 针对BMP优化 定长编码

定长编码与变长编码

​ 双字节的整数只能表示65536个状态,对于目前已有的十四万个字符显得捉襟见肘。但另一方面,四字节整数可以表示约42亿个状态。恐怕直到人类进入宇宙深空时都遇不到这么多字符。因此对于码元而言,如果采用四字节,我们可以确保编码是定长的:一个(表示字符的)自然数码位始终能用一个uint32表示。但如果使用uint8uint16作为码元,超出单个码元表示范围的字符就需要使用多个码元来表示了。因此是为变长编码。因此,UTF-32是定长编码,而UTF-8和UTF-16是变长编码。

​ 设计编码时,容错是最为重要的考量之一:计算机并不是绝对可靠的,诸如比特反转,数据坏块等问题是很有可能遇到的。字符编码的一个基本要求就是自同步(self-synchronization )。对于变长编码而言,这个问题尤为重要。应用程序必须能够从二进制数据中解析出字符的边界,才可以正确解码字符。如果如果文本数据中出现了一些细微的错漏,导致边界解析错误,我们希望错误的影响仅仅局限于那个字符,而不是后续所有的文本边界都失去了同步,变成乱码无法解析。

​ 为了保证能够从编码的二进制中自然而然的凸显出字符边界,所有的变长编码方案都应当确保编码之间不会出现重叠(Overlap):譬如一个双码元的字符,其第二个码元本身不应当是另一个字符的表示,否则在出现错误时,程序无法分辨出它到底是一个单独的字符,还是某个双码元字符的一部分,就达不到自同步的要求。我们在UTF-8和UTF-16中可以看到,它们的编码表都是针对这一于要求而设计的。

​ 下面让我们来看一下三种具体的编码表:UTF-32, UTF-16, UTF-8。

UTF32

​ 最为简单的编码方案,就是使用一个四字节标准整型int32表示一个字符,也就是采用四字节32位无符号整数作为码元,即,UTF-32。很多时候计算机内部处理字符时,确实是这么做的。例如在C语言和Go语言中,很多API都是使用int来接收单个字符的。

​ UTF-32最突出的特性是定长编码,一个码位始终编码为一个码元,因此具有随机访问与实现简单的优势:第n个字符,就是数组中的第n个码元,使用简单,实现更简单。当然这样的编码方式有个缺陷:特别浪费存储。虽然总共有十几万个字符,但即使是中文,最常用的字符通常码位也落在65535以内,可以使用两个字节来表示。而对于纯英文文本而言,只要一个字节来表示一个字符就足够了。因此使用UTF32可能导致二至四倍的存储消耗,都是真金白银啊。当然在内存与磁盘容量没有限制的时候,用UTF32可能是最为省心的做法。

UTF16

​ UTF16是一种变长编码,使用双字节16位无符号整型作为码元。位于U+0000-U+FFFF之间的码位使用单个16位码元表示,而在U+10000-U+10FFFF之间的码位则使用两个16位的码元表示。这种由两个码元组成的码元对儿,称为代理对(Surrogate Paris)

​ UTF16是针对**基本多语言平面(Basic Multilingual Plane, BMP)**优化的,也就是码位位于U+FFFF以内可以用单个16位码元表示的部分。Anyway,对于落在BMP内的高频常用字符而言,UTF-16可以视作定长编码,也就有着与UTF32一样随机访问的好处,但节省了一倍的存储空间。

​ UTF-16源于早期的Unicode标准,那时候人们认为65536个码位足以表达所有字符了。结果汉字一种文字就足够打爆它了……。**代理(Surrogate)**就是针对此打的补丁。它通过预留一部分码位作为特殊标记,将UTF-16改造成了变长编码。很多诞生于那一时期的编程语言与操作系统都受此影响(Java,Windows等)

​ 对于需要权衡性能与存储的应用,UTF-16是一种选择。尤其是当所处理的字符集仅限于BMP时,完全可以假装它是一种定长编码。需要注意的是UTF-16本质上是变长的,因此当出现超出BMP的字符时,如果以定长编码的方式来计算处理,很可能会出现错误,甚至崩溃。这也是为什么很多应用无法正确处理Emoji的原因。

UTF8

​ UTF8是一种完完全全的变长编码,它使用单字节8位无符号整数作为码元。0xFF以内的码位使用单字节编码,且与ASCII保持完全一致;U+0100-U+07FF之间的码位使用两个字节;U+0800到U+FFFF之间的码位使用三字节,超出U+FFFF的码位使用四字节,后续还可以继续扩展到最多用7个字节来表示一个字符。

​ UTF8最大的优点,一是面向字节编码,二是兼容ASCII,三是能够自我同步。众所周知,只有多字节的类型才会存在大小端字节序的问题,如果码元本身就是单个字节,就压根不存在字节序的问题了。而兼容性,或者说ASCII透明性,使得历史上海量使用ASCII编码的程序与文件无需任何变动就能继续在UTF-8编码下继续工作(ASCII范围内)。最后,自我同步机制使得UTF-8具有良好的容错性。

​ 这些特性这使得UTF-8非常适合用于信息的传输与交换。互联网上大多数文本文件的编码都是UTF-8。而Go、Python3也采用了UTF-8作为其默认编码。

​ 当然,UTF-8也是有代价的。对于中文而言,UTF-8通常使用三个字节进行编码。比起双字节编码而言带来了50%的额外存储开销。与此同时,变长编码无法进行随机访问字符,也使得处理相比“定长编码”更为复杂,也会有更高的计算开销。对于正确性不甚在乎,但对性能有严苛要求的中文文字处理应用可能不会喜欢UTF-8。

​ UTF-8的一个巨大优势就在于,它没有字节序的问题。而UTF-16与UTF-32就不得不操心大端字节在前还是小端字节在前的问题了。这个问题通常在**字符编码方案(Character Encoding Schema)**中通过BOM来解决。

字符编码方案

​ 字符编码表 CEF解决了如何将自然数码位编码为码元序列的问题,无论使用哪种码元,计算机中都有相应的整型。但我们可以说编码问题就解决了吗?还不行,假设一个字符按照UTF16拆成了若干个码元组成的码元序列,因为每个码元都是一个uint16,实际上各由两个字节组成。因此将码元序列化为字节序列的时候,就会遇到一些问题:每个码元究竟是高位字节在前还是低位字节在前呢?这就是大小端字节序问题。

​ 对于网络交换和本地处理,大小端序各有优劣,因此不同的系统往往也会采用不同的大小端序。为了标明二进制文件的大小端序,人们引入了**字节序标记(Byte Order Mark, BOM)**的概念。BOM是放置于编码字节序列开始处的一段特殊字节序列,用于表示文本序列的大小端序。

​ 字符编码方案,实质上就是带有字节序列化方案的字符编码表。即:CES = 解决端序问题的CEF。对于大小端序标识方法的不同选择,产生了几种不同的字符编码方案:

  • UTF-8:没有端序问题。
  • UTF-16LE:小端序UTF-16,不带BOM
  • UTF-16BE:大端序UTF-16,不带BOM
  • UTF-16:通过BOM指定端序
  • UTF-32LE:小端序UTF-32,不带BOM
  • UTF-32BE:大端序UTF-32,不带BOM
  • UTF-32:通过BOM指定端序

UTF-8因为已经采用字节作为码元了,所以实际上不存在字节序的问题。其他两种UTF,都有三个相应地字符编码方案:一个大端版本,一个小端版本,还有一个随机应变大小端带 BOM的版本。

​ 当然要注意,在当前上下文中的UTF-8,UTF-16,UTF-32其实是CES层次的概念,即带有字节序列化方案的CEF,这会与CEF层次的同名概念产生混淆。因此,当我们在说UTF-8,UTF-16,UTF-32时,一定要注意区分它是CEF还是CES。例如,作为一种编码方案的UTF-16产生的字节序列是会带有BOM的,而作为一种编码表的UTF-16产生的码元序列则是没有BOM这个概念的。

0x05 UTF-8

​ 介绍完了现代编码模型,让我们深入看一下一个具体的编码方案:UTF-8。 UTF-8将Unicode码位映射成1~4个字节,满足如下规则:

标量值 字节1 字节2 字节3 字节4
00000000 0xxxxxxx 0xxxxxxx
00000yyy yyxxxxxx 110yyyyy 10xxxxxx
zzzzyyyy yyxxxxxx 1110zzzz 10yyyyyy 10xxxxxx
000uuuuu zzzzyyyy yyxxxxxx 11110uuu 10uuzzzz 10yyyyyy 10xxxxxx

其实比起死记硬背,UTF-8的编码规则可以通过几个约束自然而然地推断出来:

  1. 与ASCII编码保持兼容,因此有第一行的规则。
  2. 需要有自我同步机制,因此需要在首字节中保有当前字符的长度信息。
  3. 需要容错机制,码元之间不允许发生重叠,这意味着字节2,3,4,…不能出现字节1可能出现的码元。

0, 10, 110, 1110, 11110, …这些是不会发生冲突的字节前缀,0前缀被ASCII兼容规则对应的码元用掉了。次优的10前缀就分配给后缀字节作为前缀,表示自己是某个字符的外挂部分。相应地,110,1110,11110这几个前缀就用于首字节中的长度标记,例如110前缀的首字节就表示当前字符还有一个额外的外挂字节,而1110前缀的首字节就表示还有两个额外的外挂字节。因此,UTF-8的编码规则其实非常简单。下面是使用Go语言编写的函数,展示了将一个码位编码为UTF-8字节序列的逻辑:

func UTF8Encode(i uint32) (b []byte) {
	switch {
	case i <= 0xFF: 	/* 1 byte */
		b = append(b, byte(i))
	case i <= 0x7FF: 	/* 2 byte */
		b = append(b, 0xC0|byte(i>>6))
		b = append(b, 0x80|byte(i)&0x3F)
	case i <= 0xFFFF: 	/* 3 byte*/
		b = append(b, 0xE0|byte(i>>12))