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» і при цьому читати дані, які змінюються паралельно. Стабільність повторних читань — це окрема вісь, яка вирішується ізоляцією та правильним проєктуванням сценарію використання.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