JavaRush /Курсы /Модуль 4. Работа с БД /Как реализовать ACID в приложении: практика

Как реализовать ACID в приложении: практика

Модуль 4. Работа с БД
18 уровень , 7 лекция
Открыта

8.1 ID транзакций

Обозначается как XID или TxID (если есть разница - подскажите). В качестве TxID можно использовать timestamp'ы, что может пригодиться, если мы захотим восстановить все действия к какому-то моменту времени. Проблема может возникнуть, если timestamp недостаточно гранулярный - тогда транзакции могут получить один и тот же ID.

Поэтому, самый надежный вариант – это генерировать уникальные ID при помощи UUID.


import java.util.UUID;

public class GenerateUUID {
    public static void main(String[] args) {
        System.out.println(UUID.randomUUID().toString()); // f50ec0b7-f960-400d-91f0-c42a6d44e3d0
        System.out.println(UUID.randomUUID().toString()); // d15bed89-c0a5-4a72-98d9-5507ea7bc0ba
    }
}

Также можно хэшировать данные, определяющие транзакцию, и использовать этот хэш в качестве TxID.

8.2 Повторные попытки ("retries")

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

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


import java.util.logging.Level;
import java.util.logging.Logger;

import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import io.github.resilience4j.retry.RetryRegistry;

public class DoSomethingUnreliable {

    private static final Logger LOGGER = Logger.getLogger(DoSomethingUnreliable.class.getName());

    private static final RetryRegistry RETRY_REGISTRY = RetryRegistry.ofDefaults();

    private static final Retry DO_SOMETHING_UNRELIABLE_RETRY = RETRY_REGISTRY.retry("doSomethingUnreliableRetry", RetryConfig.custom()
            .maxAttempts(5)
            .waitDuration(RetryConfig.exponentialWait(1, 4, 10))
            .retryOnException(IOException.class)
            .onRetry((retryContext) -> LOGGER.log(Level.DEBUG, "Retrying doSomethingUnreliable: attempt {0} of {1}", new Object[]{retryContext.getAttemptCount(), retryContext.getNumberOfAttempts()}))
            .build());

    public static void main(String[] args) throws IOException {
        String result = doSomethingUnreliable();
        System.out.println(result);
        System.out.println(DO_SOMETHING_UNRELIABLE_RETRY.getMetrics().toString());
    }

    @Retry(name = "doSomethingUnreliableRetry")
    private static String doSomethingUnreliable() throws IOException {
        if (random.nextInt(10) > 1) {
            throw new IOException("Broken sauce, everything is hosed!");
        } else {
            return "Awesome sauce!";
        }
    }
}

Awesome sauce!
Metrics:
  name: doSomethingUnreliableRetry
  numberOfSuccessfulCalls: 1
  numberOfFailedCalls: 0
  numberOfRetryAttempts: 0

Как мы видим, повторные попытки можно оформить креативно.

  • Можно ограничить попытки по времени (10 секунд) или количеству (5).
  • Также можно экспоненциально (то есть, 2 ** некоторое увеличивающееся число n) или фиксированно увеличивать время между отдельными попытками (называется «congestion collapse»).
  • Также можно делать повторные попытки только для некоторых видов ошибок.
  • Повторные попытки можно предварять или завершать особыми записями в лог.

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

8.3 Продвинутый инструментарий для любителей транзакций

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

Two-phase commit (2pc). У 2pc две фазы: подготовка и фиксация. Во время подготовки микросервисы готовятся к изменениям данных, которые можно выполнить атомарно. После этого, во время фиксации, происходят реальные изменения. Для синхронизации нужен глобальный координатор, который блокирует нужные объекты. Если какой-то микросервис не готов к изменениям, координатор прервет транзакцию и начнет процесс отката.

Что представляет собой этот протокол? Он обеспечивает атомарность. Также он гарантирует изоляцию при записи и чтении. Это означает, что изменения одной транзакции не будут видны другим, пока координатор не зафиксирует их. Но эти свойства имеют и минус: поскольку протокол синхронный (блокирующий), он замедляет работу системы (в то время как вызов RPC сам по себе довольно медленный). Также возникает риск взаимной блокировки.

Шаблон Saga использует асинхронные локальные транзакции во всех связанных микросервисах. Микросервисы связаны между собой через шину событий. Если один из микросервисов не может завершить свою транзакцию, другие микросервисы откатят изменения с помощью компенсационных транзакций.

Плюсом Saga является то, что никакие объекты не блокируются. Однако, есть и минусы.

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

Что мы видим? В распределенных системах и БД, которые не предоставляют гарантии ACID, приложение должно быть ответственным за атомарность и изоляцию. То есть, разработчику придется решать конфликты, делать откаты, коммиты и высвобождать место.

8.4 Как понять, когда мне нужны гарантии ACID?

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

Извините за банальность, но типичным примером являются финансовые транзакции.

Когда порядок выполнения транзакций имеет значение.

Представьте себе, что ваша компания решила перейти с мессенджера FunnyYellowChat на FunnyRedChat, потому что в нём можно отправлять гифки. Но вы не просто меняете мессенджер — вы мигрируете переписку вашей компании из одного мессенджера в другой.

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

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

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

Другой возможный пример — это биоинформатика. Я в этом не разбираюсь, но предполагаю, что при расшифровке генома человека порядок важен. Слышал, что биоинформатики используют свои инструменты и БД.

Когда нельзя выдать пользователю или процессу устаревшие данные.

Финансовые транзакции. Не могу придумать другой пример.

Когда незавершённые транзакции связаны со значительными издержками. Представьте себе, какие проблемы могут возникнуть, если врач и медсестра одновременно обновляют карту пациента и перезаписывают изменения друг друга, потому что БД не может изолировать транзакции. Система здравоохранения — это другая область, в которой гарантии ACID, как правило, крайне важны.

8.5 В каких случаях мне не нужны ACID?

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

Когда пользователи вообще не обновляют данные, а только дополняют новыми (append). Например, приложение для бега, которое сохраняет данные о ваших пробежках: сколько вы пробежали, за какое время, маршрут и т.д. Каждая новая пробежка — новые данные, а старые не редактируются. Возможно, на основании этих данных вы получаете аналитику — и для этого сценария отлично подходит БД NoSQL.

Когда бизнес-логика не определяет необходимость некоего порядка выполнения транзакций. Для блогера на YouTube, который принимает пожертвования для производства нового материала во время прямого эфира, неважно, кто и в какой последовательности дарит деньги.

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

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


Транзакции, ACID и CAP

Комментарии (5)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Валерий кривой Уровень 63
4 декабря 2025
В школе учительница просит учеников принести мячи для игр на следующий день. Каждый ученик ведёт свой блокнот, в котором записывает, сколько мячей ещё нужно, а все принесённые мячи кладут в корзину. Рассмотрим, как при этом могут возникнуть разные проблемы чтения и обновления данных: 1️⃣ Dirty Read (грязное чтение) - На доске написано 5 мячей. - Алиса решает принести 1 мяч и записывает 4 в свой блокнот, но ещё не положила мяч в корзину. - Боб смотрит на доску и видит 4 (потому что Алиса временно написала, что осталось 4), и записывает это в свой блокнот. - Позже Алиса понимает, что не сможет принести мяч, и возвращает запись обратно на 5. ❌ Результат: Боб записал неправильное число, потому что прочитал «грязное» значение, которое не было подтверждено. 2️⃣ Non-Repeatable Read (неповторяемое чтение) - Алиса записывает 5 мячей в свой блокнот. - Учитель находит 2 мяча и меняет доску на 3. - Алиса снова смотрит на доску, чтобы проверить, и видит 3 мяча, а не 5. ❌ Результат: Алиса видит разные значения при двух чтениях одной и той же «транзакции», что создаёт несоответствия. 3️⃣ Lost Update (потерянное обновление) - Исходная доска: 5 мячей. - Алиса записывает, что принесёт 1 мяч → 5-1=4. - Боб записывает, что принесёт 2 мяча → 5-2=3. - Учитель находит 2 мяча и меняет доску на 3. - Алиса приносит свой мяч и кладёт его в корзину, затем обновляет доску на 4, перезаписывая изменение учителя. ❌ Результат: доска показывает 4 мяча, но на самом деле их не должно было оставаться (учитель уже нашёл 2, а Алиса принесла 1). 4️⃣ Phantom Read (фантомное чтение) - Алиса проверяет доску и записывает в блокнот: «осталось 5 мячей». - В это время новый ученик находит 1 мяч и кладёт его в корзину, но учитель не обновляет доску. - Если Алиса снова проверит, сколько мячей не хватает, учитывая записи всех добровольцев, она увидит 4 мяча, хотя раньше было 5. ❌ Результат: появляется «фантом» (неожиданное изменение данных), которое меняет результат запроса Алисы.
Валерий кривой Уровень 63
4 декабря 2025
Использование уровней изоляции транзакций с примерами 1. READ UNCOMMITTED (самый низкий уровень) Когда использовать: когда важна максимальная скорость и можно допустить чтение «грязных» данных. Пример: Алиса записывает, что принесёт мяч, но ещё не положила его в корзину. Боб сразу видит это значение в блокноте. Могут быть ошибки, но транзакция очень быстрая. Плюс: очень быстро. Минус: возможны грязные чтения, ошибки. 2. READ COMMITTED Когда использовать: когда нужно читать только подтверждённые данные. Пример: Алиса кладёт мяч в корзину и обновляет число на доске (commit). Боб видит число только после подтверждения. Грязные чтения исключены. Плюс: предотвращает грязные чтения. Минус: Боб может ждать подтверждения, небольшая задержка. 3. REPEATABLE READ Когда использовать: когда нужно, чтобы данные не менялись при повторных чтениях в одной транзакции. Пример: Алиса записала 5 мячей в блокнот. Даже если учитель нашёл мячи или Боб обновил число, она до конца транзакции видит 5. Плюс: данные Алисы стабильны, исключает неповторяемое чтение. Минус: блокирует некоторые изменения, возможны задержки. 4. SERIALIZABLE Когда использовать: когда нужно полное согласование и никакие фантомные изменения не допускаются. Пример: Алиса берёт ключ от корзины или единственный мел, кладёт мяч в корзину и обновляет число на доске (commit). Пока транзакция Алисы не завершена, никто не может изменить корзину или доску. Фантомы исключены. Плюс: полностью предотвращает потерю данных и фантомные чтения. Минус: самая строгая блокировка, может сильно замедлить систему.
Олег Уровень 106 Expert
26 октября 2024
Middle - темы
И. Ж. Уровень 41
20 мая 2024
Просто кусок кода вставлено, лишь бы вставить, ни обьяснения, ничего. Откуда к примеру переменная retryContext, где обьявлена, что значит..
Oleg Уровень 41
22 января 2023
Очень интересная тема, но надо доработать статью