Skip to content

Dữ liệu địa lý ​

Tìm cửa hàng gần nhất, tài xế trong bán kính 3 km, kiểm tra địa chỉ có nằm trong vùng giao hàng không: đều là query theo vị trí. Trang này giải thích cách lưu toạ độ, vì sao index thường không giúp được và spatial index làm gì, geohash, khi nào dùng Redis GEO hay PostGIS, và lỗi hay gặp nhất khi tính khoảng cách.

Lưu toạ độ ​

Vị trí trên Trái Đất thường lưu bằng vĩ độ và kinh độ theo hệ toạ độ WGS 84, mã SRID 4326, cũng là hệ GPS và Google Maps dùng. Database có kiểu dữ liệu không gian riêng:

  • POINT: một điểm, như vị trí cửa hàng.
  • POLYGON: một vùng khép kín, như vùng giao hàng hay ranh giới quận.
  • LINESTRING: một đường, như tuyến đường đi.
sql
-- MySQL 8.0
CREATE TABLE stores (
  id INT PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  location POINT NOT NULL SRID 4326,
  SPATIAL INDEX idx_location (location)
);
INSERT INTO stores VALUES (1, 'Hồ Gươm', ST_GeomFromText('POINT(21.0285 105.8542)', 4326));

Một điểm cần chú ý: với SRID 4326, MySQL đọc POINT(…) theo thứ tự vĩ độ trước, kinh độ sau, trong khi nhiều thư viện và định dạng GeoJSON dùng thứ tự ngược lại. Nhầm thứ tự thì cửa hàng ở Hà Nội sẽ nằm đâu đó ngoài Ấn Độ Dương. Lưu hai cột số lat, lng riêng cũng được nếu chỉ cần tìm theo bán kính đơn giản, nhưng sẽ không có spatial index và các hàm không gian.

Spatial index ​

Index B+Tree chỉ sắp theo một chiều. Index trên (lat, lng) tìm nhanh được một dải vĩ độ, nhưng trong dải đó vẫn phải lọc từng điểm theo kinh độ. Spatial index giải quyết việc tìm theo hai chiều cùng lúc. InnoDB dùng R-tree: mỗi nút trong cây là một hình chữ nhật bao quanh các điểm hay hình con của nó, nên query "điểm nào nằm trong khung này" chỉ cần đi vào những nhánh có hình chữ nhật chạm khung. PostGIS dùng GiST, một khung index tổng quát có thể dựng R-tree.

Spatial index chỉ giúp được khi query hỏi "nằm trong khung" hay "nằm trong hình". Hỏi thẳng "khoảng cách tới điểm X nhỏ hơn 2 km" bằng ST_Distance_Sphere(location, X) <= 2000 thì database phải tính khoảng cách cho từng dòng, vì index không biết khoảng cách. Cách đúng là làm hai bước:

sql
-- 1. Lọc thô bằng khung vuông bao quanh hình tròn (dùng spatial index)
-- 2. Lọc chính xác bằng khoảng cách thật trên số ít điểm còn lại
SELECT id, name, ST_Distance_Sphere(location, @center) AS meters
FROM stores
WHERE ST_Within(location, @box)                     -- @box: khung ±2 km quanh tâm
  AND ST_Distance_Sphere(location, @center) <= 2000
ORDER BY meters
LIMIT 20;

Khung vuông tính từ bán kính: 1 độ vĩ khoảng 111,32 km, còn 1 độ kinh khoảng 111,32 × cos(vĩ độ) km, tức ngắn dần khi đi về phía cực. Trên lab với 1 triệu cửa hàng, cách một bước mất khoảng 6 giây vì phải tính khoảng cách cho cả triệu dòng, còn cách hai bước chỉ khoảng 0,1 giây. Tài liệu MySQL còn lưu ý optimizer chỉ dùng spatial index cho cột có khai báo SRID, nên đừng bỏ SRID 4326 khỏi định nghĩa cột.

Geohash ​

Geohash biến một toạ độ thành một chuỗi ngắn, ví dụ Hồ Gươm là w7er8u. Cách tính là chia đôi luân phiên kinh độ và vĩ độ: điểm nằm nửa bên phải hay bên trái, nửa trên hay nửa dưới, mỗi lần ghi một bit, rồi cứ 5 bit thành một ký tự. Chuỗi càng dài thì ô càng nhỏ. 6 ký tự là ô khoảng 1,1 km × 0,6 km, 7 ký tự là khoảng 140 m × 150 m.

Cái lợi là các điểm gần nhau thường có chung tiền tố, nên chỉ cần một index B+Tree thường trên cột geohash: tìm lân cận là WHERE geohash LIKE 'w7er8%'. Cách này chạy được trên mọi database và mọi key–value store, không cần hỗ trợ không gian.

Cái bẫy là hai điểm sát nhau nhưng nằm hai bên đường chia ô có thể có tiền tố khác hẳn nhau. Vì vậy tìm lân cận luôn phải lấy ô chứa điểm và cả 8 ô xung quanh, rồi tính khoảng cách thật để lọc những điểm ở góc ô. Thử đặt hai điểm sát hai bên một đường chia ô:

Bấm lên bản đồ để đặt điểm A.
w7er8ugeohash điểm A
w7er8ugeohash điểm B
6 ký tựtiền tố chung
391 mkhoảng cách A–B
AB
ô chứa A8 ô xung quanhB nằm trong 9 ôB nằm ngoài 9 ô

Mỗi ký tự thêm vào chia ô ra 32 ô nhỏ hơn. Tìm cửa hàng gần A là lấy mọi điểm có geohash bắt đầu bằng mã của 9 ô (ô của A và 8 ô quanh nó), rồi tính khoảng cách thật để lọc. Thử đặt A và B sát hai bên một đường kẻ đậm.

Khung nhìn là trung tâm Hà Nội; đường kẻ là ranh giới ô ở độ dài đang chọn.

Redis GEO và PostGIS ​

Công cụLà gìHợp khi
Spatial index của MySQLR-tree, các hàm ST_* cơ bảnTìm theo bán kính, kiểm tra điểm trong vùng, dữ liệu ít thay đổi
Redis GEOLưu điểm trong sorted set, điểm số là geohash; GEOADD, GEOSEARCH … BYRADIUSVị trí thay đổi liên tục (tài xế, shipper cập nhật vài giây một lần), cần nhanh và chấp nhận mất khi Redis sập
PostGISExtension của PostgreSQL, hàng trăm hàm không gian, kiểu geography tính trên mặt cầu, index GiSTQuery phức tạp: giao, hợp vùng, tìm đường, dữ liệu bản đồ lớn

Một thiết kế hay gặp cho ứng dụng gọi xe: vị trí hiện tại của tài xế nằm trong Redis GEO, vì cập nhật rất nhiều và chỉ vị trí mới nhất có giá trị. Lịch sử chuyến đi và vùng hoạt động nằm trong database, vì cần lưu bền và query phức tạp hơn.

Lỗi hay gặp: tính khoảng cách bằng công thức phẳng ​

Coi kinh độ và vĩ độ như toạ độ trên mặt phẳng rồi dùng Pytago (sqrt(dlat² + dlng²) × 111 km) là sai, vì 1 độ kinh ngắn hơn 1 độ vĩ ở mọi nơi trừ xích đạo. Ở Hà Nội (vĩ độ 21), 1 độ kinh chỉ khoảng 0,93 lần 1 độ vĩ, nên khoảng cách theo hướng đông–tây bị tính dài hơn khoảng 7%. Lệch 7% với bán kính tìm kiếm 2 km nghĩa là bỏ sót hoặc lấy thừa những cửa hàng ở mép vùng.

Cách tínhSai số ở Hà Nội
Phẳng, coi 1 độ kinh bằng 1 độ vĩTới khoảng 7% theo hướng đông–tây
Phẳng, nhân kinh độ với cos(vĩ độ)Khoảng 0,1% với khoảng cách vài km
Trên mặt cầu (haversine, ST_Distance_Sphere)Sai số chỉ do coi Trái Đất là hình cầu thay vì hình bầu dục, dưới 0,5%

Với khoảng cách ngắn trong một thành phố, nhân thêm cos(vĩ độ) đã đủ chính xác. Với khoảng cách dài, gần cực, hay khi cần đúng tới từng mét thì dùng hàm của database: ST_Distance_Sphere (MySQL, bán kính mặc định 6.370.986 m) hoặc ST_Distance trên kiểu geography (PostGIS).

Ghi nhớ

  1. Lưu toạ độ bằng kiểu POINT với SRID 4326. Với SRID 4326, MySQL đọc POINT(vĩ_độ kinh_độ), ngược thứ tự với GeoJSON.
  2. Spatial index (R-tree) chỉ giúp query "nằm trong khung/vùng". Tìm theo bán kính thì lọc thô bằng khung vuông có index, rồi lọc chính xác bằng khoảng cách thật.
  3. Geohash cho phép tìm lân cận bằng index thường qua tiền tố, nhưng phải xét cả 8 ô xung quanh vì điểm sát nhau có thể khác tiền tố.
  4. Redis GEO cho vị trí thay đổi liên tục, PostGIS cho query không gian phức tạp.
  5. Đừng tính khoảng cách bằng Pytago trên độ: ở Hà Nội sai khoảng 7% theo hướng đông–tây. Nhân kinh độ với cos(vĩ độ), hoặc dùng hàm khoảng cách trên mặt cầu.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-23):

NguồnDùng cho phần
MySQL 8.0 Reference Manual, Creating Spatial IndexesSPATIAL INDEX tạo R-tree; optimizer chỉ dùng spatial index cho cột có giới hạn SRID
MySQL 8.0 Reference Manual, Spatial Convenience FunctionsST_Distance_Sphere, bán kính mặc định 6.370.986 m
Redis docs, GeospatialRedis GEO lưu trong sorted set, thêm mỗi điểm O(log N)

Kiểm bằng lab: thứ tự (vĩ độ, kinh độ) của POINT với SRID 4326 trên MySQL; geohash của Hồ Gươm và kích thước ô (tính bằng chính hàm trong mô phỏng, đã đối chiếu với ví dụ chuẩn u4pruydqqvj). Từ kiến thức chung, chưa đối chiếu lần này: PostGIS dùng GiST và kiểu geography; sai số của mô hình hình cầu so với hình bầu dục.

Số đo chi tiết ​

Đo bằng script lab/dia-ly/geo.mjs ngày 2026-10-23 trên MacBook M2 (MySQL 8.0 trong Docker). 1.000.000 cửa hàng: 800.000 rải đều trong khung bao Việt Nam, 200.000 dồn quanh trung tâm Hà Nội.

Tìm trong bán kính 2 km quanh Hồ Gươm
CáchKết quảThời gianEXPLAIN
Chỉ ST_Distance_Sphere(…) <= 20005.458 cửa hàng6.097 mstype=ALL, không dùng index
ST_Within(khung) rồi ST_Distance_Sphere5.458 cửa hàng117 mstype=range, key=idx_location

Trong khung vuông có 7.141 cửa hàng, trong đó 1.683 cái nằm ở bốn góc, ngoài hình tròn 2 km. Nạp 1 triệu dòng vào bảng có spatial index mất khoảng 3 phút.

Công thức phẳng so với ST_Distance_Sphere
Quãng đườngMặt cầuPhẳng, 1 độ kinh = 1 độ vĩPhẳng, nhân cos(vĩ độ)
Hà Nội → Hải Phòng89 km95 km (+6,8%)89 km (+0,1%)
Hà Nội → TP.HCM (gần như bắc–nam)1.144 km1.145 km (+0,1%)1.145 km (+0,1%)
Hồ Gươm → 2 km về phía đông2,00 km2,14 km (+7,3%)2,00 km (+0,1%)

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- dia-ly sau npm run lab:up, khoảng 3 phút. Xem Lab.

lab/dia-ly/geo.mjs
js
// Dữ liệu địa lý trên MySQL 8.0: 1.000.000 cửa hàng rải ngẫu nhiên trong khung bao Việt Nam, cột POINT SRID 4326 có SPATIAL INDEX.
//   1. Tìm cửa hàng trong bán kính 2 km quanh một điểm ở Hà Nội: ST_Distance_Sphere trên cả bảng (không dùng được index)
//      so với lọc trước bằng khung bao (ST_Within một polygon, dùng spatial index) rồi mới tính khoảng cách thật.
//   2. Sai số của công thức khoảng cách phẳng (coi kinh độ, vĩ độ như toạ độ phẳng) so với ST_Distance_Sphere.
import mysql from 'mysql2/promise'
import { MYSQL } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'

const N = 1_000_000
const ms = (v) => (v >= 100 ? Math.round(v).toLocaleString('vi-VN') : v.toFixed(1).replace('.', ',')) + ' ms'
async function best(fn, n = 3) { let m = Infinity, r; for (let i = 0; i < n; i++) { const t0 = performance.now(); r = await fn(); m = Math.min(m, performance.now() - t0) } return [m, r] }

ensureDb('mysql', 'geo')
const db = await mysql.createConnection({ ...MYSQL, database: 'geo' })
await db.query('DROP TABLE IF EXISTS stores')
await db.query(`CREATE TABLE stores (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL,
  location POINT NOT NULL SRID 4326, SPATIAL INDEX idx_location (location))`)
// Khung bao gần đúng của Việt Nam: vĩ độ 8,5–23,4, kinh độ 102,1–109,5. Thêm cụm dày ở Hà Nội để có nhiều kết quả gần.
let rnd = 42
const r = () => ((rnd = (rnd * 1103515245 + 12345) % 2147483648) / 2147483648)
const pts = Array.from({ length: N }, (_, i) => i % 5 === 0
  ? [21.0285 + (r() - 0.5) * 0.2, 105.8542 + (r() - 0.5) * 0.2]
  : [8.5 + r() * 14.9, 102.1 + r() * 7.4])
for (let i = 0; i < N; i += 5000) {
  // Với SRID 4326, MySQL đọc POINT theo thứ tự (vĩ độ, kinh độ)
  const vals = pts.slice(i, i + 5000).map(([lat, lng], k) => `(${i + k + 1}, 'store ${i + k + 1}', ST_GeomFromText('POINT(${lat} ${lng})', 4326))`).join(',')
  await db.query(`INSERT INTO stores VALUES ${vals}`)
}
await db.query('ANALYZE TABLE stores')

const LAT = 21.0285, LNG = 105.8542, R_M = 2000   // Hồ Gươm, bán kính 2 km
const center = `ST_GeomFromText('POINT(${LAT} ${LNG})', 4326)`
// Khung bao: 1 độ vĩ ≈ 111,32 km; 1 độ kinh ≈ 111,32 × cos(vĩ độ) km
const dLat = R_M / 111320, dLng = R_M / (111320 * Math.cos((LAT * Math.PI) / 180))
const box = `ST_GeomFromText('POLYGON((${LAT - dLat} ${LNG - dLng}, ${LAT + dLat} ${LNG - dLng}, ${LAT + dLat} ${LNG + dLng}, ${LAT - dLat} ${LNG + dLng}, ${LAT - dLat} ${LNG - dLng}))', 4326)`

console.log(`\n=== MySQL 8.0: ${N.toLocaleString('vi-VN')} cửa hàng, tìm trong bán kính 2 km quanh Hồ Gươm ===`)
const q1 = `SELECT COUNT(*) AS n FROM stores WHERE ST_Distance_Sphere(location, ${center}) <= ${R_M}`
const q2 = `SELECT COUNT(*) AS n FROM stores WHERE ST_Within(location, ${box}) AND ST_Distance_Sphere(location, ${center}) <= ${R_M}`
const [t1, [[a]]] = await best(() => db.query(q1))
const [t2, [[b]]] = await best(() => db.query(q2))
const [[e1]] = await db.query(`EXPLAIN ${q1}`), [[e2]] = await db.query(`EXPLAIN ${q2}`)
console.log(`Chỉ ST_Distance_Sphere: ${a.n} cửa hàng, ${ms(t1)}, EXPLAIN type=${e1.type} key=${e1.key}`)
console.log(`Khung bao (spatial index) + ST_Distance_Sphere: ${b.n} cửa hàng, ${ms(t2)}, EXPLAIN type=${e2.type} key=${e2.key} rows≈${e2.rows}`)
const [[{ inbox }]] = await db.query(`SELECT COUNT(*) AS inbox FROM stores WHERE ST_Within(location, ${box})`)
console.log(`Trong khung bao vuông có ${inbox} cửa hàng; ${inbox - b.n} cái nằm ở góc khung, ngoài hình tròn 2 km`)

console.log('\n=== Công thức phẳng so với khoảng cách trên mặt cầu (ST_Distance_Sphere) ===')
const flatDeg = (la1, lo1, la2, lo2) => Math.hypot(la2 - la1, lo2 - lo1) * 111320           // coi 1 độ kinh = 1 độ vĩ
const flatCos = (la1, lo1, la2, lo2) => Math.hypot(la2 - la1, (lo2 - lo1) * Math.cos((((la1 + la2) / 2) * Math.PI) / 180)) * 111320
for (const [name, la, lo] of [['Hà Nội → Hải Phòng', 20.8449, 106.6881], ['Hà Nội → TP.HCM', 10.7769, 106.7009], ['Hồ Gươm → 2 km về phía đông', LAT, LNG + dLng]]) {
  const [[{ d }]] = await db.query(`SELECT ST_Distance_Sphere(${center}, ST_GeomFromText('POINT(${la} ${lo})', 4326)) AS d`)
  const f1 = flatDeg(LAT, LNG, la, lo), f2 = flatCos(LAT, LNG, la, lo)
  const km = (m) => (m / 1000).toFixed(m < 10000 ? 2 : 0).replace('.', ',') + ' km'
  const pc = (x) => ((x / d - 1) * 100).toFixed(1).replace('.', ',') + '%'
  console.log(`${name.padEnd(30)} mặt cầu ${km(d).padStart(9)} | phẳng, 1 độ kinh = 1 độ vĩ: ${km(f1)} (${pc(f1)}) | phẳng có nhân cos(vĩ độ): ${km(f2)} (${pc(f2)})`)
}
await db.end()