O Couchbase Analytics foi projetado para oferecer suporte a consultas ad hoc, aproveitando o processamento paralelo eficiente. Isso é particularmente útil quando os resultados de uma consulta o levam a buscar respostas em outra consulta. (Para os analistas, em outras palavras, o tempo todo!) Como em qualquer plataforma de dados, essas consultas secundárias podem, às vezes, resultar em tempos de execução significativamente diferentes dos da primeira. Recentemente, trabalhamos com um cliente que observou uma queda perceptível no desempenho depois de fazer uma pequena alteração em sua consulta. O processo pelo qual passamos para isolar e resolver o problema está descrito abaixo.
Os dados
Como sempre, a primeira etapa para entender uma consulta é se familiarizar com os dados subjacentes. Nesse caso, os dados são compostos de documentos JSON, cada um dos quais é uma descrição das cobranças do cliente por serviços. As cobranças de cada sessão são incorporadas em uma matriz:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
{ "cliente": 1000002, "dtype": "sessão", "sessionCharges": [ { "qty": 1, "revCode": "B", "svc": 44284, "amt": 40, "svcDate": 1592889276 }, { "qty": 1, "revCode": "A", "svc": 28078, "amt": 24, "svcDate": 1592891076 }, { "qty": 1, "revCode": "B", "svc": 38968, "amt": 26, "svcDate": 1592893416 } ] } |
Algumas observações sobre o design do modelo de dados:
- Eles foram inteligentes com relação aos nomes dos campos, que são longos o suficiente para serem descritivos, mas curtos o suficiente para economizar espaço. Os nomes de campo dentro da matriz poderiam facilmente ter sido escritos como "quantity", "revenueCode", "service", "amount", "serviceDate". Em 100 milhões de instâncias, no entanto, isso exigiria um adicional de 3,73 GB de espaço antes da compactação.
- Eles usaram datas de época, sem milissegundos. Se essas datas fossem armazenadas no formato ISO 8601 ("2020-07- 24T18:17:49" ou com milissegundos "2020-07-24T18:17:49.000"), elas exigiriam o dobro do espaço de armazenamento.
Consulta inicial
A consulta inicial foi projetada para recuperar a combinação de cliente/serviço de maior receita em cada uma das categorias de código de receita:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
selecionar latestService, cliente, código de revisão, serviço, rodada(totalDollar) dólares, totalQuant de ( selecionar s.cliente, c.serviço como serviço, c.código de revisão, soma(c.amt) como totalDollar, soma(c.quantidade) como totalQuant, millis_to_str(máximo(c.svcDate)*1000, '1111-11-11') como latestService de sessões s não registrado s.sessionCharges c grupo por s.cliente, c.código de revisão, c.serviço ) a onde classificação() sobre (partição por código de revisão ordem por totalDollar desc) = 1 ordem por código de revisão, totalDollar; |
Algumas observações sobre o design da consulta:
- O conteúdo da matriz é acessado pelo verbo "unnest". Isso permite que os valores sejam agregados e retornados como um registro nivelado.
- A data de serviço mais recente é convertida de época para data legível por humanos por meio da função millis_to_str().
- A função de janela "rank() over partition" é usada na cláusula where da consulta externa como um meio eficiente de retornar apenas o valor superior em cada código de receita.
- A função round() é cosmética, mas comumente usada em relatórios em que os centavos não são significativos.
A desaceleração
Os problemas surgiram quando foi adicionado um cálculo de desconto por volume (20% off quando a quantidade acumulada de um serviço é 10 ou mais). Essa nova consulta levou seis vezes mais tempo para ser executada!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
selecionar latestService, cliente, código de revisão, serviço, rodada(totalDollar) dólares, rodada(adjDólar) dólaresAdj, totalQuant de ( selecionar s.cliente, c.serviço como serviço, c.código de revisão, soma(c.amt) como totalDollar, caso quando soma(c.quantidade) < 10 então soma(c.amt) mais soma(c.amt) * .8 final como adjDólar, soma(c.quantidade) como totalQuant, millis_to_str(máximo(c.svcDate)*1000, '1111-11-11') como latestService de sessões s não registrado s.sessionCharges c grupo por s.cliente, c.serviço, c.código de revisão ) a onde classificação() sobre (partição por código de revisão ordem por totalDollar desc) = 1 ordem por código de revisão, totalDollar; |
Como podemos rastrear a causa disso?
Explique-se
O console de consulta do Analytics facilita a inspeção do plano de execução de qualquer consulta, bastando clicar no botão "Plan" (Plano). A Figura (1) abaixo mostra o plano de consulta para essa consulta. Não se preocupe com a escala minúscula do diagrama; você pode usar os controles para expandi-lo e contraí-lo. Você também pode clicar em cada etapa individual para obter informações detalhadas.

