你知道怎麼用 Google 搜尋,但你有沒有想過「搜尋引擎的資料是怎麼進去的」?

Google 有爬蟲。Elasticsearch 沒有——你得自己把資料餵進去。而且不只是餵進去就好,還得告訴它每筆資料長什麼樣、哪些欄位要能搜、哪些只要精確比對、哪些需要排序。

這就像開餐廳。菜單不是隨便一張紙——你得決定每道菜的名稱格式(文字還是編號)、價格精度(整數還是小數)、分類方式(依口味還是依烹飪法)。這些決定一旦做了就很難改,改了就得整本菜單重印。

Elasticsearch 的 Mapping 就是這本菜單的格式定義。今天從開菜單開始,教到點菜、上菜、換菜、退菜的全套流程。


開菜單:Index 建立與 Mapping

建立一個 Index 等於在 Elasticsearch 裡開一本新菜單。最簡單的方式一行就搞定:

1
PUT /menu

這樣 ES 會用預設值——1 個 primary shard、1 個 replica。正式環境通常要自己設:

1
2
3
4
5
6
7
8
PUT /menu
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"refresh_interval": "5s"
}
}

用 PUT 不是 POST。PUT 是冪等的——打一百次結果一樣。Index 已存在的話會回 400 resource_already_exists_exception

Mapping:每道菜的營養標示

Mapping 定義了每個欄位的資料型別和索引方式。不定義的話,ES 會在第一筆資料寫入時自動推斷——字串同時建 text + keyword 子欄位、數字推成 long、長得像日期的推成 date。方便歸方便,但推錯了就回不去。Mapping 一旦建立,欄位型別不能改。

正式環境一律手動定義:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PUT /menu
{
"mappings": {
"properties": {
"dish_name": { "type": "text", "analyzer": "standard" },
"category": { "type": "keyword" },
"price": { "type": "integer" },
"description":{ "type": "text" },
"tags": { "type": "keyword" },
"created_at": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||epoch_millis" },
"is_spicy": { "type": "boolean" }
}
}
}

這裡最容易搞混的是 textkeyword 的差別。text 會經過 Analyzer 分詞,適合全文搜尋——「鼎泰豐招牌小籠包」會被拆成「鼎泰豐」「招牌」「小籠包」幾個 token。keyword 不分詞,整串存、整串比對,適合精確匹配、排序、聚合。ES 預設對字串兩個都建:field 是 text,field.keyword 是 keyword。

Mapping 只能加欄位,不能改已有的。如果 price 不小心設成 text 想改成 integer,唯一的辦法是建新 Index → Reindex 搬資料 → 用 Alias 切換。文章最後會教這個。


點菜上菜換菜退菜:CRUD 四大操作

SQL 有 INSERT / SELECT / UPDATE / DELETE,Elasticsearch 用 RESTful API 對應。

新增(Create)

1
2
3
4
5
6
7
8
9
POST /menu/_doc
{
"dish_name": "小籠包",
"category": "點心",
"price": 220,
"description": "薄皮多汁的經典",
"tags": ["招牌", "蒸類"],
"is_spicy": false
}

POST 自動產生 _id。想自己指定就用 PUT:

1
2
PUT /menu/_doc/xiaolongbao
{ ... }

踩坑提醒:PUT 指定 ID 時,如果 ID 已存在會覆蓋整份文件,不是更新。要嚴格只在不存在時新增,用 _create endpoint——ID 已存在會回 409。

讀取(Read)

1
2
3
4
5
GET /menu/_doc/xiaolongbao           // 單筆
GET /menu/_source/xiaolongbao // 只要 _source
GET /menu/_mget // 批次取多筆
{ "ids": ["xiaolongbao", "friedrice"] }
HEAD /menu/_doc/xiaolongbao // 只確認存不存在(200 or 404)

更新(Update)

ES 沒有真正的 in-place update。底層永遠是「標記刪除舊版 → 寫入新版」。但 _update API 省了你手動 GET + 修改 + 重新 index 的麻煩:

1
2
3
4
5
6
7
POST /menu/_update/xiaolongbao
{
"doc": {
"price": 250,
"tags": ["招牌", "蒸類", "漲價了"]
}
}

需要做計算的話用 Painless script:

1
2
3
4
5
6
7
POST /menu/_update/xiaolongbao
{
"script": {
"source": "ctx._source.price += params.increase",
"params": { "increase": 30 }
}
}

還有一個很實用的 upsert——存在就更新,不存在就新增:

1
2
3
4
5
POST /menu/_update/hotpot
{
"doc": { "dish_name": "麻辣鍋", "price": 580 },
"doc_as_upsert": true
}

刪除(Delete)

1
2
3
DELETE /menu/_doc/xiaolongbao          // 單筆
POST /menu/_delete_by_query // 條件批次刪除
{ "query": { "match": { "category": "已下架" } } }

delete_by_query 底層是 scroll + bulk delete,大量刪除可能很慢。而且刪除只是標記——磁碟空間要等 Segment merge 才真正釋放。


效能大殺器:_bulk 批次處理

真實世界不會一筆一筆塞資料。100 萬筆商品 × 單筆 API = 100 萬次 HTTP 請求。_bulk 讓你一次請求處理上千筆,效能提升 10 到 100 倍不誇張。

語法用 NDJSON(Newline Delimited JSON)——每行一個 JSON,不是 JSON Array:

1
2
3
4
5
6
7
8
9
10
11
POST _bulk
{"index": {"_index": "menu", "_id": "1"}}
{"dish_name": "小籠包", "category": "點心", "price": 220}
{"index": {"_index": "menu", "_id": "2"}}
{"dish_name": "蝦仁蛋炒飯", "category": "飯類", "price": 280}
{"create": {"_index": "menu", "_id": "3"}}
{"dish_name": "紅油抄手", "category": "點心", "price": 200}
{"update": {"_index": "menu", "_id": "1"}}
{"doc": {"price": 250}}
{"delete": {"_index": "menu", "_id": "99"}}

四種操作混著用:index(新增或覆蓋)、create(嚴格新增)、update(部分更新)、delete(刪除,不需要下一行 body)。

_bulk 五條鐵律

最後一行必須以換行結尾。 少了這個換行,最後一筆會被靜默忽略。Kibana Dev Tools 不會有這問題,但 curl 要注意。

單次建議 5-15 MB。 太小浪費 HTTP overhead,太大壓垮 coordinating node。100 MB 以上容易 OOM。

回應要逐筆檢查。 _bulk 不是全有或全無——其中一筆失敗,其他照跑。你的程式碼必須解析 response 的 errors 欄位。

大量匯入前關掉 refresh。refresh_interval: -1 + number_of_replicas: 0,匯完再改回來,效能提升 30-50%。

匯完記得 forcemerge。 大量寫入會產生一堆小 Segment,手動跑一次 POST /menu/_forcemerge?max_num_segments=5 整理一下。


Search API 入門:Query DSL 基本功

CRUD 是基本功,搜尋才是 Elasticsearch 的核心。SQL 用 WHERE,ES 用 Query DSL——基於 JSON 的查詢語法。

match:全文搜尋

1
2
3
4
5
6
7
8
GET /menu/_search
{
"query": {
"match": {
"description": "招牌 多汁"
}
}
}

預設是 OR——包含「招牌」或「多汁」都命中。要 AND 的話加 "operator": "and"

term:精確比對

1
2
3
4
5
6
GET /menu/_search
{
"query": {
"term": { "category": "點心" }
}
}

term 只能用在 keyword 欄位。拿它去查 text 欄位會踩坑——「小籠包」被分詞成 [“小”, “籠”, “包”],你 term 搜「小籠包」反而搜不到,因為原始值已經不存在了。

