Разные виды блокировок доступа
Разные виды блокировок доступа

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


Что такое транзакция?


Это единый набор логически связанных операций. Основные типы:

  • Read. Операция чтения. Читает значение с Базы Данных(БД) и сохраняет в буфере памяти.

  • Write. Операция записи. Записывает значение из буфера в БД.

Проблемный сценарий

Бронирование места в кино/самолёте/отеле многими пользователями в одно и тоже время. Пути возникновения:

  1. Один пользователь кликнул "забронировать" много раз

  2. Множество пользователей забронировали тоже место/комнату/слот одновременно.

Рассмотрим обобщенную табличку "Booking" для лучшего понимания.

1ую проблему можно легко решить(к примеру, введением ключа идемпотентности). Рассмотрим более детально вторую.

Решения

Несколько пользователей бронируют одновременно
Несколько пользователей бронируют одновременно

Множественное бронирование может произойти, если сервис всего-лишь проверяет возможность брони. Тогда N параллельных запросов проверив одновременно осуществят бронь. В таком случае могут помочь Database Lockings - оптимистичная и пессимистичная блокировки.

Оптимистичная блокировка

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

  1. Прочитать данные с версией/меткой времени

  2. Изменить данные и версию

  3. Обновить данные, если изначальная версия не изменилась. Иначе повторить алгоритм.

Это отличное решение для небольших нагрузок. Может быть реализовано с помощью аннотаций в Spring Data JPA.

Недостатки

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

Пессимистичная блокировка

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

Наподобие мьютекса в многопоточном программирование реализует принцип ожидания, если ресурс занят.

Такую блокировку реализуют РСУБД Postgres, MYSQL, Oracle. Также ORM Spring Data JPA.
Казалось бы, все проблемы с конкурентным доступом решены. Но что будет в распределенных системах?

Распределенная блокировка

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

Распределенная блокировка - механизм для скоординированного доступа к совместным ресурсам для всех участников распределенной системы. Главная цель такой блокировки - обеспечить доступ к ресурсу лишь одному сервису/запросу в данный момент времени. И предотвратить гонку за данными и неконсистентность данных.

Для её реализации могут быть использованы решения:

  • Redis, который реализует алгоритмы типа ShedLock, Redisson. Однако, они подвергаются критики

  • Hazelcast предоставляет систему блокировки, основанную на CP subsystem

  • Zookeeper, который рассмотрим далее

Реализация Распределенной Блокировки с Apache ZooKeeper

Apache ZooKeeper является распределенным координирующим сервисом, который может быть использован для реализации такой блокировки.

Пример Java реализации:

import org.apache.zookeeper.ZooKeeper;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import java.util.concurrent.TimeUnit;

public class DistributedLock {
    private CuratorFramework client;
    private InterProcessMutex lock;
    public DistributedLock(String zkConnectionString, String lockPath) {
        client = CuratorFrameworkFactory.newClient(zkConnectionString, new ExponentialBackoffRetry(1000, 3));
        client.start();
        lock = new InterProcessMutex(client, lockPath);
    }
    public boolean acquire(long waitTime, TimeUnit timeUnit) {
        try {
            return lock.acquire(waitTime, timeUnit);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    public void release() {
        try {
            lock.release();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public void close() {
        client.close();
    }
}

Использование:

public static void main(String[] args) {
    String zkConnectionString = "127.0.0.1:2181";
    String lockPath = "/my_resource_lock";

DistributedLock lock = new DistributedLock(zkConnectionString, lockPath);
    // Acquire the lock
    try {
      if (lock.acquire(100, TimeUnit.MILLISECONDS)) {
        try {
          // Access the shared resource
          // Perform your operations here
        } finally {
          lock.release();
        }
      }
    } finally {
      lock.close();
    }
}

Захват блокировки:

Освобождение блокировки:

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

---

Дополнительно:

Базы Данных: телеграмм пост по сравнению clickhouse VS tarantool

Systemd Design: мой канал по подготовке к System Design Interview

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


  1. BugM
    05.01.2024 20:36

    У вас в коде ошибки.

        if (lock.acquire(100, TimeUnit.MILLISECONDS)) {
            // Access the shared resource
            // Perform your operations here
            // Release the lock
            lock.release();
        }
        // Close the ZooKeeper connection
        lock.close();

    Вот отсюда локи будут течь. Так код писать нельзя.


    1. avovana7 Автор
      05.01.2024 20:36

      Принято, спасибо!


  1. orrollo
    05.01.2024 20:36

    Наверно, нужно чуть поправить вариант использования:

    ...
      DistributedLock lock = new DistributedLock(zkConnectionString, lockPath);
      try {
        if (lock.acquire(100, TimeUnit.MILLISECONDS)) {
          try {
            // Access the shared resource
            // Perform your operations here
          } finally {
            lock.release();
          }
        }
      } finally {
        lock.close();
      }
    


    1. avovana7 Автор
      05.01.2024 20:36

      Спасибо! Обновил.


    1. BugM
      05.01.2024 20:36
      +1

      Лучше так:

      public void someUsersLogic(CuratorFramework client) {
          try (DistributedLock lock = new DistributedLock(client, "lockPath", 10, TimeUnit.SECONDS)) {
              System.out.println("locked");
          } catch (Exception e) {
              throw new RuntimeException(e); //сюда попадаем при ошибках взятия лока
          }
      }
      
      public class DistributedLock implements AutoCloseable {
          private final InterProcessMutex lock;
      
          public DistributedLock(CuratorFramework client, String lockPath, long waitTime, TimeUnit timeUnit) throws Exception {
              lock = new InterProcessMutex(client, lockPath);
              lock.acquire(waitTime, timeUnit);
          }
      
          @Override
          public void close() {
              try {
                  lock.release();
              } catch (Exception ignored) {
              }
          }
      }

      Так и код чище и абстракции не протекают и шанс ошибиться ниже и даже писать меньше.