Конкурентный доступ к данным
Конкурентный доступ к данным — это драйвер всего того, что делается во имя изоляции транзакций. Конечно, речь о транзакциях, которые модифицируют данные (read/write, write/write), поскол��ку именно в этом случае возникают все те аномалии, о которых шла речь ранее.
За согласованность данных отвечает прикладной код, механизмы используемого хранилища могут лишь упрощать эту задачу, но не решать ее. И прежде, чем начну с рассмотрения техник согласования изменений, сделаю два утверждения, которые нужно держать в голове:
- Чаще всего используются слабые уровни изоляции транзакций. И часто это делается под предлогом увеличения пропускной способности базы данных и, как следствие, разрабатываемого приложения.
- Чаще всего прикладные сценарии реализуются по схеме "read-modify-write": чтение данных из базы, их анализ и модификация, запись данных в базу. Такая схема предполагает, как минимум, два обращения к хранилищу (read/write), которые разнесены по времени.
Согласитесь, что это идеальная среда для размножения багов. Ошибки, вызванные нарушением изоляции, возникают иногда, при каком-то определенном стечении обстоятельств, поэтому их трудно обнаружить, воспроизвести и протестировать. При этом последствия могут быть крайне неприятными во всех отношениях. Более того, есть реальные жизненные примеры, когда подобные баги использовались как уязвимость.
Техники снижения вероятности возникновения конфликтующих изменений:
- Коммутативные операции. Изменение порядка выполнения транзакций не меняет итоговый результат. Например, увеличение счетчика.
- Атомарные операции. Все действия с данными выполняются с помощью одной операции, в рамках одного обращения к базе, которая создает эксклюзивную блокировку, исключая одновременный доступ к данным. Пример атомарной операции — SQL-оператор UPDATE. Метод работает идеально, но только в самых простых случаях.
- Переупорядочивание операций. Производится перестановка шагов алгоритма так, что возникновение конфликта становится невозможным или легко детектируемым. Например, вместо "если не существует, то создать" (SELECT/INSERT) делается "создать, но если появился дубликат, тогда откатить создание" (INSERT/SELECT/ROLLBACK).
- Хранимые процедуры. Все необходимые действия производятся на стороне базы данных, следовательно, исключаются дополнительные сетевые издержки на взаимодействие между приложением и базой. Чем короче транзакции, тем меньше шансов, что они будут конфликтовать.
Техники исключения одновременных изменений:
- Явная/пессимистичная блокировка. Данные блокируются до начала их изменения. Пример — SQL-оператор SELECT FOR UPDATE с указанием редактируемых строк в условии WHERE. Метод работает хорошо, но есть шанс заблокировать больше или меньше, чем нужно. Большая блокировка — низкая пропускная способность; недостаточная - нарушение изоляции.
- Оптимистичная блокировка. Наличие конфликта доступа к данным производится непосредственно перед или вместе с записью в базу. Например, объект дополняется свойством "version", которое увеличивается на единицу при каждом редактировании; соответственно, в момент записи ожидается, что в базе значение "version" меньше — более старая версия. Метод работает хорошо в условиях низкой конкурентности, однако решение проблемы потери данных из-за перезаписи ложится на прикладной код.
- Материализация конфликтов. Если объект "спора" еще не существует, то для контроля доступа к нему используется вспомогательный объект, который позволяет обнаружить — материализовать — конфликтующие изменения. Например, при покупке билета на концерт блокируется сам билет (уникальная пара "место-концерт"), после чего создается запись о покупке ("место-концерт-зритель"). Блокировка может быть явной или на основе уникального индекса. Работает хорошо, если вспомогательный объект существует в предметной области, а не является синтетической выдумкой, которая служит лишь для контроля доступа.
- Блокировка диапазона индекса. Предикативная блокировка диапазона изменяемых данных на основе индекса. Блокируемый диапазон может указываться явно, например, в условии WHERE оператора SELECT FOR UPDATE, либо неявно при использовании уровня изоляции Serializable. Метод эффективен для борьбы с фантомными чтениями, но может заблокировать слишком большой диапазон объектов (при отсутствии подходящего индекса — всю таблицу).
- Последовательный доступ. Все изменения данных приложение производит последовательно, в рамках одного рабочего потока — обработчика. Метод эффективен в случае коротких транзакций и когда производительности одного обработчика достаточно, иначе он может быстро стать узким местом в системе.
- Партиционирование данных. Развитие идеи с последовательным доступом, но в рамках какого-то подмножества данных. Каждое подмножество — партиция — обрабатывается последовательно, в рамках одного рабочего потока - обработчика. Например, финансовые движения партиционируются по номеру банковского счета, таким образом, все транзакции, связанные с одним счетом, попадут в одну и ту же партицию — одно и тоже подмножество. Метод эффективен в случае, когда прикладные сценарии не выходят за пределы одной партиции.
При этом в большинстве распределенных баз в общем случае неприменимы:
- Явная/пессимистичная блокировка
- Оптимистичная блокировка
- Материализация конфликтов
- Блокировка диапазона индекса
На мой взгляд, всё это выглядит крайне непросто. Поэтому стоит честно ответить на вопрос: "У меня реально такая конкурентность при записи?" Если ответ "нет", тогда стоит начать с самых простых техник (и до конца придерживаться принципа KISS). Если ответ "да", то стоит подумать о подходе работы с данными, например, посмотреть в сторону append-only-техник (шаблоны Event Sourcing, CQRS). Конечно, это не единственно возможные варианты, и очень важен контекст решаемой задачи. Однако всегда стоит подвергать сомнению и делать ревизию текущего решения и инструментов с учетом имеющегося опыта и появляющихся требований.
P.s. Если вам интересна данная тематика, присоединяйтесь к моей новостной ленте в Telegram или здесь. Буду рад поделиться опытом. ;-)