Використання 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 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів