Модель конкурентности: один процесс, много потоков
В предыдущем уроке мы разобрали MVCC — механизм, благодаря которому читатели и писатели не блокируют друг друга. Но MVCC отвечает на вопрос «как», а не «кто». Этот урок — про модель конкурентности целиком: сколько процессов, сколько потоков, кто и что может делать одновременно. Это та область, где у DuckDB больше всего расхождений с интуицией, выработанной на PostgreSQL или MySQL.
PostgreSQL — это сервер: отдельный демон, к которому по сети подключаются сотни клиентов, и каждый клиент получает свой backend-процесс. DuckDB устроена принципиально иначе. Понимание этой разницы определяет, какие задачи DuckDB решит идеально, а какие — провалит.
Две оси конкурентности
Чтобы не запутаться, разделим два разных вопроса.
Ось 1 — конкурентность внутри одного запроса. Один SELECT может исполняться на всех ядрах CPU сразу. DuckDB разбивает запрос на параллельные задачи и раскидывает их по потокам. Это — про скорость одного запроса.
Ось 2 — конкурентность между запросами и соединениями. Сколько одновременных соединений, сколько писателей, сколько процессов. Это — про многопользовательский доступ.
DuckDB агрессивно параллелит по оси 1 и сознательно ограничивает ось 2. Именно перестановка этих осей местами — корень большинства недоразумений: люди ждут от DuckDB поведения сервера (много писателей) и не ждут многопоточности одного запроса (а она по умолчанию включена).
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 могут читать параллельно из разных потоков приложения
Каждое соединение — это своя транзакционная единица со своим снимком. Два соединения, читающие одновременно, не мешают друг другу вообще.
В 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.
Конкурентные чтения: предел масштабирования
Читателей DuckDB обслуживает почти без ограничений. Несколько соединений внутри процесса, несколько read-only процессов на один файл — каждое читающее соединение получает свой снимок через MVCC и не конкурирует с другими ни за блокировки, ни за версии.
Но «много читателей» здесь означает не «много мелких запросов в секунду», как в OLTP, а «несколько тяжёлых аналитических запросов, каждый из которых сам по себе утилизирует CPU». DuckDB не оптимизирована под десятки тысяч мелких point-lookup в секунду; она оптимизирована под параллельный прогон тяжёлой аналитики. Если каждый запрос и так грузит все ядра, то десять одновременных запросов будут конкурировать за CPU независимо от модели изоляции — это уже вопрос планирования нагрузки, а не транзакций.
| Сценарий | Подходит DuckDB? | Почему |
|---|---|---|
| 1 ETL-писатель + N читающих дашбордов | Да, идеально | Single-writer + конкурентные чтения через MVCC |
| Многопоточная аналитика в одном приложении | Да | cursor() на соединение для каждого потока |
| Несколько процессов читают общий файл | Да, в read-only | MVCC, у каждого свой снимок |
| Сотни клиентов с мелкими 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 на практике.
- Создай файл базы и таблицу
accounts(id INTEGER, balance INTEGER)с парой строк. - Открой два соединения к одной базе через
db = duckdb.connect("bank.db"), затемa = db.cursor()иb = db.cursor(). - В соединении
aвыполниBEGIN, затемUPDATEстроки сid = 1. Не коммить. - В соединении
bвыполниBEGINиUPDATEстроки сid = 2. Закоммитьb, потомa— обе должны пройти. Объясни, почему конфликта нет. - Повтори эксперимент, но в шаге 4 пусть
bобновляет ту же строкуid = 1. Закоммитьaпервым, потомb. Какую ошибку вернётb? Перепиши кодbтак, чтобы при конфликте он делалROLLBACKи повторялUPDATEна свежем снимке.