tl;dr:

  • Каждая операция INSERT несет фиксированный overhead (в наших тестах 64–99 ms), независимо от количества строк.

  • Формула: Total_time = N_statements * fixed_overhead + actual_write_time – подтверждена тестами.

  • 1000 single-row INSERT = 64 секунды (Shared-data) или 100 секунд (Shared-Nothing).

  • Разница не в диске и не в Docker, а в протоколе commit: TxnLog + publish через BRPC против 2PC + publish_version.

  • В ANALYZE PROFILE commit overhead прячется в разнице TotalTime - ExecutionTime – это FE overhead.

  • Батчинг нивелирует разницу: при INSERT SELECT оба режима дают ~0.25 с на 1000 строк.

Введение

Изначально я хотел назвать статью: «INSERT в StarRocks: как данные попадают в storage и где появляется bottleneck». Но в итоге выбрал другое название. Оно лучше подходит для этой статьи и ниже станет понятнее, почему.

Для тех, кто не знаком с архитектурой StarRocks: кластер состоит из FE (Frontend, Java), который принимает SQL-запрос, разбирает, планирует, управляет транзакциями, и BE (Backend, C++), который выполняет запросы, пишет и читает данные. В режиме Shared-Nothing (классический) каждый BE хранит данные на локальном диске. В режиме Shared-data BE заменяется на CN (Compute Node) — он не хранит данные, а пишет в объектное хранилище. Это две разные архитектуры — и, как оказалось, с разной ценой на операции INSERT.

О чем и зачем эта статья:

Много лет в мире аналитических СУБД повторяют (и я в том числе) одну и ту же мысль: батч-загрузка почти всегда лучше одиночных вставок. Готовясь к вебинару про медленные запросы и инструменты диагностики StarRocks, я в процессе углубился в механику INSERT в StarRocks. И даже по этим знаниям вышли несколько постов в рубрике Release Digest в моем телеграм-канале @starrocks_selena.

Из этой статьи можно почерпнуть:

  • анатомию операции INSERT, анализ выполнения операций через команды ANALYZE PROFILE и пару интересных выводов.

  • Ключевой вывод этой статьи простой. В StarRocks стоимость INSERT часто определяется не тем, сколько строк вы пишете, а тем, сколько раз вы запускаете сам INSERT. 

  • У каждой операции есть фиксированный стартовый overhead, который платится даже за одну строку: Total_time = N_statements * fixed_overhead + actual_write_time. Поэтому 10 000 одиночных INSERT легко превращаются в сотни секунд, а один батч на 10 000 строк в секунды.

Собрав все эти мысли, я решил все проверить на практике. Я развернул single instance кластер, чтобы прогнать небольшой тест, записать результаты и… пропал на несколько дней. А по-настоящему интересное началось, когда я запустил тот же бенчмарк на трех разных кластерах и результаты оказались не теми, что я думал.

Три важных уточнения, которые лучше написать тут (в начале статьи):

  1. Все замеры сделаны на минимальной конфигурации: 1 FE + 1 BE (или 1 CN). Это не production-инсталляция, а намеренное упрощение. На одной ноде нет сетевых задержек между BE, нет overhead репликации — и мы видим чистую разницу между протоколами commit. В реальном кластере с тремя BE и replication_num=3 overhead Shared-Nothing будет только выше: дальше вы поймете, почему. Если эта тема интересна – пишите в комментариях, и я повторю тесты на 3-нодовом кластере Bare Metal и в Kubernetes.

  2. Ни один параметр StarRocks не менялся — все на дефолтных значениях из коробки. Это важно: мы исследуем архитектурный overhead, а не ищем оптимальную конфигурацию. Тюнинг write_buffer_size, max_running_txn_num_per_db или thread пулов мог бы сдвинуть цифры, но тогда возникает вопрос, что именно мы измерили: архитектуру или конфигурацию? Дефолтные значения дают честный baseline, с которым столкнется каждый новый пользователь.

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

Первый тест: Docker + Shared-data

Для начала я взял самое простое окружение — это развертывание в Docker-контейнере в режиме Shared-Data (1xFE + 1xCN + MinIO).

Окружение:

  • StarRocks 4.0.5

  • Shared-data (Docker)

  • CPU: Intel Xeon 8370C @ 2.80 GHz, 4 vCPU

  • RAM: 16 GB

  • Диск: ~166 MB/s (посчитал dd тестом)

  • 1xFE + 1xCN, MinIO для объектного хранилища.

Создал тестовую таблицу:

CREATE TABLE test_insert (
    id BIGINT,
    name VARCHAR(100),
    amount DECIMAL(10,2),
    dt DATE
)
DISTRIBUTED BY HASH(id) BUCKETS 8
PROPERTIES ("replication_num" = "1");

Дальше мы запустим обычный python-скрипт, который делает операцию INSERT 1000 строк разными способами и замерим результаты этих запусков. И ради интереса измерим этот же объем в stream load. Получилась вот такая, простенькая таблица:

Сценарий

Rows

Операций

Время

Throughput

Single-row INSERT × 1000

1000

1000

64.39 s

16 rows/s

Batch INSERT (100 rows) × 10

1000

10

0.823

1236 rows/s

Batch INSERT (1000 rows) × 1

1000

1

0.326 s

3069 rows/s

INSERT SELECT (1000 rows)

1000

1

0.250 s

4001 rows/s

Stream Load (1000 rows)

1000

1

0.126 s

7937 rows/s

Как можно видеть по результатам из таблицы, Stream Load — абсолютный чемпион.

Вы скажете: «Ничего удивительного — все логично». Но мне стало интересно другое:

Скрипт замеряет каждый single-row INSERT отдельно и считает медиану: 64,2 мс на один INSERT (P95: 80,9 мс, P99: 113,4 мс). Эта цена практически не зависит от числа строк внутри. Посмотрите, из вывода скрипта получается, что "Batch INSERT (1000 rows) × 1" занимает всего 0,326 с, т. е. одна запись занимает около 0,3 мс. Получается, из 64 мс реальная запись данных занимает доли миллисекунд. Все остальное overhead?

Эти результаты, конечно, многое объясняют, но чтобы точно двигаться дальше, я решил развернуть также single instance, но уже без docker и в Shared-Nothing режиме он-то должен быть быстрее по определению.

Второй тест: Bare-Metal и Shared-Nothing

Docker добавляет слой виртуализации, а MinIO сетевой overhead для Object Storage. Настоящий Shared-Nothing кластер с локальным SSD должен быть быстрее по всем параметрам. Верно?

А точнее, не всегда.

Окружение:

  • StarRocks 4.0.4 -- не сразу заметил, что версии то разные с Docker инсталляцией.

  • Shared-Nothing (bare-metal)

  • CPU: Intel Xeon 8272CL @ 2.60 GHz, 4 vCPU

  • RAM: 16 GB

  • Диск: ~151 MB/s (также посчитал dd тестом)

  • 1xFE + 1xBE, локальное хранение

Запускаю тот же тест, виж�� результат и сразу сравню его для наглядности с первым:

Сценарий

Shared-Data (Docker)

Shared-Nothing (BM)

Дельта

Single-row x 1000

64.39 s (median 64.2 ms)

100.12 s (median 98.6 ms)

55%

Batch 100 x 10

0.823 s (1,215 rows/s)

0.983 s (1,017 rows/s)

19%

Batch 1000 x 1

0.326 s (3,069 rows/s)

0.304 s (3,288 rows/s)

-7%

INSERT SELECT

0.250 s (4,001 rows/s)

0.227 s (4,401 rows/s)

-9%

Минуточку, Shared-Nothing у нас на 55% медленнее Docker-а на "Single-row x 1000"? Я перепроверил несколько раз, даже узлы рестартовал, результат плюс-минус такой же.

Также меня заинтересовали нижние две строчки этой таблицы. При "batch INSERT (1000 строк одной операцией)" и "INSERT SELECT" — Shared-Nothing явно быстрее. Разница стабильная и дельта между медианами примерно 35 ms на каждый single row INSERT. Похоже, это постоянный overhead.

Дальше я замечаю, что версии-то у меня разные, всего на один патч, но, может, из-за него все дело, заглядываю в changelog патча 4.0.5 https://github.com/StarRocks/starrocks/releases/tag/4.0.5, нахожу issue: 

Added thread pool for Publish Version transactions. Вот ссылка: https://github.com/StarRocks/starrocks/pull/67797.

Выглядит похоже, publish_version как раз часть commit protocol. Я, недолго думая, разворачиваю третий single-instance кластер.

Окружение:

  • StarRocks 4.0.5

  • Shared-Nothing (bare-metal)

  • CPU: Intel Xeon 8272CL @ 2.60 GHz, 4 vCPU

  • RAM: 16 GB

  • Диск: ~151 MB/s (также посчитал dd тестом)

  • 1xFE + 1xBE, локальное хранение

Результат:

Сценарий

Shared-Nothing 4.0.4

Shared-Nothing 4.0.5

Дельта

Single-row × 1000

100.12 s (median 98.6 ms)

100.09 s (median 99.3 ms)

~0%

Batch 100 × 10

0.983 s

1.006 s

~0%

Batch 1000 × 1

0.304 s

0.354 s

~0%

INSERT SELECT

0.227 s

0.277 s

~0%

Нет, результат такой же, 4.0.5 для моего маленького теста на single-node ничего не изменил.

Видимо, этот bug-fix действительно для multicluster конфигурации. Ну остается сделать вывод, что причина архитектурная, проблема в самом протоколе commit. И я решился на погружение в дебри StarRocks, чтобы понять, что вообще происходит.

Заглядываем внутрь:

StarRocks умеет показывать внутренний профиль выполнения запроса. Чтобы увидеть полную картину, включая время commit, я использовал метод с enable_profile:

SET enable_profile = true;
INSERT INTO test_insert VALUES (999999, 'test', 1.00, '2024-01-15');
SHOW PROFILELIST LIMIT 1;
ANALYZE PROFILE FROM '019c32b5-6e93-7274-9033-19224297d3ae';

Важный нюанс. 

Есть два способа профилирования INSERT:

  1. EXPLAIN ANALYZE INSERT — откатывает транзакцию и не показывает commit. Это подтверждается документацией: https://docs.starrocks.io/docs/sql-reference/sql-statements/cluster-management/plan_profile/EXPLAIN_ANALYZE

  2. enable_profile + ANALYZE PROFILE — метод захватывает полный цикл, включая commit.

Дальше я запустил скрипт ( "test_components.py" ссылку на github со скриптами приложу в комментариях немного позже), который в том числе прогоняет автоматически 30 INSERT с включенным профилем на каждом кластере и берет медиану по каждой метрике. Вот что получилось:

Метрика

Shared-data (Docker, 4.0.5)

Shared Nothing (BM, 4.0.5)

Разница

TotalTime

67.5 ms

100 ms

+32.5 ms

FE overhead (parse + plan + txn + commit)

40 ms

83 ms

43 ms

ExecutionTime (BE)

27.9 ms

17.3 ms

-10.6 ms

OLAP_TABLE_SINK

0.12 ms (109 us)

0.10 ms (100 us)

~0

Общий замер времени от клиента (benchmark)

67.0 ms

99.3 ms

+32.3 ms

Профиль дает две ключевые метрики: Total time (полное время, как видит FE) и Execution time (только часть BE). И мы можем сразу посчитать FE overhead (parse + plan + txn + commit) — разница между двумя значениями Total time и Execution time, это то, что делает FE: парсинг, планирование, открытие и фиксация результатов транзакции. Видим, что BE на Shared-Nothing работает быстрее (17 ms против 28 ms — это понятно, локальный SSD выигрывает у MinIO).

Но FE overhead в Shared-Nothing вдвое больше (83 против 40). С учетом того, что BE компенсирует часть разницы (-10 ms за счёт локального SSD), разница между двумя архитектурами 33 ms по профилю и 35 ms по фактическому замеру.

Осталось выяснить, на что FE в Shared-Nothing тратит дополнительные 43 миллисекунды. Для этого нужно разобраться, как устроен INSERT внутри.

Анатомия INSERT

Когда вы выполняете простой INSERT (обычный простой в режиме shared nothing, при котором используется LocalTabletsChannel)

INSERT INTO sales (dt, product_id, amount) VALUES ('2024-01-15', 1001, 99.99);

Вот такой pipeline происходит, сделал таблицу с шагами, надеюсь и в других кейсах будет вам полезна:

Слой

Этап

Компонент

Что делает

Выход/артефакт

FE (Java)

1

SqlParser

Парсит SQL (ANTLR4), строит AST

AST

FE (Java)

2

TransactionMgr

BEGIN TRANSACTION (до анализа)

txn_id

FE (Java)

3

InsertAnalyzer

Определяет таблицу / partitions / tablets

target partitions + tablet list

FE (Java)

4

InsertPlanner

Оптимизация + планирование фрагментов

fragments + sink plan

FE (Java)

5

OlapTableSink

Инициализирует sink (schema, partitions)

sink descriptor

FE (Java)

6

Coordinator

Отправляет фрагменты на BE/CN (BRPC)

execution dispatch

BE/CN (C++)

7

UnionConstSourceOperator

Формирует Chunk из VALUES-литералов

Chunks

BE/CN (C++)

8

OlapTableSinkOperator

Pipeline sink → запускает write path

write pipeline started

BE/CN (C++)

8.1

TabletSinkSender

Маршрутизация по BE-нодам

per-node streams

BE/CN (C++)

8.2

LoadChannel

Канал загрузки на принимающем BE

load session

BE/CN (C++)

8.3

TabletsChannel

Распределение строк по tablet_id

per-tablet batches

BE/CN (C++)

8.4

DeltaWriter ×N

По одному на каждый tablet

delta segments

BE/CN (C++)

8.5

MemTable

In-memory буфер

buffered rows

BE/CN (C++)

8.6

SegmentWriter

Flush на диск / Object Storage

segment files

BE/CN (C++)

8.7

Rowset

Подготовлен к commit

rowset + tablet commit info

FE (Java)

9

PREPARE

Валидирует TabletCommitInfo от BE

prepare ok

FE (Java)

10

COMMIT

Persist в журнал (BDBJE / EditLog)

committed txn

FE (Java)

11

VISIBLE

publish_version (ключевое отличие тут)

data visible

Optimizer вызывается даже для VALUES, но план очень простой (LogicalValuesOperator → sink), поэтому оптимизация мгновенна. Для INSERT SELECT здесь работает полноценный Cost Based Optimizer (CBO): join reorder, predicate pushdown и т.д.

Обратите внимание на порядок: транзакция начинается до анализа запроса. А commit — это три фазы (PREPARE → COMMITTED → VISIBLE), которые происходят после того, как BE подтвердил запись данных. Именно фаза VISIBLE отличается между архитектурами:

  • Для Shared Nothing: PublishVersionTask — FE отправляет RPC на все BE в кластере. BE применяет версию к локальным репликам и подтверждает.

  • Для Shared-data: TxnLog + publish через BRPC — FE записывает лог, данные уже в Object Storage, координация реплик не нужна.

Write path до Rowset в целом идентичен в обоих режимах. Разница только LocalTabletsChannel (Shared Nothing, локальный диск) против LakeTabletsChannel (Shared-data, Object Storage).

Напомню, ранее ANALYZE PROFILE показал, что FE overhead на Shared Nothing на 43 мс больше и вот почему:

  1. Shared-data: FE на commit_txn сохраняет запись в журнале (persist). Дальше PublishVersionDaemon отправляет BRPC publishVersion только тем Compute Node, которые обслуживают затронутые tablets. Каждый CN сам читает свой TxnLog из S3, применяет изменения и после этого данные становятся VISIBLE. Ключевое: один лёгкий RPC на узел, а тяжёлое — чтение и apply — делает сам CN по своему логу в object storage.

  2. Shared Nothing. FE на commit_txn:

    1. фиксирует метаданные в BDBJE.

    2. рассылает RPC publish_version на все BE.

    3. ждёт подтверждения от BE, что версия применена.

    4. только после этого данные становятся VISIBLE.

Почему это важно при single-row INSERT?

При batch INSERT или INSERT SELECT, одна транзакция на 1000 строк и разница незаметна. Но при single-row INSERT каждая операция — это же отдельная транзакция. FE commit стоит на 43 мс дороже, но BE на Shared Nothing работает на ~10 мс быстрее (локальный SSD).

Масштаб можно аппроксимировать:

Количество операций

Доп. overhead Shared-Nothing vs Shared-data

На практике

1

+35 ms

Незаметно

10

+0.35 s

Терпимо

100

+3.5 s

Ощутимо

1000

+36 s

64 s → 100 s в нашем тесте

10000

+5.8 мин

Критично

100000

+58 мин

Почти час дополнительных потерь

Вот почему результат изменяется при батчинге:

  • При N=1000 single-row: Shared Nothing медленнее (100 s vs 64 s) — commit overhead доминирует.

  • При batch 1000x1: Shared Nothing немного быстрее (0.304 s vs 0.326 s) — один commit, а локальный SSD выигрывает.

  • При INSERT SELECT: Shared Nothing немного быстрее (0.227 s vs 0.250 s) — та же логика.

Батчинг не просто ускоряет INSERT. Он устраняет разницу между архитектурами.

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

Профиль показывает, что bottleneck — в commit на FE, а write path (DeltaWriter/MemTable/SegmentWriter) почти не влияет. Но чтобы не ошибиться, я специально проверю альтернативную гипотезу: а вдруг storage pipeline всё-таки влияет, просто в базовом тесте это не проявилось? Поэтому меняю по одному параметру таблицы и смотрю, сдвигается ли время INSERT.

Тот же Python-скрипт (test_components.py) запускает 200 single-row INSERT (+20 на прогрев), меряет время от и до (сюда включается всё: сеть, парсинг, планирование, запись, commit). Для изоляции компонентов меняем один параметр за раз:

Тест 1 (проверяем DeltaWriter): таблицы с 1, 4, 8, 16, 32, 64 buckets — каждый bucket = отдельный DeltaWriter.

Тест 2 (проверяем SegmentWriter): таблицы с 4, 20, 50 колонками при одном bucket, т. е. больше колонок = больше индексов.

Тест 3 (проверяем MemTable): DUPLICATE vs AGGREGATE vs UNIQUE при одном bucket.

Результат Shared-data (Docker 4.0.5):

Тест

Параметр

Median

Дельта

Вывод

Buckets

1 → 64

60 → 70 ms

+9.5 ms на 63 tablet

~0.15 ms/tablet

Columns

4 → 50

62 → 65 ms

+2.3 ms на 46 колонок

~0.05 ms/column

Model

DUP / AGG / UNIQUE

64 / 59 / 64 ms

±5 ms

~0 ms

Результат Shared-Nothing (Bare-Metal 4.0.5):

Тест

Параметр

Median

Дельта

Вывод

Buckets

1 → 64

98 → 98 ms

+0 ms

0 ms/tablet

Columns

4 → 50

99 → 100 ms

+1.0 ms

~0.02 ms/column

Model

DUP / AGG / UNIQUE

99 / 98 / 99 ms

±0.2 ms (шум)

~0 ms

Storage pipeline не виновник. OLAP_TABLE_SINK (весь write path от LoadChannelMgr до Rowset) занимает ~0.1 ms на обоих кластерах. Ни buckets, ни модель таблицы, ни число колонок не влияют значимо. Вся разница в FE commit, как мы и установили ранее.

Мы сделали дополнительную проверку и утвердили свои гипотезы. Теперь можно и к выводам!

Практические рекомендации:

Когда что использовать. Самый главный вопрос: откуда данные?

  • Внешний файл / S3 → Stream Load (или Broker Load)

  • Другая таблица внутри БД → INSERT SELECT

  • Приложение пишет в real-time → зависит от объема:

  • Меньше 100 rows/s → INSERT VALUES (batch!)

  • Больше 100 rows/s → Stream Load или Routine Load

Но тут сделаем небольшое отступление и отдадим дань Flink, я люблю Flink и буду делать отсылки к нему в каждой статье:

  1. Flink-StarRocks Connector (рекомендуемый) использует Stream Load под капотом. Коннектор накапливает строки в буфере и периодически делает flush через Stream Load HTTP API. Это обходит INSERT overhead полностью, тот же механизм, что дал нам 7937 rows/s в тестах.

  2. Flink JDBC Connector использует INSERT VALUES под капотом. Вот тут как раз будет тот самый overhead из статьи. Если Flink шлет по одной строке, получите 16 rows/s вместо тысяч.

  3. Flink → Kafka → Routine Load Flink пишет в Kafka, StarRocks сам забирает через Routine Load. Непрямой путь, но полностью развязанный.

Отсюда дополнение к пункту, когда что использовать:

Flink / Spark → Flink-StarRocks Connector (использует Stream Load внутри). Старайтесь не использовать JDBC Connector, он делает INSERT VALUES и упирается в overhead.

Несколько практических рекомендаций по оптимизации INSERT:

  1. Старайтесь не использовать single-row INSERT в production-нагрузке (даже если разработчики Flink говорят, что по-другому нельзя).

  2. Batch INSERT: группируйте по 100–1000 строк в одну операцию.

  3. INSERT SELECT: используйте для ETL внутри StarRocks.

  4. Stream Load: для загрузки из внешних источников.

  5. Routine Load: для потоковой загрузки из Kafka.

  6. Число buckets не влияет на INSERT overhead (наши тесты: 1 vs 64 buckets = ±0 ms)

  7. В ANALYZE PROFILE смотрите не только TotalTime, но и TotalTime - ExecutionTime — это FE overhead, где прячется commit.

Заключение

Каждая операция INSERT несет фиксированный overhead (в наших тестах 64–99 ms), независимо от количества строк.

  1. 1000 single-row INSERT = 64 секунды (Shared-data) или 100 секунд (Shared-Nothing).

  2. Формула работает: Total_time = N_statements * fixed_overhead + actual_write_time.

  3. Где fixed_overhead = execute_time + commit_time, а commit_time зависит от режима Shared-Nothing и Shared Storage.

  4. Storage pipeline не bottleneck. OLAP_TABLE_SINK = 0.1 ms. DeltaWriter, MemTable, SegmentWriter вместе микросекунды. 99.8% overhead — это FE (parse, plan, txn, commit) и BE pipeline framework.

  5. В ANALYZE PROFILE смотрите TotalTime - ExecutionTime. Это FE overhead, где прячется commit protocol: 40 ms (Shared-data) vs 83 ms (Shared-Nothing).

  6. Shared-Nothing дороже на single-row INSERT из-за 2PC + publish_version (+43 ms FE).

  7. Но при батчинге разница исчезает и Shared-Nothing даже чуть быстрее за счёт локального SSD.

  8. Батчинг — это главное решение в обоих режимах. INSERT SELECT или batch INSERT VALUES на 1000 строк дают throughput в 200x больше, чем single-row.

Что я использовал для написания статьи:

  1. INSERT Best Practices -  https://docs.starrocks.io/docs/loading/InsertInto/

  2. Stream Load Documentation - https://docs.starrocks.io/docs/loading/StreamLoad/

  3. Routine Load для Kafka - https://docs.starrocks.io/docs/loading/RoutineLoad/