当前位置: 首页 > 科技观察

PostgreSQL的并行框架

时间:2023-03-16 21:28:00 科技观察

前言2016年4月,PostgreSQL社区发布了PostgreSQL9.6,首次引入了并行查询的能力,进一步释放了多核服务器的计算能力。最近扰酱因为工作原因需要调研PostgreSQL中并行算子的实现,所以刚刚翻译了一篇介绍pg提供的并行查询框架的PostgreSQL代码中的文档,后续会陆续输出几篇研究成果。;代码中文档路径为src/backend/access/transam/README.parallel,翻译有漏之处请指正。如果有的读者对并行运算符没有概念,这里给大家举个简单的例子。我们考虑一个简单的聚合语句explainselectcount(*)frombmscantest2wherea>1。如果一张表数据不多,pg优化器不会选择使用并行化,得到的查询计划如下。postgres=#explainselectcount(*)frombmscantest2wherea>1;查询计划--------------------------------------------------------------聚合(成本=1.13..1.14行=1宽度=8)->bmscantest2上的序列扫描(成本=0.00..1.12行=3宽度=0)Filter:(a>1)(3rows)如果表中数据很多,pg可能会开始考虑并行查询计划,得到查询计划如下,其中WorkersPlanned:4表示我们已经启动了4个工作进程来计算agg。postgres=*#explainselectcount(*)frombmscantestwherea>1;查询计划------------------------------------------------------------------------------------------最终确定聚合(成本=1968.35。.1968.36rows=1width=8)->Gather(cost=1568.33..1968.34rows=4width=8)WorkersPlanned:4->PartialAggregate(cost=1568.33..1568.34rows=1width=8)->ParallelSeqScanonbmscantest(cost=0.00..1547.50rows=8333width=0)Filter:(a>1)(6rows)ThomasMunro的图片来自他给的关于ParallelisminPostgreSQL11的演讲的幻灯片2018年,算子的并行化是如何实现的,能带来什么样的性能提升,每个算子都不一样,下一章会细说。以下是文档翻译:OverviewPostgreSQL提供了一些简单的机制来简化并行算法的编写。您可以使用ParallelContext数据结构来调用后台工作进程,初始化工作进程的进程状态(以匹配调用它们的后台进程),使进程能够通过动态共享内存(DynamicSharedMemory)进行通信并编写不复杂的逻辑和您可以让您的代码在用户后台进程或任何并行工作进程中运行,而无需了解并行性。发起并行指令的进程(以下简称发起进程)首先会创建一个动态共享内存区,该内存区将存在于整个并行运行过程中。动态共享内存区域将包含(1)shm_mq用于传递错误消息(以及通过elog/ereport报告的其他信息),(2)用于同步工作进程状态的原始进程私有状态的序列化表示,以及(3)任何其他ParallelContext消费者为其用途定制的数据结构。一旦发起进程完成了动态共享内存区域的初始化,它就要求postmaster启动适当数量的工作进程。这些工作进程然后连接到动态共享内存区域,初始化它们的状态并调用入口点函数,我们将在稍后介绍。报错工作进程启动时,会先绑定动态共享内存区,定位其中的shm_mq进行报错;工作进程会将所有协议消息重定向到shm_mq。在此之前,后台工作进程中发生的所有错误都不会发送到原始进程。从发起进程的角度来看,这些工作进程根本无法初始化。启动进程还需要始终准备好与比它启动的更少的工作进程一起工作,所以如果发生这种情况不会有额外的问题。PROCSIG_PARALLEL_MESSAGE当一条消息(或者如果消息体很大且拆分时是部分消息)被放入错误报告队列时发送到发起进程。发起进程的CHECK_FOR_INTERRUPTS()检查此事件,读取它,并在发起进程上重新发出消息。在大多数情况下,这足以使错误报告以并行模式工作。当然,为了正常运行,发起进程需要定期执行CHECK_FOR_INTERRUPTS()并避免中断长时间阻塞的进程,但这些都是他们应该做的。(仍然存在的一个未解决的问题是,有时某些消息会两次写入系统日志,一次是由报告发生的工作进程写入的,另一次是在原始进程收到消息后由重新抛出的消息写入的。如果我们决定避免以下之一消息写入时,我们应该想办法避免源进程的重复写入。否则,如果工作进程由于某种原因未能将消息传递给源进程,则整个消息将丢失。)状态共享会发生有时,在单进程模式下工作的C代码在并行模式下会失败。只要全局变量存在,就没有并行框架可以完全解决这个问题。没有通用机制来保证工作进程中的每个全局变量都与原始进程具有相同的值。即使我们可以保证这一点,只要我们调用一些函数来改变这些变量,那么只有发生这些变化的进程才会立即看到更新后的新值。我们使用的任何更复杂的数据结构都会出现类似的问题。例如,每次指定随机种子时,伪随机数生成器都应生成相同的可预测随机序列。这依赖于执行生成器的进程内部的私有状态,它本身不跨进程共享。因此并行安全伪随机机应该将其状态存储在动态共享内存中,并使用锁来保证其安全性。并行框架本身没有办法知道用户调用的代码是否存在这样的问题,也没有办法对其采取任何措施。相反,我们采用了更务实的策略。首先,我们试图让更多的操作在并行模式下像在单进程模式下一样正确地工作。其次,我们尝试通过错误检查来禁止一些常见的不安全操作。这些机制可以保证100%禁止SQL中的不安全行为,但C代码中的不安全行为可能不会触发这些检查。通过调用EnterParallelMode()函数启用这些检查。因此,在创建并行上下文时,我们应该调用此函数,并在调用ExitParallelMode()时删除这些检查。最后,最重要的限制是我们要求所有操作只有在只读时才使用并行模式,所有写操作和DDL都不会被并行化。也许将来我们可以减少这样的限制。为了让更多的操作能够以并行的方式安全的执行,我们会将很多重要的状态从启动进程复制到工作进程,包括:dfmgr.c动态加载的一系列动态库。经过身份验证的用户ID和当前数据库。每个工作进程将使用与原始进程相同的ID连接到相同的数据库。所有GUC值。在并行模式下禁止对GUC进行任何永久更改;但允许临时更改,例如使用非空proconfig进入函数。当前子事务的XID、最顶层事务的XID和当前XID列表(即正在进行或已提交的事务)。需要此信息以确保元组可见性检查在工作进程中返回与在原始进程中相同的结果。有关详细信息,请参阅下面的事务集成部分。CID映射。这也是为了确保一致的元组可见性检查。同步此数据结构的需要是我们无法支持并行模式写入的主要原因之一:因为写入可能会创建新的CID,而我们无法让其他工作进程知道它们。交易快照。活动快照,可能不同于事务快照。当前活动的用户ID和安全上下文。与阻止的REINDEX操作相关的状态。这会阻止访问正在重建的索引。活动的relmapper.c映射状态。这是为了保证映射得到的关系表oid对应的relfilenumber一致。为了防止在并行模式下运行时出现死锁,代码还为主进程和工作进程引入了组锁定。详情请参考src/backend/storage/lmgr/README。事务集成不管master进程中的TransactionState栈如何,每个parallelworker进程最终都会得到一个深度为1的事务状态栈,这个栈中唯一的入口被标记为特殊的事务状态TBLOCK_PARALLEL_INPROGRESS,这样就不会被与正常的顶级事务混淆。此TransactionState的XID将设置为原始进程的当前活动子事务的最内层XID。发起进程的顶级XID,以及任何当前(进行中或提交的)XID都与TransactionState堆栈分开存储,但GetTopTransactionId()、GetTopTransactionIdIfAny()和TransactionIdIsCurrentTransactionId()返回与发起进程相同的值调用时的过程。我们可以复制整个事务状态堆栈,但大部分状态都是无用的:例如,您不能从工作进程内回滚到保存点,并且中间子事务没有与内存上下文相关的资源或资源所有者。在并行模式下不能对事务状态进行有意义的更改。既不能分配XID,也不能启动或结束子事务,因为我们无法将这些状态更改传达或同步到其他协作进程。在所有工作进程退出之前,发起者进程退出任何正在进行的事务或子事务显然是不可行的;对于worker进程来说,试图提交子事务或者中止当前子事务并自行切换上下文来执行一些非当前发起进程正在处理的事务当然更不允许。允许内部子事务以并行模式执行(例如,实现PL/pgSQLEXCEPTION块)可能是可行的,只要它们不生成XID,因为其他进程实际上不需要知道这些事务的发生,并且不不需要对它们做任何事情。但是现在,我们选择直接禁用它们。在并行操作结束时,无论是成功提交还是被错误中断,与该操作关联的并行工作进程都会退出。如果出现错误,发起进程的终止事务模块会发出终止所有剩余工作进程的信号,然后等待它们退出。在并行操作成功的情况下,发起进程不发送任何信号,但必须等待工作进程完成并自行退出。无论哪种情况,发起进程都必须等待所有工作进程退出,然后才能清理创建的(子)事务;否则,可能会出现混乱。例如,如果发起进程正在回滚一个事务,该事务创建了一个正在由工作进程扫描的表,则该表可能会在工作进程扫描它时消失。这显然不安全。通常,此时每个工作进程执行的清理操作类似于提交或中止最顶层事务时发生的操作。每个进程都有自己的资源所有者:缓冲区引脚、catcache或relcache的引用计数、元组描述符等由每个进程独立管理,必须在退出前释放。但是,工作进程提交或中止事务与真正的最顶层事务的提交或中止之间仍然存在一些重要区别,包括:不会向系统写入提交或中止记录;原始进程将处理这个。pg_temp命名空间的清理不会发生。并行进程不能安全地访问原始进程的pg_temp命名空间,它们也不应该创建自己的副本。编码约定在开始任何并行操作之前,调用EnterParallelMode();所有并行操作完成后,调用ExitParallelMode()。尝试并行化任何特定运算符时使用ParallelContext。基本编码模式如下:EnterParallelMode();/*禁止不安全的状态改变*/pcxt=CreateParallelContext("library_name","function_name",nworkers);/*在这里为特定于应用程序的数据留出空间。*/shm_toc_estimate_chunk(&pcxt->estimator,size);shm_toc_estimate_keys(&pcxt->estimator,keys);初始化并行DSM(pcxt);/*创建DSM并将状态复制到它*//*存储我们为其保留空间的数据。*/space=shm_toc_allocate(pcxt->toc,size);shm_toc_insert(pcxt->toc,key,space);启动并行工作器(pcxt);/*做并行的事情*/WaitForParallelWorkersToFinish(pcxt);/*从动态共享内存中读取任何最终结果*/DestroyParallelContext(pcxt);退出并行模式();如果需要,在调用WaitForParallelWorkersToFinish()之后,可以重置上下文,以便可以使用相同的并行上下文重新启动新的工作进程。为此,我们需要先调用ReinitializeParallelDSM()来重新初始化由并行上下文机制本身管理的状态;然后重置任何需要的状态;之后,您可以再次调用LaunchParallelWorkers来调用新的工作进程。结束语PostgreSQL确实是一个非常复杂的系统。扰酱在Hashdata工作了半年,接触到的代码区还是PostgreSQL的一小部分;以至于在翻译这篇文章的时候,他对共享内存机制、锁机制和事务机制还有很多困惑,翻译的也不是很确定。希望好朋友多交流。