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()
);
دو تله‌ی portability که همین‌جا در DDL پنهان است

۱. اوراکل رشته‌ی خالی را 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);
یک کلیدواژه، دو توصیه‌ی متضاد: `ANALYZE`

در پستگرس 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';
histogram — چرا `status = 'CANCELLED'` همان کوئری‌ای است که می‌شکند

بدون 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));
اصلاحِ تخمین، بدون تغییرِ access path

اگر تابعِ داخلِ 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;
`EXPLAIN ANALYZE` statement تو را اجرا می‌کند — `EXPLAIN PLAN` هرگز

در پستگرس 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

خروجیِ 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 |
---------------------------------------------------------------------------------------
بر اساس زمانِ کل مرتب کن، هرگز بر اساس میانگین

کوئری‌ای که روزی یک‌بار ۴ ثانیه طول می‌کشد، خطای گردکردن است. کوئری‌ای که ساعتی دو میلیون بار ۴ میلی‌ثانیه طول می‌کشد، همان قطعیِ سرویسِ توست. هر دو ابزار وسوسه‌ات می‌کنند بر اساس میانگین مرتب کنی؛ مقاومت کن. 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;
`auto_explain.log_timing = off` تنظیمِ امنِ production است

زمان‌گیریِ هر گره، دو بار در هر ردیف ساعت را صدا می‌زند و می‌تواند ۲۰ تا ۱۰۰ درصد سربار اضافه کند. با timing خاموش، هنوز تعداد ردیفِ واقعی را داری — یعنی همان عددی که مهم است — تقریباً رایگان. با sample_rate ترکیبش کن و می‌توانی برای همیشه روشن نگهش داری.

AWR و ASH و ADDM و SQL Monitor لایسنسِ جدا می‌خواهند

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;
چرا tkprof هنوز برای یک سؤالِ خاص بی‌رقیب است

ابزارهای 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';
«Index-Only Scan» پستگرس فقط وقتی واقعاً only است که صفحه all-visible باشد

ورودی‌های ایندکس در پستگرس هیچ اطلاعاتی درباره‌ی visibility ندارند، پس index-only scan باید visibility map را بپرسد؛ اگر صفحه‌ی heap علامتِ all-visible نداشته باشد (نوشتنِ اخیر، عقب‌ماندگیِ autovacuum)، ردیف باز هم از heap واکشی می‌شود — با نامِ Heap Fetches: 918273. اوراکل چنین مشکلی ندارد: اطلاعاتِ نسخه در undo است نه در ایندکس، پس دسترسیِ index-only واقعاً index-only است. یکی از عمیق‌ترین تفاوت‌های ساختاریِ دو موتور و یک سؤالِ مصاحبه‌ی درجه‌یک.

clustering factor در برابر correlation — یک ایده، دو مقیاس

ترتیبِ منطقیِ ایندکس چقدر با ترتیبِ فیزیکیِ جدول می‌خواند؟ اگر بخواند، یک 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';

روش‌های 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;       -- بالاتر از این، جست‌وجوی ژنتیک (تقریبی)
`work_mem` برای هر عملیات و هر worker است — نه برای هر کوئری

گران‌ترین پیکربندیِ غلط در پستگرس. 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 در برابر ستون‌های کلیدیِ اضافه. 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 و OLTP دشمنِ خونی‌اند — و نامش هم تله است

به‌روزرسانیِ یک ردیف در bitmap indexِ اوراکل کلِ یک قطعه‌ی bitmap را قفل می‌کند، بالقوه هزاران ردیف؛ پس تراکنش‌های هم‌زمان روی ردیف‌های نامرتبط هم‌دیگر را بلاک می‌کنند و deadlock عادی می‌شود. جای bitmap index انبارِ داده است، نه جدولِ تراکنشی. جدا از این: «Bitmap Heap Scan» پستگرس اصلاً bitmap index نیست. یک تکنیکِ زمان‌اجرا روی btreeهای معمولی است: bitmapِ شماره‌ی صفحه‌ها را در حافظه بساز، مرتب کن، هر صفحه‌ی heap را یک‌بار ببین. همان کلمه، مکانیزمِ متفاوت، بدونِ عارضه‌ی قفل.

BRIN فقط وقتی جادوست که ترتیبِ فیزیکی با ستون بخواند

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 — امن‌ترین راهِ حذفِ ایندکس

پیش از حذفِ یک ایندکسِ ۴۰ گیگابایتی که «کسی استفاده نمی‌کند»، اول 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;
ایندکسی که همه فراموش می‌کنند: foreign key سمتِ فرزند

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);

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 peeking در برابر generic plan — یک بیماری، دو نام

وقتی اوراکل یک 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;
connection poolerها می‌توانند بی‌سروصدا همه‌ی این‌ها را از کار بیندازند

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 برای هینتِ واقعی.
SQL Plan Baseline در برابر SQL Profile — جفتِ کلاسیکِ مصاحبه‌ی اوراکل

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.

adaptive plan — اوراکل وسطِ پرواز نظرش را عوض می‌کند

از 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);
HOT update — ترفندی از پستگرس که هر سنیوری باید بداند

یک به‌روزرسانیِ 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;

پیکربندیِ حافظه و 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();
نسبتِ hit ratioِ کش در هر دو موتور یک دروغ است

نسبتِ ۹۹٫۹٪ هم می‌تواند یعنی «همه‌چیز کش شده» و هم یعنی «یک کوئری با 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;
pruning فقط وقتی رخ می‌دهد که کلیدِ partition با نوعِ درست در predicate باشد

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 بگرد.

partitioning به‌خودیِ‌خود یک قابلیتِ کارایی نیست

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);
موازی‌سازی throughput می‌خرد، به قیمتِ همسایه‌ها

هشت 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;
باگِ کلاسیکِ مکانی در هر دو موتور یکسان است

هر دو موتور جست‌وجوی مکانی را به یک مرحله‌ی ارزانِ 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);
`BULK COLLECT` بدونِ `LIMIT` یک بمبِ PGA است

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));
تبدیلِ ضمنیِ نوعِ داده — قاتلِ خاموشِ ایندکس، و در اوراکل بسیار بدتر

اگر 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;
`OFFSET ... FETCH` یکی از معدود جاهایی است که دو گویش به هم رسیده‌اند

در اوراکل فقط از 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);
`NOT IN` علاوه بر مسئله‌ی درستی، یک مسئله‌ی *پلن* هم هست

چون 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 برابر ۴٬۲۰۰ در برابر cost برابر ۱۸۰ — کدام کوئری سریع‌تر است؟

غیرقابل پاسخ. cost یک تخمینِ داخلیِ بی‌واحد است و فقط بین پلن‌های جایگزینِ یک statement با تنظیماتِ یکسان قابل مقایسه است — نه بین کوئری‌ها و قطعاً نه بین موتورها. سنجه‌ی مقایسه‌ی واقعی، logical read (Buffers: shared hit+read / buffer_gets) و زمانِ واقعی است.

۲. `EXPLAIN PLAN` در برابر `DBMS_XPLAN.DISPLAY_CURSOR` در برابر `EXPLAIN ANALYZE`؟ (Oracle در برابر PostgreSQL)

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 امن است.

۳. bind peeking چیست و معادلِ آن در پستگرس کدام است؟ (سنیور، Oracle در برابر PostgreSQL)

اوراکل هنگام 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 برو.

۵. چرا پستگرس درونِ «Index Only Scan» گاهی heap fetch می‌زند، و آیا اوراکل هم این مشکل را دارد؟ (سخت، Oracle در برابر PostgreSQL)

ورودی‌های ایندکسِ پستگرس هیچ اطلاعاتِ 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 Heap Scanِ پستگرس مقایسه کن. (تله)

یک کلمه‌ی مشترک دارند و هیچ چیزِ دیگر. bitmap indexِ اوراکل ساختاری ماندگار با یک bitmap به‌ازای هر مقدار است، برای ستون‌های کم‌کاردینالیتیِ انبارِ داده درخشان و قابلِ AND پیش از دست‌زدن به جدول — اما یک update روی یک ردیف کلِ یک قطعه‌ی bitmap را قفل می‌کند و آن را برای OLTP غیرقابل استفاده می‌سازد. Bitmap Index/Heap Scanِ پستگرس یک تکنیکِ زمان‌اجرا روی btreeهای معمولی است: bitmapِ شماره‌ی صفحه‌ها در حافظه، مرتب‌سازی، و یک بازدید از هر صفحه‌ی heap. نه ماندگاری دارد نه عارضه‌ی قفل. پستگرس اصلاً نوعِ ایندکسِ bitmap ندارد.

۷. در هر موتور چطور یک پلنِ خوب را پین می‌کنی؟ (Oracle در برابر PostgreSQL)

اوراکل: به‌عنوان 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` در برابر `pga_aggregate_target` — تفاوتِ عملیاتی چیست؟ (سنیور)

work_mem سهمیه‌ی هر عملیات و هر worker است و هیچ سقفِ سراسری ندارد: یک کوئری با ۳ گرهِ hash و ۴ workerِ موازی می‌تواند ~۱۵ برابرِ آن مصرف کند و تنها مرزت OOM killer است. pga_aggregate_target یک بودجه‌ی سراسریِ instance است که پویا بین سشن‌ها تقسیم می‌شود، با pga_aggregate_limit به‌عنوانِ سقفِ سختی که به‌جای کلِ instance بدترین سشن را می‌کشد. عملاً: work_mem را سراسری کوچک نگه دار (۱۶ تا ۶۴ مگابایت) و برای کارِ دسته‌ای در سشن بالا ببر؛ در اوراکل بودجه‌های تجمیعی را اندازه کن و workareaها را خودکار بگذار.

۹. `ANALYZE` در اوراکل در برابر `ANALYZE` در پستگرس. (تله)

دو توصیه‌ی متضاد برای یک کلیدواژه. در پستگرس ANALYZE همان فرمانِ آمار است. در اوراکل ANALYZE TABLE ... COMPUTE/ESTIMATE STATISTICS برای آمارِ بهینه‌ساز منسوخ است — از DBMS_STATS استفاده کن؛ ANALYZE اوراکل فقط برای کارهایی مثل LIST CHAINED ROWS و VALIDATE STRUCTURE زنده مانده.

۱۰. یک تراکنشِ بازِ فراموش‌شده با هر موتور چه می‌کند؟ (سخت، Oracle در برابر PostgreSQL)

پستگرس: افقِ 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 در اوراکل.

۱۱. ایندکسِ ترکیبیِ `(a, b, c)` — کدام کوئری‌ها می‌توانند از آن استفاده کنند؟

WHERE a = ? بله؛ a = ? AND b = ? بله؛ a = ? AND c = ? نسبی (seek روی a، فیلترِ cWHERE 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';

ترتیبِ ستون‌ها برعکس است. با (placed_at, status) ایندکس بازه‌ی ۳۰ روزه را seek می‌کند و بعد باید تک‌تکِ ردیف‌های آن را برای status فیلتر کند. با (status, placed_at) مستقیم به بلوکِ کوچکِ NEW می‌رود و درونِ آن یک بازه‌ی پیوسته‌ی تاریخی می‌خواند. قاعده: اول تساوی، آخر بازه. پستگرس آسیب را به‌شکلِ Rows Removed by Filterِ بزرگ نشان می‌دهد؛ اوراکل در Predicate Information می‌بینی که status به‌جای access به‌عنوانِ filter آمده — همان کلمه، نشانه‌ی ماجراست.

۱۳. اپِ اوراکلی‌ات SQLِ literal اجرا می‌کند و instance روی latchها دارد می‌میرد. چه خبر است و درمانِ اضطراری چیست؟ (سنیور)

هر 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 می‌دهد.

۱۴. HOT update چیست و معادلِ اوراکلی‌اش کدام است؟ (سنیور، Oracle در برابر PostgreSQL)

به‌روزرسانیِ Heap-Only Tuple در پستگرس نسخه‌ی جدید را روی همان صفحه می‌نویسد و زنجیر می‌کند، پس هیچ ورودیِ ایندکسی نوشته نمی‌شود. شرط‌ها: هیچ ستونِ ایندکس‌شده‌ای تغییر نکند و روی صفحه جای خالی باشد (fillfactor < 100). با n_tup_hot_upd / n_tup_upd پایشش کن. اوراکل در جا update می‌کند پس مفهومِ HOT ندارد، اما نگرانیِ متناظرش مهاجرتِ ردیف است — ردیفِ بزرگ‌شده جابه‌جا می‌شود و اشاره‌گرِ ارجاع می‌گذارد، پس هر دسترسیِ ایندکسی دو بلاک‌خوانی هزینه دارد. همان ایده، همان پیشگیری: جای خالی نگه دار (PCTFREE).

۱۵. `pg_stat_statements` در برابر AWR — هرکدام چه چیزی را می‌تواند جواب دهد که دیگری نمی‌تواند؟ (Oracle در برابر PostgreSQL)

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.

Roadmap for this chapter
  • 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 — ANALYZE vs DBMS_STATS.
  • Reading plans: EXPLAIN (ANALYZE, BUFFERS) vs EXPLAIN PLAN + DBMS_XPLAN.DISPLAY_CURSOR + AUTOTRACE + SQL Trace/tkprof.
  • Finding the slow query: pg_stat_statements / auto_explain vs 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) = 2026 is 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.
The one sentence that governs all tuning

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()
);
Two portability traps hiding in that DDL
  1. Oracle treats the empty string as NULL. WHERE status = '' matches nothing in Oracle, ever. PostgreSQL keeps '' and NULL strictly distinct.
  2. text vs VARCHAR2(n). PostgreSQL's text is unbounded and free. In Oracle you must pick a length — and picking VARCHAR2(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]
The optimizer is a bookmaker, not a fortune teller

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_id can own many child cursors, each with its own plan_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.
Why "why did the plan change?" is easy in Oracle and hard in PostgreSQL

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

The removal-van estimate

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);
Same keyword, opposite advice: `ANALYZE`

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.

Who gathers automatically, and why 10% is too lax

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';
Histograms — why `status = 'CANCELLED'` is the query that breaks

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-depth histogram_bounds. One mechanism, always on.
  • Oracle picks a histogram type: frequency (≤254 distinct values, exact), top-frequency, hybrid, and legacy height-balanced. SIZE AUTO decides 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));
Correct the estimate without changing the access path

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;
`EXPLAIN ANALYZE` executes your statement — `EXPLAIN PLAN` never does

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

Oracle'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 |
---------------------------------------------------------------------------------------
Sort by total time, never by mean time

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;
`auto_explain.log_timing = off` is the production-safe setting

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.

AWR, ASH, ADDM and SQL Monitor are separately licensed

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;
Why tkprof still wins for one specific question

EXPLAIN-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

Finding names in a phone book

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';
PostgreSQL's Index-Only Scan is only "only" if the page is all-visible

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.

Clustering factor vs correlation — one idea, two scales

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';

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
`work_mem` is per operation, per worker — not per query

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.

Spotting a spill in either engine

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

A phone book sorted by (city, surname)

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:

  1. 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.
  2. Then columns needed only for output (covering), not for filtering.
  3. 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);
Three real differences hiding in those two tabs
  1. INCLUDE vs extra key columns. PostgreSQL's INCLUDE payload 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.
  2. Partial indexes are PostgreSQL-only. The Oracle CASE trick works and is genuinely used, but it is brittle because the query must repeat the expression exactly.
  3. NULL ordering must match, and it is easy to get wrong. Both engines default to NULLS LAST for ASC and NULLS FIRST for DESC — but the moment you write ORDER BY placed_at DESC NULLS LAST and the index does not declare the same placement, the sort node comes back. PostgreSQL lets you bake NULLS LAST into the index; in Oracle you must declare DESC (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 ...
Bitmap indexes and OLTP are mortal enemies — and the name is a trap

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 is magic only when physical order matches the column

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;
The reverse use of INVISIBLE — the safest way to drop an index

Before 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;
The index nobody remembers: the foreign key on the child side

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);

Bind variables, plan caching and parameter sniffing

Cutting a new key every time you open your own front door

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
Bind peeking vs generic plans — the same disease, two names

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;
Connection poolers can silently disable all of this

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.
SQL Plan Baseline vs SQL Profile — the classic Oracle interview pair

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.

PostgreSQL's honest answer to "how do I pin a plan?" is: you don't

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.

Adaptive plans — Oracle changes its mind mid-flight

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.

Two libraries, two filing philosophies

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 UPDATE is delete + insert. The old tuple survives with xmax set until VACUUM reclaims it. Cost = bloat in the table and every index.
  • Oracle: an UPDATE modifies 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);
HOT updates — the PostgreSQL trick every senior must know

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).

One forgotten open transaction poisons the whole cluster

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;

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();
The buffer cache hit ratio is a lie in both engines

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

A filing cabinet with one drawer per month

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;
Pruning happens only if the partition key is in the predicate, with the right type

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 is not a performance feature by default

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);
Parallel query buys throughput at your neighbours' expense

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;
The classic spatial bug is identical in both engines

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.

Spatial is not free, and licensing differs

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

Carrying bricks one at a time

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);
`BULK COLLECT` without `LIMIT` is a PGA bomb

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 1001000. 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.

The senior rule for procedural code

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]
  1. Measure, don't guess. pg_stat_statements / V$SQL sorted by total time. If the statement is not in the top ten you are optimising your own comfort, not the system.
  2. Reproduce with realistic binds. A plan built for 'CANCELLED' says nothing about the 'SHIPPED' complaint.
  3. Get the real plan: EXPLAIN (ANALYZE, BUFFERS) or GATHER_PLAN_STATISTICS + DISPLAY_CURSOR(...,'ALLSTATS LAST'). Never EXPLAIN PLAN alone.
  4. Find the first estimate/actual divergence. That node is the defect; everything below is symptom.
  5. Prefer fixes in this order: statistics → rewrite → index → configuration → hint/baseline. A hint is a permanent debt you repay at the next upgrade.
  6. Re-measure with buffers, not a stopwatch. Wall-clock lies about cache warmth; logical reads do not.
  7. 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));
Implicit datatype conversion — the silent index killer, far worse in Oracle

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;
`OFFSET ... FETCH` is one of the few places the dialects converged

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);
`NOT IN` is a *plan* problem as well as a correctness problem

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 a CLOB/bytea column.
  • N+1 from the ORM: one query for the list, one per row for the children. Detect via calls in the millions in pg_stat_statements, or a 10046 trace showing one sql_id executed 50 000 times.
  • DISTINCT used to hide a fan-out join — you pay a sort of the duplicated set; fix the join (usually with EXISTS).
  • UNION where UNION ALL was meant — you paid for de-duplication you did not need.
  • OR across different columns often blocks index use; PostgreSQL may save you with a BitmapOr, Oracle with CONCATENATION, otherwise rewrite as UNION ALL of two indexed branches.
  • Leading wildcard LIKE '%foo' cannot use a B-tree: use pg_trgm GIN, 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 an INDEX FAST FULL SCAN. Neither is free at scale — cache or estimate it.

Best practices checklist

  1. 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.
  2. 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.
  3. Design indexes deliberately: equality columns first, range column last, covering payload after. Review usage quarterly and delete dead weight (invisible first in Oracle).
  4. Index every foreign key on the child side — a performance issue in PostgreSQL, a locking outage in Oracle.
  5. Measure with logical reads, never wall-clock alone and never hit ratios.
  6. Keep transactions short. They bloat PostgreSQL cluster-wide and burn Oracle undo.
  7. work_mem low globally, high per job; in Oracle set pga_aggregate_target plus pga_aggregate_limit and leave workareas automatic.
  8. Partition for lifecycle first, performance second, and verify pruning in the plan before celebrating.
  9. Prefer set-based SQL to loops; when you must loop, bulk-bind and chunk.
  10. Version-control every index, baseline and parameter change together with the plan output that justified it.

Interview Questions

1. Cost 4 200 versus cost 180 — which query is faster?

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.

2. `EXPLAIN PLAN` vs `DBMS_XPLAN.DISPLAY_CURSOR` vs `EXPLAIN ANALYZE`? (Oracle vs PostgreSQL)

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.

3. What is bind peeking, and what is PostgreSQL's equivalent? (senior, Oracle vs PostgreSQL)

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'.

4. A query estimated 800 rows and returned 900 000. Where do you start?

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.

5. Why does PostgreSQL do heap fetches inside an "Index Only Scan," and does Oracle have this problem? (hard, Oracle vs PostgreSQL)

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.

6. Compare an Oracle bitmap index with PostgreSQL's Bitmap Heap Scan. (gotcha)

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.

7. How do you pin a good execution plan in each engine? (Oracle vs PostgreSQL)

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.

8. `work_mem` vs `pga_aggregate_target` — the operational difference? (senior)

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.

9. `ANALYZE` in Oracle versus `ANALYZE` in PostgreSQL. (gotcha)

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.

10. One forgotten open transaction — what does it do to each engine? (hard, Oracle vs PostgreSQL)

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.

11. Composite index `(a, b, c)` — which queries can use it?

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.

12. Find the bug
CREATE INDEX ON orders (placed_at, status);

SELECT * FROM orders
WHERE  status = 'NEW' AND placed_at >= now() - interval '30 days';

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.

13. Your Oracle app runs literal SQL and the instance is dying on latches. What is happening, and what is the emergency fix? (senior)

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.

14. HOT updates — what are they, and what is the Oracle analogue? (senior, Oracle vs PostgreSQL)

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).

15. `pg_stat_statements` versus AWR — what can each answer that the other cannot? (Oracle vs PostgreSQL)

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.


In a nutshell
  • 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 PLAN alone is a guess.
  • Finding the query: pg_stat_statements + auto_explain (free, no plans, no history) vs V$SQL + AWR/ASH/ADDM/SQL Monitor (rich, historical, licensed), plus SQL Trace/tkprof for whole-session truth.
  • Statistics: ANALYZE in PostgreSQL, DBMS_STATS in Oracle (never Oracle's ANALYZE). Histograms fix skew; extended statistics fix correlated predicates.
  • Indexes: equality first, range last, covering payload after. INCLUDE and 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_mem is per-operation with no ceiling; pga_aggregate_target is 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, or SDO_WITHIN_DISTANCE + a spatial index — never a distance function in WHERE.
  • Set-based beats procedural; when you must loop in PL/SQL, BULK COLLECT ... LIMIT + FORALL.