2023年5月30日
用 Elasticsearch 做地圖找房:geo_point、geo_bounding_box 與 geo_distance
為什麼地圖搜尋不適合用關聯式資料庫做
如果物件表上有 Lat、Lon 兩個欄位,「方框內的物件」可以寫成:
SELECT * FROM Houses
WHERE Lat BETWEEN 25.030 AND 25.048
AND Lon BETWEEN 121.550 AND 121.580
AND Price <= 3000 AND Rooms >= 3;
資料量小的時候沒什麼問題,但地圖搜尋的特性是:使用者每拖曳一次、縮放一次,就要重新查詢一次,還要疊上總價、房數、屋齡等篩選條件。兩個欄位的範圍條件很難同時有效利用一般的 B-Tree 索引;「某個點方圓一公里內」更需要計算距離,每一筆都算一次就是全表掃描。
Elasticsearch 的 geo_point 欄位專門為這種查詢設計,地理條件可以和其他篩選條件一起在索引層完成。基本的索引、Mapping 與 Query DSL 概念可以參考 Elasticsearch 入門 這篇。
geo_point:先把座標存對
Mapping 中把座標欄位宣告為 geo_point:
PUT /houses
{
"mappings": {
"properties": {
"title": { "type": "text" },
"price": { "type": "integer" },
"rooms": { "type": "integer" },
"location": { "type": "geo_point" }
}
}
}
寫入時,geo_point 支援好幾種格式,最容易出錯的是順序:
{ "location": { "lat": 25.0330, "lon": 121.5654 } } // 物件格式:清楚,推薦
{ "location": "25.0330,121.5654" } // 字串格式:lat,lon
{ "location": [121.5654, 25.0330] } // 陣列格式:lon, lat(順序相反!)
陣列格式遵循 GeoJSON 的慣例,是 [經度, 緯度],和字串格式剛好相反。如果以為陣列也是「緯度在前」而寫成 [25.0330, 121.5654],緯度就變成 121.5654——緯度只能介於 −90 到 90,這筆資料會直接寫入失敗;更麻煩的是兩個值剛好都在合法範圍內的情況,資料會寫入成功,但物件默默被放到地球的另一個地方。統一使用物件格式,可以完全避開這個問題。
Mapping 一旦建立,欄位型別就不能修改。如果座標已經用
float存進去了,必須建新索引、重新寫入資料,所以第一次建立索引時就要決定好。
geo_bounding_box:地圖可視範圍內的物件
地圖元件(Google Maps、Leaflet 等)都可以在拖曳、縮放結束後取得目前畫面的邊界。把左上角與右下角的座標送到後端,組成 geo_bounding_box 查詢:
GET /houses/_search
{
"size": 200,
"_source": ["id", "price", "location"],
"query": {
"bool": {
"filter": [
{
"geo_bounding_box": {
"location": {
"top_left": { "lat": 25.0480, "lon": 121.5500 },
"bottom_right": { "lat": 25.0300, "lon": 121.5800 }
}
}
},
{ "range": { "price": { "lte": 3000 } } },
{ "range": { "rooms": { "gte": 3 } } }
]
}
}
}
幾個重點:
- 所有條件都放在
filter:地理範圍與篩選條件都是「符合/不符合」,不需要計算相關性分數。放在filter中,Elasticsearch 會跳過評分,並且可以快取篩選結果 - 只回傳地圖需要的欄位:地圖上的價格標籤只需要 ID、價格和座標,用
_source過濾掉標題、描述、照片等大欄位,回應大小可以小好幾倍。使用者點了某個標籤,再查那一筆的詳細資料 - 限制回傳數量:使用者把地圖縮小到整個台北市時,框內可能有上萬筆物件。一次全部丟給前端,傳輸慢、地圖也畫不動(下面會談怎麼處理)
geo_distance:附近物件推薦
物件詳細頁的「附近物件」,是以目前物件為圓心,找出一定半徑內的其他物件,再依距離排序:
GET /houses/_search
{
"size": 6,
"query": {
"bool": {
"filter": [
{
"geo_distance": {
"distance": "1km",
"location": { "lat": 25.0330, "lon": 121.5654 }
}
}
],
"must_not": [
{ "term": { "id": "H-102938" } }
]
}
},
"sort": [
{
"_geo_distance": {
"location": { "lat": 25.0330, "lon": 121.5654 },
"order": "asc",
"unit": "m"
}
}
]
}
must_not排除目前這一筆物件自己,否則距離 0 公尺的它永遠排第一_geo_distance排序時,每一筆結果的sort欄位會帶回實際距離,前端可以直接顯示「距離 320 公尺」,不需要自己再算一次- 半徑內的物件太少時(例如郊區),可以在後端判斷筆數不足就放大半徑再查一次,避免推薦區塊空空的
縮小到整個城市時:用聚合代替點
地圖縮得越小,框內的物件越多。這時候在地圖上畫幾千個標籤,不但看不清楚,瀏覽器也會很吃力。常見的做法是依縮放層級切換顯示方式:
- 放大(街道層級):用
geo_bounding_box回傳個別物件,顯示價格標籤 - 縮小(行政區、城市層級):改用聚合,回傳「每一塊區域有幾筆物件」,在地圖上顯示數量泡泡
geotile_grid 聚合會依地圖圖磚(Tile)切分區域,precision 對應地圖的縮放層級:
GET /houses/_search
{
"size": 0,
"query": {
"geo_bounding_box": {
"location": {
"top_left": { "lat": 25.21, "lon": 121.45 },
"bottom_right": { "lat": 24.96, "lon": 121.67 }
}
}
},
"aggs": {
"grid": {
"geotile_grid": { "field": "location", "precision": 13 },
"aggs": {
"center": { "geo_centroid": { "field": "location" } }
}
}
}
}
geo_centroid 計算每一格內物件的中心點,把數量泡泡畫在物件實際集中的位置,而不是格子的正中央。另一種做法是依「行政區」這類 keyword 欄位做 terms 聚合,把泡泡畫在行政區的中心,對使用者來說更直覺。
使用情境:房仲平台的地圖找房
在房仲平台的買屋與成交行情頻道中,地圖找房的流程是:
- 使用者拖曳或縮放地圖,前端在動作結束後(加上 300ms 左右的 Debounce,避免拖曳過程中連續發出請求)送出目前的邊界座標與篩選條件
- 中台依縮放層級決定查詢方式:街道層級用
geo_bounding_box回傳物件;較大範圍改回傳聚合結果 - 回應只帶 ID、價格、座標,前端畫出價格標籤;點擊標籤時再查詢物件詳細資料
- 物件詳細頁以
geo_distance加上_geo_distance排序,推薦附近 1 公里內的物件
成交行情頻道用的是同一套做法,差別只在索引的內容是實價登錄的成交紀錄,標籤上顯示的是成交單價。
一句話總結
座標用 geo_point 存、而且統一用 { lat, lon } 物件格式避免經緯度寫反;地圖可視範圍用 geo_bounding_box、附近推薦用 geo_distance 搭配 _geo_distance 排序,條件全部放在 filter 並只回傳必要欄位;地圖縮得很小時,改用 geotile_grid 聚合回傳數量,而不是把上萬個點丟給前端。