Современные распределённые системы — особенно базы данных — требуют сочетания высокой доступности, масштабируемости и согласованности. В своей статье «Versioning versus Coordination» Марк Брукер (Marc Brooker) показывает, как версионирование (multi-versioning) позволяет избавляться от дорогостоящей координации при чтении данных и достигать большей производительности. Ниже я поделюсь своим взглядом на ключевые идеи, упомянутые в этом материале, а также добавлю немного технических подробностей.
🏗️ Задача: распределённая СУБД с высокой параллельностью
Чтобы достичь высокой готовности и пропускной способности, мы обычно делим данные на шардированные сегменты и храним по нескольку реплик (для надёжности). Однако при параллельных операциях чтения и записи мы рискуем столкнуться с «гонками» данных и несогласованными результатами.
Например, две транзакции:
- 🍀 T1: читает строки id=1, id=2, id=3 и хочет видеть значения, существовавшие до любого апдейта.
- 🚀 T2: параллельно обновляет несколько строк, повышая значение value на 2.
Чтобы соблюсти логическую последовательность (например, сериализуемость), возникает классический вопрос: нужны ли блокировки и центральный диспетчер (lock manager), который скажет, что «T2» должна ждать окончания «T1»?
⏳ Подход с координацией (locking) против версионирования
Раньше классическое решение — блокировать строки для считывания, чтобы писатели (writers) ждали читателей (readers) и наоборот. Но есть недостатки:
- 🙅 Меньше параллелизма. Писатели блокируются при активных чтениях.
- 🌍 Где хранить глобальную информацию о блокировке при нескольких репликах? Приходится выбирать «центральный узел» или синхронно блокировать на всех репликах.
Версионирование (Multi-Version Concurrency Control — MVCC) предлагает иной путь:
- Новая транзакция (T2) не переписывает значения «на месте», а создаёт новую версию строк.
- Транзакция (T1), начавшаяся раньше, продолжает видеть старую версию, пока не завершится.
- Таким образом, T2 вообще не блокируется о «T1»; она просто пишет данные в новую «версию», видимую более поздним транзакциям.
Как следствие, не требуется глобальная координация лишь для чтения: читатели видят свою «снимок-версию» (snapshot), писатели не ждут читателей, и наоборот. Это даёт параллелизм, высокую пропускную способность и более стабильное время отклика.
📅 Физическое время и «момент выбора» версии
Чтобы транзакции знали, какую версию данных им читать, системам часто нужен «маркер времени». Самый простой подход — назначать специальный логический счётчик или использовать глобальные часы. Но глобальные счётчики могут стать «бутылочным горлышком» в распределённой среде.
- ⚙️ В Aurora DSQL (пример Марка) используют физические часы (в облачной среде типа AWS есть сервисы синхронизации с точностью до микросекунд). Это позволяет каждой транзакции брать «timestamp» без дополнительной координации.
- 🏷️ Чтение «as-of timestamp»: транзакция запрашивает строки, у которых version <= my_start_time. И сервер знает, какие версии нужны.
Таким образом, при регулярной синхронизации часов каждое чтение «знает», какую версию брать, а новая запись «пишет» свежую версию с текущим «timestamp», не мешая остальным.
🤔 Задача хранения версий: что с «мусором»?
Основная сложность MVCC в том, что система копит версии. Нужно как минимум хранить последнюю версию (для устойчивости и просмотра «последних» данных), а также версии, которые всё ещё могут быть нужны активным транзакциям:
- 💡 Если транзакция (T1) всё ещё не завершена, нужно хранить её «старое состояние» в базе. Иначе потеряем согласованность при чтении.
- 🧹 Когда транзакция завершается, соответствующие старые версии становятся неактуальными и могут быть удалены (через механизм сборки мусора «garbage collection»).
В некоторых системах делают глобальную координацию: «какой самый ранний несвободный timestamp у транзакций?». Но, как отмечает Марк, в Aurora DSQL этого избегают, жёстко ограничивая время жизни транзакции (например, 5 минут). За счёт этого не нужно вести постоянный учёт «всех таймстемпов», а лишь ждать, пока не истечёт лимит времени для «старых» транзакций.
🏆 Преимущества версионирования
MVCC позволяет «распараллелить» чтения и записи практически без конфликтов:
- 🌐 Масштабируемость: читатели могут читать с копий, не блокируясь о других транзакциях.
- 🚀 Высокая пропускная способность: «писатели» не ждут окончания «читающих».
- ⏱️ Устойчивое время отклика: минимизация пауз из-за блокировок.
По сути, мы «зашиваем» информацию о согласованном состоянии в версии данных, а не выводим её через дорогостоящую распределённую блокировку.
🏁 Выводы и ссылки
Марку удалось показать, что версионирование — мощный инструмент, который позволяет уйти от координации для подавляющего числа операций чтения. В больших распределённых БД это означает более низкие задержки, отсутствие «затыков» и проще логику. Конечно, само хранение версий и управление «garbage collection» имеют свою сложность, но этот подход в итоге оказывается эффективнее, чем глобальный менеджер блокировок.
Ссылки:
Таким образом, выбор в пользу версионирования позволяет эффективно обходиться без централизации и глобальной сериализации при чтении. Это особенно актуально для облачных систем, где синхронизация часов стала проще, а проекты наподобие Aurora наглядно демонстрируют, как практический «timestamp-based» подход решает ключевые проблемы распределённой согласованности.