bool:組合查詢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET /menu/_search
{
"query": {
"bool": {
"must": [
{ "term": { "category": "點心" } }
],
"must_not": [
{ "term": { "is_spicy": true } }
],
"filter": [
{ "range": { "price": { "lte": 250 } } }
]
}
}
}

四個子句各有分工:must(AND,算分)、should(OR,影響排名)、must_not(NOT,不算分)、filter(AND,不算分但可快取)。

效能黃金法則:不需要相關性排名的條件,一律放 filter。不算分 = 更快 + 結果可被 ES 快取。

分頁

1
2
3
4
5
6
7
GET /menu/_search
{
"query": { "match_all": {} },
"sort": [{ "price": "asc" }],
"from": 5,
"size": 5
}

from + size 有上限,預設 10,000。深分頁要改用 search_after 或 scroll API——後面的模組會教。


Alias:零停機的秘密武器

Mapping 設錯了怎麼辦?如果程式碼寫死 Index 名稱,你就得改程式碼 + 重新部署。但如果從第一天就用 Alias(別名),換 Index 只要一個 API,程式碼零改動。

Alias 就是 Index 的暱稱。程式碼永遠只跟 menu 溝通,背後指向 menu_v1 還是 menu_v2,你隨時可以切。

1
2
3
4
5
6
POST /_aliases
{
"actions": [
{ "add": { "index": "menu_v1", "alias": "menu" } }
]
}

原子切換

一個請求同時移除舊的、加上新的,中間零停機:

1
2
3
4
5
6
7
POST /_aliases
{
"actions": [
{ "remove": { "index": "menu_v1", "alias": "menu" } },
{ "add": { "index": "menu_v2", "alias": "menu" } }
]
}

actions 陣列裡的操作是原子的——全部成功或全部失敗,不會出現「舊的移除了但新的還沒加」的空窗期。

帶 Filter 的 Alias

同一個 Index 可以用 filter 建立不同視角:

1
2
3
4
5
6
7
8
9
10
11
12
POST /_aliases
{
"actions": [
{
"add": {
"index": "menu_v2",
"alias": "menu_spicy",
"filter": { "term": { "is_spicy": true } }
}
}
]
}

menu_spicy 自動只看辣的菜。不用改查詢邏輯,Index 層面就過濾好了。

完整 Reindex + Alias 切換 SOP

這是業界標準的 Mapping 修改流程:

  1. 建新 Index(正確的 Mapping)
  2. _reindex 從舊 Index 搬資料
  3. 原子切換 Alias
  4. 確認無誤後刪舊 Index
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PUT /menu_v2
{ "mappings": { "properties": { "price": { "type": "float" } } } }

POST /_reindex
{ "source": { "index": "menu_v1" }, "dest": { "index": "menu_v2" } }

POST /_aliases
{
"actions": [
{ "remove": { "index": "menu_v1", "alias": "menu" } },
{ "add": { "index": "menu_v2", "alias": "menu" } }
]
}

DELETE /menu_v1

整個過程對使用者完全無感。這就是為什麼 Alias 應該從第一天就用——不是因為你現在需要切換,是因為你不知道什麼時候會需要。


回到最初那個比喻。開餐廳,菜單格式(Mapping)決定了後面所有操作的效率和彈性。格式定錯了,後面每一步都在補救。格式定對了,CRUD 順暢、_bulk 高效、搜尋精準。

而 Alias 是你的保險——就算菜單真的定錯了,你還是能零停機重印一本新的。唯一的前提是你從一開始就用暱稱點菜,而不是直接喊菜單的印刷編號。

這個系列的下一篇會鑽進 Elasticsearch 的身體裡,看寫入和搜尋在底層到底是怎麼運作的——Translog、Segment、Merge,那些決定效能天花板的機制。

本文改寫自 Elasticsearch 課程筆記,所有 API 範例可直接在 Kibana Dev Tools 中執行。