بررسی عمیق Elasticsearch برای مصاحبههای طراحی سیستم
نحوه استفاده از Elasticsearch برای جستجو، شامل index و mapping، کوئری، مرتبسازی، صفحهبندی و معماری داخلی آن بر پایه Lucene، segment، inverted index و گرههای cluster و نکات مصاحبه
Elasticsearch
در این فصل با نحوه استفاده از Elasticsearch برای حل تعداد زیادی از مسائل طراحی سیستم آشنا میشویم.
بسیاری از مسائل طراحی سیستم شامل نوعی جستجو و بازیابی هستند: «چیزهای» زیادی دارم و میخواهم بتوانم مورد یا موردهای مناسب را پیدا کنم. بیشتر سیستمهای پایگاه داده در این کار خوباند (مثلاً Postgres با ایندکس متن کامل برای خیلی از مسائل کافی است!). اما در مقیاس یا سطح مشخصی از پیچیدگی، میخواهید سیستمی هدفمند بسازید. و معمولاً این شامل الزامات مشابه زیادی مثل مرتبسازی، فیلتر، رتبهبندی، صفحهبندی و مانند آن میشود. اینجا یکی از معروفترین موتورهای جستجو وارد میشود: Elasticsearch.
از دید مصاحبه، این بررسی عمیق Elasticsearch را از دو زاویه مختلف میبیند.
اول، یاد میگیرید Elasticsearch را چطور استفاده کنید. این یک ابزار قدرتمند به زرادخانه شما اضافه میکند. بهندرت سوال جستجو و بازیابیای پیدا میکنید که برای Elasticsearch زیادی پیچیده باشد. اگر برای استارتاپ مصاحبه میدهید، یا در مصاحبهای به سبک product architecture هستید، دانستن Elasticsearch کمک میکند. اگر قبلاً از آن استفاده کردهاید، میتوانید این بخش را رد کنید!
دوم، یاد میگیرید Elasticsearch در پشت صحنه چطور کار میکند. بهعنوان یک اثر باورنکردنی از مهندسی سیستمهای توزیعشده، Elasticsearch مفاهیم مختلف زیادی را کنار هم میآورد که حتی خارج از مسائل جستجو و بازیابی هم قابل استفادهاند. و بعضی مصاحبهگرهای سختگیر (من تنها نیستم، قول میدهم!) ممکن است از شما بخواهند وانمود کنید Elasticsearch وجود ندارد و بعضی مفاهیم سطح بالا را خودتان توضیح دهید. این بیشتر برای نقشهای سنگین زیرساخت، بهخصوص در شرکتهای ابری، رایج است.
Elasticsearch پروژه عظیمی است که بیش از یک دهه ساخته شده. پس ویژگیها و قابلیتهای زیادی دارد که اینجا به آنها نمیپردازیم، اما سعی میکنیم هنگام عمیق شدن در آن، بخشهای مهم را پوشش دهیم.
مفاهیم اصلی
با نامها شروع کنیم. مفاهیم مهم Elasticsearch از دید کلاینت، document، index، mapping و field هستند.
Document
Documentها واحدهای تکی دادهاند که در آنها جستجو میکنید. «document» لازم نیست وبسایت یا پست وبلاگ باشد، پس به اصطلاح زیاد گیر ندهید. فقط به آن مثل هر شیء JSON فکر کنید. مثل کتابهای یک کتابفروشی:
1
2
3
4
5
6
7
{
"id": "XYZ123",
"title": "The Great Gatsby",
"author": "F. Scott Fitzgerald",
"price": 10.99,
"createdAt": "2024-01-01T00:00:00.000Z"
}
Index
Index مجموعهای از documentهاست. هر document با یک شناسه یکتا و مجموعهای از fieldها مرتبط است، که جفتهای کلید-مقدار حاوی دادههایی هستند که در آنها جستجو میکنید. index را مثل جدول پایگاه داده ببینید. جستجوها روی این indexها انجام میشوند و documentهایی را که با معیار جستجو میخوانند برمیگردانند.
توجه کنید که این اصطلاح، واژه عمومیتر «ایندکس» را که اغلب برای توصیف ساختارهای داده کمکی بهکار میرود تا جستجو را سریعتر کند، دچار بار معنایی اضافه میکند. سعی میکنیم روشن کنیم درباره کدام «ایندکس» صحبت میکنیم تا سردرگمی پیش نیاید.
ما برای کتابهایمان index میسازیم، اما ممکن است برای هر تعداد موجودیت مختلف مرتبط با کسبوکارمان index داشته باشیم: نقدها، کاربران، سفارشها و مانند آن.
Mapping و Field
در نهایت، mapping همان schema مربوط به index است. مشخص میکند index چه fieldهایی دارد، نوع داده هر field چیست و هر ویژگی دیگری مثل نحوه پردازش و ایندکس شدن field. میتوانید هر دادهای را که میخواهید در document بگذارید، اما mapping تعیین میکند کدام fieldها قابل جستجو هستند و چه نوع دادهای دارند.
این نوعها میتوانند بهدلخواه پیچیده باشند. مثلاً میتوانید object و array در documentهایتان بگذارید، از نوعهای مکانی خاص استفاده کنید، analyzerهای سفارشی تعریف کنید یا حتی برای جستجوی معنایی (semantic search) از embedding استفاده کنید. وارد همه اینها نمیشویم، اما اگر فکر میکنید برای جستجو به چیز دیگری نیاز دارید، احتمالاً Elasticsearch آن را از قبل پوشش داده است.
نمونهای از mapping:
1
2
3
4
5
6
7
8
9
{
"properties": {
"id": { "type": "keyword" },
"title": { "type": "text" },
"author": { "type": "text" },
"price": { "type": "float" },
"createdAt": { "type": "date" }
}
}
mapping بسیار مهم است، چون به Elasticsearch میگوید دادهای را که ذخیره میکنید چطور تفسیر کند. مثلاً در mapping بالا id را از نوع keyword تعریف میکنیم. یعنی field id بهعنوان یک مقدار کامل در نظر گرفته میشود، نه رشتهای که بتوان tokenize کرد. این مهم است چون جستجو و مرتبسازی کارآمدتر را ممکن میکند. من معمولاً یک مقدار id را جستجو میکنم (فکر کنید: hash table!)، اما جستجو روی عنوان ممکن است کوئریهایی باشد که میخواهند ببینند عنوان مقدار مشخصی را دارد یا نه (فکر کنید: inverted index!).
mappingها اثر مهمی هم روی کارایی cluster شما دارند. اگر fieldهای زیادی را در mapping بگنجانید که واقعاً در جستجو استفاده نمیشوند، سربار حافظه هر index بالا میرود. این میتواند به مشکل کارایی و افزایش هزینه منجر شود. فرض کنید شیء کاربری با ۱۰ field دارید، اما فقط اجازه جستجو روی ۲ تا از آنها را میدهید. اگر کل شیء را map کنید، حافظه را روی ۸ field که استفاده نمیکنید هدر میدهید. این مهم است، چون بسیاری از کنترلی که روی کارایی کوئری خواهید داشت به تنظیمات mapping و پارامترهای مختلف cluster بستگی دارد. بعداً به آن میرسیم.
استفاده پایه
بعد، چند عملیات را مرور میکنیم: ساخت index، ذخیره مقداری داده و جستجو، تا درکی از قابلیتهای ضروری بگیرید. Elasticsearch یک REST API تمیز و خوب دارد که انجام این عملیات را آسان میکند، هرچند GUI و کلاینتهای زیادی هم موجودند.
ساخت Index
یک درخواست ساده PUT یک index با mapping پویا، ۱ shard و ۱ replica میسازد. اینها پارامترهایی هستند که بعد از ساخت index میتوانید بهروزشان کنید.
1
2
3
4
5
6
7
// PUT /books
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1
}
}
تنظیم Mapping
اگر mapping پویا مناسب نیست (شاید بیشتر fieldهای داده من قابل جستجو نباشند)، میتوانم از قبل mapping را برای index تعیین کنم. این کار به Elasticsearch میگوید بعضی fieldها را باید قابل جستجو در نظر بگیرد و چه نوعهایی را در آن fieldها انتظار داشته باشد.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// PUT /books/_mapping
{
"properties": {
"title": { "type": "text" },
"author": { "type": "keyword" },
"description": { "type": "text" },
"price": { "type": "float" },
"publish_date": { "type": "date" },
"categories": { "type": "keyword" },
"reviews": {
"type": "nested",
"properties": {
"user": { "type": "keyword" },
"rating": { "type": "integer" },
"comment": { "type": "text" }
}
}
}
}
اینجا fieldهایی را که میخواهم برای کتابفروشیام قابل جستجو باشند از قبل ثبت کردهام. وقتی documentها را اضافه میکنم، Elasticsearch مقدار این fieldها را استخراج و ایندکس میکند تا برای جستجو آماده شوند.
mappingها میتوانند بسیار پیچیده باشند و با اضافه شدن داده بیشتر و پیدا شدن کاربردهای جدید، در طول زمان تغییر کنند. بهعنوان مثال، به field reviews که nested است توجه کنید. در این حالت هر نقد یک document تودرتو با fieldهای مخصوص خودش است. این با ساختار مسطح (flat) که در آن هر نقد یک document جداگانه است فرق دارد.
این تصمیم که نقدها را داخل کتابها بگذارید یا جدا نگه دارید، تا حد زیادی به الگوهای بهروزرسانی و کوئری داده بستگی دارد و برای مصاحبهگر حساس بحثی منصفانه است. اگر نقدها بهندرت بهروز میشوند و زیاد کوئری میشوند، ممکن است گذاشتن آنها داخل documentهای کتاب کارآمدتر باشد. در غیر این صورت احتمالاً میخواهید برای نقدها index جداگانه بسازید. اگر با پایگاههای داده SQL کار کرده باشید، به این مسئله شبیه normalization فکر کنید.
اضافه کردن Document
پس index و mapping داریم، عالی. بعد باید documentها را به index اضافه کنم! این یک HTTP POST ساده به endpoint /_doc است.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// POST /books/_doc
{
"title": "The Great Gatsby",
"author": "F. Scott Fitzgerald",
"description": "A novel about the American Dream in the Jazz Age",
"price": 9.99,
"publish_date": "1925-04-10",
"categories": ["Classic", "Fiction"],
"reviews": [
{
"user": "reader1",
"rating": 5,
"comment": "A masterpiece!"
},
{
"user": "reader2",
"rating": 4,
"comment": "Beautifully written, but a bit sad."
}
]
}
اگر بخواهم یکی دیگر اضافه کنم، درخواست دیگری میفرستم.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// POST /books/_doc
{
"title": "To Kill a Mockingbird",
"author": "Harper Lee",
"description": "A novel about racial injustice in the American South",
"price": 12.99,
"publish_date": "1960-07-11",
"categories": ["Classic", "Fiction"],
"reviews": [
{
"user": "reader3",
"rating": 5,
"comment": "Powerful and moving."
}
]
}
هر کدام از این درخواستها شناسه document را همراه با دادههایی درباره نحوه ماندگار شدن document در cluster برمیگرداند.
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"_index": "books",
"_id": "kLEHMYkBq7V9x4qGJOnh",
"_version": 1, // NOTE!
"result": "created",
"_shards": {
"total": 2,
"successful": 1,
"failed": 0
},
"_seq_no": 0,
"_primary_term": 1
}
به قسمت _version توجه ویژه داشته باشید. این field خاصی است که Elasticsearch برای اطمینان از بهروزرسانی اتمیک document استفاده میکند.
بهروزرسانی Document
بهروزرسانی document شبیه ساختن آن است، اما باید شناسه document را در URL مشخص کنید. میتوانیم قیمت را با مشخص کردن کل document بالا ببریم:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// PUT /books/_doc/kLEHMYkBq7V9x4qGJOnh
{
"title": "To Kill a Mockingbird",
"author": "Harper Lee",
"description": "A novel about racial injustice in the American South",
"price": 13.99,
"publish_date": "1960-07-11",
"categories": ["Classic", "Fiction"],
"reviews": [
{
"user": "reader3",
"rating": 5,
"comment": "Powerful and moving."
}
]
}
این در بعضی موارد ممکن است مناسب باشد، اما میتواند پرخطر باشد! اگر فرایند دیگری همزمان همان document را بهروز کند، ممکن است تغییرات آن را بازنویسی کنید.
اگر بخواهیم از این موضوع محافظت کنیم، میتوانیم از field _version بالا استفاده کنیم و مشخص کنیم فقط وقتی نسخه یکی باشد میخواهیم document را بهروز کنیم. درخواست زیر document را فقط اگر نسخه ۱ باشد بهروز میکند. در غیر این صورت خطا میدهد.
1
2
// PUT /books/_doc/kLEHMYkBq7V9x4qGJOnh?version=1
...
این خطاها به کلاینتها فرصت میدهند با تعارض کنار بیایند و درخواست را دوباره امتحان کنند. این یک نمونه ساده از کنترل همزمانی خوشبینانه (optimistic concurrency control) است.
در نهایت، endpoint _update (توجه کنید که POST است) اجازه میدهد بعضی fieldهای document را بدون گرفتن کل document بهروز کنید.
1
2
3
4
5
6
// POST /books/_update/kLEHMYkBq7V9x4qGJOnh
{
"doc": {
"price": 14.99
}
}
شاید واضح باشد چرا این برای کلاینتها مفید است، اما یادتان باشد Elasticsearch توزیعشده، ناهمزمان و همزمان است. درخواست شما بهطور بالقوه به گرههای مختلف زیادی فرستاده میشود و درخواستها ممکن است نامرتب برسند. صریح بودن درباره معنای بهروزرسانی باعث میشود بهروزرسانیها همانطور که انتظار دارید رخ دهند.
برای سوالهای طراحی API در مصاحبه، به بعضی از این مکانیکها توجه کنید. بعضی شرکتها و انواع مصاحبه چشمگیر در طراحی API عمیقتر میشوند و یاد گرفتن از پروژههای متنباز محبوب راه عالی برای توسعه مهارت شماست.
جستجو
خب، پس indexای با document داریم. چطور در آن جستجو کنیم؟ Elasticsearch این کار را ساده میکند! سینتکس کوئری Elasticsearch شبیه SQL است و مبتنی بر JSON هم هست که کار کردن با آن را بسیار آسان میکند.
یک کوئری ساده ممکن است جستجوی کتابهایی باشد که کلمه «Great» در عنوانشان است:
1
2
3
4
5
6
7
8
// GET /books/_search
{
"query": {
"match": {
"title": "Great"
}
}
}
آیا این یک body در درخواست GET است؟ هم بله، هم نه. Elasticsearch به درخواستهای GET با body پاسخ میدهد. یا اگر با پروکسی سختگیری سروکار دارید، باید این شیء را در query string بگذارید یا از endpoint مربوط به POST استفاده کنید. من برای خوانایی بیشتر اینطور نوشتم.
همچنین میتوانیم کتابهایی با «Great» در عنوان را که قیمتشان کمتر از ۱۵ دلار است جستجو کنیم:
1
2
3
4
5
6
7
8
9
10
11
// GET /books/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "Great" } },
{ "range": { "price": { "lte": 15 } } }
]
}
}
}
در نهایت میتوانیم در field تودرتوی reviews دنبال کتابهایی با نقد «excellent» بگردیم:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// GET /books/_search
{
"query": {
"nested": {
"path": "reviews",
"query": {
"bool": {
"must": [
{ "match": { "reviews.comment": "excellent" } },
{ "range": { "reviews.rating": { "gte": 4 } } }
]
}
}
}
}
}
پاسخ ممکن است اینطور باشد:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
{
"took": 7,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 2,
"relation": "eq"
},
"max_score": 2.1806526,
"hits": [
{
"_index": "books",
"_type": "_doc",
"_id": "1",
"_score": 2.1806526,
"_source": {
"title": "The Great Gatsby",
"author": "F. Scott Fitzgerald",
"price": 12.99
}
},
{
"_index": "books",
"_type": "_doc",
"_id": "2",
"_score": 1.9876543,
"_source": {
"title": "Great Expectations",
"author": "Charles Dickens",
"price": 10.50
}
}
]
}
}
ما داریم از mapping استفاده میکنیم و مجموعهای از محدودیتها و فیلترها را روی داده اعمال میکنیم تا نتیجههایمان را بگیریم. نتیجهها شامل شناسه documentهای کتابهای منطبق، امتیاز بر اساس ارتباط (relevance) (بیشتر درباره آن در ادامه) و documentهای منبع (در این مورد، کتابهای JSON من) هستند، مگر اینکه صریحاً چیز دیگری مشخص کرده باشم.
مرتبسازی
وقتی نتیجهها را به مجموعهای از کتابهای جالب محدود کردیم، چطور مرتبشان کنیم تا بهترین نتیجهها بالای صفحه باشند؟
مرتبسازی قابلیت مهمی در Elasticsearch است که اجازه میدهد نتایج جستجو را بر اساس fieldهای مشخص مرتب کنید.
مرتبسازی پایه: برای مرتب کردن نتایج میتوانید از پارامتر sort در کوئری استفاده کنید. این یک مثال پایه است که کتابها را بر اساس قیمت صعودی مرتب میکند:
1
2
3
4
5
6
7
8
9
// GET /books/_search
{
"sort": [
{ "price": "asc" }
],
"query": {
"match_all": {}
}
}
همچنین میتوانید بر اساس چند field مرتب کنید. مثلاً مرتبسازی بر اساس قیمت صعودی و بعد تاریخ انتشار نزولی:
1
2
3
4
5
6
7
8
9
10
// GET /books/_search
{
"sort": [
{ "price": "asc" },
{ "publish_date": "desc" }
],
"query": {
"match_all": {}
}
}
مرتبسازی با script: Elasticsearch مرتبسازی با scriptهای سفارشی را هم ممکن میکند (با زبان برنامهنویسی «Painless»). این وقتی مفید است که باید بر اساس مقدار محاسبهشده مرتب کنید. این مثال کتابها را بر اساس قیمت تخفیفخورده (۱۰٪ تخفیف) مرتب میکند. البته این کار را هرگز نمیکنید، چون ترتیب مرتبسازی با قیمت اصلی یکی است:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// GET /books/_search
{
"sort": [
{
"_script": {
"type": "number",
"script": {
"source": "doc['price'].value * 0.9"
},
"order": "asc"
}
}
],
"query": {
"match_all": {}
}
}
مرتبسازی روی fieldهای تودرتو: هنگام کار با fieldهای تودرتو باید از مرتبسازی تودرتو استفاده کنید. این تضمین میکند مقدارهای مرتبسازی از همان شیء تودرتو بیایند. اینطور کتابها را بر اساس بالاترین امتیاز نقدشان مرتب میکنید:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// GET /books/_search
{
"sort": [
{
"reviews.rating": {
"order": "desc",
"mode": "max",
"nested": {
"path": "reviews"
}
}
}
],
"query": {
"match_all": {}
}
}
مرتبسازی بر اساس ارتباط: اگر ترتیب مرتبسازی را مشخص نکنیم، Elasticsearch نتایج را بر اساس امتیاز ارتباط (_score) مرتب میکند. این قابل تنظیم است، اما الگوریتم امتیازدهی پیشفرض ارتباط نزدیکی با TF-IDF (Term Frequency-Inverse Document Frequency) دارد.
اگر قبلاً درباره TF-IDF نشنیدهاید، ارزش ۱۰ دقیقه وقتتان را دارد که دربارهاش یاد بگیرید، چون در زمینههای مختلف مفید است!
صفحهبندی و Cursor
آخرین نگرانی ما، بعد از تعیین نحوه فیلتر و مرتبسازی نتایج، این است که چطور آنها را به کاربر برگردانیم. اساساً چطور صفحهبندی کنیم. صفحهبندی در Elasticsearch اجازه میدهد زیرمجموعهای از نتایج جستجو را بگیرید که معمولاً برای نمایش نتایج در چند صفحه استفاده میشود. در حالی که باید تعیین کنیم هر صفحه چه نتایجی را بخواهیم (با تعداد یا معیار فیلتر)، باید در نظر بگیریم میخواهیم state را نگه داریم یا کوئری جستجو را در هر صفحه و درخواست دوباره اجرا کنیم.
صفحهبندی From و Size: این سادهترین شکل صفحهبندی است. در آن این دو را مشخص میکنید:
from: اندیس شروع نتایج.size: تعداد نتایجی که باید برگردند.
کوئری نمونه:
1
2
3
4
5
6
7
8
9
10
// GET /my_index/_search
{
"from": 0,
"size": 10,
"query": {
"match": {
"title": "elasticsearch"
}
}
}
اما این روش برای صفحهبندی عمیق (مثلاً بیش از ۱۰ هزار نتیجه) ناکارآمد میشود، چون باید همه documentهای قبلی را مرتب و واکشی کند. cluster باید در هر درخواست همه این documentها را بازیابی و مرتب کند، که میتواند بسیار گران باشد.
Search After: این روش برای صفحهبندی عمیق کارآمدتر است. مقدارهای مرتبسازی آخرین نتیجه را بهعنوان نقطه شروع صفحه بعد به کار میبرد. با این مقدارها میتوانیم هر صفحه را محدود کنیم تا فقط documentهایی را بگیرد که بعد از آخرین document صفحه قبل آمدهاند، و مجموعه جستجو را بهتدریج کوچک کنیم.
مثال:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// GET /my_index/_search
{
"size": 10,
"query": {
"match": {
"title": "elasticsearch"
}
},
"sort": [
{"date": "desc"},
{"_id": "desc"}
],
"search_after": [1463538857, "654323"]
}
پارامتر search_after از مقدارهای مرتبسازی آخرین نتیجه صفحه قبل استفاده میکند. طرز کارش این است:
- در کوئری اولیه پارامتر
search_afterرا نمیگذارید. - از نتایج کوئری اولیه، مقدارهای مرتبسازی آخرین document را میگیرید.
- این مقدارها در درخواست بعدی به پارامتر
search_afterتبدیل میشوند.
در مثال بالا، 1463538857 یک timestamp است (مقدار field تاریخ برای آخرین document صفحه قبل) و "654323" همان _id آخرین document در صفحه قبل است.
با دادن این مقدارها، Elasticsearch دقیقاً میداند صفحه بعد را از کجا شروع کند و این کار را حتی برای صفحهبندی عمیق بسیار کارآمد میکند. این رویکرد تضمین میکند که:
- documentهایی را که در صفحههای بعدی اضافه شدهاند از دست نمیدهید (حتی اگر بین درخواستها document جدید اضافه شود).
- نتیجه تکراری در صفحهها نمیگیرید.
با این حال، لازم است state را سمت کلاینت نگه دارید (مقدارهای مرتبسازی آخرین document را به خاطر بسپارید) و دسترسی تصادفی به صفحهها را ممکن نمیکند، یعنی فقط میتوانید رو به جلو حرکت کنید. این سبک صفحهبندی همچنین اگر داده زیربنایی بهروز یا حذف شود، ریسک از دست رفتن documentها در صفحههای قبلی را دارد.
Cursor: cursorها در Elasticsearch روش stateful برای صفحهبندی در نتایج جستجو ارائه میدهند و مشکل جابهجا شدن documentها زیر دست شما را حل میکنند. cursorها بین درخواستهای صفحهبندیشده سازگاری دارند و برای همین سربار خیلی بیشتری نسبت به روشهای صفحهبندی قبلی دارند.
Elasticsearch برای صفحهبندی مبتنی بر cursor از API مربوط به Point-in-Time (PIT) بههمراه search_after استفاده میکند.
ساختن PIT:
1
// POST /my_index/_pit?keep_alive=1m
این یک شناسه PIT برمیگرداند. استفاده از PIT در جستجوها:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// GET /_search
{
"size": 10,
"query": {
"match": {
"title": "elasticsearch"
}
},
"pit": {
"id": "46To...",
"keep_alive": "1m"
},
"sort": [
{"_score": "desc"},
{"_id": "asc"}
]
}
برای صفحههای بعدی، search_after را اضافه کنید:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// GET /_search
{
"size": 10,
"query": {
"match": {
"title": "elasticsearch"
}
},
"pit": {
"id": "46To...",
"keep_alive": "1m"
},
"sort": [
{"_score": "desc"},
{"_id": "asc"}
],
"search_after": [1.0, "1234"]
}
وقتی کارتان تمام شد، PIT را ببندید:
1
2
3
4
// DELETE /_pit
{
"id" : "46To..."
}
استفاده از PIT همراه search_after یک نمای ثابت از داده را در طول فرایند صفحهبندی فراهم میکند، حتی اگر index زیرین در حال بهروز شدن باشد.
چطور کار میکند
خب، حالا که درک پایهای از نحوه استفاده از Elasticsearch بهعنوان کلاینت دارید، گام طبیعی بعدی این است که ببینیم در پشت صحنه چطور کار میکند. هر کدام از این عملیات چطور اجرا میشوند؟
Elasticsearch را میتوان چارچوب orchestration سطح بالا برای Apache Lucene دید، کتابخانه جستجوی سطح پایین و بسیار بهینهشده. Elasticsearch جنبههای سیستم توزیعشده را مدیریت میکند: هماهنگی cluster، APIها، aggregation و قابلیتهای بلادرنگ، در حالی که «قلب» عملکرد جستجو را Lucene مدیریت میکند.
اینجا مطلب به اندازه چند ساعت صحبت هست، پس با معماری سطح بالای cluster شروع میکنیم و بعد سراغ جالبترین بخشهای ایندکسگذاری و جستجو میرویم.
معماری Cluster
انواع گره
Elasticsearch موتور جستجوی توزیعشده است. وقتی cluster ای از Elasticsearch بالا میآورید، در واقع چند گره بالا میآورید. گرهها میتوانند ۵ نوع باشند که هنگام راهاندازی نمونه مشخص میشود.
- Master Node: مسئول هماهنگی cluster است. تنها گرهای است که میتواند عملیات سطح cluster مثل اضافه یا حذف گره و ساخت یا حذف index را انجام دهد. مثل «ادمین» به آن فکر کنید.
- Data Node: مسئول ذخیره داده است. جایی است که دادههای شما واقعاً ذخیره میشوند. در cluster بزرگ تعداد زیادی از اینها دارید.
- Coordinating Node: مسئول هماهنگی درخواستهای جستجو در cluster است. این گره درخواست جستجو را از کلاینت میگیرد و به گرههای مناسب میفرستد. بخش جلویی (front end) cluster شماست.
- Ingest Node: مسئول ورود داده است. جایی است که داده شما تبدیل میشود و برای ایندکس شدن آماده میشود.
- Machine Learning Node: مسئول کارهای یادگیری ماشین است.
این گرهها به روشهای خیلی ساده با هم کار میکنند. نمودار توالی زیر نشان میدهد Ingest Nodeها داده را در Data Nodeها بارگذاری میکنند و بعد از طریق Coordinating Nodeها کوئری میشود.
هر نمونه Elasticsearch میتواند چند نوع باشد و نوع آن با پیکربندی گره تعیین میشود. مثلاً یک نمونه را میتوان طوری پیکربندی کرد که هم Master-eligible باشد و هم Coordinating. در پیادهسازیهای پیچیدهتر ممکن است میزبان اختصاصی برای هر کدام از این نوعها داشته باشید. (مثلاً میزبان Ingest Node شما ممکن است CPU-bound باشد و پردازنده زیاد داشته باشد، در حالی که میزبان Data Node ممکن است I/O دیسک بالا یا حافظه بیشتری داشته باشد.)
هر کدام از این انواع گره تخصصهایی هم دارند. مثلاً Data Nodeها میتوانند hot، warm، cold یا frozen باشند، بسته به اینکه چقدر احتمال دارد دادهشان کوئری شود (مثلاً اخیر است یا نه) و اینکه آیا ممکن است تغییر کند.
وقتی cluster بالا میآید، فهرستی از seed nodeها (که master-eligible هستند) مشخص میکنید که الگوریتم leader election را اجرا میکنند تا یک master برای cluster انتخاب شود. در هر لحظه فقط یک گره باید master فعال باشد و بقیه گرههای master-eligible در حالت standby هستند.
Ingest و Coordinating Node جالباند، اما Data Nodeها جایی هستند که جادوی جستجو اتفاق میافتد، پس از آنها شروع میکنیم.
Data Node
وظیفه اصلی Data Nodeها ذخیره documentها و جستجوی سریع آنهاست. Elasticsearch این کار را با جدا کردن داده خام _source (در نتایج جستجوی بالا دیدید؟) از indexهای Lucene که در جستجو استفاده میشوند انجام میدهد. میتوانید آن را مثل داشتن یک پایگاه داده document جداگانه ببینید.
کوئریها در دو مرحله اجرا میشوند. اول مرحله «query» است که در آن documentهای مرتبط با ساختارهای داده index بهینهشده شناسایی میشوند، و بعد مرحله «fetch» که در آن (بهصورت اختیاری) documentها از گرهها بیرون کشیده میشوند.
کوئریهای ایدهآل آنهایی هستند که بدون دست زدن به documentهای منبع قابل پاسخ باشند، گاهی با کشیدن داده مرتبط به index از طریق fieldهای موجود.
Data Nodeها indexهای ما را در خود جا میدهند که از shard و replica ساخته شدهاند. داخل آن shardها indexهای Lucene هستند که از segmentهای Lucene ساخته شدهاند. مثل عروسکهای تودرتوی روسی.
Shardها به Elasticsearch اجازه میدهند داده (و indexهای همراه آن) را بین میزبانها تقسیم کند. این کار به Elasticsearch اجازه میدهد documentها و ساختارهای index مربوطه را بین چند گره cluster شما توزیع کند، که کارایی و مقیاسپذیری را چشمگیر بهتر میکند.
جستجوها روی همه shardهای مربوط بهصورت موازی اجرا میشوند و نتیجهها توسط Coordinating Node ادغام و مرتب میشوند. کوئریها معمولاً روی Coordinating Node اجرا میشوند که کوئری را بین shardهای مناسب توزیع میکند.
Replica کپی دقیقی از یک shard است. Elasticsearch اجازه میدهد یک یا چند کپی از shardهای index بسازید که به آنها replica shard یا فقط replica میگویند.
replicaها دو هدف اصلی دارند: دسترسپذیری بالا و افزایش throughput. اگر shard ما بتواند X تراکنش در ثانیه (TPS) را تحمل کند، با داشتن Y replica میتوانیم X × Y را تحمل کنیم (همه چیزهای دیگر برابر).
Coordinating Node میتواند از replicaها برای بهتر کردن کارایی جستجو استفاده کند، با توزیع درخواستهای جستجو بین همه کپیهای موجود shard (primary و replica). به این ترتیب بار کاری جستجو در cluster مؤثر متعادل میشود.
در نهایت، shardهای Elasticsearch با indexهای Lucene رابطه یکبهیک دارند. یادتان باشد Lucene کتابخانه جستجوی سطح پایین و بسیار بهینهای است که قلب Elasticsearch است. بسیاری از عملیاتی که Elasticsearch با shardها انجام میدهد (merge، split، refresh، search) در واقع عملیات proxy روی indexهای Lucene زیرین هستند.
در این نقطه میتوانیم Elasticsearch را بیش از حد ساده کنیم و آن را مجموعهای از دسترسپذیری و مقیاسپذیری روی مجموعه بزرگی از indexهای Lucene ببینیم.
CRUD در Segmentهای Lucene
indexهای Lucene از segment ساخته میشوند، واحد پایه موتور جستجوی ما. Segmentها ظرفهای تغییرناپذیر (immutable) داده ایندکسشده هستند. بگذارید این کلمه یک لحظه تهنشین شود. مگر لازم نیست بتوانیم documentها را در index Elasticsearch خود بهروز، اضافه و حذف کنیم؟
روش کار indexهای Lucene با دستهبندی نوشتنها و ساختن segmentهاست. وقتی document درج میکنیم، فوراً آن را در index ذخیره نمیکنیم. در عوض آن را به یک segment اضافه میکنیم. وقتی دستهای از documentها داریم، یک segment میسازیم و روی دیسک flush میکنیم.
وقتی segmentها بیش از حد زیاد شوند، میتوانیم آنها را ادغام (merge) کنیم: یک segment جدید از segmentهایی که میخواهیم ادغام کنیم میسازیم و segmentهای قبلی را حذف میکنیم.
حذفها مشکلساز هستند. هر segment در واقع مجموعهای از شناسههای حذفشده دارد. وقتی از segmentی برای داده کوئری میگیرید که document حذفشده دارد، وانمود میکند وجود ندارد. اما داده هنوز آنجاست! در طول عملیات merge، segmentهای ادغامشده documentهای حذفشده را پاک میکنند.
در نهایت، برای رویدادهای بهروزرسانی، در واقع segment را بهروز نمیکنیم. در عوض document قدیمی را soft-delete میکنیم و document جدید را با داده بهروزشده درج میکنیم. آن document قدیمی بعداً در رویدادهای merge مربوط به segment پاک میشود. این کار حذف را بسیار سریع میکند، اما تا وقتی segmentها را merge و پاک نکردهایم، جریمه کارایی ماندگاری دارد. در حالت ایدهآل این کار را زیاد انجام نمیدهیم!
توجه کنید که بهروزرسانیها در واقع از درج کارایی بدتری دارند، چون باید حسابداری soft-deleteها را مدیریت کنیم. این بخشی از دلیل مناسب نبودن Elasticsearch برای دادهای است که سریع بهروز میشود.
این معماری تغییرناپذیر چند مزیت برای Lucene دارد.
- کارایی نوشتن بهتر: documentهای جدید را میتوان سریع و بدون تغییر segmentهای موجود به segmentهای جدید اضافه کرد.
- کش کارآمد: چون segmentها تغییرناپذیرند، میتوان آنها را بیخطر در حافظه یا روی SSD کش کرد، بدون نگرانی از مشکلات سازگاری.
- همزمانی سادهتر: عملیات خواندن لازم نیست نگران تغییر داده در وسط کوئری باشند و دسترسی همزمان را ساده میکند.
- بازیابی آسانتر: در صورت خرابی، بازیابی از segmentهای تغییرناپذیر آسانتر است، چون state آنها مشخص و ثابت است.
- فشردهسازی بهینه: داده تغییرناپذیر را میتوان مؤثرتر فشرده کرد و فضای دیسک را صرفهجویی کرد.
- جستجوی سریعتر: ماهیت تغییرناپذیر امکان ساختارهای داده و الگوریتمهای بهینه برای جستجو را فراهم میکند.
با این حال، این طراحی چالشهایی هم میآورد، مثل نیاز به merge دورهای segmentها و افزایش موقت نیاز ذخیرهسازی قبل از عملیات پاکسازی. Elasticsearch و Lucene مکانیزمهای پیچیدهای برای مدیریت مؤثر این مصالحهها دارند.
این نوع تصمیمهای طراحی (چطور از تغییرناپذیری به نفع خود استفاده کنیم) در مصاحبههای طراحی سیستم زیرساخت دادهمحور مورد توجه قرار میگیرند! امیدواریم الهامبخش ایدههایی درباره نحوه فکر کردن شما به سیستمهای دیگری باشند که ممکن است بسازید.
ویژگیهای Segment در Lucene
segmentها فقط ظرفهای ساده داده document نیستند، بلکه ساختارهای داده بسیار بهینهای هم برای عملیات جستجو دارند. دو مورد از مهمترین آنها inverted index و doc values هستند.
Inverted Index
اگر Lucene قلب Elasticsearch باشد، inverted index قلب Lucene است. اساساً اگر بخواهید چیزها را سریع پیدا کنید، دو گزینه دارید.
- میتوانید دادههایتان را بر اساس نحوه بازیابیشان سازمان بدهید. اگر بخواهید نمونههای مشخصی را جستجو کنید، انتخاب ضعیف جدولی است که باید روی تکتک موردها اسکن کنید (O(n))، انتخاب بهتر لیست مرتب است (O(log n)) و بهترین استراتژی hash table (O(1)) است.
- میتوانید دادههایتان را کپی کنید و کپی را مثل مورد (۱) سازمان بدهید.
فرض کنید ۱ میلیارد کتاب داریم که تعداد کمی از آنها «Great» در عنوانشان دارند. میخواهیم کدی بسازیم که تمام کتابهای حاوی کلمه «Great» را در سریعترین زمان ممکن پیدا کند. چطور میتوانیم این کار را بکنیم؟
inverted index ساختار دادهای است که برای ذخیره نگاشتی از محتوا (مثل کلمهها یا عددها) به محلهای آن در پایگاه داده یا در این مورد documentها استفاده میشود. همین است که جستجوی کلمه کلیدی در Elasticsearch را سریع میکند. هر کلمه یکتایی را که در هر documentی ظاهر میشود فهرست میکند و همه documentهایی را که آن کلمه در آنهاست مشخص میکند. در این مورد، نگاشتی از رشتههایی مثل «great» به documentهای حاوی آن توکن (در مثال بالا documentهای #12 و #53) داریم.
حالا بهجای اسکن هر document برای پیدا کردن documentهای حاوی «Great»، فقط inverted index را جستجو میکنیم و documentهای حاوی «Great» را در زمان ثابت پیدا میکنیم.
از (۱) و (۲) بالا سود بردهایم. با ساختن کپی از دادههایمان و سازماندهی هوشمندانه آن، اسکن O(n) را به جستجوی O(1) تبدیل میکنیم. زیرکانه است!
Doc Values
حالا اگر بخواهیم همه آن نتیجهها را بر اساس قیمت مرتب کنیم چه؟ اینجا doc values وارد میشود. documentها مجموعهای از fieldهای دیگر مثل نویسنده، عنوان و غیره دارند، اما ما واقعاً میخواهیم فقط قیمت همه نتیجههای منطبق را بگیریم. این مشکل بسیار رایجی برای پایگاههای داده سطرمحور (row-oriented) مثل پایگاههای داده رابطهای است! حتی اگر فقط یک ستون را لازم داشته باشم، باید کل سطر را بخوانم و در آن ایندکس کنم.
راز کارایی سیستمهای تحلیلی مثل Spark یا Redshift در AWS این است که از قالب ستونی (columnar) برای ذخیره داده در تکههای پیوسته حافظه استفاده میکنند. وقتی ستونی را کوئری میکنید، در واقع فقط یک تکه حافظه پیوسته را میخوانید. ساختار doc values دقیقاً همین کار را میکند: نمایش ستونی و پیوسته از یک field واحد برای همه documentها در یک segment. inverted index نگاشت توکنها به documentها را میدهد و doc values دادهای را که برای انجام مرتبسازی نهایی لازم داریم.
Coordinating Node
یادتان هست درباره اینکه Elasticsearch سیستم توزیعشده است صحبت کردیم؟ Coordinating Nodeها مسئول دریافت درخواست از کلاینتهای نهایی و هماهنگ کردن اجرای آنها در cluster هستند. نقطه ورود درخواست کاربرند و مسئول parse کردن کوئری، تعیین اینکه کدام گرهها مسئول کوئری هستند و برگرداندن نتیجهها به کاربرند.
یکی از مهمترین مرحلهها در اجرای Coordinating Node، برنامهریزی کوئری (query planning) است. query plannerها الگوریتمهایی هستند که کارآمدترین راه اجرای کوئری جستجو را تعیین میکنند. بعد از اینکه Coordinating Node کوئری را parse کرد، query planner ارزیابی میکند بهترین documentهای مرتبط را چطور بازیابی کند. این شامل تصمیمگیری درباره استفاده از inverted index، تعیین بهترین ترتیب اجرای بخشهای کوئری و هماهنگی نحوه ترکیب نتیجههای چند گره میشود.
بهینهسازی ترتیب
بیایید این را مشخصتر کنیم. در میلیونها document دنبال رشته «bill nye» میگردید. در inverted index شما، «bill» میلیونها ورودی دارد و «nye» چند صد ورودی. چطور باید پیش بروید؟
- میتوانید hash set از همه documentهای حاوی «nye» بسازید، بعد documentهای حاوی «bill» را اسکن کنید و تقاطع بگیرید، بعد جستجوی رشتهای برای «bill nye» انجام دهید.
- میتوانید hash set از همه documentهای حاوی «bill» بسازید، بعد documentهای حاوی «nye» را اسکن کنید و تقاطع بگیرید، بعد جستجوی رشتهای برای «bill nye» انجام دهید.
- میتوانید همه documentهای حاوی «nye» را بارگذاری کنید و جستجوی رشتهای برای «bill nye» انجام دهید.
- میتوانید همه documentهای حاوی «bill» را بارگذاری کنید و جستجوی رشتهای برای «bill nye» انجام دهید.
- …
گزینههای زیادی هست! و تفاوت کارایی بین این گزینهها میتواند چند مرتبه بزرگی باشد.
query planner در Elasticsearch با نگه داشتن آمار درباره نوع fieldهای موجود، کلمههای کلیدی محبوب و طول documentها بین گزینهها انتخاب میکند تا زمان بازگشت نتیجه به کاربر حداقل شود. این بهینهسازی برای حفظ کارایی با بزرگتر و پیچیدهتر شدن مجموعه دادهها خیلی مهم است.
اگر با مصاحبه طراحی سیستم به سبک زیرساخت روبهرو هستید، این سوالها باید برایتان آشنا باشند. query plannerها با اضافه کردن لایهای از آمار و یک سطح indirection به سیستم اجازه میدهند بهصورت پویا به داده موجود در index پاسخ بدهد. توانایی مدیریت وابستگی به داده، دلیل قدرتمند بودن سیستمهای پایگاه داده بهطور کلی است!
در مصاحبه شما
Elasticsearch باید بهوضوح در بسیاری از سوالهای مصاحبه طراحی سیستم مطرح شود. هر چیزی که جستجوهای پیچیده دارد احتمالاً کاندیدای خوبی است. بیشتر وقتهایی که Elasticsearch در مصاحبهها مطرح میشود، از طریق Change Data Capture (CDC) به یک ذخیرهگاه داده معتبر مثل Postgres یا DynamoDB وصل میشود.
مواردی که هنگام استفاده از Elasticsearch در مصاحبه باید در نظر داشته باشید:
- معمولاً استفاده از Elasticsearch بهعنوان پایگاه داده ایده خوبی نیست. Elasticsearch در درجه اول و مهمتر از همه یک موتور جستجو است، و با اینکه فوقالعاده قدرتمند است، قرار نیست جایگزین پایگاه داده سنتی شود. نسخههای قدیمیتر Elasticsearch مشکلات زیادی با سازگاری و دوام داشتند و بسیاری از مشکلاتی که CouchDB را آزار میداد، Elasticsearch را هم آزار میداد. بهطور خلاصه: اگر به دادهای نیاز دارید که ماندگار بماند، آن را جای دیگری بگذارید.
- Elasticsearch برای بارهای نوشتنسنگین طراحی نشده است. اگر با سیستم نوشتنسنگین سروکار دارید، شاید بخواهید گزینههای دیگر را در نظر بگیرید یا بافر نوشتن پیاده کنید. شاید جذاب باشد که بتوانید مثلاً تعداد لایک یک پست یا تعداد بازدید را field بگذارید، اما دلایل زیادی هست که Elasticsearch با آن به مشکل میخورد.
- مدل سازگاری نهایی (Eventual Consistency) Elasticsearch را در نظر بگیرید. نتیجههای شما کهنه خواهند بود، گاهی چشمگیر. اگر کاربرد شما این را تحمل نمیکند، شاید لازم باشد جایگزینهایی در نظر بگیرید.
- Elasticsearch پایگاه داده رابطهای نیست. باید دادههایتان را تا جای ممکن denormalize کنید تا جستجو کارآمد شود. این ممکن است به منطق تبدیل اضافه در سمت نوشتن نیاز داشته باشد. هدف شما باید این باشد که نتیجهها با ۱ یا ۲ کوئری ارائه شوند.
- همه مسائل جستجو به آن نیاز ندارند! اگر داده شما کوچک است (کمتر از ۱۰۰ هزار document) یا زیاد تغییر نمیکند، راهحلهای دیگر زیادی و سریعتر هست. ببینید آیا یک کوئری ساده روی ذخیرهگاه داده اصلیتان کافی است یا نه، و فقط وقتی آن را ناکافی دیدید، Elasticsearch را در نظر بگیرید.
- باید مراقب همگام نگه داشتن Elasticsearch با داده زیرین باشید. شکست در همگامسازی میتواند به drift منجر شود و منبع رایج باگ در Elasticsearch است.
یادتان باشد با اینکه Elasticsearch ابزار قدرتمندی است، گلوله نقرهای نیست. آماده باشید توضیح دهید چرا Elasticsearch را به گزینههای دیگر ترجیح دادهاید و درباره محدودیتها و نقاط قوتش صحبت کنید.
درسهایی از Elasticsearch
حتی اگر از Elasticsearch استفاده نکنیم، میتوانیم از نحوه ساخته شدن آن چند درس برای طراحی زیرساختهای کاربردی دیگر بگیریم.
- تغییرناپذیری وقتی در لایه درست پشته استفاده شود ابزار قدرتمندی است. با ثابت نگه داشتن داده، توانایی خود را برای کش، فشردهسازی و بهینه کردن داده بیشتر میکنیم. همچنین لازم نیست نگران مسائل همگامسازی و سازگاری باشیم که با داده تغییرپذیر حلشان خیلی سختتر است.
- با جدا کردن اجرای کوئری از ذخیرهسازی داده میتوانیم هرکدام را مستقل بهینه کنیم. Data Nodeها و Coordinating Nodeهای Elasticsearch با تمرکز روی مسئولیتهای مربوط به هر نوع گره، زیبا همدیگر را کامل میکنند.
- استراتژیهای ایندکسگذاری میتوانند بهطور چشمگیر روی کارایی جستجو اثر بگذارند. ساختار inverted index در Elasticsearch جستجوی سریع متن کامل را ممکن میکند، در حالی که doc values مرتبسازی و aggregation کارآمد را. هنگام طراحی سیستمهایی که بازیابی سریع داده میخواهند، فکر کنید چطور میتوانید دادههایتان را طوری ساختار بدهید که رایجترین الگوهای کوئری را پشتیبانی کند.
- سیستمهای توزیعشده میتوانند مقیاسپذیری و تحمل خطا بدهند، اما پیچیدگی هم میآورند. معماری cluster در Elasticsearch به آن اجازه میدهد حجم زیاد داده و بار جستجوی بالا را مدیریت کند، اما بررسی دقیق سازگاری داده و پارتیشن شبکه را هم لازم میکند. هنگام طراحی سیستمهای توزیعشده همیشه مصالحه بین سازگاری، دسترسپذیری و تحمل پارتیشن (قضیه CAP) را در نظر بگیرید.
- اهمیت ساختارهای داده کارآمد را نمیتوان بزرگنمایی کرد. استفاده Elasticsearch از ساختارهای داده تخصصی مثل skip list و finite state transducer برای inverted index نشان میدهد ساختارهای داده متناسب چطور میتوانند کارایی را برای کاربردهای مشخص چشمگیر بهتر کنند. هنگام انتخاب یا طراحی ساختار داده، همیشه الگوهای دسترسی به دادههایتان را در نظر بگیرید.