Figura 1
Se preferir, você também pode clicar no botão "Plan Text" (Texto do plano) para gerar um documento JSON descrevendo o plano:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
{ "operador": "distribute-result", "expressões": [ "$$157" ], "operatorId": "1.1", "physical-operator" (operador físico): "DISTRIBUTE_RESULT", "modo de execução": "PARTITIONED", "entradas": [ { "operador": "câmbio", "operatorId": "1.2", "physical-operator" (operador físico): "ONE_TO_ONE_EXCHANGE", "modo de execução": "PARTITIONED", "entradas": [ { "operador": "projeto", "variáveis": [ "$$157" ], "operatorId": "1.3", "physical-operator" (operador físico): "STREAM_PROJECT", "modo de execução": "PARTITIONED", "entradas": [ { "operador": "atribuir", "variáveis": [ "$$157" ], ... etc. |
Isso pode se tornar muito aninhado (e um pouco difícil de ler) à medida que as consultas se tornam mais complexas. Um truque que nossos engenheiros costumam usar é anexar o comando "explain text" (explicar texto) na frente da consulta:
1 2 3 |
explicar texto selecionar ... etc.; |
Isso fornece uma descrição detalhada do plano sem os colchetes ondulados:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
distribuir resultado [$$157] -- DISTRIBUIR_RESULTADO |PARTICIONADO| troca -- INTERCÂMBIO DE UM PARA UM |PARTICIONADO| projeto ([$$157]) -- PROJETO STREAM |PARTICIONADO| atribuir [$$157] <- [{"latestService": millis-para-local(numérico-multiplicar($$168, 1000), "1111-11-11"), "cliente": $$cliente, "revCode": $$código de revisão, "serviço": $$serviço, "dólares": rodada($$166), "dollarsAdj": rodada($$126), "totalQuant": $$167}] -- ATRIBUIÇÃO |PARTICIONADO| troca -- SORT_MERGE_TROCA [$$código de revisão(ASC), $$166(ASC) ] |PARTICIONADO| ordem (ASC, $$código de revisão) (ASC, $$166) -- ESTÁVEL_CLASSIFICAR [$$código de revisão(ASC), $$166(ASC)] |PARTICIONADO| troca -- INTERCÂMBIO DE UM PARA UM |PARTICIONADO| etc. |
Isso é o que usaremos para procurar pistas.
Vamos fazer uma lista
Temos quase certeza de que a lentidão foi introduzida quando adicionamos a instrução CASE que define o campo adjDollars, portanto, vamos começar procurando a lógica de caso. Nós a encontramos na linha 33:
1 |
atribuir [$$125] <- [interruptor-caso(VERDADEIRO, lt($$165, 10), $$117, $$124)] |
Ao seguirmos a lógica nas linhas seguintes, vemos que o plano de execução continua a atribuir e trocar variáveis de forma particionada. Na linha 44, entretanto, vemos o seguinte:
1 |
agregado [$$117] <- [listar($$196)] |
Nos casos em que o desempenho da consulta ou o consumo de memória são uma preocupação, a função "listify" pode ser um sinal de alerta. (Ela tem seu uso, principalmente na transformação interna de agregados de matriz, mas também pode ser usada pelo plano de execução como a função de último recurso. No nosso caso, vemos que a variável $$117 é o resultado desse processo de listify. Essa $$117, como podemos ver no caso de troca acima, está associada ao objeto sum(c.amt) na consulta. Portanto, vamos tentar mover o cálculo das somas para fora do caso de troca interior e torná-las parte de um LET:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
explicar texto selecionar latestService, cliente, código de revisão, serviço, rodada(totalDollar) dólares, rodada(adjDólar) dólaresAdj, totalQuant de ( selecionar s.cliente, c.serviço como serviço, c.código de revisão, totalDollar, caso quando totalQuant < 10 então totalDollar mais totalDollar * .8 final como adjDólar, totalQuant, millis_to_str(máximo(c.svcDate)*1000, '1111-11-11') como latestService de sessões s não registrado s.sessionCharges c grupo por s.cliente, c.serviço, c.código de revisão deixar totalQuant = soma(c.quantidade), totalDollar = soma(c.amt) ) a onde classificação() sobre (partição por código de revisão ordem por totalDollar desc) = 1 ordem por código de revisão, totalDollar; |
Depois de fazermos isso e reexaminarmos o plano de explicação, vemos que seu comprimento foi reduzido pela metade e que o processo de execução não inclui mais as funções listify. A execução da consulta, então, deve mostrar um desempenho muito melhor, o que de fato acontece. Problema resolvido!
Uma observação final: nossos engenheiros estão sempre procurando maneiras de melhorar nosso otimizador de consultas. Se, durante a otimização de uma de suas consultas, você encontrar no texto explicativo uma função "listify" desonesta (o que já é raro e está ficando cada vez mais raro), abra um tíquete de suporte em support.couchbase.com, carregando seu plano explicativo para que eles possam dar uma olhada nele. Na verdade, como resultado direto do trabalho com o cliente mencionado acima, esse exemplo específico (switch-case usado com uma função agregada) será, a partir da versão 6.6 do Couchbase, automaticamente reescrito pelo otimizador.
Documentos e recursos
O site de documentos do Couchbase contém links para a referência da linguagem N1QL for Analytics: https://docs.couchbase.com/server/current/analytics/introduction.html. Lá você também encontra um link para um tutorial sobre a linguagem e para o livro de Don Chamberlin (co-inventor da linguagem SQL): https://www.couchbase.com/sql-plus-plus-for-sql-users.
Muito obrigado a Dmitry Lychagin e Mike Carey pela revisão cuidadosa desta publicação.