DEV Community

EME GUG
EME GUG

Posted on

Building a "Within Walking Distance" Feature: Geo Queries with PostGIS, Wikipedia GeoSearch and Isochrones

Tuần này trên Hacker News có một project được hơn 1500 điểm: Local Wikipedia. Nó chỉ hiện các bài Wikipedia về những địa điểm cách bạn khoảng nửa tiếng đi bộ. Ý tưởng nghe thì đơn giản, nhưng làm thử mới thấy đây là bài toán geospatial kinh điển mà dev Việt mình gặp suốt: app giao đồ ăn tìm quán gần, app bất động sản lọc căn hộ quanh trường học, app du lịch gợi ý điểm tham quan quanh khách sạn.

Mình từng thấy nhiều team làm kiểu này: lấy lat/lng ra, tính khoảng cách bằng công thức Haversine trong code, rồi sort. Với 500 record thì vẫn chạy. Lên 5 triệu record thì server bắt đầu kêu. Bài này mình chia sẻ cách làm đúng ngay từ đầu, gồm 3 tầng: lấy dữ liệu (Wikipedia GeoSearch API), lưu và query nhanh (PostGIS), và tính đúng "30 phút đi bộ" thay vì "bán kính 2.4km" (dùng isochrone với Valhalla).

Kiến trúc tổng quan

flowchart LR
    A[Wikipedia GeoSearch API] -->|crawl theo grid| B[Python ingest script]
    B --> C[(PostgreSQL 16 + PostGIS 3.4)]
    U[User: lat, lng] --> D[API backend]
    D -->|1. lấy isochrone 30 phút| E[Valhalla]
    E -->|GeoJSON polygon| D
    D -->|2. ST_Within + GIST index| C
    C -->|danh sách địa điểm| D

Điểm quan trọng: phần nặng nhất (lọc theo không gian) được đẩy xuống database có spatial index, không làm trong application code.

Bước 1: Lấy dữ liệu từ Wikipedia GeoSearch

MediaWiki có sẵn endpoint list=geosearch, không cần API key. Có hai giới hạn cần biết: gsradius tối đa 10000 mét và gslimit tối đa 500 kết quả mỗi lần gọi. Vì vậy, muốn phủ cả một thành phố thì phải chia thành grid rồi gọi từng ô.

# ingest_wiki.py - Python 3.12, requests 2.32
import time
import requests
import psycopg

API = 'https://vi.wikipedia.org/w/api.php'
HEADERS = {'User-Agent': 'local-wiki-demo/0.1 (contact: you@example.com)'}

def geosearch(lat: float, lng: float, radius: int = 10000):
    params = {
        'action': 'query',
        'list': 'geosearch',
        'gscoord': f'{lat}|{lng}',
        'gsradius': radius,
        'gslimit': 500,
        'format': 'json',
    }
    r = requests.get(API, params=params, headers=HEADERS, timeout=10)
    r.raise_for_status()
    return r.json()['query']['geosearch']

def grid(lat_min, lat_max, lng_min, lng_max, step=0.1):
    lat = lat_min
    while lat <= lat_max:
        lng = lng_min
        while lng <= lng_max:
            yield round(lat, 4), round(lng, 4)
            lng += step
        lat += step

with psycopg.connect('postgresql://dev:dev@localhost:5432/geo') as conn:
    # Bounding box khu vực nội thành Hà Nội
    for lat, lng in grid(20.95, 21.10, 105.75, 105.90):
        for p in geosearch(lat, lng):
            conn.execute(
                '''INSERT INTO places (pageid, title, geom)
                   VALUES (%s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography)
                   ON CONFLICT (pageid) DO NOTHING''',
                (p['pageid'], p['title'], p['lon'], p['lat']),
            )
        conn.commit()
        time.sleep(1)  # lịch sự với API public
Enter fullscreen mode Exit fullscreen mode

Có ba lỗi mình hay gặp ở đây:

  • Thứ tự lon/lat: ST_MakePoint(x, y) nhận longitude trước, latitude sau. Đảo ngược là điểm ở Hà Nội sẽ rơi xuống đâu đó gần Nam Cực.
  • Thiếu User-Agent: Wikimedia có thể chặn request không có User-Agent tử tế.
  • Grid quá thưa: ở khu dày đặc như quận Hoàn Kiếm, một ô có thể vượt quá 500 kết quả. Nên giảm step hoặc giảm gsradius ở trung tâm.

Bước 2: PostGIS và đúng loại index

Khởi động nhanh bằng Docker:

docker run -d --name geo-db \
  -e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=geo \
  -p 5432:5432 postgis/postgis:16-3.4

psql postgresql://dev:dev@localhost:5432/geo <<'SQL'
CREATE EXTENSION IF NOT EXISTS postgis;

CREATE TABLE places (
  pageid  bigint PRIMARY KEY,
  title   text NOT NULL,
  geom    geography(Point, 4326) NOT NULL
);

CREATE INDEX places_geom_gist ON places USING GIST (geom);

-- Tìm địa điểm trong bán kính 2400m quanh Hồ Gươm, sort theo khoảng cách
EXPLAIN ANALYZE
SELECT title,
       round(ST_Distance(geom, ref)::numeric) AS meters
FROM places,
     (SELECT ST_MakePoint(105.8524, 21.0287)::geography AS ref) r
WHERE ST_DWithin(geom, ref, 2400)
ORDER BY geom <-> ref
LIMIT 50;
SQL
Enter fullscreen mode Exit fullscreen mode

Vài điểm đáng chú ý:

  • geography hay geometry? Dùng geography thì khoảng cách tính bằng mét trên mặt cầu, không cần lo phép chiếu. Với app quy mô thành phố hoặc quốc gia thì đây là lựa chọn an toàn. geometry nhanh hơn một chút, hợp khi bạn đã quen làm việc với SRID dạng projected như UTM 48N (EPSG:32648) cho Việt Nam.
  • Dùng ST_DWithin, đừng dùng ST_Distance < X: ST_DWithin tận dụng được GIST index, còn ST_Distance(...) < 2400 trong WHERE thì không, nên database sẽ scan toàn bộ bảng.
  • Toán tử <->: đây là KNN operator, giúp ORDER BY theo khoảng cách mà vẫn dùng được index.

Trên bảng khoảng 2 triệu điểm, query kiểu này thường chạy dưới 10ms. Nếu EXPLAIN ANALYZE hiện Seq Scan thay vì Index Scan using places_geom_gist thì gần như chắc chắn bạn đang viết sai điều kiện WHERE.

Bước 3: "30 phút đi bộ" không phải là hình tròn

Đây là chỗ phân biệt một feature làm cẩu thả với một feature làm chuẩn. 30 phút đi bộ (khoảng 4.8km/h) tương đương 2.4km đường chim bay. Nhưng thực tế có sông Hồng, có đường ray, có cao tốc không cho đi bộ qua. Một địa điểm cách bạn 1km đường chim bay nhưng ở bên kia sông có thể mất cả tiếng mới tới.

Giải pháp là isochrone: một polygon bao gồm mọi điểm có thể tới được trong X phút theo mạng lưới đường thực tế. Mình dùng Valhalla, một routing engine open source chạy trên dữ liệu OpenStreetMap.

sequenceDiagram
    participant C as Client
    participant API as Backend
    participant V as Valhalla
    participant DB as PostGIS
    C->>API: GET /nearby?lat=21.0287&lng=105.8524
    API->>V: POST /isochrone (pedestrian, 30 phút)
    V-->>API: GeoJSON Polygon
    API->>DB: ST_Intersects(geom, polygon)
    DB-->>API: rows
    API-->>C: JSON danh sách địa điểm

Chạy Valhalla với dữ liệu OSM của Việt Nam (lấy từ Geofabrik, khoảng 300MB):

docker run -d --name valhalla -p 8002:8002 \
  -e tile_urls=https://download.geofabrik.de/asia/vietnam-latest.osm.pbf \
  -v $PWD/valhalla_tiles:/custom_files \
  ghcr.io/valhalla/valhalla-scripted:latest
Enter fullscreen mode Exit fullscreen mode

Lần đầu build tiles sẽ mất khoảng 10-20 phút tùy máy. Xong thì nối hai phần lại:

import json
import requests
import psycopg

def walkable_places(lat: float, lng: float, minutes: int = 30):
    iso = requests.post('http://localhost:8002/isochrone', json={
        'locations': [{'lat': lat, 'lon': lng}],
        'costing': 'pedestrian',
        'contours': [{'time': minutes}],
        'polygons': True,
    }, timeout=10).json()
    polygon = json.dumps(iso['features'][0]['geometry'])

    with psycopg.connect('postgresql://dev:dev@localhost:5432/geo') as conn:
        return conn.execute(
            '''SELECT title
               FROM places
               WHERE ST_Intersects(geom, ST_GeomFromGeoJSON(%s)::geography)
               ORDER BY geom <-> ST_MakePoint(%s, %s)::geography
               LIMIT 100''',
            (polygon, lng, lat),
        ).fetchall()

print(walkable_places(21.0287, 105.8524))
Enter fullscreen mode Exit fullscreen mode

Mẹo production: isochrone khá tốn CPU. Nên làm tròn tọa độ user về khoảng 3 chữ số thập phân (sai số chừng 100m) rồi cache polygon trong Redis với TTL vài ngày. Mạng lưới đường đâu có thay đổi mỗi phút.

Kết luận

Những gì nên mang về áp dụng ngay:

  1. Đừng tính khoảng cách trong application code. Đẩy xuống PostGIS, dùng kiểu geography cùng GIST index.
  2. Luôn dùng ST_DWithin hoặc ST_Intersects trong WHERE, <-> trong ORDER BY. Sau đó chạy EXPLAIN ANALYZE để chắc chắn index đang được dùng.
  3. Nhớ thứ tự lon trước, lat sau với mọi hàm PostGIS và GeoJSON. Đây là bug phổ biến nhất.
  4. Bán kính hình tròn chỉ là xấp xỉ. Nếu feature của bạn nói "X phút đi bộ" hay "X phút lái xe", hãy dùng isochrone (Valhalla hoặc OSRM) để kết quả khớp với thực tế, nhất là ở các thành phố có sông và đường lớn chia cắt như Hà Nội hay Sài Gòn.
  5. Cache isochrone theo tọa độ đã làm tròn để tiết kiệm CPU.

Cả stack này đều open source, chạy được trên một VPS 4GB RAM. Cuối tuần bạn hoàn toàn có thể dựng thử một bản "Local Wikipedia" cho thành phố của mình.

Top comments (0)