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 را 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;-- Oracle: هیچ BEGIN صریحی نیست؛ اولین DML خودش تراکنش را باز میکند
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- یا ROLLBACK;
-- SET TRANSACTION فقط ویژگی تعیین میکند: SET TRANSACTION NAME 'transfer';در اوراکل با اولین 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; -- هر دو ناپدید میشوند؛ جدول اصلاً ساخته نشد-- Oracle: هر DDL یک COMMIT ضمنی *قبل* و *بعد* از خودش دارد
INSERT INTO audit_log(msg) VALUES ('migration v42'); -- تراکنش باز شد
CREATE TABLE tmp_x (id NUMBER PRIMARY KEY); -- <-- INSERT بالا COMMIT شد!
ROLLBACK; -- چیزی برای برگرداندن نیست
-- جبران باید دستی باشد: DROP TABLE tmp_x PURGE;در پستگرس 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;SAVEPOINT before_risky;
INSERT INTO order_items(order_id, sku) VALUES (1, 'NOPE'); -- ORA-02291
-- در Oracle فقط همان statement برگشت؛ تراکنش هنوز سالم است
ROLLBACK TO before_risky; -- کلمهی SAVEPOINT اختیاری است
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; -- بررسی اینجا انجام میشودALTER TABLE order_items ADD CONSTRAINT fk_order
FOREIGN KEY (order_id) REFERENCES orders(id) DEFERRABLE INITIALLY DEFERRED;
INSERT INTO order_items(order_id, sku) VALUES (999, 'SKU-1'); -- هنوز خطا نه
INSERT INTO orders(id, status) VALUES (999, 'NEW');
COMMIT; -- بررسی اینجا انجام میشود
-- موقتی برای یک تراکنش: SET CONSTRAINTS ALL DEFERRED;ACID-C دربارهی قیدهای یکپارچگی درونِ یک نود است (آیا داده قوانینِ اعلامشده را میشکند؟). CAP-C (همان linearizability) دربارهی توافقِ replicaها روی آخرین نوشتن است (آیا هر خواننده جدیدترین نسخه را میبیند؟). دو مفهومِ کاملاً جدا که فقط حرفِ اولشان یکی است.
-- معاملهی پایایی با سرعت، برای نوشتنهای غیرحیاتی
SET synchronous_commit = off;
INSERT INTO analytics_events(payload) VALUES ('{"e":"click"}');
COMMIT;
SET synchronous_commit = on;INSERT INTO analytics_events(payload) VALUES ('{"e":"click"}');
COMMIT WRITE BATCH NOWAIT; -- منتظرِ فلاشِ LGWR نمان
-- یا در سطح session: ALTER SESSION SET commit_wait = NOWAIT;هر دوی اینها اتمیکی و ایزوله را کاملاً حفظ میکنند و فقط پنجرهای کوچک از پایایی را معامله میکنند: یک تراکنشِ 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);CREATE TABLE accounts (
id NUMBER(10) PRIMARY KEY,
owner VARCHAR2(100) NOT NULL,
balance NUMBER(18,2) NOT NULL CHECK (balance >= 0),
version NUMBER(19) DEFAULT 0 NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);
INSERT INTO accounts(id, owner, balance) VALUES (1,'ali',100);
INSERT INTO accounts(id, owner, balance) VALUES (2,'sara',100);
COMMIT;text ≈ VARCHAR2(n)، numeric ≈ NUMBER، timestamptz ≈ TIMESTAMP WITH TIME ZONE، now() ≈ SYSTIMESTAMP. اوراکل INSERT ... VALUES (..),(..) چندسطری ندارد (یا INSERT ALL یا چند دستور). و مهمترین تله: در اوراکل رشتهی خالی '' همان NULL است، پس CHECK (owner <> '') هرگز کار نمیکند.
خواندن کثیف (dirty read)
همکارت گزارشی مینویسد و هنوز ذخیره نکرده. تو از روی شانهاش رقمی میخوانی و به رئیس گزارش میدهی. بعد او آن رقم را پاک میکند. چیزی را گزارش کردهای که هرگز نهایی نشد.
T2 سطری را میخواند که T1 نوشته اما commit نکرده. اگر T1 عقب بزند، T2 روی دادهای عمل کرده که هرگز وجود نداشته است.
نه پستگرس و نه اوراکل، در هیچ سطحی، دادهی 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;-- Session A (READ COMMITTED = پیشفرض در Oracle هم)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
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-- A: B:
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 میگیرد و نجات پیدا میکنی.CREATE TABLE doctors (name VARCHAR2(50) PRIMARY KEY,
on_call NUMBER(1) NOT NULL CHECK (on_call IN (0,1)));
INSERT INTO doctors VALUES ('alice', 1);
INSERT INTO doctors VALUES ('bob', 1);
COMMIT;
-- A B
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- همان در B
SELECT count(*) FROM doctors -- SELECT count(*) FROM doctors
WHERE on_call=1; -- 2 -- WHERE on_call=1; -- 2
UPDATE doctors SET on_call=0 -- UPDATE doctors SET on_call=0
WHERE name='alice'; -- WHERE name='bob';
COMMIT; -- COMMIT; => صفر پزشک آنکال!
-- Oracle SERIALIZABLE هم جلویش را *نمیگیرد*: سطرها متفاوتاند و ORA-08177 فعال نمیشود.- پستگرس از ۹.۱ سطح
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;SELECT count(*) FROM doctors WHERE on_call = 1 FOR UPDATE;
UPDATE doctors SET on_call = 0
WHERE name = 'alice'
AND (SELECT count(*) FROM doctors WHERE on_call = 1) > 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;-- باید اولین دستورِ تراکنش باشد
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
COMMIT;
-- گزینههای موجود فقط این سهاند:
-- SET TRANSACTION ISOLATION LEVEL READ COMMITTED; (پیشفرض)
-- SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- SET TRANSACTION READ ONLY;
ALTER SESSION SET ISOLATION_LEVEL = SERIALIZABLE; -- پیشفرضِ sessionدرایورهای 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;SET TRANSACTION READ ONLY;
SELECT SUM(balance) FROM accounts;
SELECT SUM(amount) FROM transactions; -- هر دو از یک SCN
COMMIT; -- بستنِ تراکنشِ read only هم COMMIT/ROLLBACK میخواهداز SERIALIZABLE ارزانتر است چون هیچ DMLی نمیکند و هرگز ORA-08177 نمیگیرد. اما تا وقتی باز است، undoی آن SCN باید بماند: گزارشِ سهساعته یعنی سه ساعت فشار روی undo و بالا رفتنِ ریسکِ ORA-01555. نزدیکترین معادلِ پستگرس، SERIALIZABLE READ ONLY DEFERRABLE است که صبر میکند تا یک snapshotِ «امن» پیدا کند و بعد تضمین میکند هرگز 40001 ندهد.
زیر 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();-- Oracle نسخهها را در جدول نگه نمیدارد، پس xmin/xmax ندارد.
-- نزدیکترین معادلها: ROWID (نشانی فیزیکی) و ORA_ROWSCN (آخرین SCNِ تغییر)
SELECT ROWID, ORA_ROWSCN, id, balance FROM accounts WHERE id = 1;
SELECT current_scn FROM v$database;اگر جدول را با 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 به این شکل ندارد.
پرسیدنِ «آیا این 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;-- Oracle: Flashback Query — همان undo، اینبار برای سفر در زمان
SELECT id, balance FROM accounts
AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '10' MINUTE) WHERE id = 1;
SELECT versions_startscn, versions_operation, balance
FROM accounts VERSIONS BETWEEN TIMESTAMP
SYSTIMESTAMP - INTERVAL '30' MINUTE AND SYSTIMESTAMP
WHERE id = 1;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');
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;LOCK TABLE accounts IN SHARE ROW EXCLUSIVE MODE; -- گزینه: ... WAIT 5 / NOWAIT
COMMIT;
-- دیدن انتظارها
SELECT s.sid, s.username, s.blocking_session, s.event, s.seconds_in_wait
FROM v$session s WHERE s.blocking_session IS NOT NULL;قفل سطری، 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;SELECT balance FROM accounts WHERE id = 42 FOR UPDATE; -- بدون BEGIN
-- ... محاسبه در اپ ...
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;
-- Oracle میتواند بگوید کدام ستونها را قصد داری عوض کنی:
-- SELECT balance FROM accounts WHERE id = 42 FOR UPDATE OF balance;حالا «چقدر منتظر بمانم؟» — که در دو موتور کاملاً متفاوت است:
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 SHARESELECT balance FROM accounts WHERE id=42 FOR UPDATE WAIT 3; -- بعد از 3s: ORA-30006
SELECT balance FROM accounts WHERE id=42 FOR UPDATE NOWAIT; -- فوری: ORA-00054
SELECT balance FROM accounts WHERE id=42 FOR UPDATE SKIP LOCKED;
-- Oracle درجهبندیِ FOR SHARE/FOR KEY SHARE ندارد؛ فقط FOR UPDATE [OF cols]| موقعیت | PostgreSQL | Oracle |
|---|---|---|
| بنبست | 40P01 deadlock_detected |
ORA-00060 |
| شکستِ سریالایز | 40001 serialization_failure |
ORA-08177 |
| قفل گرفته نشد (NOWAIT) | 55P03 lock_not_available |
ORA-00054 |
| سررسیدِ انتظارِ قفل | lock_timeout → 55P03 |
WAIT n → ORA-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;-- Oracle: ترکیبِ FETCH FIRST با FOR UPDATE ممنوع است (ORA-02014)!
DECLARE
CURSOR c_jobs IS SELECT id, payload FROM jobs
WHERE status = 'READY' ORDER BY created_at
FOR UPDATE SKIP LOCKED;
v_id jobs.id%TYPE; v_payload jobs.payload%TYPE;
BEGIN
OPEN c_jobs; FETCH c_jobs INTO v_id, v_payload;
IF c_jobs%FOUND THEN
UPDATE jobs SET status = 'RUNNING' WHERE CURRENT OF c_jobs;
END IF;
CLOSE c_jobs; COMMIT;
END;
/در اوراکل نمیتوانی 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 اوراکل را ندارد.-- Oracle 23ai: ستونِ RESERVABLE — قفل فقط در لحظهی COMMIT گرفته میشود
CREATE TABLE inventory (
sku VARCHAR2(50) PRIMARY KEY,
qty NUMBER RESERVABLE DEFAULT 0 NOT NULL,
CONSTRAINT qty_non_negative CHECK (qty >= 0)
);
UPDATE inventory SET qty = qty - 1 WHERE sku = 'HOT-ITEM';
COMMIT; -- اعتبارسنجیِ CHECK و اعمالِ نهایی اینجا انجام میشود
-- در 19c چنین چیزی نیست؛ همان الگوی اتمیکِ پستگرس را بنویس:
-- UPDATE inventory SET qty = qty - :d WHERE sku = :sku AND qty >= :d;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)-- A B
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 => چرخه
-- Oracle در حدود 3 ثانیه تشخیص میدهد و فقط *همان دستور* را برمیگرداند:
-- ORA-00060: deadlock detected while waiting for resource
-- تراکنش هنوز زنده است و قفلهایش را دارد! خودت باید ROLLBACK بزنی.پستگرس کلِ تراکنشِ قربانی را 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 $$;CREATE OR REPLACE PROCEDURE transfer(p_from NUMBER, p_to NUMBER, p_amt NUMBER) IS
v_lo NUMBER := LEAST(p_from, p_to);
v_hi NUMBER := GREATEST(p_from, p_to);
v_x NUMBER;
BEGIN
SELECT 1 INTO v_x FROM accounts WHERE id = v_lo FOR UPDATE;
SELECT 1 INTO v_x FROM accounts WHERE id = v_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')); -- نسخهی بدون انتظارDECLARE
v_handle VARCHAR2(128); v_result INTEGER;
BEGIN
DBMS_LOCK.ALLOCATE_UNIQUE('monthly-report', v_handle);
v_result := DBMS_LOCK.REQUEST(lockhandle => v_handle,
lockmode => DBMS_LOCK.X_MODE,
timeout => 10,
release_on_commit => TRUE);
-- 0=success 1=timeout 2=deadlock 3=param 4=already owned 5=illegal handle
IF v_result <> 0 THEN RAISE_APPLICATION_ERROR(-20001, 'lock failed'); END IF;
COMMIT; -- قفل اینجا آزاد میشود
END;
/در پستگرس کلید یک عددِ ۶۴بیتی است و خودت باید نام را 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; -- هیچ سطری برنگشت یعنی باختی: دوباره بخوان و تلاش کنUPDATE accounts
SET balance = :new_balance, version = version + 1, updated_at = SYSTIMESTAMP
WHERE id = 42 AND version = :read_version
RETURNING version INTO :out_version; -- RETURNING فقط با bind/PL-SQL
-- SQL%ROWCOUNT = 0 یعنی باختیمنطقِ ظریفش: 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();-- Oracle: ORA_ROWSCN بهعنوان «نسخه»ی ضمنی، فقط با ROWDEPENDENCIES معتبر است
CREATE TABLE accounts_rd (id NUMBER PRIMARY KEY,
balance NUMBER(18,2) NOT NULL) ROWDEPENDENCIES;
SELECT balance, ORA_ROWSCN AS read_scn FROM accounts_rd WHERE id = 42;
UPDATE accounts_rd SET balance = :new_balance
WHERE id = 42 AND ORA_ROWSCN = :read_scn; -- ROWCOUNT=0 یعنی سطر عوض شدهدر 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).
}
مترجمِ کدِ خطای اسپرینگ ORA-00060 و 40P01 را هر دو به DeadlockLoserDataAccessException و ORA-08177 و 40001 را هر دو به CannotSerializeTransactionException میبرد. یعنی یک کدِ 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 میکند. بهترین راه: لاگ را بیرونِ دیتابیس ببر.CREATE OR REPLACE PROCEDURE log_error(p_msg VARCHAR2) IS
PRAGMA AUTONOMOUS_TRANSACTION;
BEGIN
INSERT INTO error_log(msg, at) VALUES (p_msg, SYSTIMESTAMP);
COMMIT; -- اجباری: پیش از خروج باید COMMIT یا ROLLBACK بدهی
EXCEPTION
WHEN OTHERS THEN ROLLBACK; RAISE;
END;
/
-- تراکنشِ اصلی rollback میشود ولی لاگ میماند
BEGIN
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
log_error('insufficient funds for account 1');
ROLLBACK;
END;
/خودبنبستی: تراکنشِ 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;-- Oracle: با نوشتن روی database link، خودش 2PC را اجرا میکند
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts@remote_db SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- تراکنشهای in-doubt
SELECT local_tran_id, global_tran_id, state, host FROM dba_2pc_pending;
-- حلِ دستی (فقط با اطمینان از تصمیمِ نهایی):
-- COMMIT FORCE '1.23.45678'; / ROLLBACK FORCE '1.23.45678';در پستگرس یک ردیف در 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;UPDATE orders SET status = 'CONFIRMED' WHERE id = 42;
INSERT INTO outbox(aggregate_id, event_type, payload)
VALUES ('42', 'OrderConfirmed', JSON_OBJECT('orderId' VALUE 42));
COMMIT;
-- Oracle یک گزینهی بومیِ بهتر هم دارد: صفِ تراکنشی (TxEventQ / AQ)
-- DBMS_AQ.ENQUEUE در همان تراکنش، که مسئلهی dual-write را از پایه حذف میکند.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ها ایمن باشند.
پرسشهای مصاحبه
سازگاریِ ACID یعنی تراکنش قیدهای یکپارچگیِ اعلامشده (PK/FK/CHECK/trigger) را حفظ میکند — عمدتاً مسئولیتِ اپلیکیشن است. سازگاریِ CAP (همان linearizability) یعنی هر خواندن آخرین نوشتنِ commitشده را در همهی replicaها میبیند. دو مفهومِ کاملاً متفاوت؛ اشتراکِ حرفِ اول تصادفی است.
پیشفرضِ هر دو Read Committed است. در پستگرس REPEATABLE READ وجود دارد و در واقع snapshot isolation است: یک snapshot برای کلِ تراکنش که phantom را هم حذف میکند (قویتر از استاندارد)، ولی write skew را نه. در اوراکل REPEATABLE READ اصلاً وجود ندارد؛ معادلهایش SET TRANSACTION ISOLATION LEVEL SERIALIZABLE یا SET TRANSACTION READ ONLY هستند.
پستگرس از ۹.۱ آن را با SSI پیاده کرده: گرافِ وابستگیهای read-write را در زمان اجرا میسازد و چرخهی خطرناک را با 40001 میکشد → سریالایزپذیریِ واقعی و حذفِ write skew. اوراکل آن را صرفاً snapshot isolation پیاده کرده: تراکنش در یک SCN منجمد میشود و تغییرِ سطری که بعد از شروعِ او commit شده ORA-08177 میدهد. هیچ تشخیصِ چرخهای ندارد، پس write skew در اوراکل حتی در SERIALIZABLE هم ممکن است و باید با FOR UPDATE یا LOCK TABLE جلویش را بگیری.
دو تراکنش مجموعهای همپوشان میخوانند، 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 میگیری.
هر دو بهای multiversion بودناند، در دو جهت. پستگرس نسخهی قدیمی را در خودِ جدول نگه میدارد: خواندنِ گذشته رایگان است ولی جدول باد میکند و به VACUUM نیاز دارد، و یک تراکنشِ طولانی افقِ xmin را پایین نگه میدارد و کلِ کلاستر را متورم میکند. اوراکل نسخهی قدیمی را به undo میفرستد: جدول فشرده میماند ولی خواندنِ گذشته CPU میخواهد و اگر undo بازنویسی شود کوئریِ طولانی با ORA-01555 میمیرد. «پستگرس فضا را قربانی میکند، اوراکل زمان و undo را».
چون هر دو 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 حساب کرده-- 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; -- ?-- A -- B
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- Oracle 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 وجود ندارد.
اوراکل چرخه را دیده و فقط همان statement را برگردانده. تراکنش هنوز باز است و همهی قفلهای قبلیاش را نگه داشته؛ اگر اپ خطا را بگیرد و ادامه دهد، یک تراکنشِ نیمهکاره با قفلهای زنده میماند که میتواند بقیه را بخواباند. کارِ درست: ROLLBACK صریح و سپس retry کلِ تراکنش. این برعکسِ پستگرس است که با 40P01 کلِ تراکنش را abort میکند.
یک 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ِ فراموششده.
XIDهای پستگرس ۳۲ بیتی و چرخهایاند؛ اگر قدیمیترین XIDِ منجمدنشده به حدودِ ۲ میلیارد فاصله برسد، پستگرس برای جلوگیری از خرابیِ داده نوشتن را متوقف میکند. برای همین vacuum وظیفهی freeze را هم دارد و age(datfrozenxid) باید مانیتور شود. اوراکل بهجای XID از SCN استفاده میکند که بسیار بزرگتر است و نرخِ رشدش هم محدود میشود، پس چنین بحرانی ندارد.
در پستگرس ساده است: ... 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).
PRAGMA AUTONOMOUS_TRANSACTION یک تراکنشِ کاملاً مستقل درونِ یک روالِ PL/SQL میسازد که قفل و سرنوشتش از والد جدا است و باید پیش از خروج COMMIT/ROLLBACK بدهد. کاربردِ اصلی: لاگ یا audit که حتی با rollbackِ تراکنشِ اصلی بماند. خطرها: خودبنبستی اگر بخواهد سطری را عوض کند که والد قفل کرده، شکستنِ اتمیکی، و اینکه در پستگرس معادلی ندارد (باید با dblink یا لاگِ بیرونِ دیتابیس شبیهسازی شود).
موردانتظار و بخشی از طراحی: در پستگرس SSI تراکنشِ ناقضِ سریالایزپذیری را میکشد؛ در اوراکل هر تغییرِ سطری که پس از شروعِ تراکنش commit شده رد میشود. مدیریتِ درست در هر دو یک حلقهی retry دورِ کلِ تراکنش با backoff است. نکتهی امتیازآور: در اوراکل خطا در همان UPDATE میآید، در پستگرس ممکن است تا COMMIT عقب بیفتد — پس کدِ اوراکلی که فقط دورِ UPDATE را میگیرد در پستگرس خطا را از دست میدهد.
بلاککننده است: مرگِ 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 در سطحِ 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_PENDING)؛ saga + 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.
- 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 TRANSACTIONandDBA_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.
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 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.
- What happens on failure? — atomicity and durability: crash recovery, the log, rollback.
- 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
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
ROLLBACKjust 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.
ROLLBACKgenuinely rewrites the previous values back, which for a huge transaction can take minutes. - Practical consequence: rolling back a ten-million-row
DELETEis 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;-- Oracle: there is no explicit BEGIN; the first DML opens the transaction
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- or ROLLBACK;
-- SET TRANSACTION only sets properties: SET TRANSACTION NAME 'transfer';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-- Oracle: every DDL carries an implicit COMMIT *before* and *after* itself
INSERT INTO audit_log(msg) VALUES ('migration v42'); -- transaction opened
CREATE TABLE tmp_x (id NUMBER PRIMARY KEY); -- <-- the INSERT is COMMITTED here!
ROLLBACK; -- nothing left to undo
-- Compensation must be manual: DROP TABLE tmp_x PURGE;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;SAVEPOINT before_risky;
INSERT INTO order_items(order_id, sku) VALUES (1, 'NOPE'); -- ORA-02291
-- In Oracle only that statement was rolled back; the transaction is still healthy
ROLLBACK TO before_risky; -- the keyword SAVEPOINT is optional
INSERT INTO order_items(order_id, sku) VALUES (1, 'SKU-1');
COMMIT;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 hereALTER TABLE order_items ADD CONSTRAINT fk_order
FOREIGN KEY (order_id) REFERENCES orders(id) DEFERRABLE INITIALLY DEFERRED;
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
-- Per-transaction override: SET CONSTRAINTS ALL DEFERRED;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;INSERT INTO analytics_events(payload) VALUES ('{"e":"click"}');
COMMIT WRITE BATCH NOWAIT; -- do not wait for the LGWR flush
-- Or session-wide: ALTER SESSION SET commit_wait = NOWAIT;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);CREATE TABLE accounts (
id NUMBER(10) PRIMARY KEY,
owner VARCHAR2(100) NOT NULL,
balance NUMBER(18,2) NOT NULL CHECK (balance >= 0),
version NUMBER(19) DEFAULT 0 NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);
INSERT INTO accounts(id, owner, balance) VALUES (1,'ali',100);
INSERT INTO accounts(id, owner, balance) VALUES (2,'sara',100);
COMMIT;text ≈ VARCHAR2(n), numeric ≈ NUMBER, timestamptz ≈ TIMESTAMP 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
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.
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
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;-- Session A (READ COMMITTED is Oracle's default too)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
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
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
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-- A: B:
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 120Under 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
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.CREATE TABLE doctors (name VARCHAR2(50) PRIMARY KEY,
on_call NUMBER(1) NOT NULL CHECK (on_call IN (0,1)));
INSERT INTO doctors VALUES ('alice', 1);
INSERT INTO doctors VALUES ('bob', 1);
COMMIT;
-- A B
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- same in B
SELECT count(*) FROM doctors -- SELECT count(*) FROM doctors
WHERE on_call=1; -- 2 -- WHERE on_call=1; -- 2
UPDATE doctors SET on_call=0 -- UPDATE doctors SET on_call=0
WHERE name='alice'; -- WHERE name='bob';
COMMIT; -- COMMIT; => zero doctors on call!
-- Oracle SERIALIZABLE does *not* prevent it: different rows, so no ORA-08177.- Postgres since 9.1 implements
SERIALIZABLEas SSI: it detects dangerous read-write dependency cycles at runtime and aborts one transaction with40001. So write skew really is eliminated. - Oracle implements
SERIALIZABLEas snapshot isolation: the transaction freezes at one SCN, and any attempt to modify a row committed after it began raisesORA-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
SERIALIZABLEis 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;SELECT count(*) FROM doctors WHERE on_call = 1 FOR UPDATE;
UPDATE doctors SET on_call = 0
WHERE name = 'alice'
AND (SELECT count(*) FROM doctors WHERE on_call = 1) > 1;
COMMIT;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;-- Must be the first statement of the transaction
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
COMMIT;
-- These three are the only options that exist:
-- SET TRANSACTION ISOLATION LEVEL READ COMMITTED; (default)
-- SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- SET TRANSACTION READ ONLY;
ALTER SESSION SET ISOLATION_LEVEL = SERIALIZABLE; -- session defaultOracle 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
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;SET TRANSACTION READ ONLY;
SELECT SUM(balance) FROM accounts;
SELECT SUM(amount) FROM transactions; -- both from one SCN
COMMIT; -- a read-only transaction still needs COMMIT/ROLLBACK to endIt 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.
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
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();-- Oracle does not keep versions in the table, so there is no xmin/xmax.
-- The closest equivalents: ROWID (physical address) and ORA_ROWSCN (last change SCN)
SELECT ROWID, ORA_ROWSCN, id, balance FROM accounts WHERE id = 1;
SELECT current_scn FROM v$database;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.
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.
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
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.
- 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;-- Oracle: Flashback Query — the same undo, now used for time travel
SELECT id, balance FROM accounts
AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '10' MINUTE) WHERE id = 1;
SELECT versions_startscn, versions_operation, balance
FROM accounts VERSIONS BETWEEN TIMESTAMP
SYSTIMESTAMP - INTERVAL '30' MINUTE AND SYSTIMESTAMP
WHERE id = 1;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
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.
- 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 withORA-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');
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
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 |
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;LOCK TABLE accounts IN SHARE ROW EXCLUSIVE MODE; -- optional: ... WAIT 5 / NOWAIT
COMMIT;
-- Inspect waits
SELECT s.sid, s.username, s.blocking_session, s.event, s.seconds_in_wait
FROM v$session s WHERE s.blocking_session IS NOT NULL;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;SELECT balance FROM accounts WHERE id = 42 FOR UPDATE; -- no BEGIN needed
-- ... compute in the app ...
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;
-- Oracle can state which columns you intend to change:
-- SELECT balance FROM accounts WHERE id = 42 FOR UPDATE OF balance;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 SHARESELECT balance FROM accounts WHERE id=42 FOR UPDATE WAIT 3; -- after 3s: ORA-30006
SELECT balance FROM accounts WHERE id=42 FOR UPDATE NOWAIT; -- immediately: ORA-00054
SELECT balance FROM accounts WHERE id=42 FOR UPDATE SKIP LOCKED;
-- Oracle has no FOR SHARE / FOR KEY SHARE gradation; only FOR UPDATE [OF cols]| 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_timeout → 55P03 |
WAIT n → ORA-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;-- Oracle: combining FETCH FIRST with FOR UPDATE is illegal (ORA-02014)!
DECLARE
CURSOR c_jobs IS SELECT id, payload FROM jobs
WHERE status = 'READY' ORDER BY created_at
FOR UPDATE SKIP LOCKED;
v_id jobs.id%TYPE; v_payload jobs.payload%TYPE;
BEGIN
OPEN c_jobs; FETCH c_jobs INTO v_id, v_payload;
IF c_jobs%FOUND THEN
UPDATE jobs SET status = 'RUNNING' WHERE CURRENT OF c_jobs;
END IF;
CLOSE c_jobs; COMMIT;
END;
/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
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.-- Oracle 23ai: a RESERVABLE column — the lock is taken only at COMMIT
CREATE TABLE inventory (
sku VARCHAR2(50) PRIMARY KEY,
qty NUMBER RESERVABLE DEFAULT 0 NOT NULL,
CONSTRAINT qty_non_negative CHECK (qty >= 0)
);
UPDATE inventory SET qty = qty - 1 WHERE sku = 'HOT-ITEM';
COMMIT; -- the CHECK is validated and the delta applied here
-- 19c has nothing like this; write the same atomic pattern as Postgres:
-- UPDATE inventory SET qty = qty - :d WHERE sku = :sku AND qty >= :d;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 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)-- A B
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
-- Oracle detects it in about 3 seconds and rolls back *only that statement*:
-- ORA-00060: deadlock detected while waiting for resource
-- The transaction is still alive and still holds its locks! You must ROLLBACK.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.
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 $$;CREATE OR REPLACE PROCEDURE transfer(p_from NUMBER, p_to NUMBER, p_amt NUMBER) IS
v_lo NUMBER := LEAST(p_from, p_to);
v_hi NUMBER := GREATEST(p_from, p_to);
v_x NUMBER;
BEGIN
SELECT 1 INTO v_x FROM accounts WHERE id = v_lo FOR UPDATE;
SELECT 1 INTO v_x FROM accounts WHERE id = v_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
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 variantDECLARE
v_handle VARCHAR2(128); v_result INTEGER;
BEGIN
DBMS_LOCK.ALLOCATE_UNIQUE('monthly-report', v_handle);
v_result := DBMS_LOCK.REQUEST(lockhandle => v_handle,
lockmode => DBMS_LOCK.X_MODE,
timeout => 10,
release_on_commit => TRUE);
-- 0=success 1=timeout 2=deadlock 3=param 4=already owned 5=illegal handle
IF v_result <> 0 THEN RAISE_APPLICATION_ERROR(-20001, 'lock failed'); END IF;
COMMIT; -- the lock is released here
END;
/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
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 retryUPDATE accounts
SET balance = :new_balance, version = version + 1, updated_at = SYSTIMESTAMP
WHERE id = 42 AND version = :read_version
RETURNING version INTO :out_version; -- RETURNING needs a bind / PL-SQL
-- SQL%ROWCOUNT = 0 means you lostThe 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();-- Oracle: ORA_ROWSCN as an implicit "version", valid only with ROWDEPENDENCIES
CREATE TABLE accounts_rd (id NUMBER PRIMARY KEY,
balance NUMBER(18,2) NOT NULL) ROWDEPENDENCIES;
SELECT balance, ORA_ROWSCN AS read_scn FROM accounts_rd WHERE id = 42;
UPDATE accounts_rd SET balance = :new_balance
WHERE id = 42 AND ORA_ROWSCN = :read_scn; -- ROWCOUNT=0 means the row changedIn 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'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.
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
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.CREATE OR REPLACE PROCEDURE log_error(p_msg VARCHAR2) IS
PRAGMA AUTONOMOUS_TRANSACTION;
BEGIN
INSERT INTO error_log(msg, at) VALUES (p_msg, SYSTIMESTAMP);
COMMIT; -- mandatory: you must COMMIT or ROLLBACK before returning
EXCEPTION
WHEN OTHERS THEN ROLLBACK; RAISE;
END;
/
-- The main transaction rolls back but the log entry survives
BEGIN
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
log_error('insufficient funds for account 1');
ROLLBACK;
END;
/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.
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;-- Oracle: writing across a database link triggers 2PC automatically
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts@remote_db SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- In-doubt transactions
SELECT local_tran_id, global_tran_id, state, host FROM dba_2pc_pending;
-- Manual resolution (only when you are sure of the final decision):
-- COMMIT FORCE '1.23.45678'; / ROLLBACK FORCE '1.23.45678';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.
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;UPDATE orders SET status = 'CONFIRMED' WHERE id = 42;
INSERT INTO outbox(aggregate_id, event_type, payload)
VALUES ('42', 'OrderConfirmed', JSON_OBJECT('orderId' VALUE 42));
COMMIT;
-- Oracle has an even better native option: transactional queues (TxEventQ / AQ)
-- DBMS_AQ.ENQUEUE inside the same transaction removes the dual-write problem.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-UPDATEloop under RC acts on stale data — either lock withFOR UPDATEor use an atomicUPDATE ... WHERE. In both engines. UPDATE ... SET x = x + 1is 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
COMMITfails 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
SERIALIZABLEsuffices; 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
NULLin Oracle.WHERE note = ''never matches andCHECK (note <> '')is a no-op; validation ported from Postgres silently stops working. - A
SELECTcan generate redo in Oracle (delayed block cleanout), so "read-only queries cost nothing to write" is false there. - Spring
@Transactionalself-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-00001even "far from theINSERT." - 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
- Keep transactions short and do external I/O outside them — the single rule that saves both engines at once.
- 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.
- For read-modify-write prefer an atomic
UPDATE ... WHEREor optimistic@Version; useFOR UPDATEonly when you truly must compute in the app. - Enforce a consistent lock ordering, and roll back explicitly in the Oracle
ORA-00060handler. - Build the right dashboard per engine: Postgres → bloat, autovacuum lag,
age(datfrozenxid),pg_prepared_xacts; Oracle →v$undostat,maxquerylen,ssolderrcnt,dba_2pc_pending,enq: TXwaits. - Map native error codes to semantic exceptions in one shared layer so retry logic stays engine-agnostic.
- In distributed systems prefer saga + outbox over 2PC; make every step idempotent and compensatable.
- Make writes idempotent (idempotency keys) so retries are safe.
Interview Questions
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.
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.
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.
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.
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.
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."
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.
-- READ COMMITTED
SELECT balance FROM accounts WHERE id = 1; -- app reads 100
UPDATE accounts SET balance = 70 WHERE id = 1; -- app computed 100-30-- READ COMMITTED
SELECT balance FROM accounts WHERE id = 1; -- app reads 100
UPDATE accounts SET balance = 70 WHERE id = 1; -- app computed 100-30Lost 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.
-- 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; -- ?-- A -- B
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- Oracle has no 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.
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.
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).
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.
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.
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).
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).
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.
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.
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.
- 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=offandCOMMIT WRITE BATCH NOWAITtrade only durability). - Beginning and ending differ: Postgres has explicit
BEGINand 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 (plusREAD ONLY), and its Serializable is snapshot isolation withORA-08177that 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, butORA-01555— and in exchange you get Flashback Query. - Locking: neither takes read locks and neither escalates locks.
FOR UPDATE/SKIP LOCKED/NOWAITexist in both; but waiting islock_timeoutin Postgres andWAIT nin Oracle, and Oracle forbidsFETCH FIRSTwithFOR UPDATE(ORA-02014). - Deadlocks: Postgres kills the whole transaction (
40P01); Oracle kills only the statement (ORA-00060) and you mustROLLBACKyourself. Cure in both: consistent global lock ordering + retry. - Optimistic (
@Version) vs pessimistic (FOR UPDATE); Oracle also offersORA_ROWSCN(withROWDEPENDENCIES) and, in 23ai,RESERVABLEcolumns and Priority Transactions for hot rows. - Distributed: 2PC is atomic but blocking (
pg_prepared_xactsvsDBA_2PC_PENDING); choose saga + outbox + idempotency and accept "eventual consistency, no isolation."