Làm việc với dữ liệu không gian GIS trong PostgreSQL với PostGIS: Lưu trữ, đánh chỉ mục và truy vấn

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Bối cảnh & Lý do: Giải quyết bài toán tìm kiếm lân cận

Cách đây vài năm, tôi từng xây dựng tính năng tìm cửa hàng gần nhất (store locator) đầu tiên cho một chuỗi bán lẻ. Cách tiếp cận ban đầu của tôi ngây thơ đến mức đáng quên. Tôi lưu tọa độ trong hai cột DECIMAL(9,6) thông thường—latitude và longitude—rồi tính khoảng cách bằng công thức SQL Haversine tự viết. Mọi thứ chạy rất mượt trong môi trường phát triển với 20 địa điểm thử nghiệm. Nhưng khi triển khai lên staging với 120.000 cửa hàng, vấn đề lập tức xuất hiện: thời gian phản hồi vọt lên hơn 1.800 ms cho mỗi truy vấn, mức sử dụng CPU chạm ngưỡng 95%, và cơ sở dữ liệu bị nghẽn ngay cả dưới mức tải khiêm tốn.

Vì sao cách này lại thất bại? B-tree index tiêu chuẩn sắp xếp dữ liệu theo một chiều vô hướng (scalar dimension) duy nhất. Chúng xử lý ID dạng số, chuỗi ký tự theo thứ tự bảng chữ cái hay timestamp rất dễ dàng.

Tuy nhiên, dữ liệu địa lý thực tế đòi hỏi khả năng lọc hai chiều trên bề mặt hình ellipsoid. B-tree index chỉ có thể lọc vĩ độ hoặc kinh độ một cách độc lập, chứ không thể đánh giá đồng thời cả hai nếu không quét qua hàng chục nghìn bản ghi tiềm năng. Hơn nữa, lượng giác phẳng cũng làm sai lệch khoảng cách thực tế do bỏ qua độ cong của Trái Đất.

PostGIS sinh ra để giải quyết triệt để hạn chế cốt lõi này. Tương tự như giải pháp PostgreSQL pgvector cho dữ liệu embedding đa chiều, PostGIS là một extension không gian mã nguồn mở dành cho PostgreSQL, bổ sung các kiểu dữ liệu không gian chuyên dụng: GEOMETRY cho các phép chiếu phẳng Euclidean và GEOGRAPHY cho các phép tính hình cầu/ellipsoid. Nó mang đến hàng trăm hàm phân tích tối ưu cùng khả năng đánh chỉ mục Generalized Search Tree (GiST). Bằng cách giao các phép tính không gian cho PostGIS, bạn đẩy toàn bộ gánh nặng tính toán phức tạp vào database engine, đồng thời loại bỏ được hàng trăm dòng code toán học dễ lỗi ở tầng ứng dụng.

Trong thực tế, dữ liệu địa lý hiếm khi có sẵn ở định dạng chuẩn để bạn nhập ngay vào database. Địa điểm của khách hàng thường được gửi dưới dạng file CSV lộn xộn hoặc bảng tính chưa định dạng. Khi cần chuẩn hóa các dòng CSV thành payload JSON có cấu trúc để batch ingestion, tôi thường dùng toolcraft.app/vi/tools/data/csv-to-json. Công cụ này xử lý file trực tiếp trên trình duyệt mà không tải dữ liệu của khách lên server ngoài, giúp việc chuẩn bị dữ liệu import vừa nhanh chóng vừa an toàn.

Cài đặt: Thiết lập PostGIS

Vì PostGIS hoạt động như một extension bên trong database, bạn sẽ cài đặt nó trực tiếp trên cụm PostgreSQL hiện có. Trên Ubuntu hoặc Debian đang chạy PostgreSQL 16, hãy cài đặt gói chính thức qua apt:

# Cập nhật danh sách gói và cài đặt PostGIS cho PostgreSQL 16
sudo apt-get update
sudo apt-get install -y postgresql-16-postgis-3

Đối với quy trình làm việc cục bộ bằng container, bạn có thể khởi chạy Docker image chính thức đã được cấu hình sẵn PostGIS:

docker run --name postgis-demo \
  -e POSTGRES_PASSWORD=secretpassword \
  -e POSTGRES_DB=gis_db \
  -p 5432:5432 \
  -d postgis/postgis:16-3.4

Tiếp theo, kết nối tới instance database bằng psql và kích hoạt extension:

-- Chuyển sang database mục tiêu
\c gis_db

-- Kích hoạt spatial engine PostGIS
CREATE EXTENSION IF NOT EXISTS postgis;

-- Xác nhận cài đặt thành công
SELECT PostGIS_Full_Version();

Truy vấn này sẽ hiển thị bản build PostGIS đang hoạt động cùng với phiên bản của các thư viện liên kết như GEOS, PROJ và LibXML. Kết quả trả về cho thấy database của bạn đã sẵn sàng xử lý dữ liệu không gian.

Cấu hình: Schema, lưu trữ dữ liệu và Spatial Indexing

Để cấu hình một spatial schema, bạn cần chọn đúng kiểu dữ liệu và Mã định danh Hệ quy chiếu Không gian (SRID). Với các ứng dụng người dùng toàn cầu sử dụng tọa độ GPS tiêu chuẩn, SRID 4326 (WGS 84) là tiêu chuẩn của ngành.

PostGIS cung cấp hai kiểu dữ liệu không gian cốt lõi:

  • GEOMETRY: Ánh xạ tọa độ lên mặt phẳng Descartes (Euclidean). Kiểu này cho tốc độ tính toán rất nhanh đối với bản đồ khu vực cục bộ, các phép chiếu cấp tiểu bang/tỉnh (như State Plane) và các ứng dụng CAD.
  • GEOGRAPHY: Tính toán khoảng cách cung lớn (great-circle) thực tế trực tiếp trên ellipsoid WGS 84 theo đơn vị mét. Đây là lựa chọn lý tưởng cho tọa độ GPS, tra cứu bán kính giao hàng và hệ thống tìm chuỗi cửa hàng trên toàn quốc.

1. Tạo Schema cho bảng

Dưới đây là schema sẵn sàng cho môi trường production dành cho các địa điểm quán cà phê, sử dụng kiểu GEOGRAPHY(Point, 4326) (bạn có thể áp dụng vào quy trình tự động hóa migration database schema với Flyway để quản lý phiên bản database nhất quán):

CREATE TABLE coffee_shops (
    id SERIAL PRIMARY KEY,
    name VARCHAR(150) NOT NULL,
    address TEXT,
    location GEOGRAPHY(Point, 4326) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

2. Đánh chỉ mục không gian (GiST Index)

Tuyệt đối không truy vấn tọa độ trên môi trường production nếu thiếu spatial index. PostGIS sử dụng GiST (Generalized Search Tree) để xây dựng cấu trúc R-tree phân cấp trên các hộp giới hạn không gian (bounding boxes):

CREATE INDEX idx_coffee_shops_location 
ON coffee_shops 
USING GIST (location);

Cấu trúc phân cấp bounding-box này giúp PostgreSQL loại bỏ ngay lập tức các nhánh không liên quan trên cây tìm kiếm, tránh được tình trạng quét tuần tự toàn bộ bảng (full-table sequential scan).

3. Lưu trữ tọa độ

Hãy luôn ghi nhớ quy tắc này: PostGIS tuân theo thứ tự hệ tọa độ Descartes (Kinh độ, Vĩ độ) (X trước Y). Hầu hết mọi người theo thói quen hay nói “Vĩ độ, Kinh độ”. Đảo ngược hai giá trị này là lỗi phổ biến nhất trong lập trình GIS, thường vô tình ném tọa độ New York sang tận… Nam Cực.

Tạo điểm (Point) bằng hàm ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography:

-- Chèn dữ liệu địa điểm (Kinh độ trước: X = Kinh độ, Y = Vĩ độ)
INSERT INTO coffee_shops (name, address, location)
VALUES
    ('Central Perk', '90 Bedford St, New York', ST_SetSRID(ST_MakePoint(-74.0054, 40.7323), 4326)::geography),
    ('Midtown Roast', '350 5th Ave, New York', ST_SetSRID(ST_MakePoint(-73.9857, 40.7484), 4326)::geography),
    ('Brooklyn Beans', '100 Water St, Brooklyn', ST_SetSRID(ST_MakePoint(-73.9922, 40.7032), 4326)::geography),
    ('Uptown Espresso', '2880 Broadway, New York', ST_SetSRID(ST_MakePoint(-73.9645, 40.8082), 4326)::geography);

Kiểm tra & Giám sát: Truy vấn thực tế và tối ưu hiệu năng

Sau khi dữ liệu đã được đánh chỉ mục, bạn có thể thực thi các truy vấn bounding-box và truy vấn bán kính để đánh giá hiệu năng thực tế.

1. Tìm các địa điểm trong bán kính xác định (ST_DWithin)

Giả sử một người dùng đang đứng ngoài tòa nhà Empire State tại tọa độ (-73.9851, 40.7488) và muốn tìm quán cà phê trong phạm vi đi bộ 2 km (2.000 mét). Hàm ST_DWithin đánh giá ranh giới không gian này với hiệu suất cực cao:

SELECT 
    name,
    ROUND(ST_Distance(location, ST_SetSRID(ST_MakePoint(-73.9851, 40.7488), 4326)::geography)::numeric, 1) AS distance_meters
FROM coffee_shops
WHERE ST_DWithin(
    location,
    ST_SetSRID(ST_MakePoint(-73.9851, 40.7488), 4326)::geography,
    2000 -- bán kính tính bằng mét
)
ORDER BY distance_meters ASC;

Vì cột sử dụng kiểu GEOGRAPHY, khoảng cách được tự động tính toán chính xác bằng mét dọc theo hình phỏng cầu Trái Đất mà không cần phải chuyển đổi thủ công từ độ sang mét.

2. Kiểm tra hiệu quả sử dụng index bằng EXPLAIN ANALYZE

Kiểm tra xem PostgreSQL có tận dụng GiST index hay lại quét tuần tự toàn bộ bảng:

EXPLAIN ANALYZE
SELECT id, name
FROM coffee_shops
WHERE ST_DWithin(
    location,
    ST_SetSRID(ST_MakePoint(-73.9851, 40.7488), 4326)::geography,
    2000
);

Ở một hệ thống hoạt động tối ưu, bạn sẽ thấy kết quả là Index Scan using idx_coffee_shops_location hoặc Bitmap Index Scan. Trên tập dữ liệu 500.000 dòng, một truy vấn quét tuần tự (sequential scan) thường mất từ 600 ms đến 900 ms. Nhưng khi GiST index hoạt động, chính truy vấn đó chỉ mất từ 1,5 ms đến 3 ms để hoàn tất.

Nếu thấy Seq Scan trên một bảng lớn, hãy kiểm tra xem có hiện tượng ép kiểu ngầm định (implicit type conversion) làm vô hiệu hóa index hay không, hoặc đảm bảo rằng bạn không bọc cột đã đánh index bên trong một lời gọi hàm chưa được lập chỉ mục.

3. Giám sát tình trạng index và dung lượng ổ đĩa

Hãy theo dõi dung lượng lưu trữ vật lý để tránh áp lực lên bộ nhớ cache khi lượng dữ liệu tọa độ tăng lên. Nếu bảng hoặc spatial index phát sinh hiện tượng phân mảnh sau nhiều thao tác cập nhật, bạn có thể sử dụng pg_repack để khắc phục bloat bảng và index trong PostgreSQL mà không làm khóa bảng:

SELECT
    relname AS object_name,
    pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
    pg_size_pretty(pg_relation_size(c.oid)) AS table_or_index_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname IN ('coffee_shops', 'idx_coffee_shops_location')
  AND n.nspname = 'public';

Hãy chạy VACUUM ANALYZE coffee_shops; sau các đợt bulk import dữ liệu lớn. Lệnh này sẽ làm mới số liệu thống kê cho bộ lập kế hoạch (planner) của PostgreSQL, giúp cost-based optimizer luôn ưu tiên duyệt cây GiST thay vì quét tuần tự. Về lâu dài, việc thiết lập và tối ưu hóa Autovacuum trong PostgreSQL là giải pháp căn cơ để hệ thống tự động dọn dẹp tuple rác và duy trì thống kê chính xác mà không gây nghẽn I/O.

Share: