Greenplum много лет жил как форк PostgreSQL: собственная кодовая база, собственные каталоги, отставание от upstream на несколько релизов. После того как Broadcom купила VMware, репозитории проекта заархивировали, и код продолжил жизнь в форках — open-gpdb, WarehousePG от EDB и Apache Cloudberry. Cloudberry основан на Greenplum 7 и вошёл в Apache Incubator. Вопрос, который разбираем: что значит перенести такую систему в PostgreSQL 19 «как расширение» и какие механизмы для этого понадобились.
Почему форк обречён отставать
Greenplum всегда отставал от PostgreSQL на несколько релизов: Greenplum 6 вышел на PostgreSQL 9.4, Greenplum 7 — на 12. Apache Cloudberry, продолжающий проект Greenplum, довёл основную ветку до 16.9 только в мае 2026 года.
Причина в объёме изменений. Cloudberry изменил 882 исходных файла PostgreSQL и добавил 646 новых, не считая 1,66 млн строк оптимизатора ORCA. Каждый новый мажорный релиз PostgreSQL означает месяцы слияния, и к моменту завершения работы разработчиков upstream уже ушёл вперёд. Это замкнутый круг: чем больше расходятся ветки, тем дороже каждое следующее слияние.
Что значит «порт как расширение»
Формулировка «Cloudberry как расширение PostgreSQL 19» означает, что код перенесён в PostgreSQL 19 преимущественно как набор расширений, а не как форк ядра: более 90% кода базы данных портировано как расширения — 93%, или 97%, если исключить MPP-планировщикапланировщик, который строит единый план сразу для всех сегментов кластера.
В расширения вынесены, в частности, ORCA, append-optimizedформат хранения, при котором строки дописываются в конец и не обновляются на месте, PAX-таблицыформат хранения, при котором данные раскладываются по колонкам внутри строковых блоков, внешние таблицы, ресурсные группымеханизм ограничения ресурсов, которые сегмент выделяет разным группам запросов, инкрементальные materialized viewsматериализованные представления, обновляемые только по изменившимся данным, планировщик задач и FTSполнотекстовый поиск. ORCA переиспользуется как есть через обёртку в коде расширения, и его 380k строк не учитываются ни в одной из долей.
Патчами к ядру остаётся небольшая часть: для расширения новая мажорная версия означает редактирование 22 небольших патчей к ядру и связанного с ними кода расширения. В сравнении с другими расширениями у порта указано 22 патча к ядру.
Неперенесённым остаётся MPP-планировщик Cloudberry: его вариант планировщика PostgreSQL, Route Bвариант планировщика PostgreSQL, который строит единый план сразу для всех сегментов кластера, не портирован, поэтому всё, что отклоняет ORCA, идёт более медленным gather-маршрутоммаршрутом, при котором результат собирается на координаторе вместо распределённого выполнения. Именно его исключение объясняет разницу между 93% и 97%: 93% — доля кода, портированного как расширения, включая MPP-планировщик, 97% — если его не учитывать.
Куда переехали метаданные
У порта нет собственных shared catalogsобщих каталогов, которые видны всем базам кластера и хранят общесистемные объекты. Метаданные Greenplum перемещены в security labels и extension tables PostgreSQL. Атрибуты ролей хранятся в общих security labels, секреты — в maintenance database, топология — в cluster file. При этом gp_segment_configuration и связанные с ним объекты являются представлениями, а не собственными общими каталогами порта.
Распределённые снимки без патча ядра
Транзакции работают без патча снимка. Распределённые снимки казались невозможными без правок ядра, пока не изменился угол зрения: нужно согласовать снимок каждого сегмента со снимком координатора, а не наоборот.
Механика такая. Сегмент ждёт транзакцию, которая по распределённому снимку уже зафиксирована, но локально у него находится лишь в состоянии prepared, и скрывает транзакцию, которая локально зафиксирована, но глобально ещё выполняется. слот репликациимеханизм, удерживающий журнал WAL от очистки для нужд репликации не даёт VACUUM удалить строки, удалённые такими транзакциями. Собственная запись о фиксации координатора решает исход каждой транзакции, а транзакция, записанная только одним сегментом, фиксируется за одну фазу — это подняло односрочные INSERT с 371 до 950 транзакций в секунду.
Распределённый снимок доставляется как SET LOCAL. На каждом сегменте писательский процесс выполняет один срез, а читающие процессы принимают транзакцию писателя через новую функцию ядра XactAdoptTransactionState(), присоединяются к его группе блокировок и разрешают combo command IDидентификаторы, различающие несколько команд внутри одной транзакции, чтобы читатели видели строки писателя согласованно через хуки R2. Всё остальное — стандартный механизм параллельных воркеров PostgreSQL.
22 хука: где не хватало точек расширения
Существующих точек расширения PostgreSQL не хватило, поэтому добавлены 22 новых хука, сгруппированных по потребностям.
- Для одинаковых OID на всех узлах добавлен R1
new_oid_hook— у выделения OID нет хука. - Для одновременной работы всех срезов и видимости незакоммиченных строк писателя — R2 combo-CID hooks и R4
XactAdoptTransactionState(), потому что только собственные параллельные воркеры PostgreSQL разделяют транзакцию. - Для 2PC в порядке Cloudberry и блокировок таблиц без дедлоков апгрейда:
XACT_EVENT_COMMITсрабатывает, когда транзакция уже видима, а парсер выбирает режимы блокировок первым. - Для синтаксиса Cloudberry,
gp_segment_idи скрытия счётчиков matview вSELECT *: нет хука парсера, а системные колонки меняют каталоги. - Для распределённого ANALYZE и инкрементальных matview: выборка из heap жёстко зашита, режим обслуживания статичен.
- Для append-optimized и PAX-хранилища, diskquota и контрольных сумм:
TableAmRoutineне может расти без нарушения ABI, а у менеджера хранилища нет хуков. - Для tablespaces на узел, защиты памяти и fault-тестов: redo имеет встроенные фиксированные пути, а методы memory-context являются
static const.
Первый тест нового combo-CID хука уронил сервер — проверки оказались не лишними.
Планирование запросов: cdbllize и cdbpath
Как обычный план превращается в MPP-план
cdbllize_adjust_top_path принимает Path, полученный от subquery_planner(), и при необходимости добавляет Motion поверх него, а также корректирует *topslice.
Сначала определяется целевая политика распределения. Если query->resultRelation > 0, берётся RangeTblEntry по этому индексу и через GpPolicyFetch(rte->relid) извлекается политика целевой таблицы. Для CREATE TABLE AS / SELECT INTO политика берётся из query->intoPolicy; иначе при gp_create_table_random_default_distribution создаётся случайная политика; иначе сначала пробуется get_partitioned_policy_from_path(root, best_path), а при неудаче — хеш по первой подходящей колонке из best_path->pathtarget->exprs. Далее query->intoPolicy = targetPolicy, и для реплицированной политики без volatile-функций в targetList и havingQual создаётся replicatedLocus.
Решение о вставке Motion принимается по типу политики:
- для POLICYTYPE_PARTITIONEDтаблица распределена по ключу между сегментами при
CdbPathLocus_IsGeneralпросто меняется numsegments; приnattrs == 0и partitioned-входе Motion добавляется только еслиgp_force_random_redistributionили не совпадают numsegments; приCdbPathLocus_IsNullсоздаётся strewn-локус иmake_motion_path; иначеcdbpath_create_motion_path; - для POLICYTYPE_ENTRYтаблица существует только на координаторе результат возвращается на QD через
CdbPathLocus_MakeEntryиcdbpath_create_motion_path; - для POLICYTYPE_REPLICATEDтаблица целиком хранится на каждом сегменте при
optimizer_replicated_table_insertи отсутствии volatile-функций Motion не добавляется, если вход SegmentGeneral сnumsegments >= policy->numsegmentsили General; иначе создаётся broadcast motion.
Как строится таблица срезов
cdbllize_build_slice_table строит slice tableтаблицу срезов — частей распределённого плана, каждая из которых выполняется своим набором процессов. Она обнуляет shared_plans, создаёт массив subplan_sliceIds длиной list_length(root->glob->subplans) и заполняет его -1.
В режиме GP_ROLE_UTILITY создаётся один фиктивный срез с sliceIndex=0, parentIndex=0, gangType=GANGTYPE_UNALLOCATED, numsegments=0, segindex=-1, и управление возвращается. В режиме GP_ROLE_EXECUTE функция просто возвращается.
В режиме GP_ROLE_DISPATCH верхний срез получает sliceIndex=0 и parentIndex=-1, кладётся в cxt.slices, cxt.currentSliceIndex=0, после чего build_slice_table_walker обходит дерево плана. Нумерация ведётся через list_length(context->slices): для init-плана новый PlanSlice получает sliceIndex=list_length(context->slices), а для Motion — sendSlice->sliceIndex=list_length(context->slices), sendSlice->parentIndex=context->currentSliceIndex, motion->motionID=sendSlice->sliceIndex.
Неиспользуемые subplans определяются по битовой маске cxt.seen_subplans: при встрече SubPlan его plan_id добавляется в маску, а затем в цикле по root->glob->subplans с plan_id начиная с 1 проверяется отсутствие в маске — такой subplan заменяется на dummy_plan из make_result с makeBoolConst(false, false), чтобы не сломать нумерацию plan_id.
Если все срезы имеют gangType == GANGTYPE_UNALLOCATED, subplan_sliceIds сбрасываются в -1, а cxt.slices усекается до одного элемента через list_truncate(cxt.slices, 1); иначе numSlices=list_length(cxt.slices) и срезы копируются в массив root->glob->slices.
Redistribute, replicate или gather-to-SingleQE
Выбор в cdbpath_motion_for_join определяется последовательностью условий, а не единой формулой стоимости. Сначала сравниваются размеры отношений: если large_rel->bytes < small_rel->bytes, они меняются местами, так что large_rel — больший по bytes.
Затем проверяется, можно ли распределить меньшее отношение по ключу распределения большего: если cdbpath_match_preds_to_distkey для small_rel->move_to успешен, выбирается redistribute меньшего.
Если это не сработало, проверяется replicate меньшего при условии, что small_rel->ok_to_replicate и стоимость репликации меньше стоимости redistribute большего. Для не-parallel случая:
small_rel->bytes * CdbPathLocus_NumSegmentsPlusParallelWorkers(large_rel->locus) < large_rel->bytesдля parallel — с CdbPathLocus_NumSegments(large_rel->locus).
Далее аналогично проверяется replicate большего, если это дешевле, чем redistribute обоих: large_rel->bytes * CdbPathLocus_NumSegmentsPlusParallelWorkers(small_rel->locus) < small_rel->bytes + large_rel->bytes (или parallel-вариант).
Если ни одно из этих условий не выполнено и нет WorkTableScan на обоих отношениях, оба отношения перемещаются в SingleQE через CdbPathLocus_MakeSingleQE.
cdbpath_cost_motion вычисляет стоимость движения как cost_per_row * 0.5 * (sendrows + recvrows), где cost_per_row равен gp_motion_cost_per_row, если он положителен, иначе 2.0 * cpu_tuple_cost. При этом motioncost умножается на mot_parallel, если целевой локус replicated и есть параллельные воркеры.
Ограничения replicated и SegmentGeneral локусов
Для UPDATE/DELETE на replicated-таблице планировщик не может полагаться на то, что логическая строка имеет одинаковый ctid или item pointer в каждой копии. Если разослать совпавшие кортежи всем сегментам, сегменты могут обновить не те строки или не найти строку по ctid/item pointer. Поэтому при соединении SegmentGeneral с другим локусом другая сторона broadcast-ится, а её целевой локус принудительно делается replicated с числом сегментов, равным числу сегментов SegmentGeneral. Это гарантирует, что все целевые кортежи будут выбраны на всех копиях и обновлены локально.
Для самого UPDATE/DELETE на replicated-таблице отдельный motion не добавляется: ветка replicated-политики в create_motion_path_for_upddel пуста, но проверяется, что targetlist не содержит volatile-функций, иначе возникает ошибка. В create_split_update_path для replicated-таблицы также нет дополнительных действий.
Чётное число сегментов и SegmentGeneral меньше Replicated
Когда один из входов соединения имеет локус SegmentGeneral, проверяется условие: если репликация запрещена хотя бы для одного входа либо число сегментов у SegmentGeneral меньше, чем у Replicated, то оба входа направляются в SingleQE. Проверка выглядит так:
!replicated->ok_to_replicate ||
!other->ok_to_replicate ||
(CdbPathLocus_NumSegments(other->locus) < CdbPathLocus_NumSegments(replicated->locus))Если условие ложно, поток не перенаправляется, а сразу возвращается результат cdbpathlocus_join(jointype, replicated->locus, other->locus).
Для случая, когда другой вход имеет локус General, число сегментов не учитывается вовсе, и при запрете репликации оба входа также переводятся в SingleQE.
В параллельном варианте при SegmentGeneral с меньшим числом сегментов, чем у Partitioned, при несовпадении числа сегментов выполняется goto fail, а при !inner.ok_to_replicate и неудачной попытке перераспределения оба входа переводятся в SingleQE.
Исполнение: Motion и interconnect
Конец потока в nodeMotion
В nodeMotion конец потока наступает, когда все отправители вернули EOS. В sorted-режиме это распознаётся по пустой приоритетной очереди: как только очередь опустела, поток считается завершённым, и верхний узел получает признак конца потока. В этот момент счётчики должны сойтись: число кортежей, полученных от AMS, совпадает с числом кортежей, отданных родителю, а счётчики от ребёнка и к AMS нулевые.
В unsorted-режиме конец потока наступает, когда от RecvTupleFrom не приходит кортеж, — тогда верхний узел также получает признак конца потока при тех же условиях на счётчики.
В cdbmotion.c при получении EOS от каждого отправителя увеличивается счётчик num_stream_ends_recvd, и когда он равен num_senders, флаг moreNetWork становится false. Верхний узел получает признак конца потока.
Sorted Motion
Sorted Motion настраивается в ExecInitMotion: для каждого столбца ключа сортировки заполняется SortSupportData (collation, порядок NULL, номер атрибута), затем вызывается PrepareSortSupportFromOrderingOp, а lastSortColIdx запоминает максимальный индекс столбца. Приоритетная очередь создаётся через binaryheap_allocate с компаратором CdbMergeComparator и числом входов numInputSegs.
При первом вызове execMotionSortedReceiver, если !node->tupleheapReady, для каждого отправителя из sendSlice->primaryProcesses вызывается RecvTupleFrom; полученный кортеж кладётся в слот, материализуется через slot_getsomeattrs до lastSortColIdx и добавляется в кучу через binaryheap_add_unordered. Если отправитель NULL или не прислал кортеж, он пропускается.
При повторных вызовах берётся голова кучи через binaryheap_first, слот отдаётся наверх, а элемент остаётся на месте до следующего вызова. Если stopRequested, отправляется SendStopMessage и поток завершается. Если куча пуста (все отправители вернули EOS), поток завершается при тех же условиях на счётчики.
Управление чанками в cdbmotion
getChunkSorterEntry возвращает запись сортировщика чанков для пары motion-node/srcRoute, создавая её при отсутствии: проверяет, что srcRoute в диапазоне [0, num_senders), и если ready_tuple_lists[srcRoute].init уже установлен, возвращает существующую запись; иначе выделяет массив ChunkSorterEntry на num_senders, обнуляет chunk_list (длины, число чанков, указатели), ставит end_of_stream = false и init = true, а ready_tuples берёт либо из htfifo_create() при preserve_order, либо из общего motNodeEntry->ready_tuples.
addChunkToSorter по типу чанка решает, что делать: для TC_WHOLE/TC_EMPTY требует пустой chunk_list, добавляет чанк через appendChunkToTCList и сразу вызывает reconstructTuple, который через CvtChunksToTup собирает кортеж, очищает список через clearTCList, при необходимости переустанавливает через TRCheckAndRemap и кладёт в htfifo_addtuple.
processIncomingChunks после обработки чанков освобождает rx-buffer: если numChunks > 0, вызывает DirectPutRxBuffer, затем учитывает статистику через statChunksProcessed.
End-of-stream помечается в addChunkToSorter: chunkSorterEntry->end_of_stream = true, увеличивается pMNEntry->num_stream_ends_recvd, и когда оно равно num_senders, pMNEntry->moreNetWork = false, после чего вызывается DeregisterReadInterest.
Транзакции: cdbdtm и двухфазный коммит
Когда выбирается 2PC
Решение принимается в prepareDtxTransaction: если по транзакции не было записи xlog на исполнителе верхнего уровня, либо локальный XID не назначен и сегментов меньше двух, выполняется однофазный коммит — транзакция фиксируется сразу, без подготовки на сегментах. Иначе выполняется подготовка, и коммит идёт в две фазы.
Вторая фаза
Вторая фаза реализована в doNotifyingCommitPrepared: она в цикле повторяет широковещательную рассылку DTX_PROTOCOL_COMMAND_RETRY_COMMIT_PREPARED, пока не добьётся успеха или не истечёт таймаут dtx_phase2_retry_second. Перед каждой повторной попыткой выполняется pg_usleep с базовой задержкой DTX_PHASE2_SLEEP_TIME_BETWEEN_RETRIES_MSECS (100 мс), умноженной на Min(Max(retry - 10, 1), 50) — задержка растёт с числом неудачных попыток.
Если успех так и не достигнут, выдаётся PANIC «unable to complete 'Commit Prepared' broadcast». При успехе вставляется запись FORGET (doInsertForgetCommitted) и освобождается TwophaseCommitLock.
Предвыборка gxid
Счётчик gp_gxid_prefetch_num задаёт размер порции предвыборки gxid: bumpGxid увеличивает nextGxid и GxidCount на gp_gxid_prefetch_num. Порог GXID_PRETCH_THRESHOLD равен gp_gxid_prefetch_num>>1, и при GxidCount <= этого порога пробуждается dtx recovery для предвыборки.
Сломанный writer gang во время prepare
Когда writer gangГруппа процессов-исполнителей на сегментах, которая создаётся для выполнения части распределённого плана и обслуживает запись или чтение. (группа процессов-исполнителей на сегментах, обслуживающая запись) обнаружен сломанным во время prepare, вызывается currentGxactWriterGangLost(), и если это так, система переходит к повторному откату подготовленной транзакции, а не к рассылке отката части подготовленных транзакций. Сломанный writer gang уже уничтожен в AtAbort_DispatcherState(), и для отката создаётся новый writer gang в контексте DTX_CONTEXT_LOCAL_ONLY.
При повторном откате подготовленной транзакции вызывается retryAbortPrepared(), который в цикле вызывает ResetAllGangs() для деаллокации gang и принудительного создания нового gang, подключающегося ко всем сегментам, и рассылает DTX_PROTOCOL_COMMAND_RETRY_ABORT_PREPARED. Ошибки при рассылке подавляются через PG_TRY/PG_CATCH: в блоке catch восстанавливается InterruptHoldoffCount, succeeded = false и вызывается FlushErrorState() — ошибка не пробрасывается наружу.
Если после всех попыток succeeded остаётся false, снова вызывается ResetAllGangs(), устанавливается событие DTX_RECOVERY_EVENT_ABORT_PREPARED и посылается сигнал PMSIGNAL_WAKEN_DTX_RECOVERY, а клиенту выдаётся предупреждение «unable to complete 'Abort' broadcast. The dtx recovery process will continue trying that.».
При рассылке отката без подготовленных транзакций, если процесс не завершается, клиент видит NOTICE «Releasing segworker groups to finish aborting the transaction.» и вызывается ResetAllGangs(); если же proc_exit_inprogress, вызывается DisconnectAndDestroyAllGangs(false) без сброса сессии, чтобы сохранить myTempNamespace для RemoveTempRelationsCallback().
Возврат старого узла и потеря записей
При возврате старого узла (retry) в finishDistributedTransactionContext разрешено оставлять активными только два состояния повторных попыток — повторный коммит подготовленной транзакции и повторный откат подготовленной транзакции; они поднимаются в PostgresMain, тогда как любое другое состояние при активной распределённой транзакции приводит к FATAL.
Для этих retry-команд в performDtxProtocolCommand требуется контекст DTX_CONTEXT_LOCAL_ONLY, и вызывается performDtxProtocolCommitPrepared/performDtxProtocolAbortPrepared с raiseErrorIfNotFound=false — отсутствие подготовленной транзакции не считается ошибкой.
Ошибка QE при диспетчеризации dtx-протокола может быть потеряна: она логируется только под Debug_print_full_dtm, а при raiseError=false не перебрасывается, поскольку ThrowErrorData вызывается лишь внутри if (raiseError). Кроме того, при raiseError=true перед ThrowErrorData выполняется FlushErrorState(), что также может уничтожить накопленную диагностику.
Потеря записи о старом узле проявляется в том, что при отсутствии подготовленной транзакции в DTX_CONTEXT_LOCAL_ONLY для ABORT_SOME_PREPARED просто пишется DTM_DEBUG3 и делается AbortOutOfAnyTransaction().
Ганги и отказоустойчивость: cdbgang
Как создаётся gang
cdbgang_createGang сам не формирует параметры: он лишь вызывает сохранённый указатель pCreateGangFunc, передавая ему список сегментов и тип сегмента, и возвращает созданный Gang.
Формирование строк option/diff_options выполняет makeOptions: она всегда добавляет в options gp_qd_hostname и gp_qd_port, а затем для каждой GUC с флагом GUC_GPDB_NEED_SYNC и контекстом PGC_USERSET или PGC_BACKEND (либо для суперпользователя) вызывает addOneOption, который в options кладёт reset_val, а в diff_options — текущее значение только если оно отличается.
Строку gpqeid формирует build_gpqeid_param через snprintf в формате "%d;" TIMESTAMP_FORMAT ";%s;%d;%d;%d;%d", подставляя gp_session_id, PgStartTime, признак is_writer ("true"/"false"), identifier, hostSegs, icHtabSize и qeidx.
Причина отказа сегмента определяется по тексту сообщения об ошибке: segment_failure_due_to_recovery ищет подстроку "FATAL" с двоеточием после неё и затем одну из строк POSTMASTER_IN_RESET_MSG, POSTMASTER_IN_STARTUP_MSG или POSTMASTER_IN_RECOVERY_MSG, а segment_failure_due_to_missing_writer — "FATAL" с двоеточием и строку WRITER_IS_MISSING_MSG.
Сброс сессии и ограничения на очистку
resetSessionForPrimaryGangLoss помечает сессию как требующую сброса (NeedResetSession = true) и запоминает старый временный namespace, чтобы позже освободить его; при этом временные таблицы предыдущей сессии становятся недоступны. Функция может быть вызвана дважды в одной транзакции, поэтому она использует NeedResetSession как двойную проверку, чтобы не пометить OldTempToastNamespace недействительным до очистки временного namespace.
RecycleGang не должен выбрасывать ERROR, потому что он не реентерабелен: ошибка могла бы возникнуть после освобождения segdbDesc, что привело бы к повторному вызову RecycleGang во время abort и двойному освобождению; поэтому interrupts удерживаются через HOLD_INTERRUPTS() до полной очистки.
DisconnectAndDestroyUnusedQEs нельзя использовать в контексте named portals, так как он уничтожает CdbComponentsContext, к которому обращаются позже при очистке named portal.
Ограничения и компромиссы
У порта нет собственных shared catalogs: атрибуты ролей хранятся в shared security labels, секреты — в maintenance database, топология — в cluster file, при этом gp_segment_configuration и родственные объекты являются представлениями. Метаданные Greenplum перенесены в security labels и extension tables PostgreSQL, а вместо TDE выбрано шифрование тома на уровне операционной системы, хотя при более глубоких изменениях ядра TDE мог бы работать так же, как в Cloudberry.
По сравнению с Cloudberry не портирован MPP-вариант планировщика PostgreSQL «Route B», поэтому всё, что ORCA отклоняет, идёт более медленным gather-путём, а планирование с ORCA дороже на коротких запросах: 13.7 мс против 0.25 мс для точечного поиска одной строки по измерениям проекта pgorca.
Параллелизм внутри сегмента охватывает только срез писателя, а SERIALIZABLE на кластере — это REPEATABLE READ, как в Greenplum. Из-за природы MPP координатор является единственным планировщиком и единственной точкой входа; соединения не по ключу распределения перемещают данные по сети; уникальные ограничения должны включать ключ распределения; добавление узлов означает перераспределение данных.
Отдельный компромисс — поведение rewrite. Одно утверждение остаётся одним утверждением после rewrite. Ранняя версия порта превращала его в два, что ломало каждый драйвер, использующий prepared statements. Позиции ошибок при этом всё ещё указывают на исходный, не переписанный текст.
Как сравнивали и что получилось
Стенды и методология
Для M1 использовался один узел (18–22 сентября), для M2 — кластер (22–24 сентября). На M1 выполнялись Cloudberry SQL, ORCA и PostGIS, а также 239 regression-тестов PostgreSQL построчно. На M2 использовались dispatcher, Motions и streaming interconnect.
Замеры на TPC проводились в конфигурации: один координатор и четыре сегмента в одном контейнере, на 16-ядерном хосте с 60 ГБ памяти. Данные размещались в tmpfs, JIT и параллельные запросы были отключены, assertions отсутствовали. Каждая цифра получена из Docker-образов, собранных из закоммиченных веток.
Использовались 22 запроса TPC-H и 99 запросов TPC-DS в том виде, как их поставляет DuckDB, на данных scale factor 1. Эталоном служили ответы DuckDB, сравнение выполнялось с точностью до цента.
Повторить замер можно с помощью харнесса pg19/test/tpc в порте: он генерирует данные, загружает кластер, прогоняет каждый запрос под ORCA и на gather-маршруте и сравнивает строки. Запросы и эталонные ответы берутся из собственных наборов DuckDB 1.5.5: TPC-H queries and SF1 answers, TPC-DS queries and SF1 answers. Записанный прогон харнесса есть в сообщении коммита beaf7379d12: 121 of 121, 3.13× и 3.94×, и те же пять запросов не завершены.
Результаты
| Метрика | TPC-H | TPC-DS |
|---|---|---|
| Спланировано ORCA | 22/22 | 99/99 |
| Ответы равны DuckDB | все | все |
| ORCA vs gather, геометрическое среднее | 3.1× (4.4 с против 123 с, 20 запросов) | 4.0× (25 с против 386 с, 96 запросов) |
| Не уложились в 120 с на gather | q17, q20 | 04, 14, 64 |
Под ORCA пять незавершённых на gather-маршруте запросов занимают 0.2–1.6 с, а все 121 выполняются за 34 с. Итоги в таблице получены из первого замера, который выполнялся в три раунда.
Прогоны без assertions фиксировали падения и ошибки: первый тест нового combo-CID хука уронил сервер, а из-за порядка взятия блокировок таблиц 87% конкурентных UPDATE в pgbench падали. Цифры подтверждают, что только порт Cloudberry выполняет сложный запрос по большим таблицам как единый распределённый конвейерный план, где каждый узел читает один и тот же распределённый снимок.
Оговорки
Все измеренные цифры получены на конкретном стенде: один координатор и четыре сегмента в одном контейнере, на 16-ядерном хосте с 60 ГБ памяти, без assertions, с данными в tmpfs и отключёнными JIT и параллельными запросами.
Ограничения стенда, способные повлиять на результаты, включают отключённые JIT и параллельные запросы, данные в tmpfs и отсутствие assertions. Итоги в таблице взяты из первого измерения, которое выполнялось в три раунда. На шести коротких запросах (0.1–0.75 с) ORCA более чем на пятую часть медленнее.
Что из этого следует на практике
- Отставание от upstream — структурная проблема форка, а не небрежность. 882 изменённых и 646 новых файлов плюс 1,66 млн строк ORCA означают, что каждый мажорный релиз PostgreSQL — это месяцы слияния, за которые upstream уходит вперёд. Перенос в расширения сокращает эту работу до 22 небольших патчей к ядру.
- Цена переноса — потеря MPP-планировщика. Всё, что ORCA отклоняет, идёт gather-маршрутом. На TPC это видно прямо: пять запросов на gather не укладываются в 120 с, тогда как под ORCA занимают 0.2–1.6 с.
- ORCA быстра на аналитике и дорога на коротких запросах. Геометрическое среднее 3.1× и 4.0× против gather на TPC-H и TPC-DS, но 13.7 мс против 0.25 мс на точе