Назад к блогу

Aurora DSQL получает поддержку внешних ключей: что это меняет для распределённых баз данных

Aurora DSQL получает поддержку внешних ключей: что это меняет для распределённых баз данных

Amazon сделала внешние ключи частью Aurora DSQL, устранив главный барьер между этой распределённой базой и привычным Postgres. Разбираем, почему проверка ссылочной целостности устроена здесь принципиально иначе, чем в классических СУБД, и как эта механика отражается на производительности и архитектуре системы.

Aurora DSQL — PostgreSQL-совместимая распределённая база, которая до недавнего времени не поддерживала внешние ключи. Отсутствие FK называли самым большим разрывом между DSQL и «обычным» Postgres, и это блокировало многие миграции. Теперь ограничения поддерживаются, но механика их проверки в распределённой системе принципиально отличается от привычной: вместо блокировок работают снимки и обнаружение конфликтов на коммите. Разберём, как это устроено и что из этого следует.

Почему отсутствие внешних ключей было блокером

В обсуждении «Amazon wasted their time building DSQL» нехватка FK упоминалась как один из главных блокеров внедрения PostgreSQL-совместимой распределённой базы. Пользователь SteveTabernacle2 утверждал, что без внешних ключей нельзя по-настоящему иметь «relational» данные, и называл «Postgres-compatible» вводящим в заблуждение.

Схема TPC-C включает все стандартные связи, и проверки ссылочной целостности добавляют чтения и могут вызывать повторяемые конфликты сериализации при конкурентных записях — эту стоимость нужно учитывать при сравнении результатов бенчмарка.

Архитектура: журналы, Crossbar и разделение путей чтения и записи

Журнальный слой (Journals) — это высокодоступный репликационный лог, который надёжно сохраняет транзакции между зонами или регионами. Crossbar — слой маршрутизации, который читает обновления из журналов и отправляет изменения нужным узлам хранения.

Разделение путей чтения и записи решает проблему, возникшую после того, как весь коммит стали писать в один журнал. Такое решение упростило путь записи и обеспечило атомарность и долговечность ACID, но усложнило путь чтения: чтобы узнать последнее значение строки, приходилось проверять все журналы, потому что изменение могла внести любая из них. При добавлении журналов для роста числа транзакций в секунду неизбежно упирались в ограничения пропускной способности сети.

Crossbar разделяет масштабирование пути чтения и пути записи: он предоставляет хранилищу API подписки, позволяя узлам хранилища подписываться на ключи в определённом диапазоне, и при поступлении транзакций маршрутизирует обновления подписанным узлам. Каждый журнал упорядочен по времени транзакции, и Crossbar должен следовать за каждым журналом, чтобы построить общий порядок.

Однако при масштабировании эта архитектура упирается в хвостовые задержки. Симуляция показала, что при 40 хостах вместо ожидаемого миллиона TPS достигалось лишь около 6 000 TPS, а хвостовая задержка выросла с приемлемой 1 секунды до катастрофических 10 секунд. Причина в том, что каждая транзакция должна читать с нескольких хостов, и вероятность столкнуться хотя бы с одной паузой GC приближается к 100%.

Консистентность DSQL обеспечивает иначе: система использует MVCC с precision timestamps для координационно-свободных чтений и оптимистичный контроль конкурентности для записей, откладывая координацию до момента коммита через распределённые adjudicators и систему репликации Journal. Это минимизирует межрегиональную задержку, требуя координации только при коммитах, а не для отдельных операторов. DSQL даёт строгую согласованность (linearizability) и ACID-транзакции с непрерывной доступностью при отказах зоны доступности или региона.

Для транзакций, охватывающих несколько Adjudicators, применяется «Linearized 2PC»: adjudicators голосуют, но только лидер записывает финальный коммит в свой единственный Journal — так удаётся избежать координации нескольких логов. Multi-Region peered clusters предоставляют единую логическую базу данных с двумя региональными эндпоинтами, поддерживают одновременные чтения и записи и обеспечивают строгую согласованность данных.

Control plane: где принимаются решения о масштабируемости

Control plane DSQL выполняет больше, чем CRUD-операции: это «мозг» hands-free операций и масштабирования, который обнаруживает перегрев кластеров и оркестрирует изменения топологии. Именно там принимаются решения о масштабируемости, безопасности и устойчивости, и они приоритизируются даже тогда, когда их трудно реализовать.

Изначально control plane был написан на Kotlin, потому что сервисы вроде Aurora и RDS доказали пригодность JVM-языков для control plane, а преимущества Rust (пропускная способность, задержка, безопасность памяти) не были настолько критичны. Также требовались внутренние библиотеки, ещё недоступные в Rust, и были инженеры, уже продуктивные в Kotlin.

Однако после интеграции с data plane возникли проблемы: control plane должен разделять часть логики с data plane, но из-за разных языков нельзя было создать общую библиотеку, и версии кода на Kotlin и Rust иногда слегка различались. В итоге было решено переписать control plane на Rust. Этому способствовали изменения за год: Rust 2021 edition устранил многие болевые точки, внутренняя библиотечная поддержка расширилась (в некоторых случаях, например в AWS Authentication Runtime client, Rust-реализации обгоняли Java-аналоги), а часть интеграционных задач была перенесена в API Gateway и Lambda.

Как работает OCC и почему это важно для FK

В Aurora DSQL конфликты проверяются на этапе коммита. Отказ от пессимистичных блокировок означает, что читатели никогда не блокируют писателей, а писатели — читателей, поэтому нет проблемы «медленного держателя блокировки»: разработчик, ушедший на обед с открытой транзакцией, не может обрушить базу.

Компромисс — рост числа абортов. Из-за длительного интервала read-modify-commit транзакция может столкнуться с write-write конфликтами на горячих ключах, и под высокой конкуренцией транзакции будут абортироваться, тогда как традиционная база могла бы поставить их в очередь. Если два региона пишут в один ключ, OCC абортирует одну из транзакций, причём конфликт обнаруживается только на коммите — вы платите задержку WAN до того, как узнаете о необходимости повтора.

Клиент при конфликте получает не ожидание в очереди, а аборт транзакции, после которого нужно повторить попытку. Поскольку конфликт обнаруживается лишь на коммите, клиент сначала платит штраф за сетевую задержку WAN, и только потом узнаёт, что транзакцию надо повторить.

Механика транзакции

Запросы исполняются query processors в Firecracker MicroVMs без локального состояния. Чтения выполняются без координации: QP назначает локальную метку времени, а хранилище само обрабатывает остальное, поэтому чтение требует нулевой координации с лидером. Записи используют OCC вместе с MVCC при Snapshot Isolation, а координация происходит один раз при коммите и стоит примерно 1 RTT:

Whether you write one row or a hundred, coordination only happens once at commit time, costing just 1 RTT (or slightly more for a multi-shard commit).

Для транзакций, затрагивающих несколько Adjudicators, применяется «linearized 2PC»: Adjudicators голосуют, но только лидер пишет финальный коммит в свой единственный Journal.

Как проверяются внешние ключи

Aurora DSQL обеспечивает ссылочную целостность через проверку снимка во время транзакций и обнаружение конфликтов в момент коммита. Проверка FK-связей идёт против согласованного снимка транзакции без блокировки таблиц, что позволяет параллельные операции, а на коммите используются неявные проверки KEY SHARE для выявления конфликтующих изменений. Транзакции, которые нарушили бы ограничение, отклоняются с ошибкой сериализации.

Механизм опирается на Adjudicator и KEY SHARE в PostgreSQL, чтобы обнаруживать изменения соответствующих строк в момент коммита, не блокируя параллельные чтения. Заявленные свойства: отсутствие блокировок, отсутствие изменения масштабирования для чтений без FK, читатели FK никогда не заставляют других читателей абортироваться, и хорошее масштабирование FK для хорошо распределённых нагрузок.

Поскольку конфликты приводят к сбоям транзакций, а не к ожиданию, приложения должны реализовывать логику повторных попыток. AWS также отмечает, что все DML-операции над ссылающимися или ссылочными таблицами вызывают дополнительные чтения для поддержания ссылочной целостности, и рекомендует бенчмаркать нагрузку перед добавлением FK-ограничения.

Какие типы FK поддерживаются

Aurora DSQL поддерживает внешние ключи с действиями CASCADE, SET NULL и другими referential actions, позволяя приложениям обеспечивать ссылочную целостность прямо в базе. Целостность обеспечивается через snapshot verification во время транзакций и conflict detection в момент commit. Проверки выполняются без блокировки таблиц, с использованием implicit KEY SHARE checks при commit, а нарушающие ограничение транзакции отклоняются с serialization error.

Дополнительные распределённые механизмы требуются в виде Adjudicator и механизма KEY SHARE из PostgreSQL. Все DML-операции над связанными таблицами вызывают дополнительные чтения для поддержания referential integrity.

Создание FK и асинхронные индексы

Aurora DSQL поддерживает нативные ограничения внешних ключей, и схема TPC-C включает все стандартные связи. Отдельно указано, что Aurora DSQL поддерживает только асинхронное создание индексов, и репозиторий учитывает это требование, чтобы избежать блокирующих операций.

Что происходит при нарушении FK

При нарушении ограничения транзакция отклоняется с ошибкой сериализации, а не блокируется и не откладывает валидацию. Приложениям следует реализовать логику повторных попыток, поскольку параллельные конфликты приводят к сбоям транзакций, а не к ожиданию. Для обнаружения изменений в соответствующих строках в момент коммита без блокировки параллельных чтений используются Adjudicator и механизм KEY SHARE из PostgreSQL.

Пограничные случаи

При сетевом разрыве между регионами проверка внешних ключей опирается на тот же механизм, что и в обычном режиме: ссылочная целостность подтверждается по согласованному снимку транзакции, а конфликты выявляются в момент коммита. Транзакция, которая нарушила бы ограничение, отклоняется с ошибкой сериализации — она не блокируется и её валидация не откладывается.

Весь коммит транзакции записывается в один журнал независимо от числа изменяемых строк, что обеспечивает атомарность и долговечность ACID. Чтобы получить последнее значение строки, нужно проверить все журналы, так как изменение могло прийти из любого из них. Crossbar предоставляет storage API подписки на ключи в заданном диапазоне и маршрутизирует обновления подписанным узлам, а каждый журнал упорядочен по времени транзакции, и Crossbar следует за каждым журналом, чтобы создать общий порядок.

Что видит клиент

При конфликте транзакций клиент получает не ожидание в очереди, а прерывание транзакции, после которого нужно повторить попытку. Поскольку конфликт обнаруживается только в момент коммита, клиент сначала платит штраф за сетевую задержку WAN и лишь затем узнаёт о необходимости повтора. При сильной конкуренции транзакции прерываются, тогда как традиционная база могла бы поставить их в очередь. Чтобы снизить частоту таких прерываний, клиентам следует использовать FOR UPDATE и проектировать схемы так, чтобы write-write конфликты возникали для нарушений бизнес-логики.

Multi-Region кластеры дают два региональных эндпоинта, по одному в каждом регионе пары, оба представляют одну логическую базу и поддерживают одновременные чтение и запись с сильной согласованностью, а третий регион служит только журнальным свидетелем.

Компромиссы выбора OCC

DSQL применяет OCC вместе с MVCC при Snapshot Isolation, чтобы читатели не конфликтовали с писателями: поскольку читатели смотрят на снимок прошлого, read-write конфликты невозможны. Это устраняет проблему «медленного держателя блокировки»: читатели не блокируют писателей и наоборот.

Компромисс — высокая вероятность write-write конфликтов на горячих ключах, что ограничивает число последовательных операций над одной строкой. При записи в один ключ из двух регионов OCC абортит одну из транзакций, и конфликт обнаруживается только на коммите, что означает оплату задержки WAN до повтора. В разделе Lessons Learned упоминается трение в отношении Foreign Key Constraints и последовательностей с высокой локальностью. AWS отмечает, что все DML-операции над ссылающимися или ссылочными таблицами несут дополнительные чтения для поддержания ссылочной целостности, и рекомендует бенчмаркать нагрузку перед добавлением FK.

Почему расширения написаны на Rust

Расширения DSQL решили писать на Rust, потому что каждая новая строка C-кода была шансом добавить ошибку безопасности памяти, такую как use-after-free или переполнение буфера. Команда осознала это на код-ревью, найдя несколько проблем безопасности памяти в «казалось бы, простой» реализации структуры данных, и поняла, что на Rust можно было бы просто взять проверенную безопасную реализацию с Crates.io.

