Перейти к содержанию

Реплики

Реплика — это копия таблицы источника, которую Provisa хранит в хранилище материализации движка и читает вместо источника. Реплицируется ли таблица вообще, определяют replicate и то, до чего может дотянуться движок; см. What an engine changes.

Реплики индексируются

Provisa индексирует реплику так, как модель использует её таблицу.

  • Объявленный первичный ключ, обеспечиваемый как ключ.
  • Столбцы, по которым соединяются объявленные связи:
  • внешний ключ каждой связи, источником которой является эта таблица;
  • целевой столбец каждой связи, целью которой является эта таблица, если этот столбец уже не является ключом;
  • оба внешних ключа таблицы-связки, через которую проходит связь.

Возьмём такую связь:

relationships:
  - id: orders-to-customers
    source_table_id: orders
    target_table_id: customers
    source_column: customer_id
    target_column: id
    cardinality: many-to-one

Реплика orders получает свой ключ и индекс по customer_id. Реплика customers получает свой ключ, а если id и есть этот ключ, больше ничего не нужно. Соединение двух таблиц тогда читает индекс с обеих сторон — в хранилище, которое использует индексы для соединений (см. Что строит каждое хранилище). Связь по вычисляемому столбцу, которого нет в реплике, индекса не получает.

Объявите ключ через is_primary_key: true на столбце. Реплика без объявленного ключа получает только индексы соединений.

Сначала читаемая, потом индексированная

Загрузка никогда не ждёт индексов. Реплика читается с момента подмены; затем исполнитель реплик строит её индексы по одному, отдельным проходом, под блокировкой индексов каждой реплики. Первые запросы после загрузки выполняются без них.

Каждая загрузка записывает индексы, которые она запросила, построенные и завершившиеся неудачей — по именам — в запись реплики в плоскости управления (replica_state.index_state).

  • Неудавшийся индекс никогда не проваливает загрузку. Он записывается в журнал с причиной, и следующая загрузка запрашивает его снова. Строка журнала выглядит так: replica of <source>.<schema>.<table>: index <name> on (<columns>) failed: <cause>.
  • Строки, повторяющие объявленный ключ, — это отдельная ошибка с кодом replica.index_duplicate_key, в которой названы столбцы ключа. В источнике есть дубликаты набора столбцов, который модель объявляет уникальным. Повтор этого не исправит: исправьте данные в источнике или исправьте объявленный ключ.
  • Загрузка, заменяющая таблицу посреди построения, оставляет предыдущие построения позади; они останавливаются и ничего не записывают, а новая таблица получает свои собственные. В хранилище PostgreSQL подмена отменяет построение, ещё выполняющееся на заменяемой таблице.
  • План следует модели при каждой загрузке. Связь, добавленная позже, получает свой индекс при следующей загрузке реплики.

Admin UI не показывает запись об индексах. Смотрите журнал сервера или столбец index_state в строке реплики.

Что строит каждое хранилище

Хранилище Что строится Как
PostgreSQL: собственная база движка pg, хранилище PostgreSQL у Trino или DuckDB, движок sqlalchemy поверх PostgreSQL Ключ и индексы соединений CREATE INDEX CONCURRENTLY на собственном подключении, поэтому читатели и писатели не блокируются. Ключ строится как уникальный индекс, затем становится первичным ключом через ADD CONSTRAINT ... PRIMARY KEY USING INDEX. Недействительный индекс, оставленный отменённым построением, удаляется и строится заново.
Другие базы данных за реляционным движком (mysql, oracle, mssql и остальные) Ключ и индексы соединений Собственный DDL диалекта. База данных, отклонившая DDL, фиксирует индекс как неудавшийся.
Встроенный файл DuckDB, MotherDuck Ключ и индексы соединений Через брокер хранилища: ALTER TABLE ... ADD PRIMARY KEY, CREATE INDEX IF NOT EXISTS.
ClickHouse Только ключ сортировки Реплика ClickHouse упорядочена по объявленному ключу (ORDER BY), заданному при загрузке. Вторичного B-tree индекса в ClickHouse для построения нет.
Snowflake, BigQuery, Databricks, Fabric, Synapse Ничего Хранилище данных само организует таблицу.

Индекс помогает только соединению, которое выполняется там же, где индекс. Два движка читают своё хранилище, не используя его индексы для соединений:

  • DuckDB со встроенным файлом. Движок никогда не подключает файл. Каждая инструкция читает собственную локальную копию каждой названной ею таблицы хранилища, создаваемую заново при каждом изменении хранилища. Соединения выполняются как хеш-соединения над этой копией, поэтому индексы соединений их не ускоряют; индексы файла обеспечивают ключ и обслуживают всё, что читает файл на месте.
  • DuckDB с хранилищем PostgreSQL. DuckDB читает хранилище через расширение postgres с отключённой передачей фильтров (ошибка в upstream duckdb/duckdb-postgres#564), поэтому он сканирует реплику, а фильтрацию и соединения выполняет в DuckDB. Индексы хранилища построены, но эти чтения их не используют.

Хранилище MotherDuck подключается и читается на месте.

Используйте реляционную базу данных как хранилище реплик там, где движок выполняет в ней SQL

Когда движок выполняет соединения внутри реляционной базы данных, держите реплики там. Тогда соединения читают описанные выше индексы. Так работают три конфигурации:

  • Движок pg. Реплики загружаются в собственную базу PostgreSQL движка, и каждая инструкция выполняется там, через их индексы.
  • Реляционный движок (mysql, oracle, mssql, универсальный sqlalchemy и остальные). Каждый источник загружается в эту базу данных и запрашивается там.
  • Trino с хранилищем PostgreSQL. PostgreSQL — единственное хранилище, которое поддерживает Trino; Trino читает реплики через свой коннектор PostgreSQL. Фильтр или соединение, которые Trino передаёт в PostgreSQL, используют индексы; работа, которую Trino выполняет сам, — нет.

Направьте движок на хранилище с помощью PROVISA_MATERIALIZE_URL или materialize_store_url; собственное зарегистрированное хранилище организации имеет приоритет над обоими.

PROVISA_MATERIALIZE_URL="postgresql://provisa:secret@replicas.internal:5432/replicas"

Это общая рекомендация, а не правило. Окупится ли она, зависит от движка, источников, хранилища и размеров таблиц. Учитывайте следующее:

  • Доступ при каждом запросе. Хранилище, до которого движок достаёт по сети, добавляет к каждому чтению обращение и передачу данных. Встроенный файл DuckDB локален и столбцовый, он сканируется быстро.
  • Стоимость загрузки. Запись реплики в реляционную базу данных с последующим построением её индексов обходится дороже записи локального файла.
  • Собственная нагрузка базы данных. Чтения реплик, загрузки и построения индексов выполняются на этой базе данных. Рассчитывайте её размер с их учётом.
  • Большие сканирования. Индекс бесполезен для запроса, читающего большую часть крупной таблицы. Столбцовое хранилище сканирует её быстрее строкового.

Выбор места для реплик

Хранилище — один из нескольких рычагов, и они пересекаются. Сначала определитесь с остальными; хранилище решает лишь то, что они оставляют.

Рычаг Что делает Где помогает
Hot tables Небольшая справочная таблица хранится в Redis и подставляется в каждый запрос, который её читает, как встроенное CTE (общее табличное выражение) VALUES. Соединения с небольшими справочниками в любом хранилище: строки передаются внутри запроса. Цель связи «многие к одному» или таблица размером не более hot_tables.auto_threshold строк (по умолчанию 1000) становится горячей при первом чтении. hot: true принудительно включает таблицу, а hot: false не пускает её.
Материализованные представления, которые вы определяете Представление с materialize: true сохраняется и обновляется. Запрос, читающий представление, обслуживается сохранёнными строками, когда они свежие и читатель видит их целиком: ни один вход не несёт правило строк, более узкое, чем «всё». Агрегаты над большими таблицами. Вы решаете, какие из них существуют; ни одно не создаётся за вас. См. Views.
Связь с materialize: true Межисточниковая связь получает сохранённое, заранее соединённое представление «родитель — потомок». Запрос, соединение в котором полностью или частично совпадает с шаблоном, переписывается на его чтение, пришёл ли он как SQL или его построил компилятор. Соединения в хранилище, не строящем индексов соединений. См. Auto-Materialization.
Размещение Источники, которые движок может читать напрямую, читаются напрямую; остальные реплицируются. Что окажется в хранилище, зависит от выбранного движка. Выбор движка определяет большую часть того, что должно вмещать хранилище.

Материализация связей и индексированные соединения отвечают на одну потребность двумя способами. В столбцовом хранилище или хранилище данных (файл DuckDB, ClickHouse, Snowflake, BigQuery, Databricks, Fabric, Synapse) индексы соединений не строятся, поэтому заранее соединённое представление — способ удешевить частое соединение. В реляционном хранилище столбцы соединений уже проиндексированы, и те же соединения могут выполняться быстро без материализации.

От хранилища всё ещё зависит следующее: разовые сканирования и соединения над большими таблицами, которые не покрыты ни одним материализованным представлением и не обслуживаются горячей таблицей. Они выполняются на индексах хранилища в реляционной базе данных или на скорости его сканирования в столбцовой.

Результаты зависят от нагрузки. Измеряйте до и после изменения с помощью инструмента подбора ресурсов; его прогоны «напрямую или реплика» показывают стоимость чтения из реплики в сравнении с прямым чтением в вашем развёртывании.

Актуальность

Включите актуальность в решение наравне со скоростью. Насколько старым может быть ответ и во что обходится источнику поддержание его свежести — от этого зависит, читается ли таблица напрямую или из реплики, а значит, и что должно вмещать хранилище.

Корпоративные источники редко поддерживают реальное время. Данные поступают пакетной загрузкой или ночным ETL, потоки изменений отстают, а многие системы не выдерживают прямых чтений при объёме запросов. Начните с актуальности, нужной каждой таблице, затем выберите самую дешёвую стратегию, которая её обеспечивает. Правила на стороне оператора описаны в разделе Freshness and load: who decides.

Стратегия Задаётся Что гарантирует Во что обходится источнику
Прямое чтение replicate: -1 Актуально на момент запроса Каждый запрос читает источник
TTL change_signal: ttl, cache_ttl Реплика не старше cache_ttl Одно перестроение за cache_ttl; при watermark_column — только строки после watermark
Проба изменений change_signal: probe или ttl_probe, cache_ttl Перестраивается только после того, как проба сообщила об изменении; проба выполняется каждые cache_ttl Одна проба за интервал и перестроение только при изменении
Поток изменений с отправкой Источник Kafka или WebSocket; change_signal: debezium для источника с блоком cdc; change_signal: native для PostgreSQL, MongoDB или Cosmos DB Актуально с точностью до отставания потока и сглаживания (debounce) Kafka, WebSocket и Debezium: ничего после первого построения, строки несёт сам поток. PostgreSQL, MongoDB и Cosmos DB: перестроение на каждую серию изменений
Внешний сигнал change_signal: signal Перестраивается каждый раз, когда POST /data/sources/{source_id}/signal сообщает, что источник изменился; никогда по часам Одно перестроение на сигнал
TTL по ролям role_ttl на таблице Перечисленная роль принимает реплику не старше её собственного TTL; cache_ttl таблицы остаётся нижней границей Меньше перестроений: чтение терпимой роли никогда их не запрашивает
Защита нагрузки load_protected: true, с cache_ttl, пробой или off_peak_window Запросы никогда не читают источник; реплику обновляет только исполнитель, а при окне вне пиковых часов только пока оно открыто Только плановые обновления
Шлюз актуальности freshness_gate: true на источнике Каждое чтение сначала проверяется по change_signal и cache_ttl источника; вердикт «устарело» обновляет данные перед чтением Обновление на пути чтения, когда данные устарели
Не истекает cache_ttl: -1 на источнике или таблице Загружается один раз; каждое чтение обслуживается из реплики, а чтение ещё не загруженной таблицы отклоняется с указанием её имени Только эта загрузка: источник, все таблицы которого загружены, больше не подключается, не опрашивается и не читается, и учётные данные ему не нужны

Проба, которой нечего сравнивать (нет watermark, контрольного значения или запроса пробы), вырождается в TTL.

Почти реальное время из потока изменений

Когда источник не выдерживает нагрузки живых запросов, но уже публикует свои изменения, поток изменений даёт данные почти в реальном времени, не трогая источник во время запроса. Запросы читают локальную реплику, что быстро и не нагружает источник, а цикл событий применяет изменения по мере их поступления. Отставание данных определяется только потоком — обычно это секунды.

Наличия потока недостаточно. Provisa следит за ним только для таблицы, подключённой к нему:

  • Источник Kafka. Таблица называет свой топик, и каждое изменение применяется к реплике по первичному ключу, включая удаления.
live:
  strategy: kafka
  kafka:
    topic: orders.cdc
push_debounce_quiet: 0      # seconds of quiet before a batch lands; 0 lands each message
push_debounce_max_delay: 5  # a batch never waits longer than this
  • Источник WebSocket. Его поток применяется так же.
  • Таблица с change_signal: debezium (у таблицы или её источника) на источнике, чей блок cdc задаёт транспорт Kafka и префикс топика коннектора. Первое чтение строит реплику; затем каждое событие изменения в топике {topic_prefix}.{schema}.{table} применяется по первичному ключу, включая удаления, а смещение фиксируется только после того, как изменение загружено.
  • Таблица такого источника с change_signal: kafka. Её изменения читаются из топика дельт, указанного в её live.kafka.topic, через тот же транспорт и применяются так же; таблица с этим сигналом, не указывающая топик, отклоняется по имени при сохранении таблицы или её источника и при загрузке конфигурации.
  • Таблица MongoDB или Cosmos DB с change_signal: native (на таблице или на её источнике). Каждая серия изменений в потоке изменений запрашивает перестроение реплики.
  • Таблица PostgreSQL с change_signal: native (у таблицы или её источника). Provisa читает логическую репликацию базы данных из собственного слота provisa_<source id> через публикацию с тем же именем, в которой перечислены нативные таблицы источника. Kafka не участвует. Каждая серия изменений в таблице запрашивает перестроение её реплики. Базе данных нужно то же, что и Debezium: wal_level со значением logical, роль с правом репликации, которой разрешено создать публикацию, и основной сервер, а не реплика для чтения; источник, которому чего-то из этого не хватает, отклоняется по имени при сохранении и при загрузке конфигурации. Слот и публикация создаются, когда за ними начинает следить первая таблица, и удаляются, когда последняя перестаёт или источник удаляется. При запуске сервер проверяет каждый зарегистрированный источник PostgreSQL и удаляет слот Provisa, за которым не следит ни одна нативная таблица, так что слот, оставшийся после изменения конфигурации, сделанного при остановленных серверах, больше не удерживает WAL источника.

Таблице с отправляемыми изменениями нужен объявленный первичный ключ; таблица без него пропускается и записывается в журнал.

Таблица с сигналом изменений типа push считается свежей на пути чтения, потому что её поток и есть её проверка актуальности. Поэтому сигнал push, который ничто не получит, отклоняется: change_signal: native для любого типа, кроме Kafka, WebSocket, ingest, PostgreSQL, MongoDB и Cosmos DB, отклоняется при сохранении источника или таблицы и при загрузке конфигурации, как и debezium для источника без блока cdc.