正在加载,请稍候…

PostgreSQL Advanced Indexing: Partial, Covering, Expression, GIN, and BRIN

Deep dive into PostgreSQL indexing beyond B-trees: partial indexes, covering indexes, expression indexes, BRIN, GiST, and GIN with real-world performance benchmarks.

PostgreSQL Advanced Indexing: Beyond Basic B-Tree Indexes

Most developers index foreign keys and WHERE clause columns. But PostgreSQL's indexing capabilities go far beyond basic B-tree indexes. The right index type can transform a 10-second query to 10 milliseconds.

Understanding Index Internals

EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT * FROM orders
WHERE user_id = 12345 AND status = 'pending'
AND created_at > NOW() - INTERVAL '7 days';

Key metrics:

  • Actual Rows vs Plan Rows: Large divergence = stale statistics
  • Buffers hit vs read: Cache hit ratio
  • Actual Total Time: True execution cost

Partial Indexes: Your Secret Weapon

Index only a subset of rows—dramatically smaller and faster:

-- 95% of orders are 'completed', so index only pending ones
-- Result: 20x smaller index

CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending';
-- ~10MB vs ~500MB for full index on 50M rows

-- Query MUST match the index predicate
SELECT * FROM orders
WHERE status = 'pending'
AND created_at < NOW() - INTERVAL '1 hour';

-- Soft delete pattern
CREATE INDEX idx_users_active ON users(email)
WHERE deleted_at IS NULL;

Covering Indexes: Eliminate Heap Fetches

-- Query needs: id, email, name
SELECT id, email, name
FROM users
WHERE email LIKE 'john%' AND status = 'active';

-- Covering index: INCLUDE non-filter columns
CREATE INDEX idx_users_covering ON users(email, status)
INCLUDE (id, name);
-- Result: Index Only Scan (0 heap fetches!)
-- 3-10x faster than regular Index Scan

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email, name FROM users
WHERE email LIKE 'john%' AND status = 'active';
-- Look for: "Index Only Scan" + "Heap Fetches: 0"

Expression Indexes

-- Case-insensitive email search
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';

-- Date-truncated time series
CREATE INDEX idx_events_date ON events(DATE_TRUNC('day', created_at));
SELECT DATE_TRUNC('day', created_at) as day, COUNT(*)
FROM events
WHERE DATE_TRUNC('day', created_at) >= '2026-01-01'
GROUP BY 1;

-- JSON field indexing
CREATE INDEX idx_orders_priority ON orders((metadata->>'priority'));
SELECT * FROM orders WHERE metadata->>'priority' = 'high';

GIN Indexes for Full-Text Search and JSONB

-- Full-text search setup
ALTER TABLE articles ADD COLUMN search_vector tsvector;

CREATE INDEX idx_articles_search ON articles USING GIN(search_vector);

-- Ranked search
SELECT title, ts_rank(search_vector, query) AS rank
FROM articles,
     to_tsquery('english', 'postgresql & indexing') query
WHERE search_vector @@ query
ORDER BY rank DESC;

-- JSONB containment
CREATE INDEX idx_products_attrs ON products USING GIN(attributes);
SELECT * FROM products WHERE attributes @> '{"color": "blue"}';

-- Array overlap
CREATE INDEX idx_articles_tags ON articles USING GIN(tags);
SELECT * FROM articles WHERE tags && ARRAY['postgresql', 'performance'];

BRIN Indexes for Time-Series

Block Range Index: 500x smaller than B-tree for ordered data:

-- Only 1MB for 100M rows (vs 500MB B-tree)
CREATE INDEX idx_events_brin ON events
USING BRIN(created_at) WITH (pages_per_range = 32);

-- Efficient range scans on append-only time-series
SELECT * FROM events
WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31';
-- BRIN only works if data is physically ordered!

GiST Indexes for Spatial and Ranges

-- PostGIS spatial queries
CREATE INDEX idx_stores_location ON stores USING GIST(location);
SELECT name FROM stores
WHERE ST_DWithin(location::geography,
  ST_MakePoint(-122.4194, 37.7749)::geography, 10000);

-- Automatic conflict prevention with exclusion constraints
CREATE TABLE reservations (
  room_id int,
  during daterange,
  EXCLUDE USING GIST (room_id WITH =, during WITH &&)
);
-- Prevents double-booking at the database level!

Monitoring

-- Find unused indexes
SELECT tablename, indexname,
  pg_size_pretty(pg_relation_size(indexrelid)) AS size,
  idx_scan AS scans
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;

-- Rebuild without locking
REINDEX INDEX CONCURRENTLY idx_orders_status;

Decision Guide

Scenario Index Type
Equality/range on scalars B-tree
Selective subset of rows Partial
Query uses only indexed cols Covering with INCLUDE
Computed expression in WHERE Expression
Full-text search or JSONB @> GIN
Large time-series ranges BRIN
Spatial or range exclusion GiST

Every index slows writes. Measure actual query patterns before over-indexing.