This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Pigsty 博客文章

收录了与 Pigsty ,云计算,数据库领域有关的文章,以及关于PostgreSQL 开发,管理,内核原理的笔记

数据库

关于数据库行业的文章:动态,新闻,调查,理念,最佳实践等等

数据库老司机:合订本

微信公众号原文

最近在技术圈有一些热议的话题,云数据库是不是智商税?公有云是不是杀猪盘分布式数据库是不是伪需求微服务是不是蠢主意你还需要运维和DBA吗中台是不是一场彻头彻尾的自欺欺人?在Twitter与HackerNews上也有大量关于这类话题的讨论与争辩。

在这些议题的背后的脉络是大环境的改变:降本增效压倒其他一切,成为绝对的主旋律。开发者体验,架构可演化性,研发效率这些属性依然重要,但在 ROI 面前都要让路 —— 社会思潮与根本价值观的变化会触发所有技术的重新估值。

有人说,互联网公司砍掉一半人依然可以正常运作,只不过老板不知道是哪一半。现在收购推特的马斯克刷新了这个记录:截止到2023年5月份,推特已经从8000人一路裁员 90% 到现在的不足千人,而依然不影响其平稳运行。这个结果彻底撕下大公司病冗员问题的遮羞布,其余互联网大厂早晚会跟进,掀起新一轮大规模裁员的血雨腥风

在经济繁荣期,大家可以有余闲冗员去自由探索,也可以使劲儿吹牛造害铺张浪费炒作。但在经济萧条下行阶段,所有务实的企业与组织都会开始重新审视过往的利弊权衡。同样的事情不仅会发生在人上,也会发生在技术上,这是实体世界的危机传导到技术界的表现:泡沫总会在某个时刻需要出清,而这件事已正在发生中。

公有云,Kubernetes,微服务,云数据库,分布式数据库,大数据全家桶,Serverless,HTAP,Microservice,等等等等,所有这些技术与理念都将面临拷问:有些事不上秤没有四两,上了秤一千斤也打不住。这个过程必然伴随着怀疑、痛苦,伤害与毁灭,但也孕育着希望,喜悦,发展与新生。花里胡哨华而不实的东西会消失在历史长河里,大浪淘沙能存留下来的才是真正的好技术。

在这场技术界的惊涛骇浪中,需要有人透过现象看本质,脚踏实地的把各项技术的好与坏,适用场景与利弊权衡讲清楚。而我本人愿意作为一个亲历者,见证者,评叙者,参与者躬身入局,加入其中。这里拟定了一个议题列表集,名为《正本清源:技术反思录》,将依次撰文讨论评论业界关心的热点与技术:


写作计划

云数据库是不是智商税

云盘是不是杀猪盘?

分布式数据库是不是伪需求?》

《国产数据库是不是大跃进?》

《TPC-C打榜是不是放卫星?》

《信创数据库是不是恰烂钱?》

《谁卡住了中国数据库的脖子?》

微服务是不是蠢主意?》

《Serverless是不是榨钱术?》

《RCU/WCU计费是不是阳谋杀猪?》

《数据库到底要不要放入K8S?》

《HTAP是不是纸上谈兵?》

《单机分布式一体化是不是脱裤放屁?》

《你真的需要专用向量数据库吗?》

《你真的需要专用时序数据库吗?》

《你真的需要专用地理数据库吗?》

《APM时序数据库选型姿势指北》

《202x数据库选型指南白皮书》

《开源崛起:商业数据库还能走多远?》

范式转移:云原生能否干翻公有云?

本地优先:你是否真的需要 XaaS?

《云厂商的 SLA 到底靠不靠得住?》

《大厂技术管理思想真的先进吗?》

《卷数据库内核还有没有出路?》

《用户到底需要什么样的数据库?》

《再搞 MySQL 还有没有前途?》

炮打 RDS —— 我的一张大字报

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

如果您有任何认为值得讨论的话题,也欢迎在评论区中留言提出,我将视情况加入列表中。

Redis不开源是“开源”之耻,更是公有云之耻

最近 Redis 修改其协议引发了争议:它从 7.4 起使用 RSALv2 与 SSPLv1,不再满足 OSI 关于 “开源软件” 的定义。但不要搞错:Redis “不开源” 不是 Redis 的耻辱,而是“开源/OSI”的耻辱 —— 它反映出开源组织/理念的过气。

当下软件自由的头号敌人是公有云服务。“开源” 与 “闭源” 也不再是软件行业的核心矛盾,斗争的焦点变为 “云上服务” 与 “本地优先”。公有云厂商搭着开源软件的便车白嫖社区的成果,这注定会引发社区的强烈反弹。

在抵御云厂商白嫖的实践中,修改协议是最常见的做法:但AGPLv3 过于严格容易敌我皆伤,SSPL 因为明确表达这种敌我歧视,不被算作开源。业界需要一种新的歧视性软件许可证协议,来达到名正言顺区分敌我的效果。

真正重要的事情一直都是软件自由,而“开源”只是实现软件自由的一种手段。而如果“开源”的理念无法适应新阶段矛盾斗争的需求,甚至会妨碍软件自由,它一样会过气,并不再重要,并最终被新的理念与实践所替代。


修改协议的开源软件

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

Redis Labs 首席执行官 Ofer Bengal

Redis 在过去几年中一直都是开发者最喜爱的数据库系统(在去年被 PostgreSQL 超过),采用了非常友善的 BSD-3 Clause 协议,并被广泛应用在许多地方。然而,几乎所有的公有云上都可以看到云 Redis 数据库服务,云厂商靠它赚的钵满盆翻,而支付研发成本的 Redis 公司和开源社区贡献者被搁在一边。这种不公平的生产关系,注定会招致猛烈的反弹。

Redis 切换为更为严格的 SSPL 协议的核心原因,用 Redis Labs CEO 的话讲就是:“多年来,我们就像个傻子一样,他们拿着我们开发的东西大赚了一笔”。“他们”是谁? —— 公有云。切换 SSPL 的目的是,试图通过法律工具阻止这些云厂商白嫖吸血开源,成为体面的社区参与者,将软件的管理、监控、托管等方面的代码开源回馈社区。

不幸的是,你可以强迫一家公司提供他们的 GPL/SSPL 衍生软件项目的源码,但你不能强迫他们成为开源社区的好公民。公有云对于这样的协议往往也嗤之以鼻,大多数云厂商只是简单拒绝使用AGPL许可的软件:要么使用一个采用更宽松许可的替代实现版本,要么自己重新实现必要的功能,或者直接购买一个没有版权限制的商业许可。

当 Redis 宣布更改协议后,马上就有 AWS 员工跳出来 Fork Redis —— “Redis 不开源了,我们的分叉才是真开源!” 然后 AWS CTO 出来叫好,并假惺惺的说:这是我们员工的个人行为 —— 堪称是现实版杀人诛心。

图:AWS CTO 转评员工 Fork Redis

被这样搞过的并非只有 Redis 一家。发明 SSPL 的 MongoDB 也是这个样子 —— 当 2018 年 MongoDB 切换至 SSPL 时,AWS 就搞了一个所谓 “API兼容“ 的 DocumentDB 来恶心它。ElasticSearch 修改协议后,AWS 就推出了 OpenSearch 作为替代。头部 NoSQL 数据库都已经切换到了 SSPL,而 AWS 也都搞出了相应的“开源替代”。

因为引入了额外的限制与所谓的“歧视”条款,OSI 并没有将 SSPL 认定为开源协议。因此使用 SSPL 的举措被解读为 —— “Redis 不再开源”,而云厂商的各种 Fork 是“开源”的。从法律工具的角度来说,这是成立的。但从朴素道德情感出发,这样的说法对于 Redis 来说是极其不公正地抹黑与羞辱。


正如罗翔老师所说:法律工具的判断永远不能超越社区成员朴素的道德情感。如果协和与华西不是三甲,那么丢脸的不是这些医院,而是三甲这个标准。如果年度游戏不是巫师3,荒野之息,博德之门,那么丢脸的不是这些厂商,而是评级机构。如果 Redis 不再算“开源”,真正应该感到耻辱的是OSI,与开源这个理念。

越来越多的知名开源软件,都开始切换到敌视针对云厂商白嫖的许可证协议上来。不仅仅是 Redis,MongoDB,与 ElasticSearch 。MinIO 与 Grafana 分别在 2020,2021年从 Apache v2 协议切换到了 AGPLv3 协议。HashipCrop 的各种组件,MariaDB MaxScale, Percona MongoDB 也都使用了风格类似的 BSL 协议。

一些老牌的开源项目例如 PostgreSQL ,正如PG核心组成员 Jonathan 所说,三十年的声誉历史沉淀让它们已经在事实上无法变更开源协议 了。但我们可以看到,许多新强力的 PostgreSQL 扩展插件开始使用 AGPLv3 作为默认的开源协议,而不是以前默认使用的 BSD-like / PostgreSQL 友善协议。例如分布式扩展 Citus,列存扩展 Hydra,ES全文检索替代扩展 BM25,OLAP 加速组件 PG Analytics …… 等等等等。

包括我们自己的 PostgreSQL 发行版 Pigsty,也在 2.0 的时候由 Apache 协议切换到了 AGPLv3 协议,背后的动机都是相似的 —— 针对软件自由的最大敌人 —— 云厂商进行反击。我们改变不了存量,但对于增量功能,是可以做出有效的回击与改变的。

在抵御云厂商白嫖的实践中,修改协议是最常见的做法:AGPLv3 是一种比较主流的实践,更激进的 SSPL 因为明确表达这种敌我歧视,不被算作开源。使用双协议进行明确的边界区分,也开始成为一种主流的开源商业化实践。但重要的是:业界需要一种新的歧视性软件许可证协议,达到名正言顺辨识敌我,区别对待的效果 —— 来解决软件自由在当下面临的最大挑战 —— 云服务。


软件行业的范式转移

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

在当下,软件自由的头号敌人是云计算租赁服务。“开源” 与 “闭源” 也不再是软件行业的核心矛盾,斗争的焦点变为 “云上服务” 与 “本地优先”。要理解这一点,我们要回顾一下软件行业的几次主要范式转移,以数据库为例:

paradigm-title.png

最初,软件吞噬世界,以 Oracle 为代表的商业数据库,用软件取代了人工簿记,用于数据分析与事务处理,极大地提高了效率。不过 Oracle 这样的商业数据库非常昂贵,vCPU·月光是软件授权费用就能破万,往往只有金融行业,大型机构才用得起,即使像如淘宝这样的互联网巨头,上了量后也不得不”去O“。

接着,开源吞噬软件,像 PostgreSQL 和 MySQL 这样”开源免费“的数据库应运而生。软件开源本身是免费的,每核每月只需要几十块钱的硬件成本。大多数场景下,如果能找到一两个数据库专家帮企业用好开源数据库,那可是要比傻乎乎地给 Oracle 送钱要实惠太多了。

然后,云吞噬开源。公有云软件,是互联网大厂将自己使用开源软件的能力产品化对外输出的结果。公有云厂商把开源数据库内核套上壳,包上管控软件跑在托管硬件上,并建设共享开源专家池提供咨询与支持,便成了云数据库服务 (RDS)。20 ¥/核·月的硬件资源通过包装,变为了 300 ~ 1300 ¥/核·月的天价 RDS 服务。

曾经,软件自由的最大敌人是商业闭源软件,以微软,甲骨文为代表 —— 许多开发者依然对拥抱开源之前的微软名声有着深刻印象,甚至可以说整个自由软件运动正是源于 1990 年代的反微软情绪。但是,自由软件与开源软件的概念已经彻底改变了软件世界:商业软件公司耗费了海量资金与这个想法斗争了几十年。最终还是难以抵挡开源软件的崛起 —— 开源软件打破了商业软件的垄断,让软件这种IT业的核心生产资料变为全世界开发者公有,按需分配。开发者各尽所能,人人为我,我为人人,这直接催生了互联网的黄金繁荣时代。

开源并不是一种商业模式,甚至是一种强烈违反商业化逻辑的模式。然而,任何可持续发展的模式都需要获取资源以支付成本,开源也不例外。开源真正的模式是 —— 通过免费的软件创造高薪技术专家岗位。分散在不同企业组织中的开源专家,产消合一者 (Pro-sumer),是(纯血)开源软件社区的核心力量 —— 免费的开源软件吸引用户,用户需求产生开源专家岗位,开源专家共创出更好的开源软件。开源专家作为组织的代理人,从开源社区,集体智慧成果中汲取力量。组织享受到了开源软件的好处(软件自由,无商业软件授权费),而分散的雇主可以轻松兜住住这些专家的薪资成本。

然而公有云,特别是云软件的出现破坏了这种生态循环 —— 几个云巨头尝试垄断开源专家供给,重新尝试在 用好开源软件(服务)这个维度上,实现商业软件没能实现的垄断。 云厂商编写了开源软件的管控软件,组建了专家池,通过提供维护攫取了软件生命周期中的绝大部分价值,并通过搭便车的行为将最大的成本 —— 产研交由整个开源社区承担。而 真正有价值的管控/监控代码却从来不回馈开源社区。而更大的伤害在于 —— 公有云就像头部带货主播消灭大量本地便利店一样,摧毁了大量的开源就业岗位,掐断了开源社区的人才流动与供给。


计算自由的头号敌人

在 2024 年,软件自由的真正敌人,是云服务软件!

开源软件带来了巨大的行业变革,可以说,互联网的历史就是开源软件的历史。互联网公司是依托开源软件繁荣起来的,而公有云是从头部互联网公司孵化出来的。公有云的历史,就是一部屠龙勇者变为新恶龙的故事。

云刚出现的时候,它也曾经是一位依托开源 挑战传统 IT 市场恶龙的勇者,挥舞着大棒砸烂“企业级”杀猪盘。他们关注的是硬件 / IaaS层 :存储、带宽、算力、服务器。云厂商的初心故事是:让计算和存储资源像水电一样,自己扮演基础设施的提供者的角色。这是一个很有吸引力的愿景:公有云厂商可以通过规模效应,压低硬件成本并均摊人力成本;理想情况下,在给自己留下足够利润的前提下,还可以向公众提供比 IDC 价格更有优势,更有弹性的存储算力(实际上也并不便宜)。

然而随着时间的推移,这位曾经的屠龙英雄逐渐变成了他曾经发誓打败的恶龙 —— 一个新的“杀猪盘”,对用户征收高昂无专家税与“保护费”。这对应着云软件( PaaS / SaaS ),它与云硬件有着迥然不同的商业逻辑:云硬件靠的是规模效应,优化整体效率赚取资源池化超卖的钱,算是一种效率进步。而云软件则是靠共享专家,提供运维外包来收取服务费。公有云上大量的软件,本质是吸血白嫖开源社区搭便车,抢了分散在各个企业中开源工程师的饭碗,依靠的是信息不对称、专家垄断、用户锁定收取天价服务费,是一种价值的攫取转移,对原有的生态模式的破坏。

不幸的是,出于混淆视线的目的,云软件与云硬件都使用了“云”这个名字。因而在云的故事中,同时混掺着将算力普及到千家万户的理想主义光辉,与达成垄断攫取不义利润的贪婪。

云软件,即主要在供应商的服务器上运行的软件,而你的所有数据也存储在这些服务器上。以云数据库为代表的 PaaS ,以及各类只能通过租赁提供服务的 SaaS 都属于此类。这些“云软件”也许有一个客户端组件(手机App,网页控制台,跑在你浏览器中的 JavaScript),但它们只能与供应商的服务端共同工作。而云软件存在很多问题:

  • 如果云软件供应商倒闭或停产,您的云软件就歇菜了,而你用这些软件创造的文档与数据就被锁死了。例如,很多初创公司的 SaaS 服务会被大公司收购,而大公司没有兴趣继续维护这些产品。
  • 云服务可能在没有任何警告和追索手段的情况下突然暂停您的服务(例如 Parler )。您可能在完全无辜的情况下,被自动化系统判定为违反服务条款:其他人可能入侵了你的账户,并在你不知情的情况下使用它来发送恶意软件或钓鱼邮件,触发违背服务条款。因而,你可能会突然发现自己用各种云文档或其它App创建的文档全部都被永久锁死无法访问。
  • 运行在你自己的电脑上的软件,即使软件供应商破产倒闭,它也可以继续跑着,想跑多久跑多久。相比之下,如果云软件被关闭,你根本没有保存的能力,因为你从来就没有服务端软件的副本,无论是源代码还是编译后的形式。
  • 云软件极大加剧了软件的定制与扩展难度,在你自己的电脑上运行的闭源软件,至少有人可以对它的数据格式进行逆向工程,这样你至少有个使用其他替代软件的PlanB。而云软件的数据只存储在云端而不是本地,用户甚至连这一点都做不到了。

如果所有软件都是免费和开源的,这些问题就都自动解决了。然而,开源和免费实际上并不是解决云软件问题的必要条件;即使是收钱的或者闭源的软件,也可以避免上述问题:只要它运行在你自己的电脑、服务器、机房上,而不是供应商的云服务器上就可以。拥有源代码会让事情更容易,但这并不是不关键,最重要的还是要有一份软件的本地副本

在当今,云软件,而不是闭源软件或商业软件,成为了软件自由的头号威胁。云软件供应商可以在您无法审计,无法取证,无法追索的情况下访问您的数据,或突然心血来潮随心所欲地锁定你的所有数据,这种可能性的潜在危害,要比无法查看和修改软件源码的危害大得多。与此同时,也有不少公有云厂商渗透进入开源社区,并将“开源”视作一种获客营销包装、或形成垄断标准的手段,作为吸引用户的钓饵,而不是真正追求“软件自由”的目的。

”开源“ 与 ”闭源“ 已经不再是软件行业中最核心的矛盾,斗争的焦点变为 “云” 与 “本地优先”。


自由世界如何应对挑战?

有力,就会有反作用力,云软件的崛起会引发新的制衡力量。面对云服务的挑战,已经有许多软件组织/公司做出了反应,包括但不限于:使用歧视性开源协议,法律工具与集体行动,抢夺云计算的定义权。


修改开源许可证

软件社区应对云服务挑战的最常见反应是修改许可证,如 Grafana,MinIO,Pigsty 那样修改为 AGPLv3,或者像 Redis,MongoDB,ElasticSearch 那样修改为 SSPL,或者使用双协议 / BSL 的方式。大的方向是一致的 —— 重新划定社区共同体边界,将竞争者、与敌人直接排除在社区之外。

友善、自由的互联网/软件世界离我们越来越远 —— 大爱无疆,一视同仁,始终无私奉献的圣母精神固然值得敬佩,但真正能靠自己力量活下来的,是爱憎分明,以德报德,以直报怨的勇者。这里的核心问题在于 “歧视” / 区别对待 —— 对待同志要像春天般的温暖,对待敌人要像严冬一样残酷。

业界需要一种实践来做到这一点。AGPL,SSPL,BSL 这样的协议就是一种尝试 —— 这些协议通常并不影响终端用户使用这些软件;也不影响普通的服务提供商在遵循开源义务的前提下提供支持与咨询服务;而是专门针对公有云厂商设计的 —— 管控软件 作为公有云厂商摇钱树,在事实上是难以选择开源的,因此公有云厂商被歧视性地排挤出软件社区之外。

使用 Copyleft 协议族可以将公有云厂商排除在社区之外,从而保护软件自由。然而这些协议也容易出现伤敌一千,自损一百的情况。 在更为严格的许可要求下,一部分软件自由也受到了不必要的连带损失,例如:Copyleft 协议族也与其他广泛使用的许可证不兼容,这使得在同一个项目中使用某些库的组合变得更为困难。因此业界需要更好的实践来真正落实好这一点。

例如,我们的自由 PostgreSQL 发行版 Pigsty 使用了 AGPLv3 协议,但我们添加了对普通用户的 补充豁免条款 —— 我们只保留对公有云供应商,与换皮套壳魔改同行进行违规追索的权利,对于普通终端用户来说实际执行的是 Apache 2.0 等效条款 —— 采购我们服务订阅的客户也可以得到书面承诺:不就违反 AGPLv3 的协议进行任何追索 —— 从某种意义上来说,这也是一种 “双协议” 实践。


法律工具与集体行动

Copyleft软件许可证是一种法律工具,它试图迫使更多的软件供应商公开其源码。但是对于促进软件自由而言,Martin Keppmann 相信更有前景的法律工具是政府监管。例如,GDPR提出了数据可移植权,这意味着用户必须可以能将他们的数据从一个服务转移到其它的服务中。另一条有希望的途径是,推动 公共部门的采购倾向于开源、本地优先的软件,而不是闭源的云软件。这为企业开发和维护高质量的开源软件创造了积极的激励机制,而版权条款却没有这样做。

我认为,有效的改变来自于对大问题的集体行动,而不仅仅来自于一些开源项目选择一种许可证而不是另一种。公有云反叛军联盟应该团结一切可以团结的有生力量 —— 开源平替软件社群,开发者与用户;服务器与硬件厂商,坚守 IaaS 阵地的资源云,运营商云,IDC 与 IDC 2.0,甚至是公有云厂商的 IaaS 部门。采取一切法律框架内允许的行动去推进这一点。

一种有效的对抗措施是为整个云计算技术栈提供开源替代品,例如在《云计算反叛军》中就提到 —— 云计算世界需要一个能代表开源价值观的替代解决方案。开源软件社区可以与云厂商比拼生产力 —— 组建一个反叛军同盟采取集体行动。针对公有云厂商提供服务所必不可少的管控软件,逐一研发开源替代

在云软件没有出现开源/本地优先的替代品前,公有云厂商可以大肆收割,攫取垄断利润。而一旦更好用,更简易,成本低得多的开源替代品出现,好日子便将到达终点。例如,Kubernetes / SealOS / OpenStack / KVM / Proxmox,可以理解为云厂商 EC2 / ECS / VPS 管控软件的开源替代;MinIO / Ceph 旨在作为为云厂商 S3 / OSS 管控软件的开源替代;而 Pigsty / 各种数据库 Operator 就是 RDS 云数据库管控软件的开源替代。这些开源替代品将直接击碎公有云计算的核心技术壁垒 —— 管控软件,让云的能力民主化,直接普及到每一个用户手中。


抢夺云计算的定义权

公有云可以渗透到开源社区中兴风作浪,那么开源社区也可以反向渗透,抢夺云计算的定义权。例如,对于 Cloud Native 的不同解释就生动地体现了这一点。云厂商将 Native 解释 “原生”:“原生诞生在公有云环境里的软件” 以混淆视听;但究其目的与效果而言,Native 真正的含义应为 “本地”,即与 Cloud 相对应的 “Local” —— 本地云 / 私有云 / 专有云 / 原生云 / 主权云,叫什么不重要,重要的是它运行在用户想运行的任何地方(包括云服务器),而不是仅仅是只能从公有云租赁!

这一理念,用一个单独的术语,可以概括为 “本地优先”,它与云服务针锋相对。“本地优先” 与 “” 的对立体现为多种不同的形式:有时候是 “Native Cloud” vs “Cloud Native”,有时候叫体现为 “私有云” vs “公有云”,大部分时候与 ”开源“ vs “闭源”重叠,某种意义上也牵扯着 “自主可控” vs “仰人鼻息”。

本地优先的软件在您自己的硬件上运行,并使用本地数据存储,但也不排斥运行在云 IaaS 上,同时也保留云软件的优点,比如实时协作,简化运维,跨设备同步,资源调度,灵活伸缩等等。开源的本地优先的软件当然非常好,但这并不是必须的,本地优先软件90%的优点同样适用于闭源的软件。同理,免费的软件当然好,但本地优先的软件也不排斥商业化与收费服务。


理直气壮地争取资源

最后,不得不说的一点,就是开源商业化,收钱的问题。开源软件社区应该理直气壮地赚钱与筹款 —— 自由不是免费的! Freedom is not free 早已经是老生常谈。然而,相当一部分开源贡献者与开源用户都对开源软件有着不切实际的期待与错觉。

一些用户误以为他们与维护者的关系是商业关系,因此期望获得商业供应商的客户服务标准;而一些开源贡献者也期待开源用户给予金钱、声望、场景上的互惠与回馈贡献。一方认为他们应得的比另一方认为的要多,这种不明确的结果就会走向怨恨。

开源不等于免费,尽管大部分开源软件都提供了让用户免费使用的条件,但免费的开源软件是一种没有条件的礼物。作为收礼人,用户只有选择收或不收的权利;作为送礼人,期待得到特定回报是愚蠢的。作为开源贡献者,给别人戴上氧气面罩前,请务必先戴好自己的氧气面罩。如果某个开源企业自己都无法养活自己,那么选择用爱发电,四处慷慨送礼就是不明智的。

因此,全职的开源软件的开发者与公司,必须审慎思考自己的商业模式 —— 想让项目与组织持续发展,资源是必不可少的。无论是做专门的企业版,提供服务支持与订阅,设置双协议,去拉赞助化缘卖周边,或者干脆像 Redis 一样使用所谓 “不开源” 的协议,这都无可厚非,应当是光明正大,且理直气壮的。实际上,因为开源软件为用户提供了额外的“软件自由” —— 因此在质量相同的前提下,收取比云租赁软件更高的费用也是完全合乎道德情理的!


博弈均衡会走向哪里

云计算的故事与电力的推广过程如出一辙,让我们把目光回退至上个世纪初,从电力的推广普及垄断监管中汲取历史经验 —— 供电也许会走向垄断、集中、国有化,但你管不住电器。如果云硬件(算力)类似于电力,那么云软件便是电器。生活在现代的我们难以想象:洗衣机,冰箱,热水器,电脑,竟然还要跑到电站边的机房去用,我们也很难想象,居民要由自己的发电机而不是公共发电厂来供电。

因此从长期来看,公有云厂商大概也会有这么一天:在云硬件上通过类似于电力行业,通过垄断并购与兼并形成“规模效应”,利用“峰谷电”,“弹性定价”等各种方式优化整体资源利用率,在相互斗兽竞争中将算力成本不断压低至新的底线,实现“家家有电用”。当然,最后也少不了政府监管介入,公私合营收归国有,成为如同国家电网与电信运营商类似的存在,最终实现 IaaS 层的存储带宽算力的垄断。

而与之对应,制造灯泡、空调、洗衣机这类电器的职能会从电力公司中剥离,百花齐放。云厂商的 PaaS / SaaS 在被更好,更优质,更便宜的开源替代冲击下逐渐萎缩,或回归到足够低廉的价格水平。

正如当年开源运动的死对头微软,现在也选择拥抱开源。公有云厂商肯定也会有这一天,与自由软件世界达成和解,心平气和地接受基础设施供应商的角色定位,为大家提供水与电一般的存算资源。而云软件终将会回归正常毛利,希望那一天人们能记得,这不是因为云厂商大发慈悲,而是有人带来过开源平替。

MySQL正确性竟如此垃圾?

微信公众号原文

MySQL 曾经是世界上最流行的开源关系型数据库,然而流行并不意味着先进,流行的东西也会出大问题。JEPSEN 对 MySQL 的隔离等级评测捅穿了这层窗户纸 —— 在正确性这个体面数据库产品必须的基本属性上,MySQL 的表现一塌糊涂。

MySQL 文档声称实现了 可重复读/RR 隔离等级,但实际提供的正确性保证却弱得多。JEPSEN 在 Hermitage 的研究基础上进一步指出,MySQL 的 可重复读/RR 隔离等级实际上并不可重复读,甚至既不原子也不单调,连 单调原子视图/MAV 的基本水平都不满足。

此外,能“避免”这些异常的 MySQL 可串行化/SR 隔离等级难以生产实用,也非官方文档与社区认可的最佳实践;而且在AWS RDS默认配置下,MySQL SR 也没有真正达到“串行化”的要求;而李海翔教授的对 MySQL 一致性的分析进一步指出了 SR 的设计缺陷与问题。

综上, MySQL 的 ACID 存在缺陷,且与文档承诺不符 —— 这可能会导致严重的正确性问题。尽管可以通过显式加锁等方式规避此类问题,但用户确实应当充分意识到这里的利弊权衡与风险:在对正确性/一致性有要求的场景中选用 MySQL 时,请务必保持谨慎。


正确性为什么很重要?

可靠的系统需要应对各种错误,在数据系统的残酷现实中,更是很多事情都可能出错。要保证数据不丢不错,实现可靠的数据处理是一件工作量巨大且极易错漏的事情。而事务的出现解决了这个问题。事务是数据处理领域最伟大的抽象之一,也是关系型数据库引以为傲的金字招牌和尊严所在。

事务这个抽象让所有可能的结果被归结为两种情况:要么成功完事 COMMIT,要么失败了事 ROLLBACK,有了后悔药,程序员不用再担心处理数据时半路翻车,留下数据一致性被破坏的惨不忍睹的车祸现场。应用程序的错误处理变得简单多了,因为它不用再担心部分失败的情况了。而它提供的保证,用四个单词的缩写,被概括为 ACID

acid.png

事务的原子性/A让你在提交前能随时中止事务并丢弃所有写入,相应地,事务的持久性/D则承诺一旦事务成功提交,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。事务的隔离性/I确保每个事务可以假装它是唯一在整个数据库上运行的事务 —— 数据库会确保当多个事务被提交时,结果与它们一个接一个地串行运行是一样的,尽管实际上它们可能是并发运行的。而原子性与隔离性则服务于 一致性/Consistency —— 也就是应用的正确性/Correctness —— ACID 中的C是应用的属性而非事务本身的属性,属于用来凑缩写的。

然而在工程实践中,完整的隔离性/I是很少见的 —— 用户很少会使用所谓的 “可串行化/SR” 隔离等级,因为它有可观的性能损失。一些流行的数据库如 Oracle 甚至没有实现它 —— 在 Oracle 中有一个名为 “可串行化” 的隔离级别,但实际上它实现了一种叫做 快照隔离(snapshot isolation) 的功能,这是一种比可串行化更弱的保证

ansi-sql.png

RDBMS 允许使用不同的隔离级别,供用户在性能正确性之间进行权衡。ANSI SQL92 用三种并发异常(Anomaly),划分出四种不同的隔离级别,将这种利弊权衡进行了(糟糕的)标准化。:更弱的隔离级别“理论上”可以提供更好的性能,但也会出现更多种类的并发异常(Anomaly),这会影响应用的正确性。

为了确保正确性,用户可以使用额外的并发控制机制,例如显式加锁或 SELECT FOR UPDATE ,但这会引入额外的复杂度并影响系统的简单性。对于金融场景而言,正确性是极其重要的 —— 记账错漏,对账不平很可能会在现实世界中产生严重后果;然而对于糙猛快的互联网场景而言,错漏几条数据并非不可接受 —— 正确性的优先级通常会让位于性能。 这也为伴随互联网东风而流行的 MySQL 的正确性问题埋下了祸根。


Hermitage的结果怎么说?

在介绍 JEPSEN 的研究之前,我们先来回顾一下 Hermitage 项目。。这是互联网名著 《DDIA》 作者 Martin Kelppmann 在 2014 年发起的项目,旨在评测各种主流关系数据库的正确性。项目设计了一系列并发运行的事务场景,用于评定数据库标称隔离等级的实际水平。

hermitage.png

从 Hermitage 的评测结果表格中不难看出,在主流数据库的隔离级别实现里有两处缺陷,用红圈标出:Oracle 的 可串行化/SR 因无法避免 G2 异常,而被认为实际上是 “快照隔离/SI”。

MySQL 的问题更为显著:因为默认使用的 可重复读/RR 隔离等级无法避免 PMP / G-Single 异常,Hermitage 将其实际等级定为 单调原子视图/MAV

需要指出 ANSI SQL 92 隔离等级是一个糟糕简陋且广为诟病的标准,它只定义了三种异常现象并用它们区分出四个隔离等级 —— 但实际上的异常种类/隔离等级要多得多。著名的《A Critique of ANSI SQL Isolation Levels》论文对此提出了修正,并介绍了几种重要的新隔离等级,并给出了它们之间的强弱关系偏序图(图左)。

在新的模型下,许多数据库的 “读已提交/RC” 与 “可重复读/RR” 实际上是更为实用的 “单调原子视图/MAV” 和 “快照隔离/SI” 。但 MySQL 确实别具一格:在 Hermitage 的评测中,MySQL的 可重复读/RR快照隔离/SI 相距甚远,也不满足 ANSI 92 可重复读/RR 的标准,实际水平为 **单调原子视图/MAV。**而 JEPSEN 的研究进一步指出,MySQL 可重复读/RR 实际上连 单调原子视图/MAV 都不满足,仅仅略强于 读已提交/RC


JEPSEN 又有什么新发现?

JEPSEN 是分布式系统领域最为权威的测试框架,他们最近发布了针对 MySQL 最新的 8.0.34 版本的研究与测评。建议读者直接阅读原文,以下是论文摘要:

MySQL 是流行的关系型数据库。我们重新审视了 Kleppmann (DDIA作者)在2014年发起的 Hermitage 项目结果,并确认了在当下 MySQL 的 可重复读/RR 隔离等级依然会出现 G2-item、G-single 和丢失更新异常。我们用事务一致性检查组件 —— Elle,发现了 MySQL 可重复读隔离等级也违反了内部一致性。更有甚者 —— 它违反了单调原子视图(MAV):即一个事务可以先观察到另一个事务的结果,再次尝试观察后却又无法复现同样的结果。作为彩蛋,我们还发现 AWS RDS 的 MySQL集群经常出现违反串行要求的异常。这项研究是独立进行的,没有报酬,并遵循 Jepsen研究伦理

MySQL 8.0.34 的 RURCSR 隔离等级符合 ANSI 标准的描述。且默认配置(RR,且innodb_flush_log_at_trx_commit = on)下的 持久性/D 并没有问题。问题出在MySQL 默认的 可重复读/RR 隔离等级上:

  1. 不满足 ANSI SQL92 可重复读(G2,WriteSkew)
  2. 不满足快照隔离(G-single, ReadSkew, LostUpdate)
  3. 不满足游标稳定性(LostUpdate)
  4. 违反内部一致性(Hermitage 披露)
  5. 违反读单调性(JEPSEN新披露)

mysql-level.png

MySQL RR 下的事务观察到了违反内部一致性、单调性、原子性的现象。这使得其评级被进一步调整至一个仅略高于 RC 的未定隔离等级水平上。

在 JEPSEN 的测试中共披露了六项异常,其中在2014年已知的问题我们先跳过,这里重点关注 JEPSEN 的新发现的异常,下面是几个具体的例子。


隔离性问题:不可重复读

在这个测试用例(JEPSEN 2.3)中是用来一张简单的表 peopleid 作为主键,预填充一行数据。

CREATE TABLE people (
  id     int PRIMARY KEY,
  name   text not null,
  gender text not null
);
INSERT INTO people (id, name, gender) VALUES (0, "moss", "enby");

随即并发运行一系列写事务 —— 每个事务先读这一行的 name 字段;然后更新 gender 字段,随即再次读取 name 字段。正确的可重复读意味着在这个事务中,两次对 name 的读取返回的结果应该是一致的。

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;                     -- 开启RR事务
SELECT name FROM people WHERE id = 0;  -- 结果为 "pebble"
UPDATE people SET gender = "femme" WHERE id = 0; -- 随便更新点什么
SELECT name FROM people WHERE id = 0;  -- 结果为 “moss”
COMMIT;

但是在测试结果中 ,9048个事务中的126个出现了内部一致性错误 —— 尽管是在 可重复读 隔离等级上运行的,但是实际读到的名字还是出现了变化。这样的行为与 MySQL 的隔离级别文档矛盾,该文档声称:“同一事务中的一致读取,会读取第一次读取建立的快照”。它与 MySQL 的一致性读文档相矛盾,该文档特别指出,“InnoDB 在事务的第一次读时分配一个时间点,并发事务的影响不应出现在后续的读取中”。

ANSI / Adya 可重复读实质是:一旦事务观察到某个值,它就可以指望该值在事务的其余部分保持稳定。MySQL 则相反:写入请求是邀请另一个事务潜入,并破坏用户刚刚读取的状态。这样的隔离设计与行为表现确实是难以置信地愚蠢。但这儿还有更离谱的事情 —— 比如单调性原子性问题。


原子性问题:非单调视图

Kleppmann 在 Hermitage 中将 MySQL 可重复读评级为单调原子视图/MAV。根据 Bailis 等 的定义,单调原子视图确保一旦事务 T2 观察到事务T1 的任意结果,T2 即观察到 T1 的所有结果。

如果 MySQL 的 RR 只是在每次执行写入查询时重新获取一个快照,那么如果快照是单调的,它还是可以提供 MAV 等级的隔离保证 —— 而这正是 PostgreSQL 读已提交/RC 隔离级别的工作原理。

然而在常规的 MySQL 单节点部署中,情况并非如此:MySQL 在 RR 隔离等级时经常违反单调原子视图。JEPSEN(2.4)的这个例子用于说明这一点:这里有一张 mav 表,预填充两条记录(id=0,1),value 字段初始值都是 0

CREATE TABLE mav (
  id      int PRIMARY KEY,
  `value` int not null,
  noop    int not null
);
INSERT INTO mav (id, `value`, noop) VALUES (0, 0, 0);
INSERT INTO mav (id, `value`, noop) VALUES (1, 0, 0);

负载是读写混合事务:有写入事务会在同一个事务里去同时自增这两条记录的 value 字段;根据事务的原子性,其他事务在观察这两行记录时,value 的值应当是保持同步锁定增长的。

START TRANSACTION;
SELECT value FROM mav WHERE id = 0;    --> 0 读取到了0
update mav SET noop = 73 WHERE id = 1; --> “邀请”新的快照进来
SELECT value FROM mav WHERE id = 1;    --> 1 读取到了新值1,那么另一行的值应该也是1才对
SELECT value FROM mav WHERE id = 0;    --> 0 结果读取到了旧值0
COMMIT;

然而在上面这个读取事务看来,它观察到了“中间状态”。读取事务首先读0号记录的 value,然后将 1 号记录的 noop 设置为一个随机值(根据上面一个案例,就能看见其他事务的变更了),接着再依次读取 0/1 号记录的 value 值。结果出现这种情况:读取0号记录拿到了新值,读取1号记录时获取到了旧值,这意味着单调性原子性都出现了严重缺陷。

MySQL 的一致性读取文档广泛讨论了快照,但这种行为看起来根本不像快照。快照系统通常提供数据库状态的一致的、时间点的视图。它们通常是原子性的:要么包含事务的所有结果,要么全都不包含。即使 MySQL 以某种方式从写入事务的中间状态拿到了非原子性快照,它也必须得在获取行1新值前看到行0的新值。然而情况并非如此:此读取事务观察到行1的变化,但却没有看到行0的变化结果,这算哪门子快照

因此,MySQL 的可重复读/RR 隔离等级既不原子,也不单调。在这一点上它甚至比不上绝大多数数据库的 读已提交/RC,起码它们实质上还是原子且单调的 单调原子视图/MAV

另外一个值得一提的问题是:MySQL 默认配置下的事务会出现违背原子性的现象。我已经在两年前的文章中抛出该问题供业界讨论,MySQL 社区的观点认为这是一个可以通过 sql_mode 进行配置的特性而非缺陷。

但这种说法无法改变这一事实:MySQL确实违反了最小意外原则,在默认配置下允许用户做出这种违背原子性的蠢事来。与之类似的还有 replica_preserve_commit_order 参数。

mysql-atom.png


串行化问题:鸡肋且糟糕

可串行化/SR 可以阻止上面的并发异常出现吗?理论上可以,可串行化就是为这个目的而设计的。但令人深感不安的是,JEPSEN 在 AWS RDS 集群中观察到,MySQL 在 可串行化/SR 隔离等级下也出现了 “Fractured Read-Like” 异常,这是G2异常的一个例子。这种异常是被 RR 所禁止的,应该只会在 RC 或更低级别出现。

深入研究发现这一现象与 MySQL 的 replica_preserve_commit_order 参数有关:禁用此参数允许 MySQL 以正确性作为代价,在重放日志时提供更高的并行性。当此选项被禁用时,JEPSEN在本地集群的 SR 隔离级别中也观察到了类似的 G-Single 和 G2-Item 异常。

可串行化系统应该保证事务(看起来是)全序执行,不能在副本上保留这个顺序是一件非常糟糕的事情。因此这个参数过去(8.0.26及以下)默认是禁用的,而在 MySQL 8.0.27 (2021-10-19)中被修改为默认启用。但是 AWS RDS 集群的参数组仍然使用“OFF”的旧默认值,并且缺少相关的文档说明,所以才会出现这样的现象。

尽管这一异常行为可以通过启用该参数进行规避,但使用 Serializable 本身也并非 MySQL 官方/社区鼓励的行为。MySQL 社区中普遍的观点是:除非绝对必要,否则应避免使用 可串行化/SR 隔离等级;MySQL 文档声称:SERIALIZABLE 执行比 REPEATABLE READ 更严格的规则,主要用于特殊情况,例如 XA 事务以及解决并发和死锁问题。”

无独有偶,专门研究数据库一致性的李海翔教授(前鹅厂T14)在其《第三代分布式数据库》系列文章中,也对包括 MySQL (InnoDB/8.0.20)在内的多种数据库的实际隔离等级进行了测评,并从另一个视角给出了下面这幅更为细化的 “《一致性八仙图》”。

mysql-baxiantu.png

在图中蓝/绿色代表正确用规则/回滚避免一场;黄色的A代表出现异常,黄色“A”越多,正确性问题就越多;红色的“D”指使用了影响性能的死锁检测来处理异常,红色D越多,性能问题就越严重

不难看出,正确性最好的是 PostgreSQL SR 与基于其构建的 CockroachDB SR,其次是 Oracle SR;都主要是通过机制与规则避免并发异常;而 MySQL 的正确性水平令人不忍直视。

李海翔教授在专文《一无是处的MySQL》对此有过详细分析:尽管MySQL的 可串行化/SR 可以通过大面积使用死锁检测算法保证正确性,但这样处理并发异常,会严重影响数据库的性能与实用价值


正确性与性能的利弊权衡

李海翔教授在《第三代分布式数据库:踢球时代》中抛出了一个问题:如何对系统的正确性与性能进行利弊权衡?

数据库圈有一些“习惯成自然”的怪圈,例如很多数据库的默认隔离等级都是 读已提交/RC,有很多人会说“数据库的隔离级别设置为 RC 足够了”!可是为什么?为什么要设置为 RC ?因为他们觉得 RC 级别数据库性能好。

可如下图所示,这里存在一个死循环:用户希望数据库性能更好,于是开发者把应用的隔离级别设置为RC。然而用户,特别是金融保险证券电信等行业的用户,又期望保证数据的正确性,于是开发者不得不在 SQL 语句中加入 SELECT FOR UPDATE 加锁以确保数据的正确性。而此举又会导致数据库系统性能下降严重。在TPC-C和YCSB场景下测试结果表明,用户主动加锁的方式会导致数据库系统性能下降严重,反而是强隔离级别下的性能损耗并没有那么严重。

circle.png

使用弱隔离等级其实严重背离了“事务”这个抽象的初衷 —— 在较低隔离级别的重要数据库中编写可靠事务极其复杂,而与弱隔离等级相关的错误的数量和影响被广泛低估[13]。使用弱隔离级别本质上是把本应由数据库来保证的正确性 & 性能责任给了应用开发者。

惯于使用弱隔离等级这个问题的根,可能出在 Oracle 和 MySQL 上。例如,Oracle 从来没有提供真正的可串行化隔离等级(SR 实际上是 快照隔离/SI),直到今天亦然。因此他们必须将*“使用 RC 隔离等级”* 宣传为一件好事。Oracle 是过去最流行的数据之一,所以后来者也纷纷效仿。

consistency.png

而使用弱隔离等级性能更好的刻板印象可能源于 MySQL —— 大面积使用死锁检测(标红)实现的 SR 性能确实糟糕。但对于其他 DBMS 来说并非必然如此。例如,PostgreSQL 在 9.1 引入的 可串行化快照隔离(SSI) 算法可以在提供完整可串行化前提下,相比快照隔离/SI 并没有多少性能损失

pgbench.png

更进一步讲,摩尔定律加持下的硬件的性能进步与价格坍缩,让OLTP性能不再成为稀缺品 —— 在单台服务器就能跑起推特的当下,超配充裕的硬件性能实在用不了几个钱。而比起数据错漏造成的潜在损失与心智负担,担心可串行化隔离等级带来的性能损失确实是杞人忧天了。

时过境迁,软硬件的进步让 “默认可串行化隔离,优先确保100%正确性” 这件事切实可行起来。为些许性能而牺牲正确性这样的利弊权衡,即使对糙猛快的互联网场景也开始显得不合时宜了。新一代的分布式数据库诸如 CockroachDB 与 FoundationDB 都选择了默认使用 可串行化 隔离等级。

做正确的事很重要,而正确性是不应该拿来做利弊权衡的。在这一点上,开源关系型数据库两巨头 MySQL 和 PostgreSQL 在早期实现上就选择了两条截然相反的道路:MySQL 追求性能而牺牲正确性;而学院派的 PostgreSQL 追求正确性而牺牲了性能。在互联网风口上半场中,MySQL 因为性能优势占据先机乘风而起。但当性能不再是核心考量时,正确性就成为了 MySQL 的致命出血点

解决性能问题有许多种办法,甚至坐等硬件性能指数增长也是一种切实可行的办法(如 Paypal);而正确性问题往往涉及到全局性的架构重构,解决起来绝非一夕之功。过去十年间,PostgreSQL守正出奇,在确保最佳正确性的前提下大步前进,很多场景的性能都反超了 MySQL;而在功能上更是籍由其扩展生态引入的向量、JSON,GIS,时序,全文检索等扩展特性全方位碾压 MySQL

sf2023.png

PostgreSQL 在 2023 年 StackOverflow 的全球开发者用户调研中,开发者使用率正式超过了 MySQL ,成为世界上最流行的数据库。而在正确性上一塌糊涂,且与高性能难以得兼的 MySQL ,确实应该好好思考一下自己的破局之路了。

参考

[1] JEPSEN: https://jepsen.io/analyses/mysql-8.0.34

[2] Hermitage: https://github.com/ept/hermitage

[4] Jepsen研究伦理: https://jepsen.io/ethics

[5] innodb_flush_log_at_trx_commit: https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit

[6] 隔离级别文档: https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html#isolevel_repeatable-read

[7] 一致性读文档: https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html

[9] 单调原子视图/MAV: https://jepsen.io/consistency/models/monotonic-atomic-view

[10] Highly Available Transactions: Virtues and Limitations,Bailis 等: https://amplab.cs.berkeley.edu/wp-content/uploads/2013/10/hat-vldb2014.pdf

[12] replica_preserve_commit_order: https://dev.mysql.com/doc/refman/8.0/en/replication-options-replica.html#sysvar_replica_preserve_commit_order

[13] 与弱隔离等级相关的错误的数量和影响被广泛低估: https://dl.acm.org/doi/10.1145/3035918.3064037

[14] 测试PostgreSQL的并行性能: https://lchsk.com/benchmarking-concurrent-operations-in-postgresql

[15] 在单台服务器上跑起推特: https://thume.ca/2023/01/02/one-machine-twitter/

数据库应该放入K8S里吗?

微信公众号原文 | 英文版

数据库是否应该放入 Kubernetes / Docker 里,到今天仍然是一个充满争议的话题。k8s 作为一个先进的容器编排工具,在无状态应用管理上非常趁手;但其在处理有状态服务 —— 特别是PostgreSQL和MySQL这样的数据库时,有着本质上的局限性。

在上一篇文章《数据库放入Docker是个好主意吗?》中,我们已经讨论了容器化数据库的利弊权衡;今天我们就来聊一聊将数据库放入 K8S 中编排调度所涉及的利弊权衡 —— 并深入探讨为什么将数据库放入 K8S 中不是一个明智的选择。

摘要

Kubernetes (k8s)是一个非常优秀的容器编排工具,它的目标是帮助开发者更好地管理海量复杂的无状态应用服务。尽管它提供了诸如 StatefulSet、PV、PVC、LocalhostPV 等抽象原语用于支持有状态服务(i.e. 数据库),但这些东西对于运行有着更高可靠性要求的生产级数据库服务来说仍然远远不够。

数据库是“宠物”而非“家畜”,需要细心地照料呵护。将数据库放入K8S作为“牲畜”对待,本质上是将外部的磁盘/文件系统/存储服务变为了新的“数据库宠物”。使用 EBS/网络存储/云盘运行数据库,在可靠性与性能上有巨大劣势;然而如果使用高性能本地NVMe磁盘,与节点绑定无法调度的数据库又失去了放入K8S的主要意义。

将数据库放入 K8S 中会导致 “双输” —— K8S 失去了无状态的简单性,不能像纯无状态使用方式那样灵活搬迁调度销毁重建;而数据库也牺牲了一系列重要的属性:可靠性,安全性,性能,以及复杂度成本,却只能换来有限的“弹性”与资源利用率 —— 但虚拟机也可以做到这些!对于公有云厂商之外的用户来说,几乎都是弊远大于利的。

以 K8S为代表的“云原生”狂热已经成为了一种畸形的现象:为了k8s而上k8s。工程师想提高不可替代性堆砌额外复杂度,管理者怕踩空被业界淘汰互相卷着上线。骑自行车就能搞定的事情非要开坦克来刷经验值/证明自己,却不考虑要解决的问题是否真的需要这些屠龙术 —— 这种架构杂耍行为终将招致恶果。

我们认为在分布式网络存储的可靠性与性能超过本地存储前,将数据库放入 K8S 是一种不明智的选择。解决数据库管理复杂度并非只有 K8S 一条道路,开箱即用的开源RDS —— Pigsty 基于裸操作系统提供了另一种选择。用户应当擦亮双眼,根据自己的真实情况与需求做出明智的利弊权衡与技术决策。

当下的现状

K8S 在无状态应用服务编排领域内表现出色,但一开始对于有状态的服务极其有限 —— 尽管运行数据库并不是 K8S 与 Docker 的本意,然而这阻挡不了社区对于扩张领地的狂热 —— 布道师们将 K8S 描绘为下一代云操作系统,断言数据库必将成为 Kubernetes 中的普通应用一员。而各种用于支持有状态服务的抽象也开始涌现:StatefulSet、PV、PVC、LocalhostPV。

有无数云原生狂热者开始尝试将现有数据库搬入 K8S 中,各种数据库的 CRD 与 Operator 开始出现 —— 仅以 PostgreSQL 为例,在市面上就已经可以找到至少十款以上种不同的 K8S 部署方案:PGO,StackGres,CloudNativePG,TemboOperator,PostgresOperator,PerconaOperator,Kubegres,KubeDB,KubeBlocks,……,琳琅满目。CNCF 的景观图就这样开始迅速扩张,成为了复杂度乐园。

然而复杂度也是一种成本,随着“降本增效”成为主旋律,反思的声音开始出现 —— 下云先锋 DHH 在公有云上深度使用了 K8S,但在回归开源自建的过程中也因为过分复杂而放弃了它,仅仅用 Docker 与一个名为 Kamal 的Ruby小工具作为替代。许多人开始思考,像数据库这样的有状态服务到底适合放入 Kuberentes 中吗?

而 K8S 本身为了支持有状态应用,也变得越来越复杂,远离了容器编排平台的初心。以至于 Kubernetes 的联合创始人Tim Hockin 也在今年的 KubeCon 上罕见地发了声:《K8s在被反噬!》:“Kubernetes 变得太复杂了,它需要学会克制,否则就会停止创新,直至丢失自己的基本盘 ” 。

双输的选择

对于有状态的服务,云原生领域非常喜欢用一个“宠物”与“牲畜”的类比 —— 前者需要精心照料,细心呵护,例如数据库;而后者可以随意处置,一次性用完即丢,就是普通的无状态应用(Disposability)。

12-factor.png

云原生应用12要素: Disposability

K8S的一个主要架构目标就是,把能当畜生的都当畜生处理。对数据库进行 “存算分离”就是这样一种尝试:把有状态的数据库服务拆分为K8S外的状态存储与K8S内的纯计算部分,状态放在云盘/EBS/分布式存储上,而“无状态”的数据库进程就可以塞进K8S里随意创建销毁与调度了。

不幸的是,数据库,特别是 OLTP 数据库是重度依赖磁盘硬件的,而网络存储的可靠性与性能相比本地磁盘仍然有数量级上的差距。因而 K8S 也提供了LocalhostPV 的选项 —— 允许用户在容器上打一个洞,直接使用节点操作系统上的数据卷,直接使用高性能/高可靠性的本地 NVMe 磁盘存储。

但这让用户面临着一个抉择:是使用垃圾云盘并忍受糟糕数据库的可靠性/性能,换取K8S的调度编排统一管理能力?还是使用高性能本地盘,但与宿主节点绑死,基本丧失所有灵活调度能力?前者是把压舱石硬塞进 K8S 的小船里,拖慢了整体的灵活性与速度;后者则是用几根钉子把 K8S 的小船锚死在某处。

运行单独的纯无状态的K8S集群是非常简单可靠的,运行在物理机裸操作系统上的有状态数据库也是十分可靠的。然而将两者混在一起的结果就是双输K8S失去了无状态的灵活与随意调度的能力,而数据库牺牲了一堆核心属性:可靠性、安全性、效率与简单性,换来了对数据库根本不重要的“弹性”、资源利用率与Day1交付速度

关于前者,一个鲜活的案例是由 KubeBlocks 贡献的 PostgreSQL@K8s 性能优化记。k8s 大师上了各种高级手段,解决了裸金属/裸OS上根本不存在的性能问题。关于后者的鲜活的案例是滴滴的K8S架构杂耍大翻车,如果不是将有状态的 MySQL 放在K8S里,单纯重建无状态 K8S 集群并重新发布应用,怎么会要12小时这么久才恢复?

利弊的权衡

对于严肃的生产技术选型决策,最重要的永远是利弊权衡。这里我们按照常用的“质量、安全、效率、成本”顺序,来聊一下K8S放数据库相对于经典裸金属/VM部署在技术上的利弊权衡。我并不想在这里写一篇面面俱到,好像什么都说了的论文,而是抛出一些具体问题,供大家思考与讨论。

质量上:K8S相比物理部署新增了额外的失效点与架构复杂度,拉高了爆炸半径,并且会显著拉长故障的平均恢复时长。在《数据库放入Docker是个好主意吗?》一文中,我们已经给出了关于可靠性的论证,同样的结论也可以适用于 Kubernetes —— K8S 与 Docker 会为数据库引入额外且不必要的依赖与失效点,而且缺乏社区故障知识积累与可靠性战绩证明(MTTR/MTBF)。

在云厂商的分类体系中,K8S属于PaaS,而RDS属于更底层的IaaS。数据库服务比K8S有着更高的可靠性要求:例如,许多公司的云管平台都会依赖一个额外的 CMDB 数据库。那么这个数据库应该放在哪里呢?你不应该把 K8S 依赖的东西交给 K8S 自己来管理,也不应该添加没有必要的额外依赖,阿里云全球史诗大故障滴滴K8S架构杂耍大翻车 为我们普及了这个常识。而且,如果已经有了K8S外的数据库,再去维护一套K8S内的数据库体系就更得不偿失了。

安全 上: 多租户环境中的数据库新增了额外的攻击面,带来了更高的风险与更复杂的审计合规挑战。K8S 会让你的数据库更安全吗?也许K8S架构杂耍的复杂度景象会劝退不熟悉K8S的脚本小子,但对真正的攻击者而言,更多的组件与依赖往往意味着更广的攻击面。

在《BrokenSesame 阿里云PostgreSQL 漏洞技术细节》中,安全人员利用一个自己的 PostgreSQL 容器逃脱到K8S主机节点中,并可以访问 K8S API 与其他租户的容器与数据。而这很明显是 K8S 专有的问题 —— 风险是真实存在的,这样的攻击已经发生,并让本土云厂领导者阿里云中招翻车。

security.png

The Attacker Perspective - Insights From Hacking Alibaba Cloud

效率上:如《数据库放入Docker是个好主意吗?》所述,不论是额外的网络开销,Ingress 瓶颈,拉垮的云盘,对于数据库的性能都会产生负面影响。又比如《PostgreSQL@K8s 性能优化记》 所揭示的 —— 你需要相当程度的技术水平功力,才能让 K8S 中的数据库性能堪堪持平于裸机。

performence.png

Latency 的单位是 ms 不是 µs,我差点以为自己眼花了。

另一个关于效率的误区是资源利用率, 不同于离线分析类业务,关键的在线 OLTP 数据库不仅不应当提高资源利用率,反而应当刻意压低资源利用率水位,从而提高系统的可靠性与用户的使用体验。如果有许许多多多零散业务,也可以通过 PDB / 共用共享数据库集群来提高资源利用率。K8S 所主张的弹性效率也并非其独有 —— KVM/EC2 也可以很好地解决这个问题。

成本上,K8S与各种Operator提供了一个不错的抽象,封装了一部分数据库管理的复杂度,对于没有DBA的团队有一定的吸引力。然而使用它管理数据库所减少的复杂度,比起使用K8S本身引入的复杂度来说就相形见绌了。比如,随机发生的IP地址漂移与Pod自动重启,对于无状态应用来说可能并不是一个大问题,然而对数据库来说这就令人难以忍受了 —— 许多公司不得不尝试魔改 kubelet 以规避这一行为,进而又引入更多的复杂度与维护成本。

正如《从降本增笑到降本增效》“降低复杂度成本” 一节所述:智力功率很难在空间上累加:当数据库出现问题时需要数据库专家来解决;当 Kubernetes 出现问题时需要 K8S 专家看问题;然而当你把数据库放入 Kubernetes 时,复杂度出现排列组合,状态空间开始爆炸,然而单独的数据库专家和 K8S 专家的智力带宽是很难叠加的 —— 你需要一个双料专家才能解决问题,而这样的专家比起单纯的数据库专家无疑要少得多也贵得多。这样的架构杂耍足以让包括头部公有云/大厂在内的绝大多数团队,在遇到故障时出现大翻车。

k8s-meme.png

云原生狂热

一个有趣的问题是,既然 K8S 并不适用于有状态的数据库,那么为什么还有这么多厂商 —— 包括 “大厂” 在争先恐后地做这件事呢?恐怕这里的原因并不是技术上的。

Google 照着内部的 Borg 宇宙飞船做了艘 K8S 战舰开源出来,老板们怕踩空被业界淘汰进而互相卷着上线,觉得自己用上 K8S 就跟Google一样牛逼了 —— 有趣的是Google自己不用K8S,开源出来搅屎AWS忽悠业界;然而绝大多数公司并没有 Google 那样的人手去操作战舰。更重要的是他们的问题可能只要一艘舢舨就解决了。裸机上的 MySQL + PHP , PostgreSQL+ Ruby / Python / Go ,已经让无数公司一路干到上市了。

现代硬件条件下,绝大多数应用,终其生命周期的复杂度都不足以用到 K8S 来解决。然而,以 K8S为代表的“云原生”狂热已经成为了一种畸形的现象:为了k8s而上k8s。一些工程师的目的是去寻找足够“先进”足够酷的,最好是大公司在用的东西来满足自己跳槽,晋升等个人价值的需求,或者趁机堆砌复杂度以提高自己的 Job Security,而压根不是考虑要解决问题是否真的需要这些屠龙术。

k8s-hell.mp4

云原生领域全景图中充斥着各种花里胡哨的项目,每个新来的开发团队都想引入一套新东西,今天一个 helm 明天一个 kubevela,说起来都是光明前途,效率拉满,实际上成为了 YAML Boy 的架构屎山与复杂度乐园 —— 折腾最新的技术,发明大把的概念,经验值和声望是自己的,复杂度代价反正是用户买单,搞出问题还可以再敲一笔维护费,简直完美!

cncf-landscape.png

CNCF Landscape

云原生运动的理念是很有感召力的 —— 让本来是公有云专属的弹性调度能力普及到每一个用户身上,K8S 也确实在无状态应用上表现出色。然而过度的狂热已经让 K8S 偏离了原本的初心与方向 —— 简单地做好无状态应用编排调度这件事,被支持有状态应用的妄念拖累的也不再简单了。

明智地决策

几年前刚接触K8S时,我也曾有过这种皈依者狂热 —— 在探探我们也有着两万多核几百套数据库,我迫切地想要尝试将数据库放入 Kubernetes 中,并测遍了各种 Operator。然而在前后长达两三年的方案调研与架构设计中,我最终冷静下来,并放弃了这种疯狂的打算 —— 而是选择基于裸金属/裸操作系统架构我们自己的数据库服务。因为在对我们来说,K8S为数据库带来的收益相比其引入的问题与麻烦,实在是微不足道。

数据库应该放入K8S里吗?这取决于具体场景:对于从资源利用率里用超卖刨食吃的云厂商而言,弹性与资源利用率非常重要,它们直接与收入和利润挂钩;稳定可靠效率都得屈居其次 —— 毕竟可用性低于3个9也不过是按SLA赔偿本月消费25%的代金券而已。但是对于我们自己,以及生态光谱中的大多数用户而言,这些利弊权衡就不成立了:一次性的 Day1 Setup效率,弹性与资源利用率并不是他们最关心的问题;可靠性、性能、Day2 Operation成本,这些数据库的核心属性才是最重要的。

我们将自己的数据库服务架构方案开源出来 —— 即开箱即用的 PostgreSQL 发行版与本地优先的 RDS 替代: Pigsty。我们没有选择 K8S 与 Docker 这种所谓 “一次构建,到处运行 ” 的讨巧办法,而是一个一个地去适配不同的操作系统发行版/不同的大版本,并使用 Ansible 实现类 K8S CRD IaC 的效果封装管理复杂度。这确实是一件非常幸苦的工作,但却是正确的事情 —— 这个世界并不需要又一个在 K8S 中放入PG数据库玩具积木的拙劣尝试,但确实需要一个最大化发挥出硬件性能与可靠性的生产数据库服务架构方案。

stackgres-pigsty.png

Pigsty vs StackGres

也许有一天,当分布式网络存储的可靠性与性能可以超过本地存储的表现时,以及当主流数据库都对存算分离有一定程度上的支持后,事情会再次发生变化 —— K8S 变得适用于数据库起来。但至少就目前来讲,我认为将严肃的生产 OLTP 数据库放入 K8S ,仍然是不成熟与不合时宜的。希望读者可以擦亮双眼,在这件事上做出明智的选择。

参考阅读

把数据库放入Docker是一个好主意吗?

《Kubernetes创始人发声!K8s在被反噬!》

《Docker 的诅咒:曾以为它是终极解法,最后却是“罪大恶极”?》

《从滴滴的故障我们能学到什么》

《PostgreSQL@K8s 性能优化记》

《Running Database on Kubernetes》

重新拿回计算机硬件的红利

从降本增笑到真的降本增效

重新拿回计算机硬件的红利

我们能从阿里云史诗级故障中学到什么

是时候放弃云计算了吗?

云SLA是不是安慰剂?

专用向量数据库凉了吗?

微信公众号原文

向量存储检索是个真需求,然而专用向量数据库已经凉了。—— 小微需求 OpenAI 亲自下场解决了,标准需求被加装向量扩展的现有成熟数据库抢占。留给专用向量数据库的生态位也许能支持一家专用向量数据库存活,但想靠讲AI故事来整活做成一个产业已经是不可能了。


向量数据库是怎么火起来的?

专用向量数据库早在几年前就出现了,比如 Milvus,主要针对的是非结构化多模态数据的检索。例如以图搜图(拍立淘),以音搜音(Shazam),用视频搜视频这类需求;PostgreSQL 生态的 pgvector,pase 等插件也可以干这些事。总的来说,算是个小众需求,一直不温不火。

但 OpenAI / ChatGPT 的出现改变了这一切:大模型可以理解各种形式的文本/图片/音视频,并统一编码为同一维度的向量,而向量数据库便可以用来存储与检索这些AI大模型的输出 —— Embedding大模型与向量数据库》。

embedding.png

更具体讲,向量数据库爆火的关键节点是今年3月23日,OpenAI 在其发布的 chatgpt-retrieval-plugin 项目中推荐使用一个向量数据库,在写 ChatGPT 插件时为其添加“长期记忆”能力。然后我们可以看到,无论是 Google Trends 热搜,还是 Github Star 上,所有向量数据库项目的关注度都从那个时间节点开始起飞了。

trends.png

Google Trends 与 GitHub Star

与此同时,数据库领域在投资领域沉寂了一段时间后,又迎来了一波小阳春 —— Pinecone,Qdrant,Weaviate 诸如此类的“专用向量数据库” 冒了出来,几亿几亿的融钱,生怕错过了这趟 AI 时代的基础设施快车。

landscape.png

向量数据库生态全景图

但是,这些暴烈的狂欢也终将以暴烈的崩塌收场。这一次茶凉的比较快,半年不到的时间,形势就翻天覆地了 —— 现在除了某些二流云厂商赶了个晚集还在发软文叫卖,已经听不到谁还在炒专用向量数据库这个冷饭了。

gartner-hype-cycle.png

专用向量数据库神话的破灭还有多远?


向量数据库是一个伪需求吗?

我们不禁要问,向量数据库是一个伪需求吗?答案是:向量的存储与检索是真实需求,而且会随着AI发展水涨船高,前途光明。但这和专用的向量数据库并没有关系 —— 加装向量扩展的经典数据库会成为绝对主流,而专用的向量数据库是一个伪需求

类似 Pinecone,Weaviate,Qdrant,Chroma 这样的专用向量数据库最初是为了解决 ChatGPT 的记忆能力不足而出现的 Workaround —— 最发布的 ChatGPT 3.5 的上下文窗口只有 4K Token,也就是不到两千个汉字。然而当下 GPT 4 的上下文窗口已经发展到了 128K,扩大了32倍,足够塞进一整篇小说了 —— 而且未来还会更大。这时候,用作临时周转的垫脚石 —— 向量数据库 SaaS 就处在一个尴尬的位置上了。

更致命的是 OpenAI 在今年11月首次开发者大会上发布的新功能 —— GPTs,对于典型的中小知识库场景,OpenAI 已经替你封装好了 “记忆” 与 “知识库” 的功能。你不需要折腾什么向量数据库,只要把知识文件上传上去写好提示词告诉 GPT 怎么用,你就可以开发出一个 Agent 来。尽管目前知识库的大小仅限于几十MB,但这对于很多场景都绰绰有余,而且上限仍有巨大提升空间。

gpts.png

GPTs 将 AI 的易用性提高到一个全新的层次

像 Llama 这样的开源大模型与私有化部署为向量数据库扳回一局 —— 然而,这一部分需求却被加装了向量功能的经典数据库占领了 —— 以 PostgreSQL 上的 PGVector 扩展为先锋代表,其他数据库如 Redis,ElasticSearch, ClickHouse, Cassandra 也紧随其后不甘示弱。说到底,向量与向量检索是一种新的数据类型和查询处理方法,而不是一种全新的基础性数据处理方式。加装一种新的数据类型与索引,对设计良好的现有数据库系统来说并不是什么复杂的事情。

pgvector-langchain.png

本地私有化部署的 RAG 架构

更大的问题在于,尽管数据库是一件门槛很高的事,但“向量”部分可以说没有任何技术门槛,而且诸如 FAISS 和 SCANN 这样的成熟开源库已经足够完美地解决这个问题了。对于有足够大规模足够复杂的场景的大厂来说,自家工程师可以不费吹灰之力地使用开源库实现这类需求,更犯不上用一个专用向量数据库了。

因此,专用向量数据库陷入了一个死局之中:小需求 OpenAI 亲自下场解决了,标准需求被加装向量扩展的现有成熟数据库抢占,支持超大型需求也几乎没什么门槛,更多可能还要靠模型微调。留给专用向量数据库的生态位也许能足以支持一家专用向量数据库内核厂商活下来,但想做成一个产业是不可能了。


通用数据库 vs 专用数据库

一个合格的向量数据库,首先得是一个合格的数据库。但是数据库是一个相当有门槛的领域,从零开始做到这一点并不容易。我通读了市面上专用向量数据库的文档,能勉强配得上“数据库”称呼的只有一个 Milvus —— 至少它的文档里还有关于备份 / 恢复 / 高可用的部分。其他专用向量数据库的设计,从文档上看基本可以视作对“数据库”这个专业领域的侮辱。

“向量”与“数据库”这两个问题的本质复杂度有着天差地别的区别,以世界上最流行的 PostgreSQL 数据库内核为例,它由上百万行C语言代码编写而成,解决“数据库”这个问题;然而基于 PostgreSQL 的向量数据库扩展 pgvector 只用了不到两千行不到的 C 代码,就解决了“向量”存储与检索的问题。这是对“向量”相对于与“数据库”这件事复杂度门槛的一个粗略量化:万分之一

distro.png

如果算上生态扩展,对比就更惊人了。

这也从另一个角度说明了向量数据库的问题 —— “向量”部分的门槛太低。数组数据结构,排序算法,以及两个向量求点积这三个知识点是大一就会讲的通识,稍微机灵点的本科生就拥有足够的知识来实现这样一个所谓的“专用向量数据库”,很难说这种编程大作业,LeetCode 简单题级别的东西有什么技术门槛 。

关系型数据库发展到今天已经相当完善了 —— 它支持各种各样的数据类型,整型,浮点数,字符串,等等等等。如果有人说要重新发明一种新的专用数据库来用,其卖点是支持一种“新的”数据类型 —— 浮点数组,核心功能是计算两个数组的距离并从库中找出最小者,而代价是其他的数据库活计几乎都没法整了,那么稍有经验的用户和工程师都会觉得 —— 这人莫不是得了失心疯?

prymaid.png

数据库需求金字塔:性能只是选型考量之一。

在绝大多数情况下,使用专用向量数据库的弊都要远远大于利:数据冗余、 大量不必要的数据搬运工作、分布式组件之间缺乏一致性、额外的专业技能带来的复杂度成本、学习成本、以及人力成本、 额外的软件许可费用、极其有限的查询语言能力、可编程性、可扩展性、有限的工具链、以及与真正数据库相比更差的数据完整性和可用性。用户唯一能够期待的收益通常是性能 —— 响应时间或吞吐量,然而这个仅存的“优点”很快也不再成立了…


案例PvP:pgvector vs pinecone

抽象的理论分析不如实际的案例更有说服力,因此让我们来看一对具体的对比:pgvector 与 pinecone。前者是基于 PostgreSQL 的向量扩展,正在向量数据库生态位中疯狂攻城略地;后者是专用向量数据库 SaaS,列于 OpenAI 首批专用向量库推荐列表首位 —— 两者可以说是通用数据库与专用数据库中最典型的代表。

pinecone.png

在 Pinecone 的官方网站上,Pinecone 提出的主要亮点特性是:“高性能,更易用”。首先来看专用向量数据库引以为豪的高性能。Supabase 给出了一个最新的测试案例,以 ANN Benchmark 中的 DBPedia 作为基准,这是由一百万个 OpenAI 1536 维向量组成的数据集。在相同的召回率下,PGVector 都有着更佳的延迟表现与总体吞吐,而且成本上要便宜得多。即使是老版本的 IVFFLAT 索引,都比 Pinecone 表现更好。

bench.png

来自 Supabase - DBPedia 的测试结果

尽管专用向量数据库 Pinecone 的性能更烂,但说实话:向量数据库的性能其实根本就不重要 —— 以致于生产上 100% 精确的全表暴力扫描 KNN 有时候都是一种切实可行的选项。更何况向量数据库需要与模型搭配使用,当大模型 API 的响应时间在百毫秒 ~ 秒级时,把向量检索的时间从 10ms 优化到 1ms 并不能带来任何用户体验上的收益。在全员HNSW索引的情况下,可伸缩性也几乎不可能成为问题 —— 语义搜索属于读远多于写的场景,如果你需要更高的 QPS 吞吐量,增配/拖从库就可以了。至于节省几倍资源这种事,以常见业务的规模与当下的资源成本来看,相比模型推理的开销只能说连三瓜两枣都算不上了。

在易用性上,专用的 Python API 还是通用的 SQL Interface 更易用,这种事见仁见智 —— 真正的致命问题在于,许多语义检索场景都需要使用一些额外的字段与计算逻辑,来对向量检索召回的结果进行进一步的筛选与处理,即 —— 混合检索。而这些元数据往往保存在一个关系数据库里作为 Source of Truth。Pinecone 确实允许你为每个向量附加不超过 40KB 的元数据,但这件事需要用户自己来维护,基于API的设计会将专用向量数据库变成可扩展性与可维护性的地狱 —— 如果你需要对主数据源进行额外的查询来完成这一点,那为啥不在主关系库上直接以统一的 SQL 一步到位直接实现呢?

作为一个数据库,Pinecone 还缺乏各种数据库应该具备的基础性能力,例如:备份/恢复/高可用、批量更新/查询操作,事务/ACID;此外,除了俭朴的 API Call 之外没有与上游数据源更可靠的数据同步机制。无法实时在召回率与响应速度之间通过参数来进行利弊权衡 —— 除了更改 Pod 类型在三档准确性中选择之外别无他法 —— 你甚至不能通过暴力全表搜索达到 100% 精确度 ,因为 Pinecone 不提供精确 KNN 这个选项!

pinecone-options.png

并不只有 Pinecone 是这样,除了 Milvus 之外的其他几个专用向量数据库也基本类似。当然也有用户会抗辩说一个 SaaS 如何与数据库软件做对比,这并不是一个问题,各家云厂商的 RDS for PostgreSQL 都已经提供了 PGVector 扩展,也有诸如 Neon / Supabase 这样的 Serverless / SaaS 和 Pigsty 这样的自建发行版。如果你可以用低的多的成本,拥有功能更强,性能更好,稳定性安全性更扎实的通用向量数据库,那么又为什么要花大价钱与大把时间去折腾一个没有任何优势的 “专用向量数据库”?想明白这一点的用户已经从 pinecone 向 pgvector 迁移了 —— 《为什么我们用 PGVector 替换了 Pinecone


小结

向量的存储与检索是一个真实需求,而且会随着AI发展水涨船高,前途光明 —— 向量将成为AI时代的JSON;但这里并没有多少位置留给专用的向量数据库 —— 诸如 PostgreSQL 这样的头部数据库毫不费力的加装了向量功能,并以压倒性的优势从专用向量数据库身上碾过。留给专用向量数据库的生态位也许能支持一家专用向量数据库存活,但想靠讲AI故事来整活,做成一个产业已经是不可能了。

专用向量数据库确实已经凉掉了,希望读者也不要再走弯路,折腾这些没有前途的东西了。

数据库真被卡脖子了吗?

微信公众号原文 | 知乎原文

如果说“云数据库”算是成本ROI略欠体面的合格品,那么很多“国产数据库”就是烂泥扶不上墙的残次品。信创操作系统数据库约等于 IT 预制菜进校园。用户捏着鼻子迁移,开发者假装在卖力,陪着不懂也不在乎技术的领导演戏。大量人力财力被挥霍到没有价值的地方去,反而了浪费掉了真正的机会。基础软件行业其实没人卡脖子,真正卡脖子的都是所谓“自己人”。


垄断关系生意

北京欢乐谷门口大喇叭一直在喊:“请不要在门外购买劣质矿泉水”,小贩都被轰的远远儿的。进去后园区就会把一样的东西用五倍的价格卖给你(当然也可能是掺尿内销啤酒这种更烂的东西)。信创数据库与操作系统大体就是这种模式,都是靠垄断保护吃饭的关系生意。这与预制菜进校园有异曲同工之妙:瓦格纳头子靠承包军队/学校伙食发的财都够搞雇佣兵造反了,堪称一本万利。

问题在于,吃预制菜的人不一定有得选,但用数据库和操作系统的用户可以用脚投票,选择更先进还不要钱的开源操作系统/数据库,这可如何是好呢?毕竟国产库很多也是跟在全球开源 OS/DB 社区屁股后面捡面包屑吃。无数国产内核基于开源PG换皮套壳魔改而成。如果说谁在数据库内核上被卡了脖子,那肯定是吃的花样太多给噎着了。

许多公司看 Oracle 大肆收割的眼红的不行不行,羡慕的哈喇子都要流下来了 —— 可如果用户选择直接去用唾手可及的免费开源软件,国产数据库还怎么去割韭菜?这属于国有资产流失啊!土鳖要翻身,得先欺师灭祖:把开源免费的软件包装一下,用 Oracle 的价格卖给你!

db-choke-1.png

首先搞个数据库硬分叉,把 pg 这俩字母先重命名一下;掺点垃圾代码混淆,再换用 C++ 搅一搅 —— 100%代码自主率,自主知识产权都有啦。然后找几个高校教授老院士来站台论证一下,开源数据库 MySQL 和 PostgreSQL 都是渣渣。最后给领导讲一讲:境外势力亡我之心不死,开源都是帝国主义摧毁我们国产软件行业的阳谋,得 “管一管” ,不抵制不行啦!

开源社区主导的项目都已经深度全球化,单一国家想制裁几乎没有办法:ARM 可以制裁,RISC-V 可以制裁吗?Windows 可以制裁,Linux 可以制裁吗?Oracle / MySQL 可以制裁,PostgreSQL 可以制裁吗?但是别人制裁不了你,你可以“制裁”别人,主动把门给关上呀!

这类企业恐怕做梦都盼着国家被技术封锁:门只有从两边一起关,才更容易关严实。门关严实之后,谁掌握了技术输液管,谁就掌握了利润源泉:那些掌握了独占翻墙权的“国产软件”企业只要定期从全球开源生态拾取些面包渣翻译引入进来,饿的嗷嗷叫的国内用户就要感激涕零,高呼遥遥领先了。


谁受到了伤害?

用户是最受伤的:本来的业务系统跑的好好的,突然就被要求 “升级改造” 了。如果是正向改造,那起码还算是有一些价值,但用来替换现有系统的都是些什么牛鬼蛇神。如果是纯粹的开源换皮也就算了,买点服务兜底还算有价值,最离谱的就是那些做一些自以为是“优化”的魔改阉割版本 —— 大把时间本可以用于更有价值的事情,现在却浪费在削足适履,饮掺尿啤酒,当小白鼠踩坑上了。

数据库开发者受了伤,大好的青春年华与技术生涯浪费在没有未来,没有希望的“数据库过家家”游戏上 —— 做出来的东西只能靠销售关系强行填喂给倒霉的用户,听到的都是用户侧同行的怒骂吐槽与冷嘲热讽。就别提技术影响力和出口创汇了,国际同行都不屑于来耻笑一下,“制裁”也不稀得给一个。整个工作毫无技术成就感可言,人也在日复一日的自我怀疑中变得麻木与犬儒。

国家实力受了伤。各行业与全球软件产业链主动脱钩:稳定性,功能性,战斗力受创。自主可控是一个真实需求,但盲目推行某某名录,歪曲自主可控的实质内涵(将运维自主可控扭曲为研发自主可控),用劣币驱逐良币,会导致实质的自主可控能力不升反降。

且不说和开源比,就连 Oracle 好歹还是个 Paper License,也有很多三方服务供应商;而有的国产数据库没 License 就立即死给你看,原厂一完蛋,连带着业务系统跟着遭殃。从被国外领先数据库“卡脖子” 换为国内土鳖供应商卡脖子,并不会提高自主可控能力,还额外损失了功能活性

基础软件需要什么样的自主可控?

db-choke-2.png


劣币驱逐良币

在CSDN最近的开发者调研中,在七成受访者对“国产数据库”持负面印象:“技术落后”,“缺乏创新”,这算是是一种比较温和的说法。用户心底真正的评价恐怕更为直白:虚假宣传,大放卫星,落后生产力。为什么国产数据库的风评如此之差,难道是软件工程师不爱国吗?

db-choke-3.png

根据信通院与墨天轮统计,现在已经有了两百六十多款“国产数据库”。其中基于开源 PostgreSQL / MySQL 的占了半壁江山还多。这是相当离谱的数字,实际上,大量数据库厂商并没有能力提供真正意义上的“产品”,只是把开源数据库简单换皮包装提供服务,辅以炒作一些分布式、HTAP之类的伪需求。

真正自研的数据库出现两极分化:极少数真正有创新贡献与使用价值的产品爱惜羽毛,不会刻意标榜“国产”。而剩下的大多数往往多是闭门造车、技术落后的土法数据库,或者开源古早分叉、负向阉割出来的劣质轮子。国产数据库并非没有踏实做事的好公司**,只是****“国产”这个标签被大量钻入数据库领域的平庸低劣产品污染**。

更让人扼腕的是劣币驱逐良币。本已稀缺的数据库研发人力经过这样的挥霍,反而会真正卡死国内数据库产业的脖子。特别是核心的OLTP/关系型数据库领域因为开源的存在,已经不缺足够好用的内核了。能把 PostgreSQL / MySQL 用好并提供服务支持,远比自欺欺人的大炼内核要有价值的多。


出路会在哪里?

中国数据库行业里优秀的工程师并不少,但极其匮乏优秀的领军人物或产品经理。或者说,这种人也有,但根本说不上话。最为重要的是,要找到正确的问题与正确的方向去发力。兵熊熊一个,将熊熊一窝:方向对了,即使只有一个人,也能做出有价值的东西;方向错了,养它一千个内核研发也是白努力。

当下的现状是什么?数据库内核已经卷不动了!作为一项有四五十年历史的技术,能折腾的东西已经被折腾的差不多了。业界已经不缺足够完美的数据库内核了 —— 比如 PostgreSQL,功能完备且开源免费(BSD-Like)。无数”国产数据库“基于PG换皮套壳魔改而成。如果说谁在数据库内核上被卡了脖子,那肯定是吃饱了撑着给噎着的

那么,真正稀缺的是什么,是把现有的内核用好的能力。要解决这个问题,有两种思路:第一种是开发扩展,以增量功能包的方式为内核加装功能 —— 解决某一个特定领域的问题。第二种是整合生态,将扩展,依赖,底座,基础设施,融合成完整的产品 & 解决方案 —— 数据库发行版

db-choke-4.jpg

在这两个方向上发力,可以产生实打实的增量用户价值,站在巨人的肩膀上,并深度参与全球软件供应链,响应号召,打造真正意义上的“人类命运共同体”。相反,去分叉一个现有成熟开源内核是极其愚蠢的做法。像 PostgreSQL 与 Linux 这样的 DB/OS 内核是全世界开发者的集体智慧结晶,并通过全世界用户各种场景的打磨与考验,指望靠某一个公司的力量就能与之抗衡是不切实际的妄想。

中国想要打造自己的世界体系,成为负责任的大国,就应当胸怀天下,扛起开源运动的大旗来:展现社会主义公有制制度在软件信息互联网领域的优越性,积极赞助、参与并引领全球开源软件事业的发展,深度参与全球软件供应链,提高在全球社区中的话语权。关起门在开源社区后面捡面包屑吃,整天搞一些换皮套壳魔改的小动作,做一些没有使用价值的软件分叉,不仅压制了真正的技术创新潜能,更是会贻笑/自绝于全球软件产业链,拉低自己的竞争力。不可不察也。


参考阅读

基础软件需要怎样的自主可控

替换数据库的代价与真假国产

“卡脖子”一说,为什么误导人

范式转移 — 从云到本地优先

EL系操作系统发行版哪家强?

微信公众号原文 | 知乎原文

有很多用户都问过我,跑数据库用什么操作系统比较好。特别是考虑到 CentOS 7.9 明年就 EOL了,应该有不少用户需要升级OS了,所以今天分享一些经验之谈。


太长不看

长话短说,在现在这个时间点如果用 EL 系列操作系统发行版,特别是如果要跑 PostgreSQL 相关的服务,我强烈推荐 RockyLinux,有“国产化”要求的也可以选龙蜥 OpenAnolis。AlmaLinux 和 OracleLinux 兼容性有点问题,不建议使用。Euler 属于独一档的 IT 领域预制菜进校园,有 EL 兼容要求的可以直接略过了

兼容水平:RHEL = Rocky ≈ Anolis > Alma > Oracle » Euler 。

在EL大版本上,EL7目前的状态最稳定,但马上 EOL 了,而且很多软件版本都太老了,所以新上的项目不建议使用了;EL 9最新,但偶尔会在仓库源更新后出现软件包依赖错误的问题。,少软件也还没有跟进 EL9 的包,比如 Citus / RedisStack / Greenplum等。

目前综合来看,EL8 是主流的选择:软件版本足够新,也足够稳定。具体的版本上建议使用 RockyLinux 8.9(Green Obsidian) 或 OpenAnolis 8.8 (rhck内核)。 激进的用户可以试试 9.3 ,保守稳妥的用户可以使用 CentOS 7.9 。


测试方法论

我们做开箱即用 PostgreSQL 数据库发行版 Pigsty,不使用容器/编排方案,因此免不了与各种操作系统打交道,基本上 EL 系的 OS 发行版我们都测试过一遍,最近刚刚把 Anolis / Euler 以及 Ubuntu / Debian 的适配做完。关于 OS EL兼容性还是有一些经验心得的。

Pigsty 的场景非常具有代表性 —— 在裸操作系统上运行世界上最先进且最流行的开源关系型数据库 PostgreSQL,以及企业级数据库服务所需要的完整软件组件。包括了 PostgreSQL 生命周期中的5个大版本(12 - 16)以及一百多个扩展插件。还有几十个常用的主机节点软件包,Prometheus / Grafana 可观测性全家桶,以及 ETCD / MinIO / Redis 等辅助组件。

pigsty-banner.png

测试方法很简单,这些 EL原生的RPM包,能不能在其他这些“兼容”系统上跑起来 —— 至少安装运行不能出错吧?每次 CI 的时候,我们会拉起三十台安装有不同操作系统的虚拟机进行完整安装,涉及到的软件包如下所示:

repo_packages:
  - ansible python3 python3-pip python36-virtualenv python36-requests python36-idna yum-utils createrepo_c sshpass                                                  # Distro & Boot
  - nginx dnsmasq etcd haproxy vip-manager pg_exporter pgbackrest_exporter                                                                                          # Pigsty Addons
  - grafana loki logcli promtail prometheus2 alertmanager pushgateway node_exporter blackbox_exporter nginx_exporter keepalived_exporter                            # Infra Packages
  - lz4 unzip bzip2 zlib yum pv jq git ncdu make patch bash lsof wget uuid tuned nvme-cli numactl grubby sysstat iotop htop rsync tcpdump perf flamegraph           # Node Packages 1
  - netcat socat ftp lrzsz net-tools ipvsadm bind-utils telnet audit ca-certificates openssl openssh-clients readline vim-minimal keepalived chrony                 # Node Packages 2
  - patroni patroni-etcd pgbouncer pgbadger pgbackrest pgloader pg_activity pg_filedump timescaledb-tools scws pgxnclient pgFormatter                               # PG Common Tools
  - postgresql15* pg_repack_15* wal2json_15* passwordcheck_cracklib_15* pglogical_15* pg_cron_15* postgis33_15* timescaledb-2-postgresql-15* pgvector_15* citus_15* # PGDG 15 Packages
  - imgsmlr_15* pg_bigm_15* pg_similarity_15* pgsql-http_15* pgsql-gzip_15* vault_15 pgjwt_15 pg_tle_15* pg_roaringbitmap_15* pointcloud_15* zhparser_15* apache-age_15* hydra_15* pg_sparse_15*
  - orafce_15* mysqlcompat_15 mongo_fdw_15* tds_fdw_15* mysql_fdw_15 hdfs_fdw_15 sqlite_fdw_15 pgbouncer_fdw_15 multicorn2_15* powa_15* pg_stat_kcache_15* pg_stat_monitor_15* pg_qualstats_15 pg_track_settings_15 pg_wait_sampling_15 system_stats_15
  - plprofiler_15* plproxy_15 plsh_15* pldebugger_15 plpgsql_check_15* pgtt_15 pgq_15* hypopg_15* timestamp9_15* semver_15* prefix_15* periods_15* ip4r_15* tdigest_15* hll_15* pgmp_15 topn_15* geoip_15 extra_window_functions_15 pgsql_tweaks_15 count_distinct_15
  - pg_background_15 e-maj_15 pg_catcheck_15 pg_prioritize_15 pgcopydb_15 pgcryptokey_15 logerrors_15 pg_top_15 pg_comparator_15 pg_ivm_15* pgsodium_15* pgfincore_15* ddlx_15 credcheck_15 safeupdate_15 pg_squeeze_15* pg_fkpart_15 pg_jobmon_15 rum_15
  - pg_partman_15 pg_permissions_15 pgexportdoc_15 pgimportdoc_15 pg_statement_rollback_15* pg_auth_mon_15 pg_checksums_15 pg_failover_slots_15 pg_readonly_15* postgresql-unit_15* pg_store_plans_15* pg_uuidv7_15* set_user_15* pgaudit17_15
  - redis_exporter mysqld_exporter mongodb_exporter docker-ce docker-compose-plugin redis minio mcli ferretdb duckdb sealos  # Miscellaneous Packages

测试结果基本可以分为三种情况:100% 兼容,小错误,大麻烦。

  • 100% 兼容:RockyLinux,OpenAnolis

  • 小错误:AlmaLinux,OracleLinux,CentOS Stream

  • 大麻烦:OpenEuler

RockyLinux 属于 100% 兼容,各种软件包安装非常流畅,没有遇到任何问题,OpenAnolis 的使用体验与 Rocky 基本一致。AlmaLinux 和 OracleLinux,以及 CentOS Stream 有少量软件包缺失,有办法补上修复,总的来说有些小错误,但可以克服。Euler 属于独一档的大麻烦,软件包遇到了大量版本依赖错误崩溃,几乎所有包都需要针对性编译,有的包因为系统依赖版本冲突问题连编译都困难了,作为EL系OS发行版的适配成本甚至比 Ubuntu/Debian 还高。


使用体验

RockyLinux 的使用体验最好,它的创始人就是原来 CentOS 的创始人,CentOS 被红帽收购后又另起炉灶搞的新 Fork。目前基本已经占据了原本 CentOS 的生态位。

最重要的是,PostgreSQL 官方源明确声明支持的 EL 系 OS 除了 RHEL 之外就是 RockyLinux 了。PGDG 构建环境就是 Rocky 8.8 与 9.2(6/7用的是CentOS)。可以说是对 PG 支持最好的 OS 发行版了。实际使用体验也非常不错,如果您没有特殊的需求,它应该是 EL 系 OS 的默认选择。

rockylinux.png

RockyLinux:100% BUG级兼容

龙蜥 / OpenAnolis 是阿里云牵头的国产化操作系统,号称100%兼容EL。本来我并没抱太大期望:只是有用户想用,我就支持一下,但实际效果超出了预期:EL8 的所有 RPM 包都一遍过,适配除了处理下 /etc/os-release 之外没有任何额外工作。适配了 Anolis 一个,就等于适配了十几种 “国产操作系统系统”发行版:阿里云、统信软件、中国移动、麒麟软件、中标软件、凝思软件、浪潮信息、中科方德、新支点、软通动力、博彦科技,可以说是很划算了。

openAnolis.png

基于 OpenAnolis 的商业操作系统发行版

如果您有“国产化”操作系统方面的需求,选择 OpenAnolis 或衍生的商业发行版,是一个不错的选择。

Oracle Linux / AlmaLinux / CentOS Stream 的兼容性相比 Rocky / Anolis 要拉跨一些,不是所有的 EL RPM 包都能直接安装成功:经常性出现依赖错漏问题。大部分包可以从它们自己的源里面找到补上 —— 有些兼容性问题,但基本上属于可以解决的小麻烦。这几个 OS 整体体验很一般,考虑到 Rocky / Anolis 已经足够好了,如果没有特殊理由我觉得没有必要使用这几种发行版。

OpenEuler 属于最拉跨的独一档,号称 EL兼容,但用起来完全不是这么回事。例如:在 PostgreSQL 内核与核心扩展中, postgresql15* ,patroni ,postgis33_15,pgbadger,pgbouncer 全部都需要重新编译。而且因为使用了不同版本的 LLVM,所有插件的 LLVMJIT 也都必须重新编译才能使用,费了非常多的功夫才完成支持,还不得不阉割掉一些功能,总的来说使用体验非常糟糕。

openEuler.png

适配时的一堆额外工作

我们有个大客户不得不用这个 OS,所以我们也不得不去做兼容性适配。适配这种操作系统简直是一种梦魇:工作量比支持 Debian / Ubuntu 系列操作系统还要大,在折腾用户这件事上确实做到了遥遥领先

BTW,知乎上有篇文章也介绍了这几个OS发行版的坑与对比,可以看看:


一些感想

之前我写过一篇《基础软件到底需要怎样的自主可控》,聊了聊关于国产操作系统/数据库的一些现状。核心的观点是:国家对于基础软件自主可控的核心需求是现有系统在制裁封锁的情况下能否继续运行,即运维自主可控,而不是研发自主可控

这里我测试适配了两种主流的国产化操作系统发行版,它们体现了两种不同的思路:OpenAnolis 与 EL 完全兼容,站在巨人的肩膀上,为有需要的用户提供服务与支持(运维自主可控),真正满足了用户需求 —— 不要折腾,让现有的软件/系统稳定运行。CentOS 停服,能有国内公司/社区站出来承担责任接手维护工作,这对于广大用户、现有系统与服务来说有着实打实的价值。

反观另外一个 OS Distro,选择了通篇魔改,为华而不实的“自研”虚荣面子去做一些没有使用价值甚至是负优化的垃圾分叉,却导致大量现有的软件不得不重新适配调整甚至弃用,给用户平白添加了不必要的负担,在折腾用户上做到了遥遥领先,堪称是 IT 领域的预制菜进校园,更是污染分裂了软件生态,自绝于全球软件产业链。

OpenEuler 和 OpenGauss 差不多:你说它能不能用?也不是不能用 —— 就是用着感觉跟吃屎一样。但问题是已经有自主可控也免费的饭吃了,那为什么还要吃屎呢?如果说领导就是要按头吃屎,或者给的钱实在太多了,那也没有办法。但如果把屎吃出了肉香味,还自觉遥遥领先,那就有些滑稽了。

我以前也没少嘲讽过阿里云的云服务(特别是EBS和RDS),但是在开源 OS 和 DB 上,谁是做实事谁是吹牛逼还是门清的。至少我认为 OpenAnolis 和 PolarDB 确实是有一些东西,比起 Euler 和 Gauss 这种没有使用价值的魔改分叉来说更配得上给世界另一个选择的说法。高质量有人维护提供服务的开源主干换皮发行版,要远远好于拍脑袋瞎魔改分叉出来的玩意儿。

同样是“自主可控”的EL系国产操作系统,Pigsty 对 OpenAnolis 和 OpenEuler 都提供了支持。前者的支持是开源免费的,因为没有任何适配成本。后者我们会本着客户至上的原则为有需要的客户提供支持:虽然我们已经适配完了,但永远也不会开源免费:必须收取高额的定制服务费用作为精神损失费才行。同理,我们也开源了对 PolarDB 的监控支持,但 OpenGauss 就不好意思了,还是自个儿玩去吧。

技术发展终究要适应先进生产力的发展要求,落后的东西最终总是会被时代所淘汰。用户也应该勇于发出自己的声音,并积极用脚投票,让那些做实事的产品与公司得到奖赏鼓励,让那些吹牛逼的东西早点儿淘汰滚蛋。不要到最后只剩翔吃了才追悔莫及。

基础软件需要什么样的自主可控?

微信公众号原文 | 知乎原文

当我们说自主可控时,到底在说什么?

对于一款基础软件(操作系统 / 数据库)来说,自主可控到底是指:由中国公司/中国人开发、发行、控制?还是可以运行在“国产操作系统”/国产芯片上? 名不正则言不顺,言不顺则事不成。当下的“自主可控”乱象正是与定义不清,标准不明有着莫大的关系。但这并不妨碍我们探究一下“信创安可自主可控”这件事,要实现的目标是什么?

国家的需求说起来很简单:打仗吃制裁后,现有系统还能不能继续跑起来

软件自主可控分为两个部分:运维自主可控研发自主可控 ,国家/用户真正需要的自主可控是前者。如果我们将基础软件“自主可控”的需求用金字塔层次的方式来表达,那么在这个需求金字塔中,国家的需求可以描述为: 对具有实用价值的基础软件:保三争五。至少应当做到 “本地自治运行”,最好能达到 “控制源代码”。

prymaid.png

基础软件自主可控需求金字塔

追求研发自主可控必须考虑活性问题。当基础软件领域(操作系统/数据库)已经存在成熟开源内核时,追求所谓 自研 对于国家与用户来说几乎没有实际价值:只有当某个团队功能研发/问题解决的速度超过全球开源社区,内核自研才是有实际意义的选择。大多数号称“自研”的基础软件厂商本质是套壳、换皮、魔改开源内核,自主可控程度属于2~3级甚至更低。低质量的软件分叉不但没有使用价值,更是浪费了稀缺的软件人才与市场机遇空间、并终将导致中国软件行业与全球产业链脱节,产生巨大的负外部性。 当我们从 Oracle/其他国外商业数据库迁移到替代方案时请注意:你的自主可控水平是否有实质意义上的提升?我们需要特别注意与警惕那些打着国产自研旗号的基础软件产品在垄断保护下劣币驱逐良币,抢占真正具有活性的开源基础软件的生态位,这会对自主可控事业造成真正的伤害 —— 所谓:搬石头砸自己的脚,自己卡自己的脖子。例如,一个所谓“自研”运行时却需要 License 文件不然就立即死给你看的国产数据库(标称L7 ,实际L2),其运维自主可控程度远比不上成熟的开源数据库(L4/L5)。如果该国产数据库公司因为任何原因失能(重组倒闭破产或被一炮轰烂),将导致一系列使用该产品的系统失去长期持续稳定运行的能力。 开源是一种全球协作的软件研发模式,在基础软件内核(操作系统/数据库)中占据压倒性优势地位。开源模式已经很好的解决了基础软件研发的问题,但没有很好地解决软件的运维问题,而这恰好是真正有意义自主可控所应当解决的 —— 软件的最终价值是在其使用过程中,而不是研发过程中实现的。真正有意义的自主可控是帮助国家/用户用好现有成熟开源操作系统/数据库内核 —— 提供基于开源内核的发行版与专业技术服务。在维持好现有/增量系统稳定运行的前提下,响应“人类命运共同体”的倡议,积极参与全球开源软件产业供应链治理,并扩大本国供应商的国际影响力。 综上所述,我们认为,运维自主可控 的重点在于:替代不可控的三方服务与受限制的商业软件,鼓励国内供应商基于流行的开源基础软件提供技术服务与发行版,对于具有重大使用价值的开源基础软件,鼓励学习、探索、研究与贡献。孵化培养国内开源社区,维护公平的竞争环境与健康的商业生态。 而 研发自主可控 的重点在于:积极参与全球开源软件产业供应链治理,提高国内软件公司与团队在全球顶级基础软件开源项目中的话语权,培养具有全球视野与先进研发能力的技术团队。应当停止低水平重复的“国产操作系统/数据库内核分叉“,着力打造具有国际影响力的服务与软件发行版。


附:自主可控的不同等级

对于基础软件来说,可控程度从高到低可细分为以下九个等级:

9:拥有软件发布权(发布权,67%)

8:掌握多数投票权(主导权,51%)

7:掌握少数否决权(否决权,34%)

6:拥有提议话语权(话语权,10%)

5:掌控源代码(跟主干修缺陷)

4:获取源代码(跨平台重分发)

3:掌控二进制(本地自治运行)

2:受限二进制(本地受限使用)

1:租用服务(调用远程服务)

其中,1 - 5 为运维自主可控,5-9 为研发自主可控。精简一下研发自主可控的几个层次,便可得到这张自主可控需求金字塔图:

自主可控第一层,租用服务的自主可控程度最差:硬件、数据都存储在供应商的服务器上。如果提供服务的公司倒闭、停产、消亡,那么软件就无法工作了,而使用这些软件创造的文档与数据就被锁死了。例如 OpenAI 提供的 ChatGPT 便属于此类。

自主可控第二层,受限二进制,意味着软件可以在自己的硬件上运行,但包含有额外的限制条件:例如需要定期更新的授权文件,或必须联网认证方可运行。此类软件的问题与上一层次类似:如果如果提供软件的公司倒闭、停产,那么使用此类软件的应用将在有限时间内死亡。一些需要授权文件才能运行的商业操作系统 / 商业数据库便属于此列。

自主可控第三层,控制二进制,意味着软件可以不受限制地在任意主流硬件上运行,用户可以在没有互联网访问的情况下不受限制地部署软件并使用其完整功能,直到地老天荒。拥有不受限制的二进制,也意味着国内供应商可以基于软件提供自己的服务,进行换皮绝大多数场景所需要的自主可控程度落在这一层。 自主可控第四层,拥有源代码,意味着软件可以被重新编译与分发,这一层自主可控意味着即使硬件受到制裁,现有开源软件系统也可以运行在国产操作系统/硬件之上。同时也意味着国内供应商可以提供自己的发行版,提供服务,进行套壳与再分发。开源基础软件默认坐落在这一层上,绝大多标称自己“自研”的国产操作系统/数据库实质上属于这一类。

自主可控第五层,掌控源代码,意味着对开源软件有跟进与兜底的能力,这意味着即使在最极端的情况下:全球开源软件社区与中国脱钩,国内供应商也可以自行分叉、跟进主干功能特性、并修复缺陷,长期确保软件的活性与安全性。掌握源代码意味着可以进行实质性魔改,并开始从运维自主可控到研发自主可控过渡。极个别国内厂商拥有此能力,也是国家对于自主可控的期待的合理上限。

从第六层到第九层,就进入了“研发自主可控”的范畴。根据国内供应商的话语权比例可以划分为四个不同的等级(提议权/否决权/主导权/发布权)。这涉及到基础软件开源内核的参与和治理。这意味着国内供应商可以参与到全球开源基础软件供应链中,发出自己的声音与影响力,参与社区治理甚至主导项目的方向。

对于全球范围内有使用价值的开源基础软件来说,对中国有意义的自主可控策略是:去二保三争五。更高的六至九所代表的“研发自主可控” 属于 Nice to have:有当然好,应当尽可能争取,但没有也不影响现有/增量系统的自主可控。切忌为了华而不实的“自研”虚荣面子去做一些没有使用价值甚至是负优化的垃圾分叉,而抛弃功能活性的里子,自绝于全球软件产业链。

数据库需求层次金字塔

微信公众号原文

与马斯洛需求金字塔类似,用户对于数据库的需求也有着一个递进的层次。用户对于数据库的需求从下往上可以分为八个层次,分别与人的八个需求层次相对应:

安全需求与生理需求同属基础需求,一个用于生产环境的严肃数据库系统至少应当满足这两类需求,才足以称得上是合格。归属需求与尊重需求同属进阶需求,满足这两类需求,可以称得上是体面。认知需求与审美需求属于高级需求,满足这两类需求,方能配得上 品味 二字。

在自我实现与超越需求上,不同种类的用户可能会有不同的需求,比如普通工程师的超越需求可能是升职加薪,搞出成绩赚大钱;而头部用户关注的可能是意义、创新与行业变革。

但是在基础需求与进阶需求上,所有类型的用户几乎是高度一致的。

生理需求

生理需求是级别最低、最急迫的需求,如:食物、水、空气、睡眠。

对于数据库用户来说,生理需求指的是功能

  • **内核特性:**数据库内核的特性是否满足需求?
  • 正确性:功能是否正确实现,没有显著缺陷?
  • ACID:是否支持确保正确性的核心功能— 事务?

对于数据库来说,功能需求就是最基础的生理需求。正确性与 ACID 是数据库最基本的要求:诚然一些不甚重要的数据与边缘系统,可以使用更灵活的数据模型,NoSQL数据库,KV存储。但对于关键核心数据来说经典 ACID 关系型数据库的地位仍然是无可取代的。此外,如果用户需要的就是 PostGIS 处理地理空间数据的能力,或者TimescaleDB 处理时序数据的能力,那么没有这些特性的数据库内核就会被一票否决。

安全需求

安全需求同样属于基础层面的需求,其中包括对人身安全、生活稳定以及免遭痛苦、威胁或疾病、身体健康以及有自己的财产等与自身安全感有关的事情。

对于数据库来说,安全需求包括:

  • 机密:避免未授权的访问,数据不泄漏,不被拖库
  • 完整:数据不丢不错不漏,即使误删了也有办法找回来。
  • 可用:可以稳定提供服务,出了故障也有办法及时恢复回来。

安全需求无论对于数据库还是人类都是至关重要的。数据库如果丢了,被拖库了,或者数据错乱了,一些企业可能就直接破产了。满足安全需求意味着数据库有了兜底,有了灾难生存能力。冷备份,WAL归档,异地备份仓库,访问控制,流量加密,身份认证,这些技术用于满足安全需求。

安全需求与生理需求同属基础需求,一个用于生产环境的严肃数据库系统至少应当满足这两类需求,才足以称得上是合格

归属需求

爱和归属的需求(常称为“社交需求”)属于进阶需求,如:对友谊,爱情以及隶属关系的需求。

对于数据库来说,社交需求意味着:

  • 监控:有人会关注着数据库健康,监控诸如心率、血氧等核心生理指标。
  • 告警:而当数据库出现问题时,指标异常时,会有人接到通知来及时处理。
  • 高可用:主库不再单打独斗,拥有了自己的追随者分担工作并在故障时能接管工作。

对数据库可靠性的需求,可以与人类对于爱和归属的需求相类比。归属意味着数据库是有人关心,有人照看着,有人支持着的。监控负责感知环境收集数据库指标,而告警组件将异常现象问题及时上抛给人类处理。多物理从库副本+自动故障切换实现高可用架构的数据库,甚至软件自身便足以检测判定应对很多常见的故障。

归属需求属于进阶需求,当基础需求(功能/安全)得到满足后,用户会开始对监控告警高可用产生需求。一个体面的数据库服务,监控告警高可用是必不可少的。

尊重需求

尊重需求是指人们对自己的尊重和自信,以及希望获得他人的尊重的需求,属于进阶层面的需求。对于数据库来说,尊重需求主要包括:

  • 性能:能够支撑高并发、大规模数据处理等高性能场景。
  • 成本:具有合理的价格和成本控制。
  • 复杂度:易于使用和管理,不会带来过多的复杂度。

对于数据库来说,安全可靠是本分,物美价廉才能出彩。数据库产品的 ROI 对应于人的尊重需求。正所谓:性价比是第一产品力,更强力、更便宜、更好用是三个核心诉求:更高的 ROI 意味着数据库以更低的财务代价与复杂度代价实现更优秀的性能表现。任何开创性的特色功能与设计,说到底也是通过提高 ROI 来赢得真正的赞誉与尊重的。

归属需求与尊重需求同属进阶需求,一个数据库系统只有满足这两类需求,才足以称得上是体面。满足了基础需求与进阶需求的用户,则会开始产生更高层次的需求:认知与审美。

认知需求

认知需求是指人们对于知识、理解和掌握新技能的需求,属于进阶层面的需求。对于数据库来说,认知需求主要包括:

  • 可观测性:能够对数据库与相关系统内部运行状态进行观测,做到全知。
  • 可视化:将数据通过图表等方式进行可视化展示,揭示内在联系,提供洞察。
  • 数字化:使用数据作为决策依据,使用标准化决策过程而非老师傅拍脑袋。

人要进步发展须进行自省,而认知需求对于数据库也一样重要:归属需求中的“监控“只关注数据库的基本生存状态,而认知需求关注的是对数据库与环境的理解与洞察。现代可观测性技术栈将收集丰富的监控指标并进行可视化呈现,而 DBA / 研发 / 运维 / 数据分析 人员则会从数据与可视化中提取洞察,形成对系统的理解与认知。

没有观测就谈不上控制可观测是为了可控制,全知即全能。只有对数据库有了深入的认知,才可以真正做到收放自如,随心所欲不逾矩。

审美需求

审美需求是指人们对于美的需求,包括审美体验、审美评价和审美创造。对于数据库来说,审美需求主要包括:

  • 可控制性:用户的意志可以被数据库系统贯彻执行。
  • 易用性:友好的界面接口工具,最小化人工操作。
  • IaC:基础设施即代码,使用声明式配置描述环境。

对于数据库来说,审美需求意味着更高级的掌控能力:简单易用的接口,高度自动化的实现,精细的定制选项,以及声明式的管理哲学。

高度可控,简单易用的数据库,才是有品味的数据库。可控制性可观测性的对偶概念,指:是否可以通过一些允许的程序让系统调整到其状态空间内的任何一个状态。传统的运维方式关注过程,要创建/销毁/扩缩容数据库集群,用户需要按照手册依次执行各种命令;而现代管理方式关注状态,用户声明式的表达自己想要什么,而系统自动调整至用户所描述的状态。

洞察掌控求属于高级需求,满足这两类需求的数据库系统,才足以称得上 品味。而这两者,也是满足更高超越层面需求的基石。

自我实现

自我实现(Self-actualization)是指人们追求最高水平的自我实现和个人成长的需求,属于超越层面的需求。对于数据库来说,自我实现需求主要包括:

  • 标准化:将各类操作沉淀为 SOP ,沉淀故障文档应急预案与制度最佳实践,量产DBA。
  • 产品化:将用好/管好数据库的经验沉淀转化为可批量复制的工具、产品与服务。
  • 智能化:范式转变,沉淀出领域特定的模型,完成人到软件的转变与飞跃。

数据库的自我实现与人类似:繁衍进化。为了持续存在,数据库需要“繁衍”扩大存在规模,这需要的是标准化与产品化,而不是一个接一个的项目。关系数据库内核功能标准化已经有SQL作为标准,然而使用数据库的方法与管理数据库的人还差的很远:更多依赖的是老师傅的直觉与经验。以 GPT4 为代表的大模型,揭示了由 AI 替代专家(领域模型)的可能性,Soon or Later,一定会演进出现模型化的DBA,实现感知-决策-执行三个层面的彻底自动化。

超越需求

超越需求(Self-transcendence)是指人们追求更高层次的价值和意义的需求,属于最高层面的需求。对于数据库来说,这也许意味着 一个几乎不需要人参与的真·自治数据库系统

前面的所有需求都得到满足时,超越需求会出现。想要做到真正的数据库自治,前提就是感知、思考、执行三个部分的自动化与智能化。认知层次的需求解决“信息系统”,负责感知职能;审美层次的需求解决“行动系统”,负责实施控制;而自我实现层次解决“模型系统”,负责做出决策。

这也可也说是数据库领域的圣杯与终极目标之一了。

然后呢?

理论模型可以帮助我们对数据系统/发行版/管控软件/云服务进行更深刻的评价与对比。比如:大部分土法自建的数据库可能还在生理需求和安全需求上挣扎,属于不合格残次品。云数据库基本属于能满足下三层功能安全可靠需求的合格品,但是在 ROI / 价格上就不太体面(参阅《云数据库是不是杀猪盘》)。顶级资深数据库专家自建,可以满足更高层次的需求,但实在是太过金贵,供不应求。最后还得是广告时间:

虽然我是 Pigsty 的作者,但我更是一个资深的甲方用户。我做这个东西的原因正是因为市面上没有足够好的能满足 L4 L5 感知/管控需求的数据库产品或服务,所以才自己动手撸了一个。开源 RDS 替代 Pigsty + IDC/云服务器自建,在满足上述需求的前提下,还可以覆盖认知、审美与少部分自我实现的需求。让你的数据库坚如磐石,辅助自动驾驶,更离谱的是开源免费,ROI 上吊打一切云数据库,如果你会用到 PGSQL, (以及REDIS, ETCD, MINIO, 或者 Prometheus/Grafana全家桶), 那还不赶紧试试?http://demo.pigsty.cc

最近发布了 Pigsty 2.0.1 版本,使用以下命令一键安装。

bash -c "$(curl -fsSL http://download.pigsty.cc/get)"

分布式数据库是不是伪需求?

微信公众号原文

随着硬件技术的进步,单机数据库的容量和性能已达到了前所未有的高度。而分布式(TP)数据库在这种变革面前极为无力,和“数据中台”一样穿着皇帝的新衣,处于自欺欺人的状态里。


太长不看

分布式数据库的核心权衡是:“以质换量”,牺牲功能、性能、复杂度、可靠性,换取更大的数据容量与请求吞吐量。但分久必合,硬件变革让集中式数据库的容量与吞吐达到一个全新高度,使分布式(TP)数据库失去了存在意义。

以 NVMe SSD 为代表的硬件遵循摩尔定律以指数速度演进,十年间性能翻了几十倍,价格降了几十倍,性价比提高了三个数量级。单卡 32TB+, 4K随机读写 IOPS 可达 1600K/600K,延时 70µs/10µs,价格不到 200 ¥/TB·年。跑集中式数据库单机能有一两百万的点写/点查 QPS。

真正需要分布式数据库的场景屈指可数,典型的中型互联网公司/银行请求数量级在几万到几十万QPS,不重复TP数据在百TB上下量级。真实世界中 99% 以上的场景用不上分布式数据库,剩下1%也大概率可以通过经典的水平/垂直拆分等工程手段解决。

头部互联网公司可能有极少数真正的适用场景,然而此类公司没有任何付费意愿。市场根本无法养活如此之多的分布式数据库内核,能够成活的产品靠的也不见得是分布式这个卖点。HATP 、分布式单机一体化是迷茫分布式TP数据库厂商寻求转型的挣扎,但离 PMF 仍有不小距离。


互联网的牵引

“分布式数据库” 并不是一个严格定义的术语。狭义上它与 NewSQL:cockroachdb / yugabytesdb / tidb / oceanbase / TDSQL 等数据库高度重合;广义上 Oracle / PostgreSQL / MySQL / SQL Server / PolarDB / Aurora 这种跨多个物理节点,使用主从复制或者共享存储的经典数据库也能归入其中。在本文语境中,分布式数据库指前者,且只涉及核心定位为事务处理型(OLTP)的分布式关系型数据库

分布式数据库的兴起源于互联网应用的快速发展和数据量的爆炸式增长。在那个时代,传统的关系型数据库在面对海量数据和高并发访问时,往往会出现性能瓶颈和可伸缩性问题。即使用 Oracle 与 Exadata,在面对海量 CRUD 时也有些无力,更别提每年以百千万计的高昂软硬件费用。

互联网公司走上了另一条路,用诸如 MySQL 这样免费的开源数据库自建。老研发/DBA可能还会记得那条 MySQL 经验规约:单表记录不要超过 2100万,否则性能会迅速劣化;与之对应的是,数据库分库分表开始成为大厂显学。

这里的基本想法是“三个臭皮匠,顶个诸葛亮”,用一堆便宜的 x86 服务器 + 大量分库分表开源数据库实例弄出一个海量 CRUD 简单数据存储。故而,分布式数据库往往诞生于互联网公司的场景,并沿着手工分库分表 → 分库分表中间件 → 分布式数据库这条路径发展进步

作为一个行业解决方案,分布式数据库成功满足了互联网公司的场景需求。但是如果想把它抽象沉淀成一个产品对外输出,还需要想清楚几个问题:

十年前的利弊权衡,在今天是否依然成立?

互联网公司的场景,对其他行业是否适用?

分布式事务数据库,会不会是一个伪需求?


分布式的权衡

分布式” 同 “HTAP”、 “存算分离”、“Serverless”、“湖仓一体” 这样的Buzzword一样,对企业用户来说没有意义。务实的甲方关注的是实打实的属性与能力:功能性能、安全可靠、投入产出、成本效益。真正重要的是利弊权衡:分布式数据库相比经典集中式数据库,牺牲了什么换取了什么?

distributive-bullshit-1.png

数据库需求层次金字塔[1]

分布式数据库的核心Trade Off 可以概括为:“以质换量”:牺牲功能、性能、复杂度、可靠性,换取更大的数据容量与请求吞吐量。

NewSQL 通常主打“分布式”的概念,通过“分布式”解决水平伸缩性问题。在架构上通常拥有多个对等数据节点以及协调者,使用分布式共识协议 Paxos/Raft 进行复制,可以通过添加数据节点的方式进行水平伸缩

首先,分布式数据库因其内在局限性,会牺牲许多功能,只能提供较为简单有限的 CRUD 查询支持。其次,分布式数据库因为需要通过多次网络 RPC 完成请求,所以性能相比集中式数据库通常有70%以上的折损。再者,分布式数据库通常由DN/CN以及TSO等多个组件构成,运维管理复杂,引入大量非本质复杂度最后,分布式数据库在高可用容灾方面相较于经典集中式主从并没有质变,反而因为复数组件引入大量额外失效点。

distributive-bullshit-2.png

SYSBENCH吞吐对比[2]

在以前,分布式数据库的利弊权衡是成立的:互联网需要更大的数据存储容量与更高的访问吞吐量:这个问题是必须解决的,而这些缺点是可以克服的。但今日,硬件的发展废问了 量 的问题,那么分布式数据库的存在意义就连同着它想解决的问题本身被一并抹除了。

distributive-bullshit-3.png

时代变了,大人


新硬件的冲击

摩尔定律指出,每18~24个月,处理器性能翻倍,成本减半。这个规律也基本适用于存储。从2013年开始到2023年是5~6个周期,性能和成本和10年前比应该有几十倍的差距,是不是这样呢?

让我们看一下 2013 年典型 SSD 的性能指标,并与 2022 年 PCI-e Gen4 NVMe SSD 的典型产品进行对比。不难发现:硬盘4K随机读写 IOPS从 60K/40K 到了 1600K/600K,价格从 2220$/TB40$/TB 。性能翻了15 ~ 26倍,价格便宜了56 倍[3,4,5],作为经验法则在数量级上肯定是成立了。

distributive-bullshit-4.png

2013 年 HDD/SSD 性能指标

distributive-bullshit-5.png

2022 年NVMe Gen4 SSD 性能指标

十年前,机械硬盘还是绝对主流。1TB 的硬盘价格大概七八百元,64GB 的SSD 还要再贵点。十年后,主流 3.2TB 的企业级 NVMe SSD 也不过三千块钱。按五年质保折算,1TB每月成本只要 16块钱,每年成本不到 200块。作为参考,云厂商号称物美价廉的 S3对象存储都要 1800¥/TB·年。

distributive-bullshit-6.png

2013 - 2030 SSD/HDD 单位价格与预测

典型的第四代本地 NVMe 磁盘单卡最大容量可达 32TB~ 64TB,提供 70µs/10µs 4K随机读/写延迟,1600K/600K 的读写IOPS,第五代更是有着单卡十几GB/s 的惊人带宽。

这样的卡配上一台经典 Dell 64C / 512G 服务器,IDC代维5年折旧,总共十万块不到。而这样一台服务器跑 PostgreSQL sysbench 单机点写入可以接近百万QPS,点查询干到两百万 QPS 不成问题。

这是什么概念呢?对于一个典型的中型互联网公司/银行,数据库请求数量级通常在几万/几十万 QPS这个范围;不重复的TP数据量级在百TB上下浮动。考虑到使用硬件存储压缩卡还能有个几倍压缩比,这类场景在现代硬件条件下,有可能集中式数据库单机单卡就直接搞定了[6]。

在以前,用户可能需要先砸个几百万搞 exadata 高端存储,再花天价购买 Oracle 商业数据库授权与原厂服务。而现在做到这些,硬件上只需一块几千块的企业级 SSD 卡即可起步;像 PostgreSQL 这样的开源 Oracle 替代,最大单表32TB照样跑得飞快,不再有当年MySQL非要分表不可的桎梏。原本高性能的数据库服务从情报/银行领域的奢侈品,变成各行各业都能轻松负担得起的平价服务[7]。

性价比是第一产品力,高性能大容量的存储在十年间性价比提高了三个数量级,分布式数据库曾经的价值亮点,在这种大力出奇迹的硬件变革下显得软弱无力。


伪需求的困境

在当下,牺牲功能性能复杂度换取伸缩性有极大概率是伪需求

在现代硬件的加持下,真实世界中 99%+ 的场景超不出单机集中式数据库的支持范围,剩下1%也大概率可以通过经典的水平/垂直拆分等工程手段解决。这一点对于互联网公司也能成立:即使是全球头部大厂,不可拆分的TP单表超过几十TB的场景依然罕见。

NewSQL的祖师爷 Google Spanner 是为了解决海量数据伸缩性的问题,但又有多少企业能有Google的业务数据量?从数据量上来讲,绝大多数企业终其生命周期的TP数据量,都超不过集中式数据库的单机瓶颈,而且这个瓶颈仍然在以摩尔定律的速度指数增长中。从请求吞吐量上来讲,很多企业的数据库性能余量足够让他们把业务逻辑全部用存储过程实现并丝滑地跑在数据库中。

过早优化是万恶之源”,为了不需要的规模去设计是白费功夫。如果量不再成为问题,那么为了不需要的量去牺牲其他属性就成了一件毫无意义的事情。

distributive-bullshit-7.png

“过早优化是万恶之源”

在数据库的许多细分领域中,分布式并不是伪需求:如果你需要一个高度可靠容灾的简单低频 KV 存储元数据,那么分布式的 etcd 就是合适的选择;如果你需要一张全球地理分布的表可以在各地任意读写,并愿意承受巨大的性能衰减作为代价,那么分布式的 YugabyteDB 也许是一个不错的选择。如果你需要进行信息公示并防止篡改与抵赖,区块链在本质上也是一种 Leaderless 的分布式账本数据库;

对于大规模数据分析OLAP来说,分布式可以说是必不可少(不过这种一般称为数据仓库,MPP);但是在事务处理OLTP领域,分布式可以说是大可不必:OTLP数据库属于工作性记忆,而工作记忆的特点就是小、快、功能丰富。即使是非常庞大的业务系统,同一时刻活跃的工作集也不会特别大。OLTP 系统设计的一个基本经验法则就是:如果你的问题规模可以在单机内解决,就不要去折腾分布式数据库

OLTP 数据库已经有几十年的历史,现有内核已经发展到了相当成熟的地步。TP 领域标准正在逐渐收敛至 PostgreSQL,MySQL,Oracle 三种 Wire Protocol 。如果只是折腾数据库自动分库分表再加个全局事务这种“分布式”,那一定是没有出路的。如果真能有“分布式”数据库杀出一条血路,那大概率也不是因为“分布式”这个“伪需求”,而应当归功于新功能、开源生态、兼容性、易用性、国产信创、自主可控这些因素。


迷茫下的挣扎

分布式数据库最大的挑战来自于市场结构:最有可能会使用分布式TP数据库的互联网公司,反而是最不可能为此付费的一个群体。互联网公司可以作为很好的高质量用户甚至贡献者,提供案例、反馈与PR,但唯独在为软件掏钱买单这件事上与其模因本能相抵触。即使头部分布式数据库厂商,也面临着叫好不叫座的难题。

近日与某分布式数据库厂工程师闲聊时获悉,在客户那儿做 POC 时,Oracle 10秒跑完的查询,他们的分布式数据库用上各种资源和 Dirty Hack 都有一个数量级上的差距。即使是从10年前 PostgreSQL 9.2 分叉出来的 openGauss,都能在一些场景下干翻不少分布式数据库,更别提10年后的 PostgreSQL 15 与 Oracle 23c 了。这种差距甚至会让原厂都感到迷茫,分布式数据库的出路在哪里?

所以一些分布式数据库开始自救转型, HTAP 是一个典型例子:分布式搞事务鸡肋,但是做分析很好呀。那么为什么不能捏在一起凑一凑?一套系统,同时可以做事务处理与分析哟!但真实世界的工程师都明白:AP系统和TP系统各有各的模式,强行把两个需求南辕北辙的系统硬捏合在一块,只会让两件事都难以成功。不论是使用经典 ETL/CDC 推拉到专用 ClickHouse/Greenplum/Doris 去处理,还是逻辑复制到In-Mem列存的专用从库,哪一种都要比用一个奇美拉杂交HTAP数据库要更靠谱。

另一种思路是 单机分布式一体化打不过就加入 :添加一个单机模式以规避代价高昂的网络RPC开销,起码在那些用不上分布式的99%场景中,不至于在硬指标上被集中式数据库碾压得一塌糊涂 —— 用不上分布式没关系,先拽上车别被其他人截胡! 但这里的问题本质与 HTAP 是一样的:强行整合异质数据系统没有意义,如果这样做有价值,那么为什么没人去把所有异构数据库整合一个什么都能做的巨无霸二进制 —— 数据库全能王? 因为这样违背了KISS原则:Keep It Simple, Stupid!

distributive-bullshit-8.png

分布式数据库和数据中台的处境类似[8]:起源于互联网大厂内部的场景,也解决过领域特定的问题。曾几何时乘着互联网行业的东风,数据库言必谈分布式,火热风光好不得意。却因为过度的包装吹捧,承诺了太多不切实际的东西,又无法达到用户预期 —— 最终一地鸡毛,成为皇帝的新衣。

TP数据库领域还有很多地方值得投入精力:Leveraging new hardwares,积极拥抱 CXL,RDMA,NVMe 等底层体系结构变革;或者提供简单易用的声明式接口,让数据库的使用与管理更加便利;提供更为智能的自动驾驶监控管控,尽可能消除运维性的杂活儿;开发类似 Babelfish 的 MySQL / Oracle 兼容插件,实现关系数据库 WireProtocol 统一。哪怕砸钱堆人提供更好的支持服务,都比一个 “分布式” 的伪需求噱头要更有意义。

因时而动,君子不器。愿分布式数据库厂商们找到自己的 PMF,做一些用户真正需要的东西。


References

[1] 数据库需求层次金字塔 : https://mp.weixin.qq.com/s/1xR92Z67kvvj2_NpUMie1Q

[2] PostgreSQL到底有多强? : https://mp.weixin.qq.com/s/651zXDKGwFy8i0Owrmm-Xg

[3] 2013年SSD性能 : https://www.snia.org/sites/default/files/SNIASSSI.SSDPerformance-APrimer2013.pdf

[4] 2022年镁光9400 NVMe SSD 规格说明 : https://media-www.micron.com/-/media/client/global/documents/products/product-flyer/9400_nvme_ssd_product_brief.pdf

[5] 2013-2030 SSD价格走势与预测 : https://blocksandfiles.com/2021/01/25/wikibon-ssds-vs-hard-drives-wrights-law/

[6] 单实例100TB使用压缩卡到20TB: https://mp.weixin.qq.com/s/JSQPzep09rDYbM-x5ptsZA

[7] 公有云是不是杀猪盘?: https://mp.weixin.qq.com/s/UxjiUBTpb1pRUfGtR9V3ag

[8] 中台:一场彻头彻尾的自欺欺人: https://mp.weixin.qq.com/s/VgTU7NcOwmrX-nbrBBeH_w

微服务是不是个蠢主意?

微信公众号原文

亚马逊的Prime Video团队发表了一篇非常引人注目的案例研究[2] ,讲述了他们为什么放弃了微服务与Serverless架构而改用单体架构。这一举措让他们在运营成本上节省了惊人的 90%,还简化了系统复杂度,堪称一个巨大的胜利。

但除了赞扬他们的明智之举之外,我认为这里还有一个重要洞察适用于我们整个行业:

“我们最初设计的解决方案是:使用Serverless组件的分布式系统架构… 理论上这个架构可以让我们独立伸缩扩展每个服务组件。然而,我们使用某些组件的方式导致我们在大约5%的预期负载时,就遇到了硬性的伸缩限制。”

理论上的” —— 这是对近年来在科技行业肆虐的微服务狂热做的精辟概括。现在纸上谈兵的理论终于有了真实世界的结论:在实践中,微服务的理念就像塞壬歌声一样诱惑着你,为系统添加毫无必要的复杂度,而 Serverless 只会让事情更糟糕。

这个故事最搞笑的地方是:亚马逊自己就是面向服务架构 / SOA 的最初典范与原始代言人。在微服务流行之前,这种组织模式还是很合理的:在一个疯狂的规模下,公司内部通信使用 API 调用的模式,是能吊打协调跨团队会议的模式的。

SOA 在亚马逊的规模下很有意义,没有任何一个团队能够知道或理解 驾驶这么一艘巨无霸邮轮所需的方方面面,而让团队之间通过公开发布的 API 进行协作简直是神来之笔。

但正如很多“好主意”一样,这种模式在脱离了原本的场景用在其他地方后,就开始变得极为有害了:特别是塞进单一应用架构内部时 —— 而人们就是这么搞微服务的。

从很多层面上来说,微服务是一种僵尸架构,是一种顽强的思想病毒:从 J2EE 的黑暗时代(Remote Server Beans,有人听说过吗),一直到 WS-Deathstar[3] 死星式的胡言乱语,再到现在微服务与 Serverless 的形式,它一直在吞噬大脑,消磨人们的智力。

不过这第三波浪潮总算是到顶了,我在 2016 年就写过一首关于 “宏伟的单体应用[4]” 的赞歌。Kubernetes 背后的意见领袖高塔先生也在 2020年 单体才是未来[5] 一文中表达过这一点:

“我们要打破单体应用,找到先前从未有过的工程纪律… 现在人们从编写垃圾代码变成打造垃圾平台与基础设施。

人们沉迷于这些与时髦术语绑定的热钱与炒作,因为微服务带来了大量的新开销,招聘机会与工作岗位,唯独对于解决他们的问题来说实际上并没有必要。“

没错,当你拥有一个连贯的单一团队与单体应用程序时,用网络调用和服务拆分取代方法调用与模块切分,在几乎所有情况下都是一个无比疯狂的想法。

我很高兴在记忆中已经是第三次击退这种僵尸狂潮一样的蠢主意了。但我们必须保持警惕,因为我们早晚还得继续这么干:有些山炮想法无论弄死多少次都会卷土重来。你能做的就是当它们借尸还魂的时候及时认出来,用文章霰弹枪给他喷个稀巴烂。

有效的复杂系统总是从简单的系统演化而来。反之亦然:从零设计的复杂系统没一个能有效工作的。

—— 约翰・加尔,Systemantics(1975)

本文作者 DHH, Ruby on Rails 作者,37signals CTO,译者冯若航。

原题为 Even Amazon can’t make sense of serverless or microservices[1] 。即《亚马逊自个都觉得微服务和Serverless扯淡了》

References

[1] Even Amazon can’t make sense of serverless or microservices: https://world.hey.com/dhh/even-amazon-can-t-make-sense-of-serverless-or-microservices-59625580 [2] 引人注目的案例研究: https://www.primevideotech.com/video-streaming/scaling-up-the-prime-video-audio-video-monitoring-service-and-reducing-costs-by-90 [3] WS-Deathstar: https://www.flickr.com/photos/psd/1428661128/ [4] 宏伟的单体应用: https://m.signalvnoise.com/the-majestic-monolith/ [5] 单体才是未来: https://changelog.com/posts/monoliths-are-the-future

是时候和GPL说再见了

原文由 Martin Kleppmann 于2021年4月14日发表,译者:冯若航。

Martin Kleppmann是《设计数据密集型应用》(a.k.a DDIA)的作者,译者冯若航为该书中文译者。

本文的导火索是Richard Stallman恢复原职,对于自由软件基金会(FSF)的董事会而言,这是一位充满争议的人物。我对此感到震惊,并与其他人一起呼吁将他撤职。这次事件让我重新评估了自由软件基金会在计算机领域的地位 —— 它是GNU项目(宽泛地说它属于Linux发行版的一部分)和以GNU通用公共许可证(GPL)为中心的软件许可证系列的管理者。这些努力不幸被Stallman的行为所玷污。然而这并不是我今天真正想谈的内容

在本文中,我认为我们应该远离GPL和相关的许可证(LGPL、AGPL),原因与Stallman无关,只是因为,我认为它们未能实现其目的,而且它们造成的麻烦比它们产生的价值要更大。

首先简单介绍一下背景:GPL系列许可证的定义性特征是 copyleft 的概念,它指出,如果你用了一些GPL许可的代码并对其进行修改或构建,你也必须在同一许可证下免费提供你的修改/扩展(被称为"衍生作品")(大致意思)。这样一来,GPL的源代码就不能被纳入闭源软件中。乍看之下,这似乎是个好主意。那么问题在哪里?

敌人变了

在上世纪80年代和90年代,当GPL被创造出来时,自由软件运动的敌人是微软和其他销售闭源(“专有”)软件的公司。GPL打算破坏这种商业模式,主要出于两个原因:

  1. 闭源软件不容易被用户所修改;你可以用,也可以不用,但你不能根据自己的需求对它进行修改定制。为了抵制这种情况,GPL设计的宗旨即是,迫使公司发布其软件的源代码,这样软件的用户就可以研究、修改、编译和使用他们自己的修改定制版本,从而获得按需定制自己计算设备的自由。
  2. 此外,GPL的动机也包括对公平的渴望:如果你在业余时间写了一些软件并免费发布,但是别人用它获利,又不向社区回馈任何东西,你肯定也不希望这样的事情发生。强制衍生作品开源,至少可以确保一些兜底的"回报"。

这些原因在1990年有意义,但我认为,世界已经变了,闭源软件已经不是主要问题所在。在2020年,计算自由的敌人是云计算软件(又称:软件即服务/SaaS,又称网络应用/Web Apps)—— 即主要在供应商的服务器上运行的软件,而你的所有数据也存储在这些服务器上。典型的例子包括:Google Docs、Trello、Slack、Figma、Notion和其他许多软件。

这些“云软件”也许有一个客户端组件(手机App,网页App,跑在你浏览器中的JavaScript),但它们只能与供应商的服务端共同工作。而云软件存在很多问题:

  • 如果提供云软件的公司倒闭,或决定停产,软件就没法工作了,而你用这些软件创造的文档与数据就被锁死了。对于初创公司编写的软件来说,这是一个很常见的问题:这些公司可能会被大公司收购,而大公司没有兴趣继续维护这些初创公司的产品。
  • 谷歌和其他云服务可能在没有任何警告和追索手段的情况下,突然暂停你的账户。例如,您可能在完全无辜的情况下,被自动化系统判定为违反服务条款:其他人可能入侵了你的账户,并在你不知情的情况下使用它来发送恶意软件或钓鱼邮件,触发违背服务条款。因而,你可能会突然发现自己用Google Docs或其它App创建的文档全部都被永久锁死,无法访问了。
  • 而那些运行在你自己的电脑上的软件,即使软件供应商破产了,它也可以继续运行,直到永远。(如果软件不再与你的操作系统兼容,你也可以在虚拟机和模拟器中运行它,当然前提是它不需要联络服务器来检查许可证)。例如,互联网档案馆有一个超过10万个历史软件的软件集锦,你可以在浏览器中的模拟器里运行!相比之下,如果云软件被关闭,你没有办法保存它,因为你从来就没有服务端软件的副本,无论是源代码还是编译后的形式。
  • 20世纪90年代,无法定制或扩展你所使用的软件的问题,在云软件中进一步加剧。对于在你自己的电脑上运行的闭源软件,至少有人可以对它的数据文件格式进行逆向工程,这样你还可以把它加载到其他的替代软件里(例如OOXML之前的微软Office文件格式,或者规范发布前的Photoshop文件)。有了云软件,甚至连这个都做不到了,因为数据只存储在云端,而不是你自己电脑上的文件。

如果所有的软件都是免费和开源的,这些问题就都解决了。然而,开源实际上并不是解决云软件问题的必要条件;即使是闭源软件也可以避免上述问题,只要它运行在你自己的电脑上,而不是供应商的云服务器上。请注意,互联网档案馆能够在没有源代码的情况下维持历史软件的正常运行:如果只是出于存档的目的,在模拟器中运行编译后的机器代码就够了。也许拥有源码会让事情更容易一些,但这并不是不关键,最重要的事情,还是要有一份软件的副本。

本地优先的软件

我和我的合作者们以前曾主张过本地优先软件的概念,这是对云软件的这些问题的一种回应。本地优先的软件在你自己的电脑上运行,将其数据存储在你的本地硬盘上,同时也保留了云计算软件的便利性,比如,实时协作,和在你所有的设备上同步数据。开源的本地优先的软件当然非常好,但这并不是必须的,本地优先软件90%的优点同样适用于闭源的软件。

云软件,而不是闭源软件,才是对软件自由的真正威胁,原因在于:云厂商能够突然心血来潮随心所欲地锁定你的所有数据,其危害要比无法查看和修改你的软件源码的危害大得多。因此,普及本地优先的软件显得更为重要和紧迫。如果在这一过程中,我们也能让更多的软件开放源代码,那也很不错,但这并没有那么关键。我们要聚焦在最重要与最紧迫的挑战上。

促进软件自由的法律工具

Copyleft软件许可证是一种法律工具,它试图迫使更多的软件供应商公开其源码。尤其是AGPL,它尝试迫使云厂商发布其服务器端软件的源代码。然而这并没有什么用:大多数云厂商只是简单拒绝使用AGPL许可的软件:要么使用一个采用更宽松许可的替代实现版本,要么自己重新实现必要的功能,或者直接购买一个没有版权限制的商业许可。有些代码无论如何都不会开放,我不认为这个许可证真的有让任何本来没开源的软件变开源。

作为一种促进软件自由的法律工具,我认为 copyleft 在很大程度上是失败的,因为它们在阻止云软件兴起上毫无建树,而且可能在促进开源软件份额增长上也没什么用。开源软件已经很成功了,但这种成功大部分都属于 non-copyleft 的项目(如Apache、MIT或BSD许可证),即使在GPL许可证的项目中(如Linux),我也怀疑版权方面是否真的是项目成功的重要因素。

对于促进软件自由而言,我相信更有前景的法律工具是政府监管。例如,GDPR提出了数据可移植权,这意味着用户必须可以能将他们的数据从一个服务转移到其它的服务中。现有的可移植性的实现,例如谷歌Takeout,是相当初级的(你真的能用一堆JSON压缩档案做点什么吗?),但我们可以游说监管机构推动更好的可移植性/互操作性,例如,要求相互竞争的两个供应商在它们的两个应用程序之间,实时双向同步你的数据。

另一条有希望的途径是,推动[公共部门的采购倾向于开源、本地优先的软件](https://joinup.ec.europa.eu/sites/default/files/document/2011-12/OSS-procurement-guideline -final.pdf),而不是闭源的云软件。这为企业开发和维护高质量的开源软件创造了积极的激励机制,而版权条款却没有这样做。

你可能会争论说,软件许可证是开发者个人可以控制的东西,而政府监管和公共政策是一个更大的问题,不在任何一个个体权力范围之内。是的,但你选择一个软件许可证能产生多大的影响?任何不喜欢你的许可证的人可以简单地选择不使用你的软件,在这种情况下,你的力量是零。有效的改变来自于对大问题的集体行动,而不是来自于一个人的小开源项目选择一种许可证而不是另一种。

GPL-家族许可证的其他问题

你可以强迫一家公司提供他们的GPL衍生软件项目的源码,但你不能强迫他们成为开源社区的好公民(例如,持续维护它们添加的功能特性、修复错误、帮助其他贡献者、提供良好的文档、参与项目管理)。如果它们没有真正参与开源项目,那么这些 “扔到你面前 “的源代码又有什么用?最好情况下,它没有价值;最坏的情况下,它还是有害的,因为它把维护的负担转嫁给了项目的其他贡献者。

我们需要人们成为优秀的开源社区贡献者,而这是通过保持开放欢迎的态度,建立正确的激励机制来实现的,而不是通过软件许可证。

最后,GPL许可证家族在实际使用中的一个问题是,它们与其他广泛使用的许可证不兼容,这使得在同一个项目中使用某些库的组合变得更为困难,且不必要地分裂了开源生态。如果GPL许可证有其他强大的优势,也许这个问题还值得忍受。但正如上面所述,我不认为这些优势存在。

结论

GPL和其他 copyleft 许可证并不坏,我只是认为它们毫无意义。它们有实际问题,而且被FSF的行为所玷污;但最重要的是,我不认为它们对软件自由做出了有效贡献。现在唯一真正在用 copyleft 的商业软件厂商(MongoDB, Elastic) —— 它们想阻止亚马逊将其软件作为服务提供,这当然很好,但这纯粹是出于商业上的考虑,而不是软件自由。

开源软件已经取得了巨大的成功,自由软件运动源于1990年代的反微软情绪,它已经走过了很长的路。我承认自由软件基金会对这一切的开始起到了重要作用。然而30年过去了,生态已经发生了变化,而自由软件基金会却没有跟上,而且变得越来越不合群。它没能对云软件和其他最近对软件自由的威胁做出清晰的回应,只是继续重复着几十年前的老论调。现在,通过恢复Stallman的地位和驳回对他的关注,FSF正在积极地伤害自由软件的事业。我们必须与FSF和他们的世界观保持距离。

基于所有这些原因,我认为抓着GPL和 copyleft 已经没有意义了,放手吧。相反,我会鼓励你为你的项目采用一种宽容的许可协议(例如MITBSDApache 2.0),然后把你的精力放在真正能对软件自由产生影响的事情上。抵制云软件的垄断效应,发展可持续的商业模式,让开源软件茁壮成长,并推动监管,将软件用户的利益置于供应商的利益之上。

参考文献

  1. RMS官复原职:(https://www.fsf.org/news/statement-of-fsf-board-on-election-of-richard-stallman
  2. 自由软件基金会主页:https://www.fsf.org/
  3. 弹劾RMS的公开信:https://rms-open-letter.github.io/
  4. GNU项目声明:https://www.gnu.org/gnu/incorrect-quotation.en.html
  5. GNU通用公共许可证 https://en.wikipedia.org/wiki/GNU_General_Public_License
  6. copyleft: https://en.wikipedia.org/wiki/Copyleft
  7. 衍生作品的定义:https://en.wikipedia.org/wiki/Derivative_work
  8. x.ai被Bizzabo收购:https://ourincrediblejourney.tumblr.com/
  9. Google Account Suspended No Reason Given:https://www.paullimitless.com/google-account-suspended-no-reason-given/
  10. Google暂停用户账户:https://twitter.com/Demilogic/status/1358661840402845696
  11. 互联网历史软件归档:https://archive.org/details/softwarelibrary
  12. Office Open XML:https://en.wikipedia.org/wiki/Office_Open_XML
  13. Photoshop File Formats Specification:https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
  14. 本地优先软件:https://www.inkandswitch.com/local-first.html
  15. AGPL协议:https://en.wikipedia.org/wiki/Affero_General_Public_License
  16. Elastic商业许可证:https://www.elastic.co/cn/pricing/faq/licensing
  17. 数据可移植权:https://ico.org.uk/for-organisations/guide-to-data-protection/guide-to-the-general-data-protection-regulation-gdpr/individual-rights/right-to-data-portability/
  18. 谷歌Takeout(带走你的数据):https://en.wikipedia.org/wiki/Google_Takeout
  19. 互操作性新闻:https://interoperability.news/
  20. 欧盟开源软件采购指南:https://joinup.ec.europa.eu/sites/default/files/document/2011-12/OSS-procurement-guideline%20-final.pdf
  21. 许可证兼容性:https://gplv3.fsf.org/wiki/index.php/Compatible_licenses
  22. MongoDB SSPL协议FAQ:https://gplv3.fsf.org/wiki/index.php/Compatible_licenses
  23. Elastic许可变更问题汇总:https://gplv3.fsf.org/wiki/index.php/Compatible_licenses
  24. “自由软件”:一个过时的想法:https://r0ml.medium.com/free-software-an-idea-whose-time-has-passed-6570c1d8218a
  25. 一条FSF未曾设想的路:https://lu.is/blog/2021/04/07/values-centered-npos-with-kmaher/

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

前言:这篇文章是19年1月写的,四年过去了,涉及到数据库与容器的利弊权衡依然成立。这里进行细微调整后重新发出。明天我会发布一篇《数据库是否应当放入K8S中?》,那么今天就先用这篇老文来预热一下吧。微信公众号原文

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

站在开发者的角度上,我非常喜欢Docker,并相信容器也许是未来软件开发部署运维的标准方式。但站在DBA的立场上,我认为就目前而言,将生产环境数据库放入Docker / K8S 中仍然是一个馊主意。


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=。

正如《从降本增笑到降本增效》中所说,智力功率很难在空间上累加 —— 团队的智力功率往往取决于最资深几个灵魂人物的水平以及他们的沟通成本。当数据库出现问题时需要数据库专家来解决;当容器出现问题时需要容器专家来看问题;然而当你把数据库放入 Kubernetes 时,单独的数据库专家和 K8S 专家的智力带宽是很难叠加的 —— 你需要一个双料专家才能解决问题。而同时精通这两者的软件肯定要比单独的数据库专家少得多。

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

隔离性

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

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

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

工具

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

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

包管理是操作系统发行版的核心问题。然而 Docker 搅乱了这一切,例如,许多 PostgreSQL 不再以 RPM/DEB 包的形式发布二进制,而是以加装扩展的 Postgres Docker 镜像分发。这就会立即产生一个显著的问题,如果我想同时使用两种,三种,或者PG生态的一百多种扩展,那么应该如何把这些散碎的镜像整合到一起呢?相比可靠的操作系统包管理,构建Docker镜像总是需要耗费更多时间与精力才能正常起效。

再比如说监控,在传统的裸机部署模式下,机器的各项指标是数据库指标的重要组成部分。容器中的监控与裸机上的监控有很多微妙的区别。不注意可能会掉到坑里。例如,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镜像可能还是要比自己瞎折腾要好一些,因为起码镜像中多少沉淀了一些运维经验。

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

Day2 Operation

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

总的来说,Docker 在某些层面上可以提高系统的可维护性,比如简化创建新实例的操作,但它引入的新麻烦让这样的优势显得苍白无力。


性能

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

实事求是地讲,Docker作为一种轻量化的容器,性能上的折损并不大,通常不会超过 10% 。但毫无疑问的是,将数据库放入Docker只会让性能变得更差而不是更好。


总结

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

需要再次强调的是,以上讨论仅限于生产环境数据库。对于开发测试而言,尽管有基于Vagrant的虚拟机沙箱,但我也支持使用Docker —— 毕竟不是所有的开发人员都知道怎么配置本地测试数据库环境,使用Docker交付环境显然要比一堆手册简单明了的多。对于生产环境的无状态应用,甚至一些带有衍生状态的不甚重要衍生数据系统(譬如Redis缓存),Docker也是一个不错的选择。但对于生产环境的核心关系型数据库而言,如果里面的数据真的很重要,使用Docker前还是需要三思:这样做的价值到底在哪里?出了疑难杂症能Hold住吗?搞砸了这锅背的动吗?

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

综上所述,将生产环境的数据库放入容器中恐怕并不是一个明智的选择。

《Docker 的诅咒:曾以为它是终极解法,最后却是“罪大恶极”?》

理解时间:闰年闰秒,时间与时区

微信公众号原文

前几天出现了四年一遇的闰年 2月29号,每到这一天,总会有一些土鳖软件出现大翻车。这种问题如果运气不好,可能要等上四年才会暴露出来。 比如今天新鲜出炉的:禾赛科技激光雷达和新西兰加油站都因为闰年Bug无法使用。

聊一聊闰年,闰秒,时间与时区的原理,以及在数据库与编程语言中的注意事项。


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;以至于这个纪元时间很多非程序员都知道了。

当然,4字节 Unix 时间戳的上限 2038 年离今天 (2024) 年已经不是遥不可及了,还没及时改成 8 字节时间戳的软件到时候就要面临比闰天加油站罢工严峻得多的千年虫问题 —— 直接罢工了,比如直到今天还没有改过来的二傻子 MySQL 。


PostgreSQL中的时间存储

通常情况下,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转换为不带时区的标准版本。

通常最佳实践是,只要应用稍具规模或涉及到任何国际化的功能,要么按照PostgreSQL Wiki中推荐的最佳实践 使用PostgreSQL 自己提供的 TimestampTZ 扩展类型,要么使用 TIMESTAMP 类型并固定存储 GMT / UTC 时间。

PostgreSQL 的时间戳实现用的是 8 字节,表示的时间范围从公元前 4713 年到 29万年后,精度为1微秒,完全不用担心 2038 千年虫问题。

-- 获取本地事务开始时的时间戳
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
 ...

常见困惑:闰天

关于闰天,PostgreSQL处理的很好,但是需要特别注意的是对于闰年加减时间范围的运算规律。 例如,如果你在 ‘2024-02-29’ 号往前减“一年”,结果是 “2023-02-28”,但如果你减去 365 天,则是“2023-03-01”。 反过来,如果你都加一年,12个月或者加365天,结果都是明年的2月28号。 这样处理肯定是比那些直接用年份+1来计算的二傻子软件靠谱多了。

postgres=# SELECT '2023-02-29'::DATE;  --# 2023 年不是闰年
ERROR:  date/time field value out of range: "2023-02-29"
LINE 1: SELECT '2023-02-29'::DATE;
               ^
postgres=# SELECT '2024-02-29'::DATE;
    today
------------
 2024-02-29

postgres=# SELECT '2024-02-29'::DATE + '1year'::INTERVAL;
    next_year
---------------------
 2025-02-28 00:00:00

postgres=# SELECT '2024-02-29'::DATE + '365day'::INTERVAL;
next_365d
---------------------
 2025-02-28 00:00:00
    
postgres=# SELECT '2024-02-29'::DATE - '1year'::INTERVAL;
     prev_year
---------------------
 2023-02-28 00:00:00

     
postgres=# SELECT '2024-02-29'::DATE - '365day'::INTERVAL;
      prev_365d
---------------------
 2023-03-01 00:00:00

常见困惑:时间戳互转

PostgreSQL 中一个经常让人困惑的问题就是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 中的时间戳也有一个与时区相关的设计比较违反直觉,就是在使用 AT TIME ZONE 的时候,应该尽可能避免使用 +8, -6 这样的数字时区,而应该使用时区名。

这是因为当你在时区部分使用数值时,PostgreSQL 会将其视作 Interval 类型进行处理,被解释为 “Fixed” Offsets from UTC,不常用,文档也不推荐使用。

举个例子,今天东八区中午 (SELECT ‘2024-01-15 12:00:00+08’::TIMESTAMPTZ) 转换为 UTC 时间戳,为 ‘2024-01-15 04:00:00’ (东八区的12点 = UTC0时区的4点)这没有问题:

2024-01-15 04:00:00

现在,我们使用 ‘+1’ 作为 zone,直观的想法应该是,+1 代表东一时区当前的时间,应该是 “2024-01-15 05:00:00+1” 但结果让人惊讶:反而是提前了一个小时

> SELECT '2024-01-15 12:00:00+08'::TIMESTAMPTZ AT TIME ZONE '+1';
2024-01-15 03:00:00

而如果我们反过来如果想当然的使用 -1 作为西一区的时区名,结果也是错误的:

> SELECT '2024-01-15 04:00:00+00'::TIMESTAMPTZ AT TIME ZONE '-1';
timezone
---------------------
2024-01-15 05:00:00

这里面的原因是,使用 Interval 而非时区名,处理逻辑是不一样的:

首先东八区 TIMESTAMPTZ '2024-01-15 12:00:00+08' 被转换为 UTC 时间的 TIMESTAMPTZ '2024-01-15 04:00:00+00'。 然后 UTC时间的 TIMESTAMPTZ '2024-01-15 04:00:00+00' 被截断时区部分为 '2024-01-15 04:00:00' 并拼接新时区 +1 成为一个新的 TIMESTAMPTZ '2024-01-15 04:00:00+1',然后这个新的时间戳被重新转换为不带时间戳的 UTC 时间 '2024-01-15 03:00:00'

并发异常那些事

微信公众号原文 | 知乎原文

并发程序很难写对,更难写好。很多程序员也没有真正弄清楚这些问题,不过是一股脑地把这些问题丢给数据库而已。并发异常并不仅仅是一个理论问题:这些异常曾经造成过很多资金损失,耗费过大量财务审计人员的心血。但即使是最流行、最强大的关系型数据库(通常被认为是“ACID”数据库),也会使用弱隔离级别,所以它们也不一定能防止这些并发异常的发生。

比起盲目地依赖工具,我们应该对存在的并发问题的种类,以及如何防止这些问题有深入的理解。 本文将阐述SQL92标准中定义的隔离级别及其缺陷,现代模型中的隔离级别与定义这些级别的异常现象。


0x01 引子

大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到并发异常

下图是一个简单的并发异常案例:两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加1再回写新值。因为有两次增长操作,计数器应该从42增至44;但由于并发异常,实际上只增长至43。

conncurrent-race-condition.png

图 两个客户之间的竞争状态同时递增计数器

事务ACID特性中的I,即隔离性(Isolation)就是为了解决这种问题。隔离性意味着,同时执行的事务是相互隔离的:它们不能相互踩踏。传统的数据库教科书将隔离性形式化为可串行化(Serializability),这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的。

如果两个事务不触及相同的数据,它们可以安全地并行(parallel)运行,因为两者都不依赖于另一个。当一个事务读取由另一个事务同时进行修改的数据时,或者当两个事务试图同时修改相同的数据时,并发问题(竞争条件)才会出现。只读事务之间不会有问题,但只要至少一个事务涉及到写操作,就有可能出现冲突,或曰:并发异常。

并发异常很难通过测试找出来,因为这样的错误只有在特殊时机下才会触发。这样的时机可能很少,通常很难重现。也很难对并发问题进行推理研究,特别是在大型应用中,你不一定知道有没有其他的应用代码正在访问数据库。在一次只有一个用户时,应用开发已经很麻烦了,有许多并发用户使其更加困难,因为任何数据都可能随时改变。

出于这个原因,数据库一直尝试通过提供**事务隔离(transaction isolation)**来隐藏应用开发中的并发问题。从理论上讲,隔离可以通过假装没有并发发生,让程序员的生活更加轻松:可串行化的隔离等级意味着数据库保证事务的效果与真的串行执行(即一次一个事务,没有任何并发)是等价的。

实际上不幸的是:隔离并没有那么简单。可串行化会有性能损失,许多数据库与应用不愿意支付这个代价。因此,系统通常使用较弱的隔离级别来防止一部分,而不是全部的并发问题。这些弱隔离等级难以理解,并且会导致微妙的错误,但是它们仍然在实践中被使用。一些流行的数据库如Oracle 11g,甚至没有实现可串行化。在Oracle中有一个名为“可串行化”的隔离级别,但实际上它实现了一种叫做**快照隔离(snapshot isolation)**的功能,这是一种比可串行化更弱的保证

在研究现实世界中的并发异常前,让我们先来复习一下SQL92标准定义的事务隔离等级。


0x02 SQL92标准

按照ANSI SQL92的标准,三种**现象(phenomena)**区分出了四种隔离等级,如下表所示:

隔离等级 脏写P0 脏读 P1 不可重复读 P2 幻读 P3
读未提交RU ⚠️ ⚠️ ⚠️
读已提交RC ⚠️ ⚠️
可重复读RR ⚠️
可串行化SR
  • 四种现象分别缩写为P0,P1,P2,P3,P是**现象(Phenonmena)**的首字母。
  • 脏写没有在标准中指明,但却是任何隔离等级都需要必须避免的异常

这四种异常可以概述如下:

P0 脏写(Dirty Write)

事务T1修改了数据项,而另一个事务T2在T1提交或回滚之前就修改了T1修改的数据项。

无论如何,事务必须避免这种情况。

P1 脏读(Dirty Read)

事务T1修改了数据项,另一个事务T2在T1提交或回滚前就读到了这个数据项。

如果T1选择了回滚,那么T2实际上读到了一个事实上不存在(未提交)的数据项。

P2 不可重复读( Non-repeatable or Fuzzy Read)

事务T1读取了一个数据项,然后另一个事务T2修改或删除了该数据项并提交。

如果T1尝试重新读取该数据项,它就会看到修改过后的值,或发现值已经被删除。

P3 幻读(Phantom)

事务T1读取了满足某一搜索条件的数据项集合,事务T2创建了新的满足该搜索条件的数据项并提交。

如果T1再次使用同样的搜索条件查询,它会获得与第一次查询不同的结果。

标准的问题

SQL92标准对于隔离级别的定义是有缺陷的 —— 模糊,不精确,并不像标准应有的样子独立于实现。标准其实针对的是基于锁调度的实现来讲的,而基于多版本的实现就很难对号入座。有几个数据库实现了“可重复读”,但它们实际提供的保证存在很大的差异,尽管表面上是标准化的,但没有人真正知道可重复读的意思。

标准还有其他的问题,例如在P3中只提到了创建/插入的情况,但实际上任何写入都可能导致异常现象。 此外,标准对于可串行化也语焉不详,只是说“SERIALIZABLE隔离级别必须保证通常所知的完全序列化执行”。

现象与异常

现象(phenomena)异常(anomalies)并不相同。现象不一定是异常,但异常肯定是现象。例如在脏读的例子中,如果T1回滚而T2提交,那么这肯定算一种异常:看到了不存在的东西。但无论T1和T2各自选择回滚还是提交,这都是一种可能导致脏读的现象。通常而言,异常是一种严格解释,而现象是一种宽泛解释。


0x03 现代模型

相比之下,现代的隔离等级与一致性等级对于这个问题有更清晰的阐述,如图所示:

conncurrent-isolation-level.png

图:隔离等级偏序关系图

conncurrent-isolation-levels.png

图:一致性与隔离等级偏序关系

右子树主要讨论的是多副本情况下的一致性等级,略过不提。为了讨论便利起见,本图中刨除了MAV、CS、I-CI、P-CI等隔离等级,主要需要关注的是快照隔离SI

表:各个隔离等级及其可能出现的异常现象

等级\现象 P0 P1 P4C P4 P2 P3 A5A A5B
读未提交 RU ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
读已提交 RC ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
游标稳定性 CS ⚠️? ⚠️? ⚠️ ⚠️ ⚠️?
可重复读 RR ⚠️
快照隔离 SI ✅? ⚠️
可序列化 SR

带有?标记的表示可能出现异常,依具体实现而异。

主流关系型数据库的实际隔离等级

相应地,将主流关系型数据库为了“兼容标准”而标称的隔离等级映射到现代隔离等级模型中,如下表所示:

表:主流关系型数据库标称隔离等级与实际隔离之间的对照关系

实际\标称 PostgreSQL/9.2+ MySQL/InnoDB Oracle(11g) SQL Server
读未提交 RU RU RU
读已提交 RC RC RC, RR RC RC
可重复读 RR RR
快照隔离 SI RR SR SI
可序列化 SR SR SR SR

以PostgreSQL为例

如果按照ANSI SQL92标准来看,PostgreSQL实际上只有两个隔离等级:RC与SR。

隔离等级 脏读 P1 不可重复读 P2 幻读 P3
RU,RC ⚠️ ⚠️
RR,SR

其中,RU和RC隔离等级中可能出现P2与P3两种异常情况。而RR与SR则能避免P1,P2,P3所有的异常。

当然实际上如果按照现代隔离等级模型,PostgreSQL的RR隔离等级实际上是快照隔离SI,无法解决A5B写偏差的问题。直到9.2引入可串行化快照隔离SSI之后才有真正意义上的SR,如下表所示:

标称 实际 P2 P3 A5A P4 A5B
RC RC ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
RR SI ⚠️
SR SR

作为一种粗略的理解,可以将RC等级视作语句级快照,而将RR等级视作事务级快照。

以MySQL为例

MySQL的RR隔离等级因为无法阻止丢失更新问题,被认为没有提供真正意义上的快照隔离/可重复读。

标称 实际 P2 P3 A5A P4 A5B
RC RC ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
RR RC ✅? ⚠️ ⚠️
SR SR

参考测试用例:ept/hermitage/mysql


0x04 并发异常

conncurrent-isolation-level.png

回到这张图来,各个异常等级恰好就是通过可能出现的异常来定义的。如果在某个隔离等级A中会出现的所有异常都不会在隔离等级B中出现,我们就认为隔离等级A弱于隔离等级B。但如果某些异常在等级A中出现,在等级B中避免,同时另一些异常在等级B中出现,却在A中避免,这两个隔离等级就无法比较强弱了。

例如在这幅图中:RR与SI是明显强于RC的。但RR与SI之间的相对强弱却难以比较。SI能够避免RR中可能出现的幻读P3,但会出现写偏差A5B的问题;RR不会出现写偏差A5B,但有可能出现P3幻读。

防止脏写与脏读可以简单地通过数据项上的读锁与写锁来阻止,其形式化表示为:

P0: w1[x]...w2[x]...((c1 or a1) and (c2 or a2)) in any order)

P1: w1[x]...r2[x]...((c1 or a1) and (c2 or a2)) in any order)
A1: w1[x]...r2[x]...(a1 and c2 in any order)

因为大多数数据库使用RC作为默认隔离等级,因此脏写P0,脏读P1等异常通常很难遇到,就不再细说了。

下面以PostgreSQL为例,介绍这几种通常情况下可能出现的并发异常现象:

  • P2:不可重复读
  • P3:幻读
  • A5A:读偏差
  • P4:丢失跟新
  • A5B:写偏差

这五种异常有两种分类方式,第一可以按照隔离等级来区分。

  • P2,P3,A5A,P4是RC中会出现,RR不会出现的异常;A5B是RR中会出现,SR中不会出现的异常。

第二种分类方式是按照冲突类型来分类:只读事务与读写事务之间的冲突,以及读写事务之间的冲突。

  • P2,P3,A5A是读事务与写事务之间的并发异常,而P4与A5B则是读写事务之间的并发异常。

读-写异常

让我们先来考虑一种比较简单的情况:一个只读事务与一个读写事务之间的冲突。例如:

  • P2:不可重复读。
  • A5A:读偏差(一种常见的不可重复读问题)
  • P3:幻读

在PostgreSQL中,这三种异常都会在RC隔离等级中出现,但使用RR(实际为SI)隔离等级就不会有这些问题。

不可重复读 P2

假设我们有一张账户表,存储了用户的银行账户余额,id是用户标识,balance是账户余额,其定义如下

CREATE TABLE account(
    id 		INTEGER PRIMARY KEY,
    balance	INTEGER
);

譬如,在事务1中前后进行两次相同的查询,但两次查询间,事务2写入并提交,结果查询得到的结果不同。

START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T1, RC, 只读
START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T2, RC, 读写

SELECT * FROM account WHERE k = 'a';  -- T1, 查询账户a,看不到任何结果
INSERT INTO account VALUES('a', 500); -- T2, 插入记录(a,500)
COMMIT; -- T2, 提交

SELECT * FROM account WHERE id = 'a'; -- T1, 重复查询,得到结果(a,500)
COMMIT; -- T1陷入迷惑,为什么同样的查询结果不同?

对于事务1而言,在同一个事务中执行相同的查询,竟然会出现不一样的结果,也就是说读取的结果不可重复。这就是不可重复读的一个例子,即现象P2。在PostgreSQL的RC级别中是会出现的,但如果将事务T1的隔离等级设置为RR,就不会出现这种问题了:

START TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- T1, RR, 只读
START TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- T2, RC, 读写

SELECT * FROM counter WHERE k = 'x'; -- T1, 查询不到任何结果
INSERT INTO counter VALUES('x', 10); -- T2, 插入记录(x,10) @ RR
COMMIT; -- T2, 提交

SELECT * FROM counter WHERE k = 'x'; -- T1, 还是查询不到任何结果
COMMIT; -- T1, 在RR下,两次查询的结果保持一致。

不可重复读的形式化表示:

P2: r1[x]...w2[x]...((c1 or a1) and (c2 or a2) in any order)
A2: r1[x]...w2[x]...c2...r1[x]...c1

读偏差 A5A

另一类读-写异常是读偏差(A5A):考虑一个直观的例子,假设用户有两个账户:a和b,各有500元。

-- 假设有一张账户表,用户有两个账户a,b,各有500元。
CREATE TABLE account(
    id 		INTEGER PRIMARY KEY,
    balance	INTEGER
);
INSERT INTO account VALUES('a', 500), ('b', 500);

现在用户向系统提交从账户b向账户a转账100元的请求,并从网页上并查看自己的账户余额。在RC隔离级别下,下列操作历史的结果可能会让用户感到困惑:

START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T1, RC, 只读,用户观察
START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T2, RC, 读写,系统转账

SELECT * FROM account WHERE id = 'a'; -- T1, 用户查询账户a, 500元

UPDATE account SET balance -= 100 WHERE id = 'b'; -- T2, 系统扣减账户b 100元
UPDATE account SET balance += 100 WHERE id = 'a'; -- T2, 系统添加账户a 100元
COMMIT; -- T2, 系统转账事务提交提交

SELECT * FROM account WHERE id = 'a'; -- T1, 用户查询账户b, 400元
COMMIT; -- T1, 用户陷入迷惑,为什么我总余额(400+500)少了100元?

这个例子中,只读事务读取到了系统的一个不一致的快照。这种现象称为读偏差(read skew),记作A5A。但其实说到底,读偏差的根本原因是不可重复读。只要避免了P2,自然能避免A5A。

但读偏差是很常见的一类问题,在一些场景中,我们希望获取一致的状态快照,读偏差是不能接受的。一个典型的场景就是备份。通常对于大型数据库,备份需要花费若干个小时。备份进程运行时,数据库仍然会接受写入操作。因此如果存在读偏差,备份可能会包含一些旧的部分和一些新的部分。如果从这样的备份中恢复,那么不一致(比如消失的钱)就会变成永久的。此外,一些长时间运行的分析查询通常也希望能在一个一致的快照上进行。如果一个查询在不同时间看见不同的东西,那么返回的结果可能毫无意义。

快照隔离是这个问题最常见的解决方案。PostgreSQL的RR隔离等级实际上就是快照隔离,提供了事务级一致性快照的功能。例如,如果我们将T1的隔离等级设置为可重复读,就不会有这个问题了。

START TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- T1, RR, 只读,用户观察
START TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- T2, RC, 读写,系统转账

SELECT * FROM account WHERE id = 'a'; -- T1 用户查询账户a, 500元

UPDATE account SET balance -= 100 WHERE id = 'b'; -- T2 系统扣减账户b 100元
UPDATE account SET balance += 100 WHERE id = 'a'; -- T2 系统添加账户a 100元
COMMIT; -- T2, 系统转账事务提交提交

SELECT * FROM account WHERE id = 'a'; -- T1 用户查询账户b, 500元
COMMIT; -- T1没有观察到T2的写入结果{a:600,b:400},但它观察到的是一致性的快照。

读偏差的形式化表示:

A5A: r1[x]...w2[x]...w2[y]...c2...r1[y]...(c1 or a1)

幻读 P3

在ANSI SQL92中,幻读是用于区分RR和SR的现象,实际上它经常与不可重复读P2混为一谈。唯一的区别在于读取列时是否使用了谓词(predicate),也就是Where条件。 将上一个例子中查询是否存在账户,变为满足特定条件账户的数目,就成了一个所谓的“幻读”问题。

START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T1, RC, 只读
START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T2, RC, 读写

SELECT count(*) FROM account WHERE balance > 0;  -- T1, 查询有存款的账户数目。0
INSERT INTO account VALUES('a', 500); -- T2, 插入记录(a,500)
COMMIT; -- T2, 提交

SELECT count(*) FROM account WHERE balance > 0;  -- T1, 查询有存款的账户数目。1
COMMIT; -- T1陷入迷惑,为什么冒出来一个人?

同理,事务1在使用PostgreSQL的RR隔离级别之后,事务1就不会看到满足谓词P的结果发生变化了。

START TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- T1, RR, 只读
START TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- T2, RC, 读写

SELECT count(*) FROM account WHERE balance > 0;  -- T1, 查询有存款的账户数目。0
INSERT INTO account VALUES('a', 500); -- T2, 插入记录(a,500)
COMMIT; -- T2, 提交

SELECT count(*) FROM account WHERE balance > 0;  -- T1, 查询有存款的账户数目。0
COMMIT; -- T1, 读取到了一致的快照(虽然不是最新鲜的)

之所以有这种相当Trivial的区分,因为基于锁的隔离等级实现往往需要额外的谓词锁机制来解决这类特殊的读-写冲突问题。但是基于MVCC的实现,以PostgreSQL的SI为例,就天然地一步到位解决了所有这些问题。

幻读的形式化表示:

P3: r1[P]...w2[y in P]...((c1 or a1) and (c2 or a2) any order)
A3: r1[P]...w2[y in P]...c2...r1[P]...c1

幻读会出现在MySQL的RC,RR隔离等级中,但不会出现在PostgreSQL的RR隔离等级(实际为SI)中。

写-写异常

上面几节讨论了只读事务在并发写入时可能发生的异常。通常这种读取异常可能只要稍后重试就会消失,但如果涉及到写入,问题就比较严重了,因为这种读取到的暂时不一致状态很可能经由写入变成永久性的不一致…。到目前为止我们只讨论了在并发写入发生时,只读事务可以看见什么。如果两个事务并发执行写入,还可能会有一种更有趣的写-写异常:

  • P4: 丢失更新:PostgreSQL的RC级别存在,RR级别不存在(MySQL的RR会存在)。
  • A5B:写入偏差:PostgreSQL的RR隔离级别会存在。

其中,写偏差(A5B)可以视作丢失更新(P4)的泛化情况。快照隔离能够解决丢失更新的问题,却无法解决写入偏差的问题。解决写入偏差需要真正的可串行化隔离等级。

丢失更新-P4-例1

仍然以上文中的账户表为例,假设有一个账户x,余额500元。

CREATE TABLE account(
    id 		TEXT PRIMARY KEY,
    balance	INTEGER
);
INSERT INTO account VALUES('x', 500);

有两个事务T1,T2,分别希望向该账户打入两笔钱,比如一笔100,一笔200。从顺序执行的角度来看,无论两个事务谁先执行,最后的结果都应当是余额 = 500 + 200 + 100 = 800。

START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T1
START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T2

SELECT balance FROM account WHERE id = 'x'; -- T1, 查询当前余额=500
SELECT balance FROM account WHERE id = 'x'; -- T2, 查询当前余额也=500

UPDATE account SET balance = 500 + 100; -- T1, 在原余额基础上增加100元
UPDATE account SET balance = 500 + 200; -- T2, 在原余额基础上增加200元,被T1阻塞。

COMMIT; -- T1, 提交前可以看到余额为600。T1提交后解除对T2的阻塞,T2进行了更新。
COMMIT; -- T2, T2提交,提交前可以看到余额为700
-- 最终结果为700

但奇妙的时机导致了意想不到的结果,最后账户的余额为700元,事务1的转账更新丢失了!

但令人意外的是,两个事务都看到了UPDATE 1的更新结果,都检查了自己更新的结果无误,都收到了事务成功提交的确认。结果事务1的更新丢失了,这就很尴尬了。最起码事务应当知道这里可能出现问题,而不是当成什么事都没有就混过去了。

如果使用RR隔离等级(主要是T2,T1可以是RC,但出于对称性尽量都用RR),后执行更新的语句就会报错中止事务。这就允许应用知耻而后勇,进行重试。

START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T1, 这里RC也可以
START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T2, 关键是T2必须为RR

SELECT balance FROM account WHERE id = 'x'; -- T1, 查询当前余额=500
SELECT balance FROM account WHERE id = 'x'; -- T2, 查询当前余额也=500

UPDATE account SET balance = 500 + 100; -- T1, 在原余额基础上增加100元
UPDATE account SET balance = 500 + 200; -- T2, 在原余额基础上增加200元,被T1阻塞。

COMMIT; -- T1, 提交前可以看到余额为600。T1提交后解除对T2的阻塞
-- T2 Update报错:ERROR:  could not serialize access due to concurrent update

ROLLBACK; -- T2, T2只能回滚
-- 最终结果为600,但T2知道了错误可以重试,并在无竞争的环境中最终达成正确的结果800。

conncurrent-p4-1.png

当然我们可以看到,在RC隔离等级的情况中,T1提交,解除对T2的阻塞时,Update操作已经能够看到T1的变更了(balance=600)。但事务2还是用自己先前计算出的增量值覆盖了T1的写入。对于这种特殊情况,可以使用原子操作解决,例如:UPDATE account SET balance = balance + 100;。这样的语句在RC隔离级别中也能正确地并发更新账户。但并不是所有的问题都能简单到能用原子操作来解决的,让我们来看另一个例子。

丢失更新-P4-例2

让我们来看一个更微妙的例子:UPDATEDELETE之间的冲突。

假设业务上每人最多有两个账户,用户最多能选择一个账号作为有效账号,管理员会定期删除无效账号。

账户表有一个字段valid表示该账户是否有效,定义如下所示:

CREATE TABLE account(
    id 		TEXT PRIMARY KEY,
    valid   BOOLEAN
);
INSERT INTO account VALUES('a', TRUE), ('b', FALSE);

现在考虑这样一种情况,用户要切换自己的有效账号,与此同时管理员要清理无效账号。

从顺序执行的角度来看,无论是用户先切换还是管理员先清理,最后结果的共同点是:总会有一个账号被删掉。

START TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- T1, 用户更换有效账户
START TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- T2, 管理员删除账户

UPDATE account SET valid = NOT valid; -- T1, 原子操作,将有效账户无效账户状态反转
DELETE FROM account WHERE NOT valid;  -- T2, 管理员删除无效账户。

COMMIT; -- T1, 提交,T1提交后解除对T2的阻塞
-- T2 DELETE执行完毕,返回DELETE 0

COMMIT; -- T2, T2能正常提交,但检查的话会发现自己没有删掉任何记录。
-- 无论T2选择提交还是回滚,最后的结果都是(a,f),(b,t)

从下图中可以看到,事务2的DELETE原本锁定了行(b,f)准备删除,但因为事务1的并发更新而阻塞。当T1提交解除T2的阻塞时,事务2看见了事务1的提交结果:自己锁定的那一行已经不满足删除条件了,因此只好放弃删除。

conncurrent-p4-2.png

相应的,改用RR隔离等级,至少给了T2知道错误的机会,在合适的时机重试就可以达到序列执行的效果。

START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T1, 用户更换有效账户
START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T2, 管理员删除账户

UPDATE account SET valid = NOT valid; -- T1, 原子操作,将有效账户无效账户状态反转
DELETE FROM account WHERE NOT valid;  -- T2, 管理员删除无效账户。

COMMIT; -- T1, 提交,T1提交后解除对T2的阻塞
-- T2 DELETE报错:ERROR:  could not serialize access due to concurrent update
ROLLBACK; -- T2, T2只能回滚

SI隔离级别小结

上面提到的异常,包括P2,P3,A5A,P4,都会在RC中出现,但却不会在SI中出现。特别需要注意的是,P3幻读问题会在RR中出现,却不会在SI中出现。从ANSI标准的意义上,SI可以算是可串行化了。SI解决的问题一言以蔽之,就是提供了真正的事务级别的快照。因而各种读-写异常(P2,P3,A5A)都不会再出现了。而且,SI还可以解决**丢失更新(P4)**的问题(MySQL的RR解决不了)。

丢失更新是一种非常常见的问题,因此也有不少应对的方法。典型的方式有三种:原子操作,显式锁定,冲突检测。原子操作通常是最好的解决方案,前提是你的逻辑可以用原子操作来表达。如果数据库的内置原子操作没有提供必要的功能,防止丢失更新的另一个选择是让应用显式地锁定将要更新的对象。然后应用程序可以执行读取-修改-写入序列,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个读取-修改-写入序列完成。(例如MySQL和PostgreSQL的SELECT FOR UPDATE子句)

另一种应对丢失更新的方法是自动冲突检测。如果事务管理器检测到丢失更新,则中止事务并强制它们重试其读取-修改-写入序列。这种方法的一个优点是,数据库可以结合快照隔离高效地执行此检查。事实上,PostgreSQL的可重复读,Oracle的可串行化和SQL Server的快照隔离级别,都会自动检测到丢失更新,并中止惹麻烦的事务。但是,MySQL/InnoDB的可重复读并不会检测丢失更新。一些专家认为,数据库必须能防止丢失更新才称得上是提供了快照隔离,所以在这个定义下,MySQL下没有提供快照隔离。

但正所谓成也快照败也快照,每个事务都能看到一致的快照,却带来了一些额外的问题。在SI等级中,一种称为写偏差(A5B)的问题仍然可能会发生:例如两个事务基于过时的快照更新了对方读取的数据,提交后才发现违反了约束。丢失更新其实是写偏差的一种特例:两个写入事务竞争写入同一条记录。竞争写入同一条数据能够被数据库的丢失更新检测机制发现,但如果两个事务基于各自的写入了不同的数据项,又怎么办呢?

写偏差 A5B

考虑一个运维值班的例子:互联网公司通常会要求几位运维同时值班,但底线是至少有一位运维在值班。运维可以翘班,只要至少有一个同事在值班就行:

CREATE TABLE duty (
  name   TEXT PRIMARY KEY,
  oncall BOOLEAN
);

-- Alice和Bob都在值班
INSERT INTO duty VALUES ('Alice', TRUE), ('Bob', True);

假如应用逻辑约束是:不允许无人值班。即:SELECT count(*) FROM duty WHERE oncall值必须大于0。现在假设A和B两位运维正在值班,两人都感觉不舒服决定请假。不幸的是两人同时按下了翘班按钮。则下列执行时序会导致异常的结果:

START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T1, Alice
START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T2, Bob

SELECT count(*) FROM duty WHERE oncall; -- T1, 查询当前值班人数, 2
SELECT count(*) FROM duty WHERE oncall; -- T2, 查询当前值班人数, 2

UPDATE duty SET oncall = FALSE WHERE name = 'Alice'; -- T1, 认为其他人在值班,Alice翘班
UPDATE duty SET oncall = FALSE WHERE name = 'Bob';   -- T2, 也认为其他人在值班,Bob翘班

COMMIT; -- T1
COMMIT; -- T2

SELECT count(*) FROM duty; -- 观察者, 结果为0, 没有人在值班了! 

两个事务看到了同一个一致性快照,首先检查翘班条件,发现有两名运维在值班,那么自己翘班是ok的,于是更新自己的值班状态并提交。两个事务都提交之后,没有运维在值班了,违背了应用定义的一致性。

但如果两个事务并不是**同时(Concurrently)**执行的,而是分了先后次序,那么后一个事务在执行检查时就会发现不满足翘班条件而终止。因此,事务之间的并发导致了异常现象。

对事务而言,明明在执行翘班操作之前看到值班人数为2,执行翘班操作之后看到值班人数为1,但为啥提交之后看到的就是0了呢?这就好像看见幻象一样,但这与SQL92标准定义的幻读并不一样,标准定义的幻读是因为不可重复读的屁股没擦干净,读到了不该读的东西(对于谓词查询不可重复读取),而这里则是因为快照的存在,事务无法意识到自己读取的记录已经被改变。

问题的关键在于不同读写事务之间的读写依赖。如果某个事务读取了一些数据作为行动的前提,那么如果当该事务执行后续写入操作时,这些被读取的行已经被其他事务修改,这就意味着事务依赖的前提可能已经改变

写偏差的形式化表示:

A5B: r1[x]...r2[y]...w1[y]...w2[x]...(c1 and c2 occur)

此类问题的共性

事务基于一个前提采取行动(事务开始时候的事实,例如:“目前有两名运维正在值班”)。之后当事务要提交时,原始数据可能已经改变——前提可能不再成立。

  1. 一个SELECT查询找出符合条件的行,并检查是否满足一些约束(至少有两个运维在值班)。

  2. 根据第一个查询的结果,应用代码决定是否继续。(可能会继续操作,也可能中止并报错)

  3. 如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。

    **这个写入的效果改变了步骤2 中的先决条件。**换句话说,如果在提交写入后,重复执行一次步骤1 的SELECT查询,将会得到不同的结果。因为写入改变符合搜索条件的行集(只有一个运维在值班)。

在SI中,每个事务都拥有自己的一致性快照。但SI是不提供**线性一致性(强一致性)**保证的。事务看到的快照副本可能因为其他事务的写入而变得陈旧,但事务中的写入无法意识到这一点。

与丢失更新的联系

作为一个特例,如果不同读写事务是对同一数据对象进行写入,这就成了丢失更新问题。通常会在RC中出现,在RR/SI隔离级别中避免。对于相同对象的并发写入可以被数据库检测出来,但如果是向不同数据对象写入,违背应用逻辑定义的约束,那RR/SI隔离等级下的数据库就无能为力了。

解决方案

有很多种方案能应对这些问题,可串行化当然是ok的,但也存在一些其他手段,例如

显式锁定
START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T1, 用户更换有效账户
START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T2, 管理员删除账户

SELECT count(*) FROM duty WHERE oncall FOR UPDATE; -- T1, 查询当前值班人数, 2
SELECT count(*) FROM duty WHERE oncall FOR UPDATE; -- T2, 查询当前值班人数, 2

WITH candidate AS (SELECT name FROM duty WHERE oncall FOR UPDATE)
SELECT count(*) FROM candidate; -- T1

WITH candidate AS (SELECT name FROM duty WHERE oncall FOR UPDATE)
SELECT count(*) FROM candidate; -- T2, 被T1阻塞

UPDATE duty SET oncall = FALSE WHERE name = 'Alice'; -- T1, 执行更新
COMMIT; -- T1, 解除T2的阻塞

-- T2报错:ERROR:  could not serialize access due to concurrent update
ROLLBACK; -- T2 只能回滚

使用SELECT FOR UPDATE语句,可以显式锁定待更新的行,这样,当后续事务想要获取相同的锁时就会被阻塞。这种方法在MySQL中称为悲观锁。这种方法本质上属于一种物化冲突,将写偏差的问题转换成了丢失更新的问题,因此允许在RR级别解决原本SR级别才能解决的问题。

在最极端的情况下(比如表没有唯一索引),显式锁定可能蜕化为表锁。无论如何,这种方式都有相对严重的性能问题,而且可能更频繁地导致死锁。因此也存一些基于谓词锁和索引范围锁的优化。

显式约束

如果应用逻辑定义的约束可以使用数据库约束表达,那是最方便的。因为事务会在提交时(或语句执行时)检查约束,违背了约束的事务会被中止。不幸的是,很多应用约束都难以表述为数据库约束,或者难以承受这种数据库约束表示的性能负担。

可串行化

使用可串行化隔离等级可以避免这一问题,这也是可串行化的定义:避免一切序列化异常。这可能是最简单的方法了,只需要使用SERIALIZABLE的事务隔离等级就可以了。

START TRANSACTION ISOLATION LEVEL SERIALIZABLE;  -- T1, Alice
START TRANSACTION ISOLATION LEVEL SERIALIZABLE;  -- T2, Bob

SELECT count(*) FROM duty WHERE oncall; -- T1, 查询当前值班人数, 2
SELECT count(*) FROM duty WHERE oncall; -- T2, 查询当前值班人数, 2

UPDATE duty SET oncall = FALSE WHERE name = 'Alice'; -- T1, 认为其他人在值班,Alice翘班
UPDATE duty SET oncall = FALSE WHERE name = 'Bob';   -- T2, 也认为其他人在值班,Bob翘班

COMMIT; -- T1
COMMIT; -- T2, 报错中止
-- ERROR:  could not serialize access due to read/write dependencies among transactions
-- DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
-- HINT:  The transaction might succeed if retried.

在事务2提交的时候,会发现自己读取的行已经被T1改变了,因此中止了事务。稍后重试很可能就不会有问题了。

PostgreSQL使用SSI实现可串行化隔离等级,这是一种乐观并发控制机制:如果有足够的备用容量,并且事务之间的争用不是太高,乐观并发控制技术往往表现比悲观的要好不少。

数据库约束物化冲突在某些场景下是很方便的。如果应用约束能用数据库约束表示,那么事务在写入或提交时就会意识到冲突并中止冲突事务。但并不是所有的问题都能用这种方式解决的,可串行化的隔离等级是一种更为通用的方案。


0x06 并发控制技术

本文简要介绍了并发异常,这也是事务ACID中的“隔离性”所要解决的问题。本文简单讲述了ANSI SQL92标准定义的隔离等级以及其缺陷,并简单介绍了现代模型中的隔离等级(简化)。最后详细介绍了区分隔离等级的几种异常现象。当然,这篇文章只讲异常问题,不讲解决方案与实现原理,关于这些隔离等级背后的实现原理,将留到下一篇文章来陈述。但这里可以大概提一下:

从宽泛的意义来说,有两大类并发控制技术:多版本并发控制(MVCC)严格两阶段锁定(S2PL),每种技术都有多种变体。

在MVCC中,每个写操作都会创建数据项的一个新版本,同时保留旧版本。当事务读取数据对象时,系统会选择其中的一个版本,来确保各个事务间相互隔离。 MVCC的主要优势在于“读不会阻塞写,而写也不会阻塞读”。相反,基于S2PL的系统在写操作发生时必须阻塞读操作,因为因为写入者获取了对象的排他锁。

PostgreSQL、SQL Server、Oracle使用一种MVCC的变体,称为快照隔离(Snapshot Isolation,SI)。为了实现SI,一些RDBMS(例如Oracle)使用回滚段。当写入新的数据对象时,旧版本的对象先被写入回滚段,随后用新对象覆写至数据区域。 PostgreSQL使用更简单的方法:一个新数据对象被直接插入到相关的表页中。读取对象时,PostgreSQL通过可见性检查规则,为每个事物选择合适的对象版本作为响应。

但数据库技术发展至今,这两种技术已经不是那样泾渭分明,进入了一个你中有我我中有你的状态:例如在PostgreSQL中,DML操作使用SI/SSI,而DDL操作仍然会使用2PL。但具体的细节,就留到下一篇吧。

Reference

【1】Designing Data-Intensive Application,ch7

【2】Highly Available Transactions: Virtues and Limitations

【3】A Critique of ANSI SQL Isolation Levels

【4】Granularity of Locks and Degrees of Consistency in a Shared Data Base

【5】Hermitage: Testing the ‘I’ in ACID

区块链与分布式数据库

知乎原文

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

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

如果这种分布式数据库用来存储各种币的交易记录,这个系统就叫做所谓的“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一脉相承,并不是一个严格的术语。(因为什么叫一致,什么叫不一致其实是应用说了算。在分布式事务的场景下可以认为是:所有节点的事务状态始终保持相同

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

参考阅读

[1] 一致性与共识

为什么要学习数据库原理

微信公众号原文 | 知乎原文

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

我们学校开了数据库系统原理课程。但是我还是很迷茫,这几节课老师一上来就讲一堆令人头大的名词概念,我以为我们知道“如何设计构建表”,“如何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,源码简单易读,实践中也很常用,非关系型数据库也应当多了解一下。

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

云计算

我们能从腾讯云大故障中学到什么?

故障过去八天后,腾讯云发布了 4.8 号大故障的复盘报告。我认为是一件好事,因为阿里云双十一大故障的官方故障复盘至今仍然是拖欠着的。公有云厂商想要真正成为 —— 提供水与电的公共基础设施,那就需要承担起责任,接受公众监督 —— 云厂商有义务披露自己故障原因,并提出切实的可靠性改进方案与措施。

那么我们就来看一看这份复盘报告,看看里面有哪些信息,以及可以从中学到什么教训。


事实是什么?

按照腾讯云官方给出的复盘报告(官方发布的“权威事实”)

  1. 15:23,监测到故障,立即执行服务的恢复,同时进行原因的排查;
  2. 15:47,发现通过回滚版本没能完全恢复服务,进一步定位问题;
  3. 15:57,定位出故障根因是配置数据出现错误,紧急设计数据修复方案;
  4. 16:02,对全地域进行数据修复工作,API服务逐地域恢复中;
  5. 16:05,观测到除上海外的地域API服务均已恢复,进一步定位上海地域的恢复问题;
  6. 16:25,定位到上海的技术组件存在API循环依赖问题,决定通过流量调度至其他地域来恢复;
  7. 16:45,观测到上海地域恢复了,此时API和依赖API的PaaS服务彻底恢复,但控制台流量剧增,按九倍容量进行了扩容;
  8. 16:50,请求量逐渐恢复到正常水平,业务稳定运行,控制台服务全部恢复;
  9. 17:45,持续观察一小时,未发现问题,按预案处理过程完毕。

复盘报告给出的原因是:云API服务新版本向前兼容性考虑不够和配置数据灰度机制不足的问题

本次API升级过程中,由于新版本的接口协议发生了变化,在后台发布新版本之后对于旧版本前端传来的数据处理逻辑异常,导致生成了一条错误的配置数据,由于灰度机制不足导致异常数据快速扩散到了全网地域,造成整体API使用异常。

发生故障后,按照标准回滚方案将服务后台和配置数据同时回滚到旧版本,并重启API后台服务,但此时因为承载API服务的容器平台也依赖API服务才能提供调度能力,即发生了循环依赖,导致服务无法自动拉起。通过运维手工启动方式才使API服务重启,完成整个故障恢复。

这份复盘报告中有一个存疑的点:复盘报告将故障归因为:向前兼容考虑不足。向前兼容性(Forward Compatibility)指的是老的版本的代码可以使用新版本的代码产生的数据。如果管控回滚到旧版本,无法读取由新版本产生的脏数据 —— 那这是确实是一个前向兼容性问题。但在下面的解释中:是新版本代码没有处理好旧版本数据 —— 而这是一个典型的向后兼容性(Backward)问题。对于一个 ToB 服务产品,我认为这样的严谨性是有问题的。

原因是什么?

作为客户,我也在此前获取了私下流传的故障复盘过程,一份具有高置信度的小道消息:

  1. 15:25 平台监控到云API进程故障告警,工程师立即介入分析;
  2. 15:36 排查发现异常集中在云API现网版本,旧版本运行正常,开始进行回滚操作;
  3. 15:47 官网控制台所用集群回滚完成,通过监控确定恢复;
  4. 15:50 开始回滚非控制台集群;
  5. 15:57 定位出故障根因是配置系统中存在错误数据;
  6. 16:02 删除配置数据的错误数据,各地域集群开始自动恢复;
  7. 16:05 由于历史配置不规范,导致上海集群无法通过回滚快速恢复,决策采用流量调度方式恢复上海集群;
  8. 16:40 上海集群流量全量切换到其他地域集群;
  9. 16:45 经过观测和现网监控,确认上海集群已经恢复。

应该说,官方发布的版本在关键点上基本上和几天前私下流出的版本是一致的,只是私下流传的版本更加详细地指出了根因: 相较旧版本,现网版本新引入逻辑存在对空字典配置数据兼容的bug,在读取数据场景下触发bug逻辑,引发云API服务进程异常 Crash

根据这两份故障复盘信息,我们可以确定,这是一次由人为失误导致的故障,而不是因为天灾(硬件故障,机房断电断网)导致的。我们基本上可以推断出故障发生的过程分为两个阶段 —— 两个子问题。

第一个问题是,管控 API 没有保持良好的双向兼容性 —— 新管控 API 因为老配置数据中的空字典崩掉了。这体现出一系列软件工程上的问题 —— 处理空对象的代码基本功,处理异常的逻辑,测试的覆盖率,发布的灰度流程。

第二个问题是,循环依赖(容器平台与管控API)导致系统无法自动拉起,需要运维手工介入进行 Bootstrap。这反映出 —— 架构设计的问题,以及 —— 腾讯云并没有从去年阿里云的大故障中吸取最核心的教训


影响是什么?

在复盘报告中,腾讯云用了大篇幅来描述故障的影响,解释管控面故障与数据面故障的区别。用了一些酒店前台的比喻。其实类似的故障在去年阿里云双十一大故障已经出现过了 —— 管控面挂了,数据面正常,在《我们能从阿里云史诗级故障中学到什么》中,我们也分析过,管控面挂了确实不会影响继续使用现有纯 IaaS 资源。但是会影响云厂商的核心服务 —— 比如,对象存储在腾讯云上叫 COS。

对象存储 COS 实在是太重要了,可以说是云计算的“定义性服务”,也许是唯一能在所有云上基本达成共识标准的服务。云厂商的各种“上层”服务或多或少都直接/间接地依赖 COS,例如 CVM/ RDS 虽然可以运行,但 CVM 快照和 RDS 备份显然是深度依赖 COS 的,CDN 回源是依赖 COS 的,各个服务的日志往往也是写入 COS 的**。所以,任何涉及到基础服务的故障,都不应该糊弄敷衍过去**。

当然最让人生气的其实是腾讯云傲慢的态度 —— 我自己作为腾讯云的用户,提了一个工单,用于测试云上的 SLA 到底好不好使 —— 事实证明:不主张就不赔付,主张了不认账也可以不赔付 —— 这个 SLA 确实跟厕纸一样。云 SLA 是安慰剂还是厕纸合同


评论与观点

马斯克的推特 X 和 DHH 的 37 Signal 通过下云省下了千万美元真金白银,创造了降本增效的“奇迹”,让下云开始成为一种潮流。云上的用户在对着账单犹豫着是否要下云,未上云的用户更是内心纠结。

在这样的背景下,作为本土云领导者的阿里云先出现史诗级大故障,紧接着腾讯云又再次出现了这种全球性管控面故障,对于犹豫观望者的信心无疑是沉重的打击。如果说阿里云大故障是公有云拐点级别的标志性事件,那么腾讯云大故障再次确认了这条投射线的方向。

这次故障再次揭示出关键基础设施的巨大风险 —— 大量依托于公有云的网络服务缺乏最基本的自主可控能力:当故障发生时没有任何自救能力,除了等死做不了别的事情。它也反映出了垄断中心化基础设施的脆弱性:互联网这个去中心化的世界奇观现在主要是在少数几个大公司/云厂商拥有的服务器上运行 —— 某个云厂商本身成为了最大的业务单点,这可不是互联网设计的初衷!

根据海恩法则,一次严重故障的背后有几十次轻微事故,几百起未遂先兆,以及上千条事故隐患。这样的事故对于腾讯云的品牌形象绝对是致命打击,甚至对整个行业的声誉都有严重的损害。Cloudflare 月初的管控面故障后,CEO 立即撰写了详细的事后复盘分析,挽回了一些声誉。腾讯云这次发布的故障复盘报告不能说及时,但起码比起遮遮掩掩的阿里云要好多了。

通过故障复盘,提出改进措施,让用户看到改进的态度,对于用户的信心非常重要。做一个故障复盘,也许会暴露更多草台班子的糗态 —— 我不会收回 “草台班子” 的评价。但重要的是 —— 技术/管理菜是可以想办法改进的,服务态度傲慢则是无药可医的。

公有云厂商想要真正成为 —— 提供水与电的公共基础设施,那就需要承担起责任来,并敢于接受公众与用户的公开监督。我在《腾讯云:颜面尽失的草台班子》与《云 SLA 是安慰剂还是厕纸合同》中指出了腾讯云面对故障时的问题 —— 故障信息发布不及时,不准确,不透明。在这一点上,我欣慰的看到在复盘报告改进措施中,腾讯云能够承认这些问题并承诺进行改进。但我无法原谅的是 —— 腾讯云选择在微信公众号上文章审核封口。


能学到什么?

往者不可留,逝者不可追,比起哀悼无法挽回的损失,更重要的是从损失中吸取教训 —— 要是能从别人的损失中吸取教训那就更好了。所以,我们能从腾讯云这场史诗级故障中学到什么?


不要把鸡蛋放在同一个篮子里,准备好 PlanB,比如,业务域名解析一定要套一层 CNAME,且 CNAME 域名用不同服务商的解析服务。这个中间层对于阿里云、腾讯云这样的全局云厂商故障非常重要,用另外一个 DNS 供应商,至少可以给你一个把流量切到别的地方去的选择,而不是干坐在屏幕前等死,毫无自救能力。


谨慎依赖需要云基础设施

云 API 是云服务的基石,大家都期待它可以始终正常工作 —— 然而越是人们感觉不可能出现故障的东西,真的出现故障时产生的杀伤力就越是毁天灭地。如无必要,勿增实体,更多的依赖意味着更多的失效点,更低的可靠性:正如在这次故障中,使用自身认证机制的 CVM/RDS 本身就没有受到直接冲击。深度使用云厂商提供的 AK/SK/IAM 不仅会让自己陷入供应商锁定,更是将自己暴露在公用基础设施的单点风险里。

我的朋友/对手,公有云的鼓吹者瑞典马工和他的朋友AK王老板,一直主张呼吁用 IAM / RAM 做访问控制,并深度利用云上的基础设施。但是在这两次故障后,马工的原话是:

“我一直鼓吹大家用 IAM 做访问控制,结果两家云都出大故障,纷纷打我的脸。云厂商不管是 PR 还是 SRE,都在用实际行动向客户证明:“别听马工的,你用他那一套,我就让你系统完蛋”。


谨慎使用云服务,优先使用纯资源。在本次故障中,云服务受到影响,但云资源仍然可用。类似 CVM/云盘 这样的纯资源,以及单纯使用这两者的 RDS,可以不受管控面故障影响可以继续运行。基础云资源(CVM/云盘)是所有云厂商的提供服务的最大公约数,只用资源有利于用户在不同公有云、以及本地自建中间择优而选。不过,很难想象在公有云上却不用对象存储 —— 在 CVM 和天价云盘 上用 MinIO 自建对象存储服务并不是真正可行的选项,这涉及到公有云商业模式的核心秘密:廉价S3获客天价EBS杀猪


自建是掌握自身命运的终极道路:如果用户想真正掌握自己的命运,最终恐怕早晚会走上自建这条路。互联网先辈们平地起高楼创建了这些服务,而现在做这件事只会容易得多:IDC 2.0 解决硬件资源问题,开源平替解决软件问题,大裁员释放出的专家解决了人力问题。短路掉公有云这个中间商,直接与 IDC 合作显然是一个更经济实惠的选择。稍微有点规模的用户下云省下的钱,可以换几个从大厂出来的资深SRE 还能盈余不少。更重要的是,自家人出问题你可以进行奖惩激励督促其改进,但是云出问题能赔给你几毛钱的代金券?


明确云厂商的 SLA 是营销工具,而非战绩承诺

在云计算的世界里,服务等级协议(SLA)曾被视为云厂商对其服务质量的承诺。然而,当我们深入研究这些由一堆9组成的协议时,会发现它们并不能像期望的那样“兜底”。与其说是 SLA 是对用户的补偿,不如说 SLA 是对云厂商服务质量没达标时的“惩罚”。比起会因为故障丢掉奖金与工作的专家来说,SLA 的惩罚对于云厂商不痛不痒,更像是自罚三杯。如果惩罚没有意义,云厂商也没有动力去提供更好的服务质量。所以,SLA 对用户来说不是兜底损失的保险单。在最坏的情况下,它是堵死了实质性追索的哑巴亏;在最好的情况下,它才是提供情绪价值的安慰剂。

云计算泥石流:合订本

微信公众号原文

世人常道云上好,托管服务烦恼少。我言云乃杀猪盘,溢价百倍实厚颜。

赛博地主搞垄断,坐地起价剥血汗。运维外包嫖开源,租赁电脑炒概念。

世人皆趋云上游,不觉开销似水流。云租天价难为持,开源自建更稳实。

下云先锋把路趟,引领潮流一肩扛。不畏浮云遮望眼,只缘身在最前线。


曾几何时,“上云“近乎成为技术圈的政治正确,整整一代应用开发者的视野被云遮蔽。就让我们用实打实的数据分析与亲身经历,讲清楚公有云租赁模式的价值与陷阱 —— 在这个降本增效的时代中,供您借鉴与参考。


云基础资源篇


云商业模式篇


下云奥德赛篇


云故障复盘篇


RDS翻车篇


云厂商画像篇


曾几何时,“上云“近乎成为技术圈的政治正确,整整一代应用开发者的视野被云遮蔽。DHH 以及像我这样的人愿意成为这个质疑者,用实打实的数据与亲身经历,讲清楚公有云租赁模式的陷阱。

很多开发者并没有意识到,底层硬件已经出现了翻天覆地的变化,性能与成本以指数方式攀升与坍缩。许多习以为常的工作假设都已经被打破,无数利弊权衡与架构方案值得重新思索与设计。

公有云有其存在意义 —— 对那些非常早期、或两年后不复存在的公司,那些完全不在乎花钱、有着极端大起大落不规则负载的公司,对那些需要出海合规,CDN服务的公司而言,公有云仍然是非常值得考虑的选项。

然而对绝大多数已经发展起来,有一定规模与韧性,能在几年内摊销资产的公司而言,真的应该重新审视一下这股云热潮。好处被大大夸张了 —— 在云上跑东西通常和你自己弄一样复杂,却贵得离谱,我们真诚建议您好好算一下账。

最近十年间,硬件以摩尔定律的速度持续演进,IDC2.0与资源云提供了公有云资源的物美价廉替代,开源软件与开源管控调度软件的出现更是让自建能力唾手可及 —— 云下开源自建,在成本,性能,与安全自主可控上都会有显著回报。

我们提倡下云理念,并提供了实践的路径与切实可用的自建替代品 —— 我们将为认同这一结论的追随者提前铺设好意识形态与技术上的道路。不为别的,只是期望所有用户都能拥有自己的数字家园,而不是从科技巨头云领主那里租用农场。这也是一场对互联网集中化与反击赛博地主垄断收租的运动,让互联网 —— 这个美丽的自由避风港与理想乡可以走得更长。


云基础资源篇

EC2 / S3 / EBS 这样的基础资源价格,是所有云服务的定价之锚。基础资源篇的五篇文章,分别剖析了 EC2 算力,S3 对象存储,EBS 云盘,RDS 云数据库,以及 CDN 网络的方方面面:质量、安全、效率,以及大家最关心的一系列问题。


重新拿回计算机硬件的红利

在当下,硬件重新变得有趣起来,AI 浪潮引发的显卡狂热便是例证。但有趣的硬件不仅仅是 GPU —— CPU 与 SSD 的变化却仍不为大多数开发者所知,有一整代软件开发者被云和炒作营销遮蔽了双眼,难以看清这一点。

许多软件与商业领域的问题的根都在硬件上:硬件的性能以指数模式增长,让分布式数据库几乎成为了伪需求。硬件的价格以指数模式衰减,让公有云从普惠基础设施变成了杀猪盘。让我们用算力与存储的价格变迁来说明这一点。


扒皮云对象存储:从降本到杀猪

对象存储是云计算的定义性服务,曾是云上降本的典范。然而随着硬件的发展,资源云(Cloudflare / IDC 2.0)与开源平替(MinIO)的出现,曾经“物美价廉”的对象存储服务失去了性价比,和EBS一样成为了杀猪盘。

硬件资源的价格随着摩尔定律以指数规律下降,但节省的成本并没有穿透云厂商这个中间层,传导到终端用户的服务价格上。 逆水行舟,不进则退,降价速度跟不上摩尔定律就是其实就是变相涨价。以S3为例,在过去十几年里,云厂商S3虽然名义上降价6倍,但硬件资源却便宜了 26 倍,S3 的价格实际上是涨了 4 倍


云盘是不是杀猪盘?

EC2 / S3 / EBS 是所有云服务的定价之锚。如果说 EC2/S3 定价还勉强能算合理,那么 EBS 的定价乃是故意杀猪。公有云厂商最好的块存储服务与自建可用的 PCI-E NVMe SSD 在性能规格上基本相同。然而相比直接采购硬件,AWS EBS 的成本高达 120 倍,而阿里云的 ESSD 则可高达 200 倍。

公有云的弹性就是针对其商业模式设计的:启动成本极低,维持成本极高。低启动成本吸引用户上云,而且良好的弹性可以随时适配业务增长,可是业务稳定后形成供应商锁定,尾大不掉,极高的维持成本就会让用户痛不欲生了。这种模式有一个俗称 —— 杀猪盘


云数据库是不是智商税

近日,Basecamp & HEY 联合创始人 DHH 的一篇文章引起热议,主要内容可以概括为一句话:“我们每年在云数据库(RDS/ES)上花50万美元,你知道50万美元可以买多少牛逼的服务器吗?我们要下云,拜拜了您呐!

在专业甲方用户眼中,云数据库只能算 60 分及格线上的大锅饭标品,却敢于卖出十几倍的天价资源溢价。如果用云数据库1年的钱,就够买几台甚至十几台性能更好的服务器自建更好的本地 RDS,那么使用云数据库的意义到底在哪里?在有完整开源平替的情况下,这算不算交了智商税?


垃圾腾讯云CDN:从入门到放弃

本来我相信至少在 IaaS 的存储,计算,网络三大件上,公有云厂商还是可以有不小优势的。只不过在腾讯云 CDN 上的亲身体验让我的想法动摇了。


云商业模式篇


FinOps终点是下云

FinOps关注点跑偏,公有云是个杀猪盘。自建能力决定议价权,数据库是自建关键。

搞 FinOps 的关注减少浪费资源的数量,却故意无视了房间里的大象 —— 云资源单价。云算力的成本是自建的五倍,块存储的成本则可达百倍以上,堪称终极成本刺客。对于有一定规模企业来说,IDC自建的总体成本在云服务列表价1折上下。下云是原教旨 FinOps 的终点,也是真正 FinOps 的起点。

拥有自建能力的用户即使不下云也能谈出极低的折扣,没有自建能力的公司只能向公有云厂商缴纳高昂的 “无专家税” 。无状态应用与数仓搬迁相对容易,真正的难点是在不影响质量安全的前提下,完成数据库的自建。


云计算为啥还没挖沙子赚钱?

公有云毛利不如挖沙子,杀猪盘为何成为赔钱货?卖资源模式走向价格战,开源替代打破垄断幻梦。服务竞争力逐渐被抹平,云计算行业将走向何方?

业界标杆的 AWS 与 Azure 毛利就可以轻松到 60% 与 70% 。反观国内云计算行业,毛利普遍在个位数到 15% 徘徊,至于像金山云这样的云厂商,毛利率直接一路干到 2.1%,还不如打工挖沙子的毛利高。这不禁让人好奇,这些本土云厂商是怎么把一门百分之三四十纯利的生意能做到这种地步的?


云SLA是不是安慰剂?

在云计算的世界里,服务等级协议(SLA)曾被视为云厂商对其服务质量的承诺。然而,当我们深入研究这些由一堆9组成的 SLA 时,会发现它们并不能像期望的那样“兜底”:与其说是 SLA 是对用户的补偿,不如说 SLA 是对云厂商服务质量没达标时的“惩罚”。

你以为给自己的服务上了保险可以高枕无忧,但 SLA 对用户来说不是兜底损失的保险单。在最坏的情况下,它是吃不了兜着走的哑巴亏。在最好的情况下,它才是提供情绪价值的安慰剂。


杀猪盘真的降价了吗?

存算资源降价跟不上摩尔定律发展其实就是变相涨价。但能看到有云厂商率先打起了降价第一枪,还是让人欣慰的:云厂商向 PLG 的康庄大道迈出了第一步:用技术优化产品,靠提高效率去降本,而不是靠销售吹逼打折杀猪。


范式转移:从云到本地优先

软件吞噬世界,开源吞噬软件。云吞噬开源,谁来吃云?与云软件相对应的本地优先软件开始如雨后春笋一般出现。而我们,就在亲历见证这次范式转移。

最终会有这么一天,公有云厂商与自由软件世界达成和解,心平气和地接受基础设施供应商的角色定位,为大家提供水与电一般的存算资源。而云软件终将会回归正常毛利,希望那一天人们能记得人们能记得,这不是因为云厂商大发慈悲,而是因为有人带来过开源平替


下云奥德赛篇


下云奥德赛:是时候放弃云计算了吗?

下云先锋:David Heinemeier Hansson,网名 DHH,37 Signal 联创与CTO,Ruby on Rails 作者,下云倡导者、实践者、领跑者。反击科技巨头垄断的先锋。

DHH 下云已省下了近百万美元云支出,未来的五年还能省下上千万美元。我们跟进了下云先锋的完整进展,挑选了十四篇博文,译为中文,合辑为《下云奥德赛》,记录了他们从云上搬下来的波澜壮阔的完整旅程。

  • 10-27 推特下云省掉 60% 云开销
  • 10-06 托管云服务的代价
  • 09-15 下云后已经省了百万美金
  • 06-23 我们已经下云了!
  • 05-03 从云遣返到主权云!
  • 05-02 下云还有性能回报?
  • 04-06 下云所需的硬件已就位!
  • 03-23 裁员前不先考虑下云吗?
  • 03-11 失控的不仅仅是云成本!
  • 02-22 指导下云的五条价值观
  • 02-21 下云将给咱省下五千万!
  • 01-26 折腾硬件的乐趣重现
  • 01-10 “企业级“替代品还要离谱
  • 2022-10-19 我们为什么要下云?

半年下云省千万:DHH下云FAQ答疑

DHH 与 X马斯克的下云举措在全球引发了激烈的讨论,有许多的支持者,也有许多的质疑者。面对众人提出的问题与质疑,DHH 给出了自己充满洞见的回复。

  • 在硬件上省下的成本,会不会被更大规模的团队薪资抵消掉?
  • 为什么你选择下云,而不是优化云账单呢?
  • 那么安全性呢?你不担心被黑客攻击吗?
  • 不需要一支世界级超级工程师团队来干这些吗?
  • 这是否意味着你们在建造自己的数据中心?
  • 谁来做码放服务器、拔插网络电缆这些活呢?
  • 那么可靠性呢?难道云不是为你做到了这些吗?
  • 那国际化业务的性能呢?云不是更快吗?
  • 你有考虑到以后更换服务器的成本吗?
  • 那么隐私法规和GDPR呢?
  • 那么需求激增时怎么办?自动伸缩呢?
  • 你在服务合同和授权许可费上花费了多少?
  • 如果云这么贵,你们为什么会选择它?

下云高可用的秘诀:拒绝智力自慰

下云之后,DHH 的几个业务都保持了 99.99% 以上的可用性。Basecamp 2 更是连续两年实现 100% 可用性的魔法。相当一部分归功于他们选择了简单枯燥、朴实无华的,复杂度很低,成熟稳定的技术栈 —— 他们并不需要 Kubernetes 大师或花哨的数据库与存储。

程序员极易被复杂度所吸引,就像飞蛾扑火一样。系统架构图越复杂,智力自慰的快感就越大。坚决抵制这种行为,是在云下自建保持稳定可靠的关键所在。


云故障复盘篇


【阿里】云计算史诗级大翻车来了

2023年11月12日,也就是双十一后的第一天,阿里云发生了一场史诗级大翻车。根据阿里云官方的服务状态页,全球范围内所有可用区 x 所有服务全部都出现异常,时间从 17:44 到 21: 11,共计3小时27分钟。规模与范围创下全球云计算行业的历史纪录。


阿里云周爆:云数据库管控又挂了

阿里云 11. 12 大故障两周 过去,还没有看到官方的详细复盘报告,结果又来了一场大故障:中美7个区域的数据库管控宕了近两个小时。这一次和 11.12 故障属于让官方发全站公告的显著故障。

11月份还有两次较小规模的局部故障。这种故障频率即使是对于草台班子来说也有些过分了。某种意义上说,阿里云这种周爆可以凭一己之力,毁掉用户对公有云云厂商的托管服务的信心


我们能从阿里云史诗级故障中学到什么

时隔一年阿里云又出大故障,并创造了云计算行业闻所未闻的新记录 —— 全球所有区域/所有服务同时异常。 阿里云不愿意发布故障复盘报告,那我就来替他复盘 —— 我们应当如何看待这一史诗级故障案例,以及,能从中学习到什么经验与教训?


从降本增笑到真的降本增效

双十一刚过,阿里云就出了打破行业纪录的全球史诗级大翻车,然后开始了11月连环炸模式 —— 从月爆到周爆再到日爆。话音未落,滴滴又出现了一场超过12小时,资损几个亿的大故障。

年底正是冲绩效的时间,互联网大厂大事故却是一波接一波。硬生生把降本增效搞成了“降本增笑” —— 这已经不仅仅是梗了,而是来自官方的自嘲。大厂故障频发,处理表现远远有失水准,乱象的背后到底是什么问题?


RDS翻车篇


更好的开源RDS替代:Pigsty

我希望,未来的世界人人都有自由使用优秀服务的事实权利,而不是只能被圈养在几个公有云厂商提供的猪圈(Pigsty)里吃粑粑。这就是我要做 Pigsty 的原因 —— 一个更好的,开源免费的 PostgreSQL RDS替代。让用户能够在任何地方(包括云服务器)上,一键拉起有比云RDS更好的数据库服务。

Pigsty 是对 PostgreSQL 的彻底补完,更是对云数据库的辛辣嘲讽。它本意是“猪圈”,但更是 Postgres In Great STYle 的缩写,即“全盛状态下的 PostgreSQL”。它是一个完全基于开源软件的,可以运行在任何地方的,浓缩了 PostgreSQL 使用管理最佳实践的 Me-Better RDS 开源替代。


驳《再论为什么你不应该招DBA》

云计算鼓吹者马工大放厥词说:云数据库可以取代 DBA。之前在《你怎么还在招聘DBA》,以及回应文《云数据库是不是智商税》中,我们便已交锋过。

当别人把屎盆子扣在这个行业所有人头上时,还是需要人来站出来说几句的。因此撰文以驳斥马工的谬论:《再论为什么你不应该招DBA》。


云RDS:从删库到跑路

用户都已经花钱买了‘开箱即用’的云数据库了,为什么连 PITR 恢复这么基本的兜底都没有呢?因为云厂商心机算计,把WAL日志归档/PITR这些PG的基础功能阉割掉,放进所谓的“高可用”版本高价出售。

云数据库也是数据库,云数据库并不是啥都不用管的运维外包魔法,不当配置使用,一样会有数据丢失的风险。没有开启WAL归档就无法使用PITR,甚至无法登陆服务器获取现存WAL来恢复误删的数据。当故障发生时,用户甚至被断绝了自救的可能性,只能原地等死。


数据库应该放入K8S里吗?

Kubernetes 作为一个先进的容器编排工具,在无状态应用管理上非常趁手;但其在处理有状态服务 —— 特别是 PostgreSQL 和 MySQL 这样的数据库时,有着本质上的局限性。

将数据库放入 K8S 中会导致 “双输” —— K8S 失去了无状态的简单性,不能像纯无状态使用方式那样灵活搬迁调度销毁重建;而数据库也牺牲了一系列重要的属性:可靠性,安全性,性能,以及复杂度成本,却只能换来有限的“弹性”与资源利用率 —— 但虚拟机也可以做到这些!对于公有云厂商之外的用户来说,几乎都是弊远大于利。


把数据库放入Docker是一个好主意吗?

对于无状态的应用服务而言,容器是一个相当完美的开发运维解决方案。然而对于带持久状态的服务 —— 数据库来说,事情就没有那么简单了。我认为就目前而言,将生产环境数据库放入Docker / K8S 中仍然是一个馊主意。

对于云厂商来说,使用容器的利益是自己的,弊端是用户的,他们有动机这样去做,但这样的利弊权衡对普通用户而言并不成立。


云厂商画像篇

这个系列多数来自云计算鼓吹者马工,为本土云厂商与互联网大厂绘制了精准幽默,幽默滑稽的肖像。

卡在政企客户门口的阿里云

互联网故障背后的草台班子们

云厂商眼中的客户:又穷又闲又缺爱

互联网技术大师速成班

门内的国企如何看门外的云厂商

云计算泥石流系列原文链接

吊打公有云的赛博佛祖 Cloudflare

是在今天的 2024 开发者周上,Cloudflare 发布了一系列令人激动的新特性,例如 Python Worker 以及 Workers AI,把应用开发与交付的便利性拔高到了一个全新的程度。与 Cloudflare 的 Serverless 开发体验相比,传统云厂商号称 Serverless 的各种产品都显得滑稽可笑。

Cloudflare 更广为人知的是它的慷慨免费套餐,一些中小型网站几乎能以零成本运行在这里。在 Cloudflare 的鲜明对比之下,天价出租 CPU磁盘带宽 的公有云厂商显得面目可憎。Cloudflare 这样的云带来的开发体验,才真正配得上“云”的称号。在我看来, Cloudflare 应该主动出击,与传统公有云厂商抢夺云计算的定义权。

利益相关:Cloudflare 没给我钱,我倒是给 Cloudflare 付了钱。纯粹是因为 Cloudflare 产品非常出色,极好地解决了我的需求,让我非常乐意付点费支持一下,并告诉更多朋友有这项福利。与之相反的是,我付钱给传统公有云厂商之后的感受是这做的都是什么玩意 —— 必须写文章狠狠地骂他们,才能缓解内心的精神损失。


Cloudflare 是什么

Cloudflare是一家提供内容分发网络(CDN)、互联网安全性、抗DDoS(分布式拒绝服务)和分布式DNS服务的美国公司。全世界互联网流量的 20% 由它服务。如果你挂着 VPN 访问一些网站,经常可以看到 Cloudflare 的抗 DDoS 验证码页面和 Logo。他们提供:

  1. 内容分发网络(CDN):Cloudflare的CDN服务通过全球分布的数据中心缓存客户网站的内容,加快网站加载速度并减少服务器压力。
  2. 网站安全性:提供SSL加密、防止SQL注入和跨站脚本攻击的安全措施,增强网站的安全性。
  3. DDoS防护:具备先进的DDoS防护功能,能够抵御各种规模的攻击,保护网站不受干扰。
  4. 智能路由:使用Anycast网络技术,能够智能识别数据传输的最佳路径,减少延迟。
  5. 自动HTTPS重定向:自动将访问转换为HTTPS,增强通信的安全性。
  6. Workers平台:提供Serverless架构,允许在Cloudflare的全球网络上运行JavaScript或WASM(WebAssembly)代码,无需管理服务器。

当然,Cloudflare 还有一些非常不错的服务,例如托管网站的 Pages,对象存储 R2,分布式数据库D1 等,开发者体验非常不错。

Cloudflare 官网介绍


Pages:简单易用的网站托管

举个例子,如果您要托管一个静态网站。用 Cloudflare 有多简单?首先在 GitHub 创建一个 Repo,把网站内容丢进去,然后在 Cloudflare 链接到你的 Git Repo,分配一个子域名,然后你的网站就自动部署到全世界的各个角落了。如果你要更新网站内容,只要 git push 到特定分支就足够了。

如果你使用特定的 网站框架,甚至还可以直接在线从仓库内容中构建:Blazor、Brunch、Docusaurus、Gatsby、Gridsome、Hexo、Hono、Hugo、Jekyll、Next.js、Nuxt、Pelican、Preact、Qwik、React、Remix、Solid、Sphinx、Svelte、Vite 3、Vue、VuePress、Zola、Angular、Astro、Elder.js、Eleventy、Ember、MkDocs。

我从完全没接触过 Cloudflare,到把 Pigsty 的网站搬运到 CF 上并完成部署,只用了一个小时左右。我不需要操心什么服务器,CI/CD / HTTPS 证书,安全高防抗 DDoS,Cloudflare 已经把一切都替我做好了 —— 更重要的是流量费全免,我唯一做的就是绑了个信用卡花了十几块钱买了个域名,但实际上根本不需要什么额外费用 —— 都已经包含在免费计划中了。

更令我震惊的是,虽然访问速度慢了一些,但在中国大陆是可以直接访问 CF 上的网站的,甚至不需要备案!说来也滑稽,本土云厂商虽然可以很快替你完成网站资源置备这件事,但耗时最久的步骤往往是卡在备案上。这一点确实算是 Cloudflare 的一个福利特性了。


Worker:极致的 Serverless 体验

尽管你可以把许多业务逻辑放在前端在浏览器中用 Javascript 解决,但一个复杂的动态网站也是需要一些后端开发的。而 Cloudflare 也把这一点简化到了极致 —— 你只需要编写业务逻辑的 Javascript 函数就可以了。当然,也可以使用 Typescript,现在更是支持 Python 了 —— 直接调用 AI 模型,难以想象后面会出现多少新的花活!

用户编写的这个函数会被部署在 Cloudflare 全世界 CDN 边缘服务器节点上,执行用户定义的业务逻辑。你可以 干各种各样的事情,返回动态的HTML与JSON,自定义路由、重定向、转发、过滤、缓存、A/B测试,重写请求,聚合请求,执行认证。当然,你也可以直接使用业务代码中调用对象存储 R2 与 SQL 数据库 D1,或者把请求转发到你自己的数据中心服务器上处理。

export interface Env {
  // If you set another name in wrangler.toml as the value for 'binding',
  // replace "DB" with the variable name you defined.
  DB: D1Database;
}

export default {
  async fetch(request: Request, env: Env) {
    const { pathname } = new URL(request.url);

    if (pathname === "/api/beverages") {
      // If you did not use `DB` as your binding name, change it here
      const { results } = await env.DB.prepare(
        "SELECT * FROM Customers WHERE CompanyName = ?"
      )
        .bind("Bs Beverages")
        .all();
      return Response.json(results);
    }

    return new Response(
      "Call /api/beverages to see everyone who works at Bs Beverages"
    );
  },
};

在 Worker 中查询 D1,简单到就是调用个变量。

[[d1_databases]]
binding = "DB" # available in your Worker on env.DB
database_name = "prod-d1-tutorial"
database_id = "<unique-ID-for-your-database>"

也不需要什么配置,指定一下D1数据库/R2对象存储名称就好了。

比起传统云上笨拙的开发部署体验来所,CF worker 真正做到了让开发者爽翻天的 Serverless 效果。开发者不需要操心什么数据库连接串,AccessPoint,AK/SK密钥管理,用什么数据库驱动,怎么管理本地日志,怎么搭建 CI/CD 流程这些繁琐问题,最多在环境变量里面指定一下存储桶名称这类简单信息就够了。写好 Worker 胶水代码实现业务逻辑,命令行一把梭就可以完成全球部署上线。

与之对应的是传统公有云厂商提供的各种所谓 Serverless 服务,比如 RDS Serverless,就像一个恶劣的笑话,单纯是一种计费模式上的区别 —— 既不能 Scale to Zero,也没什么易用性上的改善 —— 你依然要在控制台去点点点创建一套 RDS,而不是像 Neon 这种真 Serverless 一样用连接串连上去就能直接迅速拉起一个新实例。更重要的是,稍微有个几十上百的QPS,相比包年包月的账单就要爆炸上天了 —— 这种平庸的 “Serverless” 确实污染了这个词语的本意。


R2:吊打 S3 的对象存储

Cloudflare R2 提供了对象存储服务。与 AWS S3 相比,便宜了也许能有一个数量级 —— 我的意思是,尽管单纯看存储的价格 $ / GB·月,Cloudflare(0.015 $)价格与 S3 (0.023 $) 差距并不大,但 Cloudflare 的 R2 是免流量费的!

每月免费额度 Cloudflare R2 Amazon S3
存储 10 GB / 月 5 GB / 月
写请求 1 M / 月 2 K / 月
读请求 10 M / 月 20 K / 月
数据传输 无限量! 100 GB
超出免费额度后的价格
存储 ¥ 0.11 / GB ¥ 0.17 / GB
写请求 ¥ 32.63 / 百万请求 ¥ 36.25 / 百万请求
读请求 ¥ 2.61 / 百万请求 ¥ 2.9 / 百万请求
流量费 免费! ¥ 0.65 / GB

Cloudflare R2 定价 与 AWS S3 对比

举个例子,我的网站,R2 在过去一个月内消耗了 300 GB 流量,按照本土云 1GB 流量八毛钱左右的价格,需要支付 240 元,但我一分钱也没付。而且,我还知道更极端的例子 —— 比如一个月消耗了 3TB 流量,也依然在免费套餐中……

Cloudflare R2 是与 CDN 二合一的。在传统的云服务商中,你还需要操心额外的 CDN 配置,回源流量,CDN流量包,抗DDoS等等问题。但 Cloudflare 不需要,只要勾选配置启用,你的 R2 Bucket 可以直接被全世界读取,而最重要的是,而你根本不用担心账单被刷爆的问题 —— 我知道好几个在传统云厂商上,因为攻击把 CDN 流量刷爆,几万块钱余额一夜耗干欠费的案例。(包括我自己还亲历过一个因为云厂商自己SB的CDN回源设计,爆刷CDN流量的案例)但是在 Cloudflare 上,你不需要像斗牛犬和猫头鹰一样监视着 账单 与流量,首先,Cloudflare 流量免费…… ,更强的是, Cloudflare 已经有了智能的抗 DDoS 服务了,即使是免费的 Plan 也默认提供这项服务,可以有效避免恶意攻击(在传统云厂商,这玩意单独卖几千上万的所谓高防IP服务)。再加上每月慷慨的免费 1千万读取请求(对于放图片、软件包来说这已经非常大了!),可以确保在这上面的费用是高度确定性的 —— 如果不是零的话。


Cloudflare:在线的价值

王坚博士那本讲云计算的书《在线》其实说得很明白,云计算的真正价值是 在线(而不是什么弹性、敏捷、便宜之类的东西)。举个例子:我有一些下云的客户与用户,虽然已经把主体业务从公有云上搬到了 IDC 或者自己办公室的服务器上,但依然在云上留一些 ECS 和 RDS 的尾巴 —— 因为他们收取数据的 API 放在那里,感觉公有云提供的网络接入要比自己的机房/办公室更稳定可靠 —— 注意是网络接入而不是存储计算。

很多云上的客户,在算力上付出了几倍到十几倍溢价在存储上付出了几十倍到上百倍的溢价,都是为了这个网络 “在线” 的能力。但 Cloudflare 这样遍布全球的,带有边缘计算能力的 CDN ,将 “在线” 的能力拔高到了一个全新的高度上,比传统公有云更好地解决了这个问题。例如,AI 当红炸子鸡 OpenAI 的网站和 API 就是这么做的 —— 通过 CF 对外提供接入。

在这种模式下,用户完全可以把网站与 API 通过 Cloudflare 对外提供接入,而将重量级的存储与计算放在 IDC 中,而不是在传统公有云上用几倍的价格进行租赁。Cloudflare 提供的 Worker 可以在边缘用于发送、收取数据,并将请求转发至您自己的数据中心进行处理。如果希望实现更可靠的容灾,您还可以利用 Cloudflare 上的 R2 与 D1 作为临时性本地缓存,预处理汇总数据后,统一拉取到 IDC 进行处理。


CF 与 IDC 从两头挤压公有云

在 IT 规模光谱的一侧 —— 个人站长与小微企业上,新一代云服务 / SaaS(CF,Neon,Vercel,Supabase) 赛博菩萨们的免费套餐,对公有云产生了明显的替代与冲击 —— 别说 99 块钱包年的云服务器了,9块9 都不一定香了 —— 再便宜能便宜过免费吗? —— 更何况用 CF 建站的体验比云服务器自建要好太多了。

但更重要的是,在光谱另一侧的中大型企业中,新出现的 IDC 2.0 与开源管控软件替代合流,短路掉公有云这个中间商,利用好硬件摩尔定律的累积优势,成为终极FinOps实践,实现极为惊人的降本增效能力。Cloudflare 的出现补齐了开源IDC自建模式的最后一块短板 —— “在线” 能力。

Cloudflare 并没有提供传统公有云上的那些弹性计算、存储、K8SRDS 服务。但幸运地是,Cloudflare 可以与公有云 / IDC 良好地配合协同 —— 从某种意义上来说,因为 Cloudflare 成功解决了 “在线” 的问题,这使得传统数据库中心 IDC 2.0 也同样可以拥有比肩甚至超越公有云的 “在线” 能力,两者配合,在事实上摧毁了一些公有云的护城河,并挤压了传统公有云厂商的生存空间。

我非常看好 Cloudflare 这种模式,实际上,这种丝滑的体验才配称的上是云,配享太庙,可以心安理得吃高科技行业的高毛利。传统的 IDC 2.0 也在不断进步,租赁机柜、裸金属服务器的体验也并不逊色传统公有云(无非是服务器从两分钟到位变成几个小时到位)。而无法提供更多技术附加值,产品不可替代性的公有云厂商,生存空间会越来越小 —— 最终回退到传统 IDC / IaaS 业务中去

罗永浩救不了牙膏云?

老罗曾是一位很牛B的数码产品经理,算与 IT 行业沾边。但隔行如隔山,老罗卖云,就好像同时卖肉菜蛋奶和 Office 光盘的路边摊。事实也是如此 —— 老罗直播间先铺垫卖了半个小时的扫地机器人,接着姗姗来迟的老罗照本宣科念台词卖了四十分钟”云计算“ —— 然后马不停蹄地卖起了 高露洁无水酵素牙膏 —— 留下在云计算与牙膏间迷惑凌乱的观众。

这牙膏确实还不错,但这云服务器嘛…


云计算可以2C吗?

云计算是 ToB 业务,行业翘楚 AWS 的服务对象和营销焦点,显然是面向企业级开发者的。尽管一些个人站长,博主,学生或初创企业可能会因为低价而在直播间拍板购买云服务器,但这显然非常滑稽。更滑稽的是直播间的云服务器也没有更便宜 —— 99 块钱的云服务器活动从去年双十一就开始并持续至今……

把云服务器卖给个人用户这种滑稽想法,可能源于最近爆火的《幻兽帕鲁》自建服务器需求。我的朋友 SealOS 的创始人方海涛写了一篇《自建幻兽帕鲁私服的教程》,尝到了泼天富贵 SaaS 的美味。然后各家公有云厂商也快速跟进卷了起来 —— 一路从3分钟开服到30秒到3秒种开服。正如 《国内云:有大厂,没大哥》一文形容地那样,丝毫不顾颜面,撸起袖子下场,干起了浩方对战平台应该干的事情。

另一种 ToC 的典型场景是学生和个人站长。在以前,个人站长拿个 2C 2G 3M带宽的小虚拟机弄个网站还是挺不错的 —— 但是自从有了赛博佛祖 Cloudflare,别说 99 块钱的云服务器了,9块9 都不香了 —— 再便宜能便宜过免费吗? —— 何况用 CF 建站的体验比云服务器要好太多了 —— 都不用说各种Free Plan,就凭流量免费这一点,就足以让“送的几兆带宽”都统统变垃圾……

在 IT 规模光谱的一侧 —— 个人站长与小微企业上,新一代云服务/SaaS(CF,Neon,Vercel,Supabase),赛博菩萨们的免费套餐,对公有云产生了明显的替代与冲击;在光谱的另一侧 —— 中大型企业组织中,新出现的 IDC 2.0 与开源管控软件替代合流,短路掉公有云这个中间商,利用好硬件摩尔定律的累积优势,成为终极FinOps实践,实现极为惊人的降本增效能力


公有云冥灯亮起

任何行业的发展基本都遵从:技术主导 → 产品主导→ 运营主导的脉络。而在当下,除了大模型之外,公有云几乎没有什么独一无二的技术,不可替代的产品了。虚拟机、对象存储、云数据库成为了各家都有售的标品大锅饭,开源的云管控软件比如 SealOS 与 Pigsty 也将自建的能力普及。行业卷成了一片血海,大家从拼技术、拼产品的阶段走向了终局 —— 拼运营,也就是拼销售、打价格战的阶段。

对云计算来说,ToC 这种生意只能算蚊子腿。我们可以简单假设估算一下 —— 帕鲁爆卖了两千万份,十分之一中国玩家;作为单机游戏假设又有十分之一的用户有联机需求;这百分之几的本土联机用户一个月内玩腻,最后能产生个十几万核·月的云服务需求,分别从 A、B、C、D 等诸家云厂商采购 —— 每家分到个小几百万的市场规模。听上去不少,够养活一个创业公司了 —— 但随便一个云上 KA 企业客户年消,或者几个程序员工资就这个数了 —— 这显然不是云厂商应该干的事情。

公有云行业的增长已经到顶,原来看不上的蚊子腿,现在也变成了香饽饽 —— 各家云厂商的营收增速已经从原来的几十下降到了个位数,勉强靠着租GPU和大模型续了一口。但原本主营业务的增量市场没有了,市场萎缩,进入了零和博弈与斗兽厮杀阶段。营销也用出了各种荒腔走板草台班子的招数 —— 比如,女大学生第一次买服务器这种没品噱头,买数据库送天猫超市购物券这种滑稽戏,以及新出现的淘宝直播卖云服务器的新节目。

实际上,罗老师在行业晴雨表的能力上有着出色的的声誉 —— 冥灯为谁而亮,丧钟为谁而鸣?这种行为艺术有着一语成谶的潜力。牙膏云逐渐从代表先进生产力的本土云计算领头羊,变成了只能够打价格战的计算资源提供商,自己把自己玩坏了,确实不禁让人嘘唏。

剖析算力成本:阿里云真降价了吗?

微信公众号原文

2024年2月29号疯狂星期四,阿里云搞了个大降价软文漫天飞舞,作为云计算泥石流,不少朋友在后台留言让我点评一下。满屏的 20%,50% off 看上去好像降的很壮观,但外行看热闹,内行看门道:云服务的成本大头在于存储

云厂商真正的杀猪刀 —— ESSD 可是一毛钱都没降。而 EC2 和 OSS 降价降的也不是列表价,而是包年的最低折扣 —— 主力机型包一、三、五年可降价 10% 左右,所以也基本上降了个寂寞,对于已经享受低于此商务折扣的客户更是毫无卵用。

我们之前有过分析:云上 ECS 算力单价可达本地自建的十倍,云上 ESSD 存储单价可以达到本地自建的百倍。云数据库 RDS 介于两者之间。算力降价 10%, 相对于这个溢价来说就跟挠痒痒一样。

不过,既然阿里云号称大降价了,我就把云上基础资源的价格拎出来,用 2024 年的价格,再做一次成本对比。


太长不看

算力的价格使用 人民币/核·月 作为统一单位 ,云服务器的溢价为自建的 5 ~ 12 倍。

作为自建的参照案例,DHH 与 探探自建大型计算/存储服务器的单价成本为 20 ¥/(核·月),算上64x 配比的本地 NVMe 存储后为 22.4 ¥/(核·月)。我们考察阿里云国内一线可用区标准 c/g/r 实例族最近三代的算力均价,可以得出以下结论

在不考虑存储的情况下,云上按量,包月,包年,预付五年的单价分别为 187¥,125¥,81¥,37¥,相比自建的 20¥ 分别溢价了 8x, 5x, 3x, 1x。在配置常用比例的块存储后(1核:64GB, ESSD PL3),单价分别为:571¥,381¥,298¥,165¥,相比自建的 22.4¥ 溢价了 24x, 16x, 12x, 6x

关键数字 按需价格 包月价格 包年价格 预付三年 预付五年
配上64x存储价格 571 ¥ 381 ¥ 298 ¥ 181 ¥ 165 ¥
是自建价格的几倍 25x 17x 13x 8x 7x
算力单位价格 187 ¥ 125 ¥ 81 ¥ 53 ¥ 37 ¥
是自建价格的几倍 9x 6x 4x 3x 2x

随后,我们进一步定量分析阿里云服务器定价数据,发现了对单价影响最大的几项要素:附加存储,付费方式,可用区,实例族(芯片架构,实例代际,内存配比),并解释了上面的数字是如何计算得到的。

作为结论:即使是在所谓的“大降价”后,公有云提供的算力也远远称不上 “便宜” 。实际上云服务器的成本极其高昂,尤其是大型计算与大型 NVMe 存储。如果您的业务需要较大的块存储或一台物理服务器以上的算力,您确实应当仔细计算一下这里的成本,并考虑一下其他的备选项。


纯算力的价格

我们取样了最具有代表性的国内可用区,最近三代 c/g/r 实例族纯算力部分的的价格,绘制为图表。

如表所示,单位算力(1C4G)的标准价格是包月的价格,为 125 ¥。在此基础上:按量付费需要额外支付 50% 溢价,为 187 ¥;包年预付可以打 65折为 81 ¥,包三年可以打 44 折为 53 ¥,预付五年可以打三折为 37 ¥

关键数字 按需价格 包月价格 包年价格 预付三年 预付五年
算力单位价格 187 ¥ 125 ¥ 81 ¥ 53 ¥ 37 ¥
是自建价格的几倍 9x 6x 4x 3x 2x
自建成本可降低% 89% 84% 75% 62% 46%
自建成本可降至 % 11% 16% 25% 38% 54%

我们可以使用 DDH 2023 年下云自建的案例,以及我自己在探探亲身经历的云下 IDC 自建案例作为对比。 刨除 NVMe 存储部分后,DHH 自建的纯算力单价为 22 ¥,探探自建的单价为 18 ¥

因此不难看出,云上的纯算力的价格是自建的 2 ~ 9 倍!为了实现云厂商所鼓吹的 “极致弹性”,“按量付费”,您需要额外付出 8倍的成本。假如您完全不在乎弹性,就跟正常采购服务器五年摊销一样使用,那么仍然需要额外付出1倍的成本。


杀猪盘的价格

如果云上的算力溢价在预付多年的 Saving Plan 中,价格还能勉强让人接受,那么云上存储的溢价就属于离谱的杀猪盘了。

大型算力往往要搭配高性能的本地存储使用 —— 例如业内经常使用的一种配比规格为 1核:64GB NVMe 存储。探探和 DHH 的自建案例中都使用此配比。 探探的服务器案例中,64核配 3.2TB NVMe SSD,采购价 6000 元,当下市价 ¥2788。在 DHH的案例中,192 核配 12 TB NVMe SSD,采购价 $2390。以标准五年质保与财务摊销计算,单位价格 ¥/(GB·月) 约为 0.02

单位价格:¥/GiB月 IOPS 带宽 容量 按需价格 包月价格 包年价格 预付三年+
ESSD 云盘 PL0 10K 180 MB/s 40G-32T 0.76 0.50 0.43 0.25
ESSD 云盘 PL1 50K 350 MB/s 20G-32T 1.51 1.00 0.85 0.50
ESSD 云盘 PL2 100K 750 MB/s 461G-32T 3.02 2.00 1.70 1.00
ESSD 云盘 PL3 1M 4 GB/s 1.2T-32T 6.05 4.00 3.40 2.00
本地 NVMe SSD 3M 7 GB/s 最大单卡64T 0.02 0.02 0.02 0.02

抛开系统盘/HDD这种乞丐盘,在阿里云上提供了四种不同规格等级的块存储:ESSD PL0 ~ PL3。其单位价格分别为:0.5, 1, 2, 4。ESSD PL3 的性能勉强接近本地 Gen3 NVMe SSD,但它 4 块钱的单价是本地自建的 200 倍!当然 ESSD 也有自己独立的折扣策略 —— 预付费三年以上,ESSD 可以打满顶折 5 折,但那也是 100 倍溢价了!

关键数字 按需价格 包月价格 包年价格 预付三年 预付五年
配上64x存储价格 571 ¥ 381 ¥ 298 ¥ 181 ¥ 165 ¥
是自建价格的几倍 25x 17x 13x 8x 7x
自建成本可降低% 96% 94% 92% 88% 86%
自建成本可降至 % 4% 6% 8% 12% 14%

在《云盘是不是杀猪盘》中我们已经详细对比过云盘与本地盘的性能、可靠性与成本,因此这里主要使用与本地 NVMe SSD 最接近的 ESSD 云盘 PL3 作为对比。如果我们为每核 CPU 配置 64 GiB 的 PL3 块存储,则调整后的云服务器单价为:381 ¥。在此基础上:按量付费需要额外支付 50% 溢价,为 571 ¥;包年预付为 298 ¥,包三年可以打 44 折为 181 ¥,预付五年可以打三折为 165

与 DHH / 探探自建的含存储单价 22.4 ¥ 相比,云服务器单价达到了自建的 6 ~ 25 倍!

图:带64x存储单价对比


云存储对单价的影响

在影响服务器价格的因素中,存储是变数最大的一个。

例如,以上面的标准配置:1核:4x内存:64x存储,在使用不同规格的 ESSD 的情况下,存储费用占服务器费用的比例差异非常之大。

使用 PL3 ESSD 的情况下,存储费用占比可以达到 67% ~ 77%,我们也可以选择更烂的乞丐盘作为存储,以IOPS/带宽吞吐性能为代价 “降低成本”。例如,使用 PL2 以牺牲 90% 的 IOPS 性能为代价,将这一比例降低至 51% ~ 63%,PL1/PL Auto 以牺牲 95% 的性能为代价,将此比例降低至 34% ~ 46%。PL0 则以 99% 的性能为代价,将此比例降低至 20% ~ 30% 。

云盘规格 预付五年 预付四年 预付三年 预付二年 包年价格 包月价格 按量付费
+PL3 ESSD ¥165 ¥172 ¥181 ¥247 ¥298 ¥381 ¥571
+PL2 ESSD ¥101 ¥108 ¥117 ¥158 ¥190 ¥253 ¥379
+PL1 ESSD ¥69 ¥76 ¥85 ¥113 ¥135 ¥189 ¥283
+PL0 ESSD ¥53 ¥60 ¥69 ¥91 ¥108 ¥157 ¥235
纯算力价格 ¥37 ¥44 ¥53 ¥68 ¥81 ¥125 ¥187

请注意,ESSD 块存储和 ECS 算力的折扣力度是不同步的,由于 SSD 的顶配折扣(5折)比纯算力的顶配折扣(3折)要更弱,所以存储费用比例随着预付费时间增加会越来越高。

云盘规格 预付五年 预付四年 预付三年 预付二年 包年价格 包月价格 按量付费
+PL3 ESSD 77% 75% 71% 72% 73% 67% 67%
+PL2 ESSD 63% 60% 55% 57% 57% 51% 51%
+PL1 ESSD 46% 42% 38% 40% 40% 34% 34%
+PL0 ESSD 30% 27% 23% 25% 25% 20% 20%
纯算力价格 0% 0% 0% 0% 0% 0% 0%

有人主张说,云上的 ESSD 应该和 SAN 存储对比,本地 NVMe SSD 应该与实例存储进行对比。在《云盘是不是杀猪盘》中已经解释过这个问题。直接面向用户的数据库类服务使用的几乎都是 EBS 而非实例存储;此外,阿里云并非没有带本地 NVMe SSD 实例存储的机型,例如 i 系列,但这里的单价也远远说不上便宜。


预付费对单价的影响

预付费方式对云服务器的价格有着显著影响:算力的价格以包月价格作为标准,按需付费价格上浮 50%,包年打 65 折,包二、三、四、五年的折扣分别是55,44,35,30 折。存储的价格同样以包月价格作为基准,按需付费的价格上浮 51.25%,包年打85折,两年七折,三年以上五折。ECS 算力的单价(¥/核月)与块存储 ESSD 的单价(¥/GiB月)和折扣规则如下表所示:

付费模式 算力折扣 算力单价 ESSD折扣 PL3 PL2 PL1 PL0
按量付费 150% 187 151.25% 6.05 3.02 1.51 0.76
包月付费 100% 125 100% 4.00 2.00 1.00 0.50
预付一年 65% 81 85% 3.40 1.70 0.85 0.43
预付二年 55% 68 70% 2.80 1.40 0.70 0.35
预付三年 44% 53 50% 2.00 1.00 0.50 0.25
预付四年 35% 44 50% 2.00 1.00 0.50 0.25
预付五年 30% 37 50% 2.00 1.00 0.50 0.25

对于纯算力来说,最近三代主流实例族的价格与比例如下表所示,不难看出实际定价相当符合上面给出的模型:

实例族单价 预付五年 预付四年 预付三年 预付二年 包年价格 包月价格 按量付费
c6 ¥27 ¥31 ¥38 ¥49 ¥58 ¥88 ¥133
c7 ¥28 ¥33 ¥38 ¥52 ¥62 ¥96 ¥144
c8i ¥30 ¥35 ¥44 ¥56 ¥66 ¥101 ¥152
g6 ¥34 ¥40 ¥49 ¥62 ¥74 ¥114 ¥170
g7 ¥37 ¥43 ¥52 ¥67 ¥79 ¥123 ¥185
g8i ¥39 ¥46 ¥56 ¥72 ¥85 ¥130 ¥195
r6 ¥45 ¥53 ¥65 ¥83 ¥98 ¥150 ¥225
r7 ¥49 ¥57 ¥70 ¥89 ¥105 ¥164 ¥246
r8i ¥52 ¥61 ¥75 ¥95 ¥113 ¥173 ¥260
实例族折扣 预付五年 预付四年 预付三年 预付二年 包年价格 包月价格 按量付费
c6 30% 35% 43% 55% 65% 100% 150%
c7 30% 35% 40% 54% 64% 100% 150%
c8i 30% 35% 43% 55% 65% 100% 150%
g6 30% 35% 43% 55% 65% 100% 150%
g7 30% 35% 43% 54% 64% 100% 150%
g8i 30% 35% 43% 55% 65% 100% 150%
r6 30% 35% 43% 55% 65% 100% 150%
r7 30% 35% 43% 54% 64% 100% 150%
r8i 30% 35% 43% 55% 65% 100% 150%

上图展示了不同实例族在不同付费模式下的价格对比。如果我们对所有实例价格取一个平均(大致相当于 1c4g 机型的标准价),不同付费模式下的算力均价如柱状图所示:


实例族对价格的影响

实例族对价格的影响主要取决于三点:内存配比芯片架构实例代际

首先,同一实例族内的 实例单价与CPU核数无关,即最终算力部分的价格线性正比于 CPU 核数。因此不会出现规格越大,单价越便宜或越贵的现象(这意味着云服务器实例规格越大,性价比越低)。

内存配比指的是一个 CPU 核配比多少倍的内存,内存的单位是 GiB。在阿里云上,通用型(g)实例的内存配比为1:4,标记为 4x ,即一核CPU配 4 GiB 内存。计算型(c)实例内存配比为 1:2,内存型(r)实例内存配比为 1:8 。

代际 c 计算型 2x g 通用型 4x r 内存型 8x c / g r / g
6 ¥ 88 ¥ 114 ¥ 150 78% 132%
7 ¥ 96 ¥ 123 ¥ 164 78% 133%
8 ¥ 101 ¥ 130 ¥ 173 78% 133%

从统计上看,相比 4x 通用型实例族,同核数的内存型实例 8x 内存翻倍,价格是通用型实例的 132% ;同核数的计算型实例 2x 内存减半,价格是通用型实例的 78%。 我们还可以根据这里的规律计算出阿里云上内存的定价约为 12 元/GiB月:也不算便宜,16G用俩个月就够买一条了。

此外,实例/CPU 的代际也对价格有影响,例如以当下的八代实例为基准,上一代 Gen7 实例的价格会在本代基础上打 95 折,再上一代 Gen6 的实例会在本代基础上打 87 折。

代际 Intel ARM ARM / Intel 倚天 倚天 / Intel
6 ¥ 117 ¥ 104 89%
7 ¥ 128 ¥ 105 82%
8 ¥ 135 ¥ 125 92% ¥ 96 71%

从芯片架构上看,ARM 实例价格是标准 Intel 实例的九折,阿里自研倚天芯片实例的价格是标准 Intel 实例的七折。30% off 就想找小白鼠尝鲜,诚意有些不足了。


可用区对价格的影响

从大的层面上来说,阿里云的可用区可以分为国内/国际区域,两者的价格有着泾渭分明的区别。海外可用区(包括香港)的价格要比国内可用区高很多,最贵的可用区是香港,各实例族价格几乎是国内可用区的一倍。

国内可用区价格上大体分为两类:中心城市(北上广深杭+宁汉蓉榕青)和犄角旮旯(乌兰察布,河源,呼和浩特,张家口)。中心城市可用区的实例定价保持高度一致(成都例外,有几个实例族稍微便宜点)。犄角旮旯地方的实例价格会便宜一些,相比中心城市最高可能有 30% 的折扣。

云下高可用秘诀:拒绝复杂度自慰

本文翻译自 DHH 博客微信公众号原文 | Vonng博客

我们不需要Kubernetes大师或花哨的新数据库 —— 程序员极易被复杂度所吸引,就像飞蛾扑火一样。系统架构图越复杂,智力自慰的快感就越大。我们坚决抵制这种行为,是在云下可用性上成功的重要原因。

作者:David Heinemeier Hansson,网名DHH,37 Signal 联创与CTO,Ruby on Rails 作者,下云倡导者、实践者、领跑者。反击科技巨头垄断的先锋。Hey博客

译者:冯若航,网名 Vonng 。磐吉云数创始人与CEO。Pigsty 作者,PostgreSQL 专家/布道师。公众号《非法加冯》主理人,云计算泥石流,数据库老司机。


下云的同时确保系统稳定运行

Keeping the lights on while leaving the cloud

37signals 的运维团队来说,2023年无疑是充满挑战的一年。我们把包括七大核心应用从云上迁移了下来,其中包括在云上诞生的电子邮件服务 HEY —— 它有着极为严苛的可用性要求,我们下云的过程不能影响这一承诺。幸运的是,我们确实做到了。2023年,HEY的可用时间达到了令人瞩目的 99.99%!

这一点非常重要,因为如果人们无法访问他们的电子邮件,他们可能会错过飞机登机,无法及时完成交易,或者无法获知医疗检测结果。我们非常重视这项责任,因而在这个需要彻底改变运行 HEY 运行方式的一年里,实现了这接近完美的四个九,成为了让我引以为傲的重要成就。

但不仅仅是 HEY 有这种细致运维待遇。在2023年,我们运营的所有主要应用可用性最低都达到了 99.99% 以上。这里包 Highrise、Backpack、Campfire 以及所有版本的 Basecamp。我们并非没有遇到任何问题 —— 而是靠团队迅速解决了所有问题,使全年的总停机时间没有超过 0.01%

在云外能否确保应用可靠性和稳定性,没有一个应用能比 Basecamp 2 更能说明这个问题。这是我们从 2012 年到 2015 年销售的 Basecamp 版本,至今仍有数千用户,创造了数百万美元的收入。它多年来一直运行在我们自己的硬件上,现在已经是连续第二年实现了几乎难以置信的 100% 可用性 —— 2023年整整365天零停机,延续了2022年的辉煌。

我不会假装如此卓越的可用性是一件轻而易举的事情,因为事实并非如此。做到这一点绝非易事。我们拥有一支技艺精湛、敬业奉献的运维团队,他们为实现这一目标做出了巨大贡献,理应获得高度赞扬。但这也并非什么高深莫测的火箭科学!

Basecamp 2 连续两年实现100%可用性的魔法,以及其他所有应用达到99.99%可用性的成绩,相当一部分归功于我们选择了简单枯燥、基础扎实的技术。我们使用了 F5、Linux、KVM、Docker、MySQL、Redis、ElasticCache,当然还有 Ruby on Rails。我们的技术栈朴实无华,主要是复杂度很低 —— 我们并不需要 Kubernetes 大师或花哨的数据库与存储。大多数情况下,你也不会需要的。

但是程序员极易被复杂度所吸引,就像飞蛾扑火一样。系统架构图越复杂,智力自慰的快感就越大。我们坚决抵制这种行为的投入,才是在可用性上胜利的根本原因。

这里我讨论的并不是运营 Netflix、Google 或 Amazon 所需的技术。在那种规模下,你确实会遇到真正的开拓性问题,没有现成的解决方案可供借鉴。但对于我们其他 99.99% 的人来说,效仿他们的想象与认知来建模自己的基础设施,是诱人但致命的塞壬歌声。

想要拥有良好的可用性,你需要的不是云,而是在冗余硬件上运行成熟的技术,并配置好备份,一如既往。

注:DHH 省下近千万美元的高昂云开销,本文翻译了DHH下云的最新进展。下云前情提要与完整过程请参考:《下云奥德赛》,《是时候放弃云计算了吗》,以及《DHH下云FAQ》。


译者评论

DHH指出了维护良好可用性的最佳实践 —— 在冗余硬件上运行朴实无华、成熟基础的技术。软件的大部分成本开销并不在最初的研发阶段,而是在持续的维护阶段。而简单性对于系统的可维护性至关重要。

有一些程序员出于**智力自慰(Intellectual Masturbation)工作安全(Job Security)**的原因,会在架构设计中堆砌无谓的额外复杂度 —— 例如不管规模负载合适与否就一股脑全上 Kubernetes,或者使用胶水代码飞线串联起一堆花里胡哨的数据库。寻找“足够酷的“的东西来满足个人价值的需求,而不是考虑要解决问题是否真的需要这些屠龙术。

鲁布·戈德堡机械:“以极为繁复而迂回的方法去完成实际上或看起来可以容易做到事情” —— 一种通过复杂度进行智力自慰的行为。

复杂度会拖慢所有人的速度,并显著增加维护成本。在复杂的系统中进行变更,引入错误的风险也更大(例如《从降本增笑到真的降本增效》介绍的大故障)。因为复杂度导致维护困难时,预算和时间安排通常会超支。当开发人员难以理解系统时,隐藏的假设、无意的后果和意外的交互就更容易被忽略。降低复杂度能极大地提高软件的可维护性,因此简单性应该是构建系统的一个关键目标。

并不是所有公司都有 Google 那样的规模与场景,需要用宇宙战舰去解决他们特有的问题。裸机/虚机上的 PostgreSQL + Go/Ruby/Python 或者经典 LAMP 已经让无数公司一路干到上市了。切莫忘记,为了不需要的规模而设计是白费功夫,这属于过早优化的一种形式 —— 而这正是万恶之源。

以我亲身经历为例,在探探早中期的时候,几百万日活时的技术栈仍然非常朴素 —— 应用纯用 Go 写,数据库只用 PostgreSQL。在 250w TPS与 200TB 数据的量级下,单一PostgreSQL选型能稳定可靠地撑起业务:除了本职的OLTP,还在相当长的时间里兼任了缓存,OLAP,批处理,甚至消息队列的角色。最终一些兼职功能逐渐被分拆出去由专用组件负责,但那已经是近千万日活时的事了,而且事后来看其中一些新组件的必要性也存疑。

因此当我们在进行架构设计与评审时,不妨用复杂性的视角来进行额外的审视。更多关于复杂度的讨论,请参考下列文章:

数据库应该放入K8S里吗?

从降本增笑到真的降本增效

把数据库放入Docker是一个好主意吗?

微服务是不是个蠢主意?

分布式数据库是伪需求吗?

扒皮云对象存储:从降本到杀猪

微信公众号原文 | Vonng博客

对象存储是云计算的定义性服务,曾被视为云上降本的典范。不幸的是随着硬件的发展,资源云(Cloudflare R2)与开源平替(MinIO)的出现,曾经“物美价廉”的对象存储服务失去了性价比,和EBS一样成为了杀猪盘。我们在《云计算泥石流》系列中已经深入剖析过云上 EC2 算力,EBS 磁盘,RDS 数据库的成本构成,今天我们就来审视一下云服务之锚 —— 对象存储。

从降本到杀猪

对像存储,又称简单存储服务(Simple Storage Service,缩写为 S3,以下用S3代指), 曾经是云上“物美价廉”的拳头产品。

在十几年前,硬件的价格高昂;能够用一堆几百GB的机械硬盘,三副本搭建可靠的存储服务,并设计实现一套优雅的 HTTP API,还是一件颇有门槛的事情;因此相对于那些“企业级IT”存储方案,物美价廉的 S3 显得很有吸引力。

但是计算机硬件这个领域非常特殊 —— 有着一个价格每两年减半的摩尔定律。在 AWS S3 的历史上的确有过几次降价,下面的表格里整理了几次主要降价后的 S3 标准档存储单价,也整理了对应年份企业级 HDD/SSD 的参考单位价格。

Date $/GB·月 ¥/TB·5年 HDD ¥/TB SSD ¥/TB
2006.03 0.150 63000 2800
2010.11 0.140 58800 1680
2012.12 0.095 39900 420 15400
2014.04 0.030 12600 371 9051
2016.12 0.023 9660 245 3766
2023.12 0.023 9660 105 280
其他参考价 高性能存储 顶配底折价 与采购NVMe SSD 价格参考
S3 Express 0.160 67200 DHH 12T 1400
EBS io2 0.125 + IOPS 114000 Shannon 3.2T 900

不难看出,S3 标准档的单价从 2006 年的 0.15 $/GB·月 到 2023 年的 0.023 $/GB·月 ,降到原来的 15%,便宜了 6 倍,听上去不错;然而当你考虑到 S3 底层 HDD 的价格从降到原来的 3.7% ,便宜了整整 26 倍时,就能发现这里面的猫腻了。

S3 的资源溢价倍数从 2006 年的 7 倍增长到了当下的 30 倍!

在2023年当下,当我们重新进行成本核算时,不难看出 S3 / EBS 这类存储服务的性价比早已今非昔比 —— 云上的算力 EC2 相比自建服务器有着 5 ~ 10 倍的溢价,而云上的块存储 EBS 相对本地SSD有着几十倍到一两百倍的溢价。云上的 S3 相对普通HDD也有着三十倍左右的资源溢价。而作为云服务之锚的 S3 / EBS / EC2 价格,又会传导到几乎所有的云服务上去 —— 这让云服务的性价比彻底丧失吸引力。

这里的核心问题是:硬件资源的价格随着摩尔定律以指数规律下降,但节省的成本并没有穿透云厂商这个中间层,传导到终端用户的服务价格上逆水行舟,不进则退,降价速度跟不上摩尔定律就是其实就是变相涨价。以S3为例,在过去十几年里,云厂商S3虽然名义上降价6倍,但硬件资源却便宜了 26 倍,所以这个价格又该怎么说呢?

成本、性能、吞吐

尽管云服务有着高昂的溢价,但如果它是不可替代的最优选,那么即使溢价高性价比低,也不影响价格钝感的高价值头部客户使用。然而不仅仅是成本,存储硬件的性能也遵循摩尔定律,随着时间累积,自建S3的性能也开始出现显著优势。

S3 的性能主要体现在吞吐量上,AWS S3 的 100 Gb/s 网络提供了高达 12.5 GB/s 的访问带宽,这一点确实值得称道。这样的吞吐量放在十年前毫无疑问是让人震撼的,然而在今天,一块两万块不到的企业级 12 TB NVMe SSD ,单卡就可以达到 14 GB/s 的读写带宽,100Gb的交换机和网卡也已经非常普及,想做到这一点并不复杂。

在另一个关键性能指标“延迟”上,S3也被本地磁盘吊打。S3 标准档的首字节延迟相当拉垮,根据文档说明在 100~200ms 百毫秒的范围。当然 AWS 也刚刚在 2023 Re:Invent 上新发布了 “高性能S3” —— S3 Express One Zone ,能够达到单毫秒级的延迟,弥补了这个短板。但是和 4K随机读/写延迟 55µs/9µs 十微秒量级的NVMe 还是相距甚远。

S3 Express 的毫秒级延迟听上去很不错,但是当我们将其与 NVMe SSD + MinIO 自建对比时,这种“毫秒级”性能就只能自惭形秽了 —— 当代的 NVMe SSD 的4K随机读/写延迟已经达到了 55µs/9µs ,套上一层薄薄的MinIO转发,首字节输出延迟要比 S3 Express 好至少一个数量级。如果用标准档 S3 对比,性能的差距会进一步拉大到三个数量级。

性能的差距仅仅是一方面,更重要的是成本。标准档的 S3 价格自 2016 年至今都没变过,一直是 0.023 $/GB月,每TB·月的人民币价格是 161 元。高级货色 S3 Express One Zone 的价格比标准档高了一个数量级,达到每月·GB $0.16,每TB·月的人民币价格为 1120 元。作为参考,我们可以引用《重新拿回计算机硬件的红利》和 《云盘是不是杀猪盘》中的数据进行对比:

因素 本地 PCI-E NVME SSD Aliyun ESSD PL3 AWS io2 Block Express
成本 14.5 ¥/TB·月 ( 5年均摊 / 3.2T MLC ) 5 年质保,¥3000 零售 3200¥/TB·月 (原价 6400¥,包月4000¥) 3年预付整体打5折才有此价格 1900 ¥/TB·月 使用最大规格 65536GB 256K IOPS 最优惠状态
容量 32TB 32 TB 64 TB
IOPS 4K随机读:600K ~ 1.1M 4K随机写 200K ~ 350K 4K随机读:最大 1M 16K随机IOPS: 256K
延迟 4K随机读:75µs 4K随机写:15µs 4K 随机读: 200µs 随机IO:500µs 上下文推断为16K
可靠 UBER < 1e-18,折合18个9 MTBF: 200万小时 5DWPD,持续三年 数据可靠性 9个9 存储与数据可靠性 持久性:99.999%,5个9 (0.001% 年故障率) io2 说明
SLA 5年质保 出问题直接换新 Aliyun RDS SLA 可用性 99.99%: 月费 15% 99%: 月费 30% 95%: 月费 100% Amazon RDS SLA 可用性 99.95%: 月费 15% 99%: 月费 25% 95%: 月费 100%

这里本地 NVMe SSD 使用的例子是 Shannon DirectIO G5i 3.2TB MLC颗粒企业级SSD,是我们自己大规模使用的SSD卡。全新拆机零售件价 ¥2788 (闲鱼都有!),按5年60个月折算的每TB·月的人民币价格为 14.5 元。咱们退一步将按浪潮列表价 ¥4388 核算,TB·月价也就是 22.8。如果这个例子还不够,我们可以参考《是时候放弃云计算吗》里面 DHH 下云采购的 12 TB Gen4 NVMe 企业级 SSD,2390$ 一块,TB·月价也正好是 23 块钱。

那么问题就来了,性能好几个数量级的 NVMe SSD,为什么单价比 S3 标准档还要便宜一个数量级(161 vs 23),比 S3 Express 便宜了两个数量级(1120 vs 23 x3 )?如果我用这样的硬件(就算上三副本) + 开源软件自建一个对象存储服务,是不是可以实现三个数量级的性价比提升?(这还没提 SSD 在可靠性相对于 HDD 的优势)

特别值得一提的,上面对比的还单纯是存储空间的费用,流入流出对象存储的流量费通常也是一笔非常可观的支出,有的档位收费靠的不是存储费用,而是取回流量费。还有 SSD 相对 HDD 可靠性的问题,云上数据自主可控的问题,这里就不再深入展开了。

当然云厂商也会辩解——说我们这个 S3 啊,可不是纯粹的存储硬件资源,而是一个开箱即用的服务。里面还包含了软件的知识产权,人力的维护成本;而自建故障率高,自建更危险,自建运维人力开销大等等等等,可惜,这套说辞放在 2006 年或者 2013 年还可以说得通,放在今天就显得有些滑稽了。

开源自建对象存储

在十几年前,绝大多数用户都缺乏IT自建能力,S3也没有足够成熟的开源替代品。用户可以容忍接受这样的高科技溢价。然而当各家云厂商、IDC都开始提供对象存储,甚至都出现了开源免费的对象存储方案(MinIO)后,卖方市场变成了买方市场,价值定价逻辑转变为成本定价逻辑后,不降反涨的资源溢价自然就会面临用户的拷问 —— 它到底给我带来了什么额外价值,以至于要收这种巨额费用。

云的倡导者声称,上云相比自建更便宜,更简单,更快捷。对于个人站长,中小互联网企业这些云适用光谱内的用户来说,这么说肯定是没问题的。如果你的数据规模只有几十个GB,或者有一些中小规模的出海业务和CDN需求,我并不会推荐你凑热闹自建什么对象存储,你应该出门左转去 Cloudflare 用 R2 —— 这也许就是最优解。

然而对于真正贡献营收大头的高价值中大规模客户来说,这些价值主张就不一定成立了。如果你主要是在本地存储使用 TB/PB 规模的数据,那么确实应当认真考虑一下自建对象存储服务的成本与收益 —— 现在用开源软件自建已经非常简单、稳定与成熟了。存储服务的可靠性主要取决于磁盘冗余:除了零星的硬盘故障 (HDD AFR 1%,SSD 2~3‰)需要你(请代维服务商)换上备件,并不会有多少额外的负担。

如果说混合了EBS/S3能力的开源 Ceph 还能算有不少运维复杂度,功能也没有完全拉齐;那么完全兼容 S3 的对象存储服务开源替代 MinIO 可以说是开箱即用了 —— 一个没有额外依赖的纯二进制,不需要几个配置参数就可以快速拉起,把服务器上的磁盘阵列转变为一个标准的本地 S3 兼容服务,甚至还集成了了 AWS 的 AK/SK/IAM 兼容实现!

从运维管理的角度看,Redis 的运维复杂度比 PostgreSQL 低一个数量级,而 MinIO 的运维复杂度又比 Redis 还要再低一个数量级。它简单到这样一种程度:我一个人可以花个一周不到时间把 MinIO 的部署/监控作为添头放到我们开源的 PostgreSQL RDS 方案里来,用作可选的中央备份存储仓库。

我在探探时,有几个 MinIO 集群就是这么搭建维护的:放了 25PB 数据,可能是国内先前最大规模的 MinIO 部署。用了多少人来维护呢?抽调了一个运维工程师 1/10 不到的工作时间来兼职罢了,而自建整体综合成本在云列表价 0.5 折浮动。实践出真知,如果有人骗你说对象存储自建很难很贵,你确实可以自己动手试试 —— 要不了几个钟头,这些销售 FUD 话术就会不攻自破。

对于对像存储服务来说,云的三点核心价值主张:“更便宜,更简单,更快捷” ,更简单这条说不上,更便宜走向了反面,恐怕只剩下最后一点更快捷了 —— 在这一点上确实没有谁能比过云,你确实可以在云上用1分钟不到在全世界所有区域申请PB级的存储服务,Which is Amazing!只不过你也要为这个鸡肋特权付出几十倍的高昂溢价。

因此对于对像存储服务来说,云的三点核心价值主张:“更便宜,更简单,更快捷” 中,更简单这条说不上,更便宜走向了反面,恐怕只剩下最后一点快捷了 —— 在这一点上确实没有谁能比过云,你确实可以在云上用1分钟不到在全世界所有区域申请PB级的存储服务,Which is Amazing!只不过你也要为这个特权掏十几倍到几十倍的高昂溢价就是了。而对于有一定规模的企业而言,比起翻几番的运营成本来说,等个两周或者多掏点一次性资本投入根本不算个事。

小结

记得在 2019年4月1日,国内增值税正式从 16% 下调到 13% ,苹果官网随即全面降价,单品最高降价幅度达8% —— iPhone的几个典型产品都降了500元,把税点返还给了用户。但也有许多厂商选择装聋作哑维持原价,自己吃下这个福利 —— 白花花的银子怎么能随便散给别人呢?

类似的事情就发生在云计算领域 —— 硬件成本的指数下降,并没有在云厂商的服务价格上充分反映出来,而这一点,让公有云逐渐从普惠基础设施变成了垄断集中杀猪盘

然而事情正在起变化,硬件重新变得有趣起来,而云厂商没有办法把这部分红利永远掩藏遮盖下去 —— 聪明的人开始计算数字,勇敢的人已经采取行动。像马斯克和DHH这样的先锋已经充分认识到了这一点,从云上下来,脚踏实地 —— 直接产生千万美元的财务效益,并且还有性能上的回报,运营也变得更加自主独立。越来越多的人会同样意识到这一点,追随先驱者做出明智的选择,拿回属于自己的硬件红利。

References

[1] 2006: https://aws.amazon.com/cn/blogs/aws/amazon_s3/

[2] 2010: http://aws.typepad.com/aws/2010/11/what-can-i-say-another-amazon-s3-price-reduction.html

[3] 2012: http://aws.typepad.com/aws/2012/11/amazon-s3-price-reduction-december-1-2012.html

[4] 2014: http://aws.typepad.com/aws/2014/03/aws-price-reduction-42-ec2-s3-rds-elasticache-and-elastic-mapreduce.html

[5] 2016: https://aws.amazon.com/ru/blogs/aws/aws-storage-update-s3-glacier-price-reductions/

[6] 2023: https://aws.amazon.com/cn/s3/pricing

[7] 首字节延迟相当拉垮: https://docs.aws.amazon.com/AmazonS3/latest/userguide/optimizing-performance.html

[8] 存储与数据可靠性: https://help.aliyun.com/document_detail/476273.html

[9] io2 说明: https://aws.amazon.com/cn/blogs/storage/achieve-higher-database-performance-using-amazon-ebs-io2-block-express-volumes/

[10] Aliyun RDS SLA: https://terms.aliyun.com/legal-agreement/terms/suit_bu1_ali_cloud/suit_bu1_ali_cloud201910310944_35008.html?spm=a2c4g.11186623.0.0.270e6e37n8Exh5

[11] Amazon RDS SLA: https://d1.awsstatic.com/legal/amazonrdsservice/Amazon-RDS-Service-Level-Agreement-Chinese.pdf

半年下云省千万,DHH下云FAQ

本文翻译自DHH原文:The Big Cloud Exit FAQ | 微信公众号原文 | Vonng博客

一年前我们宣布了下云的计划,随后披露了2022年320万美元的云账单细节,并决定不依赖昂贵的企业级服务而是自行构建工具来下云,使命已定!

一个月后,我们下单购买了60万美元的戴尔服务器来实现这个目标,并保守估计未来五年将给我们省下700万美元。我们还详细描述了推动我们下云的五大核心价值观 —— 不仅仅是成本,还有独立性、以及忠于互联网初心等等。

在二月份,我们引入了 Kamal,这是我们花了几周自力更生打造的下云工具,它能在帮助我们从云上下来的同时,又不失去云计算中那些关于容器与运维原则上的创新点。

紧接着,我们所需的所有硬件已经运抵两个不同地理区域的数据中心,总共是 4000个vCPU、7680GB 的内存和 384TB 的 NVMe 存储!

到了6月,全部搞定。我们成功地下云了!

如果说这段下云旅途是“充满争议”的,那算是比较温和的说法 —— 有数百万人通过 LinkedIn、X/Twitter 以及此邮件列表阅读了更新。我收到了数千条评论,要求澄清、提供反馈,并对我们选择不同道路的“胆大妄为”感到难以置信 —— 在别人忙着上云还八字没有一撇时,就把下云的一捺给画完了


但我们还是用结果来说话:我们不仅迅速完成了下云,而且客户几乎没有任何感知。很快,省下的开销就开始滚雪球,到了九月,我们的云账单已经省下了一百万美元。随着预付费实例逐渐到期(那种要提前整租一年以换取折扣的实例),账单开始进一步坍缩:

时至今日,下云已经告一段落,但是各种问题开始纷至沓来:为了避免一次又一次地回答重复的问题,我想编制一份经典的“常见问题”请单(FAQ),以下是FAQ的内容:


在硬件上省下的成本,会不会被更大规模的团队薪资抵消掉?

不会,因为我们在下云后,团队组成并没有发生变化。曾经在云上运维 HEYBasecamp 的人,和现在在我们自己的硬件上运维这些应用的都是同一拨人。

这是云上营销的核心欺诈:所有的事情都非常简单,你几乎不需要任何人手来运维。我从来没见过这种事成真,无论是在 37signals,还是其他运维大型互联网应用的公司。云有一些优势,但通常不在减少运维人员上。


为什么你选择下云,而不是优化云账单呢?

我们在2022年的云账单是320万美元,而之前的账单是现在的两倍还要多。现在的云账单已经经过仔细审查、讨价还价、深度优化了 —— 通过长时间的重复榨取工作,我们已经把这里的油水给彻底挤干了。

这件事部分回答了:为什么我非常看好中型及以上软件公司下云这件事。许多和我们有着相同客户规模的业务,每月的云开销很容易达到我们优化前账单的 2~4 倍。因此降本增效的潜力只高不低。


你的有没有试过用“云原生”应用的方式上云?

云原生,通常与 “Lift and Shift” 对照,被吹捧为充分利用云上优点的正道坦途,但这不过是又一坨云营销废话。云原生基本以一个错误的信念作为核心 —— 即 Serverless函数以及各种按需使用的工具能让用户节约成本。但如果你需要一斤糖,独立包装的单块小方糖并不会更省钱。我在《不要被Serverless耍了》以及《即使是亚马逊也整不明白微服务Serverless》这两篇文章中写过这一点。


那么安全性呢?你不担心被黑客攻击吗?

在互联网上运营软件时面临的大多数安全问题,都源自应用及其直接的依赖组件。无论你是从云供应商那里租电脑来跑应用,还是自己拥有这些服务器,确保安全所需的工作并没有本质区别。

如果说有的话,那么在云上运维服务可能会给人们一种错误的安全感 —— 以为安全并不是他们应该操心的问题 —— 而事实绝非如此!

现代容器化应用交付的一个显著优势是,你不再需要花费大量时间手动给机器打补丁了。大部分工作都包含在 Dockerfile 中,你可以在最新的 Ubuntu 或其他操作系统上部署运行最新的应用程序版本 —— 无论你是在云上租用机器还是管理自己的机器,这个过程都是一样的。


不需要一支世界级超级工程师团队来干这些吗?

我从来都是毫不避讳地炫耀夸赞我们在 37signals 的优秀团队,我衷心地为我们组建的团队感到骄傲。但声称自己运维硬件是因为他们拥有一些特殊的魔法洞察力,那是在是太过于傲慢了。

互联网发展始于 1995 年,而云成为默认选项最早不会超过2015年。所以在超过二十年的时间里,公司们都在自己运维硬件来跑应用。这并不是什么已经失传的古代知识 —— 我们确实不知道金字塔到底是如何建造的,但对于如何将一台 Linux 机器连接到互联网,我们还是门儿清的。

此外,在自有硬件上运营所需的专业知识,和在云上靠租赁运营所需的专业知识,有90%是相同的。至少在我们这种数百万用户,每月几十万美元账单的规模时是这样的。


这是否意味着你们在建造自己的数据中心?

除了谷歌、微软和Meta等少数几家巨无霸公司,没人会自建数据中心。其他人都只是在专业数据中心(例如Equinix)那里租用几个机柜、一个机房或者一整层楼。

所以,拥有你自己的硬件,并不意味着你要去操心安全、电力供应、灭火系统,以及其他各种设施与细节 —— 这些设施的建设投入可能要耗资数亿美元。


谁来做码放服务器、拔插网络电缆这些活呢?

我们使用一家名为 Deft 的白手套数据中心代维服务商。还有无数类似的公司。你付费给他们,让他们把戴尔或其他公司的服务器拆箱,直接放入数据中心,然后将服务器上架,你就能看到新的 IP 地址蹦出来,就像云一样,只不过它不是即时的。

我们的运维团队基本上从未踏足这些数据中心。他们在全球各地远程工作。与互联网早期每个人都自己拉线缆的时候相比,现在这种运营体验才更称得上是“云”。


那么可靠性呢?难道云不是为你做到了这些吗?

当我们在云上运营时,使用了两个地理分隔的区域,也在每个区域内实现了大量的冗余。当我们下云后,也干了一模一样的事情。我们在两个地理分隔的数据中心托管自己的硬件,每个数据中心都能承载我们所需的全部负载,而且每个关键基础设施都有副本。

可靠性在很大程度上取决于冗余,你应该能随时失去任何一台计算机、任何一个组件,而不会造成问题。我们在云上拥有这种能力,而现在使用自己的硬件时也一样。


那国际化业务的性能呢?云不是更快吗?

我们先前的云部署使用了两个不同区域,都在美国国内,也用了一个在全球各地都拥有本地边缘节点的CDN网络。与可靠性上的问题一样:我们在下云后也是使用两个美国区数据中心,并使用国际CDN来加速内容交付。

从根本上说,云上云下的挑战是一样的。国际足迹的难点通常不在于配置硬件与确保数据中心安全上,而是你的应用程序需要处理多个主数据库写入,应对复制延迟,以及其他各种让应用在全球网络上高速运行所需的有趣工作。

我们目前正在欧洲为 HEY 规划一个数据中心前哨站,我们把这些配置的活儿留给了 Deft 的朋友们。正如所有硬件采购一样,它的交付速度确实要比云慢。对于 “我想在日本上线10台服务器,并在30秒后看到它” 这种事来说,没有谁能比得上云,这确实很了不起。

但对我们这类业务来说,为这种即时拉起的弹性能力支付疯狂的巨额溢价根本不值得。等几周才能看到服务器上线,对我们来说是一个完全可以接受的利弊权衡。


你有考虑到以后更换服务器的成本吗?

是的,我们的数学计算是基于服务器可以正常使用五年的工作假设。这是相当保守的,我们有服务器跑了七八年仍然表现很好。但大多数人还是会用五年作为时间范围,因为财务摊销计算上会更方便。

这里的关键点是:我们花了60万美元购买了大量新服务器。而下云节省的费用已经让这项投资已经回本了!所以如果明年出现了一些惊人的技术突破,我们又想再买一堆新东西,我们也毫无压力,在成本优势上依旧遥遥领先。


那么隐私法规和GDPR呢?

云在隐私合规和GDPR上并没有提供任何真正优势。如果说有,反而是有负面影响,因为所有主要的超大规模云服务商都是美国的。所以,如果你在欧洲,并且从微软、亚马逊或谷歌等公司购买云服务,你必须面对这个现实:美国政府可以合法强迫这些供应商交出数据和记录。我在《美国数据间谍永远不会关心服务器到底在哪里》一文中详细说明过这一点。

作为一家在欧洲运营的公司,如果严格遵守GDPR对你很重要,那么你最好还是拥有自己的硬件,并放在欧洲的数据中心供应商那里运行。


那么需求激增时怎么办?自动伸缩呢?

在自己采购硬件时最令我们震惊的是,我们终于意识到了现代硬件到底有多么强大与便宜。仅仅过去四五年中的进步就已经非常巨大了,这也是云变成一门糟糕生意,一年不如一年的一个重要原因。在摩尔定律的指数规律下,用户能从戴尔和其他厂商买到的产品价格不断下降,能力不断增强。但摩尔定律对对亚马逊和其他公司的云托管服务价格几乎没有任何影响。

这也就是说,你可以买得起极为夸张的超配硬件,让你有在面对尖峰时游刃有余,却几乎不会对长期预算有任何影响。

不过,如果你确实经常面临超出基线需求 5~10倍或更高的峰值,那你也许是潜在的云客户。毕竟,这也是 AWS 最初诞生的动机。亚马逊在“黑色星期五”或“双十一”所需的性能远远远远超过他们一年中其他时间,所以灵活弹性的硬件对他们是有意义的。

但你也可以用混合搭配的方式来做这件事。俗话说,“买基线,租尖峰”。绝大多数公司根本不需要操心这种事,只需要关注使用情况,根据增长曲线提前采购一些强力服务器就够了。如果确实需要计划外的扩容,一周时间就足够拉起一整个新的服务器舰队了。


你在服务合同和授权许可费上花费了多少?

啥也没有。在互联网上运行应用所需的一切,通常都以开源的形式提供。我们所有的东西都是之前云服务的开源版本。我们的 RDS 数据库变成了 MySQL 8。我们的 OpenSearch 变成了开源的 ElasticSearch。

有些公司确实可能喜欢服务合同带来的舒适感,市场上有很多供应商可以提供这类服务。我们会不定期使用来自Percona 的 MySQL 专家的优秀服务。而这不会对底层逻辑有什么根本性改变。

你确实应当尽可能远离那些高度“企业化”的服务机构,通常来说,如果他们的客户名单上有银行或者政府,你就应该去别处看看,除非你确实喜欢烧钱。


如果云这么贵,你们为什么会选择它?

因为我们相信了云营销画的大饼:更便宜、更简单、更快。对我们来说,只有最后一个承诺真正实现了。在云上,你确实可以很快地拉起一大堆服务器,但这并不是我们会经常做的事,所以不值得为此付出巨大的溢价。

我们花了几年的时间试图解锁“规模经济”与“简单易用”这两个云技能点,但从没实现过。托管服务仍然需要管理,而摩尔定律带来的硬件进步很少能透过云厂商这一层来节约“我们”的成本。

事后看来,在云上跑一把其实还是挺不错的:我们学到了很多东西,也改进了自己的工作流程。但我确实希望能够提早几年就把这个账给算清楚。


我还有其他问题想问你!

请给我发邮件: dhh@hey.com,对于大家普遍感兴趣的问题,我会在这里更新。

从降本增笑到真的降本增效

微信公众号原文

年底正是冲绩效的时间,互联网大厂大事故却是一波接一波。硬生生把降本增效搞成了“降本增笑” —— 这已经不仅仅是梗了,而是来自官方的自嘲。

smile.png

双十一刚过,阿里云就出了打破行业纪录的全球史诗级大翻车,然后开始了11月连环炸模式,在几次小故障后,又来了一场云数据库管控面跨国俩小时大故障—— 从月爆到周爆再到日爆。

但话音未落,滴滴又出现了一场超过12小时的大失效,资损几个亿 —— 替代品阿里旗下的高德打车直接爆单赚翻,堪称失之桑榆,收之东隅。

didi.png

我已经替装死的阿里云做过复盘了《我们能从阿里云史诗级故障中学到什么》:Auth因为配置失当挂了,推测根因是 OSS/Auth 循环依赖,一改错黑白名单就死锁了。

滴滴的问题,据说是 Kubernetes 升级大翻车。这种惊人的恢复时长通常会与存储/数据库有关,合理推测根因是:不小心降级了 k8s master ,还一口气跳了多个版本 —— etcd 中的元数据被污染,最后节点全都挂掉,而且无法快速回滚。

故障是难以避免的,无论是硬件缺陷,软件Bug,还是人为操作失误,发生的概率都不可能降为零。然而可靠的系统应当具有容错韧性 —— 能够预料并应对这些故障,并将冲击降低至最小程度,尽可能缩短整体失效时间。

不幸的是在这一点上,这些互联网大厂的表现都远远有失水准 —— 至少实际表现与其声称的 “1分钟发现、5分钟处置、10分钟恢复” 相距甚远。


降本增笑

根据海因法则,一起重大故障背后有着29次事故,300次未遂事故以及数千条事故隐患。在民航行业如果出现类似的事情 —— 甚至不需要出现真正造成任何后果的事故,只是连续出现两起事故征候 —— 甚至都还不是事故,那么可怕严厉的行业安全整顿马上就会全面展开。

prymaid.jpg

可靠性很重要,不仅仅是对于空中交管/飞行控制系统这类关键服务而言,我们也期望更多平凡的服务与应用能够可靠地运行 —— 云厂商的全局不可用故障几乎等同于停电停水,出行平台宕机意味着交通运力网络部分瘫痪,电商平台与支付工具的不可用则会导致收入和声誉的巨大损失。

互联网已经深入至我们生活的各个层面,然而针对互联网平台的有效监管还没有建立起来。行业领导者在面对危机时选择躺尸装死 —— 甚至都没人出来做一个坦率的危机公关与故障复盘。没有人来回答;这些故障为什么会出现?它们还会继续出现吗?其他互联网平台为此做了自纠自查了吗?它们确认自己的备用方案依然有效了吗?

这些问题的答案,我们不得而知。但可以确定的是,毫无节制堆积复杂度与大规模裁员的恶果开始显现,服务故障失效会越来越频繁以至于成为一种新常态 —— 谁都随时可能会成为下一个惹人笑话的“倒霉蛋”。想要摆脱这种暗淡的前景命运,我们需要的是真正的“降本增效”


降本增效

当故障出现时,都会经过一个 感知问题,分析定位,解决处理 的过程。所有的这些事情都需要系统的研发/运维人员投入脑力进行处理,而在这个过程中有一条基本经验法则:

处理故障的耗时 t =

系统与问题复杂度 W / 在线可用的智力功率 P

故障处理的优化的目标是尽可能缩短故障恢复时间 t ,例如阿里喜欢讲的 “1-5-10” 稳定性指标:1 分钟发现、5 分钟处置、10 分钟恢复,就是设置了一个时间硬指标。

time-limit.png

在时间限制死的情况下,要么降本,要么增效。只不过,降本要降的不是人员成本,而是系统的复杂度成本;增效不是增加汇报的谈资笑料,而是在线可用的智力功率与管理的有效性。很不幸的是,很多公司这两件事都没做好,活生生地把降本增效搞成了降本增笑。


降低复杂度成本

复杂度有着各种别名 —— 技术债,屎山代码,泥潭沼泽,架构杂耍体操。症状可能表现为:状态空间激增、模块间紧密耦合、纠结的依赖关系、不一致的命名和术语、解决性能问题的 Hack、需要绕开的特例等等。

复杂度是一种成本,因而简单性应该是构建系统时的一个关键目标。然而很多技术团队在制定方案时并不会将其纳入考虑,反而是怎么复杂怎么来:能用几个服务解决的任务,非要用微服务理念拆分成几十个服务;没多少机器,却非要上一套 Kubernetes 玩弹性杂耍;单一关系型数据库就能解决的任务,非要拆给几种不同的组件或者倒腾个分布式数据库。

这些行为都会引入大量的额外复杂度 —— 即由具体实现中涌现,而非问题本身固有的复杂度。一个最典型的例子,就是许多公司不管需要不需要,都喜欢把什么东西都往 K8S 上怼,etcd / Prometheus / CMDB / 数据库,一旦出现问题就循环依赖大翻车,一次大挂就彻底起不来了。

再比如应该支付复杂度成本的地方,很多公司又不愿意支付了:一个机房就放一个特大号 K8S ,而不是多个小集群灰度验证,蓝绿部署,滚动升级。一个版本一个版本的兼容性升级嫌麻烦,非要一次性跳几个版本。

在畸形的工程师文化中,不少工程师都会以傻大黑粗的无聊规模与高空走钢丝的架构杂耍为荣 —— 而这些折腾出来欠下的技术债都会在故障的时候变为业报回来算账。

智力功率” 则是另一个重要的问题。智力功率很难在空间上累加 —— 团队的智力功率往往取决于最资深几个灵魂人物的水平以及他们的沟通成本。比如,当数据库出现问题时需要数据库专家来解决;当 Kubernetes 出现问题时需要 K8S 专家来看问题;

然而当你把数据库放入 Kubernetes 时,单独的数据库专家和 K8S 专家的智力带宽是很难叠加的 —— 你需要一个双料专家才能解决问题。认证服务和对象存储循环依赖也同理 —— 你需要同时熟悉两者的工程师。使用两个独立专家不是不可以,但他们之间的协同增益很容易就会被平方增长的沟通成本拉低到负收益,故障时人一多就变傻就是这个道理。

当系统的复杂度成本超出团队的智力功率时,就很容易出现翻车性的灾难。然而这一点在平时很难看出来:因为调试分析解决一个出问题的服务的复杂度,远远高于将服务拉起运行的复杂度。平时好像这里裁两个人,那里裁三个,系统还是能正常跑的嘛。

然而组织的默会知识随着老司机离开而流失,流失到一定程度后,这个系统就已经是期货死人了 —— 只差某一个契机被推倒引爆。在废墟之中,新一代年轻嫩驴又逐渐变为老司机,随即丧失性价比被开掉,并在上面的循环中不断轮回。


增加管理效能

阿里云和滴滴招不到足够优秀的工程师吗?并不是,而是其的管理水平与理念低劣,用不好这些工程师。我自己在阿里工作过,也在探探这种北欧风格的创业公司和苹果这样的外企待过,对其中管理水平的差距深有体会。我可以举几个简单的例子:

第一点是值班OnCall,在 Apple 时,我们的团队有十几号人分布在三个时区:欧洲柏林,中国上海,美国加州,工作时间首尾衔接。每一个地方的工程师都有着完整处理各种问题的脑力功率,保证任一时刻都有在工作时间待命OnCall的能力,同时也不会影响各自的生活质量。

而在阿里时,OnCall 通常变成了研发需要兼任的职责,24小时随时可能有惊喜,即使是半夜告警轰炸也屡见不鲜。本土大厂在真正可以砸人的地方,反而又吝啬起来:等研发睡眼惺忪爬起来打开电脑连上 VPN,可能已经过去好几分钟了。在真正可以砸人砸资源解决问题的地方反而不砸了。

第二点是体系建设,比如从故障处理的报告看,核心基础设施服务变更如果没测试,没监控,没告警,没校验,没灰度,没回滚,架构循环依赖没过脑袋,那确实配得上草台班子的称号。还是说一个具体的例子:监控系统。设计良好的监控系统可以极大缩短故障的判定时间 —— 这种本质上是对服务器指标/日志提前进行了数据分析,而这一部分往往是最需要直觉、灵感与洞察力,最耗费时间的步骤。

定位不到根因这件事,就是反映出可观测性建设和故障预案不到位。拿数据库举个例子吧,我做 PostgreSQL DBA 的时候做了这个监控系统 (https://demo.pigsty.cc[1]),如左图,几十个Dashboard 紧密组织,任何PG故障用鼠标点点下钻两三层,1分钟不用就能立即定位各种问题,然后迅速按照预案处理恢复。

monitor-1.png

monitor-2.png

再看一下阿里云 RDS for PostgreSQL 与 PolarDB 云数据库用的监控系统,所有的东西就这可怜巴巴的一页图,如果他们是拿这个玩意来分析定位故障,那也怪不得别人要几十分钟了。

第三点是管理理念与洞察力,比如,稳定性建设需要投入一千万,总会有机会主义的草台班子跳出来说:我们只要五百万或者更少 —— 然后可能什么都不做,就不会出现问题,赌赢了就白赚,赌输了就走人。但也有可能,这个团队有真本事用技术降低成本,可是又有多少坐在领导位置上的人有足够的洞察力可以真正分辨这一点呢?

再比如,高级的故障经验对于工程师和公司来说其实是一笔非常宝贵的财富 —— 这是用真金白银喂出来的教训。然而很多管理者出了问题第一时间想到的就是要“开个程序员/运维祭天”,把这笔财富白送给下家公司。这样的环境自然而然就会产生甩锅文化、不做不错、苟且偷安的现象。

第四点是以人为本。以我自己为例,我在探探将自己作为 DBA 的数据库管理工作几乎全自动化了。我会做这件事的原因:首先是我自己能享受到技术进步带来的红利 —— 自动化自己的工作,我就可以有大把时间喝茶看报;公司也不会因为我搞自动化每天喝茶看报就把我开了,所以没有安全感的问题,就可以自由探索,一个人搞出一整套完整的 开源 RDS 出来。

但这样的事在类似阿里这样的环境中可能会发生吗?—— “今天最好的表现是明天最低的要求” ,好的,你做了自动化对不对?结果工作时间不饱和,管理者就要找一些垃圾活或者垃圾会议给你填满;更有甚者,你幸幸苦苦的建立好了体系,把自己的不可替代性打掉了,立即面临狡兔死走狗烹的结果,最后被干写PPT出嘴的人摘了桃子。那么最后的博弈优势策略当然是能打的出去单干,能演的坐在列车上摇晃身体假装前进直到大翻车。

最可怕的是,本土大厂讲究人都是可替换的螺丝钉,是35岁就“开采完”的人矿,末位淘汰大裁员也不鲜见。如果 Job Security 成为迫在眉睫的问题,谁还能安下心来踏实做事呢?

孟子曰:“君之视臣如手足,则臣视君如腹心;君之视臣如犬马,则臣视君如国人;君之视臣如土芥,则臣视君如寇仇”。这种落后的管理水平才是很多公司真正应该增效的地方。

重新拿回计算机硬件的红利

微信公众号原文

在当下,硬件重新变得有趣起来,AI 浪潮引发的显卡狂热便是例证。但有趣的硬件不仅仅是 GPU —— CPU 与 SSD 的变化却仍不为大多数开发者所知,有一整代软件开发者被云,或者炒作营销遮蔽了双眼,难以看清这一点。

许多软件与商业领域的问题的根都在硬件上:硬件的性能以指数模式增长,让分布式数据库几乎成为了伪需求。硬件的价格以指数模式衰减,让公有云从普惠基础设施变成了杀猪盘。基本工作假设的变化,将触发软件技术的重估。是时候拨云见日,正本清源了。让我们重新认识现代硬件,拿回属于用户自己的红利。


性能翻天的新硬件

如果您有一段时间没有接触过计算机硬件了,那么当下新硬件的这些数字可能会让你感到非常震惊。

曾几何时,Intel 的 CPU 以挤牙膏著称,每一代进步微乎其微,老电脑可以再战一年又一年。但最近,处理器演进的速度骤然加快,增益不再是“微不足道”,核数在迅速增长,单核性能定期跃进 20-30%,而且这种进步积累的很迅速。

例如,AMD 刚刚发布的桌面级 CPU Threadripper 7995WX ,单颗 96核192线程 ✖️ 2.5 ~ 5.1 GHz 的性能怪兽,亚马逊零售价也才 5600美元。服务器 CPU 霄龙 EPYC 系列:上一代 EPYC Genoa 9654 ,单颗 96核192线程✖️ 2.4 ~ 3.55 GHz ,Amazon 零售价 3940美元。今年新款霄龙9754 更是干到了单 CPU 128 核 256 线程,这意味着一台普通双插槽服务器的 vCPU 数量可以达到惊人的 512 核!要是按云计算/容器平台 500% 超卖率,可以虚拟出两千五百多台1核虚拟机来。

epyc-list.png

SSD/NVMe 存储上的代际跃进幅度甚至比 CPU 领域还要大得多。我们快速地从Gen2约 500MB/s 的速度,到 Gen3 约2.5GB/s,再到目前主流 Gen4约 7GB/s;Gen5的 14GB/s 的SSD 刚开始崭露头角,Gen6 标准已经发布,Gen7 在路上,I/O带宽以翻倍的方式指数增长。

io-bandwidth.png

以 Gen5 NVMe SSD: KIOXIA CM7 为例,128K顺序读带宽 14GB/s 写带宽 7GB/s,4K 随机IOPS 读 2.7M,写 600K。恐怕现在还没有多少数据库软件能够充分利用这种疯狂的读写带宽与 IOPS。作为一个简单对照,我们回头看一下机械硬盘的带宽和IOPS,机械硬盘总体读写带宽大概在一两百 MB/s 上下浮动。7200 转硬盘的 IOPS 约几十,15000 转硬盘的 IOPS约为一两百。是的,NVMe SSD 的I/O带宽速率已经比机械硬盘好了四个数量级,整整一万倍。

在数据库最关注的 4K 随机读/写响应时间上,NVMe SSD 的经典值为 读 55µs / 写 9μs,前几代就有这个水平了。这是什么概念?还是和机械硬盘比一下:机械硬盘的寻道时间通常在 10ms 量级, 平均旋转延迟视转速在 2ms - 4ms,那么一次 I/O 通常需要十几个毫秒。十几毫秒 vs 55/9µs ,NVMe SSD 比机械磁盘快了三个数量级 —— 一千倍!

除了计算与存储外,网络硬件当然也有进步,40GbE 和 100 GbE 已经烂大街了 —— 一个 100Gbps 的光模块网卡也就万把块钱,而 12 GB/s 的网络传输速度比老程序员耳熟能详的千兆网卡快了整整一百倍

Ethernet Speeds: chart

计算,存储,网络硬件的性能继续以摩尔定律指数增长的方式在演进与发展,硬件领域重新变得有趣起来。但更有趣的是硬件的技术飞跃会为世界带来什么?


分布式不再受待见

硬件十年间的变化已称得上沧海桑田,而这让许多软件领域的工作假设都不再成立:比如分布式数据库

在当下,一台普通x86服务器的能力上限已经达到了一个非常惊人的程度 —— 这里有一个有趣的草稿演算,粗略证明了将整个 Twitter 运行在一台这样的现代服务器上的可行性( Dell PowerEdge R740xd,32C,768GB RAM、6TB NVMe、360TB HDD、GPU插槽和 4x40Gbe 网络)。尽管出于生产冗余的考虑你并不会这么做(用两台或者三台可能更保险一些),但这个计算确实抛出了一个很有趣的问题 —— 可伸缩性是否还是一个真正的问题?

在本世纪初,一台 Apache 服务器只能处理很可怜的一两百个并发请求。最优秀的软件也很难处理上万的并发 —— 业界有个著名的 C10K 高并发问题,谁要是能做到几千并发,那就是业界高手。但随着 Epoll 和 Nginx 在 2003/2004 年相继问世,“高并发” 不再是什么难题了 —— 随便一个小白只要学会配置 Nginx,就可以达到前几年大师们做梦都不敢想的程度。 《云厂商眼中的客户又穷又闲又缺爱

在 2023 年的今天,硬件的冲击让同样的事再次发生在分布式数据库上:可伸缩性(Scalability)和二十年前的 C10K 一样,成为了一个已解决的历史问题。如果推特这样的业务可以跑在单台服务器上,那么99.xxxx+ % 的业务终其生命周期,对可伸缩性的需求都不会超出这样一台服务器。这意味着这些大厂高P骄傲的“分布式”独门技术,在新硬件的冲击下沦为鸡肋 —— 如果今天还有人津津乐道什么分表分布式海量规模高并发,只能说明一件事:这哥们过去十年已经停止学习了,还活在上一个时代里。

分布式数据库的基本工作假设是:单机的处理能力不足以支撑负载。然而这个支撑其存在价值的基础工作假设,在当代硬件面前已经不攻自破 —— 集中式数据库甚至什么都不用做,它的载荷上限就自动拔高到绝大多数务终生都无法触及的地步。确实会有人抬杠说,我们微信/我们支付宝就是有这样的场景需要分布式数据库 —— 且不说这个问题是不是一定要分布式数据库来解决,就算我们假设这种屈指可数的极端场景足够养活一两个分布式TP内核,但在网络硬件的性价比重新击溃磁盘前,分布式 OLTP 数据库已经不可能是数据库领域发展的大方向了。作为大厂“分布式”显学代表的阿里巴巴就是一个很有代表性的例子 —— 数据库长子 OceanBase 选择了分布式的道路,而现在的数据库太子,二儿子 PolarDB 就又及时调整了方向,重新回到集中式的架构上。

在大数据分析 OLAP 领域,分布式以前也许可能是必不可少的,但是现在也存疑 —— 对绝大多数企业来说,其全部数据库量很有可能都能在一台服务器内完成处理。以前非要“分布式数仓”不可的场景,在当代的单机服务器上跑一个 PostgreSQL 或者 DuckDB 很可能就解决掉了。诚然像大型互联网公司确实有 PB/ZB 级别的数据场景,但即使是互联网核心业务,其单一业务数据量级也极少有超过单机处理极限的案例。举个例子,BreachForums 最近泄漏的淘宝5年的(2015-2020 82亿)购物记录数据大小压缩后 600GB,同理,京东百亿,拼多多 145亿数据的大小也基本类似。更何况,戴尔或者浪潮都提供了PB级的NVMe全闪存储机柜产品,整个美国保险行业的全量历史数据和分析任务就可以塞进这样单个盒子,而只要二十万美元不到的价格。

分布式数据库的核心权衡是:“以质换量”,牺牲功能、性能、复杂度、可靠性,换取更大的数据容量与请求吞吐量。然而“过早优化是万恶之源 ”,为了不需要的规模去设计是白费功夫。如果量不再成为问题,那么为了不需要的量去牺牲其他属性,付出额外的复杂度成本与金钱成本就成了一件毫无意义的事情。


自建服务器的成本

新硬件的性能如此强悍,那成本又如何呢?摩尔定律指出,每18~24个月,处理器性能翻倍,成本减半。比起十年前,新硬件性能更加强悍,但价格反而更加便宜了。

在 《下云奥德赛:是时候放弃云计算了吗?》中,我们有一个新鲜的公开采购样本可以参考。下云先锋 DHH,37 Signal 在 2023 年为下云采购了一批物理机:他们从戴尔买了20台服务器:共计 4000核 vCPU,7680GB 的内存,以及 384TB 的 NVMe 存储,加上其他东西,总开销为50万美金。每台服务器具体配置规格为:Dell R7625 服务器,192 vCPU / 384 GB 内存:两颗 AMD EPYC 9454 处理器(48核/96线程,2.75 GHz),配置 2 x vCPU 的内存(16根32G内存条),一块12 TB NVMe Gen4 SSD,再加上其他配件,一台服务器成本两万美金($19,980),按五年摊销是 333美元/每月

想要核实这个报价的有效性,我们可以直接参考零售市场上核心组件的价格:CPU 是 EPYC 9654 ,当下零售价格 3,725 美元一颗,两颗总计 $7450。32GB 的 DDR5 ECC 服务器内存,零售单价 $128 一条,16条共计 $2048。企业级 NVMe SSD 12TB,售价 $2390。 100G 光模块 100GbE QSFP28 报价 1804 美元,加起来 $13692 左右,加上服务器准系统,电源,系统盘,RAID 卡,风扇之类的东西,总价两万美元内并没有没什么问题。

当然,服务器并不是只有 CPU ,内存,硬盘,网卡这些零件,我们还需要考虑总体持有成本。数据中心里还要为这些机器提供电力、机位和网络,代维费用,并预留冗余量(美国的价格)。这部分开销核算后基本与每月硬件摊销费用持平,所以每台 192C / 384G / 12T NVMe 存储的服务器每月综合成本为 666 美元,核月人民币成本为 25.32 元

我相信 DHH 给出这个数字没有什么问题,因为在探探时,我们从第一天就选择用 IDC / 资源云自建,经过了多轮成本优化做到的极致状态也差不多是这个价格 —— 我们采购的数据库服务器机型(Dell R730, 64 vCPU / 512GB / 3.2 TB NVMe SSD)加上人力代维网电成本后一台 TCO 约为七万五人民币,按五年摊销计算核月成本为 19.53 元。这里有一个表格,可以作为单位算力的价格参考:

EC2 核·月 RDS 核·月
DHH 自建核月价格(192C 384G) 25.32 初级开源数据库DBA参考工资 15K/人·月
IDC自建机房(独占物理机: 64C384G) 19.53 中级开源数据库DBA参考工资 30K/人·月
IDC自建机房(容器,超卖500%) 7 高级开源数据库DBA参考工资 60K/人·月
UCloud 弹性虚拟机(8C16G,有超卖) 25 ORACLE 数据库授权 10000
阿里云 弹性服务器 2x内存(独占无超卖) 107 阿里云 RDS PG 2x内存(独占) 260
阿里云 弹性服务器 4x内存(独占无超卖) 138 阿里云 RDS PG 4x内存(独占) 320
阿里云 弹性服务器 8x内存(独占无超卖) 180 阿里云 RDS PG 8x内存(独占) 410
AWS C5D.METAL 96C 200G (按月无预付) 100 AWS RDS PostgreSQL db.T2 (2x) 440
AWS C5D.METAL 96C 200G (预付三年) 80 AWS RDS PostgreSQL db.M5 (4x) 611
AWS C7A.METAL 192C 384G (预付三年) 104.8 AWS RDS PostgreSQL db.R6G (8x) 786

与云租赁价格对比

作为参照,我们可以用 AWS EC2 算力租赁的价格作为对比。666 美元每月的价格,能买到的最好规格是不带存储的 c6in.4xlarge 按需机型(16核32G x 3.5GHz);而存算规格与这类服务器相同(192C/384G)但不含 EBS 存储的 c7a.metal 机型,按需费用是 7200 美元每月,是本地自建综合成本的 10.8 倍;预付3年的保留实例最低可以到 2756 美元每月,也就是自建服务器的 4.1 倍。如果我们以 vCPU / month 的方式计算核月成本,AWS 绝大多数 EC2 实例的核月单价在 $10 ~ $30 美元之间,也就是一两百块,所以我们可以得出一个粗略的计算结论:云上算力的单位价格是自建的 5 ~ 10 倍

请注意,这里的价格并没有包括百倍溢价的 EBS 云盘存储。 在《云盘是不是杀猪盘》中,我们已经详细对比过企业级 SSD 和等效云盘的成本了。这里我们可以给出两个当下更新后的参考值,DHH 采购的 12TB 企业级 NVMe SSD(以质保五年算)TB·月成本为 24 元,而 GameStop 上零售的三星消费级 SSD 990Pro的TB·月价格更是能达到惊人的 6.6 元 …。而AWS与阿里云的对应块存储TB·月成本在满打满算折扣的情况下分别为 1900 与 3200。在最离谱的情况下(6400 vs 6.6 )溢价甚至可以达到千倍,不过更为 Apple to Apple 的对比结果:云上块存储的单位价格是自建的 100 ~ 200 倍 (且性能并没有本地磁盘好)。

EC2 和 EBS 的价格可以说是云服务的定价之锚,例如,主要使用 EC2 与 EBS 的云数据库 RDS 相对于本地自建的溢价倍率,视您的存储使用量就在两者间波动:云数据库的单位价格是自建的十几倍到几十倍。详情请参考《云数据库是不是智商税》。

当然,我们也不能否认公有云在小微实例,企业初创起步阶段的成本优势 —— 比如,公有云上用来凑补的 1~2C,0.5~2G 的 nano 实例确实是可以按照十几块钱核月的成本价给到用户。在《薅阿里云ECS羊毛,打造数字家园》中我推荐薅阿里云双十一99虚拟机也是这个原因。比如,以五倍超卖算 2C 2G 的服务器算力成本是每年84元,40G 云盘价按三副本算一年存储成本20来块钱,光这两部分每年成本就过百了。这还没有算公网IP,以及价值更高的 3M 带宽(例如你真能把3M带宽24小时打满,那么一天流量32G就是25块钱)。这种云服务器的列表价是 ¥1500 一年,因此 99¥ 并允许低价续费用4年,确实可以算得上赔本赚吆喝的福利了。

但是当你的业务不再是一大堆小微实例可以 cover 的时候,你真的应该重新仔细再算一下账:在几个关键例子上,云的成本都极其高昂 —— 无论是大型物理机数据库、大型 NVMe 存储,或者只是最新最快的算力。租用生产队的驴价钱是如此高昂 —— 以至于几个月的租金就能与直接购买它的价格持平。在这种情况下,你确实应该直接把这头驴买下来!


重新拿回硬件红利

我还记得在 2019年4月1日,国内增值税正式从 16% 下调到 13% ,苹果官网随即全面降价,单品最高降价幅度达8% —— iPhone的几个典型产品都降了500元,把税点返还给了用户。但也有许多厂商选择装聋作哑维持原价,自己吃下这个福利 —— 白花花的银子怎么能随便散给穷人呢?类似的事情就发生在云计算领域 —— 硬件成本的指数下降,并没有在云厂商的服务价格上充分反映出来,而这一点,让公有云逐渐从普惠基础设施变成了垄断集中杀猪盘。

上古时代,开发者写代码是要深入了解硬件的,然而老一辈带有硬件 Sense 的工程师与程序员,很多已经退休/转岗/转管理/停止学习了。后来操作系统与编译器技术愈发强大,各种VM编程语言出现,软件已经完全不需要关心硬件是如何完成指令的;再后来,EC2 封装了算力,S3 / EBS 封装了存储,应用开始和 HTTP API 而不是系统调用打交道。软件和硬件分离为两个世界,分道扬镳。整整一代新工程师原生成长在云环境中,被屏蔽掉了对计算机硬件的了解。

然而事情正在起变化,硬件重新变得有趣起来,而云厂商是没有办法把这部分红利永远掩藏遮盖下去 —— 聪明的人开始计算数字,勇敢的人已经采取行动。像马斯克和DHH这样的先锋已经充分认识到了这一点,从云上下来,脚踏实地 —— 直接产生千万美元的财务效益,并且还有性能上的回报,运营也变得更加自主独立。越来越多的人会同样意识到这一点,追随先驱者做出明智的选择,拿回属于自己的硬件红利。

我们能从阿里云全球故障中学到什么?

微信公众号原文 | Vonng博客

时隔一年阿里云又出大故障,并创造了云计算行业闻所未闻的新记录 —— 全球所有区域/所有服务同时异常。阿里云不愿意发布故障复盘报告,那我就来替他复盘 —— 我们应当如何看待这一史诗级故障案例,以及,能从中学习到什么经验与教训?


事实是什么?

2023年11月12日,双十一后第一天,阿里云出了一场史诗级大翻车全球所有区域同时出现故障,创造了闻所未闻的行业新记录。根据阿里云官方的服务状态页,全球所有区域/可用区 ✖️ 所有服务全部出现异常,时间范围从 17:44 到 21: 11 ,共计三个半小时。

status-page.jpg

阿里云 Status Page

阿里云公告称:

“云产品控制台、管控API等功能受到影响,OSS、OTS、SLS、MNS等产品的服务受到影响,大部分产品如ECS、RDS、网络等的实际运行不受影响”。

大量依赖阿里云服务的应用 APP,包括阿里系自己的一系列应用:淘宝,钉钉,闲鱼,…… 都出现了问题。产生了显著的外部影响,APP崩了的新闻组团冲上了热搜。

yunpan.png

淘宝刷不出聊天图片,闪送上传不了接单凭据,充电桩用不了,原神发不出验证码,饿了么下不了单,骑手进不了系统,点不了外卖、停车场不抬杆、超市无法结账。甚至有的学校因此无法用智能公共洗衣机和开水机。无数在周末休息中的研发与运维人员被喊起来加班排障……

包括金融云、政务云在内的区域也没有幸免。阿里云应该感到万幸:故障不是发生在双十一当天,也不在衙门与钱庄的工作时间段,否则大家说不定能上电视看故障复盘了。


原因是什么?

尽管阿里云至今仍未给出一份事后故障复盘报告,但老司机根据爆炸半径,就足以判断出问题在哪里了 —— Auth (认证 / 鉴权 / RAM)。

存储计算硬件故障、机房断电这些问题最多影响单个可用区(AZ),网络故障最多影响一个区域(Region),能让全球所有区域同时出问题的肯定不会是这些问题,而是跨区域共用的云基础设施组件。 —— 极大概率是 Auth,低概率是其他诸如计量付费之类的全局性服务。

阿里云的事件进展公告:出问题的是某个底层服务组件,而不是网络、机房硬件的问题。

根因出在 Auth 上的可能性最大,最直接的证明就是:深度与 Auth 集成的云服务:对象存储 OSS (类 S3),表格存储 OTS (类 DynamoDB),以及其他深度依赖 Auth 的服务本身可用性直接受到影响。而本身运行不依赖 Auth 的云资源,比如云服务器 ECS / 云数据库 RDS 以及网络仍然可以“正常”运行,用户只是无法通过控制台与API对其进行管理与变更。此外,一个可以排除的计量付费服务出问题的现象是:故障期间还有用户成功付款薅下了ECS的羊毛

尽管上面的分析过程只是一种推断,但它与流传出来的内部消息相吻合:认证挂了导致所有服务异常。至于认证服务本身到底是怎么挂的,在尸检报告出来前我们也只能猜测:人为配置失误的可能性最大 —— 因为故障不在常规变更发布窗口中,也没有灰度生效,不太像是代码/二进制发布。但具体是哪里配置失误:证书,黑白名单,循环依赖死锁,还是什么其他东西,就不知道了。

关于根因的小道消息也漫天飞舞,比如,有人说 ”权限系统推了个黑名单规则,黑名单规则维护在OSS上,访问OSS叉需要访问权限系统,然后权限系统又要访问OSS,就尬住了“。”。还有人说 “这次双11期间,技术人员连续一周加班加点,双11一过,大家都松懈了。有个新手写了一段代码,更新了组件,导致了本次的断网“,还有人说:”阿里云所有服务用的都是同一个通配符证书,证书换错了“。

如果是因为这些原因导致 Auth 挂掉,那可真是草台班子到家了。尽管听上去很离谱,但这样的先例并不少。再次强调,这些路边社消息仅供参考,具体事故原因请以阿里云官方给出的复盘分析报告为准。


影响是什么?

认证/鉴权是服务的基石,像这样的基础性组件一旦出现问题,影响是全局性灾难性的。这会导致整个云管控面不可用,伤害会直接冲击控制台,API,以及深度依赖 Auth 基础设施的服务 —— 比如公有云上的另一个基石性服务,对象存储 OSS

从阿里云公告上来看,好像只有 “几个服务( OSS、OTS、SLS、MNS)受到影响,大部分产品如ECS、RDS、网络等的实际运行不受影响 ”。但对象存储 OSS 这样的基石性服务出了问题,带来的爆炸半径是难以想象的,绝非“个别服务受到影响” 就能敷衍过去 —— 这就像汽车的油箱都着火了,说发动机和轮子仍然在转是没有意义的。

对象存储 OSS 这样的服务是通过云厂商包装的 HTTP API 对外提供服务的,因此必然深度依赖认证组件:你需要AK/SK/IAM 签名才能使用这些 HTTP API,而 Auth 故障将导致这类服务本身不可用。

对象存储 OSS 实在是太重要了,可以说是云计算的“定义性服务”,也许是唯一能在所有云上基本达成共识标准的服务。云厂商的各种“上层”服务或多或少都直接/间接地依赖 OSS,例如 ECS/ RDS 虽然可以运行,但 ECS 快照和 RDS 备份显然是深度依赖 OSS 的,CDN 回源是依赖 OSS 的,各个服务的日志往往也是写入 OSS 的。

从现象上看,核心功能跟 OSS 深度绑定的阿里云盘就挂的很惨烈,核心功能跟 OSS 关系不大的服务,比如高德地图就没听说有什么大影响。大部分相关应用的状态是,主体可以正常打开运行,但是和图片展示,文件上传/下载文件这类有关的功能就不可用了。

有一些实践减轻了对 OSS 的冲击:比如通常被认为是不安全的 —— 不走认证的 Public 存储桶就不受影响;CDN 的使用也缓释了 OSS 的问题:淘宝商品图片走 CDN 缓存还可以正常看到,但是买家聊天记录里实时发送的图片直接走 OSS 就挂了。

不仅仅是 OSS,其他深度集成依赖 Auth 的云服务也会有这样的问题,比如 OTS,SLS,MNS 等等。例如对标 DynamoDB 的表格存储服务 OTS 同样出现了问题。这里有一个非常鲜明的对比,像 RDS for PostgreSQL / MySQL 这样的云数据库服务使用的是数据库自身的认证机制,所以不受云厂商 Auth 服务故障影响。然而 OTS 没有自己的权限系统,而是直接使用 IAM/RAM,与云厂商的 Auth 深度绑定,因此受到了冲击。

技术上的影响是一方面,更重要的是业务影响。根据阿里云的 服务等级协议(SLA),3个半小时的故障使得当月各服务可用性指标降至 99.5%。落入绝大多数服务赔偿标准的中间档位上,也就是赔偿用户月度服务费用 25% ~ 30% 的代金券。特殊的是这一次故障的区域范围和服务范围是全部

阿里云 OSS SLA

当然阿里云也可以主张说虽然 OSS / OTS 这些服务挂了,但他们的 ECS/RDS 只挂了管控面,不影响正在运行的服务,所以不影响 SLA。不过这种补偿即便真的全部落地也没几个钱,更像是一种安抚性的姿态:毕竟和用户的业务损失比,赔个服务月消 25%代金券简直就是一种羞辱

比起用户信任、技术声望以及商誉折损而言,赔的那点代金券真的算不上三瓜两枣。这次事件如果处理不当,会成为公有云拐点级别的标志性事件


评论与观点?

马斯克的推特 X 和 DHH 的 37 Signal 通过下云省下了千万美元真金白银,创造了降本增效的“奇迹”,让下云开始成为一种潮流。云上的用户在对着账单犹豫着是否要下云,未上云的用户更是内心纠结。在这样的背景下,作为本土云领导者的阿里云发生如此重大故障,对于犹豫观望者的信心无疑是沉重的打击。恐怕此次故障会成为公有云拐点级别的标志性事件

阿里云一向以安全稳定高可用自居,上周还刚在云栖大会上吹极致稳定性之类的牛逼。但是无数所谓的的灾备,高可用,多活,多中心,降级方案,被一次性全部击穿,打破了N个9神话。如此大范围、长时间、影响面如此广的故障,更是创下了云计算行业的历史记录。

这次故障揭示出关键基础设施的巨大风险,大量依托于公有云的网络服务缺乏最基本的自主可控能力:当故障发生时没有任何自救能力,除了等死做不了别的事情。甚至包括金融云和政务云在内也同样出现了服务不可用。同时,它也反映出了垄断中心化基础设施的脆弱性:互联网这个去中心化的世界奇观现在主要是在少数几个大公司/云厂商拥有的服务器上运行 —— 某个云厂商本身成为了最大的业务单点,这可不是互联网设计的初衷!

更为严峻的挑战恐怕还在后面,全球用户追索赔钱事还小,真正要命的是在各个国家都在强调数据主权的时候,如果因为在中国境内的某个控制中心配置失当导致全球故障的话,(即:你真的卡了别人的脖子)很多海外客户会立即采取行动迁移到别的云供应商上:这关乎合规性,与可用性无关

根据海恩法则,一次严重故障的背后有几十次轻微事故,几百起未遂先兆,以及上千条事故隐患。去年十二月阿里云香港机房的大故障已经暴露出来许多问题,然而一年后又给了用户一个更大的惊喜(吓!)。这样的事故对于阿里云的品牌形象绝对是致命打击,甚至对整个行业的声誉都有严重的损害。阿里云应该尽快给用户一个解释与交代,发布详细的故障复盘报告,讲清楚后续改进措施,挽回用户的信任。

cf-post-moterm.png

毕竟这种规模的故障,不是“找个临时工背锅,杀个程序员祭天” 能解决的事,CEO 得亲自出面道歉解决。Cloudflare 月初的管控面故障后,CEO 立即撰写了详细的事后复盘分析,挽回了一些声誉。可不幸的是,阿里云经过了几轮裁员,一年连换三轮 CEO ,恐怕已经难有能出来扛事背责的人了。


能学到什么?

往者不可留,逝者不可追,比起哀悼无法挽回的损失,更重要的是从损失中吸取教训 —— 要是能从别人的损失中吸取教训那就更好了。所以,我们能从阿里云这场史诗级故障中学到什么?


不要把鸡蛋放在同一个篮子里,准备好 PlanB,比如,业务域名解析一定要套一层 CNAME,且 CNAME 域名用不同服务商的解析服务。这个中间层对于阿里云这样的故障非常重要,用另外一个 DNS 供应商,至少可以给你一个把流量切到别的地方去的选择,而不是干坐在屏幕前等死,毫无自救能力。


区域优先使用杭州与北京,阿里云故障恢复明显有优先级,阿里云总部所在地的杭州(华东1)和北京(华北2)故障修复的速度明显要比其他区域快很多,别的可用区故障恢复用了三个小时,这两个可用区一个小时就修复了。这两个区域可以考虑优先使用,虽然同样是吃故障,但你可以和阿里自家业务享受同种婆罗门待遇。


谨慎使用需要云认证的服务:Auth 是云服务的基石,大家都期待它可以始终正常工作 —— 然而越是人们感觉不可能出现故障的东西,真的出现故障时产生的杀伤力就越是毁天灭地。如无必要,勿增实体,更多的依赖意味着更多的失效点,更低的可靠性:正如在这次故障中,使用自身认证机制的 ECS/RDS 本身就没有受到直接冲击。深度使用云厂商提供的 AK/SK/IAM 不仅会让自己陷入供应商锁定,更是将自己暴露在公用基础设施的单点风险里。


谨慎使用云服务,优先使用纯资源。在本次故障中,云服务受到影响,但云资源仍然可用。类似 ECS/ESSD 这样的纯资源,以及单纯使用这两者的 RDS,可以不受管控面故障影响可以继续运行。基础云资源(ECS/EBS)是所有云厂商的提供服务的最大公约数,只用资源有利于用户在不同公有云、以及本地自建中间择优而选。不过,很难想象在公有云上却不用对象存储 —— 在 ECS 和天价 ESSD 上用 MinIO 自建对象存储服务并不是真正可行的选项,这涉及到公有云商业模式的核心秘密:廉价S3获客天价EBS杀猪


自建是掌握自身命运的终极道路:如果用户想真正掌握自己的命运,最终恐怕早晚会走上自建这条路。互联网先辈们平地起高楼创建了这些服务,而现在做这件事只会容易得多:IDC 2.0 解决硬件资源问题,开源平替解决软件问题,大裁员释放出的专家解决了人力问题。短路掉公有云这个中间商,直接与 IDC 合作显然是一个更经济实惠的选择。稍微有点规模的用户下云省下的钱,可以换几个从大厂出来的资深SRE 还能盈余不少。更重要的是,自家人出问题你可以进行奖惩激励督促其改进,但是云出问题能赔给你几毛代金券? —— “你算老几能让高P舔你?”


明确云厂商的 SLA 是营销工具,而非战绩承诺

在云计算的世界里,服务等级协议(SLA)曾被视为云厂商对其服务质量的承诺。然而,当我们深入研究这些由一堆9组成的协议时,会发现它们并不能像期望的那样“兜底”。与其说是 SLA 是对用户的补偿,不如说 SLA 是对云厂商服务质量没达标时的“惩罚”。比起会因为故障丢掉奖金与工作的专家来说,SLA 的惩罚对于云厂商不痛不痒,更像是自罚三杯。如果惩罚没有意义,云厂商也没有动力去提供更好的服务质量。所以,SLA 对用户来说不是兜底损失的保险单。在最坏的情况下,它是堵死了实质性追索的哑巴亏;在最好的情况下,它才是提供情绪价值的安慰剂。


最后,尊重技术,善待工程师

阿里云这两年在大力搞“降本增效”:学人家马斯克推特大裁员,人裁几千你裁几万。但人家推挂来挂去用户捏着鼻子继续凑合用,ToB 业务跟着连环裁员连环挂,企业用户可忍得了这个?队伍动荡,人心不稳,稳定性自然会受到影响。

很难说这跟企业文化没有关系:996 修福报,大把时间内耗在无穷的会议汇报上。领导不懂技术,负责汇总周报写PPT 吹牛逼,P9 出嘴;P8 带队,真正干活的都是567,晋升没指望,裁员先找你;真正能打的顶尖人才根本不吃这一套 PUA 窝囊气,成批成批地出来创业单干 —— 环境盐碱地化:学历门槛越来越高,人才密度却越来越低。

我亲自见证的例子是,一个独立开源贡献者单人搞的 开源 RDS for PostgreSQL,可以骑脸输出几十人 RDS 团队的产品,而对方团队甚至连发声辩白反驳的勇气都没有 —— 阿里云确实不缺足够优秀的产品经理和工程师,但请问这种事情为什么可能会发生呢?这是应该反思的问题。

阿里云作为本土公有云中的领导者,应当是一面旗帜 —— 所以它可以做的更好,而不应该是现在这幅样子。作为曾经的阿里人,我希望阿里云能吸取这次故障的教训,尊重技术,踏实做事,善待工程师。更不要沉迷于杀猪盘快钱而忘记了自己的初心愿景 —— 提供物美价廉的公共计算服务,让存算资源像水电一样普及

参考阅读

是时候放弃云计算了吗?

下云奥德赛

FinOps的终点是下云

云计算为啥还没挖沙子赚钱?

云SLA是不是安慰剂?

云盘是不是杀猪盘?

云数据库是不是智商税?

范式转移:从云到本地优先

腾讯云CDN:从入门到放弃


【阿里】云计算史诗级大翻车来了

阿里云的羊毛抓紧薅,五千的云服务器三百拿

云厂商眼中的客户:又穷又闲又缺爱

阿里云的故障在其他云也可能发生,并且可能丢数据

中国云服务走向全球?先把 Status Page 搞定

我们可以信任阿里云的故障处理吗?

给阿里云的一封公开信

平台软件应该像数学一样严谨 — 和阿里云RAM团队商榷

被医药业吊打的中国软件从业者

腾讯的错别字文化

云为什么留不住客户 — 以腾讯云 CAM 为例

腾讯云团队为什么用阿里云的服务名?

究竟是客户差劲,还是腾讯云差劲?

百度腾讯阿里真的是高科技企业吗?

云计算厂商们,你们辜负了中国的用户

除了打折虚拟机, 云计算用户究竟在用什么高阶云服务?

腾讯云阿里云做的真的是云计算吗?–从客户成功案例的视角

本土云厂家究竟在服务谁?

薅阿里云羊毛,打造数字家园

微信公众号原文

阿里云双十一提供了一个不错的福利,列表价 ¥1500/年 的 2C/2G/3M ECS服务器,一年 ¥99 给用三年(99¥ 每年可续费到2026),据说还要给中国所有大学生每人免费送一台。

ecs-price-1.jpg

虽然我经常揶揄公有云是杀猪盘,但那是针对我们这种体量的用户。对于个人开发者而言,这种价格真的可以说是很厚道的福利了。2C/2G 倒是不值几个钱,但这个 3M 带宽和公网IP 还是很给力的。新老用户都可以买,截止到双11,我建议所有开发者都薅了这个羊毛,用来建设一个 DevBox。

弄了 ECS 可以干啥用呢?你可以把它当成跳板机,临时文件中转站,搭建一个静态网站,个人博客,运行一些定时脚本跑一些服务,搭个梯子,打造自己的 Git 仓库,软件源,Wiki站点,搭建私有部署的论坛/社交媒体站点。

学生可以用来学习 Linux,构建一个软件编译环境,学习各种数据库:PostgreSQL,Redis,MinIO,用 SQL 和 Python 搞搞数据分析,用 Grafana 和 Echarts 玩玩数据可视化。

Pigsty 为这些需求提供了一个一键安装,开箱即用的底座,让您立刻在 ECS 上拥有一个生产级的 DevBox。


然后呢?

Pigsty 提供了开箱即用的主机/数据库监控系统,开箱即用的 Nginx Web 服务器承用于对外服务,一个功能完备,插件齐全的 PostgreSQL 数据库可以用于支持各种上层软件。在 Pigsty 的基础上,你可以很轻松的搭建一个静态网站 / 动态服务。安装完 Pigsty 后,你可以自行探索一下监控系统(Demo: https://demo.pigsty.cc ),它展示了你的主机详情和数据库的完整监控。

我们也会在后续介绍一系列有趣的主题:

  • 样例应用:ISD,对全球天气数据进行分析与可视化
  • 数据库101:快速上手数据库全能王:PostgreSQL
  • 可视化快速上手,用 Grafana 画图进行数据分析
  • 静态网站:使用 hugo 打造你自己的静态个人网站
  • 跳板机:如何将这台 ECS 作为跳板,访问家中的电脑?
  • 站点发布:如何让用户通过域名访问你的个人网站
  • SSL证书:如何使用 Let’s Encrypt 免费证书加密
  • Python环境:如何在 Pigsty 配置 Python 开发环境
  • Docker环境:如何在 Pigsty 中启用 Docker 开发环境
  • MinIO:如何将这台 ECS 作为你的文件中转站并与他人分享?
  • Git仓库:如何使用 Gitea + PostgreSQL 搭建自己的 Git 仓库?
  • Wiki站点:如何使用 Wiki.js + PostgreSQL 搭建自己的 Wiki 知识库?
  • 社交网络:如何使用快速搭建 Mastodon 与 Discourse?

购买配置ECS

作为资深 IaC 用户,我早已习惯了一键拉起所需的云资源,置备好所有东西。在控制台上用鼠标点点点这种活儿对我已经非常陌生了。不过我相信也有不少读者其实还并不熟悉云上的操作,所以我们尽可能详尽的展示这里的操作。如果你已经是老司机了,请直接跳过这一部分,进入 Pigsty 配置部分。


购买活动虚拟机

没有阿里云账号可以用手机号注册一个,然后用支付宝扫码实名认证完事。进入活动页面,立即购买。地区选一个离你位置最近的,可用区可以选字母序大一点的。操作系统网络无所谓用默认的就行后面再改,选好之后勾选最下面的:我已阅读并同意云服务器ECS-包年包月服务协议。点击“立即购买”,支付宝付钱,完工。

不差钱的话我建议直接充个三百块,锁定 99¥ 续费一年先,剩下的零钱可以花个几十块钱买个域名,补充点零用的 OSS/ESSD/流量费啥的。毕竟如果你想使用按需付费的东西,还是需要一百块抵押在里面的。

直接在实例页面点 “续费”,续费1年目前的价格是 99¥ ,可以直接续上锁定下一年的优惠。当然第三年阿里云只是说到时候你可以继续用 99¥ 的价格继续续费一年到 2026,现在还操作不了。


重装系统与密钥

购买完云服务器后,点击控制台。或者左上角 Logo 边上的菜单图标进入 ECS 控制台,然后就能看到你买的实例已经运行了。我们可以在这里进一步配置网络、操作系统,以及密码密钥。

instance-summary.jpg

直接点实例边上的 “停止”,点击实例名称链接进入详情页,选择“更换操作系统”。然后选择“公共镜像”,选择你想用的操作系统镜像就好了。Pigsty 支持 EL 7/8/9 以及兼容操作系统,Ubuntu 22.04/22.04 ,Debian 12/11。

这里我们推荐使用 RockyLinux 8.8 64位,这是目前主流的企业级操作系统,在稳定性和软件新鲜度上取得了一个均衡。 OpenAnolis 8.8 RHCKRockyLinux 9.2 ,或者 Ubuntu 22.04 也是不错的选择,不过我们后面演示用的都是 Rocky 8.8,初学者最好还是不要在这里过多折腾为妙。

《EL系统兼容性哪家强?》

在安全设置中,你可以设置 root 用户密码/密钥。如果你没有 SSH 密钥,可以使用 ssh-keygen 生成一对,或者直接填一个文本密码,这里我们方便起见随便设置一个。设置好了之后,你就可以使用 ssh root@<ip> 的方式登陆该服务器了(SSH客户端这种问题就不在这儿展开了,iTerm, putty, xshell, secureCRT 都行)

ssh-keygen              # 如果你没有 SSH 密钥对,生成一对。
ssh-copy-id  root@<ip>  # 将你的ssh密钥添加到服务器上(输入密码)

配置域名与DNS

域名现在非常便宜了,一年也就十几块钱。我非常建议整一个,能够带来很大的便利。主要是你可以用不同的子域名来区分不同的服务,让 Nginx 把流量转发到不同的上游去,一机多用。当然,喜欢用 IP 地址 + 端口号直接访问不同服务也无所谓。主要是一来比较土鳖,二来端口开的太多也会有更多安全隐患。

比如这里,我花了十几块钱在阿里云上买了个 pdata.cc 的域名,然后在阿里云DNS控制台上就可以去添加域名的解析了,指向刚申请服务器的 IP 地址。一条 @ 记录,一条 * 通配记录,A 记录,指向 ECS 实例的公网IP地址就行。

dns-resolv.jpg

有了域名之后,你就可以用 ssh root@pdata.cc 的方式登陆,不用再记 IP 地址了。你也可以在这里配置更多的子域名,指向不同的地址。如果域名是拿来建站的,在中国大陆还需要申请备案,反正阿里云也有一条龙的服务。


配置安全组规则

新创建的云服务器带有默认的安全组规则,只允许 SSH 服务也就是 22 端口访问。所以为了访问这台服务器上的 Web 服务你还需要打开 80/443 端口。如果你懒得折腾域名想用 IP + 端口直接访问相应服务,而不是通过域名走 Nginx 的 80/443 端口,那么 Grafana 监控界面的 3000 端口也应该打开。最后,如果你想从本地访问 PostgreSQL 数据库,也可以考虑打开 5432 端口。

如果你想偷懒,确实可以添加一条开放所有端口的规则,但云服务器不比自己的笔记本,你也不想自己的 ECS 被人黑了拿去干坏事被封号吧。所以这里我们还是按规矩来。实例详情页里点击安全组,然后点击那个具体的安全组进入详情页进行配置:

security-group.jpg

在默认的 “入方向” 上添加一条“允许”规则,协议选择 TCP,端口范围填入 80/443/3000/5432,从 0.0.0.0/0 任意地址都可以访问这些端口。

security-group-rule.jpg

上面这些操作属于 Linux 101 基础知识老生常谈,对老司机来说,使用 Terraform 模板一行命令就完成了。但很多初学者确实不知道如何去弄。

总之,折腾完上面的步骤之后,你就有一台准备就绪的云服务器了!你可以在任意有网络的地方使用域名登陆/访问这台服务器。接下来,我们就可以在开始建设数字家园了:安装 Pigsty。


安装配置Pigsty

现在,你已经可以使用 root 用户,通过 SSH 登陆这台服务器了,接下来就可以下载、安装、配置 Pigsty 了。

尽管使用 root 用户并非生产最佳实践,但对于个人 DevBox 来说没啥关系,我们就不折腾新建管理用户这摊子事儿了。直接使用 root 用户开干:

curl https://get.pigsty.cc/latest | bash  # 下载 Pigsty 并解压到 ~/pigsty 目录
cd ~/pigsty      # 进入 Pigsty 源码目录,完成后续 准备、配置、安装 三个步骤即可
./bootstrap      # 确保 Ansible 正常安装,如果存在 /tmp/pkg.tgz 离线软件包,便使用它。
./configure      # 执行环境检测,并生成相应的推荐配置文件,如果你知道如何配置 Pigsty 可以跳过
./install.yml    # 根据生成的配置文件开始在当前节点上执行安装,使用离线安装包大概需要10分钟完成

Pigsty 官方文档提供了详细的安装配置教程:https://pigsty.cc/doc/#/zh/INSTALL

安装完成后,您可以通过域名或80/443端口通过 Nginx 访问 WEB 界面,通过 5432 端口访问默认的 PostgreSQL 数据库服务,通过 3000 端口登陆 Grafana。

在浏览器中输入 http://<公网IP>:3000,即可访问 Pigsty 的 Grafana 监控系统。 ECS 的 3M 带宽小水管会在初次加载 Grafana 时费点功夫。 您可以匿名访问,也可以使用默认的用户名密码 admin / pigsty 登陆。请务必修改这个默认密码,避免别人随便从这里进入搞破坏。

上面这个教程看上去真的很简单对吧?对的。作为一台随时可以销毁重建的开发机,这么搞毫无问题。但如果你想把它作为一个承载个人数字家园的环境,请最好参考下面的 配置详情安全加固 部分再进行动手。


配置详情

当您安装 Pigsty 运行 configure 这一步时,Pigsty 会根据您的机器环境,生成一个单机安装的配置文件:pigsty.yml。默认的配置文件可以直接用,但你可以进一步进行定制修改,来增强其安全性与便利性。

下面是一个推荐的配置文件样例,它应当默认在 /root/pigsty/pigsty.yml 这里,描述你需要的数据库。Pigsty 提供了 280+ 定制参数,但你只需要关注几个进行微调即可。你的机器内网IP地址,可选的公网域名,以及各种密码。域名是可选的,但我们建议你最好用一个。其他的密码你懒得修改也就算了,pg_admin_password 请务必修改一个。

---
all:
  children:

    # 这里的 10.10.10.10 都应该是你 ECS 的内网 IP 地址,用于安装 Infra/Etcd 模块
    infra: { hosts: { 10.10.10.10: { infra_seq: 1 } } }
    etcd:  { hosts: { 10.10.10.10: { etcd_seq: 1 } }, vars: { etcd_cluster: etcd } }

    # 定义一个单节点的 PostgreSQL 数据库实例
    pg-meta:
      hosts: { 10.10.10.10: { pg_seq: 1, pg_role: primary } }
      vars:
        pg_cluster: pg-meta
        pg_databases:
          - { name: meta ,baseline: cmdb.sql ,schemas: [ pigsty ] }
        pg_users: # 最好把这里的两个样例用户的密码也修改一下
          - { name: dbuser_meta ,password: DBUser.Meta   ,roles: [ dbrole_admin ] }
          - { name: dbuser_view ,password: DBUser.Viewer ,roles: [ dbrole_readonly ] }
        pg_conf: tiny.yml   # 2C/2G 的云服务器,使用微型数据库配置模板
        node_tune: tiny     # 2C/2G 的云服务器,使用微型主机节点参数优化模板
        pgbackrest_enabled: false # 这么点磁盘空间,就别搞数据库物理备份了
        pg_default_version: 13   # 用 PostgreSQL 13

  vars:
    version: v2.5.0
    region: china
    admin_ip: 10.10.10.10  # 这个 IP 地址应该是你 ECS 的内网IP地址
    infra_portal: # 如果你有自己的 DNS 域名,这里面的域名后缀 pigsty 换成你自己的 DNS 域名
      home: { domain: h.pigsty }
      grafana: { domain: g.pigsty ,endpoint: "${admin_ip}:3000" , websocket: true }
      prometheus: { domain: p.pigsty ,endpoint: "${admin_ip}:9090" }
      alertmanager: { domain: a.pigsty ,endpoint: "${admin_ip}:9093" }
      minio: { domain: sss.pigsty  ,endpoint: "${admin_ip}:9001" ,scheme: https ,websocket: true }
      postgrest: { domain: api.pigsty  ,endpoint: "127.0.0.1:8884" }
      pgadmin: { domain: adm.pigsty  ,endpoint: "127.0.0.1:8885" }
      pgweb: { domain: cli.pigsty  ,endpoint: "127.0.0.1:8886" }
      bytebase: { domain: ddl.pigsty  ,endpoint: "127.0.0.1:8887" }
      gitea: { domain: git.pigsty  ,endpoint: "127.0.0.1:8889" }
      wiki: { domain: wiki.pigsty ,endpoint: "127.0.0.1:9002" }
      noco: { domain: noco.pigsty ,endpoint: "127.0.0.1:9003" }
      supa: { domain: supa.pigsty ,endpoint: "10.10.10.10:8000", websocket: true }
      blackbox: { endpoint: "${admin_ip}:9115" }
      loki: { endpoint: "${admin_ip}:3100" }

    # 把这里的密码都改掉!你也不想别人随便来串门对吧!
    pg_admin_password: DBUser.DBA
    pg_monitor_password: DBUser.Monitor
    pg_replication_password: DBUser.Replicator
    patroni_password: Patroni.API
    haproxy_admin_password: pigsty
    grafana_admin_password: pigsty
...

configure 生成的配置文件中,所有 10.10.10.10 这个 IP 地址都会被替换为你的 ECS 实例的首要内网 IP 地址。注意:不要在这里使用公网 IP 地址。在 infra_portal 参数中,你可以把所有的 .pigsty 域名后缀换成你新申请的域名,比如 pdata.cc ,这样 Pigsty 会允许您通过 Nginx 使用不同域名来访问不同的上游服务。后面如果您希望添加几个个人网站,也可以在这个配置里直接修改并应用。

修改好配置文件 pigsty.yml 之后,运行 ./install.yml 即可开始安装。

安全加固

大多数人可能都不在乎安全这个事,但我还是必须要说一下。只要你改了默认密码,ECS 和 Pigsty 的默认配置对大多数场景已经足够安全了。这里有一些安全加固的小建议:https://pigsty.cc/doc/#/zh/SECURITY

第一个要点是,出于安全考虑:除非你真的很想偷懒从本地直接访问远程数据库,一般不建议对公网开放 5432 端口,很多数据库工具都提供了 SSH Tunel 功能 —— 先 SSH 到服务器再本地连接数据库。(顺带一提,Intellij 自带的 Database Tool 是我用过最好用的数据库客户端工具)

如果你真的很想从本地直连远程数据库,Pigsty 默认规则也允许您使用默认的超级用户 dbuser_dba 使用 SSL / 密码认证从任何地方访问,请确保你改了 pg_admin_password 参数,并开放了 5432 端口。

ssh-tunel.jpg

第二个要点是使用域名而非 IP 地址访问,就要求你做一些额外的工作:域名可以在云厂商那里买,也可以使用本地 /etc/hosts 的静态解析记录作为下位替代。如果您实在懒得折腾,IP 地址 + 端口直连也不是不行。

第三个要点是使用 HTTPS, SSL 可以使用各家云厂商或 Let’s Encrypt 提供的免费证书,使用 Pigsty 默认的自签名 CA 颁发的证书作为下位替代。

Pigsty默认使用自动生成的自签名的CA证书为Nginx启用SSL,如果您希望使用 HTTPS 访问这些页面,而不弹窗提示"不安全",通常有三个选择:

  • 在您的浏览器或操作系统中信任Pigsty自签名的CA证书: files/pki/ca/ca.crt
  • 如果您使用 Chrome,可以在提示不安全的窗口键入 thisisunsafe 跳过提示
  • 您可以考虑使用 Let’s Encrypt 或其他免费的CA证书服务,为 Pigsty Nginx 生成正式的CA证书

我们会在后面的教程中详细介绍这些细节,你也可以参考 Pigsty 的文档进行自行配置。

云计算泥石流:用数据解构公有云

微信公众号原文 | Vonng博客

曾几何时,“上云“近乎成为技术圈的政治正确,但很少有人会用实打实的数据来分析这里面的利弊权衡。我愿意成为这个质疑者:让我用实打实的 数据 与亲身经历的故事,讲清楚公有云租赁模式的陷阱与价值 —— 在这个降本增效的时代中,供您借鉴与参考。

下云奥德赛

FinOps的终点是下云

云计算为啥还没挖沙子赚钱?

云SLA是不是安慰剂?

云盘是不是杀猪盘?

云数据库是不是智商税?

范式转移:从云到本地优先

腾讯云CDN:从入门到放弃


经济下行,降本增效成为主旋律,除了裁员,下云以削减高昂的云开支,也被越来越多的企业纳入考虑提上日程。

我们认为,公有云有其存在意义 —— 对于那些非常早期、或两年后不复存在的公司,对于那些完全不在乎花钱、或者真正有着极端大起大落的不规则负载的公司来说,对于那些需要出海合规,CDN等服务的公司来说,公有云仍然是非常值得考虑的服务选项。

然而对绝大多数已经发展起来,有一定规模的公司来说,如果能在几年内摊销资产,你真的应该认真重新审视一下这股云热潮。好处被大大夸张了 —— 在云上跑东西通常和你自己弄一样复杂,却贵得离谱。我真的建议您好好算一下帐。

最近十年间,硬件以摩尔定律的速度持续演进,IDC2.0与资源云提供了公有云资源的物美价廉替代,开源软件与开源管控调度软件的出现,更是让自建的能力变得唾手可及 —— 下云自建,在成本,性能,与安全自主可控上都会有非常显著的回报。

这个合集 《数据库泥石流 —— 用数据解构公有云》,包含了我们自己作为甲方在云上云下/上云下云过程中的亲身体验例证,收集对比了自建与云服务的实际效能成本数据并进行分析,为您提供参考与借鉴。

我们提倡下云理念,并提供了实践的路径与切实可用的自建替代品 —— 我们将为认同这一结论的追随者提前铺设好意识形态与技术上的道路。

不为别的,只是期望所有用户都能拥有自己的数字家园,而不是从科技巨头云领主那里租用农场。—— 这也是一场对互联网集中化与反击赛博地主垄断收租的运动,让互联网 —— 这个美丽的自由避风港与理想乡可以走的更长。


下云奥德赛

当代的下云史诗,云遣返奥德赛。来自 37 Signal 的传奇下云故事:如何在6个月内下云并省下五千万元。我从 @dhh 的博客上挑选了10篇文章,以时间线倒序展现这段波澜壮阔的旅程,译作中文,供大家参考借鉴。

作者:David Heinemeier Hansson,网名DHH,37 Signal 联创与CTO,Ruby on Rails 作者,下云倡导者、实践者、领跑者。反击科技巨头垄断的先锋。博客:https://world.hey.com/dhh

译者:冯若航,网名 Vonng。磐吉云数 创始人与CEO。Pigsty 作者,PostgreSQL 专家与布道师。云计算泥石流,数据库老司机,下云倡导者与实践者。

odyssey


FinOps的终点是下云

在 SACC 2023 大会 FinOps 专场上,我狠狠喷了一把云厂商。这是现场发言的文字整理稿,介绍了终极 FinOps —— 下云 的理念与实践路径。

FinOps关注点跑偏总价 = 单价 x 数量,搞 FinOps 的关注减少浪费资源的数量,却故意无视了房间里的大象 —— 云资源单价

公有云是个杀猪盘:廉价EC2/S3获客,EBS/RDS杀猪。云算力的成本是自建的五倍,块存储的成本则可达百倍以上,堪称终极成本刺客。

FinOps终点是下云:对于有一定规模企业来说,IDC自建的总体成本在云服务列表价1折上下。下云是原教旨 FinOps 的终点,也是真正 FinOps 的起点。

自建能力决定议价权:拥有自建能力的用户即使不下云也能谈出极低的折扣,没有自建能力的公司只能向公有云厂商缴纳高昂的 “无专家税” 。

数据库是自建关键:K8S 上的无状态应用与数据仓库搬迁相对容易,真正的难点是在不影响质量安全的前提下,完成数据库的自建。

finops


云计算为啥还没挖沙子赚钱?

公有云毛利不如挖沙子,杀猪盘为何成为赔钱货?

卖资源模式走向价格战,开源替代打破垄断幻梦。

服务竞争力逐渐被抹平,云计算行业将走向何方?

在《云盘是不是杀猪盘》、《云数据库是不是智商税》以及《云SLA是不是安慰剂》中,我们已经研究过关键云服务的真实成本。规模以上以核·月单价计算的云服务器成本是自建的 5~10 倍,云数据库则可达十几倍,云盘更是能高达上百倍,按这个定价模型,云的毛利率做到八九十也不稀奇。

业界标杆的 AWS 与 Azure 毛利就可以轻松到 60% 与 70% 。反观国内云计算行业,毛利普遍在个位数到 15% 徘徊,榜一大哥阿里云最多给一句“预估远期整体毛利 40%” ,至于像金山云这样的云厂商,毛利率直接一路干到 2.1%,还不如打工挖沙子的毛利高。

而说起净利润,国内公有云厂商更是惨不忍睹。AWS / Azure 净利润率能到 30% ~ 40% 。标杆阿里云也不过在盈亏线上下徘徊挣扎。这不禁让人好奇,国内这些云厂商是怎么把一门百分之三四十纯利的生意能做到这种地步的?

ebs


云SLA是不是安慰剂?

在云计算的世界里,服务等级协议(SLA)被视为云厂商对其服务质量的承诺。然而,当我们深入研究这些 SLA 时,会发现它们并不能像期望的那样“兜底”:你以为给自己的数据库上了保险可以高枕无忧,但其实白花花的银子买的是提供情绪价值的安慰剂。

对于云厂商来说,SLA 并不是真正的可靠性承诺或历史战绩,而是一种营销工具,旨在让买家相信云厂商可以托管关键业务应用。对用户来说,SLA不是兜底损失的保险单。在最坏的情况下,它是吃不了兜着走的哑巴亏。在最好的情况下,它才是提供情绪价值的安慰剂。

与其说是 SLA 是对用户的补偿,不如说 SLA 是对云厂商服务质量没达标时的“惩罚”。云厂商并不需要在可靠性上精益求精 —— 没达到自罚三杯即可。然而苦果只能由客户自己承担。

sla


云盘是不是杀猪盘?

我们已经用数据回答了《云数据库是不是智商税》这个问题,但在公有云块存储的百倍溢价杀猪比率前,云数据库只能说还差点意思。本文用实际数据揭示公有云真正的商业模式 —— 廉价EC2/S3获客,EBS/RDS杀猪。而这样的做法,也让公有云与其初心愿景渐行渐远。

EC2 / S3 / EBS 是所有云服务的定价之锚。如果说 EC2/S3 定价还勉强能算合理,那么 EBS 的定价乃是故意杀猪。公有云厂商最好的块存储服务与自建可用的 PCI-E NVMe SSD 在性能规格上基本相同。然而相比直接采购硬件,AWS EBS 的成本高达 120 倍,而阿里云的 ESSD 则可高达 200 倍**。**

即插即用的磁盘硬件,百倍溢价到底为何?云厂商无法解释如此的天价到底源于何处。结合其他云存储服务的设计思路与定价模型,只有一个合理的解释:EBS的高溢价倍率是故意设置的门槛,以便于云数据库杀猪

作为云数据库定价之锚的 EC2 与 EBS,溢价分别为几倍与几十倍,从而支撑起云数据库的杀猪高毛利。但这样的垄断利润必定无法持久:IDC 2.0/运营商/国资云冲击 IaaS;私有云/云原生/开源平替冲击 PaaS;科技行业大裁员、AI冲击与天朝的低人力成本冲击云服务(运维人力外包/共享专家)。公有云如果执着于目前的杀猪模式,背离“存算基础设施”的初心,那么必将在以上三者形成的合力下面临越来越严峻的竞争与挑战

ebs


云数据库是不是智商税?

近日,Basecamp & HEY 联合创始人 DHH 的一篇文章【1,2】引起热议,主要内容可以概括为一句话:“我们每年在云数据库(RDS/ES)上花50万美元,你知道50万美元可以买多少牛逼的服务器吗?我们要下云,拜拜了您呐!

所以,50 万美元可以买多少牛逼的服务器 ?

一台 64C 384G + 3.2TB NVMe SSD 的高配数据库服务器,我们本地自建,5年摊销,每年1.5万元。自建两台组HA,每年5万,同规格阿里云上25~50万(包3年打五折);AWS 上则更离谱:160 ~ 217 万元。

所以问题来了,如果你用云数据库1年的钱,就够你买几台甚至十几台性能更好的服务器,那么使用云数据库的意义到底在哪里?如果您觉得自己没有能力自建 —— 那么我们为您提供一个开箱即用,免费的 RDS 管控替代,来帮你解决这个问题!—— Pigsty!

如果您的业务符合公有云的适用光谱,那是最好不过;但为了不需要的灵活性与弹性支付几倍乃至十几倍溢价,那是纯交智商税。

rds


范式转移:从云到本地优先

最初,软件吞噬世界,以 Oracle 为代表的商业数据库,用软件取代了人工簿记,用于数据分析与事务处理,极大地提高了效率。不过 Oracle 这样的商业数据库非常昂贵,一核·一月光是软件授权费用就能破万,不是大型机构都不一定用得起,即使像壕如淘宝,上了量后也不得不”去O“。

接着,开源吞噬软件,像 PostgreSQL 和 MySQL 这样”开源免费“的数据库应运而生。软件开源本身是免费的,每核每月只需要几十块钱的硬件成本。大多数场景下,如果能找到一两个数据库专家帮企业用好开源数据库,那可是要比傻乎乎地给 Oracle 送钱要实惠太多了。

开源软件带来了巨大的行业变革,可以说,互联网的历史就是开源软件的历史。尽管如此,开源软件免费,但 专家稀缺昂贵。能帮助企业 用好/管好 开源数据库的专家非常稀缺,甚至有价无市。某种意义上来说,这就是”开源“这种模式的商业逻辑:免费的开源软件吸引用户,用户需求产生开源专家岗位,开源专家产出更好的开源软件。但是,专家的稀缺也阻碍了开源数据库的进一步普及。于是,“云软件”出现了 —— 垄断不了软件,垄断专家也可以

然后,云吞噬开源。公有云软件,是互联网大厂将自己使用开源软件的能力产品化对外输出的结果。公有云厂商把开源数据库内核套上壳,包上管控软件跑在托管硬件上,并雇佣共享 DBA 专家提供支持,便成了云数据库服务 (RDS) 。这诚然是有价值的服务,也为很多软件变现提供了新的途径。但云厂商的搭便车行径,无疑对开源软件社区是一种剥削与攫取,而捍卫计算自由的开源组织与开发者自然也会展开反击。

云软件的崛起会引发新的制衡反作用力:与云软件相对应的本地优先软件开始如雨后春笋一般出现。而我们,就在亲历见证这次范式转移

paradigm

下云奥德赛:该放弃云计算了吗?

微信公众号原文 | 知乎原文 | Vonng博客第一版微信公众号原文第一版Vonng博客

DHH一直以来都是下云先锋,本文摘取了DHH博客关于下云相关的十篇文章,记录了 37Signal 从云上搬下来的完整旅程,按照时间倒序排列组织,译为中文,以飨读者。本文翻译了DHH从云上下来的完整旅程,对于准备上云,云上的企业都非常有借鉴与参考价值。

作者:David Heinemeier Hansson,网名DHH。 37 Signal 联创与CTO,Ruby on Rails 作者,下云倡导者、实践者、领跑者。反击科技巨头垄断的先锋。Hey博客

译者:冯若航,网名 Vonng.磐吉云数创始人与CEO。Pigsty 作者,PostgreSQL 专家与布道师。云计算泥石流,数据库老司机,下云倡导者,数据库下云实践者。Vonng博客


译序

世人常道云上好,托管服务烦恼少。我言云乃杀猪盘,溢价百倍实厚颜。

赛博地主搞垄断,白嫖吸血开源件。租服务器炒概念,坐地起价剥血汗。

世人皆趋云上游,不觉开销似水流。云租天价难为持,自建之路更稳实。

下云先锋大卫王,引领潮流把枪扛,不畏浮云遮望眼,只缘身在最前锋。


曾几何时,“上云“近乎成为技术圈的政治正确,整整一代应用开发者的视野被云遮蔽。DHH,以及像我这样的人愿意成为这个质疑者,用实打实的数据与亲身经历,讲清楚公有云租赁模式的陷阱。

很多开发者并没有意识到,底层硬件已经出现了翻天覆地的变化,性能与成本以指数方式增长与降低。许多习以为常的工作假设都已经被打破,无数利弊权衡与架构方案值得重新思索与设计。

我们认为,公有云有其存在意义 —— 对于那些非常早期、或两年后不复存在的公司,对于那些完全不在乎花钱、或者真正有着极端大起大落的不规则负载的公司来说,对于那些需要出海合规,CDN等服务的公司来说,公有云仍然是非常值得考虑的服务选项。

然而对绝大多数已经发展起来,有一定规模的公司来说,如果能在几年内摊销资产,你真的应该认真重新审视一下这股云热潮。好处被大大夸张了 —— 在云上跑东西通常和你自己弄一样复杂,却贵得离谱,我真诚建议您好好算一下帐。

最近十年间,硬件以摩尔定律的速度持续演进,IDC2.0与资源云提供了公有云资源的物美价廉替代,开源软件与开源管控调度软件的出现,更是让自建的能力变得唾手可及 —— 下云自建,在成本,性能,与安全自主可控上都会有非常显著的回报。

我们提倡下云理念,并提供了实践的路径与切实可用的自建替代品 —— 我们将为认同这一结论的追随者提前铺设好意识形态与技术上的道路。不为别的,只是期望所有用户都能拥有自己的数字家园,而不是从科技巨头云领主那里租用农场。

这也是一场对互联网集中化与反击赛博地主垄断收租的运动,让互联网 —— 这个美丽的自由避风港与理想乡可以走得更长。


10-27 推特X下云省掉60%

X celebrates 60% savings from cloud exit[1]

马斯克在X公司(Twitter)大力削减成本,简化流程。这个过程或许并非一帆风顺,但却效果显著。他不止一次地证明了那些对他嗤之以鼻的人们是错的。尽管有许多声音说,在经历了这么大的人事变动后,推特很快会翻车,但事实并非如此。X不仅成功地维持了网站的稳定运行,还在此期间加速了实验功能的推进。无论你喜欢还是讨厌这里的政治立场,这都是令人印象深刻的。

我明白对于很多人来说,很难置政治于不顾。但无论你站哪一边,总是能找到一张图表来证明,X要么正在繁荣,要么即将崩溃,所以在这里抬杠没有意义。

真正重要的是,X 已经将下云(#CloudExit)作为它们节省成本计划的关键组成部分。以下是其工程团队在庆祝去年成果时的发言:

“我们优化了公有云的使用,并在本地进行更多的工作。这一转变使我们每月的云成本降低了60%。我们进行的改变里有一个是将所有的媒体/Blob工件从云端移出,这让我们的总体云数据存储量减少了60%,另外我们还成功地将云上的数据处理成本降低了75%。”

请再仔细读一遍上面那段话:把同样的工作从云端转移到自己的服务器上,让每月的云费用降低了60% (!!)。根据早先的报道,X 每年在 AWS 上的开销是1亿美元,所以如果以此为基础,他们从云退出的成果可以节省高达6000万美元/年,堪称惊人!

更令人印象深刻的是,他们在团队规模缩小到原来的四分之一的情况下,还能够如此迅速地削减云账单。Twitter 曾有大约八千名员工,而现在据报道 X 的员工数量不到2,000。

CFO 和投资人无法忽视这一现象:如果像X这样的公司能够以四分之一的员工运营,并且从下云过程中大大获利,那么在许多情况下,大多数大公司从云端退出都有巨大的省钱潜力。

#CloudExit或许即将成为主流,你算过你的云账单吗?


10-06 托管云服务的代价

The price of managed cloud services[2]

自从我们离开云计算后,常有人反驳说,我们不应该指望一个简单的下云迁移能有什么好果子吃。云的真正价值在于托管服务和新架构,而不仅仅是在租来的云服务器上运行同样的软件。翻译过来就是:“你用云的姿势不对!” ,这种论调简直是胡说八道!

首先 HEY 是在云中诞生的。在发布前,它从未在我们自己的硬件上运行过。我们在2020年开始使用 Aurora/RDS 来管理数据库,使用 OpenSearch 进行搜索,使用 EKS 来管理应用和服务器。我们深度使用了原生的云组件,而不仅仅是租了一堆虚拟机。

正是因为我们如此广泛地使用了云提供的各种服务,账单才如此之高。而且运营所需的人手也没有明显减少,我们对此深感失望。这个问题的答案绝不可能是“只管使用更多的托管服务”,或者挥一挥 “Serverless 魔法棒” 就解决了。

以我们在AWS上使用 OpenSearch 服务为例。我们每月花费三十万($43,333)来为 Basecamp、HEY 以及我们的日志基础设施提供搜索服务,每年近四百万(52万美元),这仅仅是搜索啊!

而现在,我们刚刚关闭了在 OpenSearch 上的最后一个大型日志集群,所以现在是一个很好的时机来对比替代选项的开销。我们购买所需硬件大约花费了$150,000(每个数据中心 $75,000 以实现完全冗余),如果在五年内折旧摊销,大约是每月 $2,500。我们在两个数据中心里还要为这些机器提供电力、机位和网络,每月开销大约 $2,500。所以每月总共是五千美元,这还是包含了预留缓冲的情况。

这比我们在 OpenSearch 上的开销少了整整一个数量级!我们在硬件上花费的 $150,000 在短短三个月内就回本了,从三个月后我们每月将节省约 $40,000,这仅仅是搜索啊!

这时,人们通常会开始问人力成本的问题。这是一个合理的问题:如果你得雇佣一大堆工程师来自建自维服务,那么每月节省 $40,000 又算得上啥呢?首先,我得说即使我们不得不全职雇佣一个人来负责搜索服务,我仍然认为这是一件很划算的事,但我们没有这么做。

从 OpenSearch 切换到自己运行 Elastic Search 确实需要一些初始配置工作,但长远来看,我们没有因为这次切换而扩大团队规模。因为在自己的设备上运行,与在云上运行并没有本质上的工作范围差异。这就是我们整体下云的核心理念:

在云上运营我们这种规模所需的运维团队,并不会比在我们自己硬件上运行所需的规模更小!

这原本只是一个理论,但在现实中看到它被证实,仍然是令人震惊的。


09-15 下云后已省百万美金

Our cloud exit has already yielded $1m/year in savings[3]

把我们的应用搬下云非常值得庆祝,但看到实际开支减少才是真正的奖励。你看,要将云端的价格从“荒谬”的程度降至“过份”的唯一方式是“预留实例”。就是你需要签约承诺在一年或者更长时间范围里,消费支出保持在某个水平上。因此,我们账单并没有在应用搬离后立即塌缩。但是现在,它要来了。哦,它就要来了!

我们的云支出(不包括S3)已经减少了 60% 。从每月大约十八万美元($180,000)减少到不到八万美元($80,000)。每年可以在这里省下的钱折合一百万美元,而且我们在九月还会有第二轮大幅降低,剩余的支出将在年底前逐渐减少。

现在可以将省下的云开销与我们自己购买服务器的支出相比。我们需要花五十万美元来采购新服务器,用于替换云上所有的租赁项目。虽然会产生一些与新服务器有关的额外开销,但与整体图景相比(例如我们的运维团队规模保持不变)这只能算三瓜俩枣。我们只需要把新花掉的钱和省下来的钱进行简单对比,就能看出这个令人震惊的事实:我们会在不到六个月的时间内,省下来足够多的钱,让这笔采购服务器的大开销回本。

但是请等我们把话说完,看一看最终省下来的结果:用不着小学算术就能看出,最终能节省下的开销金额高达每年两百万美元,按五年算也就是整整 一千万美元!!!。这真是一笔巨大的钱款,直接击穿了我们的底线。

我们要再次提醒,每个人的情况都可能有所不同,也许你没有像我们之前那样使用这些昂贵的云服务:Aurora/RDS,以及 OpenSearch。也许你的负载确实有着很大的波动,也许这,也许那,但我并不认为我们的情况是某种疯狂的特例。

事实上,从我看到的其他软件公司未经优化的云账单来看,我们节省下来的钱实际上可能不算大。你知道过去五年Snapchat在云上花费了三十亿美元吗?在以前没人在乎业务是否盈利,能省个十几亿这种事“不重要”,没人愿意听,但现在确实很重要了


06-23 我们已经下云了!

We have left the cloud[4]

我们当初花了几年时间才搬上公有云,所以最初我以为下云也要耗费同样漫长的时间。然而当初上云时做准备的工作 —— 比如将应用容器化,实际上使下云过程相对简单很多。如今经过六个月的努力,我们完成了这个目标 —— 我们已经从云上下来了。上周三,最后一个应用被迁移到了我们自己的硬件上。哈利路亚!

在这六个月中,我们将六个历史悠久的服务迁回了本地。虽然我们已经不再销售这些服务了,但我们承诺为现有的客户和用户提供支持,直到互联网的终点。Basecamp Classic、Highrise、Writeboard、Campfire、Backpack 和 Ta-da List 都已经有十多年的历史了,但仍在为成千上万的人提供服务,每年创造数百万美元的收入。但现在,我们在这些服务上的运营开支将大大减少,而且归功于强大的新硬件,用户体验更加丝滑迅捷了。

不过,变化最大的是 HEY,这是一个诞生于云端的应用。我们以前从未在自己的硬件上跑过它,而且作为一个功能齐全的电子邮件服务,它有许多组件。但是我们的团队通过分阶段迁移,在几周内成功地将不同的数据库、缓存、邮件服务和应用实例独立地迁移到本地,而没有出现任何岔子。

将所有这些应用迁回本地的过程中,我们所使用的技术栈完全是开源的 —— 我们使用 KVM 将新买的顶配性能怪兽 —— 192 线程的 Dell R7625s 切分为独立的虚拟机,然后使用 Docker 运行容器化的应用,最后使用 MRSK 完成不停机应用部署与回滚 —— 这种方式让我们规避了 Kubernetes 的复杂度,也省却了各种形式的 “企业级” 服务合同纠葛。

粗略的计算表明,购置自己的硬件而不是从亚马逊租赁,每年至少可以为我们节省150万美元。关键是在完成这一切的过程中,我们的运维团队规模并没有变化:云所号称的缩减团队规模带来生产力增益纯属放屁,压根没有实现过。

这可能吗?当然!因为我们运维自己硬件的方式,实际上与人们租赁使用云服务的方式差不多:我们从戴尔买新硬件,直接运到我们使用的两个数据中心,然后请 Deft 公司那些白手套代维服务商把新机器上架。接着,我们就能看到新的 IP 地址蹦出来,然后立即装上 KVM / Docker / MRSK ,完事!

这里的主要区别是,从需要新服务器和看到它们在线之间的滞后时间。在云上,你可以在几分钟内拉起一百台顶配服务器,这一点确实很牛逼,只不过你也得为这个特权掏大价钱。只不过,我们的业务没有那么反复无常,以至于需要支付这么高昂的溢价。考虑到拥有自己的服务器已经为我们省了这么多钱,使劲儿超配些服务器根本不算个事儿 —— 如果还需要更多的话,也就是等个把星期的事儿。

从另一个角度看,我们花了大概 50万美元从戴尔买了两托盘服务器,为我们的服务容量添加了 4000核的 vCPU,7680GB 的内存,以及 384TB 的 NVMe 存储。这些硬件不仅能运行我们所有的存量服务,还能让 HEY 满血复活,并为我们的 Basecamp 其他业务换个崭新的心脏。这些硬件成本会在五年里摊销,然而购买它们的总价还不到我们每年省下来钱的三分之一 !

这也难怪为什么当我们分享了自己的下云经验后,许多公司都开始重新审视他们每个月那疯狂的云账单了。我们去年的云预算是 320万 美元,而且已经优化得很厉害了 —— 像长期服务承诺、精打细算的资源配置和监控。有大把的公司比我们掏了几倍多的钱却办了更少的事儿。潜在的优化空间和 AWS 的季度业绩一样惊人 —— 2022 Q4 ,AWS 为亚马逊创造了超过 50亿美元 的利润!

正如我之前提到过的:对于那些非常早期、完全不在乎花钱、或者两年后不复存在的公司,我认为云仍然是有一席之地的。只是要小心,别把那些慷慨的云代金券当作礼物!那是个鱼钩,一旦你过于依赖他们的专有托管服务或Serverless产品。当账单飙上天际时,你就无处可逃了。

我还认为可能确实会有一些公司,有着极端大起大落的不规则负载,以至于租赁还是有意义的。如果你一年只需要犁三次地,那么在其余的363天里把犁放在谷仓里闲置,确实是没有多大意义。

但是对绝大多数已经发展起来的公司来说,如果能在几年内摊销资产,你真的应该认真重新审视一下这股云热潮。好处被大大夸张了 —— 在云上跑东西通常和你自己弄一样复杂,却贵得离谱。

所以,如果钱很重要(话说回来,什么时候不重要呢?)—— 我真的建议您好好算一下账:假设您真的有一个能从不断调整容量大小中获益的服务,然后设想它下云之后的样子。我们在六个月内搬下来七个应用,你也可以的。工具就在那里,都是开源免费的。所以不要仅仅因为炒作,就停留在云端。


05-03 从云遣返到主权云!

Sovereign clouds[5]

我一直在讨论我们的下云之旅 —— “云遣返”。从在AWS上租服务器,到在一个本地数据中心拥有这些硬件。但我意识到这个术语可能会错误地让一些人感到不舒服 —— 有一整代技术人员给自己标记上"云原生"。仅仅只是因为我们想拥有而非租用服务器这种理由而疏远他们,并不能帮到任何人。这些“云原生”人才所拥有的大部分技能都是有用的,无论他们的应用跑在哪儿。

技能的重叠实际上是为什么我们能从AWS退出如此之快的部分原因。现如今,在你自有硬件上运行所需 的知识,和在云上租赁运行所需的知识十有八九是相同的。从容器到负载均衡,再到监控和性能分析,还有其他一百万个主题 —— 技术栈不仅仅是相似而已,几乎可以说一模一样。

当然也会有地方会有差别,比如 FinOps:如果你拥有自己的硬件,就不再需要像法医和会计师那样理解账单,也不需要像斗牛犬一样防止它疯狂增长了!但这确实也意味着,你偶尔需要处理磁盘损坏告警,找数据中心的白手套来换备件。

但是在全局图景中,这些都是微不足道的差异。对于一个云上租赁弓马娴熟的人来说,教会他在自有硬件上运行同样的技术栈并不会耗费太长时间(这个学习曲线肯定比跟上Kubernetes的速度要容易多了)

针对这个问题,有些人建议使用"私有云"这个术语。这个名字让我联想到毛骨悚然的CIO白皮书 —— 虽然我也不认为它有足够的冲击力让公众明了其中的差异。但我得承认,对于刚刚沉淀下一些“云XX”自我身份认同的整个行业来说,这个术语显然更能缓和气氛。这不禁让我思考起来。

最终,我认为这里的关键区别不是公有还是私有,而是 —— 自有还是租赁。我们需要反击云上租赁的 “你将一无所有并乐在其中 ” 的宣传。这种异端观点有悖于时代精神,会招致互联网( —— 这个分布式的,无需许可的世界奇观)支持者的强烈反感。

因此,让我提出一个新术语:“主权云”。

主权云建立在所有权与独立性上。这是一个的可选的升级项:对所有的云租户来说,只要他们的业务强大到足以承担一部分前期成本。这也是一个值得追求的目标:拥有自己的数字家园,而不是从科技巨头云领主那里租用农场。这也是一场对互联网集中化,和正在兴起的赛博地主垄断租金的反击运动。

试试看吧!


05-02 下云还有性能回报?

Cloud exit pays off in performance too[6]

上周,我们成功完成了迄今为止最大的一次下云行动,这次是搬迁 Basecamp Classic。这是我们从2004年开始就整起来的元老应用。而现在,在AWS上运行了几年之后,它又回到了我们自己的硬件上使用 MRSK 管理,天哪,性能实在是太屌了,看看这张监控图:

现在的请求响应时间中位数只有 19ms,而以前要 67ms;平均值从138ms 降至 95ms。查询耗时的中位数降了一半(当你一个请求做很多查询时,这可是会累积起来放大的)。Basecamp Classic 在云上的表现一直还不错,但是现在95%的请求RT都低于那个的 300ms “慢"界限。

也别把这些比较太当回事:这并非严谨的净室科学实验,只是我们刚刚离开精细微调的云环境,放到自有替代硬件上刚跑出来的峰值。

Basecamp Classic 以前跑在 AWS EKS 上(那是他们的托管 Kubernetes ),应用本身混用 c5.xlarge 和 c5.2xlarge 实例部署。数据库跑在 db.r4.2xlarge 和 db.r4.xlarge 规格的 RDS 实例上。现在都搬回老家,跑在配备了双 AMD EPYC 9454 CPU 的 Dell R7625 服务器上。

用来跑应用和任务的 vCPU 核数规格都一样,122个。以前是云上的 vCPU,现在是 KVM 配置的核数。而且,在保持上面牛逼性能的前提下,现在负载水平还有很大压榨空间。

实际上,考虑到我们的每一台新的 Dell R7625 都有196个 vCPU,所以包含数据库与 Redis 在内的整个 Basecamp Classic 应用其实可以完整跑在这样的单台机器上!这简直太震撼了,当然,出于安全冗余的考虑你并不会真的这么做。但这确实证明了硬件领域重新变得有趣起来,我们绕了一圈又回到了 “原点” —— 当 Basecamp 起步时,我们就是在单个机器(只有1核!)上跑起来的,而到了 2023 年,我们又能重新在一台机器上跑起来了。

这样的机器每台不到两万美元,除路由器外的所有硬件五年摊销,就是333美元/每月。这就是当下运行完整 Basecamp Classic 所需的费用 —— 而这仍然是一个每年实打实产出数百万美元收入大型的 SaaS 应用!而市场上绝大多数 SaaS 业务服务客户所需的火力将远远少于此。

我们并未期望下云能提高应用的性能,但它确实做到了,这真是个意外之喜。尤其是 Basecamp Classic,我们业务中的二十年老将,依然在为一个庞大的,忠诚的,满意的客户群提供服务,这些客户在过去十年里都没获得任何新功能 —— 不过,嘿,速度也是一种特性,所以呢,你也可以说我们刚刚发布了一个新特性吧!

接下就是下云的重头戏了 —— HEY!敬请期待。


04-06 下云所需的硬件已就位!

The hardware we need for our cloud exit has arrived[7]

距离我上次看到运行我们 37Signal 公司服务所使用的物理服务器硬件已经过去很长时间了。我依稀记得上次是十年前参观我们在芝加哥的数据中心,但是在某个时间点,我对硬件就失去兴趣了。然而现在我又重新对它感兴趣了 —— 因为硬件领域变得越来越有趣起来。所以让我来和你们分享一下这种兴奋吧:

这是最近运抵我们芝加哥数据中心的两个托盘。同一天,一套同样的设备也到达了我们在弗吉尼亚州 Ashburn 的第二个数据中心。总的来说,我们收到了二十台R7625 Dell 服务器,是支撑我们的下云计划的主力。如此令人震惊的算力,占用的空间却惊人的小。

这是我们在芝加哥数据中心四个机柜的示意图(我们在 Ashburn 还有另外四个)。正如你所见,还有一堆专门给 Basecamp 用的老硬件。一旦我们装好新机器,大部分老服务器就该退役了。在下面带有 “kvm” 标记的2U服务器是新家伙:

这儿你可以看到新的R7625服务器位于机架底部,挨着旧设备:

每台R7625都包含两个 AMD EPYC 9454 处理器,每个处理器 48个核心/96个线程,频率 2.75 GHz。这意味着我们为私有部署大军的容量添加了近4000个虚拟CPU!近乎荒谬的 7680GB 内存!以及 384TB 的第四代 NVMe SSD 存储!除了足以满足未来数年需求的强大马力外,在夏季前还有另外六台数据库服务器要过来,然后我们就全准备好啦。

与 Basecamp 起源形成鲜明对比的是,我们在2004年以一台只有256MB内存的单核Celeron服务器启动了Basecamp,用的还是 7200转的破烂硬盘。而那些玩意已经足够我们在一年间把它从兼职业务转为全职工作。

二十年后,我们现在有一大堆历史遗留应用(因为我们承诺让客户依赖的应用运行到互联网的终点!),一些诸如 Basecamp 和 HEY 的大型旗舰服务,以及让这些重新运行在我们自己硬件上的使命任务。

想想三个月前,我们决定放弃 Kubernetes 并使用 MRSK 打造更简单的下云解决方案,这还是挺疯狂的一件事儿。但是看看现在,我们已经把一半跑在云上的应用搬回了家中!

在接下来的一个月中,我们计划将 Basecamp Classic(这玩意13年没有更新了,但仍然是一个每年赚数百万美元的业务 —— 这就是SaaS的魔法!),以及下云的重头戏 —— HEY!全部都带回家。这样在五月初的话,我们云上就只剩下 Highrise 和一个名叫 Portfolio 的小型辅助服务了。我原本以为,在夏末完成下云已经是很乐观的估计了,但现在看来,基本上会在春天结束之前就能搞定,绝对算是我们团队的一个杰出成就。

加速的时刻表让我对下云大业更加充满信心,我本以为下云会跟上云一样困难,但事实表明并非如此。我想也许是每月三万八千美元的云消费的原因,它就像吊在我们面前的胡萝卜一样,激励着我们更快地去完成这件事。

我真诚地希望那些看着自己令人生畏的云账单的 SaaS 创业者注意到这一点:上云之后再下来这件事,看起来几乎是不可能的,但你一个字儿也别信!

现代服务器硬件在过去几年中,在性能、密度和成本上都有了不可思议的巨大飞跃。如果在过去十年中云已经成为了你的默认选项,那么我建议你重新了解一下相关数字。这些数字很可能会像震惊我们一样吓到你们。

因此,下云的终点就在眼前,我们已经解决了所有下云所需的关键技术挑战,让这件事变得切实可行起来。我们已经在 MRSK 上运行生产应用一段时间了。道路是非常光明的,我已经迫不及待地想看到那些巨大的云账单赶紧消失掉。我觉得之前粗略估算的下云降本数额已经高度保守了,让我们拭目以待,我们也会分享出来。


03-23 裁员前不先考虑下云吗?

Cut cloud before payroll[8]

最近每周都能看到科技公司大裁员的新闻,几个科技巨头已经进行了第二轮裁员,而且没人说不会有第三轮。尽管在个人层面上这是很难受但 —— 我太难了!—— 但对整个经济来说,还算是有一个积极的方面:释放了被束缚的人才。

你看,大型科技公司在疫情期间以如此高的薪资吞噬吸收了大量人才,以至于几乎没有给生态中其他人留点渣。在关键技术人才的竞争中,除了科技巨头之外的许多公司都出不起价,被挤出了市场。长期来看,这对经济来说肯定不是啥好事。我们需要聪明人去关注一些除了让人点击广告之外的问题。

尽管 Facebook、Amazon 这些科技巨头的巨额裁员占据了新闻头条,小型科技公司也在进行裁员,很难相信这里还有什么生机。当然,小公司也有可能和巨头们一样,雄心勃勃地雇佣了一批他们不仅不需要,反而会拖慢进度的人。如果是这样,那还算有点道理。

但也有这种可能,这些公司的技术的市场正在收缩,而投资者不再愿意延长亏损期,所以不得不裁员以削减成本以免破产。这是谨慎的做法,但是人力并不是唯一的成本。

在我所了解的大部分科技公司中,主要有两项大的开支:员工 与 云服务。员工通常是最大的开支,但令人震惊的是,云服务的费用也可以非常非常大。或者对真正算过这笔账的人来说,“震惊”这个词不太合适,恰当的用词是 —— “可怖”。

在与云业务有关的事上我可能有些老生常谈了,但当我看到科技公司试图通过裁员,而不是控制他们的云支出来削减成本时,我真的感到非常困惑。削减云开支最好的方式,特别是对于中等及以上规模的软件公司来说,就是部分/全部下云!

所以我敦促所有正在检阅预算,想知道在哪里可以降本增效的创始人和高管们优先关注云开支。也许你可以通过优化现有的资源使用来走的更远(现在有一整个新冒出来的小行业在干这事:FinOps),但也许你也高估了自己采购硬件自建与下云的难度。

我们已经完成了一半的下云工作。我们真正开始全力以赴是在1月份。我们不仅在搬迁那些最先进的现代技术栈,还要迁移很多历史遗留领域的应用。然而在下云的进度上,这已经比我敢想到的速度要快太多了。我们已经在下云日程表上远超预期了,大幅度的开支缩减肉眼可见。

如果我们可以这么快地做到这些,那么当你经营一家公司而准备打印那些解雇通知前,至少有责任计算一下这些数字并检视一下可行的选项,这也是对你的员工应当负起的责任。


03-11 失控的不仅仅是云成本!

It’s not just cloud costs that are out of control[9]

这个月底我们在 Datadog 上的年度订阅要到期了,我们不打算续费。

Datadog 是一个性能监控工具,倒不是因为我们不喜欢这项服务 —— 实际上它非常棒!我们不续订是因为它每年花费我们 88,000 美元,这实在是太离谱了。而且这反映出一个更大的问题:企业SaaS定价越来越愚蠢荒谬。

然而,我原本以为我们的账单已经够傻逼了。但是竟然有一个 Datadog 的客户为他们的服务支付了 6500 万美元!!这个信息是在他们最近的财报电话会议上提到的。这显然是一家加密货币公司,用“优化”账单的方式承认了这项蠢出天际的开支。

冒着陈词滥调的风险我也要说:这种花在性能和监控服务上的支出就像零利率投资一样:我无法想象在哪个宇宙里会认为这是一项合理的开支。这种事儿明显能用开源替代解决,如果你要做点内部二开魔改定制还能解决的更好更优雅。

但我发现很多企业级 SaaS 软件都是这样的,比如在一个2000人的公司里采购 Salesforce 的 Slack, 每人每月 15$ :仅仅是为了一个聊天工具,每年的开支就超过了三十万美元!

现在,再加上像 Asana 这样的工具,每人每月30刀。然后是 Dropbox 每人每月20美元。只是这三个工具,每个席位每月就需要每月65刀的费用。对那家2000人的公司来说,每年的成本就是一百五十万刀。哎妈呀!

我很惊讶于目前还没有更多的压力迫使那些为他们的SaaS软件支付高昂费用的公司去削减成本。但我觉得快了 —— 感觉我们现在正处在一个服务的泡沫里,等待着破裂 —— 总会有人看到这些利润空间并发现机会。

这也是我们坚持 Basecamp 调性的一个关键原因。任何人向我们支付的最高费用不会超过每月299美元 —— 不限用户,不限功能,所有一切。是的,我们是不是扔掉了钓大鱼的机会?可能吧。但我不能心安理得地以我自己都不愿支付的价格来出售这些软件。

我们确实需要在这儿调整一下。


02-22 指导下云的五条价值观

Five values guiding our cloud exit[10]

当谈及我们离开云的原因时,已经说了很多关于 成本 的事儿。尽管成本非常重要,但它并不是唯一的动机。以下是五条引领我们决策的价值观,我最近在37signals的内网文章里阐述了这些原则:

1. 我们最看重的是独立性。被困在亚马逊的云里,在实验新东西(比如固态缓存)时,不得不忍受高昂到荒诞的定价带来的羞辱,这已经构成对此核心价值观无法容忍的侵犯。

2. 我们服务于互联网本身。这个业务的整个存在,都归功于社会与经济上的异类 —— 互联网。可以用来进行包括商业在内的各种活动,却不归属任何一家公司或任何一个国家。自由贸易和自由表达得以在一个人类历史上前所未有的规模上实现。我们不会让手中的钱,被用于侵蚀这个理想乡 —— 让支撑这个美丽的自由避风港的服务器在集中到少数几个超大规模数据中心的手中。

3. 我们明智地花钱。在几个关键例子上,云的成本都极其高昂 —— 无论是大型物理机数据库、大型 NVMe 存储,或者只是最新最快的算力。租生产队的驴所花的钱是如此高昂,以至于几个月的租金就能与直接购买它的价格持平。在这种情况下,你应该直接直接把这头驴买下来!我们将把我们的钱,花在我们自己的硬件和我们自己的人身上,其他的一切都会被压缩。

4. 我们引领道路。在过去十多年间,云作为 “标准答案” 被推销给我们这样的 SaaS 公司。我相信了这个故事,我们都相信了这一套,然而这个故事并不是真的。云计算有它的适用场景,例如我们在 HEY 起步时就很好地使用了云,然而这是一个少数派生态位。许多像我们一样规模的 SaaS 业务应当拥有他们自己的基础设施而不是靠租赁。我们将为认同这一结论的追随者提前铺设好意识形态与技术上的道路。

5. 我们寻求冒险。“不要弄些小打小闹的计划,它没有激发人们热血的魔力,本身也可能无法实现。要绘制宏伟蓝图,志存高远,并竭尽所能” —— 丹尼尔·伯纳姆。我们已经在这一行打拼了二十多年了。为了维持内心的热情,我们应当继续设定高标准,坚守我们的价值观,并在各个方面探索新边疆,否则我们会枯萎的。我们不需要成为最大的,我们也不需要成为赚得最多的,但我们确实需要继续学习,挑战和追求。

我们走!


02-21 下云将给咱省下五千万!

We stand to save $7m over five years from our cloud exit[11]

自从去年十月份我们宣布打算下云以来,我们一直在脚踏实地的推进。在一个企业级Kubernetes供应商那儿走了一条短暂的弯路后,我们开始自己开发工具,并在几周前成功地将第一个小应用从云上搬下来。现在我们的目标是在夏天结束前完全下云,根据我们的初步计算,这样做将在五年内为我们节省大约700万美元的服务器费用,而无需改变我们运维团队的规模。

粗略的计算方式是这样的:我们在2022年的云开销是320万美元。其中有将近100万美元用在 S3 上,用于存储 8PB 的文件,这些文件在几个不同区域中进行了全量复制。而剩下的大约230万美元用在其他所有东西上:应用服务器、缓存服务器、数据库服务器、搜索服务器,其他所有的事情。这部分预算是我们打算在2023年归零的部分。然后我们将在2024年再来着手把 S3 上的 8PB 给搬走。

经过深思熟虑与多次基准测试,我们叹服于 AMD新的 Zen4 芯片,以及四代NVMe磁盘的惊人速度。我们基本上准备好向 Dell 下订单了,大约 60万 美元。我们仍在仔细调整所需的具体配置,但是对算总账来说,无论我们最终是每个数据中心订购8台双插槽64核CPU的机器(一个箱子里256个vCPU!),还是14台运行单插槽CPU但主频更高的机器,这种细节并不重要。我们需要为每个数据中心添加约 2000核vCPU算力,而我们的业务跑在两个数据中心里,所以考虑到性能和冗余的需求,我们需要 4000 核vCPU 算力,这都是粗略的数字。

在云的时代,投入60万美元购买一堆硬件可能听上去不少,但如果你把它摊销到五年里,那就只有12万美元一年!这已经很保守了,我们还有不少服务器已经跑了七八年了。

当然,那只是服务器盒子本身的价格,它们还需要接上电源和带宽。我们目前在Deft的两个数据中心上有八个专用机架,每月花费大约6万美元。我们特意超配了一些空间,所以我们呢可以把这些新服务器塞进现有的机架中,而无需为更多机位与电力付钱,因而每年机房本身开销仍然是 72万 美元。

所有的花销总计每年84万美元:包括了带宽、电力,以及按照五年折旧的服务器盒子。与云上的230万美元相比,我们的硬件要快得多,有更多的算力,极其便宜的 NVMe 存储,以及用极低的成本进行扩展的空间(只要我们每个数据中心的四个机架里还放得下)。

我们大致可以说,每年节省了150万美元。预留出一个50万美元应对未来五年中尚未预见到的开销,那么在未来五年,我们仍然可以省下 700万 美元!!

在当下时间点,任何中型及以上的SaaS业务,如果他们的工作负载稳定,却没有对云服务器的租赁费用和自建服务器进行测试比对,那么可以称得上是财务渎职行为。我建议你打电话给 Dell,然后再打给Deft。拿到一些真实的数据,然后自己做决定。

我们将在2023年完成下云(除了S3),并将继续分享我们的经验,工具,以及计算方法。


01-26 折腾硬件的乐趣重现

Hardware is fun again [12]

2010 年代,我对计算机硬件的兴趣几乎消失殆尽。一年又一年,进步似乎微乎其微。Intel陷入困境,CPU的进步也很有限。唯一能让我眼前一亮的是Apple在手机上的A系列芯片的进步。但那更像是与常规计算机不同的另一个世界。现在却不再是这样了!

现在,计算机硬件再次活跃起来!自从Apple的M1首次亮相以来,硬件有趣程度和大幅改进的通道已经打开。不仅仅是 Apple ,还有 AMD 和 Intel 。世代跃变发生得更加频繁,增益也不再是“微不足道”。处理器核数在迅速增长,单核性能定期向前跃进 20-30%,而且这种进步积累得很迅速。

虽然我对技术本身很感兴趣,但我更关心这些新飞跃会带来什么。例如在2010年代,我们曾经因为手机运行 JavaScript 速度太慢,所以不得不为 Basecamp Web应用创建一个专门的移动版本。现在大多数 iPhone 的速度已经超过了大多数计算机,甚至安卓的芯片也在迎头赶上。因而维护单独构建版本的复杂性已经消失了。

在 SSD/NVMe 存储上也发生了类似的情况,这里的这里的代际跃进幅度甚至比 CPU 领域还要大。我们快速地从第二代约 500MB/秒的速度,到第三代的大约2.5GB/秒,再到第四代大约 5GB/秒,而现在第五代为我们带来高到荒谬的约 13GB/秒 的速度!这种数量级的飞跃,需要你重新想想你的工作假设了。

我们正在探索将 Basecamp 的缓存和任务队列从内存实现转变为NVMe磁盘实现。现在两者的延迟已经足够接近了,所以容量充足且足够快的 NVMe存储更有优势。我们刚买了一些 12TB 的第四代NVMe卡,使用新的 E1/E3 NVMe接口规格,价格两万块不到($2,390),这可是 12TB 啊!我们现在正在考虑单机柜 PB级全闪存储服务器的可能性,大概二十万美元,这简直太疯狂啦!

有整整一代的应用开发者只知道云,这使他们一叶障目,无法充分认知到硬件的直接进步速度。随着越来越多的公司开始重新计算云账单,并把他们的应用从服务器租赁市场上撤回来,我认为我们将看到越来越多的开发者,重新关心起裸金属服务器来。

举个例子,我很喜欢这个粗略的草稿证明,它证明了 Twitter 可以在单台服务器上运行。你可能不会真的这样做,但这里的数学计算非常有趣。我认为这确实提出了一种观点,那就是我们最终可能会很容易地做到这一点:运行我们所有的高级服务,为数百万用户服务,运行于单台服务器上(或为了冗余考虑,还是三台吧)。

我迫不及待地想要一台配备 Gen 5 NVMe 的 AMD Zen4 机器。拥有 384 个 vCPU 和 13GB/秒带宽的存储,全跑在一台双CPU插槽的刀片服务器内。我已经等不及报名参加这个即将到来的未来了!


01-10 “企业级“替代品还要离谱

The only thing worse than cloud pricing is the enterprisey alternatives[13]

我们在过去几个月一直在想,把 HEY 从云中带回家可能会涉及到 SUSE Rancher 和 Harvester。这些企业级软件产品的组合跑在自有硬件上,却会给我们提供类似于云上的使用体验,而且对现有 HEY 的打包和部署只需进行最小化的改造。但是,当我们需要几次在线会议才能获取关于定价的基本信息时,我们就应该觉察到有些不对劲了,然后看看别的,因为最后的报价完全就是个狗屎

我们每年在云上租用硬件和服务的开销大约为 300万美元。拥有我们自己的硬件,运行开源软件来替换,有一部分很重要的目的就是为了砍低那个荒谬的账单。但是我们从 SUSE 收到的报价竟然更为荒谬,200万美元,仅仅是许可和支持成本,仅仅是在我们自己的硬件上运行Rancher和Harvester。

最初,我们其实认为这不会是一个问题。企业销售以高出天际的标价而臭名昭著,然后打折回到实际价格以成交。当我们采购我们热爱的戴尔服务器时,经常能得到 80% 的折扣!所以,配置出一个一万美元的服务器并没有挥霍的感觉 —— 如果实际成交价只是2000美元。

如果这里的情况也类似,那么这个两百万美元的合同将会是每年40万美元。仍然太高,但是我们可以在这里那里削减一点,也许最后能得到我们可以接受的结果。但当我们开始讨价还价施加压力以获得折扣时,答案是 3, 3%。好吧,服了,拉倒吧!

我并不是来告诉别人市场能承受什么价格的。我听说 SUSE 把这些套餐卖给军队和保险公司的生意还不错,祝他们好运。

但是为什么他妈的这帮人要在一个又一个的会议中对价格折扣守口如瓶,浪费我们的时间?

因为那就是企业销售游戏。讨价还价,欺诈,玩弄。看看我们能逃脱多少狗屎的游戏,让那些在谈判中搏斗的西装革履的人生活有意义。那就是赢得交易的含义 —— 阻挠欺骗另一方。

我受够了。事实上,我极其厌恶它。

所以现在我把那种怨气装瓶摇两下,然后大口闷下去以驱使我选择另一条路:我们要建造自己的主题公园,有二十一点,以及……没有那些该死的企业销售人员。


2022-10-19 我们为什么要下云?

Why we’re leaving the cloud[14]

过去十多年间 Basecamp 这个业务一只脚在云上,而 HEY 自两年前推出以来就一直独占云端。我们在亚马逊云和谷歌云上大展身手,我们在裸金属与虚拟机上运行,我们在 Kubernetes 上飞驰。我们见识了云上的花花世界,也都尝试过大部分。终于是时候画上句号了:对于像我们这样稳定增长的中型公司来说,租用计算机(在绝大多数情况下)是比糟糕的交易。简化复杂度带来的节省并未变成现实。所以,我们正在制定下云的计划。

云在生态位光谱的两个极端上表现出色,而我们只对其中一个感兴趣。第一个是当你的应用非常简单,流量很低,使用完全托管的服务确实可以省却很多复杂性。这是由 Heroku 开创的光辉之路,后来被 Render 和其他公司发扬光大。当你还没有客户时,这仍然是一个绝佳的起点,即使你开始有一些客户了,它也能支撑你走得很远。(然后随着使用量增加,而账单蹭蹭往上涨突破天际时,你会面临一个大难题,但这是合理的利弊权衡。)

第二个是当你的负载高度不规则时。当你的使用量波动剧烈或者就是高耸的尖峰,基线只是你最大需求的一小部分;或者当你不确定是否需要十台服务器还是一百台。当这种情况发生时,没有什么比云更合适的了,就像我们在推出 HEY 时遇到的情况:突然有 30 万用户在三周内注册尝试我们的服务,而不是我们预计的六个月内 3 万用户。

但是,这两种情况现在对我们都不适用了。对 Basecamp 来说则是从来没有过。继续在云上运营的代价是,我们为可能发生的情况支付了近乎荒谬的溢价。这就像你为地震保险支付四分之一的房产价值,而你根本不住在断层线附近一样。是的,如果两个州之外的地震让地面裂开,导致你的地基开裂,你可能会很高兴自己买了保险,但这里的比例感很不对劲,对吧?

以 HEY 为例。我们在数据库(RDS)和搜索(ES)服务上,每年要支付给 AWS 超过50万美元的费用。是的,当你为成千上万的客户处理电子邮件时,确实有大量的数据需要分析和存储,但这仍然让我感到有些荒谬。你知道每年50万美元能买多少性能怪兽一般的服务器吗?

现在的论调都是这样:没错,但你必须管理这些机器!而云要简单得太多啦!节省下来的都是人工成本!然而,事实并非如此。任何认为在云中运行像 HEY 或 Basecamp 这样的大型服务是“简单”的人,显然从未自己动手试过。有些事情更简单了,而其他事情更复杂了,但总的来说,我还没有听说过我们这个规模的组织因为转向云而能够大幅缩减运维团队的。

不过,这确实是一个绝妙的营销妙招。用类比来推销,比如 “你不会自己开发电厂,对吧?” 或者 “基础设施服务真的是你的核心能力吗?” ,然后再刷上层层脂粉,云的光芒如此闪耀,以至于只有愚昧的路德分子(强烈抵制技术革新的人)才会在它的阴影下运维自己的服务器。

与此同时,亚马逊以高额利润出租服务器来大发横财。尽管在未来的产能和新服务上进行了巨大的投资,AWS 的利润率还能高达 30%(622亿美元营收,185亿美元的利润)。这个利润率肯定还会飙升,因为该公司表示,“计划将服务器的使用寿命从四年延长到五年,将网络设备的使用寿命从五年延长到将来的六年”。

很好!从别人那里租用计算机当然是很昂贵的。但它从来没有以这种方式呈现 —— 云被描述为按需计算,听起来很前卫很酷,而绝非像“租用电脑”这样平淡无奇的东西,尽管它基本上就是这么回事。

但这不仅仅关乎成本,也关乎我们希望在未来运营一个什么样的互联网。这个去中心化的世界奇迹现在主要是在少数几个大公司拥有的计算机上运行,这让我感到非常悲哀。如果 AWS 的主要区域之一出现故障,看似一半的互联网都会随之下线。这可不是 DARPA 的设计初衷啊!

因此,我认为我们在 37signals 有责任逆流而上。我们的商业模式非常适合拥有自己的硬件,并在多年内进行折旧。增长轨迹大多是可预测的。我们有专业的人才,他们完全可以将他们的才华用于维护我们自己的机器,而不是属于亚马逊或谷歌的机器。而且,我认为还有很多其他公司处于与我们类似的境地。

但在我们勇敢扬帆起航,回到低成本和去中心化的海岸之前,我们需要转舵以扭转公共议题的风向,远离云服务营销的胡言乱语 —— 比如营运你自己的发电厂这类屁话。

直到最近,人们自建服务器所需的工具已经有了巨大的进展,让云成为可能的大部分工具也可以用在你自己的服务器上。不要相信根深蒂固的云上既得利益集团的鬼话 —— 自建运维过于复杂。当年的先辈,开局一条狗,平地起高楼搞起了整个互联网,而现在这件事已经容易太多了。

是时候让拨云见日,让互联网再次闪耀人间了。


References

[1] X celebrates 60% savings from cloud exit

[2] The price of managed cloud services

[3] Our cloud exit has already yielded $1m/year in savings

[4] We have left the cloud

[5] Sovereign clouds

[6] Cloud exit pays off in performance too

[7] The hardware we need for our cloud exit has arrived

[8] Cut cloud before payroll

[9] It’s not just cloud costs that are out of control

[10] Five values guiding our cloud exit

[11] We stand to save $7m over five years from our cloud exit

[12] Hardware is fun again

[13] The only thing worse than cloud pricing is the enterprisey alternatives

[14] Why we’re leaving the cloud

FinOps终点是下云

微信公众号原文 | Vonng博客

在 SACC 2023 FinOps专场上,我狠狠喷了一把云厂商。这是现场发言的文字整理稿,介绍了终极 FinOps —— 下云 的理念与实践路径。


太长不看

FinOps关注点跑偏总价 = 单价 x 数量,搞 FinOps 的关注减少浪费资源的数量,却故意无视了房间里的大象 —— 云资源单价

公有云是个杀猪盘:廉价EC2/S3获客,EBS/RDS杀猪。云算力的成本是自建的五倍,块存储的成本则可达百倍以上,堪称终极成本刺客。

FinOps终点是下云:对于规模以上企业,IDC自建成本在云服务列表价1折上下。下云是原教旨 FinOps 的终点,也是真正 FinOps 的起点。

自建能力决定议价权:拥有自建能力的用户即使不下云也能谈出极低的折扣,没有自建能力的公司只能向公有云厂商缴纳高昂的 “无专家税” 。

数据库是自建关键:K8S 上的无状态应用与数据仓库搬迁相对容易,真正的难点是在不影响质量安全的前提下,完成数据库的自建。


FinOps关注点跑偏

比起浪费的数量,资源单价才是重点。

FinOps 基金会说,FinOPS关注的是“云成本优化”。但我们认为只强调公有云其实是有意把这个概念缩小了 —— 值得关注的是全部的资源的成本管控优化,而不仅仅局限在上公有云 —— 还有“混合云”与”私有云“。即使不用公有云,FinOps 的一些方法论也依然能用于 K8S 云原生全家桶。正因为这样,很多搞 FinOps 的人关注点被刻意带偏 —— 目光被局限在减少云资源浪费数量上,却忽视了一个非常重要的问题:单价

总成本取决于两个因素:数量 ✖️ 单价。比起数量,单价可能才是降本增效的关键。前面几位嘉宾介绍过,云上资源平均有 1/3 左右的浪费,这也是 FinOps 的优化空间。然而如果你把不需要弹性的服务放在公有云上,本身使用的资源单价就已经溢价了几倍到十几倍,浪费的部分与之相比只能算三瓜两枣。

在我职业生涯的第一站,便亲历过一次 FinOps 运动。我们的BU曾是阿里云第一批内部用户,也是“数据中台”诞生的地方,阿里云出了十几个工程师直接加入带我们上云。上了 ODPS 后每年存储计算开销七千万,而通过健康分等 FinOps 手段确实优化了十几个M的浪费。然而原本使用自建机房 Hadoop 全家桶跑同样的东西,每年成本不到一千万 —— 节约是好事,但与翻几番的资源成本相比根本算不了什么

随着降本增效成为主旋律,云遣返也成为一种潮流。发明中台这个概念的阿里,自己已经开始拆中台了。然而还有很多企业在重蹈杀猪盘的覆辙,重复着上云 - 云遣返的老路。


公有云是个杀猪盘

廉价EC2/S3获客,EBS/RDS杀猪。

公有云所鼓吹的弹性是针对其商业模式而设计的:启动成本极低,维持成本极高。低启动成本吸引用户上云,而且良好的弹性可以随时适配业务增长,可是业务稳定后形成供应商锁定,尾大不掉,极高的维持成本就会让用户痛不欲生了。这种模式有一个俗称 —— 杀猪盘

要想杀猪,先要养猪,舍不着孩子套不着狼。所以对于新用户、初创企业,小微用户,公用云都不吝于提供一些甜头,甚至赔本赚吆喝。新用户首单骨折,初创企业免费/半价 Credit,以及微妙的定价策略。以 AWS RDS 报价为例可以看出,1核2核的迷你机型,单价也就是 几$/核·月,折合三四百块人民币一年,可以说是非常便宜实惠(不含存储):如果你需要一个低频使用的小微数据库放点东西,这也许就是最简单便宜的选择。

然而,只要你稍微把配置往上抬哪怕一丁点儿,核月单价就出现了数量级的变化,干到了二三十~一百来刀,最高可以翻到几十倍 —— 这还没有算上惊悚的 EBS 价格。用户也许只有在看到突然出现的天价账单时,才会意识到到底发生了什么。

相比自建,云资源单价普遍在几倍到十几倍的范围,租售比在十几天到几个月之间。例如,探探IDC包网包电代维+运维网工所有成本均摊下来,一个物理机核算力核月成本在 19块钱,如果使用 K8S 容器私有云,一个虚拟核月成本只要7块钱。

与之对应,阿里云的 ECS 核月单价在一两百块钱,AWS EC2 的核月单价在两三百块钱。如果你“不在乎弹性”,预付买个三年,通常还能再打个五六折。但再怎么算,云算力和本地自建算力的几倍差价是放在这儿没跑的。

存储资源的定价则更为离谱,一块常见的 3.2 TB 规格企业级 NVMe SSD 有着极为强悍的性能、可靠性与性价比,批发价 ¥3000 元出头,全方位吊打老存储。然而,在云上同样的存储,就敢卖你 100 倍的价格。相比直接采购硬件,AWS EBS io2 的成本高达 120 倍,而阿里云的 ESSD PL3 则高达 200 倍

以 3.2TB 规格的企业级 PCI-E SSD 卡为参照基准,AWS 上按需售租比为 15 天,阿里云上不到 5 天,租用此时长即可买下整块磁盘。若在阿里云以采购三年预付最大优惠五折计算,三年租金够买下 120 多块同款硬盘

云盘是不是杀猪盘

云数据库RDS的的溢价倍率则介于云盘与云服务器之间。以 RDS for PostgreSQL 为例, AWS 上 64C / 256GB 的 RDS 用一个月价格 $25,817 / 月,折合每月 18 万元人民币,一个月的租金够你把两台性能比这还要好的多得多的服务器直接买下来自建了。租售比甚至都不到一个月,租十来天就够你买下来整台服务器。

任何理智的企业用户都看得明白这里面的道理:如果采购这种服务不是为了短期的,临时性的需求,那么绝对算得上是重大的财务失当行为。

付费模式 价格 折合每年(万¥)
IDC自建(单物理机) ¥7.5w / 5年 1.5
IDC自建(2~3台组HA) ¥15w / 5年 3.0 ~ 4.5
阿里云 RDS 按需 ¥87.36/时 76.5
阿里云 RDS 月付(基准) ¥4.2w / 月 50
阿里云 RDS 年付(85折) ¥425095 / 年 42.5
阿里云 RDS 3年付(5折) ¥750168 / 3年 25
AWS 按需 $25,817 / 月 217
AWS 1年不预付 $22,827 / 月 191.7
AWS 3年全预付 12w$ + 17.5k$/月 175
AWS 中国/宁夏按需 ¥197,489 / 月 237
AWS 中国/宁夏1年不预付 ¥143,176 / 月 171
AWS 中国/宁夏3年全预付 ¥647k + 116k/月 160.6

我们可以对比一下自建与云数据库的成本差异:

方式 折合每年(万元)
IDC托管服务器 64C / 384G / 3.2TB NVME SSD 660K IOPS (2~3台) 3.0 ~ 4.5
阿里云 RDS PG 高可用版 pg.x4m.8xlarge.2c, 64C / 256GB / 3.2TB ESSD PL3 25 ~ 50
AWS RDS PG 高可用版 db.m5.16xlarge, 64C / 256GB / 3.2TB io1 x 80k IOPS 160 ~ 217

云数据库是不是智商税

任何有意义的降本增效行动都无法忽视这个问题:如果资源单价有打0.5~2折的潜力,那么折腾30%的浪费根本排不上优先级。只要你的业务主体还在云上,原教旨 FinOps 就如同隔靴搔痒 —— 下云,才是 FinOps 的重点


FinOps终点是下云

饱汉不知饿汉饥,人类的悲欢并不相通。

我在探探待了五年 —— 这是一家瑞典人创办的北欧风互联网创业公司。北欧工程师有个特点,实事求是。在云与自建选型这件事上不会受炒作营销影响,而是会定量分析利弊得失。我们仔细核算过自建与上云的成本 —— 简单的结论是,自建(含人力)总体成本基本在云列表价的 0.5 ~ 1 折范围浮动

因此,探探从创立伊始就选择了自建。除了出海合规业务,CDN 与极少量弹性业务使用公有云,主体部分完全放在 IDC 托管代维的机房中自建。我们的数据库规模不小,13K 核的 PostgreSQL 与 12K 核的 Redis,450w QPS 与 300TB 不重复的TP数据。这两部分每年成本1000万不到:算上两个DBA一个网工的工资,网电托管代维费用,硬件五年均摊。但是这样的规模,如果使用公有云上的云数据库,即使打到骨折,也需要五六千万起步,更别提杀猪更狠的大数据部分了。

然而,企业数字化是有阶段的,不同的企业处于不同的阶段。对于不少互联网公司来说,已进入自建云原生 K8S 全家桶玩到飞起的阶段了。在这个阶段,关注资源利用率,在线离线混合部署,减少浪费是合理的需求,也是 FinOps 应该发力的方向。但是对那些占总量绝大多数的数字化门外汉企业来说,迫在眉睫的不是减少浪费,而是压低资源单价 —— Dell服务器可以打两折,IDC虚拟机可以打两折,云骨折也能打两折,请问这些公司是不是还在掏原价采购,甚至还有几倍的回扣费用呢?大把大把的公司还在因为信息不对称与能力缺失被嘎嘎割韭菜

企业应该根据自己的规模与阶段,评估审视自己的业务并进行利弊权衡。如果是小规模初创企业,云确实能节省不少人力成本,很有吸引力 —— 但请您保持警惕,不要因为贪图便利而被供应商锁定。如果您在云上的年消费已经超过了 100万人民币,那么是时候认真考虑一下 下云所带来的收益 —— 很多业务并不需要微博并发出轨,训练 AI 大模型那种弹性。为了临时/突发性需求,或出海合规支付溢价还算合情合理,但是为了不需要的弹性支付几倍到几十倍的资源溢价,那就是冤大头了。您可以把真正需要弹性的部分留在公有云上,把那些不需要弹性的部分转移到 IDC 。仅仅只是这样做就能省下的成本可能让用户惊掉下巴。

下云是原教旨 FinOps 的终点,也是真正 FinOps 的起点。


下云核心是自建

以斗争求和平则和平存,以妥协求和平则和平亡

时来天地皆同力,运去英雄不自由:在泡沫阶段,大家可以不在乎在云上大撒币,在经济下行阶段,降本增效成为核心议题。越来越多的公司意识到,使用云服务其实是一种缴纳“无专家税”与“保护费”的行为。行业也出现了下云的思潮 —— 云遣返,37 Signal DHH就是最著名的例子。与之对应,全球各大云厂商营收增速也出现了持续性下滑,阿里云的营收甚至已经增速为负 —— 开始负增长了(2023Q1)。

云计算为啥还没挖沙子赚钱

背后的时代趋势是,开源平替的出现,打破了公有云的技术壁垒;资源云/IDC2.0的出现,提供了公有云资源的物美价廉替代;大裁员释放的技术人才与将来的AI大模型,让各行各业都有机会拥有自建所需的专家知识与能力。结合以上三方面的趋势,IDC2.0 + 开源自建的组合越来越有竞争力短路掉公有云这个中间商,直接与 IDC 合作显然是一个更经济实惠的选择

公有云厂商并非做不好 IDC 卖资源站着挣钱的生意,按理说云厂商的水平比IDC好不少,理应通过技术优势与规模效应降本增效,向公众提供比 IDC自建更便宜的资源才对。然而残酷的现状是,资源云可以爽快的给用户2折虚拟机,而公有云不行。甚至如果考虑到存储计算行业摩尔定律的指数增长规律,公有云其实每年都还在大幅涨价

专业懂行的大客户,特别是那种随时有能力迁移横跳的甲方去和公有云搞商务谈判,确实有可能获取两折的底价折扣,而小客户不太可能有这种机会 —— 在这种意义上,云其实是吸血中小客户补贴大客户,损不足以奉有余的云厂商在大客户那边疯狂打折促销,而对中小客户和开发者进行薅羊毛杀猪,这种方式已经完全违背了云计算本身的初心与愿景。

云用低起步价格把用户赚上来,当用户被深度锁定之后,杀猪就会开始 —— 聊好的折扣与优惠,在每年续约时没有了。从云下来又要支付一笔伤筋动骨的大费用,用户处在进退两难的困境中只能选择饮鸩止渴,继续缴纳保护费。

然而对于有自建能力,可以在多云/本地混合云灵活横跳的用户来说,就没有这个问题:谈折扣的杀手锏,就是你自己有随时下云、或迁移至其他云上的自建能力,这比什么口舌都好使 —— 正所谓 “以斗争求和平则和平存,以妥协求和平则和平亡” 。成本能降多少取决于您的议价权,而议价权取决于您是否有能力自建

自建听上去很麻烦,但其实会者不难。关键是解决 资源能力 两个核心问题。而在 2023 年,因为资源云与开源平替的出现,这两件事已经比以前要简单太多了。

在资源方面,IDC与资源云已经解决得足够好了。上面说的IDC自建,并不是自己从头买土地建机房,而是可以直接使用资源云/IDC的机房托管 —— 你可能只需要一位网工规划一下网络,其他的运维性工作都可以交由供应商打理。

如果您懒得折腾,IDC能爽快地直接卖你云列表价两折的虚拟机,您也可以直接每月两三千租用现成的物理机 64C/256G;无论是整租一整个机房,还是只要一个零售托管机位都没问题。一个零售机位网电代维全包,一年五千块全搞定,整两台百核物理机跑个 K8S 或虚拟化,还要啥弹性ECS ?

自建还有一个额外的好处 —— 如果您真的想做到极致 FinOps,可以使用过保甚至二手服务器。服务器通常按三年五年均摊报废,但用八年十年的也不少见 —— 相比购买云服务的消费,这是实打实的资产,多用都算白赚。

上面所使用的 64C 256G 服务器全新价格也需要五万,但用了一两年二手甩卖的“电子垃圾”只需要两千八。换掉最容易坏的部件插块全新企业级3.2TB NVMe SSD (¥2800),整机六千块拿下。

在这种情况下,你的核月成本甚至可以做到1块钱以内 —— 游戏领域确实有这么个传奇案例,可以做到几毛钱一个服务器。有了K8S调度能力和数据库高可用切换能力,可靠性可以完全可以靠多台电子垃圾并联,最终做到令人震惊的成本效益比。

在能力方面,随着足够好用的开源平替出现,当下自建的难度和几年前完全不可同日而语

例如,Kubernetes / OpenStack / SealOS,可以理解为云厂商 EC2/ECS/VPS 管控软件的开源替代;MinIO / Ceph 旨在作为为云厂商 S3 / OSS 管控软件的开源替代;而 Pigsty / 各种数据库 Operator 就是 RDS 云数据库管控软件的开源替代 —— 有许许多多的开源软件提供免费用好这些资源的能力;也有许许多多的商业公司提供明码标价的服务支持

您的业务应该尽可能收敛到只用虚拟机和对象存储这种纯资源,因为这是所有云厂商的提供服务的最大公约数。在理想的状态下,所有应用都完整运行在 Kubernetes 上,而这套 Kubernetes 可以运行在任意环境中 —— 不论是云提供的 K8S底座,ECS,独占物理机,还是你自己机房的服务器上。外部状态例如数据库备份,大数据数仓使用存算分离的方案放在 MinIO / S3 存储上。

这样一套 CloudNative 技术栈,理论上有了在任意资源环境上运行与灵活搬迁的能力,从而避免了供应商锁定,掌握了主动权 —— 您可以选择直接下云省大钱, 也可以选择以此为筹码和公有云商务谈判,要一个骨折折扣出来继续使用

当然,下云自建也是有风险的,而最大的风险点就在 RDS 上。


数据库是最大风险点

云数据库也许不是最大的开销项,但一定是锁定最深,最难搬迁的服务。

质量、安全、效率、成本,是一个递进的需求金字塔上的不同层次。而 FinOps 的目标,是要在不影响质量安全的前提下完成降本增效

无论是K8S上的无状态应用,还是离线大数据平台,搬迁起来都很难有致命风险。特别是如果您已经完成了大数据存算分离无状态应用云原生改造,这两个部分搬动起来通常不会有太大困难。前者通常停几个小时也无所谓,后者则可以蓝绿部署,灰度切换。唯有作为工作记忆的数据库动起来容易搞出大问题。

可以说绝大多数 IT 系统的架构都服务于数据库这一核心,下云牵一发而动全身的关键风险点落在 OLTP 数据库 / RDS 上。很多用户不下云自建的原因正是缺少自建靠谱数据库服务的能力 —— 土法的 Kubernetes Operator 似乎没法达到云数据库的完整功能体验:把 OLTP 数据库放在 K8S/容器 中,使用 EBS 运行也不是成熟的最佳实践。

时代在呼唤一个足够好的 RDS 的开源替代品,而这正是我们要解决的问题:让用户可以在任意环境上自建起比肩甚至超越云数据库的本地 RDS 服务 —— Pigsty,开源免费的 RDS PG 替代。帮助用户真正用好 世界上最先进、最成功的数据库 —— PostgreSQL

Pigsty 是一个公益性质的自由软件,软件本身完全开源免费用爱发电。它提供了一个开箱即用扩展齐全的 PostgreSQL 发行版,带有自动配置的高可用与PITR,业界顶尖的监控系统,Infra as Code,提供一键安装部署的云端 Terraform 模板 与本地 Vagrant沙箱,针对各种操作都给出了 SOP 手册预案,让您无需专业 DBA 也可以快速完成 RDS 自建。

尽管 Pigsty 是一个数据库发行版,但它赋能用户践行最终极的 FinOps 理念 —— 用几乎接近于纯资源的价格,在任何地方(ECS,资源云,机房服务器甚至本地笔记本虚拟机)运行生产级的 PostgreSQL RDS 数据库服务。让云数据库的能力成本,从正比于资源的边际成本,变为约等于0的固定学习成本

可能也就是北欧企业的社会主义氛围,才能孵化出这种的纯粹的自由软件。我们做这件事不是为了挣钱,而是要践行一种理念:把用好世界上最先进的开源数据库 PostgreSQL 的能力普及给每一个用户,而不是让使用软件的能力沦为公有云的禁脔。云厂商垄断开源专家与岗位,吸血白嫖开源软件,而我们要要打破云厂商对于能力的垄断 —— Freedom is not free,你不应该把世界让给你所鄙视的人,而应该直接把他们的饭桌掀翻

这才是最终极的 FinOps —— 授人以渔,提供让用户真正用得上的更优备选,赋予用户自建的能力与面对云厂商的议价权


References

[1] 云计算为啥还没挖沙子赚钱?

[2] 云数据库是不是智商税?

[3] 云SLA是不是安慰剂?

[4] 云盘是不是杀猪盘?

[5] 范式转移:从云到本地优先

[6] 杀猪盘真的降价了吗?

[7] 炮打 RDS,Pigsty v2.0 发布

[8] 垃圾腾讯云CDN:从入门到放弃

[9] 云RDS:从删库到跑路

[10] 分布式数据库是伪需求吗?

[11] 微服务是不是个蠢主意?

[12] 更好的开源RDS替代:Pigsty

云计算为啥还没挖沙子赚钱?

微信公众号原文 | Vonng博客

公有云毛利不如挖沙子

杀猪盘为何成为赔钱货

卖资源模式走向价格战

开源替代打破垄断幻梦

服务竞争力逐渐被抹平

云计算行业将走向何方


公有云毛利不如挖沙子

在《云盘是不是杀猪盘》、《云数据库是不是智商税》以及《云SLA是不是安慰剂》中,我们已经研究过关键云服务的真实成本。规模以上以核·月单价计算的云服务器成本是自建的 5~10 倍,云数据库则可达十几倍,云盘更是能高达上百倍,按这个定价模型,云的毛利率做到八九十也不稀奇。

业界标杆的 AWS 与 Azure 毛利就可以轻松到 60% 与 70% 。反观国内云计算行业,毛利普遍在个位数到 15% 徘徊,榜一大哥阿里云最多给一句“预估远期整体毛利 40%” ,至于像金山云这样的云厂商,毛利率直接一路干到 2.1%,还不如打工挖沙子的毛利高。

而说起净利润,国内公有云厂商更是惨不忍睹。AWS / Azure 净利润率能到 30% ~ 40% 。标杆阿里云也不过在盈亏线上下徘徊挣扎。这不禁让人好奇,这些云厂商是怎么把一门百分之三四十纯利的生意能做到这种地步的?


杀猪盘为何成为赔钱货

我们可以列举出许许多多多可能的原因:营收压倒一切的KPI,销售主导的增长模式,大公司病与团队内耗,冗员导致的高昂成本,恶意竞争价格战同行卷翻,反佣产生的回扣贪腐,不顾生态亲自下场抢食吃,忘记初心迷失方向陷入歧途,等等等等。

但国内云厂商赔钱的核心问题,还是在于利润空间被两头挤压能提供的用户价值也因为资源云(国资云/IDC2.0)和开源平替的出现越来越少。而要理解这一点,就需要从从公有云的业务结构说起。

公有云可以分为 IaaS, PaaS,SaaS 三层,尽管这三层都带 S(ervice),但还是有不小的区别:底层更偏向于卖资源,顶层更偏向于卖能力(服务/技术/知识/认知/保险)。IaaS 层资源占主导地位,SaaS 层能力占主导地位,PaaS 层介于两者之间 —— 例如数据库,既可以视为一种利用整合底层存算资源的能力,又可以视作一种更高层次的抽象软件资源

在云刚出现的时候,核心是硬件 / IaaS层 :存储、带宽、算力、服务器。云厂商的初心故事是:让计算和存储资源像水电一样,自己扮演基础设施的提供者的角色。这是一个很有吸引力的愿景:公有云厂商可以通过规模效应,压低硬件成本并均摊人力成本;理想情况下,在给自己留下足够利润的前提下,还可以向公众提供比 IDC 价格更有优势,更有弹性的存储算力资源

但很快,公有云就不满足只卖包装硬件卖资源的 IaaS 了:卖资源吃饭的 IaaS 定价没有太大水分空间,可以对着 BOM 一笔一笔精算。但是像云数据库这样的 PaaS, 里面包含的“服务/保险”,人力 / 研发成本就包含大量水分,难以厘定,就可以名正言顺地卖出天价并攫取高额利润。

尽管国内公有云 IaaS 层存储、计算、网络三大件的收入能占营收一半的比例,但其毛利率只有 15% ~ 20%,而以云数据库为代表的公有云 PaaS 毛利率可以达到 50% 或更高,完爆卖资源吃饭的 IaaS

对公有云来说,PaaS 是核心技术壁垒IaaS 是营收基本盘,云厂商的主要营收也来自这两者。然而,前者面临开源平替的冲击,后者受到价格战的挑战。

AWS 的壁垒是先发优势,繁荣的软件生态,Azure 的壁垒是Office SaaS和大模型 PaaS,GCP的壁垒是全球一张网。反观国内云厂商的壁垒:阿里云的数据库,腾讯云的微信生态,百度云的大模型?


卖资源模式走向价格战

拔了毛的凤凰不如鸡,失去技术垄断的公有云将陷入卖资源价格战的泥潭。当质量安全效率搞不出亮点时,唯一能抢占市场份额的选择就是在成本上做文章 —— 价格战

然而与公有云 IaaS 云硬件竞争的,是运营商/国资云/IDC2.0。这些对手的特点就是有各种各样的资源 —— 特殊血统身份关系,自有机房网络土地,廉价带宽低息贷款;卖资源躺着挣钱,主打一个物美价廉:没啥高精尖 PaaS/SaaS,但IDC可以爽快的卖给用户公有云列表价两折或更低的虚拟机,租机柜自建托管更是便宜上天。云列表价 1/5 ~ 1/10 的综合成本,不玩云盘杀猪之类花里胡哨的东西,就是纯卖资源。

公有云厂商在面对这些对手时,敢卖天价甚至“涨价”(请注意,资源降价速度慢于摩尔定律等于涨价)的最大的壁垒就是自己有“技术” —— IaaS 层差距拉不了太大,靠的就是还有不错的 PaaS 作为壁垒,来吸引用户 —— 数据库,K8S,大模型以及配套的基础设施。而拥有能力的技术专家多被互联网/云计算大厂垄断,很多客户上云就是因为找不到稀缺的专家来自建这些服务,因而不得不向公有云缴纳高昂的“无专家税”与“保护费”

然而,开源的管控软件以普惠赋能的方式,起到了降维打击的效果。当这些资源型选手或者用户自己就可以轻松使用开源软件拉起、搭建、组织起自己的“私有云平台”时,公有云构造的技术壁垒护城河就被打破了。曾经靠技术垄断高高在上的云厂商被拉下神坛,拉到了和躺平纯卖资源同侪相近的起跑线。公有云厂商不得不卷入泥潭中厮杀斗兽起来,和这些自己曾经“看不上”的对手打成一团。


开源替代打破垄断幻梦

自由软件 / 开源软件曾经彻底改变了整个软件与互联网行业,而我们将再次见证历史。

最初,软件吞噬世界,例如以 Oracle / Unix 为代表的商业软件,用机器取代了人工,极大提高了效率,节省了很多开销。商业软件凭借“人无我有”形成了垄断优势,牢牢掌握了定价权。像 Oracle 这样的商业数据库非常昂贵,一核·一月光是软件授权费用就能破万,不是大型机构都不一定用得起,即使像壕如淘宝,上了量后也不得不”去O“。

接着,开源吞噬软件,像 PostgreSQL 和 Linux 这样”开源免费“的软件应运而生,打破商业软件的垄断。软件开源本身是免费的,只需要几十块钱每核·每月的硬件成本,即可获取接近商业软件的效能。比如在大多数场景下,如果能找到专家帮助企业用好开源操作系统数据库,那么要比用商业软件划算太多了。互联网的历史就是开源软件的历史,互联网的繁荣便是建立在开源软件之上。

开源的“商业逻辑”不是“卖产品”,而是创造专家岗位:免费的开源软件吸引用户,用户需求产生开源专家岗位,开源专家产出更好的开源软件,形成一个闭环。开源软件免费,但能帮助企业用好/管好 开源数据库的专家非常稀缺昂贵。这也产生了新的垄断机会 —— 产品没法垄断,那就垄断专家。垄断了专家,就能垄断提供服务的能力。于是,“云服务”出现了。

然后,云吞噬开源。公有云软件,是互联网大厂将自己使用开源软件的能力产品化对外输出的结果。公有云厂商把开源数据库内核套上壳,跑在自己的硬件资源上,并雇佣专家编写管控软件并提供代运维与专家咨询服务。大量高端专家人才被头部互联网厂商高薪垄断,普通公司想要用好开源软件,除了少数幸运者能找到“专家”自建,大部分不得不选择云服务,并支付十几倍甚至百倍的资源溢价。

那么,谁来吃云呢云原生运动,就是开源社区对公有云垄断的反击 —— 在公有云的话语体系中,CloudNative 被解释为长在公有云上的服务;而开源世界对此的理解是“在本地运行云一样的”服务。如何在本地运行云一样的服务?服务真正的壁垒,不是软件/资源本身,而是能用好这些软件的知识。—— 无论是以直接堆专家人力的形式,还是专家经验沉淀而成的管控软件 / K8S Operator 的形式,或者专家经验训练得到的大模型的形式。

实际上,真正负责云服务日常性、高频性、运维性核心主体工作的往往并不是专家本身,而是管控软件 —— 沉淀了专家经验的元软件。一旦这些云管控软件出现开源替代,开源软件打破商业软件垄断的剧情会再一次上演。

这一次,是本地优先的管控软件掀翻云管控软件。PaaS 失去垄断度,将使云厂商丧失部分定价权,进而利润受损。但真正让云受伤的,是基本盘 IaaS 资源生意失去壁垒,不得不直面纯资源厂商的价格战。


服务竞争力逐渐被抹平

利润源于定价权,定价权源于垄断度,垄断度取决于产品在市场上的相对竞争力。随着开源社区滚雪球形成合力,开源管控软件与云管控软件的竞争力已经被逐步抹平,甚至在一些领域出现了反超

例如,Kubernetes / OpenStack / SealOS,可以理解为云厂商 EC2/ECS/VPS 管控软件的开源替代;MinIO / Ceph 旨在作为为云厂商 S3 / OSS 管控软件的开源替代;而 Pigsty / 各种数据库 Operator 就是 RDS 云数据库管控软件的开源替代。

这些软件的特点在于:它们旨在解决管理好资源的问题,并提供用好软件本身的能力。此类自建服务的质量水平在很多方面不逊色甚至超越了所对标的云服务,而上手复杂度与人力成本基本持平,却只需要几分之一到十几分之一的纯资源成本即可:The more you run, the more you save!

自建的门槛也在以惊人的速度不断降低,一个初中级的研发运维,可以轻松使用 Sealos 这样的软件创建起具有弹性伸缩,资源调度能力的 Kubernetes 集群运行无状态应用;也可以轻松地使用 Pigsty 部署 PostgreSQL / Redis / MinIO / Greenplum 集群,声明式地拉起“自动驾驶”的本地云数据库(数仓/缓存/对象存储)存储状态,补完K8S的短板。

即使是云厂商,也不得不承认 Kubernetes 的成功:它已经成为了运行无状态弹性应用的事实标准,并且有很大概率成为下个时代的数据中心级“操作系统” —— 在应用与底层物理机资源之间,可能并不需要一层 EC2/VM 作为中间商。

同理,在用户使用数据库的平均水平就是yum安装+定时备份+设置密码的时代,云数据库RDS服务可以凭借质量安全效率合格标品的先进优势大杀四方,然而当顶尖水平的用户以可复制的形式输出最佳实践,出现开源免费的上位优质替代后,大锅饭合格品层次的 RDS 就只能相形见绌了。

作为公有云壁垒的云 PaaS 将在开源替代的冲击下走向独立/解体/萎缩/消亡,然而一鲸落,万物生,这也意味着更多的 PaaS/SaaS 创业团队将会迎来解放。


云计算行业将走向何方?

如果我们把目光回退至上世纪初,从电力的推广普及垄断监管中汲取历史经验。就不难看云计算行业的剧本走向 —— 云的故事与电力行业如出一辙,资源与能力的拆分是未来的方向

资源与基础设施性质的行业,最终的归宿是国家垄断。公有云的资源部分 —— IaaS 层会被剥离,整合,招安,成为算力/存储的“国家电网”。作为央企,国家电网并不负责发电,也不负责制造电器,它做的就是电力垄断资源的传输与分发。云 IaaS 也不会去制造芯片,硬盘,光纤,服务器,而是将其整合为存算网资源,交付到用户手上。国资云,运营商云,阿里云/华为云等会瓜分这一市场。

能力性质的行业,主旋律将是自由竞争,百花齐放。如果 IaaS 是供电行业,那么 PaaS/SaaS 便是电器行业 —— 提供各种不同的,使用存算网资源的能力。洗衣机,冰箱,热水器,电脑,都会涌现出无数创业公司与开源社区同台竞技,充分竞争。当然也会有一部分软件可以享受例外的垄断保护地位 —— 比如安可信创。

同时有着 IaaS / PaaS / SaaS 的公有云可能会解体。云内部的博弈可能要比外部竞争更激烈:IaaS 团队会认为,就连自己用的 IDC机房都有 30% 的毛利,凭啥有躺着卖资源挣钱的机会,却要去陪 PaaS/SaaS 一起卷?有能力的云软件团队会认为,在哪家云甚至私有云上卖不是卖,为啥要绑在一棵树上吊死,为 IaaS 作嫁衣裳?像 OceanBase 一样独立出去到处卖或者自己出来创业难道不香?

公有云厂商寡头价格战只是这个进程的开始,云厂 IaaS 在相互斗兽竞争中,通过垄断并购形成“规模效应”,利用“峰谷电”,“弹性定价”等各种方式优化整体资源利用率,会将算力成本不断压低至新的底线,最终实现“家家有电用”。中小云厂商估计不太可能活过这一场,当然,最后也少不了政府监管介入,公私合营国资入场。各家云 IaaS 成为类似于电信运营商的国有垄断企业,通过控制竞争烈度维持一个可观的利润率,边挣边躺。

公有云的 PaaS / SaaS 在被更好,更优质,更便宜的替代物冲击下逐渐萎缩,或回归到足够低廉的价格水平。数据库,K8S,云安全等业务团队会从云中拆分与独立,能打的云软件团队必然选择出来单干**,采取云中立的立场**,在各种云底座上与各种供应商、开源社区同台赛马,各显所长。

正如当年开源运动的死对头微软现在也选择拥抱开源。公有云厂商肯定也会有这一天,与自由软件世界达成和解,心平气和地接受基础设施供应商的角色定位,为社会提供水与电一般的平价存算网资源,云软件也会回归正常毛利率,不卑不亢,不骗不抢,采购软件如同购买家电一样稀松寻常。

博弈的终点非常清晰,然而道阻且长。但能肯定的是,我们的下一代将对于云端的习以为常,正如上一代看,这一代看

云SLA是不是安慰剂?

微信公众号原文 | 微信校订版 | Vonng博客

在云计算的世界里,服务等级协议(SLA)被视为云厂商对其服务质量的承诺。然而,当我们深入研究这些 SLA 时,会发现它们并不能像期望的那样“兜底”:你以为给自己的数据库上了保险可以高枕无忧,但其实白花花的银子买的是提供情绪价值的安慰剂。


保险单还是安慰剂?

许多用户购买云服务的一个原因是“兜底”,而问他们所谓“兜底”到底指的是什么,很多人会回答“SLA”。云专家将购买云服务比作购买保险:一些故障可能在许多公司的整个生命周期中都不会出现,但一旦遇到,后果可能就是毁灭性的。在这种情况下,云服务提供商的 SLA 就是兜底保险。然而,当我们实际查看这些 SLA 时,会发现这份“保单”并不像想象中那样有用。

数据是许多企业的生命线,云盘是公有云上几乎所有数据存储的基石,所以让我们以云盘服务为例。许多云服务提供商在其产品介绍中都会宣称他们的云盘服务具有 9个9 的数据可靠性【1】。然而当查看其 SLA 时,就会发现这些最为重要的承诺压根没有写入 SLA 【2】。

写入 SLA 的通常只有服务的可用性。而且这种可用性的承诺也流于表面,相比真实世界的核心业务可靠性指标极其逊色,赔偿方案相比常见停机损失来说约等于没有。比起保险单,SLA 更像是提供情绪价值的安慰剂。


拉胯的可用性

云 SLA 中使用的关键指标是可用性。云服务可用性通常表示为:可以从外部访问该资源的时间占比,通常使用一个月作为衡量周期。如果由于云厂商的问题,导致用户无法通过 Internet 访问该资源,则该资源将被视为不可用(Unavailable / Down)。

以业界标杆 AWS 为例,AWS 大部分服务使用类似的 SLA 模板3。AWS 上的单个虚拟机提供以下 SLA【4】。这意味着在最好情况下,如果 AWS 上EC2 一个月内不可用时间在 21 分钟内(99.9%),AWS 一分钱不赔。在最坏情况下,只有当不可用时间超过36小时(95%),您才能获得 100% 的代金券返还。

对于一些互联网公司来说,15分钟的服务故障就足以让奖金泡汤,30分钟的故障足够让领导下课。绝大多数时间实际运行的核心系统可用性可能有5个9,6个9,甚至无穷多个9。从互联网大厂孵化出来的云厂商使用如此逊色的可用性指标,实在是让人看了摇头。

更过分的是,当故障发生后,这些补偿也不是自动提供给你的。用户需要在时效(通常是两个月)内,自己负责衡量停机时间,提出申诉举证,并要求赔偿才会有。这要求用户去收集监控指标与日志证据和云厂商扯皮,换回来的也不是现金,而是代金券/时长补偿 —— 对云厂商而言可以说没有任何实质损失,对用户来说没有任何实际意义,几乎没有可能弥补服务中断产生的实际损失。


“兜底”有意义吗?

对于企业来说,兜底意味着在故障发生后如何尽可能减少损失。不幸的是,SLA 在这里帮不上什么忙。

服务不可用对业务造成的影响因行业、时间、长度而异。几秒钟几分钟的短暂的故障,可能对一般行业影响不大,然而长时间(几个小时到几十个小时)的故障会严重影响收入与声誉

在 Uptime Institute 2021年数据中心调查中【5】,几场最严重停机故障给受访者造成的平均成本近100万美元,最惨重的 2% 不在其中,他们遭受的损失超过 4000 万美元。

然而,SLA 补偿对于这些业务损失来说是杯水车薪。以 us-east-1 区域的 t4g.nano 虚拟机实例为例,价格约为每月 3 美元。如果不可用时间少于 7 小时 18 分钟(月可用性 99%),AWS 将支付该虚拟机每月成本的 10%,总补偿为 30 美分。如果虚拟机不可用时间少于 36 小时(一个月内 95% 的可用性),补偿仅为 30% —— 不到 1 美元。如果不可用时间超过一天半,用户才能收到当月的全额退款 —— 3美元。即使是补偿个成千上万台,与损失相比也可基本忽略不计。

相比之下,传统的保险行业是实打实地为客户兜底。例如,顺丰快递的保价费用为物品价值 1%,但如果物品丢失,他们会全额赔偿。同样,每年几万商业医保,出问题真能兜底几百万。“保险”这个行业也是一分钱一分货的。

云服务提供商收取了远超BOM的昂贵服务费(参见:《公有云是不是杀猪盘》【7】),但当服务出现问题时,他们提供的补偿所谓的“兜底”却只是一些代金券,这显然是有失公平的。


消失的可靠性

有些人使用云服务是为了“甩锅”,推卸自己的责任。有一些重要的责任是无法推卸给外部 IT 供应商的。比如数据安全。用户可以忍受一段时间的服务不可用,但数据丢乱错带来的伤害往往是无法接受的。轻信浮夸承诺的后果实在是太过严重,以至于对于一个创业公司就是生与死的区别。

在各家云厂商的存储类产品中,经常能看到“承诺 99.9999999%” 9个9的可靠性【1】,可以理解为,使用云盘出现数据丢失的概率是十亿分之一。考察云厂商硬盘故障率的实际报告【6】,非常让人怀疑这个数字是拍脑袋想出来的。但只要敢说敢赔敢作敢当,那就没问题。

然而翻开各家云厂商的 SLA 就会发现,这一条“承诺”消失了!【2】

在2018年轰动的《腾讯云给一家创业公司带来的灾难!》 【8】案例中,这家创业公司就相信了云厂商的承诺,把数据放在服务器硬盘上,结果遇到了所谓“硬盘静默错误”:“几年来积累的数据全部丢失,造成近千万元的损失”。腾讯云向该公司表达歉意,愿意赔偿该公司在腾讯云产生的实际消费共计3569元,本着帮助用户迅速恢复业务的目的,承诺为该公司提供13.29万元现金或云资源的额外补偿。达到其消费金额的 37 倍!多么慷慨,多么仁慈,但这对于用户来说能弥补损失哪怕是零头吗?不行。

如果您是企业主或IT负责人,会觉得这样的甩锅有意义吗?


SLA 到底是什么

话都讲到这里,云服务的鼓吹者会祭出最后一招:虽然出了故障后的兜底是个摆设,但是用户需要的是尽可能不出故障,按照 SLA 中的承诺,我们有 99.99% 的概率不出故障,这才是对用户最有价值的。

然而,SLA 被有意地与服务的真实可靠性相混淆用户不应该将 SLA 视作来服务可用性的可靠预测指标 —— 甚至是过去可用性水平的真实记录对于厂家来说,SLA 并不是真正的可靠性承诺或历史战绩,而是一种营销工具,旨在让买家相信云厂商可以托管关键业务应用。

UPTIME INSTITUTE 发布的年度数据中心故障分析报告表明,很多云服务的真实表现低于其发布的 SLA 。对 2022 年故障分析发现:行业遏制故障频率的努力落空,故障成本和后果正在不断恶化【9】。

与其说是 SLA 是对用户的补偿,不如说 SLA 是对云厂商服务质量没达标时的“惩罚”惩罚的威慑取决于惩罚的确定性及惩罚的严重性。月消的时长/代金券赔付对云厂商来说并没有什么实际成本,所以惩罚的严重性趋近于零;赔付还需要需要用户自己举证主张并得到云厂商的批准,这意味着确定性也不会很高。

比起会因为故障丢掉奖金与工作的专家工程师来说,SLA的惩罚对于云厂商属于是自罚三杯,不痛不痒。如果惩罚没有意义,那么云厂商也没有动力会提供更好的服务质量。用户遇到问题时只能提供单等死,服务态度比起自建/三方服务公司可谓天差地别,对小客户更是趾高气扬。

更微妙的是,云厂商对于 SLA协议具有绝对的权力:云厂商有权单方调整修订SLA并告知用户生效,用户只有选择不用的权利,没有任何参与权和选择权。作为默认签署无法拒绝的“霸王条款”,堵死了用户进行真正有意义赔偿追索的可能性。

所以,SLA 对用户来说不是兜底损失的保险单。在最坏的情况下,它是吃不了兜着走的哑巴亏。在最好的情况下,它才是提供情绪价值的安慰剂。因此,当我们选择云服务时,我们需要擦亮双眼,清楚地了解其 SLA 的内容,以便做出明智的决策。


Reference

【1】阿里云 ESSD云盘

【2】阿里云 SLA 汇总页

【3】AWS SLA 汇总页

【4】AWS EC2 SLA 样例

【5】云SLA更像是惩罚用户而不是补偿用户

【6】NVMe SSD失效率统计

【7】公有云是不是杀猪盘

【8】腾讯云给一家创业公司带来的灾难!

【9】Uptime Institute 2022 故障分析

云盘是不是杀猪盘?

微信公众号原文 / 微信校订版 / | Vonng博客

我们已经用数据回答了《云数据库是不是智商税》这个问题,但在公有云块存储的百倍溢价杀猪比率前,云数据库只能说还差点意思。本文用实际数据揭示公有云真正的商业模式 —— 廉价EC2/S3获客,EBS/RDS杀猪。而这样的做法,也让公有云与其初心愿景渐行渐远。


TL;DR 太长不看

EC2 / S3 / EBS 是所有云服务的定价之锚。如果说 EC2/S3 定价还勉强能算合理,那么 EBS 的定价乃是故意杀猪。公有云厂商最好的块存储服务与自建可用的 PCI-E NVMe SSD 在性能规格上基本相同。然而相比直接采购硬件,AWS EBS 的成本高达 60 倍,而阿里云的 ESSD 则可高达 100 倍。

即插即用的磁盘硬件,百倍溢价到底为何?云厂商无法解释如此的天价到底源于何处。结合其他云存储服务的设计思路与定价模型,只有一个合理的解释:EBS的高溢价倍率是故意设置的门槛,以便于云数据库杀猪

作为云数据库定价之锚的 EC2 与 EBS,溢价分别为几倍与几十倍,从而支撑起云数据库的杀猪高毛利。但这样的垄断利润必定无法持久:IDC 2.0/运营商/国资云冲击 IaaS;私有云/云原生/开源平替冲击 PaaS;科技行业大裁员、AI冲击与天朝的低人力成本冲击云服务(运维人力外包/共享专家)。公有云如果执着于目前的杀猪模式,背离“存算基础设施”的初心,那么必将在以上三者形成的合力下面临越来越严峻的竞争与挑战


WHAT:真正的杀猪盘

你在家用微波炉加热黄焖鸡米饭料理包花费10元,餐馆老板替你用微波炉加热装碗上桌收费30元,你不会计较什么,房租水电人工服务也都是要钱的。但如果现在老板端出同样一碗饭跟你收费 1000 元并说:我们提供的不是黄焖鸡米饭,而是可靠质保的弹性餐饮服务,大厨品控掌握火候,按量付费想吃多少有多少,按需付费吃多少盛多少,不吃黄焖鸡还有麻辣烫串串香可选,反正就值这个价,你会不会有打一顿这个老板的冲动?这样的事情就发生在块存储上!

硬件技术日新月异,PCI-E NVMe SSD 在各种指标上都达到了一个全新水平。一块常见的 3.2 TB 规格企业级 MLC颗粒 SSD 有着极为强悍的性能、可靠性与性价比,价格 ¥3000 元不到,全方位吊打老存储。

Aliyun ESSD PL3 和我们 IDC 自建采购 PCI-E NVMe SSD 【1】是同一家供应商。所以在最大容量和 IOPS 限制上都一模一样。AWS 最好的块存储 io2 Block Express 的规格和各类指标也基本类似。云厂商提供的最高端存储就是使用这种 32 TB 的单卡,所以才会有 32TB 最大容量的限制(AWS 64T),可以认为底下的实际硬件基本是高度一致的。

然而相比直接采购硬件,AWS EBS io2 的成本高达 120 倍,而阿里云的 ESSD PL3 则高达 200 倍。以 3.2TB 规格的企业级 PCI-E SSD 卡为参照基准,AWS 上按需售租比为 15 天,阿里云上不到 5 天,租用此时长即可买下整块磁盘。若在阿里云以采购三年预付最大优惠五折计算,三年租金够买下 120 多块同款硬盘。

你这 SSD 是金子做的 ?

当然,云厂商会争论说块存储的对标物是 SAN,而本地 DAS 在云上的对标物应当是实例存储(Host Storage)。但是,公有云的实例存储基本都是临时性的( Ephemeral Storage),实例一旦休眠/停止就会回收抹除数据【7,11】,难以用于严肃的生产数据库,云厂商自己也建议你不要把重要数据放在上面。因此唯一能用于数据库的存储就是 EBS 块存储 。类似于DBFS之类的产品指标与成本与 EBS 基本类似,也在此合并同类项。

说到底,用户在意的不是设备块底下到底是 SAN,SSD,还是HDD;真正重要的永远是实打实的硬指标:延迟、IOPS,可靠性,成本。拿本地与云上的最好选项对比,没有任何问题,更别提最好的云存储底下用的也是一样的本地盘了。

有的“专家”又会说,云上的块存储稳定可靠,多副本冗余纠错。在以前,Share Everything 的数据库要用 SAN 存储跑,然而现在很多数据库都是 Share Nothing 架构了。在数据库实例层面进行冗余,不需要存储层再搞个三副本,更何况企业级磁盘本身就有极强的自我纠错能力与安全冗余( UBER < 1e-18 )。在上层数据库本身已经有冗余的情况下,多副本块存储对数据库来说属于毫无意义的浪费。退一万步讲,如果云厂商真的用了多余的两副本来做无谓的冗余,那也不过是溢价率从 200x 降到 66x ,杀猪逻辑依然没有质变。

“专家”还会说,买“云服务”其实类似于买保险:“年化 0.02% 的故障看起来大部分人一次都遇不到,但是遇到一次就毁灭性的打击,而云厂商来为你兜底”。听上去好像很有吸引力。但翻开各家云厂商 EBS 的 SLA,你会发现压根没有为可靠性兜底的条款。ESSD 云盘介绍上是写了 9个9 的数据可靠性,但他也不敢把这句话写到 SLA 里。云厂商敢兜的只有可用性,而且还是相当逊的可用性,以 AWS EBS SLA 【9】为例:

云SLA是不是安慰剂

翻译成大白话就是:如果一个月里挂一天半(95%),本月此项服务费补偿100%代金券,挂了7个小时(99%)补偿 30% 代金券,挂了几十分钟 (99.9%单盘,99.99%区域)补偿10%代金券。云厂商收了百倍费用,这么大的重大冲击就补偿点代金券?挂几分钟都受不了的应用,谁会稀罕这几毛钱代金券?莫过于前几年那篇《腾讯云给一家创业公司带来的灾难》

顺丰快递保价 1% ,搞丢了人家真的赔你。每年几万块的商业医保,出问题真能兜底几百万。不要侮辱“保险”这个行业,起码人家也是一分钱一分货的。所以,SLA 对用户来说不是兜底损失的保险单。在最坏的情况下,它是吃不了兜着走的哑巴亏。在最好的情况下,它才是提供情绪价值的安慰剂。

云数据库服务的溢价还可以用 “专家人力” 来解释,但这一点对于服务器插上就能用的磁盘完全说不通,云厂商自己也讲不出这里几十倍的价格到底溢在哪里。你去问他们的工程师,大概逼急了也只能告诉你:

我们抄 AWS ,人家就是这么设计的


WHY:为什么要这样定价

即使是公有云自己的工程师可能也搞不清楚这样定价的意义,明白的人也不太可能会告诉你。但是这并不妨碍我们从产品的设计中推断出这样做背后的道理。

存储是有事实标准的:POSIX 文件系统 + 块存储。无论是数据库文件,图片音视频都使用同样的文件系统接口存储在磁盘上。但是 AWS 的“神之一手” 将其切分为两种不同的服务:S3 (简单对象存储)与 EBS (弹性块存储)。很多“追随者”模仿了AWS 的产品设计与定价模型,却说不清这样做的逻辑与原理。

阿里云官网对 EBS 和 OSS 的定位

S3 的全称 是 Simple Storage Service ,简单存储服务。它是文件系统/存储的一种简化替代:牺牲了强一致性、目录管理,访问时延等功能属性,以换取廉价的成本与海量伸缩的能力。它提供了一个简单的、高延迟、高吞吐扁平 KV 存储服务,从标准的存储服务中剥离出来。这个部分物美价廉,是公有云用来吸引用户上云的一大杀手锏:因此成为了可能是唯一一个,在各家公有云通行的云计算事实标准

而数据库需要的是低延迟,强一致、高质量、高性能、可随机读写的块存储,这一部分被包装为 EBS 服务:Elastic Block Store ,弹性块存储服务,这个部分成为了公有云厂商的 禁脔 :不愿为用户染指。因为EBS是 RDS 的定价之锚 —— 也就是云数据库的壁垒与护城河

卖资源吃饭的 IaaS 定价没有太大水分空间,可以对着 BOM 一笔一笔精算。但是像云数据库这样的 PaaS, 里面包含的“服务”,人力 / 研发成本就包含大量水分,难以厘定,就可以名正言顺的卖出天价,攫取高额利润。尽管国内公有云 IaaS 层存储、计算、网络三大件的收入能占营收一半的比例,但其毛利率只有 15% ~ 20%,而以云数据库为代表的公有云 PaaS 毛利率可以达到 50% 或更高,完爆卖资源吃饭的 IaaS。

如果用户选择使用 IaaS 资源(EC2 / EBS)自行搭建数据库,对云厂商而言是一笔巨大的利润损失。所以公有云厂商会竭尽全力避免这样的情况出现,而怎样设计产品才能实现这个需求呢?

首先,最适合用于自建数据库的实例存储必须添加各种限制:实例一旦休眠/停止就会回收并抹除数据,让你没法用 EC2 自带的盘跑严肃的生产数据库服务。其次尽管 EBS 相比本地 NVMe SSD 存储性能可靠性稍显拉胯,但用来跑数据库也是可以的,所以这里也要限制:但也不能不给用户,那么就设置一个天价!作为补偿,次一级的廉价海量存储 S3 就可以卖便宜一些来钓鱼获客。

当然要想客户买单,也需要一些云计算 KOL 来鼓吹配套 “公有云云原生” 哲学理念:“EC2 不适合放状态哟,请把状态放到 S3 或者 RDS 以及其他托管服务,这才是使用我们公有云的‘最佳实践’ ”。

这四条总结的很到位,但公有云绝对不会告诉你“最佳实践”的代价是什么。用白话解释一下这四条的意思,这是一个为客户精心设计的连环杀猪套

普通文件丢S3!(这么物美价廉的S3,还要啥 EBS?)

不要自建数据库!(别想着用实例存储折腾开源替代)

请深度使用厂商专有的身份认证系统(供应商锁定)

乖乖给云数据库上贡!(锁死用户后,杀猪时刻)


HOW:还原杀猪盘内幕

公有云的商业模式可以概括为:廉价EC2/S3获客,EBS/RDS杀猪

要想杀猪,先要养猪,舍不着孩子套不着狼。所以对于新用户、初创企业,小微用户,公用云都不吝于提供一些甜头,甚至赔本赚吆喝。新用户首单骨折,初创企业免费/半价 Credit,以及微妙的定价策略。

以 AWS RDS 报价为例可以看出,1核2核的迷你机型,单价也就是 几$/核·月,折合三四百块人民币一年,可以说是非常便宜实惠(不含存储):如果你需要一个低频使用的小微数据库放点东西,这也许就是最简单便宜的选择【10】。

然而,只要你稍微把配置往上抬哪怕一丁点儿,核月单价就出现了数量级的变化,干到了二三十~一百来刀,最高可以翻到几十倍 —— 算上惊悚的 EBS 价格还要再翻番。用户只有在看到突然出现的天价账单时,才会意识到到底发生了什么。

以 RDS for PostgreSQL 为例, AWS 上 64C / 256GB 的 db.m5.16xlarge RDS用一个月价格 $25,817 / 月,折合每月 18 万元人民币,一个月的租金够你把两台性能比这还要好的多得多的服务器直接买下来自建了。租售比甚至都不到一个月,租十来天就够你买下来整台服务器自建

付费模式 价格 折合每年(万¥)
IDC自建(单物理机) ¥7.5w / 5年 1.5
IDC自建(2~3台组HA) ¥15w / 5年 3.0 ~ 4.5
阿里云 RDS 按需 ¥87.36/时 76.5
阿里云 RDS 月付(基准) ¥4.2w / 月 50
阿里云 RDS 年付(85折) ¥425095 / 年 42.5
阿里云 RDS 3年付(5折) ¥750168 / 3年 25
AWS 按需 $25,817 / 月 217
AWS 1年不预付 $22,827 / 月 191.7
AWS 3年全预付 12w$ + 17.5k$/月 175
AWS 中国/宁夏按需 ¥197,489 / 月 237
AWS 中国/宁夏1年不预付 ¥143,176 / 月 171
AWS 中国/宁夏3年全预付 ¥647k + 116k/月 160.6

我们可以对比一下自建与云数据库的成本差异:

方式 折合每年(万元)
IDC托管服务器 64C / 384G / 3.2TB NVME SSD 660K IOPS (2~3台) 3.0 ~ 4.5
阿里云 RDS PG 高可用版 pg.x4m.8xlarge.2c, 64C / 256GB / 3.2TB ESSD PL3 25 ~ 50
AWS RDS PG 高可用版 db.m5.16xlarge, 64C / 256GB / 3.2TB io1 x 80k IOPS 160 ~ 217

RDS 定价与自建对比,详见《云数据库是不是智商税

任何理智的企业用户都看得明白这里面的道理:如果采购这种服务不是为了短期的,临时性的需求,那么绝对算得上是重大的财务失当行为

不仅仅是 关系型数据库服务 / RDS 是这样,各种云数据库都在杀猪。MongoDB, ClickHouse,Cassandra,用 EC2 / EBS的有一个算一个。以流行的 NoSQL 文档数据库 MongoDB 为例:

这种报价没有十年脑血栓的产品经理真的想不出来

五年时间正好是常见服务器折旧年限,最大折扣,12节点(64C 512G),报价两千三百万。这个报价的零头就可以轻松把五年硬件代维轻松搞定,再加一个 MongoDB 专家天团为您按需定制随意自建了。

高级餐厅加收菜品 15% 的服务费,而用户也可以理解并支持这种合理范围内的利润需求。云数据库如果在硬件资源的基础上加收百分之几十的服务费与弹性溢价(白嫖开源的云服务就不要提软件费用了),可以解释为生产性要素的定价,解决的问题与提供的服务确实值这个钱。

然而加收百分之几百甚至几千的溢价,那就完全属于破坏性要素参与分配了:云厂商吃死了用户上来之后没有备选项,以及迁移会产生伤筋动骨的大成本,所以大可以放心杀猪!在这种意义下,用户掏的钱,买的不是服务,而是在被强制征收 “无专家税” 与 “保护费”。


被遗忘的初心愿景

因而面对杀猪的指责,云厂商也会辩解说:“哎呀,你们看到的都是列表价啦,说是最低 5折,但大客户打起折来,可是没有底线的哦“。作为一条经验法则:自建成本差不多在目前云服务列表价的零点五到一折上下浮动,如果能长期保有此折扣,云服务就会比自建更有竞争力。

专业懂行的大客户,特别是那种随时有能力迁移横跳的甲方去和公有云搞商务谈判,确实有可能获取两折的骨折折扣,而小客户天然在议价方面没有能力,通常不太可能有这种机会。

然而,云计算不应成为云算计:云厂商如果只能在大企业那边疯狂打折促销,而面对中小客户和开发者进行薅羊毛的方式进行杀猪,那实际上是损不足以奉有余 —— 吸中小客户的血补贴大客户。这种方式已经完全违背了云计算本身的初心与愿景,必然难以持续长久。

在云刚出现的时候,关注的焦点是 云硬件 / IaaS 层 :算力、存储、带宽。云硬件 是云厂商的初心故事:让计算和存储资源像水电一样,而自己扮演基础设施的提供者的角色。这是一个很有吸引力的愿景:公有云厂商可以通过规模效应,压低硬件成本并均摊人力成本;理想情况下,在给自己留下足够利润的前提下,还可以向公众提供比 IDC 价格更有优势,更有弹性的存储算力。

云软件( PaaS / SaaS ),则是与云硬件有着迥然不同的商业逻辑:云硬件靠的是规模效应,优化整体效率赚取资源池化超卖的钱,总体来说算是一种效率进步。而云软件则是靠共享专家,提供运维外包来收取服务费。公有云上大量的服务,本质是对免费的开源软件进行封装,依靠的是垄断专家,利用信息不对称收取天价保险费,是一种价值的攫取转移。

不幸的是,出于混淆视线的目的,云软件与云硬件都使用了“云”这个 Title。因而在云的故事中,混杂着打破资源垄断与建立能力垄断的叙事:同时混掺着将算力普及到千家万户的理想主义光辉与达成垄断攫取不义利润杀猪的贪婪

抛弃平台中立性与做基础设施的初心,沉沦在 PaaS / SaaS / 甚至应用层恰烂钱的公有云供应商,将在没有底线的竞争中沉沦。


博弈将走向何方?

垄断利润将随着竞争出现而消失,公有云厂商被卷入了一场苦战之中。

在基础设施层面,运营商,国资云,IDC 1.5/2.0 都进入了赛道,并提供了非常有竞争力的 IaaS 服务,一条龙包网包电托管代维,高端服务器既可以自购托管,也完全可以用实价弹性直接租赁,论起弹性来一点不怵。

IDC 2.0 服务器租赁新模式:实价租赁,满年限归用户

在软件层面,曾经作为公有云技术门槛的各类管控软件 / PaaS 已经出现了相当优秀的开源平替,OpenStack / Kubernetes 取代 EC2,MinIO / Ceph 取代 S3,RDS 上也出现了诸如 Pigsty 【5】与各种 K8S Operator 这样的开源替代品。

整个“云原生”运动,说白了就是开源生态对公有云白嫖挑战的回应用户与开发者们为了避免被公有云杀猪,打造了一套本地优先的完整公有云开源替代

“CloudNative” 这个名字起的好,一中各表,公有云觉得是“公有云上出生”的,私有云想的是“在本地跑和云一样的东西”。推 Kubernetes 最狠的竟然还是公有云本身,就和销售绞死自己的绳索一样。

经济衰退的大环境下,降本增效成为主旋律。十几万规模的科技行业大裁员,以及未来AI对智力行业的大规模冲击将释放出大量相关人才,再加上本朝的低工资优势,自建人才稀缺与昂贵的情状将大为缓解。人力成本相比云服务成本要有优势的多。

结合以上三方面的趋势,IDC2.0 + 开源自建的组合越来越有竞争力:对于稍微有点规模和人才储备的组织来说,短路掉公有云这个中间商,直接与 IDC 合作显然是一个更经济实惠的选择

不忘初心,方得始终。公有云在云硬件 / IaaS 层上做的确实不错,除了贵到离谱,没有太大问题,东西确实是不错的。如果能回归最初的愿景初心,真正做好水与电一样的基础设施提供者,卖资源虽毛利虽不高,但可以站着把钱挣了。如果继续执迷不悟沉迷于杀猪,最终用户将用脚给出自己的选择

云计算为啥还没挖沙子挣钱?

References

【1】撤离 AWS:3年省下27.5亿元

【2】云数据库是不是智商税

【3】范式转移:从云到本地优先

【4】腾讯云CDN:从入门到放弃

【5】炮打 RDS,Pigsty v2.0 发布

【6】Shannon NVMe Gen4 Series

【7】AWS实例存储

【8】AWS io2 gp3 存储性能与定价

【9】AWS EBS SLA

【10】AWS EC2 / RDS 报价查询

【11】阿里云:本地盘

【12】阿里云:云盘概述

【13】图说块存储与云盘

【14】从狂飙到集体失速,云计算换挡寻出路

【15】云计算为啥还没挖沙子赚钱?

垃圾腾讯云CDN:从入门到放弃?

微信公众号原文 | Vonng博客

我和 瑞典马工虽然在 云数据库 VS DBA 这个议题上针锋相对,但在一点上能达成共识:至少国内的公有云厂商做的是真垃圾。用马工的话来说就是:“阿里云是个工程质量差劲的正经云,但腾讯云是一群业余销售加业务码农玩游戏”。


前因后果

我有个软件托管在 GitHub 上,提供了 1MB 的源码包和 1GB 的离线软件包下载。大陆用户因为 在境内 没法从 GitHub 下载,因此需要有一个境内的下载地址,于是我就用了腾讯云的 COS(对象存储) 与 CDN(内容分发网络)服务。

CDN 的初衷不外乎:1. 加速,2. 省钱。互联网流量费通常是 8毛钱1GB,使用 CDN 流量包可以节省不少流量费打对折。当然,因为 CDN 就是给境内用户专用的。所以我也只买了境内的流量包。一直以来也用的还算可以,直到 2月底,我突然发现,怎么冒出来几笔异常费用。

点进去一看,CDN 收了几百块钱,我还想,难道是哪里渠道带火了下载?于是进入 数据统计分析界面一看,这段时间里,总共软件包也就是 100GB 不到的下载流量,撑死了几十块钱,怎么会我收几百块呢?

点看分析一看好家伙,1TB的访问流量,全是 “境外其他”,什么鬼?

于是,呼出腾讯云客服来:

甩锅 x 1,我们这个 TOP不准,要看日志哈

那我就下载一份日志来看看,究竟是谁吃饱了撑着来爆破。结果,里面一大堆请求连客户端 IP 地址都没有。

甩锅 x 2 ,客服说,这种大概率是被攻击了!好怕怕啊!

这种话术还想忽悠我?不懂行的用户一听”攻击“,说不定就被拐带着跑去买公有云厂商的”高防服务“了。

绕了一圈又等了半个小时,终于告知了我真正的原因,流量费是“预热”扣除的。所以这些没有 “来源IP”的请求终于真相大白了:原来是“监守自盗,贼喊捉贼”,你们自己的系统跑过来爆破俺的流量啊!

工程师电话沟通告知说:我们的 CDN 预热是这样的,所有的 CDN 节点都会来回源请求,所以才有的这两万次请求与 1TB 流量。

听到这样的解释,我都要笑喷了。我为什么买CDN?因为要省对象存储的流量钱。CDN 去刷新预热,这是云厂商自己系统内部的流量,为啥要挂在用户身上付费?但是你刷新预热一下,一下花掉了我直接走对象存储下载一年都用不了的钱。

好吧,就算我们退一万步讲预热要付费,一个合乎工程逻辑的做法是,每个大区域来对象存储回源一次,然后再下发同步到每个终端节点上。付个几倍流量费,我也不会介意的。1GB 的软件花个 10GB 预热一下,客户也不会说什么。

腾讯云 CDN则不然,一个1MB/1GB的软件包预热,每个终端节点直接来请求,产生 1TB 的“预热流量”。八毛钱1GB 流量费,几百块就没了。更滑稽的是,我 CDN 是给大陆用户使用的,墙外用户根本用不着,直接 Github 下载就好了,而这些流量全都跑到“境外”去了,事先买好境内的流量包压根不能抵扣。而腾讯云的文档上也压根没有提及,这些 “预热” 究竟有多大的量和来自哪里。我认为,这已经构成了事实上的欺诈与恶意引导杀猪盘。比 《云数据库是不是智商税》还要恶劣的多。

所以,一个简简单单的 1MB / 1GB 的软件包要走 CDN 分发,本来可能一天也没几块钱的东西,点一下按钮,大几百块就没了在监控图表上,把内部的流量藏的好好的;在计费逻辑上,把本应免费的内部流量硬塞到用户头上。

CDN预热内部流量也好意思向用户收钱?

几百块钱的成本对我来说不痛不痒,但我作为用户,感觉到智商受到了羞辱。腾讯云提议退一半钱给你好不好?全退给你好不好?我也一分没要,我就是要写一篇文章告诉大家,这个产品做的有多烂多傻逼

阿里云不管怎么样,起码在某些方面我还会表示一下敬意。而腾讯云的表现,实实在在是彻底丢了中国公有云的脸,这么大一厂商,这点事儿都弄不明白就出来做云。也难怪别人会说:

中国没有云计算,有的只是 IDC 2.0

就这个样子出来卖,还是早点洗洗睡吧。


扩展阅读

更多腾讯云的精彩表现,可以欣赏云计算霰弹枪的往期文章:

[1] 腾讯云团队为什么用阿里云的服务名?

[2] 究竟是客户差劲,还是腾讯云差劲?

[3] 腾讯云:从入门到放弃

[4] 腾讯云阿里云做的真的是云计算吗?–从客户成功案例的视角

[5] 本土云厂家究竟在服务谁?

[6] 云计算厂商们,你们辜负了中国的用户

驳《再论为什么你不应该招DBA》

微信公众号原文

郭德纲有一段相声:比如我和火箭专家说,你那火箭不行,燃料不好,我认为得烧柴,最好是烧煤,煤还得精选煤,水洗煤不行。如果那科学家拿正眼看我一眼,那他就输了。

但不管怎么说,马工也还是一位体面的瑞典研发工程师。没有做过DBA就敢大放厥词,开地图炮拉仇恨,实在勇气可嘉。之前在《你怎么还在招聘DBA》,以及回应文《云数据库是不是智商税》中,我们便已交锋过。

当别人把屎盆子扣在这个行业所有人头上时,还是需要人来站出来说几句的。因此今天特此撰文以驳斥马工的谬论:《再论为什么你不应该招DBA》。

马工的论点有三:

  1. DBA妨碍了研发交付新特性

  2. DBA威胁了企业数据安全

  3. 人工DBA需要被基于代码的软件所取代

我的看法是:

  1. 第一点属于无效输出,DBA本来就是在稳定性侧制衡研发的存在。

  2. 第二点则是完全扯淡,DBA本来就是类似于财务的关健岗位,需要信任。

  3. 第三点属于部分事实,但严重高估了短期变化,且云数据库并非唯一的路。

且听我一一道来:

DBA 对稳定性负责

关于信息系统的一个基本原理是:安全性与活性相互抵触,过于强调安全稳定,则活性受损;过于强调活性,则难以稳定。任何组织都要在两者中间找到一个平衡点。而研发与运维,就是两者的职能化身。

研发对新功负责,而 SRE/DBA 对稳定性负责,一个开创,一个守成,两者相互协作,但也是相互制衡。马工作为研发,特别还是创业公司的研发,主张功能活性很重要,从立场上来说是无可厚非的。但在更广大的组织中,稳定性的地位往往是高于新功能的,成熟的组织如银行,大型互联网平台,从来都是稳定性压倒一切。毕竟,新功能的收益是不确定的,而大故障的损失是肉眼可见的。每天发10个新版本不见得能带来多少增长,但一次大故障也许就能让几个月的努力付之东流。

“在高速上开两百迈的阻碍从来都不是车的性能,而是司机的胆量”。站在更高位的管理者角度来说,马工强调的 “开掉DBA得到更快的DB交付速度”,纯粹属于研发者的一厢情愿:用云把运维职能外包出去, 无人制衡,我想怎样就怎样。这样的想法如果落地,最终必将在某个时刻以惨痛的教训收场。

笔者曾是 DBA,但也没少干 Dev。关于研发和 DBA 的心态,都有亲身的体会。我在刚入行当研发的时候,在“PostgreSQL数据库里”跑神经网络,推荐系统,Web服务器和爬虫,用FDW接了 MongoDB 和 HBase以及一堆外部系统,什么稳定性?跑的不是挺好吗?直到没有运维与DBA愿意接手维护,我不得不亲自干起了 DBA 的活自己狗食背起锅来,才能设身处地的对DBA / 运维有同理心,谨慎选择有所为有所不为。

交付速度谁在乎?

评价一款数据库需要从许多维度出发:稳定性,可靠性,安全性,简单性,可伸缩性,可扩展性,可观测性,可维护性,成本性价比,等等等等。交付速度这件事勉强属于“可伸缩性”里一个比较次要的附属维度,在数据库系统需要关注的属性中,压根排不上号

更重要的是,性价比才是第一产品力对比方案却对成本闭而不提,是一种耍流氓的行为。笔者对研发人员的这种心理非常了解:花的是公司的钱,省的是自己事儿,自然没有几个人会有动机为公司去省钱。你的数据库花半个小时交付,还是花三四天交付,老板与领导不会 Care 这些。但是,你的老板会很在乎你花30分钟拉起了一个数据库,然后每个月账单多出来几十万元。

meme.png

以 AWS 上 64核256GB的 db.m5.16xlarge RDS 为例,用一个月价格 $25,817 / 月,折合约 18 万元人民币,一个月的租金,够你把两台性能比这还要好的多得多的服务器直接买下来了,任何理智的企业用户都看得明白这里面的道理:如果采购这种服务不是为了短期的,临时性的需求,那么绝对算得上是重大的财务失当行为

比交付速度也不怵

但即使我们退一万步讲,交付速度真的重要,马工的论证用例也是破绽百出。

马工假想了一个数据库上线的案例:PG新版本,两地三中心,同城HA,异地灾备,数据加密,自动备份,自带监控,App与DB独立网段,DBA也无法删库。然后得意洋洋的宣称:”使用 Terraform,我只要28分钟就可以完成满足需要的配置!比拉DBA搭建数据库快几个数量级!“

实际上只要你的机器就绪,网络打通,规划完毕:使用 Pigsty 部署一套满足这些需求的数据库系统,执行耗时也就是十几分钟。自建机房且不提,Pigsty 完全可以使用同样的逻辑:基于Terraform 一键拉起EC2、存储、网络,然后在这个基础上额外执行一条命令部署数据库,耗费的时间说不定比 Terraform 还短一点,更重要的是,还能省掉百分之八九十的天价 RDS 智商税

price.jpeg

能想出这种定价的云数据库产品经理脑袋一定被门夹了

分库分表的稻草人靶子

马工提出,分库分表是DBA自抬身价的一种工具。

在今天,数据库的能力已经得到了极大的发展,给应用开发者带来巨大管理成本的分库分表已经没必要了。凡是在用分库分表的系统, 都可以用分布式数据库或者NoSQL数据库替换掉。几乎可以说,分库分表不过是DBA自抬身价的一种工具。

时至今日,硬件存储技术的发展已经让很多老同志跟不上新形势了。家用 PCI-E NVME SSD 2TB的价格已经进入了三位数,常用的企业级 3.2TB MLC NVME SSD也不过六七千,最大几十TB的单卡容量,已经完爆了很多中大型企业的所有 TB 数据量,七位数的IOPS让几千/几万IOPS还卖天价的 云 EBS 恨不得找个地缝钻进去。

软件方面,以 PostgreSQL 为例,使用堆表存储的单表容量十几TB千亿量级数据一点儿不成问题,还有 Citus 插件可以原地改造为分布式数据库。各种分布式数据库的卖点也是 “不用分库分表”。这都已经是老黄历问题了。除了极个别场景,恐怕也只有原教旨 MySQL 用户还守着 “单表不能超过 2000w 记录” 去玩分表了。

当然,分布式数据库对于 DBA 的水平要求不会更低只会更高;所以这里马工主要想说的还是 NoSQL ,更具体的讲,就是 DynamoDB 这种所谓“不需要” DBA运维的数据库直接干翻 DBA。不过,一个平均延迟在 10ms 的数据库,一个抽象程度只是等同于文件系统的扁平 KV 存储的数据库,光是杀猪程度要比 RDS 还要狠毒的 RCU / WCU 计费方式,就足够用户喝上一壶,有何德何能敢标榜自己能替掉 DBA ?

指望用NoSQL替代DBA是做梦

互联网应用大多属于数据密集型应用,对于真实世界的数据密集型应用而言,除非你准备从基础组件的轮子造起,不然根本没那么多机会去摆弄花哨的数据结构和算法。实际生产中,数据表就是数据结构,索引与查询就是算法。而应用研发写的代码往往扮演的是胶水的角色,处理IO与业务逻辑,其他大部分工作都是在数据系统之间搬运数据

在最宽泛的意义上,有状态的地方就有数据库。它无所不在,网站的背后、应用的内部,单机软件,区块链里。有。关系型数据库只是数据系统的冰山一角(或者说冰山之巅),实际上存在着各种各样的数据系统组件:

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

状态管理是信息系统的永恒问题,马工以为的 DBA 是抱着祖传 Oracle 手册的打字员,实际上互联网公司的 DBA 已经是十八班武艺样样精通的 数据架构师 了。架构师最重要的能力之一,就是了解这些组件的性能特点与应用场景,能够灵活地权衡取舍、集成拼接这些数据系统。 他们上要 Push 业务落地最佳实践指导模式设计,下要深入操作系统与硬件排查问题优化性能,中间要掌握无数种数据组件的使用方式。君子不器,关系型数据库的知识,只是其中最为核心重要的一种。

正如我在《为什么要学习数据库原理和设计》所说, 只会写代码的是码农;学好数据库,基本能混口饭吃;在此基础上再学好操作系统和计算机网络,就能当一个不错的程序员。可惜的是,数据建模和SQL几乎快成为一门失传的艺术:这类基础知识逐渐为新一代工程师遗忘,他们设计出离谱的模式,不懂得正确地创建索引,然后草率得出结论:关系型数据库和SQL都是垃圾,我们必须使用糙猛快的NoSQL来省时间。然而人们总是需要可靠的系统来处理关键业务数据:在许多企业中,核心数据仍然是一个常规关系型数据库作为Source of Truth,NoSQL数据库仅用于非关键数据。某个研发跳出来说 DynamoDB / Redis / MongoDB / HBase 太牛逼了,我所有的状态都能放在这里,而且再也不需要 DBA 了,毫无疑问是滑稽可笑的。

DBA 是企业数据库的守护者

马工的最后一炮,直指 DBA 的职业道德 :DBA想删库,谁也拦不住。

这话倒是没有错,DBA和财务一样,都属于能对企业造成致命杀伤的关键岗位:用人不疑,疑人不用。但这句话同样也绕开了一个重要事实:没有DBA守门,人人都能删库。在马工举的微盟和百度删库跑路的两个例子中,主犯都是普通的研发与运维人员,正是因为没有称职的 DBA 把关,才有删库跑路的可趁之机。

合格的 DBA 可以有效减少有能力对企业进行致命一击的人群范围,从所有的研发与运维收敛到DBA本身。至于 DBA 本身如何制衡,要么是两个 DBA 互为备份,要么是由运维/安全团队管理冷备份的删除权限。马工举的,腾讯云不让手工删例行备份的例子,实属对业内实践少见多怪。

对于给 DBA 群体泼脏水的行为,本人表示鄙视愤慨 😄。按照这个逻辑,我也完全可以认为马工喜爱的公有云厂商,才是对数据安全最大的威胁:用云不过是把运维和DBA外包给了云厂商,而你完全阻碍不了某个云厂商中有权限的研发/运维/DBA,在心血来潮的情况下来你的库里里逛逛。或者干脆脱个备份裤子赏玩一下,你压根不可能追索,不可能取证,当然核心原因是你压根没有能力知道这一点。而这样的人许许多多,一个运维的脚本出岔子就会爆破一大片,你能指望的赔偿也只有不痛不痒的时长代金券。

参考阅读:《云RDS:从删库到跑路

DBA要退出历史舞台?

作为一个整体行业, DBA 确实在走下坡路, 但人们总是会过高估短期影响而低估长期趋势。许多大型组织都雇用DBA,DBA类似于 Cobol 程序员,那些听上去不那么Fancy的制造业,银行保险证券、以及大量运行本地软件的党政军部门,大量使用了关系型数据库。在可预见的未来,DBA在某个地方找工作是不会有什么问题的。

但大的趋势是,数据库本身会越来越智能,易用性越来越好,而各式各样的工具、SaaS、PaaS不断涌出,也会进一步压低数据库的使用门槛。公有云/私有云DBasS的出现更是让数据库的管理门槛进一步下降。数据库的专业技术门槛降低,将导致DBA的不可替代性降低:安装一套软件收费十几万,做一次数据恢复上百万的好日子肯定是一去不复返了。但在另一种意义上讲,这也将 DBA 从运维性的琐事中解放出来,他们可以把更多时间投身于更有价值的性能优化,隐患排查,制度建设工作之中。

无论是公有云厂商,还是以Kubernetes为代表的云原生/私有云,其核心价值都在于尽可能多地使用软件,而不是人来应对系统复杂度。但是不要指望这些能完全替代 DBA:云并不是什么都不用管的运维外包魔法。根据复杂度守恒定律,无论是系统管理员还是数据库管理员,管理员这个岗位消失的唯一方式是,它们被重命名为“DevOps Engineer”或SRE/DRE。好的云软件可以帮你屏蔽运维杂活,解决70%的日常高频问题,然而总是会有那么一些复杂问题只有人才能处理。你可能需要更少的人手来打理这些云软件,但总归还是需要人来管理。毕竟:

你也需要懂行的人来协调处理,才不至于被云厂商嘎嘎割韭菜当傻逼。

题外话:有那么一些研发,总想着通过云这种运维外包外援,用云数据库,云XX砸掉 DBA 的饭碗。我们做了一个开箱即用的 云数据库 RDS PostgreSQL 本地开源替代 Pigsty ,最近刚发布了 2.0,监控/数据库开箱即用 HA/PITR/IaC一应俱全。允许您在缺乏数据库专家的情况下,用接近硬件的成本运行企业级数据库服务,省掉50%~90%上贡给RDS的“无专家税”,让 RDS 除了它引以为傲的弹性,在各个方面都像是一个大笑话。对于广大 DBA 来说,这就是一件怼回去的武器。咱们明人不说暗话,就是要砸了云数据库的饭碗,并断了研发的这种痴念。https://pigsty.cc/zh/docs/feature

最后,让我们用某个 Notion AI 生成的无版权提词小笑话结束今天的主题。

joke.png

范式转移:从云到本地优先

微信公众号原文 / 微信校订版 / Vonng博客

上一篇里,我们用数据回答了《云数据库是不是智商税》 这个问题:高达几倍到十几倍的溢价,对于云适用光谱外的用户是毫无疑问的杀猪。但我们还可以进一步探究:公有云特别是云数据库为什么会是这样?并基于其底层逻辑此对行业的未来进行预测与判断。

软件行业经历了几次范式转移,数据库也不例外


前生今世

天下大势,分久必合,合久必分。

—— The pendulum of the software industry.

软件行业经历了几次范式转移,数据库也不例外。

paradigm-title.png

软件吞噬世界,开源吞噬软件,云吞噬开源,谁来吃云?

最初,软件吞噬世界,以 Oracle 为代表的商业数据库,用软件取代了人工簿记,用于数据分析与事务处理,极大地提高了效率。不过 Oracle 这样的商业数据库非常昂贵,一核·一月光是软件授权费用就能破万,不是大型机构都不一定用得起,即使像壕如淘宝,上了量后也不得不”去O“。

接着,开源吞噬软件,像 PostgreSQL 和 MySQL 这样”开源免费“的数据库应运而生。软件开源本身是免费的,每核每月只需要几十块钱的硬件成本。大多数场景下,如果能找到一两个数据库专家帮企业用好开源数据库,那可是要比傻乎乎地给 Oracle 送钱要实惠太多了。

开源软件带来了巨大的行业变革,可以说,互联网的历史就是开源软件的历史。尽管如此,开源软件免费,但 专家稀缺昂贵。能帮助企业 用好/管好 开源数据库的专家非常稀缺,甚至有价无市。某种意义上来说,这就是”开源“这种模式的商业逻辑:免费的开源软件吸引用户,用户需求产生开源专家岗位,开源专家产出更好的开源软件。但是,专家的稀缺也阻碍了开源数据库的进一步普及。于是,“云软件”出现了。

然后,云吞噬开源。公有云软件,是互联网大厂将自己使用开源软件的能力产品化对外输出的结果。公有云厂商把开源数据库内核套上壳,包上管控软件跑在托管硬件上,并雇佣共享 DBA 专家提供支持,便成了云数据库服务 (RDS) 。这诚然是有价值的服务,也为很多软件变现提供了新的途径。但云厂商的搭便车行径,无疑对开源软件社区是一种剥削与攫取,而捍卫计算自由的开源组织与开发者自然也会展开反击。

云软件的崛起会引发新的制衡反作用力:与云软件相对应的本地优先软件开始如雨后春笋一般出现。而我们,就在亲历见证这次范式转移。


二律背反

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

Redis Labs 首席执行官 Ofer Bengal

冷战已经结束,但在软件行业中,垄断和反垄断的斗争却方兴未艾。

物理世界不同,信息复制近乎为0的成本,让两种模式在软件世界有了相互争斗的实际意义商业软件与云软件遵循垄断资本主义的逻辑;而自由软件、开源软件以及正在崛起的本地优先软件,遵循的是共产主义的逻辑【2】。信息技术行业之所以有今天的繁荣,人们能享受到如此多的免费信息服务,正是这种斗争的结果。

正如开源软件的概念已经彻底改变了软件世界:商业软件公司耗费了海量资金与这个想法斗争了几十年。最终还是难以抵挡开源软件的崛起 —— 软件这种IT业的核心生产资料变为全世界开发者公有,按需分配。开发者各尽所能,人人为我,我为人人,这直接催生了互联网的黄金繁荣时代。

paradigm-2.png

然而,盛极而衰,物极必反。商业软件改头换面以云服务的方式卷土重来,而开源软件的理念在云计算时代出了大问题。云软件在本质上是商业软件的形态升级:如果软件只能运行在供应商的服务器上,而不是跑在用户本地的服务器上,就可以形成新的垄断。更妙的是云软件完全可以白嫖开源,以彼之矛攻彼之盾。免费的开源软件加上供应商的运维与服务器,整合包装为虚实莫辨的服务,即可省却研发成本,溢价几倍甚至十几倍大赚一笔。

在云刚出现的时候,他们的核心是硬件 / IaaS层 :存储、带宽、算力、服务器。云厂商的初心故事是:让计算和存储资源像水电一样,自己扮演基础设施的提供者的角色。这是一个很有吸引力的愿景:公有云厂商可以通过规模效应,压低硬件成本并均摊人力成本;理想情况下,在给自己留下足够利润的前提下,还可以向公众提供比 IDC 价格更有优势,更有弹性的存储算力。

而云软件( PaaS / SaaS ),则是与云硬件有着迥然不同的商业逻辑:云硬件靠的是规模效应,优化整体效率赚取资源池化超卖的钱,总体来说算是一种效率进步。而云软件则是靠共享专家,提供运维外包来收取服务费。公有云上大量的软件,本质是对免费的开源软件进行封装,依靠的是信息不对称收取天价服务费,是一种价值的攫取转移。【1】

硬件算力 单价
IDC自建机房(独占物理机 A1: 64C384G) 19
IDC自建机房(独占物理机 B1: 40C64G) 26
IDC自建机房(独占物理机 C2: 8C16G) 38
IDC自建机房(容器,超卖200%) 17
IDC自建机房(容器,超卖500%) 7
UCloud 弹性虚拟机(8C16G,有超卖) 25
阿里云 弹性服务器 2x内存(独占无超卖) 107
阿里云 弹性服务器 4x内存(独占无超卖) 138
阿里云 弹性服务器 8x内存(独占无超卖) 180
AWS C5D.METAL 96C 200G (按月无预付) 100
AWS C5D.METAL 96C 200G(预付3年) 80
数据库
AWS RDS PostgreSQL db.T2 (4x) 440
AWS RDS PostgreSQL db.M5 (4x) 611
AWS RDS PostgreSQL db.R6G (8x) 786
AWS RDS PostgreSQL db.M5 24xlarge 1328
阿里云 RDS PG 2x内存(独占) 260
阿里云 RDS PG 4x内存(独占) 320
阿里云 RDS PG 8x内存(独占) 410
ORACLE数据库授权 10000

论云如何把 20来块的单位硬件卖出十几倍溢价

不幸的是,出于混淆视线的目的,云软件与云硬件都使用了“云”这个名字。因而在云的故事中,同时混掺着将算力普及到千家万户的理想主义光辉,与达成垄断攫取不义利润的贪心。


矛盾嬗变

在 2022 年,软件自由的敌人是云计算软件。【3】

云计算软件,即主要在供应商的服务器上运行的软件,而你的所有数据也存储在这些服务器上。以云数据库为代表的 PaaS 与各类 SaaS 服务都属于此类。这些“云软件”也许有一个客户端组件(手机App,网页控制台,跑在你浏览器中的JavaScript),但它们只能与供应商的服务端共同工作。而云软件存在很多问题:

  • 如果云软件供应商倒闭或停产,您的云软件就歇菜了,而你用这些软件创造的文档与数据就被锁死了。例如,很多初创公司的 SaaS 服务会被大公司收购,而大公司没有兴趣继续维护这些产品。
  • 云服务可能在没有任何警告和追索手段的情况下突然暂停您的服务(例如 Parler )。您可能在完全无辜的情况下,被自动化系统判定为违反服务条款:其他人可能入侵了你的账户,并在你不知情的情况下使用它来发送恶意软件或钓鱼邮件,触发违背服务条款。因而,你可能会突然发现自己用各种云文档或其它App创建的文档全部都被永久锁死无法访问。
  • 运行在你自己的电脑上的软件,即使软件供应商破产倒闭,它也可以继续跑着,想跑多久跑多久。相比之下,如果云软件被关闭,你根本没有保存的能力,因为你从来就没有服务端软件的副本,无论是源代码还是编译后的形式。
  • 云软件极大加剧了软件的定制与扩展难度,在你自己的电脑上运行的闭源软件,至少有人可以对它的数据格式进行逆向工程,这样你至少有个使用其他替代软件的PlanB。而云软件的数据只存储在云端而不是本地,你甚至连这一点都做不到了。

如果所有软件都是免费和开源的,这些问题就都自动解决了。然而,开源和免费实际上并不是解决云软件问题的必要条件;即使是收钱的或者闭源的软件,也可以避免上述问题:只要它运行在你自己的电脑、服务器、机房上,而不是供应商的云服务器上就可以。拥有源代码会让事情更容易一些,但这并不是不关键,最重要的还是要有一份软件的本地副本

在当今,云软件,而不是闭源软件或商业软件,成为了软件自由的头号威胁云软件供应商可以在您无法审计,无法取证,无法追索的情况下访问您的数据,或突然心血来潮随心所欲地锁定你的所有数据,这种可能性的潜在危害,要比无法查看和修改软件源码的危害大得多。与此同时,也有不少“开源软件公司”将“开源”视作一种获客营销包装、或形成垄断标准的手段,而不是真正追求“软件自由”的目的。

”开源“ 与 ”闭源“ 已经不再是软件行业中最核心的矛盾,斗争的焦点变为 “云” 与 “本地”。


本地优先

本地” 与 “” 的对立体现为多种不同的形式:有时候是 “Native Cloud” vs “Cloud Native”,有时候叫体现为 “私有云” vs “公有云”,大部分时候与 ”开源“ vs “闭源”重叠,某种意义上也牵扯着 “自主可控” vs “仰人鼻息”。

Kubernetes 为代表的 Cloud Native 运动就是最典型的例子:云厂商将 Native 解释 “原生”:“原生诞生在公有云环境里的软件” 以混淆视听;但究其目的与效果而言,Native 真正的含义应为 “本地”,即与 Cloud 相对应的 “Local” —— 本地云 / 私有云 / 专有云 / 原生云,叫什么不重要,重要的是它运行在用户想运行的任何地方(包括云服务器),而不是仅仅是公有云所独有!

本地优先的软件在您自己的硬件上运行,并使用本地数据存储,但也保留云软件的便利特性,比如实时协作,简化运维,跨设备同步,资源调度,灵活伸缩等等。开源的本地优先的软件当然非常好,但这并不是必须的,本地优先软件90%的优点同样适用于闭源的软件。同理,免费的软件当然好,但本地优先的软件也不排斥商业化与收费服务。

在云软件没有出现开源/本地优先的替代品前,公有云厂商尽可大肆收割,攫取垄断利润。而一旦更好用,更简易,成本低得多的开源替代品出现,好日子便将到达终点。正如 Kubernetes 用于替代云计算服务 EC2, MinIO / Ceph 用于替代云存储服务 S3, 而 Pigsty 则指在替代云数据库服务:RDS PostgreSQL。越来越多的云软件开源/本地优先替代正如雨后春笋一样冒出来。

paradigm-3.jpeg

CNCF Landscape


历史经验

云计算的故事与电力的推广过程如出一辙,让我们把目光回退至上个世纪初,从电力的推广普及垄断监管中汲取历史经验。

paradigm-4.jpeg

ChatGPT: 电力的推广过程

供电也许会走向垄断、集中、国有化,但你管不住电器。如果云硬件(算力)类似于电力,那么云软件便是电器。生活在现代的我们难以想象:洗衣机,冰箱,热水器,电脑,竟然还要跑到电站边的机房去用,我们也很难想象,居民要由自己的发电机而不是公共发电厂来供电。

因此从长期来看,公有云厂商大概也会有这么一天:在云硬件上通过类似于电力行业,通过垄断并购与兼并形成“规模效应”,利用“峰谷电”,“弹性定价”等各种方式优化整体资源利用率,在相互斗兽竞争中将算力成本不断压低至新的底线,实现“家家有电用”。当然,最后也少不了政府监管介入,公私合营收归国有,成为如同国家电网与电信运营商类似的存在,最终实现 IaaS 层的存储带宽算力的垄断。

而与之对应,制造灯泡、空调、洗衣机这类电器的职能会从电力公司中剥离,百花齐放。云厂商的 PaaS / SaaS 在被更好,更优质,更便宜的替代物冲击下逐渐萎缩,或回归到足够低廉的价格水平。

正如当年开源运动的死对头微软,现在也选择拥抱开源。公有云厂商肯定也会有这一天,与自由软件世界达成和解,心平气和地接受基础设施供应商的角色定位,为大家提供水与电一般的存算资源。而云软件终将会回归正常毛利,希望那一天人们能记得人们能记得,这不是因为云厂商大发慈悲,而是因为有人带来过开源平替。


参考阅读

【1】云数据库是不是智商税?

【2】为什么软件应该是自由的

【3】是时候和GPL说再见了

云数据库是不是智商税

寒冬来袭,大厂纷纷开始裁员,进入降本增效模式,作为公有云杀猪刀一哥的云数据库,故事还能再讲下去吗?

近日,Basecamp & HEY 联合创始人 DHH 的一篇文章【1,2】引起热议,主要内容可以概括为一句话:

“我们每年在云数据库(RDS/ES)上花50万美元,你知道50万美元可以买多少牛逼的服务器吗?

我们要下云,拜拜了您呐!“

所以,50 万美元可以买多少牛逼的服务器 ?


荒谬定价

磨刀霍霍向猪羊

我们可以换一种问法,服务器和RDS都卖多少钱?

以我们自己数据库大量使用的物理机型为例:Dell R730, 64核384GB内存,加装一块 3.2 TB MLC的NVME SSD。像这样的一台服务器部署标准的生产级 PostgreSQL,单机可以承载十几万的TPS,只读点查询可以干到四五十万。要多钱呢?算上电费网费IDC托管代维费用,按照5年报废均摊,整个生命周期成本七万五千上下,合每年一万五。当然,如果要在生产中使用,高可用是必须的,所以通常一组数据库集群需要两到三台物理机,也就是每年3万到4.5万。

这里没有算入DBA费用:两三个人管几万核真没多少。

如果您直接购买这样规格的云数据库,则费用几何呢?让我们看看国内阿里云的收费【3】。因为基础版(乞丐版)实在是没法生产实用(请参考:《云数据库:删库到跑路》),我们选用高可用版,通常底下是两到三个实例。包年包月,引擎 PostgreSQL 15 on x86,华东1默认可用区,独享的64核256GB实例:pg.x4m.8xlarge.2c,并加装一块3.2TB的ESSD PL3云盘。每年的费用在25万(3年)到75万(按需)不等,其中存储费用约占1/3。

让我们再来看看公有云一哥AWS【4】【5】。AWS上与此最接近的是 db.m5.16xlarge ,也是64核256GB多可用区部署,同理,我们加装一块最大8万IOPS,3.2TB的 io1 SSD磁盘,查询AWS全球与国区的报价,总体在每年160 ~ 217万元不等,存储费用约占一半,整体成本如下表所示:

付费模式 价格 折合每年(万¥)
IDC自建(单物理机) ¥7.5w / 5年 1.5
IDC自建(2~3台组HA) ¥15w / 5年 3.0 ~ 4.5
阿里云 RDS 按需 ¥87.36/时 76.5
阿里云 RDS 月付(基准) ¥4.2w / 月 50
阿里云 RDS 年付(85折) ¥425095 / 年 42.5
阿里云 RDS 3年付(5折) ¥750168 / 3年 25
AWS 按需 $25,817 / 月 217
AWS 1年不预付 $22,827 / 月 191.7
AWS 3年全预付 12w$ + 17.5k$/月 175
AWS 中国/宁夏按需 ¥197,489 / 月 237
AWS 中国/宁夏1年不预付 ¥143,176 / 月 171
AWS 中国/宁夏3年全预付 ¥647k + 116k/月 160.6

我们可以对比一下自建与云数据库的成本差异:

方式 折合每年(万元)
IDC托管服务器 64C / 384G / 3.2TB NVME SSD 660K IOPS (2~3台) 3.0 ~ 4.5
阿里云 RDS PG 高可用版 pg.x4m.8xlarge.2c, 64C / 256GB / 3.2TB ESSD PL3 25 ~ 50
AWS RDS PG 高可用版 db.m5.16xlarge, 64C / 256GB / 3.2TB io1 x 80k IOPS 160 ~ 217

所以问题来了,如果你用云数据库1年的钱,就够你买几台甚至十几台性能更好的服务器,那么使用云数据库的意义到底在哪里?当然,公有云的大客户通常可以有商务折扣,但再怎么打折,数量级上的差距也是没法弥补的吧?

用云数据库到底是不是在交智商税?


适用场景

没有银弹

数据库是数据密集型应用的核心,应用跟着数据库走,所以数据库选型需要非常审慎。而评价一款数据库需要从许多维度出发:可靠性,安全性,简单性,可伸缩性,可扩展性,可观测性,可维护性,成本性价比,等等等等。甲方真正在意的是这些属性,而不是虚头巴脑的技术炒作:存算分离,Serverless,HTAP,云原生,超融合……,这些必须翻译成工程的语言:牺牲了什么换来了什么,才有实际意义。

公有云的鼓吹者很喜欢往给它脸上贴金:节约成本,灵活弹性,安全可靠,是企业数字化转型的万灵药,是汽车对马车的革命,又好又快还便宜,诸如此类,可惜没几条是实事求是的。绕开这些虚头巴脑的东西,云数据库相比专业的数据库服务真正有优势的属性只有一条:弹性。具体来说是两点:启用成本低,可伸缩性强

启动成本低,意味着用户不需要进行机房建设,人员招聘培训,服务器采购就可以开始用;可伸缩性强,指的是各种配置升降配,扩缩容比较容易;因此公有云真正适用的场景核心就是这两种:

  1. 起步阶段,流量极小的简单应用
  2. 毫无规律可循,大起大落的负载

前者主要包括简单网站,个人博客,小程序小工具,演示/PoC,Demo,后者主要包括低频的的数据分析/模型训练,突发的秒杀抢票,明星并发出轨等特殊场景。

公有云的商业模型就是租赁:租服务器,租带宽,租存储,租专家。它和租房,租车,租充电宝没有本质区别。当然,租服务器和运维外包实在是不怎么中听,所以有了云这个名字,听起来更有赛博地主的感觉。而租赁这种模式的特点,就是弹性。

租赁模型有租赁的好处,出门在外,共享充电宝可以解决临时应急性的小规模充电需求。但对于大量日常从家到单位两点一线的人来说,每天用共享充电宝给手机电脑充电,毫无疑问是非常荒谬的,何况共享充电宝租一个小时4块钱,租几个小时的钱,就够你把它直接买下来了。租车可以很好的满足临时的、突发的、一次性用车需求:外地出差旅游,临时拉一批货。但如果你的出行需求是频繁的,本地性,那么购置一辆自动驾驶的车也许是最省事省钱的选择。

问题的关键还是在于租售比,房子的租售比几十年,汽车的租售几年,而公有云服务器的租售比通常只有几个月。如果你的业务能够稳定活几个月以上,为什么要租,而不是直接买呢?

所以,云厂商赚的钱,要么来自VC砸钱求爆发增长的科技创企,要么来自灰色寻租空间比云溢价还高的特殊单位,要么是人傻钱多的狗大户,要么是零零散散的站长/学生/VPN个人用户。聪明的高净值企业客户,谁会放着便宜舒适的大House不住,跑去挤着租住方舱医院人才公寓呢?

如果您的业务符合公有云的适用光谱,那是最好不过;但为了不需要的灵活性与弹性支付几倍乃至十几倍溢价,那是纯交智商税**。**


成本刺客

任何信息不对称都可以构成盈利空间,但你无法永远欺骗所有人。

公有云的弹性就是针对其商业模式设计的:启动成本极低,维持成本极高。低启动成本吸引用户上云,而且良好的弹性可以随时适配业务增长,可是业务稳定后形成供应商锁定,尾大不掉,极高的维持成本就会让用户痛不欲生了。这种模式有一个俗称 —— 杀猪盘

在我职业生涯的第一站中,就有这样一次杀猪经历让我记忆犹新。作为前几个被逼上A云的内部BU,A云直接出工程师加入手把手提供上云服务。用ODPS全家桶换掉了自建的大数据/数据库全家桶。应该说,服务确实不错,只不过,每年的存储计算开销从千万出头飙升到接近一亿,利润几乎都转移到A云了,堪称终极成本刺客。

后来在下一站,情况则完全不同。我们管理着两万五千核规模,450W QPS的 PostgreSQL 与 Redis 数据库集群。像这种规格的数据库,如果按AWS RCU/WCU 计费,每年几个亿就出去了;即使全买长期包年包月再加个大商务折扣,至少五六千万是肯定是少不了的。而我们总共两三个DBA,几百台服务器,人力+资产均摊每年统共不到一千万。

这里我们可以用一种简单的方式来核算单位成本:一核算力(含mem/disk)使用一个月的综合成本,简称核·月。我们核算过自建各机型的成本,以及云厂商给出的报价,大致结果如下:

硬件算力 单价
IDC自建机房(独占物理机 A1: 64C384G) 19
IDC自建机房(独占物理机 B1: 40C64G) 26
IDC自建机房(独占物理机 C2: 8C16G) 38
IDC自建机房(容器,超卖200%) 17
IDC自建机房(容器,超卖500%) 7
UCloud 弹性虚拟机(8C16G,有超卖) 25
阿里云 弹性服务器 2x内存(独占无超卖) 107
阿里云 弹性服务器 4x内存(独占无超卖) 138
阿里云 弹性服务器 8x内存(独占无超卖) 180
AWS C5D.METAL 96C 200G (按月无预付) 100
AWS C5D.METAL 96C 200G(预付3年) 80
数据库
AWS RDS PostgreSQL db.T2 (4x) 440
AWS RDS PostgreSQL db.M5 (4x) 611
AWS RDS PostgreSQL db.R6G (8x) 786
AWS RDS PostgreSQL db.M5 24xlarge 1328
阿里云 RDS PG 2x内存(独占) 260
阿里云 RDS PG 4x内存(独占) 320
阿里云 RDS PG 8x内存(独占) 410
ORACLE数据库授权 10000

所以这里问题就来了,单价二十块的服务器硬件,为什么可以卖出上百块,而且装上云数据库软件还可以再翻几番?运维是金子做的,还是服务器是金子做的?

常用的回应话术是:数据库是基础软件里的皇冠明珠,凝聚着无数无形知识产权BlahBlah。因此软件的价格远超硬件非常合理。如果是 Oracle 这样的顶尖商业数据库,或者索尼任天堂的主机游戏,这么说也过得去。

但公有云上的云数据库(RDS for PostgreSQL/MySQL/….),本质上是开源数据库内核换皮魔改封装,加上自己管控软件和共享DBA人头服务。那这种溢价率就非常荒谬了:数据库内核是免费的呀,你家管控软件是金子做的,还是DBA是金子做的?

公有云的秘密就在这里:用廉价的存算资源获客,用云数据库杀猪

尽管国内公有云 IaaS (存储、计算、网络)的收入占营收接近一半,但毛利率只有 15% ~ 20%,而公有云 PaaS 的营收虽然不如 IaaS,但 PaaS 的毛利率可以达到 50%,完爆卖资源吃饭的 IaaS 。而 PaaS 中最具代表性的,就是云数据库

正常来说,如果不是把公有云单纯当作一个 IDC 2.0 或者 CDN供应商来用,最费钱的服务就是数据库。公有云上的存储、计算、网络资源贵吗?严格来说不算特别离谱。IDC托管物理机代维的核月成本大约为二三十块,而公有云上一核 CPU 算力用一个月的价格,大概在七八十块到一两百块,考虑到各种折扣与活动,以及弹性溢价,勉强处在可以接受的合理范围。

但云数据库就非常离谱了,同样是一核算力用一个月,云数据库价格比起对应规格的硬件可以翻几倍乃至十几倍。便宜一些阿里云,核月单价两百到四百,贵一些的 AWS,核月单价可以七八百甚至上千。

如果说您只有一两核的 RDS ,那也别折腾了,交点税就交点吧。但如果您的业务上了量还不赶紧从云上下来,那可真的是在交智商税了。


足够好吗?

不要误会,云数据库只是及格品大锅饭。

关于云数据库/云服务器的成本,如果您能跟销售聊到这儿,话术就该变成:虽然我们贵,但是我们好呀!

但是,云数据库真的好吗?

应该说,对于玩具应用,小微网站,个人托管,以及土法野路子自建数据库来,毫无技术认知的甲方来说,RDS也许足够好了。但在高净值客户与数据库专家看来,RDS不过是及格线上的大锅饭产品罢了。

说到底,公有云源于大厂内部的运维能力外溢,大厂人自己厂里技术啥样门儿清,大可不必有啥莫名的崇拜。(Google也许算个例外)。

性能 为例,性能的核心指标是 延迟 / 响应时间,特别是长尾延迟,会直接影响用户体验:没有人愿意等着划一下屏幕转几秒圈圈。而在这一点上,磁盘起到决定性作用。

我们生产环境数据库中使用的是本地 NVME SSD,典型4K写延迟为15µs,读延迟94µs。因而,PostgreSQL简单查询的响应时间通常为 100 ~ 300µs,应用侧的查询响应时间通常为 200 ~ 600µs;对于简单查询,我们的 SLO 是命中1ms内,未命中10ms内,超过10ms算慢查询,要打回去优化。

而 AWS 提供的EBS服务用fio实测性能极其拉垮【6】,默认的gp3读写延迟为 40ms,io1 读写延迟为10ms,整整差了近三个数量级,而且IOPS最大也只有八万。RDS使用的存储就是EBS,如果连一次磁盘访问都需要10ms,那这就根本没法整了。io2 倒是使用了自建同款 NVMe SSD,然而远程块存储相比本地磁盘延迟直接翻倍。

确实,有时候云厂商会提供性能足够好的本地的NVME SSD,但都会非常鸡贼的设定各种限制条件,来避免用户来使用EC2来自建数据库。AWS的限制方式是只有NVME SSD Ephemeral Storage,这种盘一旦遇上EC2重启就自动抹干净了,根本没法用。阿里云的限制方式是给你卖天价,相比直接采购硬件,阿里云的 ESSD PL3 则高达 200 倍。以 3.2TB 规格的企业级 PCI-E SSD 卡为参照基准,AWS 上售租比为 1个月,阿里云上为 9 天,租用此时长即可买下整块磁盘。若在阿里云以采购三年最大优惠五折计算,租用三年的时间可购买 123 块同款硬盘近 400TB 永久所有权。

再以 可观测性 为例,没有一家RDS的监控能称得上是“好”。就以监控指标数量来说吧,虽然说知道服务死了还是活着只需要几个指标,但如果想进行故障根因分析,需要越多越好的监控指标来构建良好的Context。而大多数RDS都只是做了一些基本的监控指标,和简陋到可怜的监控面板。以阿里云RDS PG为例【7】,所谓的“增强监控”,里面只有这么点可怜的指标 , AWS里和PG数据库相关的指标也差不多不到100个,而我们自己的监控系统里主机指标有800多类,PGSQL数据库指标610类,REDIS指标257类,整个大约三千类指标,在数量上完爆这些RDS。

公开 Demo:https://demo.pigsty.cc

至于可靠性,以前我对RDS的可靠性还抱有基本的信任,直到一个月前A云香港机房那场丑闻。租的机房,服务器喷水消防,OSS故障,大量RDS不可用也切不了;然后A云自己整个Region的管控服务竟然因为一个单AZ的故障自己挂点了,连自己的管控API都做不到异地容灾,那做云数据库异地容灾岂不是天大的笑话

当然,并不是说自建就不会出现这些问题,只是稍微靠谱点的IDC托管都不至于犯这么离谱的错误。安全性也不用多说,最近闹的出过的几次大笑话,比如著名的SHGA;一堆样例代码里硬编码AK/SK,云RDS更安全吗?别搞笑啦,经典架构起码还有VPN堡垒机扛着一层,而公网上暴露端口弱密码裸奔的数据库简直不要太多,攻击面肯定是更大了无疑。

云数据库另一个广为人所诟病的是其可扩展性。RDS是不给用户 dbsu 权限的,这也意味着用户是不能在数据库中安装扩展插件,而 PostgreSQL 的插件恰恰就是其醍醐味,没有扩展的PostgreSQL就像可乐不加冰,酸奶不加糖一样。更严重的问题是在一些故障出现时,用户甚至都丧失了自我救助的能力,参见《云数据库:从删库到跑路》中的真实案例:WAL归档与PITR这么基础性的功能,在RDS中竟然是一个付费的升级功能。至于可维护性,有些人说云数据库可以点点鼠标就创建销毁多方便呀,说这话的人肯定没经历过重启每个数据库都要收手机短信验证码的山炮场景。有 Database as Code 式的管理工具,真正的工程师绝对不会用这种“ClickOps”

不过任何事物存在都有其道理,云数据库也不是一无是处,在可伸缩性上,云数据库确实卷出了新高度,比如各种 Serverless 的花活,但这也是给云厂商自己省钱超卖用的,对用户来说确实没有太大意义。


淘汰DBA?

被云厂商垄断,想招都找不到,还淘汰?

云数据库的另一种鼓吹思路是,用了RDS,你就不用DBA啦!

例如这篇知名点炮文《你怎么还在招聘DBA》【8】里说:我们有数据库自治服务!RDS和DAS能帮你解决这些数据库相关的问题,DBA都要下岗了,哈哈哈哈。我相信任何认真看过这些所谓“自治服务”,“AI4DB”官方文档【9】【10】的人都不会相信这种鬼话:连一个足够好用的监控系统都算不上的小模块,能让数据库自治起来,这不是在说梦话?

DBA,Database Administrator,数据库管理员,以前也叫做数据库协调员、数据库程序员。DBA是一个横跨于研发团队与运维团队的广博角色,涉及DA、SA、Dev、Ops、以及SRE的多种职责,负责各种与数据与数据库有关的问题:设置管理策略与运维标准,规划软硬件架构,协调管理数据库,验证表模式设计,优化SQL查询,分析执行计划,乃至于处理紧急故障以及抢救数据。

DBA的第一点价值在于安全兜底他是企业核心数据资产的守护者,也是可以轻易对企业造成致命伤害的人。在蚂蚁金服有个段子,能搞死支付宝的,除了监管就是DBA了。高管们通常也很难意识到 DBA 对于公司的重要性,直到出了数据库事故,一堆CXO紧张地站在DBA背后观看救火修复过程时…。比起避免一场数据库故障所造成的损失,例如:全美停航,Youtube宕机,工厂停产一天,雇佣DBA的成本显得微不足道。

DBA的第二点价值在于模型设计与优化。许多公司并不在乎他们的查询是纯狗屎,他们只是觉得“硬件很便宜”,砸钱买硬件就好了。然而问题在于,一个调整不当的查询/SQL或设计不当的数据模型与表结构,可以对性能产生几个数量级的影响。总会在某一个规模,堆硬件的成本相比雇佣一个靠谱DBA的成本高得令人望而却步。实话说,我认为大多数公司在IT软硬件开销中花费最大的是:开发人员没有正确使用数据库

DBA的基本功是管理DB,但灵魂在于A:Administration ,如何管住研发人员创造的熵,需要的可不仅仅是技术。“自治数据库”也许可以帮助你分析负载创建索引,但没有任何可能帮你理解业务需求,去Push业务去优化表结构,而这一点在未来的二三十年里,都看不到任何被云替代的可能。

无论是公有云厂商,还是以Kubernetes为代表的云原生/私有云,或者是类似 Pigsty 【11】这样的本地开源RDS替代,其核心价值都在于****尽可能多地使用软件,而不是人来应对系统复杂度。那么,云软件会革了运维与DBA的命吗

云并不是什么都不用管的运维外包魔法。根据复杂度守恒定律,无论是系统管理员还是数据库管理员,管理员这个岗位消失的唯一方式是,它们被重命名为“DevOps Engineer”或SRE。好的云软件可以帮你屏蔽运维杂活,解决70%的日常高频问题,然而总是会有那么一些复杂问题只有人才能处理。你可能需要更少的人手来打理这些云软件,但总归还是需要人来管理【12】。毕竟,你也需要懂行的人来协调处理,才不至于被云厂商嘎嘎割韭菜当傻逼。

在大型组织中,一个好的DBA是至关重要的。然而优秀的DBA相当稀有,供不应求,以至于这个角色在大多数组织中只能外包:包给专业的数据库服务公司,包给云数据库RDS服务团队。找不到DBA供应的组织只能将这个职责 内包 给自己的研发/运维人员,直到公司的规模足够大,或者吃到足够的苦头之后,一些Dev/Ops才会培养出相应的能力来。

DBA不会被淘汰,只会被集中到云厂商中垄断提供服务。


垄断阴影

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

比起“淘汰DBA”,云的出现蕴含着更大的威胁。我们需要担心的是这样一幅图景:公有云(或果子云)坐大,控制硬件与运营商上下游,垄断计算,存储,网络,顶尖专家资源,形成事实标准。假如所有顶级DBA都被挖到云厂商去集中提供共享专家服务,普通的企业组织就彻底失去了用好数据库的能力,最终只能选择被公有云收税杀猪。最终,所有IT资源集中于云厂商,只要控制住这几个关键少数,就可以控制整个互联网。这毫无疑问与互联网诞生的初衷相悖。

引用 DDIA 作者 Martin Kelppmann 的一段话【13】来说:

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

—— 即主要在供应商的服务器上运行的软件,而你的所有数据也存储在这些服务器上。这些“云软件”也许有一个客户端组件(手机App,网页App,跑在你浏览器中的JavaScript),但它们只能与供应商的服务端共同工作。而云软件存在很多问题:

  • 如果提供云软件的公司倒闭,或决定停产,软件就没法工作了,而你用这些软件创造的文档与数据就被锁死了。对于初创公司编写的软件来说,这是一个很常见的问题:这些公司可能会被大公司收购,而大公司没有兴趣继续维护这些初创公司的产品。
  • 谷歌和其他云服务可能在没有任何警告和追索手段的情况下,突然暂停你的账户。例如,您可能在完全无辜的情况下,被自动化系统判定为违反服务条款:其他人可能入侵了你的账户,并在你不知情的情况下使用它来发送恶意软件或钓鱼邮件,触发违背服务条款。因而,你可能会突然发现自己用Google Docs或其它App创建的文档全部都被永久锁死,无法访问了。
  • 而那些运行在你自己的电脑上的软件,即使软件供应商破产了,它也可以继续运行,直到永远。(如果软件不再与你的操作系统兼容,你也可以在虚拟机和模拟器中运行它,当然前提是它不需要联络服务器来检查许可证)。例如,互联网档案馆有一个超过10万个历史软件的软件集锦,你可以在浏览器中的模拟器里运行!相比之下,如果云软件被关闭,你没有办法保存它,因为你从来就没有服务端软件的副本,无论是源代码还是编译后的形式。
  • 20世纪90年代,无法定制或扩展你所使用的软件的问题,在云软件中进一步加剧。对于在你自己的电脑上运行的闭源软件,至少有人可以对它的数据文件格式进行逆向工程,这样你还可以把它加载到其他的替代软件里(例如OOXML之前的微软Office文件格式,或者规范发布前的Photoshop文件)。有了云软件,甚至连这个都做不到了,因为数据只存储在云端,而不是你自己电脑上的文件。

如果所有的软件都是免费和开源的,这些问题就都解决了。然而,开源实际上并不是解决云软件问题的必要条件;即使是闭源软件也可以避免上述问题,只要它运行在你自己的电脑上,而不是供应商的云服务器上。请注意,互联网档案馆能够在没有源代码的情况下维持历史软件的正常运行:如果只是出于存档的目的,在模拟器中运行编译后的机器代码就够了。也许拥有源码会让事情更容易一些,但这并不是关键,最重要的事情,还是要有一份软件的副本

我和我的合作者们以前曾主张过本地优先软件的概念,这是对云软件的这些问题的一种回应。本地优先的软件在你自己的电脑上运行,将其数据存储在你的本地硬盘上,同时也保留了云计算软件的便利性,比如,实时协作,和在你所有的设备上同步数据。开源的本地优先的软件当然非常好,但这并不是必须的,本地优先软件90%的优点同样适用于闭源的软件。云软件,而不是闭源软件,才是对软件自由的真正威胁,原因在于:云厂商能够突然心血来潮随心所欲地锁定你的所有数据,其危害要比无法查看和修改你的软件源码的危害大得多。因此,普及本地优先的软件显得更为重要和紧迫。

有力就会有反作用力,与云软件相对应的本地优先软件开始如雨后春笋一般出现。例如,以Kubernetes为代表的 Cloud Native 运动就是一例。“Cloud Native”,云厂商将 Native 解释 “原生”:“原生诞生在公有云环境里的软件”;而其真正的含义应为 “本地”,即与 Cloud 相对应的 “Local” —— 本地云 / 私有云 / 专有云 / 原生云,叫什么不重要,重要的是它运行在用户想运行的任何地方(包括云服务器),而不是仅仅是公有云所独有!

以 K8S为代表的开源项目,将原本公有云才有的资源调度/智能运维能力普及到所有企业中,让企业在本地也可以运行起‘云’一样的能力。对于无状态的应用来说,它已经是一个足够好的 “云操作系统” 内核。Ceph/Minio也提供了 S3 对象存储的开源替代,只有一个问题仍然没有答案,有状态的,生产级的数据库服务如何管理与部署?

时代在呼唤 RDS 的开源替代物。


解决方案

Pigsty —— 开源免费,开箱即用,更好的 RDS PG 替代

我希望,未来的世界人人都有自由使用优秀服务的事实权利,而不是只能被圈养在几个公有云厂商提供的猪圈(Pigsty)里吃粑粑。这就是我要做 Pigsty 的原因 —— 一个更好的,开源免费的 PostgreSQL RDS替代。让用户能够在任何地方(包括云服务器)上,一键拉起有比云RDS更好的数据库服务。

Pigsty 是是对 PostgreSQL 的彻底补完,更是对云数据库的辛辣嘲讽。它本意是“猪圈”,但更是 Postgres In Great STYle 的缩写,即“全盛状态下的 PostgreSQL”。它是一个完全基于开源软件的,可以运行在任何地方的,浓缩了 PostgreSQL 使用管理最佳实践的 Me-Better RDS 开源替代。用真实世界的大规模,高标准 PostgreSQL 集群打磨而来的解决方案,它是为了满足探探自己管理数据库的需求而生,在八个维度上进行了许多有价值的工作:

可观测性(Observability)是天;天行健君子以自强不息;Pigsty使用现代可观测性技术栈为 PostgreSQL 打造了一款无与伦比的监控系统,从全局大盘概览到单个表/索引/函数等对象的秒极历史详情指标一览无遗,让用户对系统能够做到洞若观火,进而掌控一切。此外,Pigsty的监控系统还可以独立使用,监控第三方数据库实例。

可控制性(Controllability)是地;地势坤君子以厚德载物;Pigsty提供Database as Code的能力:使用表现力丰富的声明式接口描述数据库集群的状态,并使用幂等的剧本进行部署与调整。让用户拥有精细定制的能力的同时又无需操心实现细节,解放心智负担,让数据库操作与管理的门槛从专家级降低到新手级。

可伸缩性(Scalability)是水;水洊至习坎君子以常德行;Pigsty提供预制通用调参模板(OLTP / OLAP / CRIT / TINY),自动优化系统参数,并可通过级联复制无限扩展只读能力,也使用Pgbouncer连接池优化海量并发连接;Pigsty确保 PostgreSQL 的性能可以在现代硬件条件下充分发挥:单机可达数万并发连接/百万级单点查询QPS/十万级单条写入TPS。

可维护性(Maintainability)是火;明两作离大人以继明照于四方;Pigsty 允许在线摘除添加实例以扩缩容,Switchover/滚动升级进行升降配,提供基于逻辑复制的不停机迁移方案,将维护窗口压缩至亚秒级,让系统整体的可演化性,可用性,可维护性提高到一个新的水准。

安全性(Security)是雷;洊雷震君子以恐惧修省;Pigsty提供了一套遵循最小权限原则的访问控制模型,并带有各种安全特性开关:流复制同步提交防丢失,数据目录校验和防腐败,网络流量SSL加密防监听,远程备份AES-256防泄漏。只要物理硬件与密码安全,用户无需担心数据库的安全性。

简单性(Simplicity)是风;随风巽君子以申命行事;使用Pigsty的难度不会超过任何云数据库,它旨在以最小的复杂度成本交付完整的RDS功能,模块化设计允许用户自行组合选用所需的功能。Pigsty提供基于Vagrant的本地开发测试沙箱,与Terraform的云端IaC一键部署模板,让您在任意新EL节点上一键完成离线安装,完整复刻环境。

可靠性(Reliability)是山;兼山艮君子以思不出其位;Pigsty提供了故障自愈的高可用架构应对硬件问题,也提供开箱即用的PITR时间点恢复为人为删库与软件缺陷兜底,并通过长时间、大规模的生产环境运行与高可用演练验证其可靠性。

可扩展性(Extensibility)是泽:丽泽兑君子以朋友讲习;Pigsty深度整合PostgreSQL生态核心扩展PostGIS、TimescaleDB、Citus 、PGVector、以及大量扩展插件;Pigsty提供模块化设计的Prometheus/Grafana可观测性技术栈,以及MINIO,ETCD,Redis、Greenplum 等组件的监控与高可用部署与PostgreSQL 组合使用;

更重要的是,Pigsty是完全开源免费的自由软件,采用 AGPL v3.0 协议。我们用爱发电,而您可以用几十块核·月的纯硬件成本,跑起运行功能完备甚至更好的RDS服务。无论你是初心者还是资深DBA,无论你管理着上万核的大集群还是1核2G的小水管,无论你已经用了RDS还是在本地搭建过数据库,只要你是 PostgreSQL 用户,Pigsty都会对您有所帮助,完全免费。您可以专注于业务中最有趣或最有价值的部分,将杂活丢给软件来解决

尽管Pigsty 本身旨在用数据库自动驾驶软件替代人肉数据库运维,但正如上所述,再好的软件也没法解决 100% 的问题。总会有一些的冷门低频疑难杂症需要专家介入处理。我们提供免费的社区答疑,如果您觉得安装使用维护有困难,需要下云迁移或者疑难杂症兜底,我们也提供顶尖的数据库专家咨询服务,性价比相对公有云数据库的工单/SLA极有竞争力。Pigsty帮助用户用好 PostgreSQL,而我们帮助用户用好 Pigsty。

Pigsty简单易用,人力成本与复杂度RDS持平,但资源成本差异确是天翻地覆。且不说自建机房的20块和几百块的云数据库怎么比,考虑到 RDS 对比同规格 EC2 都有几倍的溢价,您完全可以折中:使用云服务器部署 Pigsty RDS,既保留了云的弹性,又可以原地省掉五六成开销。如果是IDC自建或者代维,成本砍掉90%都不一定打得住。

RDS成本与规模成本曲线

Pigsty 允许您践行最终极的 FinOps 理念 —— 用几乎接近于纯资源的价格,在任何地方(ECS,资源云,机房服务器甚至本地笔记本虚拟机)运行生产级的 PostgreSQL RDS 数据库服务。让云数据库的能力成本,从正比于资源的边际成本,变为约等于0的固定学习成本

如果您可以用几分之一的成本来使用更好的 RDS 服务,那么再用云数据库就真的是纯纯的智商税了。


Reference

【1】为什么我们要离开云

【2】上云“被坑”十年终放弃,寒冬里第一轮“下云潮”要来了?

【3】阿里云RDS for PostgreSQL定价

【4】AWS Pricing Calculator

【5】 AWS Pricing Calculator (中国宁夏)

【6】FIO 测试 AWS EBS性能

【7】阿里云RDS PG 增强监控

【8】你为什么还在招DBA

【9】阿里云RDS PG 数据库自治服务

【10】OpenGauss AI for DB

【11】Me-Better RDS PostgreSQL 替代 Pigsty

【12】Pigsty v2 正式发布:更好的RDS PG开源替代

【13】是时候和GPL说再见了

【14】云数据库是不是智商税?

【15】大厂裁员轰轰烈烈,哪个技术岗位可以独善其身?

【16】蹭个热度–要不要DBA和云数据库

【17】你怎么不招DBA

【18】DBA还是一份好工作吗?

【19】云RDS:从删库到跑路

DBA还是一份好工作吗?

微信公众号原文 | 知乎原文

蚂蚁金服有过一个自嘲的段子:能干翻支付宝的,除了监管就是DBA了。

数字时代,数据是很多企业的核心资产,对于互联网/软件服务类企业更是如此。而负责保管这些数据资产的人,就是DBA(数据库管理员)

想象一下所有账户余额和联系人全部丢失的场景,尽管发生概率微乎其微,即使是支付宝与微信,如果出现无法恢复的核心库删库事件,恐怕也只能吃不了兜着走了。


缘从何起?

img

软件吃世界,开源吃软件,云吞噬开源,谁来吞噬云?

很久很久以前,开发软件/信息服务需要使用非常昂贵的商业数据库软件:例如Oracle与SQL Server:单花在软件授权上的费用可能就有六七位数,加之相近的硬件成本与服务订阅成本。如果公司已经砸了成百上千万的钱在数据库软硬件上,那么再花一些钱雇佣一些专职专家来照顾这些昂贵且复杂的数据库,就是一件很自然的事情,这些专家就是DBA。

接下来事情出现了有趣的变化:随着PostgreSQL/MySQL这些开源数据库的兴起,公司们有了一个新选择:不用软件授权费用即可使用数据库软件,而它们也开始(不理性地)停止为数据库专家付费:维护数据库的工作被隐含在了研发与运维的附属职责中,而这两类人通常:既不擅长、也不喜欢、更不在乎照顾数据库的事情。直到公司的规模足够大,或者吃到足够的苦头之后,一些Dev/Ops才会培养出相应的能力来,不过这是相当罕见的事情。

接下来,云出现了。云实际上是一种运维外包,将DBA工作中属于运维部分,最具像化的部分给自动化了:高可用,备份/恢复,配置,置备。DBA仍然剩下很多事情,但普通麻瓜难以理解此类工作的价值,这部分职责依然静悄悄地落在了研发与运维工程师的身上。“不要钱” 的开源数据库可以让我们自由随意地使用数据库软件,因此随着微服务哲学兴起,用户开始给每个小服务弄一个单独的数据库,而不是很多应用共享一个巨大的中央共享数据库。在这种情况下,数据库被视作每个服务的一部分,可以更方便的把DBA的活推给研发了。

那么,云之后是什么?DBA还会是一份好的工作吗?


核心价值

很多地方都需要DBA:糟糕的模式设计,奇烂的查询性能,鬼知道有没有用的备份;等等等等。可惜的是,从事软件工作的人中,很少有人了解什么是DBA。成为DBA,意味着与研发人员创造的熵进行永无休止的战斗。

DBA,Database Administrator,数据库管理员,以前也叫做数据库协调员、数据库程序员。DBA是一个横跨于研发团队与运维团队的广博角色,涉及DA、SA、Dev、Ops、以及SRE的多种职责,负责各种与数据与数据库有关的问题:设置管理策略与运维标准,规划软硬件架构,协调管理数据库,验证表模式设计,优化SQL查询,分析执行计划,乃至于处理紧急故障以及抢救数据。

DBA的第一点价值在于安全兜底:他是企业核心数据资产的守护者,也是可以轻易对企业造成致命伤害的人。在蚂蚁金服有个段子,能搞死支付宝的,除了监管就是DBA了。高管们通常也很难意识到 DBA 对于公司的重要性,直到出了数据库事故,一堆CXO紧张地站在DBA背后观看救火修复过程时…。

DBA的第二点价值在于性能优化。许多公司并不在乎他们的查询是纯狗屎,他们只是觉得“硬件很便宜”,砸钱买硬件就好了。然而问题在于,一个调整不当的查询/SQL或设计不当的数据模型与表结构,可以对性能产生几个数量级的影响。总会在某一个规模,堆硬件的成本相比雇佣一个靠谱DBA的成本高得令人望而却步。实话说,我认为大多数公司在IT软硬件开销中花费最大的是:开发人员没有正确使用数据库

优秀的DBA还会负责数据模型设计与优化。数据建模和SQL几乎已成为一门失传的艺术,这类基础知识逐渐为新一代工程师遗忘,他们设计出离谱的模式,不懂得正确地创建索引,然后草率得出结论:关系型数据库和SQL都是垃圾,我们必须使用糙猛快的NoSQL来省时间。然而人们总是需要可靠的系统来处理关键业务数据:在许多企业中,核心数据仍然是一个常规关系型数据库作为Source of Truth,NoSQL数据库仅用于非关键数据。

对于尚未进入PMF的初创企业,雇佣一个全职DBA是奢侈的行为。然而在一个大型组织中,一个好的DBA是至关重要的。不过好的DBA相当稀有,以至于这个角色在大多数组织中只能外包:包给专业的数据库服务公司,包给云数据库RDS服务团队,或者内包给自己的研发/运维人员。


DBA的未来

许多公司都雇用DBA,DBA类似于Cobol程序员,除了科技公司/初创企业外:那些听上去不那么Fancy的制造业,银行保险证券、以及大量运行本地软件的党政军部门,也大量使用了这些关系型数据库。在可预见的未来,DBA在某个地方找工作是不会有什么问题的。

尽管数据库专家对于大型组织与大型数据库而言非常重要,不幸的是,DBA作为一份职业前景可能是晦涩暗淡的。大趋势是数据库本身会越来越智能,易用性越来越好,而各式各样的工具、SaaS、PaaS不断涌出,也会进一步压低数据库的使用门槛。公有云/私有云DBasS的出现更是让数据库的门槛进一步下降,只要掏钱就可以迅速达到优秀DBA的廉价七成正确水准。

数据库的专业技术门槛降低,将导致DBA的不可替代性降低:安装一套软件收费十几万,做一次数据恢复上百万的好日子肯定是一去不复返了。但对于开源数据库软件社区生态来说,却是一件好事:将会有更多的开发者有能力来使用它,并或多或少扮演着DBA的角色。

云会革了运维与DBA的命吗?

无论是公有云厂商,还是以Kubernetes为代表的云原生/私有云,其核心价值都在于使用软件,而不是人来应对系统复杂度。那么,云软件会革了运维与DBA的命吗?

从长期来看,这类云软件代表着先进生产力的发展方向。对于云原生环境中成长起来的新一代开发者,对于他们来说K8S才是操作系统,底下的Linux、网络、存储都属于魔法巫术,成为极少数人才会关心的“底层细节”。大概就像现在我们作为应用研发人员,看待汇编语言指令集,摆弄内存扣字节差不多。但就像人工智能的三起三落一样:过早追逐潮流的人不一定是先驱,而有大概率成为先烈

无论是系统管理员还是数据库管理员,管理员这个岗位消失的唯一方式是,它们被重命名为“DevOps Engineer”或SRE。云并不会消灭管理员,你可能需要更少的人手来打理这些云软件,但总归还是需要人来管理的:从整个行业的视角看,云软件的推广会让100个初中级运维(传统系统管理员)的工作岗位变成10个中高级运维岗位(DevOps/SRE),同样的事也有可能发生在DBA身上。例如,现在也出现了与SRE相对应的 DREDatabase Reliability Engineer。

img

Database Reliability

从另一方面来说,云RDS提供的性能与可靠性属于廉价七成正确的大锅饭,比起优秀专职DBA所精心照顾的本地数据库表现仍然相距甚远。云数据库就像IT中的每一轮炒作一样:东西很受欢迎,每个人都为玩具Demo着迷,直到将其投入生产。然后他们终于发现了时尚潮流的垃圾箱火灾是什么样的,并回头开始研究久经考验的真实技术。总是一样的,人工智能便是前车之鉴。

img

公有云RDS的两大核心问题:成本/自主可控

尽管DBA听上去是一个有着光辉历史与暗淡前景的行当,但未来仍未可知。天知道在几次恐怖的重大云数据库事故后,DBA会不会重新成为潮流呢?

做点什么?

开源免费的数据库发行版解决方案,也能让大批量研发/运维工程师成为合格的兼职DBA。而这就是我正在做的事情:Pigsty —— 开箱即用的开源数据库发行版, 上手即用,量产DBA。

img

Pigsty 架构简介

img

Pigsty监控界面概览

我是一个PostgreSQL DBA,但也是软件架构师与全栈应用开发者。Pigsty是我用软件来完成自己作为DBA的工作的一次尝试:它成功的完成了我大部分的日常工作:无可比拟的监控系统能为性能优化与故障排查预警提供扎实的数据支持,自动切换的高可用集群能让我在故障时游刃有余甚至睡醒了觉再慢慢处理,一键安装部署扩缩容备份恢复则将日常管理事务变为了零星几条命令的事。

img

What is Pigsty

如果您想要使用PostgreSQL / Redis / Greenplum 等数据库,比起聘请昂贵稀缺的专职DBA,或使用费用高昂无法自主可控的云数据库,也许这是一个不错的替代选择。扫码加公众号与微信交流群了解更多。

云RDS:从删库到跑路

微信公众号原文

上一篇文章《DBA还是份好工作吗》中提到:尽管DBA作为一份职业在没落,但谁也保不准DBA会不会在几次恐怖的大规模云数据库故障后,重新成为潮流。

这不,最近就目睹了一场云数据库删库跑路现场情景剧。本文就来聊一聊在生产环境使用PostgreSQL,如何应对误删数据的问题。

mysql-dropdb.gif

rmrf.gif

wm-dropdb.png


故障现场

case-1.png

case-2.png


解决方案

看完了故事,我们不禁要问,我都已经花钱买了‘开箱即用’的云数据库了,为啥连PITR恢复这么基本的兜底都没有呢?

说到底,云数据库也是数据库,云数据库并不是啥都不用管的运维外包魔法,不当配置使用,一样会有数据丢失的风险。没有开启WAL归档就无法使用PITR,甚至无法登陆服务器获取现存WAL来恢复误删的数据。

当然,这也得怪云厂商抠门心机,WAL日志归档PITR这些PG的基础高可用功能被云阉割掉了,放进所谓的“高可用”版本。WAL归档对于本地部署的实例来说,无非是加块磁盘配置条命令的事情。对象存储1GB一个月几分钱,最是廉价不过,但乞丐版云数据库还是要应省尽省,不然怎么卖“高可用”版的数据库呢?

在Pigsty中,所有PG数据库集群都默认启用了WAL归档并每日进行全量备份:保留最近一日的基础冷备份与WAL,允许用户回溯至当日任意时刻的状态。更是提供了开箱即用的延迟从库搭建工具,防误删快人一步!


如何应对删库?

传统的“高可用”数据库集群通常指的是基于主从物理复制的数据库集群。

故障大体可以分为两类**:硬件故障/资源不足**(坏盘/宕机),软件缺陷/人为错误(删库/删表)。基于主从复制的物理复制用于应对前者,延迟从库与冷备份通常用于应对后者。因为误删数据的操作会立刻被复制到从库上执行,所以热备份与温备份都无法解决诸如 DROP DATABASE,DROP TABLE这样的错误,需要使用冷备份延迟从库

冷备份

在Pigsty中,可以通过为集群中的数据库实例指定角色( pg_role ),即可以创建物理复制备份,用于从机器与硬件故障中恢复。例如以下配置声明了一个一主两从的高可用数据库集群,带有一个热备一个温备,并自动制作每日冷备。

pg-backup.png

pg-backup 是一个Pigsty内置的开箱即用备份剧本,可自动制作基础备份。

pigsty.png

在 Pigsty 所有的配置文件模板中,都配置有以下归档命令

wal_dir=/pg/arcwal;
/bin/mkdir -p ${wal_dir}/$(date +%Y%m%d) && /usr/bin/lz4 -q -z %p > ${wal_dir}/$(date +%Y%m%d)/%f.lz4

默认在集群主库上,所有WAL文件会自动压缩并按天归档,需要使用时,配合基础备份,即可将集群恢复至任意时间点。

当然,您也可以使用 Pigsty 带有的 pg_probackup, pg_backrest 等工具来自动管理备份与归档。将冷备份与归档丢到云存储或专用备份中心,轻松实现异地跨机房容灾

冷备份是经典的兜底备份机制,如果只有冷备份本身,那么系统将只能恢复到备份时刻到状态。如果加之以WAL日志,就可以通过在基础冷备份上重放WAL日志,将集群恢复到任意时间点

延迟从库

冷备份虽然很重要,但对于核心业务来说,下载冷备份,解开压缩包,推进WAL重放需要很长一段时间,时间不等人。为了最小化RTO,可以使用另一种称为 延****迟从库的技术来应对误删故障。

延迟从库可以从主库接受实时的WAL变更,但延迟特定的时间再应用。从用户的视角来看,延迟从库就像主库在特定时间前的一份历史快照。例如,您可以设置一个延迟1天的从库,当出现误删数据时,您可以将该实例快进至误删前的时刻,然后立刻从延迟从库中查询出数据,恢复至原始主库中。下面的Pigsty配置文件声明了两个集群:一个标准的高可用一主一从集群 pg-test,以及一个该集群的延迟从库:pg-testdelay,为方便起见,配置1分钟的复制延迟:

# pg-test 是原始集群
pg-test:
  hosts:
    10.10.10.11: { pg_seq: 1, pg_role: primary }
  vars: { pg_cluster: pg-test }

# pg-testdelay 是 pg-test 的延迟集群
pg-testdelay:
  hosts:
    10.10.10.12: { pg_seq: 1, pg_role: primary , pg_upstream: 10.10.10.11, pg_delay: 1d }
    10.10.10.13: { pg_seq: 2, pg_role: replica }
  vars: { pg_cluster: pg-test2 }

monitor.png

在PGSQL REPLICATION监控面板中,pg-test集群的复制指标如上图所示,启用复制延迟配置后,延迟从库pg-testdelay-1有了稳定的1分钟“应用延迟”(Apply Delay),在LSN进度图表中,主库的LSN进度与延迟从库的LSN进度在水平时间轴上相差了正好1分钟。

您也可以创建一个普通的备份集群,然后使用 **pg edit-config pg-testdelay **的方式,来手工修改延迟的时长配置。

edit-config.png

修改延迟为1小时并应用

Pigsty提供了完善的备份支持,无需配置即可使用开箱即用的主从物理复制,绝大多数物理故障均可自愈。同时,还提供了延迟备库与冷备份支持,用于应对软件故障与人为误操作。您只需要准备几台物理机/虚拟机/或者云服务器,即可一键创建并拥有真正的高可用数据库集群!

Pigsty,让您的数据库坚若磐石,除了高可用,还自带监控系统,完全开源免费!

注:您依然可以使用 pgsql-remove.yml **一键删光所有数据库。

又注:此行为受 pg_safeguardpg_clean 等一系列安全保险参数控制,以避免胖手指误删。

pigsty.png

DBA会被云淘汰吗?

微信公众号原文

前天开源漫谈第九期主题《DBA会被云淘汰吗?》,我作为主持人全程克制着自己亲自下场的冲动,因此特此写了这篇文章来聊聊这个问题 : DBA 会被云淘汰吗?

DBA帮助用户用好数据库

很多地方都需要DBA:糟糕的模式设计,奇烂的查询性能,鬼知道有没有用的备份;等等等等。可惜的是,从事软件工作的人中,很少有人了解什么是DBA。成为DBA,意味着与研发人员创造的熵进行永无休止的战斗。

DBA,Database Administrator,数据库管理员,以前也叫做数据库协调员、数据库程序员。DBA是一个横跨于研发团队与运维团队的广博角色,涉及DA、SA、Dev、Ops、以及SRE的多种职责,负责各种与数据与数据库有关的问题:设置管理策略与运维标准,规划软硬件架构,协调管理数据库,验证表模式设计,优化SQL查询,分析执行计划,乃至于处理紧急故障以及抢救数据。

许多公司都会雇用DBA,传统的 DBA 类似 Cobol 程序员,除了科技公司/初创企业外:那些听上去不那么Fancy的制造业,银行保险证券、以及大量运行本地软件的党政军部门,也大量使用了这些关系型数据库。单花在这些商业数据库软件授权上的费用可能就有六七位数,加之相近的硬件成本与服务订阅成本。如果公司已经砸了成百上千万的钱在数据库软硬件上,那么再花一些钱雇佣一些专职专家来照顾这些昂贵且复杂的数据库,就是一件很自然的事情,这些专家就是传统的 DBA。

接下来随着 PostgreSQL / MySQL 这些开源数据库的兴起,这些公司们有了一个新选择:不用软件授权费用即可使用数据库软件,而它们也开始(不理性地)停止为数据库专家付费:维护数据库的工作被隐含在了研发与运维的附属职责中,而这两类人通常:既不擅长、也不喜欢、更不在乎照顾数据库的事情。直到公司的规模足够大,或者吃到足够的苦头之后,一些Dev/Ops才会培养出相应的能力来,成为DBA —— 不过这是相当罕见的事情,而这也是今天我们讨论的主角 —— 开源数据库的 DBA。

用好数据库的能力很稀缺

培养开源数据库 DBA 的核心要素是场景,而有足够复杂度和规模的场景是极其稀缺的,往往只有头部的大甲方才有。就好比国内 MySQL 的 DBA 主要产自重度使用 MySQL 的淘宝等互联网头部公司。而优秀 PostgreSQL DBA 基本上都出自去哪儿网、平安银行、探探这几个大规模使用 PG 的公司。顶级的开源数据库 DBA 的来源极其有限,基本是在顶级甲方用户中精通数据库的运维/研发,靠着真金白银的大故障与复杂场景的建设经验,才能零星砸出来几个。

以中国的 PostgreSQL DBA 为例,根据圈内纯技术文传播阅览量,圈子规模大概在千人左右;但能建设架构超过 RDS 水准的数据库系统的 DBA 就收敛到几十个了;能自己打造更好的 RDS ,甚至做到对外复制输出最佳实践的更是凤毛麟角,一只手就能数过来。

所以,当下数据库领域的主要矛盾,不是缺少更好更强大的新内核,而是极度匮乏用好管好现有数据库内核的能力 —— 数据库太多,而司机太少! 数据库内核已经发展了几十年,在内核上的小修小补边际收益已经很小了。而像 PostgreSQL 这样成熟开源数据库内核引擎出现,让卖商业数据库成为一门糟糕的生意 —— 开源数据库不需要高昂的软件授权费用,那么能用好这些免费的开源数据库的老司机 —— DBA,就成为了最大的瓶颈与成本。

在这个阶段,高级的经验都“垄断”在少数头部专家手中。实际上,这正好是开源真正的“商业模式” —— 创造高薪的技术专家岗位。然而这也出现了一个新的机会 —— 商业数据库产品因为开源替代的出现已经很难形成垄断了,但能用好开源数据库的DBA专家是屈指可数的,而垄断少数几个专家的难度比起干翻开源数据库要简单太多了。垄断不了数据库产品,就垄断用好它的能力!

阶段 名称 特点 “商业模式”
阶段1 商业数据库 商业数据库软件垄断了数据库产品供给。 天价软件授权
阶段2 开源数据库 开源打破了商业数据库垄断,
但技术垄断在少量头部开源专家手中。
高薪专家岗位
阶段3 云数据库 云打破了开源专家技术垄断
但在用好数据库的能力上形成垄断
管控软件租赁
阶段4 “云原生?” 开源管控软件打破了云管控软件垄断
用好数据库的能力普及到千家万户
咨询保险兜底

所以,尽可能招揽能用好开源数据库的专家,打造一个共享专家池让稀缺的高级 DBA 得以时分复用,并和 DBA 经验沉淀而成的管控软件一起打包成服务出租,就是一种非常有利可图的商业模式 —— 而云数据库RDS 正是这样做的,并赚的钵满盆翻。

云数据库使用的内核本身是开源免费的,所以云数据库提供的核心能力,正是和 DBA 一样的,帮助用户用好数据库的能力! 它真正的竞品不是其他商业数据库内核,或者开源数据库内核,而是 DBA —— 特别是处于中下游位置的 DBA。这就跟出租车公司要取代的不是汽车厂,而是全职司机一样。

DBA的工作与自动化管控

除了 DBA 人力,还有什么办法可以获得用好数据库的能力?那我们就需要先来看一看 DBA 的工作模式。

DBA的工作在时间上主要分为建设维护两个阶段。在最初几个月的密集建设阶段会比较幸苦,需要负责搭建成熟的技术架构与管理体系;而当自动化建设完成,进入了维护阶段后 —— DBA的工作就要轻松很多了。

建设阶段 维护阶段
管理层 数据库选型,制度建设 数据库建模,查询设计,人员培训,SOP积累,开发规约
应用层 架构设计,服务接入 SQL审核 / SQL变更 / SQL优化 / 分库分表 / 数据恢复
数据库层 Infra建设,数据库部署 备份恢复 / 监控告警 / 安全合规 / 版本升级 / 参数调优
操作系统层 OS调优,内核调参 存储空间管理
硬件层 测试选型,驱动适配 (更换备件)

体系建设并不是一蹴而就的一锤子买卖,而是一个水平随时间对数增长的演化过程。有兴趣研究折腾的DBA会持续致力于更高水平的自动化建设,将建设过程浓缩为可复制的经验、文档、流程、脚本、工具、方案、平台、管控软件。管控软件也许是目前 DBA 经验沉淀的终极形态 —— 用软件代替自己干 DBA 的活儿。

管控系统的自动化水平越高,维护阶段所需的维护人力就越少。但是对于 DBA 水平的要求也就越高,所需的建设投入与时间周期也就越长。所以在某一个平衡点上,或者是自动化程度撞上了 DBA 水平的天花板,或者是高到了威胁DBA 的职业安全,建设演进就会告一段落,DBA 进入“喝茶看报”的持续维护状态。

维护状态的系统,所需的智力带宽会显著下降。在建设完毕的良好系统架构中,如果只是日常性、规范性的工作,水平更低一些的 DBA 也足以维持,对高级 DBA 的时间需求也会戏剧性下降 —— 进入 “养兵千日,用兵一时” 的 “闲置” 状态,只有当出现紧急的故障与疑难杂症时,这些数据库专家老司机才能再次体现出自己的价值。

阶段 能力构成
普通用户-建设起步 100% 专家人力
普通用户-维护阶段 **30%**管控 + 70% 专家人力
顶级用户-维护阶段 90% 管控 + 9% 运维人力 + 1% 专家人力

所以 DBA 以前其实是一个非常不错的岗位,经过创业打江山的建设阶段之后,就可以躺在功劳簿上,享受建设成果带来的效率红利。 比如顶级甲方中的 DBA 经过长期建设,也许 90% 的工作内容都高度自动化了 —— 比如连硬件故障都靠高可用管控自愈了。DBA 只需要 10% 的救火/优化/指导/管理时间,那么剩下 90% 的时间就可以自由支配:继续改善管控软件实现利滚利,或者学习内核源码翻译书籍,或者单纯就是像 DBA 的先辈 —— ‘图书管理员“ 那样在图书馆里喝茶看报,好不惬意。

然而 DBA 的这种舒适的生活被云数据库的模式打破了。首先,云厂商拿着已经建设好的管控软件批量复制分发,消灭了数据库建设阶段的重复性工作。其次,如果没有建设阶段,只有维护阶段,而维护工作只需要 DBA 10% 的时间,那么与其用 90% 的时间摸鱼,总会有卷王选择当时间管理大师同时去打 10 份工。云厂商的数据库专家通过管控和共享 DBA,让这个IT领域难得的清闲岗位也卷翻了起来。

云数据库的模式与新挑战

云数据库为什么会对 DBA 构成威胁?要解释这个问题,我们就需要先来聊一聊云数据库 RDS 的用户价值。

云数据库的核心价值是 “敏捷” 与 “兜底”。至于什么 “便宜”,“简单”,“弹性”,“安全“,”可靠” 其实都不是核心,甚至也都不一定真的成立。所谓 “敏捷” —— 翻译过来就是为用户省掉几个月的建设阶段工作,一步到位进入维护阶段 。所谓 “兜底”,就是指用户真正出现疑难杂症,真正需要顶级 DBA 的高智力带宽时,云厂商为用户通过工单的方式提供保障 —— 至少你确实能摇到人来管一管。

云数据库在技术上的核心壁垒,是沉淀了高级DBA经验的管控软件。大部分DBA,包括不少顶级 DBA —— 尽管其本身是数据库管理领域的专家,但却并没有研发能力 —— 可以自己将自己的领域知识与经验沉淀为可复制软件产品的能力。因此通常需要一个研发团队的辅助,来将高级DBA的领域知识转变为业务软件。

这些沉淀了 DBA 经验的管控软件,就成为了云数据库的核心生产资料与摇钱树。核·月单位成本20块钱的硬件资源,套上管控软件,就能卖出 300~400(Aliyun),甚至 800~1300(AWS)这样几十倍的天价来。不过也正是RDS这样线性绑定硬件资源的定价策略,让一部分中级 DBA 现在还能有喘息空间 —— 当 RDS 规模达到 100核以上,招聘一个 DBA 自建维护就会达到 ROI 的转折点了。

管控软件替代 DBA 工作的另一个好处是, DBA 可以加杠杆了!举个例子,如果你的管控软件可以自动化掉 DBA 90% 的工作,那么 同样的活就只需要一个DBA 10% 的时间,可以把一个 DBA 当十个用,所以 DBA 乘数就是10。如果你的管控软件简单易用,门槛很低,让普通运维/开发也能玩 DBA Cosplay,自助完成这 10% 工作中的 9%,那么就只需要专家 1% 的时间了,1个DBA可以当100个用!当然如果未来出现个 DBA 大模型,再把这 1% 的剩余工作替代 0.9% ,DBA 乘数就可以放大到 1000 倍了!

管控软件 DBA乘数
普通用户-建设起步阶段 100% DBA人力 1
普通用户-维护阶段 30%管控 + 70% DBA人力 1.43
云数据库 60% 管控 + 38% 人力 + 2% DBA人力 50
顶级用户-维护阶段 90% 管控 + 9% 人力 + 1% DBA人力 100
未来状态想象 95% 管控 + 4% 大模型 + 0.9% 人力 + 0.1% DBA人力 1000

所以,云厂商的模式和 银行很像。有所谓的 “存款准备金率” 和 “DBA乘数”,可以十个坛子甚至上百个坛子一个盖。充分释(ya)放(zha) DBA 老司机的空闲时间与剩余价值,用较低的人力成本,为更多的客户提供“兜底”服务。解决了 “用好数据库能力” 非常稀缺的问题,并赚的钵满盆翻。

如果让我来实事求是的评价云数据库服务的质量水平,用百分制打分的话。那么顶级DBA的自建水准可以到 95~100 分,优秀 DBA 自建能达到八十分上下;云数据库的水平大约就在 70 分。可是中级 DBA 土法自建也就大概五六十分,初级DBA土法自建也就三四十分,运维兼职的土法自建可能也就十几分。头部的甲方确实看不上云数据库这种大锅饭,但这对于腰部的用户来说这简直太香了 —— 他们要的就是大锅饭,而比起采购天价的商业数据库与聘请稀缺的数据库老司机,RDS确实配得上一句“物美价廉”。

第一:云数据库是预制菜,直接就能吃,不需要建设阶段;第二:云数据库是廉价七成正确的合格品,而相当一部分初中级DBA土法自建几个月,也都达不到 RDS 这样的水平;第三:云数据库是标准件,降低了DBA天马行空自由发挥带来的不确定性与不可替代性;第四:云数据库提供了共享专家,“兜底”了其余一些对 DBA 的需求,也解决了出问题摇不到人或者遇人不淑的担忧。所以对于那些规模偏小,水平一般的甲方用户来说,云数据库比起招聘培养一个初中级 DBA 自建很有吸引力。

云数据库服务对 DBA 的冲击是结构性的。极度稀缺的顶尖 DBA 不受影响,一直会是云厂商争相笼络招安拉拢的香饽饽。而胸部以下的 DBA ,或者说自建水平达不到 70 分的 DBA ,就会直接面临云数据库服务的生态位竞争。对于 DBA 这个行业来说,这不是一件好事 —— 因为高级 DBA 都是从初级,中级DBA 成长起来的。如果诞生培育这些初中级DBA的土壤 —— 中小公司的数据库应用场景都被云厂商垄断截胡,那么这个行业金字塔就会被腰斩掉,顶级DBA的增量被断掉,存量被蚕食,最终也会成为无根之木。

打破云数据库的核心壁垒

云数据库会是未来吗?云数据库会像 “汽车替代马车” 那样革掉 DBA 的命吗?我不这么想,因为有力就会有反作用力。与时俱进的 DBA 们会用工具武装自己,重新回到舞台中央与 RDS 同台竞技。

DBA 们想要与云数据库竞争,采用路德分子抵制技术进步的方式是没有用的。而应当用 “你强我更强” 的方式提高自己相对于云数据库的竞争力。而要做到这一点,DBA 需要用更低的成本,提供比RDS更高的价值。要做到这一点,质量、安全、可靠的部分我都不用担心 DBA 的专业能力,核心在于 “敏捷” 与 “兜底” 这两个问题:

首先,把几个月的建设周期缩短到几天甚至几小时,做到“敏捷”。

其次,真的出现疑难杂症问题时,能够摇到顶级DBA来“兜底”。

解决前者要靠 管控软件,解决后者,要靠DBA老司机。而前者的紧迫性要远远高于后者 —— 建设良好的系统也许跑个几年都不会遇到需要 ”兜底“ 的问题,让普通 DBA 人人都成为老司机也不现实。而如何敏捷、低成本的拉起一套 70 分以上的数据库服务体系,是 DBA 应对 RDS 挑战的核心问题。

而这,正是我发起 Pigsty 这个开源项目的初衷 —— 提供一个完全开源免费,且质量更好的 RDS PG 替代品。让普通的DBA/研发/运维人员都能以同样的敏捷的方式迅速建设交付 80分+ 的本地 RDS 服务!彻底解决掉第一个问题。而我自己的商业模式是咨询与服务,为这些疑难杂症提供商业支持与最后兜底,解决第二个问题。

一个开源且足够好的数据库管控软件,会直接颠覆云数据库的商业模式。举个最简单的例子,你完全可以拿同样具有弹性的云服务器 ECS 和云盘 ESSD,使用开源管控来自建 RDS 服务。在不损失云所鼓吹的“弹性”与“敏捷”以及各种RDS好处的前提下,在不需要额外的人手的情况下,立竿见影的省掉 60% ~ 90% 不等的 “纯RDS溢价”。如果在使用自有服务器纯自建的情况下,能带来的降本增效水平恐怕会超出绝大多数用户的认知。

Pigsty 会重新设置云数据库服务的基线水平,所有质量不及它的PG管控软件价值都会逐渐萎缩归零,这是数据库管理领域的核武器扩散,是站在道德高地上的开源倾销。和当年开源数据库掀翻商业数据库的桌子是一模一样的,只不过这一次发生在了另一个维度 —— 管控软件 上。Pigsty 为所有 PG DBA 立即装备上了瞬间完成高水平数据库服务建设与交付的魔法棒,也让更多的研发/运维可以扮演 PG DBA 的角色,瞬间量产出大量的初级 DBA 来。

当然作为开源的管控软件,Pigsty 确实和云数据库管控一样,替代了很大一部分的 DBA 工作内容,特别是运维性的部分。但和云数据库不一样的是,它掌握在 DBA 自己的手中,由DBA所拥有,控制,使用,而不是只能向云计算领主去租赁并”替代“DBA。更强生产力带来的闲暇时间红利与DBA乘数杠杆,会直接普及到每一个从业者手中。而这,就是我作为一个顶尖DBA对于 RDS 挑战的回复。

如何面对云数据库的冲击

对于广大初中级 DBA 来说,我认为应对云数据库挑战的最佳办法就是立即放弃周期长,效果良莠不齐的土法自建尝试,直接拥抱成熟的开源管控软件,快速放大自己相对于云数据库的竞争力 —— 这一部分是完全开源免费,掌握在你自己手中的生产资料与能力。如果需要疑难杂症兜底,我非常乐意以一个相比于云数据库极有竞争力的价格提供支持、咨询与答疑。

请不要再来问我:PostgreSQL 高可用如何做?PITR 备份恢复怎么搞?可观测性与监控系统如何搭建?如何用配置IaC管理几百套数据库集群?连接池如何配置管理?负载均衡与服务接入怎么做?上百个扩展插件如何编译分发打包?主机参数怎么调优?上线/下线/扩容/缩容/滚动升级/数据迁移这些怎么做?这些你真正会遇到的问题,也是我曾经遇到的问题,而我已经在 Pigsty 中我都给出了工具化的最佳实践与版本答案,并配有 DBA SOP 手册,让小白也能快速上手玩起 DBA Cosplay。

对于顶级DBA与同侪们,我倡议合力打造开源共有的管控软件,并基于此提供专业数据库服务。与其你搞一套云管,我搞一套云管,投入大量的研发人力搞低水平、重复性的建设,倒不如凝聚起来打造公有的开源管控,打造中国社区里真正有世界影响力的开源项目品牌。Pigsty 是一个很不错的候选开源项目 —— 在当下,它已经成为中国人主导的 PostgreSQL 生态开源项目中排名最前的项目了。它也许有机会成为 PostgreSQL 世界中的 Debian 与 Ubuntu,但这取决于所有每一个贡献者与每一个用户。

我也不靠 Pigsty 来赚钱,和许多数据库服务公司一样,靠的也是提供专业的咨询与服务。这也许不是资本市场喜欢听的那种 “Scale to the Moon” 的故事,但确实可以解决用户的痛点需求。我一个人即使再牛逼,能打 200 份 PG DBA 的工吗?不能!但 Pigsty 这个管控工具可以让每一个 PG DBA 老司机都加上这样的杠杆,去为社会提供真正有价值的咨询与服务,从而卷翻云数据库!

例如提供 MySQL 专家服务的 Percona ,负责 PostgreSQL 部门的头 Umair Shahid 就很敏锐地看到了这个趋势。他从 Percona 出来,成立了自己的创业公司 Stormatics 来提供专业 PostgreSQL 服务。他没有自己再 “研发” 一套什么 PG云数据库管控平台之类的东西,而是直接使用 Pigsty 进行系统交付。同样也有一些意大利,美国,国内的数据库公司在使用 Pigsty 交付 PostgreSQL 服务。我对此表示热烈欢迎、并愿意提供支持与帮助。

数据库产品的模式正在消亡,而数据库咨询与专家服务的模式方兴未已。用好数据库是一个门槛很高的领域,即使强如下云先锋 DHH,抠门大王也依然会有一笔采购 Percona MySQL 专家服务的开销来请专业的人解决专业的问题。比起出卖尊严去包装换皮套壳吹牛撒谎打造使用价值微乎其微的(Minor PG fork) “新数据库内核产品” ,倒不如堂堂正正地去为用户提供真正有价值的数据库专家咨询与服务 。

在当下,服务器硬件资源非常便宜,数据库内核软件开源免费且足够牛逼,现在,如果管控软件不再被云厂商垄断,那么提供完整数据库服务的核心要素,就只剩下了用于兜底的专家能力!AI 与 GPT 的出现更是让单个数据库专家的杠杆乘数放大到一个惊人的地步。

所以,有很多云厂商内部的数据库老司机都敏锐地洞察到了这个趋势,选择脱离云厂商自己出来单干!比如从阿里云出来的就有,唐成老师的乘数科技,曹伟老师的Kubeblocks,叶正盛老师的 NineData,等等等等。所以即使是云数据库厂商内部的团队,也不是铁板一块。团队也在剧烈变动,凋零失血,人心思变中。

我相信未来的世界,不会是一个云数据库垄断的世界。各家 RDS 管控的质量水平长期止步不前,已经达到了场景土壤所容许的能力天花板。而顶尖 DBA 的经验沉淀下来的生产力工具则更进一步,让许多腰部 DBA 面对 RDS 都能重新有一战之力。与时俱进的 DBA 们会用工具武装自己,与 RDS 同台竞技。而我愿意替天行道,扛起下云与自建替代的大旗,开发这些管控软件与工具并普及到每一个DBA手中,帮助 DBA 打赢反抗云数据库的战斗!

PG 生态

关于 Pigsty 与 PostgreSQL ,以及其他 PostgreSQL 生态项目、组件的文章

PostgreSQL会修改开源许可证吗?

作者:Jonathan Katz,PostgreSQL 核心组成员(1 of 7),AWS RDS 首席产品经理

译者:冯若航,PostgreSQL 专家,Free RDS PG Alternative —— Pigsty 作者


PostgreSQL会修改开源许可证吗

声明:我是PostgreSQL 核心组 的成员,但本文内容是我的个人观点,并非 PostgreSQL 官方声明 …… 除非我提供了指向官方声明的链接

今天得知 Redis 项目将不再使用开源许可证发布,我感到非常遗憾。原因有二:一是作为长期的 Redis 用户和较早的采用者,二是作为一个开源贡献者。对于开源商业化这件事的挑战,我不得不说确实感同身受 —— 特别是我曾站在针锋相对的不同阵营之中(译注:作者也是 AWS RDS 首席产品经理)。我也清楚这些变化对下游的冲击,它们可能对用户采纳、应用技术的方式产生颠覆性的影响。

每当开源许可证领域出现重大变动时,尤其是在数据库及相关系统中(例如 MySQL => Sun => Oracle 就是第一个映入我脑海的),我总会听到这样的问题:“PostgreSQL会修改其许可证吗?”

PostgreSQL 的网站上其实 有答案

PostgreSQL会使用不同的许可证发布吗?PostgreSQL 全球开发组(PGDG)依然致力于永远将 PostgreSQL 作为自由和开源软件提供。我们没有更改 PostgreSQL 许可证,或使用不同许可证发布 PostgreSQL 的计划。

声明:上面这段确实是我参与撰写的

PostgreSQL许可证(又名 “协议” — Dave Page 和我在这个词上来回辩论挺有意思的)是一个开源倡议组织(OSI)认可的许可证,采用非常宽松的许可模型。至于它与哪个许可证最为相似,我建议阅读 Tom Lane在2009年写的这封电子邮件 (大意是:更接近 MIT 协议,叫 BSD 也行)。

尽管这么说,但 PostgreSQL不会改变许可证,还是有一些原因在里面的:

  • 许可证的名字就叫 “PostgreSQL许可证” —— 你都用项目来命名许可证了,还改什么协议?
  • PostgreSQL项目发起时,以开源社区协作为主旨,意在防止任何单一实体控制本项目。这一点作为项目的精神主旨已经延续了近三十年时间了,并且在项目 项目政策 中有着明确体现。
  • Dave Page 在这封邮件中明确表示过 😊

那么真正的问题就变成了,如果 PostgreSQL 要改变许可证,会出于什么理由呢?通常变更许可证的原因是出于商业决策 —— 但看起来围绕 PostgreSQL 的商业业务与 PostgreSQL 的功能集合一样强壮。冯若航(Vonng)最近写了一篇博客文章,突出展现了围绕 PostgreSQL 打造的软件与商业生态,这还仅仅是一部分。

我说 “仅仅是一部分” 的意思是,在历史上和现在还有更多的项目和商业,是围绕着 PostgreSQL 代码库的某些部分构建的。这些项目中许多都使用了不同的许可证发布,或者干脆就是闭源的。但它们也直接或间接地推动了PostgreSQL 的采用,并使 PostgreSQL 协议变得无处不在。

但 PostgreSQL 不会改变其许可证的最大原因是,这将对所有 PostgreSQL 用户产生不利影响。对一项技术来说,建立信任需要很长时间,尤其是当该技术经常用于应用程序最关键的部分:数据存储与检索。PostgreSQL赢得了良好的声誉 —— 凭借其久经考验的架构、可靠性、数据完整性、强大的功能集、可扩展性,以及背后充满奉献精神的开源社区,始终如一地提供优质、创新的解决方案。修改 PostgreSQL 的许可证将破坏该项目过去近三十年来建立起的所有良好声誉。

尽管 PostgreSQL 项目确实有不完美之处(我当然也对这些不完美的地方有所贡献),但 PostgreSQL 许可证对PostgreSQL 社区和整个开源界来说,确实是一份真正的礼物,我们将继续珍惜并帮助保持 PostgreSQL 真正的自由和开源。毕竟,官网上也是这么说的 ;)

译者评论

能被 PostgreSQL 全球社区核心组成员提名推荐,我感到非常荣幸。上文中 Jonathan 提到我的文章是《PostgreSQL正在吞噬数据库世界》,英文版为《PostgreSQL is Eating The Database World》。发布于 Medium:https://medium.com/@fengruohang/postgres-is-eating-the-database-world-157c204dcfc4 ,并在 HackerNews ,X,LinkedIn 上引起相当热烈的讨论。

Redis 变更其许可证协议,是开源软件领域又一里程碑式的事件 —— 至此,所有头部的 NoSQL 数据库 ,包括 MongoDB, ElasticSearch,加上 Redis ,都已经切换到了 SSPL —— 一种不被 OSI 承认的许可证协议。

Redis 切换为更为严格的 SSPL 协议的核心原因,用 Redis Labs CEO 的话讲就是:“多年来,我们就像个傻子一样,他们拿着我们开发的东西大赚了一笔”。“他们”是谁? —— 公有云。切换 SSPL 的目的是,试图通过法律工具阻止这些云厂商白嫖吸血开源,成为体面的社区参与者,将软件的管理、监控、托管等方面的代码开源回馈社区。

不幸的是,你可以强迫一家公司提供他们的 GPL/SSPL 衍生软件项目的源码,但你不能强迫他们成为开源社区的好公民。公有云对于这样的协议往往也嗤之以鼻,大多数云厂商只是简单拒绝使用AGPL许可的软件:要么使用一个采用更宽松许可的替代实现版本,要么自己重新实现必要的功能,或者直接购买一个没有版权限制的商业许可。

当 Redis 宣布更改协议后,马上就有 AWS 员工跳出来 Fork Redis —— “Redis 不开源了,我们的分叉才是真开源!” 然后 AWS CTO 出来叫好,并假惺惺的说:这是我们员工的个人行为 —— 堪称是现实版杀人诛心。而同样的事情,已经发生过几次了,比如分叉 ElasticSearh 的 OpenSearch,分叉 MongoDB 的 DocumentDB。

因为引入了额外的限制与所谓的“歧视”条款,OSI 并没有将 SSPL 认定为开源协议。因此使用 SSPL 的举措被解读为 —— “Redis 不再开源”,而云厂商的各种 Fork 是“开源”的。从法律工具的角度来说,这是成立的。但从朴素道德情感出发,这样的说法对于 Redis 来说是极其不公正的抹黑与羞辱。

正如罗翔老师所说:法律工具的判断永远不能超越社区成员朴素的道德情感。如果协和与华西不是三甲,那么丢脸的不是这些医院,而是三甲这个标准。如果年度游戏不是巫师3,荒野之息,博德之门,那么丢脸的不是这些厂商,而是评级机构。如果 Redis 不再算“开源”,真正应该感到汗颜的应该是OSI 与开源这个理念。

越来越多的知名开源软件,都开始切换到敌视针对云厂商白嫖的许可证协议上来。不仅仅是 Redis 与 MongoDB,ElasticSearch 在 2021 年也从 Apache 2.0 修改为 SSL 与 ElasticSearch,知名的开源软件 MinIO 与 Grafana 分别在 2020,2021年从 Apache v2 协议切换到了 AGPLv3 协议。

一些老牌的开源项目例如 PostgreSQL ,正如 Jonathan 所说,历史沉淀(三十年的声誉!)让它们已经在事实上无法变更开源协议了。但我们可以看到,许多新强力的 PostgreSQL 扩展插件开始使用 AGPLv3 作为默认的开源协议,而不是以前默认使用的 BSD-like / PostgreSQL 友善协议。例如分布式扩展 Citus,列存扩展 Hydra,ES全文检索替代扩展 BM25,OLAP 加速组件 PG Analytics …… 等等等等。包括我们自己的 PostgreSQL 发行版 Pigsty,也在 2.0 的时候由 Apache 协议切换到了 AGPLv3 协议,背后的动机都是相似的 —— 针对软件自由的最大敌人 —— 云厂商进行反击。

在抵御云厂商白嫖的实践中,修改协议是最常见的做法:但AGPLv3 过于严格容易敌我皆伤,SSPL 因为明确表达这种敌我歧视,不被算作开源。业界需要一种新的歧视性软件许可证协议,来达到名正言顺区分敌我的效果。使用双协议进行明确的边界区分,也开始成为一种主流的开源商业化实践。

真正重要的事情一直都是软件自由,而“开源”只是实现软件自由的一种手段。而如果“开源”的理念无法适应新阶段矛盾斗争的需求,甚至会妨碍软件自由,它一样会过气,并不再重要,并最终被新的理念与实践所替代 —— 比如“本地优先”。

英文原文

WILL POSTGRESQL EVER CHANGE ITS LICENSE?

(Disclosure: I’m on the PostgreSQL Core Team, but what’s written in this post are my personal views and not official project statements…unless I link to something that’s an official project statement ;)

I was very sad to learn today that the Redis project will no longer be released under an open source license. Sad for two reasons: as a longtime Redis user and pretty early adopter, and as an open source contributor. I’ll preface that I’m empathetic to the challenges of building businesses around open source, having been on multiple sides of this equation. I’m also cognizant of the downstream effects of these changes that can completely flip how a user adopts and uses a piece of technology.

Whenever there’s a shakeup in open source licensing, particularly amongst databases and related systems (MySQL => Sun => Oracle being the one that first springs to mind), I’ll hear the question “Will PostgreSQL ever change its license?”

The PostgreSQL website has an answer:

Will PostgreSQL ever be released under a different license? The PostgreSQL Global Development Group remains committed to making PostgreSQL available as free and open > source software in perpetuity. There are no plans to change the PostgreSQL License or release PostgreSQL under a different license.

(Disclosure: I did help write the above paragraph).

The PostgreSQL Licence (aka “License” – Dave Page and I have fun going back and forth on this) is an Open Source Initiative (OSI) recognized license, and has a very permissive model. In terms of which license it’s most similar to, I defer to this email that Tom Lane wrote in 2009.

That said, there are a few reasons why PostgreSQL won’t change it’s license:

The question then becomes - is there a reason that PostgreSQL would change its license? Typically these changes happen as part of a business decision - but it seems that business around PostgreSQL is as robust as its feature set. Ruohang Feng (Vonng) recently wrote a blog post that highlighted just a slice of the PostgreSQL software and business ecosystem that’s been built around it, which is only possible through the PostgreSQL Licence. I say “just a slice” because there’s even more, both historically and current, projects and business that are built up around some portion of the PostgreSQL codebase. While many of these projects may be released under different licenses or be closed source, they have helped drive, both directly and indirectly, PostgreSQL adoption, and have helped make the PostgreSQL protocol ubiquitous.

But the biggest reason why PostgreSQL would not change its license is the disservice it would do to all PostgreSQL users. It takes a long time to build trust in a technology that is often used for the most critical part of an application: storage and retrieval of data. PostgreSQL has earned a strong reputation for its proven architecture, reliability, data integrity, robust feature set, extensibility, and the dedication of the open source community behind the software to consistently deliver performant and innovative solutions. Changing the license of PostgreSQL would shatter all of the goodwill the project has built up through the past (nearly) 30 years.

While there are definitely parts of the PostgreSQL project that are imperfect (and I certainly contribute to those imperfections), the PostgreSQL Licence is a true gift to the PostgreSQL community and open source in general that we’ll continue to cherish and help keep PostgreSQL truly free and open source. After all, it says so on the website ;)

PostgreSQL正在吞噬数据库世界

PostgreSQL 并不是一个简单的关系型数据库,而是一个数据管理的抽象框架,具有吞噬整个数据库世界的力量。而这也是正在发生的事情 —— “一切皆用 Postgres” 已经不再是少数精英团队的前沿探索,而是成为了一种进入主流视野的最佳实践。


OLAP 领域迎来踢馆者

在 2016 年的一次数据库沙龙里,我提出了一个观点: 现在 PostgreSQL 生态的一个主要遗憾是,缺少一个足够好的列式存储分析插件来做 OLAP 分析。尽管PostgreSQL 本身提供了很强大的分析功能集,应付常规的分析任务绰绰有余。但在较大数据量下全量分析的性能,相比专用的实时数仓仍然有些不够看。

以分析领域的权威评测 Clickbench 为例,我们在其中标注出了 PostgreSQL 与生态扩展插件以及兼容衍生数据库在其中的性能表现。原生未经过调优的 PostgreSQL 表现较为拉垮(x1050),但经过调优后可以达到(x47);此外还有三个与分析有关系的扩展:列存 Hydrax42),时序扩展 TimescaleDBx103),以及分布式扩展 Citusx262)。

pg-eat-clickbench.png

这样的分析性能表现不能说烂,因为比起 MySQL,MariaDB 这样的纯 OLTP 数据库的辣眼表现(x3065,x19700)确实好很多;但第三梯队的性能表现也绝对说不上足够好,与专注于 OLAP 的第一梯队组件:Umbra,ClickHouse,Databend,SelectDB(x3~x4)相比,在分析性能上仍然有十几倍的性能差距。食之无味,弃之可惜。

然而, ParadeDB DuckDB 的出现改变了这一点!

ParadeDB 提供的 PG 原生扩展 pg_analytics 实现了第二梯队(x10)的性能水准,与第一梯队只有 3~4 倍的性能差距。相对于其他功能上的收益,这种程度的性能差距通常是可以接受的 —— ACID,新鲜性与实时性,无需 ETL、额外学习成本、维护独立的新服务,更别提它还提供了 ElasticSearch 质量的全文检索能力。

DuckDB 则专注于 OLAP ,将分析性能这件事做到了极致(x3.2) —— 略过第一名 Umbra 这种学术研究型闭源数据库,DuckDB 也许是 OLAP 实战性能最快的数据库了。它并不是 PG 的扩展插件,但它是一个嵌入式文件数据库,而 DuckDB FDW 以及 pg_quack 这样的 PG 生态项目,能让 PostgreSQL 充分利用 DuckDB 带来的完整分析性能红利!

ParadeDB 与 DuckDB 的出现让 PostgreSQL 的分析性能来到了 OLAP 的第一梯队与金字塔尖,弥补了 PostgreSQL 在 OLAP 性能这最后一块关键短板。


分久必合的数据库领域

数据库诞生伊始,并没有 OLTP 与 OLAP 的分野。OLAP 数据仓库从数据库中“独立”出来,已经是上世纪九十年代时候的事了 —— 因为传统的 OLTP 数据库难以支撑起分析场景下的查询模式,数据量与性能要求。

在相当一段时间里,数据处理的最佳实践是使用 MySQL / PG 处理 OLTP 工作负载,并通过 ETL 将数据同步到专用的 OLAP 组件中去处理,比如 Greenplum, ClickHouse, Doris, Snowflake 等等。

与许多 “专用数据库” 一样,专业的 OLAP 组件的优势往往在于性能 —— 相比原生 PG 、MySQL 上有 1~3 个数量级的提升;而代价则是数据冗余、 大量不必要的数据搬运工作、分布式组件之间缺乏一致性、额外的专业技能带来的复杂度成本、学习成本、以及人力成本、 额外的软件许可费用、极其有限的查询语言能力、可编程性、可扩展性、有限的工具链、以及与OLTP 数据库相比更差的数据完整性和可用性 —— 但这是一个合理的利弊权衡

然而天下大势,分久必合,合久必分硬件遵循摩尔定律又发展了三十年,性能翻了几个数量级,成本下降了几个数量级。在 2024 年的当下,x86 单机可以达到几百核 (512 vCPU EPYC 9754x2),几个TB的内存,单卡 NVMe SSD 可达 64TB,全闪单机柜 2PB ;S3 这样对象存储更是能实现几乎没有上限的存储。

io-bandwidth.png

硬件的发展解决了数据量的问题,而数据库软件的发展(PostgreSQL,ParadeDB,DuckDB)解决了查询模式的问题,而这导致分析领域 —— 所谓的“大数据” 行业基本工作假设面临挑战。

正如 DuckDB 发表的宣言《大数据已死》所主张的:大数据时代已经结束了 —— 大多数人并没有那么多的数据,大多数数据也很少被查询。大数据的前沿随着软硬件发展不断后退,99% 的场景已经不再需要所谓“大数据”了。

如果 99% 的场景甚至都可以放在一台计算机上用单机/主从的 DuckDB 或 PostgreSQL 搞定,那么使用专用的分析组件还有多少意义?如果每台手机都可以自由自主收发短信,那么 BP 机还有什么存在价值?(北美医院还在用BP机,正好比也还有 1% 不到的场景也许真的需要“大数据”)

基本工作假设的变化,将重新推动数据库世界从百花齐放的“合久必分”阶段,走向“分久必合”的阶段,从大爆发到大灭绝,大浪淘沙中,新的大一统超融合数据库将会出现,重新统一 OLTP 与 OLAP。而承担重新整合数据库领域这一使命的会是谁?


吞食天地的 PostgreSQL

数据库领域有许多“细分领域”:时序数据库,地理空间数据库,文档数据库,搜索数据库,图数据库,向量数据库,消息队列,对象数据库。而 PostgreSQL 在任何一个领域都不会缺席。

一个 PostGIS 插件,成为了地理空间事实标准;一个 TimescaleDB 扩展,让一堆“通用”时序数据库尴尬的说不出话来;一个向量扩展 PGVector 插件,更是让整个**专用向量数据库细分领域** 变成笑话。

同样的事情已经发生过很多次,而现在,我们将在拆分最早,地盘最大的一个子领域 OLAP 分析中再次见证这一点。但 PostgreSQL 要替代的可不仅仅是 OLAP 数仓,它的野望是整个数据库世界!

pigsty-ecosystem.png

然 PostgreSQL 有何德何能,可当此大任?诚然 PostgreSQL 先进,但 Oracle 也先进;PostgreSQL 开源,但 MySQL 也开源。PostgreSQL 先进且开源,这是它与 Oracle / MySQL 竞争的底气,但要说其独一无二的特点,那还得是它的极致可扩展性,与繁荣的扩展生态

pg-survey.png

用户选择 PostgreSQL 的原因:开源,先进,扩展

PostgreSQL 并不是一个简单的关系型数据库,而是一个数据管理的抽象框架,具有囊括一切,吞噬整个数据库世界的力量。而它的核心竞争力(除了开源与先进)来自可扩展性,即基础设施的可复用性与扩展插件的可组合性


PostgreSQL 允许用户开发功能模块,复用数据库公共基础设施,以最低的成本交付功能。例如,仅有两千行代码的向量数据库扩展 pgvector 与百万行代码的 PostgreSQL 在复杂度上相比可以说微不足道,但正是这“微不足道”的扩展,实现了完整的向量数据类型与索引能力,干翻了几乎所有专用向量数据库。

为什么?因为 PGVECTOR 作者不需要操心数据库的通用额外复杂度:事务 ACID,故障恢复,备份PITR,高可用,访问控制,监控,部署,三方生态工具,客户端驱动这些需要成百上千万行代码才能解决好的问题,只需要关注自己所需问题的本质复杂度即可。

vector-survey.jpg

向量数据库哪家强?

再比如,ElasticSearch 基于 Lucene 搜索库开发,而 Rust 生态有一个改进版的下一代 Tantivy 全文搜索库作为 Lucene 的替代;而 ParadeDB 只需要将其封装对接到 PostgreSQL 的接口上,即可提供比肩 ElasticSearch 的搜索服务。更重要的是,它可以站在 PostgreSQL 巨人的肩膀上,借用 PG 生态的全部合力(例如,与 PG Vector 做混合检索),不讲武德地用数据库全能王的力量,去与一个专用数据库单品来对比。


可扩展性带来的另一点巨大优势是扩展的可组合性,让不同扩展相互合作,产生出 1+1 » 2 的协同效应。例如,TimescaleDB 可以与 PostGIS 组合使用,提供时空数据支持;再比如,提供全文检索能力的 BM25 扩展可以和提供语义模糊检索的 PGVector 扩展组合使用,提供混合检索能力。

再比如,分布式扩展 Citus 可以将单机主从数据库集群,原地升级改造为透明水平分片的分布式数据库集群。而这个能力是可以与其他功能正交组合的,因此,PostGIS 可以成为分布式地理数据库,PGVector 可以成为分布式向量数据库,ParadeDB 可以成为分布式全文搜索数据库,诸如此类。


更强大的地方在于,扩展插件是独立演进的,不需要繁琐的主干合并,联调协作。因此可以 Scale —— PG 的可扩展性允许无数个团队并行探索数据库前研发展方向,而扩展全部都是的可选的,不会影响主干核心能力的稳定性。那些非常强大成熟的特性,则有机会以稳定的形态进入主干中。

通过极致可扩展性的魔法,PostgreSQL 做到了**守正出奇,实现了主干极致稳定性与功能敏捷性的统一。**扎实的基本盘配上惊人的演进速度,让它成为了数据库世界中的一个异数,改变了数据库世界的游戏规则。


改变游戏规则的玩家

PostgreSQL 的出现,改变了数据库领域的游戏规则:任何试图开发“新数据库内核”的团队,都需要经过这道试炼与考验 —— 相比开源免费、功能齐备的 Postgres,价值点在哪里?

至少到硬件出现革命性突破前,实用的通用数据库新内核都不太可能诞生了,因为任何单一数据库都无法与所有扩展加持下的 PG 在整体实力上相抗衡 —— 包括 Oracle,因为 PG 还有开源免费的必杀技。

而某个细分领域的数据库产品,如果能在单点属性(通常是性能)上相比 PostgreSQL 实现超过一个数量级的优势,那也许还有一个专用数据库的生态位存在。但通常用不了多久,便会有 PostgreSQL 生态的开源替代扩展插件滚滚而来。因为选择开发 PG 扩展,而不是一个完整数据库的团队会在追赶复刻速度上有碾压性优势!

因此,如果按照这样的逻辑展开,PostgreSQL 生态的雪球只会越滚越大,随着优势的积累,不可避免地进入一家独大的状态。在几年的时间内,实现 Linux 内核在服务器操作系统领域的状态。而各种开发者调研报告,数据库流行趋势都在印证着这一点。

pg-is-no1-2.png

StackOverflow 2023 调研结果,PostgreSQL 三项全能王

pg-is-no1-1.png

StackOverflow过去7年的数据库指标走势

在引领潮流的 HackerNews StackOverflow 上,PostgreSQL 早已成为了最受欢迎的数据库。许多新的开源项目都默认使用 PostgreSQL 作为首要,甚至唯一的数据库 —— 例如,给各种数据库做模式管理的 Bytebase。《云时代数据库DevOps:硅谷调研》也提出,许多新一代互联网公司都开始积极拥抱并 All in PostgreSQL。

正如《技术极简主义:一切皆用 Postgres 》所言:简化技术栈、减少组件、加快开发速度、降低风险并提供更多功能特性的方法之一就是 “一切皆用 Postgres”。Postgres 能够取代许多后端技术,包括 MySQL,Kafka、RabbitMQ、ElasticSearch,Mongo和 Redis,至少到数百万用户时都毫无问题。一切皆用 Postgres ,已经不再是少数精英团队的前沿探索,而是成为了一种进入主流视野的最佳实践。


还有什么可以做的?

我们已经不难预见到数据库领域的终局。但我们又能做什么,又应该做什么呢?

PostgreSQL 对于绝大多数场景都已经是一个足够完美的数据库内核了,在这个前提下,数据库内核卡脖子纯属无稽之谈。这些Fork PostgreSQL 和 MySQL 并以内核魔改作为卖点的所谓“数据库”基本没啥出息。

这好比今天我们看 Linux 操作系统内核一样,尽管市面上有这么多的 Linux 操作系统发行版,但大家都选择使用同样的 Linux 内核,吃饱了撑着魔改内核属于没有困难创造困难也要上,会被业界当成山炮看待。

同理,数据库内核本身已经不再是主要矛盾,焦点将会集中到两个方向上 —— 数据库扩展与数据库服务!前者体现为数据库内部的可扩展性, 后者体现为数据库外部的可组合性。而竞争的形式,正如操作系统生态一样 —— 集中于数据库发行版上。对于数据库领域来说,只有那些以扩展和服务作为核心价值主张的发行版,才有最终成功的可能。

做内核的厂商不温不火,MariaDB 作为 MySQL 的亲爹 Fork 甚至都已经濒临退市,而白嫖内核自己做服务与扩展卖 RDS 的 AWS 可以赚的钵满盆翻。投资机构已经出手了许多 PG 生态的扩展插件与服务发行版:Citus,TimescaleDB,Hydra,PostgresML,ParadeDB,FerretDB,StackGres,Aiven,Neon,Supabase,Tembo,PostgresAI,以及我们正在做的 Pigsty 。


PostgreSQL 生态中的一个困境就是,许多扩展插件,生态工具都是独立演进,各自为战的,没有一个整合者能将他们凝聚起来形成合力。例如,提供分析的 Hydra 会打一个包一个 Docker 镜像, PostgresML 也会打自己的包和镜像,各家只发行加装了自己扩展的 Postgres 镜像。而这些朴素的镜像与包也距离 RDS 这样完整的数据库服务相距甚远。

即使是类似于 AWS RDS 这样的服务提供商与生态整合者,在诸多扩展面前也依然力有所不逮,只能提供其中的少数。更多的强力扩展出于各种原因(AGPLv3 协议,多租户租赁带来的安全挑战)而无法使用。从而难以发挥 PostgreSQL 生态扩展的协同增幅作用。

这里列出了一些重要扩展,对比基于最新的 PostgreSQL 16 主干版本进行,截止至 2024-02-28

扩展类目 Pigsty RDS / PGDG 官方仓库 阿里云 RDS AWS RDS PG
加装扩展 自由加装 不允许 不允许
地理空间 PostGIS 3.4.2 PostGIS 3.3.4 / Ganos 6.1 PostGIS 3.4.1
雷达点云 PG PointCloud 1.2.5 Ganos PointCloud 6.1
向量嵌入 PGVector 0.6.1 / Svector 0.5.6 pase 0.0.1 PGVector 0.6
机器学习 PostgresML 2.8.1
时序扩展 TimescaleDB 2.14.2
水平分布式 Citus 12.1
列存扩展 Hydra 1.1.1
全文检索 pg_bm25 0.5.6
图数据库 Apache AGE 1.5.0
GraphQL PG GraphQL 1.5.0
OLAP pg_analytics 0.5.6
消息队列 pgq 3.5.0
DuckDB duckdb_fdw 1.1
模糊分词 zhparser 1.1 / pg_bigm 1.2 zhparser 1.0 / pg_jieba pg_bigm 1.2
CDC抽取 wal2json 2.5.3 wal2json 2.5
膨胀治理 pg_repack 1.5.0 pg_repack 1.4.8 pg_repack 1.5.0

许多关键扩展在RDS中并不可用

扩展是 PostgreSQL 的灵魂,无法自由使用扩展的 Postgres 就像做菜不放盐。只能和 MySQL 放在同一个 RDS 的框子里同台,龙游浅水,虎落平阳。

而这正是我们想要解决的首要问题之一。


知行合一的实践:Pigsty

虽然接触 MySQL 和 MSSQL 要早得多,但我在 2015 年第一次上手 PostgreSQL 时,就相信它会是数据库领域的未来了。快十年过去,我也从 PG 的使用者,管理者,变为了贡献者,开发者。也不断见证着 PG 走向这一目标。

在与形形色色的用户沟通交流中,我早已发现数据库领域的木桶短板不是内核 —— 现有的 PostgreSQL 已经足够好了,而是用好数据库内核本身的能力,这也是 RDS 这样的服务赚的钵满盆翻的原因。

但我希望这样的能力,应该像自由软件运动所倡导的理念那样,像 PostgreSQL 内核本身一样 —— 普及到每一个用户手中,而不是必须向赛博空间上的封建云领主花大价钱租赁。

所以我打造了 Pigsty —— 一个开箱即用的开源 PostgreSQL 数据库发行版,旨在凝聚 PostgreSQL 生态扩展的合力,并把提供优质数据库服务的能力普及到每个用户手中。

Pigsty 是 PostgreSQL in Great STYle 的缩写,意为 PostgreSQL 的全盛状态

我们提出了六点核心价值主张,对应 PostgreSQL 数据库服务中的的六个核心问题:Postgres 的可扩展性基础设施的可靠性图形化的可观测性服务的可用性工具的可维护性,以及扩展模块和三方组件可组合性

Pigsty 六点价值主张的首字母合起来,则为 Pigsty 提供了另外一种缩写解释:

Postgres, Infras, Graphics, Service, Toolbox, Yours.

属于你的图形化 Postgres 基础设施服务工具箱。

可扩展的 PostgreSQL 是这个发行版中最重要的价值主张。在刚刚发布的 Pigsty v2.6 中,我们整合了上面提到的 DuckdbFDW 与 ParadeDB 扩展,这两个插件让 PostgreSQL 的分析能力得到史诗级增强,而我们确保每个用户都能轻松用得上这样的能力。

pg-eat-regards.png

来自 ParadeDB 创始人与 DuckdbFDW 作者的感谢致意

我们希望整合 PostgreSQL 生态里的各种力量,并将其凝聚在一起形成合力,打造一个数据库世界中的 Ubuntu 发行版。而我相信,内核之争早已尘埃落定,而这里才会是数据库世界的未来竞争焦点。

  • PostGIS:提供地理空间数据类型与索引支持,GIS 事实标准 (& pgPointCloud 点云,pgRouting 寻路)
  • TimescaleDB:添加时间序列/持续聚合/分布式/列存储/自动压缩的能力
  • PGVector:添加 AI 向量/嵌入数据类型支持,以及 ivfflat 与 hnsw 向量索引。(& pg_sparse 稀疏向量支持)
  • Citus:将经典的主从PG集群原地改造为水平分片的分布式数据库集群。
  • Hydra:添加列式存储与分析能力,提供比肩 ClickHouse 的强力分析能力。
  • ParadeDB:添加 ElasticSearch 水准的全文搜索能力与混合检索的能力。(& zhparser 中文分词)
  • Apache AGE:图数据库扩展,为 PostgreSQL 添加类 Neo4J 的 OpenCypher 查询支持,
  • PG GraphQL:为 PostgreSQL 添加原生内建的 GraphQL 查询语言支持。
  • DuckDB FDW:允许您通过 PostgreSQL 直接读写强力的嵌入式分析数据库 DuckDB 文件 (& DuckDB CLI 本体)。
  • Supabase:基于 PostgreSQL 的开源的 Firebase 替代,提供完整的应用开发存储解决方案。
  • FerretDB:基于 PostgreSQL 的开源 MongoDB 替代,兼容 MongoDB API / 驱动协议。
  • PostgresML:使用SQL完成经典机器学习算法,调用、部署、训练 AI 模型。

Pigsty 支持的 180+ 扩展列表

开发者朋友们,你们的选择会塑造数据库世界的未来。希望我的这些工作,可以帮助你们更好的用好这世界上最先进的开源数据库内核 —— PostgreSQL。


参考阅读

Pigsty v2.6:PostgreSQL 踢馆 OLAP

技术极简主义:一切皆用Postgres

PG生态新玩家ParadeDB

DBA会被云淘汰吗?

令人惊叹的PostgreSQL可伸缩性

中国对PostgreSQL的贡献约等于零吗?

展望PostgreSQL的2024 (Jonathan Katz)

2023年度数据库:PostgreSQL (DB-Engine)

MySQL的正确性为何如此拉垮?

向量数据库凉了吗?

重新拿回计算机硬件的红利

数据库真被卡脖子了吗?

PG查询优化:观宏之道

FerretDB:假扮成MongoDB的PostgreSQL

如何用 pg_filedump 抢救数据?

PGSQL x Pigsty: 数据库全能王来了

Pigsty 特性与快速上手

PG先写脏页还是先写WAL?

PostgreSQL:世界上最成功的数据库

ISD数据集:分析全球120年气候变化

AI大模型与向量数据库 PGVECTOR

更好的开源RDS替代:Pigsty

PostgreSQL 到底有多强?

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

PG与Pigsty用户需求问卷调研结果

高可用PgSQL集群架构设计与落地

为什么说PostgreSQL前途无量?

Postgres本地化排序规则

PG复制标识详解(Replica Identity)

利用监控系统诊断PG慢查询

数据库集群管理概念与实体命名规范

PostgreSQL的KPI

PostgreSQL监控系统Pigsty概述

故障档案:PG安装扩展导致无法连接

PostgreSQL中的表锁

把PG放入Docker是一个好主意吗?

PostgreSQL监控系统概览

pg_dump导致的血案

PostgreSQL数据页面损坏修复

PostgreSQL关系膨胀:原理,监控与处理

探探PostgreSQL开发规约

并发异常那些事

PG好处都有啥?

IP归属地查询的高效实现

PostGIS高效解决行政区划归属查询问题

技术极简主义:一切皆用Postgres

微信公众号原文链接

本文由 Stephan Schmidt @ KingOfCoders 发表于 Hacker News 并引发热议[1]:使用 Postgres 替代 Kafka、RabbitMQ、ElasticSearch、Mongo 和 Redis 是一种切实可行的方式,这样做可以极大降低系统复杂度,并将敏捷性发挥到极致。


如何简化复杂度并快速前进:用 PostgreSQL 完成所有任务

欢迎,HN(Hacker News)读者们。技术是关于取舍的艺术。全面使用 PostgreSQL 完成所有工作,也是一种策略与权衡。显然,我们应根据需求选用合适的工具。很多情况下,这个工具就是 Postgres

在辅助许多初创企业的过程中,我观察到很多人过度复杂化他们的系统,这样做的公司远超过那些选择了过于简单工具的公司。如果你们拥有超过一百万用户,超过五十名开发者,并且你们确实需要 Kafka、Spark 和 Kubernetes,那么请便。如果你的系统数量比开发者还多,只用 Postgres 就是一个明智之选。

附言:全面使用 Postgres 并不意味着单台器搞定一切 ;-)


简单来说,一切皆可用 Postgres 解决

请神容易送神难,让复杂度溜进家里,再送走就没那么容易了。


然而,我们有极致简化的方案


在初创公司中简化技术栈、减少组件、加快开发速度、降低风险并提供更多功能特性的方法之一就是**“一切皆用 Postgres”**。Postgres 能够取代许多后端技术,包括 Kafka、RabbitMQ、ElasticSearch,Mongo和 Redis ,至少到数百万用户时都毫无问题。

使用 Postgres 替代 Redis 作为缓存,使用 UNLOGGED Table[3] 并用 TEXT 类型存储 JSON 数据,并使用存储过程来添加并强制执行过期时间,正如 Redis 所做的那样。

使用 Postgres 作为消息队列,采用 SKIP LOCKED[4] 来代替Kafka(如果你只需要消息队列的能力)。

使用加装了 TimescaleDB[5] 扩展的 Postgres 作为数据仓库。

使用 PostgreSQL 的 JSONB[6] 类型来存储、索引、搜索 JSON 文档,从而替代 MongoDB。

使用加装 pg_cron[7] 扩展的 Postgres 作为定时任务守护程序,在特定时间执行特定任务,例如发送邮件,或向消息队列中添加事件。

使用 Postgres + PostGIS 执行 地理空间查询[8]。

使用 Postgres 进行全文搜索[9],加装 ParadeDB 替代 ElasticSearch。

使用 Postgres 在数据库中生成JSON[10],免去服务器端代码编写,直接供 API 使用。

使用 GraphQL适配器[11],也可以让 PostgreSQL 提供 GraphQL 服务。

我已明言,一切皆可Postgres


关于作者 Stephan

作为一名CTO、临时CTO、CTO教练以及开发者,斯蒂芬在许多快速成长的初创公司的技术部门中都留下了自己的足迹。他在1981年左右,因为想编写视频游戏,就在一家百货公司自学了编程。斯蒂芬在乌尔姆大学(University of Ulm)学习计算机科学,专攻分布式系统和人工智能,并且还学习了哲学。90年代互联网进入德国时,他作为几家初创公司的首位编程员工。他创办过一家获风险资本投资的初创公司,在其他获得风险资本投资的快速成长的初创公司中负责架构、流程和成长挑战,曾在ImmoScout担任管理职位,并且是一家eBay Inc.公司的CTO。在他的妻子成功出售了她的初创公司后,他们搬到了海边,斯蒂芬开始从事CTO辅导工作。你可以在LinkedIn上找到他,或者在Twitter上关注@KingOfCoders。


译者评论

译者:冯若航,创业者与 PostgreSQL 专家,下云倡导者,开源 PG RDS 替代,开箱即用的 PostgreSQL 发行版 —— Pigsty 作者。

使用 Postgres 完成一切工作并不是一种空想,而是一种正在流行起来的最佳实践。对此我感到非常欣慰:早在 2016 年时我便看到了这里的潜力[12]并选择躬身入局,而事情的发展正如所愿。

我曾任职的探探,便是这条道路的先锋 —— PostgreSQL for Everything。这是一个由瑞典创始团队打造的中国互联网 App —— 使用 PostgreSQL 的规模与复杂度在中国首屈一指。探探的技术架构选型参照了 Instagram —— 或者说更为激进,几乎所有业务逻辑都使用 PostgreSQL 存储过程实现(甚至包括 100ms 的推荐算法!)。

探探整个系统架构围绕 PostgreSQL 而设计并展开。几百万日活,几百万全局 DB-TPS,几百 TB数据的量级下,数据组件只用了 PostgreSQL 。直到接近千万日活,才开始进行架构调整引入独立的数仓,消息队列和缓存。在 2017 年,我们甚至没有使用 Redis 缓存,250万 TPS 完全是由一百多台服务器上的 PostgreSQL 直接扛下的。消息队列也是用 PostgreSQL 实现的,早中期的数据分析也是由一套十几TB的专用PG集群负责。我们早已经践行了 —— “一切皆用 PostgreSQL 的理念”,并从中获益良多。

这个故事还有下半段 —— 随后的 “微服务改造” 带来了海量的复杂度,最终让系统陷入泥潭。这让我从另一个角度更加确信这一点 —— 我非常怀念一切皆用 PostgreSQL 时那种简单可靠高效敏捷的状态。


PostgreSQL 并不是一个简单的关系型数据库,而是一个数据管理的抽象框架,具有囊括一切,吞噬整个数据库世界的潜力。在十年前,这仅仅是一种潜力与可能性,在十年后,它已经兑现成为真正的影响力。而我很高兴能见证这个过程,并推动这一进程。

PostgreSQL is for Everything!


参考阅读

PGSQL x Pigsty: 数据库全能王来了

PG生态新玩家ParadeDB

FerretDB:假扮成MongoDB的PostgreSQL

AI大模型与向量数据库 PGVECTOR

PostgreSQL 到底有多强?

PostgreSQL:世界上最成功的数据库

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

为什么说PostgreSQL前途无量?

更好的开源RDS替代:Pigsty

References

PG生态新玩家:ParadeDB

微信公众号原文链接


PG生态新玩家ParadeDB

YC S23 投了一个新项目 ParadeDB, 非常有意思。他们的 Slogan 是 “Postgres for Search & Analytics —— Modern Elasticsearch Alternative built on Postgres”。就是用于搜索和分析的 PostgreSQL,旨在成为 Elasticsearch 的替代。

PostgreSQL 的生态确实越来越繁荣了,在基于 PG 的扩展与衍生中,我们已经有了基于 MongoDB 开源替代 —— FerretDB,SQL Server 开源替代 Babelfish,Firebase 开源替代 Supabase,AirTable 开源替代 NocoDB,现在又多了 ElasticSearch 开源替代 —— ParadeDB。

ParadeDB 实际上是由三个 PostgreSQL 扩展组成:pg_bm25pg_analytics,以及 pg_sparse。这三个扩展都可以独立使用了。我已经将这几个扩展打好包(v0.5.6),并将会在 Pigsty 的下个 Release 中默认收录,让用户能够开箱即用。

我翻译了 ParadeDB 的官网介绍与四篇博客文章,为您介绍这个 PostgreSQL 生态的新星。 今天是第一篇 —— 概览


ParadeDB

我们荣幸地向您介绍 ParadeDB:针对搜索场景优化的 PostgreSQL 数据库。ParadeDB 是第一个旨在成为 Elasticsearch 替代的 Postgres 数据库构建,被设计为可以在PG表上进行闪电般快速的全文检索、语义检索、以及混合检索。

ParadeDB解决什么问题?

对于许多组织而言,搜索依然是一个未解问题 —— 尽管有像 Elasticsearch 这样的巨头存在,但大多数与其打过交道的开发者都知道,运行、调优和管理 Elasticsearch 是多么痛苦的一件事。虽然也有其他的搜索引擎服务,但在现有数据库上粘连对接这些外部服务,会引入更多重建索引和数据复制的复杂难题与成本。

那些追求统一权威数据源与搜索引擎的开发者转了 Postgres,PG 已经通过 tsvector 提供了基本的全文检索能力,也通过 pgvector 提供了向量语义检索能力。这些工具也许对于简单用例和中等大小的数据集来说很好使,但当表变大或查询变得复杂时就有些不够用了:

  1. 大表上的排序和关键词搜索非常缓慢
  2. 不支持 BM25 计算
  3. 没有混合检索支持,将向量搜索与全文搜索的技术
  4. 没有实时搜索 — 数据必须手动重新索引或重新嵌入
  5. 对复杂查询如分面或相关性调优的支持有限

到目前为止,我们已经目睹了许多工程团队用很勉强的方式在 Postgres 上叠加了一套 Elasticsearch,随即因为后者太过于臃肿、昂贵或复杂,而最终放弃。我们在想:如果 Postgres 本身就带有 ElasticSearch 水平的搜索会发生什么?那么开发者就不会有这种两难选择了 —— 统一使用 PostgreSQL 但搜索能力受限,还是使用事实源和搜索引擎两种独立的服务?

ParadeDB适用于谁?

Elasticsearch 拥有广泛的应用场景,但我们并不企图一蹴而就地覆盖所有场景——至少现阶段不是。我们更倾向于专注于一些核心场景 —— 专为那些希望在 PostgreSQL 上进行搜索的用户服务。对于以下情况,ParadeDB 会是您的理想选择:

  • 希望使用单一 Postgres 作为事实来源,厌恶在多个服务之间搬运复制数据。
  • 希望在不损害性能与可伸缩性的前提下,对存储在 Postgres 中的海量文档进行全文搜索。
  • 希望 ANN/相似度搜索与全文搜索相结合,从而获得更精准的语义匹配效果

ParadeDB产品介绍

ParadeDB 是一个完全托管的 Postgres 数据库,具有在任何其他 Postgres 提供者中未发现的索引和搜索 Postgres 表的能力:

特性 描述
BM25全文搜索 支持布尔、模糊、提升和关键字查询的全文搜索。搜索结果使用 BM25 算法打分。
分面搜索 Postgres 列可以定义为分面,以便轻松分桶和收集指标。
混合搜索 搜索结果可以打分,综合考虑语义相关性(向量搜索)与全文相关性( BM25)。
分布式搜索 表可以进行分片,以便进行并行查询加速。
生成式搜索 Postgres 列可以输入到大型语言模型(LLMs)中,用于自动摘要、分类或文本生成。
实时搜索 文本索引和向量列自动与底层数据保持同步。

与 AWS RDS 等托管服务不同,ParadeDB 是一个 PostgreSQL 扩展插件,不需要任何设置,可以与整个 PG 生态集成,并完全可定制。ParadeDB 是开源的(AGPLv3),并提供了一个简单的 Docker Compose 模板以满足需要自建/定制的开发者的需求。

ParadeDB 的构建方式

ParadeDB 的核心是一个带有自定义扩展的标准 Postgres 数据库,这些扩展使用 Rust 编写,引入了增强的搜索能力。

ParadeDB 的搜索引擎基于 Tantivy 构建,Tantivy 是受 Apache Lucene 启发的开源 Rust 搜索库。其索引作为原生的 PG 索引存储在PG中,从而避免了繁琐的数据复制/ETL工作,并同时可以确保事务 ACID。

ParadeDB 为 Postgres 生态提供了一个新扩展:pg_bm25pg_bm25 使用 BM25 评分算法在 Postgres 中实现了基于 Rust 的全文搜索。ParadeDB 会预装这个扩展插件。

下一步是什么?

ParadeDB 的托管云版本目前处于 PrivateBeta 阶段。我们的目标是在 2024 年初推出一个自助服务的云平台。如果你想在此期间访问 PrivateBeta 版本,欢迎加入我们的等待名单

我们核心团队的重点是开发 ParadeDB 的开源版本,将在 2023 年冬季推出。

我们 Build in Public,并很高兴能与整个社区分享 ParadeDB。欢迎关注我们,在未来的博文中我们会进一步详细介绍 ParadeDB 背后的有趣技术挑战。

令人惊叹的PostgreSQL可伸缩性

原文:Cloudflare是如何用15个PG集群支持55M QPS的 | 微信公众号原文

本文概述了 Cloudflare 是如何利用 15 个 PostgreSQL 集群,伸缩到支持每秒 5500 万个请求。

2009年7月,美国加州,一个创业团队搞了一个名为 Cloudflare 的内容分发网络(CDN),用于加速互请求,让网络访问更稳定且更快捷。他们在发展初期面临着各种挑战,然而其增长速度却十分惊人。

Overall Internet Traffic; PostgreSQL Scalability

互联网流量全局概览

现在他们承载着 20% 的互联网流量,每秒 5500 万个 HTTP 请求。 而他们仅仅使用 15 个 PostgreSQL 集群就做到了这一点。

Cloudflare 使用 PostgreSQL 来存储服务元数据,并处理 OLTP 工作负载。然而在同一个集群支持有着多种不同负载类型的租户是一个难题。一个**集群(Cluster)是一组数据库服务器,一个租户(tenant)**是特定用户或用户组专用的隔离数据空间。

PostgreSQL的可伸缩性

以下是他们如何将 PostgreSQL 的可伸缩性用到极致的。

1. 争用

大多数客户端都会相互争用 Postgres 连接。但是 Postgres 连接的成本很高,因为每个连接都是操作系统级别的独立进程。而且每个租户都有独特的工作负载类型,所以很难创建一个全局阈值进行限流。

而且,人工限制行为不端的租户是一项巨大的工作。某个租户可能会发起一个开销巨大的查询,因而阻塞邻居租户的查询饿着他们。同时,一旦查询到达数据库服务器这儿,再想隔离它就很难了。

Connection Pooling With PgBouncer

使用 Pgbouncer 进行连接池化

因此他们使用 Pgbouncer 作为 Postgres 前面的连接池。PgBouncer 将充当 TCP 代理,池化 Postgres 连接。租户连接到 PgBouncer ,而不是直连 Postgres。因而限制了 Postgres 连接的数量,也能防止连接饥饿现象。

此外,PgBouncer 还通过使用持久连接来规避了创建和销毁数据库连接的高昂开销,也被用于在运行时限流那些发起高开销查询的租户们。

2. 惊群

当许多客户端同时查询服务器时就会出现**惊群(Thundering Herd)**的问题,这会导致数据库性能降级。

Thundering Herd Problem

惊群

当应用程序被重新部署时,其状态会初始化,应用会一次性创建许多条数据库连接。因而当当租户争抢 Postgres 连接时,就会引起惊群现象,Cloudflare 使用 PgBouncer 来限制特定租户创建的 Postgres 连接数。

3. 性能

Cloudflare 没有在云上运行 PostgreSQL ,而是使用没有任何虚拟化开销的裸金属物理机,以实现最好的性能。

Load Balancing Traffic Between Database Instances

在数据库实例之间对流量做负载均衡

Cloudflare 使用 HAProxy 作为四层负载均衡,Pgbouncer 将查询转发至 HAProxy,而 HAProxy 负载均衡器会在集群主实例与只读副本之间对流量进行负载均衡。

4. 并发

如果有许多租户发起并发(Concurrent)查询,性能会下降。

Congestion Avoidance Algorithm Throttling Tenants

拥塞控制限流算法

因而 Cloudflare 使用 TCP Vegas 拥塞控制算法 来对租户限流。这个算法的工作原理是,首先采样每个租户的事务往返 Postgres 的响应时间(RTT),然后只要 RTT 不降级就持续调整连接池大小,因而在出现资源枯竭前就能实现限流。

5. 排队

Cloudflare 在 PgBouncer 层面使用队列对查询进行排队。查询在队列中的顺序取决于它们的历史资源使用情况,换句话说,需要更多资源的查询会排在队列的尾部。

Ordering Queries in Priority Queue

使用优先队列排序查询

Cloudflare 只在流量峰时刻启用优先队列以防资源饥饿。换言之在正常流量中,查询不会永远排在队尾。

这种方法改善了绝大多数查询的延迟(Latency),不过在流量峰时发起大开销查询的租户会观察到更高的延迟。

6. 高可用

Cloudflare 使用 Stolon 集群管控负责 Postgres 的高可用.

High Availability of Data Layer With Stolon

使用 Stolon 负责数据库高可用

Stolon 可用于搭建 Postgres 主从复制,并在出现问题时负责选举 Postgres 集群领导者(主库)并进行故障切换。

这里的每个数据库集群都会复制到两个区域,每个区域内有三个实例。

写请求会被路由到主要区域中的主库上,然后异步复制到次要区域,读请求会路由到次要区域中处理。

Cloudflare 会进行组件间连通性测试以便主动发现网络分区问题,也会进行混沌测试以优化系统韧性,还会配置冗余的网络交换机于路由器来避免网络分区。

当故障切换结束,主库实例重新上线时,他们会使用 pg_rewind 工具重放错过的写入变更,来让旧主库重新与集群同步。

Cloudflare 的 Postgres 主库实例与从库实例加起来超过 100 台。他们组合使用了 操作系统资源管理,排队理论,拥塞控制算法,甚至是 PostgreSQL 统计量来实现 PostgreSQL 的可伸缩性。

评价与讨论

这是一篇有价值的经验分享,主要介绍了如何使用 Pgbouncer 以解决 PostgreSQL 的可伸缩性(Scalability)问题。五千万 QPS + 20% 的互联网流量,听上去是不小的一个规模。尽管从 PostgreSQL 专家的角度看这里的实践确实写的有些朴素简陋,但是这篇文章确实抛出来了一个有意义的问题 —— PostgreSQL的 可伸缩性

PostgreSQL 的可伸缩性现状

PostgreSQL 在垂直伸缩和水平伸缩能力上享有盛誉。在读请求上,PostgreSQL 没有什么伸缩性问题 —— 因为读写互不阻塞,所以只读查询的吞吐量上限几乎是随投入的资源(CPU)线性增长的,无论是垂直增加 CPU/内存还是水平扩容拖从库,都可以通过加资源解决。

PostgreSQL 在写入上的伸缩性没有读上那么强,单机 WAL 写入/重放速度达到 100 MB/s ~ 300 MB/s 就会遇到软件瓶颈 —— 但对于常规生产 OLTP 负载这已经是一个很大的值了 —— 作为参考,探探这样一个两亿用户千万日活的应用,所有数据库写入的结构化数据率就在 120 MB/s 左右。PostgreSQL 社区也正在讨论通过 DIO/AIO 以及并行WAL重放的方式来进一步拓展此瓶颈。用户也可以考虑使用 Citus 或者其他分库分表中间件实现写入的伸缩扩容。

在容量上,PostgreSQL 的可伸缩性主要取决于磁盘,本身并没有瓶颈。在 NVMe SSD 单卡64TB的当下,配合压缩卡支持百TB级别的数据容量毫无问题,更大的容量也可以使用 RAID 或使用多个表空间的方式进行支持。社区曾经报告不少百TB量级的OLTP实例,也有零星 PB 级的实例。大实例的挑战主要是备份管理与空间维护上的,而不是性能上的。

在过去,PostgreSQL 可伸缩性比较为人诟病的一个问题,就是对海量连接的支持 (在 PostgreSQL 14 后得到显著改善)。PostgreSQL 和 Oracle 默认的模型一样都使用了多进程架构。这种设计有着更好的可靠性,但在面对海量高并发场景时,这种模型就有些拖后腿了。

互联网场景下数据库访问模式主要是海量短连接:一个查询过来就创建一条连接,执行完后就销毁连接 —— PHP 以前就是这么干的,所以和使用线程模型的搭档 MySQL 很配。但对于 PostgreSQL 而言,海量的后端进程与频繁的进程创建销毁会浪费大量的软硬件资源,因而在这种场景的性能表现上就些力不从心了。

连接池 —— 解决高并发问题

PostgreSQL 推荐默认使用的连接数量约为 CPU 核数的两倍,通常在几十 ~ 几百的范围内会比较合适。互联网场景下动辄以千/以万计的客户端连接如果直连 PostgreSQL,就会产生显著的额外负担。连接池便是为了解决这个问题而出现的 —— 可以说,连接池对于在互联网场景下使用 PostgreSQL 是一个必选项,能够起到化腐朽为神奇的效果。

请注意,PostgreSQL 并非不支持高吞吐,问题的关键在于并发连接的数量 —— 在《PG性能有多强》中,我们在 92 vCPU 的服务器上使用 约 96 条连接压测出 sysbench 点查吞吐量峰值 233 万。而在超出可用资源后,这一最大吞吐随着并发进一步加大而开始缓慢下降。

使用连接池有一些显著的好处:首先,数万条客户端连接,可以池化缓冲收敛为几条活跃 Server 连接(使用事务级连接池),极大减少了操作系统上的进程数量与开销,也避免了进程创建销毁的开销。第二点,并发争用的情况因为活跃连接数的减少而大大减小,进一步优化了性能。第三点,突然出现的负载峰值会在连接池上排队,而不是直接打爆数据库,降低了雪崩概率,从而提高了系统的稳定性。

性能与瓶颈

我在探探时有很多关于 PgBouncer 的最佳实践,我们有一套核心数据库集群,整个集群有着 50万 QPS,主库上的客户端连接数为两万,写入 TPS 约为 5 万。这样的负载如果直接打到 Postgres 上会立即打爆数据库。因此在应用与数据库之间,还有一个 PgBouncer 连接池中间件。所有两万条客户端连接经过连接池事务池化模式后,总共只需要 5 ~ 8 条活跃服务器连接就支撑起所有的请求,CPU 使用率约为 20%,这是一个非常巨大的性能改善。

PgBouncer 是一个轻量级连接池,可以部署在用户侧或者数据库侧。PgBouncer 本身因为使用了单进程模式,存在一个 QPS / TPS 瓶颈,约为 3 ~ 5 万。因此为了避免 PgBouncer 本身的单点问题与瓶颈,在核心主库上我们使用了 4 个幂等的 PgBouncer 实例,并通过 HAProxy 均匀分发流量给这四个 PgBouncer 连接池池化后,再到数据库主库上处理。但是对于绝大多数场景而言,单个 PgBouncer 进程的 3万 QPS 的处理能力已经是绰绰有余了。

管理灵活性

PgBouncer 的一个巨大优势是,它可以提供 User / Database / Instance 级别的查询响应时间指标(RT)。这是用于性能衡量的核心指标,对于早些年的 PostgreSQL 老版本,PgBouncer 中的统计值也是获取这类数据的唯一方式。尽管用户可以通过 pg_stat_statements 扩展获取查询组的 RT, PostgreSQL 14 以后也可以获取数据库级别的会话活跃时间来计算事务 RT,新出现的 eBPF 也可以完成这一点。但 PgBouncer 提供的性能监控数据对于数据库管理仍然是非常重要的参考依据。

PgBouncer 连接池不仅提供了性能上的改善,还为精细管理提供了抓手。例如在数据库在线不停机迁移中,如果在线流量完全通过连接池访问,那么你就可以通过简单修改 PgBouncer 配置文件的方式,将旧集群的读写流量丝滑重定向到新集群中,甚至都不需要业务方即时参与改配置重启服务。你也可以像上面 Cloudflare 的例子一样,在连接池修改 Database / User 的参数,实现限流的能力。如果某一个数据库租户表现不良,影响了整个共享集群,管理员可以在 PgBouncer 上轻松实现限流与阻断的能力。

其他替代品

PostgreSQL 生态中还有其他的一些连接池产品。与 PgBouncer 同期的 PGPool-II 也曾经是一个有力竞争者:它提供了更为强大的负载均衡/读写分离等能力,也能充分利用多核的能力,但是对 PostgreSQL 数据库本身有侵入性 —— 需要安装扩展才能用,而且曾经有比较显著的性能折损(30%)。所以在连接池大PK中,简单轻量的 PgBouncer 成为了胜利者,占据了PG连接池的主流生态位。

除了 PgBouncer 之外,新的 PostgreSQL 连接池项目也在不断出现,比如 Odyssey,pgcat,pgagroal,ZQPool 等。我非常期待能有一个完全兼容 PgBouncer 的高性能/更易用原位替代出现。

此外,许多编程语言标准库的数据库驱动里,都开始内置了连接池,加上 PostgreSQL 14 的改进让多个进程的开销减少。以及硬件性能的指数增长(现在都有 512 vCPU 的服务器了,内存也不是啥稀缺资源了)。所以有时候不用连接池,几千个连接直接干上去也是一个可行选项了。

我能用上 Cloudflare 的实践吗?

随着硬件性能的不断提升,软件架构的不断优化,管理最佳实践的逐渐普及 —— 高可用、高并发、高性能(可伸缩性)对于互联网公司来说属于老生常谈,基本不算什么新鲜技术了。

例如在当下,随便一个初级 DBA / 运维,只要使用 Pigsty 部署一套 PostgreSQL 集群都可以轻松做到这一点,包括 Cloudflare 提到的 Pgbouncer 连接池,以及高可用组件 Stolon 的上位替代 Patroni ,都已经做到开箱即用了。只要硬件达标,轻松处理好海量并发百万请求不是梦。

在本世纪初,一台 Apache 服务器只能处理很可怜的一两百个并发请求。最优秀的软件也很难处理上万的并发 —— 业界有个著名的 C10K 高并发 问题,谁要是能做到几千并发,那就是业界高手。但随着 Epoll 和 Nginx 在 2003/2004 年相继问世,“高并发” 不再是什么难题了 —— 随便一个小白只要学会配置 Nginx,就可以达到前几年大师们做梦都不敢想的程度 —— 瑞典马工《云厂商眼中的客户:又穷又闲又缺爱

这就跟现在随便哪个新手都可以拿 Nginx 实现以前用 httpd 的大师们想都不敢想的 Web 海量请求与高并发一样。PostgreSQL 的可伸缩性也随着 PgBouncer 的普及走入千家万户。

例如,在 Pigsty 中,默认为所有 PostgreSQL 1:1 部署了 PgBouncer 实例,使用事务池化模式,并纳入监控。而默认的 Primary 与 Replica 服务也是通过 PgBouncer 访问 Postgres 数据库的。用户不需要操心太多与 PgBouncer 有关的细节 —— 例如, PgBouncer 的数据库与用户是在通过剧本创建 Postgres 数据库/用户时自动维护的。一些常见的配置注意事项和坑也在预置配置模板中进行了规避,力求做到开箱即用。

当然,对于非互联网场景的应用,PgBouncer 也并非必须品。而且默认的 Transaction Pooling 虽然在性能上非常优秀,但也是以牺牲了一些会话级功能为代价的。所以您也完全可以配置 Primary / Replica 服务直连 Postgres,绕过 PgBouncer;或者使用兼容性最好的 Session Pooling 模式。

总的来说,PgBouncer 确实是一个非常实用的 PostgreSQL 生态工具。如果您的系统对于 PostgreSQL 客户端并发连接数有着较高要求,那么在测试性能时请务必试一试这款中间件。

展望 PostgreSQL 的2024

微信公众号原文

本文是 PostgreSQL 核心组成员 Jonathan Katz 对 2024 年 PostgreSQL 项目的未来展望,并回顾过去几年 PostgreSQL 所取得的进展。

作者:Jonathan Kats,Amazon RDS 首席产品经理兼技术主管, PostgreSQL 全球开发组核心成员与主要贡献者。博客:https://jkatz05.com/。

译者:冯若航 / Vonng。磐吉云数创始人 / CEO,PostgreSQL 专家与布道师,开源 RDS PG —— Pigsty 作者。博客:https://vonng.com

点击“查看原文”查看英文原文:https://jkatz05.com/post/postgres/postgresql-2024/

在我经常听到的问题中,有一个尤为深刻:“PostgreSQL 将走向何方?” —— 这也是我经常问自己的一个问题。这个问题不仅仅局限在数据库内核引擎的技术层面,而关乎整个社区的方方面面 —— 包括相关的开源项目、活动和社区发展。PostgreSQL 已经广受欢迎,并且已经是第四次被 DB Engine评为“年度数据库。尽管已取得显著成功,我们依然需要不时地后退一步,从更宏观的角度思考 PostgreSQL 的未来。虽然这种思考不会立即带来显著的变化,但它对于社区正在进行的工作提供了重要的背景板。

新年是思考 **“PostgreSQL 的未来”**这一问题的绝佳时机,我对2024年的PostgreSQL发展方向也有一些思考,这里是我的一些想法:这并不是一个路线图,而是我个人对 PostgreSQL 发展方向的一些想法。


PostgreSQL功能开发

PGCon 2023 开发者会议上,我提出了一个题为“PostgreSQL 用户面临的重大挑战是什么?”的话题。这个话题旨在探讨用户的常见需求和数据库工作负载的发展趋势,以此来判断我们是否正在朝着正确的方向发展 PostgreSQL。通过多次交谈和观察,我提出了三个主要的特性类目:

  • 可用性
  • 性能
  • 面向开发者的特性

这些特性组将成为 2024 年,甚至更长时间段里的工作重点。接下来,我将对每个特性类目进行更深入的探讨。

可用性

对于PostgreSQL现有用户和潜在用户来说,提高可用性是最迫切的需求。这个需求不仅仅是排在第一位,而且毫不夸张地讲,也同时能排在第二位和第三位。虽然重启 PostgreSQL 通常可以迅速完成,但在某些极端情况下,这个过程可能耗时过长。此外,长时间的写入阻塞,例如某些锁操作,也可被视作一种“停机时间”。

大部分 PostgreSQL 用户对现有的可用性水平已感满意,但有些工作负载对可用性的要求极为严格。为了更好地满足这些要求,我们需要进行额外的开发工作。这篇文章或这一小节就聚焦于这一点:通过改进使 PostgreSQL 适用于更多有严苛可用性需求的环境。

逻辑复制是如何助益于双主,蓝绿部署,零停机升级,以及其他工作流的

对于现有的 PostgreSQL 用户,以及那些计划迁移至 PostgreSQL 的用户来说,提升可用性是最重要的需求。这通常指的是高可用——即在计划内的更新或计划外的中断期间,数据库能够持续进行读写操作的能力。PostgreSQL 已经提供了许多支持高可用的特性,如流复制。然而为了实现最高水平的可用性,通常还需要借助额外的服务或诸如 Patroni 这样的工具。

我聊过许多用户,在绝大多数情况下,他们对 PostgreSQL 提供的可用性是满意的。但我也发现了一个新趋势:现在有一些负载对可用性的要求越来越高,15-30 秒的离线窗口已不够了。这包括计划内的中断(如小版本升级、大版本升级),以及计划外的中断。一些用户表示,他们的系统最多只能承受1秒的不可用时间。起初我对这种要求持怀疑态度,但了解到这些工作负载的具体用途后,我认为1秒确实是一个合理的需求。

在持续提高 PostgreSQL 可用性方面,逻辑复制 是一个关键特性。逻辑复制能够实时将 PostgreSQL 数据库中的变更流式传输到任何支持 PostgreSQL 逻辑复制协议的系统中。PostgreSQL 中的逻辑复制已经存在了一段时间,而最近的版本在可用性方面带来了显著的改进,包括功能和性能上的新特性。

逻辑复制在 PostgreSQL 的大版本升级过程中扮演着关键角色,与传统的物理(或二进制)复制相比,它的一大优势在于能够实现跨版本的数据流转。举例来说,通过逻辑复制,我们可以轻松地将 PostgreSQL 15 的数据变更实时传输至 PostgreSQL 16,从而大幅缩减升级过程中的停机时间。这种方法已在 Instacart 的零停机大版本升级中得到成功应用。然而,PostgreSQL 在支持此类用例和其他高可用性场景方面仍有待提升。未来的发展预计将进一步优化支持蓝绿部署的功能,以实现更加无缝的数据迁移和应用升级。

除了在大版本升级中的用例,逻辑复制本身也是构建高可用系统的重要手段。"多主复制“就是其中的一个典型应用,它允许多个数据库实例同时接受写入操作,并在它们之间同步数据变更。这种模式尤其适用于对停机时间敏感的系统(例如:不接受1秒以上的不可用时间),其设计目标是在任何写入数据库出现问题时,应用能迅速切换到另一可用的写入数据库,而不必等待它被提升为新主库。构建与管理这样的双活系统是极度复杂的:它会影响到应用设计,并需要用户提供对写入冲突进行管理的策略,而且为了确保数据完整性(比如:冲突风暴),需要有仔细设计的容错监控系统 —— (比如,一个实例如果几个小时都无法复制它的变更会发生什么?)

大版本升级和双活复制案例为我们指明了改善 PostgreSQL 逻辑复制的方向。Amit Kapila 是众多逻辑复制功能开发的领导者。今年,他和我共同在一场会议上发表了题为“PostgreSQL 中的多主复制之旅”的演讲(并提供了视频版本),深入探讨了为何针对这些用例的解决方案至关重要、PostgreSQL 在逻辑复制方面取得的成就,以及为更好支持这些场景所需做的工作。好消息是从 PostgreSQL 16 版本起,我们已经有了大部分基础模块来支持双活复制、蓝绿部署和零停机大版本升级。虽然这些功能可能没有全部集成在内核中,但某些扩展(比如我参与开发的pgactive)已提供了这些能力。

在 2024 年,有多项努力旨在帮助缩小这些功能差距。对于 PostgreSQL 17 来说(惯例免责声明:这些特性可能不会发布),有一个重点是确保逻辑复制能够与关键工作流(如pg_upgrade高可用系统)协同工作,支持更多类型的数据变更(如序列/Sequence)的复制,扩展对更多命令(如 DDL)的支持,提高性能,以及增加简化逻辑复制管理的特性(如节点同步/再同步)。

这些努力能让 PostgreSQL 适用于更多种类的负载,特别是那些有着极致严苛可用性要求的场景,并简化用户在生产环境中滚动发布新变更的方式。尽管改进逻辑复制功能的道路仍然漫长,但 2024 年无疑将为 PostgreSQL 带来更多强大的功能特性,帮助用户在关键环境中更加高效地运行 PostgreSQL。

减少锁定

另一个有关可用性的领域是模式维护操作(即DDL语句)。例如,ALTER TABLE的大部分形式会对表施加 ACCESS EXCLUSIVE 锁,从而阻止对该表的所有并发访问。对于许多用户来说这等同于不可用,即使这只是数据的一个子集。PostgreSQL 缺乏对非阻塞/在线模式维护操作的完整支持,随着其他关系数据库也开始支持这些功能,这方面的不足开始逐渐凸显。

目前虽有多种工具和扩展支持非阻塞模式更新,但如果 PostgreSQL 能原生支持更广泛的非阻塞模式变更,那肯定更方便,而且性能也会更好。从设计上来看,我们已有了开发此功能的基础,但还需要一些时间来实现。尽管我不确定是否有正在进行中的具体实现,但我相信在2024年我们应该在这方面取得更多进展:让用户能够在不阻塞写入的情况下执行大部分(或全部)DDL 命令

性能

性能是一个不断持续演进的特性 —— 我们总是会追求更快的速度。好消息是,PostgreSQL 在垂直扩展能力上享有盛誉 —— 当你为单个实例提供更多硬件资源时,PostgreSQL 也能扩展自如。虽然在某些场景下,水平扩展读写操作是有意义的。但我们还是要确保 PostgreSQL 能够随着计算和内存资源的增加而持续扩展。

举个更具体的例子:考虑到 AWS EC2 实例中有着高达 448 vCPU / 24TB 内存 的选配项 —— PostgreSQL 能否在单个实例上充分利用这些资源呢?我们可以根据 PostgreSQL 用户现在与未来可能使用的硬件配置,设定一个性能提升的目标,并持续提升 PostgreSQL 的整体表现。

在 2024 年,已经有多项工作致力于继续垂直扩展 PostgreSQL。其中最大的努力之一,也是一个持续多年的项目,就是在 PostgreSQL 中支持 DirectIO(DIO)与 Asynchronous IO(AIO)。至于细节我就留给 Andres Freund 在PGConf.EU上关于在 PostgreSQL 中添加 AIO 的现状的PPT来讲了。看起来在 2024 年,我们将离完全支持 AIO 更进一步。

另一项让我感兴趣的工作是并行恢复。有着大量写入负载的 PostgreSQL 用户往往会推迟 Checkpoint 以减少 I/O 负载。对于忙碌的系统而言,如果 PostgreSQL 在执行 Checkpoint 的相当一段时间后才崩溃,那么当 PostgreSQL 重新启动时,它会进入 “崩溃恢复 “状态:它会重新执行自上次 Checkpoint 以来的所有变更,以便达到一致的状态 —— 在崩溃恢复期间,PostgreSQL 不能读也不能写,这意味着它不可用。这对繁忙的核心系统来说是个问题:虽然 PostgreSQL 可以接受并发写入,但它重放变更时只能使用单个进程。如果一个繁忙系统崩溃于上个检查点后的一小时,那么系统会需要离线追赶几个小时,才能达到一致的状态点重新上线!

克服这一局限性的方法之一是支持”并行恢复",或者说能够并行重放WAL变更。在PGCon 2023上,Koichi Suzuki做了一个 关于PostgreSQL如何支持并行恢复 的详细介绍。这不仅适用于崩溃恢复,也适用于任何 PostgreSQL WAL 重放操作(例如:PITR 时间点恢复)。虽然这是一个极具挑战性的问题,但支持并行恢复有助于 PostgreSQL 继续垂直扩展,因为用户可以进一步针对重度写入负载进行优化,也能缓解 “从故障中恢复上线所需的延时超出承受范围” 的风险。

这并不是一份关于性能特性的详细清单。在 PostgreSQL 服务器性能上还有很多工作要做,包括索引优化、改进锁机制、充分利用硬件加速等。此外,客户端(如驱动程序和连接池)上的工作也能为应用与 PostgreSQL 的交互带来额外的性能提升。展望 2024 年,看看社区正在进行的工作,我相信 PostgreSQL 在各个领域上的性能都会有整体性提升。

开发者特性

我认为 “开发者特性 “(developer features)是一个相当宽泛的类目,核心在于如何让用户围绕 PostgreSQL 来架构 & 构建应用。这里包括:SQL语法、函数、存储过程语言支持,以及帮助用户从其他数据库系统迁移到 PostgreSQL 的功能。一个具体的创新例子是在 PostgreSQL 14 中引入的 multirange 数据类型,它允许用户将一些不连续的 范围(Range) 聚合在一起,这个特性非常实用,我个人在实现一个调度功能时,用它将数百行PL/pgSQL代码减少到三行。开发者特性也关乎 PostgreSQL 如何支持新出现的工作负载:例如JSON 或向量

值得一提的是,许多开发者特性创新主要出现在**扩展(Extension)**上,而这正是 PostgreSQL 可扩展模型的优势所在。然而就数据库服务器本身而言,PostgreSQL 在某些开发者特性上的发布速度相比过去有所落后。例如,尽管PostgreSQL是第一个将JSON作为可查询数据类型的关系数据库,但它在实现 SQL/JSON 标准锁定义的语法与特性上已经开始变得迟缓。PostreSQL 16 发布了 SQL/JSON 中的一些语法特性,2024 年也会有更多的努力用在实现 SQL/JSON 标准上。

话既然说到这儿了,我们应当着力于 PostgreSQL 中那些无法通过扩展插件实现的开发者特性,比如 SQL标准特性。我的建议是集中精力关注那些其他数据库已经具备的功能,比如进一步实现 SQL/JSON 标准(例如: JSON_TABLE)、系统层面的版本化表(对于审计、闪回,与在特定时间点进行的时态查询非常有用),以及对模块的支持(对于“打包”存储过程来说尤其重要)。

此外,考虑到之前讨论的可用性和性能问题,我们应继续努力简化用户从其他数据库迁移到 PostgreSQL 的过程。在我的日常工作中,我有机会了解了大量与数据库迁移相关的内容:从商业数据库到 PostgreSQL 的迁移策略。当我们增强 PostgreSQL 功能的同时,也有许多机会可以简化迁移流程。包括引入其他数据库中现有的功能(例如全局临时表、全局分区索引、自治事务),并在 PL/pgSQL 中增加更多功能与性能优化(如批量数据处理函数、模式变量缓存函数元数据)。所有这些都将改善 PostgreSQL 开发者的体验,并让其他关系数据库的用户更容易采纳 PostgreSQL。

最后我们需要了解,如何才能持续不断地支持来自 AI/ML 数据的新兴负载,特别是向量存储与检索。在2023年的PGCon会议上,尽管人们希望在 PostgreSQL 本身中看到原生的向量支持,但大家一致认为,在 pgvector这样的扩展中实现这类功能可以抢占先机,更快地支持这些工作负载(这一策略似乎已经奏效在向量数据上性能表现优异)。有鉴于向量负载的诸多特征,我们可以在PostgreSQL中添加一些额外的支持,以便进一步支持它们:其中包括对处理 活动查询路径中的TOAST数据的规划器进行优化,并探索如何更好地支持带有大量过滤条件和 ORDER BY 子句的查询。

我确信在 2024 年,PostgreSQL 可以在这些领域取得显著进步。我们看到在 PostgreSQL 的扩展生态中,有大量的新能力正在涌现;但即便如此,我们还是可以继续直接为 PostgreSQL 添加新特性,让它更易于构建应用。

安全性如何?

我想快速过一下 PostgreSQL 的安全特性。众所周知在安全敏感型场景中,PostgreSQL 有着极佳的声誉。但总会有许多能改进的地方。在过去几年中,PostgreSQL社区对引入透明数据加密(TDE)的原生支持表现出许多兴趣与关注。然而还有许多其他地方可以搞搞创新,比如支持其他的身份验证方式/机制(主要需求是OIDC),或是探索联邦授权模式的可能性,使PostgreSQL能够继承其他系统的权限设置。尽管这些特性在当下都颇有挑战,我建议先在 “Per-Database” 层面上支持 TDE。这里我不想过多展开,因为已经有在 PostgreSQL 中满足这些特性需求的方法了,但我们还是应该不懈努力,争取实现完整的原生支持。

让我们再来看看PostgreSQL能在2024年里发力的其他方向。


扩展

PostgreSQL 的设计是高度可扩展的。您可以为PostgreSQL添加新功能,而无需分叉项目。包括新的数据类型、索引方法、与其他数据库系统协同工作的方法、更易于管理PostgreSQL特性的实用工具、额外的编程语言支持,甚至编写自己的扩展插件。人们已经围绕一些特定的 PostgreSQL 扩展(如PostGIS)建立了开源社区和公司;PostgreSQL 单一数据库便能支持不同类型的工作负载(地理空间、时间序列、数据分析、人工智能),正是扩展让这件事变得可能。数千个可用的PostgreSQL扩展成为了PostgreSQL的 “力量倍增器” —— 它一方面让用户能够快速的为数据库新增功能,另一方面也极大推动了 PostgreSQL 的普及与采用。

然而这也产生了一个副作用,即“扩展蔓延”现象。用户如何去选择合适的扩展?扩展的支持程度如何?如何判断某个扩展是否有持续积极的维护?如何为扩展做出自己的贡献?甚至“在哪里可以下载扩展”也成为了一个大问题。postgresql.org 提供了一个不完整的扩展列表,社区也维护了一些扩展包,也有其他几个可供选择的 PostgreSQL 扩展仓库(例如 PGXNdbdevTrunk)和 pgxman 可供选择。

PostgreSQL社区的一个优势是去中心化,广泛散布于世界各处。但我们可以做得更好,帮助用户在复杂的数据管理中做出明智的选择。我认为2024年是一个机遇,我们可以投入更多资源来整合与展示 PostgreSQL 扩展,帮助用户理解什么时候可以使用哪些扩展,并了解扩展们的开发成熟度,并同样为扩展开发者提供更好的管理支持与维护资源。


社区建设

在谈论2024年社区建设的构想时,我深感自加入 PostgreSQL 贡献者社区以来,我们已取得显著进步。社区在认可各类贡献者方面表现突出(尽管仍有提升空间)—— 不仅限于代码贡献,还包括项目的各个方面。展望未来,我想着重强调三个关键领域:导师制、多元化、公平与包容(DEI)以及透明度,这些都对项目的全方位发展至关重要。

PGCon 2023开发者会议上,Melanie Plageman 就新贡献者的体验和挑战进行了深入分析。她提到了诸多挑战,如初学者需要花费大量时间来掌握基本知识,包括使用代码库和邮件列表进行交流,以及将补丁提交到可审查状态所需的努力。她还指出,提供建设性指导意见(从审查补丁开始)可能比编写代码本身更具挑战性,同时也讨论了如何有效地提供反馈。

关于提供反馈,我想引用罗伯特-哈斯(Robert Haas)的一篇优秀博文,其中他特别强调了在批评时同时给予表扬的重要性——这种方法可以产生显著的效果,并提醒我们即使在批评时也应保持支持态度。

回到 Melanie 的观点,我们应该在整个社区更好地实施导师计划。就我个人而言,我认为我在宣传项目方面做得不够好,包括帮助更多人为网络基础设施发布流程 做出贡献。这并不是说 PostgreSQL 缺乏优秀的导师,而是我们可以在帮助人们开始贡献和找到导师方面做得更好。

2024年将是建立更完善导师制度的起点。我们希望在5月于温哥华举行的 PGConf.dev 2024 上试验一些新想法。

PGConf.dev 出现前,从2007年到2023年,PGCon一直是PostgreSQL贡献者们集结并讨论即将开始的开发周期和关键项目的重要活动。PGCon 一直由 Dan Langille 负责组织。经过多年的辛勤工作,他决定将组织职责扩展至一个团队,并协助成立了 PGConf.dev

PGConf.dev 是专为那些希望为 PostgreSQL 做贡献的人士举办的会议。会议内容覆盖了 PostgreSQL 的开发工作(包括内核及所有相关的开源项目,如扩展和驱动程序)、社区建设以及开源意见领袖等主题。PGConf.dev 的一大特色是导师制,并计划举办关于如何为 PostgreSQL 贡献的研讨会。如果你正寻找为 PostgreSQL 贡献的机会,我强烈建议你考虑参加本活动或提交演讲提案

接下来是 PostgreSQL 社区如何在多元化、公平与包容性(DEI)上进步的话题。我强烈建议观看凯伦·杰克斯莱蒂西亚·阿夫罗特在 2023 年 PGConf.eu 上的演讲: 在肯的 Mojo Dojo Casa House 里尝试成为芭比:因为这是一场关于如何继续让 PostgreSQL 社区变得更加包容的深刻演讲。社区在这方面取得了进步(凯伦和莱蒂西亚指出了有助于此的一些举措),但我们还能做得更好,我们应该积极主动地处理反馈,以确保为 PostgreSQL 做出贡献是一种受欢迎的体验。我们所有人都可以采取行动,例如,在发生(诸如性别歧视的)不当行为时及时指出,并指出行为不当的原因。

最后是透明度问题。在开源领域这可能听起来有些奇怪,毕竟它本身就是开放的。但有不少治理问题并不会在公开场合讨论,了解决策制定的流程会很有帮助。PostgreSQL 行为守则委员会 提供了一个优秀的例子:一个社区如何就需要敏感处理的问题保持透明度。该委员会每年都会发布一份报告(这是 2022 年的报告),包括案例的总体描述和整体统计数据。我们可以在许多 PostgreSQL 团队中复制这种做法 —— 这些团队参与的任务可能由于其敏感性需要保密。


结论:本来这篇文章应该更短

最初,我以为这篇文章会是一篇简短的帖子,几小时内就能完成。但几天后,我意识到情况并非如此……

老实说,PostgreSQL目前处于一个非常好的状态。它依然备受欢迎,其可靠性、鲁棒性和性能的声誉稳如磐石。然而我们仍可以做得更好,令人感到振奋的是,社区正在积极地在各个方向上努力改善。

虽然上面这些是 PostgreSQL 在 2024 年及以后可以做的事情,但 PostgreSQL 走到今天已经做成了很多很多的事。提出 “PostgreSQL何去何从” 这样的问题,实际上为我们提供了一个机会:回顾过去几年 PostgreSQL 所取得的进展,并展望未来!

PostgreSQL 规约(2024版)

微信公众号原文


0x00背景

没有规矩,不成方圆。

PostgreSQL的功能非常强大,但是要把PostgreSQL用好,需要后端、运维、DBA的协力配合。

本文针对PostgreSQL数据库原理与特性,整理了一份开发/运维规约,希望可以减少大家在使用PostgreSQL数据库过程中遇到的困惑:你好我也好,大家都好。

本文第一版主要针对 PostgreSQL 9.4 - PostgreSQL 10 版本 ,当前最新版本针对 PostgreSQL 15/16 进行更新与调整。


0x01 命名规范

计算机科学只存在两个难题:缓存失效和命名

通用命名规则(Generic)

  • 本规则适用于所有数据库内对象,包括:库名、表名、索引名、列名、函数名、视图名、序列号名、别名等。
  • 对象名务必只使用小写字母,下划线,数字,其中首字母必须为小写字母。
  • 对象名长度不得超过63个字符,命名统一采用 snake_case 风格。
  • 禁止使用SQL保留字,使用select pg_get_keywords(); 获取保留关键字列表。
  • 禁止出现美元符号 $ ,禁止使用中文,不要以 pg 开头。
  • 提高用词品味,做到信达雅;不要使用拼音,不要使用生僻冷词,不要使用小众缩写。

集群命名规则 (Cluster)

  • PostgreSQL 集群的命名将作为集群资源的命名空间,必须为有效的 DNS 域名,不包含任何点号与下划线。
  • 集群名应当由小写字母开头,仅包含小写字母、数字、减号,符合正则表达式:[a-z][a-z0-9-]*
  • PostgreSQL 数据库集群命名通常以三段式结构:pg-<biz>-<tld>。数据库类型 / 业务名称 / 业务线或环境
  • biz 为最能代表业务特征的英文词语,应当仅由小写字母与数字组成,不得包含连字符 -
  • 使用备份集群搭建某一个现有集群的延迟从库时,biz 名应当为 <biz>delay,例如 pg-testdelay
  • 分支一个现有集群时,可以在 biz 尾部添加数字:例如从 pg-user1 可以分支出 pg-user2pg-user3
  • 水平分片集群,biz命名中应当包含 shard,并缀以分片号,例如 pg-testshard1pg-testshard2,……
  • <tld> 为顶层业务线,也可用于区分不同环境:例如 -tt-dev-uat-prod 等。无此需要可以省略。

服务命名规则(Service)

  • 每一套 PostgreSQL 集群会对外提供 2~6 种不等的服务,这些默认使用固定命名规则。
  • 服务名以集群名作为前缀,服务类型作为后缀,例如 pg-test-primarypg-test-replica
  • 读写服务统一以 primary 后缀命名,只读服务统一以 replica 后缀命名,这两个为必选服务。
  • ETL拉取/个人用户查询以 offline 后缀命名,直连主库/ETL写入以 default 后缀命名,为选配服务。
  • 同步读取服务以 standby 后缀命名,延迟从库服务以 delayed 后缀命名,少量核心库可提供此服务。

实例命名规则(Instance)

  • 一套 PostgreSQL 集群由至少一个实例组成,每个实例都有集群内从零或一开始唯一分配的实例号。
  • 实例名由集群名 + 实例号通过连字符 - 拼接而成,例如: pg-test-1pg-test-2
  • 实例号一经分配不得修改,持续到实例下线销毁,不得重新分配使用。
  • 实例名将作为监控系统数据的 ins 标签,附加到该实例的所有数据上。
  • 如果是用主机/数据库 1:1 独占式部署,节点 Hostname 可以使用数据库实例名。

数据库命名规则(Database)

  • 数据库库名应当与集群、应用保持一致,必须为具有高区分度的英文单词。
  • 命名以 <tld>_<biz> 的形式构建,<tld> 为顶层业务线,也可用于区分不同环境,不用可以省略。
  • <biz>为具体业务名称,例如 pg-test-tt 集群可以使用库名 tt_testtest。这一点不强制,即允许创建不同于集群 <biz> 名称的其他数据库。
  • 对于分片库,<biz> 部分必须以shard结尾,但不应当包含分片号,例如 pg-testshard1pg-testshard2 都用 testshard 即可。
  • 多个部分使用-连接。例如:<biz>-chat-shard<biz>-payment等,总共不超过三段。

角色命名规范(Role/User)

  • 数据库超级用户 dbsu 有且仅有一个:postgres,用于流复制的用户命名为replicator
  • 用于监控的用户统一命名为 dbuser_monitor,用于日常管理的超级用户为:dbuser_dba
  • 程序/服务使用的业务用户默认使用 dbuser_<biz> 作为用户名,例如 dbuser_test。来自不同服务的访问应当使用独立的业务用户区分访问。
  • 个人用户申请的数据库用户同意使用 dbp_<name>,其中 name 为 LDAP 中的标准用户名。
  • 默认权限组命名固定为: dbrole_readonlydbrole_readwritedbrole_admindbrole_offline

模式命名规则(Schema)

  • 业务统一使用一个全局的 <prefix> 作为模式名,尽可能简短,默认设置为search_path 首位元素。
  • <prefix> 不得使用 publicmonitor ,不得与任何 PostgreSQL 扩展使用的模式名冲突,例如: timescaledbcitusrepackgraphqlnetcron,…… 不宜使用特殊名称:dbatrash
  • 分片模式命名规则采用:rel_<partition_total_num>_<partition_index>。中间为总分片数,目前固定使用 8192 ,后缀是分片号,从0开始计数。如 rel_8192_0,…… ,rel_8192_11,等等。
  • 创建额外的模式,或者使用 <prefix> 之外的模式名需要由研发解释其必要性。

关系命名规则(Relation)

  • 关系命名以表意清晰为第一要义,不要使用含混的缩写,也不应过分冗长,遵循通用命名规则。
  • 表名应当使用复数名词,并与历史惯例保持一致,应尽量避免带有不规则复数形式的单词。
  • 视图以v_作为命名前缀,物化视图使用mv_作为命名前缀,临时表以tmp_作为命名前缀。
  • 继承或分区表应当以父表表名作为前缀,并以子表特性(规则,分片范围等)作为后缀。
  • 时间范围分区使用起始区间作为命名后缀,首个分区如果无上界则由研发指定一个足够久远的时间点:年级分区:tbl_2023,月级分区 tbl_202304,天级分区 tbl_20230405,小时级分区 tbl_2023040518 ,默认分区以 _default 结尾。
  • 哈希分区命名以余数作为分区表名的后缀,列表分区由研发手工指定与列表项对应的合理分区表名。

索引命名规则(Index)

  • 创建索引时,应当显式指定索引名称,并与PostgreSQL默认命名规则保持一致。
  • 索引名称以表名作为前缀,主键索引以 _pkey 结尾,唯一索引以 _key 结尾,普通索引以 _idx 结尾,用于EXCLUDED约束的索引以_excl结尾。
  • 使用条件索引/函数索引时,应当在索引名称中体现使用的函数与条件内容。例如 tbl_md5_title_idxtbl_ts_ge_2023_idx,但不可超出长度限制。

字段命名规则(Attribute)

  • 禁止使用系统列保留字段名:oidxminxmaxcmincmaxctid
  • 主键列通常命名为id,或以id作为后缀。
  • 创建时间字段惯用名为created_time,最后修改时间惯用名为 updated_time
  • 布尔型字段建议使用is_has_ 等作为前缀。
  • 额外的灵活 JSONB 字段固定使用 extra 作为列名。
  • 其余各字段名需与已有表命名惯例保持一致,任何打破惯例的字段命名都应当做出书面设计说明与解释。

枚举项命名 (Enum)

  • 枚举项默认应当使用 camelCase,但也允许其他风格。

函数命名规则(Function)

  • 函数命名以动词起头: selectinsertdeleteupdateupsertcreate ,……。
  • 重要参数可以通过_by_ids_by_user_ids的后缀在函数名中体现。
  • 避免函数重载,同名函数尽量只保留一个。
  • 禁止通过 BIGINT/INTEGER/SMALLINT 等整型进行函数签名重载,调用时可能产生歧义。
  • 存储过程与函数中的变量使用命名参数,避免位置参数($1$2,…)。
  • 如果参数名与对象名出现冲突,在参数前添加 _,例如_user_id

注释规范(Comment)

  • 尽最大可能为各种对象提供注释(COMMENT),注释使用英文,言简意赅,一行为宜。
  • 对象的模式或内容语义发生变更时,请务必一并更新注释,并与实际情况保持同步。

0x02 设计规范

Suum cuique

建表注意事项

  • 建表 DDL 语句需要使用标准格式,SQL 关键词大写,其他小写。
  • 在字段名/表名/别名中统一使用小写,尽量不要区分大小写。如果遇到大小写混用的情况,或者与 SQL 关键词冲突的名称,需要使用双引号扩起进行引用。
  • 能使用专有类型的,不使用字符串。(数值,枚举,网络地址,货币,JSON,UUID等):使用正确的数据类型,能显著提高数据存储,查询,索引,计算的效率,并提高可维护性。
  • 优化列的布局,对齐类型可以有额外的性能/存储空间收益。
  • 唯一约束须由数据库保证,任何唯一列须有对应的唯一约束。EXCLUDE约束是泛化的唯一约束,可以在低频更新场景下用于保证数据完整性。

分区表注意事项

  • 如果单表超过百TB量级,或者每月增量数据超过十几GB量级,可以考虑进行表分区。
  • 分区的指导原则是,让每个分区的大小尽可能落在 1GB ~ 64GB 的舒适范围内。
  • 有条件按照时间范围分区的表优先按时间范围分区,通常使用的粒度包括: decade,year,month,day,hour,应当至少提前三个月创建好未来所需的分区。
  • 对于极端倾斜的数据分布,可以组合使用不同的时间粒度,例如: 1900 - 2000 一个大分区,2000 - 2020 按年分区,2020 后按月分区。使用时间分区时,表名使用分区下限界的值(无穷大则选用一个足够久远的值)。

宽表注意事项

  • 宽表(例如有几十个字段的表)可以考虑进行纵向拆分,通过相同的主键与主表相互引用。
  • 因为PostgreSQL MVCC机制,宽表的写放大现象更为明显,减少对宽表的频繁更新。
  • 互联网场景中,允许适当降低规范化等级,减少多表连接以提高性能。

主键注意事项

  • 每个表都必须身份列,原则上必须有主键,最低要求为拥有非空唯一约束

  • 身份列用于唯一标识表中的任一元组,逻辑复制与诸多三方工具有赖于此。

  • 主键如果包含多列,应当在建表DDL的字段列表之后,使用 PRIMARY KEY(a,b,...) 单列指定。

  • 主键原则上建议使用整型,可以谨慎使用 UUID 与长度受限的文本类型,使用其他类型需要显式说明与评估。

  • 主键通常使用单一整型列,原则上建议使用 BIGINT,谨慎使用 INTEGER,不允许使用 SMALLINT

  • 主键应使用 GENERATED ALWAYS AS IDENTITY 生成唯一主键;SERIALBIGSERIAL 仅当需要兼容 PG 10 以下版本时允许使用。

  • 主键可以使用 UUID 类型作为主键,建议用 UUID v1/v7;谨慎使用 UUIDv4 作为主键,随机 UUID 的局部性较差且有碰撞概率。

  • 使用字符串列作为主键时,应当添加长度限制。通常使用 VARCHAR(64),使用更长的字符串时应当进行说明与评估。

  • INSERT/UPDATE 时原则上禁止修改主键列的值,INSERT RETURNING 可用于返回自动生成的主键值。

外键注意事项

  • 定义外键时引用必须显式设置相应的动作:SET NULLSET DEFAULTCASCADE,慎用级联操作。
  • 外键引用的列,需要为其他表/本表上的主键列。
  • 互联网类业务,特别是分区表、水平分片库慎用外键,可以在应用层解决。

空值/默认值注意事项

  • 字段语义上没有零值与空值区分的,不允许空值存在,须为列配置NOT NULL约束。
  • 字段语义上带有默认值的,应当配置 DEFAULT 默认值。

数值类型注意事项

  • 常规数值字段使用INTEGER。容量拿不准的数值列使用BIGINT
  • 无特殊理由不要用SMALLINT,性能与存储提升甚小,但会有很多额外的问题。
  • 注意SQL标准不提供无符号整型,超过INTMAX但没超过UINTMAX的值需要升格存储。不要存储超过INT64MAX的值到BIGINT列中,会溢出为负数。
  • REAL 表示4字节浮点数,FLOAT 表示8字节浮点数。浮点数仅可用于末尾精度无所谓的场景,例如地理坐标。切记不要对浮点数使用等值判断,零值除外
  • 精确数值类型使用NUMERIC,如果可行,请用 NUMERIC(p)NUMERIC(p,s) 设置有效数字位数以及小数部分的有效位数。例如摄氏气温(37.0)可以用 NUMERIC(3,1) 类型来存储3位有效数字与1位小数。
  • 货币数值类型使用MONEY

文本类型注意事项

  • PostgreSQL的文本类型包括 char(n)varchar(n)text。默认情况下,可以使用 text 类型 ,不限制字符串长度,但受字段最大长度1GB限制。
  • 如果条件许可,优先使用 varchar(n) 类型来设置一个最大字符串长度,这会引入极微小的额外检查开销,但能规避一些脏数据与极端情况。
  • 避免使用char(n),该类型为了与SQL标准兼容,存在不合直觉的行为表现(补齐空格与截断),且并没有存储和性能优势。

时间类型注意事项

  • 时间只有两种存储方式:带时区的 TIMESTAMPTZ,不带时区的 TIMESTAMP
  • 建议使用带时区的 TIMESTAMPTZ,如果使用 TIMESTAMP 存储,必须使用0时区标准时。
  • 生成0时区时间请使用 now() AT TIME ZONE 'UTC' ,不能直接截断时区 now()::TIMESTAMP
  • 统一使用 ISO-8601 格式输入输出时间类型:2006-01-02 15:04:05,避免DMY与MDY问题。
  • 中国区域用户可以统一使用 Asia/Hong_Kong +8 时区,因为上海时区缩写 CST 有歧义。

枚举类型注意事项

  • 较稳定的,取值空间较小(几十到几百内)的字段应当使用枚举类型,不要使用整型与字符串表示。
  • 枚举内部使用动态整型实现,相比整型有可读性优势,相比字符串有性能、存储、可维护性上的优势。
  • 枚举项只能添加,无法删除,但是可以重命名现有枚举值。ALTER TYPE <enum_name> 用于修改枚举。

UUID类型注意事项

  • 请注意,全随机的 UUIDv4 用作主键时局部性太差,尽可能考虑用 UUIDv1 / v7 代替。
  • 一些 UUID 生成/处理函数需要额外的扩展插件,例如 uuid-ossppg_uuidv7 等,有此需求请在配置时指明。

JSON类型注意事项

  • 如无特殊原因,总是使用二进制存储的 JSONB 类型与相关函数,而非文本版本的 JSON
  • 请注意 JSON 中的原子类型与 PostgreSQL 对应类型的细微差别:与 JSON 字符串对应的text 类型中不允许出现 \u0000 零字符,与 JSON 数值类型对应的 numeric 中不允许出现 NaNinfinity。布尔值只接受小写的 truefalse 字面值。
  • 请注意JSON标准中的null对象和 SQL 标准中的空值 NULL 并非同一个概念。

数组类型注意事项

  • 当存储元素数量较少时,可以使用数组字段代替单独。
  • 适合用于存储元素数量相对较少且变化不频繁的数据。如果数组中的元素数量非常大或经常变化,考虑使用单独的表来存储数据,并使用外键关联。
  • 高维度的浮点数组,可以考虑使用 pgvector 扩展提供的专用数据类型。

GIS类型注意事项

  • GIS 类型默认使用 srid=4326 参考坐标系。
  • 经纬度坐标点应当使用 Geography 类型,无需显式指定参考系坐标 4326

触发器注意事项

  • 触发器会提高数据库系统的复杂度与维护成本,原则上不鼓励使用。禁止使用规则系统,此类需求应当使用触发器替代。

  • 触发器的典型场景是,在修改某一行后自动修改 updated_time 为当前时间戳,或者将表的增删改动作记录到另一张日志表中,或者维持两张表在业务上的一致性。

  • 触发器中的操作是事务性的,意味着如果触发器或触发器中的操作失败,整个事务都会回滚,所以请充分测试并证明触发器的正确性。对于递归调用、执行复杂查询死锁,多个触发器执行顺序等情况需要特别关注。

存储过程/函数注意事项

  • 函数/存储过程适用于封装事务,减少并发冲突,减少网络往返,减少返回数据量,执行少量自定义逻辑。

  • 存储过程不适合进行复杂计算,不适合进行平凡/频繁的类型转换与包装。在关键高负载系统中,应当移除数据库中不必要的计算密集型逻辑,例如在数据库中使用SQL进行WGS84到其他坐标系的换算。与数据获取、筛选密切关联的计算逻辑可以使用函数/存储过程:例如PostGIS中的几何关系判断。

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

  • 使用统一的函数创建语法格式,签名单独占用一行(函数名与参数),返回值单启一行,语言为第一个标签。一定要标注函数易变性等级:IMMUTABLE, STABLE, VOLATILE。添加属性标签,如:RETURNS NULL ON NULL INPUTPARALLEL SAFEROWS 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$;
    

使用合理的Locale选项

使用合理的字符编码与本地化配置

  • 必须使用 UTF8 字符编码,严格禁止使用其他任何字符编码。
  • 必须使用 C 作为 LC_COLLATE 默认排序规则,有特殊需求必须在DDL/查询子句中显式指定来实现。
  • 字符集 LC_CTYPE 默认使用 en_US.UTF8,一些扩展依赖字符集信息方可正常工作,如 pg_trgm

索引相关注意事项

  • 所有在线查询必须针对其访问模式设计相应索引,除极小表外不允许全表扫描。
  • 索引有代价,不允许创建不使用的索引,应当及时清理不再使用的索引。
  • 建立联合索引时,应当将区分度,选择率高的列放在前面,例如 ID,时间戳等。
  • GiST索引可用于解决近邻查询问题,传统B树索引无法提供对KNN问题的良好支持。
  • 对于值与堆表的存储顺序线性相关的数据,如果通常的查询为范围查询,建议使用BRIN索引。最典型场景如仅追加写入的时序数据,BRIN索引相比Btree更为高效。
  • 针对 JSONB / 数组字段进行检索时,可以使用 GIN 索引加速查询。

明确B树索引空值的顺序

  • 如在可空列上有排序需求,需要在查询与索引中明确指定NULLS FIRST还是NULLS LAST
  • 注意,DESC排序的默认规则是NULLS FIRST,即空值会出现在排序的最前面,通常这不是期望行为。
  • 索引的排序条件必须与查询匹配,如:CREATE INDEX ON tbl (id DESC NULLS LAST);

禁止在大字段上建立索引

  • 被索引字段大小无法超过2KB(1/3的页容量),在文本类型上创建索引需要谨慎,被索引的文本应当使用带有长度约束的 varchar(n) 类型。
  • 文本类型用作主键时,必须设置最大长度。原则上长度不应当超过 64 个字符,特殊情况需显式说明评估。
  • 如有大字段索引需求,可以考虑对大字段取哈希,并建立函数索引。或使用其他类型的索引(GIN)。

充分利用函数索引

  • 任何可以由同一行其他字段推断得出的冗余字段,可以使用函数索引替代。
  • 对于经常使用表达式作为查询条件的语句,可以使用表达式或函数索引加速查询。
  • 典型场景:建立大字段上的哈希函数索引,为需要左模糊查询的文本列建立 reverse 函数索引。

充分利用部分索引

  • 查询中查询条件固定的部分,可以使用部分索引,减小索引大小并提升查询效率。
  • 查询中某待索引字段若只有有限几种取值,也可以建立几个相应的部分索引。
  • 如果部分索引中的列会被频繁更新,请关注这些索引的膨胀情况

0x03 查询规范

The limits of my language mean the limits of my world.

—Ludwig Wittgenstein

使用服务接入

  • 生产数据库接入必须通过域名接入服务,严禁使用 IP 地址直连。
  • 服务与接入使用 VIP,LVS/HAProxy 屏蔽集群实例成员的角色变化,主从切换无需应用重启。

读写分离

  • 互联网业务场景:写请求必须走主库,通过 Primary 服务访问。
  • 读请求原则上走从库,通过 Replica 服务访问。
  • 例外情况:需要读己之写的一致性保证,且检测到显著的复制延迟时,只读请求可以访问主库;或向DBA申请提供 Standby 服务。

快慢分离

  • 生产中1毫秒以内的查询称为快查询,生产中超过1秒的查询称为慢查询。
  • 慢查询必须走离线从库 —— Offline 服务/实例,应当在执行时设置超时。
  • 生产中的在线普通查询执行时长原则上应当控制在 1ms 内。
  • 生产中的在线普通查询执行时长,超过10ms需修改技术方案,优化达标后再上线。
  • 在线查询应当配置10ms 数量级或更快的 Timeout,避免堆积造成雪崩。
  • 禁止从 Primary 上 ETL 数据,应当使用 Offline 服务从专用实例取数。

使用连接池

  • 生产应用必须通过连接池访问数据库,通过 1:1 部署的 Pgbouncer 代理访问 PostgreSQL 数据库。Offline 服务,个人用户严禁直接使用连接池。
  • Pgbouncer 连接池默认使用 Transaction Pooling 模式,一些会话级别的功能可能无法使用(比如Notify/Listen),需要特别注意。在此模式下,1.21 以前的 Pgbouncer 不支持使用 Prepared Statements。特殊场景可以使用 Session Pooling 或绕开连接池直接访问数据库,需要 DBA 审核特批。
  • 使用连接池时禁止修改连接状态,包括修改连接参数,修改搜索路径,更换角色,更换数据库。万不得已修改后必须彻底销毁连接,将状态变更后的连接放回连接池会导致污染扩散。严禁使用 pg_dump 通过 Pgbouncer 转储数据。

为查询语句配置主动超时

  • 应用应当为所有的语句配置主动超时,超时后主动取消请求,避免雪崩。(Go context)
  • 周期性执行的语句,必须配置小于执行周期的超时 Timeout,避免雪崩。
  • HAProxy 配置有 24 小时连接默认超时,用于滚动过期长连接。请不要在离线实例上运行执行时间超过1天的 SQL,有此需求由DBA特批调整。

关注复制延迟

  • 应用必须意识到主从之间的同步延迟,并妥善处理好复制延迟超出合理范围的情况。
  • 常规情况下,复制延迟在 100µs / 几十KB 的数量级,但是在极端情况下,从库可能会出现分钟/小时级的复制延迟,应用应当知晓这种现象,并有相应的降级方案 —— 选择从主库读取,稍后重试,或直接报错。

重试失败的事务

  • 查询可能因为并发争用,管理员命令等原因被杀死,应用需要意识到这一点,并在必要时重试。
  • 应用在数据库大量报错时可以触发断路器熔断,避免雪崩。但要注意区分错误的类型与性质。

掉线重连

  • 数据库连接可能因为各种原因被中止,应用必须有掉线重连机制。
  • 可以使用SELECT 1作为心跳包查询,检测连接的有消息,并定期保活。

在线服务应用代码禁止执行DDL

  • 生产应用严禁执行 DDL,不要在应用代码里搞个大新闻。
  • 例外场景:为分区表创建新的时间分区,可由应用谨慎管理。
  • 特殊例外:办公系统使用的数据库,例如 Gitlab / Jira/ Confluence 等可以授予应用 DDL 权限。

SELECT语句显式指定列名

  • 避免使用SELECT *,或在RETURNING子句中使用*。请使用具体的字段列表,不要返回用不到的字段。当表结构发生变动时(例如,新值列),使用列通配符的查询很可能会发生列数不匹配的错误。
  • 一些表的字段经过维护之后,顺序会发生变化,例如:将 INTEGER 主键 id 升级为 BIGINT 后,id 的列顺序会到最后一列。此问题只能在维护迁移时择机修复,研发应当克制调整列顺序的强迫症,并在 SELECT 语句中显式指定列的顺序
  • 例外:当存储过程返回具体的表行类型时,允许使用通配符。

禁止在线查询全表扫描

  • 例外情况:常量极小表,极低频操作,表/返回结果集很小(百条记录/百KB内)。
  • 在首层过滤条件上使用诸如!=, <>的否定式操作符会导致全表扫描,必须避免。

禁止在事务中长时间等待

  • 开启事务后必须尽快提交或回滚,超过10分钟的IDEL IN Transaction将被强制杀死。
  • 应用应当开启 AutoCommit,避免BEGIN之后没有配对的ROLLBACKCOMMIT
  • 尽量使用标准库提供的事务基础设施,不到万不得已不要手动控制事务。

使用 count 计数时的注意事项

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

使用游标后必须及时关闭

重复查询使用准备语句

  • 重复的查询应当使用准备语句(Prepared Statement),消除数据库硬解析的CPU开销。低于 1.21 版本的 Pgbouncer 无法在事务池化模式中支持此功能,请特别注意。
  • 准备语句会修改连接状态,请注意连接池对于准备语句的影响。

选择合适的事务隔离等级

  • 默认隔离等级为读已提交,适合大多数简单读写事务,普通事务选择满足需求的最低隔离等级。
  • 需要事务级一致性快照的写事务,请使用可重复读隔离等级。
  • 对正确性有严格要求(例如与钱有关)的写入事务,使用可序列化隔离等级。
  • 在RR与SR隔离等级出现并发冲突时,应用应当视错误类型进行积极的重试。

r h 09判断结果存在性不要使用count

  • 使用SELECT 1 FROM tbl WHERE xxx LIMIT 1判断是否存满足条件的列,要比Count快。
  • 可以使用SELECT exists(SELECT * FROM tbl WHERE xxx LIMIT 1)将存在性结果转换为布尔值。

使用RETURNING子句一次性取回修改后的结果

  • RETURNING 子句可以在 INSERTUPDATEDELETE 语句后使用,有效减少数据库交互次数。

使用UPSERT简化逻辑

  • 当业务出现插入-失败-更新的操作序列时,考虑使用UPSERT替代。

利用咨询锁应对热点并发

  • 针对单行记录的极高频并发写入(秒杀),应当使用咨询锁对记录ID进行锁定。
  • 如果能在应用层次解决高并发争用,就不要放在数据库层面进行。

优化IN操作符

  • 使用 EXISTS 子句代替IN操作符,性能更佳。
  • 使用 =ANY(ARRAY[1,2,3,4]) 代替 IN (1,2,3,4),效果更佳。
  • 控制参数列表的大小,原则上不要超过1万个,超过时可以考虑分批处理。

不建议使用左模糊搜索

  • 左模糊搜索WHERE col LIKE '%xxx'无法充分利用B树索引,如有需要,可用reverse表达式函数索引。

使用数组代替临时表

  • 考虑使用数组替代临时表,例如在获取一系列ID的对应记录时。=ANY(ARRAY[1,2,3]) 要比临时表JOIN好。

0x04 管理规范

使用 Pigsty 搭建 PostgreSQL 集群与基础设施

  • 生产环境统一使用 Pigsty 主干版本,在 x86_64 机器, CentOS 7.9 / RockyLinux 8.8 操作系统上部署数据库。
  • pigsty.yml 配置文件通常包含了高度敏感的重要机密信息,应当使用 git 进行版本化管理,并严格控制访问权限。
  • files/pki 内生成的 CA 私钥与其他证书应当妥善保管,定期将备份至安全区域存储归档,并严格控制访问权限。
  • 所有密码都不允许使用默认值,确保都已经修改为强度足够的新密码。
  • 严格控制管理节点与配置代码仓库的的访问权限,仅限 DBA 登陆与访问。

监控系统是必选项

  • 任何部署必须有一套监控系统,生产环境至少使用两套 Infra 节点以提供冗余。

根据需求合理规划集群架构

  • 任何由DBA管理的生产数据库集群,必须带有至少一个在线从库,用于在线故障切换。
  • 默认使用 oltp 模板,分析类数据库使用 olap 模板,财务库使用 crit 模板,小微虚拟机(四核内)使用 tiny 模板。
  • 年增数据量超过1TB的业务,或者写入 TPS 超过3~5万的集群,可以考虑搭建水平分片集群。

使用 Patroni 与 Etcd 配置集群高可用

  • 生产数据库集群使用 Patroni 作为高可用组件,使用 etcd 作为 DCS。
  • etcd 使用专用虚拟机集群,3 ~ 5 个节点,严格打散分布在不同机柜上。
  • 必须开启 Patroni Failsafe 模式,确保 etcd 故障时集群主库可以继续工作。

使用 pgBackRest 与 MinIO 配置集群PITR

  • 生产数据库集群使用 pgBackRest 作为备份恢复/PITR方案,使用 MinIO 作为备份存储仓库。
  • MinIO 使用多节点多盘集群,亦可使用 S3 / OSS / COS 服务代替,冷备份必须设置密码加密。
  • 所有数据库集群每天进行一次本地全量备份,保留最近一周的备份与WAL,每隔一月存留一个全备。
  • 出现 WAL 归档错误时,应当及时检查备份仓库并排查问题。

核心业务数据库配置注意事项

  • 核心业务集群至少需要配置两个在线从库,其中一个为专用离线查询实例。
  • 核心业务集群需要搭建一套延迟24小时的延迟从库集群,用于应急数据恢复。
  • 核心业务集群通常采用异步提交,与钱有关则采用同步提交。

财务数据库配置注意事项

  • 财务数据库集群需要至少两个在线从库,其中一个为专用同步 Standby 实例,并启用 Standby 服务接入。
  • 与钱有关的库必须使用 RPO = 0 的 crit 模板,启用同步提交确保数据零丢失,视情况启用 Watchdog。
  • 与钱有关的库必须强制打开数据校验和,视情况打开全量 DML 日志。

使用合理的字符编码与本地化配置

  • 必须使用 UTF8 字符编码,严格禁止使用其他任何字符编码。
  • 必须使用 C 作为 LC_COLLATE 默认排序规则,有特殊需求必须在DDL/查询子句中显式指定来实现。
  • 字符集 LC_CTYPE 默认使用 en_US.UTF8,一些扩展依赖字符集信息方可正常工作,如 pg_trgm

业务数据库管理注意事项

  • 同一个集群内允许创建多个不同的数据库,必须使用 Ansible 剧本新建业务数据库。
  • 所有业务数据库都必须同步存在于 Pgbouncer 连接池中。

业务用户管理注意事项

  • 不同的业务/服务必须使用不同的数据库用户,必须使用 Ansible 剧本新建业务用户。
  • 所有生产业务用户都必须同步存在于 Pgbouncer 连接池的用户列表文件中。
  • 个人用户应当设置默认有效期为 90 天的密码并定时更换。
  • 个人用户只允许从跳板机访问有权限的集群 Offline 实例,或带有 pg_offline_query 的从库。

扩展插件管理注意事项

  • 安装新扩展时,必须先在集群所有实例中使用 yum/apt 安装对应大版本的扩展插件二进制软件包。
  • 启用扩展前,需要确认扩展是否需要加入 shared_preload_libraries ,如果需要应当安排滚动重启。
  • 注意 shared_preload_libraries 优先级顺序, citustimescaledbpgml 通常要放在最前面。
  • pg_stat_statementsauto_explain 是必选插件,必须在所有集群中启用。
  • 安装扩展统一使用 dbsu 进行,在业务数据库中 CREATE EXTENSION 执行创建。

数据库XID与年龄注意事项

  • 关注数据库与表的年龄,避免XID事务号用尽。使用超过 20% 应当关注,超过 50% 应当立即介入处理。
  • 处理 XID 时,按年龄从大到小顺序挨个对表执行 VACUUM FREEZE

数据库表与索引膨胀注意事项

  • 关注表与索引的膨胀率,避免索引性能劣化,使用 pg_repack 在线处理表/索引膨胀问题。
  • 一般情况下,膨胀率超过 50% 的索引与表可以考虑进行重整处理。
  • 处理超过 100GB 的表膨胀时,应当特别注意,并挑选业务低谷时进行。

数据库重启注意事项

  • 重启数据库前,执行 CHECKPOINT 两次,强制脏页刷盘,可加速重启过程。
  • 重启数据库前,执行 pg_ctl reload 重载配置确认配置文件正常可用。
  • 重启数据库可使用 pg_ctl restart 或 patronictl 同时重启整个集群。
  • 严禁使用 kill -9 关闭任何数据库进程。

复制延迟注意事项

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

新从库数据预热

  • 高负载业务集群添加新从库实例时,应当对新数据库实例进行预热,逐步调整并应用 HAProxy 实例权重,分梯度上量:4,8,16,32,64,100。可以使用 pg_prewarm 将热数据加载至内存。

数据库发布流程

  • 线上数据库发布需要经过研发自测,主管审核,QA审核(可选),DBA审核几个评估阶段。
  • 研发自测阶段,应当由研发确保变更在开发、预发环境执行正确无误。
    • 如果是新建表,应当给出记录数量级,数据日增量预估值,读写吞吐量级预估。
    • 如果是新建函数,应当给出平均执行时间与极端情况说明。
    • 如果是模式变更,必须梳理清楚所有上下游依赖。
    • 如果是数据变更,记录订正,必须给出回滚 SQL。
  • 研发 Team Leader 需要对变更进行评估与审核,对变更内容负责。
  • DBA对发布的形式与影响进行评估与审核,提出审核意见,打回或统一执行

数据工单格式

  • 数据库变更通过平台进行,每个变更一个工单。
  • 标题清晰:某某业务需在 xx 库执行 yy 动作。
  • 目标明确:每个步骤需要在哪些实例上执行哪些操作,结果如何校验。
  • 回滚方案:任何变更都需要提供回滚方案,新建也需要提供清理脚本。
  • 任何变更都需要记录归档,有完善的审批记录,首先由研发上级TL Review 审批通过后由 DBA 审批。

数据库变更发布注意事项

  • 使用统一的发布窗口,每天 16:00 统一收集当日变更依次执行;16:00点后TL确认的需求将顺延至第二天执行。19:00 后不允许数据库发布,紧急发布请TL做特殊说明,抄送CTO审批同意后执行。

  • 数据库 DDL 变更 DML 变更统一使用管理员用户 dbuser_dba 远程执行,确保默认权限正常工作。

  • 业务管理员在自行执行 DDL 时,必须SET ROLE dbrole_admin 后再执行发布,确保默认权限。

  • 任何变更都需要有回滚预案方可执行,极个别无法回滚的操作需要特别谨慎处理(例如枚举加值)

  • 数据库变更使用 psql 命令行工具,连接到集群主库执行,使用 \i 执行脚本或 \e 手工分批执行。

删除表注意事项

  • 生产数据表 DROP 应当首先重命名,冷却 1~3 天确认没有访问后再移除。
  • 清理表时必须梳理所有依赖,包括直接间接依赖的对象:触发器,外键引用等。
  • 待删除的临时表通常放置于 trash Schema 中,通过 ALTER TABLE SET SCHEMA 修改模式名。
  • 高负载业务集群中,移除特别大的表 (> 100G) 时挑选业务低谷进行,避免抢占 I/O 。

创建与删除索引注意事项

  • 必须使用 CREATE INDEX CONCURRENTLY 并发创建索引,使用 DROP INDEX CONCURRENTLY 并发移除索引。
  • 重建索引时,总是先创建新索引,再移除旧索引,并修改新索引名与旧索引保持一致。
  • 创建索引失败后,应当及时移除 INVALID 的索引,修改索引后,使用 analyze 重新收集表上的统计数据。
  • 业务空闲时,可以启用并行索引创建,并设置 maintenance_work_mem 为更大的值加速索引创建。

审慎地进行模式变更

  • 尽可能避免整表重写式的变更,1GB 以内的表允许全表重写,DBA 应当在变更时告知所有相关业务方。
  • 向现有表中添加新列时,应当避免在默认值中使用 VOLATILE 的函数,避免全表重写。
  • 变更列类型时,必要时应当重建所有依赖该类型的函数,视图,并 ANALYZE 刷新统计信息。

控制数据写入的批次规模

  • 大批量写入操作应当切分为小批量进行,避免一次产生大量WAL或占用 I/O。
  • 大批量 UPDATE 后,执行 VACUUM 回收死元组占用的空间。
  • 执行 DDL 语句本质是对系统目录的修改,同样需要控制一个批次内的DDL语句数量。

数据加载注意事项

  • 使用COPY加载数据,如有需要可以并行执行。
  • 加载数据前可以临时关闭autovacuum,按需禁用触发器,并在加载完后再建立约束与索引。
  • 调大 maintenance_work_mem,增大max_wal_size
  • 加载完成后执行vacuum verbose analyze table

数据库迁移、大版本升级注意事项

  • 生产环境统一使用标准迁移搭建剧本逻辑,通过蓝绿部署实现不停机集群迁移、大版本升级等需求。
  • 对于停机时间没有要求的集群,可以使用 pg_dump | psql 逻辑导出导入的方式停机升级。

数据误删/误更新处理流程

  • 事故发生后,立即评估是否需要停机止血,评估影响规模,决定处理手段。
  • 研发侧如有办法恢复,优先由研发自行通过 SQL 发布进行订正;否则使用 pageinspectpg_dirtyread 从坏表中抢救数据。
  • 若有延迟从库,从延时从库中抽取数据进行修复。首先确认误删时间点,推进延迟从库至该 XID 后抽取数据。
  • 大面积误删误写,经与业务沟通同意后,执行原地 PITR 回滚至特定时间。

数据腐坏处理流程

  • 确认从库数据是否可用于恢复,若从库数据无恙可先 Switchover 至从库。
  • 临时关闭 auto_vacuum ,定位错误根因,替换故障磁盘并补充新从库。
  • 若系统目录损坏,或使用 pg_filedump 从表二进制文件中恢复数据。
  • 若 CLOG 损坏,使用 dd 生成仿造提交记录。

数据库连接打满注意事项

  • 出现连接打满现象(雪崩)时,立即使用杀连接查询治标止损:pg_cancel_backendpg_terminate_backend
  • 使用 pg_terminate_backend 中止所有普通后端进程,从每秒一次(psql \watch 1)开始。并从监控系统确认连接情况,如果继续堆积,则不断提高杀连接查询的执行频次,例如每 0.1 秒一次,直到不再堆积为止。
  • 从监控系统确认止血后,尝试停止杀连接,若重新出现堆积则立即恢复杀连接。立即分析根因并进行相应处理(升配,限流,加索引等)

FerretDB:假扮成MongoDB的PG

微信公众号原文链接

MongoDB 曾经是一项令人惊叹的技术,让开发者能够抛开关系型数据库的“模式束缚”,快速构建应用程序。然而随着时间推移,MongoDB 放弃了它的开源本质,这使得许多开源项目和早期商业项目无法使用它。

大多数 MongoDB 用户其实并不需要 MongoDB 提供的高级功能,但他们确实需要一个易于使用的开源文档数据库解决方案。PostgreSQL 的 JSON 功能支持已经足够完善了:二进制存储 JSONB,GIN 任意字段索引 ,各种 JSON 处理函数,JSON PATH 和 JSON Schema,PG早已是一个功能完备,性能强大的文档数据库了。但是提供替代的功能,和直接仿真还是不一样的。

为了填补这个空白,FerretDB 应运而生,旨在提供一个真正开源MongoDB 替代。这是一个非常有趣的项目,之前的名字叫 “MangoDB”,因为有碰瓷 “MongoDB” 的嫌疑(芒果DB vs 蒙古DB),所以在 1.0 版本改成了现在的名字 FerretDB。FerretDB 可以为使用 MongoDB 驱动的应用提供一个丝滑迁移到 PostgreSQL 的过渡方案。

它的功能就是让 PostgreSQL 假扮成 MongoDB。它是一个为 PG 提供 MongoDB Wire Protocol 支持的协议转换中间件/Proxy。上次做过这种事的插件是 AWS 的 Babelfish,让 PostgreSQL 兼容 SQL Service 的线缆协议假扮成 Microsoft SQL Server。

FerretDB 作为一个选装组件,对丰富 PostgreSQL 生态大有裨益。Pigsty 在 1.x 中就提供了基于 Docker 的 FerretDB 模板,在 v2.3 中更是提供了原生部署支持。目前,Pigsty 社区已经与 FerretDB 社区成为了合作伙伴,后续将进行深度的合作与适配支持。

本文简单介绍了 FerretDB 的安装、部署与使用。


配置

在部署 Mongo (FerretDB) 集群前,你需要先在配置清单中使用相关参数定义好它。下面的例子将默认的单节点 pg-meta 集群的 meta 数据库作为 FerretDB 的底层存储:

ferret:
  hosts: { 10.10.10.10: { mongo_seq: 1 } }
  vars:
    mongo_cluster: ferret
    mongo_pgurl: 'postgres://dbuser_meta:DBUser.Meta@10.10.10.10:5432/meta'

这里 mongo_clustermongo_seq 属于不可或缺的身份参数,对于 FerretDB 来说,还有一个必须提供的参数是 mongo_pgurl,指定了底层 PG 的位置。

您可以使用 服务 来接入高可用的 PostgreSQL 集群,并部署多个 FerretDB 实例副本并绑定 L2 VIP 以实现 FerretDB 层本身的高可用。

ferret-ha:
  hosts:
    10.10.10.45: { mongo_seq: 1 }
    10.10.10.46: { mongo_seq: 2 }
    10.10.10.47: { mongo_seq: 3 }
  vars:
    mongo_cluster: ferret
    mongo_pgurl: 'postgres://test:test@10.10.10.3:5436/test'
    vip_enabled: true
    vip_vrid: 128
    vip_address: 10.10.10.99
    vip_interface: eth1

管理

创建Mongo集群

在配置清单中定义好MONGO集群后,您可以使用以下命令完成安装。

./mongo.yml -l ferret   # 在 ferret 分组上安装“MongoDB/FerretDB”

因为 FerretDB 使用了 PostgreSQL 作为底层存储,所以重复运行此剧本通常并无大碍。

移除Mongo集群

要移除 Mongo/FerretDB 集群,运行 mongo.yml剧本的子任务:mongo_purge,并使用 mongo_purge 命令行参数即可:

./mongo.yml -e mongo_purge=true -t mongo_purge

安装MongoSH

您可以使用 MongoSH 作为客户端工具访问 FerretDB 集群

cat > /etc/yum.repos.d/mongo.repo <<EOF
[mongodb-org-6.0]
name=MongoDB Repository
baseurl=https://repo.mongodb.org/yum/redhat/$releasever/mongodb-org/6.0/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://www.mongodb.org/static/pgp/server-6.0.asc
EOF
yum install -y mongodb-mongosh

当然,您也可以直接安装 mongosh 的 RPM 包:

rpm -ivh https://mirrors.tuna.tsinghua.edu.cn/mongodb/yum/el7/RPMS/mongodb-mongosh-1.9.1.x86_64.rpm

连接到FerretDB

你可以使用 MongoDB 连接串,用任何语言的 MongoDB 驱动访问 FerretDB,这里以上面安装的 mongosh 命令行工具为例:

mongosh 'mongodb://dbuser_meta:DBUser.Meta@10.10.10.10:27017?authMechanism=PLAIN'mongosh 'mongodb://test:test@10.10.10.11:27017/test?authMechanism=PLAIN'

Pigsty 管理的 PostgreSQL 集群默认使用 scram-sha-256 作为默认的认证方式,因此,您必须使用 PLAIN 认证方式连接至 FerretDB。参阅 FerretDB:认证[17] 获取详细信息。

你也可以使用其他 PostgreSQL 用户来访问 FerretDB,只要在连接串中指定即可:

mongosh 'mongodb://dbuser_dba:DBUser.DBA@10.10.10.10:27017?authMechanism=PLAIN'

快速上手

你可以连接到 FerretDB 并假装它是一个 MongoDB 集群。

$ mongosh 'mongodb://dbuser_meta:DBUser.Meta@10.10.10.10:27017?authMechanism=PLAIN'

MongoDB 的命令会被翻译为SQL命令,在底下的 PostgreSQL 中执行:

use test                            # CREATE SCHEMA test;
db.dropDatabase()                   # DROP SCHEMA test;
db.createCollection('posts')        # CREATE TABLE posts(_data JSONB,...)
db.posts.insert({                   # INSERT INTO posts VALUES(...);
    title: 'Post One',body: 'Body of post one',category: 'News',tags: ['news', 'events'],
    user: {name: 'John Doe',status: 'author'},date: Date()}
)
db.posts.find().limit(2).pretty()   # SELECT * FROM posts LIMIT 2;
db.posts.createIndex({ title: 1 })  # CREATE INDEX ON posts(_data->>'title');

如果你不是很熟悉 MongoDB,这里有一个快速上手教程,同样适用于 FerretDB: Perform CRUD Operations with MongoDB Shell[18]

如果你希望生成一些样例负载,可以使用 mongosh 执行以下的简易测试剧本:

cat > benchmark.js <<'EOF'
const coll = "testColl";
const numDocs = 10000;

for (let i = 0; i < numDocs; i++) {  // insert
  db.getCollection(coll).insert({ num: i, name: "MongoDB Benchmark Test" });
}

for (let i = 0; i < numDocs; i++) {  // select
  db.getCollection(coll).find({ num: i });
}

for (let i = 0; i < numDocs; i++) {  // update
  db.getCollection(coll).update({ num: i }, { $set: { name: "Updated" } });
}

for (let i = 0; i < numDocs; i++) {  // delete
  db.getCollection(coll).deleteOne({ num: i });
}
EOF

mongosh 'mongodb://dbuser_meta:DBUser.Meta@10.10.10.10:27017?authMechanism=PLAIN' benchmark.js

你可以查阅 FerretDB 支持的 MongoDB命令,同时还有一些已知的区别,对于基本的使用来说,通常不是什么大问题。

  1. FerretDB uses the same protocol error names and codes, but the exact error messages may be different in some cases.
  2. FerretDB does not support NUL (\0) characters in strings.
  3. FerretDB does not support nested arrays.
  4. FerretDB converts -0 (negative zero) to 0 (positive zero).
  5. Document restrictions:
    • document keys must not contain . sign;
    • document keys must not start with $ sign;
    • document fields of double type must not contain Infinity, -Infinity, or NaN values.
  6. When insert command is called, insert documents must not have duplicate keys.
  7. Update command restrictions:
    • update operations producing Infinity, -Infinity, or NaN are not supported.
  8. Database and collection names restrictions:
    • name cannot start with the reserved prefix _ferretdb_;
    • database name must not include non-latin letters;
    • collection name must be valid UTF-8 characters;
  9. FerretDB offers the same validation rules for the scale parameter in both the collStats and dbStats commands. If an invalid scale value is provided in the dbStats command, the same error codes will be triggered as with the collStats command.

剧本

Pigsty 提供了一个内置的剧本: mongo.yml,用于在节点上安装 FerretDB 集群。

mongo.yml

该剧本由以下子任务组成:

  • mongo_check :检查 mongo 身份参数•mongo_dbsu :创建操作系统用户 mongod•mongo_install :安装 mongo/ferretdb RPM包•mongo_purge :清理现有 mongo/ferretdb 集群(默认不执行)•mongo_config :配置 mongo/ferretdb
  • mongo_cert :签发 mongo/ferretdb SSL证书
  • mongo_launch :启动 mongo/ferretdb 服务•mongo_register:将 mongo/ferretdb 注册到 Prometheus 监控中

监控

MONGO 模块提供了一个简单的监控面板:Mongo Overview

Mongo Overview

Mongo Overview: Mongo/FerretDB 集群概览

这个监控面板提供了关于 FerretDB 的基本监控指标,因为 FerretDB 底层使用了 PostgreSQL,所以更多的监控指标,还请参考 PostgreSQL 本身的监控。


参数

MONGO[24] 模块中提供了9个相关的配置参数,如下表所示:

参数 类型 级别 注释
mongo_seq int I mongo 实例号,必选身份参数
mongo_cluster string C mongo 集群名,必选身份参数
mongo_pgurl pgurl C/I mongo/ferretdb 底层使用的 PGURL 连接串,必选
mongo_ssl_enabled bool C mongo/ferretdb 是否启用SSL?默认为 false
mongo_listen ip C mongo 监听地址,默认留控则监听所有地址
mongo_port port C mongo 服务端口,默认使用 27017
mongo_ssl_port port C mongo TLS 监听端口,默认使用 27018
mongo_exporter_port port C mongo exporter 端口,默认使用 9216
mongo_extra_vars string C MONGO 服务器额外环境变量,默认为空白字符串

向量是新的 JSON

微信公众号原文 | 英文原文

向量是新的JSON”,这本身就是一种很有趣的说法。因为向量(Vector)是一种已经被深入研究过的数学结构,而 JSON 是一种数据交换格式。然而,在数据存储和检索的世界中,这两种数据表示方式都已经成为了各自领域的通用语言,成为(或即将成为)现代应用开发中必不可少的要素。如果按当下的趋势发展,向量将会像 JSON 一样,成为构建应用时的关键要素

生成型AI 引发的热潮促使开发者寻找一种简便的方法来存储与查询这些系统的输出。出于很多因素,PostgreSQL 成为了最自然的选择。但即使是生成型AI 炒翻天也无法改变这一事实:向量并不是一种新的数据模式,它作为一种数学概念已经存在数百年了,而机器学习领域也对其已有半个世纪多的研究。向量的基础数据结构 —— 数组,几乎在所有初级导论性质的计算机科学课程中都会讲授。连 PostgreSQL 对向量运算的支持也已经有20多年的历史了!

高中数学知识:向量的余弦距离与相似度

那有什么东西是新的呢?其实是 AI/ML 算法的 易用性(Accessibility),以及如何将一些“真实世界”的结构(文本、图像、音频、视频)用向量的形式表示,并将其存储起来,以供应用实现一些有用的功能。有些人可能会说,把这些AI系统的输出(也就是所谓的“嵌入 Embedding”)放进数据存储系统中并不是什么新把戏。所以这里我们得再次强调,真正的新模式是 易用性:几乎所有应用都可以用这种近乎实时的方式查询并返回这些数据(文字图片音视频的向量表示)。

不过,这些与 PostgreSQL 有什么关系?那关系可大了!高效存储检索向量 —— 这种普适泛用数据类型,可以极大地简化应用程序开发,让相关联的数据都存放在同一个地方,并让人们继续使用现有的工具链。我们在十多年前的 JSON 上看到了这一点,现在我们在向量上也看到了这一点。

要理解为什么向量是新的 JSON,让我们回顾一下 JSON —— 互联网通信的事实标准,当 JSON 崭露头角时发生了什么?


JSON 简史:PostgreSQL 实现

在 “JSON崛起” 期间,我主要还是一名应用开发者。我正在构建的系统,要么是将 JSON 数据发送到前端,使其可以完成某种操作(例如渲染一个可更新的组件),要么是与返回 JSON 格式数据的“现代”API交互。JSON 的好处在于其简单性(很容易阅读和操作),作为一种数据交换格式具有很强的表达力。JSON 确实简化了系统间的通信,无论是从开发还是运维的角度。但我是希望在JSON中看到一些我喜欢的东西 —— 在数据库这一侧,我是使用模式(Schemas)的坚定支持者。

虽然 JSON 最初是作为一种交换格式而存在的,但人们确实会问 “为什么我不能直接存储和查询这玩意?” 这个问题引出了一种专门的数据存储系统 —— 可以用来存储和查询 JSON 文档。我确实试过好几种不同的 专用 JSON 存储系统,来解决一个特定场景下的问题,但我并不确定我是否想把他们引入到自己的应用技术栈中 —— 出于性能与可维护性的原因 (我不会说具体是哪些,因为十多年过去,时过境迁了)。这就引出了一个问题 —— 能否在PostgreSQL中存储 JSON 数据?

PostgreSQL JSON 特性矩阵

我记得当年去参加 PostgreSQL 活动时的急切心情 —— 等待 PostgreSQL 对原生 JSON 存储检索支持的更新。我记得当 PostgreSQL 9.2 增加了基于文本的 JSON 类型支持时自己是多么的激动开心。PostgreSQL 对 JSON 最开始的支持是对所存储 JSON 内容的合法性校验,以及一些用于提取 JSON 文档数据的函数与运算符。那时候并没有原生的索引支持,但如果你需要根据文档中的某个 Key 进行频繁查询,还是可以使用 表达式索引 功能来为你感兴趣的 Key 添加索引。

PostgreSQL 对 JSON 的初步支持帮助我解决了一些问题,具体来说有:对数据库中几个表的状态做快照,以及记录我与之交互的 API 的输出。最初的基于文本的 JSON 数据类型在检索能力上乏善可陈:你确实可以构建表达式索引来根据 JSON 文档中的特定 Key 来走索引,但实践上我还是会把那个 Key 单独抽取出来放在与 JSON 相邻的单独列中。

这里的关键在于:PG 对 JSON 的初步支持以 “JSON数据库”的标准来看还是很有限的。没错,我们现在可以存储 JSON,也拥有了一些有限的查询能力,但要和专用 JSON 数据库拼功能,显然还需要更多的工作。不过对于许多这样的用例,PostgreSQL仍然已经是足够好了:只要能和现有的应用基础设施一起使用,开发者还是愿意在某种程度上接受这些局限性的。PostgreSQL 也是第一个提供 JSON 支持的关系型数据库,带了一波节奏,最终直接导致 JSON 进入到 SQL 标准中。

俄罗斯的 PostgreSQL 与 Oleg 对 PG JSON 特性居功至伟

紧接着 PostgreSQL 作为 “JSON数据库” 的可行性,在 PostgreSQL 9.4 发布后出现质变:这个版本新增了 JSONB 类型,这是 JSON 数据类型的二进制表示,而且可以使用 GIN 索引来索引 JSON 文档中的任意数据。这让 PostgreSQL 能在性能上与专用 JSON数据库旗鼓相当,同时还能保留有关系数据库的所有好处 —— 尽管适应并支持这类应用负载花费了 PostgreSQL 好几年的时间。

PostgreSQL 对 JSON 的支持在过去的几年中持续发展演进,随着PostgreSQL不断实现和采纳 SQL/JSON 标准,未来也一定会继续保持这种发展势头。我曾与一些 PostgreSQL 用户聊过,他们在 PostgreSQL 数据库中存了几十TB的 JSON 文档 —— 用户表示体验甚好!

这个故事的关键是,开发者愿意押注 PostgreSQL 会拥有一个具有竞争力的 JSON存储系统,并愿意接受其最初实现的局限性,直到更为强大稳健的支持出现。这就引出了我们要讨论的 向量


向量崛起:一种新 JSON

向量并不是新东西,但近来它们的流行度飙升。如前所述,这归功于AI/ML系统新涌现出的易用性,而这些系统的输出结果是向量。典型用例是在存储的数据(文本、声音、视频)上建立模型,并用模型将其转换为向量格式,然后用于“语义搜索”。

语义搜索工作原理如下:你把输入用模型转换为对应的向量,并在数据库中查找与此向量最为相似的结果。相似度使用距离函数进行衡量:比如欧式距离,或余弦距离,结果通常会按距离排序取 TOP K,即 K 个最为相似的对象(K-NN, k nearest neighbors)。

向量的余弦距离被广泛用于衡量两者的相似度

用模型将“训练集”编码为向量需要耗费很长的时间,所以把这些编码结果 “缓存” 在持久化数据存储 —— 比如说数据库中是有意义的,然后你就可以在数据库中运行 K-NN 查询了。事先在数据库里准备好一组备查的向量,通常会为语义搜索带来更好的用户体验,需要“向量数据库”的想法就是这么来的。

AI模型将各种对象统一编码为向量(浮点数组)

在PostgreSQL中存储向量不是一件新鲜事儿。1996 年 PostgreSQL 首次开源时就已经带有数组类型(Array)了!而且多年来又进行了无数的改进。实际上,PostgreSQL 中 数组 类型名称可能有些用词不当,因为它其实可以存储多维数据(例如矩阵/张量)。PostgreSQL 原生支持了一些数组函数,不过有一些常见的向量运算不在其中,比如计算两个数组间的距离。你确实可以写个存储过程来干这个事,但这就是把活儿推给开发者了。

PostgreSQL特性矩阵:数组与Cube

幸运的是,cube 数据类型克服了这些局限。cube 在PostgreSQL代码库中也已经有20多年了,并且是为在高维向量上执行运算而设计的。cube 包含了在向量相似性搜索中使用的大多数常见距离函数,包括欧几里得距离,而且可以使用 GiST索引来执行高效的 K-NN 查询!但是 cube 最多只能存储100维的向量,而许多现代AI/ML系统的维度远超这个数。

ChatGPT Embedding API 使用 1536 维向量

那么,如果 array 可以搞定向量维度的问题但没有解决向量运算的问题;而 cube 可以搞定运算但搞不定维度,我们该怎么办?


PGVECTOR: 开源PG向量扩展

可扩展性 是 PostgreSQL 的基石特性之一:PostgreSQL 提供创建新数据类型和新索引方法的接口。这让 pgvector 成为可能:一个开源 PostgreSQL 扩展,提供了一种可索引的 vector 数据类型。简而言之,pgvector 允许您在 PostgreSQL 中存储向量,并使用各种距离度量执行K-NN查询:欧式距离、余弦和内积。到目前为止,pgvector 带有一种新索引类型 ivfflat,实现了 IVF FLAT 向量索引。

当您使用索引来查询向量数据时,事情可能和您所习惯的 PostgreSQL 数据查询略有不同。由于在高维向量上执行最近邻搜索的计算成本很高,许多向量索引方法选择寻找与正确结果 “足够接近” 的 “近似” 答案,这将我们带入 “近似最近邻搜索”(ANN)的领域。ANN 查询的关注焦点是,性能与召回率两个维度上的利弊权衡,这里“召回率(Recall)”指的是返回相关的结果所占百分比。

pgvector 在 ANN Benchmark 各测试集下的召回率/性能曲线

让我们以 ivfflat 方法为例。构建 ivfflat 索引时,您需要决定有多少个 list 。每个 list 代表一个“中心”,这些中心会使用 k-means 聚类算法确定。确定所有中心后,ivfflat 会计算每一个向量最接近哪个中心点,并将其添加到索引中。当查询向量数据时,你还需要决定需要检查多少个中心,这由 ivfflat.probes 参数确定。这就是您所看到的 ANN性能/召回率权衡:你检查的中心越多,结果就会越精确,但性能开销就越大。

IVF FLAT 索引算法的的召回率取决于检查的中心数量

把 AI/ML 的输出存入 “向量数据库” 已经很流行了,至于 pgvector 也已经有大把的使用样例。所以这里我们将关注重点放在未来的发展方向上。


迈向明天:更好的向量支持

与 PostgreSQL 9.2 版本中的 JSON 情况类似,我们正处于如何在 PostgreSQL 中存储向量数据的初级阶段 —— 虽然我们在PostgreSQL和 pgvector 中看到的大部分内容都很不错,但它即将要好得多!

pgvector 已经可以处理许多常见的 AI/ML 数据用例 —— 我已经看到许多用户成功地使用它开发部署应用!—— 因此下一步是帮助它打江山。这与 PostgreSQL 中的 JSON 和 JSONB 的情况没有太大区别,但 pgvector 作为一个扩展,将有助于它更快地迭代。

pgvector 的 Github Star 增长在2023年4月出现加速

在 2023 年的 PGCon 上,这是一个聚集了许多内部开发者的 PostgreSQL 会议,我做了一个名为《向量是新的JSON[1]》的快速演讲,其中分享了使用案例,以及改进 PostgreSQL 和 pgvector 向量数据检索性能所面临的挑战。这是一些需要解决的问题(有些已经在做了!):包括给 pgvector 添加更多并行机制,对超过 2000 维向量的索引支持,以及尽可能使用硬件来加速计算。好消息是添加这些功能并不难,只需要开源贡献!

许多人对于把 PostgreSQL 当成向量数据库这件事充满兴趣(重点是 PG 还是一个全能数据库!)。我预计正如历史上的 JSON 一样,PostgreSQL 社区会找到一种支持这种新兴工作负载的方法,更为安全,更容易伸缩扩展。

我期待您能提供各种反馈 —— 无论是关于PostgreSQL 本身还是 pgvector ,还是关于您如何在 PostgreSQL 中处理向量数据,或者您希望如何在 PostgreSQL 中处理数据,因为这将帮助社区为向量查询提供最佳的支持。

本文译自《VECTORS ARE THE NEW JSON IN POSTGRESQL[2]》一文。

作者 JONATHAN KATZ ,译者 冯若航


译者评论

PostgreSQL 在过去十年间有着持续稳定的高速增长,从一个"相对来说小众"的数据库,成为如今全世界开发者中最流行,最受喜爱,需求量最大的数据库,不可谓不成功。PG 成功的因素有很多,开源,稳定,可扩展,等等等等。但我认为这里的关键一招还是 JSON 支持。笔者本人就是在 PostgreSQL 9.4 为其强大 JSON 功能折服,果断从 MySQL 跳车弃暗投明。

PostgreSQL 获得数据库三项大满贯冠军,且势头一往无前

拥有了 JSON 特性的 PostgreSQL 等于 MongoDBMySQL 合二为一,恰到好处地赶上了互联网下半场的风口。从 DB-Engine 热度趋势上也能看出,PostgreSQL 开始起飞的时间正是在 2014 年 发布 PostgreSQL 9.4 之后。2013 ~ 2023 这十年可以说是 PG 的黄金十年,无数强大的新功能与各式扩展插件喷涌而出,奠定了 PG 现今不可撼动的地位。

DB-Engine 热度走势,来自搜索引擎与网站的综合指数

而放眼未来十年,数据库的下一站会是哪里?本文给出了答案 —— 向量。正如同 JSON 一样,PostgreSQL 永远站在时代浪潮的巅峰引领潮流 —— 成为第一个提供全方位向量支持的关系型数据库。我有充足的把握断言:以向量为代表的功能将在接下来的十年中继续驱动 PostgreSQL 的高速增长。

pgvector 一定不会是 PostgreSQL 处理向量数据的终点,但它为 SQL 向量处理设定了一个标杆。PGVector 项目由 Andrew Kane 于 2021年4月创建,慢热了两年,而从今年三四月开始半年不到暴涨 4K star。而我也可以骄傲的说,作为 PG 社区的一员,我也在这里推波助澜,做了一些工作。

我们将 pgvector 提入 PostgreSQL PGDG 官方源,正式成为 PG向量扩展的事实标准;我们进行性能评测,引发了推上关于 PGVector 的大讨论;而我们所维护的开箱即用的开源 RDS PG 替代 Pigsty,则是第一波将 pgvector 集成整合提供服务的 PostgreSQL 发行版

Pigsty 凝聚 PG 生态合力,为用户提供开源免费开箱即用的本地 PostgreSQL RDS 服务

目前我们也在着力于改进 pgvector 的实现,实现了另一种主流向量索引算法 hnsw,在一些 ANN 场景下相比 IVFFLAT 有20倍的性能提升,而且完全兼容 pgvector 接口,并将于近期 Pigsty Release 提供预览。

pgvector 改进实现在 ANN-Benchmark 下的初步表现

最重要的是,我们相信 PostgreSQL 社区的力量,我们愿意凝聚合力,劲往一处使,共同让 PostgreSQL 走得更快、更远,让 PostgreSQL 在 AI 时代再创辉煌!


References

[1] 向量是新的JSON

[2] VECTORS ARE THE NEW JSON IN POSTGRESQL

[3] AI大模型与向量数据库 PGVECTOR

[4] PostgreSQL:世界上最成功的数据库

[5] 更好的开源RDS替代:Pigsty

[6] Pigsty v2.1 发布:向量扩展 / PG12-16 支持

PostgreSQL:最成功的数据库

微信公众号原文 | 知乎原文

2023 年 StackOverflow 调研结果已经新鲜出炉,来自185个国家与地区的9万名开发者给出了高质量的反馈。 在今年的调研中,PostgreSQL 在数据库全部三项调研指标(流行度,喜爱度,需求度)上获得无可争议的全能冠军,成为真正意义上“最成功”的数据库 —— “PostgreSQL is the Linux of Database!”

https://demo.pigsty.cc/d/sf-db-survey

当我们说一个数据库“成功”时,究竟在说什么?评价一个数据库有许多标准:功能、质量、安全、性能、成本,但没有哪种可以普世泛用。不过 Succeed 既代表成功,又代表继承,所以成功与“后继有人”相通。对一项技术而言,用户的规模 、喜好、需求决定了生态的繁荣程度,唯有这种最终存在意义上的神意裁决 —— 才能让所有人心服口服。 而连续进行七年的 StackOverflow 年度开发者调研为我们窥见技术发展流行趋势打开了一扇窗户。

PostgreSQL现在是全世界最流行的数据库

PostgreSQL是开发者最喜爱欣赏的数据库!

PostgreSQL是用户需求最为强烈的数据库!

流行度代表过去,喜爱度代表现在,需求度代表将来,这三个指标很好地反映了一项技术的生命力。存量与增量,时与势都站在 PostgreSQL 一侧,恐怕在几年内恐怕都不会有任何能挑战 PostgreSQL 地位的竞争对手。 作为 PostgreSQL 忠实的用户,社区成员,专家,布道师与贡献者,从拥抱 PostgreSQL的那一刻起,我就相信会有这一天,然而亲自见证这一刻,仍然让我感慨良多。遂撰此文,聊一聊这件事背后的 WhyWhat

推荐阅读:StackOverflow 2022 往期调研结果回顾:《为什么PostgreSQL将成为最成功的数据库?


数据的来源:社区调研

数据库的用户是开发者,而没有比直接问开发者们更有代表性的调研方式了。StackOverflow 调研结果中提供了 流行,欣赏,渴望三个结果指标,但这三项数据都来自同一个巧妙设计的问卷题目:

“在过去一年中,您在哪些数据库环境中进行了密集的开发工作,您又希望在接下来一年在哪些数据库上工作?如果你过去一年用了这个数据库,来年还希望接着用,那么就在两个复选框上都打勾”。

“Which database environments have you done extensive development work in over the past year, and which do you want to work in over the next year? If you both worked with the database and want to continue to do so, please check both boxes in that row.”

每个数据库后都有两个复选框,如果开发者在第一个框上打勾,即去年我在用此数据库,那么就会被标记为“使用者”(Used); 如果开发者在第二个框上打勾,即来年我想用这个数据库,那么会被标记为“需求者”(Wanted);而两个框都打勾的开发者,会被标记为“赞赏者”(Loved / Admired)。

https://survey.stackoverflow.co/2023

使用者占总体的比例,就是流行度,或使用率,在上图左边用柱状图表示。需求者占总体的比例,就是需求度,或渴望度,在上图右边以蓝点表示。 赞赏者占现有使用者的比例,就是欣赏度,或喜爱度/口碑,在上图右边以红点表示。不难看出,2023年,PostgreSQL 在流行度上甩开 MySQL,成为世界上最流行的数据库。在需求度和口碑上更是远远甩开其他数据库独树一帜。

同样的问题连续问了七年,如果我们结合这过去七年的变迁,把排名前10的主流数据库流行度 - 净喜爱度画在一张二维散点图上,那么就能更容易地获得一些关于数据库领域的发展变迁的洞察,对形成正确的比例感很有帮助。

X轴为流行度,Y轴为净喜爱程度(2*喜爱度% - 100),图元大小与流行度与喜爱度的几何平均数成正比。

在 2023年的当下切面中,四个角落被四种数据库占据:右上角是最为流行且最受欢迎的 PostgreSQL,右下角是流行但不受待见的 MySQL; 左上角是流行程度一般但备受喜爱的 Redis,左下角是过气且不受待见的 Oracle。在四者中间,坐落着相对中庸的 SQLiteMongoDBSQL Server

结合时间轴不难看出,PostgreSQL 的流行程度与受欢迎程度在持续增长;MySQL 的受欢迎程度变化不大但流行度暴跌; Redis 与 SQLite 整体上在进步,而 MongoDB 开始见顶回落,SQL Server 和 Oracle 这两种商业关系型数据库最近几年都在持续走下坡路。

从图中我们可以得出一个基本的判断:在未来几年中,数据库领域都不会出现足以挑战 PostgreSQL 的对手。PostgreSQL 在数据库领域的地位,已经如同 Linux 在服务器操作系统上的地位一样难以撼动


过去的积累:流行度

PostgreSQL —— 世界上最流行的数据库

一项技术使用者占总体的比例,就是流行度。它的含义是:过去一年有多少比例的用户使用了这项技术。流行度代表过去一年的积累使用,是存量指标,也是最核心的事实指标。

在 2023 年, “最先进” PostgreSQL 在所有开发者中以 45.6% 的使用率,首次超过“最流行”数据库 MySQL 41.1%,领先 4.5%,使用率是第二名 MySQL 的1.1倍。 对于专业开发者(约占总样本的3/4)来说,PostgreSQL 的使用率在去年(2022)就已经超过 MySQL 了,以 46.5% vs 45.7% 领先0.8个百分点; 在 2023 年,这一差距进一步拉大到 49.1% vs 40.6,领先 8.5% —— 换句话说,专业开发者中,PostgreSQL 的使用率已经是 MySQL 的 1.2 倍了。

过去几年,MySQL 一直霸占着数据库流行榜的榜首,洋洋得意地打起了“世界上最流行的开源关系型数据库” 这一旗号。 不过这次,“最流行” 的桂冠真的要让给 PostgreSQL 了。在流行度上,其他数据库和 PostgreSQL / MySQL 比根本就不是一个重量级,自然就更不用说了。

更重要的的是变化趋势:在长期列入排名的十几款头部数据库中,只有 PostgreSQL 的流行度是持续上升的,保持着高歌猛进的增长势头,而其他所有的数据库使用率都在下行。 此消彼长,随着时间的推移,PostgreSQL 与其他数据库的流行度差距只会进一步拉大 —— 因此在相当长的一段时间内,恐怕是看不到有任何挑战者能撼动 PostgreSQL 现在的位置了。

值得一提的是,“国产数据库”的标杆 ”TiDB“ 这次也加入到 StackOverflow 排行榜中,并以 0.2% 的使用率,拿到了末位第 32 名的名次。

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


现在的动能:喜爱度

PostgreSQL —— 最受开发者喜爱的数据库

所谓“口碑”,喜爱度(Loved)或欣赏度(Admired),指的是有多少比例的用户愿意继续使用此项技术,这是一个年度的“留存率”指标,可以反映用户对一项技术的看法与评价。

2023 年, PostgreSQL 蝉联最受开发者喜爱的数据库。过去几年 Redis 一直是用户最喜欢的数据库。直到 2022 年,PostgreSQL 第一次超过 Redis,成为最受开发者喜爱的数据库。 PostgreSQL 和 Redis 的口碑一直在伯仲之间(70%),并与其他后来者拉开了非常显著的差距。

作为一个交叉印证,在 2022 PostgreSQL 社区年度调研中,对于 PostgreSQL 的存量用户来说,使用程度加深,用量加大的比例(蓝/粉)对于用量萎缩的比例(黄绿)占据了压倒性多数,足以说明基本盘留存的稳定程度。

Redis是简单易用的数据结构缓存服务器,经常会与关系型数据库 PostgreSQL 搭配使用,广受开发者喜爱(但流行度一般,只有20%,位列第六)。 在后面的交叉分析环节我们也可以看到这两者之间有着所有数据库间最为强烈的羁绊 —— 86% 的 Redis 用户想要使用 PostgreSQL,而 30% 的 PostgreSQL 用户想要使用 Redis。 其他评价正面的数据库包括:SQLite,MongoDB,SQL Server 等。MySQL 和 ElasticSearch 的口碑在 50% 中线算毁誉参半。榜上最不受用户待见的数据库为 Access、 IBM DB2 、CouchDB,Couchbase,以及 Oracle。

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


未来的趋势:需求度

PostgreSQL —— 需求量最大的数据库

需求者占总体的比例,就是需求率(Wanted),或渴望度(Desired)。它的含义是,接下来一年有多少比例的用户会实际选择使用此项技术。 在需求度 / 渴望度 这一项中,PostgreSQL 一骑绝尘,远远甩开其他数据库。以 42.3% 的比例连续第二年获得第一,且保持着一往无前的增长态势。不断与后来者拉开距离。

在 2023 年,一些数据库的需求量出现了显著增长。大概率是因为由 OpenAI ChatGPT 所引领的大语言模型AI浪潮所致:对智能的需求拉动了对数据基础设施的需求。 10年前,对 JSONB/GIN 等 NoSQL 特性的支持奠定了 PostgreSQL 在互联网黄金时代的蓬勃发展,而今天,第一个构建在成熟数据库上的向量扩展 pgvector ,更是让 PostgreSQL 有了进入 AI 时代的船票,为下个十年的增长准备好了敲门砖。


但是,为什么呢?

PostgreSQL 在需求率, 使用率,喜爱率上都拔得头筹,天时地利人和齐备,动能势能潜能都有,足以称得上是最成功的数据库,而且在肉眼可见的几年里也不会有任何挑战者。 但令人好奇的是,为什么 PostgreSQL 会如此成功 ? 其实,秘密就藏在它的 Slogan 里:“世界上最先进的开源关系型数据库

关系型数据库是如此的普及与重要,也许其他的数据库品类如键值,文档,搜索引擎,时序,图,向量加起来也比不上它的一个零头。以至于当大家谈起数据库时,如果没有特殊说明,默认隐指的就是”关系型数据库“。在它面前,没有其他数据库品类敢称自己为”主流“。 在去年的《为什么PostgreSQL将成为最成功的数据库?》中,我们详细介绍了关系型数据库的竞争格局 —— 三足鼎立:关系型数据库的生态位高度重叠,其关系可以视作零和博弈。抛开微软生态关门自嗨相对独立的商业数据库 SQL Server 不提,在当下分久必合的收敛阶段中,以 WireProtocol 计能作为“根”的数据库只有三种:Oracle,MySQL,以及PostgreSQL。关系型数据库世界里上演的是一场 三国演义

今天下三分,然 Oracle/MySQL 疲敝 ,日薄西山, PostgreSQL 高歌猛进,如日中天。此消彼长,前途无量。

Oracle 有才无德,MySQL 才浅德薄,PGSQL 德才兼备”

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

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

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


开源与先进

来自 TimescaleDB 的PostgreSQL 社区年度调研也反映出,用户选择 PostgreSQL 的首要因素便是 开源稳定开源 —— 意味着软件本身可以免费使用,可以二次开发,没有供应商锁定,不存在“卡脖子问题”。 可靠 —— 意味它能正确稳定工作,行为表现能够符合预期,而且有着长时间大规模生产环境的优异战绩。越是资深的开发者,便越是看重这两个属性。

宽泛地讲,扩展,生态,社区,协议可以归并入 “开源” 。而稳定可靠,ACID,SQL,扩展,可用性,可以总结为 “先进” 。这便正好与 PostgreSQL 的 Slogan 相呼应 —— 世界上最先进的开源关系型数据库

https://www.timescale.com/state-of-postgres/2022


开源之德

PG的“德”在于开源。祖师爷级的开源项目,全世界开发者群策群力的伟大成果。协议友善BSD,生态繁荣扩展多。开枝散叶,子孙满堂,Oracle替代扛旗者.

什么叫“德”,合乎于“道”的表现就是德。而这条“道”就是开源。PostgreSQL是历史悠久的祖师爷级开源项目,更是全世界开发者群策群力的典范成果。

很久很久以前,开发软件/信息服务需要使用非常昂贵的商业数据库软件。单花在软件授权上的费用可能就有六七位数,加之相近的硬件成本与服务订阅成本。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 “二开/魔改/套壳/换皮”,华为的openGauss 与 GaussDB 就是最典型的例子。 重要的是,PostgreSQL 使用 BSD-Like 的 PostgreSQL 协议,是允许这种行为的 —— 你只要不打着PG的名号招摇撞骗,改个名字直接卖起来都行。这样开放的胸襟,是被Oracle收购的,使用GPL协议的 MySQL 所难以比拟的。


先进之才

PG的“才”在于先进。一专多长,全栈多模:“自主可控自动驾驶时序地理空间AI向量分布式文档图谱全文检索可编程超融合联邦流批一体 HTAP Serverless 全栈式平台数据库”,单一组件即可覆盖几乎所有数据库需求。

PostgreSQL 不仅仅是传统意义上只能做 OLTP 的单纯 “关系型数据库”,而是一个多模态数据库。 对于中小企业来说,基本单一组件便足以覆盖中小型企业绝大多数场景的数据需求:OLTP,OLAP,时序,地理空间GIS,分词与全文检索,JSON/XML文档,NoSQL特性,图,向量,全都能用上。

皇帝数据库 —— 自主可控自动驾驶时序地理空间AI向量分布式文档图谱全文检索可编程超融合联邦流批一体 HTAP Serverless 全栈式平台数据库

PostgreSQL 的先进,除了体现在其备受赞誉的内核稳定性上,更是体现在它强大的可扩展性里。 插件系统让 PostgreSQL 不再仅仅是一个单线程演化的数据库内核,而是可以有无数并行演进的扩展插件,如同量子计算一般同时探索所有方向上的可能性。每一个数据处理的细分垂直领域,PostgreSQL 绝不会缺席。

正如:PostGIS 之于地理时空数据库,TimescaleDB 之于时序数据库,Citus 之于分布式/列存储/HTAP数据库,PGVector 之于AI向量数据库,AGE之于图数据库,PipelineDB 之于流处理; 以及终极杀招 —— 使用外部数据源包装器(FDW),使用统一的 SQL 访问所有异构的外部数据库。可以说PG是真正的全栈数据库平台,比起 MySQL 这样单纯的 OLTP 数据库,它的功能要先进太多了。

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

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

PostgreSQL 的先进,更是体现在其繁荣的生态里。以数据库内核为中心,向上,有着衍生特化的变体与构建于其上的“上层数据库” —— Greenplum数据仓库,Firebase的开源替代 Supabase,专用图数据库 edgedb 等等等等。 向下,有着各种开源/商业/云发行版来整合各种工具形成合力 —— 各家的RDS ,开箱即用的 Pigsty ;水平方向上,甚至还有着一些强大的拟态组件/版本,可以通过兼容 Wire Protocol 的方式来仿真其他数据库,无需修改客户端驱动就能完成数据库迁移 —— 模拟 SQL Server 的 babelfish,模拟 MongoDB 的 FerretDB,兼容 Oracle 的 EnterpriseDB / IvorySQL 都是样例。

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

先进,是 PostgreSQL 压倒 MySQL 的核心竞争力。

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

然而时来天地皆同力,运去英雄不自由。时过境迁,PostgreSQL 进步神速,在”快“与”猛“上 MySQL 已经不占优,现在只剩下”“了。

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

先进的因会反映为流行的果,流行的东西因为落后而过气,而先进的东西会因为先进变得流行。在这个变革的时代中,没有先进的功能打底,“流行”也也难以长久。时代所赋予的红利,也会随时代过去而退潮。 调查的结果也用事实证明,MySQL 唯一能引以为豪的 “流行” 在 PostgreSQL 压倒性的 “先进” 优势前,根本维持不住。

先进开源,就是 PostgreSQL 成功的最大法宝。Oracle 先进, MySQL 开源,PostgreSQL 先进又开源。天时地利人和齐备,何愁大业不成?


展望未来

PostgreSQL 数据库内核在数据库领域的生态位,类似于 Linux 操作系统内核在操作系统领域的生态位。 对于数据库,至少是 OLTP 数据库来说,数据库内核之争已经尘埃落定 —— PostgreSQL 已经是一台足够完美的内核发动机。

然而,用户最终需要的不单单是一台发动机,而是整车、驾驶能力与交通服务。数据库领域竞争的焦点,已经从 Software 本身,转移到了 Software enabled Service —— 完整的数据库发行版与数据库服务。 对于基于 PostgreSQL 内核的数据库发行版而言,竞争才刚刚开始。谁会成为PG的Debian,RedHat 与 Ubuntu ? 这便是我们做 Pigsty 的初衷 —— 制作一个开箱即用的、开源免费、本地优先的 PostgreSQL 数据库发行版,让所有人都能用好数据库用好数据库。 当然,限于篇幅,那就是另一篇要介绍的故事了。


参考阅读

2022-08 《PostgreSQL 到底有多强?

2022-07 《为什么PostgreSQL是最成功的数据库?

2022-06 《StackOverflow 2022数据库年度调查

2021-05 《Why PostgreSQL Rocks!

2021-05 《为什么说PostgreSQL前途无量?

2018 《PostgreSQL 好处都有啥?

2023 《更好的开源RDS替代:Pigsty

2023 《StackOverflow 7年调研数据跟踪

2022 《PostgreSQL 社区状态调查报告 2022

PostgreSQL 到底有多强?

微信公众号原文 | 知乎原文

上一回,我们通过分析 StackOverflow 的用户调研数据,说明了《为什么PostgreSQL是最成功的数据库》。

而这一次我们将用性能数据来说话,聊聊最成功的 PostgreSQL 到底有多强,帮助大家做到“心中有数”。


太长不看

如果您对以下这些问题有兴趣,那么本文会对您有所帮助:

  • PostgreSQL 到底性能有多强? 点查 QPS 60万+,最高达 200 万。读写 TPS (4写1读)每秒 7 万+,最高达14万。
  • PostgreSQL 与 MySQL 的极限性能对比 极限条件下,PgSQL点查性能显著压倒 MySQL,其他性能基本与MySQL持平。
  • PostgreSQL 与其他数据库的性能对比 “分布式数据库”/NewSQL 在相同硬件规格下的性能表现显著落后于经典数据库。
  • PostgreSQL 与其他分析数据库的 TPC-H 表现。 PostgreSQL 原生作为一个 HATP 数据库,有比较亮眼的分析表现。
  • 云数据库 / 云服务器 的成本到底有没有优势? c5d.metal 用1年的价格,可以把服务器买下来托管用5年。对应规格云数据库用1年的价格,可以供你买同样的EC2用20年

详细测试过程与原始数据放置于:github.com/Vonng/pgtpc


PGBENCH

软件与硬件的技术日新月异,尽管性能评测的文章汗牛充栋,却没有多少能反映这些变换。在这项测试中,我们选择了两种新规格硬件,使用 PGBENCH 测试了最新的 PostgreSQL 14.5 在这些硬件上的性能表现。

测试的主体包括四种规格的硬件,两台 Apple 笔记本与三台 AWS EC2云服务器,分别是 2018 年使用 Intel 6核 i9芯片的 15寸顶配 Macbook Pro,2021 年使用 M1 MAX 芯片的顶配 16 寸 Macbook Pro ,AWS z1d.2xlarge (8C 64G),以及 AWS c5d.metal ,这些都是市面上可以轻松买到的商用硬件。

PGBENCH是 PostgreSQL 自带的压测工具,默认使用类 TPC-B 的查询,可用于评估 PostgreSQL 及其兼容版数据库的性能。测试分为两种:只读查询 RO、以及读写 RW。只读查询包含一条 SQL,随机从1亿条数据库中挑选一条查出;而读写事务包含5条SQL语句,一条查询、1条插入与三条更新。测试基于 s=1000 的数据集规模,使用 PGBENCH 逐步增加客户端连接数,找到 QPS / TPS 的极大值点,并记录持续测试 3-5 分钟后的稳定均值,结果如下:

No Spec Config CPU Freq S RO RW
1 Apple MBP Intel 2018 Normal 6 2.9GHz - 4.8GHz 1000 113870 15141
2 AWS z1d.2xlarge Normal 8 4GHz 1000 162315 24808
3 Apple MBP M1 Max 2021 Normal 10 600MHz - 3.22GHz 1000 240841 31903
4 AWS c5d.metal Normal 96 3.6GHz 1000 625849 71624
5 AWS c5d.metal Extreme 96 3.6GHz 5000 1998580 137127

Read Write

pg-performence-2.jpg

图:各硬件配置下读写 TPS 上限

pg-performence-3.png

图:各硬件配置下读写 TPS 曲线

Read Only

pg-performence-4.png

图:各硬件配置下点查 QPS 上限

pg-performence-5.png

图:各硬件配置下点查 QPS - 并发曲线

结果相当令人震惊,在 Apple M1 Max 10C 笔记本上,PG 跑出了 32K 读写,240K 点查的性能水平,在 AWS c5d.metal 生产物理机上,PG 跑出了 72K 读写,630K 点查的性能。使用极限优化压榨,最多可以达到 单机 137K 读写,2M 点查 的怪兽级性能。

作为一个粗略的规格参考,探探作为一个前部的互联网App,PostgreSQL 全局 TPS 为 40万左右。这意味着十几台这样的新笔记本,或几台顶配服务器(10W内¥)就有潜力支撑起一个大型互联网应用的数据库服务,这对于以前来说是难以想象的。

关于成本

以宁夏区域,C5D.METAL 机型为例,该机型是目前综合算力最好的物理机,且自带 3.6 TB的本地NVME SSD存储,有7种可选的付费模式:

付费模式 月度 预付 折合每年
按需付费 31927 0 383,124
标准预留,1年,无预付费用 12607 0 151,284
标准预留,1年,预付部分 5401 64,540 129,352
标准预留,1年,预付全部费用 0 126,497 126,497
可转化预留,3年,无预付费用 11349 0 136,188
可转化预留,3年,预付部分 4863 174,257 116,442
可转化预留,3年,预付全部费用 0 341,543 113,847

折合每年成本在 11万 ~ 15万,零售按需每年成本38万。该机器如果自行购置,IDC托管代维网电五年综合成本应在10万内。尽管看上去云硬件的年化成本高达自建的五倍,但考虑到其灵活性,折扣优惠与抵扣券,AWS EC2 云服务器定价总体仍处于合理范围。使用此类云硬件自建数据库,也有非常优异的性能表现。

但 RDS for PostgreSQL 则完全是另一个故事了,如果您想使用类似规格的云数据库,最接近的规格是 db.m5.24xlarge,96C,384G,配置 3.6T / 80000 IOPS 的 io1存储(c5d.metal 3.6T NVME SSD 8K RW IOPS 大约95K左右,普通 io1 存储最高 IOPS 为 80K),则每月成本为 24万¥,每年成本为286,7630¥ ,是同规格 EC2 自建的近 20 倍

AWS价格计算器:https://calculator.amazonaws.cn/


SYSBENCH

PostgreSQL 确实很强,但与其他数据库系统相比则何如?PGBENCH 主要用于评估 PostgreSQL 及其衍生/兼容数据库的性能,但如果需要横向比较不同数据库的性能表现,我们就要用到 sysbench 了。

sysbench 是一款开源、跨平台的多线程数据库性能测试工具,测试结果可以很有代表性地反映一个数据库系统的事务处理能力能力。sysbench 包含了10个典型测试用例,如测试点查性能的 oltp_point_select,更新性能的 oltp_update_index,综合读写事务性能的 oltp_read_only (16条查询一个事务),oltp_read_write (20条混合查询一个事务)与oltp_write_only (6条写入SQL)等…。

sysbench 既可以用于测试 MySQL 的性能,也可以用来测试 PgSQL 的性能(当然也包括两者的兼容衍生),因此具有良好的横向可比性。让我们先来看一下最为喜闻乐见的对比,开源关系数据库内战:世界上“最流行”的开源关系型数据库 —— MySQL , 与世界上最先进的开源关系型数据库 —— PostgreSQL 性能横向对比。

Dirty Hack

MySQL 并没有提供一个官方的 sysbench 测试结果,只是在官网上贴出了一个第三方评测结果的图片与链接,不加解释地暗示 MySQL 可以做到 1M 的点查 QPS,240K 的索引键更新,约 39K 的复合读写TPS。

pg-performence-5.png

图:https://www.mysql.com/why-mysql/benchmarks/mysql/

这是相当不讲武德的行为。因为如果阅览了连接的评测文章就会发现:这是把所有 MySQL 安全特性关闭得到的结果:关闭Binlog,提交刷盘,FSYNC,性能监控,DoubleWrite,校验和,强制使用 LATIN-1 字符集,这样的数据库根本没法用于生产环境,只是为了刷分而刷分。

但反过来说,我们也可以使用这些 Dirty Hack,把对应的 PostgreSQL 安全特性也关闭,也看看 PostgreSQL 的最终极限在哪里?结果相当震撼,PGSQL点查QPS干到了 233万每秒,峰值远远甩开 MySQL 一倍还多。

pg-performence-6.png

图:不讲武德的Benchmark:PgSQL vs MySQL

pg-performence-7.png

PostgreSQL 极限配置下点查压测现场

必须说明的是,MySQL 的bench使用的是 48C 2.7GHz的机器,而PostgreSQL使用的是 96C 3.6GHz 的机器。不过因为PG使用进程模型,我们可以使用 c=48 的测试值作为 PG 在 48C 机器上表现的一个下限近似:对于只读请求,QPS峰值通常在客户端数略大于CPU核数时达到。即便如此,c=48 时PG的点查 QPS( 150万)仍然比MySQL峰值高了43%。

在此也期待 MYSQL 专家基于完全相同的硬件给出测评报告,更好的地进行对比。

pg-performence-8.png

图:MySQL 有结果的四项 sysbench 结果,c=48

在其他测试上,MySQL 也有不错的极限表现,otlp_read_only, oltp_update_non_index 都与 PostgreSQL (c=48)接近持平,甚至在 oltp_read_write 上还略微超过 PostgreSQL。

总体来说在极限条件下,PG除了点查上碾压了MySQL,其他测试上性能与 MySQL 基本持平。

Fair Play

尽管在功能丰富度上判若云泥,但 MySQL 在极限性能上基本能与 PostgreSQL 称得上大体旗鼓相当。那么其他的数据库,特别是新一代 NewSQL 的表现又如何呢?

能够在官网上给出 sysbench 测试报告的数据库都算是 Fair Play 的体面玩家,我们相信他们都是基于真实生产环境使用的配置进行的测试,因此不能和 MySQL 那样使用 Dirty Hack。这里我们依然使用 AWS c5d.metal 机型,但完全使用生产环境配置进行性能测试,相比极限性能有接近一半折损,但更为费厄泼赖,具有很强的可对比性。

我们从几种比较具有代表性的NewSQL数据库官网上收集到了官方的 sysbench 评测报告。并不是所有的数据库都给出了完整的 sysbench 10 项测试结果,而且硬件规格与表规格也参差不齐。不过考虑到几种数据库均使用基本相仿的硬件规格(100核上下的算力,PolarDB-X , YugaBytes 除外),数据规模也基本为 160M 记录(OB,YB除外),总体还是具有比较可观的横向可比性,也足以让我们管中窥豹形成直觉认知了。

Database PGSQL.C5D96C TiDB.108C OceanBase.96C PolarX.64C Cockroach Yugabyte
oltp_point_select 1372654 407625 401404 336000 95695
oltp_read_only 852440 279067 366863 52416
oltp_read_write 519069 124460 157859 177506 9740
oltp_write_only 495942 119307 9090
oltp_delete 839153 67499
oltp_insert 164351 112000 6348
oltp_update_non_index 217626 62084 11496
oltp_update_index 169714 26431 4052
select_random_points 227623
select_random_ranges 24632
Machine c5d.metal m5.xlarge x3 i3.4xlarge x3 c5.4xlarge x3 ecs.hfg7.8xlarge x3 ecs.hfg7.8xlarge x1 Enterprise c5d.9xlarge x3 c5.4xlarge x3
Spec 96C 192G 108C 510G 96C 384G 64C 256G 108C 216G 48C 96G
Table 16 x 10M 16 x 10M 30 x 10M 1 x 160M N/A 10 x 0.1M
CPU 96 108 96 64 108 48
Source Vonng TiDB 6.1 OceanBase PolarDB Cockroach YugaByte

pg-performence-10.png

图:sysbench 10项测试结果(QPS,越高越好)

pg-performence-11.png

按数据库分类,除以核数的归一化性能对比

让人感到震惊的是,新一代分布式数据库(NewSQL)全线拉胯。在相近的硬件规格下,与 PostgreSQL 表现出高达数量级的差距,几种新数据库中表现最好的反而是仍然基于经典主从架构的 PolarDB。这样的性能结果,难免不让人重新审视起分布式数据库与 NewSQL 的理念

通常来说,分布式数据库的核心利弊权衡是质量换规模,但让人没想到的是牺牲掉的不仅仅是功能与稳定性,还有如此可观的性能。高德纳曰:“过早优化是万恶之源”,为了不需要的规模(万亿级+,TP百TB+)牺牲如此大的性能(以及功能与稳定性)毫无疑问是过早优化的一种形式,而能有多少业务场景会有 Google 量级的数据非要分布式数据库不可,仍然是一个问号。


TPC-H分析性能

TP不行,AP来凑。尽管分布式数据库在 TP 领域如此拉胯,但数据分析 AP 才是分布式数据库的基本盘,因此很多分布式数据库喜欢炒作 HTAP 的概念。而衡量 AP 系统的能力,我们会用到 TPC-H 测试。

TPC-H 是一个模拟数仓,包含8张数据表,与22条复杂分析类SQL。衡量分析性能的标准通常是在指定仓数下执行这22条SQL的耗时。通常使用100仓,约100GB数据作为基准。我们在本地笔记本和小型AWS云服务器进行了 TPC-H 1,10,50,100 仓的测试,完成全部22个查询,耗时结果如下:

Scale Factor Time (s) CPU Environment Comment
1 8 10 10C / 64G apple m1 max
10 56 10 10C / 64G apple m1 max
50 1327 10 10C / 64G apple m1 max
100 4835 10 10C / 64G apple m1 max
1 13.5 8 8C / 64G z1d.2xlarge
10 133 8 8C / 64G z1d.2xlarge

作为横向对比,我们选取了一些其他数据库官网或比较详细的第三方测评结果。不过在对比前,有几点需要注意:一是有一些数据库产品仓数并非100,二来硬件规格也不尽相同,三来并不是所有数据库评测结果都来自原厂,因此只能作为大致的对照和参考

Database Time S CPU QPH Environment Source
PostgreSQL 8 1 10 45.0 10C / 64G M1 Max Vonng
PostgreSQL 56 10 10 64.3 10C / 64G M1 Max Vonng
PostgreSQL 1327 50 10 13.6 10C / 64G M1 Max Vonng
PostgreSQL 4835 100 10 7.4 10C / 64G M1 Max Vonng
PostgreSQL 13.51 1 8 33.3 8C / 64G z1d.2xlarge Vonng
PostgreSQL 133.35 10 8 33.7 8C / 64G z1d.2xlarge Vonng
TiDB 190 100 120 15.8 120C / 570G TiDB
Spark 388 100 120 7.7 120C / 570G TiDB
Greenplum 436 100 288 2.9 120C / 570G TiDB
DeepGreen 148 200 256 19.0 288C / 1152G Digoal
MatrixDB 2306 1000 256 6.1 256C / 1024G MXDB
Hive 59599 1000 256 0.2 256C / 1024G MXDB
StoneDB 3388 100 64 1.7 64C / 128G StoneDB
ClickHouse 11537 100 64 0.5 64C / 128G StoneDB
OceanBase 189 100 96 19.8 96C / 384G OceanBase
PolarDB 387 50 32 14.5 32C / 128G 阿里云
PolarDB 755 50 16 14.9 16C / 64G 阿里云

为了便于衡量,我们可以归一化核数与仓数,用 QPH ,即每小时,每核,执行1仓 TPC-H 查询可以执行多少轮,来近似评估数据库的相对分析性能。

pg-performence-12.png

QPH = (1 / 时长) * (仓数 / 核数) * 3600

22个查询耗时对于不同仓数来说并非完全线性关系,因此只可作为近似参考。

不过总体来说,即使是 10 核的笔记本跑 PostgreSQL,也可以有相当亮眼的分析成绩来

(注:50C以上已经超过内存,走SWAP与磁盘IO了)。

pg-performence-13.png

图:论文《how good is my HTAP system》提出的评测 HTAP系统能力的方法 —— 吞吐量前沿,在AP/TP二维平面上画出混合负载的吞吐量极值。

至少在百GB级的表上,PostgreSQL足以称得上是一款表现优秀的分析数据库。如果单表超过几TB量级,也可以平滑升级至 Greenplum / MatrixDB / DeepGreen 等 PostgreSQL 兼容MPP数仓。。采用主从复制的 PostgreSQL 可以通过级联从库的方式近乎无限地 Scale 读负载,采用逻辑复制的 PostgreSQL 可以内置/同步地完成AP模式ETL,可谓是真正的 HTAP 数据库。

综上所述,PostgreSQL 在 TP 领域表现极其亮眼,在 AP 领域表现可圈可点。这也难怪在最近几年的 StackOverflow 开发者年度调研中, PostgreSQL 成为了 专业开发者最常用,最受喜爱,最想要的三冠王数据库

pg-performence-14.png

StackOverflow 近六年数据库开发者调研结果


参考

[1] Vonng: PGTPC

[2] WHY MYSQL

[3] MySQL Performance : 1M IO-bound QPS with 8.0 GA on Intel Optane SSD !

[4] MySQL Performance : 8.0 and Sysbench OLTP_RW / Update-NoKEY

[5] MySQL Performance : The New InnoDB Double Write Buffer in Action

[6] TiDB Sysbench Performance Test Report – v6.1.0 vs. v6.0.0

[7] OceanBase 3.1 Sysbench 性能测试报告

[8] Cockroach 22.15 Benchmarking Overview

[9] Benchmark YSQL performance using sysbench (v2.15)

[10] PolarDB-X 1.0 Sysbench 测试说明

[11] StoneDB OLAP TCP-H测试报告

[12] Elena Milkai: “How Good is My HTAP System?",SIGMOD ’22 Session 25

[13] AWS Calculator

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

微信公众号原文

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

数据库的用户是开发者,而开发者的意愿、喜好、选择又如何?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

微信公众号原文


什么是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前途无量?

微信公众号原文

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

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

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


天下大势

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

DB-Engine 数据库流行度趋势 (注意这是对数坐标系)

在唯二两个头部开源关系型数据库 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)

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

PostgreSQL好处都有啥

微信公众号原文

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捏着蛋蛋。

PostgreSQL 可以替代 MongoDB 吗?

最近,一篇《从 MongoDB 到 PostgreSQL 的大迁移》引发了 MongoDB 用户的关注,在我们的用户群组里有朋友 @flyingcrp 问了这样一个问题 —— 为什么PG上的一个插件或者功能点就能顶的上别人一个完整的产品?当然也有持相反观点的朋友 —— PG的 JSON 性能肯定比不过细分领域的专业产品,这个讨论引起了我的兴趣,这些命题成立吗?

通常来说,讨论性能问题离不开具体的场景。我上一次和 MongoDB 打交道是在 2016 年 —— 我使用 Multicorn FDW 迁移了一套服务于生产在线业务的 MongoDB 到 PostgreSQL。把1TB出头规模的数据,搬到使用同样硬件的 PostgreSQL 上。在迁移的过程中,我对 MongoDB 留下了极差的印象 —— 我花费了很多时间清洗模式错乱的垃圾数据;在性能方面我已经忘掉了具体的数字,但还记得迁移至 PostgreSQL 后,性能有了突飞猛进的改善。之后的这么多年里,我没有再和 MongoDB 打过交道,确实也不知道 MongoDB 后面有没有什么长进。

于是,我做了一些简单的检索与研究,结果发现了一些非常有趣且震惊的结论:

  • 在关于 JSON 存储检索性能上,PG 吊打 MongoDB!
  • MongoDB 在正确性上比 MySQL 还要垃圾得多!
  • MongoDB 已经开始过气,流行度趋势下跌

我在 Reddit 上看到一个帖子,怒斥 MongoDB 的问题,最令我震惊的莫过于 MongoDB 还有这么一段轶事 —— 在 3.2 的时候,拿着嵌入的 PostgreSQL 作为其“分析引擎”。而且做法和我 2016 年搞数据迁移的方式如出一辙 —— 都是用 Multicorn 编写的 FDW。

关于性能

Performance Benchmark: PostgreSQL vs. MongoDB @ 2020

Can PostgreSQL with its JSONB column type replace MongoDB? @ 2023-08

关于正确性

MongoDB and Jepsen

JEPSEN MongoDB 4.2.6

关于流行度

StackOverflow Developer Survey 2017 - 2023

DB-Engine Ranking - Trend Popularity

关于社区

JSON/JSONB, FerretDB, mongo_fdw

The Great Migration from MongoDB to PostgreSQL

关于黑历史

Doubt regarding PostgreSQL vs Mongodb

MongoDB 3.2: Now Powered by PostgreSQL

小结

Why You Should Never Use MongoDB

群友讨论的时候抛出了一个有趣的问题,研究了一下发现 MongoDB 实在是太垃圾了,无论是性能还是正确性都被PG吊打。正好最近喷云厂商太多了,应该回归一下数据库主业,清明节准备写一篇批判 MongoDB 的文章。

看看能不能吸引些 MongoDB 的用户迁移到 PostgreSQL 上来。

迁移完成后测试了一下

我已经忘记具体的性能数字了,只是记得迁移万

性能这种要看具体场景的场景。作为一个特定经验:我在 2016 年的时候用 MongoDB FDW 迁移了一套 MongoDB 到同硬件上的 PG,性能反正好了很多。但这么多年过去了,我也不知道现在 MongoDB 怎么样了。

首先,MongoDB 公司被发现在性能方面大量撒谎。

然后人们发现该产品的数据质量也很差。

然后人们发现“无模式”实际上意味着你拥有数百万个模式。

然后人们发现MongoDB的报告子系统实际上是一个嵌入式的Postgres数据库。

然后人们发现缺乏连接实际上是一个巨大的问题。而且在繁忙的 MongoDB 集群上回填旧数据可能需要数周时间。

最后人们发现,在存储 json 的一系列基准测试中,Postgres 比 MongoDB 更快。

因此,从这一点来看,MongoDB 就像 NoSQL 世界中的 MySQL:有太多谎言、太多夸张、太多坏建议。我们中的许多人都希望永远不再看到 mongodb。

学习如何有效使用文档数据库并不需要成为营销部门的鹦鹉。这意味着了解优势、劣势和设计模式:

数据质量:当 mongodb 复制不是原子的并且您可以覆盖部分记录时 - 您会怎么做?

无模式:实际上意味着“数百万个模式”——每行可能包含不同类型的不同字段。或者您很严格,坚持使用单一模式,并频繁进行模式迁移。这违背了公司的建议,违背了他们的“无模式”公关,并且在大型数据库上可能需要很长时间。

连接:是一种非常强大的适应性功能。最终,大多数人发现他们需要 mongodb 返回包含或被其他数据过滤的文档。例如,不仅要返回交易时的 customer_id,还要返回截至今天的customer_name ?很简单,只需连接到您的客户表即可。当连接速度非常慢时,您将失去大量的灵活性。

在某些时候和某些地方,mongodb 可能有意义。但就像 mysql 的领导层 20 年前告诉大家,90% 的开发人员不需要事务、视图、子选择、联合等一样,MongoDB 给人们带来了很多糟糕的建议。

PG 开发

使用 PostgreSQL 进行开发的经验

AI大模型与向量库 PGVector

微信公众号原文

新 AI 应用在过去一年中出现了指数爆炸的增长态势,而这些应用面临的一个共同挑战是如何大规模地存储查询以向量表示的 AI Embedding。本文聚焦被 AI 炒火了的向量数据库,介绍了AI嵌入与向量存储检索的基本原理,并用一个具体的知识库检索案例来串联介绍向量数据库插件 PGVECTOR 的功能、性能、获取与应用。


AI是怎么工作的

GPT 展现出来了强大的智能水平,它的成功有很多因素,但在工程上关键的一步是:神经网络与大语言模型将一个语言问题转化为数学问题,并使用工程手段高效解决了这个数学问题

对于AI来说,各种各样的知识与概念在内部都使用数学向量来存储表示输入输出。将词汇/文本/语句/段落/图片/音频各种对象转换为数学向量的这个过程被叫做嵌入Embedding)。

例如 OpenAI 就使用 1536 维的浮点数向量空间。当你问 ChatGPT 一个问题时,输入的文本首先被编码转换成为一个数学向量,才能作为神经网络的输入。而神经网络的直接输出结果,也是一个向量,向量被重新解码为人类的自然语言或其他形式,再呈现到人类眼前。

llm-pgvector-1.jpeg

人工智能大模型的“思考过程”,在数学上就是一系列向量与矩阵之间的加乘正逆运算。这种向量对于人类来说过于抽象,无法理解。但这种形式很适合使用 GPU/FPGA/ASIC 这样的专用硬件来高效实现 —— AI 有了一个硅基的仿生大脑,带有更多的神经元,更快的处理速度,以及更强大的学习算法,惊人的智能水平,高速自我复制与永生的能力。

语言大模型解决的是 编码 - 运算 - 输出 的问题,但是只有计算是不够的,还有一个重要的部分是记忆。大模型本身可以视作人类公开数据集的一个压缩存储,这些知识通过训练被编码到了模型中,内化到了模型的权重参数里。而精确性的,长期性的,过程性的,大容量的外部记忆存储,就需要用到向量数据库了。

llm-pgvector-2.png

所有的概念都可以用向量来表示,而向量空间有一些很好的数学性质,比如可以计算两个向量的“距离”。这意味着任意两个抽象概念之间的“相关性”,都可以用对应编码向量的距离来衡量

这个看上去简单的功能却有着非常强大的效果,例如最经典的应用场景就是搜索。比如,您可以预处理你的知识库,将每个文档都是用模型转换成抽象向量存储在向量数据库中,当你想要检索时,只需要将您的问题也用模型编码成为一个一次性的查询向量,并在数据库中找到与此查询向量“**距离最近“**的文档作为回答返回给用户即可。

llm-pgvector-3.jpeg

通过这种方式,一个模糊而困难的自然语言处理问题,转换成为了一个简单清晰的数学问题。而向量数据库,就可以用来高效地解决这个数学问题。


向量数据库能干什么?

数据库有事务处理(OLTP)与数据分析(OLAP)两大核心场景,向量数据库自然也不例外。典型的事务处理场景包括:知识库,问答,推荐系统,人脸识别,图片搜索,等等等等。知识问答:给出一个自然语言描述的问题,返回与这些输入最为接近的结果;以图搜图:给定一张图片,找出与这张图片在逻辑上最接近的其他相关图片。

这些功能说到底都是一个共同的数学问题:**向量最近邻检索(KNN):**给定一个向量,找到距离此向量最近的其他向量。

典型的分析场景是聚类:将一系列向量按照距离亲疏远近分门别类,找出内在的关联结构,并对比急簇之间的差异。

llm-pgvector-4.jpeg


PG向量插件 PGVECTOR

市面上有许多向量数据库产品,商业的有 Pinecone,Zilliz,开源的有 Milvus,Qdrant 等,基于已有流行数据库以插件形式提供的则有 pgvector 与 Redis Stack。

在所有现有向量数据库中,pgvector 是一个独特的存在 —— 它选择了在现有的世界上最强大的开源关系型数据库 PostgreSQL 上以插件的形式添砖加瓦,而不是另起炉灶做成另一个专用的“数据库” [1]。pgvector 有着优雅简单易用的接口,不俗的性能表现,更是继承了PG生态的超能力集合。

llm-pgvector-5.png

一个合格的向量数据库,首先得是一个合格的数据库,而从零开始做到这一点并不容易比起使用一种全新的独立数据库品类,为现有数据库加装向量搜索的能力显然是一个更为务实,简单,经济的选择


PGVECTOR 知识检索案例

下面我们通过一个具体的例子演示 PGVECTOR 这样的向量数据库是如何工作的。

模型

OpenAI 提供了将自然语言文本转换为数学向量的 API :例如 text-embedding-ada-002 ,便可以将最长2048~8192个字符的句子/文档转换为一个 1536 维的向量。但是这里我们选择使用 HuggingFace 上的 shibing624/text2vec-base-chinese 模型替代 OpenAI 的 API 完成文本到向量的转换。

这个模型针对中文语句进行了优化,尽管没有 OpenAI 模型有那样深入的语义理解能力,但它是开箱即用的,使用 pip install torch text2vec 即可完成安装,而且可以在本地CPU上运行,完全开源免费。您可以随时换用其他模型:基本用法是类似的。

from text2vec import SentenceModel 
# 自动下载并加载模型
model = SentenceModel('shibing624/text2vec-base-chinese')
sentence = '这里是你想编码的文本输入'
vec = model.encode(sentence)

使用以上代码片段即可将任意长度在512内的中文语句编码为 768 维的向量。拆分后只需要调用模型的编码(encode)方法,即可将文本转换为数学向量。对于很长的大文档,您需要合理地将文档与知识库拆分成一系列长度得当的段落。

存储

编码后的结果,在 PostgreSQL 中使用形如 ARRAY[1.1,2.2,...] 这样的浮点数组形式表示。这里我们跳过数据清洗灌入的琐碎细节,总之在一番操作后有了一张语料数据表 sentences,一个 txt 字段来存储原始文本表示,并使用一个额外的 vec 字段存储文本编码后的 768 维向量。

CREATE EXTENSION vector;
CREATE TABLE sentences(id    BIGINT PRIMARY KEY,  -- 标识    txt   TEXT NOT NULL,       -- 文本    vec   VECTOR(768) NOT NULL -- 向量);

这张表和普通的数据库表并没有任何区别,你可以用一模一样的增删改查语句。特殊的地方在于 pgvector 扩展提供了一种新的数据类型 VECTOR ,以及相应的几种距离函数、运算符与对应的索引类型,允许您高效地完成向量最近邻搜索。

查询

这里我们只需要用一个简易的 Python 小脚本,就可以制作一个全文模糊检索的命令行小工具:

# !/usr/bin/env python3
from text2vec import SentenceModel
from psycopg2 import connect

model = SentenceModel('shibing624/text2vec-base-chinese')

def query(question, limit=64):
    vec = model.encode(question)  # 生成一个一次性的编码向量,默认查找最接近的64条记录
    item = 'ARRAY[' + ','.join([str(f) for f in vec.tolist()]) + ']::VECTOR(768)'
    cursor = connect('postgres:///').cursor()
    cursor.execute("""SELECT id, txt, vec <-> %s AS d FROM sentences ORDER BY 3 LIMIT %s;""" % (item, limit))
    for id, txt, distance in cursor.fetchall():
        print("%-6d [%.3f]\t%s" % (id, distance, txt))

llm-pgvector-6.png


PGVECTOR 的性能

当功能、正确性、安全性满足需求后,用户的目光就会转向性能。PGVECTOR 有着不错的性能表现,尽管比起专用的高性能向量计算Library来说有些差距,但性能对于生产环境中使用已经是绰绰有余了。

对于向量数据库来说,最近邻查询的延迟是一个重要的性能指标,ANN-Benchmark 则是一个相对权威的最近邻性能评测基准[2]。pgvector 的索引算法是 ivfflat ,在几个常见的基准测试中表现如下图所示:

llm-pgvector-7.png

为了对 pgvector 的性能表现在直觉上有一个把握,在 M1 Max 芯片 Macbook 下单核运行一些简单的测试:从1百万条随机 1536 维向量(正好是 OpenAI 的输出向量维度)中找出余弦距离最近的TOP 1 ~ 50 条向量,每次耗时大约 8ms 。从 1 亿条随机 128 维向量 (SIFT图像数据集的维度)中找出 L2 欧几里得距离 TOP 1 向量耗时 5ms,TOP 100 耗时也只要 21ms 。

-- 1M 个 1536 维向量,随机取 TOP1~50,余弦距离, 单核:插入与索引耗时均为5~6分钟,大小8GB左右。随机向量最近邻 Top1 召回:8ms
DROP TABLE IF EXISTS vtest; 
CREATE TABLE vtest ( id BIGINT, v  VECTOR(1536) ); 
TRUNCATE vtest;

INSERT INTO vtest SELECT i, random_array(1536)::vector(1536) FROM generate_series(1, 1000000) AS i;
CREATE INDEX ON vtest USING ivfflat (v vector_cosine_ops) WITH(lists = 1000);
WITH probe AS (SELECT random_array(1536)::VECTOR(1536) AS v) 
  SELECT id FROM vtest ORDER BY v <=> (SELECT v FROM probe) limit 1;


-- 简易SIFT ,1亿个128维向量,测试L2距离,召回1个最近向量, 5 ms, 召回最近100个向量:21ms
DROP TABLE IF EXISTS vtest;
CREATE TABLE vtest( id BIGINT, v  VECTOR(128) );
TRUNCATE vtest;

INSERT INTO vtest SELECT i, random_array(128)::vector(128) FROM generate_series(1, 100000000) AS i;
CREATE INDEX ON vtest USING ivfflat (v vector_l2_ops) WITH(lists = 10000);
WITH probe AS (SELECT random_array(128)::VECTOR(128) AS v) 
  SELECT id FROM vtest ORDER BY v <-> (SELECT v FROM probe) limit 1; -- LIMIT 100

使用真实的 SIFT 1M 数据集来测试,找出测试集中1万条向量在1百万条基础向量集中的最近邻单核总共只需18秒,单次查询的延迟在 1.8 ms ,折合单核500 QPS,可以说是相当不错了。当然对于 PostgreSQL 这样的成熟数据库来说,你总可以简单地通过加核数与拖从库来近乎无限地扩容其QPS吞吐量。

-- SIFT 1M 数据集,128维embedding,使用ivfflat索引, L2距离,10K测试向量集。
DROP TABLE IF EXISTS sift_base;
CREATE TABLE sift_base  (id BIGINT PRIMARY KEY , v VECTOR(128));
DROP TABLE IF EXISTS sift_query; 
CREATE TABLE sift_query (id BIGINT PRIMARY KEY , v VECTOR(128));
CREATE INDEX ON sift_base USING ivfflat (v vector_l2_ops) WITH(lists = 1000);

-- 一次性寻找 sift_query 表中 10000 条向量在 sift_base 表中的最近邻 Top1: 单进程 18553ms / 10000 Q = 1.8ms
explain analyze SELECT q.id, s.id FROM sift_query q ,LATERAL (SELECT id FROM sift_base ORDER BY v <-> q.v limit 1) AS s; 

-- 单次随机查询耗时在 个位数毫秒
WITH probe AS (SELECT v AS query FROM sift_query WHERE id =  (random() * 999)::BIGINT LIMIT 1)
  SELECT id FROM sift_base ORDER BY v <-> (SELECT query FROM probe) LIMIT 1;

如何获取 PGVECTOR?

最后,我们来聊一聊,如何快速获取一个可用的 PGVECTOR ?

在以前,PGVECTOR 需要自行下载编译安装,所以我提了一个 Issue 把它加入到 PostgreSQL 全球开发组的官方仓库中[5]。你只需要正常使用 PGDG 源即可直接 yum install pgvector_15 完成安装。在安装了 pgvector 的数据库实例中使用 CREATE EXTENSION vector 即可启用此扩展。

CREATE EXTENSION vector;
CREATE TABLE items (vec vector(2));
INSERT INTO items (vec) VALUES ('[1,1]'), ('[-2,-2]'), ('[-3,4]');
SELECT *, vec <=> '[0,1]' AS d FROM items ORDER BY 2 LIMIT 3;

更简单的选择是本地优先的开源 RDS PostgreSQL 替代 —— Pigsty ,在三月底发布的v2.0.2 中, pgvector 已经默认启用,开箱即用。您可以在一台全新虚拟机上一键完成安装,自带时序地理空间向量插件,监控备份高可用齐全。分文不收,立等可取。

llm-pgvector-8.png

Supabase,Neon 也提供了带有 pgvector 插件的付费托管 PostgreSQL 服务,AWS RDS for PostgreSQL 也已经在五月初刚刚支持了此扩展 。提供托管服务的完整供应商列表可以参考 pgvector 的 Github Issue [6]。


参考

[1] PGVECTOR GitHub仓库

[2] ANN性能评测基准

[3] 使用 PGVECTOR 存储 OpenAI 嵌入

[4] 文本与代码嵌入

[5] Add official RPM package and inclusion in PGDG YUM repository

[6] PGVector Hosted Providers

高级模糊查询的实现

如何在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

前后端通信线缆协议

了解PostgreSQL服务器与客户端通信使用的TCP协议,并使用Go语言打印消息

了解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 &{}

事务隔离等级注意事项

PostgreSQL实际上只有两种事务隔离等级:读已提交(Read Commited)可序列化(Serializable)

PostgreSQL实际上只有两种事务隔离等级:读已提交(Read Commited)可序列化(Serializable)


基础

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

CDC 变更数据捕获机理

数据变更捕获是一种很有趣的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++)

GeoIP 地理逆查询优化

在应用开发中,一个‘很常见’的需求就是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))
		b = append(b, 0x80|byte(i>>6)&0x3F)
		b = append(b, 0x80|byte(i)&0x3F)
	default: 			/* 4 byte*/
		b = append(b, 0xF0|byte(i>>18))
		b = append(b, 0x80|byte(i>>12)&0x3F)
		b = append(b, 0x80|byte(i>>6)&0x3F)
		b = append(b, 0x80|byte(i)&0x3F)
	}
	return
}

0x06 编程语言中的字符编码

讲完了现代编码模型,让我们来看两个现实编程语言中的例子:Go和Python2。这两者都是非常简单实用的语言。但在字符编码的模型设计上却是两个典型:一个正例一个反例。

Go

Go语言的缔造者之一,Ken Thompson,同时也是UTF-8的发明人(同时也是C语言,Go语言,Unix的缔造者),因此Go对于字符编码的实现堪称典范。Go的语法与C和Python类似,非常简单。它也是一门比较新的语言,抛开了一些历史包袱,直接使用了UTF-8作为默认编码。

UTF-8编码在Go语言中有着特殊的位置,无论是源代码的文本编码,还是字符串的内部编码都是UTF-8。Go绕开前辈语言们踩过的坑,使用了UTF8作为默认编码是一个非常明智的选择。相比之下,Java,Javascript都使用 UCS-2/UTF16作为内部编码,早期还有随机访问的优势,可当Unicode增长超出BMP之后,这一优势也荡然无存了。相比之下,字节序,Surrogate , 空间冗余带来的麻烦却仍让人头大无比。

Go语言中有三种重要的基本文本类型: byte, rune,string,分别是字节,字符,与字符串。其中:

  • 字节byte实际上是uint8的别名,[]byte表示字节序列。
  • 字符rune实质上是int32的别名,表示一个Unicode的码位[]rune表示码位序列
  • 字符串string实质上是UTF-8编码的二进制字节数组(底层是字节数组),加上一个长度字段。

而相应的编码与解码操作为:

  • 编码:使用string(rune_array)字符数组转换为UTF-8编码的字符串。
  • 解码:使用for i,r := range str语法迭代字符串中的字符,实际上是依次将二进制UTF-8字节序列还原为码位序列。

更详细的内容可以参阅文档,我也写过一篇博文详细解释了Go语言中的文本类型。

Python2

如果说Go可以作为字符编码处理实现的典范,那么Python2则可以当做一个最典型的反例了。Python2使用ASCII作为默认编码以及默认源文件编码,因此如果不理解字符编码的相关知识,以及Python2的一些设计,在处理非ASCII编码很容易出现一些错误。实际上只要看到Python3与Python2在字符编码处理上的差异有多大就大概有数了。Python2用的人还是不少,所以这里的坑其实很多,但其实最严重的问题是:

  • Python2的默认编码方案的非常不合理。
  • Python2的字符串类型与字符串字面值很容易让人混淆。

第一个问题是,Python2的默认编码方案的非常不合理:

  • Python2使用'xxx'作为字节串字面值,其类型为<str>,但<str>本质上是字节串而不是字符串
  • Python2使用u'xxx'作为字符串字面值的语法,其类型为<unicode><unicode>是真正意义上的字符串,每一个字符都属于UCS。

与此同时,Python2解释器的默认编码方案(CES)是US-ASCII 。作为对照,Java,C#,Javascript等语言内部的默认编码方案都是UTF-16,Go语言的内部默认编码方案使用UTF-8。默认使用US-ASCII的python2简直是骨骼清奇,当然,这也有一部分历史原因在里头。Python3就乖乖地改成UTF-8了。

第二个问题:python的默认’字符串类型<str>与其叫字符串,不如叫字节串,用下标去访问的每一个元素都是一个字节。而<unicode>类型才是真正意义上的字符串,用下标去访问的每一个元素都是一个字符(虽然底下可能每个字符长度不同)。字符串<unicode> 与 字节串<str> 的关系为:

  • 字符串<unicode> 通过 字符编码方案编码得到字节串<str>
  • 字节串<str> 通过 字符编码方案解码得到字符串<unicode>

字节串就字节串,为啥要起个类型名叫<str>呢?另外,字面值语法用一对什么前缀都没有的引号表示str,这样的设计非常反直觉。因此让很多人掉进了坑里。当然,<str><unicode>这样的类型设计以及两者的关系设计本身是无可厚非的。该黑的应该是这两个类型起的名字字面值表示方法。至于怎么改进是好的,Python3已经给出答案。在理解了字符编码模型之后,什么样的操作才是正确的操作,读者应该已经心里有数了。

微信公众号原文

PostgreSQL开发规约(2018版)

微信公众号原文

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

KNN极致优化:从RDS到PostGIS

KNN问题极致优化,从传统关系型设计到PostGIS

灵活应用数据库的功能,可以轻松实现 GIS 圈选场景下三万倍的性能提升。

Level 方法 性能/耗时(ms) 可维护性/可靠性 备注
1 暴力扫表 30,000 - 形式简单
2 经纬索引 35 复杂度/魔数问题 额外复杂度
3 联合索引 10 复杂度/魔数问题 额外复杂度
4 GIST 4 最简表达,完全精确 形式简单,距离更精确,PostgreSQL限定
5 btree_gist联合索引 1 最简表达,完全精确 形式简单,距离更精确,PostgreSQL限定

场景

互联网中的很多业务都涉及到地理相关的功能需求,最为普遍的需求莫过于最近邻查询了。

例如:

  • 为用户推荐附近的POI(餐厅、加油站、公交站)
  • 为用户推荐附近的用户(聊天匹配)
  • 找到距离用户所处的地址(地理逆编码)
  • 找到用户所处的商圈、省、市、区、县 (以点找面)

这些问题实质上都属于最近邻搜索或其变体。

有一些功能,它看上去和最近邻搜索无关,实际上剥了皮,也是最近邻搜索,典型的例如地理逆编码:

打车选择上车地点的时,点外卖选择送达位置时,都会将用户当前的经纬度坐标转换为文本地理位置,诸如:“某某小区几号楼”。实际上这也是最近邻搜索的问题:找到距离用户当前位置最近的一个坐标点。

最近邻(knn,k nearest neighiboor),顾名思义,就是找出距离某个中心点最近的K个对象。其问题满足这样一种形式:

找出满足某一条件的最近的K个对象(及其属性)。

最近邻搜索是如此常用的功能,优化的效益非常显著。

下面我们从一个具体问题出发,讲述这一功能实现方式的演化——如何实现超过三万倍的性能提升。


问题

我们选择推荐最近的餐厅,作为此类问题的代表。

问题很简单:给定包含中国所有POI点的表pois,及一个经纬度坐标点。在足够快的时间内找出距离该坐标点最近的10家餐馆。并返回这十家餐馆的名称和距离

细节说明:

  • pois表包括一亿条记录,其中类型为餐馆的POI约占一千万。

  • 给定的示例中心店:北京师范大学,116.3660 E, 39.9615 N。

  • 足够快意味着在1毫秒内完成

  • 距离意味着,以米计算的地球表面距离

  • pois表模式定义:

    CREATE TABLE pois (
      id        CHAR(10) PRIMARY KEY,
      name      VARCHAR(100),
      position  GEOMETRY, -- PostGIS ST_Point
      longitude FLOAT,    -- Float64
      latitude  FLOAT,    -- Float64
      category  INTEGER   -- type of POI
    );
    
  • 餐馆的特征是WHERE category BETWEEN 50000 AND 51000

同类问题

这个模式适用于许多的例子,例如对探探而言,其实就可以是:找出离用户所在位置最近的,且年龄位于某个范围,加上一些其他筛选条件的100个人。

对于美团点评而言,就是找出离用户最近的10个,类型为餐馆的POI。

对于逆地理编码而言,实质上就是找出离用户最近的POI(加上可选的类型限制,类型十字路口,地标建筑等)

题外话-坐标系:WGS84与GCJ02

这是另外一个很多人都会搞混的地方。

  • 滴滴打车的魔幻偏移。
  • 港澳台边界,碎屑多边形。

绝大多数互联网中与地理相关的功能,都涉及到最近邻查询的需求。

比如对于谈朋友的场景,把这里的 WHERE category BETWEEN 50000 AND 51000

换成 WHERE age BETWEEN 18 AND 27就好。

很多打ACM的同学,熟练使用各种数据结构与算法。可能已经跃跃欲试了,R树,就决定是你了。

不过在真实项目中,数据表就是数据结构,而索引与查询方式就是算法。

距离如何定义?

欲解此题,需明定义。距离的定义并没有看上去那样简单。

例如,对于导航软件而言,距离可能意味着路径长度而非直线距离。

在二维平面坐标系中,通常距离指的是欧氏距离:$d=\sqrt{(x_2-x_1)^2+(y_2-y_1)^2}$

但在GIS中,通常使用的坐标系是球面坐标系,即通过经纬度来标识一个点。

在球面上,两点之间的距离等于所在球面大圆上的弧长,也就是其球面角 x 半径。

这就引入了一个问题,每一纬度对应的距离是基本恒定的,差不多都是111公里。

然而,每一经度对应的距离,随着纬度不同而变化,在赤道上和一纬度差不多,也是111公里,然而随着纬度升高,到了北纬40°时,一经度对应的弧长只有85公里了,而到了北极点,一经度对应的弧长距离为0。

事实上,还会有其他更棘手的问题。例如,地球实际上是一个椭球体,而非正球体。

地球非球,乃不规则椭球。在最开始的时候,为了省事,我们可以设其为球计算距离。

CREATE OR REPLACE FUNCTION sphere_distance(lon_a FLOAT, lat_a FLOAT, lon_b FLOAT, lat_b FLOAT)
  RETURNS FLOAT AS $$
SELECT asin(
           sqrt(
               sin(0.5 * radians(lat_b - lat_a)) ^ 2 +
               sin(0.5 * radians(lon_b - lon_a)) ^ 2 * cos(radians(lat_a)) * cos(radians(lat_b))
           )
       ) * 127561999.961088 AS distance;
$$
LANGUAGE SQL IMMUTABLE COST 100;

将经纬度坐标当成平面坐标使用并不是不可以,但对于需要精准排序的场景,这样的近似可能会产生很大的问题:

每一经度对应的距离,随着纬度不同而变化,在赤道上一经度和一纬度代表的距离差不多都是111公里,然而随着纬度升高,到了北纬40°时,一经度对应的弧长只有85公里了,而到了极点,一经度对应的弧长距离为0。

因此,平面坐标系上的圆,在球面坐标系上可能只是一个瘦长的椭圆。计算距离时,纬度与经度方向上的距离权重不同会导致严重的正确性问题:一个正北100米处的商店可能比正东70m处的商店距离排序更靠前。对于高纬度地区,这一类问题会变得非常严重。

因此,暴力扫表之后,通常需要使用精确的距离计算公式再次计算并排序。

注意,这里的距离,量纲单位并不是米,而是°的平方,考虑到1经度和1纬度对应的实际距离在不同的地方存在巨大差异,这一结果并没有精确的实际意义。

经纬度是球面坐标系,而不是二维平面坐标系中的坐标。然而对于快速粗略圈选,这种方式是可以接受的。

对于需要精确排序的场景,必须使用地球表面球面距离的计算公式,而不是简单地求欧氏距离。

足够快又是多快?

天下武功,唯快不破,互联网强调的就是一个快,跑的也快,写的也快。

足够快又是多快呢?一毫秒,足够快了。这也是我们的优化目标

好了,开始进入干货环节。在PostGIS展现真正的实力之前,让我们来先看一看传统的关系型数据库,对解决这一问题,能走到多远。


0x02 方案

让我们从传统关系型数据库开始

LEVEL-1 暴力扫表

使用传统关系型数据库,此题有何解法?

暴力算法写起来是非常简单的,我们来看一下。

从POIS表中,首先找出所有的餐馆,拿出餐馆的名字,算出餐馆到我们这儿的距离,然后呢?再按距离排序,取距离最短的,也就是最近的10条记录。

新手拍拍脑袋,也可以很快写出这样Naive的SQL:

SELECT
  id,
  name,
  sphere_distance(longitude, latitude, 
                  116.3660 , 39.9615 ) AS d
FROM pois
WHERE category BETWEEN 50000 AND 51000
ORDER BY d
LIMIT 10;

为了简化问题,让我们暂时忽略经纬度其实是球面坐标,地球又是个椭球体的事实。

在这一前提下,这个SQL确实能正确完成工作。不过,谁要敢在生产环境这么用,DBA肯定得打死他。

让我们先考察其执行计划:

题外话:SQL内联

SQL内联有助于正确使用索引。

在真实环境执行,缓存充分预热,实际耗时30秒;开启PostgreSQL并行查询(2 worker)后实际执行时间16秒。

用时30秒,实际执行时间17秒。

用户对于响应时间是很敏感的,响应时间一上去,用户满意度立马就会掉下来。打王者荣耀的时候,100毫秒的延迟都已经很让人抓狂了。如果是一个实时性

对于几千条记录的表也许可以凑合工作,但对于1亿量级的表,暴力扫表不可取。

用户无法接受十几秒的等待时间,更罔论这样的设计能有任何扩展性可言。

存在的问题

开销离谱

这个查询每次都要计算目标点所有记录点之间的距离,然后再按距离排序取TOP。

对于几千条记录的表也许可以凑合工作,但对于1亿量级的表,暴力扫表不可取。用户无法接受十几秒的等待时间,更罔论这样的设计能有任何扩展性可言。

正确性堪忧

将经纬度坐标当成平面坐标使用并不是不可以,但对于需要精准排序的场景,这样的近似可能会产生很大的问题:

每一经度对应的距离,随着纬度不同而变化,在赤道上一经度和一纬度代表的距离差不多都是111公里,然而随着纬度升高,到了北纬40°时,一经度对应的弧长只有85公里了,而到了极点,一经度对应的弧长距离为0。

因此,平面坐标系上的圆,在球面坐标系上可能只是一个瘦长的椭圆。计算距离时,纬度与经度方向上的距离权重不同会导致严重的正确性问题:一个正北100米处的商店可能比正东70m处的商店距离排序更靠前。对于高纬度地区,这一类问题会变得非常严重。

因此,暴力扫表之后,通常需要使用精确的距离计算公式再次计算并排序。

题外话:错误的索引效果适得其反

有同学会说,这里POI类型字段,category出现在了查询的where条件中,可以通过索引来提高性能

这次他不直接扫表了,它先去扫描category上的索引,把属于餐厅的记录都过滤出来。

然后再按照索引,一个页面接一个页面地扫描。

结果顺序IO变成了随机IO。

那么索引的正确使用方式又是怎么样的呢?

LEVEL-2 经纬索引

索引是关系型数据库的吃饭家伙,既然顺序扫表不可取,我们自然会想到利用索引来加速查询。

朴素的思路是这样的,通过索引筛选出目标点周围一定范围内的候选点,再进一步计算距离并排序。

索引是关系型数据库的吃饭家伙,既然顺序扫表不可取,我们自然会想到利用索引来加速查询。

使用经纬度上的索引是基于这样一种思路:

北师在帝都繁华之地宇宙中心,如果我们用一个边长一公里的正方形(直径一公里的圆)

去地图上画个圈,那么别说十家餐厅了,一百家都有可能。

反过来说呢,既然最近的10家餐厅一定落在这么大的一个圆里,

这个表里的POI点包括了全中国的POI点,

筛选出目标点周围一定范围内的候选点,再进一步计算距离并排序。

CREATE INDEX ON pois1 USING btree(longitude);
CREATE INDEX ON pois1 USING btree(latitude);

同时,为了解决正确性的问题,假设我们已经有了一个从经纬度计算球面距离的SQL函数sphere_distance

CREATE FUNCTION sphere_distance(lon_a FLOAT, lat_a FLOAT, lon_b FLOAT, lat_b FLOAT) RETURNS FLOAT
IMMUTABLE LANGUAGE SQL COST 100 AS $$
SELECT asin(
           sqrt(
               sin(0.5 * radians(lat_b - lat_a)) ^ 2 +
               sin(0.5 * radians(lon_b - lon_a)) ^ 2 * cos(radians(lat_a)) *
               cos(radians(lat_b))
           )
       ) * 127561999.961088 AS distance;
$$;

$$ \Delta\sigma=\arccos\bigl(\sin\phi_1\cdot\sin\phi_2+\cos\phi_1\cdot\cos\phi_2\cdot\cos(\Delta\lambda)\bigr). $$

于是,如果使用以目标点为中心的边长为1公里的正方形来做初筛,这个查询可以写作:

SELECT 
  id, name, 
  sphere_distance(longitude, latitude, 116.365798, 39.966956) as d
FROM pois1
WHERE
  longitude BETWEEN 116.365798 - 0.5 / 85 AND 116.365798 + 0.5 / 85 AND
  latitude BETWEEN 39.966956 - 0.5 / 111 AND 39.966956 + 0.5 / 111  AND
  category = 60000
ORDER BY 3 LIMIT 10;

预热后,实际执行平均耗时35毫秒,相比暴力扫表有了近千倍的性能提高,一个巨大的进步。

对于比较简单粗糙的产品,这种方法已经达到了‘可用’的级别。但这一方法仍然存在许多问题。

存在的问题

这种方法最大的问题在于额外复杂度。它使用了一个(多个)魔数,来确定候选点的大致范围。

而这个魔数的选取,是有赖我们的先验知识的。我们清楚地知道,以繁华的宇宙中心五道口的商铺密度,一公里见方内,商铺个数绝对超过10个了。但对于极端的场景(实际可能很常见),比如在塔克拉玛干大沙漠或者羌塘无人区,最近的商铺,逻辑上是必定存在的,不过其距离可能超过几百公里。

这种方法的性能表现对魔数的选取极其敏感:距离选择的太大,性能会急剧恶化,距离选择的太小,对于乡下偏僻的地方又可能无法返回结果。让程序员头大的事情又多了一个。

用时35毫秒

千倍提升,不错哦,但不能高兴的太早

这么多奇怪的常数又是几个意思?

一千倍的性能提升,让我们来看一下查询执行计划,看看它是怎么做到的。

首先呢,经度上,走了一个索引扫描,生成了一个位图。

然后呢,纬度上,也走了一个索引扫描,又生成了一个位图。

接下来,两个位图做了一个位运算,生成了一个新位图,筛选出了满足经纬度条件的记录。

然后,才去扫描这些满足条件的候选点,计算距离,并排序。

我们这个边界值选的比较巧,所以实际参与距离计算和排序的记录,可能只有三十多条。

比起先前一千多万次的距离计算与排序,显然是要高明的多了。

题外话:超参数与额外复杂度

因为这个边界魔数凑的很好,所以性能比较理想。

这种方法最大的问题在于额外复杂度。它使用了一个(多个)魔数,来确定候选点的大致范围。

而这个魔数的选取,是有赖我们的先验知识的。我们清楚地知道,以繁华的宇宙中心五道口的商铺密度,一公里见方内,商铺个数绝对超过10个了。但对于极端的场景(实际可能很常见),比如在塔克拉玛干大沙漠或者羌塘无人区,最近的商铺,逻辑上是必定存在的,不过其距离可能超过几百公里。

这种方法的性能表现对魔数的选取极其敏感:距离选择的太大,性能会急剧恶化,距离选择的太小,对于乡下偏僻的地方又可能无法返回结果。让程序员头大的事情又多了一个。

让我们先忽略这恼人的问题,看看传统关系型数据库还能不能再压榨压榨。

Bad Case

因为这个边界魔数凑的很好,所以性能比较理想。

这种方法最大的问题在于额外复杂度。它使用了一个(多个)魔数,来确定候选点的大致范围。

而这个魔数的选取,是有赖我们的先验知识的。我们清楚地知道,以繁华的宇宙中心五道口的商铺密度,一公里见方内,商铺个数绝对超过10个了。但对于极端的场景(实际可能很常见),比如在塔克拉玛干大沙漠或者羌塘无人区,最近的商铺,逻辑上是必定存在的,不过其距离可能超过几百公里。

这种方法的性能表现对魔数的选取极其敏感:距离选择的太大,性能会急剧恶化,距离选择的太小,对于乡下偏僻的地方又可能无法返回结果。让程序员头大的事情又多了一个。

让我们先忽略这恼人的问题,看看传统关系型数据库还能不能再压榨压榨。

半径大了性能差 半径小了圈不着
mage-20180321221805
繁荣的五道口,一公里圈10家小意思。 300公里外才有一家,新疆人民哭晕在厕所

LEVEL-3 联合索引与聚簇

抛开魔数带来的烦恼,我们来研究传统关系型数据库能在解决这个问题上走得有多远。

通过多列索引替换每一列上独自的索引,并将表按该索引聚簇。

仍然是一模一样的查询语句

从30毫秒提升到10毫秒,三倍的性能提升

对于传统关系型数据库,这差不多就是极限了

有没有优雅、正确、快速的解决方案呢?

mage-20180321221928

CREATE INDEX ON pois4 USING btree(longitude, latitude, category);
CLUSTER pois4 USING pois4_longitude_latitude_category_idx;

相应的查询保持不变

SELECT id, name,
	sphere_distance(longitude, latitude, 116.365798, 39.966956) as d FROM pois4
WHERE
  longitude BETWEEN 116.365798 - 0.5 / 85  AND 116.365798 + 0.5 / 85  AND
  latitude  BETWEEN  39.966956 - 0.5 / 111 AND 39.966956  + 0.5 / 111 AND
  category = 60000
ORDER BY sphere_distance(longitude, latitude, 116.365798, 39.966956)
LIMIT 10;

联合索引查询的执行计划,实际执行时间可以压缩至7毫秒。

mage-20180321221945

这差不多就是传统关系数据模型的极限了,对于大部分业务,这都是一个可以接受水平了。

因为这个边界魔数凑的很好,所以性能比较理想。

扩展变体:GeoHash

GeoHash是此类方式的变体,通过将二维经纬度编码为一维字符串,可以使用传统的字符串前缀匹配操作来对地理位置进行过滤。然而固定的粒度使得其灵活度有显著下降,采用联合索引还是特殊编码的冗余字段需要针对具体场景进行分析。

仍然是一模一样的查询语句

从30毫秒提升到10毫秒,三倍的性能提升

对于传统关系型数据库,这差不多就是极限了

有没有优雅、正确、快速的解决方案呢?


LEVEL-4 GIST

有没有一种办法,能够优雅,高效,简洁的完成这项工作呢?

PostGIS提出了非常优秀的解决方案,改用Geometry类型,并创建GIST索引。

CREATE TABLE pois5(
  id       CHAR(10) PRIMARY KEY,
  name     VARCHAR(100),
  position GEOGRAPHY(Point), -- PostGIS ST_Point
  category INTEGER   -- type of POI
);

CREATE INDEX ON pois5 USING GIST(position);
SELECT id, name FROM pois6 WHERE category = 60000
ORDER BY position <-> ST_GeogFromText('SRID=4326;POINT(116.365798 39.961576)') LIMIT 10;

R树

R树的核心思想是,聚合距离相近的节点,并在树结构的上一层,将其表示为这些节点的最小外接矩形,这个最小外接矩形就成为上一层的一个节点。因为所有节点都在它们的最小外接矩形中,所以跟某个矩形不相交的查询就一定跟这个矩形中的所有节点都不相交。

mage-20180321220143

实际查询中,该查询能在1.6毫秒完成,这是相当惊人的一个结果了。但要注意,这里position的类型是GEOMETRY,意味着它使用的是二维平面坐标,正确的计算距离需要使用Geography类型。

SELECT
  id,
  name,
  position <-> 
  ST_Point(116.3660, 39.9615)::GEOGRAPHY AS d
FROM pois5
WHERE category BETWEEN 50000 AND 51000
ORDER BY d
LIMIT 10;

因为球面距离的计算开销比平面距离要大很多,使用Geography替换Geometry产开销,约4.5ms。

一倍的性能损失相当可观,因此日常应用中需要仔细权衡精确性与性能之间的关系。

通常拓扑类的查询、粗略的圈人都适合用Geometry类型,而精确的计算与判断则必须使用Geography类型。这里,按照距离排序需要精确的距离,因此使用Geography。

Geometry: 1.6 ms Geography: 3.4 ms
mage-20180321222024

现在,我们来看看PostGIS交出的答卷。

PostGIS,使用了不一样的数据类型、索引、与查询方法。

首先,这里数据类型不再是两个浮点数,而变成一个Geography字段。里面存就是一对经纬度坐标。

然后,我们使用的索引,也不再是常见的Btree索引,而是GIST索引。

Generalized Search Tree. 通用搜索树,平衡树结构。对于空间几何类型而言,实现通常使用的是R树。

通常拓扑类的查询、粗略的圈人都适合用Geometry类型,而精确的计算与判断则必须使用Geography类型。这里,按照距离排序需要精确的距离,因此使用Geography。

题外话:Geometry还是Geography?

因为球面距离的计算开销比平面距离要大很多,使用Geography替换Geometry产开销

拓扑关系,粗略估计使用Geometry,精确计算使用Geography

计算开销约为一倍,需要仔细权衡正确性/精确性与性能之间的关系。

现在,我们来看看PostGIS交出的答卷。

PostGIS,使用了不一样的数据类型、索引、与查询方法。

首先,这里数据类型不再是两个浮点数,而变成一个Geography字段。里面存就是一对经纬度坐标。

然后,我们使用的索引,也不再是常见的Btree索引,而是GIST索引。

Generalized Search Tree. 通用搜索树,平衡树结构。对于空间几何类型而言,实现通常使用的是R树。

通常拓扑类的查询、粗略的圈人都适合用Geometry类型,而精确的计算与判断则必须使用Geography类型。这里,按照距离排序需要精确的距离,因此使用Geography。


LEVEL-5 btree_gist

还能更进一步否?

观察Leve-4中的执行计划,我们发现category上的条件并没有用到索引。

可不可以像Level-3中的优化方式一样,创建一个 position 与 category 的联合索引呢?

不幸的是,B树与R树是两种完全不同的数据结构,甚至连使用方式都不一样

于是我们有这样一个想法,能不能把category当成 position的第三维坐标,让R树直接在三维空间里面进行索引呢?

这个思路是正确的, 但是完全不需要这么麻烦

GIST索引的一个问题在于,它的工作原理与B树不同,无法在不支持GIST索引方法的数据类型上创建GIST索引。

通常,几何类型,范围(range)类型支持GIST索引,但字符串,数值类型等都不支持GIST。这就导致了无法创建形如GIST(position, category)的多列索引。

PostgreSQL内置的btree_gist扩展解决了这一问题。

PostgreSQL内置的扩展 btree_gist,允许创建常规类型与几何类型的联合索引。

CREATE EXTENSION btree_gist;

CREATE INDEX ON pois6 USING GIST(position, category);

CLUSTER VERBOSE pois6 USING idx_pois6_position_category_gist;

同样的查询,可以简写为:

SELECT id, name, position <-> ST_Point(lon, lat) :: GEOGRAPHY AS distance
FROM pois6 WHERE category = 60000 ORDER BY 3 LIMIT 10;
Geometry: 0.85ms / Geography: 1.2ms
CREATE OR REPLACE FUNCTION get_random_nearby_store() RETURNS TEXT
AS $$
DECLARE
  lon FLOAT := 110 + (random() - 0.5) * 10;
  lat FLOAT := 30 + (random() - 0.5) * 10;
BEGIN
  RETURN (
    SELECT jsonb_pretty(jsonb_build_object('list', a.list, 'lon', lon, 'lat', lat)) :: TEXT
    FROM (
           SELECT json_agg(row_to_json(top10)) AS list
           FROM (
             SELECT id, name, position <-> ST_Point(lon, lat) :: GEOGRAPHY AS distance
             FROM pois6 WHERE category = 60000 ORDER BY 3 LIMIT 10 ) top10
         ) a);
END;
$$ LANGUAGE PlPgSQL;
import http, http.server, random, psycopg2

class GetHandler(http.server.BaseHTTPRequestHandler):
    conn = psycopg2.connect("postgres://localhost:5432/geo")
    def do_GET(self):
        self.send_response(http.HTTPStatus.OK)
        self.send_header('Content-type','application/json')
        with GetHandler.conn.cursor() as cursor:
            cursor.execute('SELECT get_random_nearby_store() as res;')
            res = cursor.fetchone()[0]
            self.wfile.write(res.encode('utf-8'))
        return

with http.server.HTTPServer(("localhost", 3001), GetHandler) as httpd: httpd.serve_forever()

案例小结

Level 方法 性能/耗时(ms) 可维护性/可靠性 备注
1 暴力扫表 30,000 - 形式简单
2 经纬索引 35 复杂度/魔数问题 额外复杂度
3 联合索引 10 复杂度/魔数问题 额外复杂度
4 GIST 4 最简表达,完全精确 形式简单,距离更精确,PostgreSQL限定
5 btree_gist联合索引 1 最简表达,完全精确 形式简单,距离更精确,PostgreSQL限定

那么好的,经过这么漫长的旅途,通过PostGIS与PostgreSQL,将原本需要3万毫秒的查询加速至1毫秒,三万倍的提升。相比传统关系型数据库,除了超过十倍以上的性能提升,还有很多优点:

SQL的形式非常简单,就是暴力扫表的SQL,不需要奇奇怪怪的额外复杂度。而且计算距离使用的是更精确的WGS84椭球球面距离。

那么从这个例子中我们可以得出什么结论呢? PostGIS的性能表现是非常优秀的,那么它在实际生产环境里的表现又如何呢?

我们把这里的position,从餐厅的位置换为用户的位置,把poi的种类范围,换成候选人的年龄范围。这就是探探匹配功能所面临的场景。

实际场景中的表现

性能很重要。天下武功,唯快不破。

目前数据库总共用了220台机器,业务QPS近10万。数据库TPS峰值的时候差不多接近250W。其中核心数据库是1主19从的配置。

我厂对于数据库的SLA是:99.99%的普通数据库请求需要在1毫秒内完成,而单个数据库节点的QPS峰值在3万上下。这两者之间其实有着紧密的联系,如果一个请求能在1毫秒内完成,那么对于单个线程而言,每秒钟就可以处理1000个请求。我们使用的数据库物理机CPU为24核48线程,不过超线程的机器CPU利用率在60%~70%左右。可以近似折算为30个可用核。那么,所有核能够承载的QPS量就是30*1000=30000。以极限水位80% CPU算,QPS上限在38k 左右,也与现实压测结果吻合。

整理自本人在2018象形中国北京PostGIS专场所做分享,转载请保留出处。

PostGIS高效解决行政区划归属查询

如何高效解决典型地理逆编码问题:根据用户的经纬度坐标,定位用户的行政区划。

微信公众号原文

在应用开发中,很多时候我们需要解决这样一个问题:根据用户的经纬度坐标,定位用户的行政区划。

我们收集到的是诸如28°00'00"N 100°00'00.000"E这样的经纬度坐标,但实际感兴趣的是这个点所属的行政区划:(中华人民共和国,云南省,迪庆藏族自治州,香格里拉市)。这种将地理坐标映射到某条记录的操作就称为地理编码(GeoEncode)。高效实现地理编码是一个很有趣的问题。

本文介绍了该问题的解决与优化方案:能在确保正确性的前提下,能用几兆的空间,110μs的执行时间完成一次地理编码。


0x01 正确至上

正确性是第一位的。我们不希望出现用户明明身处A地,却被划分到B地的尴尬情况。然而一个尴尬的现实是,很多地理编码服务的实现粗糙到令人无法直视,Vornoi方法就是一个典型的例子。

假设我们有一系列的坐标点,那么这些坐标点之间两两连线的中垂线就对整个坐标平面做了一个Vornoi划分。每一个细胞的中心点就是细胞核,而元胞内的任意一点到该细胞核的距离是最近的(与其他细胞核相比)。

当我们没有行政区划的边界数据,但有行政区划中心点的数据时,这也是一种能凑合管用办法。找到距离用户最近的某级行政区域中心,然后认为用户就位于该行政区域中心。这个功能实现起来非常简单。

不过,这种方法对于边界情况的处理很差:

最近邻搜索—Vornoi方法

vornoi

现实总是与理想情况相距甚远。也许对于国内而言,这种错误影响也许并不大。但涉及到国际主权边界时,这种粗糙的实现很可能会给自己带来不必要的麻烦:

还有一种思路,和编程中的“查表法”类似,预先计算好所有经纬度到行政区划的映射,使用时只要用经纬度坐标查表就好了。当然无论经度还是维度,都是一个连续的标量,理论上精度必然是有限的。

GeoHash就是这样一种方案:它将经度与维度交叉编码为单一字符串,字符串越长精度越高,每一个字符串都对应一个经纬度围成的“矩形”,只要精度足够,理论上是可以这么做的。当然,这种方案难以做到真正意义上的正确,存储开销也极为浪费。好处是实现很简单。只要有数据,一个KV服务就可以轻松搞定。

geohash

相比之下,基于地理边界多边形的解决方案在保证绝对正确的前提下,能在一毫秒内完成这种地理编码功能,而且可能只需要几兆的空间。唯一的难点可能在于如何获取数据上。


0x02 数据为王

地理编码属于典型的数据密集型应用,数据的质量直接决定了最终服务的效果。要想真正做好服务,优质数据必不可少。好在行政区划与地理边界数据也不算什么保密信息,有一些地方提供了公开获取的方式:

民政部信息查询平台与高德地图两者都提供了精确到县级区划的地理边界数据:

  • 高德地图行政区域查询API

    高德的数据更新更及时,形式简单,边界精度较高(点数多),但不够权威,有不少错漏之处

    geohash

  • 民政部全国行政区划信息查询平台

    民政部平台数据相对更加权威,而且采用的是拓扑编码,严格避免了边界重叠的问题,使用无偏的WGS84坐标,但边界精度较低(点数目较少)。

geohash

除了地理围栏数据之外,另一份重要的数据是行政区划代码数据。国家统计局使用的12位城乡统计用行政区划代码编制还是很科学的,具有层次包含关系,尤其适合作为行政区划的唯一标示。但问题是稍显过时,最新的版本是2016年8月发布的,2018年7月后可能会发布一份更新的数据。

笔者整理了一份连接国际统计局行政区划与高德区划边界的数据:https://github.com/Vonng/adcode

民政部的数据可以直接在该网站中打开浏览器的调试工具,从接口返回数据中直接获取。


0x03 牛刀小试

假设我们已经有一张表了,全国行政区划与地理围栏表:adcode_fences

create table adcode_fences
(
  code         bigint,
  parent       bigint,
  name         varchar(64),
  level        varchar(16),
  rank         integer,
  adcode       integer,
  post_code    varchar(8),
  area_code    varchar(4),
  ur_code      varchar(4),
  municipality boolean,
  virtual      boolean,
  dummy        boolean,
  longitude    double precision,
  latitude     double precision,
  center       geometry,
  province     varchar(64),
  city         varchar(64),
  county       varchar(64),
  town         varchar(64),
  village      varchar(64),
  fence        geometry
);

geohash

索引

为了高效执行空间查询,首先需要在表示地理边界的fence列上创建GIST索引。

中国县级行政区划的记录数据并不多(约3000条),但使用索引仍然能带来几十倍的性能提升。因为这个优化太基础太Trivial了,就不单独拎出来说了。(一百多毫秒到几毫秒)

CREATE INDEX ON adcode_fences USING GIST(fence);

查询

PostGIS提供了ST_ContainsST_Within两个函数,用于判断多边形与点之间的包含关系,例如以下SQL就会找出表中所有包含该点(116,40)的行政区划:

SELECT
  code,
  name
FROM adcode_fences
WHERE ST_Contains(fence, ST_Point(116, 40))
ORDER BY rank;

结果是:

100000000000	中华人民共和国
110000000000	北京市
110100000000	市辖区
110109000000	门头沟区

再比如(100,28)的坐标点:

SELECT json_object_agg(level,name) 
FROM adcode_fences WHERE ST_Contains(fence, ST_Point(100, 28));
{
  "country": "中华人民共和国",
  "city": "迪庆藏族自治州",
  "county": "香格里拉市",
  "province": "云南省"
}

相当不可思议,数据就位之后,借力于PostgreSQL与PostGIS,实现这一功能所需的代码少的惊人:一行SQL。

在笔者的笔记本上,该查询执行用时6毫秒。6ms的平均查询时间,换算为48核机器上的QPS差不多就是6400。在我们以前的生产环境代码中基本上就是这么做的,但因为还有其他国家的数据,以及单核主频没有我的机器高,因此一次查询的平均执行时间可能在12毫秒左右。

看上去几毫秒似乎已经很快了,但还是没有达到我们生产环境的性能要求(1毫秒)。对于真实世界的生产业务而言,性能很重要,十倍的性能提升意味着省十倍的机器。还能不能再给力点?实际上通过简单的优化就可以达到百倍的性能提升。


0x04 性能优化

针对数据特性优化

导致上述查询慢的一个重要原因是不必要的相交判断。行政区划是有层级关系的,如果一个用户位于县级行政区划中,那么他一定位于该县级区划所处的省级区划中。因此,知道了最低级的行政区划,其高级区划归属已经自然而然地确定了;那么与省界,国界做相交判断就是没有必要的。 实际上这可能是效果最明显的优化,单是中国地理边界与点做相交判断可能就需要几毫秒。

区域切分

R树索引的原理,能为我们带来优化的启发。R树是基于**AABB(Axis Aligned Bounding Box)**的索引。因此越是饱满的凸多边形,索引的效果就越好。而对于拥有遥远飞地的行政区划,效果则可能恶化的很厉害。因此,将区域切分为均匀饱满的小块,能有效提高查询的性能。

最基本的优化,就是将所有的`ST_MultiPolygon`拆分为`ST_Polygon`,并指向同一个行政区划。更进一步,可以将长得比较畸形的行政区划切分为形状饱满的小块(典型的比如甘肃这种)。当然,这样的代价就是让所有行政区划与地理围栏从一对一变成了一对多的关系。需要拆出一张单独的表。

实际操作中,如果已经有了县级行政区划的数据,通常只要将带有飞地的MultiPolygon拆为单独的几个Polygon,就已经能有很好的表现了。而县一级的行政区划通常边界也比较饱满,进一步拆分效果相当有限。

精确度

正确性是第一位的,然而有的时候我们宁愿牺牲一些准确性,换来性能的大幅提升。例如高德与民政部的数据对比,显然民政部要粗糙的多,但对于糙猛快的互联网场景,低精度的数据反而可能是更合适的。

高德 民政部
geohash geohash

高德的全国行政区划数据约100M左右,而民政部的数据约为10M(以原始拓扑数据表示则为4M)。但实际使用中效果差别不大,因此推荐使用民政部的数据。

主键设计

行政区划有内在的层次关系,国家包含省,省包含城市,城市包含区县,区县包含乡镇,乡镇包含村庄街道。我国的行政区划代码就很好的体现了这种层次关系,十二位的城乡区划代码包含了很丰富的信息:

  • 第1~2位,为省级代码;
  • 第3~4 位,为地级代码;
  • 第5~6位,为县级代码;
  • 第7~9位,为乡级代码;
  • 第10~12位,为村级代码。

因此这种12位的行政区划代码是很适合作为行政区划表的主键的。此外,当需要国际化支持时,这套区划代码体系还可以通过在前面添加国家代码来扩展(相应地中国行政区划对应地就是高位国家代码为0的特殊情况)。

另一方面,地理围栏表与行政区划表由一对一变为多对一,那么地理围栏表就不再适合用行政区划代码作为主键了。可能自增列是一个更合适的选择。

规范化与反规范化

数据模型设计的一个重要权衡就是规范化与反规范化。将地理围栏表从行政区划表中拆出来是一种规范化,而反规范化也可以用于优化:既然行政区划存在层次关系,那么在子行政区划中保留所有的祖先行政区划信息(或仅仅是代码与名称)是很合理的反规范化操作。这样,通过区划代码主键一次查询就可以取出所有的层次信息。

回溯支持

有时候我们想回溯到历史上某个特定时刻,查询该时刻的行政区划状态。

举个例子,行政区划变更并不会影响该区划内现有公民的身份证号码,只会影响新出生公民的身份证号。因此有时候用公民身份证号前6位去查现在的行政区划表可能一无所获,需要回溯到该公民出生的历史时间才能查询到正确的结果。可以参考PostgreSQL MVCC的实现方式,为行政区划表添加一对PostgreSQL提供的tstzrange类型字段,标识行政区划记录版本的有效时间段,并在查询时指明时间点作为筛选条件。PostgreSQL可以支持在范围类型与空间类型上建立联合GIST索引,提供高效查询支持。

不过,时序数据获取难度是很大的。而且一般这个需求也并不常见。所以这里就不展开了。


0x05 设计实现

既然已经将地理编码的功能从区划代码表拆分出来,本题对adcode中的结构就不甚关注了。我们只需要知道凭借code字段能从该表中快速查出我们感兴趣的东西,比如一连串的行政区划层次,行政区划的人口,面积,等级,行政中心等等。

create table adcode
(
  code         bigint PRIMARY KEY ,
  parent       bigint references adcode(code),
  name         text,
  rank         integer,
  path         text[],
        
  …… <other attrs>
);

相比之下,fences表才是我们需要关注的对象,因为这是性能损耗的关键路径。

CREATE TABLE fences (
  id    BIGSERIAL PRIMARY KEY,
  fence geometry(POLYGON),
  code  BIGINT
);

CREATE INDEX ON fences USING GiST(fence);
CREATE INDEX ON fences USING Btree(code);

CLUSTER TABLE fences USING fences_fence_idx;

不使用行政区划代码code作为主键,给予了我们更多的灵活性与优化空间。任何时候需要修正地理编码的逻辑时,只修改fences中的数据即可。你甚至可以添加冗余字段与条件索引,将不同来源的数据,不同等级的行政区划,相互重叠的地理围栏放在同一张表中,灵活地执行自定义的编码逻辑。

说句题外话:如果您能确保自己的数据不会重叠,则可以考虑使用PostgreSQL提供的Exclude约束确保数据完整性:

CREATE TABLE fences (
  id    BIGSERIAL PRIMARY KEY,
  fence geometry(POLYGON),
  code  BIGINT,
  EXCLUDE USING gist(fence WITH &&) 
     -- no need to create gist index for fence anymore
);

性能测试

那么优化完之后的性能表现又如何?让我们随机生成一些坐标点,检验一下性能。

\set	x	random(75,125)
\set	y	random(20,50)
SELECT code FROM fences2 WHERE ST_Contains(fence,ST_Point(:x,:y));

在笔者的机器上,现在一次查询只要0.1ms了,单进程9k TPS,折算为48核机器约为350kTPS

$ pgbench adcode -T 5 -f run.sql

number of clients: 1
number of threads: 1
duration: 5 s
number of transactions actually processed: 45710
latency average = 0.109 ms
tps = 9135.632484 (including connections establishing)
tps = 9143.947723 (excluding connections establishing)

当然拿到code之后还是需要去行政区划表里查一次,但一次索引扫描的开销是很小的。

总的来说,与优化之前的实现相比,性能提升了60倍。落实在生产环境中,可能就意味着省了百来万的成本。

Distinct On 去除重复数据

使用Distinct On扩展字句快速找出分组内具有最大最小值的记录

Distinct On是PostgreSQL提供的特有语法,可以高效解决一些典型查询问题,例如,快速找出分组内具有最大最小值的记录。

前言

找出分组内具有最大最小值的记录,这是一个非常常见的需求。用传统SQL当然有办法解决,但是都不够优雅,PostgreSQL的SQL扩展语法Distinct ON能一步到位解决这一类问题。

DISTINCT ON 语法

SELECT DISTINCT ON (expression [, expression ...]) select_list ...

Here expression is an arbitrary value expression that is evaluated for all rows. A set of rows for which all the expressions are equal are considered duplicates, and only the first row of the set is kept in the output. Note that the “first row” of a set is unpredictable unless the query is sorted on enough columns to guarantee a unique ordering of the rows arriving at the DISTINCT filter. (DISTINCT ON processing occurs after ORDER BY sorting.)

Distinct On应用案例

例如,找出每台机器的最新日志在日志表中,取出按照机器node_id分组,时间戳ts最大的的日志记录。

CREATE TABLE nodes(node_id INTEGER, ts TIMESTAMP);

INSERT INTO test_data
SELECT (random() * 10)::INTEGER as node_id, t
FROM generate_series('2019-01-01'::TIMESTAMP, '2019-05-01'::TIMESTAMP, '1h'::INTERVAL) AS t;

这里可以制造一些随机数据

5	2019-01-01 00:00:00.000000
0	2019-01-01 01:00:00.000000
9	2019-01-01 02:00:00.000000
1	2019-01-01 03:00:00.000000
7	2019-01-01 04:00:00.000000
2	2019-01-01 05:00:00.000000
8	2019-01-01 06:00:00.000000
3	2019-01-01 07:00:00.000000
1	2019-01-01 08:00:00.000000
4	2019-01-01 09:00:00.000000
9	2019-01-01 10:00:00.000000
0	2019-01-01 11:00:00.000000
3	2019-01-01 12:00:00.000000
6	2019-01-01 13:00:00.000000
9	2019-01-01 14:00:00.000000
1	2019-01-01 15:00:00.000000
7	2019-01-01 16:00:00.000000
8	2019-01-01 17:00:00.000000
9	2019-01-01 18:00:00.000000
10	2019-01-01 19:00:00.000000
5	2019-01-01 20:00:00.000000
4	2019-01-01 21:00:00.000000

现在使用DistinctON,这里Distinct On后面的括号里代表了记录需要按哪一个键进行除重,在括号内的表达式列表上有着相同取值的记录会只保留一条记录。(当然保留哪一条是随机的,因为分组内哪一条记录先返回是不确定的)

SELECT DISTINCT ON (node_id) * FROM test_data

0	2019-04-30 17:00:00.000000
1	2019-04-30 22:00:00.000000
2	2019-04-30 23:00:00.000000
3	2019-04-30 13:00:00.000000
4	2019-05-01 00:00:00.000000
5	2019-04-30 20:00:00.000000
6	2019-04-30 11:00:00.000000
7	2019-04-30 15:00:00.000000
8	2019-04-30 16:00:00.000000
9	2019-04-30 21:00:00.000000
10	2019-04-29 18:00:00.000000

DistinctON有一个配套的ORDER BY子句,用于指明分组内哪一条记录将被保留,排序第一条记录会留下,因此如果我们想要每台机器上的最新日志,可以这样写。

SELECT DISTINCT ON (node_id) * FROM test_data ORDER BY node_id, ts DESC NULLS LAST

0	2019-04-30 17:00:00.000000
1	2019-04-30 22:00:00.000000
2	2019-04-30 23:00:00.000000
3	2019-04-30 13:00:00.000000
4	2019-05-01 00:00:00.000000
5	2019-04-30 20:00:00.000000
6	2019-04-30 11:00:00.000000
7	2019-04-30 15:00:00.000000
8	2019-04-30 16:00:00.000000
9	2019-04-30 21:00:00.000000
10	2019-04-29 18:00:00.000000

使用索引加速Distinct On查询

Distinct On查询当然可以被索引加速,例如以下索引就可以让上面的查询用上索引

CREATE INDEX ON test_data USING btree(node_id, ts DESC NULLS LAST);

set enable_seqscan = off;
explain SELECT DISTINCT ON (node_id) * FROM test_data ORDER BY node_id, ts DESC NULLS LAST;
Unique  (cost=0.28..170.43 rows=11 width=12)
  ->  Index Only Scan using test_data_node_id_ts_idx on test_data  (cost=0.28..163.23 rows=2881 width=12)

注意,排序的时候一定要确保NULLS FIRST|LAST与查询时实际使用的规则匹配。否则可能用不上索引。

函数易变性等级分类

PgSQL中的函数默认有三种易变性等级,合理使用可以显著改善性能。

PgSQL中的函数默认有三种易变性等级,合理使用可以显著改善性能。

核心种差

  • VOLATILE : 有副作用,不可被优化。
  • STABLE: 执行了数据库查询。
  • IMMUTABLE : 纯函数,执行结果可能会在规划时被预求值并缓存。

什么时候用?

  • VOLATILE : 有任何写入,有任何副作用,需要看到外部命令所做的变更,或者调用了任何VOLATILE的函数
  • STABLE: 有数据库查询,但没有写入,或者函数的结果依赖于配置参数(例如时区)
  • IMMUTABLE : 纯函数。

具体解释

每个函数都带有一个易变性(Volatility) 等级。可能的取值包括 VOLATILESTABLE,以及IMMUTABLE。创建函数时如果没有指定易变性等级,则默认为 VOLATILE。易变性是函数对优化器的承诺:

  • VOLATILE函数可以做任何事情,包括修改数据库状态。在连续调用时即使使用相同的参数,也可能会返回不同的结果。优化器不会优化掉此类函数,每次调用都会重新求值。
  • STABLE函数不能修改数据库状态,且在单条语句中保证给定同样的参数一定能返回同样的结果,因而优化器可以将相同参数的多次调用优化成一次调用。在索引扫描条件中允许使用STABLE函数,但VOLATILE函数就不行。(一次索引扫描中只会对参与比较的值求值一次,而不是每行求值一次,因而在一个索引扫描条件中不能使用 VOLATILE函数)。
  • IMMUTABLE函数不能修改数据库状态,并且保证任何时候给定输入永远返回相同的结果。这种分类允许优化器在一个查询用常量参数调用该函数 时提前计算该函数。例如,一个 SELECT ... WHERE x = 2 + 2这样的查询可以被简化为SELECT ... WHERE x = 4,因为整数加法操作符底层的函数被 标记为IMMUTABLE

STABLE与IMMUTABLE的区别

调用次数优化

以下面这个函数为例,它只是简单的返回常数2

CREATE OR REPLACE FUNCTION return2() RETURNS INTEGER AS
$$
BEGIN
RAISE NOTICE 'INVOKED';
RETURN 2;
END;
$$ LANGUAGE PLPGSQL STABLE;

当使用STABLE标签时,它会真的调用10次,而当使用IMMUTABLE标签时,它会被优化为一次调用。

vonng=# select return2() from generate_series(1,10);
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
 return2
---------
       2
       2
       2
       2
       2
       2
       2
       2
       2
       2
(10 rows)

这里将函数的标签改为IMMUTABLE

CREATE OR REPLACE FUNCTION return2() RETURNS INTEGER AS
$$
BEGIN
RAISE NOTICE 'INVOKED';
RETURN 2;
END;
$$ LANGUAGE PLPGSQL IMMUTABLE;

再执行同样的查询,这次函数只被调用了一次

vonng=# select return2() from generate_series(1,10);
NOTICE:  INVOKED
 return2
---------
       2
       2
       2
       2
       2
       2
       2
       2
       2
       2
(10 rows)

执行计划缓存

第二个例子是有关索引条件中的函数调用,假设我们有这么一张表,包含从1到1000的整数:

create table demo as select * from generate_series(1,1000) as id;
create index idx_id on demo(id);

现在创建一个IMMUTABLE的函数mymax

CREATE OR REPLACE FUNCTION mymax(int, int)
RETURNS int
AS $$
BEGIN
     RETURN CASE WHEN $1 > $2 THEN $1 ELSE $2 END;
END;
$$ LANGUAGE 'plpgsql' IMMUTABLE;

我们会发现,当我们在索引条件中直接使用该函数时,执行计划中的索引条件被直接求值缓存并固化为了id=2

vonng=# EXPLAIN SELECT * FROM demo WHERE id = mymax(1,2);
                               QUERY PLAN
------------------------------------------------------------------------
 Index Only Scan using idx_id on demo  (cost=0.28..2.29 rows=1 width=4)
   Index Cond: (id = 2)
(2 rows)

而如果将其改为STABLE函数,则结果变为运行时求值:

vonng=# EXPLAIN SELECT * FROM demo WHERE id = mymax(1,2);
                               QUERY PLAN
------------------------------------------------------------------------
 Index Only Scan using idx_id on demo  (cost=0.53..2.54 rows=1 width=4)
   Index Cond: (id = mymax(1, 2))
(2 rows)

用 Exclude 实现互斥约束

Exclude约束是一个PostgreSQL扩展,它可以实现一些更高级,更巧妙的的数据库约束。

Exclude约束是一个PostgreSQL扩展,它可以实现一些更高级,更巧妙的的数据库约束。


前言

数据完整性是极其重要的,但由应用保证的数据完整性并不总是那么靠谱:人会犯傻,程序会出错。如果能通过数据库约束来强制数据完整性那是再好不过了:后端程序员不用再担心竞态条件导致的微妙错误,数据分析师也可以对数据质量充满信心,不需要验证与清洗。

关系型数据库通常会提供PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK约束,然而并不是所有的业务约束都可以用这几种约束表达。一些约束会稍微复杂一些,例如确保IP网段表中的IP范围不发生重叠,确保同一个会议室不会出现预定时间重叠,确保地理区划表中各个城市的边界不会重叠。传统上要实现这种保证是相当困难的:譬如UNIQUE约束就无法表达这种语义,CHECK与存储过程或者触发器虽然可以实现这种检查,但也相当tricky。PostgreSQL提供的EXCLUDE约束可以优雅地解决这一类问题。


Eclude约束的语法

 EXCLUDE [ USING index_method ] ( exclude_element WITH operator [, ... ] ) index_parameters [ WHERE ( predicate ) ] |
 
exclude_element in an EXCLUDE constraint is:
{ column_name | ( expression ) } [ opclass ] [ ASC | DESC ] [ NULLS { FIRST | LAST } ]

EXCLUDE子句定一个排除约束,它保证如果任意两行在指定列或表达式上使用指定操作符进行比较,不是所有的比较都将会返回TRUE。如果所有指定的操作符都测试相等,这就等价于一个UNIQUE约束,尽管一个普通的唯一约束将更快。不过,排除约束能够指定比简单相等更通用的约束。例如,你可以使用&&操作符指定一个约束,要求表中没有两行包含相互覆盖的圆(见 Section 8.8)。

排除约束使用一个索引实现,这样每一个指定的操作符必须与用于索引访问方法index_method的一个适当的操作符类(见Section 11.9)相关联。操作符被要求是交换的。每一个exclude_element可以选择性地指定一个操作符类或者顺序选项,这些在???中有完整描述。

访问方法必须支持amgettuple(见Chapter 61),目前这意味着GIN无法使用。尽管允许,但是在一个排除约束中使用 B-树或哈希索引没有意义,因为它无法做得比一个普通唯一索引更出色。因此在实践中访问方法将总是GiST或SP-GiST。

predicate允许你在该表的一个子集上指定一个排除约束。在内部这会创建一个部分索引。注意在为此周围的圆括号是必须的。


应用案例:会议室预定

假设我们想要设计一个会议室预定系统,并希望在数据库层面确保不会有冲突的会议室预定出现:即,对于同一个会议室,不允许同时存在两条预定时间范围上存在重叠的记录。那么数据库表可以这样设计:

-- PostgreSQL自带扩展,为普通类型添加GIST索引运算符支持
CREATE EXTENSION btree_gist;

-- 会议室预定表
CREATE TABLE meeting_room
(
    id      SERIAL PRIMARY KEY,
    user_id INTEGER,
    room_id INTEGER,
    range   tsrange,
    EXCLUDE USING GIST(room_id WITH = , range WITH &&)
);

这里EXCLUDE USING GIST(room_id WITH = , range WITH &&)指明了一个排它约束:不允许存在room_id相等,且range相互重叠的多条记录。

-- 用户1预定了101号房间,从早上10点到下午6点
INSERT INTO meeting_room(user_id, room_id, range) 
VALUES (1,101, tsrange('2019-01-01 10:00', '2019-01-01 18:00'));

-- 用户2也尝试预定101号房间,下午4点到下午6点
INSERT INTO meeting_room(user_id, room_id, range) 
VALUES (2,101, tsrange('2019-01-01 16:00', '2019-01-01 18:00'));

-- 用户2的预定报错,违背了排它约束
ERROR:  conflicting key value violates exclusion constraint "meeting_room_room_id_range_excl"
DETAIL:  Key (room_id, range)=(101, ["2019-01-01 16:00:00","2019-01-01 18:00:00")) conflicts with existing key (room_id, range)=(101, ["2019-01-01 10:00:00","2019-01-01 18:00:00")).

这里的EXCLUDE约束会自动创建一个相应的GIST索引:

"meeting_room_room_id_range_excl" EXCLUDE USING gist (room_id WITH =, range WITH &&)

应用案例:确保IP网段不重复

有一些约束是相当复杂的,例如确保表中的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索引,因此无需手工创建了。

file_fdw妙用无穷——从数据库读取系统信息

通过file_fdw,轻松查看操作系统信息,拉取网络数据,把各种各样的数据源轻松喂进数据库里统一查看管理。

PostgreSQL是最先进的开源数据库,其中一个非常给力的特性就是FDW:外部数据包装器(Foreign Data Wrapper)。通过FDW,用户可以用统一的方式从Pg中访问各类外部数据源。file_fdw就是其中随数据库附赠的两个fdw之一。随着pg10的更新,file_fdw也添加了一颗赛艇的功能:从程序输出读取。

小霸王妙用无穷,我们能通过file_fdw,轻松查看操作系统信息,拉取网络数据,把各种各样的数据源轻松喂进数据库里统一查看管理。


安装与配置

file_fdw是Pg自带的组件,不需要奇怪的配置,在数据库中执行以下命令即可启用file_fdw

CREATE EXTENSION file_fdw;

启用FDW插件之后,需要创建一个实例,也是一行SQL搞定,创建一个名为fs的FDW Server实例。

CREATE SERVER fs FOREIGN DATA WRAPPER file_fdw;

创建外部表

举个栗子,如果我想从数据库中读取操作系统中正在运行的进程信息,该怎么做呢?

最典型,也是最常用的外部数据格式就是CSV啦。不过系统命令输出的结果并不是很规整:

>>> ps ux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
vonng     2658  0.0  0.2 148428  2620 ?        S    11:51   0:00 sshd: vonng@pts/0,pts/2
vonng     2659  0.0  0.2 115648  2312 pts/0    Ss+  11:51   0:00 -bash
vonng     4854  0.0  0.2 115648  2272 pts/2    Ss   15:46   0:00 -bash
vonng     5176  0.0  0.1 150940  1828 pts/2    R+   16:06   0:00 ps -ux
vonng    26460  0.0  1.2 271808 13060 ?        S    10月26   0:22 /usr/local/pgsql/bin/postgres
vonng    26462  0.0  0.2 271960  2640 ?        Ss   10月26   0:00 postgres: checkpointer process
vonng    26463  0.0  0.2 271808  2148 ?        Ss   10月26   0:25 postgres: writer process
vonng    26464  0.0  0.5 271808  5300 ?        Ss   10月26   0:27 postgres: wal writer process
vonng    26465  0.0  0.2 272216  2096 ?        Ss   10月26   0:31 postgres: autovacuum launcher process
vonng    26466  0.0  0.1 126896  1104 ?        Ss   10月26   0:54 postgres: stats collector process
vonng    26467  0.0  0.1 272100  1588 ?        Ss   10月26   0:01 postgres: bgworker: logical replication launcher

可以通过awk,将ps的命令输出规整为分隔符为\x1F的csv格式。

ps aux | awk '{print $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,substr($0,index($0,$11))}' OFS='\037'

正戏来啦!通过以下DDL创建一张外表定义

CREATE FOREIGN TABLE process_status (
  username TEXT,
  pid      INTEGER,
  cpu      NUMERIC,
  mem      NUMERIC,
  vsz      BIGINT,
  rss      BIGINT,
  tty      TEXT,
  stat     TEXT,
  start    TEXT,
  time     TEXT,
  command  TEXT
) SERVER fs OPTIONS (
PROGRAM $$ps aux | awk '{print $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,substr($0,index($0,$11))}' OFS='\037'$$,
FORMAT 'csv', DELIMITER E'\037', HEADER 'TRUE');

这里,关键是通过CREATE FOREIGN TABLE OPTIONS (xxxx)中的OPTIONS提供相应的参数,在PROGRAM参数中填入上面的命令,pg就会在查询这张表的时候自动执行此命令,并读取其输出。FORMAT参数可以指定为CSVDELIMITER参数指定为之前使用的\x1F,并通过HEADER 'TRUE'忽略CSV的第一行

那么结果如何呢?


有什么用

最简单的场景,原本系统指标监控需要编写各种监测脚本,部署在奇奇怪怪的地方。然后定期执行拉取metric,再存进数据库。现在通过file_fdw的方式,可以将感兴趣的指标直接录入数据库表,一步到位,而且维护方便,部署简单,更加可靠。在外表上加上视图,定期拉取聚合,将原本一个监控系统完成的事情,在数据库中一条龙解决了。

因为可以从程序输出读取结果,因此file_fdw可以与linux生态里各类强大的命令行工具配合使用,发挥出强大的威力。


其他栗子

诸如此类,实际上后来我发现Facebook貌似有一个类似的产品,叫OSQuery,也是干了差不多的事。通过SQL查询操作系统的指标。但明显PostgreSQL这种方法最简单粗暴高效啦,只要定义表结构,和命令数据源就能轻松对接指标数据,用不了一天就能做出一个功能差不多的东西来。

用于读取系统用户列表的DDL:

CREATE FOREIGN TABLE etc_password (
  username  TEXT,
  password  TEXT,
  user_id   INTEGER,
  group_id  INTEGER,
  user_info TEXT,
  home_dir  TEXT,
  shell     TEXT
) SERVER fs OPTIONS (
  PROGRAM $$awk -F: 'NF && !/^[:space:]*#/ {print $1,$2,$3,$4,$5,$6,$7}' OFS='\037' /etc/passwd$$, 
  FORMAT 'csv', DELIMITER E'\037'
);

用于读取磁盘用量的DDL:

CREATE FOREIGN TABLE disk_free (
  file_system TEXT,
  blocks_1m   BIGINT,
  used_1m     BIGINT,
  avail_1m    BIGINT,
  capacity    TEXT,
  iused       BIGINT,
  ifree       BIGINT,
  iused_pct   TEXT,
  mounted_on  TEXT
) SERVER fs OPTIONS (PROGRAM $$df -ml| awk '{print $1,$2,$3,$4,$5,$6,$7,$8,$9}' OFS='\037'$$, FORMAT 'csv', HEADER 'TRUE', DELIMITER E'\037'
);

当然,用file_fdw只是一个很Naive的FDW,譬如这里就只能读,不能改。

自己编写FDW实现增删改查逻辑也非常简单,例如Multicorn就是使用Python编写FDW的项目。

SQL over everything,让世界变的更简单~

GO与PG实现缓存同步

巧妙运用Pg的Notify功能,可以方便地通知应用元数据变更,实现基于触发器的逻辑复制。

Parallel与Hierarchy是架构设计的两大法宝,缓存是Hierarchy在IO领域的体现。单线程场景下缓存机制的实现可以简单到不可思议,但很难想象成熟的应用会只有一个实例。在使用缓存的同时引入并发,就不得不考虑一个问题:如何保证每个实例的缓存与底层数据副本的数据一致性(和实时性)。

PostgreSQL在版本9引入了流式复制,在版本10引入了逻辑复制,但这些都是针对PostgreSQL数据库而言的。如果希望PostgreSQL中某张表的部分数据与应用内存中的状态保持一致,我们还是需要自己实现一种逻辑复制的机制。对于关键的少量元数据而言,使用触发器与Notify-Listen就是一个不错的选择。


传统方法

最简单粗暴的办法就是定时重新拉取,例如每个整点,所有应用一起去数据库拉取一次最新版本的数据。很多应用都是这么做的。当然问题也很多:拉的间隔长了,变更不能及时应用,用户体验差;拉的频繁了,IO压力大。而且实例数目和数据大小一旦膨胀起来,对于宝贵的IO资源是很大的浪费。

异步通知是一种更好的办法,尤其是在读请求远多于写请求的情况下。接受到写请求的实例,通过发送广播的方式通知其他实例。RedisPubSub就可以很好地实现这个功能。如果原本下层存储就是Redis自然是再方便不过,但如果下层存储是关系型数据库的话,为这样一个功能引入一个新的组件似乎有些得不偿失。况且考虑到后台管理程序或者其他应用如果在修改了数据库后也要去redis发布通知,实在太麻烦了。一种可行的办法是通过数据库中间件来监听RDS变动并广播通知,淘宝不少东西就是这么做的。但如果DB本身就能搞定的事情,为什么需要额外的组件呢?通过PostgreSQL的Notfiy-Listen机制,可以方便地实现这种功能。


目标

无论从任何渠道产生的数据库记录变更(增删改)都能被所有相关应用实时感知,用于维护自身缓存与数据库内容的一致性。


原理

PostgreSQL行级触发器 + Notify机制 + 自定义协议 + Smart Client

  • 行级触发器:通过为我们感兴趣的表建立一个行级别的写触发器,对数据表中的每一行记录的Update,Delete,Insert都会出发自定义函数的执行。
  • Notify:通过PostgreSQL内建的异步通知机制向指定的Channel发送通知
  • 自定义协议:协商消息格式,传递操作的类型与变更记录的标识
  • Smart Client:客户端监听消息变更,根据消息对缓存执行相应的操作。

实际上这样一套东西就是一个超简易的WAL(Write After Log)实现,从而使应用内部的缓存状态能与数据库保持实时一致(compare to poll)。


DDL

这里以一个最简单的表作为示例,一张以主键标识的users表。

-- 用户表
CREATE TABLE users (
  id   TEXT,
  name TEXT,
  PRIMARY KEY (id)
);

触发器

-- 通知触发器
CREATE OR REPLACE FUNCTION notify_change() RETURNS TRIGGER AS $$
BEGIN
  IF    (TG_OP = 'INSERT') THEN 
	PERFORM pg_notify(TG_RELNAME || '_chan', 'I' || NEW.id); RETURN NEW;
  ELSIF (TG_OP = 'UPDATE') THEN 
	PERFORM pg_notify(TG_RELNAME || '_chan', 'U' || NEW.id); RETURN NEW;
  ELSIF (TG_OP = 'DELETE') THEN 
	PERFORM pg_notify(TG_RELNAME || '_chan', 'D' || OLD.id); RETURN OLD;
  END IF;
END; $$ LANGUAGE plpgsql SECURITY DEFINER;

这里创建了一个触发器函数,通过内置变量TG_OP获取操作的名称,TG_RELNAME获取表名。每当触发器执行时,它会向名为<table_name>_chan的通道发送指定格式的消息:[I|U|D]<id>

题外话:通过行级触发器,还可以实现一些很实用的功能,例如In-DB Audit,自动更新字段值,统计信息,自定义备份策略与回滚逻辑等。

-- 为用户表创建行级触发器,监听INSERT UPDATE DELETE 操作。
CREATE TRIGGER t_user_notify AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE PROCEDURE notify_change();

创建触发器也很简单,表级触发器对每次表变更执行一次,而行级触发器对每条记录都会执行一次。这样,数据库的里的工作就算全部完成了。


消息格式

通知需要传达出两个信息:变更的操作类型,变更的实体标记。

  • 变更的操作类型就是增删改:INSERT,DELETE,UPDATE。通过一个打头的字符’[I|U|D]‘就可以标识。
  • 变更的对象可以通过实体主键来标识。如果不是字符串类型,还需要确定一种无歧义的序列化方式。

这里为了省事直接使用字符串类型作为ID,那么插入一条id=1的记录,对应的消息就是I1,更新一条id=5的记录消息就是U5,删除id=3的记录消息就是D3

完全可以通过更复杂的消息协议实现更强大的功能。


智能客户端

数据库的机制需要客户端的配合才能生效,客户端需要监听数据库的变更通知,才能将变更实时应用到自己的缓存副本中。对于插入和更新,客户端需要根据ID重新拉取相应实体,对于删除,客户端需要删除自己缓存副本的相应实体。以Go语言为例,编写了一个简单的客户端模块。

本例中使用一个以User.ID作为键,User对象作为值的并发安全字典Users sync.Map作为缓存。

作为演示,启动了另一个goroutine对数据库写入了一些变更。

package main

import "sync"
import "strings"
import "github.com/go-pg/pg"
import . "github.com/Vonng/gopher/db/pg"
import log "github.com/Sirupsen/logrus"

type User struct {
	ID   string `sql:",pk"`
	Name string
}

var Users sync.Map // Users 内部数据缓存

func LoadAllUser() {
	var users []User
	Pg.Query(&users, `SELECT ID,name FROM users;`)
	for _, user := range users {
		Users.Store(user.ID, user)
	}
}

func LoadUser(id string) {
	user := User{ID: id}
	Pg.Select(&user)
	Users.Store(user.ID, user)
}

func PrintUsers() string {
	var buf []string
	Users.Range(func(key, value interface{}) bool {
		buf = append(buf, key.(string));
		return true
	})
	return strings.Join(buf, ",")
}

// ListenUserChange 会监听PostgreSQL users数据表中的变动通知
func ListenUserChange() {
	go func(c <-chan *pg.Notification) {
		for notify := range c {
			action, id := notify.Payload[0], notify.Payload[1:]
			switch action {
			case 'I':
				fallthrough
			case 'U':
				LoadUser(id);
			case 'D':
				Users.Delete(id)
			}
			log.Infof("[NOTIFY] Action:%c ID:%s Users: %s", action, id, PrintUsers())
		}
	}(Pg.Listen("users_chan").Channel())
}

// MakeSomeChange 会向数据库写入一些变更
func MakeSomeChange() {
	go func() {
		Pg.Insert(&User{"001", "张三"})
		Pg.Insert(&User{"002", "李四"})
		Pg.Insert(&User{"003", "王五"})  // 插入
		Pg.Update(&User{"003", "王麻子"}) // 改名
		Pg.Delete(&User{ID: "002"})    // 删除
	}()
}

func main() {
	Pg = NewPg("postgres://localhost:5432/postgres")
	Pg.Exec(`TRUNCATE TABLE users;`)
	LoadAllUser()
	ListenUserChange()
	MakeSomeChange()
	<-make(chan struct{})
}

运行结果如下:

[NOTIFY] Action:I ID:001 Users: 001          
[NOTIFY] Action:I ID:002 Users: 001,002      
[NOTIFY] Action:I ID:003 Users: 002,003,001  
[NOTIFY] Action:U ID:003 Users: 001,002,003  
[NOTIFY] Action:D ID:002 Users: 001,003      

可以看出,缓存确是与数据库保持了同样的状态。


应用场景

小数据量下这种做法是相当可靠的,大数据量下尚未进行充分的测试。

其实,对于上例中缓存同步的场景,完全不需要自定义消息格式,只要发送发生变更的记录ID,由应用直接拉取,然后覆盖或删除缓存中的记录即可。

用触发器审计数据变化

有时候,我们希望记录一些重要的元数据变更,以便事后审计之用。PostgreSQL的触发器就可以很方便地自动解决这一需求。

有时候,我们希望记录一些重要的元数据变更,以便事后审计之用。

PostgreSQL的触发器就可以很方便地自动解决这一需求。

-- 创建一个审计专用schema,并废除所有非superuser的权限。
DROP SCHEMA IF EXISTS audit CASCADE;
CREATE SCHEMA IF NOT EXISTS audit;
REVOKE CREATE ON SCHEMA audit FROM PUBLIC;

-- 审计表
CREATE TABLE audit.action_log (
  schema_name   TEXT                     NOT NULL,
  table_name    TEXT                     NOT NULL,
  user_name     TEXT,
  time          TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
  action        TEXT                     NOT NULL CHECK (action IN ('I', 'D', 'U')),
  original_data TEXT,
  new_data      TEXT,
  query         TEXT
) WITH (FILLFACTOR = 100
);

-- 审计表权限
REVOKE ALL ON audit.action_log FROM PUBLIC;
GRANT SELECT ON audit.action_log TO PUBLIC;


-- 索引
CREATE INDEX logged_actions_schema_table_idx
  ON audit.action_log (((schema_name || '.' || table_name) :: TEXT));

CREATE INDEX logged_actions_time_idx
  ON audit.action_log (time);

CREATE INDEX logged_actions_action_idx
  ON audit.action_log (action);
---------------------------------------------------------------


---------------------------------------------------------------
-- 创建审计触发器函数
---------------------------------------------------------------
CREATE OR REPLACE FUNCTION audit.logger()
  RETURNS TRIGGER AS $body$
DECLARE
  v_old_data TEXT;
  v_new_data TEXT;
BEGIN
  IF (TG_OP = 'UPDATE')
  THEN
    v_old_data := ROW (OLD.*);
    v_new_data := ROW (NEW.*);
    INSERT INTO audit.action_log (schema_name, table_name, user_name, action, original_data, new_data, query)
    VALUES (TG_TABLE_SCHEMA :: TEXT, TG_TABLE_NAME :: TEXT, session_user :: TEXT, substring(TG_OP, 1, 1), v_old_data,
            v_new_data, current_query());
    RETURN NEW;
  ELSIF (TG_OP = 'DELETE')
    THEN
      v_old_data := ROW (OLD.*);
      INSERT INTO audit.action_log (schema_name, table_name, user_name, action, original_data, query)
      VALUES (TG_TABLE_SCHEMA :: TEXT, TG_TABLE_NAME :: TEXT, session_user :: TEXT, substring(TG_OP, 1, 1), v_old_data,
              current_query());
      RETURN OLD;
  ELSIF (TG_OP = 'INSERT')
    THEN
      v_new_data := ROW (NEW.*);
      INSERT INTO audit.action_log (schema_name, table_name, user_name, action, new_data, query)
      VALUES (TG_TABLE_SCHEMA :: TEXT, TG_TABLE_NAME :: TEXT, session_user :: TEXT, substring(TG_OP, 1, 1), v_new_data,
              current_query());
      RETURN NEW;
  ELSE
    RAISE WARNING '[AUDIT.IF_MODIFIED_FUNC] - Other action occurred: %, at %', TG_OP, now();
    RETURN NULL;
  END IF;

  EXCEPTION
  WHEN data_exception
    THEN
      RAISE WARNING '[AUDIT.IF_MODIFIED_FUNC] - UDF ERROR [DATA EXCEPTION] - SQLSTATE: %, SQLERRM: %', SQLSTATE, SQLERRM;
      RETURN NULL;
  WHEN unique_violation
    THEN
      RAISE WARNING '[AUDIT.IF_MODIFIED_FUNC] - UDF ERROR [UNIQUE] - SQLSTATE: %, SQLERRM: %', SQLSTATE, SQLERRM;
      RETURN NULL;
  WHEN OTHERS
    THEN
      RAISE WARNING '[AUDIT.IF_MODIFIED_FUNC] - UDF ERROR [OTHER] - SQLSTATE: %, SQLERRM: %', SQLSTATE, SQLERRM;
      RETURN NULL;
END;
$body$
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, audit;

COMMENT ON FUNCTION audit.logger() IS '记录特定表上的插入、修改、删除行为';
---------------------------------------------------------------


---------------------------------------------------------------
-- 最后修改时间审计触发器函数
---------------------------------------------------------------
-- 当记录发生变更前,记录修改时间。
CREATE OR REPLACE FUNCTION audit.update_mtime()
  RETURNS TRIGGER AS $$
BEGIN
  NEW.mtime = now();
  RETURN NEW;
END;
$$ LANGUAGE 'plpgsql';

COMMENT ON FUNCTION audit.update_mtime() IS '更新记录mtime';
---------------------------------------------------------------


---------------------------------------------------------------
-- 元数据变动事件触发器函数
-- 向'change'信道发送数据变动的表名
---------------------------------------------------------------
CREATE OR REPLACE FUNCTION audit.notify_change()
  RETURNS TRIGGER AS $$
BEGIN
  PERFORM pg_notify('change', TG_RELNAME);
  RETURN NULL;
END;
$$ LANGUAGE 'plpgsql';

COMMENT ON FUNCTION audit.notify_change() IS '数据变动事件触发器函数,向`change`信道发送数据变动的表名';
---------------------------------------------------------------

SQL实现ItemCF推荐系统

用PostgreSQL 5分钟实现一个最简单ItemCF推荐系统

推荐系统大家都熟悉,猜你喜欢,淘宝个性化什么的,前年双十一搞了个大新闻,还拿了CEO特别贡献奖。

今天就来说说怎么用PostgreSQL 5分钟实现一个最简单ItemCF推荐系统,以推荐系统最喜闻乐见的movielens数据集为例。


原理

ItemCF的原理可以看项亮的《推荐系统实战》,不过还是稍微提一下吧,了解的直接跳过就好。

Item CF,全称Item Collaboration Filter,即基于物品的协同过滤,是目前业界应用最多的推荐算法。ItemCF不需要物品与用户的标签、属性,只要有用户对物品的行为日志就可以了,同时具有很好的可解释性。所以无论是亚马逊,Hulu,YouTube,balabala用的都是该算法。

ItemCF算法的核心思想是:给用户推荐那些和他们之前喜欢的物品相似的物品。

这里有两个要点:

  • 用户喜欢物品怎么表示?
  • 物品的相似度怎样表示?

用户评分表

可以通过用户评分表来判断用户对物品的喜爱程度,例如电影数据的5分制:5分表示非常喜欢,1分表示不喜欢。

用户评分表有三个核心字段:user_id, movie_id, rating,分别是用户ID,物品ID,用户对物品的评分。

怎样得到这个表呢?如果本来就是评论打分网站在做推荐系统,直接有用户对电影,音乐,小说的评分记录那是最好不过。其他的场景,比如电商,社交网络,则可以通过用户对物品的行为日志生成这张评分表。例如可以为“浏览”,“点击”,“收藏”,“购买”,点击“我不喜欢”按钮这些行为分别设一个喜好权重:0.1, 0.2, 0.3, 0.4, -100。将所有行为评分加权求和,最终得到这张用户对物品的评分表来,事就成了一半了。

物品相似度

还需要解决的一个问题是物品相似度的计算表示

假设一共有$N$个物品,则物品相似度数据可以表示为一个$N \times N$的矩阵,第$i$行$j$列的值表示物品$i$与物品$j$之间的相似度。这样相似度表示的问题就解决了。

第二个问题是物品相似度矩阵的计算。

但在计算前,首先必须定义,什么是物品的相似度?

两个物品之间的相似度有很多种定义与计算方式,如果我们有物品的各种属性数据(类型,大小,价格,风格,标签)的话,就可以在属性空间定义各式各样的“距离”,来定义相似度。但ItemCF的亮点就在于,不需要物品的属性标签数据也可以计算其相似度来。其核心思想是:如果一对物品被很多人同时喜欢,则认为这一对物品更为相似。

令$N(i)$为喜欢物品$i$的用户集合,$|N(i)|$为喜欢物品$i$的人数,$|N(i) \cap N(j)|$为同时喜欢物品$i,j$的人数,则物品$i,j$之间的相似度$_{ij}$可w以表示为:

$$ w_{ij} = \frac{|N(i) \cap N(j)|}{ \sqrt{ |N(i)| * |N(j)|}} $$

即:同时喜欢物品$i,j$的人数,除以喜爱物品$i$人数和喜爱物品$j$人数的几何平均数。

这样,就可以通过用户对物品的行为日志,导出一份物品之间的相似矩阵数据来。

推荐物品

现在有一个用户$u$,他对物品$j$的评分可以通过以下公式计算:

$$ \displaystyle p_{uj} = \sum_{i \in N(u) \cap S(i, K)} w_{ji}r_{ui} $$

其中,用户$i$对物品$i_1,i_2,\cdots,i_n$的评分分别为$r_1,r_2,…,r_n$,而物品$i_1,i_2,\cdots,i_n$与目标物品$j$的相似度分别为$w_1,w_2,\cdots,w_n$。以用户$u$评分过的物品集合作为纽带,按照评分以相似度加权求和,就可以得到用户$u$对物品$j$的评分了。

对这个预测评分$p$排序取TopN,就得到了用户$u$的推荐物品列表


实践

说了这么多废话,赶紧燥起来。

第一步:准备数据

下载Movielens数据集,开发测试的话选小规模的(100k)就可以。对于ItemCF来说,有用的数据就是用户行为日志,即文件ratings.csv地址

-- movielens 用户评分数据集
CREATE TABLE mls_ratings (
  user_id   INTEGER,
  movie_id  INTEGER,
  rating    TEXT,
  timestamp INTEGER,
  PRIMARY KEY (user_id, movie_id)
);

-- 从CSV导入数据,并将评分乘以2变为2~10的整数便于处理,将Unix时间戳转换为日期类型
COPY mls_ratings FROM '/Users/vonng/Dev/recsys/ml-latest-small/ratings.csv' DELIMITER ',' CSV HEADER;
ALTER TABLE mls_ratings
  ALTER COLUMN rating SET DATA TYPE INTEGER USING (rating :: DECIMAL * 2) :: INTEGER;
ALTER TABLE mls_ratings
  ALTER COLUMN timestamp SET DATA TYPE TIMESTAMPTZ USING to_timestamp(timestamp :: DOUBLE PRECISION);

得到的数据长这样:第一列用户ID列表,第二列电影ID列表,第三列是评分,最后是时间戳。一共十万条

movielens=# select * from mls_ratings limit 10;
 user_id | movie_id | rating |       timestamp
---------+----------+--------+------------------------
       1 |       31 |      5 | 2009-12-14 10:52:24+08
       1 |     1029 |      6 | 2009-12-14 10:52:59+08
       1 |     1061 |      6 | 2009-12-14 10:53:02+08
       1 |     1129 |      4 | 2009-12-14 10:53:05+08

第二步:计算物品相似度

物品相似度的DDL

-- 物品相似度表,这是把矩阵用<i,j,M_ij>的方式在数据库中表示。
CREATE TABLE mls_similarity (
  i INTEGER,
  j INTEGER,
  p FLOAT,
  PRIMARY KEY (i, j)
);

物品相似度是一个矩阵,虽说PostgreSQL里提供了数组,多维数组,自定义数据结构,不过这里为了方便起见还是使用了最传统的矩阵表示方法:坐标索引法$(i,j,m_{ij})$。其中前两个元素为矩阵下标,各自表示物品的ID。最后一个元素存储了这一对物品的相似度。

物品相似度的计算

计算物品相似度,要计算两个中间数据:

  • 每个物品被用户喜欢的次数:$|N(i)|$
  • 每对物品共同被同一个用户喜欢的次数 $|N(i) \cap N(j)|$

如果是用编程语言,那自然可以一趟(One-Pass)解决两个问题。不过SQL就要稍微麻烦点了,好处是不用操心撑爆内存的问题。

这里可以使用PostgreSQL的With子句功能,计算两个临时结果供后续使用,一条SQL就搞定相似矩阵计算:

-- 计算物品相似度矩阵: 3m 53s
WITH mls_occur AS ( -- 中间表:计算每个电影被用户看过的次数
    SELECT
      movie_id,     -- 电影ID: i
      count(*) AS n -- 看过电影i的人数: |N(i)|
    FROM mls_ratings
    GROUP BY movie_id
),
    mls_common AS ( -- 中间表:计算每对电影被用户同时看过的次数
      SELECT
        a.movie_id AS i, -- 电影ID: i
        b.movie_id AS j, -- 电影ID: j
        count(*)   AS n  -- 同时看过电影i和j的人数: |N(i) ∩ N(j)|
      FROM mls_ratings a INNER JOIN mls_ratings b ON a.user_id = b.user_id
      GROUP BY i, j
  )
INSERT INTO mls_similarity
  SELECT
    i,
    j,
    n / sqrt(n1 * n2) AS p  -- 距离公式
  FROM
    mls_common c,
    LATERAL (SELECT n AS n1 FROM mls_occur WHERE movie_id = i) n1,
    LATERAL (SELECT n AS n2 FROM mls_occur WHERE movie_id = j) n2;

物品相似度表大概长这样:

movielens=# SELECT * FROM mls_similarity LIMIT 10;
   i    | j |         p
--------+---+--------------------
 140267 | 1 |  0.110207753755597
   2707 | 1 |  0.180280682843137
 140174 | 1 |  0.113822078644894
   7482 | 1 | 0.0636284762975778

实际上还可以修剪修剪,比如计算时非常小的相似度干脆可以直接删掉。也可以用整个表中相似度的最大值作为单位1,进行归一化。这里都不弄了。


第三步:进行推荐!

现在假设我们为ID为10的用户推荐10部他没看过的电影,该怎么做呢?

WITH seed AS	-- 10号用户评分过的影片作为种子集合
  (SELECT movie_id,rating FROM mls_ratings WHERE user_id = 10)
SELECT
  j as movie_id,	-- 所有待预测评分的电影ID
  sum(seed.rating * p) AS score -- 预测加权分,按此字段降序排序取TopN
FROM
  seed LEFT JOIN mls_similarity s ON seed.movie_id = s.i 
  WHERE j not in (SELECT DISTINCT movie_id FROM seed) -- 去除已经看过的电影(可选)
GROUP BY j ORDER BY score DESC LIMIT 10; -- 聚合,排序,取TOP

推荐结果如下:

 movie_id |      score
----------+------------------
     1270 | 121.487735902517
     1214 | 116.146138947698
     1580 | 116.015331936539
     2797 | 115.144083402858
     1265 | 114.959033115913
      260 | 114.313571128143
     2716 | 113.087151014987
     1097 |  113.07771922959
     1387 | 112.869891345883
     2916 |  112.84326997566

可以进一步包装一下,把它变成一个存储过程get_recommendation

CREATE OR REPLACE FUNCTION get_recommendation(userid INTEGER)
  RETURNS JSONB AS $$ BEGIN
  RETURN (SELECT jsonb_agg(movie_id)
          FROM (WITH seed AS
          (SELECT movie_id,rating FROM mls_ratings WHERE user_id = userid)
                SELECT
                  j as movie_id,
                  sum(seed.rating * p) AS score
                FROM
                  seed LEFT JOIN mls_similarity s ON seed.movie_id = s.i
                WHERE j not in (SELECT DISTINCT movie_id FROM seed)
                GROUP BY j ORDER BY score DESC LIMIT 10) res);
END $$ LANGUAGE plpgsql STABLE;

这样用起来更方便啦,同时也可以在这里加入一些其他的处理逻辑:比如过滤掉禁片黄片,去除用户明确表示过不喜欢的电影,加入一些热门电影,引入一些随机惊喜,打点小广告之类的。

movielens=# SELECT get_recommendation(11) as res;
                                  res
-----------------------------------------------------------------------
 [80489, 96079, 79132, 59315, 91529, 69122, 58559, 59369, 1682, 71535]

最后写个应用把这个存储过程作为OpenAPI开放出去,事就这样成了。

关于这一步可以参考前一篇:当PostgreSQL遇上GraphQL:Postgraphql中的做法,直接由存储过程生成GraphQL API,啥都不用操心了。


What’s more

几行SQL一条龙执行下来,加上下载数据的时间,总共也就五分钟吧。一个简单的推荐系统就这样搭建起来了。

但一个真正的生产系统还需要考虑许许多多其他问题,例如,性能。

这里比如说计算相似度矩阵的时候,才100k条记录花了三四分钟,不太给力。而且这么多SQL写起来,管理起来也麻烦,有没有更好的方案?

这儿有个基于PostgreSQL源码魔改的推荐数据库:RecDB,直接用C实现了推荐系统相关的功能扩展,性能看起来杠杠地;同时还包装了SQL语法糖,一行SQL建立推荐系统!再一行SQL就开始使用啦。

-- 计算推荐所需的信息
CREATE RECOMMENDER MovieRec ON ml_ratings
USERS FROM userid
ITEMS FROM itemid
EVENTS FROM ratingval
USING ItemCosCF

-- 进行推荐!
SELECT * FROM ml_ratings R
RECOMMEND R.itemid TO R.userid ON R.ratingval USING ItemCosCF
WHERE R.userid = 1
ORDER BY R.ratingval
LIMIT 10

PostgreSQL能干的事情太多了,最先进的开源关系数据库确实不是吹的,其实真的可以试一试。

UUID性质原理与应用

UUID性质原理与应用,以及如何利用PostgreSQL的存储过程操作UUID。

最近一个项目需要生成业务流水号,需求如下:

  • ID必须是分布式生成的,不能依赖中心节点分配并保证全局唯一。
  • ID必须包含时间戳并尽量依时序递增。(方便阅读,提高索引效率)
  • ID尽量散列。(分片,与HBase日志存储需要)

在造轮子之前,首先要看一下有没有现成的解决方案。

Serial

传统实践上业务流水号经常通过数据库自增序列或者发码服务来实现。 MySQLAuto Increment,PostgresSerial,或者Redis+lua写个小发码服务都是方便快捷的解决方案。这种方案可以保证全局唯一,但会出现中心节点依赖:每个节点需要访问一次数据库才能拿到序列号。这就产生了可用性问题:如果能在本地生成流水号并直接返回响应,那为什么非要用一次网络访问拿ID呢?如果数据库挂了,节点也GG了。所以这并不是一个理想的方案。

SnowflakeID

然后就是twitter的SnowflakeID了,SnowflakeID是一个BIGINT,第一位不用,41bit的时间戳,10bit的节点ID,12bit的毫秒内序列号。时间戳,工作机器ID,序列号占用的位域长度是可以根据业务需求不同而变化的。

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |x|                    41-bit timestamp                         |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |       timestamp   |10-bit machine node|    12-bit serial      |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

SnowflakeID可以说基本满足了这四个需求,首先,通过不同的时间戳(精确到毫秒),节点ID(工作机器ID),以及毫秒内的序列号,某种意义上确实可以做到唯一。一个比较讨喜的特性是所有ID是依时序递增的,所以索引起来或者拉取数据会非常方便,长整形的索引和存储效率也很高,生成效率也没得说。

但我认为SnowflakeId存在两个致命问题:

  • 虽然ID生成不需要中心节点分配,但工作机器ID还是需要手工分配或者提供中心节点协调的,本质上是改善而不是解决问题。
  • 无法解决时间回溯的问题,一旦服务器时间发生调整,几乎一定会生成出重复ID。

UUID (Universally Unique IDentifier)

其实这种问题早就有经典的解决方案了,譬如:UUID by RFC 4122 。著名的IDFA就是一种UUID

UUID是一种格式,共有5个版本,最后我选择了v1作为最终方案。下面详细简单介绍一下UUID v1的性质。

  • 可以分布式本地生成。
  • 保证全局唯一,且可以应对时间回溯或网卡变化导致ID重复生成的问题。
  • 时间戳(60bit),精确至0.1微秒(1e-7 s)。蕴含在ID中。
  • 在一个连续的时间片段(2^32/1e7 s约7min)内,ID单调递增。
  • 连续生成的ID会被均匀散列,(所以分片起来不要太方便,放在HBase里也可以直接当Rowkey)
  • 有现成的标准,不需要任何事先配置与参数输入,各个语言均有实现,开箱即用。
  • 可以直接通过UUID字面值得知大概的业务时间戳。
  • PostgreSQL直接内建UUID支持(ver>9.0)。

综合考虑,这确实是我能找到的最完美的解决方案了。

UUID概览

# Shell中生成一个随机UUID的简单方式
$ python -c 'import uuid;print(uuid.uuid4())'
8d6d1986-5ab8-41eb-8e9f-3ae007836a71

我们通常见到的UUID如上所示,通常用'-'分隔的五组十六进制数字表示。但这个字符串只不过是UUID的字符串表示,即所谓的UUID Literal。实际上UUID是一个128bit的整数。也就是16个字节,两个长整形的宽度。

因为每个字节用2个hex字符表示,所以UUID通常可以表示为32个十六进制数字,按照8-4-4-4-12的形式进行分组。为什么采用这种分组形式?因为最原始版本的UUID v1采用了这种位域划分方式,后面其他版本的UUID虽然可能位域划分跟这个结构已经不同了,依然采用此种字面值表示方法。UUID1是最经典的UUID,所以我着重介绍UUID1。

下面是UUID版本1的位域划分:

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                          time_low                             |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |       time_mid                |         time_hi_and_version   |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |clk_seq_hi_res |  clk_seq_low  |         node (0-1)            |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                         node (2-5)                            |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   
 typedef struct {
    unsigned32  time_low;
    unsigned16  time_mid;
    unsigned16  time_hi_and_version;
    unsigned8   clock_seq_hi_and_reserved;
    unsigned8   clock_seq_low;
    byte        node[6];
} uuid_t;

但位域划分是按照C结构体的表示方便来划分的,从逻辑上UUID1包括五个部分:

  • 时间戳 :time_low(32), time_mid(16),time_high(12),共60bit。
  • UUID版本:version(4)
  • UUID类型: variant(2)
  • 时钟序列:clock_seq(14)
  • 节点: node(48),UUID1中为MAC地址。

这五个部分实际占用的位域如下图所示:

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                          time_low                             |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |       time_mid                |  ver  |      time_high        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |var|       clock_seq           |         node (0-1)            |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                         node (2-5)                            |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

在UUID中:

  • version固定等于0b0001,即版本号固定为1

    反应在字面值上就是:一个合法的UUID v1第三个分组的第一个hex一定是1:

    • 6b54058a-a413-11e6-b501-a0999b048337

    当然,如果这个值是2,3,4,5,也代表着这就是一个版本2,3,4,5的UUID。

  • varient是用来和其他类型UUID(如GUID)进行区分的字段,指明了UUID的位域解释方法。这里固定为0b10

    反应在字面值上,一个合法的UUID v1第四个分组的第一个hex一定是8,9,A,B之一:

    • 6b54058a-a413-11e6-b501-a0999b048337
  • timestamp由系统时钟获得,形式为60bit的整数,内容是:Coordinated Universal Time (UTC) as a count of 100- nanosecond intervals since 00:00:00.00, 15 October 1582 (the date of Gregorian reform to the Christian calendar).

    即从1582/10/15 00:00:00至今经过的百纳秒数(100 ns= 1e-7 s)。这么蛋疼的设计是为了让产生良好的散列,让输出ID分布的熵最大化。

    unix timestamp换算为所需时间戳的公式为:ts * 10000000 + 122192928000000000

    time_low = (long long)timestamp [32:64) ,将时间戳的最低位的32bit按照同样的顺序填入UUID前32bit

    time_mid = (long long)timestamp [16:32) ,将时间戳中间的16bit按照同样的顺序填入UUID的time_mid

    time_high = (long long)timestamp [4:16) ,将时间戳的最高的12bit按照同样的顺序生成time_hi

    不过time_hiversion是共享一个short int的,所以其生成方法为:

    time_hi_and_version = (long long)timestamp[0:16) & 0x0111 | 0x1000

  • clock_seq是为了防止网卡变更与时间回溯导致的ID重复问题,当系统时间回溯或网卡状态变更时,clock_seq会自动重置,从而避免ID重复问题。其形式为14个bit,换算成整数即0~16383,一般的UUID库都会自动处理,不在乎的话也可以随机生成或者设为固定值提高性能。

  • node字段在UUID1中的涵义等同于机器网卡MAC。48bit正好与MAC地址等长。一般UUID库会自动获取,但因为MAC地址泄露出去可能会有一些安全隐患,所以也有一些库是按照IP地址生成的,或者因为拿不到MAC就用一些系统指纹来生成,总之也不用操心。

所以,其实UUIDv1的所有字段都可以自动获取,压根不用人操心。其实是很方便的。

阅读UUID v1时也有一些经验和技巧。

UUID的第一个分组位域宽度为32bit,以百纳秒表示时间的话,也就是(2 ^ 32 / 1e7 s = 429.5 s = 7.1 min)。即每7分钟,第一个分组经历一次重置循环。所以对于随机到达的请求,生成的ID哈希分布应该是很均匀的。

UUID的第二个分组位域宽度为16bit,也就是2^48 / 1e7 s = 326 Day,也就是说,第二个分组基本上每年循环一次。可以近似的看做年内的业务日期。

当然,最靠谱的方法还是用程序直接从UUID v1中提取出时间戳来。这也是非常方便的。

一些问题

前几天需要合并老的业务日志,老的系统里面日志压根没有流水号这个概念,这就让人蛋疼了。新老日志合并需要为老日志补充生成业务流水ID。

UUID v1生成起来是非常方便的,但要手工构造一个UUID去补数据就比较蛋疼了。我在中英文互联网,StackOverflow找了很久都没发现现成的python,Node,Go,pl/pgsql库或者函数能完成这个功能,这些包大抵就是提供一个uuid.v1()给外面用,压根没想到还会有回溯生成ID这种功能吧……

所以我自己写了一个pl/pgsql的存储过程,可以根据业务时间戳和当初工作机器的MAC重新生成UUID1。编写这个函数让我对UUID的实现细节与原理有了更深的了解,还是不错的。

根据时间戳,时钟序列(非必须),MAC生成UUID的存储过程,其他语言同理:

-- Build UUIDv1 via RFC 4122. 
-- clock_seq is a random 14bit unsigned int with range [0,16384)
CREATE OR REPLACE FUNCTION form_uuid_v1(ts TIMESTAMPTZ, clock_seq INTEGER, mac MACADDR)
  RETURNS UUID AS $$
DECLARE
  t       BIT(60) := (extract(EPOCH FROM ts) * 10000000 + 122192928000000000) :: BIGINT :: BIT(60);
  uuid_hi BIT(64) := substring(t FROM 29 FOR 32) || substring(t FROM 13 FOR 16) || b'0001' ||
                     substring(t FROM 1 FOR 12);
BEGIN
  RETURN lpad(to_hex(uuid_hi :: BIGINT) :: TEXT, 16, '0') ||
         (to_hex((b'10' || clock_seq :: BIT(14)) :: BIT(16) :: INTEGER)) :: TEXT ||
         replace(mac :: TEXT, ':', '');
END
$$ LANGUAGE plpgsql;

-- Usage: SELECT form_uuid_v1(time, 666, '44:88:99:36:57:32');

从UUID1中提取时间戳的存储过程

CREATE OR REPLACE FUNCTION uuid_v1_timestamp(_uuid UUID)
  RETURNS TIMESTAMP WITH TIME ZONE AS $$
SELECT to_timestamp(
    (
      ('x' || lpad(h, 16, '0')) :: BIT(64) :: BIGINT :: DOUBLE PRECISION -
      122192928000000000
    ) / 10000000
)
FROM (
       SELECT substring(u FROM 16 FOR 3) ||
              substring(u FROM 10 FOR 4) ||
              substring(u FROM 1 FOR 8) AS h
       FROM (VALUES (_uuid :: TEXT)) s (u)
     ) s;
$$ LANGUAGE SQL IMMUTABLE;

PG 管理

PostgreSQL 运维管理诊断调优的的经验

Postgres逻辑复制详解

本文介绍PostgreSQL 13中逻辑复制的相关原理,以及最佳实践。

逻辑复制

逻辑复制(Logical Replication),是一种根据数据对象的 复制标识(Replica Identity)(通常是主键)复制数据对象及其变化的方法。

逻辑复制 这个术语与 物理复制相对应,物理复制使用精确的块地址与逐字节复制,而逻辑复制则允许对复制过程进行精细的控制。

逻辑复制基于 发布(Publication)订阅Subscription)模型:

  • 一个 发布者(Publisher) 上可以有多个发布,一个 订阅者(Subscriber) 上可以有多个 订阅
  • 一个发布可被多个订阅者订阅,一个订阅只能订阅一个发布者,但可订阅同发布者上的多个不同发布。

针对一张表的逻辑复制通常是这样的:订阅者获取发布者数据库上的一个快照,并拷贝表中的存量数据。一旦完成数据拷贝,发布者上的变更(增删改清)就会实时发送到订阅者上。订阅者会按照相同的顺序应用这些变更,因此可以保证逻辑复制的事务一致性。这种方式有时候又称为 事务性复制(transactional replication)

逻辑复制的典型用途是:

  • 迁移,跨PostgreSQL大版本,跨操作系统平台进行复制。
  • CDC,收集数据库(或数据库的一个子集)中的增量变更,在订阅者上为增量变更触发触发器执行定制逻辑。
  • 分拆,将多个数据库集成为一个,或者将一个数据库拆分为多个,进行精细的分拆集成与访问控制。

逻辑订阅者的行为就是一个普通的PostgreSQL实例(主库),逻辑订阅者也可以创建自己的发布,拥有自己的订阅者。

如果逻辑订阅者只读,那么不会有冲突。如果会写入逻辑订阅者的订阅集,那么就可能会出现冲突。

发布(Publication)

一个 发布(Publication) 可以在物理复制主库 上定义。创建发布的节点被称为 发布者(Publisher)

一个 发布由一组表构成的变更集合。也可以被视作一个 变更集(change set)复制集(Replication Set) 。每个发布都只能在一个 数据库(Database) 中存在。

发布不同于模式(Schema),不会影响表的访问方式。(表纳不纳入发布,自身访问不受影响)

发布目前只能包含(即:索引,序列号,物化视图这些不会被发布),每个表可以添加到多个发布中。

除非针对ALL TABLES创建发布,否则发布中的对象(表)只能(通过ALTER PUBLICATION ADD TABLE)被显式添加

发布可以筛选所需的变更类型:包括INSERTUPDATEDELETETRUNCATE的任意组合,类似触发器事件,默认所有变更都会被发布。

复制标识

一个被纳入发布中的表,必须带有 复制标识(Replica Identity),只有这样才可以在订阅者一侧定位到需要更新的行,完成UPDATEDELETE操作的复制。

默认情况下,主键 (Primary Key)是表的复制标识,非空列上的唯一索引 (UNIQUE NOT NULL)也可以用作复制标识。

如果没有任何复制标识,可以将复制标识设置为FULL,也就是把整个行当作复制标识。(一种有趣的情况,表中存在多条完全相同的记录,也可以被正确处理,见后续案例)使用FULL模式的复制标识效率很低(因为每一行修改都需要在订阅者上执行全表扫描,很容易把订阅者拖垮),所以这种配置只能是保底方案。使用FULL模式的复制标识还有一个限制,订阅端的表上的复制身份所包含的列,要么与发布者一致,要么比发布者更少。

INSERT操作总是可以无视 复制标识 直接进行(因为插入一条新记录,在订阅者上并不需要定位任何现有记录;而删除和更新则需要通过复制标识 定位到需要操作的记录)。如果一个没有 复制标识 的表被加入到带有UPDATEDELETE的发布中,后续的UPDATEDELETE会导致发布者上报错。

表的复制标识模式可以查阅pg_class.relreplident获取,可以通过ALTER TABLE进行修改。

ALTER TABLE tbl REPLICA IDENTITY 
{ DEFAULT | USING INDEX index_name | FULL | NOTHING };

尽管各种排列组合都是可能的,然而在实际使用中,只有三种可行的情况。

  • 表上有主键,使用默认的 default 复制标识
  • 表上没有主键,但是有非空唯一索引,显式配置 index 复制标识
  • 表上既没有主键,也没有非空唯一索引,显式配置full复制标识(运行效率非常低,仅能作为兜底方案)
  • 其他所有情况,都无法正常完成逻辑复制功能。输出的信息不足,可能会报错,也可能不会。
  • 特别需要注意:如果nothing复制标识的表纳入到逻辑复制中,对其进行删改会导致发布端报错!
复制身份模式\表上的约束 主键(p) 非空唯一索引(u) 两者皆无(n)
default 有效 x x
index x 有效 x
full 低效 低效 低效
nothing xxxx xxxx xxxx

管理发布

CREATE PUBLICATION用于创建发布,DROP PUBLICATION用于移除发布,ALTER PUBLICATION用于修改发布。

发布创建之后,可以通过ALTER PUBLICATION动态地向发布中添加或移除表,这些操作都是事务性的。

CREATE PUBLICATION name
    [ FOR TABLE [ ONLY ] table_name [ * ] [, ...]
      | FOR ALL TABLES ]
    [ WITH ( publication_parameter [= value] [, ... ] ) ]

ALTER PUBLICATION name ADD TABLE [ ONLY ] table_name [ * ] [, ...]
ALTER PUBLICATION name SET TABLE [ ONLY ] table_name [ * ] [, ...]
ALTER PUBLICATION name DROP TABLE [ ONLY ] table_name [ * ] [, ...]
ALTER PUBLICATION name SET ( publication_parameter [= value] [, ... ] )
ALTER PUBLICATION name OWNER TO { new_owner | CURRENT_USER | SESSION_USER }
ALTER PUBLICATION name RENAME TO new_name

DROP PUBLICATION [ IF EXISTS ] name [, ...];

publication_parameter 主要包括两个选项:

  • publish:定义要发布的变更操作类型,逗号分隔的字符串,默认为insert, update, delete, truncate
  • publish_via_partition_root:13后的新选项,如果为真,分区表将使用根分区的复制标识进行逻辑复制。

查询发布

发布可以使用psql元命令\dRp查询。

# \dRp
  Owner   | All tables | Inserts | Updates | Deletes | Truncates | Via root
----------+------------+---------+---------+---------+-----------+----------
 postgres | t          | t       | t       | t       | t         | f

pg_publication 发布定义表

``pg_publication` 包含了发布的原始定义,每一条记录对应一个发布。

# table pg_publication;
oid          | 20453
pubname      | pg_meta_pub
pubowner     | 10
puballtables | t
pubinsert    | t
pubupdate    | t
pubdelete    | t
pubtruncate  | t
pubviaroot   | f
  • puballtables:是否包含所有的表
  • pubinsert|update|delete|truncate 是否发布这些操作
  • pubviaroot:如果设置了该选项,任何分区表(叶表)都会使用最顶层的(被)分区表的复制身份。所以可以把整个分区表当成一个表,而不是一系列表进行发布。

pg_publication_tables 发布内容表

pg_publication_tables是由pg_publicationpg_classpg_namespace拼合而成的视图,记录了发布中包含的表信息。

postgres@meta:5432/meta=# table pg_publication_tables;
   pubname   | schemaname |    tablename
-------------+------------+-----------------
 pg_meta_pub | public     | spatial_ref_sys
 pg_meta_pub | public     | t_normal
 pg_meta_pub | public     | t_unique
 pg_meta_pub | public     | t_tricky

使用pg_get_publication_tables可以根据订阅的名字获取订阅表的OID

SELECT * FROM pg_get_publication_tables('pg_meta_pub');
SELECT p.pubname,
       n.nspname AS schemaname,
       c.relname AS tablename
FROM pg_publication p,
     LATERAL pg_get_publication_tables(p.pubname::text) gpt(relid),
     pg_class c
         JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.oid = gpt.relid;

同时,pg_publication_rel 也提供类似的信息,但采用的是多对多的OID对应视角,包含的是原始数据。

  oid  | prpubid | prrelid
-------+---------+---------
 20414 |   20413 |   20397
 20415 |   20413 |   20400
 20416 |   20413 |   20391
 20417 |   20413 |   20394

这两者的区别特别需要注意:当针对ALL TABLES发布时,pg_publication_rel中不会有具体表的OID,但是在pg_publication_tables中可以查询到实际纳入逻辑复制的表列表。所以通常应当以pg_publication_tables为准。

创建订阅时,数据库会先修改pg_publication目录,然后将发布表的信息填入pg_publication_rel

订阅

订阅(Subscription) 是逻辑复制的下游。定义订阅的节点被称为 订阅者(Subscriber)

订阅定义了:如何连接到另一个数据库,以及需要订阅目标发布者上的哪些发布

逻辑订阅者的行为与一个普通的PostgreSQL实例(主库)无异,逻辑订阅者也可以创建自己的发布,拥有自己的订阅者。

每个订阅者,都会通过一个 复制槽(Replication) 来接收变更,在初始数据复制阶段,可能会需要更多的临时复制槽。

逻辑复制订阅可以作为同步复制的备库,备库的名字默认就是订阅的名字,也可以通过在连接信息中设置application_name来使用别的名字。

只有超级用户才可以用pg_dump转储订阅的定义,因为只有超级用户才可以访问pg_subscription视图,普通用户尝试转储时会跳过并打印警告信息。

逻辑复制不会复制DDL变更,因此发布集中的表必须已经存在于订阅端上。只有普通表上的变更会被复制,视图、物化视图、序列号,索引这些都不会被复制。

发布与订阅端的表是通过完整限定名(如public.table)进行匹配的,不支持把变更复制到一个名称不同的表上。

发布与订阅端的表的列也是通过名称匹配的。列的顺序无关紧要,数据类型也不一定非得一致,只要两个列的文本表示兼容即可,即数据的文本表示可以转换为目标列的类型。订阅端的表可以包含有发布端没有的列,这些新列都会使用默认值填充。

管理订阅

CREATE SUBSCRIPTION用于创建订阅,DROP SUBSCRIPTION用于移除订阅,ALTER SUBSCRIPTION用于修改订阅。

订阅创建之后,可以通过ALTER SUBSCRIPTION 随时暂停恢复订阅。

移除并重建订阅会导致同步信息丢失,这意味着相关数据需要重新进行同步。

CREATE SUBSCRIPTION subscription_name
    CONNECTION 'conninfo'
    PUBLICATION publication_name [, ...]
    [ WITH ( subscription_parameter [= value] [, ... ] ) ]

ALTER SUBSCRIPTION name CONNECTION 'conninfo'
ALTER SUBSCRIPTION name SET PUBLICATION publication_name [, ...] [ WITH ( set_publication_option [= value] [, ... ] ) ]
ALTER SUBSCRIPTION name REFRESH PUBLICATION [ WITH ( refresh_option [= value] [, ... ] ) ]
ALTER SUBSCRIPTION name ENABLE
ALTER SUBSCRIPTION name DISABLE
ALTER SUBSCRIPTION name SET ( subscription_parameter [= value] [, ... ] )
ALTER SUBSCRIPTION name OWNER TO { new_owner | CURRENT_USER | SESSION_USER }
ALTER SUBSCRIPTION name RENAME TO new_name

DROP SUBSCRIPTION [ IF EXISTS ] name;

subscription_parameter定义了订阅的一些选项,包括:

  • copy_data(bool):复制开始后,是否拷贝数据,默认为真
  • create_slot(bool):是否在发布者上创建复制槽,默认为真
  • enabled(bool):是否启用该订阅,默认为真
  • connect(bool):是否尝试连接到发布者,默认为真,置为假会把上面几个选项强制设置为假。
  • synchronous_commit(bool):是否启用同步提交,向主库上报自己的进度信息。
  • slot_name:订阅所关联的复制槽名称,设置为空会取消订阅与复制槽的关联。

管理复制槽

每个活跃的订阅都会通过复制槽 从远程发布者接受变更。

通常这个远端的复制槽是自动管理的,在CREATE SUBSCRIPTION时自动创建,在DROP SUBSCRIPTION时自动删除。

在特定场景下,可能需要分别操作订阅与底层的复制槽:

  • 创建订阅时,所需的复制槽已经存在。则可以通过create_slot = false关联已有复制槽。

  • 创建订阅时,远端不可达或状态不明朗,则可以通过connect = false不访问远程主机,pg_dump就是这么做的。这种情况下,您必须在远端手工创建复制槽后,才能在本地启用该订阅。

  • 移除订阅时,需要保留复制槽。这种情况通常是订阅者要搬到另一台机器上去,希望在那里重新开始订阅。这种情况下需要先通过ALTER SUBSCRIPTION解除订阅与复制槽点关联

  • 移除订阅时,远端不可达。这种情况下,需要在删除订阅之前使用ALTER SUBSCRIPTION解除复制槽与订阅的关联。

    如果远端实例不再使用那么没事,然而如果远端实例只是暂时不可达,那就应该手动删除其上的复制槽;否则它将继续保留WAL,并可能导致磁盘撑爆。

订阅查询

订阅可以使用psql元命令\dRs查询。

# \dRs
     Name     |  Owner   | Enabled |  Publication
--------------+----------+---------+----------------
 pg_bench_sub | postgres | t       | {pg_bench_pub}

pg_subscription 订阅定义表

每一个逻辑订阅都会有一条记录,注意这个视图是跨数据库集簇范畴的,每个数据库中都可以看到整个集簇中的订阅信息。

只有超级用户才可以访问此视图,因为里面包含有明文密码(连接信息)。

oid             | 20421
subdbid         | 19356
subname         | pg_test_sub
subowner        | 10
subenabled      | t
subconninfo     | host=10.10.10.10 user=replicator password=DBUser.Replicator dbname=meta
subslotname     | pg_test_sub
subsynccommit   | off
subpublications | {pg_meta_pub}
  • subenabled:订阅是否启用
  • subconninfo :因为包含敏感信息,会针对普通用户进行隐藏。
  • subslotname:订阅使用的复制槽名称,也会被用作逻辑复制的源名称(Origin Name),用于除重。
  • subpublications:订阅的发布名称列表。
  • 其他状态信息:是否启用同步提交等等。

pg_subscription_rel 订阅内容表

pg_subscription_rel 记录了每张处于订阅中的表的相关信息,包括状态与进度。

  • srrelid 订阅中关系的OID
  • srsubstate,订阅中关系的状态:i 初始化中,d 拷贝数据中,s 同步已完成,r 正常复制中。
  • srsublsn,当处于i|d状态时为空,当处于s|r状态时,远端的LSN位置。

创建订阅时

当一个新的订阅创建时,会依次执行以下操作:

  • 将发布的信息存入 pg_subscription 目录中,包括连接信息,复制槽,发布名称,一些配置选项等。
  • 连接至发布者,检查复制权限,(注意这里不会检查对应发布是否存在),
  • 创建逻辑复制槽:pg_create_logical_replication_slot(name, 'pgoutput')
  • 将复制集中的表注册到订阅端的 pg_subscription_rel 目录中。
  • 执行初始快照同步,注意订阅测表中的原有数据不会被删除。

复制冲突

逻辑复制的行为类似于正常的DML操作,即使数据在用户节点上的本地发生了变化,数据也会被更新。如果复制来的数据违反了任何约束,复制就会停止,这种现象被称为 冲突(Conflict)

当复制UPDATEDELETE操作时,缺失数据(即要更新/删除的数据已经不存在)不会产生冲突,此类操作直接跳过。

冲突会导致错误,并中止逻辑复制,逻辑复制管理进程会以5秒为间隔不断重试。冲突不会阻塞订阅端对复制集中表上的SQL。关于冲突的细节可以在用户的服务器日志中找到,冲突必须由用户手动解决

日志中可能出现的冲突

冲突模式 复制进程 输出日志
缺少UPDATE/DELETE对象 继续 不输出
表/行锁等待 等待 不输出
违背主键/唯一/Check约束 中止 输出
目标表不存在/目标列不存在 中止 输出
无法将数据转换为目标列类型 中止 输出

解决冲突的方法,可以是改变订阅侧的数据,使其不与进入的变更相冲突,或者跳过与现有数据冲突的事务。

使用订阅对应的node_name与LSN位置调用函数pg_replication_origin_advance()可以跳过事务,pg_replication_origin_status系统视图中可以看到当前ORIGIN的位置。

局限性

逻辑复制目前有以下限制,或者说功能缺失。这些问题可能会在未来的版本中解决。

数据库模式和DDL命令不会被复制。存量模式可以通过pg_dump --schema-only手动复制,增量模式变更需要手动保持同步(发布订阅两边的模式不需要绝对相同不需要两边的模式绝对相同)。逻辑复制对于对在线DDL变更仍然可靠:在发布数据库中执行DDL变更后,复制的数据到达订阅者但因为表模式不匹配而导致复制出错停止,订阅者的模式更新后复制会继续。在许多情况下,先在订阅者上执行变更可以避免中间的错误。

序列号数据不会被复制序列号所服务的标识列与SERIAL类型里面的数据作为表的一部分当然会被复制,但序列号本身仍会在订阅者上保持为初始值。如果订阅者被当成只读库使用,那么通常没事。然而如果打算进行某种形式的切换或Failover到订阅者数据库,那么需要将序列号更新为最新的值,要么通过从发布者复制当前数据(也许可以使用pg_dump -t *seq*),要么从表本身的数据内容确定一个足够高的值(例如max(id)+1000000)。否则如果在新库执行获取序列号作为身份的操作时,很可能会产生冲突。

逻辑复制支持复制TRUNCATE命令,但是在TRUNCATE由外键关联的一组表时需要特别小心。当执行TRUNCATE操作时,发布者上与之关联的一组表(通过显式列举或级连关联)都会被TRUNCATE,但是在订阅者上,不在订阅集中的表不会被TRUNCATE。这样的操作在逻辑上是合理的,因为逻辑复制不应该影响到复制集之外的表。但如果有一些不在订阅集中的表通过外键引用订阅集中被TRUNCATE的表,那么TRUNCATE操作就会失败。

大对象不会被复制

只有表能被复制(包括分区表),尝试复制其他类型的表会导致错误(视图,物化视图,外部表,Unlogged表)。具体来说,只有在pg_class.relkind = 'r'的表才可以参与逻辑复制。

复制分区表时默认按子表进行复制。默认情况下,变更是按照分区表的叶子分区触发的,这意味着发布上的每一个分区子表都需要在订阅上存在(当然,订阅者上的这个分区子表不一定是一个分区子表,也可能本身就是一个分区母表,或者一个普通表)。发布可以声明要不要使用分区根表上的复制标识取代分区叶表上的复制标识,这是PG13提供的新功能,可以在创建发布时通过publish_via_partition_root 选项指定。

触发器的行为表现有所不同行级触发器会触发,但UPDATE OF cols类型的触发器不触发。而语句级触发器只会在初始数据拷贝时触发。

日志行为不同。即使设置log_statement = 'all',日志中也不会记录由复制产生的SQL语句。

双向复制需要极其小心:互为发布与订阅是可行的,只要两遍的表集合不相交即可。但一旦出现表的交集,就会出现WAL无限循环。

同一实例内的复制:同一个实例内的逻辑复制需要特别小心,必须手工创建逻辑复制槽,并在创建订阅时使用已有的逻辑复制槽,否则会卡死。

只能在主库上进行:目前不支持从物理复制的从库上进行逻辑解码,也无法在从库上创建复制槽,所以从库无法作为发布者。但这个问题可能会在未来解决。

架构

逻辑复制始于获取发布者数据库上的快照,基于此快照拷贝表上的存量数据。一旦拷贝完成,发布者上的变更(增删改等)就会实时发送到订阅者上。

逻辑复制采用与物理复制类似的架构,是通过一个walsenderapply进程实现的。发布端端walsender进程会加载逻辑解码插件(pgoutput),并开始逻辑解码WAL日志。逻辑解码插件(Logical Decoding Plugin) 会读取WAL中的变更,按照发布的定义筛选变更,将变更转变为特定的形式,以逻辑复制协议传输出去。数据会按照流复制协议传输至订阅者一侧的apply进程,该进程会在接收到变更时,将变更映射至本地表上,然后按照事务顺序重新应用这些变更。

初始快照

订阅侧的表在初始化与拷贝数据期间,会由一种特殊的apply进程负责。这个进程会创建它自己的临时复制槽,并拷贝表中的存量数据。

一旦数据拷贝完成,这张表会进入到同步模式(pg_subscription_rel.srsubstate = 's'),同步模式确保了 主apply进程 可以使用标准的逻辑复制方式应用拷贝数据期间发生的变更。一旦完成同步,表复制的控制权会转交回 主apply进程,恢复正常的复制模式。

进程结构

逻辑复制的发布端会针对来自订阅端端每一条连接,创建一个对应的 walsender 进程,发送解码的WAL日志。在订阅测,则会

复制槽

当创建订阅时,

一条逻辑复制

逻辑解码

同步提交

逻辑复制的同步提交是通过Backend与Walsender之间的SIGUSR1通信完成的。

临时数据

逻辑解码的临时数据会落盘为本地日志快照。当walsender接收到walwriter发送的SIGUSR1信号时,就会读取WAL日志并生成相应的逻辑解码快照。当传输结束时会删除这些快照。

文件地址为:$PGDATA/pg_logical/snapshots/{LSN Upper}-{LSN Lower}.snap

监控

逻辑复制采用与物理流复制类似的架构,所以监控一个逻辑复制的发布者节点与监控一个物理复制主库差别不大。

订阅者的监控信息可以通过pg_stat_subscription视图获取。

pg_stat_subscription 订阅统计表

每个活跃订阅都会在这个视图中有至少一条 记录,即Main Worker(负责应用逻辑日志)。

Main Worker的relid = NULL,如果有负责初始数据拷贝的进程,也会在这里有一行记录,relid为负责拷贝数据的表。

subid                 | 20421
subname               | pg_test_sub
pid                   | 5261
relid                 | NULL
received_lsn          | 0/2A4F6B8
last_msg_send_time    | 2021-02-22 17:05:06.578574+08
last_msg_receipt_time | 2021-02-22 17:05:06.583326+08
latest_end_lsn        | 0/2A4F6B8
latest_end_time       | 2021-02-22 17:05:06.578574+08
  • received_lsn :最近收到的日志位置。
  • lastest_end_lsn:最后向walsender回报的LSN位置,即主库上的confirmed_flush_lsn。不过这个值更新不太勤快,

通常情况下一个活跃的订阅会有一个apply进程在运行,被禁用的订阅或崩溃的订阅则在此视图中没有记录。在初始同步期间,被同步的表会有额外的工作进程记录。

pg_replication_slot 复制槽

postgres@meta:5432/meta=# table pg_replication_slots ;
-[ RECORD 1 ]-------+------------
slot_name           | pg_test_sub
plugin              | pgoutput
slot_type           | logical
datoid              | 19355
database            | meta
temporary           | f
active              | t
active_pid          | 89367
xmin                | NULL
catalog_xmin        | 1524
restart_lsn         | 0/2A08D40
confirmed_flush_lsn | 0/2A097F8
wal_status          | reserved
safe_wal_size       | NULL

复制槽视图中同时包含了逻辑复制槽与物理复制槽。逻辑复制槽点主要特点是:

  • plugin字段不为空,标识了使用的逻辑解码插件,逻辑复制默认使用pgoutput插件。
  • slot_type = logical,物理复制的槽类型为physical
  • datoiddatabase字段不为空,因为物理复制与集簇关联,而逻辑复制与数据库关联。

逻辑订阅者也会作为一个标准的 复制从库 ,出现于 pg_stat_replication 视图中。

pg_replication_origin 复制源

复制源

table pg_replication_origin_status;
-[ RECORD 1 ]-----------
local_id    | 1
external_id | pg_19378
remote_lsn  | 0/0
local_lsn   | 0/6BB53640
  • local_id:复制源在本地的ID,2字节高效表示。
  • external_id:复制源的ID,可以跨节点引用。
  • remote_lsn:源端最近的提交位点
  • local_lsn:本地已经持久化提交记录的LSN

检测复制冲突

最稳妥的检测方法总是从发布与订阅两侧的日志中检测。当出现复制冲突时,发布测上可以看见复制连接中断

LOG:  terminating walsender process due to replication timeout
LOG:  starting logical decoding for slot "pg_test_sub"
DETAIL:  streaming transactions committing after 0/xxxxx, reading WAL from 0/xxxx

而订阅端则可以看到复制冲突的具体原因,例如:

logical replication worker PID 4585 exited with exit code 1
ERROR: duplicate key value violates unique constraint "pgbench_tellers_pkey","Key (tid)=(9) already exists.",,,,"COPY pgbench_tellers, line 31",,,,"","logical replication worker"

此外,一些监控指标也可以反映逻辑复制的状态:

例如:pg_replication_slots.confirmed_flush_lsn 长期落后于pg_cureent_wal_lsn。或者pg_stat_replication.flush_ag/write_lag 有显著增长。

安全

参与订阅的表,其Ownership与Trigger权限必须控制在超级用户所信任的角色手中(否则修改这些表可能导致逻辑复制中断)。

在发布节点上,如果不受信任的用户具有建表权限,那么创建发布时应当显式指定表名而非通配ALL TABLES。也就是说,只有当超级用户信任所有 可以在发布或订阅侧具有建表(非临时表)权限的用户时,才可以使用FOR ALL TABLES

用于复制连接的用户必须具有REPLICATION权限(或者为SUPERUSER)。如果该角色缺少SUPERUSERBYPASSRLS,发布者上的行安全策略可能会被执行。如果表的属主在复制启动之后设置了行级安全策略,这个配置可能会导致复制直接中断,而不是策略生效。该用户必须拥有LOGIN权限,而且HBA规则允许其访问。

为了能够复制初始表数据,用于复制连接的角色必须在已发布的表上拥有SELECT权限(或者属于超级用户)。

创建发布,需要在数据库中的CREATE权限,创建一个FOR ALL TABLES的发布,需要超级用户权限。

将表加入到发布中,用户需要具有表的属主权限。

创建订阅需要超级用户权限,因为订阅的apply进程在本地数据库中以超级用户的权限运行。

权限只会在建立复制连接时检查,不会在发布端读取每条变更记录时重复检查,也不会在订阅端应用每条记录时检查。

配置选项

逻辑复制需要一些配置选项才能正常工作。

在发布者一侧,wal_level 必须设置为logicalmax_replication_slots最少需要设为 订阅的数量+用于表数据同步的数量。max_wal_senders最少需要设置为max_replication_slots + 为物理复制保留的数量,

在订阅者一侧,也需要设置max_replication_slotsmax_replication_slots,最少需要设为订阅数。

max_logical_replication_workers最少需要配置为订阅的数量,再加上一些用于数据同步的工作进程数。

此外,max_worker_processes需要相应调整,至少应当为max_logical_replication_worker + 1。注意一些扩展插件和并行查询也会从工作进程的池子中获取连接使用。

配置参数样例

64核机器,1~2个发布与订阅,最多6个同步工作进程,最多8个物理从库的场景,一种样例配置如下所示:

首先决定Slot数量,2个订阅,6个同步工作进程,8个物理从库,所以配置为16。Sender = Slot + Physical Replica = 24。

同步工作进程限制为6,2个订阅,所以逻辑复制的总工作进程设置为8。

wal_level: logical                      # logical	
max_worker_processes: 64                # default 8 -> 64, set to CPU CORE 64
max_parallel_workers: 32                # default 8 -> 32, limit by max_worker_processes
max_parallel_maintenance_workers: 16    # default 2 -> 16, limit by parallel worker
max_parallel_workers_per_gather: 0      # default 2 -> 0,  disable parallel query on OLTP instance
# max_parallel_workers_per_gather: 16   # default 2 -> 16, enable parallel query on OLAP instance

max_wal_senders: 24                     # 10 -> 24
max_replication_slots: 16               # 10 -> 16 
max_logical_replication_workers: 8      # 4 -> 8, 6 sync worker + 1~2 apply worker
max_sync_workers_per_subscription: 6    # 2 -> 6, 6 sync worker

快速配置

首先设置发布侧的配置选项 wal_level = logical,该参数需要重启方可生效,其他参数的默认值都不影响使用。

然后创建复制用户,添加pg_hba.conf配置项,允许外部访问,一种典型配置是:

CREATE USER replicator REPLICATION BYPASSRLS PASSWORD 'DBUser.Replicator';

注意,逻辑复制的用户需要具有SELECT权限,在Pigsty中replicator已经被授予了dbrole_readonly角色。

host     all          replicator     0.0.0.0/0     md5
host     replicator   replicator     0.0.0.0/0     md5

然后在发布侧的数据库中执行:

CREATE PUBLICATION mypub FOR TABLE <tablename>;

然后在订阅测数据库中执行:

CREATE SUBSCRIPTION mysub CONNECTION 'dbname=<pub_db> host=<pub_host> user=replicator' PUBLICATION mypub;

以上配置即会开始复制,首先复制表的初始数据,然后开始同步增量变更。

沙箱样例

以Pigsty标准4节点两集群沙箱为例,有两套数据库集群pg-metapg-test。现在将pg-meta-1作为发布者,pg-test-1作为订阅者。

PGSRC='postgres://dbuser_admin@meta-1/meta'           # 发布者
PGDST='postgres://dbuser_admin@node-1/test'           # 订阅者
pgbench -is100 ${PGSRC}                               # 在发布端初始化Pgbench
pg_dump -Oscx -t pgbench* -s ${PGSRC} | psql ${PGDST} # 在订阅端同步表结构

# 在发布者上创建**发布**,将默认的`pgbench`相关表加入到发布集中。
psql ${PGSRC} -AXwt <<-'EOF'
CREATE PUBLICATION "pg_meta_pub" FOR TABLE
  pgbench_accounts,pgbench_branches,pgbench_history,pgbench_tellers;
EOF

# 在订阅者上创建**订阅**,订阅发布者上的发布。
psql ${PGDST} <<-'EOF'
CREATE SUBSCRIPTION pg_test_sub
  CONNECTION 'host=10.10.10.10 dbname=meta user=replicator' 
  PUBLICATION pg_meta_pub;
EOF

复制流程

逻辑复制的订阅创建后,如果一切正常,逻辑复制会自动开始,针对每张订阅中的表执行复制状态机逻辑。

如下图所示。

stateDiagram-v2 [*] --> init : 表被加入到订阅集中 init --> data : 开始同步表的初始快照 data --> sync : 存量数据同步完成 sync --> ready : 同步期间的增量变更应用完毕,进入就绪状态

当所有的表都完成复制,进入r(ready)状态时,逻辑复制的存量同步阶段便完成了,发布端与订阅端整体进入同步状态。

因此从逻辑上讲,存在两种状态机:表级复制小状态机全局复制大状态机。每一个Sync Worker负责一张表上的小状态机,而一个Apply Worker负责一条逻辑复制的大状态机。

逻辑复制状态机

逻辑复制有两种Worker:Sync与Apply。Sync

因此,逻辑复制在逻辑上分为两个部分:每张表独自进行复制,当复制进度追赶至最新位置时,由

当创建或刷新订阅时,表会被加入到 订阅集 中,每一张订阅集中的表都会在pg_subscription_rel视图中有一条对应纪录,展示这张表当前的复制状态。刚加入订阅集的表初始状态为i,即initialize初始状态

如果订阅的copy_data选项为真(默认情况),且工作进程池中有空闲的Worker,PostgreSQL会为这张表分配一个同步工作进程,同步这张表上的存量数据,此时表的状态进入d,即拷贝数据中。对表做数据同步类似于对数据库集群进行basebackup,Sync Worker会在发布端创建临时的复制槽,获取表上的快照并通过COPY完成基础数据同步。

当表上的基础数据拷贝完成后,表会进入sync模式,即数据同步,同步进程会追赶同步过程中发生的增量变更。当追赶完成时,同步进程会将这张表标记为r(ready)状态,转交逻辑复制主Apply进程管理变更,表示这张表已经处于正常复制中。

2.4 等待逻辑复制同步

创建订阅后,首先必须监控 发布端与订阅端两侧的数据库日志,确保没有错误产生

2.4.1 逻辑复制状态机

2.4.2 同步进度跟踪

数据同步(d)阶段可能需要花费一些时间,取决于网卡,网络,磁盘,表的大小与分布,逻辑复制的同步worker数量等因素。

作为参考,1TB的数据库,20张表,包含有250GB的大表,双万兆网卡,在6个数据同步worker的负责下大约需要6~8小时完成复制。

在数据同步过程中,每个表同步任务都会源端库上创建临时的复制槽。请确保逻辑复制初始同步期间不要给源端主库施加过大的不必要写入压力,以免WAL撑爆磁盘。

发布侧的 pg_stat_replicationpg_replication_slots,订阅端的pg_stat_subscriptionpg_subscription_rel提供了逻辑复制状态的相关信息,需要关注。

psql ${PGDST} -Xxw <<-'EOF'
    SELECT subname, json_object_agg(srsubstate, cnt) FROM
    pg_subscription s JOIN
      (SELECT srsubid, srsubstate, count(*) AS cnt FROM pg_subscription_rel 
       GROUP BY srsubid, srsubstate) sr
    ON s.oid = sr.srsubid GROUP BY subname;
EOF

可以使用以下SQL确认订阅中表的状态,如果所有表的状态都显示为r,则表示逻辑复制已经成功建立,订阅端可以用于切换。

   subname   | json_object_agg
-------------+-----------------
 pg_test_sub | { "r" : 5 }

当然,最好的方式始终是通过监控系统来跟踪复制状态。

沙箱样例

以Pigsty标准4节点两集群沙箱为例,有两套数据库集群pg-metapg-test。现在将pg-meta-1作为发布者,pg-test-1作为订阅者。

通常逻辑复制的前提是,发布者上设置有wal_level = logical,并且有一个可以正常访问,具有正确权限的复制用户。

Pigsty的默认配置已经符合要求,且带有满足条件的复制用户replicator,以下命令均从元节点以postgres用户发起,数据库用户dbuser_admin,带有SUPERUSER权限。

PGSRC='postgres://dbuser_admin@meta-1/meta'        # 发布者
PGDST='postgres://dbuser_admin@node-1/test'        # 订阅者

准备逻辑复制

使用pgbench工具,在pg-meta集群的meta数据库中初始化表结构。

pgbench -is100 ${PGSRC}

使用pg_dumppsql 同步 pgbench* 相关表的定义。

pg_dump -Oscx -t pgbench* -s ${PGSRC} | psql ${PGDST}

创建发布订阅

在发布者上创建发布,将默认的pgbench相关表加入到发布集中。

psql ${PGSRC} -AXwt <<-'EOF'
CREATE PUBLICATION "pg_meta_pub" FOR TABLE
  pgbench_accounts,pgbench_branches,pgbench_history,pgbench_tellers;
EOF

在订阅者上创建订阅,订阅发布者上的发布。

psql ${PGDST} <<-'EOF'
CREATE SUBSCRIPTION pg_test_sub
  CONNECTION 'host=10.10.10.10 dbname=meta user=replicator' 
  PUBLICATION pg_meta_pub;
EOF

观察复制状态

pg_subscription_rel.srsubstate全部变为r (准备就绪)状态后,逻辑复制就建立起来了。

$ psql ${PGDST} -c 'TABLE pg_subscription_rel;'
 srsubid | srrelid | srsubstate |  srsublsn
---------+---------+------------+------------
   20451 |   20433 | d          | NULL
   20451 |   20442 | r          | 0/4ECCDB78
   20451 |   20436 | r          | 0/4ECCDB78
   20451 |   20439 | r          | 0/4ECCDBB0

校验复制数据

可以简单地比较发布与订阅端两侧的表记录条数,与复制标识列的最大最小值来校验数据是否完整地复制。

function compare_relation(){
	local relname=$1
	local identity=${2-'id'}
	psql ${3-${PGPUB}} -AXtwc "SELECT count(*) AS cnt, max($identity) AS max, min($identity) AS min FROM ${relname};"
	psql ${4-${PGSUB}} -AXtwc "SELECT count(*) AS cnt, max($identity) AS max, min($identity) AS min FROM ${relname};"
}
compare_relation pgbench_accounts aid
compare_relation pgbench_branches bid
compare_relation pgbench_history  tid
compare_relation pgbench_tellers  tid

更近一步的验证可以通过在发布者上手工创建一条记录,再从订阅者上读取出来。

$ psql ${PGPUB} -AXtwc 'INSERT INTO pgbench_accounts(aid,bid,abalance) VALUES (99999999,1,0);'
INSERT 0 1
$ psql ${PGSUB} -AXtwc 'SELECT * FROM pgbench_accounts WHERE aid = 99999999;'
99999999|1|0|

现在已经拥有一个正常工作的逻辑复制了。下面让我们来通过一系列实验来掌握逻辑复制的使用与管理,探索可能遇到的各种离奇问题。

逻辑复制实验

将表加入已有发布

CREATE TABLE t_normal(id BIGSERIAL PRIMARY KEY,v  TIMESTAMP); -- 常规表,带有主键
ALTER PUBLICATION pg_meta_pub ADD TABLE t_normal; -- 将新创建的表加入到发布中

如果这张表在订阅端已经存在,那么即可进入正常的逻辑复制流程:i -> d -> s -> r

如果向发布加入一张订阅端不存在的表?那么新订阅将会无法创建已有订阅无法刷新,但可以保持原有复制继续进行。

如果订阅还不存在,那么创建的时候会报错无法进行:在订阅端找不到这张表。如果订阅已经存在,无法执行刷新命令:

ALTER SUBSCRIPTION pg_test_sub REFRESH PUBLICATION;

如果新加入的表没有任何写入,已有的复制关系不会发生变化,一旦新加入的表发生变更,会立即产生复制冲突

将表从发布中移除

ALTER PUBLICATION pg_meta_pub ADD TABLE t_normal;

从发布移除后,订阅端不会有影响。效果上就是这张表的变更似乎消失了。执行订阅刷新后,这张表会从订阅集中被移除。

另一种情况是重命名发布/订阅中的表,在发布端执行表重命名时,发布端的发布集会立刻随之更新。尽管订阅集中的表名不会立刻更新,但只要重命名后的表发生任何变更,而订阅端没有对应的表,那么会立刻出现复制冲突

同理,在订阅端重命名表时,订阅的关系集也会刷新,但因为发布端的表没有对应物了。如果这张表没有变更,那么一切照旧,一旦发生变更,立刻出现复制冲突

直接在发布端DROP此表,会顺带将该表从发布中移除,不会有报错或影响。但直接在订阅端DROP表则可能出现问题DROP TABLE时该表也会从订阅集中被移除。如果发布端此时这张表上仍有变更产生,则会导致复制冲突

所以,删表应当先在发布端进行,再在订阅端进行。

两端列定义不一致

发布与订阅端的表的列通过名称匹配,列的顺序无关紧要。

订阅端表的列更多,通常不会有什么影响。多出来的列会被填充为默认值(通常是NULL)。

特别需要注意的是,如果要为多出来的列添加NOT NULL约束,那么一定要配置一个默认值,否则变更发生时违反约束会导致复制冲突。

订阅端如果列要比发布端更少,会产生复制冲突。在发布端添加一个新列并不会立刻导致复制冲突,随后的第一条变更将导致复制冲突。

所以在执行加列DDL变更时,可以先在订阅者上先执行,然后在发布端进行。

列的数据类型不需要完全一致,只要两个列的文本表示兼容即可,即数据的文本表示可以转换为目标列的类型。

这意味着任何类型都能转换成TEXT类型,BIGINT 只要不出错,也可以转换成INT,不过一旦溢出,还是会出现复制冲突

复制身份与索引的正确配置

表上的复制标识配置,与表上有没有索引是两件独立的事。尽管各种排列组合都是可能的,然而在实际使用中只有三种可行的情况,其他情况都无法正常完成逻辑复制的功能(如果不报错,通常也是侥幸)

  • 表上有主键,使用默认的 default 复制标识,不需要额外配置。
  • 表上没有主键,但是有非空唯一索引,显式配置 index 复制标识。
  • 表上既没有主键也没有非空唯一索引,显式配置full复制标识(运行效率低,仅作为兜底方案)
复制身份模式\表上的约束 主键(p) 非空唯一索引(u) 两者皆无(n)
default 有效 x x
index x 有效 x
full 低效 低效 低效
nothing x x x

在所有情况下,INSERT都可以被正常复制。x代表DELETE|UPDATE所需关键信息缺失无法正常完成。

最好的方式当然是事前修复,为所有的表指定主键,以下查询可以用于找出缺失主键或非空唯一索引的表:

SELECT quote_ident(nspname) || '.' || quote_ident(relname) AS name, con.ri AS keys,
       CASE relreplident WHEN 'd' THEN 'default' WHEN 'n' THEN 'nothing' WHEN 'f' THEN 'full' WHEN 'i' THEN 'index' END AS replica_identity
FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid, LATERAL (SELECT array_agg(contype) AS ri FROM pg_constraint WHERE conrelid = c.oid) con
WHERE relkind = 'r' AND nspname NOT IN ('pg_catalog', 'information_schema', 'monitor', 'repack', 'pg_toast')
ORDER BY 2,3;

注意,复制身份为nothing的表可以加入到发布中,但在发布者上对其执行UPDATE|DELETE会直接导致报错。

其他问题

Q:逻辑复制准备工作

Q:什么样的表可以逻辑复制?

Q:监控逻辑复制状态

Q:将新表加入发布

Q:没有主键的表加入发布?

Q:没有复制身份的表如何处理?

Q:ALTER PUB的生效方式

Q:在同一对 发布者-订阅者 上如果存在多对订阅,且发布包含的表重叠?

Q:订阅者和发布者的表定义有什么限制?

Q:pg_dump是如何处理订阅的

Q:什么情况下需要手工管理订阅复制槽?

如何用 pg_filedump 抢救数据?

WeChat Column

备份是DBA的生命线 —— 但如果你的 PostgreSQL 数据库已经爆炸了又没有备份,那么该怎么办呢?也许 pg_filedump 可以帮到你!

最近遇到了一个比较离谱的活儿,情况是这样的:有个用户的 PostgreSQL 数据库损坏了,是 Gitlab 自己拉起的 PostgreSQL。没有从库,没有备份,也没有 dump。跑在拿 SSD 当透明缓存的BCACHE上,断电后起不来了。

但这还没完,接连经受了几轮摧残之后,它彻底歇菜了:首先是因为忘了挂BCACHE盘,导致 Gitlab重新初始化了一遍新的数据库集群;然后是因为各种原因隔离失效,在同一个集簇目录上运行两个数据库进程烤糊了数据目录;接着是运行 pg_resetwal 不带参数把数据库推回起源点,最后是让空数据库跑了一阵子,然后把烤糊前的临时备份移除了。

看到这个 Case 我确实有点无语:这都成一团浆糊了还恢复个什么,目测只能从底层二进制文件直接抽取数据来恢复了。我建议他去找个数据恢复公司碰碰运气吧,也帮忙问了一圈儿,但是一大堆数据恢复公司里,几乎没有几个有 PostgreSQL 数据恢复服务的,有的也是比较基础的那种问题处理,碰上这种情况都说只能随缘试试。

数据恢复报价通常是按文件数量来收费的,一个文件从 ¥1000 ~ ¥5000 不等。Gitlab库里几千个文件,按表算的话大概有 1000张表,全恢复完几十万可能不至于,但十几万肯定是没跑了。可一天过去了也没人接,这着实让我感觉蛋疼:要是没人能接这活,岂不是显得 PG 社区没人了?

我想了一下,这活看着挺蛋疼,但也挺有挑战趣味的,咱死马当活马医,修不好不收钱就是 —— 不试试咋知道行不行呢?所以就接了自己上了。


工具

工欲善其事,必先利其器。数据恢复首先当然是要找有没有趁手的工具:pg_filedump 就是一把不错的武器,它可以用来从 PostgreSQL 数据页面中抽取原始二进制数据,许多低层次的工作可以交给它。

这个工具可以用 make 三板斧编译安装,当然需要先安装对应大版本的 PostgreSQL 才行。Gitlab 默认使用的是 PG 13,所以确保对应版本的 pg_config 在路径中后直接编译即可。

git clone https://github.com/df7cb/pg_filedump
cd pg_filedump && make && sudo make install

pg_filedump 的使用方式并不复杂,你把数据文件喂给他,告诉它这张表每一列的类型,它就能帮你解读出来。比如第一步,我们就得知道这个数据库集簇中有哪几个数据库。这个信息记录在系统视图 pg_database 中。这是一张系统层面的表,位于 global 目录中,在集群初始化时会分配固定的 OID 1262,所以对应的物理文件通常是: global/1262

vonng=# select 'pg_database'::RegClass::OID;
 oid
------
 1262

这张系统视图里有不少字段,但我们主要关心的是前两个: oiddatnamedatname 是数据库的名称,oid 则可以用于定位数据库目录位置。以用 pg_filedump 把这张表解出来看一看, -D 参数可以告诉 pg_filedump 如何解释这张表里每一行的二进制数据。你可以指定每个字段的类型,用逗号分隔,~ 表示后面的部分都忽略不要。

pg-filedump-1.png

可以看到,每一行数据都以 COPY 开始,这里我们发现了目标数据库 gitlabhq_production,其 OID 为 16386 。所以这个数据库内的所有文件都应当位于 base/16386 子目录中。


恢复数据字典

知道了要恢复的数据文件目录,下一步就是解出数据字典来,这里面有四张重要的表需要关注:

pg_class:包含了所有表的重要元数据•pg_namespace:包含了模式的元数据•pg_attribute:包含了所有的列定义•pg_type:包含了类型的名称

其中 pg_class 是最为重要,不可或缺的一张表。其他几张系统视图属于 Nice to have:能让我们的工作更加简单一些。所以,我们首先尝试恢复这张表。

pg_class 是数据库级别的系统视图,默认有着 OID = 1259 ,所以 pg_class 对应的文件应当是: base/16386/1259,在 gitlabhq_production 对应数据库目录下。

pg-filedump-2.png

这里说句题外话:熟悉 PostgreSQL 原理的朋友知道:实际底层存储数据的文件名(RelFileNode)虽然默认与表的 OID 保持一致,但是一些操作可能会改变这一点,在这种情况下,你可以用 pg_filedump -m pg_filenode.map 解析数据库目录下的映射文件,找到 OID 1259 对应的 Filenode。当然这里两者是一致的,就表过不提了。

我们根据 pg_class 的表结构定义(注意要使用对应PG大版本的表结构),解析其二进制文件: pg_filedump -D ‘oid,name,oid,oid,oid,oid,oid,oid,oid,int,real,int,oid,bool,bool,char,char,smallint,smallint,bool,bool,bool,bool,bool,bool,char,bool,oid,xid,xid,text,text,text’ -i base/16386/1259

然后就可以看到解析出来的数据了。这里的数据是 \t 分隔的单行记录,与 PostgreSQL COPY 命令默认使用的格式相同。所以你可以用脚本 grep 收集过滤,掐掉每行开头的 COPY ,并重新灌入一张真正的数据库表来细看。

pg-filedump-3.png

在数据恢复时需要注意许多细节,其中第一条就是:你需要处理被删除的行。怎么识别呢?使用 -i 参数打印每一行的元数据,元数据里有一个 XMAX 字段。如果某一行元组被某个事务删除了,那么这条记录的 XMAX 就会被设置为该事务的 XID 事务号。所以如果某一行的 XMAX 不是零,就意味着这是一条被删除的记录,不应当输出到最终的结果中。

pg-filedump-4.png

这里的 XMAX 代表这是条被删除的记录

有了 pg_class 数据字典之后,你就可以清楚地找到其他表,包括系统视图的 OID 对应关系了。用同样的办法可以恢复 pg_namespacepg_attributepg_type 这三张表。有了这四张表就可以干什么呢?

pg-filedump-5.png

你可以用 SQL 生成每张表的输入路径,自动拼出每一列的类型作为 -D 参数,生成临时结果表的 Schema。总而言之,可以用编程自动化的方式,自动生成所有需要完成的任务。

SELECT  id, name, nspname, relname, nspid, attrs, fields, has_tough_type,
        CASE WHEN toast_page > 0 THEN toast_name ELSE NULL END AS toast_name, relpages, reltuples, path
FROM
    (
        SELECT n.nspname || '.' || c.relname AS "name", n.nspname, c.relname, c.relnamespace AS nspid, c.oid AS id, c.reltoastrelid AS tid,
               toast.relname AS toast_name, toast.relpages AS toast_page,
               c.relpages, c.reltuples, 'data/base/16386/' || c.relfilenode::TEXT AS path
        FROM meta.pg_class c
                 LEFT JOIN meta.pg_namespace n ON c.relnamespace = n.oid
           , LATERAL (SELECT * FROM meta.pg_class t WHERE t.oid = c.reltoastrelid) toast
        WHERE c.relkind = 'r' AND c.relpages > 0
          AND c.relnamespace IN (2200, 35507, 35508)
        ORDER BY c.relnamespace, c.relpages DESC
    ) z,
    LATERAL ( SELECT string_agg(name,',') AS attrs,
                     string_agg(std_type,',') AS fields,
                     max(has_tough_type::INTEGER)::BOOLEAN AS has_tough_type
              FROM meta.pg_columns WHERE relid = z.id ) AS columns;

这里需要注意,pg_filedump -D 参数支持的数据类型名称是有严格限定的标准名称的,所以你必须把 boolean 转为 boolINTEGER 转为 int。如果你想解析的数据类型不在下面这个列表中,可以首先尝试使用 TEXT 类型,例如表示IP地址的 INET 类型就可以用 TEXT 的方式解析。

bigint bigserial bool char charN date float float4 float8 int json macaddr name numeric oid real serial smallint smallserial text time timestamp timestamptz timetz uuid varchar varcharN xid xml

但确实会有其他的一些特殊情况需要额外的处理,比如 PostgreSQL 中的 ARRAY 数组类型,后面会详细介绍。

恢复一张普通表

恢复普通数据表和恢复一张系统目录表并没有本质区别:只不过 Catalog 的模式和信息都是公开的标准化的,而待恢复的数据库模式则不一定。

Gitlab 也属于一个开源的很有知名度的软件,所以找到它的数据库模式定义并不是一件难事。如果是一个普通的业务系统,那么多费点功夫也可以从 pg_catalog 中还原出原始 DDL 。

知道了 DDL 定义,我们就可以使用 DDL 中每一列的数据类型,来解释二进制文件中的数据了。下面,我们用 public.approval_merge_request_rules 这张 Gitlab 中的普通表为例,演示如何恢复这样一张普通数据表。

create table approval_project_rules
(
    id                        bigint,
    created_at                timestamp with time zone,
    updated_at                timestamp with time zone,
    project_id                integer,
    approvals_required        smallint,
    name                      varchar,
    rule_type                 smallint,
    scanners                  text[],
    vulnerabilities_allowed   smallint,
    severity_levels           text[],
    report_type               smallint,
    vulnerability_states      text[],
    orchestration_policy_idx  smallint,
    applies_to_all_protected_branches              boolean,
    security_orchestration_policy_configuration_id bigint,
    scan_result_policy_id                          bigint
);

首先,我们要将这里的类型转换成 pg_filedump 可以识别的类型,这里涉及到类型映射的问题:如果你有不确定的类型,比如上面的 text[] 字符串数组字段,就可以先用 text 类型占位替代,也可以直接用 ~ 忽略:

bigint,timestamptz,timestamptz,int,smallint,varchar,smallint,text,smallint,text,smallint,text,smallint,bool,bigint,bigint

当然这里有第一个知识点就是 PostgreSQL 的元组列布局是有顺序的,这个顺序保存在系统视图 pg_attribute 里面的 attrnum 中,而表中每一列的类型ID则保存在 atttypid 字段中,而为了获取类型的英文名称,你又需要通过类型ID引用 pg_type 系统视图(当然系统默认类型都有固定ID,也可以直接用ID映射)。综上,为了获取表中物理记录的解释方法,你至少需要用到上面提到的那四张系统字典表。

有了这张表上列的顺序与类型之后,并且知道这张表的二进制文件位置之后,你就可以利用这个信息翻译二进制数据了。

pg_filedump -i -f -D 'bigint,...,bigint' 38304

pg-filedump-6.png

输出时结果建议添加 -i-f 选项,前者会打印每一行的元数据(需要根据 XMAX 判断这一行有没有被删除);后者会打印原始二进制数据上下文(这一点对于处理 pg_filedump 解决不了的复杂数据是必要的)。

正常情况下,每一条记录都会以 COPY:Error: 开头,前者代表提取成功,后者代表部分成功,或者失败。如果是失败,会有各种各样的原因,需要分别处理。对于成功的数据,你可以直接把它拿出来,每一行就是一条数据,用 \t 分隔,把 \N 替换为 NULL,处理好写入到临时表中保存待用即可。

当然魔鬼其实都在细节里,要是数据恢复真这么容易就好了。


魔鬼在细节中

在处理数据数据恢复时,有许多小细节需要关注,这里我提几个重要的点。

首先是 TOAST 字段的处理。TOAST 是“ The Oversized-Attribute Storage Technique ”的缩写,即超标属性存储技术。如果你发现解析出来的字段内容是 (TOASTED),那就说明这个字段因为太长,被切片转移到另外一张专用的表 —— TOAST 表中了。

如果某张表里有可能 TOAST 的字段,它就会有一张对应的 TOAST 表,在 pg_class 中用 reltoastrelid 标识其 OID。TOAST 其实也可以看做一张普通的表来处理,所以你可以用一样的方法把 TOAST 数据解析出来,拼接回去,再填入到原表中,这里就不展开了。

pg-filedump-7.png

第二个问题是复杂类型,正如上一节所说, pg_filedump README里列出了支持的类型,但类似数组这样的类型就需要进行额外的二进制解析处理了。

举个例子,当你转储数组二进制时,看到的结果可能是一串儿 \0\0 。这是因为 pg_filedump 直接把处理不了的复杂类型给吐出来了。当然这里就会带来一些额外的问题 —— 字符串里的零值会让你的插入报错,所以你的解析脚本需要处理好这种问题,当遇到一个解析错误的复杂列时,应该先做个标记占个坑,把二进制值现场给保留下来,留给后面的步骤去具体处理。

这里我们来看个具体的例子:还是以上面 public.approval_merge_request_rules 表为例。我们可以从吐出来的数据,二进制视图,以及 ASCII 视图里面看到一些零星的字符串:criticalunknown 之类的东西,掺杂在一串 \0 与二进制控制字符中。没错,这就是一个字符串数组的二进制表示。PostgreSQL 中的数组允许任意类型任意深度的嵌套,所以这里的数据结构会有一点点复杂。

pg-filedump-8.png

例如,图片中标色的地方对应的数据是一个包含三个字符串的数组:{unknown,high,critical}::TEXT[] 。01 代表这是一个一位数组,紧跟着空值位图,以及代表数组元素的类型OID 的 0x00000019 ,0x19 十进制值为 25 对应 pg_type 中的 text类型,说明这里是一个字符串数组(如果是 0x17 则说明是整型数组)。紧接着是这个数组第一维的维度 0x03,因为这个数组只有一维,三个元素;接下来的 1 告诉我们数组第一维度的起始偏移量在哪儿。再后面才是挨着的三个字符串结构了:由4字节的长度打头(要右移两位处理标记未),接着才是字符串内容,还要考虑布局对齐与填充的问题。

总的来说,你需要对照着源代码实现去挖掘,而这里有无穷无尽的细节:可变长度,空值位图,字段压缩,线外存储,以及大小端序,稍有不慎,你解出来的东西就是一团没用的浆糊。

你可以选择直接用 Python 脚本去记录的上下文中解析原始二进制回补数据,或者在 pg_filedump 源代码中注册新的类型与回调处理函数,复用 PG 提供的 C 解析函数,无论哪一种都称不上是轻松。

好在 PostgreSQL 本身已经提供了一些C语言的辅助函数 & 宏可以帮助你完成大部分工作,而且幸运的是 Gitlab 中的数组都是一维数组,类型也仅限于整型数组与字符串数组,其他带复杂类型的数据页也可以从其他表中重建,所以总体工作量还是可以接受的 。

pg-filedump-9.png


后记

这个活儿折腾了我两天,掏粪细节就不展开了,我估计读者也不会感兴趣。总之经过了一系列处理,校正,补对之后,数据恢复的工作终于完成了!除了有几张表里有几条损坏的数据之外,其他的数据都成功解出来了。好家伙,整整一千张表啊!

我以前也弄过一些数据恢复的活儿,大多数情况都还比较简单,数据坏块儿,控制文件/CLOG损坏,或者是被挖矿病毒种了勒索木马(往Tablespace里写了几个垃圾文件),但炸的这么彻底的Case我还是第一次弄。之所以敢接这个活,也是因为我对PG内核还是有些了解的,知道这些繁琐的实现细节。只要你知道这是一个工程上可解的问题,那么即使过程再脏再累也不会担心完不成。

尽管有些缺陷,但 pg_filedump 还是一个不错的工具,后面我可能会考虑完善一下它,让它对各种数据类型都有完整的支持,这样就不用再自己写一堆 Python 小脚本来处理各种繁琐的细节了。在弄完这个案例后,我已经把 pg_filedump 打好了 PG 12 - 16 x EL 7 - 9 上的 RPM 包放在 Pigsty 的 Yum源中,默认收录在 Pigsty 离线软件包里,目前已经在 Pigsty v2.4.1 中实装交付了。我衷心希望您永远也用不上这个扩展,但如果你真的碰上需要它的场景时,我也希望它就在你的手边可以开箱即用。

最后我还是想说一句,许多软件都需要数据库,但数据库的安装部署维护是一件很有门槛的活儿。Gitlab 拉起的 PostgreSQL 质量已经算是相当不错的了,但面对这种情况依然束手无策,更不用提那些土法手造 docker 镜像的简陋单机实例了。一场大故障,就能让一个企业积累的代码数据、CI/CD流程、Issue/PR/MR 记录灰飞烟灭。我真的建议您好好检视一下自己的数据库系统,至少请定期做个备份吧!

pg-filedump-10.png

Gitlab 的企业版和社区版的核心区别就在于它底下的 PG 有没有高可用和监控。而开箱即用的 PostgreSQL 发行版 —— Pigsty 也可以为您更好地解决这些问题,却完全开源免费,分文不取:无论是高可用,PITR,还是监控系统一应俱全:下次再遇到这种问题时,就可以自动切换/一键回滚,游刃有余得多。之前我们自己的 Gitlab, Jira, Confluence 等软件都跑在上面,如果您有类似需求,倒是不妨试一下哦。

PG中的本地化排序规则

什么?不知道COLLATTION是什么,那记住一件事,用C COLLATE准没错!

为什么Pigsty在初始化Postgres数据库时默认指定了locale=Cencoding=UTF8

答案其实很简单,除非真的明确知道自己会用到LOCALE相关功能,否则就根本不应该配置C.UTF8之外的任何字符编码与本地化排序规则选项。特别是`

关于字符编码的部分,之前写过一篇文章专门介绍,这里表过不提。今天专门说一下LOCALE(本地化)的配置问题。

如果说服务端字符编码配置因为某些原因配置为UTF8之外的值也许还情有可原,那么LOCALE配置为C之外的任何选就是无可救药了。因为对于PostgreSQL来说,LOCALE不仅仅是控制日期和钱怎么显示这一类无伤大雅的东西,而是会影响到某些关键功能的使用。

错误的LOCALE配置可能导致几倍到十几倍的性能损失,还会导致LIKE查询无法在普通索引上使用。而设置LOCALE=C一点也不会影响真正需要本地化规则的使用场景。所以官方文档给出的指导是:“如果你真正需要LOCALE,才去使用它”。

不幸的是,在PostgreSQLlocaleencoding的默认配置取决于操作系统的配置,因此C.UTF8可能并不是默认的配置,这就导致了很多人误用LOCALE而不自知,白白折损了大量性能,也导致了某些数据库特性无法正常使用。

太长;不看

  • 强制使用UTF8字符编码,强制数据库使用C的本地化规则。
  • 使用非C本地化规则,可能导致涉及字符串比较的操作开销增大几倍到几十倍,对性能产生显著负面影响
  • 使用非C本地化规则,会导致LIKE查询无法使用普通索引,容易踩坑雪崩。
  • 使用非C本地化规则的实例,可以通过text_ops COLLATE "C"text_pattern_ops建立索引,支持LIKE查询。

LOCALE是什么

我们经常能在操作系统和各种软件中看到 LOCALE(区域) 的相关配置,但LOCALE到底是什么呢?

LOCALE支持指的是应用遵守文化偏好的问题,包括字母表、排序、数字格式等。LOCALE由很多规则与定义组成,包括:

LC_COLLATE 字符串排序顺序
LC_CTYPE 字符分类(什么是一个字符?它的大写形式是否等效?)
LC_MESSAGES 消息使用的语言Language of messages
LC_MONETARY 货币数量使用的格式
LC_NUMERIC 数字的格式
LC_TIME 日期和时间的格式
…… 其他……

一个LOCALE就是一组规则,LOCALE通常会用语言代码 + 国家代码的方式来命名。例如中国大陆使用的LOCALE zh_CN就分为两个部分:zh是 语言代码,CN 是国家代码。现实世界中,一种语言可能有多个国家在用,一个国家内也可能存在多种语言。还是以中文和中国为例:

中国(COUNTRY=CN)相关的语言LOCALE有:

  • zh:汉语:zh_CN
  • bo:藏语:bo_CN
  • ug:维语:ug_CN

讲中文(LANG=zh)的国家或地区相关的LOCAL有:

  • CN 中国:zh_CN
  • HK 香港:zh_HK
  • MO 澳门:zh_MO
  • TW 台湾:zh_TW
  • SG 新加坡:zh_SG

LOCALE的例子

我们可以参考一个典型的Locale定义文件:Glibc提供的 zh_CN

这里截取一小部分展示,看上去好像都是些鸡零狗碎的格式定义,月份星期怎么叫啊,钱和小数点怎么显示啊之类的东西。

但这里有一个非常关键的东西,叫做LC_COLLATE,即排序方式(Collation),会对数据库行为有显著影响。

LC_CTYPE
copy "i18n"
translit_start
include  "translit_combining";""
translit_end
class	"hanzi"; /
<U4E00>..<U9FA5>;/
<UF92C>;<UF979>;<UF995>;<UF9E7>;<UF9F1>;<UFA0C>;<UFA0D>;<UFA0E>;/
<UFA0F>;<UFA11>;<UFA13>;<UFA14>;<UFA18>;<UFA1F>;<UFA20>;<UFA21>;/
<UFA23>;<UFA24>;<UFA27>;<UFA28>;<UFA29>
END LC_CTYPE

LC_COLLATE
copy "iso14651_t1_pinyin"
END LC_COLLATE

LC_TIME
% 一月, 二月, 三月, 四月, 五月, 六月, 七月, 八月, 九月, 十月, 十一月, 十二月
mon           "<U4E00><U6708>";/
     "<U4E8C><U6708>";/
     "<U4E09><U6708>";/
     "<U56DB><U6708>";/
...
% 星期日, 星期一, 星期二, 星期三, 星期四, 星期五, 星期六
day           "<U661F><U671F><U65E5>";/
     "<U661F><U671F><U4E00>";/
     "<U661F><U671F><U4E8C>";/
...
week          7;19971130;1
first_weekday 2
% %Y年%m月%d日 %A %H时%M分%S秒
d_t_fmt       "%Y<U5E74>%m<U6708>%d<U65E5> %A %H<U65F6>%M<U5206>%S<U79D2>"
% %Y年%m月%d日
d_fmt         "%Y<U5E74>%m<U6708>%d<U65E5>"
% %H时%M分%S秒
t_fmt         "%H<U65F6>%M<U5206>%S<U79D2>"
% 上午, 下午
am_pm         "<U4E0A><U5348>";"<U4E0B><U5348>"
% %p %I时%M分%S秒
t_fmt_ampm    "%p %I<U65F6>%M<U5206>%S<U79D2>"
% %Y年 %m月 %d日 %A %H:%M:%S %Z
date_fmt      "%Y<U5E74> %m<U6708> %d<U65E5> %A %H:%M:%S %Z"
END LC_TIME

LC_NUMERIC
decimal_point "."
thousands_sep ","
grouping      3
END LC_NUMERIC

LC_MONETARY
% ¥
currency_symbol    "<UFFE5>"
int_curr_symbol    "CNY "

比如zh_CN提供的LC_COLLATE使用了iso14651_t1_pinyin排序规则,这是一个基于拼音的排序规则

下面通过一个例子来介绍LOCALE中的COLLATION如何影响Postgres的行为。

排序规则一例

创建一张包含7个汉字的表,然后执行排序操作。

CREATE TABLE some_chinese(
    name TEXT PRIMARY KEY
);
INSERT INTO some_chinese VALUES 
('阿'),('波'),('磁'),('得'),('饿'),('佛'),('割');

SELECT * FROM some_chinese ORDER BY name;

执行以下SQL,按照默认的C排序规则对表中的记录排序。可以看到,这里实际上是按照字符的ascii|unicode 码位 进行排序的。

vonng=# SELECT name, ascii(name) FROM some_chinese ORDER BY name COLLATE "C";
 name | ascii
------+-------
| 20315
| 21106
| 24471
| 27874
| 30913
| 38463
 饿   | 39295

但这样基于码位的排序对于中国人来说可能没有任何意义。例如新华字典在收录汉字时,就不会使用这种排序方式。而是采用zh_CN 所使用的 拼音排序 规则,按照拼音比大小。如下所示:

 SELECT * FROM some_chinese ORDER BY name COLLATE "zh_CN";
 name
------
 
 
 
 
 饿
 
 

可以看到,按照zh_CN排序规则排序得到的结果,就是拼音顺序abcdefg,而不再是不知所云的Unicode码位排序。

当然这个查询结果取决于zh_CN 排序规则的具体定义,像这样的排序规则并不是数据库本身定义的,数据库本身提供的排序规则就是C(或者其别名POSIX)。COLLATION的来源,通常要么是操作系统,要么是glibc,要么是第三方的本地化库(例如icu),所以可能因为不同的实质定义出现不同的效果。

但代价是什么?

PostgreSQL中使用非C或非POSIX LOCALE的最大负面影响是:

特定排序规则对涉及字符串大小比较的操作有巨大的性能影响,同时它还会导致无法在LIKE查询子句中使用普通索引。

另外,C LOCALE是由数据库本身确保在任何操作系统与平台上使用的,而其他的LOCALE则不然,所以使用非C Locale的可移植性更差。

性能损失

接下来让我们考虑一个使用LOCALE排序规则的例子, 我们有Apple Store 150万款应用的名称,现在希望按照不同的区域规则进行排序。

-- 创建一张应用名称表,里面有中文也有英文。
CREATE TABLE app(
    name TEXT PRIMARY KEY
);
COPY app FROM '/tmp/app.csv';

-- 查看表上的统计信息
SELECT
    correlation , -- 相关系数 0.03542578 基本随机分布
    avg_width ,   -- 平均长度25字节
    n_distinct    -- -1,意味着1508076个记录没有重复
FROM pg_stats WHERE tablename = 'app';

-- 使用不同的排序规则进行一系列的实验
SELECT * FROM app;
SELECT * FROM app order by name; 
SELECT * FROM app order by name COLLATE "C";
SELECT * FROM app order by name COLLATE "en_US";
SELECT * FROM app order by name COLLATE "zh_CN"; 

相当令人震惊的结果,使用Czh_CN的结果能相差十倍之多:

序号 场景 耗时(ms) 说明
1 不排序 180 使用索引
2 order by name 969 使用索引
3 order by name COLLATE "C" 1430 顺序扫描,外部排序
4 order by name COLLATE "en_US" 10463 顺序扫描,外部排序
5 order by name COLLATE "zh_CN" 14852 顺序扫描,外部排序

下面是实验5对应的详细执行计划,即使配置了足够大的内存,依然会溢出到磁盘执行外部排序。尽管如此,显式指定LOCALE的实验都出现了此情况,因此可以横向对比出C与zh_CN的性能差距来。

另一个更有对比性的例子是比大小

这里,表中的所有的字符串都会和World比一下大小,相当于在表上进行150万次特定规则比大小,而且也不涉及到磁盘IO。

SELECT count(*) FROM app WHERE name > 'World';
SELECT count(*) FROM app WHERE name > 'World' COLLATE "C";
SELECT count(*) FROM app WHERE name > 'World' COLLATE "en_US";
SELECT count(*) FROM app WHERE name > 'World' COLLATE "zh_CN";

尽管如此,比起C LOCALE来,zh_CN 还是费了接近3倍的时长。

序号 场景 耗时(ms)
1 默认 120
2 C 145
3 en_US 351
4 zh_CN 441

如果说排序可能是O(n2)次比较操作有10倍损耗 ,那么这里的O(n)次比较3倍开销也基本能对应上。我们可以得出一个初步的粗略结论:

比起C Locale来,使用zh_CN或其他Locale可能导致几倍的额外性能开销。

除此之外,错误的Locale不仅仅会带来性能损失,还会导致功能损失

功能缺失

除了性能表现糟糕外,另一个令人难以接受的问题是,使用非C的LOCALE,LIKE查询走不了普通索引

还是以刚才的实验为例,我们分别在使用Cen_US作为默认LOCALE创建的数据库实例上执行以下查询:

SELECT * FROM app WHERE name LIKE '中国%';

找出所有以“中国”两字开头的应用。

在使用C的库上

该查询能正常使用app_pkey索引,利用主键B树的有序性加速查询,约2毫秒内执行完毕。

postgres@meta:5432/meta=# show lc_collate;
 C

postgres@meta:5432/meta=# EXPLAIN SELECT * FROM app WHERE name LIKE '中国%';
                                 QUERY PLAN
-----------------------------------------------------------------------------
 Index Only Scan using app_pkey on app  (cost=0.43..2.65 rows=1510 width=25)
   Index Cond: ((name >= '中国'::text) AND (name < '中图'::text))
   Filter: (name ~~ '中国%'::text)
(3 rows)

在使用en_US的库上

我们发现,这个查询无法利用索引,走了全表扫描。查询劣化至70毫秒,性能恶化了三四十倍。

vonng=# show lc_collate;
 en_US.UTF-8

vonng=# EXPLAIN SELECT * FROM app WHERE name LIKE '中国%';
                        QUERY PLAN
----------------------------------------------------------
 Seq Scan on app  (cost=0.00..29454.95 rows=151 width=25)
   Filter: (name ~~ '中国%'::text)

为什么?

因为索引(B树索引)的构建,也是建立在的基础上,也就是等值比大小这两个操作。

然而,LOCALE关于字符串的等价规则有一套自己的定义,例如在Unicode标准中就定义了很多匪夷所思的等价规则(毕竟是万国语言,比如多个字符复合而成的字符串等价于另一个单体字符,详情参考 现代字符编码 一文)。

因此,只有最朴素的C LOCALE,才能够正常地进行模式匹配。C LOCALE的比较规则非常简单,就是挨个比较 字符码位,不玩那一套花里胡哨虚头巴脑的东西。所以,如果您的数据库不幸使用了非C的LOCALE,那么在执行LIKE查询时就没有办法使用默认的索引了。

解决办法

对于非C LOCALE的实例,只有建立特殊类型的索引,才能支持此类查询:

CREATE INDEX ON app(name COLLATE "C");
CREATE INDEX ON app(name text_pattern_ops);

这里使用 text_pattern_ops运算符族来创建索引也可以用来支持LIKE查询,这是专门用于支持模式匹配的运算符族,从原理上讲它会无视 LOCALE,直接基于 逐个字符 比较的方式执行模式匹配,也就是使用C LOCALE的方式。

因此在这种情况下,只有基于text_pattern_ops操作符族建立的索引,或者基于默认的text_ops但使用COLLATE "C"' 的索引,才可以用于支持LIKE查询。

vonng=# EXPLAIN ANALYZE SELECT * FROM app WHERE name LIKE '中国%';

Index Only Scan using app_name_idx on app  (cost=0.43..1.45 rows=151 width=25) (actual time=0.053..0.731 rows=2360 loops=1)
   Index Cond: ((name ~>=~ '中国'::text) AND (name ~<~ '中图'::text))
   Filter: (name ~~ '中国%'::text COLLATE "en_US.UTF-8")

建立完索引后,我们可以看到原来的LIKE查询可以走索引了。

LIKE无法使用普通索引这个问题,看上去似乎可以通过额外创建一个text_pattern_ops索引来曲线解决。但这也意味着原本可以直接利用现成的PRIMARY KEYUNIQUE约束自带索引解决的问题,现在需要额外的维护成本与存储空间。

对于不熟悉这一问题的开发者来说,很有可能因为错误的LOCALE配置,导致本地没问题的模式结果在线上因为没有走索引而雪崩。(例如本地使用C,但生产环境用了非C LOCALE)。

兼容性

假设您在接手时数据库已经使用了非C的LOCALE(这种事相当常见),现在您在知道了使用非C LOCALE的危害后,决定找个机会改回来。

那么有哪些地方需要注意呢?具体来讲,Locale的配置影响PostgreSQL以下功能:

  1. 使用LIKE子句的查询。

  2. 任何依赖特定LOCALE排序规则的查询,例如依赖拼音排序作为结果排序依据。

  3. 使用大小写转换相关功能的查询,函数upperlowerinitcap

  4. to_char函数家族,涉及到格式化为本地时间时。

  5. 正则表达式中的大小写不敏感匹配模式(SIMILAR TO ,~)。

如果不放心,可以通过pg_stat_statements列出所有涉及到以下关键词的查询语句进行手工排查:

LIKE|ILIKE                   -- 是否使用了模式匹配
SIMILAR TO | ~ | regexp_xxx  -- 是否使用了 i 选项
upper, lower, initcap        -- 是否针对其他带有大小写模式的语言使用(西欧字符之类)
ORDER BY col                 -- 按文本类型列排序时,是否依赖特定排序规则?(例如按照拼音)

兼容性修改

通常来说,C LOCALE在功能上是其他LOCALE配置的超集,总是可以从其他LOCALE切换为C。如果您的业务没有使用这些功能,通常什么都不需要做。如果使用本地化规则特性,则总是可以通过**显式指定COLLATE**的方式,在C LOCALE下实现相同的效果。

SELECT upper('a' COLLATE "zh_CN");  -- 基于zh_CN规则执行大小写转换
SELECT  '阿' < '波';                 -- false, 在默认排序规则下  阿(38463) > 波(27874)
SELECT  '阿' < '波' COLLATE "zh_CN"; -- true, 显式使用中文拼音排序规则: 阿(a) < 波(bo)

目前唯一已知的问题出现在扩展pg_trgm上。

PG复制标识详解(Replica Identity)

复制标识很重要,它关系到逻辑复制的成败

引子:土法逻辑复制

复制身份的概念,服务于 逻辑复制

逻辑复制的基本工作原理是,将逻辑发布相关表上对行的增删改事件解码,复制到逻辑订阅者上执行。

逻辑复制的工作方式有点类似于行级触发器,在事务执行后对变更的元组逐行触发。

假设您需要自己通过触发器实现逻辑复制,将一章表A上的变更复制到另一张表B中。通常情况下,这个触发器的函数逻辑通常会长这样:

-- 通知触发器
CREATE OR REPLACE FUNCTION replicate_change() RETURNS TRIGGER AS $$
BEGIN
  IF    (TG_OP = 'INSERT') THEN 
  -- INSERT INTO tbl_b VALUES (NEW.col);
  ELSIF (TG_OP = 'DELETE') THEN 
	-- DELETE tbl_b WHERE id = OLD.id;
  ELSIF (TG_OP = 'UPDATE') THEN 
	-- UPDATE tbl_b SET col = NEW.col,... WHERE id = OLD.id;
  END IF;
END; $$ LANGUAGE plpgsql;

触发器中会有两个变量OLDNEW,分别包含了变更记录的旧值与新值。

  • INSERT操作只有NEW变量,因为它是新插入的,我们直接将其插入到另一张表即可。
  • DELETE操作只有OLD变量,因为它只是删除已有记录,我们 根据ID 在目标表B上。
  • UPDATE操作同时存在OLD变量与NEW变量,我们需要通过 OLD.id 定位目标表B中的记录,将其更新为新值NEW

这样的基于触发器的“逻辑复制”可以完美达到我们的目的,在逻辑复制中与之类似,表A上带有主键字段id。那么当我们删除表A上的记录时,例如:删除id = 1的记录时,我们只需要告诉订阅方id = 1,而不是把整个被删除的元组传递给订阅方。那么这里主键列id就是逻辑复制的复制标识

但上面的例子中隐含着一个工作假设:表A和表B模式相同,上面有一个名为 id 的主键。

对于生产级的逻辑复制方案,即PostgreSQL 10.0后提供的逻辑复制,这样的工作假设是不合理的。因为系统无法要求用户建表时一定会带有主键,也无法要求主键的名字一定叫id

于是,就有了 复制标识(Replica Identity) 的概念。复制标识是对OLD.id这样工作假设的进一步泛化与抽象,它用来告诉逻辑复制系统,哪些信息可以被用于唯一定位表中的一条记录

复制标识

对于逻辑复制而言,INSERT 事件不需要特殊处理,但要想将DELETE|UPDATE复制到订阅者上时,必须提供一种标识行的方式,即复制标识(Replica Identity)。复制标识是一组列的集合,这些列可以唯一标识一条记录。其实这样的定义在概念上来说就是构成主键的列集,当然非空唯一索引中的列集(候选键)也可以起到同样的效果。

一个被纳入逻辑复制 发布中的表,必须配置有 复制标识(Replica Identity),只有这样才可以在订阅者一侧定位到需要更新的行,完成UPDATEDELETE操作的复制。默认情况下,主键 (Primary Key)和 非空列上的唯一索引 (UNIQUE NOT NULL)可以用作复制标识。

注意,复制标识 和表上的主键、非空唯一索引并不是一回事。复制标识是上的一个属性,它指明了在逻辑复制时,哪些信息会被用作身份定位标识符写入到逻辑复制的记录中,供订阅端定位并执行变更。

如PostgreSQL 13官方文档所述,表上的复制标识 共有4种配置模式,分别为:

  • 默认模式(default):非系统表采用的默认模式,如果有主键,则用主键列作为身份标识,否则用完整模式。
  • 索引模式(index):将某一个符合条件的索引中的列,用作身份标识
  • 完整模式(full):将整行记录中的所有列作为复制标识(类似于整个表上每一列共同组成主键)
  • 无身份模式(nothing):不记录任何复制标识,这意味着UPDATE|DELETE操作无法复制到订阅者上。

复制标识查询

表上的复制标识可以通过查阅pg_class.relreplident获取。

这是一个字符类型的“枚举”,标识用于组装 “复制标识” 的列:d = default ,f = 所有的列,i 使用特定的索引,n 没有复制标识。

表上是否具有可用作复制标识的索引约束,可以通过以下查询获取:

SELECT quote_ident(nspname) || '.' || quote_ident(relname) AS name, con.ri AS keys,
       CASE relreplident WHEN 'd' THEN 'default' WHEN 'n' THEN 'nothing' WHEN 'f' THEN 'full' WHEN 'i' THEN 'index' END AS replica_identity
FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid, LATERAL (SELECT array_agg(contype) AS ri FROM pg_constraint WHERE conrelid = c.oid) con
WHERE relkind = 'r' AND nspname NOT IN ('pg_catalog', 'information_schema', 'monitor', 'repack', 'pg_toast')
ORDER BY 2,3;

复制标识配置

表到复制标识可以通过ALTER TABLE进行修改。

ALTER TABLE tbl REPLICA IDENTITY { DEFAULT | USING INDEX index_name | FULL | NOTHING };
-- 具体有四种形式
ALTER TABLE t_normal REPLICA IDENTITY DEFAULT;                    -- 使用主键,如果没有主键则为FULL
ALTER TABLE t_normal REPLICA IDENTITY FULL;                       -- 使用整行作为标识
ALTER TABLE t_normal REPLICA IDENTITY USING INDEX t_normal_v_key; -- 使用唯一索引
ALTER TABLE t_normal REPLICA IDENTITY NOTHING;                    -- 不设置复制标识

复制标识实例

下面用一个具体的例子来说明复制标识的效果:

CREATE TABLE test(k text primary key, v int not null unique);

现在有一个表test,上面有两列kv

INSERT INTO test VALUES('Alice', '1'), ('Bob', '2');
UPDATE test SET v = '3' WHERE k = 'Alice';    -- update Alice value to 3
UPDATE test SET k = 'Oscar' WHERE k = 'Bob';  -- rename Bob to Oscaar
DELETE FROM test WHERE k = 'Alice';           -- delete Alice

在这个例子中,我们对表test执行了增删改操作,与之对应的逻辑解码结果为:

table public.test: INSERT: k[text]:'Alice' v[integer]:1
table public.test: INSERT: k[text]:'Bob' v[integer]:2
table public.test: UPDATE: k[text]:'Alice' v[integer]:3
table public.test: UPDATE: old-key: k[text]:'Bob' new-tuple: k[text]:'Oscar' v[integer]:2
table public.test: DELETE: k[text]:'Alice'

默认情况下,PostgreSQL会使用表的主键作为复制标识,因此在UPDATE|DELETE操作中,都通过k列来定位需要修改的记录。

如果我们手动修改表的复制标识,使用非空且唯一的列v作为复制标识,也是可以的:

ALTER TABLE test REPLICA IDENTITY USING INDEX test_v_key; -- 基于UNIQUE索引的复制身份

同样的变更现在产生如下的逻辑解码结果,这里v作为身份标识,出现在所有的UPDATE|DELETE事件中。

table public.test: INSERT: k[text]:'Alice' v[integer]:1
table public.test: INSERT: k[text]:'Bob' v[integer]:2
table public.test: UPDATE: old-key: v[integer]:1 new-tuple: k[text]:'Alice' v[integer]:3
table public.test: UPDATE: k[text]:'Oscar' v[integer]:2
table public.test: DELETE: v[integer]:3

如果使用完整身份模式(full)

ALTER TABLE test REPLICA IDENTITY FULL; -- 表test现在使用所有列作为表的复制身份

这里,kv同时作为身份标识,记录到UPDATE|DELETE的日志中。对于没有主键的表,这是一种保底方案。

table public.test: INSERT: k[text]:'Alice' v[integer]:1
table public.test: INSERT: k[text]:'Bob' v[integer]:2
table public.test: UPDATE: old-key: k[text]:'Alice' v[integer]:1 new-tuple: k[text]:'Alice' v[integer]:3
table public.test: UPDATE: old-key: k[text]:'Bob' v[integer]:2 new-tuple: k[text]:'Oscar' v[integer]:2
table public.test: DELETE: k[text]:'Alice' v[integer]:3

如果使用无身份模式(nothing)

ALTER TABLE test REPLICA IDENTITY NOTHING; -- 表test现在没有复制标识

那么逻辑解码的记录中,UPDATE操作中只有新记录,没有包含旧记录中的唯一身份标识,而DELETE操作中则完全没有信息。

table public.test: INSERT: k[text]:'Alice' v[integer]:1
table public.test: INSERT: k[text]:'Bob' v[integer]:2
table public.test: UPDATE: k[text]:'Alice' v[integer]:3
table public.test: UPDATE: k[text]:'Oscar' v[integer]:2
table public.test: DELETE: (no-tuple-data)

这样的逻辑变更日志对于订阅端来说完全没用,在实际使用中,对逻辑复制中的无复制标识的表执行DELETE|UPDATE会直接报错。

复制标识详解

表上的复制标识配置,与表上有没有索引,是相对正交的两个因素。

尽管各种排列组合都是可能的,然而在实际使用中,只有三种可行的情况。

  • 表上有主键,使用默认的 default 复制标识
  • 表上没有主键,但是有非空唯一索引,显式配置 index 复制标识
  • 表上既没有主键,也没有非空唯一索引,显式配置full复制标识(运行效率非常低,仅能作为兜底方案)
  • 其他所有情况,都无法正常完成逻辑复制功能
复制身份模式\表上的约束 主键(p) 非空唯一索引(u) 两者皆无(n)
default 有效 x x
index x 有效 x
full 低效 低效 低效
nothing x x x

下面,我们来考虑几个边界条件。

重建主键

假设因为索引膨胀,我们希望重建表上的主键索引回收空间。

CREATE TABLE test(k text primary key, v int);
CREATE UNIQUE INDEX test_pkey2 ON test(k);
BEGIN;
ALTER TABLE test DROP CONSTRAINT test_pkey;
ALTER TABLE test ADD PRIMARY KEY USING INDEX test_pkey2;
COMMIT;

default模式下,重建并替换主键约束与索引并不会影响复制标识。

重建唯一索引

假设因为索引膨胀,我们希望重建表上的非空唯一索引回收空间。

CREATE TABLE test(k text, v int not null unique);
ALTER TABLE test REPLICA IDENTITY USING INDEX test_v_key;
CREATE UNIQUE INDEX test_v_key2 ON test(v);
-- 使用新的test_v_key2索引替换老的Unique索引
BEGIN;
ALTER TABLE test ADD UNIQUE USING INDEX test_v_key2;
ALTER TABLE test DROP CONSTRAINT test_v_key;
COMMIT;

default模式不同,index模式下,复制标识是与具体的索引绑定的:

                                    Table "public.test"
 Column |  Type   | Collation | Nullable | Default | Storage  | Stats target | Description
--------+---------+-----------+----------+---------+----------+--------------+-------------
 k      | text    |           |          |         | extended |              |
 v      | integer |           | not null |         | plain    |              |
Indexes:
    "test_v_key" UNIQUE CONSTRAINT, btree (v) REPLICA IDENTITY
    "test_v_key2" UNIQUE CONSTRAINT, btree (v)

这意味着如果采用偷天换日的方式替换UNIQUE索引会导致复制身份的丢失。

解决方案有两种:

  1. 使用REINDEX INDEX (CONCURRENTLY)的方式重建该索引,不会丢失复制标识信息。
  2. 在替换索引时,一并刷新表的默认复制身份:
BEGIN;
ALTER TABLE test ADD UNIQUE USING INDEX test_v_key2;
ALTER TABLE test REPLICA IDENTITY USING INDEX test_v_key2;
ALTER TABLE test DROP CONSTRAINT test_v_key;
COMMIT;

顺带一提,移除作为身份标识的索引。尽管在表的配置信息中仍然为index模式,但效果与nothing相同。所以不要随意折腾作为身份的索引。

使用不合格的索引作为复制标识

复制标识需要一个 唯一,不可延迟,整表范围的,建立在非空列集上的索引。

最经典的例子就是主键索引,以及通过col type NOT NULL UNIQUE声明的单列非空索引。

之所以要求 NOT NULL,是因为NULL值无法进行等值判断,所以表中允许UNIQE的列上存在多条取值为NULL的记录,允许列为空说明这个列无法起到唯一标识记录的效果。如果尝试使用一个普通的UNIQUE索引(列上没有非空约束)作为复制标识,则会报错。

[42809] ERROR: index "t_normal_v_key" cannot be used as replica identity because column "v" is nullable

使用FULL复制标识

如果没有任何复制标识,可以将复制标识设置为FULL,也就是把整个行当作复制标识。

使用FULL模式的复制标识效率很低,所以这种配置只能是保底方案,或者用于很小的表。因为每一行修改都需要在订阅者上执行全表扫描很容易把订阅者拖垮

FULL模式限制

使用FULL模式的复制标识还有一个限制,订阅端的表上的复制身份所包含的列,要么与发布者一致,要么比发布者更少,否则也无法保证的正确性,下面具体来看一个例子。

假如发布订阅两侧的表都采用FULL复制标识,但是订阅侧的表要比发布侧多了一列(是的,逻辑复制允许订阅端的表带有发布端表不具有的列)。这样的话,订阅端的表上的复制身份所包含的列要比发布端多了。假设在发布端上删除(f1=a, f2=a)的记录,却会导致在订阅端删除两条满足身份标识等值条件的记录。

     (Publication)       ------>           (Subscription)
|--- f1 ---|--- f2 ---|          |--- f1 ---|--- f2 ---|--- f3 ---|
|    a     |     a    |          |    a     |     a    |     b    |
                                 |    a     |     a    |     c    |

FULL模式如何应对重复行问题

PostgreSQL的逻辑复制可以“正确”处理FULL模式下完全相同行的场景。假设有这样一张设计糟糕的表,表中存在多条一模一样的记录。

CREATE TABLE shitty_table(
	 f1  TEXT,
	 f2  TEXT,
	 f3  TEXT
);
INSERT INTO shitty_table VALUES ('a', 'a', 'a'), ('a', 'a', 'a'), ('a', 'a', 'a');

在FULL模式下,整行将作为复制标识使用。假设我们在shitty_table上通过ctid扫描作弊,删除了3条一模一样记录中的其中一条。

# SELECT ctid,* FROM shitty_table;
 ctid  | a | b | c
-------+---+---+---
 (0,1) | a | a | a
 (0,2) | a | a | a
 (0,3) | a | a | a

# DELETE FROM shitty_table WHERE ctid = '(0,1)';
DELETE 1

# SELECT ctid,* FROM shitty_table;
 ctid  | a | b | c
-------+---+---+---
 (0,2) | a | a | a
 (0,3) | a | a | a

从逻辑上讲,使用整行作为身份标识,那么订阅端执行以下逻辑,会导致全部3条记录被删除。

DELETE FROM shitty_table WHERE f1 = 'a' AND f2 = 'a' AND f3 = 'a'

但实际情况是,因为PostgreSQL的变更记录以行为单位,这条变更仅会对第一条匹配的记录生效,所以在订阅侧的行为也是删除3行中的1行。在逻辑上与发布端等效。

故障档案:时间回溯导致的Patroni故障

机器因为故障重启,NTP服务在PG启动后修复了PG的时间,导致Patroni无法启动。

摘要:机器因为故障重启,NTP服务在PG启动后修复了PG的时间,导致 Patroni 无法启动。

Patroni中的故障信息如下所示:

Process %s is not postmaster, too much difference between PID file start time %s and process start time %s

patroni 进程启动时间和pid时间不一致。就会认为:postgres is not running。

两个时间相差超过30秒。patroni 就尿了,启动不了了。

打印错误信息的代码为:

start_time = int(self._postmaster_pid.get('start_time', 0))
if start_time and abs(self.create_time() - start_time) > 3:
    logger.info('Process %s is not postmaster, too much difference between PID file start time %s and process start time %s', self.pid, self.create_time(), start_time)

同时,发现了Patroni里的一个BUG:https://github.com/zalando/patroni/issues/811 错误信息里两个时间戳打反了。

经验与教训: NTP 时间同步是非常重要的

PG慢查询诊断方法论

慢查询是在线业务数据库的大敌,本文介绍了使用监控系统定位诊断慢查询的一般方法论。

You can’t optimize what you can’t measure

慢查询是在线业务数据库的大敌,如何诊断定位慢查询是DBA的必修课题。

本文介绍了使用监控系统 —— Pigsty诊断慢查询的一般方法论。


慢查询:危害

对于实际服务于在线业务事务处理的PostgreSQL数据库而言,慢查询的危害包括:

  • 慢查询挤占数据库连接,导致普通查询无连接可用,堆积并导致数据库雪崩。
  • 慢查询长时间锁住了主库已经清理掉的旧版本元组,导致流复制重放进程锁死,导致主从复制延迟。
  • 查询越慢,查询间相互踩踏的几率越高,越容易产生死锁、锁等待,事务冲突等问题。
  • 慢查询浪费系统资源,拉高系统水位。

因此,一个合格的DBA必须知道如何及时定位并处理慢查询。

图:一个慢查询优化前后,系统的整体饱和度从40%降到了4%


慢查询诊断 —— 传统方法

传统上来说,在PostgreSQL有两种方式可以获得慢查询的相关信息,一个是通过官方的扩展插件pg_stat_statements,另一种是慢查询日志。

慢查询日志顾名思义,所有执行时间长于log_min_duration_statement参数的查询都会被记录到PG的日志中,对于定位慢查询,特别是对于分析特例、单次慢查询不可或缺。不过慢查询日志也有自己的局限性。在生产环境中出于性能考虑,通常只会记录时长超出某一阈值的查询,那么许多信息就无法从慢查询日志中获取了。当然值得一提的是,尽管开销很大,但全量查询日志仍然是慢查询分析的终极杀手锏

更常用的慢查询诊断工具可能还是pg_stat_statements。这事是一个非常实用的扩展,它会收集数据库内运行查询的统计信息,在任何场景下都强烈建议启用该扩展

pg_stat_statements 提供的原始指标数据以系统视图表的形式呈现。系统中的每一类查询(即抽取变量后执行计划相同的查询)都分配有一个查询ID,紧接着是调用次数,总耗时,最大、最小、平均单次耗时,响应时间都标准差,每次调用平均返回的行数,用于块IO的时间这些指标类数据。

一种简单的方式当然是观察 mean_time/max_time这类指标,从系统的Catalog中,您的确可以知道某类查询有史以来平均的响应时间。对于定位慢查询来说,也许这样也算得上基本够用了。但是像这样的指标,只是系统在当前时刻的一个静态快照,所以能够回答的问题是有限的。譬如说,您想看一看某个查询在加上新索引之后的性能表现是不是有所改善,用这种方式可能就会非常繁琐。

pg_stat_statements需要在shared_preload_library中指定,并在数据库中通过CREATE EXTENSION pg_stat_statements显式创建。创建扩展后即可通过视图pg_stat_statements访问查询统计信息

慢查询的定义

多慢的查询算慢查询?

应该说这个问题取决于业务、以及实际的查询类型,并没有通用的标准

作为一种经验阈值,频繁的CRUD点查,如果超过1ms,可列为慢查询。

对于偶发的单次特例查询而言,通常超过100ms或1s可以列为慢查询。

慢查询诊断 —— Pigsty

监控系统就可以更全面地回答关于慢查询的问题。监控系统中的数据是由无数历史快照组成的(如5秒一次快照采样)。因此用户可以回溯至任意时间点,考察不同时间段内查询平均响应时间的变化。

上图是Pigsty中 PG Query Detail提供的界面,这里展现出了单个查询的详细信息。

这是一个典型的慢查询,平均响应时间几秒钟。为它添加了一个索引后。从右中Query RT仪表盘的上可以看到,查询的平均响应世界从几秒降到了几毫秒。

用户可以利用监控系统提供的洞察迅速定位数据库中的慢查询,定位问题,提出猜想。更重要的是,用户可以即时地在不同层次审视表与查询的详细指标,应用解决方案并获取实时反馈,这对于紧急故障处理是非常有帮助的。

有时监控系统的用途不仅仅在于提供数据与反馈,它还可以作为一种安抚情绪的良药:设想一个慢查询把生产数据库打雪崩了,如果老板或客户没有一个地方可以透明地知道当前的处理状态,难免会焦急地催问,进一步影响问题解决的速度。监控系统也可以做作为精确管理的依据。您可以有理有据地用监控指标的变化和老板与客户吹牛逼。


一个模拟的慢查询案例

Talk is cheap, show me the code

假设用户已经拥有一个 Pigsty沙箱演示环境,下面将使用Pigsty沙箱,演示模拟的慢查询定位与处理流程。


慢查询:模拟

因为没有实际的业务系统,这里我们以一种简单快捷的方式模拟系统中的慢查询。即pgbench自带的类tpc-b场景。

通过make ri / make ro / make rw,在pg-test集群上初始化 pgbench 用例,并对集群施加读写负载

# 50TPS 写入负载
while true; do pgbench -nv -P1 -c20 --rate=50 -T10 postgres://test:test@pg-test:5433/test; done

# 1000TPS 只读负载
while true; do pgbench -nv -P1 -c40 --select-only --rate=1000 -T10 postgres://test:test@pg-test:5434/test; done

现在我们已经有了一个模拟运行中的业务系统,让我们通过简单粗暴的方式来模拟一个慢查询场景。在pg-test集群的主库上执行以下命令,删除表pgbench_accounts的主键:

ALTER TABLE pgbench_accounts DROP CONSTRAINT pgbench_accounts_pkey ;

该命令会移除 pgbench_accounts 表上的主键,导致相关查询从索引扫描变为顺序全表扫描,全部变为慢查询,访问PG Instance ➡️ Query ➡️ QPS,结果如下图所示:

图1:平均查询响应时间从1ms飙升为300ms,单个从库实例的QPS从500下降至7。

与此同时,实例因为慢查询堆积,系统会在瞬间雪崩过载,访问PG Cluster首页,可以看到集群负载出现飙升。

图2:系统负载达到200%,触发机器负载过大,与查询响应时间过长的报警规则。


慢查询:定位

首先,使用PG Cluster面板定位慢查询所在的具体实例,这里以 pg-test-2 为例。

然后,使用PG Query面板定位具体的慢查询:编号为 -6041100154778468427

图3:从查询总览中发现异常慢查询

该查询表现出:

  • 响应时间显著上升: 17us 升至 280ms
  • QPS 显著下降: 从500下降到 7
  • 花费在该查询上的时间占比显著增加

可以确定,就是这个查询变慢了!

接下来,利用PG Stat Statements面板或PG Query Detail,根据查询ID定位慢查询的具体语句

图4:定位查询语句为SELECT abalance FROM pgbench_accounts WHERE aid = $1


慢查询:猜想

获知慢查询语句后,接下来需要推断慢查询产生的原因

SELECT abalance FROM pgbench_accounts WHERE aid = $1

该查询以 aid 作为过滤条件查询 pgbench_accounts 表,如此简单的查询变慢,大概率是这张表上的索引出了问题。 用屁股想都知道是索引少了,因为就是我们自己删掉的嘛!

分析查询后, 可以提出猜想: 该查询变慢是pgbench_accounts表上aid列缺少索引。

下一步,我们就要验证猜想

第一步,使用PG Table Catalog,我们可以检视表的详情,例如表上建立的索引。

第二步,查阅 PG Table Detail 面板,检查 pgbench_accounts 表上的访问,来验证我们的猜想

图5: pgbench_accounts 表上的访问情况

通过观察,我们发现表上的索引扫描归零,与此同时顺序扫描却有相应增长。这印证了我们的猜想!


慢查询:方案

假设一旦成立,就可以着手提出方案,解决问题了。

解决慢查询通常有三种方式:修改表结构修改查询修改索引

修改表结构与查询通常涉及到具体的业务知识和领域知识,需要具体问题具体分析。但修改索引通常来说不需要太多的具体业务知识。

这里的问题可以通过添加索引解决,pgbench_accounts 表上 aid 列缺少索引,那么我们尝试在 pgbench_accounts 表上为 aid 列添加索引,看看能否解决这个问题。

CREATE UNIQUE INDEX ON pgbench_accounts (aid);

加上索引后,神奇的事情发生了。

图6:可以看到,查询的响应时间与QPS已经恢复正常。

图7:系统的负载也恢复正常


慢查询:评估

作为慢查询处理的最后一步,我们通常需要对操作的过程进行记录,对效果进行评估。

有时候一个简单的优化可以产生戏剧性的效果。也许本来需要砸几十万加机器的问题,创建一个索引就解决了。

这种故事,就可以通过监控系统,用很生动直观的形式表达出来,赚取KPI与Credit。

图:一个慢查询优化前后,系统的整体饱和度从40%降到了4%

(相当于节省了X台机器,XX万元,老板看了心花怒放,下一任CTO就是你了!)


慢查询:小结

通过这篇教程,您已经掌握了慢查询优化的一般方法论。即:

  • 定位问题

  • 提出猜想

  • 验证假设

  • 制定方案

  • 评估效果

监控系统在慢查询处理的整个生命周期中都能起到重要的效果。更能将运维与DBA的“经验”与“成果”,以可视化,可量化,可复制的方式表达出来。

在线修改主键列类型

如何在线修改表中列的类型,例如从INT升级为BIGINT?

如何在线修改主键列类型,比如将 INTBIGINT,同时又不影响业务?

假设在PG中有一个表,在设计的时候拍脑袋使用了 INT 整型主键,现在业务蓬勃发展发现序列号不够用了,想升级到BIGINT类型。这时候该怎么做呢?

拍脑袋的方法当然是直接使用DDL修改类型:

ALTER TABLE pgbench_accounts ALTER COLUMN aid SET DATA TYPE BIGINT;

但这种方式对于访问频繁的生产大表是不可行的


太长;不看

让我们以 pgbench 自带的场景为例

-- 操作目标:升级 pgbench_accounts 表普通列 abalance 类型:INT -> BIGINT

-- 添加新列:abalance_tmp BIGINT
ALTER TABLE pgbench_accounts ADD COLUMN abalance_tmp BIGINT;

-- 创建触发器函数:保持新列数据与旧列同步
CREATE OR REPLACE FUNCTION public.sync_pgbench_accounts_abalance() RETURNS TRIGGER AS $$
BEGIN NEW.abalance_tmp = NEW.abalance; RETURN NEW;END;
$$ LANGUAGE 'plpgsql';

-- 完成整表更新,分批更新的方式见下
UPDATE pgbench_accounts SET abalance_tmp = abalance; -- 不要在大表上运行这个

-- 创建触发器
CREATE TRIGGER tg_sync_pgbench_accounts_abalance BEFORE INSERT OR UPDATE ON pgbench_accounts
    FOR EACH ROW EXECUTE FUNCTION sync_pgbench_accounts_abalance();

-- 完成列的新旧切换,这时候数据同步方向变化 旧列数据与新列保持同步
BEGIN;
LOCK TABLE pgbench_accounts IN EXCLUSIVE MODE;
ALTER TABLE pgbench_accounts DISABLE TRIGGER tg_sync_pgbench_accounts_abalance;
ALTER TABLE pgbench_accounts RENAME COLUMN abalance TO abalance_old;
ALTER TABLE pgbench_accounts RENAME COLUMN abalance_tmp TO abalance;
ALTER TABLE pgbench_accounts RENAME COLUMN abalance_old TO abalance_tmp;
ALTER TABLE pgbench_accounts ENABLE TRIGGER tg_sync_pgbench_accounts_abalance;
COMMIT;

-- 确认数据完整性
SELECT count(*) FROM pgbench_accounts WHERE abalance_new != abalance;

-- 清理触发器与函数
DROP FUNCTION IF EXISTS sync_pgbench_accounts_abalance();
DROP TRIGGER tg_sync_pgbench_accounts_abalance ON pgbench_accounts;

外键

alter table my_table add column new_id bigint;

begin; update my_table set new_id = id where id between 0 and 100000; commit;
begin; update my_table set new_id = id where id between 100001 and 200000; commit;
begin; update my_table set new_id = id where id between 200001 and 300000; commit;
begin; update my_table set new_id = id where id between 300001 and 400000; commit;
...

create unique index my_table_pk_idx on my_table(new_id);

begin;
alter table my_table drop constraint my_table_pk;
alter table my_table alter column new_id set default nextval('my_table_id_seq'::regclass);
update my_table set new_id = id where new_id is null;
alter table my_table add constraint my_table_pk primary key using index my_table_pk_idx;
alter table my_table drop column id;
alter table my_table rename column new_id to id;
commit;

以pgbench为例

vonng=# \d pgbench_accounts
              Table "public.pgbench_accounts"
  Column  |     Type      | Collation | Nullable | Default
----------+---------------+-----------+----------+---------
 aid      | integer       |           | not null |
 bid      | integer       |           |          |
 abalance | integer       |           |          |
 filler   | character(84) |           |          |
Indexes:
    "pgbench_accounts_pkey" PRIMARY KEY, btree (aid)

升级abalance列为BIGINT

会锁表,在表大小非常小,访问量非常小的的情况下可用。

ALTER TABLE pgbench_accounts ALTER COLUMN abalance SET DATA TYPE bigint;

在线升级流程

  1. 添加新列
  2. 更新数据
  3. 在新列上创建相关索引(如果没有也可以单列创建,加快第四步的速度)
  4. 执行切换事务
    1. 排他锁表
    2. UPDATE更新空列(也可以使用触发器)
    3. 删旧列
    4. 重命名新列
-- Step 1 : 创建新列
ALTER TABLE pgbench_accounts ADD COLUMN abalance_new BIGINT;

-- Step 2 : 更新数据,可以分批更新,分批更新方法详见下面
UPDATE pgbench_accounts SET abalance_new = abalance;

-- Step 3 : 可选(在新列上创建索引)
CREATE INDEX CONCURRENTLY ON public.pgbench_accounts (abalance_new);
UPDATE pgbench_accounts SET abalance_new = abalance WHERE ;

-- Step 3 :

-- Step 4 :
-- 同步更新对应列
CREATE OR REPLACE FUNCTION public.sync_abalance() RETURNS TRIGGER AS $$
BEGIN NEW.abalance_new = OLD.abalance; RETURN NEW;END;
$$ LANGUAGE 'plpgsql';

CREATE TRIGGER pgbench_accounts_sync_abalance BEFORE INSERT OR UPDATE ON pgbench_accounts EXECUTE FUNCTION sync_abalance();
alter table my_table add column new_id bigint;

begin; update my_table set new_id = id where id between 0 and 100000; commit;
begin; update my_table set new_id = id where id between 100001 and 200000; commit;
begin; update my_table set new_id = id where id between 200001 and 300000; commit;
begin; update my_table set new_id = id where id between 300001 and 400000; commit;
...

create unique index my_table_pk_idx on my_table(new_id);

begin;
alter table my_table drop constraint my_table_pk;
alter table my_table alter column new_id set default nextval('my_table_id_seq'::regclass);
update my_table set new_id = id where new_id is null;
alter table my_table add constraint my_table_pk primary key using index my_table_pk_idx;
alter table my_table drop column id;
alter table my_table rename column new_id to id;
commit;

批量更新逻辑

有时候需要为大表添加一个非空的,带有默认值的列。因此需要对整表进行一次更新,可以使用下面的办法,将一次巨大的更新拆分为100次或者更多的小更新。

从统计信息中获取主键的分桶信息:

SELECT unnest(histogram_bounds::TEXT::BIGINT[]) FROM pg_stats WHERE tablename = 'signup_users' and attname = 'id';

直接从统计分桶信息中生成需要执行的SQL,在这里把SQL改成需要更新的语

SELECT 'UPDATE signup_users SET app_type = '''' WHERE id BETWEEN ' || lo::TEXT || ' AND ' || hi::TEXT || ';'
FROM (
         SELECT lo, lead(lo) OVER (ORDER BY lo) as hi
         FROM (
                  SELECT unnest(histogram_bounds::TEXT::BIGINT[]) lo
                  FROM pg_stats
                  WHERE tablename = 'signup_users'
                    and attname = 'id'
                  ORDER BY 1
              ) t1
     ) t2;

直接使用SHELL脚本打印出更新语句

DATNAME=""
RELNAME="pgbench_accounts"
IDENTITY="aid"
UPDATE_CLAUSE="abalance_new = abalance"

SQL=$(cat <<-EOF
SELECT 'UPDATE ${RELNAME} SET ${UPDATE_CLAUSE} WHERE ${IDENTITY} BETWEEN ' || lo::TEXT || ' AND ' || hi::TEXT || ';'
FROM (
		SELECT lo, lead(lo) OVER (ORDER BY lo) as hi
		FROM (
				SELECT unnest(histogram_bounds::TEXT::BIGINT[]) lo
				FROM pg_stats
				WHERE tablename = '${RELNAME}'
					and attname = '${IDENTITY}'
				ORDER BY 1
			) t1
	) t2;
EOF
)

# echo $SQL

psql ${DATNAME} -qAXwtc "ANALYZE ${RELNAME};"
psql ${DATNAME} -qAXwtc "${SQL}"

处理边界情况。

 UPDATE signup_users SET app_type = '' WHERE app_type != '';

优化与改进

也可以加工一下,添加事务语句和休眠间隔

DATNAME="test"
RELNAME="pgbench_accounts"
COLNAME="aid"
UPDATE_CLAUSE="abalance_tmp = abalance"
SLEEP_INTERVAL=0.1

SQL=$(cat <<-EOF
SELECT 'BEGIN;UPDATE ${RELNAME} SET ${UPDATE_CLAUSE} WHERE ${COLNAME} BETWEEN ' || lo::TEXT || ' AND ' || hi::TEXT || ';COMMIT;SELECT pg_sleep(${SLEEP_INTERVAL});VACUUM ${RELNAME};'
FROM (
		SELECT lo, lead(lo) OVER (ORDER BY lo) as hi
		FROM (
				SELECT unnest(histogram_bounds::TEXT::BIGINT[]) lo
				FROM pg_stats
				WHERE tablename = '${RELNAME}'
					and attname = '${COLNAME}'
				ORDER BY 1
			) t1
	) t2;
EOF
)
# echo $SQL
psql ${DATNAME} -qAXwtc "ANALYZE ${RELNAME};"
psql ${DATNAME} -qAXwtc "${SQL}"
BEGIN;UPDATE pgbench_accounts SET abalance_new = abalance WHERE aid BETWEEN 397 AND 103196;COMMIT;SELECT pg_sleep(0.5);VACUUM pgbench_accounts;
BEGIN;UPDATE pgbench_accounts SET abalance_new = abalance WHERE aid BETWEEN 103196 AND 213490;COMMIT;SELECT pg_sleep(0.5);VACUUM pgbench_accounts;
BEGIN;UPDATE pgbench_accounts SET abalance_new = abalance WHERE aid BETWEEN 213490 AND 301811;COMMIT;SELECT pg_sleep(0.5);VACUUM pgbench_accounts;
BEGIN;UPDATE pgbench_accounts SET abalance_new = abalance WHERE aid BETWEEN 301811 AND 400003;COMMIT;SELECT pg_sleep(0.5);VACUUM pgbench_accounts;
BEGIN;UPDATE pgbench_accounts SET abalance_new = abalance WHERE aid BETWEEN 400003 AND 511931;COMMIT;SELECT pg_sleep(0.5);VACUUM pgbench_accounts;
BEGIN;UPDATE pgbench_accounts SET abalance_new = abalance WHERE aid BETWEEN 511931 AND 613890;COMMIT;SELECT pg_sleep(0.5);VACUUM pgbench_accounts;

黄金监控指标:错误延迟吞吐饱和

了解PostgreSQL中的黄金监控指标

前言

玩数据库和玩车有一个共通之处,就是都需要经常看仪表盘。

盯着仪表盘干什么,看指标。为什么看指标,掌握当前运行状态才能有效施加控制。

车有很多指标:车速,胎压,扭矩,刹车片磨损,各种温度,等等等等,各式各样。

但人的注意力空间有限,仪表盘也就那么大,

所以,指标可以分两类:

  • 你会去看的黄金指标 / 关键指标 / 核心指标
  • 你不会看的:黑匣子指标 / 冷指标。

黄金指标就是那几个关键性的核心数据,需要时刻保持关注(或者让自动驾驶系统/报警系统替你时刻保持关注),而冷指标通常只有故障排查时才会去看,故障排查与验尸要求尽可能还原现场,黑匣子指标多多益善。需要时没有就很让人抓狂

今天我们来说说PostgreSQL的核心指标,数据库的核心指标是什么?


数据库的指标

在讲数据库的核心指标之前,我们先来瞄一眼有哪些指标。

avg(count by (ins) ({__name__=~"pg.*"}))
avg(count by (ins) ({__name__=~"node.*"}))

1000多个pg的指标,2000多个机器的指标。

这些指标都是数据宝藏,挖掘与可视化可以提取出其中的价值。

但对于日常管理,只需要少数几个核心指标就可以了。

可用指标千千万,哪些才是核心指标?


核心指标

根据经验和使用频度,不断地做减法,可以筛选出一些核心指标:

指标 缩写 层次 来源 种类
错误日志条数 Error Count SYS/DB/APP 日志系统 错误
连接池排队数 Queue Clients DB 连接池 错误
数据库负载 PG Load DB 连接池 饱和度
数据库饱和度 PG Saturation DB 连接池&节点 饱和度
主从复制延迟 Repl Lag DB 数据库 延迟
平均查询响应时间 Query RT DB 连接池 延迟
活跃后端进程数 Backends DB 数据库 饱和度
数据库年龄 Age DB 数据库 饱和度
每秒查询数 QPS APP 连接池 流量
CPU使用率 CPU Usage SYS 机器节点 饱和度

紧急情况下:错误是始终是第一优先级的黄金指标。

常规情况下:应用视角的黄金指标:QPS与RT

常规情况下:DBA视角的黄金指标:DB饱和度(水位)


为什么是它们?

错误指标

第一优先级的指标永远是错误,错误往往是直接面向终端用户的。

如果只能选一个指标进行监控,那么选错误指标,比如应用,系统,DB层的每秒错误日志条数可能最合适。

一辆车,只能选一个仪表盘上的功能,你会选什么?

错误指标,小车不停只管推。

错误类指标非常重要,直接反映出系统的异常,譬如连接池排队。但错误类指标最大的问题就是,它只在告警时有意义,难以用于日常的水位评估与性能分析,此外,错误类指标也往往难以精确量化,往往只能给出定性的结果:有问题 vs 没问题。

此外,错误类指标难以精确量化。我们只能说:当连接池出现排队时,数据库负载比较大;队列越长,负载越大;没有排队时,数据库负载不怎么大,仅此而已。对于日常使用管理来说,这样的能力肯定也是不够的。

定指标,做监控报警系统的一个重要原因就是用于预防系统过载,如果系统已经过载大量报错,那么使用错误现象反过来定义饱和度是没有意义的

指标的目的,是为了衡量系统的运行状态。,我们还会关注系统其他方面的能力:吞吐量/流量,响应时间/延迟,饱和度/利用率/水位线。这三者分别代表系统的能力,服务质量,负载水平。

关注点不同,后端(数据库用户)关注系统能力与服务质量,DBA(数据库管理者)更关注系统的负载水平。

流量指标

流量类的指标很有潜力,特别是QPS,TPS这样的指标相当具有代表性。

流量指标可以直接衡量系统的能力,譬如每秒处理多少笔订单,每秒处理的多少个请求。

与车速计有异曲同工之妙,高速限速,城市限速。环境,负载。

但像TPS QPS这样流量也存在问题。一个数据库实例上的查询往往是五花八门各式各样的,一个耗时10微秒的查询和一个10秒的查询在统计时都被算为一个Q,类似于QPS这样的指标无法进行横向比较,只有比较粗略的参考意义,甚至当查询类型发生变化时,都无法和自己的历史数据进行纵向比较。此外也很难针对QPS、TPS这样的指标设置利用率目标,同一个数据库执行SELECT 1可以打到几十万的QPS,但执行复杂SQL时可能就只能打到几千的QPS。不同负载类型和机器硬件会对数据库的QPS上限产生显著影响,只有当一个数据库上的查询都是高度单一同质且没有复杂变化的条件下,QPS才有参考意义,在这种苛刻条件下倒是可以通过压力测试设定一个QPS的水位目标。

延迟指标

与档位类似,查询慢,档位低,车速慢。查询档次低,TPS水位低。查询档次高,TPS水位高

延迟适合衡量系统的服务质量。

比起QPS/TPS,RT(响应时间 Response Time)这样的指标反而更具有参考价值。因为响应时间增加往往是系统饱和的前兆。根据经验法则,数据库的负载越大,查询与事务的平均响应时间也会越高。RT相比QPS的一个优势是**,RT是可以设置一个利用率目标的**,比如可以为RT设定一个绝对阈值:不允许生产OLTP库上出现RT超过1ms的慢查询。但QPS这样的指标就很难画出红线来。不过,RT也有自己的问题。第一个问题是它依然是定性而非定量的,延迟增加只是系统饱和的预警,但没法用来精确衡量系统的饱和度。第二个问题通常能从数据库与中间件获取到的RT统计指标都是平均值,但真正起到预警效果的有可能是诸如P99,P999这样的统计量。

饱和度指标

饱和度指标类似汽车的发动机转速表,油量表,水温表。

饱和度指标适合衡量系统的负载

即用户期待的负载指标是一个饱和度(Saturation)指标,所谓饱和度,即服务容量有多”满“,通常是系统中目前最为受限的某种资源的某个具体指标的度量。通常来说,0%的饱和度意味着系统完全空闲,100%的饱和度意味着满载,系统在达到100%利用率前就会出现性能的严重下降,因此设定指标时还需要包括一个利用率目标,或者说水位红线、黄线,当系统瞬时负载超过红线时应当触发告警,长期负载超过黄线时应当进行扩容。

其他可选指标
每秒事务数 TPS APP 连接池 流量
磁盘IO使用率 Disk Usage SYS 机器节点 饱和度
内存使用率 Mem Usage SYS 机器节点 饱和度
网卡带宽使用率 Net Usage SYS 机器节点 饱和度
TCP错误:溢出重传等 TCP ERROR SYS 机器节点 错误

数据库集群管理概念与实体命名规范

概念及其命名是非常重要的东西,命名风格体现了工程师对系统架构的认知。定义不清的概念将导致沟通困惑,随意设定的名称将产生意想不到的额外负担。因此需要审慎地设计。

名之则可言也,言之则可行也。

概念及其命名是非常重要的东西,命名风格体现了工程师对系统架构的认知。定义不清的概念将导致沟通困惑,随意设定的名称将产生意想不到的额外负担。因此需要审慎地设计。

TL;DR

entity-naming.png

  • **集群(Cluster)**是基本自治单元,由用户指定唯一标识,表达业务含义,作为顶层命名空间。
  • 集群在硬件层面上包含一系列的节点(Node),即物理机,虚机(或Pod),可以通过IP唯一标识。
  • 集群在软件层面上包含一系列的实例(Instance),即软件服务器,可以通过IP:Port唯一标识。
  • 集群在服务层面上包含一系列的服务(Service),即可访问的域名与端点,可以通过域名唯一标识。
  • Cluster命名可以使用任意满足DNS域名规范的名称,但不能带点([a-zA-Z0-9-]+)。
  • Node/Pod命名采用Cluster名称前缀,后接-连接一个从0开始分配的序号,(与k8s保持一致)
  • 实例命名通常与Node保持一致,即${cluster}-${seq}的方式,这种方式隐含着节点与实例1:1部署的假设。如果这个假设不成立,则可以采用独立于节点的序号,但保持同样的命名规则。
  • Service命名采用Cluster名称前缀,后接-连接服务具体内容,如primary, standby

以上图为例,用于测试的数据库集群名为“pg-test”,该集群由一主两从三个数据库服务器实例组成,部署在集群所属的三个节点上。pg-test集群集群对外提供两种服务,读写服务pg-test-primary与只读副本服务pg-test-standby

基本概念

在Postgres集群管理中,有如下概念:

集群(Cluster)

集群是基本的自治业务单元,这意味着集群能够作为一个整体组织对外提供服务。类似于k8s中Deployment的概念。注意这里的集群是软件层面的概念,不要与PG Cluster(数据库集簇,即包含多个PG Database实例的单个PG Server Instance)或Node Cluster(机器集群)混淆。

集群是管理的基本单位之一,是用于统合各类资源的组织单位。例如一个PG集群可能包括:

  • 三个物理机器节点
  • 一个主库实例,对外提供数据库读写服务。
  • 两个从库实例,对外提供数据库只读副本服务。
  • 两个对外暴露的服务:读写服务,只读副本服务。

每个集群都有用户根据业务需求定义的唯一标识符,本例中定义了一个名为pg-test的数据库集群。

节点(Node)

节点是对硬件资源的一种抽象,通常指代一台工作机器,无论是物理机(bare metal)还是虚拟机(vm),或者是k8s中的Pod。这里注意k8s中Node是硬件资源的抽象,但在实际管理使用上,是k8s中的Pod而不是Node更类似于这里Node概念。总之,节点的关键要素是:

  • 节点是硬件资源的抽象,可以运行一系列的软件服务
  • 节点可以使用IP地址作为唯一标识符

尽管可以使用lan_ip地址作为节点唯一标识符,但为了便于管理,节点应当拥有一个人类可读的充满意义的名称作为节点的Hostname,作为另一个常用的节点唯一标识。

服务(Service)

服务是对软件服务(例如Postgres,Redis)的一种命名抽象(named abastraction)。服务可以有各种各样的实现,但其的关键要素在于:

  • 可以寻址访问的服务名称,用于对外提供接入,例如:
    • 一个DNS域名(pg-test-primary
    • 一个Nginx/Haproxy Endpoint
  • 服务流量路由解析与负载均衡机制,用于决定哪个实例负责处理请求,例如:
    • DNS L7:DNS解析记录
    • HTTP Proxy:Nginx/Ingress L7:Nginx Upstream配置
    • TCP Proxy:Haproxy L4:Haproxy Backend配置
    • Kubernetes:Ingress:Pod Selector 选择器

同一个数据集簇中通常包括主库与从库,两者分别提供读写服务(primary)和只读副本服务(standby)。

实例(Instance)

实例指带一个具体的数据库服务器,它可以是单个进程,也可能是共享命运的一组进程,也可以是一个Pod中几个紧密关联的容器。实例的关键要素在于:

  • 可以通过IP:Port唯一标识
  • 具有处理请求的能力

例如,我们可以把一个Postgres进程,为之服务的独占Pgbouncer连接池,PgExporter监控组件,高可用组件,管理Agent看作一个提供服务的整体,视为一个数据库实例。

实例隶属于集群,每个实例在集群范围内都有着自己的唯一标识用于区分。

实例由服务负责解析,实例提供被寻址的能力,而Service将请求流量解析到具体的实例组上。

命名规则

entity-naming.png

一个对象可以有很多组 标签(Tag)元数据(Metadata/Annotation) ,但通常只能有一个名字。

管理数据库和软件,其实与管理子女或者宠物类似,都是需要花心思去照顾的。而起名字就是其中非常重要的一项工作。肆意的名字(例如 XÆA-12,NULL,史珍香)很可能会引入不必要的麻烦(额外复杂度),而设计得当的名字则可能会有意想不到的效果。

总的来说,对象起名应当遵循一些原则:

  • 简洁直白,人类可读:名字是给人看的,因此要好记,便于使用。

  • 体现功能,反映特征:名字需要反映对象的关键特征

  • 独一无二,唯一标识:名字在命名空间内,自己的类目下应当是独一无二,可以惟一标识寻址的。

  • 不要把太多无关的东西塞到名字里去:在名字中嵌入很多重要元数据是一个很有吸引力的想法,但维护起来会非常痛苦,例如反例:pg:user:profile:10.11.12.13:5432:replica:13

集群命名

集群名称,其实类似于命名空间的作用。所有隶属本集群的资源,都会使用该命名空间。

集群命名的形式,建议采用符合DNS标准 RFC1034 的命名规则,以免给后续改造埋坑。例如哪一天想要搬到云上去,发现以前用的名字不支持,那就要再改一遍名,成本巨大。

我认为更好的方式是采用更为严格的限制:集群的名称不应该包括点(dot)。应当仅使用小写字母,数字,以及减号连字符(hyphen)-。这样,集群中的所有对象都可以使用这个名称作为前缀,用于各种各样的地方,而不用担心打破某些约束。即集群命名规则为:

cluster_name := [a-z][a-z0-9-]*

之所以强调不要在集群名称中用,是因为以前很流行一种命名方式,例如com.foo.bar。即由点分割的层次结构命名法。这种命名方式虽然简洁名快,但有一个问题,就是用户给出的名字里可能有任意多的层次,数量不可控。如果集群需要与外部系统交互,而外部系统对于命名有一些约束,那么这样的名字就会带来麻烦。一个最直观的例子是K8s中的Pod,Pod的命名规则中不允许出现.

集群命名的内涵,建议采用-分隔的两段式,三段式名称,例如:

<集群类型>-<业务>-<业务线>

比如:pg-test-tt就表示tt 业务线下的test集群,类型为pgpg-user-fin表示fin业务线下的user服务。当然,采集多段命名最好还是保持段数固定。

节点命名

节点命名建议采用与k8s Pod一致的命名规则,即

<cluster_name>-<seq>

Node的名称会在集群资源分配阶段确定下来,每个节点都会分配到一个序号${seq},从0开始的自增整型。这个与k8s中StatefulSet的命名规则保持一致,因此能够做到云上云下一致管理。

例如,集群pg-test有三个节点,那么这三个节点就可以命名为:

pg-test-0, pg-test-1pg-test2

节点的命名,在整个集群的生命周期中保持不变,便于监控与管理。

实例命名

对于数据库来说,通常都会采用独占式部署方式,一个实例占用整个机器节点。PG实例与Node是一一对应的关系,因此可以简单地采用Node的标识符作为Instance的标识符。例如,节点pg-test-1上的PG实例名即为:pg-test-1,以此类推。

采用独占部署的方式有很大优势,一个节点即一个实例,这样能最小化管理复杂度。混部的需求通常来自资源利用率的压力,但虚拟机或者云平台可以有效解决这种问题。通过vm或pod的抽象,即使是每个redis(1核1G)实例也可以有一个独占的节点环境。

作为一种约定,每个集群中的0号节点(Pod),会作为默认主库。因为它是初始化时第一个分配的节点。

服务命名

通常来说,数据库对外提供两种基础服务:primary 读写服务,与standby只读副本服务。

那么服务就可以采用一种简单的命名规则:

<cluster_name>-<service_name>

例如这里pg-test集群就包含两个服务:读写服务pg-test-primary与只读副本服务pg-test-standby

还有一种流行的实例/节点命名规则:<cluster_name>-<service_role>-<sequence>,即把数据库的主从身份嵌入到实例名称中。这种命名方式有好处也有坏处。好处是管理的时候一眼就能看出来哪一个实例/节点是主库,哪些是从库。缺点是一但发生Failover,实例与节点的名称必须进行调整才能维持一执性,这就带来的额外的维护工作。此外,服务与节点实例是相对独立的概念,这种Embedding命名方式扭曲了这一关系,将实例唯一隶属至服务。但复杂的场景下这一假设可能并不满足。例如,集群可能有几种不同的服务划分方式,而不同的划分方式之间很可能会出现重叠。

  • 可读从库(解析至包含主库在内的所有实例)
  • 同步从库(解析至采用同步提交的备库)
  • 延迟从库,备份实例(解析至特定具体实例)

因此,不要把服务角色嵌入实例名称,而是在服务中维护目标实例列表。

小结

命名属于相当经验性的知识,很少有地方会专门会讲这件事。这种“细节”其实往往能体现出命名者的一些经验水平来。

标识对象不仅仅可以通过ID和名称,还可以通过标签(Label)和选择器(Selector)。实际上这一种做法会更具有通用性和灵活性,本系列下一篇文章(也许)将会介绍数据库对象的标签设计与管理。

WeChat Column

PostgreSQL的KPI

管数据库和管人差不多,都需要定KPI(关键性能指标)。那么数据库的KPI是什么?本文介绍了一种衡量PostgreSQL负载的方式:使用一种单一横向可比,与负载类型和机器类型基本无关的指标,名曰PG Load(PG负载)

管数据库和管人差不多,都需要定KPI(关键性能指标)。那么数据库的KPI是什么?本文介绍了一种衡量PostgreSQL负载的方式:使用一种单一横向可比,与负载类型和机器类型基本无关的指标,名曰PG Load(PG负载)

0x01 Introduction

在现实生产中,经常会有衡量数据库性能与负载,评估数据库水位的需求。一种最朴素的形式就是,能不能有一个类似于KPI的单一指标,能直接了当地告诉用户他心爱的数据库负载有没有超过警戒线?工作量到底饱和不饱和?

当然这里其实隐含着一个重要信息,即用户期待的负载指标是一个饱和度(Saturation)指标,所谓饱和度,即服务容量有多”满“,通常是系统中目前最为受限的某种资源的某个具体指标的度量。通常来说,0%的饱和度意味着系统完全空闲,100%的饱和度意味着满载,系统在达到100%利用率前就会出现性能的严重下降,因此设定指标时还需要包括一个利用率目标,或者说水位红线、黄线,当系统瞬时负载超过红线时应当触发告警,长期负载超过黄线时应当进行扩容。

不幸的是,定义系统有多”饱和“并不是一件容易的事情,往往需要借助某些间接指标。评估一个数据库的负载程度,传统上通常会基于这样几类指标进行综合评估:

  • 流量:每秒查询数量QPS,或每秒事务数量TPS。

  • 延迟:查询平均响应时间 Query RT,或事务平均响应时间Xact RT

  • 饱和度:机器负载(Load),CPU使用率,磁盘读写带宽饱和度,网卡IO带宽饱和度

  • 错误:数据库客户端连接排队

这些指标对于数据库性能评估都很有参考意义,但它们也都存在各式各样的问题。

0x02 常用评估指标的问题

让我们来看一看,这些现有的常用指标都有哪些问题。

第一个Pass的当然是错误类指标,譬如连接池排队。错误类指标最大的问题就是,当错误出现时,饱和度可能已经没有意义了。评估饱和度的一个重要原因就是用于预防系统过载,如果系统已经过载大量报错,那么使用错误现象反过来定义饱和度是没有意义的。此外,错误类指标难以精确量化。我们只能说:当连接池出现排队时,数据库负载比较大;队列越长,负载越大;没有排队时,数据库负载不怎么大,仅此而已。这样的定义当然也无法让人满意。

第二个Pass的则是系统层(机器级别)指标,数据库运行在机器上,CPU使用率,IO使用率这样的指标与数据库负载程度密切相关,如果CPU和IO是瓶颈,理论上当然是可以直接使用瓶颈资源的饱和度指标作为数据库的饱和指标,但这一点并非总是成立的,有可能系统瓶颈在于数据库本身。而且严格来说它们是机器的KPI而不是DB的KPI,评估数据库负载时当然可以参照系统层的指标,但DB层也应该有本层的评估指标。要先有数据库本身的饱和度指标,才可以去比较底层资源和数据库本身到底谁先饱和谁是瓶颈。这条原则同样适用于应用层观察到的指标。

流量类的指标很有潜力,特别是QPS,TPS这样的指标相当具有代表性。但这些指标也存在问题。一个数据库实例上的查询往往是五花八门各式各样的,一个耗时10微秒的查询和一个10秒的查询在统计时都被算为一个Q,类似于QPS这样的指标无法进行横向比较,只有比较粗略的参考意义,甚至当查询类型发生变化时,都无法和自己的历史数据进行纵向比较。此外也很难针对QPS、TPS这样的指标设置利用率目标,同一个数据库执行SELECT 1可以打到几十万的QPS,但执行复杂SQL时可能就只能打到几千的QPS。不同负载类型和机器硬件会对数据库的QPS上限产生显著影响,只有当一个数据库上的查询都是高度单一同质且没有复杂变化的条件下,QPS才有参考意义,在这种苛刻条件下倒是可以通过压力测试设定一个QPS的水位目标。

比起QPS/TPS,RT(响应时间 Response Time)这样的指标反而更具有参考价值。因为响应时间增加往往是系统饱和的前兆。根据经验法则,数据库的负载越大,查询与事务的平均响应时间也会越高。RT相比QPS的一个优势是**,RT是可以设置一个利用率目标的**,比如可以为RT设定一个绝对阈值:不允许生产OLTP库上出现RT超过1ms的慢查询。但QPS这样的指标就很难画出红线来。不过,RT也有自己的问题。第一个问题是它依然是定性而非定量的,延迟增加只是系统饱和的预警,但没法用来精确衡量系统的饱和度。第二个问题通常能从数据库与中间件获取到的RT统计指标都是平均值,但真正起到预警效果的有可能是诸如P99,P999这样的统计量。

这里把常用指标都批判了一番,到底什么样的指标适合作为数据库本身的饱和度呢?

0x03 衡量PG的负载

我们不妨参考一下**机器负载(Node Load)CPU利用率(CPU Utilization)**的评估指标是如何设计的。

机器负载(Node Load)

想要看到机器的负载水平,可以在Linux系统中使用top命令。top命令的第一行输出就醒目地打印出当前机器1分钟,5分钟,15分钟的平均负载水平

$ top -b1
top - 19:27:38 up 18:49,  1 user,  load average: 1.15, 0.72, 0.71

这里load average后面的三个数字分别表示最近1分钟,5分钟,15分钟系统的平均负载水平。

那么这个数字到底是什么意思呢?简单的解释是,这个数字越大机器越忙。

在单核CPU的场景下,Node Load(以下简称负载)是一个非常标准的饱和度指标。对于单核CPU,负载为0时CPU处于完全空闲的状态,负载为1(100%)时,CPU正好处于满载工作的状态。负载大于100%时,超出100%部分比例的任务正在排队。

Node Load也有自己的利用率目标,通常的经验是在单核情况下:0.7(70%)是黄线,意味着系统有问题,需要尽快检查;1.0(100%)是红线,负载大于1意味着进程开始堆积,需要立即着手处理。5.0(500%)是死线,意味着系统基本上已经堵死了。

对于多核CPU,事情稍微有点不一样。假设有n个核,那么当系统负载为n时,所有CPU都处于满载工作的状态;而当系统负载为n/2时,姑且可以认为一半CPU核正在满载运行。因而48核CPU的机器满载时的负载为48。总的来说,如果我们把机器负载除以机器的CPU核数,得到的指标就与单核场景下保持一致了(0%空载,100%满载)。

CPU利用率(CPU Utilization)

另一个很有借鉴意义的指标是CPU利用率(CPU Utilization)。CPU利用率其实是通过一个简单的公式计算出来的,对于单核CPU:

1 - irate(node_cpu_seconds_total{mode="idle"}[1m]

这里node_cpu_seconds_total{mode="idle"}是一个计数器指标,表示CPU处于空闲状态的总时长。irate函数会用该指标对时间进行求导,得出的结果是,每秒CPU处于空闲状态的时长,换句话说也就是CPU空闲率。用1减去该值就得到了CPU的利用率。

对于多核CPU来说,只需要把每个CPU核的利用率加起来,除以CPU的核数,就可以得到CPU的整体利用率。

那么这两个指标对于PG的负载又有什么借鉴意义呢?

数据库负载(PG Load)

PG的负载是不是也可以采用类似于CPU利用率和机器负载的方式来定义?当然可以,而且这是一个极棒的主意。

让我们先来考虑单进程情况下的PG负载,假设我们需要这样一个指标,当该PG进程完全空闲时负载因子为0,当该进程处于满载状态时负载为1(100%)。类比CPU利用率的定义,我们可以使用“单个PG进程处于活跃状态的时长占比”来表示“单个PG后端进程的利用率”。

如图1所示,在一秒的统计周期内,PG处于活跃(执行查询或者执行事务)状态的时长为0.6秒,那么这一秒内的PG负载就是60%。如果这个唯一的PG进程在整个统计周期中都处于忙碌状态,而且还有0.4秒的任务在排队,如那么就可以认为PG的负载为140%。

对于并行场景,计算方法与多核CPU的利用率类似,首先把所有PG进程在统计周期(1s)内处于活跃状态的时长累加,然后除以“可用的PG进程/连接数”,或者说“可用并行数”,即可得到PG本身的利用率指标,如图3所示。两个PG后端进程分别有200ms+400ms与800ms的活跃时长,那么整体的负载水平为:(0.2s + 0.4s + 0.8s) / 1s / 2 = 70%

总结一下,某一段时间内PG的负载可以定义为:

pg_load = pg_active_seconds / time_peroid / parallel

  • pg_active_seconds是该时间段内所有PG进程处于活跃状态的时长之和。

  • time_peroid是负载计算的统计周期,通常为1分钟,5分钟,15分钟,以及实时(小于10秒)。

  • parallel 是PostgreSQL的可用并行数,后面会详细解释。

因为前两项之商实际上就是一段时间内的每秒活跃时长总数,因此这个公式进一步可以简化为活跃时长对时间的导数除以可用并行数,即:

rate(pg_active_seconds[time_peroid]) / parallel

time_peroid通常是固定的常量(1,5,15分钟),所以问题就是如何获取PG进程活跃总时长pg_active_seconds这个指标,以及如何评估计算数据库可用并行数max_parallel 了。

0x04 计算PG的负载饱和度

事务还是查询?

当我们说数据库进程 活跃/空闲 时,究竟在说什么? PG处于活跃状态,到底是什么意思?如果PG后端进程正在执行查询,那么当然可以认为PG正处于忙碌状态。但如果如上图4所示,PG进程正在执行一个交互式事务,但没有实际执行查询,即所谓的“Idle in Transaction”状态,又应该怎么计算“活跃时长”呢?图4中两个查询中空闲的那200ms时间。那么这段时间应该算作“活跃”,还是算作“空闲”呢?

这里的核心问题是怎么定义活跃状态:数据库进程位于事务中算活跃,还是只有当实际执行查询时才算活跃。对于没有交互式事务的场景,一个查询就是一个事务,用哪种方式都一样,但对于多语句,特别是交互式的多语句事务,这两者就有比较明显的区别了。从资源使用的角度看,没有执行查询也就意味着没有消耗数据库本身的资源。但空闲着的事务本身会占用连接导致连接无法复用,Idle In Transaction本身也应当是一种极力避免的情况。总的来说,这两种定义方式都可以,使用事务的方式会略微高估应用负载,但从负载评估的角度可能会更为合适。

如何获取活跃时长

决定了数据库后端进程的活跃定义后,第二个问题就是,如何获取一段时间的数据库活跃时长?不幸的是在PG中,用户很难通过数据库本身获取这一性能指标。PG提供了一个系统视图:pg_stat_activity,可以看到当前运行着的Postgres进程里列表,但这是一个时间点快照,只能大致告诉在当前时刻,数据库的后端进程中有多少个处于活跃状态,有多少个处于空闲状态。统计一段时间内数据库处于活跃状态的时长,就成了一个难题。一种解决方案是使用类似于Load的计算方式,通过周期性地采样PG中活跃进程的数量,计算出一个负载指标来。不过,这里有更好的办法,但是需要中间件的协助参与。

数据库中间件对于性能监控非常重要,因为很多指标数据库本身并没有提供,只有通过中间件才能暴露出来。以Pgbouncer为例,Pgbouncer在内部维护了一系列统计计数器,使用SHOW STATS可以打印出这些指标,诸如:

  • total_xact_count:总共执行了多少个事务
  • total_query_count:总共执行了多少个查询
  • total_xact_time:总共花费在事务执行的时长
  • total_query_time:总共花费在查询执行上的时长

这里total_xact_time就是我们需要的数据,它记录了Pgbouncer中间件中花费在某个数据库上的事务总耗时。我们只需要用这个指标对时间求导,就可以得到想要的数据:每秒活跃时长占比。

这里使用Prometheus的PromQL表达计算逻辑,首先对事务耗时计数器求导,分别算出其1分钟,5分钟,15分钟,以及实时粒度(最近两次采样点之间)上的每秒活跃时长。再上卷求和,将数据库层次的指标上卷为实例级别的指标。(连接池SHOW STATS这里的统计指标是以数据库为单位的,因此在计算实例级别的总活跃时长时,应当上卷求和,消除数据库维度的标签:sum without(datname)

- record: pg:ins:xact_time_realtime
expr: sum without (datname) (irate(pgbouncer_stat_total_xact_time{}[1m]))
- record: pg:ins:xact_time_rate1m
expr: sum without (datname) (rate(pgbouncer_stat_total_xact_time{}[1m]))
- record: pg:ins:xact_time_rate5m
expr: sum without (datname) (rate(pgbouncer_stat_total_xact_time{}[5m]))
- record: pg:ins:xact_time_rate15m
expr: sum without (datname) (rate(pgbouncer_stat_total_xact_time{}[15m]))

这样计算得到的结果指标已经可以相对本身进行纵向比较,并在同样规格的实例间进行横向比较了。而且无论数据库的负载类型怎样,都可以使用这个指标。

不过不同规格的实例,仍然没法使用这个指标进行对比。比如对于单核单连接PG,满载时每秒活跃时长可能是1秒,也就是100%利用率。而对于64核64连接的PG,满载时每秒活跃时长是64秒,那么就是6400%的利用率。因此,还需要一个归一化的处理,那么问题又来了。

可用并行数如何定义?

不同于CPU利用率,PG的可用并行数并没有一个清晰的定义,而且跟负载类型有一些微妙的关系。但能够确定的是,在一定范围内,最大可用并行与CPU的核数呈粗略的线性关系。当然这个结论的前提是数据库最大连接数显著超过CPU核数,如果在64核的CPU上只允许数据库建立30条连接,那么可以肯定最大可用并行就是30而不是CPU核数64。软件的并行最终还是要由硬件的并行度来支撑,因此我们可以简单的使用实例的CPU核数作为可用并行数。

在64核的CPU上运行64个活跃PG进程,则其负载为(6400% / 64 = 100%)。同理运行128个活跃PG进程,负载就是(12800% / 64 = 200%)。

那么利用上面计算得到的每秒活跃时长指标,就可以计算出实例级别的PG负载指数了。

- record: pg:ins:load0
expr:  pg:ins:xact_time_realtime / on (ip) group_left()  node:ins:cpu_count
- record: pg:ins:load1
expr: pg:ins:xact_time_rate1m  / on (ip) group_left()  node:ins:cpu_count
- record: pg:ins:load5
expr: pg:ins:xact_time_rate5m  / on (ip) group_left()  node:ins:cpu_count
- record: pg:ins:load15
expr: pg:ins:xact_time_rate15m  / on (ip) group_left()  node:ins:cpu_count

PG LOAD的另一种解释

如果我们仔细审视PG Load的定义,其实可以发现每秒活跃时长这个指标,其实可以粗略等价于:TPS x XactRT,或者QPS x Query RT。这个也很好理解,假设我QPS为1000,每个查询RT为1ms,则每秒花费在查询上的时间为 1000 * 1ms = 1s。

因此,PG Load可以视为一个由三个核心指标复合而成的衍生指标:tps * xact_rt / cpu_count

TPS,RT用于负载评估都有各自的问题,但它们通过简单的乘法结合成一个新的复合指标,一下子就显示出了神奇的力量。(尽管实际上是通过其他更准确的方式计算出来的)

0x05 PG Load的实际效果

接下来,我们来看一下PG Load用于实际生产环境的表现。

PG Load最直接的作用有两个,告警以及容量评估。

Case 1: 用于报警:慢查询堆积导致的服务不可用

下图是一次生产事故的现场,由于某业务上线了一个慢查询,瞬间导致连接池被慢查询占据,发生堆积。可以看出PG Load和RT都很及时地反映出了故障的情况,而TPS看上去则是掉了一个坑,并不是特别显眼。

从效果上看,PG Load1与PG Load0(实时负载)是一个相当灵敏的指标,对于大多数与压力负载有关的故障都能及时准确作出反应。所以被我们采纳为核心报警指标。

PG Load的利用率目标有一些经验值:黄线通常为50%,即需要引起关注的阈值;红线通常为70%,即报警线,需要立刻采取行动的阈值;500%或更高通常意味着这个实例已经被打崩了。

Case 2:用于水位评估与容量规划

比起报警,水位评估与容量规划更像是PG Load的核心用途。毕竟报警之类的的需求还是可以通过延迟,排队连接等指标来满足的。

这里,PG集群的15分钟负载是一个很好的参考值。通过这个指标的历史均值,峰值,以及其他一些统计量,我们可以很轻松地看出哪些集群处于高负载状态需要扩容,哪些集群处于低资源利用率状态需要缩容。

CPU利用率是另一个很重要的容量评估指标。我们可以看出,PG Load与CPU Usage有着很密切的关系。不过相比CPU使用率,PG Load更为纯粹地反映了数据库本身的负载水平,滤除了机器上的无关负载,也可以滤除掉数据库维护工作(备份,清理,垃圾回收)产生的杂音,更为丝滑平顺。因此非常适合用于容量评估。

当系统负载长期位于30%~50%时,就应该考虑进行扩容了。

0x06 结论

本文介绍了一种定量衡量PG负载的方式,即PG Load指标

该指标可以简单直观地反映数据库实例的负载水平

该指标非常适合作容量评估之用,也可以作为核心报警指标。

该指标可以基本无视负载类型与机器类型,进行纵向历史比较与横向水位比较。

该指标可以通过简单的方式计算得出,即每秒后端进程活跃总时长除以可用并发数。

该指标所需数据需要从数据库中间件获取

PG Load的0代表空载,100%代表满载。黄线经验值为50%,红线经验值为70%,

PG Load是一个好指标👍

WeChat Column

在线修改PG字段类型

如何在线修改PostgreSQL中的字段类型?一种通用方法

场景

在数据库的生命周期中,有一类需求是很常见的,修改字段类型。例如:

  • 使用INT作为主键,结果发现业务红红火火,INT32的21亿序号不够用了,想要升级为BIGINT
  • 使用BIGINT存身份证号,结果发现里面有个X需要改为TEXT类型。
  • 使用FLOAT存放货币,发现精度丢失,想要修改为Decimal
  • 使用TEXT存储JSON字段,想用到PostgreSQL的JSON特性,修改为JSONB类型。

那么,如何应对这种需求呢?

常规操作

通常来说,ALTER TABLE可以用来修改字段类型。

ALTER TABLE tbl_name ALTER col_name TYPE new_type USING expression;

修改字段类型通常会重写整个表。作为一个特例,如果修改后的类型与之前是二进制兼容的,则可以跳过表重写的过程,但是如果列上有索引,索引还是需要重建的。二进制兼容的转换可以使用以下查询列出。

SELECT t1.typname AS from, t2.typname AS To
FROM pg_cast c
         join pg_type t1 on c.castsource = t1.oid
         join pg_type t2 on c.casttarget = t2.oid
where c.castmethod = 'b';

刨除PostgreSQL内部的类型,二进制兼容的类型转换如下所示

text      varchar 
xml       varchar 
xml       text    
cidr      inet    
varchar   text    
bit       varbit  
varbit    bit     

常见的二进制兼容类型转换基本就是这两种:

  • varchar(n1) → varchar(n2) (n2 ≥ n1)(比较常用,扩大长度约束不会重写,缩小会重写)

  • varchar ↔ text (同义转换,基本没啥用)

也就是说,其他的类型转换,都会涉及到表的重写。大表的重写是很慢的,从几分钟到十几小时都有可能。一旦发生重写,表上就会有AccessExclusiveLock,阻止一切并发访问。

如果是一个玩具数据库,或者业务还没上线,或者业务根本不在乎停机多久,那么整表重写的方式当然是没有问题的。但绝大多数时候,业务根本不可能接受这样的停机时间。所以,我们需要一种在线升级的办法。在不停机的情况完成字段类型的改造。

基本思路

在线改列的基本原理如下:

  • 创建一个新的临时列,使用新的类型

  • 旧列的数据同步至新的临时列

    • 存量同步:分批更新
    • 增量同步:更新触发器
  • 处理列依赖:索引

  • 执行切换

    • 处理列以来:约束,默认值,分区,继承,触发器

    • 通过列重命名的方式完成新旧列切换

在线改造的问题在于锁粒度拆分,将原来一次长期重锁操作,等效替代为多个瞬时轻锁操作。

原来ALTER TYPE重写过程中,会加上AccessExclusiveLock,阻止一切并发访问,持续时间几分钟到几天。

  • 添加新列:瞬间完成:AccessExclusiveLock
  • 同步新列-增量:创建触发器,瞬间完成,锁级别低。
  • 同步新列-存量:分批次UPDATE,少量多次,每次都能快速完成,锁级别低。
  • 新旧切换:锁表,瞬间完成。

让我们用pgbench的默认用例来说明在线改列的基本原理。假设我们希望在pgbench_accounts有访问的情况下修改abalance字段类型,从INT修改为BIGINT,那么应该如何处理呢?

  1. 首先,为pgbench_accounts创建一个名为abalance_tmp,类型为BIGINT的新列。
  2. 编写并创建列同步触发器,触发器会在每一行被插入或更新前,使用旧列abalance同步到

详情如下所示:

-- 操作目标:升级 pgbench_accounts 表普通列 abalance 类型:INT -> BIGINT

-- 添加新列:abalance_tmp BIGINT
ALTER TABLE pgbench_accounts ADD COLUMN abalance_tmp BIGINT;

-- 创建触发器函数:保持新列数据与旧列同步
CREATE OR REPLACE FUNCTION public.sync_pgbench_accounts_abalance() RETURNS TRIGGER AS $$
BEGIN NEW.abalance_tmp = NEW.abalance; RETURN NEW;END;
$$ LANGUAGE 'plpgsql';

-- 完成整表更新,分批更新的方式见下
UPDATE pgbench_accounts SET abalance_tmp = abalance; -- 不要在大表上运行这个

-- 创建触发器
CREATE TRIGGER tg_sync_pgbench_accounts_abalance BEFORE INSERT OR UPDATE ON pgbench_accounts
    FOR EACH ROW EXECUTE FUNCTION sync_pgbench_accounts_abalance();

-- 完成列的新旧切换,这时候数据同步方向变化 旧列数据与新列保持同步
BEGIN;
LOCK TABLE pgbench_accounts IN EXCLUSIVE MODE;
ALTER TABLE pgbench_accounts DISABLE TRIGGER tg_sync_pgbench_accounts_abalance;
ALTER TABLE pgbench_accounts RENAME COLUMN abalance TO abalance_old;
ALTER TABLE pgbench_accounts RENAME COLUMN abalance_tmp TO abalance;
ALTER TABLE pgbench_accounts RENAME COLUMN abalance_old TO abalance_tmp;
ALTER TABLE pgbench_accounts ENABLE TRIGGER tg_sync_pgbench_accounts_abalance;
COMMIT;

-- 确认数据完整性
SELECT count(*) FROM pgbench_accounts WHERE abalance_new != abalance;

-- 清理触发器与函数
DROP FUNCTION IF EXISTS sync_pgbench_accounts_abalance();
DROP TRIGGER tg_sync_pgbench_accounts_abalance ON pgbench_accounts;

注意事项

  1. ALTER TABLE的MVCC安全性
  2. 列上如果有约束?(PrimaryKey、ForeignKey,Unique,NotNULL)
  3. 列上如果有索引?
  4. ALTER TABLE导致的主从复制延迟

故障档案:PG安装Extension导致无法连接

今天遇到一个比较有趣的Case,客户报告说数据库连不上了,发现是扩展导致的。

今天遇到一个比较有趣的Case,客户报告说数据库连不上了。报这个错:

psql: FATAL:  could not load library "/export/servers/pgsql/lib/pg_hint_plan.so": /export/servers/pgsql/lib/pg_hint_plan.so: undefined symbol: RINFO_IS_PUSHED_DOWN

当然,这种错误一眼就知道是插件没编译好,报符号找不到。因此数据库后端进程在启动时尝试加载pg_hint_plan插件时就GG了,报FATAL错误直接退出。

通常来说这个问题还是比较好解决的,这种额外的扩展通常都是在shared_preload_libraries中指定的,只要把这个扩展名称去掉就好了。

结果……

客户说是通过ALTER ROLE|DATABASE SET session_preload_libraries = pg_hint_plan的方式来启用扩展的。

这两条命令会在使用特定用户,或连接到特定数据库时覆盖系统默认参数,去加载pg_hint_plan插件。

ALTER DATABASE postgres SET session_preload_libraries = pg_hint_plan;
ALTER ROLE postgres SET session_preload_libraries = pg_hint_plan;

如果是这样的话,也是可以解决的,通常来说只要有其他的用户或者其他的数据库可以正常登陆,就可以通过ALTER TABLE语句把这两行配置给去掉。

但坏事就坏在,所有的用户和数据库都配了这个参数,以至于没有任何一条连接能连到数据库了。

这种情况下,数据库就成了植物人状态,postmaster还活着,但任何新创建的后端服务器进程都会因为扩展失效自杀……。即使dropdb这种外部自带的二进制命令也无法工作。

于是……

无法建立到数据库的连接,那么常规手段就都失效了……,只能Dirty hack了。

如果我们从二进制层面把用户和数据库级别的配置项给抹掉,那么就可以连接到数据库,把扩展给清理掉了。

DB与Role级别的配置存储在系统目录pg_db_role_setting中,这个表有着固定的OID = 2964,存储在数据目录下global/2964里。关闭数据库,使用二进制编辑器打开pg_db_role_setting对应的文件

# vim打开后使用 :%!xxd 编辑二进制
# 编辑完成后使用 :%!xxd -r转换回二进制,再用:wq保存
vi ${PGDATA}/global/2964

这里,将所有的pg_hint_plan字符串都替换成等长的^@二进制零字符即可。当然如果不在乎原来的配置,更省事的做法是直接把这个文件截断成零长文件。

重启数据库,终于又能连接上了。

复现

这个问题复现起来也非常简单,初始化一个新数据库实例

initdb -D /pg/test -U postgres && pg_ctl -D /pg/test start

然后执行以下语句,就可以体会这种酸爽了。

psql postgres postgres -c 'ALTER ROLE postgres SET session_preload_libraries = pg_hint_plan;'

教训……

  1. 安装扩展后,一定要先验证扩展本身可以正常工作,再启用扩展
  2. 凡事留一线,日后好相见:一个紧急备用的纯洁的su,或者一个无污染的可连接数据库,都不至于这么麻烦。

PostgreSQL 常见复制拓扑方案

复制是系统架构中的核心问题之一。

复制是系统架构中的核心问题之一。

集群拓扑

假设我们使用4单元的标准配置:主库,同步从库,延迟备库,远程备库,分别用字母M,S,O,R标识。

  • MMaster, Main, Primary, Leader, 主库,权威数据源。
  • S: Slave, Secondary, Standby, Sync Replica,同步副本,需要直接挂载至主库
  • R: Remote Replica, Report instance,远程副本,可以挂载到主库或同步从库上
  • O: Offline,离线延迟备库,可以挂载到主库,同步从库,或者远程备库上。

依照R和O的挂载目标不同,复制拓扑关系有以下几种选择:

其中,拓扑2具有显著的优越性:

假设采用同步提交,那么为了安全起见,必须有超过一个的同步从库,这样当采用ANY 1FIRST 1同步提交时,主库不至于因为从库故障而挂掉。因此,离线库O应当直接挂载到主库上:在具体实现细节上:延迟备库可以采用日志传输的方式实现,这样能够将线上库与延迟库解耦。日志归档使用自带的pg_receivewal采用同步的方式(即pg_receivewal作为一个“备库”,而不是离线数据库实例本身)。

另一方面,当使用同步提交时,假设M出现故障,Failover至S,那么S也需要一个同步从库,以免在切换后立刻因为同步提交而Hang住,因此远程备库适合挂载到S上。

故障恢复

当故障发生时,我们需要尽可能快地将生产系统救回来,例如通过Failover,并在事后有时间时恢复原有的拓扑结构。

  • P0:(M)主库失效,应当在秒级到分钟级内恢复
  • P1:(S)从库失效,影响只读查询,但主库可以先抗,可以容忍分钟级别到小时级别的问题。
  • P2:(O,R)离线库与远程备库故障,可能没有直接影响,故障容忍范围可以放宽至小时到天级别。

![](

当M失效时,会对所有组件产生影响。需要执行故障转移(Failover)将S提升为新的M以便尽快使系统恢复。手工Failover包括两个步骤:Fencing M(由重到轻:关机,关数据库,改HBA,关连接池,暂停连接池)与Promote S,这两个操作都可以通过脚本在很短的时间内完成。Failover之后,系统基本恢复。还需要在事后重新恢复原来的拓扑结构。例如将原有的M通过pg_rewind变为新的从库,将O挂载到新的M上,将R挂载到新的S上;或者在修复M后,通过计划内的Failover再次回归原有拓扑。

当S失效时,会对R产生直接影响。作为一种HotFix,我们可以将R的复制源由S改到M,即可将R的影响修复。同时,通过连接池倒流将S的原有流量分发至其他从库或M,接下来就可以慢慢研究并修复S上的问题了。

当O和R失效时,因为它们既没有很大的直接影响,也没有直属后代,因此只要重做一个即可。

实施方式

PostgreSQL Testing Environment 这里给出了一个3节点的样例集群,包含了M,S,O三个节点。R节点是S的一种,因此在此略过。

这里,主库直接挂载了两个“从库”,一个是S节点,一个是O节点上的WAL日志归档器。在丢数据容忍度很低的情况下,可以将两者配置为同步从库。

温备:使用pg_receivewal

备份有各种各样的策略,物理备份通常可以分为四种。

备份是DBA的安身立命之本,也是数据库管理中最为关键的工作之一。有各种各样的备份,但今天这里讨论的备份都是物理备份。物理备份通常可以分为以下四种:

  • 热备(Hot Standby):与主库一模一样,当主库出现故障时会接管主库的工作,同时也会用于承接线上只读流量。
  • 温备(Warm Standby):与热备类似,但不承载线上流量。通常数据库集群需要一个延迟备库,以便出现错误(例如误删数据)时能及时恢复。在这种情况下,因为延迟备库与主库内容不一致,因此不能服务线上查询。
  • 冷备(Code Backup):冷备数据库以数据目录静态文件的形式存在,是数据库目录的二进制备份。便于制作,管理简单,便于放到其他AZ实现容灾。是数据库的最终保险。
  • 异地副本(Remote Standby):所谓X地X中心,通常指的就是放在其他AZ的热备实例。

通常我们所说的备份,指的是冷备和温备。它们与热备的重要区别是:它们通常不是最新的。当服务线上查询时,这种滞后是一个缺陷,但对于故障恢复而言,这是一个非常重要的特性。同步的备库是不足以应对所有的问题。设想这样一种情况:一些人为故障或者软件错误把整个数据表甚至整个数据库删除了,这样的变更会立刻应用到同步从库上。这种情况只能通过从延迟温备中查询,或者从冷备重放日志来恢复。因此无论有没有从库,冷/温备都是必须的。

参考:PostgreSQL复制方案

温备方案

通常我比较建议采用延时日志传输备库的方式做温备,从而快速响应故障,并通过异地云存储冷备的方式做容灾。

温备方案有一些显著的优势:

  • 可靠:温备实际上在运行过程中,就在不断地进行“恢复测试”,因此只要温备工作正常没报错,你总是能够相信它是一个可用的备份,但冷备就不一定了。同时,采用同步提交pg_receivewal与日志传输的离线实例,一方面能够降低主库因为单一同步从库故障而挂点的风险,另一方面也消除了备库活动影响主库的风险。
  • 管理简单:温备的管理方式基本与普通从库类似,因此如果已经有了主从配置,部署一个温备是很简单的事;此外,用到的工具都是PostgreSQL官方提供的工具:pg_basebackuppg_receivewal。温备的延时窗口可以通过参数简单地调整。
  • 响应快速:在延迟备库的延时窗口内发生的故障(删库),都可以快速地恢复:从延迟备库中查出来灌回主库,或者直接将延迟备库步进至特定时间点并提升为新主库。同时,采用温备的方式,就不用每天或每周从主库上拉去全量备份了,更省带宽,执行也更快。

步骤概览

日志归档

如何归档主库生成的WAL日志,传统上通常是通过配置主库上的archive_command实现的。不过最近版本的PostgreSQL提供了一个相当实用的工具:pg_receivewal(10以前的版本称为pg_receivexlog)。对于主库而言,这个客户端应用看上去就像一个从库一样,主库会不断发送最新的WAL日志,而pg_receivewal会将其写入本地目录中。这种方式相比archive_command的一个显著优势就是,pg_receivewal不会等到PostgreSQL写满一个WAL段文件之后再进行归档,因此可以在同步提交的情况下做到故障不丢数据。

pg_receivewal使用起来也非常简单:

# create a replication slot named walarchiver
pg_receivewal --slot=walarchiver --create-slot --if-not-exists

# add replicator credential to /home/postgres/.pgpass 0600
# start archiving (with proper supervisor/init scritpts)
pg_receivewal \
  -D /pg/arcwal \
  --slot=walarchiver \
  --compress=9\
  -d'postgres://replicator@master.csq.tsa.md/postgres'

当然在实际生产环境中,为了更为鲁棒地归档,通常我们会将其注册为服务,并保存一些命令状态。这里给出了生产环境中使用的一个pg_receivewal命令包装:walarchiver

相关脚本

这里提供了一个初始化PostgreSQL Offline Instance的脚本,可以作为参考:

pg/test/bin/offline.sh

备份测试

面对故障时如何充满信心?只要备份还在,再大的问题都能恢复。但如何确保你的备份方案真正有效,这就需要我们事先进行充分的测试。

让我们来设想一些故障场景,以及在本方案下应对这些故障的方式

  • pg_receive进程终止
  • 离线节点重启
  • 主库节点重启
  • 干净的故障切换
  • 脑裂的故障切换
  • 误删表一张
  • 误删库

To be continue

故障档案:pg_dump导致的连接池污染

有时候,组件之间的相互作用会以微妙的形式表现出来。例如使用pg_dump从连接池中导出数据,就可能产生连接池污染的问题。

PostgreSQL很棒,但这并不意味着它是Bug-Free的。这一次在线上环境中,我又遇到了一个很有趣的Case:由pg_dump导致的线上故障。这是一个非常微妙的Bug,由Pgbouncer,search_path,以及特殊的pg_dump操作所触发。


背景知识

连接污染

在PostgreSQL中,每条数据库连接对应一个后端进程,会持有一些临时资源(状态),在连接结束时会被销毁,包括:

  • 本会话中修改过的参数。RESET ALL;
  • 准备好的语句。 DEALLOCATE ALL
  • 打开的游标。CLOSE ALL;
  • 监听的消息信道。UNLISTEN *
  • 执行计划的缓存。DISCARD PLANS;
  • 预分配的序列号值及其缓存。DISCARD SEQUENCES;
  • 临时表。DISCARD TEMP

Web应用会频繁建立大量的数据库连接,故在实际应用中通常都会使用连接池,复用连接,以减小连接创建与销毁的开销。除了使用各种语言/驱动内置的连接池外,Pgbouncer是最常用的第三方中间件连接池。Pgbouncer提供了一种Transaction Pooling的模式,即:每当客户端事务开始时,连接池会为客户端连接分配一个服务端连接,当事务结束时,服务端连接会被放回到池中。

事务池化模式也存在一些问题,例如连接污染。当某个客户端修改了连接的状态,并将该连接放回池中,其他的应用遍可能受到非预期的影响。如下图所示:

假设有四条客户端连接(前端连接)C1、C2、C3、C4,和两条服务器连接(后端连接)S1,S2。数据库默认搜索路径被配置为:app,$user,public,应用知道该假设,并使用SELECT * FROM tbl;的方式,来默认访问模式app下的表app.tbl。现在假设客户端C2在使用了服务器连接S2的过程中,执行了set search_path = ''清空了连接S2上的搜索路径。当S2被另一个客户端C3复用时,C3执行SELECT * FROM tbl时就会因为search_path中找不到对应的表而报错。

当客户端对于连接的假设被打破时,很容易出现各种错误。


故障排查

线上应用突然大量报错触发熔断,错误内容为大量的对象(表,函数)找不到。

第一直觉就是连接池被污染了:某个连接在修改完search_path之后将连接放回池中,当这个后端连接被其他前端连接复用时,就会出现找不到对象的情况。

连接至相应的Pool中,发现确实存在连接的search_path被污染的情况,某些连接的search_path被置空了,因此使用这些连接的应用就找不到对象了。

psql -p6432 somedb
# show search_path; \watch 0.1

在Pgbouncer中使用管理员账户执行RECONNECT命令,强制重连所有连接,search_path重置为默认值,问题解决。

reconnect somedb

不过问题就来了,究竟是什么应用修改了search_path呢?如果问题来源没有排查清楚,难免以后会重犯。有几种可能:业务代码修改,应用的驱动Bug,人工操作,或者连接池本身的Bug。嫌疑最大的当然是手工操作,有人如果使用生产账号用psql连到连接池,手工修改了search_path,然后退出,这个连接就会被放回到生产池中,导致污染。

首先检查数据库日志,发现报错的日志记录全都来自同一条服务器连接5c06218b.2ca6c,即只有一条连接被污染。找到这条连接开始持续报错的临界时刻:

cat postgresql-Tue.csv | grep 5c06218b.2ca6c

2018-12-04 14:44:42.766 CST,"xxx","xxx-xxx",182892,"127.0.0.1:60114",5c06218b.2ca6c,36,"SELECT",2018-12-04 14:41:15 CST,24/0,0,LOG,00000,"duration: 1067.392 ms  statement: SELECT xxxx FROM x",,,,,,,,,"app - xx.xx.xx.xx:23962"

2018-12-04 14:45:03.857 CST,"xxx","xxx-xxx",182892,"127.0.0.1:60114",5c06218b.2ca6c,37,"SELECT",2018-12-04 14:41:15 CST,24/368400961,0,ERROR,42883,"function upsert_xxxxxx(xxx) does not exist",,"No function matches the given name and argument types. You might need to add explicit type casts.",,,,"select upsert_phone_plan('965+6628',1,0,0,0,1,0,'2018-12-03 19:00:00'::timestamp)",8,,"app - 10.191.160.49:46382"

这里5c06218b.2ca6c是该连接的唯一标识符,而后面的数字36,37则是该连接所产生日志的行号。一些操作并不会记录在日志中,但这里幸运的是,正常和出错的两条日志时间相差只有21秒,可以比较精确地定位故障时间点。

通过扫描所有白名单机器上该时刻的命令操作记录,精准定位到了一条执行记录:

pg_dump --host master.xxxx --port 6432 -d somedb -t sometable

嗯?pg_dump不是官方自带的工具吗,难道会修改search_path?不过直觉告诉我,还真不是没可能。例如我想起了一个有趣的行为,因为schema本质上是一个命名空间,因此位于不同schema内的对象可以有相同的名字。在老版本在使用-t转储特定表时,如果提供的表名参数不带schema前缀,pg_dump默认会默认转储所有同名的表。

查阅pg_dump的源码,发现还真有这种操作,以10.5版本为例,发现在setup_connection的时候,确实修改了search_path

// src/bin/pg_dump/pg_dump.c line 287
int main(int argc, char **argv);

// src/bin/pg_dump/pg_dump.c line 681 main
setup_connection(fout, dumpencoding, dumpsnapshot, use_role);

// src/bin/pg_dump/pg_dump.c line 1006 setup_connection
PQclear(ExecuteSqlQueryForSingleRow(AH, ALWAYS_SECURE_SEARCH_PATH_SQL));

// include/server/fe_utils/connect.h
#define ALWAYS_SECURE_SEARCH_PATH_SQL \
   "SELECT pg_catalog.set_config('search_path', '', false)" 

Bug复现

接下来就是复现该BUG了。但比较奇怪的是,在使用PostgreSQL11的时候并没能复现出该Bug来,于是我看了一下肇事司机的全部历史记录,还原了其心路历程(发现pg_dump和服务器版本不匹配,来回折腾),使用不同版本的pg_dump终于复现了该BUG。

使用一个现成的数据库,名为data进行测试,版本为11.1。使用的Pgbouncer配置如下,为了便于调试,连接池的大小已经改小,只允许两条服务端连接。

[databases]
postgres = host=127.0.0.1

[pgbouncer]
logfile = /Users/vonng/pgb/pgbouncer.log
pidfile = /Users/vonng/pgb/pgbouncer.pid
listen_addr = *
listen_port = 6432
auth_type = trust
admin_users = postgres
stats_users = stats, postgres
auth_file = /Users/vonng/pgb/userlist.txt
pool_mode = transaction
server_reset_query =
max_client_conn = 50000
default_pool_size = 2

reserve_pool_size = 0
reserve_pool_timeout = 5

log_connections = 1
log_disconnections = 1
application_name_add_host = 1

ignore_startup_parameters = extra_float_digits

启动连接池,检查search_path,正常的默认配置。

$ psql postgres://vonng:123456@:6432/data -c 'show search_path;'
     search_path
-----------------------
 app, "$user", public

使用10.5版本的pg_dump,从6432端口发起Dump

/usr/local/Cellar/postgresql/10.5/bin/pg_dump \
	postgres://vonng:123456@:6432/data \
	-t geo.pois -f /dev/null
pg_dump: server version: 11.1; pg_dump version: 10.5
pg_dump: aborting because of server version mismatch

虽然Dump失败,但再次检查所有连接的search_path时,就会发现池里的连接已经被污染了,一条连接的search_path已经被修改为空

$ psql postgres://vonng:123456@:6432/data -c 'show search_path;'
 search_path
-------------

(1 row)

解决方案

同时配置pgbouncer的server_reset_query以及server_reset_query_always参数,可以彻底解决此问题。

server_reset_query = DISCARD ALL
server_reset_query_always = 1

在TransactionPooling模式下,server_reset_query默认是不执行的,因此需要通过配置server_reset_query_always=1使每次事务执行完后强制执行DISCARD ALL清空连接的所有状态。不过,这样的配置是有代价的,DISCARD ALL实质上执行了以下操作:

SET SESSION AUTHORIZATION DEFAULT;
RESET ALL;
DEALLOCATE ALL;
CLOSE ALL;
UNLISTEN *;
SELECT pg_advisory_unlock_all();
DISCARD PLANS;
DISCARD SEQUENCES;
DISCARD TEMP;

如果每个事务后面都要多执行这些语句,确实会带来一些额外的性能开销。

当然,也有其他的方法,譬如从管理上解决,杜绝使用pg_dump访问6432端口的可能,将数据库账号使用专门的加密配置中心管理。或者要求业务方使用带schema限定名的name访问数据库对象。但都可能产生漏网之鱼,不如强制配置来的直接。

WeChat Column

PostgreSQL数据页面损坏修复

采用二进制编辑的方式修复PostgreSQL数据页,以及如何让一条主键查询出现两条记录来。

PostgreSQL是一个很可靠的数据库,但是再可靠的数据库,如果碰上了不可靠的硬件,恐怕也得抓瞎。本文介绍了在PostgreSQL中,应对数据页面损坏的方法。

最初的问题

线上有一套统计库跑离线任务,业务方反馈跑SQL的时候碰上一个错误:

ERROR:  invalid page in block 18858877 of relation base/16400/275852

看到这样的错误信息,第一直觉就是硬件错误导致的关系数据文件损坏,第一步要检查定位具体问题。

这里,16400是数据库的oid,而275852则是数据表的relfilenode,通常等于OID。

somedb=# select 275852::RegClass;
      regclass
---------------------
 dailyuseractivities
 
-- 如果relfilenode与oid不一致,则使用以下查询
somedb=# select relname from pg_class where pg_relation_filenode(oid) = '275852';
       relname
---------------------
 dailyuseractivities
(1 row)

定位到出问题的表之后,检查出问题的页面,这里错误提示区块号为18858877的页面出现问题。

somedb=# select * from dailyuseractivities where ctid = '(18858877,1)';
ERROR:  invalid page in block 18858877 of relation base/16400/275852

-- 打印详细错误位置
somedb=# \errverbose
ERROR:  XX001: invalid page in block 18858877 of relation base/16400/275852
LOCATION:  ReadBuffer_common, bufmgr.c:917

通过检查,发现该页面无法访问,但该页面前后两个页面都可以正常访问。使用errverbose可以打印出错误所在的源码位置。搜索PostgreSQL源码,发现这个错误信息只在一处位置出现:https://github.com/postgres/postgres/blob/master/src/backend/storage/buffer/bufmgr.c。可以看到,错误发生在页面从磁盘加载到内存共享缓冲区时。PostgreSQL认为这是一个无效的页面,因此报错并中止事务。

/* check for garbage data */
if (!PageIsVerified((Page) bufBlock, blockNum))
{
    if (mode == RBM_ZERO_ON_ERROR || zero_damaged_pages)
    {
        ereport(WARNING,
                (errcode(ERRCODE_DATA_CORRUPTED),
                 errmsg("invalid page in block %u of relation %s; zeroing out page",
                        blockNum,
                        relpath(smgr->smgr_rnode, forkNum))));
        MemSet((char *) bufBlock, 0, BLCKSZ);
    }
    else
        ereport(ERROR,
                (errcode(ERRCODE_DATA_CORRUPTED),
                 errmsg("invalid page in block %u of relation %s",
                        blockNum,
                        relpath(smgr->smgr_rnode, forkNum))));
}

进一步检查PageIsVerified函数的逻辑:

/* 这里的检查并不能保证页面首部是正确的,只是说它看上去足够正常
 * 允许其加载至缓冲池中。后续实际使用该页面时仍然可能会出错,这也
 * 是我们提供校验和选项的原因。*/

if ((p->pd_flags & ~PD_VALID_FLAG_BITS) == 0 &&
    p->pd_lower <= p->pd_upper &&
    p->pd_upper <= p->pd_special &&
    p->pd_special <= BLCKSZ &&
    p->pd_special == MAXALIGN(p->pd_special))
    header_sane = true;

if (header_sane && !checksum_failure)
    return true;

接下来就要具体定位问题了,那么第一步,首先要找到问题页面在磁盘上的位置。这其实是两个子问题:在哪个文件里,以及在文件里的偏移量地址。这里,关系文件的relfilenode是275852,在PostgreSQL中,每个关系文件都会被默认切割为1GB大小的段文件,并用relfilenode, relfilenode.1, relfilenode.2, …这样的规则依此命名。

因此,我们可以计算一下,第18858877个页面,每个页面8KB,一个段文件1GB。偏移量为18858877 * 2^13 = 154491920384

154491920384 / (1024^3) = 143
154491920384 % (1024^3) = 946839552 = 0x386FA000

由此可得,问题页面位于第143个段内,偏移量0x386FA000处。

落实到具体文件,也就是${PGDATA}/base/16400/275852.143

hexdump 275852.143 | grep -w10 386fa00

386f9fe0 003b 0000 0100 0000 0100 0000 4b00 07c8
386f9ff0 9b3d 5ed9 1f40 eb85 b851 44de 0040 0000
386fa000 0000 0000 0000 0000 0000 0000 0000 0000
*
386fb000 62df 3d7e 0000 0000 0452 0000 011f c37d
386fb010 0040 0003 0b02 0018 18f6 0000 d66a 0068

使用二进制编辑器打开并定位至相应偏移量,发现该页面的内容已经被抹零,没有抢救价值了。好在线上的数据库至少都是一主一从配置,如果是因为主库上的坏块导致的页面损坏,从库上应该还有原来的数据。在从库上果然能找到对应的数据:

386f9fe0:3b00 0000 0001 0000 0001 0000 004b c807  ;............K..
386f9ff0:3d9b d95e 401f 85eb 51b8 de44 4000 0000  =..^@...Q..D@...
386fa000:e3bd 0100 70c8 864a 0000 0400 f801 0002  ....p..J........
386fa010:0020 0420 0000 0000 c09f 7a00 809f 7a00  . . ......z...z.
386fa020:409f 7a00 009f 7a00 c09e 7a00 809e 7a00  @.z...z...z...z.
386fa030:409e 7a00 009e 7a00 c09d 7a00 809d 7a00  @.z...z...z...z.

当然,如果页面是正常的,在从库上执行读取操作就不会报错。因此可以直接通过CTID过滤把损坏的数据找回来。

到现在为止,数据虽然找回来,可以松一口气了。但主库上的坏块问题仍然需要处理,这个就比较简单了,直接重建该表,并从从库抽取最新的数据即可。有各种各样的方法,VACUUM FULLpg_repack,或者手工重建拷贝数据。

不过,我注意到在判定页面有效性的代码中出现了一个从来没见过的参数zero_damaged_pages,查阅文档才发现,这是一个开发者调试用参数,可以允许PostgreSQL忽略损坏的数据页,将其视为全零的空页面。用WARNING替代ERROR。这引发了我的兴趣。毕竟有时候,对于一些粗放的统计业务,跑了几个小时的SQL因为一两条脏数据中断,恐怕要比错漏那么几条记录更令人抓狂。这个参数可不可以满足这样的需求呢?

zero_damaged_pages (boolean)

PostgreSQL在检测到损坏的页面首部时通常会报告一个错误,并中止当前事务。将参数zero_damaged_pages配置为on,会使系统取而代之报告一个WARNING,并将内存中的页面抹为全零。然而该操作会摧毁数据,也就是说损坏页面上的行全都会丢失。不过,这样做确实能允许你略过错误并从未损坏的页面中获取表中未受损的行。当出现软件或硬件导致的数据损坏时,该选项可用于恢复数据。通常情况下只有当您放弃从受损的页面中恢复数据时,才应当使用该选项。抹零的页面并不会强制刷回磁盘,因此建议在重新关闭该选项之前重建受损的表或索引。本选项默认是关闭的,且只有超级用户才能修改。

毕竟,当重建表之后,原来的坏块就被释放掉了。如果硬件本身没有提供坏块识别与筛除的功能,那么这就是一个定时炸弹,很可能将来又会坑到自己。不幸的是,这台机器上的数据库有14TB,用的16TB的SSD,暂时没有同类型的机器了。只能先苟一下,因此需要研究一下,这个参数能不能让查询在遇到坏页时自动跳过。

苟且的办法

如下,在本机搭建一个测试集群,配置一主一从。尝试复现该问题,并确定

# tear down
pg_ctl -D /pg/d1 stop
pg_ctl -D /pg/d2 stop
rm -rf /pg/d1 /pg/d2

# master @ port5432
pg_ctl -D /pg/d1 init
pg_ctl -D /pg/d1 start
psql postgres -c "CREATE USER replication replication;"

# slave @ port5433
pg_basebackup -Xs -Pv -R -D /pg/d2 -Ureplication 
pg_ctl -D /pg/d2 start -o"-p5433"

连接至主库,创建样例表并插入555条数据,约占据三个页面。

-- psql postgres
DROP TABLE IF EXISTS test;
CREATE TABLE test(id varchar(8) PRIMARY KEY);
ANALYZE test;

-- 注意,插入数据之后一定要执行checkpoint确保落盘
INSERT INTO test SELECT generate_series(1,555)::TEXT;
CHECKPOINT;

现在,让我们模拟出现坏块的情况,首先找出主库中test表的对应文件。

SELECT pg_relation_filepath(oid) FROM pg_class WHERE relname = 'test';

base/12630/16385
$ hexdump /pg/d1/base/12630/16385 | head -n 20
0000000 00 00 00 00 d0 22 02 03 00 00 00 00 a0 03 c0 03
0000010 00 20 04 20 00 00 00 00 e0 9f 34 00 c0 9f 34 00
0000020 a0 9f 34 00 80 9f 34 00 60 9f 34 00 40 9f 34 00
0000030 20 9f 34 00 00 9f 34 00 e0 9e 34 00 c0 9e 36 00
0000040 a0 9e 36 00 80 9e 36 00 60 9e 36 00 40 9e 36 00
0000050 20 9e 36 00 00 9e 36 00 e0 9d 36 00 c0 9d 36 00
0000060 a0 9d 36 00 80 9d 36 00 60 9d 36 00 40 9d 36 00
0000070 20 9d 36 00 00 9d 36 00 e0 9c 36 00 c0 9c 36 00

上面已经给出了PostgreSQL判断页面是否“正常”的逻辑,这里我们就修改一下数据页面,让页面变得“不正常”。页面的第12~16字节,也就是这里第一行的最后四个字节a0 03 c0 03,是页面内空闲空间上下界的指针。这里按小端序解释的意思就是本页面内,空闲空间从0x03A0开始,到0x03C0结束。符合逻辑的空闲空间范围当然需要满足上界小于等于下界。这里我们将上界0x03A0修改为0x03D0,超出下界0x03C0,也就是将第一行的倒数第四个字节由A0修改为D0

# vim打开后使用 :%!xxd 编辑二进制
# 编辑完成后使用 :%!xxd -r转换回二进制,再用:wq保存
vi /pg/d1/base/12630/16385

# 查看修改后的结果。
$ hexdump /pg/d1/base/12630/16385 | head -n 2
0000000 00 00 00 00 48 22 02 03 00 00 00 00 d0 03 c0 03
0000010 00 20 04 20 00 00 00 00 e0 9f 34 00 c0 9f 34 00

这里,虽然磁盘上的页面已经被修改,但页面已经缓存到了内存中的共享缓冲池里。因此从主库上仍然可以正常看到页面1中的结果。接下来重启主库,清空其Buffer。不幸的是,当关闭数据库或执行检查点时,内存中的页面会刷写会磁盘中,覆盖我们之前编辑的结果。因此,首先关闭数据库,重新执行编辑后再启动。

pg_ctl -D /pg/d1 stop
vi /pg/d1/base/12630/16385
pg_ctl -D /pg/d1 start

psql postgres -c 'select * from test;'
ERROR:  invalid page in block 0 of relation base/12630/16385

psql postgres -c "select * from test where id = '10';"
ERROR:  invalid page in block 0 of relation base/12630/16385

psql postgres -c "select * from test where ctid = '(0,1)';"
ERROR:  invalid page in block 0 of relation base/12630/16385

$ psql postgres -c "select * from test where ctid = '(1,1)';"
 id
-----
 227

可以看到,修改后的0号页面无法被数据库识别出来,但未受影响的页面1仍然可以正常访问。

虽然主库上的查询因为页面损坏无法访问了,这时候在从库上执行类似的查询,都可以正常返回结果

$ psql -p5433 postgres -c 'select * from test limit 2;'
 id
----
 1
 2

$ psql -p5433 postgres -c "select * from test where id = '10';"
 id
----
 10

$ psql -p5433 postgres -c "select * from test where ctid = '(0,1)';"
 id
----
 1
(1 row)

接下来,让我们打开zero_damaged_pages参数,现在在主库上的查询不报错了。取而代之的是一个警告,页面0中的数据蒸发掉了,返回的结果从第1页开始。

postgres=# set zero_damaged_pages = on ;
SET
postgres=# select * from test;
WARNING:  invalid page in block 0 of relation base/12630/16385; zeroing out page
 id
-----
 227
 228
 229
 230
 231

第0页确实已经被加载到内存缓冲池里了,而且页面里的数据被抹成了0。

create extension pg_buffercache ;

postgres=# select relblocknumber,isdirty,usagecount from pg_buffercache where relfilenode = 16385;
 relblocknumber | isdirty | usagecount
----------------+---------+------------
              0 | f       |          5
              1 | f       |          3
              2 | f       |          2

zero_damaged_pages参数需要在实例级别进行配置:

# 确保该选项默认打开,并重启生效
psql postgres -c 'ALTER SYSTEM set zero_damaged_pages = on;'
pg_ctl -D /pg/d1 restart
psql postgres -c 'show zero_damaged_pages;'

zero_damaged_pages
--------------------
 on

这里,通过配置zero_damaged_pages,能够让主库即使遇到坏块,也能继续应付一下。

垃圾页面被加载到内存并抹零之后,如果执行检查点,这个全零的页面是否又会被重新刷回磁盘覆盖原来的数据呢?这一点很重要,因为脏数据也是数据,起码有抢救的价值。为了一时的方便产生永久性无法挽回的损失,那肯定也是无法接受的。

psql postgres -c 'checkpoint;'
hexdump /pg/d1/base/12630/16385 | head -n 2
0000000 00 00 00 00 48 22 02 03 00 00 00 00 d0 03 c0 03
0000010 00 20 04 20 00 00 00 00 e0 9f 34 00 c0 9f 34 00

可以看到,无论是检查点还是重启,这个内存中的全零页面并不会强制替代磁盘上的损坏页面,留下了抢救的希望,又能保证线上的查询可以苟一下。甚好,甚好。这也符合文档中的描述:“抹零的页面并不会强制刷回磁盘”。

微妙的问题

就当我觉得实验完成,可以安心的把这个开关打开先对付一下时。突然又想起了一个微妙的事情,主库和从库上读到的数据是不一样的,这就很尴尬了。

psql -p5432 postgres -Atqc 'select * from test limit 2;'
2018-11-29 22:31:20.777 CST [24175] WARNING:  invalid page in block 0 of relation base/12630/16385; zeroing out page
WARNING:  invalid page in block 0 of relation base/12630/16385; zeroing out page
227
228

psql -p5433 postgres -Atqc 'select * from test limit 2;'
1
2

更尴尬的是,在主库上是看不到第0页中的元组的,也就是说主库认为第0页中的记录都不存在,因此,即使表上存在主键约束,仍然可以插入同一个主键的记录:

# 表中已经有主键 id = 1的记录了,但是主库抹零了看不到!
psql postgres -c "INSERT INTO test VALUES(1);"
INSERT 0 1

# 从从库上查询,夭寿了!主键出现重复了!
psql postgres -p5433 -c "SELECT * FROM test;"

 id
-----
 1
 2
 3
...
 555
 1
 
# id列真的是主键……
$ psql postgres -p5433 -c "\d test;"
                      Table "public.test"
 Column |         Type         | Collation | Nullable | Default
--------+----------------------+-----------+----------+---------
 id     | character varying(8) |           | not null |
Indexes:
    "test_pkey" PRIMARY KEY, btree (id)

如果把这个从库Promote成新的主库,这个问题在从库上依然存在:一条主键能返回两条记录!真是夭寿啊……。

此外,还有一个有趣的问题,VACUUM会如何处理这样的零页面呢?


# 对表进行清理
psql postgres -c 'VACUUM VERBOSE;'

INFO:  vacuuming "public.test"
2018-11-29 22:18:05.212 CST [23572] WARNING:  invalid page in block 0 of relation base/12630/16385; zeroing out page
2018-11-29 22:18:05.212 CST [23572] WARNING:  relation "test" page 0 is uninitialized --- fixing
WARNING:  invalid page in block 0 of relation base/12630/16385; zeroing out page
WARNING:  relation "test" page 0 is uninitialized --- fixing
INFO:  index "test_pkey" now contains 329 row versions in 5 pages
DETAIL:  0 index row versions were removed.
0 index pages have been deleted, 0 are currently reusable.
CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s.

VACUUM把这个页面“修好了”?但杯具的是,VACUUM自作主张修好了脏数据页,并不一定是一件好事…。因为当VACUUM完成修复时,这个页面就被视作一个普通的页面了,就会在CHECKPOINT时被刷写回磁盘中……,从而覆盖了原始的脏数据。如果这种修复并不是你想要的结果,那么数据就有可能会丢失。

总结

  • 复制,备份是应对硬件损坏的最佳办法。
  • 当出现数据页面损坏时,可以找到对应的物理页面,进行比较,尝试修复。
  • 当页面损坏导致查询无法进行时,参数zero_damaged_pages可以临时用于跳过错误。
  • 参数zero_damaged_pages极其危险
  • 打开抹零时,损坏页面会被加载至内存缓冲池中并抹零,且在检查点时不会覆盖磁盘原页面。
  • 内存中被抹零的页面会被VACUUM尝试修复,修复后的页面会被检查点刷回磁盘,覆盖原页面。
  • 抹零页面内的内容对数据库不可见,因此可能会出现违反约束的情况出现。

WeChat Column地址

关系膨胀的监控与治理

PostgreSQL使用了MVCC作为主要并发控制技术,它有很多好处,但也会带来一些其他的影响,例如关系膨胀。

PostgreSQL使用了MVCC作为主要并发控制技术,它有很多好处,但也会带来一些其他的影响,例如关系膨胀。关系(表与索引)膨胀会对数据库性能产生负面影响,并浪费磁盘空间。为了使PostgreSQL始终保持在最佳性能,有必要及时对膨胀的关系进行垃圾回收,并定期重建过度膨胀的关系。

在实际操作中,垃圾回收并没有那么简单,这里有一系列的问题:

  • 关系膨胀的原因?
  • 关系膨胀的度量?
  • 关系膨胀的监控?
  • 关系膨胀的处理?

本文将详细说明这些问题。


关系膨胀概述

假设某个关系实际占用存储100G,但其中有很多空间被死元组,碎片,空闲区域浪费,如果将其压实为一个新的关系,占用空间变为60G,那么就可以近似认为该关系的膨胀率是 (100 - 60) / 100 = 40%。

普通的VACUUM不能解决表膨胀的问题,死元组本身能够被并发VACUUM机制回收,但它产生的碎片,留下的空洞却不可以。比如,即使删除了许多死元组,也无法减小表的大小。久而久之,关系文件被大量空洞填满,浪费了大量的磁盘空间。

VACUUM FULL命令可以回收这些空间,它将旧表文件中的活元组复制到新表中,通过重写整张表的方式将表压实。但在实际生产中,因为该操作会持有表上的AccessExclusiveLock,阻塞业务正常访问,因此在不间断服务的情况下并不适用,pg_repack是一个实用的第三方插件,能够在线上业务正常进行的同时进行无锁的VACUUM FULL

不幸的是,关于什么时候需要进行VACUUM FULL处理膨胀并没有一个最佳实践。DBA需要针对自己的业务场景制定清理策略。但无论采用何种策略,实施这些策略的机制都是类似的:

  • 监控,检测,衡量关系的膨胀程度
  • 依据关系的膨胀程度,时机等因素,处理关系膨胀。

这里有几个关键的问题,首先是,如何定义关系的膨胀率?


关系膨胀的度量

衡量关系膨胀的程度,首先需要定义一个指标:膨胀率(bloat rate)

膨胀率的计算思想是:通过统计信息估算出目标表如果处于 紧实(Compact) 状态所占用的空间,而实际使用空间超出该紧实空间部分的占比,就是膨胀率。因此膨胀率可以被定义为 1 - (活元组占用字节总数 / 关系占用字节总数)。

例如,某个表实际占用存储100G,但其中有很多空间被死元组,碎片,空闲区域浪费,如果将其压实为一张新表,占用空间变为60G,那么膨胀率就是 1 - 60/100 = 40%。

关系的大小获取较为简单,可以直接从系统目录中获取。所以问题的关键在于,活元组的字节总数这一数据如何获取。

膨胀率的精确计算

PostgreSQL自带了pgstattuple模块,可用于精确计算表的膨胀率。譬如这里的tuple_percent字段就是元组实际字节占关系总大小的百分比,用1减去该值即为膨胀率。

vonng@[local]:5432/bench# select *,
                          1.0 - tuple_len::numeric / table_len as bloat 
                          from pgstattuple('pgbench_accounts');
┌─[ RECORD 1 ]───────┬────────────────────────┐
 table_len           136642560              
 tuple_count         1000000                
 tuple_len           121000000              
 tuple_percent       88.55                  
 dead_tuple_count    16418                  
 dead_tuple_len      1986578                
 dead_tuple_percent  1.45                   
 free_space          1674768                
 free_percent        1.23                   
 bloat               0.11447794889088729017 
└────────────────────┴────────────────────────┘

pgstattuple对于精确地判断表与索引的膨胀情况非常有用,具体细节可以参考官方文档:https://www.postgresql.org/docs/current/static/pgstattuple.html。

此外,PostgreSQL还提供了两个自带的扩展,pg_freespacemappageinspect,前者可以用于检视每个页面中的空闲空间大小,后者则可以精确地展示关系中每个数据页内物理存储的内容。如果希望检视关系的内部状态,这两个插件非常实用,详细使用方法可以参考官方文档:

https://www.postgresql.org/docs/current/static/pgfreespacemap.html

https://www.postgresql.org/docs/current/static/pageinspect.html

不过在绝大多数情况下,我们并不会太在意膨胀率的精确度。在实际生产中对膨胀率的要求并不高:第一位有效数字是准确的,就差不多够用了。另一方面,要想精确地知道活元组占用的字节总数,需要对整个关系执行一遍扫描,这会对线上系统的IO产生压力。如果希望对所有表的膨胀率进行监控,也不适合使用这种方式。

例如一个200G的关系,使用pgstattuple插件执行精确的膨胀率估算大致需要5分钟时间。在9.5及后续版本,pgstattuple插件还提供了pgstattuple_approx函数,以精度换速度。但即使使用估算,也需要秒级的时间。

监控膨胀率,最重要的要求是速度快,影响小。因此当我们需要对很多数据库的很多表同时进行监控时,需要对膨胀率进行快速估算,避免对业务产生影响。


膨胀率的估算

PostgreSQL为每个关系都维护了很多的统计信息,利用统计信息,可以快速高效地估算数据库中所有表的膨胀率。估算膨胀率需要使用表与列上的统计信息,直接使用的统计指标有三个:

  • 元组的平均宽度avgwidth:从列级统计数据计算而来,用于估计紧实状态占用的空间。
  • 元组数:pg_class.reltuples:用于估计紧实状态占用的空间
  • 页面数:pg_class.relpages:用于测算实际使用的空间

而计算公式也很简单:

1 - (reltuples * avgwidth) / (block_size - pageheader) / relpages 

这里block_size是页面大小,默认为8182,pageheader是首部占用的大小,默认为24字节。页面大小减去首部大小就是可以用于元组存储的实际空间,因此(reltuples * avgwidth)给出了元组的估计总大小,而除以前者后,就可以得到预计需要多少个页面才能紧实地存下所有的元组。最后,期待使用的页面数量,除以实际使用的页面数量,就是利用率,而1减去利用率,就是膨胀率。

难点

这里的关键,在于如何使用统计信息估算元组的平均长度,而为了实现这一点,我们需要克服三个困难:

  • 当元组中存在空值时,首部会带有空值位图。
  • 首部与数据部分存在Padding,需要考虑边界对齐。
  • 一些字段类型也存在对齐要求

但好在,膨胀率本身就是一种估算,只要大致正确即可。

计算元组的平均长度

为了理解估算的过程,首先需要理解PostgreSQL中数据页面与元组的的内部布局。

首先来看元组的平均长度,PG中元组的布局如下图所示。

一条元组占用的空间可以分为三个部分:

  • 定长的行指针(4字节,严格来说这不算元组的一部分,但它与元组一一对应)
  • 变长的首部
    • 固定长度部分23字节
    • 当元组中存在空值时,会出现空值位图,每个字段占一位,故其长度为字段数除以8。
    • 在空值位图后需要填充至MAXALIGN,通常为8。
    • 如果表启用了WITH OIDS选项,元组还会有一个4字节的OID,但这里我们不考虑该情况。
  • 数据部分

因此,一条元组(包括相应的行指针)的平均长度可以这样计算:

avg_size_tuple = 4 + avg_size_hdr + avg_size_data

关键在于求出首部的平均长度数据部分的平均长度

计算首部的平均长度

首部平均长度主要的变数在于空值位图填充对齐。为了估算元组首部的平均长度,我们需要知道几个参数:

  • 不带空值位图的首部平均长度(带有填充):normhdr
  • 带有空值位图的首部平均长度(带有填充):nullhdr
  • 带有空值的元组比例:nullfrac

而估算首部平均长度的公式,也非常简单:

avg_size_hdr =  nullhdr * nullfrac + normhdr * (1 - nullfrac)

因为不带空值位图的首部,其长度是23字节,对齐至8字节的边界,长度为24字节,上式可以改为:

avg_size_hdr =  nullhdr * nullfrac +  24 * (1 - nullfrac)

计算某值被补齐至8字节边界的长度,可以使用以下公式进行高效计算:

padding = lambda x : x + 7 >> 3 << 3

计算数据部分的平均长度

数据部分的平均长度主要取决于每个字段的平均宽度与空值率,加上末尾的对齐。

以下SQL可以利用统计信息算出所有表的平均元组数据部分宽度。

SELECT schemaname, tablename, sum((1 - null_frac) * avg_width)
FROM pg_stats GROUP BY (schemaname, tablename);

例如,以下SQL能够从pg_stats系统统计视图中获取app.apple表上一条元组的平均长度。

SELECT
  count(*),                        -- 字段数目
  ceil(count(*) / 8.0),            -- 空值位图占用的字节数
  max(null_frac),                  -- 最大空值率
  sum((1 - null_frac) * avg_width) -- 数据部分的平均宽度
FROM pg_stats
where schemaname = 'app' and tablename = 'apple';

-[ RECORD 1 ]-----------
count | 47
ceil  | 6
max   | 1
sum   | 1733.76873471724

整合

将上面三节的逻辑整合,得到以下的存储过程,给定一个表,返回其膨胀率。

CREATE OR REPLACE FUNCTION public.pg_table_bloat(relation regclass)
 RETURNS double precision
 LANGUAGE plpgsql
AS $function$
DECLARE
  _schemaname text;
  tuples      BIGINT := 0;
  pages       INTEGER := 0;
  nullheader  INTEGER:= 0;
  nullfrac    FLOAT := 0;
  datawidth   INTEGER :=0;
  avgtuplelen FLOAT :=24;
BEGIN
  SELECT
    relnamespace :: RegNamespace,
    reltuples,
    relpages
  into _schemaname, tuples, pages
  FROM pg_class
  Where oid = relation;

  SELECT
    23 + ceil(count(*) >> 3),
    max(null_frac),
    ceil(sum((1 - null_frac) * avg_width))
  into nullheader, nullfrac, datawidth
  FROM pg_stats
  where schemaname = _schemaname and tablename = relation :: text;

  SELECT (datawidth + 8 - (CASE WHEN datawidth%8=0 THEN 8 ELSE datawidth%8 END)) -- avg data len
    + (1 - nullfrac) * 24 + nullfrac * (nullheader + 8 - (CASE WHEN nullheader%8=0 THEN 8 ELSE nullheader%8 END))
    INTO avgtuplelen;

  raise notice '% %', nullfrac, datawidth;

  RETURN 1 - (ceil(tuples * avgtuplelen / 8168)) / pages;
END;
$function$

批量计算

对于监控而言,我们关注的往往不仅仅是一张表,而是库中所有的表。因此,可以将上面的膨胀率计算逻辑重写为批量计算的查询,并定义为视图便于使用:

DROP VIEW IF EXISTS monitor.pg_bloat_indexes CASCADE;
CREATE OR REPLACE VIEW monitor.pg_bloat_indexes AS
  WITH btree_index_atts AS (
      SELECT
        pg_namespace.nspname,
        indexclass.relname                                                          AS index_name,
        indexclass.reltuples,
        indexclass.relpages,
        pg_index.indrelid,
        pg_index.indexrelid,
        indexclass.relam,
        tableclass.relname                                                          AS tablename,
        (regexp_split_to_table((pg_index.indkey) :: TEXT, ' ' :: TEXT)) :: SMALLINT AS attnum,
        pg_index.indexrelid                                                         AS index_oid
      FROM ((((pg_index
        JOIN pg_class indexclass ON ((pg_index.indexrelid = indexclass.oid)))
        JOIN pg_class tableclass ON ((pg_index.indrelid = tableclass.oid)))
        JOIN pg_namespace ON ((pg_namespace.oid = indexclass.relnamespace)))
        JOIN pg_am ON ((indexclass.relam = pg_am.oid)))
      WHERE ((pg_am.amname = 'btree' :: NAME) AND (indexclass.relpages > 0))
  ), index_item_sizes AS (
      SELECT
        ind_atts.nspname,
        ind_atts.index_name,
        ind_atts.reltuples,
        ind_atts.relpages,
        ind_atts.relam,
        ind_atts.indrelid                                    AS table_oid,
        ind_atts.index_oid,
        (current_setting('block_size' :: TEXT)) :: NUMERIC   AS bs,
        8                                                    AS maxalign,
        24                                                   AS pagehdr,
        CASE
        WHEN (max(COALESCE(pg_stats.null_frac, (0) :: REAL)) = (0) :: FLOAT)
          THEN 2
        ELSE 6
        END                                                  AS index_tuple_hdr,
        sum((((1) :: FLOAT - COALESCE(pg_stats.null_frac, (0) :: REAL)) *
             (COALESCE(pg_stats.avg_width, 1024)) :: FLOAT)) AS nulldatawidth
      FROM ((pg_attribute
        JOIN btree_index_atts ind_atts
          ON (((pg_attribute.attrelid = ind_atts.indexrelid) AND (pg_attribute.attnum = ind_atts.attnum))))
        JOIN pg_stats ON (((pg_stats.schemaname = ind_atts.nspname) AND (((pg_stats.tablename = ind_atts.tablename) AND
                                                                          ((pg_stats.attname) :: TEXT =
                                                                           pg_get_indexdef(pg_attribute.attrelid,
                                                                                           (pg_attribute.attnum) :: INTEGER,
                                                                                           TRUE))) OR
                                                                         ((pg_stats.tablename = ind_atts.index_name) AND
                                                                          (pg_stats.attname = pg_attribute.attname))))))
      WHERE (pg_attribute.attnum > 0)
      GROUP BY ind_atts.nspname, ind_atts.index_name, ind_atts.reltuples, ind_atts.relpages, ind_atts.relam,
        ind_atts.indrelid, ind_atts.index_oid, (current_setting('block_size' :: TEXT)) :: NUMERIC, 8 :: INTEGER
  ), index_aligned_est AS (
      SELECT
        index_item_sizes.maxalign,
        index_item_sizes.bs,
        index_item_sizes.nspname,
        index_item_sizes.index_name,
        index_item_sizes.reltuples,
        index_item_sizes.relpages,
        index_item_sizes.relam,
        index_item_sizes.table_oid,
        index_item_sizes.index_oid,
        COALESCE(ceil((((index_item_sizes.reltuples * ((((((((6 + index_item_sizes.maxalign) -
                                                             CASE
                                                             WHEN ((index_item_sizes.index_tuple_hdr %
                                                                    index_item_sizes.maxalign) = 0)
                                                               THEN index_item_sizes.maxalign
                                                             ELSE (index_item_sizes.index_tuple_hdr %
                                                                   index_item_sizes.maxalign)
                                                             END)) :: FLOAT + index_item_sizes.nulldatawidth)
                                                          + (index_item_sizes.maxalign) :: FLOAT) - (
                                                           CASE
                                                           WHEN (((index_item_sizes.nulldatawidth) :: INTEGER %
                                                                  index_item_sizes.maxalign) = 0)
                                                             THEN index_item_sizes.maxalign
                                                           ELSE ((index_item_sizes.nulldatawidth) :: INTEGER %
                                                                 index_item_sizes.maxalign)
                                                           END) :: FLOAT)) :: NUMERIC) :: FLOAT) /
                        ((index_item_sizes.bs - (index_item_sizes.pagehdr) :: NUMERIC)) :: FLOAT) +
                       (1) :: FLOAT)), (0) :: FLOAT) AS expected
      FROM index_item_sizes
  ), raw_bloat AS (
      SELECT
        current_database()                                                           AS dbname,
        index_aligned_est.nspname,
        pg_class.relname                                                             AS table_name,
        index_aligned_est.index_name,
        (index_aligned_est.bs * ((index_aligned_est.relpages) :: BIGINT) :: NUMERIC) AS totalbytes,
        index_aligned_est.expected,
        CASE
        WHEN ((index_aligned_est.relpages) :: FLOAT <= index_aligned_est.expected)
          THEN (0) :: NUMERIC
        ELSE (index_aligned_est.bs *
              ((((index_aligned_est.relpages) :: FLOAT - index_aligned_est.expected)) :: BIGINT) :: NUMERIC)
        END                                                                          AS wastedbytes,
        CASE
        WHEN ((index_aligned_est.relpages) :: FLOAT <= index_aligned_est.expected)
          THEN (0) :: NUMERIC
        ELSE (((index_aligned_est.bs * ((((index_aligned_est.relpages) :: FLOAT -
                                          index_aligned_est.expected)) :: BIGINT) :: NUMERIC) * (100) :: NUMERIC) /
              (index_aligned_est.bs * ((index_aligned_est.relpages) :: BIGINT) :: NUMERIC))
        END                                                                          AS realbloat,
        pg_relation_size((index_aligned_est.table_oid) :: REGCLASS)                  AS table_bytes,
        stat.idx_scan                                                                AS index_scans
      FROM ((index_aligned_est
        JOIN pg_class ON ((pg_class.oid = index_aligned_est.table_oid)))
        JOIN pg_stat_user_indexes stat ON ((index_aligned_est.index_oid = stat.indexrelid)))
  ), format_bloat AS (
      SELECT
        raw_bloat.dbname                                             AS database_name,
        raw_bloat.nspname                                            AS schema_name,
        raw_bloat.table_name,
        raw_bloat.index_name,
        round(
            raw_bloat.realbloat)                                     AS bloat_pct,
        round((raw_bloat.wastedbytes / (((1024) :: FLOAT ^
                                         (2) :: FLOAT)) :: NUMERIC)) AS bloat_mb,
        round((raw_bloat.totalbytes / (((1024) :: FLOAT ^ (2) :: FLOAT)) :: NUMERIC),
              3)                                                     AS index_mb,
        round(
            ((raw_bloat.table_bytes) :: NUMERIC / (((1024) :: FLOAT ^ (2) :: FLOAT)) :: NUMERIC),
            3)                                                       AS table_mb,
        raw_bloat.index_scans
      FROM raw_bloat
  )
  SELECT
    format_bloat.database_name                    as datname,
    format_bloat.schema_name                      as nspname,
    format_bloat.table_name                       as relname,
    format_bloat.index_name                       as idxname,
    format_bloat.index_scans                      as idx_scans,
    format_bloat.bloat_pct                        as bloat_pct,
    format_bloat.table_mb,
    format_bloat.index_mb - format_bloat.bloat_mb as actual_mb,
    format_bloat.bloat_mb,
    format_bloat.index_mb                         as total_mb
  FROM format_bloat
  ORDER BY format_bloat.bloat_mb DESC;


COMMENT ON VIEW monitor.pg_bloat_indexes IS 'index bloat monitor';

虽然看上去很长,但查询该视图获取全库(3TB)所有表的膨胀率,计算只需要50ms。而且只需要访问统计数据,不需要访问关系本体,占用实例的IO。


表膨胀的处理

如果只是玩具数据库,或者业务允许每天有很长的停机维护时间,那么简单地在数据库中执行VACUUM FULL就可以了。但VACUUM FULL需要表上的排它读写锁,但对于需要不间断运行的数据库,我们就需要用到pg_repack来处理表的膨胀。

  • 主页:http://reorg.github.io/pg_repack/

pg_repack已经包含在了PostgreSQL官方的yum源中,因此可以直接通过yum install pg_repack安装。

yum install pg_repack10

pg_repack的使用

与大多数PostgreSQL客户端程序一样,pg_repack也通过类似的参数连接至PostgreSQL服务器。

在使用pg_repack之前,需要在待重整的数据库中创建pg_repack扩展

CREATE EXTENSION pg_repack

然后就可以正常使用了,几种典型的用法:

# 完全清理整个数据库,开5个并发任务,超时等待10秒
pg_repack -d <database> -j 5 -T 10

# 清理mydb中一张特定的表mytable,超时等待10秒
pg_repack mydb -t public.mytable -T 10

# 清理某个特定的索引 myschema.myindex,注意必须使用带模式的全名
pg_repack mydb -i myschema.myindex

详细的用法可以参考官方文档。


pg_repack的策略

通常,如果业务存在峰谷周期,则可以选在业务低谷器进行整理。pg_repack执行比较快,但很吃资源。在高峰期执行可能会影响整个数据库的性能表现,也有可能会导致复制滞后。

例如,可以利用上面两节提供的膨胀率监控视图,每天挑选膨胀最为严重的若干张表和若干索引进行自动重整。

#--------------------------------------------------------------#
# Name: repack_tables
# Desc: repack table via fullname
# Arg1: database_name
# Argv: list of table full name
# Deps: psql
#--------------------------------------------------------------#
# repack single table
function repack_tables(){
    local db=$1
    shift

    log_info "repack ${db} tables begin"
    log_info "repack table list: $@"

    for relname in $@
    do
        old_size=$(psql ${db} -Atqc "SELECT pg_size_pretty(pg_relation_size('${relname}'));")
        # kill_queries ${db}
        log_info "repack table ${relname} begin, old size: ${old_size}"
        pg_repack ${db} -T 10 -t ${relname}
        new_size=$(psql ${db} -Atqc "SELECT pg_size_pretty(pg_relation_size('${relname}'));")
        log_info "repack table ${relname} done , new size: ${old_size} -> ${new_size}"
    done

    log_info "repack ${db} tables done"
}

#--------------------------------------------------------------#
# Name: get_bloat_tables
# Desc: find bloat tables in given database match some condition
# Arg1: database_name
# Echo: list of full table name
# Deps: psql, monitor.pg_bloat_tables
#--------------------------------------------------------------#
function get_bloat_tables(){
    echo $(psql ${1} -Atq <<-'EOF'
    WITH bloat_tables AS (
        SELECT
          nspname || '.' || relname as relname,
          actual_mb,
          bloat_pct
        FROM monitor.pg_bloat_tables
        WHERE nspname NOT IN ('dba', 'monitor', 'trash')
        ORDER BY 2 DESC,3 DESC
    )
    -- 64 small + 16 medium + 4 large
    (SELECT relname FROM bloat_tables WHERE actual_mb < 256 AND bloat_pct > 40 ORDER BY bloat_pct DESC LIMIT 64) UNION
    (SELECT relname FROM bloat_tables WHERE actual_mb BETWEEN 256 AND 1024  AND bloat_pct > 30 ORDER BY bloat_pct DESC LIMIT 16) UNION
    (SELECT relname FROM bloat_tables WHERE actual_mb BETWEEN 1024 AND 4096  AND bloat_pct > 20 ORDER BY bloat_pct DESC  LIMIT 4);
EOF
)
}

这里,设置了三条规则:

  • 从小于256MB,且膨胀率超过40%的小表中,选出TOP64
  • 从256MB到1GB之间,且膨胀率超过40%的中表中,选出TOP16
  • 从1GB到4GB之间,且膨胀率超过20%的大表中,选出TOP4

选出这些表,每天凌晨低谷自动进行重整。超过4GB的表手工处理。

但何时进行重整,还是取决于具体的业务模式。


pg_repack的原理

pg_repack的原理相当简单,它会为待重建的表创建一份副本。首先取一份全量快照,将所有活元组写入新表,并通过触发器将所有针对原表的变更同步至新表,最后通过重命名,使用新的紧实副本替换老表。而对于索引,则是通过PostgreSQL的CREATE(DROP) INDEX CONCURRENTLY完成的。

重整表

  1. 创建一张与原表模式相同,但不带索引的空表。
  2. 创建一张与原始表对应的日志表,用于记录pg_repack工作期间该表上发生的变更。
  3. 为原始表添加一个行触发器,在相应日志表中记录所有INSERT,DELETE,UPDATE操作。
  4. 将老表中的数据复制到新的空表中。
  5. 在新表上创建同样的索引
  6. 将日志表中的增量变更应用到新表上
  7. 通过重命名的方式切换新旧表
  8. 将旧的,已经被重命名掉的表DROP掉。

重整索引

  1. 使用CREATE INDEX CONCURRENTLY在原表上创建新索引,保持与旧索引相同的定义。
  2. Analyze新索引,并将旧索引设置为无效,在数据目录中将新旧索引交换。
  3. 删除旧索引。

pg_repack的注意事项

  • 重整开始之前,最好取消掉所有正在进行的Vacuum任务。

  • 对索引做重整之前,最好能手动清理掉可能正在使用该索引的查询

  • 如果出现异常的情况(譬如中途强制退出),有可能会留下未清理的垃圾,需要手工清理。可能包括:

    • 临时表与临时索引建立在与原表/索引同一个schema内
    • 临时表的名称为:${schema_name}.table_${table_oid}
    • 临时索引的名称为:${schema_name}.index_${table_oid}}
    • 原始表上可能会残留相关的触发器,需要手动清理。
  • 重整特别大的表时,需要预留至少与该表及其索引相同大小的磁盘空间,需要特别小心,手动检查。

  • 当完成重整,进行重命名替换时,会产生巨量的WAL,有可能会导致复制延迟,而且无法取消。

PipelineDB快速上手

PipelineDB是PostgreSQL的一个扩展插件,提供流式数据处理的相关功能。

PipelineDB安装与配置

PipelineDB可以直接通过官方rpm包安装。

加载PipelineDB需要添加动态链接库,在postgresql.conf中修改配置项并重启:

shared_preload_libraries = 'pipelinedb'
max_worker_processes = 128

注意如果不修改max_worker_processes会报错。其他配置都参照标准的PostgreSQL

PipelineDB使用样例 —— 维基PV数据

-- 创建Stream
CREATE FOREIGN TABLE wiki_stream (
        hour timestamp,
        project text,
        title text,
        view_count bigint,
        size bigint)
SERVER pipelinedb;

-- 在Stream上进行聚合
CREATE VIEW wiki_stats WITH (action=materialize) AS
SELECT hour, project,
        count(*) AS total_pages,
        sum(view_count) AS total_views,
        min(view_count) AS min_views,
        max(view_count) AS max_views,
        avg(view_count) AS avg_views,
        percentile_cont(0.99) WITHIN GROUP (ORDER BY view_count) AS p99_views,
        sum(size) AS total_bytes_served
FROM wiki_stream
GROUP BY hour, project;

然后,向Stream中插入数据:

curl -sL http://pipelinedb.com/data/wiki-pagecounts | gunzip | \
        psql -c "
        COPY wiki_stream (hour, project, title, view_count, size) FROM STDIN"

基本概念

PipelineDB中的基本抽象被称之为:连续视图(Continuous View)

TimescaleDB 快速上手

TimescaleDB是PostgreSQL的一个扩展插件,提供时序数据库的一些功能。
  • 官方网站:https://www.timescale.com
  • 官方文档:https://docs.timescale.com/v0.9/main
  • Github:https://github.com/timescale/timescaledb

为什么使用TimescaleDB

什么是时间序列数据?

我们一直在谈论什么是“时间序列数据”,以及与其他数据有何不同以及为什么?

许多应用程序或数据库实际上采用的是过于狭窄的视图,并将时间序列数据与特定形式的服务器度量值等同起来:

Name:    CPU

Tags:    Host=MyServer, Region=West

Data:
2017-01-01 01:02:00    70
2017-01-01 01:03:00    71
2017-01-01 01:04:00    72
2017-01-01 01:05:01    68

但实际上,在许多监控应用中,通常会收集不同的指标(例如,CPU,内存,网络统计数据,电池寿命)。因此,单独考虑每个度量并不总是有意义的。考虑这种替代性的“更广泛”的数据模型,它保持了同时收集的指标之间的相关性。

Metrics: CPU, free_mem, net_rssi, battery

Tags:    Host=MyServer, Region=West

Data:
2017-01-01 01:02:00    70    500    -40    80
2017-01-01 01:03:00    71    400    -42    80
2017-01-01 01:04:00    72    367    -41    80
2017-01-01 01:05:01    68    750    -54    79

这类数据属于更广泛的类别,无论是来自传感器的温度读数,股票价格,机器状态,甚至是登录应用程序的次数。

时间序列数据是统一表示系统,过程或行为随时间变化的数据。

时间序列数据的特征

如果仔细研究它是如何生成和摄入的,TimescaleDB等时间序列数据库通常具有以下重要特征:

  • 以时间为中心:数据记录始终有一个时间戳。
  • 仅追加-:数据是几乎完全追加只(插入)。
  • 最近:新数据通常是关于最近的时间间隔,我们更少更新或回填旧时间间隔的缺失数据。

尽管数据的频率或规律性并不重要,它可以每毫秒或每小时收集一次。它也可以定期或不定期收集(例如,当发生某些事件时,而不是在预先确定的时间)。

但是没有数据库很久没有时间字段?与标准关系“业务”数据等其他数据相比,时间序列数据(以及支持它们的数据库)之间的一个主要区别是对数据的更改是插入而不是覆盖

时间序列数据无处不在

时间序列数据无处不在,但有些环境特别是在洪流中创建。

  • 监控计算机系统:虚拟机,服务器,容器指标(CPU,可用内存,网络/磁盘IOP),服务和应用程序指标(请求率,请求延迟)。
  • 金融交易系统:经典证券,较新的加密货币,支付,交易事件。
  • 物联网:工业机器和设备上的传感器,可穿戴设备,车辆,物理容器,托盘,智能家居的消费设备等的数据。
  • 事件应用程序:用户/客户交互数据,如点击流,综合浏览量,登录,注册等。
  • 商业智能:跟踪关键指标和业务的整体健康状况。
  • 环境监测:温度,湿度,压力,pH值,花粉计数,空气流量,一氧化碳(CO),二氧化氮(NO2),颗粒物质(PM10)。
  • (和更多)

时序数据模型

TimescaleDB使用“宽表”数据模型,这在关系数据库中是非常普遍的。这使得Timescale与大多数其他时间序列数据库有所不同,后者通常使用“窄表”模型。

在这里,我们讨论为什么我们选择宽表模型,以及我们如何推荐将它用于时间序列数据,使用物联网(IoT)示例。

设想一个由1,000个IoT设备组成的分布式组,旨在以不同的时间间隔收集环境数据。这些数据可能包括:

  • 标识符: device_idtimestamp
  • 元数据: location_id,,,dev_type``firmware_version``customer_id
  • 设备指标: cpu_1m_avg,,,,,free_mem``used_mem``net_rssi``net_loss``battery
  • 传感器指标: temperature,,,,,humidity``pressure``CO``NO2``PM10

例如,您的传入数据可能如下所示:

时间戳 设备ID cpu_1m_avg Fri_mem 温度 LOCATION_ID dev_type
2017-01-01 01:02:00 ABC123 80 500MB 72 335 领域
2017-01-01 01:02:23 def456 90 400MB 64 335 屋顶
2017-01-01 01:02:30 ghi789 120 0MB 56 77 屋顶
2017-01-01 01:03:12 ABC123 80 500MB 72 335 领域
2017-01-01 01:03:35 def456 95 350MB 64 335 屋顶
2017-01-01 01:03:42 ghi789 100 100MB 56 77 屋顶

现在,我们来看看用这些数据建模的各种方法。

窄表模型

大多数时间序列数据库将以下列方式表示这些数据:

  • 代表每个指标作为一个单独的实体(例如,表示与作为两个不同的东西)cpu_1m_avg``free_mem
  • 为该指标存储一系列“时间”,“值”对
  • 将元数据值表示为与该指标/标记集组合关联的“标记集”

在这个模型中,每个度量/标签集组合被认为是包含一系列时间/值对的单独“时间序列”。

使用我们上面的例子,这种方法会导致9个不同的“时间序列”,每个“时间序列”由一组独特的标签定义。

1. {name:  cpu_1m_avg,  device_id: abc123,  location_id: 335,  dev_type: field}
2. {name:  cpu_1m_avg,  device_id: def456,  location_id: 335,  dev_type: roof}
3. {name:  cpu_1m_avg,  device_id: ghi789,  location_id:  77,  dev_type: roof}
4. {name:    free_mem,  device_id: abc123,  location_id: 335,  dev_type: field}
5. {name:    free_mem,  device_id: def456,  location_id: 335,  dev_type: roof}
6. {name:    free_mem,  device_id: ghi789,  location_id:  77,  dev_type: roof}
7. {name: temperature,  device_id: abc123,  location_id: 335,  dev_type: field}
8. {name: temperature,  device_id: def456,  location_id: 335,  dev_type: roof}
9. {name: temperature,  device_id: ghi789,  location_id:  77,  dev_type: roof}

这样的时间序列的数量与每个标签的基数的叉积(即,(#名称)×(#设备ID)×(#位置ID)×(设备类型))的交叉积。

而且这些“时间序列”中的每一个都有自己的一组时间/值序列。

现在,如果您独立收集每个指标,而且元数据很少,则此方法可能有用。

但总的来说,我们认为这种方法是有限的。它会丢失数据中的固有结构,使得难以提出各种有用的问题。例如:

  • 系统状态到0 时是什么状态?free_mem
  • 如何关联?cpu_1m_avg``free_mem
  • 平均值是多少?temperature``location_id

我们也发现这种方法认知混乱。我们是否真的收集了9个不同的时间序列,或者只是一个包含各种元数据和指标读数的数据集?

宽表模型

相比之下,TimescaleDB使用宽表模型,它反映了数据中的固有结构。

我们的宽表模型看起来与初始数据流完全一样:

时间戳 设备ID cpu_1m_avg Fri_mem 温度 LOCATION_ID dev_type
2017-01-01 01:02:00 ABC123 80 500MB 72 42 领域
2017-01-01 01:02:23 def456 90 400MB 64 42 屋顶
2017-01-01 01:02:30 ghi789 120 0MB 56 77 屋顶
2017-01-01 01:03:12 ABC123 80 500MB 72 42 领域
2017-01-01 01:03:35 def456 95 350MB 64 42 屋顶
2017-01-01 01:03:42 ghi789 100 100MB 56 77 屋顶

在这里,每一行都是一个新的读数,在给定的时间里有一组度量和元数据。这使我们能够保留数据中的关系,并提出比以前更有趣或探索性更强的问题。

当然,这不是一种新的格式:这是在关系数据库中常见的。这也是为什么我们发现这种格式更直观的原因。

与关系数据JOIN

TimescaleDB的数据模型与关系数据库还有另一个相似之处:它支持JOIN。具体来说,可以将附加元数据存储在辅助表中,然后在查询时使用该数据。

在我们的示例中,可以有一个单独的位置表,映射到该位置的其他元数据。例如:location_id

LOCATION_ID name 纬度 经度 邮政编码 地区
42 大中央车站 40.7527°N 73.9772°W 10017 NYC
77 大厅7 42.3593°N 71.0935°W 02139 马萨诸塞

然后在查询时,通过加入我们的两个表格,可以提出如下问题:10017 中我们的设备的平均值是多少?free_mem``zip_code

如果没有联接,则需要对数据进行非规范化并将所有元数据存储在每个测量行中。这造成数据膨胀,并使数据管理更加困难。

通过连接,可以独立存储元数据,并更轻松地更新映射。

例如,如果我们想更新我们的“区域”为77(例如从“马萨诸塞州”到“波士顿”),我们可以进行此更改,而不必返回并覆盖历史数据。location_id


架构与概念

TimescaleDB作为PostgreSQL的扩展实现,这意味着Timescale数据库在整个PostgreSQL实例中运行。该扩展模型允许数据库利用PostgreSQL的许多属性,如可靠性,安全性以及与各种第三方工具的连接性。同时,TimescaleDB通过在PostgreSQL的查询规划器,数据模型和执行引擎中添加钩子,充分利用扩展可用的高度自定义。

从用户的角度来看,TimescaleDB公开了一些看起来像单数表的称为hypertable的表,它们实际上是一个抽象或许多单独表的虚拟视图,称为

可改变和块

通过将hypertable的数据划分为一个或多个维度来创建块:所有可编程元素按时间间隔分区,并且可以通过诸如设备ID,位置,用户ID等的关键字进行分区。我们有时将此称为分区横跨“时间和空间”。

术语

Hypertables

与数据交互的主要点是一个可以抽象化的跨越所有空间和时间间隔的单个连续表,从而可以通过标准SQL查询它。

实际上,所有与TimescaleDB的用户交互都是使用可调整的。创建表格和索引,修改表格,插入数据,选择数据等都可以(也应该)在hypertable上执行。[[跳转到基本的SQL操作] [jumpSQL]]

一个带有列名和类型的标准模式定义了一个hypertable,其中至少一列指定了一个时间值,另一列(可选)指定了一个额外的分区键。

提示:请参阅我们的[数据模型] [],以进一步讨论组织数据的各种方法,具体取决于您的使用情况; 最简单和最自然的就像许多关系数据库一样在“宽桌”中。

单个TimescaleDB部署可以存储多个可更改的超文本,每个超文本具有不同的架构。

在TimescaleDB中创建一个可超过的值需要两个简单的SQL命令:( 使用标准的SQL语法),后面跟着。CREATE TABLE``SELECT create_hypertable()

时间索引和分区键自动创建在hypertable上,尽管也可以创建附加索引(并且TimescaleDB支持所有PostgreSQL索引类型)。

Chunk

在内部,TimescaleDB自动将每个可分区块分割成,每个块对应于特定的时间间隔和分区键空间的一个区域(使用散列)。这些分区是不相交的(非重叠的),这有助于查询计划人员最小化它必须接触以解决查询的组块集合。

每个块都使用标准数据库表来实现。(在PostgreSQL内部,这个块实际上是一个“父”可变的“子表”。)

块是正确的大小,确保表的索引的所有B树可以在插入期间驻留在内存中。这样可以避免在修改这些树中的任意位置时发生颠簸。

此外,通过避免过大的块,我们可以避免根据自动化保留策略删除删除的数据时进行昂贵的“抽真空”操作。运行时可以通过简单地删除块(内部表)来执行这些操作,而不是删除单独的行。


单节点与集群

TimescaleDB在单节点部署和集群部署(开发中)上执行这种广泛的分区。虽然分区传统上只用于在多台机器上扩展,但它也允许我们扩展到高写入速率(并改进了并行查询),即使在单台机器上也是如此。

TimescaleDB的当前开源版本仅支持单节点部署。值得注意的是,TimescaleDB的单节点版本已经在商用机器上基于超过100亿行高可用性进行了基准测试,而没有插入性能的损失。

单节点分区的好处

在单台计算机上扩展数据库性能的常见问题是内存和磁盘之间的显着成本/性能折衷。最终,我们的整个数据集不适合内存,我们需要将我们的数据和索引写入磁盘。

一旦数据足够大以至于我们无法将索引的所有页面(例如B树)放入内存中,那么更新树的随机部分可能会涉及从磁盘交换数据。像PostgreSQL这样的数据库为每个表索引保留一个B树(或其他数据结构),以便有效地找到该索引中的值。所以,当您索引更多列时,问题会复杂化。

但是,由于TimescaleDB创建的每个块本身都存储为单独的数据库表,因此其所有索引都只能建立在这些小得多的表中,而不是代表整个数据集的单个表。所以,如果我们正确地确定这些块的大小,我们可以将最新的表(和它们的B-树)完全放入内存中,并避免交换到磁盘的问题,同时保持对多个索引的支持。

有关TimescaleDB自适应空间/时间组块的动机和设计的更多信息,请参阅我们的[技术博客文章] [chunking]。


TimescaleDB 与 PostgreSQL 相比

TimescaleDB相对于存储时间序列数据的vanilla PostgreSQL或其他传统RDBMS提供了三大优势:

  1. 数据采集率要高得多,尤其是在数据库规模较大的情况下。
  2. 查询性能从相当于数量级更大
  3. 时间导向的功能。

而且由于TimescaleDB仍然允许您使用PostgreSQL的全部功能和工具 - 例如,与关系表联接,通过PostGIS进行地理空间查询,以及任何可以说PostgreSQL的连接器 - 都没有理由使用TimescaleDB来存储时间序列PostgreSQL节点中的数据。pg_dump``pg_restore

更高的写入速率

对于时间序列数据,TimescaleDB比PostgreSQL实现更高且更稳定的采集速率。正如我们的架构讨论中所描述的那样,只要索引表不能再适应内存,PostgreSQL的性能就会显着下降。

特别是,无论何时插入新行,数据库都需要更新表中每个索引列的索引(例如B树),这将涉及从磁盘交换一个或多个页面。在这个问题上抛出更多的内存只会拖延不可避免的,一旦您的时间序列表达到数千万行,每秒10K-100K +行的吞吐量就会崩溃到每秒数百行。

TimescaleDB通过大量利用时空分区来解决这个问题,即使在单台机器上运行也是如此。因此,对最近时间间隔的所有写入操作仅适用于保留在内存中的表,因此更新任何二级索引的速度也很快。

基准测试显示了这种方法的明显优势。数据库客户端插入适度大小的包含时间,设备标记集和多个数字指标(在本例中为10)的批量数据,以下10亿行(在单台计算机上)的基准测试模拟常见监控方案。在这里,实验在具有网络连接的SSD存储的标准Azure VM(DS4 v2,8核心)上执行。

img

我们观察到PostgreSQL和TimescaleDB对于前20M请求的启动速度大约相同(分别为106K和114K),或者每秒超过1M指标。然而,在大约五千万行中,PostgreSQL的表现开始急剧下降。在过去的100M行中,它的平均值仅为5K行/秒,而TimescaleDB保留了111K行/秒的吞吐量。

简而言之,Timescale在PostgreSQL的总时间的十五分之一中加载了十亿行数据库,并且吞吐量超过了PostgreSQL在这些较大规模时的20倍

我们的TimescaleDB基准测试表明,即使使用单个磁盘,它仍能保持超过10B行的恒定性能。

此外,用户在一台计算机上利用多个磁盘时,可以为数以十亿计的行提供稳定的性能,无论是采用RAID配置,还是使用TimescaleDB支持在多个磁盘上传播单个超级缓存(通过多个表空间传统的PostgreSQL表)。

卓越或类似的查询性能

在单磁盘机器上,许多只执行索引查找或表扫描的简单查询在PostgreSQL和TimescaleDB之间表现相似。

例如,在具有索引时间,主机名和CPU使用率信息的100M行表上,对于每个数据库,以下查询将少于5毫秒:

SELECT date_trunc('minute', time) AS minute, max(user_usage)
  FROM cpu
  WHERE hostname = 'host_1234'
    AND time >= '2017-01-01 00:00' AND time < '2017-01-01 01:00'
  GROUP BY minute ORDER BY minute;

涉及对索引进行基本扫描的类似查询在两者之间也是等效的:

SELECT * FROM cpu
  WHERE usage_user > 90.0
    AND time >= '2017-01-01' AND time < '2017-01-02';

涉及基于时间的GROUP BY的较大查询 - 在面向时间的分析中很常见 - 通常在TimescaleDB中实现卓越的性能。

例如,当整个(超)表为100M行时,接触33M行的以下查询在TimescaleDB中速度提高5倍,而在1B行时速度提高约2倍。

SELECT date_trunc('hour', time) as hour,
    hostname, avg(usage_user)
  FROM cpu
  WHERE time >= '2017-01-01' AND time < '2017-01-02'
  GROUP BY hour, hostname
  ORDER BY hour;

此外,可以约时间订购专理等查询可以在TimescaleDB更好的性能。

例如,TimescaleDB引入了基于时间的“合并追加”优化,以最小化必须处理以执行以下操作的组的数量(考虑到时间已经被排序)。对于我们的100M行表,这导致查询延迟比PostgreSQL快396倍(82ms vs. 32566ms)。

SELECT date_trunc('minute', time) AS minute, max(usage_user)
  FROM cpu
  WHERE time < '2017-01-01'
  GROUP BY minute
  ORDER BY minute DESC
  LIMIT 5;

我们将很快发布PostgreSQL和TimescaleDB之间更完整的基准测试比较,以及复制我们基准的软件。

我们的查询基准测试的高级结果是,对于几乎所有我们已经尝试过的查询,TimescaleDB都可以为PostgreSQL 实现类似或优越(或极其优越)的性能

与PostgreSQL相比,TimescaleDB的一项额外成本是更复杂的计划(假设单个可超集可由许多块组成)。这可以转化为几毫秒的计划时间,这对于非常低延迟的查询(<10ms)可能具有不成比例的影响。

时间导向的功能

TimescaleDB还包含许多在传统关系数据库中没有的时间导向功能。这些包括特殊查询优化(如上面的合并附加),它为面向时间的查询以及其他面向时间的函数(其中一些在下面列出)提供了一些巨大的性能改进。

面向时间的分析

TimescaleDB包含面向时间分析的功能,其中包括以下一些功能:

  • 时间分段:标准功能的更强大的版本,它允许任意的时间间隔(例如5分钟,6小时等),以及灵活的分组和偏移,而不仅仅是第二,分钟,小时等。date_trunc
  • 最后第一个聚合:这些函数允许您按另一个列的顺序获取一列的值。例如,将返回基于组内时间的最新温度值(例如,一小时)。last(temperature, time)

这些类型的函数能够实现非常自然的面向时间的查询。例如,以下财务查询打印每个资产的开盘价,收盘价,最高价和最低价。

SELECT time_bucket('3 hours', time) AS period
    asset_code,
    first(price, time) AS opening, last(price, time) AS closing,
    max(price) AS high, min(price) AS low
  FROM prices
  WHERE time > NOW() - interval '7 days'
  GROUP BY period, asset_code
  ORDER BY period DESC, asset_code;

通过辅助列进行排序的能力(甚至不同于集合)能够实现一些强大的查询类型。例如,财务报告中常见的技术是“双时态建模”,它们分别从与记录观察时间有关的观察时间的原因出发。在这样的模型中,更正插入为新行(具有更新的time_recorded字段),并且不替换现有数据。last

以下查询返回每个资产的每日价格,按最新记录的价格排序。

SELECT time_bucket('1 day', time) AS day,
    asset_code,
    last(price, time_recorded)
  FROM prices
  WHERE time > '2017-01-01'
  GROUP BY day, asset_code
  ORDER BY day DESC, asset_code;

有关TimescaleDB当前(和增长中)时间功能列表的更多信息,请参阅我们的API

面向时间的数据管理

TimescaleDB还提供了某些在PostgreSQL中不易获取或执行的数据管理功能。例如,在处理时间序列数据时,数据通常会很快建立起来。因此,您希望按照“仅存储一周原始数据”的方式编写数据保留策略。

实际上,将这与使用连续聚合相结合是很常见的,因此您可以保留两个可改写的数据:一个包含原始数据,另一个包含已经汇总为精细或小时聚合的数据。然后,您可能需要在两个(超)表上定义不同的保留策略,以长时间存储汇总的数据。

TimescaleDB允许通过其功能有效地删除级别的旧数据,而不是行级别的旧数据。drop_chunks

SELECT drop_chunks(interval '7 days', 'conditions');

这将删除只包含比此持续时间早的数据的可超级“条件”中的所有块(文件),而不是删除块中的任何单独数据行。这避免了底层数据库文件中的碎片,这反过来又避免了在非常大的表格中可能过于昂贵的抽真空的需要。

有关更多详细信息,请参阅我们的数据保留讨论,包括如何自动执行数据保留策略。


TimescaleDB之于NoSQL

与一般的NoSQL数据库(例如MongoDB,Cassandra)或更专门的时间导向数据库(例如InfluxDB,KairosDB)相比,TimescaleDB提供了定性和定量差异:

  • 普通SQL:即使在规模上,TimescaleDB也可以为时间序列数据提供标准SQL查询的功能。大多数(所有?)NoSQL数据库都需要学习新的查询语言或使用最好的“SQL-ish”(它仍然与现有工具兼容)。
  • 操作简单:使用TimescaleDB,您只需要为关系数据和时间序列数据管理一个数据库。否则,用户通常需要将数据存储到两个数据库中:“正常”关系数据库和第二个时间序列数据库。
  • JOIN可以通过关系数据和时间序列数据执行。
  • 对于不同的查询集,查询性能更快。在NoSQL数据库中,更复杂的查询通常是缓慢或全表扫描,而有些数据库甚至无法支持许多自然查询。
  • **像PostgreSQL一样管理,**并继承对不同数据类型和索引(B树,哈希,范围,BRIN,GiST,GIN)的支持。
  • 对地理空间数据的本地支持:存储在TimescaleDB中的数据可以利用PostGIS的几何数据类型,索引和查询。
  • 第三方工具:TimescaleDB支持任何可以说SQL的东西,包括像Tableau这样的BI工具。

何时使用TimescaleDB?

然后,如果以下任一情况属实,则可能不想使用TimescaleDB:

  • 简单的读取要求:如果您只需要快速键值查找或单列累积,则内存或列导向数据库可能更合适。前者显然不能扩展到相同的数据量,但是,后者的性能明显低于更复杂的查询。
  • 非常稀疏或非结构化的数据:尽管TimescaleDB利用PostgreSQL对JSON / JSONB格式的支持,并且相当有效地处理稀疏性(空值的位图),但在某些情况下,无模式体系结构可能更合适。
  • 重要的压缩是一个优先事项:基准测试显示在ZFS上运行的TimescaleDB获得约4倍的压缩率,但压缩优化的列存储可能更适合于更高的压缩率。
  • 不频繁或离线分析:如果响应时间较慢(或响应时间限于少量预先计算的度量标准),并且您不希望许多应用程序/用户同时访问该数据,则可以避免使用数据库,而只是将数据存储在分布式文件系统中。

安装

Mac下直接使用 brew 安装,最省事的方法,可以连PostgreSQL和PostGIS一起装了。

# Add our tap
brew tap timescale/tap

# To install
brew install timescaledb

# Post-install to move files to appropriate place
/usr/local/bin/timescaledb_move.sh

在 EL 系操作系统下

sudo yum install -y https://download.postgresql.org/pub/repos/yum/9.6/redhat/fedora-7.2-x86_64/pgdg-redhat10-10-1.noarch.rpm


wget https://timescalereleases.blob.core.windows.net/rpm/timescaledb-0.9.0-postgresql-9.6-0.x86_64.rpm
# For PostgreSQL 10:
wget https://timescalereleases.blob.core.windows.net/rpm/timescaledb-0.9.0-postgresql-10-0.x86_64.rpm

# To install
sudo yum install timescaledb

配置

postgresql.conf中添加以下配置,即可在PostgreSQL启动时加载该插件。

shared_preload_libraries = 'timescaledb'

在数据库中执行以下命令以创建timescaledb扩展。

CREATE EXTENSION timescaledb;

调参

对timescaledb比较重要的参数是锁的数量。

TimescaleDB在很大程度上依赖于表分区来扩展时间序列工作负载,这对锁管理有影响。在查询过程中,可修改需要在许多块(子表)上获取锁,这会耗尽所允许的锁的数量的默认限制。这可能会导致如下警告:

psql: FATAL:  out of shared memory
HINT:  You might need to increase max_locks_per_transaction.

为了避免这个问题,有必要修改默认值(通常是64),增加最大锁的数量。由于更改此参数需要重新启动数据库,因此建议预估未来的增长。对大多数情况,推荐配置为:max_locks_per_transaction

max_locks_per_transaction = 2 * num_chunks

num_chunks是在**超级表(HyperTable)中可能存在的块(chunk)**数量上限。

这种配置是考虑到对超级表查询可能申请锁的数量粗略等于超级表中的块数量,如果使用索引的话还要翻倍。

注意这个参数并不是精确的限制,它只是控制每个事物中平均的对象锁数量。


创建超表

为了创建一个可改写的,你从一个普通的SQL表开始,然后通过函数(API参考)将它转换为一个可改写的。create_hypertable

以下示例创建一个可随时间跨越一系列设备来跟踪温度和湿度的可调整高度。

-- We start by creating a regular SQL table

CREATE TABLE conditions (
  time        TIMESTAMPTZ       NOT NULL,
  location    TEXT              NOT NULL,
  temperature DOUBLE PRECISION  NULL,
  humidity    DOUBLE PRECISION  NULL
);

接下来,把它变成一个超表:create_hypertable

-- This creates a hypertable that is partitioned by time
--   using the values in the `time` column.

SELECT create_hypertable('conditions', 'time');

-- OR you can additionally partition the data on another
--   dimension (what we call 'space partitioning').
-- E.g., to partition `location` into 4 partitions:

SELECT create_hypertable('conditions', 'time', 'location', 4);

插入和查询

通过普通的SQL 命令将数据插入到hypertable中,例如使用毫秒时间戳:INSERT

INSERT INTO conditions(time, location, temperature, humidity)
  VALUES (NOW(), 'office', 70.0, 50.0);

同样,查询数据是通过正常的SQL 命令完成的。SELECT

SELECT * FROM conditions ORDER BY time DESC LIMIT 100;

SQL 和命令也按预期工作。有关使用TimescaleDB标准SQL接口的更多示例,请参阅我们的使用页面UPDATE``DELETE

故障档案:PostgreSQL事务号回卷

XID WrapAround也许是PostgreSQL特有的一种故障

遇到一次磁盘坏块导致的事务回卷故障:

  • 主库(PostgreSQL 9.3)磁盘坏块导致几张表上的VACUUM FREEZE执行失败。
  • 无法回收老旧事务ID,导致整库事务ID濒临用尽,数据库进入自我保护状态不可用。
  • 磁盘坏块导致手工VACUUM抢救不可行。
  • 提升从库后,需要紧急VACUUM FREEZE才能继续服务,进一步延长了故障时间。
  • 主库进入保护状态后提交日志(clog)没有及时复制到从库,从库产生存疑事务拒绝服务。

摘要

这是一个即将下线老旧库,疏于管理。坏块征兆在一周前就已经出现,没有及时跟进年龄。 通常AutoVacuum会保证很难出现这种故障,但一旦出现往往意味着祸不单行…让救火更加困难了……


背景

PostgreSQL实现了快照隔离(Snapshot Isolation),每个事务开始时都能获取数据库在该时刻的快照(也就是只能看到过去事务提交的结果,看不见后续事务提交的结果)。这一强大的功能是通过MVCC实现的,但也引入了额外复杂度,例如事务ID回卷问题。

事务ID(xid)是用于标识事务的32位无符号整型数值,递增分配,其中值0,1,2为保留值,溢出后回卷为3重新开始。事务ID之间的大小关系决定了事务的先后顺序

/*
 * TransactionIdPrecedes --- is id1 logically < id2?
 */
bool
TransactionIdPrecedes(TransactionId id1, TransactionId id2)
{
	/*
	 * If either ID is a permanent XID then we can just do unsigned
	 * comparison.  If both are normal, do a modulo-2^32 comparison.
	 */
	int32		diff;

	if (!TransactionIdIsNormal(id1) || !TransactionIdIsNormal(id2))
		return (id1 < id2);

	diff = (int32) (id1 - id2);
	return (diff < 0);
}

xid-wrap-around

可以将xid的取值域视为一个整数环,但刨除0,1,2三个特殊值。0代表无效事务ID,1代表系统事务ID,2代表冻结事务ID。特殊的事务ID比任何普通事务ID小。而普通事务ID之间的比较可参见上图:它取决于两个事务ID的差值是否超出INT32_MAX。对任意一个事务ID,都有约21亿个位于过去的事务和21亿个位于未来的事务。

xid不仅仅存在于活跃的事务上,xid会影响所有的元组:事务会给自己影响的元组打上自己的xid作为记号。每个元组都会用(xmin, xmax)来标识自己的可见性,xmin 记录了最后写入(INSERT, UPDATE)该元组的事务ID,而xmax记录了删除或锁定该元组的事务ID。每个事务只能看见由先前事务提交(xmin < xid)且未被删除的元组(从而实现快照隔离)。

如果一个元组是由很久很久以前的事务产生的,那么在数据库的例行VACUUM FREEZE时,会找出当前活跃事务中最老的xid,将所有xmin < xid的元组的xmin标记为2,也就是冻结事务ID。这意味着这条元组跳出了这个比较环,比所有普通事务ID都要小,所以能被所有的事务看到。通过清理,数据库中最老的xid会不断追赶当前的xid,从而避免事务回卷。

数据库或表的年龄(age),定义为当前事务ID与数据库/表中存在最老的xid之差。最老的xid可能来自一个持续了几天的超长事务。也可能来自几天前老事务写入,但尚未被冻结的元组中。如果数据库的年龄超过了INT32_MAX,灾难性情况就发生了。过去的事务变成了未来的事务,过去事务写入的元组将变得不可见。

为了避免这种情况,需要避免超长事务与定期VACUUM FREEZE冻结老元组。如果单库在平均3万TPS的超高负载下,20亿个事务号一整天内就会用完。在这样的库上就无法执行一个超过一天的超长事务。而如果由于某种原因,自动清理工作无法继续进行,一天之内就可能遇到事务回卷。

9.4之后对FREEZE的机制进行了修改,FREEZE使用元组中单独的标记位来表示。

PostgreSQL应对事务回卷有自我保护机制。当临界事务号还剩一千万时,会进入紧急状态。

查询

查询当前所有表的年龄,SQL 语句如下:

SELECT c.oid::regclass as table_name,
     greatest(age(c.relfrozenxid),age(t.relfrozenxid)) as age
FROM pg_class c
LEFT JOIN pg_class t ON c.reltoastrelid = t.oid
WHERE c.relkind IN ('r', 'm') order by 2 desc;

查询数据库的年龄,SQL语句如下:

SELECT *, age(datfrozenxid) FROM pg_database; 

清理

执行VACUUM FREEZE可以冻结老旧事务的ID

set vacuum_cost_limit = 10000;
set vacuum_cost_delay = 0;

VACUUM FREEZE VERBOSE;

可以针对特定的表进行VACUUM FREEZE,抓主要矛盾。

问题

通常来说,PostgreSQL的AutoVacuum机制会自动执行FREEZE操作,冻结老旧事务的ID,从而降低数据库的年龄。因此一旦出现事务ID回卷故障,通常祸不单行,意味着vacuum机制可能被其他的故障挡住了。

目前遇到过三种触发事务ID回卷故障的情况

IDLE IN TRANSACTION

空闲事务会阻塞VACUUM FREEZE老旧元组。

解决方法很简单,干掉IDEL IN TRANSACTION的长事务然后执行VACUUM FREEZE即可。

存疑事务

clog损坏,或没有复制到从库,会导致相关表进入事务存疑状态,拒绝服务。

需要手工拷贝,或使用dd生成虚拟的clog来强行逃生。

磁盘/内存坏块

因为坏块导致的无法VACUUM比较尴尬。

需要通过二分法定位并跳过脏数据,或者干脆直接抢救从库。

注意事项

紧急抢救的时候,不要整库来,按照年龄大小降序挨个清理表会更快。

注意当主库进入事务回卷保护状态时,从库也会面临同样的问题。

解决方案

AutoVacuum参数配置

年龄监控

[未完待续]

故障档案:序列号消耗过快导致整型溢出

如果您在表上用了Interger的序列号,最好还是考虑一下可能溢出的情况。

0x01 概览

  • 故障表现:

    • 某张使用自增列的表序列号涨至整型上限,无法写入。
    • 发现表中的自增列存在大量空洞,很多序列号没有对应记录就被消耗掉了。
  • 故障影响:非核心业务某表,10分钟左右无法写入。

  • 故障原因:

    • 内因:使用了INTEGER而不是BIGINT作为主键类型。
    • 外因:业务方不了解SEQUENCE的特性,执行大量违背约束的无效插入,浪费了大量序列号。
  • 修复方案:

    • 紧急操作:降级线上插入函数为直接返回,避免错误扩大。
    • 应急方案:创建临时表,生成5000万个浪费空洞中的临时ID,修改插入函数,变为先检查再插入,并从该临时ID表中取ID。
    • 解决方案:执行模式迁移,将所有相关表的主键与外键类型更新为Bigint。

原因分析

内因:类型使用不当

业务使用32位整型作为主键自增ID,而不是Bigint。

  • 除非有特殊的理由,主键,自增列都应当使用BIGINT类型。

外因:不了解Sequence的特性

  • 非要使用如果会频繁出现无效插入,或频繁使用UPSERT,需要关注Sequence的消耗问题。
  • 可以考虑使用自定义发号函数(类Snowflake)

在PostgreSQL中,Sequence是一个比较特殊的类型。特别是,在事务中消耗的序列号不会回滚。因为序列号能被并发地获取,不存在逻辑上合理的回滚操作。

在生产中,我们就遇到了这样一种故障。有一张表直接使用了Serial作为主键:

CREATE TABLE sample(
	id   	SERIAL PRIMARY KEY,
	name  	TEXT UNIQUE,
    value   INTEGER
);

而插入的时候是这样的:

INSERT INTO sample(name, value) VALUES(?,?)

当然,实际上由于name列上的约束,如果插入了重复的name字段,事务就会报错中止并回滚。然而序列号已经被消耗掉了,即使事务回滚了,序列号也不会回滚。

vonng=# INSERT INTO sample(name, value) VALUES('Alice',1);
INSERT 0 1
vonng=# SELECT currval('sample_id_seq'::RegClass);
 currval
---------
       1
(1 row)

vonng=# INSERT INTO sample(name, value) VALUES('Alice',1);
ERROR:  duplicate key value violates unique constraint "sample_name_key"
DETAIL:  Key (name)=(Alice) already exists.
vonng=# SELECT currval('sample_id_seq'::RegClass);
 currval
---------
       2
(1 row)

vonng=# BEGIN;
BEGIN
vonng=# INSERT INTO sample(name, value) VALUES('Alice',1);
ERROR:  duplicate key value violates unique constraint "sample_name_key"
DETAIL:  Key (name)=(Alice) already exists.
vonng=# ROLLBACK;
ROLLBACK
vonng=# SELECT currval('sample_id_seq'::RegClass);
 currval
---------
       3

因此,当执行的插入有大量重复,即有大量的冲突时,可能会导致序列号消耗的非常快。出现大量空洞!

另一个需要注意的点在于,UPSERT操作也会消耗序列号!从表现上来看,这就意味着即使实际操作是UPDATE而不是INSERT,也会消耗一个序列号。

vonng=# INSERT INTO sample(name, value) VALUES('Alice',3) ON CONFLICT(name) DO UPDATE SET value = EXCLUDED.value;
INSERT 0 1
vonng=# SELECT currval('sample_id_seq'::RegClass);
 currval
---------
       4
(1 row)

vonng=# INSERT INTO sample(name, value) VALUES('Alice',4) ON CONFLICT(name) DO UPDATE SET value = EXCLUDED.value;
INSERT 0 1
vonng=# SELECT currval('sample_id_seq'::RegClass);
 currval
---------
       5
(1 row)

解决方案

线上所有查询与插入都使用存储过程。非核心业务,允许接受短暂的写入失效。首先降级插入函数,避免错误影响AppServer。因为该表存在大量依赖,无法直接修改其类型,需要一个临时解决方案。

检查发现ID列中存在大量空洞,每10000个序列号中实际只有1%被使用。因此使用下列函数生成临时ID表。

CREATE TABLE sample_temp_id(id INTEGER PRIMARY KEY);

-- 插入约5000w个临时ID,够用十几天了。
INSERT INTO sample_temp_id
    SELECTT generate_series(2000000000,2100000000) as id EXCEPT SELECT id FROM sample;

-- 修改插入的存储过程,从临时表中Pop出ID。
DELETE FROM sample_temp_id WHERE id = (SELECT id FROM sample_temp_id FOR UPDATE LIMIT 1) RETURNING id;

修改插入存储过程,每次从临时ID表中取一个ID,显式插入表中。


经验与教训

能用 BIGINT 的就别用 INT,另外 UPSERT 的时候需要特别注意。

监控PG中的表大小

PostgreSQL中的表对应着许多物理文件,本文介绍如何统计一张表在PostgreSQL的实际大小

表的空间布局

宽泛意义上的表(Table),包含了本体表TOAST表两个部分:

  • 本体表,存储关系本身的数据,即狭义的关系,relkind='r'
  • TOAST表,与本体表一一对应,存储过大的字段,relinkd='t'

而每个表,又由主体索引两个**关系(Relation)**组成(对本体表而言,可以没有索引关系)

  • 主体关系:存储元组。
  • 索引关系:存储索引元组。

每个关系又可能会有四种分支

  • main: 关系的主文件,编号为0

  • fsm:保存关于main分支中空闲空间的信息,编号为1

  • vm:保存关于main分支中可见性的信息,编号为2

  • init:用于不被日志记录(unlogged)的的表和索引,很少见的特殊分支,编号为3

每个分支存储为磁盘上的一到多个文件:超过1GB的文件会被划分为最大1GB的多个段。

综上所述,一个表并不是看上去那么简单,它由几个关系组成:

  • 本体表的主体关系(单个)
  • 本体表的索引(多个)
  • TOAST表的主体关系(单个)
  • TOAST表的索引(单个)

而每个关系实际上可能又包含了1~3个分支:main(必定存在),fsmvm

获取表的附属关系

使用下列查询,列出所有的分支oid。

select
  nsp.nspname,
  rel.relname,
  rel.relnamespace    as nspid,
  rel.oid             as relid,
  rel.reltoastrelid   as toastid,
  toastind.indexrelid as toastindexid,
  ind.indexes
from
  pg_namespace nsp
  join pg_class rel on nsp.oid = rel.relnamespace
  , LATERAL ( select array_agg(indexrelid) as indexes from pg_index where indrelid = rel.oid) ind
  , LATERAL ( select indexrelid from pg_index where indrelid = rel.reltoastrelid) toastind
where nspname not in ('pg_catalog', 'information_schema') and rel.relkind = 'r';
 nspname |  relname   |  nspid  |  relid  | toastid | toastindexid |      indexes
---------+------------+---------+---------+---------+--------------+--------------------
 public  | aoi        | 4310872 | 4320271 | 4320274 |      4320276 | {4325606,4325605}
 public  | poi        | 4310872 | 4332324 | 4332327 |      4332329 | {4368886}

统计函数

PG提供了一系列函数用于确定各个部分占用的空间大小。

函数 统计口径
pg_total_relation_size(oid) 整个关系,包括表,索引,TOAST等。
pg_indexes_size(oid) 关系索引部分所占空间
pg_table_size(oid) 关系中除索引外部分所占空间
pg_relation_size(oid) 获取一个关系主文件部分的大小(main分支)
pg_relation_size(oid, 'main') 获取关系main分支大小
pg_relation_size(oid, 'fsm') 获取关系fsm分支大小
pg_relation_size(oid, 'vm') 获取关系vm分支大小
pg_relation_size(oid, 'init') 获取关系init分支大小

虽然在物理上一张表由这么多文件组成,但从逻辑上我们通常只关心两个东西的大小:表与索引。因此这里要用到的主要就是两个函数:pg_indexes_sizepg_table_size,对普通表其和为pg_total_relation_size

而通常表大小的部分可以这样计算:

 pg_table_size(relid)
 	= pg_relation_size(relid, 'main') 
 	+ pg_relation_size(relid, 'fsm') 
 	+ pg_relation_size(relid, 'vm') 
 	+ pg_total_relation_size(reltoastrelid)
 	
 pg_indexes_size(relid)
 	= (select sum(pg_total_relation_size(indexrelid)) where indrelid = relid)

注意,TOAST表也有自己的索引,但有且仅有一个,因此使用pg_total_relation_size(reltoastrelid)可计算TOAST表的整体大小。

例:统计某一张表及其相关关系UDTF

SELECT
  oid,
  relname,
  relnamespace::RegNamespace::Text               as nspname,
  relkind                                        as relkind,
  reltuples                                      as tuples,
  relpages                                       as pages,
  pg_total_relation_size(oid)                    as size
  FROM pg_class
WHERE oid = ANY(array(SELECT 16418 as id -- main
UNION ALL SELECT indexrelid FROM pg_index WHERE indrelid = 16418 -- index
UNION ALL SELECT reltoastrelid FROM pg_class WHERE oid = 16418)); -- toast

可以将其包装为UDTF:pg_table_size_detail,便于使用:

CREATE OR REPLACE FUNCTION pg_table_size_detail(relation RegClass)
  RETURNS TABLE(
    id      oid,
    pid     oid,
    relname name,
    nspname text,
    relkind "char",
    tuples  bigint,
    pages   integer,
    size    bigint
  )
AS $$
BEGIN
  RETURN QUERY
  SELECT
    rel.oid,
    relation::oid,
    rel.relname,
    rel.relnamespace :: RegNamespace :: Text as nspname,
    rel.relkind                              as relkind,
    rel.reltuples::bigint                    as tuples,
    rel.relpages                             as pages,
    pg_total_relation_size(oid)              as size
  FROM pg_class rel
  WHERE oid = ANY (array(
      SELECT relation as id -- main
      UNION ALL SELECT indexrelid FROM pg_index WHERE indrelid = relation -- index
      UNION ALL SELECT reltoastrelid FROM pg_class WHERE oid = relation)); -- toast
END;
$$
LANGUAGE PlPgSQL;

SELECT * FROM pg_table_size_detail(16418);

返回结果样例:

geo=# select * from  pg_table_size_detail(4325625);
   id    |   pid   |        relname        | nspname  | relkind |  tuples  |  pages  |    size
---------+---------+-----------------------+----------+---------+----------+---------+-------------
 4325628 | 4325625 | pg_toast_4325625      | pg_toast | t       |   154336 |   23012 |   192077824
 4419940 | 4325625 | idx_poi_adcode_btree  | gaode    | i       | 62685464 |  172058 |  1409499136
 4419941 | 4325625 | idx_poi_cate_id_btree | gaode    | i       | 62685464 |  172318 |  1411629056
 4419942 | 4325625 | idx_poi_lat_btree     | gaode    | i       | 62685464 |  172058 |  1409499136
 4419943 | 4325625 | idx_poi_lon_btree     | gaode    | i       | 62685464 |  172058 |  1409499136
 4419944 | 4325625 | idx_poi_name_btree    | gaode    | i       | 62685464 |  335624 |  2749431808
 4325625 | 4325625 | gaode_poi             | gaode    | r       | 62685464 | 2441923 | 33714962432
 4420005 | 4325625 | idx_poi_position_gist | gaode    | i       | 62685464 |  453374 |  3714039808
 4420044 | 4325625 | poi_position_geohash6 | gaode    | i       | 62685464 |  172058 |  1409499136

例:关系大小详情汇总

select
  nsp.nspname,
  rel.relname,
  rel.relnamespace    as nspid,
  rel.oid             as relid,
  rel.reltoastrelid   as toastid,
  toastind.indexrelid as toastindexid,
  pg_total_relation_size(rel.oid)  as size,
  pg_relation_size(rel.oid) + pg_relation_size(rel.oid,'fsm') 
  + pg_relation_size(rel.oid,'vm') as relsize,
  pg_indexes_size(rel.oid)         as indexsize,
  pg_total_relation_size(reltoastrelid) as toastsize,
  ind.indexids,
  ind.indexnames,
  ind.indexsizes
from pg_namespace nsp
  join pg_class rel on nsp.oid = rel.relnamespace
  ,LATERAL ( select indexrelid from pg_index where indrelid = rel.reltoastrelid) toastind
  , LATERAL ( select  array_agg(indexrelid) as indexids,
                      array_agg(indexrelid::RegClass) as indexnames,
                      array_agg(pg_total_relation_size(indexrelid)) as indexsizes
              from pg_index where indrelid = rel.oid) ind
where nspname not in ('pg_catalog', 'information_schema') and rel.relkind = 'r';

PgAdmin安装配置

PgAdmin是一个管理PostgreSQL的GUI程序,用python写成,但实在是过于古早,需要一些额外配置。

PgAdmin4的安装与配置

PgAdmin是一个为PostgreSQL定制设计的GUI。用起来很不错。可以以本地GUI程序或者Web服务的方式运行。因为Retina屏幕下面PgAdmin依赖的GUI组件显示效果有点问题,这里主要介绍如何以Web服务方式(Python Flask)配置运行PgAdmin4。

下载

PgAdmin可以从官方FTP下载。

postgresql网站FTP目录地址

wget https://ftp.postgresql.org/pub/pgadmin3/pgadmin4/v1.1/source/pgadmin4-1.1.tar.gz
tar -xf pgadmin4-1.1.tar.gz && cd pgadmin4-1.1/

也可以从官方Git Repo下载:

git clone git://git.postgresql.org/git/pgadmin4.git
cd pgadmin4

安装依赖

首先,需要安装Python,2或者3都可以。这里使用管理员权限安装Anaconda3发行版作为示例。

首先创建一个虚拟环境,当然直接上物理环境也是可以的……

conda create -n pgadmin python=3 anaconda

根据对应的Python版本,按照对应的依赖文件安装依赖。

sudo pip install -r requirements_py3.txt

配置选项

首先执行初始化脚本,创立PgAdmin的管理员用户。

python web/setup.py

按照提示输入Email和密码即可。

编辑web/config.py,修改默认配置,主要是改监听地址和端口。

DEFAULT_SERVER = 'localhost'
DEFAULT_SERVER_PORT = 5050

修改监听地址为0.0.0.0以便从任意IP访问。 按需修改端口。

故障档案:快慢不匀雪崩

最近发生了一起匪夷所思的故障,某数据库切走了一半的数据量和负载,结果却因为负载变大被打挂了。

最近发生了一起匪夷所思的故障,某数据库切走了一半的数据量和负载。

其他什么都没变,本来还好;压力减小,却在高峰期陷入濒死状态,完全不符合直觉。

但正如福尔摩斯所说,当你排除掉一切不可能之后,剩下的即使再离奇,也是事实。

一、摘要

某日凌晨4点,进行了核心库进行分库迁移,拆走一半的表和一半的查询负载,原库节点规模不变。

当日晚高峰核心库所有热备库(15台)出现连接堆积,压力暴涨,针对性地清理慢查询不再起效。

无差别持续杀查询,有立竿见影的救火效果(22:30后),且暂停后故障立刻重现(22:48),杀至高峰期结束。

匪夷所思的是,移走了表(数据量减半),移走了负载(TPS减半),其他什么都没变竟然会导致压力上升?

二、现象

CPU使用率的正常水位在25%,警戒水位在45%,极限水位在80%。故障期间所有从库飙升至极限水位。

PostgreSQL连接数发生暴涨,通常5~10个左右的数据库连接就足够撑起所有流量,连接池的最大连接数为100。

pg-conn

pgbouncer连接池平均响应时间平时在500μs左右,故障期间飙升至百毫秒级别。

pgb-time

故障期间,数据库TPS发生显著下滑。进行杀查询抢救后恢复,但处于剧烈抖动状态。

故障期间,两个函数的执行时间发生显著恶化,从几百微秒劣化至几十毫秒。

故障期间,复制延迟显著上升,开始出现GB级别的复制延迟,业务指标出现显著下滑。

开始杀查询后,大部分指标恢复,但一旦停止马上重新开始出现(22:48尝试性停止故障恢复)。

三、原因分析

【表因】:所有从库连接池被打满,连接被慢查询占据,快查询无法执行,发生连接堆积。

【主内因】:两个函数的并发数增大到30左右时,性能会发生急剧劣化,变为慢查询(500μs到100ms)。

【副内因】:后端与数据库没有合理的超时取消机制,断路器会放大故障。

【外因】:分库后,快查询比例下降,导致特定查询的相对比例上升,并发数增大至临界点。恶化为慢查询。

表因:连接打满

故障的表因是数据库连接池被打满,产生大量堆积连接。进而新连接无法建立,拒绝服务。

原理

数据库配置的最大连接数max_connections = 100,一个连接实质上就是一个数据库进程。机器能够负载的实际数据库进程数目与查询类型高度相关:如果全是在1ms内的快查询,几百上千个链接都是可以的(生产环境中的正常情况)。而如果全都是CPU和IO密集的慢查询,则最大支持的连接数可能只有(48 * 80% ≈ 38)个左右。

在生产环境中使用了连接池,正常情况下5~10个实际数据库连接就可以支撑起所有快查询。然而一旦有大量慢查询持续进入,长期占用了活跃连接,那么快查询就会排队等待发生堆积,连接池进而启动更多实际数据库的连接,而这些连接上的快查询很快就会执行完毕,最终仍然会被不断进入的慢查询占据。最终导致约100个实际数据库连接都在执行CPU/IO密集的慢查询(max_pool_size=100),CPU暴涨,进一步恶化情况。

证据

连接池活跃连接数

pgb-active-conn-5888653

连接池排队连接数

pgb-wait-conn

数据库后端连接数

pg-conn

修复

持续地无差别杀掉所有数据库活跃连接,能起到很好的治标效果,且对业务指标影响很小。

但杀掉连接(pg_terminate_backend)会导致连接池重连,更好的做法是取消查询(pg_cancel_backend

因为快查询走的快,卡在后端实际连接上执行的查询极大概率都是慢查询,这时候无差别取消所有查询命中的绝大多数都是慢查询。杀查询能将连接释放给快查询使用,让应用苟活下去,但必须持续不断的杀才有效果,因为用不了零点几秒,慢查询就会重新占据活跃连接。

使用psql执行以下SQL,每隔0.5秒取消所有活跃查询。

SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE application_name != 'psql' \watch 0.5

解决方案:调整了连接池的后端最大连接数,进行快慢分离,强制所有批量任务与慢查询走离线从库。

主内因:并行恶化

故障的主内因是两个函数的执行时间在并行数增大时发生恶化。

原理

故障的直接导火索是这两个函数劣化为慢查询。经过单独的压力测试,这两个函数随着并行执行数增高,发生急剧的性能劣化,阈值点为约30个并发进程。(因为所有进程只执行同一个查询,所以可认为并行数等于并发数)

证据

图:故障期间函数平均执行时间出现明显飙升

图:在不同并行数下压测该函数能达到的最大QPS

degenerate-5889931

修复

  • 优化函数执行逻辑,将该函数的执行时间优化至原来的一半(最大QPS翻倍)。
  • 新增五台从库,进一步降低单机负载。

副内因:没有超时

故障的副内因在于没有合理的超时取消机制,查询不会因为超时被取消,是发生堆积的必要条件。

原理

发生查询超时时,应用层的合理行为是

  • 直接返回,报错。
  • 进行若干次重试(在高峰期可以考虑直接返回错误)

查询等待超出合理范围的时间却不取消,就会导致连接堆积。抛弃返回结果并无法取消已经发出的查询,客户端需要主动Cancel Request。Go1.7后的标准实践是通过context包与database/sql提供的QueryContext/ExecContext进行超时控制。

数据库端和连接池端可以配置语句超时(statement_timeout),但实践表明这样的操作很容易误杀查询。

手动杀灭能够立竿见影地治标,但它本质上是一种人工超时取消机制。稳健的系统应当有自动化的超时取消机制,这需要在数据库、连接池、应用多个层次协同解决。

检视后端使用的驱动代码,发现pg.v3 pg.v5并没有真正意义上的查询超时机制,超时参数不过是为net.Conn加上的TCP超时(通常在分钟级别)。

修复

  • 建议使用github.com/jackc/pgxgithub.com/go-pg/pg 第六版驱动替代现有驱动
  • 使用circuit-breaker会导致故障效应被放大,建议后端使用主动超时替代断路器。
  • 建议在应用层面对连接的使用进行更精细的控制。

外因:分库迁移

分库导致了原库中的快慢查询比例发生变化,诱发了两个函数的劣化。

问题函数在迁移前后的全局调用次数占比由1/6 变为1/2,导致问题函数的并行数增大。

原理

  • 迁走的函数全都是快查询,原本问题函数:普通函数的比例为1:5
  • 迁移负载后,快查询迁走了大半。问题函数:普通函数超过1:1
  • 问题函数的比例大幅升高,导致高峰期其并发数超出阈值点,出现劣化。

证据

通过分析分库迁移前后的数据库全量日志,回放查询流量进行压测,重现了现象,确认了问题原因。

指标 迁移前 迁移后
问题函数占比 1/6 5/9
最大QPS 40k 8k

QPS/TPS是一个极具误导性的指标,只有在负载类型不变的清空下,比较QPS才有意义。当系统负载类型发生变化时,QPS的水位点也需要重新进行评估测试。

在本例中,在负载变化后,系统的最大QPS水位点已经发生了戏剧性的变化,因为问题函数并发劣化,最大QPS变为原来的五分之一。

修复

对问题函数进行了改写优化,提高了一倍的性能。

通过测试,确定了迁移后的系统水位值,并进行了相应的优化与容量调整。

四、经验与教训

在故障排查中,走了一些弯路。比如一开始认为是某个离线批量任务拖慢了查询(根据日志中观察到的前后相关性),也排查了API调用量突增,外部恶意访问,其他变更因素,未知线上操作等。虽然分库迁移被列入怀疑对象,但因为直觉上认为负载小了,系统的Capacity怎么可能会下降?就没有列为优先排查对象。现实马上就给我们上了一课:

当你排除掉一切不可能之后,剩下的即使再离奇,也是事实。

Bash与psql小技巧

一些PostgreSQL与Bash交互的技巧。

一些PostgreSQL与Bash交互的技巧。

使用严格模式编写Bash脚本

使用Bash严格模式,可以避免很多无谓的错误。在Bash脚本开始的地方放上这一行很有用:

set -euo pipefail
  • -e:当程序返回非0状态码时报错退出
  • -u:使用未初始化的变量时报错,而不是当成NULL
  • -o pipefail:使用Pipe中出错命令的状态码(而不是最后一个)作为整个Pipe的状态码1

执行SQL脚本的Bash包装脚本

通过psql运行SQL脚本时,我们期望有这么两个功能:

  1. 能向脚本中传入变量
  2. 脚本出错后立刻中止(而不是默认行为的继续执行)

这里给出了一个实际例子,包含了上述两个特性。使用Bash脚本进行包装,传入两个参数。

#!/usr/bin/env bash
set -euo pipefail

if [ $# != 2 ]; then
    echo "please enter a db host and a table suffix"
    exit 1
fi

export DBHOST=$1
export TSUFF=$2

psql \
    -X \
    -U user \
    -h $DBHOST \
    -f /path/to/sql/file.sql \
    --echo-all \
    --set AUTOCOMMIT=off \
    --set ON_ERROR_STOP=on \
    --set TSUFF=$TSUFF \
    --set QTSTUFF=\'$TSUFF\' \
    mydatabase

psql_exit_status = $?

if [ $psql_exit_status != 0 ]; then
    echo "psql failed while trying to run this sql script" 1>&2
    exit $psql_exit_status
fi

echo "sql script successful"
exit 0

一些要点:

  • 参数TSTUFF会传入SQL脚本中,同时作为一个裸值和一个单引号包围的值,因此,裸值可以当成表名,模式名,引用值可以当成字符串值。
  • 使用-X选项确保当前用户的.psqlrc文件不会被自动加载
  • 将所有消息打印到控制台,这样可以知道脚本的执行情况。(失效的时候很管用)
  • 使用ON_ERROR_STOP选项,当出问题时立即终止。
  • 关闭AUTOCOMMIT,所以SQL脚本文件不会每一行都提交一次。取而代之的是SQL脚本中出现COMMIT时才提交。如果希望整个脚本作为一个事务提交,在sql脚本最后一行加上COMMIT(其它地方不要加),否则整个脚本就会成功运行却什么也没提交(自动回滚)。也可以使用--single-transaction标记来实现。

/path/to/sql/file.sql的内容如下:

begin;
drop index this_index_:TSUFF;
commit;

begin;
create table new_table_:TSUFF (
    greeting text not null default '');
commit;

begin;
insert into new_table_:TSUFF (greeting)
values ('Hello from table ' || :QTSUFF);
commit;

使用PG环境变量让脚本更简练

使用PG环境变量非常方便,例如用PGUSER替代-U <user>,用PGHOST替代-h <host>,用户可以通过修改环境变量来切换数据源。还可以通过Bash为这些环境变量提供默认值。

#!/bin/bash

set -euo pipefail

# Set these environmental variables to override them,
# but they have safe defaults.
export PGHOST=${PGHOST-localhost}
export PGPORT=${PGPORT-5432}
export PGDATABASE=${PGDATABASE-my_database}
export PGUSER=${PGUSER-my_user}
export PGPASSWORD=${PGPASSWORD-my_password}

RUN_PSQL="psql -X --set AUTOCOMMIT=off --set ON_ERROR_STOP=on "

${RUN_PSQL} <<SQL
select blah_column 
  from blahs 
 where blah_column = 'foo';
rollback;
SQL

在单个事务中执行一系列SQL命令

你有一个写满SQL的脚本,希望将整个脚本作为单个事务执行。一种经常出现的情况是在最后忘记加一行COMMIT。一种解决办法是使用—single-transaction标记:

psql \
    -X \
    -U myuser \
    -h myhost \
    -f /path/to/sql/file.sql \
    --echo-all \
    --single-transaction \
    --set AUTOCOMMIT=off \
    --set ON_ERROR_STOP=on \
    mydatabase

file.sql的内容变为:

insert into foo (bar) values ('baz');
insert into yikes (mycol) values ('hello');

两条插入都会被包裹在同一对BEGIN/COMMIT中。

让多行SQL语句更美观

#!/usr/bin/env bash
set -euo pipefail

RUN_ON_MYDB="psql -X -U myuser -h myhost --set ON_ERROR_STOP=on --set AUTOCOMMIT=off mydb"

$RUN_ON_MYDB <<SQL
drop schema if exists new_my_schema;
create table my_new_schema.my_new_table (like my_schema.my_table);
create table my_new_schema.my_new_table2 (like my_schema.my_table2);
commit;
SQL

# 使用'包围的界定符意味着HereDocument中的内容不会被Bash转义。
$RUN_ON_MYDB <<'SQL'
create index my_new_table_id_idx on my_new_schema.my_new_table(id);
create index my_new_table2_id_idx on my_new_schema.my_new_table2(id);
commit;
SQL

也可以使用Bash技巧,将多行语句赋值给变量,并稍后使用。

注意,Bash会自动清除多行输入中的换行符。实际上整个Here Document中的内容在传输时会重整为一行,你需要添加合适的分隔符,例如分号,来避免格式被搞乱。

CREATE_MY_TABLE_SQL=$(cat <<EOF
    create table foo (
        id bigint not null,
        name text not null
    );
EOF
)

$RUN_ON_MYDB <<SQL
$CREATE_MY_TABLE_SQL
commit;
SQL

如何将单个SELECT标量结果赋值给Bash变量

CURRENT_ID=$($PSQL -X -U $PROD_USER -h myhost -P t -P format=unaligned $PROD_DB -c "select max(id) from users")
let NEXT_ID=CURRENT_ID+1
echo "next user.id is $NEXT_ID"

echo "about to reset user id sequence on other database"
$PSQL -X -U $DEV_USER $DEV_DB -c "alter sequence user_ids restart with $NEXT_ID"

如何将单行结果赋给Bash变量

并且每个变量都以列名命名。

read username first_name last_name <<< $(psql \
    -X \
    -U myuser \
    -h myhost \
    -d mydb \
    --single-transaction \
    --set ON_ERROR_STOP=on \
    --no-align \
    -t \
    --field-separator ' ' \
    --quiet \
    -c "select username, first_name, last_name from users where id = 5489")

echo "username: $username, first_name: $first_name, last_name: $last_name"

也可以使用数组的方式

#!/usr/bin/env bash
set -euo pipefail

declare -a ROW=($(psql \
    -X \
    -h myhost \
    -U myuser \
    -c "select username, first_name, last_name from users where id = 5489" \
    --single-transaction \
    --set AUTOCOMMIT=off \
    --set ON_ERROR_STOP=on \
    --no-align \
    -t \
    --field-separator ' ' \
    --quiet \
    mydb))

username=${ROW[0]}
first_name=${ROW[1]}
last_name=${ROW[2]}

echo "username: $username, first_name: $first_name, last_name: $last_name"

如何在Bash脚本中迭代查询结果集

#!/usr/bin/env bash
set -euo pipefail
PSQL=/usr/bin/psql

DB_USER=myuser
DB_HOST=myhost
DB_NAME=mydb

$PSQL \
    -X \
    -h $DB_HOST \
    -U $DB_USER \
    -c "select username, password, first_name, last_name from users" \
    --single-transaction \
    --set AUTOCOMMIT=off \
    --set ON_ERROR_STOP=on \
    --no-align \
    -t \
    --field-separator ' ' \
    --quiet \
    -d $DB_NAME \
| while read username password first_name last_name ; do
    echo "USER: $username $password $first_name $last_name"
done

也可以读进数组里:

#!/usr/bin/env bash
set -euo pipefail

PSQL=/usr/bin/psql

DB_USER=myuser
DB_HOST=myhost
DB_NAME=mydb

$PSQL \
    -X \
    -h $DB_HOST \
    -U $DB_USER \
    -c "select username, password, first_name, last_name from users" \
    --single-transaction \
    --set AUTOCOMMIT=off \
    --set ON_ERROR_STOP=on \
    --no-align \
    -t \
    --field-separator ' ' \
    --quiet \
    $DB_NAME | while read -a Record ; do

    username=${Record[0]}
    password=${Record[1]}
    first_name=${Record[2]}
    last_name=${Record[3]}

    echo "USER: $username $password $first_name $last_name"
done

如何使用状态表来控制多个PG任务

假设你有一份如此之大的工作,以至于你一次只想做一件事。 您决定一次可以完成一项任务,而这对数据库来说更容易,而不是执行一个长时间运行的查询。 您创建一个名为my_schema.items_to_process的表,其中包含要处理的每个项目的item_id,并且您将一列添加到名为done的items_to_process表中,该表默认为false。 然后,您可以使用脚本从items_to_process中获取每个未完成项目,对其进行处理,然后在items_to_process中将该项目更新为done = true。 一个bash脚本可以这样做:

#!/usr/bin/env bash
set -euo pipefail

PSQL="/u99/pgsql-9.1/bin/psql"
DNL_TABLE="items_to_process"
#DNL_TABLE="test"
FETCH_QUERY="select item_id from my_schema.${DNL_TABLE} where done is false limit 1"

process_item() {
    local item_id=$1
    local dt=$(date)
    echo "[${dt}] processing item_id $item_id"
    $PSQL -X -U myuser -h myhost -c "insert into my_schema.thingies select thingie_id, salutation, name, ddr from thingies where item_id = $item_id and salutation like 'Mr.%'" mydb
}

item_id=$($PSQL -X -U myuser -h myhost -P t -P format=unaligned -c "${FETCH_QUERY}" mydb)
dt=$(date)
while [ -n "$item_id" ]; do
    process_item $item_id
    echo "[${dt}] marking item_id $item_id as done..."
    $PSQL -X -U myuser -h myhost -c "update my_schema.${DNL_TABLE} set done = true where item_id = $item_id" mydb
    item_id=$($PSQL -X -U myuser -h myhost -P t -P format=unaligned -c "${FETCH_QUERY}" mydb)
    dt=$(date)
done

跨数据库拷贝表

有很多方式可以实现这一点,利用psql\copy命令可能是最简单的方式。假设你有两个数据库olddbnewdb,有一张users表需要从老库同步到新库。如何用一条命令实现:

psql \
    -X \
    -U user \
    -h oldhost \
    -d olddb \
    -c "\\copy users to stdout" \
| \
psql \
    -X \
    -U user \
    -h newhost \
    -d newdb \
    -c "\\copy users from stdin"

一个更困难的例子:假如你的表在老数据库中有三列:first_name, middle_name, last_name

但在新数据库中只有两列,first_namelast_name,则可以使用:

psql \
    -X \
    -U user \
    -h oldhost \
    -d olddb \
    -c "\\copy (select first_name, last_name from users) to stdout" \
| \
psql \
    -X \
    -U user \
    -h newhost \
    -d newdb \
    -c "\\copy users from stdin"

获取表定义的方式

pg_dump \
    -U db_user \
    -h db_host \
    -p 55432 \
    --table my_table \
    --schema-only my_db

将bytea列中的二进制数据导出到文件

注意bytea列,在PostgreSQL 9.0 以上是使用十六进制表示的,带有一个恼人的前缀\x,可以用substring去除。

#!/usr/bin/env bash
set -euo pipefail

psql \
    -P t \
    -P format=unaligned \
    -X \
    -U myuser \
    -h myhost \
    -c "select substring(my_bytea_col::text from 3) from my_table where id = 12" \
    mydb \
| xxd -r -p > dump.txt

将文件内容作为一个列的值插入

有两种思路完成这件事,第一种是在外部拼SQL,第二种是在脚本中作为变量。

CREATE TABLE sample(
	filename	INTEGER,
    value		JSON
);
psql <<SQL
\set content `cat ${filename}`
INSERT INTO sample VALUES(\'${filename}\',:'content')
SQL

显示特定数据库中特定表的统计信息

#!/usr/bin/env bash
set -euo pipefail
if [ -z "$1" ]; then
    echo "Usage: $0 table [db]"
    exit 1
fi

SCMTBL="$1"
SCHEMANAME="${SCMTBL%%.*}"  # everything before the dot (or SCMTBL if there is no dot)
TABLENAME="${SCMTBL#*.}"  # everything after the dot (or SCMTBL if there is no dot)

if [ "${SCHEMANAME}" = "${TABLENAME}" ]; then
    SCHEMANAME="public"
fi

if [ -n "$2" ]; then
    DB="$2"
else
    DB="my_default_db"
fi

PSQL="psql -U my_default_user -h my_default_host -d $DB -x -c "

$PSQL "
select '-----------' as \"-------------\", 
       schemaname,
       tablename,
       attname,
       null_frac,
       avg_width,
       n_distinct,
       correlation,
       most_common_vals,
       most_common_freqs,
       histogram_bounds
  from pg_stats
 where schemaname='$SCHEMANAME'
   and tablename='$TABLENAME';
" | grep -v "\-\[ RECORD "

使用方式

./table-stats.sh myschema.mytable

对于public模式中的表

./table-stats.sh mytable

连接其他数据库

./table-stats.sh mytable myotherdb

将psql的默认输出转换为Markdown表格

alias pg2md=' sed '\''s/+/|/g'\'' | sed '\''s/^/|/'\'' | sed '\''s/$/|/'\'' |  grep -v rows | grep -v '\''||'\'''

# Usage
psql -c 'SELECT * FROM pg_database' | pg2md

输出的结果贴到Markdown编辑器即可。


  1. 管道程序的退出状态放置在环境变量数组PIPESTATUS中 ↩︎

PostgreSQL例行维护

汽车需要上油,数据库也需要维护保养。对Pg而言,有三项比较重要的维护工作:备份、重整、清理

汽车需要上油,数据库也需要维护保养。

PG中的维护工作

对Pg而言,有三项比较重要的维护工作:备份、重整、清理

  • 备份(backup):最重要的例行工作,生命线。
    • 制作基础备份
    • 归档增量WAL
  • 重整(repack)
    • 重整表与索引能消除其中的膨胀,节约空间,确保查询性能不会劣化。
  • 清理(vacuum)
    • 维护表与库的年龄,避免事务ID回卷故障。
    • 更新统计数据,生成更好的执行计划。
    • 回收死元组。节约空间,提高性能。

备份

备份可以使用pg_backrest 作为一条龙解决方案,但这里考虑使用脚本进行备份。

参考:pg-backup

重整

重整使用pg_repack,PostgreSQL自带源里包含了pg_repack

参考:pg-repack

清理

虽然有AutoVacuum,但手动执行Vacuum仍然有帮助。检查数据库的年龄,当出现老化时及时上报。

参考:pg-vacuum

备份恢复手段概览

备份是DBA的安身立命之本,有备份,就不用慌。

备份是DBA的安身立命之本,有备份,就不用慌。

备份有三种形式:SQL转储,文件系统备份,连续归档

1. SQL转储

SQL 转储方法的思想是:

创建一个由SQL命令组成的文件,服务器能利用其中的SQL命令重建与转储时状态一样的数据库。

1.1 转储

工具pg_dumppg_dumpall用于进行SQL转储。结果输出到stdout。

pg_dump dbname > filename
pg_dump dbname -f filename
  • pg_dump是一个普通的PostgreSQL客户端应用。可以在任何可以访问该数据库的远端主机上进行备份工作。
  • pg_dump不会以任何特殊权限运行,必须要有你想备份的表的读权限,同时它也遵循同样的HBA机制。
  • 要备份整个数据库,几乎总是需要一个数据库超级用户。
  • 该备份方式的重要优势是,它是跨版本、跨机器架构的备份方式。(最远回溯至7.0)
  • pg_dump的备份是内部一致的,是转储开始时刻的数据库快照,转储期间的更新不被包括在内。
  • pg_dump不会阻塞其他数据库操作,但需要排它锁的命令除外(例如大多数 ALTER TABLE)

1.2 恢复

文本转储文件可由psql读取,从转储中恢复的常用命令是:

psql dbname < infile
  • 这条命令不会创建数据库dbname,必须在执行psql前自己从template0创建。例如,用命令createdb -T template0 dbname。默认template1template0是一样的,新创建的数据库默认以template1为模板。

    CREATE DATABASE dbname TEMPLATE template0;

  • 非文本文件转储可以使用pg_restore工具来恢复。

  • 在开始恢复之前,转储库中对象的拥有者以及在其上被授予了权限的用户必须已经存在。如果它们不存在,那么恢复过程将无法将对象创建成具有原来的所属关系以及权限(有时候这就是你所需要的,但通常不是)。

  • 恢复时遇到错误自动终止,则可以设置ON_ERROR_STOP变量来运行psql,遇到SQL错误后退出并返回状态3:

psql --set ON_ERROR_STOP=on dbname < infile
  • 恢复时可以使用单个事务来保证要么完全正确恢复,要么完全回滚。使用-1--single-transaction
  • pg_dump和psql可以通过管道on-the-fly做转储与恢复
pg_dump -h host1 dbname | psql -h host2 dbname

1.3 全局转储

一些信息属于数据库集簇,而不是单个数据库的,例如角色、表空间。如果希望转储这些,可使用pg_dumpall

pg_dumpall > outfile

如果只想要全局的数据(角色与表空间),则可以使用-g, --globals-only参数。

转储的结果可以使用psql恢复,通常将转储载入到一个空集簇中可以用postgres作为数据库名

psql -f infile postgres
  • 在恢复一个pg_dumpall转储时常常需要具有数据库超级用户访问权限,因为它需要恢复角色和表空间信息。
  • 如果使用了表空间,请确保转储中的表空间路径适合于新的安装。
  • pg_dumpall工作步骤是,先创建角色、表空间转储,再为每一个数据库做pg_dump。这意味着每个数据库自身是一致的,但是不同数据库的快照并不同步。

1.4 命令实践

准备环境,创建测试数据库

psql postgres -c "CREATE DATABASE testdb;"
psql postgres -c "CREATE ROLE test_user LOGIN;"
psql testdb -c "CREATE TABLE test_table(i INTEGER);"
psql testdb -c "INSERT INTO test_table SELECT generate_series(1,16);"
# dump到本地文件
pg_dump testdb -f testdb.sql 

# dump并用xz压缩,-c指定从stdio接受,-d指定解压模式
pg_dump testdb | xz -cd > testdb.sql.xz

# dump,压缩,分割为1m的小块
pg_dump testdb | xz | split -b 1m - testdb.sql.xz
cat testdb.sql.xz* | xz -cd | psql # 恢复

# pg_dump 常用参数参考
-s --schema-only
-a --data-only
-t --table
-n --schema
-c --clean
-f --file

--inserts
--if-exists
-N --exclude-schema
-T --exclude-table

2. 文件系统转储

SQL 转储方法的思想是:拷贝数据目录的所有文件。为了得到一个可用的备份,所有备份文件都应当保持一致。

所以通常比而且为了得到一个可用的备份,所有备份文件都应当保持一致。

  • 文件系统拷贝不做逻辑解析,只是简单拷贝文件。好处是执行快,省掉了逻辑解析和重建索引的时间,坏处是占用空间更大,而且只能用于整个数据库集簇的备份
  • 最简单的方式:停机,直接拷贝数据目录的所有文件。

  • 有办法通过文件系统(例如xfs)获得一致的冻结快照也可以不停机,但wal和数据目录必须是一致的。

  • 可以通过制作pg_basebackup进行远程归档备份,可以不停机。

  • 可以通过停机执行rsync的方式向远端增量同步数据变更。

3. PITR 连续归档与时间点恢复

Pg在运行中会不断产生WAL,WAL记录了操作日志,从某一个基础的全量备份开始回放后续的WAL,就可以恢复数据库到任意的时刻的状态。为了实现这样的功能,就需要配置WAL归档,将数据库生成的WAL不断保存起来。

WAL在逻辑上是一段无限的字节流。pg_lsn类型(bigint)可以标记WAL中的位置,pg_lsn代表一个WAL中的字节位置偏移量。但实践中WAL不是连续的一个文件,而被分割为每16MB一段。

WAL文件名是有规律的,而且归档时不允许更改。通常为24位十六进制数字,000000010000000000000003,其中前面8位十六进制数字表示时间线,后面的16位表示16MB块的序号。即lsn >> 24的值。

查看pg_lsn时,例如0/84A8300,只要去掉最后六位hex,就可以得到WAL文件序号的后面部分,这里,也就是8,如果使用的是默认时间线1,那么对应的WAL文件就是000000010000000000000008

3.1 准备环境

# 目录:
# 使用/var/lib/pgsql/data 作为主库目录,使用/var/lib/pgsql/wal作为日志归档目录
# sudo mkdir /var/lib/pgsql && sudo chown postgres:postgres /var/lib/pgsql/
pg_ctl stop -D /var/lib/pgsql/data
rm -rf /var/lib/pgsql/{data,wal} && mkdir -p /var/lib/pgsql/{data,wal}

# 初始化:
# 初始化主库并修改配置文件
pg_ctl -D /var/lib/pgsql/data init 

# 配置文件
# 创建默认额外配置文件夹,并在postgresql.conf中配置include_dir
mkdir -p /var/lib/pgsql/data/conf.d
cat >> /var/lib/pgsql/data/postgresql.conf <<- 'EOF'
include_dir = 'conf.d'
EOF

3.2 配置自动归档命令

# 归档配置
# %p 代表 src wal path, %f 代表 filename
cat > /var/lib/pgsql/data/conf.d/archive.conf <<- 'EOF'
archive_mode = on
archive_command = 'conf.d/archive.sh %p %f'
EOF

# 归档脚本 
cat > /var/lib/pgsql/data/conf.d/archive.sh <<- 'EOF'
test ! -f /var/lib/pgsql/wal/${2} && cp ${1} /var/lib/pgsql/wal/${2}
EOF
chmod a+x /var/lib/pgsql/data/conf.d/archive.sh

归档脚本可以简单到只是一个cp,也可以非常复杂。但需要注意以下事项:

  • 归档命令使用数据库用户postgres执行,最好放在0700的目录下面。

  • 归档命令应当拒绝覆盖现有文件,出现覆盖时,返回一个错误代码。

  • 归档命令可以通过reload配置更新。

  • 处理归档失败时的情形

  • 归档文件应当保留原有文件名。

  • WAL不会记录对配置文件的变更。

  • 归档命令中:%p 会替换为生成待归档WAL的路径,而%f会替换为待归档WAL的文件名

  • 归档脚本可以使用更复杂的逻辑,例如下面的归档命令,在归档目录中每天创建一个以日期YYYYMMDD命名的文件夹,在每天12点移除前一天的归档日志。每天的归档日志使用xz压缩存储。

    wal_dir=/var/lib/pgsql/wal;
    [[ $(date +%H%M) == 1200 ]] && rm -rf ${wal_dir}/$(date -d"yesterday" +%Y%m%d); /bin/mkdir -p ${wal_dir}/$(date +%Y%m%d) && \
    test ! -f ${wal_dir}/ && \ 
    xz -c %p > ${wal_dir}/$(date +%Y%m%d)/%f.xz
    
  • 归档也可以使用外部专用备份工具进行。例如pgbackrestbarman等。

3.3 测试归档

# 启动数据库
pg_ctl -D /var/lib/pgsql/data start

# 确认配置
psql postgres -c "SELECT name,setting FROM pg_settings where name like '%archive%';"

在当前shell开启监视循环,不断查询WAL的位置,以及归档目录和pg_wal中的文件变化

for((i=0;i<100;i++)) do 
	sleep 1 && \
	ls /var/lib/pgsql/data/pg_wal && ls /var/lib/pgsql/data/pg_wal/archive_status/
	psql postgres -c 'SELECT pg_current_wal_lsn() as current, pg_current_wal_insert_lsn() as insert, pg_current_wal_flush_lsn() as flush;'
done

在另一个Shell中创建一张测试表foobar,包含单一的时间戳列,并引入负载,每秒写入一万条记录:

psql postgres -c 'CREATE TABLE foobar(ts TIMESTAMP);'
for((i=0;i<1000;i++)) do 
	sleep 1 && \
	psql postgres -c 'INSERT INTO foobar SELECT now() FROM generate_series(1,10000)' && \
	psql postgres -c 'SELECT pg_current_wal_lsn() as current, pg_current_wal_insert_lsn() as insert, pg_current_wal_flush_lsn() as flush;'
done

自然切换WAL

可以看到,当WAL LSN的位置超过16M(可以由后6个hex表示)之后,就会rotate到一个新的WAL文件,归档命令会将写完的WAL归档。

000000010000000000000001 archive_status
  current  |  insert   |   flush
-----------+-----------+-----------
 0/1FC2630 | 0/1FC2630 | 0/1FC2630
(1 row)

# rotate here

000000010000000000000001 000000010000000000000002 archive_status
000000010000000000000001.done
  current  |  insert   |   flush
-----------+-----------+-----------
 0/205F1B8 | 0/205F1B8 | 0/205F1B8

手工切换WAL

再开启一个Shell,执行pg_switch_wal,强制写入一个新的WAL文件

psql postgres -c 'SELECT pg_switch_wal();'

可以看到,虽然位置才到32C1D68,但立即就跳到了下一个16MB的边界点。

000000010000000000000001 000000010000000000000002 000000010000000000000003 archive_status
000000010000000000000001.done 000000010000000000000002.done
  current  |  insert   |   flush
-----------+-----------+-----------
 0/32C1D68 | 0/32C1D68 | 0/32C1D68
(1 row)

# switch here

000000010000000000000001 000000010000000000000002 000000010000000000000003 archive_status
000000010000000000000001.done 000000010000000000000002.done 000000010000000000000003.done
  current  |  insert   |   flush
-----------+-----------+-----------
 0/4000000 | 0/4000028 | 0/4000000
(1 row)

000000010000000000000001 000000010000000000000002 000000010000000000000003 000000010000000000000004 archive_status
000000010000000000000001.done 000000010000000000000002.done 000000010000000000000003.done
  current  |  insert   |   flush
-----------+-----------+-----------
 0/409CBA0 | 0/409CBA0 | 0/409CBA0
(1 row)

强制kill数据库

数据库因为故障异常关闭,重启之后,会从最近的检查点,也就是0/2FB0160开始重放WAL。

[17:03:37] vonng@vonng-mac /var/lib/pgsql
$  ps axu | grep postgres | grep data | awk '{print $2}' | xargs kill -9

[17:06:31] vonng@vonng-mac /var/lib/pgsql
$ pg_ctl -D /var/lib/pgsql/data start
pg_ctl: another server might be running; trying to start server anyway
waiting for server to start....2018-01-25 17:07:27.063 CST [9762] LOG:  listening on IPv6 address "::1", port 5432
2018-01-25 17:07:27.063 CST [9762] LOG:  listening on IPv4 address "127.0.0.1", port 5432
2018-01-25 17:07:27.064 CST [9762] LOG:  listening on Unix socket "/tmp/.s.PGSQL.5432"
2018-01-25 17:07:27.078 CST [9763] LOG:  database system was interrupted; last known up at 2018-01-25 17:06:01 CST
2018-01-25 17:07:27.117 CST [9763] LOG:  database system was not properly shut down; automatic recovery in progress
2018-01-25 17:07:27.120 CST [9763] LOG:  redo starts at 0/2FB0160
2018-01-25 17:07:27.722 CST [9763] LOG:  invalid record length at 0/49CBE78: wanted 24, got 0
2018-01-25 17:07:27.722 CST [9763] LOG:  redo done at 0/49CBE50
2018-01-25 17:07:27.722 CST [9763] LOG:  last completed transaction was at log time 2018-01-25 17:06:30.158602+08
2018-01-25 17:07:27.741 CST [9762] LOG:  database system is ready to accept connections
 done
server started

至此,WAL归档已经确认可以正常工作了。

3.4 制作基础备份

首先,查看当前WAL的位置:

$ psql postgres -c 'SELECT pg_current_wal_lsn() as current, pg_current_wal_insert_lsn() as insert, pg_current_wal_flush_lsn() as flush;'

  current  |  insert   |   flush
-----------+-----------+-----------
 0/49CBF20 | 0/49CBF20 | 0/49CBF20

使用pg_basebackup制作基础备份

psql postgres -c 'SELECT now();'
pg_basebackup -Fp -Pv -Xs -c fast -D /var/lib/pgsql/bkup

# 常用选项
-D  : 必选项,基础备份的位置。
-Fp : 备份格式: plain 普通文件 tar 归档文件
-Pv : -P 启用进度报告 -v 启用详细输出
-Xs : 在备份中包括备份期间产生的WAL日志 f:备份完后拉取 s:备份时流式传输
-c  : fast 立即执行Checkpoint而不是均摊IO spread:均摊IO
-R  : 设置recovery.conf

制作基础备份时,会立即创建一个检查点使得所有脏数据页落盘。

$ pg_basebackup -Fp -Pv -Xs -c fast -D /var/lib/pgsql/bkup
pg_basebackup: initiating base backup, waiting for checkpoint to complete
pg_basebackup: checkpoint completed
pg_basebackup: write-ahead log start point: 0/5000028 on timeline 1
pg_basebackup: starting background WAL receiver
45751/45751 kB (100%), 1/1 tablespace
pg_basebackup: write-ahead log end point: 0/50000F8
pg_basebackup: waiting for background process to finish streaming ...
pg_basebackup: base backup completed

3.5 使用备份

直接使用

最简单的使用方式,就是直接用pg_ctl启动它。

recovery.conf不存在时,这样做会启动一个新的完整数据库实例,原原本本地保留了备份完成时的状态。数据库会并不会意识到自己是一个备份。而是以为自己上次没有正常关闭,应用pg_wal目录中自带的WAL进行修复,正常重启。

基础的全量备份可能每天或每周备份一次,要想恢复到最新的时刻,需要和WAL归档配合使用。

使用WAL归档追赶进度

可以在备份中数据库下创建一个recovery.conf文件,并指定restore_command选项。这样的话,当使用pg_ctl启动这个数据目录时,postgres会依次拉取所需的WAL,直到没有了为止。

cat >> /var/lib/pgsql/bkup/recovery.conf <<- 'EOF'
restore_command = 'cp /var/lib/pgsql/wal/%f %p' 
EOF

继续在原始主库中执行负载,这时候WAL的进度已经到了0/9060CE0,而制作备份的时候位置还在0/5000028

启动备份之后,可以发现,备份数据库自动从归档文件夹拉取了5~8号WAL并应用。

$ pg_ctl start -D /var/lib/pgsql/bkup -o '-p 5433'
waiting for server to start....2018-01-25 17:35:35.001 CST [10862] LOG:  listening on IPv6 address "::1", port 5433
2018-01-25 17:35:35.001 CST [10862] LOG:  listening on IPv4 address "127.0.0.1", port 5433
2018-01-25 17:35:35.002 CST [10862] LOG:  listening on Unix socket "/tmp/.s.PGSQL.5433"
2018-01-25 17:35:35.016 CST [10863] LOG:  database system was interrupted; last known up at 2018-01-25 17:21:15 CST
2018-01-25 17:35:35.051 CST [10863] LOG:  starting archive recovery
2018-01-25 17:35:35.063 CST [10863] LOG:  restored log file "000000010000000000000005" from archive
2018-01-25 17:35:35.069 CST [10863] LOG:  redo starts at 0/5000028
2018-01-25 17:35:35.069 CST [10863] LOG:  consistent recovery state reached at 0/50000F8
2018-01-25 17:35:35.070 CST [10862] LOG:  database system is ready to accept read only connections
 done
server started
2018-01-25 17:35:35.081 CST [10863] LOG:  restored log file "000000010000000000000006" from archive
$ 2018-01-25 17:35:35.924 CST [10863] LOG:  restored log file "000000010000000000000007" from archive
2018-01-25 17:35:36.783 CST [10863] LOG:  restored log file "000000010000000000000008" from archive
cp: /var/lib/pgsql/wal/000000010000000000000009: No such file or directory
2018-01-25 17:35:37.604 CST [10863] LOG:  redo done at 0/8FFFF90
2018-01-25 17:35:37.604 CST [10863] LOG:  last completed transaction was at log time 2018-01-25 17:30:39.107943+08
2018-01-25 17:35:37.614 CST [10863] LOG:  restored log file "000000010000000000000008" from archive
cp: /var/lib/pgsql/wal/00000002.history: No such file or directory
2018-01-25 17:35:37.629 CST [10863] LOG:  selected new timeline ID: 2
cp: /var/lib/pgsql/wal/00000001.history: No such file or directory
2018-01-25 17:35:37.678 CST [10863] LOG:  archive recovery complete
2018-01-25 17:35:37.783 CST [10862] LOG:  database system is ready to accept connections

但是使用WAL归档的方式来恢复也有问题,例如查询主库与备库最新的数据记录,发现时间戳差了一秒。也就是说,主库还没有写完的WAL并没有被归档,因此也没有应用。

[17:37:22] vonng@vonng-mac /var/lib/pgsql
$ psql postgres -c 'SELECT max(ts) FROM foobar;'
            max
----------------------------
 2018-01-25 17:30:40.159684
(1 row)


[17:37:42] vonng@vonng-mac /var/lib/pgsql
$ psql postgres -p 5433 -c 'SELECT max(ts) FROM foobar;'
            max
----------------------------
 2018-01-25 17:30:39.097167
(1 row)

通常archive_command, restore_command主要用于紧急情况下的恢复,比如主库从库都挂了。因为还没有归档

3.6 指定进度

默认情况下,恢复将会一直恢复到 WAL 日志的末尾。下面的参数可以被用来指定一个更早的停止点。recovery_targetrecovery_target_namerecovery_target_timerecovery_target_xid四个选项中最多只能使用一个,如果在配置文件中使用了多个,将使用最后一个。

上面四个恢复目标中,常用的是 recovery_target_time,用于指明将系统恢复到什么时间。

另外几个常用的选项包括:

  • recovery_target_inclusive (boolean) :是否包括目标点,默认为true
  • recovery_target_timeline (string): 指定恢复到一个特定的时间线中。
  • recovery_target_action (enum):指定在达到恢复目标时服务器应该立刻采取的动作。
    • pause: 暂停恢复,默认选项,可通过pg_wal_replay_resume恢复。
    • shutdown: 自动关闭。
    • promote: 开始接受连接

例如在2018-01-25 18:51:20 创建了一个备份

$ psql postgres -c 'SELECT now();'
             now
------------------------------
 2018-01-25 18:51:20.34732+08
(1 row)


[18:51:20] vonng@vonng-mac ~
$ pg_basebackup -Fp -Pv -Xs -c fast -D /var/lib/pgsql/bkup
pg_basebackup: initiating base backup, waiting for checkpoint to complete
pg_basebackup: checkpoint completed
pg_basebackup: write-ahead log start point: 0/3000028 on timeline 1
pg_basebackup: starting background WAL receiver
33007/33007 kB (100%), 1/1 tablespace
pg_basebackup: write-ahead log end point: 0/30000F8
pg_basebackup: waiting for background process to finish streaming ...
pg_basebackup: base backup completed

之后运行了两分钟,到了2018-01-25 18:53:05我们发现有几条脏数据,于是从备份开始恢复,希望恢复到脏数据出现前一分钟的状态,例如2018-01-25 18:52

可以这样配置

cat >> /var/lib/pgsql/bkup/recovery.conf <<- 'EOF'
restore_command = 'cp /var/lib/pgsql/wal/%f %p' 
recovery_target_time = '2018-01-25 18:52:30'
recovery_target_action = 'promote'
EOF

当新的数据库实例完成恢复之后,可以看到它的状态确实回到了 18:52分,这正是我们期望的。

$ pg_ctl -D /var/lib/pgsql/bkup -o '-p 5433' start
waiting for server to start....2018-01-25 18:56:24.147 CST [13120] LOG:  listening on IPv6 address "::1", port 5433
2018-01-25 18:56:24.147 CST [13120] LOG:  listening on IPv4 address "127.0.0.1", port 5433
2018-01-25 18:56:24.148 CST [13120] LOG:  listening on Unix socket "/tmp/.s.PGSQL.5433"
2018-01-25 18:56:24.162 CST [13121] LOG:  database system was interrupted; last known up at 2018-01-25 18:51:22 CST
2018-01-25 18:56:24.197 CST [13121] LOG:  starting point-in-time recovery to 2018-01-25 18:52:30+08
2018-01-25 18:56:24.210 CST [13121] LOG:  restored log file "000000010000000000000003" from archive
2018-01-25 18:56:24.215 CST [13121] LOG:  redo starts at 0/3000028
2018-01-25 18:56:24.215 CST [13121] LOG:  consistent recovery state reached at 0/30000F8
2018-01-25 18:56:24.216 CST [13120] LOG:  database system is ready to accept read only connections
 done
server started
2018-01-25 18:56:24.228 CST [13121] LOG:  restored log file "000000010000000000000004" from archive
$ 2018-01-25 18:56:25.034 CST [13121] LOG:  restored log file "000000010000000000000005" from archive
2018-01-25 18:56:25.853 CST [13121] LOG:  restored log file "000000010000000000000006" from archive
2018-01-25 18:56:26.235 CST [13121] LOG:  recovery stopping before commit of transaction 649, time 2018-01-25 18:52:30.492371+08
2018-01-25 18:56:26.235 CST [13121] LOG:  redo done at 0/67CFD40
2018-01-25 18:56:26.235 CST [13121] LOG:  last completed transaction was at log time 2018-01-25 18:52:29.425596+08
cp: /var/lib/pgsql/wal/00000002.history: No such file or directory
2018-01-25 18:56:26.240 CST [13121] LOG:  selected new timeline ID: 2
cp: /var/lib/pgsql/wal/00000001.history: No such file or directory
2018-01-25 18:56:26.293 CST [13121] LOG:  archive recovery complete
2018-01-25 18:56:26.401 CST [13120] LOG:  database system is ready to accept connections
$

# query new server ,确实回到了18:52分
$ psql postgres -p 5433 -c 'SELECT max(ts) FROM foobar;'
            max
----------------------------
 2018-01-25 18:52:29.413911
(1 row)

3.7 时间线

每当归档文件恢复完成后,也就是服务器可以开始接受新的查询,写新的WAL的时候。会创建一个新的时间线用来区别新生成的WAL记录。WAL文件名由时间线和日志序号组成,因此新的时间线WAL不会覆盖老时间线的WAL。时间线主要用来解决复杂的恢复操作冲突,例如试想一个场景:刚才恢复到18:52分之后,新的服务器开始不断接受请求:

psql postgres -c 'CREATE TABLE foobar(ts TIMESTAMP);'
for((i=0;i<1000;i++)) do 
	sleep 1 && \
	psql -p 5433 postgres -c 'INSERT INTO foobar SELECT now() FROM generate_series(1,10000)' && \
	psql -p 5433 postgres -c 'SELECT pg_current_wal_lsn() as current, pg_current_wal_insert_lsn() as insert, pg_current_wal_flush_lsn() as flush;'
done

可以看到,WAL归档目录中出现了两个6号WAL段文件,如果没有前面的时间线作为区分,WAL就会被覆盖。

$ ls -alh wal
total 262160
drwxr-xr-x  12 vonng  wheel   384B Jan 25 18:59 .
drwxr-xr-x   6 vonng  wheel   192B Jan 25 18:51 ..
-rw-------   1 vonng  wheel    16M Jan 25 18:51 000000010000000000000001
-rw-------   1 vonng  wheel    16M Jan 25 18:51 000000010000000000000002
-rw-------   1 vonng  wheel    16M Jan 25 18:51 000000010000000000000003
-rw-------   1 vonng  wheel   302B Jan 25 18:51 000000010000000000000003.00000028.backup
-rw-------   1 vonng  wheel    16M Jan 25 18:51 000000010000000000000004
-rw-------   1 vonng  wheel    16M Jan 25 18:52 000000010000000000000005
-rw-------   1 vonng  wheel    16M Jan 25 18:52 000000010000000000000006
-rw-------   1 vonng  wheel    50B Jan 25 18:56 00000002.history
-rw-------   1 vonng  wheel    16M Jan 25 18:58 000000020000000000000006
-rw-------   1 vonng  wheel    16M Jan 25 18:59 000000020000000000000007

假设完成恢复之后又反悔了,则可以用基础备份通过指定recovery_target_timeline = '1' 再次恢复回第一次运行到18:53 时的状态。

3.8 其他注意事项

  • 在Pg 10之前,哈希索引上的操作不会被记录在WAL中,需要在Slave上手工REINDEX。
  • 不要在创建基础备份的时候修改任何模板数据库
  • 注意表空间会严格按照字面值记录其路径,如果使用了表空间,恢复时要非常小心。

4. 制作备机

通过主从(master-slave),可以同时提高可用性与可靠性。

  • 主从读写分离提高性能:写请求落在Master上,通过WAL流复制传输到从库上,从库接受读请求。
  • 通过备份提高可靠性:当一台服务器故障时,可以立即由另一台顶上(promote slave or & make new slave)

通常主从、副本、备机这些属于高可用的话题。但从另一个角度来讲,备机也是备份的一种。

创建目录

sudo mkdir /var/lib/pgsql && sudo chown postgres:postgres /var/lib/pgsql/
mkdir -p /var/lib/pgsql/master /var/lib/pgsql/slave /var/lib/pgsql/wal

制作主库

pg_ctl -D /var/lib/pgsql/master init && pg_ctl -D /var/lib/pgsql/master start

创建用户

创建备库需要一个具有REPLICATION权限的用户,这里在Master中创建replication用户

psql postgres -c 'CREATE USER replication REPLICATION;'

为了创建从库,需要一个具有REPLICATION权限的用户,并在pg_hba中允许访问,10中默认允许:

local   replication     all                                     trust
host    replication     all             127.0.0.1/32            trust

制作备库

通过pg_basebackup创建一个slave实例。实际上是连接到Master实例,并复制一份数据目录到本地。

pg_basebackup -Fp -Pv -R -c fast -U replication -h localhost -D /var/lib/pgsql/slave

这里的关键是通过-R 选项,在备份的制作过程中自动将主机的连接信息填入recovery.conf,这样使用pg_ctl 启动时,数据库会意识到自己是备机,并从主机自动拉取WAL追赶进度。

启动从库

pg_ctl -D /var/lib/pgsql/slave -o "-p 5433" start

从库与主库的唯一区别在于,数据目录中多了一个recovery.conf文件。这个文件不仅仅可以用于标识从库的身份,而且在故障恢复时也需要用到。对于pg_basebackup构造的从库,它默认包含两个参数:

standby_mode = 'on'
primary_conninfo = 'user=replication passfile=''/Users/vonng/.pgpass'' host=localhost port=5432 sslmode=prefer sslcompression=1 krbsrvname=postgres target_session_attrs=any'

standby_mode指明是否将PostgreSQL作为从库启动。

在备份时,standby_mode默认关闭,这样当所有的WAL拉取完毕后,就完成恢复,进入正常工作模式。

如果打开,那么数据库会意识到自己是备机,那么即使到达WAL末尾也不会停止,它会持续拉取主库的WAL,追赶主库的进度。

拉取WAL有两种办法,通过primary_conninfo流式复制拉取(9.0后的新特性,推荐,默认),或者通过restore_command来手工指明WAL的获取方式(老办法,恢复时使用)。

查看状态

主库的所有从库可以通过系统视图pg_stat_replication查阅:

$ psql postgres -tzxc 'SELECT * FROM pg_stat_replication;'
pid              | 1947
usesysid         | 16384
usename          | replication
application_name | walreceiver
client_addr      | ::1
client_hostname  |
client_port      | 54124
backend_start    | 2018-01-25 13:24:57.029203+08
backend_xmin     |
state            | streaming
sent_lsn         | 0/5017F88
write_lsn        | 0/5017F88
flush_lsn        | 0/5017F88
replay_lsn       | 0/5017F88
write_lag        |
flush_lag        |
replay_lag       |
sync_priority    | 0
sync_state       | async

检查主库和备库的状态可以使用函数pg_is_in_recovery,备库会处于恢复状态:

$ psql postgres -Atzc 'SELECT pg_is_in_recovery()' && \
psql postgres -p 5433 -Atzc 'SELECT pg_is_in_recovery()'
f
t

在主库中建表,从库也能看到。

psql postgres -c 'CREATE TABLE foobar(i INTEGER);' && psql postgres -p 5433 -c '\d'

在主库中插入数据,从库也能看到

psql postgres -c 'INSERT INTO foobar VALUES (1);' && \
psql postgres -p 5433 -c 'SELECT * FROM foobar;'

现在主备已经配置就绪

PgBackRest2中文文档

PgBackRest是用perl写的一组PostgreSQL备份工具

pgBackRest主页:http://pgbackrest.org

pgBackRest Github主页:https://github.com/pgbackrest/pgbackrest

前言

pgBackRest旨在提供一个简单可靠,容易纵向扩展的PostgreSQL备份恢复系统。

pgBackRest并不依赖像tar和rsync这样的传统备份工具,而在内部实现所有备份功能,并使用自定义协议来与远程系统进行通信。 消除对tar和rsync的依赖可以更好地解决特定于数据库的备份问题。 自定义远程协议提供了更多的灵活性,并限制执行备份所需的连接类型,从而提高安全性。

pgBackRest v2.01是目前的稳定版本。 发行说明位在发行页面上。

pgBackRest旨在成为一个简单,可靠的备份和恢复系统,可以无缝扩展到最大的数据库和工作负载。

pgBackRest不依赖像tar和rsync这样的传统备份工具,而是在内部实现所有备份功能,并使用自定义协议与远程系统进行通信。消除对tar和rsync的依赖可以更好地解决针对数据库的备份挑战。自定义远程协议允许更大的灵活性,并限制执行备份所需的连接类型,从而提高安全性。

pgBackRest v2.01是当前的稳定版本。发行说明位于发行版页面上。

只有在EOL之前,pgBackRest v1才会收到修复错误。v1的文档可以在这里找到。

0. 特性

  • 并行备份和恢复

    压缩通常是备份操作的瓶颈,但即使是现在已经很普及的多核服务器,大多数数据库备份解决方案仍然是单进程的。 pgBackRest通过并行处理解决了压缩瓶颈问题。利用多个核心进行压缩,即使在1Gb/s的链路上,也可以实现1TB /小时的原生吞吐量。更多的核心和更大的带宽将带来更高的吞吐量。

  • 本地或远程操作

    自定义协议允许pgBackRest以最少的配置通过SSH进行本地或远程备份,恢复和归档。通过协议层也提供了查询PostgreSQL的接口,从而不需要对PostgreSQL进行远程访问,从而增强了安全性。

  • 全量备份与增量备份

    支持全量备份,增量备份,以及差异备份。 pgBackRest不会受到rsync的时间分辨问题的影响,使得差异备份和增量备份完全安全。

  • 备份轮换和归档过期

    可以为全量备份和增量备份设置保留策略,以创建覆盖任何时间范围的备份。 WAL归档可以设置为为所有的备份或仅最近的备份保留。在后一种情况下,在归档过程中会自动保证更老备份的一致性。

  • 备份完整性

    每个文件在备份时都会计算校验和,并在还原过程中重新检查。完成文件复制后,备份会等待所有必须的WAL段进入存储库。存储库中的备份以与标准PostgreSQL集群(包括表空间)相同的格式存储。如果禁用压缩并启用硬链接,则可以在存储库中快照备份,并直接在快照上创建PostgreSQL集群。这对于以传统方式恢复很耗时的TB级数据库是有利的。所有操作都使用文件和目录级别fsync来确保持久性。

  • 页面校验和

    PostgreSQL从9.3开始支持页面级校验和。如果启用页面校验和,pgBackRest将验证在备份过程中复制的每个文件的校验和。所有页面校验和在完整备份过程中均得到验证,在差异备份和增量备份过程中验证了已更改文件中的校验和。 验证失败不会停止备份过程,但会向控制台和文件日志输出具体的哪些页面验证失败的详细警告。

    此功能允许在包含有效数据副本的备份已过期之前及早检测到页级损坏。

  • 备份恢复

    中止的备份可以从停止点恢复。已经复制的文件将与清单中的校验和进行比较,以确保完整性。由于此操作可以完全在备份服务器上进行,因此减少了数据库服务器上的负载,并节省了时间,因为校验和计算比压缩和重新传输数据要快。

  • 流压缩和校验和

    无论存储库位于本地还是远程,压缩和校验和计算均在流中执行,而文件正在复制到存储库。 如果存储库位于备份服务器上,则在数据库服务器上执行压缩,并以压缩格式传输文件,并将其存储在备份服务器上。当禁用压缩时,利用较低级别的压缩来有效使用可用带宽,同时将CPU成本降至最低。

  • 增量恢复

    清单包含备份中每个文件的校验和,以便在还原过程中可以使用这些校验和来加快处理速度。在增量恢复时,备份中不存在的任何文件将首先被删除,然后对其余文件执行校验和。与备份相匹配的文件将保留在原位,其余文件将照常恢复。并行处理可能会导致恢复时间大幅减少。

  • 并行WAL归档

    包括专用的命令将WAL推送到归档并从归档中检索WAL。push命令会自动检测多次推送的WAL段,并在段相同时自动解除重复,否则会引发错误。 push和get命令都通过比较PostgreSQL版本和系统标识符来确保数据库和存储库匹配。这排除了错误配置WAL归档位置的可能性。 异步归档允许将传输转移到另一个并行压缩WAL段的进程,以实现最大的吞吐量。对于写入量非常高的数据库来说,这可能是一个关键功能。

  • 表空间和链接支持

    完全支持表空间,并且还原表空间可以重映射到任何位置。也可以使用一个对开发恢复有用的命令将所有的表空间重新映射到一个位置。

  • Amazon S3支持

    pgBackRest存储库可以存储在Amazon S3上,以实现几乎无限的容量和保留。

  • 加密

    pgBackRest可以对存储库进行加密,以保护无论存储在何处的备份。

  • 与PostgreSQL兼容> = 8.3

    pgBackRest包含了对8.3以下版本的支持,因为旧版本的PostgreSQL仍然是经常使用的。

1. 简介

本用户指南旨在从头到尾按顺序进行,每一节依赖上一节。例如“备份”部分依赖“快速入门”部分中执行的设置。

尽管这些例子是针对Debian / Ubuntu和PostgreSQL 9.4的,但是将这个指南应用到任何Unix发行版和PostgreSQL版本上应该相当容易。请注意,由于Perl代码中的64位操作,目前只支持64位发行版。唯一的特定于操作系统的命令是创建,启动,停止和删除PostgreSQL集群的命令。尽管安装Perl库和可执行文件的位置可能有所不同,但任何Unix系统上的pgBackRest命令都是相同的。

PostgreSQL的配置信息和文档可以在PostgreSQL手册中找到。

本用户指南采用了一些新颖的方法来记录。从XML源生成文档时,每个命令都在虚拟机上运行。这意味着您可以高度自信地确保命令按照所呈现的顺序正确工作。捕获输出并在适当的时候显示在命令之下。如果输出不包括,那是因为它被认为是不相关的或者被认为是从叙述中分心的。

所有的命令都是作为非特权用户运行的,它对root用户和postgres用户都具有sudo权限。也可以直接以各自的用户身份运行这些命令而不用修改,在这种情况下,sudo命令可以被剥离。

2. 概念

2.1 备份

备份是数据库集群的一致副本,可以从硬件故障中恢复,执行时间点恢复或启动新的备用数据库。

  • 全量备份(Full Backup)

    pgBackRest将数据库集簇的全部文件复制到备份服务器。数据库集簇的第一个备份总是全量备份。

    pgBackRest总能从全量备份直接恢复。全量备份的一致性不依赖任何外部文件。

  • 差异备份(Differential Backup)

    pgBackRest仅复制自上次全量备份以来,内容发生变更的数据库群集文件。恢复时,pgBackRest拷贝差异备份中的所有文件,以及之前一次全量备份中所有未发生变更的文件。差异备份的优点是它比全量备份需要更少的硬盘空间,缺点是差异备份的恢复依赖上一次全量备份的有效性。

  • 增量备份(Incremental Backup)

    pgBackRest仅复制自上次备份(可能是另一个增量备份,差异备份或完全备份)以来发生更改的数据库群集文件。由于增量备份只包含自上次备份以来更改的那些文件,因此它们通常远远小于完全备份或差异备份。与差异备份一样,增量备份依赖于其他备份才能有效恢复增量备份。由于增量备份只包含自上次备份以来的文件,所有之前的增量备份都恢复到以前的差异,先前的差异备份和先前的完整备份必须全部有效才能执行增量备份的恢复。如果不存在差异备份,则以前的所有增量备份将恢复到之前的完整备份(必须存在),而完全备份本身必须有效才能恢复增量备份。

2.2 还原

还原是将备份复制到将作为实时数据库集群启动的系统的行为。还原需要备份文件和一个或多个WAL段才能正常工作。

2.3 WAL

WAL是PostgreSQL用来确保没有提交的更改丢失的机制。将事务顺序写入WAL,并且在将这些写入刷新到磁盘时认为事务被提交。之后,后台进程将更改写入主数据库集群文件(也称为堆)。在发生崩溃的情况下,重播WAL以使数据库保持一致。

WAL在概念上是无限的,但在实践中被分解成单独的16MB文件称为段。 WAL段按照命名约定0000000100000A1E000000FE,其中前8个十六进制数字表示时间线,接下来的16个数字是逻辑序列号(LSN)。

2.4 加密

加密是将数据转换为无法识别的格式的过程,除非提供了适当的密码(也称为密码短语)。

pgBackRest将根据用户提供的密码加密存储库,从而防止未经授权访问存储库中的数据。

3. 安装

short version

# cent-os
sudo yum install -y pgbackrest

# ubuntu
sudo apt-get install libdbd-pg-perl libio-socket-ssl-perl libxml-libxml-perl

verbose version

创建一个名为db-primary的新主机来包含演示群集并运行pgBackRest示例。 如果已经安装了pgBackRest,最好确保没有安装先前的副本。取决于pgBackRest的版本可能已经安装在几个不同的位置。以下命令将删除所有先前版本的pgBackRest。

  • db-primary⇒删除以前的pgBackRest安装
sudo rm -f /usr/bin/pgbackrest
sudo rm -f /usr/bin/pg_backrest
sudo rm -rf /usr/lib/perl5/BackRest
sudo rm -rf /usr/share/perl5/BackRest
sudo rm -rf /usr/lib/perl5/pgBackRest
sudo rm -rf /usr/share/perl5/pgBackRest

pgBackRest是用Perl编写的,默认包含在Debian/Ubuntu中。一些额外的模块也必须安装,但是它们可以作为标准包使用。

  • db-primary⇒安装必需的Perl软件包
# cent-os
sudo yum install -y pgbackrest

# ubuntu
sudo apt-get install libdbd-pg-perl libio-socket-ssl-perl libxml-libxml-perl

适用于pgBackRest的Debian / Ubuntu软件包位于apt.postgresql.org。如果没有为您的发行版/版本提供,则可以轻松下载源代码并手动安装。

  • db-primary⇒下载pgBackRest的2.01版本
sudo wget -q -O- \
       https://github.com/pgbackrest/pgbackrest/archive/release/2.01.tar.gz | \
       sudo tar zx -C /root
       
# or without sudo
wget -q -O - https://github.com/pgbackrest/pgbackrest/archive/release/2.01.tar.gz | tar zx -C /tmp
  • db-primary⇒安装pgBackRest
sudo cp -r /root/pgbackrest-release-2.01/lib/pgBackRest \
       /usr/share/perl5
sudo find /usr/share/perl5/pgBackRest -type f -exec chmod 644 {} +
sudo find /usr/share/perl5/pgBackRest -type d -exec chmod 755 {} +
sudo mkdir -m 770 /var/log/pgbackrest
sudo chown postgres:postgres /var/log/pgbackrest
sudo touch /etc/pgbackrest.conf
sudo chmod 640 /etc/pgbackrest.conf
sudo chown postgres:postgres /etc/pgbackrest.conf



sudo cp -r /root/pgbackrest-release-1.27/lib/pgBackRest \
       /usr/share/perl5
sudo find /usr/share/perl5/pgBackRest -type f -exec chmod 644 {} +
sudo find /usr/share/perl5/pgBackRest -type d -exec chmod 755 {} +

sudo cp /root/pgbackrest-release-1.27/bin/pgbackrest /usr/bin/pgbackrest
sudo chmod 755 /usr/bin/pgbackrest
sudo mkdir -m 770 /var/log/pgbackrest
sudo chown postgres:postgres /var/log/pgbackrest
sudo touch /etc/pgbackrest.conf
sudo chmod 640 /etc/pgbackrest.conf
sudo chown postgres:postgres /etc/pgbackrest.conf

pgBackRest包含一个可选的伴随C库,可以增强性能并启用checksum-page选项和加密。预构建的软件包通常比手动构建C库更好,但为了完整性,下面给出了所需的步骤。根据分布情况,可能需要一些软件包,这里不一一列举。

  • db-primary⇒构建并安装C库
sudo sh -c 'cd /root/pgbackrest-release-2.01/libc && \
       perl Makefile.PL INSTALLMAN1DIR=none INSTALLMAN3DIR=none'
sudo make -C /root/pgbackrest-release-2.01/libc test
sudo make -C /root/pgbackrest-release-2.01/libc install

现在pgBackRest应该正确安装了,但最好检查一下。如果任何依赖关系被遗漏,那么当你从命令行运行pgBackRest的时候你会得到一个错误。

  • db-primary⇒确保安装正常
sudo -u postgres pgbackrest
pgBackRest 1.27 - General help

Usage:
    pgbackrest [options] [command]

Commands:
    archive-get     Get a WAL segment from the archive.
    archive-push    Push a WAL segment to the archive.
    backup          Backup a database cluster.
    check           Check the configuration.
    expire          Expire backups that exceed retention.
    help            Get help.
    info            Retrieve information about backups.
    restore         Restore a database cluster.
    stanza-create   Create the required stanza data.
    stanza-upgrade  Upgrade a stanza.
    start           Allow pgBackRest processes to run.
    stop            Stop pgBackRest processes from running.
    version         Get version.

Use 'pgbackrest help [command]' for more information.

mac version

在MacOS上安装可以按照之前的手动安装教程,参考文章:https://hunleyd.github.io/posts/pgBackRest-2.07-and-macOS-Mojave/

# 注意如果需要从终端访问代理,可以使用以下命令:
alias proxy='export all_proxy=socks5://127.0.0.1:1080'
alias unproxy='unset all_proxy'

# 安装 homebrew & wget
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
brew install wget

# install perl DB driver: Pg
perl -MCPAN -e 'install Bundle::DBI'
perl -MCPAN -e 'install Bundle::DBD::Pg'
perl -MCPAN -e 'install IO::Socket::SSL'
perl -MCPAN -e 'install XML::LibXML'

# Download and unzip
wget https://github.com/pgbackrest/pgbackrest/archive/release/2.07.tar.gz

# Copy to Perls lib
sudo cp -r  ~/Downloads/pgbackrest-release-1.27/lib/pgBackRest /Library/Perl/5.18
sudo find /Library/Perl/5.18/pgBackRest -type f -exec chmod 644 {} +
sudo find /Library/Perl/5.18/pgBackRest -type d -exec chmod 755 {} +

# Copy binary to your path
sudo cp ~/Downloads/pgbackrest-release-1.27/bin/pgbackrest /usr/local/bin/
sudo chmod 755 /usr/local/bin/pgbackrest

# Make log dir & conf file. maybe you will change vonng to postgres
sudo mkdir -m 770 /var/log/pgbackrest && sudo touch /etc/pgbackrest.conf
sudo chmod 640 /etc/pgbackrest.conf
sudo chown vonng /etc/pgbackrest.conf /var/log/pgbackrest

# Uninstall
# sudo rm -rf /usr/local/bin/pgbackrest /Library/Perl/5.18/pgBackRest /var/log/pgbackrest /etc/pgbackrest.conf

4. 快速入门

4.1 搭建测试数据库集群

创建示例群集是可选的,但强烈建议试一遍,尤其对于新用户,因为用户指南中的示例命令引用了示例群集。 示例假定演示群集正在默认端口(即5432)上运行。直到后面的部分才会启动群集,因为还有一些配置要做。

  • db-primary⇒创建演示群集
# create database cluster
pg_ctl init -D /var/lib/pgsql/data

# change listen address to *
sed -ie "s/^#listen_addresses = 'localhost'/listen_addresses = '*'/g" /var/lib/pgsql/data/postgresql.conf

# change log prefix 
sed -ie "s/^#log_line_prefix = '%m [%p] '/log_line_prefix = ''/g" /var/lib/pgsql/data/postgresql.conf

默认情况下PostgreSQL只接受本地连接。本示例需要来自其他服务器的连接,将listen_addresses配置为在所有端口上侦听。如果有安全性要求,这样做可能是不合适的。

出于演示目的,log_line_prefix设置将被最低限度地配置。这使日志输出尽可能简短,以更好地说明重要的信息。

4.2 配置集群的备份单元(Stanza)

一个备份单元是指 一组关于PostgreSQL数据库集簇的配置,它定义了数据库的位置,如何备份,归档选项等。大多数数据库服务器只有一个Postgres数据库集簇,因此只有一个备份单元,而备份服务器则对每一个需要备份的数据库集簇都有一个备份单元。

在主群集之后命名该节是诱人的,但是更好的名称描述群集中包含的数据库。由于节名称将用于主节点名称和所有副本,因此选择描述群集实际功能(例如app或dw)的名称(而不是本地群集名称(如main或prod))会更合适。

“Demo”这个名字可以准确地描述这个数据库集簇的目的,所以这里就这么用了。

pgBackRest需要知道PostgreSQL集簇的数据目录所在的位置。备份的时候PostgreSQL可以使用该目录,但恢复的时候PostgreSQL必须停机。备份期,提供给pgBackRest的值将与PostgreSQL运行的路径比较,如果它们不相等则备份将报错。确保db-pathpostgresql.conf中的data_directory完全相同。

默认情况下,Debian / Ubuntu在/ var / lib / postgresql / [版本] / [集群]中存储集群,因此很容易确定数据目录的正确路径。

在创建/etc/pgbackrest.conf文件时,数据库所有者(通常是postgres)必须被授予读取权限。

  • db-primary:/etc/pgbackrest.conf⇒配置PostgreSQL集群数据目录
[demo]
db-path=/var/lib/pgsql/data

pgBackRest配置文件遵循Windows INI约定。部分用括号中的文字表示,每个部分包含键/值对。以#开始的行被忽略,可以用作注释。

4.3 创建存储库

存储库是pgBackRest存储备份和归档WAL段的地方。

新备份很难提前估计需要多少空间。最好的办法是做一些备份,然后记录不同类型备份的大小(full / incr / diff),并测量每天产生的WAL数量。这将给你一个大致需要多少空间的概念。当然随着数据库的发展,需求可能会随着时间而变化。

对于这个演示,存储库将被存储在与PostgreSQL服务器相同的主机上。这是最简单的配置,在使用传统备份软件备份数据库主机的情况下非常有用。

  • db-primary⇒创建pgBackRest存储库
sudo mkdir /var/lib/pgbackrest
sudo chmod 750 /var/lib/pgbackrest
sudo chown postgres:postgres /var/lib/pgbackrest

存储库路径必须配置,以便pgBackRest知道在哪里找到它。

  • db-primary:/etc/pgbackrest.conf ⇒配置pgBackRest存储库路径
[demo]
db-path=/var/lib/postgresql/9.4/demo

[global]
repo-path=/var/lib/pgbackrest

4.4 配置归档

备份正在运行的PostgreSQL集群需要启用WAL归档。请注意,即使没有对群集进行明确写入,在备份过程中也会创建至少一个WAL段。

  • db-primary:/var/lib/pgsql/data/postgresql.conf⇒ 配置存档设置
archive_command = 'pgbackrest --stanza=demo archive-push %p'
archive_mode = on
listen_addresses = '*'
log_line_prefix = ''
max_wal_senders = 3
wal_level = hot_standby

wal_level设置必须至少设置为archive,但hot_standbylogical也适用于备份。 在PostgreSQL 10中,相应的wal_level是replica。将wal_level设置为hot_standy并增加max_wal_senders是一个好主意,即使您当前没有运行热备用数据库也是一个好主意,因为这样可以在不重新启动主群集的情况下添加它们。在进行这些更改之后和执行备份之前,必须重新启动PostgreSQL群集。

4.5 保留配置(retention)

pgBackRest会根据保留配置对备份进行过期处理。

  • db-primary: /etc/pgbackrest.conf ⇒ 配置为保留两个全量备份
[demo]
db-path=/var/lib/postgresql/9.4/demo

[global]
repo-path=/var/lib/pgbackrest

retention-full=2

更多关于保留的信息可以在Retention一节找到。

4.6 配置存储库加密

该节创建命令必须在仓库位于初始化节的主机上运行。建议的检查命令后运行节创建,确保归档和备份的配置是否正确。

  • db-primary: /etc/pgbackrest.conf ⇒ 配置pgBackRest存储库加密
[demo]
db-path=/var/lib/postgresql/9.4/demo

[global]
repo-cipher-pass=zWaf6XtpjIVZC5444yXB+cgFDFl7MxGlgkZSaoPvTGirhPygu4jOKOXf9LO4vjfO
repo-cipher-type=aes-256-cbc
repo-path=/var/lib/pgbackrest
retention-full=2

一旦存储库(repository)配置完成且备份单元创建并检查完毕,存储库加密设置便不能更改。

4.7 创建存储单元

stanza-create命令必须在仓库位于初始化节的主机上运行。建议在stanza-create命令之后运行check命令,确保归档和备份的配置是否正确。

  • db-primary ⇒ 创建存储单元并检查配置
postgres$ pgbackrest --stanza=demo --log-level-console=info stanza-create

P00   INFO: stanza-create command begin 1.27: --db1-path=/var/lib/postgresql/9.4/demo --log-level-console=info --no-log-timestamp --repo-cipher-pass= --repo-cipher-type=aes-256-cbc --repo-path=/var/lib/pgbackrest --stanza=demo

P00   INFO: stanza-create command end: completed successfully
1. Install

  $ sudo yum install -y pgbackrest


2. configuration

  1) pgbackrest.conf

    $ sudo vim /etc/pgbackrest.conf
      [global]
      repo-cipher-pass=O8lotSfiXYSYomc9BQ0UzgM9PgXoyNo1t3c0UmiM7M26rOETVNawbsW7BYn+I9es
      repo-cipher-type=aes-256-cbc
      repo-path=/var/backups
      retention-full=2
      retention-diff=2
      retention-archive=2
      start-fast=y
      stop-auto=y
      archive-copy=y
      
      [global:archive-push]
      archive-async=y
      process-max=4
      
      [test]
      db-path=/var/lib/pgsql/9.5/data
      process-max=10

  2) postgresql.conf

    $ sudo vim /var/lib/pgsql/9.5/data/postgresql.conf
      archive_command = '/usr/bin/pgbackrest --stanza=test archive-push %p'

3. Initial

  $ sudo chown -R postgres:postgres /var/backups/
  $ sudo -u postgres pgbackrest --stanza=test --log-level-console=info stanza-create
    2018-01-04 11:38:21.082 P00   INFO: stanza-create command begin 1.27: --db1-path=/var/lib/pgsql/9.5/data --log-level-console=info --repo-cipher-pass=<redacted> --repo-cipher-type=aes-256-cbc --repo-path=/var/backups --stanza=test
    2018-01-04 11:38:21.533 P00   INFO: stanza-create command end: completed successfully
  $ sudo service postgresql-9.5 reload

  $ sudo -u postgres pgbackrest --stanza=test --log-level-console=info info
  stanza: test
      status: error (no valid backups)
  
      db (current)
          wal archive min/max (9.5-1): 0000000500041CFD000000BE / 0000000500041CFD000000BE

4. Backup

  $ sudo -u postgres pgbackrest --stanza=test --log-level-console=info --type=full backup
  2018-01-04 16:24:57.329 P00   INFO: backup command begin 1.27: --archive-copy --db1-path=/var/lib/pgsql/9.5/data --log-level-console=info --process-max=40 --repo-cipher-pass=<redacted> --repo-cipher-type=aes-
  256-cbc --repo-path=/var/backups --retention-archive=2 --retention-diff=2 --retention-full=2 --stanza=test --start-fast --stop-auto --type=full
  2018-01-04 16:24:58.192 P00   INFO: execute exclusive pg_start_backup() with label "pgBackRest backup started at 2018-01-04 16:24:57": backup begins after the requested immediate checkpoint completes
  2018-01-04 16:24:58.495 P00   INFO: backup start archive = 0000000500041CFD000000C0, lsn = 41CFD/C0000060
  2018-01-04 16:26:04.863 P34   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016471.83 (1GB, 0%) checksum ab17fdd9f70652a0de55fd0da5d2b6b1f48de490
  2018-01-04 16:26:04.923 P35   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016471.82 (1GB, 0%) checksum 5acba8d0eb70dcdc64199201ee3999743e747699
  2018-01-04 16:26:05.208 P37   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016471.80 (1GB, 0%) checksum 74e2f876d8e7d68ab29624d53d33b0c6cb078382
  2018-01-04 16:26:06.973 P30   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016471.87 (1GB, 1%) checksum b6d6884724178476ee24a9a1a812e8941d4da396
  2018-01-04 16:26:09.434 P24   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016471.92 (1GB, 1%) checksum c5e6232171e0a7cadc7fc57f459a7bc75c2955d8
  2018-01-04 16:26:09.860 P40   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016471.78 (1GB, 1%) checksum 95d94b1bac488592677f7942b85ab5cc2a39bf62
  2018-01-04 16:26:10.708 P33   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016471.84 (1GB, 2%) checksum 32e8c83f9bdc5934552f54ee59841f1877b04f69
  2018-01-04 16:26:11.035 P28   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016471.89 (1GB, 2%) checksum aa7bee244d2d2c49b56bc9b2e0b9bf36f2bcc227
  2018-01-04 16:26:11.239 P17   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016471.99 (1GB, 2%) checksum 218bcecf7da2230363926ca00d719011a6c27467
  2018-01-04 16:26:11.383 P18   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016471.98 (1GB, 2%) checksum 38744d27867017dfadb6b520b6c0034daca67481
  ...
  2018-01-04 16:34:07.782 P32   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016471.184 (852.7MB, 98%) checksum 92990e159b0436d5a6843d21b2d888b636e246cf
  2018-01-04 16:34:07.935 P10   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016468.100 (1GB, 98%) checksum d9e0009447a5ef068ce214239f1c999cc5251462
  2018-01-04 16:34:10.212 P35   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016476.3 (569.6MB, 98%) checksum d02e6efed6cea3005e1342d9d6a8e27afa5239d7
  2018-01-04 16:34:12.289 P20   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016468.10 (1GB, 98%) checksum 1a99468cd18e9399ade9ddc446eb21f1c4a1f137
  2018-01-04 16:34:13.270 P03   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016468.1 (1GB, 99%) checksum c0ddb80d5f1be83aa4557777ad05adb7cbc47e72
  2018-01-04 16:34:13.792 P38   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016468 (1GB, 99%) checksum 767a2e0d21063b92b9cebc735fbb0e3c7332218d
  2018-01-04 16:34:18.446 P26   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016473.3 (863.9MB, 99%) checksum 87ba54690ea418c2ddd1d488c56fa164ebda5042
  2018-01-04 16:34:23.551 P13   INFO: backup file /var/lib/pgsql/9.5/data/base/16384/3072016475.7 (895.4MB, 100%) checksum a2693bfdc84940c82b7d77a13b752e33448bb008
  2018-01-04 16:34:23.648 P00   INFO: full backup size = 341.5GB
  2018-01-04 16:34:23.649 P00   INFO: execute exclusive pg_stop_backup() and wait for all WAL segments to archive
  2018-01-04 16:34:37.774 P00   INFO: backup stop archive = 0000000500041CFD000000C0, lsn = 41CFD/C0000168
  2018-01-04 16:34:39.648 P00   INFO: new backup label = 20180104-162457F
  2018-01-04 16:34:41.004 P00   INFO: backup command end: completed successfully
  2018-01-04 16:34:41.005 P00   INFO: expire command begin 1.27: --log-level-console=info --repo-cipher-pass=<redacted> --repo-cipher-type=aes-256-cbc --repo-path=/var/backups --retention-archive=2 --retention-diff=2 --retention-full=2 --stanza=test
  2018-01-04 16:34:41.028 P00   INFO: full backup total < 2 - using oldest full backup for 9.5-1 archive retention
  2018-01-04 16:34:41.034 P00   INFO: expire command end: completed successfully 

  $ sudo -u postgres pgbackrest --stanza=test --log-level-console=info info
  stanza: test
      status: ok
  
      db (current)
          wal archive min/max (9.5-1): 0000000500041CFD000000C0 / 0000000500041CFD000000C0
  
          full backup: 20180104-162457F
              timestamp start/stop: 2018-01-04 16:24:57 / 2018-01-04 16:34:38
              wal start/stop: 0000000500041CFD000000C0 / 0000000500041CFD000000C0
              database size: 341.5GB, backup size: 341.5GB
              repository size: 153.6GB, repository backup size: 153.6GB


5. restore

  $ sudo vim /etc/pgbackrest.conf
    db-path=/export/pgdata
  $ sudo mkdir /export/pgdata
  $ sudo chown -R postgres:postgres /export/pgdata/
  $ sudo chmod 0700 /export/pgdata/
  $ sudo -u postgres pgbackrest --stanza=test --log-level-console=info --delta --set=20180104-162457F --type=time "--target=2018-01-04 16:34:38" restore
  2018-01-04 17:04:23.170 P00   INFO: restore command begin 1.27: --db1-path=/export/pgdata --delta --log-level-console=info --process-max=40 --repo-cipher-pass=<redacted> --repo-cipher-type=aes-256-cbc --repo-
  path=/var/backups --set=20180104-162457F --stanza=test "--target=2018-01-04 16:34:38" --type=time
  WARN: --delta or --force specified but unable to find 'PG_VERSION' or 'backup.manifest' in '/export/pgdata' to confirm that this is a valid $PGDATA directory.  --delta and --force have been disabled and if an
  y files exist in the destination directories the restore will be aborted.
  2018-01-04 17:04:23.313 P00   INFO: restore backup set 20180104-162457F
  2018-01-04 17:04:23.935 P00   INFO: remap $PGDATA directory to /export/pgdata
  2018-01-04 17:05:09.626 P01   INFO: restore file /export/pgdata/base/16384/3072016476.2 (1GB, 0%) checksum be1145405b8bcfa57c3f1fd8d0a78eee3ed2df21
  2018-01-04 17:05:09.627 P04   INFO: restore file /export/pgdata/base/16384/3072016475.6 (1GB, 0%) checksum d2bc51d5b58dea3d14869244cd5a23345dbc4ffb
  2018-01-04 17:05:09.627 P27   INFO: restore file /export/pgdata/base/16384/3072016471.9 (1GB, 0%) checksum 94cbf743143baffac0b1baf41e60d4ed99ab910f
  2018-01-04 17:05:09.627 P37   INFO: restore file /export/pgdata/base/16384/3072016471.80 (1GB, 1%) checksum 74e2f876d8e7d68ab29624d53d33b0c6cb078382
  2018-01-04 17:05:09.627 P38   INFO: restore file /export/pgdata/base/16384/3072016471.8 (1GB, 1%) checksum 5f0edd85543c9640d2c6cf73257165e621a6b295
  2018-01-04 17:05:09.652 P02   INFO: restore file /export/pgdata/base/16384/3072016476.1 (1GB, 1%) checksum 3e262262b106bdc42c9fe17ebdf62bc4ab2e8166
  ...
  2018-01-04 17:09:15.415 P34   INFO: restore file /export/pgdata/base/1/13142 (0B, 100%)
  2018-01-04 17:09:15.415 P35   INFO: restore file /export/pgdata/base/1/13137 (0B, 100%)
  2018-01-04 17:09:15.415 P36   INFO: restore file /export/pgdata/base/1/13132 (0B, 100%)
  2018-01-04 17:09:15.415 P37   INFO: restore file /export/pgdata/base/1/13127 (0B, 100%)
  2018-01-04 17:09:15.418 P00   INFO: write /export/pgdata/recovery.conf
  2018-01-04 17:09:15.950 P00   INFO: restore global/pg_control (performed last to ensure aborted restores cannot be started)
  2018-01-04 17:09:16.588 P00   INFO: restore command end: completed successfully

  $ sudo vim /export/pgdata/postgresql.conf
    port = 5433
  $ sudo -u postgres /usr/pgsql-9.5/bin/pg_ctl -D /export/pgdata/ start
  server starting
  < 2018-01-04 17:13:47.361 CST >LOG:  redirecting log output to logging collector process
  < 2018-01-04 17:13:47.361 CST >HINT:  Future log output will appear in directory "pg_log".

  $ sudo -u postgres psql -p5433
  psql (9.5.10)
  Type "help" for help.
  
  postgres=# \q

6. archive_command and restore_command
  1) on master
    $ sudo vim /var/lib/pgsql/9.5/data/postgresql.conf
      archive_command = '/usr/bin/pgbackrest --stanza=test archive-push %p'
    $ sudo service postgresql-9.5 reload
    $ sudo yum install -y -q nfs-utils
    $ sudo echo "/var/backups 10.191.0.0/16(rw)" > /etc/exports
    $ sudo service nfs start

  2) on slave
    $ sudo mount -o v3 master_ip:/var/backups /var/backups
    $ sudo vim /etc/pgbackrest.conf
      [global]
      repo-cipher-pass=O8lotSfiXYSYomc9BQ0UzgM9PgXoyNo1t3c0UmiM7M26rOETVNawbsW7BYn+I9es
      repo-cipher-type=aes-256-cbc
      repo-path=/var/backups
      retention-full=2
      retention-diff=2
      retention-archive=2
      start-fast=y
      stop-auto=y
      archive-copy=y

      [global:archive-push]
      archive-async=y
      process-max=4

      [test]
      db-path=/var/lib/pgsql/9.5/data
      process-max=10

    $ sudo vim /var/lib/pgsql/9.5/data/recovery.conf
      restore_command = '/usr/bin/pgbackrest --stanza=test archive-get %f "%p"'

Pgbouncer快速上手

Pgbouncer是一个轻量级的数据库连接池,这里简单介绍Pgbouncer的配置、管理与使用。

Pgbouncer是一个轻量级的数据库连接池。

概要

pgbouncer [-d][-R][-v][-u user] <pgbouncer.ini>
pgbouncer -V|-h

描述

pgbouncer 是一个PostgreSQL连接池。 任何目标应用程序都可以连接到 pgbouncer, 就像它是PostgreSQL服务器一样,pgbouncer 将创建到实际服务器的连接, 或者它将重用其中一个现有的连接。

pgbouncer 的目的是为了降低打开PostgreSQL新连接时的性能影响。

为了不影响连接池的事务语义,pgbouncer 在切换连接时,支持多种类型的池化:

  • 会话连接池(Session pooling)

    最礼貌的方法。当客户端连接时,将在客户端保持连接的整个持续时间内分配一个服务器连接。 当客户端断开连接时,服务器连接将放回到连接池中。这是默认的方法。

  • 事务连接池(Transaction pooling)

    服务器连接只有在一个事务的期间内才指派给客户端。 当PgBouncer发觉事务结束的时候,服务器连接将会放回连接池中。

  • 语句连接池(Statement pooling)

    最激进的模式。在查询完成后,服务器连接将立即被放回连接池中。 该模式中不允许多语句事务,因为它们会中断。

pgbouncer 的管理界面由连接到特殊’虚拟’数据库 pgbouncer 时可用的一些新的 SHOW 命令组成。

上手

基本设置和用法如下。

  1. 创建一个pgbouncer.ini文件。pgbouncer(5) 的详细信息。简单例子

    [databases]
    template1 = host=127.0.0.1 port=5432 dbname=template1
    
    [pgbouncer]
    listen_port = 6543
    listen_addr = 127.0.0.1
    auth_type = md5
    auth_file = users.txt
    logfile = pgbouncer.log
    pidfile = pgbouncer.pid
    admin_users = someuser
    
  2. 创建包含许可用户的 users.txt 文件

    "someuser" "same_password_as_in_server"
    
  3. 加载 pgbouncer

    $ pgbouncer -d pgbouncer.ini
    
  4. 你的应用程序(或 客户端psql)已经连接到 pgbouncer ,而不是直接连接到PostgreSQL服务器了吗:

     psql -p 6543 -U someuser template1
    
  5. 通过连接到特殊管理数据库 pgbouncer 来管理 pgbouncer, 发出 show help; 开始

    $ psql -p 6543 -U someuser pgbouncer
    pgbouncer=# show help;
    NOTICE:  Console usage
    DETAIL:
      SHOW [HELP|CONFIG|DATABASES|FDS|POOLS|CLIENTS|SERVERS|SOCKETS|LISTS|VERSION]
      SET key = arg
      RELOAD
      PAUSE
      SUSPEND
      RESUME
      SHUTDOWN
    
  6. 如果你修改了pgbouncer.ini文件,可以用下列命令重新加载:

    pgbouncer=# RELOAD;
    

命令行开关

-d 在后台运行。没有它,进程将在前台运行。 注意:在Windows上不起作用,pgbouncer 需要作为服务运行。
-R 进行在线重启。这意味着连接到正在运行的进程,从中加载打开的套接字, 然后使用它们。如果没有活动进程,请正常启动。 注意:只有在操作系统支持Unix套接字且 unix_socket_dir 在配置中未被禁用时才可用。在Windows机器上不起作用。 不使用TLS连接,它们被删除了。
-u user 启动时切换到给定的用户。
-v 增加详细度。可多次使用。
-q 安静 - 不要登出到stdout。请注意, 这不影响日志详细程度,只有该stdout不被使用。用于init.d脚本。
-V 显示版本。
-h 显示简短的帮助。
–regservice Win32:注册pgbouncer作为Windows服务运行。 service_name 配置参数值用作要注册的名称。
–unregservice Win32: 注销Windows服务。

管理控制台

通过正常连接到数据库 pgbouncer 可以使用控制台

$ psql -p 6543 pgbouncer

只有在配置参数 admin_usersstats_users 中列出的用户才允许登录到控制台。 (除了 auth_mode=any 时,任何用户都可以作为stats_user登录。)

另外,如果通过Unix套接字登录,并且客户端具有与运行进程相同的Unix用户uid, 允许用户名 pgbouncer 不使用密码登录。

SHOW命令

SHOW STATS;

显示统计信息。

字段 说明
database 统计信息按数据库组织
total_xact_count SQL事务总数
total_query_count SQL查询总数
total_received 收到的网络流量(字节)
total_sent 发送的网络流量(字节)
total_xact_time 在事务中的总时长
total_query_time 在查询中的总时长
total_wait_time 在等待中的总时长
avg_xact_count (当前)平均事务数
avg_query_count (当前)平均查询数
avg_recv (当前)平均每秒收到字节数
avg_sent (当前)平均每秒发送字节数
avg_xact_time 平均事务时长(以毫秒计)
avg_query_time 平均查询时长(以毫秒计)
avg_wait_time 平均等待时长(以毫秒计)

两个变体:SHOW STATS_TOTALSSHOW STATS_AVERAGES,分别显示整体与平均的统计。

TOTAL实际上是Counter,而AVG通常是Guage。监控时建议采集TOTAL,查看时建议查看AVG。

SHOW SERVERS

字段 说明
type Server的类型固定为S
user Pgbouncer用于连接数据库的用户名
state pgbouncer服务器连接的状态,activeusedidle 之一。
addr PostgreSQL server服务器的IP地址。
port PostgreSQL服务器的端口。
local_addr 本机连接启动的地址。
local_port 本机上的连接启动端口。
connect_time 建立连接的时间。
request_time 最后一个请求发出的时间。
ptr 该连接内部对象的地址,用作唯一标识符
link 服务器配对的客户端连接地址。
remote_pid 后端服务器进程的pid。如果通过unix套接字进行连接, 并且OS支持获取进程ID信息,则为OS pid。 否则它将从服务器发送的取消数据包中提取出来,如果服务器是Postgres, 则应该是PID,但是如果服务器是另一个PgBouncer,则它是一个随机数。

SHOW CLIENTS

字段 说明
type Client的类型固定为C
user 客户端用于连接的用户
state pgbouncer客户端连接的状态,activeusedwaitingidle 之一。
addr 客户端的IP地址。
port 客户端的端口
local_addr 本机地址
local_port 本机端口
connect_time 建立连接的时间。
request_time 最后一个请求发出的时间。
ptr 该连接内部对象的地址,用作唯一标识符
link 配对的服务器端连接地址。
remote_pid 如果通过unix套接字进行连接, 并且OS支持获取进程ID信息,则为OS pid。

SHOW CLIENTS

字段 说明
type Client的类型固定为C
user 客户端用于连接的用户
state pgbouncer客户端连接的状态,activeusedwaitingidle 之一。
addr 客户端的IP地址。
port 客户端的端口
local_addr 本机地址
local_port 本机端口
connect_time 建立连接的时间。
request_time 最后一个请求发出的时间。
ptr 该连接内部对象的地址,用作唯一标识符
link 配对的服务器端连接地址。
remote_pid 如果通过unix套接字进行连接, 并且OS支持获取进程ID信息,则为OS pid。

SHOW POOLS;

为每对(database, user)创建一个新的连接池选项。

  • database

    数据库名称。

  • user

    用户名。

  • cl_active

    链接到服务器连接并可以处理查询的客户端连接。

  • cl_waiting

    已发送查询但尚未获得服务器连接的客户端连接。

  • sv_active

    链接到客户端的服务器连接。

  • sv_idle

    未使用且可立即用于客户机查询的服务器连接。

  • sv_used

    已经闲置超过 server_check_delay 时长的服务器连接, 所以在它可以使用之前,需要运行 server_check_query。

  • sv_tested

    当前正在运行 server_reset_query 或 server_check_query 的服务器连接。

  • sv_login

    当前正在登录过程中的服务器连接。

  • maxwait

    队列中第一个(最老的)客户端已经等待了多长时间,以秒计。 如果它开始增加,那么服务器当前的连接池处理请求的速度不够快。 原因可能是服务器负载过重或 pool_size 设置过小。

  • pool_mode

    正在使用的连接池模式。

SHOW LISTS;

在列(不是行)中显示以下内部信息:

  • databases

    数据库计数。

  • users

    用户计数。

  • pools

    连接池计数。

  • free_clients

    空闲客户端计数。

  • used_clients

    使用了的客户端计数。

  • login_clients

    login 状态中的客户端计数。

  • free_servers

    空闲服务器计数。

  • used_servers

    使用了的服务器计数。

SHOW USERS;

  • name

    用户名

  • pool_mode

    用户重写的pool_mode,如果使用默认值,则返回NULL。

SHOW DATABASES;

  • name

    配置的数据库项的名称。

  • host

    pgbouncer连接到的主机。

  • port

    pgbouncer连接到的端口。

  • database

    pgbouncer连接到的实际数据库名称。

  • force_user

    当用户是连接字符串的一部分时,pgbouncer和PostgreSQL 之间的连接被强制给给定的用户,不管客户端用户是谁。

  • pool_size

    服务器连接的最大数量。

  • pool_mode

    数据库的重写pool_mode,如果使用默认值则返回NULL。

SHOW FDS;

内部命令 - 显示与附带的内部状态一起使用的fds列表。

当连接的用户使用用户名"pgbouncer"时, 通过Unix套接字连接并具有与运行过程相同的UID,实际的fds通过连接传递。 该机制用于进行在线重启。 注意:这不适用于Windows机器。

此命令还会阻止内部事件循环,因此在使用PgBouncer时不应该使用它。

  • fd

    文件描述符数值。

  • task

    poolerclientserver 之一。

  • user

    使用该FD的连接的用户。

  • database

    使用该FD的连接的数据库。

  • addr

    使用FD的连接的IP地址,如果使用unix套接字则是 unix

  • port

    使用FD的连接的端口。

  • cancel

    取消此连接的键。

  • link

    对应服务器/客户端的fd。如果空闲则为NULL。

SHOW CONFIG;

显示当前的配置设置,一行一个,带有下列字段:

  • key

    配置变量名

  • value

    配置值

  • changeable

    yes 或者 no,显示运行时变量是否可更改。 如果是 no,则该变量只能在启动时改变。

SHOW DNS_HOSTS;

显示DNS缓存中的主机名。

  • hostname

    主机名。

  • ttl

    直到下一次查找经过了多少秒。

  • addrs

    地址的逗号分隔的列表。

SHOW DNS_ZONES

显示缓存中的DNS区域。

  • zonename

    区域名称。

  • serial

    当前序列号。

  • count

    属于此区域的主机名。

过程控制命令

PAUSE [db];

PgBouncer尝试断开所有服务器的连接,首先等待所有查询完成。 所有查询完成之前,命令不会返回。在数据库重新启动时使用。如果提供了数据库名称,那么只有该数据库将被暂停。

DISABLE db;

拒绝给定数据库上的所有新客户端连接。

ENABLE db;

在上一个的 DISABLE 命令之后允许新的客户端连接。

KILL db;

立即删除给定数据库上的所有客户端和服务器连接。

SUSPEND;

所有套接字缓冲区被刷新,PgBouncer停止监听它们上的数据。 在所有缓冲区为空之前,命令不会返回。在PgBouncer在线重新启动时使用。

RESUME [db];

从之前的 PAUSESUSPEND 命令中恢复工作。

SHUTDOWN;

PgBouncer进程将会退出。

RELOAD;

PgBouncer进程将重新加载它的配置文件并更新可改变的设置。

信号

  • SIGHUP

    重新加载配置。与在控制台上发出命令 RELOAD; 相同。

  • SIGINT

    安全关闭。与在控制台上发出 PAUSE;SHUTDOWN; 相同。

  • SIGTERM

    立即关闭。与在控制台上发出 SHUTDOWN; 相同。

Libevent设置

来自libevent的文档:

可以通过分别设置环境变量EVENT_NOEPOLL、EVENT_NOKQUEUE、
VENT_NODEVPOLL、EVENT_NOPOLL或EVENT_NOSELECT来禁用对
epoll、kqueue、devpoll、poll或select的支持。

通过设置环境变量EVENT_SHOW_METHOD,libevent显示它使用的内核通知方法。 

Pgbouncer参数配置

默认配置

;; 数据库名 = 连接串
;;
;; 连接串包括这些参数:
;;   dbname= host= port= user= password=
;;   client_encoding= datestyle= timezone=
;;   pool_size= connect_query=
;;   auth_user=
[databases]

instanceA = host=10.1.1.1 dbname=core
instanceB = host=102.2.2.2 dbname=payment

; 通过Unix套接字的 foodb
;foodb =

; 将bardb在localhost上重定向为bazdb 
;bardb = host=localhost dbname=bazdb

; 使用单个用户访问目标数据库
;forcedb = host=127.0.0.1 port=300 user=baz password=foo client_encoding=UNICODE datestyle=ISO connect_query='SELECT 1'

; 使用定制的连接池大小
;nondefaultdb = pool_size=50 reserve_pool=10

; 如果用户不在认证文件中,替换使用的auth_user; auth_user必须在认证文件中
; foodb = auth_user=bar

; 保底的通配连接串
;* = host=testserver

;; Pgbouncer配置区域
[pgbouncer]

;;;
;;; 管理设置
;;;

logfile = /var/log/pgbouncer/pgbouncer.log
pidfile = /var/run/pgbouncer/pgbouncer.pid

;;;
;;; 监听哪里的客户端
;;;

; 监听IP地址,* 代表所有IP
listen_addr = *
listen_port = 6432

; -R选项也会处理Unix Socket.
; 在Debian上是 /var/run/postgresql
;unix_socket_dir = /tmp
;unix_socket_mode = 0777
;unix_socket_group =

;;;
;;; TLS配置
;;;

;; 选项:disable, allow, require, verify-ca, verify-full
;client_tls_sslmode = disable

;; 信任CA证书的路径
;client_tls_ca_file = <system default>

;; 代表客户端的私钥与证书路径
;; 从客户端接受TLS连接时,这是必须参数
;client_tls_key_file =
;client_tls_cert_file =

;; fast, normal, secure, legacy, <ciphersuite string>
;client_tls_ciphers = fast

;; all, secure, tlsv1.0, tlsv1.1, tlsv1.2
;client_tls_protocols = all

;; none, auto, legacy
;client_tls_dheparams = auto

;; none, auto, <curve name>
;client_tls_ecdhcurve = auto

;;;
;;; 连接到后端数据库时的TLS设置
;;;

;; disable, allow, require, verify-ca, verify-full
;server_tls_sslmode = disable

;; 信任CA证书的路径
;server_tls_ca_file = <system default>

;; 代表后端的私钥与证书
;; 只有当后端服务器需要客户端证书时需要
;server_tls_key_file =
;server_tls_cert_file =

;; all, secure, tlsv1.0, tlsv1.1, tlsv1.2
;server_tls_protocols = all

;; fast, normal, secure, legacy, <ciphersuite string>
;server_tls_ciphers = fast

;;;
;;; 认证设置
;;;

; any, trust, plain, crypt, md5, cert, hba, pam
auth_type = trust
auth_file = /etc/pgbouncer/userlist.txt

;; HBA风格的认证配置文件
# auth_hba_file = /pg/data/pg_hba.conf

;; 从数据库获取密码的查询,结果必须包含两列: 用户名 与 密码哈希值.
;auth_query = SELECT usename, passwd FROM pg_shadow WHERE usename=$1

;;;
;;; 允许访问虚拟数据库'pgbouncer'的用户
;;;

; 允许修改设置,逗号分隔的用户名列表。
admin_users = postgres

; 允许使用SHOW命令,逗号分隔的用户名列表。
stats_users = stats, postgres

;;;
;;; 连接池设置
;;;

; 什么时候服务端连接会被放回到池中?(默认为session)
;   session      - 会话模式,当客户端断开连接时
;   transaction  - 事务模式,当事务结束时
;   statement    - 语句模式,当语句结束时
pool_mode = session

; 客户端释放连接后,用于立刻清理连接的查询。
; 不用把ROLLBACK放在这儿,当事务还没结束时,Pgbouncer是不会重用连接的。
;
; 8.3及更高版本的查询:
;   DISCARD ALL;
;
; 更老的版本:
;   RESET ALL; SET SESSION AUTHORIZATION DEFAULT
;
; 如果启用事务级别的连接池,则为空。
;
server_reset_query = DISCARD ALL


; server_reset_query 是否需要在任何情况下执行。
; 如果关闭(默认),server_reset_query 只会在会话级连接池中使用。
;server_reset_query_always = 0

;
; Comma-separated list of parameters to ignore when given
; in startup packet.  Newer JDBC versions require the
; extra_float_digits here.
;
;ignore_startup_parameters = extra_float_digits

;
; When taking idle server into use, this query is ran first.
;   SELECT 1
;
;server_check_query = select 1

; If server was used more recently that this many seconds ago,
; skip the check query.  Value 0 may or may not run in immediately.
;server_check_delay = 30

; Close servers in session pooling mode after a RECONNECT, RELOAD,
; etc. when they are idle instead of at the end of the session.
;server_fast_close = 0

;; Use <appname - host> as application_name on server.
;application_name_add_host = 0

;;;
;;; 连接限制
;;;

; 最大允许的连接数
max_client_conn = 100

; 默认的连接池尺寸,当使用事务连接池时,20是一个合适的值。对于会话级连接池而言
; 该值是你想在同一时刻处理的最大连接数。
default_pool_size = 20

;; 连接池中最少的保留连接数
;min_pool_size = 0

; 出现问题时,最多允许多少条额外连接
;reserve_pool_size = 0

; 如果客户端等待超过这么多秒,使用备用连接池
;reserve_pool_timeout = 5

; 单个数据库/用户最多允许多少条连接
;max_db_connections = 0
;max_user_connections = 0

; If off, then server connections are reused in LIFO manner
;server_round_robin = 0

;;;
;;; Logging
;;;

;; Syslog settings
;syslog = 0
;syslog_facility = daemon
;syslog_ident = pgbouncer

; log if client connects or server connection is made
;log_connections = 1

; log if and why connection was closed
;log_disconnections = 1

; log error messages pooler sends to clients
;log_pooler_errors = 1

;; Period for writing aggregated stats into log.
;stats_period = 60

;; Logging verbosity.  Same as -v switch on command line.
;verbose = 0

;;;
;;; Timeouts
;;;

;; Close server connection if its been connected longer.
;server_lifetime = 3600

;; Close server connection if its not been used in this time.
;; Allows to clean unnecessary connections from pool after peak.
;server_idle_timeout = 600

;; Cancel connection attempt if server does not answer takes longer.
;server_connect_timeout = 15

;; If server login failed (server_connect_timeout or auth failure)
;; then wait this many second.
;server_login_retry = 15

;; Dangerous.  Server connection is closed if query does not return
;; in this time.  Should be used to survive network problems,
;; _not_ as statement_timeout. (default: 0)
;query_timeout = 0

;; Dangerous.  Client connection is closed if the query is not assigned
;; to a server in this time.  Should be used to limit the number of queued
;; queries in case of a database or network failure. (default: 120)
;query_wait_timeout = 120

;; Dangerous.  Client connection is closed if no activity in this time.
;; Should be used to survive network problems. (default: 0)
;client_idle_timeout = 0

;; Disconnect clients who have not managed to log in after connecting
;; in this many seconds.
;client_login_timeout = 60

;; Clean automatically created database entries (via "*") if they
;; stay unused in this many seconds.
; autodb_idle_timeout = 3600

;; How long SUSPEND/-R waits for buffer flush before closing connection.
;suspend_timeout = 10

;; Close connections which are in "IDLE in transaction" state longer than
;; this many seconds.
;idle_transaction_timeout = 0

;;;
;;; Low-level tuning options
;;;

;; buffer for streaming packets
;pkt_buf = 4096

;; man 2 listen
;listen_backlog = 128

;; Max number pkt_buf to process in one event loop.
;sbuf_loopcnt = 5

;; Maximum PostgreSQL protocol packet size.
;max_packet_size = 2147483647

;; networking options, for info: man 7 tcp

;; Linux: notify program about new connection only if there
;; is also data received.  (Seconds to wait.)
;; On Linux the default is 45, on other OS'es 0.
;tcp_defer_accept = 0

;; In-kernel buffer size (Linux default: 4096)
;tcp_socket_buffer = 0

;; whether tcp keepalive should be turned on (0/1)
;tcp_keepalive = 1

;; The following options are Linux-specific.
;; They also require tcp_keepalive=1.

;; count of keepalive packets
;tcp_keepcnt = 0

;; how long the connection can be idle,
;; before sending keepalive packets
;tcp_keepidle = 0

;; The time between individual keepalive probes.
;tcp_keepintvl = 0

;; DNS lookup caching time
;dns_max_ttl = 15

;; DNS zone SOA lookup period
;dns_zone_check_period = 0

;; DNS negative result caching time
;dns_nxdomain_ttl = 15

;;;
;;; Random stuff
;;;

;; Hackish security feature.  Helps against SQL-injection - when PQexec is disabled,
;; multi-statement cannot be made.
;disable_pqexec = 0

;; Config file to use for next RELOAD/SIGHUP.
;; By default contains config file from command line.
;conffile

;; Win32 service name to register as.  job_name is alias for service_name,
;; used by some Skytools scripts.
;service_name = pgbouncer
;job_name = pgbouncer

;; Read additional config from the /etc/pgbouncer/pgbouncer-other.ini file
;%include /etc/pgbouncer/pgbouncer-other.ini

PG服务器日志常规配置

建议配置PostgreSQL的日志格式为CSV,方便分析,而且可以直接导入PostgreSQL数据表中。

建议配置PostgreSQL的日志格式为CSV,方便分析,而且可以直接导入PostgreSQL数据表中。

日志相关配置项

log_destination ='csvlog'
logging_collector =on
log_directory ='log'
log_filename ='postgresql-%a.log'
log_min_duration_statement =1000
log_checkpoints =on
log_lock_waits =on
log_statement ='ddl'
log_replication_commands =on
log_timezone ='UTC'
log_autovacuum_min_duration =1000

track_io_timing =on
track_functions =all
track_activity_query_size =16384

日志收集

如果需要从外部收集日志,可以考虑使用filebeat。

filebeat.prospectors:

## input
- type: log
enabled: true
paths:
- /var/lib/postgresql/data/pg_log/postgresql-*.csv
document_type: db-trace
tail_files: true
multiline.pattern: '^20\d\d-\d\d-\d\d'
multiline.negate: true
multiline.match: after
multiline.max_lines: 20
max_cpus: 1

## modules
filebeat.config.modules:
path: ${path.config}/modules.d/*.yml
reload.enabled: false

## queue
queue.mem:
events: 1024
flush.min_events: 0
flush.timeout: 1s

## output
output.kafka:
hosts: ["10.10.10.10:9092","x.x.x.x:9092"]
topics:
- topic: 'log.db'

CSV日志格式

很有趣的想法,将CSV日志弄成PostgreSQL表,对于分析而言非常方便。

原始的csv日志格式定义如下:

日志表的结构定义
create table postgresql_log
(
  log_time               timestamp,
  user_name              text,
  database_name          text,
  process_id             integer,
  connection_from        text,
  session_id             text   not null,
  session_line_num       bigint not null,
  command_tag            text,
  session_start_time     timestamp with time zone,
  virtual_transaction_id text,
  transaction_id         bigint,
  error_severity         text,
  sql_state_code         text,
  message                text,
  detail                 text,
  hint                   text,
  internal_query         text,
  internal_query_pos     integer,
  context                text,
  query                  text,
  query_pos              integer,
  location               text,
  application_name       text,
  PRIMARY KEY (session_id, session_line_num)
);

导入日志

日志是结构良好的CSV,(CSV允许跨行记录),直接使用COPY命令导入即可。

COPY postgresql_log FROM '/var/lib/pgsql/data/pg_log/postgresql.log' CSV DELIMITER ',';

映射日志

当然,除了把日志直接拷贝到数据表里分析,还有一种办法,可以让PostgreSQL直接将自己的本地CSVLOG映射为一张外部表。以SQL的方式直接进行访问。

CREATE SCHEMA IF NOT EXISTS monitor;

-- search path for su
ALTER ROLE postgres SET search_path = public, monitor;
SET search_path = public, monitor;

-- extension
CREATE EXTENSION IF NOT EXISTS file_fdw WITH SCHEMA monitor;

-- log parent table: empty
CREATE TABLE monitor.pg_log
(
  log_time               timestamp(3) with time zone,
  user_name              text,
  database_name          text,
  process_id             integer,
  connection_from        text,
  session_id             text,
  session_line_num       bigint,
  command_tag            text,
  session_start_time     timestamp with time zone,
  virtual_transaction_id text,
  transaction_id         bigint,
  error_severity         text,
  sql_state_code         text,
  message                text,
  detail                 text,
  hint                   text,
  internal_query         text,
  internal_query_pos     integer,
  context                text,
  query                  text,
  query_pos              integer,
  location               text,
  application_name       text,
  PRIMARY KEY (session_id, session_line_num)
);
COMMENT ON TABLE monitor.pg_log IS 'PostgreSQL csv log schema';
-- local file server
CREATE SERVER IF NOT EXISTS pg_log FOREIGN DATA WRAPPER file_fdw;
-- Change filename to actual path
CREATE FOREIGN TABLE IF NOT EXISTS monitor.pg_log_mon() INHERITS (monitor.pg_log) SERVER pg_log OPTIONS (filename '/pg/data/log/postgresql-Mon.csv', format 'csv');
CREATE FOREIGN TABLE IF NOT EXISTS monitor.pg_log_tue() INHERITS (monitor.pg_log) SERVER pg_log OPTIONS (filename '/pg/data/log/postgresql-Tue.csv', format 'csv');
CREATE FOREIGN TABLE IF NOT EXISTS monitor.pg_log_wed() INHERITS (monitor.pg_log) SERVER pg_log OPTIONS (filename '/pg/data/log/postgresql-Wed.csv', format 'csv');
CREATE FOREIGN TABLE IF NOT EXISTS monitor.pg_log_thu() INHERITS (monitor.pg_log) SERVER pg_log OPTIONS (filename '/pg/data/log/postgresql-Thu.csv', format 'csv');
CREATE FOREIGN TABLE IF NOT EXISTS monitor.pg_log_fri() INHERITS (monitor.pg_log) SERVER pg_log OPTIONS (filename '/pg/data/log/postgresql-Fri.csv', format 'csv');
CREATE FOREIGN TABLE IF NOT EXISTS monitor.pg_log_sat() INHERITS (monitor.pg_log) SERVER pg_log OPTIONS (filename '/pg/data/log/postgresql-Sat.csv', format 'csv');
CREATE FOREIGN TABLE IF NOT EXISTS monitor.pg_log_sun() INHERITS (monitor.pg_log) SERVER pg_log OPTIONS (filename '/pg/data/log/postgresql-Sun.csv', format 'csv');

加工日志

可以使用以下存储过程从日志消息中进一步提取语句的执行时间

CREATE OR REPLACE FUNCTION extract_duration(statement TEXT)
  RETURNS FLOAT AS $$
DECLARE
  found_duration BOOLEAN;
BEGIN
  SELECT position('duration' in statement) > 0
  into found_duration;
  IF found_duration
  THEN
    RETURN (SELECT regexp_matches [1] :: FLOAT
            FROM regexp_matches(statement, 'duration: (.*) ms')
            LIMIT 1);
  ELSE
    RETURN NULL;
  END IF;
END
$$
LANGUAGE plpgsql
IMMUTABLE;


CREATE OR REPLACE FUNCTION extract_statement(statement TEXT)
  RETURNS TEXT AS $$
DECLARE
  found_statement BOOLEAN;
BEGIN
  SELECT position('statement' in statement) > 0
  into found_statement;
  IF found_statement
  THEN
    RETURN (SELECT regexp_matches [1]
            FROM regexp_matches(statement, 'statement: (.*)')
            LIMIT 1);
  ELSE
    RETURN NULL;
  END IF;
END
$$
LANGUAGE plpgsql
IMMUTABLE;


CREATE OR REPLACE FUNCTION extract_ip(app_name TEXT)
  RETURNS TEXT AS $$
DECLARE
  ip TEXT;
BEGIN
  SELECT regexp_matches [1]
  into ip
  FROM regexp_matches(app_name, '(\d+\.\d+\.\d+\.\d+)')
  LIMIT 1;
  RETURN ip;
END
$$
LANGUAGE plpgsql
IMMUTABLE;

空中换引擎 —— PostgreSQL不停机迁移数据

通常涉及到数据迁移,常规操作都是停服务更新。不停机迁移数据是相对比较高级的操作。

通常涉及到数据迁移,常规操作都是停服务更新。不停机迁移数据是相对比较高级的操作。

不停机数据迁移在本质上,可以视作由三个操作组成:

  • 复制:将目标表从源库逻辑复制到宿库。
  • 改读:将应用读取路径由源库迁移到宿库上。
  • 改写:将应用写入路径由源库迁移到宿库上。

但在实际执行中,这三个步骤可能会有不一样的表现形式。

逻辑复制

使用逻辑复制是比较稳妥的做法,也有几种不同的做法:应用层逻辑复制,数据库自带的逻辑复制(PostgreSQL 10 之后的逻辑订阅),使用第三方逻辑复制插件(例如pglogical)。

几种逻辑复制的方法各有优劣,我们采用了应用层逻辑复制的方式。具体包括四个步骤:

一、复制

  • 在新库中fork老库目标表的模式,以及所有依赖的函数、序列、权限、属主等对象。
  • 应用添加双写逻辑,同时向新库与老库中写入同样数据。
    • 同时向新库与老库写入
  • 保证增量数据正确写入两个一样的库中。
  • 应用需要正确处理全量数据不存在下的删改逻辑。例如改UPDATEUPSERT,忽略DELETE
  • 应用读取仍然走老库。
  • 出现问题时,回滚应用至原来的单写版本。

二、同步

  • 老表加上表级排它锁 LOCK TABLE <xxx> IN EXCLUSIVE MODE,阻塞所有写入。
  • 执行全量同步 pg_dump | psql
  • 校验数据一致性,判断迁移是否成功。
  • 出现问题时,简单清空新库中的对应表。
  1. 改读
    • 应用修改为从新库中读取数据。
    • 出现问题时,回滚至从老库中读取的版本。
  2. 单写
    • 观察一段时间无误后,应用修改为仅写入新库。
    • 出现问题时,回滚至双写版本。

说明

关键在于阻塞全量同步期间对老表的写入。这可以通过表级排它锁实现。

在对表进行了分片的情况下,锁表对业务造成的影响非常小。

一张逻辑表拆分成8192个分区,实际上一次只需要处理一个分区。

阻塞对八千分之一的数据写入约几秒到十几秒,业务上通常是可以接受的。

但如果是单张非常大的表,也许就需要特殊处理了。

ETL函数

以下Bash函数接受三个参数,源库URL,宿库URL,以及待迁移的表名。

假设是源宿库都可连接,且目标表都存在。

function etl(){
    local src_url=${1}
    local dst_url=${2}
    local table_name=${3}

    rm -rf "/tmp/etl-${table_name}.done"
    
    psql ${src_url} -1qAtc "LOCK TABLE ${table_name} IN EXCLUSIVE MODE;COPY ${table_name} TO STDOUT;" \
    | psql ${dst_url} -1qAtc "LOCK TABLE ${table_name} IN EXCLUSIVE MODE; TRUNCATE ${table_name}; COPY ${table_name} FROM STDIN;"
    
    touch "/tmp/etl-${table_name}.done"
}

实际上虽然锁定了源表与宿表,但在实际测试中,管道退出时前后两个psql进程退出的timing并不是完全同步的。管道前面的进程比后面一个进程早了0.1秒退出。在负载很大的情况下,可能会产生数据不一致。

另一种更科学的做法是按照某一唯一约束列进行切分,锁定相应的行,更新后释放

物理复制

物理复制是通过回放WAL日志实现的复制,是数据库集簇层面的复制。

基于物理复制的迁移粒度很粗,仅适用于垂直分裂库时使用,会有极短暂的服务不可用。

使用物理复制进行数据迁移的流程如下:

  • 复制,从主库拖出一台从库,保持流式复制。
  • 改读:将应用读取路径从主库改为从库,但写入仍然写入主库。
    • 如果有问题,将应用回滚至读主库版本。
  • 改写:将从库提升为主库,阻塞老库的写入,并立即重启应用,切换写入路径至新主库上。
    • 将不需要的表和库删除。
    • 这一步无法回滚(回滚会损失写入新库的数据)

使用FIO测试磁盘性能

FIO可以很方便地测试磁盘IO性能

Fio是一个很好用的磁盘性能测试工具,可以通过以下命令测试磁盘的读写性能。

fio --filename=/tmp/fio.data \
    -direct=1 \
    -iodepth=32 \
    -rw=randrw \
    --rwmixread=80 \
    -bs=4k \
    -size=1G \
    -numjobs=16 \
    -runtime=60 \
    -group_reporting \
    -name=randrw \
    --output=/tmp/fio_randomrw.txt \
    && unlink /tmp/fio.data

下面是常用的测试组合,确保 /data/ 挂载了数据盘

--8k  随机写
fio -name=8krandw  -runtime=120  -filename=/data/rand.txt -ioengine=libaio -direct=1  -bs=8K  -size=10g  -iodepth=128  -numjobs=1  -rw=randwrite -group_reporting -time_based
  
--8K  随机读
fio -name=8krandr  -runtime=120  -filename=/data/rand.txt -ioengine=libaio -direct=1  -bs=8K  -size=10g  -iodepth=128  -numjobs=1  -rw=randread -group_reporting -time_based
  
--8k  混合读写
fio -name=8krandrw  -runtime=120  -filename=/data/rand.txt -ioengine=libaio -direct=1  -bs=8k  -size=10g  -iodepth=128  -numjobs=1  -rw=randrw -rwmixwrite=30  -group_reporting -time_based
   
--1Mb  顺序写
fio -name=1mseqw  -runtime=120  -filename=/data/seq.txt -ioengine=libaio -direct=1  -bs=1024k  -size=20g  -iodepth=128  -numjobs=1  -rw=write -group_reporting -time_based
  
--1Mb  顺序读
fio -name=1mseqr  -runtime=120  -filename=/data/seq.txt -ioengine=libaio -direct=1  -bs=1024k  -size=20g  -iodepth=128  -numjobs=1  -rw=read -group_reporting -time_based
  
--1Mb  顺序读写
fio -name=1mseqrw  -runtime=120  -filename=/data/seq.txt -ioengine=libaio -direct=1  -bs=1024k  -size=20g  -iodepth=128  -numjobs=1  -rw=rw -rwmixwrite=30  -group_reporting -time_based

测试 PostgreSQL 相关的 IO 性能表现时,应当主要以 8KB 随机IO为主,可以考虑以下参数组合。

3个维度:RW Ratio, Block Size, N Jobs 进行排列组合

  • RW Ratio: Pure Read, Pure Write, rwmixwrite=80, rwmixwrite=20
  • Block Size = 4KB (OS granular), 8KB (DB granular)
  • N jobs: 1 , 4 , 8 , 16 ,32

使用sysbench测试PostgreSQL性能

尽管PostgreSQL提供了pgbench,但有时候为了吊打一下MySQL,还是需要用到sysbench的。

sysbench首页:https://github.com/akopytov/sysbench


安装

二进制安装,在Mac上,使用brew安装sysbench。

brew install sysbench --with-postgresql

源代码编译(CentOS):

yum -y install make automake libtool pkgconfig libaio-devel
# For MySQL support, replace with mysql-devel on RHEL/CentOS 5
yum -y install mariadb-devel openssl-devel
# For PostgreSQL support
yum -y install postgresql-devel

源代码编译

brew install automake libtool openssl pkg-config
# For MySQL support
brew install mysql
# For PostgreSQL support
brew install postgresql
# openssl is not linked by Homebrew, this is to avoid "ld: library not found for -lssl"
export LDFLAGS=-L/usr/local/opt/openssl/lib 

编译:

./autogen.sh

# --with-pgsql --with-pgsql-libs --with-pgsql-includes
# -- without-mysql
./configure 

make -j
make install

准备

创建一个压测用PostgreSQL数据库:bench,初始化测试用数据库:

sysbench /usr/local/share/sysbench/oltp_read_write.lua \
	--db-driver=pgsql \
	--pgsql-host=127.0.0.1 \
	--pgsql-port=5432 \
	--pgsql-user=vonng \
	--pgsql-db=bench \
	--table_size=100000 \
	--tables=3 \
	prepare

输出:

Creating table 'sbtest1'...
Inserting 100000 records into 'sbtest1'
Creating a secondary index on 'sbtest1'...
Creating table 'sbtest2'...
Inserting 100000 records into 'sbtest2'
Creating a secondary index on 'sbtest2'...
Creating table 'sbtest3'...
Inserting 100000 records into 'sbtest3'
Creating a secondary index on 'sbtest3'...

压测

sysbench /usr/local/share/sysbench/oltp_read_write.lua \
	--db-driver=pgsql \
	--pgsql-host=127.0.0.1 \
	--pgsql-port=5432 \
	--pgsql-user=vonng \
	--pgsql-db=bench \
	--table_size=100000 \
    --tables=3 \
    --threads=4 \
    --time=12 \
    run

输出

sysbench 1.1.0-e6e6a02 (using bundled LuaJIT 2.1.0-beta3)

Running the test with following options:
Number of threads: 4
Initializing random number generator from current time


Initializing worker threads...

Threads started!

SQL statistics:
    queries performed:
        read:                            127862
        write:                           36526
        other:                           18268
        total:                           182656
    transactions:                        9131   (760.56 per sec.)
    queries:                             182656 (15214.20 per sec.)
    ignored errors:                      2      (0.17 per sec.)
    reconnects:                          0      (0.00 per sec.)

Throughput:
    events/s (eps):                      760.5600
    time elapsed:                        12.0056s
    total number of events:              9131

Latency (ms):
         min:                                    4.30
         avg:                                    5.26
         max:                                   15.20
         95th percentile:                        5.99
         sum:                                47995.39

Threads fairness:
    events (avg/stddev):           2282.7500/4.02
    execution time (avg/stddev):   11.9988/0.00

找出没用过的索引

索引很有用, 但不是免费的。没用到的索引是一种浪费,使用这里的方法找出未使用的索引

索引很有用, 但不是免费的。没用到的索引是一种浪费,使用以下SQL找出未使用的索引:

  • 首先要排除用于实现约束的索引(删不得)
  • 表达式索引(pg_index.indkey中含有0号字段)
  • 然后找出走索引扫描的次数为0的索引(也可以换个更宽松的条件,比如扫描小于1000次的)

找出没有使用的索引

  • 视图名称:monitor.v_bloat_indexes
  • 计算时长:1秒,适合每天检查/手工检查,不适合频繁拉取。
  • 验证版本:9.3 ~ 10
  • 功能:显示当前数据库索引膨胀情况。

在版本9.3与10.4上工作良好。视图形式

-- CREATE SCHEMA IF NOT EXISTS monitor;
-- DROP VIEW IF EXISTS monitor.pg_stat_dummy_indexes;

CREATE OR REPLACE VIEW monitor.pg_stat_dummy_indexes AS
SELECT s.schemaname,
       s.relname AS tablename,
       s.indexrelname AS indexname,
       pg_relation_size(s.indexrelid) AS index_size
FROM pg_catalog.pg_stat_user_indexes s
   JOIN pg_catalog.pg_index i ON s.indexrelid = i.indexrelid
WHERE s.idx_scan = 0      -- has never been scanned
  AND 0 <>ALL (i.indkey)  -- no index column is an expression
  AND NOT EXISTS          -- does not enforce a constraint
         (SELECT 1 FROM pg_catalog.pg_constraint c
          WHERE c.conindid = s.indexrelid)
ORDER BY pg_relation_size(s.indexrelid) DESC;

COMMENT ON VIEW monitor.pg_stat_dummy_indexes IS 'monitor unused indexes'
-- 人类可读的手工查询
SELECT s.schemaname,
       s.relname AS tablename,
       s.indexrelname AS indexname,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size
FROM pg_catalog.pg_stat_user_indexes s
   JOIN pg_catalog.pg_index i ON s.indexrelid = i.indexrelid
WHERE s.idx_scan = 0      -- has never been scanned
  AND 0 <>ALL (i.indkey)  -- no index column is an expression
  AND NOT EXISTS          -- does not enforce a constraint
         (SELECT 1 FROM pg_catalog.pg_constraint c
          WHERE c.conindid = s.indexrelid)
ORDER BY pg_relation_size(s.indexrelid) DESC;

批量生成删除索引的命令

SELECT 'DROP INDEX CONCURRENTLY IF EXISTS "' 
	|| s.schemaname || '"."' || s.indexrelname || '";'
FROM pg_catalog.pg_stat_user_indexes s
   JOIN pg_catalog.pg_index i ON s.indexrelid = i.indexrelid
WHERE s.idx_scan = 0      -- has never been scanned
  AND 0 <>ALL (i.indkey)  -- no index column is an expression
  AND NOT EXISTS          -- does not enforce a constraint
         (SELECT 1 FROM pg_catalog.pg_constraint c
          WHERE c.conindid = s.indexrelid)
ORDER BY pg_relation_size(s.indexrelid) DESC;

找出重复的索引

检查是否有索引工作在相同的表的相同列上,但要注意条件索引。

SELECT
  indrelid :: regclass              AS table_name,
  array_agg(indexrelid :: regclass) AS indexes
FROM pg_index
GROUP BY
  indrelid, indkey
HAVING COUNT(*) > 1;

批量配置SSH免密登录

快速配置所有机器的免密登陆

配置SSH是运维工作的基础,有时候还是要老生常谈一下。


生成公私钥对

理想的情况是全部通过公私钥认证,从本地免密码直接连接所有数据库机器。最好不要使用密码认证。

首先,使用ssh-keygen生成公私钥对

ssh-keygen -t rsa

注意权限问题,ssh内文件的权限应当设置为0600.ssh目录的权限应当设置为0700,设置失当会导致免密登录无法使用。


配置ssh config穿透跳板机

User换成自己的名字。放入.ssh/config,这里给出了有跳板机环境下配置生产网数据库免密直连的方式:

# Vonng's ssh config

# SpringBoard IP
Host <BastionIP>
	Hostname <your_ip_address>
	IdentityFile ~/.ssh/id_rsa

# Target Machine Wildcard (Proxy via Bastion)
Host 10.xxx.xxx.*
	ProxyCommand ssh <BastionIP> exec nc %h %p 2>/dev/null
	IdentityFile ~/.ssh/id_rsa

# Common Settings
Host *
	User xxxxxxxxxxxxxx
	PreferredAuthentications publickey,password
	Compression yes
	ServerAliveInterval 30
	ControlMaster auto
	ControlPath ~/.ssh/ssh-%r@%h:%p
	ControlPersist yes
	StrictHostKeyChecking no

将公钥拷贝到目标机器上

然后将公钥拷贝到跳板机,DBA工作机,所有数据库机器上。

ssh-copy-id <target_ip>

每次执行此命令都会要求输入密码,非常繁琐无聊,可以通过expect 脚本进行自动化,或者使用sshpass


使用expect自动化

将下列脚本中的<your password>替换为你自己的密码。如果服务器IP列表有变化,修改列表即可。

#!/usr/bin/expect
foreach id { 
     10.xxx.xxx.xxx
     10.xxx.xxx.xxx
     10.xxx.xxx.xxx
} {
    spawn ssh-copy-id $id
    expect {
    	"*(yes/no)?*"
    	{
            send "yes\n"
            expect "*assword:" { send "<your password>\n"}
    	}
     	"*assword*" { send "<your password>\n"}
    }
}

exit

更优雅的解决方案: sshpass

sshpass -i <your password> ssh-copy-id <target address>

当然缺点是,密码很有可能出现在bash历史记录中,执行完请及时清理痕迹。

Wireshark抓包分析协议

Wireshark是一个很有用的工具,特别适合用来分析网络协议,这里简单介绍使用Wireshark抓包分析PostgreSQL协议的方法。

Wireshark是一个很有用的工具,特别适合用来分析网络协议。

这里简单介绍使用Wireshark抓包分析PostgreSQL协议的方法。

假设调试本地PostgreSQL实例:127.0.0.1:5432

快速开始

  1. 下载并安装Wireshark:下载地址
  2. 选择要抓包的网卡,如果是本地测试选择lo0即可。
  3. 添加抓包过滤器,如果PostgreSQL使用默认设置,使用port 5432即可。
  4. 开始抓包
  5. 添加显示过滤器pgsql,这样就可以滤除无关的TCP协议报文。
  6. 然后就可以执行一些操作,观察并分析协议了


抓包样例

我们先从最简单的case开始,不使用认证,也不使用SSL,执行以下命令建立一条到PostgreSQL的连接。

psql postgres://localhost:5432/postgres?sslmode=disable -c 'SELECT 1 AS a, 2 AS b;'

注意这里sslmode=disable是不能省略的,不然客户端会默认尝试发送SSL请求。localhost也是不能省略的,不然客户端会默认尝试使用unix socket。

这条Bash命令实际上在PostgreSQL对应着三个协议阶段与5组协议报文

  • 启动阶段:客户端建立一条到PostgreSQL服务器的连接。
  • 简单查询协议:客户端发送查询命令,服务器回送查询结果。
  • 终止:客户端中断连接。

Wireshark内建了对PGSQL的解码,允许我们方便地查看PostgreSQL协议报文的内容。

启动阶段,客户端向服务端发送了一条StartupMessage (F),而服务端回送了一系列消息,包括AuthenticationOK(R)ParameterStatus(S), BackendKeyData(K) , ReadyForQuery(Z)。这里这几条消息都打包在同一个TCP报文中发送给客户端。

简单查询阶段,客户端发送了一条Query (F)消息,将SQL语句SELECT 1 AS a, 2 AS b;直接作为内容发送给服务器。服务器依次返回了RowDescription(T),DataRow(D),CommandComplete(C),ReadyForQuery(Z).

终止阶段,客户端发送了一条Terminate(X)消息,终止连接。


题外话:使用Mac进行无线网络嗅探

结论: Mac: airport, tcpdump Windows: Omnipeek Linux: tcpdump, airmon-ng

以太网里抓包很简单,各种软件一大把,什么Wireshark,Ethereal,Sniffer Pro 一抓一大把。不过如果是无线数据包,就要稍微麻烦一点了。网上找了一堆罗里吧嗦的文章,绕来绕去的,其实抓无线包一条命令就好了。

Windows下因为无线网卡驱动会拒绝进入混杂模式,所以比较蛋疼,一般是用Omnipeek去弄,不细说了。

Linux和Mac就很方便了。只要用tcpdump就可以,一般系统都自带了。最后-i选项的参数填想抓的网络设备名就行。Mac默认的WiFi网卡是en0。 tcpdump -Ine -i en0

主要就是指定-I参数,进入监控模式。 -I :Put the interface in "monitor mode"; this is supported only on IEEE 802.11 Wi-Fi interfaces, and supported only on some operating systems. 进入监控模式之后计算机用于监控的无线网卡就上不了网了,所以可以考虑买个外置无线网卡来抓包,上网抓包两不误。

抓了包能干很多坏事,比如WEP网络抓几个IV包就可以用aircrack破密码,WPA网络抓到一个握手包就能跑字典破无线密码了。如果在同一个网络内,还可以看到各种未加密的流量……什么小黄图啊,隐私照啊之类的……。

假如我已经知道某个手机的MAC地址,那么只要 tcpdump -Ine -i en0 | grep $MAC_ADDRESS 就过滤出该手机相关的WiFi流量。

具体帧的类型详情参看802.11协议,《802.11无线网络权威指南》等。

顺便解释以下混杂模式与监控模式的区别: 混杂(promiscuous)模式是指:接收同一个网络中的所有数据包,无论是不是发给自己的。 监控(monitor)模式是指:接收某个物理信道中所有传输着的数据包。

RFMON RFMON is short for radio frequency monitoring mode and is sometimes also described as monitor mode or raw monitoring mode. In this mode an 802.11 wireless card is in listening mode (“sniffer” mode).

The wireless card does not have to associate to an access point or ad-hoc network but can passively listen to all traffic on the channel it is monitoring. Also, the wireless card does not require the frames to pass CRC checks and forwards all frames (corrupted or not with 802.11 headers) to upper level protocols for processing. This can come in handy when troubleshooting protocol issues and bad hardware.

RFMON/Monitor Mode vs. Promiscuous Mode Promiscuous mode in wired and wireless networks instructs a wired or wireless card to process any traffic regardless of the destination mac address. In wireless networks promiscuous mode requires that the wireless card be associated to an access point or ad-hoc network. While in promiscuous mode a wireless card can transmit and receive but will only captures traffic for the network (SSID) to which it is associated.

RFMON mode is only possible for wireless cards and does not require the wireless card to be associated to a wireless network. While in monitor mode the wireless card can passively monitor traffic of all networks and devices within listening range (SSIDs, stations, access points). In most cases the wireless card is not able to transmit and does not follow the typical 802.11 protocol when receiving traffic (i.e. transmit an 802.11 ACK for received packet).

Both modes have to be supported by the driver of the wired or wireless card.

另外在研究抓包工具时,发现了Mac下有一个很好用的命令行工具airport,可以用来抓包,以及摆弄Macbook的WiFi。 位置在 /System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport

可以创建一个符号链接方便使用: sudo ln -s /System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport /usr/sbin/airport

常用的命令有: 显示当前网络信息:airport -I 扫描周围无线网络:airport -s 断开当前无线网络:airport -z 强制指定无线信道:airport -c=$CHANNEL

抓无线包,可以指定信道: airport en0 sniff [$CHANNEL] 抓到的包放在/tmp/airportSniffXXXXX.cap,可以用tcpdump, tshark, wireshark等软件来读。

最实用的功能还是扫描周围无线网络。

Linux 常用统计 CLI 工具

top, free, vmstat, iostat:四大常用 CLI 工具命令速查

top

显示Linux任务

摘要

  • 按下空格或回车强制刷新
  • 使用h打开帮助
  • 使用l,t,m收起摘要部分。
  • 使用d修改刷新周期
  • 使用z开启颜色高亮
  • 使用u列出指定用户的进程
  • 使用<>来改变排序列
  • 使用P按CPU使用率排序
  • 使用M按驻留内存大小排序
  • 使用T按累计时间排序

批处理模式

-b参数可以用于批处理模式,配合-n参数指定批次数目。同时-d参数可以指定批次的间隔时间

例如获取机器当前的负载使用情况,以0.1秒为间隔获取三次,获取最后一次的CPU摘要。

$ top -bn3 -d0.1 | grep Cpu | tail -n1
Cpu(s):  4.1%us,  1.0%sy,  0.0%ni, 94.8%id,  0.0%wa,  0.0%hi,  0.1%si,  0.0%st

输出格式

top的输出分为两部分,上面几行是系统摘要,下面是进程列表,两者通过一个空行分割。下面是top命令的输出样例:

top - 12:11:01 up 401 days, 19:17,  2 users,  load average: 1.12, 1.26, 1.40
Tasks: 1178 total,   3 running, 1175 sleeping,   0 stopped,   0 zombie
Cpu(s):  5.4%us,  1.7%sy,  0.0%ni, 92.5%id,  0.1%wa,  0.0%hi,  0.4%si,  0.0%st
Mem:  396791756k total, 389547376k used,  7244380k free,   263828k buffers
Swap: 67108860k total,        0k used, 67108860k free, 366252364k cached

   PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND
  5094 postgres  20   0 37.2g 829m 795m S 14.2  0.2   0:04.11 postmaster
  5093 postgres  20   0 37.2g 926m 891m S 13.2  0.2   0:04.96 postmaster
165359 postgres  20   0 37.2g 4.0g 4.0g S 12.6  1.1   0:44.93 postmaster
 93426 postgres  20   0 37.2g 6.8g 6.7g S 12.2  1.8   1:32.94 postmaster
  5092 postgres  20   0 37.2g 856m 818m R 11.2  0.2   0:04.21 postmaster
 67634 root      20   0  569m 520m  328 S 11.2  0.1 140720:15 haproxy
 93429 postgres  20   0 37.2g 8.7g 8.7g S 11.2  2.3   2:12.23 postmaster
129653 postgres  20   0 37.2g 6.8g 6.7g S 11.2  1.8   1:27.92 postmaster

摘要部分

摘要默认由三个部分,共计五行组成:

  • 系统运行时间,平均负载,共计一行(l切换内容)
  • 任务、CPU状态,各一行(t切换内容)
  • 内存使用,Swap使用,各一行(m切换内容)

系统运行时间和平均负载

top - 12:11:01 up 401 days, 19:17,  2 users,  load average: 1.12, 1.26, 1.40
  • 当前时间:12:11:01
  • 系统已运行的时间:up 401 days
  • 当前登录用户的数量:2 users
  • 相应最近5、10和15分钟内的平均负载:load average: 1.12, 1.26, 1.40

Load表示操作系统的负载,即,当前运行的任务数目。而load average表示一段时间内平均的load,也就是过去一段时间内平均有多少个任务在运行。注意Load与CPU利用率并不是一回事。

任务

Tasks: 1178 total,   3 running, 1175 sleeping,   0 stopped,   0 zombie

第二行显示的是任务或者进程的总结。进程可以处于不同的状态。这里显示了全部进程的数量。除此之外,还有正在运行、睡眠、停止、僵尸进程的数量(僵尸是一种进程的状态)。

CPU状态

Cpu(s):  5.4%us,  1.7%sy,  0.0%ni, 92.5%id,  0.1%wa,  0.0%hi,  0.4%si,  0.0%st

下一行显示的是CPU状态。 这里显示了不同模式下的所占CPU时间的百分比。这些不同的CPU时间表示:

  • us, user: 运行(未调整优先级的) 用户进程的CPU时间
  • sy,system: 运行内核进程的CPU时间
  • ni,niced:运行已调整优先级的用户进程的CPU时间
  • id,idle:空闲CPU时间
  • wa,IO wait: 用于等待IO完成的CPU时间
  • hi:处理硬件中断的CPU时间
  • si: 处理软件中断的CPU时间
  • st:虚拟机被hypervisor偷去的CPU时间(如果当前处于一个虚拟机内,宿主机消耗的CPU处理时间)。

内存使用

Mem:  396791756k total, 389547376k used,  7244380k free,   263828k buffers
Swap: 67108860k total,        0k used, 67108860k free, 366252364k cached
  • 内存部分:全部可用内存、已使用内存、空闲内存、缓冲内存。
  • SWAP部分:全部、已使用、空闲和缓冲交换空间。

进程部分

进程部分默认会显示一些关键信息

   PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND
  5094 postgres  20   0 37.2g 829m 795m S 14.2  0.2   0:04.11 postmaster
  5093 postgres  20   0 37.2g 926m 891m S 13.2  0.2   0:04.96 postmaster
165359 postgres  20   0 37.2g 4.0g 4.0g S 12.6  1.1   0:44.93 postmaster
 93426 postgres  20   0 37.2g 6.8g 6.7g S 12.2  1.8   1:32.94 postmaster
  5092 postgres  20   0 37.2g 856m 818m R 11.2  0.2   0:04.21 postmaster
 67634 root      20   0  569m 520m  328 S 11.2  0.1 140720:15 haproxy
 93429 postgres  20   0 37.2g 8.7g 8.7g S 11.2  2.3   2:12.23 postmaster
129653 postgres  20   0 37.2g 6.8g 6.7g S 11.2  1.8   1:27.92 postmaster
  • PID:进程ID,进程的唯一标识符

  • USER:进程所有者的实际用户名。

  • PR:进程的调度优先级。这个字段的一些值是’rt’。这意味这这些进程运行在实时态。

  • NI:进程的nice值(优先级)。越小的值意味着越高的优先级。

  • VIRT:进程使用的虚拟内存。

  • RES:驻留内存大小。驻留内存是任务使用的非交换物理内存大小。

  • SHR:SHR是进程使用的共享内存。

  • S这个是进程的状态。它有以下不同的值:

  • D - 不可中断的睡眠态。

  • R – 运行态

  • S – 睡眠态

  • T – Trace或Stop

  • Z – 僵尸态

  • %CPU:自从上一次更新时到现在任务所使用的CPU时间百分比。

  • %MEM:进程使用的可用物理内存百分比。

  • TIME+:任务启动后到现在所使用的全部CPU时间,单位为百分之一秒。

  • COMMAND:运行进程所使用的命令。

Linux进程的状态

static const char * const task_state_array[] = {
  "R (running)", /* 0 */
  "S (sleeping)", /* 1 */
  "D (disk sleep)", /* 2 */
  "T (stopped)", /* 4 */
  "t (tracing stop)", /* 8 */
  "X (dead)", /* 16 */
  "Z (zombie)", /* 32 */
};
  • R (TASK_RUNNING),可执行状态。实际运行与Ready在Linux都算做Running状态
  • S (TASK_INTERRUPTIBLE),可中断的睡眠态,进程等待事件,位于等待队列中。
  • D (TASK_UNINTERRUPTIBLE),不可中断的睡眠态,无法响应异步信号,例如硬件操作,内核线程
  • T (TASK_STOPPED | TASK_TRACED),暂停状态或跟踪状态,由SIGSTOP或断点触发
  • Z (TASK_DEAD),子进程退出后,父进程还没有来收尸,留下task_structure的进程就处于这种状态。

free

显示系统的内存使用情况

free -b | -k | -m | -g | -h -s delay  -a -l
  • 其中-b | -k | -m | -g | -h 可用于控制显示大小时的单位(字节,KB,MB,GB,自动适配)
  • -s可以指定轮询周期,-c指定轮询次数。

输出样例

$ free -m
             total       used       free     shared    buffers     cached
Mem:        387491     379383       8107      37762        182     348862
-/+ buffers/cache:      30338     357153
Swap:        65535          0      65535
  • 这里,总内存有378GB,使用370GB,空闲8GB。三者存在total=used+free的关系。共享内存占36GB。
  • buffers与cache由操作系统分配管理,用于提高I/O性能,其中Buffer是写入缓冲,而Cache是读取缓存。这一行表示,应用程序已使用buffers/cached,以及理论上可使用buffers/cache-/+ buffers/cache: 30338 357153
  • 最后一行显示了SWAP信息,总的SWAP空间,实际使用的SWAP空间,以及可用的SWAP空间。只要没有用到SWAP(used = 0),就说明内存空间仍然够用。

数据来源

free实际上是通过cat /proc/meminfo获取信息的。

详细信息:https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/6/html/deployment_guide/s2-proc-meminfo

$ cat /proc/meminfo
MemTotal:       396791752 kB	# 总可用RAM, 物理内存减去内核二进制与保留位
MemFree:         7447460 kB		# 系统可用物理内存
Buffers:          186540 kB		# 磁盘快的临时存储大小
Cached:         357066928 kB	# 缓存
SwapCached:            0 kB		# 曾移入SWAP又移回内存的大小
Active:         260698732 kB	# 最近使用过,如非强制不会回收的内存。
Inactive:       112228764 kB	# 最近没怎么用过的内存,可能会回收
Active(anon):   53811184 kB		# 活跃的匿名内存(不与具体文件关联)
Inactive(anon):   532504 kB		# 不活跃的匿名内存
Active(file):   206887548 kB	# 活跃的文件缓存
Inactive(file): 111696260 kB	# 不活跃的文件缓存
Unevictable:           0 kB		# 不可淘汰的内存
Mlocked:               0 kB		# 被钉在内存中
SwapTotal:      67108860 kB		# 总SWAP
SwapFree:       67108860 kB		# 可用SWAP
Dirty:            115852 kB		# 被写脏的内存
Writeback:             0 kB		# 回写磁盘的内存
AnonPages:      15676608 kB		# 匿名页面
Mapped:         38698484 kB		# 用于mmap的内存,例如共享库
Shmem:          38668836 kB		# 共享内存
Slab:            6072524 kB		# 内核数据结构使用内存
SReclaimable:    5900704 kB		# 可回收的slab
SUnreclaim:       171820 kB		# 不可回收的slab
KernelStack:       25840 kB		# 内核栈使用的内存
PageTables:      2480532 kB		# 页表大小
NFS_Unstable:          0 kB		# 发送但尚未提交的NFS页面
Bounce:                0 kB		# bounce buffers
WritebackTmp:          0 kB
CommitLimit:    396446012 kB
Committed_AS:   57195364 kB
VmallocTotal:   34359738367 kB
VmallocUsed:     6214036 kB
VmallocChunk:   34353427992 kB
HardwareCorrupted:     0 kB
AnonHugePages:         0 kB
HugePages_Total:       0
HugePages_Free:        0
HugePages_Rsvd:        0
HugePages_Surp:        0
Hugepagesize:       2048 kB
DirectMap4k:        5120 kB
DirectMap2M:     2021376 kB
DirectMap1G:    400556032 kB

其中,free与/proc/meminfo中指标的对应关系为:

total	= (MemTotal + SwapTotal)
used	= (total - free - buffers - cache)
free	= (MemFree + SwapFree)
shared	= Shmem
buffers	= Buffers
cache	= Cached
buffer/cached = Buffers + Cached

清理缓存

可以通过以下命令强制清理缓存:

$ sync # flush fs buffers
$ echo 1 > /proc/sys/vm/drop_caches	# drop page cache
$ echo 2 > /proc/sys/vm/drop_caches	# drop dentries & inode
$ echo 3 > /proc/sys/vm/drop_caches	# drop all

vmstat

汇报虚拟内存统计信息

摘要

vmstat [-a] [-n] [-t] [-S unit] [delay [ count]]
vmstat [-s] [-n] [-S unit]
vmstat [-m] [-n] [delay [ count]]
vmstat [-d] [-n] [delay [ count]]
vmstat [-p disk partition] [-n] [delay [ count]]
vmstat [-f]
vmstat [-V]

最常用的用法是:

vmstat <delay> <count>

例如vmstat 1 10就是以1秒为间隔,采样10次内存统计信息。

样例输出

$ vmstat 1 4 -S M
procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 3  0      0   7288    170 344210    0    0   158   158    0    0  2  1 97  0  0
 5  0      0   7259    170 344228    0    0  7680 13292 38783 36814  6  1 93  0  0
 3  0      0   7247    170 344246    0    0  8720 21024 40584 39686  6  1 93  0  0
 1  0      0   7233    170 344255    0    0  6800 24404 39461 36984  6  1 93  0  0
Procs
    r: 等待运行的进程数目
    b: 处于不可中断睡眠状态的进程数(Block)
Memory
    swpd: 使用的交换区大小,大于0则说明内存过小
    free: 空闲内存
    buff: 缓冲区内存
    cache: 页面缓存
    inact: 不活跃内存 (-a 选项)
    active: 活跃内存 (-a 选项)
Swap
    si: 每秒从磁盘中换入的内存 (/s).
    so: 每秒从换出到磁盘的内存 (/s).
IO
    bi: 从块设备每秒收到的块数目 (blocks/s).
    bo: 向块设备每秒发送的快数目 (blocks/s).
System
    in: 每秒中断数,包括时钟中断
    cs: 每秒上下文切换数目
CPU
    总CPU时间的百分比
    us: 用户态时间 (包括nice的时间)
    sy: 内核态时间
    id: 空闲时间(在2.5.41前包括等待IO的时间)
    wa: 等待IO的时间(在2.5.41前包括在id里)
    st: 空闲时间(在2.6.11前没有)

数据来源

从下面三个文件中提取信息:

  • /proc/meminfo
  • /proc/stat
  • /proc/*/stat

iostat

汇报IO相关统计信息

摘要

iostat [ -c ] [ -d ] [ -N ] [ -n ] [ -h ] [ -k | -m ] [ -t ] [ -V ] [ -x ] [ -y ] [ -z ] [ -j { ID | LABEL | PATH | UUID | ... } [ device [...] | ALL ] ] [ device [...] | ALL ] [ -p [ device [,...] | ALL ] ] [interval [ count ] ]

默认情况下iostat会打印cpu信息和磁盘io信息,使用-d参数只显示IO部分,使用-x打印更多信息。样例输出:

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           5.77    0.00    1.31    0.07    0.00   92.85

Device:            tps   Blk_read/s   Blk_wrtn/s   Blk_read   Blk_wrtn
sdb               0.00         0.00         0.00          0          0
sda               0.00         0.00         0.00          0          0
dfa            5020.00     15856.00     35632.00      15856      35632
dm-0              0.00         0.00         0.00          0          0

常用选项

  • 使用-d参数只显示IO部分的信息,而-c参数则只显示CPU部分的信息。
  • 使用-x会打印更详细的扩展信息
  • 使用-k会使用KB替代块数目作为部分数值的单位,-m则使用MB。

输出说明

不带-x选项默认会为每个设备打印5列:

  • tps:该设备每秒的传输次数。(多个逻辑请求可能会合并为一个IO请求,传输量未知) -kB_read/s:每秒从设备读取的数据量;kB_wrtn/s:每秒向设备写入的数据量;kB_read:读取的总数据量;kB_wrtn:写入的总数量数据量;这些单位都为Kilobytes,这是使用-k参数的情况。默认则以块数为单位。

带有-x选项后,会打印更多信息:

  • rrqm/s:每秒这个设备相关的读取请求有多少被Merge了(当系统调用需要读取数据的时候,VFS将请求发到各个FS,如果FS发现不同的读取请求读取的是相同Block的数据,FS会将这个请求合并Merge);
  • wrqm/s:每秒这个设备相关的写入请求有多少被Merge了。
  • r/s 与 w/s:(合并后)每秒读取/写入请求次数
  • rsec/s 与 wsec/s:每秒读取/写入扇区的数目
  • avgrq-sz:请求的平均大小(以扇区计)
  • avgqu-sz:平均请求队列长度
  • await:每一个IO请求的处理的平均时间(单位是毫秒)
  • r_await/w_await:读/写的平均响应时间。
  • %util:设备的带宽利用率,IO时间占比。在统计时间内所有处理IO时间。一般该参数是100%表示设备已经接近满负荷运行了。

常用方法

收集 /dev/dfa 的IO信息,按kB计算,每秒一次,连续 10 次。

iostat -dxk /dev/dfa 1 10

数据来源

其实是从下面几个文件中提取信息的:

/proc/stat contains system statistics.
/proc/uptime contains system uptime.
/proc/partitions contains disk statistics (for pre 2.5 kernels that have been patched).
/proc/diskstats contains disks statistics (for post 2.5 kernels).
/sys contains statistics for block devices (post 2.5 kernels).
/proc/self/mountstats contains statistics for network filesystems.
/dev/disk contains persistent device names.

源码编译安装 PostGIS

PostGIS是PG的杀手锏插件,但编译安装可不容易。

强烈建议使用使用 yum / apt 命令从 PostgreSQL 官方二进制仓库安装 PostGIS。

参考http://www.postgresonline.com/journal/archives/362-An-almost-idiots-guide-to-install-PostgreSQL-9.5,-PostGIS-2.2-and-pgRouting-2.1.0-with-Yum.html


1. 安装环境

  • CentOS 7
  • PostgreSQL10
  • PostGIS2.4
  • PGROUTING2.5.2

2. PostgreSQL10安装

2.1 确定系统环境
$ uname -a

Linux localhost.localdomain 3.10.0-693.el7.x86_64 #1 SMP Tue Aug 22 21:09:27 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux
2.2 安装正确的rpm包
  rpm -ivh https://download.postgresql.org/pub/repos/yum/10/redhat/rhel-7-x86_64/pgdg-centos10-10-2.noarch.rpm

不同的系统使用不同的rpm源,你可以从 http://yum.postgresql.org/repopackages.php 获取相应的平台链接。

2.3 查看rpm包是否正确安装
yum list | grep pgdg

pgdg-centos10.noarch                        10-2                       installed
CGAL.x86_64                                 4.7-1.rhel7                pgdg10
CGAL-debuginfo.x86_64                       4.7-1.rhel7                pgdg10
CGAL-demos-source.x86_64                    4.7-1.rhel7                pgdg10
CGAL-devel.x86_64                           4.7-1.rhel7                pgdg10
MigrationWizard.noarch                      1.1-3.rhel7                pgdg10
...
2.4 安装PG
yum install -y postgresql10 postgresql10-server postgresql10-libs postgresql10-contrib postgresql10-devel

你可以根据需要选择安装相应的rpm包。

2.5 启动服务

默认情况下,PG安装目录为/usr/pgsql-10/,data目录为/var/lib/pgsql/,系统默认创建用户postgres

passwd postgres # 为系统postgres设置密码
su - postgres 	# 切换到用户postgres
/usr/pgsql-10/bin/initdb -D /var/lib/pgsql/10/data/	# 初始化数据库
/usr/pgsql-10/bin/pg_ctl -D /var/lib/pgsql/10/data/ -l logfile start	# 启动数据库
/usr/pgsql-10/bin/psql postgres postgres	# 登录

3. PostGIS安装

yum install postgis24_10-client postgis24_10

如果遇到错误如下:

--> 解决依赖关系完成
错误:软件包:postgis24_10-client-2.4.2-1.rhel7.x86_64 (pgdg10)
          需要:libproj.so.0()(64bit)
错误:软件包:postgis24_10-2.4.2-1.rhel7.x86_64 (pgdg10)
          需要:gdal-libs >= 1.9.0

你可以尝试通过以下命令解决:yum -y install epel-release

4. fdw安装

yum install ogr_fdw10

5. pgrouting安装

yum install pgrouting_10

6. 验证测试

# 登录pg后执行以下命令,无报错则证明成功
CREATE EXTENSION postgis;
CREATE EXTENSION postgis_topology;
CREATE EXTENSION ogr_fdw;

SELECT postgis_full_version();

编译工具

此类工具一般系统都自带。

  • GCC与G++,版本至少为4.x
  • GNU Make,CMake, Autotools
  • Git

CentOS下直接通过sudo yum install gcc gcc-c++ git autoconf automake libtool m4 安装。


必选依赖

PostgreSQL

PostgreSQL是PostGIS的宿主平台。这里以10.1为例。

GEOS

GEOS是Geometry Engine, Open Source的缩写,是一个C++版本的几何库。是PostGIS的核心依赖。

PostGIS 2.4用到了GEOS 3.7的一些新特性。不过截止到现在,GEOS官方发布的最新版本是3.6.2,3.7版本的GEOS可以通过Nightly snapshot 获取。所以目前如果希望用到所有新特性,需要从源码编译安装GEOS 3.7。

# 滚动的每日更新,此URL有可能过期,检查这里http://geos.osgeo.org/snapshots/
wget -P ./ http://geos.osgeo.org/snapshots/geos-20171211.tar.bz2
tar -jxf geos-20171211.tar.bz2
cd geos-20171211
./configure
make
sudo make install
cd ..

Proj

为PostGIS提供坐标投影支持,目前最新版本为4.9.3 :下载

# 此URL有可能过期,检查这里http://proj4.org/download.html
wget -P . http://download.osgeo.org/proj/proj-4.9.3.tar.gz
tar -zxf proj-4.9.3.tar.gz
cd proj-4.9.3
make 
sudo make install

JSON-C

目前用于导入GeoJSON格式的数据,函数ST_GeomFromGeoJson用到了这个库。

编译json-c需要用到autoconf, automake, libtool

git clone https://github.com/json-c/json-c
cd json-c
sh autogen.sh

./configure  # --enable-threading
make
make install

LibXML2

目前用于导入GML与KML格式的数据,函数ST_GeomFromGMLST_GeomFromKML依赖这个库。

目前可以在这个FTP服务器上搞到,目前使用的版本是2.9.7

tar -zxf libxml2-sources-2.9.7.tar.gz
cd libxml2-sources-2.9.7
./configure
make 
sudo make install

GADL

wget -P . http://download.osgeo.org/gdal/2.2.3/gdal-2.2.3.tar.gz

SFCGAL

SFCGAL是CGAL的扩展包装,虽说是可选项,但是很多函数都会经常用到,因此这里也需要安装。下载页面

SFCGAL依赖的东西比较多。包括CMake, CGAL, Boost, MPFR, GMP等,其中,CGAL在上面手动安装过了。这里还需要手动安装BOOST

wget -P . https://github.com/Oslandia/SFCGAL/archive/v1.3.0.tar.gz

Boost

Boost是C++的常用库,SFCGAL依赖BOOST,下载页面

wget -P . https://dl.bintray.com/boostorg/release/1.65.1/source/boost_1_65_1.tar.gz
tar -zxf boost_1_65_1.tar.gz
cd boost_1_65_1
./bootstrap.sh
./b2

PostgreSQL MongoFDW安装部署

了解PostgreSQL中的黄金监控指标

更新:最近MongoFDW已经由Cybertech接手维护,也许没有这么不堪了。

最近有业务要求通过PostgreSQL FDW去访问MongoDB。开始我觉得这是个很轻松的任务。但接下来的事真是让人恶心的吐了。MongoDB FDW编译起来真是要人命:混乱的依赖,临时下载和Hotpatch,错误的编译参数,以及最过分的是错误的文档。总算,我在生产环境(Linux RHEL7u2)和开发环境(Mac OS X 10.11.5)都编译成功了。赶紧记录下来,省的下次蛋疼。


环境概述

理论上编译这套东西,GCC版本至少为4.1。 生产环境 (RHEL7.2 + PostgreSQL9.5.3 + GCC 4.8.5) 本地环境 (Mac OS X 10.11.5 + PostgreSQL9.5.3 + clang-703.0.31)


mongo_fdw的依赖

总的来说,能用包管理解决的问题,尽量用包管理解决。 mongo_fdw是我们最终要安装的包 它的直接依赖有三个:

总的来说,mongo_fdw是使用mongo提供的C驱动程序完成功能的。所以我们需要安装libbson与libmongoc。其中libmongoc就是MongoDB的C语言驱动库,它依赖于libbson。 所以最后的安装顺序是: libbsonlibmongocjson-cmongo_fdw


间接依赖

默认依赖的GNU Build全家桶,文档是不会告诉你的。下面列出一些比较简单的,可以通过包管理解决的依赖。请一定按照以下顺序安装GNU Autotools

m4-1.4.17autoconf-2.69automake-1.15libtool-2.4.6pkg-config-0.29.1

总之,用yum也好,apt也好,homebrew也好,都是一行命令能搞定的事。 还有一个依赖是libmongoc的依赖:openssl-devel,不要忘记装。


安装 libbson-1.3.1

git clone -b r1.3 https://github.com/mongodb/libbson;
cd libbson;
git checkout 1.3.1;
./autogen.sh;
make && sudo make install;
make test;

安装 libmongoc-1.3.1

git clone -b r1.3 https://github.com/mongodb/mongo-c-driver
cd mongo-c-driver;
git checkout 1.3.1;
./autogen.sh;
# 下一步很重要,一定要使用刚才安装好的系统中的libbson。
./configure --with-libbson=system;
make && sudo make install;

这里为什么要使用1.3.1的版本?这也是有讲究的。因为mongo_fdw中默认使用的是1.3.1的mongo-c-driver。但是它在文档里说只要1.0.0+就可以,其实是在放狗屁。mongo-c-driver与libbson版本是一一对应的。1.0.0版本的libbson脑子被驴踢了,使用了超出C99的特性,比如复数类型。要是用了默认版本就傻逼了。


安装json-c

首先,我们来解决json-c的问题

git clone https://github.com/json-c/json-c;
cd json-c
git checkout json-c-0.12
  • ./configure完了可不要急着Make,这个版本的json-c编译参数有问题。
  • 打开Makefile,找到CFLAGS,在编译参数后面添加-fPIC
  • 这样GCC会生成位置无关代码,不这样做的话mongo_fdw链接会报错。

安装 mongo_fdw

真正恶心的地方来咯。

git clone https://github.com/EnterpriseDB/mongo_fdw;

好了,如果这时候想当然的运行./autogen.sh --with-master,它就会去重新下一遍上面几个包了……,而且都是从墙外亚马逊的云主机去下。靠谱的方法就是手动一条条的执行autogen里面的命令。

首先把上面的json-c目录复制到mongo_fdw的根目录内。 然后添加libbson和libmongoc的include路径。

export C_INCLUDE_PATH="/usr/local/include/libbson-1.0/:/usr/local/include/libmongoc-1.0:$C_INCLUDE_PATH"

查看autogen.sh,发现里面根据--with-legacy--with-master的不同选项,会有不同的操作。具体来说,当指定--with-master选项时,它会创建一个config.h,里面定义了一个META_DRIVER的宏变量。当有这个宏变量时,mongo_fdw会使用mongoc.h头文件,也就是所谓的“master”,新版的mongo驱动。当没有时,则会使用"mongo.h"头文件,也就是老版的mongo驱动。这里,我们直接vi config.h,添加一行

#define META_DRIVER

这时候,基本上才能算万事大吉。 在最终build之前,别忘了执行:ldconfig

sudo ldconfig

回到mongo_fdw根目录make,不出意外,这个mongo_fdw.so就出来了。


试一试吧?

sudo make install;
psql
admin=# CREATE EXTENSION mongo_fdw;

如果提示找不到 libmongoc.solibbson.so,直接把它们丢进pgsql的lib目录即可。

sudo cp /usr/local/lib/libbson* /usr/local/pgsql/lib/
sudo cp /usr/local/lib/libmongoc* /usr/local/pgsql/lib/

PG 内核

关于 PostgreSQL 内核原理与体系架构的文章

PG先写脏页还是先写WAL?

微信公众号原文

昨天在群里遇到一个有趣的关于 PostgreSQL 的问题:

”写脏数据页和写入WAL缓冲区的先后顺序是什么?“

我们都知道, WAL 就是 Write Ahead Log / 预写式日志 的缩写,那从逻辑上说,好像是先写 WAL 再写数据页才对。

但其实这个问题有趣在,写入其实是发生在两个地方的:内存与磁盘。而这对这两者的写入顺序是不一样的:在内存中,先写脏数据页,再写 WAL记录。在刷盘时,先刷 WAL 记录,再刷脏数据页。

我们可以用一个简单的例子来说明,当你执行一条 INSERT 时到底发生了什么?以及,数据库是如何确保这条插入的数据被正确持久化的。


INSERT的内存修改

当你执行 INSERT 语句时(不包括前后隐含的 BEGIN / COMMIT),修改首先在内存中发生:

1.首先排它锁定并钉住目标数据页面,准备修改。2.进入临界区,不允许打断,出错就 PANIC。3.修改内存中的数据页面。4.将修改的内存数据页面标记为脏页。5.生成一条包含修改内容的 WAL记录 ,写入内存中的 WAL 缓冲区。6.从临界区出来,以上3个操作都是内存中的高速操作7.解锁解钉数据页面。

完成这些任务之后,内存中的缓冲池数据页包含了 INSERT 后的结果,WAL缓冲区中则包含了 INSERT 的 XLogRecord 操作记录。这里我们可以看出,在内存中是先写数据页,再写 WAL 的。原因其实很简单,PostgreSQL默认使用物理复制,记录的是页面内的二进制数据变化,所以只有先把数据写入页面里,才会知道具体的页面变化到底是什么。

内存中的操作非常快,而且这里 3 和 6 中间使用了临界区(Critical Zone),确保数据页/WAL的修改整体是原子性的。不过,内存中的修改要落到磁盘上,才算真正持久化了。所以,还会涉及到 WAL 记录与 脏数据页刷盘的问题。

而这里,才是 Write-Ahead 真正约束的地方:脏数据页刷盘应当晚于WAL缓冲区刷盘。

下面是一个具体的例子,一条由单一 INSERT 语句构成的事务:

参考阅读《PostgreSQL指南:内幕探索》 9.5

testdb=# INSERT INTO tbl VALUES ('A');

执行上述语句时,内部函数exec_simple_query()会被调用,其伪代码如下所示:

exec_simple_query() @postgres.c

(1) ExtendCLOG()    @clog.c     /* 将当前事务的状态"IN_PROGRESS"写入CLOG */
(2) heap_insert()    @heapam.c    /* 插入元组,创建一条XLOG记录并调用函XLogInsert. */
(3)   XLogInsert()    @xlog.c     /* (9.5 以及后续的版本为 xloginsert.c) */
                                /* 将插入元组的XLOG记录写入WAL缓冲区,更新页面的 pd_lsn */

(4) finish_xact_command() @postgres.c    /* 执行提交 */   
      XLogInsert() @xlog.c              /* (9.5 以及后续的版本为 xloginsert.c) */
                                        /* 将该提交行为的XLOG记录写入WAL缓冲区 */
(5)   XLogWrite() @xlog.c                /* 将WAL缓冲区中所有的XLOG刷写入WAL段中 */

(6) TransactionIdCommitTree() @transam.c    
                            /* 在CLOG中将当前事务的状态由"IN_PROGRESS"修改为"COMMITTED" /*

  1. 函数ExtendCLOG()将当前事务的状态IN_PROGRESS写入内存中的CLOG。
  2. 函数heap_insert()向共享缓冲池的目标页面中插入堆元组,创建当前页面的XLOG记录,并执行函数XLogInsert()
  3. 函数XLogInsert()会将heap_insert()创建的XLOG记录写入WAL缓冲区LSN_1处,并将被修改页面的pd_lsnLSN_0更新为LSN_1
  4. 函数finish_xact_command()会在该事务被提交时被调用,用于创建该提交动作的XLOG记录,而这里的XLogInsert()函数会将该记录写入WAL缓冲区LSN_2处。
  5. 函数XLogWrite()会冲刷WAL缓冲区,并将所有内容写入WAL段文件中。如果wal_sync_method参数被配置为open_syncopen_datasync,记录会被同步写入(译者注:而不是提交才会刷新WAL缓冲区),因为函数会使用带有O_SYNCO_DSYNC标记的open()系统调用。如果该参数被配置为fsyncfsync_writethroughfdatasync,相应的系统调用就是fsync(),带有F_FULLSYNC选项的fcntl(),以及fdatasync()。无论哪一种情况,所有的XLOG记录都会被确保写入存储之中。
  6. 函数TransactionIdCommitTree()将提交日志clog中当前事务的状态从IN_PROGRESS更改为COMMITTED

如何强制WAL先于脏页刷盘?

那么,先刷WAL,再刷磁盘这条规则具体是怎么确保的呢?

每一个内存中的数据页上都保存了一个状态:最后一次对本数据页进行修改的 WAL 记录 LSN:pd_lsn,因此如果要把内存中的脏页刷入磁盘中,首先需要确保最后一次对这个页面进行修改的 WAL 已经被刷入磁盘中了。

所以我们可以在在 backend/storage/buffer/bufmgr.c#FlushBuffer (L3350)中看到,刷脏页的过程中会调用 XLogFlush 函数来确保这一点,XLogFlush 函数会检查当前的 WAL 刷盘位置是不是已经大于页面的 LSN,如果不是,则会推动 WAL 刷盘。

recptr = BufferGetLSN(buf);
XLogFlush(recptr);

谁会刷脏页呢?主要是BGWriter与Checkpointer,但普通的后端进程也可以刷脏页。一个脏页具体是被哪个进程刷盘比较随机,大家都有机会出力,但通常来说刷脏页的主力是,后台刷盘进程 BGWriter。不管是哪个进程刷脏页,都会确保最后修改数据页的WAL已经落盘,从而满足 Write Ahead 的约束条件。

脏页会在什么时间被刷盘呢?首先,数据页不能被锁定,其次,数据页不能被钉住。也就是说在上面 INSERT 的例子中,只有完成步骤 7 解锁解钉数据页 后,数据页才有可能被刷盘。而这一行为是异步的,具体时间是不确定的:PostgreSQL 能提供的保证是:在下次 Checkpoint(存盘点/检查点)之前,这个脏页肯定会被刷盘。(bufmgr.c


/*
 * FlushBuffer
 *		Physically write out a shared buffer.
 *
 * NOTE: this actually just passes the buffer contents to the kernel; the
 * real write to disk won't happen until the kernel feels like it.  This
 * is okay from our point of view since we can redo the changes from WAL.
 * However, we will need to force the changes to disk via fsync before
 * we can checkpoint WAL.
 *
 * The caller must hold a pin on the buffer and have share-locked the
 * buffer contents.  (Note: a share-lock does not prevent updates of
 * hint bits in the buffer, so the page could change while the write
 * is in progress, but we assume that that will not invalidate the data
 * written.)
 *
 * If the caller has an smgr reference for the buffer's relation, pass it
 * as the second parameter.  If not, pass NULL.
 */
static void
FlushBuffer(BufferDesc *buf, SMgrRelation reln, IOObject io_object,
			IOContext io_context)
{
	XLogRecPtr	recptr;
	ErrorContextCallback errcallback;
	instr_time	io_start;
	Block		bufBlock;
	char	   *bufToWrite;
	uint32		buf_state;

    /* ... */
	recptr = BufferGetLSN(buf);

	/* To check if block content changes while flushing. - vadim 01/17/97 */
	buf_state &= ~BM_JUST_DIRTIED;
	UnlockBufHdr(buf, buf_state);

	/*
	 * Force XLOG flush up to buffer's LSN.  This implements the basic WAL
	 * rule that log updates must hit disk before any of the data-file changes
	 * they describe do.
	 *
	 * However, this rule does not apply to unlogged relations, which will be
	 * lost after a crash anyway.  Most unlogged relation pages do not bear
	 * LSNs since we never emit WAL records for them, and therefore flushing
	 * up through the buffer LSN would be useless, but harmless.  However,
	 * GiST indexes use LSNs internally to track page-splits, and therefore
	 * unlogged GiST pages bear "fake" LSNs generated by
	 * GetFakeLSNForUnloggedRel.  It is unlikely but possible that the fake
	 * LSN counter could advance past the WAL insertion point; and if it did
	 * happen, attempting to flush WAL through that location would fail, with
	 * disastrous system-wide consequences.  To make sure that can't happen,
	 * skip the flush if the buffer isn't permanent.
	 */
	if (buf_state & BM_PERMANENT)
		XLogFlush(recptr);
    /* ... */
}

WAL是如何刷盘的?

我们已经知道了,刷脏数据页这件事通常是异步进行的,且肯定晚于对应的 WAL 记录刷盘。那么新的问题就是,WAL 是由谁在什么时间点来刷盘的呢:从内存中的 WAL 缓冲区刷入磁盘中?

要回答这个问题,首先要理解 WAL 的模型。WAL 在逻辑上是一个长度无限的文件,任何一个改变数据库系统状态的操作,都会生成相应的 XLogRecord,即 WAL记录。每一条 WAL 记录都会使用其起始位置的文件偏移量作为自己的唯一标识符,即 LSN(逻辑日志位点)。

各种各样修改系统状态的行为都会产生 WAL记录:例如 BEGIN 有一条 WAL记录,INSERT 有一条 WAL记录,COMMIT 也有一条WAL记录,而WAL记录会首先被写入内存中的 WAL缓冲区(最大16MB)。

PostgreSQL 支持多个客户端并发修改,所以同一时刻会有各种进程往内存中的 WAL缓冲区(最大16MB)写东西。所以不同进程、不同事务产生的 XLogRecord 会在同一个逻辑文件中相互交织。每次写入都是原子性的,一条记录一条记录的写。

内存里的WAL缓冲区中的内容,会被各种进程写入/刷入持久化磁盘上的WAL文件里。当前写入内存WAL缓冲区的逻辑日志位置点称作 INSERT LSN。写入操作系统缓冲区的日志位点叫 WRITE LSN,已经使用 FSYNC 之类的 API 确保已经成功持久化的日志位点叫 FLUSH LSN。这里面的关系是 INSERT_LSN >= WRITE_LSN >= FLUSH_LSN。原理很简单:内存中的东西最新,写入可能稍微滞后些,刷盘则可能比写入更滞后一些。

刷盘的主力是 WAL Writer 进程,但其实各种进程都可以刷写。刷盘靠 XLogFlush 函数 (backend/access/transam/xlog.c#XLogFlush),这里的逻辑很简单,就是指定一个位置点,把这个位置点及之前的 WAL 从缓冲区全刷至磁盘。具体的实现逻辑是死循环抢自旋锁,如果目标 LSN 已经被别的进程刷盘了就退出循环,否则就亲自上阵把 WAL 日志刷盘到指定位点。(xlog.c

/*
 * Ensure that all XLOG data through the given position is flushed to disk.
 *
 * NOTE: this differs from XLogWrite mainly in that the WALWriteLock is not
 * already held, and we try to avoid acquiring it if possible.
 */
void
XLogFlush(XLogRecPtr record)
{
	XLogRecPtr	WriteRqstPtr;
	XLogwrtRqst WriteRqst;
	TimeLineID	insertTLI = XLogCtl->InsertTimeLineID;

	/*
	 * During REDO, we are reading not writing WAL.  Therefore, instead of
	 * trying to flush the WAL, we should update minRecoveryPoint instead. We
	 * test XLogInsertAllowed(), not InRecovery, because we need checkpointer
	 * to act this way too, and because when it tries to write the
	 * end-of-recovery checkpoint, it should indeed flush.
	 */
	if (!XLogInsertAllowed())
	{
		UpdateMinRecoveryPoint(record, false);
		return;
	}

	/* Quick exit if already known flushed */
	if (record <= LogwrtResult.Flush)
		return;

	START_CRIT_SECTION();

	/*
	 * Since fsync is usually a horribly expensive operation, we try to
	 * piggyback as much data as we can on each fsync: if we see any more data
	 * entered into the xlog buffer, we'll write and fsync that too, so that
	 * the final value of LogwrtResult.Flush is as large as possible. This
	 * gives us some chance of avoiding another fsync immediately after.
	 */

	/* initialize to given target; may increase below */
	WriteRqstPtr = record;

	/*
	 * Now wait until we get the write lock, or someone else does the flush
	 * for us.
	 */
	for (;;)
	{
		/* ... */
	}

	END_CRIT_SECTION();

	/* wake up walsenders now that we've released heavily contended locks */
	WalSndWakeupProcessRequests(true, !RecoveryInProgress());

	/*
	 * If we still haven't flushed to the request point then we have a
	 * problem; most likely, the requested flush point is past end of XLOG.
	 * This has been seen to occur when a disk page has a corrupted LSN.
	 *
	 * Formerly we treated this as a PANIC condition, but that hurts the
	 * system's robustness rather than helping it: we do not want to take down
	 * the whole system due to corruption on one data page.  In particular, if
	 * the bad page is encountered again during recovery then we would be
	 * unable to restart the database at all!  (This scenario actually
	 * happened in the field several times with 7.1 releases.)	As of 8.4, bad
	 * LSNs encountered during recovery are UpdateMinRecoveryPoint's problem;
	 * the only time we can reach here during recovery is while flushing the
	 * end-of-recovery checkpoint record, and we don't expect that to have a
	 * bad LSN.
	 *
	 * Note that for calls from xact.c, the ERROR will be promoted to PANIC
	 * since xact.c calls this routine inside a critical section.  However,
	 * calls from bufmgr.c are not within critical sections and so we will not
	 * force a restart for a bad LSN on a data page.
	 */
	if (LogwrtResult.Flush < record)
		elog(ERROR,
			 "xlog flush request %X/%X is not satisfied --- flushed only to %X/%X",
			 LSN_FORMAT_ARGS(record),
			 LSN_FORMAT_ARGS(LogwrtResult.Flush));
}

关于内核原理

关于 PostgreSQL 的内核原理,我认为有几个学习材料非常值得参考。

第一本是《PG Internal》,鈴木啓修写的,基于 PostgreSQL 9.6 与 11 的代码,讲解PG内核原理。我之前翻译了中文版《PostgreSQL指南:内部探索》。第二本是 《PostgreSQL 14 Internal》,是俄罗斯 Postgres Pro 公司 Egor Rogov 写的,基于 PostgreSQL 14 进行架构讲解。

当然我认为最有学习价值的还是 PostgreSQL 源代码,特别是源代码中的 README,比如本文中的这个问题,就在事务管理器源码 README 中详细介绍了。PostgreSQL 的源代码是自我解释的,你只需要懂英文大致就能理解这里面的逻辑。


The general schema for executing a WAL-logged action is

1. Pin and exclusive-lock the shared buffer(s) containing the data page(s)
to be modified.

2. START_CRIT_SECTION()  (Any error during the next three steps must cause a
PANIC because the shared buffers will contain unlogged changes, which we
have to ensure don't get to disk.  Obviously, you should check conditions
such as whether there's enough free space on the page before you start the
critical section.)

3. Apply the required changes to the shared buffer(s).

4. Mark the shared buffer(s) as dirty with MarkBufferDirty().  (This must
happen before the WAL record is inserted; see notes in SyncOneBuffer().)
Note that marking a buffer dirty with MarkBufferDirty() should only
happen iff you write a WAL record; see Writing Hints below.

5. If the relation requires WAL-logging, build a WAL record using
XLogBeginInsert and XLogRegister* functions, and insert it.  (See
"Constructing a WAL record" below).  Then update the page's LSN using the
returned XLOG location.  For instance,

		XLogBeginInsert();
		XLogRegisterBuffer(...)
		XLogRegisterData(...)
		recptr = XLogInsert(rmgr_id, info);

		PageSetLSN(dp, recptr);

6. END_CRIT_SECTION()

7. Unlock and unpin the buffer(s).

Complex changes (such as a multilevel index insertion) normally need to be
described by a series of atomic-action WAL records.  The intermediate states
must be self-consistent, so that if the replay is interrupted between any
two actions, the system is fully functional.  In btree indexes, for example,
a page split requires a new page to be allocated, and an insertion of a new
key in the parent btree level, but for locking reasons this has to be
reflected by two separate WAL records.  Replaying the first record, to
allocate the new page and move tuples to it, sets a flag on the page to
indicate that the key has not been inserted to the parent yet.  Replaying the
second record clears the flag.  This intermediate state is never seen by
other backends during normal operation, because the lock on the child page
is held across the two actions, but will be seen if the operation is
interrupted before writing the second WAL record.  The search algorithm works
with the intermediate state as normal, but if an insertion encounters a page
with the incomplete-split flag set, it will finish the interrupted split by
inserting the key to the parent, before proceeding.

第一章 数据库的物理/逻辑结构

第一章和第二章简单介绍了一些PostgreSQL的基础知识,有助于读者理解后续章节的内容。本章包括以下几个主题:

  • 数据库集簇(database cluster) 的逻辑结构
  • 数据库集簇的物理结构
  • 堆表(heap table) 文件的内部布局
  • 从表中读写数据的方式

如果你已经熟悉这些内容,可以跳过本章。

1.1 数据库集簇的逻辑结构

数据库集簇(database cluster) 是一组 数据库(database) 的集合,由一个PostgreSQL服务器管理。第一次听到这个定义也许会令人疑惑,PostgreSQL中的术语“数据库集簇”,并非 意味着“一组数据库服务器”。 一个PostgreSQL服务器只会在单机上运行并管理单个数据库集簇。

图1.1展示了一个数据库集簇的逻辑结构。 数据库(database)数据库对象(database objects) 的集合。 在关系型数据库理论中,数据库对象是用于存储或引用数据的数据结构。 (堆)表是一个典型的例子,还有更多种对象,例如索引,序列,视图,函数等。 在PostgreSQL中数据库本身也是数据库对象,并在逻辑上彼此分离。 所有其他的数据库对象(例如表,索引等)归属于各自相应的数据库。

图1.1 数据库集簇的逻辑结构

在PostgreSQL内部,所有的数据库对象都通过相应的 对象标识符(Object Identifiers, OID) 进行管理,这些标识符是无符号的4字节整型。数据库对象与相应OID之间的关系存储在相应的系统目录中,依具体的对象类型而异。 例如数据库和堆表对象的OID分别存储在pg_databasepg_class中,因此当你希望找出OID时,可以执行以下查询:

sampledb=# SELECT datname, oid FROM pg_database WHERE datname = 'sampledb';
 datname  |  oid  
----------+-------
 sampledb | 16384
(1 row)

sampledb=# SELECT relname, oid FROM pg_class WHERE relname = 'sampletbl';
  relname  |  oid  
-----------+-------
 sampletbl | 18740 
(1 row)

1.2 数据库集簇的物理结构

数据库集簇在本质上就是一个文件目录,名曰 基础目录(base directory) ,包含着一系列子目录与文件。 执行 initdb 命令会在指定目录下创建基础目录从而初始化一个新的数据库集簇。 通常会将基础目录的路径配置到环境变量PGDATA中,但这并不是必须的。

图1.2 展示了一个PostgreSQL数据库集簇的例子。 base子目录中的每一个子目录都对应一个数据库,数据库中每个表和索引都会在相应子目录下存储为(至少)一个文件;还有几个包含特定数据的子目录,以及配置文件。 虽然PostgreSQL支持表空间(Tablespace),但该术语的含义与其他RDBMS不同。 PostgreSQL中的表空间对应一个包含基础目录之外数据的目录。

图1.2 数据库集簇示例

后续小节将描述数据库集簇的布局,数据库的布局,表和索引对应的文件布局,以及PostgreSQL中表空间的布局。

1.2.1 数据库集簇的布局

官方文档中描述了数据库集簇的布局。 表1.1中列出了主要的文件与子目录:

表 1.1 基本目录下的数据库文件和子目录的布局(参考官方文档)

文件 描述
PG_VERSION 包含PostgreSQL主版本号
pg_hba.conf 控制PosgreSQL客户端认证
pg_ident.conf 控制PostgreSQL用户名映射
postgresql.conf 配置参数
postgresql.auto.conf 存储使用ALTER SYSTEM修改的配置参数(9.4或更新版本)
postmaster.opts 记录服务器上次启动的命令行选项
子目录 描述
base/ 每个数据库对应的子目录存储于此
global/ 数据库集簇范畴的表(例如pg_database),以及pg_control文件。
pg_commit_ts/ 事务提交的时间戳数据(9.5及更新版本)。
pg_clog/ (9.6-) 事务提交状态数据(9.6及更老版本),在版本10中被重命名为pg_xact。CLOG将在5.4节中描述
pg_dynshmem/ 动态共享内存子系统中使用的文件(9.4或更新版本)。
pg_logical/ 逻辑解码的状态数据(9.4或更新版本)。
pg_multixact/ 多事务状态数据
pg_notify/ LISTEN/NOTIFY状态数据
pg_repslot/ 复制槽数据(9.4或更新版本)。
pg_serial/ 已提交的可串行化事务相关信息(9.1或更新版本)
pg_snapshots/ 导出快照(9.2或更新版本)。 PostgreSQL函数pg_export_snapshot在此子目录中创建快照信息文件。
pg_stat/ 统计子系统的永久文件
pg_stat_tmp/ 统计子系统的临时文件
pg_subtrans/ 子事务状态数据
pg_tblspc/ 指向表空间的符号链接
pg_twophase/ 两阶段事务(prepared transactions)的状态文件
pg_wal/ (10+) WAL( Write Ahead Logging)段文件(10或更新版本),从pg_xlog重命名而来。
pg_xact/ (10+) 事务提交状态数据,(10或更新版本),从pg_clog重命名而来。CLOG将在5.4节中描述。
pg_xlog/ (9.6-) WAL(Write Ahead Logging) 段文件(9.6及更老版本),它在版本10中被重命名为pg_wal

1.2.2 数据库布局

一个数据库与base子目录下的一个子目录对应;且该子目录的名称与相应数据库的OID相同。 例如当数据库sampledb的OID为16384时,它对应的子目录名称即为16384。

$ cd $PGDATA
$ ls -ld base/16384
drwx------  213 postgres postgres  7242  8 26 16:33 16384

1.2.3 表与索引相关文件的布局

每个小于1GB的表或索引都在相应的数据库目录中存储为单个文件。在数据库内部,表和索引作为数据库对象是通过OID来管理的,而这些数据文件则由变量relfilenode管理。 表和索引的relfilenode值通常与其OID一致,但也有例外,下面将详细展开。

让我们看一看表sampletbloidrelfilenode

sampledb=# SELECT relname, oid, relfilenode FROM pg_class WHERE relname = 'sampletbl';
  relname  |  oid  | relfilenode
-----------+-------+-------------
 sampletbl | 18740 |       18740 
(1 row)

从上面的结果可以看出oidrelfilenode值相等。还可以看到表sampletbl的数据文件路径是base/16384/18740

$ cd $PGDATA
$ ls -la base/16384/18740
-rw------- 1 postgres postgres 8192 Apr 21 10:21 base/16384/18740

表和索引的relfilenode值会被一些命令(例如TRUNCATEREINDEXCLUSTER)所改变。 例如对表 sampletbl执行TRUNCATE,PostgreSQL会为表分配一个新的relfilenode(18812),删除旧的数据文件(18740),并创建一个新的数据文件(18812)。

sampledb=# TRUNCATE sampletbl;
TRUNCATE TABLE

sampledb=# SELECT relname, oid, relfilenode FROM pg_class WHERE relname = 'sampletbl';
  relname  |  oid  | relfilenode
-----------+-------+-------------
 sampletbl | 18740 |       18812 
(1 row)

在9.0或更高版本中,内建函数pg_relation_filepath能够根据OID或名称返回关系对应的文件路径,非常实用。

sampledb=# SELECT pg_relation_filepath('sampletbl');
pg_relation_filepath 
----------------------
base/16384/18812
(1 row)

当表和索引的文件大小超过1GB时,PostgreSQL会创建并使用一个名为relfilenode.1的新文件。如果新文件也填满了,则会创建下一个名为relfilenode.2的新文件,依此类推。

译者注:数据库系统中的 表(Table) 与关系代数中的 关系(Relation) 关系紧密但又不尽相同。在PostgreSQL中,表,索引,TOAST表都归类为关系。

$ cd $PGDATA
$ ls -la -h base/16384/19427*
-rw------- 1 postgres postgres 1.0G  Apr  21 11:16 data/base/16384/19427
-rw------- 1 postgres postgres  45M  Apr  21 11:20 data/base/16384/19427.1
...

在构建PostgreSQL时,可以使用配置选项--with-segsize更改表和索引的最大文件大小。

仔细观察数据库子目录就会发现,每个表都有两个与之相关联的文件,后缀分别为_fsm_vm。这些实际上是空闲空间映射(free space map)可见性映射(visibility map) 文件,分别存储了表文件每个页面上的空闲空间信息与可见性信息(更多细节见第5.3.4节第6.2节)。索引没有可见性映射文件,只有空闲空间映射文件。

一个具体的示例如下所示:

$ cd $PGDATA
$ ls -la base/16384/18751*
-rw------- 1 postgres postgres  8192 Apr 21 10:21 base/16384/18751
-rw------- 1 postgres postgres 24576 Apr 21 10:18 base/16384/18751_fsm
-rw------- 1 postgres postgres  8192 Apr 21 10:18 base/16384/18751_vm

在数据库系统内部,这些文件(主体数据文件,空闲空间映射文件,可见性映射文件等)也被称为相应关系的 分支(fork) ;空闲空间映射是表/索引数据文件的第一个分支(分支编号为1),可见性映射表是数据文件的第二个分支(分支编号为2),数据文件的分支编号为0。

译者注:每个 关系(relation) 可能会有四种分支,分支编号分别为0,1,2,3,0号分支main为关系数据文件本体,1号分支fsm保存了main分支中空闲空间的信息,2号分支vm保存了main分支中可见性的信息,3号分支init是很少见的特殊分支,通常用于不被日志记录(unlogged)的表与索引。

每个分支都会被存储为磁盘上的一到多个文件:PostgreSQL会将过大的分支文件切分为若干个段,以免文件的尺寸超过某些特定文件系统允许的大小,也便于一些归档工具进行并发复制,默认的段大小为1GB。

1.2.4 表空间

PostgreSQL中的 表空间(Tablespace) 是基础目录之外的附加数据区域。 在8.0版本中引入了该功能。

图1.3展示了表空间的内部布局,以及表空间与主数据区域的关系。

图 1.3 数据库集簇的表空间

执行CREATE TABLESPACE语句会在指定的目录下创建表空间。而在该目录下还会创建版本特定的子目录(例如PG_9.4_201409291)。版本特定的命名方式为:

PG_主版本号_目录版本号

举个例子,如果在/home/postgres/tblspc中创建一个表空间new_tblspc,其oid为16386,则会在表空间下创建一个名如PG_9.4_201409291的子目录。

$ ls -l /home/postgres/tblspc/
total 4
drwx------ 2 postgres postgres 4096 Apr 21 10:08 PG_9.4_201409291

表空间目录通过pg_tblspc子目录中的符号链接寻址,链接名称与表空间的OID值相同。

$ ls -l $PGDATA/pg_tblspc/
total 0
lrwxrwxrwx 1 postgres postgres 21 Apr 21 10:08 16386 -> /home/postgres/tblspc

如果在该表空间下创建新的数据库(OID为16387),则会在版本特定的子目录下创建相应目录。

$ ls -l /home/postgres/tblspc/PG_9.4_201409291/
total 4
drwx------ 2 postgres postgres 4096 Apr 21 10:10 16387

如果在该表空间内创建一个新表,但新表所属的数据库却创建在基础目录下,那么PG会首先在版本特定的子目录下创建名称与现有数据库OID相同的新目录,然后将新表文件放置在刚创建的目录下。

sampledb=# CREATE TABLE newtbl (.....) TABLESPACE new_tblspc;

sampledb=# SELECT pg_relation_filepath('newtbl');
             pg_relation_filepath             
----------------------------------------------
 pg_tblspc/16386/PG_9.4_201409291/16384/18894

1.3 堆表文件的内部布局

在数据文件(堆表,索引,也包括空闲空间映射和可见性映射)内部,它被划分为固定长度的页(pages),或曰 区块(blocks),大小默认为8192字节(8KB)。 每个文件中的页从0开始按顺序编号,这些数字称为区块号(block numbers)。 如果文件已填满,PostgreSQL通过在文件末尾追加一个新的空页来增长文件。

页面内部的布局取决于数据文件的类型。本节会描述表的页面布局,因为理解接下来的几章需要这些知识。

图 1.4. 堆表文件的页面布局

表的页面包含了三种类型的数据:

  1. 堆元组(heap tuples) —— 堆元组就是数据记录本身。它们从页面底部开始依序堆叠。第5.2节第9章会描述元组的内部结构,这一知识对于理解PostgreSQL并发控制与WAL机制是必须的。

  2. 行指针(line pointer) —— 每个行指针占4个字节,保存着指向堆元组的指针。它们也被称为项目指针(item pointer)。行指针简单地组织为一个数组,扮演了元组索引的角色。每个索引项从1开始依次编号,称为偏移号(offset number)。当向页面中添加新元组时,一个相应的新行指针也会被放入数组中,并指向新添加的元组。

  3. 首部数据(header data) —— 页面的起始位置分配了由结构PageHeaderData定义的首部数据。它的大小为24个字节,包含关于页面的元数据。该结构的主要成员变量为:

    • pd_lsn —— 本页面最近一次变更所写入XLOG记录对应的LSN。它是一个8字节无符号整数,与WAL机制相关,第9章将详细展开。
    • pd_checksum —— 本页面的校验和值。(注意只有在9.3或更高版本才有此变量,早期版中该字段用于存储页面的时间线标识)
    • pd_lowerpd_upper —— pd_lower指向行指针的末尾,pd_upper指向最新堆元组的起始位置。
    • pd_special —— 在索引页中会用到该字段。在堆表页中它指向页尾。(在索引页中它指向特殊空间的起始位置,特殊空间是仅由索引使用的特殊数据区域,包含特定的数据,具体内容依索引的类型而定,如B树,GiST,GiN等。
    /* @src/include/storage/bufpage.h */
    
    /*
     * 磁盘页面布局
     *
     * 对任何页面都适用的通用空间管理信息
     *
     *		pd_lsn		- 本页面最近变更对应xlog记录的标识。
     *		pd_checksum - 页面校验和
     *		pd_flags	- 标记位
     *		pd_lower	- 空闲空间开始位置
     *		pd_upper	- 空闲空间结束位置
     *		pd_special	- 特殊空间开始位置
     *		pd_pagesize_version - 页面的大小,以及页面布局的版本号
     *		pd_prune_xid - 本页面中可以修剪的最老的元组中的XID.
     *
     * 缓冲管理器使用LSN来强制实施WAL的基本规则:"WAL需先于数据写入"。直到xlog刷盘位置超过
     * 本页面的LSN之前,不允许将缓冲区的脏页刷入磁盘。
     *
     * pd_checksum 存储着页面的校验和,如果本页面配置了校验。0是一个合法的校验和值。如果页面
     * 没有使用校验和,我们就不会设置这个字段的值;通常这意味着该字段值为0,但如果数据库是从早于
     * 9.3版本从 pg_upgrade升级而来,也可能会出现非零的值。因为那时候这块地方用于存储页面最后
     * 更新时的时间线标识。 注意,并没有标识告诉你页面的标识符到底是有效还是无效的,也没有与之关
     * 联的标记为。这是特意设计成这样的,从而避免了需要依赖页面的具体内容来决定是否校验页面本身。
     *
     * pd_prune_xid是一个提示字段,用于帮助确认剪枝是否有用。目前对于索引页没用。
     *
     * 页面版本编号与页面尺寸被打包成了单个uint16字段,这是有历史原因的:在PostgreSQL7.3之前
     * 并没有页面版本编号这个概念,这样做能让我们假装7.3之前的版本的页面版本编号为0。我们约束页面
     * 的尺寸必须为256的倍数,留下低8位用于页面版本编号。
     *
     * 最小的可行页面大小可能是64字节,能放下页的首部,空闲空间,以及一个最小的元组。当然在实践中
     * 肯定要大得多(默认为8192字节),所以页面大小必需是256的倍数并不是一个重要限制。而在另一端,
     * 我们最大只能支持32KB的页面,因为 lp_off/lp_len字段都是15bit。
     */
    typedef struct PageHeaderData
    {
    	PageXLogRecPtr 	pd_lsn;			/* 最近应用至本页面XLog记录的LSN */
    	uint16			pd_checksum;	/* 校验和 */
    	uint16		  	pd_flags;		/* 标记位,详情见下 */
    	LocationIndex 	pd_lower;		/* 空闲空间起始位置 */
    	LocationIndex 	pd_upper;		/* 空闲空间终止位置 */
    	LocationIndex 	pd_special;		/* 特殊用途空间的开始位置 */
    	uint16		  	pd_pagesize_version;
    	TransactionId 	pd_prune_xid; 	/* 最老的可修剪XID, 如果没有设置为0 */
    	ItemIdData		pd_linp[FLEXIBLE_ARRAY_MEMBER]; /* 行指针的数组 */
    } PageHeaderData;
    
    
    /* 缓冲区页中的项目指针(item pointer),也被称为行指针(line pointer)。
     *
     * 在某些情况下,项目指针处于 “使用中”的状态,但在本页中没有任何相关联的存储区域。
     * 按照惯例,lp_len == 0 表示该行指针没有关联存储。独立于其lp_flags的状态. 
     */
    typedef struct ItemIdData
    {
    	unsigned	lp_off:15,		/* 元组偏移量 (相对页面起始处) */
    				lp_flags:2,		/* 行指针的状态,见下 */
    				lp_len:15;		/* 元组的长度,以字节计 */
    } ItemIdData;
    
    /* lp_flags有下列可能的状态,LP_UNUSED的行指针可以立即重用,而其他状态的不行。 */
    #define LP_UNUSED		0		/* unused (lp_len必需始终为0) */
    #define LP_NORMAL		1		/* used (lp_len必需始终>0) */
    #define LP_REDIRECT		2		/* HOT 重定向 (lp_len必需为0) */
    #define LP_DEAD			3		/* 死元组,有没有对应的存储尚未可知 */
    

行指针的末尾与最新元组起始位置之间的空余空间称为空闲空间(free space)空洞(hole)

为了识别表中的元组,数据库内部会使用元组标识符(tuple identifier, TID)。TID由一对值组成:元组所属页面的区块号,及指向元组的行指针的偏移号。TID的一种典型用途是索引,更多细节参见第1.4.2节

结构体PageHeaderData定义于src/include/storage/bufpage.h中。

此外,大小超过约2KB(8KB的四分之一)的堆元组会使用一种称为 TOAST(The Oversized-Attribute Storage Technique,超大属性存储技术) 的方法来存储与管理。详情请参阅PostgreSQL文档

1.4 读写元组的方式

本章的最后将描述读取与写入堆元组的方式。

1.4.1 写入堆元组

让我们假设有一个表,仅由一个页面组成,且该页面只包含一个堆元组。 此页面的pd_lower指向第一个行指针,而该行指针和pd_upper都指向第一个堆元组。 如图1.5(a)所示。

当第二个元组被插入时,它会被放在第一个元组之后。第二个行指针被插入到第一个行指针的后面,并指向第二个元组。 pd_lower更改为指向第二个行指针,pd_upper更改为指向第二个堆元组,如图1.5(b)。 页面内的首部数据(例如pd_lsnpg_checksumpg_flag)也会被改写为适当的值,细节在第5.3节第9章中描述。

图1.5 堆元组的写入

1.4.2 读取堆元组

这里简述两种典型的访问方式:顺序扫描与B树索引扫描:

  • 顺序扫描 —— 通过扫描每一页中的行指针,依序读取所有页面中的所有元组,如图1.6(a)。
  • B树索引扫描 —— 索引文件包含着索引元组,索引元组由一个键值对组成,键为被索引的列值,值为目标堆元组的TID。进行索引查询时,首先使用键进行查找,如果找到了对应的索引元组,PostgreSQL就会根据相应值中的TID来读取对应的堆元组 (使用B树索引找到索引元组的方法请参考相关资料,这一部分属于数据库系统的通用知识,限于篇幅这里不再详细展开)。例如在图1.6(b)中,所获索引元组中TID的值为(区块号 = 7,偏移号 = 2), 这意味着目标堆元组是表中第7页的第2个元组,因而PostgreSQL可以直接读取所需的堆元组,而避免对页面做不必要的扫描。

图 1.6 顺序扫描和索引扫描

PostgreSQL还支持TID扫描位图扫描(Bitmap-Scan仅索引扫描(Index-Only-Scan)

TID扫描是一种通过使用所需元组的TID直接访问元组的方法。 例如要在表中找到第0个页面中的第1个元组,可以执行以下查询:

sampledb=# SELECT ctid, data FROM sampletbl WHERE ctid = '(0,1)';
 ctid  |   data    
-------+-----------
 (0,1) | AAAAAAAAA
(1 row)

sampledb=# EXPLAIN SELECT ctid, data FROM sampletbl WHERE ctid = '(0,1)';
                        QUERY PLAN
----------------------------------------------------------
 Tid Scan on sampletbl  (cost=0.00..1.11 rows=1 width=38)
   TID Cond: (ctid = '(0,1)'::tid)

仅索引扫描将在第7章中详细介绍。

第二章 进程和内存架构

本章总结了PostgreSQL中进程与内存的架构,有助于读者理解后续章节。 如果读者已经熟悉这些内容,可以直接跳过本章。

2.1 进程架构

PostgreSQL是一个客户端/服务器风格的关系型数据库管理系统,采用多进程架构,运行在单台主机上。

我们通常所谓的“PostgreSQL服务器(PostgreSQL Server)” 实际上是一系列协同工作的进程集合,包含着下列进程:

  • Postgres服务器进程(Postgres Server Process) 是所有数据库集簇管理进程的父进程。

  • 每个后端进程(Backend Process) 负责处理客户端发出的查询和语句。

  • 各种后台进程(Background Process) 负责执行各种数据库管理任务(例如清理过程与检查点过程)。

  • 各种复制相关(Replication Associated Process) 的进程负责流复制,流复制的细节会在第11章中介绍。

  • 后台工作进程(Background Worker Process) 在9.3版被引入,它能执行任意由用户实现的处理逻辑。这里不详述,请参阅官方文档

以下几小节将详细描述前三种进程。

图2.1 PostgreSQL的进程架构示例

本图展示了PostgreSQL服务器包含的进程:postgres服务器进程,两个后端进程,七个后台进程,以及两个客户端进程。 也画出了数据库集簇,共享内存,以及两个客户端。

2.1.1 Postgres服务器进程

如上所述,Postgres服务器进程(postgres server process) 是PostgreSQL服务器中所有进程的父进程,在早期版本中它被称为*“postmaster“*。

start参数执行pg_ctl实用程序会启动一个postgres服务器进程。它会在内存中分配共享内存区域,启动各种后台进程,如有必要还会启动复制相关进程与后台工作进程,并等待来自客户端的连接请求。 每当接收到来自客户端的连接请求时,它都会启动一个后端进程 (然后由启动的后端进程处理该客户端发出的所有查询)。

一个postgres服务器进程只会监听一个网络端口,默认端口为5432。如果要在同一台主机上运行多个PostgreSQL服务器,则应为每个服务器配置不同的监听端口,如5432,5433等。

2.1.2 后端进程

每个后端进程(也称为*”postgres“*)由postgres服务器进程启动,并处理连接另一侧的客户端发出的所有查询。它通过单条TCP连接与客户端通信,并在客户端断开连接时终止。

因为一条连接只允许操作一个数据库,因此必须在连接到PostgreSQL服务器时显式指定要连接的数据库。

PostgreSQL允许多个客户端同时连接;配置参数max_connections用于控制最大客户端连接数(默认为100)。

因为PostgreSQL没有原生的连接池功能,因此如果许多客户端频繁地重复与PostgreSQL服务器建立断开连接(譬如WEB应用),则会导致建立连接与创建后端进程的开销变大。这种情况对数据库服务器的性能有负面影响,通常可以使用池化中间件(pgbouncerpgpool-II)来避免该问题。

2.1.3 后台进程

表2.1是后台进程的列表。比起postgres服务器和后端进程,后台进程的种类要多很多。想要简单地解释每种后台进程的具体功能是不现实的,因为这些功能有赖PostgreSQL的内部机制与特定的独立特性。依赖于各个特定的特性以及PostgreSQL的内部机制。 因此在本章中仅做简要介绍。 细节将在后续章节中描述。

表2.1 后台进程

进程 概述 参考
background writer 本进程负责将共享缓冲池中的脏页逐渐刷入持久化存储中(例如,HDD,SSD)(在9.1及更旧版本中,它还负责处理检查点(checkpoint) 8.6
checkpointer 在9.2及更新版本中,该进程负责处理检查点。 8.6, 9.7
autovacuum launcher 周期性地启动自动清理工作进程(更准确地说,它向Postgres服务器请求创建自动清理工作进程) 6.5
WAL writer 本进程周期性地将WAL缓冲区中的WAL数据刷入持久存储中。 9.9
statistics collector 本进程负责收集统计信息,用于诸如pg_stat_activitypg_stat_database等系统视图。
logging collector (logger) 本进程负责将错误消息写入日志文件。
archiver 本进程负责将日志归档。 9.10

这里展示了PostgreSQL服务器包含的实际进程。 在以下示例中有一个postgres服务器进程(pid为9687),两个后端进程(pid为9697和9717),以及表2.1中列出的几个后台进程正在运行,亦见图2.1。

postgres> pstree -p 9687
-+= 00001 root /sbin/launchd
 \-+- 09687 postgres /usr/local/pgsql/bin/postgres -D /usr/local/pgsql/data
   |--= 09688 postgres postgres: logger process     
   |--= 09690 postgres postgres: checkpointer process     
   |--= 09691 postgres postgres: writer process     
   |--= 09692 postgres postgres: wal writer process     
   |--= 09693 postgres postgres: autovacuum launcher process     
   |--= 09694 postgres postgres: archiver process     
   |--= 09695 postgres postgres: stats collector process     
   |--= 09697 postgres postgres: postgres sampledb 192.168.1.100(54924) idle  
   \--= 09717 postgres postgres: postgres sampledb 192.168.1.100(54964) idle in transaction  

2.2 内存架构

PostgreSQL的内存架构可以分为两部分:

  • 本地内存区域 —— 由每个后端进程分配,供自己使用。
  • 共享内存区域 —— 供PostgreSQL服务器的所有进程使用。

下面一小节简要介绍了这两部分架构。

图2.2 PostgreSQL的内存架构

2.2.1 本地内存区域

每个后端进程都会分配一块本地内存区域用于查询处理。该区域会分为几个子区域 —— 子区域的大小有的固定,有的可变。 表2.2列出了主要的子区域。 详细信息将在后续章节中介绍。

表2.2 本地内存区域

子区域 描述 参考
work_mem 执行器在执行ORDER BYDISTINCT时使用该区域对元组做排序,以及存储归并连接和散列连接中的连接表。 第3章
maintenance_work_mem 某些类型的维护操作使用该区域(例如VACUUMREINDEX)。 6.1
temp_buffers 执行器使用此区域存储临时表。

2.2.2 共享内存区域

PostgreSQL服务器启动时会分配共享内存区域。该区域分为几个固定大小的子区域。 表2.3列出了主要的子区域。 详细信息将在后续章节中介绍。

表2.3 共享内存区域

子区域 描述 参考
shared buffer pool PostgreSQL将表和索引中的页面从持久存储加载至此,并直接操作它们。 第8章
WAL buffer 为确保服务故障不会导致任何数据丢失,PostgreSQL实现了WAL机制。 WAL数据(也称为XLOG记录)是PostgreSQL中的事务日志;WAL缓冲区是WAL数据在写入持久存储之前的缓冲区。 第9章
commit log 提交日志(Commit Log, CLOG) 为并发控制(CC)机制保存了所需的所有事务状态(例如进行中,已提交,已中止等)。 5.4

除了上面这些,PostgreSQL还分配了这几个区域:

  • 用于访问控制机制的子区域(例如信号量,轻量级锁,共享和排他锁等)。
  • 各种后台进程使用的子区域,例如checkpointerautovacuum
  • 用于事务处理的子区域,例如保存点(save-point)两阶段提交(2PC)

诸如此类。

第三章 查询处理

查询处理是PostgreSQL中最为复杂的子系统。如PostgreSQL官方文档所述,PostgreSQL支持SQL2011标准中的大多数特性,查询处理子系统能够高效地处理这些SQL。本章概述了查询处理的流程,特别关注了查询优化的部分。

本章包括下列三个部分:

  • 第一部分:3.1节

    这一节会简单介绍PostgreSQL中查询处理的流程。

  • 第二部分:3.2~3.4节

    这一部分会描述获取单表查询上最优执行计划的步骤。3.2节讨论代价估计的过程,3.3节描述创建计划树的过程,3.4节将简要介绍执行器的工作过程。

  • 第三部分:3.5~3.6节

    这一部分会描述获取多表查询上最优执行计划的步骤。3.5节介绍了三种连接算法:嵌套循环连接(Nested Loop Join)归并连接(Merge Join)散列连接(Hash Join)。3.6节将介绍为多表查询创建计划树的过程。

PostgreSQL支持三种技术上很有趣,而且也很实用的功能:外部数据包装(Foreign Data Wrapper, FDW)并行查询,以及版本11即将支持的JIT编译。前两者将在第4章中描述,JIT编译超出范围本书的范围,详见官方文档

3.1 概览

尽管PostgreSQL在9.6版本后有了基于多个后台工作进程的并行查询,但大体上来讲,还是每个连接对应一个后端进程。后端进程由五个子系统组成,如下所示:

  1. 解析器(Parser)

    解析器根据SQL语句生成一颗语法解析树(parse tree)

  2. 分析器(Analyzer)

    分析器对语法解析树进行语义分析,生成一颗查询树(query tree)

  3. 重写器(Rewriter)

    重写器按照规则系统中存在的规则,对查询树进行改写。

  4. 计划器(Planner)

    计划器基于查询树,生成一颗执行效率最高的计划树(plan tree)

  5. 执行器(Executor)

    执行器按照计划树中的顺序访问表和索引,执行相应查询。

图3.1 查询处理

QueryProcessing

本节将概述这些子系统。计划器和执行器很复杂,后面的章节会对这些函数的细节进行描述。

PostgreSQL的查询处理在官方文档中有详细的描述

3.1.1 解析器(Parser)

解析器基于SQL语句的文本,生成一颗后续子系统可以理解的语法解析树。下面是一个具体的例子。

考虑以下查询:

testdb=# SELECT id, data FROM tbl_a WHERE id < 300 ORDER BY data;

语法解析树的根节点是一个定义在parsenodes.h中的SelectStmt数据结构。图3.2(a)展示了一个查询,而图3.2(b)则是该查询对应的语法解析树。

typedef struct SelectStmt
{
        NodeTag         type;

        /* 这些字段只会在SelectStmts“叶节点”中使用 */
        List       *distinctClause;     /* NULL, DISTINCT ON表达式列表, 或
                                       对所有的(SELECT DISTINCT)为lcons(NIL,NIL) */
        IntoClause *intoClause;         /* SELECT INTO 的目标 */
        List       *targetList;         /* 结果目标列表 (ResTarget) */
        List       *fromClause;         /* FROM 子句 */
        Node       *whereClause;        /* WHERE 限定条件 */
        List       *groupClause;        /* GROUP BY 子句 */
        Node       *havingClause;       /* HAVING 条件表达式 */
        List       *windowClause;       /* WINDOW window_name AS (...), ... */

        /*  在一个表示值列表的叶节点中,上面的字段全都为空,而这个字段会被设置。
         * 注意这个子列表中的元素仅仅是表达式,没有ResTarget的修饰,还需要注意列表元素可能为
         * DEFAULT (表示一个 SetToDefault 节点),而无论值列表的上下文。 
         * 由分析阶段决定否合法并拒绝。      */
        List       *valuesLists;        /* 未转换的表达式列表 */

        /* 这些字段会同时在SelectStmts叶节点与SelectStmts上层节点中使用 */
        List       *sortClause;         /* 排序子句 (排序依据的列表) */
        Node       *limitOffset;        /* 需要跳过的元组数目 */
        Node       *limitCount;         /* 需要返回的元组数目 */
        List       *lockingClause;      /* FOR UPDATE (锁子句的列表) */
        WithClause *withClause;         /* WITH 子句 */

        /* 这些字段只会在上层的 SelectStmts 中出现 */
        SetOperation op;                /* set 操作的类型 */
        bool            all;            /* 是否指明了 ALL 选项? */
        struct SelectStmt *larg;        /* 左子节点 */
        struct SelectStmt *rarg;        /* 右子节点 */
} SelectStmt;

图3.2. 语法解析树的例子

ParseTree

SELECT查询中的元素和语法解析树中的元素有着对应关系。比如,(1)是目标列表中的一个元素,与目标表的'id'列相对应,(4)是一个WHERE子句,诸如此类。

当解析器生成语法分析树时只会检查语法,只有当查询中出现语法错误时才会返回错误。解析器并不会检查输入查询的语义,举个例子,如果查询中包含一个不存在的表名,解析器并不会报错,语义检查由分析器负责。

3.1.2 分析器(Analyzer)

分析器对解析器产出的语法解析树(parse tree)进行语义分析,并产出一颗查询树(query tree)

查询树的根节点是parsenode.h中定义的Query数据结构,这个结构包含着对应查询的元数据,比如命令的类型(SELECT/INSERT等),还包括了一些叶子节点,叶子节点由列表或树组成,包含了特定子句相应的数据。

/*
 * Query -
 *	  解析与分析过程会将所有的语句转换为一颗查询树,供重写器与计划器用于进一步的处理。
 *    功能语句(即不可优化的语句)会设置utilityStmt字段,而Query结构本身基本上是空的。
 *	  DECLARE CURSOR 是一个特例:它的形式与SELECT类似,但原始的DeclareCursorStmt会
 *    被放在 utilityStmt 字段中。
 *    计划过程会将查询树转换为一颗计划树,计划树的根节点是一个PlannedStmt结构
 *    执行器不会用到查询树结构
 */
typedef struct Query
{
	NodeTag		type;
	CmdType		commandType;		/* select|insert|update|delete|utility */
	QuerySource querySource;		/* 我来自哪里? */
	uint32		queryId;		    /* 查询标识符 (可由插件配置) */

	bool		canSetTag;		    /* 我设置了命令结果标签吗? */
	Node	   	*utilityStmt;		/* 如果这是一条DECLARE CURSOR或不可优化的语句 */
	int		resultRelation; 	    /* 对增删改语句而言是目标关系的索引; SELECT为0 */
	bool		hasAggs;		    /* 是否在目标列表或having表达式中指定了聚合函数 */
	bool		hasWindowFuncs; 	/* tlist是否包含窗口函数 */
	bool		hasSubLinks;		/* 是否包含子查询SubLink */
	bool		hasDistinctOn;		/* 是否包含来自DISTINCT ON的distinct子句 */
	bool		hasRecursive;		/* 是否制定了WITH RECURSIVE */
	bool		hasModifyingCTE;	/* 是否在WITH子句中包含了INSERT/UPDATE/DELETE */
	bool		hasForUpdate;		/* 是否指定了FOR [KEY] UPDATE/SHARE*/
	bool		hasRowSecurity; 	/* 是否应用了行安全策略 */
	List	   	*cteList;		    /* CTE列表 */
	List	   	*rtable;		    /* 范围表项目列表 */
	FromExpr   	*jointree;		    /* 表连接树 (FROM 与 WHERE 子句) */
	List	   	*targetList;		/* 目标列表 (TargetEntry的列表) */
	List	   	*withCheckOptions;	/* WithCheckOption的列表 */
	OnConflictExpr 	*onConflict; 	/* ON CONFLICT DO [NOTHING | UPDATE] */
	List	   	*returningList;		/* 返回值列表(TargetEntry的列表) */
	List	   	*groupClause;		/* SortGroupClause的列表 */
	List	   	*groupingSets;		/* 如果有,GroupingSet的列表 */
	Node	   	*havingQual;		/* 分组的Having条件列表 */
	List	   	*windowClause;		/* 窗口子句列表 */
	List	   	*distinctClause; 	/* SortGroupClause列表 */
	List	   	*sortClause;		/* SortGroupClause列表 */
	Node	   	*limitOffset;		/* Offset跳过元组数目 (int8 表达式) */
	Node	   	*limitCount;		/* Limit返回元组数目 (int8 表达式) */
	List	   	*rowMarks;          /* RowMarkClause列表 */
	Node	   	*setOperations;		/* 如果是UNION/INTERSECT/EXCEPT的顶层查询,
	                                   则为集合操作列表 */
	List	   	*constraintDeps; 	/* 确认查询语义是否合法时,所依赖约束对象的OID列表 */
} Query;

图3.3 查询树一例

QueyTree

简要介绍一下上图中的查询树:

  • targetlist 是查询结果中**列(Column)**的列表。在本例中该列表包含两列:iddata。如果在输入的查询树中使用了*(星号),那么分析器会将其显式替换为所有具体的列。
  • 范围表rtable是该查询所用到关系的列表。本例中该变量包含了表tbl_a的信息,如该表的表名与oid
  • 连接树jointree存储着FROMWHERE子句的相关信息。
  • 排序子句sortClauseSortGroupClause结构体的列表。

官方文档描述了查询树的细节。

3.1.3 重写器(Rewriter)

PostgreSQL的规则系统正是基于重写器实现的;当需要时,重写器会根据存储在pg_rules中的规则对查询树进行转换。规则系统本身也是一个很有趣的系统,不过本章略去了关于规则系统和重写器的描述,以免内容过于冗长。

视图

在PostgreSQL中,视图是基于规则系统实现的。当使用CREATE VIEW命令定义一个视图时,PostgreSQL就会创建相应的规则,并存储到系统目录中。

假设下面的视图已经被定义,而pg_rule中也存储了相应的规则。

sampledb=# CREATE VIEW employees_list 
sampledb-#   AS SELECT e.id, e.name, d.name AS department 
sampledb-#      FROM employees AS e, departments AS d WHERE e.department_id = d.id;

当执行一个包含该视图的查询,解析器会创建一颗如图3.4(a)所示的语法解析树。

sampledb=# SELECT * FROM employees_list;

在该阶段,重写器会基于pg_rules中存储的视图规则将rangetable节点重写为一颗查询子树,与子查询相对应。

图3.4 重写阶段一例

rewriter

因为PostgreSQL使用这种机制实现视图,直到9.2版本,视图都是不能更新的。虽然9.3版本后可以对视图进行更新,但对视图的更新仍然存在很多限制,具体细节请参考官方文档

3.1.4 计划器与执行器

计划器从重写器获取一颗查询树(query tree),基于查询树生成一颗能被执行器高效执行的(查询)计划树(plan tree)

在PostgreSQL中,计划器是完全基于代价估计(cost-based)的;它不支持基于规则的优化与提示(hint)。计划器是RDBMS中最为复杂的部分,因此本章的后续内容会对计划器做一个概述。

pg_hint_plan

PostgreSQL不支持SQL中的提示(hint),并且永远也不会去支持。如果你想在查询中使用提示,可以考虑使用pg_hint_plan扩展,细节请参考官方站点

与其他RDBMS类似,PostgreSQL中的EXPLAIN命令会显示命令的计划树。下面给出了一个具体的例子。

testdb=# EXPLAIN SELECT * FROM tbl_a WHERE id < 300 ORDER BY data;
                          QUERY PLAN                           
---------------------------------------------------------------
 Sort  (cost=182.34..183.09 rows=300 width=8)
   Sort Key: data
   ->  Seq Scan on tbl_a  (cost=0.00..170.00 rows=300 width=8)
         Filter: (id < 300)
(4 rows)

图3.5展示了结果相应的计划树。

图3.5 一个简单的计划树以及其与EXPLAIN命令的关系

planTree

计划树由许多称为**计划节点(plan node)**的元素组成,这些节点挂在PlannedStmt结构对应的计划树上。这些元素的定义在plannodes.h中,第3.3.3节与第3.5.4.2会解释相关细节。

每个计划节点都包含着执行器进行处理所必需的信息,在单表查询的场景中,执行器会按照从终端节点往根节点的顺序依次处理这些节点。

比如图3.5中的计划树就是一个列表,包含一个排序节点和一个顺序扫描节点;因而执行器会首先对表tbl_a执行顺序扫描,并对获取的结果进行排序。

执行器会通过第8章将介绍的缓冲区管理器来访问数据库集簇的表和索引。当处理一个查询时,执行器会使用预先分配的内存空间,比如temp_bufferswork_mem,必要时还会创建临时文件。

图3.6 执行器,缓冲管理器,临时文件之间的关系

dd

除此之外,当访问元组的时候,PostgreSQL还会使用并发控制机制来维护运行中事务的一致性和隔离性。第五章介绍了并发控制机制。

3.2 单表查询的代价估计

PostgreSQL的查询优化是基于**代价(Cost)**的。代价是一个无量纲的值,它并不是一种绝对的性能指标,但可以作为比较各种操作代价时的相对性能指标。

costsize.c中的函数用于估算各种操作的代价。所有被执行器执行的操作都有着相应的代价函数。例如,函数cost_seqscan()cost_index()分别用于估算顺序扫描和索引扫描的代价。

在PostgreSQL中有三种代价:启动(start-up)运行(run)总和(total)总代价启动代价运行代价的和;因此只有启动代价和运行代价是单独估计的。

  1. 启动代价(start-up):在读取到第一条元组前花费的代价,比如索引扫描节点的启动代价就是读取目标表的索引页,取到第一个元组的代价
  2. 运行代价(run): 获取全部元组的代价
  3. 总代价(total):前两者之和

EXPLAIN命令显示了每个操作的启动代价和总代价,下面是一个简单的例子:

testdb=# EXPLAIN SELECT * FROM tbl;
                       QUERY PLAN                        
---------------------------------------------------------
 Seq Scan on tbl  (cost=0.00..145.00 rows=10000 width=8)
(1 row)

在第4行显示了顺序扫描的相关信息。代价部分包含了两个值:0.00和145.00。在本例中,启动代价和总代价分别为0.00和145.00。

在本节中,我们将详细介绍顺序扫描,索引扫描和排序操作的代价是如何估算的。

在接下来的内容中,我们使用下面这个表及其索引作为例子。

testdb=# CREATE TABLE tbl (id int PRIMARY KEY, data int);
testdb=# CREATE INDEX tbl_data_idx ON tbl (data);
testdb=# INSERT INTO tbl SELECT generate_series(1,10000),generate_series(1,10000);
testdb=# ANALYZE;
testdb=# \d tbl
      Table "public.tbl"
 Column |  Type   | Modifiers 
--------+---------+-----------
 id     | integer | not null
 data   | integer | 
Indexes:
    "tbl_pkey" PRIMARY KEY, btree (id)
    "tbl_data_idx" btree (data)

3.2.1 顺序扫描

顺序扫描的代价是通过函数cost_seqscan()估计的。本节将研究顺序扫描代价是如何估计的,以下面的查询为例:

testdb=# SELECT * FROM tbl WHERE id < 8000;

在顺序扫描中,启动代价等于0,而运行代价由以下公式定义: $$ \begin{align} \verb|run_cost| &= \verb|cpu_run_cost| + \verb|disk_run_cost | \ &= (\verb|cpu_tuple_cost| + \verb|cpu_operator_cost|) × N_{\verb|tuple|} + \verb|seq_page_cost| × N_{\verb|page|}, \end{align} $$ 其中seq_page_costcpu_tuple_costcpu_operator_cost是在postgresql.conf 中配置的参数,默认值分别为1.0,0.01和0.0025。$N_{\verb|tuple|}$ 和$N_{\verb|page|}$ 分别是表中的元组总数与页面总数,这两个值可以使用以下查询获取。

testdb=# SELECT relpages, reltuples FROM pg_class WHERE relname = 'tbl';
 relpages | reltuples 
----------+-----------
       45 |     10000
(1 row)

$$ \begin{equation}\tag{1} N_{\verb|tuple|}=10000 \end{equation} $$

$$ \begin{equation}\tag{2} N_{\verb|page|}=45 \end{equation} $$

因此: $$ \begin{align} \verb|run_cost| &= (0.01 + 0.0025) × 10000 + 1.0 × 45 = 170.0. \end{align} $$

最终: $$ \verb|total_cost| = 0.0 + 170.0 = 170.0 $$

作为验证,下面是该查询的EXPLAIN结果:

testdb=# EXPLAIN SELECT * FROM tbl WHERE id < 8000;
                       QUERY PLAN                       
--------------------------------------------------------
 Seq Scan on tbl  (cost=0.00..170.00 rows=8000 width=8)
   Filter: (id < 8000)
(2 rows)

在第4行中可以看到,启动代价和总代价分别是0.00和170.0,且预计全表扫描返回行数为8000条(元组)。

在第5行显示了一个顺序扫描的过滤器Filter:(id < 8000)。更精确地说,它是一个表级过滤谓词(table level filter predicate)。注意这种类型的过滤器只会在读取所有元组的时候使用,它并不会减少需要扫描的表页面数量。

从优化运行代价的角度来看,PostgreSQL假设所有的物理页都是从存储介质中获取的;即,PostgreSQL不会考虑扫 描的页面是否来自共享缓冲区。

3.2.2 索引扫描

尽管PostgreSQL支持很多索引方法,比如B树,GiSTGINBRIN,不过索引扫描的代价估计都使用一个共用的代价函数:cost_index()

本节将研究索引扫描的代价是如何估计的,以下列查询为例。

testdb=# SELECT id, data FROM tbl WHERE data < 240;

在估计该查询的代价之前,下面的查询能获取$N_{\verb|index|,\verb|page|}$和$N_{\verb|index|,\verb|tuple|}$的值:

testdb=# SELECT relpages, reltuples FROM pg_class WHERE relname = 'tbl_data_idx';
 relpages | reltuples 
----------+-----------
       30 |     10000
(1 row)

$$ \begin{equation}\tag{3} N_{\verb|index|,\verb|tuple|} = 10000 \end{equation} $$

$$ \begin{equation}\tag{4} N_{\verb|index|,\verb|page|} = 30 \end{equation} $$

3.2.2.1 启动代价

索引扫描的启动代价就是读取索引页以访问目标表的第一条元组的代价,由下面的公式定义: $$ \begin{equation} \verb| start-up_cost| = {\mathrm{ceil}(\log_2 (N_{\verb|index|,\verb|tuple|})) + (H_{\verb|index|} + 1) × 50} × \verb|cpu_operator_cost| \end{equation} $$ 其中$H_{\verb|index|}$是索引树的高度。

在本例中,套用公式(3),$N_{\verb|index,tuple|}$是10000;$H_{\verb|index|}$是1;$\verb|cpu_operator_cost|$是0.0025(默认值)。因此 $$ \begin{equation}\tag{5} \verb|start-up_cost| = {\mathrm{ceil}(\log_2(10000)) + (1 + 1) × 50} × 0.0025 = 0.285 \end{equation} $$

3.2.2.2 运行代价

索引扫描的运行代价是表和索引的CPU代价与IO代价之和。 $$ \begin{align} \verb|run_cost| &= (\verb|index_cpu_cost| + \verb|table_cpu_cost|) + (\verb|index_io_cost| + \verb|table_io_cost|). \end{align} $$

如果使用仅索引扫描,则不会估计table_cpu_costtable_io_cost,仅索引扫描将在第七章中介绍。

前三个代价(即index_cpu_costtable_cpu_costindex_io_cost)如下所示:

$$ \begin{align} \verb|index_cpu_cost| &= \verb|Selectivity| × N_{\verb|index|,\verb|tuple|} × (\verb|cpu_index_tuple_cost| + \verb|qual_op_cost|) \ \verb|table_cpu_cost| &= \verb|Selectivity| × N_{\verb|tuple|}× \verb|cpu_tuple_cost| \ \verb|index_io_cost| &= \mathrm{ceil}(\verb|Selectivity| × N_{\verb|index|,\verb|page|}) ×\verb|random_page_cost| \end{align} $$

以上公式中的cpu_index_tuple_costrandom_page_costpostgresql.conf中配置(默认值分别为0.005和4.0)。$\verb|qual_op_cost|$粗略来说就是索引求值的代价,默认值是0.0025,这里不再展开。**选择率(Selectivity)**是一个0到1之间的浮点数,代表查询指定的WHERE子句在索引中搜索范围的比例。举个例子,$(\verb|Selectivity| × N_{\verb|tuple|})$就是需要读取的表元组数量,$(\verb|Selectivity| × N_{\verb|index|,\verb|tuple|})$就是需要读取的索引元组数量,诸如此类。

选择率(Selectivity)

查询谓词的选择率是通过**直方图界值(histogram_bounds)高频值(Most Common Value, MCV)**估计的,这些信息都存储在系统目录pg_statistics中,并可通过pg_stats视图查询。这里通过一个具体的例子来简要介绍选择率的计算方法,细节可以参考官方文档

表中每一列的高频值都在pg_stats视图的most_common_valsmost_common_freqs中成对存储。

  • 高频值(most_common_vals):该列上最常出现的取值列表
  • 高频值频率(most_common_freqs):高频值相应出现频率的列表

下面是一个简单的例子。表countries有两列:一列country存储国家名,一列continent存储该国所属大洲。

testdb=# \d countries
   Table "public.countries"
  Column   | Type | Modifiers 
-----------+------+-----------
 country   | text | 
 continent | text | 
Indexes:
    "continent_idx" btree (continent)

testdb=# SELECT continent, count(*) AS "number of countries", 
testdb-#     (count(*)/(SELECT count(*) FROM countries)::real) AS "number of countries / all countries"
testdb-#       FROM countries GROUP BY continent ORDER BY "number of countries" DESC;
   continent   | number of countries | number of countries / all countries 
---------------+---------------------+-------------------------------------
 Africa        |                  53 |                   0.274611398963731
 Europe        |                  47 |                   0.243523316062176
 Asia          |                  44 |                   0.227979274611399
 North America |                  23 |                   0.119170984455959
 Oceania       |                  14 |                  0.0725388601036269
 South America |                  12 |                  0.0621761658031088
(6 rows)

考虑下面的查询,该查询带有WHERE条件continent = 'Asia'

testdb=# SELECT * FROM countries WHERE continent = 'Asia';

这时候,计划器使用continent列上的高频值来估计索引扫描的代价,列上的most_common_valsmost_common_freqs如下所示:

testdb=# \x
Expanded display is on.
testdb=# SELECT most_common_vals, most_common_freqs FROM pg_stats 
testdb-#                  WHERE tablename = 'countries' AND attname='continent';
-[ RECORD 1 ]-----+-----------------------------------------------------------
most_common_vals  | {Africa,Europe,Asia,"North America",Oceania,"South America"}
most_common_freqs | {0.274611,0.243523,0.227979,0.119171,0.0725389,0.0621762}

most_common_valsAsia值对应的most_common_freqs为0.227979。因此0.227979会在估算中被用作选择率。

如果高频值不可用,就会使用目标列上的直方图界值来估计代价。

  • **直方图值(histogram_bounds)**是一系列值,这些值将列上的取值划分为数量大致相同的若干个组。

下面是一个具体的例子。这是表tbldata列上的直方图界值;

testdb=# SELECT histogram_bounds FROM pg_stats WHERE tablename = 'tbl' AND attname = 'data';
        			     	      histogram_bounds
------------------------------------------------------------------------------
 {1,100,200,300,400,500,600,700,800,900,1000,1100,1200,1300,1400,1500,1600,1700,1800,1900,2000,2100,
2200,2300,2400,2500,2600,2700,2800,2900,3000,3100,3200,3300,3400,3500,3600,3700,3800,3900,4000,4100,
4200,4300,4400,4500,4600,4700,4800,4900,5000,5100,5200,5300,5400,5500,5600,5700,5800,5900,6000,6100,
6200,6300,6400,6500,6600,6700,6800,6900,7000,7100,7200,7300,7400,7500,7600,7700,7800,7900,8000,8100,
8200,8300,8400,8500,8600,8700,8800,8900,9000,9100,9200,9300,9400,9500,9600,9700,9800,9900,10000}
(1 row)

默认情况下,直方图界值会将列上的取值划分入100个桶。图3.7展示了这些桶及其对应的直方图界值。桶从0开始编号,每个桶保存了(大致)相同数量的元组。直方图界值就是相应桶的边界。比如,直方图界值的第0个值是1,意即这是bucket_0中的最小值。第1个值是100,意即bucket_1中的最小值是100,等等。

图3.7 桶和直方图界值

然后本节例子中选择率计算如下所示。假设查询带有WHERE子句data < 240,而值240落在第二个桶中。在本例中可以通过线性插值推算出相应的选择率。因此查询中data列的选择率可以套用下面的公式计算: $$ \verb|Selectivity| = \frac{2+(240-hb[2])/(hb[3]-hb[2])}{100}=\frac{2+(240-200)/(300-200)}{100}=\frac{2+40/100}{100}=0.024 \ (6) $$

因此,根据公式(1),(3),(4)和(6),有 $$ \begin{equation}\tag{7} \verb|index_cpu_cost| = 0.024× 10000 × (0.005+0.0025)=1.8 \end{equation} $$ $$ \begin{equation}\tag{8} \verb|table_cpu_cost| = 0.024 × 10000 × 0.01 = 2.4 \end{equation} $$

$$ \begin{equation}\tag{9} \verb|index_io_cost| = \mathrm{ceil}(0.024 × 30) × 4.0 = 4.0 \end{equation} $$

$\verb|table_io_cost|$ 由下面的公式定义: $$ \begin{equation} \verb|table_io_cost| = \verb|max_io_cost| + \verb|indexCorerelation|^2 × (\verb|min_io_cost|-\verb|max_io_cost|) \end{equation} $$

$\verb|max_io_cost_io_cost|$ 是最差情况下的I/O代价,即,随机扫描所有数据页的代价;这个代价由以下公式定义: $$ \begin{equation} \verb|max_io_cost| = N_{\verb|page|} × \verb|random_page_cost| \end{equation} $$

在本例中,由(2),$N_{\verb|page|}=45$,得 $$ \begin{equation}\tag{10} \verb|max_io_cost| = 45 × 4.0 = 180.0 \end{equation} $$

$\verb|min_io_cost|$是最优情况下的I/O代价,即,顺序扫描选定的数据页;这个代价由以下公式定义: $$ \begin{equation} \verb|min_io_cost| = 1 × \verb|random_page_cost| + (\mathrm{ceil}(\verb|Selectivity| × N_{\verb|page|})-1) × \verb|seq_page_cost| \end{equation} $$ 在本例中, $$ \begin{equation} \tag{11} \verb|min_io_cost| \ = 1 × 4.0 + (\mathrm{ceil}(0.024 × 45)-1) × 1.0 \end{equation} $$

下文详细介绍$\verb|indexCorrelation|$,在本例中, $$ \begin{equation} \tag{12} \verb|indexCorrelation| = 1.0 \end{equation} $$

由(10),(11)和(12),得 $$ \begin{equation} \tag{13} \verb|table_io_cost| = 180.0+1.0^2 × (5.0-180.0)=5.0 \end{equation} $$

综上,由(7),(8),(9)和(13)得 $$ \begin{equation}\tag{14} \verb|run_cost| = (1.8+2.4)+(4.0+5.0)=13.2 \end{equation} $$

索引相关性(index correlation)

索引相关性是列值在物理上的顺序和逻辑上的顺序的统计相关性(引自官方文档)。索引相关性的取值范围从$-1$到$+1$。下面的例子有助于理解索引扫描和索引相关性的关系。

tbl_corr有5个列:两个列为文本类型,三个列为整数类型。这三个整数列保存着从1到12的数字。在物理上表tbl_corr包含三个页,每页有4条元组。每个数字列有一个名如index_col_asc的索引。

testdb=# \d tbl_corr
    Table "public.tbl_corr"
  Column  |  Type   | Modifiers 
----------+---------+-----------
 col      | text    | 
 col_asc  | integer | 
 col_desc | integer | 
 col_rand | integer | 
 data     | text    |
Indexes:
    "tbl_corr_asc_idx" btree (col_asc)
    "tbl_corr_desc_idx" btree (col_desc)
    "tbl_corr_rand_idx" btree (col_rand)
testdb=# SELECT col,col_asc,col_desc,col_rand 
testdb-#                         FROM tbl_corr;
   col    | col_asc | col_desc | col_rand 
----------+---------+----------+----------
 Tuple_1  |       1 |       12 |        3
 Tuple_2  |       2 |       11 |        8
 Tuple_3  |       3 |       10 |        5
 Tuple_4  |       4 |        9 |        9
 Tuple_5  |       5 |        8 |        7
 Tuple_6  |       6 |        7 |        2
 Tuple_7  |       7 |        6 |       10
 Tuple_8  |       8 |        5 |       11
 Tuple_9  |       9 |        4 |        4
 Tuple_10 |      10 |        3 |        1
 Tuple_11 |      11 |        2 |       12
 Tuple_12 |      12 |        1 |        6
(12 rows)

这些列的索引相关性如下:

testdb=# SELECT tablename,attname, correlation FROM pg_stats WHERE tablename = 'tbl_corr';
 tablename | attname  | correlation 
-----------+----------+-------------
 tbl_corr  | col_asc  |           1
 tbl_corr  | col_desc |          -1
 tbl_corr  | col_rand |    0.125874
(3 rows)

当执行下列查询时,由于所有的目标元组都在第一页中,PostgreSQL只会读取第一页,如图3.8(a)所示。

testdb=# SELECT * FROM tbl_corr WHERE col_asc BETWEEN 2 AND 4;

而执行下列查询时则不然,PostgreSQL需要读所有的页,如图3.8(b)所示。

testdb=# SELECT * FROM tbl_corr WHERE col_rand BETWEEN 2 AND 4;

如此可知,索引相关性是一种统计上的相关性。在索引扫描代价估计中,索引相关性体现了索引顺序和物理元组顺序扭曲程度给随机访问性能造成的影响大小。

图3.8 索引相关性

indexcor

3.2.2.3 整体代价

由(3)和(14)可得 $$ \begin{equation}\tag{15} \verb|total_cost| = 0.285 + 13.2 = 13.485 \end{equation} $$

作为确认,上述SELECT查询的EXPLAIN结果如下所示:

testdb=# EXPLAIN SELECT id, data FROM tbl WHERE data < 240;
                                QUERY PLAN                                 
---------------------------------------------------------------------------
 Index Scan using tbl_data_idx on tbl  (cost=0.29..13.49 rows=240 width=8)
   Index Cond: (data < 240)
(2 rows)

在第4行可以看到启动代价和总代价分别是0.29和13.49,预估有240条元组被扫描。

在第5行显示了一个索引条件Index Cond:(data < 240)。更准确地说,这个条件叫做访问谓词(access predicate),它表达了索引扫描的开始条件与结束条件。

根据这篇文章,PostgreSQL中的EXPLAIN命令不会区分访问谓词(access predicate)索引过滤谓词(index filter predicate)。因此当分析EXPLAIN的输出时,即使看到了“IndexCond”,也应当注意一下预估返回行数。

seq_page_costrandom_page_cost

seq_page_costrandom_page_cost的默认值分别为1.0和4.0。这意味着PostgreSQL假设随机扫描比顺序扫描慢4倍;显然,PostgreSQL的默认值是基于HDD(普通硬盘)设置的。

另一方面,近年来SSD得到了广泛的应用,random_page_cost的默认值就显得太大了。使用SSD时如果仍然采用random_page_cost的默认值,则计划器有可能会选择低效的计划。因此当使用SSD时最好将random_page_cost的值设为1.0。

这篇文章报告了使用random_page_cost默认值导致的问题。

3.2.3 排序

排序路径(sort path) 会在排序操作中被使用。排序操作包括ORDER BY,归并连接的预处理操作,以及其他函数。函数cost_sort()用于估计排序操作的代价。

如果能在工作内存中放下所有元组,那么排序操作会选用快速排序算法。否则的话则会创建临时文件,使用文件归并排序算法。

排序路径的启动代价就是对目标表的排序代价,因此代价就是$O(N_{\verb|sort|}× \log_2(N_{\verb|sort|})$,这里$N_{\verb|sort|}$就是待排序的元组数。排序路径的运行代价就是读取已经排好序的元组的代价,因而代价就是$O(N_{sort})$。

本节将研究以下查询排序代价的估计过程。假设该查询只使用工作内存,不使用临时文件。

testdb=# SELECT id, data FROM tbl WHERE data < 240 ORDER BY id;

在本例中,启动代价由以下公式定义: $$ \begin{equation} \verb|start-up_cost| = \verb|C|+ \verb|comparison_cost| × N_{\verb|sort|} × \log_2(N_{\verb|sort|}) \end{equation} $$

这里$C$就是上一次扫描的总代价,即上次索引扫描的总代价;由(15)可得C等于13.485;$N_{\verb|sort|}=240$;$\verb|comparison_cost|$ 定义为$2 × \verb|cpu_operator_cost|$。因此有

$$ \begin{equation} \verb|start-up_cost| = 13.485+(2×0.0025)×240.0×\log_2(240.0)=22.973 \end{equation} $$

运行代价是在内存中读取排好序的元组的代价,即: $$ \begin{equation} \verb|run_cost| = \verb|cpu_operator_cost| × N_{\verb|sort|} = 0.0025 × 240 = 0.6 \end{equation} $$ 综上: $$ \begin{equation} \verb|total_cost|=22.973+0.6=23.573 \end{equation} $$ 作为确认,以上SELECT查询的EXPLAIN命令结果如下:

testdb=# EXPLAIN SELECT id, data FROM tbl WHERE data < 240 ORDER BY id;
                                   QUERY PLAN                                    
---------------------------------------------------------------------------------
 Sort  (cost=22.97..23.57 rows=240 width=8)
   Sort Key: id
   ->  Index Scan using tbl_data_idx on tbl  (cost=0.29..13.49 rows=240 width=8)
         Index Cond: (data < 240)
(4 rows)

在第4行可以看到启动代价和运行代价分别为22.97和23.57。

3.3 创建单表查询的计划树

计划器非常复杂,故本节仅描述最简单的情况,即单表查询的计划树创建过程。更复杂的查询,换而言之即多表查询,其计划树创建过程将在第3.6节中阐述。

PostgreSQL中的计划器会执行三个处理步骤:

  1. 执行预处理
  2. 在所有可能的访问路径中,找出代价最小的访问路径
  3. 按照代价最小的路径,创建计划树

访问路径(access path)是估算代价时的处理单元;比如,顺序扫描,索引扫描,排序以及各种连接操作都有其对应的路径。访问路径只在计划器创建查询计划树的时候使用。最基本的访问路径数据结构就是relation.h中定义的Path结构体。它就相当于是顺序扫描。所有其他的访问路径都基于该结构,下面会介绍细节。

计划器为了处理上述步骤,会在内部创建一个PlannerInfo数据结构。在该数据结构中包含着查询树,查询所涉及关系信息,访问路径等等。

typedef struct PathKey {
    NodeTag type;
    EquivalenceClass *pk_eclass; /* 值是否有序 */
    Oid pk_opfamily;             /* 用于定义顺序的B树操作符族 */
    int pk_strategy;             /* 排序方向(ASC or DESC) */
    bool pk_nulls_first;         /* NULL是否排序在常规值之前? */
} PathKey;

typedef struct Path {
    NodeTag type;
    NodeTag pathtype;          /* 标识 scan/join 方法的标签 */
    RelOptInfo *parent;        /* 路径所基于的关系 */
    PathTarget *pathtarget;    /* Vars/Exprs的列表, 代价, 宽度 */
    ParamPathInfo *param_info; /* 参数化信息, 如果没有则为NULL */
    bool parallel_aware;       /* 涉及到并行相关的逻辑? */
    bool parallel_safe;        /* 是否能作为并行执行计划的一部分? */
    int parallel_workers;      /* 期待的并行工作进程数量; 0表示没有并行 */

    /* 估计路径的尺寸或代价 (更多详情参考costsize.c) */
    double rows;       /* 预估结果元组数目 */
    Cost startup_cost; /* 获取任何元组前需要花费的代价 */
    Cost total_cost;   /* 总代价 (假设获取所有元组所需代价) */
    List *pathkeys;    /* 路径输出的排序顺序 */
    /* pathkeys 是PathKey节点的列表,PathKey定义见上面 */
} Path;

typedef struct PlannerInfo {
    NodeTag type;
    Query *parse;                    /* 被计划的查询 */
    PlannerGlobal *glob;             /* 当前计划器运行时的全局信息 */
    Index query_level;               /* 最外层查询为1 */
    struct PlannerInfo *parent_root; /* 最外层查询为NULL */

    /* plan_params包含着当前计划中的查询层次需要对低层查询暴露的表达式。
     * outer_params包含着PARAM_EXEC参数中的paramId列表,这些参数是外
     * 部查询层次对当前查询层次所暴露的。*/
    List *plan_params; /* PlannerParamItems的列表, 见下 */
    Bitmapset *outer_params;

    /* simple_rel_array 持有着指向“基础关系”与“其他关系”的指针 (详情参考
     * RelOptInfo的注释)。它由rangetable index所索引(因此第0项总是废值)。
     * 当RTE并不与基础关系相对应,譬如连接的RTE,或未引用的视图RTE,或该
     * RelOptInfo还没有产生时,里面的项目可能为NULL。*/
    struct RelOptInfo **simple_rel_array; /* 所有单个关系的RelOptInfos */
    int simple_rel_array_size;            /* 数组分配的大小 */

    /* simple_rte_array 与simple_rel_array 长度相同,且持有指向关联范围表项的指针。
     * 这使得我们能避免执行rt_fetch(), 当需要展开很大的继承集时会很慢。 */
    RangeTblEntry **simple_rte_array; /* rangetable的数组 */

    /* all_baserels是所有查询所涉及基本关系的关系ID列表(但不含“其他关系”的ID)
     * 也就是说,最终连接时,所需构建的关系标识符。该字段是由make_one_rel计算的。
     * 计算发生于计算Paths之前。*/
    Relids all_baserels;

    /* nullable_baserels 是在进行外连接的jointree中那些可空的基础关系的ID集合。
     * 这些关系可能在WHERE子句,SELECT目标列表或其他地方产生空值。该字段由函数
     * deconstruct_jointree负责计算。*/
    Relids nullable_baserels;

    /* join_rel_list是一个列表,在计划过程中连接关系的RelOptInfos都放在这里。
     * 对于比较小的问题,我们只是简单的扫过这个列表来完成查找。但当连接很多关系时,
     * 我们会使用散列表来加速查询。散列表当且仅当join_rel_hash不为空时存在且
     * 有效。注意即使用散列表查找时,我们依然会维护列表,这会简化GEQO的相关问题。*/
    List *join_rel_list;        /* 连接关系的RelOptInfos */
    struct HTAB *join_rel_hash; /* 连接关系的散列表,可选 */

    /* 当使用动态规划进行连接搜索时,join_rel_level[k]是第k层的连接关系RelOptInfos列表。
     * 新的连接关系RelOptInfos会自动添加到join_rel_level[join_cur_level]中,
     * 而join_cur_level为当前层级。如果没用到动态规划,join_rel_level则为空。*/
    List **join_rel_level;    /* 连接关系RelOptInfo的列表 */
    int join_cur_level;       /* 待追加列表的序号 */
    List *init_plans;         /* 查询的初始SubPlans */
    List *cte_plan_ids;       /* 子计划的ID列表,每个CTE对应一个 */
    List *multiexpr_params;   /* MULTIEXPR子查询输出用到的双层嵌套参数列表 */
    List *eq_classes;         /* 活跃的EquivalenceClasses列表 */
    List *canon_pathkeys;     /* "标准" PathKeys 的列表 */
    List *left_join_clauses;  /* RestrictInfos列表,用于左连接子句 */
    List *right_join_clauses; /* RestrictInfos列表,用于右连接子句 */
    List *full_join_clauses;  /* RestrictInfos列表,用于完全连接子句 */
    List *join_info_list;     /* SpecialJoinInfos 的列表 */
    List *append_rel_list;    /* AppendRelInfos 的列表 */
    List *rowMarks;           /* PlanRowMarks 的列表 */
    List *placeholder_list;   /* PlaceHolderInfos 的列表 */
    List *fkey_list;          /* ForeignKeyOptInfos 的列表 */
    List *query_pathkeys;     /* query_planner()期望的pathkeys */
    List *group_pathkeys;     /* groupClause的pathkeys, 如果有的话 */
    List *window_pathkeys;    /* 底部窗口的pathkeys, 如果有的话 */
    List *distinct_pathkeys;  /* distinctClause的pathkeys, 如果有的话 */
    List *sort_pathkeys;      /* sortClause的pathkeys, 如果有的话 */
    List *initial_rels;       /* 我们现在正在尝试连接的RelOptInfos */

    /* 使用fetch_upper_rel()来获取任意特定的上层关系 */
    List *upper_rels[UPPERREL_FINAL + 1]; /* upper-rel RelOptInfos */

    /* grouping_planner针对上层处理过程选择的目标列表 */
    struct PathTarget *upper_targets[UPPERREL_FINAL + 1];

    /* grouping_planner会将最终处理过后的targetlist回传至此。在最终计划最顶层的目标列表中会用到 */
    List *processed_tlist;

    /* create_plan()期间填充的字段,定义于setrefs.c */
    AttrNumber *grouping_map;    /* 针对GroupingFunc的修补 */
    List *minmax_aggs;           /* MinMaxAggInfos列表 */
    MemoryContext planner_cxt;   /* 持有PlannerInfo的上下文 */
    double total_table_pages;    /* 查询涉及到所有表的页面总数 */
    double tuple_fraction;       /* 传递给查询计划器的tuple_fraction */
    double limit_tuples;         /* 传递给查询计划器的limit_tuples */
    bool hasInheritedTarget;     /* 若parse->resultRelation为继承的子关系则为真 */
    bool hasJoinRTEs;            /* 如果任意RTEs为RTE_JOIN类别则为真 */
    bool hasLateralRTEs;         /* 如果任意RTEs被标记为LATERAL则为真 */
    bool hasDeletedRTEs;         /* 如果任意RTEs从连接树中被删除则为真 */
    bool hasHavingQual;          /* 如果havingQual非空则为真 */
    bool hasPseudoConstantQuals; /* 如果任意RestrictInfo包含
    								pseudoconstant = true则为真 */
    bool hasRecursion;           /* 如果计划中包含递归WITH项则为真 */

    /* 当hasRecursion为真时,会使用以下字段: */
    int wt_param_id;                 /* 工作表上PARAM_EXEC的ID */
    struct Path *non_recursive_path; /* 非递归项的路径 */

    /* 这些字段是createplan.c的工作变量 */
    Relids curOuterRels;  /* 当前节点外部的关系 */
    List *curOuterParams; /* 尚未赋值的NestLoopParams */

    /* 可选的join_search_hook私有数据, 例如, GEQO */
    void *join_search_private;
} PlannerInfo;

本节会通过一个具体的例子,来描述如何基于查询树创建计划树。

3.3.1 预处理

在创建计划树之前,计划器对先PlannerInfo中的查询树进行一些预处理。

预处理有很多步骤,本节只讨论和单表查询处理相关的主要步骤。其他预处理操作将在3.6节中描述。

  1. 简化目标列表(target list)LIMIT子句等;

    例如,表达式2+2会被重写为4,这是由clauses.ceval_const_expressions()函数负责的。

  2. 布尔表达式的规范化

    例如,NOT(NOT a)会被重写为a

  3. 压平与/或表达式

    SQL标准中的AND/OR是二元操作符;但在PostgreSQL内部它们是多元操作符。而计划器总是会假设所有的嵌套AND/OR都应当被压平。

    这里有一个具体的例子。考虑这样一个布尔表达式(id = 1) OR (id = 2) OR (id = 3),图3.9(a) 展示了使用二元表达式时的查询树,预处理会将这些二元算子简化压平为一个三元算子,如图3.9(b)所示。

    图3.9. 压平布尔表达式的例子

    扁平化

3.3.2 找出代价最小的访问路径

计划器对所有可能的访问路径进行代价估计,然后选择代价最小的那个。具体来说,计划器会执行以下几个步骤:

  1. 创建一个RelOptInfo数据结构,存储访问路径及其代价。

    RelOptInfo结构体是通过make_one_rel()函数创建的,并存储于PlannerInfo结构体的simple_rel_array字段中,如图3.10所示。在初始状态时RelOptInfo持有着baserestrictinfo变量,如果存在相应索引,还会持有indexlist变量。baserestrictinfo存储着查询的WHERE子句,而indexlist存储着目标表上相关的索引。

    typedef enum RelOptKind
    {
    	RELOPT_BASEREL,
    	RELOPT_JOINREL,
    	RELOPT_OTHER_MEMBER_REL,
    	RELOPT_UPPER_REL,
    	RELOPT_DEADREL
    } RelOptKind;
    
    typedef struct RelOptInfo
    {
    	NodeTag		type;
    	RelOptKind	reloptkind;
    
    	/* 本RelOptInfo包含的所有关系 */
    	Relids		relids;			/* 基本关系的ID集合 (范围表索引) */
    
    	/* 由计划器生成的预估尺寸 */
    	double		rows;			/* 预估结果元组数目 */
    
    	/* 计划器标记位,每个关系一份 */
    	bool		consider_startup;	    /* 保留启动代价最低的路径? */
    	bool		consider_param_startup; /* 同上, 针对参数化路径? */
    	bool		consider_parallel;	    /* 考虑并行路径? */
    
    	/* 扫描当前关系的默认结果目标列表 */
    	struct PathTarget *reltarget;		/* Vars/Exprs, 代价, 宽度的列表 */
    
    	/* 物化相关信息 */
    	List	   *pathlist;			    /* Path 结构体列表 */
    	List	   *ppilist;			    /* pathlist中使用的ParamPathInfos */
    	List	   *partial_pathlist;		/* 部分路径 */
    	struct Path *cheapest_startup_path;
    	struct Path *cheapest_total_path;
    	struct Path *cheapest_unique_path;
    	List	    *cheapest_parameterized_paths;
    
    	/* 基础关系与连接关系都需要的 参数化信息 */
    	/* (参见 lateral_vars 与 lateral_referencers) */
    	Relids		direct_lateral_relids;	/* 直接以LATERAL方式引用的关系 */
    	Relids		lateral_relids; 	    
    
    	/* 关于基础关系的信息 (连接关系不会设置这些字段!) */
    	Index		relid;
    	Oid		    reltablespace;	    /* 表空间 */
    	RTEKind		rtekind;		    /* RELATION, SUBQUERY, 或 FUNCTION */
    	AttrNumber	min_attr;		    /* 关系属性的最小值 (通常<0) */
    	AttrNumber	max_attr;		    /* 关系属性的最大值 */
    	Relids	   	*attr_needed;		/* 被索引的数组 [min_attr .. max_attr] */
    	int32	   	*attr_widths;	   	/* 被索引的数组 [min_attr .. max_attr] */
    	List	   	*lateral_vars;	   	/* 关系所引用的LATERAL Vars 与 PHVs */
    	Relids		lateral_referencers;/* 侧面引用本表的关系 */
    	List	   	*indexlist;		    /* IndexOptInfo列表 */
    	BlockNumber pages;			    /* 来自pg_class的页面数估计值 */
    	double		tuples;
    	double		allvisfrac;
    	PlannerInfo *subroot;		    /* 如有子查询 */
    	List	   	*subplan_params; 	/* 如有子查询 */
    	int		    rel_parallel_workers;	/* 期望的并行工作进程数量 */
    
    	/* 有关外部表与外部表连接的相关信息 */
    	Oid			serverid;		/* 外部表与外部表连接相应的服务器ID */
    	Oid			userid;			/* 用于检查访问权限的用户标识 */
    	bool		useridiscurrent;/* 当前用户是否能合法进行JOIN */
    	struct FdwRoutine *fdwroutine;
    	void	   	*fdw_private;
    
    	/* 被各种扫描与连接所使用 */
    	List	   	*baserestrictinfo;	/* RestrictInfo结构体列表 (如果存在基础关系) */
    	QualCost	baserestrictcost;	/* 求值上述限制条件的代价 */
    	List	   	*joininfo;		    /* RestrictInfo 结构体列表,涉及到本表的连接会用到 */
    	bool		has_eclass_joins;	/* T 意味着joininfo不完整 */
    } RelOptInfo;
    
  2. 估计所有可能访问路径的代价,并将访问路径添加至RelOptInfo结构中。

    这一处理过程的细节如下:

    1. 创建一条路径,估计该路径中顺序扫描的代价,并将其写入路径中。将该路径添加到RelOptInfo结构的pathlist变量中。
    2. 如果目标表上存在相关的索引,则为每个索引创建相应的索引访问路径。估计所有索引扫描的代价,并将代价写入相应路径中。然后将索引访问路径添加到pathlist变量中。
    3. 如果可以进行位图扫描,则创建一条位图扫描访问路径。估计所有的位图扫描的代价,并将代价写入到路径中。然后将位图扫描路径添加到pathlist变量中。
  3. RelOptInfopathlist中,找出代价最小的访问路径。

  4. 如有必要,估计LIMITORDER BYAGGREGATE操作的代价。

为了更加清晰的理解计划器的执行过程,下面给出了两个具体的例子。

3.3.2.1 例1

首先来研究一个不带索引的简单单表查询;该查询同时包含WHEREORDER BY子句。

testdb=# \d tbl_1
     Table "public.tbl_1"
 Column |  Type   | Modifiers 
--------+---------+-----------
 id     | integer | 
 data   | integer | 

testdb=# SELECT * FROM tbl_1 WHERE id < 300 ORDER BY data;

图3.10和图3.11展示了本例中计划器的处理过程。

图3.10 如何得到例1中代价最小的路径

  1. 创建一个RelOptInfo结构,将其保存在PlannerInfo结构的simple_rel_array字段中。

  2. RelOptInfo结构的baserestrictinfo字段中,添加一条WHERE子句。

    WHERE子句id<300会经由initsplan.c中定义的distribute_restrictinfo_to_rels()函数,添加至列表变量baserestrictinfo中。另外由于目标表上没有相关索引,RelOptInfoindexlist字段为空。

  3. 为了满足排序要求,planner.c中的standard_qp_callback()函数会在PlannerInfosor_pathkeys字段中添加一个pathkey

    Pathkey是表示路径排序顺序的数据结构。本例因为查询包含一条ORDER BY子句,且该子句中的列为data,故data会被包装为pathkey,放入列表变量sort_pathkeys中。

  4. 创建一个Path结构,并使用cost_seqscan函数估计顺序扫描的代价,并将代价写入Path中。然后使用pathnode.c中定义的add_path()函数,将该路径添加至RelOptInfo中。

    如之前所提到过的,Path中同时包含启动代价和总代价,都是由cost_seqscan函数所估计的。

在本例中,因为目标表上没有索引,计划器只估计了顺序扫描的代价,因此最小代价是自动确定的。

图3.11 如何得到例1中代价最小的路径(接图3.10)

  1. 创建一个新的RelOptInfo结构,用于处理ORDER BY子句。

    注意新的RelOptInfo没有baserestrictinfo字段,该信息已经被WHERE子句所持有。

  2. 创建一个排序路径,并添加到新的RelOptInfo中;然后让SortPathsubpath字段指向顺序扫描的路径。

    typedef struct SortPath
    {
        Path	path;
        Path	*subpath;		/* 代表输入来源的子路径 */
    } SortPath;
    

    SortPath结构包含两个Path结构:pathsubpathpath中存储了排序算子本身的相关信息,而subpath则指向之前得到的代价最小的路径。

    注意顺序扫描路径中parent字段,该字段指向之前的RelOptInfo结构体(也就是在baserestrictinfo中存储着WHERE子句的那个RelOptInfo)。因此在下一步创建计划树的过程中,尽管新的RelOptInfo结构并未包含baserestrictinfo,但计划器可以创建一个包含Filter的顺序扫描节点,将WHERE子句作为过滤条件。

这里已经获得了代价最小的访问路径,然后就可以基于此生成一颗计划树。3.3.3节描述了相关的细节。

3.3.2.2 例2

下面我们将研究另一个单表查询的例子,这一次表上有两个索引,而查询带有一个WHERE子句。

testdb=# \d tbl_2
     Table "public.tbl_2"
 Column |  Type   | Modifiers 
--------+---------+-----------
 id     | integer | not null
 data   | integer | 
Indexes:
    "tbl_2_pkey" PRIMARY KEY, btree (id)
    "tbl_2_data_idx" btree (data)

testdb=# SELECT * FROM tbl_2 WHERE id < 240;

图3.12到3.14展示了本例中计划器的处理过程。

  1. 创建一个RelOptInfo结构体

  2. baserestrictinfo中添加一个WHERE子句;并将目标表上的索引(们)添加到indexlist中。

    在本例中,WHERE子句'id <240'会被添加至baserestrictinfo中,而两个索引:tbl_2_pkeytbl_2_data_idx会被添加至RelOptInfo的列表变量indexlist中。

  3. 创建一条路径,估计其顺序扫描代价,并添加到RelOptInfopathlist中。

图3.12 如何得到例2中代价最小的路径

typedef struct IndexPath
{
	Path			path;
	IndexOptInfo 	*indexinfo;
	List	   		*indexclauses;
	List	   		*indexquals;
	List	   		*indexqualcols;
	List	   		*indexorderbys;
	List	   		*indexorderbycols;
	ScanDirection 	indexscandir;
	Cost			indextotalcost;
	Selectivity 	indexselectivity;
} IndexPath;

/*
 * IndexOptInfo
 *      用于计划/优化的信息,每个索引对应一个。
 *
 *		indexkeys[], indexcollations[], opfamily[], 以及 opcintype[]
 *		每个字段都有 ncolumns 个项.
 *
 *		sortopfamily[], reverse_sort[], 以及 nulls_first[] 类似,也都有
 *		ncolumns 个项, 当且仅当该索引是有序的,否则这些指针都为空。
 *
 *		indexkeys[] 数组中的零值表示该索引列是一个表达式,而每个这种列在indexprs
 *      中都会有一个对应的元素。
 *
 *      对于有序索引,reverse_sort[] 以及 nulls_first[] 描述了正向索引扫描时
 *      索引的排序顺序。当进行反向索引扫描时,就会产生相反的顺序。
 *
 *		indexprs 与 indpred 会通过prepqual.c 中的 eval_const_expressions() 
 *      用于简单地与WHERE子句匹配,indpred使用简单的合取范式。
 *
 *		indextlist 是 TargetEntry的列表,标识着索引建在哪些列上。对于简单的列,
 *      它会提供基本关系中与之对应的Var,对于表达式列,它还会指向indexprs对应元素。
 *      相对应的Var。
 *
 *		当IndexOptInfo创建时,这里大多数字段都会被填充 (plancat.c),但indrestrictinfo 
 *      与predOK会在稍后通过check_index_predicates()设置。
 */
typedef struct IndexOptInfo
{
	NodeTag		type;
	Oid		    indexoid;		/* 索引关系的OID */
	Oid		    reltablespace;	/* 索引所属的表空间 (不是表) */
	RelOptInfo 	*rel;			/* 索引对应的表,反向链接 */

	/* 索引尺寸的统计 (来自pg_class和其他地方) */
	BlockNumber pages;			/* 索引中的磁盘页面数 */
	double		tuples;			/* 索引中的元组数量 */
	int		    tree_height;	/* 索引树的高度,未知则为 -1  */

	/* 索引描述符信息 */
	int		    ncolumns;		/* 索引中列的数量 */
	int		    *indexkeys;		/* 索引中列的序号,或者0 */
	Oid		    *indexcollations;	/* 索引列上排序规则的OID */
	Oid		    *opfamily;		/* 列上运算符族的OID */
	Oid		    *opcintype;		/* 运算符族输入数据类型的OID */
	Oid		    *sortopfamily;	/* 如果这些列有序,B树算子族的OID */
	bool	   	*reverse_sort;	/* 排序顺序是反向降序的吗? */
	bool	   	*nulls_first;	/* 排序顺序中,空值是排在最前面的吗? */
	bool	   	*canreturn;		/* 在仅索引扫描中,哪些索引列可以被返回? */
	Oid		    relam;			/* 访问方法的OID (在 pg_am 中) */

	List	   	*indexprs;		/* 非平凡的索引列,即表达式 */
	List	   	*indpred;		/* 如果是部分索引,则为谓词,否则为空 */

	List	   	*indextlist;	/* 表示索引列的目标列表 */

	List	   	*indrestrictinfo;	/* 父关系的baserestrictinfo列表 */

	bool		predOK;			    /* 如果查询与索引谓词匹配则为真 */
	bool		unique;			    /* 唯一索引则为真 */
	bool		immediate;		    /* 唯一约束是否是立即强制实施的? */
	bool		hypothetical;		/* 如果索引并非真实存在则为真。 */

	/* 剩下这些字段是从索引访问方法的API结构里复制过来的 */
	bool		amcanorderbyop;     /* 访问方法是否支持按算子结果排序? */
	bool		amoptionalkey;		/* 查询是否可以忽略第一列中的键? */
	bool		amsearcharray;		/* 访问方法是否能处理ScalarArrayOpExpr限定条件? */
	bool		amsearchnulls;		/* 访问方法是否能搜索空项或非空项? */
	bool		amhasgettuple;		/* 访问方法是否有amgettuple接口? */
	bool		amhasgetbitmap; 	/* 访问方法是否有amgetbitmap接口? */
	/* 相比include amapi.h,我们直接在这里用这种方式声明 amcostestimate  */
	void		(*amcostestimate) ();	/* 访问方法的代价估计器 */
} IndexOptInfo;
  1. 创建一个IndexPath,估计索引扫描的代价,并通过add_path()函数将IndexPath添加到RelOptInfopathlist中。

    在本例中有两个索引:tbl_2_pkeytbl_2_data_index,这些索引会按先后顺序依次处理。

    一条针对tbl_2_pkeyIndexPath会先被创建出来,并进行启动代价与总代价的评估。在本例中,tbl_2_pkeyid列上的索引,而WHERE子句也包含该id列;因此WHERE子句会被存储在IndexPathindexclauses字段中。

  2. 创建另一个IndexPath,估计另一种索引扫描的代价,并将该IndexPath添加到RelOptInfopathlist中。

    接下来,与tbl_2_data_idx相应的IndexPath会被创建出来,并进行代价估计。本例中tbl_2_data_idx并没有相关的WHERE子句;因此其indexclauses为空。

注意add_path()函数并不总是真的会将路径添加到路径列表中。这一操作相当复杂,故这里就省去了具体描述。详细细节可以参考add_path()函数的注释。

图3.13 如何得到例2中代价最小的路径(接图3.12)

  1. 创建一个新的RelOptInfo结构

  2. 将代价最小的路径,添加到新RelOptInfopathlist中。

    本例中代价最小的路径是使用tbl_2_pkey的索引路径;故将该路径添加到新的RelOptInfo中。

图3.14 如何得到例2中代价最小的路径(接图3.13)

3.3.3 创建计划树

在最后一步中,计划器按照代价最小的路径生成一颗计划树。 

计划树的根节点是定义在plannodes.h中的PlannedStmt结构,包含19个字段,其中有4个代表性字段:

  • **commandType**存储操作的类型,诸如SELECTUPDATEINSERT
  • **rtable**存储范围表的列表(RangeTblEntry的列表)。
  • **relationOids**存储与查询相关表的oid
  • **plantree**存储着一颗由计划节点组成的计划树,每个计划节点对应着一种特定操作,诸如顺序扫描,排序和索引扫描。
/* ----------------
 *		PlannedStmt 节点
 * 计划器的输出是一颗计划树,PlannedStmt是计划树的根节点。
 * PlannedStmt存储着执行器所需的“一次性”信息。
 * ----------------*/
typedef struct PlannedStmt
{
	NodeTag		type;
	CmdType		commandType;		/* 增|删|改|查 */
	uint32		queryId;			/* 查询标识符 (复制自Query) */
	bool		hasReturning;		/* 增|删|改是否带有RETURNING? */
	bool		hasModifyingCTE;	/* WITH子句中是否出现了增|删|改? */
	bool		canSetTag;			/* 我是否设置了命令结果标记? */
	bool		transientPlan;		/* 当TransactionXmin变化时重新进行计划? */
	bool		dependsOnRole;		/* 执行计划是否特定于当前的角色? */
	bool		parallelModeNeeded;	/* 需要并行模式才能执行? */
	struct Plan *planTree;			/* 计划节点树 */
	List	   	*rtable;			/* RangeTblEntry节点的列表 */
	
    /* 目标关系上用于增|删|改的范围表索引 */
	List	   	*resultRelations;   /* RT索引的整数列表, 或NIL */
	Node	   	*utilityStmt;		/* 如为DECLARE CURSOR则非空 */
	List	   	*subplans;			/* SubPlan表达式的计划树 expressions */
	Bitmapset  	*rewindPlanIDs;		/* 需要REWIND的子计划的索引序号 */
	List	   	*rowMarks;			/* PlanRowMark列表 */
	List	   	*relationOids;		/* 计划所依赖的关系OID列表 */
	List	   	*invalItems;		/* 其他依赖,诸如PlanInvalItems */
	int			nParamExec;			/* 使用的PARAM_EXEC参数数量 */
} PlannedStmt;

 如上所述,计划树包含各式各样的计划节点。PlanNode是所有计划节点的基类,其他计划节点都会包含PlanNode结构。比如顺序扫描节点SeqScanNode,包含一个PlanNode和一个整型变量scanrelidPlanNode包含14个字段。下面是7个代表性字段:

  • startup_costtotal_cost是该节点对应操作的预估代价。
  • rows是计划器预计扫描的行数。
  • targetlist保存了该查询树中目标项的列表。
  • qual储存了限定条件的列表。
  • lefttreerighttree用于添加子节点。
/* ----------------
 * 计划节点(Plan Node)
 *
 * 所有的计划节点都"派生"自Plan结构,将其作为自己的第一个字段。这样确保了当其强制转换为Plan
 * 结构时所有东西都能正常工作。(当作为通用参数传入执行器时,节点指针会很频繁地转换为Plan*)
 *
 * 我们从来不会真的去实例化任何Plan节点,它只是所有Plan类型节点的公共抽象父类。
 * ----------------
 */
typedef struct Plan
{
	NodeTag		type;
	/* 计划的预估执行开销 ( 详情见 costsize.c )	 */
	Cost		startup_cost;	/* 获取第一条元组前的代价 */
	Cost		total_cost;		/* 获取所有元组的代价 */

	/* 计划器对该计划步骤返回结果大小的估计 */
	double		plan_rows;		/* 计划预期产出的行数 */
	int			plan_width;		/* 以字节计的行宽 */

	/* 并行查询所需的信息 */
	bool		parallel_aware; /* 是否涉及到并行逻辑? */

	/* 所有计划类型的公有结构化数据 */
	int			plan_node_id;	/* 在整个计划树范围内唯一的标识 */
	List	   	*targetlist;	/* 该节点需要计算的目标列表 */
	List	   	*qual;			/* 隐式合取化处理的 限制条件 列表 */
	struct Plan *lefttree;		/* 输入的查询树 */
	struct Plan *righttree;
	List	   	*initPlan;	/* Init Plan 节点 (无关子查询表达式) */
	/* “参数变化驱动”的重扫描 相关的管理信息
	 * extParam包含着所有外部PARAM_EXEC参数的参数ID列表,这些参数会影响当前计划节点
     * 及其子节点。这里不包括该节点initPlans时setParam的相关参数,但会包括其extParams
     * 
     * allParam包含了所有extParam的参数ID列表,以及影响当前节点的参数ID。(即,
     * 在initPlans中setParams的参数)。注意这里包含了*所有*会影响本节点的PARAM_EXEC参数
	 */
	Bitmapset	*extParam;
	Bitmapset  	*allParam;
} Plan;

/* ------------
 * 扫描节点(Scan nodes)
 * ----------- */
typedef unsigned int Index;

typedef struct Scan
{
	Plan		plan;
	Index		scanrelid;		/* relid 是访问范围表的索引 */
} Scan;

/* ----------------
 *	顺序扫描节点
 * ---------------- */
typedef Scan SeqScan;

下面是两颗计划树,分别与前一小节中的两个例子对应。

3.3.3.1 例1

第一个例子是3.3.2.1节例1对应的计划树。图3.11所示的代价最小的路径,是由一个排序路径和一个顺序扫描路径组合而成。根路径是排序路径,而其子路径为顺序扫描路径。尽管这里忽略了大量细节,但是从代价最小的路径中生成计划树的过程是显而易见的。在本例中,一个 SortNode被添加到PlannedStmt结构中,而SortNode的左子树上则挂载了一个SeqScanNode,如图3.15(a)所示。

SortNode中,左子树lefttree指向SeqScanNode

SeqScanNode中,qual保存了WHERE子句:'id<300'

typedef struct Sort
{
	Plan		plan;
	int			numCols;			/* 排序键 列的数目 */
	AttrNumber 	*sortColIdx;		/* 它们在目标列表中的位置序号 */
	Oid			*sortOperators;		/* 排序所赖运算符的OID  */
	Oid			*collations;		/* collation的OID  */
	bool	   	*nullsFirst;		/* NULLS FIRST/LAST 方向 */
} Sort;

图3.15. 计划树的例子

3.3.3.2 例2

第二个例子是3.3.2.2节例2对应的计划树。其代价最小的路径为索引扫描路径,如图3.14所示。因此计划树由单个IndexScanNode独立组成,如图3.15(b)所示。

在本例中,WHERE子句id < 240是一个访问谓词,它储存在IndexScanNodeindexqual字段中。

/* 索引扫描节点 */
typedef struct Scan
{
    Plan          plan;
    Index         scanrelid;         /* relid 是范围表上的索引ID */
} Scan;

typedef struct IndexScan
{
    Scan          scan;
    Oid           indexid;            /* 待扫描的索引OID */
    List          *indexqual;         /* 索引限定条件的列表 (通常是OpExprs) */
    List          *indexqualorig;     /* 同上,但使用原始形式 */
    List          *indexorderby;      /* 索引的ORDER BY表达式 */
    List          *indexorderbyorig;  /* 同上,但使用原始形式 */
    List          *indexorderbyops;   /* ORDER BY表达式用到的排序运算符的OID */
    ScanDirection indexorderdir;      /* 正序扫描还是逆序扫描,或者根本不在乎 */
} IndexScan;

3.4 执行器如何工作

在单表查询的例子中,执行器从计划树中取出计划节点,按照自底向上的顺序进行处理,并调用节点相应的处理函数。

每个计划节点都有相应的函数,用于执行节点对应的操作。这些函数在src/backend/executor目录中。例如,执行顺序扫描的的函数(SeqScan)定义于nodeSeqscan.c中;执行索引扫描的函数(IndexScanNode)定义在nodeIndexScan.c中;SortNode节点对应的排序函数定义在nodeSort.c中,诸如此类。

当然,理解执行器如何工作的最好方式,就是阅读EXPLAIN命令的输出。因为PostgreSQL的EXPLAIN命令几乎就是照着计划树输出的。下面以3.3.3节的例1为例。

testdb=# EXPLAIN SELECT * FROM tbl_1 WHERE id < 300 ORDER BY data;
                          QUERY PLAN                           
---------------------------------------------------------------
 Sort  (cost=182.34..183.09 rows=300 width=8)
   Sort Key: data
   ->  Seq Scan on tbl_1  (cost=0.00..170.00 rows=300 width=8)
         Filter: (id < 300)
(4 rows)

我们可以自底向上阅读EXPLAIN的结果,来看一看执行器是如何工作的。

第6行:首先,执行器通过nodeSeqscan.c中定义的函数执行顺序扫描操作。

第4行:然后,执行器通过nodeSort.c中定义的函数,对顺序扫描的结果进行排序。

临时文件

执行器在处理查询时会使用工作内存(work_mem)和临时缓冲区(temp_buffers),两者都于内存中分配。如果查询无法在内存中完成,就会用到临时文件。

使用带有Analyze选项的EXPLAIN,待解释的命令会真正执行,并显示实际结果行数,实际执行时间和实际内存用量。下面是一个具体的例子:

testdb=# EXPLAIN ANALYZE SELECT id, data FROM tbl_25m ORDER BY id;
                          QUERY PLAN                                                        
----------------------------------------------------------------------
 Sort  (cost=3944070.01..3945895.01 rows=730000 width=4104) (actual time=885.648..1033.746 rows=730000 loops=1)
   Sort Key: id
   Sort Method: external sort  Disk: 10000kB
   ->  Seq Scan on tbl_25m  (cost=0.00..10531.00 rows=730000 width=4104) (actual time=0.024..102.548 rows=730000 loops=1)
 Planning time: 1.548 ms
 Execution time: 1109.571 ms
(6 rows)

在第6行,EXPLAIN命令显示出执行器使用了10000KB的临时文件。

临时文件会被临时创建于base/pg_tmp子目录中,并遵循如下命名规则

{"pgsql_tmp"} + {创建本文件的Postgres进程PID} . {从0开始的序列号}

比如,临时文件pgsql_tmp8903.5pid8903postgres进程创建的第6个临时文件

3.5 连接

PostgreSQL 中支持三种**连接(JOIN)**操作:嵌套循环连接(Nested Loop Join)归并连接(Merge Join)散列连接(Hash Join)。在PostgreSQL中,嵌套循环连接与归并连接有几种变体。

在下文中,我们会假设读者已经对这三种操作的基本行为有了解。如果读者对这些概念不熟悉,可以参阅[1, 2]。PostgreSQL支持一种针对数据倾斜的混合散列连接(hybrid hash join),关于这方面的资料不多,因此这里会详细描述该操作。

需要注意的是,这三种**连接方法(join method)**都支持PostgreSQL中所有的连接操作,诸如INNER JOINLEFT/RIGHT OUTER JOINFULL OUTER JOIN等;但是为了简单起见,这里只关注NATURAL INNER JOIN

3.5.1 嵌套循环连接(Nested Loop Join)

嵌套循环连接是最为基础的连接操作,任何**连接条件(join condition)**都可以使用这种连接方式。PostgreSQL支持嵌套循环连接及其五种变体。

3.5.1.1 嵌套循环连接

嵌套循环连接无需任何启动代价,因此: $$ \verb|start-up_cost| = 0 $$ 运行代价与内外表尺寸的乘积成比例;即$\verb|runcost|$是$O(N_{\verb|outer|}× N_{\verb|inner|})$,这里$N_{\verb|outer|}$和$N_{\verb|inner|}$分别是外表和内表的元组条数。更准确的说,$\verb|run_cost|$的定义如下: $$ \begin{equation} \verb|run_cost|=(\verb|cpu_operator_cost|+ \verb|cpu_tuple_cost|)× N_{\verb|outer|}× N_{\verb|inner|} + C_{\verb|inner|}× N_{\verb|outer|}+C_{\verb|outer|} \end{equation} $$ 这里$C_{\verb|outer|}$和$C_{\verb|inner|}$分别是内表和外表顺序扫描的代价;

图3.16 嵌套循环连接

嵌套循环连接的代价总是会被估计,但实际中很少会使用这种连接操作,因为它有几种更高效的变体,下面将会讲到。

3.5.1.2 物化嵌套循环连接

在上面描述的嵌套循环连接中,每当读取一条外表中的元组时,都需要扫描内表中的所有元组。为每条外表元组对内表做全表扫描,这一过程代价高昂,PostgreSQL支持一种物化嵌套循环连接(materialized nested loop join) ,可以减少内表全表扫描的代价。

在运行嵌套循环连接之前,执行器会使用**临时元组存储(temporary tuple storage)**模块对内表进行一次扫描,将内表元组加载到工作内存或临时文件中。在处理内表元组时,临时元组存储比缓冲区管理器更为高效,特别是当所有的元组都能放入工作内存中时。

图 3.17说明了物化嵌套循环连接的处理过程。扫描物化元组在内部被称为重扫描(rescan)

图3.17 物化嵌套循环连接

临时元组存储

PostgreSQL内部提供了临时元组存储的模块,可用于各种操作:物化表,创建混合散列连接的批次,等等。该模块包含一系列函数,都在tuplestore.c中。这些函数用于从工作内存或临时文件读写元组。使用工作内存还是临时文件取决于待存储元组的总数。

下面给出一个具体的例子,并研究一下执行器是如何处理物化嵌套循环连接的计划树并估计其代价的。

testdb=# EXPLAIN SELECT * FROM tbl_a AS a, tbl_b AS b WHERE a.id = b.id;
                              QUERY PLAN                               
-----------------------------------------------------------------------
 Nested Loop  (cost=0.00..750230.50 rows=5000 width=16)
   Join Filter: (a.id = b.id)
   ->  Seq Scan on tbl_a a  (cost=0.00..145.00 rows=10000 width=8)
   ->  Materialize  (cost=0.00..98.00 rows=5000 width=8)
         ->  Seq Scan on tbl_b b  (cost=0.00..73.00 rows=5000 width=8)
(5 rows)

上面显示了执行器要进行的操作,执行器对这些计划节点的处理过程如下:

第7行:执行器使用顺序扫描,物化内部表tbl_b

第4行:执行器执行嵌套循环连接操作,外表是tbl_a,内表是物化的tbl_b

下面来估算“物化”操作(第7行)与“嵌套循环”(第4行)的代价。假设物化的内部表元组都在工作内存中。

物化(Materialize):

物化操作没有启动代价;因此, $$ \begin{equation} \verb|start-up_cost| = 0 \end{equation} $$ 其运行代价定义如下: $$ \verb|run_cost| = 2 × \verb|cpu_operator_cost| × N_{\verb|inner|}; $$ 因此: $$ \verb|run_cost|=2× 0.0025× 5000=25.0 $$ 此外, $$ \verb|total_cost| = (\verb|start-up_cost|+ \verb|total_cost_of_seq_scan|)+ \verb|run_cost| $$ 因此, $$ \verb|total_cost| = (0.0+73.0)+25.0=98.0 $$ (物化)嵌套循环:

嵌套循环没有启动代价,因此: $$ \verb|start-up_cost|=0 $$ 在估计运行代价之前,先来看一下重扫描的代价,重扫描的代价定义如下: $$ \verb|rescan_cost| = \verb|cpu_operator_cost| × N_{\verb|inner|} $$ 这本例中: $$ \verb|rescan_cost| = (0.0025)× 5000=12.5 $$ 运行代价由以下公式定义: $$ \verb|run_cost| =(\verb|cpu_operator_cost| + \verb|cpu_tuple_cost|)× N_{\verb|inner|}× N_{\verb|outer|} \

  • \verb|recan_cost|× (N_{\verb|outer|}-1) + C^{\verb|total|}{\verb|outer|,\verb|seqscan|} + C^{\verb|total|}{\verb|materialize|}, $$ 这里 $C^{\verb|total|}{\verb|outer|,\verb|seqscan|}$代表外部表的全部扫描代价,$C^{\verb|total|}{\verb|materialize|}$代表物化代价;因此 $$ \verb|run_cost| = ( 0.0025 + 0.01 ) × 5000 × 10000 + 12.5 ×(10000−1)+145.0+98.0=750230.5 $$

3.5.1.3 索引嵌套循环连接

如果内表上有索引,且该索引能用于搜索满足连接条件的元组。那么计划器在为外表的每条元组搜索内表中的匹配元组时,会考虑使用索引进行直接搜索,以替代顺序扫描。这种变体叫做索引嵌套循环连接(indexed nested loop join),如图3.18所示。尽管这种变体叫做索引"嵌套循环连接",但该算法基本上只需要在在外表上循环一次,因此连接操作执行起来相当高效。

图3.18 索引嵌套循环连接

下面是索引嵌套循环连接的一个具体例子。

testdb=# EXPLAIN SELECT * FROM tbl_c AS c, tbl_b AS b WHERE c.id = b.id;
                                   QUERY PLAN                                   
--------------------------------------------------------------------------------
 Nested Loop  (cost=0.29..1935.50 rows=5000 width=16)
   ->  Seq Scan on tbl_b b (cost=0.00..73.00 rows=5000 width=8)
   ->  Index Scan using tbl_c_pkey on tbl_c c  (cost=0.29..0.36 rows=1 width=8)
         Index Cond: (id = b.id)
(4 rows)

第6行展示了访问内表中元组的代价。即在内表中查找满足第七行连接条件(id = b.id)的元组的代价。

在第7行的索引条件(id = b.id)中,b.id是连接条件中的外表属性的值。每当顺序扫描外表取回一条元组时,就会依第6行所示的索引搜索路径,查找内表中需要与之连接的元组。换而言之,外表元组的值作为参数传入内表的索引扫描中,索引扫描路径会查找满足连接条件的内表元组。这种索引路径被称为参数化(索引)路径(parameterized (index) path),细节见PostgreSQ源码:backend/optimizer/README

该嵌套循环连接的启动代价,等于第6行中索引扫描的代价,因此: $$ \verb|start-up_cost| = 0.285 $$ 索引嵌套循环扫描的总代价由下列公式所定义: $$ \verb|total_cost|= (\verb|cpu_tuple_cost| + C^{\verb|total|}{\verb|inner,parameterized|} )× N{\verb|outer|}+C^{\verb|run|}{\verb|outer,seqscan|} $$ 这里$C^{\verb|total|}{\verb|inner,parameterized|}$是参数化内表索引扫描的整体代价,

在本例中: $$ \verb|total_cost|=(0.01+0.3625)× 5000 + 73.0 = 1935.5 $$ 而运行代价为: $$ \verb|run_cost| = 1935.5-0.285=1935.215 $$ 如上所示,索引嵌套扫描的整体代价是$O(N_{\verb|outer|})$。

3.5.1.4 其他变体

如果在外表上存在一个与连接条件相关的索引,那么在外表上也可以以索引扫描替代顺序扫描。特别是,当WHERE子句中的访问谓词可以使用该索引时,能缩小外表上的搜索范围,嵌套循环连接的代价可能会急剧减少。

当使用外表索引扫描时,PostgreSQL支持三种嵌套循环连接的变体,如图3.19所示。

图3.19 嵌套循环连接的三种变体,使用外表索引扫描

out

这些连接的EXPLAIN结果如下:

  1. 使用外表索引扫描的嵌套循环连接

    testdb=# SET enable_hashjoin TO off;
    SET
    testdb=# SET enable_mergejoin TO off;
    SET
    testdb=# EXPLAIN SELECT * FROM tbl_c AS c, tbl_b AS b WHERE c.id = b.id AND c.id = 500;
                                       QUERY PLAN                                   
    -------------------------------------------------------------------------------
     Nested Loop  (cost=0.29..93.81 rows=1 width=16)
       ->  Index Scan using tbl_c_pkey on tbl_c c  (cost=0.29..8.30 rows=1 width=8)
             Index Cond: (id = 500)
       ->  Seq Scan on tbl_b b  (cost=0.00..85.50 rows=1 width=8)
             Filter: (id = 500)
    (5 rows)
    
  2. 使用外表索引扫描的物化嵌套循环连接

    testdb=# SET enable_hashjoin TO off;
    SET
    testdb=# SET enable_mergejoin TO off;
    SET
    testdb=# EXPLAIN SELECT * FROM tbl_c AS c, tbl_b AS b WHERE c.id = b.id AND c.id < 40 AND b.id < 10;
                                       QUERY PLAN                                    
    -------------------------------------------------------------------------------
     Nested Loop  (cost=0.29..99.76 rows=1 width=16)
       Join Filter: (c.id = b.id)
       ->  Index Scan using tbl_c_pkey on tbl_c c  (cost=0.29..8.97 rows=39 width=8)
             Index Cond: (id < 40)
       ->  Materialize  (cost=0.00..85.55 rows=9 width=8)
             ->  Seq Scan on tbl_b b  (cost=0.00..85.50 rows=9 width=8)
                   Filter: (id < 10)
    (7 rows)
    
  3. 使用外表索引扫描的索引嵌套循环连接

    testdb=# SET enable_hashjoin TO off;
    SET
    testdb=# SET enable_mergejoin TO off;
    SET
    testdb=# EXPLAIN SELECT * FROM tbl_a AS a, tbl_d AS d WHERE a.id = d.id AND a.id <  40;
                                       QUERY PLAN                                    
    -------------------------------------------------------------------------------
     Nested Loop  (cost=0.57..173.06 rows=20 width=16)
       ->  Index Scan using tbl_a_pkey on tbl_a a  (cost=0.29..8.97 rows=39 width=8)
             Index Cond: (id < 40)
       ->  Index Scan using tbl_d_pkey on tbl_d d  (cost=0.28..4.20 rows=1 width=8)
             Index Cond: (id = a.id)
    (5 rows)
    

3.5.2 归并连接(Merge Join)

与嵌套循环连接不同的是,**归并连接(Merge Join)**只能用于自然连接与等值连接。

函数initial_cost_mergejoin()final_cost_mergejoin()用于估计归并连接的代价。

因为精确估计归并连接的代价非常复杂,因此这里略过不提,只会说明归并连接算法的工作流程。归并连接的启动成本是内表与外表排序成本之和,因此其启动成本为: $$ O(N_{\verb|outer|} \log_2(N_{\verb|outer|}) + N_{\verb|inner|} \log_2(N_{\verb|inner|})) $$ 这里$N_{\verb|outer|}$和$N_{\verb|inner|}$是分别是外表和内表的元组条数,而运行代价是$O(N_{\verb|outer|}+N_{\verb|inner|})$。

与嵌套循环连接类似,归并连接在PostgreSQL中有4种变体。

3.5.2.1 归并连接

图3.20是归并连接的概念示意图。

图3.20 归并连接

如果所有元组都可以存储在内存中,那么排序操作就能在内存中进行,否则会使用临时文件。

下面是一个具体的例子,一个归并连接的EXPLAIN输出如下所示。

# EXPLAIN SELECT * FROM tbl_a AS a, tbl_b AS b WHERE a.id = b.id AND b.id < 1000;
                               QUERY PLAN
-------------------------------------------------------------------------
 Merge Join  (cost=944.71..984.71 rows=1000 width=16)
   Merge Cond: (a.id = b.id)
   ->  Sort  (cost=809.39..834.39 rows=10000 width=8)
         Sort Key: a.id
         ->  Seq Scan on tbl_a a  (cost=0.00..145.00 rows=10000 width=8)
   ->  Sort  (cost=135.33..137.83 rows=1000 width=8)
         Sort Key: b.id
         ->  Seq Scan on tbl_b b  (cost=0.00..85.50 rows=1000 width=8)
               Filter: (id < 1000)
(9 rows)
  • 第9行:执行器对内表tbl_b进行排序,使用顺序扫描(第11行)。
  • 第6行:执行器对外表tbl_a进行排序,使用顺序扫描(第8行)。
  • 第4行:执行器执行归并连接操作,外表是排好序的tbl_a,内表是排好序的tbl_b

3.5.2.2 物化归并连接

与嵌套循环连接类似,归并连接还支持物化归并连接(Materialized Merge Join),物化内表,使内表扫描更为高效。

图3.21 物化归并连接

这里是物化归并连接的EXPLAIN结果,很容易发现,与普通归并连接的差异是第9行:Materialize

testdb=# EXPLAIN SELECT * FROM tbl_a AS a, tbl_b AS b WHERE a.id = b.id;
                                    QUERY PLAN                                     
---------------------------------------------------------------------------------
 Merge Join  (cost=10466.08..10578.58 rows=5000 width=2064)
   Merge Cond: (a.id = b.id)
   ->  Sort  (cost=6708.39..6733.39 rows=10000 width=1032)
         Sort Key: a.id
         ->  Seq Scan on tbl_a a  (cost=0.00..1529.00 rows=10000 width=1032)
   ->  Materialize  (cost=3757.69..3782.69 rows=5000 width=1032)
         ->  Sort  (cost=3757.69..3770.19 rows=5000 width=1032)
               Sort Key: b.id
               ->  Seq Scan on tbl_b b  (cost=0.00..1193.00 rows=5000 width=1032)
(9 rows)
  • 第10行:执行器对内表tbl_b进行排序,使用顺序扫描(第12行)。
  • 第9行:执行器对tbl_b排好序的结果进行物化。
  • 第6行:执行器对外表tbl_a进行排序,使用顺序扫描(第8行)。
  • 第4行:执行器执行归并连接操作,外表是排好序的tbl_a,内表是物化的排好序的tbl_b

3.5.2.3 其他变体

与嵌套循环连接类似,当外表上可以进行索引扫描时,归并连接也存在相应的变体。

图3.22 归并连接的三种变体,使用外表索引扫描

这些连接的EXPLAIN结果如下。

  1. 使用外表索引扫描的归并连接

    testdb=# SET enable_hashjoin TO off;
    SET
    testdb=# SET enable_nestloop TO off;
    SET
    testdb=# EXPLAIN SELECT * FROM tbl_c AS c, tbl_b AS b WHERE c.id = b.id AND b.id < 1000;
                                          QUERY PLAN                                      
    ------------------------------------------------------------------------------
     Merge Join  (cost=135.61..322.11 rows=1000 width=16)
       Merge Cond: (c.id = b.id)
       ->  Index Scan using tbl_c_pkey on tbl_c c  (cost=0.29..318.29 rows=10000 width=8)
       ->  Sort  (cost=135.33..137.83 rows=1000 width=8)
             Sort Key: b.id
             ->  Seq Scan on tbl_b b  (cost=0.00..85.50 rows=1000 width=8)
                   Filter: (id < 1000)
    (7 rows)
    
  2. 使用外表索引扫描的物化归并连接

    testdb=# SET enable_hashjoin TO off;
    SET
    testdb=# SET enable_nestloop TO off;
    SET
    testdb=# EXPLAIN SELECT * FROM tbl_c AS c, tbl_b AS b WHERE c.id = b.id AND b.id < 4500;
                                          QUERY PLAN                                      
    -------------------------------------------------------------------------------
     Merge Join  (cost=421.84..672.09 rows=4500 width=16)
       Merge Cond: (c.id = b.id)
       ->  Index Scan using tbl_c_pkey on tbl_c c  (cost=0.29..318.29 rows=10000 width=8)
       ->  Materialize  (cost=421.55..444.05 rows=4500 width=8)
             ->  Sort  (cost=421.55..432.80 rows=4500 width=8)
                   Sort Key: b.id
                   ->  Seq Scan on tbl_b b  (cost=0.00..85.50 rows=4500 width=8)
                         Filter: (id < 4500)
    (8 rows)
    
  3. 使用外表索引扫描的索引归并连接

    testdb=# SET enable_hashjoin TO off;
    SET
    testdb=# SET enable_nestloop TO off;
    SET
    testdb=# EXPLAIN SELECT * FROM tbl_c AS c, tbl_d AS d WHERE c.id = d.id AND d.id < 1000;
                                          QUERY PLAN                                      
    -------------------------------------------------------------------------------
     Merge Join  (cost=0.57..226.07 rows=1000 width=16)
       Merge Cond: (c.id = d.id)
       ->  Index Scan using tbl_c_pkey on tbl_c c  (cost=0.29..318.29 rows=10000 width=8)
       ->  Index Scan using tbl_d_pkey on tbl_d d  (cost=0.28..41.78 rows=1000 width=8)
             Index Cond: (id < 1000)
    (5 rows)
    

3.5.3 散列连接(Hash Join)

与归并连接类似,**散列连接(Hash Join)**只能用于自然连接与等值连接。

PostgreSQL中的散列连接的行为因表的大小而异。 如果目标表足够小(确切地讲,内表大小不超过工作内存的25%),那么散列连接就是简单的两阶段内存散列连接(two-phase in-memory hash join) ; 否则,将会使用带倾斜批次的混合散列连接(hybrid hash join)

本小节将介绍PostgreSQL中这两种散列连接的执行过程。

这里省略了代价估算的部分,因为它很复杂。粗略来说,假设向散列表插入与搜索时没有遇到冲突,那么启动和运行成本复杂度都是$O(N_{\verb|outer|} + N_{\verb|inner|})$。

3.5.3.1 内存散列连接

下面将描述内存中的散列连接。

内存中的散列连接是在work_mem中处理的,在PostgreSQL中,散列表区域被称作处理批次(batch)。 一个处理批次会有多个散列槽(hash slots),内部称其为桶(buckets),桶的数量由nodeHash.c中定义的ExecChooseHashTableSize()函数所确定。 桶的数量总是2的整数次幂。

内存散列连接有两个阶段:**构建(build)阶段和探测(probe)**阶段。 在构建阶段,内表中的所有元组都会被插入到batch中;在探测阶段,每条外表元组都会与处理批次中的内表元组比较,如果满足连接条件,则将两条元组连接起来。

为了理解该操作的过程,下面是一个具体的例子。 假设该查询中的连接操作使用散列连接。

SELECT * FROM tbl_outer AS outer, tbl_inner AS inner WHERE inner.attr1 = outer.attr2;

散列连接的过程如图3.23和3.24所示。

图3.23 内存散列连接的构建阶段

  1. 在工作内存上创建一个处理批次。

    在本例中,处理批次有八个桶;即桶的数量是2的3次方。

  2. 将内表的第一个元组插入批次的相应的桶中。

    具体过程如下:

    1. 找出元组中涉及连接条件的属性,计算其散列键。

      在本例中,因为WHERE子句是inner.attr1 = outer.attr2,因此内置的散列函数会对第一条元组的属性attr1取散列值,用作散列键。

    2. 将第一条元组插入散列键相应的桶中。

      假设第一条元组的散列键以二进制记法表示为0x000 ... 001,即其末三**位(bit)**为001。 在这种情况下,该元组会被插入到键为001的桶中。

    在本文中,构建处理批次的插入操作会用运算符 ⊕ 表示。

  3. 插入内表中的其余元组。

图3.24. 内存散列连接的探测阶段

  1. 依外表的第一条元组进行探测。

    详情如下:

    1. 找出第一条外表元组中涉及连接条件的属性,计算其散列键。

      在这个例子中,假设第一条元组的属性attr2的散列键是0x000 ... 100,即其末三**位(bit)**为100。 最后三位是100

    2. 将外表中第一条元组与批次中的内表元组进行比较。如果满足连接条件,则连接内外表元组。

      因为第一个元组的散列键的末三位为100,执行器找出键为100的桶中的所有内表元组,并对内外表元组两侧相应的属性进行比较。这些属性由连接条件(在WHERE子句中)所指明。

      如果满足连接条件,执行器会连接外表中的第一条元组与内表中的相应元组。如果不满足则执行器不做任何事情。

      在本例中,键为100的桶中有Tuple_C。如果Tuple_Cattr1等于第一条元组(Tuple_W)的attr2,则Tuple_CTuple_W将被连接,并保存至内存或临时文件中。

    在本文中,处理批次的探测操作用运算符 ⊗ 表示。

  2. 依次对外表中的其他元组执行探测。

3.5.3.2 带倾斜的混合散列连接

当内表的元组无法全部存储在工作内存中的单个处理批次时,PostgreSQL使用带倾斜批次的混合散列连接算法,该算法是混合散列连接的一种变体。

首先,这里会描述混合散列连接的基本概念。在第一个构建和探测阶段,PostgreSQL准备多个批次。与桶的数目类似,处理批次的数目由函数ExecChooseHashTableSize()决定,也总是2的整数次幂。工作内存中只会分配一个处理批次,而其他批次作都以临时文件的形式创建。属于这些批次的元组将通过临时元组存储功能,被写入到相应的文件中。

图3.25说明了如何将元组存储在四个($ 2 ^ 2 $)处理批次中。在本例中元组散列键的最后五个比特位决定了元组所属的批次与桶,因为处理批次的数量为$2^2$,而桶的数量为$2^3$,因此需要5个比特位来表示,其中前两位决定了元组所属的批次,而后三位决定了元组在该批次中所属的桶。例如:Batch_0存储着散列键介于$\textcolor{red}{00}000$与$\textcolor{red}{00}111$的元组;而Batch_1存储着散列键介于$\textcolor{red}{01}000$与$\textcolor{red}{01}111$的元组,依此类推。

图3.25 混合散列连接中的多个处理批次

在混合散列连接中,构建与探测阶段的执行次数与处理批次的数目相同,因为内外表元组都被存至相同数量的处理批次中。在第一轮构建与探测阶段中,除了处理第一个处理批次,还会创建所有的处理批次。另一方面,第二轮及后续的处理批次都需要读写临时文件,这属于代价巨大的操作。因此PostgreSQL还准备了一个名为skew的特殊处理批次,即倾斜批次,以便在第一轮中高效处理尽可能多的元组。

这个特殊的倾斜批次中的内表元组在连接条件内表一侧属性上的取值,会选用外表连接属性上的高频值(MCV)。因此在第一轮处理中能与外表中尽可能多的元组相连接。这种解释不太好理解,因此下面给出了一个具体的例子。

假设有两个表:客户表customers与购买历史表purchase_historycustomers由两个属性组成:nameaddresspurchase_history由两个属性组成:customer_namebuying_itemcustomers有10,000行,而purchase_history表有1,000,000行。前10%的客户进行了70%的购买。

理解了这些假设,让我们考虑当执行以下查询时,带倾斜的混合散列连接的第一轮是如何执行的。

SELECT * FROM customers AS c, purchase_history AS h 
WHERE c.name = h.customer_name;

如果customers是内表,而purchase_history是外表,则PostgreSQL将使用purchase_history表的高频值值,将前10%的customers元组存储于倾斜批次中。 请注意这里引用的是外表上的高频值,而插入倾斜批次的是内表元组。 在第一轮的探测阶段,外表(purchase_history)中70%的元组将与倾斜批次中存储的元组相连接。 因此,外表分布越是不均匀,第一轮中越是可以处理尽可能多的元组。

接下来会介绍带倾斜批次的混合散列连接的工作原理,如图3.26至3.29所示。

图3.26 混合散列连接的构建阶段的第一轮

  1. 在工作内存中创建一个处理批次,以及一个倾斜批次。

  2. 创建处理批次相应的临时文件,用于存储排好序的内表元组。

    在本例中,内表被分割为四个批次,因此创建了三个批次文件。

  3. 为内表的第一条元组执行构建操作。

    细节如下:

    1. 如果第一条元组应当插入倾斜批次中,则将其插入倾斜批次;否则继续下一步。

      在该例中,如果第一条元组属于前10%的客户,则将其插入到倾斜批次中。

    2. 计算第一条元组的散列键,然后将其插入相应的处理批次。

  4. 对内表其余元组依次执行构建操作。

图3.27 混合散列连接,探测阶段第一轮

  1. 创建临时处理批次文件,用于外表排序。

  2. 为外表的第一条元组执行探测操作,如果外表第一条元组上相应字段取值为MCV,则在倾斜批次上进行探测,否则进行第七步。

    在本例中,如果第一条元组是前10%客户的购买数据,则它会与倾斜批次中的内表元组进行比较。

  3. 为外表的第一条元组执行探测操作。

    操作的内容取决于该元组散列键的取值。如果该元组属于Batch_0则直接完成探测操作;否则将其插入相应的外表处理批次中。

  4. 为外表的其余元组执行探测操作。

    注意在本例中,外表中70%的元组已经在第一轮中的倾斜批次中处理了。

图3.28 构建阶段与探测阶段,第二轮

  1. 移除倾斜批次与Batch_0,为下一轮处理批次腾地方。

  2. 为批次文件batch_1_in中的内表元组执行构建操作。

  3. 为批次文件batch_1_out中的外表元组依次执行探测操作。

图3.29 构建阶段与探测阶段,第三轮及后续

  1. 为批次文件batch_2_inbatch_2_out执行构建操作与探测操作。

  2. 为批次文件batch_3_inbatch_3_out执行构建操作与探测操作。

3.5.4 连接访问路径与连接节点

3.5.4.1 连接访问路径

嵌套循环连接的访问路径由JoinPath结构表示,其他连接访问路径,诸如MergePathHashPath都基于其实现。

下图列出了所有的连接访问路径,细节略过不提。

图3.30 Join访问路径

typedef JoinPath NestPath;

typedef enum JoinType
{
        /* 根据SQL JOIN语法确定的标准连接种类,解析器只允许输出这几种取值。
         * (例如JoinExpr节点) */
        JOIN_INNER,           /* 仅包含匹配的元组对 */
        JOIN_LEFT,            /* 匹配元组对 + 未匹配的左表元组 */
        JOIN_FULL,            /* 匹配元组对 + 未匹配的左右表元组  */
        JOIN_RIGHT,           /* 匹配元组对 + 未匹配的右表元组  */
        /* 关系理论中的半连接(semijoin)与否定半连接(anti-semijoin)并没有用SQL JOIN
         * 语法来表示,而是用另一种风格标准来表示(举个例子,EXISTS)。计划器会认出这些情景
         * 并将其转换为连接。因此计划器与执行器必须支持这几种取值。注意:对于JOIN_SEMI的
         * 输出而言,连接到哪一条右表元组是不确定的。而对于JOIN_ANTI的输出而言,会保证使用
         * 空值进行行扩展。*/
        JOIN_SEMI,            /* 左表元组的一份拷贝,如果该元组有相应匹配 */
        JOIN_ANTI,            /* 右表元组的一份拷贝,如果该元组有相应匹配 */
        /* 这几种代码用于计划器内部,执行器并不支持。(其实大多数时候计划器也不会用)   */
        JOIN_UNIQUE_OUTER,    /* 左表路径必须是UNIQUE的 */
        JOIN_UNIQUE_INNER     /* 右表路径必须是UNIQUE的 */
} JoinType;

typedef struct JoinPath
{
	Path	   path;
	JoinType   jointype;
	Path	   *outerjoinpath;		/* 连接外表一侧的路径 */
	Path	   *innerjoinpath;		/* 连接内表一侧的路径 */
	List	   *joinrestrictinfo;	/* 连接所适用的限制信息 */
	/* 参考RelOptInfo与ParamPathInfo才能理解为什么JoinPath需要有joinrestrictinfo
	 * 且不能合并到RelOptInfo中。 */
} JoinPath;

typedef struct MergePath
{
	JoinPath   jpath;
	List	   *path_mergeclauses;	/* 归并所需的连接子句 */
	List	   *outersortkeys;		/* 用于外表显式排序的键,如果存在 */
	List	   *innersortkeys;		/* 用于内表显式排序的键,如果存在 */
	bool	   materialize_inner;	/* 为内表执行物化过程? */
} MergePath;

3.5.4.2 连接节点

本小节列出了三种连接节点:NestedLoopNodeMergeJoinNodeHashJoinNode,它们都基于JoinNode实现,细节略过不提。

/* ----------------
 *        连接节点
 *
 * jointype:    连接左右子树元组的规则
 * joinqual:    来自 JOIN/ON 或 JOIN/USING 的连接限定条件
 *                (plan.qual 包含了来自WHERE子句的条件)
 *
 * 当jointype为INNER时,joinqual 与 plan.qual 在语义上可以互换。对于OUTER而言这两者
 * 则无法互换;只有joinqual会被用于匹配判定,以及是否需要生成空值扩展的元组。
 * (但 plan.qual 仍然会在实际返回一条元组前生效。)
 * 对于外连接而言,只有joinquals能被用于归并连接或散列连接的连接条件。
 * ----------------
 */
typedef struct Join
{
    Plan        plan;
    JoinType    jointype;
    List        *joinqual;    /* 连接条件 (除 plan.qual 外) */
} Join;

/* ----------------
 *        嵌套循环连接节点
 * 
 * nestParams的列表标识出了执行器所需的参数,这些参数从外表子计划中的当前行获取,
 * 并传入内表子计划中用于执行。当前我们限制这些值为简单的Vars,但也许某一天这一限制
 * 会放松。(注意在创建执行计划期间,paramval实际上可能是一个PlaceHolderVar表达式;
 * 但当其进入执行器时,它必须转换为varno为OUTER_VAR的Var。)
 * ----------------*/
typedef struct NestLoop
{
    Join       join;
    List       *nestParams;   /* NestLoopParam 节点的列表*/
} NestLoop;

typedef struct NestLoopParam
{
    NodeTag   type;
    int       paramno;        /* 需要配置的PARAM_EXEC参数数量 */
    Var       *paramval;      /* 需要赋值给Param的外表变量 */
} NestLoopParam;

/* ----------------
 *        归并连接节点
 * 
 * 待归并列上期待的顺序是通过一个btree运算符族的OID,一个排序规则的OID,一个方向字段
 * (BTLessStrategyNumber 或 * BTGreaterStrategyNumber),以及一个 NULL FIRST
 * 标记位描述的。注意归并语句的两侧可能是不同的数据类型,但它们会按照共同的运算符族与排序
 * 规则,以同样的方式排序。每个归并子句中的算子必须为相应运算符族中的等值运算。
 * ---------------- */
typedef struct MergeJoin
{
    Join    join;
    List    *mergeclauses;        /* mergeclauses 是一颗表达式树 */
    /* 这些字段都是数组,但与mergeclauses列表有着同样的长度: */
    Oid     *mergeFamilies;      /* B树运算符族的OID列表,每条子句一个 */
    Oid     *mergeCollations;    /* 排序规则的OID列表,每条子句一个 */
    int     *mergeStrategies;    /* 顺序(ASC 或 DESC)的列表,每条子句一个 */
    bool    *mergeNullsFirst;    /* 空值顺序,每条子句一个  */
} MergeJoin;


/* ----------------
 *        散列连接节点
 * ---------------- */
typedef struct HashJoin
{
    Join    join;
    List    *hashclauses;
} HashJoin;

3.6 创建多表查询计划树

本节将说明多表查询计划树的创建过程。

3.6.1 预处理

预处理由planner.c中定义的subquery_planner()函数执行。第3.3.1节已经描述了单表查询的预处理。本节将描述多表查询的预处理;尽管这块内容很多,但这里只会挑其中一部分来讲。

  1. 对CTE进行计划与转换

    如果存在WITH列表,计划器就会通过SS_process_ctes()函数对每个WITH查询进行处理。

  2. 上拉子查询

    如果FROM子句带有一个子查询,且该子查询没用用到GROUP BYHAVINGORDER BYLIMITDISTINCTINTERSECTEXCEPT,那么计划器会使用pull_up_subqueries()函数将其转换为连接形式。例如下面一个FROM子句含子查询的查询就可以被转换为自然连接查询。自不必说,这种转换是在查询树上进行的。

    # SELECT * FROM tbl_a AS a, (SELECT * FROM tbl_b) as b WHERE a.id = b.id;
    	 	       	     
    # SELECT * FROM tbl_a AS a, tbl_b as b WHERE a.id = b.id;
    
  3. 将外连接转为内连接

    如果可能的话,计划器会将OUTER JOIN查询转换为INNER JOIN查询。

3.6.2 获取代价最小的路径

为了获取最佳计划树,计划器必须考虑各个索引与各种连接方法之间的所有可能组合。 如果表的数量超过某个水平,该过程的代价就会因为组合爆炸而变得非常昂贵,以至于根本不可行。

幸运的是,如果表的数量小于12张,计划器可以使用动态规划来获取最佳计划; 否则计划器会使用遗传算法。详情如下:

基因查询优化器

当执行一个多表连接查询时,大量时间耗费在了优化查询计划上。 为了应对这种情况,PostgreSQL实现了一个有趣的功能:基因查询优化器。 这种近似算法能在合理时间内确定一个合理的计划。 因此在查询优化阶段,如果参与连接的表数量超过参数geqo_threshold指定的阈值(默认值为12),PostgreSQL将使用遗传算法来生成查询计划。

使用动态规划确定最佳计划树的过程,其步骤如下:

  • 第一层

    获得每张表上代价最小的路径,代价最小的路径存储在表相应的RelOptInfo结构中。

  • 第二层

    从所有表中选择两个表,为每种组合找出代价最低的路径。

    举个例子,如果总共有两张表,表A与表B,则表AB表连接的各种路径中,代价最小的那条即为最终想要的答案。在下文中,两个表的RelOptInfo记做${A,B}$。

    如果有三个表,则需要获取${A,B}, {A,C},{B,C}$三种组合里各自代价最小的路径。

  • 第三层及其后

    继续进行同样的处理,直到层级等于表数量。

通过这种方式,在每个层级都能解决最小代价问题的一部分,且其结果能被更高层级的计算复用,从而使代价最小的计划树能够被高效地计算出来。

图3.31 如何使用动态规划获取代价最小的访问路径

接下来会针对下面的查询,解释计划器是如何获取代价最小的计划的。

testdb=# \d tbl_a
     Table "public.tbl_a"
 Column |  Type   | Modifiers 
--------+---------+-----------
 id     | integer | not null
 data   | integer | 
Indexes:
    "tbl_a_pkey" PRIMARY KEY, btree (id)

testdb=# \d tbl_b
     Table "public.tbl_b"
 Column |  Type   | Modifiers 
--------+---------+-----------
 id     | integer | 
 data   | integer | 

testdb=# SELECT * FROM tbl_a AS a, tbl_b AS b WHERE a.id = b.id AND b.data < 400;

3.6.2.1 第一层的处理

在第一层中,计划器会为查询中涉及的关系创建相应的RelOptInfo结构,并估计每个关系上的最小代价。 在这一步中,RelOptInfo结构会被添加至该查询对应PlannerInfosimple_rel_arrey数组字段中。

图3.32 第一层处理后的PlannerInfoRelOptInfo

tbl_aRelOptInfo有三条访问路径,它们被添加至RelOptInfo的路径列表中。这三条路径分别被三个指针所链接,即三个指向代价最小路径的指针:启动代价最小的路径,总代价最小的路径,参数化代价最小的路径。 启动代价最小的路径与总代价最小的路径涵义显而易见,因此,这里只会说一下参数化索引扫描代价最小的路径(cheapest parameterized index scan path)

如3.5.1.3节所述,计划器会考虑为索引嵌套循环连接使用参数化路径(parameterized path)(极少数情况下也会用于带外表索引扫描的索引化归并连接)。参数化索引扫描代价最小的路径,就是所有参数化路径中代价最小的那个。

tbl_bRelOptInfo仅有顺序扫描访问路径,因为tbl_b上没有相关索引。

3.6.2.2 第二层的处理

在第二层中,计划器会在PlannerInfojoin_rel_list字段中创建一个RelOptInfo结构。 然后估计所有可能连接路径的代价,并且选择代价最小的那条访问路径。 RelOptInfo会将最佳访问路径作为总代价最小的路径, 如图3.33所示。

图3.33 第二层处理后的PlannerInfoRelOptInfo

表3.1展示了本例中连接访问路径的所有组合。本例中查询的连接类型为等值连接(equi-join),因而对全部三种连接算法进行评估。 为方便起见,这里引入了一些有关访问路径的符号:

  • SeqScanPath(table)表示表table上的顺序扫描路径。
  • Materialized -> SeqScanPath(table)表示表table上的物化顺序扫描路径。
  • IndexScanPath(table,attribute)表示按表table中属性attribute上的索引扫描路径。
  • ParameterizedIndexScanPath(table,attribute1,attribute2)表示表table中属性attribute1上的参数化索引路径,并使用外表上的属性attribute2参数化。

表 3.1 此示例中的所有连接访问路径组合

嵌套循环连接

外表路径 内表路径 备注
SeqScanPath(tbl_a) SeqScanPath(tbl_b)
SeqScanPath(tbl_a) Materialized -> SeqScanPath(tbl_b) 物化嵌套循环链接
IndexScanPath(tbl_a,id) SeqScanPath(tbl_b) 嵌套循环连接,走外表索引
IndexScanPath(tbl_a,id) Materialized -> SeqScanPath(tbl_b) 物化嵌套循环连接,走外表索引
SeqScanPath(tbl_b) SeqScanPath(tbl_a)
SeqScanPath(tbl_b) Materialized -> SeqScanPath(tbl_a) 物化嵌套循环连接
SeqScanPath(tbl_b) ParametalizedIndexScanPath(tbl_a, id, tbl_b.id) 索引嵌套循环连接

归并连接

外表路径 内表路径 备注
SeqScanPath(tbl_a) SeqScanPath(tbl_b)
IndexScanPath(tbl_a,id) SeqScanPath(tbl_b) 用外表索引做归并连接
SeqScanPath(tbl_b) SeqScanPath(tbl_a)

哈希连接

外表路径 内表路径 备注
SeqScanPath(tbl_a) SeqScanPath(tbl_b)
SeqScanPath(tbl_b) SeqScanPath(tbl_a)

例如在嵌套循环连接的部分总共评估了七条连接路径。 第一条表示在外表tbl_a和内表tbl_b上都使用顺序扫描路径;第二条表示在外表tbl_a上使用路径顺序扫描路径,而在内表tbl_b上使用物化顺序扫描路径,诸如此类。

计划器最终从估计的连接访问路径中选择代价最小的那条,并且将其添加至RelOptInfo{tbl_a,tbl_b}的路径列表中,如图3.33所示。

在本例中,如下面EXPLAIN的结果所示,计划器选择了在内表tbl_b和外表tbl_c上进行散列连接。

testdb=# EXPLAIN  SELECT * FROM tbl_b AS b, tbl_c AS c WHERE c.id = b.id AND b.data < 400;
                              QUERY PLAN                              
----------------------------------------------------------------------
 Hash Join  (cost=90.50..277.00 rows=400 width=16)
   Hash Cond: (c.id = b.id)
   ->  Seq Scan on tbl_c c  (cost=0.00..145.00 rows=10000 width=8)
   ->  Hash  (cost=85.50..85.50 rows=400 width=8)
         ->  Seq Scan on tbl_b b  (cost=0.00..85.50 rows=400 width=8)
               Filter: (data < 400)
(6 rows)

3.6.3 获取三表查询代价最小的路径

涉及三个表的查询,其代价最小的路径的获取过程如下所示:

testdb=# \d tbl_a
     Table "public.tbl_a"
 Column |  Type   | Modifiers 
--------+---------+-----------
 id     | integer | 
 data   | integer | 

testdb=# \d tbl_b
     Table "public.tbl_b"
 Column |  Type   | Modifiers 
--------+---------+-----------
 id     | integer | 
 data   | integer | 

testdb=# \d tbl_c
     Table "public.tbl_c"
 Column |  Type   | Modifiers 
--------+---------+-----------
 id     | integer | not null
 data   | integer | 
Indexes:
    "tbl_c_pkey" PRIMARY KEY, btree (id)

testdb=# SELECT * FROM tbl_a AS a, tbl_b AS b, tbl_c AS c 
testdb-#                WHERE a.id = b.id AND b.id = c.id AND a.data < 40;
  • 第一层:

    计划器估计所有表上各自开销最小的路径,并将该信息存储在表相应的RelOptInfos结构{tbl_a}{tbl_b}{tbl_c}中。

  • 第二层:

    计划器从三个表中选出两个,列出所有组合,分别评估每种组合里代价最小的路径。然后,规划器将信息存储在组合相应的RelOptInfos结构中:{tbl_a,tbl_b}{tbl_b,tbl_c}{tbl_a,tbl_c}中。

  • 第三层:

    计划器根据所有已获取的RelOptInfos,选择代价最小的路径。更确切地说,计划器会考虑三种RelOptInfos组合:{tbl_a,{tbl_b,tbl_c}}{tbl_b,{tbl_a,tbl_c}}{tbl_c,{tbl_a,tbl_b}},而{tbl_a,tbl_b,tbl_c}如下所示:

$$ \begin{equation} {\verb|tbl_a|,\verb|tbl_b|,\verb|tbl_c|} = \ \mathrm{min}({\verb|tbl_a|,{\verb|tbl_b|,\verb|tbl_c|}}, {\verb|tbl_b|,{\verb|tbl_a|,\verb|tbl_c|}}, {\verb|tbl_c|,{\verb|tbl_a|,\verb|tbl_b|}}). \end{equation} $$

计划器会估算这里面所有可能连接路径的代价。

在处理{tbl_c,{tbl_a,tbl_b}}对应的RelOptInfo时,计划器会估计tbl_c{tbl_a,tbl_b}连接代价最小的路径。本例中{tbl_a,tbl_b}已经选定为内表为tbl_a且外表为tbl_b的散列连接。如先前小节所述,在估计时三种连接算法及其变体都会被评估,即嵌套循环连接及其变体,归并连接及其变体,散列连接及其变体。

计划器以同样的方式处理{tbl_a,{tbl_b,tbl_c}}{tbl_b,{tbl_a,tbl_c}}对应的RelOptInfo,并最终从所有估好的路径中选择代价最小的访问路径。

该查询的EXPLAIN命令结果如下所示:

最外层的连接是索引嵌套循环连接(第5行),第13行显示了内表上的参数化索引扫描,外表则是一个散列连接的结果该散列连接的内表是tbl_a,外表是tbl_b(第7-12行)。 因此,执行程序首先执行tbl_atbl_b上的散列连接,再执行索引嵌套循环连接。

参考文献

  • [1] Abraham Silberschatz, Henry F. Korth, and S. Sudarshan, “Database System Concepts”, McGraw-Hill Education, ISBN-13: 978-0073523323
  • [2] Thomas M. Connolly, and Carolyn E. Begg, “Database Systems”, Pearson, ISBN-13: 978-0321523068

第四章 外部数据包装器

本章将介绍一种相当实用,而且很有趣的特性:外部数据包装器(Foreign Data Wrapper FDW)

4.1 外部数据包装器(FDW)

2003年,SQL标准中添加了一个访问远程数据的规范,称为SQL外部数据管理(SQL/MED)。PostgreSQL在9.1版本开发出了FDW,实现了一部分SQL/MED中的特性。

在SQL/MED中,远程服务器上的表被称为外部表(Foreign Table)。 PostgreSQL的外部数据包装器(FDW) 使用与本地表类似的方式,通过SQL/MED来管理外部表。

图4.1 FDW的基本概念

Fig. 4.1. Basic concept of FDW.

安装完必要的扩展并配置妥当后,就可以访问远程服务器上的外部表了。 例如假设有两个远程服务器分别名为postgresqlmysql,它们上面分别有两张表:foreign_pg_tblforeign_my_tbl。 在本例中,可以在本地服务器上执行SELECT查询以访问外部表,如下所示。

localdb=# -- foreign_pg_tbl 在远程postgresql服务器上
localdb-# SELECT count(*) FROM foreign_pg_tbl;
 count 
-------
 20000

localdb=# -- foreign_my_tbl 在远程mysql服务器上
localdb-# SELECT count(*) FROM foreign_my_tbl;
 count 
-------
 10000

此外还可以在本地连接来自不同服务器中的外部表。

localdb=# SELECT count(*) FROM foreign_pg_tbl AS p, foreign_my_tbl AS m WHERE p.id = m.id;
 count 
-------
 10000

Postgres wiki中列出了很多现有的FDW扩展。但只有postgres_fdwfile_fdw 是由官方PostgreSQL全球开发组维护的。postgres_fdw可用于访问远程PostgreSQL服务器。

以下部分将详细介绍PostgreSQL的FDW。 4.1.1节为概述,4.1.2节介绍了postgres_fdw扩展的工作方式。

Citus

Citus是由citusdata.com开发的开源PostgreSQL扩展,它能创建用于并行化查询的分布式PostgreSQL服务器集群。citus算是PostgreSQL生态中机制上最为复杂,且商业上最为成功的扩展之一,它也是一种FDW。

4.1.1 概述

使用FDW特性需要先安装相应的扩展,并执行一些设置命令,例如CREATE FOREIGN TABLECREATE SERVERCREATE USER MAPPING(细节请参阅官方文档)。

在配置妥当之后,查询处理期间,执行器将会调用扩展中定义的相应函数来访问外部表。

图4.2 FDW是如何执行的

Fig. 4.2. How FDWs perform.

  1. 分析器为输入的SQL创建一颗查询树。
  2. 计划器(或执行器)连接到远程服务器。
  3. 如果启用了use_remote_estimate选项(默认关闭),则计划器将执行EXPLAIN命令以估计每条计划路径的代价。
  4. 计划器按照计划树创建出纯文本SQL语句,在内部称该过程为逆解析(deparesing)
  5. 执行器将纯文本SQL语句发送到远程服务器并接收结果。

如有必要,执行器会进一步处理接收到的结果。 例如执行多表查询时,执行器会将收到的数据与其他表进行连接。

以下各节介绍了每一步中的具体细节。

4.1.1.1 创建一颗查询树

分析器会根据输入的SQL创建一颗查询树,并使用外部表的定义。当执行命令CREATE FOREIGN TABLEIMPORT FOREIGN SCHEMA时,外部表的定义会被存储至系统目录pg_catalog.pg_classpg_catalog.pg_foreign_table中。

4.1.1.2 连接至远程服务器

计划器(或执行器)会使用特定的库连接至远程数据库服务器。 例如要连接至远程PostgreSQL服务器时,postgres_fdw会使用libpq。 而连接到mysql服务器时,由EnterpriseDB开发的mysql_fdw使用libmysqlclient

当执行CREATE USER MAPPINGCREATE SERVER命令时,诸如用户名,服务器IP地址和端口号等连接参数会被存储至系统目录pg_catalog.pg_user_mappingpg_catalog.pg_foreign_server中。

4.1.1.3 使用EXPLAIN命令创建计划树(可选)

PostgreSQL的FDW机制支持一种特性:获取外部表上的统计信息,用于估计查询代价。一些FDW扩展使用了该特性,例如postgres_fdwmysql_fdwtds_fdwjdbc2_fdw

如果使用ALTER SERVER命令将use_remote_estimate选项设置为on,则计划器会向远程服务器发起查询,执行EXPLAIN命令获取执行计划的代价。否则在默认情况下,会使用默认内置常量值作为代价。

localdb=# ALTER SERVER remote_server_name OPTIONS (use_remote_estimate 'on');

尽管一些扩展也会执行EXPLAIN命令,但目前只有postgres_fdw才能忠于EXPLAIN命令的真正意图,因为PostgreSQL的EXPLAIN命令会同时返回启动代价和总代价。而其他DBMS的FDW扩展一般无法使用EXPLAIN命令的结果进行规划。 例如MySQL的EXPLAIN命令仅仅返回估计的行数, 但如第3章所述,PostgreSQL的计划器需要更多的信息来估算代价。

4.1.1.4 逆解析

在生成执行计划树的过程中,计划器会为执行计划树上外部表的扫描路径创建相应的纯文本SQL语句。 例如图4.3展示了下列SELECT语句对应的计划树。

localdb=# SELECT * FROM tbl_a AS a WHERE a.id < 10;

图4.3展示了一个存储着纯文本形式SELECT语句的ForeignScan节点,PlannedStmt是执行计划树对应的数据结构,包含指向ForeignScan节点的链接。 这里,postgres_fdw从查询树中重新创建出SELECT纯文本语句,该过程在PostgreSQL中被称为逆解析(deparsing)

图4.3 扫描外部表的计划树样例

Fig. 4.3. Example of the plan tree that scans a foreign table.

使用mysql_fdw时,则会从查询树中重新创建MySQL相应的SELECT语句。 使用redis_fdwrw_redis_fdw会创建一条Redis中的SELECT命令

4.1.1.5 发送SQL命令并接收结果

在进行逆解析之后,执行器将逆解析得到的SQL语句发送到远程服务器并接收结果。

扩展的开发者决定了将SQL语句发送至远程服务器的具体方法。 例如mysql_fdw在发送多条SQL语句时不使用事务。 在mysql_fdw中执行SELECT查询的典型SQL语句序列如下所示(图4.4)。

  • (5-1)将SQL_MODE设置为'ANSI_QUOTES'
  • (5-2)将SELECT语句发送到远程服务器。
  • (5-3)从远程服务器接收结果。这里mysql_fdw会将结果转换为PostgreSQL可读的格式。所有FDW扩展都实现了将结果转换为PostgreSQL可读数据的功能。

图4.4 mysql_fdw执行一个典型SELECT查询时的SQL语句序列

Fig. 4.4. Typical sequence of SQL statements to execute a SELECT query in mysql_fdw

下面是远程服务器的日志,列出了实际接收到的语句。

mysql> SELECT command_type,argument FROM mysql.general_log;
+--------------+-----------------------------------------------------------+
| command_type | argument                                                              |
+--------------+-----------------------------------------------------------+
... snip ...

| Query        | SET sql_mode='ANSI_QUOTES'                                            |
| Prepare      | SELECT `id`, `data` FROM `localdb`.`tbl_a` WHERE ((`id` < 10))         |
| Close stmt   |                                                                       |
+--------------+-----------------------------------------------------------+

postgres_fdw中的SQL命令顺序要更为复杂。在postgres_fdw中执行一个典型的SELECT查询,实际的语句序列如图4.5所示。

  • (5-1)启动远程事务。远程事务的默认隔离级别是REPEATABLE READ;但如果本地事务的隔离级别设置为SERIALIZABLE,则远程事务的隔离级别也会设置为SERIALIZABLE

  • (5-2)-(5-4)声明一个游标,SQL语句基本上以游标的方式来执行。

  • (5-5)执行FETCH命令获取结果。默认情况下FETCH命令一次获取100行。

  • (5-6)从远程服务器接收结果。

  • (5-7)关闭游标。

  • (5-8)提交远程事务。

图4.5 postgres_fdw执行一个典型SELECT查询时的SQL语句序列

Fig. 4.5. Typical sequence of SQL statements to execute a SELECT query in postgres_fdw.

这里是远程服务器的实际日志。

LOG:  statement: START TRANSACTION ISOLATION LEVEL REPEATABLE READ
LOG:  parse : DECLARE c1 CURSOR FOR SELECT id, data FROM public.tbl_a WHERE ((id < 10))
LOG:  bind : DECLARE c1 CURSOR FOR SELECT id, data FROM public.tbl_a WHERE ((id < 10))
LOG:  execute : DECLARE c1 CURSOR FOR SELECT id, data FROM public.tbl_a WHERE ((id < 10))
LOG:  statement: FETCH 100 FROM c1
LOG:  statement: CLOSE c1
LOG:  statement: COMMIT TRANSACTION

postgres_fdw中远程事务的默认隔离级别

远程事务的默认隔离级别为REPEATABLE READ,官方文档给出了原因和说明:

当本地事务使用SERIALIZABLE隔离级别时,远程事务也会使用SERIALIZABLE隔离级别,否则使用REPEATABLE READ隔离级别。 这样做可以确保在远程服务器上执行多次扫表时,每次的结果之间都能保持一致。因此,即使其他活动在远程服务器上进行了并发更新,单个事务中的连续查询也将看到远程服务器上的一致性快照。

4.1.2 postgres_fdw的工作原理

postgres_fdw扩展是一个由PostgreSQL全球开发组官方维护的特殊模块,其源码包含在PostgreSQL源码树中。

postgres_fdw正处于不断改善的过程中。 表4.1列出了官方文档中与postgres_fdw有关的发行说明。

表4.1 与postgres_fdw有关的发布说明(摘自官方文档)

版本 描述
9.3 postgres_fdw模块正式发布
9.6 在远程服务器上执行排序
在远程服务器上执行连接
如果可行,在远程服务器上执行UPDATEDELETE
允许在服务器与表的选项中设置批量拉取结果集的大小
10 如果可行, 将聚合函数下推至远程服务器
前一节描述了postgres_fdw如何处理单表查询,接下来的小节将介绍postgres_fdw如何处理多表查询,排序操作与聚合函数。

本小节重点介绍SELECT语句;但postgres_fdw还可以处理其他DML(INSERTUPDATEDELETE)语句。

PostgreSQL的FDW不会检测死锁

postgres_fdw与FDW功能并不支持分布式锁管理器与分布式死锁检测功能, 因此很容易产生死锁。 例如某客户端A更新了一个本地表tbl_local与一个外部表tbl_remote,而另一个客户端B以相反的顺序更新tbl_remotetbl_local,则这两个事务陷入死锁。但PostgreSQL无法检测到这种情况, 因而无法提交这些事务。

localdb=# -- Client A
localdb=# BEGIN;
BEGIN
localdb=# UPDATE tbl_local SET data = 0 WHERE id = 1;
UPDATE 1
localdb=# UPDATE tbl_remote SET data = 0 WHERE id = 1;
UPDATE 1
localdb=# -- Client B
localdb=# BEGIN;
BEGIN
localdb=# UPDATE tbl_remote SET data = 0 WHERE id = 1;
UPDATE 1
localdb=# UPDATE tbl_local SET data = 0 WHERE id = 1;
UPDATE 1

4.1.2.1 多表查询

当执行多表查询时,postgres_fdw使用单表SELECT语句依次拉取每个外部表,并在本地服务器上执行连接操作。

在9.5或更早版本中,即使所有外部表都存储在同一个远程服务器中,postgres_fdw也会单独拉取每个表再连接。

在9.6或更高版本中,postgres_fdw已经有所改进,当外部表位于同一服务器上且use_remote_estimate选项打开时,可以在远程服务器上执行远程连接操作。

执行细节如下所述。

9.5及更早版本:

我们研究一下PostgreSQL如何处理以下查询:两个外部表的连接:tbl_atbl_b

localdb=# SELECT * FROM tbl_a AS a, tbl_b AS b WHERE a.id = b.id AND a.id < 200;

EXPLAIN的执行结果如下

localdb=# EXPLAIN SELECT * FROM tbl_a AS a, tbl_b AS b WHERE a.id = b.id AND a.id < 200;
                                  QUERY PLAN                                  
------------------------------------------------------------------------------
 Merge Join  (cost=532.31..700.34 rows=10918 width=16)
   Merge Cond: (a.id = b.id)
   ->  Sort  (cost=200.59..202.72 rows=853 width=8)
         Sort Key: a.id
         ->  Foreign Scan on tbl_a a  (cost=100.00..159.06 rows=853 width=8)
   ->  Sort  (cost=331.72..338.12 rows=2560 width=8)
         Sort Key: b.id
         ->  Foreign Scan on tbl_b b  (cost=100.00..186.80 rows=2560 width=8)
(8 rows)

结果显示,执行器选择了归并连接,并按以下步骤处理:

  • 第8行:执行器使用外部表扫描拉取表tbl_a

  • 第6行:执行器在本地服务器上对拉取的tbl_a行进行排序。

  • 第11行:执行器使用外表扫描拉取表tbl_b

  • 第9行:执行器在本地服务器上对拉取的tbl_b行进行排序。

  • 第4行:执行器在本地服务器上执行归并连接操作。

下面描述执行器如何拉取行集(图4.6)。

  • (5-1)启动远程事务。

  • (5-2)声明游标c1,其SELECT语句如下所示:

    SELECT iddata FROM public.tbl_a WHEREid <200
    
  • (5-3)执行FETCH命令以拉取游标c1的结果。

  • (5-4)声明游标c2,其SELECT语句如下所示:

    SELECT id,data FROM public.tbl_b
    

    注意原来双表查询中的WHERE子句是tbl_a.id = tbl_b.id AND tbl_a.id <200;因而从逻辑上讲这条SELECT语句也可以添加上一条WHERE子句tbl_b.id <200。但postgres_fdw没有办法执行这样的推理,因此执行器必须执行不包含任何WHERE子句的SELECT语句,获取外部表tbl_b 中的所有行。

    这种处理方式效率很差,因为必须通过网络从远程服务器读取不必要的行。此外,执行归并连接还需要先对接受到的行进行排序。

  • (5-5)执行FETCH命令,拉取游标c2的结果。

  • (5-6)关闭游标c1

  • (5-7)关闭游标c2

  • (5-8)提交事务。

图4.6 在9.5及更早版本中执行多表查询时的SQL语句序列

Fig. 4.6. Sequence of SQL statements to execute the Multi-Table Query in version 9.5 or earlier.

这里是远程服务器的实际日志。

LOG:  statement: START TRANSACTION ISOLATION LEVEL REPEATABLE READ
LOG:  parse : DECLARE c1 CURSOR FOR
      SELECT id, data FROM public.tbl_a WHERE ((id < 200))
LOG:  bind : DECLARE c1 CURSOR FOR
      SELECT id, data FROM public.tbl_a WHERE ((id < 200))
LOG:  execute : DECLARE c1 CURSOR FOR
      SELECT id, data FROM public.tbl_a WHERE ((id < 200))
LOG:  statement: FETCH 100 FROM c1
LOG:  statement: FETCH 100 FROM c1
LOG:  parse : DECLARE c2 CURSOR FOR
      SELECT id, data FROM public.tbl_b
LOG:  bind : DECLARE c2 CURSOR FOR
      SELECT id, data FROM public.tbl_b
LOG:  execute : DECLARE c2 CURSOR FOR
      SELECT id, data FROM public.tbl_b
LOG:  statement: FETCH 100 FROM c2
LOG:  statement: FETCH 100 FROM c2
LOG:  statement: FETCH 100 FROM c2
LOG:  statement: FETCH 100 FROM c2

... snip

LOG:  statement: FETCH 100 FROM c2
LOG:  statement: FETCH 100 FROM c2
LOG:  statement: FETCH 100 FROM c2
LOG:  statement: FETCH 100 FROM c2
LOG:  statement: CLOSE c2
LOG:  statement: CLOSE c1
LOG:  statement: COMMIT TRANSACTION

在接收到行之后,执行器对接收到的tbl_atbl_b行进行排序,然后对已排序的行执行合并连接操作。

9.6或更高版本:

如果启用了use_remote_estimate选项(默认为关闭),则postgres_fdw会发送几条EXPLAIN命令,用于获取与外部表相关的所有计划的代价。

当发送EXPLAIN命令时,postgres_fdw将为每个单表查询执行EXPLAIN,也为执行远程连接操作时的SELECT语句执行EXPLAIN 。在本例中,以下七个EXPLAIN命令会被发送至远程服务器,用于估算每个SELECT语句的开销,从而选择开销最小的执行计划。

(1) EXPLAIN SELECT id, data FROM public.tbl_a WHERE ((id < 200))
(2) EXPLAIN SELECT id, data FROM public.tbl_b
(3) EXPLAIN SELECT id, data FROM public.tbl_a WHERE ((id < 200)) ORDER BY id ASC NULLS LAST
(4) EXPLAIN SELECT id, data FROM public.tbl_a WHERE ((((SELECT null::integer)::integer) = id)) AND ((id < 200))
(5) EXPLAIN SELECT id, data FROM public.tbl_b ORDER BY id ASC NULLS LAST
(6) EXPLAIN SELECT id, data FROM public.tbl_b WHERE ((((SELECT null::integer)::integer) = id))
(7) EXPLAIN SELECT r1.id, r1.data, r2.id, r2.data FROM (public.tbl_a r1 INNER JOIN public.tbl_b r2 ON (((r1.id = r2.id)) AND ((r1.id < 200))))

让我们在本地服务器上执行EXPLAIN命令,并观察计划器选择了哪一个计划。

localdb=# EXPLAIN SELECT * FROM tbl_a AS a, tbl_b AS b WHERE a.id = b.id AND a.id < 200;
                        QUERY PLAN                         
-----------------------------------------------------------
 Foreign Scan  (cost=134.35..244.45 rows=80 width=16)
   Relations: (public.tbl_a a) INNER JOIN (public.tbl_b b)
(2 rows)

结果显示,计划器选择了在远程服务器上进行INNER JOIN处理的执行计划,也是最有效率的执行计划。

下面讲述postgres_fdw是如何执行这一过程的,如图4.7所示。

图4.7 执行远程连接操作时的SQL语句序列,9.6及更高版本

Fig. 4.7. Sequence of SQL statements to execute the remote-join operation in version 9.6 or later.

  • (3-1)启动远程事务。

  • (3-2)执行EXPLAIN命令,估计每条计划路径的代价。在本例中执行了七条EXPLAIN命令。然后计划器根据EXPLAIN命令的结果,选取具有最低开销的SELECT查询。

  • (5-1)声明游标c1,其SELECT语句如下所示:

    SELECT r1.id, r1.data, r2.id, r2.data 
    FROM (public.tbl_a r1 INNER JOIN public.tbl_b r2 
      ON (((r1.id = r2.id)) AND ((r1.id < 200))))
    
  • (5-2)从远程服务器接收结果。

  • (5-3)关闭游标c1

  • (5-4)提交事务。

这里是远程服务器的实际日志。

LOG:  statement: START TRANSACTION ISOLATION LEVEL REPEATABLE READ
LOG:  statement: EXPLAIN SELECT id, data FROM public.tbl_a WHERE ((id < 200))
LOG:  statement: EXPLAIN SELECT id, data FROM public.tbl_b
LOG:  statement: EXPLAIN SELECT id, data FROM public.tbl_a WHERE ((id < 200)) ORDER BY id ASC NULLS LAST
LOG:  statement: EXPLAIN SELECT id, data FROM public.tbl_a WHERE ((((SELECT null::integer)::integer) = id)) AND ((id < 200))
LOG:  statement: EXPLAIN SELECT id, data FROM public.tbl_b ORDER BY id ASC NULLS LAST
LOG:  statement: EXPLAIN SELECT id, data FROM public.tbl_b WHERE ((((SELECT null::integer)::integer) = id))
LOG:  statement: EXPLAIN SELECT r1.id, r1.data, r2.id, r2.data FROM (public.tbl_a r1 INNER JOIN public.tbl_b r2 ON (((r1.id = r2.id)) AND ((r1.id < 200))))
LOG:  parse: DECLARE c1 CURSOR FOR
	   SELECT r1.id, r1.data, r2.id, r2.data FROM (public.tbl_a r1 INNER JOIN public.tbl_b r2 ON (((r1.id = r2.id)) AND ((r1.id < 200))))
LOG:  bind: DECLARE c1 CURSOR FOR
	   SELECT r1.id, r1.data, r2.id, r2.data FROM (public.tbl_a r1 INNER JOIN public.tbl_b r2 ON (((r1.id = r2.id)) AND ((r1.id < 200))))
LOG:  execute: DECLARE c1 CURSOR FOR
	   SELECT r1.id, r1.data, r2.id, r2.data FROM (public.tbl_a r1 INNER JOIN public.tbl_b r2 ON (((r1.id = r2.id)) AND ((r1.id < 200))))
LOG:  statement: FETCH 100 FROM c1
LOG:  statement: FETCH 100 FROM c1
LOG:  statement: CLOSE c1
LOG:  statement: COMMIT TRANSACTION

注意如果禁用use_remote_estimate选项(默认情况),则远程连接查询很少会被选择,因为这种情况下其代价会使用一个很大的预置值进行估计。

4.1.2.2 排序操作

在9.5或更早版本中,排序操作(如ORDER BY)都是在本地服务器上处理的。即,本地服务器在排序操作之前从远程服务器拉取所有的目标行。让我们通过EXPLAIN来看一个包含ORDER BY子句的简单查询是如何被处理的。

localdb=# EXPLAIN SELECT * FROM tbl_a AS a WHERE a.id < 200 ORDER BY a.id;
                              QUERY PLAN                               
-----------------------------------------------------------------------
 Sort  (cost=200.59..202.72 rows=853 width=8)
   Sort Key: id
   ->  Foreign Scan on tbl_a a  (cost=100.00..159.06 rows=853 width=8)
(3 rows)

第6行:执行器将以下查询发送到远程服务器,然后获取查询结果。

SELECT id, data FROM public.tbl_a WHERE ((id < 200))

第4行:执行器在本地服务器上对拉取的tbl_a中的行进行排序。

这里是远程服务器的实际日志。

LOG:  statement: START TRANSACTION ISOLATION LEVEL REPEATABLE READ
LOG:  parse : DECLARE c1 CURSOR FOR
      SELECT id, data FROM public.tbl_a WHERE ((id < 200))
LOG:  bind : DECLARE c1 CURSOR FOR
      SELECT id, data FROM public.tbl_a WHERE ((id < 200))
LOG:  execute : DECLARE c1 CURSOR FOR
      SELECT id, data FROM public.tbl_a WHERE ((id < 200))
LOG:  statement: FETCH 100 FROM c1
LOG:  statement: FETCH 100 FROM c1
LOG:  statement: CLOSE c1
LOG:  statement: COMMIT TRANSACTION

在9.6或更高版本中,如果可行,postgres_fdw能在远程服务器上直接执行带ORDER BY子句的SELECT语句。

localdb=# EXPLAIN SELECT * FROM tbl_a AS a WHERE a.id < 200 ORDER BY a.id;
                           QUERY PLAN                            
-----------------------------------------------------------------
 Foreign Scan on tbl_a a  (cost=100.00..167.46 rows=853 width=8)
(1 row)

第4行:执行器将以下带ORDER BY子句的查询发送至远程服务器,然后拉取已排序的查询结果。

SELECT id, data FROM public.tbl_a WHERE ((id < 200)) ORDER BY id ASC NULLS LAST

这里是远程服务器的实际日志。

LOG:  statement: START TRANSACTION ISOLATION LEVEL REPEATABLE READ
LOG:  parse : DECLARE c1 CURSOR FOR
	   SELECT id, data FROM public.tbl_a WHERE ((id < 200)) ORDER BY id ASC NULLS LAST
LOG:  bind : DECLARE c1 CURSOR FOR
	   SELECT id, data FROM public.tbl_a WHERE ((id < 200)) ORDER BY id ASC NULLS LAST
LOG:  execute : DECLARE c1 CURSOR FOR
	   SELECT id, data FROM public.tbl_a WHERE ((id < 200)) ORDER BY id ASC NULLS LAST
LOG:  statement: FETCH 100 FROM c1
LOG:  statement: FETCH 100 FROM c1
LOG:  statement: CLOSE c1
LOG:  statement: COMMIT TRANSACTION

4.1.2.3 聚合函数

在9.6及更早版本中,类似于前一小节中提到的排序操作,AVG()COUNT()这样的聚合函数会在本地服务器上进行处理,如下所示。

localdb=# EXPLAIN SELECT AVG(data) FROM tbl_a AS a WHERE a.id < 200;
                              QUERY PLAN                               
-----------------------------------------------------------------------
 Aggregate  (cost=168.50..168.51 rows=1 width=4)
   ->  Foreign Scan on tbl_a a  (cost=100.00..166.06 rows=975 width=4)
(2 rows)

第5行:执行器将以下查询发送到远程服务器,然后拉取查询结果。

SELECT id, data FROM public.tbl_a WHERE ((id < 200))

第4行:执行器在本地服务器上对拉取的tbl_a行集求均值。

这一过程开销很大,因为发送大量的行会产生大量网络流量,而且需要很长时间。

这里是远程服务器的实际日志。

LOG:  statement: START TRANSACTION ISOLATION LEVEL REPEATABLE READ
LOG:  parse : DECLARE c1 CURSOR FOR
      SELECT data FROM public.tbl_a WHERE ((id < 200))
LOG:  bind : DECLARE c1 CURSOR FOR
      SELECT data FROM public.tbl_a WHERE ((id < 200))
LOG:  execute : DECLARE c1 CURSOR FOR
      SELECT data FROM public.tbl_a WHERE ((id < 200))
LOG:  statement: FETCH 100 FROM c1
LOG:  statement: FETCH 100 FROM c1
LOG:  statement: CLOSE c1
LOG:  statement: COMMIT TRANSACTION

在10或更高版本中,如果可行的话,postgres_fdw将在远程服务器上执行带聚合函数的SELECT语句。

localdb=# EXPLAIN SELECT AVG(data) FROM tbl_a AS a WHERE a.id < 200;
                     QUERY PLAN                      
-----------------------------------------------------
 Foreign Scan  (cost=102.44..149.03 rows=1 width=32)
   Relations: Aggregate on (public.tbl_a a)
(2 rows)

第4行:执行器将以下包含AVG()函数的查询发送至远程服务器,然后获取查询结果。

SELECT avg(data) FROM public.tbl_a WHERE ((id < 200))

这种处理方式显然更为高效,因为远程服务器会负责计算均值,仅发送单行结果。

这里是远程服务器的实际日志。

LOG:  statement: START TRANSACTION ISOLATION LEVEL REPEATABLE READ
LOG:  parse : DECLARE c1 CURSOR FOR
	   SELECT avg(data) FROM public.tbl_a WHERE ((id < 200))
LOG:  bind : DECLARE c1 CURSOR FOR
	   SELECT avg(data) FROM public.tbl_a WHERE ((id < 200))
LOG:  execute : DECLARE c1 CURSOR FOR
	   SELECT avg(data) FROM public.tbl_a WHERE ((id < 200))
LOG:  statement: FETCH 100 FROM c1
LOG:  statement: CLOSE c1
LOG:  statement: COMMIT TRANSACTION

下推

与上面的例子类似,下推(push-down) 指的是本地服务器允许一些操作在远程服务器上执行,例如聚合函数。

第五章 并发控制

当多个事务同时在数据库中运行时,并发控制是一种用于维持一致性隔离性的技术,一致性与隔离性是ACID的两个属性。

从宽泛的意义上来讲,有三种并发控制技术:多版本并发控制(Multi-version Concurrency Control, MVCC)严格两阶段锁定(Strict Two-Phase Locking, S2PL)乐观并发控制(Optimistic Concurrency Control, OCC),每种技术都有多种变体。在MVCC中,每个写操作都会创建一个新版本的数据项,并保留其旧版本。当事务读取数据对象时,系统会选择其中的一个版本,通过这种方式来确保各个事务间相互隔离。 MVCC的主要优势在于“读不会阻塞写,而写也不会阻塞读”,相反的例子是,基于S2PL的系统在写操作发生时会阻塞相应对象上的读操作,因为写入者获取了对象上的排他锁。 PostgreSQL和一些RDBMS使用一种MVCC的变体,名曰 快照隔离(Snapshot Isolation,SI)

一些RDBMS(例如Oracle)使用回滚段来实现快照隔离SI。当写入新数据对象时,旧版本对象先被写入回滚段,随后用新对象覆写至数据区域。 PostgreSQL使用更简单的方法:新数据对象被直接插入到相关表页中。读取对象时,PostgreSQL根据可见性检查规则(visibility check rules),为每个事务选择合适的对象版本作为响应。

SI中不会出现在ANSI SQL-92标准中定义的三种异常:脏读,不可重复读和幻读。但SI无法实现真正的可串行化,因为在SI中可能会出现串行化异常:例如 写偏差(write skew)只读事务偏差(Read-only Transaction Skew) 。需要注意的是:ANSI SQL-92标准中可串行化的定义与现代理论中的定义并不相同。为了解决这个问题,PostgreSQL从9.1版本之后添加了可串行化快照隔离(SSI,Serializable Snapshot Isolation),SSI可以检测串行化异常,并解决这种异常导致的冲突。因此,9.1版本之后的PostgreSQL提供了真正的SERIALIZABLE隔离等级(此外SQL Server也使用SSI,而Oracle仍然使用SI)。

本章包括以下四个部分:

  • 第1部分:第5.1~5.3节。

    这一部分介绍了理解后续部分所需的基本信息。

    第5.1和5.2节分别描述了事务标识和元组结构。第5.3节展示了如何插入,删除和更新元组。

  • 第2部分:第5.4~5.6节。

    这一部分说明了实现并发控制机制所需的关键功能。

    第5.4,5.5和5.6节描述了提交日志(clog),分别介绍了事务状态,事务快照和可见性检查规则。

  • 第3部分:第5.7~5.9节。

    这一部分使用具体的例子来介绍PostgreSQL中的并发控制。

    这一部分说明了如何防止ANSI SQL标准中定义的三种异常。第5.7节描述了可见性检查,第5.8节介绍了如何防止丢失更新,第5.9节简要描述了SSI。

  • 第4部分:第5.10节。

    这一部分描述了并发控制机制持久运行所需的几个维护过程。维护过程主要通过 清理过程(vacuum processing) 进行,清理过程将在第6章详细阐述。

并发控制包含着很多主题,本章重点介绍PostgreSQL独有的内容。故这里省略了锁模式与死锁处理的内容(相关信息请参阅官方文档)。


PostgreSQL中的事务隔离等级

PostgreSQL实现的事务隔离等级如下表所示:

隔离等级 脏读 不可重复读 幻读 串行化异常
读已提交 不可能 可能 可能 可能
可重复读[1] 不可能 不可能 PG中不可能,见5.7.2小节
但ANSI SQL中可能
可能
可串行化 不可能 不可能 不可能 不可能

[1]:在9.0及更早版本中,该级别被当做SERIALIZABLE,因为它不会出现ANSI SQL-92标准中定义的三种异常。 但9.1版中SSI的实现引入了真正的SERIALIZABLE级别,该级别已被改称为REPEATABLE READ

PostgreSQL对DML(SELECT, UPDATE, INSERT, DELETE等命令)使用SSI,对DDL(CREATE TABLE等命令)使用2PL。

5.1 事务标识

每当事务开始时,事务管理器就会为其分配一个称为 事务标识(transaction id, txid) 的唯一标识符。 PostgreSQL的txid是一个32位无符号整数,总取值约42亿。在事务启动后执行内置的txid_current()函数,即可获取当前事务的txid,如下所示。

testdb=# BEGIN;
BEGIN
testdb=# SELECT txid_current();
 txid_current 
--------------
          100
(1 row)

PostgreSQL保留以下三个特殊txid

  • 0 表示 无效(Invalid)txid
  • 1 表示 初始启动(Bootstrap)txid,仅用于数据库集群的初始化过程。
  • 2 表示 冻结(Frozen)txid,详情参考第5.10.1节。

txid可以相互比较大小。例如对于txid=100的事务,大于100的txid属于“未来”,且对于txid=100的事务而言都是 不可见(invisible) 的;小于100的txid属于“过去”,且对该事务可见,如图5.1(a)所示。

图5.1 PostgreSQL中的事务标识

因为txid在逻辑上是无限的,而实际系统中的txid空间不足(4字节取值空间约42亿),因此PostgreSQL将txid空间视为一个环。对于某个特定的txid,其前约21亿个txid属于过去,而其后约21亿个txid属于未来。如图5.1(b)所示。

所谓的txid回卷问题将在5.10.1节中介绍。

请注意,txid并非是在BEGIN命令执行时分配的。在PostgreSQL中,当执行BEGIN命令后的第一条命令时,事务管理器才会分配txid,并真正启动其事务。

5.2 元组结构

可以将表页中的堆元组分为两类:普通数据元组与TOAST元组。本节只会介绍普通元组。

堆元组由三个部分组成,即HeapTupleHeaderData结构,空值位图,以及用户数据,如图5.2所示。

图5.2 元组结构

HeapTupleHeaderData结构在src/include/access/htup_details.h中定义。

typedef struct HeapTupleFields
{
        TransactionId t_xmin;		   /* 插入事务的ID */
        TransactionId t_xmax;          /*删除或锁定事务的ID*/

        union
        {
                CommandId       t_cid;     /* 插入或删除的命令ID */
                TransactionId 	t_xvac;    /* 老式VACUUM FULL的事务ID */
        } t_field3;
} HeapTupleFields;

typedef struct DatumTupleFields
{
        int32          datum_len_;          /* 变长头部长度*/
        int32          datum_typmod;   	    /* -1或者是记录类型的标识 */
        Oid            datum_typeid;   	    /* 复杂类型的OID或记录ID */
} DatumTupleFields;

typedef struct HeapTupleHeaderData
{
        union
        {
                HeapTupleFields t_heap;
                DatumTupleFields t_datum;
        } t_choice;

        ItemPointerData t_ctid;         /* 当前元组,或更新元组的TID */

        /* 下面的字段必需与结构MinimalTupleData相匹配! */
        uint16          t_infomask2;    /* 属性与标记位 */
        uint16          t_infomask;     /* 很多标记位 */
        uint8           t_hoff;         /* 首部+位图+填充的长度 */
        /* ^ - 23 bytes - ^ */
        bits8           t_bits[1];      /* NULL值的位图 —— 变长的 */

        /* 本结构后面还有更多数据 */
} HeapTupleHeaderData;

typedef HeapTupleHeaderData *HeapTupleHeader;

虽然HeapTupleHeaderData结构包含七个字段,但后续部分中只需要了解四个字段即可。

  • t_xmin保存插入此元组的事务的txid
  • t_xmax保存删除或更新此元组的事务的txid。如果尚未删除或更新此元组,则t_xmax设置为0,即无效。
  • t_cid保存命令标识(command id, cid)cid意思是在当前事务中,执行当前命令之前执行了多少SQL命令,从零开始计数。例如,假设我们在单个事务中执行了三条INSERT命令BEGIN;INSERT;INSERT;INSERT;COMMIT;。如果第一条命令插入此元组,则该元组的t_cid会被设置为0。如果第二条命令插入此元组,则其t_cid会被设置为1,依此类推。
  • t_ctid保存着指向自身或新元组的元组标识符(tid)。如第1.3节中所述,tid用于标识表中的元组。在更新该元组时,其t_ctid会指向新版本的元组;否则t_ctid会指向自己。

5.3 元组的增删改

本节会介绍元组的增删改过程,并简要描述用于插入与更新元组的自由空间映射(Free Space Map, FSM)

这里主要关注元组,页首部与行指针不会在这里画出来,元组的具体表示如图5.3所示。

图5.3 元组的表示

5.3.1 插入

在插入操作中,新元组将直接插入到目标表的页面中,如图5.4所示。

图5.4 插入元组

假设元组是由txid=99的事务插入页面中的,在这种情况下,被插入元组的首部字段会依以下步骤设置。

Tuple_1

  • t_xmin设置为99,因为此元组由txid=99的事务所插入。
  • t_xmax设置为0,因为此元组尚未被删除或更新。
  • t_cid设置为0,因为此元组是由txid=99的事务所执行的第一条命令所插入的。
  • t_ctid设置为(0,1),指向自身,因为这是该元组的最新版本。

pageinspect

PostgreSQL自带了一个第三方贡献的扩展模块pageinspect,可用于检查数据库页面的具体内容。

testdb=# CREATE EXTENSION pageinspect;
CREATE EXTENSION
testdb=# CREATE TABLE tbl (data text);
CREATE TABLE
testdb=# INSERT INTO tbl VALUES(A);
INSERT 0 1
testdb=# SELECT lp as tuple, t_xmin, t_xmax, t_field3 as t_cid, t_ctid 
                FROM heap_page_items(get_raw_page(tbl, 0));
 tuple | t_xmin | t_xmax | t_cid | t_ctid 
-------+--------+--------+-------+--------
     1 |     99 |      0 |     0 | (0,1)
(1 row)

5.3.2 删除

在删除操作中,目标元组只是在逻辑上被标记为删除。目标元组的t_xmax字段将被设置为执行DELETE命令事务的txid。如图5.5所示。

图5.5 删除元组

假设Tuple_1txid=111的事务删除。在这种情况下,Tuple_1的首部字段会依以下步骤设置。

Tuple_1

  • t_xmax被设为111。

如果txid=111的事务已经提交,那么Tuple_1就不是必需的了。通常不需要的元组在PostgreSQL中被称为死元组(dead tuple)

死元组最终将从页面中被移除。清除死元组的过程被称为清理(VACUUM)过程第6章将介绍清理过程。

5.3.3 更新

在更新操作中,PostgreSQL在逻辑上实际执行的是删除最新的元组,并插入一条新的元组(图5.6)。

图5.6 两次更新同一行

假设由txid=99的事务插入的行,被txid=100的事务更新两次。

当执行第一条UPDATE命令时,Tuple_1t_xmax被设为txid 100,在逻辑上被删除;然后Tuple_2被插入;接下来重写Tuple_1t_ctid以指向Tuple_2Tuple_1Tuple_2的头部字段设置如下。

Tuple_1

  • t_xmax被设置为100。
  • t_ctid(0,1)被改写为(0,2)

Tuple_2

  • t_xmin被设置为100。
  • t_xmax被设置为0。
  • t_cid被设置为0。
  • t_ctid被设置为(0,2)

当执行第二条UPDATE命令时,和第一条UPDATE命令类似,Tuple_2被逻辑删除,Tuple_3被插入。Tuple_2Tuple_3的首部字段设置如下。

Tuple_2

  • t_xmax被设置为100。
  • t_ctid(0,2)被改写为(0,3)

Tuple_3

  • t_xmin被设置为100。
  • t_xmax被设置为0。
  • t_cid被设置为1。
  • t_ctid被设置为(0,3)

与删除操作类似,如果txid=100的事务已经提交,那么Tuple_1Tuple_2就成为了死元组,而如果txid=100的事务中止,Tuple_2Tuple_3就成了死元组。

5.3.4 空闲空间映射

插入堆或索引元组时,PostgreSQL使用表与索引相应的FSM来选择可供插入的页面。

如1.2.3节所述,表和索引都有各自的FSM。每个FSM存储着相应表或索引文件中每个页面可用空间容量的信息。

所有FSM都以后缀fsm存储,在需要时它们会被加载到共享内存中。

pg_freespacemap

扩展pg_freespacemap能提供特定表或索引上的空闲空间信息。以下查询列出了特定表中每个页面的空闲率。

testdb=# CREATE EXTENSION pg_freespacemap;
CREATE EXTENSION

testdb=# SELECT *, round(100 * avail/8192 ,2) as "freespace ratio"
                FROM pg_freespace(accounts);
 blkno | avail | freespace ratio 
-------+-------+-----------------
     0 |  7904 |           96.00
     1 |  7520 |           91.00
     2 |  7136 |           87.00
     3 |  7136 |           87.00
     4 |  7136 |           87.00
     5 |  7136 |           87.00
....

5.4 提交日志(clog)

PostgreSQL在提交日志(Commit Log, clog)中保存事务的状态。提交日志(通常称为clog)分配于共享内存中,并用于事务处理过程的全过程。

本节将介绍PostgreSQL中事务的状态,clog的工作方式与维护过程。

5.4.1 事务状态

PostgreSQL定义了四种事务状态,即:IN_PROGRESSCOMMITTEDABORTEDSUB_COMMITTED

前三种状态涵义显而易见。例如当事务正在进行时,其状态为IN_PROGRESS,依此类推。

SUB_COMMITTED状态用于子事务,本文省略了与子事务相关的描述。

5.4.2 提交日志如何工作

提交日志(下称clog)在逻辑上是一个数组,由共享内存中一系列8KB页面组成。数组的序号索引对应着相应事务的标识,而其内容则是相应事务的状态。clog的工作方式如图5.7所示。

图5.7 clog如何工作

T1txid 200提交;txid 200的状态从IN_PROGRESS变为COMMITTEDT2txid 201中止;txid 201的状态从IN_PROGRESS变为ABORTED

txid不断前进,当clog空间耗尽无法存储新的事务状态时,就会追加分配一个新的页面。

当需要获取事务的状态时,PostgreSQL将调用相应内部函数读取clog,并返回所请求事务的状态。(参见第5.7.1节中的提示位(Hint Bits)

5.4.3 提交日志的维护

当PostgreSQL关机或执行存档过程时,clog数据会写入至pg_clog子目录下的文件中(注意在10版本中,pg_clog被重命名为pg_xact)。这些文件被命名为00000001等等。文件的最大尺寸为256 KB。例如当clog使用八个页面时,从第一页到第八页的总大小为64 KB,这些数据会写入到文件0000(64 KB)中;而当clog使用37个页面时(296 KB),数据则会写入到00000001两个文件中,其大小分别为256 KB和40 KB。

当PostgreSQL启动时会加载存储在pg_clogpg_xact)中的文件,用其数据初始化clog。

clog的大小会不断增长,因为只要clog一填满就会追加新的页面。但并非所有数据都是必需的。第6章中描述的清理过程会定期删除这些不需要的旧数据(clog页面和文件),有关删除clog数据的详情请参见第6.4节。

5.5 事务快照

**事务快照(transaction snapshot)**是一个数据集,存储着某个特定事务在某个特定时间点所看到的事务状态信息:哪些事务处于活跃状态。这里活跃状态意味着事务正在进行中,或还没有开始。

事务快照在PostgreSQL内部的文本表示格式定义为100:100:。举个例子,这里100:100:意味着txid < 100的事务处于非活跃状态,而txid ≥ 100的事务处于活跃状态。下文都将使用这种便利形式来表示。如果读者还不熟悉这种形式,请参阅下文。

内置函数txid_current_snapshot及其文本表示

函数txid_current_snapshot显示当前事务的快照。

testdb=# SELECT txid_current_snapshot();
 txid_current_snapshot 
-----------------------
 100:104:100,102
(1 row)

txid_current_snapshot的文本表示是xmin:xmax:xip_list,各部分描述如下。

  • xmin

    最早仍然活跃的事务的txid。所有比它更早的事务txid < xmin要么已经提交并可见,要么已经回滚并生成死元组。

  • xmax

    第一个尚未分配的txid。所有txid ≥ xmax的事务在获取快照时尚未启动,因而其结果对当前事务不可见。

  • xip_list

    获取快照时活跃事务txid列表。该列表仅包括xminxmax之间的txid

    例如,在快照100:104:100,102中,xmin100xmax104,而xip_list100,102

以下显示了两个具体的示例:

图5.8 事务快照的表示样例

第一个例子是100:100:,如图图5.8(a)所示,此快照表示:

  • 因为xmin为100,因此txid < 100的事务是非活跃的
  • 因为xmax为100,因此txid ≥ 100的事务是活跃的

第二个例子是100:104:100,102,如图5.8(b)所示,此快照表示:

  • txid < 100的事务不活跃。
  • txid ≥ 104的事务是活跃的。
  • txid等于100和102的事务是活跃的,因为它们在xip_list中,而txid等于101和103的事务不活跃。

事务快照是由事务管理器提供的。在READ COMMITTED隔离级别,事务在执行每条SQL时都会获取快照;其他情况下(REPEATABLE READSERIALIZABLE隔离级别),事务只会在执行第一条SQL命令时获取一次快照。获取的事务快照用于元组的可见性检查,如第5.7节所述。

使用获取的快照进行可见性检查时,所有活跃的事务都必须被当成IN PROGRESS的事务等同对待,无论它们实际上是否已经提交或中止。这条规则非常重要,因为它正是READ COMMITTEDREPEATABLE READ/SERIALIZABLE隔离级别中表现差异的根本来源,我们将在接下来几节中频繁回到这条规则上来。

在本节的剩余部分中,我们会通过一个具体的场景来描述事务与事务管理器,如图5.9所示。

图5.9 事务管理器与事务

事务管理器始终保存着当前运行的事务的有关信息。假设三个事务一个接一个地开始,并且Transaction_ATransaction_B的隔离级别是READ COMMITTEDTransaction_C的隔离级别是REPEATABLE READ

  • T1: Transaction_A启动并执行第一条SELECT命令。执行第一个命令时,Transaction_A请求此刻的txid和快照。在这种情况下,事务管理器分配txid=200,并返回事务快照200:200:

  • T2: Transaction_B启动并执行第一条SELECT命令。事务管理器分配txid=201,并返回事务快照200:200:,因为Transaction_A(txid=200)正在进行中。因此无法从Transaction_B中看到Transaction_A

  • T3: Transaction_C启动并执行第一条SELECT命令。事务管理器分配txid=202,并返回事务快照200:200:,因此不能从Transaction_C中看到Transaction_ATransaction_B

  • T4: Transaction_A已提交。事务管理器删除有关此事务的信息。

  • T5: Transaction_BTransaction_C执行它们各自的SELECT命令。

    Transaction_B需要一个新的事务快照,因为它使用了READ COMMITTED隔离等级。在这种情况下,Transaction_B获取新快照201:201:,因为Transaction_A(txid=200)已提交。因此Transaction_A的变更对Transaction_B可见了。

    Transaction_C不需要新的事务快照,因为它处于REPEATABLE READ隔离等级,并继续使用已获取的快照,即200:200:。因此,Transaction_A的变更仍然对Transaction_C不可见。

5.6 可见性检查规则

可见性检查规则是一组规则,用于确定一条元组是否对一个事务可见,可见性检查规则会用到元组的t_xmint_xmax,提交日志clog,以及已获取的事务快照。这些规则太复杂,无法详细解释,故本书只列出了理解后续内容所需的最小规则子集。在下文中省略了与子事务相关的规则,并忽略了关于t_ctid的讨论,比如我们不会考虑在同一个事务中对一条元组多次重复更新的情况。

所选规则有十条,可以分类为三种情况。

5.6.1 t_xmin的状态为ABORTED

t_xmin状态为ABORTED的元组始终不可见(规则1),因为插入此元组的事务已中止。

			/* 创建元组的事务已经中止 */
Rule 1:     IF t_xmin status is ABORTED THEN
                RETURN Invisible
            END IF

该规则明确表示为以下数学表达式。

  • 规则1If Status(t_xmin) = ABORTED ⇒ Invisible

5.6.2 t_xmin的状态为IN_PROGRESS

t_xmin状态为IN_PROGRESS的元组基本上是不可见的(规则3和4),但在一个条件下除外。

            /* 创建元组的事务正在进行中 */
            IF t_xmin status is IN_PROGRESS THEN
                /* 当前事务自己创建了本元组 */
            	IF t_xmin = current_txid THEN
                    /* 该元组没有被标记删除,则应当看见本事务自己创建的元组 */
Rule 2:             IF t_xmax = INVALID THEN 
                        RETURN Visible /* 例外,被自己创建的未删元组可见 */
Rule 3:             ELSE  
                    /* 这条元组被当前事务自己创建后又删除掉了,故不可见 */
                        RETURN Invisible
                    END IF
Rule 4:         ELSE   /* t_xmin ≠ current_txid */
                    /* 其他运行中的事务创建了本元组 */
		            RETURN Invisible
                END IF
            END IF

如果该元组被另一个进行中的事务插入(t_xmin对应事务状态为IN_PROGRESS),则该元组显然是不可见的(规则4)。

如果t_xmin等于当前事务的txid(即,是当前事务插入了该元组),且t_xmax ≠ 0,则该元组是不可见的,因为它已被当前事务更新或删除(规则3)。

例外是,当前事务插入此元组且t_xmax无效(t_xmax = 0)的情况。 在这种情况下,此元组对当前事务中可见(规则2)。

  • 规则2If Status(t_xmin) = IN_PROGRESS ∧ t_xmin = current_txid ∧ t_xmax = INVAILD ⇒ Visible
  • 规则3If Status(t_xmin) = IN_PROGRESS ∧ t_xmin = current_txid ∧ t_xmax ≠ INVAILD ⇒ Invisible
  • 规则4If Status(t_xmin) = IN_PROGRESS ∧ t_xmin ≠ current_txid ⇒ Invisible

5.6.3 t_xmin的状态为COMMITTED

t_xmin状态为COMMITTED的元组是可见的(规则 6,8和9),但在三个条件下除外。

            /* 创建元组的事务已经提交 */
            IF t_xmin status is COMMITTED THEN
            	/* 创建元组的事务在获取的事务快照中处于活跃状态,创建无效,不可见 */
Rule 5:         IF t_xmin is active in the obtained transaction snapshot THEN
                      RETURN Invisible
                /* 元组被删除,但删除元组的事务中止了,删除无效,可见 */
                /* 创建元组的事务已提交,且非活跃,元组也没有被标记为删除,则可见 */
Rule 6:         ELSE IF t_xmax = INVALID OR status of t_xmax is ABORTED THEN
                      RETURN Visible
                /* 元组被删除,但删除元组的事务正在进行中,分情况 */
            	ELSE IF t_xmax status is IN_PROGRESS THEN
                    /* 如果恰好是被本事务自己删除的,删除有效,不可见 */
Rule 7:             IF t_xmax =  current_txid THEN
                        RETURN Invisible
                    /* 如果是被其他事务删除的,删除无效,可见 */
Rule 8:             ELSE  /* t_xmax ≠ current_txid */
                        RETURN Visible
                    END IF
                /* 元组被删除,且删除元组的事务已经提交 */
            	ELSE IF t_xmax status is COMMITTED THEN
                    /* 删除元组的事务在获取的事务快照中处于活跃状态,删除无效,不可见 */
Rule 9:             IF t_xmax is active in the obtained transaction snapshot THEN
                        RETURN Visible
Rule 10:            ELSE /* 删除有效,可见 */
                        RETURN Invisible
                    END IF
            	 END IF
            END IF

规则6是显而易见的,因为t_xmaxINVALID,或者t_xmax对应事务已经中止,相应元组可见。三个例外条件及规则8与规则9的描述如下。

第一个例外情况是t_xmin在获取的事务快照中处于活跃状态(规则5)。在这种情况下,这条元组是不可见的,因为t_xmin应该被视为正在进行中(取快照时创建该元组的事务尚未提交,因此对于REPEATABLE READ以及更高隔离等级而言,即使在判断时创建该元组的事务已经提交,但其结果仍然不可见)。

第二个例外情况是t_xmax是当前的txid(规则7)。这种情况与规则3类似,此元组是不可见的,因为它已经被此事务本身更新或删除。

相反,如果t_xmax的状态是IN_PROGRESS并且t_xmax不是当前的txid(规则8),则元组是可见的,因为它尚未被删除(因为删除该元组的事务尚未提交)。

第三个例外情况是t_xmax的状态为COMMITTED,且t_xmax在获取的事务快照中是非活跃的(规则10)。在这种情况下该元组不可见,因为它已被另一个事务更新或删除。

相反,如果t_xmax的状态为COMMITTED,但t_xmax在获取的事务快照中处于活跃状态(规则9),则元组可见,因为t_xmax对应的事务应被视为正在进行中,删除尚未提交生效。

  • 规则5If Status(t_xmin) = COMMITTED ∧ Snapshot(t_xmin) = active ⇒ Invisible
  • 规则6If Status(t_xmin) = COMMITTED ∧ (t_xmax = INVALID ∨ Status(t_xmax) = ABORTED) ⇒ Visible
  • 规则7If Status(t_xmin) = COMMITTED ∧ Status(t_xmax) = IN_PROGRESS ∧ t_xmax = current_txid ⇒ Invisible
  • 规则8If Status(t_xmin) = COMMITTED ∧ Status(t_xmax) = IN_PROGRESS ∧ t_xmax ≠ current_txid ⇒ Visible
  • 规则9If Status(t_xmin) = COMMITTED ∧ Status(t_xmax) = COMMITTED ∧ Snapshot(t_xmax) = active ⇒ Visible
  • 规则10If Status(t_xmin) = COMMITTED ∧ Status(t_xmax) = COMMITTED ∧ Snapshot(t_xmax) ≠ active ⇒ Invisible

5.7 可见性检查

本节描述了PostgreSQL执行可见性检查的流程。可见性检查(Visiblity Check),即如何为给定事务挑选堆元组的恰当版本。本节还介绍了PostgreSQL如何防止ANSI SQL-92标准中定义的异常:脏读,可重读和幻读。

5.7.1 可见性检查

图5.10中的场景描述了可见性检查的过程。

图5.10 可见性检查场景一例

在图5.10所示的场景中,SQL命令按以下时序执行。

  • T1:启动事务(txid=200)
  • T2:启动事务(txid=201)
  • T3:执行txid=200和201的事务的SELECT命令
  • T4:执行txid=200的事务的UPDATE命令
  • T5:执行txid=200和201的事务的SELECT命令
  • T6:提交txid=200的事务
  • T7:执行txid=201的事务的SELECT命令

为了简化描述,假设这里只有两个事务,即txid=200201的事务。txid=200的事务的隔离级别是READ COMMITTED,而txid=201的事务的隔离级别是READ COMMITTEDREPEATABLE READ

我们将研究SELECT命令是如何为每条元组执行可见性检查的。

T3的SELECT命令:

在T3时间点,表tbl中只有一条元组Tuple_1,按照规则6,这条元组是可见的,因此两个事务中的SELECT命令都返回"Jekyll"

  • Rule 6(Tuple_1) ⇒ Status(t_xmin:199) = COMMITTED ∧ t_xmax = INVALID ⇒ Visible

    创建元组Tuple_1的事务199已经提交,且该元组并未被标记删除,因此根据规则6,对当前事务可见。

testdb=# -- txid 200
testdb=# SELECT * FROM tbl;
  name  
--------
 Jekyll
(1 row)
testdb=# -- txid 201
testdb=# SELECT * FROM tbl;
  name  
--------
 Jekyll
(1 row)

T5的SELECT命令

首先来看一下由txid=200的事务所执行的SELECT命令。根据规则7,Tuple_1不可见,根据规则2,Tuple_2可见;因此该SELECT命令返回"Hyde"

  • Rule 7(Tuple_1): Status(t_xmin:199) = COMMITTED ∧ Status(t_xmax:200) = IN_PROGRESS ∧ t_xmax:200 = current_txid:200 ⇒ Invisible

    创建元组Tuple_1的事务199已经提交,且该元组被当前事务标记删除,根据规则7,Tuple_1对当前事务不可见。

  • Rule 2(Tuple_2): Status(t_xmin:200) = IN_PROGRESS ∧ t_xmin:200 = current_txid:200 ∧ t_xmax = INVAILD ⇒ Visible

    创建元组Tuple_2的事务200正在进行,而且就是当前事务自己,根据规则2,Tuple_2对当前事务可见。

testdb=# -- txid 200
testdb=# SELECT * FROM tbl;
 name 
------
 Hyde
(1 row)

另一方面,在由txid=201的事务所执行的SELECT命令中,Tuple_1基于规则8确定可见,而Tuple_2基于规则4不可见;因此该SELECT命令返回"Jekyll"

  • Rule 8(Tuple_1): Status(t_xmin:199) = COMMITTED ∧ Status(t_xmax:200) = IN_PROGRESS ∧ t_xmax:200 ≠ current_txid:201 ⇒ Visible

    元组Tuple_1由已提交事务199创建,由活跃事务200标记删除,但删除效果对当前事务201不可见。因此根据规则8,Tuple_1可见。

  • Rule 4(Tuple_2): Status(t_xmin:200) = IN_PROGRESS ∧ t_xmin:200 ≠ current_txid:201 ⇒ Invisible

    元组Tuple_2由活跃事务200创建,且不是由当前事务自己创建的,故根据规则4,Tuple_2不可见。

testdb=# -- txid 201
testdb=# SELECT * FROM tbl;
  name  
--------
 Jekyll
(1 row)

如果更新的元组在本事务提交之前被其他事务看见,这种现象被称为脏读(Dirty Reads),也称为写读冲突(wr-conflicts)。 但如上所示,PostgreSQL中任何隔离级别都不会出现脏读。

T7的SELECT命令

在下文中,描述了T7的SELECT命令在两个隔离级别中的行为。

首先来研究txid=201的事务处于READ COMMITTED隔离级别时的情况。 在这种情况下,txid=200的事务被视为已提交,因为在这个时间点获取的事务快照是201:201:。因此Tuple_1根据规则10不可见,Tuple_2根据规则6可见,SELECT命令返回"Hyde"

  • Rule 10(Tuple_1): Status(t_xmin:199) = COMMITTED ∧ Status(t_xmax:200) = COMMITTED ∧ Snapshot(t_xmax:200) ≠ active ⇒ Invisible

    元组Tuple_1由已提交事务199创建,由非活跃的已提交事务200标记删除,Tuple_1按照规则10不可见。

  • Rule 6(Tuple_2): Status(t_xmin:200) = COMMITTED ∧ t_xmax = INVALID ⇒ Visible

    元组Tuple_2由已提交事务200创建,且未被标记为删除,故Tuple_2按照规则6可见。

testdb=# -- txid 201 (READ COMMITTED)
testdb=# SELECT * FROM tbl;
 name 
------
 Hyde
(1 row)

这里需要注意,事务201中的SELECT命令,在txid=200的事务提交前后中时的执行结果是不一样的,这种现象通常被称作不可重复读(Non-Repeatable Read)

相反的是,当txid=201的事务处于REPEATABLE READ级别时,即使在T7时刻txid=200的事务实际上已经提交,它也必须被视作仍在进行,因而获取到的事务快照是200:200:。 根据规则9,Tuple_1是可见的,根据规则5,Tuple_2不可见,所以最后SELECT命令会返回"Jekyll"。 请注意在REPEATABLE READ(和SERIALIZABLE)级别中不会发生不可重复读。

  • Rule9(Tuple_1): Status(t_xmin:199) = COMMITTED ∧ Status(t_xmax:200) = COMMITTED ∧ Snapshot(t_xmax:200) = active ⇒ Visible

    元组Tuple_1由已提交事务199创建,由已提交事务200标记删除,但因为事务200位于当前事物的活跃事务快照中(也就是在当前事物201开始执行并获取事务级快照时,事物200还未提交),因此删除对当前事务尚未生效,根据规则9,Tuple_1可见。

    Tuple_1按照规则10不可见。

  • Rule5(Tuple_2): Status(t_xmin:200) = COMMITTED ∧ Snapshot(t_xmin:200) = active ⇒ Invisible

    元组Tuple_2由已提交事务200创建,但该事务在本事务快照中属于活跃事务(即在本事务开始前还未提交),因此事务200的变更对本事务尚不可见,按照规则5,Tuple_2不可见。

testdb=# -- txid 201 (REPEATABLE READ)
testdb=# SELECT * FROM tbl;
  name  
--------
 Jekyll
(1 row)

提示位(Hint Bits)

PostgreSQL在内部提供了三个函数TransactionIdIsInProgressTransactionIdDidCommitTransactionIdDidAbort,用于获取事务的状态。这些函数被设计为尽可能减少对clog的频繁访问。 尽管如此,如果在检查每条元组时都执行这些函数,那这里很可能会成为一个性能瓶颈。

为了解决这个问题,PostgreSQL使用了提示位(hint bits),如下所示。

#define HEAP_XMIN_COMMITTED       0x0100   /* 元组xmin对应事务已提交 */
#define HEAP_XMIN_INVALID         0x0200   /* 元组xmin对应事务无效/中止 */
#define HEAP_XMAX_COMMITTED       0x0400   /* 元组xmax对应事务已提交 */
#define HEAP_XMAX_INVALID         0x0800   /* 元组xmax对应事务无效/中止 */

在读取或写入元组时,PostgreSQL会择机将提示位设置到元组的t_informask字段中。 举个例子,假设PostgreSQL检查了元组的t_xmin对应事务的状态,结果为COMMITTED。 在这种情况下,PostgreSQL会在元组的t_infomask中置位一个HEAP_XMIN_COMMITTED标记,表示创建这条元组的事务已经提交了。 如果已经设置了提示位,则不再需要调用TransactionIdDidCommitTransactionIdDidAbort来获取事务状态了。 因此PostgreSQL能高效地检查每个元组t_xmint_xmax对应事务的状态。

5.7.2 PostgreSQL可重复读等级中的幻读

ANSI SQL-92标准中定义的REPEATABLE READ隔离等级允许出现幻读(Phantom Reads), 但PostgreSQL实现的REPEATABLE READ隔离等级不允许发生幻读。 在原则上,快照隔离中不允许出现幻读。

假设两个事务Tx_ATx_B同时运行。 它们的隔离级别分别为READ COMMITTEDREPEATABLE READ,它们的txid分别为100和101。两个事务一前一后接连开始,首先Tx_A插入一条元组,并提交。 插入的元组的t_xmin为100。接着,Tx_B执行SELECT命令;但根据规则5,Tx_A插入的元组对Tx_B是不可见的。因此不会发生幻读。

  • Rule5(new tuple): Status(t_xmin:100) = COMMITTED ∧ Snapshot(t_xmin:100) = active ⇒ Invisible

    新元组由已提交的事务Tx_A创建,但Tx_ATx_B的事务快照中处于活跃状态,因此根据规则5,新元组对Tx_B不可见。

    Tx_A: txid = 100 Tx_B: txid = 101
    START TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION ISOLATION LEVEL REPEATABLE READ;
    INSERT tbl(id, data)
    COMMIT;
    SELECT * FROM tbl WHERE id=1;
    (0 rows)

    5.8 防止丢失更新

丢失更新(Lost Update),又被称作写-写冲突(ww-conflict),是事务并发更新同一行时所发生的异常,REPEATABLE READSERIALIZABLE隔离等级必须阻止该异常的出现。 本节将会介绍PostgreSQL是如何防止丢失更新的,并举一些例子来说明。

5.8.1 并发UPDATE命令的行为

执行UPDATE命令时,内部实际上调用了ExecUpdate函数。 ExecUpdate的伪代码如下所示:

伪代码:ExecUpdate
(1) FOR row in 本UPDATE命令待更新的所有行集
(2)     WHILE true
            /* 第一部分 */
(3)         IF 目标行 正在 被更新 THEN
(4)	            等待 更新目标行的事务 结束(提交或中止)

(5)	            IF (更新目标行的事务已提交)
   	                AND (当前事务隔离级别是 可重复读或可串行化) THEN
(6)	                    中止当前事务  /* 以先更新者为准 */
	            ELSE 
(7)                     跳转步骤(2)
	            END IF

            /* 第二部分 */
(8)         ELSE IF 目标行 已经 被另一个并发事务所更新 THEN
(9)	            IF (当前事务的隔离级别是 读已提交 ) THEN
(10)	            更新目标行
	            ELSE
(11)	            中止当前事务  /* 先更新者为准 */
                END IF

            /* 第三部分 */
            /* 目标行没有被修改过,或者被一个 已经结束 的事务所更新 */
            ELSE  
(12)	            更新目标行
            END IF
        END WHILE 
    END FOR 
  1. 获取被本UPDATE命令更新的每一行,并对每一行依次执行下列操作。
  2. 重复以下过程,直到目标行更新完成,或本事务中止。
  3. 如果目标行正在被更新则进入步骤(4),否则进入步骤(8)。
  4. 等待正在更新目标行的事务结束,因为PostgreSQL在SI中使用了**以先更新者为准(first-updater-win)**的方案。
  5. 如果更新目标行的事务已经提交,且当前事务的隔离等级为可重复读或可串行化则进入步骤(6),否则进入步骤(7)。
  6. 中止本事务,以防止丢失更新。(因为另一个事务已经对目标行进行了更新并提交)
  7. 跳转回步骤(2),并对目标行进行新一轮的更新尝试。
  8. 如果目标行已被另一个并发事务所更新则进入步骤(9),否则进入步骤(12)。
  9. 如果当前事务的隔离级别为读已提交则进入步骤(10),否则进入步骤(11)。
  10. 更新目标行,并回到步骤(1),处理下一条目标行。
  11. 中止当前事务,以防止丢失更新。
  12. 更新目标行,并回到步骤(1),因为目标行尚未被修改过,或者虽然已经被更新,但更新它的事务已经结束。已终止的事务更新,即存在写写冲突。

此函数依次为每个待更新的目标行执行更新操作。 它有一个外层循环来更新每一行,而内部while循环则包含了三个分支,分支条件如图5.11所示。

图5.11 ExecUpdate内部的三个部分

Fig. 5.11. Three internal blocks in ExecUpdate.

  1. 目标行正在被更新,如图5.11[1]所示

    “正在被更新”意味着该行正在被另一个事务同时更新,且另一个事务尚未结束。在这种情况下,当前事务必须等待更新目标行的事务结束,因为PostgreSQL的SI实现采用**以先更新者为准(first-updater-win)**的方案。例如,假设事务Tx_ATx_B同时运行,且Tx_B尝试更新某一行;但Tx_A已更新了这一行,且仍在进行中。在这种情况下Tx_B会等待Tx_A结束。

    在更新目标行的事务提交后,当前事务的更新操作将完成等待继续进行。如果当前事务处于READ COMMITTED隔离等级,则会更新目标行;而若处于REPEATABLE READSERIALIZABLE隔离等级时,当前事务则会立即中止,以防止丢失更新。

  2. 目标行已经被另一个并发事务所更新,如图5.11[2]所示

    当前事务尝试更新目标元组,但另一个并发事务已经更新了目标行并提交。在这种情况下,如果当前事务处于READ COMMITTED级别,则会更新目标行;否则会立即中止以防止丢失更新。

  3. 没有冲突,如图5.11[3]所示

    当没有冲突时,当前事务可以直接更新目标行。

以先更新者为准 / 以先提交者为准

PostgreSQL基于SI的并发控制机制采用**以先更新者为准(first-updater-win)方案。 相反如下一节所述,PostgreSQL的SSI实现使用以先提交者为准(first-commiter-win)**方案。

5.8.2 例子

以下是三个例子。 第一个和第二个例子展示了目标行正在被更新时的行为,第三个例子展示了目标行已经被更新的行为。

例1

事务Tx_ATx_B更新同一张表中的同一行,它们的隔离等级均为READ COMMITTED

Tx_A Tx_B
START TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION START TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION
UPDATE tbl SET name = 'Hyde';
UPDATE 1
UPDATE tbl SET name = 'Utterson';
– 本事务进入阻塞状态,等待Tx_A完成
COMMIT; Tx_A提交,阻塞解除
UPDATE 1

Tx_B的执行过程如下:

  1. 在执行UPDATE命令之后,Tx_B应该等待Tx_A结束,因为目标元组正在被Tx_A更新(ExecUpdate步骤4)
  2. Tx_A提交后,Tx_B尝试更新目标行(ExecUpdate步骤7)
  3. ExecUpdate内循环第二轮中,目标行被Tx_B更新(ExecUpdate步骤2,8,9,10)。

例2

Tx_ATx_B更新同一张表中的同一行,它们的隔离等级分别为读已提交和可重复读。

Tx_A Tx_B
START TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION START TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION
UPDATE tbl SET name = 'Hyde';
UPDATE 1
UPDATE tbl SET name = 'Utterson';
– 本事务进入阻塞状态,等待Tx_A完成
COMMIT; Tx_A提交,阻塞解除
ERROR:couldn't serialize access due to concurrent update

Tx_B的执行过程如下:

  1. Tx_B在执行UPDATE命令后阻塞,等待Tx_A终止(ExecUpdate步骤4)。
  2. Tx_A提交后,Tx_B会中止以解决冲突。因为目标行已经被更新,且当前事务Tx_B的隔离级别为可重复读(ExecUpdate步骤5,6)。

例3

Tx_B(可重复读)尝试更新已经被Tx_A更新的目标行,且Tx_A已经提交。 在这种情况下,Tx_B会中止(ExecUpdate中的步骤2,8,9,11)。

Tx_A Tx_B
START TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION START TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION
UPDATE tbl SET name = 'Hyde';
UPDATE 1
COMMIT;
UPDATE tbl SET name = 'Utterson';
ERROR:couldn't serialize access due to concurrent update

5.9 可串行化快照隔离

从版本9.1开始,可串行化快照隔离(SSI)已经嵌入到快照隔离(SI)中,用以实现真正的可串行化隔离等级。SSI解释起来过于复杂,故本书仅解释其概要,详细信息请参阅文献[2]。

下文使用了以下技术术语而未加定义。 如果读者不熟悉这些术语,请参阅[1,3]。

  • 前趋图(precedence graph),亦称作依赖图(dependency graph)串行化图(serialization graph)
  • 串行化异常(serialization anomalies)(例如,写偏差(Write-Skew)

5.9.1 SSI实现的基本策略

如果前趋图中存在由某些冲突构成的环,则会出现串行化异常。 这里使用一种最简单的异常来解释,即写偏差(Write-Skew)

图5.12(1)展示了一种调度方式。 这里Transaction_A读取了Tuple_BTransaction_B读取了Tuple_A。 然后Transaction_ATuple_ATransaction_BTuple_B。 在这种情况下存在两个读-写冲突(rw-conflict),它们在该调度的前趋图中构成了一个环,如图5.12(2)所示。 故该调度存在串行化异常,即写偏差。

图5.12 存在写偏差的调度及其前趋图

Fig. 5.11. Three internal blocks in ExecUpdate.

从概念上讲,存在三种类型的冲突:写-读冲突(wr-conflicts)(脏读),写-写冲突(ww-conflicts)(丢失更新),以及读写冲突(rw-conflicts)。 但是这里无需考虑写-读冲突与写-写冲突,因为如前所述,PostgreSQL可以防止此类冲突。 因此PostgreSQL中的SSI实现只需要考虑读-写冲突。

PostgreSQL在SSI实现中采用以下策略:

  1. 使用SIREAD锁记录事务访问的所有对象(元组,页面,关系)。
  2. 当写入任何堆元组/索引元组时,使用SIREAD锁检测读-写冲突。
  3. 如果从读-写冲突中检测出串行化异常,则中止事务。

5.9.2 PostgreSQL的SSI实现

为了实现上述策略,PostgreSQL实现了很多数据结构与函数。 但这里我们只会使用两种数据结构:SIREAD锁与读-写冲突来描述SSI机制。它们都储存在共享内存中。

为简单起见,本文省略了一些重要的数据结构,例如SERIALIZABLEXACT。 因此对CheckTargetForConflictOutCheckTargetForConflictInPreCommit_CheckForSerializationFailure等函数的解释也极为简化。比如本文虽然指出哪些函数能检测到冲突;但并没有详细解释如何检测冲突。 如果读者想了解详细信息,请参阅源代码:predicate.c

SIREAD锁

SIREAD锁,在内部又被称为谓词锁(predicate lock),是一个由对象与(虚拟)事务标识构成的二元组,存储着哪个事务访问了哪个对象的相关信息。注意这里省略了对虚拟事务标识的描述,使用txid而非虚拟txid能大幅简化说明。

SERIALIZABLE模式下只要执行DML命令,就会通过CheckTargetForConflictsOut函数创建出SIREAD锁。举个例子,如果txid=100的事务读取给定表的Tuple_1,则会创建一个SIREAD锁{Tuple_1,{100}}。如果是其他事务,例如txid=101读取了Tuple_1,则SIREAD锁会更新为{Tuple_1,{100,101}}。请注意,读取索引页时也会创建SIREAD锁,因为在使用了第7.2节中将描述的**仅索引扫描(Index-Only Scan)**时,数据库只会读取索引页而不读取表页。

SIREAD锁有三个级别:元组,页面,以及关系。如果单个页面内所有元组的SIREAD锁都被创建,则它们会聚合为该页上的单个SIREAD锁,原有相关元组上的SIREAD锁都会被释放(删除),以减少内存空间占用。对读取的页面也是同理。

当为索引创建SIREAD锁时,一开始会创建页级别的SIREAD锁。当使用顺序扫描时,无论是否存在索引,是否存在WHERE子句,一开始都会创建关系级别的SIREAD锁。请注意在某些情况下,这种实现可能会导致串行化异常的误报(假阳性(false-positive)),细节将在第5.9.4节中描述。

读-写冲突

读-写冲突是一个三元组,由SIREAD锁,以及两个分别读写该SIREAD锁的事务txid构成。

当在可串行化模式下执行INSERTUPDATEDELETE命令时,函数CheckTargetForConflictsIn会被调用,并检查SIREAD锁来检测是否存在冲突,如果有就创建一个读-写冲突。

举个例子,假设txid = 100的事务读取了Tuple_1,然后txid=101的事务更新了Tuple_1。在这种情况下,txid=101的事务中的UPDATE命令会调用CheckTargetForConflictsIn函数,并检测到在Tuple_1上存在txid=100,101之间的读-写冲突,并创建rw-conflict{r = 100, w = 101, {Tuple_1}}

CheckTargetForConflictOutCheckTargetForConflictIn函数,以及在可串行化模式中执行COMMIT命令会触发的PreCommit_CheckForSerializationFailure函数,都会使用创建的读写冲突来检查串行化异常。如果它们检测到异常,则只有先提交的事务会真正提交,其他事务会中止(依据**以先提交者为准(first-committer-win)**策略)。

5.9.3 SSI的原理

本节将描述SSI如何解决写偏差异常,下面将使用一个简单的表tbl为例。

testdb=# CREATE TABLE tbl (id INT primary key, flag bool DEFAULT false);
testdb=# INSERT INTO tbl (id) SELECT generate_series(1,2000);
testdb=# ANALYZE tbl;

事务Tx_ATx_B执行以下命令,如图5.13所示。

图5.13 写偏差场景一例

写偏

假设所有命令都使用索引扫描。 因此当执行命令时,它们会同时读取堆元组与索引页,每个索引页都包含指向相应堆元组的索引元组,如图5.14所示。

图5.14 例子中索引与表的关系

索引和表的关系

  • T1Tx_A执行SELECT命令,该命令读取堆元组Tuple_2000,以及包含主键的索引页Pkey_2
  • T2Tx_B执行SELECT命令。 此命令读取堆元组Tuple_1,以及包含主键的索引页Pkey_1
  • T3Tx_A执行UPDATE命令,更新Tuple_1
  • T4Tx_B执行UPDATE命令,更新Tuple_2000
  • T5Tx_A提交。
  • T6Tx_B提交,然而由于写偏差异常而被中止。

图5.15展示了PostgreSQL如何检测和解决上述场景中描述的写偏差异常。

图5.15 SIREA锁与读-写冲突,图5.13场景中的调度方式

SIREAD锁和rw-conflict

  • T1: 执行Tx_ASELECT命令时,CheckTargetForConflictsOut会创建SIREAD锁。在本例中该函数会创建两个SIREAD锁:L1L2L1L2分别与Pkey_2Tuple_2000相关联。

  • T2: 执行Tx_BSELECT命令时,CheckTargetForConflictsOut会创建两个SIREAD锁:L3L4L3L4分别与Pkey_1Tuple_1相关联。

  • T3: 执行Tx_AUPDATE命令时,CheckTargetForConflictsOutCheckTargetForConflictsIN会分别在ExecUpdate执行前后被调用。在本例中,CheckTargetForConflictsOut什么都不做。而CheckTargetForConflictsIn则会创建读-写冲突C1,这是Tx_BTx_APkey_1Tuple_1上的冲突,因为Pkey_1Tuple_1都由Tx_B读取并被Tx_A写入。

  • T4: 执行Tx_BUPDATE命令时,CheckTargetForConflictsIn会创建读-写冲突C2,这是Tx_ATx_BPkey_2Tuple_2000上的冲突。

    在这种情况下,C1C2在前趋图中构成一个环;因此Tx_ATx_B处于不可串行化状态。但事务Tx_ATx_B都尚未提交,因此CheckTargetForConflictsIn不会中止Tx_B。注意这是因为PostgreSQL的SSI实现采用先提交者为准方案。

  • T5: 当Tx_A尝试提交时,将调用PreCommit_CheckForSerializationFailure。此函数可以检测串行化异常,并在允许的情况下执行提交操作。在这里因为Tx_B仍在进行中,Tx_A成功提交。

  • T6: 当Tx_B尝试提交时,PreCommit_CheckForSerializationFailure检测到串行化异常,且Tx_A已经提交;因此Tx_B被中止。

此外,如果在Tx_A提交之后(T5时刻),Tx_B执行了UPDATE命令,则Tx_B会立即中止。因为Tx_BUPDATE命令会调用CheckTargetForConflictsIn,并检测到串行化异常,如图5.16(1)所示。

如果Tx_B在T6时刻执行SELECT命令而不是COMMIT命令,则Tx_B也会立即中止。因为Tx_BSELECT命令调用的CheckTargetForConflictsOut会检测到串行化异常,如图5.16(2)所示。

图5.16 其他写偏差场景

其他写偏

这里的Wiki解释了几种更为复杂的异常。

5.9.4 假阳性的串行化异常

在可串行化模式下,因为永远不会检测到**假阴性(false-negative,发生异常但未检测到)**串行化异常,PostgreSQL能始终完全保证并发事务的可串行性。 但相应的是在某些情况下,可能会检测到假阳性异常(没有发生异常但误报发生),用户在使用SERIALIZABLE模式时应牢记这一点。 下文会描述PostgreSQL检测到假阳性异常的情况。

图5.17展示了发生假阳性串行化异常的情况。

图5.17 发生假阳性串行化异常的场景

假阳性串行化异常的场景

当使用顺序扫描时,如SIREAD锁的解释中所述,PostgreSQL创建了一个关系级的SIREAD锁。 图5.18(1)展示了PostgreSQL使用顺序扫描时的SIREAD锁和读-写冲突。 在这种情况下,产生了与tbl表上SIREAD锁相关联的读-写冲突:C1C2,并且它们在前趋图中构成了一个环。 因此会检测到假阳性的写偏差异常(即,虽然实际上没有冲突,但Tx_ATx_B两者之一也将被中止)。

图 5.18 假阳性异常(1) - 使用顺序扫描

使用顺序扫描

即使使用索引扫描,如果事务Tx_ATx_B都获取里相同的索引SIREAD锁,PostgreSQL也会误报假阳性异常。 图5.19展示了这种情况。 假设索引页Pkey_1包含两条索引项,其中一条指向Tuple_1,另一条指向Tuple_2。 当Tx_ATx_B执行相应的SELECTUPDATE命令时,Pkey_1同时被Tx_ATx_B读取与写入。 这时候会产生Pkey_1相关联的读-写冲突:C1C2,并在前趋图中构成一个环,因而检测到假阳性写偏差异常(如果Tx_ATx_B获取不同索引页上的SIREAD锁则不会误报,并且两个事务都可以提交)。

图5.19 假阳性异常(2) - 使用相同索引页的索引扫描

使用相同索引页的索引扫描

5.10 所需的维护进程

PostgreSQL的并发控制机制需要以下维护过程。

  1. 删除死元组及指向死元组的索引元组
  2. 移除**提交日志(clog)**中非必需的部分
  3. 冻结旧的事务标识(txid)
  4. 更新FSM,VM,以及统计信息

第5.3.2和5.4.3节分别解释了为什么需要第一个和第二个过程。第三个过程与事务标识回卷问题有关,本小节将概述**事务标识回卷(txid wrap around)**问题。

在PostgreSQL中,清理过程(VACUUM)负责这些过程。**清理过程(VACUUM)**在第6章中描述。

5.10.1 冻结处理

接下来将介绍**事务标识回卷(txid wrap around)**问题。

假设元组Tuple_1是由txid = 100事务创建的,即Tuple_1t_xmin = 100。服务器运行了很长时间,但Tuple_1一直未曾被修改。假设txid已经前进到了$2^{31}+100$,这时候正好执行了一条SELECT命令。此时,因为对当前事务而言txid = 100的事务属于过去的事务,因而Tuple_1对当前事务可见。然后再执行相同的SELECT命令,此时txid步进至$2^{31}+101$。但因对当前事务而言,txid = 100的事务是属于未来的,因此Tuple_1不再可见(图5.20)。这就是PostgreSQL中所谓的事务回卷问题。

图5.20 回卷问题

为了解决这个问题,PostgreSQL引入了一个**冻结事务标识(Frozen txid)**的概念,并实现了一个名为FREEZE的过程。

在PostgreSQL中定义了一个冻结的txid,它是一个特殊的保留值txid = 2,在参与事务标识大小比较时,它总是比所有其他txid都旧。换句话说,冻结的txid始终处于非活跃状态,且其结果对其他事务始终可见。

清理过程(VACUUM会调用冻结过程(FREEZE)。冻结过程将扫描所有表文件,如果元组的t_xmin比当前txid - vacuum_freeze_min_age(默认值为5000万)更老,则将该元组的t_xmin重写为冻结事务标识。在第6章中会有更详细的解释。

举个例子,如图5.21(a)所示,当前txid为5000万,此时通过VACUUM命令调用冻结过程。在这种情况下,Tuple_1Tuple_2t_xmin都被重写为2。

在版本9.4或更高版本中使用元组t_infomask字段中的XMIN_FROZEN标记位来标识冻结元组,而不是将元组的t_xmin重写为冻结的txid,如图5.21(b)所示。

图5.21 冻结过程

参考文献

第六章 垃圾清理过程

**清理(VACUUM)**是一种维护过程,有助于PostgreSQL的持久运行。它的两个主要任务是删除死元组,以及冻结事务标识,两者都在第5.10节中简要提到过。

为了移除死元组,清理过程有两种模式:并发清理(Concurrent Vacuum)完整清理(Full Vacuum) 。并发清理(通常简称为VACUUM)会删除表文件每个页面中的死元组,而其他事务可以在其运行时继续读取该表。相反,完整清理不仅会移除整个文件中所有的死元组,还会对整个文件中所有的活元组进行碎片整理。而其他事务在完整清理运行时无法访问该表。

尽管清理过程对PostgreSQL至关重要,但与其他功能相比,它的改进相对其他功能而言要慢一些。例如在8.0版本之前,清理过程必须手动执行(通过psql实用程序或使用cron守护进程)。直到2005年实现了autovacuum守护进程时,这一过程才实现了自动化。

由于清理过程涉及到全表扫描,因此该过程代价高昂。在版本8.4(2009)中引入了**可见性映射(Visibility Map, VM)**来提高移除死元组的效率。在版本9.6(2016)中增强了VM,从而改善了冻结过程的表现。

6.1节概述了并发清理的过程,而后续部分的内容如下所示:

  • 可见性映射
  • **冻结(Freeze)**过程
  • 移除不必要的clog文件
  • **自动清理(AutoVacuum)**守护进程
  • 完整清理

6.1 并发清理概述

清理过程为指定的表,或数据库中的所有表执行以下任务。

  1. 移除死元组
    • 移除每一页中的死元组,并对每一页内的活元组进行碎片整理。
    • 移除指向死元组的索引元组。
  2. 冻结旧的事务标识(txid
    • 如有必要,冻结旧元组的事务标识(txid)。
    • 更新与冻结事务标识相关的系统视图(pg_databasepg_class)。
    • 如果可能,移除非必需的提交日志(clog)。
  3. 其他
    • 更新已处理表的空闲空间映射(FSM)和可见性映射(VM)。
    • 更新一些统计信息(pg_stat_all_tables等)。

这里假设读者已经熟悉以下术语:死元组,冻结事务标识,FSM,clog;如果读者不熟悉这些术语的含义,请参阅第5章。VM将在第6.2节中介绍。

以下伪代码描述了清理的过程。

伪码:并发清理

(1)     FOR each table
(2)         在目标表上获取 ShareUpdateExclusiveLock 锁
    
            /* 第一部分 */
(3)         扫描所有页面,定位死元组;如有必要,冻结过老的元组。
(4)         如果存在,移除指向死元组的索引元组。
    
            /* 第二部分 */
(5)         FOR each page of the table
(6)             移除死元组,重排本页内的活元组。
(7)             更新 FSM 与 VM
            END FOR
    
            /* 第三部分 */
(8)         如果可能,截断最后的页面。
(9)         更新系统数据字典与统计信息
            释放ShareUpdateExclusiveLock锁
        END FOR
    
        /* 后续处理 */
(10)    更新统计信息与系统数据字典
(11)    如果可能,移除没有必要的文件,以及clog中的文件。
  1. 从指定的表集中依次处理每一张表。
  2. 获取表上的ShareUpdateExclusiveLock锁, 此锁允许其他事务对该表进行读取。
  3. 扫描表中所有的页面,以获取所有的死元组,并在必要时冻结旧元组。
  4. 删除指向相应死元组的索引元组(如果存在)。
  5. 对表的每个页面执行步骤(6)和(7)中的操作
  6. 移除死元组,并重新分配页面中的活元组。
  7. 更新目标表对应的FSM与VM。
  8. 如果最后一个页面没有任何元组,则截断最后一页。
  9. 更新与目标表清理过程相关的统计数据和系统视图。
  10. 更新与清理过程相关的统计数据和系统视图。
  11. 如果可能,移除clog中非必需的文件与页面。

该伪码分为两大块:一块是依次处理表的循环,一块是后处理逻辑。而循环块又能分为三个部分,每一个部分都有各自的任务。接下来会描述这三个部分,以及后处理的逻辑。

6.1.1 第一部分

这一部分执行冻结处理,并删除指向死元组的索引元组。

首先,PostgreSQL扫描目标表以构建死元组列表,如果可能的话,还会冻结旧元组。该列表存储在本地内存中的maintenance_work_mem里(维护用的工作内存)。冻结处理将在第6.3节中介绍。

扫描完成后,PostgreSQL根据构建得到的死元组列表来删除索引元组。该过程在内部被称为“清除阶段(cleanup stage)”。不用说,该过程代价高昂。在10或更早版本中始终会执行清除阶段。在11或更高版本中,如果目标索引是B树,是否执行清除阶段由配置参数vacuum_cleanup_index_scale_factor决定。详细信息请参考此参数的说明

maintenance_work_mem已满,且未完成全部扫描时,PostgreSQL继续进行后续任务,即步骤4到7;完成后再重新返回步骤3并继续扫描。

6.1.2 第二部分

这一部分会移除死元组,并逐页更新FSM和VM。图6.1展示了一个例子:

图6.1 删除死元组

假设该表包含三个页面,这里先关注0号页面(即第一个页面)。该页面包含三条元组, 其中Tuple_2是一条死元组,如图6.1(1)所示。在这里PostgreSQL移除了Tuple_2,并重排剩余元组来整理碎片空间,然后更新该页面的FSM和VM,如图6.1(2)所示。 PostgreSQL不断重复该过程直至最后一页。

请注意,非必需的行指针是不会被移除的,它们会在将来被重用。因为如果移除了行指针,就必须同时更新所有相关索引中的索引元组。

6.1.3 第三部分

第三部分会针对每个表,更新与清理过程相关的统计信息和系统视图。

此外,如果最后一页中没有元组,则该页会从表文件中被截断。

6.1.4 后续处理

当处理完成后,PostgreSQL会更新与清理过程相关的几个统计数据,以及相关的系统视图;如果可能的话,它还会移除部分非必需的clog(第6.4节)。

清理过程使用8.5节中将描述的环形缓冲区(ring buffer)。因此处理过的页面不会缓存在共享缓冲区中。

6.2 可见性映射

清理过程的代价高昂,因此PostgreSQL在8.4版中引入了VM,用于减小清理的开销。

VM的基本概念很简单。 每个表都拥有各自的可见性映射,用于保存表文件中每个页面的可见性。 页面的可见性确定了每个页面是否包含死元组。清理过程可以跳过没有死元组的页面。

图6.2展示了VM的使用方式。 假设该表包含三个页面,第0页和第2页包含死元组,而第1页不包含死元组。 表的可见性映射中保存着哪些页面包含死元组的信息。 在这种情况下,清理过程可以参考VM中的信息,跳过第一个页面。

图6.2 VM的使用方式

每个VM由一个或多个8 KB页面组成,文件以后缀_vm存储。 例如,一个表文件的relfilenode是18751,其FSM(18751_fsm)和VM(18751_vm)文件如下所示。

$ cd $PGDATA
$ ls -la base/16384/18751*
-rw------- 1 postgres postgres  8192 Apr 21 10:21 base/16384/18751
-rw------- 1 postgres postgres 24576 Apr 21 10:18 base/16384/18751_fsm
-rw------- 1 postgres postgres  8192 Apr 21 10:18 base/16384/18751_vm

6.2.1 可见性映射的改进

可见性映射在9.6版中进行了加强,以提高冻结处理的效率。新的VM除了显示页面可见性之外,还包含了页面中元组是否全部冻结的信息,参见第6.3.3节。

6.3 冻结过程

冻结过程有两种模式,依特定条件而择其一执行。为方便起见,将这两种模式分别称为惰性模式(lazy mode)迫切模式(eager mode)

**并发清理(Concurrent VACUUM)通常在内部被称为“惰性清理(lazy vacuum)”。但是,本文中定义的惰性模式是冻结过程(Freeze Processing)**执行的模式。

冻结过程通常以惰性模式运行;但当满足特定条件时,也会以迫切模式运行。在惰性模式下,冻结处理仅使用目标表对应的VM扫描包含死元组的页面。迫切模式相则反,它会扫描所有的页面,无论其是否包含死元组,它还会更新与冻结处理相关的系统视图,并在可能的情况下删除不必要的clog。

第6.3.1和6.3.2节分别描述了这两种模式;第6.3.3节描述了改进后的迫切模式冻结过程。

6.3.1 惰性模式

当开始冻结处理时,PostgreSQL计算freezeLimit_txid,并冻结t_xmin小于freezeLimit_txid的元组。

freezeLimit_txid定义如下: $$ \begin{align} \verb|freezeLimit_txid| = (\verb|OldestXmin| - \verb|vacuum_freeze_min_age|) \end{align} $$

OldestXmin是当前正在运行的事务中最早的事务标识(txid)。 举个例子,如果在执行VACUUM命令时,还有其他三个事务正在运行,且其txid分别为100,101,102,那么这里OldestXmin就是100。如果不存在其他事务,OldestXmin 就是执行此VACUUM命令的事务标识。 这里vacuum_freeze_min_age是一个配置参数(默认值为50,000,000)。

图6.3给出了一个具体的例子。这里Table_1由三个页面组成,每个页面包含三条元组。 执行VACUUM命令时,当前txid50,002,500且没有其他事务。在这种情况下,OldestXmin就是50,002,500;因此freezeLimit_txid2500。冻结过程按照如下步骤执行。

图6.3 冻结元组——惰性模式

  • 第0页:

    三条元组被冻结,因为所有元组的t_xmin值都小于freezeLimit_txid。此外,因为Tuple_1是一条死元组,因而在该清理过程中被移除。

  • 第1页:

    通过引用可见性映射(从VM中发现该页面所有元组都可见),清理过程跳过了对该页面的清理。

  • 第2页:

    Tuple_7Tuple_8被冻结,且Tuple_7被移除。

在完成清理过程之前,与清理相关的统计数据会被更新,例如pg_stat_all_tables视图中的n_live_tupn_dead_tuplast_vacuumvacuum_count等字段。

如上例所示,因为惰性模式可能会跳过页面,它可能无法冻结所有需要冻结的元组。

6.3.2 迫切模式

迫切模式弥补了惰性模式的缺陷。它会扫描所有页面,检查表中的所有元组,更新相关的系统视图,并在可能时删除非必需的clog文件与页面。

当满足以下条件时,会执行迫切模式。 $$ \begin{align} \verb|pg_database.datfrozenxid| < (\verb|OldestXmin| - \verb|vacuum_freeze_table_age|) \end{align} $$ 在上面的条件中,pg_database.datfrozenxid是系统视图pg_database中的列,并保存着每个数据库中最老的已冻结的事务标识。细节将在后面描述;因此这里我们假设所有pg_database.datfrozenxid的值都是1821(这是在9.5版本中安装新数据库集群之后的初始值)。 vacuum_freeze_table_age是配置参数(默认为150,000,000)。

图6.4给出了一个具体的例子。在表1中,Tuple_1Tuple_7都已经被删除。Tuple_10Tuple_11则已经插入第2页中。执行VACUUM命令时的事务标识为150,002,000,且没有其他事务。因此,OldestXmin=150,002,000freezeLimit_txid=100,002,000。在这种情况下满足了上述条件:因为1821 < (150002000 - 150000000) ,因而冻结过程会以迫切模式执行,如下所示。

(注意,这里是版本9.5或更早版本的行为;最新版本的行为将在第6.3.3节中描述。)

图6.4 冻结旧元组——迫切模式(9.5或更早版本)

  • 第0页:

    即使所有元组都被冻结,也会检查Tuple_2Tuple_3

  • 第1页:

    此页面中的三条元组都会被冻结,因为所有元组的t_xmin值都小于freezeLimit_txid。注意在惰性模式下会跳过此页面。

  • 第2页:

    Tuple_10冻结,而Tuple_11没有冻结。

冻结一张表后,目标表的pg_class.relfrozenxid将被更新。 pg_class是一个系统视图,每个pg_class.relfrozenxid列都保存着相应表的最近冻结的事务标识。本例中表1的pg_class.relfrozenxid会被更新为当前的freezeLimit_txid(即100,002,000),这意味着表1中t_xmin小于100,002,000的所有元组都已被冻结。

在完成清理过程之前,必要时会更新pg_database.datfrozenxid。每个pg_database.datfrozenxid列都包含相应数据库中的最小pg_class.relfrozenxid。例如,如果在迫切模式下仅仅对表1做冻结处理,则不会更新该数据库的pg_database.datfrozenxid,因为其他关系的pg_class.relfrozenxid(当前数据库可见的其他表和系统视图)还没有发生变化,如图6.5(1)所示。如果当前数据库中的所有关系都以迫切模式冻结,则数据库的pg_database.datfrozenxid就会被更新,因为此数据库的所有关系的pg_class.relfrozenxid都被更新为当前的freezeLimit txid,如图6.5(2)所示。

图6.5 pg_database.datfrozenxidpg_class.relfrozenxid之间的关系

 如何显示pg_class.relfrozenxidpg_database.datfrozenxid

如下所示,第一个查询显示testdb数据库中所有可见关系的relfrozenxid,第二个查询显示testdb数据库的pg_database.datfrozenxld

testdb=# VACUUM table_1;
VACUUM

testdb=# SELECT n.nspname as "Schema", c.relname as "Name", c.relfrozenxid
             FROM pg_catalog.pg_class c
             LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
             WHERE c.relkind IN ('r','')
                   AND n.nspname <> 'information_schema' 
                   AND n.nspname !~ '^pg_toast'
                   AND pg_catalog.pg_table_is_visible(c.oid)
             ORDER BY c.relfrozenxid::text::bigint DESC;

   Schema   |            Name         | relfrozenxid 
------------+-------------------------+--------------
 public     | table_1                 |    100002000
 public     | table_2                 |         1846
 pg_catalog | pg_database             |         1827
 pg_catalog | pg_user_mapping         |         1821
 pg_catalog | pg_largeobject          |         1821

...

 pg_catalog | pg_transform            |         1821
(57 rows)

testdb=# SELECT datname, datfrozenxid FROM pg_database 
            WHERE datname = 'testdb';
 datname | datfrozenxid 
---------+--------------
 testdb  |         1821
(1 row)

FREEZE选项

带有FREEZE选项的VACUUM命令会强制冻结指定表中的所有事务标识。虽然这是在迫切模式下执行的,但这里freezeLimit会被设置为OldestXmin(而不是OldestXmin - vacuum_freeze_min_age)。 例如当txid=5000的事务执行VACUUM FULL命令,且没有其他正在运行的事务时,OldesXmin会被设置为5000,而t_xmin小于5000的元组将会被冻结。

6.3.3 改进迫切模式中的冻结过程

9.5或更早版本中的迫切模式效率不高,因为它始终会扫描所有页面。 比如在第6.3.2节的例子中,尽管第0页中所有元组都被冻结,但也会被扫描。

为了解决这一问题,9.6版本改进了可见性映射VM与冻结过程。如第6.2.1节所述,新VM包含着每个页面中所有元组是否都已被冻结的信息。在迫切模式下进行冻结处理时,可以跳过仅包含冻结元组的页面。

图6.6给出了一个例子。 根据VM中的信息,冻结此表时会跳过第0页。在更新完1号页面后,相关的VM信息会被更新,因为该页中所有的元组都已经被冻结了。

图6.6 冻结旧元组——迫切模式(9.6或更高版本)

6.4 移除不必要的提交日志文件

如5.4节中所述,**提交日志(clog)**存储着事务的状态。 当更新pg_database.datfrozenxid时,PostgreSQL会尝试删除不必要的clog文件。 注意相应的clog页面也会被删除。

图6.7给出了一个例子。 如果clog文件0002中包含最小的pg_database.datfrozenxid,则可以删除旧文件(00000001),因为存储在这些文件中的所有事务在整个数据库集簇中已经被视为冻结了。

图6.7 删除不必要的clog文件和页面

 pg_database.datfrozenxid与clog文件

下面展示了pg_database.datfrozenxid与clog文件的实际输出

$ psql testdb -c "SELECT datname, datfrozenxid FROM pg_database"
  datname  | datfrozenxid 
-----------+--------------
 template1 |      7308883
 template0 |      7556347
 postgres  |      7339732
 testdb    |      7506298
(4 rows)

$ ls -la -h data/pg_clog/	# 10或更新的版本, "ls -la -h data/pg_xact/"
total 316K
drwx------  2 postgres postgres   28 Dec 29 17:15 .
drwx------ 20 postgres postgres 4.0K Dec 29 17:13 ..
-rw-------  1 postgres postgres 256K Dec 29 17:15 0006
-rw-------  1 postgres postgres  56K Dec 29 17:15 0007

6.5 自动清理守护进程

**自动清理(AutoVacuum)**守护进程已经将清理过程自动化,因此PostgreSQL运维起来非常简单。

自动清理守护程序周期性地唤起几个autovacuum_worker进程,默认情况下会每分钟唤醒一次(由参数autovacuum_naptime定义),每次唤起三个工作进程(由autovacuum_max_works定义)。

自动清理守护进程唤起的autovacuum工作进程会依次对各个表执行并发清理,从而将对数据库活动的影响降至最低。

关于如何维护AUTOVACUUM

参考文章:[PostgreSQL中的Autovacuum调参,Autovacuum内幕][https://www.percona.com/blog/2018/08/10/tuning-autovacuum-in-postgresql-and-autovacuum-internals/]

6.6 完整清理(FULL VACUUM

虽然并发清理对于运维至关重要,但光有它还不够。比如,即使删除了许多死元组,也无法压缩表大小的情况。

图6.8给出了一个极端的例子。假设一个表由三个页面组成,每个页面包含六条元组。执行以下DELETE命令以删除元组,并执行VACUUM命令以移除死元组:

图6.8 并发清理的缺陷示例

testdb=# DELETE FROM tbl WHERE id % 6 != 0;
testdb=# VACUUM tbl;

死元组虽然都被移除了,但表的尺寸没有减小。 这种情况既浪费了磁盘空间,又会对数据库性能产生负面影响。 例如在上面的例子中,当读取表中的三条元组时,必须从磁盘加载三个页面。

为了解决这种情况,PostgreSQL提供了完整清理模式。 图6.9概述了该模式。

图6.9 完整清理模式概述

  1. 创建新的表文件:见图6.9(1)

    当对表执行VACUUM FULL命令时,PostgreSQL首先获取表上的AccessExclusiveLock锁,并创建一个大小为8 KB的新的表文件。 AccessExclusiveLock锁不允许任何其他访问。

  2. 将活元组复制到新表:见图6.9(2)

    PostgreSQL只将旧表文件中的活元组复制到新表中。

  3. 删除旧文件,重建索引,并更新统计信息,FSM和VM,见图6.9(3)

    复制完所有活元组后,PostgreSQL将删除旧文件,重建所有相关的表索引,更新表的FSM和VM,并更新相关的统计信息和系统视图。

完整清理的伪代码如下所示:

伪代码:完整清理

(1)     FOR each table
(2)         获取表上的AccessExclusiveLock锁
(3)         创建一个新的表文件
(4)         FOR 每个活元组 in 老表
(5)             将活元组拷贝到新表中
(6)             如果有必要,冻结该元组。
            END FOR
(7)         移除旧的表文件
(8)         重建所有索引
(9)         更新FSM与VM
(10)        更新统计信息
            释放AccessExclusiveLock锁
        END FOR
(11)    移除不必要的clog文件

使用VACUUM FULL命令时,应当考虑两点。

  1. 当执行完整清理时,没有人可以访问(读/写)表。
  2. 最多会临时使用两倍于表的磁盘空间;因此在处理大表时,有必要检查剩余磁盘容量。

什么时候该使用VACUUM FULL

不幸的是,并没有关于什么时候该执行VACUUM FULL的最佳实践。但是扩展pg_freespacemap可能会给出很好的建议。

以下查询给出了表的平均空间空闲率。

testdb=# CREATE EXTENSION pg_freespacemap;
CREATE EXTENSION

testdb=# SELECT count(*) as "number of pages",
       pg_size_pretty(cast(avg(avail) as bigint)) as "Av. freespace size",
       round(100 * avg(avail)/8192 ,2) as "Av. freespace ratio"
       FROM pg_freespace('accounts');
 number of pages | Av. freespace size | Av. freespace ratio 
-----------------+--------------------+---------------------
            1640 | 99 bytes           |                1.21
(1 row)

从上面的结果可以看出,没有多少空闲空间。

如果删除几乎所有的元组,并执行VACUUM命令,则可以发现每个页面几乎都是空的。

testdb=# DELETE FROM accounts WHERE aid %10 != 0 OR aid < 100;
DELETE 90009

testdb=# VACUUM accounts;
VACUUM

testdb=# SELECT count(*) as "number of pages",
       pg_size_pretty(cast(avg(avail) as bigint)) as "Av. freespace size",
       round(100 * avg(avail)/8192 ,2) as "Av. freespace ratio"
       FROM pg_freespace('accounts');
 number of pages | Av. freespace size | Av. freespace ratio 
-----------------+--------------------+---------------------
            1640 | 7124 bytes         |               86.97
(1 row)

以下查询检查特定表中每个页面的自由空间占比。

testdb=# SELECT *, round(100 * avail/8192 ,2) as "freespace ratio"
                FROM pg_freespace('accounts');
 blkno | avail | freespace ratio 
-------+-------+-----------------
     0 |  7904 |           96.00
     1 |  7520 |           91.00
     2 |  7136 |           87.00
     3 |  7136 |           87.00
     4 |  7136 |           87.00
     5 |  7136 |           87.00
....

执行VACUUM FULL后会发现表被压实了。

testdb=# VACUUM FULL accounts;
VACUUM
testdb=# SELECT count(*) as "number of blocks",
       pg_size_pretty(cast(avg(avail) as bigint)) as "Av. freespace size",
       round(100 * avg(avail)/8192 ,2) as "Av. freespace ratio"
       FROM pg_freespace('accounts');
 number of pages | Av. freespace size | Av. freespace ratio 
-----------------+--------------------+---------------------
             164 | 0 bytes            |                0.00
(1 row)

第七章 堆内元组与仅索引扫描

本章中介绍两个和索引扫描有关的特性—— 堆内元组(heap only tuple, HOT)仅索引扫描(index-only scan)

7.1 堆内元组(HOT)

在8.3版本中实现的HOT特性,使得更新行的时候,可以将新行放置在老行所处的同一个数据页中,从而高效地利用索引与表的数据页;HOT特性减少了不必要的清理过程。

在源码的README.HOT中有关于HOT的详细介绍,本章只是简短的介绍HOT。首先,7.1.1节描述了在没有HOT特性的时候,更新一行是怎样一个过程,以阐明要解决的问题。接下来,在7.1.2中将介绍HOT做了什么。

7.1.1 没有HOT时的行更新

假设表tbl有两个列:iddataidtbl的主键。

testdb=# \d tbl
                Table "public.tbl"
 Column |  Type   | Collation | Nullable | Default
--------+---------+-----------+----------+---------
 id     | integer |           | not null |
 data   | text    |           |          |
Indexes:
    "tbl_pkey" PRIMARY KEY, btree (id)

tbl有1000条元组;最后一个元组的id是1000,存储在第五个数据页中。最后一条元组被相应的索引元组所引用,索引元组的key是1000,且tid(5,1),如图7.1(a)所示。

图 7.1 没有HOT的行更新

update

我们考虑一下,没有HOT特性时,最后一个元组是如何更新的。

testdb=# UPDATE tbl SET data = 'B' WHERE id = 1000;

在该场景中,PostgreSQL不仅要插入一条新的表元组,还需要在索引页中插入新的索引元组,如图7.1(b)所示。索引元组的插入消耗了索引页的空间,而且索引元组的插入和清理都是开销很大的操作。HOT的目的,就是降低这种影响。

7.1.2 HOT如何工作

当使用HOT特性更新行时,如果被更新的元组存储在老元组所在的页面中,PostgreSQL就不会再插入相应的索引元组,而是分别设置新元组的HEAP_ONLY_TUPLE标记位与老元组的HEAP_HOT_UPDATED标记位,两个标记位都保存在元组的t_informask2字段中。如图7.2和7.3所示;

图7.2 HOT的行更新

hot informask

比如在这个例子中,Tuple_1Tuple_2分别被设置成HEAP_HOT_UPDATEDHEAP_ONLY_TUPLE

另外,在**修剪(pruning)碎片整理(defragmentation)**处理过程中,都会使用下面介绍的HEAP_HOT_UPDATEDHEAP_ONLY_TUPLE标记位。

接下来会介绍,当基于HOT更新一个元组后,PostgreSQL是如何在索引扫描中访问这些被HOT更新的元组的,如图7.4(a)所示。

图7.4 行指针修剪

pruning

  1. 找到指向目标数据元组的索引元组
  2. 按所获索引元组指向的位置访问行指针数组,找到行指针1
  3. 读取Tuple_1
  4. 经由Tuple_1t_ctid字段,读取Tuple_2

在这种情况下,PostgreSQL会读取两条元组,Tuple_1Tuple_2,并通过第5章所述的并发控制机制来判断哪条元组是可见的;但如果数据页中的**死元组(dead tuple)**已经被清理了,那就有问题了。比如在图7.4(a)中,如果Tuple_1由于是死元组而被清理了,就无法通过索引访问Tuple_2了。

为了解决这个问题,PostgreSQL会在合适的时候进行行指针重定向:将指向老元组的行指针重新指向新元组的行指针。在PostgreSQL中,这个过程称为修剪(pruning)。图7.4(b)说明了PostgreSQL在修剪之后如何访问更新的元组。

  1. 找到索引元组
  2. 通过索引元组,找到行指针[1]
  3. 通过重定向的行指针[1],找到行指针[2]
  4. 通过行指针[2],读取Tuple_2

可能的话,剪枝任何时候都有可能会发生,比如 SELECTUPDATEINSERTDELETE这类SQL命令执行的时候,确切的执行时机不会在本章中描述,因为它太复杂了。细节可以在README.HOT文件中找到。

在PostgreSQL执行剪枝时,如果可能,会挑选合适的时机来清理死元组。在PostgreSQL中这种操作称为碎片整理(defragmentation),图7.5中描述了HOT中的碎片整理过程。

图 7.5 死元组的碎片整理

需要注意的是,因为碎片整理的工作并不涉及到索引元组的移除,因此碎片整理比起常规的清理开销要小得多。

因此,HOT特性降低了索引和表的空间消耗,同样减少了清理过程需要处理的元组数量。由于减少了更新操作需要插入的索引元组数量,并减小了清理操作需要处理的元组数量,HOT对于性能提高有良好的促进作用。

HOT不可用的场景

为了清晰地理解HOT的工作,这里介绍一些HOT不可用的场景。

  1. 当更新的元组在其他的页面时,即和老元组在不在同一个数据页中时,指向该元组的索引元组也会被添加至索引页中,如图7.6(a)所示。
  2. 当索引的更新时,会在索引页中插入一条新的索引元组,如图7.6(b)所示。

图7.6 HOT不适用的情况

notavaible

pg_stat_all_tables视图提供了每个表的统计信息视图,也可以参考这个扩展

7.2 仅索引扫描

SELECT语句的所有的目标列都在索引键中时,为了减少I/O代价,仅索引扫描(Index-Only Scan)(又叫仅索引访问)会直接使用索引中的键值。所有商业关系型数据库中都提供这个技术,比如DB2和Oracle。PostgreSQL在9.2版本中引入这个特性。

接下来我们会基于一个特殊的例子,介绍PostgreSQL中仅索引扫描的工作过程。

首先是关于这个例子的假设:

  • 表定义

    我们有一个tbl表,其定义如下所示:

    testdb=# \d tbl
          Table "public.tbl"
     Column |  Type   | Modifiers 
    --------+---------+-----------
     id     | integer | 
     name   | text    | 
     data   | text    | 
    Indexes:
        "tbl_idx" btree (id, name)
    
  • 索引

    tbl有一个索引tbl_idx,包含两列:idname

  • 元组

    tbl已经插入了一些元组。

    id=18, name = 'Queen'Tuple_18存储在0号数据页中。

    id=19, name='BOSTON'Tuple_19存储在1号数据页中。

  • 可见性

    所有在0号页面中的元组永远可见;1号页面中的元组并不总是可见的。注意每个页的可见性信息都存储在相应的**可见性映射(visibility map)**中,关于可见性映射的描述可以参考第6.2节。

我们来研究一下,当下面的SELECT语句执行时,PostgreSQL是如何读取元组的。

testdb=# SELECT id, name FROM tbl WHERE id BETWEEN 18 and 19;
 id |  name   
----+--------
 18 | Queen
 19 | Boston
(2 rows)

查询需要从表中读取两列:idname,然而索引tbl_idx包含了这些列。因此在使用索引扫描时,第一眼看上去好像访问表的页面是没有必要的,因为索引中已经包含了必要的数据。然而原则上,PostgreSQL有必要需要检查这些元组的可见性,然而索引元组中并没有任何关于堆元组的事务相关信息,比如t_xmint_xmax,详细参考第5章。因此,PostgreSQL需要访问表数据来检查索引元组中数据的可见性,这就有点本末倒置了。

面对这种困境,PostgreSQL使用目标数据表对应的可见性映射表来解决此问题。如果某一页中存储所有的元组都是可见的,PostgreSQL就会使用索引元组,而不去访问索引元组指向的数据页去检查可见性;否则,PostgreSQL读取索引元组指向的数据元组并检查元组可见性,而这个就跟原来设想的一样。

在这个例子中,因为的0号页面被标记为可见,因此0号页面中存储的包括Tuple_18在内的所有元组都是可见的,所以就无需再去访问Tuple_18了。相应的,因为1号页面并没有被标记为可见,此时为了检查并发控制的可见性,需要访问Tuple_19

图 7.7 仅索引扫描的工作过程

vm

7.3 README.HOT

这是PostgreSQL官方文档中关于HOT的介绍。

堆内元组(HOT)

堆内元组(HOT)功能消除了冗余索引条目,并允许在不进行表级清理的前提下,重用被删除或被更新的元组空间。这是通过单页清理 实现的,也称为碎片整理(defragmentation)

注意:本文档末尾有一个词汇表,对于新读者可能会有所帮助。

技术挑战

一次一页的清理通常是不切实际的,因为找到并移除链接到待回收元组的索引项开销很大。标准清理会完整扫描索引,并确保所有这些索引项都被移除。但是将索引扫描的开销分摊到许多死元组上是可能的;这种方法向下扩展的并不好,比如只是回收几个元组。原则上,这样的问题只需要重新计算索引键,并进行标准的索引搜索找出这些索引项。但因为函数索引的存在,可能会有各种充满Bug的用户定义函数被用于函数索引,这样做会有风险。声称IMMUTABLE但实际上可变的函数会妨碍我们重新找到索引项(而且我们没法仅仅因为没找到索引项就报错,特别是当死掉的索引项有时候会提前回收)。这些问题可能会导致很严重的索引损坏问题,例如以这样一种形式:索引项指向了一些包含无关内容的元组槽。在任何情况下,我们都更倾向于在不调用任何用户编写的代码的情况下来进行清理。

HOT针对一种受限但很实用的场景解决了这一问题:当一条元组以不改变其索引键的方式被重复更新(这里,“索引列(index column)” 意味着在索引定义中引用的任何列,包括部分索引中用于条件测试但并未实际存储的列)。

HOT的另一个特性是它减小了索引的尺寸,通过避免创建键相等的索引项。这能提高搜索速度。

单个索引项的更新链

在没有HOT的情况下,在更新链条上行的每一个版本都有它们各自的索引项,尽管这些索引项中的索引列值都是相同的。在有HOT的情况下,如果一个元组被放置在与其旧元组相同的页面中,且与旧元组在索引列上值相同,那么新的元组不会产生新的索引项。这意味着在这个堆页面上的一整条更新链,只会有且仅有一条索引项。没有相应索引项的元组会被标记为HEAP_ONLY_TUPLE,而先前的行版本则会被标记为HEAP_HOT_UPDATED,而在一条更新链中,它们的t_ctid字段都会继续指向更新的版本。

举个例子:

索引指向1
lp [1]  [2]

[111111111]->[2222222222]

在上面这幅图中,索引指向了行指针1,而元组1被标记为HEAP_HOT_UPDATED。元组2是一个HOT元组,带有HEAP_ONLY_TUPLE,意味着没有索引项指向它。尽管元组2没有被索引直接引用,它仍然能够通过索引搜索被找到。当从索引遍历至元组1时,索引搜索会继续跟进其子元组,只要它看到HEAP_HOT_UPDATED就会尽可能远地持续前进。因为我们将HOT链限制在单个页面内,这样的操作不会导致额外的页面访问,因此也不会引入很多性能损失。

最后元组1不再对任何事务可见,在那个时候,它就应该被清理掉了。但是它的行指针无法被清理掉,因为索引项仍然指向该行指针,而元组2仍然需要通过索引被搜索到。HOT通过将行指针1变为一个“重定向行指针”来解决这个问题,该指针没有实际的元组与之关联,而会链接至元组2。这时候看上去应该是这样的:

索引指向1
lp [1]->[2]

[2222222222]

如果现在这一行又被更新了,到了版本3,页面看上去就会是这样的。

索引指向1
lp [1]->[2]  [3]

[2222222222]->[3333333333]

当没有事务能在其快照中看见元组2时,元组2和它的行指针可以被整个剪枝掉:

索引指向1
lp [1]------>[3]

[3333333333]

这是安全的,因为没有指向行指针2的索引项。在该页面中,后续的插入可以回收利用行指针2和原来元组2占用的空间。

如果更新修改了被索引的列,或者同一页中没有空间能放下这个新元组,那么这条更新链就会结束:最后一个成员会有一个通常的t_ctid,指向下一个版本的位置,而且不会被标记为HEAP_HOT_UPDATED。(原则上讲我们是能够跨越页面继续这条HOT链的,但是这会打破我们所期望的性质:能够使用页面本地的操作回收空间。无论如何,我们都不想追着越过好几个堆页面,只是为了拿到一个索引项对应的元组,在那种情况下为新的元组创建一个新的索引项看上去会是一个更好的选择)如果后续的更新继续出现,下一个版本会成为一条新更新链的根。

只要当前页面中更新链还有任何活着的元组,行指针1就始终需要保留。当没有的时候,就可以将其标记为“死掉”,这就允许我们立即回收最后一个子节点的行指针与元组空间。下一次常规的VACUUM扫描会回收该索引项,以及索引项指向的这些行指针本身。因为比起元组而言行指针很小,这并不会出现过度的空间浪费。

注意:我们我们可以用“死掉”的行指针指向任何被删除的元组,无论它是不是HOT链中的元组。这允许我们像HOT更新一样在VACUUM之前,对普通的DELETE也可以进行空间回收。

进行HOT更新的必要条件是被索引的列上没有发生变化,这是在运行时检查旧值与新值的二进制表示来实现的。我们坚持位级别的相等,而不是特定于数据类型的等值比较方法。这样做的原因是后者可能会产生等价的多种表现形式。而我们并不知道索引用的是哪一种。我们假设位级别的相等保证对于所有目的的相等性都是适用的。

中止的情形

索引扫描与顺序扫描

剪枝

碎片整理

什么时候使用剪枝或碎片整理

清理

统计

创建索引

并行创建索引

并行移除索引

局限性

词汇表

第八章 缓冲区管理器

缓冲区管理器(Buffer Manager)管理着共享内存和持久存储之间的数据传输,对于DBMS的性能有着重要的影响。PostgreSQL的缓冲区管理器十分高效。

本章介绍了PostgreSQL的缓冲区管理器。第一节概览了缓冲区管理器,后续的章节分别介绍以下内容:

  • 缓冲区管理器的结构

  • 缓冲区管理器的锁

  • 缓冲区管理器是如何工作的

  • 环形缓冲区

  • 脏页刷写

图8.1 缓冲区管理器,存储和后端进程之间的关系

C76949F9-6362-4AA8-A5FB-9537C9A9B970

8.1 概览

本节介绍了一些关键概念,有助于理解后续章节。

8.1.1 缓冲区管理器的结构

PostgreSQL缓冲区管理器由缓冲表,缓冲区描述符和缓冲池组成,这几个组件将在接下来的小节中介绍。 缓冲池(buffer pool)层存储着数据文件页面,诸如表页与索引页,及其相应的自由空间映射可见性映射的页面。 缓冲池是一个数组,数据的每个槽中存储数据文件的一页。 缓冲池数组的序号索引称为buffer_id。8.2和8.3节描述了缓冲区管理器的内部细节。

8.1.2 缓冲区标签(buffer_tag

PostgreSQL中的每个数据文件页面都可以分配到唯一的标签,即缓冲区标签(buffer tag)。 当缓冲区管理器收到请求时,PostgreSQL会用到目标页面的缓冲区标签。

缓冲区标签(buffer_tag) 由三个值组成:关系文件节点(relfilenode)关系分支编号(fork number)页面块号(block number)。例如,缓冲区标签{(16821, 16384, 37721), 0, 7}表示,在oid=16821的表空间中的oid=16384的数据库中的oid=37721的表的0号分支(关系本体)的第七号页面。再比如缓冲区标签{(16821, 16384, 37721), 1, 3}表示该表空闲空间映射文件的三号页面。(关系本体main分支编号为0,空闲空间映射fsm分支编号为1)

/*
 * Buffer tag 标识了缓冲区中包含着哪一个磁盘块。
 * 注意:BufferTag中的数据必需足以在不参考pg_class或pg_tablespace中的数据项
 * 的前提下,能够直接确定该块需要写入的位置。不过有可能出现这种情况:刷写缓冲区的
 * 后端进程甚至都不认为自己能在那个时刻看见相应的关系(譬如,后段进程对应的的事务
 * 开始时间早于创建该关系的事务)。无论如何,存储管理器都必须能应对这种情况。
 *
 * 注意:如果结构中存在任何填充字节,INIT_BUFFERTAG需要将所有字段抹为零,因为整个
 * 结构体被当成一个散列键来用。
 */
typedef struct buftag
{
	RelFileNode rnode;			/* 关系的物理标识符 */
	ForkNumber	forkNum;        /* 关系的分支编号   */
	BlockNumber blockNum;		/* 相对于关系开始位置的块号 */
} BufferTag;

typedef struct RelFileNode
{
    Oid         spcNode;        /* 表空间 */
    Oid         dbNode;         /* 数据库 */
    Oid         relNode;        /* 关系 */
} RelFileNode;

8.1.3 后端进程如何读取数据页

本小节描述了后端进程如何从缓冲区管理器中读取页面,如图8.2所示。

图8.2 后端进程如何读取数据页

  1. 当读取表或索引页时,后端进程向缓冲区管理器发送请求,请求中带有目标页面的buffer_tag
  2. 缓冲区管理器会根据buffer_tag返回一个buffer_id,即目标页面存储在数组中的槽位的序号。如果请求的页面没有存储在缓冲池中,那么缓冲区管理器会将页面从持久存储中加载到其中一个缓冲池槽位中,然后再返回该槽位的buffer_id
  3. 后端进程访问buffer_id对应的槽位(以读取所需的页面)。

当后端进程修改缓冲池中的页面时(例如向页面插入元组),这种尚未刷新到持久存储,但已被修改的页面被称为脏页(dirty page)

第8.4节描述了缓冲区管理器的工作原理。

8.1.4 页面置换算法

当所有缓冲池槽位都被占用,且其中未包含所请求的页面时,缓冲区管理器必须在缓冲池中选择一个页面逐出,用于放置被请求的页面。 在计算机科学领域中,选择页面的算法通常被称为页面置换算法(page replacement algorithms),而所选择的页面被称为受害者页面(victim page)

针对页面置换算法的研究从计算机科学出现以来就一直在进行,因此先前已经提出过很多置换算法了。 从8.1版本开始,PostgreSQL使用**时钟扫描(clock-sweep)**算法,因为比起以前版本中使用的LRU算法,它更为简单高效。

第8.4.4节描述了时钟扫描的细节。

8.1.5 刷写脏页

脏页最终应该被刷入存储,但缓冲区管理器执行这个任务需要额外帮助。 在PostgreSQL中,两个后台进程:**检查点进程(checkpointer)后台写入器(background writer)**负责此任务。

8.6节描述了检查点进程和后台写入器。

直接I/O(Direct I/O)

PostgreSQL并支持直接I/O,但有时会讨论它。 如果你想了解更多详细信息,可以参考这篇文章,以及pgsql-ML中的这个讨论

8.2 缓冲区管理器的结构

PostgreSQL缓冲区管理器由三层组成,即缓冲表层缓冲区描述符层缓冲池层(图8.3):

图8.3 缓冲区管理器的三层结构

  • **缓冲池(buffer pool)**层是一个数组。 每个槽都存储一个数据文件页,数组槽的索引称为buffer_id

  • **缓冲区描述符(buffer descriptors)**层是一个由缓冲区描述符组成的数组。 每个描述符与缓冲池槽一一对应,并保存着相应槽的元数据。请注意,术语“缓冲区描述符层”只是在本章中为方便起见使用的术语。

  • **缓冲表(buffer table)**层是一个哈希表,它存储着页面的buffer_tag与描述符的buffer_id之间的映射关系。

这些层将在以下的节中详细描述。

8.2.1 缓冲表

缓冲表可以在逻辑上分为三个部分:散列函数,散列桶槽,以及数据项(图8.4)。

内置散列函数将buffer_tag映射到哈希桶槽。 即使散列桶槽的数量比缓冲池槽的数量要多,冲突仍然可能会发生。因此缓冲表采用了**使用链表的分离链接方法(separate chaining with linked lists)**来解决冲突。 当数据项被映射到至同一个桶槽时,该方法会将这些数据项保存在一个链表中,如图8.4所示。

图8.4 缓冲表

数据项包括两个值:页面的buffer_tag,以及包含页面元数据的描述符的buffer_id。例如数据项Tag_A,id=1 表示,buffer_id=1对应的缓冲区描述符中,存储着页面Tag_A的元数据。

散列函数

这里使用的散列函数是由calc_bucket()hash()组合而成。 下面是用伪函数表示的形式。

uint32 bucket_slot = 
    calc_bucket(unsigned hash(BufferTag buffer_tag), uint32 bucket_size)

这里还没有对诸如查找、插入、删除数据项的基本操作进行解释。这些常见的操作将在后续小节详细描述。

8.2.2 缓冲区描述符

本节将描述缓冲区描述符的结构,下一小节将描述缓冲区描述符层。

缓冲区描述符保存着页面的元数据,这些与缓冲区描述符相对应的页面保存在缓冲池槽中。缓冲区描述符的结构由BufferDesc结构定义。这个结构有很多字段,主要字段如下所示:

/* src/include/storage/buf_internals.h  (before 9.6) */

/* 缓冲区描述符的标记位定义(since 9.6)
 * 注意:TAG_VALID实际上意味着缓冲区哈希表中有一条与本tag关联的项目。
 */
#define BM_DIRTY                (1 << 0)    /* 数据需要写入 */
#define BM_VALID                (1 << 1)    /* 数据有效 */
#define BM_TAG_VALID            (1 << 2)    /* 已经分配标签 */
#define BM_IO_IN_PROGRESS       (1 << 3)    /* 读写进行中 */
#define BM_IO_ERROR             (1 << 4)    /* 先前的I/O失败 */
#define BM_JUST_DIRTIED         (1 << 5)    /* 写之前已经脏了 */
#define BM_PIN_COUNT_WAITER     (1 << 6)    /* 有人等着钉页面 */
#define BM_CHECKPOINT_NEEDED    (1 << 7)    /* 必需在检查点时写入 */
#define BM_PERMANENT            (1 << 8)    /* 永久缓冲(不是unlogged) */

/* BufferDesc -- 单个共享缓冲区的共享描述符/共享状态
 * 
 * 注意: 读写tag, flags, usage_count, refcount, wait_backend_pid等字段时必须持有
 * buf_hdr_lock锁。buf_id字段在初始化之后再也不会改变,所以不需要锁。freeNext是通过
 * buffer_strategy_lock来保护的,而不是buf_hdr_lock。LWLocks字段可以自己管好自己。
 * 注意buf_hdr_lock *不是* 用来控制对缓冲区内数据的访问的!
 *
 * 一个例外是,如果我们固定了(pinned)缓冲区,它的标签除了我们自己之外不会被偷偷修改。
 * 所以我们无需锁定自旋锁就可以检视该标签。此外,一次性的标记读取也无需锁定自旋锁,
 * 当我们期待测试标记位不会改变时,这种做法很常见。
 *
 * 如果另一个后端固定了该缓冲区,我们就无法从磁盘页面上物理移除项目了。因此后端需要等待
 * 所有其他的钉被移除。移除时它会得到通知,这是通过将它的PID存到wait_backend_pid,
 * 并设置BM_PIN_COUNT_WAITER标记为而实现的。就目前而言,每个缓冲区只能有一个等待者。
 *
 * 对于本地缓冲区,我们也使用同样的首部,不过锁字段就没用了,一些标记位也没用了。
 */
typedef struct sbufdesc
{
   BufferTag    tag;                 /* 存储在缓冲区中页面的标识 */
   BufFlags     flags;               /* 标记位 */
   uint16       usage_count;         /* 时钟扫描要用到的引用计数 */
   unsigned     refcount;            /* 在本缓冲区上持有pin的后端进程数 */
   int          wait_backend_pid;    /* 等着Pin本缓冲区的后端进程PID */
   slock_t      buf_hdr_lock;        /* 用于保护上述字段的锁 */
   int          buf_id;              /* 缓冲的索引编号 (从0开始) */
   int          freeNext;            /* 空闲链表中的链接 */

   LWLockId     io_in_progress_lock; /* 等待I/O完成的锁 */
   LWLockId     content_lock;        /* 访问缓冲区内容的锁 */
} BufferDesc;
  • tag 保存着目标页面的buffer_tag,该页面存储在相应的缓冲池槽中(缓冲区标签的定义在8.1.2节给出)。

  • buffer_id 标识了缓冲区描述符(亦相当于对应缓冲池槽的buffer_id)。

  • refcount 保存当前访问相应页面的PostgreSQL进程数,也被称为钉数(pin count)。当PostgreSQL进程访问相应页面时,其引用计数必须自增1(refcount ++)。访问结束后其引用计数必须减1(refcount--)。 当refcount为零,即页面当前并未被访问时,页面将取钉(unpinned) ,否则它会被钉住(pinned)

  • usage_count 保存着相应页面加载至相应缓冲池槽后的访问次数。usage_count会在页面置换算法中被用到(第8.4.4节)。

  • context_lock 和 **io_in_progress_lock**是轻量级锁,用于控制对相关页面的访问。第8.3.2节将介绍这些字段。

  • flags 用于保存相应页面的状态,主要状态如下:

    • 脏位(dirty bit 指明相应页面是否为脏页。
    • 有效位(valid bit 指明相应页面是否可以被读写(有效)。例如,如果该位被设置为"valid",那就意味着对应的缓冲池槽中存储着一个页面,而该描述符中保存着该页面的元数据,因而可以对该页面进行读写。反之如果有效位被设置为"invalid",那就意味着该描述符中并没有保存任何元数据;即,对应的页面无法读写,缓冲区管理器可能正在将该页面换出。
    • IO进行标记位(io_in_progress) 指明缓冲区管理器是否正在从存储中读/写相应页面。换句话说,该位指示是否有一个进程正持有此描述符上的io_in_pregress_lock
  • freeNext 是一个指针,指向下一个描述符,并以此构成一个空闲列表(freelist),细节在下一小节中介绍。

结构BufferDesc定义于src/include/storage/buf_internals.h中。

为了简化后续章节的描述,这里定义三种描述符状态:

  • 空(Empty:当相应的缓冲池槽不存储页面(即refcountusage_count都是0),该描述符的状态为
  • 钉住(Pinned:当相应缓冲池槽中存储着页面,且有PostgreSQL进程正在访问的相应页面(即refcountusage_count都大于等于1),该缓冲区描述符的状态为钉住
  • 未钉住(Unpinned:当相应的缓冲池槽存储页面,但没有PostgreSQL进程正在访问相应页面时(即 usage_count大于或等于1,但refcount为0),则此缓冲区描述符的状态为未钉住

每个描述符都处于上述状态之一。描述符的状态会根据特定条件而改变,这将在下一小节中描述。

在下图中,缓冲区描述符的状态用彩色方框表示。

  • ${□}$(白色)
  • $\color{blue}{█}$(蓝色)钉住
  • $\color{cyan}{█}$(青色)未钉住

此外,脏页面会带有“X”的标记。例如一个未固定的脏描述符用 $\color{cyan}☒$ 表示。

8.2.3 缓冲区描述符层

缓冲区描述符的集合构成了一个数组。本书称该数组为缓冲区描述符层(buffer descriptors layer)

当PostgreSQL服务器启动时,所有缓冲区描述符的状态都为。在PostgreSQL中,这些描述符构成了一个名为**freelist**的链表,如图8.5所示。

图8.5 缓冲区管理器初始状态

请注意PostgreSQL中的**freelist**完全不同于Oracle中freelists的概念。PostgreSQL的freelist只是空缓冲区描述符的链表。PostgreSQL中与Oracle中的freelist相对应的对象是空闲空间映射(FSM)(第5.3.4节)。

图8.6展示了第一个页面是如何加载的。

  1. freelist的头部取一个空描述符,并将其钉住(即,将其refcountusage_count增加1)。
  2. 在缓冲表中插入新项,该缓冲表项保存了页面buffer_tag与所获描述符buffer_id之间的关系。
  3. 将新页面从存储器加载至相应的缓冲池槽中。
  4. 将新页面的元数据保存至所获取的描述符中。

第二页,以及后续页面都以类似方式加载,其他细节将在第8.4.2节中介绍。

图8.6 加载第一页

freelist中摘出的描述符始终保存着页面的元数据。换而言之,仍然在使用的非空描述符不会返还到freelist中。但当下列任一情况出现时,描述符状态将变为“空”,并被重新插入至freelist中:

  1. 相关表或索引已被删除。
  2. 相关数据库已被删除。
  3. 相关表或索引已经被VACUUM FULL命令清理了。

为什么使用freelist来维护空描述符?

保留freelist的原因是为了能立即获取到一个描述符。这是内存动态分配的常规做法,详情参阅这里的说明

缓冲区描述符层包含着一个32位无符号整型变量**nextVictimBuffer**。此变量用于8.4.4节将介绍的页面置换算法。

8.2.4 缓冲池

缓冲池只是一个用于存储关系数据文件(例如表或索引)页面的简单数组。缓冲池数组的序号索引也就是buffer_id

缓冲池槽的大小为8KB,等于页面大小,因而每个槽都能存储整个页面。

8.3 缓冲区管理器锁

缓冲区管理器会出于不同的目的使用各式各样的锁,本节将介绍理解后续部分所必须的一些锁。

注意本节中描述的锁,指的是是缓冲区管理器同步机制的一部分。它们与SQL语句和SQL操作中的锁没有任何关系。

8.3.1 缓冲表锁

**BufMappingLock**保护整个缓冲表的数据完整性。它是一种轻量级的锁,有共享模式与独占模式。在缓冲表中查询条目时,后端进程会持有共享的BufMappingLock。插入或删除条目时,后端进程会持有独占的BufMappingLock

BufMappingLock会被分为多个分区,以减少缓冲表中的争用(默认为128个分区)。每个BufMappingLock分区都保护着一部分相应的散列桶槽。

图8.7给出了一个BufMappingLock分区的典型示例。两个后端进程可以同时持有各自分区的BufMappingLock独占锁以插入新的数据项。如果BufMappingLock是系统级的锁,那么其中一个进程就需要等待另一个进程完成处理。

图8.7 两个进程同时获取相应分区的BufMappingLock独占锁,以插入新数据项

缓冲表也需要许多其他锁。例如,在缓冲表内部会使用**自旋锁(spin lock)**来删除数据项。不过本章不需要其他这些锁的相关知识,因此这里省略了对其他锁的介绍。

在9.4版本之前,BufMappingLock在默认情况下被分为16个独立的锁。

8.3.2 缓冲区描述符相关的锁

每个缓冲区描述符都会用到两个轻量级锁 —— content_lockio_in_progress_lock,来控制对相应缓冲池槽页面的访问。当检查或更改描述符本身字段的值时,则会用到自旋锁。

8.3.2.1 内容锁(content_lock

content_lock是一个典型的强制限制访问的锁。它有两种模式:共享(shared)独占(exclusive)

当读取页面时,后端进程以共享模式获取页面相应缓冲区描述符中的content_lock

但执行下列操作之一时,则会获取独占模式的content_lock

  • 将行(即元组)插入页面,或更改页面中元组的t_xmin/t_xmax字段时(t_xmint_xmax第5.2节中介绍,简单地说,这些字段会在相关元组被删除或更新行时发生更改)。
  • 物理移除元组,或压紧页面上的空闲空间(由清理过程和HOT执行,分别在第6章第7章中介绍)。
  • 冻结页面中的元组(冻结过程在第5.10.1节第6.3节中介绍)。

官方README文件包含更多的细节。

8.3.2.2 IO进行锁(io_in_progress_lock

io_in_progress_lock用于等待缓冲区上的I/O完成。当PostgreSQL进程加载/写入页面数据时,该进程在访问页面期间,持有对应描述符上独占的io_in_progres_lock

8.3.2.3 自旋锁(spinlock

当检查或更改标记字段与其他字段时(例如refcountusage_count),会用到自旋锁。下面是两个使用自旋锁的具体例子:

  1. 下面是钉住缓冲区描述符的例子:
    1. 获取缓冲区描述符上的自旋锁。
    2. 将其refcountusage_count的值增加1。
    3. 释放自旋锁。
  • LockBufHdr(bufferdesc);    /* 获取自旋锁 */
    bufferdesc->refcont++;
    bufferdesc->usage_count++;
    UnlockBufHdr(bufferdesc);  /* 释放该自旋锁 */
    
  1. 下面是将脏位设置为"1"的例子:

    1. 获取缓冲区描述符上的自旋锁。
    2. 使用位操作将脏位置位为"1"
    3. 释放自旋锁。
  • #define BM_DIRTY             (1 << 0)    /* 数据需要写回 */
    #define BM_VALID             (1 << 1)    /* 数据有效 */
    #define BM_TAG_VALID         (1 << 2)    /* 已经分配了TAG */
    #define BM_IO_IN_PROGRESS    (1 << 3)    /* 正在进行读写 */
    #define BM_JUST_DIRTIED      (1 << 5)    /* 开始写之后刚写脏 */
    
    LockBufHdr(bufferdesc);
    bufferdesc->flags |= BM_DIRTY;
    UnlockBufHdr(bufferdesc);
    
    其他标记位也是通过同样的方式来设置的。

用原子操作替换缓冲区管理器的自旋锁

在9.6版本中,缓冲区管理器的自旋锁被替换为原子操作,可以参考这个提交日志的内容。如果想进一步了解详情,可以参阅这里的讨论

附,9.6版本中缓冲区描述符的数据结构定义。

/* src/include/storage/buf_internals.h  (since 9.6, 移除了一些字段) */

/* 缓冲区描述符的标记位定义(since 9.6)
 * 注意:TAG_VALID实际上意味着缓冲区哈希表中有一条与本tag关联的项目。
 */
#define BM_LOCKED				(1U << 22)	/* 缓冲区首部被锁定 */
#define BM_DIRTY				(1U << 23)	/* 数据需要写入 */
#define BM_VALID				(1U << 24)	/* 数据有效 */
#define BM_TAG_VALID			(1U << 25)	/* 标签有效,已经分配 */
#define BM_IO_IN_PROGRESS		(1U << 26)	/* 读写进行中 */
#define BM_IO_ERROR				(1U << 27)	/* 先前的I/O失败 */
#define BM_JUST_DIRTIED			(1U << 28)	/* 写之前已经脏了 */
#define BM_PIN_COUNT_WAITER		(1U << 29)	/* 有人等着钉页面 */
#define BM_CHECKPOINT_NEEDED	(1U << 30)	/* 必需在检查点时写入 */
#define BM_PERMANENT			(1U << 31)	/* 永久缓冲 */


/* BufferDesc -- 单个共享缓冲区的共享描述符/共享状态
 * 
 * 注意: 读写tag, state, wait_backend_pid 等字段时必须持有缓冲区首部锁(BM_LOCKED标记位)
 * 简单地说,refcount, usagecount,标记位组合起来被放入一个原子变量state中,而缓冲区首部锁
 * 实际上是嵌入标记位中的一个bit。 这种设计允许我们使用单个原子操作,而不是获取/释放自旋锁
 * 来实现一些操作。举个例子,refcount的增减。buf_id字段在初始化之后再也不会改变,所以不需要锁。
 * freeNext是通过buffer_strategy_lock而非buf_hdr_lock来保护的。LWLocks字段可以自己管好自
 * 己。注意buf_hdr_lock *不是* 用来控制对缓冲区内数据的访问的!
 *
 * 我们假设当持有首部锁时,没人会修改state字段。因此持有缓冲区首部锁的人可以在一次写入中
 * 中对state变量进行很复杂的更新,包括更新完的同时释放锁(清理BM_LOCKED标记位)。此外,不持有
 * 缓冲区首部锁而对state进行更新仅限于CAS操作,它能确保操作时BM_LOCKED标记位没有被置位。
 * 不允许使用原子自增/自减,OR/AND等操作。
 *
 * 一个例外是,如果我们固定了(pinned)该缓冲区,它的标签除了我们自己之外不会被偷偷修改。
 * 所以我们无需锁定自旋锁就可以检视该标签。此外,一次性的标记读取也无需锁定自旋锁,
 * 当我们期待测试标记位不会改变时,这种做法很常见。
 *
 * 如果另一个后端固定了该缓冲区,我们就无法从磁盘页面上物理移除项目了。因此后端需要等待
 * 所有其他的钉被移除。移除时它会得到通知,这是通过将它的PID存到wait_backend_pid,并设置
 * BM_PIN_COUNT_WAITER标记为而实现的。目前而言,每个缓冲区只能有一个等待者。
 *
 * 对于本地缓冲区,我们也使用同样的首部,不过锁字段就没用了,一些标记位也没用了。为了避免不必要
 * 的额外开销,对state字段的操作不需要用实际的原子操作(即pg_atomic_read_u32,
 * pg_atomic_unlocked_write_u32)
 *
 * 增加该结构的尺寸,增减,重排该结构的成员时需要特别小心。保证该结构体小于64字节对于性能
 * 至关重要(最常见的CPU缓存线尺寸)。
 */
typedef struct BufferDesc
{
	BufferTag	tag;			/* 存储在缓冲区中页面的标识 */
	int			buf_id;			/* 缓冲区的索引编号 (从0开始) */

	/* 标记的状态,包含标记位,引用计数,使用计数 */
    /* 9.6使用原子操作替换了很多字段的功能 */
	pg_atomic_uint32 state;

	int			wait_backend_pid;	/* 等待钉页计数的后端进程PID */
	int			freeNext;		    /* 空闲链表中的链接 */

	LWLock		content_lock;	    /* 访问缓冲内容的锁 */
} BufferDesc;

8.4 缓冲区管理器的工作原理

本节介绍缓冲区管理器的工作原理。当后端进程想要访问所需页面时,它会调用ReadBufferExtended函数。

函数ReadBufferExtended的行为依场景而异,在逻辑上具体可以分为三种情况。每种情况都将用一小节介绍。最后一小节将介绍PostgreSQL中基于**时钟扫描(clock-sweep)**的页面置换算法。

8.4.1 访问存储在缓冲池中的页面

首先来介绍最简单的情况,即,所需页面已经存储在缓冲池中。在这种情况下,缓冲区管理器会执行以下步骤:

  1. 创建所需页面的buffer_tag(在本例中buffer_tag'Tag_C'),并使用散列函数计算与描述符相对应的散列桶槽。
  2. 获取相应散列桶槽分区上的BufMappingLock共享锁(该锁将在步骤(5)中被释放)。
  3. 查找标签为"Tag_C"的条目,并从条目中获取buffer_id。本例中buffer_id为2。
  4. buffer_id=2的缓冲区描述符钉住,即将描述符的refcountusage_count增加1(8.3.2节描述了钉住)。
  5. 释放BufMappingLock
  6. 访问buffer_id=2的缓冲池槽。

图8.8 访问存储在缓冲池中的页面。

然后,当从缓冲池槽中的页面里读取行时,PostgreSQL进程获取相应缓冲区描述符的共享content_lock。因而缓冲池槽可以同时被多个进程读取。

当向页面插入(及更新、删除)行时,该postgres后端进程获取相应缓冲区描述符的独占content_lock(注意这里必须将相应页面的脏位置位为"1")。

访问完页面后,相应缓冲区描述符的引用计数值减1。

8.4.2 将页面从存储加载至空槽

在第二种情况下,假设所需页面不在缓冲池中,且freelist中有空闲元素(空描述符)。在这种情况下,缓冲区管理器将执行以下步骤:

  1. 查找缓冲区表(本节假设页面不存在,找不到对应页面)。

    1. 创建所需页面的buffer_tag(本例中buffer_tag'Tag_E')并计算其散列桶槽。

    2. 以共享模式获取相应分区上的BufMappingLock

    3. 查找缓冲区表(根据假设,这里没找到)。

    4. 释放BufMappingLock

  2. freelist中获取空缓冲区描述符,并将其钉住。在本例中所获的描述符buffer_id=4

  3. 独占模式获取相应分区的BufMappingLock(此锁将在步骤(6)中被释放)。

  4. 创建一条新的缓冲表数据项:buffer_tag='Tag_E’, buffer_id=4,并将其插入缓冲区表中。

  5. 将页面数据从存储加载至buffer_id=4的缓冲池槽中,如下所示:

    1. 以排他模式获取相应描述符的io_in_progress_lock

    2. 将相应描述符的IO_IN_PROGRESS标记位设置为1,以防其他进程访问。

    3. 将所需的页面数据从存储加载到缓冲池插槽中。

    4. 更改相应描述符的状态;将IO_IN_PROGRESS标记位置位为"0",且VALID标记位被置位为"1"

    5. 释放io_in_progress_lock

  6. 释放相应分区的BufMappingLock

  7. 访问buffer_id=4的缓冲池槽。

图8.9 将页面从存储装载到空插槽

8.4.3 将页面从存储加载至受害者缓冲池槽中

在这种情况下,假设所有缓冲池槽位都被页面占用,且未存储所需的页面。缓冲区管理器将执行以下步骤:

  1. 创建所需页面的buffer_tag并查找缓冲表。在本例中假设buffer_tag'Tag_M'(且相应的页面在缓冲区中找不到)。

  2. 使用时钟扫描算法选择一个受害者缓冲池槽位,从缓冲表中获取包含着受害者槽位buffer_id的旧表项,并在缓冲区描述符层将受害者槽位的缓冲区描述符钉住。本例中受害者槽的buffer_id=5,旧表项为Tag_F, id = 5。时钟扫描将在下一节中介绍。

  3. 如果受害者页面是脏页,将其刷盘(write & fsync),否则进入步骤(4)。

    在使用新数据覆盖脏页之前,必须将脏页写入存储中。脏页的刷盘步骤如下:

    1. 获取buffer_id=5描述符上的共享content_lock和独占io_in_progress_lock(在步骤6中释放)。

    2. 更改相应描述符的状态:相应IO_IN_PROCESS位被设置为"1"JUST_DIRTIED位设置为"0"

    3. 根据具体情况,调用XLogFlush()函数将WAL缓冲区上的WAL数据写入当前WAL段文件(详细信息略,WAL和XLogFlush函数在第9章中介绍)。

    4. 将受害者页面的数据刷盘至存储中。

    5. 更改相应描述符的状态;将IO_IN_PROCESS位设置为"0",将VALID位设置为"1"

    6. 释放io_in_progress_lockcontent_lock

  4. 以排他模式获取缓冲区表中旧表项所在分区上的BufMappingLock

  5. 获取新表项所在分区上的BufMappingLock,并将新表项插入缓冲表:

    1. 创建由新表项:由buffer_tag='Tag_M'与受害者的buffer_id组成的新表项。
    2. 以独占模式获取新表项所在分区上的BufMappingLock
    3. 将新表项插入缓冲区表中。

图8.10 将页面从存储加载至受害者缓冲池槽

  1. 从缓冲表中删除旧表项,并释放旧表项所在分区的BufMappingLock

  2. 将目标页面数据从存储加载至受害者槽位。然后用buffer_id=5更新描述符的标识字段;将脏位设置为0,并按流程初始化其他标记位。

  3. 释放新表项所在分区上的BufMappingLock

  4. 访问buffer_id=5对应的缓冲区槽位。

图8.11 将页面从存储加载至受害者缓冲池槽(接图8.10)

8.4.4 页面替换算法:时钟扫描

本节的其余部分介绍了**时钟扫描(clock-sweep)算法。该算法是NFU(Not Frequently Used)**算法的变体,开销较小,能高效地选出较少使用的页面。

我们将缓冲区描述符想象为一个循环列表(如图8.12所示)。而nextVictimBuffer是一个32位的无符号整型变量,它总是指向某个缓冲区描述符并按顺时针顺序旋转。该算法的伪代码与算法描述如下:

伪代码:时钟扫描

    WHILE true
(1)     获取nextVictimBuffer指向的缓冲区描述符
(2)     IF 缓冲区描述符没有被钉住 THEN
(3)	        IF 候选缓冲区描述符的 usage_count == 0 THEN
	            BREAK WHILE LOOP  /* 该描述符对应的槽就是受害者槽 */
	        ELSE
		        将候选描述符的 usage_count - 1
            END IF
        END IF
(4)     迭代 nextVictimBuffer,指向下一个缓冲区描述符
    END WHILE 
(5) RETURN 受害者页面的 buffer_id
  1. 获取nextVictimBuffer指向的候选缓冲区描述符(candidate buffer descriptor)
  2. 如果候选描述符未被钉住(unpinned),则进入步骤(3), 否则进入步骤(4)。
  3. 如果候选描述符的usage_count为0,则选择该描述符对应的槽作为受害者,并进入步骤(5);否则将此描述符的usage_count减1,并继续执行步骤(4)。
  4. nextVictimBuffer迭代至下一个描述符(如果到末尾则回绕至头部)并返回步骤(1)。重复至找到受害者。
  5. 返回受害者的buffer_id

具体的例子如图8.12所示。缓冲区描述符为蓝色或青色的方框,框中的数字显示每个描述符的usage_count

图8.12 时钟扫描

  1. nextVictimBuffer指向第一个描述符(buffer_id = 1);但因为该描述符被钉住了,所以跳过。
  2. extVictimBuffer指向第二个描述符(buffer_id = 2)。该描述符未被钉住,但其usage_count为2;因此该描述符的usage_count将减1,而nextVictimBuffer迭代至第三个候选描述符。
  3. nextVictimBuffer指向第三个描述符(buffer_id = 3)。该描述符未被钉住,但其usage_count = 0,因而成为本轮的受害者。

nextVictimBuffer扫过未固定的描述符时,其usage_count会减1。因此只要缓冲池中存在未固定的描述符,该算法总能在旋转若干次nextVictimBuffer后,找到一个usage_count为0的受害者。

8.5 环形缓冲区

在读写大表时,PostgreSQL会使用**环形缓冲区(ring buffer)**而不是缓冲池。环形缓冲器是一个很小的临时缓冲区域。当满足下列任一条件时,PostgreSQL将在共享内存中分配一个环形缓冲区:

  1. 批量读取

    当扫描关系读取数据的大小超过缓冲池的四分之一(shared_buffers/4)时,在这种情况下,环形缓冲区的大小为256 KB

  2. 批量写入

    当执行下列SQL命令时,这种情况下,环形缓冲区大小为16 MB

  3. 清理过程

    当自动清理守护进程执行清理过程时,这种情况环形缓冲区大小为256 KB。

分配的环形缓冲区将在使用后被立即释放。

环形缓冲区的好处显而易见,如果后端进程在不使用环形缓冲区的情况下读取大表,则所有存储在缓冲池中的页面都会被移除(踢出),因而会导致缓存命中率降低。环形缓冲区可以避免此问题。

为什么批量读取和清理过程的默认环形缓冲区大小为256 KB?

为什么是256 KB?源代码中缓冲区管理器目录下的README中解释了这个问题。

顺序扫描使用256KB的环缓冲。它足够小,因而能放入L2缓存中,从而使得操作系统缓存到共享缓冲区的页面传输变得高效。通常更小一点也可以,但环形缓冲区必需足够大到能同时容纳扫描中被钉住的所有页面。

8.6 脏页刷盘

除了置换受害者页面之外,**检查点进程(Checkpointer)**进程和后台写入器进程也会将脏页刷写至存储中。尽管两个进程都具有相同的功能(刷写脏页),但它们有着不同的角色和行为。

检查点进程将**检查点记录(checkpoint record)**写入WAL段文件,并在检查点开始时进行脏页刷写。9.7节介绍了检查点,以及检查点开始的时机。

后台写入器的目的是通过少量多次的脏页刷盘,减少检查点带来的密集写入的影响。后台写入器会一点点地将脏页落盘,尽可能减小对数据库活动造成的影响。默认情况下,后台写入器每200毫秒被唤醒一次(由参数bgwriter_delay定义),且最多刷写bgwriter_lru_maxpages个页面(默认为100个页面)。

为什么检查点进程与后台写入器相分离?

在9.1版及更早版本中,后台写入器会规律性的执行检查点过程。在9.2版本中,检查点进程从后台写入器进程中被单独剥离出来。原因在一篇题为”将检查点进程与后台写入器相分离“的提案中有介绍。下面是一些摘录:

当前(在2011年)后台写入器进程既执行后台写入,又负责检查点,还处理一些其他的职责。这意味着我们没法在不停止后台写入的情况下执行检查点最终的fsync。因此,在同一个进程中做两件事会有负面的性能影响。

此外,在9.2版本中,我们的一个目标是通过将轮询循环替换为锁存器,从而降低功耗。bgwriter中的循环复杂度太高了,以至于无法找到一种简洁的使用锁存器的方法。

第九章 预写式日志

事务日志(transaction log)是数据库的关键组件,因为当出现系统故障时,任何数据库管理系统都不允许丢失数据。事务日志是数据库系统中所有**变更(change)行为(action)**的历史记录,当诸如电源故障,或其他服务器错误导致服务器崩溃时,它被用于确保数据不会丢失。由于日志包含每个已执行事务的相关充分信息,因此当服务器崩溃时,数据库服务器应能通过重放事务日志中的变更与行为来恢复数据库集群。

在计算机科学领域,WAL是Write Ahead Logging的缩写,它指的是将变更与行为写入事务日志的协议或规则;而在PostgreSQL中,WAL是Write Ahead Log的缩写。在这里它被当成事务日志的同义词,而且也用来指代一种将行为写入事务日志(WAL)的实现机制。虽然有些令人困惑, 但本文将使用PostgreSQL中的定义。

WAL机制在7.1版本中首次被实现,用以减轻服务器崩溃的影响。它还是**时间点恢复(Point-in-Time Recovery PIRT)流复制(Streaming Replication, SR)**实现的基础,这两者将分别在第10章第11章中介绍。

尽管理解WAL机制对于管理、集成PostgreSQL非常重要,但由于它的复杂性,不可能做到简要介绍。因此本章将会对WAL做一个完整的解释。第一节描绘了WAL的全貌,介绍了一些重要的概念与关键词。接下来的小节中会依次讲述其他主题:

  • 事务日志(WAL)的逻辑结构与物理结构
  • WAL数据的内部布局
  • WAL数据的写入
  • WAL写入者进程
  • 检查点过程
  • 数据库恢复流程
  • 管理WAL段文件
  • 持续归档

9.1 概述

让我们先来概述一下WAL机制。为了阐明WAL要解决的问题,第一部分展示了如果PostgreSQL在没有实现WAL时崩溃会发生什么。第二部分介绍了一些关键概念,并概览了本章中的一些关键主题。最后一部分总结了WAL概述部分,并引出了一个更为重要的概念。

9.1.1 没有WAL的插入操作

正如在第八章中讨论的那样,为了能高效访问关系表的页面,几乎所有的DBMS都实现了共享缓冲池。

假设有这样一个没有实现WAL机制的PostgreSQL,现在向表A中插入一些数据元组,如图9.1所示。

图9.1 没有WAL的插入操作

图9.1 没有WAL的插入操作

  1. 发起第一条INSERT语句时,PostgreSQL从数据库集簇文件中加载表A的页面到内存中的共享缓冲池。然后向页面中插入一条元组。页面并没有立刻写回到数据库集簇文件中。正如第8章中提到的,被修改过的页面通常称为脏页(dirty page)
  2. 发起第二条INSERT语句时,PostgreSQL直接向缓冲池里的页面内添加了一条新元组。这一页仍然没有被写回到持久存储中。
  3. 如果操作系统或PostgreSQL服务器因为各种原因失效(例如电源故障),所有插入的数据都会丢失。

因此没有WAL的数据库在系统崩溃时是很脆弱的。

9.1.2 插入操作与数据库恢复

为了解决上述系统失效问题,同时又不招致性能损失,PostgreSQL支持了WAL。这一部分介绍了一些关键词和概念,以及WAL数据的写入和数据库系统的恢复。

为了应对系统失效,PostgreSQL将所有修改作为历史数据写入持久化存储中。这份历史数据称为XLOG记录(xlog record)WAL数据(wal data)

当插入、删除、提交等变更动作发生时,PostgreSQL会将XLOG记录写入内存中的WAL缓冲区(WAL Buffer)。当事务提交或中止时,它们会被立即写入持久存储上的**WAL段文件(WAL segment file)中(更精确来讲,其他场景也可能会有XLOG记录写入,细节将在9.5节中描述)。XLOG记录的日志序列号(Log Sequence Number, LSN)**标识了该记录在事务日志中的位置,记录的LSN被用作XLOG记录的唯一标识符。

顺便一提,当我们考虑数据库系统如何恢复时,可能会想到一个问题:PostgreSQL是从哪一点开始恢复的?答案是重做点(REDO Point),即最新一个检查点(Checkpoint)开始时XLOG记录写入的位置。(PostgreSQL中的检查点将在9.7节中描述)。实际上,数据库恢复过程与检查点过程紧密相连,两者是不可分割的。

WAL与检查点过程在7.1版本中同时实现

介绍完了主要的关键词与概念,现在来说一下带有WAL时的元组插入操作。如图9.2所示:

图9.2 带有WAL的插入操作

图9.2 带有WAL的插入操作

表A的LSN展示的是表A页面中页首部里pd_lsn类型的PageXLogRecPtr字段,与页面的LSN是一回事。

  1. 检查点进程是一个后台进程,周期性地执行过程。当检查点进程开始执行检查点时,它会向当前WAL段文件写入一条XLOG记录,称为检查点(Checkpoint Record)。这条记录包含了最新的重做点位置。
  2. 发起第一条INSERT语句时,PostgreSQL从数据库集簇文件中加载表A的页面至内存中的共享缓冲池,向页面中插入一条元组,然后在LSN_1位置创建并写入一条相应的XLOG记录,然后将表A的LSN从LSN_0更新为LSN_1。在本例中,XLOG记录是由首部数据与完整元组组成的一对值。
  3. 当该事务提交时,PostgreSQL向WAL缓冲区创建并写入一条关于该提交行为的XLOG记录,然后将WAL缓冲区中的所有XLOG记录刷写入WAL段文件中。
  4. 发起第二条INSERT语句时,PostgreSQL向页面中插入一条新元组,然后在LSN_2位置创建并写入一条相应的XLOG记录,然后将表A的LSN从LSN_1更新为LSN_2
  5. 当这条语句的事务提交时,PostgreSQL执行同步骤3类似的操作。
  6. 设想当操作系统失效发生时,尽管共享缓冲区中的所有数据都丢失了,但所有页面修改已经作为历史记录被写入WAL段文件中。

接下来的步骤展示了如何将数据库集簇恢复到崩溃时刻前的状态。不需要任何特殊的操作,重启PostgreSQL时会自动进入恢复模式,如图9.3所示。PostgreSQL会从重做点开始,依序读取正确的WAL段文件并重放XLOG记录。

图9.3 使用WAL进行数据库恢复

图9.3 使用WAL进行数据库恢复

  1. PostgreSQL从相关的WAL段文件中读取第一条INSERT语句的XLOG记录,并从硬盘上的数据库集簇目录加载表A的页面到内存中的共享缓冲区中。
  2. 在重放XLOG记录前,PostgreSQL会比较XLOG记录的LSN与相应页面的LSN。这么做的原因在第9.8节中描述。重放XLOG记录的规则如下所示:
    • 如果XLOG记录的LSN要比页面LSN大,XLOG记录中的数据部分就会被插入到页面中,并将页面的LSN更新为XLOG记录的LSN。
    • 如果XLOG记录的LSN要比页面的LSN小,那么不用做任何事情,直接读取后续的WAL数据即可。
  3. PostgreSQL按照同样的方式重放其余的XLOG记录。

PostgreSQL可以通过按时间顺序重放写在WAL段文件中的XLOG记录来自我恢复,因此,PostgreSQL的XLOG记录显然是一种重做日志(REDO log)

PostgreSQL不支持撤销日志(UNDO log)

尽管写XLOG记录肯定有一定的代价,这些代价和全页写入相比微不足道。所付出的代价换来了巨大的收益,比如,系统崩溃时的恢复能力。

9.1.3 整页写入

假设后台写入进程在写入脏页的过程中出现了操作系统故障,导致磁盘上表A的页面数据损坏。XLOG是无法在损坏的页面上重放的,我们需要其他功能来确保这一点。

译注:PostgreSQL默认使用8KB的页面,操作系统通常使用4KB的页面,可能出现只写入一个4KB页面的情况。

PostgreSQL支持诸如整页写入(full-page write)的功能来处理这种失效。如果启用,PostgreSQL会在每次检查点之后,在每个页面第一次发生变更时,会将整个页面及相应首部作为一条XLOG记录写入。这个功能默认是开启的。在PostgreSQL中,这种包含完整页面的XLOG记录称为备份区块(backup block),或者整页镜像(full-page image)

图9.4 整页写入

图9.4 整页写入

  1. 检查点进程开始进行检查点过程。
  2. 在第一条INSERT语句进行插入操作时,PostgreSQL执行的操作几乎同上所述。区别在于这里的XLOG记录是当前页的备份区块(即,包含了完整的页面),因为这是自最近一次检查点以来,该页面的第一次写入。
  3. 当事务提交时,PostgreSQL的操作同上节所述。
  4. 第二条INSERT语句进行插入操作时,PostgreSQL的操作同上所述,这里的XLOG记录就不是备份块了。
  5. 当这条语句的事务提交时,PostgreSQL的操作同上节所述。
  6. 为了说明整页写入的效果,我们假设后台写入进程在向磁盘写入脏页的过程中出现了操作系统故障,导致磁盘上表A的页面数据损坏。

重启PostgreSQL即可修复损坏的集簇,如图9.5所示

图9.5 使用备份区块进行数据库恢复

图9.5 使用备份区块进行数据库恢复

  1. PostgreSQL读取第一条INSERT语句的XLOG记录,并从数据库集簇目录加载表A的页面至共享缓冲池中。在本例中,按照整页写入的规则,这条XLOG记录是一个备份区块。

  2. 当一条XLOG记录是备份区块时,会使用另一条重放规则:XLOG记录的数据部分会直接覆盖当前页面,无视页面或XLOG记录中的LSN,然后将页面的LSN更新为XLOG记录的LSN。

    在本例中,PostgreSQL使用记录的数据部分覆写了损坏的页面,并将表A的LSN更新为LSN_1,通过这种方式,损坏的页面通过它自己的备份区块恢复回来了。

  3. 因为第二条XLOG记录不是备份区块, 因此PostgreSQL的操作同上所述。

即使发生一些数据写入错误,PostgreSQL也能从中恢复。(当然如果发生文件系统或物理介质失效,就不行了)

9.2 事务日志与WAL段文件

PostgreSQL在逻辑上将XLOG记录写入事务日志,即,一个长度用8字节表示的虚拟文件(16 EB)。

虽说事务日志的容量实际上应该是无限的,但8字节长度的地址空间已经足够宽广了。目前是不可能处理这个量级的单个文件的。因此PostgreSQL中的事务日志实际上默认被划分为16M大小的一系列文件,这些文件被称作WAL段(WAL Segment)。如图9.6所示。

WAL段文件尺寸

从版本11开始,在使用initdb创建数据库时,可以通过--wal-segsize选项来配置WAL段文件的大小。

图9.6 事务日志与WAL段文件

图9.6 事务日志与WAL段文件

WAL段文件的文件名是由24个十六进制数字组成的,其命名规则如下: $$ \begin{align} \verb|WAL段文件名| = \verb|timelineId| + (\verb|uint32|) \frac{\verb|LSN|-1}{16\verb|M|*256}
+ (\verb|uint32|)\left(\frac{\verb|LSN|-1}{16\verb|M|}\right) % 256 \end{align} $$

时间线标识

PostgreSQL的WAL有时间线标识(TimelineID,四字节无符号整数)的概念,用于第十章中所述的时间点恢复(PITR)。不过在本章中时间线标识将固定为0x00000001,因为接下来的几节里还不需要这个概念。

第一个WAL段文件名是$00000001\color{blue}{00000000}000000\color{blue}{01}$,如果第一个段被XLOG记录写满了,就会创建第二个段$00000001\color{blue}{00000000}000000\color{blue}{02}$,后续的文件名将使用升序。在$00000001\color{blue}{00000000}000000\color{blue}{FF}$被填满之后,就会使用下一个文件$00000001\color{blue}{00000001}000000\color{blue}{00}$。通过这种方式,每当最后两位数字要进位时,中间8位数字就会加一。与之类似,在$00000001\color{blue}{00000001}000000\color{blue}{FF}$被填满后,就会开始使用$00000001\color{blue}{00000002}000000\color{blue}{00}$,依此类推。

WAL文件名

使用内建的函数pg_xlogfile_name(9.6及以前的版本),或pg_walfile_name(10及以后的版本),我们可以找出包含特定LSN的WAL段文件。例如:

testdb=# SELECT pg_xlogfile_name('1/00002D3E');   -- 9.6-
testdb=# -- SELECT pg_walfile_name('1/00002D3E'); -- 10+

     pg_xlogfile_name     
--------------------------
 000000010000000100000000
(1 row)

9.3 WAL段文件的内部布局

一个WAL段文件大小默认为16MB,并在内部划分为大小为8192字节(8KB)的页面。第一个页包含了由XLogLongPageHeaderData定义的首部数据,其他的页包含了由XLogPageHeaderData定义的首部数据。每页在首部数据之后,紧接着就是以降序写入的XLOG记录,如图9.7所示。

图9.7 WAL段文件内部布局

图9.7 WAL段文件内部布局

XLogLongPageHeaderDataXLogPageHeaderData结构定义在 src/include/access/xlog_internal.h中。这两个结构的具体说明就不在此展开了,因为对于后续小节并非必需。

typedef struct XLogPageHeaderData
{
	uint16		xlp_magic;		/* 用于正确性检查的魔数 */
	uint16		xlp_info;		/* 标记位,详情见下 */
	TimeLineID	xlp_tli;		/* 页面中第一条记录的时间线ID */
	XLogRecPtr	xlp_pageaddr;	/* 当前页的XLOG地址 */
    
	/* 当本页放不下一条完整记录时,我们会在下一页继续,xlp_rem_len存储了来自先前页面
	 * 记录剩余的字节数。注意xl_rem_len包含了备份区块的数据,也就是说它会在第一个首部跟踪
	 * xl_tot_len而不是xl_len。还要注意延续的数据不一定是对齐的。*/
	uint32		xlp_rem_len;	/* 记录所有剩余数据的长度 */
} XLogPageHeaderData;

typedef XLogPageHeaderData *XLogPageHeader;

/* 当设置了XLP_LONG_HEADER标记位时,我们将在页首部中存储额外的字段。
 * (通常是在XLOG文件中的第一个页面中) 额外的字段用于确保文件的正确性。 */
typedef struct XLogLongPageHeaderData
{
  XLogPageHeaderData std;            /* 标准首部 */
  uint64             xlp_sysid;      /* 来自pg_control中的系统标识符 */
  uint32             xlp_seg_size;   /* 交叉校验 */
  uint32             xlp_xlog_blcksz;/* 交叉校验 */
} XLogLongPageHeaderData;

9.4 WAL记录的内部布局

一条XLOG记录由通用的首部部分与特定的数据部分构成。本章第一节描述了首部的结构,剩下两个节分别解释了9.5版本前后数据部分的结构。(9.5版本改变了数据格式)

9.4.1 WAL记录首部部分

所有的XLOG记录都有一个通用的首部,由结构XLogRecord定义。9.5更改了首部的定义,9.4及更早版本的结构定义如下所示:

typedef struct XLogRecord
{
   uint32          xl_tot_len;   /* 整条记录的全长 */
   TransactionId   xl_xid;       /* 事务ID */
   uint32          xl_len;       /* 资源管理器的数据长度 */
   uint8           xl_info;      /* 标记位,如下所示 */
   RmgrId          xl_rmid;      /* 本记录的资源管理器 */
   /* 这里有2字节的填充,初始化为0 */
   XLogRecPtr      xl_prev;      /* 在日志中指向先前记录的指针 */
   pg_crc32        xl_crc;       /* 本记录的CRC */
} XLogRecord;

除了两个变量,大多数变量的意思非常明显,无需多言。xl_rmidxl_info都是与**资源管理器(resource manager)**相关的变量,它是一些与WAL功能(写入,重放XLOG记录)相关的操作集合。资源管理器的数目随着PostgreSQL不断增加,第10版包括这些:

资源管理器
堆元组操作 RM_HEAP, RM_HEAP2
索引操作 RM_BTREE, RM_HASH, RM_GIN, RM_GIST, RM_SPGIST, RM_BRIN
序列号操作 RM_SEQ
事务操作 RM_XACT, RM_MULTIXACT, RM_CLOG, RM_XLOG, RM_COMMIT_TS
表空间操作 RM_SMGR, RM_DBASE, RM_TBLSPC, RM_RELMAP
复制与热备操作 RM_STANDBY, RM_REPLORIGIN, RM_GENERIC_ID, RM_LOGICALMSG_ID

下面是一些有代表性的例子,展示了资源管理器工作方式。

  • 如果发起的是INSERT语句,则其相应XLOG记录首部中的变量xl_rmidxl_info会相应地被设置为RM_HEAPXLOG_HEAP_INSERT。当恢复数据库集簇时,就会按照xl_info选用资源管理器RM_HEAP的函数heap_xlog_insert()来重放当前XLOG记录。
  • UPDATE语句与之类似,首部变量中的xl_info会被设置为XLOG_HEAP_UPDATE,而在数据库恢复时就会选用资源管理器RM_HEAP的函数heap_xlog_update()进行重放。
  • 当事务提交时,相应XLOG记录首部的变量xl_rmidxl_info会被相应地设置为RM_XACTXLOG_XACT_COMMIT。当数据库恢复时,RM_XACTxact_redo_commit()就会执行本记录的重放。

在9.5及之后的版本,首部结构XLogRecord移除了一个字段xl_len,精简了XLOG记录的格式,省了几个字节。

typedef struct XLogRecord
{
	uint32		xl_tot_len;		/* 整条记录的总长度 */
	TransactionId xl_xid;		/* 事物标识 xid */
	XLogRecPtr	xl_prev;		/* 指向日志中前一条记录的指针 */
	uint8		xl_info;		/* 标记位,详情见下*/
	RmgrId		xl_rmid;		/* 本条记录对应的资源管理器 */
	/* 这里有2字节的填充,初始化为0 */
	pg_crc32c	xl_crc;			/* 本记录的CRC */

	/* 紧随其后的是XLogRecordBlockHeaders 与 XLogRecordDataHeader ,不带填充 */

} XLogRecord;

9.4版本中的XLogRecord结构定义在src/include/access/xlog.h中,9.5及以后的定义在src/include/access/xlogrecord.hheap_xlog_insertheap_xlog_update定义在src/backend/access/heap/heapam.c ;而函数xact_redo_commit定义在src/backend/access/transam/xact.c

9.4.2 XLOG记录数据部分(9.4及以前)

XLOG记录的数据部分可以分为两类:备份区块(完整的页面),或非备份区块(不同的操作相应的数据不同)。

图9.8 XLOG记录的样例(9.4版本或更早)

让我们通过几个具体示例来了解XLOG记录的内部布局。

9.4.2.1 备份区块

备份区块如图9.8(a)所示,它由两个数据结构和一个数据对象组成,如下所述:

  1. 首部部分,XLogRecord结构体
  2. BkpBlock结构体
  3. 除去空闲空间的完整页面。

BkpBlock包括了用于在数据库集簇目录中定位该页面的变量(比如,包含该页面的关系表的RelFileNodeForkNumber,以及文件内的区块号BlockNumber),以及当前页面空闲空间的开始位置与长度。

# @include/access/xlog_internal.h
typedef struct BkpBlock 
{
  RelFileNode node;        /* 包含该块的关系 */
  ForkNumber  fork;        /* 关系的分支(main,vm,fsm,...) */
  BlockNumber block;       /* 区块号 */
  uint16      hole_offset; /* "空洞"前的字节数 */
  uint16      hole_length; /* "空洞"的长度 */

  /* 实际的区块数据紧随该结构体后 */
} BkpBlock;

9.4.2.2 非备份区块

在非备份区块中,数据部分的布局依不同操作而异。这里举一个具有代表性的例子:一条INSERT语句的XLOG记录。如图9.8(b)所示,INSERT语句的XLOG记录是由两个数据结构与一个数据对象组成的:

  1. 首部部分,XLogRecord结构体
  2. xl_heap_insert结构体
  3. 被插入的元组 —— 更精确地说,是移除了一些字节的元组。

结构体xl_heap_insert包含的变量用于在数据库集簇中定位被插入的元组。(即,包含该元组的表的RelFileNode,以及该元组的tid),以及该元组的可见性标记位。

typedef struct BlockIdData
{
   uint16          bi_hi;
   uint16          bi_lo;
} BlockIdData;

typedef uint16 OffsetNumber;

typedef struct ItemPointerData
{
   BlockIdData     ip_blkid;
   OffsetNumber    ip_posid;
}

typedef struct RelFileNode
{
   Oid             spcNode;             /* 表空间 */
   Oid             dbNode;              /* 数据库 */
   Oid             relNode;             /* 关系 */
} RelFileNode;

typedef struct xl_heaptid
{
   RelFileNode     node;				/* 关系定位符 */
   ItemPointerData tid;                 /* 元组在关系中的位置 */
} xl_heaptid;

typedef struct xl_heap_insert
{
   xl_heaptid      target;              /* 被插入的元组ID */
   bool            all_visible_cleared; /* PD_ALL_VISIBLE 是否被清除 */
} xl_heap_insert;

在结构体xl_heap_header的代码注释中解释了移除插入元组中若干字节的原因:

我们并没有在WAL中存储被插入或被更新元组的固定部分(即HeapTupleHeaderData,堆元组首部),我们可以在需要时从WAL中的其它部分重建这几个字段,以此节省一些字节。或者根本就无需重建。

这里还有一个例子值得一提,如图9.8(c)所示,检查点的XLOG记录相当简单,它由如下所示的两个数据结构组成:

  1. XLogRecord结构(首部部分)
  2. 包含检查点信息的CheckPoint结构体(参见9.7节

xl_heap_header结构定义在src/include/access/htup.h中,而CheckPoint结构体定义在src/include/catalog/pg_control.h中。

9.4.3 XLOG记录数据部分(9.5及后续版本)

在9.4及之前的版本,XLOG记录并没有通用的格式,因此每一种资源管理器都需要定义各自的格式。在这种情况下,维护源代码,以及实现与WAL相关的新功能变得越来越困难。为了解决这个问题,9.5版引入了一种通用的结构化格式,不依赖于特定的资源管理器。

XLOG记录的数据部分可以被划分为两个部分:首部与数据,如图9.9所示:

图9.9 通用XLOG记录格式

首部部分包含零个或多个XLogRecordBlockHeaders,以及零个或一个XLogRecordDataHeaderShort(或XLogRecordDataHeaderLong);它必须至少包含其中一个。当记录存储着整页镜像时(即备份区块),XLogRecordBlockHeader会包含XLogRecordBlockImageHeader,如果启用压缩还会包含XLogRecordBlockCompressHeader

/* 追加写入XLOG记录的区块数据首部。
 * 'data_length'是与本区块关联的,特定于资源管理器的数据荷载长度。它不包括可能会出现
 * 的整页镜像的长度,也不会包括XLogRecordBlockHeader结构本身。注意我们并不会对
 * XLogRecordBlockHeader结构做边界对齐!因此在使用前该结构体必须拷贝到对齐的本地存储中。
 */
typedef struct XLogRecordBlockHeader
{
	uint8		id;				/* 块引用 ID */
	uint8		fork_flags;		/* 关系中的分支,以及标志位 */
	uint16		data_length;	/* 荷载字节数(不包括页面镜像) */

	/* 如果设置 BKPBLOCK_HAS_IMAGE, 紧接一个XLogRecordBlockImageHeader结构 */
	/* 如果未设置 BKPBLOCK_SAME_REL, 紧接着一个RelFileNode结构 */
	/* 紧接着区块号码 */
} XLogRecordBlockHeader;

/* 分支标号放在fork_flags的低4位中,高位用于标记位 */
#define BKPBLOCK_FORK_MASK	0x0F
#define BKPBLOCK_FLAG_MASK	0xF0
#define BKPBLOCK_HAS_IMAGE	0x10	/* 区块数据是一个XLogRecordBlockImage */
#define BKPBLOCK_HAS_DATA	0x20
#define BKPBLOCK_WILL_INIT	0x40	/* 重做会重新初始化当前页 */
#define BKPBLOCK_SAME_REL	0x80	/* 忽略RelFileNode,与前一个相同 */
/* XLogRecordDataHeaderShort/Long 被用于本记录的“主数据”部分。如果数据的长度小于256字节
 * 则会使用Short版本的格式,即使用单个字节来保存长度,否则会使用长版本的格式。 */
typedef struct XLogRecordDataHeaderShort
{
	uint8		id;				/* XLR_BLOCK_ID_DATA_SHORT */
	uint8		data_length;	/* 载荷字节数目 */
}			XLogRecordDataHeaderShort;

#define SizeOfXLogRecordDataHeaderShort (sizeof(uint8) * 2)

typedef struct XLogRecordDataHeaderLong
{
	uint8		id;				/* XLR_BLOCK_ID_DATA_LONG */
	/* 紧随其后的是uint32类型的data_length, 未对齐 */
}			XLogRecordDataHeaderLong;
/* 当包含整页镜像时额外的首部信息(即当BKPBLOCK_HAS_IMAGE标记位被设置时)。
 * 
 * XLOG相关的代码会意识到数据压缩上一个显而易见的情况,即PG里的数据页面通常会在中间包含一个
 * 未使用的“空洞”,空洞里面通常只有置零的字节。如果空洞的长度大于0,我们就会从存储的数据中
 * 移除该“空洞”(且XLOG记录的CRC也不会计算该空洞)。因此区块数据的总量实际上是块大小BLCKSZ
 * 减去空洞包含的字节大小。
 * 
 * 当启用 wal_compression 时,一个包含空洞的整页镜像除了移除空洞,还会额外是引用PGLZ压缩
 * 算法进行压缩。这能减小WAL日志的体积,但会增加在记录WAL日志过程中的CPU开销。在这种情况下,
 * 空洞的大小就无法通过块大小-页面镜像大小来计算了。基本上,这需要存储额外的信息。但当没有
 * 空洞存在时,我们可以假设空洞大小为0,因此就不需要存储额外信息了。注意,当压缩节约的字节数
 * 小于额外信息的长度时,WAL里就会存储原始的页面镜像,而不是压缩过的版本。因此当成功进行压缩
 * 时,区块数据的总量总是要比(块大小BLCKSZ - 空洞字节数 - 额外信息长度)要更小。
 */
typedef struct XLogRecordBlockImageHeader
{
	uint16		length;			/* 页面镜像的字节数 */
	uint16		hole_offset;	/* 空洞前面的字节数 */
	uint8		bimg_info;		/* 标记位,详情见下 */

	/* 如果 BKPIMAGE_HAS_HOLE 且 BKPIMAGE_IS_COMPRESSED, 后面会跟着
	 * XLogRecordBlockCompressHeader 结构体 */
} XLogRecordBlockImageHeader;

/* 当页面镜像含有“空洞”且被压缩时,会用到这里的额外首部信息 */
typedef struct XLogRecordBlockCompressHeader
{
	uint16		hole_length;	/* number of bytes in "hole" */
} XLogRecordBlockCompressHeader;

数据部分则由零或多个区块数据与零或一个主数据组成,区块数据与XLogRecordBlockHeader(s)对应,而**主数据(main data)**则与XLogRecordDataHeader对应。

WAL压缩

在9.5及其后的版本,可以通过设置wal_compression = enable启用WAL压缩:使用LZ压缩方法对带有整页镜像的XLOG记录进行压缩。在这种情况下,会添加XLogRecordBlockCompressHeader结构。

该功能有两个优点与一个缺点,优点是降低写入记录的I/O开销,并减小WAL段文件的消耗量;缺点是会消耗更多的CPU资源来执行压缩。

图9.10 XLOG记录样例(9.5及其后的版本)

和前一小节一样,这里通过一些特例来描述。

9.4.3.1 备份区块

INSERT语句创建的备份区块如图9.10(a)所示,它由如下所示的四个数据结构与一个数据对象组成:

  1. XLogRecord结构 (首部部分)
  2. XLogRecordBlockHeader结构,且包含一个XLogRecordBlockImageHeader
  3. XLogRecordDataHeaderShort结构
  4. 一个备份区块(区块数据)
  5. xl_heap_insert结构 (主数据)

XLogRecordBlockHeader包含了用于在数据库集簇中定位区块的变量 (关系节点,分支编号,以及区块号); XLogRecordImageHeader 包含了当前区块的长度与偏移量(这两个首部结构合起来效果与9.4及先前版本中的BkpBlock结构相同)。

XLogRecordDataHeaderShort存储了xl_heap_insert结构的长度,该结构是当前记录的主数据部分(见下)。

除了某些特例外(例如逻辑解码与推测插入(speculative insertion)),包含整页镜像的XLOG记录的主数据不会被使用。它们会在记录重放时被忽略,属于冗余数据,未来可能会对其改进。

此外,备份区块记录的主数据与创建它们的语句相关。例如UPDATE语句就会追加写入xl_heap_lockxl_heap_updated

9.4.3.2 非备份区块

接下来描述由INSERT语句创建的非备份区块,如图9.10(b)所示,它由四个数据结构与一个数据对象组成:

  1. XLogRecord结构 (首部部分)
  2. XLogRecordBlockHeader结构
  3. XLogRecordDataHeaderShort结构
  4. 一条被插入的元组(更精确地说,一个xl_heap_header结构与完整的插入数据)
  5. xl_heap_insert结构 (主数据)

XLogRecordBlockHeader包含三个值 (关系节点,分支编号,以及区块号),用以指明该元组被插入到哪个区块中,以及要插入数据部分的长度。XLogRecordDataHeaderShort存储了xl_heap_insert结构的长度,该结构是当前记录的主数据部分。

新版本的xl_heap_insert仅包含两个值:当前元组在区块内的偏移量,以及一个可见性标记。该结构变得十分简单,因为XLogRecordBlockHeader存储了旧版本中该结构体的绝大多数数据。

/* 这里是关于该INSERT操作,我们所需要知道的一切 */
typedef struct xl_heap_insert
{
	OffsetNumber offnum;		/* 被插入元组的偏移量 */
	uint8		flags;

	/* xl_heap_header & 备份区块0中的元组数据 */
} xl_heap_insert;

最后一个例子,检查点的记录如图9.10(c)所示,它由三个数据结构组成:

  1. XLogRecord结构体(首部部分)
  2. XLogRecordDataHeaderShort结构,包含了主数据的长度。
  3. 结构体CheckPoint(主数据)

xl_heap_header定义于src/include/access/htup.h中,而CheckPoint结构定义于src/include/catalog/pg_control.h.

尽管对我们来说新格式稍显复杂,但它对于资源管理器的解析而言,设计更为合理,而且许多类型的XLOG记录的大小都比先前要小。主要的结构如图9.8和图9.10所示,你可以计算并相互比较这些记录的大小。(新版CheckPoint记录的尺寸要比旧版本大一些,但它也包含了更多的变量)。

9.5 WAL记录的写入

完成了热身练习后,现在我们已经做好理解XLOG记录写入过程的准备了。因此在本节中,我将尽可能仔细地描述。首先,以下列语句的执行为例,让我们来看一看PostgreSQL的内幕。

testdb=# INSERT INTO tbl VALUES ('A');

通过发出上述语句,内部函数exec_simple_query()会被调用,其伪代码如下所示:

exec_simple_query() @postgres.c

(1) ExtendCLOG()	@clog.c     /* 将当前事务的状态"IN_PROGRESS"写入CLOG */
(2) heap_insert()	@heapam.c	/* 插入元组,创建一条XLOG记录并调用函XLogInsert. */
(3)   XLogInsert()	@xlog.c 	/* (9.5 以及后续的版本为 xloginsert.c) */
								/* 将插入元组的XLOG记录写入WAL缓冲区,更新页面的 pd_lsn */

(4) finish_xact_command() @postgres.c	/* 执行提交 */   
      XLogInsert() @xlog.c  			/* (9.5 以及后续的版本为 xloginsert.c) */
										/* 将该提交行为的XLOG记录写入WAL缓冲区 */
(5)   XLogWrite() @xlog.c				/* 将WAL缓冲区中所有的XLOG刷写入WAL段中 */

(6) TransactionIdCommitTree() @transam.c	
							/* 在CLOG中将当前事务的状态由"IN_PROGRESS"修改为"COMMITTED" /*

在接下来的段落中将会解释每一行伪代码,从而理解XLOG记录写入的过程。如图9.11和图9.12所示。

  1. 函数ExtendCLOG()将当前事务的状态IN_PROGRESS写入内存中的CLOG。
  2. 函数heap_insert()向共享缓冲池的目标页面中插入堆元组,创建当前页面的XLOG记录,并执行函数XLogInsert()
  3. 函数XLogInsert()会将heap_insert()创建的XLOG记录写入WAL缓冲区LSN_1处,并将被修改页面的pd_lsnLSN_0更新为LSN_1
  4. 函数finish_xact_command()会在该事务被提交时被调用,用于创建该提交动作的XLOG记录,而这里的XLogInsert()函数会将该记录写入WAL缓冲区LSN_2处。

图9.11 XLOG记录的写入顺序

上图的XLOG格式是9.4版本的

  1. 函数XLogWrite()会冲刷WAL缓冲区,并将所有内容写入WAL段文件中。如果wal_sync_method参数被配置为open_syncopen_datasync,记录会被同步写入(译者注:而不是提交才会刷新WAL缓冲区),因为函数会使用带有O_SYNCO_DSYNC标记的open()系统调用。如果该参数被配置为fsyncfsync_writethroughfdatasync,相应的系统调用就是fsync(),带有F_FULLSYNC选项的fcntl(),以及fdatasync()。无论哪一种情况,所有的XLOG记录都会被确保写入存储之中。
  2. 函数TransactionIdCommitTree()将提交日志clog中当前事务的状态从IN_PROGRESS更改为COMMITTED

图9.12 XLOG记录的写入顺序(续图9.11)

在上面这个例子中,COMMIT操作致使XLOG记录写入WAL段文件。但发生在下列任一情况时,都会执行这种写入操作:

  1. 一个运行中的事务提交或中止。
  2. WAL缓冲区被写入的元组填满(WAL缓冲区的大小由参数wal_buffers控制)
  3. WAL写入者进程周期性执行写入(参见下一节)

如果出现上述情况之一,无论其事务是否已提交,WAL缓冲区上的所有WAL记录都将写入WAL段文件中。

DML操作写XLOG记录是理所当然的,但非DML操作也会产生XLOG。如上所述,COMMIT操作会写入包含着提交的事务ID的XLOG记录。另一个例子是Checkpoint操作会写入关于该检查点概述信息的XLOG记录。此外,尽管不是很常见,SELECT语句在一些特殊情况下也会创建XLOG记录。例如在SELECT语句处理的过程中,如果因为HOT(Heap Only Tuple)需要删除不必要元组并拼接必要的元组时,修改对应页面的XLOG记录就会写入WAL缓冲区。

9.6 WAL写入进程

WAL写入者是一个后台进程,用于定期检查WAL缓冲区,并将所有未写入的XLOG记录写入WAL段文件。 这个进程的目的是避免XLOG记录的突发写入。 如果没有启用该进程,则在一次提交大量数据时,XLOG记录的写入可能会成为瓶颈。

WAL写入者默认是启用的,无法禁用。但检查间隔可以通过参数wal_writer_delay进行配置,默认值为200毫秒。

9.7 PostgreSQL中的检查点过程

在PostgreSQL中,检查点进程(后台)会执行检查点;当下列情形之一发生时,它会启动处理:

  1. 距离上次检查点已经过去了由参数checkpoint_timeout配置的时间间隔(默认间隔为300秒(5分钟))。
  2. 在9.4及以前的版本中,自上一次检查点以来消耗的WAL段文件超出了参数checkpoint_segments的数量(默认值为3)。
  3. 在9.5及以后的版本,pg_xlog(10之后是pg_wal)中的WAL段文件总大小超过参数max_wal_size配置的值(默认值为1GB,64个段文件)。
  4. PostgreSQL服务器以smartfast模式关闭。

当超级用户手动执行CHECKPOINT命令时,该进程也会启动。

在9.1或更早的版本中,后台写入进程(见8.6节)同时负责脏页写入与检查点。

在接下来的几个小节中会简要描述检查点过程与pg_control文件,pg_control文件保存了当前检查点的元数据。

9.7.1 检查点过程概述

检查点进程负责两个方面:为数据库恢复做准备工作,以及共享缓冲池上脏页的刷盘工作。在本小节介绍其内部过程时,将重点关注前一个方面。参见图9.13和以下描述。

图9.13 PostgreSQL检查点的内部流程

  1. 当检查点进程启动时,会将**重做点(REDO Point)**存储在内存中;重做点是上次检查点开始时刻时XLOG记录的写入位置,也是数据库恢复的开始位置。
  2. 该检查点相应的XLOG记录(即检查点)会被写入WAL缓冲区,该记录的数据部分是由CheckPoint结构体定义的,包含了一些变量,比如第一步中重做点的位置。另外,写入检查点记录的位置,也按照字面意义叫做检查点(checkpoint)。
  3. 共享内存中的所有数据(例如,CLOG的内容)都会被刷入持久存储中。
  4. 共享缓冲池中的所有脏页都会被逐渐刷写到存储中。
  5. 更新pg_control文件,该文件包含了一些基础信息,例如上一次检查点的位置,后面会介绍该文件的细节
typedef struct CheckPoint
{
  XLogRecPtr      redo;           /* 当创建存盘时,下一个可用的RecPtr(即重做点) */
  TimeLineID      ThisTimeLineID; /* 当前时间线ID TLI */
  TimeLineID      PrevTimeLineID; /* 前一个时间线ID TLI, 如果当前记录开启了一条新的时间线
                                   * (其他情况下等于ThisTimeLineID) */
  bool            fullPageWrites; /* 当前全页写入的状态 */
  uint32          nextXidEpoch;   /* 下一个时事务ID(nextXid)的高位bit */
  TransactionId   nextXid;        /* 下一个空闲事务ID */
  Oid             nextOid;        /* 下一个空闲OID */
  MultiXactId     nextMulti;      /* 下一个空闲MultiXactId */
  MultiXactOffset nextMultiOffset;/* 下一个空闲MultiXact 偏移量 */
  TransactionId   oldestXid;      /* 集蔟范围最小的datfronzenxid */
  Oid             oldestXidDB;    /* 带有最小datfrozenxid的数据库 */
  MultiXactId     oldestMulti;    /* 集蔟范围内最小的datminmxid */
  Oid             oldestMultiDB;  /* 带有最小datminmxid的数据库 */
  pg_time_t       time;           /* 存盘的时间戳 */

 /* 最老的仍然在运行的事务ID,只有当从在线检查点中初始化一个热备时才会需要该字段。因此只有
  * 当在线检查点且wal_level配置为热备时我们才会费劲计算这个字段。其他情况下会被设置为
  * InvalidTransactionId  */
  TransactionId oldestActiveXid;
} CheckPoint;

让我们从数据库恢复的角度来总结上面的内容,检查点过程会创建包含重做点的检查点,并将检查点位置与其他信息存储到pg_control文件中。因此,PostgreSQL能够通过从重做点回放WAL数据来进行恢复(重做点是从检查点中获取的)。

9.7.2 pg_crontrol文件

由于pg_control文件包含了检查点的基本信息,因此它对于数据库恢复肯定是必不可少的。如果它被破坏或不可读,因为系统不知道从哪里开始恢复,则恢复过程就无法启动。

尽管pg_control文件存储了40多条数据项,如下三个是接下来和我们讨论内容相关的:

  1. 状态(State) —— 最近检查点过程开始时数据库的状态,总共有七种状态:start up表示系统正在启动,shut down表示系统被关机命令正常关闭,in production表示数据库正在运行,诸如此类。
  2. 最新检查点位置(Latest Checkpoint Location) —— 最新检查点的LSN位置
  3. 上次检查点位置(Prior Checkpoint Location)—— 前一个检查点的LSN位置,在版本11中已经弃用,细节如引文所示。

pg_control文件存储在数据目录中global子目录内;可以使用pg_controldata程序显示其内容。

PostgreSQL11中移除了前任检查点

PostgreSQL 11及后续版本只会存储包含最新检查点或更新版本的WAL段文件;将不会存储包含先前检查点的旧段文件,以减少用于在pg_xlog(pg_wal)子目录下保存WAL段文件的磁盘空间。 详细信息请参见此主题

9.8 PostgreSQL中的数据库恢复

PostgreSQL的恢复功能基于**重做日志(REDO log)**实现。如果数据库服务器崩溃,PostgreSQL通过从REDO点依序重放WAL段文件中的XLOG记录来恢复数据库集群。

在本节之前,我们已经多次讨论过数据库恢复,所以这里将会介绍两个与恢复有关,但尚未解释过的事情。

第一件事是PostgreSQL如何启动恢复过程。当PostgreSQL启动时,它首先读取pg_control文件。以下是从那时起恢复处理的细节。参见图9.14和以下描述。

图9.14 恢复过程的细节

  1. PostgreSQL在启动时读取pg_control文件的所有项。如果state项是in production,PostgreSQL将进入恢复模式,因为这意味着数据库没有正常停止;如果是shut down,它就会进入正常的启动模式。
  2. PostgreSQL从合适的WAL段文件中读取最近的检查点,该记录的位置写在pg_control文件中,并从该检查点中获得重做点。如果最新的检查点是无效的,PostgreSQL会读取前一个检查点。如果两个记录都不可读,它就会放弃自我恢复(注意在PostgreSQL11中不会存储前一个检查点)。
  3. 使用合适的资源管理器按顺序读取并重放XLOG记录,从重做点开始,直到最新WAL段文件的最后位置。当遇到一条属于备份区块的XLOG记录时,无论其LSN如何,它都会覆写相应表的页面。其他情况下,只有当此记录的LSN大于相应页面的pd_lsn时,才会重放该(非备份区块的)XLOG记录。

第二件事是关于LSN的比较:为什么应该比较非备份区块的LSN和相应页面的pd_lsn。与前面的示例不同,这里使用需要在两个LSN之间进行比较的具体例子来解释,如图9.15和图9.16。 (注意这里省略了WAL缓冲区,以简化描述)。

图9.15 当后台写入者工作时的插入操作

  1. PostgreSQL将一条元组插入表A,并将一条XLOG记录写入LSN_1

  2. 后台写入者进程将表A的页面写入存储。此时,此页面的pd_lsnLSN_1

  3. PostgreSQL在表A中插入一条新元组,并在LSN_2处写入一条XLOG记录。修改后的页面尚未写入存储。

与本章概述中的例子不同,在本场景中,表A的页面已经被一次性写入存储中。

使用immediate模式关闭数据库,然后启动数据库。

图9.16 数据库恢复

  1. PostgreSQL加载第一条XLOG记录和表A的页面,但不重放它,因为该记录的LSN不大于表A的LSN(两个值都是LSN_1)。实际上一目了然,没有重放该记录的必要性。
  2. 接下来,PostgreSQL会重放第二条XLOG记录,因为该记录的LSN(LSN_2)大于当前表A的LSN(LSN_1)。

从这个例子中可以看出,如果非备份区块的重放顺序不正确,或者多次重放非备份区块,数据库集群将不再一致。简而言之,非备份区块的重做(重放)操作不是**幂等(idempotent)**的。因此,为了确保正确的重放顺序,非备份区块中的记录当且仅当其LSN大于相应页面的pd_lsn时,才执行重放。

另一方面,由于备份区块的重放操作是幂等的,不管其LSN为何值,备份块可以重放任意次。

9.9 WAL段文件管理

PostgreSQL将XLOG记录写入pg_xlog子目录中的WAL段文件中(版本10之后是pg_wal子目录),当旧的段文件写满时就会切换至新的段文件。WAL文件的数量会根据几个配置参数的变化而变化,一些服务器的行为也会相应变化。此外,在9.5版中,段文件的管理机制也有了一些改善。

9.9.1 WAL段切换

当出现下列任一情况时,WAL段会发生切换:

  1. WAL段已经被填满。
  2. 函数pg_switch_xlog()(10以后为pg_switch_wal())被调用。
  3. 启用了archive_mode,且已经超过archive_timeout配置的时间。

被切换的文件通常会被回收(重命名或重用),以供未来之用。但如果不是需要的话,也可能会被移除。

9.9.2 WAL段管理(9.5版及以后)

每当检查点过程启动时,PostgreSQL都会估计并准备下一个检查点周期所需的WAL段文件数。这种估计基于前一个检查点周期中消耗的文件数量,即从包含上一个重做点的段文件开始计数,而这个值应当在min_wal_size(默认80MB,5个文件)与max_wal_size之间(默认1GB,64个文件)。如果检查点过程启动,必需的段文件会被保留或回收,而不必要的段文件会被移除。

一个具体的例子如图9.17所示,假设在检查点开始前有六个文件,WAL_3包含了上一个重做点(版本10及以前,版本11后就是当前重做点),PostgreSQL估计会需要五个文件,在这种情况下,WAL_1被重命名为WAL_7回收利用,而WAL_2会被移除。

任何比包含上一个重做点的段文件更老的段文件都可以被移除,因为按照9.8节中描述的恢复机制,这些文件永远不会被用到了。

图9.17 在检查点时发生的WAL段文件循环与回收

如果出现了WAL活动尖峰,导致需要更多的文件,新的文件会被创建,而WAL文件的总大小是小于max_wal_size的。例如在图9.18中,如果WAL_7被填满,WAL_8就会被新创建出来。

9.18 创建WAL段文件

WAL文件的数量会根据服务器活动而自动适配。 如果WAL数据写入量持续增加,则WAL段文件的估计数量以及WAL文件的总大小也会逐渐增加。 在相反的情况下(即WAL数据写入量减少),这些值也会减少。

如果WAL文件的总大小超过max_wal_size,则将启动检查点。 图9.19说明了这种情况。 检查点将会创建一个新的重做点,最近的重做点将会变为上一个重做点,不是必需的文件将被回收。通过这种方式,PostgreSQL将始终只保留数据库恢复所必需的WAL段文件。

图9.19 检查点与回收WAL段文件

配置参数wal_keep_segments以及**复制槽(Replication Slot)**功能都会影响WAL段文件的数量。

9.9.3 WAL段管理(9.4版及以前)

WAL段文件的数量主要由下列三个参数控制:

  • checkpoint_segments
  • checkpoint_completion_target
  • wal_keep_segments

WAL段文件的数量通常会:

比 $((2 + \verb|checkpoint_completion_target|) × \verb|checkpoint_segments| + 1 )$ 要大

比$( \verb|checkpoint_segments| + \verb|wal_keep_segments| + 1)$要大

且不超过$(3×\verb|checkpoint_segments|+1)$个文件

WAL段文件具体数目的取决于不同的服务器活动,复制槽的存在也会影响WAL文件的数量。

如第9.7节中所提到的,当消耗了超过checkpoint_segments个数量的文件时,就会启动检查点过程。因此可以保证WAL段文件中总是包含至少两个重做点,因为文件的数量始终大于$2×\verb|checkpoint_segments|$,对于由超时导致的检查点同样适用。PostgreSQL总是会保留足够用于恢复的WAL段文件(有时候会超出必需的量)。

在版本9.4或更早版本中,调整参数checkpoint_segments是一个痛苦的问题。 如果设置为较小的值,则检查点会频繁发生,这会导致性能下降;而如果设置为较大的数值,则WAL文件总是需要巨大的磁盘空间,但其中一些空间不是必须的。

在9.5版本中,WAL文件的管理策略得到了改善,而checkpoint_segments参数被弃用,因此上述的权衡问题已经得到解决。

9.10 归档日志与持续归档

持续归档(continuous archiving)是当WAL段文件发生切换时会自动将其拷贝至归档区域的一项功能。持续归档是由归档后台进程执行的,拷贝的文件称为归档日志(archive log)。该功能通常用于物理备份与时间点恢复(参见第十章)。

归档区域的配置取决于配置参数archieve_command,例如使用下列配置时,每当发生段文件切换时,WAL段文件会被拷贝到目录/home/postgres/archives目录下:

archive_command = 'cp %p /home/postgres/archives/%f'

这里%p是被拷贝WAL段文件的路径占位符,而%f是归档日志文件名的占位符。

图9.20 持续归档

当WAL段文件WAL_7发生切换时,该文件被拷贝至归档区域,作为归档日志7。

archive_command参数可以配置为任意的Unix命令或程序,因此你也能用scp将归档日志发送到其他主机上,或使用任意的文件备份工具来替代普通的拷贝命令。

PostgreSQL并不会清理归档日志,所以在使用该功能时需要管理好这些日志。如果什么都不做,归档日志的数量会不断增长。

pg_archivecleanup工具是一个管理归档日志的实用工具。

第十章 基础备份与时间点恢复

在线数据库备份大致可分为两类:逻辑备份和物理备份。它们有着各自的优点和缺点。尽管各有优劣,但逻辑备份有一个缺点:执行需要花费太多时间。特别是对于大型数据库而言,需要花费很长时间进行备份,而从备份数据中恢复数据库可能需要更长的时间。相反的是,物理备份可以在相对较短的时间内备份和恢复大型数据库,因此在实际系统中,它是一个非常重要而实用的功能。

在PostgreSQL中,自8.0版本开始提供在线的全量物理备份,整个数据库集簇(即物理备份数据)的运行时快照称为基础备份(base backup)

PostgreSQL还在8.0版中引入了时间点恢复(Point-In-Time Recovery, PITR)。这一功能可以将数据库恢复至任意时间点,这是通过使用一个基础备份和由持续归档生成的归档日志来实现的。例如,即使你犯了一个严重的错误(例如TRUNCATE所有的表),此功能使您可以将数据库恢复至错误发生之前的时刻。

本章描述了以下主题:

  • 什么是基础备份
  • PITR的工作原理
  • 时间线标识(TimelineID)是什么
  • 时间线历史文件是什么

在7.4或更早版本中,PostgreSQL仅支持逻辑备份(全量逻辑备份、部分逻辑备份,数据导出)。

10.1 基础备份

首先,使用底层命令进行基本备份的标准过程如下所示:

  1. 发出pg_start_backup命令

  2. 使用你想用的归档命令获取数据库集簇的快照

  3. 发出pg_stop_backup命令

这个简单的过程对于DBA来说很容易操作,因为它不需要特殊工具,只需要常用工具(如复制命令或类似的归档工具)来创建基本备份。此外,在此过程中,不需要获取表上的锁,所有用户都可以在不受备份操作影响的情况下发起查询。相对于其他开源关系型数据库,这是一个巨大的优势。

更简单的方式是使用pg_basebackup命令来做基础备份,不过在它内部也是使用这些底层命令来工作的。

图10.1 制作基础备份

由于这些命令对显然是理解PITR的关键点之一,我们将在以下小节中探讨它们。

pg_start_backuppg_stop_backup命令定义在:src/backend/access/transam/xlogfuncs.c

10.1.1 pg_start_backup

pg_start_backup开始为制作基础备份进行准备工作。如第9.8节所述,恢复过程从重做点开始,因此pg_start_backup必须执行检查点,以便在制作基础备份的开始时刻显式创建一个重做点。此外,这次检查点的检查点位置必须保存在不同于pg_control的其他文件中,因为在备份期间可能会进行多次常规检查点。因此pg_start_backup执行下列四个操作:

  1. 强制进入整页写入模式。
  2. 切换到当前的WAL段文件(8.4或更高版本)。
  3. 执行检查点。
  4. 创建backup_label文件 —— 该文件创建于基本目录顶层中,包含有关该基本备份本身的关键信息,例如检查点的检查点位置。

第三和第四个操作是该命令的核心。第一和第二个操作是为了更可靠地恢复数据库集簇。

备份标签backup_label文件包含以下六个项目(11或更新版本为七个项目):

  • 检查点位置(CHECKPOINT LOCATION —— 该命令所创建检查点的LSN位置。
  • WAL开始位置(START WAL LOCATION —— 这不是给PITR用的,而是为第11章描述的流复制准备的。它被命名为START WAL LOCATION,因为复制模式下的备用服务器在初始启动时只读取一次该值。
  • 备份方法(BACKUP METHOD —— 这是用于进行此基本备份的方法。 (pg_start_backuppg_basebackup
  • 备份来源(BACKUP FROM —— 说明此备份是从主库还是备库拉取。
  • 开始时间(START TIME—— 这是执行pg_start_backup时的时间戳。
  • 备份标签(LABEL —— 这是pg_start_backup中指定的标签。
  • 开始时间线(START TIMELINE —— 这是备份开始的时间线。这是为了进行正常性检查,在版本11中被引入。

备份标签

一个9.6版本中备份标签的实际例子如下所示:

postgres> cat /usr/local/pgsql/data/backup_label
START WAL LOCATION: 0/9000028 (file 000000010000000000000009)
CHECKPOINT LOCATION: 0/9000060
BACKUP METHOD: pg_start_backup
BACKUP FROM: master
START TIME: 2018-7-9 11:45:19 GMT
LABEL: Weekly Backup

可以想象,当使用此基础备份恢复数据库时,PostgreSQL从backup_label文件中取出检查点位置CHECKPOINT LOCATION,然后从归档日志中的合适位置读取检查点记录,然后从检查点记录中获取重做点的位置,最后从重做点开始进行恢复过程(下一节将介绍细节)。

10.1.2 pg_stop_backup

pg_stop_backup执行以下五个操作以完成备份。

  1. 如果pg_start_backup打开了整页写入,那么关闭整页写入
  2. 写入一条备份结束的XLOG记录。
  3. 切换WAL段文件。
  4. 创建一个备份历史记录文件 —— 此文件包含backup_label文件的内容,以及已执行pg_stop_backup的时间戳。
  5. 删除backup_label文件 —— 从基础备份恢复需要backup_label文件,不过一旦被复制,原始的数据库集簇中就不需要它了。

备份历史文件的命名方法如下所示:

{WAL段文件名}.{基础备份开始时的偏移量}.backup

10.2 时间点恢复(PITR)的工作原理

图10.2展示了PITR的基本概念。 PITR模式下的PostgreSQL会在基础备份上重放归档日志中的WAL数据,从pg_start_backup创建的重做点开始,恢复至你想要的位置为止。在PostgreSQL中,要恢复到的位置,被称为恢复目标(recovery target)

图10.2 PITR的基本概念

PITR是这样工作的。假设你在GMT时间2018-07-16 12:05:00搞出了错误。那你应该删掉当前的数据库集簇,并使用之前制作的基础备份恢复一个新的出来。然后,创建一个recovery.conf文件,并在其中将参数recovery_target_time参数配置为你犯错误的时间点(在本例中,也就是12:05 GMT) 。recovery.conf文件如下所示:

# Place archive logs under /mnt/server/archivedir directory.
restore_command = 'cp /mnt/server/archivedir/%f %p'
recovery_target_time = "2018-7-16 12:05 GMT"

当PostgreSQL启动的时候,如果数据库集簇中存在recovery.confbackup_label文件,它就会进入恢复模式。

PITR过程几乎与第九章中描述的常规恢复过程一模一样,唯一的区别只有以下两点:

  1. 从哪里读取WAL段/归档日志?
    • 正常恢复模式 —— 来自基础目录下的pg_xlog子目录(10或更新版本,pg_wal子目录)。
    • PITR模式 —— 来自配置参数archive_command中设置的归档目录。
  2. 从哪里读取检查点位置?
    • 正常恢复模式 —— 来自pg_control文件。
    • PITR模式 —— 来自backup_label文件。

PITR流程概述如下:

  1. 为了找到重做点,PostgreSQL使用内部函数read_backup_labelbackup_label文件中读取CHECKPOINT LOCATION的值。

  2. PostgreSQL从recovery.conf中读取一些参数值;在此示例中为restore_commandrecovery_target_time

  3. PostgreSQL开始从重做点重放WAL数据,重做点的位置可以简单地从CHECKPOINT LOCATION的值中获得。 PostgreSQL执行参数restore_command中配置的命令,将归档日志从归档区域拷贝到临时区域,并从中读取WAL数据(复制到临时区域中的日志文件会在使用后删除)。

    在本例中,PostgreSQL从重做点读取并重放WAL数据,直到时间戳2018-7-16 12:05:00为止,因为参数recovery_target_time被设置为该时间戳。如果recovery.conf中没有配置恢复目标,则PostgreSQL将重放至归档日志的末尾。

  4. 当恢复过程完成时,会在pg_xlog子目录(10或更高版本为pg_wal子目录)中创建时间线历史文件,例如00000002.history;如果启用了日志归档功能,则还会在归档目录中创建相同的命名文件。以下各节介绍了此文件的内容和作用。

提交和中止操作的记录包含每个操作完成时的时间戳(两个操作的XLOG数据部分分别在xl_xact_commitxl_xact_abort中定义)。因此,如果将目标时间设置为参数recovery_target_time,只要PostgreSQL重放提交或中止操作的XLOG记录,它可以选择是否继续恢复。当重放每个动作的XLOG记录时,PostgreSQL会比较目标时间和记录中写入的每个时间戳;如果时间戳超过目标时间,PITR过程将完成。

typedef struct xl_xact_commit
{
        TimestampTz	xact_time;              /* 提交时间 */
        uint32          xinfo;              /* 信息标记位 */
        int            	nrels;              /* RelFileNodes的数量 */
        int            	nsubxacts;          /* 子事务XIDs的数量 */
        int            	nmsgs;              /* 共享失效消息的数量 */
        Oid            	dbId;               /* MyDatabaseId, 数据库Oid */
        Oid            	tsId;               /* MyDatabaseTableSpace, 表空间Oid */
        /* 在提交时需要丢弃的RelFileNode(s)数组 */
        RelFileNode     xnodes[1];          /* 变长数组 */
        /* 紧接着已提交的子事务XIDs数组 */
        /* 紧接着共享失效消息的数组 */
} xl_xact_commit;
typedef struct xl_xact_abort
{
        TimestampTz     xact_time;          /* 中止时间 */
        int            	nrels;              /* RelFileNodes的数量 */
        int             nsubxacts;          /* 子事务XIDs的数量 */
        /* 在中止时需要丢弃的RelFileNode(s)数组 */
        RelFileNode     xnodes[1];          /* 变长数组 */
        /* 紧接着已提交的子事务XIDs数组 */
} xl_xact_abort;

函数read_backup_label定义于src/backend/access/transam/xlog.c中。 结构xl_xact_commitxl_xact_abort定义于src/backend/access/transam/xlog.c

 为什么可以用一般归档工具做基础备份?

尽管数据库集簇可能是不一致的,但恢复过程是使数据库集簇达成一致状态的过程。由于PITR是基于恢复过程的,所以即使基础备份是一堆不一致的文件,它也可以恢复数据库集簇。因此我们可以在没有文件系统快照功能,或其他特殊工具的情况下,使用一般归档工具做基础备份。

10.3 时间线与时间线历史文件

PostgreSQL中的时间线用于区分原始数据库集簇和恢复生成的数据库集簇,它是PITR的核心概念。在本节中,描述了与时间线相关的两件事:时间线标识(TimelineID),以及时间线历史文件(Timeline History Files)

10.3.1 时间线标识(TimelineID

每个时间线都有一个相应的时间线标识,一个四字节的无符号整型,从1开始计数。

每个数据库集簇都会被指定一个时间线标识。由initdb命令创建的原始数据库集簇,其时间线标识为1。每当数据库集簇恢复时,时间线标识都会增加1。例如在前一节的例子中,从原始集簇中恢复得到的集簇,其时间线标识为2。

图10.3从时间线标识的角度展示了PITR过程。首先,我们删除当前的数据库集簇,并替换为过去的基础备份,以便返回到恢复的起始点,这一步在图中用红色曲线箭头标识。接下来,我们启动PostgreSQL服务器,它通过跟踪初始时间线(时间线标识1),从pg_start_backup创建的重做点开始,重放归档日志中的WAL数据,直到恢复目标达成,这一步在图中用蓝色直线箭头标识。接下来,恢复得到的数据库集簇将被分配一个新的时间线标识2,而PostgreSQL将运行在新的时间线上。

图10.3 原始数据库集簇和恢复数据库集簇之间时间线标识的关系

正如第九章中简要提到的,WAL段文件名的前8位数字等于创建这些段文件的数据库集簇的时间线标识。当时间线标识发生变化时,WAL段文件名也会相应改变。

让我们从WAL段文件的角度重新审视恢复过程。假设我们使用两个归档日志文件来恢复数据库:

$\color{blue}{00000001}0000000000000009$,以及 $\color{blue}{00000001}000000000000000A$。新恢复得到的数据库集簇被分配了时间线标识2,而PostgreSQL就会从 $\color{blue}{00000002}000000000000000A$ 开始创建WAL段。如图10.4所示。

图10.4 原始数据库集簇和恢复数据库集簇之间WAL段文件的关系

10.3.2 时间线历史文件

当PITR过程完成时,会在归档目录和pg_xlog子目录(10或更高版本为pg_wal子目录)下创建名称为00000002.history的时间线历史文件。该文件记录了当前时间线是从哪条时间线分叉出来的,以及分叉的时间。

该文件的命名规则如下所示:

“8位数字的新时间线标识”.history

时间线历史文件至少包含一行,每行由以下三项组成:

  • 时间线标识 —— 曾用于恢复的归档日志的时间线。
  • LSN —— 发生WAL段切换的LSN位置。
  • 原因 —— 可读的时间线发生变化的原因解释。

具体示例如下所示:

postgres> cat /home/postgres/archivelogs/00000002.history
1	  0/A000198	before 2018-7-9 12:05:00.861324+00

含义如下:

数据库集簇(时间线标识为2)基于时间线标识为1的基础备份,并在2018-7-9 12:05:00.861324+00之前,通过重放检查点日志,恢复至0/A000198的位置。

通过这种方式,每个时间线历史文件都会告诉我们每个恢复所得的数据库集簇的完整历史。二期它也在PITR过程中也有使用。下一节将描述具体细节。

时间线历史文件的格式在9.3版本中发生变化。9.3前后的格式如下所示,但相对简略。

9.3及后续版本:

timelineId	LSN	"reason"

9.2及先前版本

timelineId	WAL_segment	"reason"

10.4 时间点恢复与时间线历史文件

时间线历史文件在第二次及后续PITR过程中起着重要作用。通过尝试第二次恢复,我们将探索如何使用它。

同样,假设您在12:15:00又犯了一个错误,错误发生在时间线ID为2的数据库集簇上。在这种情况下为了恢复数据库集簇,你需要创建一个如下所示的recovery.conf文件:

restore_command = 'cp /mnt/server/archivedir/%f %p'
recovery_target_time = "2018-7-16 12:15:00 GMT"
recovery_target_timeline = 2

参数recovery_target_time被设置为您犯下新错误的时间,而recovery_target_timeline被设置为2,以便沿着这条时间线恢复。

重启PostgreSQL服务器并进入PITR模式,数据库会沿着时间线标识2进行恢复。如图10.5所示。

图10.5 沿着时间线2将数据库恢复至12:15的状态

  1. PostgreSQL从backup_label文件中读取CHECKPOINT LOCATION的值。

  2. recovery.conf中读取一些参数值;在此示例中为restore_commandrecovery_target_timerecovery_target_timeline

  3. PostgreSQL读取时间线历史文件00000002.history,该文件对应参数recovery_target_timeline的值。

  4. PostgreSQL通过以下步骤重放WAL数据:

    1. 对于从重做点到LSN 0/A000198(该值写在00000002.history文件中)之间的WAL数据,PostgreSQL会(从合适的归档日志中)读取并重放TimelineID=1的WAL数据。
    2. 对于从LSN 0/A000198,到时间戳2018-7-9 12:15:00之间的WAL数据,PostgreSQL会(从合适的归档日志中)读取并重放TimelineID=2的WAL数据。
  5. 当恢复过程完成时,当前的时间线标识将增加到3,并在pg_xlog子目录(10及后续版本为pg_wal子目录)和归档目录中创建名为00000003.history的新时间线历史文件。

    postgres> cat /home/postgres/archivelogs/00000003.history
    1         0/A000198     before 2018-7-9 12:05:00.861324+00
    
    2         0/B000078     before 2018-7-9 12:15:00.927133+00
    

当你进行过超过一次的PITR时,应明确设置时间线标识,以便使用合适的时间线历史文件。

因此,时间线历史文件不仅仅是数据库集簇的历史日志,还是PITR过程的参考恢复指令。

第十一章 流复制

PostgreSQL在9.1版本中实现了流复制。它属于所谓的一主多从类型的复制,而这两个术语 —— 主(master)从(slave),在PostgreSQL中通常分别被称为主(primary)备(standby)

译注:存储数据库副本的每个节点称为副本(replica)。每一次对数据库的写入操作都需要传播到所有副本上,否则副本就会包含不一样的数据。最常见的解决方案被称为基于领导者的复制(leader-based replication),也称主动/被动(active/passive)主/从(master/slave)复制。其中,副本之一被指定为领导者(leader),也称为 主库(master)首要(primary)。当客户端要向数据库写入时,它必须将请求发送给领导者,领导者会将新数据写入其本地存储。其他副本被称为追随者(followers),亦称为只读副本(read replicas)从库(slaves)次要( sencondaries)热备(hot-standby)

这种原生复制功能是基于日志传输实现的,这是一种通用的复制技术:主库不断发送WAL数据,而每个备库接受WAL数据,并立即重放日志。

本章将介绍以下主题,重点介绍流复制的工作原理:

  • 流复制是如何启动的
  • 数据是如何在主备之间传递的
  • 主库如何管理多个备库
  • 主库如何检测到备库的失效

尽管在9.0版本中最初实现的复制功能只能进行异步复制,它很快就在9.1版中被新的实现(如今采用的)所替代,可以支持同步复制。

11.1 流复制的启动

在流复制中,有三种进程协同工作。首先,主库上的walsender(WAL发送器)进程将WAL数据发送到备库;同时,备库上的walreceiver(WAL接收器)在接收这些数据,而备库上的startup进程可以重放这些数据。 其中walsenderwalreceiver 之间使用单条TCP连接进行通信。

在本节中,我们将探讨流复制的启动顺序,以了解这些进程如何启动并且它们之间是如何建立连接的。图11.1显示了流复制的启动顺序图:

图11.1 流复制的启动顺序

  1. 启动主库服务器和备库服务器。
  2. 备库服务器启动一个startup进程。
  3. 备库服务器启动一个walreceiver进程。
  4. walreceiver向主库服务器发送连接请求。如果主库尚未启动,walreceiver会定期重发该请求。
  5. 当主库服务器收到连接请求时,将启动walsender进程,并建立walsenderwalreceiver之间的TCP连接。
  6. walreceiver发送备库数据库集簇上最新的LSN。在IT领域中通常将该阶段称作握手(handshaking)
  7. 如果备库最新的LSN小于主库最新的LSN(备库的LSN < 主库的LSN),则walsender会将前一个LSN到后一个LSN之间的WAL数据发送到walreceiver。这些WAL数据由存储在主库pg_xlog子目录(版本号为10+的更名为pg_wal)中的WAL段提供。最终,备库重放接收到的WAL数据。在这一阶段,备库在追赶主库,因此被称为**追赶(catch-up)**阶段。
  8. 最终,流复制开始工作。

每个walsender进程都维护了连接上的walreceiver或其他应用程序的复制进度状态(请注意,不是连接到walsenderwalreceiver或应用程序的本身的状态)。如下是其可能的状态:

  • 启动(start-up) —— 从启动walsender到握手结束。如图11.1(5)-(6)。
  • 追赶(catch-up) —— 处于追赶期间,如图11.1(7)。
  • 流复制(streaming)—— 正在运行流复制。如图11.1(8)。
  • 备份(backup)—— 处于向pg_basebackup等备份工具发送整个数据库集簇文件的过程中。

系统视图pg_stat_replication显示了所有正在运行的walsenders的状态,如下例所示:

testdb=# SELECT application_name,state FROM pg_stat_replication;
 application_name |   state
------------------+-----------
 standby1         | streaming
 standby2         | streaming
 pg_basebackup    | backup
(3 rows)

如上结果所示,有两个walsender正在运行,其正在向连接的备库发送WAL数据,另一个walsender在向pg_basebackup应用发送所有数据库集簇中的文件。

 ### 在备库长时间停机后,如果重启会发生什么?

在9.3版及更早版本中,如果备库所需的WAL段在主库上已经被回收了,备库就无法追上主库了。这一问题并没有可靠的解决方案,只能为参数wal_keep_segments配置一个较大的值,以减少这种情况发生的可能性,但这只是权宜之计。

在9.4及后续版本中,可以使用**复制槽(replications slot)来预防此问题发生。复制槽是一项提高WAL数据发送灵活性的功能。主要是为逻辑复制(logical replication)**而提出的,同时也能解决这类问题 ——复制槽通过暂停回收过程,从而保留pg_xlog(10及后续版本的pg_wal)中含有未发送数据的的WAL段文件,详情请参阅官方文档

11.2 如何实施流复制

流复制有两个方面:日志传输和数据库同步。因为流复制基于日志,日志传送显然是其中的一个方面 —— 主库会在写入日志记录时,将WAL数据发送到连接的备库。同步复制中需要数据库同步 —— 主库与多个备库通信,从而同步整个数据库集簇。

为准确理解流复制的工作原理,我们应该探究下主库如何管理多个备库。为了尽可能简化问题,本节描述了一个特例(即单主单备系统),而下一节将描述一般情况(单主多备系统)。

11.2.1 主从间的通信

假设备库处于同步复制模式,但配置参数hot-standby已禁用,且wal_level'archive'。主库的主要参数如下所示:

synchronous_standby_names = 'standby1'
hot_standby = off
wal_level = archive

另外,在9.5节中提到,有三个情况触发写WAL数据,这里我们只关注事务提交。

假设主库上的一个后端进程在自动提交模式下发出一个简单的INSERT语句。后端启动事务,发出INSERT语句,然后立即提交事务。让我们进一步探讨此提交操作如何完成的。如图11.2中的序列图:

图11.2 流复制的通信序列图

  1. 后端进程通过执行函数XLogInsert()XLogFlush(),将WAL数据写入并刷新到WAL段文件中。
  2. walsender进程将写入WAL段文件的WAL数据发送到walreceiver进程。
  3. 在发送WAL数据之后,后端进程继续等待来自备库的ACK响应。更确切地说,后端进程通过执行内部函数SyncRepWaitForLSN()来获取锁存器(latch),并等待它被释放。
  4. 备库上的walreceiver通过write()系统调用,将接收到的WAL数据写入备库的WAL段,并向walsender返回ACK响应。
  5. walreceiver通过系统调用(例如fsync())将WAL数据刷新到WAL段中,向walsender返回另一个ACK响应,并通知**启动进程(startup process )**相关WAL数据的更新。
  6. 启动进程重放已写入WAL段的WAL数据。
  7. walsender在收到来自walreceiver的ACK响应后释放后端进程的锁存器,然后,后端进程完成commitabort动作。 锁存器释放的时间取决于参数synchronous_commit。如果它是'on'(默认),当接收到步骤(5)的ACK时,锁存器被释放。而当它是'remote_write'时,接收到步骤(4)的ACK时,即被释放。

如果配置参数wal_level'hot_standby''logical',则PostgreSQL会根据COMMITABORT操作的记录,写入热备功能相关的WAL记录。(在这个例子中,PostgreSQL不写那些记录,因为它是'archive'。)

每个ACK响应将备库的内部信息通知给主库。包含以下四个项目:

  • 已写入最新WAL数据的LSN位置。
  • 已刷新最新WAL数据的LSN位置。
  • 启动进程已经重放最新的WAL数据的LSN。
  • 发送此响应的时间戳。

walreceiver不仅在写入和刷新WAL数据时返回ACK响应,而且还定期发送备库的心跳响应。因此,主库始终掌握所有连接备库的状态。

执行如下查询,可以显示所连接备库的相关LSN信息。

testdb=# SELECT application_name AS host,
        write_location AS write_LSN, flush_location AS flush_LSN, 
        replay_location AS replay_LSN FROM pg_stat_replication;

   host   | write_lsn | flush_lsn | replay_lsn 
----------+-----------+-----------+------------
 standby1 | 0/5000280 | 0/5000280 | 0/5000280
 standby2 | 0/5000280 | 0/5000280 | 0/5000280
(2 rows)

心跳的间隔设置为参数wal_receiver_status_interval,默认为10秒。

11.2.2 发生故障时的行为

在本小节中,将介绍在同步备库发生故障时,主库的行为方式,以及主库会如何处理该情况。

即使同步备库发生故障,且不再能够返回ACK响应,主库也会继续等待响应。因此,正在运行的事务无法提交,而后续查询也无法启动。换而言之,实际上主库的所有操作都已停止(流复制不支持发生超时时自动降级回滚到异步模式的功能)。

有两种方法可以避免这种情况。其中之一是使用多个备库来提高系统可用性,另一个是通过手动执行以下步骤从同步模式切换到异步模式。

  1. 将参数synchronous_standby_names的值设置为空字符串。

    synchronous_standby_names = ''
    
  2. 使用reload选项执行pg_ctl命令。

    postgres> pg_ctl -D $PGDATA reload
    

上述过程不会影响连接的客户端。主库继续事务处理,以及会保持客户端与相应的后端进程之间的所有会话。

11.2 流复制如何实施

流式复制有两个部分:日志传输与数据库同步。日志传输是很明显的部分,因为流复制正是基于此的 —— 每当主库发生写入时,它会向所有连接着的备库发送WAL数据。数据库同步对于同步复制而言则是必需的 —— 主库与多个备库中的每一个相互沟通,以便各自的数据库集簇保持同步。

为了准确理解流复制的工作原理,我们应当研究主库是如何管理多个备库的。为了简单起见,下面的小节将会描述一种特殊场景(即一主一从的情况),而通用的场景(一主多从)会在更后面一个小节中描述。

11.2.1 主库与同步备库之间的通信

假设备库处于同步复制模式,但参数hot_standby被配置为禁用,而wal_level被配置为archive,而主库上的主要参数如下所示:

synchronous_standby_names = 'standby1'
hot_standby = off
wal_level = archive

除了在9.5节中提到过的三种操作外,我们在这里主要关注事务的提交。

假设主库上一个后端进程在自动提交模式中发起了一条INSERT语句。首先,后端进程开启了一个事务,执行INSERT语句,然后立即提交。让我们深入研究一下这个提交动作是如何完成的,如下面的序列图11.2。

  1. 后端进程通过执行函数XLogInsert()XLogFlush()将WAL数据刷写入WAL段文件中。
  2. walsender进程将写入WAL段的WAL数据发送到walreceiver进程。
  3. 在发送WAL数据之后,后端进程继续等待来自备库的ACK响应。更确切地说,后端进程通过执行内部函数SyncRepWaitForLSN()来获取锁存器(latch),并等待它被释放。
  4. 备库上的walreceiver使用write()系统调用将接收到的WAL数据写入备库的WAL段,并向walsender返回ACK响应。
  5. 备库上的walreceiver使用诸如fsync()的系统调用将WAL数据刷入WAL段中,向walsender返回另一个ACK响应,并通知startup进程WAL数据已经更新。
  6. startup进程重放已经被写入WAL段文件中的WAL数据。
  7. walsender在收到来自walreceiver的ACK响应后,释放后端进程的锁存器,然后后端进程的提交或中止动作就会完成。释放锁存器的时机取决于参数synchronous_commit,其默认是on,也就是当收到步骤(5)中的确认(远端刷入)时,而当其值为remote_write时,则是在步骤(4)(远端写入)时。

如果配置参数wal_levelhot_standbylogical,PostgreSQL会按照热备功能来写WAL记录,并写入提交或终止的记录(在本例中PostgreSQL不会写这些记录,因为它被配置为archive

每一个ACK响应都会告知主库一些关于备库的信息,包含下列四个项目:

  • 最近被**写入(write)**的WAL数据的LSN位置。
  • 最近被**刷盘(flush)**的WAL数据的LSN位置。
  • 最近被**重放(replay)**的WAL数据的LSN位置。
  • 响应发送的时间戳。
 /* XLogWalRcvSendReply(void) */
 /* src/backend/replication/walreceiver.c */

 /* 构造一条新消息 */
 reply_message.write = LogstreamResult.Write;
 reply_message.flush = LogstreamResult.Flush;
 reply_message.apply = GetXLogReplayRecPtr();
 reply_message.sendTime = now;

 /* 为消息添加消息类型,并执行发送 */
 buf[0] = 'r';
 memcpy(&buf[1], &reply_message, sizeof(StandbyReplyMessage));
 walrcv_send(buf, sizeof(StandbyReplyMessage) + 1);

walreceiver不仅仅在写入和刷盘WAL数据时返回ACK响应,也会周期性地发送ACK,作为备库的心跳。因此主库能掌控所有连接到自己的备库的状态。

在主库上执行下面的查询,可以显示所有关联的备库与LSN相关的信息。

testdb=# SELECT application_name AS host,
        write_location AS write_LSN, flush_location AS flush_LSN, 
        replay_location AS replay_LSN FROM pg_stat_replication;

   host   | write_lsn | flush_lsn | replay_lsn 
----------+-----------+-----------+------------
 standby1 | 0/5000280 | 0/5000280 | 0/5000280
 standby2 | 0/5000280 | 0/5000280 | 0/5000280
(2 rows)

心跳频率是由参数wal_receiver_status_interval决定的,默认为10秒。

11.2.2 失效时的行为

本节将介绍当备库失效时主库的行为,以及如何处理这种情况。

当备库发生故障且不再能返回ACK响应,主库也会继续并永远等待响应。导致运行中的事务无法提交,而后续的查询处理也无法开始。换而言之,主库上的所有操作实际上都停止了(流复制并不支持这种功能:通过超时将同步提交模式降级为异步提交模式)

有两种方法能避免这种情况,一种是使用多个备库,以提高系统的可用性;另一种方法是通过手动执行下列步骤,将同步提交模式改为**异步提交(Asynchronous)**模式:

  1. 将参数synchronous_standby_names的值配置为空字符串

    synchronous_standby_names = ''
    
  2. 使用pg_ctl执行reload

    postgres> pg_ctl -D $PGDATA reload
    

上述过程不会影响连接着的客户端,主库会继续进行事务处理,所有客户端与后端进程之间的会话也会被保留。

11.3 管理多个备库

本节描述了存在多个备库时,流复制是如何工作的。

11.3.1 同步优先级与同步状态

主库为自己管理的每一个备库指定一个同步优先级(sync_priority同步状态(sync_state 。(上一节并没有提到这一点,即使主库只管理一个备库,也会指定这些值)。

**同步优先级(sync_priority)**表示备库在同步模式下的优先级,它是一个固定值。较小的值表示较高的优先级,而0是一个特殊值,表示“异步模式”。备库优先级是一个有序列表,在主库配置参数 synchronous_standby_names中依序给出。例如在以下配置中,standby1standby2的优先级分别为1和2。

synchronous_standby_names = 'standby1, standby2'

(未列于此参数中的备库处于异步模式,优先级为0)

**同步状态(sync_state)**是备库的状态,它因所有在列备库的运行状态及其优先级而异,以下是可能的状态:

  • **同步(Sync)**状态的备库,是所有正在工作中的备库中,具有最高优先级的同步备库的状态(异步模式除外)。
  • 潜在(Potential) 状态的备库,是所有工作备库(异步备库除外)中,优先级等于或低于2的闲置同步备库。如果同步备库失效,潜在备库中有着最高优先级的那个将替换为同步备库。
  • 异步(Async) 状态的备库是固定的。主库以与潜在备库相同的方式处理异步备库,只是它们的sync_state永远不会是syncpotential

执行以下查询来显示备库的优先级和状态:

testdb=# SELECT application_name AS host, 
         sync_priority, sync_state FROM pg_stat_replication;
   host   | sync_priority | sync_state
----------+---------------+------------
 standby1 |             1 | sync
 standby2 |             2 | potential
(2 rows)

最近有几个开发者尝试实现“多个同步备库”。详情参见此处

11.3.2 主库如何管理多个备库

主库仅等待来自同步备库的ACK响应。换句话说,主库仅确保同步备库写入并刷新WAL数据。因此在流复制中,只有同步备库的状态是与主库始终一致且同步的。

图11.3展示了潜在备库的ACK响应早于首要备库ACK响应的情况。这时主库并不会完成当前事务的COMMIT操作,而是继续等待首要备库的ACK响应。而当收到首要备库的响应时,后端进程释放锁存器并完成当前事务的处理。

图11.3 管理多个备库

备库1与备库2的同步状态分别为syncpotential

  1. 尽管从潜在备库接收到ACK响应,但主库的后端进程会继续等待来自同步备库的ACK响应。
  2. 主库的后端进程释放锁存器,完成当前的事务处理。

在相反的情况下(即首要从库的ACK响应返回早于潜在从库的响应),主库会立即完成当前事务的COMMIT操作,而不会去确认潜在从库是否已经写入和刷盘了WAL数据。

11.3.3 发生故障时的行为

我们再来看看当从库发生故障时主库的表现。

当潜在或异步备库发生故障时,主库会终止连接到故障备库的walsender进程,并继续进行所有处理。换而言之,主库上的事务处理不会受到这两种备库的影响。

当同步备库发生故障时,主库将终止连接到故障备库的walsender进程,并使用具有最高优先级的潜在备库替换首要同步备库,如图11.4。与上述的故障相反,主库将会暂停从失效点到成功替换同步备库之间的查询处理。(因此备库的故障检测对于提高复制系统可用性至关重要,故障检测将在下一节介绍)

图11.4 更换同步备库

在任何情况下,如果一个或多个备库在同步模式下运行,主库始终只会保留一个同步备库,而同步备库始终与主库保持一致且同步的状态。

11.4 备库的故障检测

流复制使用两种常见的故障检测过程,不需要任何特别的硬件。

  1. 备库服务器的失效检测

    当检测到walsenderwalreceiver之间的连接断开时,主库立即判定备库或walreceiver进程出现故障。当底层网络函数由于未能成功读写walreceiver的套接字接口而返回错误时,主库也会立即判定其失效。

  2. 硬件与网络的失效检测

    如果walreceiver在参数wal_sender_timeout(默认为60秒)配置的时间段内没有返回任何结果,则主库会判定备库出现故障。相对于上面的故障而言,尽管从库可能因为一些失效原因(例如备库上的硬件失效,网络失效等),已经无法发送任何响应,但主库仍需要耗费特定的时间 —— 最大为wal_sender_timeout,来确认备库的死亡。

取决于失效的类型,一些失效可以在失效发生时被立即检测到,而有时候则可能在出现失效与检测到失效之间存在一段时间延迟。如果在同步从库上出现后一种失效,那么即使有多个潜在备库正常工作,直到检测到同步备库失效了,主库仍然可能会停止一段时间的事务处理。

在9.2或更早版本中,参数wal_sender_timeout被称为replication_timeout

WAL与检查点概述

介绍PostgreSQL中的WAL与检查点机制。

数据库需要保证两个基本的特性:可靠性可用性。通俗来讲:

可靠性就是:出了故障,既不会丢数据,也不会弄脏数据。

可用性就是:保证足够的读写性能,出了故障后,能够快速恢复服务。

朴素的数据库实现有两个选项:在内存中修改数据页,或者将事物变更直接写入磁盘。但这产生了一个两难困境:

  • 内存支持随机读写,因此在性能上表现强悍,然而作为易失性存储,一旦故障就会丢数据。
  • 硬盘恰恰相反,随机读写表现糟糕,但在故障时数据要可靠的多。

内存可用性强可靠性差,硬盘可用性差但可靠性强,如何解决这一对矛盾,让内存与硬盘取长补短,就是生产级数据库需要考虑的问题了。


0x1 核心思想

硬盘的随机写入性能很糟糕,但顺序写入的性能却非常可观。即使是SSD也符合这一规律,因为一次写入的擦除单位是Block(通常是几M),而操作系统的写入单元是Page(通常约4k)。如果每次事物提交都要直接将脏数据页落盘,性能表现肯定不会可观。但如果采用另一种方式,将数据的变更而不是变更后的最新数据本身落盘,就可以将随机写入变为顺序写入,从而极大地提高磁盘写入效率。

于是,预写式日志(WAL,Write Ahead Log) 出现了,所谓日志,在最朴素的意义上来讲,就是一个Append-Only的数据文件,记录了操作的内容。只要保留了WAL,数据库就是可靠的,可以恢复的。从一个给定的状态,例如空数据库开始,回放所有的操作日志到当前的时间点,就可以恢复出当前数据库应有的状态。与此同时,如果日志已经落盘确保了可靠性,数据页就不需要在每次提交时落盘了。数据页的读写可以完全在内存进行,从而提供强悍的性能支持。

但可用性不仅仅包括足够的性能,当发生故障时能够快速恢复也是可用性要求的一部分。考虑最极端的情况,从数据库创建之初所有数据页就在内存里一直飘着,只有操作日志落了盘。现在数据库运行了一整年,突然崩溃了,这时候要想恢复就需要重放一整年的操作日志,也许需要几个小时,也许需要好几天。对于生产环境,这是无法接受的,检查点(Checkpoint)解决了这个问题。

检查点(Checkpoint)类似于游戏中存档的概念,远古时期的很多游戏没有存档,一旦Game Over就要重头再来。后来的游戏有了记忆和存档,当挑战Boss失败时,只要读取最近的存档,就可以避免从头开始。

数据库中的检查点代表这样一种操作,在某一个检查点时,所有脏数据页会写回到磁盘中,使得磁盘和内存中的数据保持一致。这样当故障恢复时,只需要从该检查点开始回放操作日志即可。

例如,每个整点执行一次检查点,存档一次,那么当故障时,只需要从本小时开始的检查点开始回放WAL,就可以完成恢复。同时,检查点还有一个好处是,当数据页落盘之后,在这个检查点之前的WAL日志就可以不用了。对于高负载数据库,例如每小时产生TB级别WAL的数据库,使用检查点能够极大地减少恢复的时间和磁盘的用量。

通过检查点和预写式日志,数据库可以同时保证高度的可靠性和可用性。


0x2 WAL概述

预写式日志(WAL)是保证数据完整性的一种标准方法。对其详尽的描述几乎可以在所有(如果不是全部)有关事务处理的书中找到。简单来说,WAL的中心概念是数据文件(存储着表和索引)的修改必须在这些动作被日志记录之后才被写入,即在描述这些改变的日志记录被刷到持久存储以后。如果我们遵循这种过程,我们不需要在每个事务提交时刷写数据页面到磁盘,因为我们知道在发生崩溃时可以使用日志来恢复数据库:任何还没有被应用到数据页面的改变可以根据其日志记录重做(这是前滚恢复,也被称为REDO)。

使用WAL可以显著降低磁盘的写次数,因为只有日志文件需要被刷出到磁盘以保证事务被提交,而被事务改变的每一个数据文件则不必被刷出。日志文件被按照顺序写入,因此同步日志的代价要远低于刷写数据页面的代价。在处理很多影响数据存储不同部分的小事务的服务器上这一点尤其明显。此外,当服务器在处理很多小的并行事务时,日志文件的一个fsync可以提交很多事务。

##异步提交

异步提交是一个允许事务能更快完成的选项,代价是在数据库崩溃时最近的事务会丢失。在很多应用中这是一个可接受的交换。

如前一节所述,事务提交通常是同步的:服务器等到事务的WAL记录被刷写到持久存储之后才向客户端返回成功指示。因此客户端可以确保那些报告已被提交的事务确会被保存,即便随后马上发生了一次服务器崩溃。但是,对于短事务来说这种延迟是其总执行时间的主要部分。选择异步提交模式意味着服务器将在事务被逻辑上提交后立刻返回成功,而此时由它生成的WAL记录还没有被真正地写到磁盘上。这将为小型事务的生产力产生显著地提升。

异步提交会带来数据丢失的风险。在向客户端报告事务完成到事务真正被提交(即能保证服务器崩溃时它也不会被丢失)之间有一个短的时间窗口。因此如果客户端将会做一些要求其事务被记住的外部动作,就不应该用异步提交。例如,一个银行肯定不会使用异步提交事务来记录一台ATM的现金分发。但是在很多情境中不需要这种强的保证,例如事件日志。

使用异步提交带来的风险是数据丢失,而不是数据损坏。如果数据库可能崩溃,它会通过重放WAL到被刷写的最后一个记录来进行恢复。数据库将因此被恢复到一个自身一致状态,但是任何还没有被刷写到磁盘的事务将不会反映在该状态中。因此其影响就是丢失了最后的少量事务。由于事务按照提交顺序被重放,所以不会出现任何不一致性 — 例如一个事务B按照前面一个事务A的效果来进行修改,则不会出现A的效果丢失而B的效果被保留的情况。

用户可以选择每一个事务的提交模式,这样可以有同步提交和异步提交的事务并行运行。这允许我们灵活地在性能和事务持久性之间进行权衡。提交模式由用户可设置的参数synchronous_commit控制,它可以使用任何一种修改配置参数的方法进行设置。一个事务真正使用的提交模式取决于当事务提交开始时synchronous_commit的值。

特定的实用命令,如DROP TABLE,被强制按照同步提交而不考虑synchronous_commit的设定。这是为了确保服务器文件系统和数据库逻辑状态之间的一致性。支持两阶段提交的命令页总是同步提交的,如PREPARE TRANSACTION

如果数据库在异步提交和事务WAL记录写入之间的风险窗口期间崩溃,在该事务期间所作的修改丢失。风险窗口的持续时间是有限制的,因为一个后台进程(“WAL写进程”)每wal_writer_delay毫秒会把未写入的WAL记录刷写到磁盘。风险窗口实际的最大持续时间是wal_writer_delay的3倍,因为WAL写进程被设计成倾向于在忙时一次写入所有页面。

一个立刻关闭等同于一次服务器崩溃,因此也将会导致未刷写的异步提交丢失。

异步提交提供的行为与配置fsync = off不同。fsync是一个服务器范围的设置,它将会影响所有事务的行为。它禁用了PostgreSQL中所有尝试同步写入到数据库不同部分的逻辑,并且因此一次系统崩溃(即,一个硬件或操作系统崩溃,不是PostgreSQL本身的失败)可能造成数据库状态的任意损坏。在很多情境中,带来大部分性能提升的异步提交可以通过关闭fsync来获得,而且不会带来数据损坏的风险。

commit_delay也看起来很像异步提交,但它实际上是一种同步提交方法(事实上,commit_delay在异步提交时被忽略)。commit_delay会使事务在刷写WAL到磁盘之前有一个延迟,它期望由一个这样的事务所执行的刷写能够也服务于其他同时提交的事务。该设置可以被看成是一种时间窗口,在其期间事务可以参与到一次单一的刷写中,这种方式用于在多个事务之间摊销刷写的开销。


0x03 查看WAL状态

  • pg_current_wal_lsn
  • pg_current_wal_flush_lsn
  • pg_current_wal_insert_lsn
  • pg_last_wal_receive_lsn
  • pg_last_wal_replay_lsn
  • pg_last_xact_replay_timestamp

0x04 流复制

流复制是通过重放 WAL 实现的。


0x05 Checkpoint

执行 CHECKPOINT 命令,会强制发起一个事务日志检查点。

一个检查点是事务日志序列中的一个点,在该点上所有数据文件 都已经被更新为反映日志中的信息。所有数据文件将被刷写到磁盘。 检查点期间发生的细节可见第 30.4 节

CHECKPOINT命令在发出时强制一个 立即的检查点,而不用等待由系统规划的常规检查点(由 第 19.5.2 节中的设置控制)。 CHECKPOINT不是用来在普通操作中 使用的命令。

如果在恢复期间执行,CHECKPOINT 命令将强制一个重启点(见第 30.4 节) 而不是写一个新检查点。

只有超级用户能够调用CHECKPOINT


安全的删除WAL

如果想要删除wal日志,要么让pg在CHECKPOINT的时候自己删除,或者使用pg_archivecleanup。除了以下三种情况,pg会自动清除不再需要的wal日志:

  1. archive_mond=on,但是archive_commandfailed,这样pg会一直保留wal日志,直到重试成功。
  2. wal_keep_segments需要保留一定的数据。
  3. 9.4之后,可能会因为replication slot保留;

如果都不符合上述情况,我们想要清理wal日志,可以通过执行CHECKPOINT来清理当前不需要的wal。

在一些非寻常情况下,可能需要pg_archivecleanup命令,比如由于wal归档失败导致的wal堆积引起的磁盘空间溢出。你可能使用这个命令来清理归档wal日志,但是永远不要手动删除wal段;

版本发布

v2.6.0 发布注记

Pigsty v2.6 使用 PostgreSQL 16.2 作为了默认主版本,并引入了 ParadeDB 与 DuckDB 支持。

v2.6.0

亮点特性

软件配置变更

  • 使用 node_repo_modules 替换 node_repo_method 参数,并移除 node_repo_local_urls 参数。
  • 暂时关闭 Grafana 统一告警功能,避免 “Database Locked” 错误。
  • 新增 node_repo_modules 参数,用于指定在节点上添加的上游仓库源。
  • 移除 node_local_repo_urls,其功能由 node_repo_modules & repo_upstream 替代。
  • 移除 node_repo_method 参数,其功能由 node_repo_modules 替代。
  • repo_upstream 添加新的 local 源,并通过 node_repo_modules 使用,替代 node_local_repo_urls 的功能
  • 重排 node_default_packagesinfra_packagespg_packagespg_extensions 参数默认值。
  • repo_upstream 中替换 repo_upstream.baseurl 时,如果 EL8/9 PGDG小版本特定的仓库可用,使用 major.minor 而不是 major 替换 $releasever,提高小版本兼容性。

软件版本升级

  • Grafana 10.3
  • Prometheus 2.47
  • node_exporter 1.7.0
  • HAProxy 2.9.5
  • Loki / Promtail 2.9.4
  • minio-20240216110548 / mcli-20240217011557
  • etcd 3.5.11
  • Redis 7.2.4
  • Bytebase 2.13.2
  • DuckDB 0.10.0
  • FerretDB 1.19
  • Metabase:新Docker应用模板

PostgreSQL扩展插件

  • PostgreSQL 小版本升级: 16.2, 15.6, 14.11, 13.14, 12.18
  • PostgreSQL 16: 现在被提升为默认主版本
  • pg_exporter 0.6.1:安全修复
  • Patroni 3.2.2
  • pgBadger 12.4
  • pgBackRest 2.50
  • vip-manager 2.3.0
  • PostGIS 3.4.2
  • TimescaleDB 2.14.1
  • 向量扩展 PGVector 0.6.0:新增并行创建 HNSW 索引功能
  • 新增扩展插件 duckdb_fdw v1.1 ,支持读写 DuckDB 数据 v1.1
  • 新增扩展插件 pgsql-gzip ,用于支持 Gzip 压缩解压缩 v1.0.0
  • 新增扩展插件 pg_sparse,高效处理稀疏向量(ParadeDB) v0.5.6
  • 新增扩展插件 pg_bm25,用于支持高质量全文检索 BM25 算法的插件(ParadeDB) v0.5.6
  • 新增扩展插件 pg_analytics,支持 SIMD 与列式存储的PG分析插件(ParadeDB) v0.5.6
  • 升级AIML插件 pgml 至 v2.8.1,新增 PG 16 支持。
  • 升级列式存储插件 hydra 版本至 v1.1.1,新增 PG 16 支持。
  • 升级图扩展插件 age 至 v1.5.0,新增 PG 16 支持。
  • 升级GraphQL插件 pg_graphql 版本至 v1.5.0 ,支持 Supabase。
330e9bc16a2f65d57264965bf98174ff  pigsty-v2.6.0.tgz
81abcd0ced798e1198740ab13317c29a  pigsty-pkg-v2.6.0.debian11.x86_64.tgz
7304f4458c9abd3a14245eaf72f4eeb4  pigsty-pkg-v2.6.0.debian12.x86_64.tgz
f914fbb12f90dffc4e29f183753736bb  pigsty-pkg-v2.6.0.el7.x86_64.tgz
fc23d122d0743d1c1cb871ca686449c0  pigsty-pkg-v2.6.0.el8.x86_64.tgz
9d258dbcecefd232f3a18bcce512b75e  pigsty-pkg-v2.6.0.el9.x86_64.tgz
901ee668621682f99799de8932fb716c  pigsty-pkg-v2.6.0.ubuntu20.x86_64.tgz
39872cf774c1fe22697c428be2fc2c22  pigsty-pkg-v2.6.0.ubuntu22.x86_64.tgz

v2.5.0 发布注记

Pigsty v2.5 提供了 Ubuntu/Debian 支持:bullseye, bookworm, jammy, focal,新扩展,监控改进

v2.5.0

curl https://get.pigsty.cc/latest | bash

亮点特性

  • Ubuntu / Debian 支持: bullseye, bookworm, jammy, focal

  • 使用CDN repo.pigsty.cc 软件源,提供 rpm/deb 软件包下载。

  • Anolis 操作系统支持( 兼容 EL 8.8 )。

  • 使用 PostgreSQL 16 替代 PostgreSQL 14 作为备选主要支持版本

  • 新增了 PGSQL Exporter / PGSQL Patroni 监控面板,重做 PGSQL Query 面板

  • 扩展更新:

    • PostGIS 版本至 3.4( EL8/EL9 ),EL7 仍使用 PostGIS 3.3
    • 移除 pg_embedding,因为开发者不再对其进行维护,建议使用 pgvector 替换。
    • 新扩展(EL):点云插件 pointcloud 支持,Ubuntu原生带有此扩展。
    • 新扩展(EL): imgsmlrpg_similaritypg_bigm 用于搜索。
    • 重新编译 pg_filedump 为 PG 大版本无关的软件包。。
    • 新收纳 hydra 列存储扩展,不再默认安装 citus 扩展。
  • 软件更新:

    • Grafana 更新至 v10.1.5
    • Prometheus 更新至 v2.47
    • Promtail/Loki 更新至 v2.9.1
    • Node Exporter 更新至 v1.6.1
    • Bytebase 更新至 v2.10.0
    • patroni 更新至 v3.1.2
    • pgbouncer 更新至 v1.21.0
    • pg_exporter 更新至 v0.6.0
    • pgbackrest 更新至 v2.48.0
    • pgbadger 更新至 v12.2
    • pg_graphql 更新至 v1.4.0
    • pg_net 更新至 v0.7.3
    • ferretdb 更新至 v0.12.1
    • sealos 更新至 4.3.5
    • Supabase 支持更新至 20231013070755

Ubuntu 支持说明

Pigsty 支持了 Ubuntu 22.04 (jammy) 与 20.04 (focal) 两个 LTS 版本,并提供相应的离线软件安装包。

相比 EL 系操作系统,一些参数的默认值需要显式指定调整,详情请参考 ubuntu.yml

  • repo_upstream:按照 Ubuntu/Debian 的包名进行了调整
  • repo_packages:按照 Ubuntu/Debian 的包名进行了调整
  • node_repo_local_urls:默认值为 ['deb [trusted=yes] http://${admin_ip}/pigsty ./']
  • node_default_packages
    • zlib -> zlib1g, readline -> libreadline-dev
    • vim-minimal -> vim-tiny, bind-utils -> dnsutils, perf -> linux-tools-generic,
    • 新增软件包 acl,确保 Ansible 权限设置正常工作
  • infra_packages:所有含 _ 的包要替换为 - 版本,此外 postgresql-client-16 用于替换 postgresql16
  • pg_packages:Ubuntu 下惯用 - 替代 _,不需要手工安装 patroni-etcd 包。
  • pg_extensions:扩展名称与EL系不太一样,Ubuntu下缺少 passwordcheck_cracklib 扩展。
  • pg_dbsu_uid: Ubuntu 下 Deb 包不显式指定uid,需要手动指定,Pigsty 默认分配为 543

API变更

默认值变化:

  • repo_modules 现在的默认值为 infra,node,pgsql,redis,minio,启用所有上游源

  • repo_upstream 发生变化,现在添加了 Pigsty Infra/MinIO/Redis/PGSQL 模块化软件源

  • repo_packages 发生变化,移除未使用的 karma,mtail,dellhw_exporter,移除了 PG14 主要扩展,新增了 PG16 主要扩展,添加了 virtualenv 包。

  • node_default_packages 发生变化,默认安装 python3-pip 组件。

  • pg_libs: timescaledb 从 shared_preload_libraries 中移除,现在默认不自动启用。

  • pg_extensions 发生变化,不再默认安装 Citus 扩展,默认安装 passwordcheck_cracklib 扩展,EL8,9 PostGIS 默认版本升级至 3.4

    - pg_repack_${pg_version}* wal2json_${pg_version}* passwordcheck_cracklib_${pg_version}*
    - postgis34_${pg_version}* timescaledb-2-postgresql-${pg_version}* pgvector_${pg_version}*
    
  • Patroni 所有模板默认移除 wal_keep_size 参数,避免触发 Patroni 3.1.1 的错误,其功能由 min_wal_size 覆盖。

87e0be2edc35b18709d7722976e305b0  pigsty-pkg-v2.5.0.el7.x86_64.tgz
e71304d6f53ea6c0f8e2231f238e8204  pigsty-pkg-v2.5.0.el8.x86_64.tgz
39728496c134e4352436d69b02226ee8  pigsty-pkg-v2.5.0.el9.x86_64.tgz
e3f548a6c7961af6107ffeee3eabc9a7  pigsty-pkg-v2.5.0.debian11.x86_64.tgz
1e469cc86a19702e48d7c1a37e2f14f9  pigsty-pkg-v2.5.0.debian12.x86_64.tgz
cc3af3b7c12f98969d3c6962f7c4bd8f  pigsty-pkg-v2.5.0.ubuntu20.x86_64.tgz
c5b2b1a4867eee624e57aed58ac65a80  pigsty-pkg-v2.5.0.ubuntu22.x86_64.tgz

v2.5.1

跟进 PostgreSQL v16.1, v15.5, 14.10, 13.13, 12.17, 11.22 小版本例行更新。

现在 PostgreSQL 16 的所有重要扩展已经就位(新增 pg_repacktimescaledb 支持)

  • 软件更新:
    • PostgreSQL to v16.1, v15.5, 14.10, 13.13, 12.17, 11.22
    • Patroni v3.2.0
    • PgBackrest v2.49
    • Citus 12.1
    • TimescaleDB 2.13
    • Grafana v10.2.0
    • FerretDB 1.15
    • SealOS 4.3.7
    • Bytebase 2.11.1
  • 移除 PGCAT 监控面板中查询对 monitor 模式前缀(允许用户将 pg_stat_statements 扩展装到别的地方)
  • 新的配置模板 wool.yml,为阿里云免费99 ECS 单机针对设计。
  • 为 EL9 新增 python3-jmespath 软件包,解决 Ansible 依赖更新后 bootstrap 缺少 jmespath 的问题
31ee48df1007151009c060e0edbd74de  pigsty-pkg-v2.5.1.el7.x86_64.tgz
a40f1b864ae8a19d9431bcd8e74fa116  pigsty-pkg-v2.5.1.el8.x86_64.tgz
c976cd4431fc70367124fda4e2eac0a7  pigsty-pkg-v2.5.1.el9.x86_64.tgz
7fc1b5bdd3afa267a5fc1d7cb1f3c9a7  pigsty-pkg-v2.5.1.debian11.x86_64.tgz
add0731dc7ed37f134d3cb5b6646624e  pigsty-pkg-v2.5.1.debian12.x86_64.tgz
99048d09fa75ccb8db8e22e2a3b41f28  pigsty-pkg-v2.5.1.ubuntu20.x86_64.tgz
431668425f8ce19388d38e5bfa3a948c  pigsty-pkg-v2.5.1.ubuntu22.x86_64.tgz

v2.4.0 发布注记

PG16,监控RDS,服务咨询支持,新扩展:中文分词全文检索/图/HTTP/嵌入等

v2.4.0

使用 bash -c "$(curl -fsSL https://get.pigsty.cc/latest)" 快速上手。

最新特性

  • PostgreSQL 16 正式发布,Pigsty提供支持。
  • 可以监控云数据库,RDS for PostgreSQL,以及 PolarDB,提供全新的 PGRDS 监控面板
  • 正式提供商业支持与咨询服务。并发布首个 LTS 版本,为订阅客户提供最长5年的支持。
  • 新扩展插件: Apache AGE, openCypher graph query engine on PostgreSQL
  • 新扩展插件: zhparser, full text search for Chinese language
  • 新扩展插件: pg_roaringbitmap, roaring bitmap for PostgreSQL
  • 新扩展插件: pg_embedding, hnsw alternative to pgvector
  • 新扩展插件: pg_tle, admin / manage stored procedure extensions
  • 新扩展插件: pgsql-http, issue http request with SQL interface
  • 新增插件: pg_auth_mon pg_checksums pg_failover_slots pg_readonly postgresql-unit pg_store_plans pg_uuidv7 set_user
  • Redis改进:支持 Redis 哨兵监控,配置主从集群的自动高可用。

API变化

  • 新增参数,REDIS.redis_sentinel_monitor,用于指定 Sentinel 集群监控的主库列表

问题修复

  • 修复 Grafana 10.1 注册数据源时缺少 uid 的问题
MD5 (pigsty-pkg-v2.4.0.el7.x86_64.tgz) = 257443e3c171439914cbfad8e9f72b17
MD5 (pigsty-pkg-v2.4.0.el8.x86_64.tgz) = 41ad8007ffbfe7d5e8ba5c4b51ff2adc
MD5 (pigsty-pkg-v2.4.0.el9.x86_64.tgz) = 9a950aed77a6df90b0265a6fa6029250

v2.3.0 发布注记

PGSQL/REDIS升级,NODE集群可绑VIP,Mongo初步支持与MySQL存根

v2.3.0

相关文章:《Pigsty v2.3 发布:应用生态丰富

发布注记:https://github.com/Vonng/pigsty/releases/tag/v2.3.0

使用 bash -c "$(curl -fsSL https://get.pigsty.cc/latest)" 快速开始。

亮点特性

  • INFRA: 添加了对 NODE/PGSQL VIP 的监控支持
  • PGSQL: 通过小版本升级修复了 PostgreSQL CVE-2023-39417: 15.4, 14.9, 13.12, 12.16,以及 Patroni v3.1.0
  • NODE: 允许用户使用 keepalived 为一个节点集群绑定 L2 VIP
  • REPO: Pigsty 专用 yum 源优化精简,全站默认使用 HTTPS: get.pigsty.ccdemo.pigsty.cc
  • APP: 升级 app/bytebase 版本至 v2.6.0, app/ferretdb 版本至 v1.8;添加新的应用模板:nocodb,开源的 Airtable。
  • REDIS: 升级版本至 v7.2,并重制了 Redis 监控面板。
  • MONGO: 添加基于 FerretDB 1.8 实现的基本支持。
  • MYSQL: 添加了 Prometheus / Grafana / CA 中的代码存根,便于后续纳管。

API变化

新增一个新的参数组 NODE.NODE_VIP:包含 8 个新参数

  • NODE.VIP.vip_enabled:在此节点集群上启用 vip 吗?
  • NODE.VIP.vip_address:ipv4 格式的节点 vip 地址,如果启用了 vip,则必需
  • NODE.VIP.vip_vrid:必需,整数,1-255 在相同 VLAN 中应该是唯一的
  • NODE.VIP.vip_role:master/backup,默认为备份,用作初始角色
  • NODE.VIP.vip_preempt:可选,true/false,默认为 false,启用 vip 抢占
  • NODE.VIP.vip_interface:节点 vip 网络接口监听,eth0 默认
  • NODE.VIP.vip_dns_suffix:节点 vip dns 名称后缀,默认为 .vip
  • NODE.VIP.vip_exporter_port:keepalived 导出器监听端口,默认为 9650
MD5 (pigsty-pkg-v2.3.0.el7.x86_64.tgz) = 81db95f1c591008725175d280ad23615
MD5 (pigsty-pkg-v2.3.0.el8.x86_64.tgz) = 6f4d169b36f6ec4aa33bfd5901c9abbe
MD5 (pigsty-pkg-v2.3.0.el9.x86_64.tgz) = 4bc9ae920e7de6dd8988ca7ee681459d

v2.3.1

使用 bash -c "$(curl -fsSL https://get.pigsty.cc/latest)" 快速开始。

最新特性

  • pgvector 更新至 0.5,添加 hnsw 算法支持。
  • 支持 PostgreSQL 16 RC1 (el8/el9)
  • 默认包中添加了 SealOS 用于快速部署Kubernetes集群。

问题修复

  • 修复了 infra.repo.repo_pkg 任务:当 repo_packages 中包名包含 * 时,下载可能会受到 /www/pigsty 现有内容的影响。
  • vip_dns_suffix 的默认值由 .vip 调整为空字符串,即集群本身的名称将默认作为节点集群的 L2 VIP
  • modprobe watchdog and chown watchdog if patroni_watchdog_mode is required
  • pg_dbsu_sudo = limit and patroni_watchdog_mode = required 时,授予数据库 dbsu 以下命令的 sudo 执行权限
    • /usr/bin/sudo /sbin/modprobe softdog:在启动 Patroni 服务时确保 softdog 内核模块启用
    • /usr/bin/sudo /bin/chown {{ pg_dbsu }} /dev/watchdog: 在启动 Patroni 服务时,确保 watchdog 属主正确

文档更新

  • 向英文文档中添加了更新内容。
  • 添加了简体中文版本的内置文档,修复了 pigsty.cc 文档站的中文文档。

软件更新

  • PostgreSQL 16 RC1 for EL8/EL9
  • PGVector 0.5.0,支持 hnsw 索引
  • TimescaleDB 2.11.2
  • grafana 10.1.0
  • loki & promtail 2.8.4
  • redis-stack 7.2 on el7/8
  • mcli-20230829225506 / minio-20230829230735
  • ferretdb 1.9
  • sealos 4.3.3
  • pgbadger 1.12.2
ce69791eb622fa87c543096cdf11f970  pigsty-pkg-v2.3.1.el7.x86_64.tgz
495aba9d6d18ce1ebed6271e6c96b63a  pigsty-pkg-v2.3.1.el8.x86_64.tgz
38b45582cbc337ff363144980d0d7b64  pigsty-pkg-v2.3.1.el9.x86_64.tgz

v2.2.0 发布注记

监控面板 & 沙箱置备重做,构建流程优化,UOS兼容性

v2.2.0

相关文章:《Pigsty v2.2 发布 —— 监控系统大升级

发布注记:https://github.com/Vonng/pigsty/releases/tag/v2.2.0

快速开始: bash -c "$(curl -fsSL https://get.pigsty.cc/latest)"

亮点特性

  • 监控面板重做: https://demo.pigsty.cc
  • Vagrant沙箱重做: 支持 libvirt 与新的配置模板
  • Pigsty EL Yum 仓库: 统一收纳零碎 RPM,简化安装构建流程。
  • 操作系统兼容性: 新增信创操作系统 UOS-v20-1050e 支持
  • 新的配置模板:42 节点的生产仿真配置
  • 统一使用官方 PGDG citus 软件包(el7)

软件升级

  • PostgreSQL 16 beta2
  • Citus 12 / PostGIS 3.3.3 / TimescaleDB 2.11.1 / PGVector 0.44
  • patroni 3.0.4 / pgbackrest 2.47 / pgbouncer 1.20
  • grafana 10.0.3 / loki/promtail/logcli 2.8.3
  • etcd 3.5.9 / haproxy v2.8.1 / redis v7.0.12
  • minio 20230711212934 / mcli 20230711233044

Bug修复

  • 修复了 Docker 组权限的问题 [29434bd]https://github.com/Vonng/pigsty/commit/29434bdd39548d95d80a236de9099874ed564f9b
  • infra 操作系统用户组作为额外的组,而不是首要用户组。
  • 修复了 Redis Sentinel Systemd 服务的自动启用状态 5c96feb
  • 放宽了 bootstrap & configure 的检查,特别是当 /etc/redhat-release 不存在的时候。
  • 升级到 Grafana 10,修复了 Grafana 9.x CVE-2023-1410
  • 在 CMDB pglog 模式中添加了 PG 14 - 16 的 command tags 与 错误代码。

API变化

新增1个变量

  • INFRA.NGINX.nginx_exporter_enabled: 现在用户可以通过设置这个参数来禁用 nginx_exporter 。

默认值变化:

  • repo_modules: node,pgsql,infra : redis 现在由 pigsty-el 仓库提供,不再需要 redis 模块。
  • repo_upstream:
    • 新增 pigsty-el: 与具体EL版本无关的RPM: 例如 grafana, minio, pg_exporter, 等等……
    • 新增 pigsty-misc: 与具体EL版本有关的RPM: 例如 redis, prometheus 全家桶,等等……
    • 移除 citus: 现在 PGDG 中有完整的 EL7 - EL9 citus 12 支持
    • 移除 remi: redis 现在由 pigsty-el 仓库提供,不再需要 redis 模块。
  • repo_packages:
    • ansible python3 python3-pip python3-requests python3.11-jmespath dnf-utils modulemd-tools # el7: python36-requests python36-idna yum-utils
    • grafana loki logcli promtail prometheus2 alertmanager karma pushgateway node_exporter blackbox_exporter nginx_exporter redis_exporter
    • redis etcd minio mcli haproxy vip-manager pg_exporter nginx createrepo_c sshpass chrony dnsmasq docker-ce docker-compose-plugin flamegraph
    • lz4 unzip bzip2 zlib yum pv jq git ncdu make patch bash lsof wget uuid tuned perf nvme-cli numactl grubby sysstat iotop htop rsync tcpdump
    • netcat socat ftp lrzsz net-tools ipvsadm bind-utils telnet audit ca-certificates openssl openssh-clients readline vim-minimal
    • postgresql13* wal2json_13* pg_repack_13* passwordcheck_cracklib_13* postgresql12* wal2json_12* pg_repack_12* passwordcheck_cracklib_12* postgresql16* timescaledb-tools
    • postgresql15 postgresql15* citus_15* pglogical_15* wal2json_15* pg_repack_15* pgvector_15* timescaledb-2-postgresql-15* postgis33_15* passwordcheck_cracklib_15* pg_cron_15*
    • postgresql14 postgresql14* citus_14* pglogical_14* wal2json_14* pg_repack_14* pgvector_14* timescaledb-2-postgresql-14* postgis33_14* passwordcheck_cracklib_14* pg_cron_14*
    • patroni patroni-etcd pgbouncer pgbadger pgbackrest pgloader pg_activity pg_partman_15 pg_permissions_15 pgaudit17_15 pgexportdoc_15 pgimportdoc_15 pg_statement_rollback_15*
    • orafce_15* mysqlcompat_15 mongo_fdw_15* tds_fdw_15* mysql_fdw_15 hdfs_fdw_15 sqlite_fdw_15 pgbouncer_fdw_15 multicorn2_15* powa_15* pg_stat_kcache_15* pg_stat_monitor_15* pg_qualstats_15 pg_track_settings_15 pg_wait_sampling_15 system_stats_15
    • plprofiler_15* plproxy_15 plsh_15* pldebugger_15 plpgsql_check_15* pgtt_15 pgq_15* pgsql_tweaks_15 count_distinct_15 hypopg_15 timestamp9_15* semver_15* prefix_15* rum_15 geoip_15 periods_15 ip4r_15 tdigest_15 hll_15 pgmp_15 extra_window_functions_15 topn_15
    • pg_background_15 e-maj_15 pg_catcheck_15 pg_prioritize_15 pgcopydb_15 pg_filedump_15 pgcryptokey_15 logerrors_15 pg_top_15 pg_comparator_15 pg_ivm_15* pgsodium_15* pgfincore_15* ddlx_15 credcheck_15 safeupdate_15 pg_squeeze_15* pg_fkpart_15 pg_jobmon_15
  • repo_url_packages:
  • node_default_packages:
    • lz4,unzip,bzip2,zlib,yum,pv,jq,git,ncdu,make,patch,bash,lsof,wget,uuid,tuned,nvme-cli,numactl,grubby,sysstat,iotop,htop,rsync,tcpdump
    • netcat,socat,ftp,lrzsz,net-tools,ipvsadm,bind-utils,telnet,audit,ca-certificates,openssl,readline,vim-minimal,node_exporter,etcd,haproxy,python3,python3-pip
  • infra_packages
    • grafana,loki,logcli,promtail,prometheus2,alertmanager,karma,pushgateway
    • node_exporter,blackbox_exporter,nginx_exporter,redis_exporter,pg_exporter
    • nginx,dnsmasq,ansible,postgresql15,redis,mcli,python3-requests
  • PGSERVICE in .pigsty 被移除了,取而代之的是 PGDATABASE=postgres,这用户只需 IP 地址就可以从管理节点访问特定实例。

目录结构变化:

  • bin/dns and bin/ssh 现在被移动到 vagrant/ 目录中。
MD5 (pigsty-pkg-v2.2.0.el7.x86_64.tgz) = 5fb6a449a234e36c0d895a35c76add3c
MD5 (pigsty-pkg-v2.2.0.el8.x86_64.tgz) = c7211730998d3b32671234e91f529fd0
MD5 (pigsty-pkg-v2.2.0.el9.x86_64.tgz) = 385432fe86ee0f8cbccbbc9454472fdd

v2.1.0 发布注记

Pigsty v2.1 提供了对 PostgreSQL 12 ~ 16 的支持

v2.1.0

相关文章:Pigsty v2.1 发布:向量扩展 / PG12-16 支持

发布注记:https://github.com/Vonng/pigsty/releases/tag/v2.1.0

Highlight

  • PostgreSQL 16 beta 支持, 以及 12 ~ 15 的支持.
  • 为 PG 12 - 15 新增了 PGVector 扩展支持,用于存储 AI 嵌入。
  • 为 Grafana 添加了额外6个默认的扩展面板/数据源插件。
  • 添加 bin/profile 脚本用于执行远程 Profiling ,生成火焰图。
  • 添加 bin/validate 用于校验 pigsty.yml 配置文件合法性。
  • 添加 bin/repo-add 用于快速向节点添加 Yum 源定义。
  • PostgreSQL 16 可观测性:添加了 pg_stat_io 支持与相关监控面板

软件升级

  • PostgreSQL 15.3 , 14.8, 13.11, 12.15, 11.20, and 16 beta1
  • pgBackRest 2.46 / pgbouncer 1.19
  • Redis 7.0.11
  • Grafana v9.5.3
  • Loki / Promtail / Logcli 2.8.2
  • Prometheus 2.44
  • TimescaleDB 2.11.0
  • minio-20230518000536 / mcli-20230518165900
  • Bytebase v2.2.0

改进增强

  • 当添加本地用户的公钥时,所有的 id*.pub 都会被添加到远程机器上(例如椭圆曲线算法生成的密钥文件)

v2.0.0 发布注记

Pigsty v2.0 在安全性,兼容性,功能整合上进行了大量工作,真正成为 RDS 的本地开源替代品。

v2.0.0

相关文章:

Pigsty v2.0.0 正式发布!

从v2.0.0开始,PIGSTY 现在是 “PostgreSQL In Great STYle"的首字母缩写,即"全盛状态的PostgreSQL”。

curl -fsSL http://download.pigsty.cc/get | bash
Download directly from GitHub Release
bash -c "$(curl -fsSL https://raw.githubusercontent.com/Vonng/pigsty/master/bin/get)"

# or download tarball directly with curl (EL9)
curl -L https://github.com/Vonng/pigsty/releases/download/v2.0.0/pigsty-v2.0.0.tgz -o ~/pigsty.tgz
curl -L https://github.com/Vonng/pigsty/releases/download/v2.0.0/pigsty-pkg-v2.0.0.el9.x86_64.tgz  -o /tmp/pkg.tgz
# EL7: https://github.com/Vonng/pigsty/releases/download/v2.0.0/pigsty-pkg-v2.0.0.el7.x86_64.tgz
# EL8: https://github.com/Vonng/pigsty/releases/download/v2.0.0/pigsty-pkg-v2.0.0.el8.x86_64.tgz

亮点

  • 完美整合 PostgreSQL 15, PostGIS 3.3, Citus 11.2, TimescaleDB 2.10,分布式地理时序超融合数据库。
  • OS兼容性大幅增强:支持 EL7,8,9,以及 RHEL, CentOS, Rocky, OracleLinux, AlmaLinux等兼容发行版。
  • 安全性改进:自签名CA,全局网络流量SSL加密,密码scram-sha-256认证,备份采用AES加密,重制的HBA规则系统。
  • Patroni升级至3.0,提供原生的高可用 Citus 分布式集群支持,默认启用FailSafe模式,无惧DCS故障致全局主库瘫痪。
  • 提供基于 pgBackRest 的开箱即用的时间点恢复 PITR 支持,默认支持本地文件系统与专用MinIO/S3集群备份。
  • 新模块 ETCD,可独立部署,简易扩缩容,自带监控高可用,彻底取代 Consul 作为高可用 PG 的 DCS。
  • 新模块 MINIO,可独立部署,支持多盘多节点部署,用作S3本地替代,亦用于集中式 PostgreSQL 备份仓库。
  • 大幅精简配置文件参数,无需默认值即可使用;模板自动根据机器规格调整主机与PG参数,HBA/服务的定义更简洁泛用。
  • 受 Grafana 与 MinIO 影响,软件协议由 Apache License 2.0 变更为 AGPL 3.0

兼容性

  • 支持 EL7, EL8, EL9 三个大版本,并提供三个版本对应的离线软件包,默认开发测试环境由EL7升级至EL9。
  • 支持更多EL兼容Linux发行版:RHEL, CentOS, RockyLinux, AlmaLinux, OracleLinux等…
  • 源码包与离线软件包的命名规则发生改变,现在版本号,操作系统版本号,架构都会体现在包名中。
  • PGSQL: PostgreSQL 15.2, PostGIS 3.3, Citus 11.2, TimescaleDB 2.10 现可同时使用,协同工作。
  • PGSQL: Patroni 升级至 3.0 版本,作为 PGSQL 的高可用组件。
    • 默认使用 ETCD 作为 DCS,取代 Consul,减少一个 Consul Agent 失效点。
    • 因为 vip-manager 升级至 2.1 并使用 ETCDv3 API,彻底弃用 ETCDv2 API,Patroni同理
    • 提供原生的高可用 Citus 分布式集群支持。使用完全开源所有功能的 Citus 11.2。
    • 默认启用FailSafe模式,无惧DCS故障致全局主库瘫痪。
  • PGSQL: 引入 pgBackrest v2.44 提供开箱即用的 PostgreSQL 时间点恢复 PITR 功能
    • 默认使用主库上的备份目录创建备份仓库,滚动保留两天的恢复窗口。
    • 默认备选备份仓库为专用 MinIO/S3 集群,滚动保留两周的恢复窗口,本地使用需要启用 MinIO 模块。
  • ETCD 现在作为一个独立部署的模块,带有完整的扩容/缩容方案与监控。
  • MINIO 现在成为一个独立部署的模块,支持多盘多节点部署,用作S3本地替代,亦可用作集中式备份仓库。
  • NODE 模块现在包含 haproxy, docker, node_exporter, promtail 功能组件
    • chronyd 现在取代 ntpd 成为所有节点默认的 NTP 服务。
    • HAPROXY 现从属于 NODE 的一部分,而不再是 PGSQL 专属,可以 NodePort 的方式对外暴露服务。
    • 现在 PGSQL 模块可以使用专用的集中式 HAPROXY 集群统一对外提供服务。
  • INFRA 模块现在包含 dnsmasq, nginx, prometheus, grafana, loki 等组件
    • Infra 模块中的 DNSMASQ 服务器默认启用,并添加为所有节点的默认 DNS 服务器之一。
    • 添加了 blackbox_exporter 用于主机 PING 探测,pushgateway 用于批处理任务指标。
    • lokipromtail 现在使用 Grafana 默认的软件包,使用官方的 Grafana Echarts 面板插件
    • 提供针对 PostgreSQL 15 的新增可观测性位点的监控支持,添加 Patroni 监控
  • 软件版本升级
    • PostgreSQL 15.2 / PostGIS 3.3 / TimescaleDB 2.10 / Citus 11.2
    • Patroni 3.0 / Pgbouncer 1.18 / pgBackRest 2.44 / vip-manager 2.1
    • HAProxy 2.7 / Etcd 3.5 / MinIO 20230131022419 / mcli 20230128202938
    • Prometheus 2.42 / Grafana 9.3 / Loki & Promtail 2.7 / Node Exporter 1.5

安全性

  • 启用了一个完整的本地自签名CA:pigsty-ca,用于签发内网组件所使用的证书。
  • 创建用户/修改密码的操作将不再会在日志文件中留下痕迹。
  • Nginx 默认启用 SSL 支持(如需HTTPS,您需要在系统中信任pigsty-ca,或使用Chrome thisisunsafe
  • ETCD 全面启用 SSL 加密客户端与服务端对等通信
  • PostgreSQL 添加并默认启用了 SSL 支持,管理链接默认都使用SSL访问。
  • Pgbouncer 添加了 SSL 支持,出于性能考虑默认不启用。
  • Patroni 添加了 SSL 支持,并默认限制了管理 API 只能从本机与管理节点使用密码认证方可访问。
  • PostgreSQL 的默认密码认证方式由 md5 改为 scram-sha-256
  • Pgbouncer添加了认证查询支持,可以动态管理连接池用户。
  • pgBackRest 使用远端集中备份存储仓库时,默认使用 AES-256-CBC 加密备份数据。
  • 提供高安全等级配置模板:强制使用全局 SSL,并要求使用管理员证书登陆。
  • 所有默认HBA规则现在全部在配置文件中显式定义。

可维护性

  • 现有的配置模板可根据机器规格(CPU/内存/存储)自动调整优化。
  • 现在可以动态配置 Postgres/Pgbouncer/Patroni/pgBackRest 的日志目录:默认为:/pg/log/<type>/
  • 原有的 IP 地址占位符 10.10.10.10 被替换为一个专用变量:${admin_ip},可在多处引用,便于切换备用管理节点。
  • 您可以指定 region 来使用不同地区的上游镜像源,以加快软件包的下载速度。
  • 现在允许用户定义更细粒度的上游源地址,您可以根据不同的EL版本、架构,以及地区,使用不同的上游源。
  • 提供了阿里云与AWS中国地区的 Terraform 模板,可用于一键拉起所需的 EC2 虚拟机。
  • 提供了多种不同规格的 Vagrant 沙箱模板:meta, full, el7/8/9, minio, build, citus
  • 添加了新的专用剧本:pgsql-monitor.yml 用于监控现有的 Postgres 实例或 RDS。
  • 添加了新的专用剧本:pgsql-migration.yml ,使用逻辑复制无缝迁移现有实例至 Pigsty管理的集群。
  • 添加了一系列专用 Shell 实用命令,封装常见运维操作,方便用户使用。
  • 优化了所有 Ansible Role 的实现,使其更加简洁、易读、易维护,无需默认参数即可使用。
  • 允许在业务数据库/用户的层次上定义额外的 Pgbouncer 参数。

API变更

Pigsty v2.0 进行了大量变更,新增64个参数,移除13个参数,重命名17个参数。

新增的参数

  • INFRA.META.admin_ip : 主元节点 IP 地址
  • INFRA.META.region : 上游镜像区域:default|china|europe
  • INFRA.META.os_version : 企业版 Linux 发行版本:7,8,9
  • INFRA.CA.ca_cn : CA 通用名称,默认为 pigsty-ca
  • INFRA.CA.cert_validity : 证书有效期,默认为 20 年
  • INFRA.REPO.repo_enabled : 在 infra 节点上构建本地 yum 仓库吗?
  • INFRA.REPO.repo_upstream : 上游 yum 仓库定义列表
  • INFRA.REPO.repo_home : 本地 yum 仓库的主目录,通常与 nginx_home ‘/www’ 相同
  • INFRA.NGINX.nginx_ssl_port : https 监听端口
  • INFRA.NGINX.nginx_ssl_enabled : 启用 nginx https 吗?
  • INFRA.PROMTETHEUS.alertmanager_endpoint : altermanager 端点(ip|domain):端口格式
  • NODE.NODE_TUNE.node_hugepage_ratio : 内存 hugepage 比率,默认禁用,值为 0
  • NODE.HAPROXY.haproxy_service : 要公开的 haproxy 服务列表
  • PGSQL.PG_ID.pg_mode : pgsql 集群模式:pgsql,citus,gpsql
  • PGSQL.PG_BUSINESS.pg_dbsu_password : dbsu 密码,默认为空字符串表示没有 dbsu 密码
  • PGSQL.PG_INSTALL.pg_log_dir : postgres 日志目录,默认为 /pg/data/log
  • PGSQL.PG_BOOTSTRAP.pg_storage_type : SSD|HDD,默认为 SSD
  • PGSQL.PG_BOOTSTRAP.patroni_log_dir : patroni 日志目录,默认为 /pg/log
  • PGSQL.PG_BOOTSTRAP.patroni_ssl_enabled : 使用 SSL 保护 patroni RestAPI 通信?
  • PGSQL.PG_BOOTSTRAP.patroni_username : patroni rest api 用户名
  • PGSQL.PG_BOOTSTRAP.patroni_password : patroni rest api 密码(重要:请更改此密码)
  • PGSQL.PG_BOOTSTRAP.patroni_citus_db : 由 patroni 管理的 citus 数据库,默认为 postgres
  • PGSQL.PG_BOOTSTRAP.pg_max_conn : postgres 最大连接数,auto 将使用推荐值
  • PGSQL.PG_BOOTSTRAP.pg_shmem_ratio : postgres 共享内存比率,默认为 0.25,范围 0.1~0.4
  • PGSQL.PG_BOOTSTRAP.pg_rto : 恢复时间目标,故障转移的 ttl,默认为 30s
  • PGSQL.PG_BOOTSTRAP.pg_rpo : 恢复点目标,默认最多丢失 1MB 数据
  • PGSQL.PG_BOOTSTRAP.pg_pwd_enc : 密码加密算法:md5|scram-sha-256
  • PGSQL.PG_BOOTSTRAP.pgbouncer_log_dir : pgbouncer 日志目录,默认为 /var/log/pgbouncer
  • PGSQL.PG_BOOTSTRAP.pgbouncer_auth_query : 如果启用,查询 pg_authid 表以检索 biz 用户,而不是填充用户列表
  • PGSQL.PG_BOOTSTRAP.pgbouncer_sslmode : pgbouncer 客户端的 SSL:disable|allow|prefer|require|verify-ca|verify-full
  • PGSQL.PG_BOOTSTRAP.pg_service_provider : 专用的 haproxy 节点组名称,或者默认为本地节点的空字符串
  • PGSQL.PG_BOOTSTRAP.pg_default_service_dest : 如果 svc.dest=‘default’,则为默认服务目标
  • PGSQL.PG_BACKUP.pgbackrest_enabled : 启用 pgbackrest 吗?
  • PGSQL.PG_BACKUP.pgbackrest_clean : 初始化期间删除 pgbackrest 数据吗?
  • PGSQL.PG_BACKUP.pgbackrest_log_dir : pgbackrest 日志目录,默认为 /pg/log
  • PGSQL.PG_BACKUP.pgbackrest_method : pgbackrest 备份仓库方法,local 或 minio
  • PGSQL.PG_BACKUP.pgbackrest_repo : pgbackrest 备份仓库配置
  • PGSQL.PG_DNS.pg_dns_suffix : pgsql dns 后缀,默认为空字符串
  • PGSQL.PG_DNS.pg_dns_target : auto, primary, vip, none 或 ad hoc ip
  • ETCD.etcd_seq : etcd 实例标识符,必需
  • ETCD.etcd_cluster : etcd 集群和组名称,默认为 etcd
  • ETCD.etcd_safeguard : 防止清除正在运行的 etcd 实例吗?
  • ETCD.etcd_clean : 在初始化期间清除现有的 etcd 吗?
  • ETCD.etcd_data : etcd 数据目录,默认为 /data/etcd
  • ETCD.etcd_port : etcd 客户端端口,默认为 2379
  • ETCD.etcd_peer_port : etcd 对等端口,默认为 2380
  • ETCD.etcd_init : etcd 初始集群状态,新建或已存在
  • ETCD.etcd_election_timeout : etcd 选举超时,默认为 1000ms
  • ETCD.etcd_heartbeat_interval : etcd 心跳间隔,默认为 100ms
  • MINIO.minio_seq : minio 实例标识符,必须参数
  • MINIO.minio_cluster : minio 集群名称,默认为 minio
  • MINIO.minio_clean : 初始化时清理 minio 吗?默认为 false
  • MINIO.minio_user : minio 操作系统用户,默认为 minio
  • MINIO.minio_node : minio 节点名模式
  • MINIO.minio_data : minio 数据目录,使用 {x…y} 来指定多个驱动器
  • MINIO.minio_domain : minio 外部域名,默认为 sss.pigsty
  • MINIO.minio_port : minio 服务端口,默认为 9000
  • MINIO.minio_admin_port : minio 控制台端口,默认为 9001
  • MINIO.minio_access_key : 根访问密钥,默认为 minioadmin
  • MINIO.minio_secret_key : 根秘密密钥,默认为 minioadmin
  • MINIO.minio_extra_vars : minio 服务器的额外环境变量
  • MINIO.minio_alias : 本地 minio 部署的别名
  • MINIO.minio_buckets : 待创建的 minio 存储桶列表
  • MINIO.minio_users : 待创建的 minio 用户列表

移除的参数

  • INFRA.CA.ca_homedir : CA 主目录,现在固定为 /etc/pki/
  • INFRA.CA.ca_cert : CA 证书文件名,现在固定为 ca.key
  • INFRA.CA.ca_key : CA 密钥文件名,现在固定为 ca.key
  • INFRA.REPO.repo_upstreams : 已被 repo_upstream 替代
  • PGSQL.PG_INSTALL.pgdg_repo : 现在由节点 playbooks 负责
  • PGSQL.PG_INSTALL.pg_add_repo : 现在由节点 playbooks 负责
  • PGSQL.PG_IDENTITY.pg_backup : 未使用且与部分名称冲突
  • PGSQL.PG_IDENTITY.pg_preflight_skip : 不再使用,由 pg_id 替代
  • DCS.dcs_name : 由于使用 etcd 而被移除
  • DCS.dcs_servers : 被 ad hoc 组 etcd 替代
  • DCS.dcs_registry : 由于使用 etcd 而被移除
  • DCS.dcs_safeguard : 被 etcd_safeguard 替代
  • DCS.dcs_clean : 被 etcd_clean 替代

重命名的参数

  • nginx_upstream -> infra_portal
  • repo_address -> repo_endpoint
  • pg_hostname -> node_id_from_pg
  • pg_sindex -> pg_group
  • pg_services -> pg_default_services
  • pg_services_extra -> pg_services
  • pg_hba_rules_extra -> pg_hba_rules
  • pg_hba_rules -> pg_default_hba_rules
  • pgbouncer_hba_rules_extra -> pgb_hba_rules
  • pgbouncer_hba_rules -> pgb_default_hba_rules
  • vip_mode -> pg_vip_enabled
  • vip_address -> pg_vip_address
  • vip_interface -> pg_vip_interface
  • node_packages_default -> node_default_packages
  • node_packages_meta -> infra_packages
  • node_packages_meta_pip -> infra_packages_pip
  • node_data_dir -> node_data

Checksums

MD5 (pigsty-pkg-v2.0.0-rc1.el7.x86_64.tgz) = af4b5db9dc38c860de609956a8f1f0d3
MD5 (pigsty-pkg-v2.0.0-rc1.el8.x86_64.tgz) = 5b7152e142df3e3cbc06de30bd70e433
MD5 (pigsty-pkg-v2.0.0-rc1.el9.x86_64.tgz) = 1362e2a5680fc1a3a014cc4f304100bd

特别感谢意大利用户 @alemacci 在 SSL加密,备份,多操作系统发行版适配与自适应参数模版上的贡献!


v2.0.1

https://github.com/Vonng/pigsty/releases/tag/v2.0.1

安全性改进,与对 v2.0.0 的 BUG 修复。

改进

  • 更换猪头 logo 以符合 PostgreSQL 商标政策。
  • 将 grafana 版本升级至 v9.4,界面更佳且修复了 bug。
  • 将 patroni 版本升级至 v3.0.1,其中包含了一些 bug 修复。
  • 修改:将 grafana systemd 服务文件回滚到 rpm 默认的版本。
  • 使用缓慢的 copy 代替 rsync 来复制 grafana 仪表板,更加可靠。
  • 增强:bootstrap 执行后会添加回默认 repo 文件。
  • 添加 asciinema 视频,用于各种管理任务。
  • 安全增强模式:限制监控用户权限。
  • 新的配置模板:dual.yml,用于双节点部署。
  • crit.yml 模板中启用 log_connectionslog_disconnections
  • crit.yml 模板中的 pg_libs 中启用 $lib/passwordcheck
  • 明确授予 pg_monitor 角色监视视图权限。
  • dbuser_monitor 中移除默认的 dbrole_readonly 以限制监控用户的权限
  • 现在 patroni 监听在 {{ inventory_hostname }} 而不是 0.0.0.0
  • 现在你可以使用 pg_listen 控制 postgres/pgbouncer 监听的地址
  • 现在你可以在 pg_listen 中使用 ${ip}, ${lo}, ${vip} 占位符
  • 将 Aliyun terraform 镜像从 centos 7.9 提升到 rocky Linux 9
  • 将 bytebase 版本升级到 v1.14.0

BUG修复

  • 为 alertmanager 添加缺失的 advertise 地址。
  • 解决使用 bin/pgsql-user 创建数据库用户时,pg_mode 变量缺失问题。
  • redis.yml 中为 Redis 集群加入任务添加 -a password 选项。
  • infra-rm.yml.remove infra data 任务中补充缺失的默认值。
  • 修复 prometheus 监控对象定义文件的属主为 prometheus 用户。
  • 使用 管理员用户 而不是 root 去删除 DCS 中的元数据。
  • 修复了由 grafana 9.4 bug 导致的问题:Meta数据源缺失。

注意事项

EL8 pgdg 上游官方源处于依赖破损状态,请小心使用。涉及到的软件包: postgis33_15, pgloader, postgresql_anonymizer_15*, postgresql_faker_15

如何升级?

cd ~/pigsty; tar -zcf /tmp/files.tgz files; rm -rf ~/pigsty    # backup files dir and remove
cd ~; bash -c "$(curl -fsSL https://get.pigsty.cc/latest)"      # get latest pigsty source
cd ~/pigsty; rm -rf files; tar -xf /tmp/files.tgz -C ~/pigsty  # restore files dir

Checksums

MD5 (pigsty-pkg-v2.0.1.el7.x86_64.tgz) = 5cfbe98fd9706b9e0f15c1065971b3f6
MD5 (pigsty-pkg-v2.0.1.el8.x86_64.tgz) = c34aa460925ae7548866bf51b8b8759c
MD5 (pigsty-pkg-v2.0.1.el9.x86_64.tgz) = 055057cebd93c473a67fb63bcde22d33

特别感谢 @cocoonkid 提供的反馈。


v2.0.2

https://github.com/Vonng/pigsty/releases/tag/v2.0.2

亮点

使用开箱即用的 pgvector 存储 AI Embedding、索引、检索向量。

变更

  • 新扩展插件 pgvector 用于存储 AI 嵌入,并执行向量相似度搜索。
  • 修复 MinIO CVE-2023-28432 ,使用 20230324 新提供的 policy API.
  • 为 DNSMASQ systemd 服务添加动态重载命令
  • 更新 PEV 版本至 v1.8
  • 更新 grafana 版本至 v9.4.7
  • 更新 MinIO 与 MCLI 版本至 20230324
  • 更新 bytebase 版本至 v1.15.0
  • 更新监控面板并修复死链接
  • 更新了阿里云 Terraform 模板,默认使用 RockyLinux 9
  • 使用 Grafana v9.4 的 Provisioning API
  • 为众多管理任务添加了 asciinema 视频
  • 修复了 EL8 PostgreSQL 的破损依赖:移除 anonymizer_15 faker_15 pgloader
MD5 (pigsty-pkg-v2.0.2.el7.x86_64.tgz) = d46440a115d741386d29d6de646acfe2
MD5 (pigsty-pkg-v2.0.2.el8.x86_64.tgz) = 5fa268b5545ac96b40c444210157e1e1
MD5 (pigsty-pkg-v2.0.2.el9.x86_64.tgz) = c8b113d57c769ee86a22579fc98e8345

v1.5.0 发布注记

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 : 主要的数据挂载路径,如果不存在会被创建。
  • node_crontab_overwrite : 覆盖 /etc/crontab 而非追加内容。
  • node_crontab: 要被追加或覆盖的 node crontab 内容。
  • nameserver_enabled: 在这个基础设施节节点上启用 nameserver 吗?
  • prometheus_enabled: 在这个基础设施节节点上启用 prometheus 吗?
  • grafana_enabled: 在这个基础设施节节点上启用 grafana 吗?
  • loki_enabled: 在这个基础设施节节点上启用 loki 吗?
  • docker_enable: 在这个基础设施节点上启用 docker 吗?
  • consul_enable: 启用 consul 服务器/代理吗?
  • etcd_enable: 启用 etcd 服务器/客户端吗?
  • pg_checksum: 启用 pg 集群数据校验和吗?
  • pg_delay: 备份集群主库复制重放时的应用延迟。

参数重制

现在 *_clean 是布尔类型的参数,用于在初始化期间清除现有实例。

*_safeguard 也是布尔类型的参数,用于在执行任何剧本时,避免清除正在运行的实例。

  • 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 发布注记

Pigsty v1.4 添加了时序数据仓库 YMatrixDB 支持

v1.4.0

架构

  • 将系统解耦为4大类别:INFRANODESPGSQLREDIS,这使得pigsty更加清晰、更易于扩展。
  • 单节点部署 = INFRA + NODES + PGSQL
  • 部署pgsql集群 = NODES + PGSQL
  • 部署redis集群 = NODES + REDIS
  • 部署其他数据库 = NODES + xxx(例如 MONGOKAFKA…待定)

可访问性

  • 为中国大陆提供CDN。
  • 使用 bash -c "$(curl -fsSL http://get.pigsty.cc/latest)" 获取最新源代码。
  • 使用新的 download 脚本下载并提取包。

监控增强

  • 将监控系统分为5大类别:INFRANODESREDISPGSQLAPP
  • 默认启用日志记录
    • 现在默认启用lokipromtail,带有预构建的 loki-rpm
  • 模型和标签
    • 为所有仪表板添加了一个隐藏的ds prometheus数据源变量,因此您只需选择一个新的数据源而不是修改Grafana数据源和仪表板。
    • 为所有指标添加了一个ip标签,并将其用作数据库指标和节点指标之间的连接键。
  • INFRA监控
    • Infra主仪表板:INFRA概览
    • 添加日志仪表板:日志实例
    • PGLOG分析和PGLOG会话现在被视为一个示例Pigsty APP。
  • NODES监控应用
    • 如果您完全不关心数据库,现在可以单独使用Pigsty作为主机监控软件!
    • 包括4个核心仪表板:节点概览 & 节点集群 & 节点实例 & 节点警报
    • 为节点引入新的身份变量:node_clusternodename
    • 变量pg_hostname现在意味着将主机名设置为与postgres实例名相同,以保持向后兼容性
    • 变量nodename_overwrite 控制是否用nodename覆盖节点的主机名
    • 变量nodename_exchange 将nodename写入彼此的/etc/hosts
    • 所有节点指标引用都经过修订,通过ip连接
    • 节点监控目标在/etc/prometheus/targets/nodes下单独管理
  • PGSQL监控增强
    • 完全新的PGSQL集群,简化并专注于集群中的重要内容。
    • 新仪表板PGSQL数据库是集群级对象监控。例如整个集群而不是单个实例的表和查询。
    • PGSQL警报仪表板现在只关注pgsql警报。
    • PGSQL Shard已添加到PGSQL中。
  • Redis监控增强
    • 为所有redis仪表板添加节点监控。

MatrixDB支持

  • 通过pigsty-matrix.yml playbook可以部署MatrixDB(Greenplum 7)
  • MatrixDB监控仪表板:PGSQL MatrixDB
  • 添加示例配置:pigsty-mxdb.yml

监控增强

  • 将监控系统分为5大类别:INFRANODESREDISPGSQLAPP
  • 默认启用日志记录
    • 现在默认启用lokipromtail,带有预构建的 loki-rpm
  • 模型和标签
    • 为所有仪表板添加了一个隐藏的ds prometheus数据源变量,因此您只需选择一个新的数据源而不是修改Grafana数据源和仪表板。
    • 为所有指标添加了一个ip标签,并将其用作数据库指标和节点指标之间的连接键。
  • INFRA监控
    • Infra主仪表板:INFRA概览
    • 添加日志仪表板:日志实例
    • PGLOG分析和PGLOG会话现在被视为一个示例Pigsty APP。
  • NODES监控应用
    • 如果您完全不关心数据库,现在可以单独使用Pigsty作为主机监控软件!
    • 包括4个核心仪表板:节点概览 & 节点集群 & 节点实例 & 节点警报
    • 为节点引入新的身份变量:node_clusternodename
    • 变量pg_hostname现在意味着将主机名设置为与postgres实例名相同,以保持向后兼容性
    • 变量nodename_overwrite 控制是否用nodename覆盖节点的主机名
    • 变量nodename_exchange 将nodename写入彼此的/etc/hosts
    • 所有节点指标引用都经过修订,通过ip连接
    • 节点监控目标在/etc/prometheus/targets/nodes下单独管理
  • PGSQL监控增强
    • 完全新的PGSQL集群,简化并专注于集群中的重要内容。
    • 新仪表板PGSQL数据库是集群级对象监控。例如整个集群而不是单个实例的表和查询。
    • PGSQL警报仪表板现在只关注pgsql警报。
    • PGSQL Shard已添加到PGSQL中。
  • Redis监控增强
    • 为所有redis仪表板添加节点监控。

MatrixDB支持

  • 通过pigsty-matrix.yml playbook可以部署MatrixDB(Greenplum 7)
  • MatrixDB监控仪表板:PGSQL MatrixDB
  • 添加示例配置:pigsty-mxdb.yml

置备改进

现在 pigsty 的工作流如下:

 infra.yml ---> 在单一的元节点上安装 pigsty
      |          然后将更多节点加入 pigsty 的管理下
      |
 nodes.yml --->  pigsty 准备节点(节点设置、dcsnode_exporterpromtail
      |          然后选择一个 playbook 在这些节点上部署数据库集群
      |
      ^--> pgsql.yml   在已准备好的节点上安装 postgres
      ^--> redis.yml   在已准备好的节点上安装 redis

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:用于设置和准备 pigsty 的节点,
  • 在节点上设置 node、node_exporter、consul agent
  • node-remove.yml 用于节点注销
  • pgsql.yml:现在只在已准备好的节点上工作
  • pgsql-remove 现在只负责 postgres 本身(dcs 和节点监控由 node.yml 负责)
  • 添加一系列新选项以在 greenplum/matrixdb 中重用 postgres 角色
  • redis.yml:现在在已准备好的节点上工作
  • redis-remove.yml 现在从节点上移除 redis。
  • pgsql-matrix.yml 现在在已准备好的节点上安装 matrixdb(Greenplum 7)。

软件升级

  • PostgreSQL 14.2
  • PostGIS 3.2
  • TimescaleDB 2.6
  • Patroni 2.1.3 (Prometheus 指标 + 故障转移插槽)
  • HAProxy 2.5.5 (修复统计错误,更多指标)
  • PG 导出器 0.4.1 (超时参数等)
  • Grafana 8.4.4
  • Prometheus 2.33.4
  • Greenplum 6.19.4 / MatrixDB 4.4.0
  • Loki 现在作为 rpm 包提供,而不是 zip 存档。

错误修复

  • 删除 patroni 的 consul 依赖,这使其更容易迁移到新的 consul 集群
  • 修复 prometheus bin/new 脚本的默认数据目录路径:从 /export/prometheus 更改为 /data/prometheus
  • 在 vip-manager systemd 服务中添加重新启动秒数
  • 修复错别字和任务

API 变更

新增变量

  • node_cluster:节点集群的身份变量
  • nodename_overwrite:如果设置,则 nodename 将设置为节点的主机名
  • nodename_exchange:交换 play 主机之间的节点主机名(在 /etc/hosts 中)
  • node_dns_hosts_extra:可以通过单个实例/集群轻松覆盖的额外静态 dns 记录
  • patroni_enabled:如果禁用,postgres & patroni 的引导过程不会在 postgres 角色期间执行
  • pgbouncer_enabled:如果禁用,pgbouncer 在 postgres 角色期间不会启动
  • pg_exporter_params:生成监控目标 url 时为 pg_exporter 提供的额外 url 参数。
  • pg_provision:布尔值变量,表示是否执行 postgres 角色的资源配置部分(模板,数据库,用户)
  • no_cmdb:用于 infra.ymlinfra-demo.yml 播放书,不会在元节点上创建 cmdb。
MD5 (app.tgz) = f887313767982b31a2b094e5589a75ea
MD5 (matrix.tgz) = 3d063437c482d94bd7e35df1a08bbc84
MD5 (pigsty.tgz) = e143b88ebea1474f9ebaffddc6072c49
MD5 (pkg.tgz) = 73e8f5ce995b1f1760cb63c1904fb91b

v1.4.1

日常错误修复 / Docker 支持 / 英文文档

现在,默认在元节点上启用 docker。您可以使用它启动海量的各类软件

现在提供英文文档。

Bug 修复

v1.3.0 发布注记

Pigsty v1.3.0 更新了 PGCAT 重整 & PGSQL 增强 & Redis Beta支持

1.3.0

  • 【功能增强】Redis 部署(集群、哨兵、主从)
  • 【功能增强】Redis 监控
    • Redis 总览仪表盘
    • Redis 集群仪表盘
    • Redis 实例仪表盘 -【功能增强】 监控:PGCAT 大修
    • 新仪表盘:PGCAT 实例
    • 新仪表盘:PGCAT 数据库仪表盘
    • 重做仪表盘:PGCAT 表格
  • 【功能增强】 监控:PGSQL 增强
    • 新面板:PGSQL 集群,添加 10 个关键指标面板(默认切换)
    • 新面板:PGSQL 实例,添加 10 个关键指标面板(默认切换)
    • 简化 & 重新设计:PGSQL 服务
    • 在 PGCAT & PGSL 仪表盘之间添加交叉引用 -【功能增强】 监控部署
    • 现在 grafana 数据源在仅监控部署期间自动注册 -【功能增强】 软件升级
    • 将 PostgreSQL 13 添加到默认包列表
    • 默认升级到 PostgreSQL 14.1
    • 添加 greenplum rpm 和依赖项
    • 添加 redis rpm & 源代码包
    • 将 perf 添加为默认包

v1.3.1

监控

  • PGSQL & PGCAT 仪表盘改进
  • 优化 pgcat 实例 & pgcat 数据库的布局
  • 在 pgsql 实例仪表盘中添加关键指标面板,与 pgsql 集群保持一致
  • 在 pgcat 数据库中添加表/索引膨胀面板,移除 pgcat 膨胀仪表盘
  • 在 pgcat 数据库仪表盘中添加索引信息
  • 修复在 grafana 8.3 中的损坏面板
  • 在 nginx 主页中添加 redis 索引

部署

  • 新的 infra-demo.yml 剧本用于一次性引导
  • 使用 infra-jupyter.yml 剧本部署可选的 jupyter lab 服务器
  • 使用 infra-pgweb.yml 剧本部署可选的 pgweb 服务器
  • 在 meta 节点上新的 pg 别名,可以从 admin 用户启动 postgres 集群(除了 postgres)
  • 根据 timescaledb-tune 的建议调整所有 patroni 配置模板中的 max_locks_per_transactions
  • 在配置模板中添加 citus.node_conninfo: 'sslmode=prefer' 以便在没有 SSL 的情况下使用 citus
  • 在 pgdg14 包列表中添加所有扩展(除了 pgrouting)
  • 将 node_exporter 升级到 v1.3.1
  • 将 PostgREST v9.0.0 添加到包列表。从 postgres 模式生成 API。

错误修复

  • Grafana 的安全漏洞(升级到 v8.3.1 问题)
  • 修复 pg_instance & pg_serviceregister 角色中从剧本的中间开始时的问题
  • 修复在没有 pg_cluster 变量存在的主机上 nginx 主页渲染问题
  • 在升级到 grafana 8.3.1 时修复样式问题

v1.2.0 发布注记

Redis Support, PGCAT Overhaul

v1.2.0

  • 【功能增强】默认使用 PostgreSQL 14 版本
  • 【功能增强】默认使用 TimescaleDB 2.5 扩展
    • 现在 timescaledb 和 postgis 默认在 cmdb 中启用
  • 【功能增强】 新增仅监控模式:
    • 仅通过可连接的 URL,您可以使用 pigsty 监控现有的 pg 实例
    • pg_exporter 将在本地的 meta 节点上部署
    • 新仪表板 PGSQL Cluster Monly 用于远程集群
  • 【功能增强】软件升级
    • grafana 升级到 8.2.2
    • pev2 升级到 v0.11.9
    • promscale 升级到 0.6.2
    • pgweb 升级到 0.11.9
    • 新增扩展:pglogical、pg_stat_monitor、orafce -【功能增强】自动检测机器规格并使用适当的 node_tunepg_conf 模板 -【功能增强】重做与膨胀相关的视图,现在公开更多信息 -【功能增强】删除 timescale 和 citus 的内部监控 -【功能增强】新剧本 pgsql-audit.yml 用于创建审计报告 -【BUG修复】现在 pgbouncer_exporter 资源所有者是 {{ pg_dbsu }} 而不是 postgres -【BUG修复】 修复在执行 REINDEX TABLE CONCURRENTLY 时 pg_exporter 在 pg_table pg_index 上的重复指标 -【功能增强】现在所有配置模板都减少到两个:auto 和 demo。(已删除:pub4, pg14, demo4, tiny, oltp)
    • 如果 vagrant 是默认用户,则配置 pigsty-demo,否则使用 pigsty-auto

如何从 v1.1.1 升级

在 1.2.0 中没有 API 变更。您仍然可以使用旧的 pigsty.yml 配置文件 (PG13)。 对于基础设施部分,重新执行 repo 将完成大部分工作。

至于数据库,您仍然可以使用现有的 PG13 实例。就地升级在涉及到像 PostGIS 和 Timescale 这样的扩展时非常棘手。我强烈推荐使用逻辑复制进行数据库迁移。 新的剧本 pgsql-migration.yml 将使这一过程变得容易得多。它将创建一系列的脚本,帮助您近乎零停机时间地迁移您的集群。

v1.1.0 发布注记

Pigsty v1.1.0 更新了主页设计, JupyterLab, PGWEB, Pev2 & pgbadger 支持

v1.1.0

  • 【增强功能】 增加 pg_dummy_filesize 以创建文件系统空间占位符
  • 【增强功能】 主页大改版
  • 【增强功能】 增加 Jupyter Lab 整合
  • 【增强功能】 增加 pgweb 控制台整合
  • 【增强功能】 增加 pgbadger 支持
  • 【增强功能】 增加 pev2 支持,解释可视化工具
  • 【增强功能】 增加 pglog 工具
  • 【增强功能】 更新默认的 pkg.tgz 软件版本:
    • PostgreSQL 升级至 v13.4(支持官方的 pg14)
    • pgbouncer 升级至 v1.16(指标定义更新)
    • Grafana 升级至 v8.1.4
    • Prometheus 升级至 v2.2.29
    • node_exporter 升级至 v1.2.2
    • haproxy 升级至 v2.1.1
    • consul 升级至 v1.10.2
    • vip-manager 升级至 v1.0.1

API 变更

  • nginx_upstream 现在持有不同的结构。(不兼容)
  • 新的配置条目:app_list,渲染至主页的导航条目
  • 新的配置条目:docs_enabled,在默认服务器上设置本地文档
  • 新的配置条目:pev2_enabled,设置本地的 pev2 工具
  • 新的配置条目:pgbadger_enabled,创建日志概要/报告目录
  • 新的配置条目:jupyter_enabled,在元节点上启用 Jupyter Lab 服务器
  • 新的配置条目:jupyter_username,指定运行 Jupyter Lab 的用户
  • 新的配置条目:jupyter_password,指定 Jupyter Lab 的默认密码
  • 新的配置条目:pgweb_enabled,在元节点上启用 pgweb 服务器
  • 新的配置条目:pgweb_username,指定运行 pgweb 的用户
  • 将内部标记 repo_exist 重命名为 repo_exists
  • 现在 repo_address 的默认值为 pigsty 而非 yum.pigsty
  • 现在 haproxy 的访问点为 http://pigsty 而非 http://h.pigsty

v1.1.1

  • 【功能增强】 用 timescale 版本替换 timescaledb 的 apache 版本
  • 【功能增强】 升级 prometheus 到 2.30
  • 【BUG修复】 现在 pg_exporter 配置目录的属主是 {{ pg_dbsu }},而不再是 prometheus

如何从v1.1.0升级?

这个版本的主要变动是 TimescaleDB,使用 TimescaleDB License (TSL)的官方版本替代了 PGDG 仓库中的 Apache License v2 的版本。

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

v1 正式发布,监控系统全面改进

亮点

  • 监控系统全面改进
    • 在 Grafana 8.0 上新增仪表盘
    • 新的度量定义,增加 PG14 支持
    • 简化的标签系统:静态标签集:(job, cls, ins)
    • 新的警报规则与衍生度量
    • 同时监控多个数据库
    • 实时日志搜索 & csvlog 分析
    • 链接丰富的仪表盘,点击图形元素进行深入|汇总
  • 架构变更
    • 将 citus 和 timescaledb 加入默认安装部分
    • 增加对 PostgreSQL 14beta2 的支持
    • 简化 haproxy 管理页面索引
    • 通过添加新的角色 register 来解耦基础设施和 pgsql
    • 添加新角色 lokipromtail 用于日志记录
    • 为管理员节点上的管理员用户添加新角色 environ 以设置环境
    • 默认使用 static 服务发现用于 prometheus(而不是 consul
    • 添加新角色 remove 以优雅地移除集群和实例
    • 升级 prometheus 和 grafana 的配置逻辑
    • 升级到 vip-manager 1.0,node_exporter 1.2,pg_exporter 0.4,grafana 8.0
    • 现在,每个实例上的每个数据库都可以自动注册为 grafana 数据源
    • 将 consul 注册任务移到 register 角色,更改 consul 服务标签
    • 添加 cmdb.sql 作为 pg-meta 基线定义(CMDB & PGLOG)
  • 应用框架
    • 可扩展框架用于新功能
    • 核心应用:PostgreSQL 监控系统:pgsql
    • 核心应用:PostgreSQL 目录浏览器:pgcat
    • 核心应用:PostgreSQL Csvlog 分析器:pglog
    • 添加示例应用 covid 用于可视化 covid-19 数据
    • 添加示例应用 isd 用于可视化 isd 数据
  • 其他
    • 添加 jupyterlab,为数据科学提供完整的 python 环境
    • 添加 vonng-echarts-panel 以恢复对 Echarts 的支持
    • 添加 wrap 脚本 createpgcreatedbcreateuser
    • 添加 cmdb 动态库存脚本:load_conf.pyinventory_cmdbinventory_conf
    • 移除过时的剧本:pgsql-monitorpgsql-servicenode-remove 等….

API 变更

  • 新变量: node_meta_pip_install
  • 新变量: grafana_admin_username
  • 新变量: grafana_database
  • 新变量: grafana_pgurl
  • 新变量: pg_shared_libraries
  • 新变量: pg_exporter_auto_discovery
  • 新变量: pg_exporter_exclude_database
  • 新变量: pg_exporter_include_database
  • 变量重命名: grafana_urlgrafana_endpoint

Bug 修复

  • 修复默认时区 Asia/Shanghai (CST) 问题
  • 修复 pgbouncer & patroni 的 nofile 限制
  • 当执行标签 pgbouncer 时,pgbouncer 的用户列表和数据库列表将会被生成

v1.0.1

2021-09-14

  • 文档更新
    • 现已支持中文文档
    • 现已支持机器翻译的英文文档
  • 错误修复:pgsql-remove 不会移除主实例
  • 错误修复:用 pg_cluster + pg_seq 替换 pg_instance
    • Start-At-Task 可能因为 pg_instance 未定义而失败
  • 错误修复:从默认共享预加载库中移除 citus
    • citus 会强制 max_prepared_transaction 的值为非零
  • 错误修复:在 configure 中进行 ssh sudo 检查:
    • 现在使用 ssh -t sudo -n ls 进行权限检查
  • 笔误修复:pg-backup 脚本的笔误
  • 警报调整:移除 NTP 合理性检查警报(与 ClockSkew 重复)
  • 导出器调整:移除 collector.systemd 以减少开销

v0.9.0 发布注记

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

v0.9.0

新功能

  • 一键安装模式:

    /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 发布注记

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

v0.8.0

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 发布注记

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

v0.7.0

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 发布注记

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

v0.6.0

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 发布注记

Pigsty 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 发布注记

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

v0.4.0

第二个公开测试版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 发布注记

Pigsty 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