Databases & SQL · پایگاهداده و SQL سنیورSenior ~91 دقیقه مطالعه~75 min read
Oracle در عمق: PL/SQL، بهینهسازی و معماریOracle In Depth: PL/SQL, Tuning & Architecture
از PL/SQL و PL/pgSQL تا بهینهساز هزینهمحور، ایندکس و پارتیشنبندی، undo/redo در برابر WAL و VACUUM، و معماری RAC/Data Guard/Exadata/PDB در برابر streaming replication و Patroni — یک سفر عمیق که هر تکه SQL را همزمان در PostgreSQL و Oracle نشان میدهد تا بدانی چهوقت کدام را انتخاب کنی و چطور در پروداکشن رامش کنی.From PL/SQL and PL/pgSQL to the cost-based optimizer, indexing and partitioning, undo/redo versus WAL and VACUUM, and RAC/Data Guard/Exadata/PDB versus streaming replication and Patroni — a deep tour that shows every SQL snippet in both PostgreSQL and Oracle so you know when to pick which and how to tame it in production.
پیشنیاز:Prerequisites: تسلط بر SQL: Join، Window، CTE و ایندکسSQL Mastery: Joins, Windows, CTEs & Indexes
خیلی از ما با Oracle مثل یک «جعبهٔ سیاه گرانقیمت» رفتار میکنیم: یک DataSource در Spring تنظیم میکنیم، چند تا @Repository مینویسیم و امیدواریم همهچیز کار کند. اما تفاوت یک مهندس چهار-پنجساله با یک سنیور دقیقاً همینجاست: سنیور میداند وقتی کوئریاش کند میشود، پشت آن ResultSet چه اتفاقی میافتد — پارس شدن، بهینهسازی، خواندن block ها از buffer cache، ساختن نسخهٔ سازگار از داده با کمک undo، و قفلهایی که بیسروصدا گرفته میشوند.
این فصل تو را از صفر تا جایی میبرد که بتوانی سر یک میز مصاحبهٔ سنیور بنشینی و دربارهٔ optimizer، bind variable ها، پارتیشنبندی و read consistency طوری حرف بزنی که انگار سالها با آنها زندگی کردهای. هر ایده با آنالوژی شروع میشود، بعد مفهوم ساخته میشود، بعد کد واقعی میبینیم و آخر میپرسیم: «این کجای پروداکشن میترکد؟»
و یک نخ دوم هم در کل فصل جریان دارد: تقریباً هیچکس تمام عمر حرفهایاش را روی یک موتور نمیماند. پس هر تکه SQL این فصل در هر دو گویش آمده است — اول PostgreSQL، بعد Oracle. یاد گرفتنِ جفتی دو برابر کار نیست؛ سریعترین راه است برای اینکه ببینی کدام بخشها تئوری واقعی دیتابیساند و کدامها فقط «املای» یک vendor خاص.
۱) PL/SQL از پایه: بلاکهای ناشناس، procedure/function/package، cursor، BULK COLLECT/FORALL، exception ها و trigger ها — هرکدام نگاشتهشده روی PL/pgSQL.
۲) سهگانهٔ گیجکننده: ROWNUM در برابر ROW_NUMBER() در برابر ROWID (و ctid در PostgreSQL).
۳) قلب کارایی: بهینهساز هزینهمحور (CBO)، آمار، EXPLAIN PLAN/AWR در برابر EXPLAIN (ANALYZE, BUFFERS)/pg_stat_statements، و ماجرای bind variable ها و shared pool.
۴) ایندکسها (B-tree، bitmap، function-based، composite، covering، partial) و پارتیشنبندی.
۵) MERGE در برابر ON CONFLICT، sequence و identity، و materialized view ها.
۶) موتور تراکنش: undo/redo/SCN و دوقلوهای PostgreSQL شان WAL/xmin-xmax/VACUUM.
۷) معماری کلان: RAC، Data Guard، Exadata، PDB/CDB در برابر streaming replication، Patroni، Citus.
۸) و در پایان تصمیم مهندسی: Oracle یا PostgreSQL؟ — بهعلاوهٔ تلههای مهاجرت که واقعاً پول آب میکنند.
نسخهٔ کاری اکثر شرکتها هنوز Oracle 19c است؛ این نسخهٔ long-term با Premier Support تا پایان ۲۰۲۹ و Extended Support تا پایان ۲۰۳۲ عملاً ستون فقرات پروداکشن است. نسخهٔ 23ai (که در ۲۰۲۳ با نام 23c آمد و در ۲۰۲۴ تغییر نام داد) اول روی OCI/Exadata عرضه شد و بعد Oracle همان خانواده را زیر نام Oracle AI Database 26ai جمع کرد — همان خط کد 23.x با نسخهگذاری 23.26.1.0.0، که در ۲۰۲۶ برای on-prem روی Linux x86-64 عمومی شد. در سمت مقابل با PostgreSQL 16 و 17 سروکار داری (۱۷ MERGE ... RETURNING و failover replication slot آورد؛ ۱۶ pg_stat_io و EXPLAIN (GENERIC_PLAN)). ویژگیهای 23ai/26ai را بشناس — BOOLEAN بومی در SQL، JSON Relational Duality، AI Vector Search، SELECT بدون FROM DUAL — اما هرگز فرض نکن روی سروری که جلوی توست موجودند.
۱) PL/SQL: زبانی که «کد را میبرد پیش داده»
تصور کن یک رستوران داری. اگر برای هر تکه سبزی یک پیک موتوری بفرستی، هزینهٔ رفتوبرگشت تو را میکشد؛ حتی اگر خود سبزی ارزان باشد. اپلیکیشن Java تو (که با JDBC هر بار یک query میفرستد) همان پیک است و دیتابیس همان انبار. PL/SQL یعنی یک آشپز را داخل خود انبار بگذاری: منطق را کنار داده اجرا کنی و فقط نتیجهٔ نهایی را برگردانی.
PL/SQL یک زبان رویهای است که مستقیماً داخل موتور Oracle اجرا میشود و SQL جزئی طبیعی از آن است. معادل آن در PostgreSQL PL/pgSQL است — عمداً با همان حالوهوای Ada — طوری که این دو مثل پسرعمو به نظر میرسند. کوچکترین واحدشان anonymous block است؛ PostgreSQL به آن DO block میگوید.
DO $$
DECLARE
v_total numeric := 0;
BEGIN
SELECT COALESCE(SUM(amount), 0) INTO v_total
FROM payments WHERE status = 'PAID';
RAISE NOTICE 'Total = %', v_total;
END $$;DECLARE
v_total NUMBER := 0;
BEGIN
SELECT NVL(SUM(amount), 0) INTO v_total
FROM payments WHERE status = 'PAID';
DBMS_OUTPUT.PUT_LINE('Total = ' || v_total);
EXCEPTION
WHEN NO_DATA_FOUND THEN
DBMS_OUTPUT.PUT_LINE('No rows');
END;
/هر دو سه بخش دارند: DECLARE (متغیرها)، BEGIN...END (منطق) و EXCEPTION (مدیریت خطا). Oracle بلاک را با یک / تنها میبندد؛ PostgreSQL بدنه را با dollar quoting ($$ ... $$) میپیچد تا پارسر با سمیکالنهای داخلی گیر نکند.
در Oracle، SELECT ... INTO اگر صفر ردیف برگرداند NO_DATA_FOUND پرتاب میکند و اگر بیش از یک ردیف بیاید TOO_MANY_ROWS. در PL/pgSQL، SELECT ... INTO ساده هیچکدام را نمیکند: صفر ردیف یعنی متغیر NULL میماند و اجرا ادامه پیدا میکند؛ ردیفهای اضافی هم بیصدا دور ریخته میشوند. رفتار Oracle را فقط با SELECT ... INTO STRICT میگیری. هزاران procedure منتقلشده، بیسروصدا «رکورد پیدا نشد» را به «NULL را مثل داده پردازش کن» تبدیل کردهاند.
Procedure، Function و مهمتر از همه: Package
- Procedure: یک کار انجام میدهد، مقدار برنمیگرداند (یا با
OUTبرمیگرداند). - Function: یک مقدار برمیگرداند و میتواند داخل
SELECTاستفاده شود. - Package: مجموعهای از procedure/function/type/constant را کنار هم میگذارد — دقیقاً مثل یک کلاس Java. دو بخش دارد: specification (رابط عمومی) و body (پیادهسازی).
-- PostgreSQL پکیج ندارد: نزدیکترین واحد گروهبندی، schema است
CREATE SCHEMA IF NOT EXISTS billing;
CREATE OR REPLACE FUNCTION billing.invoice_total(p_invoice_id bigint)
RETURNS numeric LANGUAGE plpgsql STABLE AS $$
DECLARE v_sum numeric;
BEGIN
SELECT COALESCE(SUM(line_amount), 0) INTO v_sum
FROM invoice_lines WHERE invoice_id = p_invoice_id;
RETURN v_sum;
END $$;
CREATE OR REPLACE PROCEDURE billing.mark_paid(p_invoice_id bigint)
LANGUAGE plpgsql AS $$
BEGIN
UPDATE invoices SET status = 'PAID', paid_at = now()
WHERE invoice_id = p_invoice_id;
END $$;
CALL billing.mark_paid(42);CREATE OR REPLACE PACKAGE billing_pkg AS
FUNCTION invoice_total(p_invoice_id NUMBER) RETURN NUMBER;
PROCEDURE mark_paid(p_invoice_id NUMBER);
END billing_pkg;
/
CREATE OR REPLACE PACKAGE BODY billing_pkg AS
FUNCTION invoice_total(p_invoice_id NUMBER) RETURN NUMBER IS
v_sum NUMBER;
BEGIN
SELECT NVL(SUM(line_amount), 0) INTO v_sum
FROM invoice_lines WHERE invoice_id = p_invoice_id;
RETURN v_sum;
END;
PROCEDURE mark_paid(p_invoice_id NUMBER) IS
BEGIN
UPDATE invoices SET status = 'PAID', paid_at = SYSTIMESTAMP
WHERE invoice_id = p_invoice_id;
END;
END billing_pkg;
/
BEGIN billing_pkg.mark_paid(42); END;
/package فقط «تمیزکاری» نیست. اولین فراخوانی در هر session کل آن را کامپایلشده در حافظهٔ session (UGA) لود میکند و فراخوانیهای بعدی سریعترند؛ میتواند state داشته باشد (متغیرهای سطح package که تا پایان session زندهاند)؛ و تغییر در body یک package، اشیای وابسته را invalid نمیکند (برخلاف تغییر در spec) — یعنی deployment بهمراتب کمدردسرتر.
package در Oracle چهار چیز را همزمان میدهد: namespacing، تفکیک public/private (هرچه در spec نباشد خصوصیِ body است)، state در سطح session، و overloading. PostgreSQL namespacing را با schema و overloading را با امضای تابع میدهد، اما تفکیک public/private ندارد و state در سطح package ندارد — خصوصیبودن را با GRANT/REVOKE و قرارداد نامگذاری شبیهسازی میکنی و state را با GUC های سفارشی (set_config('app.tenant_id', …) و current_setting(...)). در عوض PostgreSQL 11 به بعد همان تفکیک Oracle را بین FUNCTION (قابل استفاده در SQL، بدون کنترل تراکنش) و PROCEDURE (با CALL، مجاز به COMMIT) دارد.
یک متغیر package یا یک GUC سفارشی روی کانکشن فیزیکی زندگی میکند، نه روی request تو. با HikariCP، کانکشن شمارهٔ ۷ همان tenant id ای را تحویلت میدهد که request قبلی جا گذاشته. اگر از این الگو استفاده میکنی، حتماً در شروع هر تراکنش ریستش کن — در PostgreSQL set_config(..., true) با دامنهٔ تراکنش را ترجیح بده و در Oracle DBMS_SESSION.SET_CONTEXT با پاکسازی صریح. نشتی داده بین tenant ها در پروداکشن تقریباً همیشه از همینجا میآید.
از سمت Spring، کد ذخیرهشده را با SimpleJdbcCall صدا میزنی، نه رشتهٔ SQL دستی:
@Repository
public class BillingDao {
private final SimpleJdbcCall markPaidCall;
public BillingDao(JdbcTemplate jdbc) {
this.markPaidCall = new SimpleJdbcCall(jdbc)
.withCatalogName("BILLING_PKG") // Oracle: package = "catalog"
.withProcedureName("MARK_PAID");
// PostgreSQL: .withSchemaName("billing").withProcedureName("mark_paid")
}
public void markPaid(long invoiceId) {
markPaidCall.execute(Map.of("P_INVOICE_ID", invoiceId));
}
}
پاسخ: چون منطق را کنار داده اجرا میکند و round-trip شبکه را حذف میکند؛ برای کار دادهمحور سنگین (batch، ETL، aggregation روی میلیونها ردیف) تفاوت چشمگیر است. اما برای محاسبهٔ CPU-bound یا کدی که باید افقی مقیاس بخورد بد است: دیتابیس گرانترین لایه برای scale-out است. قانون سنیور: منطقی که تنگاتنگ با داده کار میکند برود دیتابیس؛ منطق کسبوکار عمومی بماند در سرویس Java. همین استدلال دربارهٔ PL/pgSQL هم صادق است، با یک پیچ اقتصادی: CPU در Oracle لایسنسدار است، پس یک حلقهٔ for آنجا واقعاً پول خرج میکند.
پاسخ: خصوصیبودن (تفکیک spec/body از بین میرود و با privilege های schema شبیهسازی میشود)، state سطح session (متغیر package معادل ندارد — سراغ GUC یا جدول موقت میروی و باید pooling را خودت مدیریت کنی)، دانهبندی وابستگیها (Oracle با تغییر body وابستهها را سالم نگه میدارد؛ PostgreSQL بهطور پیشفرض بدنهٔ تابع را حتی اعتبارسنجی هم نمیکند، پس شکستن از زمان deploy به زمان اجرا منتقل میشود)، و بلاک initialization یکبار در هر session. در عوض CREATE OR REPLACE اتمی میگیری بدون شیء INVALID برای نگهداری، و چند زبان در یک schema.
Cursor و cursor FOR loop
برای پیمایش ردیفبهردیف نتیجه به cursor نیاز داری. امنترین شکلش cursor FOR loop است که خودش open/fetch/close را مدیریت میکند — در هر دو موتور تقریباً یکسان:
DO $$
DECLARE rec RECORD;
BEGIN
FOR rec IN
SELECT invoice_id, customer_id FROM invoices WHERE status = 'DUE'
LOOP
UPDATE customers SET dunning_flag = true WHERE id = rec.customer_id;
END LOOP;
END $$;BEGIN
FOR rec IN (SELECT invoice_id, customer_id
FROM invoices WHERE status = 'DUE') LOOP
UPDATE customers SET dunning_flag = 'Y' WHERE id = rec.customer_id;
END LOOP;
END;
/این الگو زیبا به نظر میرسد و در هر دو موتور قاتل کارایی است: هر بار عبور از loop یک جابهجایی بین موتور رویهای و موتور SQL است. صد هزار ردیف یعنی صد هزار context switch — row-by-row is slow-by-slow. راهحل: یک UPDATE ... WHERE مجموعهای، یا BULK COLLECT/FORALL در Oracle و دستورهای آرایهمحور در PostgreSQL. به نشانهٔ گویشی بالا دقت کن: Oracle قبل از 23ai BOOLEAN در SQL نداشت و به همین دلیل schema های قدیمی flag را 'Y'/'N' نگه میدارند در حالی که PostgreSQL boolean واقعی دارد.
BULK COLLECT و FORALL: شکستن دیوار context switch
BULK COLLECT چندین ردیف را در یک رفتوبرگشت داخل یک collection میریزد و FORALL چندین DML را در یک رفتوبرگشت میفرستد. PostgreSQL FORALL ندارد — اصطلاح رایج آنجا این است که کلاً set-based بمانی، یا یک آرایه بفرستی و با unnest در یک دستور بازش کنی.
-- یک دستور مجموعهای از هر حلقهای بهتر است
UPDATE invoices SET status = 'DUNNED' WHERE status = 'DUE';
-- از Java، N ردیف در یک رفتوبرگشت با پارامتر آرایهای:
-- UPDATE invoices SET status='DUNNED' WHERE invoice_id = ANY($1::bigint[]);
-- نسخهٔ دستهای، امن برای worker های همزمان
WITH batch AS (
SELECT invoice_id FROM invoices WHERE status = 'DUE'
ORDER BY invoice_id LIMIT 1000 FOR UPDATE SKIP LOCKED
)
UPDATE invoices i SET status = 'DUNNED'
FROM batch b WHERE i.invoice_id = b.invoice_id
RETURNING i.invoice_id;DECLARE
TYPE t_ids IS TABLE OF invoices.invoice_id%TYPE;
v_ids t_ids;
CURSOR c IS SELECT invoice_id FROM invoices WHERE status = 'DUE';
BEGIN
OPEN c;
LOOP
FETCH c BULK COLLECT INTO v_ids LIMIT 1000; -- LIMIT حیاتی است
EXIT WHEN v_ids.COUNT = 0;
FORALL i IN 1 .. v_ids.COUNT
UPDATE invoices SET status = 'DUNNED' WHERE invoice_id = v_ids(i);
COMMIT; -- commit دستهای
EXIT WHEN v_ids.COUNT < 1000;
END LOOP;
CLOSE c;
END;
/BULK COLLECT بدون LIMIT کل نتیجه را یکجا در PGA میریزد؛ روی جدول دهمیلیونردیفی یعنی انفجار حافظه و ORA-04030. همیشه دستههای ۱۰۰ تا ۱۰۰۰ بخوان. PostgreSQL همین بیماری را با لباس دیگری دارد: آرایهٔ غولپیکر در PL/pgSQL یا FOR rec IN SELECT بیکران، حافظهٔ backend را میخورد تا OOM killer داستان را تمام کند. قانون در هر دو یکی است: اندازهٔ دسته را صریح محدود کن.
SAVE EXCEPTIONS در Oracle میگذارد یک DML انبوه از ردیفهای خطادار رد شود و آنها را در SQL%BULK_EXCEPTIONS جمع کند (که به شکل ORA-24381 بالا میآید) — در ETL بینظیر است. PostgreSQL چنین چیزی ندارد: یک دستور یا کامل موفق میشود یا کامل rollback. گزینههایت: فیلتر کردن ردیفهای بد از قبل، ON CONFLICT DO NOTHING، یا حلقهٔ ردیفی با زیربلاک BEGIN ... EXCEPTION — و حواست باشد در PostgreSQL هر بلاکی که EXCEPTION دارد یک subtransaction باز میکند که رایگان نیست: میلیونها تا از آن، transaction id میسوزاند و کارایی را میخورد.
برای پردازش دستهای با commit میانی، هر دو موتور میتوانند در کد ذخیرهشده این کار را بکنند — PostgreSQL فقط از نسخهٔ ۱۱ و با یک محدودیت تیز:
CREATE OR REPLACE PROCEDURE purge_old_events(p_batch int DEFAULT 5000)
LANGUAGE plpgsql AS $$
DECLARE v_deleted int;
BEGIN
LOOP
DELETE FROM events WHERE ctid IN (
SELECT ctid FROM events
WHERE created_at < now() - interval '90 days' LIMIT p_batch);
GET DIAGNOSTICS v_deleted = ROW_COUNT;
EXIT WHEN v_deleted = 0;
COMMIT; -- در PROCEDURE مجاز، در FUNCTION هرگز
END LOOP;
END $$;
CALL purge_old_events(5000);CREATE OR REPLACE PROCEDURE purge_old_events(p_batch NUMBER DEFAULT 5000) IS
BEGIN
LOOP
DELETE FROM events
WHERE created_at < SYSDATE - 90 AND ROWNUM <= p_batch;
EXIT WHEN SQL%ROWCOUNT = 0;
COMMIT;
END LOOP;
END;
/
BEGIN purge_old_events(5000); END;
/کنترل تراکنش در PL/pgSQL فقط در یک PROCEDURE که با CALL صدا زده شده (یا در DO block) کار میکند، فقط وقتی چیز دیگری در زنجیرهٔ فراخوانی نباشد، و هرگز داخل بلاکی که بخش EXCEPTION دارد — خطای cannot commit while a subtransaction is active میگیری. Oracle چنین محدودیتی ندارد و علاوه بر آن PRAGMA AUTONOMOUS_TRANSACTION را دارد برای یک تراکنش واقعاً مستقل (همان الگوی کلاسیک «خطا را لاگ کن حتی اگر rollback میکنیم»). PostgreSQL autonomous transaction ندارد؛ آن را با dblink/pg_background شبیهسازی میکنی — یعنی یک backend کامل جدید در هر فراخوانی. همین یک شکاف، خیلی از port های مکانیکی PL/SQL را زمین میزند.
Exception ها
PL/SQL exception های از پیشتعریفشده دارد (NO_DATA_FOUND، TOO_MANY_ROWS، DUP_VAL_ON_INDEX) و با RAISE_APPLICATION_ERROR(-20001, 'msg') خطای کاربردی میسازد که در Java به شکل SQLException با error code برابر -20001 میرسد. PL/pgSQL بر پایهٔ SQLSTATE کار میکند و از RAISE ... USING ERRCODE استفاده میکند:
CREATE OR REPLACE FUNCTION billing.charge(p_id bigint, p_amount numeric)
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'amount must be positive: %', p_amount
USING ERRCODE = 'P0001';
END IF;
INSERT INTO charges(invoice_id, amount) VALUES (p_id, p_amount);
EXCEPTION
WHEN unique_violation THEN -- SQLSTATE 23505
RAISE NOTICE 'already charged: %', p_id;
WHEN no_data_found THEN
RAISE; -- دوباره پرتاب کن، هرگز نبلع
END $$;CREATE OR REPLACE PROCEDURE charge(p_id NUMBER, p_amount NUMBER) IS
BEGIN
IF p_amount <= 0 THEN
RAISE_APPLICATION_ERROR(-20001, 'amount must be positive: ' || p_amount);
END IF;
INSERT INTO charges(invoice_id, amount) VALUES (p_id, p_amount);
EXCEPTION
WHEN DUP_VAL_ON_INDEX THEN -- ORA-00001
DBMS_OUTPUT.PUT_LINE('already charged: ' || p_id);
WHEN NO_DATA_FOUND THEN
RAISE; -- دوباره پرتاب کن، هرگز نبلع
END;
/EXCEPTION WHEN OTHERS THEN NULL; خطا را میبلعد و باگ را نامرئی میکند — بدترین کاری که میشود کرد. دوقلوی آن در PL/pgSQL یعنی WHEN others THEN NULL; دقیقاً همانقدر جنایت است، با یک نیش اضافه: آن subtransaction را هم میبندد، پس کار نیمهتمام بدون هیچ ردی محو میشود. اگر catch-all میگیری، حداقل لاگ کن و دوباره RAISE کن. در code review، هر catch-all بدون پرتاب مجدد باید علامت قرمز باشد.
getErrorCode() شمارهٔ vendor را میدهد (1 برای نقض یکتایی Oracle، 20001 برای RAISE_APPLICATION_ERROR تو) و روی PostgreSQL بیمعناست. getSQLState() کد استاندارد را میدهد: 23505 نقض یکتایی، 40001 شکست serialization، 40P01/60 deadlock. SQLExceptionTranslator در Spring دقیقاً همین کار را میکند و به تو DuplicateKeyException و DeadlockLoserDataAccessException میدهد — مستقل از موتور. کدی که روی getErrorCode() شرط میگذارد از مهاجرت جان سالم به در نمیبرد.
Trigger ها
Trigger کدی است که خودکار قبل/بعد از INSERT/UPDATE/DELETE اجرا میشود. Oracle بدنه را درجا مینویسد؛ PostgreSQL یک تابع جدا با RETURNS trigger میخواهد که trigger به آن ارجاع دهد.
CREATE OR REPLACE FUNCTION audit_salary() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO salary_audit(emp_id, old_val, new_val, changed_at)
VALUES (OLD.id, OLD.salary, NEW.salary, now());
RETURN NEW; -- trigger های BEFORE باید ردیف برگردانند
END $$;
CREATE TRIGGER trg_audit_salary
BEFORE UPDATE OF salary ON employees
FOR EACH ROW
WHEN (OLD.salary IS DISTINCT FROM NEW.salary)
EXECUTE FUNCTION audit_salary();CREATE OR REPLACE TRIGGER trg_audit_salary
BEFORE UPDATE OF salary ON employees
FOR EACH ROW
WHEN (OLD.salary <> NEW.salary)
BEGIN
INSERT INTO salary_audit(emp_id, old_val, new_val, changed_at)
VALUES (:OLD.id, :OLD.salary, :NEW.salary, SYSTIMESTAMP);
END;
/trigger ها منطق را از دید توسعهدهندهای که فقط UPDATE میزند پنهان میکنند و دیباگ را کابوس. مشکلات کلاسیک: خطای mutating table در Oracle یعنی ORA-04091 (وقتی یک row trigger روی همان جدول در حال تغییر query میزند)، ترتیب اجرای نامعلوم بین چند trigger، و افت شدید کارایی روی DML انبوه. قانون سنیور: trigger فقط برای audit ساده یا اعمال یک invariant سخت؛ منطق کسبوکار در سرویس. تیمهایی که منطق حیاتی را در trigger پنهان کردند، ماهها بعد پشیمان شدند.
(۱) PostgreSQL خطای mutating table ندارد — یک trigger میتواند روی جدول خودش query بزند؛ خوشایند به نظر میرسد اما اجازهٔ بازگشت بینهایت میدهد که Oracle جلویش را میگرفت؛ پاسخ خود Oracle به ORA-04091 هم compound trigger در 12c است. (۲) ترتیب اجرا: در Oracle نامعلوم است مگر با FOLLOWS/PRECEDES؛ PostgreSQL به ترتیب الفبایی نام اجرا میکند و به همین دلیل DBA هایش نام trigger را با 10_، 20_ شروع میکنند. (۳) در بند WHEN، عبارت OLD.salary <> NEW.salary در Oracle وقتی یکی از دو طرف NULL باشد false است (پس تغییر NULL به مقدار نادیده میماند) در حالی که IS DISTINCT FROM در PostgreSQL NULL را درست مدیریت میکند — همین یک عملگر در سیستمهای واقعی باعث گم شدن ردیفهای audit شده است.
۲) ROWNUM در برابر ROW_NUMBER() در برابر ROWID
سه چیز کاملاً متفاوت با نامهایی گیجکننده.
| مفهوم | چیست | کِی مقداردهی میشود | معادل در PostgreSQL |
|---|---|---|---|
ROWID |
آدرس فیزیکی ردیف (فایل، block، ردیف) | ثابت تا وقتی ردیف جابهجا نشود | ctid — اما بسیار ناپایدارتر |
ROWNUM |
pseudocolumn که حین واکشی شماره میگیرد | قبل از ORDER BY |
ندارد (از LIMIT استفاده کن) |
ROW_NUMBER() |
window function | بعد از ORDER BY داخل OVER |
دقیقاً همان — استاندارد SQL |
SELECT * FROM emp WHERE ROWNUM <= 5 ORDER BY salary DESC; همیشه اشتباه است: ROWNUM قبل از ORDER BY اختصاص مییابد، یعنی اول ۵ ردیف تصادفی برداشته میشوند و بعد مرتب. راه درست: مرتبسازی داخل subquery، یا ROW_NUMBER()/FETCH FIRST. PostgreSQL چنین تلهای ندارد چون LIMIT طبق تعریف بعد از ORDER BY اعمال میشود — و دقیقاً به همین دلیل توسعهدهندهٔ Oracle که به PostgreSQL میرود احساس میکند صفحهبندی ناگهان «خودش کار میکند».
SELECT * FROM (
SELECT e.*, ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn
FROM employees e
) t WHERE rn BETWEEN 1 AND 5;
SELECT * FROM employees ORDER BY salary DESC
FETCH FIRST 5 ROWS ONLY; -- املای کلاسیک: LIMIT 5SELECT * FROM (
SELECT e.*, ROW_NUMBER() OVER (ORDER BY salary DESC) rn
FROM employees e
) WHERE rn BETWEEN 1 AND 5;
SELECT * FROM employees ORDER BY salary DESC
FETCH FIRST 5 ROWS ONLY; -- Oracle 12c به بعدOracle قدیم فقط ROWNUM داشت؛ از 12c استاندارد OFFSET n ROWS FETCH FIRST m ROWS ONLY کار میکند و PostgreSQL هم آن را در کنار LIMIT/OFFSET میفهمد — پس همان املا انتخاب اول تیمهای multi-database است. دو تلهٔ اضافه: WITH TIES (از Oracle 12c و PostgreSQL 13) بیصدا تعداد ردیف را عوض میکند، و ORDER BY روی کلید غیریکتا صفحهبندی را در هر دو موتور غیرقطعی میکند. همیشه primary key را بهعنوان tiebreaker اضافه کن.
یک گرفتاری مشترک: OFFSET بزرگ در هر دو موتور کند است، چون ردیفهای پریدهشده هم باید تولید شوند. صفحهبندی keyset (seek) جایگزینش میشود:
SELECT invoice_id, created_at, amount
FROM invoices
WHERE (created_at, invoice_id) < ($1, $2) -- آخرین ردیف صفحهٔ قبل
ORDER BY created_at DESC, invoice_id DESC
FETCH FIRST 20 ROWS ONLY;SELECT invoice_id, created_at, amount
FROM invoices
WHERE (created_at < :last_created
OR (created_at = :last_created AND invoice_id < :last_id))
ORDER BY created_at DESC, invoice_id DESC
FETCH FIRST 20 ROWS ONLY;PostgreSQL سازندهٔ ردیف را lexicographic مقایسه میکند — (a, b) < ($1, $2) — و میتواند مستقیم از ایندکس composite استفاده کند. Oracle سازندهٔ ردیف را برای =/IN میپذیرد اما برای </> نه، پس باید همان فرم بازشدهٔ OR بالا را بنویسی. زشتتر است، از همان ایندکس استفاده میکند، و اگر یک دستور باید هر دو موتور را سرویس بدهد، فرم پرتابل همین است.
پاسخ: ROWNUM یک pseudocolumn است که در مرحلهٔ واکشی و پیش از ORDER BY اختصاص مییابد، پس top-N مرتبشده باید داخل subquery ای که قبلاً مرتب شده به کار برود. ROW_NUMBER() یک window function است که پس از ORDER BY داخل OVER (...) عمل میکند و با PARTITION BY میتواند درون هر گروه رتبه بدهد. ROW_NUMBER استاندارد SQL است و روی PostgreSQL دقیقاً همینطور رفتار میکند؛ ROWNUM مخصوص Oracle است و معادلی در PostgreSQL ندارد. قاطی کردنشان یکی از رایجترین باگهای top-N است.
پاسخ: هر دو آدرس فیزیکیاند و هر دو میگذارند دقیقاً همان ردیفی را که خواندی دوباره هدف بگیری، اما ctid را هرگز نباید ذخیره کنی. ROWID در Oracle فقط وقتی عوض میشود که ردیف فیزیکی جابهجا شود (row migration، ALTER TABLE ... MOVE، تغییر partition key با row movement)، پس در یک batch نسبتاً بادوام است. در PostgreSQL هر UPDATE یک نسخهٔ جدید از tuple مینویسد، پس ctid با هر آپدیت عوض میشود و بعد از اینکه VACUUM جای خالی را بازاستفاده کرد، ممکن است به ردیف دیگری اشاره کند. کاربردهای مشروعش کوتاهعمر و محدودند — دستهبندی یک DELETE، حذف تکراریها در یک دستور. برای هر چیز ماندگار، از primary key استفاده کن.
۳) بهینهساز هزینهمحور (CBO): مغز دیتابیس
مقصد را که وارد میکنی دهها مسیر ممکن هست؛ اپ بر اساس تخمین ترافیک و طول مسیر ارزانترین را برمیدارد. optimizer هم برای هر query چند execution plan ممکن میسازد (full scan؟ index؟ کدام join و به چه ترتیبی؟) و ارزانترین را بر اساس هزینهٔ تخمینی برمیگزیند که از آمار میآید. اگر آمار کهنه باشد، مسیر احمقانه انتخاب میشود.
Oracle از دههٔ ۹۰ cost-based optimizer دارد (نه rule-based)؛ PostgreSQL از اول فقط همین را داشته. هر دو به statistics تکیه میکنند: تعداد ردیف، تعداد مقادیر متمایز هر ستون (NDV)، هیستوگرام، و اینکه ترتیب ایندکس چقدر با ترتیب فیزیکی میخواند (در Oracle clustering factor، در PostgreSQL correlation).
مسیر یک SQL از رسیدن تا اجرا | The lifecycle of a SQL statement from arrival to execution:
flowchart LR
App[Java/JDBC] -->|SQL text| Parse[Syntax & Semantic Check]
Parse --> Cache{In Shared Pool?}
Cache -- Yes: soft parse --> Exec[Execute]
Cache -- No: hard parse --> Opt[Cost-Based Optimizer]
Opt -->|uses statistics| Plan[Choose Cheapest Plan]
Plan --> Store[Store in Library Cache]
Store --> Exec
Exec --> Fetch[Fetch rows from Buffer Cache]
همین تصویر در PostgreSQL یک تفاوت ساختاری دارد که همهچیز را عوض میکند — plan cache بهازای هر backend است، نه مشترک | The same picture in PostgreSQL: the plan cache is per backend:
flowchart LR
C[Client / JDBC] --> B[Backend process]
B --> PC{Prepared in THIS session?}
PC -- Yes --> Reuse[Reuse cached plan]
PC -- No --> Plan[Planner: cost-based]
Plan -->|pg_statistic| Choose[Choose cheapest plan]
Choose --> LocalCache[Per-session plan cache]
LocalCache --> Run[Executor -> shared_buffers]
Reuse --> Run
جمعآوری آمار
ANALYZE VERBOSE orders; -- معمولاً autovacuum انجامش میدهد
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000; -- دقت هیستوگرام
CREATE STATISTICS orders_cust_status (dependencies, ndistinct)
ON customer_id, status FROM orders;
ANALYZE orders;
SELECT attname, n_distinct, correlation
FROM pg_stats WHERE tablename = 'orders';BEGIN
DBMS_STATS.GATHER_TABLE_STATS(USER, 'ORDERS',
method_opt => 'FOR ALL COLUMNS SIZE AUTO',
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
cascade => TRUE);
END;
/
SELECT DBMS_STATS.CREATE_EXTENDED_STATS(USER, 'ORDERS',
'(CUSTOMER_ID, STATUS)') FROM dual;
SELECT column_name, num_distinct, histogram
FROM user_tab_col_statistics WHERE table_name = 'ORDERS';هر دو optimizer فرض میکنند شرطها مستقلاند، پس WHERE city='Tehran' AND country='Iran' را حاصلضرب دو selectivity تخمین میزنند — که وقتی ستونها وابستهاند فاجعهبار پایین است. تخمین به یک ردیف سقوط میکند، planner nested loop برمیدارد و کوئری یک ساعت طول میکشد. درمان Oracle extended statistics است و درمان PostgreSQL CREATE STATISTICS ... (dependencies, ndistinct, mcv). هیچکدام خودکار ساخته نمیشوند؛ دانستن وجودشان یک تمایز واقعی سنیوری است.
دیدن نقشهٔ اجرا
EXPLAIN PLAN/EXPLAIN: پلن تخمینی بدون اجرای کوئری.- AUTOTRACE /
EXPLAIN (ANALYZE, BUFFERS): اجرا میکند و ردیفها و I/O واقعی را میدهد. DBMS_XPLAN.DISPLAY_CURSOR/auto_explain: پلن واقعیِ اجراشده.- AWR / ASH /
pg_stat_statements+pg_stat_activity: تاریخچهٔ بار سیستم و «الان دقیقاً چه خبر است؟».
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT * FROM orders WHERE customer_id = 42;
-- پلن یک دستور پارامتری بدون ساختن مقدار قلابی (نسخهٔ ۱۶ به بعد)
EXPLAIN (GENERIC_PLAN)
SELECT * FROM orders WHERE customer_id = $1;SELECT /*+ GATHER_PLAN_STATISTICS */ * FROM orders WHERE customer_id = 42;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST'));
-- فقط پلن تخمینی
EXPLAIN PLAN FOR SELECT * FROM orders WHERE customer_id = :c;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);در Oracle، GATHER_PLAN_STATISTICS + ALLSTATS LAST ستون E-Rows را کنار A-Rows چاپ میکند؛ در PostgreSQL، EXPLAIN (ANALYZE) عدد rows= (تخمین) را کنار actual rows= میگذارد. هرجا این دو چند مرتبهٔ بزرگی فاصله دارند، همانجا پلن خراب شده — و تقریباً همیشه اولین تخمین بدِ پایینِ درخت است که همهٔ join های بالای خودش را مسموم میکند. بهجای جنگیدن با پلن به کمک hint، خودِ تخمین را درست کن: آمار، آمار چندستونی، یا بازنویسی شرط.
CREATE EXTENSION IF NOT EXISTS pg_stat_statements; -- نیازمند preload و restart
SELECT calls, round(total_exec_time) AS total_ms,
round(mean_exec_time, 2) AS mean_ms, rows,
shared_blks_hit, shared_blks_read, left(query, 60) AS sql
FROM pg_stat_statements
ORDER BY total_exec_time DESC FETCH FIRST 10 ROWS ONLY;
SELECT pid, state, wait_event_type, wait_event,
now() - query_start AS running_for
FROM pg_stat_activity WHERE state <> 'idle';-- گزارش AWR: @?/rdbms/admin/awrrpt.sql (لایسنس Diagnostics Pack!)
SELECT sql_id, SUM(executions_delta) AS execs,
ROUND(SUM(elapsed_time_delta)/1e6) AS total_s
FROM dba_hist_sqlstat
WHERE snap_id BETWEEN :b_snap AND :e_snap
GROUP BY sql_id ORDER BY 3 DESC FETCH FIRST 10 ROWS ONLY;
SELECT session_id, event, sql_id, COUNT(*) AS samples
FROM v$active_session_history
WHERE sample_time > SYSTIMESTAMP - INTERVAL '5' MINUTE
GROUP BY session_id, event, sql_id ORDER BY samples DESC;AWR و ASH بخشی از Diagnostics Pack هستند و مجوز جداگانهٔ پولی میخواهند؛ اجرای awrrpt.sql یا کوئری روی DBA_HIST_*/V$ACTIVE_SESSION_HISTORY بدون آن نقض مجوز است و در ممیزی Oracle جریمهٔ سنگین دارد. اگر لایسنس نداری سراغ Statspack رایگان برو. PostgreSQL مشکل آینهای دارد: pg_stat_statements رایگان است اما پیشفرض روشن نیست — به shared_preload_libraries و restart نیاز دارد، پس ممکن است وسط یک incident تازه بفهمی هیچ تاریخچهای از کوئریها نداری. قبل از اینکه لازمش شوی روشنش کن.
Bind variable ها و shared pool: مهمترین درس کارایی
هر متن SQL جدید Oracle را مجبور به hard parse میکند (پارس، بهینهسازی، ساخت پلن) — گران. نتیجه در shared pool (library cache) میماند و اگر همان متن دوباره بیاید soft parse ارزان رخ میدهد. چسباندن مقدار در متن، این را خراب میکند: هر مقدار یک متن متفاوت است.
-- بد: بهازای هر کاربر یک متن → CPU برای planning میسوزد
SELECT * FROM orders WHERE customer_id = 12345;
-- خوب: یک متن، با placeholder پارامتر
PREPARE ord(bigint) AS SELECT * FROM orders WHERE customer_id = $1;
EXECUTE ord(12345);-- بد: هر مقدار یک متن جدید → shared pool پر میشود، CPU میسوزد
SELECT * FROM orders WHERE customer_id = 12345;
-- خوب: یک متن، پلن مشترک → soft parse
VARIABLE cust_id NUMBER;
EXEC :cust_id := 12345;
SELECT * FROM orders WHERE customer_id = :cust_id;در Spring، ? یا named parameter خودبهخود به bind variable تبدیل میشود:
jdbcTemplate.query(
"SELECT * FROM orders WHERE customer_id = ?",
new Object[]{ custId },
orderRowMapper);
دو دلیل و هر دو حیاتی. (۱) کارایی — بدون hard parse بیپایان، بدون پر شدن shared pool، بدون «shared pool latch contention» یا ORA-04031 که کل instance را کند میکند. (۲) امنیت — bind variable تنها دفاع واقعی در برابر SQL injection است. چسباندن "... = '" + userInput + "'" در Java هم کارایی را میکشد هم در را باز میگذارد. هرگز رشتهٔ SQL نساز؛ همیشه PreparedStatement/?.
عمیقترین تفاوت معماری این بخش. library cache در Oracle بین همهٔ session ها مشترک است: یک hard parse به نفع همه است و سیل SQL با literal، pool را برای همه مسموم میکند. PostgreSQL بهازای هر backend پلن میسازد؛ هیچ اشتراک پلن بین session ها وجود ندارد و prepared statement فقط داخل همان session زندگی میکند. نتیجه: SQL با literal در PostgreSQL هزینهٔ planning هر بار را دارد اما نمیتواند latch storm بسازد؛ PgBouncer در حالت transaction، prepared statement سمت سرور را میشکند مگر با پشتیبانی پروتکلی؛ و shared_pool_size ای برای تنظیم وجود ندارد — shared_buffers داده را cache میکند نه پلن را. گفتن اینکه «shared_buffers همان shared pool است» در مصاحبه یک نشانهٔ کلاسیک است.
Oracle در اولین اجرا مقدار واقعی bind را «نگاه میکند» و پلن را برای همان مقدار میسازد و cache میکند. با دادهٔ skew (مثلاً status='ACTIVE' نود درصد و status='ARCHIVED' یک درصد) آن پلن میتواند برای مقدار بعدی فاجعه باشد؛ Oracle با Adaptive Cursor Sharing آن را تعدیل میکند. PostgreSQL همان بیماری را با درمان دیگری دارد: پنج اجرای اول یک prepared statement با پارامترهای واقعی پلن اختصاصی میگیرند، بعد یک generic plan ساخته میشود و اگر هزینهٔ تخمینیاش خیلی بدتر نباشد نگه داشته میشود؛ با SET plan_cache_mode = force_custom_plan میتوانی این heuristic را کنار بزنی. اگر کوئری «گاهی سریع، گاهی خیلی کند» است، در هر دو موتور مظنون شمارهٔ یک همین است.
وقتی پلن خوب را پیدا کردی، Oracle میگذارد آن را بهعنوان SQL Plan Baseline ثبت کنی (DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE) که optimizer موظف به استفاده از آن میشود — بسیار امنتر از hint، چون hint تصمیمی را با حجم دادهٔ امروز منجمد میکند و سه سال بعد همان دلیل کندی است که کسی جرئت حذفش را ندارد. PostgreSQL نه baseline دارد نه hint در هسته: اهرمهایت آمار، ایندکس، plan_cache_mode و بازنویسی کوئریاند؛ pg_hint_plan یک extension است که روی سرویسهای managed معمولاً نمیتوانی نصبش کنی. این محدودیت تقریباً یک مزیت است — مجبورت میکند علت را درمان کنی.
پاسخ: hard parse یعنی Oracle متن SQL را از صفر پارس، معنایابی و بهینهسازی میکند و پلن میسازد — پرهزینهترین بخش پردازش یک کوئری کوچک. soft parse یعنی همان متن در library cache هست و فقط پلن آماده بازیابی میشود. literal ها هر مقدار را به یک متن یکتا و یک hard parse تبدیل میکنند که CPU میسوزاند و روی shared pool latch contention میسازد. bind متن را ثابت نگه میدارد تا پلن مشترک شود، و تنها دفاع مطمئن در برابر SQL injection هم هست. در PostgreSQL این واژگان اصلاً وجود ندارد — library cache مشترکی نیست — اما هزینه هست: هر دستور پر از literal دوباره planning میشود، پس همان انضباط لازم است.
پاسخ: نه، و قاطی کردنشان علامت قرمز است. SGA در Oracle هم buffer cache دارد (بلاکهای داده) هم shared pool (SQL پارسشده، پلن، dictionary cache). shared_buffers در PostgreSQL فقط معادل نیمهٔ buffer cache است؛ پلنها در حافظهٔ خصوصی هر backend میمانند. برای همین Oracle یک رشتهٔ کامل از تنظیمات دربارهٔ contention روی library cache دارد (ORA-04031، cursor sharing، SESSION_CACHED_CURSORS) که در PostgreSQL اصلاً وجود ندارد، و در عوض PostgreSQL به page cache سیستمعامل اهمیت میدهد (توصیهٔ رایج ۲۵٪ RAM برای shared_buffers دقیقاً از همینجاست) و به تعداد کانکشن، چون هر کانکشن یک پروسه است.
۴) ایندکسها: نقشهٔ گنج داده
برای پیدا کردن «SCN» در یک کتاب ۸۰۰ صفحهای یا صفحهبهصفحه میخوانی (full table scan) یا سراغ فهرست انتهای کتاب میروی و مستقیم به صفحهٔ درست میپری. ایندکس دیتابیس دقیقاً همین است: ساختاری مرتب که آدرس (ROWID/ctid) ردیفهای مطابق را نگه میدارد.
B-tree (پیشفرض در هر دو): درخت متوازن، سلطان OLTP، عالی برای برابری و بازه، و درخشان روی ستونهای پرکاردینالیتی.
Bitmap index (فقط Oracle): برای هر مقدار متمایز یک bitmap؛ روی ستونهای کمکاردینالیتی بسیار فشرده و برای AND/OR چند شرط در انبار داده فوقالعاده. PostgreSQL ایندکس bitmap ذخیرهشده ندارد — bitmap را هنگام اجرا میسازد (Bitmap Index Scan → BitmapAnd → Bitmap Heap Scan) و برای جدولهای عظیمِ ذاتاً مرتب، BRIN را دارد.
CREATE INDEX idx_sales_status ON sales (status);
CREATE INDEX idx_sales_region ON sales (region);
-- پلن: Bitmap Index Scan + BitmapAnd + Bitmap Heap Scan
-- BRIN: چند کیلوبایت برای جدول میلیاردی مرتب بر اساس زمان
CREATE INDEX idx_sales_date_brin ON sales
USING brin (sale_date) WITH (pages_per_range = 64);CREATE BITMAP INDEX idx_sales_status ON sales (status);
CREATE BITMAP INDEX idx_sales_region ON sales (region);
-- optimizer میتواند BITMAP AND / BITMAP OR بزند
-- معادل «ایندکس ریز برای جدول غول» در Oracle: zone map
-- (روی Exadata) یا خیلی ساده، پارتیشنبندی.هرگز روی جدولی که بهشدت update میشود bitmap index نگذار: تغییر یک ردیف میتواند قطعهای از bitmap را قفل کند که صدها ردیف دیگر را میپوشاند و contention وحشیانه بسازد. bitmap فقط برای جدولهای عمدتاً read-only است. PostgreSQL نمیتواند این اشتباه را بکند، اما BRIN اشتباه خودش را دارد: فقط وقتی کار میکند که ترتیب فیزیکی ردیفها با ستون ایندکس همبسته باشد (timestamp های append-only). روی دادهٔ تصادفی، BRIN از بیفایده هم بدتر است چون همهٔ بازههای بلاک مطابقت میکنند.
Function-based / expression index: WHERE UPPER(email) = 'A@B.COM' نمیتواند از ایندکس روی email استفاده کند — باید خود عبارت را ایندکس کنی:
CREATE INDEX idx_emp_upper_email ON employees (upper(email));
SELECT * FROM employees WHERE upper(email) = 'ANA@EXAMPLE.COM';
-- در PostgreSQL اغلب بهتر: بیتفاوتی به حروف در سطح نوع داده
CREATE EXTENSION IF NOT EXISTS citext;CREATE INDEX idx_emp_upper_email ON employees (UPPER(email));
SELECT * FROM employees WHERE UPPER(email) = 'ANA@EXAMPLE.COM';
-- Oracle 12.2 به بعد: collation بیتفاوت به حروف همین کار را میکند
-- ALTER TABLE employees MODIFY (email VARCHAR2(200) COLLATE BINARY_CI);PostgreSQL تابع را IMMUTABLE میخواهد و Oracle DETERMINISTIC. شکست کلاسیک، تلاش برای ایندکس کردن چیزی است که به SYSDATE/now() دست میزند. شکست بدتر، دروغ گفتن است: علامت زدن یک تابع وابسته به timezone بهعنوان IMMUTABLE در PostgreSQL ایندکسی میسازد که بیصدا ردیف اشتباه برمیگرداند. اگر «۳۰ روز اخیر» باید سریع باشد، خود ستون timestamp را ایندکس کن و حسابوکتاب را سمت ثابتِ شرط ببر.
ایندکس composite روی (a, b, c) به WHERE a=...، a AND b و a AND b AND c سرویس میدهد، اما برای WHERE b=... تنها معمولاً بیفایده است — مثل دفترچهتلفنی که با نامخانوادگی مرتب شده و تو فقط نام کوچک را داری. ستونی که بیشتر و با برابری در WHERE میآید را اول بگذار. هر دو موتور فرارگاههای جزئی دارند (index skip scan در Oracle وقتی ستون اول مقادیر متمایز کمی دارد؛ اسکن کامل یک ایندکس کوچک در PostgreSQL) اما هیچکدام جای ترتیب درست ستونها را نمیگیرد.
Covering index / index-only scan: اگر ایندکس همهٔ ستونهای موردنیاز کوئری را داشته باشد، جدول اصلاً خوانده نمیشود.
-- INCLUDE ستونهای payload را از کلید جستوجو بیرون نگه میدارد (نسخهٔ ۱۱+)
CREATE INDEX idx_orders_cust_incl
ON orders (customer_id) INCLUDE (status, amount);
VACUUM (ANALYZE) orders; -- index-only scan به visibility map نیاز دارد
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, status, amount FROM orders WHERE customer_id = $1;-- بند INCLUDE ندارد: ستونهای payload را به انتهای کلید اضافه کن
CREATE INDEX idx_orders_cust_incl
ON orders (customer_id, status, amount);
EXPLAIN PLAN FOR
SELECT customer_id, status, amount FROM orders WHERE customer_id = :c;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY); -- بدون TABLE ACCESS BY ROWIDدر Oracle اگر ایندکس همهٔ ستونها را داشته باشد، دسترسی index-only میگیری، تمام. PostgreSQL باید علاوه بر آن قابلرؤیت بودن tuple را هم بررسی کند و فقط برای صفحههایی که در visibility map بهعنوان all-visible علامت خوردهاند میتواند از این بررسی بگذرد — و آن map را VACUUM نگه میدارد. پس روی جدول داغ با autovacuum عقبمانده، همان ایندکس covering به index scan بهعلاوهٔ یک heap fetch بهازای هر ردیف تنزل میکند، در حالی که پلن هنوز میگوید Index Only Scan و عدد بزرگی جلوی Heap Fetches: مینویسد. خواندن همان عدد، نشانه است.
Partial / filtered index: فقط ردیفهایی را ایندکس کن که واقعاً کوئری میشوند.
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'PENDING';
-- یکتایی شرطی، مجانی
CREATE UNIQUE INDEX uq_active_email ON users (email) WHERE deleted_at IS NULL;-- ایندکس بند WHERE ندارد. اصطلاح رایج: function-based index ای که
-- برای ردیفهای بیاهمیت NULL برمیگرداند (کلید کاملاً NULL ذخیره نمیشود).
CREATE INDEX idx_orders_pending ON orders (
CASE WHEN status = 'PENDING' THEN created_at END);
SELECT * FROM orders
WHERE CASE WHEN status = 'PENDING' THEN created_at END > :since;ترفند CASE ایندکس کوچکی میدهد، اما optimizer فقط وقتی از آن استفاده میکند که شرط دقیقاً به همان شکل عبارت ایندکسشده نوشته شود — یعنی SQL اپلیکیشن زشت و به ایندکس چسبیده میشود. به همین دلیل partial index یکی از چیزهای واقعاً بهتر PostgreSQL است. (Oracle 12c به بعد «partial index» برای جدولهای پارتیشنبندیشده با INDEXING ON/OFF دارد — اما آن ایندکس را per-partition روشن/خاموش میکند، نه per-row.)
هر ایندکس اضافه یعنی هر INSERT/UPDATE/DELETE باید آن را هم نگه دارد — نوشتن کندتر، redo/WAL بیشتر، فضای بیشتر. پانزده ایندکس روی یک جدول یعنی write های دردناک. PostgreSQL یک پیچ اضافه دارد: آپدیتی که هیچ ستون ایندکسشدهای را دست نزند و در همان صفحه جا داشته باشد میتواند HOT update باشد و کل نگهداری ایندکس را رد کند؛ پس یک ایندکس غیرضروری روی ستون داغ بیصدا هزینهٔ نوشتن و bloat را چند برابر میکند (آنجا fillfactor را حدود ۸۵ بگذار). ایندکس را از شواهد بساز — AWR / pg_stat_statements — نه از حدس، و بیاستفادهها را حذف کن.
CREATE INDEX CONCURRENTLY idx_orders_created ON orders (created_at);
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_old;
REINDEX INDEX CONCURRENTLY idx_orders_created;
SELECT relname, indexrelname, idx_scan
FROM pg_stat_user_indexes WHERE idx_scan = 0;CREATE INDEX idx_orders_created ON orders (created_at) ONLINE;
ALTER INDEX idx_orders_created REBUILD ONLINE;
-- قبل از حذف، امن بودنش را ثابت کن
ALTER INDEX idx_orders_old INVISIBLE;
ALTER SESSION SET optimizer_use_invisible_indexes = TRUE;
ALTER INDEX idx_orders_old MONITORING USAGE;
SELECT * FROM v$object_usage;INVISIBLE در Oracle معادلی در PostgreSQL ندارد: ایندکس همچنان روی DML نگهداری میشود اما optimizer نادیدهاش میگیرد، پس میتوانی ثابت کنی حذفش بیخطر است و اگر دنیا آتش گرفت در یک ثانیه برش گردانی. در PostgreSQL نزدیکترین الگوی امن DROP INDEX CONCURRENTLY است بههمراه اسکریپت ذخیرهشدهٔ CREATE INDEX CONCURRENTLY — و این آگاهی که ساخت دوبارهٔ یک ایندکس بزرگ ساعتها طول میکشد. همین عدم تقارن است که DBA های PostgreSQL را نسبت به حذف ایندکس بزرگ اینقدر محتاط میکند.
پاسخ: covering index همهٔ ستونهای موردنیاز کوئری را دارد، پس موتور فقط از ایندکس جواب میدهد. در Oracle ستونهای payload را به انتهای کلید composite اضافه میکنی و پلن با نمایش INDEX RANGE SCAN بدون TABLE ACCESS BY INDEX ROWID پس از آن اثباتش میکند. در PostgreSQL از بند INCLUDE (نسخهٔ ۱۱+) استفاده میکنی و پلن Index Only Scan نشان میدهد — اما باید Heap Fetches: را هم بخوانی؛ عدد بزرگ یعنی visibility map کهنه است و در عمل داری heap را هم میخوانی، که VACUUM درستش میکند. هزینه در هر دو موتور، حجم ایندکس و کندی نوشتن است.
۵) پارتیشنبندی: تقسیم غول به تکههای قابلمدیریت
یک انبار اسناد دهساله در یک اتاق یعنی برای پیدا کردن سند «مارس ۲۰۲۴» همهجا را بگردی. اگر بر اساس سال قفسهبندی کنی، مستقیم سراغ قفسهٔ ۲۰۲۴ میروی و بقیه را کامل نادیده میگیری. partition pruning دقیقاً همین است: optimizer پارتیشنهای نامربوط را کامل حذف میکند.
پارتیشنبندی یک جدول بزرگ را به قطعات فیزیکی کوچکتر میشکند که منطقاً یک جدولاند. سه نوع اصلی در هر دو موتور: range (معمولاً تاریخ)، list (مقادیر مشخص)، hash (توزیع یکنواخت وقتی مرز طبیعی نداری).
CREATE TABLE orders (
order_id bigint GENERATED ALWAYS AS IDENTITY,
order_date date NOT NULL,
amount numeric(12,2)
) PARTITION BY RANGE (order_date);
CREATE TABLE orders_2025 PARTITION OF orders
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
CREATE TABLE orders_2026 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
CREATE TABLE orders_default PARTITION OF orders DEFAULT;
-- کلید یکتا باید حتماً شامل partition key باشد:
ALTER TABLE orders ADD PRIMARY KEY (order_id, order_date);CREATE TABLE orders (
order_id NUMBER GENERATED ALWAYS AS IDENTITY,
order_date DATE NOT NULL,
amount NUMBER(12,2),
CONSTRAINT pk_orders PRIMARY KEY (order_id) -- ایندکس global: مشکلی ندارد
)
PARTITION BY RANGE (order_date) (
PARTITION p2025 VALUES LESS THAN (DATE '2026-01-01'),
PARTITION p2026 VALUES LESS THAN (DATE '2027-01-01'),
PARTITION pmax VALUES LESS THAN (MAXVALUE)
);در Oracle ایندکس میتواند global باشد (یک B-tree روی همهٔ پارتیشنها)، پس PRIMARY KEY (order_id) همچنان کار میکند و جستوجو با id یک probe است. در PostgreSQL هر ایندکس روی جدول پارتیشنبندیشده local است: قید یکتایی باید شامل partition key باشد، و جستوجوی بدون تاریخ به همهٔ پارتیشنها سر میزند. همین یک قانون شکل schema را عوض میکند — یا partition key را به foreign key ها و کوئریها میبری یا fan-out را میپذیری. این شمارهٔ یکِ غافلگیریها هنگام پورت کردن یک مدل پارتیشنبندیشدهٔ Oracle است.
نمودار زیر نشان میدهد چطور یک کوئری تاریخمحور فقط یک پارتیشن را میخواند | How a date-filtered query prunes to a single partition:
flowchart TD
Q["Query: WHERE order_date in 2026"] --> Opt[Optimizer: Partition Pruning]
Opt -->|skip| P25[Partition 2025]
Opt -->|scan| P26[Partition 2026]
Opt -->|skip| PD[Default / MAXVALUE]
P26 --> R[Result rows]
سود بزرگتر نگهداری است: حذف یک سال داده بهجای DELETE صدمیلیونردیفی، کار metadata میشود.
ALTER TABLE orders DETACH PARTITION orders_2025 CONCURRENTLY;
DROP TABLE orders_2025;
-- هستهٔ PostgreSQL بند INTERVAL ندارد: یا اسکریپت بنویس یا pg_partman
CREATE TABLE orders_2027 PARTITION OF orders
FOR VALUES FROM ('2027-01-01') TO ('2028-01-01');ALTER TABLE orders DROP PARTITION p2025 UPDATE INDEXES;
-- interval partitioning: Oracle پارتیشن بعدی را خودکار میسازد
ALTER TABLE orders SET INTERVAL (NUMTOYMINTERVAL(1, 'MONTH'));
-- دادهٔ سرد را بهجای حذف، به storage ارزان منتقل کن
ALTER TABLE orders MOVE PARTITION p2026 TABLESPACE archive_ts ONLINE;سه سود: pruning (فقط پارتیشنهای مربوط خوانده میشوند)، مدیریت داده (پاکسازی آنی بهجای DELETE ای که undo/redo عظیم تولید میکند) و نگهداری و موازیسازی (rebuild، analyze و backup پارتیشنبهپارتیشن). Oracle بالغتر است — interval، reference و composite partitioning و UPDATE INDEXES برای سالم نگهداشتن ایندکسهای global — اما Partitioning یک option جداگانهٔ پولی است، در حالی که declarative partitioning در PostgreSQL رایگان است. در PostgreSQL یادت باشد enable_partitionwise_join و enable_partitionwise_aggregate هر دو پیشفرض خاموشاند و دقیقاً همانهایی هستند که join های بزرگ پارتیشنی را سریع میکنند.
پاسخ: وقتی جدول آنقدر بزرگ شده که full scan یا نگهداری (purge، archive، rebuild ایندکس) دردناک است — معمولاً دهها میلیون ردیف به بالا، خصوصاً با یک بُعد زمانی طبیعی. بزرگترین اشتباه، partition key ای است که با WHERE کوئریهای واقعی همراستا نیست: pruning اتفاق نمیافتد، همهٔ پارتیشنها اسکن میشوند و تازه سربار هم اضافه شده. اشتباه دوم: پارتیشنبندی جدول کوچک. اشتباه سومِ مخصوص PostgreSQL: فراموش کردن اینکه ایندکسها local اند، پس جستوجوی بدون partition key fan-out میکند — و با هزاران پارتیشن، خودِ زمان planning گلوگاه میشود. تعداد را در حد صدها نگه دار، نه دهها هزار.
۶) MERGE، Sequence و Identity، و Materialized View
MERGE (upsert)
MERGE در یک دستور بسته به وجود ردیف مقصد UPDATE، INSERT یا DELETE میکند. Oracle از دیرباز داردش؛ PostgreSQL در نسخهٔ ۱۵ اضافهاش کرد و در ۱۷ کاملش کرد.
-- upsert اصیل PostgreSQL (نیازمند قید یکتا روی id)
INSERT INTO target (id, amount) VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET amount = EXCLUDED.amount
RETURNING id, amount;
-- MERGE استاندارد (۱۵+) با RETURNING و merge_action() (۱۷+)
MERGE INTO target t
USING (SELECT $1::bigint AS id, $2::numeric AS amount) s ON t.id = s.id
WHEN MATCHED THEN UPDATE SET amount = s.amount
WHEN NOT MATCHED THEN INSERT (id, amount) VALUES (s.id, s.amount)
RETURNING merge_action(), t.id, t.amount;MERGE INTO target t
USING (SELECT :id AS id, :amount AS amount FROM dual) s ON (t.id = s.id)
WHEN MATCHED THEN UPDATE SET t.amount = s.amount
WHEN NOT MATCHED THEN INSERT (id, amount) VALUES (s.id, s.amount);
-- Oracle بند ON CONFLICT ندارد؛ معادل تکردیفی یا همان MERGE بالاست
-- یا گرفتن خطای یکتایی:
BEGIN
INSERT INTO target (id, amount) VALUES (:id, :amount);
EXCEPTION
WHEN DUP_VAL_ON_INDEX THEN
UPDATE target SET amount = :amount WHERE id = :id;
END;
/ON CONFLICT به یک ایندکس یکتا گره خورده، پس در برابر insert کنندهٔ همزمان اتمیک است: تضاد در سطح ایندکس تشخیص داده میشود و دستور به update سوییچ میکند. MERGE بر اساس شرط join تعریف شده و مصون نیست — در هر دو موتور دو session که همزمان یک کلید جدید را merge کنند میتوانند به ORA-00001 / SQLSTATE 23505 بخورند، و پاسخ، retry است. PostgreSQL 17 بند WHEN NOT MATCHED BY SOURCE و RETURNING merge_action() را اضافه کرد؛ Oracle 23ai هم به UPDATE/MERGE بند RETURNING با مقادیر قدیم و جدید داد، اما روی 19c باید داخل PL/SQL از RETURNING ... INTO استفاده کنی. برای کد پرتابل، upsert را پشت یک متد repository با پیادهسازی جدا برای هر گویش پنهان کن.
Sequence و Identity
CREATE SEQUENCE seq_order START WITH 1 INCREMENT BY 1 CACHE 20;
INSERT INTO orders (id, amount) VALUES (nextval('seq_order'), 100);
-- ترجیح: ستون identity استاندارد
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY,
amount numeric(12,2));
INSERT INTO orders (amount) VALUES (100) RETURNING id;CREATE SEQUENCE seq_order START WITH 1 INCREMENT BY 1 CACHE 100;
INSERT INTO orders (id, amount) VALUES (seq_order.NEXTVAL, 100);
-- ترجیح از 12c: ستون identity استاندارد
CREATE TABLE orders (
id NUMBER GENERATED ALWAYS AS IDENTITY,
amount NUMBER(12,2));
INSERT INTO orders (amount) VALUES (100) RETURNING id INTO :out_id;CACHE n یعنی n مقدار بعدی برای سرعت در حافظه رزرو میشود؛ ریاستارت instance مقادیر استفادهنشده را از دست میدهد و gap میسازد. این طبیعی است — هرگز فرض نکن sequence بدون gap است؛ آن را برای یکتایی به کار ببر نه برای شمارش. دو نکتهٔ گویشی: در Oracle RAC هر instance رنج خودش را دارد، پس gap و ترتیب نامرتب قاعده است و ORDER NOCACHE ترتیب را به قیمت کارایی وحشتناک برمیگرداند؛ در PostgreSQL cache بهازای هر session است، پس یک pool با ۵۰ کانکشن میتواند ۵۰ بلاک استفادهنشده نگه دارد. در هر دو موتور nextval غیرتراکنشی است — تراکنش rollback شده هم شماره را مصرف کرده. در JPA حتماً allocationSize را با CACHE دیتابیس هماهنگ کن وگرنه طوفان duplicate key میگیری.
Materialized View
view یک کوئری ذخیرهشده است؛ materialized view (MV) نتیجه را فیزیکی ذخیره میکند — یک cache در سطح دیتابیس برای aggregation های گران.
CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT date_trunc('day', order_date) AS d, SUM(amount) AS total
FROM orders GROUP BY 1
WITH DATA;
-- CONCURRENTLY به ایندکس یکتا نیاز دارد و خوانندهها را بلاک نمیکند
CREATE UNIQUE INDEX ON mv_daily_sales (d);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;CREATE MATERIALIZED VIEW LOG ON orders
WITH ROWID, SEQUENCE (order_date, amount) INCLUDING NEW VALUES;
CREATE MATERIALIZED VIEW mv_daily_sales
BUILD IMMEDIATE
REFRESH FAST ON COMMIT -- افزایشی، هنگام commit
ENABLE QUERY REWRITE
AS SELECT TRUNC(order_date) d, SUM(amount) total, COUNT(*) cnt
FROM orders GROUP BY TRUNC(order_date);جادوی واقعی MV در Oracle query rewrite است: با ENABLE QUERY REWRITE، یک کوئری aggregation معمولی روی جدول اصلی بیسروصدا به MV هدایت میشود، و REFRESH FAST فقط تغییرات را از طریق materialized view log اعمال میکند. PostgreSQL هم MV دارد اما نه refresh افزایشی و نه rewrite خودکار — REFRESH MATERIALIZED VIEW همهچیز را از نو محاسبه میکند و شکل بدون CONCURRENTLY قفل ACCESS EXCLUSIVE میگیرد که تا پایان کار همهٔ خوانندهها را بلاک میکند. extension بهنام pg_ivm نگهداری افزایشی اضافه میکند اما جزو هسته نیست. برای بار کاری BI، همین یک تفاوت دلیل واقعی انتخاب یک موتور بر دیگری است.
DBMS_SCHEDULER یک زیرسیستم درجهیک Oracle است (زنجیرهٔ job، window، resource plan، اعلان) و هر سیستم قدیمی Oracle دهها job مستندنشده در آن دارد. PostgreSQL در هسته هیچ ندارد: یا extension بهنام pg_cron (SELECT cron.schedule('mv-daily','10 2 * * *', $$REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales$$))، یا crontab سیستمعامل، یا scheduler اپلیکیشن (Quartz، Spring @Scheduled با ShedLock). ویژگی کوچکی است که در مهاجرت یک قلم واقعی میشود.
۷) موتور تراکنش: Undo، Redo، SCN و Read Consistency
سه مفهوم را از هم جدا کن:
- Redo (Oracle) / WAL (PostgreSQL): لاگ همهٔ تغییرات برای دوام و بازیابی، که متوالی نوشته میشود و پیش از بازگشت commit باید به دیسک برسد.
- Undo (Oracle): تصویر قبلی داده در یک undo tablespace جدا، برای rollback و read consistency. PostgreSQL undo ندارد — نسخهٔ قدیمی ردیف را داخل خود جدول نگه میدارد و قابلرؤیت بودن را با
xmin/xmaxعلامت میزند. - SCN (Oracle) / LSN و transaction id (PostgreSQL): ساعت منطقی که تعیین میکند کدام نسخه از دیتابیس را میبینی.
| مفهوم | Oracle | PostgreSQL |
|---|---|---|
| لاگ تغییرات | redo log و archived redo | فایلهای WAL (pg_wal) |
| نسخههای قدیمی | undo tablespace | dead tuple داخل heap |
| ساعت نسخه | SCN | LSN، xmin/xmax، snapshot |
| بازپسگیری فضا | undo خودکار بازچرخانی میشود | VACUUM / autovacuum |
| شکست کلاسیک | ORA-01555 snapshot too old |
bloat و XID wraparound |
| خواندن نقطهای در زمان | Flashback Query (AS OF SCN) |
ندارد (فقط restore با PITR) |
چند نفر همزمان به یک نقاشی نگاه میکنند و یکی دارد گوشهای را دوباره رنگ میزند. دیتابیس هرگز رنگ نیمهکاره را به بقیه نشان نمیدهد؛ به هرکدام نسخهٔ سازگار از لحظهٔ شروع کوئری خودش را نشان میدهد. پس خواننده هرگز نویسنده را بلاک نمیکند و برعکس — در هر دو موتور درست است، با دو پیادهسازی کاملاً متفاوت.
BEGIN ISOLATION LEVEL REPEATABLE READ; -- snapshot در اولین دستور ثابت میشود
SELECT SUM(amount) FROM orders;
SELECT SUM(amount) FROM orders; -- تضمیناً یکسان
COMMIT;
-- flashback query ندارد. تنها چیزی که میگیری اطلاعات خام رؤیت است:
SELECT xmin, xmax, * FROM orders WHERE order_id = 42;SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- snapshot در سطح تراکنش
SELECT SUM(amount) FROM orders;
SELECT SUM(amount) FROM orders; -- تضمیناً یکسان
COMMIT;
-- Flashback Query: جدول را همانطور که ۱۵ دقیقه پیش بود بخوان
SELECT * FROM orders AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '15' MINUTE)
WHERE order_id = 42;
SELECT * FROM orders AS OF SCN 12345678 WHERE order_id = 42;چون Oracle تصاویر قبلی را در undo نگه میدارد، به «این ردیف ساعت ۹:۱۵ چه شکلی بود؟» با یک SELECT معمولی جواب میدهد و FLASHBACK TABLE ... TO TIMESTAMP میتواند یک UPDATE اشتباه را در چند ثانیه بدون restore برگرداند. PostgreSQL ساختاراً نمیتواند: بهمحض اینکه VACUUM آن dead tuple را حذف کرد، مقدار قدیمی برای همیشه رفته و بازیابی یعنی restore یک base backup و replay کردن WAL تا یک نقطهٔ زمانی — دقیقهها تا ساعتها. اگر playbook عملیاتیات به flashback تکیه دارد، قبل از مهاجرت برای جدولهای تاریخچه یا یک extension زمانی بودجه بگذار.
ORA-01555 snapshot too old وقتی رخ میدهد که یک کوئری طولانی به تصویری از undo نیاز دارد که تراکنشهای دیگر رویش نوشتهاند — علتهای کلاسیک: گزارشهای چنددقیقهای بهعلاوهٔ undo کوچک، یا «fetch across commit». درمان: undo بزرگتر، UNDO_RETENTION کافی، و کوتاه/مجموعهای کردن کوئریها. PostgreSQL روی primary نمیتواند چنین خطایی بدهد؛ بهجایش کوئری طولانی جلوی VACUUM را میگیرد و جدول bloat میکند. اما روی standby دقیقاً معادلش را دارد: ERROR: canceling statement due to conflict with recovery که با max_standby_streaming_delay و hot_standby_feedback تنظیم میشود — و روشن کردن feedback فقط درد را به شکل bloat به primary برمیگرداند. همان فیزیک، صورتحساب متفاوت.
Locking
هر دو موتور در سطح ردیف قفل میگذارند و در هر دو، نویسنده خواننده را بلاک نمیکند. deadlock خودکار تشخیص داده میشود و یکی از تراکنشها قربانی میشود (ORA-00060 / SQLSTATE 40P01).
-- مصرفکنندهٔ صف کلاسیک: کاری را بردار که کسی نگرفته
SELECT * FROM job_queue
WHERE status = 'READY' ORDER BY created_at
FETCH FIRST 10 ROWS ONLY
FOR UPDATE SKIP LOCKED;
SET lock_timeout = '3s'; -- سریع شکست بخور، معلق نمان
SELECT * FROM accounts WHERE id = $1 FOR UPDATE NOWAIT;
SELECT pid, wait_event, pg_blocking_pids(pid), query
FROM pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0;SELECT * FROM job_queue
WHERE status = 'READY' ORDER BY created_at
FETCH FIRST 10 ROWS ONLY
FOR UPDATE SKIP LOCKED;
SELECT * FROM accounts WHERE id = :id FOR UPDATE NOWAIT;
SELECT * FROM accounts WHERE id = :id FOR UPDATE WAIT 3;
SELECT sid, blocking_session, event, sql_id
FROM v$session WHERE blocking_session IS NOT NULL;FOR UPDATE SKIP LOCKED (از Oracle 11g و PostgreSQL 9.5) روشی است که با آن یک صف کار درست روی دیتابیس میسازی: هر worker ردیفهایی را برمیدارد که کسی نگرفته، بدون طوفان polling و بدون پردازش تکراری. قبل از رفتن سراغ Kafka این را امتحان کن — یک جدول بهعلاوهٔ SKIP LOCKED حجمهای شگفتآوری را میبرد. تفاوتها در انتظار است: Oracle WAIT n درونخطی دارد، PostgreSQL lock_timeout سطح session؛ و PostgreSQL در READ COMMITTED بعد از انتظار جدیدترین نسخهٔ ردیف را دوباره میخواند، که میتواند بیصدا معنای شرط تو را عوض کند.
نمودار زیر چرخهٔ حیات یک ردیف در تراکنش را نشان میدهد | The lifecycle of a row under a transaction:
stateDiagram-v2
[*] --> Clean
Clean --> Modified: UPDATE — Oracle keeps the old image in undo, PG writes a new tuple version
Modified --> Committed: COMMIT — SCN or LSN advances and the log is flushed
Modified --> RolledBack: ROLLBACK
Committed --> Cleaned: undo recycled / VACUUM removes dead tuple
Cleaned --> [*]
RolledBack --> Clean
هر دو MVCC دارند اما سطحها فرق میکند. Oracle عملاً دو سطح دارد: READ COMMITTED (پیشفرض) و SERIALIZABLE — سطح مستقل REPEATABLE READ ندارد، و SERIALIZABLE اش snapshot isolation است که جلوی write skew را نمیگیرد. PostgreSQL هر سه را دارد و SERIALIZABLE را با SSI پیاده کرده که واقعاً write skew را میگیرد و تراکنش را با SQLSTATE 40001 میبندد. نتیجهٔ عملی: در PostgreSQL SERIALIZABLE یک ابزار واقعی درستی است اما اپلیکیشن باید retry کند؛ در Oracle همان محافظت باید از قفل صریح یا constraint بیاید.
DO $$
DECLARE attempt int := 0;
BEGIN
LOOP
BEGIN
PERFORM transfer_funds(1, 2, 100);
EXIT;
EXCEPTION
WHEN serialization_failure OR deadlock_detected THEN -- 40001 / 40P01
attempt := attempt + 1;
EXIT WHEN attempt > 3;
END;
END LOOP;
END $$;DECLARE
attempt PLS_INTEGER := 0;
cannot_serialize EXCEPTION;
PRAGMA EXCEPTION_INIT(cannot_serialize, -8177); -- ORA-08177
deadlock EXCEPTION;
PRAGMA EXCEPTION_INIT(deadlock, -60); -- ORA-00060
BEGIN
LOOP
BEGIN
transfer_funds(1, 2, 100);
EXIT;
EXCEPTION
WHEN cannot_serialize OR deadlock THEN
ROLLBACK;
attempt := attempt + 1;
EXIT WHEN attempt > 3;
END;
END LOOP;
END;
/صورتحساب نگهداری: بازچرخانی undo در برابر VACUUM
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC FETCH FIRST 5 ROWS ONLY;
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02,
fillfactor = 85);
-- بازسازی جدول bloat شده بهصورت online با extension بهنام pg_repackSELECT tuned_undoretention, maxquerylen
FROM v$undostat ORDER BY begin_time DESC FETCH FIRST 5 ROWS ONLY;
ALTER TABLE orders ENABLE ROW MOVEMENT;
ALTER TABLE orders SHRINK SPACE COMPACT; -- بازپسگیری فضا بهصورت onlineپاسخ: با چندنسخهای بودن بهعلاوهٔ یک ساعت نسخه. در Oracle هر commit SCN را جلو میبرد؛ کوئری SCN شروعش را ثبت میکند و هر بلاکی را که بعد از آن تغییر کرده از undo segment بازسازی میکند (consistent read). در PostgreSQL هر نسخهٔ ردیف xmin/xmax دارد و هر تراکنش یک snapshot از transaction id های در جریان میگیرد؛ نسخهای قابلرؤیت است که xmin اش commit و قابلرؤیت باشد و xmax اش نه. هر دو خاصیت طلایی را میدهند — خواننده و نویسنده هم را بلاک نمیکنند — اما صورتحسابها فرق دارد: Oracle ریسک ORA-01555 دارد وقتی undo زود بازچرخانی شود، PostgreSQL ریسک bloat دارد وقتی نسخههای قدیمی vacuum نشوند.
پاسخ: redo برای جلو بردن است و undo برای عقب بردن. redo همهٔ تغییرات را ثبت میکند تا instanceِ مرده هنگام recovery کارِ commit شده را roll-forward کند؛ پس ضامن durability است. undo تصویر پیشازتغییر را نگه میدارد تا هم ROLLBACK ممکن باشد هم خواننده نسخهٔ سازگار قدیمی را ببیند. در PostgreSQL WAL معادل redo است، اما undo وجود ندارد: rollback عملاً رایگان است چون نسخههای جدید هرگز قابلرؤیت نمیشوند، و نسخههای قدیمی تا VACUUM در جدول میمانند. دقیقاً به همین دلیل Oracle ORA-01555 دارد و PostgreSQL bloat و تنظیم autovacuum. ادعای اینکه WAL هر دو کار را میکند، همان لغزش کلاسیک مصاحبه است.
پاسخ: بهخاطر پیادهسازی MVCC. Oracle نسخهٔ قدیمی را در undo segment جدا نگه میدارد، پس با پایان تراکنش آن فضا قابل بازچرخانی است و بلاکهای جدول تمیز میمانند. PostgreSQL هر UPDATE را بهصورت نسخهٔ جدید ردیف در همان جدول پیاده میکند و dead tuple تا VACUUM میماند. اگر autovacuum عقب بیفتد، جدول و ایندکس bloat میکنند، پلنها بد میشوند و در بدترین حالت به transaction ID wraparound و vacuum اضطراری نزدیک میشوی. هیچکدام مطلقاً بهتر نیست: Oracle با ORA-01555 و اندازهگذاری undo هزینه میدهد، PostgreSQL با تنظیم autovacuum، fillfactor، HOT update و گاهی pg_repack.
پاسخ: هر COMMIT یک flush همگام از لاگ را مجبور میکند (LGWR در Oracle، WAL writer در PostgreSQL)، پس commit ردیفی یعنی هزاران I/O همگام؛ در Oracle الگوی «fetch across commit» را هم میسازد که ORA-01555 را دعوت میکند. کار را در دستههای منطقی (مثلاً هر ۱۰۰۰ ردیف) commit کن و برای درج انبوه از JDBC batch (addBatch/executeBatch) یا FORALL استفاده کن. سر دیگر طیف هم بد است: یک تراکنش غولپیکر که ساعتها باز میماند در Oracle undo را نگه میدارد و در PostgreSQL vacuum را میبندد — و session طولانیِ idle in transaction رایجترین علت bloat افسارگسیخته آنجاست.
پاسخ: DELETE یک DML است: ردیفبهردیف، undo/WAL کامل، قابل rollback، فعالکنندهٔ trigger — روی جدول بزرگ کند، و در PostgreSQL dead tuple هایی میگذارد که فقط VACUUM جمعشان میکند. TRUNCATE فضا را یکجا آزاد میکند، trigger ردیفی فعال نمیکند و تقریباً هیچ لاگی تولید نمیکند. تفاوت گویشی: در Oracle TRUNCATE یک DDL با commit ضمنی است و قابل rollback نیست؛ در PostgreSQL TRUNCATE تراکنشی است — میتوانی داخل تراکنش truncate کنی و rollback بزنی — هرچند قفل ACCESS EXCLUSIVE میگیرد. DROP خود شیء را حذف میکند. برای پاکسازی دورهای، بهترین پاسخ در هر دو موتور سطح پارتیشن است: ALTER TABLE ... DROP/TRUNCATE PARTITION در Oracle و DETACH PARTITION CONCURRENTLY بهعلاوهٔ DROP TABLE در PostgreSQL.
۸) معماری کلان: RAC، Data Guard، Exadata و Multitenant — و پاسخهای PostgreSQL
اینجا جایی است که سازمانها میلیونها دلار خرج میکنند. هر قطعه نقش واقعی خودش را دارد — و یک همتای متنباز، یا یک شکاف صادقانه.
flowchart TB
App[Application Tier] --> SCAN[RAC SCAN Listener]
SCAN --> N1[RAC Node 1]
SCAN --> N2[RAC Node 2]
N1 --> Storage[(Shared Storage / Exadata)]
N2 --> Storage
Storage -. redo shipping .-> DG[Data Guard Standby]
DG --> DR[(DR Site Storage)]
پشتهٔ PostgreSQL همان مسائل را با توپولوژی shared-nothing و یک cluster manager حل میکند | The PostgreSQL stack solves the same problems shared-nothing:
flowchart TB
App2[Application Tier] --> HA[HAProxy / pgBouncer]
HA --> P[(Primary)]
HA --> R1[(Hot Standby 1)]
P -. streaming WAL .-> R1
P -. streaming WAL .-> R2[(Hot Standby 2)]
Pat[Patroni + etcd] --- P
Pat --- R1
RAC — دسترسپذیری، و مرزهای مقیاس
در RAC، چند instance به یک دیتابیس روی storage مشترک وصل میشوند. اگر یک node بمیرد بقیه سرویس میدهند؛ همهٔ node ها یک buffer cache منطقی مشترک دارند و Cache Fusion بلاکها را روی interconnect جابهجا میکند.
«هرچه node بیشتر، سریعتر» غلط است. اگر یک بلاک داغ — یک شمارندهٔ پرترافیک یا یک sequence با NOCACHE — همزمان روی چند node آپدیت شود، آن بلاک مدام روی interconnect پاس داده میشود (gc buffer busy) و throughput از حالت تکnode هم پایینتر میرود. RAC برای HA و بارهایی که تمیز پارتیشن میشوند عالی است، اما طراحی میخواهد. مشکل مقیاسپذیری نوشتن را حل نمیکند.
کلاستر PostgreSQL با storage و cache مشترک وجود ندارد. پاسخ PostgreSQL به «یک node کافی نیست» این است: خواندن را با hot standby مقیاس بده، نوشتن را با sharding (Citus، PostgreSQL را به یک ذخیرهساز جدول توزیعشده تبدیل میکند)، یا در یک fork ابری compute را از storage جدا کن (Aurora، AlloyDB، Neon). مزیت: هرگز با Cache Fusion و تأخیر interconnect نمیجنگی. عیب: یک node نویسنده سقف واقعی است و failover یک promotion است (چند ثانیه downtime) نه بقا (بدون downtime). وقتی مصاحبهگر میپرسد «RAC در PostgreSQL چیست؟»، پاسخ درست این است: «وجود ندارد، و بهجایش اینها را استفاده میکنیم».
Data Guard در برابر streaming replication
Data Guard یک یا چند standby نگه میدارد و redo میفرستد؛ PostgreSQL WAL را به hot standby ها میفرستد. همان دستگیرهٔ «هرگز داده از دست نده» در برابر «تأخیر commit» در هر دو هست:
ALTER SYSTEM SET synchronous_standby_names = 'FIRST 1 (standby1, standby2)';
ALTER SYSTEM SET synchronous_commit = 'remote_apply'; -- یا remote_write/on
SELECT pg_reload_conf();
SELECT application_name, state, sync_state,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
FROM pg_stat_replication;ALTER DATABASE SET STANDBY DATABASE TO MAXIMIZE AVAILABILITY;
ALTER SYSTEM SET log_archive_dest_2 =
'SERVICE=stby SYNC AFFIRM VALID_FOR=(ONLINE_LOGFILES,PRIMARY_ROLE)
DB_UNIQUE_NAME=stby';
SELECT name, value, time_computed FROM v$dataguard_stats
WHERE name IN ('transport lag', 'apply lag');قاطی کردن این دو اشتباه کلاسیک مصاحبه است. RAC محافظت در برابر خرابی سرور در یک دیتاسنتر است؛ همه به یک storage وصلاند، پس اگر storage یا سایت برود RAC نجاتت نمیدهد. Data Guard محافظت در برابر خرابی کل سایت است با یک کپی فیزیکی جدا، معمولاً در منطقهٔ دیگر. سازمانهای جدی هر دو را دارند. نگاشت PostgreSQL صادقانه اما ناقص است: streaming replication همگام ≈ Data Guard در حالت Maximum Availability، یک hot standby فقطخواندنی ≈ Active Data Guard، و Patroni + etcd ≈ همان بخش Fast-Start Failover در Data Guard Broker. هیچچیز به RAC نگاشته نمیشود.
تاریخاً هر مصرفکنندهٔ logical replication (یک خط CDC، یک connector مثل Debezium) با هر failover میشکست، چون replication slot فقط روی primary وجود داشت. PostgreSQL 17 میگذارد slot را با failover = true بسازی و با standby همگام کنی (sync_replication_slots = on یا تابع pg_sync_replication_slots())، پس مصرفکنندههای logical از promotion جان سالم به در میبرند؛ pg_createsubscriber هم یک standby فیزیکی را بدون کپی دوبارهٔ داده به subscriber منطقی تبدیل میکند. اگر معماریات تغییرات را از دیتابیس بیرون میریزد، همین دو ویژگی تعیین میکند failover یک شانهبالاانداختن باشد یا یک incident.
Exadata — سختافزار مهندسیشده
Exadata یک engineered system است: سرورهای دیتابیس، سرورهای storage هوشمند و یک شبکهٔ پرسرعت. ویژگی کلیدیاش Smart Scan است — خود storage cell ها فیلترینگ و projection را انجام میدهند و فقط ردیفهای مربوط را برمیگردانند — بهعلاوهٔ storage index و Hybrid Columnar Compression. برای PostgreSQL چیزی معادلش نمیتوانی بخری؛ نزدیکترین ایدهها extension های columnar (مثل Citus columnar) و fork های ابریاند که فیلترینگ را به لایهٔ storage هل میدهند.
Multitenant: CDB و PDB
از 12c، Oracle از معماری multitenant استفاده میکند: یک CDB که چند PDB را میزبانی میکند. هر PDB از دید اپلیکیشن یک دیتابیس مستقل است اما background process ها و redo را با CDB به اشتراک میگذارد و میتواند unplug و در جای دیگری plug شود.
CREATE DATABASE tenant_a;
\c tenant_a
CREATE SCHEMA billing;
-- کوئری بین دو دیتابیس ممکن نیست: به extension نیاز داری
CREATE EXTENSION IF NOT EXISTS postgres_fdw;
SELECT * FROM dblink('dbname=tenant_b', 'SELECT id FROM invoices')
AS t(id bigint);CREATE PLUGGABLE DATABASE tenant_a
ADMIN USER ta_admin IDENTIFIED BY "***"
FILE_NAME_CONVERT = ('/pdbseed/', '/tenant_a/');
ALTER PLUGGABLE DATABASE tenant_a OPEN;
ALTER SESSION SET CONTAINER = tenant_a;
-- دسترسی بین PDB ها هم به link نیاز دارد
CREATE DATABASE LINK tb CONNECT TO app IDENTIFIED BY "***" USING 'tenant_b';
SELECT id FROM invoices@tb;هر دو موتور همان سه سطح را با نامها و هزینههای متفاوت میدهند. schema ارزان است و میگذارد آزادانه بین tenant ها join بزنی — برای یک اپلیکیشن عالی، برای multi-tenancy سختگیرانه خطرناک. یک database در PostgreSQL یا یک PDB در Oracle مرز سخت است: بدون link/FDW نمیشود join زد، کاربر و backup جداست و میشود مستقل حذفش کرد. یک instance/CDB جدا بیشترین حافظه و کار ادمین را میخواهد. برتری Oracle این است که PDB ها حافظه و پروسههای یک instance را شریکاند، پس صدها PDB عملی است و کلون کردن یکی برای محیط تست چند ثانیه طول میکشد. تله: بعد از تعداد کمی PDB در هر CDB به لایسنس Multitenant option نیاز داری.
پاسخ: RAC مثل چند موتور روی یک هواپیماست: یک موتور از کار بیفتد هواپیما به پرواز ادامه میدهد — اما همه هنوز سوار یک بدنهاند (یک storage، یک سایت). Data Guard مثل هواپیمای دومی است که در فرودگاهی دیگر سوختگیریشده و آماده است: اگر کل فرودگاه اول از کار بیفتد، با هواپیمای دوم پرواز میکنی. RAC برای در دسترس ماندن روزمره است؛ Data Guard برای بقا بعد از فاجعه. شرکت بالغ هر دو را دارد.
پاسخ: Active Data Guard ← hot standby با streaming replication (دستگیرهٔ کوئری در برابر bloat همان hot_standby_feedback است). Data Guard Broker / Fast-Start Failover ← Patroni با etcd/Consul بهعلاوهٔ HAProxy یا یک virtual IP برای مسیریابی کلاینت (repmgr و pg_auto_failover جایگزیناند). حالتهای protection ← synchronous_commit (on، remote_write، remote_apply) با synchronous_standby_names. RMAN ← pg_basebackup/pgBackRest/Barman با آرشیو WAL و PITR. GoldenGate ← logical replication یا Debezium. معادل ندارد: RAC، Smart Scan در Exadata، Flashback Database، و ابزارهای Diagnostics Pack — که پاسخ صادقانه برایشان pg_stat_statements بهعلاوهٔ ابزار بیرونی است، نه جایگزین یکبهیک.
۹) تصمیم مهندسی: Oracle یا PostgreSQL؟
این پرسش امروز از همیشه داغتر است، چون هزینهٔ license و ممیزی Oracle سنگین است و PostgreSQL بهشدت بالغ شده.
| بُعد | Oracle | PostgreSQL |
|---|---|---|
| هزینه | license گران + option های جدا (Partitioning, RAC, Diagnostics Pack…) | متنباز، رایگان |
| MVCC | undo segment جدا؛ بدون bloat، بدون VACUUM | نسخههای قدیمی درون جدول؛ نیاز به VACUUM |
| زبان رویهای | PL/SQL بسیار بالغ، با package | PL/pgSQL + PL/Python و PL/V8؛ بدون package |
| پایداری پلن | SQL Plan Baseline، hint، Adaptive Cursor Sharing | plan_cache_mode، آمار، pg_hint_plan (extension) |
| ابزار عیبیابی | AWR/ASH/SQL Monitor (لایسنسدار) | pg_stat_statements، pg_stat_io، auto_explain (رایگان) |
| HA/DR | RAC + Data Guard (بلوغ بالا) | streaming/logical replication + Patroni |
| پارتیشنبندی | پیشرفته (interval، reference، ایندکس global) | declarative از v10، فقط ایندکس local |
| Materialized View | refresh افزایشی + query rewrite | فقط refresh کامل، بدون rewrite خودکار |
| سفر در زمان | Flashback Query/Table/Database | فقط restore با PITR |
| Job ها | DBMS_SCHEDULER داخلی |
pg_cron یا scheduler بیرونی |
| Extension | محدود | غنی (PostGIS, pgvector, TimescaleDB, Citus…) |
تلههای معنایی که واقعاً مهاجرت را میشکنند
هر خط زیر برای یک پروژهٔ واقعی هفتهها هزینه داشته است:
-- ۱) رشتهٔ خالی NULL نیست
SELECT '' IS NULL AS empty_is_null; -- false
-- ۲) مدیریت NULL و تجمیع رشته
SELECT COALESCE(bonus, 0), STRING_AGG(name, ',' ORDER BY name) FROM emp;
-- ۳) now() یک timestamptz است؛ حساب تاریخ با interval
SELECT now(), CURRENT_DATE + INTERVAL '30 days',
to_char(now(), 'YYYY-MM-DD');
-- ۴) به DUAL نیازی نیست
SELECT 1 + 1 AS result;
-- ۵) شناسههای بدون کوتیشن به حروف کوچک تبدیل میشوند
CREATE TABLE Foo (Id int); -- میشود foo(id)
SELECT * FROM FOO; -- کار میکند؛ SELECT * FROM "Foo" خطا میدهد-- ۱) رشتهٔ خالی NULL است — خطرناکترین رفتار Oracle
SELECT CASE WHEN '' IS NULL THEN 'true' ELSE 'false' END FROM dual; -- true
-- ۲) مدیریت NULL و تجمیع رشته
SELECT NVL(bonus, 0), LISTAGG(name, ',') WITHIN GROUP (ORDER BY name) FROM emp;
-- ۳) SYSDATE یک DATE (با زمان) است؛ حساب بر حسب روز
SELECT SYSDATE, TRUNC(SYSDATE) + 30,
TO_CHAR(SYSDATE, 'YYYY-MM-DD') FROM dual;
-- ۴) قبل از 23ai به DUAL نیاز است
SELECT 1 + 1 AS result FROM dual;
-- ۵) شناسههای بدون کوتیشن به حروف بزرگ تبدیل میشوند
CREATE TABLE Foo (Id NUMBER); -- میشود FOO(ID)
SELECT * FROM foo; -- کار میکند؛ SELECT * FROM "Foo" خطا میدهددر Oracle '' و NULL یک مقدارند: WHERE name = '' هیچ ردیفی برنمیگرداند، ستون NOT NULL رشتهٔ خالی را رد میکند و 'a' || '' = 'a'. در PostgreSQL '' یک رشتهٔ واقعی با طول صفر است که NULL نیست، پس همان شرط ناگهان ردیف برمیگرداند و COALESCE(col, 'x') دیگر فعال نمیشود. کد Java ای که فیلد خالی را به "" نگاشت میکند روی هر موتور رفتار متفاوتی دارد. هیچ سوییچ تنظیماتی برای این وجود ندارد — یا همهٔ شرطهای متأثر را پیدا میکنی، یا یک سال گزارشهای ظریفاً غلط تحویل میدهی.
Oracle شناسهٔ بدون کوتیشن را به حروف بزرگ و PostgreSQL به حروف کوچک تبدیل میکند و هر دو شناسهٔ داخل کوتیشن را عیناً میگیرند. ابزاری که "OrderId" تولید میکند ستونی میسازد که تا ابد در هر دو موتور باید داخل کوتیشن بیاید. قانونی که از همهچیز جان سالم به در میبرد: lower_snake_case و هرگز کوتیشن نگذار. برای تفاوت معنای طول در VARCHAR2(n) (بایت در برابر کاراکتر، یعنی VARCHAR2(10 CHAR)) در مقابل varchar(n) در PostgreSQL که همیشه کاراکتر است هم بودجه بگذار — دادهٔ فارسی، عربی و emoji مدام از ستونهای بایتمحور Oracle سرریز میکند.
Oracle را انتخاب کن وقتی: بار OLTP بسیار سنگین با همزمانی شدید داری که MVCC مبتنی بر undo کاملاً از bloat نجاتش میدهد؛ HA محلی سختگیرانهای میخواهی که فقط RAC برآورده میکند؛ اکوسیستم شرکت (ERP، Fusion، GoldenGate) از قبل دور Oracle بسته شده؛ انبار دادهٔ بزرگ روی Exadata داری؛ یا عملیاتت به بلوغ Flashback و Data Guard وابسته است. PostgreSQL را انتخاب کن وقتی: میخواهی هزینهٔ license صفر شود؛ به extension ها نیاز داری (PostGIS، pgvector، TimescaleDB، Citus)؛ استقرار cloud-portable و container-friendly میخواهی؛ یا تیم میخواهد از vendor lock-in بیرون بیاید. واقعیت بازار: بسیاری از سازمانها برای صرفهجویی از Oracle به PostgreSQL مهاجرت میکنند و PL/SQL سنگین بهمراتب دردناکترین بخش است. بازنویسی هزاران خط بهعلاوهٔ تست رگرسیون ماهها طول میکشد، نه هفتهها.
پاسخ: اول، دارایی PL/SQL: پکیجها، trigger ها و منطق batch باید به PL/pgSQL بازنویسی شوند و بعضی ساختارها اصلاً معادل ندارند (PRAGMA AUTONOMOUS_TRANSACTION، FORALL ... SAVE EXCEPTIONS، state پکیج، DBMS_SCHEDULER). دوم، معناشناسی: رشتهٔ خالی برابر NULL، NVL/LISTAGG/DECODE/SYSDATE/DUAL، تبدیل حروف شناسهها، و اینکه SELECT INTO خطای NO_DATA_FOUND نمیدهد. سوم، ویژگیهای سازمانی: RAC، Flashback، query rewrite در MV و ایندکس global پارتیشنی جایگزین یکبهیک ندارند. چهارم، عملیات: autovacuum و مدیریت bloat برای تیم Oracle رشتهای کاملاً تازه است و connection pooling اجباری میشود چون هر backend یک پروسه است. پنجم، کارایی: همهٔ پلنها و ایندکسها باید دوباره تنظیم شوند. رویکرد درست: مهاجرت مرحلهای، ora2pg برای بخش مکانیکی، یک فاز اجرای موازی برای مقایسهٔ خروجیها، و هرگز big-bang.
پاسخ: PostgreSQL در توسعهپذیری برنده است (PostGIS، pgvector، TimescaleDB، Citus، type و index access method سفارشی)، در هزینه و پرتابل بودن (بدون license، همان موتور از کانتینر لپتاپ تا هر ابری، بدون ریسک ممیزی) و در راحتی توسعهدهنده (partial index، ایندکس با INCLUDE، boolean واقعی، DDL تراکنشی — میتوانی CREATE TABLE را rollback کنی). Oracle در عملیات MVCC برنده است (undo یعنی بدون bloat، بدون VACUUM، بدون نگرانی wraparound)، در عمق HA (RAC + Data Guard + Flashback Database) و در بلوغ ابزار داخلی (AWR/ASH/SQL Monitor، DBMS_SCHEDULER، پارتیشنبندی پیشرفته، query rewrite در MV). پاسخ دادن با ویژگیهای مشخص در هر دو جهت، همان چیزی است که مهندس را از طرفدار جدا میکند.
پاسخ: روش یکی است، ابزار فرق میکند. (۱) پلن واقعی را بگیر — DBMS_XPLAN.DISPLAY_CURSOR(..., 'ALLSTATS LAST') در Oracle، EXPLAIN (ANALYZE, BUFFERS) یا auto_explain در PostgreSQL — و ردیف تخمینی را با واقعی مقایسه کن. (۲) بپرس آیا آمار تازه عوض شده: یک DBMS_STATS جدید یا یک ANALYZE از autovacuum میتواند پلن را برگرداند. (۳) به پلن وابسته به پارامتر شک کن: bind peeking بهعلاوهٔ skew در Oracle، سوییچ از custom به generic در PostgreSQL. (۴) به انتظارها نگاه کن: AWR/ASH؛ pg_stat_activity.wait_event، pg_locks، pg_stat_io. (۵) رشد یا bloat را بررسی کن: عبور از یک آستانه پلن را عوض کرده، ایندکسی unusable شده، یا autovacuum عقب افتاده و جدول دو برابر شده. راهحلها: SQL Plan Baseline یا قفل کردن آمار در Oracle؛ آمار چندستونی، ایندکس، یا بازنویسی شرط در PostgreSQL. کلید سنیور در هر دو یکی است: تغییر را پیدا کن، حدس نزن.
- PL/SQL و PL/pgSQL هر دو کد را کنار داده میبرند — برای batch/ETL طلاست و برای منطق کسبوکارِ scale-out اشتباه.
BULK COLLECT+FORALLباLIMITقاتل context switch در Oracle است؛ معادلش در PostgreSQL ماندن در فضای مجموعهای با آرایه وunnestاست. حلقهٔ ردیفبهردیف و هر catch-all بلعیدهشده را بکش. - مینهای port کردن کد رویهای:
SELECT INTO STRICT، نبود autonomous transaction، نبودSAVE EXCEPTIONS، ممنوعیتCOMMITداخل بلاک دارای exception handler، و تبدیل package به schema. - ROWNUM قبل از ORDER BY، ROW_NUMBER() بعد از آن، ROWID/ctid آدرس فیزیکی — و
ctidبسیار ناپایدارتر است. برای پرتابل بودنFETCH FIRSTو برای عمق، keyset pagination. - کارایی در هر دو یعنی CBO + آمار سالم + bind variable؛ تفاوت عمیق این است که Oracle پلن را در library cache مشترک میکند و PostgreSQL بهازای هر backend پلن میسازد. آمار چندستونی را یاد بگیر و قبل از دست زدن به hint، ردیف تخمینی را با واقعی مقایسه کن.
- ایندکس: leading column، covering (با
INCLUDEدر برابر ستونهای انتهایی)، bitmap فقط برای جدولهای read-only در Oracle، BRIN فقط برای دادهٔ مرتب در PostgreSQL، partial index بهعنوان ابرقدرت PostgreSQL — و یادت باشد index-only scan به visibility map وابسته است. - روی کلیدی پارتیشن کن که با
WHEREهایت بخواند؛ Oracle interval partitioning و ایندکس global اضافه میکند، PostgreSQL پارتیشنبندی را رایگان میدهد اما فقط با ایندکس local. - تراکنش: undo/redo/SCN در برابر WAL/xmin-xmax/LSN. در هر دو خواننده نویسنده را بلاک نمیکند؛ صورتحسابها
ORA-01555در برابر bloat و VACUUM است.SKIP LOCKEDابرقدرت مشترک است. - معماری: RAC=HA محلی، Data Guard=DR، Exadata=Smart Scan، PDB=consolidation؛ پاسخ PostgreSQL، streaming replication + Patroni است، sharding با Citus، و اصلاً بدون RAC.
- Oracle یا PostgreSQL: عمق سازمانی و بلوغ عملیاتی در برابر هزینهٔ صفر و اکوسیستم باز. سنگینترین ریسکهای مهاجرت بازنویسی PL/SQL و معناشناسی است — رشتهٔ خالی، تبدیل حروف،
SELECT INTO، VACUUM. هرگز big-bang نکن.
Many of us treat Oracle like an expensive black box: we configure a DataSource in Spring, write a few @Repository classes, and hope for the best. But the difference between a four-to-five-year engineer and a senior lives right here: the senior knows what happens behind that ResultSet when a query goes slow — parsing, optimization, reading blocks from the buffer cache, building a consistent version of the data using undo, and the locks quietly being taken.
This chapter takes you from zero to the point where you can sit at a senior interview table and talk about the optimizer, bind variables, partitioning and read consistency as if you had lived with them for years. Every idea starts with an analogy, then the concept, then real code, then "where does this blow up in production?"
And a second thread runs through all of it: almost nobody spends a career on one engine. So every SQL snippet here appears in both dialects — PostgreSQL first, Oracle second. Learning the pair is not twice the work; it is the fastest way to see which parts are real database theory and which are one vendor's spelling.
- PL/SQL from the ground up — anonymous blocks, procedures/functions/packages, cursors,
BULK COLLECT/FORALL, exceptions, triggers — each mapped onto PL/pgSQL. - The confusing trio: ROWNUM vs ROW_NUMBER() vs ROWID (and PostgreSQL's
ctid). - The heart of performance: the cost-based optimizer, statistics,
EXPLAIN PLAN/AWR vsEXPLAIN (ANALYZE, BUFFERS)/pg_stat_statements, and bind variables & the shared pool. - Indexes (B-tree, bitmap, function-based, composite, covering, partial) and partitioning.
MERGEvsON CONFLICT, sequences & identity, materialized views.- The transaction engine: undo/redo/SCN and their PostgreSQL twins WAL/xmin-xmax/VACUUM.
- The big picture: RAC, Data Guard, Exadata, PDB/CDB vs streaming replication, Patroni, Citus.
- The engineering decision: Oracle or PostgreSQL? — plus the migration traps that actually cost money.
Most shops still run Oracle 19c; this long-term release (Premier Support to end of 2029, Extended to end of 2032) is the backbone of production. 23ai (shipped as 23c in 2023, renamed in 2024) landed first on OCI/Exadata, and Oracle then folded that family into Oracle AI Database 26ai — the same 23.x code line, versioned 23.26.1.0.0, generally available on-prem for Linux x86-64 in 2026. On the other side you will meet PostgreSQL 16 and 17 (17 brought MERGE ... RETURNING and failover replication slots; 16 brought pg_stat_io and EXPLAIN (GENERIC_PLAN)). Know the 23ai/26ai features — native SQL BOOLEAN, JSON Relational Duality, AI Vector Search, SELECT without FROM DUAL — but never assume they exist on the server in front of you.
1) PL/SQL: the language that "moves the code to the data"
Imagine you run a restaurant. If you dispatch a delivery scooter for every single vegetable, the round-trip cost kills you — even if the vegetable is cheap. Your Java app (one query at a time over JDBC) is that scooter, the database is the warehouse. PL/SQL means putting a chef inside the warehouse: run the logic next to the data and return only the final result.
PL/SQL is a procedural language running inside the Oracle engine, with SQL as a natural part of it. PostgreSQL's equivalent is PL/pgSQL — deliberately Ada-flavoured in the same way, so the two look like cousins. The smallest unit is the anonymous block; PostgreSQL calls it a DO block.
DO $$
DECLARE
v_total numeric := 0;
BEGIN
SELECT COALESCE(SUM(amount), 0) INTO v_total
FROM payments WHERE status = 'PAID';
RAISE NOTICE 'Total = %', v_total;
END $$;DECLARE
v_total NUMBER := 0;
BEGIN
SELECT NVL(SUM(amount), 0) INTO v_total
FROM payments WHERE status = 'PAID';
DBMS_OUTPUT.PUT_LINE('Total = ' || v_total);
EXCEPTION
WHEN NO_DATA_FOUND THEN
DBMS_OUTPUT.PUT_LINE('No rows');
END;
/Three sections in both: DECLARE (variables), BEGIN...END (logic), EXCEPTION (errors). Oracle terminates the block with a lone /; PostgreSQL uses dollar quoting ($$ ... $$) so the parser ignores the inner semicolons.
In Oracle, SELECT ... INTO returning zero rows raises NO_DATA_FOUND and more than one row raises TOO_MANY_ROWS. In PL/pgSQL, plain SELECT ... INTO does neither: zero rows leaves the variable NULL and execution continues; extra rows are discarded silently. You only get Oracle behaviour with SELECT ... INTO STRICT. Countless ported procedures have quietly turned "record not found" into "process a NULL as data".
Procedure, Function, and most importantly: the Package
- Procedure: does a job, returns nothing (or via
OUTparams). - Function: returns a value and can be used inside a
SELECT. - Package: groups procedures/functions/types/constants — like a Java class. Two parts: the specification (public interface) and the body (implementation).
-- PostgreSQL has no packages: a schema is the closest grouping unit
CREATE SCHEMA IF NOT EXISTS billing;
CREATE OR REPLACE FUNCTION billing.invoice_total(p_invoice_id bigint)
RETURNS numeric LANGUAGE plpgsql STABLE AS $$
DECLARE v_sum numeric;
BEGIN
SELECT COALESCE(SUM(line_amount), 0) INTO v_sum
FROM invoice_lines WHERE invoice_id = p_invoice_id;
RETURN v_sum;
END $$;
CREATE OR REPLACE PROCEDURE billing.mark_paid(p_invoice_id bigint)
LANGUAGE plpgsql AS $$
BEGIN
UPDATE invoices SET status = 'PAID', paid_at = now()
WHERE invoice_id = p_invoice_id;
END $$;
CALL billing.mark_paid(42);CREATE OR REPLACE PACKAGE billing_pkg AS
FUNCTION invoice_total(p_invoice_id NUMBER) RETURN NUMBER;
PROCEDURE mark_paid(p_invoice_id NUMBER);
END billing_pkg;
/
CREATE OR REPLACE PACKAGE BODY billing_pkg AS
FUNCTION invoice_total(p_invoice_id NUMBER) RETURN NUMBER IS
v_sum NUMBER;
BEGIN
SELECT NVL(SUM(line_amount), 0) INTO v_sum
FROM invoice_lines WHERE invoice_id = p_invoice_id;
RETURN v_sum;
END;
PROCEDURE mark_paid(p_invoice_id NUMBER) IS
BEGIN
UPDATE invoices SET status = 'PAID', paid_at = SYSTIMESTAMP
WHERE invoice_id = p_invoice_id;
END;
END billing_pkg;
/
BEGIN billing_pkg.mark_paid(42); END;
/A package is not just tidiness. The first call in a session loads the whole thing into session memory (UGA) compiled, so later calls are faster; it can hold state (package variables alive until the session ends); and changing a package body does not invalidate dependent objects (unlike changing the spec), which means far less painful deployment.
Oracle's package gives four things at once: namespacing, a public/private split (anything not in the spec is private), session state and overloading. PostgreSQL gives namespacing via schemas and overloading via signatures, but has no public/private split and no package state — you emulate privacy with GRANT/REVOKE and naming conventions, and state with custom GUCs (set_config('app.tenant_id', …) / current_setting(...)). Note that PostgreSQL 11+ does mirror Oracle's split between FUNCTION (usable in SQL, no transaction control) and PROCEDURE (invoked by CALL, may COMMIT).
A package variable or a custom GUC lives on the physical connection, not on your request. With HikariCP, connection #7 hands you whatever tenant id the previous request left behind. If you use this pattern, reset it at the start of every transaction — prefer transaction-scoped set_config(..., true) in PostgreSQL and DBMS_SESSION.SET_CONTEXT with an explicit clear in Oracle. Cross-tenant leaks in production almost always trace back to exactly this.
From Spring you call stored code with SimpleJdbcCall, not hand-rolled SQL strings:
@Repository
public class BillingDao {
private final SimpleJdbcCall markPaidCall;
public BillingDao(JdbcTemplate jdbc) {
this.markPaidCall = new SimpleJdbcCall(jdbc)
.withCatalogName("BILLING_PKG") // Oracle: package = "catalog"
.withProcedureName("MARK_PAID");
// PostgreSQL: .withSchemaName("billing").withProcedureName("mark_paid")
}
public void markPaid(long invoiceId) {
markPaidCall.execute(Map.of("P_INVOICE_ID", invoiceId));
}
}
Answer: It runs the logic next to the data and removes network round-trips; for data-heavy work (batch, ETL, aggregation over millions of rows) the difference is dramatic. But it is bad for CPU-bound compute or anything that must scale horizontally: the database is the most expensive layer to scale out. Senior rule: logic tightly coupled to the data goes into the database; general business logic stays in the Java service. The same reasoning holds for PL/pgSQL, with one economic twist — Oracle CPU is licensed, so a for loop there literally costs money.
Answer: Privacy (the spec/body split disappears; you emulate it with schema privileges), session state (package variables have no equivalent — you move to GUCs or a temp table and must handle pooling), dependency granularity (Oracle keeps dependents valid when only the body changes; PostgreSQL doesn't even validate function bodies against dependencies by default, so breakage moves from deploy time to runtime), and per-session initialization blocks. In exchange you get atomic CREATE OR REPLACE deploys with no INVALID objects to babysit, and multiple languages in one schema.
Cursors and cursor FOR loops
To walk a result set row by row you need a cursor. The safest form is the cursor FOR loop, which handles open/fetch/close for you — nearly identical in both engines:
DO $$
DECLARE rec RECORD;
BEGIN
FOR rec IN
SELECT invoice_id, customer_id FROM invoices WHERE status = 'DUE'
LOOP
UPDATE customers SET dunning_flag = true WHERE id = rec.customer_id;
END LOOP;
END $$;BEGIN
FOR rec IN (SELECT invoice_id, customer_id
FROM invoices WHERE status = 'DUE') LOOP
UPDATE customers SET dunning_flag = 'Y' WHERE id = rec.customer_id;
END LOOP;
END;
/This looks elegant and is a performance killer on both engines: every pass switches between the procedural engine and the SQL engine. A hundred thousand rows means a hundred thousand context switches — row-by-row is slow-by-slow. Fix it with a single set-based UPDATE ... WHERE, or with BULK COLLECT/FORALL on Oracle and array-driven statements on PostgreSQL. Note the dialect tell above: Oracle had no SQL BOOLEAN before 23ai, which is why legacy schemas carry 'Y'/'N' flags where PostgreSQL uses a real boolean.
BULK COLLECT and FORALL: breaking the context-switch wall
BULK COLLECT pulls many rows into a collection in one round-trip; FORALL sends many DML operations in one round-trip. PostgreSQL has no FORALL — the idiom there is to stay set-based, or pass an array and unnest it in one statement.
-- One set-based statement beats any loop
UPDATE invoices SET status = 'DUNNED' WHERE status = 'DUE';
-- From Java, N rows in one round-trip via an array parameter:
-- UPDATE invoices SET status='DUNNED' WHERE invoice_id = ANY($1::bigint[]);
-- Chunked variant, safe for concurrent workers
WITH batch AS (
SELECT invoice_id FROM invoices WHERE status = 'DUE'
ORDER BY invoice_id LIMIT 1000 FOR UPDATE SKIP LOCKED
)
UPDATE invoices i SET status = 'DUNNED'
FROM batch b WHERE i.invoice_id = b.invoice_id
RETURNING i.invoice_id;DECLARE
TYPE t_ids IS TABLE OF invoices.invoice_id%TYPE;
v_ids t_ids;
CURSOR c IS SELECT invoice_id FROM invoices WHERE status = 'DUE';
BEGIN
OPEN c;
LOOP
FETCH c BULK COLLECT INTO v_ids LIMIT 1000; -- LIMIT is critical
EXIT WHEN v_ids.COUNT = 0;
FORALL i IN 1 .. v_ids.COUNT
UPDATE invoices SET status = 'DUNNED' WHERE invoice_id = v_ids(i);
COMMIT; -- batched commit
EXIT WHEN v_ids.COUNT < 1000;
END LOOP;
CLOSE c;
END;
/BULK COLLECT without LIMIT pulls the entire result into PGA at once; on a ten-million-row table that is a memory explosion and ORA-04030. Always read in batches of 100–1000. PostgreSQL has the same disease in different clothes: a huge array built in PL/pgSQL, or an unbounded FOR rec IN SELECT, eats backend-local memory until the OOM killer ends the story. Same rule both sides: bound your batch size explicitly.
Oracle's SAVE EXCEPTIONS lets a bulk DML continue past failing rows and collect them in SQL%BULK_EXCEPTIONS (surfacing as ORA-24381) — invaluable in ETL. PostgreSQL has nothing like it: a statement either succeeds entirely or rolls back. Your options are pre-filtering the bad rows, ON CONFLICT DO NOTHING, or a row loop with a BEGIN ... EXCEPTION sub-block — and beware that in PostgreSQL every block with an EXCEPTION clause opens a subtransaction, which is not free: millions of them burn transaction ids and hurt performance.
For chunked processing with intermediate commits, both engines can do it in stored code — PostgreSQL only since v11, and with one sharp restriction:
CREATE OR REPLACE PROCEDURE purge_old_events(p_batch int DEFAULT 5000)
LANGUAGE plpgsql AS $$
DECLARE v_deleted int;
BEGIN
LOOP
DELETE FROM events WHERE ctid IN (
SELECT ctid FROM events
WHERE created_at < now() - interval '90 days' LIMIT p_batch);
GET DIAGNOSTICS v_deleted = ROW_COUNT;
EXIT WHEN v_deleted = 0;
COMMIT; -- allowed in a PROCEDURE, never in a FUNCTION
END LOOP;
END $$;
CALL purge_old_events(5000);CREATE OR REPLACE PROCEDURE purge_old_events(p_batch NUMBER DEFAULT 5000) IS
BEGIN
LOOP
DELETE FROM events
WHERE created_at < SYSDATE - 90 AND ROWNUM <= p_batch;
EXIT WHEN SQL%ROWCOUNT = 0;
COMMIT;
END LOOP;
END;
/
BEGIN purge_old_events(5000); END;
/Transaction control in PL/pgSQL works only in a PROCEDURE called by CALL (or a DO block), only with nothing else in the call chain, and never inside a block with an EXCEPTION section — you get cannot commit while a subtransaction is active. Oracle has no such restriction and additionally offers PRAGMA AUTONOMOUS_TRANSACTION for a genuinely independent transaction (the classic "log the failure even though we roll back"). PostgreSQL has no autonomous transactions; you emulate them with dblink/pg_background — a whole new backend per call. This single gap sinks many mechanical PL/SQL ports.
Exceptions
PL/SQL has predefined exceptions (NO_DATA_FOUND, TOO_MANY_ROWS, DUP_VAL_ON_INDEX) and RAISE_APPLICATION_ERROR(-20001, 'msg') for application errors, which reach Java as a SQLException with error code -20001. PL/pgSQL uses SQLSTATE-based conditions and RAISE ... USING ERRCODE:
CREATE OR REPLACE FUNCTION billing.charge(p_id bigint, p_amount numeric)
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'amount must be positive: %', p_amount
USING ERRCODE = 'P0001';
END IF;
INSERT INTO charges(invoice_id, amount) VALUES (p_id, p_amount);
EXCEPTION
WHEN unique_violation THEN -- SQLSTATE 23505
RAISE NOTICE 'already charged: %', p_id;
WHEN no_data_found THEN
RAISE; -- re-raise, never swallow
END $$;CREATE OR REPLACE PROCEDURE charge(p_id NUMBER, p_amount NUMBER) IS
BEGIN
IF p_amount <= 0 THEN
RAISE_APPLICATION_ERROR(-20001, 'amount must be positive: ' || p_amount);
END IF;
INSERT INTO charges(invoice_id, amount) VALUES (p_id, p_amount);
EXCEPTION
WHEN DUP_VAL_ON_INDEX THEN -- ORA-00001
DBMS_OUTPUT.PUT_LINE('already charged: ' || p_id);
WHEN NO_DATA_FOUND THEN
RAISE; -- re-raise, never swallow
END;
/EXCEPTION WHEN OTHERS THEN NULL; swallows the error and makes the bug invisible — the worst thing you can do. The PL/pgSQL twin WHEN others THEN NULL; is just as criminal with an extra sting: it also ends the subtransaction, so partial work vanishes without a trace. If you catch the catch-all, log it and RAISE again. In code review, a bare catch-all with no re-raise is a red flag.
getErrorCode() returns the vendor number (1 for Oracle's unique violation, 20001 for your RAISE_APPLICATION_ERROR) and is meaningless on PostgreSQL. getSQLState() returns the standard code: 23505 unique violation, 40001 serialization failure, 40P01/60 deadlock. Spring's SQLExceptionTranslator does exactly this and hands you DuplicateKeyException, DeadlockLoserDataAccessException — engine-independent. Code that switches on getErrorCode() will not survive a migration.
Triggers
A trigger runs automatically before/after INSERT/UPDATE/DELETE. Oracle inlines the body; PostgreSQL requires a separate RETURNS trigger function that the trigger references.
CREATE OR REPLACE FUNCTION audit_salary() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO salary_audit(emp_id, old_val, new_val, changed_at)
VALUES (OLD.id, OLD.salary, NEW.salary, now());
RETURN NEW; -- BEFORE triggers must return a row
END $$;
CREATE TRIGGER trg_audit_salary
BEFORE UPDATE OF salary ON employees
FOR EACH ROW
WHEN (OLD.salary IS DISTINCT FROM NEW.salary)
EXECUTE FUNCTION audit_salary();CREATE OR REPLACE TRIGGER trg_audit_salary
BEFORE UPDATE OF salary ON employees
FOR EACH ROW
WHEN (OLD.salary <> NEW.salary)
BEGIN
INSERT INTO salary_audit(emp_id, old_val, new_val, changed_at)
VALUES (:OLD.id, :OLD.salary, :NEW.salary, SYSTIMESTAMP);
END;
/Triggers hide logic from the developer who just runs an UPDATE and make debugging a nightmare. Classic problems: Oracle's mutating-table error ORA-04091 (a row trigger querying its own table), undefined ordering across triggers, and collapsing performance on bulk DML. Senior rule: triggers only for simple auditing or a hard invariant; business logic belongs in the service. Teams that hid critical logic in triggers regret it months later.
(1) PostgreSQL has no mutating-table error — a trigger may query its own table, which sounds nicer but permits infinite recursion Oracle would have refused; Oracle's own answer to ORA-04091 is the 12c compound trigger. (2) Firing order: undefined in Oracle unless you use FOLLOWS/PRECEDES; PostgreSQL fires in alphabetical order by name, which is why its DBAs prefix triggers 10_, 20_. (3) In the WHEN clause, Oracle's OLD.salary <> NEW.salary is false when either side is NULL (so NULL-to-value changes are skipped) while PostgreSQL's IS DISTINCT FROM handles NULL correctly — that one operator has caused missing audit rows in real systems.
2) ROWNUM vs ROW_NUMBER() vs ROWID
Three entirely different things with confusingly similar names.
| Concept | What it is | When it's assigned | PostgreSQL counterpart |
|---|---|---|---|
ROWID |
A row's physical address (file, block, row) | Stable until the row moves | ctid — far less stable |
ROWNUM |
Pseudocolumn numbered during fetch | Before ORDER BY |
none (use LIMIT) |
ROW_NUMBER() |
Window function | After the ORDER BY in OVER |
identical — SQL standard |
SELECT * FROM emp WHERE ROWNUM <= 5 ORDER BY salary DESC; is always wrong: ROWNUM is assigned before ORDER BY, so 5 arbitrary rows are grabbed first and then sorted. The fix: sort inside a subquery, or use ROW_NUMBER()/FETCH FIRST. PostgreSQL has no equivalent trap because LIMIT is applied after ORDER BY by definition — which is why Oracle developers moving to PostgreSQL find pagination suddenly "just works".
SELECT * FROM (
SELECT e.*, ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn
FROM employees e
) t WHERE rn BETWEEN 1 AND 5;
SELECT * FROM employees ORDER BY salary DESC
FETCH FIRST 5 ROWS ONLY; -- LIMIT 5 is the classic spellingSELECT * FROM (
SELECT e.*, ROW_NUMBER() OVER (ORDER BY salary DESC) rn
FROM employees e
) WHERE rn BETWEEN 1 AND 5;
SELECT * FROM employees ORDER BY salary DESC
FETCH FIRST 5 ROWS ONLY; -- Oracle 12c+Old Oracle had only ROWNUM; since 12c the standard OFFSET n ROWS FETCH FIRST m ROWS ONLY works, and PostgreSQL understands it too alongside LIMIT/OFFSET — so that spelling is the first choice for multi-database teams. Two extra traps: WITH TIES (Oracle 12c+, PostgreSQL 13+) silently changes the row count, and ORDER BY on a non-unique key makes pagination non-deterministic on both engines. Always add the primary key as a tiebreaker.
A shared gotcha: a large OFFSET is slow on both engines, because the skipped rows must still be produced. Keyset (seek) pagination replaces it:
SELECT invoice_id, created_at, amount
FROM invoices
WHERE (created_at, invoice_id) < ($1, $2) -- last row of previous page
ORDER BY created_at DESC, invoice_id DESC
FETCH FIRST 20 ROWS ONLY;SELECT invoice_id, created_at, amount
FROM invoices
WHERE (created_at < :last_created
OR (created_at = :last_created AND invoice_id < :last_id))
ORDER BY created_at DESC, invoice_id DESC
FETCH FIRST 20 ROWS ONLY;PostgreSQL compares row constructors lexicographically — (a, b) < ($1, $2) — and can drive a composite index straight from it. Oracle accepts row constructors for =/IN but not for </>, so you must write the expanded OR form shown above. It is uglier, uses the same index, and is the portable formulation if one statement has to serve both engines.
Answer: ROWNUM is a pseudocolumn assigned during fetch, before ORDER BY, so a sorted top-N must apply it in an already-sorted subquery. ROW_NUMBER() is a window function evaluated after the ORDER BY inside OVER (...), and with PARTITION BY it ranks within each group. ROW_NUMBER is SQL-standard and behaves identically on PostgreSQL; ROWNUM is Oracle-only with no PostgreSQL equivalent. Confusing them is one of the most common top-N bugs.
Answer: Both are physical addresses and both let you re-target the exact row you just read, but you must never persist a ctid. An Oracle ROWID changes only when the row physically moves (row migration, ALTER TABLE ... MOVE, partition-key update with row movement), so it is fairly durable within a batch. In PostgreSQL every UPDATE writes a new tuple version, so the ctid changes on every update, and after VACUUM reuses the slot it may point at a different row. Legitimate uses are narrow and short-lived — batching a DELETE, de-duplicating in one statement. For anything durable, use the primary key.
3) The Cost-Based Optimizer: the database's brain
Enter a destination and there are dozens of possible routes; the app picks the cheapest from estimated traffic and distance. The optimizer does the same per query: it builds candidate execution plans (full scan? index? which join, in what order?) and picks the cheapest by estimated cost, derived from statistics. If the statistics are stale, it picks a stupid route.
Oracle has used a cost-based optimizer since the 1990s; PostgreSQL has only ever had one. Both lean on statistics: row counts, distinct values per column (NDV), histograms, and how well index order matches physical order (Oracle's clustering factor, PostgreSQL's correlation).
The path of a SQL statement from arrival to execution:
flowchart LR
App[Java/JDBC] -->|SQL text| Parse[Syntax & Semantic Check]
Parse --> Cache{In Shared Pool?}
Cache -- Yes: soft parse --> Exec[Execute]
Cache -- No: hard parse --> Opt[Cost-Based Optimizer]
Opt -->|uses statistics| Plan[Choose Cheapest Plan]
Plan --> Store[Store in Library Cache]
Store --> Exec
Exec --> Fetch[Fetch rows from Buffer Cache]
The same picture in PostgreSQL has one structural difference that changes everything — the plan cache is per backend, not shared:
flowchart LR
C[Client / JDBC] --> B[Backend process]
B --> PC{Prepared in THIS session?}
PC -- Yes --> Reuse[Reuse cached plan]
PC -- No --> Plan[Planner: cost-based]
Plan -->|pg_statistic| Choose[Choose cheapest plan]
Choose --> LocalCache[Per-session plan cache]
LocalCache --> Run[Executor -> shared_buffers]
Reuse --> Run
Gathering statistics
ANALYZE VERBOSE orders; -- autovacuum usually does this
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000; -- histogram size
CREATE STATISTICS orders_cust_status (dependencies, ndistinct)
ON customer_id, status FROM orders;
ANALYZE orders;
SELECT attname, n_distinct, correlation
FROM pg_stats WHERE tablename = 'orders';BEGIN
DBMS_STATS.GATHER_TABLE_STATS(USER, 'ORDERS',
method_opt => 'FOR ALL COLUMNS SIZE AUTO',
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
cascade => TRUE);
END;
/
SELECT DBMS_STATS.CREATE_EXTENDED_STATS(USER, 'ORDERS',
'(CUSTOMER_ID, STATUS)') FROM dual;
SELECT column_name, num_distinct, histogram
FROM user_tab_col_statistics WHERE table_name = 'ORDERS';Both optimizers assume predicates are independent, so WHERE city='Tehran' AND country='Iran' is estimated as the product of two selectivities — catastrophically low when the columns are functionally dependent. The estimate collapses to one row, the planner picks a nested loop, and the query runs for an hour. Oracle's cure is extended statistics, PostgreSQL's is CREATE STATISTICS ... (dependencies, ndistinct, mcv). Neither is created automatically; knowing both exist is a real senior differentiator.
Seeing the plan
EXPLAIN PLAN/EXPLAIN: the estimated plan, without running the query.- AUTOTRACE /
EXPLAIN (ANALYZE, BUFFERS): runs it and reports real rows and real I/O. DBMS_XPLAN.DISPLAY_CURSOR/auto_explain: the actually executed plan.- AWR / ASH /
pg_stat_statements+pg_stat_activity: workload history and "what is it doing right now?".
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT * FROM orders WHERE customer_id = 42;
-- Plan a parameterized statement without inventing a literal (v16+)
EXPLAIN (GENERIC_PLAN)
SELECT * FROM orders WHERE customer_id = $1;SELECT /*+ GATHER_PLAN_STATISTICS */ * FROM orders WHERE customer_id = 42;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST'));
-- Estimated plan only
EXPLAIN PLAN FOR SELECT * FROM orders WHERE customer_id = :c;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);Oracle's GATHER_PLAN_STATISTICS + ALLSTATS LAST prints E-Rows next to A-Rows; PostgreSQL's EXPLAIN (ANALYZE) prints rows= (estimate) next to actual rows=. Where those diverge by orders of magnitude is where the plan went wrong, and it is almost always the first bad estimate low in the tree poisoning every join above it. Fix the estimate — statistics, extended statistics, a rewritten predicate — instead of fighting the plan with hints.
CREATE EXTENSION IF NOT EXISTS pg_stat_statements; -- needs preload + restart
SELECT calls, round(total_exec_time) AS total_ms,
round(mean_exec_time, 2) AS mean_ms, rows,
shared_blks_hit, shared_blks_read, left(query, 60) AS sql
FROM pg_stat_statements
ORDER BY total_exec_time DESC FETCH FIRST 10 ROWS ONLY;
SELECT pid, state, wait_event_type, wait_event,
now() - query_start AS running_for
FROM pg_stat_activity WHERE state <> 'idle';-- AWR report: @?/rdbms/admin/awrrpt.sql (Diagnostics Pack licence!)
SELECT sql_id, SUM(executions_delta) AS execs,
ROUND(SUM(elapsed_time_delta)/1e6) AS total_s
FROM dba_hist_sqlstat
WHERE snap_id BETWEEN :b_snap AND :e_snap
GROUP BY sql_id ORDER BY 3 DESC FETCH FIRST 10 ROWS ONLY;
SELECT session_id, event, sql_id, COUNT(*) AS samples
FROM v$active_session_history
WHERE sample_time > SYSTIMESTAMP - INTERVAL '5' MINUTE
GROUP BY session_id, event, sql_id ORDER BY samples DESC;AWR and ASH belong to the Diagnostics Pack and need a separate paid licence; running awrrpt.sql or querying DBA_HIST_*/V$ACTIVE_SESSION_HISTORY without it is a violation that draws heavy penalties in an audit. Without the licence, use free Statspack. PostgreSQL has the mirror problem: pg_stat_statements is free but not enabled by default — it needs shared_preload_libraries and a restart, so during an incident you may discover you have no query history at all. Turn it on before you need it.
Bind variables and the shared pool: the single most important tuning lesson
A new SQL text forces Oracle to hard parse it (parse, optimize, build a plan) — expensive. The result is kept in the shared pool (library cache); an identical text later gets a cheap soft parse. Inline literals defeat this: every value is a different text.
-- Bad: a different text per user → planner CPU burned on every call
SELECT * FROM orders WHERE customer_id = 12345;
-- Good: one text, parameter placeholder
PREPARE ord(bigint) AS SELECT * FROM orders WHERE customer_id = $1;
EXECUTE ord(12345);-- Bad: each value is a new text → shared pool fills, CPU burns
SELECT * FROM orders WHERE customer_id = 12345;
-- Good: one text, shared plan → soft parse
VARIABLE cust_id NUMBER;
EXEC :cust_id := 12345;
SELECT * FROM orders WHERE customer_id = :cust_id;In Spring, ? or a named parameter becomes a bind variable automatically:
jdbcTemplate.query(
"SELECT * FROM orders WHERE customer_id = ?",
new Object[]{ custId },
orderRowMapper);
Two reasons, both vital. (1) Performance — no endless hard parses, no full shared pool, no "shared pool latch contention" or ORA-04031 that slows the whole instance. (2) Security — bind variables are the only real defence against SQL injection. Concatenating "... = '" + userInput + "'" in Java kills performance and opens the door. Never build SQL strings; always PreparedStatement/?.
The deepest architectural difference in this section. Oracle's library cache is shared across all sessions: one hard parse benefits everyone, and a flood of literal SQL poisons the pool for everyone. PostgreSQL plans per backend process; there is no cross-session plan sharing, and a prepared statement exists only in the session that made it. So literal SQL costs planning CPU per execution but cannot cause a latch storm; a transaction-mode PgBouncer breaks server-side prepared statements unless protocol-level prepare is supported; and there is no shared_pool_size to tune — shared_buffers caches data, not plans. Calling shared_buffers "the shared pool" in an interview is a classic tell.
On its first execution Oracle "peeks" at the actual bind value and caches a plan built for it. With skewed data (status='ACTIVE' 90% of rows, status='ARCHIVED' 1%) that plan can be a disaster for the next value; Oracle mitigates with Adaptive Cursor Sharing. PostgreSQL has the same disease and a different cure: the first five executions of a prepared statement use custom plans with the real parameters, then a generic plan is built and kept if its estimated cost isn't much worse; you override with SET plan_cache_mode = force_custom_plan. If a query is "sometimes fast, sometimes very slow", this interaction is suspect number one on either engine.
When you have found the good plan, Oracle lets you capture it as a SQL Plan Baseline (DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE), which the optimizer must then use — far safer than a hint, because a hint freezes a decision made with today's data volumes and becomes next year's slow query nobody dares remove. PostgreSQL has no baselines and no core hints: your levers are statistics, indexes, plan_cache_mode, and rewriting the query; pg_hint_plan is an extension you usually cannot install on managed services. That constraint is arguably a feature — it forces you to fix the cause.
Answer: A hard parse means Oracle parses, resolves and optimizes the text from scratch and builds a plan — the most expensive part of processing a small query. A soft parse finds the identical text already in the library cache and reuses the plan. Literals make every value a unique text and a hard parse, burning CPU and causing shared-pool latch contention. Binds keep the text constant so the plan is shared, and they are the only reliable defence against SQL injection. On PostgreSQL the terminology doesn't exist — no shared library cache — but the cost does: every literal-laden statement is planned again, so the same discipline applies.
Answer: No, and conflating them is a red flag. Oracle's SGA holds both a buffer cache (data blocks) and a shared pool (parsed SQL, plans, dictionary cache). PostgreSQL's shared_buffers corresponds only to the buffer cache; plans live in each backend's private memory. That is why Oracle has a whole discipline around library-cache contention (ORA-04031, cursor sharing, SESSION_CACHED_CURSORS) that PostgreSQL simply lacks, and why PostgreSQL instead cares about the OS page cache (hence the ~25%-of-RAM guidance for shared_buffers) and about connection count, since every connection is a process.
4) Indexes: the treasure map of your data
To find "SCN" in an 800-page book you either read page by page (a full table scan) or use the index at the back and jump to the right page. A database index is exactly that: a sorted structure holding the address (ROWID/ctid) of matching rows.
B-tree (the default on both): balanced tree, king of OLTP, great for equality and ranges, shines on high-cardinality columns.
Bitmap index (Oracle only): one bitmap per distinct value; on low-cardinality columns it is tiny and superb for AND/OR of several predicates in a warehouse. PostgreSQL has no stored bitmap index — it builds bitmaps at execution time (Bitmap Index Scan → BitmapAnd → Bitmap Heap Scan) and offers BRIN for huge naturally ordered tables.
CREATE INDEX idx_sales_status ON sales (status);
CREATE INDEX idx_sales_region ON sales (region);
-- plan: Bitmap Index Scan + BitmapAnd + Bitmap Heap Scan
-- BRIN: kilobytes for a billion-row time-ordered table
CREATE INDEX idx_sales_date_brin ON sales
USING brin (sale_date) WITH (pages_per_range = 64);CREATE BITMAP INDEX idx_sales_status ON sales (status);
CREATE BITMAP INDEX idx_sales_region ON sales (region);
-- optimizer can do BITMAP AND / BITMAP OR across them
-- Oracle's "tiny index for huge tables" analogue is a zone map
-- (Exadata/engineered systems) or simply partitioning.Never put a bitmap index on a heavily updated table: changing one row can lock a bitmap segment covering hundreds of rows, producing brutal lock contention. Bitmaps belong on mostly read-only tables. PostgreSQL cannot make this mistake, but BRIN has its own: it only works when physical row order correlates with the indexed column (append-only timestamps). On randomly ordered data a BRIN index is worse than useless, because every block range matches.
Function-based / expression index: WHERE UPPER(email) = 'A@B.COM' cannot use an index on email — you must index the expression:
CREATE INDEX idx_emp_upper_email ON employees (upper(email));
SELECT * FROM employees WHERE upper(email) = 'ANA@EXAMPLE.COM';
-- Often better on PostgreSQL: case-insensitive by type
CREATE EXTENSION IF NOT EXISTS citext;CREATE INDEX idx_emp_upper_email ON employees (UPPER(email));
SELECT * FROM employees WHERE UPPER(email) = 'ANA@EXAMPLE.COM';
-- Oracle 12.2+: a case-insensitive collation does the same
-- ALTER TABLE employees MODIFY (email VARCHAR2(200) COLLATE BINARY_CI);PostgreSQL requires the function to be IMMUTABLE, Oracle requires DETERMINISTIC. The classic failure is trying to index something touching SYSDATE/now(). The nastier failure is lying — marking a timezone-dependent function IMMUTABLE in PostgreSQL builds an index that silently returns wrong rows. If "last 30 days" must be fast, index the raw timestamp and keep the arithmetic on the constant side of the predicate.
A composite index on (a, b, c) serves WHERE a=..., a AND b, and a AND b AND c, but for WHERE b=... alone it is usually useless — like a phone book sorted by surname when you only know the first name. Put the column that appears most often with equality first. Both engines have partial escapes (Oracle's index skip scan when the leading column has very few distinct values; PostgreSQL happily scanning a small index end to end) but neither replaces the right column order.
Covering index / index-only scan: if the index holds every column the query touches, the table is never visited.
-- INCLUDE keeps payload columns out of the search key (v11+)
CREATE INDEX idx_orders_cust_incl
ON orders (customer_id) INCLUDE (status, amount);
VACUUM (ANALYZE) orders; -- index-only scans need the visibility map
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, status, amount FROM orders WHERE customer_id = $1;-- No INCLUDE clause: append the payload columns to the key
CREATE INDEX idx_orders_cust_incl
ON orders (customer_id, status, amount);
EXPLAIN PLAN FOR
SELECT customer_id, status, amount FROM orders WHERE customer_id = :c;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY); -- no TABLE ACCESS BY ROWIDIn Oracle, if the index holds all the columns, you get index-only access, full stop. PostgreSQL must additionally verify tuple visibility, and can skip that only for pages marked all-visible in the visibility map, maintained by VACUUM. On a hot table with lagging autovacuum the same covering index degrades into an index scan plus a heap fetch per row — while the plan still says Index Only Scan and reports a large Heap Fetches:. Reading that number is the tell.
Partial / filtered index: index only the rows you actually query.
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'PENDING';
-- Conditional uniqueness, for free
CREATE UNIQUE INDEX uq_active_email ON users (email) WHERE deleted_at IS NULL;-- No WHERE clause on an index. The idiom: a function-based index that
-- returns NULL for uninteresting rows (all-NULL keys are not stored).
CREATE INDEX idx_orders_pending ON orders (
CASE WHEN status = 'PENDING' THEN created_at END);
SELECT * FROM orders
WHERE CASE WHEN status = 'PENDING' THEN created_at END > :since;The CASE trick gives you a small index, but the optimizer uses it only when the predicate is written exactly as the indexed expression — so your application SQL becomes ugly and coupled to the index. That is why partial indexes are one of the genuinely nicer things about PostgreSQL. (Oracle 12c+ does have "partial indexes" for partitioned tables via INDEXING ON/OFF — but that switches indexing per partition, not per row.)
Every extra index means every INSERT/UPDATE/DELETE maintains it too — slower writes, more redo/WAL, more space. Fifteen indexes on one table means painful writes. PostgreSQL adds a twist: an update touching no indexed column with room in the same page can be a HOT update that skips index maintenance entirely, so one unnecessary index on a hot column silently multiplies write cost and bloat (tune fillfactor to ~85 there). Build indexes from evidence — AWR / pg_stat_statements — not guesses, and drop the unused ones.
CREATE INDEX CONCURRENTLY idx_orders_created ON orders (created_at);
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_old;
REINDEX INDEX CONCURRENTLY idx_orders_created;
SELECT relname, indexrelname, idx_scan
FROM pg_stat_user_indexes WHERE idx_scan = 0;CREATE INDEX idx_orders_created ON orders (created_at) ONLINE;
ALTER INDEX idx_orders_created REBUILD ONLINE;
-- Prove a drop is safe before you do it
ALTER INDEX idx_orders_old INVISIBLE;
ALTER SESSION SET optimizer_use_invisible_indexes = TRUE;
ALTER INDEX idx_orders_old MONITORING USAGE;
SELECT * FROM v$object_usage;Oracle's INVISIBLE has no PostgreSQL equivalent: the index is still maintained on DML but the optimizer ignores it, so you can prove that dropping it is safe and flip it back in one second if the world catches fire. In PostgreSQL the closest safe pattern is DROP INDEX CONCURRENTLY plus a saved CREATE INDEX CONCURRENTLY script — and the knowledge that rebuilding a large index takes hours. That asymmetry is exactly why PostgreSQL DBAs are so reluctant to drop big indexes.
Answer: A covering index contains every column the query needs, so the engine answers from the index alone. In Oracle you append the payload columns to the composite key and the plan proves it by showing INDEX RANGE SCAN without a following TABLE ACCESS BY INDEX ROWID. In PostgreSQL you use the INCLUDE clause (v11+) and the plan shows Index Only Scan — but you must also read Heap Fetches:; a large number means the visibility map is stale and you are paying heap access anyway, which VACUUM fixes. The cost on both engines is index size and slower writes.
5) Partitioning: splitting a giant into manageable pieces
A ten-year document archive in one room means searching everywhere for a "March 2024" file. Shelve it by year and you go straight to the 2024 shelf and ignore the rest. Partition pruning is exactly this: the optimizer eliminates irrelevant partitions completely.
Partitioning breaks a large table into smaller physical pieces that are logically one table. Three main types on both engines: range (usually date), list (discrete values), hash (even spread when there is no natural boundary).
CREATE TABLE orders (
order_id bigint GENERATED ALWAYS AS IDENTITY,
order_date date NOT NULL,
amount numeric(12,2)
) PARTITION BY RANGE (order_date);
CREATE TABLE orders_2025 PARTITION OF orders
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
CREATE TABLE orders_2026 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
CREATE TABLE orders_default PARTITION OF orders DEFAULT;
-- A unique key MUST contain the partition key:
ALTER TABLE orders ADD PRIMARY KEY (order_id, order_date);CREATE TABLE orders (
order_id NUMBER GENERATED ALWAYS AS IDENTITY,
order_date DATE NOT NULL,
amount NUMBER(12,2),
CONSTRAINT pk_orders PRIMARY KEY (order_id) -- global index: fine
)
PARTITION BY RANGE (order_date) (
PARTITION p2025 VALUES LESS THAN (DATE '2026-01-01'),
PARTITION p2026 VALUES LESS THAN (DATE '2027-01-01'),
PARTITION pmax VALUES LESS THAN (MAXVALUE)
);In Oracle an index can be global (one B-tree over all partitions), so PRIMARY KEY (order_id) still works and a lookup by id is one probe. In PostgreSQL every index on a partitioned table is local: a unique constraint must include the partition key, and a lookup without the date probes every partition. This one rule reshapes the schema — you carry the partition key into foreign keys and queries, or you accept fan-out. It is the number-one surprise when porting a partitioned Oracle model.
flowchart TD
Q["Query: WHERE order_date in 2026"] --> Opt[Optimizer: Partition Pruning]
Opt -->|skip| P25[Partition 2025]
Opt -->|scan| P26[Partition 2026]
Opt -->|skip| PD[Default / MAXVALUE]
P26 --> R[Result rows]
The bigger payoff is maintenance: dropping a year becomes metadata work instead of a hundred-million-row DELETE.
ALTER TABLE orders DETACH PARTITION orders_2025 CONCURRENTLY;
DROP TABLE orders_2025;
-- No INTERVAL clause in core PostgreSQL: script it, or use pg_partman
CREATE TABLE orders_2027 PARTITION OF orders
FOR VALUES FROM ('2027-01-01') TO ('2028-01-01');ALTER TABLE orders DROP PARTITION p2025 UPDATE INDEXES;
-- Interval partitioning: Oracle creates the next partition automatically
ALTER TABLE orders SET INTERVAL (NUMTOYMINTERVAL(1, 'MONTH'));
-- Move cold data to cheap storage instead of deleting it
ALTER TABLE orders MOVE PARTITION p2026 TABLESPACE archive_ts ONLINE;Three wins: pruning (read only relevant partitions), data management (instant purge instead of a DELETE that generates enormous undo/redo), and maintenance/parallelism (rebuild, analyze and back up partition by partition). Oracle is more mature — interval, reference and composite partitioning, plus UPDATE INDEXES to keep global indexes valid — but Partitioning is a separately licensed option, while PostgreSQL's declarative partitioning is free. On PostgreSQL remember enable_partitionwise_join and enable_partitionwise_aggregate: both are off by default and both are what make big partitioned joins fast.
Answer: When the table is large enough that full scans or maintenance (purge, archive, index rebuild) hurt — usually tens of millions of rows, especially with a natural time dimension. The biggest mistake is a partition key that doesn't match the WHERE clauses of real queries: pruning never happens, every partition is scanned, and you have added overhead for nothing. Second mistake: partitioning a small table. Third, PostgreSQL-specific: forgetting that indexes are local, so lookups without the partition key fan out — and with thousands of partitions, planning time alone becomes the bottleneck. Keep the count in the hundreds.
6) MERGE, Sequences & Identity, and Materialized Views
MERGE (upsert)
MERGE performs UPDATE, INSERT or DELETE in one statement depending on whether the target row exists. Oracle has had it forever; PostgreSQL added it in v15 and completed it in v17.
-- Idiomatic PostgreSQL upsert (requires a unique constraint on id)
INSERT INTO target (id, amount) VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET amount = EXCLUDED.amount
RETURNING id, amount;
-- Standard MERGE (v15+) with RETURNING and merge_action() (v17+)
MERGE INTO target t
USING (SELECT $1::bigint AS id, $2::numeric AS amount) s ON t.id = s.id
WHEN MATCHED THEN UPDATE SET amount = s.amount
WHEN NOT MATCHED THEN INSERT (id, amount) VALUES (s.id, s.amount)
RETURNING merge_action(), t.id, t.amount;MERGE INTO target t
USING (SELECT :id AS id, :amount AS amount FROM dual) s ON (t.id = s.id)
WHEN MATCHED THEN UPDATE SET t.amount = s.amount
WHEN NOT MATCHED THEN INSERT (id, amount) VALUES (s.id, s.amount);
-- Oracle has no ON CONFLICT; the single-row equivalent is MERGE above,
-- or catching the unique violation:
BEGIN
INSERT INTO target (id, amount) VALUES (:id, :amount);
EXCEPTION
WHEN DUP_VAL_ON_INDEX THEN
UPDATE target SET amount = :amount WHERE id = :id;
END;
/ON CONFLICT is tied to a unique index, so it is atomic against concurrent inserters: the conflict is detected at index level and the statement switches to update. MERGE is defined on a join condition and is not immune — on both engines two sessions merging the same new key can still collide (ORA-00001 / SQLSTATE 23505), and the answer is a retry. PostgreSQL 17 added WHEN NOT MATCHED BY SOURCE and RETURNING merge_action(); Oracle 23ai added a RETURNING clause to UPDATE/MERGE with old and new values, but on 19c you need RETURNING ... INTO inside PL/SQL. For portable code, hide the upsert behind one repository method with a per-dialect implementation.
Sequences and Identity
CREATE SEQUENCE seq_order START WITH 1 INCREMENT BY 1 CACHE 20;
INSERT INTO orders (id, amount) VALUES (nextval('seq_order'), 100);
-- Preferred: standard identity column
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY,
amount numeric(12,2));
INSERT INTO orders (amount) VALUES (100) RETURNING id;CREATE SEQUENCE seq_order START WITH 1 INCREMENT BY 1 CACHE 100;
INSERT INTO orders (id, amount) VALUES (seq_order.NEXTVAL, 100);
-- Preferred since 12c: standard identity column
CREATE TABLE orders (
id NUMBER GENERATED ALWAYS AS IDENTITY,
amount NUMBER(12,2));
INSERT INTO orders (amount) VALUES (100) RETURNING id INTO :out_id;CACHE n reserves the next n values in memory for speed; an instance restart loses the unused ones and leaves a gap. That is normal — never assume a sequence is gap-free; use it for uniqueness, not for counting. Two dialect notes: in Oracle RAC each instance holds its own range, so gaps and out-of-order ids are the rule and ORDER NOCACHE fixes ordering at a brutal performance cost; in PostgreSQL the cache is per session, so a pool of 50 connections can hold 50 unused blocks. On both engines nextval is non-transactional — a rolled-back transaction still consumed the number. In JPA, align allocationSize with the database CACHE or expect duplicate-key storms.
Materialized Views
A view is a stored query; a materialized view (MV) physically stores the result — a database-level cache for expensive aggregations.
CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT date_trunc('day', order_date) AS d, SUM(amount) AS total
FROM orders GROUP BY 1
WITH DATA;
-- CONCURRENTLY needs a unique index and avoids blocking readers
CREATE UNIQUE INDEX ON mv_daily_sales (d);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;CREATE MATERIALIZED VIEW LOG ON orders
WITH ROWID, SEQUENCE (order_date, amount) INCLUDING NEW VALUES;
CREATE MATERIALIZED VIEW mv_daily_sales
BUILD IMMEDIATE
REFRESH FAST ON COMMIT -- incremental, at commit time
ENABLE QUERY REWRITE
AS SELECT TRUNC(order_date) d, SUM(amount) total, COUNT(*) cnt
FROM orders GROUP BY TRUNC(order_date);The real magic of Oracle MVs is query rewrite: with ENABLE QUERY REWRITE, an ordinary aggregation against the base table is silently redirected to the MV, and REFRESH FAST applies only the deltas through a materialized view log. PostgreSQL has MVs but no incremental refresh and no automatic rewrite — REFRESH MATERIALIZED VIEW recomputes everything, and the non-CONCURRENTLY form takes an ACCESS EXCLUSIVE lock that blocks every reader meanwhile. The pg_ivm extension adds incremental maintenance but is not core. For BI workloads this is a genuine reason to prefer one engine.
DBMS_SCHEDULER is a first-class Oracle subsystem (job chains, windows, resource plans, notifications) and every old Oracle system has dozens of undocumented jobs in it. PostgreSQL has nothing in core: you use the pg_cron extension (SELECT cron.schedule('mv-daily','10 2 * * *', $$REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales$$)), the OS crontab, or your application scheduler (Quartz, Spring @Scheduled with ShedLock). Small feature, real migration line item.
7) The transaction engine: Undo, Redo, SCN, and Read Consistency
Three concepts to keep apart:
- Redo (Oracle) / WAL (PostgreSQL): a log of all changes for durability and recovery, written sequentially and flushed to disk before commit returns.
- Undo (Oracle): the before image, kept in a separate undo tablespace, for rollback and read consistency. PostgreSQL has no undo — it keeps the old row version in the table and tracks visibility with
xmin/xmax. - SCN (Oracle) / LSN + transaction ids (PostgreSQL): the logical clock defining which version of the database you see.
| Concept | Oracle | PostgreSQL |
|---|---|---|
| Change log | redo logs + archived redo | WAL segments (pg_wal) |
| Old versions | undo tablespace | dead tuples in the heap |
| Version clock | SCN | LSN, xmin/xmax, snapshots |
| Space reclaim | undo auto-recycles | VACUUM / autovacuum |
| Classic failure | ORA-01555 snapshot too old |
bloat, XID wraparound |
| Point-in-time read | Flashback Query (AS OF SCN) |
none built in (PITR restore only) |
Several people are looking at a painting while one person repaints a corner. The database never shows the others the half-finished colour; it shows each of them a consistent version as of the moment their query started. So a reader never blocks a writer and vice versa — true on both engines, implemented in two completely different ways.
BEGIN ISOLATION LEVEL REPEATABLE READ; -- snapshot fixed at first statement
SELECT SUM(amount) FROM orders;
SELECT SUM(amount) FROM orders; -- identical, guaranteed
COMMIT;
-- No flashback query. Raw visibility info is all you get:
SELECT xmin, xmax, * FROM orders WHERE order_id = 42;SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- transaction-level snapshot
SELECT SUM(amount) FROM orders;
SELECT SUM(amount) FROM orders; -- identical, guaranteed
COMMIT;
-- Flashback Query: read the table as it was 15 minutes ago
SELECT * FROM orders AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '15' MINUTE)
WHERE order_id = 42;
SELECT * FROM orders AS OF SCN 12345678 WHERE order_id = 42;Because Oracle keeps before-images in undo, it answers "what did this row look like at 09:15?" with an ordinary SELECT, and FLASHBACK TABLE ... TO TIMESTAMP can undo a bad UPDATE in seconds without a restore. PostgreSQL structurally cannot: once VACUUM removes the dead tuple the old value is gone, and recovery means restoring a base backup and replaying WAL to a point in time — minutes to hours. If your operational playbook relies on flashback, budget for history tables or a temporal extension before you migrate.
ORA-01555 snapshot too old fires when a long query needs an undo image that other transactions have overwritten — classic causes: multi-minute reports plus a small undo tablespace, or "fetch across commit". Remedies: bigger undo, adequate UNDO_RETENTION, shorter set-based queries. PostgreSQL cannot raise that on the primary; instead the long query holds back VACUUM and the table bloats. On a standby it has the exact analogue: ERROR: canceling statement due to conflict with recovery, tuned with max_standby_streaming_delay and hot_standby_feedback — and turning feedback on simply moves the pain back to the primary as bloat. Same physics, different bill.
Locking
Both engines lock at row level, and on both a writer never blocks a reader. Deadlocks are detected automatically and one transaction is sacrificed (ORA-00060 / SQLSTATE 40P01).
-- Classic queue consumer: take work nobody else holds
SELECT * FROM job_queue
WHERE status = 'READY' ORDER BY created_at
FETCH FIRST 10 ROWS ONLY
FOR UPDATE SKIP LOCKED;
SET lock_timeout = '3s'; -- fail fast, don't hang
SELECT * FROM accounts WHERE id = $1 FOR UPDATE NOWAIT;
SELECT pid, wait_event, pg_blocking_pids(pid), query
FROM pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0;SELECT * FROM job_queue
WHERE status = 'READY' ORDER BY created_at
FETCH FIRST 10 ROWS ONLY
FOR UPDATE SKIP LOCKED;
SELECT * FROM accounts WHERE id = :id FOR UPDATE NOWAIT;
SELECT * FROM accounts WHERE id = :id FOR UPDATE WAIT 3;
SELECT sid, blocking_session, event, sql_id
FROM v$session WHERE blocking_session IS NOT NULL;FOR UPDATE SKIP LOCKED (Oracle 11g+, PostgreSQL 9.5+) is how you build a correct database-backed job queue: each worker grabs rows nobody else holds, with no polling storm and no double processing. Reach for it before you reach for Kafka — one table plus SKIP LOCKED handles surprising volumes. Syntax differs around waiting: Oracle has inline WAIT n, PostgreSQL uses session-level lock_timeout; and in READ COMMITTED PostgreSQL re-reads the newest row version after waiting, which can quietly change what your predicate matched.
stateDiagram-v2
[*] --> Clean
Clean --> Modified: UPDATE — Oracle keeps the old image in undo, PG writes a new tuple version
Modified --> Committed: COMMIT — SCN or LSN advances and the log is flushed
Modified --> RolledBack: ROLLBACK
Committed --> Cleaned: undo recycled / VACUUM removes dead tuple
Cleaned --> [*]
RolledBack --> Clean
Both use MVCC but the levels differ. Oracle offers effectively two: READ COMMITTED (default) and SERIALIZABLE — there is no independent REPEATABLE READ, and Oracle's SERIALIZABLE is snapshot isolation, which does not prevent write skew. PostgreSQL offers all three and implements SERIALIZABLE with SSI, which genuinely detects write skew and aborts a transaction with SQLSTATE 40001. Practical consequence: on PostgreSQL SERIALIZABLE is a real correctness tool but the application must retry; on Oracle the same protection has to come from explicit locking or constraints.
DO $$
DECLARE attempt int := 0;
BEGIN
LOOP
BEGIN
PERFORM transfer_funds(1, 2, 100);
EXIT;
EXCEPTION
WHEN serialization_failure OR deadlock_detected THEN -- 40001 / 40P01
attempt := attempt + 1;
EXIT WHEN attempt > 3;
END;
END LOOP;
END $$;DECLARE
attempt PLS_INTEGER := 0;
cannot_serialize EXCEPTION;
PRAGMA EXCEPTION_INIT(cannot_serialize, -8177); -- ORA-08177
deadlock EXCEPTION;
PRAGMA EXCEPTION_INIT(deadlock, -60); -- ORA-00060
BEGIN
LOOP
BEGIN
transfer_funds(1, 2, 100);
EXIT;
EXCEPTION
WHEN cannot_serialize OR deadlock THEN
ROLLBACK;
attempt := attempt + 1;
EXIT WHEN attempt > 3;
END;
END LOOP;
END;
/The maintenance bill: undo recycling vs VACUUM
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC FETCH FIRST 5 ROWS ONLY;
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02,
fillfactor = 85);
-- Rebuild a bloated table online with the pg_repack extensionSELECT tuned_undoretention, maxquerylen
FROM v$undostat ORDER BY begin_time DESC FETCH FIRST 5 ROWS ONLY;
ALTER TABLE orders ENABLE ROW MOVEMENT;
ALTER TABLE orders SHRINK SPACE COMPACT; -- reclaim space onlineAnswer: Multi-versioning plus a version clock. In Oracle every commit advances the SCN; a query records its start SCN and rebuilds any block changed after it from the undo segment (a consistent read). In PostgreSQL every row version carries xmin/xmax and each transaction takes a snapshot of in-flight transaction ids; a version is visible if its xmin is committed-and-visible and its xmax is not. Both give the golden property — readers never block writers and vice versa — but the bills differ: Oracle risks ORA-01555 when undo recycles too early, PostgreSQL risks bloat when old versions cannot be vacuumed.
Answer: Redo goes forward, undo goes backward. Redo records every change so a crashed instance can roll committed work forward, guaranteeing durability. Undo keeps the before-image so you can ROLLBACK and so readers see an older consistent version. PostgreSQL's WAL is the redo equivalent, but there is no undo: rollback is essentially free because the new tuple versions are simply never made visible, and old versions stay in the table until VACUUM. That is precisely why Oracle has ORA-01555 and PostgreSQL has bloat and autovacuum tuning. Claiming WAL does both jobs is the classic slip.
Answer: MVCC implementation. Oracle stores old versions in a separate undo segment, so when the transaction ends that space is recyclable and the table blocks stay clean. PostgreSQL implements every UPDATE as a new row version in the same table; the dead tuple survives until VACUUM reclaims it. If autovacuum falls behind, table and indexes bloat, plans degrade, and in the worst case you approach transaction ID wraparound and an emergency vacuum. Neither is absolutely better: Oracle pays with ORA-01555 and undo sizing, PostgreSQL with autovacuum tuning, fillfactor, HOT updates and occasionally pg_repack.
Answer: Every COMMIT forces a synchronous log flush (LGWR on Oracle, the WAL writer on PostgreSQL), so per-row commits mean thousands of synchronous I/Os; on Oracle it also creates the "fetch across commit" pattern that invites ORA-01555. Commit in logical batches (say every 1000 rows) and use JDBC batching (addBatch/executeBatch) or FORALL. The opposite extreme is just as bad: a giant transaction open for hours pins undo on Oracle and blocks vacuum on PostgreSQL — a long idle in transaction session is the single most common cause of runaway bloat there.
Answer: DELETE is DML: row by row, full undo/WAL, rollback-able, fires triggers — slow on a big table, and on PostgreSQL it leaves dead tuples for VACUUM. TRUNCATE frees the storage in one operation, fires no row triggers and logs almost nothing. Dialect detail: in Oracle TRUNCATE is DDL with an implicit commit and cannot be rolled back; in PostgreSQL it is transactional — you can truncate inside a transaction and roll it back — though it takes an ACCESS EXCLUSIVE lock. DROP removes the object. For periodic purging the best answer on both is partition-level: ALTER TABLE ... DROP/TRUNCATE PARTITION on Oracle, DETACH PARTITION CONCURRENTLY plus DROP TABLE on PostgreSQL.
8) The big picture: RAC, Data Guard, Exadata, Multitenant — and their PostgreSQL answers
This is where organizations spend millions. Each piece has a real role — and an open-source counterpart, or an honest gap.
flowchart TB
App[Application Tier] --> SCAN[RAC SCAN Listener]
SCAN --> N1[RAC Node 1]
SCAN --> N2[RAC Node 2]
N1 --> Storage[(Shared Storage / Exadata)]
N2 --> Storage
Storage -. redo shipping .-> DG[Data Guard Standby]
DG --> DR[(DR Site Storage)]
The PostgreSQL stack solves the same problems shared-nothing, with a cluster manager:
flowchart TB
App2[Application Tier] --> HA[HAProxy / pgBouncer]
HA --> P[(Primary)]
HA --> R1[(Hot Standby 1)]
P -. streaming WAL .-> R1
P -. streaming WAL .-> R2[(Hot Standby 2)]
Pat[Patroni + etcd] --- P
Pat --- R1
RAC — availability, and the limits of scale
In RAC, multiple instances connect to one database on shared storage. If a node dies the others keep serving; all nodes share one logical buffer cache and Cache Fusion ships blocks over the interconnect.
"More nodes means faster" is wrong. If a hot block — a busy counter, a NOCACHE sequence — is updated on several nodes at once, that block is passed back and forth over the interconnect (gc buffer busy) and throughput drops below a single node. RAC is excellent for HA and for workloads that partition cleanly, but it demands design. It does not solve write scalability.
There is no shared-storage, shared-cache PostgreSQL cluster. The PostgreSQL answer to "one node isn't enough" is: scale reads with hot standbys, scale writes by sharding (Citus turns PostgreSQL into a distributed table store), or separate compute from storage in a cloud fork (Aurora, AlloyDB, Neon). Upside: you never fight Cache Fusion or interconnect latency. Downside: one write node is a real ceiling, and failover is a promotion (seconds of downtime) rather than survival (zero downtime). When asked "what is PostgreSQL's RAC?", the right answer is "there isn't one, and here is what you use instead".
Data Guard vs streaming replication
Data Guard maintains standby copies and ships redo; PostgreSQL ships WAL to hot standbys. The same dial — "never lose data" versus "commit latency" — exists on both:
ALTER SYSTEM SET synchronous_standby_names = 'FIRST 1 (standby1, standby2)';
ALTER SYSTEM SET synchronous_commit = 'remote_apply'; -- or remote_write/on
SELECT pg_reload_conf();
SELECT application_name, state, sync_state,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
FROM pg_stat_replication;ALTER DATABASE SET STANDBY DATABASE TO MAXIMIZE AVAILABILITY;
ALTER SYSTEM SET log_archive_dest_2 =
'SERVICE=stby SYNC AFFIRM VALID_FOR=(ONLINE_LOGFILES,PRIMARY_ROLE)
DB_UNIQUE_NAME=stby';
SELECT name, value, time_computed FROM v$dataguard_stats
WHERE name IN ('transport lag', 'apply lag');Conflating them is a classic interview mistake. RAC protects against a server failure in one data centre; all nodes share one storage, so if the storage or the site dies, RAC does not save you. Data Guard protects against a whole-site failure with a separate physical copy, usually in another region. Serious organizations run both. The PostgreSQL mapping is honest but partial: synchronous streaming replication ≈ Data Guard Maximum Availability, a read-only hot standby ≈ Active Data Guard, Patroni + etcd ≈ Data Guard Broker's Fast-Start Failover. Nothing maps to RAC.
Historically a logical replication consumer (a CDC pipeline, Debezium) broke on every failover, because replication slots lived only on the primary. PostgreSQL 17 lets a slot be created with failover = true and synchronised to the standby (sync_replication_slots = on, or pg_sync_replication_slots()), so logical consumers survive promotion; pg_createsubscriber also converts a physical standby into a logical subscriber without re-copying data. If you stream changes out of the database, these two features decide whether a failover is a shrug or an incident.
Exadata — engineered hardware
Exadata is an engineered system: database servers, intelligent storage servers and a fast fabric. Its key feature is Smart Scan — the storage cells do filtering and projection themselves and return only relevant rows — plus storage indexes and Hybrid Columnar Compression. There is nothing equivalent you can buy for PostgreSQL; the nearest ideas are columnar extensions (Citus columnar) and cloud forks that push filtering into a storage layer.
Multitenant: CDB and PDB
Since 12c Oracle uses multitenant: a CDB hosting multiple PDBs. Each PDB is an independent database to the application but shares background processes and redo with the CDB, and can be unplugged and plugged elsewhere.
CREATE DATABASE tenant_a;
\c tenant_a
CREATE SCHEMA billing;
-- No cross-database queries: you need an extension
CREATE EXTENSION IF NOT EXISTS postgres_fdw;
SELECT * FROM dblink('dbname=tenant_b', 'SELECT id FROM invoices')
AS t(id bigint);CREATE PLUGGABLE DATABASE tenant_a
ADMIN USER ta_admin IDENTIFIED BY "***"
FILE_NAME_CONVERT = ('/pdbseed/', '/tenant_a/');
ALTER PLUGGABLE DATABASE tenant_a OPEN;
ALTER SESSION SET CONTAINER = tenant_a;
-- Cross-PDB access also needs a link
CREATE DATABASE LINK tb CONNECT TO app IDENTIFIED BY "***" USING 'tenant_b';
SELECT id FROM invoices@tb;Both engines give the same three levels with different names and costs. A schema is cheap and lets you join across tenants freely — perfect for one application, dangerous for hard multi-tenancy. A PostgreSQL database or an Oracle PDB is a hard boundary: no joins across it without a link/FDW, separate users and backups, droppable independently. A separate instance/CDB costs the most memory and admin. Oracle's edge is that PDBs share one instance's memory and processes, so hundreds are viable and cloning one for a test environment takes seconds. The trap: past a small number of PDBs per CDB you need the Multitenant option licence.
Answer: RAC is several engines on one aeroplane: one engine fails and the plane keeps flying — but everyone still rides one fuselage (one storage, one site). Data Guard is a second aeroplane fuelled and ready at another airport: if the whole first airport goes down, you fly the second plane. RAC keeps you up day to day; Data Guard lets you survive a disaster. Mature companies run both.
Answer: Active Data Guard → hot standby with streaming replication (hot_standby_feedback is the query-versus-bloat knob). Data Guard Broker / Fast-Start Failover → Patroni with etcd/Consul, plus HAProxy or a virtual IP for client routing (repmgr, pg_auto_failover are alternatives). Protection modes → synchronous_commit (on, remote_write, remote_apply) with synchronous_standby_names. RMAN → pg_basebackup/pgBackRest/Barman with WAL archiving and PITR. GoldenGate → logical replication or Debezium. No equivalent: RAC, Exadata Smart Scan, Flashback Database, and the Diagnostics Pack tooling — for which the honest answer is pg_stat_statements plus external tools, not a like-for-like replacement.
9) The engineering decision: Oracle or PostgreSQL?
Hotter than ever, because Oracle licensing and audit costs are heavy and PostgreSQL has matured enormously.
| Dimension | Oracle | PostgreSQL |
|---|---|---|
| Cost | expensive licence + separate options (Partitioning, RAC, Diagnostics Pack…) | open source, free |
| MVCC | separate undo segment; no bloat, no VACUUM | old versions in-table; needs VACUUM |
| Procedural language | very mature PL/SQL, packages | PL/pgSQL + PL/Python, PL/V8; no packages |
| Plan stability | SQL Plan Baselines, hints, Adaptive Cursor Sharing | plan_cache_mode, statistics, pg_hint_plan (extension) |
| Diagnostics | AWR/ASH/SQL Monitor (licensed) | pg_stat_statements, pg_stat_io, auto_explain (free) |
| HA/DR | RAC + Data Guard (very mature) | streaming/logical replication + Patroni |
| Partitioning | advanced (interval, reference, global indexes) | declarative since v10, local indexes only |
| Materialized views | incremental refresh + query rewrite | full refresh only, no automatic rewrite |
| Time travel | Flashback Query / Table / Database | PITR restore only |
| Jobs | DBMS_SCHEDULER built in |
pg_cron or external scheduler |
| Extensions | limited | rich (PostGIS, pgvector, TimescaleDB, Citus…) |
The semantic traps that actually break migrations
Each line below has cost a real project weeks:
-- 1) Empty string is NOT null
SELECT '' IS NULL AS empty_is_null; -- false
-- 2) NULL handling and string aggregation
SELECT COALESCE(bonus, 0), STRING_AGG(name, ',' ORDER BY name) FROM emp;
-- 3) now() is a timestamptz; date arithmetic uses intervals
SELECT now(), CURRENT_DATE + INTERVAL '30 days',
to_char(now(), 'YYYY-MM-DD');
-- 4) No DUAL needed
SELECT 1 + 1 AS result;
-- 5) Unquoted identifiers fold to LOWER case
CREATE TABLE Foo (Id int); -- becomes foo(id)
SELECT * FROM FOO; -- works; SELECT * FROM "Foo" fails-- 1) Empty string IS null — the single most dangerous Oracle behaviour
SELECT CASE WHEN '' IS NULL THEN 'true' ELSE 'false' END FROM dual; -- true
-- 2) NULL handling and string aggregation
SELECT NVL(bonus, 0), LISTAGG(name, ',') WITHIN GROUP (ORDER BY name) FROM emp;
-- 3) SYSDATE is a DATE (with time); arithmetic is in days
SELECT SYSDATE, TRUNC(SYSDATE) + 30,
TO_CHAR(SYSDATE, 'YYYY-MM-DD') FROM dual;
-- 4) DUAL required before 23ai
SELECT 1 + 1 AS result FROM dual;
-- 5) Unquoted identifiers fold to UPPER case
CREATE TABLE Foo (Id NUMBER); -- becomes FOO(ID)
SELECT * FROM foo; -- works; SELECT * FROM "Foo" failsIn Oracle '' and NULL are the same value: WHERE name = '' matches nothing, a NOT NULL column rejects an empty string, and 'a' || '' = 'a'. In PostgreSQL '' is a real zero-length string that is not NULL, so the same predicate suddenly matches rows and COALESCE(col, 'x') stops firing. Java code that maps blanks to "" behaves differently on each engine. There is no configuration switch — you find every affected predicate, or you ship subtly wrong reports for a year.
Oracle folds unquoted identifiers to UPPER case, PostgreSQL to lower, and both treat a quoted identifier as exact. A tool that generates "OrderId" produces a column that must be quoted forever on both engines. The rule that survives everything: lower_snake_case, never quote identifiers. Budget too for VARCHAR2(n) byte-versus-character semantics (VARCHAR2(10 CHAR)) against PostgreSQL's varchar(n), which always counts characters — Persian, Arabic and emoji data overflows byte-limited Oracle columns constantly.
Choose Oracle when: very heavy OLTP with intense concurrency where undo-based MVCC avoids bloat entirely; strict local HA that only RAC satisfies; an ecosystem (ERP, Fusion, GoldenGate) already built around Oracle; a large warehouse on Exadata; or operations that depend on Flashback and Data Guard maturity. Choose PostgreSQL when: you want licence cost to be zero; you need extensions (PostGIS, pgvector, TimescaleDB, Citus); you want cloud-portable, container-friendly deployment; or the team wants out of vendor lock-in. Market reality: many organizations are migrating Oracle → PostgreSQL to save money, and heavy PL/SQL is by far the most painful part. Rewriting thousands of lines plus regression testing takes months, not weeks.
Answer: First, the PL/SQL estate: packages, triggers and batch logic must be rewritten to PL/pgSQL, and some constructs have no equivalent at all (PRAGMA AUTONOMOUS_TRANSACTION, FORALL ... SAVE EXCEPTIONS, package state, DBMS_SCHEDULER). Second, semantics: empty string equals NULL, NVL/LISTAGG/DECODE/SYSDATE/DUAL, identifier case folding, SELECT INTO not raising NO_DATA_FOUND. Third, enterprise features: RAC, Flashback, MV query rewrite and global partitioned indexes have no one-to-one replacement. Fourth, operations: autovacuum and bloat management are new disciplines for an Oracle team, and connection pooling becomes mandatory because backends are processes. Fifth, performance: every plan and index must be re-tuned. Right approach: phased migration, ora2pg for the mechanical part, a parallel-run validation phase comparing outputs, never a big-bang cutover.
Answer: PostgreSQL wins on extensibility (PostGIS, pgvector, TimescaleDB, Citus, custom types and index access methods), cost and portability (zero licence, the same engine from a laptop container to any cloud, no audit risk) and developer ergonomics (partial indexes, INCLUDE indexes, real booleans, transactional DDL — you can roll back a CREATE TABLE). Oracle wins on MVCC operations (undo means no bloat, no VACUUM, no wraparound), HA depth (RAC + Data Guard + Flashback Database) and built-in tooling maturity (AWR/ASH/SQL Monitor, DBMS_SCHEDULER, advanced partitioning, MV query rewrite). Answering with concrete features in both directions is what separates an engineer from a fan.
Answer: Same method, different tools. (1) Get the actual plan — DBMS_XPLAN.DISPLAY_CURSOR(..., 'ALLSTATS LAST') on Oracle, EXPLAIN (ANALYZE, BUFFERS) or auto_explain on PostgreSQL — and compare estimated with actual rows. (2) Ask whether statistics just changed: a fresh DBMS_STATS gather or an autovacuum ANALYZE can flip a plan. (3) Suspect parameter-specific plans: bind peeking plus skew on Oracle, the custom-to-generic switch on PostgreSQL. (4) Look at waits: AWR/ASH; pg_stat_activity.wait_event, pg_locks, pg_stat_io. (5) Consider growth or bloat: a crossed threshold changed the plan, an index went unusable, or autovacuum fell behind and the table doubled. Fixes: a SQL Plan Baseline or fixed statistics on Oracle; extended statistics, an index or a rewritten predicate on PostgreSQL. The senior key on both: find what changed, don't guess.
- PL/SQL and PL/pgSQL both move code to the data — gold for batch/ETL, wrong for scale-out business logic.
BULK COLLECT+FORALLwithLIMITbeats the context switch on Oracle; staying set-based with arrays andunnestis the PostgreSQL equivalent. Kill row-by-row loops and every swallowed catch-all. - Porting landmines in procedural code:
SELECT INTO STRICT, no autonomous transactions, noSAVE EXCEPTIONS,COMMITforbidden inside a block with an exception handler, packages becoming schemas. - ROWNUM before ORDER BY, ROW_NUMBER() after it, ROWID/ctid are physical addresses — and
ctidis far less stable.FETCH FIRSTfor portability, keyset pagination for depth. - Performance is CBO + healthy statistics + bind variables on both; the deep difference is Oracle's shared library cache versus PostgreSQL's per-backend planning. Learn multi-column statistics, and compare estimated with actual rows before touching a hint.
- Indexes: leading column, covering (
INCLUDEvs trailing columns), bitmap only for read-only Oracle tables, BRIN only for clustered PostgreSQL data, partial indexes as a PostgreSQL superpower — and index-only scans depend on the visibility map. - Partition on a key that matches your
WHERE; Oracle adds interval partitioning and global indexes, PostgreSQL gives it away free with local indexes only. - Transactions: undo/redo/SCN versus WAL/xmin-xmax/LSN. Readers never block writers on either side; the bills are
ORA-01555versus bloat and VACUUM.SKIP LOCKEDis the shared superpower. - Architecture: RAC = local HA, Data Guard = DR, Exadata = Smart Scan, PDB = consolidation; PostgreSQL answers with streaming replication + Patroni, sharding via Citus, and no RAC at all.
- Oracle or PostgreSQL: enterprise depth and operational maturity versus zero cost and an open ecosystem. The heaviest migration risks are PL/SQL rewrites and semantics — empty string, case folding,
SELECT INTO, VACUUM. Never big-bang.