Paginação elasticsearch
Recentemente recebi uma demanda de realizar consultas no elasticsearch e poder paginar as respostas, com cada página contento 100 registros, elasticsearch pode me ajudar?
Paginação de respostas é algo muito comum em aplicações. Em geral ela usa um componente mais ou menos nesse formato:
Acessar digamos a página 50 envolve no “pular” as 49 páginas anteriores para depois ter os dados referentes a página 50.
Para realizarmos os testes de paginação iremos utilizar o shakespeare database (possui cerca de 130.000 registros), que iremos carregar com o seguinte comando, e em seguida fazer um pequeno ajuste com o update by query, com o objetivo de criar um campo id no documento para ser usado como critério de ordenação da paginação:
curl -H "Content-Type: application/x-ndjson" -XPOST "localhost:9200/shakespeare/doc/_bulk?pretty" --data-binary @shakespeare_6.0.jsonPOST shakespeare/_update_by_query
{
"script": {
"source": "ctx._source.id = ctx._id",
"lang": "painless"
}
}
O método mais simples de paginação consiste em usar o from/size nas queries (exemplo para localizar a página 50) :
GET /shakespeare/_search
{
"from":5000,
"size": 100,
"query": {
"match_all": {}
},
"sort": "id.keyword"
}
Esta estratégia funciona bem , entretanto se tentamos extrair uma página posterior ao registro 10000 temos o seguinte erro:
Result window is too large, from + size must be less than or equal to: [10000] but was [10100]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level setting
Usar o from/size é uma técnica legal mas realmente apresenta essa limitação. Para paginações superiores a esse limite devemos usar as apis de scroll. As apis de scroll vão invariavelmente realizar chamadas sucessivas para recuperar blocos de consulta (não maiores que 10000 registros) a fim e chegar na página desejada.
Entre uma chamada e outra, TALVEZ algum registro tenha sido indexado pelo elasticsearch e estragar a consistência da paginação. Para resolver isso, precisamos entender um pouco como lucene (engine de indexação do elastic) trabalha. Os dados indexados são organizados em segmentos no lucene, e quando novos dados são indexados, novos segmentos são criados e em algum momento grupos de segmentos são unificados (merge) gerando novos segmentos. Neste processo de merge os segmentos antigos são descartados e removidos. Para garantir a consistência na paginação a estrategia das apis de paginação é manter os segmentos antigos, impedindo o seu descarte pelo período que a paginação estiver ativa (que não deve ser muito longo a fim de evitar o uso desnecessário de disco pelos segmentos que poderiam ser descartados).
Temos uma outra questão importante que como devemos considerar que é relativa ao payload transferido do elasticsearch para o cliente da sua linguagem. Ao requisitar o um bloco de 10000 registros contento o documento INTEIRO a quantidade de registros retornados ao cliente é muito grande. A estratégia que eu utilizo é realizar a paginação retornando no _source somente campos identificadores do registro, dessa forma o payload fica muito menor e ao final, com os ids da página selecionada, posso consultar esses registros na sua fonte(no meu caso um banco sql), ou ainda realizar uma segunda consulta no elasticsearch para retornar os documentos completos somente para esses 100 registros referente a página.
Bem, vamos ver a primeira opção, que é usar o scroll api, onde realizamos a consulta normal no elasticsearch, mas no GET, solicitamos o scroll por um determinado tempo:
GET /shakespeare/_search?scroll=1m
{
"size": 10000,
"_source": "id",
"query": {
"match_all": {}
},
"sort": "id.keyword"
}
Na resposta além dos campos usuais, existe um campo extra chamado scroll_id:
{
"_scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFm9mclR4Z1BZUmhpSTZ4c3A5eGNGOUEAAAAAAAAJwBZydjlNdldqblRHZUdGSzdXWm1jSHNB",
"took" : 450,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
....
E usando esse scroll_id podemos acessar as próximas páginas (desta vez sem especificar o indice) e vamos renovando para mais 1 minuto a “validade” do scroll:
GET /_search/scroll?scroll=1m
{
"scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFm9mclR4Z1BZUmhpSTZ4c3A5eGNGOUEAAAAAAAAJwBZydjlNdldqblRHZUdGSzdXWm1jSHNB"
}
O outro tipo de scroll é usando search_after. A estratégia dele é um pouco diferente, e consiste em realizar uma consulta ordenada e solicitando para trazer os registros posteriores ao último registro retornado na consulta anterior. Um dos requisitos para essa ativdade é ter um critério de ordenação de desempate, que em geral pode ser utilizar um id após as ordenações anteriores. A fim de resolver a questão de atualização, esta técnica deve ser combinado com a técnica de PIT (Point in Time) do elastic, onde “congelamos” os segmentos por um tempo e realizamos as consultas nos segmentos congelados. Primeiro, criamos o PIT:
POST /shakespeare/_pit?keep_alive=1mResposta:{
"id" : "p66xAwELc2hha2VzcGVhcmUWVjBlUHMxNy1Sci1mNTRoMVEzRFIwQQAWcnY5TXZXam5UR2VHRks3V1ptY0hzQQAAAAAAAAAMLBZvZnJUeGdQWVJoaUk2eHNwOXhjRjlBARZWMGVQczE3LVJyLWY1NGgxUTNEUjBBAAA="
}Pesquisa:GET /_search
{
"size": 10000,
"_source": "id",
"query": {
"match_all": {}
},
"sort": "id.keyword",
"pit": {
"id": "p66xAwELc2hha2VzcGVhcmUWVjBlUHMxNy1Sci1mNTRoMVEzRFIwQQAWcnY5TXZXam5UR2VHRks3V1ptY0hzQQAAAAAAAAAL_RZvZnJUeGdQWVJoaUk2eHNwOXhjRjlBARZWMGVQczE3LVJyLWY1NGgxUTNEUjBBAAA=",
"keep_alive": "1m"
}
}Resposta:
{
"pit_id" : "p66xAwELc2hha2VzcGVhcmUWVjBlUHMxNy1Sci1mNTRoMVEzRFIwQQAWcnY5TXZXam5UR2VHRks3V1ptY0hzQQAAAAAAAAAMTBZvZnJUeGdQWVJoaUk2eHNwOXhjRjlBARZWMGVQczE3LVJyLWY1NGgxUTNEUjBBAAA=",
....
{
"_index" : "shakespeare",
"_type" : "doc",
"_id" : "108997",
"_score" : null,
"_source" : {
"id" : "108997"
},
"sort" : [
"108997"
]
}
]
}
}
De posse do campo sort do último registro, podemos então realizar a consulta para o próximo bloco, sempre renovando o keep alive do PIT:
GET /_search
{
"size": 10000,
"_source": "id",
"query": {
"match_all": {}
},
"sort": "id.keyword",
"search_after": [
"108997"
],
"pit": {
"id": "p66xAwELc2hha2VzcGVhcmUWVjBlUHMxNy1Sci1mNTRoMVEzRFIwQQAWcnY5TXZXam5UR2VHRks3V1ptY0hzQQAAAAAAAAANDBZvZnJUeGdQWVJoaUk2eHNwOXhjRjlBARZWMGVQczE3LVJyLWY1NGgxUTNEUjBBAAA=",
"keep_alive": "1m"
}
}
E assim sucessivamente, alterando o parâmetro do search_after para irmos avançando nas páginas, de forma similar a scroll api.
A documentação do elasticsearch apresenta as duas soluções porém ela recomenda em muitos casos usar o search_after ao invés do scroll api. Construi dois shell scripts usando o curl e o jq(para seleção de elementos do json) para percorrer todo o dataset usando as duas abordagens (algumas lógicas no meu bash são meio old school, mas funcionam):
Usando o utilitário time do linux e fazendo uma analise empírica e nada cientifica na meu notebook, o search api realizou a consulta de todo o dataset em cerca de 4.5s enquanto que o search_after realizou em 5.7s, em um elasticsearch 7.10.1 .
Algo que notei em todos os meus testes de paginação que acessando páginas mais distantes do inicio vão ficando cada vez mais lento. Isso seria esperado pelo scroll api e search_after, visto que é necessário realizar diversas chamadas consecutivas, mas o próprio request from/size fica mais lento próximo ao limite de 10000.
Atualmente estou usando uma mescla das duas abordagens, sempre minimizando o retorno do elastic usando um _source bem enxuto. Para páginas anteriores ao limite de 10000 uso o from/size e para posteriores a este limite utilizo o search api
Referências: