Два світи — одна проблема
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 |
| 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 |
|---|---|---|
Глобальний |
|
|
Об'єкт / Таблиця |
|
|
Поле / Рядок |
|
|
Бакет / Партиція | 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) |
Неконфліктні операції | Різні бакети → паралельно | Різні рядки → паралельно |
Конфліктна операція |
| 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 |
|
|
Try with timeout |
|
|
Reentrant | Так | Так (session-level: повторний lock збільшує лічильник) |
Scope | JVM process | Database cluster |
Видимість у моніторингу | Thread dump, JFR |
|
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 |
| SELECT (snapshot read) |
Compute | Обчислення нового значення | Обчислення нового рядка |
Validate + commit |
| COMMIT (conflict check) |
Conflict → retry |
| 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()→ відкриває scopefork()створює дочірні задачі всередині scopeShutdownOnFailure: перша помилка → 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 |
| 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:
Яку гарантію видимості я потребую? → volatile / optimistic snapshot / global lock ↔ READ COMMITTED / REPEATABLE READ / SERIALIZABLE
Pessimistic чи optimistic? → Lock і чекати (SELECT FOR UPDATE) vs version check і retry при конфлікті (SERIALIZABLE + retry)
Яка гранулярність блокування? → Global lock (TABLE LOCK) vs per-object (row lock) vs lock-free (MVCC snapshot)
Як забезпечити bounded lifetime? → StructuredTaskScope + ScopedValue ↔ Transaction + SET LOCAL
Як діагностувати? → Thread dump + JFR ↔ pg_stat_activity + pg_locks + pg_stat_statements
Не всі аналогії ідеальні — і знання обмежень аналогії не менш цінне, ніж сама аналогія. Але розуміння одного світу суттєво поглиблює розуміння іншого, тому що під капотом — це одна й та сама інженерна дисципліна: керування видимістю та доступом до shared mutable state під конкурентним навантаженням.