Databases & SQL · پایگاه‌داده و SQL متوسطIntermediate ~79 دقیقه مطالعه~69 min read

تراکنش، ACID، سطوح ایزوله و MVCCTransactions, ACID, Isolation & MVCC

از صفر یاد می‌گیری تراکنش و ACID یعنی چه و چهار سطح ایزوله و ناهنجاری‌های همروندی چطور کار می‌کنند، و همه‌چیز را هم‌زمان برای **PostgreSQL** (MVCC با xmin/xmax، SSI، vacuum و bloat) و **Oracle** (خوانشِ سازگار با undo و SCN، ORA-08177، ORA-01555، تراکنش‌های READ ONLY و autonomous) می‌بینی — با تشبیه، کدِ اجراشدنیِ هر دو دیالکت و جدولِ مقایسه‌ی ناهنجاری‌ها.Learn from scratch what a transaction and ACID really mean and how the four isolation levels and concurrency anomalies behave, seeing every statement side by side for **PostgreSQL** (MVCC with xmin/xmax, SSI, vacuum and bloat) and **Oracle** (consistent read from undo and SCN, ORA-08177, ORA-01555, READ ONLY and autonomous transactions) — with analogies, runnable code in both dialects and a per-engine anomaly matrix.


هر باگِ پولی، هر «موجودی منفی شد»، هر «سفارش دوبار ثبت شد» ریشه در یک چیز دارد: نفهمیدنِ اینکه دیتابیس در دو موقعیتِ خطرناک چه تعهدی می‌دهد — وقتی چیزی خراب می‌شود، و وقتی چند نفر هم‌زمان به یک داده دست می‌زنند. این فصل همین دو موقعیت را از پایه، با تشبیه، می‌سازد.

و یک نکته از همین ابتدا: مهندسِ ارشد امروز با PostgreSQL کار می‌کند و فردا با Oracle؛ این دو هرچند هر دو multiversion‌اند، دقیقاً در همان جزئیاتی فرق دارند که در production تو را می‌کُشند. پس هر دستور را با هر دو دیالکت می‌بینی و هر جا رفتار موتورها واقعاً فرق دارد، همان‌جا هشدار می‌گیری.

نقشه‌ی راه این فصل
  • دو محورِ مستقل: «هنگام خطا چه می‌شود؟» (atomicity/durability) در برابر «در همروندی چه می‌شود؟» (isolation).
  • شروع و پایانِ تراکنش در دو موتور — و تله‌ی «DDL در اوراکل خودش commit می‌کند».
  • ACID دقیق + دو تله‌ی معروف مصاحبه (C در ACID ≠ C در CAP).
  • ناهنجاری‌ها: dirty read، non-repeatable، phantom، lost update، write skew.
  • چهار سطح ایزوله + جدولِ «کدام موتور واقعاً کدام را دارد» (اوراکل فقط دو سطح + READ ONLY دارد).
  • درونیات: xmin/xmax، snapshot، VACUUM، bloat و wraparound در پستگرس؛ undo، SCN، بلاکِ CR و ORA-01555 در اوراکل.
  • قفل سطری، FOR UPDATE، SKIP LOCKED، بن‌بست و تفاوتِ حیاتیِ rollback در دو موتور؛ قفل‌های advisory.
  • خوش‌بینانه در برابر بدبینانه، @Version، ORA_ROWSCN، تراکنش‌های autonomous.
  • توزیع‌شده: ۲PC (PREPARE TRANSACTION و DBA_2PC_PENDING) و چرا saga می‌آید.
  • چک‌لیستِ مهاجرت و ۱۸ پرسش مصاحبه با پاسخ کامل.

بخش ۰ — واژه‌هایی که پیش از شروع باید بلد باشی

  • تراکنش: بسته‌ی کاری که دیتابیس قول می‌دهد «یک‌تکه» ببیند. مثل بسته‌ی پستی: یا کامل می‌رسد یا اصلاً؛ نصفه‌بسته وجود ندارد.
  • همروندی: چند تراکنش هم‌زمان روی یک داده. مثل چند نفر که هم‌زمان روی یک وایت‌بورد می‌نویسند.
  • اتمیک: «تقسیم‌ناپذیر». یا کاملاً انجام می‌شود یا اصلاً — هیچ حالتِ نیمه‌کاره‌ای بیرون دیده نمی‌شود.
  • snapshot: عکسِ منجمد از حالتِ دیتابیس در یک لحظه. بعدِ عکس هرکس هرجا برود، عکس همان لحظه را نشان می‌دهد.
  • tuple: در پستگرس «یک نسخه از یک سطر». چرا نسخه؟ چون پستگرس از یک سطر چند نسخه‌ی هم‌زمان نگه می‌دارد.
  • XID: شماره‌ی سریالِ صعودی که پستگرس به هر تراکنش می‌دهد — مثل شماره‌ی نوبت. با آن می‌فهمد کدام کار زودتر بوده.
  • SCN (System Change Number): همتای مفهومیِ همان در اوراکل: ساعتِ منطقیِ صعودیِ کلِ دیتابیس. هر commit یک SCN می‌گیرد و هر کوئری می‌گوید «داده را به‌شکلی که در SCN فلان بود می‌خواهم».
  • undo: در اوراکل، فضایی جدا که مقدارِ قبلیِ هر تغییر در آن نوشته می‌شود؛ هم برای rollback و هم برای بازسازیِ نسخه‌ی قدیمی برای خواننده‌ها.
  • redo: در اوراکل، لاگِ «چه تغییری دادم» برای بازیابی پس از crash — هم‌ارزِ مفهومیِ WAL پستگرس.
  • invariant: قانونی که همیشه باید درست بماند («همیشه حداقل یک صندوقِ باز باشد»).
  • idempotent: عملی که چندبار اجرا شود همان نتیجه‌ی یک‌بار را می‌دهد؛ مثل دکمه‌ی طبقه در آسانسور.
مدلِ دو‌محوری را بچسب

کل فصل حول یک ایده می‌چرخد: دو پرسشِ کاملاً جدا. «اگر برق برود چه؟» و «اگر دو نفر هم‌زمان بنویسند چه؟» این‌ها با ماشین‌آلاتِ متفاوتی حل می‌شوند و قاطی‌کردنشان منشأ نیمی از سوءتفاهم‌هاست.


مدل ذهنی: دو محورِ مستقل

رستوران

آشپزخانه‌ی رستوران دو خطرِ کاملاً متفاوت دارد. اول: وسطِ پخت برق می‌رود — یا غذا کامل تحویل می‌شود یا سفارش لغو و پول برگردانده می‌شود، نه اینکه نیمه‌پخت سِرو شود. دوم: دو گارسون هم‌زمان همان میز را سفارش می‌گیرند — سفارش‌ها نباید روی هم بریزند. این دو خطر ربطی به هم ندارند و راه‌حلشان فرق می‌کند. دیتابیس هم دقیقاً همین دو خطر را دارد.

۱. هنگام خطا چه می‌شود؟اتمیکی و پایایی: بازیابی پس از crash، لاگ، rollback. ۲. در همروندی چه می‌شود؟ایزوله: یک تراکنش چه چیزی از تراکنشِ هم‌زمانِ دیگر مجاز است ببیند.

نمای دو محور و ابزارِ هر موتور — Two axes of transaction guarantees:

flowchart TD
  T[Transaction guarantees] --> F[Axis 1: on failure]
  T --> C[Axis 2: under concurrency]
  F --> PGW["PostgreSQL: WAL for redo, old tuple versions act as undo"]
  F --> ORW["Oracle: redo log for replay, undo tablespace for rollback"]
  C --> PGM["PostgreSQL: MVCC snapshots, xmin/xmax, SSI"]
  C --> ORM["Oracle: consistent read from undo, SCN snapshots"]
  PGM --> L[Row locks for writers in both engines]
  ORM --> L
WAL در پستگرس در برابر redo + undo در اوراکل

هر دو پیش از دست‌زدن به داده «قصدشان» را در دفترچه می‌نویسند، اما تقسیم‌کارشان فرق دارد:

  • پستگرس یک لاگ دارد: WAL. کارِ redo را WAL می‌کند و کارِ undo را خودِ MVCC — چون نسخه‌ی قدیمی هنوز سرِ جایش است، ROLLBACK یعنی «نسخه‌ی جدید را visible نکن» و تقریباً رایگان و آنی است.
  • اوراکل دو دفتر دارد: redo برای بازیابی و undo برای برگرداندن و برای خوانشِ سازگار. ROLLBACK یعنی بازنویسیِ واقعیِ مقادیرِ قبلی — برای تراکنشِ بزرگ می‌تواند دقایق طول بکشد.
  • نتیجه‌ی عملی: rollbackِ یک DELETE ده‌میلیونی در اوراکل کند است؛ در پستگرس آنی است ولی به‌جایش vacuum باید ده میلیون tupleِ مرده را جمع کند. ناهار مجانی وجود ندارد.

شروع و پایانِ تراکنش: از همین‌جا دو موتور فرق دارند

-- PostgreSQL: بیرونِ بلاک، هر دستور یک تراکنشِ ضمنی است (autocommit)
BEGIN;                                  -- یا START TRANSACTION;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;                                 -- یا ROLLBACK;
تله‌ی شماره‌ی یک: اوراکل autocommit ندارد

در اوراکل با اولین DML تراکنش باز می‌شود و تا COMMIT/ROLLBACK صریح باز می‌ماند (ابزارها ممکن است autocommit روشن کنند، اما خودِ موتور نه). برنامه‌نویسی که از پستگرس می‌آید عادت دارد «اگر BEGIN نزنم چیزی باز نمی‌ماند» — در اوراکل همین عادت یعنی sessionهایی که ساعت‌ها قفل نگه داشته‌اند و undo را پین کرده‌اند.

-- PostgreSQL: DDL تراکنشی است! یک migration را می‌شود کامل عقب زد
BEGIN;
  CREATE TABLE audit_log (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
                          msg text NOT NULL);
  ALTER TABLE accounts ADD COLUMN note text;
ROLLBACK;   -- هر دو ناپدید می‌شوند؛ جدول اصلاً ساخته نشد
پیامدِ معماریِ «DDL تراکنشی»

در پستگرس Flyway/Liquibase می‌توانند کلِ migration را در یک تراکنش بپیچند؛ خطای گامِ پنجم یعنی بازگشتِ کاملِ دیتابیس. در اوراکل این ممکن نیست: هر CREATE/ALTER/DROP قطعی است، پس migrationهای اوراکل باید گام‌به‌گام و idempotent نوشته شوند (23ai با IF [NOT] EXISTS نوشتنشان را ساده‌تر کرده، ولی تراکنشی‌شان نکرده). در پستگرس هم چند دستور استثنا هستند و درونِ بلاکِ تراکنش اجرا نمی‌شوند: CREATE INDEX CONCURRENTLY، VACUUM، CREATE DATABASE.

BEGIN;
  INSERT INTO orders(id, status) VALUES (1, 'NEW');
  SAVEPOINT before_risky;
  INSERT INTO order_items(order_id, sku) VALUES (1, 'NOPE');  -- FK می‌شکند
  ROLLBACK TO SAVEPOINT before_risky;    -- بدون این، کل تراکنش سوخته است
  INSERT INTO order_items(order_id, sku) VALUES (1, 'SKU-1');
COMMIT;
تفاوتی که در مهاجرت زمین می‌زند: رفتارِ خطا

اوراکل پیش از هر statement یک savepointِ ضمنی می‌گذارد؛ خطا فقط همان statement را برمی‌گرداند و تراکنش زنده می‌ماند. پستگرس این‌طور نیست: هر خطا کلِ تراکنش را به 25P02 in_failed_sql_transaction می‌برد و از آن پس هر دستوری خطا می‌دهد تا ROLLBACK بزنی. پس کدِ اوراکلیِ «خطا را بگیر و ادامه بده» در پستگرس می‌شکند مگر دورِ هر گامِ ریسکی SAVEPOINT بگذاری — و بدان که بیش از ۶۴ savepoint در یک تراکنش، cacheِ subtransaction پستگرس را سرریز می‌کند و کلِ کلاستر کند می‌شود (SubtransSLRU).


ACID، دقیق و بی‌ابهام

ویژگی تضمین واقعی مکانیزم در PostgreSQL مکانیزم در Oracle
Atomicity یا همه‌ی دستورات commit می‌شوند یا هیچ‌کدام؛ اثرِ نیمه‌کاره هرگز دیده نمی‌شود. WAL + نسخه‌های MVCC؛ abort یعنی «هرگز visible نشو» — آنی. undo tablespace؛ ROLLBACK مقادیرِ قبلی را واقعاً بازمی‌نویسد — زمان‌بر.
Consistency حرکت از یک حالتِ معتبر به معتبرِ دیگر، نسبت به قیدهای اعلام‌شده (PK/FK/CHECK/trigger). بررسی در زمان statement/commit؛ DEFERRABLE INITIALLY DEFERRED. همان؛ DEFERRABLE و SET CONSTRAINTS ALL DEFERRED.
Isolation تراکنش‌های همروند نتیجه‌ای می‌دهند انگار سریال اجرا شده‌اند (در قوی‌ترین سطح). snapshotهای MVCC + SSI (سریالایزپذیریِ واقعی). خوانشِ سازگارِ مبتنی بر SCN و undo؛ SERIALIZABLE اوراکل در واقع snapshot isolation است.
Durability وقتی COMMIT برگشت، اثرش از crash جان سالم به در می‌برد. فلاشِ WAL پیش از تأیید؛ synchronous_commit، wal_sync_method. فلاشِ redo توسطِ LGWR؛ `COMMIT WRITE [BATCH

حرفِ C بیشترین سوءتفاهم را دارد: دیتابیس «درستیِ منطقِ کسب‌وکارِ تو» را تضمین نمی‌کند، فقط قیدهایی را که خودت اعلام کرده‌ای. اگر منطقت غلط باشد ولی هیچ قیدی نشکند، دیتابیس با کمالِ میل قبولش می‌کند.

-- قیدِ به‌تعویق‌افتاده: تا لحظه‌ی COMMIT بررسی نمی‌شود
ALTER TABLE order_items ADD CONSTRAINT fk_order
  FOREIGN KEY (order_id) REFERENCES orders(id) DEFERRABLE INITIALLY DEFERRED;
BEGIN;
  INSERT INTO order_items(order_id, sku) VALUES (999, 'SKU-1');  -- هنوز خطا نه
  INSERT INTO orders(id, status) VALUES (999, 'NEW');
COMMIT;   -- بررسی اینجا انجام می‌شود
تله‌ی معروف مصاحبه: C در ACID ≠ C در CAP

ACID-C درباره‌ی قیدهای یکپارچگی درونِ یک نود است (آیا داده قوانینِ اعلام‌شده را می‌شکند؟). CAP-C (همان linearizability) درباره‌ی توافقِ replicaها روی آخرین نوشتن است (آیا هر خواننده جدیدترین نسخه را می‌بیند؟). دو مفهومِ کاملاً جدا که فقط حرفِ اولشان یکی است.

-- معامله‌ی پایایی با سرعت، برای نوشتن‌های غیرحیاتی
SET synchronous_commit = off;
INSERT INTO analytics_events(payload) VALUES ('{"e":"click"}');
COMMIT;
SET synchronous_commit = on;
نکته‌ی پایایی که کم‌تر کسی می‌داند

هر دوی این‌ها اتمیکی و ایزوله را کاملاً حفظ می‌کنند و فقط پنجره‌ای کوچک از پایایی را معامله می‌کنند: یک تراکنشِ commit‌شده ممکن است در crash از دست برود، ولی دیتابیس هرگز ناسازگار یا پاره نمی‌شود. برای لاگِ تحلیلی عالی، برای تراکنشِ مالی ممنوع.


ناهنجاری‌های همروندی: پنج دردی که باید بشناسی

سطوح ایزوله با ناهنجاری‌هایی که ممنوع می‌کنند تعریف می‌شوند. پس اول باید بدانی این ناهنجاری‌ها چه هستند. جدولِ کارِ مثال‌ها:

CREATE TABLE accounts (
  id      integer PRIMARY KEY,
  owner   text          NOT NULL,
  balance numeric(18,2) NOT NULL CHECK (balance >= 0),
  version bigint        NOT NULL DEFAULT 0,
  updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO accounts(id, owner, balance) VALUES (1,'ali',100), (2,'sara',100);
تفاوت‌های نوعِ داده که همین‌جا دیدی

textVARCHAR2(n)، numericNUMBER، timestamptzTIMESTAMP WITH TIME ZONE، now()SYSTIMESTAMP. اوراکل INSERT ... VALUES (..),(..) چندسطری ندارد (یا INSERT ALL یا چند دستور). و مهم‌ترین تله: در اوراکل رشته‌ی خالی '' همان NULL است، پس CHECK (owner <> '') هرگز کار نمی‌کند.

خواندن کثیف (dirty read)

خواندنِ پیش‌نویسِ منتشرنشده

همکارت گزارشی می‌نویسد و هنوز ذخیره نکرده. تو از روی شانه‌اش رقمی می‌خوانی و به رئیس گزارش می‌دهی. بعد او آن رقم را پاک می‌کند. چیزی را گزارش کرده‌ای که هرگز نهایی نشد.

T2 سطری را می‌خواند که T1 نوشته اما commit نکرده. اگر T1 عقب بزند، T2 روی داده‌ای عمل کرده که هرگز وجود نداشته است.

خبرِ خوب: هیچ‌کدام از این دو موتور dirty read ندارند

نه پستگرس و نه اوراکل، در هیچ سطحی، داده‌ی commit‌نشده‌ی تراکنشِ دیگر را نشان نمی‌دهند؛ چون هر دو multiversion‌اند و اصلاً مکانیزمی برای این کار ندارند. پستگرس READ UNCOMMITTED را می‌پذیرد ولی بی‌صدا به READ COMMITTED ارتقا می‌دهد؛ اوراکل اصلاً این سطح را نمی‌پذیرد. این ناهنجاری عملاً مسئله‌ی SQL Server (بدونِ RCSI) و MySQL است.

خواندن غیرتکرارپذیر (non-repeatable read)

برچسبِ قیمتی که عوض شد

به کالایی نگاه می‌کنی: ۱۰۰. می‌روی سبد بیاوری، برمی‌گردی، همان کالا ۱۲۰ است. یک بارِ خرید (تراکنش) داری اما دو نگاه دو عدد داد.

T1 سطری را می‌خواند، T2 آن را آپدیت و commit می‌کند، T1 دوباره می‌خواند و مقدارِ متفاوتی می‌بیند.

-- Session A (READ COMMITTED = پیش‌فرض)
BEGIN;
SELECT balance FROM accounts WHERE id = 1;   -- 100
-- Session B: UPDATE accounts SET balance=120 WHERE id=1; COMMIT;
SELECT balance FROM accounts WHERE id = 1;   -- 120  <-- غیرتکرارپذیر
COMMIT;

خواندن شبح‌وار (phantom read)

مهمانِ تازه‌واردِ لیست

مهمان‌های «بالای ۳۰ سال» را می‌شماری: ۵ نفر. وسطِ کار کسی یک ۳۵ ساله اضافه می‌کند. دوباره می‌شماری: ۶ نفر. ردیفی تازه از ناکجا ظاهر شد؛ انگار شبح.

فرقِ ظریف: non-repeatable درباره‌ی تغییرِ یک سطرِ موجود است؛ phantom درباره‌ی تغییرِ مجموعه‌ی سطرهای منطبق با یک شرط.

به‌روزرسانی گم‌شده (lost update)

دو ویراستار روی یک سند

هر دو همان نسخه را باز می‌کنید. تو پاراگرافی اضافه و ذخیره می‌کنی. او پاراگرافِ دیگری روی نسخه‌ی قدیمیِ خودش ذخیره می‌کند. تغییرِ تو محو می‌شود، انگار هرگز نبوده.

هر دو balance=۱۰۰ را می‌خوانند، هر دو ۱۱۰ می‌نویسند؛ پاسخِ درست ۱۲۰ بود. مسابقه‌ی کلاسیکِ read-modify-write. در جدولِ SQL-92 نیست اما شایع‌ترین باگِ دنیای واقعی است.

-- A:                                     B:
BEGIN;                                 -- BEGIN;
SELECT balance FROM accounts            -- SELECT balance FROM accounts
  WHERE id=1;             -- 100        --   WHERE id=1;             -- 100
UPDATE accounts SET balance=110         -- UPDATE accounts SET balance=110
  WHERE id=1;                           --   WHERE id=1;   (منتظر A)
COMMIT;                                -- COMMIT;   -- نتیجه 110، نه 120
چرا هر دو موتور دقیقاً یک‌طور می‌بازند — و تفاوتِ ظریفشان

هر دو در RC نویسنده‌ی دوم را پشتِ قفلِ سطری نگه می‌دارند و بعد آپدیتِ کورش را اعمال می‌کنند. تفاوت در بعد از آزادشدنِ قفل است: پستگرس با مکانیزمِ EvalPlanQual سطرِ جدید را دوباره در برابرِ WHERE می‌سنجد و اگر منطبق نبود ردش می‌کند؛ اوراکل کلِ statement را از یک savepointِ ضمنی restart می‌کند (به آن write consistency می‌گویند) — که یعنی در اوراکل triggerهای سطری ممکن است دوبار اجرا شوند.

انحراف نوشتن (write skew)

دو پزشکِ آنکال

قانون: همیشه حداقل یک پزشک باید آنکال بماند. Alice و Bob هر دو آنکال‌اند و هر دو هم‌زمان می‌خواهند بروند. هرکدام نگاه می‌کند «۲ نفر آنکال‌اند، پس می‌توانم بروم» و خودش را برمی‌دارد. حالا صفر نفر آنکال است. هیچ‌کدام به‌تنهایی کارِ غلطی نکرد — هرکدام یک ردیفِ متفاوت را عوض کرد — ولی با هم قانون را شکستند.

چون هرکدام سطرِ متفاوتی می‌نویسد، تعارضِ مستقیمی روی «یک سطرِ واحد» رخ نمی‌دهد؛ برای همین snapshot isolation جلویش را نمی‌گیرد.

CREATE TABLE doctors (name text PRIMARY KEY, on_call boolean NOT NULL);
INSERT INTO doctors VALUES ('alice', true), ('bob', true);

-- A                                        B
BEGIN ISOLATION LEVEL REPEATABLE READ;   -- BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors             -- SELECT count(*) FROM doctors
  WHERE on_call;           -- 2          --   WHERE on_call;           -- 2
UPDATE doctors SET on_call=false         -- UPDATE doctors SET on_call=false
  WHERE name='alice';                    --   WHERE name='bob';
COMMIT;                                  -- COMMIT;   => صفر پزشک آنکال!
-- با ISOLATION LEVEL SERIALIZABLE یکی از دو تراکنش 40001 می‌گیرد و نجات پیدا می‌کنی.
تفاوتِ حیاتیِ Serializable در دو موتور
  • پستگرس از ۹.۱ سطح SERIALIZABLE را با SSI پیاده کرده: چرخه‌های خطرناکِ وابستگیِ read-write را در زمان اجرا می‌بیند و یکی را با 40001 می‌کشد. یعنی write skew واقعاً حذف می‌شود.
  • اوراکل SERIALIZABLE را به‌معنای snapshot isolation پیاده کرده: تراکنش در یک SCN منجمد می‌شود و اگر بخواهد سطری را عوض کند که بعد از شروعِ او commit شده ORA-08177: can't serialize access for this transaction می‌گیرد. هیچ تشخیصِ چرخه‌ای ندارد، پس write skew در بالاترین سطحِ اوراکل هم ممکن است.
  • پیامدِ مهاجرت: منطقی که در پستگرس با SERIALIZABLE امن بود، در اوراکل امن نیست؛ آنجا باید invariant را با قفلِ صریح تضمین کنی.

راهِ قابلِ‌حمل، بدونِ تکیه به سطحِ ایزوله:

BEGIN;
SELECT count(*) FROM doctors WHERE on_call FOR UPDATE;   -- سطرها قفل شدند
UPDATE doctors SET on_call = false
 WHERE name = 'alice'
   AND (SELECT count(*) FROM doctors WHERE on_call) > 1;
COMMIT;
جمله‌ی کلیدی این بخش

سطوحِ ایزوله را «هرچه بیشتر ناهنجاری ممنوع کنند، قوی‌تر» بفهم: dirty < non-repeatable < phantom < write skew. و یادت باشد: نامِ سطح در دو موتور یکی است، معنایش نه.


چهار سطح ایزوله‌ی SQL — و اینکه هر موتور واقعاً کدام را دارد

سطح استاندارد dirty read non-repeatable phantom lost update / write skew
Read Uncommitted ممکن ممکن ممکن ممکن
Read Committed خیر ممکن ممکن ممکن (lost update)
Repeatable Read خیر خیر ممکن (طبق استاندارد) ممکن (write skew)
Serializable خیر خیر خیر خیر

اما جدولِ واقعی — همانی که در مصاحبه‌ی ارشد می‌پرسند — این است:

سطحی که می‌نویسی PostgreSQL 16/17 واقعاً چه می‌دهد Oracle 19c/23ai واقعاً چه می‌دهد
READ UNCOMMITTED پذیرفته می‌شود ولی دقیقاً READ COMMITTED اجرا می‌شود پشتیبانی نمی‌شود — خطا می‌گیری
READ COMMITTED (پیش‌فرضِ هر دو) هر statement یک snapshotِ تازه؛ بدون dirty read هر statement یک SCN تازه؛ بدون dirty read
REPEATABLE READ snapshot isolation واقعی: یک snapshot برای کلِ تراکنش، phantom هم حذف می‌شود، ولی write skew باقی است پشتیبانی نمی‌شود — معادلش SERIALIZABLE یا READ ONLY اوراکل است
SERIALIZABLE SSI: سریالایزپذیریِ واقعی؛ write skew حذف می‌شود؛ 40001 serialization_failure snapshot isolation: یک SCN برای کلِ تراکنش؛ ORA-08177؛ write skew همچنان ممکن
READ ONLY وجود ندارد (معادل: REPEATABLE READ READ ONLY) سطحِ اختصاصی: خوانشِ سازگارِ کلِ‌تراکنش، بدون DML و بدون ریسکِ ORA-08177
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
COMMIT;
BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE;   -- شکلِ فشرده
COMMIT;
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SHOW transaction_isolation;
اگر کدت `REPEATABLE READ` می‌فرستد، در اوراکل چه می‌شود؟

درایورهای JDBC اوراکل فقط TRANSACTION_READ_COMMITTED و TRANSACTION_SERIALIZABLE را می‌پذیرند. @Transactional(isolation = Isolation.REPEATABLE_READ) روی اوراکل یک استثنا می‌دهد، نه یک ارتقای بی‌صدا. در کدِ چنددیتابیسی یا Isolation.DEFAULT بگذار یا سطح را در پروفایلِ هر دیتابیس جدا تنظیم کن.

سطحِ READ ONLY اوراکل

عکسِ هوایی برای گزارش سالانه

اگر هر نمودارِ گزارش را از یک عکسِ متفاوت بکشی، اعداد با هم نمی‌خوانند. READ ONLY یعنی «یک عکس بگیر و هر ۲۰ نمودار را از همان یک عکس بکش».

BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
  SELECT sum(balance) FROM accounts;
  SELECT sum(amount)  FROM transactions;    -- هر دو از یک snapshot
COMMIT;
-- برای گزارشِ سنگین که هرگز نباید 40001 بگیرد:
-- BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
چرا `READ ONLY` در اوراکل مهم است

از SERIALIZABLE ارزان‌تر است چون هیچ DMLی نمی‌کند و هرگز ORA-08177 نمی‌گیرد. اما تا وقتی باز است، undoی آن SCN باید بماند: گزارشِ سه‌ساعته یعنی سه ساعت فشار روی undo و بالا رفتنِ ریسکِ ORA-01555. نزدیک‌ترین معادلِ پستگرس، SERIALIZABLE READ ONLY DEFERRABLE است که صبر می‌کند تا یک snapshotِ «امن» پیدا کند و بعد تضمین می‌کند هرگز 40001 ندهد.

ظرافتِ کلیدیِ Read Committed (در هر دو موتور)

زیر RC هر statement یک عکسِ تازه می‌گیرد — در پستگرس snapshot، در اوراکل SCN. پس دو SELECTِ پشتِ‌سرهم می‌توانند داده‌ی متفاوت ببینند. اما در پستگرس زیر RR یا در اوراکل زیر SERIALIZABLE/READ ONLY عکس فقط یک‌بار — در نخستین statement — گرفته و برای کلِ تراکنش منجمد می‌شود. همین تفاوت قلبِ خیلی از باگ‌هاست.


زیرِ کاپوت (۱): MVCC پستگرس

کتابخانه‌ای که نسخه‌های قدیمی را دور نمی‌ریزد

کتابخانه‌ای که با آمدنِ ویرایشِ جدید، نسخه‌ی قدیمی را در انبار نگه می‌دارد و روی هرکدام می‌نویسد «از تاریخِ X معتبر شد» و «در تاریخِ Y منسوخ شد». هر خواننده‌ای که وسطِ کار وارد شده، همان نسخه‌ای را می‌گیرد که موقعِ ورودش معتبر بوده — بی‌آنکه معطلِ نویسنده بماند.

هر نسخه‌ی سطر (tuple) دو ستونِ سیستمیِ پنهان دارد — همان «یادداشت‌های تاریخ»:

  • xmin — XIDی که این نسخه را ساخته.
  • xmax — XIDی که آن را منسوخ کرده؛ اگر هنوز زنده باشد ۰/null.
SELECT ctid, xmin, xmax, id, balance FROM accounts WHERE id = 1;
SELECT pg_current_xact_id();
`ORA_ROWSCN` پیش‌فرض در سطحِ *بلاک* است نه سطر

اگر جدول را با ROWDEPENDENCIES نساخته باشی، ORA_ROWSCN آخرین SCNِ کلِ بلاک را می‌دهد. یعنی تغییرِ سطرِ همسایه هم آن را جلو می‌برد و کنترلِ خوش‌بینانه‌ی مبتنی بر آن مثبتِ کاذب می‌دهد — و همین باعثِ ORA-08177های به‌ظاهر بی‌دلیل در تراکنش‌های SERIALIZABLE هم می‌شود. راه‌حل: CREATE TABLE ... ROWDEPENDENCIES (فقط هنگام ساخت) یا یک ستونِ version صریح.

سه عمل به این ستون‌ها این‌طور ترجمه می‌شوند: INSERT یک tuple با xmin=my_xid, xmax=0 می‌نویسد؛ DELETE فقط xmax=my_xid می‌گذارد؛ UPDATE = delete + insert.

وقتی تراکنش (یا در RC، هر statement) شروع می‌شود یک snapshot می‌گیرد: عکسی از «چه کسی تمام شده و چه کسی هنوز در حالِ کار است». یک نسخه visible است اگر xminاش پیش از snapshot commit شده و در فهرستِ درحال‌اجرا نباشد، و xmaxاش ۰/abort‌شده باشد یا به تراکنشی تعلق داشته باشد که تا آن لحظه commit نشده بود. همین یک قاعده همه‌ی سطوحِ ایزوله را می‌سازد، و به‌خاطرش خواننده و نویسنده هرگز هم را بلاک نمی‌کنند.

چرا آپدیتِ سنگین جدول را متورم می‌کند

چون UPDATE یعنی «قدیمی را منسوخ کن و جدید بنویس»، هر آپدیت یک نسخه‌ی مرده روی دیسک باقی می‌گذارد. جدولِ پرآپدیت پر از نسخه‌های مرده (bloat) می‌شود تا VACUUM بیاید. این مشکل مخصوصِ پستگرس است؛ اوراکل bloat به این شکل ندارد.

چطور بررسیِ commit ارزان می‌شود — و معادلِ اوراکلی‌اش

پرسیدنِ «آیا این XID commit شده؟» گران است، پس پستگرس نتیجه را از pg_xact می‌خواند و در hint bits روی هدرِ همان tuple کش می‌کند. اوراکل معادلِ همین را دارد و به آن delayed block cleanout می‌گویند: بلاکی که تراکنش عوض کرده ممکن است هنوز تمیز نشده باشد و اولین SELECT بعدی آن را تمیز می‌کند — به همین دلیل در اوراکل یک SELECT ساده می‌تواند redo تولید کند، چیزی که تازه‌کارها را شوکه می‌کند.


زیرِ کاپوت (۲): خوانشِ سازگارِ اوراکل با undo و SCN

دفترچه‌ی «قبلاً چه بود»

اوراکل مثل ویراستاری است که روی همان نسخه می‌نویسد ولی هر خط‌خوردگی را در دفترچه‌ای جدا یادداشت می‌کند: «ساعت ۱۰:۰۳ کلمه X بود، کردمش Y». اگر کسی متن را «به‌شکلِ ساعت ۱۰:۰۰» بخواهد، ویراستار نسخه‌ی فعلی را برمی‌دارد، خط‌خوردگی‌های بعد از ۱۰:۰۰ را از دفترچه برعکس اعمال می‌کند و یک کپیِ موقت می‌دهد. آن دفترچه undo است و آن کپی بلاکِ CR (Consistent Read).

مکانیزم گام‌به‌گام — Oracle builds a consistent-read copy of a block from undo:

sequenceDiagram
  participant Q as Query started at SCN 500
  participant B as Data block current SCN 620
  participant U as Undo segment
  Q->>B: read block
  B-->>Q: block SCN 620 is too new
  Q->>U: fetch undo records after SCN 500
  U-->>Q: before-images
  Q->>Q: clone block in buffer cache and roll it back
  Q->>Q: read the CR copy as of SCN 500
  Note over Q,U: undo overwritten leads to ORA-01555 snapshot too old

اگر رکوردِ undoی لازم دیگر موجود نباشد (چون تراکنش‌های بعدی رویش نوشته‌اند)، اوراکل بلاک را نمی‌تواند بازسازی کند و خطای معروف را می‌دهد: ORA-01555: snapshot too old.

دو موتور، دو بهای متفاوت برای یک ایده
  • پستگرس: نسخه‌ی جدید کنارِ قدیمی نوشته می‌شود. خواندنِ داده‌ی قدیمی رایگان است. بها = bloat و نیازِ همیشگی به VACUUM.
  • اوراکل: نسخه‌ی جدید روی قدیمی نوشته می‌شود و قبلی به undo می‌رود. جدول هرگز باد نمی‌کند. بها = CPU برای ساختِ بلاکِ CR هنگامِ خواندن و خطرِ ORA-01555.
  • هر وقت کسی گفت «اوراکل vacuum ندارد پس بهتر است»، جوابت این است: اوراکل هزینه را از زمانِ نگه‌داری به زمانِ خواندن منتقل کرده، حذفش نکرده.
مفهوم PostgreSQL Oracle
ساعت منطقیِ سراسری XID (۳۲ بیتی، چرخه‌ای) SCN (بسیار بزرگ‌تر)
محلِ نسخه‌ی قدیمی همان heap، به‌عنوان dead tuple undo tablespace
بازیابی پس از crash WAL redo log
rollback «visible نشو» — آنی اعمالِ معکوسِ undo — زمان‌بر
هزینه‌ی نگه‌داری VACUUM/autovacuum، bloat اندازه و retention مربوط به undo
خطای snapshot قدیمی در PG 17 حذف شد (old_snapshot_threshold برداشته شد) ORA-01555 — زنده و رایج
دیدنِ نسخه‌های قدیمی مستقیم ممکن نیست AS OF SCN/TIMESTAMP (Flashback)
-- PostgreSQL معادلِ داخلی برای «خواندن داده‌ی ۱۰ دقیقه پیش» ندارد.
-- باید خودت جدولِ تاریخچه بسازی و با trigger پرش کنی:
SELECT * FROM accounts_history
WHERE  id = 1
  AND  now() - interval '10 minutes' BETWEEN valid_from AND valid_to;
همان undو، همان محدودیت

Flashback Query جادو نیست: از همان undo استفاده می‌کند، پس اگر از UNDO_RETENTION عبور کنی ORA-01555 می‌گیری. برای تاریخچه‌ی بلندمدت به FLASHBACK ARCHIVE نیاز داری. در پستگرس این قابلیت اصلاً نیست و باید با trigger یا CDC بسازی‌اش.


بهایی که هر موتور می‌گیرد: VACUUM در برابر undo retention

سطلِ بازیافت در برابر انبارِ محدود

پستگرس مثل خانه‌ای است که کهنه را کنارِ نو می‌گذارد؛ اگر مأمورِ بازیافت (VACUUM) نیاید خانه پر می‌شود. اوراکل کهنه را به انبارِ اجاره‌ای (undo) می‌فرستد؛ خانه مرتب می‌ماند، اما اگر انبار پر شود قدیمی‌ترین اقلام دور ریخته می‌شوند و اگر همان لحظه کسی سراغشان را بگیرد دستِ خالی برمی‌گردد (ORA-01555).

سمتِ پستگرس: tupleهای مرده به‌صورتِ bloat انباشته می‌شوند. VACUUM افقِ xmin را حساب می‌کند — قدیمی‌ترین XID که هر snapshotِ فعال ممکن است لازم داشته باشد — و فقط نسخه‌های قدیمی‌تر از آن را آزاد می‌کند.

سمتِ اوراکل: UNDO_RETENTION (پیش‌فرض ۹۰۰ ثانیه) می‌گوید «سعی کن undoی commit‌شده را دستِ‌کم این‌قدر نگه داری». کلمه‌ی کلیدی «سعی کن» است: زیرِ فشار، undoی منقضی‌نشده هم بازنویسی می‌شود، مگر RETENTION GUARANTEE روشن باشد — که آن‌وقت به‌جای ORA-01555، نویسنده‌ها با ORA-30036: unable to extend segment ... in undo tablespace می‌شکنند. درد جابه‌جا می‌شود، حذف نمی‌شود.

چرا یک تراکنشِ طولانی هر دو موتور را نابود می‌کند — به دو شکلِ متفاوت
  • پستگرس: افقِ xmin برابرِ قدیمی‌ترین تراکنشِ زنده است، پس یک تراکنشِ فراموش‌شده افق را همه‌جا پایین نگه می‌دارد؛ vacuum در هیچ جدولی نمی‌تواند تمیز کند و کلِ کلاستر متورم می‌شود — نه فقط جدولی که آن تراکنش لمسش کرده.
  • اوراکل: کوئریِ طولانی undoی SCNِ خودش را لازم دارد؛ اگر نباشد، خودش با ORA-01555 می‌میرد. و تراکنشِ نویسنده‌ی طولانی undo را تا commit اشغال می‌کند و می‌تواند بقیه را با ORA-30036 بکشد.
  • جمله‌ای که امتیاز می‌گیرد: «در پستگرس تراکنشِ طولانی بقیه را قربانی می‌کند؛ در اوراکل معمولاً خودش قربانی می‌شود — مگر نویسنده باشد.»
-- تراکنش‌های طولانی که vacuum را بلاک می‌کنند
SELECT pid, state, now() - xact_start AS xact_age, left(query, 60) AS query
FROM   pg_stat_activity
WHERE  xact_start IS NOT NULL
ORDER  BY xact_start;      -- قدیمی‌ترین xact_start افقِ xmin را پین می‌کند

-- ریسکِ wraparound (الزامِ عملیاتی، نه لوکس)
SELECT datname, age(datfrozenxid) AS xid_age,
       round(100 * age(datfrozenxid) / 2000000000.0, 1) AS pct_to_shutdown
FROM   pg_database ORDER BY xid_age DESC;
-- تراکنش‌های طولانی و مصرفِ undoی آن‌ها
SELECT s.sid, s.username, t.start_time,
       t.used_ublk * 8192 / 1024 / 1024 AS undo_mb,
       SUBSTR(q.sql_text, 1, 60) AS sql_text
FROM   v$transaction t
JOIN   v$session s ON s.taddr = t.addr
LEFT   JOIN v$sql q ON q.sql_id = s.sql_id
ORDER  BY t.start_time;

-- سلامتِ undo در ۲۴ ساعت گذشته
SELECT MAX(tuned_undoretention) AS tuned_retention_sec,
       MAX(maxquerylen)         AS longest_query_sec,
       SUM(ssolderrcnt)         AS ora_01555_count
FROM   v$undostat;
-- تنظیماتِ نگه‌داری و کشتنِ تراکنش‌های زامبی
ALTER SYSTEM SET autovacuum_vacuum_scale_factor = 0.05;
ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
ALTER SYSTEM SET transaction_timeout = '10min';   -- تازه در PostgreSQL 17
SELECT pg_reload_conf();
VACUUM (ANALYZE, VERBOSE) accounts;
ALTER SYSTEM SET undo_retention = 3600 SCOPE=BOTH;    -- ثانیه
ALTER TABLESPACE undotbs1 RETENTION GUARANTEE;        -- تضمینِ سخت
ALTER PROFILE app_profile LIMIT IDLE_TIME 5;          -- دقیقه
-- Oracle معادلِ VACUUM ندارد؛ به‌جایش آمار را تازه کن:
EXEC DBMS_STATS.GATHER_TABLE_STATS(USER, 'ACCOUNTS');
wraparound فقط دردِ پستگرس است — و کشنده

XIDها ۳۲‌بیتی و چرخه‌ای‌اند؛ اگر قدیمی‌ترین XIDِ منجمدنشده به حدودِ ۲ میلیارد فاصله برسد، پستگرس نوشتن را متوقف می‌کند. سناریوی کلاسیکِ فاجعه: یک replication slot یا یک تراکنشِ prepared فراموش‌شده افق را نگه می‌دارد → autovacuum نمی‌تواند freeze کند → یک شبِ جمعه دیتابیس read-only می‌شود. سه چیز را همیشه چک کن: pg_stat_activity، pg_replication_slots، pg_prepared_xacts. اوراکل چنین بحرانی ندارد چون SCN بسیار بزرگ‌تر است. در ضمن transaction_timeout تازه در پستگرس ۱۷ آمده؛ پیش از آن یک تراکنشِ پر از دستورهای کوتاه از هر دو timeoutِ قبلی فرار می‌کرد.


همروندیِ قفل‌محور (2PL) — راهِ رقیب

رزروِ اتاقِ جلسه

برای کار روی هر داده باید کلیدِ اتاق را برداری: برای خواندن کلیدِ «مشترک» (Shared) که چند نفر هم‌زمان می‌توانند بگیرند، برای نوشتن کلیدِ «انحصاری» (eXclusive) که تا آزادش نکنی هیچ‌کس وارد نمی‌شود. مادامی که کلیدِ نوشتن دستِ کسی است، بقیه پشتِ در منتظرند.

در 2PL یک فاز رشد داری (فقط قفل می‌گیری) و یک فاز کوچک‌شدن (به‌محضِ آزادکردنِ اولین قفل، دیگر حق نداری قفلِ تازه بگیری). این سریالایزپذیری می‌دهد اما خواننده و نویسنده هم را بلاک می‌کنند. Strict 2PL (نگه‌داشتن تا commit) رایج است چون از abortهای آبشاری جلوگیری می‌کند.

PostgreSQL (MVCC) Oracle (MVCC با undo) 2PL کلاسیک (SQL Server بدون RCSI)
خواننده در برابر نویسنده هرگز بلاک نمی‌کنند هرگز بلاک نمی‌کنند بلاک می‌کنند
قفلِ خواندن ندارد ندارد Shared lock
ارتقای قفل (escalation) ندارد هرگز ندارد (قفل در خودِ بلاک) دارد (سطر → صفحه → جدول)
هزینه نسخه‌ها، vacuum/bloat ساختِ بلاکِ CR، مدیریت undo مدیر قفل، رقابت، بن‌بست
هر دو موتور برای نوشتن قفل می‌گیرند

UPDATE/DELETE/MERGE/SELECT FOR UPDATE در هر دو، سطر را قفل می‌کنند. پستگرس قفل را در xmax و lock manager نگه می‌دارد؛ اوراکل آن را در ITL (Interested Transaction List) درونِ headerِ همان بلاک می‌نویسد. نتیجه‌ی عملیِ اوراکل: هرگز قفل کم نمی‌آورد و هرگز به قفلِ جدول ارتقا نمی‌دهد — ولی اگر INITRANS کوچک و همروندیِ یک بلاک زیاد باشد، انتظارِ enq: TX - allocate ITL entry می‌بینی که علاجش افزایشِ INITRANS/PCTFREE است.

BEGIN;
LOCK TABLE accounts IN SHARE ROW EXCLUSIVE MODE;
COMMIT;
-- دیدن انتظارها
SELECT pid, pg_blocking_pids(pid) AS blockers, wait_event, left(query,60)
FROM   pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0;

قفل سطری، SELECT ... FOR UPDATE و کنترل بدبینانه

راهِ بدبینانه برای جلوگیری از lost update این است که سطر را همان لحظه‌ی خواندن قفل کنی:

BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;   -- قفلِ انحصاریِ سطر
-- ... محاسبه در اپ ...
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;

حالا «چقدر منتظر بمانم؟» — که در دو موتور کاملاً متفاوت است:

SET LOCAL lock_timeout = '3s';
SELECT balance FROM accounts WHERE id=42 FOR UPDATE;             -- بعد از 3s: 55P03
SELECT balance FROM accounts WHERE id=42 FOR UPDATE NOWAIT;      -- فوری: 55P03
SELECT balance FROM accounts WHERE id=42 FOR UPDATE SKIP LOCKED;
-- قدرت‌های قفل: FOR UPDATE > FOR NO KEY UPDATE > FOR SHARE > FOR KEY SHARE
کدهای خطا را حفظ کن؛ در حلقه‌ی retry به دردت می‌خورند
موقعیت PostgreSQL Oracle
بن‌بست 40P01 deadlock_detected ORA-00060
شکستِ سریالایز 40001 serialization_failure ORA-08177
قفل گرفته نشد (NOWAIT) 55P03 lock_not_available ORA-00054
سررسیدِ انتظارِ قفل lock_timeout55P03 WAIT nORA-30006
نقضِ unique 23505 ORA-00001
snapshot خیلی قدیمی — (در PG 17 حذف شد) ORA-01555
پرشدنِ undo ORA-30036

صفِ کار با SKIP LOCKED — و تله‌ی بزرگِ اوراکل

-- الگوی تک‌دستوری و بسیار رایج در پستگرس
UPDATE jobs SET status = 'RUNNING'
WHERE  id = (SELECT id FROM jobs WHERE status = 'READY'
             ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1)
RETURNING id, payload;
`ORA-02014` — تله‌ای که هر تیمِ مهاجرت‌کننده یک‌بار می‌خورد

در اوراکل نمی‌توانی FOR UPDATE را با FETCH FIRST n ROWS ONLY (یا DISTINCT/GROUP BY) ترکیب کنی. راهِ «حیله‌ای» با ROWNUM هم خطرناک است: ROWNUM پیش از SKIP LOCKED ارزیابی می‌شود، پس ممکن است هیچ سطری برنگردد حتی وقتی کارِ آزاد وجود دارد — یعنی workerها بی‌دلیل بی‌کار می‌مانند. تنها راهِ قابل‌اعتماد همان cursor است (یا در JDBC، setFetchSize(1) روی همان کوئری بدونِ محدودکننده‌ی سطر).

وقتی یک سطر خیلی داغ است

تنها باجه‌ی بلیت‌فروشی

اگر کلِ موجودی در یک سطر باشد، آن سطر مثل تنها باجه‌ی استادیوم است: هرچقدر CPU اضافه کنی، صف پشتِ همان یک باجه می‌ماند. یا باید «چند باجه» بسازی (تقسیمِ موجودی به چند سطر) یا «به‌جای صف، برگه‌ی درخواست بگیر و آخر شب جمع بزن» — که دقیقاً ایده‌ی lock-free reservation اوراکل است.

-- بهترین کارِ پستگرس: آپدیتِ اتمیکِ شرطی، بدون خواندنِ قبلی
UPDATE inventory SET qty = qty - :d
WHERE  sku = :sku AND qty >= :d
RETURNING qty;      -- صفر سطر یعنی موجودی کافی نبود
-- برای سطرِ خیلی داغ: موجودی را به N «سطل» بشکن و تصادفی یکی را بردار.
-- PostgreSQL معادلِ RESERVABLE اوراکل را ندارد.
محدودیت‌های `RESERVABLE` و ابزارِ دومِ ۲۳ai

RESERVABLE فقط روی ستون‌های عددی کار می‌کند، جدول حتماً باید PK داشته باشد، حداکثر ۱۰ ستون در هر جدول، ستونِ reservable نمی‌تواند PK یا identity یا virtual باشد و index هم نمی‌پذیرد، و فقط در CHECK قابل استفاده است؛ مقدارِ نهایی در لحظه‌ی commit اعمال می‌شود. اوراکل ۲۳ai یک ابزارِ دیگر هم دارد که پستگرس ندارد: Priority Transactions — با ALTER SESSION SET TXN_PRIORITY = LOW و آستانه‌ی txn_auto_rollback_medium_priority_wait_target در سطح سیستم، تراکنشِ کم‌اهمیتی که تراکنشِ مهم‌تر را بلاک کرده خودکار rollback می‌شود. نزدیک‌ترین کارِ پستگرس، دادنِ lock_timeout کوتاه به کارهای کم‌اهمیت است.

بن‌بست (deadlock)

دو نفر در راهروی باریک

دو نفر از دو سرِ راهروی باریک وارد می‌شوند، هرکدام نیمی از راه را گرفته و منتظرِ عقب‌رفتنِ دیگری است. هیچ‌کدام عقب نمی‌رود. این دقیقاً بن‌بست است.

-- A                                       B
BEGIN;                                  -- BEGIN;
UPDATE accounts SET balance=balance-10  -- UPDATE accounts SET balance=balance-10
  WHERE id=1;                           --   WHERE id=2;
UPDATE accounts SET balance=balance+10  -- UPDATE accounts SET balance=balance+10
  WHERE id=2;   -- منتظر B               --   WHERE id=1;   -- منتظر A  => چرخه
-- بعد از deadlock_timeout (پیش‌فرض 1s) یکی از دو تراکنش *کاملاً* abort می‌شود:
--   ERROR: deadlock detected (SQLSTATE 40P01)
مهم‌ترین تفاوتِ عملیِ بن‌بست بین دو موتور

پستگرس کلِ تراکنشِ قربانی را abort می‌کند؛ تنها کارِ ممکن ROLLBACK و اجرای دوباره‌ی کلِ تراکنش است. اوراکل فقط همان statement را برمی‌گرداند: تراکنش باز می‌ماند و قفل‌های قبلی‌اش را همچنان نگه داشته است. اگر اپ ORA-00060 را بگیرد و بی‌خیال ادامه بدهد، یک تراکنشِ نیمه‌کاره با قفل‌های زنده باقی می‌ماند که می‌تواند کلِ سیستم را بخواباند. قانون: در handlerِ ORA-00060 حتماً ROLLBACK صریح بزن و بعد کلِ تراکنش را retry کن. اوراکل ضمناً برای هر بن‌بست یک trace file با «deadlock graph» می‌نویسد.

پیشگیری از بن‌بست، در یک جمله

همیشه قفل‌ها را با یک ترتیبِ سراسریِ ثابت بگیر — مثلاً همیشه اول id کوچک‌تر. اگر همه‌ی مسیرهای کد همین ترتیب را رعایت کنند، چرخه‌ای شکل نمی‌گیرد و بیشترِ بن‌بست‌ها ناپدید می‌شوند.

CREATE OR REPLACE FUNCTION transfer(p_from int, p_to int, p_amt numeric)
RETURNS void LANGUAGE plpgsql AS $$
DECLARE lo int := least(p_from, p_to); hi int := greatest(p_from, p_to);
BEGIN
  PERFORM 1 FROM accounts WHERE id = lo FOR UPDATE;
  PERFORM 1 FROM accounts WHERE id = hi FOR UPDATE;
  UPDATE accounts SET balance = balance - p_amt WHERE id = p_from;
  UPDATE accounts SET balance = balance + p_amt WHERE id = p_to;
END $$;

قفل‌های advisory: وقتی چیزی که قفل می‌کنی سطر نیست

تابلوی «در حالِ تعمیر»

گاهی چیزی که می‌خواهی محافظت کنی سطر نیست: «فقط یک نمونه از این job همزمان اجرا شود». اینجا تابلوی «در حالِ تعمیر» را روی یک ایده می‌گذاری، نه روی یک شیء.

BEGIN;
  SELECT pg_advisory_xact_lock(hashtext('monthly-report'));   -- در COMMIT آزاد می‌شود
  -- ... تنها یک session این بلاک را همزمان اجرا می‌کند ...
COMMIT;
SELECT pg_try_advisory_xact_lock(hashtext('monthly-report')); -- نسخه‌ی بدون انتظار
تفاوت‌های ریزی که مهم‌اند

در پستگرس کلید یک عددِ ۶۴بیتی است و خودت باید نام را hash کنی و مراقبِ برخوردِ hash باشی؛ در اوراکل ALLOCATE_UNIQUE نام را در DBMS_LOCK_ALLOCATED ثبت می‌کند (با یک commitِ autonomous پشتِ صحنه)، پس برخورد ندارد. در هر دو، نسخه‌ی «تا پایانِ session» یک نشتِ کلاسیک است — همیشه نوعِ تراکنشی (pg_advisory_xact_lock یا release_on_commit => TRUE) را ترجیح بده. و هیچ‌کدام بینِ چند دیتابیس کار نمی‌کند؛ برای قفلِ توزیع‌شده باید سراغِ etcd/ZooKeeper بروی و آنجا هم بدونِ fencing token تضمینِ واقعی نداری.


خوش‌بینانه در برابر بدبینانه

رزروِ رستوران در برابر سرزده رفتن

بدبینانه یعنی از قبل زنگ بزنی و میز را قفل کنی — مطمئنی میز هست، اما اگر شلوغ نباشد بی‌جهت میز را از دیگران گرفته‌ای. خوش‌بینانه یعنی بی‌رزرو بروی: اگر خالی بود عالی و سریع، اگر پر بود باید برگردی و دوباره امتحان کنی. کدام بهتر است؟ بستگی دارد رستوران چقدر شلوغ باشد.

UPDATE accounts
SET    balance = :new_balance, version = version + 1, updated_at = now()
WHERE  id = 42 AND version = :read_version
RETURNING version;      -- هیچ سطری برنگشت یعنی باختی: دوباره بخوان و تلاش کن

منطقِ ظریفش: AND version = :read_version می‌گوید «فقط وقتی بنویس که نسخه هنوز همان است که خواندم». اگر کسی وسطِ کار نوشته باشد، شرط برقرار نیست، صفر سطر آپدیت می‌شود، و می‌فهمی باید از نو بخوانی.

-- PostgreSQL معادلِ ORA_ROWSCN ندارد. xmin را جایگزینش نکن —
-- بعد از VACUUM FREEZE عوض می‌شود. راهِ درست: ستونِ version با trigger.
CREATE OR REPLACE FUNCTION bump_version() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN NEW.version := OLD.version + 1; NEW.updated_at := now(); RETURN NEW; END $$;
CREATE TRIGGER accounts_bump BEFORE UPDATE ON accounts
FOR EACH ROW EXECUTE FUNCTION bump_version();

در JPA/Hibernate این همان @Version است و مستقل از موتور کار می‌کند:

@Entity
class Account {
    @Id Long id;
    BigDecimal balance;

    @Version            // Hibernate به UPDATEها "AND version = ?" اضافه می‌کند
    long version;       // عدم‌تطابق -> OptimisticLockException
}

// بدبینانه؛ در هر دو موتور به SELECT ... FOR UPDATE ترجمه می‌شود:
//   PostgreSQL -> lock_timeout / FOR UPDATE NOWAIT
//   Oracle     -> FOR UPDATE WAIT 3 / NOWAIT
em.find(Account.class, 42L, LockModeType.PESSIMISTIC_WRITE,
        Map.of("jakarta.persistence.lock.timeout", 3000));
@Retryable(retryFor = { CannotSerializeTransactionException.class,  // 40001 و ORA-08177
                        DeadlockLoserDataAccessException.class,     // 40P01 و ORA-00060
                        OptimisticLockingFailureException.class },
           maxAttempts = 4, backoff = @Backoff(delay = 25, multiplier = 2, random = true))
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transfer(long from, long to, BigDecimal amt) {
    // PostgreSQL: SSI ممکن است 40001 بدهد (شاید تازه هنگام COMMIT).
    // Oracle:     snapshot isolation ORA-08177 می‌دهد (همان لحظه‌ی UPDATE).
}
Spring هر دو موتور را به یک سلسله‌مراتبِ استثنا نگاشت می‌کند

مترجمِ کدِ خطای اسپرینگ ORA-00060 و 40P01 را هر دو به DeadlockLoserDataAccessException و ORA-08177 و 40001 را هر دو به CannotSerializeTransactionException می‌برد. یعنی یک کدِ retry روی هر دو موتور کار می‌کند — به شرطی که به کلاسِ استثنا تکیه کنی، نه به عددِ خطا.

نکته‌ی ارشد: Serializable بدونِ retry، در هر دو موتور خراب است

در پستگرس SSI می‌تواند هر تراکنش را هنگامِ commit با 40001 بکشد؛ در اوراکل هر آپدیت روی سطری که بعد از شروعِ تراکنش عوض شده ORA-08177 می‌دهد. این باگ نیست، طراحیِ سطح است. تفاوتِ ظریف: در اوراکل خطا در همان UPDATE می‌آید، در پستگرس ممکن است تا COMMIT عقب بیفتد — پس کدی که فقط دورِ UPDATE را می‌گیرد، در پستگرس خطا را از دست می‌دهد.


تراکنش‌های autonomous: قابلیتی که فقط اوراکل دارد

دفترِ حضور و غیابِ جدا

قرارداد ممکن است در نهایت لغو شود، اما می‌خواهی این واقعیت که «امروز جلسه برگزار شد» هرچه شد در دفترِ حضور بماند. تراکنشِ autonomous یعنی یک دفترِ کاملاً جدا که سرنوشتش به قرارداد وصل نیست.

-- PostgreSQL تراکنشِ autonomous ندارد. رایج‌ترین جایگزین: dblink
CREATE EXTENSION IF NOT EXISTS dblink;
CREATE OR REPLACE FUNCTION log_error(p_msg text)
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
  PERFORM dblink_exec('dbname=' || current_database(),
    format('INSERT INTO error_log(msg, at) VALUES (%L, now())', p_msg));
END $$;
-- گزینه‌ی دوم: PROCEDURE با COMMIT داخلی (PG 11+) — که autonomous نیست،
-- تراکنشِ بیرونی را commit می‌کند. بهترین راه: لاگ را بیرونِ دیتابیس ببر.
تله‌های تراکنشِ autonomous

خودبن‌بستی: تراکنشِ autonomous از قفل‌های والد ارث نمی‌برد؛ اگر بخواهد سطری را عوض کند که والد قفل کرده، تا ORA-00060 منتظرِ خودش می‌ماند. شکستنِ اتمیکی: خودِ ایده یعنی خروجِ بخشی از کار از قانونِ همه-یا-هیچ. دورزدنِ mutating table trigger با آن رایج ولی نشانه‌ی طراحیِ بد است. و قابلِ حمل نیست: در مهاجرت به پستگرس هر PRAGMA AUTONOMOUS_TRANSACTION باید بازنویسی شود — پس در کدِ چنددیتابیسی لاگ‌کردن را از دیتابیس بیرون بکش.


تراکنش‌های توزیع‌شده: 2PC در برابر Saga

تا اینجا همه‌چیز روی یک دیتابیس بود. به‌محضِ اینکه یک عملیات چند دیتابیس یا سرویس را دربر بگیرد، ACIDِ تک‌نودی دیگر پوششت نمی‌دهد.

مراسمِ عقد

عاقد (coordinator) از هر طرف جداگانه می‌پرسد «آیا بله؟». هرکس بله بگوید متعهد و قفل می‌شود. وقتی هر دو بله گفتند، عاقد اعلامِ نهایی می‌کند. اما اگر عاقد درست بینِ «بله»ها و اعلامِ نهایی غش کند، هر دو طرف بلاتکلیف و قفل‌شده می‌مانند.

جریانِ ۲PC و نقطه‌ی مرگش — Two-phase commit and the in-doubt window:

sequenceDiagram
  participant C as Coordinator
  participant A as DB A
  participant B as DB B
  C->>A: PREPARE
  C->>B: PREPARE
  A-->>C: vote YES, durable, locks held
  B-->>C: vote YES, durable, locks held
  Note over C: coordinator crashes here
  Note over A,B: in-doubt, locks held until manual resolution
  C->>A: COMMIT
  C->>B: COMMIT
-- PostgreSQL: پروتکل صریحِ 2PC — و پیش‌فرض *خاموش* است
-- ALTER SYSTEM SET max_prepared_transactions = 50;   -- نیاز به restart
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'txn-42';     -- قفل‌ها نگه داشته می‌شوند، تراکنش معلق است
COMMIT PREPARED 'txn-42';         -- یا ROLLBACK PREPARED 'txn-42';

-- تراکنش‌های معلقِ فراموش‌شده (خطرناک: vacuum را فلج می‌کنند)
SELECT gid, prepared, owner, now() - prepared AS age FROM pg_prepared_xacts;
تراکنشِ prepared فراموش‌شده = بمبِ ساعتی در هر دو موتور

در پستگرس یک ردیف در pg_prepared_xacts که کسی commitش نکرده افقِ xmin را برای همیشه پایین نگه می‌دارد؛ vacuum فلج می‌شود و در نهایت به بحرانِ wraparound می‌رسی — دقیقاً به همین دلیل max_prepared_transactions پیش‌فرض صفر است. در اوراکل یک ردیف در DBA_2PC_PENDING یعنی قفل و undo برای همیشه نگه داشته می‌شوند و بقیه ORA-01591: lock held by in-doubt distributed transaction می‌گیرند؛ فرایندِ RECO تلاش می‌کند خودکار حلش کند و اگر نتوانست DBA باید دخالت کند. در هر دو، آلارمِ «تراکنشِ prepared قدیمی‌تر از N دقیقه» از روز اول واجب است.

۲PC در عمل پرهیز می‌شود: هنگام مرگِ coordinator بلاک می‌کند و مقاوم به partition نیست، قفل را در طولِ رفت‌وبرگشتِ شبکه نگه می‌دارد و throughput را می‌کشد، و بسیاری از storeها XA خوبی ندارند.

رزروِ یک سفر با امکانِ لغو

پرواز، هتل و ماشین را جدا رزرو می‌کنی؛ هرکدام یک تراکنشِ محلیِ کامل است. اگر هتل نشد، همه‌چیز را «یکجا» عقب نمی‌زنی — برای هر رزروِ موفق یک لغو (جبران) اجرا می‌کنی. قفلِ سراسری نیست؛ فقط زنجیره‌ای از کارها و لغوهایشان.

عملیات را به دنباله‌ای از تراکنش‌های محلی مدل کن که هرکدام یک تراکنشِ جبرانی دارد. بدونِ قفلِ سراسری؛ در عوض سازگاریِ نهایی.

flowchart LR
  S1[Create order PENDING] --> S2[Charge payment]
  S2 --> S3[Reserve stock]
  S3 --> S4[Confirm order]
  S3 -. failure .-> C2[Refund payment]
  C2 --> C1[Cancel order]
  S2 -. failure .-> C1

دو سبکِ هماهنگی: Orchestration (یک orchestratorِ مرکزی مثل رهبرِ ارکستر — استدلال آسان‌تر) و Choreography (سرویس‌ها به رویدادهای هم واکنش می‌دهند مثل رقصنده‌ها — کوپلینگِ شل‌تر ولی ردیابیِ سخت‌تر).

ستونِ فقراتِ هر saga، الگوی outbox است: رویداد را در همان تراکنشِ محلیِ تغییرِ حالت بنویس.

BEGIN;
  UPDATE orders SET status = 'CONFIRMED' WHERE id = 42;
  INSERT INTO outbox(aggregate_id, event_type, payload)
  VALUES ('42', 'OrderConfirmed', jsonb_build_object('orderId', 42));
COMMIT;

-- publisher با SKIP LOCKED چند نمونه‌ای می‌شود
UPDATE outbox SET published_at = now()
WHERE  id IN (SELECT id FROM outbox WHERE published_at IS NULL
              ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 100)
RETURNING id, event_type, payload;
بهایی که saga می‌گیرد

saga دسترس‌پذیری و مقیاس می‌دهد اما فقط سازگاریِ نهایی و بدونِ isolation: حالت‌های میانی visible‌اند (لحظه‌ای پول کم شده ولی سفارش تأیید نشده). پس قفل‌های معنایی / پرچم‌های status و گام‌های idempotent، قابلِ‌retry و قابلِ‌جبران لازم داری — و حتماً outbox تا از dual-write در امان بمانی.


چک‌لیستِ مهاجرت بین دو موتور

موضوع PostgreSQL Oracle کارِ لازم
شروعِ تراکنش BEGIN صریح؛ بیرونش autocommit اولین DML خودش شروع می‌کند COMMIT/ROLLBACK صریح بنویس
DDL تراکنشی commitِ ضمنی migrationهای اوراکل را idempotent کن
خطای statement کلِ تراکنش abort فقط همان statement در پستگرس SAVEPOINT بگذار
REPEATABLE READ هست (snapshot isolation) ندارد به SERIALIZABLE/READ ONLY نگاشت کن
SERIALIZABLE SSI؛ بدون write skew snapshot isolation؛ با write skew در اوراکل قفلِ صریح بگذار
بن‌بست کلِ تراکنش abort فقط statement در اوراکل ROLLBACK صریح در handler
FOR UPDATE + محدودیت سطر LIMIT مجاز FETCH FIRST ممنوع (ORA-02014) در اوراکل cursor بنویس
انتظارِ قفل lock_timeout FOR UPDATE WAIT n به لایه‌ی دسترسی منتقل کن
autonomous tx ندارد PRAGMA AUTONOMOUS_TRANSACTION لاگ را بیرونِ DB ببر یا dblink
قفلِ advisory pg_advisory_xact_lock DBMS_LOCK.REQUEST یک لایه‌ی abstraction بساز
سفر در زمان ندارد AS OF SCN/TIMESTAMP جدولِ تاریخچه یا CDC بساز
نگه‌داری VACUUM، bloat، wraparound UNDO_RETENTION، ORA-01555 داشبوردِ هرکدام را جدا بساز

دام‌ها و نکات ظریف (اینها را از خون تجربه بخوان)

  • بازخوانی‌های Read Committed جابه‌جا می‌شوند. حلقه‌ی SELECT سپس UPDATE در RC روی داده‌ی کهنه عمل می‌کند — یا FOR UPDATE بزن یا UPDATE ... WHEREِ اتمیک. در هر دو موتور.
  • UPDATE ... SET x = x + 1 حتی در RC اتمیک و ایمن است، چون خودِ نوشتن آخرین نسخه را زیرِ قفلِ سطری بازمی‌خواند. خطر فقط در read-then-writeِ بینِ دو statement است.
  • تراکنش‌های طولانی سم‌اند — با دو علامتِ متفاوت. پستگرس: افقِ xmin و bloat. اوراکل: مصرفِ undo و ORA-01555. هرگز تراکنش را در طولِ think-timeِ کاربر یا فراخوانِ HTTP باز نگه ندار.
  • در اوراکل فراموش‌کردنِ COMMIT سکوت می‌کند. چون autocommit ندارد، sessionی که کارش تمام شده ولی commit نکرده ساعت‌ها قفل نگه می‌دارد بی‌آنکه خطایی ببینی. اولین چیزی که در حادثه‌ی «سیستم قفل کرده» چک می‌کنی همین است.
  • Repeatable Read ≠ Serializable (در پستگرس)، و Serializable اوراکل ≠ Serializable پستگرس. اگر درستیِ کارت به invariantی بینِ سطرهایی وابسته است که می‌خوانی ولی نمی‌نویسی، در پستگرس SERIALIZABLE کافی است؛ در اوراکل باید قفلِ صریح بگذاری.
  • بن‌بست‌ها زیرِ رقابت طبیعی‌اند. برای retry طراحی کن، ترتیبِ سراسریِ قفل را الزام کن، و در اوراکل خودت ROLLBACK بزن.
  • رشته‌ی خالی در اوراکل NULL است. WHERE note = '' هرگز چیزی برنمی‌گرداند و CHECK (note <> '') بی‌اثر است؛ اعتبارسنجیِ منتقل‌شده از پستگرس بی‌صدا از کار می‌افتد.
  • SELECT در اوراکل می‌تواند redo تولید کند (delayed block cleanout)؛ پس «کوئریِ فقط-خواندنی هزینه‌ی نوشتن ندارد» در اوراکل درست نیست.
  • فراخوانِ درون‌کلاسیِ @Transactional در اسپرینگ proxy را دور می‌زند و هیچ تراکنشی شکل نمی‌گیرد — باگِ کلاسیکِ خاموش، مستقل از موتور.
  • نقضِ قید می‌تواند هنگامِ commit ظاهر شود (قیدهای deferred) — پس 23505 / ORA-00001 را حتی «دور از INSERT» هم مدیریت کن.
  • savepointهای پرتعداد در پستگرس گران‌اند (بیش از ۶۴ در هر تراکنش → سرریزِ subtransaction cache)؛ در اوراکل ارزان‌ترند چون هر statement خودش savepointِ ضمنی دارد.

بهترین شیوه‌ها

۱. تراکنش‌ها را کوتاه نگه دار و I/Oِ خارجی را بیرونشان انجام بده — تنها قانونی که هر دو موتور را هم‌زمان نجات می‌دهد. ۲. پیش‌فرض Read Committed؛ فقط جایی که invariant لازم دارد بالا برو، و بدان «بالا رفتن» در پستگرس یعنی SSI و در اوراکل یعنی snapshot isolation + قفلِ صریح. ۳. برای read-modify-write، UPDATE ... WHEREِ اتمیک یا @Version را ترجیح بده؛ FOR UPDATE فقط وقتی که واقعاً باید در اپ محاسبه کنی. ۴. ترتیبِ ثابتِ قفل را الزام کن، و در اوراکل در handlerِ ORA-00060 رول‌بک کن. ۵. داشبوردِ درستِ هر موتور را بساز: پستگرس → bloat، تأخیرِ autovacuum، age(datfrozenxid)، pg_prepared_xacts؛ اوراکل → v$undostat، maxquerylen، ssolderrcnt، dba_2pc_pending، انتظارهای enq: TX. ۶. کدهای خطا را در یک لایه‌ی مشترک به استثناهای معنایی نگاشت کن تا منطقِ retry مستقل از موتور بماند. ۷. در سیستم‌های توزیع‌شده saga + outbox را به ۲PC ترجیح بده؛ هر گام را idempotent و قابلِ‌جبران کن. ۸. نوشتن‌ها را idempotent کن (کلیدِ idempotency) تا retryها ایمن باشند.


پرسش‌های مصاحبه

۱. «C» در ACID چه تضمینی می‌دهد و با «C» در CAP چه فرقی دارد؟

سازگاریِ ACID یعنی تراکنش قیدهای یکپارچگیِ اعلام‌شده (PK/FK/CHECK/trigger) را حفظ می‌کند — عمدتاً مسئولیتِ اپلیکیشن است. سازگاریِ CAP (همان linearizability) یعنی هر خواندن آخرین نوشتنِ commit‌شده را در همه‌ی replicaها می‌بیند. دو مفهومِ کاملاً متفاوت؛ اشتراکِ حرفِ اول تصادفی است.

۲. سطح ایزوله‌ی پیش‌فرضِ هر دو موتور چیست و Repeatable Read در آن‌ها چه معنایی دارد؟ (پیچیده)

پیش‌فرضِ هر دو Read Committed است. در پستگرس REPEATABLE READ وجود دارد و در واقع snapshot isolation است: یک snapshot برای کلِ تراکنش که phantom را هم حذف می‌کند (قوی‌تر از استاندارد)، ولی write skew را نه. در اوراکل REPEATABLE READ اصلاً وجود ندارد؛ معادل‌هایش SET TRANSACTION ISOLATION LEVEL SERIALIZABLE یا SET TRANSACTION READ ONLY هستند.

۳. تفاوتِ `SERIALIZABLE` در پستگرس و اوراکل را دقیق بگو. (بسیار مهم)

پستگرس از ۹.۱ آن را با SSI پیاده کرده: گرافِ وابستگی‌های read-write را در زمان اجرا می‌سازد و چرخه‌ی خطرناک را با 40001 می‌کشد → سریالایزپذیریِ واقعی و حذفِ write skew. اوراکل آن را صرفاً snapshot isolation پیاده کرده: تراکنش در یک SCN منجمد می‌شود و تغییرِ سطری که بعد از شروعِ او commit شده ORA-08177 می‌دهد. هیچ تشخیصِ چرخه‌ای ندارد، پس write skew در اوراکل حتی در SERIALIZABLE هم ممکن است و باید با FOR UPDATE یا LOCK TABLE جلویش را بگیری.

۴. write skew را توضیح بده و بگو در هر موتور چطور جلویش را می‌گیری.

دو تراکنش مجموعه‌ای هم‌پوشان می‌خوانند، invariantی را که برقرار است تأیید می‌کنند، و هرکدام سطرِ متفاوتی می‌نویسد که با هم invariant را می‌شکنند (هر دو پزشکِ آنکال مرخصی می‌گیرند). در پستگرس SERIALIZABLE واقعاً جلویش را می‌گیرد. در اوراکل هیچ سطحی جلویش را نمی‌گیرد؛ باید سطرهای خوانده‌شده را FOR UPDATE کنی، یا یک «سطرِ نگهبان» بسازی که همه قبل از تصمیم قفلش کنند، یا invariant را با materialized viewِ REFRESH ON COMMIT + CHECK اعلام کنی.

۵. اوراکل چطور بدونِ نگه‌داشتنِ چند نسخه در جدول، خوانشِ سازگار می‌دهد؟ (ارشد)

با undo + SCN. هر بلاک آخرین SCNِ تغییرش را در header دارد؛ اگر کوئری (که در SCNِ S شروع شده) بلاکی جدیدتر ببیند، آن را در buffer cache کپی می‌کند و رکوردهای undoی بعد از S را معکوس اعمال می‌کند تا نسخه‌ی آن لحظه بازسازی شود. به این کپی «بلاکِ CR» می‌گویند. اگر undoی لازم بازنویسی شده باشد ORA-01555: snapshot too old می‌گیری.

۶. `ORA-01555` و bloatِ پستگرس دو روی یک سکه‌اند. توضیح بده. (سخت)

هر دو بهای multiversion بودن‌اند، در دو جهت. پستگرس نسخه‌ی قدیمی را در خودِ جدول نگه می‌دارد: خواندنِ گذشته رایگان است ولی جدول باد می‌کند و به VACUUM نیاز دارد، و یک تراکنشِ طولانی افقِ xmin را پایین نگه می‌دارد و کلِ کلاستر را متورم می‌کند. اوراکل نسخه‌ی قدیمی را به undo می‌فرستد: جدول فشرده می‌ماند ولی خواندنِ گذشته CPU می‌خواهد و اگر undo بازنویسی شود کوئریِ طولانی با ORA-01555 می‌میرد. «پستگرس فضا را قربانی می‌کند، اوراکل زمان و undo را».

۷. چرا هیچ‌کدام از این دو موتور dirty read ندارند و `READ UNCOMMITTED` در هر کدام چه می‌شود؟

چون هر دو multiversion‌اند و اصلاً مکانیزمی برای عرضه‌ی نسخه‌ی commit‌نشده ندارند: پستگرس فقط نسخه‌هایی را visible می‌کند که xminشان commit شده، و اوراکل همیشه بلاک را به SCNِ کوئری برمی‌گرداند. پستگرس READ UNCOMMITTED را می‌پذیرد ولی بی‌صدا مثل READ COMMITTED رفتار می‌کند (عملاً ۳ رفتار دارد، نه ۴). اوراکل اصلاً این سطح را نمی‌پذیرد.

۸. باگ را پیدا کن
-- READ COMMITTED
SELECT balance FROM accounts WHERE id = 1;   -- اپ 100 می‌خواند
UPDATE accounts SET balance = 70 WHERE id = 1;   -- اپ 100-30 حساب کرده

lost update — و در هر دو موتور یکسان. بینِ خواندن و نوشتن، تراکنشِ دیگری می‌تواند balance را ۵۰ کند؛ این کورکورانه ۷۰ می‌نویسد و تغییرِ او را گم می‌کند. اصلاح: UPDATE accounts SET balance = balance - 30 WHERE id = 1 (اتمیک)، یا SELECT ... FOR UPDATE هنگام خواندن، یا شرطِ AND version = :v.

۹. این چه برمی‌گرداند؟ (پیچیده)
-- A                                    -- B
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT sum(x) FROM t;   -- 10
                                        -- INSERT INTO t(x) VALUES (5); COMMIT;
SELECT sum(x) FROM t;   -- ?

در هر دو، دوباره ۱۰. پستگرس snapshot را در نخستین statement منجمد کرد و اوراکل تراکنش را در یک SCN. زیر Read Committed در هر دو، دومین SELECT عددِ ۱۵ می‌دهد. نکته‌ی امتیازآور: در اوراکل باید SERIALIZABLE یا READ ONLY بنویسی چون REPEATABLE READ وجود ندارد.

۱۰. یک تراکنش در اوراکل `ORA-00060` می‌گیرد. چه اتفاقی افتاده و اپ باید چه کند؟ (دام)

اوراکل چرخه را دیده و فقط همان statement را برگردانده. تراکنش هنوز باز است و همه‌ی قفل‌های قبلی‌اش را نگه داشته؛ اگر اپ خطا را بگیرد و ادامه دهد، یک تراکنشِ نیمه‌کاره با قفل‌های زنده می‌ماند که می‌تواند بقیه را بخواباند. کارِ درست: ROLLBACK صریح و سپس retry کلِ تراکنش. این برعکسِ پستگرس است که با 40P01 کلِ تراکنش را abort می‌کند.

۱۱. اگر وسطِ یک تراکنش در اوراکل `CREATE INDEX` بزنی چه می‌شود؟ (دام)

یک commitِ ضمنی: همه‌ی کارِ تا آن لحظه قطعی می‌شود، بعد DDL اجرا و دوباره commit می‌شود؛ ROLLBACK بعدی هیچ‌چیز را برنمی‌گرداند. در پستگرس برعکس است — DDL تراکنشی است و کلِ migration را می‌شود عقب زد (به‌جز استثناهایی مثل CREATE INDEX CONCURRENTLY و VACUUM که درونِ بلاکِ تراکنش اجرا نمی‌شوند).

۱۲. چرا یک تراکنشِ طولانیِ واحد می‌تواند کلِ دیتابیس پستگرس را متورم کند؟ (سخت)

Vacuum فقط tupleهای مرده‌ی قدیمی‌تر از افقِ xmin را حذف می‌کند. یک تراکنشِ قدیمی این افق را همه‌جا پایین می‌آورد، پس tupleهای مرده در همه‌ی جدول‌ها غیرقابلِ‌حذف می‌شوند → bloatِ کل‌کلاستر و بلاکِ freezingِ ضدِ wraparound. سه منبعِ پنهانِ همین مشکل: تراکنشِ idle-in-transaction، replication slotِ رهاشده، و pg_prepared_xactsِ فراموش‌شده.

۱۳. wraparound چیست و چرا اوراکل آن را ندارد؟

XIDهای پستگرس ۳۲ بیتی و چرخه‌ای‌اند؛ اگر قدیمی‌ترین XIDِ منجمدنشده به حدودِ ۲ میلیارد فاصله برسد، پستگرس برای جلوگیری از خرابیِ داده نوشتن را متوقف می‌کند. برای همین vacuum وظیفه‌ی freeze را هم دارد و age(datfrozenxid) باید مانیتور شود. اوراکل به‌جای XID از SCN استفاده می‌کند که بسیار بزرگ‌تر است و نرخِ رشدش هم محدود می‌شود، پس چنین بحرانی ندارد.

۱۴. صفِ کار با `SKIP LOCKED` را در هر دو موتور بنویس. تفاوتِ خطرناک کجاست؟

در پستگرس ساده است: ... WHERE status='READY' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1. در اوراکل نمی‌توانی FETCH FIRST را با FOR UPDATE ترکیب کنی (ORA-02014)، و ROWNUM هم خطرناک است چون پیش از SKIP LOCKED ارزیابی می‌شود و ممکن است هیچ سطری برنگرداند حتی وقتی کارِ آزاد هست — یعنی workerها بی‌دلیل بی‌کار می‌مانند. راهِ درستِ اوراکل: cursor با FOR UPDATE SKIP LOCKED و فقط یک FETCH (یا setFetchSize(1) در JDBC).

۱۵. تراکنشِ autonomous چیست، کِی به دردت می‌خورد و خطرش چیست؟ (اوراکل)

PRAGMA AUTONOMOUS_TRANSACTION یک تراکنشِ کاملاً مستقل درونِ یک روالِ PL/SQL می‌سازد که قفل و سرنوشتش از والد جدا است و باید پیش از خروج COMMIT/ROLLBACK بدهد. کاربردِ اصلی: لاگ یا audit که حتی با rollbackِ تراکنشِ اصلی بماند. خطرها: خودبن‌بستی اگر بخواهد سطری را عوض کند که والد قفل کرده، شکستنِ اتمیکی، و اینکه در پستگرس معادلی ندارد (باید با dblink یا لاگِ بیرونِ دیتابیس شبیه‌سازی شود).

۱۶. گاهی زیر Serializable خطای `40001` یا `ORA-08177` می‌گیری. باگ است یا موردانتظار؟ (دام)

موردانتظار و بخشی از طراحی: در پستگرس SSI تراکنشِ ناقضِ سریالایزپذیری را می‌کشد؛ در اوراکل هر تغییرِ سطری که پس از شروعِ تراکنش commit شده رد می‌شود. مدیریتِ درست در هر دو یک حلقه‌ی retry دورِ کلِ تراکنش با backoff است. نکته‌ی امتیازآور: در اوراکل خطا در همان UPDATE می‌آید، در پستگرس ممکن است تا COMMIT عقب بیفتد — پس کدِ اوراکلی که فقط دورِ UPDATE را می‌گیرد در پستگرس خطا را از دست می‌دهد.

۱۷. چرا ۲PC پرهیز می‌شود و در هر موتور چطور پیاده شده؟

بلاک‌کننده است: مرگِ coordinator پس از prepare یعنی شرکت‌کننده‌ها بی‌نهایت با قفل‌های in-doubt گیر می‌کنند؛ و قفل در طولِ رفت‌وبرگشتِ شبکه throughput را می‌کشد. در پستگرس با PREPARE TRANSACTION/COMMIT PREPARED است و max_prepared_transactions پیش‌فرض صفر (عمداً خاموش، چون تراکنشِ prepared فراموش‌شده vacuum را فلج می‌کند). در اوراکل با نوشتن روی database link خودکار فعال می‌شود و تراکنش‌های گیرکرده در DBA_2PC_PENDING می‌نشینند و بقیه ORA-01591 می‌گیرند؛ RECO تلاش می‌کند و در نهایت DBA با COMMIT FORCE/ROLLBACK FORCE حل می‌کند.

۱۸. چرا UPDATE در پستگرس bloat می‌سازد ولی در اوراکل نه؟ راه‌حل هر کدام؟ (ارشد)

در پستگرس UPDATE در سطحِ tuple همان delete + insert است: نسخه‌ی قدیمی تا vacuum می‌ماند و هر index یک entryِ جدید می‌گیرد. کاهش‌ها: HOT update (ستون‌های index‌شده را عوض نکن تا tupleِ جدید در همان page بماند)، fillfactor مناسب، autovacuumِ تهاجمی‌تر، و VACUUM FULL/pg_repack. در اوراکل سطر درجا آپدیت می‌شود و قبلی به undo می‌رود، پس bloat نداری؛ در عوض اگر سطر بزرگ‌تر شود و در بلاک جا نشود row migration رخ می‌دهد و خواندن از طریق index گران می‌شود — علاجش PCTFREE مناسب و در صورت لزوم ALTER TABLE ... MOVE/SHRINK SPACE است.


جمع‌بندی
  • تراکنش دو محورِ مستقل دارد: هنگام خطا (WAL در پستگرس، redo + undo در اوراکل) و در همروندی (isolation). قاطی‌شان نکن.
  • ACID: اتمیکی، سازگاری (فقط قیدهای اعلام‌شده — و C در ACID ≠ C در CAP)، ایزوله، پایایی (synchronous_commit=off و COMMIT WRITE BATCH NOWAIT فقط durability را معامله می‌کنند).
  • شروع/پایان فرق دارد: پستگرس BEGIN صریح و DDL تراکنشی؛ اوراکل بدونِ autocommit، با commitِ ضمنی روی هر DDL و rollbackِ فقط-statement روی خطا.
  • پنج ناهنجاری: dirty read (در این دو موتور ناممکن)، non-repeatable، phantom، lost update، write skew.
  • سطوح: هر دو پیش‌فرض Read Committed. پستگرس چهار سطح دارد که عملاً سه رفتار است؛ RRاش snapshot isolation و Serializableاش SSI با 40001. اوراکل فقط Read Committed و Serializable (به‌اضافه‌ی READ ONLY) دارد و Serializableاش snapshot isolation با ORA-08177 است که write skew را نمی‌گیرد.
  • درونیات: پستگرس نسخه‌ها را در heap نگه می‌دارد (xmin/xmax) → bloat، VACUUM، ریسکِ wraparound. اوراکل قبلی را به undo می‌فرستد و بلاکِ CR می‌سازد → بدون bloat ولی با ORA-01555؛ و به‌جایش Flashback Query دارد.
  • قفل: هیچ‌کدام برای خواندن قفل نمی‌گیرند و هیچ‌کدام lock escalation ندارند. FOR UPDATE/SKIP LOCKED/NOWAIT در هر دو؛ ولی انتظار در پستگرس با lock_timeout و در اوراکل با WAIT n، و در اوراکل FETCH FIRST با FOR UPDATE ممنوع است (ORA-02014).
  • بن‌بست: پستگرس کلِ تراکنش را می‌کشد (40P01)؛ اوراکل فقط statement را (ORA-00060) و تو باید ROLLBACK بزنی. علاج در هر دو: ترتیبِ سراسریِ ثابت + retry.
  • خوش‌بینانه (@Version) در برابر بدبینانه (FOR UPDATE)؛ اوراکل ORA_ROWSCN (با ROWDEPENDENCIES) و در ۲۳ai ستونِ RESERVABLE و Priority Transactions را هم دارد.
  • توزیع‌شده: ۲PC اتمیک ولی بلاک‌کننده است (pg_prepared_xacts در برابر DBA_2PC_PENDINGsaga + outbox + idempotency را برگزین و بهای «سازگاریِ نهایی، بدونِ isolation» را بپذیر.

Every money bug, every "the balance went negative," every "the order got placed twice" traces back to one thing: not understanding what the database actually promises in two dangerous moments — when something fails, and when many people touch the same data at once. This chapter builds both moments from the ground up, with analogies.

One note before we start: a senior engineer works on PostgreSQL at one company and Oracle at the next. Both are multiversion engines, yet they differ precisely in the details that kill you in production. So every statement here appears in both dialects, and wherever the engines genuinely behave differently you get a warning right there.

Roadmap for this chapter
  • Two independent axes: "what happens on failure?" (atomicity/durability) vs "what happens under concurrency?" (isolation).
  • Starting and ending a transaction in both engines — and the "Oracle DDL commits itself" trap.
  • ACID, precisely plus the famous interview trap (ACID-C ≠ CAP-C).
  • The anomalies: dirty read, non-repeatable, phantom, lost update, write skew.
  • The four isolation levels plus a matrix of what each engine actually implements (Oracle has only two levels + READ ONLY).
  • Internals: xmin/xmax, snapshots, VACUUM, bloat and wraparound in Postgres; undo, SCN, CR blocks and ORA-01555 in Oracle.
  • Row locks, FOR UPDATE, SKIP LOCKED, deadlocks and the crucial rollback difference; advisory locks.
  • Optimistic vs pessimistic, @Version, ORA_ROWSCN, autonomous transactions.
  • Distributed: 2PC (PREPARE TRANSACTION and DBA_2PC_PENDING) and why sagas take over.
  • A porting checklist and 18 interview questions with full answers.

Part 0 — words you must know before we start

  • Transaction: a bundle of work the database promises to treat as "one piece." Like a mailed parcel: it either arrives whole or not at all; there's no half-parcel.
  • Concurrency: several transactions working on the same data at once. Like several people writing on one whiteboard.
  • Atomic: "indivisible." It either happens fully or not at all — no half-finished state is ever visible from outside.
  • Snapshot: a frozen photo of the database state at one instant. After the shutter clicks, people can wander off; the photo still shows that moment.
  • Tuple: in Postgres, "one version of one row." Why "version"? Because Postgres can keep several versions of a row at the same time.
  • XID (transaction ID): an ever-increasing serial number Postgres hands each transaction — like a deli-counter ticket. With it the engine knows which work came earlier.
  • SCN (System Change Number): Oracle's conceptual equivalent: a global, monotonically increasing logical clock for the whole database. Every commit gets an SCN, and every query says "give me the data as it was at SCN N."
  • Undo: in Oracle, a separate tablespace holding the previous value of every change — used both to roll back and to reconstruct old versions for readers.
  • Redo: in Oracle, the "what I changed" log used for crash recovery — the conceptual counterpart of Postgres WAL.
  • Invariant: a rule that must always stay true ("there must always be at least one open checkout counter").
  • Idempotent: an operation that, run many times, gives the same result as running it once — like an elevator floor button.
Hold onto the two-axis model

This entire chapter revolves around one idea: two completely separate questions. "What if the power dies?" and "What if two people write at once?" These are solved by different machinery, and mixing them up is the source of half of all confusion here.


Mental model: two independent axes

A restaurant kitchen

A restaurant kitchen faces two totally different dangers. First: the power cuts out mid-cook — the meal must either be delivered complete or the order cancelled and refunded, never served half-raw. Second: two waiters take an order for the same table at once — the orders must not collide. These dangers are unrelated and have different fixes. A database faces exactly the same two.

  1. What happens on failure?atomicity and durability: crash recovery, the log, rollback.
  2. What happens under concurrency?isolation: what one transaction may observe about another running at the same time.

The two axes and each engine's machinery — دو محور تضمین‌های تراکنش و ابزار هر موتور:

flowchart TD
  T[Transaction guarantees] --> F[Axis 1: on failure]
  T --> C[Axis 2: under concurrency]
  F --> PGW["PostgreSQL: WAL for redo, old tuple versions act as undo"]
  F --> ORW["Oracle: redo log for replay, undo tablespace for rollback"]
  C --> PGM["PostgreSQL: MVCC snapshots, xmin/xmax, SSI"]
  C --> ORM["Oracle: consistent read from undo, SCN snapshots"]
  PGM --> L[Row locks for writers in both engines]
  ORM --> L
Postgres WAL vs Oracle redo + undo

Both engines write down their "intent" before touching real data, but they divide the work differently:

  • Postgres has one log: the WAL. WAL does redo; undo is effectively done by MVCC itself — the old row version is still there, so ROLLBACK just means "never make the new version visible." That makes rollback essentially free and instant.
  • Oracle has two ledgers: the redo log for recovery and the undo tablespace for rollback and for consistent read. ROLLBACK genuinely rewrites the previous values back, which for a huge transaction can take minutes.
  • Practical consequence: rolling back a ten-million-row DELETE is slow in Oracle; it's instant in Postgres — but then vacuum must later reclaim ten million dead tuples. There is no free lunch.

Starting and ending a transaction: the engines already differ

-- PostgreSQL: outside a block, every statement is its own implicit transaction
BEGIN;                                  -- or START TRANSACTION;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;                                 -- or ROLLBACK;
Trap number one: Oracle has no autocommit

In Oracle the first DML opens a transaction that stays open until you explicitly COMMIT or ROLLBACK (tools may enable autocommit, but the engine does not). A developer coming from Postgres assumes "if I don't write BEGIN, nothing stays open" — in Oracle that habit produces sessions that hold locks for hours and pin undo.

-- PostgreSQL: DDL is transactional! A whole migration can be rolled back
BEGIN;
  CREATE TABLE audit_log (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
                          msg text NOT NULL);
  ALTER TABLE accounts ADD COLUMN note text;
ROLLBACK;   -- both vanish; the table was never created
The architectural consequence of "transactional DDL"

In Postgres, Flyway/Liquibase can wrap a whole migration in one transaction; a failure at step five returns the database exactly to its prior state. In Oracle that is impossible: every CREATE/ALTER/DROP is final, so Oracle migrations must be written step by step and idempotently (23ai's IF [NOT] EXISTS makes that easier but does not make DDL transactional). Postgres has a few exceptions too — CREATE INDEX CONCURRENTLY, VACUUM and CREATE DATABASE cannot run inside a transaction block.

BEGIN;
  INSERT INTO orders(id, status) VALUES (1, 'NEW');
  SAVEPOINT before_risky;
  INSERT INTO order_items(order_id, sku) VALUES (1, 'NOPE');  -- FK violation
  ROLLBACK TO SAVEPOINT before_risky;    -- without this the whole tx is dead
  INSERT INTO order_items(order_id, sku) VALUES (1, 'SKU-1');
COMMIT;
The difference that wrecks migrations: error behaviour

Oracle places an implicit savepoint before every statement, so an error rolls back only that statement and the transaction stays alive. Postgres does not: any error puts the transaction into 25P02 in_failed_sql_transaction and every subsequent command fails until you ROLLBACK. So Oracle code that "catches the error and carries on" breaks on Postgres unless you wrap each risky step in a SAVEPOINT — and be aware that more than 64 savepoints in one transaction overflows Postgres's subtransaction cache and slows the whole cluster (SubtransSLRU waits).


ACID, precisely and without hand-waving

Property What it actually guarantees Mechanism in PostgreSQL Mechanism in Oracle
Atomicity All statements commit or none do; a partial effect is never visible. WAL + MVCC versions; abort simply means "never become visible" — instant. Undo tablespace; ROLLBACK really rewrites the old values — time-consuming.
Consistency Moves the DB from one valid state to another with respect to declared constraints (PK/FK/CHECK/triggers). Checked at statement/commit time; DEFERRABLE INITIALLY DEFERRED. Same; DEFERRABLE and SET CONSTRAINTS ALL DEFERRED.
Isolation Concurrent transactions produce a result as if run serially (at the strongest level). MVCC snapshots + SSI (true serializability). SCN- and undo-based consistent read; Oracle's SERIALIZABLE is really snapshot isolation.
Durability Once COMMIT returns, the effect survives a crash. WAL flushed before ack; synchronous_commit, wal_sync_method. Redo flushed by LGWR; `COMMIT WRITE [BATCH

The C causes the most confusion: the database does not guarantee "your business logic is correct." It only preserves the constraints you declared. If your logic is wrong but breaks no declared constraint, the database will happily accept it.

-- Deferred constraint: not checked until COMMIT
ALTER TABLE order_items ADD CONSTRAINT fk_order
  FOREIGN KEY (order_id) REFERENCES orders(id) DEFERRABLE INITIALLY DEFERRED;
BEGIN;
  INSERT INTO order_items(order_id, sku) VALUES (999, 'SKU-1');  -- no error yet
  INSERT INTO orders(id, status) VALUES (999, 'NEW');
COMMIT;   -- the check happens here
The famous interview trap: ACID-C ≠ CAP-C

ACID-C is about integrity constraints inside one node (does the data break the declared rules?). CAP-C (linearizability) is about all replicas agreeing on the latest write (does every reader see the newest version?). Two completely separate concepts that merely share a first letter.

-- Trading durability for speed on non-critical writes
SET synchronous_commit = off;
INSERT INTO analytics_events(payload) VALUES ('{"e":"click"}');
COMMIT;
SET synchronous_commit = on;
A durability nuance few people know

Both of these fully preserve atomicity and isolation and trade only a small window of durability: a committed transaction can be lost on a crash, yet the DB is never left inconsistent or torn. Great for analytics logs, forbidden for money.


The concurrency anomalies: five pains you must recognize

Isolation levels are defined by which anomalies they forbid, so first you must know the anomalies. The working table for all examples:

CREATE TABLE accounts (
  id      integer PRIMARY KEY,
  owner   text          NOT NULL,
  balance numeric(18,2) NOT NULL CHECK (balance >= 0),
  version bigint        NOT NULL DEFAULT 0,
  updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO accounts(id, owner, balance) VALUES (1,'ali',100), (2,'sara',100);
Data-type differences you just saw

textVARCHAR2(n), numericNUMBER, timestamptzTIMESTAMP WITH TIME ZONE, now()SYSTIMESTAMP. Oracle has no multi-row INSERT ... VALUES (..),(..) — use INSERT ALL or separate statements. And the biggest trap: in Oracle the empty string '' is NULL, so CHECK (owner <> '') never works.

Dirty read

Reading an unpublished draft

A coworker is writing a report and hasn't saved it. You peek over their shoulder, read a figure, and report it to the boss. Then they delete it. You've reported something that was never finalized.

T2 reads a row that T1 wrote but has not committed. If T1 rolls back, T2 acted on data that never existed.

Good news: neither of these engines has dirty reads

Neither Postgres nor Oracle will ever show you another transaction's uncommitted data, at any level — both are multiversion and have no mechanism to expose an uncommitted version. Postgres accepts READ UNCOMMITTED but silently upgrades it to READ COMMITTED; Oracle refuses the level entirely. This anomaly is really a SQL Server (without RCSI) and MySQL concern.

Non-repeatable read

The price tag that changed

You look at an item: 100. You fetch a cart, come back, and it says 120. One shopping trip (transaction), two looks, two numbers.

T1 reads a row, T2 updates and commits it, T1 reads again and sees a different value.

-- Session A (READ COMMITTED = default)
BEGIN;
SELECT balance FROM accounts WHERE id = 1;   -- 100
-- Session B: UPDATE accounts SET balance=120 WHERE id=1; COMMIT;
SELECT balance FROM accounts WHERE id = 1;   -- 120  <-- non-repeatable
COMMIT;

Phantom read

A new guest on the list

You count the guests "over 30": 5. Mid-count someone adds a 35-year-old. You count again: 6. A new row appeared out of nowhere — like a phantom.

The subtle difference: non-repeatable read is about a changed existing row; phantom is about the set of rows matching a predicate changing.

Lost update

Two editors on one document

You both open the same version. You add a paragraph and save. Your colleague adds a different one on top of their old copy. Your change vanishes as if it never existed.

Both read balance = 100, both write 110; the correct answer was 120. This classic read-modify-write race is not in the SQL-92 anomaly table, but it is the most common real-world bug.

-- A:                                     B:
BEGIN;                                 -- BEGIN;
SELECT balance FROM accounts            -- SELECT balance FROM accounts
  WHERE id=1;             -- 100        --   WHERE id=1;             -- 100
UPDATE accounts SET balance=110         -- UPDATE accounts SET balance=110
  WHERE id=1;                           --   WHERE id=1;   (waits for A)
COMMIT;                                -- COMMIT;   -- result 110, not 120
Why both engines lose in exactly the same way — and where they differ

Under RC both park the second writer behind the row lock and then apply its blind update. The difference comes after the lock is released: Postgres uses EvalPlanQual to re-check the new row version against the WHERE clause and skip it if it no longer matches; Oracle restarts the whole statement from an implicit savepoint (this is called write consistency) — which means row triggers in Oracle can fire twice.

Write skew

Two on-call doctors

Hospital rule: at least one doctor must always remain on call. Alice and Bob are both on call and both want to leave at the same moment. Each checks "2 are on call, so I can go" and removes themselves. Now zero are on call. Neither did anything wrong alone — each changed a different row — yet together they broke the rule.

Because each writes a different row, there is no direct conflict on any single row — which is exactly why snapshot isolation does not stop it.

CREATE TABLE doctors (name text PRIMARY KEY, on_call boolean NOT NULL);
INSERT INTO doctors VALUES ('alice', true), ('bob', true);

-- A                                        B
BEGIN ISOLATION LEVEL REPEATABLE READ;   -- BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors             -- SELECT count(*) FROM doctors
  WHERE on_call;           -- 2          --   WHERE on_call;           -- 2
UPDATE doctors SET on_call=false         -- UPDATE doctors SET on_call=false
  WHERE name='alice';                    --   WHERE name='bob';
COMMIT;                                  -- COMMIT;   => zero doctors on call!
-- Under ISOLATION LEVEL SERIALIZABLE one of them gets 40001 and you are saved.
The crucial Serializable difference between the engines
  • Postgres since 9.1 implements SERIALIZABLE as SSI: it detects dangerous read-write dependency cycles at runtime and aborts one transaction with 40001. So write skew really is eliminated.
  • Oracle implements SERIALIZABLE as snapshot isolation: the transaction freezes at one SCN, and any attempt to modify a row committed after it began raises ORA-08177: can't serialize access for this transaction. There is no cycle detection, so write skew is possible even at Oracle's highest level.
  • Porting consequence: logic that was safe under Postgres SERIALIZABLE is not safe on Oracle; there you must enforce the invariant with an explicit lock.

The portable way, not relying on the isolation level at all:

BEGIN;
SELECT count(*) FROM doctors WHERE on_call FOR UPDATE;   -- rows are locked
UPDATE doctors SET on_call = false
 WHERE name = 'alice'
   AND (SELECT count(*) FROM doctors WHERE on_call) > 1;
COMMIT;
The key sentence of this section

Read isolation levels as "the more anomalies they forbid, the stronger": dirty < non-repeatable < phantom < write skew. And remember: the level names are the same in both engines, the meanings are not.


The four SQL isolation levels — and what each engine really implements

Standard level Dirty read Non-repeatable Phantom Lost update / write skew
Read Uncommitted possible possible possible possible
Read Committed no possible possible possible (lost update)
Repeatable Read no no possible (per standard) possible (write skew)
Serializable no no no no

But that is the standard. Here is the real table — the one senior interviews ask about:

What you write What PostgreSQL 16/17 actually gives What Oracle 19c/23ai actually gives
READ UNCOMMITTED Accepted but executed exactly as READ COMMITTED Not supported — you get an error
READ COMMITTED (default in both) Each statement takes a fresh snapshot; no dirty reads Each statement takes a fresh SCN; no dirty reads
REPEATABLE READ True snapshot isolation: one snapshot for the whole transaction, phantoms eliminated too, but write skew remains Not supported — the equivalents are Oracle's SERIALIZABLE or READ ONLY
SERIALIZABLE SSI: true serializability; write skew eliminated; may raise 40001 serialization_failure Snapshot isolation: one SCN for the whole transaction; ORA-08177; write skew still possible
READ ONLY Does not exist (equivalent: REPEATABLE READ READ ONLY) An Oracle-specific level: transaction-wide consistent read, no DML, no ORA-08177 risk
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
COMMIT;
BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE;   -- compact form
COMMIT;
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SHOW transaction_isolation;
What happens on Oracle if your code asks for `REPEATABLE READ`?

Oracle JDBC drivers accept only TRANSACTION_READ_COMMITTED and TRANSACTION_SERIALIZABLE. @Transactional(isolation = Isolation.REPEATABLE_READ) against Oracle throws an exception rather than silently upgrading. In multi-database code either use Isolation.DEFAULT or set the level per database profile.

Oracle's READ ONLY level

One aerial photo for the annual report

If you draw each chart from a different photo, the numbers won't agree. READ ONLY means "take one photo and draw all twenty charts from it."

BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
  SELECT sum(balance) FROM accounts;
  SELECT sum(amount)  FROM transactions;    -- both from one snapshot
COMMIT;
-- For a heavy report that must never get 40001:
-- BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
Why `READ ONLY` matters on Oracle

It is cheaper than SERIALIZABLE because it performs no DML and therefore can never raise ORA-08177. But while it is open the undo for that SCN must be retained: a three-hour report means three hours of undo pressure and rising ORA-01555 risk. The closest Postgres analogue is SERIALIZABLE READ ONLY DEFERRABLE, which waits for a "safe" snapshot and then guarantees it will never raise 40001.

The key Read Committed nuance (in both engines)

Under RC each statement gets a fresh photo — a snapshot in Postgres, an SCN in Oracle. So two back-to-back SELECTs can see different data. But under Postgres RR, or Oracle SERIALIZABLE/READ ONLY, the photo is taken once — at the first statement — and frozen for the whole transaction. This one difference is the heart of many bugs.


Under the hood (1): PostgreSQL MVCC

A library that never throws out old editions

When the author brings a new edition, the library keeps the old one in the archive and notes on each: "valid from date X" and "obsoleted at date Y." A reader who walked in mid-way gets exactly the edition valid when they entered — without waiting for the author.

Every row version (tuple) carries two hidden system columns — the "date notes" from the analogy:

  • xmin — the XID that created this version.
  • xmax — the XID that obsoleted it; 0/null while still live.
SELECT ctid, xmin, xmax, id, balance FROM accounts WHERE id = 1;
SELECT pg_current_xact_id();
`ORA_ROWSCN` is block-level by default, not row-level

Unless the table was created with ROWDEPENDENCIES, ORA_ROWSCN returns the last SCN of the whole block. A change to a neighbouring row advances it too, so optimistic checks built on it produce false positives — and the same block-level granularity causes seemingly inexplicable ORA-08177 errors in SERIALIZABLE transactions. Fix: CREATE TABLE ... ROWDEPENDENCIES (only at creation time) or an explicit version column.

The three operations map to these columns like this: INSERT writes a tuple with xmin = my_xid, xmax = 0; DELETE merely sets xmax = my_xid; UPDATE = delete + insert.

When a transaction (or, under RC, each statement) starts it captures a snapshot: a photo of "who has already finished and who is still working." A version is visible if its xmin committed before the snapshot and is not in the in-progress list, and its xmax is 0/aborted or belongs to a transaction not yet committed as of the snapshot. That single rule delivers every isolation level — and it is why readers and writers never block each other.

Why heavy updates bloat a table

Because UPDATE means "obsolete the old version and write a new one," every update leaves a dead version on disk. A heavily updated table fills with dead versions (bloat) until VACUUM cleans them. This problem is specific to Postgres; Oracle has no bloat of this kind.

How the commit check is made cheap — and Oracle's counterpart

Asking "has this XID committed?" every time is expensive, so Postgres reads pg_xact once and caches the answer in hint bits on the tuple header. Oracle has exactly the same idea under the name delayed block cleanout: a block a transaction modified may not be "cleaned" yet, and the next SELECT cleans it — which is why in Oracle a plain SELECT can generate redo, a fact that shocks newcomers.


Under the hood (2): Oracle's consistent read via undo and SCN

The "what it used to be" notebook

Oracle is like an editor who writes on the same copy but notes every correction in a separate notebook: "at 10:03 the word was X, I made it Y." If someone wants the text "as of 10:00," the editor takes the current copy, applies the notebook entries in reverse, and hands over a temporary clone. That notebook is undo and the clone is the CR (Consistent Read) block.

Step by step — اوراکل نسخه‌ی سازگارِ بلاک را از undo می‌سازد:

sequenceDiagram
  participant Q as Query started at SCN 500
  participant B as Data block current SCN 620
  participant U as Undo segment
  Q->>B: read block
  B-->>Q: block SCN 620 is too new
  Q->>U: fetch undo records after SCN 500
  U-->>Q: before-images
  Q->>Q: clone block in buffer cache and roll it back
  Q->>Q: read the CR copy as of SCN 500
  Note over Q,U: undo overwritten leads to ORA-01555 snapshot too old

If the required undo records are gone (later transactions overwrote them), Oracle cannot rebuild the block and raises the famous ORA-01555: snapshot too old.

Two engines, two different prices for one idea
  • Postgres: the new version is written beside the old one. Reading old data is free. The price is bloat and a permanent need for VACUUM.
  • Oracle: the new version is written over the old one and the previous image goes to undo. Tables never bloat. The price is CPU to build CR blocks at read time and the risk of ORA-01555.
  • Whenever someone says "Oracle has no vacuum, so it's better," the senior answer is: Oracle moved the cost from maintenance time to read time; it did not remove it.
Concept PostgreSQL Oracle
Global logical clock XID (32-bit, cyclic) SCN (much larger)
Where the old version lives In the heap, as a dead tuple Undo tablespace
Crash recovery WAL Redo log
Rollback "never become visible" — instant Reverse-apply undo — time-consuming
Maintenance cost VACUUM/autovacuum, bloat Undo sizing and retention
"Snapshot too old" error Removed in PG 17 (old_snapshot_threshold dropped) ORA-01555 — alive and common
User-visible old versions Not possible directly AS OF SCN/TIMESTAMP (Flashback)
-- PostgreSQL has no built-in way to read "the data as of ten minutes ago".
-- You must build a history table yourself and fill it with triggers:
SELECT * FROM accounts_history
WHERE  id = 1
  AND  now() - interval '10 minutes' BETWEEN valid_from AND valid_to;
Same undo, same limitation

Flashback Query is no magic: it uses the very same undo, so going beyond UNDO_RETENTION gives you ORA-01555. For long-term history you need FLASHBACK ARCHIVE. Postgres has no such feature at all — you build it with triggers or CDC.


The price each engine charges: VACUUM vs undo retention

A recycling bin vs a rented storage unit

Postgres is a house where every new thing is placed next to the old one; if the recycling worker (VACUUM) never comes, the house fills up. Oracle immediately ships the old item to a rented unit (undo); the house stays tidy, but if the unit fills up the oldest items are discarded — and if someone asks for them right then, they go home empty-handed (ORA-01555).

On the Postgres side: dead tuples accumulate as bloat. VACUUM computes the xmin horizon — the oldest XID any active snapshot could still need — and reclaims only versions older than that.

On the Oracle side: UNDO_RETENTION (default 900 seconds) says "try to keep committed undo at least this long." The operative word is try: under pressure Oracle overwrites unexpired undo anyway, unless RETENTION GUARANTEE is on — and then instead of ORA-01555 your writers start failing with ORA-30036: unable to extend segment ... in undo tablespace. The pain moves; it does not disappear.

Why one long transaction ruins both engines — in two different ways
  • Postgres: the xmin horizon equals the oldest live transaction, so one forgotten transaction holds the horizon down everywhere; vacuum can clean no table and the whole cluster bloats — not just the table that transaction touched.
  • Oracle: a long query needs the undo for its own SCN; if it is gone, the query itself dies with ORA-01555. A long writing transaction occupies undo until commit and can fill the undo tablespace, killing others with ORA-30036.
  • The sentence that scores points: "In Postgres a long transaction victimises everyone else; in Oracle it usually victimises itself — unless it is a writer."
-- Long transactions that block vacuum
SELECT pid, state, now() - xact_start AS xact_age, left(query, 60) AS query
FROM   pg_stat_activity
WHERE  xact_start IS NOT NULL
ORDER  BY xact_start;      -- the oldest xact_start pins the xmin horizon

-- Wraparound risk (an operational must, not a luxury)
SELECT datname, age(datfrozenxid) AS xid_age,
       round(100 * age(datfrozenxid) / 2000000000.0, 1) AS pct_to_shutdown
FROM   pg_database ORDER BY xid_age DESC;
-- Long transactions and their undo consumption
SELECT s.sid, s.username, t.start_time,
       t.used_ublk * 8192 / 1024 / 1024 AS undo_mb,
       SUBSTR(q.sql_text, 1, 60) AS sql_text
FROM   v$transaction t
JOIN   v$session s ON s.taddr = t.addr
LEFT   JOIN v$sql q ON q.sql_id = s.sql_id
ORDER  BY t.start_time;

-- Undo health over the last 24 hours
SELECT MAX(tuned_undoretention) AS tuned_retention_sec,
       MAX(maxquerylen)         AS longest_query_sec,
       SUM(ssolderrcnt)         AS ora_01555_count
FROM   v$undostat;
-- Maintenance settings and killing zombie transactions
ALTER SYSTEM SET autovacuum_vacuum_scale_factor = 0.05;
ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
ALTER SYSTEM SET transaction_timeout = '10min';   -- new in PostgreSQL 17
SELECT pg_reload_conf();
VACUUM (ANALYZE, VERBOSE) accounts;
ALTER SYSTEM SET undo_retention = 3600 SCOPE=BOTH;    -- seconds
ALTER TABLESPACE undotbs1 RETENTION GUARANTEE;        -- hard guarantee
ALTER PROFILE app_profile LIMIT IDLE_TIME 5;          -- minutes
-- Oracle has no VACUUM equivalent; refresh statistics instead:
EXEC DBMS_STATS.GATHER_TABLE_STATS(USER, 'ACCOUNTS');
Wraparound is a Postgres-only disease — and a lethal one

XIDs are 32-bit and cyclic; if the oldest un-frozen XID gets within roughly 2 billion of the current one, Postgres stops accepting writes. The classic disaster: a forgotten replication slot or prepared transaction pins the horizon → autovacuum cannot freeze → one Friday night the database goes read-only. Always monitor three things: pg_stat_activity, pg_replication_slots, pg_prepared_xacts. Oracle has no such crisis because SCNs are far larger. Note also that transaction_timeout only arrived in Postgres 17; before it, a transaction full of short statements escaped both older timeouts.


Lock-based concurrency (2PL) — the rival approach

Booking a meeting room

To work on any data you must take a room key: a "Shared" key for reading that several people can hold at once, or an "eXclusive" key for writing that nobody else can take until you release it. While someone holds the write key, everyone else waits at the door.

In 2PL you have a growing phase (you only acquire locks) and a shrinking phase (the moment you release any lock you may acquire no more). This guarantees serializability, but readers block writers and vice versa. Strict 2PL (hold everything until commit) is the norm because it prevents cascading aborts.

PostgreSQL (MVCC) Oracle (MVCC with undo) Classic 2PL (SQL Server without RCSI)
Readers vs writers never block each other never block each other block each other
Read locks none none shared locks
Lock escalation none never (lock lives in the block) yes (row → page → table)
Cost version storage, vacuum/bloat CR block construction, undo management lock manager, contention, deadlocks
Both engines lock for writes

UPDATE/DELETE/MERGE/SELECT FOR UPDATE take row locks in both. Postgres keeps the lock in xmax plus the lock manager; Oracle writes it into the ITL (Interested Transaction List) in the data block header. The practical Oracle consequence: it never runs out of locks and never escalates to a table lock — but if INITRANS is small and one block is hot you will see enq: TX - allocate ITL entry waits, cured by raising INITRANS/PCTFREE.

BEGIN;
LOCK TABLE accounts IN SHARE ROW EXCLUSIVE MODE;
COMMIT;
-- Inspect waits
SELECT pid, pg_blocking_pids(pid) AS blockers, wait_event, left(query,60)
FROM   pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0;

Row locking, SELECT ... FOR UPDATE and pessimistic control

The pessimistic way to prevent lost update is to lock the row at the moment you read it:

BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;   -- exclusive row lock
-- ... compute in the app ...
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;

Now "how long do I wait?" — where the engines differ completely:

SET LOCAL lock_timeout = '3s';
SELECT balance FROM accounts WHERE id=42 FOR UPDATE;             -- after 3s: 55P03
SELECT balance FROM accounts WHERE id=42 FOR UPDATE NOWAIT;      -- immediately: 55P03
SELECT balance FROM accounts WHERE id=42 FOR UPDATE SKIP LOCKED;
-- Lock strengths: FOR UPDATE > FOR NO KEY UPDATE > FOR SHARE > FOR KEY SHARE
Memorize the error codes; your retry loop needs them
Situation PostgreSQL Oracle
Deadlock 40P01 deadlock_detected ORA-00060
Serialization failure 40001 serialization_failure ORA-08177
Lock not available (NOWAIT) 55P03 lock_not_available ORA-00054
Lock wait expired lock_timeout55P03 WAIT nORA-30006
Unique violation 23505 ORA-00001
Snapshot too old — (removed in PG 17) ORA-01555
Undo exhausted ORA-30036

Work queues with SKIP LOCKED — and Oracle's big trap

-- The single-statement pattern, extremely common in Postgres
UPDATE jobs SET status = 'RUNNING'
WHERE  id = (SELECT id FROM jobs WHERE status = 'READY'
             ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1)
RETURNING id, payload;
`ORA-02014` — the trap every migrating team hits once

In Oracle you cannot combine FOR UPDATE with FETCH FIRST n ROWS ONLY (or DISTINCT/GROUP BY). The "clever" ROWNUM workaround is dangerous: ROWNUM is evaluated before SKIP LOCKED, so the query can return zero rows even when free work exists — your workers idle for no reason. The only reliable approach is the cursor above (or, from JDBC, setFetchSize(1) on the same query with no row limiter).

When one row is very hot

The only ticket window

If all the stock lives in one row, that row is the stadium's single ticket window: adding CPUs does not shorten the queue. You either build "more windows" (split the stock across rows) or "stop queueing — take a request slip and total them up at closing time," which is exactly the idea behind Oracle's lock-free reservations.

-- The best Postgres approach: a conditional atomic update, no prior read
UPDATE inventory SET qty = qty - :d
WHERE  sku = :sku AND qty >= :d
RETURNING qty;      -- zero rows means there wasn't enough stock
-- For a very hot row: split the stock into N buckets and pick one at random.
-- PostgreSQL has no equivalent of Oracle's RESERVABLE.
`RESERVABLE` restrictions, and 23ai's second tool

RESERVABLE works only on numeric columns, the table must have a primary key, at most ten reservable columns per table, the column cannot be a primary key, identity or virtual column and cannot be indexed, and it may only appear in CHECK constraints; the final value is applied at commit. Oracle 23ai also has something Postgres lacks: Priority Transactions — with ALTER SESSION SET TXN_PRIORITY = LOW plus a system-level txn_auto_rollback_medium_priority_wait_target threshold, a low-priority transaction blocking a more important one is rolled back automatically. The closest Postgres move is giving unimportant work a short lock_timeout.

Deadlocks

Two people in a narrow hallway

Two people enter from opposite ends. Each has taken half the way and waits for the other to back up. Neither backs up. That is a deadlock.

-- A                                       B
BEGIN;                                  -- BEGIN;
UPDATE accounts SET balance=balance-10  -- UPDATE accounts SET balance=balance-10
  WHERE id=1;                           --   WHERE id=2;
UPDATE accounts SET balance=balance+10  -- UPDATE accounts SET balance=balance+10
  WHERE id=2;   -- waits for B           --   WHERE id=1;   -- waits for A => cycle
-- After deadlock_timeout (default 1s) one transaction is aborted *entirely*:
--   ERROR: deadlock detected (SQLSTATE 40P01)
The most important practical deadlock difference

Postgres aborts the entire victim transaction; the only possible action is ROLLBACK and a full retry. Oracle rolls back only that statement: the transaction stays open and still holds all its earlier locks. If your app catches ORA-00060 and carries on, you are left with a half-finished transaction holding live locks that can freeze the whole system. Rule: always issue an explicit ROLLBACK in the ORA-00060 handler, then retry the whole transaction. Oracle also writes a trace file containing a "deadlock graph" for every deadlock.

Deadlock prevention, in one sentence

Always acquire locks in a consistent global order — e.g. always the lower id first. If every code path follows the same order, no cycle can form and most deadlocks disappear.

CREATE OR REPLACE FUNCTION transfer(p_from int, p_to int, p_amt numeric)
RETURNS void LANGUAGE plpgsql AS $$
DECLARE lo int := least(p_from, p_to); hi int := greatest(p_from, p_to);
BEGIN
  PERFORM 1 FROM accounts WHERE id = lo FOR UPDATE;
  PERFORM 1 FROM accounts WHERE id = hi FOR UPDATE;
  UPDATE accounts SET balance = balance - p_amt WHERE id = p_from;
  UPDATE accounts SET balance = balance + p_amt WHERE id = p_to;
END $$;

Advisory locks: when the thing you lock is not a row

An "under maintenance" sign

Sometimes what you protect is not a row: "only one instance of this job may run at a time." You hang the sign on an idea, not on an object.

BEGIN;
  SELECT pg_advisory_xact_lock(hashtext('monthly-report'));   -- released at COMMIT
  -- ... only one session runs this block at a time ...
COMMIT;
SELECT pg_try_advisory_xact_lock(hashtext('monthly-report')); -- non-blocking variant
Small differences that matter

In Postgres the key is a 64-bit integer, so you hash the name yourself and must think about hash collisions; in Oracle ALLOCATE_UNIQUE registers the name in DBMS_LOCK_ALLOCATED (using an autonomous commit behind the scenes), so there are no collisions. In both, the "until end of session" variant is a classic leak — always prefer the transaction-scoped form (pg_advisory_xact_lock, or release_on_commit => TRUE). And neither works across databases: for a distributed lock you need etcd/ZooKeeper, and even there you have no real guarantee without a fencing token.


Optimistic vs pessimistic

Reserving a restaurant vs walking in

Pessimistic means calling ahead and locking the table — you're sure it's there, but if the place is empty you denied it to others for nothing. Optimistic means walking in and hoping: fast and great if there's room, a wasted trip if there isn't. Which is better depends on how busy the restaurant is.

UPDATE accounts
SET    balance = :new_balance, version = version + 1, updated_at = now()
WHERE  id = 42 AND version = :read_version
RETURNING version;      -- zero rows returned means you lost: reload and retry

The subtle logic: AND version = :read_version says "only write if the version is still what I read." If someone wrote in the meantime, the predicate fails, zero rows are updated, and you learn you must re-read from scratch.

-- PostgreSQL has no ORA_ROWSCN. Do NOT substitute xmin —
-- it changes after VACUUM FREEZE. The right way: a version column via trigger.
CREATE OR REPLACE FUNCTION bump_version() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN NEW.version := OLD.version + 1; NEW.updated_at := now(); RETURN NEW; END $$;
CREATE TRIGGER accounts_bump BEFORE UPDATE ON accounts
FOR EACH ROW EXECUTE FUNCTION bump_version();

In JPA/Hibernate this is exactly @Version, and it is engine-independent:

@Entity
class Account {
    @Id Long id;
    BigDecimal balance;

    @Version            // Hibernate adds "AND version = ?" to UPDATEs
    long version;       // on mismatch -> OptimisticLockException
}

// Pessimistic; translated to SELECT ... FOR UPDATE on both engines:
//   PostgreSQL -> lock_timeout / FOR UPDATE NOWAIT
//   Oracle     -> FOR UPDATE WAIT 3 / NOWAIT
em.find(Account.class, 42L, LockModeType.PESSIMISTIC_WRITE,
        Map.of("jakarta.persistence.lock.timeout", 3000));
@Retryable(retryFor = { CannotSerializeTransactionException.class,  // 40001 and ORA-08177
                        DeadlockLoserDataAccessException.class,     // 40P01 and ORA-00060
                        OptimisticLockingFailureException.class },
           maxAttempts = 4, backoff = @Backoff(delay = 25, multiplier = 2, random = true))
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transfer(long from, long to, BigDecimal amt) {
    // PostgreSQL: SSI may raise 40001 (possibly only at COMMIT).
    // Oracle:     snapshot isolation raises ORA-08177 (right at the UPDATE).
}
Spring maps both engines onto one exception hierarchy

Spring's SQL error-code translator maps ORA-00060 and 40P01 both to DeadlockLoserDataAccessException, and ORA-08177 and 40001 both to CannotSerializeTransactionException. So one retry policy works on both engines — provided you branch on the exception class, not the numeric code.

Senior point: Serializable without retry is broken on both engines

In Postgres SSI can abort any transaction at commit with 40001; in Oracle any update to a row committed after your transaction began raises ORA-08177. This is not a bug, it is the level's design. One subtlety: on Oracle the error arrives at the UPDATE, on Postgres it may be deferred to COMMIT — so code that only guards the UPDATE will miss it on Postgres.


Autonomous transactions: an Oracle-only capability

A separate attendance book

The contract may ultimately be cancelled, but you want the fact that "the meeting happened today" to survive no matter what. An autonomous transaction is a completely separate book whose fate is not tied to the contract.

-- PostgreSQL has no autonomous transactions. The usual substitute: dblink
CREATE EXTENSION IF NOT EXISTS dblink;
CREATE OR REPLACE FUNCTION log_error(p_msg text)
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
  PERFORM dblink_exec('dbname=' || current_database(),
    format('INSERT INTO error_log(msg, at) VALUES (%L, now())', p_msg));
END $$;
-- Second option: a PROCEDURE with an internal COMMIT (PG 11+) — which is not
-- autonomous; it commits the outer transaction. Best option: log outside the DB.
Autonomous transaction traps

Self-deadlock: an autonomous transaction inherits none of the parent's locks, so if it tries to modify a row the parent locked it waits on itself until ORA-00060. Broken atomicity: the whole idea removes part of the work from the all-or-nothing rule. Working around mutating-table triggers with it is common but a design smell. And it is not portable: every PRAGMA AUTONOMOUS_TRANSACTION must be rewritten when moving to Postgres — so in multi-database code, move logging out of the database.


Distributed transactions: 2PC vs Saga

So far everything was on one database. The moment a business operation spans multiple databases or services, single-node ACID no longer covers you.

A wedding ceremony

An officiant (coordinator) asks each party separately "do you take...?". Whoever says "I do" is committed and locked. Once both said yes, the officiant declares it final. But if the officiant faints between the "I do"s and the declaration, both parties are stuck locked and in-doubt.

The 2PC flow and its fatal window — جریان ۲PC و نقطه‌ی مرگش:

sequenceDiagram
  participant C as Coordinator
  participant A as DB A
  participant B as DB B
  C->>A: PREPARE
  C->>B: PREPARE
  A-->>C: vote YES, durable, locks held
  B-->>C: vote YES, durable, locks held
  Note over C: coordinator crashes here
  Note over A,B: in-doubt, locks held until manual resolution
  C->>A: COMMIT
  C->>B: COMMIT
-- PostgreSQL: explicit 2PC protocol — and it is *off* by default
-- ALTER SYSTEM SET max_prepared_transactions = 50;   -- requires restart
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'txn-42';     -- locks are held, the transaction is pending
COMMIT PREPARED 'txn-42';         -- or ROLLBACK PREPARED 'txn-42';

-- Forgotten pending transactions (dangerous: they paralyse vacuum)
SELECT gid, prepared, owner, now() - prepared AS age FROM pg_prepared_xacts;
A forgotten prepared transaction is a time bomb in both engines

In Postgres, an uncommitted row in pg_prepared_xacts holds the xmin horizon down forever; vacuum is paralysed and you eventually hit the wraparound crisis — which is exactly why max_prepared_transactions defaults to zero. In Oracle, a row in DBA_2PC_PENDING means locks and undo are held indefinitely and other sessions get ORA-01591: lock held by in-doubt distributed transaction; the RECO process tries to resolve it automatically and a DBA must step in otherwise. In both cases, alerting on "prepared transaction older than N minutes" is mandatory from day one.

In practice 2PC is avoided: it blocks when the coordinator dies and is not partition-tolerant, it holds locks across network round-trips (killing throughput), and many stores have poor XA support.

Booking a trip with cancellation

You book a flight, a hotel and a car separately; each is a complete local transaction. If the hotel fails you don't roll everything back at once — for each successful booking you run an undo (compensation). There is no global lock; just a chain of steps and their undos.

Model the operation as a sequence of local transactions, each with a compensating transaction. No global locks; instead eventual consistency.

flowchart LR
  S1[Create order PENDING] --> S2[Charge payment]
  S2 --> S3[Reserve stock]
  S3 --> S4[Confirm order]
  S3 -. failure .-> C2[Refund payment]
  C2 --> C1[Cancel order]
  S2 -. failure .-> C1

Two coordination styles: orchestration (a central saga orchestrator, like a conductor — easier to reason about) and choreography (services react to each other's events, like dancers — looser coupling but harder to trace).

The backbone of every saga is the outbox pattern: write the event in the same local transaction as the state change.

BEGIN;
  UPDATE orders SET status = 'CONFIRMED' WHERE id = 42;
  INSERT INTO outbox(aggregate_id, event_type, payload)
  VALUES ('42', 'OrderConfirmed', jsonb_build_object('orderId', 42));
COMMIT;

-- The publisher scales out with SKIP LOCKED
UPDATE outbox SET published_at = now()
WHERE  id IN (SELECT id FROM outbox WHERE published_at IS NULL
              ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 100)
RETURNING id, event_type, payload;
The price saga extracts

Sagas give availability and scale but only eventual consistency and no isolation: intermediate states are visible (money charged but the order not yet confirmed). So you need semantic locks / status flags and idempotent, retryable, compensatable steps — plus the outbox to stay safe from dual writes.


Porting checklist between the two engines

Topic PostgreSQL Oracle What you must do
Starting a transaction explicit BEGIN; autocommit outside first DML starts it always write explicit COMMIT/ROLLBACK
DDL transactional implicit commit make Oracle migrations idempotent
Statement error whole transaction aborts only that statement add SAVEPOINTs on Postgres
REPEATABLE READ exists (snapshot isolation) does not exist map to SERIALIZABLE/READ ONLY
SERIALIZABLE SSI; no write skew snapshot isolation; write skew possible add explicit locks on Oracle
Deadlock whole transaction aborts only the statement explicit ROLLBACK in the Oracle handler
FOR UPDATE + row limit LIMIT allowed FETCH FIRST forbidden (ORA-02014) use a cursor on Oracle
Lock wait lock_timeout FOR UPDATE WAIT n push it into the data-access layer
Autonomous tx none PRAGMA AUTONOMOUS_TRANSACTION move logging out of the DB, or dblink
Advisory lock pg_advisory_xact_lock DBMS_LOCK.REQUEST build an abstraction layer
Time travel none AS OF SCN/TIMESTAMP build a history table or CDC
Maintenance VACUUM, bloat, wraparound UNDO_RETENTION, ORA-01555 build a separate dashboard for each

Pitfalls & gotchas (read these from the blood of experience)

  • Read Committed re-reads move. A SELECT-then-UPDATE loop under RC acts on stale data — either lock with FOR UPDATE or use an atomic UPDATE ... WHERE. In both engines.
  • UPDATE ... SET x = x + 1 is atomic and lost-update-safe even at RC, because the write itself re-reads the latest row version under a row lock. The danger is only read-then-write across two statements.
  • Long transactions are poison — with two different symptoms. Postgres: xmin horizon and bloat. Oracle: undo consumption and ORA-01555. Never hold a transaction open across user think-time or an external HTTP call.
  • In Oracle, forgetting COMMIT fails silently. With no autocommit, a session that finished its work but never committed holds locks for hours without any error. That is the first thing you check in a "the system is frozen" incident.
  • Repeatable Read ≠ Serializable (Postgres), and Oracle Serializable ≠ Postgres Serializable. If correctness depends on an invariant across rows you read but don't write, Postgres SERIALIZABLE suffices; on Oracle you must take explicit locks.
  • Deadlocks are normal under contention. Design for retry, enforce a global lock ordering, and on Oracle roll back yourself.
  • The empty string is NULL in Oracle. WHERE note = '' never matches and CHECK (note <> '') is a no-op; validation ported from Postgres silently stops working.
  • A SELECT can generate redo in Oracle (delayed block cleanout), so "read-only queries cost nothing to write" is false there.
  • Spring @Transactional self-invocation bypasses the proxy, so no transaction is created — a classic silent bug, engine-independent.
  • Constraint violations can surface at commit for deferred constraints — so handle 23505 / ORA-00001 even "far from the INSERT."
  • Many savepoints are expensive in Postgres (more than 64 per transaction overflows the subtransaction cache); they are cheaper in Oracle, which already has an implicit savepoint per statement.

Best practices

  1. Keep transactions short and do external I/O outside them — the single rule that saves both engines at once.
  2. Default to Read Committed; escalate only where an invariant demands it, remembering that "escalating" means SSI on Postgres and snapshot isolation + explicit locks on Oracle.
  3. For read-modify-write prefer an atomic UPDATE ... WHERE or optimistic @Version; use FOR UPDATE only when you truly must compute in the app.
  4. Enforce a consistent lock ordering, and roll back explicitly in the Oracle ORA-00060 handler.
  5. Build the right dashboard per engine: Postgres → bloat, autovacuum lag, age(datfrozenxid), pg_prepared_xacts; Oracle → v$undostat, maxquerylen, ssolderrcnt, dba_2pc_pending, enq: TX waits.
  6. Map native error codes to semantic exceptions in one shared layer so retry logic stays engine-agnostic.
  7. In distributed systems prefer saga + outbox over 2PC; make every step idempotent and compensatable.
  8. Make writes idempotent (idempotency keys) so retries are safe.

Interview Questions

1. What does ACID's "C" actually guarantee, and how does it differ from CAP's "C"?

ACID consistency means the transaction preserves declared integrity constraints (PK/FK/CHECK/triggers) — mostly the application's responsibility. CAP consistency (linearizability) means every read sees the latest committed write across replicas. Entirely different concepts; the shared letter is a coincidence.

2. What is the default isolation level in each engine, and what does Repeatable Read mean there? (tricky)

Both default to Read Committed. Postgres has REPEATABLE READ and it is really snapshot isolation: one transaction-wide snapshot that also prevents phantom reads (stronger than the standard), though not write skew. Oracle has no REPEATABLE READ at all; the equivalents are SET TRANSACTION ISOLATION LEVEL SERIALIZABLE or SET TRANSACTION READ ONLY.

3. Explain precisely how `SERIALIZABLE` differs between Postgres and Oracle. (very important)

Postgres since 9.1 implements it as SSI: it builds the read-write dependency graph at runtime and aborts a transaction with 40001 when it sees a dangerous cycle → true serializability, write skew eliminated. Oracle implements it as plain snapshot isolation: the transaction freezes at one SCN and any modification of a row committed after it began raises ORA-08177. There is no cycle detection, so write skew is possible on Oracle even at SERIALIZABLE, and you must prevent it with FOR UPDATE or LOCK TABLE.

4. Explain write skew and how you prevent it on each engine.

Two transactions read an overlapping set, verify an invariant that currently holds, and each writes a different row, jointly breaking the invariant (both on-call doctors leave). On Postgres, SERIALIZABLE genuinely prevents it. On Oracle, no isolation level does; you must lock the rows you read with FOR UPDATE, or introduce a "sentinel row" everyone locks before deciding, or declare the invariant with a REFRESH ON COMMIT materialized view plus a CHECK.

5. How does Oracle provide consistent read without keeping multiple versions in the table? (senior)

With undo + SCN. Every block records its last change SCN in the header; if a query (started at SCN S) meets a newer block, it clones the block in the buffer cache and reverse-applies the undo records written after S to rebuild that moment's image. That clone is the "CR block." If the required undo was overwritten you get ORA-01555: snapshot too old.

6. `ORA-01555` and Postgres bloat are two sides of one coin. Explain. (hard)

Both are the price of being multiversion, in opposite directions. Postgres keeps the old version in the table itself: reading the past is free, but the table bloats and needs VACUUM, and one long transaction pins the xmin horizon and bloats the entire cluster. Oracle ships the old image to undo: tables stay compact, but reading the past costs CPU and a long query dies with ORA-01555 if the undo was overwritten. "Postgres sacrifices space; Oracle sacrifices time and undo."

7. Why does neither engine ever show dirty reads, and what does `READ UNCOMMITTED` do in each?

Because both are multiversion and simply have no mechanism to expose an uncommitted version: Postgres only makes versions visible whose xmin has committed, and Oracle always reconstructs the block as of the query's SCN. Postgres accepts READ UNCOMMITTED but silently behaves as READ COMMITTED (it really has 3 behaviours, not 4). Oracle rejects the level outright.

8. Find the bug
-- READ COMMITTED
SELECT balance FROM accounts WHERE id = 1;   -- app reads 100
UPDATE accounts SET balance = 70 WHERE id = 1;   -- app computed 100-30

Lost update — identical on both engines. Between the read and the write another transaction could set balance to 50; this blindly overwrites it to 70, losing their change. Fix: UPDATE accounts SET balance = balance - 30 WHERE id = 1 (atomic), or SELECT ... FOR UPDATE at read time, or an optimistic AND version = :v.

9. What does this return? (tricky)
-- A                                    -- B
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT sum(x) FROM t;   -- 10
                                        -- INSERT INTO t(x) VALUES (5); COMMIT;
SELECT sum(x) FROM t;   -- ?

10 again, in both. Postgres froze the snapshot at the first statement; Oracle froze the transaction at one SCN. B's committed insert is invisible for the rest of A. Under Read Committed both would return 15. The bonus point: on Oracle you must write SERIALIZABLE or READ ONLY because REPEATABLE READ does not exist.

10. An Oracle transaction hits `ORA-00060`. What exactly happened and what must the app do? (gotcha)

Oracle detected the cycle and rolled back only that statement. The transaction is still open and still holds all its earlier locks; if the app swallows the error and continues, you are left with a half-finished transaction holding live locks that can freeze everyone. Correct handling: an explicit ROLLBACK, then retry the whole transaction. This is the opposite of Postgres, where 40P01 aborts the entire transaction.

11. What happens if you run `CREATE INDEX` in the middle of an Oracle transaction? (gotcha)

An implicit commit: everything done so far becomes permanent, then the DDL runs, then it commits again; a later ROLLBACK undoes nothing. Postgres is the opposite — DDL is transactional and a whole migration can be rolled back (except for a few statements such as CREATE INDEX CONCURRENTLY and VACUUM, which cannot run inside a transaction block).

12. Why can a single long-running transaction bloat an entire Postgres database? (hard)

Vacuum can only remove dead tuples older than the xmin horizon. One old transaction lowers that horizon everywhere, so dead tuples across all tables become unremovable — cluster-wide bloat plus blocked anti-wraparound freezing. Three hidden sources of the same problem: idle-in-transaction sessions, abandoned replication slots, and forgotten pg_prepared_xacts entries.

13. What is wraparound and why doesn't Oracle have it?

Postgres XIDs are 32-bit and cyclic; if the oldest un-frozen XID gets within about 2 billion of the current one, Postgres stops accepting writes to avoid corruption. That is why vacuum also has the vital job of freezing, and why age(datfrozenxid) must be monitored. Oracle uses SCNs instead, which are far larger and whose growth rate is capped, so no such crisis exists.

14. Write a `SKIP LOCKED` work queue on both engines. Where is the dangerous difference?

On Postgres it is trivial: ... WHERE status='READY' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1. On Oracle you cannot combine FETCH FIRST with FOR UPDATE (ORA-02014), and ROWNUM is dangerous because it is evaluated before SKIP LOCKED and can return zero rows while free work exists — leaving workers idle for no reason. The correct Oracle approach is a cursor with FOR UPDATE SKIP LOCKED and a single FETCH (or setFetchSize(1) from JDBC).

15. What is an autonomous transaction, when is it useful, and what is the risk? (Oracle)

PRAGMA AUTONOMOUS_TRANSACTION creates a fully independent transaction inside a PL/SQL routine whose locks and fate are separate from the parent, and it must COMMIT or ROLLBACK before returning. Main use: logging or auditing that survives a rollback of the main transaction. Risks: self-deadlock if it touches a row the parent locked, broken atomicity, and no Postgres equivalent (you must emulate it with dblink or log outside the database).

16. You occasionally get `40001` or `ORA-08177` under Serializable. Bug or expected? (gotcha)

Expected, and by design: Postgres SSI aborts transactions that would violate serializability; Oracle rejects any modification of a row committed after the transaction began. The correct handling in both is a retry loop around the whole transaction with backoff. The bonus detail: Oracle raises it at the UPDATE, Postgres may defer it to COMMIT — so Oracle code that only wraps the UPDATE misses the error on Postgres.

17. Why is 2PC avoided, and how is it implemented in each engine?

It blocks: if the coordinator dies after prepare, participants hold in-doubt locks indefinitely; and holding locks across network round-trips destroys throughput. On Postgres it is PREPARE TRANSACTION / COMMIT PREPARED, with max_prepared_transactions defaulting to zero (deliberately off, because a forgotten prepared transaction paralyses vacuum). On Oracle it activates automatically when you write across a database link; stuck transactions sit in DBA_2PC_PENDING, other sessions get ORA-01591, the RECO process tries to resolve them, and a DBA finishes with COMMIT FORCE/ROLLBACK FORCE.

18. Why does UPDATE bloat in Postgres but not in Oracle, and what fixes each? (senior)

In Postgres UPDATE is delete + insert at tuple level: the old version stays until vacuum reclaims it and every index gets a new entry. Mitigations: HOT updates (don't change indexed columns so the new tuple stays on the same page), a proper fillfactor, more aggressive autovacuum, and VACUUM FULL/pg_repack. In Oracle the row is updated in place and the previous image goes to undo, so there is no bloat; instead, if the row grows and no longer fits the block you get row migration (the row moves and leaves a pointer), which makes index access more expensive — cured with a suitable PCTFREE and, if needed, ALTER TABLE ... MOVE / SHRINK SPACE.


In a nutshell
  • A transaction has two independent axes: on failure (WAL in Postgres, redo + undo in Oracle) and under concurrency (isolation). Don't conflate them.
  • ACID: atomicity, consistency (only declared constraints — and ACID-C ≠ CAP-C), isolation, durability (synchronous_commit=off and COMMIT WRITE BATCH NOWAIT trade only durability).
  • Beginning and ending differ: Postgres has explicit BEGIN and transactional DDL; Oracle has no autocommit, an implicit commit around every DDL, and statement-only rollback on error.
  • Five anomalies: dirty read (impossible in both engines), non-repeatable, phantom, lost update, write skew.
  • Levels: both default to Read Committed. Postgres has four levels but really three behaviours; its RR is snapshot isolation and its Serializable is SSI with 40001. Oracle has only Read Committed and Serializable (plus READ ONLY), and its Serializable is snapshot isolation with ORA-08177 that does not stop write skew.
  • Internals: Postgres keeps versions in the heap (xmin/xmax) → bloat, VACUUM, and wraparound risk. Oracle ships the old image to undo and builds CR blocks → no bloat, but ORA-01555 — and in exchange you get Flashback Query.
  • Locking: neither takes read locks and neither escalates locks. FOR UPDATE/SKIP LOCKED/NOWAIT exist in both; but waiting is lock_timeout in Postgres and WAIT n in Oracle, and Oracle forbids FETCH FIRST with FOR UPDATE (ORA-02014).
  • Deadlocks: Postgres kills the whole transaction (40P01); Oracle kills only the statement (ORA-00060) and you must ROLLBACK yourself. Cure in both: consistent global lock ordering + retry.
  • Optimistic (@Version) vs pessimistic (FOR UPDATE); Oracle also offers ORA_ROWSCN (with ROWDEPENDENCIES) and, in 23ai, RESERVABLE columns and Priority Transactions for hot rows.
  • Distributed: 2PC is atomic but blocking (pg_prepared_xacts vs DBA_2PC_PENDING); choose saga + outbox + idempotency and accept "eventual consistency, no isolation."