Привет, Хабр! 

Сегодня мы рассмотрим работу блокировок в базах данных, уделив особое внимание оптимистичному подходу и его реализации во фреймворке Hibernate.  

Сначала я решил сделать схему, которая покажет, как происходит блокировка в базе данных при запросе на обновление внутри какой-либо транзакции. Но в разных базах это происходит по-разному. И чем глубже изучаешь этот вопрос, тем больше находишь отличий. Поэтому я для начала просто приведу красивую картинку, как ИИ видит оптимистичные блокировки. А чтобы совсем не оторваться от реальности, расскажу, как это происходит на примере Postgres.

Postgres это делает в несколько шагов:

  • В начале транзакции база создает запись о том, что сейчас происходит новая транзакция, и присваивает ей идентификатор.

  • Затем проверяется наличие существующих блокировок таблиц и строк.

  • Потом существующая запись затеняется.

  • На последнем этапе происходит обновление системных таблиц, то есть запрос фиксируется, происходит блокировка строк, обновление статуса транзакции.

Давайте посмотрим, с какими проблемами можно столкнуться, блокируя ресурсы.

А был ли update?
А был ли update?

Первый на очереди — Lost Update, он же «потерянное обновление».  

Стоит оговориться, что в Postgres разработчики исключают такой тип ошибок при использовании пессимистичных блокировок. Но для оптимистичных это наиболее распространенный сценарий, поэтому исключать его из обзора нельзя.  

Итак, что здесь происходит? Два пользователя в своих транзакциях хотят обновить одну и ту же запись. Оба выполняют обновление. Проблема в следующем: второй пользователь никак не уведомляется о том, что его данные устарели и надо что-то с этим сделать. Соответственно, действия второго пользователя выглядят так, как будто других транзакций не существует, как будто никаких обновлений не произошло. 

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

Казалось бы, это очень быстро, и это огромный плюс. Но и есть минус: на уровне базы у нас нет никаких гарантий - транзакции друг другу мешают, и это становится проблемой разработчика.

Следующий сценарий — это грязное чтение.

Читаем то, чего не было. Или было?..
Читаем то, чего не было. Или было?..

Речь о ситуации, когда одна транзакция читает неподтвержденные изменения другой. Представим, что первый пользователь отправляет запрос на обновление, в это время второй пользователь в своей транзакции видит эти данные и что-то с ними делает. Затем в транзакции первого пользователя что-то пошло не так и она откатывается; проблема здесь в том что откат одной транзакции не приводит к откату второй при том что область видимости у них пересекается. В итоге второй пользователь работает с несуществующими данными.  

Плюс такого подхода — это, разумеется, скорость. Минус — мы теряем консистентность (но в некоторых случаях, например, для аналитических запросов, это может быть не критично).

Теперь рассмотрим неповторяющееся чтение.

Неповторимое неповторяющееся чтение
Неповторимое неповторяющееся чтение

Пользователь читает данные. В это время другой пользователь сообщает, что хочет совершить запись. Никаких эксклюзивных блокировок нет, чтение открыто. Дальше происходят изменения, о которых один из пользователей не уведомлен. Как итог — мы теряем консистентность. В каких-то случаях на такую ситуацию можно закрыть глаза (например, когда абсолютная консистентность не важна). Это может быть, сценарий использования распределенной системы или, например, когда пользователь читает статью на новостном портале, а в это время редактор обновляет заголовок или какую-то колонку. Пользователь видит сначала старую версию, но потом он увидит новую. И это нормально. Здесь очень важно отметить следующий момент. Если транзакция очень короткая и нет повторяющихся чтений, этого второго чтения не будет. Если мы закладываем такое поведение в логику, то мы не получим проблемы.  

Но это усложняет проектирование самой системы. Подобная проблема может возникнуть при оптимистической блокировке, и здесь важно учесть то, что проверки изменения сущности должны происходить на каждом этапе, и самое главное — время транзакции должно сводиться к минимуму.

И, наконец, последнее (но не по важности) — это чтение «фантомов».

Фантомное чтение
Фантомное чтение

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

Как такое предотвратить? Тут все просто и сложно одновременно - нужно чтобы у каждой транзакции (на момент ее начала) был свой слепок памяти. Это очень дорого, поэтому такой подход используется редко.

Можно в качестве примера привести версионность MVCC в Postgres. Но давайте подумаем, как с бороться фантомными чтениями, что у нас может защитить? Использовать транзакции дорого и могут возникать блокировки. Лечится это либо самой базой данных, которая может выявлять взаимные блокировки и снимать их, либо эту функцию выполняет человек. А еще важно проектирование, если мы учитываем подобные сценарии и ставим тайм-ауты или уменьшаем пересечения. Ну и, конечно, непосредственно тайм-ауты.

Если мы придерживаемся пессимистичного подхода, нужно руководствоваться следующими соображениями:

  • Пессимистическая блокировка дает гарантию целостности на заявленном уровне. Но надо учитывать, что, в зависимости от реализации, от того, какую СУБД вы выберете, поведение может отличаться.

  • Следующий момент — это простота реализации; немного утрируя это begin + commit и несколько строк запросов в середине. Иногда это rollback, иногда мы делаем какие-то чекпоинты посередине, но чаще всего мы с этим не сталкиваемся и просто отмечаем соответствующими аннотациями, а дальше уже Hibernate\Spring Data делают свою магию и нам не нужно об этом задумываться.

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

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

Ну и, наконец, сложности масштабирования. Если база распределена по нескольким узлам, то транзакция будет выполняться дольше чтобы обеспечить консистентность между узлами, намного дольше, чем если бы все данные находились вместе. А это уже скорее вопрос к правильному проектированию (данные используемые совместно должны находиться рядом друг с другом).  

К несчастью, для оптимистических блокировок подойдет только один единственный спасительный «подорожник» — это правильное проектирование.

Итак, СУБД может блокировать записи или таблицы, ограничивать к ним доступ.

А что если мы будем использовать неблокирующий подход? Из мира Java на ум приходят «атомики», которые используют магию CAS. Что, собственно, такое CAS?

Итак, традиционный подход к конкурентному доступу к переменной основан на блокировках. В Java — это концепция мониторов. Такой подход достаточно громоздкий, он требует дополнительных действий. То есть это lock, затем операция, затем unlock. Часто приходится слышать на собеседованиях от начинающих программистов, что атомики работают примерно также. Отнюдь! Они работают гораздо быстрее и «умнее».

У блокировок есть еще одна проблема. Дело в том, что (если верить Java Memory Model), размер и границы блока синхронизации могут смещаться во благо Империума reordering’а. И это тоже неприятно. CAS основан на процессорной магии, инструкциях, которые помогают за раз обновить значение переменной. То есть вместо того, чтобы брать lock’и, обновлять, потом снимать их, одной инструкцией шлем запрос. Процессор быстро все делает в кэше, раскидывает по памяти и, собственно, переменная обновлена. Такой подход не идеален и, например, у него есть проблема ABA.

Суть ее состоит в следующем: мы взяли некоторую сущность, перевели ее из состояния A в состояние B, затем обратно в A. И если сторонний наблюдатель посмотрит в начало нашего взаимодействия и в его конец, он подумает, что мы ничего не меняли – оба раза он увидит сущность А.  

Но перейдем ближе к теме нашего сегодняшнего разговора — это Hibernate и оптимистические блокировки. 

Hibernate умеет делать оптимистические блокировки двумя способами. 

Во-первых, он может использовать версионный подход. В чем суть? У нас есть контроль версий: Hibernate следит за специальным полем, где присутствует соответствующая метка, он меняет свое поведение и учитывает эту метку в запросах. Кстати, у такой метки есть дополнительный функционал?

Следующий подход — безверсионный. Он отслеживает изменения сущности без опоры на поле версии.

Давайте разберемся, как это работает. Начнем сначала. Что здесь происходит?

Добавляем аннотацию версии — и все работает, больше ничего не нужно делать. Здесь висит аннотация Optimistic Locking, которая указывает тип оптимистичной блокировки, но на самом деле она здесь не нужна и служит только демонстрацией. Hibernate, как только увидит наличие @Verion над полем, сам включит версионирование.  

Итак, что меняется в поведении сущности? При обновлении мы (вместо обычного поиска по ID) добавляем еще версию и сравниваем — та ли эта версия? Если версия, которую мы загрузили, будет отличаться, то Hibernate сделает вывод о том, что сущность изменилась.

Что тогда произойдет?

  1. Мы не получим обновление нашей сущности, если версия разойдется, потому что этот запрос вернет ноль измененных строк. Hibernate это поймет.

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

  3. Ну и последнее. Этот пункт нельзя однозначно причислить ни к хорошим, ни к плохим. Сущность начинает реализовывать принцип «кто первым встал, того и тапки», то есть кто первый изменил своим запросом состояние, тот и выиграл.

Здесь, кстати, очень интересный момент. Как быть со вторым «пришедшим»? Что он будет делать? Ему Hibernate вернет специфическую ошибку, она называется OptimisticLockException» и говорит о том, что наша сущность больше не соответствует в той версии.

Давайте пойдем дальше и посмотрим, какие еще есть способы. Вы можете сказать, что добавлять новое поле — это не круто: надо делать alter table, надо что-то добавлять, надо добавлять какой-то индекс. Да, действительно, индексы нужны, это правило хорошего тона — использовать в индексах те переменные, которые мы используем в запросах. Мы можем переиспользовать что-то уже существующее. У нас есть много разных дополнительных полей. Одно из них — это метка обновления, она достаточно часто встречается. И здесь я, как пример, привел last_update.

