Databases & SQL · پایگاهداده و SQL سنیورSenior ~83 دقیقه مطالعه~74 min read
Elasticsearch و معماری جستوجوElasticsearch & Search Architecture
از index معکوس و تحلیل متن تا BM25، شاردینگ، ILM و همگامسازی با CDC و outbox — همهچیزی که برای طراحی، تنظیم و نگهداری یک لایهٔ جستوجوی واقعی روی Elasticsearch لازم داری.From the inverted index and text analysis to BM25, sharding, ILM and CDC/outbox synchronisation — everything you need to design, tune and operate a real Elasticsearch-backed search layer.
پیشنیاز:Prerequisites: Redis، ClickHouse، ScyllaDB و ElasticsearchRedis, ClickHouse, ScyllaDB & Elasticsearch
تقریباً هر محصولی که میسازی یک روز به «جستوجو» میرسد: یک کادر ساده که کاربر داخلش تایپ میکند و انتظار دارد بهترین نتیجهها بالا بیایند — حتی با غلط املایی، حتی وقتی جمع و مفرد را اشتباه نوشته، حتی وقتی فقط بخشی از عبارت را به یاد میآورد. پشت آن کادر ساده یک دنیای مهندسی است: تحلیل متن، index معکوس، رتبهبندی، شاردینگ، و معماری همگامسازی که داده را از پایگاهدادهٔ اصلی به موتور جستوجو میرساند بدون اینکه چیزی گم شود.
این فصل تو را از «چرا LIKE '%x%' جستوجو نیست» میبرد تا «چطور یک خوشهٔ چندترابایتی را sizing کنم و با CDC همگام نگه دارم».
۱. چرا جستوجو با کوئری رابطهای فرق دارد و index معکوس دقیقاً چیست.
۲. Analysis: char filter، tokenizer، token filter، stemming، stopword، synonym، ngram — و متن فارسی.
۳. Mapping: dynamic در برابر explicit، text در برابر keyword، multi-field، nested در برابر object.
۴. Query DSL: match، term، bool، query context در برابر filter context، fuzziness، highlight.
۵. Autocomplete و suggester، و صفحهبندی عمیق با search_after و PIT.
۶. رتبهبندی: BM25، boost، function_score و دیباگ امتیاز با _explain.
۷. Aggregationها بهعنوان نیمهٔ تحلیلی موتور.
۸. مکانیک خوشه: node role، shard، query-then-fetch، refresh و flush و merge، ILM و sizing.
۹. Elastic Stack، کلاینت Java و Spring Data Elasticsearch.
۱۰. همگامسازی: dual write در برابر CDC در برابر outbox — و چرا Elasticsearch منبع حقیقت نیست.
۱. چرا LIKE جستوجو نیست
یک کتاب ۹۰۰ صفحهای را تصور کن و میخواهی بدانی «transaction isolation» کجا توضیح داده شده. یا از صفحهٔ ۱ شروع میکنی و همه را میخوانی — کاری که LIKE '%transaction isolation%' میکند — یا به نمایهٔ انتهای کتاب میروی: فهرستی الفبایی از کلمهها و جلوی هرکدام شمارهٔ صفحهها. یک نگاه، و جواب داری.
آن نمایهٔ انتهای کتاب همان inverted index است. کل Elasticsearch روی همین یک ایده بنا شده.
یک index از نوع B-tree روی title مثل دفترچهتلفن مرتبشده است: میگوید کدام رکوردها با «data» شروع میشوند، اما نه اینکه کدامها این کلمه را در وسط دارند. % در ابتدای الگو index را بیاثر میکند و به full scan میرسی — روی ۱۰ میلیون ردیف یعنی ثانیهها زمان بهازای هر بار کلید فشردن کاربر.
اما مشکل بزرگتر performance نیست. LIKE پاسخ باینری میدهد: یا هست یا نیست. جستوجو ذاتاً باینری نیست؛ یعنی «کدامها بیشتر مرتبطاند» — یعنی رتبهبندی. ضمناً LIKE روی کاراکتر کار میکند نه کلمه: «cat» داخل «concatenate» match میشود ولی «running» با «run» نه.
پایگاهدادههای رابطهای full-text search دارند و برای بارهای کوچک و متوسط کاملاً کافیاند:
CREATE INDEX idx_articles_fts
ON articles USING GIN (to_tsvector('english', title || ' ' || body));
SELECT id,
ts_rank(to_tsvector('english', title || ' ' || body),
plainto_tsquery('english', 'database index')) AS rank
FROM articles
WHERE to_tsvector('english', title || ' ' || body)
@@ plainto_tsquery('english', 'database index')
ORDER BY rank DESC
FETCH FIRST 10 ROWS ONLY;-- Oracle Text: یک domain index از نوع CONTEXT
CREATE INDEX idx_articles_fts ON articles(body)
INDEXTYPE IS CTXSYS.CONTEXT;
SELECT id, SCORE(1) AS rank
FROM articles
WHERE CONTAINS(body, 'database AND index', 1) > 0
ORDER BY SCORE(1) DESC
FETCH FIRST 10 ROWS ONLY;اگر جستوجویت روی چند صد هزار ردیف است و رتبهبندی خیلی مهم نیست، tsvector یا Oracle Text کافی است و یک سیستم کمتر برای نگهداری داری. Elasticsearch را وقتی بیاور که relevance قابلتنظیم بخواهی (boost فیلدها، phrase، synonym، تحمل غلط املایی)، یا facet و aggregation تعاملی، یا دهها میلیون سند شاردشده روی چند node، یا autocomplete زیر ۵۰ میلیثانیه. معیار عملی: اگر داری در SQL چند LIKE و ORDER BY دستی مینویسی تا relevance را تقلید کنی، وقتش رسیده.
سه دلیل. اجرا: الگوی '%x%' نمیتواند از index استفاده کند و هزینه خطی با اندازهٔ جدول رشد میکند. معناشناسی: LIKE تطبیق زیررشتهای روی کاراکترهاست نه کلمه — «cat» در «concatenate» match میشود و «running» با «run» نمیشود چون stemming نداریم. خروجی: LIKE بولین برمیگرداند در حالی که جستوجو یک لیست رتبهبندیشده میخواهد.
جواب کامل اضافه میکند که راه میانی هم هست: to_tsvector با GIN یا Oracle Text. Elasticsearch را وقتی انتخاب میکنیم که به relevance قابلتنظیم، aggregation تعاملی، مقیاس افقی یا autocomplete نیاز داشته باشیم — نه صرفاً چون داده متن است.
۲. Inverted index از صفر
سه سند داریم: سند ۱ برابر the quick brown fox، سند ۲ برابر the quick blue car، و سند ۳ برابر brown fox jumps over the lazy dog. هر متن را به term میشکنیم و بعد جدول را «وارونه» میکنیم — بهجای «سند ← کلمهها»، مینویسیم «کلمه ← سندها»:
| term | postings list با قالب docId: tf [positions] |
|---|---|
brown |
1:1 [2], 3:1 [0] |
car |
2:1 [3] |
fox |
1:1 [3], 3:1 [1] |
jumps |
3:1 [2] |
quick |
1:1 [1], 2:1 [1] |
the |
1:1 [0], 2:1 [0], 3:1 [4] |
اصطلاحها را از صفر بسازیم:
- term: واحد اتمی جستوجو بعد از تحلیل متن. لزوماً «کلمه» نیست؛ هرچه analyzer تولید کند term است.
- postings list: فهرست مرتبِ شناسهٔ سندهایی که آن term را دارند. مرتب بودن حیاتی است، چون اشتراک و اجتماع دو لیست مرتب با یک پیمایش خطی انجام میشود (فصلهای core-ds و complexity).
- term frequency یا
tf: چند بار آن term در آن سند آمده — مبنای «این سند بیشتر دربارهٔ این کلمه است». - document frequency یا
df: چند سند این term را دارند.theدر همهٔ سندهاست پس بیارزش؛jumpsنادر است پس پرارزش. - positions: جای term در سند. بدون این،
match_phraseممکن نیست.
کوئری brown fox یعنی اشتراک {1,3} با {1,3}. هزینهاش وابسته به تعداد سندهای حاوی این کلمهها است، نه به کل سندهای index — همین چیزی است که جستوجو را مقیاسپذیر میکند.
نمودار زیر مدل ذهنی index معکوس را نشان میدهد — Inverted index model: the term dictionary points to postings lists.
flowchart LR
subgraph Dict["Term dictionary (sorted, on disk)"]
T1["brown"]
T2["fox"]
end
subgraph Post["Postings"]
P1["doc1 tf=1 pos=2 | doc3 tf=1 pos=0"]
P2["doc1 tf=1 pos=3 | doc3 tf=1 pos=1"]
end
T1 --> P1
T2 --> P2
Q["Query: brown AND fox"] --> T1
Q --> T2
P1 --> M["Intersect sorted lists"]
P2 --> M
M --> R["Hits: doc1, doc3"]
Elasticsearch خودش index معکوس را پیاده نکرده؛ روی Apache Lucene سوار است. هر shard در واقع یک index کامل Lucene است و هر index Lucene از چند segment ساخته شده. segment یک بستهٔ فایلِ تغییرناپذیر است: وقتی نوشته شد دیگر عوض نمیشود.
تغییرناپذیری سه چیز مجانی میدهد: خواندن همزمان بدون قفل، کششدن مطمئن در page cache سیستمعامل، و امکان فشردهسازی سنگین. هزینهاش این است که «بهروزرسانی» وجود ندارد — update یعنی سند قدیمی را در یک bitmap «حذفشده» علامت بزن و نسخهٔ جدید را در segment تازه بنویس. فضای سند قدیمی فقط موقع merge آزاد میشود.
در کنار index معکوس، Lucene ساختار دوم هم میسازد: doc values، که ستونی است و برای sort، aggregation و script استفاده میشود.
| ساختار | جهت | مصرف | پیشفرض |
|---|---|---|---|
| inverted index | از term به سند | فیلتر و جستوجو | برای text و keyword روشن |
| doc values | از سند به مقدار | sort، aggregation، script | برای keyword و عددی و تاریخ روشن، برای text خاموش |
stored fields و _source |
از سند به JSON اصلی | برگرداندن نتیجه، highlight، reindex | _source روشن |
وسوسه میشوی برای صرفهجویی دیسک "_source": {"enabled": false} بگذاری؛ نکن. با این کار reindex، update، highlight و بازسازی سند از دست میرود — دقیقاً کارهایی که روز حادثه لازمشان داری. اگر اندازه اذیتت میکند، اول سراغ "codec": "best_compression" یا _source.excludes برو.
نگاشتی است از هر term به یک postings list مرتب از سندهای حاوی آن term، بههمراه tf برای امتیازدهی و positions برای عبارتیابی. چون لیستها مرتباند، AND و OR بین چند term با یک پیمایش خطی و پرشهای skip-list انجام میشود؛ پس هزینهٔ کوئری با تعداد سندهای حاوی آن term رشد میکند نه با اندازهٔ کل مجموعه.
نکتهٔ تکمیلی که مصاحبهگر دنبالش است: خودِ term dictionary هم روی دیسک مرتب و فشرده ذخیره میشود (در Lucene با یک FST) تا جستوجوی term تقریباً متناسب با طول term باشد، و ساختار مکمل doc values برای sort و aggregation وجود دارد چون index معکوس برای «مقدار این سند چیست» ابزار بدی است.
۳. Analysis — قلب واقعی کیفیت جستوجو
متن خام مثل مادهٔ خامی است که وارد یک خط تولید سهایستگاهی میشود: char filter روی رشتهٔ خام دست میبرد (تگ HTML را حذف میکند، ي عربی را به ی نگاشت میکند)؛ tokenizer رشته را به قطعهها میشکند — دقیقاً یکی، نه بیشتر؛ و token filter روی هر قطعه کار میکند (کوچک میکند، ریشه میگیرد، حذف میکند، مترادف اضافه میکند).
خروجی خط تولید همان term هایی است که در index معکوس نوشته میشوند. هر چیزی که این خط تولید نسازد، هرگز پیدا نمیشود.
نمودار زیر خط تولید تحلیل متن است — Text analysis pipeline: raw text becomes indexed terms.
flowchart LR
A["Raw field value"] --> B["Character filters (0..n)"]
B --> C["Tokenizer (exactly 1)"]
C --> D["Token filters (0..n)"]
D --> E["Terms written to inverted index"]
Q["Query string"] --> B2["Same analyzer at search time"]
B2 --> F["Query terms"]
E --> G["Match only if terms are identical"]
F --> G
مهمترین ابزار دیباگ جستوجو _analyze است. قبل از هر حدسی، اجرایش کن:
POST /_analyze
{
"analyzer": "english",
"text": "The Foxes were quickly Running through 3 Databases!"
}
خروجی term هایی مثل fox، quickli، run، 3، databas میدهد. سه چیز را ببین: The و were حذف شدهاند (stop word)؛ Foxes به fox و Databases به databas رسیده (stemming — ریشهگیری الگوریتمی که لزوماً کلمهٔ واقعی نمیسازد)؛ و همهچیز کوچک شده. بهجای نام analyzer میتوانی اجزا را جدا هم بدهی: "char_filter": ["html_strip"], "tokenizer": "standard", "filter": ["lowercase","asciifolding"].
| جزء | نمونهها | کارش |
|---|---|---|
| char filter | html_strip، mapping، pattern_replace |
دستکاری رشتهٔ خام قبل از شکستن |
| tokenizer | standard (UAX#29، پیشفرض)، keyword (بدون شکستن)، whitespace، pattern، ngram، edge_ngram، path_hierarchy |
شکستن رشته به token — دقیقاً یکی |
| token filter | lowercase، asciifolding، stop، stemmer، synonym_graph، shingle، decimal_digit، unique |
تغییر، حذف یا افزودن token |
| analyzer آماده | standard (پیشفرض کل سیستم، stop word حذف نمیکند)، keyword، simple، stop، و زبانیها مثل english و persian |
ترکیب سه لایهٔ بالا |
| normalizer | فقط char filter و token filter، بدون tokenizer | برای فیلد keyword |
stop word کلمههای پرتکرار و کماطلاعاند (the، and، «از»، «که»)؛ حذفشان index را کوچک میکند اما match_phrase را میشکند — اگر the حذف شود، عبارت «to be or not to be» عملاً خالی میشود. synonym برای وقتی است که کاربر «موبایل» بنویسد و تو میخواهی «گوشی» هم پیدا شود:
PUT /products
{
"settings": { "analysis": {
"filter": { "product_synonyms": { "type": "synonym_graph", "synonyms": [
"laptop, notebook",
"cellphone, mobile phone, smartphone => phone" ] } },
"analyzer": {
"product_index": { "tokenizer": "standard", "filter": ["lowercase"] },
"product_search": { "tokenizer": "standard", "filter": ["lowercase", "product_synonyms"] }
} } },
"mappings": { "properties": {
"name": { "type": "text", "analyzer": "product_index", "search_analyzer": "product_search" } } }
}
قاعدهٔ نوشتن: a, b یعنی همارزی دوطرفه؛ a, b => c یعنی همهٔ سمت چپ به c بازنویسی میشوند. synonym را در search_analyzer بگذار نه در زمان index — وگرنه هر تغییر در فهرست مترادفها یعنی reindex کامل. synonym_graph برخلاف synonym قدیمی عبارتهای چندکلمهای را درست مدیریت میکند و برای زمان search طراحی شده؛ فهرست را با synonyms set API بیرون از mapping نگه دار تا بدون deploy قابل تغییر باشد.
متن هم موقع نوشتن در index تحلیل میشود و هم موقع کوئری. اگر این دو ناسازگار باشند، term ها بر هم منطبق نمیشوند و نتیجه صفر است — بدون هیچ خطایی.
مثال واقعی: برای autocomplete یک edge_ngram analyzer میسازی و همان را برای search هم میگذاری. کاربر «لپتاپ» تایپ میکند؛ کوئری هم ngram میشود: ل، لپ، لپ… و حالا هر سندی که فقط حرف «ل» دارد match میشود. راه درست: ngram فقط در زمان index و در زمان search analyzer عادی. قانون کلی: analyzer دو طرف باید سازگار باشند، نه لزوماً یکسان؛ و هر ناسازگاری باید عمدی و مستند باشد.
edge_ngram از ابتدای کلمه پیشوندها را میسازد: از search میشود se، sea، sear، searc، search. ngram معمولی همهٔ زیررشتهها را میسازد و برای «شامل بودن» بهکار میرود.
PUT /catalog
{
"settings": {
"index": { "max_ngram_diff": 18 },
"analysis": {
"tokenizer": { "edge_2_20": { "type": "edge_ngram", "min_gram": 2, "max_gram": 20,
"token_chars": ["letter", "digit"] } },
"analyzer": { "autocomplete_index": { "tokenizer": "edge_2_20", "filter": ["lowercase"] } }
}
},
"mappings": { "properties": {
"name": { "type": "text", "analyzer": "autocomplete_index", "search_analyzer": "standard" } } }
}
پیشفرض index.max_ngram_diff برابر ۱ است و index.max_shingle_diff برابر ۳؛ عمداً کوچکاند تا جلوی انفجار را بگیرند. با min_gram: 1 و max_gram: 20 یک کلمهٔ ۲۰ حرفی حدود ۲۰ term تولید میکند و اندازهٔ index چند برابر میشود؛ حافظه و زمان merge هم بالا میرود. min_gram را کمتر از ۲ نگذار و max_gram را به طول واقعی پیشوند مفید محدود کن.
متن فارسی و زبانهای غیرانگلیسی
فارسی سه مشکل مشخص دارد: نیمفاصله یا ZWNJ با کد U+200C که «میرود» و «میرود» و «می رود» را سه رشتهٔ متفاوت میکند؛ کاراکترهای همشکل عربی و فارسی مثل ي در برابر ی و ك در برابر ک؛ و ارقام فارسی و عربی و لاتین. analyzer آمادهٔ persian دقیقاً همینها را پوشش میدهد و تعریف رسمیاش این است:
PUT /articles_fa
{
"settings": { "analysis": {
"char_filter": { "zero_width_spaces": { "type": "mapping", "mappings": [ "\\u200C=>\\u0020" ] } },
"filter": { "persian_stop": { "type": "stop", "stopwords": "_persian_" } },
"analyzer": {
"rebuilt_persian": {
"tokenizer": "standard",
"char_filter": [ "zero_width_spaces" ],
"filter": [ "lowercase", "decimal_digit", "arabic_normalization",
"persian_normalization", "persian_stop", "persian_stem" ]
}
} } }
}
zero_width_spaces نیمفاصله را به فاصله تبدیل میکند تا «میرود» به دو token بشکند؛ decimal_digit هر رقم Unicode را به لاتین میبرد؛ دو normalization شکلهای عربی را یکدست میکنند؛ و persian_stem ریشهگیری فارسی انجام میدهد.
قضاوت senior برای فارسی: persian نقطهٔ شروع خوبی است اما کافی نیست. روی فیلدهای کوتاه و حساس مثل نام محصول و نام شخص، هم نسخهٔ stem شده و هم نسخهٔ خام را نگه دار (multi-field) و در multi_match به نسخهٔ خام boost بیشتری بده؛ stemming فارسی برای اسم خاص گاهی خرابکاری میکند. و اگر با چند زبان سروکار داری افزونهٔ analysis-icu را نصب کن: icu_normalizer و icu_folding پوشش Unicode بسیار وسیعتری از asciifolding دارند.
چون تطبیق روی term انجام میشود نه روی رشتهٔ خام. اگر موقع index کلمهٔ Running به term run تبدیل شده باشد ولی موقع کوئری نشود، کوئری دنبال term ای میگردد که در index وجود ندارد و نتیجه صفر است — بدون خطا، که بدترین حالت است چون بیسروصدا خراب میشود.
استثنای عمدی: در autocomplete با edge_ngram عمداً analyzer دو طرف را فرق میدهیم، چون در غیر اینصورت پیشوندهای کوئری هم شکسته میشوند و دقت از بین میرود. قانون درست این است که analyzer ها باید سازگار باشند و هر ناسازگاری عمدی و مستند باشد؛ ابزار اثباتش هم _analyze است، نه حدس.
۴. Mapping — شِمای Elasticsearch
mapping یعنی تعریف اینکه هر فیلد چه نوعی دارد و چطور تحلیل و ذخیره میشود. معادل CREATE TABLE است، با یک تفاوت بزرگ: اگر ننویسی، Elasticsearch خودش حدس میزند — رشته میشود text بهعلاوهٔ زیرفیلد keyword با ignore_above: 256، عدد صحیح long، اعشاری float، و رشتهٔ شبیهتاریخ date.
سه فاجعهٔ رایج: (۱) mapping explosion — سرویسی کلیدهای پویا مثل attrs.user_1234 میفرستد و هر کلید یک فیلد جدید میشود؛ سقف پیشفرض index.mapping.total_fields.limit برابر ۱۰۰۰ است و وقتی رد شود همهٔ نوشتنها fail میشوند. (۲) حدس نوع اشتباه — اولین سند "zip": 12345 میفرستد و فیلد long میشود؛ بعد "zip": "01234" میآید و رد میشود. (۳) تاریخهای تصادفی — رشتهای مثل "2024-1" ممکن است date تشخیص داده شود و بقیه reject شوند.
راهحلها: نوع flattened برای دادههای کلید-پویا، dynamic: "strict" برای بقیه، و نگه داشتن mapping بهصورت index template نسخهدار در repo.
PUT /_index_template/products-template
{
"index_patterns": ["products-*"],
"priority": 200,
"template": {
"settings": { "number_of_shards": 3, "number_of_replicas": 1, "refresh_interval": "5s" },
"mappings": {
"dynamic": "strict",
"properties": {
"sku": { "type": "keyword" },
"name": { "type": "text", "fields": {
"keyword": { "type": "keyword", "ignore_above": 256 },
"ngram": { "type": "text", "analyzer": "autocomplete_index",
"search_analyzer": "standard" } } },
"description": { "type": "text", "analyzer": "english" },
"price": { "type": "scaled_float", "scaling_factor": 100 },
"created_at": { "type": "date", "format": "strict_date_optional_time||epoch_millis" },
"location": { "type": "geo_point" },
"tags": { "type": "keyword" },
"attributes": { "type": "flattened" }
}
}
}
}
مقادیر dynamic: true (اضافه کن)، runtime (فیلد runtime بدون index)، false (نادیده بگیر ولی در _source نگه دار — قابل جستوجو نیست)، strict (خطا بده). برای API عمومی strict بهترین است چون باگ سمت تولیدکنندهٔ داده را زود لو میدهد.
بلوک fields همان multi-field است: name هم بهصورت text برای جستوجو index میشود، هم name.keyword برای فیلتر و sort، هم name.ngram برای autocomplete. یک بار _source، سه ساختار index — ابزار اصلیِ حل تعارض «هم جستوجو میخواهم هم دقت».
| ویژگی | text |
keyword |
|---|---|---|
| تحلیل میشود؟ | بله، به term میشکند | نه، کل مقدار یک term |
| مناسبِ | جستوجوی متن آزاد، relevance | فیلتر دقیق، sort، aggregation، id و enum |
term query روی آن |
معمولاً هیچی برنمیگرداند | دقیقاً کار میکند |
| aggregation | نیاز به fielddata (خطرناک) |
مستقیم با doc values |
| sort | عملاً بیمعنی | درست |
| حساس به بزرگی و کوچکی حروف | نه (بعد از lowercase) | بله، مگر با normalizer |
ignore_above |
ندارد | دارد؛ مقادیر بلندتر index نمیشوند |
status را text گذاشتهای با مقدار "IN PROGRESS"؛ index شامل term های in و progress است. حالا {"term": {"status": "IN PROGRESS"}} میزنی: Elasticsearch رشتهٔ کوئری را تحلیل نمیکند و دنبال term دقیقِ IN PROGRESS میگردد که وجود ندارد. نتیجه صفر، بدون خطا.
دو راه: status را keyword کن (درستترین کار برای enum)، یا {"term": {"status.keyword": "..."}} بزن. نسخهٔ ظریفترش: {"term": {"name.keyword": "iphone"}} هم صفر میدهد اگر مقدار واقعی iPhone باشد، چون keyword حروف را کوچک نمیکند؛ برای فیلتر دقیقِ بیتوجه به حروف یک normalizer با lowercase روی keyword بگذار.
object در برابر nested در برابر join
JSON تودرتو را Elasticsearch بهطور پیشفرض مسطح میکند. سند {"orders": [{"product":"laptop","qty":1}, {"product":"mouse","qty":5}]} داخلاً به orders.product: ["laptop","mouse"] و orders.qty: [1,5] تبدیل میشود و رابطهٔ بین laptop و 1 گم میشود. نتیجه: کوئری «سفارشی که product = laptop و qty = 5 باشد» این سند را برمیگرداند با اینکه چنین سفارشی وجود ندارد — باگی که ماهها بیسروصدا در گزارشها میماند.
با nested، هر عضو آرایه بهعنوان یک سند مخفی جداگانه در همان shard ذخیره میشود و رابطه حفظ میشود:
PUT /carts
{ "mappings": { "properties": {
"orders": { "type": "nested", "properties": {
"product": { "type": "keyword" }, "qty": { "type": "integer" } } } } } }
GET /carts/_search
{ "query": { "nested": {
"path": "orders",
"query": { "bool": { "filter": [
{ "term": { "orders.product": "laptop" } },
{ "range": { "orders.qty": { "gte": 5 } } } ] } },
"inner_hits": { "size": 3 } } } }
inner_hits میگوید کدام عضو match شده — بدون آن فقط میدانی سند والد match کرده.
هزینهاش را دستکم نگیر: هر عضو nested یک سند Lucene جداگانه است، پس سندی با ۲۰۰ عضو یعنی ۲۰۱ سند و بهروزرسانی یک عضو یعنی بازنویسی کل سند والد. محدودیتهای پیشفرض هم عمدیاند — index.mapping.nested_fields.limit برابر ۵۰ و index.mapping.nested_objects.limit برابر ۱۰۰۰۰؛ اگر داری اینها را بالا میبری، احتمالاً مدل دادهات اشتباه است. نوع join رابطهٔ parent/child واقعی میسازد و اجازه میدهد فرزند را بدون بازنویسی والد بهروز کنی، اما کوئریها کندترند. قاعده: اول denormalize، بعد nested، و join فقط با نسبت یکبهخیلیزیاد و بهروزرسانی مستقل.
object فقط یک راه نوشتن JSON تودرتو است؛ Elasticsearch آن را مسطح میکند و آرایههای موازی از مقادیر میسازد، پس همبستگی بین فیلدهای یک عضو از بین میرود و کوئریهای ترکیبی نتیجهٔ اشتباه میدهند. nested هر عضو را بهعنوان یک سند Lucene پنهان جدا index میکند و رابطه را حفظ میکند، اما باید با nested query و path سراغش بروی و برای دیدن عضو منطبق از inner_hits استفاده کنی.
بخش senior جواب، هزینه است: تعداد سند در shard چند برابر میشود، هر بهروزرسانی جزئی کل خانوادهٔ سند را بازنویسی میکند، و aggregation نیاز به nested و reverse_nested دارد. پس اول میپرسم آیا واقعاً به همبستگی درونعضوی نیاز دارم یا میتوانم denormalize کنم.
تغییر mapping بدون downtime
نوع یک فیلد موجود را نمیشود عوض کرد، چون داده روی دیسک با آن نوع نوشته شده. الگوی استاندارد: index جدید، سپس reindex، سپس جابهجایی اتمی alias.
نمودار زیر مسیر تغییر mapping بدون قطعی است — Zero-downtime mapping change with an alias swap.
sequenceDiagram
participant App
participant Alias as "Alias: products"
participant V1 as "products-v1"
participant V2 as "products-v2"
App->>Alias: read and write
Alias->>V1: routes
Note over V2: create with new mapping
V2->>V2: _reindex from products-v1
Note over App,V2: dual-write or replay changes since snapshot
Alias->>V2: atomic _aliases swap
Note over V1: delete after verification
curl -X POST "localhost:9200/_reindex?wait_for_completion=false" \
-H 'Content-Type: application/json' \
-d '{"source":{"index":"products-v1","size":2000},"dest":{"index":"products-v2","op_type":"index"}}'
curl -s "localhost:9200/_tasks?actions=*reindex&detailed"
# جابهجایی اتمی — هیچ لحظهای بدون index نیستی
curl -X POST localhost:9200/_aliases -H 'Content-Type: application/json' -d '{
"actions": [
{ "remove": { "index": "products-v1", "alias": "products" } },
{ "add": { "index": "products-v2", "alias": "products" } } ] }'
هیچوقت اسم واقعی index را در کد اپلیکیشن نگذار. همیشه یک alias مثل products بساز و کد به alias بزند. آنوقت reindex، تغییر تعداد shard، rollback و تست A/B همه بدون تغییر کد و بدون downtime ممکن میشوند. ارزانترین تصمیم معماری این فصل است و بیشترین بازده را دارد.
نوع فیلد موجود قابل تغییر نیست؛ فقط میشود فیلد جدید اضافه کرد. پس index نسخهٔ جدید با mapping درست میسازم، با _reindex داده را منتقل میکنم (با wait_for_completion=false و پایش task)، تغییرات ایجادشده حین reindex را با dual-write یا replay از روی updated_at یا offset صف جبران میکنم، با شمارش سند و چند کوئری نمونه تأیید میکنم، و بعد alias را با یک فراخوانی _aliases که remove و add را با هم دارد بهصورت اتمی جابهجا میکنم.
نکتهٔ senior: index قدیمی را بلافاصله پاک نکن — یک چرخهٔ deploy نگهش دار تا rollback فقط یک swap دیگر باشد. و اگر از data stream استفاده میکنی، معمولاً کافی است template را عوض کنی و rollover بزنی تا دادههای جدید با mapping تازه بیایند.
۵. Query DSL — زبان پرسش
filter نگهبان در است: فقط میپرسد «حق ورود داری یا نه؟» — بله یا خیر. سریع است و جوابش قابل ذخیره در حافظه. query داور مسابقه است: میپرسد «چقدر خوب بودی؟» و یک نمرهٔ اعشاری (_score) میدهد؛ گرانتر است و کشکردنش سختتر.
قانون: هر شرطی که «بله یا خیر» است — بازهٔ تاریخ، وضعیت، دسته، مالک — باید در filter باشد. فقط چیزی که واقعاً باید روی رتبه اثر بگذارد در query.
GET /products/_search
{
"query": {
"bool": {
"must": [ { "match": { "name": { "query": "wireless headphones", "operator": "and" } } } ],
"should": [
{ "match_phrase": { "name": { "query": "noise cancelling", "boost": 3 } } },
{ "term": { "brand": { "value": "acme", "boost": 1.5 } } }
],
"filter": [
{ "term": { "active": true } },
{ "terms": { "category": ["audio", "accessories"] } },
{ "range": { "price": { "gte": 50, "lte": 400 } } },
{ "range": { "created_at": { "gte": "now-1y/d" } } }
],
"must_not": [ { "term": { "discontinued": true } } ],
"minimum_should_match": 1
}
},
"size": 20,
"track_total_hits": 1000
}
چهار بند bool: must باید match شود و روی _score اثر میگذارد؛ filter باید match شود ولی _score را عوض نمیکند و قابل کش است؛ should اختیاری است و امتیاز را بالا میبرد؛ must_not نباید match شود و در filter context اجرا میشود.
دو کش وجود دارد: node query cache نتیجهٔ بندهای filter را بهصورت bitset در حافظهٔ node نگه میدارد (پیشفرض ۱۰٪ heap) و فقط برای filter های بهقدر کافی تکرارشده فعال میشود؛ shard request cache کل پاسخ درخواستهای size: 0 را کش میکند — یعنی دقیقاً aggregation و داشبورد — و با هر refresh باطل میشود.
اول، minimum_should_match: اگر must و filter هر دو خالی باشند پیشفرضش ۱ است، در غیر اینصورت ۰. یعنی بهمحض اینکه یک filter اضافه کنی، should هایت از «شرط» به «تقویتکنندهٔ امتیاز» تبدیل میشوند و تعداد نتایج ناگهان میترکد. اگر «حداقل یکی لازم است» را میخواهی، صریح بنویسش.
دوم، now بدون گرد کردن: "gte": "now-1h" تا میلیثانیه دقت دارد، پس هر درخواست یک کلید کش متفاوت میسازد و هیچوقت hit نمیخوری. "now-1h/h" بنویس تا بازه به سر ساعت گرد شود؛ در داشبوردهای پرترافیک همین یک اسلش میتواند بار CPU را چند برابر کم کند.
| کوئری | چه میکند | متن کوئری تحلیل میشود؟ | کجا استفاده کن |
|---|---|---|---|
match |
تحلیل میکند و OR/AND بین term ها | بله | جستوجوی متن آزاد — پیشفرض تو |
match_phrase |
term ها باید پشتسرهم و بهترتیب باشند | بله | نقلقول، عبارت دقیق |
match_phrase_prefix |
مثل بالا ولی آخرین term پیشوند است | بله | search-as-you-type ساده |
multi_match |
match روی چند فیلد با boost |
بله | ۹۰٪ کوئریهای محصول |
term |
term دقیق، بدون تحلیل | نه | keyword، عدد، بولین |
terms |
یکی از چند مقدار دقیق | نه | فیلتر چندانتخابی |
range |
بازهٔ عددی، تاریخی یا IP | نه | قیمت، تاریخ |
exists |
فیلد مقدار دارد | — | فیلتر null |
prefix |
term با این پیشوند شروع شود | نه | با احتیاط |
wildcard / regexp |
الگوی * و ? یا عبارت باقاعده |
نه | آخرین راهحل |
fuzzy |
فاصلهٔ ویرایشی روی یک term | نه | غلط املایی |
bool |
ترکیب همهٔ بالا | — | همیشه |
GET /products/_search
{
"query": {
"multi_match": {
"query": "sony wireless headphone",
"type": "best_fields",
"fields": ["name^4", "brand^2", "description", "tags"],
"tie_breaker": 0.3,
"fuzziness": "AUTO",
"prefix_length": 1,
"minimum_should_match": "70%"
}
},
"highlight": {
"fields": { "name": { "number_of_fragments": 0 },
"description": { "fragment_size": 120, "number_of_fragments": 2 } },
"pre_tags": ["<mark>"], "post_tags": ["</mark>"]
}
}
انواع multi_match: best_fields (پیشفرض) امتیاز را برابر بهترین فیلد بهعلاوهٔ tie_breaker ضربدر بقیه میگیرد و وقتی خوب است که همهٔ کلمهها معمولاً در یک فیلد باشند؛ most_fields امتیاز همهٔ فیلدها را جمع میکند و برای وقتی است که یک متن را با چند analyzer مختلف index کردهای؛ cross_fields فیلدها را مثل یک فیلد بزرگ میبیند و برای نام و آدرس که کلمهها بین فیلدها پخشاند مناسب است؛ و phrase، phrase_prefix و bool_prefix برای عبارت و پیشوند.
fuzziness: AUTO یعنی term های ۰ تا ۲ حرفی باید دقیق match شوند، ۳ تا ۵ حرفی یک ویرایش، بیشتر از ۵ حرف دو ویرایش؛ آستانهها با AUTO:4,8 قابل تغییرند و پیشفرض معادل AUTO:3,6 است.
هزینهٔ واقعی wildcard، prefix و regexp: اینها term expansion انجام میدهند — Elasticsearch باید در فرهنگ term ها بگردد، همهٔ term های منطبق را پیدا کند و کوئری را به OR بین آنها تبدیل کند. {"wildcard": {"name": "*phone*"}} یعنی پیمایش کل فرهنگ term های آن فیلد در هر shard، که روی index بزرگ ثانیهها طول میکشد. راه درست: بهجای prefix از edge_ngram در زمان index یا پارامتر index_prefixes استفاده کن؛ بهجای *x* یک فیلد ngram بساز؛ و در خوشههای چندمستأجری search.allow_expensive_queries را false بگذار.
در query context هر بند یک _score تولید میکند که نشان میدهد سند «چقدر خوب» match شده؛ در filter context فقط بله یا خیر مطرح است. تفاوت عملی سه چیز است: filter نیازی به محاسبهٔ امتیاز ندارد پس CPU کمتری میخورد، میتواند از اجرای تنبل و پرش روی postings استفاده کند، و نتیجهاش بهصورت bitset در node query cache ذخیره میشود و درخواستهای بعدی آن را تقریباً رایگان میگیرند.
در bool، بندهای filter و must_not در filter context هستند و must و should در query context. عادت درست این است که هر شرط قطعی — وضعیت، دسته، مالکیت، بازهٔ زمانی — به filter برود؛ و now را همیشه گرد کن وگرنه کلید کش در هر درخواست عوض میشود.
چون term رشتهٔ کوئری را تحلیل نمیکند ولی فیلد text در زمان index تحلیل شده است. اگر مقدار "iPhone 15 Pro" باشد، term های موجود iphone، 15 و pro هستند و هیچ term ی دقیقاً برابر رشتهٔ کامل نیست. چاره یا match است که کوئری را با همان analyzer تحلیل میکند، یا زدن به زیرفیلد name.keyword با term.
تلهٔ دوم: حتی روی keyword هم term به بزرگی و کوچکی حروف حساس است، چون keyword اصلاً analyzer ندارد. اگر فیلتر دقیقِ بیتوجه به حروف میخواهی، روی keyword یک normalizer با فیلتر lowercase تعریف کن و داده را reindex کن.
۶. Autocomplete و صفحهبندی
| راهبرد autocomplete | ساز و کار | infix؟ | fuzzy؟ | حافظه | کِی انتخابش کن |
|---|---|---|---|---|---|
edge_ngram در زمان index |
term های پیشوندی در همان inverted index | فقط با ngram کامل |
بله | دیسک بیشتر، heap کم | حالت عمومی؛ وقتی فیلتر و امتیاز هم لازم داری |
فیلد search_as_you_type |
خودکار زیرفیلدهای ._2gram، ._3gram و ._index_prefix میسازد |
بله | بله | متوسط | راهاندازی سریع بدون طراحی analyzer |
| completion suggester | ساختار FST در heap | نه | بله | heap زیاد | لیست کوتاه و ثابت با ترتیب مشخص (نام شهر، برند) |
match_phrase_prefix |
زمان کوئری، بدون آمادهسازی | نه | نه | صفر | نمونهٔ اولیه و دادههای کوچک |
PUT /suggestions
{ "mappings": { "properties": {
"title": { "type": "search_as_you_type", "max_shingle_size": 3 },
"suggest": { "type": "completion", "analyzer": "simple" } } } }
GET /suggestions/_search
{
"suggest": {
"product-suggest": {
"prefix": "wirel",
"completion": { "field": "suggest", "size": 8, "skip_duplicates": true,
"fuzzy": { "fuzziness": "AUTO", "prefix_length": 1 } } }
},
"query": {
"multi_match": { "query": "wirel head", "type": "bool_prefix",
"fields": ["title", "title._2gram", "title._3gram"] }
}
}
بلوک suggest از completion suggester استفاده میکند و بلوک query همان کار را با زیرفیلدهای خودکارِ search_as_you_type انجام میدهد. کنار اینها term suggester و phrase suggester هم وجود دارند که برای «آیا منظورت این بود؟» بهکار میروند، نه برای autocomplete.
completion suggester آن چیزی نیست که فکر میکنی. سه محدودیت که معمولاً دیر کشف میشوند: (۱) فقط پیشوند را match میکند؛ «headphone» با «wireless headphone» پیدا نمیشود مگر خودت همهٔ چرخشها را بهعنوان input بنویسی. (۲) کل FST در heap بارگذاری میشود؛ روی چند میلیون عبارت گیگابایتها heap میخورد. (۳) فیلتر کردن فقط با contexts از پیش تعریفشده ممکن است، نه با یک bool دلخواه — پس «autocomplete فقط بین محصولات موجود در انبار این شهر» را باید در طراحی context دیده باشی. برای اکثر محصولات واقعی، edge_ngram روی یک multi-field بههمراه bool انتخاب درستتری است.
صفحهبندی — جایی که سیستمها میمیرند
{"from": 0, "size": 20} تا صفحهٔ ۵۰۰ کار میکند و بعد خطای Result window is too large میگیری، چون index.max_result_window پیشفرض ۱۰۰۰۰ است. دلیلش این است که برای from=9980, size=20 روی index ای با ۵ shard، هر shard باید ۱۰۰۰۰ نتیجهٔ برتر خودش را مرتب کند و بفرستد و node هماهنگکننده ۵۰۰۰۰ رکورد را در حافظه ادغام کند تا ۲۰ تای وسط را بردارد. هزینه با from خطی رشد میکند و ضربدر تعداد shard میشود؛ چند درخواست همزمان کافی است تا heap پر شود و circuit breaker بزند. آن سقف یک محافظ است، نه یک مزاحم.
| روش | حالتدار؟ | پرش تصادفی به صفحهٔ N | ثبات نتیجه | مناسبِ |
|---|---|---|---|---|
from/size |
نه | بله | نه (داده عوض میشود) | UI، فقط چند صفحهٔ اول |
search_after |
نه | فقط بعدی و قبلی | نسبی | اسکرول بینهایت، API عمومی |
search_after بههمراه PIT |
بله (PIT) | فقط بعدی | snapshot ثابت | خروجی گرفتن، صفحهبندی عمیق |
scroll |
بله | نه | snapshot ثابت | export و مهاجرت دستهای؛ برای UI توصیه نمیشود |
curl -X POST "localhost:9200/products/_pit?keep_alive=2m"
# پاسخ: {"id":"46ToAwMDaWR5..."}
GET /_search
{
"size": 1000,
"query": { "term": { "active": true } },
"pit": { "id": "46ToAwMDaWR5...", "keep_alive": "2m" },
"sort": [
{ "created_at": { "order": "asc", "format": "strict_date_optional_time_nanos" } },
{ "_shard_doc": "asc" }
],
"track_total_hits": false
}
برای صفحهٔ بعد همان بدنه را بفرست و فقط "search_after": ["2026-03-14T10:22:31.001Z", 4294967298] اضافه کن — یعنی آرایهٔ sort آخرین hit صفحهٔ قبل.
سه نکتهٔ ریز که فرق حرفهای و آماتور است: tiebreaker اجباری است — کلید sort باید یکتا شود وگرنه رکوردها بین صفحهها تکرار یا گم میشوند؛ با PIT فیلد _shard_doc خودکار اضافه میشود و بدون PIT خودت _id را آخرین کلید sort بگذار. track_total_hits پیشفرض روی ۱۰۰۰۰ متوقف میشود و hits.total.relation مقدار "gte" میگیرد؛ برای شمارش دقیق true بگذار و در صفحهبندی false. و PIT را ببند با DELETE /_pit، چون PIT باز segment ها را نگه میدارد و جلوی آزادسازی فضا توسط merge را میگیرد.
برای UI اصلاً صفحهبندی عمیق نمیدهم؛ چند صفحهٔ اول با from/size و بعد کاربر را به فیلتر و مرتبسازی هدایت میکنم، چون هیچ کاربری صفحهٔ ۴۰۰۰ را نمیخواند. برای API و اسکرول بینهایت از search_after با یک کلید sort یکتا استفاده میکنم که cursor بدون حالت میسازد و هزینهٔ هر صفحه ثابت است.
برای خروجی گرفتن یا پردازش دستهای، search_after را با یک PIT ترکیب میکنم تا نمای داده در طول کل پیمایش ثابت بماند؛ track_total_hits: false میگذارم و PIT را در finally میبندم. scroll را فقط برای مهاجرتهای یکبارهٔ داخلی نگه میدارم چون context را روی shard قفل میکند و جلوی merge را میگیرد. و هرگز max_result_window را بالا نمیبرم.
۷. رتبهبندی — چرا این سند بالاتر است؟
از یک کتابدار میپرسی «کتابی دربارهٔ index پایگاهداده داری؟» او سه چیز را میسنجد: (۱) این کتاب چند بار کلمهٔ «index» را بهکار برده — هرچه بیشتر مرتبطتر، ولی از جایی به بعد فرقی نمیکند؛ (۲) کلمهٔ «index» چقدر نادر است — اگر همهٔ کتابهای قفسه آن را دارند، تمایزی نمیسازد؛ (۳) کتاب چقدر بلند است — جزوهٔ ۱۰ صفحهای با ۵ بار تکرار، متمرکزتر از دایرةالمعارف ۱۰۰۰ صفحهای با همان ۵ بار است.
این دقیقاً سه مؤلفهٔ BM25 است: اشباع term frequency، وزن نادر بودن، و نرمالسازی طول.
فرمول قدیمی TF-IDF امتیاز را تقریباً tf × idf × 1/√length میگرفت و مشکلش این بود که tf بدون سقف رشد میکرد. BM25 — که از نسخهٔ ۵ به بعد پیشفرض Elasticsearch است — دو پارامتر اضافه میکند: k1 با پیشفرض 1.2 که سرعت اشباع term frequency را کنترل میکند (از تکرار سوم به بعد هر تکرار اضافه کمتر اثر میگذارد)، و b با پیشفرض 0.75 که شدت نرمالسازی طول را تعیین میکند (با b = 0 طول سند مهم نیست، با b = 1 کاملاً مهم است).
PUT /articles
{
"settings": { "index": { "similarity": {
"tuned_bm25": { "type": "BM25", "k1": 1.1, "b": 0.4 } } } },
"mappings": { "properties": {
"title": { "type": "text", "similarity": "tuned_bm25" },
"body": { "type": "text" } } }
}
k1 و b را تقریباً هیچوقت دست نزن — دستکم نه قبل از اینکه mapping، analyzer و boost فیلدها را درست کرده باشی. تنها حالت واقعی، فیلدهایی است که طولشان ذاتاً بسیار متفاوت است؛ برای عنوانهای کوتاه b کوچکتر منطقی است چون نمیخواهی عنوان یککلمهای فقط بهخاطر کوتاهی برنده شود.
relevance متنی فقط نیمی از داستان است. در یک فروشگاه، «مرتبط» یعنی ترکیب متن با موجودی، امتیاز کاربران، تازگی و حاشیهٔ سود:
GET /products/_search
{
"query": {
"function_score": {
"query": { "multi_match": { "query": "running shoes", "fields": ["name^3", "description"] } },
"functions": [
{ "filter": { "term": { "in_stock": true } }, "weight": 2 },
{ "field_value_factor": { "field": "rating", "factor": 1.2,
"modifier": "sqrt", "missing": 3 } },
{ "gauss": { "created_at": { "origin": "now", "scale": "30d", "decay": 0.5 } } }
],
"score_mode": "sum",
"boost_mode": "multiply"
}
}
}
score_mode میگوید چند تابع چطور با هم ترکیب شوند و boost_mode میگوید نتیجهٔ توابع چطور با امتیاز متنی ترکیب شود. gauss یک منحنی زوال است: هرچه از origin دورتر، امتیاز کمتر — ابزار استاندارد برای «تازگی» و «نزدیکی جغرافیایی».
این توابع گراناند: برای هر سند کاندید اجرا میشوند، نه فقط ۱۰ تای برتر — اگر کوئری یک میلیون سند را match کند، تابع یک میلیون بار اجرا میشود. دو راه نجات: اول با filter مجموعه را کوچک کن؛ و بهجای script از فیلدهای rank_feature و rank_features استفاده کن که در Lucene بهینه شدهاند و سیگنال عددی مثل «تعداد بازدید» را بدون اجرای script وارد امتیاز میکنند. هرگز از sort روی _script در مسیر کاربر استفاده نکن؛ هم کند است و هم بهینهسازیهای زودخروج Lucene را از کار میاندازد.
وقتی نتیجهٔ اشتباهی بالای لیست است، حدس نزن — توضیح بخواه:
curl -X GET localhost:9200/products/_explain/SKU-123 -H 'Content-Type: application/json' -d '{
"query": { "multi_match": { "query": "running shoes", "fields": ["name^3","description"] } } }'
خروجی یک درخت است: هر گره یک value، یک description و فرزندانش. آنجا دقیقاً میبینی boost، idf (با n و N) و tf (با freq، k1، b، dl، avgdl) چقدر بودهاند. برای دیدن همین برای همهٔ نتایج، "explain": true را در بدنهٔ _search بگذار. برای مشکل کندی (نه رتبه) ابزار دیگری داری: "profile": true که زمان هر بخش از اجرا را در هر shard میشکند.
امتیازها بین shard ها مقایسهپذیر نیستند. idf بهطور پیشفرض روی آمار همان shard حساب میشود، نه کل index؛ پس اگر تعداد سند کم و توزیع نامتوازن باشد، دو سند یکسان در دو shard امتیاز متفاوت میگیرند. این معمولاً در محیط تست با ۲۰ سند و ۵ shard دیده میشود و باعث میشود ساعتها دنبال باگ خیالی بگردی. راهحل موقت برای تست ?search_type=dfs_query_then_fetch است؛ راهحل واقعی در production، shard کمتر و داده بیشتر است.
Elasticsearch علاوه بر جستوجوی واژگانی (BM25) از جستوجوی برداری هم پشتیبانی میکند: فیلدهای dense_vector و sparse_vector، جستوجوی kNN، فیلد semantic_text که خودش در زمان ingest متن را قطعهقطعه و بردار میکند، و چارچوب retriever که در نسخهٔ ۸.۱۶ عمومی شد. مهمترینش برای معماری rrf است — Reciprocal Rank Fusion — که رتبهٔ نتایج BM25 و برداری را ادغام میکند بدون اینکه لازم باشد امتیازهای دو مقیاس متفاوت را نرمال کنی.
قضاوت senior: جستوجوی برداری جایگزین BM25 نیست، مکمل آن است. برای کوئریهای دقیق مثل کد کالا و شمارهٔ قطعه، واژگانی همیشه بهتر است. اول BM25 را درست کن؛ اگر هنوز کوئریهای مفهومی نتیجه نمیدهند، آنوقت لایهٔ ترکیبی اضافه کن.
اول فرضها را حذف میکنم: با _analyze میبینم کوئری و فیلد واقعاً به چه term هایی تبدیل میشوند، چون بیشتر باگهای relevance در واقع باگ analyzer اند. بعد GET /index/_explain/<id> را با همان کوئری میزنم و درخت توضیح را میخوانم: سهم idf، سهم tf با پارامترهای k1، b، dl و avgdl، و ضریب boost هر بند. همین را برای سندی که انتظار داشتم بالا باشد هم میزنم و دو درخت را مقایسه میکنم — تفاوت همیشه در یک گره مشخص است.
اگر عددها معقولاند ولی نتیجه بد است، مشکل مدل امتیاز است نه اجرا: معمولاً most_fields جای best_fields استفاده شده، یا فیلد بلند بدون کاهش وزن با فیلد کوتاه رقابت میکند، یا سیگنال کسبوکار وارد نشده. و همیشه هشدار میدهم که در محیط تستِ کوچک، اختلاف idf بین shard ها میتواند خودش را بهجای باگ جا بزند.
۸. Aggregation — نیمهٔ تحلیلی موتور
Elasticsearch یک موتور تحلیل ستونی هم هست که روی doc values کار میکند. سه خانواده داریم: metric (avg، sum، cardinality، percentiles، top_hits)، bucket (terms، range، date_histogram، filters، nested، composite) و pipeline که روی خروجی بقیه کار میکند (derivative، moving_fn، cumulative_sum، bucket_selector، bucket_sort).
GET /orders/_search
{
"size": 0,
"query": { "bool": { "filter": [ { "range": { "created_at": { "gte": "now-90d/d" } } } ] } },
"aggs": {
"per_day": {
"date_histogram": { "field": "created_at", "calendar_interval": "day",
"time_zone": "Asia/Tehran", "min_doc_count": 0 },
"aggs": {
"revenue": { "sum": { "field": "total" } },
"unique_buyers": { "cardinality": { "field": "customer_id", "precision_threshold": 3000 } },
"revenue_growth": { "derivative": { "buckets_path": "revenue" } }
}
},
"top_categories": {
"terms": { "field": "category", "size": 10, "shard_size": 100,
"order": { "revenue": "desc" } },
"aggs": {
"revenue": { "sum": { "field": "total" } },
"big_only": { "bucket_selector": { "buckets_path": { "r": "revenue" },
"script": "params.r > 10000000" } }
}
}
}
}
"size": 0 یعنی «hit نمیخواهم، فقط aggregation» — و همین است که shard request cache را فعال میکند.
۱. terms دقیق نیست: هر shard جداگانه shard_size سطل برتر خودش را حساب میکند و میفرستد؛ اگر توزیع ناهموار باشد، سهم یک category از یک shard اصلاً فرستاده نمیشود و شمارش نهایی کمتر از واقعیت میشود. فیلدهای doc_count_error_upper_bound و sum_other_doc_count دقیقاً برای همیناند. چاره: shard_size بالاتر (پیشفرضش تقریباً size * 1.5 + 10) یا composite aggregation با صفحهبندی after.
۲. aggregation روی فیلد text: خطای «Fielddata is disabled» میگیری و وسوسه میشوی "fielddata": true کنی — این کل term های آن فیلد را در heap بارگذاری میکند و راه کلاسیک زمینزدن یک خوشه است. راه درست: زیرفیلد .keyword.
۳. فراموش کردن time_zone در date_histogram: مرز روزها بر UTC میافتد و گزارشهای مالی چند ساعت جابهجا میشوند — باگی که تیم مالی پیدا میکند، نه تیم فنی. ضمناً cardinality هم تقریبی است (HyperLogLog++) و سقف precision_threshold برابر ۴۰۰۰۰ است.
چون aggregation توزیعشده است: هر shard فقط shard_size سطل برتر خودش را برمیگرداند، پس اگر توزیع بین shard ها ناهموار باشد یک مقدار میتواند در برخی shard ها از فهرست بیفتد و شمارش نهایی کمتر از واقعیت شود. Elasticsearch این عدمقطعیت را در doc_count_error_upper_bound و sum_other_doc_count گزارش میکند.
راهحلها بهترتیب هزینه: بالا بردن shard_size، کم کردن تعداد shard، یا composite aggregation که همهٔ سطلها را صفحهبندیشده و دقیق پیمایش میکند. اگر گزارش مالی است و باید دقیق باشد، composite یا محاسبهٔ دستهای بیرون از Elasticsearch انتخاب درست است — و cardinality ذاتاً تقریبی است.
۹. مکانیک خوشه
node یک پروسهٔ JVM است و نقشهایش را با node.roles تعیین میکنی: master (مدیریت وضعیت خوشه، نه داده)، data و لایههای data_hot، data_warm، data_cold، data_frozen، data_content، همچنین ingest (اجرای pipeline پیش از index)، ml، transform و remote_cluster_client. با node.roles: [] یک coordinating node خالص داری که فقط مسیریابی و ادغام نتایج میکند.
shard اصلی یک تکه از index است و خودش یک index کامل Lucene؛ تعدادش موقع ساخت index تعیین میشود و بعداً فقط با _split، _shrink یا reindex تغییر میکند. replica کپی یک primary روی node دیگر است که هم افزونگی میدهد و هم ظرفیت خواندن، و تعدادش را در لحظه میشود عوض کرد.
سند به کدام shard میرود؟ با فرمول قطعی shard = hash(routing) % number_of_primary_shards که routing بهطور پیشفرض همان _id است. همین فرمول توضیح میدهد چرا تعداد primary shard بعد از ساخت قابل تغییر نیست: عوض شدن مخرج یعنی همهٔ سندها باید دوباره جایگذاری شوند.
اگر دادهات بهطور طبیعی چندمستأجری است (tenant_id)، موقع index و search پارامتر routing=tenant_id بده. آنوقت هر کوئری فقط به یک shard میرود بهجای همه و تأخیر و بار خوشه دراماتیک پایین میآید. هزینهاش خطر «shard داغ» است اگر یک مستأجر خیلی بزرگتر از بقیه باشد؛ برای آن مستأجر index.routing_partition_size را بالا ببر تا دادهاش روی چند shard پخش شود.
نمودار زیر مسیر واقعی یک جستوجو است — Distributed search: query phase gathers ranked ids, fetch phase loads documents.
sequenceDiagram
participant C as Client
participant Co as "Coordinating node"
participant S1 as "Shard A copy"
participant S2 as "Shard B copy"
C->>Co: POST /idx/_search size=10 from=0
Co->>S1: Query phase (local top 10 ids and scores)
Co->>S2: Query phase (local top 10 ids and scores)
S1-->>Co: 10 doc ids plus scores
S2-->>Co: 10 doc ids plus scores
Co->>Co: Merge and sort, keep global top 10
Co->>S1: Fetch phase (get _source for chosen ids)
Co->>S2: Fetch phase (get _source for chosen ids)
S1-->>Co: documents
Co-->>C: Final ranked hits
سه نتیجهٔ مهم: (۱) from + size بر تعداد shard ضرب میشود — برای from=9000, size=10 روی ۵ shard، در فاز اول ۵ × ۹۰۱۰ رکورد ادغام میشود؛ ریشهٔ همان سقف ۱۰۰۰۰. (۲) کندترین shard سرعت کل را تعیین میکند — یک shard غولپیکر تأخیر صدک ۹۹ همهٔ کوئریها را بالا میبرد. (۳) فاز fetch فقط _source سندهای برنده را میخواند، پس با _source: {"includes": [...]} یا fields فقط چیزی را بردار که لازم داری.
refresh، flush، merge — و «near real-time» یعنی چه
نمودار زیر چرخهٔ عمر یک نوشتن است — Document write lifecycle: buffer, refresh, flush, merge.
stateDiagram-v2
[*] --> InMemoryBuffer: index request
InMemoryBuffer --> Translog: appended for durability
InMemoryBuffer --> NewSegment: refresh (default 1s)
NewSegment --> Searchable: visible to search
Searchable --> DiskCommit: flush (Lucene commit, translog truncated)
DiskCommit --> MergedSegment: background merge
MergedSegment --> [*]: deleted docs reclaimed
- refresh: بافر حافظه را به یک segment جدید تبدیل میکند تا قابل جستوجو شود. پیشفرض
index.refresh_intervalبرابر1s. تنها دلیلی است که میگوییم Elasticsearch near real-time است: سندی که همین الان نوشتی تا حدود یک ثانیه در_searchنیست — ولی باGET /index/_doc/<id>بلافاصله خوانده میشود، چون آن مسیر از translog میخواند. - translog: لاگ فقط-افزودنی برای دوام. با
index.translog.durabilityبرابرrequest(پیشفرض) هر درخواست قبل از ack شدن روی دیسک fsync میشود؛ باasyncهر ۵ ثانیه — سریعتر ولی با پنجرهٔ از دست رفتن داده. - flush: یک commit واقعی Lucene؛ segment ها قطعی میشوند و translog کوتاه میشود. خودکار است.
- merge: چند segment کوچک را به یکی بزرگتر ترکیب میکند و سندهای حذفشده را واقعاً آزاد میکند.
هر بار POST /idx/_doc?refresh=true یک segment جدید میسازد؛ در یک حلقهٔ ۱۰۰۰ تایی یعنی ۱۰۰۰ segment ریز که merge باید تمیزشان کند. اگر واقعاً باید بعد از نوشتن بلافاصله جستوجو کنی (معمولاً فقط در تست)، از ?refresh=wait_for استفاده کن که منتظر refresh بعدی میماند و segment اضافه نمیسازد. در بارگذاری انبوه برعکسش را بکن:
curl -X PUT localhost:9200/products/_settings -H 'Content-Type: application/json' \
-d '{"index":{"refresh_interval":"-1","number_of_replicas":0}}'
# ... اینجا با bulk API داده را میریزی ...
curl -X PUT localhost:9200/products/_settings -H 'Content-Type: application/json' \
-d '{"index":{"refresh_interval":"1s","number_of_replicas":1}}'
curl -X POST "localhost:9200/products/_forcemerge?max_num_segments=1"
_forcemerge را فقط روی index هایی بزن که دیگر نوشتنی ندارند، مثل index لاگ دیروز. روی یک index فعال، segment های بزرگِ حاصل دیگر هرگز merge نمیشوند و سندهای حذفشده در آنها تلنبار میشود. در ILM هم forcemerge عمداً در فاز warm است، نه hot.
refresh بافر حافظه را به یک segment جدید و قابل جستوجو تبدیل میکند و پیشفرض هر یک ثانیه اجرا میشود؛ flush یک commit روی دیسک است که segment ها را ماندگار و translog را کوتاه میکند؛ merge segment های کوچک را ادغام و فضای سندهای حذفشده را آزاد میکند. دوام از translog میآید نه از refresh — داده قبل از قابلجستوجو شدن هم امن است.
Elasticsearch near real-time است: پنجرهٔ حدوداً یکثانیهای بین نوشتن و دیده شدن در جستوجو وجود دارد، اما GET با شناسه بلافاصله جواب میدهد چون از translog میخواند. پس اگر الگوی «بنویس و فوراً بخوان» داری، با GET بخوان نه با _search، یا از ?refresh=wait_for استفاده کن — و هرگز ?refresh=true را در مسیر داغ production نگذار.
چند shard؟ — و اشتباه کلاسیک over-sharding
تصور غلط: «shard بیشتر یعنی موازیسازی بیشتر یعنی سریعتر». واقعیت: هر shard یک index کامل Lucene با فایلها، بافرها، thread ها و سهم خودش از cluster state است. یک خوشه با ۵۰۰۰ shard تقریباً خالی میتواند بهخاطر بزرگ شدن cluster state و کند شدن master ناپایدار شود.
اعداد رسمی که باید حفظ باشی: اندازهٔ هر shard بین ۱۰ تا ۵۰ گیگابایت و زیر ۲۰۰ میلیون سند؛ سقف پیشفرض ۱۰۰۰ shard غیرfrozen بهازای هر node (و ۳۰۰۰ shard frozen روی node اختصاصی frozen) با cluster.max_shards_per_node؛ روی master کمتر از ۳۰۰۰ index بهازای هر گیگابایت heap؛ و heap حداکثر ۳۱ گیگابایت تا از compressed oops جا نمانی. قاعدهٔ قدیمی «۲۰ shard بهازای هر گیگابایت heap» از نسخهٔ ۸.۳ منسوخ شده.
حساب سرانگشتی برای index جدید: حجم نهایی داده با احتساب رشد یک سال تقسیم بر ۴۰ گیگابایت، گرد شده به بالا، و حداقل به تعداد data node ها. برای ۶۰۰ گیگابایت داده میشود حدود ۱۵ shard اصلی. اگر index کوچک و ثابت است، number_of_shards: 1 کاملاً درست است.
برای دادههای زمانمحور (لاگ، متریک، رویداد) نباید یک index بینهایترشد داشته باشی. الگوی درست data stream است: یک نام منطقی که پشتش دنبالهای از index های پنهان است، و rollover که وقتی index جاری به آستانه رسید یکی جدید میسازد.
نمودار زیر چرخهٔ عمر داده در ILM است — Index lifecycle: hot to warm to cold to frozen to delete.
flowchart LR
H["Hot: active writes, fast SSD"] -->|rollover 50gb or 1d| W["Warm: read only, forcemerge, shrink"]
W -->|min_age 30d| C["Cold: cheaper nodes, searchable snapshot"]
C -->|min_age 90d| F["Frozen: object storage, slow queries"]
F -->|min_age 365d| D["Delete"]
PUT /_ilm/policy/logs-lifecycle
{
"policy": { "phases": {
"hot": { "min_age": "0ms", "actions": {
"rollover": { "max_primary_shard_size": "50gb", "max_age": "1d" },
"set_priority": { "priority": 100 } } },
"warm": { "min_age": "2d", "actions": {
"shrink": { "number_of_shards": 1 },
"forcemerge": { "max_num_segments": 1 },
"set_priority": { "priority": 50 } } },
"cold": { "min_age": "30d", "actions": { "set_priority": { "priority": 0 } } },
"delete": { "min_age": "90d", "actions": { "delete": {} } }
} }
}
max_primary_shard_size را به max_size ترجیح بده: max_size کل index را میسنجد، پس با تغییر تعداد shard اندازهٔ هر shard از بازهٔ سالم بیرون میزند. همیشه یک max_age هم کنارش بگذار تا index کمترافیک برای همیشه باز نماند.
| کار | دستور |
|---|---|
| سلامت خوشه | GET /_cluster/health?level=indices |
| چرا shard تخصیص نمییابد | GET /_cluster/allocation/explain |
| فهرست index ها با اندازه | GET /_cat/indices?v&s=store.size:desc |
| توزیع shard ها | GET /_cat/shards?v&s=store:desc |
| heap و disk هر node | GET /_cat/nodes?v&h=name,heap.percent,disk.used_percent,node.role |
| کوئریهای در حال اجرا | GET /_cat/tasks?v&detailed |
| لغو یک کوئری سنگین | POST /_tasks/<task_id>/_cancel |
| thread pool پر شده؟ | GET /_cat/thread_pool/search,write?v&h=node_name,name,active,queue,rejected |
| آمار کش و merge | GET /index/_stats/query_cache,request_cache,merge,refresh |
| تست analyzer | POST /_analyze |
| توضیح امتیاز | GET /index/_explain/<id> |
| تنظیمات پویای خوشه | PUT /_cluster/settings |
با سه سؤال شروع میکنم: حجم نهایی داده در یک سال چقدر است، الگو زمانمحور است یا ثابت، و چند data node داریم. بعد ساده حساب میکنم: هر shard اصلی بین ۱۰ تا ۵۰ گیگابایت و زیر ۲۰۰ میلیون سند، پس تعداد shard تقریباً حجم تقسیم بر ۴۰ گیگابایت، و حداقل به تعداد data node تا موازیسازی داشته باشم. برای دادههای زمانمحور اصلاً عدد ثابت انتخاب نمیکنم؛ data stream با rollover روی max_primary_shard_size: 50gb میگذارم تا اندازه خودش تنظیم شود.
مهمترین تأکیدم پرهیز از over-sharding است: هر shard هزینهٔ ثابتی در heap و cluster state دارد، سقف پیشفرض ۱۰۰۰ shard در هر node است، و قاعدهٔ قدیمی «۲۰ shard بهازای هر گیگابایت heap» منسوخ شده. اگر خطا کردم ترجیح میدهم shard کمتر و بزرگتر باشد، چون بزرگ شدن را با rollover و _split میشود حل کرد اما ناپایداری master را نه.
اول تعریف میکنم «کند» یعنی چه: صدک ۹۹ یا میانگین، همهٔ کوئریها یا یک الگوی خاص. بعد از سمت خوشه شروع میکنم: _cat/thread_pool/search را نگاه میکنم که آیا queue و rejected بالا رفتهاند، _cat/nodes را برای heap و disk، و _cat/shards را برای پیدا کردن shard نامتوازن یا غولپیکر. اگر یک node قرمز است، معمولاً مشکل توزیع است نه کوئری.
بعد سراغ خود کوئری میروم: "profile": true میزنم تا ببینم زمان کجا میرود، و دنبال متهمهای همیشگی میگردم — wildcard با ستارهٔ ابتدایی، script در امتیازدهی یا sort، from عمیق، terms با فهرست عظیم، aggregation روی فیلد پرتنوع، یا now بدون گرد کردن. slow log را هم روشن میکنم تا الگوی واقعی ترافیک را ببینم. در نهایت به لایهٔ داده نگاه میکنم: تعداد segment و آمار merge، _source بیشازحد بزرگ در فاز fetch، و نبود replica کافی برای بار خواندن.
۱۰. Elastic Stack و کلاینتهای Java
Elasticsearch بهتنهایی فقط موتور است. Beats جمعکنندههای سبک تکمنظورهاند (Filebeat برای فایل لاگ، Metricbeat برای متریک، Heartbeat)، اما امروز Elastic Agent جای همهٔ آنها را میگیرد: یک عامل واحد که از طریق Fleet در Kibana مرکزی مدیریت میشود. Logstash خط لولهٔ تبدیل سنگین با دهها plugin است؛ اگر تبدیل تو ساده است (grok، تغییر نام فیلد، افزودن geo) به آن نیاز نداری چون ingest pipeline داخل خود Elasticsearch همان کار را با یک سرویس کمتر انجام میدهد. Kibana هم رابط کاربری است: کاوش داده، داشبورد، مدیریت ILM و template، و Dev Tools.
نمودار زیر جای هر قطعه را نشان میدهد — Elastic Stack data flow from source to dashboard.
flowchart LR
A["App logs / metrics / DB rows"] --> B["Elastic Agent or Beats"]
B --> C["Logstash (optional heavy transform)"]
B --> D["Ingest pipeline (inside Elasticsearch)"]
C --> D
D --> E["Elasticsearch indices / data streams"]
E --> F["Kibana: Discover, Lens, Alerts"]
E --> G["Your application search API"]
PUT /_ingest/pipeline/normalize-logs
{
"processors": [
{ "grok": { "field": "message",
"patterns": ["%{TIMESTAMP_ISO8601:ts} %{LOGLEVEL:level} %{GREEDYDATA:msg}"] } },
{ "date": { "field": "ts", "formats": ["ISO8601"], "target_field": "@timestamp" } },
{ "lowercase": { "field": "level" } },
{ "remove": { "field": ["ts", "message"], "ignore_missing": true } }
],
"on_failure": [ { "set": { "field": "error.pipeline", "value": "normalize-logs" } } ]
}
این دو کاربرد تقریباً هیچ شباهتی ندارند و اگر هر دو را روی یک خوشه بگذاری، بار سنگین ingest لاگ تأخیر جستوجوی محصول را خراب میکند. لاگ یعنی نوشتن بسیار زیاد، خواندن کم، دادهای که بعد از ۳۰ روز بیارزش است و ILM جدی. جستوجوی محصول یعنی نوشتن کم، خواندن زیاد با تأخیر پایین، relevance حساس و mapping ای که مدام تغییر میکند. قاعده: خوشهها را جدا کن، یا دستکم node های اختصاصی با tier جدا بده. جزئیات سمت مشاهدهپذیری در فصل observability آمده است.
کلاینتهای TransportClient و RestHighLevelClient قدیمی حذف شدهاند. کلاینت رسمی امروز Elasticsearch Java API Client با گروه co.elastic.clients است:
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>9.3.0</version>
</dependency>
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
public class ProductSearch {
private final ElasticsearchClient client;
public ProductSearch(String host, int port) {
RestClient rest = RestClient.builder(new HttpHost(host, port, "https")).build();
this.client = new ElasticsearchClient(new RestClientTransport(rest, new JacksonJsonpMapper()));
}
public SearchResponse<Product> search(String text, double maxPrice) throws Exception {
Query byText = Query.of(q -> q.multiMatch(m -> m
.query(text).fields("name^4", "description").fuzziness("AUTO")));
Query priceFilter = Query.of(q -> q.range(r -> r.number(n -> n.field("price").lte(maxPrice))));
Query activeFilter = Query.of(q -> q.term(t -> t.field("active").value(true)));
return client.search(s -> s
.index("products") // یک alias است، نه اسم واقعی index
.query(q -> q.bool(b -> b.must(byText).filter(priceFilter, activeFilter)))
.size(20)
.trackTotalHits(t -> t.count(1000)),
Product.class);
}
}
نوشتن دستهای همیشه باید با bulk باشد، نه یکییکی:
public void indexAll(List<Product> batch) throws Exception {
BulkRequest.Builder br = new BulkRequest.Builder().index("products");
for (Product p : batch) {
br.operations(op -> op.index(i -> i.id(p.sku()).document(p)));
}
BulkResponse res = client.bulk(br.build());
if (res.errors()) {
res.items().stream()
.filter(it -> it.error() != null)
.forEach(it -> log.error("bulk item {} failed: {}", it.id(), it.error().reason()));
}
}
bulk API با HTTP 200 برمیگردد حتی وقتی نیمی از اسناد fail شدهاند. اگر فقط استثنا را بگیری و پاسخ را نخوانی، داده بیسروصدا گم میشود و ماهها بعد کشف میکنی که index ناقص است. همیشه errors() را چک کن و بین «خطای دائمی» مثل mapper_parsing_exception (که باید به dead-letter برود) و «خطای گذرا» مثل es_rejected_execution_exception (که باید با backoff دوباره تلاش شود) فرق بگذار. اندازهٔ دستهٔ سالم بین ۵ تا ۱۵ مگابایت است، نه بر اساس تعداد سند؛ دستههای خیلی بزرگ صف write را پر میکنند.
در Spring، لایهٔ Spring Data Elasticsearch روی همین کلاینت سوار است:
@Document(indexName = "products")
public class ProductDoc {
@Id
private String sku;
@Field(type = FieldType.Text, analyzer = "standard")
private String name;
@Field(type = FieldType.Keyword)
private String brand;
@Field(type = FieldType.Scaled_Float, scalingFactor = 100)
private BigDecimal price;
@Field(type = FieldType.Date, format = DateFormat.date_optional_time)
private Instant createdAt;
}
برای هر چیزی جز کوئریهای ساده، از NativeQuery و ElasticsearchOperations استفاده کن — همان Query DSL کامل، با type-safety:
public SearchHits<ProductDoc> topProducts(String text, ElasticsearchOperations ops) {
NativeQuery query = NativeQuery.builder()
.withQuery(q -> q.bool(b -> b
.must(m -> m.multiMatch(mm -> mm.query(text).fields("name^4", "description")))
.filter(f -> f.term(t -> t.field("active").value(true)))))
.withPageable(PageRequest.of(0, 20))
.build();
return ops.search(query, ProductDoc.class);
}
ماتریس سازگاری نسخهها را جدی بگیر. Spring Data Elasticsearch به نسخهٔ مشخصی از کلاینت رسمی گره خورده: نسخهٔ 6.1.x با Elasticsearch 9.4.2 و Spring Framework 7.0.x، نسخهٔ 6.0.x با Elasticsearch 9.2.2، و نسخهٔ 5.5.x با Elasticsearch 8.18.1. نسخهٔ elasticsearch-java را دستی override نکن، چون ناسازگاریاش بهصورت NoSuchMethodError در زمان اجرا خودش را نشان میدهد نه در زمان کامپایل؛ و در ارتقا اول خوشه را بالا ببر، بعد اپلیکیشن را. برای تست هم بهجای mock کردن کلاینت، با Testcontainers یک Elasticsearch واقعی بالا بیاور — رفتار analyzer و mapping را هیچ mock ای شبیهسازی نمیکند (فصل testing).
با spring-boot-starter-data-elasticsearch که زیر پوسته همان ElasticsearchClient رسمی را میسازد. برای CRUD ساده ElasticsearchRepository کافی است، اما هر کوئری جدی را با NativeQuery و ElasticsearchOperations مینویسم چون تمام Query DSL در دسترس است و مجبور نمیشوم منطق را در نام متد بچپانم.
در review سه چیز را نگاه میکنم: آیا به alias میزنیم یا اسم واقعی index (باید alias باشد)؛ آیا BulkResponse.errors() بررسی میشود یا فرض شده HTTP 200 یعنی موفقیت؛ و آیا فیلترها در filter هستند یا اشتباهاً در must که هم کش را از دست میدهد و هم امتیاز را آلوده میکند. بهعلاوه چک میکنم mapping بهصورت index template نسخهدار در repo باشد، نه از طریق createIndex خودکار در production.
۱۱. معماری جستوجو — همگام نگه داشتن با پایگاهدادهٔ اصلی
Elasticsearch یک index جستوجو است، نه منبع حقیقت (source of truth).
دلیلش فنی است نه سلیقهای: تراکنش چندسندی ندارد، مدل سازگاریاش near real-time است، برای تغییر mapping باید کل داده را بازسازی کنی، و مدل دادهاش denormalize شده است. اگر پاک شود، باید بتوانی با یک دستور از منبع اصلی بازسازیاش کنی. اگر نمیتوانی، معماریات ایراد دارد.
نمودار زیر سه راهبرد را کنار هم میگذارد — Three ways to keep the search index in sync with the primary database.
flowchart TD
subgraph DW["1. Dual write (avoid)"]
A1["Service"] --> B1[(Primary DB)]
A1 --> C1[(Elasticsearch)]
end
subgraph OB["2. Transactional outbox"]
A2["Service"] -->|one local transaction| B2[(Primary DB plus outbox table)]
B2 --> R2["Relay / poller"]
R2 --> C2[(Elasticsearch)]
end
subgraph CDC["3. Log-based CDC"]
B3[(Primary DB)] -->|WAL / redo log| K3["CDC connector"]
K3 --> Q3["Message broker"]
Q3 --> I3["Indexer service"]
I3 --> C3[(Elasticsearch)]
end
الگوی «اول در پایگاهداده بنویس، بعد در Elasticsearch بنویس» ساده بهنظر میرسد و همان هفتهٔ اول کار میکند. مشکل این است که این دو نوشتن اتمی نیستند: اگر بین آنها پروسه crash کند، شبکه قطع شود، یا تراکنش پایگاهداده rollback شود، دو سیستم برای همیشه واگرا میشوند و هیچکس متوجه نمیشود.
بدتر: اگر نوشتن Elasticsearch را داخل تراکنش پایگاهداده بگذاری، کندی یا قطعی Elasticsearch مستقیماً تراکنشهای کسبوکار تو را میشکند و مدت قفلها را طولانی میکند. اگر واقعاً مجبوری (نمونهٔ اولیه یا سیستم کماهمیت)، حداقل یک job تطبیق شبانه بنویس که با شمارش و checksum اختلاف را پیدا و ترمیم کند.
در الگوی outbox، در همان تراکنشی که داده را تغییر میدهی یک ردیف رویداد هم در جدول outbox مینویسی. چون هر دو در یک تراکنش محلیاند، یا هر دو انجام میشوند یا هیچکدام. بعد یک relay مستقل آن ردیفها را میخواند و بهصورت bulk به Elasticsearch میفرستد.
CREATE TABLE outbox (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
-- partial index: فقط ردیفهای منتشرنشده
CREATE INDEX idx_outbox_unpublished ON outbox (id) WHERE published_at IS NULL;CREATE TABLE outbox (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
aggregate_id VARCHAR2(64) NOT NULL,
event_type VARCHAR2(64) NOT NULL,
payload CLOB NOT NULL CHECK (payload IS JSON),
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
published_at TIMESTAMP WITH TIME ZONE
);
-- Oracle partial index ندارد؛ با function-based index شبیهسازی میشود
CREATE INDEX idx_outbox_unpublished
ON outbox (CASE WHEN published_at IS NULL THEN id END);در هر دو موتور، index ای که فقط ردیفهای منتشرنشده را نگه دارد کوچک میماند حتی وقتی outbox میلیونها ردیف تاریخی دارد. در PostgreSQL این کار مستقیم با WHERE انجام میشود؛ در Oracle چون B-tree ردیفهای تماماً NULL را index نمیکند، عبارت CASE همان اثر را میدهد (فصلهای sql-mastery و oracle-postgres-dialects).
اگر ترجیح میدهی از polling ساده استفاده کنی، دستکم درست انجامش بده — با صفحهبندی keyset، نه با OFFSET:
SELECT id, updated_at
FROM products
WHERE (updated_at, id) > ($1, $2)
ORDER BY updated_at, id
FETCH FIRST 1000 ROWS ONLY;SELECT id, updated_at
FROM products
WHERE (updated_at, id) > ((:last_ts, :last_id))
ORDER BY updated_at, id
FETCH FIRST 1000 ROWS ONLY;polling روی updated_at سه سوراخ دارد: حذفها را نمیبیند (رکورد پاکشده هیچوقت در نتیجه نمیآید و برای همیشه در Elasticsearch میماند — راهحل soft delete است)؛ پرش ساعت و اختلاف ساعت بین سرورها میتواند رکوردها را جا بیندازد؛ و تراکنشهای طولانی که با updated_at قدیمیتر ولی commit دیرتر ثبت میشوند از پنجرهٔ pull جا میمانند، برای همین معمولاً چند ثانیه همپوشانی عمدی میگذارند و بر idempotency تکیه میکنند.
CDC مبتنی بر لاگ یعنی خواندن مستقیم لاگ تراکنش پایگاهداده — WAL در PostgreSQL، redo با LogMiner یا XStream در Oracle — و تبدیل هر تغییر به یک رویداد. هیچ تغییری در کد اپلیکیشن لازم نیست، حذفها هم دیده میشوند، و ترتیب رویدادها دقیقاً ترتیب commit است.
| معیار | dual write | outbox | CDC مبتنی بر لاگ | polling |
|---|---|---|---|---|
| تضمین از دست ندادن | ندارد | دارد (یک تراکنش محلی) | دارد | نسبی |
| حذفها را میبیند | بله (اگر یادت بماند) | بله | بله | نه، مگر soft delete |
| تغییر در کد اپلیکیشن | زیاد | متوسط (نوشتن رویداد) | تقریباً هیچ | کم |
| تأخیر معمول | آنی | زیر ثانیه تا چند ثانیه | زیر ثانیه | برابر بازهٔ polling |
| بار روی پایگاهداده | کم | نوشتن اضافه و polling | خواندن لاگ، بار کم | کوئری تکراری |
| پیچیدگی عملیاتی | کم | متوسط | زیاد (connector، broker، پایش) | کم |
| پرکردن اولیهٔ index | دستی | دستی | معمولاً snapshot خودکار | دستی |
| کِی انتخابش کن | هیچوقت در سیستم جدی | پیشفرض معقول برای اکثر تیمها | حجم زیاد، چند مصرفکننده، بدون دستزدن به کد | داده کم و تحمل تأخیر بالا |
جزئیات broker و تحویل در فصلهای messaging و ms-data آمده است.
هر خط لولهٔ رویداد در نهایت at-least-once است: یک رویداد ممکن است دو بار برسد و دو رویداد از دو پارتیشن ممکن است بیترتیب برسند. اگر بیمحابا بنویسی، نسخهٔ قدیمی میتواند نسخهٔ جدید را بازنویسی کند و کاربر تغییرش را «گمشده» ببیند.
راهحل درست versioning خارجی است: یک شمارندهٔ یکنواخت از منبع (LSN، SCN، شمارهٔ نسخهٔ ردیف، یا timestamp میلیثانیهای commit) را بهعنوان نسخهٔ سند بفرست:
curl -X PUT "localhost:9200/products/_doc/SKU-123?version=1712345678901&version_type=external" \
-H 'Content-Type: application/json' -d '{"sku":"SKU-123","name":"Wireless Headphones"}'
با version_type=external، Elasticsearch نوشتن با نسخهٔ کوچکتر یا مساوی را با خطای ۴۰۹ رد میکند. آن ۴۰۹ خطا نیست — یعنی «این رویداد قدیمی بود»؛ آن را بشمار و رد شو. برای الگوی «بخوان، تغییر بده، بنویس» هم if_seq_no و if_primary_term را داری که کنترل همزمانی خوشبینانه میدهند.
هر معماری همگامسازی باید یک مسیر بازسازی کامل داشته باشد که هر زمان قابل اجرا باشد: index جدید با mapping تازه بساز؛ مصرفکنندهٔ رویداد را طوری تنظیم کن که هم به index قدیمی بنویسد هم جدید (این dual index است، نه dual write — امن است چون هر دو مقصد از یک منبع رویداد تغذیه میشوند)؛ با search_after و PIT از پایگاهدادهٔ اصلی یا با _reindex از index قبلی backfill کن؛ با شمارش سند و نمونهگیری تصادفی تأیید کن؛ alias را اتمی جابهجا کن و یک هفته index قدیمی را نگه دار.
- denormalize را در زمان index انجام بده، نه در زمان کوئری. Elasticsearch
JOINندارد و شبیهسازیاش با چند رفتوبرگشت تأخیر را چند برابر میکند. هزینهاش این است که تغییر نام یک برند یعنی بهروزرسانی همهٔ محصولات آن — که با_update_by_queryو یک صف پسزمینه قابل مدیریت است. - مجوز دسترسی را در سند بگذار، مثلاً آرایهای از
acl_group، و در هر کوئری یکfilterروی آن بزن. فیلتر کردن نتایج بعد از دریافت، هم صفحهبندی را میشکند و هم از طریق تعداد نتایج اطلاعات نشت میدهد. - کوئری کاربر را مستقیم به Elasticsearch نده. ورودی را در سرویس خودت به Query DSL ترجمه کن؛
query_stringرا در معرض کاربر قرار نده چون سینتکس دارد و میتواند کوئریهای بسیار سنگین بسازد. همیشهtimeoutوterminate_afterبگذار. - relevance را اندازه بگیر. یک مجموعهٔ ارزیابی از کوئریهای واقعی بههمراه نتایج درست بساز و پیش از هر تغییر boost آن را اجرا کن. بدون این، تنظیم relevance فقط جابهجا کردن شکایتهاست.
اول اعلام میکنم که Elasticsearch منبع حقیقت نیست؛ باید همیشه بتوان از پایگاهدادهٔ اصلی بازسازیاش کرد. dual write را رد میکنم چون دو نوشتن اتمی نیستند و با اولین crash یا rollback دو سیستم بیسروصدا واگرا میشوند. پیشفرض من transactional outbox است: در همان تراکنشی که داده تغییر میکند یک رویداد در جدول outbox مینویسم و یک relay مستقل آن را میخواند و بهصورت bulk میفرستد. اگر حجم زیاد است، چند مصرفکننده لازم داریم، یا اجازهٔ دستزدن به کد legacy را نداریم، سراغ CDC مبتنی بر لاگ میروم که تغییرات را از WAL یا redo میخواند و حذفها را هم میبیند.
در هر دو حالت مصرفکننده باید idempotent باشد و با version_type=external روی یک شمارندهٔ یکنواخت کار کند تا رویداد قدیمیِ دیررسیده نتواند نسخهٔ جدید را بازنویسی کند؛ خطای ۴۰۹ در این حالت یعنی «رویداد کهنه»، نه خرابی. و همیشه یک مسیر backfill کامل بههمراه job تطبیق دورهای دارم.
نه، مگر اینکه داده ذاتاً قابل بازتولید باشد — مثل index لاگ. دلایل فنی: تراکنش چندسندی وجود ندارد؛ مدل سازگاری near real-time است پس الگوی «بنویس و فوراً در نتیجه ببین» بیدرنگ کار نمیکند؛ تغییر نوع یک فیلد یعنی بازسازی کامل index؛ و مدل داده denormalize شده است، پس یک بهروزرسانی کوچک ممکن است میلیونها سند را لمس کند.
جواب کاملِ senior اضافه میکند که نگه داشتن یک منبع حقیقت رابطهای، Elasticsearch را از یک سیستم «حیاتی» به یک سیستم «قابل دورانداختن» تبدیل میکند؛ یعنی میتوانی آزادانه mapping را عوض کنی، دوباره بسازی، نسخهٔ خوشه را ارتقا دهی و در بحران فقط از مدار خارجش کنی و به جستوجوی سادهتر برگردی. این خودش یک تصمیم معماری است، نه یک محدودیت.
۱۲. سیاههٔ نهایی برای production
| موضوع | تصمیم درست |
|---|---|
| دسترسی به index | همیشه از طریق alias، هرگز نام واقعی |
| mapping | explicit و dynamic: strict، در قالب index template نسخهدار در repo |
| enum و شناسه | keyword، نه text |
| فیلترها | در filter، با now گردشده |
| تعداد shard | ۱۰ تا ۵۰ گیگابایت بهازای هر shard، زیر ۲۰۰ میلیون سند |
| دادههای زمانمحور | data stream بههمراه ILM و rollover روی max_primary_shard_size |
| نوشتن | bulk با دستهٔ ۵ تا ۱۵ مگابایت، بررسی errors()، backoff نمایی |
| صفحهبندی | search_after بههمراه PIT؛ هرگز بالا بردن max_result_window |
| همگامسازی | outbox یا CDC، هرگز dual write؛ نوشتن با version_type=external |
| بازسازی | مسیر backfill کامل که همیشه قابل اجراست، بهعلاوهٔ job تطبیق |
| امنیت | TLS، احراز هویت، و فیلتر مجوز دسترسی داخل خود کوئری |
| پایش | تأخیر جستوجو، صف و rejection در thread pool، heap، تعداد shard، سلامت ILM |
جستوجو یعنی تبدیل متن به term و نگه داشتن نگاشت term به سند در یک inverted index؛ همهچیز دیگر از همین یک ایده میآید. analysis تعیین میکند چه term هایی وجود دارند، و اگر analyzer زمان index و زمان query سازگار نباشند نتیجه بیسروصدا صفر میشود. mapping جایی است که بیشتر باگها متولد میشوند: text برای متن آزاد، keyword برای فیلتر و sort و aggregation، multi-field برای داشتن هر دو، و nested فقط وقتی رابطهٔ درونعضوی واقعاً لازم است.
در Query DSL هر شرط قطعی باید در filter باشد تا کش شود، و کوئریهای گران مثل wildcard با ستارهٔ ابتدایی باید با طراحی زمان index جایگزین شوند. رتبهبندی با BM25 انجام میشود و برای دیباگش _explain داری نه حدس. مکانیک خوشه — query-then-fetch، refresh و flush و merge، و اندازهٔ shard — توضیح میدهد چرا صفحهبندی عمیق گران است، چرا داده تا یک ثانیه دیده نمیشود، و چرا over-sharding رایجترین اشتباه معماری است.
و در نهایت مهمترین قضاوت senior: Elasticsearch یک index جستوجو است، نه منبع حقیقت. آن را با outbox یا CDC از پایگاهدادهٔ اصلی تغذیه کن، مصرفکننده را idempotent و نسخهآگاه بساز، و همیشه مسیری برای بازسازی کامل داشته باش. هر خوشهای که نتوانی با یک دستور بازسازیاش کنی، یک بدهی فنی است که منتظر روز حادثه نشسته.
Almost every product you build eventually grows a search box: one plain input where a user types and expects the best results to float to the top — even with a typo, even with the wrong plural, even when they only half-remember the phrase. Behind that plain box sits a whole engineering world: text analysis, an inverted index, ranking, sharding, and a synchronisation architecture that carries data from your primary database into the search engine without losing anything.
This chapter takes you from "why LIKE '%x%' is not search" all the way to "how do I size a multi-terabyte cluster and keep it in sync with CDC".
- Why search is different from a relational query — and what an inverted index actually is.
- Analysis: character filters, tokenizers, token filters, stemming, stop words, synonyms, ngrams — and non-English text.
- Mappings: dynamic vs explicit,
textvskeyword, multi-fields,nestedvsobject. - The query DSL:
match,term,bool, query context vs filter context, fuzziness, highlighting. - Autocomplete and suggesters, and deep pagination with
search_afterand PIT. - Relevance: BM25, boosting,
function_score, and debugging a score with_explain. - Aggregations as the analytics half of the engine.
- Cluster mechanics: node roles, shards, query-then-fetch, refresh vs flush vs merge, ILM and sizing.
- The Elastic Stack, the Java API client and Spring Data Elasticsearch.
- Sync architecture: dual write vs CDC vs outbox — and why Elasticsearch is not a source of truth.
1. Why LIKE is not search
Picture a 900-page book and you want to know where "transaction isolation" is explained. Either you start at page 1 and read every page — which is exactly what LIKE '%transaction isolation%' does — or you flip to the index at the back: an alphabetical list of words, each with the page numbers where it appears. One glance and you have your answer.
That index at the back of the book is the inverted index. All of Elasticsearch is built on that single idea.
A B-tree index on title is like a sorted phone book: it can tell you which records start with "data", but not which records contain that word in the middle. A leading % therefore makes the index useless and you fall back to a full scan — on 10 million rows that means seconds of work for every keystroke a user makes.
But the bigger problem is not performance. LIKE gives you a binary answer: present or absent. Search is not inherently binary; search means "which of these are most relevant" — it means ranking. And LIKE matches characters, not words: "cat" matches inside "concatenate", while "running" does not match "run".
Relational databases do have full-text search, and for small to medium workloads it is entirely sufficient:
CREATE INDEX idx_articles_fts
ON articles USING GIN (to_tsvector('english', title || ' ' || body));
SELECT id,
ts_rank(to_tsvector('english', title || ' ' || body),
plainto_tsquery('english', 'database index')) AS rank
FROM articles
WHERE to_tsvector('english', title || ' ' || body)
@@ plainto_tsquery('english', 'database index')
ORDER BY rank DESC
FETCH FIRST 10 ROWS ONLY;-- Oracle Text: a CONTEXT domain index
CREATE INDEX idx_articles_fts ON articles(body)
INDEXTYPE IS CTXSYS.CONTEXT;
SELECT id, SCORE(1) AS rank
FROM articles
WHERE CONTAINS(body, 'database AND index', 1) > 0
ORDER BY SCORE(1) DESC
FETCH FIRST 10 ROWS ONLY;If your search covers a few hundred thousand rows and ranking barely matters, tsvector or Oracle Text is enough and you have one fewer system to operate. Reach for Elasticsearch when you want tunable relevance (field boosting, phrases, synonyms, typo tolerance), interactive facets and aggregations over the same result set, tens of millions of documents sharded across nodes, sub-50ms autocomplete, or time-based log search with retention.
The practical signal: if you are hand-writing several LIKE clauses plus an ORDER BY in SQL to imitate relevance, it is time.
Three reasons. Execution: the '%x%' pattern cannot use an index, so you fall back to a full scan and cost grows linearly with table size. Semantics: LIKE matches substrings of characters, not words — "cat" matches inside "concatenate", and "running" will never match "run" because there is no stemming. Output: LIKE returns a boolean, while search needs a ranked list — a document with the term in the title three times should beat one with a single mention in a footnote.
The complete senior answer adds that there is a middle ground: to_tsvector with a GIN index, or Oracle Text. We choose Elasticsearch when we need tunable relevance, interactive aggregations, horizontal scale or autocomplete — not merely because the data happens to be text.
2. The inverted index from scratch
Take three documents: doc 1 is the quick brown fox, doc 2 is the quick blue car, doc 3 is brown fox jumps over the lazy dog. First we break each text into terms, then we invert the table — instead of "document → words" we write "word → documents":
| term | postings list as docId: tf [positions] |
|---|---|
brown |
1:1 [2], 3:1 [0] |
car |
2:1 [3] |
fox |
1:1 [3], 3:1 [1] |
jumps |
3:1 [2] |
quick |
1:1 [1], 2:1 [1] |
the |
1:1 [0], 2:1 [0], 3:1 [4] |
Building the jargon from zero:
- term: the atomic unit of search after analysis. Not necessarily a "word" — whatever the analyzer emits is a term.
- postings list: the sorted list of document ids containing that term. Sortedness is crucial, because intersecting or unioning two sorted lists is a single linear walk (see the core-ds and complexity chapters).
- term frequency (
tf): how many times the term occurs in that document — the basis for "this document is more about this word". - document frequency (
df): how many documents contain the term.theis in every document so it is near worthless;jumpsis rare so it is valuable. - positions: where the term sits in the document. Without positions,
match_phraseis impossible.
The query brown fox is the intersection of {1,3} with {1,3}. Its cost depends on how many documents contain those words, not on the total size of the index — that is precisely what makes search scale.
The diagram below is the mental model of an inverted index — Inverted index model: the term dictionary points to postings lists.
flowchart LR
subgraph Dict["Term dictionary (sorted, on disk)"]
T1["brown"]
T2["fox"]
end
subgraph Post["Postings"]
P1["doc1 tf=1 pos=2 | doc3 tf=1 pos=0"]
P2["doc1 tf=1 pos=3 | doc3 tf=1 pos=1"]
end
T1 --> P1
T2 --> P2
Q["Query: brown AND fox"] --> T1
Q --> T2
P1 --> M["Intersect sorted lists"]
P2 --> M
M --> R["Hits: doc1, doc3"]
Elasticsearch did not implement the inverted index itself; it sits on Apache Lucene. Each Elasticsearch shard is in fact a complete Lucene index, and each Lucene index is made of segments. A segment is an immutable bundle of files: once written, it never changes.
Immutability buys three things for free: lock-free concurrent reads, safe caching of whole files in the OS page cache, and aggressive compression. The price is that "update" does not exist — an update means marking the old document as deleted in a bitmap and writing the new version into a fresh segment. The old document's space is only reclaimed during a merge.
Alongside the inverted index, Lucene builds a second structure: doc values, which are columnar and used for sorting, aggregations and scripts.
| Structure | Direction | Used for | Default |
|---|---|---|---|
| inverted index | term to document | filtering and searching | on for text and keyword |
| doc values | document to value | sort, aggregation, scripts | on for keyword, numeric, date; off for text |
stored fields and _source |
document to original JSON | returning hits, highlighting, reindex | _source on |
You will be tempted to set "_source": {"enabled": false} to save disk; don't. It kills reindex, partial update, highlighting and document reconstruction — exactly the things you need on incident day. If size really hurts, reach for "codec": "best_compression" or _source.excludes first.
It is a mapping from each term to a sorted postings list of the documents containing that term, plus the metadata needed for scoring (tf) and phrase matching (positions). Because the lists are sorted, AND and OR across several terms is a single linear walk with skip-list jumps; so query cost grows with the number of documents containing that term, not with the size of the whole collection.
The follow-up the interviewer is fishing for: the term dictionary itself is stored sorted and compressed on disk (in Lucene, as an FST) so term lookup is roughly proportional to term length, and there is a complementary doc-values structure for sorting and aggregation because an inverted index is a poor tool for answering "what is this document's value".
3. Analysis — the real heart of search quality
Raw text is the raw material entering a three-station line. A character filter works on the raw string first (stripping HTML tags, mapping one Unicode form to another). A tokenizer breaks the string into pieces — exactly one tokenizer, never more. Token filters then work on each piece: lowercasing, stemming, removing, adding synonyms.
The output of that line is exactly the terms written into the inverted index. Anything this line does not produce can never be found.
The diagram below is the analysis pipeline — Text analysis pipeline: raw text becomes indexed terms.
flowchart LR
A["Raw field value"] --> B["Character filters (0..n)"]
B --> C["Tokenizer (exactly 1)"]
C --> D["Token filters (0..n)"]
D --> E["Terms written to inverted index"]
Q["Query string"] --> B2["Same analyzer at search time"]
B2 --> F["Query terms"]
E --> G["Match only if terms are identical"]
F --> G
The single most useful debugging tool in search is _analyze. Before you guess anything, run it:
POST /_analyze
{
"analyzer": "english",
"text": "The Foxes were quickly Running through 3 Databases!"
}
You get terms like fox, quickli, run, 3, databas. Notice three things: The and were are gone (stop words); Foxes became fox and Databases became databas (stemming — an algorithmic root that is not necessarily a real word); and everything is lowercased. Instead of a named analyzer you can pass the parts directly: "char_filter": ["html_strip"], "tokenizer": "standard", "filter": ["lowercase","asciifolding"].
| Component | Examples | What it does |
|---|---|---|
| character filter | html_strip, mapping, pattern_replace |
rewrite the raw string before tokenising |
| tokenizer | standard (UAX#29, default), keyword (no split), whitespace, pattern, ngram, edge_ngram, path_hierarchy |
split the string into tokens — exactly one |
| token filter | lowercase, asciifolding, stop, stemmer, synonym_graph, shingle, decimal_digit, unique |
modify, drop or add tokens |
| built-in analyzer | standard (system default, does not remove stop words), keyword, simple, stop, plus language ones like english and persian |
a packaged combination of the three layers |
| normalizer | character and token filters only, no tokenizer | for keyword fields |
Stop words are high-frequency, low-information words (the, and). Removing them shrinks the index but breaks match_phrase — drop the and "to be or not to be" becomes effectively empty. Synonyms are for when the user types "cellphone" and you want "phone" to match:
PUT /products
{
"settings": { "analysis": {
"filter": { "product_synonyms": { "type": "synonym_graph", "synonyms": [
"laptop, notebook",
"cellphone, mobile phone, smartphone => phone" ] } },
"analyzer": {
"product_index": { "tokenizer": "standard", "filter": ["lowercase"] },
"product_search": { "tokenizer": "standard", "filter": ["lowercase", "product_synonyms"] }
} } },
"mappings": { "properties": {
"name": { "type": "text", "analyzer": "product_index", "search_analyzer": "product_search" } } }
}
Notation: a, b means two-way equivalence; a, b => c rewrites everything on the left to c. Put synonyms in the search_analyzer, not at index time — otherwise every edit to the synonym list means a full reindex. synonym_graph, unlike the older synonym filter, handles multi-word phrases correctly and is designed for search time; keep the list outside the mapping via the synonyms set API so it can change without a deploy.
Text is analysed both when written to the index and when queried. If the two are incompatible, the terms never line up and the result is zero hits — with no error at all.
A real example: you build an edge_ngram analyzer for autocomplete and use it for search too. The user types "laptop"; the query is also ngram-ed into la, lap, lapt… and now every document containing la matches, destroying relevance. The fix: ngram only at index time, and a normal analyzer at search time. The general rule is that the two analyzers must be compatible, not necessarily identical, and every deliberate mismatch should be documented.
edge_ngram produces prefixes from the start of a word: search becomes se, sea, sear, searc, search. Plain ngram produces every substring and is used for "contains" matching.
PUT /catalog
{
"settings": {
"index": { "max_ngram_diff": 18 },
"analysis": {
"tokenizer": { "edge_2_20": { "type": "edge_ngram", "min_gram": 2, "max_gram": 20,
"token_chars": ["letter", "digit"] } },
"analyzer": { "autocomplete_index": { "tokenizer": "edge_2_20", "filter": ["lowercase"] } }
}
},
"mappings": { "properties": {
"name": { "type": "text", "analyzer": "autocomplete_index", "search_analyzer": "standard" } } }
}
The defaults index.max_ngram_diff of 1 and index.max_shingle_diff of 3 are deliberately small to stop an explosion. With min_gram: 1 and max_gram: 20, a 20-character word produces about 20 terms and the index multiplies in size; memory pressure and merge time follow. Never set min_gram below 2, and cap max_gram at the length of a genuinely useful prefix.
Non-English text: the Persian example
Persian, like many non-English scripts, has three concrete problems that will otherwise leave your search permanently "a bit off": the zero-width non-joiner (ZWNJ, U+200C), which makes one word look like three different strings depending on how it was typed; visually identical Arabic and Persian characters such as ي versus ی and ك versus ک; and digits written in Persian, Arabic or Latin forms. The built-in persian analyzer covers exactly these, and this is its official definition:
PUT /articles_fa
{
"settings": { "analysis": {
"char_filter": { "zero_width_spaces": { "type": "mapping", "mappings": [ "\\u200C=>\\u0020" ] } },
"filter": { "persian_stop": { "type": "stop", "stopwords": "_persian_" } },
"analyzer": {
"rebuilt_persian": {
"tokenizer": "standard",
"char_filter": [ "zero_width_spaces" ],
"filter": [ "lowercase", "decimal_digit", "arabic_normalization",
"persian_normalization", "persian_stop", "persian_stem" ]
}
} } }
}
zero_width_spaces turns the ZWNJ into a normal space so a compound word splits into two tokens; decimal_digit folds any Unicode digit to its Latin equivalent; the two normalization filters unify Arabic character shapes; and persian_stem performs Persian stemming.
The senior judgement generalises to any language: a built-in language analyzer is a starting point, not a finished design. On short, high-stakes fields such as product or person names, index both a stemmed and a raw variant as a multi-field and boost the raw one higher in multi_match, because aggressive stemming mangles proper nouns. And install the analysis-icu plugin if you handle several languages: icu_normalizer and icu_folding cover far more of Unicode than asciifolding.
Because matching happens on terms, not on the raw string. If Running was turned into the term run at index time but the query is not transformed, the query looks for a term that simply does not exist in the index and you get zero hits — with no error, which is the worst case because it fails silently.
The deliberate exception is autocomplete: with edge_ngram we intentionally differ, because otherwise the user's prefix is also shredded into ngrams and precision collapses. So the correct rule is that analyzers must be compatible and any mismatch must be intentional and documented. The way to prove it is _analyze, not guesswork.
4. Mappings — the Elasticsearch schema
A mapping defines what type each field has and how it is analysed and stored. It is the equivalent of CREATE TABLE, with one big difference: if you do not write it, Elasticsearch guesses. A string becomes text plus a keyword sub-field with ignore_above: 256, an integer becomes long, a decimal float, and a date-looking string date.
Three common disasters: (1) mapping explosion — a service starts sending dynamic keys like attrs.user_1234 and every key becomes a new field; the default index.mapping.total_fields.limit is 1000 and once you cross it every write fails. (2) wrong type inference — the first document sends "zip": 12345 so the field becomes long, then "zip": "01234" arrives and is rejected. (3) accidental dates — a string like "2024-1" may be detected as a date and every other value gets rejected.
The fixes: the flattened type for dynamic-key data, dynamic: "strict" for everything else, and keeping the mapping as a versioned index template in your repository.
PUT /_index_template/products-template
{
"index_patterns": ["products-*"],
"priority": 200,
"template": {
"settings": { "number_of_shards": 3, "number_of_replicas": 1, "refresh_interval": "5s" },
"mappings": {
"dynamic": "strict",
"properties": {
"sku": { "type": "keyword" },
"name": { "type": "text", "fields": {
"keyword": { "type": "keyword", "ignore_above": 256 },
"ngram": { "type": "text", "analyzer": "autocomplete_index",
"search_analyzer": "standard" } } },
"description": { "type": "text", "analyzer": "english" },
"price": { "type": "scaled_float", "scaling_factor": 100 },
"created_at": { "type": "date", "format": "strict_date_optional_time||epoch_millis" },
"location": { "type": "geo_point" },
"tags": { "type": "keyword" },
"attributes": { "type": "flattened" }
}
}
}
}
Values for dynamic: true (add it), runtime (add as a runtime field, not indexed), false (ignore it but keep it in _source — not searchable), strict (throw). For a public API strict is best because it surfaces producer-side bugs immediately.
The fields block is a multi-field: name is indexed as text for search, name.keyword for filtering and sorting, and name.ngram for autocomplete. One _source, three index structures — the main tool for resolving "I want both search and precision".
| Property | text |
keyword |
|---|---|---|
| Analysed? | yes, split into terms | no, the whole value is one term |
| Good for | free-text search, relevance | exact filters, sort, aggregations, ids and enums |
term query against it |
usually returns nothing | works exactly |
| Aggregations | needs fielddata (dangerous) |
direct via doc values |
| Sorting | effectively meaningless | correct |
| Case sensitive | no (after lowercase) | yes, unless you add a normalizer |
ignore_above |
not available | available; longer values are not indexed |
Say status is text with the value "IN PROGRESS"; the index holds the terms in and progress. You then issue {"term": {"status": "IN PROGRESS"}}: Elasticsearch does not analyse the query string and looks for the exact term IN PROGRESS, which does not exist. Result: zero hits, no error.
Two fixes: make status a keyword (the right call for an enum), or query {"term": {"status.keyword": "..."}}. The subtler version: {"term": {"name.keyword": "iphone"}} also returns nothing if the stored value is iPhone, because keyword does not lowercase. For a case-insensitive exact filter, attach a normalizer with lowercase to the keyword field.
object vs nested vs join
Elasticsearch flattens nested JSON by default. The document {"orders": [{"product":"laptop","qty":1}, {"product":"mouse","qty":5}]} is stored internally as orders.product: ["laptop","mouse"] and orders.qty: [1,5] — and the link between laptop and 1 is lost. The consequence: a query for "an order where product = laptop and qty = 5" returns this document even though no such order exists. That is a bug which sits silently in reports for months.
With nested, each array element is stored as a separate hidden document in the same shard and the relationship is preserved:
PUT /carts
{ "mappings": { "properties": {
"orders": { "type": "nested", "properties": {
"product": { "type": "keyword" }, "qty": { "type": "integer" } } } } } }
GET /carts/_search
{ "query": { "nested": {
"path": "orders",
"query": { "bool": { "filter": [
{ "term": { "orders.product": "laptop" } },
{ "range": { "orders.qty": { "gte": 5 } } } ] } },
"inner_hits": { "size": 3 } } } }
inner_hits tells you which element matched — without it you only know the parent matched.
Do not underestimate the cost: every nested element is a separate Lucene document, so a parent with 200 elements is 201 documents, and updating one element rewrites the entire parent. The default limits are deliberate — index.mapping.nested_fields.limit is 50 and index.mapping.nested_objects.limit is 10000; if you are raising them, your data model is probably wrong. The join type gives you real parent/child so a child can be updated without rewriting the parent, but queries are slower. The rule of thumb: denormalise first, then nested, and join only for a genuine one-to-very-many relationship with independent updates.
object is just a way of writing nested JSON; Elasticsearch flattens it into parallel arrays of values, so the correlation between fields of a single element is destroyed and combined queries return false positives. nested indexes each element as a separate hidden Lucene document and preserves the relationship, but you must query it through a nested query with a path and use inner_hits to see which element matched.
The senior part of the answer is the cost: document count per shard multiplies, any partial update rewrites the whole document family, and aggregations need nested plus reverse_nested. So the first question I ask is whether I genuinely need intra-element correlation or whether I can denormalise instead.
Changing a mapping with zero downtime
You cannot change the type of an existing field, because the data on disk was written with that type. The standard pattern is: new index, reindex, atomic alias swap.
The diagram below is the zero-downtime path — Zero-downtime mapping change with an alias swap.
sequenceDiagram
participant App
participant Alias as "Alias: products"
participant V1 as "products-v1"
participant V2 as "products-v2"
App->>Alias: read and write
Alias->>V1: routes
Note over V2: create with new mapping
V2->>V2: _reindex from products-v1
Note over App,V2: dual-write or replay changes since snapshot
Alias->>V2: atomic _aliases swap
Note over V1: delete after verification
curl -X POST "localhost:9200/_reindex?wait_for_completion=false" \
-H 'Content-Type: application/json' \
-d '{"source":{"index":"products-v1","size":2000},"dest":{"index":"products-v2","op_type":"index"}}'
curl -s "localhost:9200/_tasks?actions=*reindex&detailed"
# atomic swap — there is never a moment without an index
curl -X POST localhost:9200/_aliases -H 'Content-Type: application/json' -d '{
"actions": [
{ "remove": { "index": "products-v1", "alias": "products" } },
{ "add": { "index": "products-v2", "alias": "products" } } ] }'
Never put a real index name in application code. Always create an alias such as products and have the code hit the alias. Reindexing, changing shard counts, rollback and A/B testing then all become possible without a code change and without downtime. It is the cheapest architectural decision in this chapter and the highest-return one.
You cannot change an existing field's type; you can only add new fields. So I create a new versioned index with the correct mapping, move the data with _reindex (using wait_for_completion=false and monitoring the task), compensate for changes that happened during the reindex via dual-write or by replaying from updated_at or a queue offset, verify with document counts and a handful of real sample queries, and then swap the alias atomically with a single _aliases call containing both remove and add.
The senior note: do not delete the old index immediately — keep it for one deploy cycle so a rollback is just another swap. And if you are on a data stream, it is usually enough to update the template and trigger a rollover so new data lands with the new mapping.
5. The query DSL
A filter is the bouncer: it only asks "are you allowed in?" — yes or no. It is fast and its answer can be cached. A query is the judge: it asks "how good were you?" and returns a floating-point _score. That is more expensive and much harder to cache.
The rule: every yes/no condition — a date range, a status, a category, an owner — belongs in a filter. Only what should genuinely affect ranking belongs in a query.
GET /products/_search
{
"query": {
"bool": {
"must": [ { "match": { "name": { "query": "wireless headphones", "operator": "and" } } } ],
"should": [
{ "match_phrase": { "name": { "query": "noise cancelling", "boost": 3 } } },
{ "term": { "brand": { "value": "acme", "boost": 1.5 } } }
],
"filter": [
{ "term": { "active": true } },
{ "terms": { "category": ["audio", "accessories"] } },
{ "range": { "price": { "gte": 50, "lte": 400 } } },
{ "range": { "created_at": { "gte": "now-1y/d" } } }
],
"must_not": [ { "term": { "discontinued": true } } ],
"minimum_should_match": 1
}
},
"size": 20,
"track_total_hits": 1000
}
The four bool clauses: must must match and contributes to _score; filter must match but does not affect _score and is cacheable; should is optional and raises the score when it matches; must_not must not match and runs in filter context.
There are two caches. The node query cache keeps filter results as bitsets in node memory (10% of heap by default) and only kicks in for filters that are reused often enough on large enough segments. The shard request cache caches the whole response for requests with size: 0 — that is, exactly aggregations and dashboards — and is invalidated on every refresh.
First, minimum_should_match: it defaults to 1 only when both must and filter are empty, and to 0 otherwise. So the moment you add a single filter, your should clauses silently degrade from requirements into score boosters and the hit count explodes. If you really mean "at least one of these is required", write it explicitly.
Second, unrounded now: "gte": "now-1h" has millisecond precision, so every request produces a different cache key and you never get a hit. Write "now-1h/h" to round to the hour; on a busy dashboard that one slash can cut CPU load several times over.
| Query | What it does | Query text analysed? | Where to use it |
|---|---|---|---|
match |
analyses, then OR/AND across terms | yes | free-text search — your default |
match_phrase |
terms must be adjacent and in order | yes | quotes, exact phrases |
match_phrase_prefix |
as above but the last term is a prefix | yes | simple search-as-you-type |
multi_match |
match across several fields with boosts |
yes | 90% of product queries |
term |
exact term, no analysis | no | keyword, numbers, booleans |
terms |
any of several exact values | no | multi-select filters |
range |
numeric, date or IP range | no | price, dates |
exists |
field has a value | — | null filters |
prefix |
term starts with this prefix | no | with care |
wildcard / regexp |
* and ? patterns, or a regex over terms |
no | last resort |
fuzzy |
edit distance on a single term | no | typos |
bool |
combines all of the above | — | always |
GET /products/_search
{
"query": {
"multi_match": {
"query": "sony wireless headphone",
"type": "best_fields",
"fields": ["name^4", "brand^2", "description", "tags"],
"tie_breaker": 0.3,
"fuzziness": "AUTO",
"prefix_length": 1,
"minimum_should_match": "70%"
}
},
"highlight": {
"fields": { "name": { "number_of_fragments": 0 },
"description": { "fragment_size": 120, "number_of_fragments": 2 } },
"pre_tags": ["<mark>"], "post_tags": ["</mark>"]
}
}
The multi_match types: best_fields (default) scores as the best single field plus tie_breaker times the rest, and is right when all the words usually live in one field; most_fields sums the scores of all fields and suits a single text indexed with several analyzers; cross_fields treats the fields as one big field and suits names and addresses where words are spread across fields; and phrase, phrase_prefix and bool_prefix cover phrase and prefix cases.
fuzziness: AUTO means terms of 0–2 characters must match exactly, 3–5 characters allow one edit, and longer than 5 allow two. The thresholds are configurable via AUTO:4,8; the default is equivalent to AUTO:3,6.
The real cost of wildcard, prefix and regexp: these perform term expansion — Elasticsearch has to walk the term dictionary, find every matching term and rewrite the query as an OR across them. {"wildcard": {"name": "*phone*"}} means scanning that field's entire term dictionary on every shard, which on a large index takes seconds. The right approach: replace prefix with edge_ngram at index time or the index_prefixes mapping parameter; replace *x* with an ngram field; and on multi-tenant clusters set search.allow_expensive_queries to false so nobody can accidentally take the cluster down.
In query context each clause produces a _score describing how well the document matched; in filter context it is only yes or no. Three practical consequences: a filter does not compute a score so it burns less CPU, it can use lazy execution and skip ahead over postings, and — most importantly — its result is stored as a bitset in the node query cache, so later requests with the same filter get it almost for free.
Inside bool, the filter and must_not clauses run in filter context while must and should run in query context. The right habit is to push every deterministic condition — status, category, ownership, time range — into filter; and always round now, otherwise the cache key changes on every request.
Because term does not analyse the query string while the text field was analysed at index time. If the value is "iPhone 15 Pro", the stored terms are iphone, 15 and pro, and no term equals the full string. The fix is either match, which analyses the query with the same analyzer, or targeting the name.keyword sub-field with term.
The second trap: even on a keyword field, term is case-sensitive because keyword has no analyzer at all. If you want a case-insensitive exact filter, define a normalizer with a lowercase filter on the keyword field and reindex. The way to prove either case is _analyze, not guesswork.
6. Autocomplete and pagination
| Autocomplete strategy | Mechanism | Infix? | Fuzzy? | Memory | When to pick it |
|---|---|---|---|---|---|
index-time edge_ngram |
prefix terms in the normal inverted index | only with full ngram |
yes | more disk, little heap | the general case; when you also need filters and scoring |
search_as_you_type field |
auto-generates ._2gram, ._3gram and ._index_prefix sub-fields |
yes | yes | medium | quick setup without designing an analyzer |
| completion suggester | an FST held in heap | no | yes | heavy heap | short, stable lists with known order (city or brand names) |
match_phrase_prefix |
at query time, no preparation | no | no | none | prototypes and small datasets |
PUT /suggestions
{ "mappings": { "properties": {
"title": { "type": "search_as_you_type", "max_shingle_size": 3 },
"suggest": { "type": "completion", "analyzer": "simple" } } } }
GET /suggestions/_search
{
"suggest": {
"product-suggest": {
"prefix": "wirel",
"completion": { "field": "suggest", "size": 8, "skip_duplicates": true,
"fuzzy": { "fuzziness": "AUTO", "prefix_length": 1 } } }
},
"query": {
"multi_match": { "query": "wirel head", "type": "bool_prefix",
"fields": ["title", "title._2gram", "title._3gram"] }
}
}
The suggest block uses the completion suggester while the query block does the same job through the auto-generated search_as_you_type sub-fields. Alongside these there are also the term suggester and phrase suggester, which serve "did you mean?" rather than autocomplete.
The completion suggester is not what you think it is. Three limitations that people discover late: (1) it matches prefixes only, so "headphone" will never find "wireless headphone" unless you write every rotation as an input; (2) the whole FST is loaded into heap, which costs gigabytes on a few million phrases; (3) filtering is only possible through predefined contexts, not an arbitrary bool — so "autocomplete only over items in stock in this warehouse" must have been anticipated in the context design. For most real products, edge_ngram on a multi-field combined with a bool carrying the business filters is the better choice.
Pagination — where systems go to die
{"from": 0, "size": 20} works up to page 500 and then throws Result window is too large, because index.max_result_window defaults to 10000. The reason: for from=9980, size=20 on a 5-shard index, every shard must sort and ship its own top 10000 hits, and the coordinating node merges 50000 records in memory just to take 20 from the middle. Cost grows linearly with from and multiplies by shard count; a handful of concurrent requests is enough to fill the heap and trip a circuit breaker. That ceiling is a guard rail, not an annoyance.
| Method | Stateful? | Random jump to page N | Result stability | Good for |
|---|---|---|---|---|
from/size |
no | yes | no (data shifts) | UI, first few pages only |
search_after |
no | next/previous only | partial | infinite scroll, public APIs |
search_after with PIT |
yes (the PIT) | next only | fixed snapshot | exports, deep pagination |
scroll |
yes | no | fixed snapshot | bulk export and migration; not recommended for UI |
curl -X POST "localhost:9200/products/_pit?keep_alive=2m"
# response: {"id":"46ToAwMDaWR5..."}
GET /_search
{
"size": 1000,
"query": { "term": { "active": true } },
"pit": { "id": "46ToAwMDaWR5...", "keep_alive": "2m" },
"sort": [
{ "created_at": { "order": "asc", "format": "strict_date_optional_time_nanos" } },
{ "_shard_doc": "asc" }
],
"track_total_hits": false
}
For the next page send the same body and simply add "search_after": ["2026-03-14T10:22:31.001Z", 4294967298] — the sort array of the last hit of the previous page.
Three details that separate a professional from an amateur: a tiebreaker is mandatory — the sort key must become unique or records duplicate or vanish between pages; with a PIT the _shard_doc field is added automatically, and without one you must append _id as the last sort key. track_total_hits stops counting at 10000 by default and reports hits.total.relation as "gte"; set it to true for an exact count (expensive) and to false while paginating. And close the PIT with DELETE /_pit, because an open PIT pins segments and prevents merges from reclaiming space.
For a UI I do not offer deep pagination at all; the first few pages use from/size and after that I push the user towards filters and sorting, because nobody genuinely reads page 4000. For APIs and infinite scroll I use search_after with a unique sort key, which gives a stateless cursor whose per-page cost is constant.
For exports or batch processing I combine search_after with a PIT so the view of the data stays fixed for the whole traversal and concurrent inserts cannot cause duplicates or gaps; I set track_total_hits: false and close the PIT in a finally block. I keep scroll only for one-off internal migrations, since it pins search contexts on the shards and blocks merges. And I never raise max_result_window — that limit is protecting the cluster.
7. Relevance — why is this document on top?
You ask a librarian: "do you have a book about database indexes?" They weigh three things. (1) How many times does the book use the word "index" — more is more relevant, but past a point it stops mattering; a book that says it 500 times is not necessarily better than one that says it 100 times. (2) How rare is the word "index" — if every book on the shelf contains it, it no longer discriminates. (3) How long is the book — a 10-page note using the word five times is more focused than a 1000-page encyclopedia using it five times.
Those are exactly the three components of BM25: term-frequency saturation, rarity weighting, and length normalisation.
The older TF-IDF formula scored roughly tf × idf × 1/√length, and its flaw was that tf grew without bound, so keyword-stuffed documents scored absurdly high. BM25 — the Elasticsearch default since version 5 — adds two parameters: k1, default 1.2, controlling how quickly term frequency saturates (from the third occurrence onward each extra one matters less as the curve approaches a ceiling); and b, default 0.75, controlling the strength of length normalisation (b = 0 ignores document length, b = 1 weights it fully).
PUT /articles
{
"settings": { "index": { "similarity": {
"tuned_bm25": { "type": "BM25", "k1": 1.1, "b": 0.4 } } } },
"mappings": { "properties": {
"title": { "type": "text", "similarity": "tuned_bm25" },
"body": { "type": "text" } } }
}
Touch k1 and b almost never — certainly not before you have fixed mappings, analyzers and field boosts. The one genuine case is fields whose length varies enormously; for short titles a smaller b makes sense so a one-word title does not win purely by being short.
Textual relevance is only half the story. In a shop, "relevant" means combining text with stock, ratings, recency and margin:
GET /products/_search
{
"query": {
"function_score": {
"query": { "multi_match": { "query": "running shoes", "fields": ["name^3", "description"] } },
"functions": [
{ "filter": { "term": { "in_stock": true } }, "weight": 2 },
{ "field_value_factor": { "field": "rating", "factor": 1.2,
"modifier": "sqrt", "missing": 3 } },
{ "gauss": { "created_at": { "origin": "now", "scale": "30d", "decay": 0.5 } } }
],
"score_mode": "sum",
"boost_mode": "multiply"
}
}
}
score_mode says how the individual functions combine with each other and boost_mode says how their result combines with the textual score. gauss is a decay curve: the further from origin, the lower the score — the standard tool for recency and geographic proximity.
These functions are expensive: they run for every candidate document, not just the top 10. If the query matches a million documents, the function runs a million times. Two escapes: shrink the candidate set with filter first; and instead of scripts use the rank_feature and rank_features field types, which are optimised in Lucene and let you fold a numeric signal such as "view count" into the score without executing a script per document. Never sort by _script on a user-facing path; it is both slow and it defeats Lucene's early-termination optimisations.
When the wrong result is on top, do not guess — ask for an explanation:
curl -X GET localhost:9200/products/_explain/SKU-123 -H 'Content-Type: application/json' -d '{
"query": { "multi_match": { "query": "running shoes", "fields": ["name^3","description"] } } }'
The output is a tree: each node has a value, a description and children. There you can see exactly what boost, idf (with n and N) and tf (with freq, k1, b, dl, avgdl) contributed. To see the same for every hit, add "explain": true to the _search body. For a latency problem rather than a ranking problem you have a different tool: "profile": true, which breaks down where time went in each shard.
Scores are not comparable across shards. By default idf is computed from the statistics of that shard, not the whole index. With few documents and an uneven distribution, two identical documents in two shards get different scores. This usually appears in a test environment with 20 documents and 5 shards and sends you hunting for an imaginary bug. The temporary fix for testing is ?search_type=dfs_query_then_fetch, which gathers distributed statistics first; the real fix in production is fewer shards and more data.
Beyond lexical search (BM25), Elasticsearch also supports vector search: the dense_vector and sparse_vector field types, kNN search, the semantic_text field which chunks and embeds text at ingest time, and the retriever framework which became generally available in 8.16. The architecturally important one is rrf — Reciprocal Rank Fusion — which merges the rankings of BM25 and vector results without you having to normalise two incompatible score scales.
The senior judgement: vector search does not replace BM25, it complements it. For exact queries such as part numbers and SKUs, lexical always wins. Get BM25 right first; only if conceptual queries still fail should you add a hybrid layer.
First I eliminate assumptions: _analyze shows me what terms the query and the field really produce, because most relevance bugs are actually analyzer bugs. Then I run GET /index/_explain/<id> with the same query and read the explanation tree: the idf contribution, the tf contribution with its k1, b, dl and avgdl parameters, and each clause's boost. I run the same for the document I expected to win and diff the two trees — the difference is always in one identifiable node.
If the numbers look sane but the result is still bad, the problem is the scoring model rather than the execution: usually most_fields was used where best_fields belonged, or a long field competes with a short one without being down-weighted, or a business signal was never wired in. And I always flag that in a small test environment, per-shard idf differences can masquerade as a bug.
8. Aggregations — the analytics half of the engine
Elasticsearch is also a columnar analytics engine working over doc values. There are three families: metric (avg, sum, cardinality, percentiles, top_hits), bucket (terms, range, date_histogram, filters, nested, composite), and pipeline, which operates on the output of other aggregations (derivative, moving_fn, cumulative_sum, bucket_selector, bucket_sort).
GET /orders/_search
{
"size": 0,
"query": { "bool": { "filter": [ { "range": { "created_at": { "gte": "now-90d/d" } } } ] } },
"aggs": {
"per_day": {
"date_histogram": { "field": "created_at", "calendar_interval": "day",
"time_zone": "Europe/Berlin", "min_doc_count": 0 },
"aggs": {
"revenue": { "sum": { "field": "total" } },
"unique_buyers": { "cardinality": { "field": "customer_id", "precision_threshold": 3000 } },
"revenue_growth": { "derivative": { "buckets_path": "revenue" } }
}
},
"top_categories": {
"terms": { "field": "category", "size": 10, "shard_size": 100,
"order": { "revenue": "desc" } },
"aggs": {
"revenue": { "sum": { "field": "total" } },
"big_only": { "bucket_selector": { "buckets_path": { "r": "revenue" },
"script": "params.r > 10000000" } }
}
}
}
}
"size": 0 means "no hits, just aggregations" — and that is exactly what enables the shard request cache.
termsis not exact: each shard computes its own topshard_sizebuckets and ships them; if the distribution is uneven, one category's contribution from one shard is never sent and the final count comes out lower than reality. Thedoc_count_error_upper_boundandsum_other_doc_countfields exist precisely for this. The remedies are a largershard_size(the default is roughlysize * 1.5 + 10) or acompositeaggregation paginated withafter.- Aggregating on a
textfield: you get "Fielddata is disabled" and are tempted to set"fielddata": true— which loads every term of that field into heap and is the classic way to take a cluster down. The right answer is the.keywordsub-field. - Forgetting
time_zoneindate_histogram: day boundaries fall on UTC and financial reports shift by hours — a bug found by the finance team, not the engineering team. Also note thatcardinalityis approximate (HyperLogLog++) andprecision_thresholdcaps at 40000.
Because aggregation is distributed: each shard only returns its own top shard_size buckets, so with an uneven distribution a value can drop off some shards' lists and the merged count ends up lower than the truth. Elasticsearch reports that uncertainty in doc_count_error_upper_bound and sum_other_doc_count.
The remedies in increasing cost: raise shard_size, reduce shard count, or use a composite aggregation which walks every bucket exactly with pagination. If this is a financial report that must be exact, composite or an offline batch computation is the right answer — and I would remind everyone that cardinality is approximate by design.
9. Cluster mechanics
A node is a JVM process, and you set its responsibilities with node.roles: master (manages cluster state, not data), data plus the tier roles data_hot, data_warm, data_cold, data_frozen, data_content, and also ingest (runs pipelines before indexing), ml, transform and remote_cluster_client. With node.roles: [] you get a pure coordinating node that only routes requests and merges results.
A primary shard is one slice of an index and is itself a complete Lucene index; its count is fixed at index creation and can only change via _split, _shrink or a reindex. A replica is a copy of a primary on another node, giving both redundancy and read capacity, and its count can be changed at any time.
Which shard does a document land on? A deterministic formula: shard = hash(routing) % number_of_primary_shards, where routing defaults to the _id. That formula is exactly why the primary shard count cannot change after creation: changing the divisor would relocate every document.
If your data is naturally multi-tenant (tenant_id), pass routing=tenant_id at both index and search time. Every query then hits one shard instead of all of them, which drops latency and cluster load dramatically. The price is the risk of a hot shard if one tenant is far larger than the rest; for that tenant, raise index.routing_partition_size so its data spreads across several shards.
The diagram below is what a search actually does — Distributed search: query phase gathers ranked ids, fetch phase loads documents.
sequenceDiagram
participant C as Client
participant Co as "Coordinating node"
participant S1 as "Shard A copy"
participant S2 as "Shard B copy"
C->>Co: POST /idx/_search size=10 from=0
Co->>S1: Query phase (local top 10 ids and scores)
Co->>S2: Query phase (local top 10 ids and scores)
S1-->>Co: 10 doc ids plus scores
S2-->>Co: 10 doc ids plus scores
Co->>Co: Merge and sort, keep global top 10
Co->>S1: Fetch phase (get _source for chosen ids)
Co->>S2: Fetch phase (get _source for chosen ids)
S1-->>Co: documents
Co-->>C: Final ranked hits
Three consequences. (1) from + size is multiplied by shard count — for from=9000, size=10 on 5 shards, the first phase merges 5 × 9010 records; that is the root of the 10000 ceiling. (2) The slowest shard sets the overall latency — one oversized shard raises p99 for every query. (3) The fetch phase only reads _source for the winning documents, so use _source: {"includes": [...]} or fields to take only what you need.
refresh, flush, merge — and what "near real-time" means
The diagram below is the life of a write — Document write lifecycle: buffer, refresh, flush, merge.
stateDiagram-v2
[*] --> InMemoryBuffer: index request
InMemoryBuffer --> Translog: appended for durability
InMemoryBuffer --> NewSegment: refresh (default 1s)
NewSegment --> Searchable: visible to search
Searchable --> DiskCommit: flush (Lucene commit, translog truncated)
DiskCommit --> MergedSegment: background merge
MergedSegment --> [*]: deleted docs reclaimed
- refresh turns the in-memory buffer into a new segment so it becomes searchable.
index.refresh_intervaldefaults to1s. This is the only reason we call Elasticsearch near real-time: a document you just wrote is not in_searchresults for about a second — butGET /index/_doc/<id>returns it immediately, because that path reads from the translog. - translog is an append-only log for durability. With
index.translog.durabilityset torequest(the default) every request is fsynced before it is acknowledged; withasyncit is flushed every 5 seconds — faster, but with a data-loss window. - flush is a real Lucene commit; segments become durable and the translog is truncated. It happens automatically.
- merge combines several small segments into a larger one and genuinely reclaims the space of deleted documents.
Every POST /idx/_doc?refresh=true creates a new segment; inside a 1000-iteration loop that is 1000 tiny segments for the merge policy to clean up. If you truly must search immediately after writing (usually only in tests), use ?refresh=wait_for, which waits for the next scheduled refresh and creates no extra segment. For a bulk load, do the opposite:
curl -X PUT localhost:9200/products/_settings -H 'Content-Type: application/json' \
-d '{"index":{"refresh_interval":"-1","number_of_replicas":0}}'
# ... bulk-load the data here ...
curl -X PUT localhost:9200/products/_settings -H 'Content-Type: application/json' \
-d '{"index":{"refresh_interval":"1s","number_of_replicas":1}}'
curl -X POST "localhost:9200/products/_forcemerge?max_num_segments=1"
Only run _forcemerge on indices that will receive no further writes, such as yesterday's log index. On a live index the resulting huge segments will never be merged again and deleted documents accumulate inside them forever. That is also why ILM places forcemerge in the warm phase, not the hot phase.
refresh converts the in-memory buffer into a new, searchable segment and runs every second by default; flush is a commit to disk that makes segments durable and truncates the translog; merge combines small segments and reclaims the space of deleted documents. Durability comes from the translog, not from refresh — data is safe before it is searchable.
Elasticsearch is near real-time: there is roughly a one-second window between writing and appearing in search. But GET by id answers immediately because it reads the translog. So if you have a read-your-own-write pattern, read with GET rather than _search, or use ?refresh=wait_for — and never put ?refresh=true on a hot production path.
How many shards? — and the classic over-sharding mistake
The misconception is "more shards means more parallelism means faster". The reality is that each shard is a complete Lucene index with its own files, buffers, threads and share of the cluster state. A cluster with 5000 nearly-empty shards can become unstable simply because the cluster state grows and the master slows down.
The official numbers worth memorising: keep each shard between 10 GB and 50 GB and under 200 million documents; the default ceiling is 1000 non-frozen shards per node (and 3000 frozen shards on a dedicated frozen node) via cluster.max_shards_per_node; keep fewer than 3000 indices per GB of heap on master nodes; and cap heap at 31 GB so you keep compressed object pointers. The old "20 shards per GB of heap" rule of thumb was deprecated in 8.3.
A back-of-the-envelope for a new index: final data volume including a year of growth divided by 40 GB, rounded up, and at least as many shards as you have data nodes. For 600 GB of data that is roughly 15 primary shards. If the index is small and static, number_of_shards: 1 is entirely correct.
For time-based data (logs, metrics, events) you should never have a single ever-growing index. The right pattern is a data stream: one logical name backed by a series of hidden indices, with rollover creating a new one whenever the current index crosses a threshold.
The diagram below is the data lifecycle under ILM — Index lifecycle: hot to warm to cold to frozen to delete.
flowchart LR
H["Hot: active writes, fast SSD"] -->|rollover 50gb or 1d| W["Warm: read only, forcemerge, shrink"]
W -->|min_age 30d| C["Cold: cheaper nodes, searchable snapshot"]
C -->|min_age 90d| F["Frozen: object storage, slow queries"]
F -->|min_age 365d| D["Delete"]
PUT /_ilm/policy/logs-lifecycle
{
"policy": { "phases": {
"hot": { "min_age": "0ms", "actions": {
"rollover": { "max_primary_shard_size": "50gb", "max_age": "1d" },
"set_priority": { "priority": 100 } } },
"warm": { "min_age": "2d", "actions": {
"shrink": { "number_of_shards": 1 },
"forcemerge": { "max_num_segments": 1 },
"set_priority": { "priority": 50 } } },
"cold": { "min_age": "30d", "actions": { "set_priority": { "priority": 0 } } },
"delete": { "min_age": "90d", "actions": { "delete": {} } }
} }
}
Prefer max_primary_shard_size over max_size: max_size measures the whole index, so changing shard count pushes each shard outside the healthy range. max_primary_shard_size controls the thing that actually matters. Always pair it with a max_age so a low-traffic index does not stay open forever.
| Task | Command |
|---|---|
| Cluster health | GET /_cluster/health?level=indices |
| Why is a shard unassigned | GET /_cluster/allocation/explain |
| Indices with sizes | GET /_cat/indices?v&s=store.size:desc |
| Shard distribution | GET /_cat/shards?v&s=store:desc |
| Heap and disk per node | GET /_cat/nodes?v&h=name,heap.percent,disk.used_percent,node.role |
| Currently running queries | GET /_cat/tasks?v&detailed |
| Cancel a heavy query | POST /_tasks/<task_id>/_cancel |
| Is a thread pool saturated | GET /_cat/thread_pool/search,write?v&h=node_name,name,active,queue,rejected |
| Cache and merge stats | GET /index/_stats/query_cache,request_cache,merge,refresh |
| Test an analyzer | POST /_analyze |
| Explain a score | GET /index/_explain/<id> |
| Dynamic cluster settings | PUT /_cluster/settings |
I start with three questions: how much data in a year, is the pattern time-based or static, and how many data nodes do we have. Then the arithmetic is simple: each primary shard between 10 and 50 GB and under 200 million documents, so shard count is roughly volume divided by 40 GB, raised to at least the number of data nodes so we actually get parallelism. For time-based data I do not pick a fixed number at all; I use a data stream with rollover on max_primary_shard_size: 50gb so size regulates itself.
What I emphasise most is avoiding over-sharding: every shard carries a fixed cost in heap and cluster state, the default ceiling is 1000 shards per node, and the old "20 shards per GB of heap" rule is deprecated. If I have to be wrong, I would rather have fewer, larger shards — growth can be handled with rollover and _split, but master instability cannot.
First I define "slow": p99 or average, all queries or one pattern. Then I start from the cluster side: _cat/thread_pool/search to see whether queue and rejected are climbing, _cat/nodes for heap and disk, and _cat/shards to find an unbalanced or oversized shard. If one node is red, the problem is usually distribution rather than the query.
Then I go to the query itself: "profile": true shows where the time goes, and I look for the usual suspects — a leading-wildcard wildcard, a script in scoring or sorting, deep from, a huge terms list, an aggregation on a very high-cardinality field, or an unrounded now. I also enable the slow log so I see the real traffic pattern rather than my own hand-picked sample. Finally I look at the data layer: segment count and merge statistics, oversized _source inflating the fetch phase, and whether we have enough replicas for the read load.
10. The Elastic Stack and the Java clients
Elasticsearch on its own is only the engine. Beats are lightweight single-purpose collectors (Filebeat for log files, Metricbeat for metrics, Heartbeat for uptime), but today Elastic Agent replaces all of them: one unified agent centrally managed through Fleet in Kibana. Logstash is a heavyweight transformation pipeline with dozens of input and output plugins; if your transformation is simple (grok, renaming a field, adding geo data) you do not need it, because an ingest pipeline inside Elasticsearch does the same job with one fewer service to run. Kibana is the UI: data exploration, dashboards, ILM and template management, and Dev Tools.
The diagram below shows where each piece sits — Elastic Stack data flow from source to dashboard.
flowchart LR
A["App logs / metrics / DB rows"] --> B["Elastic Agent or Beats"]
B --> C["Logstash (optional heavy transform)"]
B --> D["Ingest pipeline (inside Elasticsearch)"]
C --> D
D --> E["Elasticsearch indices / data streams"]
E --> F["Kibana: Discover, Lens, Alerts"]
E --> G["Your application search API"]
PUT /_ingest/pipeline/normalize-logs
{
"processors": [
{ "grok": { "field": "message",
"patterns": ["%{TIMESTAMP_ISO8601:ts} %{LOGLEVEL:level} %{GREEDYDATA:msg}"] } },
{ "date": { "field": "ts", "formats": ["ISO8601"], "target_field": "@timestamp" } },
{ "lowercase": { "field": "level" } },
{ "remove": { "field": ["ts", "message"], "ignore_missing": true } }
],
"on_failure": [ { "set": { "field": "error.pipeline", "value": "normalize-logs" } } ]
}
These two use cases have almost nothing in common, and if you put both on one cluster the heavy log ingest will ruin product-search latency. Logs mean very high write volume, low read volume, data that is worthless after 30 days, and serious ILM. Product search means low write volume, high read volume at low latency, sensitive relevance and a mapping that keeps changing. The rule: separate the clusters, or at the very least give them dedicated nodes on separate tiers. The observability side is covered in the observability chapter.
The old TransportClient and RestHighLevelClient have been removed. Today's official client is the Elasticsearch Java API Client under the co.elastic.clients group:
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>9.3.0</version>
</dependency>
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
public class ProductSearch {
private final ElasticsearchClient client;
public ProductSearch(String host, int port) {
RestClient rest = RestClient.builder(new HttpHost(host, port, "https")).build();
this.client = new ElasticsearchClient(new RestClientTransport(rest, new JacksonJsonpMapper()));
}
public SearchResponse<Product> search(String text, double maxPrice) throws Exception {
Query byText = Query.of(q -> q.multiMatch(m -> m
.query(text).fields("name^4", "description").fuzziness("AUTO")));
Query priceFilter = Query.of(q -> q.range(r -> r.number(n -> n.field("price").lte(maxPrice))));
Query activeFilter = Query.of(q -> q.term(t -> t.field("active").value(true)));
return client.search(s -> s
.index("products") // an alias, never a real index name
.query(q -> q.bool(b -> b.must(byText).filter(priceFilter, activeFilter)))
.size(20)
.trackTotalHits(t -> t.count(1000)),
Product.class);
}
}
Batch writes must always go through bulk, never one at a time:
public void indexAll(List<Product> batch) throws Exception {
BulkRequest.Builder br = new BulkRequest.Builder().index("products");
for (Product p : batch) {
br.operations(op -> op.index(i -> i.id(p.sku()).document(p)));
}
BulkResponse res = client.bulk(br.build());
if (res.errors()) {
res.items().stream()
.filter(it -> it.error() != null)
.forEach(it -> log.error("bulk item {} failed: {}", it.id(), it.error().reason()));
}
}
The bulk API returns HTTP 200 even when half the documents failed. If you only catch exceptions and never read the response, data disappears silently and months later you discover the index is incomplete. Always check errors(), and distinguish a permanent failure such as mapper_parsing_exception (which belongs in a dead-letter queue) from a transient one such as es_rejected_execution_exception (which should be retried with backoff). A healthy batch is 5–15 MB, sized by bytes rather than document count; oversized batches saturate the write thread pool and get rejected.
In Spring, Spring Data Elasticsearch sits on top of that same client:
@Document(indexName = "products")
public class ProductDoc {
@Id
private String sku;
@Field(type = FieldType.Text, analyzer = "standard")
private String name;
@Field(type = FieldType.Keyword)
private String brand;
@Field(type = FieldType.Scaled_Float, scalingFactor = 100)
private BigDecimal price;
@Field(type = FieldType.Date, format = DateFormat.date_optional_time)
private Instant createdAt;
}
For anything beyond trivial queries, use NativeQuery with ElasticsearchOperations — the full query DSL, with type safety:
public SearchHits<ProductDoc> topProducts(String text, ElasticsearchOperations ops) {
NativeQuery query = NativeQuery.builder()
.withQuery(q -> q.bool(b -> b
.must(m -> m.multiMatch(mm -> mm.query(text).fields("name^4", "description")))
.filter(f -> f.term(t -> t.field("active").value(true)))))
.withPageable(PageRequest.of(0, 20))
.build();
return ops.search(query, ProductDoc.class);
}
Take the version compatibility matrix seriously. Spring Data Elasticsearch is pinned to a specific version of the official client: 6.1.x targets Elasticsearch 9.4.2 and Spring Framework 7.0.x, 6.0.x targets Elasticsearch 9.2.2, and 5.5.x targets Elasticsearch 8.18.1. Do not manually override the elasticsearch-java version, because the incompatibility shows up as a runtime NoSuchMethodError rather than a compile error; and when upgrading, raise the cluster first and the application second. For testing, rather than mocking the client, start a real Elasticsearch with Testcontainers — no mock reproduces analyzer and mapping behaviour (see the testing chapter).
Through spring-boot-starter-data-elasticsearch, which builds the same official ElasticsearchClient underneath. For simple CRUD an ElasticsearchRepository is fine, but I write every serious query with NativeQuery and ElasticsearchOperations, because the whole query DSL is available and I am not forced to cram logic into a method name.
In review I look at three things: are we hitting an alias or a real index name (it must be an alias); is BulkResponse.errors() actually checked, or has someone assumed HTTP 200 means success; and are the filters in filter rather than accidentally in must, which both loses caching and pollutes the score. I also check that the mapping lives as a versioned index template in the repository rather than relying on automatic index creation in production.
11. Search architecture — keeping in sync with the primary database
Elasticsearch is a search index, not a source of truth.
The reason is technical, not stylistic: there are no multi-document transactions, the consistency model is near real-time, changing a mapping requires rebuilding the data, and the data model is denormalised. If it is wiped, you must be able to rebuild it from the primary source with one command. If you cannot, your architecture has a defect.
The diagram below puts the three strategies side by side — Three ways to keep the search index in sync with the primary database.
flowchart TD
subgraph DW["1. Dual write (avoid)"]
A1["Service"] --> B1[(Primary DB)]
A1 --> C1[(Elasticsearch)]
end
subgraph OB["2. Transactional outbox"]
A2["Service"] -->|one local transaction| B2[(Primary DB plus outbox table)]
B2 --> R2["Relay / poller"]
R2 --> C2[(Elasticsearch)]
end
subgraph CDC["3. Log-based CDC"]
B3[(Primary DB)] -->|WAL / redo log| K3["CDC connector"]
K3 --> Q3["Message broker"]
Q3 --> I3["Indexer service"]
I3 --> C3[(Elasticsearch)]
end
"Write to the database, then write to Elasticsearch" looks simple and works fine in the first week. The problem is that the two writes are not atomic: if the process crashes between them, the network drops, or the database transaction rolls back, the two systems diverge permanently and nobody notices.
Worse: if you put the Elasticsearch write inside the database transaction, then Elasticsearch slowness or downtime directly breaks your business transactions and lengthens lock hold times. If you truly must dual-write (a prototype, or a low-stakes system), at least write a nightly reconciliation job that finds and repairs divergence using counts and checksums.
In the outbox pattern you write an event row into an outbox table inside the very same transaction that changes the data. Because both happen in one local transaction, either both commit or neither does. A separate relay then reads those rows and bulk-ships them to Elasticsearch.
CREATE TABLE outbox (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
-- partial index: only unpublished rows
CREATE INDEX idx_outbox_unpublished ON outbox (id) WHERE published_at IS NULL;CREATE TABLE outbox (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
aggregate_id VARCHAR2(64) NOT NULL,
event_type VARCHAR2(64) NOT NULL,
payload CLOB NOT NULL CHECK (payload IS JSON),
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
published_at TIMESTAMP WITH TIME ZONE
);
-- Oracle has no partial index; emulate it with a function-based index
CREATE INDEX idx_outbox_unpublished
ON outbox (CASE WHEN published_at IS NULL THEN id END);In both engines, an index holding only unpublished rows stays small even when outbox holds millions of historical rows. In PostgreSQL that is done directly with WHERE; in Oracle, because a B-tree does not index all-NULL entries, the CASE expression achieves the same thing (see the sql-mastery and oracle-postgres-dialects chapters).
If you would rather poll, at least do it properly — with keyset pagination, never OFFSET:
SELECT id, updated_at
FROM products
WHERE (updated_at, id) > ($1, $2)
ORDER BY updated_at, id
FETCH FIRST 1000 ROWS ONLY;SELECT id, updated_at
FROM products
WHERE (updated_at, id) > ((:last_ts, :last_id))
ORDER BY updated_at, id
FETCH FIRST 1000 ROWS ONLY;Polling on updated_at has three holes: it never sees deletes (a deleted row simply stops appearing and lives in Elasticsearch forever — the fix is a soft delete); clock skew between servers can drop records; and long transactions that commit late with an older updated_at fall outside the pull window, which is why teams deliberately overlap the window by a few seconds and rely on idempotency.
Log-based CDC means reading the database transaction log directly — the WAL in PostgreSQL, redo via LogMiner or XStream in Oracle — and turning each change into an event. No application code change is needed, deletes are visible, and the event order is exactly commit order.
| Criterion | Dual write | Outbox | Log-based CDC | Polling |
|---|---|---|---|---|
| Guaranteed not to lose events | no | yes (one local transaction) | yes | partial |
| Sees deletes | yes, if you remember | yes | yes | no, unless soft delete |
| Application code change | large | moderate (write the event) | almost none | small |
| Typical latency | immediate | sub-second to seconds | sub-second | the polling interval |
| Load on the database | low | extra write plus polling | log reading, low | repeated query |
| Operational complexity | low | moderate | high (connector, broker, monitoring) | low |
| Initial index backfill | manual | manual | usually an automatic snapshot | manual |
| When to choose it | never in a serious system | the sensible default for most teams | high volume, many consumers, no code access | small data, high latency tolerance |
Broker and delivery semantics are covered in the messaging and ms-data chapters.
Every event pipeline is ultimately at-least-once: one event may arrive twice, and two events from different partitions may arrive out of order. If you write blindly, an older version can overwrite a newer one and the user sees their change "disappear".
The correct fix is external versioning: send a monotonic counter from the source (an LSN, an SCN, a row version, or a millisecond commit timestamp) as the document version:
curl -X PUT "localhost:9200/products/_doc/SKU-123?version=1712345678901&version_type=external" \
-H 'Content-Type: application/json' -d '{"sku":"SKU-123","name":"Wireless Headphones"}'
With version_type=external, Elasticsearch rejects a write whose version is lower than or equal to the stored one with a 409. That 409 is not an error — it means "this event was stale"; count it and move on. For read-modify-write flows you also have if_seq_no and if_primary_term, which give optimistic concurrency control.
Every sync architecture needs a full rebuild path that can be run at any moment: create a new index with the fresh mapping; configure the event consumer to write to both the old and the new index (this is dual indexing, not dual writing — it is safe because both destinations are fed from the same event source); backfill with search_after plus a PIT from the primary database, or _reindex from the previous index; verify with document counts and random sampling; then swap the alias atomically and keep the old index for a week.
- Denormalise at index time, not at query time. Elasticsearch has no
JOIN, and emulating one with extra round trips multiplies latency. The price is that renaming a brand means updating all of its products — which is manageable with_update_by_queryand a background queue. - Put the access control list in the document, for example an
acl_grouparray, and add afilteron it to every query. Filtering results after retrieval both breaks pagination and leaks information through the hit count. - Never pass a raw user query to Elasticsearch. Translate the input into the query DSL in your own service; do not expose
query_string, which has its own syntax and can construct extremely expensive queries. Always settimeoutandterminate_after. - Measure relevance. Build an evaluation set of real queries with known-good results and run it before every boost change. Without that, tuning relevance is just moving complaints around.
I start by stating that Elasticsearch is not the source of truth; it must always be rebuildable from the primary database. I rule out dual write because the two writes are not atomic and the first crash or rollback makes the systems diverge silently. My default is a transactional outbox: an event row written in the same transaction that changes the data, and an independent relay that reads it and bulk-ships it. If volume is high, several consumers are needed, or we are not allowed to touch legacy code, I move to log-based CDC, which reads changes from the WAL or redo log and also sees deletes.
In both cases the consumer must be idempotent and use version_type=external against a monotonic counter, so a late-arriving old event cannot overwrite a newer version; a 409 there means "stale event", not a failure. And I always keep a full backfill path plus a periodic reconciliation job.
No, unless the data is inherently reproducible — a log index, for instance. The technical reasons: there are no multi-document transactions; the consistency model is near real-time so read-your-own-write does not work instantly; changing a field's type means a full index rebuild; and the data model is denormalised, so one small update may touch millions of documents.
The complete senior answer adds that keeping a relational source of truth converts Elasticsearch from a critical system into a disposable one: you can freely change the mapping, rebuild, upgrade the cluster version, and in a crisis simply take it out of the path and fall back to simpler search. That property is itself an architectural decision, not a limitation.
12. Production checklist
| Topic | The right decision |
|---|---|
| Index access | always through an alias, never a real name |
| Mapping | explicit with dynamic: strict, as a versioned index template in the repo |
| Enums and ids | keyword, not text |
| Filters | in filter, with now rounded |
| Shard count | 10–50 GB per shard, under 200 million documents |
| Time-based data | data stream with ILM and rollover on max_primary_shard_size |
| Writes | bulk with 5–15 MB batches, check errors(), exponential backoff |
| Pagination | search_after with a PIT; never raise max_result_window |
| Synchronisation | outbox or CDC, never dual write; write with version_type=external |
| Rebuild | a full backfill path that is always runnable, plus a reconciliation job |
| Security | TLS, authentication, and an ACL filter inside the query itself |
| Monitoring | search latency, thread pool queue and rejections, heap, shard count, ILM health |
Search means turning text into terms and keeping a term-to-document mapping in an inverted index; everything else follows from that single idea. Analysis determines which terms exist, and if the index-time and query-time analyzers are incompatible your results silently become zero. Mappings are where most bugs are born: text for free text, keyword for filtering, sorting and aggregating, multi-fields to get both, and nested only when intra-element correlation genuinely matters.
In the query DSL, every deterministic condition belongs in filter so it can be cached, and expensive queries such as leading-wildcard wildcard should be replaced with index-time design. Ranking is BM25, and you debug it with _explain rather than guesswork. Cluster mechanics — query-then-fetch, refresh vs flush vs merge, and shard sizing — explain why deep pagination is expensive, why data is invisible for a second, and why over-sharding is the most common architecture mistake.
And finally the most important senior judgement: Elasticsearch is a search index, not a source of truth. Feed it from the primary database with an outbox or CDC, make the consumer idempotent and version-aware, and always keep a path to a full rebuild. Any cluster you cannot rebuild with one command is technical debt waiting for incident day.