Використання 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
Збереглись тільки 2 перегляди з 1000.

Другий приклад (послідовне оновлення в циклі 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
ПрикладЧисло успішних збереженьЧас
123.16s
2100014.27s
31291.87s

Причини

В мене відсутня достовірна інформація стосовно причин, пройшло достатньо часу, але версія, що дійшла до мене, така:
Потрібно було швидко розробити готове рішення, тому в Elasticsearch одразу зберігали документ у форматі, який далі будуть віддавати в API.

Але забули про відсутність транзакцій в Elasticsearch.

Наслідки

  1. Інколи трапляється, що квитків на сеанс можна купити більше, ніж є в наявності.
  2. Розчаровані покупці пишуть гнівні відгуки про сервіс.
  3. Новий розробник, якому дали цей сервіс, як першу задачу, звільнився через тиждень, але звісно підписував договір, що забороняє розголошувати комерційну таємницю.
  4. Переписування мікросервісу, імпорт даних та збереження сумісності зі старою версією мікросервісу буде коштувати в рази дорожче ніж написати одразу з транзактивною БД.

Як виглядає мікросервіс зараз

Хоч сервіс й використовує Elasticsearch, але відсутній функціонал, де потрібен повнотекстовий пошук. Поля, за якими відбувається фільтрація, без індексів.

У квитків є статуси, зміна яких розмазана за двома мікросервісами, що потрібно докласти значних зусиль, щоб зрозуміти зміни статусів та фільтрації з двома десятками функцій з різними запитами.

Другий мікросервіс, що підключає перший як бібліотеку, робить підключення до Elasticsearch й викликає публічні функції першого мікросервісу для створення квитків до сеансів або скасування сеансів.

Звісно, відсутня документація (README.md містить лише назву мікросервісу).

Є додаткові скрипти, які запускаються по крону та виправляють, що можуть.

Альтернативи Elasticsearch

НазваНаписаний наРепозиторійЧисло зірочок
MeilisearchRustgithub.com/meilisearch/meilisearch30.5k stars
ZincGogithub.com/zinclabs/zinc12.6k stars

Епілог

Elasticsearch — хороший пошуковий рушій, яким задоволений й продовжую використовувати. Зазвичай в проєктах була одна функція для збереження в Elasticsearch й дві-три для пошуку та побудови фільтру.

На щастя мікросервіс вже почали переписувати в новому репозиторії.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Танцы с бубном начинаются когда у вас миллионы записей и вам нужно изменить маппинг. Эластик не позволяет такое. Придется создать новый индекс с новым maping-ом.

Таким образом появляется задача версионирования индекса, с созданием алиаса.
Но это все тоже решаемая задача.

— run 1000 times
UPDATE views
SET count = count = 1;

Виглядає на одруківку — мабуть мало бути count = count + 1

Стаття про те що багато хто хотів але соромився зробити =) Дякую!

Начебто усюди пишуть, включаючи elastic search docs, що це НЕ база данних, нахіба всі ці збочення?!

Так, то може тому й пишуть, що зустрічали такі мікросервіси про які соромно писати.

Але при цьому усі використовують як базу даних логів у ELK стеку ;-)

Які ознаки бази даних?

В випадку ElasticSearch це можливість вичитати назад оригінальні значення полів.

www.elastic.co/...​urrent/mapping-store.html

Якщо пропадуть логи то ніхто не помре, а от якщо данні то буде боляче

Нормально — ніхто не помре. А аудит як вести?

Ви логи з метриками не плутаєте часом? Логи — шукати баги, метрики робити аудіт, малювати графіки в графані

Це ви плутаєте. Аудит це хто що коли робив. Метрики не про це.

А логи яким боком до цього?

В логах — раптово — можна побачити хто коли і що робив. Особливо якщо логати в MDC юзер айді і подібні поля.

Ви колись про gdpr чули? Якщо хочете опустити контору на гроші то логи саме те місто де треба логати user id та інші такі поля.

User ID не є PII (personal identifiable information), шановний — це не email, не номер телефону і не адреса.
Освіжіть свої знання GDPR замість того щоб вимахуватись в коментарях.

Вимахуетесь ви, аудіт на логах не роблять

logz.io/...​logs-security-compliance

“A log from any network device, application, host, or operating system can be classified as an audit log if it contains the information mentioned above and is used for auditing purposes.”

www.datadoghq.com/...​dge-center/audit-logging

cloud.google.com/logging/docs/audit

etc etc

Вимахуюсь я, і вся індустрія разом зі мною. А Ви ну зовсім не вимахуєтесь.

Йдіть обфускуйте databse ID для юзерів в своїх логах — бо GDPR :D

Я згоден погодитись що метрики то не зовсім аудіт, але якщо потрібен тру аудіт то це лежить в базі в виді івентів, поряд з основними даними, але ніяк не в логах. Ну і повертаючись до того з чого ми почали, я не вважаю еластік надійним сховищем, бо це в принципі не сховище, а лише інструмент для індексації

я не вважаю еластік надійним сховищем

Автори ElasticSearch ридають і рвуть волосся на голові — пан Таран не вважає їх рішення надійним сховищем!

Хтось вважає так, хтось сяк, хтось ще щось як — невідомо на основі чого. «От не вважаю і фсьо — переконуйте мене!» :D

А в цей час світова індустрія зберігає логи в ELK, і зовсім не тому що їм наплювати чи логи пропадуть.

Лол ти смішний. Логи там зберігають тільки тому що зручно по них шукати.

Впевнений що «тільки»?
Так чи інакше — зберігають. Отже це БД.

Файлова система виходить теж БД

ElasticSearch це та БД що з BASE (Basic Availability, Soft State, Eventual Consistency)

www.coursera.org/...​sticsearch-overview-w38A9

“We will examine Elasticsearch as an example of a BASE-style (Basic Availability, Soft State, Eventual Consistency) database approach, as well as compare and contrast the advantages and challenges associated with ACID and BASE databases”

Так з цим ніхто не сперечається. Тут мова йде про те, що на реальних проектах не можна використовувати базу данних з «Basic Availability, Soft State, Eventual Consistency» як основну. В ELK використовують еластік, не тому, що це хороша база даних, а тому, що це компроміс, в якому пожертвували цілісністю логів задля економії місця на диску і загальному спрощенню всієї системи.

Цього року я тиждень розбирався

Цікаве формулювання

Веселіше було б навпаки — цього тижня я рік розбирався :D

я думал, что подобной ерундой перестали заниматься, еще со времен монгодб

Хоч сервіс й використовує Elasticsearch, але відсутній функціонал, де потрібен повнотекстовий пошук. Поля, за якими відбувається фільтрація, без індексів.

А что, хороший подход: взять в проект базу, главная фича которой — полнотекстовый поиск, но не использовать его, зато пытаться использовать эту базу как реляционку. И что тут могло пойти не так? Хм..

Ну и про CAP теорему почитать рекомендую.

а потім

Інколи трапляється, що квитків на сеанс можна купити більше, ніж є в наявності.
Розчаровані покупці пишуть гнівні відгуки про сервіс.
Новий розробник, якому дали цей сервіс, як першу задачу, звільнився через тиждень, але звісно підписував договір, що забороняє розголошувати комерційну таємницю.
Переписування мікросервісу, імпорт даних та збереження сумісності зі старою версією мікросервісу буде коштувати в рази дорожче ніж написати одразу з транзактивною БД.

Кстати, вот этот пункт я не понял:

Новий розробник, якому дали цей сервіс, як першу задачу, звільнився через тиждень, але звісно підписував договір, що забороняє розголошувати комерційну таємницю.

Он тут к чему? Автор статьи такой договор не подписывал? Или о чем тут речь?

Звісно я підписував й на подальші подібні питання зможу відповісти через пару-трійку років.

А з іншого боку взагалі-то попередній розробник успішно перейшов від класичних реляційних CA до AP і успішно вирішив проблеми горизонтального масштабування на майбутнє (якого з бізнес-логікою в ES нема).

Якщо виправленням займався я то ймовірно попередній розробник сервісу ще й успішно перейшов в іншу компанію

спішно перейшов від класичних реляційних CA до AP

И какое business value это принесло в конкретном случае?

і успішно вирішив проблеми горизонтального масштабування на майбутнє

А она, эта проблема, вообще была? Я нигде не увидел такого упоминания. Имхо больше похоже на классический паттерн «поиграть в Гугл и решать не реальную проблему, а ту что интересно»

Принесло «досвід з Elasticsearch» в резюме автора, не більше.

Чому ж? В use-cases здорової людини Elasicsearch дуже потужний інструмент.

намагаєтеся менше згадувати про це — а то всі будут вважати неосиляторами.

Підписатись на коментарі