← назад к разделу

Elasticsearch — мощный поисковый движок, но писать напрямую HTTP-запросы его JSON API — это много шаблонного кода. Spring Data Elasticsearch берёт эту работу на себя: вы описываете структуру документа Java-классом, а поиск и индексацию делаете привычными Spring-методами.

путь одного документа: объект → JSON → индекс → поиск ProductDoc name Text price ScaledFloat created Date @Field{"name": "крем для рук","price": 149.9,"inStock": true} bulkIndex() индекс products буфер записи product-42 сегмент, виден поиску product-42 refresh, раз в секунду поиск match name «крем»: 0 попаданий — документ ещё в буфере 1 попадание: product-42

Spring Data не делает ничего волшебного: по аннотациям он собирает из объекта JSON и отдаёт его индексу. Дальше начинается Elasticsearch — документ сначала лежит в буфере, и поиск его не видит. Переносит документ в сегмент refresh, по умолчанию раз в секунду; отсюда и правило «сохранил — не значит нашёл».

Обязательно

Подключение

Добавьте зависимость в build.gradle.kts:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch")
}

И настройте адрес кластера в application.properties:

spring.elasticsearch.uris=http://elasticsearch:9200
spring.elasticsearch.username=elastic
spring.elasticsearch.password=${ES_PASSWORD}
spring.elasticsearch.connection-timeout=2s
spring.elasticsearch.socket-timeout=10s

Spring Boot сам создаст RestClient — HTTP-клиент к Elasticsearch, поверх него ElasticsearchOperations для запросов, а интерфейсы ElasticsearchRepository найдёт в пакетах и реализует на лету.

Как описать документ через @Document

В JPA вы описываете таблицу через @Entity. В Elasticsearch аналогичную роль играет @Document — он связывает Java-класс с конкретным индексом.

@Document(indexName = "products")
public class ProductDoc {

    @Id
    private String id;

    @Field(type = FieldType.Text, analyzer = "russian")
    private String name;

    @MultiField(
        mainField = @Field(type = FieldType.Text, analyzer = "russian"),
        otherFields = {
            @InnerField(suffix = "raw", type = FieldType.Keyword)
        }
    )
    private String description;

    @Field(type = FieldType.Long)
    private Long categoryId;

    @Field(type = FieldType.ScaledFloat, scalingFactor = 100)
    private BigDecimal price;

    @Field(type = FieldType.Boolean)
    private Boolean inStock;

    @Field(type = FieldType.Date, format = DateFormat.date_time)
    private Instant createdAt;

    // геттеры и сеттеры
}

Несколько важных деталей:

  • @Field(type = FieldType.Text, analyzer = "russian") — поле полнотекстового поиска с русскоязычным анализатором (стемминг, стоп-слова).
  • @MultiField — одно поле в двух вариантах: Text для поиска по смыслу и Keyword (суффикс .raw) для точных фильтров и сортировки.
  • FieldType.ScaledFloat — рекомендуемый тип для цен: хранит число как целое с масштабом (100 → копейки), поэтому не теряет копейки на округлении.
  • Это не JPA: здесь нет @Entity, @Table, транзакций Hibernate. Класс нужен только для маппинга ES-документа.

Разница между Text и Keyword — не в хранении, а в том, что попадает в индекс: Text проходит через анализатор и распадается на слова, Keyword ложится целиком:

живой пример

import java.util.Arrays;
import java.util.List;

public class MappingDemo {

    static List<String> asText(String value) {
        return Arrays.stream(value.toLowerCase().split("[^\\p{L}\\p{N}]+"))
                .filter(word -> !word.isEmpty())
                .toList();
    }

