Друкарня від WE.UA

Java Memory Model ↔ PostgreSQL MVCC: один і той самий фундамент конкурентності

Зміст

Два світи — одна проблема

Java Memory Model та PostgreSQL MVCC/Isolation Levels — це дві відповіді на одне й те саме фундаментальне питання: за яких умов зміна, зроблена одним учасником (потоком / транзакцією), стає видимою для іншого?

На перший погляд — це абсолютно різні доменні області. JMM оперує CPU-кешами, compiler reordering, happens-before зв'язками. PostgreSQL — xmin/xmax, snapshot'ами, WAL, MVCC-версіями рядків. Але якщо подивитись на рівень абстракції вище, виявляється, що обидві системи вирішують ідентичну задачу з ідентичним набором trade-off'ів: consistency vs performance під конкурентним доступом до shared mutable state.

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

Застереження перед читанням

Аналогії між JMM і PostgreSQL працюють на рівні патернів та trade-off'ів, а не на рівні реалізації. JMM — це контракт між кодом і CPU/compiler. PostgreSQL isolation — контракт між транзакцією та storage engine. Масштаб, механізми та деталі різні. Але інженерні рішення, які стоять за ними, дивовижно симетричні.


Спільний фундамент: Shared Mutable State

У Java конкурентність стає проблемою лише тоді, коли є спільний змінюваний стан — три умови, прибери будь-яку — і race condition зникає:

Умова

Java

PostgreSQL

Shared

Heap-об'єкт доступний з кількох потоків

Рядок у таблиці доступний з кількох транзакцій

Mutable

Поле можна змінити після створення об'єкта

Рядок можна UPDATE/DELETE

Concurrent access

2+ потоки, хоча б один пише

2+ транзакції, хоча б одна пише

І рішення теж симетричні:

Стратегія усунення

Java

PostgreSQL

Прибрати Shared

Thread confinement, ScopedValue

Partitioning per connection, advisory locks per tenant

Прибрати Mutable

final fields, Records, immutable collections

Append-only таблиці, INSERT замість UPDATE, event sourcing

Контролювати Access

synchronized, Lock, Atomic*, volatile

Row locks, table locks, SERIALIZABLE isolation, SELECT FOR UPDATE

Це не просто гарна аналогія — це один і той самий фундаментальний принцип інженерії конкурентних систем.


Visibility: happens-before ↔ Isolation Levels

Що таке happens-before

Happens-before — це формальне відношення між діями в JMM (визначене в JLS §17.4). Якщо дія A happens-before дії B, то ефекти A гарантовано видимі для B. Термін прийшов з теорії розподілених систем (Lamport, 1978) і моделюється як направлений граф: дії — вершини, happens-before зв'язки між ними — ребра (edges) цього графу. Коли далі в тексті я пишу «HB-зв'язок від A до B», це означає «A happens-before B, тобто ефекти A видимі для B».

Головне питання обох моделей

В Java: «Коли запис, зроблений потоком A, гарантовано видимий для читання потоком B?»

В PostgreSQL: «Коли зміна, зроблена транзакцією T1 (навіть уже committed), стає видимою для SELECT в транзакції T2?»

Відповідь в обох випадках — не автоматично. Потрібен явний механізм, який встановлює гарантію видимості.

В Java без happens-before зв'язку JVM та CPU мають право тримати значення в кешах нескінченно (ви бачите stale data) або переставляти інструкції (ви бачите «неможливий» порядок подій). Це не баг — це оптимізація.

В PostgreSQL MVCC робить щось концептуально подібне: транзакція бачить snapshot бази, зроблений у певний момент часу. Навіть якщо інша транзакція вже зробила COMMIT — ваш snapshot може цього не відображати. Це теж не баг — це ізоляція.

Ось систематична відповідність — з чесними застереженнями, де аналогії неідеальні.


READ UNCOMMITTED ↔ Відсутність happens-before (plain read у JMM)

PostgreSQL: Теоретично дозволяє dirty reads — бачити незакомічені дані інших транзакцій. (У PostgreSQL фактично не реалізований — READ UNCOMMITTED працює як READ COMMITTED.)

Java: Читання без volatile/synchronized — plain read. JMM не гарантує, що ви побачите останнє записане значення. Ви можете побачити stale значення з CPU-кеша, або навіть значення, яке «ще не мало б існувати» (через reordering).

Спільне: Мінімальні гарантії видимості. Максимальна продуктивність. Практично непридатно для коректної логіки.


READ COMMITTED ↔ per-access freshness (volatile для одного поля, окремий synchronized-блок для ширшого стану)

PostgreSQL: Кожен окремий SQL-оператор бачить все, що було committed на момент його початку. Але два послідовних SELECT в одній транзакції можуть бачити різні дані, якщо між ними хтось зробив COMMIT. Ключове: snapshot береться per-statement, а не per-transaction.

-- Транзакція T2, READ COMMITTED
SELECT balance FROM accounts WHERE id = 1; -- бачить 1000
-- T1 робить: UPDATE ... SET balance = 500; COMMIT;
SELECT balance FROM accounts WHERE id = 1; -- бачить 500 (!)
-- Non-repeatable read

Java: Найближчий аналог залежить від масштабу:

Для одного поля: volatile read гарантує, що ви побачите останній запис у цю конкретну змінну. Між двома volatile reads інший потік може змінити значення — non-repeatable read.

volatile int balance = 1000;

// Thread B:
int a = balance;     // бачить 1000 (volatile read — fresh value)
// Thread A: balance = 500;
int b = balance;     // бачить 500 (!)
// Non-repeatable read — подібний патерн

Для кількох полів: окремий synchronized-блок на кожне читання (різні entry/exit для кожного) — ви бачите fresh стан на момент входу в блок, але між двома блоками стан може змінитись.

Спільний патерн: Гарантія «бачу committed / записане на момент цього конкретного читання», але без консистентності між послідовними читаннями.

Де аналогія ламається: volatile працює per-variable — ви бачите fresh значення конкретного поля, але не маєте гарантій щодо інших полів. READ COMMITTED дає fresh snapshot усієї бази per-statement — це ширша гарантія. Точнішою Java-аналогією READ COMMITTED було б «кожен окремий synchronized-блок бере fresh snapshot кількох полів, але між блоками консистентність не гарантована».


REPEATABLE READ ↔ snapshot isolation (StampedLock optimistic read / immutable snapshot)

PostgreSQL: Транзакція отримує snapshot на початку першого SQL-оператора. Всі подальші SELECT бачать однакову картину світу, навіть якщо інші транзакції commit-или зміни. Ви працюєте з «замороженим» станом бази. Критично: інші транзакції можуть продовжувати писати — ви просто цього не бачите.

-- Транзакція T2, REPEATABLE READ
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- бачить 1000
-- T1: UPDATE ... SET balance = 500; COMMIT;
SELECT balance FROM accounts WHERE id = 1; -- все ще 1000!
-- Snapshot isolation — картина світу фіксована
-- Але T1 не була заблокована! Вона вільно писала і зробила COMMIT.
COMMIT;

Java: Найточніший аналог — StampedLock.tryOptimisticRead() або робота з immutable snapshot об'єкта. Ви «фотографуєте» стан і працюєте з цією копією, не блокуючи нікого:

// StampedLock — optimistic snapshot
long stamp = lock.tryOptimisticRead();  // «фотографуємо» момент — нікого не блокуємо!
int balance = account.balance;          // читаємо — інші потоки вільно пишуть
int balance2 = account.balance;         // repeatable: якщо validate пройде, обидва значення consistent
if (!lock.validate(stamp)) {
    // хтось писав — наш «snapshot» невалідний, як serialization failure в PG
}

// Або: працюємо з immutable копією
var snapshot = Map.copyOf(sharedState);  // snapshot — ніхто не заблокований
// snapshot.get("balance") завжди повертає те саме — repeatable read

Спільний патерн: Фіксована, узгоджена картина стану, без блокування інших учасників. Ви бачите consistent view, але це view може бути «застарілою» відносно поточного стану.

Де аналогія ламається — і чому synchronized тут НЕ підходить: synchronized блокує інші потоки (mutual exclusion). REPEATABLE READ — ні. У REPEATABLE READ інші транзакції вільно читають і пишуть, просто ваш snapshot цього не відображає. Це принципова різниця: synchronized — це pessimistic lock, REPEATABLE READ — це optimistic snapshot isolation. Тому synchronized ближче до SELECT FOR UPDATE (див. секцію про Locks нижче), а не до REPEATABLE READ.


SERIALIZABLE ↔ повна серіалізація (single global lock АБО application-level optimistic locking з retry)

PostgreSQL: SERIALIZABLE використовує Serializable Snapshot Isolation (SSI) — відстежує залежності між транзакціями (read-write, write-read, write-write) і abort-ить транзакцію, якщо виявлено цикл. Результат еквівалентний тому, ніби транзакції виконувались послідовно.

-- T1 (SERIALIZABLE): SELECT sum(balance) FROM accounts;
--                     UPDATE accounts SET balance = balance + 100 WHERE id = 1;
-- T2 (SERIALIZABLE): SELECT sum(balance) FROM accounts;
--                     UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- PostgreSQL виявить rw-залежність → abort одну з транзакцій
-- ERROR: could not serialize access due to read/write dependencies

Java — pessimistic варіант: Один глобальний lock, через який серіалізуються всі операції. Простий, але вбиває throughput:

synchronized (globalLock) {
    int sum = account1.balance + account2.balance;
    account1.balance += 100;
}
// Всі потоки стоять у черзі — гарантована серіалізація, мінімальна паралельність

Java — optimistic варіант (ближчий до SSI): Application-level optimistic locking — працюємо без блокування, перевіряємо при «commit», retry при конфлікті:

// Optimistic locking з version field — семантично ідентичний SSI
while (true) {
    var account = repository.findById(id);      // read snapshot
    int version = account.version;               // запам'ятати «stamp»
    int newBalance = account.balance + 100;      // compute

    int updated = jdbc.update(
        "UPDATE accounts SET balance = ?, version = version + 1 WHERE id = ? AND version = ?",
        newBalance, id, version);                // validate + commit

    if (updated == 1) break;                     // success
    // updated == 0 → конфлікт, retry (як serialization failure в PG)
}

Спільний патерн: Гарантія, що результат еквівалентний послідовному виконанню.

Де аналогія ламається: SSI у PostgreSQL працює на рівні цілих транзакцій, відстежуючи граф залежностей між ними. Hardware CAS (compareAndSet) в Java працює на рівні одного слова пам'яті — це зовсім інший масштаб. Тому я навмисно показав application-level optimistic locking (version field) як Java-аналог SSI, а не AtomicReference.compareAndSet(). CAS-loop — це скоріше аналог UPDATE ... WHERE id = X (single-row conflict detection), а не SERIALIZABLE (transaction-level conflict detection).


Повна таблиця відповідностей: Visibility

Рівень ізоляції (PG)

Java аналог

Що бачите

Trade-off

READ UNCOMMITTED

plain read (no HB)

Можливо stale / dirty

Макс. швидкість, мін. гарантії

READ COMMITTED

volatile (per-field) / окремі synchronized блоки (per-statement)

Останнє committed / recorded на момент читання

Per-read freshness, не repeatable

REPEATABLE READ

StampedLock optimistic read / immutable snapshot

Consistent frozen view

Consistent view, можливі write конфлікти

SERIALIZABLE

Global lock (pessimistic) або optimistic locking з version + retry

Повна серіалізація

Макс. consistency, мін. throughput або retry cost


Locks: synchronized ↔ PostgreSQL Locking

Гранулярність блокувань

І в Java, і в PostgreSQL ключове архітектурне рішення — на якому рівні блокувати. Занадто грубий lock — вбиває throughput. Занадто тонкий — overhead або deadlock.

Гранулярність

Java

PostgreSQL

Глобальний

synchronized (GlobalLock.class)

LOCK TABLE ... IN ACCESS EXCLUSIVE MODE

Об'єкт / Таблиця

synchronized (this)

LOCK TABLE ... IN ROW EXCLUSIVE MODE

Поле / Рядок

ReentrantLock на конкретне поле

SELECT ... FOR UPDATE (row-level lock)

Бакет / Партиція

ConcurrentHashMap (lock per bucket)

Partitioned table + partition-level locks

ConcurrentHashMap ↔ PostgreSQL Row-Level Locks

ConcurrentHashMap в Java 8+ — яскрава аналогія з PostgreSQL row-level locking:

CHM: Lock-гранулярність — один бакет. CAS для вставки в порожній бакет (без блокування). synchronized на head node для зайнятого. Два потоки, що пишуть у різні бакети, НЕ блокують один одного.

PostgreSQL: Кожен рядок ідентифікується через ctid. UPDATE бере lock на конкретному рядку (tuple-level lock у shared memory). Два запити, що оновлюють різні рядки, НЕ блокують один одного.

Властивість

ConcurrentHashMap

PostgreSQL Row Locks

Гранулярність

Per bucket (head node)

Per row (ctid + lock table)

Неконфліктні операції

Різні бакети → паралельно

Різні рядки → паралельно

Конфліктна операція

synchronized на head node

Wait на row lock

Read під час write

Volatile read, weakly consistent

MVCC — читаємо стару версію без блокування

SELECT FOR UPDATE ↔ synchronized (pessimistic lock + read + modify)

Ось де synchronized знаходить свою найточнішу PostgreSQL-аналогію — не у рівнях ізоляції, а у явному блокуванні:

SELECT ... FOR UPDATE — це семантичний еквівалент Java-патерну «захопи lock, прочитай, зміни, відпусти»:

-- PostgreSQL: pessimistic lock → read → modify → release
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- lock + read
-- інші транзакції, що хочуть FOR UPDATE на цей рядок, чекають
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- modify
COMMIT;  -- release lock
// Java: те саме — lock → read → modify → release
synchronized (account) {                  // lock
    int balance = account.getBalance();   // read
    account.setBalance(balance - 100);    // modify
}                                         // release

В обох випадках: захопити ексклюзивний доступ → прочитати → змінити → відпустити. Паттерн ідентичний. І в обох випадках це pessimistic підхід — ми блокуємо ресурс ДО того, як починаємо працювати.

Advisory Locks ↔ ReentrantLock

PostgreSQL advisory locks — це еквівалент ReentrantLock у Java: explicit, application-level, не прив'язані до конкретного рядка чи таблиці.

-- PostgreSQL: advisory lock по ID ресурсу
SELECT pg_advisory_lock(42);       -- lock (blocking)
-- critical section
SELECT pg_advisory_unlock(42);     -- unlock

-- З timeout (аналог tryLock)
SELECT pg_try_advisory_lock(42);   -- non-blocking attempt
// Java: ReentrantLock
lock.lock();                                   // lock (blocking)
try {
    // critical section
} finally {
    lock.unlock();                             // unlock
}

// З timeout
if (lock.tryLock(5, TimeUnit.SECONDS)) { ... } // non-blocking attempt

Властивість

ReentrantLock

pg_advisory_lock

Explicit acquire/release

lock() / unlock()

pg_advisory_lock() / pg_advisory_unlock()

Try with timeout

tryLock(timeout)

pg_try_advisory_lock()

Reentrant

Так

Так (session-level: повторний lock збільшує лічильник)

Scope

JVM process

Database cluster

Видимість у моніторингу

Thread dump, JFR

pg_locks view


Optimistic vs Pessimistic: два підходи в обох світах

Це, можливо, найглибша і найважливіша паралель — тому що тут збігається не тільки патерн, а й критерій вибору.

Pessimistic Concurrency Control

Java: synchronized / ReentrantLockспершу блокуємо, потім працюємо. Якщо ресурс зайнятий — чекаємо.

PostgreSQL: SELECT FOR UPDATEспершу блокуємо рядок, потім читаємо/оновлюємо. Якщо рядок locked — чекаємо.

Обидва підходи гарантують відсутність конфліктів, але платять за це простоєм (threads waiting / transactions waiting).

Optimistic Concurrency Control

Java — application-level: Optimistic locking з version field. Працюємо без блокування, при записі перевіряємо: «нічого не змінилось, поки я працював?» Якщо змінилось — retry.

// Application-level optimistic locking — найточніший аналог PostgreSQL MVCC
while (true) {
    var account = repository.findById(id);       // read without lock (як snapshot)
    int version = account.version;
    int newBalance = account.balance + 100;

    int updated = jdbc.update(
        "UPDATE accounts SET balance = ?, version = version + 1 " +
        "WHERE id = ? AND version = ?",
        newBalance, id, version);

    if (updated == 1) break;                     // success — version matched
    // version mismatch → конфлікт → retry
}

Java — low-level: StampedLock.tryOptimisticRead(). Та сама ідея, але на рівні in-memory полів замість БД:

long stamp = lock.tryOptimisticRead();  // no lock — «snapshot moment»
double x = this.x, y = this.y;         // read without blocking
if (!lock.validate(stamp)) {            // check: was there a write?
    // conflict → fallback to pessimistic
    stamp = lock.readLock();
    try { x = this.x; y = this.y; } finally { lock.unlockRead(stamp); }
}

PostgreSQL: MVCC + REPEATABLE READ / SERIALIZABLE. Працюємо з snapshot без блокування. При COMMIT PostgreSQL перевіряє конфлікти. Якщо конфлікт — abort (serialization failure), application робить retry.

-- REPEATABLE READ: optimistic approach
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;  -- snapshot: 1000
-- Інша транзакція: UPDATE balance = 500 WHERE id = 1; COMMIT;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- ERROR: could not serialize access due to concurrent update
-- → application retry

Спільний патерн (однаковий для обох світів)

1. Прочитати стан без блокування (snapshot / optimistic read / read with version)
2. Виконати обчислення
3. Спробувати «зафіксувати» результат (UPDATE WHERE version = X / validate(stamp) / COMMIT)
4. Якщо конфлікт — retry з кроку 1

Фаза

Java (application-level)

PostgreSQL

Read without lock

findById() + запам'ятати version

SELECT (snapshot read)

Compute

Обчислення нового значення

Обчислення нового рядка

Validate + commit

UPDATE ... WHERE version = ?

COMMIT (conflict check)

Conflict → retry

updated == 0 → retry loop

Serialization failure → retry

Коли що обирати (правило однакове для обох світів)

Optimistic (version + retry / MVCC + SERIALIZABLE + retry): коли конфліктів мало. Більшість операцій пройдуть без конкуренції. Retry — рідкість. Читання не блокують нікого.

Pessimistic (Lock / SELECT FOR UPDATE): коли конфліктів багато, або коли retry дорогий — наприклад, якщо в процесі обробки ви вже зробили зовнішній HTTP-виклик чи відправили email, і «відкотити» це неможливо.


Deadlock: однакова проблема, однакове рішення

Java Deadlock

// Thread A:                              // Thread B:
synchronized (account1) {                 synchronized (account2) {
    synchronized (account2) {                 synchronized (account1) {
        transfer();                               transfer();
    }                                         }
}                                         }
// → Deadlock: A тримає account1, чекає account2; B тримає account2, чекає account1

PostgreSQL Deadlock

-- T1:                                         -- T2:
BEGIN;                                         BEGIN;
UPDATE accounts SET balance = balance - 100    UPDATE accounts SET balance = balance - 50
  WHERE id = 1;  -- lock row 1                   WHERE id = 2;  -- lock row 2
UPDATE accounts SET balance = balance + 100    UPDATE accounts SET balance = balance + 50
  WHERE id = 2;  -- wait for row 2...            WHERE id = 1;  -- wait for row 1...
-- → Deadlock: T1 тримає row 1, чекає row 2; T2 тримає row 2, чекає row 1

Рішення — ідентичне в обох світах

Завжди захоплюйте локи в однаковому порядку.

// Java: ORDER BY id — глобально узгоджений порядок
int first = Math.min(a.id, b.id), second = Math.max(a.id, b.id);
synchronized (accounts[first]) {
    synchronized (accounts[second]) { transfer(); }
}
-- PostgreSQL: ORDER BY id — той самий принцип
BEGIN;
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
-- Обидві транзакції завжди блокують спершу id=1, потім id=2
UPDATE ...;
COMMIT;

Виявлення: Java JVM автоматично детектить monitor deadlocks (jstack показує). PostgreSQL має deadlock detector (дефолт: deadlock_timeout = 1s), який abort-ить одну з транзакцій з помилкою.


Inconsistency між спостереженнями: дві різні причини, схожий симптом

Java: Reordering — переставляння інструкцій

Без happens-before зв'язку JVM/CPU може переставити інструкції. Класичний приклад — double-checked locking без volatile:

// Thread A:
instance = new Singleton();
// Компілятор може: 1) allocate memory, 2) assign reference, 3) run constructor
// Thread B бачить non-null reference на неініціалізований об'єкт!

Причина: compiler/CPU reordering. Інструкції переставлені в межах одного потоку, бо це не змінює результат для цього потоку (as-if-serial), але інший потік бачить проміжний стан.

PostgreSQL: Phantom Reads — нові рядки з'являються

-- T1 (READ COMMITTED):
SELECT count(*) FROM orders WHERE status = 'pending';  -- 5
-- T2: INSERT INTO orders (status) VALUES ('pending'); COMMIT;
SELECT count(*) FROM orders WHERE status = 'pending';  -- 6!
-- «Фантомний» рядок з'явився між двома однаковими запитами

Причина: конкурентний INSERT, який став видимим через новий per-statement snapshot.

Чому це НЕ пряма аналогія, але корисне порівняння

Механізми абсолютно різні: reordering — це переставляння інструкцій всередині одного учасника; phantom read — це поява нових даних від іншого учасника. Але симптом схожий: стан світу inconsistent між двома послідовними спостереженнями, і ви бачите те, чого «не мало б бути».

І рішення в обох випадках — це підвищення рівня гарантій за рахунок продуктивності:

Java

PostgreSQL

Симптом

Inconsistent стан між reads

Inconsistent набір рядків між queries

Причина

Compiler/CPU reordering

Конкурентний INSERT/DELETE

Рішення

volatile / synchronized (встановити HB-зв'язок)

REPEATABLE READ / SERIALIZABLE (snapshot)

Ціна

Memory fences → менша оптимізація

Snapshot maintenance → пам'ять + abort ризик


MVCC Versioning ↔ CopyOnWrite

PostgreSQL MVCC і Java CopyOnWriteArrayList мають спільний архітектурний підхід — immutable versions + deferred cleanup:

PostgreSQL MVCC: UPDATE не змінює існуючий рядок. Створюється нова версія з новими xmin/xmax. Старі транзакції продовжують бачити стару версію (через свій snapshot). Нові — бачать нову. VACUUM потім прибирає dead tuples.

CopyOnWriteArrayList: Write не змінює існуючий масив. Створюється новий масив з модифікацією. Потоки, що ітерують по старому масиву, продовжують бачити стару версію. Нові читачі бачать нову. GC прибирає старий масив.

Аспект

PostgreSQL MVCC

CopyOnWriteArrayList

Write strategy

Створити нову версію рядка

Створити новий масив

Old readers

Бачать стару версію (snapshot)

Ітерують по старому масиву

New readers

Бачать нову версію

Отримують reference на новий масив

Cleanup

VACUUM (видалити dead tuples)

GC (зібрати unreachable масив)

Ідеально для

Read-heavy workload, рідкий write

Read-heavy, рідкий write (listeners, configs)

Ціна write

Дублювання рядка + WAL

Копіювання всього масиву

Де аналогія ламається: VACUUM — це явний, конфігурований процес (autovacuum_vacuum_cost_delay, autovacuum_vacuum_scale_factor), який потрібно тюнити під навантаження. Cleanup відбувається за принципом «чи є ще транзакції, чий snapshot бачить цю версію?». Java GC працює через reachability analysis — «чи є ще хтось, хто тримає reference на цей масив?». Ідея deferred cleanup спільна, але механізми та операційні характеристики різні. VACUUM може блокувати autovacuum workers, створювати I/O spike, вимагати ручного VACUUM FULL. GC має свої stop-the-world паузи, але не потребує ручного втручання.


Transaction Scope ↔ StructuredTaskScope: bounded lifetime

PostgreSQL транзакція та Java StructuredTaskScope мають спільну ідею bounded lifetime — ресурси не «течуть» за межі scope.

PostgreSQL транзакція:

  • BEGIN → відкриває scope

  • Всі операції всередині — один атомарний блок

  • Якщо будь-яка операція впала — ROLLBACK всього

  • COMMIT → всі зміни видимі одночасно

  • Жодна зміна не «тече» за межі транзакції без COMMIT

StructuredTaskScope:

  • StructuredTaskScope.open() → відкриває scope

  • fork() створює дочірні задачі всередині scope

  • ShutdownOnFailure: перша помилка → cancel решти

  • scope.join() + close() → завершення scope

  • Жодна задача не «тече» за межі scope

// Java Structured Concurrency
try (var scope = StructuredTaskScope.open(Joiner.allSuccessfulOrThrow())) {
    var user = scope.fork(() -> fetchUser(id));
    var orders = scope.fork(() -> fetchOrders(id));
    scope.join();
    return combine(user.get(), orders.get());
}
// scope.close() = всі задачі guaranteed завершені
-- PostgreSQL Transaction
BEGIN;
    SELECT * FROM users WHERE id = $1;
    SELECT * FROM orders WHERE user_id = $1;
COMMIT;  -- або ROLLBACK при помилці

Властивість

PostgreSQL Transaction

StructuredTaskScope

Bounded lifetime

Транзакція не «тече»

Задача не «тече» за scope

Failure handling

ROLLBACK при помилці

ShutdownOnFailure → cancel all

First-wins

Не типово

ShutdownOnSuccess → cancel rest

Nested scopes

SAVEPOINT

Nested StructuredTaskScope

Observability

pg_stat_activity

Thread dump з parent-child деревом

Де аналогія ламається — і це принципово: PostgreSQL транзакція дає ACID-атомарність: або ВСІ зміни видимі, або ЖОДНА. ROLLBACK відкочує все — ніби нічого не відбулось. StructuredTaskScope дає лише lifetime management: всі задачі гарантовано завершені до закриття scope. Але якщо одна задача вже зробила HTTP POST, а друга впала — перший POST не «відкочується». StructuredTaskScope — це не транзакція в ACID-сенсі. Це closer до того, як defer працює в Go, або try-with-resources для потоків.


ScopedValue ↔ Transaction-local state

Java ThreadLocal → PostgreSQL session variables:

// Java: ThreadLocal — глобальний, mutable, leak-prone
ThreadLocal<UserContext> ctx = new ThreadLocal<>();
ctx.set(user);  // хто завгодно може перезаписати
// Забули remove() → memory leak у thread pool
-- PostgreSQL: session variable — живе до кінця сесії
SET myapp.current_user = 'alice';
-- Може бути перезаписана будь-коли
-- «Тече» між транзакціями в одній сесії

Java ScopedValue → PostgreSQL SET LOCAL:

// Java: ScopedValue — immutable, bounded, auto-cleanup
ScopedValue.where(CURRENT_USER, user).run(() -> {
    // значення доступне тут і в усіх child scopes
    // автоматично зникає при виході
});
-- PostgreSQL: transaction-local SET
BEGIN;
SET LOCAL myapp.current_user = 'alice';
-- Доступна тільки в цій транзакції
-- Автоматично зникає при COMMIT/ROLLBACK
COMMIT;
-- myapp.current_user більше не 'alice'

SET LOCAL — PostgreSQL-аналог ScopedValue: bounded lifetime (transaction scope), автоматичне очищення, не «тече» за межі scope.


Фінальна карта відповідностей

Концепція Java

Концепція PostgreSQL

Спільний патерн

Точність аналогії

plain read (no HB)

READ UNCOMMITTED

Мінімальні гарантії видимості

⚡ Висока

volatile (per-field)

READ COMMITTED (per-statement)

Fresh на момент читання, non-repeatable

⚡ Середня (різний scope: field vs statement)

StampedLock optimistic / immutable snapshot

REPEATABLE READ (snapshot isolation)

Frozen consistent view без блокування інших

⚡ Висока

Global lock / optimistic locking + retry

SERIALIZABLE (SSI)

Еквівалент послідовного виконання

⚡ Середня (різний масштаб: app vs engine)

synchronized / Lock

SELECT FOR UPDATE / row lock

Pessimistic: блокую → працюю → відпускаю

⚡ Висока

Optimistic locking (version field + retry)

MVCC + serialization failure + retry

Read → compute → validate → retry

⚡ Висока

CopyOnWriteArrayList

MVCC versioning

Immutable versions + deferred cleanup

⚡ Середня (VACUUM ≠ GC)

ConcurrentHashMap (per-bucket)

Row-level locks

Fine-grained locking

⚡ Висока

ReentrantLock

pg_advisory_lock

Explicit application-level lock

⚡ Висока

Deadlock detection (JVM)

Deadlock detection (PG)

Lock ordering + auto-detection

⚡ Висока

StructuredTaskScope

Transaction (BEGIN...COMMIT)

Bounded lifetime

⚡ Середня (STS ≠ ACID atomicity)

ScopedValue

SET LOCAL

Scoped state, auto-cleanup

⚡ Висока

Reordering → partial state

Phantom reads

Inconsistency між спостереженнями

⚡ Низька (різні причини, схожий симптом)

ThreadLocal leak

Session variable leak

Unbounded lifetime → resource leak

⚡ Висока

Thread dump / JFR

pg_stat_activity / pg_locks

Діагностика конкурентних проблем

⚡ Висока


Практичний висновок: один mental model для двох світів

Коли ви проектуєте конкурентний Java-код — ви приймаєте ті самі рішення, що й при виборі стратегії в PostgreSQL:

  1. Яку гарантію видимості я потребую? → volatile / optimistic snapshot / global lock ↔ READ COMMITTED / REPEATABLE READ / SERIALIZABLE

  2. Pessimistic чи optimistic? → Lock і чекати (SELECT FOR UPDATE) vs version check і retry при конфлікті (SERIALIZABLE + retry)

  3. Яка гранулярність блокування? → Global lock (TABLE LOCK) vs per-object (row lock) vs lock-free (MVCC snapshot)

  4. Як забезпечити bounded lifetime? → StructuredTaskScope + ScopedValue ↔ Transaction + SET LOCAL

  5. Як діагностувати? → Thread dump + JFR ↔ pg_stat_activity + pg_locks + pg_stat_statements

Не всі аналогії ідеальні — і знання обмежень аналогії не менш цінне, ніж сама аналогія. Але розуміння одного світу суттєво поглиблює розуміння іншого, тому що під капотом — це одна й та сама інженерна дисципліна: керування видимістю та доступом до shared mutable state під конкурентним навантаженням.

Статті про вітчизняний бізнес та цікавих людей:

Поділись своїми ідеями в новій публікації.
Ми чекаємо саме на твій довгочит!
Oleksandr Klymenko
Oleksandr Klymenko@overpathz we.ua/overpathz

Java Software Engineer

40Довгочити
8.5KПрочитання
100Підписники
На Друкарні з 19 квітня

Більше від автора

Це також може зацікавити:

Коментарі (0)

Підтримайте автора першим.
Напишіть коментар!

Це також може зацікавити: