Substrait — это промежуточный формат (IR) для обмена планами запросов между системами. Он снимает боль диалектов SQL, позволяет делать pushdown в разные бэкенды и избавляет от повторного парсинга/оптимизации федеративных системах и позволяет относительно безболезненно заменять один бэкенд другим. Ниже - зачем он нужен, как устроен и кто поддерживает.

Введение

Часто нужные данные находятся в разных хранилищах: например, продажи лежат в Parquet/S3, а справочники — в Postgres. Бизнес-вопрос требует один запрос поверх всего этого (например, джойн продаж с продуктами). Для этого можно использовать федеративный движок (CedrusData/Trino/Dremio/Presto), который парсит SQL написанный аналитиком, строит единый план исполнения и спускает (push down) части плана к источникам: фильтры и проекции — в ридер Parquet, подзапросы — в Postgres. Для работы с Postgres используется JDBC-коннектор федеративного движка. Это обычно означает что после оптимизации федеративному движку снова приходится генерировать фрагмент SQL в диалекте целевой БД, отправить его туда, забрать промежуточный результат, а остаток плана (джойн между источниками, финальные агрегации) посчитать у себя.

Этот подход имеет ряд определенных проблем:

  • Диалекты SQL. Разные системы говорят на разных диалектах, с разным поведением функций и NULL. Продюсер запроса (где мы пишем запрос) вынужден знать каждого потребителя (где запрос или его часть исполняется).

  • Повторная тяжёлая работа. Каждый движок заново парсит и оптимизирует SQL, сгенерированный в конекторах федеративного движка. Подсказки и тонкости плана теряются по дороге.

  • Несовместимая семантика. Переполнение int32 + int32, деление на 0, сравнение NULL - всё это может вести себя по‑разному в разных движках

  • NoSQL и множество DSL. MongoDB, Cassandra и разнообразные API не говорят на SQL. Аналитики же пишут ещё и на Python (Ibis), R (dplyr), … — хочется единый «общий знаменатель» для всех.

Идея Substrait — не отправлять строку SQL, а передавать сериализованный план виде промежуточного представления (intermediate representation — IR) на protobuf: с чётко описанными операторами, типами и функциями.

Это позволяет:

  • делать pushdown фрагментов плана в подходящие бэкенды,

  • не парсить/оптимизировать одно и то же по нескольку раз,

  • декомпозировать большой план на части и исполнять их там, где выгоднее,

  • снижать связность между фронтендами (SQL/Ibis/dplyr/…​) и бэкендами (Velox/DuckDB/DataFusion/…​).

Место Substrait в мире СУБД хорошо демонстрирует следующая иллюстрация:

Когда Substrait НЕ нужен:

  • Вы живёте в одной СУБД/DI-стеке, без федерации источников.

  • Нет сценариев или необходимости шеринга логики между движками.

  • Внутренний IR одной системы полностью вас устраивает и нет планов заменить его на некий универсальный и легкозаменяемый бэкенд.

Арихитектура Substrait

Substrait по своей сути — открытая, языко- и движко-агностичная спецификация переносимого IR планов вычислений над структурированными данными. Планы задаются в Protocol Buffers (есть удобная текстовая форма), расширяемость обеспечивают YAML-каталоги функций, выражений, реляционных операторов, типов и их вариаций. Основные строительные кирпичики Substrait приведены на этом рисунке:

Одна из систем, которая генерирует планы исполнения, называется продюсером (Producer), вторая, которая принимает план и исполняет его — консьюмером (Consumer).

Начать общаться эти системы могут с обмена своими возможностями (Capabilities capabilities.proto), чтобы они обе понимали что умеет а что нет их коллега. Проще говоря, они обмениваются текущей поддерживаемой версией Substrait и именами с описаниями тех функций и типов которые они поддерживают. Это описано с помощью так называемых SimpleExtensions, которые содержатся в Capabilities. 

В Substrait принято определять поддерживаемые функции и типы (SimpleExtensions) с помощью yaml. Типичный пример extensions можно найти, например, тут functions_arithmetic.yaml. Здесь описаны поддерживаемые арифметические функции вот в таком виде:

urn: extension:io.substrait:functions_arithmetic
scalar_functions:
  -
    name: "add"
    description: "Add two values."
    impls:
      - args:
          - name: x
            value: i8
          - name: y
            value: i8
        options:
          overflow:
            values: [ SILENT, SATURATE, ERROR ]
        return: i8
      - args:
          - name: x
            value: i16
          - name: y
            value: i16
        options:
          overflow:
            values: [ SILENT, SATURATE, ERROR ]
        return: i16
…

Можно обратить внимание, что описаны не только функции, типы аргументов и результата, но их особенности — здесь, например, указано, как должна вести себя функция при переполнении.

В этом и есть мощь Substrait и его отличие от других способов передачи планов, ведь учитываются даже такие тонкие моменты. Кроме того, каждая функция помечена URN (в ранних версиях Substrait — URI) — это сделано для разрешения конфликта имен по аналогии с package в java и namespace в C++, так как возможны ситуации, когда функции с одним и тем же именем в разных системах могут делать разные вещи. Типичный пример: DATEDIFF — одно имя, разные сигнатуры/семантика.

  • MySQL: DATEDIFF(date1, date2) — дни между датами (date1 — date2).

  • SQL Server: DATEDIFF(day, start, end) — разность в указанных единицах (не только дни); другой порядок аргументов.

Типы данных (кроме встроенных в Substrait) описываются похожим образом в yaml. Вот пример из самого Substrait extension_types.yaml, где описан тип Point:

urn: extension:io.substrait:extension_types
types:
  - name: point
    structure:
      latitude: i32
      longitude: i32
  - name: line
    structure:
      start: point
      end: point

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

Когда поступает запрос от пользователя в каком-либо виде (SQL, python, dplyr,...), продюсер конвертирует его в protobuf-сообщение в соответствии со спецификацией Substrait. 

Чтобы было нагляднее возьмем типичный use-case пушдауна подзапроса в другую систему. Например, мы должны выполнить этот запрос:

-- Сумма продаж по строкам, где price > 100
SELECT SUM(amount) AS total_amount
FROM sales
WHERE price > 100;

Где таблица sales лежит, допустим, в паркете, и наш Parquet-reader умеет выполнять фильтрацию с простыми предикатами, как в примере WHERE price > 100, но агрегацию (SUM) придется продюсеру  делать самому. Для этого продюсер генерирует Substrait план и отправляет его консьюмеру плана:

{
 // Перечисление использующихся extensions (функций и типов)
 "extensions": [
    // namespase для extensions имеет anchor=1, это ссылка по которой функция привязывается к простанству имен
    { "extensionUri": { "uriAnchor": 1, "uri": "urn:substrait:functions_comparison" } },
    // Определение используемой функции “gt”, сокращенно “greater than”. Имеет anchor по которому на нее ссылаются ниже
    { "extensionFunction": { "functionAnchor": 1, "name": "gt", "uriReference": 1 } }
  ],

  "relations": [
    {
      // Дерево реляционных выражений
      "root": {
        "input": {
          "project": {  // Оператор проеции SELECT(amount) который откидывает лишние колонки
            "input": {
              "filter": {  // Оператор фильтра WHERE price > 100
                "input": {
                  "read": {  // Оператор чтения из таблицы и схема таблицы         
                    "namedTable": { "names": ["sales"] },
                    "baseSchema": {
                      "names": ["id", "price", "amount"],
                      "struct": {
                        "types": [
                          { "i64": {} },
                          { "i32": {} },
                          { "i32": {} }
                        ]
                      }
                    }
                  }
                },
                "condition": {  // Аргумент оператора filter - функция price > 100
                  "scalarFunction": {
                    "functionReference": 1,          // gt(x, y) - операция “Больше” с anchor=1 (см. в начале определние extensionFunction)
                    "arguments": [
                      {
                        "value": {
                          "selection": {
                            "directReference": { "structField": { "field": 1 } },  // поле price - поле #1
                            "rootReference": {}
                          }
                        }
                      },
                      { "value": { "literal": { "i32": 100 } } } // Литерал “100” типа int32
                    ]
                  }
                }
              }
            },
            "expressions": [ // Аргументоператора project - выбираем только колонку amount
              {                                          
                "selection": {
                  "directReference": { "structField": { "field": 2 } }, // amount - поле #2
                  "rootReference": {}
                }
              }
            ]
          }
        },
        "names": ["amount"] // финальная проекция узла root
      }
    }
  ]
}

