Реплики¶
Реплика — это копия таблицы источника, которую 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; собственное зарегистрированное хранилище организации имеет приоритет над обоими.
Это общая рекомендация, а не правило. Окупится ли она, зависит от движка, источников, хранилища и размеров таблиц. Учитывайте следующее:
- Доступ при каждом запросе. Хранилище, до которого движок достаёт по сети, добавляет к каждому чтению обращение и передачу данных. Встроенный файл 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.