Використання Elasticsearch як основної БД
Цього року я тиждень розбирався з мікросервісом, де Elasticsearch використовується як основна єдина база даних, щоб знайти й виправити помилку. В статті наведу приклади такого використання, опишу причини та наслідки.
Лічильник як спрощений приклад
Розглянемо трійку варіантів оновлення лічильника переглядів в Elasticsearch.Аналогія в SQL:
CREATE TABLE views ( id INT PRIMARY KEY, count INT NOT NULL ); INSERT INTO views (id, count) VALUES (1, 0); SELECT id, count FROM views WHERE id = 1; -- run 1000 times UPDATE views SET count = count + 1; SELECT id, count FROM views WHERE id = 1;
Перший приклад (послідовне оновлення в циклі 1000 разів)
package main import ( "io" "strings" "testing" es8 "github.com/elastic/go-elasticsearch/v8" "github.com/stretchr/testify/require" ) func TestCounterSequence(t *testing.T) { es, err := es8.NewDefaultClient() require.NoError(t, err) const viewsIndex = "views" // language=JSON const viewsMapping = `{ "properties": { "id": { "type": "number" }, "views": { "type": "number" } } }` resetMapping(t, es, viewsIndex, viewsMapping) const documentID = "1" // language=JSON const document = `{ "id":"1", "views":0 }` createDocument(t, es, viewsIndex, documentID, document) printDocument(t, es, viewsIndex, documentID) const count = 1000 { // language=JSON const updateScript = `{ "script": { "lang": "painless", "source": "ctx._source.views += 1;" }, "query": { "term": { "_id": "1" } } }` for i := 0; i < count; i++ { updateDocumentByQuery(t, es, viewsIndex, updateScript) } } printDocument(t, es, viewsIndex, documentID) } func resetMapping(t *testing.T, es *es8.Client, index, viewsMapping string) { es.Indices.Delete([]string{index}) _, err := es.Indices.PutMapping( []string{index}, strings.NewReader(viewsMapping), ) require.NoError(t, err) } func createDocument(t *testing.T, es *es8.Client, viewsIndex string, documentID string, document string) { _, err := es.Create( viewsIndex, documentID, strings.NewReader(document), ) require.NoError(t, err) } func updateDocumentByQuery(t *testing.T, es *es8.Client, viewsIndex string, updateScript string) { _, err := es.UpdateByQuery( []string{viewsIndex}, es.UpdateByQuery.WithBody(strings.NewReader(updateScript)), ) require.NoError(t, err) } func printDocument(t *testing.T, es *es8.Client, index, id string) { response, err := es.Get( index, id, es.Get.WithPretty(), ) require.NoError(t, err) defer response.Body.Close() content, err := io.ReadAll(response.Body) require.NoError(t, err) t.Log(string(content)) }
go test . -v -count=1
=== RUN TestCounterSequence main_test.go:106: { "_index" : "views", "_type" : "_doc", "_id" : "1", "_version" : 1, "_seq_no" : 0, "_primary_term" : 1, "found" : true, "_source" : { "id" : "1", "views" : 0 } } main_test.go:106: { "_index" : "views", "_type" : "_doc", "_id" : "1", "_version" : 3, "_seq_no" : 2, "_primary_term" : 1, "found" : true, "_source" : { "id" : "1", "views" : 2 } } --- PASS: TestCounterSequence (3.16s) PASS ok escounter 3.196s
Другий приклад (послідовне оновлення в циклі 1000 разів з refresh)
До кожного запиту в Elasticsearch додам ?refresh=true й повторно запущу тест.
func createDocument(t *testing.T, es *es8.Client, viewsIndex, documentID, document string) { _, err := es.Create( viewsIndex, documentID, strings.NewReader(document), // WithRefresh es.Create.WithRefresh("true"), ) require.NoError(t, err) }
func updateDocumentByQuery(t *testing.T, es *es8.Client, viewsIndex, updateScript string) { _, err := es.UpdateByQuery( []string{viewsIndex}, es.UpdateByQuery.WithBody(strings.NewReader(updateScript)), // WithRefresh es.UpdateByQuery.WithRefresh(true), ) require.NoError(t, err) }
func printDocument(t *testing.T, es *es8.Client, index, id string) { response, err := es.Get( index, id, es.Get.WithPretty(), // WithRefresh es.Get.WithRefresh(true), ) require.NoError(t, err) defer response.Body.Close() content, err := io.ReadAll(response.Body) require.NoError(t, err) t.Log(string(content)) }
go test . -v -count=1
go test . -v -count=1 === RUN TestCounterSequence main_test.go:111: { "_index" : "views", "_type" : "_doc", "_id" : "1", "_version" : 1, "_seq_no" : 0, "_primary_term" : 1, "found" : true, "_source" : { "id" : "1", "views" : 0 } } main_test.go:111: { "_index" : "views", "_type" : "_doc", "_id" : "1", "_version" : 1001, "_seq_no" : 1000, "_primary_term" : 1, "found" : true, "_source" : { "id" : "1", "views" : 1000 } } --- PASS: TestCounterSequence (14.27s) PASS ok escounter 14.303s
Тест другого прикладу виконується за 14 секунд (першого за 3 секунди), але збереглись всі перегляди.
Третій приклад — розпаралелене оновлення в циклі 1000 разів з refresh)
const ( parallel = 2 ) var ( wg sync.WaitGroup rate = make(chan struct{}, parallel) ) for i := 0; i < count; i++ { wg.Add(1) rate <- struct{}{} go func() { defer func() { _ = <-rate wg.Done() }() updateDocumentByQuery(t, es, viewsIndex, updateScript) }() } wg.Wait()
go test . -run=TestCounterParallel -v -count=1
=== RUN TestCounterParallel main_test.go:190: { "_index" : "views", "_type" : "_doc", "_id" : "1", "_version" : 1, "_seq_no" : 0, "_primary_term" : 1, "found" : true, "_source" : { "id" : "1", "views" : 0 } } main_test.go:190: { "_index" : "views", "_type" : "_doc", "_id" : "1", "_version" : 130, "_seq_no" : 129, "_primary_term" : 1, "found" : true, "_source" : { "id" : "1", "views" : 129 } } --- PASS: TestCounterParallel (1.87s) PASS ok escounter 1.892s
Приклад | Число успішних збережень | Час |
---|---|---|
1 | 2 | 3.16s |
2 | 1000 | 14.27s |
3 | 129 | 1.87s |
Причини
В мене відсутня достовірна інформація стосовно причин, пройшло достатньо часу, але версія, що дійшла до мене, така:Потрібно було швидко розробити готове рішення, тому в Elasticsearch одразу зберігали документ у форматі, який далі будуть віддавати в API.
Але забули про відсутність транзакцій в Elasticsearch.
Наслідки
- Інколи трапляється, що квитків на сеанс можна купити більше, ніж є в наявності.
- Розчаровані покупці пишуть гнівні відгуки про сервіс.
- Новий розробник, якому дали цей сервіс, як першу задачу, звільнився через тиждень, але звісно підписував договір, що забороняє розголошувати комерційну таємницю.
- Переписування мікросервісу, імпорт даних та збереження сумісності зі старою версією мікросервісу буде коштувати в рази дорожче ніж написати одразу з транзактивною БД.
Як виглядає мікросервіс зараз
Хоч сервіс й використовує Elasticsearch, але відсутній функціонал, де потрібен повнотекстовий пошук. Поля, за якими відбувається фільтрація, без індексів.
У квитків є статуси, зміна яких розмазана за двома мікросервісами, що потрібно докласти значних зусиль, щоб зрозуміти зміни статусів та фільтрації з двома десятками функцій з різними запитами.
Другий мікросервіс, що підключає перший як бібліотеку, робить підключення до Elasticsearch й викликає публічні функції першого мікросервісу для створення квитків до сеансів або скасування сеансів.
Звісно, відсутня документація (README.md містить лише назву мікросервісу).
Є додаткові скрипти, які запускаються по крону та виправляють, що можуть.
Альтернативи Elasticsearch
Назва | Написаний на | Репозиторій | Число зірочок |
---|---|---|---|
Meilisearch | Rust | github.com/meilisearch/meilisearch | 30.5k stars |
Zinc | Go | github.com/zinclabs/zinc | 12.6k stars |
Епілог
Elasticsearch — хороший пошуковий рушій, яким задоволений й продовжую використовувати. Зазвичай в проєктах була одна функція для збереження в Elasticsearch й дві-три для пошуку та побудови фільтру.На щастя мікросервіс вже почали переписувати в новому репозиторії.
48 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів