Hôm nay trên Hacker News có một project tên "Local Wikipedia" đạt hơn 1100 điểm. Ý tưởng rất đơn giản: Wikipedia, nhưng chỉ hiện các bài về những địa điểm cách bạn khoảng nửa tiếng đi bộ. Đọc xong mình nghĩ ngay tới phía kỹ thuật bên dưới, vì câu hỏi "cái gì ở gần tôi?" là loại query mà gần như dev nào cũng sẽ gặp: app giao đồ ăn, tìm quán cà phê, chấm công theo vị trí, gợi ý phòng trọ gần trường. Nhiều team ở Việt Nam vẫn làm bằng cách lưu lat/lng dạng FLOAT, kéo hết về application rồi tính khoảng cách trong vòng lặp. Với 500 record thì không sao. Với 2 triệu POI thì server khóc. Bài này đi qua 3 cấp độ, từ "bán kính đường chim bay" tới "thật sự đi bộ được trong 30 phút", kèm code chạy được.
Cấp độ 1: Bán kính đường chim bay với PostGIS
Đầu tiên phải đổi "30 phút đi bộ" thành con số. Tốc độ đi bộ trung bình khoảng 4.5–5 km/h, nên 30 phút ≈ 2.5 km. Đây là khoảng cách đường thẳng, nên nó luôn lớn hơn khoảng cách thực tế. Tạm thời cứ chấp nhận vậy.
Đừng tự viết công thức Haversine trong SQL. Dùng PostGIS (mình đang dùng PostGIS 3.5 trên PostgreSQL 17), lưu cột kiểu geography và đánh index GiST:
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE places (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
geom GEOGRAPHY(Point, 4326) NOT NULL
);
CREATE INDEX places_geom_gist ON places USING GIST (geom);
-- Lưu ý: ST_MakePoint nhận (longitude, latitude), KHÔNG phải (lat, lng)
INSERT INTO places (name, geom) VALUES
('Hồ Hoàn Kiếm', ST_MakePoint(105.8522, 21.0287)::geography),
('Văn Miếu', ST_MakePoint(105.8355, 21.0293)::geography);
-- Mọi địa điểm trong bán kính 2.5 km, sắp xếp theo khoảng cách
SELECT id, name,
round(ST_Distance(geom, me)::numeric) AS meters
FROM places,
(SELECT ST_MakePoint(105.8480, 21.0245)::geography AS me) p
WHERE ST_DWithin(geom, me, 2500)
ORDER BY geom <-> me
LIMIT 50;
Có vài điểm mình thấy người mới hay sai:
-
Thứ tự tham số là
(lng, lat). Google Maps hiển thịlat, lngnên ai cũng copy ngược. Kết quả là điểm của bạn rơi xuống đâu đó ở Nam Cực. -
Dùng
ST_DWithin, đừng dùngST_Distance(...) < 2500trong WHERE.ST_DWithindùng được index, cònST_Distancetrong WHERE sẽ scan toàn bộ bảng. -
geographytính bằng mét trên mặt cầu, còngeometrySRID 4326 tính bằng độ. Nếu viếtST_DWithin(geom, me, 2500)vớigeometrythì nghĩa là 2500 độ, tức cả Trái Đất. - Toán tử
<->là KNN, dùng index để sắp xếp theo khoảng cách. Nó cực nhanh khi đi kèmLIMIT.
Chạy EXPLAIN ANALYZE thì bạn sẽ thấy Index Scan using places_geom_gist. Với khoảng 1 triệu điểm, query này thường mất dưới 10ms.
Cấp độ 2: Khi không có PostGIS, dùng H3 để chia lưới
Có lúc bạn không dùng được PostGIS: data nằm trong Redis, DynamoDB, Elasticsearch không có geo, hoặc bạn cần cache kết quả theo khu vực. Lúc đó cách làm là chia bản đồ thành ô (spatial index). Hai lựa chọn phổ biến là Geohash (ô chữ nhật) và H3 của Uber (ô lục giác). Mình thích H3 hơn vì các ô lân cận đều cách tâm gần như bằng nhau, không bị méo ở góc như ô vuông.
flowchart LR
A[User location lat/lng] --> B[h3.latlng_to_cell res 8]
B --> C[grid_disk k ô xung quanh]
C --> D[Lookup các cell ID trong DB/Redis]
D --> E[Lọc chính xác bằng Haversine]
E --> F[Sort và trả kết quả]
Ví dụ với h3 4.x (pip install h3==4.1.2):
import math
import h3
RES = 8 # cạnh ô khoảng 530m ở resolution 8
def haversine_m(lat1, lng1, lat2, lng2):
r = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = p2 - p1
dl = math.radians(lng2 - lng1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
# Lúc ghi: tính sẵn cell cho mỗi địa điểm, lưu thành 1 cột có index
def index_place(place):
place["h3"] = h3.latlng_to_cell(place["lat"], place["lng"], RES)
return place
# Lúc đọc: lấy các ô lân cận rồi lọc chính xác
def nearby(places_by_cell, lat, lng, radius_m=2500):
center = h3.latlng_to_cell(lat, lng, RES)
edge = h3.average_hexagon_edge_length(RES, unit="m")
k = math.ceil(radius_m / (edge * 1.5)) + 1
candidates = []
for cell in h3.grid_disk(center, k):
candidates.extend(places_by_cell.get(cell, []))
result = [
(p, haversine_m(lat, lng, p["lat"], p["lng"])) for p in candidates
]
return sorted([x for x in result if x[1] <= radius_m], key=lambda x: x[1])
Pattern ở đây là lọc thô bằng cell → lọc tinh bằng công thức. Cột h3 chỉ là một string hoặc int64 bình thường, nên database nào cũng index được. Thêm một lợi ích: cell ID làm cache key rất tốt. Hai người đứng cách nhau 100m trong cùng một ô sẽ dùng chung kết quả cache.
Cấp độ 3: "Đi bộ được" chứ không phải "đường chim bay"
Đây là chỗ phân biệt một app làm cho có với một app làm tốt. Ở Hà Nội hay Sài Gòn, một quán cách bạn 800m theo đường thẳng có thể nằm bên kia sông, bên kia đường ray, hoặc bên kia một cái hẻm cụt, và đi bộ mất 25 phút. Câu trả lời đúng là isochrone: một polygon bao trọn mọi điểm bạn tới được trong X phút trên mạng lưới đường thực tế.
Công cụ open-source mình hay dùng là Valhalla, chạy trên dữ liệu OpenStreetMap. Tự host bằng Docker:
# Tải dữ liệu OSM Việt Nam (~300MB) từ Geofabrik
mkdir -p valhalla_data && cd valhalla_data
wget https://download.geofabrik.de/asia/vietnam-latest.osm.pbf
# Chạy Valhalla, lần đầu build tiles mất khoảng 15-30 phút
docker run -d --name valhalla -p 8002:8002 \
-v "$PWD":/custom_files \
ghcr.io/gis-ops/docker-valhalla/valhalla:latest
# Isochrone đi bộ 30 phút, trả về GeoJSON polygon
curl -s http://localhost:8002/isochrone --data '{
"locations": [{"lat": 21.0245, "lon": 105.8480}],
"costing": "pedestrian",
"contours": [{"time": 30}],
"polygons": true
}' | jq '.features[0].geometry' > walk30.json
Có polygon rồi thì ghép lại với PostGIS. Mình làm theo flow 2 bước để vẫn tận dụng được index:
sequenceDiagram
participant C as Client
participant API as API Server
participant V as Valhalla
participant DB as PostGIS
C->>API: GET /nearby?lat=..&lng=..
API->>V: POST /isochrone (pedestrian, 30 min)
V-->>API: GeoJSON polygon
API->>DB: ST_DWithin 2.5km AND ST_Intersects polygon
DB-->>API: Danh sách địa điểm
API-->>C: JSON kết quả
SELECT id, name
FROM places
WHERE ST_DWithin(geom, ST_MakePoint(105.8480, 21.0245)::geography, 2500)
AND ST_Intersects(
geom::geometry,
ST_SetSRID(ST_GeomFromGeoJSON($1), 4326)
);
Điều kiện ST_DWithin ở đây đóng vai "lưới an toàn": nó dùng index để cắt nhanh tập ứng viên, rồi ST_Intersects với polygon chỉ phải chạy trên vài trăm dòng thay vì cả bảng. Gọi isochrone tốn khoảng 50–200ms, nên hãy cache polygon theo H3 cell của người dùng (ví dụ resolution 9, TTL vài ngày). Đường sá không thay đổi mỗi phút.
Vài cái bẫy production
- Độ chính xác GPS trên điện thoại trong nhà hoặc giữa các tòa cao tầng có thể lệch 50–200m. Đừng hứa "cách bạn 37m". Làm tròn hiển thị tới 50m hoặc 100m.
- Privacy: tọa độ người dùng là dữ liệu nhạy cảm. Nếu chỉ cần query thì làm tròn hoặc snap về H3 cell trước khi log. Đừng bao giờ ghi lat/lng chính xác vào access log.
- Dữ liệu OSM ở Việt Nam khá đầy đủ ở các thành phố lớn, nhưng nhiều hẻm nhỏ còn thiếu. Isochrone vùng ven có thể bị "hụt" so với thực tế.
- Đừng tính khoảng cách ở frontend cho hàng nghìn marker. Để database làm, client chỉ render.
Kết luận
Những việc bạn làm được ngay tuần này:
-
Bỏ kiểu
lat FLOAT, lng FLOAT+ loop trong code. Chuyển sangGEOGRAPHY(Point, 4326)+ index GiST, dùngST_DWithinđể lọc và<->để sắp xếp. -
Kiểm tra thứ tự
(lng, lat)ở mọi chỗ gọiST_MakePoint. Đây là bug phổ biến nhất, và hay chạy "có vẻ đúng" ở những khu vực gần xích đạo. -
Không có PostGIS thì dùng H3: lưu cell ID như một cột thường, lọc thô bằng
grid_disk, lọc tinh bằng Haversine. Tận dụng luôn cell ID làm cache key. -
Nếu UX nói "đi bộ X phút" thì dùng isochrone (Valhalla hoặc OSRM tự host với dữ liệu Geofabrik), kết hợp với
ST_DWithinlàm bộ lọc trước để giữ query nhanh. -
Luôn
EXPLAIN ANALYZEđể chắc query thật sự dùng index. Một geo query không dùng index là quả bom hẹn giờ khi data tăng lên.
"Gần tôi có gì?" nghe có vẻ là một feature nhỏ, nhưng làm đúng thì nó là ranh giới giữa một app lag và một app khiến người dùng thấy "ồ, nhanh thật".



