JavaRush /Курси /Spring Data JPA /Ізоляція: dirty/

Ізоляція: dirty/ non-repeatable/ phantom

Spring Data JPA
Рівень 19 , Лекція 0
Відкрита

1. Коли потрібна ізоляція під час читання

Коли ви починаєте писати бекенд, дуже хочеться вірити у світ, де код виконується суворо по черзі. Ви викликаєте метод сервісу — він усе читає, усе рахує, усе зберігає і йде відпочивати. А далі приходить наступний запит. На жаль, у реальному застосунку запити йдуть паралельно, користувачі натискають одночасно, планувальники запускають задачі, і ваша база даних змушена обслуговувати багато транзакцій в один і той самий момент.

У цей момент з’являється тонка, але важлива думка: коректна сервісна логіка сама по собі ще не гарантує передбачуваний результат читання. Якщо дві транзакції працюють одночасно, одна може побачити зміни іншої — питання лише в тому, які саме зміни і в який момент. Щоб це було не «як пощастить», а передбачувано, і існує тема рівня ізоляції.

Уявіть mini-shop. Одна людина оформлює замовлення, друга дивиться залишки, третя в адмінці активує й деактивує товари. Усі вони можуть торкатися пов’язаних таблиць. У якийсь момент «читання» перестає бути простим SELECT, тому що воно стає читанням в умовах конкуренції.

Ми вже звикли думати про транзакцію як про рамку однієї бізнес-операції: placeOrder(), changePrice(), reserve(). Але щойно поряд запускається друга така сама операція, commit/rollback уже недостатньо для розмови. Тепер важливо зрозуміти, що ці транзакції бачать одна в одної під час роботи.

2. Ізоляція як правило видимості даних

Слово isolation звучить так, ніби вас зараз змушують читати товсту книгу з СУБД, і наприкінці буде іспит: «перелічіть усі рівні ізоляції, не кліпнувши». Сьогодні ми робимо простіше й практичніше: будемо вважати, що рівень ізоляції — це правило видимості даних між транзакціями. Тобто домовленість, яка відповідає на питання: «Що одна транзакція може побачити з того, що відбувається в іншій транзакції?»

Важливо, що це обговорюють на рівні сервісної операції. Не на рівні окремого репозиторію і точно не на рівні «ось тут у мене поле availableQuantity — давайте йому поставимо ізоляцію». Ізоляція — це характеристика транзакції, тобто того «контейнера», у якому виконується сценарій використання.

У Spring це найчастіше виглядає так:

import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

// readOnly = true — ми явно говоримо, що це операція читання (без запису).
// isolation задає правила видимості даних, які ми хочемо отримати від БД.
@Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED)
public int currentAvailable(Long productId) {
    // Важливо: це читання має бачити лише зафіксовані (committed) зміни.
    return stockItemRepository.findAvailableQuantityByProductId(productId);
}

Тут важливо навіть не те, що ми прописали Isolation.READ_COMMITTED, а те, що ми мислено кажемо: «Це транзакція читання, і я хочу, щоб вона бачила лише зафіксовані (committed) дані». Це вже інженерна мова, а не магічні анотації.

Щоб не заплутатися, зафіксуємо терміни в одній таблиці — без фанатизму, але як шпаргалку для мозку:

Термін Що означає людською мовою Мініприклад у mini-shop
Транзакція Група операцій, яка має завершитися цілком або не завершитися зовсім placeOrder() створює замовлення і зменшує залишки
Ізоляція Правила «хто що бачить», коли транзакції йдуть паралельно Поки один зменшує залишок, інший читає залишок
Аномалія читання Неочікувана поведінка повторного читання під час конкуренції Порахували «10», а за секунду в межах тієї самої операції побачили «7»

І тепер — три «страшилки» ізоляції, які насправді дуже життєві.

3. dirty read: читання незакомічених даних

Брудне читання звучить як щось зі світу пліток. І по суті так і є: ви прочитали дані, які інша транзакція ще не зафіксувала, і які, можливо, взагалі ніколи не стануть «правдою». У реальному бізнесі це схоже на ситуацію: «співробітник сказав, що замовлення оформлено, але потім виявилося, що він не натиснув «Зберегти» і пішов пити каву».

Класична схема dirty read виглядає так: транзакція B змінила рядок, але не зробила commit. Транзакція A уже прочитала цю зміну і прийняла рішення. Потім транзакція B робить rollback. Виходить, що транзакція A прийняла рішення на основі даних, яких «офіційно» не існувало.

Намалюємо це як таймлайн:

sequenceDiagram
    participant A as "TX-A (читає)"
    participant DB as DB
    participant B as "TX-B (змінює)"

    B->>DB: "UPDATE stock_item SET available=0 (без commit)"
    A->>DB: SELECT available FROM stock_item
    DB-->>A: "0 (брудне, незафіксоване значення)"
    B->>DB: ROLLBACK
    Note over A: TX-A вже встигла повірити в "0" і ухвалити рішення

У нашому проєкті mini-shop це могло б виглядати так: один потік «резервує» товар і тимчасово робить availableQuantity = 0, другий потік читає залишок і показує користувачу «немає в наявності», хоча за секунду резервування відкотиться.

Практична ремарка на рівні здорового глузду: у більшості сучасних production-БД, зокрема в PostgreSQL, брудні читання зазвичай не є типовою проблемою за замовчуванням. Але сам термін корисний, тому що він вчить вас відрізняти «дані, які вже стали правдою» від «даних у процесі зміни». І головне — він пояснює, чому багато систем узагалі хочуть, щоб читання не бачило незакомічених змін.

4. non-repeatable read: повторне читання рядка

Неповторюване читання — це вже не про «бруд», а про чесні зафіксовані зміни, які просто сталися між двома вашими читаннями. Тобто ви двічі в межах однієї операції читаєте один і той самий рядок, але отримуєте різний результат, тому що хтось інший встиг зробити commit.

Звучить безневинно, доки ви не зрозумієте, що в сервісі це легко перетворюється на «я сам собі суперечу». Наприклад, ви прочитали залишок 10 і вирішили, що можна резервувати 8. Потім ще раз прочитали залишок — або порахували щось на його основі — і раптово побачили 7, тому що інший користувач встиг купити 3. І тепер ваша операція опиняється у дивному положенні: вона стартувала з одних передумов, а продовжує з іншими.

Міні-модель, як у коментарях, щоб не заходити в багатопоточність:

public void nonRepeatableRead() {
    // Обидва читання виконуються в межах однієї TX-A, але між ними встигає закомітитися TX-B.
    // TX-A: читає availableQuantity = 10
    // TX-B: змінює availableQuantity на 7 і робить commit
    // TX-A: повторно читає той самий рядок і отримує вже 7
}

Щоб це було ближче до коду нашого проєкту, покажемо репозиторний метод, який повертає скалярне значення — число, а не сутність. Це важливо: такий виклик справді робить SQL-запит і звертається до БД, а не впирається в особливості об’єктного кешу ORM.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface StockItemRepository extends JpaRepository<StockItem, Long> {

    // Повертаємо саме число, а не сутність: запит гарантовано піде в БД.
    // Це корисно в прикладах ізоляції, щоб спостерігати "реальну" конкуренцію.
    @Query("select s.availableQuantity from StockItem s where s.product.id = :productId")
    int findAvailableQuantityByProductId(Long productId);
}

І сервісне читання — звичайний випадок:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class StockReadService {

    private final StockItemRepository stockItemRepository;

    public StockReadService(StockItemRepository stockItemRepository) {
        this.stockItemRepository = stockItemRepository;
    }

    @Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED)
    public int currentAvailable(Long productId) {
        // При READ_COMMITTED кожне читання бачить те, що закомічено на момент запиту.
        return stockItemRepository.findAvailableQuantityByProductId(productId);
    }
}

Тепер уявіть складніший сценарій читання: ви в одному сервісному методі робите два пов’язані читання і очікуєте, що дані не смикнуться посередині. Під READ_COMMITTED це очікування може не справдитися: кожне окреме читання чесно бачить те, що вже закомічено на момент запиту. Між запитами життя встигло статися — і ви бачите нове життя.

5. phantom read: повторний запит і нові рядки

phantom read відрізняється від non-repeatable read важливою деталлю. У non-repeatable read змінюється значення одного й того самого рядка. У phantom read змінюється набір рядків, який підходить під умову запиту. Ви запускаєте один і той самий запит двічі, але вдруге він повертає «ще один рядок», якого раніше наче не було. Звідси й «привид».

Найзрозуміліший приклад — агрегати: count(), sum(), вибірка списку за фільтром. Ви рахуєте кількість активних товарів у категорії, а в паралельній транзакції хтось додає ще один активний товар і комітить. Повторний count() дає інше число — хоча ви, можливо, вважаєте це одним «знімком» даних.

Міні-модель:

public void phantomRead() {
    // Тут змінюється не один рядок, а сам набір рядків, що підходять під умову запиту.
    // TX-A: рахує активні товари в категорії BOOKS -> 20
    // TX-B: додає ще один активний товар у категорію BOOKS і робить commit
    // TX-A: повторює той самий запит і отримує вже 21
}

Те саме ближче до нашого каталогу. Припустімо, у нас є запит «скільки активних товарів у категорії за кодом категорії». У проєкті в Category є code — це просто проситься в запити.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // JPQL-запит: рахуємо кількість рядків, що підходять під умову (це і робить фантоми помітними).
    @Query("""
           select count(p)
           from Product p
           where p.active = true and p.category.code = :categoryCode
           """)
    long countActiveByCategoryCode(String categoryCode);
}

І ось сценарій phantom read виглядає в термінах SQL дуже просто:

-- TX-A (робимо запит і фіксуємо число в логіці сервісу)
select count(*) from product
where active = true and category_id = (select id from category where code = 'BOOKS');

-- TX-B (між двома запитами TX-A)
insert into product(id, sku, name, active, category_id, price)
values (...);
commit;

-- TX-A (повторює)
select count(*) from product
where active = true and category_id = (select id from category where code = 'BOOKS');

Важливе практичне відчуття: phantom read часто б’є по тих операціях, де ви в сервісі робите «спочатку порахувати, потім ухвалити рішення». Ви думали, що порахували реальність цілком, а насправді рахували реальність на момент конкретного запиту.

6. READ_COMMITTED і REPEATABLE_READ

У рівнів ізоляції є багато варіантів, але на нашому рівні — і в межах завдань mini-shop — нам зараз достатньо тримати в голові дві опори. Перша — READ_COMMITTED: це найчастіший звичайний режим, де транзакція бачить лише те, що вже зафіксовано. Друга — REPEATABLE_READ: тут транзакція прагне дати вам стабільнішу картину під час повторних читань у межах однієї операції.

Ми не будемо перетворювати це на енциклопедію. Нам важливий інженерний сенс: READ_COMMITTED — це «я читаю актуальне на момент кожного запиту», а REPEATABLE_READ — це «я хочу, щоб повторні читання в межах операції не стрибали».

Зберемо це в максимально прикладну таблицю, як чек-лист очікувань:

Що може статися під час паралельної роботи READ_COMMITTED (типовий режим) REPEATABLE_READ (стабільніше читання)
Побачити незакомічені зміни іншої транзакції (dirty read) Зазвичай ні (читаємо лише зафіксовані дані) Ні
Двічі прочитати один рядок і отримати різні значення (non-repeatable read) Може статися Зазвичай не має ставатися в межах однієї транзакції
Двічі виконати запит за умовою і отримати різний набір рядків (phantom read) Може статися Зазвичай не має «скакати» в межах однієї транзакції

І тепер — два мініприклади, як це виражається в сервісах.

Звичайне читання залишків — повсякденний режим:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class StockReadService {

    private final StockItemRepository stockItemRepository;

    public StockReadService(StockItemRepository stockItemRepository) {
        this.stockItemRepository = stockItemRepository;
    }

    @Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED)
    public int currentAvailable(Long productId) {
        // Типова логіка: бачимо лише закомічене "на зараз", без обіцянок стабільного знімка.
        return stockItemRepository.findAvailableQuantityByProductId(productId);
    }
}

Більш «стабільний знімок» потрібен не для одиночного findById(), а для операції читання, яка збирає одну картину з кількох пов’язаних запитів. Наприклад, ви завантажуєте замовлення, потім його позиції, потім рахуєте підсумок для звіту і не хочете, щоб дані стрибали між цими читаннями. Нижче — каркас саме такого сценарію читання: він починається із завантаження замовлення, але сенс REPEATABLE_READ з’являється лише тому, що далі метод спирається на той самий знімок.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderQueryService {

    private final CustomerOrderRepository orderRepository;

    public OrderQueryService(CustomerOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
    public CustomerOrder loadStableView(Long orderId) {
        // Сам по собі один findById не потребує REPEATABLE_READ.
        // Такий режим має сенс, якщо далі цей сценарій читання добирає пов'язані дані
        // і очікує один стабільний знімок.
        return orderRepository.findById(orderId).orElseThrow();
    }
}

Зверніть увагу: readOnly і isolation — це різні виміри. readOnly більше про намір «я не збираюся змінювати дані», а isolation — про те, наскільки стабільно я хочу бачити картину даних, якщо паралельно хтось їх змінює.

Як думати про ізоляцію на рівні сценарію використання

Найчастіша помилка новачка тут — вирішити, що сильна ізоляція завжди краща. Це приблизно як вирішити, що каска в офісі безпечніша, ніж без каски, і тому ви ходитимете в касці на кухню пити чай. Формально ви можете, але люди почнуть ставити питання, а вам стане жарко. З ізоляцією схожа логіка: «суворіше» часто означає «дорожче», і іноді — просто зайве.

Тому правильний спосіб обирати ізоляцію починається не з анотації, а з питання: «Чого я боюся в цьому конкретному сценарії використання?» Якщо ваш сервісний метод робить один запит і відразу повертає результат, то non-repeatable read і phantom read для нього майже не мають сенсу: вам нічого «повторювати». Ви прочитали поточне — і все. Тоді типовий READ_COMMITTED зазвичай ідеальний: він дає живу картину.

Якщо ж ваш сценарій читання робить кілька запитів і ви хочете, щоб вони були узгоджені між собою, тоді у вас з’являється аргумент на користь стабільнішого режиму. Типовий приклад, без заглиблення в майбутні теми: ви читаєте замовлення, потім читаєте його позиції, потім рахуєте суму або формуєте «знімок» для звіту. Якщо між цими читаннями паралельна транзакція змінить дані, ви можете зібрати «гібрид» зі старого і нового стану.

Ще одне важливе відчуття: ізоляцію має сенс задавати там, де ви справді формулюєте «операцію». Тобто на сервісі. Репозиторій не повинен перетворюватися на килим із @Transactional просто тому, що ви одного разу злякалися фантомів. Репозиторій — це інструмент доступу до даних; він не має вирішувати, наскільки «стабільним» має бути бізнес-знімок.

7. Типові помилки під час роботи з ізоляцією

Помилка №1: сприймати ізоляцію як тему «для DBA», а не для розробника сценарію використання.
Таке мислення зазвичай закінчується дивним діагнозом: «у нас іноді звіт показує різні цифри, але це, мабуть, база глючить». База не глючить — база чесно обслуговує паралельні транзакції. Якщо ваш сервіс робить кілька читань і очікує внутрішньої узгодженості, це вже ваше інженерне завдання: сформулювати очікування і вибрати режим, який робить поведінку передбачуваною.

Помилка №2: плутати non-repeatable read і phantom read, через що ви лікуєте не ту проблему.
Якщо у вас «стрибає» значення одного рядка, це історія про неповторюване читання. Якщо у вас «стрибає» кількість рядків або склад списку, це фантоми. Коли ці дві речі змішують, з’являється хаос в обговореннях: одна людина каже «у нас фантоми», друга латає читання одного рядка, третя взагалі вирішує, що потрібно переписати проєкт на «щось без SQL».

Помилка №3: обирати найсуворіший режим «про всяк випадок», не поставивши собі питання «навіщо?».
Це майже гарантовано призводить до того, що транзакції починають жити довше, тримати ресурси довше і просто сповільнювати систему без видимої користі. Правильна звичка — спочатку описати сценарій паралельної роботи (дві транзакції, порядок дій, де саме ви бачите проблему), і лише потім вирішувати, чи потрібна стабільніша видимість.

Помилка №4: обговорювати ізоляцію на рівні «репозиторій зробив SELECT», забуваючи, що йдеться про сервісну операцію цілком.
Один запит сам по собі рідко «ламається» через ізоляцію. Ламається зазвичай композиція: «прочитав A, прочитав B, порівняв, ухвалив рішення». Саме там і треба думати про передбачуваність видимості. Якщо дивитися на це лише як на «ну ось цей репозиторій іноді повертає інше число», ви будете виправляти симптоми, не бачачи причини.

Помилка №5: очікувати, що readOnly = true автоматично гарантує стабільне читання і захищає від аномалій.
readOnly — це про намір і потенційні оптимізації, але це не «щит від конкурентного світу». Ви можете бути абсолютно «read-only» і при цьому читати дані, які змінюються паралельно. Стабільність повторних читань — це окрема вісь, яка вирішується ізоляцією та правильним проєктуванням сценарію використання.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