    public static void main(String[] args) {
        String title = "Крем для рук Velvet";

        System.out.println("Text:    " + asText(title));
        System.out.println("Keyword: [" + title + "]");

        System.out.println("найдётся по слову «крем» в Text:    " + asText(title).contains("крем"));
        System.out.println("найдётся по слову «крем» в Keyword: " + title.equals("крем"));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Поэтому по description ищут словами, а по description.raw — фильтруют и сортируют.

Если нужен тонкий контроль маппинга — укажите готовый JSON-файл: @Mapping(mappingPath = "elasticsearch/product-mapping.json").

И сразу про умолчание, которое неприятно удивляет на рабочем кластере: у @Document параметр createIndex по умолчанию равен true, и Spring Data при поднятии репозитория сам заведёт индекс, если его ещё нет. Совет «задавайте маппинг явно» сам собой при этом не выполняется — индекс появится по аннотациям, а всё, чего в классе нет, достанется динамическому маппингу и зафиксирует типы по первому документу. В промышленной среде индекс создают отдельной миграцией, а автосоздание выключают: @Document(indexName = "products", createIndex = false).

Простые запросы через ElasticsearchRepository

Самый быстрый старт — объявить интерфейс-репозиторий:

public interface ProductRepository extends ElasticsearchRepository<ProductDoc, String> {

    Page<ProductDoc> findByCategoryId(Long categoryId, Pageable pageable);

    List<ProductDoc> findByNameContainingAndInStockTrue(String namePart);

    long countByPriceBetween(BigDecimal min, BigDecimal max);
}

Spring парсит имена методов и генерирует Query DSL автоматически. Работает для простых случаев: фильтры по точным значениям, сортировка, постраничная выдача.

Когда репозитория уже не хватает:

  • Имена методов разрастаются до 8–10 слов и плохо читаются.
  • Нет контроля над нечётким поиском (fuzziness), весами полей, агрегациями.
  • Нельзя написать сложный bool-запрос с несколькими условиями.

Для таких случаев есть ElasticsearchOperations.

Гибкие запросы через ElasticsearchOperations

ElasticsearchOperations — это низкоуровневый API, который даёт полный контроль над запросами:

@Service
@RequiredArgsConstructor
public class ProductSearchService {

    private final ElasticsearchOperations elasticsearch;

    public SearchHits<ProductDoc> search(String text, Set<Long> categories,
                                          BigDecimal minPrice, Pageable pageable) {
        var criteria = new Criteria("name").matches(text)
            .and(new Criteria("inStock").is(true));

        if (!categories.isEmpty()) {
            criteria = criteria.and(new Criteria("categoryId").in(categories));
        }
        if (minPrice != null) {
            criteria = criteria.and(new Criteria("price").greaterThanEqual(minPrice));
        }

        var query = new CriteriaQuery(criteria, pageable);
        return elasticsearch.search(query, ProductDoc.class);
    }
}

Когда не хватает и CriteriaQuery — нужны агрегации или затухание веса по дате — берут NativeQuery: почти тот же JSON, но с проверкой типов при компиляции:

public SearchHits<ProductDoc> searchWithFunctionScore(String text) {
    var query = NativeQuery.builder()
        .withQuery(q -> q
            .functionScore(fs -> fs
                .query(qq -> qq.match(m -> m.field("name").query(text)))
                .functions(f -> f
                    .gauss(g -> g.date(d -> d
                        .field("createdAt")
                        .placement(p -> p.origin("now")
                            .scale(Time.of(t -> t.time("30d"))).decay(0.5)))))
            ))
        .build();
    return elasticsearch.search(query, ProductDoc.class);
}

Массовая индексация

Индексировать документы по одному медленно: каждый вызов — отдельный HTTP-запрос с ожиданием подтверждения. На большом каталоге так не делают.

Bulk API позволяет передать тысячи документов одним запросом:

public void reindexAll(List<ProductDoc> docs) {
    var queries = docs.stream()
        .map(doc -> new IndexQueryBuilder()
            .withId(doc.getId())
            .withObject(doc)
            .build())
        .toList();

    elasticsearch.bulkIndex(queries, ProductDoc.class);
}

Для очень больших объёмов (миллионы документов) документы разбивают на пачки по 500–5000 и на время заливки отключают автообновление индекса. Такой ручки в Spring Data нет — настройку меняют запросом к самому Elasticsearch:

PUT /products/_settings
{ "index": { "refresh_interval": "-1" } }

После заливки возвращают "1s" и вызывают elasticsearch.indexOps(ProductDoc.class).refresh() — иначе только что залитые документы не найдутся. Прирост скорости по сравнению с поштучной индексацией — в 10–50 раз.

Как держать индекс актуальным: четыре подхода

Elasticsearch в большинстве приложений — не основная база данных, а поисковый индекс рядом с PostgreSQL: данные появляются в PostgreSQL, и их нужно своевременно отражать в ES. Способов четыре.

Двойная запись — просто, но ненадёжно

Самый очевидный вариант: пишем в PostgreSQL и в ES в одном методе.

@Transactional
public void save(Product product) {
    productRepo.save(product);              // PostgreSQL
    elasticsearch.save(toDoc(product));     // Elasticsearch
}

Проблема: транзакция PostgreSQL и HTTP-запрос к ES — две разные операции. PostgreSQL закоммитил, ES вернул ошибку сети — данные разошлись, и приводить их обратно нечем. Подходит для прототипов, не для продакшена.

Transactional Outbox — надёжно, но нужна инфраструктура

Внутри транзакции PostgreSQL вместе с бизнес-данными записываем событие в специальную таблицу outbox. Отдельный процесс-читатель берёт события из этой таблицы и отправляет в ES.

@Transactional
public void save(Product product) {
    productRepo.save(product);
    outboxRepo.save(new OutboxEvent(
        UUID.randomUUID(),
        "product.updated",
        toJson(product)
    ));
}

Читатель — обычный @Scheduled-метод: взять пачку непрочитанных событий, отправить документы в ES, отметить события опубликованными.

Плюсы: данные не разойдутся — событие записано атомарно вместе с бизнес-данными; при сбое ES событие останется непрочитанным и будет повторено. Минусы: нужна таблица outbox, логика читателя и мониторинг отставания.

Разницу видно и без Elasticsearch — важно лишь то, что запись в базу и отправка в поиск не связаны одной транзакцией:

живой пример

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class SyncDemo {

    static final List<String> postgres = new ArrayList<>();
    static final List<String> outbox = new ArrayList<>();
    static final List<String> index = new ArrayList<>();
    static boolean esAvailable = false;

    static void sendToEs(String doc) {
        if (!esAvailable) {
            throw new IllegalStateException("ES недоступен");
        }
        index.add(doc);
    }

    static void dualWrite(String doc) {
        postgres.add(doc);
        try {
            sendToEs(doc);
        } catch (RuntimeException e) {
            // транзакция уже закоммичена — откатывать нечего
        }
    }

    static void writeWithOutbox(String doc) {
        postgres.add(doc);
        outbox.add(doc);
    }

    static void relay() {
        for (Iterator<String> it = outbox.iterator(); it.hasNext(); ) {
            try {
                sendToEs(it.next());
                it.remove();
            } catch (RuntimeException e) {
                return;
            }
        }
    }

    public static void main(String[] args) {
        dualWrite("product-1");
        writeWithOutbox("product-2");
        relay();
        System.out.println("ES лежит. в базе: " + postgres + ", в индексе: " + index + ", ждут: " + outbox);

        esAvailable = true;
        relay();
        System.out.println("ES поднялся. в индексе: " + index + ", ждут: " + outbox);
        System.out.println("product-1 потерян навсегда: " + !index.contains("product-1"));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Двойная запись теряет product-1 молча, а событие из outbox дождалось и ушло в индекс, когда ES вернулся.

CDC через Debezium → Kafka → ES — промышленный вариант

Change Data Capture (CDC) — подход, при котором ваш сервис вообще не знает про Elasticsearch. Он просто пишет в PostgreSQL.

PostgreSQL журнал WAL Debezium Kafka Kafka Connect ES Sink Elasticsearch

Индекс обновляется не приложением, а изменениями в журнале базы: так в него попадает всё, что реально записалось, даже правки в обход сервиса. Плата — ещё один конвейер, который надо эксплуатировать.

Debezium читает журнал транзакций PostgreSQL (WAL) и публикует каждое изменение как событие в Kafka. Коннектор Elasticsearch Sink читает эти события и вставляет/обновляет/удаляет документы в ES.

Преимущества: сервис не привязан к ES; ловятся все изменения, в том числе прямые правки в базе; при сбое коннектор продолжает с последней позиции. Задержка индексации — обычно 100–500 миллисекунд. Недостаток — нужно развернуть и поддерживать Debezium, Kafka и Kafka Connect.

Полная переиндексация по расписанию

Если данные меняются редко (справочники, каталоги), можно просто пересоздавать весь индекс ночью:

@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Moscow")
public void reindexCatalog() {
    var newIndex = "products-v" + System.currentTimeMillis();
    elasticsearch.indexOps(IndexCoordinates.of(newIndex)).create();

    // пачками, а не по одному документу — см. раздел про bulk выше
    var docs = productRepo.findAll().stream().map(this::toDoc).toList();
    for (int from = 0; from < docs.size(); from += 1000) {
        elasticsearch.save(docs.subList(from, Math.min(from + 1000, docs.size())),
                           IndexCoordinates.of(newIndex));
    }

    var actions = new AliasActions();
    if (elasticsearch.indexOps(IndexCoordinates.of("products")).exists()) {
        actions.add(new AliasAction.Remove(
            AliasActionParameters.builder()
                .withIndices("products-*").withAliases("products").build()));
    }
    actions.add(new AliasAction.Add(
        AliasActionParameters.builder()
            .withIndices(newIndex).withAliases("products").build()));
    elasticsearch.indexOps(IndexCoordinates.of(newIndex)).alias(actions);
}

Здесь принципиально одно: псевдоним обязательно снимают со старого индекса в той же операции — иначе он будет указывать сразу на оба, и поиск начнёт выдавать каждый товар дважды. Шаблон products-* в Remove снимает алиас со всех прошлых версий индекса. А на первом запуске, пока алиаса нет вовсе, это действие приведёт к ошибке — поэтому в коде перед ним и стоит проверка exists().

Прост в реализации и обслуживании. Не подходит, когда обновления нужно видеть в поиске в реальном времени.

Как прочитать результат

SearchHits — это не список документов, а выдача целиком: попадания, общее число, оценки, подсветка, агрегации. Разбирать её надо явно.

public SearchPage<ProductCard> search(String text, Pageable pageable) {
    NativeQuery query = NativeQuery.builder()
            .withQuery(q -> q.multiMatch(m -> m.query(text).fields("title^3", "description")))
            .withHighlightQuery(new HighlightQuery(
                    new Highlight(List.of(new HighlightField("title"))), Product.class))
            .withPageable(pageable)
            .build();

    SearchHits<Product> hits = operations.search(query, Product.class);

    List<ProductCard> cards = hits.getSearchHits().stream()
            .map(hit -> new ProductCard(
                    hit.getContent().getId(),
                    firstHighlight(hit, "title").orElse(hit.getContent().getTitle()),
                    hit.getScore()))
            .toList();

    return new SearchPage<>(cards, hits.getTotalHits(), pageable);
}

private Optional<String> firstHighlight(SearchHit<Product> hit, String field) {
    return Optional.ofNullable(hit.getHighlightFields().get(field))
            .flatMap(list -> list.stream().findFirst());
}

Три вещи, которые здесь важны. getContent() — это сам документ, getScore() — его оценка (она понадобится, чтобы показать «релевантность» или отладить порядок), getHighlightFields() — фрагменты с выделенными совпадениями, и они приходят только если подсветку запросили. getTotalHits() по умолчанию точен лишь до десяти тысяч — об этом ниже.

И общее правило: наружу отдают не SearchHits и не сам документ индекса, а свой объект для экрана. Иначе форма индекса протечёт в API, и любое изменение отображения станет ломающим изменением контракта.

Пагинация и её предел

Pageable работает как везде, и ровно поэтому в него попадают быстро: PageRequest.of(1000, 20) превращается в from: 20000, а поиск отвечает ошибкой — сумма смещения и размера страницы ограничена десятью тысячами (max_result_window).

Два способа жить с этим. Для интерфейса — не давать пролистать глубже: страниц показывают столько, сколько влезает в предел, а дальше просят уточнить запрос. Для выгрузок и бесконечной прокрутки — продолжение от последнего результата (search_after вместе с точкой во времени), которое в Spring Data задаётся через withSearchAfter и не имеет предела по глубине.

Отдельно про счётчик: точное общее число совпадений считается только до того же предела, дальше в ответе стоит «больше десяти тысяч». Точное число можно попросить (track_total_hits), но оно стоит полного подсчёта — для интерфейса это почти никогда не нужно.

Когда кластер недоступен

Поиск — не основная база, и его отказ не должен ронять сервис. По умолчанию всё наоборот: клиент ждёт, исключение летит наружу, страница отдаёт ошибку.

Что прилетает: при недоступности — NoNodeAvailableException или ошибка соединения, при перегрузке — отказ с кодом 429 (EsRejectedExecutionException в терминах кластера), при долгом запросе — таймаут. Все они в Spring Data заворачиваются в DataAccessResourceFailureException и родственные.

Что с этим делают. Таймаут держат коротким (единицы секунд — поиск либо быстрый, либо не нужен). Запрос оборачивают в обработку отказа и деградируют: показать выдачу из основной базы простым фильтром по названию, показать популярные товары вместо результатов поиска, или честно сказать «поиск временно недоступен» — но не пятисотую ошибку на весь экран. Размыкатель не даёт добивать кластер повторами, пока он не восстановится.

public SearchPage<ProductCard> searchSafely(String text, Pageable pageable) {
    try {
        return search(text, pageable);
    } catch (DataAccessResourceFailureException e) {
        log.warn("поиск недоступен, отдаём выдачу из базы: {}", e.toString());
        return fallbackFromDatabase(text, pageable);
    }
}

Запасной путь стоит один раз проверить учениями: погасить контейнер поиска на стенде и посмотреть, что видит пользователь.

Что кладут в индекс, а что оставляют в базе

Правило простое: в индекс попадает то, по чему ищут и фильтруют, плюс минимум для показа списка. Всё остальное остаётся в основной базе.

Так делают по трём причинам. Индекс дорожает: каждое поле — это место, время индексации и память под колоночные значения. Синхронизация дорожает: любое изменение поля, которое лежит в индексе, требует обновления индекса, даже если для поиска оно не нужно. И граница ответственности размывается: индекс, в котором лежит всё, незаметно становится вторым источником правды, и однажды кто-то начнёт считать по нему деньги.

Практическая раскладка для каталога: в индексе — название, описание, категория, бренд, цена, признаки наличия и активности, идентификатор. В базе — состав, характеристики, история цен, тексты для карточки. Карточка товара собирается из базы по идентификатору, который вернул поиск; список — прямо из индекса, чтобы не делать сотню запросов к базе на страницу.

Типичные ловушки

Elasticsearch не транзакционен

elasticsearch.save(doc) сохраняет документ немедленно и не откатывается вместе с транзакцией PostgreSQL. Это фундаментальное ограничение, и все подходы синхронизации выше построены именно вокруг него.

Документ не виден сразу после сохранения

По умолчанию ES обновляет поисковый индекс раз в секунду. Если в интеграционном тесте вы ищете документ сразу после его сохранения — он может ещё не появиться. Решение для тестов:

elasticsearch.withRefreshPolicy(RefreshPolicy.WAIT_UNTIL).save(doc);

withRefreshPolicy возвращает копию ElasticsearchOperations с другой политикой: у самого save такого параметра нет. Цена есть и здесь: запрос не вернётся, пока не случится ближайшее обновление индекса, так что каждый такой тест становится на доли секунды дольше. Часто дешевле сохранить как обычно, а потом один раз позвать elasticsearch.indexOps(ProductDoc.class).refresh() — обновление произойдёт сразу, и ждать не придётся никому.

В продакшене это дорого — там лучше принять задержку как данность.

Версия клиента и версия кластера должны совпадать

Держите клиента и кластер на одной мажорной версии: 8.x — с 8.x, 9.x — с 9.x. Строго обязательным это правило назвать нельзя: у клиента есть режим совместимости, в котором он представляется кластеру предыдущей мажорной версии (в запросах появляется заголовок compatible-with) — так 8.x умеет работать с 7.x. Но это выручалочка на время перехода: новых возможностей через неё не будет. Обновляете кластер — обновляйте и клиента.

Тип поля важно задать сразу

Если поле categoryId описать как Keyword вместо Long, его нужно будет передавать строкой. Позже изменить тип без пересоздания индекса нельзя.

Размер документа

Большой документ дорог не только при отправке: при изменении одного поля Elasticsearch переиндексирует его целиком. Размер HTTP-запроса по умолчанию ограничен 100 МБ (http.max_content_length), но тяжело становится гораздо раньше — большие тексты держат в отдельном хранилище, а в ES кладут только поисковые поля.

Дополнительно: при первом чтении можно пропустить

Глубже: тестырасширенное

Поиск проверяется только на настоящем Elasticsearch: заглушки не воспроизводят ни анализаторы, ни оценку релевантности, а именно в них живут ошибки. Поднимают его контейнером.

@Testcontainers
@SpringBootTest
class ProductSearchIT {

    @Container
    static ElasticsearchContainer es = new ElasticsearchContainer(
            DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch:8.15.0"))
            .withEnv("xpack.security.enabled", "false");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry registry) {
        registry.add("spring.elasticsearch.uris", es::getHttpHostAddress);
    }

    @Autowired ElasticsearchOperations operations;
    @Autowired ProductSearchService search;

    @Test
    void находитПоОснове() {
        operations.save(new Product("p-1", "Кофемолка ручная", "жернова"));
        operations.indexOps(Product.class).refresh();      // без этого документ ещё не виден

        var page = search.search("кофемолки", PageRequest.of(0, 10));

        assertThat(page.items()).extracting(ProductCard::id).containsExactly("p-1");
    }
}

Две обязательные детали. Обновление индекса (refresh) после записи: поиск видит документ не сразу, и без этой строки тест будет падать через раз — та самая ловушка, о которой предупреждает статья. И отображение из кода: индекс в тесте создаётся из тех же аннотаций, что в проде, иначе тест проверяет другую схему.

Что стоит покрывать такими тестами: что запрос находит нужное по разным формам слова, что фильтры не теряют документы, что сортировка и пагинация не ломаются, и что обновление документа доезжает до выдачи.

Глубже: aliases: переключение индексов без простоярасширенное

Схему Elasticsearch нельзя изменить на месте: если нужно переименовать поле или сменить тип — придётся создать новый индекс и перелить данные. Чтобы при этом приложение продолжало работать, используют алиасы.

Алиас — это указатель на индекс. Приложение обращается к алиасу products, не зная, какой именно индекс за ним стоит.

# схема поменялась: создали products-v2, перелили данные, переключаем алиас
POST /_aliases
{
  "actions": [
    { "remove": { "index": "products-v1", "alias": "products" } },
    { "add":    { "index": "products-v2", "alias": "products" } }
  ]
}

Переключение атомарно: все запросы переходят на новый индекс разом, без ошибок на стороне приложения.

правильно remove и add алиас на v2 товар один раз забыли remove только add алиас на оба товар дважды

Верхний ряд - как надо: алиас снимают со старого индекса и вешают на новый в одной операции; нижний показывает, что выйдет, если сделать только add.

Коротко

  • spring-boot-starter-data-elasticsearch поднимает клиента и репозитории; @Document описывает индекс, @Field — типы полей, @MultiField — поле сразу в двух вариантах: словами для поиска и целиком для фильтров.
  • ElasticsearchRepository подходит для простых запросов по именам методов; ElasticsearchOperations — для сложных запросов с полным контролем.
  • Bulk API быстрее поштучной индексации в 10–50 раз; при большом объёме временно отключайте refresh_interval.
  • Двойная запись ненадёжна; для продакшена — Transactional Outbox или CDC через Debezium.
  • Используйте алиасы вместо прямых имён индексов — это даёт возможность менять схему без простоя.
  • После сохранения документ виден в поиске примерно через секунду, поэтому поиск тестируют на настоящем Elasticsearch в контейнере и обязательно с обновлением индекса после записи — иначе тест падает через раз.
  • SearchHits разбирают явно: getContent() — документ, getScore() — оценка, getHighlightFields() — подсветка (только если её запросили); наружу отдают свой объект, а не документ индекса.
  • Pageable упирается в предел десяти тысяч (max_result_window), глубже листают через search_after с точкой во времени; точный счётчик совпадений тоже ограничен этим пределом.
  • Недоступный кластер не должен ронять страницу: короткий таймаут, обработка отказа с деградацией на выдачу из базы, размыкатель — и учения с погашенным контейнером.
  • В индекс кладут только то, по чему ищут и фильтруют, плюс минимум для списка: остальное оставляют в базе, иначе индекс становится вторым источником правды.

Что почитать дальше