Исследование команды Android подтвердило их мышление: подавляющее большинство новых багов приходит из нового кода, поэтому, выбрав безопасный по памяти язык, можно предотвратить такие баги. Хотя Rust-код тесно взаимодействует с API Postgres, команда смогла создать абстракции, которые принудительно обеспечивают безопасные шаблоны доступа к памяти. Например, в C часто есть два поля, которые нужно безопасно использовать вместе, вроде char* и поля len, и приходится полагаться на соглашения или комментарии; в Rust это оборачивается в единый тип String, который инкапсулирует безопасность. Своими Rust-абстракциями команда смогла закодировать эти правила в систему типов, делая невозможным нарушение инвариантов.

Как сравнивали и что получилось

Стенд и методология

Для максимальной производительности нагрузку распределяют по нескольким EC2-инстансам в разных зонах доступности: каждый инстанс-загрузчик и исполнитель запускается в отдельной зоне доступности. Регион задаётся параметром --region, который обязателен для подключения к Aurora DSQL, но сам регион не назван.

Замер воспроизводится в три фазы.

Фаза 1 (schema/item init) выполняется один раз: создаёт таблицы и индексы и загружает общую таблицу item на 100000 позиций, но не грузит данные складов. Используются --skipMainDataLoad true, --create true, --load true, --execute false.

Фаза 2 (distributed warehouse loading) запускает несколько параллельных инстансов, каждый из которых грузит свой поднабор складов через --startWarehouseIndex и --endWarehouseIndex. Например, loader 1 берёт склады 1,4,7,...,199 (67 складов), с --loaderThreads 70, --skipItemLoad true, --create false, --load true, --execute false, --clear false.

Фаза 3 (distributed benchmark execution) запускает несколько инстансов-исполнителей, каждый на своём поднаборе складов, с теми же --startWarehouseIndex, --endWarehouseIndex, --region, а также --execute true, --create false, --load false, --clear false.

Параметры нагрузки: общее число складов в базе должно быть одинаковым на всех инстансах; распределение между инстансами задаётся шагом между складами (например, шаг 3 для трёхчастного разделения); число одновременных терминалов на исполнителе должно соответствовать числу складов, обрабатываемых этим инстансом. Пример распределения — 200 складов на 3 инстанса в разных зонах доступности.

Агрегация результатов

Каждый executor-инстанс генерирует собственный файл результатов с метриками производительности для назначенного ему подмножества складов. Файлы сохраняются в текущем каталоге с метками времени в формате results_<timestamp>.csv или results_<timestamp>.json, и каждый инстанс создаёт независимые файлы.

Для оценки общей производительности Aurora DSQL нужно объединить результаты со всех executor-инстансов. Ручная агрегация: собрать все файлы результатов, сложить значения TPS со всех инстансов для получения общей пропускной способности кластера, вычислить взвешенное по объёмам транзакций среднее значение задержки и сложить количество успешных и неуспешных транзакций по всем инстансам.

Пример агрегации: для трёх инстансов с результатами 1 200 TPS / 67 складов, 1 180 TPS / 67 складов и 1 150 TPS / 66 складов итоговая производительность кластера составляет 3 530 TPS на 200 складах.

Оговорки

Распределённый подход даёт более реалистичные результаты за счёт распределения нагрузки по нескольким EC2-инстансам в разных зонах доступности.

Что из этого следует на практике

  • FK теперь есть, но ценой дополнительных чтений. Все DML-операции над ссылающимися или ссылочными таблицами вызывают дополнительные чтения для поддержания ссылочной целостности, поэтому перед добавлением FK-ограничения нагрузку нужно измерить.
  • Конфликты не ждут, а абортят. При нарушении ограничения или конкурентном изменении транзакция отклоняется с ошибкой сериализации, а не ставится в очередь. Приложения обязаны реализовывать логику повторных попыток.
  • Цена конфликта в multi-Region — задержка WAN. Если два региона пишут в один ключ, OCC абортирует одну из транзакций, и конфликт обнаруживается только на коммите: задержку WAN вы платите до того, как узнаете о необходимости повтора.
  • Горячие ключи ограничивают число последовательных операций над строкой. Длительный интервал read-modify-commit делает write-write конфликты на горячих ключах вероятными.
  • Проверка FK не блокирует таблицы. Она идёт против согласованного снимка транзакции, а на коммите используются неявные проверки KEY SHARE. Читатели FK не заставляют других читателей абортироваться.
  • Схему стоит проектировать под конфликты. При необходимости клиентам следует использовать FOR UPDATE и проектировать схемы так, чтобы write-write конфликты возникали для нарушений бизнес-логики.

Где смотреть в коде

Источники

Похожее