В таком случае Hibernate будет использовать именно ее. Он будет использовать метку времени. И хотя JPA использует только java sql timestamp (это сделано для переносимости). Hibernate расширяет это поведение - он позволяет использовать несколько дополнительных классов. Например, это Date, указанный здесь, и Calendar.

Такой подход не лишён недостатков, и мы поговорим о них чуть позже.

А если мы совсем не хотим никаких полей? Так тоже можно!

И здесь начинается разбор безверсионных подходов. У нас изменился тип оптимистических блокировок. Да, OptimisticLocking теперь у нас «type = Dirty». Это значит, что мы будем проверять наличие каких-то загрязнений.

Итак, мы вешаем аннотации @OptimisticLocking(type =dirty) и @DynamicUpdate. Собственно, все.

*Справа приведен псевдокод. Мы используем какой-то репозиторий из Spring Data, вытаскиваем сущность по ID, а затем обновляем ее и сохраняем. При обновлении будут обновляться не все поля, а только те, которые мы изменили. Это как раз специфика данного типа безверсионной проверки - происходит работа с контекстом: при обновлении Hibernate сравнит сущность с ее исходным слепком и проведет тот самый Dirty Checking. То есть он сравнит то, что было в начале, то, что получилось в конце (когда мы производим вытеснение из контекста) и сформирует соответствующий запрос. За такое поведение отвечает аннотация @DynamicUpdate. Она нам позволяет не таскать туда-сюда все поля. И это прекрасно: когда у нас таких полей 20, мы не хотим перемещать все. Или, например, когда у нас есть какие-то тяжелые переменные, какие-нибудь большие бинарные сущности (BLOB), @DynamicUpdate переопределяет генерацию запросов. Но у этого поля есть свои недостатки. 

Hibernate использует кэширование запросов. То есть он делает пре-компиляцию, создает пре-компилированный запрос, а затем повторно его использует раз за разом. Но @DynamicUpdate портит это поведение, поскольку для каждой ситуации генерируется новый запрос, и если таких запросов много, то они просто вытеснятся из кэша (а кэш запросов достаточно маленький). Это приведет к тому, что кэш будет работать против нас – компиляция есть, повторного использования нет.  

С одной стороны, хорошо, что такой подход позволяет нам независимо обновлять разные сущности. Но есть проблема: когда мы работаем в рамках одного приложения, Dirty Checking будет работать, только пока у нас есть контекст. Как только сущность отсоединилась, мы не можем к ней обращаться. Нам нужно либо заново ввести сущность в сессию, либо «слить» ее, либо обновить (в зависимости от того, каким функционалом мы будем пользоваться). Но очевидный минус в такой ситуации — это то, что оба метода вызовут SELECT, полезут в базу и будут использовать дополнительный запрос.

Давайте посмотрим, что за аннотация @OptimisticLocking, и какие типы у нее бывают.

Все они завязаны на перечисление OptimisticLockType. Есть NONE (это поведение по умолчанию, у нас отключены оптимистические блокировки), VERSION (включается автоматически, как только Hibernate видит у сущности аннотацию @Version, он следит только за ней), DIRTY (следит за изменением в контексте каких-либо полей) и ALL (Hibernate всегда будет проверять все поля; @DynamicUpdate здесь, в общем-то, и не нужен).  

А теперь давайте поговорим о тех проблемах, которые могут возникнуть.

У нас есть SELECT, в одной транзакции мы вытаскиваем данные по какому-то пользователю и параллельно изменяем его. Как только в первой транзакции мы попытаемся его обновить, получим ошибку.

OptimisticLockException связан с тем, что устарела версия сущности и если вы немножко пороетесь в памяти, сможете точно определить, что произошло нарушение целостности, известное как потерянное обновление. Давайте попробуем на это взглянуть под другим углом. Что у нас с доступностью?

Допустим, мы пытаемся обновить разные поля (каждое в своём запросе) и делаем запросы, но они не успевают. Да, у нас везде OptimisticLock. Кто-то успел и смог обновить поле current_task, и у него все получилось. Допустим, мы знаем, что в этой таблице высоконагруженным будет именно поле current_task. И здесь мы можем воспользоваться принципом «разделяй и властвуй»: мы декомпозируем таблицу, выносим отдельно таски, которые относятся к пользователям. Теперь при попытке обновить имя у нас все получится (если, допустим, 98% обновлений приходится именно на таски). Все эти поля, которые блокировали CurrentTask, окажутся разблокированными. Это хороший подход для того, чтобы использовать оптимистичные блокировки.

Вы можете сказать, что это дополнительные джойны, дополнительные нагрузки. Но для амортизации можно использовать кэш второго уровня. Также важным плюсом будет то, что, если раньше при обновлении большой сущности мы вычищали ее из контекста каждый раз и каждый раз запрашивали заново, то теперь, при работе с этими двумя сущностями (если это осталось без обновления и мы обновили только задачу), то пользователя менять не нужно. Никаких изменений не произойдет, и вытеснение не потребуется. Dirty checking, скажет, что у нас все актуально, оставляем как есть.  

Перейдем к более интересным моментам. Как быть с коллекциями?

Дело в том, что (по спецификации) коллекции тоже будут участвовать в версионировании, и это может быть проблемой. Важно отметить, что только сторона «владелец отношений» может вносить изменения в БД. Соответственно, если мы пытаемся зайти со стороны, которая является зависимой, и внести какие-то изменения, ничего не произойдет. Мы потеряем все наши изменения, все наши наработки. Отслеживания также будут работать только для неё. И в Hibernate есть достаточно хороший способ отключить (если вдруг надо отключить) отслеживание коллекций с помощью @OptimisticLock(excluded = true).

Таким образом мы упрощаем логику, и нам не надо думать о соединениях и о том, как просчитать откат, если что-то пойдет не так.

Следующая проблема — это выбор счетчика (то самое поле версии). У нас есть варианты физического и логического версионирования. Под физическим подразумевается время. И, я бы сказал, что оно обладает обманчивой простотой: c одной стороны, мы можем точно сказать, когда что-то произошло, но мы окажемся в затруднении, если захотим сказать, каким по порядку было это действие. Здесь может быть достаточно проблем. И в распределенных системах именно порядок играет важную роль. К тому же время может преподнести нам несколько сюрпризов. Например, чьё время мы определяем - клиента или сервера? А если у нас несколько серверов, то какого из них? А если разные часовые пояса? А если отключили питание или по NTP пришло обновление времени? Вариантов очень много, и их достаточно сложно спрогнозировать и продумать.

Давайте разложим по порядку особенности меток версионирования.

Время легко читать. То есть, проверяя логи Grafan’ы или Kiban’ы, мы видим эту метку и сразу понимаем: это было здесь, а это было тогда. К тому же мы можем переиспользовать существующий столбец. Это прекрасно. Но что, если, например, мы углубимся в подробности и попытаемся разобраться в разрешающей способности? Это к вопросу о том, кто был первый.

В зависимости от выбранной операционной системы разрешающая способность будет отличаться и это тоже может быть проблемой.

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

Если вы хотите, погрузиться в это поглубже, отсылаю вас к “книге с кабанчиком”. Там есть целая глава, посвященная времени, как с ним бороться и взаимодействовать. Также проблемой будет то, что в разных базах данных могут быть разные стандарты времени и разная точность. И это тоже может отразиться на наших допусках и на наших ожиданиях. Кроме того, разные форматы времени есть и в Java - может быть, относительное время, а может быть абсолютное.

Логические метки лишены большинства этих проблем. Из плюсов можно отметить то, что это неубывающая последовательность, то есть мы всегда знаем, кто за кем шёл. Кроме того, присутствует простота масштабирования: можно переносить на сколько угодно узлов и не переживать за стандарт - все мы знаем, что такое int, что такое long и как они себя ведут, а самое главное — упрощается логика в коде, потому что операции increment\decrement очевидно проще, чем какие-то расчеты времени. Единственное, нам требуется вводить новый столбец, но дефолтные значения никто не отменял, и проведение ревизии индексов тоже приветствуется.

Итак, давайте кратко подведем итоги. По основному механизму, пессимистичная блокировка требует явных (извините за тавтологию) блокировок в таблицах, оптимистичная же использует ту или иную версионность на уровне базы данных или Hibernate. Отмечу, что для Hibernate достаточно легко с оптимистическими блокировками добиться уровня repeatable read на уровне приложения, но, когда таких приложений становится несколько, возникает проблема синхронизации.

Также следует кое-что отметить влияние на нагрузку базы данных - оптимистические блокировки работают достаточно быстро, они не требуют каких-то дополнительных действий в базе данных, но может возникнуть проблема с тем, что, в случае частых ошибок (если мы не успеваем за версией), транзакции будут пытаться выполниться раз за разом, и при высокой нагрузке можно получить «оптимистик локи» один за одним. Нужно предусмотреть грамотную retry-policy - в каких-то случаях следует откатиться или подождать.

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

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

Оптимистичные локи прямо в коде дают OptimisticLockException, их приходится просматривать и думать: что с этим делать? Мы делаем retry. Если не хочется Lost update, то мы не откатываемся, а делаем повторный SELECT, и все это раз за разом может вызывать лишние «холостые» запросы.

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

Спасибо за внимание!

Комментарии (0)