~/icsd.ir — bash
SYSTEM_ONLINE

ایندکس‌گذاری حرفه‌ای در PostgreSQL

ایندکس‌ها قلب performance دیتابیس هستند. PostgreSQL بیش از ۶ نوع ایندکس مختلف دارد - B-Tree، Hash، GIN، GiST، BRIN و SP-GiST - هرکدام برای مورد خاصی. در این فصل با همه آن‌ها، Partial Index، Expression Index و Covering Index آشنا می‌شویم.

ایندکس‌ها قلب performance دیتابیس هستند. PostgreSQL بیش از ۶ نوع ایندکس مختلف دارد – B-Tree، Hash، GIN، GiST، BRIN و SP-GiST – هرکدام برای مورد خاصی. در این فصل با همه آن‌ها، Partial Index، Expression Index و Covering Index آشنا می‌شویم.

چرا ایندکس مهم است؟

-- جدول 10 میلیون رکورد بدون ایندکس
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INTEGER,
    status TEXT,
    total NUMERIC(10,2),
    created_at TIMESTAMPTZ
);

-- query بدون ایندکس - Sequential Scan
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 12345;
-- Seq Scan on orders ... (cost=0.00..165000 rows=1000) 
-- Execution Time: 850ms

-- با ایندکس - Index Scan
CREATE INDEX idx_orders_user ON orders(user_id);

EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 12345;
-- Index Scan using idx_orders_user ...
-- Execution Time: 0.5ms       ← 1700 برابر سریع‌تر!

B-Tree – ایندکس پیش‌فرض

B-Tree ایندکس متعادل (balanced) است که برای equality (=) و range (<، >، BETWEEN) عالی کار می‌کند.

-- ایندکس ساده
CREATE INDEX idx_users_email ON users(email);

-- ایندکس multi-column (مرکب)
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at DESC);

-- ایندکس unique
CREATE UNIQUE INDEX idx_users_username ON users(username);

-- یا با constraint
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email TEXT UNIQUE         -- خودکار ایندکس می‌سازد
);

ترتیب ستون‌ها در multi-column

ترتیب ستون‌ها بسیار مهم است:

CREATE INDEX idx ON orders(user_id, status, created_at);

-- ✅ این query‌ها از ایندکس استفاده می‌کنند:
SELECT * FROM orders WHERE user_id = 1;
SELECT * FROM orders WHERE user_id = 1 AND status = 'pending';
SELECT * FROM orders WHERE user_id = 1 AND status = 'pending' AND created_at > '2026-01-01';

-- ❌ این‌ها نمی‌توانند از ایندکس استفاده کنند:
SELECT * FROM orders WHERE status = 'pending';                    -- بدون user_id
SELECT * FROM orders WHERE created_at > '2026-01-01';             -- بدون prefix
SELECT * FROM orders WHERE status = 'pending' AND created_at ...; -- بدون user_id
قاعده: ستون‌های پر تکرار در equality را اول، پر تنوع را وسط، range را آخر بگذارید (Equality → Sort → Range).

DESC و ترتیب

-- اگر ORDER BY ... DESC رایج است:
CREATE INDEX idx ON orders(user_id, created_at DESC);

-- اگر ORDER BY مختلف داریم:
CREATE INDEX idx ON orders(user_id, created_at DESC, status);
-- این برای WHERE user_id = ? ORDER BY created_at DESC کار می‌کند

Hash Index

فقط برای equality (=) – معمولاً B-Tree بهتر است:

CREATE INDEX idx_session_id ON sessions USING HASH (session_id);

-- مزیت: کوچک‌تر از B-Tree برای ستون‌های با مقدار بزرگ (مثل UUID/hash)
-- معایب: فقط = پشتیبانی می‌کند، نه <، >، ORDER BY
قبل از PostgreSQL 10، Hash Index WAL-logged نبود و crash-safe نبود. الان مشکل ندارد.

GIN – Generalized Inverted Index

GIN برای ستون‌هایی که چند مقدار دارند: array، JSONB، tsvector، trigram.

روی JSONB

CREATE INDEX idx_products_attrs ON products USING GIN (attributes);

-- حالا این query‌ها سریع می‌شوند:
SELECT * FROM products WHERE attributes @> '{"color": "قرمز"}';
SELECT * FROM products WHERE attributes ? 'discount';
SELECT * FROM products WHERE attributes ?| ARRAY['size', 'weight'];

jsonb_path_ops – سریع‌تر و کوچک‌تر

-- اگر فقط @> می‌خواهید (نه ? یا ?|)
CREATE INDEX idx_products_attrs ON products USING GIN (attributes jsonb_path_ops);
-- ~3 برابر کوچک‌تر و سریع‌تر برای @>

روی Array

CREATE INDEX idx_products_tags ON products USING GIN (tags);

SELECT * FROM products WHERE tags @> ARRAY['پشمی'];
SELECT * FROM products WHERE 'دستباف' = ANY(tags);

روی Full-Text Search

CREATE INDEX idx_articles_fts ON articles USING GIN (to_tsvector('simple', content));

SELECT * FROM articles 
WHERE to_tsvector('simple', content) @@ to_tsquery('simple', 'فرش & کاشان');

pg_trgm – جستجوی fuzzy

CREATE EXTENSION pg_trgm;

CREATE INDEX idx_products_name_trgm ON products USING GIN (name gin_trgm_ops);

-- LIKE با wildcard در ابتدا - معمولاً ایندکس نمی‌شود
SELECT * FROM products WHERE name LIKE '%کاشان%';   -- با gin_trgm_ops سریع!

-- شباهت
SELECT * FROM products WHERE similarity(name, 'کاشان') > 0.3;
SELECT * FROM products WHERE name % 'کشان';   -- شباهت حدودی - حتی غلط املایی

GiST – Generalized Search Tree

GiST برای داده‌هایی که ترتیب طبیعی ندارند: مکان (geometry)، Range، tsvector، انواع سفارشی.

روی Range

CREATE TABLE reservations (
    id SERIAL PRIMARY KEY,
    period TSTZRANGE
);

CREATE INDEX idx_reservations_period ON reservations USING GIST (period);

-- جستجوی overlap
SELECT * FROM reservations 
WHERE period && '[2026-05-01, 2026-05-10)'::tstzrange;

روی Geometry (با PostGIS)

CREATE EXTENSION postgis;

CREATE TABLE cities (
    id SERIAL PRIMARY KEY,
    name TEXT,
    location GEOGRAPHY(POINT)
);

CREATE INDEX idx_cities_location ON cities USING GIST (location);

-- 100 شهر نزدیک تهران
SELECT name, ST_Distance(location, ST_GeographyFromText('POINT(51.4 35.7)')) AS dist
FROM cities
ORDER BY location <-> ST_GeographyFromText('POINT(51.4 35.7)')
LIMIT 100;

Exclusion Constraint با GiST

CREATE TABLE reservations (
    room_id INTEGER,
    period TSTZRANGE,
    EXCLUDE USING GIST (room_id WITH =, period WITH &&)
);
-- جلوگیری از رزرو همزمان یک اتاق

BRIN – Block Range Index

BRIN ایندکسی فوق‌العاده کوچک برای جداول بزرگ که داده در آن مرتب وارد می‌شود (مثل log‌ها):

CREATE TABLE access_logs (
    id BIGSERIAL,
    accessed_at TIMESTAMPTZ DEFAULT NOW(),
    ip_address INET,
    user_id INTEGER,
    page TEXT
);

-- B-Tree روی timestamp برای 1 میلیارد ردیف:
-- ~30 GB!

-- BRIN روی timestamp:
CREATE INDEX idx_logs_time_brin ON access_logs USING BRIN (accessed_at);
-- ~50 MB ! (600 برابر کوچک‌تر)

-- query‌های range همچنان سریع
SELECT * FROM access_logs 
WHERE accessed_at BETWEEN '2026-01-01' AND '2026-01-31';
کاربرد BRIN: جداول log، time-series، data warehouse با میلیاردها ردیف که داده مرتب وارد می‌شود. اگر داده پراکنده است (random insert)، BRIN بی‌فایده است.

Partial Index – ایندکس جزئی

ایندکس فقط روی زیرمجموعه‌ای از ردیف‌ها:

-- معمولاً 99% ردیف‌ها active هستند
CREATE INDEX idx_users_email ON users(email);  -- بزرگ

-- فقط user‌های banned را ایندکس می‌کند (کوچک‌تر، سریع‌تر)
CREATE INDEX idx_users_banned ON users(email) 
WHERE status = 'banned';

-- query‌هایی که condition را دارند، از ایندکس استفاده می‌کنند:
SELECT * FROM users WHERE status = 'banned' AND email = 'x@y.com';

کاربردهای رایج

-- فقط orders فعال
CREATE INDEX idx_active_orders ON orders(user_id) 
WHERE status IN ('pending', 'processing');

-- فقط non-NULL
CREATE INDEX idx_users_phone ON users(phone) 
WHERE phone IS NOT NULL;

-- soft delete
CREATE INDEX idx_products_active ON products(name) 
WHERE deleted_at IS NULL;

Expression Index – روی محاسبه

-- جستجو case-insensitive کند است
SELECT * FROM users WHERE LOWER(email) = LOWER('Ali@Example.com');

-- ایندکس روی expression
CREATE INDEX idx_users_email_lower ON users(LOWER(email));

-- حالا سریع است:
SELECT * FROM users WHERE LOWER(email) = LOWER('Ali@Example.com');

-- ایندکس روی تاریخ بدون ساعت
CREATE INDEX idx_logs_date ON logs(DATE(created_at));
SELECT * FROM logs WHERE DATE(created_at) = '2026-05-01';

-- ایندکس روی JSON expression
CREATE INDEX idx_products_color 
ON products ((attributes->>'color'));

-- ایندکس روی محاسبه
CREATE INDEX idx_orders_total_with_tax 
ON orders ((subtotal + tax));

Covering Index – INCLUDE

گاهی می‌خواهیم ستون‌هایی برای خواندن در ایندکس باشند، بدون اینکه scan روی جدول لازم باشد (Index-Only Scan):

-- بدون INCLUDE
CREATE INDEX idx_orders_user ON orders(user_id);

SELECT user_id, total FROM orders WHERE user_id = 1;
-- باید جدول را برای total بخواند ← Heap Fetch

-- با INCLUDE
CREATE INDEX idx_orders_user_inc ON orders(user_id) INCLUDE (total, status);

SELECT user_id, total, status FROM orders WHERE user_id = 1;
-- Index-Only Scan ← همه‌چیز در ایندکس

Unique Index پیچیده

-- یکتا فقط در ردیف‌های فعال
CREATE UNIQUE INDEX idx_active_email 
ON users(email) 
WHERE deleted_at IS NULL;

-- اجازه می‌دهد چند کاربر deleted با همان email باشد

-- یکتا case-insensitive
CREATE UNIQUE INDEX idx_email_ci 
ON users(LOWER(email));

چه زمانی ایندکس نگذاریم؟

  • جداول کوچک (< 1000 ردیف) – sequential scan سریع‌تر است
  • ستون‌هایی که زیاد UPDATE می‌شوند – ایندکس هم باید بروز شود
  • ستون‌های با کاردینالیتی پایین (مثل bool) – مگر با partial index
  • جداول write-heavy با خواندن کم
-- ایندکس روی gender با cardinality 2 - تقریباً بی‌فایده
CREATE INDEX idx_users_gender ON users(gender);  -- ❌

-- اما این مفید است:
CREATE INDEX idx_users_female ON users(id) WHERE gender = 'female';

EXPLAIN – بررسی استفاده از ایندکس

EXPLAIN ANALYZE 
SELECT * FROM orders WHERE user_id = 100 AND status = 'pending';

-- خروجی:
-- Bitmap Heap Scan on orders (cost=4.45.....)
--   Recheck Cond: ((user_id = 100) AND (status = 'pending'))
--   ->  Bitmap Index Scan on idx_orders_user_status
-- 
-- Index Scan using idx_... ← خوب
-- Seq Scan ← بد (ایندکس مفید نیست یا وجود ندارد)

نگهداری ایندکس‌ها

-- بازسازی ایندکس‌های bloat شده
REINDEX INDEX idx_orders_user;
REINDEX TABLE orders;

-- در پروداکشن - بدون lock
REINDEX INDEX CONCURRENTLY idx_orders_user;

-- لیست ایندکس‌ها و سایز
SELECT 
    schemaname, tablename, indexname,
    pg_size_pretty(pg_relation_size(indexrelid)) AS size,
    idx_scan AS times_used
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;

-- ایندکس‌های unused
SELECT schemaname, tablename, indexname, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexname NOT LIKE '%_pkey'
ORDER BY pg_relation_size(indexrelid) DESC;
-- این‌ها شاید قابل حذف باشند

Index Bloat

با گذشت زمان و UPDATE/DELETE، ایندکس‌ها bloat می‌شوند (فضای مرده). راه‌حل:

-- بررسی bloat (نیاز به extension pgstattuple)
CREATE EXTENSION pgstattuple;

SELECT * FROM pgstatindex('idx_orders_user');
-- index_size, leaf_pages, density, ...

-- REINDEX CONCURRENTLY بهترین راه‌حل (PG 12+)
REINDEX INDEX CONCURRENTLY idx_orders_user;

CREATE INDEX CONCURRENTLY

-- ساخت ایندکس بدون lock کردن جدول (مهم در پروداکشن)
CREATE INDEX CONCURRENTLY idx_orders_user ON orders(user_id);

-- اگر شکست خورد:
SELECT * FROM pg_indexes WHERE indexname = 'idx_orders_user';
-- ایندکس INVALID خواهد بود
DROP INDEX idx_orders_user;
-- و دوباره بسازید

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

  • برای equality و range: B-Tree
  • برای JSONB، array، FTS: GIN
  • برای range types، geometry: GiST
  • برای جداول log بزرگ مرتب: BRIN
  • ترتیب ستون‌ها در multi-column مهم است
  • Partial Index برای ردیف‌های کم اما پرکار
  • Expression Index برای محاسبات تکراری
  • INCLUDE برای Index-Only Scan
  • در پروداکشن همیشه CONCURRENTLY
  • ایندکس‌های unused را حذف کنید (overhead دارند)

جمع‌بندی

  • B-Tree: پیش‌فرض، برای = و <، >
  • Hash: فقط =، معمولاً B-Tree بهتر
  • GIN: array، JSONB، FTS، trigram
  • GiST: range، geometry، exclusion constraints
  • BRIN: time-series، log‌های مرتب، حافظه کم
  • Partial: فقط روی subset
  • Expression: روی محاسبه
  • Covering (INCLUDE): Index-Only Scan

نمایش سایت

رنگ سایت
حالت نمایش
اندازهٔ متن
خوانایی

این تنظیمات فقط روی مرورگر شما ذخیره می‌شود.