← PostgreSQL DBA← PostgreSQL DBA
PostgreSQL DBAPostgreSQL DBA19 Th7, 2026Jul 19, 202630 phút đọc22 min read

Extension & Hệ sinh tháiExtensions & Ecosystem

Thuộc bộ kiến thức PostgreSQL DBA Roadmap.

Tổng quan

Hầu hết các hệ quản trị cơ sở dữ liệu quan hệ chỉ cho phép bạn thêm dữ liệu và code; rất ít hệ cho phép bạn thêm hẳn những loại dữ liệu mới, toán tử mới, và thuật toán index mới mà không cần fork server hay chờ chu kỳ release của vendor. Cơ chế extension của PostgreSQL làm được chính xác điều đó, và có lẽ đây là lý do lớn nhất khiến PostgreSQL trở thành câu trả lời mặc định “cứ dùng Postgres đi” cho những workload trước đây bắt buộc phải dùng một database chuyên biệt: dữ liệu geospatial, tìm kiếm tương đồng vector, dữ liệu time-series, và bảng dữ liệu phân tán ngang (sharded) đều tồn tại dưới dạng extension chạy trên cùng một engine lõi, cùng một cú pháp SQL, cùng công cụ backup, và cùng thói quen vận hành mà một team đã xây dựng sẵn.

Một extension là một đơn vị đóng gói, có thể cài đặt, có khả năng thêm kiểu dữ liệu mới, hàm mới, toán tử mới, phương thức truy cập index mới, foreign data wrapper, và thậm chí cả background worker vào một instance PostgreSQL đang chạy — tất cả chỉ qua một câu lệnh CREATE EXTENSION duy nhất, được đánh version và nâng cấp giống như bất kỳ phần mềm đã cài đặt nào khác. Bài viết này trình bày cách bản thân cơ chế extension hoạt động, sau đó đi qua một số extension quan trọng nhất trong thực tế sản xuất: PostGIS (geospatial), pgvector (tìm kiếm tương đồng vector cho AI/embeddings), TimescaleDB (time-series), Citus (PostgreSQL phân tán), và một nhóm extension nhỏ hơn, phạm vi hẹp nhưng đáng biết là tồn tại. Sợi chỉ xuyên suốt là một quyết định mà DBA phải đưa ra lặp đi lặp lại: dùng một extension PostgreSQL đã trưởng thành và giữ một hệ thống duy nhất, hay dùng một hệ thống bên ngoài chuyên biệt và chấp nhận sự cồng kềnh vận hành để đổi lấy khả năng thực sự nằm ngoài tầm với của extension.

Kiến thức nền tảng

Cơ chế CREATE EXTENSION

Một extension không chỉ là một túi hàm SQL — nó là một đối tượng hạng nhất mà PostgreSQL theo dõi trong catalog, có tên, version đã cài, và một tập hợp đối tượng thành viên xác định (type, function, operator, index access method, cast, event trigger) được tạo, nâng cấp, và xóa cùng nhau. Cài đặt một extension thường chỉ cần một câu lệnh:

-- Cài extension vào database hiện tại
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Extension có thể được cài vào một schema cụ thể
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA extensions;

-- Xem những gì đã được cài: các đối tượng mà extension sở hữu
\dx+ pg_stat_statements

Bên dưới, một extension được xây dựng từ ba thành phần mà một gói phần mềm (RPM/deb/Homebrew formula, hoặc make install thủ công) đặt lên hệ thống file để server PostgreSQL có thể tìm thấy:

Thành phầnVai trò
Control file (extname.control)Metadata: version mặc định, mô tả, có relocatable/schema hay không, và các phụ thuộc vào extension khác
SQL script(s) (extname--1.0.sql, extname--1.0--1.1.sql, …)Các câu lệnh CREATE FUNCTION/CREATE TYPE/CREATE OPERATOR thực tế định nghĩa đối tượng của extension, cộng với các script nâng cấp gia tăng giữa các version
Shared library (extname.so / .dylib / .dll), nếu extension chứa code CCode đã biên dịch triển khai các hàm mới, phương thức truy cập index mới, hoặc background worker; được nạp vào tiến trình backend

Các file này nằm dưới SHAREDIR của PostgreSQL (tìm bằng pg_config --sharedir, thường là kiểu /usr/share/postgresql/16/extension/ trên Linux hoặc đường dẫn Homebrew Cellar trên macOS) đối với control file và SQL file, và dưới PKGLIBDIR (pg_config --pkglibdir) đối với shared library đã biên dịch. Vì CREATE EXTENSION đọc từ các thư mục phía server này, việc cài một extension trên thực tế là quy trình hai bước: một bước ở cấp hệ điều hành (copy file vào đúng chỗ, thường qua package manager, đòi hỏi quyền truy cập hệ thống file/root trên server) và một bước ở cấp SQL (CREATE EXTENSION, đòi hỏi quyền tương đương CREATEDB/superuser đối với extension được đánh dấu trusted = false, dù từ PostgreSQL 13+ nhiều extension phổ biến được đánh dấu trusted, có thể cài bởi bất kỳ user nào có quyền CREATE trên database mà không cần superuser).

Chính bản chất hai bước này là lý do các nhà cung cấp managed cloud database (Amazon RDS/Aurora, Google Cloud SQL, Azure Database for PostgreSQL, và các dịch vụ khác) chỉ hỗ trợ một danh sách cho phép (allowlist) gồm một số extension nhất định thay vì “bất cứ thứ gì trên PGXN”. Một nhà cung cấp không thể để khách hàng tùy ý đưa các shared library C chưa qua kiểm định vào máy chủ đang chạy database của các khách hàng khác — đó là phạm vi ảnh hưởng bảo mật và ổn định không thể chấp nhận trong môi trường multi-tenant. Vì vậy các nhà cung cấp cài sẵn và thẩm định một tập hợp được tuyển chọn (pg_stat_statements, postgis, pgvector, pg_cron, pg_partman, và những extension tương tự thường nằm trong danh sách; các extension hiếm gặp hơn hoặc động chạm đến hệ thống file thường thì không). Luôn kiểm tra trang danh sách extension được hỗ trợ của nhà cung cấp trước khi thiết kế kiến trúc dựa trên một extension cụ thể, và coi “dịch vụ managed mục tiêu có hỗ trợ extension này không” là một ràng buộc hạng nhất khi lựa chọn extension — không phải một việc nghĩ đến sau cùng khi đã triển khai.

Đánh version và nâng cấp

Các đối tượng SQL của một extension được đánh version độc lập với bản thân server PostgreSQL — PostGIS 3.4 và PostGIS 3.5 đều có thể chạy trên PostgreSQL 16 chẳng hạn. Khi một version mới hơn của file extension được cài trên server nhưng database vẫn đang có đối tượng của version cũ, ALTER EXTENSION ... UPDATE sẽ đi qua chuỗi script nâng cấp gia tăng để đưa đối tượng của database lên version mới nhất có sẵn:

-- Kiểm tra version đang cài đặt so với version có sẵn trên server
SELECT extname, extversion FROM pg_extension WHERE extname = 'postgis';
SELECT * FROM pg_available_extension_versions WHERE name = 'postgis';

-- Nâng cấp lên version mới nhất có sẵn trên server
ALTER EXTENSION postgis UPDATE;

-- Hoặc nâng cấp lên một version cụ thể
ALTER EXTENSION postgis UPDATE TO '3.4.2';

Lưu ý bản chất hai lớp của một lần nâng cấp: ALTER EXTENSION ... UPDATE chỉ chạy lại các script nâng cấp cấp SQL đối với các đối tượng đã có sẵn trên đĩa của server — nó không tự tải một bản release extension mới hơn từ internet. Để thực sự có được một version mới hơn cần bước cấp hệ điều hành trước (nâng cấp package cung cấp file của extension), sau đó bước cấp SQL (ALTER EXTENSION ... UPDATE) để đưa từng database lên cập nhật. Sự tách biệt này quan trọng về mặt vận hành: sau khi nâng cấp package ở cấp OS, mọi database đã cài extension vẫn chạy version cấp SQL cho đến khi ai đó chạy ALTER EXTENSION ... UPDATE trong database đó — một nguồn gây nhầm lẫn phổ biến kiểu “tôi đã nâng cấp mà chẳng thấy gì thay đổi”, đặc biệt với PostGIS và TimescaleDB, nơi các lần nâng cấp version lớn có thể còn đòi hỏi bước di chuyển dữ liệu được ghi trong release notes riêng của chúng.

Khám phá và đánh giá extension

Ngoài những gì có sẵn trong các module contrib của chính PostgreSQL (pg_stat_statements, pgcrypto, uuid-ossp, hstore, pg_trgm, và nhiều module khác — được cài qua package postgresql-contrib trên hầu hết các bản phân phối), hệ sinh thái rộng hơn được liệt kê tại PGXN, PostgreSQL Extension Network — một registry công cộng tương tự PyPI hay npm, nơi các tác giả cộng đồng đăng tải extension kèm metadata, tài liệu, và lịch sử version. pgxn client có thể tìm kiếm, tải về, và build các extension được liệt kê trên PGXN, dù nhiều team trong thực tế sản xuất cài extension qua package manager của hệ điều hành hoặc danh sách cho phép của nhà cung cấp cloud thay vì build trực tiếp từ mã nguồn PGXN.

Trước khi phụ thuộc vào bất kỳ extension không thuộc core nào trong sản xuất, hãy kiểm tra thực tế những gì có sẵn trên server và đánh giá một cách có chủ đích thay vì mặc định nó tồn tại hoặc an toàn để dùng:

-- Liệt kê mọi extension mà server có file (có thể cài được)
SELECT * FROM pg_available_extensions ORDER BY name;

-- Liệt kê những gì thực sự đã được cài trong database hiện tại
SELECT extname, extversion, extrelocatable FROM pg_extension;

-- Kiểm tra các version có sẵn của một extension cụ thể và version mặc định
SELECT * FROM pg_available_extension_versions WHERE name = 'vector';

Mức độ trưởng thành và tình trạng bảo trì quan trọng với extension hơn là với thư viện ứng dụng thông thường, bởi vì một extension chạy bên trong tiến trình backend của PostgreSQL với quyền truy cập cấp C — một extension có lỗi hoặc bị bỏ rơi có thể làm sập server, làm hỏng dữ liệu, hoặc chặn đứng một lần nâng cấp version lớn trong tương lai nếu nó không được giữ tương thích. Trước khi cam kết dùng một extension cộng đồng, hãy kiểm tra: nó có đang được bảo trì tích cực không (commit gần đây, các bản release theo kịp các version PostgreSQL hiện tại)? Nhà cung cấp cloud của bạn (nếu có) có hỗ trợ nó không, và nó có được hỗ trợ sau lần nâng cấp version lớn PostgreSQL tiếp theo không? Nó có cộng đồng người dùng và issue tracker thực sự, hay chỉ là một proof-of-concept của một người bảo trì duy nhất? Các extension được trình bày dưới đây (PostGIS, pgvector, TimescaleDB, Citus, pg_partman, pg_cron) đều vượt qua tiêu chuẩn này một cách thoải mái — chúng được triển khai rộng rãi, bảo trì tích cực, và được hầu hết các nhà cung cấp managed PostgreSQL lớn hỗ trợ — đó chính xác là lý do chúng đáng để học kỹ thay vì hàng ngàn mục nhỏ hẹp hơn trên PGXN.

Khái niệm chính

pg_stat_statements — extension nhàm chán nhưng thiết yếu

pg_stat_statements đáng được nhắc đến ở đây chủ yếu như ví dụ điển hình của một extension nền tảng đến mức dễ quên rằng nó một extension: nó có sẵn trong các module contrib của PostgreSQL, chỉ cần cấu hình shared_preload_libraries cộng với CREATE EXTENSION pg_stat_statements, và không thêm gì vào bề mặt SQL ngoài một view duy nhất — vậy mà nó là công cụ hữu ích nhất để tìm ra điều gì thực sự đang chậm trong một database đang chạy. Nó được trình bày chi tiết trong ./10-query-planning-and-performance-tuning.md; điều cần ghi nhớ ở đây mang tính kiến trúc: cùng một cơ chế cho phép một extension nhỏ lẻ thêm một kiểu geometry cũng chính là cơ chế cho phép extension trụ cột này thêm các view theo dõi truy vấn, và cả hai đều được cài đặt, đánh version, và nâng cấp theo cùng một cách.

PostGIS — dữ liệu geospatial làm đúng bài bản

PostGIS thêm các kiểu dữ liệu không gian (geometry cho tọa độ phẳng/Cartesian, geography cho tọa độ trên bề mặt ellipsoid của Trái Đất), các toán tử và hàm không gian (khoảng cách, chứa, giao nhau, tạo vùng đệm, chuyển đổi giữa các hệ tọa độ tham chiếu), và hỗ trợ đánh index không gian — tất cả được xây dựng trên cùng phương thức truy cập index GiST đã được trình bày trong ./08-indexing-strategies.md. Đây không phải là “hỗ trợ không gian tốt cho một database quan hệ” như một lời khen có điều kiện; PostGIS thường xuyên được đánh giá là một trong những hệ thống GIS mã nguồn mở đầy đủ nhất, tuân thủ chuẩn nhất (OGC Simple Features, SQL/MM) tồn tại dưới bất kỳ hình thức nào, chấm hết — nó vận hành mọi thứ từ pipeline render tile của OpenStreetMap đến các hệ thống GIS cấp thành phố mà nếu không có nó sẽ phải chạy trên phần mềm độc quyền tốn kém giấy phép thực sự.

CREATE EXTENSION IF NOT EXISTS postgis;

CREATE TABLE stores (
    id serial PRIMARY KEY,
    name text NOT NULL,
    location geography(Point, 4326) NOT NULL  -- SRID 4326 = WGS84 lat/long
);

INSERT INTO stores (name, location) VALUES
    ('Downtown', ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography),
    ('Airport',  ST_SetSRID(ST_MakePoint(-122.3790, 37.6213), 4326)::geography);

-- Index không gian GiST — cùng phương thức truy cập dùng cho range/exclusion constraint
CREATE INDEX idx_stores_location ON stores USING gist (location);

-- Tìm các cửa hàng trong bán kính 5 km từ một điểm, gần nhất trước
SELECT name, ST_Distance(location, ST_SetSRID(ST_MakePoint(-122.42, 37.77), 4326)::geography) AS meters
FROM stores
WHERE ST_DWithin(location, ST_SetSRID(ST_MakePoint(-122.42, 37.77), 4326)::geography, 5000)
ORDER BY location <-> ST_SetSRID(ST_MakePoint(-122.42, 37.77), 4326)::geography;

-- Kiểm tra chứa đa giác: một điểm có nằm trong vùng giao hàng không?
SELECT z.name
FROM delivery_zones z
WHERE ST_Contains(z.boundary, ST_SetSRID(ST_MakePoint(-122.41, 37.78), 4326));

ST_DWithin và toán tử khoảng cách k-NN <-> đều dựa vào index GiST thay vì tính khoảng cách cho từng dòng, đây chính là điều giúp các truy vấn “N cửa hàng gần nhất” và “mọi thứ trong bán kính 5km” nhanh ở quy mô lớn thay vì phải quét tuần tự kèm lọc khoảng cách. Hãy dùng PostGIS ngay khi ứng dụng cần suy luận không gian địa lý thực sự — định tuyến, geofencing, tính diện tích, sinh tile bản đồ — thay vì xấp xỉ bằng phép toán bounding-box trên các cột latitude/longitude thuần túy, vốn sẽ sai ở gần các cực, khi vượt qua đường đổi ngày quốc tế, và với bất kỳ truy vấn nào phức tạp hơn “gần đúng ở gần đây”.

pgvector — tìm kiếm tương đồng cho kỷ nguyên embeddings

pgvector thêm kiểu dữ liệu vector và các toán tử khoảng cách để tìm kiếm tương đồng — đúng thứ cần thiết để lưu trữ và truy vấn các vector embedding mà các ứng dụng AI/LLM hiện đại sinh ra từ văn bản, hình ảnh, hoặc âm thanh. Trước khi pgvector ra đời, làm việc này đồng nghĩa với việc phải dựng một vector database chuyên biệt (Pinecone, Weaviate, Qdrant, Milvus) chỉ để chứa embedding, đồng bộ nó với data store chính, và chấp nhận chi phí vận hành của một hệ thống thứ hai với câu chuyện backup, kiểm soát truy cập, và các kiểu lỗi riêng của nó. pgvector gộp tất cả lại: embedding sống trong một bảng bình thường, ngay cạnh dòng dữ liệu mà nó mô tả, được truy vấn bằng SQL bình thường, được backup bởi cùng cơ chế pg_dump/WAL vốn đã có sẵn — một sự đơn giản hóa đáng kể cho trường hợp rất phổ biến “chúng tôi đã có sẵn database Postgres và muốn tìm kiếm ngữ nghĩa hoặc truy xuất RAG trên dữ liệu đã lưu ở đó”. Xem ../backend/en/20-ai-for-backend.md để hiểu bức tranh embeddings/RAG rộng hơn mà extension này gắn vào.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE document_chunks (
    id bigserial PRIMARY KEY,
    document_id bigint NOT NULL REFERENCES documents(id),
    content text NOT NULL,
    embedding vector(1536)  -- số chiều phải khớp với mô hình embedding, vd. OpenAI text-embedding-3-small
);

-- Index approximate nearest-neighbor; HNSW thường được ưu tiên hơn IVFFlat kể từ pgvector 0.5+
CREATE INDEX idx_chunks_embedding_hnsw
    ON document_chunks USING hnsw (embedding vector_cosine_ops);

-- Tìm 5 đoạn văn bản có ngữ nghĩa gần nhất với một embedding truy vấn (khoảng cách cosine)
SELECT id, content, embedding <=> '[0.014, -0.032, ...]'::vector AS distance
FROM document_chunks
ORDER BY embedding <=> '[0.014, -0.032, ...]'::vector
LIMIT 5;

pgvector hỗ trợ ba toán tử khoảng cách — <-> (Euclidean/L2), <=> (khoảng cách cosine), và <#> (tích vô hướng âm) — và operator class dùng trên index (vector_l2_ops, vector_cosine_ops, vector_ip_ops) phải khớp với toán tử khoảng cách dùng trong truy vấn thì index mới được sử dụng; nếu không khớp, truy vấn sẽ âm thầm rơi về quét tuần tự, đây là lỗi hiệu năng phổ biến nhất khi dùng pgvector. Loại index HNSW đánh đổi thời gian build lâu hơn để lấy recall/độ trễ truy vấn tốt hơn so với IVFFlat, và là lựa chọn mặc định hợp lý kể từ pgvector 0.5. pgvector thực sự tốt cho tìm kiếm ngữ nghĩa ở quy mô nhỏ đến vừa — hàng chục triệu vector một cách thoải mái — và sức hút “không cần hệ thống mới” của nó là có thật; các team vận hành ở quy mô hàng tỷ vector với SLA độ trễ cực kỳ chặt, hoặc cần các tính năng nâng cao như phân tách namespace vector đa tenant ở tầng vector-DB, mới là những người vẫn cần một vector database chuyên biệt. Với hầu hết các ứng dụng muốn thêm RAG hoặc tìm kiếm ngữ nghĩa vào một sản phẩm sẵn có, pgvector là điểm khởi đầu hợp lý, và thường cũng là điểm kết thúc.

TimescaleDB — chuyên biệt cho time-series

TimescaleDB biến một bảng thành hypertable: một bảng tự động, phân vùng theo thời gian một cách trong suốt (và tùy chọn theo thêm một khóa khác như device_id) thành các “chunk”, với toàn bộ công việc quản lý phân vùng — tạo chunk mới khi dữ liệu đến, chọn ranh giới chunk, đánh index từng chunk — được extension đảm nhiệm thay cho DBA. Điều này mở rộng trực tiếp quy trình phân vùng range thủ công đã trình bày trong ./12-partitioning-and-sharding.md: trong khi declarative partitioning thuần túy yêu cầu bạn chạy (hoặc tự động hóa qua pg_partman) CREATE TABLE ... PARTITION OF cho từng khoảng thời gian tương lai và nhớ xóa các phân vùng cũ, một hypertable tự động tạo và loại bỏ chunk dựa trên các chính sách bạn khai báo một lần.

CREATE EXTENSION IF NOT EXISTS timescaledb;

CREATE TABLE sensor_readings (
    time        timestamptz NOT NULL,
    device_id   int NOT NULL,
    temperature double precision,
    humidity    double precision
);

-- Chuyển thành hypertable, chia chunk theo thời gian (khoảng chunk mặc định: 7 ngày)
SELECT create_hypertable('sensor_readings', 'time');

-- Continuous aggregate: một bản rollup được vật chất hóa, làm mới gia tăng
CREATE MATERIALIZED VIEW sensor_hourly
WITH (timescaledb.continuous) AS
SELECT device_id,
       time_bucket('1 hour', time) AS bucket,
       avg(temperature) AS avg_temp,
       max(temperature) AS max_temp
FROM sensor_readings
GROUP BY device_id, bucket;

-- Chỉ giữ lại 90 ngày dữ liệu thô; chunk cũ bị bỏ nguyên khối, không phải DELETE từng dòng
SELECT add_retention_policy('sensor_readings', INTERVAL '90 days');

-- Nén các chunk cũ hơn 7 ngày (nén dạng cột, thường đạt 10-20 lần trên dữ liệu time-series)
ALTER TABLE sensor_readings SET (timescaledb.compress, timescaledb.compress_segmentby = 'device_id');
SELECT add_compression_policy('sensor_readings', INTERVAL '7 days');

Continuous aggregate là tính năng nổi bật nhất ngoài việc tự động phân vùng: một materialized view thông thường phải được tính lại toàn bộ bằng REFRESH MATERIALIZED VIEW, việc này ngày càng tốn kém khi bảng gốc lớn lên, trong khi một continuous aggregate chỉ cập nhật gia tăng các bucket bị ảnh hưởng bởi dữ liệu mới hoặc thay đổi. Kết hợp với nén dạng cột gốc được tối ưu cho hình dạng dữ liệu time-series (device ID lặp lại, timestamp tăng đơn điệu, giá trị cảm biến thay đổi chậm), TimescaleDB thường xuyên đạt được cả hiệu năng truy vấn tốt hơn lẫn dung lượng lưu trữ nhỏ hơn đáng kể so với một bảng PostgreSQL thuần túy được phân vùng thủ công trên cùng dữ liệu. Hãy dùng TimescaleDB cụ thể khi dữ liệu thực sự có hình dạng time-series — số liệu, telemetry IoT, tick tài chính, log sự kiện nơi “cho tôi một tổng hợp trên một khoảng thời gian” là mẫu truy vấn chủ đạo — thay vì cho bất kỳ bảng nào chỉ tình cờ có một cột timestamp; một bảng users với cột created_at không phải là workload time-series chỉ vì nó có trường datetime.

Citus — PostgreSQL phân tán

Citus đã được giới thiệu trong ./12-partitioning-and-sharding.md như thứ gần nhất mà PostgreSQL có với sharding hạng nhất; đáng để mở rộng thêm ở đây với tư cách một extension hệ sinh thái riêng biệt. Citus biến một coordinator node cộng với một tập hợp worker node thành một database PostgreSQL logic duy nhất: bạn chỉ định một bảng là distributed bằng cách chọn một cột phân phối (distribution column, thường là tenant_id hoặc một khóa cardinality cao tương tự), và Citus tự động chia các dòng của nó ra các shard trên worker, định tuyến các truy vấn lọc theo cột đó đến đúng shard liên quan, và song song hóa các truy vấn cần chạm đến mọi shard. Các bảng nhỏ hơn, ít thay đổi mà mọi truy vấn đều join vào (một bảng tra cứu countries hoặc plans) được khai báo là reference table thay vì vậy — được sao chép toàn bộ lên mọi worker để các phép join với chúng không bao giờ cần di chuyển dữ liệu giữa các node.

CREATE EXTENSION IF NOT EXISTS citus;

-- Trên coordinator: chỉ định cột phân phối
SELECT create_distributed_table('events', 'tenant_id');

-- Một bảng nhỏ, ít thay đổi được join ở khắp nơi: nhân bản nó lên mọi worker
SELECT create_reference_table('plans');

-- Từ góc nhìn của ứng dụng, đây là SQL thông thường —
-- Citus định tuyến nó đến (các) shard chứa tenant_id = 42
SELECT count(*) FROM events WHERE tenant_id = 42 AND event_type = 'signup';

-- Một phép tổng hợp xuyên tenant được rải ra mọi worker và kết hợp kết quả trên coordinator
SELECT tenant_id, count(*) FROM events GROUP BY tenant_id ORDER BY count(*) DESC LIMIT 10;

Giá trị cốt lõi ở đây là ứng dụng vẫn viết SQL PostgreSQL tiêu chuẩn — không cần ngôn ngữ truy vấn riêng, không cần định tuyến shard thủ công trong code ứng dụng — trong khi Citus tiếp nhận độ phức tạp của việc quyết định (các) worker nào một truy vấn cần chạm đến và ghép kết quả lại. Điều này khiến Citus trở thành bước tiếp theo tự nhiên khi năng lực của một instance duy nhất (chứ không phải số dòng của một bảng nào đó) mới là nút thắt cổ chai thực sự: một sản phẩm SaaS đa tenant bận rộn nơi dữ liệu của những tenant lớn nhất không còn vừa thoải mái trong ngân sách bộ nhớ/IO của một máy là ví dụ điển hình cho Citus, so với một bảng cực lớn nhưng không bị nghẽn I/O, vốn được phục vụ tốt hơn bởi phân vùng thuần túy trong một instance.

Full-text search: tsvector/tsquery là tính năng cốt lõi, không phải extension

Tìm kiếm văn bản toàn văn của PostgreSQL — tsvector (một biểu diễn tài liệu đã được tiền xử lý, chuẩn hóa với stemming và loại bỏ stop-word) và tsquery (một biểu thức tìm kiếm đã được phân tích), được khớp qua toán tử @@ và tăng tốc bởi index GIN đúng như đã trình bày trong ./08-indexing-strategies.md — đáng được nhắc đến ở đây cụ thể vì nó không phải một extension riêng cần cài đặt; nó được tích hợp sẵn vào PostgreSQL core và có sẵn ngay khi server khởi động. Điều đó khiến nó trở thành lựa chọn ít ma sát nhất cho “cho phép người dùng tìm kiếm trong cột văn bản này” ở quy mô vừa phải: không cần hệ thống thứ hai, không cần pipeline đồng bộ dữ liệu, không cần thêm hạ tầng để vận hành, và nhất quán giao dịch đầy đủ với phần còn lại của dòng dữ liệu (một index tìm kiếm không bao giờ lệch pha với dữ liệu vì nó sống trong cùng một commit).

ALTER TABLE articles ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
        setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
        setweight(to_tsvector('english', coalesce(body, '')), 'B')
    ) STORED;

CREATE INDEX idx_articles_search ON articles USING gin (search_vector);

SELECT title, ts_rank(search_vector, query) AS rank
FROM articles, to_tsquery('english', 'postgres & (index | performance)') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 10;

Tuy nhiên nó cũng có giới hạn thực sự: không có khả năng chịu lỗi chính tả/khớp mờ tích hợp sẵn (ngoài việc kết hợp với extension contrib pg_trgm riêng cho độ tương đồng trigram), không có index phân tán, mở rộng ngang qua nhiều node theo cách Elasticsearch chia shard, khả năng điều chỉnh độ liên quan (relevance) và faceting yếu hơn so với DSL truy vấn của một search engine chuyên biệt, và các tính năng vận hành đặc thù cho tìm kiếm (quản lý từ đồng nghĩa, learning-to-rank, highlight ở quy mô lớn) mà một engine chuyên biệt đã đầu tư nhiều năm để phát triển. Ranh giới thực tế: full-text search tích hợp sẵn phục vụ tốt cho “tìm kiếm trong dữ liệu riêng của ứng dụng này, lên đến vài triệu tài liệu, không cần điều chỉnh độ liên quan ở cấp search engine” — một trang tài liệu, một ô tìm kiếm ticket hỗ trợ, tìm kiếm admin nội bộ. Hãy dùng Elasticsearch/OpenSearch thay vào đó khi bạn cần tìm kiếm faceted trên nhiều trường, cần một index tìm kiếm hoàn toàn tách biệt khỏi tải của database chính, cần độ trễ tìm kiếm dưới 100ms ở số lượng tài liệu rất lớn, hoặc cần các tính năng liên quan (hàm scoring tùy chỉnh, learning-to-rank, boosting đa trường) vượt ngoài những gì ts_rank cung cấp.

Các extension đáng chú ý khác nên biết là tồn tại

Một số extension nhỏ hơn, mục đích đơn lẻ xuất hiện đủ thường xuyên trong PostgreSQL sản xuất đến mức một DBA thực thụ nên nhận biết được chúng dù không dùng tất cả hằng ngày:

Bảng đối chiếu: extension so với hệ thống bên ngoài chuyên biệt

ExtensionVấn đề nó giải quyếtDùng extension thay vì hệ thống chuyên biệt khiCân nhắc hệ thống chuyên biệt khi
PostGISDữ liệu và truy vấn không gian/địa lýBạn cần suy luận geospatial cùng với dữ liệu quan hệ, ở bất kỳ quy mô nào lên đến workload GIS rất lớnHầu như không bao giờ — PostGIS cạnh tranh sòng phẳng với các hệ GIS chuyên biệt ngay cả ở quy mô lớn
pgvectorTìm kiếm tương đồng vector cho embeddingBạn đã dùng Postgres và cần tìm kiếm ngữ nghĩa/RAG trên vài chục triệu vector trở xuốngBạn cần hàng tỷ vector, phân tách namespace vector đa tenant, hoặc ANN độ trễ cực thấp ở quy mô cực lớn
TimescaleDBLưu trữ time-series, rollup, nénDữ liệu thực sự có hình dạng time-series (số liệu, IoT, tick) và bạn muốn tự động phân vùng + continuous aggregateBạn cần ngôn ngữ truy vấn/dashboard time-series chuyên biệt ở cardinality cực cao (vd. hạ tầng metrics quy mô Prometheus/InfluxDB rất lớn)
CitusMở rộng ngang một database logic duy nhấtCPU/bộ nhớ/IO của một instance là nút thắt cổ chai, và workload có một khóa sharding tự nhiên (vd. tenant_id)Workload cần transaction/join xuyên shard tùy ý ở quy mô mà mô hình của Citus không định tuyến hiệu quả
tsvector/tsquery (core)Full-text search trên dữ liệu đã có trong PostgresSố lượng tài liệu vừa phải, không cần điều chỉnh độ liên quan nâng cao hay hạ tầng tìm kiếm chuyên biệtTìm kiếm faceted, số lượng tài liệu rất lớn, learning-to-rank, hoặc tải tìm kiếm cần tách biệt khỏi database chính
pg_partmanTự động hóa vòng đời phân vùngBất kỳ bảng đã phân vùng nào cần tạo phân vùng tương lai và loại bỏ phân vùng cũ theo lịchHiếm khi — đây thuần túy là mối quan tâm trong database
pg_cronLên lịch job trong databaseCác tác vụ bảo trì SQL theo lịch gắn chặt với bản thân databaseĐiều phối job phức tạp, workflow xuyên hệ thống, retry/phụ thuộc (hãy dùng một scheduler/orchestrator bên ngoài)

Best Practices

Hãy coi mọi extension không thuộc core như một dependency có chu kỳ release, bề mặt bảo mật, và lộ trình nâng cấp riêng — không phải một tính năng miễn phí xuất hiện sẵn trong PostgreSQL core. Pin và theo dõi version extension giống như cách pin dependency ứng dụng, kiểm thử ALTER EXTENSION ... UPDATE trên môi trường staging trước khi chạy trong sản xuất (script nâng cấp đôi khi viết lại index hoặc yêu cầu REINDEX, đặc biệt với PostGIS và pgvector sau các thay đổi định dạng index), và đọc release notes của extension trước khi nâng cấp version lớn PostgreSQL — sự không tương thích giữa version PostgreSQL và phạm vi hỗ trợ của một extension là nguồn gây tắc nghẽn nâng cấp phổ biến và có thể tránh được.

Trước khi áp dụng một extension cho một khả năng mới, hãy kiểm tra xem môi trường mục tiêu có thực sự hỗ trợ nó không: danh sách cho phép của nhà cung cấp cloud, quy trình phê duyệt package của một team on-prem, hoặc một yêu cầu tuân thủ đều có thể loại bỏ một extension vốn hoàn hảo, và phát hiện điều đó sau khi đã đưa ra quyết định kiến trúc thì rất tốn kém. Truy vấn pg_available_extensions trên server mục tiêu thực tế (không phải máy dev cục bộ, nơi có thể có bộ extension khác được cài) như một bước đầu tiên, không phải một việc nghĩ đến sau cùng.

Kháng cự việc cài extension “phòng khi cần” — mỗi extension đã cài là bề mặt tấn công, là thứ cần giữ cập nhật, và với extension viết bằng C cụ thể, là thứ chạy trong cùng tiến trình với server database và có thể ảnh hưởng đến sự ổn định của nó. Hãy khớp extension với một nhu cầu thực tế, hiện hữu (truy vấn geospatial trong code hôm nay, không phải “có thể sau này chúng ta sẽ làm bản đồ”), và ưu tiên các tính năng core của PostgreSQL (JSONB thay vì hstore, gen_random_uuid() tích hợp sẵn thay vì uuid-ossp khi đủ dùng, tsvector thay vì một extension tìm kiếm) khi chúng đã đáp ứng đủ nhu cầu, chỉ dành các extension chuyên biệt cho khả năng cụ thể mà chỉ chúng mới cung cấp được.

Cuối cùng, hãy nhớ rằng extension kết hợp với mọi thứ khác đã trình bày trong bộ kiến thức này chứ không thay thế chúng: một cột geography của PostGIS vẫn hưởng lợi từ kỷ luật đánh index trong ./08-indexing-strategies.md, một hypertable của TimescaleDB vẫn cần tư duy chiến lược phân vùng trong ./12-partitioning-and-sharding.md áp dụng vào việc chọn khoảng chunk, và một bảng phân tán bởi Citus vẫn cần kỷ luật transaction và khóa từ ./09-transactions-and-concurrency-control.md — chỉ là áp dụng trên cả cụm thay vì một instance. Extension mở rộng những gì PostgreSQL có thể làm; chúng không tạm ngưng các nguyên tắc nền tảng của việc vận hành nó tốt.

Tài liệu tham khảo

Part of the PostgreSQL DBA Roadmap knowledge base.

Overview

Most relational databases let you add data and code; very few let you add fundamentally new kinds of data, new operators, and new index algorithms without forking the server or waiting for a vendor release cycle. PostgreSQL’s extension system does exactly that, and it is arguably the single biggest reason PostgreSQL has become the default “just use Postgres” answer for workloads that used to require a specialized database: geospatial data, vector similarity search, time-series metrics, and horizontally-sharded distributed tables all live as extensions on top of the same core engine, the same SQL dialect, the same backup tooling, and the same operational muscle memory a team has already built up.

An extension is a packaged, installable unit that can add new data types, functions, operators, index access methods, foreign data wrappers, and even background workers to a running PostgreSQL instance — all through a single CREATE EXTENSION statement, versioned and upgradeable like any other piece of installed software. This note covers how the extension mechanism itself works, then walks through the handful of extensions that matter most in production: PostGIS (geospatial), pgvector (vector similarity search for AI/embeddings), TimescaleDB (time-series), Citus (distributed PostgreSQL), and a supporting cast of smaller, narrowly-scoped extensions worth knowing exist. The throughline is a recurring decision a DBA has to make: reach for a mature PostgreSQL extension and keep one system, or reach for a dedicated external system and accept operational sprawl in exchange for capability that’s genuinely out of an extension’s reach.

Fundamentals

The CREATE EXTENSION mechanism

An extension is not just a bag of SQL functions — it’s a first-class object PostgreSQL tracks in its catalog, with a name, an installed version, and a defined set of member objects (types, functions, operators, index access methods, casts, event triggers) that all get created, upgraded, and dropped together. Installing one is normally a single statement:

-- Install an extension into the current database
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Extensions can be installed into a specific schema
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA extensions;

-- See what got installed: the objects an extension owns
\dx+ pg_stat_statements

Under the hood, an extension is built from three pieces that a package (an RPM/deb/Homebrew formula, or a manual make install) drops onto the filesystem where the PostgreSQL server can find them:

PiecePurpose
Control file (extname.control)Metadata: default version, comment, whether it requires relocatable/schema, and dependencies on other extensions
SQL script(s) (extname--1.0.sql, extname--1.0--1.1.sql, …)The actual CREATE FUNCTION/CREATE TYPE/CREATE OPERATOR statements that define the extension’s objects, plus incremental upgrade scripts between versions
Shared library (extname.so / .dylib / .dll), if the extension includes C codeCompiled code implementing new functions, index access methods, or background workers; loaded into the backend process

These files live under PostgreSQL’s SHAREDIR (find it with pg_config --sharedir, typically something like /usr/share/postgresql/16/extension/ on Linux or the Homebrew Cellar path on macOS) for the control and SQL files, and under PKGLIBDIR (pg_config --pkglibdir) for compiled shared libraries. Because CREATE EXTENSION reads from these server-side directories, installing an extension is a two-step process in practice: an operating-system-level step (copying files into place, usually via a package manager, which requires filesystem/root access on the server) and a SQL-level step (CREATE EXTENSION, which requires the CREATEDB/superuser-ish privilege for extensions marked trusted = false, though PostgreSQL 13+ allows many common extensions to be marked trusted, installable by any user with CREATE on the database without superuser).

This two-step nature is exactly why managed cloud database providers (Amazon RDS/Aurora, Google Cloud SQL, Azure Database for PostgreSQL, and others) only support an allowlist of extensions rather than “anything on PGXN.” A provider cannot let arbitrary customers drop untrusted C shared libraries onto the host running other customers’ databases — that’s an unacceptable security and stability blast radius in a multi-tenant environment. So providers pre-install and vet a curated set (pg_stat_statements, postgis, pgvector, pg_cron, pg_partman, and similar are common inclusions; more obscure or filesystem-touching extensions typically are not), and CREATE EXTENSION on their platforms only succeeds for names on that list. Always check a provider’s supported-extensions page before architecting around a specific extension, and treat “does the target managed service support this extension” as a first-class constraint when choosing one — not an afterthought discovered during deployment.

Versioning and upgrades

An extension’s SQL objects are versioned independently of the PostgreSQL server itself — PostGIS 3.4 and PostGIS 3.5 can both run on PostgreSQL 16, for instance. When a newer version of an extension’s files is installed on the server but the database still has the old version’s objects created, ALTER EXTENSION ... UPDATE walks the chain of incremental upgrade scripts to bring the database’s objects up to the newest available version:

-- Check what's currently installed vs. what's available on the server
SELECT extname, extversion FROM pg_extension WHERE extname = 'postgis';
SELECT * FROM pg_available_extension_versions WHERE name = 'postgis';

-- Upgrade to the latest version available on the server
ALTER EXTENSION postgis UPDATE;

-- Or upgrade to a specific version
ALTER EXTENSION postgis UPDATE TO '3.4.2';

Note the two-layer nature of an upgrade: ALTER EXTENSION ... UPDATE only re-runs SQL-level upgrade scripts against objects already on the server’s disk — it does not fetch a newer extension release from the internet. Getting a genuinely newer version available requires the OS-level step first (upgrading the package that ships the extension’s files), then the SQL-level step (ALTER EXTENSION ... UPDATE) to bring each database up to date. This split matters operationally: after an OS package upgrade, every database that has the extension installed still runs the old SQL-level version until someone runs ALTER EXTENSION ... UPDATE in it — a common source of “I upgraded but nothing changed” confusion, especially with PostGIS and TimescaleDB where major version upgrades can also require data migration steps documented in their own release notes.

Discovering and evaluating extensions

Beyond what ships with PostgreSQL’s own contrib modules (pg_stat_statements, pgcrypto, uuid-ossp, hstore, pg_trgm, and others — installed via the postgresql-contrib package on most distributions), the wider ecosystem is catalogued at PGXN, the PostgreSQL Extension Network — a public registry analogous to PyPI or npm, where community authors publish extensions with metadata, documentation, and version history. pgxn client can search, download, and build PGXN-listed extensions, though many production teams instead install extensions through their OS package manager or their cloud provider’s allowlist rather than building from PGXN source directly.

Before depending on any non-core extension in production, check what’s actually available on the server and evaluate it deliberately rather than assuming it exists or is safe to adopt:

-- List every extension the server has files for (installable)
SELECT * FROM pg_available_extensions ORDER BY name;

-- List what's actually installed in the current database
SELECT extname, extversion, extrelocatable FROM pg_extension;

-- Check a specific extension's available versions and default
SELECT * FROM pg_available_extension_versions WHERE name = 'vector';

Maturity and maintenance status matter more for extensions than for application libraries, because an extension runs inside the PostgreSQL backend process with C-level access — a buggy or abandoned extension can crash the server, corrupt data, or block a future major-version upgrade if it isn’t kept compatible. Before committing to a community extension, check: is it actively maintained (recent commits, releases tracking current PostgreSQL major versions)? Does your cloud provider (if you use one) support it, and will it be supported after your next PostgreSQL major version upgrade? Does it have a real user base and issue tracker, or is it a single-maintainer proof of concept? The extensions covered below (PostGIS, pgvector, TimescaleDB, Citus, pg_partman, pg_cron) all pass this bar comfortably — they are widely deployed, actively maintained, and supported by most major managed PostgreSQL providers — which is exactly why they’re the ones worth learning well rather than the thousands of narrower entries on PGXN.

Key Concepts

pg_stat_statements — the boring, essential one

pg_stat_statements deserves a mention here mainly as the canonical example of an extension so foundational that it’s easy to forget it is one: it ships in PostgreSQL’s contrib modules, requires only shared_preload_libraries configuration plus CREATE EXTENSION pg_stat_statements, and adds nothing to the SQL surface except a single view — yet it is the single most useful tool for finding what’s actually slow in a running database. It’s covered in depth in ./10-query-planning-and-performance-tuning.md; the point to internalize here is architectural: the same mechanism that lets a niche extension add a geometry type is what lets this workhorse extension add query-tracking views, and both are installed, versioned, and upgraded identically.

PostGIS — geospatial data done properly

PostGIS adds spatial data types (geometry for planar/Cartesian coordinates, geography for coordinates on the Earth’s ellipsoid), spatial operators and functions (distance, containment, intersection, buffering, transformation between coordinate reference systems), and spatial indexing support — all built on the same GiST index access method already covered in ./08-indexing-strategies.md. It is not “good spatial support for a relational database” as a qualified compliment; PostGIS is routinely rated among the most complete, standards-compliant (OGC Simple Features, SQL/MM) open-source GIS systems that exist in any form, full stop — it powers everything from OpenStreetMap’s tile rendering pipeline to municipal GIS systems that would otherwise run on proprietary software costing real licensing budgets.

CREATE EXTENSION IF NOT EXISTS postgis;

CREATE TABLE stores (
    id serial PRIMARY KEY,
    name text NOT NULL,
    location geography(Point, 4326) NOT NULL  -- SRID 4326 = WGS84 lat/long
);

INSERT INTO stores (name, location) VALUES
    ('Downtown', ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography),
    ('Airport',  ST_SetSRID(ST_MakePoint(-122.3790, 37.6213), 4326)::geography);

-- GiST spatial index — same access method used for range/exclusion constraints
CREATE INDEX idx_stores_location ON stores USING gist (location);

-- Find stores within 5 km of a point, nearest first
SELECT name, ST_Distance(location, ST_SetSRID(ST_MakePoint(-122.42, 37.77), 4326)::geography) AS meters
FROM stores
WHERE ST_DWithin(location, ST_SetSRID(ST_MakePoint(-122.42, 37.77), 4326)::geography, 5000)
ORDER BY location <-> ST_SetSRID(ST_MakePoint(-122.42, 37.77), 4326)::geography;

-- Polygon containment: is a point inside a delivery zone?
SELECT z.name
FROM delivery_zones z
WHERE ST_Contains(z.boundary, ST_SetSRID(ST_MakePoint(-122.41, 37.78), 4326));

ST_DWithin and the <-> k-NN distance operator both lean on the GiST index rather than computing distance for every row, which is what makes “nearest N stores” and “everything within 5km” queries fast at scale rather than a sequential scan with a distance filter. Reach for PostGIS the moment an application needs real geospatial reasoning — routing, geofencing, area calculations, map-tile generation — rather than approximating it with bounding-box math on plain latitude/longitude columns, which breaks down at the poles, across the antimeridian, and for any query beyond “roughly nearby.”

pgvector — similarity search for the embeddings era

pgvector adds a vector data type and distance operators for similarity search — exactly what’s needed to store and query the embedding vectors that modern LLM/AI applications generate from text, images, or audio. Before pgvector existed, doing this meant standing up a dedicated vector database (Pinecone, Weaviate, Qdrant, Milvus) purely to hold embeddings, syncing it with the primary data store, and accepting the operational cost of a second system with its own backup story, access controls, and failure modes. pgvector collapses that: embeddings live in an ordinary table, next to the row they describe, queried with ordinary SQL, backed up by the same pg_dump/WAL machinery already in place — a meaningful simplification for the very common case of “we have a Postgres database and want semantic search or RAG retrieval over data we already store there.” See ../backend/en/20-ai-for-backend.md for the broader embeddings/RAG picture this extension plugs into.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE document_chunks (
    id bigserial PRIMARY KEY,
    document_id bigint NOT NULL REFERENCES documents(id),
    content text NOT NULL,
    embedding vector(1536)  -- dimension must match the embedding model, e.g. OpenAI text-embedding-3-small
);

-- Approximate nearest-neighbor index; HNSW is generally preferred over IVFFlat since pgvector 0.5+
CREATE INDEX idx_chunks_embedding_hnsw
    ON document_chunks USING hnsw (embedding vector_cosine_ops);

-- Find the 5 chunks most semantically similar to a query embedding (cosine distance)
SELECT id, content, embedding <=> '[0.014, -0.032, ...]'::vector AS distance
FROM document_chunks
ORDER BY embedding <=> '[0.014, -0.032, ...]'::vector
LIMIT 5;

pgvector supports three distance operators — <-> (Euclidean/L2), <=> (cosine distance), and <#> (negative inner product) — and the operator class used on the index (vector_l2_ops, vector_cosine_ops, vector_ip_ops) must match the distance operator used in the query for the index to be used at all; a mismatch silently falls back to a sequential scan, which is the single most common pgvector performance mistake. The HNSW index type trades slower build time for better query recall/latency than IVFFlat and is the sensible default choice starting from pgvector 0.5. pgvector is genuinely good for small-to-mid-scale semantic search — tens of millions of vectors comfortably — and its “no new system” appeal is real; teams operating at the scale of billions of vectors with very tight latency SLAs, or needing advanced features like multi-tenant namespace isolation at the vector-DB layer, are the ones who still reach for a dedicated vector database. For most applications adding RAG or semantic search to an existing product, pgvector is the right starting point, and often the ending point too.

TimescaleDB — purpose-built for time-series

TimescaleDB turns a table into a hypertable: a table that is automatically, transparently partitioned by time (and optionally by an additional key like device_id) into “chunks,” with all the partition-management bookkeeping — creating new chunks as data arrives, choosing chunk boundaries, indexing each chunk — handled by the extension instead of the DBA. This directly extends the manual range-partitioning workflow covered in ./12-partitioning-and-sharding.md: where plain declarative partitioning requires you to run (or automate via pg_partman) CREATE TABLE ... PARTITION OF for each future time range and remember to drop old ones, a hypertable creates and expires chunks automatically based on policies you declare once.

CREATE EXTENSION IF NOT EXISTS timescaledb;

CREATE TABLE sensor_readings (
    time        timestamptz NOT NULL,
    device_id   int NOT NULL,
    temperature double precision,
    humidity    double precision
);

-- Convert to a hypertable, chunked by time (default chunk interval: 7 days)
SELECT create_hypertable('sensor_readings', 'time');

-- Continuous aggregate: a materialized, incrementally-refreshed rollup
CREATE MATERIALIZED VIEW sensor_hourly
WITH (timescaledb.continuous) AS
SELECT device_id,
       time_bucket('1 hour', time) AS bucket,
       avg(temperature) AS avg_temp,
       max(temperature) AS max_temp
FROM sensor_readings
GROUP BY device_id, bucket;

-- Keep only 90 days of raw data; older chunks are dropped wholesale, not row-by-row DELETE
SELECT add_retention_policy('sensor_readings', INTERVAL '90 days');

-- Compress chunks older than 7 days (columnar compression, often 10-20x on time-series data)
ALTER TABLE sensor_readings SET (timescaledb.compress, timescaledb.compress_segmentby = 'device_id');
SELECT add_compression_policy('sensor_readings', INTERVAL '7 days');

Continuous aggregates are the standout feature beyond automatic partitioning: an ordinary materialized view must be fully re-computed with REFRESH MATERIALIZED VIEW, which gets expensive as the underlying table grows, while a continuous aggregate incrementally updates only the buckets touched by new or changed data. Combined with native columnar compression tuned for time-series shapes (repeated device IDs, monotonically increasing timestamps, slowly-changing sensor values), TimescaleDB regularly achieves both better query performance and dramatically smaller storage than a hand-partitioned plain PostgreSQL table on the same data. Reach for TimescaleDB specifically when data is genuinely time-series-shaped — metrics, IoT telemetry, financial ticks, event logs where “give me an aggregate over a time window” is the dominant query pattern — rather than for any table that merely happens to have a timestamp column; a users table with a created_at column is not a time-series workload just because it has a datetime field.

Citus — distributed PostgreSQL

Citus was introduced in ./12-partitioning-and-sharding.md as the closest thing PostgreSQL has to first-class sharding; it’s worth expanding on here as an ecosystem extension in its own right. Citus turns a coordinator node plus a set of worker nodes into a single logical PostgreSQL database: you designate a table as distributed by choosing a distribution column (commonly tenant_id or a similar high-cardinality key), and Citus transparently splits its rows across worker shards, routes queries filtered by that column to the single relevant shard, and parallelizes queries that must touch every shard. Smaller, rarely-changing tables that every query joins against (a countries or plans lookup table) are declared reference tables instead — copied in full onto every worker so that joins against them never require cross-node data movement.

CREATE EXTENSION IF NOT EXISTS citus;

-- On the coordinator: designate the distribution column
SELECT create_distributed_table('events', 'tenant_id');

-- A small, rarely-changing table joined everywhere: replicate it to every worker
SELECT create_reference_table('plans');

-- From the application's point of view, this is ordinary SQL —
-- Citus routes it to the shard(s) holding tenant_id = 42
SELECT count(*) FROM events WHERE tenant_id = 42 AND event_type = 'signup';

-- A cross-tenant aggregate fans out to every worker and combines results on the coordinator
SELECT tenant_id, count(*) FROM events GROUP BY tenant_id ORDER BY count(*) DESC LIMIT 10;

The core value proposition is that the application keeps writing standard PostgreSQL SQL — no bespoke query language, no manual shard routing in application code — while Citus absorbs the complexity of deciding which worker(s) a query needs to touch and stitching results back together. This makes Citus the natural next step when a single instance’s capacity (not any one table’s row count) is the actual bottleneck: a busy multi-tenant SaaS product where the biggest tenants’ data no longer comfortably fits one machine’s memory/IO budget is the textbook Citus use case, versus a single very large but not I/O-saturated table, which is better served by plain partitioning within one instance.

Full-text search: tsvector/tsquery as a core feature, not an extension

PostgreSQL’s text search — tsvector (a preprocessed, normalized document representation with stemming and stop-word removal) and tsquery (a parsed search expression), matched via the @@ operator and accelerated by GIN indexes exactly as shown in ./08-indexing-strategies.md — is worth a mention here specifically because it’s not a separate extension to install; it’s baked into core PostgreSQL and available the moment the server starts. That makes it the lowest-friction option for “let users search this text column” at moderate scale: no second system, no data synchronization pipeline, no additional infrastructure to operate, and full transactional consistency with the rest of the row (a search index that’s never out of sync with the data because it lives in the same commit).

ALTER TABLE articles ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
        setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
        setweight(to_tsvector('english', coalesce(body, '')), 'B')
    ) STORED;

CREATE INDEX idx_articles_search ON articles USING gin (search_vector);

SELECT title, ts_rank(search_vector, query) AS rank
FROM articles, to_tsquery('english', 'postgres & (index | performance)') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 10;

It has real limits, though: no built-in typo tolerance/fuzzy matching (beyond pairing it with the separate pg_trgm contrib extension for trigram similarity), no distributed, horizontally-scalable index the way Elasticsearch shards across nodes, weaker relevance-tuning and faceting ergonomics than a dedicated search engine’s query DSL, and search-specific operational features (synonyms management, learning-to-rank, highlighting at scale) that a purpose-built engine has invested years into. The practical line: built-in full-text search comfortably serves “search within this app’s own data, up to a few million documents, without needing search-engine-grade relevance tuning” — a documentation site, a support-ticket search box, an internal admin search. Reach for Elasticsearch/OpenSearch instead once you need faceted search across many fields, need a search index totally decoupled from the primary database’s load, need sub-100ms search latency at very large document counts, or need relevance features (custom scoring functions, learning-to-rank, cross-field boosting) beyond what ts_rank offers.

Other notable extensions worth knowing exist

A handful of smaller, single-purpose extensions come up often enough in production PostgreSQL that a working DBA should recognize them even without using all of them daily:

A map: extension versus specialized external system

ExtensionProblem it solvesReach for it instead of a dedicated system whenConsider the dedicated system instead when
PostGISSpatial/geographic data and queriesYou need geospatial reasoning alongside relational data, at any scale up to very large GIS workloadsAlmost never — PostGIS is competitive with dedicated GIS systems even at large scale
pgvectorVector similarity search for embeddingsYou already run Postgres and need semantic search/RAG over tens of millions of vectors or fewerYou need billions of vectors, multi-tenant vector namespace isolation, or extreme low-latency ANN at massive scale
TimescaleDBTime-series storage, rollups, compressionData is genuinely time-series-shaped (metrics, IoT, ticks) and you want automatic partitioning + continuous aggregatesYou need purpose-built time-series query languages/dashboards at extreme cardinality (e.g., very large Prometheus/InfluxDB-scale metrics fleets)
CitusHorizontal scale-out of a single logical databaseA single instance’s CPU/memory/IO is the bottleneck, and the workload has a natural sharding key (e.g., tenant_id)Workloads need arbitrary cross-shard transactions/joins at a scale Citus’s model can’t route efficiently
tsvector/tsquery (core)Full-text search over rows already in PostgresModerate document volume, no need for advanced relevance tuning or dedicated search infraFaceted search, huge document counts, learning-to-rank, or search load that must be isolated from the primary database
pg_partmanPartition lifecycle automationAny partitioned table needs future partitions created and old ones retired on a scheduleRarely — this is squarely an in-database concern
pg_cronIn-database job schedulingScheduled SQL maintenance tasks tied tightly to the database itselfComplex job orchestration, cross-system workflows, retries/dependencies (reach for an external scheduler/orchestrator)

Best Practices

Treat every non-core extension as a dependency with its own release cadence, security surface, and upgrade path — not a free feature that showed up in core PostgreSQL. Pin and track extension versions the same way application dependencies are pinned, test ALTER EXTENSION ... UPDATE in a staging environment before running it in production (upgrade scripts occasionally rewrite indexes or require REINDEX, particularly for PostGIS and pgvector after index-format changes), and read an extension’s release notes before a PostgreSQL major-version upgrade — a mismatch between the PostgreSQL version and an extension’s supported range is a common, avoidable source of a blocked upgrade.

Before adopting an extension for a new capability, check whether the target environment actually supports it: a managed cloud provider’s allowlist, an on-prem team’s package-approval process, or a compliance requirement can all rule out an otherwise-perfect extension, and discovering that after architecture decisions are made is expensive. Query pg_available_extensions on the actual target server (not a local dev machine, which may have a different extension set installed) as a first step, not an afterthought.

Resist installing an extension “just in case” — every installed extension is attack surface, a thing to keep upgraded, and, for C-language extensions specifically, a thing that runs inside the same process as the database server and can affect its stability. Match the extension to an actual, present need (geospatial queries in the code today, not “we might do maps someday”), and prefer core PostgreSQL features (JSONB over hstore, built-in gen_random_uuid() over uuid-ossp where sufficient, tsvector over a search extension) when they already cover the need, reserving specialized extensions for the specific capability only they provide.

Finally, remember that extensions compose with everything else covered in this knowledge base rather than replacing it: a PostGIS geography column still benefits from the indexing discipline in ./08-indexing-strategies.md, a TimescaleDB hypertable still needs the partition-strategy thinking in ./12-partitioning-and-sharding.md applied to choosing chunk intervals, and a Citus-distributed table still needs the transaction and locking discipline from ./09-transactions-and-concurrency-control.md — just applied across a cluster instead of one instance. Extensions expand what PostgreSQL can do; they don’t suspend the fundamentals of running it well.

References