Оглавление
- Введение
- Гранулярность блокировок: уровень таблицы vs. уровень строк, MVCC
- Зачем backend-разработчику знать про блокировку строк?
- Пессимистическая блокировка (row-level locking) в практике JPA
- Заключение
Введение
В современном приложении несколько пользователей или потоков могут одновременно обращаться к одним и тем же данным в базе данных. Такой конкурентный доступ (concurrent access) без должной координации может приводить к разного рода аномалиям и ошибкам. Например, возможны ситуации:
- Грязное чтение (dirty read) – когда транзакция прочитала несохраненные (незафиксированные) изменения другой транзакции. Эти изменения могут затем откатиться, и первое чтение окажется недействительным.
- Потерянное обновление (lost update) – когда одно обновление “перетирает” результат другого. Например, две транзакции читают одно значение и параллельно его изменяют; в итоге изменения одной из них теряются при сохранении последней.
- Deadlock (взаимная блокировка) – когда две транзакции навсегда ждут друг друга: каждая удерживает ресурс (например, строку) и пытается получить ресурс, занятый другой транзакцией. Без специальной логики СУБД такие транзакции будут ждать бесконечно.
Чтобы обеспечить целостность данных и избежать подобных проблем, используются механизмы блокировок. Блокировки гарантируют, что критические операции выполняются последовательно, предотвращая одновременное небезопасное изменение одних и тех же данных. В данном посте мы рассмотрим, что такое блокировка на уровне строк (row-level locking), почему она важна, как она реализована в современных СУБД (особенно PostgreSQL) и как ее применять в Java (Spring Boot, JPA/Hibernate) – включая альтернативный подход оптимистичных блокировок. Также обсудим плюсы и минусы разных подходов к конкурентности.
Гранулярность блокировок: уровень таблицы vs. уровень строк, MVCC
Гранулярность (уровень) блокировки определяет, какой объем данных “захватывается” при блокировании. Исторически простейший подход – блокировка всей таблицы (table-level locking) при изменении данных. Этот способ прост: если транзакция A начала изменение таблицы, транзакция B вынуждена ждать завершения A, даже если они затрагивают разные строки. Примером является механизм MyISAM в старых версиях MySQL, где при вставках или обновлениях блокируется целая таблица, не позволяя параллельно модифицировать ее другим сессиям. Такой подход гарантирует отсутствие конфликтов (и даже избегает deadlock’ов, так как все блокируется “оптом”), но резко снижает конкурентность – другие пользователи должны ждать, пока текущая операция завершится.
Более тонкий и современный подход – блокировка на уровне строк. СУБД блокирует конкретные строки, затрагиваемые операцией (UPDATE/DELETE/SELECT … FOR UPDATE), вместо всей таблицы. Это позволяет разным транзакциям одновременно изменять разные строки в одной таблице без конфликтов, значительно повышая пропускную способность системы при многопользовательской работе. Например, InnoDB (стандартный механизм хранения в MySQL) поддерживает одновременную запись в разные строки за счет именно row-level блокировок. PostgreSQL и большинство других современных СУБД также по умолчанию используют блокировки строк при изменении данных.
Однако с более мелкой гранулярностью приходит сложность: появляется риск взаимоблокировок. Если две транзакции блокируют разные строки, но затем каждая пытается получить блокировку на строку, уже занятую другой транзакцией, возникнет deadlock. Рассмотрим упрощенный пример:
- Транзакция А обновила (и заблокировала) строку с id=1
- Транзакция B – строку с id=2.
- Далее A пытается обновить id=2 (блокировка не получается, так как B держит эту строку) и засыпает в ожидании
- Транзакция B в это время пытается обновить id=1 (заблокирована A) и тоже засыпает.
- Получается круговая зависимость – A ждет B, а B ждет A. СУБД, такие как PostgreSQL и InnoDB, автоматически детектируют подобные ситуации и прерывают одну из транзакций (выдают ошибку о deadlock), чтобы разблокировать систему.
Лучшей практикой для разработчиков считается избегать deadlock’ов – например, всегда брать блокировки (обновлять данные) в согласованном порядке во всех местах кода, чтобы транзакции конкурировали в одном порядке ресурсов. Если избежать полностью не получается, приложение должно уметь обработать ошибку взаимоблокировки (например, повторно выполнить транзакцию).

Рис. 1: взаимоблокировка двух транзакций и её разрешение СУБД:
Помимо гранулярности блокировок, важно понять подход СУБД к одновременному чтению и записи данных. Многие современные системы (Oracle, PostgreSQL, PostgreSQL, а с недавних пор и SQL Server в режиме snapshot) используют MVCC (многоверсионный контроль конкурентности). Основная идея MVCC: чтение данных не блокирует их изменение, и запись не блокирует чтение. Реализуется это за счет хранения нескольких версий строки: каждая транзакция видит консистентное состояние данных на момент начала (или текущий коммит для Read Committed уровня изоляции) и не ждет, пока другие транзакции завершат свои изменения. PostgreSQL гарантирует отсутствие блокировок между чтением и записью даже на самом строгом уровне изоляции Serializable, используя для этого механизм Serializable Snapshot Isolation (SSI).
Таким образом, когда одна транзакция обновляет строку в PostgreSQL, она ставит блокировку только для других модифицирующих транзакций на эту строку, но не мешает параллельным чтениям этой (старой) версии строки. В официальной документации сказано: “блокировки на уровне строк блокируют только запись (модификацию) в определенные строки, но никак не влияют на выборку”. Читатели просто увидят последнюю зафиксированную версию данных, не дожидаясь окончания транзакции, держащей блокировку. Это ключевое преимущество MVCC-модели перед классической схемой блокировок.
Примечание: Спецификация JPA в общем случае утверждает, что эксклюзивная блокировка (PESSIMISTIC_WRITE) должна предотвратить другим транзакциям чтение заблокированной записи. Однако в СУБД с MVCC (PostgreSQL, Oracle и др.) это ограничение фактически обходит стороной обычные SELECT-запросы – они не видят несохраненные изменения и потому не блокируются, продолжая читать старые версии данных. То есть пессимистичная блокировка в PostgreSQL блокирует только конкурентные изменения строки, но не мешает читающим транзакциям прочитать ранее commit-нутые данные этой строки.
Наконец, следует упомянуть, что хотя MVCC снижает необходимость блокировать читателей, блокировки записей все равно нужны для координации одновременных изменений. Если две транзакции пытаются изменить одну и ту же строку, вторая вынуждена ждать освобождения блокировки первой – иначе данные неизбежно разойдутся. Также некоторые операции (например, DDL-изменения схемы, перестройка индексов) в PostgreSQL требуют блокировок на уровне таблицы с режимом ACCESS EXCLUSIVE, которые блокируют и чтение тоже – но такие ситуации редки и обычно связаны с административными действиями. В общем случае, правильное использование row-level locking позволяет достичь высокой параллельности без потери корректности данных.
Зачем backend-разработчику знать про блокировку строк?
Работая с Spring Boot, JPA/Hibernate или любым фреймворком доступа к базе, разработчик постоянно имеет дело с транзакциями и возможными гонками данных. По умолчанию многие ORM реализуют так называемую стратегию Last-Write-Wins – последний коммит “побеждает”, а предыдущие изменения тихо перезаписываются. В ситуациях низкой конкуренции это незаметно, но под нагрузкой могут проявиться проблемы.
Рассмотрим сценарий: вы разрабатываете онлайн-магазин. В базе есть таблица products, где хранится количество товара на складе (stock). Допустим, от телефона iPhone 15 остался последний 1 шт. на складе. Двое пользователей почти одновременно нажимают “Купить”. Ваш сервис на Spring Data JPA выполняет для каждого запрос: найти товар по ID, проверить stock > 0, уменьшить на 1 и сохранить. Каждая транзакция работает с одним и тем же продуктом. Без специальных мер возможны такие шаги:
- Транзакция A: читает продукт #42 (iPhone) – stock = 1. Условие >0 проходит.
- Транзакция B: почти параллельно тоже читает тот же продукт – stock = 1 (ведь A еще не закоммитила изменения).
- A уменьшает stock до 0 и сохраняет (пишет в базу stock=0).
- B уменьшает свою копию stock (которая у нее была 1) до 0 и тоже сохраняет.
В итоге оба запроса “успешно” выполнились, и система считает, что два телефона продано, хотя был один. Более того, значение stock в базе может стать 0 (если первая записалась последней) или даже -1 (если обновления каким-то образом слились некорректно). Мы получили аномалию перепродажи товара и нарушение целостности данных. Этот пример иллюстрирует проблему lost update – обновление от транзакции B фактически затер результат транзакции A (или наоборот).

Рис. 2: lost update при одновременной покупке последней единицы товара без блокировок/версионирования
Подобные ситуации могут произойти не только со счетчиком товара. Классические примеры для критически важных данных:
- Банковские счета: два параллельных списания со счета без блокировки могут оба пройти проверку баланса и привести к перерасходу или пропаже денег.
- Система бронирования: два пользователя успевают забронировать одно и то же место (последнее в зале, номер в отеле и т.д.) одновременно.
- Документы/формы: один пользователь сохраняет изменения документа, пока другой уже внес свои правки – в итоге часть правок теряется.
Для разработчика важно уметь управлять транзакциями и конкурентным доступом к данным, чтобы избежать этих проблем. Далее мы рассмотрим, как решать проблему безопасного обновления с помощью пессимистической блокировки на уровне строк, а также альтернативный подход оптимистической блокировки.
Пессимистическая блокировка (row-level locking) в практике JPA
Пессимистическая блокировка – это подход “наихудшего случая”: мы заранее предполагаем, что данные могут быть конфликтно изменены параллельно, и поэтому блокируем их перед изменением. В контексте баз данных и JPA это обычно означает использование конструкции SELECT … FOR UPDATE или аналогичной, которая ставит эксклюзивный лок на выбранные строки до конца транзакции. Другая транзакция, попытавшаяся изменить или также залочить эти же строки, будет ждать, пока первая не завершится (или пока не истечет таймаут ожидания).
Пример 1: без блокировок (проблема потерянного обновления)
Начнем с кода без явных блокировок, чтобы увидеть проблему. Представим типичный репозиторий товара и сервис покупки на Spring Data JPA:
@Entity
class Product {
@Id
private Long id;
private int stock;
// ... геттеры/сеттеры ...
}
@Repository
interface ProductRepository extends JpaRepository<Product, Long> {
// стандартный репозиторий
}
@Service
public class ShopService {
@Transactional
public void purchaseProduct(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(); // получили продукт
if (product.getStock() > 0) {
product.setStock(product.getStock() - 1);
productRepository.save(product);
}
}
}
Этот код аналогичен тому, что мы обсуждали: он читает запись товара и на основе прочитанного значения выполняет обновление. При одновременном запуске дважды возникает риск, что оба потока прочтут старое значение stock и оба запишут корректно с своей точки зрения новое значение. В результате одно обновление стирает другое. База данных на уровне Read Committed изоляции не предотвращает такой сценарий автоматически – оба транзакции читают зафиксированное на тот момент значение (1) и успешно коммитят. Получается классический пример race condition на уровне данных.
Пример 2: с блокировкой на уровне строки (пессимистический лок)
Теперь улучшим код, используя пессимистическую блокировку. В JPA можно при выполнении запроса указать, что мы хотим залочить данные для обновления. Один из способов – написать метод репозитория с аннотацией @Lock. Например:
@Repository
interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Product findByIdForUpdate(@Param("id") Long id);
}
Этот метод при вызове выполнит SQL вида:
SELECT *
FROM product
WHERE id = ?
FOR UPDATE;
То есть выберет строку товара с эксклюзивной блокировкой до конца транзакции. Используем его в сервисе:
@Service
public class ShopService {
@Transactional
public void purchaseProductSafe(Long productId) {
Product product = productRepository.findByIdForUpdate(productId);
if (product.getStock() > 0) {
product.setStock(product.getStock() - 1);
productRepository.save(product);
}
}
}
Теперь поведение будет другим.
- Когда транзакция A вызвала findByIdForUpdate(42), PostgreSQL найдет строку с id=42 и поставит на нее блокировку FOR UPDATE.
- До тех пор, пока транзакция A не завершится (commit или rollback), никакая другая транзакция не сможет получить эксклюзивный лок на эту же строку.
- Если параллельно транзакция B попытается выполнить такой же findByIdForUpdate(42), она заблокируется (будет ждать) освобождения строки. В результате B не сможет прочитать stock до коммита/ролбэка A.
- После завершения A транзакция B либо получит обновленные данные (например, узнает, что stock уже 0 и не выполнит лишнего списания), либо – если A откатилась – продолжит работу.

Рис. 3: пессимистическая блокировка (SELECT … FOR UPDATE): безопасная покупка последней единицы
Таким образом, row-level блокировка гарантирует, что одновременно только одна транзакция изменяет данный товар. Ни “грязного” чтения, ни потери обновления здесь не происходит – вторая транзакция вынуждена дождаться результата первой.
В терминах СУБД, SELECT … FOR UPDATE устанавливает на выбранные строки эксклюзивную блокировку, предотвращающую их изменение или блокирову другими до конца транзакции. Любая другая попытка UPDATE, DELETE или также SELECT FOR UPDATE этих же строк из другой транзакции будет заблокирована (поставлена в очередь) до освобождения локов. Это надежно решает проблему гонки за обновление данных.
Важно: при использовании пессимистической блокировки нужно быть готовым к возможным исключениям. Если другая транзакция уже держит лок, то в зависимости от настроек драйвера/ORM ваше ожидание может выдать LockTimeoutException (если время ожидания истекло) или PessimisticLockException. Как правило, по умолчанию транзакция будет ждать достаточно долго, а deadlock-ситуации отслеживаются СУБД. Но обработку таких исключений (например, повтор операции) также стоит учитывать при построении надежной системы.
Пример 3: оптимистичная блокировка (@Version)
Подход с явным блокированием – «пессимистичный», потому что мы предотвращаем проблемы ценой потенциального ожидания. Альтернативой является оптимистичная блокировка, которая делает ставку на то, что конфликтов случается мало, и не блокирует данные заранее. Вместо этого каждый конкурентный транзакционный апдейт проверяет, не изменились ли данные кем-то еще с момента чтения, и отменяется, если обнаружен конфликт.
В JPA оптимистичная блокировка реализуется с помощью специального поля-версии в сущности. Добавим в наш класс Product поле version:
@Entity
class Product {
@Id
private Long id;
private int stock;
@Version
private int version;
// ...
}
Аннотация @Version указывает ORM, что это поле служит для версионного контроля. При каждом обновлении записи ORM будет проверять и увеличивать версию. Принцип такой:
- когда мы считываем объект, запоминается его текущая версия (скажем, version = 1).
- При сохранении (UPDATE) в SQL-запрос добавляется условие на версию, например: … WHERE id = 42 AND version = 1.
- Если никакой другой транзакцией эта строка не изменялась, версия все еще 1, условие выполняется, запись обновляется и версия инкрементируется до 2.
- Но если другая транзакция успела изменить эту же строку и сделать commit раньше, то версия там уже стала 2 – условие version = 1 не выполнится, и SQL-запрос не обновит ни одной строки. ORM обнаружит, что при обновлении затронуто 0 строк, и кинет исключение OptimisticLockException. Это означает: “не удалось сохранить, данные изменены кем-то еще”. Активная транзакция при этом обычно помечается для отката.