Консьюмер плана, получив его, начинает исполнение и отдает обратно данные, а именно колонку amount, которая уже отфильтрована в соответствии с условием WHERE price > 100. Остается данные сагрегировать в SELECT SUM(amount) и вернуть пользователю итоговый результат. 

Итак, мы выполнили федеративный запрос без парсинга и оптимизации SQL, а так же без генерации SQL для подзапроса и мучений с диалектами.

Способы использования Substrait

В примере выше показан лишь один из сценариев, где Substrait был бы полезен. А именно, пушдаун подзапроса в другую систему. Но есть и другие кейсы использования Substrait.

push-down
push-down

Его мы только что видели выше, где одна система отправляет план подазпроса в другую (или в несколько других) систем. При этом подходе Database 1 должна хорошо понимать возможности других систем, чтобы случайно не отправить то, что они не могут выполнить. Например, в Parquet reader нельзя отправлять джойны или агрегаты, а проекции и простые фильтры можно.

Residual execution
Residual execution

В этом случае Database 1 не обязана знать возможности других систем. Но взаимодействие с Database 2 будет немного усложнено. Database 1 может отправить хоть целиком план запроса запроса в Database 2, и она уже сама решит: что возможно исполнить в ее рамках — она исполнит, а что нет, вернет в качестве остаточной (residual) части запроса, так же в формате Substrait, и остаточную часть уже будет исполнять Database 1.

В рамках одной системы
В рамках одной системы

Substrait может также использоваться и в рамках одной системы, заменяя собой внутренний IR для общения между планировщиком запросов и движком исполнения. Это может быть удобным для быстрой замены одного движка на другой при необходимости. Из существующих систем сейчас так работает Spark с движком нативного исполнения Gluten.

Кто поддерживает Substrait

Список активно пополняется. Проверяйте релизы на момент чтения.

Система / библиотека

Роль

Примечания

DuckDB (substrait extension)

producer & consumer

Конвертирует запросы в  планы, и обратно, умеет исполнять планы

Apache DataFusion

producer & consumer

Конвертация логических планов Substrait и обратно, Python‑биндинги

Velox

consumer

Конвертация Substrait в план Velox и исполнение

Arrow Acero

consumer

Интерфейс потребителя планов Substrait

Ibis / ibis‑substrait

producer

Генерация планов из Python DSL

R / dplyr (substrait‑r)

producer

Генерация планов из R

Gluten (Spark backend на Velox/и др.)

producer

Трансляция Spark physical plan в Substrait

Заключение

Substrait приближает нас к миру, где фронтенды (SQL/Ibis/dplyr/…​) и бэкенды (Velox/DuckDB/DataFusion/…​) можно свободно комбинировать. Это не серебряная пуля: придется договориться о семантике и профиле, наладить валидацию и наблюдаемость. Но выигрыш — в скорости интеграций и переносимости логики —  почти всегда стоит усилий.

Полезные ссылки

[1] Substrait site https://substrait.io/

[2] Substrait tutorial https://substrait.io/tutorial/sql_to_substrait/

[3] Substrait repo https://github.com/substrait-io/substrait/blob/main/extensions/extension_types.yaml

[4] Useful exmamples https://medium.com/@omri-levy/one-query-plan-three-different-engines-e5dc74aeb52f

Комментарии (1)


  1. Pavel_Agafonov
    10.11.2025 14:18

    Немного добавлю.

    Сейчас активно развивается adbc - что-то вроде jdbc/odbc, но нацеленное на columnar бдшки и работает с форматом Arrow. API предполагает возможность использования SQL или Substrait-а.