Sáu tháng sử dụng tìm kiếm PostgreSQL trong môi trường Production
Sáu tháng trước, tôi đã gặp bế tắc. Ứng dụng của một khách hàng đã mở rộng đến mức các truy vấn LIKE %keyword% cơ bản mất từ 5 đến 8 giây để trả về kết quả—về cơ bản là chậm chạp vô cùng. Phản ứng tự nhiên của đội ngũ phát triển là điều có thể dự đoán được: “Hãy triển khai một cụm Elasticsearch.”
Elasticsearch rất mạnh mẽ, nhưng nó cũng là một “con quái vật” về mặt quản lý. Nó mang theo một loạt các chi phí vận hành đi kèm: thêm máy chủ, logic đồng bộ hóa dữ liệu (CDC) phức tạp và hóa đơn đám mây cao hơn đáng kể. Thay vì lao vào “vòng lửa” đó, tôi quyết định xem mình có thể đẩy tính năng Tìm kiếm toàn văn (FTS) gốc của PostgreSQL đi xa đến đâu.
Bẵng đi nửa năm. Chúng tôi đang vận hành thiết lập này trong môi trường production với 1,2 triệu dòng nội dung, và các truy vấn tìm kiếm vẫn duy trì ở mức dưới 100ms. Đối với hầu hết các sản phẩm SaaS, Elasticsearch là quá dư thừa. Dưới đây là chính xác cách tôi đã xây dựng một hệ thống tìm kiếm cấp độ production ngay trong cơ sở dữ liệu mà chúng tôi đã sở hữu.
Thiết lập trong 5 phút
Để có một công cụ tìm kiếm hoạt động ổn định, bạn chỉ cần hai khái niệm: tsvector (tài liệu có thể tìm kiếm) và tsquery (ý định tìm kiếm). Dưới đây là một triển khai mạnh mẽ cho bảng bài viết tiêu chuẩn.
-- 1. Tạo bảng với cột tìm kiếm tự động
-- (Yêu cầu PostgreSQL 12+ cho các cột sinh tự động)
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
title TEXT,
body TEXT,
search_vector tsvector GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))
) STORED
);
-- 2. Chèn một số dữ liệu thực tế
INSERT INTO posts (title, body) VALUES
('Tối ưu hóa PostgreSQL', 'Sử dụng GIN index có thể giảm thời gian truy vấn từ vài giây xuống còn vài mili giây.'),
('Ứng dụng Web hiện đại', 'Các ứng dụng nhanh yêu cầu truy xuất dữ liệu hiệu quả và độ trễ tối thiểu.'),
('Thiết kế cơ sở dữ liệu', 'Cách cấu trúc các bảng của bạn để mở rộng tối đa mà không cần thêm hạ tầng bổ sung.');
-- 3. Tạo GIN index để tra cứu tốc độ cao
CREATE INDEX idx_posts_search ON posts USING GIN (search_vector);
Giờ đây bạn có thể truy vấn bằng toán tử @@. Truy vấn này sẽ tìm các bài viết chứa ‘cơ sở dữ liệu’ hoặc ‘tối ưu hóa’ ngay lập tức:
SELECT title
FROM posts
WHERE search_vector @@ to_tsquery('english', 'database | optimizing');
Cơ chế hoạt động bên dưới
Hãy coi việc khớp chuỗi tiêu chuẩn là một người theo chủ nghĩa nguyên văn; nó tìm kiếm chính xác các ký tự. FTS của PostgreSQL thông minh hơn—nó tìm kiếm các lexeme (từ tố). Khi bạn chạy to_tsvector('english', 'running'), Postgres sẽ đưa nó về từ gốc là ‘run’. Quá trình này, được gọi là stemming (trích lọc từ gốc), đảm bảo rằng việc tìm kiếm ‘runs’ hoặc ‘running’ đều khớp hoàn hảo với tài liệu của bạn.
Kiểu tsvector lưu trữ các token đã được chuẩn hóa này cùng với vị trí của chúng. Trong khi đó, tsquery đại diện cho những gì người dùng đang tìm kiếm, hỗ trợ logic boolean như & (AND), | (OR), và ! (NOT).
Phép màu của GIN Index
B-Tree index—mặc định trong hầu hết các cơ sở dữ liệu—vô dụng đối với tìm kiếm toàn văn vì chúng chỉ lập chỉ mục cho phần đầu của một chuỗi. Một GIN (Generalized Inverted Index – Chỉ mục nghịch đảo tổng quát) hoạt động giống như phần mục lục ở cuối một cuốn sách giáo khoa 500 trang. Nó ánh xạ mọi từ duy nhất vào một danh sách các ID nơi từ đó xuất hiện. Khi người dùng tìm kiếm ‘PostgreSQL’, chỉ mục sẽ cung cấp cho cơ sở dữ liệu vị trí chính xác của dòng đó ngay lập tức, bỏ qua việc quét toàn bộ bảng (full table scan) chậm chạp.
Tính năng nâng cao: Tính điểm và Logic
Người dùng không chỉ muốn kết quả; họ muốn những kết quả *tốt nhất* lên đầu. Postgres không chỉ tìm các kết quả khớp; nó còn tính điểm chúng bằng ts_rank. Bạn thậm chí có thể gán trọng số cho các cột khác nhau—làm cho kết quả khớp ở tiêu đề có giá trị hơn đáng kể so với kết quả khớp trong thân bài.
-- Truy vấn với xếp hạng và gán trọng số cho tiêu đề
SELECT title, ts_rank(search_vector, query) as rank
FROM posts, to_tsquery('english', 'database') query
WHERE search_vector @@ query
ORDER BY rank DESC;
Xử lý dữ liệu cũ và Nhập liệu
Nếu bạn đang sử dụng Postgres 11 trở về trước, bạn không thể sử dụng các cột sinh tự động (generated columns). Với các thiết lập đó, tôi sử dụng một trigger để giữ cho cột tsvector luôn đồng bộ. Nó đảm bảo chỉ mục luôn được cập nhật mà mã ứng dụng của tôi không cần biết đến sự tồn tại của nó.
Khi tôi di chuyển 450MB dữ liệu CSV cũ cho dự án này, tôi cần một cách nhanh chóng để chuẩn bị các script khởi tạo dữ liệu (seeding scripts). Tôi đã sử dụng toolcraft.app/vi/tools/data/csv-to-json vì nó chạy hoàn toàn trong trình duyệt. Không có dữ liệu nào rời khỏi máy của tôi, điều này khiến đội ngũ bảo mật hài lòng trong khi tôi chuẩn bị các search vector ban đầu.
Kinh nghiệm thực chiến quý giá
Sau khi theo dõi hệ thống của mình trong 180 ngày, đây là ba bài học thiết yếu của tôi để giữ cho hiệu suất luôn ổn định:
- Gán trọng số là bắt buộc: Một kết quả khớp trong tiêu đề 5 từ luôn phải có thứ hạng cao hơn kết quả khớp trong thân bài 1.000 từ. Sử dụng
setweight()để ưu tiên các trường ‘A’ (Tiêu đề) và ‘B’ (Tóm tắt). - Truy vấn kiểu tìm kiếm Web: Đừng ép người dùng phải học cú pháp
&và|. Hãy sử dụngwebsearch_to_tsquery. Nó xử lý các kiểu nhập liệu “kiểu Google” như dấu ngoặc kép cho các cụm từ chính xác và dấu trừ cho các trường hợp loại trừ một cách tự động. - Bảo trì GIN của bạn: GIN index có thể bị phình to (bloat) nếu bảng của bạn nhận hơn 50.000 lượt ghi mỗi ngày. Hãy thiết lập
autovacuumở mức quyết liệt. Tôi lên lịchVACUUM ANALYZEhàng tuần cho các bảng nặng về tìm kiếm để giữ cho các bộ lập kế hoạch truy vấn luôn chính xác.
Khi nào bạn thực sự nên chuyển sang Elasticsearch?
PostgreSQL là một “mãnh thú”—cho đến khi nó chạm ngưỡng giới hạn. Nếu bạn cần tìm kiếm mờ (fuzzy matching) nâng cao vượt xa các trigram cơ bản, hoặc nếu bạn đang tìm kiếm hàng tỷ dòng dữ liệu với yêu cầu độ trễ dưới một mili giây, Elasticsearch sẽ xứng đáng với công sức bỏ ra. Nhưng đối với đại đa số các sản phẩm SaaS, blog và công cụ nội bộ, Postgres là một cỗ máy hiệu năng mạnh mẽ nằm ngay trong hệ thống hiện có của bạn.