Рис. 4: оптимистическая блокировка (@Version) – конфликт версий и OptimisticLockException
Оптимистичный подход не мешает другим транзакциям – все могут свободно читать и писать (commmit-иться) параллельно, но при коммите происходит проверка версии. В нашем случае, если два пользователя попытаются одновременно купить последний товар, то оба сначала прочитают stock=1, version=1. Оба выполнят локально stock–. Но когда первый сохранится – версия станет 2. Второй при сохранении обнаружит, что “версия 1 уже не актуальна”, и получит OptimisticLockException от JPA. Его транзакция откатится, и можно, например, вывести пользователю сообщение типа “товар уже разобрали, попробуйте другой”. Либо приложение может автоматически перехватить исключение и попытаться повторить операцию: перечитать актуальные данные и снова выполнить логику (например, вдруг товар был возвращен на склад).
Оптимистичная блокировка – встроенный механизм JPA при наличии поля @Version. Начиная с Spring Data JPA 2.x, при попытке сохранения устаревшей версии сущности фреймворк бросит ObjectOptimisticLockingFailureException (на уровне Spring), что по сути обертка над OptimisticLockException. Разработчику важно обработать это либо сообщением пользователю, либо автоматическим ретраем операции. Часто рекомендуется реализовывать определенный цикл повторов: например, повторить сохранение до N раз, если версия постоянно меняется под ногами, либо отказаться после нескольких неудач (чтобы не уйти в бесконечный цикл при очень высокой конкуренции).
Для чего же оптимистичный подход может быть предпочтительнее? Он отлично подходит для систем, где конфликты случаются редко, а чтений намного больше, чем одновременных изменений. В таких случаях вы избегаете лишних блокировок и ожидания. Как отмечают материалы, этот механизм хорош там, где в основном много чтения и мало записи. Кроме того, оптимистичный лок удобен, когда данные долго “на руках” у пользователя (например, пользователь открыл форму, долго редактирует, потом сохраняет) – держать блокировку на запись так долго нельзя, а версионность позволит поймать конфликт при сохранении. С другой стороны, если у вас частые одновременные обновления одних и тех же записей (например, счетчики, финансовые транзакции), то пессимистическая блокировка может быть надежнее – иначе при оптимистичном подходе слишком много транзакций будет откатываться и повторяться заново, что ударит по производительности.
Заключение
Подведем итоги и сравним два подхода, а также когда какой применять:
- Row-Level Locking (Пессимистическая блокировка) – обеспечивает строгую последовательность выполнения транзакций на одной записи. Гарантирует отсутствие потерянных обновлений без необходимости ручных повторов – все конфликтующие операции просто выполняются по очереди. Не возникает ситуаций, когда два процесса “думают”, что они успешно записали каждый свое – второй всегда будет ждать первого. Обязательно применять при критических операциях, где недопустима потеря или дублирование данных (финансовые счета, инвентарь складов и пр.).
Минусы: при высокой конкуренции может снизиться производительность, так как потоки будут чаще ждать друг друга. Также нужно аккуратно следить за порядком блокировок, чтобы не попасть во взаимоблокировки (см. выше). Для высоконагруженных систем с частыми конфликтами пессимистический подход обеспечивает большую целостность, но ценой потенциального снижения параллелизма. - Optimistic Locking (Оптимистическая блокировка) – не ограничивает параллельный доступ, работает через проверку версий. Хорошо масштабируется для систем с преобладанием чтения: множество пользователей могут одновременно работать с данными, не блокируя друг друга, и лишь при одновременном обновлении возникает конфликт на этапе коммита. В случае конфликта вторая транзакция/запрос получает исключение и откатывается – требуется повтор запроса или уведомление пользователя. Данный подход удобен, когда конфликты редки: например, разные пользователи обычно работают с разными записями. Он исключает накладные расходы на локи и проблемы дедлоков (никаких долгих блокировок на уровне СУБД – значит, и классических deadlock из-за локов тоже не будет). Однако при нагрузке с частыми конфликтами оптимистичные откаты могут превратиться в серию постоянных повторов, что снизит эффективность. В таких случаях лучше явно синхронизировать доступ (пессимистично) или переработать архитектуру так, чтобы конкуренция уменьшилась.
Реальные примеры использования: В банковских приложениях обычно комбинируют подходы – для операций, связанных с переводом денег, часто используют пессимистические блокировки (или даже серверные хранимые процедуры, чтобы выполнить все атомарно). В интернет-магазинах при обработке заказа на остаток товара чаще применяются пессимистические блокировки или низкоуровневые механизмы типа SELECT … FOR UPDATE на строку товара, чтобы точно избежать oversell – минус один товар. В системах бронирования (авиабилеты, отели) – пессимистическая блокировка места на время оформления заказа. С другой стороны, в системах управления контентом, где редакторы редко лезут в один и тот же документ одновременно, может использоваться оптимистичный подход: если уж два редактора правят одновременно, система обнаружит конфликт версий при сохранении и предложит слить изменения вручную.
В контексте PostgreSQL стоит помнить, что эта СУБД сочетает оба подхода: MVCC снижает необходимость блокировать чтение, но при обновлениях PostgreSQL автоматически ставит row-level блокировки для защиты от конфликтующих записей. Если вы используете JPA с PostgreSQL, у вас “из коробки” работает оптимистичный подход (при наличии @Version) и базовая защита от грязного чтения на уровне изоляции Read Committed. Но для устранения lost updates нужно либо повышать уровень изоляции до Repeatable Read/Serializable (что автоматически бросит ошибку при конфликте в конце транзакции), либо использовать описанные выше механизмы: пессимистические локи (например, @Lock(PESSIMISTIC_WRITE) в репозитории) или оптимистические версии.
Вывод: опытный backend-инженер должен понимать характеристики данных в своей системе и выбирать стратегию синхронизации соответственно. Если допустить конкурентный конфликт совсем нельзя – применяем жесткую блокировку (пессимистическую). Если система рассчитана на огромное число параллельных чтений и редких конфликтов – лучше оптимистичный подход с обработкой коллизий по факту. В некоторых случаях комбинация: например, сначала попытаться сделать оптимистично, а при постоянных сбоях отладки перейти на пессимистичный. Главное – тестировать сценарии конкурентного доступа и убедиться, что при нагрузке данные остаются консистентными. Блокировка на уровне строк – мощный инструмент в арсенале, позволяющий достичь безопасной конкурентности без излишнего “торможения” всей базы (как при блокировке целых таблиц), поэтому важно уметь им правильно пользоваться для защиты данных ваших приложений.
You must be logged in to post a comment.