MERGE, UPDATE, DELETE и полиморфные табличные функции
Долгое время Trino воспринимали как движок только для чтения: подключи источники, читай и федеративно джойни. Это давно не так. Trino полноценно меняет данные в источниках, которые это поддерживают: INSERT, DELETE, UPDATE и сводящий их воедино MERGE. И есть отдельный, мощный механизм — полиморфные табличные функции (PTF), которые расширяют сам набор того, что может стоять в FROM.
Этот урок — про DML-операции в Trino, про то, почему их возможность зависит от коннектора и формата таблицы, и про PTF как точку расширяемости движка.
Почему DML зависит от коннектора
Сначала принципиальное. Trino — движок без своего хранилища. Когда вы пишете DELETE FROM, Trino сам ничего не удаляет — он переводит операцию в действия коннектора над источником. А источники по-разному способны менять данные.
Простой INSERT поддерживают почти все пишущие коннекторы. А вот построчные UPDATE и DELETE — операция нетривиальная для файловых форматов. Parquet и ORC иммутабельны: файл нельзя «отредактировать», изменив одну строку. Поэтому row-level изменения требуют, чтобы формат таблицы умел их выражать поверх иммутабельных файлов.
Современные форматы таблиц это умеют. Iceberg и Delta Lake поддерживают row-level операции через механизм delete-файлов (delete files): вместо переписывания файла данных создаётся отдельный файл, помечающий, какие строки считать удалёнными; при чтении движок применяет эти пометки. Для Iceberg это требует формата таблицы версии v2 или выше. Hive поддерживает UPDATE/DELETE только для транзакционных ACID-таблиц формата ORC.
Вывод, который надо держать в голове: возможность MERGE/UPDATE/DELETE — это свойство пары «коннектор + формат таблицы», а не самого Trino. На обычной нетранзакционной Hive-таблице в Parquet построчный DELETE работать не будет; на таблице Iceberg v2 — будет.
DELETE и UPDATE
DELETE удаляет строки, удовлетворяющие условию WHERE:
DELETE FROM iceberg.shop.orders
WHERE orderstatus = 'CANCELLED' AND orderdate < DATE '2024-01-01';
Есть важный частный случай. Если условие WHERE совпадает с границами партиций — например, удаляются целиком партиции за старые даты, — коннектор может выполнить metadata delete: не трогать файлы данных вообще, а лишь обновить метаданные таблицы, исключив целые файлы или партиции. Это операция уровня метаданных, мгновенная независимо от объёма данных в удаляемых партициях. Если же условие отсекает строки внутри файлов, включается row-level механизм с delete-файлами — он дороже.
UPDATE меняет значения столбцов в строках по условию:
UPDATE iceberg.shop.orders
SET orderstatus = 'ARCHIVED'
WHERE orderdate < DATE '2023-01-01';
Под капотом для иммутабельных форматов UPDATE — это, по сути, «удалить старую версию строки и записать новую»: затронутые строки помечаются удалёнными через delete-файл, а их обновлённые версии дописываются новыми файлами данных.
MERGE: upsert одной командой
MERGE — самая мощная DML-команда. Она сопоставляет целевую таблицу с источником по условию и в зависимости от того, нашлось совпадение или нет, выполняет разные действия: обновить, удалить или вставить. Это каноничный upsert (update-or-insert) и основа инкрементальной загрузки данных.
MERGE INTO iceberg.shop.customers AS t
USING staging.customer_updates AS s
ON t.customer_id = s.customer_id
WHEN MATCHED AND s.is_deleted = true
THEN DELETE
WHEN MATCHED
THEN UPDATE SET name = s.name, email = s.email, updated_at = s.updated_at
WHEN NOT MATCHED
THEN INSERT (customer_id, name, email, updated_at)
VALUES (s.customer_id, s.name, s.email, s.updated_at);
Разберём структуру. MERGE INTO target USING source ON условие задаёт цель, источник и правило сопоставления. Дальше — ветки WHEN:
WHEN MATCHED— строка цели нашла пару в источнике. Действие —UPDATEилиDELETE. ВетокWHEN MATCHEDможет быть несколько с дополнительнымиAND-условиями: в примере первая ветка удаляет помеченные на удаление, вторая обновляет остальные совпавшие.WHEN NOT MATCHED— строка источника не нашла пары в цели. Действие —INSERT.
Один MERGE заменяет связку из отдельных UPDATE, DELETE и INSERT, и делает это согласованно, за один проход сопоставления. Именно MERGE — рабочая лошадка инкрементального обновления таблиц лейкхауса: в источнике лежит «дельта» изменений, MERGE вмёрживает её в большую таблицу.
MERGE физически выполняет всю работу row-level операций: для иммутабельных форматов совпавшие строки помечаются удалёнными через delete-файлы, а обновлённые и вставленные дописываются новыми файлами данных. Частый и интенсивный MERGE мелкими порциями быстро накапливает множество мелких файлов данных и delete-файлов — это потом замедляет чтение. Поэтому MERGE в лейкхаусе идёт в паре с регулярным обслуживанием таблицы: компакцией мелких файлов через ALTER TABLE ... EXECUTE optimize. Планируя инкрементальную загрузку через MERGE, сразу планируйте и обслуживание таблицы.
Полиморфные табличные функции
Теперь другая тема — расширяемость. Обычно в FROM стоит таблица или подзапрос. Но что если нужно нечто, чего реляционная модель не выражает: выполнить «родной» запрос прямо в источнике, сгенерировать данные, разобрать нестандартный формат? Для этого есть табличные функции — функции, которые возвращают не скаляр, а целую таблицу, и потому стоят в FROM.
Большинство полезных табличных функций — полиморфные (PTF, polymorphic table functions). «Полиморфная» означает: схема результата (имена и типы столбцов) не фиксирована заранее, а определяется в момент вызова — по аргументам функции. Одна и та же PTF при разных аргументах вернёт таблицы с разной схемой. Обычная функция так не умеет — у неё фиксированный тип результата.
Самый важный практический пример PTF — query pass-through через функцию query. Она позволяет отправить запрос на «родном» диалекте источника прямо в источник, а Trino получает результат как таблицу:
SELECT *
FROM TABLE(
postgresql.system.query(
query => 'SELECT region, count(*) FROM analytics.users GROUP BY region'
)
);
Зачем это нужно. Trino транслирует SQL в вызовы коннектора и проталкивает в источник то, что умеет (pushdown — тема восьмого модуля). Но не всё: специфичная для PostgreSQL функция, хитрая агрегация, фича, которой нет в SPI, в источник не протолкнутся — Trino вытянет сырые данные и посчитает сам. query обходит это ограничение: текст в query => '...' исполняется источником как есть, его родным движком, со всеми его возможностями. PTF здесь полиморфна по необходимости — схема результата зависит от того, что вернёт переданный SQL, и известна только в момент вызова.
Синтаксис вызова любой табличной функции — TABLE(имя_функции(аргументы)) в позиции FROM. Аргументы передаются по имени через =>. Кроме query существуют и другие табличные функции — например, генераторы данных и функции разбора файлов в объектном хранилище; конкретный набор зависит от коннектора.
Query pass-through через query — мощный, но осознанный инструмент. Запрос внутри query Trino не анализирует и не оптимизирует — он целиком отдаётся источнику. Из этого следует две вещи. Плюс: можно использовать любые родные возможности источника, которые не выражаются через SPI. Минус: Trino не сможет применить к такому подзапросу свои оптимизации, dynamic filtering, не проверит его на этапе планирования. Берите query, когда действительно нужна фича источника, недоступная иначе, — а не как замену обычным запросам через Trino.
Попробуй сам
На песочнице курса (Trino 481):
-
На песочнице есть каталог
memory(коннектор Memory, поддерживает запись). Создайте таблицу:CREATE TABLE memory.default.t AS SELECT 1 AS id, 'a' AS val;. ВыполнитеINSERT, затемUPDATE, затемDELETEи проверяйте содержимое черезSELECTпосле каждого шага. Зафиксируйте, что все три DML-операции отработали, потому что коннектор Memory это поддерживает. -
Рассуждение в двух абзацах. Первый: объясните, почему построчный
DELETEпо условию, отсекающему строки внутри файлов, на таблице Iceberg v2 возможен, а на обычной нетранзакционной Hive-таблице в Parquet — нет. Второй: в чём разница между metadata delete (удаление целых партиций) и row-level delete по стоимости и почему первый практически мгновенный. -
Объясните своими словами, что значит «полиморфная» в названии PTF и почему функция
queryобязана быть полиморфной. Приведите пример запроса к источнику, который имеет смысл выполнить черезTABLE(...query(...)), а не обычнымSELECTчерез Trino, и обоснуйте выбор.