Databases & SQL · پایگاهداده و SQL سنیورSenior ~94 دقیقه مطالعه~78 min read
PostgreSQL/PostGIS، Oracle/PL-SQL و بهینهسازی کوئریPostgreSQL/PostGIS, Oracle/PL-SQL & Query Tuning
این فصل بهینهسازی کوئری را برای هر دو موتور PostgreSQL و Oracle کنار هم میآموزد — از cardinality و آمار و histogram تا EXPLAIN (ANALYZE, BUFFERS) و DBMS_XPLAN، از pg_stat_statements و auto_explain تا AWR/ASH و SQL Trace، و از طراحی ایندکس و bind variable و SQL Plan Baseline تا bloat/undo، partitioning، PostGIS/Oracle Spatial و PL/SQL.This chapter teaches query tuning for PostgreSQL and Oracle side by side — cardinality, statistics and histograms; EXPLAIN (ANALYZE, BUFFERS) versus EXPLAIN PLAN and DBMS_XPLAN; pg_stat_statements and auto_explain versus AWR/ASH and SQL Trace; index design, bind variables and SQL Plan Baselines; bloat versus undo, partitioning, PostGIS/Oracle Spatial and PL/SQL.
کوئریای که دیروز ۸ میلیثانیه طول میکشید و امروز ۴۰ ثانیه، «کندتر نشده» است. یک تصمیم عوض شده: آمار (statistics) بیات شده، مقدارِ یک bind فرق کرده، ایندکس دیگر انتخاب نمیشود، یا جدول از یک آستانه رد شده. بهینهسازی یک کیسهی حقه نیست — یک انضباط است: تصمیمی که دیتابیس گرفته را بخوان، عددِ غلط را داخلش پیدا کن، و همان عدد را درست کن.
این فصل همین انضباط را دو بار یاد میدهد: یکبار با ابزارهای PostgreSQL و یکبار با ابزارهای Oracle. تئوری مشترک است؛ ابزار اصلاً مشترک نیست. هر قطعهکدِ SQL این فصل یک تبِ PostgreSQL و یک تبِ Oracle دارد — موتور خودت را انتخاب کن، سایت انتخابت را به یاد میسپارد.
- واژهنامه: cost، cardinality، selectivity، access path، block/buffer، sargable.
- خط لوله: parse ← optimize ← execute، و جایی که دو موتور از هم جدا میشوند (shared pool در برابرِ نبودِ plan cacheِ مشترک).
- عددی که همهچیز را تعیین میکند: cardinality، آمار، histogram —
ANALYZEدر برابرDBMS_STATS. - خواندنِ پلن:
EXPLAIN (ANALYZE, BUFFERS)در برابرEXPLAIN PLAN+DBMS_XPLAN.DISPLAY_CURSOR+ AUTOTRACE + SQL Trace/tkprof. - پیداکردنِ کوئریِ کند:
pg_stat_statements/auto_explainدر برابر AWR / ASH / ADDM / SQL Monitor. - access pathها و روشهای join، سپس طراحی ایندکس: ترتیبِ ستونها، partial، covering (
INCLUDE)، GiST/GIN/BRIN در برابرِ bitmap، function-based index، IOT. - bind variable و پایداریِ پلن: hard parse، bind peeking، adaptive cursor sharing، generic plan، SQL Plan Baseline، هینتها.
- مالیاتِ MVCC: bloat، HOT، autovacuum در برابر undo و
ORA-01555. - تنظیمات حافظه، partitioning، parallelism، PostGIS/Oracle Spatial، و bulk binding در PL/SQL.
- یک روشِ هفتمرحلهای، فهرستِ ضدالگوها، و ۱۵ پرسش مصاحبه — چندتایشان دقیقاً دربارهی تفاوتهای Oracle و PostgreSQL.
بخش ۰ — واژههایی که باید مالِ خودت باشند
- execution plan (پلن): دستورالعملِ گامبهگامی که دیتابیس انتخاب کرده — «این ایندکس را بخوان، بعد hash join بزن». مثل مسیری که GPS از میان دهها مسیر انتخاب میکند.
- cost (هزینه): یک عددِ داخلیِ بیواحد؛ بهینهساز ارزانترین پلن را برمیدارد. میلیثانیه نیست. مقایسهی cost بینِ دو کوئریِ متفاوت بیمعناست؛ مقایسهی دو پلنِ یک کوئری، تمامِ ماجراست.
- cardinality: تعدادِ ردیفی که یک مرحله انتظار دارد تولید کند — تأثیرگذارترین عددِ کلِ سیستم.
- selectivity: کسری از ردیفها که یک predicate نگه میدارد. cardinality = selectivity × تعداد ردیفها.
- block / page / buffer: تکهی ثابتی که لایهی ذخیرهسازی میخواند (بهصورت پیشفرض ۸ کیلوبایت در هر دو موتور). دیتابیس هیچوقت «یک ردیف» از دیسک نمیخواند — کلِ بلاکِ حاویِ آن را میخواند. logical read یعنی در حافظه پیدایش کرد؛ physical read یعنی رفت سراغ دیسک.
- access path: چگونگیِ رسیدن به ردیفهای یک جدول — full scan، index range scan، جستوجو با rowid.
- sargable: predicateای که ایندکس میتواند بهصورت range جوابش را بدهد.
placed_at >= DATE '2026-01-01'هست؛EXTRACT(YEAR FROM placed_at) = 2026نیست، چون ستون داخل یک تابع دفن شده و ترتیبِ ایندکس دیگر به کار نمیآید. - statistics (آمار): خلاصهای که بهینهساز از دادهی تو دارد — تعداد ردیف، مقادیر یکتا، پرتکرارها، histogram. اگر آمار دروغ بگوید، هر تصمیمِ بعدی غلط است.
پلنِ بد تقریباً همیشه یعنی تخمینِ ردیفِ بد. پیش از آنکه سراغ هینت و ایندکس و پارامتر بروی، اولین گرهای از پلن را پیدا کن که تخمینِ ردیف و ردیفِ واقعی یک مرتبهی بزرگی با هم فرق دارند. همانجا حقیقت شکسته؛ باقیِ پلن فقط استدلالِ درستِ بهینهساز از رویِ یک دروغ است.
اسکیمای نمونهای که کلِ فصل تیون میکنیم
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
country_code char(2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id),
status text NOT NULL, -- NEW / PAID / SHIPPED / CANCELLED
total numeric(12,2) NOT NULL,
placed_at timestamptz NOT NULL DEFAULT now()
);CREATE TABLE customers (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email VARCHAR2(320) NOT NULL UNIQUE,
country_code CHAR(2) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);
CREATE TABLE orders (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id NUMBER NOT NULL REFERENCES customers(id),
status VARCHAR2(16) NOT NULL, -- NEW / PAID / SHIPPED / CANCELLED
total NUMBER(12,2) NOT NULL,
placed_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);۱. اوراکل رشتهی خالی را NULL میداند. WHERE status = '' در اوراکل هرگز هیچ ردیفی را match نمیکند. پستگرس '' و NULL را کاملاً جدا نگه میدارد.
۲. text در برابر VARCHAR2(n). text پستگرس بیکران و رایگان است. در اوراکل باید طول انتخاب کنی — و انتخابِ VARCHAR2(4000) «محضِ احتیاط» یک اشتباهِ واقعیِ تیونینگ است: اوراکل اندازهی workareaهای sort و hash را از طولِ اعلامشده برآورد میکند، پس ستونهای بیشازحد بزرگ تخمینِ حافظه را باد میکنند و sort را به دیسک میفرستند.
هر موتور چطور یک کوئری را پردازش میکند
کپشن دوزبانه — The query pipeline in both engines / خط لولهی پردازش کوئری در هر دو موتور:
flowchart LR
A[SQL text] --> B[Parse + semantic check]
B --> C{Plan cached?}
C -- yes --> F[Execute]
C -- no --> D[Rewrite / transform]
D --> E[Cost-based optimizer picks a plan]
E --> F[Execute]
F --> G[Rows to client]
هرگز کوئریات را اجرا نمیکند تا ببیند چه چیزی سریع است. شرط میبندد: آماری که دارد (شاید مالِ هفتهها پیش) را برمیدارد، مسیرهای محتمل را میشمارد، هرکدام را قیمت میزند و رویِ ارزانترین شرط میبندد. مثل بوکمیکر، وقتی دفترچهی فرم دقیق باشد عالی است و وقتی بیات باشد فاجعه. نود درصدِ تیونینگ یعنی درستکردنِ دفترچهی فرم، نه بحث با بوکمیکر.
تفاوتِ ساختاریای که باید بدانی:
- اوراکل پلنهای parseشده را در shared pool (library cache) نگه میدارد، با کلیدِ متنِ دقیقِ SQL. سشنها یک پلن را share میکنند. parse ارزان است اگر bind variable بهکار ببری، و ویرانگر است اگر نبری. یک
sql_idمیتواند چندین child cursor داشته باشد، هرکدام باplan_hash_valueخودش. - پستگرس هیچ plan cacheِ مشترکِ بینسشنی ندارد. هر backend هر statement را خودش plan میکند، مگر آن سشن از
PREPARE/پروتکلِ extended استفاده کرده باشد — که آنوقت پلن فقط در همان سشن کش میشود. planning نسبتاً ارزان است، پس «طوفانِ hard parse» بیماریِ اوراکل است نه پستگرس.
اوراکل خودِ پلن را در V$SQL_PLAN و تاریخچهاش را در AWR با plan_hash_value در هر snapshot نگه میدارد، پس تغییرِ پلن مستقیماً قابل مشاهده است. pg_stat_statements متن را به queryid نرمال میکند و هیچ پلنی ذخیره نمیکند. در پستگرس برای همان سؤال به auto_explain یا یک ابزارِ snapshotگیرِ بیرونی نیاز داری.
عددی که همهچیز را تعیین میکند: cardinality و آمار
به شرکت اسبابکشی میگویی «حدود ده کارتن». یک وانتِ کوچک میفرستند. تو در واقع چهارصد کارتن داری. راننده احمق نیست — او برای ده کارتن عالی برنامه ریخته بود. nested loop همان وانتِ کوچک است: برای ده ردیف درخشان، برای چهارصد هزار ردیف فاجعه. اندازهی وانت را تماماً تخمینِ ردیف تعیین میکند.
تازهکردنِ آمار
-- پستگرس: ANALYZE از جدول نمونه میگیرد و pg_statistic را تازه میکند.
ANALYZE orders;
VACUUM (ANALYZE, VERBOSE) orders; -- پاکسازی و آمار در یک پاس
-- بالابردن دقتِ histogram روی یک ستونِ skewed
-- (پیشفرض default_statistics_target برابر ۱۰۰ است؛ این آن را override میکند):
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
-- کاری کن autoanalyze روی جدول بزرگ عقب نماند:
ALTER TABLE orders SET (autovacuum_analyze_scale_factor = 0.01);-- اوراکل: تنها راهِ پشتیبانیشده DBMS_STATS است.
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(
ownname => USER,
tabname => 'ORDERS',
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
method_opt => 'FOR ALL COLUMNS SIZE AUTO',
cascade => TRUE, -- آمار ایندکسها هم جمع شود
degree => 4); -- جمعآوریِ موازی
END;
/
-- histogram بزرگ روی یک ستونِ skewed:
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(USER, 'ORDERS',
method_opt => 'FOR COLUMNS SIZE 254 STATUS');
END;
/در پستگرس ANALYZE همان فرمانِ آمار است. در اوراکل ANALYZE TABLE ... COMPUTE STATISTICS برای آمارِ بهینهساز منسوخ (deprecated) است — و تله همین است که هنوز اجرا میشود، اما چیزی که CBOِ مدرن لازم دارد را تولید نمیکند. از DBMS_STATS استفاده کن. ANALYZE اوراکل فقط برای کارهایی مثل LIST CHAINED ROWS و VALIDATE STRUCTURE زنده مانده است. تلهی محبوبِ مصاحبهگرها.
autovacuum پستگرس autoanalyze هم میکند: با ۵۰ ردیف + ۱۰٪ از جدول تغییریافته. جابِ شبانهی automatic optimizer statistics اوراکل هم سراغ آبجکتهایی میرود که بیش از ۱۰٪ تغییر کردهاند (DBA_TAB_MODIFICATIONS). روی جدولِ یکمیلیاردی، ۱۰٪ یعنی صد میلیون تغییر تا اولین refresh — خیلی شل. بعد از هر bulk load دستی آمار بگیر، منتظر نمان.
خواندنِ آماری که داری
SELECT attname, n_distinct, null_frac,
most_common_vals, most_common_freqs, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('status', 'placed_at');
SELECT relname, n_live_tup, last_analyze, last_autoanalyze
FROM pg_stat_user_tables WHERE relname = 'orders';SELECT column_name, num_distinct, num_nulls, density,
histogram, -- NONE / FREQUENCY / TOP-FREQUENCY / HEIGHT BALANCED / HYBRID
num_buckets, last_analyzed
FROM user_tab_col_statistics
WHERE table_name = 'ORDERS' AND column_name IN ('STATUS', 'PLACED_AT');
SELECT table_name, num_rows, blocks, avg_row_len, last_analyzed, stale_stats
FROM user_tab_statistics WHERE table_name = 'ORDERS';بدون histogram، بهینهساز توزیع یکنواخت فرض میکند: ۴ وضعیت ⇒ هرکدام ۲۵٪. اما دادهی واقعی skewed است — ۹۶٪ SHIPPED، ۰٫۱٪ CANCELLED. فرضِ یکنواختی باعث میشود برای CANCELLED ایندکس را رد کند و برای SHIPPED ایندکس را انتخاب کند: هر دو غلط.
- پستگرس همیشه یک فهرستِ پرتکرارها (
most_common_vals/most_common_freqs) بهعلاوهیhistogram_boundsبا عمقِ یکسان نگه میدارد. یک مکانیزم، همیشه روشن. - اوراکل نوعِ histogram را انتخاب میکند: frequency (تا ۲۵۴ مقدار یکتا، دقیق)، top-frequency، hybrid و height-balancedِ قدیمی.
SIZE AUTOبر اساسِ استفادهی ثبتشدهی ستون تصمیم میگیرد — یعنی اوراکل روی ستونی که هیچ کوئریای تا حالا فیلترش نکرده histogram نمیسازد. روی یک کلونِ تازه آمار بگیر و histogramهایت بیسروصدا با production فرق میکنند.
ستونهای همبسته — جایی که هر دو بهینهساز دروغ میگویند
هر دو موتور predicateها را مستقل فرض میکنند و selectivityها را در هم ضرب میکنند. WHERE city = 'Tehran' AND country_code = 'IR' را (۱/تعداد شهرها × ۱/تعداد کشورها) تخمین میزنند — صد برابر کمتر از واقعیت، چون هر ردیفِ تهران همان ردیفِ IR است. هر دو درمان دارند و درمانها هیچ شباهتی به هم ندارند:
-- پستگرس: یک آبجکت آمار توسعهیافته (از PG 10؛ لیست MCV از PG 12)
CREATE STATISTICS customers_geo (ndistinct, dependencies, mcv)
ON country_code, city FROM customers;
ANALYZE customers;
-- آمارِ عبارت (expression) رایگان همراهِ expression index میآید:
CREATE INDEX customers_email_lower_idx ON customers (lower(email));-- اوراکل: column group (آمار توسعهیافته)
DECLARE cg VARCHAR2(64);
BEGIN
cg := DBMS_STATS.CREATE_EXTENDED_STATS(USER, 'CUSTOMERS', '(COUNTRY_CODE, CITY)');
END;
/
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(USER, 'CUSTOMERS',
method_opt => 'FOR ALL COLUMNS SIZE AUTO FOR COLUMNS (COUNTRY_CODE, CITY)');
END;
/
-- آمارِ عبارت، بدون ساختِ هیچ ایندکسی:
DECLARE e VARCHAR2(64);
BEGIN
e := DBMS_STATS.CREATE_EXTENDED_STATS(USER, 'CUSTOMERS', '(UPPER(EMAIL))');
END;
/اگر تابعِ داخلِ predicate گریزناپذیر است، معمولاً تخمین را میخواهی درست کنی، نه ایندکسِ جدید. آمارِ عبارت در اوراکل دقیقاً همین را رایگان میدهد. پستگرس معادلِ مستقل ندارد — expression index میسازی و آمارش را بهعنوانِ اثرِ جانبی میگیری، و هزینهی فضا و نوشتنِ ایندکس را میپردازی.
خواندنِ پلن — PostgreSQL
EXPLAIN پلن و تخمینها را نشان میدهد. EXPLAIN ANALYZE واقعاً کوئری را اجرا میکند و تخمین را کنارِ واقعیت میگذارد. همین کلمهی دوم، تمامِ ماجراست.
-- فرمانی که باید غریزی تایپ کنی (و DML را حتماً در rollback بپیچی!):
BEGIN;
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT o.id, o.total, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'NEW'
AND o.placed_at >= now() - interval '7 days';
ROLLBACK;-- اوراکل: statement را برچسب بزن، اجرا کن، بعد cursorِ واقعی را بخوان.
ALTER SESSION SET statistics_level = ALL; -- یا هینتِ زیر را برای هر statement
SELECT /*+ GATHER_PLAN_STATISTICS */ o.id, o.total, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'NEW'
AND o.placed_at >= SYSTIMESTAMP - INTERVAL '7' DAY;
SELECT * FROM TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST +COST +BYTES'));در پستگرس EXPLAIN ANALYZE DELETE ... واقعاً حذف میکند؛ همیشه در BEGIN ... ROLLBACK بپیچ. EXPLAIN PLAN FOR ... اوراکل دقیقاً به این دلیل روی DML امن است که هرگز اجرا نمیکند — و دقیقاً به همین دلیل هم دروغ میگوید: bind peeking ندارد، adaptive plan را نادیده میگیرد، و مرتباً پلنی نشان میدهد که production از آن استفاده نمیکند. وقتی کسی میگوید «ولی پلن که خوب است»، بپرس با کدام فرمان گرفته شده.
خروجیِ پستگرس را از درون به بیرون بخوان و این پنج چیز را به همین ترتیب چک کن:
Nested Loop (cost=0.86..2451.07 rows=812 width=48)
(actual time=0.041..913.220 rows=214883 loops=1)
Buffers: shared hit=629104 read=18422
-> Index Scan using orders_status_placed_idx on orders o
(cost=0.43..118.52 rows=812) (actual time=0.021..71.004 rows=214883 loops=1)
Index Cond: ((status = 'NEW') AND (placed_at >= ...))
-> Index Scan using customers_pkey on customers c
(cost=0.43..2.87 rows=1) (actual time=0.003..0.003 rows=1 loops=214883)
Planning Time: 0.211 ms
Execution Time: 940.882 ms-- شکلهای «فقط تخمین» و «تخمینِ پارامتری» در پستگرس:
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'NEW';
EXPLAIN (GENERIC_PLAN) -- از PG 16
SELECT * FROM orders WHERE customer_id = $1 AND status = $2;-- شکلِ «فقط تخمین» در اوراکل، بهعلاوهی AUTOTRACE در SQL*Plus:
EXPLAIN PLAN SET STATEMENT_ID = 'q1' FOR
SELECT * FROM orders WHERE customer_id = :cid AND status = :st;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY('PLAN_TABLE', 'q1', 'ALL +OUTLINE'));
-- AUTOTRACE: پلن + consistent gets و physical readsِ واقعیِ هر اجرا
SET AUTOTRACE TRACEONLY EXPLAIN STATISTICS
SELECT * FROM orders WHERE status = 'NEW';
SET AUTOTRACE OFFخروجیِ ALLSTATS LAST اوراکل برای همان مفاهیم نامهای دیگری دارد:
---------------------------------------------------------------------------------------
| Id | Operation | Name | Starts | E-Rows | A-Rows | Buffers |
---------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | | 215K| 647K |
| 1 | NESTED LOOPS | | 1 | 812 | 215K| 647K |
| 2 | TABLE ACCESS BY INDEX ROWID| ORDERS | 1 | 812 | 215K| 18K |
|* 3 | INDEX RANGE SCAN | ORD_ST_PL_I | 1 | 812 | 215K| 1421 |
| 4 | TABLE ACCESS BY INDEX ROWID| CUSTOMERS | 215K | 1 | 215K| 629K |
|* 5 | INDEX UNIQUE SCAN | CUSTOMERS_PK| 215K | 1 | 215K| 414K |
----------------------------------------------------------------------------------------- pg_stat_statements: به shared_preload_libraries اضافه، restart، سپس:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT queryid, calls,
ROUND(total_exec_time::numeric, 1) AS total_ms,
ROUND(mean_exec_time::numeric, 3) AS mean_ms,
rows,
shared_blks_hit + shared_blks_read AS logical_reads,
left(query, 80) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
FETCH FIRST 10 ROWS ONLY;
SELECT pg_stat_statements_reset(); -- گرفتنِ خطِ پایه پیش از تستِ بار-- اوراکل: shared pool همهچیز را از قبل ثبت کرده؛ افزونه لازم نیست.
SELECT sql_id, plan_hash_value, executions,
ROUND(elapsed_time/1e6, 1) AS total_s,
ROUND(elapsed_time/GREATEST(executions,1)/1000, 3) AS mean_ms,
rows_processed,
buffer_gets AS logical_reads,
SUBSTR(sql_text, 1, 80) AS query
FROM v$sql
ORDER BY elapsed_time DESC
FETCH FIRST 10 ROWS ONLY;
-- نسخهی تاریخی (نیازمند Diagnostics Pack):
SELECT sql_id, plan_hash_value, SUM(executions_delta) execs,
ROUND(SUM(elapsed_time_delta)/1e6, 1) total_s
FROM dba_hist_sqlstat WHERE snap_id BETWEEN :s1 AND :s2
GROUP BY sql_id, plan_hash_value ORDER BY 4 DESC FETCH FIRST 10 ROWS ONLY;کوئریای که روزی یکبار ۴ ثانیه طول میکشد، خطای گردکردن است. کوئریای که ساعتی دو میلیون بار ۴ میلیثانیه طول میکشد، همان قطعیِ سرویسِ توست. هر دو ابزار وسوسهات میکنند بر اساس میانگین مرتب کنی؛ مقاومت کن. calls × mean را بهینه کن — که دقیقاً همان total_exec_time / elapsed_time است.
ثبتِ خودکارِ پلن، و اینکه زمان واقعاً کجا میرود
-- auto_explain: پلنِ هر چیزِ کندتر از ۵۰۰ میلیثانیه را لاگ کن.
LOAD 'auto_explain';
SET auto_explain.log_min_duration = '500ms';
SET auto_explain.log_analyze = on; -- تعداد ردیفِ واقعی
SET auto_explain.log_buffers = on;
SET auto_explain.log_nested_statements = on; -- داخل توابع هم
SET auto_explain.log_timing = off; -- ارزان: ردیف بدون زمانِ هر گره
SET auto_explain.sample_rate = 0.05;
-- الان زمان کجا میرود؟ (پستگرس waitها را در pg_stat_activity نمونهبرداری میکند)
SELECT wait_event_type, wait_event, state, count(*)
FROM pg_stat_activity WHERE backend_type = 'client backend'
GROUP BY 1,2,3 ORDER BY 4 DESC;-- Real-Time SQL Monitoring هر statementِ بالای ~۵ ثانیه CPU/IO یا هر
-- statementِ موازی را خودکار ثبت میکند. نیازی به راهاندازی نیست.
SELECT DBMS_SQL_MONITOR.REPORT_SQL_MONITOR(
sql_id => '7ftjftd9xdrsp', type => 'TEXT', report_level => 'ALL')
FROM dual;
-- برای statementِ سریع، اجباریاش کن: SELECT /*+ MONITOR */ ...
-- زمان کجا میرود؟ ASH هر ثانیه از هر سشنِ فعال نمونه میگیرد.
SELECT event, wait_class, COUNT(*) samples
FROM v$active_session_history
WHERE sample_time > SYSTIMESTAMP - INTERVAL '10' MINUTE
GROUP BY event, wait_class ORDER BY samples DESC FETCH FIRST 10 ROWS ONLY;زمانگیریِ هر گره، دو بار در هر ردیف ساعت را صدا میزند و میتواند ۲۰ تا ۱۰۰ درصد سربار اضافه کند. با timing خاموش، هنوز تعداد ردیفِ واقعی را داری — یعنی همان عددی که مهم است — تقریباً رایگان. با sample_rate ترکیبش کن و میتوانی برای همیشه روشن نگهش داری.
V$ACTIVE_SESSION_HISTORY، DBA_HIST_*، DBMS_WORKLOAD_REPOSITORY، ADDM و Real-Time SQL Monitoring همگی به Oracle Diagnostics Pack نیاز دارند (و SQL Tuning Advisor به Tuning Pack). کوئریزدن به آنها بدونِ لایسنس تخلفی است که خودِ DBA_FEATURE_USAGE_STATISTICS اوراکل برای ممیز ثبت میکند. جایگزینِ رایگان Statspack است (?/rdbms/admin/spcreate.sql) که گزارشِ snapshot میدهد اما ASH ندارد. تمامِ سمتِ پستگرسِ این فصل رایگان است — استدلالی واقعی و دستکمگرفتهشده در جلسهی معماری.
حقیقتِ کلِ یک سشن: SQL Trace و tkprof
-- پستگرس: هر statement را با مدتش لاگ کن، بعد با pgBadger تجمیع کن.
ALTER ROLE app_user SET log_min_duration_statement = 0;
SET log_duration = on;
-- بعد از اتمام کار برگردان:
ALTER ROLE app_user RESET log_min_duration_statement;-- تریسِ 10046 سطح ۱۲: waitها + مقادیرِ bind، برای یک سشن.
BEGIN
DBMS_MONITOR.SESSION_TRACE_ENABLE(session_id => 123, serial_num => 4567,
waits => TRUE, binds => TRUE, plan_stat => 'ALL_EXECUTIONS');
END;
/
-- ... بار کاری اجرا میشود ...
BEGIN DBMS_MONITOR.SESSION_TRACE_DISABLE(123, 4567); END;
/
SELECT value FROM v$diag_info WHERE name = 'Default Trace File';
-- از شلِ سیستمعامل:
-- tkprof orcl_ora_12345.trc out.txt sort=prsela,exeela,fchelaابزارهای EXPLAINمانند میگویند یک statement چه کرد. تریسِ 10046 میگوید یک سشن بهترتیب چه کرد — از جمله statementهایی که اصلاً نمیدانستی صادر میکند: SELECTهای پنهانِ ORM، SQLِ بازگشتیِ triggerها، ۴۰ هزار رفتوبرگشتی که یک حلقهی FOR ساخته. وقتی شکایت «جابِ شبانه کند است» باشد نه «این کوئری کند است»، سشن را trace کن. شکلِ معادلِ همین جواب در پستگرس از log_min_duration_statement = 0 بهعلاوهی pgBadger میآید.
access path: یک جدول چطور خوانده میشود
سه راهبرد. خواندنِ همهی صفحهها (full scan) — احمقانه، اما اگر ۸۰٪ نامها را میخواهی بیرقیب است. استفاده از ترتیبِ الفبا (index range scan) — عالی برای یک نام یا بازهای باریک. اول شمارهی صفحهها را جمع کن، مرتبشان کن، بعد یکبار دفترچه را به ترتیبِ صفحه بپیما (دسترسیِ bitmapی) — برنده برای چند هزار نامِ پراکنده، چون هر صفحهی فیزیکی را دقیقاً یکبار لمس میکنی.
| مفهوم | PostgreSQL | Oracle |
|---|---|---|
| خواندنِ کلِ جدول | Seq Scan |
TABLE ACCESS FULL |
| پیمودن بازهای از ایندکس و واکشیِ ردیف | Index Scan |
INDEX RANGE SCAN + TABLE ACCESS BY INDEX ROWID |
| جستوجوی کلیدِ یکتا | Index Scan روی ایندکسِ unique |
INDEX UNIQUE SCAN |
| پاسخ کاملاً از خودِ ایندکس | Index Only Scan |
نبودِ خطِ TABLE ACCESS زیر عملیاتِ ایندکس |
| خواندنِ کلِ ایندکس بهجای جدول | (معادل ندارد) | INDEX FAST FULL SCAN |
| جمعکردنِ ردیفهای پراکنده | Bitmap Index Scan + Bitmap Heap Scan |
bitmap index / BITMAP CONVERSION |
| پرش از ستونِ پیشروی ایندکسِ ترکیبی | (پشتیبانی نمیشود) | INDEX SKIP SCAN |
-- مقایسهی تجربیِ access pathها با خاموشکردنِ یکی:
SET enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'CANCELLED';
RESET enable_seqscan;
-- index-only scan به visibility mapِ بهروز نیاز دارد:
VACUUM (ANALYZE) orders;
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id FROM orders WHERE status = 'NEW';-- همان آزمایش، با هینت:
SELECT /*+ FULL(o) */ * FROM orders o WHERE status = 'CANCELLED';
SELECT /*+ INDEX(o orders_status_idx) */ * FROM orders o WHERE status = 'CANCELLED';
-- index-only در اوراکل: اگر ایندکس همهی ستونهای ارجاعشده را پوشش دهد،
-- خطِ TABLE ACCESS BY INDEX ROWID اصلاً نمیآید. visibility map در کار نیست.
SELECT /*+ INDEX(o orders_status_cust_idx) */ customer_id
FROM orders o WHERE status = 'NEW';ورودیهای ایندکس در پستگرس هیچ اطلاعاتی دربارهی visibility ندارند، پس index-only scan باید visibility map را بپرسد؛ اگر صفحهی heap علامتِ all-visible نداشته باشد (نوشتنِ اخیر، عقبماندگیِ autovacuum)، ردیف باز هم از heap واکشی میشود — با نامِ Heap Fetches: 918273. اوراکل چنین مشکلی ندارد: اطلاعاتِ نسخه در undo است نه در ایندکس، پس دسترسیِ index-only واقعاً index-only است. یکی از عمیقترین تفاوتهای ساختاریِ دو موتور و یک سؤالِ مصاحبهی درجهیک.
ترتیبِ منطقیِ ایندکس چقدر با ترتیبِ فیزیکیِ جدول میخواند؟ اگر بخواند، یک range scan بلاکهای کمی لمس میکند؛ اگر تصادفی باشد، تقریباً هر ردیف یک بلاکخوانیِ جدا هزینه دارد. اوراکل این را در USER_INDEXES.CLUSTERING_FACTOR نگه میدارد (نزدیک به BLOCKS عالی، نزدیک به NUM_ROWS فاجعه)؛ پستگرس در pg_stats.correlation (نزدیک ±۱ عالی، نزدیک صفر فاجعه). هر دو یک معما را توضیح میدهند — «چرا بهینهساز ایندکسِ کاملاً خوبِ من را رد میکند؟» — و هر دو یک جفت درمان دارند: جدول را فیزیکی مرتب کن (CLUSTER در پستگرس، ALTER TABLE ... MOVE ONLINE یا IOT یا partitioning در اوراکل)، یا ایندکس را covering کن تا اصلاً به جدول دست نزند.
CLUSTER orders USING orders_placed_at_idx; -- قفلِ ACCESS EXCLUSIVE میگیرد
ANALYZE orders;
SELECT attname, correlation FROM pg_stats
WHERE tablename = 'orders' AND attname = 'placed_at';ALTER TABLE orders MOVE ONLINE; -- بازسازیِ آنلاین از 12c
ALTER INDEX orders_placed_at_idx REBUILD ONLINE;
SELECT index_name, clustering_factor, num_rows, leaf_blocks
FROM user_indexes WHERE table_name = 'ORDERS';روشهای join
کپشن دوزبانه — Choosing a join method by input size / انتخاب روش join بر اساس اندازهی ورودی:
flowchart TD
A[Two row sources to join] --> B{Outer side tiny AND<br/>inner has a selective index?}
B -- yes --> NL[Nested Loop]
B -- no --> C{Both sides already sorted<br/>on the join key?}
C -- yes --> MJ[Merge / SORT MERGE]
C -- no --> D{Smaller side fits in<br/>work_mem / PGA?}
D -- yes --> HJ[Hash Join]
D -- no --> HJD[Hash Join spilling to disk]
- nested loop: بهازای هر ردیفِ بیرونی، یکبار سمتِ درونی را probe میکند. هزینه ≈ ردیفهای بیرونی × هزینهی probe. فقط وقتی قابل قبول است که probe یک seekِ ایندکسی باشد — و وقتی تخمینِ سمتِ بیرونی کم باشد فاجعهبار شکست میخورد. همان وانتِ کوچک با چهارصد کارتن.
- hash join: روی ورودیِ کوچکتر یک جدولِ hash میسازد و بزرگتر را از آن عبور میدهد. تقریباً خطی؛ جوابِ درست برای equijoinهای بزرگ و بدون ایندکس.
- merge / sort-merge: هر دو ورودی را روی کلید مرتب میکند و همگام میپیماید. وقتی برنده است که ورودیها از قبل مرتب برسند، یا (در اوراکل) برای بعضی non-equijoinها.
-- تشخیص با خاموشکردنِ یک روش، بعد رفعِ علتِ اصلی.
SET enable_nestloop = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT /* ... */ 1;
RESET enable_nestloop;
SET work_mem = '256MB'; -- در سطح سشن؛ حواست به ضریبِ پایین باشد
SET join_collapse_limit = 8; -- چقدر برای ترتیبِ join جستوجو کند
SET geqo_threshold = 12; -- بالاتر از این، جستوجوی ژنتیک (تقریبی)-- در اوراکل هینتها دستور هستند: تا وقتی قانونی باشند اجرا میشوند.
SELECT /*+ LEADING(o c) USE_NL(c) INDEX(c customers_pk) */ o.id, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id;
SELECT /*+ LEADING(c o) USE_HASH(o) */ o.id, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id;
ALTER SESSION SET pga_aggregate_target = 2G;
-- جابِ دستهای که workareaِ بزرگ میخواهد:
ALTER SESSION SET workarea_size_policy = MANUAL;
ALTER SESSION SET hash_area_size = 268435456;گرانترین پیکربندیِ غلط در پستگرس. work_mem بودجهی هر گرهِ sort/hash/materialize در هر workerِ موازی است: ۳ تا hash join × ۵ پروسه = ۱۵ برابرِ work_mem برای یک کوئری. اگر روی سروری با ۱۰۰ کانکشن مقدارش را ۱ گیگابایت بگذاری، صدها گیگابایت مجوز دادهای، و سقفی جز OOM killer وجود ندارد. سراسری کم بگذار (۱۶ تا ۶۴ مگابایت) و برای همان جابی که لازم دارد در سطح سشن بالا ببر. pga_aggregate_target اوراکل طراحیِ معکوس است: یک بودجهی سراسری که پویا بین سشنها تقسیم میشود، با pga_aggregate_limit بهعنوان سقفِ سخت که بهجای کلِ instance، سشنِ خاطی را میکشد.
پستگرس: Sort Method: external merge Disk: 412032kB. اوراکل: ستونهای OMem/1Mem/Used-Mem و Used-Tmp در خروجیِ ALLSTATS — مقدارِ 2048K (1) یعنی یک اجرای one-pass، (0) یعنی بهینه (کاملاً در حافظه) و multi-pass یعنی فاجعه.
طراحی ایندکس — هنرِ اصلی
همهی اهالیِ «تهران» را فوری پیدا میکنی، و همهی «احمدی»ها را درونِ تهران هم فوری. اما همهی احمدیهای کشور را نمیتوانی، مگر اینکه همهی شهرها را بخوانی. ایندکسِ ترکیبی دقیقاً همین است: ستونهای پیشرو پیشوندِ قابلاستفادهاند؛ ستونهای بعدی نه.
قواعد، به ترتیبِ اهمیت:
۱. اول predicateهای تساوی، آخر predicateِ بازهای. برای WHERE status = ? AND placed_at >= ? ایندکس باید (status, placed_at) باشد. بهمحضِ اینکه ایندکس یک بازه را بپیماید، ستونهای بعدش درونِ آن بازه دیگر مرتب نیستند.
۲. بعد ستونهایی که فقط برای خروجی لازماند (covering)، نه برای فیلتر.
۳. بعد ORDER BY — ایندکسِ منطبق ترتیب را رایگان میدهد و گرهِ sort را حذف میکند، اما فقط اگر ترتیبِ ستونها و جهتشان بخواند.
-- تساوی، بعد بازه، بعد payloadِ covering (از PG 11).
CREATE INDEX orders_status_placed_idx
ON orders (status, placed_at) INCLUDE (total, customer_id);
-- ایندکسِ partial: فقط ردیفهایی که واقعاً کوئری میکنی.
CREATE INDEX orders_open_idx ON orders (placed_at)
WHERE status IN ('NEW', 'PAID');
-- ایندکسِ عبارت، و ایندکسِ نزولی برای انطباق با ORDER BY:
CREATE INDEX customers_email_lower_idx ON customers (lower(email));
CREATE INDEX orders_recent_idx ON orders (placed_at DESC NULLS LAST);-- اوراکل INCLUDE ندارد: ستونهای اضافه صرفاً به کلید میچسبند.
CREATE INDEX orders_status_placed_idx
ON orders (status, placed_at, total, customer_id);
-- اوراکل partial index ندارد. معادلِ اصطلاحی، یک function-based index است
-- که برای ردیفهای نامطلوب NULL ذخیره میکند (کلیدِ NULL ایندکس نمیشود):
CREATE INDEX orders_open_idx
ON orders (CASE WHEN status IN ('NEW','PAID') THEN placed_at END);
-- و کوئری باید همان عبارت را عیناً تکرار کند:
SELECT * FROM orders
WHERE CASE WHEN status IN ('NEW','PAID') THEN placed_at END >= :d;
CREATE INDEX customers_email_lower_idx ON customers (LOWER(email));
CREATE INDEX orders_recent_idx ON orders (placed_at DESC);۱. INCLUDE در برابر ستونهای کلیدیِ اضافه. payloadِ INCLUDE پستگرس فقط در صفحاتِ leaf مینشیند، جزوِ کلید نیست، صفحاتِ داخلی را بزرگ نمیکند، و میتواند به یک ایندکسِ unique اضافه شود بدون آنکه یکتایی عوض شود. اوراکل فقط میتواند به کلید اضافه کند — که قیدِ unique را میشکند. «unique روی (a) ولی covering برای (a,b)» در پستگرس ممکن است و در اوراکل ناممکن.
۲. partial index فقط پستگرس دارد. ترفندِ CASE در اوراکل کار میکند و واقعاً استفاده میشود، اما شکننده است چون کوئری باید عبارت را عیناً تکرار کند.
۳. جایگاهِ NULL باید بخواند، و بهراحتی اشتباه میشود. هر دو موتور بهصورت پیشفرض برای ASC از NULLS LAST و برای DESC از NULLS FIRST استفاده میکنند — اما همین که بنویسی ORDER BY placed_at DESC NULLS LAST و ایندکس همان جایگاه را اعلام نکرده باشد، گرهِ sort برمیگردد. پستگرس اجازه میدهد NULLS LAST را داخلِ خودِ ایندکس بپزی؛ در اوراکل باید DESC را اعلام کنی (که ایندکسِ نزولی ذخیره میکند) و بندِ NULL کوئری را با پیشفرض هماهنگ نگه داری.
فهرستِ انواعِ ایندکس — جایی که دو موتور واقعاً از هم جدا میشوند
| نیاز | PostgreSQL | Oracle |
|---|---|---|
| تساوی/بازهی مرتب | btree |
B-tree |
| کاردینالیتیِ پایین، عمدتاً خواندنی (DWH) | partial/btree + bitmapِ زماناجرا |
bitmap index |
| جستوجوی متنِ کامل | GIN روی tsvector |
Oracle Text (ایندکسِ CONTEXT) |
| در بر داشتنِ JSON | GIN با jsonb_path_ops |
JSON search / multivalue index (از 21c) |
| هندسه، بازه، نزدیکترین همسایه | GiST، SP-GiST |
ایندکسِ دامنهایِ Oracle Spatial |
| جدولِ عظیمِ فقطافزودنی، ایندکسِ کوچک | BRIN |
(ندارد — از partitioning استفاده کن) |
| جدولی که داخلِ ایندکسش ذخیره شود | (ندارد؛ CLUSTER یکبارمصرف است) |
Index-Organized Table |
-- BRIN: جدولِ ۲۰۰ گیگابایتیِ فقطافزودنی، ایندکسی در حدِ چند مگابایت.
CREATE INDEX gps_pings_brin ON gps_pings USING brin (recorded_at)
WITH (pages_per_range = 32);
CREATE INDEX orders_meta_gin ON orders USING gin (meta jsonb_path_ops);
SELECT * FROM orders WHERE meta @> '{"channel":"mobile"}';
-- پستگرس bitmap INDEX ندارد. در زمانِ اجرا از روی btreeهای معمولی
-- یک bitmap میسازد — که چیز کاملاً دیگری است:
EXPLAIN SELECT * FROM orders WHERE status = 'NEW' AND country_code = 'IR';
-- -> BitmapAnd -> Bitmap Index Scan ... , Bitmap Index Scan ...-- اوراکل: یک bitmap indexِ واقعی و ماندگار — یک bitmap بهازای هر مقدار.
CREATE BITMAP INDEX orders_status_bmx ON orders (status);
CREATE BITMAP INDEX orders_country_bmx ON orders (country_code);
SELECT * FROM orders WHERE status = 'NEW' AND country_code = 'IR';
-- Index-Organized Table: خودِ جدول همان B-treeِ کلیدِ اصلی است.
CREATE TABLE order_items (
order_id NUMBER, line_no NUMBER, sku VARCHAR2(64), qty NUMBER,
CONSTRAINT pk_oi PRIMARY KEY (order_id, line_no)
) ORGANIZATION INDEX PCTTHRESHOLD 20 OVERFLOW TABLESPACE users;بهروزرسانیِ یک ردیف در bitmap indexِ اوراکل کلِ یک قطعهی bitmap را قفل میکند، بالقوه هزاران ردیف؛ پس تراکنشهای همزمان روی ردیفهای نامرتبط همدیگر را بلاک میکنند و deadlock عادی میشود. جای bitmap index انبارِ داده است، نه جدولِ تراکنشی. جدا از این: «Bitmap Heap Scan» پستگرس اصلاً bitmap index نیست. یک تکنیکِ زماناجرا روی btreeهای معمولی است: bitmapِ شمارهی صفحهها را در حافظه بساز، مرتب کن، هر صفحهی heap را یکبار ببین. همان کلمه، مکانیزمِ متفاوت، بدونِ عارضهی قفل.
BRIN فقط min/max هر بازهی بلاک را نگه میدارد، پس ذرهبینی است — چند کیلوبایت برای جدولی عظیم — اما فقط اگر ستون با ترتیبِ درج همبسته باشد کار میکند (timestamp روی جدولِ فقطافزودنی، مثالِ کلاسیک). با update جدول را بههم بریز و هر بازه match میشود، یعنی full scan با چند گامِ اضافی. پاسخِ اوراکل به همین مسئله اصلاً ایندکس نیست: partition pruning روی جدولِ range-partitioned.
آزمودنِ ایندکس بدونِ تعهد به آن
-- پستگرس ایندکسِ invisible ندارد. دو گزینهی صادقانه:
CREATE EXTENSION IF NOT EXISTS hypopg;
SELECT * FROM hypopg_create_index('CREATE INDEX ON orders (customer_id, status)');
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'NEW';
SELECT hypopg_reset();
-- یا واقعاً بسازش بدون قفلکردنِ نویسندهها، و اگر ناامیدت کرد حذفش کن:
CREATE INDEX CONCURRENTLY orders_cust_status_idx ON orders (customer_id, status);
DROP INDEX CONCURRENTLY orders_cust_status_idx;-- اوراکل: INVISIBLE بساز، در یک سشن تستش کن، بعد منتشر یا حذف کن.
CREATE INDEX orders_cust_status_idx ON orders (customer_id, status) INVISIBLE ONLINE;
ALTER SESSION SET optimizer_use_invisible_indexes = TRUE;
SELECT * FROM orders WHERE customer_id = :c AND status = :s;
ALTER INDEX orders_cust_status_idx VISIBLE; -- راضی بودی
-- DROP INDEX orders_cust_status_idx; -- راضی نبودیپیش از حذفِ یک ایندکسِ ۴۰ گیگابایتی که «کسی استفاده نمیکند»، اول invisible کن. اگر گزارشِ پایانِ فصل از کار افتاد، یک ALTER INDEX ... VISIBLE در چند میلیثانیه برش میگرداند، بهجای ساعتها بازسازی. پستگرس این را ندارد، پس انضباطِ معادل این است: pg_stat_user_indexes.idx_scan را در یک چرخهی کاملِ کسبوکار (یک ماه، نه یک روز) ببین و پیش از حذف، دستورِ دقیقِ CREATE INDEX را در version control بگذار.
-- ایندکسهای وزنِ مرده (اول شمارندهها را reset کن، بعد هفتهها صبر کن):
SELECT s.relname AS table_name, s.indexrelname AS index_name, s.idx_scan,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0 AND NOT i.indisunique AND NOT i.indisprimary
ORDER BY pg_relation_size(s.indexrelid) DESC;-- اوراکل از 12.2: ردیابیِ همیشهروشنِ استفاده از ایندکس.
SELECT name, total_access_count, total_exec_count, last_used
FROM dba_index_usage WHERE owner = USER ORDER BY total_access_count;
-- روشِ قدیمی، هنوز روی نسخههای پایینتر مفید:
ALTER INDEX orders_cust_status_idx MONITORING USAGE;
SELECT index_name, used, start_monitoring FROM v$object_usage;foreign keyِ بدون ایندکس در پستگرس یک آزارِ خفیف است (حذفِ والد و ON DELETE CASCADE جدولِ فرزند را اسکن میکنند). در اوراکل یک باگِ در دسترسپذیری است: حذف یا تغییرِ کلیدِ والد وقتی FKِ فرزند ایندکس ندارد، باعث میشود اوراکل تا پایانِ آن statement یک قفلِ share روی کلِ جدولِ فرزند بگیرد و همهی DMLهای آن را بلاک کند. یک DELETE نگهداری میتواند شلوغترین جدولِ سیستم را منجمد کند. حسابرسیشان کن:
SELECT c.conrelid::regclass AS child, c.conname
FROM pg_constraint c
WHERE c.contype = 'f'
AND NOT EXISTS (SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid
AND (i.indkey::smallint[])[0:array_length(c.conkey,1)-1] @> c.conkey);SELECT c.table_name, c.constraint_name, cc.column_name
FROM user_constraints c
JOIN user_cons_columns cc ON cc.constraint_name = c.constraint_name
WHERE c.constraint_type = 'R'
AND NOT EXISTS (SELECT 1 FROM user_ind_columns ic
WHERE ic.table_name = c.table_name
AND ic.column_name = cc.column_name
AND ic.column_position = cc.position);bind variable، کشِ پلن و parameter sniffing
hard parse یعنی تراشیدنِ یک کلیدِ نو: گران، و بعد از یک بار دور انداختنش. bind variable یعنی کلید را روی جاکلیدی نگه داری؛ shared poolِ اوراکل همان جاکلیدیِ مشترکِ کلِ خانه است. اگر هر سشن برای هر در کلید بتراشد، کلیدساز — یعنی parser، که پشتِ latchهایی نگهبانی میشود که همه در صفشان میایستند — گلوگاه میشود و کلِ خانه میخوابد.
-- پستگرس: پارامتر با PREPARE / پروتکل extended (JDBC، psycopg همین را میکنند).
PREPARE find_orders (bigint, text) AS
SELECT * FROM orders WHERE customer_id = $1 AND status = $2;
EXECUTE find_orders (42, 'NEW');
SELECT name, generic_plans, custom_plans FROM pg_prepared_statements;
-- کنترلِ تصمیمِ custom در برابر generic (از PG 12):
SET plan_cache_mode = 'force_custom_plan'; -- همیشه با مقادیر واقعی پلن بزن-- اوراکل: bind variable در OLTP غیرقابل مذاکره است.
VARIABLE cid NUMBER
VARIABLE st VARCHAR2(16)
EXEC :cid := 42; :st := 'NEW';
SELECT * FROM orders WHERE customer_id = :cid AND status = :st;
-- بهینهساز موقع ساختِ این پلن به چه مقادیری PEEK کرده؟
SELECT * FROM TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST +PEEKED_BINDS'));
-- چسبِ زخمِ اضطراری برای اپ لگاسیِ literal-SQL (پیشفرض و درست: EXACT):
ALTER SYSTEM SET cursor_sharing = FORCE SCOPE = MEMORY;وقتی اوراکل یک statementِ bindدار را hard parse میکند، به اولین مقادیرِ واقعی peek میزند و برای همانها بهینه میکند. اگر اولین فراخوان status = 'CANCELLED' بوده (۰٫۱٪ ردیفها)، پلنِ کششده از ایندکس استفاده میکند و هر فراخوانِ بعدی با 'SHIPPED' (۹۶٪) همان را ارث میبرد و میمیرد. راهکار: Adaptive Cursor Sharing — اوراکل cursor را ابتدا bind-sensitive و بعد از دیدنِ تعدادِ ردیفهای خیلی متفاوت bind-aware علامت میزند و چند child cursor نگه میدارد.
پستگرس دقیقاً همین شکست را یک لایه بالاتر دارد: یک prepared statement در ۵ اجرای اول با مقادیرِ واقعی پلن میشود (custom plan)، بعد پستگرس یک generic plan میسازد و اگر هزینهی تخمینیاش از میانگینِ custom بدتر نباشد، برای همیشه در آن سشن به آن سوییچ میکند. علامتِ بیماری در هر دو موتور یکی است: اولش سریع، بعد بهطرزِ مرموزی برای همیشه کند. درمانِ پستگرس: plan_cache_mode = 'force_custom_plan'.
-- آیا قربانیِ generic plan شدهام؟ صریح مقایسه کن:
SET plan_cache_mode = 'force_generic_plan';
EXPLAIN (ANALYZE) EXECUTE find_orders (42, 'SHIPPED');
SET plan_cache_mode = 'force_custom_plan';
EXPLAIN (ANALYZE) EXECUTE find_orders (42, 'SHIPPED');
RESET plan_cache_mode;-- آیا adaptive cursor sharing فعال شد؟ یک ردیف بهازای هر child cursor:
SELECT sql_id, child_number, is_bind_sensitive, is_bind_aware, is_shareable,
executions, buffer_gets, plan_hash_value
FROM v$sql WHERE sql_id = :sid ORDER BY child_number;PgBouncer در حالتِ transaction pooling از قدیم prepared statementهای سمتِ سرور را میشکست (هر تراکنش ممکن است روی کانکشنِ سرورِ دیگری بنشیند)، و به همین دلیل خیلی تیمها JDBC را با prepareThreshold=0 اجرا میکردند و هزینهی کاملِ planning را در هر فراخوان میدادند. از PgBouncer 1.21 پشتیبانی از prepared statement در transaction mode اضافه شد — پیش از فرضِ هر رفتاری، نسخهات را چک کن. shared poolِ اوراکل سمتِ سرور و مستقل از pooler است، اما خطرِ آینهای دارد: تنظیماتِ سشن (optimizer_mode، NLS، cursor_sharing) بین سشنهای poolشده نشت میکنند و پلنِ مشتریِ بعدی را عوض میکنند.
پایداریِ پلن: خوب را خوب نگهداشتن
اینجا دو موتور اصلاً هموزن نیستند.
-- پستگرس: نه baseline، نه stored outline، نه هینتِ درونSQL در هسته.
-- اهرمهای صادقانه، به ترتیبِ اولویت:
-- ۱. تخمین را درست کن:
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
CREATE STATISTICS orders_stat (dependencies, mcv) ON status, customer_id FROM orders;
ANALYZE orders;
-- ۲. مدلِ هزینه را با سختافزار هماهنگ کن:
SET random_page_cost = 1.1; -- NVMe: seek تقریباً رایگان است
SET effective_cache_size = '48GB'; -- چقدر کشِ OS را میتواند فرض کند
-- ۳. سدِ ساختاری، بهعنوان آخرین راه (از PG 12):
WITH scoped AS MATERIALIZED (
SELECT id FROM orders WHERE status = 'NEW'
)
SELECT * FROM scoped JOIN order_items USING (id);
-- ۴. افزونهی جانبیِ pg_hint_plan برای هینتِ واقعی.-- اوراکل: یک زیرسیستمِ کامل دقیقاً برای همین وجود دارد.
-- ۱. هینت — دستور است، تا وقتی قانونی باشد اجرا میشود:
SELECT /*+ LEADING(o c) USE_HASH(c) INDEX(o orders_status_placed_idx) PARALLEL(o 4) */
o.id, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id;
-- ۲. SQL Plan Baseline: پلنِ خوب را ثبت کن، regression را ممنوع کن.
DECLARE n PLS_INTEGER;
BEGIN
n := DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE(
sql_id => '7ftjftd9xdrsp', plan_hash_value => 3956160932,
fixed => 'YES', enabled => 'YES');
END;
/
SELECT sql_handle, plan_name, enabled, accepted, fixed, origin
FROM dba_sql_plan_baselines;
-- ۳. SQL Profile از Tuning Advisor (ضرایبِ اصلاحیِ cardinality):
DECLARE t VARCHAR2(64);
BEGIN
t := DBMS_SQLTUNE.CREATE_TUNING_TASK(sql_id => '7ftjftd9xdrsp');
DBMS_SQLTUNE.EXECUTE_TUNING_TASK(t);
DBMS_OUTPUT.PUT_LINE(DBMS_SQLTUNE.REPORT_TUNING_TASK(t));
END;
/baseline یک فهرستِ سفیدِ پلنهای پذیرفتهشده است: بهینهساز فقط میتواند پلنی را بهکار ببرد که در baseline و ACCEPTED باشد؛ پلنهای جدید ثبت اما قرنطینه میشوند تا سریعتر بودنشان ثابت شود (DBMS_SPM.EVOLVE_SQL_PLAN_BASELINE). پاسخ میدهد به: «هرگز پسرفت نکن.»
profile اصلاً پلن نیست — اطلاعاتِ اضافه است، عمدتاً ضرایبِ اصلاحیِ cardinality که Tuning Advisor با نمونهگیری بهدست آورده. بهینهساز هنوز آزادانه انتخاب میکند، فقط با اعدادِ بهتر. پاسخ میدهد به: «اشتباه حدس میزدی، این حقیقت است.»
Stored Outline جدِ منسوخِ baseline در 9i است — نامش را بشناس، بهکارش نبر. Oracle 23ai قابلیتِ Real-Time SQL Plan Management را اضافه میکند که خودش regression را تشخیص میدهد و با ساختنِ baseline از پلنِ قبلیِ خوب ترمیمش میکند.
در هستهی پستگرس مکانیزمِ baseline وجود ندارد. pg_hint_plan هست و کار میکند، اما افزونه است، روی چند سرویسِ managed در دسترس نیست، و هینتهایش به متنِ کوئری میچسبند. پاسخِ اصطلاحی این است که پایداریِ پلن از پایداریِ تخمین میآید: statistics_target کافی، آمارِ توسعهیافته روی ستونهای همبسته، random_page_costِ منطبق با استوریج، work_mem کافی. وقتی تیمی که از اوراکل مهاجرت میکند میپرسد «baselineها کجاست؟»، جای این پاسخ در ریسکرجیسترِ مهاجرت است، نه در سورپرایزِ روزِ go-live.
از 12c اوراکل میتواند پلنی با سوییچِ زیرپلن بسازد: با nested loop شروع میکند، از طریقِ یک جمعکنندهی آماریِ درونخطی ردیفها را میشمارد، و اگر واقعیت از آستانه رد شد، در همان اجرا به hash join سوییچ میکند. DBMS_XPLAN یادداشتِ This is an adaptive plan میگذارد. مرتبط با آن: statistics feedback که در اجرای بعدی با ردیفهای تازهدیده دوباره بهینه میکند، و SQL Plan Directive که «بهینهساز این ترکیبِ ستونها را بد تخمین زد» را ماندگار میکند. با optimizer_adaptive_plans (پیشفرض TRUE) و optimizer_adaptive_statistics (پیشفرض FALSE از 12.2، بعد از اینکه پیشفرضهای 12.1 موجِ regression ساخت) کنترل میشوند. پستگرس در این خانواده هیچ چیز ندارد: پلنی که انتخاب شد، از اول تا آخر همان اجرا میشود.
مالیاتِ MVCC: bloat، HOT، vacuum و undoِ اوراکل
هر دو موتور MVCC هستند — خواننده هرگز نویسنده را بلاک نمیکند — اما بهایش را در دو جای متضاد میپردازند، و همین تفاوت خودش یک موضوعِ تیونینگ است.
پستگرس نسخهی قدیمی را با یادداشتِ «منسوخ شد» روی قفسه نگه میدارد و یک نظافتچی (VACUUM) استخدام میکند که راهروها را بگردد و نسخههایی را که دیگر کسی نمیخواهد بردارد. قفسهها بلند میشوند؛ نظافت حیاتی است. اوراکل کتاب را در جا بازنویسی میکند، اما اول از صفحهی قدیمی در اتاقِ جداگانهی undo فتوکپی میگیرد. قفسهها مرتب میمانند — اما اگر خوانندهی کند زیادی طول بدهد، ممکن است فتوکپیِ موردنیازش را قبلاً خرد کرده باشند و او را با یک خطا بیرون بیندازند.
- پستگرس:
UPDATEیعنی delete + insert. tuple قدیمی باxmaxباقی میماند تا VACUUM آزادش کند. هزینه = bloat در جدول و همهی ایندکسها. - اوراکل:
UPDATEبلاک را در جا تغییر میدهد و تصویرِ قبلی را در undo مینویسد. هزینه = فضای undo و ORA-01555 «snapshot too old» وقتی کوئریِ طولانی به undoای نیاز پیدا کند که بازنویسی شده.
-- bloat و عقبماندگیِ vacuum، بهعلاوهی نسبتِ HOT:
SELECT relname, n_live_tup, n_dead_tup,
ROUND(100.0*n_dead_tup/NULLIF(n_live_tup+n_dead_tup,0), 1) AS dead_pct,
n_tup_upd, n_tup_hot_upd,
ROUND(100.0*n_tup_hot_upd/NULLIF(n_tup_upd,0), 1) AS hot_pct,
last_autovacuum
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC FETCH FIRST 15 ROWS ONLY;
-- روی هر صفحه جا بگذار تا updateها بتوانند HOT بمانند:
ALTER TABLE orders SET (fillfactor = 85);
-- autovacuum را روی جدولِ داغ تهاجمی کن:
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_cost_delay = 0);-- اوراکل: فضای undo، مهاجرتِ ردیف، و high-water mark.
SELECT tablespace_name, status, ROUND(SUM(bytes)/1024/1024) mb
FROM dba_undo_extents GROUP BY tablespace_name, status;
SHOW PARAMETER undo_retention;
ALTER TABLESPACE undotbs1 RETENTION GUARANTEE;
-- مهاجرتِ ردیف: ردیف بزرگ شده و دیگر در بلاکش جا نمیشود.
ANALYZE TABLE orders LIST CHAINED ROWS INTO chained_rows; -- کاربردِ مشروعِ ANALYZE
SELECT COUNT(*) FROM chained_rows;
-- «fillfactor» اوراکل، بعد بازپسگیریِ فضا و ریستِ HWM:
ALTER TABLE orders PCTFREE 20;
ALTER TABLE orders ENABLE ROW MOVEMENT;
ALTER TABLE orders SHRINK SPACE COMPACT;
ALTER TABLE orders SHRINK SPACE;یک بهروزرسانیِ Heap-Only Tuple نسخهی جدید را روی همان صفحه مینویسد و به قدیمی زنجیر میکند، پس هیچ ورودیِ ایندکسی ساخته نمیشود — «۱ نوشتنِ heap + N نوشتنِ ایندکس» تبدیل میشود به «۱ نوشتنِ heap». دو شرط باید همزمان برقرار باشند: هیچ ستونِ ایندکسشدهای تغییر نکند، و روی صفحه جای خالی باشد (که همان fillfactor کمتر از ۱۰۰ میخرد). نسبتِ n_tup_hot_upd / n_tup_upd را پایش کن؛ نسبتِ پایین روی داغترین جدولت یعنی در هر update داری نگهداریِ ایندکس را میپردازی. اوراکل HOT ندارد چون در جا update میکند — نگرانیِ متناظرش مهاجرتِ ردیف است: ردیفِ بزرگشده جابهجا میشود و یک اشارهگرِ ارجاع باقی میگذارد، پس هر دسترسیِ ایندکسی دو بلاکخوانی هزینه دارد. همان ایده، همان پیشگیری: جای خالی نگه دار (PCTFREE).
در پستگرس، VACUUM فقط tupleهای قدیمیتر از افقِ xmin را میتواند بردارد — قدیمیترین snapshotی که هر تراکنشِ زندهای ممکن است لازم داشته باشد. سشنی که idle in transaction رها شده این افق را در کلِ کلاستر پایین نگه میدارد، پس tupleهای مرده در هر جدولی غیرقابلحذف میشوند و همهجا bloat میشود؛ ضمناً freezingی را که جلوی wraparound شناسهی تراکنش را میگیرد بلاک میکند (XID سیودوبیتی و چرخهای است — اگر freezing حدود ۲ میلیارد عقب بیفتد، کلاستر نوشتن را رد میکند). در اوراکل همان سشنِ فراموششده undo را پین میکند (و برای دیگران ORA-01555 میسازد) و قفلِ ردیف نگه میدارد — اما معادلِ wraparound وجود ندارد، چون SCN چهلوهشتبیتی است. این دستهی کاملِ خرابی در اوراکل اصلاً وجود ندارد.
SET idle_in_transaction_session_timeout = '60s';
SET statement_timeout = '30s';
SELECT pid, state, now() - xact_start AS xact_age, query
FROM pg_stat_activity WHERE state <> 'idle' ORDER BY xact_start
FETCH FIRST 10 ROWS ONLY;
SELECT datname, age(datfrozenxid) AS xid_age FROM pg_database ORDER BY 2 DESC;ALTER PROFILE app_profile LIMIT IDLE_TIME 5 CONNECT_TIME 480;
SELECT s.sid, s.serial#, s.username, s.last_call_et AS idle_s, q.sql_text
FROM v$transaction t
JOIN v$session s ON s.saddr = t.ses_addr
LEFT JOIN v$sql q ON q.sql_id = s.sql_id
ORDER BY t.start_time;
-- wraparound ای برای پایش وجود ندارد؛ نزدیکترین سنجه، سنِ قدیمیترین تراکنش است:
SELECT MIN(start_time) AS oldest_tx FROM v$transaction;پیکربندیِ حافظه و I/O
| موضوع | PostgreSQL | Oracle |
|---|---|---|
| کشِ مشترکِ بلاکهای داده | shared_buffers |
DB_CACHE_SIZE درونِ SGA |
| حافظهی sort/hash | work_mem (هر گره، هر worker) |
PGA با PGA_AGGREGATE_TARGET (سراسری) |
| اشاره به کشِ سیستمعامل | effective_cache_size (بدون تخصیص) |
(ندارد — اوراکل direct I/O میکند) |
| سقفِ سخت | ندارد (سقفت همان OOM killer است) | PGA_AGGREGATE_LIMIT، MEMORY_TARGET |
| هزینهی خواندنِ تصادفی | random_page_cost (پیشفرض ۴٫۰) |
system statistics، optimizer_index_cost_adj |
-- نقطهی شروعِ معقول برای سروری اختصاصی با ۶۴ گیگ رم — بعد اندازه بگیر.
ALTER SYSTEM SET shared_buffers = '16GB';
ALTER SYSTEM SET effective_cache_size = '48GB';
ALTER SYSTEM SET work_mem = '32MB'; -- کم! در سشن بالا ببر
ALTER SYSTEM SET maintenance_work_mem = '2GB';
ALTER SYSTEM SET random_page_cost = 1.1; -- SSD/NVMe
ALTER SYSTEM SET effective_io_concurrency = 200;
ALTER SYSTEM SET max_parallel_workers_per_gather = 4;
SELECT pg_reload_conf();-- pool ها را اندازه کن، بعد از advisorهای خودِ اوراکل بپرس که کمک کرده یا نه.
ALTER SYSTEM SET sga_target = 16G SCOPE = SPFILE;
ALTER SYSTEM SET pga_aggregate_target = 8G SCOPE = SPFILE;
ALTER SYSTEM SET pga_aggregate_limit = 16G SCOPE = SPFILE;
SELECT size_for_estimate AS cache_mb, estd_physical_read_factor
FROM v$db_cache_advice WHERE name = 'DEFAULT' AND block_size = 8192
ORDER BY size_for_estimate;
SELECT pga_target_for_estimate/1024/1024 AS pga_mb,
estd_pga_cache_hit_percentage, estd_overalloc_count
FROM v$pga_target_advice ORDER BY 1;
-- به CBO یاد بده استوریجِ تو واقعاً چقدر هزینه دارد:
BEGIN DBMS_STATS.GATHER_SYSTEM_STATS('INTERVAL', interval => 60); END;
/نسبتِ ۹۹٫۹٪ هم میتواند یعنی «همهچیز کش شده» و هم یعنی «یک کوئری با nested loopِ افسارگسیخته ۴۰۰ میلیون logical read میزند و همان یک بلاک را بینهایت بار میگیرد». هر دو در نسبت یکسان دیده میشوند. logical read سنجهی واقعیِ بار است — هنوز CPU و latch و ترافیکِ cache-line هزینه دارد. تیون کن تا مجموعِ بازدیدِ بلاکها کم شود، نه تا نسبتی بالا برود. هر توصیهی تیونینگی که بر hit ratio بنا شده مالِ سالِ ۱۹۹۸ است.
partitioning: تقسیم کن تا بتوانی رد شوی
با یک کشوی غولپیکر، پیداکردنِ مارس یعنی دستزدن به همهچیز. با یک کشو برای هر ماه، «فاکتورهای مارس» یعنی بازکردنِ یک کشو — و «۲۰۱۹ را پاک کن» یعنی برداشتنِ یک کشو، نه خردکردنِ کاغذ. معمولاً همین ویژگیِ دوم، یعنی حذفِ فوریِ انبوه، دلیلِ واقعیِ partitioning تیمهاست.
-- partitioningِ اعلانی (از PG 10)، بازهای بر اساس ماه.
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY,
customer_id bigint NOT NULL,
status text NOT NULL,
total numeric(12,2) NOT NULL,
placed_at timestamptz NOT NULL
) PARTITION BY RANGE (placed_at);
CREATE TABLE orders_2026_07 PARTITION OF orders
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
-- ایندکس را یکبار روی والد تعریف کن؛ پستگرس برای هر partition میسازد.
CREATE INDEX ON orders (customer_id, status);
-- حذفِ فوریِ یک ماه، بدون DELETE ردیفبهردیف:
ALTER TABLE orders DETACH PARTITION orders_2026_07 CONCURRENTLY;
DROP TABLE orders_2026_07;
SET enable_partitionwise_join = on;
SET enable_partitionwise_aggregate = on;-- اوراکل: partitioningِ INTERVAL خودش partitionهای ماهانه را میسازد.
CREATE TABLE orders (
id NUMBER GENERATED ALWAYS AS IDENTITY,
customer_id NUMBER NOT NULL,
status VARCHAR2(16) NOT NULL,
total NUMBER(12,2) NOT NULL,
placed_at DATE NOT NULL
)
PARTITION BY RANGE (placed_at)
INTERVAL (NUMTOYMINTERVAL(1, 'MONTH'))
( PARTITION orders_p0 VALUES LESS THAN (DATE '2026-07-01') );
-- ایندکسِ LOCAL = یک segment بهازای هر partition (معمولاً همان چیزی که میخواهی).
CREATE INDEX orders_cust_status_idx ON orders (customer_id, status) LOCAL;
-- ایندکسِ GLOBAL برای کلیدِ یکتایی که کلیدِ partition را در بر ندارد:
CREATE UNIQUE INDEX orders_pk ON orders (id) GLOBAL;
ALTER TABLE orders DROP PARTITION orders_p0 UPDATE GLOBAL INDEXES;
-- تعویضِ یک جدولِ staging کاملاً بارگذاریشده، در چند میلیثانیه:
ALTER TABLE orders EXCHANGE PARTITION orders_p1
WITH TABLE orders_stage INCLUDING INDEXES WITHOUT VALIDATION;WHERE placed_at >= now() - interval '7 days' prune میکند؛ WHERE to_char(placed_at,'YYYY-MM') = '2026-07' نه — تابع کلید را پنهان کرده. در اوراکل، bindِ VARCHAR2 در مقایسه با کلیدِ partitionِ DATE بیسروصدا تبدیل میشود و میتواند pruning را کاملاً از کار بیندازد: ستونهای Pstart/Pstop را ببین و دنبالِ PARTITION RANGE ITERATOR (prune شده) در برابر PARTITION RANGE ALL (prune نشده) بگرد. پستگرس partitionهای pruneشده را اصلاً در پلن نمیآورد؛ در زمانِ اجرا دنبالِ Subplans Removed: N بگرد.
partition کردنِ جدولِ ۵ گیگابایتی «چون بزرگ به نظر میرسد» معمولاً همهچیز را کندتر میکند: relationهای بیشتر برای plan و قفل، segmentهای ایندکسِ بیشتر، و هر کوئریای که کلیدِ partition را ندارد حالا همهی partitionها را اسکن میکند. وقتی partition کن که به حذف/آرشیوِ زمانی نیاز داری، یا جدول صدها گیگابایت است و کوئریها ذاتاً زمانمحورند، یا partition-wise join لازم داری. ضمناً پستگرس تا چند هزار partition روی PG 13+ راحت است اما دهها هزارتا نه؛ اوراکل خیلی بالاتر مقیاس میگیرد.
موازیسازی (parallelism)
-- پستگرس: planner تصمیم میگیرد؛ تو سقفها را میگذاری.
SET max_parallel_workers_per_gather = 4;
SET min_parallel_table_scan_size = '8MB';
EXPLAIN (ANALYZE, BUFFERS)
SELECT status, count(*), sum(total) FROM orders GROUP BY status;
-- Finalize GroupAggregate -> Gather Merge -> Partial GroupAggregate
-- Workers Planned: 4 Workers Launched: 4 <- این دو را مقایسه کن!
ALTER TABLE orders SET (parallel_workers = 8);-- اوراکل: درجهی موازیسازی با هینت، در سطح جدول، یا خودکار.
SELECT /*+ PARALLEL(o, 8) */ status, COUNT(*), SUM(total)
FROM orders o GROUP BY status;
ALTER TABLE orders PARALLEL 8;
ALTER SESSION SET parallel_degree_policy = AUTO;
ALTER SESSION SET parallel_min_time_threshold = 10; -- ثانیه
-- واقعاً موازی شد؟ و downgrade خورد؟
SELECT sql_id, px_servers_requested, px_servers_allocated, status
FROM v$sql_monitor WHERE px_servers_requested > 0;هشت worker روی یک گزارش یعنی هشت CPU که به بارِ OLTP نمیرسد. در پستگرس workerها از استخرِ کلاسترسطحِ max_parallel_workers میآیند و کوئریای که آنها را نگیرد بیصدا با تعدادِ کمتر اجرا میشود (Workers Planned: 4 Workers Launched: 0). در اوراکل همین کمبود یک downgrade است که در V$SQL_MONITOR و AWR دیده میشود، و Resource Manager دقیقاً برای مهارش وجود دارد. ضمناً پستگرس در هسته parallel DML ندارد ولی اوراکل دارد — ALTER SESSION ENABLE PARALLEL DML آنجا یک تکنیکِ استانداردِ بارگذاریِ انبوه است.
بهینهسازیِ مکانی: PostGIS و Oracle Spatial
پرسشهای GPS ناوگان («کدام خودروها همین حالا در شعاعِ ۵۰۰ متریِ این دپو هستند؟») جایی است که SQLِ سادهلوحانه کندترین و ایندکسِ مکانیِ درست چشمگیرترین است.
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE gps_pings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
vehicle_id bigint NOT NULL,
recorded_at timestamptz NOT NULL,
location geography(Point, 4326) NOT NULL
);
CREATE INDEX gps_pings_loc_gix ON gps_pings USING gist (location);
CREATE INDEX gps_pings_veh_time_idx ON gps_pings (vehicle_id, recorded_at DESC);
-- مجاورتِ SARGABLE: ST_DWithin از ایندکسِ GiST استفاده میکند.
SELECT vehicle_id, recorded_at
FROM gps_pings
WHERE ST_DWithin(location, ST_MakePoint(51.389, 35.689)::geography, 500) -- متر
AND recorded_at > now() - interval '5 minutes';
-- غیرِ sargable: تابعِ فاصله که برای هر ردیف محاسبه میشود.
-- WHERE ST_Distance(location, ST_MakePoint(51.389,35.689)::geography) < 500
-- نزدیکترین همسایهها با اپراتورِ ایندکسپذیرِ <-> :
SELECT vehicle_id, location <-> ST_MakePoint(51.389, 35.689)::geography AS m
FROM gps_pings
ORDER BY location <-> ST_MakePoint(51.389, 35.689)::geography
FETCH FIRST 5 ROWS ONLY;CREATE TABLE gps_pings (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
vehicle_id NUMBER NOT NULL,
recorded_at TIMESTAMP NOT NULL,
location SDO_GEOMETRY NOT NULL
);
-- ثبتِ ردیفِ متادیتا پیش از ساختِ ایندکسِ مکانی الزامی است:
INSERT INTO user_sdo_geom_metadata (table_name, column_name, diminfo, srid)
VALUES ('GPS_PINGS', 'LOCATION',
SDO_DIM_ARRAY(SDO_DIM_ELEMENT('LONG', -180, 180, 0.05),
SDO_DIM_ELEMENT('LAT', -90, 90, 0.05)), 4326);
COMMIT;
CREATE INDEX gps_pings_loc_sidx ON gps_pings (location)
INDEXTYPE IS MDSYS.SPATIAL_INDEX_V2;
CREATE INDEX gps_pings_veh_time_idx ON gps_pings (vehicle_id, recorded_at DESC);
-- مجاورتِ SARGABLE: شکلِ اپراتوری از ایندکسِ مکانی عبور میکند.
SELECT vehicle_id, recorded_at
FROM gps_pings
WHERE SDO_WITHIN_DISTANCE(location,
SDO_GEOMETRY(2001, 4326, SDO_POINT_TYPE(51.389, 35.689, NULL), NULL, NULL),
'distance=500 unit=M') = 'TRUE'
AND recorded_at > SYSTIMESTAMP - INTERVAL '5' MINUTE;
-- غیرِ sargable: SDO_GEOM.SDO_DISTANCE(...) < 500 در WHERE.
-- نزدیکترین همسایهها:
SELECT vehicle_id, SDO_NN_DISTANCE(1) AS m
FROM gps_pings
WHERE SDO_NN(location,
SDO_GEOMETRY(2001, 4326, SDO_POINT_TYPE(51.389, 35.689, NULL), NULL, NULL),
'sdo_num_res=5', 1) = 'TRUE'
ORDER BY m;هر دو موتور جستوجوی مکانی را به یک مرحلهی ارزانِ filter (ایندکس و جعبههای محیطی) و یک مرحلهی دقیقِ refine تقسیم میکنند. تابعِ فاصله در WHERE مستقیماً میپرد به refine — روی تکتکِ ردیفهای جدول. همیشه شکلِ اپراتوری را بهکار ببر: ST_DWithin(...) در PostGIS و SDO_WITHIN_DISTANCE(...) = 'TRUE' در اوراکل. همان تله، همان درمان، واژگانِ متفاوت.
geography در PostGIS روی بیضیگون محاسبه میکند: دقیق، اما محسوساً کندتر از geometry در یک SRIDِ تصویرشده. برای کارِ در مقیاسِ شهر، ذخیرهی geometry در یک SRIDِ محلیِ متری اغلب چند برابر سریعتر است. سمتِ اوراکل، زیرمجموعهی Locator (یعنی SDO_GEOMETRY، ایندکسِ مکانی و SDO_WITHIN_DISTANCE) با همهی ویرایشها میآید، اما network data model و GeoRaster و بعضی توابعِ تحلیلی متعلق به آپشنِ جداگانهی Oracle Spatialاند. پیش از طراحی بر پایهشان، بررسی کن.
PL/SQL در برابر PL/pgSQL: مالیاتِ context switch
موتورِ SQL و موتورِ رویهای دو اتاقاند. هر statementِ SQL داخلِ یک حلقه، یک نفر را میفرستد بین دو اتاق با یک آجر در دست. ده هزار ردیف یعنی بیست هزار رفتوبرگشت. BULK COLLECT/FORALL همان فرغون است: یک رفتوبرگشت، هزار آجر.
-- PL/pgSQL درونِ همان پروسهی backend اجرا میشود، پس context switchِ
-- SQL/PL وجود ندارد که بخواهی سرشکنش کنی. برد از SET-BASED بودن میآید.
CREATE OR REPLACE FUNCTION mark_stale_orders(p_days int)
RETURNS bigint LANGUAGE plpgsql AS $$
DECLARE n bigint;
BEGIN
-- کند: FOR r IN SELECT id FROM orders ... LOOP UPDATE ... END LOOP;
-- سریع: یک statement، یک پلن، یک پاس.
UPDATE orders
SET status = 'STALE'
WHERE status = 'NEW'
AND placed_at < now() - make_interval(days => p_days);
GET DIAGNOSTICS n = ROW_COUNT;
RETURN n;
END;
$$;
-- حذفهای بزرگ را تکهتکه کن تا یک تراکنش جدول را bloat نکند:
DELETE FROM orders
WHERE id IN (SELECT id FROM orders WHERE status = 'CANCELLED'
FETCH FIRST 10000 ROWS ONLY);-- PL/SQL در موتورِ جداگانه اجرا میشود: هر تکرارِ حلقه یک context switch
-- هزینه دارد. بهترین کار هنوز set-based است؛ دومی BULK COLLECT + FORALL.
CREATE OR REPLACE FUNCTION mark_stale_orders(p_days NUMBER) RETURN NUMBER IS
TYPE id_tab IS TABLE OF orders.id%TYPE;
v_ids id_tab;
v_cnt NUMBER := 0;
CURSOR c IS SELECT id FROM orders
WHERE status = 'NEW' AND placed_at < SYSDATE - p_days;
BEGIN
OPEN c;
LOOP
FETCH c BULK COLLECT INTO v_ids LIMIT 1000; -- LIMIT الزامی است: PGA!
EXIT WHEN v_ids.COUNT = 0;
FORALL i IN 1 .. v_ids.COUNT
UPDATE orders SET status = 'STALE' WHERE id = v_ids(i);
v_cnt := v_cnt + SQL%ROWCOUNT;
COMMIT;
END LOOP;
CLOSE c;
RETURN v_cnt;
END;
/FETCH c BULK COLLECT INTO v_ids; بدونِ LIMIT کلِ نتیجه را در PGA بار میکند — روی ده میلیون ردیف یعنی ORA-04030 و با pga_aggregate_limit تنظیمشده، یعنی کشتهشدنِ سشن. همیشه با LIMIT 100 تا 1000 تکهتکه کن. اشتباهِ آینهای در پستگرس، fetchِ سمتِ کلاینت بدونِ cursor است: JDBC و psycopg کلِ نتیجه را در کلاینت materialize میکنند مگر fetch size بگذاری و autocommit را خاموش کنی. همان باگ، حافظهی متفاوت.
هر حلقهی ردیفبهردیف تا خلافش ثابت نشود یک باگ است. اول بپرس: میشود این را یک UPDATE ... FROM یا MERGE کرد؟ اگر بله، همان را بکن — یک پلن، یک پاس، و بهینهساز هم اجازه پیدا میکند کمکت کند. کدِ رویهای برای منطقِ کنترلیای است که SQL واقعاً نمیتواند بیانش کند، نه برای تکرار.
یک روشِ تکرارپذیر برای تیونینگ
کپشن دوزبانه — The seven-step tuning loop / حلقهی هفتمرحلهای بهینهسازی:
flowchart TD
A[1. Measure: which statement owns the total time?] --> B[2. Reproduce with real bind values]
B --> C[3. Get the REAL plan with actual row counts]
C --> D[4. Find the first estimate vs actual divergence]
D --> E{Why is the estimate wrong?}
E -- stale stats --> F[Gather stats / histogram / extended stats]
E -- non-sargable --> G[Rewrite the query]
E -- no index --> H[Design the index]
F --> I[5. Re-measure the same way]
G --> I
H --> I
I --> J[6. Check the whole workload, not just this query]
J --> K[7. Pin and document the fix]
۱. اندازه بگیر، حدس نزن. pg_stat_statements / V$SQL مرتبشده بر اساسِ زمانِ کل. اگر statement در دهتای اول نیست، داری آسایشِ خودت را بهینه میکنی نه سیستم را.
۲. با bindهای واقعی بازتولید کن. پلنی که برای 'CANCELLED' ساخته شده، دربارهی شکایتِ 'SHIPPED' هیچ نمیگوید.
۳. پلنِ واقعی را بگیر: EXPLAIN (ANALYZE, BUFFERS) یا GATHER_PLAN_STATISTICS + DISPLAY_CURSOR(...,'ALLSTATS LAST'). هرگز فقط EXPLAIN PLAN.
۴. اولین واگراییِ تخمین/واقعیت را پیدا کن. همان گره نقصِ اصلی است؛ باقی نشانهاند.
۵. درمانها را به این ترتیب ترجیح بده: آمار ← بازنویسی ← ایندکس ← پیکربندی ← هینت/baseline. هینت بدهیِ دائمی است که سرِ ارتقای بعدی پرداختش میکنی.
۶. دوباره با buffer اندازه بگیر، نه با کرنومتر. ساعتِ دیواری دربارهی گرمیِ کش دروغ میگوید؛ logical read نه.
۷. بنویسش: DDLِ ایندکس در version control، baseline در اسکریپتِ مهاجرت، استدلال در کامنت. وگرنه نفرِ بعدی «آن ایندکسِ بیاستفاده را تمیز میکند».
فهرستِ ضدالگوها
تابع روی ستونِ ایندکسشده
-- بد: ایندکسِ روی placed_at مرده است.
SELECT * FROM orders WHERE date_trunc('day', placed_at) = DATE '2026-07-29';
-- خوب: یک بازهی نیمبازِ sargable.
SELECT * FROM orders
WHERE placed_at >= DATE '2026-07-29' AND placed_at < DATE '2026-07-30';
-- یا اگر عبارت گریزناپذیر است، خودِ عبارت را ایندکس کن:
CREATE INDEX orders_day_idx ON orders (date_trunc('day', placed_at));-- بد: TRUNC() ستون را از دیدِ ایندکس پنهان میکند.
SELECT * FROM orders WHERE TRUNC(placed_at) = DATE '2026-07-29';
-- خوب: بازهی نیمبازِ sargable.
SELECT * FROM orders
WHERE placed_at >= DATE '2026-07-29' AND placed_at < DATE '2026-07-30';
-- یا function-based index — و حتماً روی ستونِ پنهانش آمار بگیر!
CREATE INDEX orders_day_idx ON orders (TRUNC(placed_at));
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(USER, 'ORDERS',
method_opt => 'FOR ALL HIDDEN COLUMNS SIZE AUTO');
END;
/اگر account_no از نوعِ VARCHAR2 باشد و بنویسی WHERE account_no = 12345، اوراکل ستون را در TO_NUMBER() میپیچد — سمتِ رشته همیشه بازنده است — و ایندکست فوراً غیرقابلاستفاده میشود. پلن TABLE ACCESS FULL با predicateِ TO_NUMBER("ACCOUNT_NO")=12345 نشان میدهد، و هیچچیزِ متنِ SQL مشکوک به نظر نمیرسد. پستگرس سختگیرتر است و خطای operator does not exist: text = integer میدهد، پس باگ در زمانِ توسعه بروز میکند نه ساعتِ ۳ بامداد. همیشه بخشِ Predicate Information در DBMS_XPLAN را برای TO_NUMBER/TO_CHAR/INTERNAL_FUNCTIONهایی که خودت ننوشتهای اسکن کن؛ SQL Analysis Reportِ Oracle 23ai حالا صریحاً علامتشان میزند.
صفحهبندی با OFFSET
-- بد: صفحهی ۵۰۰۰ باید ۱۰۰٬۰۰۰ ردیف را بشمارد و دور بیندازد.
SELECT * FROM orders ORDER BY placed_at DESC, id DESC OFFSET 100000 LIMIT 20;
-- خوب: صفحهبندیِ keyset («seek») — زمانِ ثابت روی هر صفحه.
SELECT * FROM orders
WHERE (placed_at, id) < (:last_placed_at, :last_id)
ORDER BY placed_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;-- بد: همان مشکل با row-limiting clause (از 12c).
SELECT * FROM orders ORDER BY placed_at DESC, id DESC
OFFSET 100000 ROWS FETCH NEXT 20 ROWS ONLY;
-- خوب: صفحهبندیِ keyset، برای وضوح بدونِ نحوِ row-value.
SELECT * FROM orders
WHERE placed_at < :last_placed_at
OR (placed_at = :last_placed_at AND id < :last_id)
ORDER BY placed_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;در اوراکل فقط از 12c وجود دارد؛ پیش از آن صفحهبندی یعنی ساندویچِ ROWNUM. روی 19c/23ai از OFFSET n ROWS FETCH NEXT m ROWS ONLY استفاده کن — همان بندِ ANSI که پستگرس هم دارد. پستگرس علاوه بر آن LIMIT n OFFSET m قدیمی را هم میپذیرد؛ اوراکل نه.
NOT IN روی ستونی که NULL میپذیرد
-- تله: یک NULL از زیرکوئری، کلِ نتیجه را خالی میکند.
SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
-- امن و معمولاً بسیار سریعتر (anti-joinِ واقعی):
SELECT c.* FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);-- همان تله، با همان علتِ منطقِ سهارزشی.
SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
-- anti-joinِ امن:
SELECT c.* FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);چون NOT IN باید معناشناسیِ NULL را رعایت کند، هیچکدام از دو بهینهساز نمیتوانند آن را به anti-joinِ تمیز تبدیل کنند مگر بتوانند اثبات کنند ستون NOT NULL است. قید را اضافه کن و هر دو موتور ناگهان بهجای nested loopِ فیلترشده، hash anti-join تولید میکنند — روی جدولِ بزرگ چند مرتبهی بزرگی تفاوت. اعلامِ constraint یک فعالیتِ تیونینگ است: NOT NULL، CHECK، FOREIGN KEY و قیدهای unique همگی به بهینهساز اطلاعاتِ واقعی میدهند. اوراکل یک قدم جلوتر میرود و قیدهای RELY را در انبارِ داده (با query_rewrite_integrity = trusted) صرفاً برای بازکردنِ transformationها مجاز میکند.
بقیه، بهاختصار
SELECT *index-only scan را از کار میاندازد، حافظهی sort و شبکه را باد میکند، و روزی که کسی ستونِCLOB/byteaاضافه کند میشکند.- N+1 از سمتِ ORM: یک کوئری برای لیست، یکی برای هر ردیف. تشخیص از
callsِ میلیونی درpg_stat_statementsیا تریسِ 10046 که یکsql_idرا ۵۰ هزار بار نشان میدهد. DISTINCTبرای پنهانکردنِ joinِ منفجرشده — هزینهی sortِ مجموعهی تکراری را میدهی؛ join را درست کن (معمولاً باEXISTS).UNIONجایی که منظورتUNION ALLبوده — پولِ حذفِ تکراریای را دادی که لازم نداشتی.ORبین ستونهای مختلف اغلب جلوی ایندکس را میگیرد؛ پستگرس شاید باBitmapOrنجاتت دهد و اوراکل باCONCATENATION، وگرنه بهUNION ALLدو شاخهی ایندکسدار بازنویسی کن.LIKE '%foo'با wildcardِ ابتدایی نمیتواند از B-tree استفاده کند:pg_trgmبا GIN، Oracle Text، یا function-based index روی رشتهی معکوس.- ایندکسِ بیشازحد روی هر نوشتن و هر vacuum/rebuild مالیات میگیرد. ده ایندکس روی یک جدولِ داغِ OLTP بویِ بدِ طراحی میدهد.
- فرضِ ارزانبودنِ
count(*). پستگرس باید برای visibility به ردیفها سر بزند؛ اوراکل اغلب میتواندINDEX FAST FULL SCANبزند. هیچکدام در مقیاس رایگان نیست — کشش کن یا تخمین بزن.
چکلیستِ بهترین شیوهها
۱. اول آمار. بعد از هر bulk load آمار بگیر؛ منتظرِ autovacuum یا پنجرهی نگهداری نمان. روی ستونهای skewed دقتِ histogram را بالا ببر و برای جفتهای همبسته آمارِ توسعهیافته اضافه کن.
۲. همیشه bind کن — در اوراکل برای پرهیز از طوفانِ hard parse الزامی، در پستگرس بهداشتِ درست — با آگاهی از خطرِ skew که bind در هر دو موتور میسازد و راهِ فرارِ هرکدام.
۳. ایندکس را عامدانه طراحی کن: اول ستونهای تساوی، آخر ستونِ بازهای، بعد payloadِ covering. فصلی یکبار استفاده را مرور کن و وزنِ مرده را حذف کن (در اوراکل اول invisible).
۴. هر foreign key را سمتِ فرزند ایندکس کن — در پستگرس مسئلهی کارایی، در اوراکل قطعیِ سرویس از سرِ قفل.
۵. با logical read اندازه بگیر، نه فقط با ساعتِ دیواری و هرگز با hit ratio.
۶. تراکنشها را کوتاه نگه دار. در پستگرس کلِ کلاستر را bloat میکنند و در اوراکل undo را میسوزانند.
۷. work_mem سراسری کم، در سطح جاب زیاد؛ در اوراکل pga_aggregate_target و pga_aggregate_limit را بگذار و workareaها را خودکار رها کن.
۸. اول برای چرخهی عمر partition کن، بعد برای کارایی، و پیش از جشنگرفتن pruning را در پلن تأیید کن.
۹. SQLِ مجموعهای را به حلقه ترجیح بده؛ اگر مجبور به حلقه شدی، bulk-bind و تکهتکه کن.
۱۰. هر ایندکس و baseline و تغییرِ پارامتر را در version control بگذار، همراهِ خروجیِ پلنی که توجیهش میکند.
پرسشهای مصاحبه
غیرقابل پاسخ. cost یک تخمینِ داخلیِ بیواحد است و فقط بین پلنهای جایگزینِ یک statement با تنظیماتِ یکسان قابل مقایسه است — نه بین کوئریها و قطعاً نه بین موتورها. سنجهی مقایسهی واقعی، logical read (Buffers: shared hit+read / buffer_gets) و زمانِ واقعی است.
EXPLAIN PLAN FOR ... اوراکل یک تخمینِ بدون اجرا است: نه bind peeking، نه حلِ adaptive plan، نه ردیفِ واقعی — و مرتباً پلنی نشان میدهد که production استفاده نمیکند. اجرای statement با /*+ GATHER_PLAN_STATISTICS */ و سپس DBMS_XPLAN.DISPLAY_CURSOR(NULL,NULL,'ALLSTATS LAST') پلنی را نشان میدهد که واقعاً اجرا شده، با Starts و E-Rows و A-Rows. در پستگرس EXPLAIN شکلِ تخمینی و EXPLAIN (ANALYZE, BUFFERS) شکلِ واقعی است — اما ANALYZE پستگرس statement را اجرا میکند، حتی DML، پس در BEGIN ... ROLLBACK بپیچ؛ EXPLAIN PLAN اوراکل همیشه روی DML امن است.
اوراکل هنگام hard parse به اولین مقادیرِ bind peek میزند و برایشان بهینه میکند؛ روی ستونِ skewed پلنِ کششده برای اولین فراخوان عالی و برای بقیه فاجعه است. راهکار: Adaptive Cursor Sharing که cursor را bind-sensitive و سپس bind-aware میکند و چند child cursor نگه میدارد. معادلِ پستگرس، سوییچِ custom در برابر generic plan است: ۵ اجرای اولِ prepared statement با مقادیر واقعی پلن میشوند و بعد ممکن است پستگرس یک generic planِ مستقل از مقدار را قفل کند. علامت در هر دو: اولش سریع، بعد برای همیشه کند. درمانِ پستگرس: plan_cache_mode = 'force_custom_plan'.
از تخمین، نه از پلن. از برگها تا اولین واگرایی حرکت کن و بپرس چرا: آمارِ بیات، نبودِ histogram روی ستونِ skewed، predicateهای همبسته که مستقل ضرب شدهاند، یا عبارتِ غیرِ sargable که بهینهساز مجبور به حدس شده. با ANALYZE/DBMS_STATS، histogram، آمارِ توسعهیافته (CREATE STATISTICS / column group) یا بازنویسی درست کن. فقط بعد از اثباتِ اینکه تخمین اصلاحشدنی نیست، سراغ هینت یا baseline برو.
ورودیهای ایندکسِ پستگرس هیچ اطلاعاتِ visibility ندارند، پس index-only scan باید visibility map را بپرسد؛ اگر صفحهی heap علامتِ all-visible نداشته باشد (نوشتنِ اخیر، عقبماندگیِ autovacuum) ردیف باز هم از heap واکشی میشود و بهشکلِ Heap Fetches: N گزارش میشود. اوراکل چنین مشکلی ندارد — اطلاعاتِ نسخه در undo است نه ایندکس — پس دسترسیِ index-only واقعاً index-only است. درمانِ پستگرس، autovacuumِ تهاجمیتر روی آن جدول است تا صفحهها all-visible بمانند.
یک کلمهی مشترک دارند و هیچ چیزِ دیگر. bitmap indexِ اوراکل ساختاری ماندگار با یک bitmap بهازای هر مقدار است، برای ستونهای کمکاردینالیتیِ انبارِ داده درخشان و قابلِ AND پیش از دستزدن به جدول — اما یک update روی یک ردیف کلِ یک قطعهی bitmap را قفل میکند و آن را برای OLTP غیرقابل استفاده میسازد. Bitmap Index/Heap Scanِ پستگرس یک تکنیکِ زماناجرا روی btreeهای معمولی است: bitmapِ شمارهی صفحهها در حافظه، مرتبسازی، و یک بازدید از هر صفحهی heap. نه ماندگاری دارد نه عارضهی قفل. پستگرس اصلاً نوعِ ایندکسِ bitmap ندارد.
اوراکل: بهعنوان SQL Plan Baseline ثبتش کن (DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE و در صورت نیاز fixed => 'YES')، یا SQL Profile از Tuning Advisor بپذیر، یا هینت بگذار؛ Real-Time SPM در 23ai بعد از regression خودش baseline میسازد. پستگرس: در هسته هیچ مکانیزمِ baselineای نیست. پایداری را غیرمستقیم میسازی — آمارِ بهتر، آمارِ توسعهیافته، random_page_costِ منطبق با سختافزار، plan_cache_mode، سدِ WITH ... AS MATERIALIZED — یا افزونهی جانبیِ pg_hint_plan نصب میکنی. گفتنِ «baseline اضافه میکنم» در مصاحبهی پستگرس فوراً لو میرود.
work_mem سهمیهی هر عملیات و هر worker است و هیچ سقفِ سراسری ندارد: یک کوئری با ۳ گرهِ hash و ۴ workerِ موازی میتواند ~۱۵ برابرِ آن مصرف کند و تنها مرزت OOM killer است. pga_aggregate_target یک بودجهی سراسریِ instance است که پویا بین سشنها تقسیم میشود، با pga_aggregate_limit بهعنوانِ سقفِ سختی که بهجای کلِ instance بدترین سشن را میکشد. عملاً: work_mem را سراسری کوچک نگه دار (۱۶ تا ۶۴ مگابایت) و برای کارِ دستهای در سشن بالا ببر؛ در اوراکل بودجههای تجمیعی را اندازه کن و workareaها را خودکار بگذار.
دو توصیهی متضاد برای یک کلیدواژه. در پستگرس ANALYZE همان فرمانِ آمار است. در اوراکل ANALYZE TABLE ... COMPUTE/ESTIMATE STATISTICS برای آمارِ بهینهساز منسوخ است — از DBMS_STATS استفاده کن؛ ANALYZE اوراکل فقط برای کارهایی مثل LIST CHAINED ROWS و VALIDATE STRUCTURE زنده مانده.
پستگرس: افقِ xmin را پین میکند، پس VACUUM نمیتواند tupleهای مرده را در هیچ جدولی آزاد کند و کلِ کلاستر bloat میشود؛ ضمناً freezingی را که جلوی wraparound XID را میگیرد بلاک میکند و در نهایت میتواند به خاموشی منجر شود. اوراکل: undo را پین میکند (و برای کوئریهای طولانیِ دیگر ORA-01555 snapshot too old میسازد) و قفلِ ردیف نگه میدارد، اما معادلِ wraparound ندارد چون SCN چهلوهشتبیتی است. دفاع: idle_in_transaction_session_timeout در پستگرس؛ IDLE_TIME در profile و Resource Manager در اوراکل.
WHERE a = ? بله؛ a = ? AND b = ? بله؛ a = ? AND c = ? نسبی (seek روی a، فیلترِ c)؛ WHERE b = ? در پستگرس خیر — اما در اوراکل ممکن است با INDEX SKIP SCAN کار کند، وقتی a مقادیر یکتای خیلی کمی دارد. a > ? AND b = ? فقط بازهی a را seek میکند و بعد b را فیلتر — و دقیقاً به همین دلیل ستونِ بازهای باید آخر باشد.
CREATE INDEX ON orders (placed_at, status);
SELECT * FROM orders
WHERE status = 'NEW' AND placed_at >= now() - interval '30 days';CREATE INDEX orders_pl_st ON orders (placed_at, status);
SELECT * FROM orders
WHERE status = 'NEW' AND placed_at >= SYSDATE - 30;ترتیبِ ستونها برعکس است. با (placed_at, status) ایندکس بازهی ۳۰ روزه را seek میکند و بعد باید تکتکِ ردیفهای آن را برای status فیلتر کند. با (status, placed_at) مستقیم به بلوکِ کوچکِ NEW میرود و درونِ آن یک بازهی پیوستهی تاریخی میخواند. قاعده: اول تساوی، آخر بازه. پستگرس آسیب را بهشکلِ Rows Removed by Filterِ بزرگ نشان میدهد؛ اوراکل در Predicate Information میبینی که status بهجای access بهعنوانِ filter آمده — همان کلمه، نشانهی ماجراست.
هر literal یک متنِ SQLِ متمایز است، پس یک hard parse، پس رقابت روی latch/mutexهای library cache و shared pool — یک طوفانِ hard parse. درمانِ واقعی، bind variable در اپلیکیشن است. درمانِ اضطراری cursor_sharing = FORCE است که هنگام parse، bindهای تولیدیِ سیستم را جایگزین literalها میکند؛ خونریزی را بند میآورد اما پلنهای خاصِ هر مقدار را میگیرد و روی کوئریهای skewed میتواند regression بسازد. پستگرس دقیقاً این بیماری را نمیگیرد — plan cacheِ مشترک ندارد و صرفاً هزینهی (بسیار ارزانترِ) planning را در هر statement میدهد.
بهروزرسانیِ Heap-Only Tuple در پستگرس نسخهی جدید را روی همان صفحه مینویسد و زنجیر میکند، پس هیچ ورودیِ ایندکسی نوشته نمیشود. شرطها: هیچ ستونِ ایندکسشدهای تغییر نکند و روی صفحه جای خالی باشد (fillfactor < 100). با n_tup_hot_upd / n_tup_upd پایشش کن. اوراکل در جا update میکند پس مفهومِ HOT ندارد، اما نگرانیِ متناظرش مهاجرتِ ردیف است — ردیفِ بزرگشده جابهجا میشود و اشارهگرِ ارجاع میگذارد، پس هر دسترسیِ ایندکسی دو بلاکخوانی هزینه دارد. همان ایده، همان پیشگیری: جای خالی نگه دار (PCTFREE).
pg_stat_statements شمارندههای تجمعیِ هر statement را برای یک queryidِ نرمالشده میدهد، رایگان، روی هر پستگرسی — اما هیچ پلنی، هیچ مقدار bindی و هیچ سریزمانی ذخیره نمیکند، پس «کِی regress کرد و به کدام پلن؟» به auto_explain یا snapshotگیریِ بیرونی نیاز دارد. AWR snapshotهای دورهای شامل plan_hash_value، تفکیکِ رویدادهای انتظار، آمارِ segment و نمونههای ASH نگه میدارد، پس مستقیماً جواب میدهد «ساعت ۲:۱۵ پلن عوض شد و انتظار به db file sequential read منتقل شد» — به قیمتِ لایسنسِ Diagnostics Pack. جایگزینِ رایگانِ اوراکل، Statspack است.
- تیونینگ یعنی ترمیمِ تخمین. اولین گرهای را پیدا کن که تخمین و واقعیتِ ردیف واگرا میشوند؛ تقریباً هر پلنِ بدی از همانجا شروع میشود.
- cost بیواحد و محلیِ همان کوئری است؛ logical read سنجهی واقعی است. hit ratio نویز است.
- پلنِ واقعی:
EXPLAIN (ANALYZE, BUFFERS)در پستگرس (که اجرا میکند — DML را در rollback بپیچ)؛/*+ GATHER_PLAN_STATISTICS */+DBMS_XPLAN.DISPLAY_CURSOR(...,'ALLSTATS LAST')در اوراکل.EXPLAIN PLANبهتنهایی یک حدس است. - پیداکردنِ کوئری:
pg_stat_statements+auto_explain(رایگان، بدون پلن، بدون تاریخچه) در برابرV$SQL+ AWR/ASH/ADDM/SQL Monitor (غنی، تاریخی، لایسنسدار)، بهعلاوهی SQL Trace/tkprof برای حقیقتِ کلِ سشن. - آمار:
ANALYZEدر پستگرس،DBMS_STATSدر اوراکل (هرگزANALYZEِ اوراکل). histogram skew را درمان میکند؛ آمارِ توسعهیافته predicateهای همبسته را. - ایندکسها: اول تساوی، آخر بازه، بعد payloadِ covering.
INCLUDEو partial index فقط در پستگرس؛ bitmap index و IOT و skip scan و invisible index فقط در اوراکل؛ BRIN پاسخِ پستگرس به چیزی است که اوراکل با partitioning حل میکند. - پایداریِ پلن: اوراکل هینت و baseline و profile و adaptive plan و (در 23ai) real-time SPM دارد؛ پستگرس در هسته هیچکدام را ندارد — پایداری از کیفیتِ تخمین میآید.
- bind variable در اوراکل الزامی است و در هر دو موتور همان خطرِ skew را میآورد: bind peeking / adaptive cursor sharing در برابر custom-vs-generic plan.
- مالیاتِ MVCC در پستگرس یعنی bloat و VACUUM و HOT و wraparound، و در اوراکل یعنی undo و
ORA-01555و مهاجرتِ ردیف. یک تراکنشِ طولانی به هر دو آسیب میزند، به دو شکل. work_memبرای هر عملیات و بدون سقف است؛pga_aggregate_targetبودجهای سراسری با سقفِ سخت.- اول برای چرخهی عمر partition کن و pruning را تأیید کن؛ موازیسازی throughput میخرد به حسابِ همسایهها.
- مکانی:
ST_DWithin+ GiST یاSDO_WITHIN_DISTANCE+ ایندکسِ مکانی — هرگز تابعِ فاصله درWHERE. - مجموعهای بر رویهای میچربد؛ اگر در PL/SQL مجبور به حلقه شدی،
BULK COLLECT ... LIMIT+FORALL.
A query that ran in 8 milliseconds yesterday and takes 40 seconds today did not "get slower." Something changed a decision: statistics went stale, a bind value shifted, an index stopped being chosen, a table crossed a threshold. Tuning is not a bag of tricks — it is the discipline of reading the decision the database made, finding the wrong number inside it, and fixing that number.
This chapter teaches that discipline twice: once with PostgreSQL's instruments and once with Oracle's. The theory is shared; the tooling is not. Every SQL snippet below comes as a PostgreSQL tab and an Oracle tab — pick your engine and the site remembers.
- Vocabulary: cost, cardinality, selectivity, access path, block/buffer, sargable.
- The pipeline: parse → optimize → execute, and where the engines diverge (shared pool vs no shared plan cache).
- The number that decides everything: cardinality, statistics, histograms —
ANALYZEvsDBMS_STATS. - Reading plans:
EXPLAIN (ANALYZE, BUFFERS)vsEXPLAIN PLAN+DBMS_XPLAN.DISPLAY_CURSOR+ AUTOTRACE + SQL Trace/tkprof. - Finding the slow query:
pg_stat_statements/auto_explainvs AWR / ASH / ADDM / SQL Monitor. - Access paths & join methods, then index design: column order, partial, covering (
INCLUDE), GiST/GIN/BRIN vs bitmap, function-based indexes, IOT. - Bind variables & plan stability: hard parses, bind peeking, adaptive cursor sharing, generic plans, SQL Plan Baselines, hints.
- The MVCC tax: bloat, HOT, autovacuum vs undo and
ORA-01555. - Memory knobs, partitioning, parallelism, PostGIS/Oracle Spatial, PL/SQL bulk binding.
- A 7-step method, an anti-pattern catalogue, and 15 interview questions — several on Oracle-vs-PostgreSQL differences.
Part 0 — the words you must own first
- Execution plan: the step-by-step recipe the database chose — "scan this index, then hash-join." Like the route your GPS picked out of many.
- Cost: a unitless internal number; the optimizer picks the cheapest plan. It is not milliseconds. Comparing cost between two different queries is meaningless; comparing two plans of the same query is the whole point.
- Cardinality: how many rows a step is expected to produce — the most consequential number in the system.
- Selectivity: the fraction of rows a predicate keeps. Cardinality = selectivity × rows.
- Block / page / buffer: the fixed chunk storage reads (8 KB by default in both). Databases never read "a row" — they read the block containing it. A logical read found it in memory; a physical read went to disk.
- Access path: how one table's rows are reached — full scan, index range scan, rowid lookup.
- Sargable: a predicate an index range can answer.
placed_at >= DATE '2026-01-01'is;EXTRACT(YEAR FROM placed_at) = 2026is not, because the column is buried in a function and the index's ordering no longer helps. - Statistics: the optimizer's summary of your data — row counts, distinct values, most-common values, histograms. If they lie, every downstream decision is wrong.
A bad plan is almost always a bad row estimate. Before you reach for a hint, an index or a config knob, find the first plan node where estimated and actual rows diverge by an order of magnitude. That node is where truth broke; everything after it is the optimizer reasoning correctly from a lie.
The demo schema we tune all chapter
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
country_code char(2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id),
status text NOT NULL, -- NEW / PAID / SHIPPED / CANCELLED
total numeric(12,2) NOT NULL,
placed_at timestamptz NOT NULL DEFAULT now()
);CREATE TABLE customers (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email VARCHAR2(320) NOT NULL UNIQUE,
country_code CHAR(2) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);
CREATE TABLE orders (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id NUMBER NOT NULL REFERENCES customers(id),
status VARCHAR2(16) NOT NULL, -- NEW / PAID / SHIPPED / CANCELLED
total NUMBER(12,2) NOT NULL,
placed_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);- Oracle treats the empty string as NULL.
WHERE status = ''matches nothing in Oracle, ever. PostgreSQL keeps''andNULLstrictly distinct. textvsVARCHAR2(n). PostgreSQL'stextis unbounded and free. In Oracle you must pick a length — and pickingVARCHAR2(4000)"to be safe" is a genuine tuning mistake, because Oracle sizes sort and hash workareas from the declared length, so oversized columns inflate memory estimates and push sorts to disk.
How each engine processes a query
Bilingual caption — The query pipeline in both engines / خط لولهی پردازش کوئری در هر دو موتور:
flowchart LR
A[SQL text] --> B[Parse + semantic check]
B --> C{Plan cached?}
C -- yes --> F[Execute]
C -- no --> D[Rewrite / transform]
D --> E[Cost-based optimizer picks a plan]
E --> F[Execute]
F --> G[Rows to client]
It never runs your query to find out what is fast. It bets: takes the statistics it has (possibly weeks old), enumerates plausible routes, prices each, and backs the cheapest. Like a bookmaker it is excellent when the form book is accurate and catastrophically wrong when it is stale. Ninety per cent of tuning is fixing the form book, not arguing with the bookmaker.
The structural difference you must know:
- Oracle caches parsed plans in the shared pool (library cache), keyed by exact SQL text. Sessions share one plan. Parsing is cheap if you use bind variables and ruinous if you don't. A single
sql_idcan own many child cursors, each with its ownplan_hash_value. - PostgreSQL has no shared, cross-session plan cache. Each backend plans each statement it receives, unless that session used
PREPARE/the extended protocol — then the plan is cached in that session only. Planning is comparatively cheap, so "hard parse storms" are an Oracle disease, not a PostgreSQL one.
Oracle keeps the plan itself in V$SQL_PLAN and historically in AWR with a plan_hash_value per snapshot, so plan flips are directly observable. pg_stat_statements normalises text into a queryid and stores no plan at all. In PostgreSQL you need auto_explain or an external snapshotting tool to answer the same question.
The number that decides everything: cardinality and statistics
You tell the movers "about ten boxes." They send a small van. You actually have four hundred boxes. The driver is not stupid — he planned perfectly for ten. Nested-loop joins are that small van: brilliant for ten rows, catastrophic for four hundred thousand. The van size is chosen entirely by the row estimate.
Refreshing statistics
-- PostgreSQL: ANALYZE samples the table and refreshes pg_statistic.
ANALYZE orders;
VACUUM (ANALYZE, VERBOSE) orders; -- cleanup + stats in one pass
-- Raise histogram resolution on a skewed column
-- (default_statistics_target is 100; per-column overrides it):
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
-- Make autoanalyze keep up on a large table:
ALTER TABLE orders SET (autovacuum_analyze_scale_factor = 0.01);-- Oracle: DBMS_STATS is the only supported way.
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(
ownname => USER,
tabname => 'ORDERS',
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
method_opt => 'FOR ALL COLUMNS SIZE AUTO',
cascade => TRUE, -- index stats too
degree => 4); -- parallel gather
END;
/
-- Force a big histogram on one skewed column:
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(USER, 'ORDERS',
method_opt => 'FOR COLUMNS SIZE 254 STATUS');
END;
/In PostgreSQL ANALYZE is the statistics command. In Oracle, ANALYZE TABLE ... COMPUTE STATISTICS is deprecated for optimizer statistics — it still runs, which is the trap, but it does not produce what the modern CBO needs. Use DBMS_STATS. Oracle's ANALYZE survives only for chores like LIST CHAINED ROWS and VALIDATE STRUCTURE. Favourite interview trip-wire.
PostgreSQL's autovacuum also autoanalyzes, at 50 rows + 10% of the table changed. Oracle's nightly automatic optimizer statistics job targets objects flagged stale at >10% modified (DBA_TAB_MODIFICATIONS). On a billion-row table, 10% means 100 million changes before a refresh — far too lax. Gather manually after every bulk load instead of waiting.
Reading the statistics you have
SELECT attname, n_distinct, null_frac,
most_common_vals, most_common_freqs, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('status', 'placed_at');
SELECT relname, n_live_tup, last_analyze, last_autoanalyze
FROM pg_stat_user_tables WHERE relname = 'orders';SELECT column_name, num_distinct, num_nulls, density,
histogram, -- NONE / FREQUENCY / TOP-FREQUENCY / HEIGHT BALANCED / HYBRID
num_buckets, last_analyzed
FROM user_tab_col_statistics
WHERE table_name = 'ORDERS' AND column_name IN ('STATUS', 'PLACED_AT');
SELECT table_name, num_rows, blocks, avg_row_len, last_analyzed, stale_stats
FROM user_tab_statistics WHERE table_name = 'ORDERS';Without a histogram the optimizer assumes uniform distribution: 4 statuses ⇒ each is 25%. Real data is skewed — 96% SHIPPED, 0.1% CANCELLED. Uniformity makes it refuse the index for CANCELLED and use it for SHIPPED: wrong both ways.
- PostgreSQL always keeps a most-common-values list (
most_common_vals/most_common_freqs) plus equi-depthhistogram_bounds. One mechanism, always on. - Oracle picks a histogram type: frequency (≤254 distinct values, exact), top-frequency, hybrid, and legacy height-balanced.
SIZE AUTOdecides from recorded column usage — meaning Oracle will not build a histogram on a column no query has ever filtered on. Gather stats on a fresh clone and your histograms silently differ from production.
Correlated columns — where both optimizers lie
Both engines assume predicates are independent and multiply selectivities. WHERE city = 'Tehran' AND country_code = 'IR' gets estimated as (1/n_cities × 1/n_countries) — a hundred times too small, because every Tehran row is an IR row. Both have a cure, and they look nothing alike:
-- PostgreSQL: an extended statistics object (PG 10+, MCV lists since PG 12)
CREATE STATISTICS customers_geo (ndistinct, dependencies, mcv)
ON country_code, city FROM customers;
ANALYZE customers;
-- Expression statistics come free with an expression index:
CREATE INDEX customers_email_lower_idx ON customers (lower(email));-- Oracle: a column group (extended statistics)
DECLARE cg VARCHAR2(64);
BEGIN
cg := DBMS_STATS.CREATE_EXTENDED_STATS(USER, 'CUSTOMERS', '(COUNTRY_CODE, CITY)');
END;
/
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(USER, 'CUSTOMERS',
method_opt => 'FOR ALL COLUMNS SIZE AUTO FOR COLUMNS (COUNTRY_CODE, CITY)');
END;
/
-- Expression statistics WITHOUT building an index:
DECLARE e VARCHAR2(64);
BEGIN
e := DBMS_STATS.CREATE_EXTENDED_STATS(USER, 'CUSTOMERS', '(UPPER(EMAIL))');
END;
/If a function in a predicate is unavoidable, you often need the estimate fixed, not a new index. Oracle's expression statistics do exactly that for free. PostgreSQL has no standalone equivalent — you create the expression index and get its statistics as a side effect, paying the index's storage and write cost.
Reading the plan — PostgreSQL
EXPLAIN shows the plan and the estimates. EXPLAIN ANALYZE actually runs the query and shows estimates next to reality. That second word is the whole game.
-- The command you should type by reflex (and wrap DML in a rollback!):
BEGIN;
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT o.id, o.total, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'NEW'
AND o.placed_at >= now() - interval '7 days';
ROLLBACK;-- Oracle: tag the statement, run it, then read the real cursor.
ALTER SESSION SET statistics_level = ALL; -- or use the hint per statement
SELECT /*+ GATHER_PLAN_STATISTICS */ o.id, o.total, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'NEW'
AND o.placed_at >= SYSTIMESTAMP - INTERVAL '7' DAY;
SELECT * FROM TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST +COST +BYTES'));In PostgreSQL, EXPLAIN ANALYZE DELETE ... really deletes; always wrap it in BEGIN ... ROLLBACK. Oracle's EXPLAIN PLAN FOR ... is safe on DML precisely because it never executes — but that is also why it lies: it does not bind-peek, ignores adaptive plans, and routinely shows a plan production is not using. When someone says "but the plan looks fine," ask which command produced it.
Read PostgreSQL output inside-out and check five things in this order:
Nested Loop (cost=0.86..2451.07 rows=812 width=48)
(actual time=0.041..913.220 rows=214883 loops=1)
Buffers: shared hit=629104 read=18422
-> Index Scan using orders_status_placed_idx on orders o
(cost=0.43..118.52 rows=812) (actual time=0.021..71.004 rows=214883 loops=1)
Index Cond: ((status = 'NEW') AND (placed_at >= ...))
-> Index Scan using customers_pkey on customers c
(cost=0.43..2.87 rows=1) (actual time=0.003..0.003 rows=1 loops=214883)
Planning Time: 0.211 ms
Execution Time: 940.882 ms-- PostgreSQL's "estimate only" and "parameterised estimate" forms:
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'NEW';
EXPLAIN (GENERIC_PLAN) -- PG 16+
SELECT * FROM orders WHERE customer_id = $1 AND status = $2;-- Oracle's estimate-only form, plus SQL*Plus AUTOTRACE for a quick look:
EXPLAIN PLAN SET STATEMENT_ID = 'q1' FOR
SELECT * FROM orders WHERE customer_id = :cid AND status = :st;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY('PLAN_TABLE', 'q1', 'ALL +OUTLINE'));
-- AUTOTRACE (SQL*Plus): plan + real consistent gets/physical reads per run
SET AUTOTRACE TRACEONLY EXPLAIN STATISTICS
SELECT * FROM orders WHERE status = 'NEW';
SET AUTOTRACE OFFOracle's ALLSTATS LAST output uses different names for the same ideas:
---------------------------------------------------------------------------------------
| Id | Operation | Name | Starts | E-Rows | A-Rows | Buffers |
---------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | | 215K| 647K |
| 1 | NESTED LOOPS | | 1 | 812 | 215K| 647K |
| 2 | TABLE ACCESS BY INDEX ROWID| ORDERS | 1 | 812 | 215K| 18K |
|* 3 | INDEX RANGE SCAN | ORD_ST_PL_I | 1 | 812 | 215K| 1421 |
| 4 | TABLE ACCESS BY INDEX ROWID| CUSTOMERS | 215K | 1 | 215K| 629K |
|* 5 | INDEX UNIQUE SCAN | CUSTOMERS_PK| 215K | 1 | 215K| 414K |
----------------------------------------------------------------------------------------- pg_stat_statements: add to shared_preload_libraries, restart, then:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT queryid, calls,
ROUND(total_exec_time::numeric, 1) AS total_ms,
ROUND(mean_exec_time::numeric, 3) AS mean_ms,
rows,
shared_blks_hit + shared_blks_read AS logical_reads,
left(query, 80) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
FETCH FIRST 10 ROWS ONLY;
SELECT pg_stat_statements_reset(); -- baseline before a load test-- Oracle: the shared pool already tracks everything; no extension needed.
SELECT sql_id, plan_hash_value, executions,
ROUND(elapsed_time/1e6, 1) AS total_s,
ROUND(elapsed_time/GREATEST(executions,1)/1000, 3) AS mean_ms,
rows_processed,
buffer_gets AS logical_reads,
SUBSTR(sql_text, 1, 80) AS query
FROM v$sql
ORDER BY elapsed_time DESC
FETCH FIRST 10 ROWS ONLY;
-- Historical version (Diagnostics Pack):
SELECT sql_id, plan_hash_value, SUM(executions_delta) execs,
ROUND(SUM(elapsed_time_delta)/1e6, 1) total_s
FROM dba_hist_sqlstat WHERE snap_id BETWEEN :s1 AND :s2
GROUP BY sql_id, plan_hash_value ORDER BY 4 DESC FETCH FIRST 10 ROWS ONLY;A query taking 4 seconds once a day is a rounding error. A query taking 4 milliseconds two million times an hour is your outage. Both tools invite you to sort by the mean; resist. Optimise calls × mean — which is exactly total_exec_time / elapsed_time.
Automatic plan capture, and where the time really goes
-- auto_explain: log the plan of anything slower than 500 ms.
LOAD 'auto_explain';
SET auto_explain.log_min_duration = '500ms';
SET auto_explain.log_analyze = on; -- real row counts
SET auto_explain.log_buffers = on;
SET auto_explain.log_nested_statements = on; -- inside functions too
SET auto_explain.log_timing = off; -- cheap: rows without per-node timing
SET auto_explain.sample_rate = 0.05;
-- Where is time going right now? (PG samples waits in pg_stat_activity)
SELECT wait_event_type, wait_event, state, count(*)
FROM pg_stat_activity WHERE backend_type = 'client backend'
GROUP BY 1,2,3 ORDER BY 4 DESC;-- Real-Time SQL Monitoring auto-captures anything over ~5 s of CPU/IO
-- or any parallel statement. No setup required.
SELECT DBMS_SQL_MONITOR.REPORT_SQL_MONITOR(
sql_id => '7ftjftd9xdrsp', type => 'TEXT', report_level => 'ALL')
FROM dual;
-- Force it for a fast statement: SELECT /*+ MONITOR */ ...
-- Where is time going? ASH samples every active session every second.
SELECT event, wait_class, COUNT(*) samples
FROM v$active_session_history
WHERE sample_time > SYSTIMESTAMP - INTERVAL '10' MINUTE
GROUP BY event, wait_class ORDER BY samples DESC FETCH FIRST 10 ROWS ONLY;Per-node timing calls the clock twice per row and can add 20–100% overhead. With timing off you still get actual row counts — the number that matters — at almost no cost. Combine with sample_rate and you can leave it on permanently.
V$ACTIVE_SESSION_HISTORY, DBA_HIST_*, DBMS_WORKLOAD_REPOSITORY, ADDM and Real-Time SQL Monitoring require the Oracle Diagnostics Pack (and the SQL Tuning Advisor the Tuning Pack). Querying them unlicensed is a violation that Oracle's own DBA_FEATURE_USAGE_STATISTICS records for the auditor. The free fallback is Statspack (?/rdbms/admin/spcreate.sql), which gives snapshot reports but no ASH. Everything on the PostgreSQL side of this chapter is free — a genuine and underrated argument in an architecture review.
Whole-session truth: SQL Trace + tkprof
-- PostgreSQL: log every statement with its duration, then aggregate with pgBadger.
ALTER ROLE app_user SET log_min_duration_statement = 0;
SET log_duration = on;
-- Reset when done:
ALTER ROLE app_user RESET log_min_duration_statement;-- 10046 level-12 trace: waits + bind values, for one session.
BEGIN
DBMS_MONITOR.SESSION_TRACE_ENABLE(session_id => 123, serial_num => 4567,
waits => TRUE, binds => TRUE, plan_stat => 'ALL_EXECUTIONS');
END;
/
-- ... workload runs ...
BEGIN DBMS_MONITOR.SESSION_TRACE_DISABLE(123, 4567); END;
/
SELECT value FROM v$diag_info WHERE name = 'Default Trace File';
-- From the OS shell:
-- tkprof orcl_ora_12345.trc out.txt sort=prsela,exeela,fchelaEXPLAIN-style tools tell you what one statement did. A 10046 trace tells you everything a session did, in order — including the statements you did not know it was issuing: the ORM's hidden SELECTs, the trigger's recursive SQL, the 40 000 round trips a FOR loop generated. When the complaint is "the batch job is slow" rather than "this query is slow," trace the session. PostgreSQL's equivalent shape of answer comes from log_min_duration_statement = 0 plus pgBadger.
Access paths: how one table gets read
Three strategies. Read every page (full scan) — dumb, but unbeatable if you want 80% of the names. Use the alphabet (index range scan) — perfect for one name or a narrow range. Collect page numbers first, sort them, then walk the book once in page order (bitmap access) — the winner for a few thousand scattered names, because you touch each physical page exactly once.
| Concept | PostgreSQL | Oracle |
|---|---|---|
| Read the whole table | Seq Scan |
TABLE ACCESS FULL |
| Walk an index range, fetch rows | Index Scan |
INDEX RANGE SCAN + TABLE ACCESS BY INDEX ROWID |
| Unique key lookup | Index Scan on a unique index |
INDEX UNIQUE SCAN |
| Answer entirely from the index | Index Only Scan |
no TABLE ACCESS line under the index op |
| Read the whole index instead of the table | (no equivalent) | INDEX FAST FULL SCAN |
| Gather many scattered rows | Bitmap Index Scan + Bitmap Heap Scan |
bitmap index / BITMAP CONVERSION |
| Skip the leading column of a composite index | (not supported) | INDEX SKIP SCAN |
-- Compare access paths experimentally by disabling one:
SET enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'CANCELLED';
RESET enable_seqscan;
-- Index-only scan needs a current visibility map:
VACUUM (ANALYZE) orders;
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id FROM orders WHERE status = 'NEW';-- Same experiment, driven by hints:
SELECT /*+ FULL(o) */ * FROM orders o WHERE status = 'CANCELLED';
SELECT /*+ INDEX(o orders_status_idx) */ * FROM orders o WHERE status = 'CANCELLED';
-- Index-only: Oracle simply omits TABLE ACCESS BY INDEX ROWID when the
-- index covers every referenced column. No visibility map involved.
SELECT /*+ INDEX(o orders_status_cust_idx) */ customer_id
FROM orders o WHERE status = 'NEW';PostgreSQL index entries carry no visibility information, so an index-only scan must consult the visibility map; if a heap page is not marked all-visible (recent writes, lagging autovacuum) the row is fetched from the heap anyway — shown as Heap Fetches: 918273. Oracle has no such problem: version information lives in undo, not in the index, so index-only access really is index-only. One of the deepest structural differences between the engines, and a superb interview question.
How well does the index's logical order match the table's physical order? If they match, a range scan touches few blocks; if random, nearly every row costs its own block read. Oracle stores this as USER_INDEXES.CLUSTERING_FACTOR (near BLOCKS = perfect, near NUM_ROWS = terrible); PostgreSQL as pg_stats.correlation (near ±1 = perfect, near 0 = terrible). Both explain the same mystery — "why does the optimizer refuse my perfectly good index?" — and both have the same two cures: physically reorder the table (CLUSTER in PostgreSQL, ALTER TABLE ... MOVE ONLINE / an IOT / partitioning in Oracle), or make the index covering so the table is never touched.
CLUSTER orders USING orders_placed_at_idx; -- ACCESS EXCLUSIVE lock
ANALYZE orders;
SELECT attname, correlation FROM pg_stats
WHERE tablename = 'orders' AND attname = 'placed_at';ALTER TABLE orders MOVE ONLINE; -- 12c+ online rebuild
ALTER INDEX orders_placed_at_idx REBUILD ONLINE;
SELECT index_name, clustering_factor, num_rows, leaf_blocks
FROM user_indexes WHERE table_name = 'ORDERS';Join methods
Bilingual caption — Choosing a join method by input size / انتخاب روش join بر اساس اندازهی ورودی:
flowchart TD
A[Two row sources to join] --> B{Outer side tiny AND<br/>inner has a selective index?}
B -- yes --> NL[Nested Loop]
B -- no --> C{Both sides already sorted<br/>on the join key?}
C -- yes --> MJ[Merge / SORT MERGE]
C -- no --> D{Smaller side fits in<br/>work_mem / PGA?}
D -- yes --> HJ[Hash Join]
D -- no --> HJD[Hash Join spilling to disk]
- Nested loop: probe the inner side once per outer row. Cost ≈ outer_rows × probe_cost. Acceptable only when the probe is an index seek — and it fails catastrophically when the outer estimate is too low. That is the small van hauling four hundred boxes.
- Hash join: build a hash table on the smaller input, stream the larger through it. Roughly linear; the right answer for large, unindexed equijoins.
- Merge / sort-merge: sort both inputs on the key and walk them in lockstep. Wins when inputs already arrive ordered, or (in Oracle) for some non-equijoins.
-- Diagnose by disabling a method, then fix the real cause.
SET enable_nestloop = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT /* ... */ 1;
RESET enable_nestloop;
SET work_mem = '256MB'; -- session-scoped; mind the multiplier below
SET join_collapse_limit = 8; -- how hard the planner searches join orders
SET geqo_threshold = 12; -- above this, genetic (approximate) search-- Hints are directives in Oracle: honoured whenever legal.
SELECT /*+ LEADING(o c) USE_NL(c) INDEX(c customers_pk) */ o.id, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id;
SELECT /*+ LEADING(c o) USE_HASH(o) */ o.id, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id;
ALTER SESSION SET pga_aggregate_target = 2G;
-- One-off batch job that needs a huge workarea:
ALTER SESSION SET workarea_size_policy = MANUAL;
ALTER SESSION SET hash_area_size = 268435456;The most expensive misconfiguration in PostgreSQL. work_mem budgets each sort/hash/materialise node in each parallel worker: 3 hash joins × 5 processes = 15 × work_mem for one query. Set it to 1 GB globally on a 100-connection server and you have authorised hundreds of gigabytes with no ceiling but the OOM killer. Set it low globally (16–64 MB) and raise it per session for the job that needs it. Oracle's pga_aggregate_target is the opposite design: a global budget divided dynamically among sessions, with pga_aggregate_limit as a hard cap that kills the offending session rather than the instance.
PostgreSQL: Sort Method: external merge Disk: 412032kB. Oracle: the OMem/1Mem/Used-Mem and Used-Tmp columns in ALLSTATS output — 2048K (1) means one one-pass execution, (0) means optimal (fully in memory), multi-pass is the disaster case.
Index design — the craft
You can find everyone in "Tehran," and everyone named "Ahmadi" within Tehran, instantly. But you cannot find all the Ahmadis in the country without reading every city. That is a composite index: leading columns are usable prefixes; trailing ones are not.
The rules, in order of importance:
- Equality predicates first, the range predicate last. For
WHERE status = ? AND placed_at >= ?the index must be(status, placed_at). Once an index walks a range, the columns after it are no longer sorted within that range. - Then columns needed only for output (covering), not for filtering.
- Then
ORDER BY— a matching index supplies ordering for free and removes the sort node, but only if column order and direction match.
-- Equality, then range, then covering payload (PG 11+ INCLUDE).
CREATE INDEX orders_status_placed_idx
ON orders (status, placed_at) INCLUDE (total, customer_id);
-- Partial index: index only the rows you actually query.
CREATE INDEX orders_open_idx ON orders (placed_at)
WHERE status IN ('NEW', 'PAID');
-- Expression index, and a descending index to match an ORDER BY:
CREATE INDEX customers_email_lower_idx ON customers (lower(email));
CREATE INDEX orders_recent_idx ON orders (placed_at DESC NULLS LAST);-- Oracle has no INCLUDE: extra columns simply join the key.
CREATE INDEX orders_status_placed_idx
ON orders (status, placed_at, total, customer_id);
-- Oracle has no partial index. The idiomatic equivalent is a function-based
-- index that stores NULL for rows you want excluded (NULL keys aren't indexed):
CREATE INDEX orders_open_idx
ON orders (CASE WHEN status IN ('NEW','PAID') THEN placed_at END);
-- and the query must repeat the expression verbatim:
SELECT * FROM orders
WHERE CASE WHEN status IN ('NEW','PAID') THEN placed_at END >= :d;
CREATE INDEX customers_email_lower_idx ON customers (LOWER(email));
CREATE INDEX orders_recent_idx ON orders (placed_at DESC);INCLUDEvs extra key columns. PostgreSQL'sINCLUDEpayload lives only in leaf pages, is not part of the key, does not enlarge internal pages, and can be added to a unique index without changing uniqueness. Oracle can only append to the key — which would break the unique constraint. "Unique on (a) but covering (a,b)" is possible in PostgreSQL and impossible in Oracle.- Partial indexes are PostgreSQL-only. The Oracle
CASEtrick works and is genuinely used, but it is brittle because the query must repeat the expression exactly. - NULL ordering must match, and it is easy to get wrong. Both engines default to
NULLS LASTforASCandNULLS FIRSTforDESC— but the moment you writeORDER BY placed_at DESC NULLS LASTand the index does not declare the same placement, the sort node comes back. PostgreSQL lets you bakeNULLS LASTinto the index; in Oracle you must declareDESC(which stores a descending index) and keep the query's NULL clause consistent with the default.
The index-type menu — where the engines really part ways
| Need | PostgreSQL | Oracle |
|---|---|---|
| Ordered equality / range | btree |
B-tree |
| Low-cardinality, read-mostly (DWH) | partial/btree + runtime bitmap |
Bitmap index |
| Full-text search | GIN on tsvector |
Oracle Text (CONTEXT index) |
| JSON containment | GIN (jsonb_path_ops) |
JSON search / multivalue index (21c+) |
| Geometry, ranges, nearest-neighbour | GiST, SP-GiST |
Oracle Spatial domain index |
| Huge append-only table, tiny index | BRIN |
(none — use partitioning) |
| Table stored inside its index | (none; CLUSTER is a one-off) |
Index-Organized Table |
-- BRIN: a 200 GB append-only table, an index measured in megabytes.
CREATE INDEX gps_pings_brin ON gps_pings USING brin (recorded_at)
WITH (pages_per_range = 32);
CREATE INDEX orders_meta_gin ON orders USING gin (meta jsonb_path_ops);
SELECT * FROM orders WHERE meta @> '{"channel":"mobile"}';
-- PostgreSQL has NO bitmap INDEX. It builds a bitmap at RUNTIME from
-- ordinary btree indexes — a different thing entirely:
EXPLAIN SELECT * FROM orders WHERE status = 'NEW' AND country_code = 'IR';
-- -> BitmapAnd -> Bitmap Index Scan ... , Bitmap Index Scan ...-- Oracle: a real, persistent bitmap index — one bitmap per distinct value.
CREATE BITMAP INDEX orders_status_bmx ON orders (status);
CREATE BITMAP INDEX orders_country_bmx ON orders (country_code);
SELECT * FROM orders WHERE status = 'NEW' AND country_code = 'IR';
-- Index-Organized Table: the table IS its primary-key B-tree.
CREATE TABLE order_items (
order_id NUMBER, line_no NUMBER, sku VARCHAR2(64), qty NUMBER,
CONSTRAINT pk_oi PRIMARY KEY (order_id, line_no)
) ORGANIZATION INDEX PCTTHRESHOLD 20 OVERFLOW TABLESPACE users;Updating one row in an Oracle bitmap index locks an entire bitmap segment, potentially thousands of rows, so concurrent updates to unrelated rows block each other and deadlock routinely. Bitmap indexes belong in warehouses, never in transactional tables. Separately: PostgreSQL's "Bitmap Heap Scan" is not a bitmap index. It is a runtime technique over ordinary B-trees — build an in-memory bitmap of page numbers, sort it, visit each heap page once. Same word, different mechanism, no locking downside.
BRIN stores min/max per block range, so it is microscopic — kilobytes for a huge table — but it works only if the column correlates with insert order (timestamps on append-only tables). Shuffle the table with updates and every range matches, i.e. a full scan with extra steps. Oracle's answer to the same problem is not an index at all: partition pruning on a range-partitioned table.
Testing an index without committing to it
-- PostgreSQL has no invisible index. Two honest options:
CREATE EXTENSION IF NOT EXISTS hypopg;
SELECT * FROM hypopg_create_index('CREATE INDEX ON orders (customer_id, status)');
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'NEW';
SELECT hypopg_reset();
-- Or build it for real without locking writers, and drop it if it disappoints:
CREATE INDEX CONCURRENTLY orders_cust_status_idx ON orders (customer_id, status);
DROP INDEX CONCURRENTLY orders_cust_status_idx;-- Oracle: build it INVISIBLE, test it in ONE session, then publish or drop.
CREATE INDEX orders_cust_status_idx ON orders (customer_id, status) INVISIBLE ONLINE;
ALTER SESSION SET optimizer_use_invisible_indexes = TRUE;
SELECT * FROM orders WHERE customer_id = :c AND status = :s;
ALTER INDEX orders_cust_status_idx VISIBLE; -- happy
-- DROP INDEX orders_cust_status_idx; -- unhappyBefore dropping a 40 GB index "nobody uses," make it invisible. If the quarter-end report collapses, one ALTER INDEX ... VISIBLE restores it in milliseconds instead of hours of rebuilding. PostgreSQL cannot do this, so the discipline there is: check pg_stat_user_indexes.idx_scan over a full business cycle (a month, not a day) and keep the exact CREATE INDEX statement in version control before dropping.
-- Dead-weight indexes (reset counters first, then wait weeks):
SELECT s.relname AS table_name, s.indexrelname AS index_name, s.idx_scan,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0 AND NOT i.indisunique AND NOT i.indisprimary
ORDER BY pg_relation_size(s.indexrelid) DESC;-- Oracle 12.2+: always-on index usage tracking.
SELECT name, total_access_count, total_exec_count, last_used
FROM dba_index_usage WHERE owner = USER ORDER BY total_access_count;
-- Legacy method, still useful on older releases:
ALTER INDEX orders_cust_status_idx MONITORING USAGE;
SELECT index_name, used, start_monitoring FROM v$object_usage;An unindexed foreign key is a mild nuisance in PostgreSQL (parent deletes and ON DELETE CASCADE scan the child). In Oracle it is an availability bug: deleting or updating a parent key with an unindexed child FK makes Oracle take a share lock on the entire child table for the duration of the statement, blocking all DML on it. One housekeeping DELETE can freeze the busiest table in the system. Audit them:
SELECT c.conrelid::regclass AS child, c.conname
FROM pg_constraint c
WHERE c.contype = 'f'
AND NOT EXISTS (SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid
AND (i.indkey::smallint[])[0:array_length(c.conkey,1)-1] @> c.conkey);SELECT c.table_name, c.constraint_name, cc.column_name
FROM user_constraints c
JOIN user_cons_columns cc ON cc.constraint_name = c.constraint_name
WHERE c.constraint_type = 'R'
AND NOT EXISTS (SELECT 1 FROM user_ind_columns ic
WHERE ic.table_name = c.table_name
AND ic.column_name = cc.column_name
AND ic.column_position = cc.position);Bind variables, plan caching and parameter sniffing
Hard-parsing is cutting a brand-new key: expensive, and thrown away after one use. A bind variable keeps the key on your keyring; Oracle's shared pool is the keyring the whole household shares. If every session cuts its own key for every door, the locksmith — the parser, guarded by latches every session queues for — becomes the bottleneck and the house grinds to a halt.
-- PostgreSQL: parameters via PREPARE / the extended protocol (JDBC, psycopg).
PREPARE find_orders (bigint, text) AS
SELECT * FROM orders WHERE customer_id = $1 AND status = $2;
EXECUTE find_orders (42, 'NEW');
SELECT name, generic_plans, custom_plans FROM pg_prepared_statements;
-- Control the custom-vs-generic decision (PG 12+):
SET plan_cache_mode = 'force_custom_plan'; -- re-plan with real values, always-- Oracle: bind variables are non-negotiable in OLTP.
VARIABLE cid NUMBER
VARIABLE st VARCHAR2(16)
EXEC :cid := 42; :st := 'NEW';
SELECT * FROM orders WHERE customer_id = :cid AND status = :st;
-- What values did the optimizer PEEK at when it built this plan?
SELECT * FROM TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST +PEEKED_BINDS'));
-- Emergency band-aid for a literal-SQL legacy app (default and correct: EXACT):
ALTER SYSTEM SET cursor_sharing = FORCE SCOPE = MEMORY;When Oracle hard-parses a statement with binds it peeks at the first actual values and optimises for those. If the first caller asked for status = 'CANCELLED' (0.1% of rows) the cached plan uses an index, and every later caller asking for 'SHIPPED' (96%) inherits it and dies. Mitigation: Adaptive Cursor Sharing — Oracle marks the cursor bind-sensitive, then bind-aware once it observes wildly different row counts, and maintains several child cursors.
PostgreSQL has the identical failure one abstraction away: a prepared statement is re-planned with real values (custom plan) for the first 5 executions, then PostgreSQL computes a generic plan and, if its estimated cost is not worse than the average custom cost, switches to it permanently for that session. Symptom in both engines: fast at first, mysteriously slow forever after. The PostgreSQL cure is plan_cache_mode = 'force_custom_plan'.
-- Was I hit by the generic plan? Compare the two explicitly:
SET plan_cache_mode = 'force_generic_plan';
EXPLAIN (ANALYZE) EXECUTE find_orders (42, 'SHIPPED');
SET plan_cache_mode = 'force_custom_plan';
EXPLAIN (ANALYZE) EXECUTE find_orders (42, 'SHIPPED');
RESET plan_cache_mode;-- Did adaptive cursor sharing kick in? One row per child cursor:
SELECT sql_id, child_number, is_bind_sensitive, is_bind_aware, is_shareable,
executions, buffer_gets, plan_hash_value
FROM v$sql WHERE sql_id = :sid ORDER BY child_number;PgBouncer in transaction pooling mode historically broke server-side prepared statements (each transaction may land on a different server connection), which is why many teams ran JDBC with prepareThreshold=0 and paid full planning cost per call. PgBouncer 1.21+ added prepared-statement support in transaction mode — check your version before assuming either behaviour. Oracle's shared pool is server-side and pooler-independent, but has the mirror-image hazard: session settings (optimizer_mode, NLS, cursor_sharing) leak across pooled sessions and change plans for the next borrower.
Plan stability: keeping a good plan good
Here the engines are not in the same weight class.
-- PostgreSQL: no baselines, no stored outlines, no in-SQL hints in core.
-- The honest levers, best first:
-- 1. Fix the estimate:
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
CREATE STATISTICS orders_stat (dependencies, mcv) ON status, customer_id FROM orders;
ANALYZE orders;
-- 2. Nudge the cost model to match the hardware:
SET random_page_cost = 1.1; -- NVMe: seeks are nearly free
SET effective_cache_size = '48GB'; -- how much OS cache the planner may assume
-- 3. Structural barrier as a last resort (PG 12+):
WITH scoped AS MATERIALIZED (
SELECT id FROM orders WHERE status = 'NEW'
)
SELECT * FROM scoped JOIN order_items USING (id);
-- 4. pg_hint_plan (third-party extension) for genuine hints.-- Oracle: an entire subsystem exists for exactly this.
-- 1. Hints — directives, honoured whenever legal:
SELECT /*+ LEADING(o c) USE_HASH(c) INDEX(o orders_status_placed_idx) PARALLEL(o 4) */
o.id, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id;
-- 2. SQL Plan Baseline: capture the good plan, forbid regressions.
DECLARE n PLS_INTEGER;
BEGIN
n := DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE(
sql_id => '7ftjftd9xdrsp', plan_hash_value => 3956160932,
fixed => 'YES', enabled => 'YES');
END;
/
SELECT sql_handle, plan_name, enabled, accepted, fixed, origin
FROM dba_sql_plan_baselines;
-- 3. SQL Profile from the Tuning Advisor (corrective cardinality factors):
DECLARE t VARCHAR2(64);
BEGIN
t := DBMS_SQLTUNE.CREATE_TUNING_TASK(sql_id => '7ftjftd9xdrsp');
DBMS_SQLTUNE.EXECUTE_TUNING_TASK(t);
DBMS_OUTPUT.PUT_LINE(DBMS_SQLTUNE.REPORT_TUNING_TASK(t));
END;
/A baseline is a whitelist of accepted plans: the optimizer may only use a plan that is in the baseline and ACCEPTED; new plans are captured but quarantined until proven faster (DBMS_SPM.EVOLVE_SQL_PLAN_BASELINE). It answers "never regress."
A profile is not a plan at all — it is extra information, mostly corrective cardinality scaling that the Tuning Advisor derived by sampling. The optimizer still chooses freely, just with better numbers. It answers "you were guessing wrong, here is the truth."
Stored Outlines are the deprecated 9i ancestor of baselines — recognise the name, do not use them. Oracle 23ai adds Real-Time SQL Plan Management, which detects a plan regression and repairs it by creating a baseline from the previously-good plan automatically.
There is no baseline mechanism in core PostgreSQL. pg_hint_plan exists and works, but it is an extension, unavailable on several managed services, and its hints attach to query text. The idiomatic answer is that plan stability comes from estimate stability: adequate statistics_target, extended statistics on correlated columns, a random_page_cost that matches your storage, enough work_mem. When a team migrating off Oracle asks "where are the baselines?", this belongs in the migration risk register — not in a surprise at go-live.
Since 12c Oracle can build a plan with a subplan switch: it starts a nested loop, counts rows through an inline statistics collector, and if reality exceeds a threshold it switches to a hash join during the same execution. DBMS_XPLAN notes This is an adaptive plan. Related: statistics feedback re-optimises on the next execution using the row counts just observed, and SQL Plan Directives persist "the optimizer misestimated this column combination." Controlled by optimizer_adaptive_plans (default TRUE) and optimizer_adaptive_statistics (default FALSE since 12.2, after the 12.1 defaults caused widespread regressions). PostgreSQL has nothing in this family: a plan chosen is a plan executed, start to finish.
The MVCC tax: bloat, HOT, vacuum, and Oracle's undo
Both engines are MVCC — readers never block writers — but they pay for it in opposite places, and that difference is a tuning topic.
PostgreSQL keeps the old edition on the shelf with a note "superseded," and hires a cleaner (VACUUM) to walk the aisles removing copies nobody can still want. Shelves grow; cleaning is essential. Oracle overwrites the book in place, but photocopies the old page into a separate undo room first. Shelves stay tidy — but if a slow reader takes too long, the photocopy they need may already have been shredded, and they get thrown out with an error.
- PostgreSQL: an
UPDATEis delete + insert. The old tuple survives withxmaxset until VACUUM reclaims it. Cost = bloat in the table and every index. - Oracle: an
UPDATEmodifies the block in place and writes the pre-image to undo. Cost = undo space and ORA-01555 "snapshot too old" when a long query needs undo already overwritten.
-- Bloat and vacuum lag, plus the HOT ratio:
SELECT relname, n_live_tup, n_dead_tup,
ROUND(100.0*n_dead_tup/NULLIF(n_live_tup+n_dead_tup,0), 1) AS dead_pct,
n_tup_upd, n_tup_hot_upd,
ROUND(100.0*n_tup_hot_upd/NULLIF(n_tup_upd,0), 1) AS hot_pct,
last_autovacuum
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC FETCH FIRST 15 ROWS ONLY;
-- Leave room on each page so updates can stay HOT:
ALTER TABLE orders SET (fillfactor = 85);
-- Make autovacuum aggressive on a hot table:
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_cost_delay = 0);-- Oracle: undo headroom, row migration, and the high-water mark.
SELECT tablespace_name, status, ROUND(SUM(bytes)/1024/1024) mb
FROM dba_undo_extents GROUP BY tablespace_name, status;
SHOW PARAMETER undo_retention;
ALTER TABLESPACE undotbs1 RETENTION GUARANTEE;
-- Row migration: a row grew and no longer fits its block.
ANALYZE TABLE orders LIST CHAINED ROWS INTO chained_rows; -- a legit use of ANALYZE
SELECT COUNT(*) FROM chained_rows;
-- Oracle's "fillfactor", then reclaim space and reset the HWM:
ALTER TABLE orders PCTFREE 20;
ALTER TABLE orders ENABLE ROW MOVEMENT;
ALTER TABLE orders SHRINK SPACE COMPACT;
ALTER TABLE orders SHRINK SPACE;A Heap-Only Tuple update writes the new version on the same page as the old and chains them, so no index entry is created at all — turning "1 heap write + N index writes" into "1 heap write." Two conditions must both hold: no indexed column changed, and free space exists on the page (which is what fillfactor < 100 buys). Watch n_tup_hot_upd / n_tup_upd; a low ratio on your hottest table means you pay index maintenance on every update. Oracle has no HOT because it updates in place — its analogous concern is row migration, where a grown row moves and leaves a forwarding pointer, so every indexed access costs two block reads. Same idea, same prevention: reserve free space (PCTFREE).
In PostgreSQL, VACUUM may only remove tuples older than the xmin horizon — the oldest snapshot any live transaction could need. A session left idle in transaction holds that horizon down cluster-wide, so dead tuples in every table become unremovable and everything bloats; it also blocks the freezing that prevents XID wraparound (32-bit, cyclic — the cluster refuses writes if freezing falls ~2 billion behind). In Oracle the same forgotten session pins undo (causing ORA-01555 for others) and holds row locks — but there is no wraparound analogue, because SCNs are 48-bit. This entire failure category simply does not exist in Oracle.
SET idle_in_transaction_session_timeout = '60s';
SET statement_timeout = '30s';
SELECT pid, state, now() - xact_start AS xact_age, query
FROM pg_stat_activity WHERE state <> 'idle' ORDER BY xact_start
FETCH FIRST 10 ROWS ONLY;
SELECT datname, age(datfrozenxid) AS xid_age FROM pg_database ORDER BY 2 DESC;ALTER PROFILE app_profile LIMIT IDLE_TIME 5 CONNECT_TIME 480;
SELECT s.sid, s.serial#, s.username, s.last_call_et AS idle_s, q.sql_text
FROM v$transaction t
JOIN v$session s ON s.saddr = t.ses_addr
LEFT JOIN v$sql q ON q.sql_id = s.sql_id
ORDER BY t.start_time;
-- No wraparound to monitor; the nearest health check is undo/SCN headroom:
SELECT MIN(start_time) AS oldest_tx FROM v$transaction;Memory and I/O configuration
| Concern | PostgreSQL | Oracle |
|---|---|---|
| Shared cache of data blocks | shared_buffers |
DB_CACHE_SIZE inside the SGA |
| Sort/hash memory | work_mem (per node, per worker) |
PGA via PGA_AGGREGATE_TARGET (global) |
| Hint about OS cache | effective_cache_size (no allocation) |
(none — Oracle uses direct I/O) |
| Hard ceiling | none (the OOM killer is your ceiling) | PGA_AGGREGATE_LIMIT, MEMORY_TARGET |
| Cost of a random read | random_page_cost (default 4.0) |
system statistics, optimizer_index_cost_adj |
-- A sane starting point for a dedicated 64 GB server — then MEASURE.
ALTER SYSTEM SET shared_buffers = '16GB';
ALTER SYSTEM SET effective_cache_size = '48GB';
ALTER SYSTEM SET work_mem = '32MB'; -- low! raise per session
ALTER SYSTEM SET maintenance_work_mem = '2GB';
ALTER SYSTEM SET random_page_cost = 1.1; -- SSD/NVMe
ALTER SYSTEM SET effective_io_concurrency = 200;
ALTER SYSTEM SET max_parallel_workers_per_gather = 4;
SELECT pg_reload_conf();-- Size the pools, then let Oracle's own advisors tell you if it helped.
ALTER SYSTEM SET sga_target = 16G SCOPE = SPFILE;
ALTER SYSTEM SET pga_aggregate_target = 8G SCOPE = SPFILE;
ALTER SYSTEM SET pga_aggregate_limit = 16G SCOPE = SPFILE;
SELECT size_for_estimate AS cache_mb, estd_physical_read_factor
FROM v$db_cache_advice WHERE name = 'DEFAULT' AND block_size = 8192
ORDER BY size_for_estimate;
SELECT pga_target_for_estimate/1024/1024 AS pga_mb,
estd_pga_cache_hit_percentage, estd_overalloc_count
FROM v$pga_target_advice ORDER BY 1;
-- Teach the CBO what your storage really costs:
BEGIN DBMS_STATS.GATHER_SYSTEM_STATS('INTERVAL', interval => 60); END;
/A 99.9% hit ratio can mean "everything is cached" or "one query does 400 million logical reads through a runaway nested loop and hits the same block forever." Both look identical in the ratio. Logical reads are the real workload metric — they still cost CPU, latching and cache-line traffic. Tune to reduce total block visits, not to raise a ratio. Any tuning advice built on hit ratios is from 1998.
Partitioning: divide so you can skip
With one enormous drawer, finding March means touching everything. With one drawer per month, "March invoices" opens one drawer — and "delete 2019" removes a drawer instead of shredding paper. That second property, instant bulk delete, is usually the real reason teams partition.
-- Declarative partitioning (PG 10+), range by month.
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY,
customer_id bigint NOT NULL,
status text NOT NULL,
total numeric(12,2) NOT NULL,
placed_at timestamptz NOT NULL
) PARTITION BY RANGE (placed_at);
CREATE TABLE orders_2026_07 PARTITION OF orders
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
-- Define the index once on the parent; PostgreSQL creates one per partition.
CREATE INDEX ON orders (customer_id, status);
-- Drop a month instantly, no row-by-row DELETE:
ALTER TABLE orders DETACH PARTITION orders_2026_07 CONCURRENTLY;
DROP TABLE orders_2026_07;
SET enable_partitionwise_join = on;
SET enable_partitionwise_aggregate = on;-- Oracle: INTERVAL partitioning creates the monthly partitions for you.
CREATE TABLE orders (
id NUMBER GENERATED ALWAYS AS IDENTITY,
customer_id NUMBER NOT NULL,
status VARCHAR2(16) NOT NULL,
total NUMBER(12,2) NOT NULL,
placed_at DATE NOT NULL
)
PARTITION BY RANGE (placed_at)
INTERVAL (NUMTOYMINTERVAL(1, 'MONTH'))
( PARTITION orders_p0 VALUES LESS THAN (DATE '2026-07-01') );
-- LOCAL index = one segment per partition (usually what you want).
CREATE INDEX orders_cust_status_idx ON orders (customer_id, status) LOCAL;
-- GLOBAL index for a unique key that excludes the partition key:
CREATE UNIQUE INDEX orders_pk ON orders (id) GLOBAL;
ALTER TABLE orders DROP PARTITION orders_p0 UPDATE GLOBAL INDEXES;
-- Swap a fully-loaded staging table in, in milliseconds:
ALTER TABLE orders EXCHANGE PARTITION orders_p1
WITH TABLE orders_stage INCLUDING INDEXES WITHOUT VALIDATION;WHERE placed_at >= now() - interval '7 days' prunes; WHERE to_char(placed_at,'YYYY-MM') = '2026-07' does not — the function hides the key. In Oracle a VARCHAR2 bind compared to a DATE partition key silently converts and can defeat pruning entirely: check Pstart/Pstop and look for PARTITION RANGE ITERATOR (pruned) versus PARTITION RANGE ALL (not pruned). PostgreSQL simply omits pruned partitions from the plan; at execution time look for Subplans Removed: N.
Partitioning a 5 GB table "because it feels big" usually makes things slower: more relations to plan and lock, more index segments, and every query lacking the partition key now scans every partition. Partition when you need time-based drop/archive, when the table is hundreds of gigabytes with naturally time-scoped queries, or when you need partition-wise joins. Note also that PostgreSQL handles a few thousand partitions comfortably on PG 13+ but not tens of thousands; Oracle scales much further.
Parallelism
-- PostgreSQL: the planner decides; you set the ceilings.
SET max_parallel_workers_per_gather = 4;
SET min_parallel_table_scan_size = '8MB';
EXPLAIN (ANALYZE, BUFFERS)
SELECT status, count(*), sum(total) FROM orders GROUP BY status;
-- Finalize GroupAggregate -> Gather Merge -> Partial GroupAggregate
-- Workers Planned: 4 Workers Launched: 4 <- compare these two!
ALTER TABLE orders SET (parallel_workers = 8);-- Oracle: degree of parallelism by hint, by table, or automatic.
SELECT /*+ PARALLEL(o, 8) */ status, COUNT(*), SUM(total)
FROM orders o GROUP BY status;
ALTER TABLE orders PARALLEL 8;
ALTER SESSION SET parallel_degree_policy = AUTO;
ALTER SESSION SET parallel_min_time_threshold = 10; -- seconds
-- Did it go parallel, and was it downgraded?
SELECT sql_id, px_servers_requested, px_servers_allocated, status
FROM v$sql_monitor WHERE px_servers_requested > 0;Eight workers on one report are eight CPUs the OLTP workload does not get. In PostgreSQL, workers come from the cluster-wide max_parallel_workers pool and a query that cannot get them silently runs with fewer (Workers Planned: 4 Workers Launched: 0). In Oracle the same shortfall is a downgrade, visible in V$SQL_MONITOR and AWR, and Resource Manager exists precisely to fence it in. Also: PostgreSQL has no parallel DML in core, while Oracle does — ALTER SESSION ENABLE PARALLEL DML is a standard bulk-load technique there.
Spatial tuning: PostGIS and Oracle Spatial
Fleet-GPS questions ("which vehicles are within 500 m of this depot right now?") are where naive SQL is slowest and a correct spatial index is most dramatic.
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE gps_pings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
vehicle_id bigint NOT NULL,
recorded_at timestamptz NOT NULL,
location geography(Point, 4326) NOT NULL
);
CREATE INDEX gps_pings_loc_gix ON gps_pings USING gist (location);
CREATE INDEX gps_pings_veh_time_idx ON gps_pings (vehicle_id, recorded_at DESC);
-- SARGABLE proximity: ST_DWithin uses the GiST index.
SELECT vehicle_id, recorded_at
FROM gps_pings
WHERE ST_DWithin(location, ST_MakePoint(51.389, 35.689)::geography, 500) -- metres
AND recorded_at > now() - interval '5 minutes';
-- NOT sargable: a distance function computed for every row.
-- WHERE ST_Distance(location, ST_MakePoint(51.389,35.689)::geography) < 500
-- K-nearest-neighbour via the index-assisted <-> operator:
SELECT vehicle_id, location <-> ST_MakePoint(51.389, 35.689)::geography AS m
FROM gps_pings
ORDER BY location <-> ST_MakePoint(51.389, 35.689)::geography
FETCH FIRST 5 ROWS ONLY;CREATE TABLE gps_pings (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
vehicle_id NUMBER NOT NULL,
recorded_at TIMESTAMP NOT NULL,
location SDO_GEOMETRY NOT NULL
);
-- The metadata row is MANDATORY before the spatial index can be created:
INSERT INTO user_sdo_geom_metadata (table_name, column_name, diminfo, srid)
VALUES ('GPS_PINGS', 'LOCATION',
SDO_DIM_ARRAY(SDO_DIM_ELEMENT('LONG', -180, 180, 0.05),
SDO_DIM_ELEMENT('LAT', -90, 90, 0.05)), 4326);
COMMIT;
CREATE INDEX gps_pings_loc_sidx ON gps_pings (location)
INDEXTYPE IS MDSYS.SPATIAL_INDEX_V2;
CREATE INDEX gps_pings_veh_time_idx ON gps_pings (vehicle_id, recorded_at DESC);
-- SARGABLE proximity: the operator form goes through the spatial index.
SELECT vehicle_id, recorded_at
FROM gps_pings
WHERE SDO_WITHIN_DISTANCE(location,
SDO_GEOMETRY(2001, 4326, SDO_POINT_TYPE(51.389, 35.689, NULL), NULL, NULL),
'distance=500 unit=M') = 'TRUE'
AND recorded_at > SYSTIMESTAMP - INTERVAL '5' MINUTE;
-- NOT sargable: SDO_GEOM.SDO_DISTANCE(...) < 500 in the WHERE clause.
-- K-nearest-neighbour:
SELECT vehicle_id, SDO_NN_DISTANCE(1) AS m
FROM gps_pings
WHERE SDO_NN(location,
SDO_GEOMETRY(2001, 4326, SDO_POINT_TYPE(51.389, 35.689, NULL), NULL, NULL),
'sdo_num_res=5', 1) = 'TRUE'
ORDER BY m;Both split spatial search into a cheap filter stage (index, bounding boxes) and an exact refine stage. A distance function in the WHERE clause skips straight to refine — on every row of the table. Always use the operator form: ST_DWithin(...) in PostGIS, SDO_WITHIN_DISTANCE(...) = 'TRUE' in Oracle. Same trap, same fix, different vocabulary.
PostGIS geography computes on a spheroid: accurate but noticeably slower than geometry in a projected SRID. For city-scale work, storing geometry in a local metre-based SRID is often several times faster. On the Oracle side, the Locator subset (SDO_GEOMETRY, spatial indexes, SDO_WITHIN_DISTANCE) ships with all editions, but the network data model, GeoRaster and some analysis functions belong to the separately licensed Oracle Spatial option. Check before designing around them.
PL/SQL vs PL/pgSQL: the context-switch tax
The SQL engine and the procedural engine are two rooms. Every SQL statement inside a loop sends someone walking between the rooms carrying one brick. Ten thousand rows, twenty thousand walks. BULK COLLECT/FORALL is a wheelbarrow: one walk, a thousand bricks.
-- PL/pgSQL runs inside the same backend process, so there is no SQL/PL
-- context switch to amortise. The win comes purely from being SET-BASED.
CREATE OR REPLACE FUNCTION mark_stale_orders(p_days int)
RETURNS bigint LANGUAGE plpgsql AS $$
DECLARE n bigint;
BEGIN
-- SLOW: FOR r IN SELECT id FROM orders ... LOOP UPDATE ... END LOOP;
-- FAST: one statement, one plan, one pass.
UPDATE orders
SET status = 'STALE'
WHERE status = 'NEW'
AND placed_at < now() - make_interval(days => p_days);
GET DIAGNOSTICS n = ROW_COUNT;
RETURN n;
END;
$$;
-- Chunk huge deletes so one transaction does not bloat the table:
DELETE FROM orders
WHERE id IN (SELECT id FROM orders WHERE status = 'CANCELLED'
FETCH FIRST 10000 ROWS ONLY);-- PL/SQL runs in a separate engine: every loop iteration costs a context
-- switch. Best is still set-based; second best is BULK COLLECT + FORALL.
CREATE OR REPLACE FUNCTION mark_stale_orders(p_days NUMBER) RETURN NUMBER IS
TYPE id_tab IS TABLE OF orders.id%TYPE;
v_ids id_tab;
v_cnt NUMBER := 0;
CURSOR c IS SELECT id FROM orders
WHERE status = 'NEW' AND placed_at < SYSDATE - p_days;
BEGIN
OPEN c;
LOOP
FETCH c BULK COLLECT INTO v_ids LIMIT 1000; -- LIMIT is mandatory: PGA!
EXIT WHEN v_ids.COUNT = 0;
FORALL i IN 1 .. v_ids.COUNT
UPDATE orders SET status = 'STALE' WHERE id = v_ids(i);
v_cnt := v_cnt + SQL%ROWCOUNT;
COMMIT;
END LOOP;
CLOSE c;
RETURN v_cnt;
END;
/FETCH c BULK COLLECT INTO v_ids; with no LIMIT loads the entire result set into PGA — on ten million rows that is ORA-04030 and, with pga_aggregate_limit set, a terminated session. Always chunk with LIMIT 100–1000. The PostgreSQL mirror-image mistake is a client-side fetch without a cursor: JDBC and psycopg will materialise the whole result set in the client unless you set a fetch size and disable autocommit. Same bug, different memory.
Every row-by-row loop is a bug until proven otherwise. Ask first: can this be one UPDATE ... FROM or MERGE? If yes, do that — one plan, one pass, and the optimizer gets to help. Procedural code is for control flow SQL genuinely cannot express, not for iterating.
A repeatable tuning method
Bilingual caption — The seven-step tuning loop / حلقهی هفتمرحلهای بهینهسازی:
flowchart TD
A[1. Measure: which statement owns the total time?] --> B[2. Reproduce with real bind values]
B --> C[3. Get the REAL plan with actual row counts]
C --> D[4. Find the first estimate vs actual divergence]
D --> E{Why is the estimate wrong?}
E -- stale stats --> F[Gather stats / histogram / extended stats]
E -- non-sargable --> G[Rewrite the query]
E -- no index --> H[Design the index]
F --> I[5. Re-measure the same way]
G --> I
H --> I
I --> J[6. Check the whole workload, not just this query]
J --> K[7. Pin and document the fix]
- Measure, don't guess.
pg_stat_statements/V$SQLsorted by total time. If the statement is not in the top ten you are optimising your own comfort, not the system. - Reproduce with realistic binds. A plan built for
'CANCELLED'says nothing about the'SHIPPED'complaint. - Get the real plan:
EXPLAIN (ANALYZE, BUFFERS)orGATHER_PLAN_STATISTICS+DISPLAY_CURSOR(...,'ALLSTATS LAST'). NeverEXPLAIN PLANalone. - Find the first estimate/actual divergence. That node is the defect; everything below is symptom.
- Prefer fixes in this order: statistics → rewrite → index → configuration → hint/baseline. A hint is a permanent debt you repay at the next upgrade.
- Re-measure with buffers, not a stopwatch. Wall-clock lies about cache warmth; logical reads do not.
- Write it down: index DDL in version control, baseline in a migration script, reasoning in a comment. Otherwise the next person "cleans up that unused index."
Anti-pattern catalogue
A function on an indexed column
-- BAD: the index on placed_at is dead.
SELECT * FROM orders WHERE date_trunc('day', placed_at) = DATE '2026-07-29';
-- GOOD: a sargable half-open range.
SELECT * FROM orders
WHERE placed_at >= DATE '2026-07-29' AND placed_at < DATE '2026-07-30';
-- Or, if the expression is unavoidable, index the expression:
CREATE INDEX orders_day_idx ON orders (date_trunc('day', placed_at));-- BAD: TRUNC() hides the column from the index.
SELECT * FROM orders WHERE TRUNC(placed_at) = DATE '2026-07-29';
-- GOOD: a sargable half-open range.
SELECT * FROM orders
WHERE placed_at >= DATE '2026-07-29' AND placed_at < DATE '2026-07-30';
-- Or a function-based index — and gather stats on the hidden column!
CREATE INDEX orders_day_idx ON orders (TRUNC(placed_at));
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(USER, 'ORDERS',
method_opt => 'FOR ALL HIDDEN COLUMNS SIZE AUTO');
END;
/If account_no is VARCHAR2 and you write WHERE account_no = 12345, Oracle wraps the column in TO_NUMBER() — the string side always loses — and your index is instantly unusable. The plan shows TABLE ACCESS FULL with predicate TO_NUMBER("ACCOUNT_NO")=12345, and nothing in the SQL text looks wrong. PostgreSQL is stricter and raises operator does not exist: text = integer, so the bug surfaces in development instead of at 3 a.m. Always scan the Predicate Information section of DBMS_XPLAN for TO_NUMBER/TO_CHAR/INTERNAL_FUNCTION wrappers you did not write; Oracle 23ai's SQL Analysis Report now flags them explicitly.
Offset pagination
-- BAD: page 5000 counts and discards 100,000 rows.
SELECT * FROM orders ORDER BY placed_at DESC, id DESC OFFSET 100000 LIMIT 20;
-- GOOD: keyset ("seek") pagination — constant time on any page.
SELECT * FROM orders
WHERE (placed_at, id) < (:last_placed_at, :last_id)
ORDER BY placed_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;-- BAD: same problem with the row-limiting clause (12c+).
SELECT * FROM orders ORDER BY placed_at DESC, id DESC
OFFSET 100000 ROWS FETCH NEXT 20 ROWS ONLY;
-- GOOD: keyset pagination, written without row-value syntax for clarity.
SELECT * FROM orders
WHERE placed_at < :last_placed_at
OR (placed_at = :last_placed_at AND id < :last_id)
ORDER BY placed_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;It exists in Oracle only since 12c; before that, pagination meant the ROWNUM sandwich. On 19c/23ai use OFFSET n ROWS FETCH NEXT m ROWS ONLY — the same ANSI clause PostgreSQL supports. PostgreSQL additionally accepts the older LIMIT n OFFSET m; Oracle does not.
NOT IN against a nullable column
-- TRAP: one NULL from the subquery makes the whole result empty.
SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
-- SAFE and usually far faster (a real anti-join):
SELECT c.* FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);-- Identical trap, identical three-valued-logic cause.
SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
-- SAFE anti-join:
SELECT c.* FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);Because NOT IN must honour NULL semantics, neither optimizer can convert it into a clean anti-join unless it can prove the column is NOT NULL. Add the constraint and both engines suddenly produce a hash anti-join instead of a filtered nested loop — orders of magnitude on large tables. Declaring constraints is a tuning activity: NOT NULL, CHECK, FOREIGN KEY and unique constraints all feed the optimizer real information. Oracle goes further, allowing RELY constraints on a warehouse (with query_rewrite_integrity = trusted) purely to unlock transformations.
The rest, briefly
SELECT *defeats index-only scans, inflates sort memory and network, and breaks the day someone adds aCLOB/byteacolumn.- N+1 from the ORM: one query for the list, one per row for the children. Detect via
callsin the millions inpg_stat_statements, or a 10046 trace showing onesql_idexecuted 50 000 times. DISTINCTused to hide a fan-out join — you pay a sort of the duplicated set; fix the join (usually withEXISTS).UNIONwhereUNION ALLwas meant — you paid for de-duplication you did not need.ORacross different columns often blocks index use; PostgreSQL may save you with aBitmapOr, Oracle withCONCATENATION, otherwise rewrite asUNION ALLof two indexed branches.- Leading wildcard
LIKE '%foo'cannot use a B-tree: usepg_trgmGIN, Oracle Text, or a reversed-string function-based index. - Over-indexing taxes every write and every vacuum/rebuild. Ten indexes on a hot OLTP table is a design smell.
- Assuming
count(*)is cheap. PostgreSQL must visit rows for visibility; Oracle can often use anINDEX FAST FULL SCAN. Neither is free at scale — cache or estimate it.
Best practices checklist
- Statistics first. Gather after every bulk load; do not wait for autovacuum or the maintenance window. Raise histogram resolution on skewed columns and add extended statistics for correlated pairs.
- Always bind — mandatory in Oracle to avoid hard-parse storms, still correct hygiene in PostgreSQL — while knowing the skew hazard binds create in both engines and the escape hatch in each.
- Design indexes deliberately: equality columns first, range column last, covering payload after. Review usage quarterly and delete dead weight (invisible first in Oracle).
- Index every foreign key on the child side — a performance issue in PostgreSQL, a locking outage in Oracle.
- Measure with logical reads, never wall-clock alone and never hit ratios.
- Keep transactions short. They bloat PostgreSQL cluster-wide and burn Oracle undo.
work_memlow globally, high per job; in Oracle setpga_aggregate_targetpluspga_aggregate_limitand leave workareas automatic.- Partition for lifecycle first, performance second, and verify pruning in the plan before celebrating.
- Prefer set-based SQL to loops; when you must loop, bulk-bind and chunk.
- Version-control every index, baseline and parameter change together with the plan output that justified it.
Interview Questions
Unanswerable. Cost is a unitless internal estimate, comparable only between alternative plans for the same statement under the same settings — never across queries and certainly never across engines. The real comparison metrics are logical reads (Buffers: shared hit+read / buffer_gets) and actual elapsed time.
Oracle's EXPLAIN PLAN FOR ... is an estimate without execution: no bind peeking, no adaptive-plan resolution, no real row counts — it routinely shows a plan production is not using. Running the statement with /*+ GATHER_PLAN_STATISTICS */ and then DBMS_XPLAN.DISPLAY_CURSOR(NULL,NULL,'ALLSTATS LAST') shows the plan that actually ran, with Starts, E-Rows and A-Rows. In PostgreSQL, plain EXPLAIN is the estimate form and EXPLAIN (ANALYZE, BUFFERS) the real one — but PostgreSQL's ANALYZE executes the statement, including DML, so wrap it in BEGIN ... ROLLBACK; Oracle's EXPLAIN PLAN is always safe on DML.
Oracle peeks at the first bind values at hard-parse time and optimises for them; on a skewed column the cached plan is perfect for the first caller and terrible for everyone else. Mitigation: Adaptive Cursor Sharing, which makes the cursor bind-sensitive, then bind-aware, then maintains several child cursors. PostgreSQL's equivalent is the custom-vs-generic plan switch: the first 5 executions of a prepared statement are re-planned with real values, after which PostgreSQL may lock in a value-independent generic plan. Symptom in both: fast at first, then permanently slow. PostgreSQL's fix: plan_cache_mode = 'force_custom_plan'.
At the estimate, not the plan. Walk from the leaves to the first divergence and ask why: stale statistics, a missing histogram on a skewed column, correlated predicates multiplied as if independent, or a non-sargable expression the optimizer had to guess about. Fix with ANALYZE/DBMS_STATS, a histogram, extended statistics (CREATE STATISTICS / a column group), or a rewrite. Only reach for a hint or baseline after proving the estimate cannot be corrected.
PostgreSQL index entries carry no visibility information, so an index-only scan consults the visibility map; if a heap page is not all-visible (recent writes, lagging autovacuum) the row is fetched from the heap anyway, reported as Heap Fetches: N. Oracle has no such problem — version information lives in undo, not the index — so index-only access really is index-only. The PostgreSQL fix is more aggressive autovacuum so pages stay all-visible.
They share a word and nothing else. An Oracle bitmap index is a persistent structure with one bitmap per distinct value, superb for low-cardinality warehouse columns and AND-able before touching the table — but a single-row update locks a whole bitmap segment, making it unusable in OLTP. PostgreSQL's Bitmap Index/Heap Scan is a runtime technique over ordinary B-trees: build an in-memory bitmap of page numbers, sort it, visit each heap page once. No persistence, no locking downside. PostgreSQL has no bitmap index type at all.
Oracle: capture it as a SQL Plan Baseline (DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE, optionally fixed => 'YES'), accept a SQL Profile from the Tuning Advisor, or embed hints; 23ai's Real-Time SPM creates the baseline automatically after a regression. PostgreSQL: there is no baseline mechanism in core. You stabilise indirectly — better statistics, extended statistics, a random_page_cost matched to the hardware, plan_cache_mode, WITH ... AS MATERIALIZED barriers — or you install the third-party pg_hint_plan. Saying "I'd add a baseline" in a PostgreSQL interview is an instant tell.
work_mem is a per-operation, per-worker allowance with no global ceiling: one query with 3 hash nodes and 4 parallel workers can use ~15× the setting, and the OOM killer is your only limit. pga_aggregate_target is a global instance budget divided dynamically among sessions, with pga_aggregate_limit as a hard cap that terminates the worst offender instead of the instance. Practically: keep work_mem small globally (16–64 MB) and raise it per session for batch work; in Oracle size the aggregate targets and leave workareas automatic.
Opposite advice for the same keyword. In PostgreSQL, ANALYZE is the statistics command. In Oracle, ANALYZE TABLE ... COMPUTE/ESTIMATE STATISTICS is deprecated for optimizer statistics — use DBMS_STATS; Oracle's ANALYZE survives only for chores like LIST CHAINED ROWS and VALIDATE STRUCTURE.
PostgreSQL: it pins the xmin horizon, so VACUUM cannot reclaim dead tuples in any table and the whole cluster bloats; it also blocks the freezing that prevents XID wraparound, which can eventually force a shutdown. Oracle: it pins undo (causing ORA-01555 snapshot too old for other long queries) and holds row locks, but there is no wraparound analogue because SCNs are 48-bit. Defences: idle_in_transaction_session_timeout in PostgreSQL; profile IDLE_TIME and Resource Manager in Oracle.
WHERE a = ? yes; a = ? AND b = ? yes; a = ? AND c = ? partially (seek on a, filter c); WHERE b = ? no in PostgreSQL — but in Oracle it may still work via an INDEX SKIP SCAN when a has very few distinct values. a > ? AND b = ? seeks only the a range and then filters b, which is exactly why the range column must come last.
CREATE INDEX ON orders (placed_at, status);
SELECT * FROM orders
WHERE status = 'NEW' AND placed_at >= now() - interval '30 days';CREATE INDEX orders_pl_st ON orders (placed_at, status);
SELECT * FROM orders
WHERE status = 'NEW' AND placed_at >= SYSDATE - 30;The column order is backwards. With (placed_at, status) the index seeks the 30-day range and then filters every row in it for status. With (status, placed_at) it seeks straight to the small NEW block and reads a contiguous date range inside it. Rule: equality first, range last. PostgreSQL shows the damage as a large Rows Removed by Filter; Oracle's Predicate Information shows status as a filter rather than an access predicate — that word is the tell.
Every literal is a distinct SQL text, hence a hard parse, hence contention on library-cache and shared-pool latches/mutexes — a hard-parse storm. The real fix is bind variables in the application. The emergency fix is cursor_sharing = FORCE, which substitutes system-generated binds at parse time; it stops the bleeding but costs literal-specific plans and can regress skewed queries. PostgreSQL cannot catch this exact disease — it has no shared plan cache, so it simply pays (much cheaper) planning cost per statement.
A PostgreSQL Heap-Only Tuple update writes the new version on the same page as the old and chains them, so no index entries are written. Conditions: no indexed column changed and free space on the page (fillfactor < 100). Monitor n_tup_hot_upd / n_tup_upd. Oracle updates in place so it has no HOT concept, but the analogous concern is row migration — a grown row moves and leaves a forwarding pointer, so every indexed access costs two block reads. Same idea, same prevention: reserve free space (PCTFREE).
pg_stat_statements gives cumulative per-statement counters for a normalised queryid, free, on any PostgreSQL — but it stores no plan, no bind values and no time series, so "when did this regress and to which plan?" needs auto_explain or external snapshotting. AWR stores periodic snapshots including plan_hash_value, wait-event breakdowns, segment statistics and ASH samples, so it answers "at 02:15 the plan flipped and the wait shifted to db file sequential read" directly — at the price of the licensed Diagnostics Pack. The free Oracle fallback is Statspack.
- Tuning is estimate repair. Find the first node where estimated and actual rows diverge; nearly every bad plan starts there.
- Cost is unitless and query-local; logical reads are the real metric. Hit ratios are noise.
- The real plan:
EXPLAIN (ANALYZE, BUFFERS)in PostgreSQL (it executes — wrap DML in a rollback);/*+ GATHER_PLAN_STATISTICS */+DBMS_XPLAN.DISPLAY_CURSOR(...,'ALLSTATS LAST')in Oracle.EXPLAIN PLANalone is a guess. - Finding the query:
pg_stat_statements+auto_explain(free, no plans, no history) vsV$SQL+ AWR/ASH/ADDM/SQL Monitor (rich, historical, licensed), plus SQL Trace/tkprof for whole-session truth. - Statistics:
ANALYZEin PostgreSQL,DBMS_STATSin Oracle (never Oracle'sANALYZE). Histograms fix skew; extended statistics fix correlated predicates. - Indexes: equality first, range last, covering payload after.
INCLUDEand partial indexes are PostgreSQL-only; bitmap indexes, IOTs, skip scans and invisible indexes are Oracle-only; BRIN answers what Oracle solves with partitioning. - Plan stability: Oracle has hints, baselines, profiles, adaptive plans and (23ai) real-time SPM; PostgreSQL has none in core — stability comes from estimate quality.
- Bind variables are mandatory in Oracle and bring the same skew hazard in both: bind peeking / adaptive cursor sharing vs custom-vs-generic plans.
- The MVCC tax is bloat + VACUUM + HOT + wraparound in PostgreSQL, undo +
ORA-01555+ row migration in Oracle. One long transaction hurts both, differently. work_memis per-operation with no ceiling;pga_aggregate_targetis a global budget with a hard limit.- Partition for lifecycle first and verify pruning; parallelism buys throughput at your neighbours' expense.
- Spatial:
ST_DWithin+ GiST, orSDO_WITHIN_DISTANCE+ a spatial index — never a distance function inWHERE. - Set-based beats procedural; when you must loop in PL/SQL,
BULK COLLECT ... LIMIT+FORALL.