并行外部排序
并行 CREATE INDEX 修补程序
本节总结了与 CREATE INDEX 相关的并行外部排序。这是并行外部排序的初始提案,于 2016 年中期作为 PostgreSQL 10 开发周期的一部分发布。用于并行化 CREATE INDEX 的方法原则上可以被系统中执行元组排序的所有其他部分复制,但短期内不太可能提出,因为更复杂的架构也需要从系统中许多其他部分的并行性中获得最大收益。提出的补丁只添加了在更广泛范围内添加排序操作周围的并行性所需的一些部分(特别是,分区不是由补丁引入的)。CREATE INDEX 是并行排序的首个客户的另一个原因是,成本模型可以相对简单。虽然这些因素对于并行 CLUSTER 来说影响较小(CLUSTER 在某些方面与 CREATE INDEX 类似),但它似乎已经太 I/O 绑定了,以至于并行性无法提供太多帮助,因此在短期内不太可能被采用。[1]
初始并行 CREATE INDEX 修补程序的 CommitFest 条目:https://commitfest.postgresql.org/13/690/
补丁
- 为 tuplesort.c 添加了并行排序功能。
- 添加了此功能的新客户:btbuild()/nbtsort.c 现在可以并行创建 B 树。
补丁的大部分复杂性都与添加的架构相关,用于使临时文件可共享。并行排序原则上可供所有现有的 tuplesort 调用者使用,不受新扩展的 tuplesort.h 接口施加的任何限制。因此,例如,已经添加了 randomAccess MinimalTuple 支持(如前所述,目前不会使用此功能)。
该项目在 提高性能和可扩展性方面具有明确的目标。
内部:tuplesort.c 的新关键概念
堆被并行扫描,如果需要,工作进程也会并行合并。实现充分利用了现有的外部排序基础设施。事实上,该实现几乎是外部排序的概括,允许工作进程独立地执行堆扫描和运行排序,然后在领导者进程中“统一”磁带进行合并。此时,领导者持有的状态或多或少与领导者是一个按传统方式(串行)到达合并阶段的串行外部排序进程一致。
调用者必须采取的步骤在 tuplesort.h 中有完整描述(为了清晰简洁,这里跳过了一些现有的原型)。
/*
* Tuplesortstate and Sharedsort are opaque types whose details are not
* known outside tuplesort.c.
*/
typedef struct Tuplesortstate Tuplesortstate;
typedef struct Sharedsort Sharedsort;
/*
* Tuplesort parallel coordination state. Caller initializes everything.
* Serial sorts should pass NULL coordinate argument. See usage notes below.
*/
typedef struct SortCoordinateData
{
/* Worker process? If not, must be leader. */
bool isWorker;
/*
* Leader-process-passed number of workers known launched (workers set this
* to -1). This typically includes the leader-as-worker process.
*/
int nLaunched;
/* Private opaque state in shared memory */
Sharedsort *sharedsort;
} SortCoordinateData;
typedef struct SortCoordinateData* SortCoordinate;
/*
* (...Existing tuplesort.h comments for serial case...)
*
* Parallel sort callers are required to coordinate multiple tuplesort
* states in a leader process, and one or more worker processes. The
* leader process must launch workers, and have each perform an independent
* "partial" tuplesort, typically fed by the parallel heap interface. The
* leader later produces the final output (internally, it merges runs output
* by workers).
*
* Note that callers may use the leader process to sort runs, as if it was
* an independent worker process (prior to the process performing a leader
* sort to produce the final sorted output). Doing so only requires a
* second "partial" tuplesort within the leader process, initialized like
* any worker process.
*
* Callers must do the following to perform a sort in parallel using
* multiple worker processes:
*
* 1. Request tuplesort-private shared memory for n workers. Use
* tuplesort_estimate_shared() to get the required size.
* 2. Have leader process initialize allocated shared memory using
* tuplesort_initialize_shared(). This assigns a unique identifier for
* the sort. See BufFileGetHandle() for notes on resource management and
* the shared memory segment that is passed through from this point.
* 3. Initialize a "coordinate" argument (serial case just passes NULL
* here), within both the leader process, and for each worker process.
* Note that this has a pointer to the shared tuplesort-private
* structure.
* 4. Begin a tuplesort using some appropriate tuplesort_begin* routine,
* passing a "coordinate" argument, within each worker. The workMem
* argument need not be identical. All other arguments to the
* routine should be identical across workers and the leader.
* The workMem argument should be at least 64 (64KB) in all cases.
* 5. Feed tuples to each worker, and call tuplesort_performsort() for each
* when input is exhausted.
* 6. Optionally, workers may aggregate information/statistics about the
* heap scan someplace; caller must handle all those details. Then, call
* tuplesort_end() in each worker process (but not for any leader-as-worker
* Tuplesortstate). Worker processes can generally shut down as soon as
* underlying temp file state is handed over to the leader.
* 7. Begin a tuplesort in the leader using the same tuplesort_begin* routine,
* passing a leader-appropriate "coordinate" argument. The leader must now
* wait for workers to finish; have the leader process wait for workers by
* calling tuplesort_leader_wait(). tuplesort_leader_wait() waits until
* workers finish, and no longer. Note that the leader requires the
* number of workers actually launched now, so this need only happen after
* caller has established that number (after step 4). If there was a
* leader-as-worker Tuplesortstate, call tuplesort_end() with it now.
* 8. Call tuplesort_performsort() in leader. When this returns, sorting
* has completed, or leader will do final on-the-fly merge. Consume
* output using the appropriate tuplesort_get* routine as required.
* 9. Leader caller may now optionally combine any data that may have been
* aggregated by workers in step 6. (e.g., for custom instrumentation.)
* 10. Call tuplesort_end() in leader.
*
* This division of labor assumes nothing about how input tuples are produced,
* but does require that caller combine the state of multiple tuplesorts for
* any purpose other than producing the final output. For example, callers
* must consider that tuplesort_get_stats() reports on only one worker's role
* in a sort (or the leader's role), and not statistics for the sort as a
* whole.
*
* Note that there is an assumption that temp_tablespaces GUC matches across
* processes. Typically, this happens automatically because caller uses
* parallel infrastructure. Note also that only a very small amount of
* memory will be allocated prior to the leader state first consuming input,
* and that workers will free the vast majority of their memory upon
* reaching a quiescent state. Callers can rely on this to arrange for
* memory to be consumed in a way that respects a workMem-style budget
* across an entire sort operation, and not just within one backend.
*
* Callers are also responsible for parallel safety in general. However, they
* can at least rely on there being no parallel safety hazards within
* tuplesort, because tuplesort conceptualizes the sort as several independent
* sorts whose results are combined. Since, in general, the behavior of sort
* operators is immutable, caller need only worry about the parallel safety of
* whatever the process is through which input tuples are generated
* (typically, caller uses a parallel heap scan). Furthermore, note that
* callers must be careful in providing a perfectly consistent tuple
* descriptor across processes. This can be more subtle than it appears,
* since for example the RECORD pseudo-type uses transient typmods that are
* only meaningful within a single backend (see tqueue infrastructure to
* support transient record types). For the cluster, index_btree and
* index_hash APIs, callers automatically avoid problems by opening up the
* target relation from within worker processes, since the relation's
* cataloged attributes are necessarily not of transient types.
*/
extern Tuplesortstate *tuplesort_begin_index_btree(Relation heapRel,
Relation indexRel,
bool enforceUnique,
int workMem, SortCoordinate coordinate,
bool randomAccess);
/* ... */
extern Size tuplesort_estimate_shared(int nworkers);
extern void tuplesort_initialize_shared(Sharedsort *shared, int nWorkers,
dsm_segment *seg);
extern void tuplesort_leader_wait(Tuplesortstate *state);
总体思路是,Tuplesortstate 意识到它可能不是一个自包含排序;它可能只是并行排序操作的一部分。可以说,tuplesort 调用者必须从参与者工作进程的 Tuplesortstate “构建自己的排序”。调用者为每个并行排序操作创建一个动态共享内存段 + TOC(当然,可能不止一个并发排序操作),将其传递给 tuplesort 进行初始化和管理,并在私有内存中创建一个“领导者”Tuplesortstate,以及一个或多个“工作者”Tuplesortstate,每个工作者都可能由不同的并行工作进程管理。
tuplesort.c 协调工作进程并管理相互依赖关系,包括让进程相互等待以尊重其排序依赖关系。调用者(nbtsort.c)负责生成工作进程以完成工作,通过共享内存向 tuplesort 报告工作进程的详细信息,并让工作进程调用 tuplesort 来实际执行排序。在最低级别,资源管理外包给 buffile.c(以及道德上归属于 buffile.c 的 fd.c 例程)。tuplesort_estimate_shared() 例程请求它直接使用的共享内存来跟踪共享磁带状态,以及 buffile.c 为引用计数和其他固定工作进程状态所需的某些共享内存。
调用者通过领导者进程中的领导者 Tuplesortstate 消费最终输出。实际上,调用者正在获取最终的即时合并,因为最终输出被消费并且索引被物理写入。
在 tuplesort 工作进程之间共享临时文件
排序操作有一个唯一的标识符,在启动任何工作进程之前生成,使用基于领导者 PID 的方案和一个唯一的临时文件编号。工作进程临时文件名以确定性方式生成。这使得领导者进程可以发现所有磁盘上的状态(由 buffile.c 代表 logtape.c 管理的临时文件)。共享内存中的状态大小与工作进程数量成比例,因此围绕数据排序的唯一传递到共享内存中的东西是一些 logtape.c 磁带元数据,例如描述每个组成 BufFile 的大小(与特定工作进程的磁带集关联的 BufFile)。每个排序的 BufFile 也有一些状态,每个工作进程有一个条目,用于维护引用计数。
buffile.c 和 BufFile 统一
已经添加了大量新的架构,使 logtape.c 能够“统一”工作进程磁带(对 logtape.c 本身的更改很少)。buffile.c 已经了解统一作为抽象的一部分,某些细节的低级管理发生在 fd.c 临时文件例程中。请注意,在实践中,这些 fd.c 例程仅由 buffile.c 调用,这使得 buffile.c 成为 postgres 中临时文件的唯一合理接口(临时文件是指由资源管理器自动删除的文件,受 temp_file_limit 限制等)。此架构是补丁的关键部分。显然,它是补丁中最复杂的来源,因为我们必须考虑
- 为其他可能的 BufFile 用户创建一个相当通用且可重用的抽象。将来,tuplestore.c 可能能够使用统一 BufFile 的功能。并且,已经计划让并行哈希连接补丁使用相同的机制,该机制支持多个后端同时打开彼此的 BufFile(虽然只是在关键的屏障点;一旦发生这种情况,临时文件将被视为不可变,因此即使在那里,该机制也是一种单向的、一次性的 IPC 方法)。
- 资源管理。fd.c + 资源管理器代码在事务结束时或当工作进程需要消失时关闭相关的临时文件,而不一定在同一时间删除文件。从 V8 开始,需要删除每个 BufFiles 段文件的后端是根据在 buffile.c 中实现的引用计数机制确定的。在此方案下,工作进程可以在领导者的最终即时合并开始后立即消失,实际上已将它们临时文件段(BufFile)的所有权移交给领导者。领导者在整个排序操作结束时删除这些 fd.c 段,因为由于操作的排序方式,并行排序始终让领导者负责“关灯”。
- 崩溃安全性(例如,何时截断现有临时文件,何时不截断)。另请参见上面关于 RemovePgTempFiles() 的现有主分支说明,了解有关冲突的信息。
补丁的 V9 中描述了新的 buffile.c 接口,如下所示
/*
* Returns unified BufFile, from multiple worker process files. All
* underlying BufFiles must be temp BufFiles, and should have been
* created with BufFileCreateUnifiable(), and therefore discoverable
* with the minimal metadata passed by caller.
*
* This should be called when workers have flushed out temp file buffers and
* yielded control to caller's process. Workers should hold open their
* BufFiles at least until the caller's process is able to call here and
* assume ownership of BufFile. The general pattern is that workers make
* available data from their temp files to one nominated process; there is
* no support for workers that want to read back data from their original
* BufFiles following writes performed by the caller, or any other
* synchronization beyond what is implied by caller contract. All
* communication occurs in one direction. All output is made available to
* caller's process exactly once by workers, following call made here at the
* tail end of processing.
*
* Callers with more advanced requirements should consider an IPC mechanism
* that is optimized for fine-grained parallel access. Using a unified
* BufFile makes sense when there is an access pattern characterized by
* access to original data in physical order within workers (access in
* undefined order, such as from a parallel heap scan), without any immediate
* need for logical combination of the data across worker boundaries. There
* will perhaps be some relatively limited logical combination within the
* caller's process, input by reading from unified BufFile in mostly
* sequential order, but sensible use of this interface is limited to use in
* support of parallel batch processing that isn't naturally pipelinable. In
* cases where this interface is appropriate, caller might well have used
* temp files for a similar serial case due to needing to process a
* significant volume of data. Taking advantage of I/O parallelism should be
* almost as important to caller as taking advantage of available CPU
* resources; the high level batch processing task led by caller should not
* be performed with the expectation of workers being able to eliminate their
* input from consideration early.
*
* npieces is number of BufFiles involved; one input BufFile per worker, plus
* 1 for the caller's space, which should always come last. npieces is the
* size of the pieces argument array. Currently, interface assumes that
* there is a contiguous range of worker whose BufFiles are numbered 0
* through to npieces - 1, followed by zero-sized entry for space reserved
* for caller process to write to. Callers must be certain that all worker
* BufFiles already exist. (Caller-owned entry will not exist yet,
* obviously.)
*
* Caller's pieces argument should be set to the size of each
* discoverable/input BufFile when passed here. The size value should
* ultimately originate from a BufFileUnifiableHandover() call in each worker,
* which must have been made by worker immediately prior to worker yielding
* control to caller, and only after worker is done with all writing. For
* the caller's space at the end of returned BufFile, this value should
* always be 0. Writing in space reserved for the caller is a convention
* that provides clear separation of responsibilities between workers and the
* caller's unified BufFile space. A writable space in unified BufFiles is
* only supported because callers find this more convenient than opening a
* separate, conventional (non-unified) BufFile to write to.
*
* The pieces argument also has a field that is written to as output to
* caller; each piece's offsetBlockNumber field is set here. Caller
* should go on to apply these offsets for each input BufFile piece when
* seeking within unified BufFile, typically while using the
* BufFileSeekBlock() interface with metadata that originates from
* workers. This is necessary iff original block offsets are in terms
* of the worker's original BufFile space, for example due to
* originating from metadata read from the unified BufFile itself, the
* typical case. Caller needs to consider which part of the unified
* BufFile space is being accessed to determine the correct offset to
* apply. The ownership of one particular worker's data should be naturally
* delineated by distinct state built and managed by caller for each input
* from each worker; caller's own abstraction should manage that complexity
* (we don't do any of that for them because it's impractical for us to take
* an interest in every possible scheme callers might have for serializing
* data, but callers should sharply limit the places that need to know
* anything about applying these offsets).
*
* If callers are prepared to deal with offsets in a clean way, and are
* prepared to have workers lose the ability to read from their temp files
* following ending their quiescent state, they may write even to
* worker-owned space. temp_file_limit enforcement will start to consider
* blocks as owned by caller process iff caller does this, but does not count
* worker-owned files against that limit. This is allowed only so that our
* caller can reuse temp file space on disk. It is not intended as a mechanism
* for sending data back to workers.
*/
BufFile *
BufFileUnify(SharedBF *handle, int npieces, BufFilePiece *pieces)
{
/* (Unification of worker tapes occurs here) */
}
并行进程的内存使用和 Amdahl 定律
每个工作进程接收工作内存预算的平均份额(实际上,对于 CREATE INDEX 来说,它始终是 maintenance_work_mem -- 这就是传递给 tuplesort.c 作为所有 B 树 CREATE INDEX 情况的“workMem”参数的内容)。
新实现重新使用了最初为外部排序设计的许多内容。因此,并行排序必然是外部排序,即使 workMem 预算原则上允许并行排序完全在内存中进行。该实现可以说坚持将这些情况作为外部排序,即使它们并不真正需要是外部排序。
由于并行排序仅对中等至大型排序有吸引力,因此有意义的是优先考虑能够以并行方式执行外部排序操作。更广泛地使用共享内存(并行内部排序)可以在以后的版本中实现。
让每个并行 CREATE INDEX 使用外部临时文件(“逻辑磁带集”管理这些临时文件)比人们想象的要容易得多,因为 9.6 版本关于外部排序的工作[2] [3] 有点模糊了内部排序和外部排序之间的区别(想想 trace_sort 指示的工作进程花费在等待写入上的时间有多少;它通常只是花费的总时间的很小一部分)。此外,Heikki Linnakangas 最近关于在 tuplesort.c 中为外部排序预加载的工作,该工作已经提交到主分支[4] 使得这个问题变得更加容易。并且,Peter Geoghegan 最近关于合并堆的工作[5] 也提供了很大的帮助,尤其是在合并的输入在某种程度上是聚簇的时候。
maintenance_work_mem
工作进程(包括 leader-as-worker 进程)有机会在 leader 合并有机会分配大量内存之前释放内存。因此,**maintenance_work_mem 被视为整个 CREATE INDEX 操作的预算**。与并行查询使用 work_mem 不同,启动的工作进程数量不会显着影响使用的内存量(每个额外启动的工作进程只有微不足道的固定开销)。但是,maintenance_work_mem 设置将通过对每个启动的工作进程至少有 32MB 的 maintenance_work_mem 的后备要求来影响新成本模型中指示为最佳的工作进程数量(32MB 后备是补丁 V9 中的新行为)。
所有这些都运行得很好,因为串行合并步骤可以单独访问整个预算,并且通常比工作进程(运行的快速排序)能够更有效地使用该预算。用户可以增加 maintenance_work_mem 并且在看到等效串行情况下可以观察到的点之后继续看到好处。
补丁的性能和可扩展性目标
该项目的**主要目标**是向 PostgreSQL 添加一个并行 CREATE INDEX 功能,该功能具有**与其他主要 RDBMS 中的等效功能相当的性能和可扩展性**。
Peter Geoghegan 在 2016 年 10 月评估了 V4 的可扩展性 [6]。该实现似乎相当一致地管理了与等效串行情况相比约 3 倍的改进,如整个 CREATE INDEX 操作的总挂钟时间所衡量(使用 8 个核心/工作进程,以及 12 个以 RAID0 配置的 HDD)。请注意,这是基于未修补的 PostgreSQL 10 git 尖端作为基准线——如果 PostgreSQL 9.6 被视为基准线,则改进将更加明显。
**该补丁已经达到了其声明的主要目标,至少根据非正式比较**(轶事报告表明其他主要系统中的可扩展性已经达到约 3 倍,即使“并行度”等于 8 或 16)。这个结论后来得到了 Thomas Munro 在 2017 年 2 月进行的一项基准测试的佐证 [7]。
gensort 工具 已被采用多年来 生成 sortbenchmark.org 竞赛的测试数据,并已被采用以将任意数量的数据批量输入 Postgres 表中
https://github.com/petergeoghegan/gensort
该工具有其局限性,但至少为测试排序优化提供了一个完全确定性的测试用例。为并行 CREATE INDEX 设计可验证的测试用例是性能验证的重要组成部分。需要进一步研究的一个领域是并行 CREATE INDEX 在其输入表非常大(超过 1TB)时的性能。
总结
总之,CREATE INDEX 执行的“串行部分”近年来得到了显著优化,因此阿姆达尔定律并没有不合理地限制并行 CREATE INDEX 的可扩展性。
合并
合并运行的模型是,每个工作进程都保证将一个物化运行输出到一个磁带上,以便 leader 从其“统一”磁带集中进行合并。无论工作Mem可用量是多少,或者其他任何因素,情况都是如此。leader 始终假设工作进程运行/磁带是存在的,并且仅基于已知启动的工作进程数量以及通过共享内存传递的每个工作进程的一点元数据就可以发现。
从工作进程中的所有输入元组生成一个输出运行/物化磁带有时可以在工作进程不耗尽工作Mem(其维护_work_mem 的总量的一部分)的情况下发生。会发生直接的快速排序和所有元组的转储,而无需在工作进程中进行任何合并。不过,更常见的情况是,将需要在每个工作进程中进行一定量的合并才能生成一个物化输出运行。工作进程合并的处理方式与需要单个物化输出磁带以支持后续随机访问的随机访问情况几乎相同(如果并行元组排序实际上没有进行随机访问,则不会写入每个元组的大小)。工作进程执行的合并确实需要对工作进程的所有临时文件进行另一次传递,但这对于大型排序来说通常是一个相对较小的成本。
并行合并有限制
目前,合并工作进程输出运行只能在 leader 进程中发生。换句话说,我们始终让 n 个工作进程忙于扫描和排序(可能还有一些合并),但随后除了 leader 进程之外的所有进程都停止了。
一个 leader 进程忙于动态合并这些 n 个输出运行,并写入新索引(这包括 WAL 日志记录新索引)。工作进程有时会并行合并,但仅合并他们自己的运行——绝不会合并另一个工作进程的运行。
并行合并(超出工作进程分别生成一个用于 leader 串行合并的最终物化磁带所需的合并)是将来可以改进的领域之一。但是,分区似乎更有希望,尤其是对于并行查询(并行 CREATE INDEX 似乎在 leader 合并期间创建新索引的 I/O 上受到高度瓶颈)。
CREATE INDEX 用户界面与并行
有两种方法可以确定并行 CREATE INDEX 请求多少个并行工作进程——一种新的成本模型和一个新的存储参数。如果实现无法启动任何并行工作进程,则会执行传统的串行排序(串行排序可以在内部执行,也可以不执行)。
成本模型
添加了一个成本模型,该模型目前大致基于 create_plain_partial_paths()
**更新:**V9 大大简化了成本模型,并且在工作进程如何扩展方面,使其工作方式与并行顺序扫描几乎相同。无需考虑最终索引的大小。 [8]
工作进程以新索引的预计大小的对数间隔添加(从 V5 开始) [9]。间隔是当前 GUC min_parallel_relation_size 值的函数(此 GUC 在 9.6 中添加)。新的 GUC max_parallel_workers_maintenance 将始终在设置为 0 时禁用并行性的使用。(max_parallel_workers_maintenance 旨在作为 max_parallel_workers_per_gather 的“维护版”。当然,新的 postgres-10 max_parallel_workers GUC 也被并行 CREATE INDEX 尊重。
成本模型讨论的主要线程,始于 Postgres 10 的最终 CF:https://postgresql.ac.cn/message-id/flat/CAH2-WzmjVMCUviDnUmrJnvhfPpzODtCM71NEHx7_QYCtz+=8ng@mail.gmail.com#CAH2-WzmjVMCUviDnUmrJnvhfPpzODtCM71NEHx7_QYCtz+=8ng@mail.gmail.com
pageinspect 中的 bt_estimated_nblocks() 函数
**更新:**V9 根本不使用对最终索引大小的任何估计。 [10]
V5 还添加了一个临时测试工具,用于显示新成本模型/优化器预计最终索引的大小将是多少
mgd=# analyze; ANALYZE mgd=# select oid::regclass as rel, bt_estimated_nblocks(oid), relpages, to_char(bt_estimated_nblocks(oid)::numeric / relpages, 'FM990.990') as estimate_actual from pg_class where relkind = 'i' order by relpages desc limit 20; rel │ bt_estimated_nblocks │ relpages │ estimate_actual ────────────────────────────────────────────────────┼──────────────────────┼──────────┼───────────────── mgd.acc_accession_idx_accid │ 107,091 │ 106,274 │ 1.008 mgd.acc_accession_0 │ 169,024 │ 106,274 │ 1.590 mgd.acc_accession_1 │ 169,024 │ 80,382 │ 2.103 mgd.acc_accession_idx_prefixpart │ 76,661 │ 80,382 │ 0.954 mgd.acc_accession_idx_mgitype_key │ 76,661 │ 76,928 │ 0.997 mgd.acc_accession_idx_clustered │ 76,661 │ 76,928 │ 0.997 mgd.acc_accession_idx_createdby_key │ 76,661 │ 76,928 │ 0.997 mgd.acc_accession_idx_numericpart │ 76,661 │ 76,928 │ 0.997 mgd.acc_accession_idx_logicaldb_key │ 76,661 │ 76,928 │ 0.997 mgd.acc_accession_idx_modifiedby_key │ 76,661 │ 76,928 │ 0.997 mgd.acc_accession_pkey │ 76,661 │ 76,928 │ 0.997 mgd.mgi_relationship_property_idx_propertyname_key │ 74,197 │ 74,462 │ 0.996 mgd.mgi_relationship_property_idx_modifiedby_key │ 74,197 │ 74,462 │ 0.996 mgd.mgi_relationship_property_pkey │ 74,197 │ 74,462 │ 0.996 mgd.mgi_relationship_property_idx_clustered │ 74,197 │ 74,462 │ 0.996 mgd.mgi_relationship_property_idx_createdby_key │ 74,197 │ 74,462 │ 0.996 mgd.seq_sequence_idx_clustered │ 50,051 │ 50,486 │ 0.991 mgd.seq_sequence_raw_pkey │ 35,826 │ 35,952 │ 0.996 mgd.seq_sequence_raw_idx_modifiedby_key │ 35,826 │ 35,952 │ 0.996 mgd.seq_source_assoc_idx_clustered │ 35,822 │ 35,952 │ 0.996 (20 rows)
这表明该工具如何在新鲜的 REINDEX 之后非常准确地估计真实世界索引的大小。(源数据是 鼠标基因组样本数据库)。
pg_restore
成本模型在 pg_restore 期间往往不一致地使用并行 CREATE INDEX,因为 pg_class.reltuples 在调用它时可能设置,也可能没有设置。表中缺少 pg_statistic 条目是一个相关的但不同的问题。目前,这会使并行性用于或不使用单个 CREATE INDEX 语句或多或少地不可预测地发生 [11]。
**更新:**补丁的版本 7 添加了一个新的 GUC,enable_parallelddl,pg_restore 在运行时将其设置为“关闭”。这意味着 pg_restore 仍然尊重 parallel_workers 存储参数(如果已设置),但否则不使用并行 CREATE INDEX。(基于 pg_restore 将 GUC max_parallel_workers_maintenance 设置为 0 的方法是不可行的,因为这样做将在所有情况下禁用并行 CREATE INDEX,这被认为是对 pg_restore 的错误操作。这就是为什么在 V7 中添加了新的 enable_parallelddl GUC)。
作为对新的 V7 pg_restore 行为的替代方案,可能值得考虑让 CREATE INDEX 在没有任何预先存在的可用统计数据的情况下执行某种分析 [12]。
**更新:**补丁的 V9 在 pg_restore 中没有任何特殊操作。它也只引入了一个新的 GUC,没有新的存储参数。
parallel_workers 索引存储参数
**更新:**V9 没有添加新的索引存储参数。但是,与任何并行堆扫描一样,会考虑现有的 parallel_workers 堆存储参数。
添加了一个 parallel_workers 存储参数,它完全绕过成本模型。这是“DBA 最了解”的方法。
如前所述,新的 max_parallel_workers_maintenance 会限制请求的并行工作进程数量,无论是由成本模型还是 parallel_workers 存储参数确定请求的数量。示例
-- 补丁有 8 个元组排序“排序和扫描”工作进程: CREATE INDEX patch_8_idx ON parallel_sort_test (randint) WITH (parallel_workers = 7); -- REINDEX,但强制执行串行元组排序(不更改 max_parallel_workers_maintenance,存储参数将指示使用 7 个工作进程): SET max_parallel_workers_maintenance = 0; REINDEX INDEX patch_8_idx; -- 清理: RESET max_parallel_workers_maintenance; -- 串行情况(可能是内部排序): CREATE INDEX serial_idx ON parallel_sort_test (randint) WITH (parallel_workers = 0); -- 让成本模型决定要使用的并行工作进程数量(最终可能成为串行排序,这可能是内部排序): CREATE INDEX cost_model_idx ON parallel_sort_test;
虽然为存储参数(例如 fillfactor)提供“索引版本”有明确的先例,但关于新存储参数的具体含义,可能还会存在其他问题。例如,最终可能会有多种方法,系统不同部分会在不同上下文中关心索引存储参数(例如,并行索引扫描可能会重用存储参数)。这种矛盾至少被考虑过一次 [13],这可能是可以的 [14]。
注意,可以启用 trace_sort 来详细了解并行是如何使用的。请注意,当成功启动 7 个工作进程时,会有 8 个“参与者”元组排序——领导者一个,剩下的每个启动的工作进程一个。
2 个参与者进程(1 个工作进程 + 1 个领导者)的示例,每个进程在领导者的最终动态合并之前执行自己的合并
postgres=# set trace_sort = on; SET postgres=# set client_min_messages = 'log'; SET postgres=# create index on land_registry_price_paid_uk (price) with (parallel_workers=1); LOG: begin index sort: unique = f, workMem = 1048576, randomAccess = f LOG: numeric_abbrev: cardinality 1515.963752 after 10240 values (10240 rows) LOG: numeric_abbrev: cardinality 1515.963752 after 10240 values (10240 rows) LOG: numeric_abbrev: cardinality 2178.509579 after 20480 values (20480 rows) LOG: numeric_abbrev: cardinality 2161.858806 after 20480 values (20480 rows) LOG: numeric_abbrev: cardinality 3075.797830 after 40960 values (40960 rows) LOG: numeric_abbrev: cardinality 3030.798768 after 40960 values (40960 rows) LOG: numeric_abbrev: cardinality 4424.075663 after 81920 values (81920 rows) LOG: numeric_abbrev: cardinality 4455.486412 after 81920 values (81920 rows) LOG: numeric_abbrev: cardinality 6747.487758 after 163840 values (163840 rows) LOG: numeric_abbrev: cardinality 6504.966941 after 163840 values (163840 rows) LOG: numeric_abbrev: cardinality 9830.136484 after 327680 values (327680 rows) LOG: numeric_abbrev: cardinality 9978.823948 after 327680 values (327680 rows) LOG: numeric_abbrev: cardinality 14904.604827 after 655360 values (655360 rows) LOG: numeric_abbrev: cardinality 14537.378637 after 655360 values (655360 rows) LOG: numeric_abbrev: cardinality 24083.776053 after 1310720 values (1310720 rows) LOG: numeric_abbrev: cardinality 25524.289758 after 1310720 values (1310720 rows) LOG: numeric_abbrev: cardinality 44701.701832 after 2621440 values (2621440 rows) LOG: numeric_abbrev: cardinality 43976.514975 after 2621440 values (2621440 rows) LOG: numeric_abbrev: cardinality 61521.386147 after 5242880 values (5242880 rows) LOG: numeric_abbrev: cardinality 62482.797227 after 5242880 values (5242880 rows) LOG: starting quicksort of run 0/1: CPU: user: 4.13 s, system: 0.39 s, elapsed: 4.55 s LOG: starting quicksort of run 1/1: CPU: user: 4.06 s, system: 0.47 s, elapsed: 4.57 s LOG: finished quicksort of run 1/1: CPU: user: 32.79 s, system: 0.48 s, elapsed: 33.40 s LOG: finished quicksort of run 0/1: CPU: user: 33.14 s, system: 0.39 s, elapsed: 33.58 s LOG: finished writing run 0/1 to tape 0: CPU: user: 35.52 s, system: 0.93 s, elapsed: 36.51 s LOG: finished writing run 1/1 to tape 0: CPU: user: 35.12 s, system: 1.10 s, elapsed: 36.35 s LOG: performsort of 0 starting: CPU: user: 36.68 s, system: 1.04 s, elapsed: 38.26 s LOG: performsort of 1 starting: CPU: user: 36.38 s, system: 1.24 s, elapsed: 38.24 s LOG: starting quicksort of run 0/2: CPU: user: 36.68 s, system: 1.04 s, elapsed: 38.26 s LOG: starting quicksort of run 1/2: CPU: user: 36.38 s, system: 1.24 s, elapsed: 38.24 s LOG: finished quicksort of run 0/2: CPU: user: 42.91 s, system: 1.04 s, elapsed: 44.50 s LOG: finished writing run 0/2 to tape 1: CPU: user: 43.42 s, system: 1.17 s, elapsed: 45.14 s LOG: 0 using 524213 KB of memory for read buffers among 2 input tapes LOG: finished quicksort of run 1/2: CPU: user: 43.60 s, system: 1.24 s, elapsed: 45.48 s LOG: finished writing run 1/2 to tape 1: CPU: user: 44.18 s, system: 1.36 s, elapsed: 46.17 s LOG: 1 using 524213 KB of memory for read buffers among 2 input tapes LOG: 0 finished 2-way merge step: CPU: user: 46.40 s, system: 1.73 s, elapsed: 48.67 s LOG: performsort of 0 done: CPU: user: 46.41 s, system: 1.85 s, elapsed: 48.81 s LOG: 1 finished 2-way merge step: CPU: user: 47.09 s, system: 1.89 s, elapsed: 49.60 s LOG: performsort of 1 done: CPU: user: 47.10 s, system: 2.02 s, elapsed: 49.75 s LOG: performsort of -1 starting: CPU: user: 46.42 s, system: 1.85 s, elapsed: 49.77 s LOG: leader using 1048500 KB of memory for read buffers among 2 worker tapes LOG: performsort done (except 2-way final merge): CPU: user: 46.45 s, system: 2.09 s, elapsed: 50.04 s LOG: parallel external sort ended, 52438 disk blocks used: CPU: user: 53.11 s, system: 3.39 s, elapsed: 58.76 s CREATE INDEX
注意,“finished quicksort of run 0/2” 这样的输出表示参与者进程 0 的第二次运行已完成其内存中的排序步骤。“参与者进程”不同于工作进程,因为它可能是领导者进程——这个 CREATE INDEX 有 2 个元组排序方式的工作进程/参与者,只有一个实际的工作进程。请注意,这里 0 和 1 的序数标识符是按未定义的顺序分配的——没有简单的方法来确定这两个进程中哪一个是实际的领导者进程,因为这真的无关紧要。并行 CREATE INDEX 不会重用执行器方式的工作进程编号。
不过,对于像 min_parallel_relation_size 这样的东西,没有新的维护方式变体 GUC。只添加了这一个新的 GUC(加上新的索引存储参数 parallel_workers).
用于并行的分区(超越 CREATE INDEX 的并行外部排序)
虽然在最初对并行化排序密集型操作进行广泛支持似乎并不重要,但至少对一个世界进行规划是有意义的,在这个世界中,并行排序被普遍使用,尤其是在执行器中。分区似乎是目前实现这一目标最可能的方式。为并行 CREATE INDEX 添加的并行外部排序基础设施最终应该能够扩展以支持执行器进行的并行外部排序。
预期要求
分区是指将工作进程运行的切片跨工作进程重新分配到预定分区边界,在专用工作进程中排序一定范围的值,然后连接以获得最终结果,有点像内存中的 样本排序 算法。这种方法不太适合 CREATE INDEX,因为这种方法的优势在于工作进程可以在整个持续时间内并行运行,因为没有合并瓶颈(假设分区边界良好,这是一个有点冒险的假设)。并行 CREATE INDEX 需要一种工作进程可以独立写入索引,并独立创建一组统一的内部页面的方法,这很困难。对于日志记录表上的索引,WAL 日志记录也必须并行执行,这可能使分区在与串行合并方法相比毫无价值。
这个并行 CREATE INDEX 补丁将倾向于按与其他主要数据库系统相当的级别来按比例加快 CREATE INDEX 语句。分区排序对于查询执行比对于 CREATE INDEX 这样的实用程序语句更有用;通常可以下推更多内容。
分区和合并连接
Robert Haas 经常推测让合并连接在并行环境中良好运行需要什么。“范围分布”/桶化可能是其中重要的组成部分。在共享内存中最初聚合元组,让工作进程排序而没有任何串行合并瓶颈,这太有用了;关于误估计、数据倾斜等的论点不应该长期阻止我们这样做。这种方法的 IPC 开销最小,尤其是在 LWLock 争用方面。
不过,这种重新分配可能属于一个 Gather 类节点,该节点可以访问确定范围所需的上下文,甚至可以在发生误估计的情况下动态更改范围。在这种方案下,tuplesort.c 只需要被告知这些工作进程私有 Tuplesortstates 是范围分区的(即,就它而言,排序实际上是独立的)。这有点乱,但这仍然可能是合并连接和其他依赖排序的执行器节点的最佳选择。
关于分区设计的粗略说明
Peter Geoghegan 在发送给 pgsql-hackers 的一封电子邮件中详细说明了一种分区设计 [15]。这种设计将重用并行 CREATE INDEX 提议的一部分基础设施,但这仍然需要做大量工作才能实现。