Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Geographic queries: SQL với toạ độ và vùng

SQL and Data Analysis cho BA Bài 46/60

Mở đầu — vì sao bài này quan trọng

Hãy thử nghĩ về một ngày làm việc của một BA tại một công ty giao hàng như GrabExpress hay Ahamove. Sếp hỏi: "Có bao nhiêu đơn hàng nằm trong bán kính 3km quanh kho Quận 7?", "Tài xế nào đang gần điểm lấy hàng nhất?", "Vùng nội thành nào có mật độ đơn cao nhất tháng này?". Tất cả những câu hỏi đó đều có một điểm chung: chúng dựa trên vị trí địa lý — toạ độ, khoảng cách, và vùng (region).

Trong thời đại mọi ứng dụng đều gắn với bản đồ — gọi xe, giao đồ ăn, e-commerce, bất động sản, ngân hàng đặt máy ATM — dữ liệu địa lý đã trở thành một loại dữ liệu nghiệp vụ cốt lõi. Một BA biết viết geographic query (truy vấn địa lý) sẽ tự trả lời được những câu hỏi mà người khác phải chờ team data hoặc engineer. Bạn sẽ tính được khoảng cách giữa hai điểm, tìm những điểm nằm trong một vùng, gom nhóm dữ liệu theo quận/tỉnh, và đo mật độ phân bố — tất cả bằng SQL thuần.

Điều quan trọng cần hiểu ngay từ đầu: bạn không cần phải là chuyên gia GIS (Geographic Information System) để làm việc này hiệu quả. Phần lớn nhu cầu nghiệp vụ chỉ cần ba thứ: lưu toạ độ đúng cách, tính khoảng cách giữa hai điểm, và lọc/gom theo vùng. Bài này tập trung đúng vào ba thứ đó, cùng với một vài mẹo về hiệu năng để query của bạn không "treo" khi dữ liệu lớn lên.

Khái niệm cốt lõi

Toạ độ địa lý: latitude và longitude

Mọi điểm trên Trái Đất được xác định bằng hai con số:

  • Latitude (vĩ độ, lat): chạy từ -90 (Nam Cực) đến +90 (Bắc Cực). TP.HCM ở khoảng 10.7626.
  • Longitude (kinh độ, lng/lon): chạy từ -180 đến +180. TP.HCM ở khoảng 106.6602.
Một quy ước dễ nhớ: lat là "lên xuống", lng là "trái phải". Khi viết cặp toạ độ, thứ tự rất quan trọng — Google Maps dùng (lat, lng), nhưng nhiều hàm SQL không gian (như PostGIS) lại dùng (lng, lat). Sai thứ tự là một trong những lỗi phổ biến nhất, ta sẽ nói kỹ ở phần lỗi thường gặp.

Lưu toạ độ trong database

Cách lưu cơ bản và phổ biến nhất là dùng hai cột số thập phân:

CREATE TABLE locations (
  id BIGINT PRIMARY KEY,
  name VARCHAR(255),
  lat DECIMAL(10,7),    -- ví dụ 10.7626220
  lng DECIMAL(10,7),    -- ví dụ 106.6601800
  city VARCHAR(100),
  district VARCHAR(100)
);

Vì sao là DECIMAL(10,7)? Bảy chữ số sau dấu phẩy cho độ chính xác khoảng 1cm — quá đủ cho mọi nhu cầu nghiệp vụ (giao hàng, gọi xe chỉ cần chính xác tới mét). Dùng DECIMAL thay vì FLOAT để tránh sai số làm tròn nhị phân, vì toạ độ thường được so sánh và join.

Ngoài cách "hai cột số", các database hiện đại còn hỗ trợ kiểu dữ liệu không gian (spatial type) như POINT, GEOGRAPHY, GEOMETRY. Với PostgreSQL có extension PostGIS, MySQL có sẵn các hàm spatial. Kiểu này mạnh hơn (hỗ trợ index không gian, đa giác, đường) nhưng phức tạp hơn. Là BA, bạn nên hiểu cả hai, nhưng phần lớn công việc dùng "hai cột số" là đủ.

Tính khoảng cách giữa hai điểm: công thức Haversine

Trái Đất là hình cầu, nên không thể tính khoảng cách bằng định lý Pythagoras đơn giản. Công thức chuẩn để tính khoảng cách "đường chim bay" giữa hai điểm trên mặt cầu là Haversine:

-- Khoảng cách (km) từ (lat1, lng1) đến (lat2, lng2)
SELECT
  6371  2  ASIN(SQRT(
    POWER(SIN(RADIANS(:lat2 - :lat1) / 2), 2) +
    COS(RADIANS(:lat1))  COS(RADIANS(:lat2)) 
    POWER(SIN(RADIANS(:lng2 - :lng1) / 2), 2)
  )) AS distance_km;

Trong đó 6371 là bán kính Trái Đất tính bằng km (dùng 3959 nếu muốn ra dặm). Bạn không cần thuộc lòng công thức này — chỉ cần biết nó tồn tại, copy ra dùng, và hiểu nó cho ra khoảng cách đường chim bay (chứ không phải quãng đường đi thực tế trên đường phố).

Trong PostgreSQL có PostGIS, bạn không cần viết tay công thức trên. Chỉ cần:

SELECT ST_Distance(
  ST_MakePoint(:lng1, :lat1)::geography,
  ST_MakePoint(:lng2, :lat2)::geography
) AS distance_meters;

Hàm ST_Distance với kiểu geography tự xử lý độ cong Trái Đất và trả về khoảng cách tính bằng mét.

Vùng (region): từ đơn giản đến nâng cao

"Vùng" trong nghiệp vụ thường có hai cách hiểu:

  • Vùng hành chính (quận, tỉnh, vùng miền): thường lưu sẵn dưới dạng cột text như district, province. Lọc và gom nhóm theo vùng này chỉ là WHEREGROUP BY thông thường — không cần toán học gì.
  • Vùng hình học (bán kính quanh một điểm, hoặc một đa giác — polygon): ví dụ "trong bán kính 5km quanh sân bay Tân Sơn Nhất" hoặc "bên trong ranh giới Quận 1". Loại này cần tính khoảng cách hoặc kiểm tra điểm-trong-đa-giác (ST_Contains trong PostGIS).

Tình huống thực tế

Tình huống 1: Ahamove tìm tài xế gần điểm lấy hàng

Bối cảnh: Một startup giao hàng nội thành kiểu Ahamove cần, mỗi khi có đơn mới, tìm 5 tài xế đang online gần điểm lấy hàng nhất để gửi thông báo nhận đơn. Điểm lấy hàng ở (10.7769, 106.7009) — khu Bến Thành, Quận 1.

Bảng drivers có các cột id, name, lat, lng, is_online. BA viết query:

SELECT
  id, name,
  6371  2  ASIN(SQRT(
    POWER(SIN(RADIANS(lat - 10.7769) / 2), 2) +
    COS(RADIANS(10.7769))  COS(RADIANS(lat)) 
    POWER(SIN(RADIANS(lng - 106.7009) / 2), 2)
  )) AS distance_km
FROM drivers
WHERE is_online = 1
ORDER BY distance_km ASC
LIMIT 5;

Diễn giải: query tính khoảng cách từ mỗi tài xế online tới điểm lấy hàng, sắp xếp gần nhất lên đầu, lấy 5 người. Kết quả ví dụ: tài xế A cách 0.8km, B cách 1.2km, C cách 1.5km...

Bài học rút ra: đây là bài toán "nearest neighbor" (điểm gần nhất) kinh điển. Nhưng cẩn thận — query này tính Haversine cho mọi tài xế online rồi mới sort. Nếu có 50.000 tài xế thì sẽ chậm. Mẹo tối ưu: lọc thô trước bằng một "hộp" toạ độ (bounding box) rồi mới tính Haversine chính xác — ta sẽ làm ở phần hướng dẫn từng bước.

Tình huống 2: Tiki phân tích mật độ đơn theo quận tại TP.HCM

Bối cảnh: Team vận hành của một sàn e-commerce kiểu Tiki muốn biết quận nào ở TP.HCM có nhiều đơn nhất để cân nhắc đặt thêm kho cận giao (micro-warehouse). Bảng orders có cột delivery_district, delivery_lat, delivery_lng, created_at.

SELECT
  delivery_district,
  COUNT(*) AS so_don,
  ROUND(AVG(order_value), 0) AS gia_tri_tb
FROM orders
WHERE created_at >= '2026-06-01'
  AND delivery_district IS NOT NULL
GROUP BY delivery_district
ORDER BY so_don DESC
LIMIT 10;

Kết quả ví dụ: Quận Bình Thạnh 12.400 đơn, Quận 7 9.800 đơn, Quận Gò Vấp 8.100 đơn... Team thấy Bình Thạnh dẫn đầu cả về số đơn lẫn giá trị trung bình, nên ưu tiên đặt kho ở đó.

Diễn giải: đây là geographic query "dạng vùng hành chính" — không cần Haversine, không cần toán cầu, chỉ là GROUP BY trên cột district. Đừng phức tạp hoá vấn đề: rất nhiều câu hỏi địa lý nghiệp vụ chỉ cần gom theo cột vùng có sẵn.

Bài học rút ra: luôn hỏi "dữ liệu vùng đã có sẵn dạng text chưa?" trước khi nghĩ tới toạ độ. Nếu đơn hàng đã được gắn sẵn district lúc khách chọn địa chỉ, bạn tiết kiệm được rất nhiều công sức.

Tình huống 3: Be xác định đơn nằm trong vùng phục vụ

Bối cảnh: Một hãng gọi xe kiểu Be muốn kiểm tra xem có bao nhiêu cuốc xe yêu cầu nằm ngoài vùng phục vụ — định nghĩa là bán kính 15km quanh trung tâm thành phố ở (10.7769, 106.7009). Những cuốc ngoài vùng này thường bị huỷ, gây trải nghiệm xấu.

SELECT
  COUNT(*) AS tong_cuoc,
  SUM(CASE WHEN distance_km > 15 THEN 1 ELSE 0 END) AS cuoc_ngoai_vung,
  ROUND(100.0  SUM(CASE WHEN distance_km > 15 THEN 1 ELSE 0 END) / COUNT(), 1) AS ty_le_ngoai_vung
FROM (
  SELECT
    6371  2  ASIN(SQRT(
      POWER(SIN(RADIANS(pickup_lat - 10.7769) / 2), 2) +
      COS(RADIANS(10.7769))  COS(RADIANS(pickup_lat)) 
      POWER(SIN(RADIANS(pickup_lng - 106.7009) / 2), 2)
    )) AS distance_km
  FROM ride_requests
  WHERE request_date >= '2026-06-01'
) t;

Kết quả ví dụ: 45.000 cuốc, trong đó 2.700 cuốc ngoài vùng (6%). Team marketing dùng con số này để quyết định mở rộng vùng phục vụ ra các huyện ngoại thành.

Bài học rút ra: ở đây ta tính khoảng cách trong subquery rồi mới gom thống kê ở ngoài. Đây là pattern rất hữu ích — tách phép tính địa lý ra một lớp riêng để query ngoài dễ đọc, dễ thêm các điều kiện thống kê.

Hướng dẫn từng bước

Hãy đi qua bài toán "tìm điểm trong bán kính" một cách bài bản, có tối ưu hiệu năng — đây là kỹ năng địa lý dùng nhiều nhất.

Bước 1 — Xác định điểm tâm và bán kính. Ví dụ: tìm tất cả cửa hàng trong bán kính 3km quanh (10.7626, 106.6602).

Bước 2 — Lọc thô bằng bounding box. Trước khi tính Haversine cho cả triệu dòng, ta loại nhanh những điểm chắc chắn ở xa bằng một hình chữ nhật bao quanh. Quy tắc xấp xỉ: 1 độ vĩ độ ≈ 111km. Vậy 3km ≈ 0.027 độ lat. Với longitude phải chia thêm cho COS(lat) vì các đường kinh tuyến hẹp dần về phía cực:

WHERE lat BETWEEN 10.7626 - 0.027 AND 10.7626 + 0.027
  AND lng BETWEEN 106.6602 - 0.027 AND 106.6602 + 0.027

Bước này dùng được index thông thường trên cột lat, lng nên rất nhanh.

Bước 3 — Tính Haversine chính xác trên tập đã lọc. Bounding box là hình vuông, còn bán kính là hình tròn, nên ta vẫn cần lọc lại bằng khoảng cách thật:

SELECT id, name, distance_km FROM (
  SELECT id, name,
    6371  2  ASIN(SQRT(
      POWER(SIN(RADIANS(lat - 10.7626) / 2), 2) +
      COS(RADIANS(10.7626))  COS(RADIANS(lat)) 
      POWER(SIN(RADIANS(lng - 106.6602) / 2), 2)
    )) AS distance_km
  FROM stores
  WHERE lat BETWEEN 10.7626 - 0.027 AND 10.7626 + 0.027
    AND lng BETWEEN 106.6602 - 0.027 AND 106.6602 + 0.027
) t
WHERE distance_km <= 3
ORDER BY distance_km;

Bước 4 — Diễn giải kết quả cho nghiệp vụ. Đừng dừng ở bảng số. Hãy kết luận: "Có 14 cửa hàng trong bán kính 3km; gần nhất 0.4km, xa nhất 2.9km" — đó mới là thứ stakeholder cần.

Với PostGIS, ba bước trên gói gọn thành một dòng nhờ index không gian (GiST):

SELECT id, name
FROM stores
WHERE ST_DWithin(
  geom::geography,
  ST_MakePoint(106.6602, 10.7626)::geography,
  3000  -- mét
);

Lỗi thường gặp & mẹo

Lỗi 1 — Đảo lat/lng. Lỗi kinh điển nhất. Nếu kết quả khoảng cách ra số vô lý (cửa hàng ở Sài Gòn mà tính ra cách 8.000km), gần như chắc chắn bạn đã đảo thứ tự. Nhớ: Google Maps là (lat, lng), còn ST_MakePoint và hầu hết hàm spatial là (lng, lat).

Lỗi 2 — Dùng Pythagoras để tính khoảng cách. Công thức SQRT((lat2-lat1)^2 + (lng2-lng1)^2) chỉ đúng trên mặt phẳng và trên phạm vi rất nhỏ. Ở quy mô thành phố trở lên, sai số đáng kể vì 1 độ lat ≠ 1 độ lng (về khoảng cách thực). Luôn dùng Haversine cho khoảng cách thật.

Lỗi 3 — Quên rằng khoảng cách Haversine là đường chim bay. Khách cách kho 2km đường chim bay có thể phải đi 4km đường thực tế vì sông, ngõ cụt, đường một chiều. Khi báo cáo ETA giao hàng, nhân hệ số (thường 1.3–1.5 ở nội thành) hoặc dùng API định tuyến thật. Đừng hứa thời gian giao dựa trên đường chim bay.

Lỗi 4 — Tính Haversine trên toàn bảng rồi mới lọc. Như đã nói, luôn lọc thô bằng bounding box trước. Hàm toán học trong WHERE hoặc ORDER BY không dùng được index thông thường, nên trên bảng lớn sẽ quét toàn bảng và rất chậm.

Lỗi 5 — Bỏ qua NULL toạ độ. Đơn hàng nhập tay, địa chỉ chưa geocode... thường có lat/lng NULL. Hàm toán trên NULL trả về NULL, làm sai thống kê. Luôn thêm WHERE lat IS NOT NULL AND lng IS NOT NULL.

Mẹo — Chuẩn hoá hệ toạ độ. Toạ độ web/GPS dùng chuẩn WGS84 (EPSG:4326). Khi dữ liệu đến từ nhiều nguồn, hãy xác nhận tất cả cùng một chuẩn trước khi tính, kẻo lệch vài chục mét.

Mẹo — Lưu sẵn cột vùng. Nếu bạn thường xuyên gom nhóm theo quận, hãy đề xuất engineer thêm cột district được điền sẵn lúc tạo bản ghi. Gom theo cột text nhanh và đơn giản hơn nhiều so với kiểm tra điểm-trong-đa-giác mỗi lần query.

Bài tập thực hành

Giả sử bạn có bảng stores(id, name, lat, lng, district, city) và bảng customers(id, name, lat, lng).

  • Khoảng cách đơn giản: Viết query tính khoảng cách (km) từ cửa hàng có id = 1 đến tất cả khách hàng, sắp xếp gần nhất lên đầu.
  • Trong bán kính: Tìm tất cả khách hàng nằm trong bán kính 2km quanh cửa hàng id = 1. Nhớ áp dụng bounding box để lọc thô trước.
  • Gom theo vùng: Đếm số cửa hàng theo từng district, chỉ lấy các quận có từ 3 cửa hàng trở lên, sắp xếp giảm dần.
  • Mật độ: Với mỗi cửa hàng, đếm xem có bao nhiêu khách hàng nằm trong bán kính 1km. Gợi ý: dùng JOIN giữa storescustomers với điều kiện Haversine, rồi GROUP BY cửa hàng.
  • Phát hiện bất thường: Tìm các bản ghi khách hàng có toạ độ "đáng ngờ" — lat ngoài khoảng [8, 24] hoặc lng ngoài khoảng [102, 110] (ngoài lãnh thổ Việt Nam), nhiều khả năng là dữ liệu nhập sai hoặc bị đảo lat/lng.
Hãy tự viết và chạy thử; với mỗi câu, viết một câu kết luận nghiệp vụ thay vì chỉ trả về bảng số.

Tóm tắt

Geographic query không hề "đáng sợ" như tên gọi. Là BA, bạn chỉ cần nắm vững một số ít công cụ:

  • Lưu toạ độ bằng hai cột DECIMAL(10,7) cho lat (-90..90) và lng (-180..180); 7 chữ số thập phân cho độ chính xác tới centimet.
  • Tính khoảng cách giữa hai điểm bằng công thức Haversine (đường chim bay), hoặc ST_Distance nếu dùng PostGIS. Nhớ đây là đường chim bay, không phải quãng đường thực.
  • Tìm trong bán kính theo pattern hai lớp: lọc thô bằng bounding box (dùng index, nhanh) rồi lọc tinh bằng Haversine (chính xác).
  • Gom theo vùng hành chính chỉ cần GROUP BY trên cột district/province — đừng phức tạp hoá khi dữ liệu vùng đã có sẵn.
  • Tránh các lỗi kinh điển: đảo lat/lng, dùng Pythagoras, quên NULL, và tính Haversine trên toàn bảng.
Khi nắm chắc những điều này, bạn sẽ tự tin trả lời hàng loạt câu hỏi nghiệp vụ về vị trí — từ "tài xế gần nhất", "đơn trong vùng phục vụ", đến "quận nào nên đặt kho" — mà không phải chờ đợi ai. Ở những bài sau về truy vấn phân cấp và tối ưu, bạn sẽ thấy các kỹ thuật này kết hợp với nhau để giải những bài toán phân tích phức tạp hơn.