Два світи — одна проблема
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.
Цей пост — спроба побудувати систематичну карту відповідностей між цими двома світами. Не поверхневі аналогії, а глибокі структурні паралелі, які допоможуть розробнику, що добре знає одну сторону, інтуїтивно зрозуміти іншу.
Спільний фундамент: 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
Головне питання обох моделей
В Java: «Коли запис, зроблений потоком A, гарантовано видимий для читання потоком B?»
В PostgreSQL: «Коли зміна, зроблена транзакцією T1 (навіть уже committed), стає видимою для SELECT в транзакції T2?»
Відповідь в обох випадках — не автоматично. Потрібен явний механізм, який встановлює гарантію видимості.
JMM Happens-Before → PostgreSQL Isolation Guarantees
В 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 ↔ volatile read / synchronized (per-statement visibility)
PostgreSQL: Кожен окремий SQL-оператор бачить все, що було committed на момент його початку. Але два послідовних SELECT в одній транзакції можуть бачити різні дані, якщо між ними хтось зробив COMMIT.
-- Транзакція 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 гарантує visibility — ви побачите останнє значення, записане будь-яким потоком у цю volatile змінну. Але це гарантія per-access, а не per-block. Між двома volatile reads інший потік може змінити значення.
volatile int balance = 1000;
// Thread B:
int a = balance; // бачить 1000 (volatile read — fresh)
// Thread A: balance = 500;
int b = balance; // бачить 500 (!)
// Non-repeatable read — те саме!
Спільне: Гарантія «бачу щось committed / записане», але без консистентності між послідовними читаннями. Кожне окреме читання fresh, але сукупність читань може бути inconsistent.
Ключова різниця: У PostgreSQL «committed» означає durability (WAL fsync). У Java «видимо» означає store buffer flushed / cache coherence. Але pattern — ідентичний.
REPEATABLE READ ↔ snapshot-based reads / synchronized block
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 — картина світу фіксована
COMMIT;
Java: synchronized блок створює happens-before ребро: при вході в блок ви «бачите» всі зміни, зроблені до виходу з того самого монітора. Всередині блоку ваша view є consistent — жоден інший потік з тим самим монітором не може вносити зміни. Це аналог «snapshot» — на час виконання блоку ваш стан фіксований.
synchronized (account) {
int a = account.balance; // бачить 1000
// ніхто з тим самим монітором не може змінити
int b = account.balance; // гарантовано 1000
// Repeatable read в межах synchronized
}
Спільне: Фіксована, узгоджена картина стану на момент «входу». Зміни інших учасників не видимі до завершення scope.
SERIALIZABLE ↔ повна синхронізація (global lock / SSI)
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: Повна серіалізація через один глобальний lock або synchronized на одному об'єкті для всіх операцій. Або — якщо використовувати optimistic підхід — CAS-loop з retry при конфлікті (що семантично ідентично Postgres SSI з retry).
// Pessimistic (як одна глобальна черга в PostgreSQL)
synchronized (globalLock) {
int sum = account1.balance + account2.balance;
account1.balance += 100;
}
// Optimistic (як SSI з retry при конфлікті)
while (true) {
long stamp = stampedLock.tryOptimisticRead();
int sum = account1.balance + account2.balance;
if (stampedLock.validate(stamp)) { // нічого не змінилось?
break; // успіх
}
// конфлікт → retry (як SERIALIZABLE abort + retry)
}
Спільне: Гарантія, що результат еквівалентний послідовному виконанню. Максимальна consistency, мінімальна concurrency.
Повна таблиця відповідностей: Visibility
Рівень ізоляції (PG) | JMM / Java аналог | Що бачите | Trade-off |
|---|---|---|---|
READ UNCOMMITTED | plain read (no HB) | Можливо stale / dirty | Макс. швидкість, мін. гарантії |
READ COMMITTED | volatile read | Останнє committed / recorded | Per-read freshness, не repeatable |
REPEATABLE READ | synchronized block entry | Snapshot на момент входу | Consistent view, можливі write конфлікти |
SERIALIZABLE | Global lock або CAS+retry | Повна серіалізація | Макс. consistency, мін. throughput |
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 MVCC: Кожен рядок має xmin/xmax. UPDATE створює нову версію рядка. Два запити, що оновлюють різні рядки, НЕ блокують один одного. Lock утримується тільки на конкретному рядку (через tuple-level lock у shared memory).
Властивість | ConcurrentHashMap | PostgreSQL Row Locks |
|---|---|---|
Гранулярність | Per bucket (head node) | Per row (ctid + lock table) |
Неконфліктні операції | Різні бакети → паралельно | Різні рядки → паралельно |
Конфліктна операція |
| Blocking на row lock (xt_lock wait) |
Read під час write | Volatile read, weakly consistent | MVCC — бачимо стару версію |
SELECT FOR UPDATE ↔ synchronized + read
SELECT ... FOR UPDATE — це семантичний еквівалент Java-патерну «захопи lock, прочитай, зміни, відпусти»:
-- PostgreSQL
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- row locked
-- інші транзакції чекають
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT; -- lock released
// Java
synchronized (account) { // lock acquired
int balance = account.getBalance(); // read
account.setBalance(balance - 100); // modify
} // lock released
В обох випадках: захопити ексклюзивний доступ → прочитати → змінити → відпустити. Паттерн ідентичний.
Advisory Locks ↔ ReentrantLock
PostgreSQL advisory locks — це еквівалент ReentrantLock у Java: explicit, application-level, не прив'язані до конкретного рядка чи таблиці.
-- PostgreSQL: advisory lock по ID ресурсу
SELECT pg_advisory_lock(42); -- lock
-- critical section
SELECT pg_advisory_unlock(42); -- unlock
-- З timeout (аналог tryLock)
SELECT pg_try_advisory_lock(42);
// Java: ReentrantLock
lock.lock(); // lock
try {
// critical section
} finally {
lock.unlock(); // unlock
}
// З timeout
lock.tryLock(5, TimeUnit.SECONDS);
Властивість | ReentrantLock | pg_advisory_lock |
|---|---|---|
Explicit acquire/release |
|
|
Try with timeout |
|
|
Reentrant | Так | Так (session-level) |
Scope | JVM process | Database cluster |
Видимість у моніторингу | Thread dump, JFR |
|
Optimistic vs Pessimistic: CAS ↔ MVCC conflict detection
Це, можливо, найглибша і найважливіша паралель.
Pessimistic Concurrency Control
Java: synchronized / ReentrantLock — спершу блокуємо, потім працюємо. Якщо ресурс зайнятий — чекаємо.
PostgreSQL: SELECT FOR UPDATE — спершу блокуємо рядок, потім читаємо/оновлюємо. Якщо рядок locked — чекаємо.
Обидва підходи гарантують відсутність конфліктів, але платять за це простоєм (threads waiting / transactions waiting).
Optimistic Concurrency Control
Java: CAS-операції (AtomicReference, StampedLock.tryOptimisticRead). Працюємо без блокування, а в кінці перевіряємо: «нічого не змінилось, поки я працював?» Якщо змінилось — retry.
// StampedLock optimistic read
long stamp = lock.tryOptimisticRead(); // no lock!
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).
-- 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
-- → retry transaction
Паттерн ідентичний:
1. Прочитати стан без блокування (snapshot / optimistic read)
2. Виконати обчислення
3. Спробувати «зафіксувати» результат (CAS / COMMIT)
4. Якщо конфлікт — retry з нуля
Фаза | Java CAS / Optimistic | PostgreSQL MVCC |
|---|---|---|
Read without lock |
| SELECT (snapshot read) |
Compute | Обчислення нового значення | Обчислення нового рядка |
Validate + commit |
| COMMIT (conflict check) |
Conflict → retry | CAS loop | Serialization failure → retry |
Коли що обирати (правило однакове для обох світів)
Optimistic (CAS / MVCC + SERIALIZABLE + retry): коли конфліктів мало. Більшість операцій пройдуть без конкуренції. Retry — рідкість.
Pessimistic (Lock / SELECT FOR UPDATE): коли конфліктів багато. Краще зачекати, ніж зробити роботу даремно і retry'їти. Або коли retry дорогий (side effects, зовнішні виклики).
Deadlock: однакова проблема, однакове рішення
Java Deadlock
// Thread A:
synchronized (account1) {
synchronized (account2) { transfer(); }
}
// Thread B:
synchronized (account2) {
synchronized (account1) { transfer(); }
}
// → Deadlock: A тримає account1, чекає account2; B тримає account2, чекає account1
PostgreSQL Deadlock
-- T1:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- lock row 1
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- wait for row 2...
-- T2:
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- lock row 2
UPDATE accounts SET balance = balance + 50 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-ить одну з транзакцій.
Reordering і Phantom Reads: коли світ змінюється під ногами
Java Reordering
Без happens-before JVM може переставити інструкції. Класичний приклад — double-checked locking без volatile:
// Thread A:
instance = new Singleton();
// Компілятор може: 1) allocate memory, 2) assign reference, 3) run constructor
// Thread B бачить non-null reference на неініціалізований об'єкт!
Об'єкт «існує» (reference not null), але його стан ще не ініціалізований. Ви бачите «частковий» стан, якого ніколи не мало б бути.
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!
-- «Фантомний» рядок з'явився між двома однаковими запитами
Спільна суть
В обох випадках: стан світу змінився між двома спостереженнями, і ви бачите inconsistency. Рішення теж симетричне:
Java | PostgreSQL | |
|---|---|---|
Проблема | Reordering → частковий / inconsistent стан | Phantom read → inconsistent набір рядків |
Рішення | volatile / synchronized (establish HB) | REPEATABLE READ / SERIALIZABLE (snapshot) |
Ціна | Memory fences → менша оптимізація | Snapshot maintenance → memory + abort ризик |
MVCC Versioning ↔ CopyOnWrite
PostgreSQL MVCC і Java CopyOnWriteArrayList мають вражаюче спільну архітектуру:
PostgreSQL MVCC: UPDATE не змінює існуючий рядок. Натомість створюється нова версія рядка з новими xmin/xmax. Старі транзакції продовжують бачити стару версію (через свій snapshot). Нові — бачать нову. VACUUM потім прибирає мертві версії.
CopyOnWriteArrayList: Write не змінює існуючий масив. Створюється новий масив з модифікацією. Потоки, що ітерують по старому масиву, продовжують бачити стару версію. Нові читачі бачать нову. GC прибирає старий масив коли останній reader закінчить.
Аспект | 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 | Копіювання всього масиву |
Це один і той самий патерн: immutable snapshots для readers + copy-on-write для writers + deferred cleanup.
Transaction Scope ↔ StructuredTaskScope
Ще одна глибока паралель: PostgreSQL транзакція та Java StructuredTaskScope мають ідентичну семантику lifecycle management.
PostgreSQL транзакція:
BEGIN → відкриває scope
Всі операції всередині — один атомарний блок
Якщо будь-яка операція впала — ROLLBACK всього
COMMIT → всі зміни видимі одночасно
Жодна зміна не «тече» за межі транзакції без COMMIT
StructuredTaskScope:
StructuredTaskScope.open()→ відкриває scopefork()створює дочірні задачі всередині scopeShutdownOnFailure: перша помилка → cancel решти (ROLLBACK аналог)
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(); // wait for all (як COMMIT чекає завершення)
return combine(user.get(), orders.get());
}
// scope.close() = всі задачі guaranteed завершені (як END TRANSACTION)
-- PostgreSQL Transaction
BEGIN;
SELECT * FROM users WHERE id = $1;
SELECT * FROM orders WHERE user_id = $1;
COMMIT; -- всі зміни атомарно видимі (або ROLLBACK при помилці)
Властивість | PostgreSQL Transaction | StructuredTaskScope |
|---|---|---|
Atomic scope | BEGIN...COMMIT | try-with-resources |
All-or-nothing | ROLLBACK при помилці | ShutdownOnFailure → cancel all |
First-wins | Не типово (але можна через PL/pgSQL) | ShutdownOnSuccess → cancel rest |
Bounded lifetime | Транзакція не «тече» | Задача не «тече» за scope |
Нested scopes | SAVEPOINT | Nested StructuredTaskScope |
Observability |
| Thread dump з parent-child деревом |
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 transaction-scoped config:
// 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 | Спільна суть |
|---|---|---|
Happens-before | Isolation level guarantees | За яких умов зміна видима |
volatile read | READ COMMITTED (per-statement) | Бачу committed, але non-repeatable |
synchronized block | REPEATABLE READ (snapshot) | Consistent view протягом scope |
Global lock / CAS+retry | SERIALIZABLE (SSI) | Еквівалент послідовного виконання |
synchronized / Lock | SELECT FOR UPDATE / row lock | Pessimistic: блокую, потім працюю |
CAS / StampedLock optimistic | MVCC + serialization failure + retry | Optimistic: працюю, потім валідую |
CopyOnWriteArrayList | MVCC versioning | Immutable snapshots + copy-on-write |
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, all-or-nothing |
ScopedValue | SET LOCAL | Scoped state, auto-cleanup |
Reordering → inconsistency | Phantom reads | Світ змінюється між спостереженнями |
ThreadLocal leak | Session variable leak | Unbounded lifetime → resource leak |
VarHandle acquire-release | Visibility rules між snapshot'ами | Fine-grained control над видимістю |
Thread dump / JFR | pg_stat_activity / pg_locks | Діагностика конкурентних проблем |
Практичний висновок: один mental model для двох світів
Коли ви проектуєте конкурентний Java-код — ви приймаєте ті самі рішення, що й при виборі рівня ізоляції в PostgreSQL:
Яку гарантію видимості я потребую? → volatile (READ COMMITTED) vs synchronized (REPEATABLE READ) vs global lock (SERIALIZABLE)
Pessimistic чи optimistic? → Lock і чекати (SELECT FOR UPDATE) vs CAS і retry при конфлікті (SERIALIZABLE + retry)
Яка гранулярність блокування? → Global lock (TABLE LOCK) vs per-object (row lock) vs lock-free (MVCC)
Як забезпечити bounded lifetime? → StructuredTaskScope (Transaction) + ScopedValue (SET LOCAL)
Як діагностувати? → Thread dump + JFR (pg_stat_activity + pg_locks + pg_stat_statements)
Розуміння одного світу автоматично поглиблює розуміння іншого, тому що під капотом — це одна й та сама інженерна дисципліна: керування видимістю та доступом до shared mutable state під конкурентним навантаженням.