Learning Platform
Глоссарий Troubleshooting
Урок 13.02 · 21 мин
Средний
concurrencythreadssingle-writerisolation

Модель конкурентности: один процесс, много потоков

В предыдущем уроке мы разобрали MVCC — механизм, благодаря которому читатели и писатели не блокируют друг друга. Но MVCC отвечает на вопрос «как», а не «кто». Этот урок — про модель конкурентности целиком: сколько процессов, сколько потоков, кто и что может делать одновременно. Это та область, где у DuckDB больше всего расхождений с интуицией, выработанной на PostgreSQL или MySQL.

PostgreSQL — это сервер: отдельный демон, к которому по сети подключаются сотни клиентов, и каждый клиент получает свой backend-процесс. DuckDB устроена принципиально иначе. Понимание этой разницы определяет, какие задачи DuckDB решит идеально, а какие — провалит.


Две оси конкурентности

Чтобы не запутаться, разделим два разных вопроса.

Ось 1 — конкурентность внутри одного запроса. Один SELECT может исполняться на всех ядрах CPU сразу. DuckDB разбивает запрос на параллельные задачи и раскидывает их по потокам. Это — про скорость одного запроса.

Ось 2 — конкурентность между запросами и соединениями. Сколько одновременных соединений, сколько писателей, сколько процессов. Это — про многопользовательский доступ.

DuckDB агрессивно параллелит по оси 1 и сознательно ограничивает ось 2. Именно перестановка этих осей местами — корень большинства недоразумений: люди ждут от DuckDB поведения сервера (много писателей) и не ждут многопоточности одного запроса (а она по умолчанию включена).

Две оси конкурентности в DuckDB
Один запросSELECT исполняется параллельно на всех ядрах через morsel-driven parallelism
агрессивно параллелит
Все ядра CPUПо умолчанию DuckDB задействует столько потоков, сколько логических ядер у машины
Много соединенийНесколько Connection внутри одного процесса; писать может одно из них в каждый момент
сознательно ограничивает
Один писательSingle-writer: одна транзакция изменяет данные, остальные читают

Single-process: база живёт внутри хост-процесса

DuckDB — in-process (embedded) СУБД. Нет демона, нет сетевого порта, нет отдельного процесса базы. Когда вы пишете import duckdb; con = duckdb.connect("data.db"), движок базы загружается прямо в адресное пространство вашего Python-процесса. Запрос — это вызов функции, а не сетевой round-trip.

Отсюда следствия:

  • Файл базы открыт одним процессом. Этот процесс держит его на запись.
  • Другой процесс может открыть тот же файл только в read-only режиме.
  • In-memory база (duckdb.connect() без пути) полностью приватна процессу и исчезает с его завершением.
  • Падение хост-процесса = закрытие базы. Durability обеспечивает WAL: закоммиченные данные восстановятся при следующем открытии.

Это не недостаток, а определение категории. SQLite устроена так же и стала самой распространённой СУБД в мире именно благодаря embedded-модели. DuckDB — её аналитический аналог.


Multi-threaded: один процесс, много потоков

Внутри единственного процесса DuckDB активно использует потоки. По умолчанию число потоков равно числу логических ядер машины. Проверить и изменить:

SELECT current_setting('threads');
-- 8   (на 8-ядерной машине)

SET threads = 4;  -- ограничить четырьмя
SELECT current_setting('threads');
-- 4

Эти потоки используются двояко.

Во-первых, один запрос параллелится по потокам. Сканирование таблицы, hash join, агрегация — всё это разбивается на morsel-ы (небольшие порции работы) и раздаётся потокам. Подробно morsel-driven parallelism разбирался в модуле про параллелизм; здесь важно лишь то, что это естественное состояние: даже простой SELECT count(*) на большой таблице задействует все ядра.

Во-вторых, несколько соединений (Connection) внутри одного процесса могут выполнять запросы параллельно. Можно создать пул соединений к одной базе и гонять по ним запросы из разных потоков приложения.

import duckdb

# Одна база, два соединения внутри одного процесса
db = duckdb.connect("warehouse.db")
con_a = db.cursor()   # независимое соединение
con_b = db.cursor()   # ещё одно

# con_a и con_b могут читать параллельно из разных потоков приложения

Каждое соединение — это своя транзакционная единица со своим снимком. Два соединения, читающие одновременно, не мешают друг другу вообще.

TIP

В Python-API метод cursor() на объекте соединения создаёт новое независимое соединение к той же базе. Это рекомендуемый способ безопасно использовать одну базу из нескольких потоков приложения: каждому потоку — свой cursor.


Single-writer: пишет один

Теперь — главное ограничение. В каждый момент времени изменять данные может только одна транзакция. Читать могут сколько угодно, писать — один.

Это не значит, что параллельная запись «падает с ошибкой» в любом случае. Если два соединения внутри процесса пишут в разные таблицы или в разные строки, обе записи проходят: их транзакции не конфликтуют. Конфликт — и ошибка — возникает только при пересечении: когда две транзакции меняют одну и ту же строку. Тогда работает «first committer wins», вторая получает conflict error и должна повторить.

-- Соединение A
BEGIN; UPDATE inventory SET qty = qty - 1 WHERE sku = 'A-100';

-- Соединение B параллельно, ДРУГАЯ строка
BEGIN; UPDATE inventory SET qty = qty - 1 WHERE sku = 'B-200';

COMMIT;  -- A: OK
COMMIT;  -- B: OK, конфликта нет, строки разные
-- А вот так будет конфликт:
-- A: BEGIN; UPDATE inventory SET qty = 0 WHERE sku = 'A-100';
-- B: BEGIN; UPDATE inventory SET qty = 5 WHERE sku = 'A-100';
-- A: COMMIT;  -> OK
-- B: COMMIT;  -> Conflict on update: row was modified by another transaction

Практический смысл: DuckDB прекрасно подходит для нагрузки «один пишущий пайплайн + много читающих дашбордов». Она плохо подходит для нагрузки «сотни клиентов, каждый делает мелкий INSERT/UPDATE в реальном времени» — это OLTP-сценарий для PostgreSQL.

Конкурентный доступ внутри процесса
ETL-пайплайнЕдинственный писатель: загружает и трансформирует данные; его транзакции изменяют таблицы
пишет через MVCC, не блокируя читателей
Дашборд BIЧитающее соединение, видит стабильный снимок
Ноутбук аналитикаЕщё один читатель, свой снимок, не мешает остальным
API-запросЧитающее соединение из веб-сервиса

Конкурентные чтения: предел масштабирования

Читателей DuckDB обслуживает почти без ограничений. Несколько соединений внутри процесса, несколько read-only процессов на один файл — каждое читающее соединение получает свой снимок через MVCC и не конкурирует с другими ни за блокировки, ни за версии.

Но «много читателей» здесь означает не «много мелких запросов в секунду», как в OLTP, а «несколько тяжёлых аналитических запросов, каждый из которых сам по себе утилизирует CPU». DuckDB не оптимизирована под десятки тысяч мелких point-lookup в секунду; она оптимизирована под параллельный прогон тяжёлой аналитики. Если каждый запрос и так грузит все ядра, то десять одновременных запросов будут конкурировать за CPU независимо от модели изоляции — это уже вопрос планирования нагрузки, а не транзакций.

СценарийПодходит DuckDB?Почему
1 ETL-писатель + N читающих дашбордовДа, идеальноSingle-writer + конкурентные чтения через MVCC
Многопоточная аналитика в одном приложенииДаcursor() на соединение для каждого потока
Несколько процессов читают общий файлДа, в read-onlyMVCC, у каждого свой снимок
Сотни клиентов с мелкими INSERT/UPDATEНетOLTP-нагрузка; нужен сервер вроде PostgreSQL
Многопроцессная конкурентная запись в один файлНетSingle-process на запись; смотрите MotherDuck/DuckLake

Когда нужно больше: куда расти

Если задача упёрлась в single-writer или single-process, это сигнал выйти за рамки локального файла, а не «чинить» DuckDB.

  • MotherDuck — managed-облако на базе DuckDB. Снимает ограничение на конкурентный доступ через серверную координацию, сохраняя движок и SQL-диалект DuckDB.
  • DuckLake с PostgreSQL-каталогом — лейкхаус-формат, где метаданные и координацию транзакций между процессами берёт на себя реляционная база-каталог (PostgreSQL поддерживает настоящую многопользовательскую запись). Несколько процессов DuckDB пишут в общий лейкхаус, конфликты разрешаются на уровне каталога.

Обе опции подробно разбираются в модуле «DuckDB everywhere». Здесь важен принцип: модель конкурентности DuckDB — это осознанный выбор embedded-движка, а масштабирование за её пределы — это смена слоя, а не настройка флага.


Read-only режим: зачем он нужен

Отдельно стоит разобрать read-only режим — он не «урезанная версия», а полезный инструмент. Открыть базу только для чтения можно явно: флагом -readonly у CLI или параметром при подключении в API.

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

# read-only подключение в Python
con = duckdb.connect("warehouse.db", read_only=True)
# любой INSERT/UPDATE/DELETE через это соединение завершится ошибкой

То есть read-only — это и обходной путь для конкурентного доступа к занятому файлу, и сознательная мера безопасности для компонентов, которым запись не нужна.


Попробуй сам

В Python воспроизведи single-writer на практике.

  1. Создай файл базы и таблицу accounts(id INTEGER, balance INTEGER) с парой строк.
  2. Открой два соединения к одной базе через db = duckdb.connect("bank.db"), затем a = db.cursor() и b = db.cursor().
  3. В соединении a выполни BEGIN, затем UPDATE строки с id = 1. Не коммить.
  4. В соединении b выполни BEGIN и UPDATE строки с id = 2. Закоммить b, потом a — обе должны пройти. Объясни, почему конфликта нет.
  5. Повтори эксперимент, но в шаге 4 пусть b обновляет ту же строку id = 1. Закоммить a первым, потом b. Какую ошибку вернёт b? Перепиши код b так, чтобы при конфликте он делал ROLLBACK и повторял UPDATE на свежем снимке.
Уровни изоляции SQL: READ COMMITTED vs SNAPSHOT ISOLATION
Проверка знанийKnowledge check
Студент утверждает: «DuckDB однопоточная, потому что в неё может писать только один». Что в этом утверждении верно, а что — ошибка?
ОтветAnswer
Утверждение смешивает две независимые оси конкурентности. Верная часть: DuckDB действительно single-writer — изменять данные в каждый момент может только одна транзакция (модель one writer, many readers). Ошибочная часть: из этого не следует, что DuckDB однопоточная. DuckDB агрессивно многопоточная по другой оси — внутри одного запроса. Любой SELECT (даже простой count) разбивается на morsel-ы и исполняется параллельно на всех логических ядрах машины; число потоков по умолчанию равно числу ядер. Кроме того, несколько читающих соединений внутри процесса работают параллельно. Single-writer ограничивает конкурентную запись между транзакциями, а не параллелизм исполнения. Это разные вещи: одна про многопользовательский доступ, другая про скорость одного запроса.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Сколько потоков по умолчанию использует DuckDB для исполнения одного запроса?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6