O custo de não conhecer o MongoDB
Artur Costa23 min read • Published Nov 11, 2024 • Updated Nov 11, 2024
Avalie esse Artigo
O foco principal desta série é mostrar quanto de desempenho você pode obter e, como consequência, o custo que pode economizar ao usar o MongoDB corretamente, seguindo as práticas recomendadas, analisando as necessidades do seu aplicação e usando-o para modelar seus dados.
Para mostrar esses possíveis ganhos, um aplicação fictício será apresentado, e muitas possíveis implementações dele usando o MongoDB serão desenvolver e testar com carga. Haverão implementações para todos os níveis de conhecimento do MongoDB : iniciante, intermediário, sênior e atordoante(m) .
Todo o código e algumas informações extras usados neste artigo podem ser encontrados no Github repositório do .
O objetivo do aplicação é identificar o comportamento malicioso em um sistema de transações financeiros analisando os status das transações durante um período de tempo para um determinado usuário. Os possíveis status de transação são
approved
, noFunds
, pending
e rejected
.Cada usuário é exclusivamente identificável por um valor dekey
64caracteres hexadecimais.O aplicação receberá o status da transação por meio de um documento
event
. Um event
sempre fornecerá informações para uma transação para um usuário em um dia específico e, por isso, sempre terá apenas um dos campos de status possíveis, e esse campo de status terá o valor numérico 1. Como exemplo, o documentoevent
a seguir documento uma transação com o status de pending
para o usuário com identificação key
de ...0001
que ocorreu no date
/dia 2022-02-01
:1 const event = { 2 key: '0000000000000000000000000000000000000000000000000000000000000001', 3 date: new Date('2022-02-01'), 4 pending: 1, 5 };
Os status das transações serão analisados comparando os totais de status nos últimos
oneYear
, threeYears
,fiveYears
, sevenYears
e tenYears
para qualquer usuário. Estes totais serão fornecidos em um documentoreports
, que pode ser solicitado fornecendo ao usuário key
e o final date
do relatório.O documento a seguir é um exemplo de um documento
reports
para o usuário do key
...0001
e data final de 2022-06-15
:1 export const reports = [ 2 { 3 id: 'oneYear', 4 end: new Date('2022-06-15T00:00:00.000Z'), 5 start: new Date('2021-06-15T00:00:00.000Z'), 6 totals: { approved: 4, noFunds: 1, pending: 1, rejected: 1 }, 7 }, 8 { 9 id: 'threeYears', 10 end: new Date('2022-06-15T00:00:00.000Z'), 11 start: new Date('2019-06-15T00:00:00.000Z'), 12 totals: { approved: 8, noFunds: 2, pending: 2, rejected: 2 }, 13 }, 14 { 15 id: 'fiveYears', 16 end: new Date('2022-06-15T00:00:00.000Z'), 17 start: new Date('2017-06-15T00:00:00.000Z'), 18 totals: { approved: 12, noFunds: 3, pending: 3, rejected: 3 }, 19 }, 20 { 21 id: 'sevenYears', 22 end: new Date('2022-06-15T00:00:00.000Z'), 23 start: new Date('2015-06-15T00:00:00.000Z'), 24 totals: { approved: 16, noFunds: 4, pending: 4, rejected: 4 }, 25 }, 26 { 27 id: 'tenYears', 28 end: new Date('2022-06-15T00:00:00.000Z'), 29 start: new Date('2012-06-15T00:00:00.000Z'), 30 totals: { approved: 20, noFunds: 5, pending: 5, rejected: 5 }, 31 }, 32 ];
Duas funções para cada versão do aplicação foram criadas para serem executadas simultaneamente e testarem o desempenho de cada versão do aplicação . Uma função é chamada
Bulk Upsert
, que insere os documentos do evento . O outro é chamado Get Reports
, que gera o reports
para um usuário específico key
e date
. A paralelização da execução de cada função foi feita usando threads de trabalho, com 20 trabalhadores alocados para cada função. A duração do teste para cada versão do aplicação é 200 minutos, com diferentes parâmetros de execução sendo usados durante o teste de carga.A função
Bulk Upsert
receberá lotes de 250 documentos de evento a serem registrados. Como o nome indica, esses registros serão feitos usando a funcionalidadeupsertdo MongoDB. Ele tentará atualizar um documento e, se ele não existir, criará um novo com os dados disponíveis na operação de atualização. Cada iteraçãoBulk Upsert
será programada e registrada em um banco de banco de dados secundário. A taxa de processamento em lote será dividida em quatro fases, cada uma com 50 minutos, totalizando 200 minutos. A taxa começará com uma inserção em lote por segundo e aumentará em um a cada 50 minutos, terminando com quatro inserções em lote por segundo ou 1000 documentos de evento por segundo.A função
Get Reports
gerará um documentoreports
por execução. Cada execuçãoreports
será temporizada e registrada no banco de banco de dados secundário. A taxa de geração de reports
será dividida em 40 fases, 10 fases para cadafaseBulk Upsert
. Em cada fase de Bulk Upsert
, a taxa começará com 25 solicitações de relatório por segundo e aumentará 25 solicitações de relatório por segundo a cada cinco minutos, terminando com 250 relatórios completos por segundo na mesma faseBulk Upsert
.O gráfico a seguir representa as taxas de
Bulk Upsert
e Get Reports
para o cenário de teste apresentado acima: Para fazer uma comparação justa entre as versões do aplicação , o cenário inicial/ conjunto de trabalho usado nos testes tinha que ser maior do que a memória da máquina que executa o servidor MongoDB , forçando a atividade de cache e evitando a situação em que todo o conjunto de trabalho caberia o cache. Para isso, foram escolhidos os seguintes parâmetros:
- 10 anos de dados, de
2010-01-01
a2020-01-01
- 50 milhões de eventos por ano, totalizando 500 milhões para o conjunto de trabalho
- 60 eventos por usuário/
key
por ano
Considerando o número de eventos por ano e o número de eventos por usuário por ano, o número total de usuários é 50.000.000/60=833.333. O gerador do usuário
key
foi ajustado para produzir chaves que se aproximam de uma distribuição normal/gaussiano para simular um cenário do mundo real em que alguns usuários terão mais eventos do que outros. O gráfico a seguir mostra a distribuição de 50 milhões de chaves geradas pelo geradorkey
.Para também abordar um cenário do mundo real, a distribuição dos status do evento é:
- 80%
approved
. - 10%
noFunds
. - 7.5%
pending
. - 2.5%
rejected
.
A instância do EC2 que executa o servidor MongoDB é um
c7a.large
na nuvem do Amazon Web Services . Tem 2vCPU e 4GB de memória. Dois discos foram anexados a ele: um para o sistema operacional com 15GB
de tamanho e GP3
tipo, e o outro para o servidor MongoDB , que armazena seus dados com 300GB
de tamanho, IO2
tipo e 10.000IOPS
. O sistema operacional instalado na instância é Ubuntu 22.04, com todas as atualizações e upgrades disponíveis no momento. Todas as notas de produção recomendadas foram aplicadas à máquina para permitir que o MongoDB extraia o desempenho máximo do hardware disponível.A instância do EC2 que executa o servidor de aplicação é um
c6a.xlarge
na nuvem do Amazon Web Services . Tem 4vCPU e 8GB de memória. Dois discos foram anexados a ele: um para o sistema operacional com 10GB
de tamanho e GP3
tipo, e o outro para o servidor MongoDB secundário, que armazena seus dados com 10GB
de tamanho e GP3
tipo. O sistema operacional instalado na instância é Ubuntu 22.04, com todas as atualizações e upgrades disponíveis no momento. Todas as notas de produção recomendadas foram aplicadas à máquina para permitir que o MongoDB extraia o desempenho máximo do hardware disponível.A primeira versão do aplicação e o caso base de nossa comparação teria sido desenvolvido por alguém com um nível de conhecimento júnior do MongoDB , que acabou de dar uma rápida olhada na documentação e aprender que cada documento em uma coleção deve ter um campo
_id
e este o campo é sempre indexado único.Para aproveitar o campo e índice obrigatórios
_id
, o desenvolvedor decide armazenar os valores de key
e date
em um documento incorporado no campo _id
. Com isso, cada documento registrará os totais de status para um usuário, especificado pelo campo _id.key
, em um dia, especificado pelo campo _id.date
.A implementação do aplicação apresentado acima teria o seguinte esquema de documento Typescript denominado
ScemaV1
:1 type SchemaV1 = { 2 _id: { 3 key: string; 4 date: Date; 5 }; 6 approved?: number; 7 noFunds?: number; 8 pending?: number; 9 rejected?: number; 10 };
Com base na especificação apresentada, temos a seguinte operação em massa
updateOne
para cada event
gerado pelo aplicação:1 const operation = { 2 updateOne: { 3 filter: { 4 _id: { date: event.date, key: event.key }, 5 }, 6 update: { 7 $inc: { 8 approved: event.approved, 9 noFunds: event.noFunds, 10 pending: event.pending, 11 rejected: event.rejected, 12 }, 13 }, 14 upsert: true, 15 }, 16 };
serão necessários cinco pipelines de agregação , um para cada intervalo de datas, para concluir a operação
Get Reports
. Cada intervalo de datas terá o seguinte pipeline, com apenas o intervalo_id.date
no filtro$match
sendo diferente:1 const pipeline = [ 2 { 3 $match: { 4 '_id.key': request.key, 5 '_id.date': { $gte: Date.now() - oneYear, $lt: Date.now() }, 6 }, 7 }, 8 { 9 $group: { 10 _id: null, 11 approved: { $sum: '$approved' }, 12 noFunds: { $sum: '$noFunds' }, 13 pending: { $sum: '$pending' }, 14 rejected: { $sum: '$rejected' }, 15 }, 16 }, 17 ];
Conforme apresentado na introdução desta implementação do aplicação , o principal objetivo de incorporar os campos
key
e date
no campo _id
campo aproveitar sua existência e índice obrigatórios. Mas, após alguns testes e pesquisas prévias, verificou-se que o índice no campo _id
campo suportaria os critérios de filtragem/correspondência na função Get Reports
. Com isso, o seguinte índice extra foi criado:1 const keys = { '_id.key': 1, '_id.date': 1 }; 2 const options = { unique: true }; 3 4 db.appV1.createIndex(keys, options);
Para aqueles que se perguntam por que precisamos de um índice extra nos campos do documento incorporado no campo
_id
, que já é indexado por padrão, uma explicação detalhada pode ser encontrada em Índice de documentos incorporados.Inserindo os 500 milhões de documentos de evento para o cenário inicial na collection
appV1
com o esquema e a funçãoBulk Upsert
apresentados acima, temos o seguinte collection stats
:coleção | Documentos | tamanho de dados | Tamanho do documento | Tamanho do armazenamento | Índices | Tamanho do Índice |
---|---|---|---|---|---|---|
appV1 | 359,639,622 | 39.58GB | 119B | 8.78GB | 2 | 20.06GB |
Outra métrica interessante que podemos acompanhar por meio das versões do aplicação é o tamanho do armazenamento necessário, os dados e o índice para armazenar um dos 500 milhões de eventos — vamos chamá-lo
event stats
. Podemos obter esse valor dividindo o Tamanho dos dados e o Tamanho do índice das estatísticas iniciais do cenário pelo número de documentos do evento . Para appV1
, temos o seguinte event stats
:coleção | Tamanho dos Dados/ventos | Tamanho/ventos do Índice | Tamanho total/ventos |
---|---|---|---|
appV1 | 85B | 43.1B | 128.1B |
Executando o teste de carga para
appV1
, temos os seguintes resultados para Get Reports
e Bulk Upsert
:Os gráficos acima mostram que em quase nenhum momento o
appV1
conseguiu atingir as taxas desejadas. O primeiro estágio do Bulk Upsert dura 50 minutos com uma taxa desejada de 250 eventos por segundo. A taxa de evento só é atingida nos primeiros 10 minutos do teste de carga. O primeiro estágio de Obter relatórios dura 10 minutos com uma taxa desejada de 20 relatórios por segundo. A taxa de relatórios nunca é atingida, e o valor mais alto é de 16.5 relatórios por segundo. Como esta é nossa primeira implementação e teste, não há muito mais sobre o que raciocinar.O primeiro problema que pode ser destacado e melhorado nesta implementação é o esquema do documento em combinação com os dois índices. Como os campos
key
e date
estão em um documento incorporado no campo _id
, seus valores são indexados duas vezes: pelo índice padrão/obrigatório no campo _id
e pelo índice que criamos para suportar Bulk Upserts
e operaçõesGet Reports
.Como o campo
key
é uma string de 64caracteres e o campodate
é do tipo data, esses dois valores usam pelo menos 68 bytes de armazenamento. Como temos dois índices, cada documento contribuirá para 136 bytes de índice em um cenário não compactado.A melhoria aqui é extrair os campos
key
e date
do campo _id
e permitir que o campo _id
campo seu valor padrão do tipo ObjectId. O tipo de dados ObjectId ocupa apenas 12 bytes de armazenamento.Essa primeira implementação pode ser vista como um cenário forçado do pior caso para fazer com que as soluções mais otimizadas tenham uma aparência melhor. Infelizmente, esse não é o caso. Não é difícil encontrar implementações como essa na internet e trabalhei em um grande projeto com um esquema como esse, de onde tirar a ideia para este primeiro caso.
Conforme discutido nos problemas e melhorias de
appV1
, incorporar os campos key
e date
como um documento no campo _id
, tentando aproveitar seu índice obrigatório, não é uma boa solução para nosso aplicação porque ainda precisariamos criar um índice extra, e o índice no campo _id
campo mais armazenamento do que o necessário.Para resolver o problema do índice no campo
_id
campo maior do que o necessário, a solução é mover os campos key
e date
para fora do documento incorporado no campo _id
e deixar o _id
campo tem seu valor padrão do tipo ObjectId
. Cada documento ainda registraria os totais de status para um usuário, especificado pelo campo key
, em um dia, especificado pelo campo date
, assim como em appV1
.Esta segunda versão do aplicação e suas melhorias ainda teriam sido desenvolvida por alguém com um nível de conhecimento júnior do MongoDB , mas que tem mais profundidade na documentação relacionada a índices no MongoDB, especialmente ao indexar campos de documentos do tipo.
A implementação do aplicação apresentado acima teria o seguinte esquema de documento Typescript denominado
SchemaV2
:1 type SchemaV2 = { 2 _id: ObjectId; 3 key: string; 4 date: Date; 5 approved?: number; 6 noFunds?: number; 7 pending?: number; 8 rejected?: number; 9 };
Com base na especificação apresentada, temos a seguinte operação em massa
updateOne
para cada event
gerado pelo aplicação:1 const operation = { 2 updateOne: { 3 filter: { key: event.key, date: event.date }, 4 update: { 5 $inc: { 6 approved: event.approved, 7 noFunds: event.noFunds, 8 pending: event.pending, 9 rejected: event.rejected, 10 }, 11 }, 12 upsert: true, 13 }, 14 };
serão necessários cinco pipelines de agregação , um para cada intervalo de datas, para concluir a operação
Get Reports
. Cada intervalo de datas terá o seguinte pipeline, com apenas o intervalodate
no filtro$match
sendo diferente:1 const pipeline = [ 2 { 3 $match: { 4 key: request.key, 5 date: { $gte: Date.now() - oneYear, $lt: Date.now() }, 6 }, 7 }, 8 { 9 $group: { 10 _id: null, 11 approved: { $sum: '$approved' }, 12 noFunds: { $sum: '$noFunds' }, 13 pending: { $sum: '$pending' }, 14 rejected: { $sum: '$rejected' }, 15 }, 16 }, 17 ];
Para suportar os critérios de filtro/correspondência de
Bulk Upsert
e Get Reports
, o seguinte índice foi criado na coleçãoappV2
:1 const keys = { key: 1, date: 1 }; 2 const options = { unique: true }; 3 4 db.appV2.createIndex(keys, options);
Inserindo os 500 milhões de documentos de evento para o cenário inicial na collection
appV2
com o esquema e a funçãoBulk Upsert
apresentados acima, além de apresentar os valores das versões anteriores, temos o seguinte collection stats
:coleção | Documentos | tamanho de dados | Tamanho do documento | Tamanho do armazenamento | Índices | Tamanho do Índice |
---|---|---|---|---|---|---|
appV1 | 359,639,622 | 39.58GB | 119B | 8.78GB | 2 | 20.06GB |
appV2 | 359,614,536 | 41.92GB | 126B | 10.46GB | 2 | 16.66GB |
Calculando o
event stats
para appV2
e também apresentam os valores das versões anteriores, temos o seguinte:coleção | Tamanho dos Dados/ventos | Tamanho/ventos do Índice | Tamanho total/ventos |
---|---|---|---|
appV1 | 85B | 43.1B | 128.1B |
appV2 | 90B | 35.8B | 125.8B |
Analisando as tabelas acima, podemos ver que de
appV1
para appV2
, aumentamos o tamanho dos dados em 6% e diminuímos o tamanho do índice em 17%. Podemos dizer que nosso objetivo de tornar o índice no campo _id
campo foi atingido.Analisando
event stats
, o tamanho total por valor de evento aumentou apenas 1.8%, de 128.1B para 125.8B. Com essa diferença sendo tão pequena, há uma boa chance de que não vejamos melhorias significativas do ponto de vista do desempenho.Executando o teste de carga para
appV2
e plotando-o juntamente com os resultados para appV1
, temos os seguintes resultados para Get Reports
e Bulk Upsert
:Os gráficos acima mostram que em quase nenhum momento
appV2
atingiu as taxas desejadas, tendo um resultado muito semelhante ao appV1
, conforme previsto no Initial Scenario Stats
quando obtivemos apenas uma melhoria de 1.7% no event stats
. appV2
só atingiu a taxaBulk Upsert
desejada de 250 eventos por segundo nos primeiros 10 minutos do teste e obteve apenas 17 relatórios por segundo em Get Reports
, abaixo dos 20 relatórios por segundo desejado.Comparando as duas versões, podemos ver que
appV2
teve um desempenho melhor que appV1
nas operaçõesBulk Upsert
e pior que as operaçõesGet Reports
. A melhoria nas operaçõesBulk Upsert
pode ser atribuída aos índices serem menores, e a degradação no Get Reports
pode ser atribuída ao fato de o documento ser maior.O seguinte documento é uma amostra da collection
appV2
:1 const document = { 2 _id: ObjectId('6685c0dfc2445d3c5913008f'), 3 key: '0000000000000000000000000000000000000000000000000000000000000001', 4 date: new Date('2022-06-25T00:00:00.000Z'), 5 approved: 10, 6 noFunds: 3, 7 pending: 1, 8 rejected: 1, 9 };
Analisando-o com o objetivo de reduzir seu tamanho, podem ser encontrados dois pontos de melhoria. Um é o campo
key
, que é uma string e sempre terá 64 caracteres de dados hexadecimais, e o outro é o nome dos campos de status, que combinados podem ter até 30 caracteres.O campo
key
, conforme apresentado na seção de cenário, é composto por dados hexadecimais, nos quais cada caractere requer quatro bits para ser apresentado. Em nossa implementação até agora, armazenamos esses dados como strings usando codificação UTF-8, na qual cada caractere requer oito bits para ser representado. Então, estamos usando o double do armazenamento necessário. Uma maneira de contornar esse problema é armazenar os dados hexadecimais em seu formato bruto usando os dados binários.Para os nomes dos campo de status, podemos ver que os nomes dos campos usam mais armazenamento do que o próprio valor. Os nomes dos campo são cadeias de caracteres com pelo menos sete caracteres UTF-8 , que usam pelo menos sete bytes. O valor dos campos de status é um número inteiro 32bits, que usa quatro bytes. Podemos abreviar os nomes de status pelo primeiro caractere, em que
approved
se torna a
, noFunds
se torna n
, pending
se torna p
e rejected
se torna r
.Conforme discutido nas questões e melhorias do
appV2
, para reduzir o tamanho do documento , duas melhorias foram propostas. Uma é converter o tipo de dados do campo key
de string para binário, exigindo quatro bits para representar cada caractere hexadecimal em vez dos oito bits de um caractere UTF-8 . A outra é abreviar o nome dos campos de status pela primeira letra, exigindo um byte para cada nome de campo em vez de sete bytes. Cada documento ainda registraria os totais de status para um usuário, especificado pelo campo key
, em um dia, especificado pelo campo date
, assim como nas implementações anteriores.Para converter o valor
key
de uma string para binário/buffer, a seguinte função Typescript foi criada:1 const buildKey = (key: string): Buffer => { 2 return Buffer.from(key, 'hex'); 3 };
A terceira versão do aplicação tem duas melhorias em comparação com a segunda versão. A melhoria de armazenar o campo
key
como dados binários para reduzir suas necessidades de armazenamento teria sido pensada por um desenvolvedor intermediário a sênior do MongoDB que leu a documentação do MongoDB muitas vezes e trabalhou em projetos diferentes. A melhoria de abreviar o nome dos campos de status teria sido pensada por um desenvolvedor intermediário do MongoDB que passou por parte da documentação do MongoDB .A implementação do aplicação apresentado acima teria o seguinte esquema de documento Typescript denominado
SchemaV3
:1 type SchemaV3 = { 2 _id: ObjectId; 3 key: Buffer; 4 date: Date; 5 a?: number; 6 n?: number; 7 p?: number; 8 r?: number; 9 };
Com base na especificação apresentada, temos a seguinte operação em massa
updateOne
para cada event
gerado pelo aplicação:1 const operation = { 2 updateOne: { 3 filter: { key: buildKey(event.key), date: event.date }, 4 update: { 5 $inc: { 6 a: event.approved, 7 n: event.noFunds, 8 p: event.pending, 9 r: event.rejected, 10 }, 11 }, 12 upsert: true, 13 }, 14 };
serão necessários cinco pipelines de agregação , um para cada intervalo de datas, para concluir a operação
Get Reports
. Cada intervalo de datas terá o seguinte pipeline, com apenas o intervalodate
no filtro$match
sendo diferente:1 const pipeline = [ 2 { 3 $match: { 4 key: buildKey(request.key), 5 date: { $gte: Date.now() - oneYear, $lt: Date.now() }, 6 }, 7 }, 8 { 9 $group: { 10 _id: null, 11 approved: { $sum: '$a' }, 12 noFunds: { $sum: '$n' }, 13 pending: { $sum: '$p' }, 14 rejected: { $sum: '$r' }, 15 }, 16 }, 17 ];
Para suportar os critérios de filtro/correspondência de
Bulk Upsert
e Get Reports
, o seguinte índice foi criado na coleçãoappV3
:1 const keys = { key: 1, date: 1 }; 2 const options = { unique: true }; 3 4 db.appV3.createIndex(keys, options);
Inserindo os 500 milhões de documentos de evento para o cenário inicial na collection
appV3
com o esquema e a funçãoBulk Upsert
apresentados acima, além de apresentar os valores das versões anteriores, temos o seguinte collection stats
:coleção | Documentos | tamanho de dados | Tamanho do documento | Tamanho do armazenamento | Índices | Tamanho do Índice |
---|---|---|---|---|---|---|
appV1 | 359,639,622 | 39.58GB | 119B | 8.78GB | 2 | 20.06GB |
appV2 | 359,614,536 | 41.92GB | 126B | 10.46GB | 2 | 16.66GB |
appV3 | 359,633,376 | 28.7GB | 86B | 8.96GB | 2 | 16.37GB |
Calculando o
event stats
para appV3
e também apresentam os valores das versões anteriores, temos o seguinte:coleção | Tamanho dos Dados/ventos | Tamanho/ventos do Índice | Tamanho total/ventos |
---|---|---|---|
appV1 | 85B | 43.1B | 128.1B |
appV2 | 90B | 35.8B | 125.8B |
appV3 | 61.6B | 35.2B | 96.8B |
Analisando as tabelas acima, podemos ver que de
appV2
para appV3
, praticamente não houve alteração no tamanho do índice e uma diminuição de 32% no tamanho dos dados. Nosso objetivo de reduzir o tamanho do documento foi atingido.Analisando
event stats
, o tamanho total por valor de evento diminuído em 23%, de 125.8B para 96.8B. Com essa redução, provavelmente observaremos melhorias consideráveis.Executando o teste de carga para
appV3
e plotando-o juntamente com os resultados para appV2
, temos os seguintes resultados para Get Reports
e Bulk Upsert
:Os gráficos acima mostram explicitamente que
appV3
é mais eficiente do que appV2
, e estamos começando a nos aproximar de algumas taxas desejadas. appV3
conseguiu fornecer as taxas desejadas para os primeiros 100 minutos de operaçõesBulk Upsert
: 250 eventos por segundo de 0 a 50 minutos e 500 eventos por segundo de 50 minutos a 100 minutos. Por outro lado, Get Report
operações ainda não conseguem atingir a taxa mais baixa desejada de 20 relatórios por segundo, mas obviamente tiveram melhor desempenho do que appV2
, sendo capazes de manter a taxa de 16 relatórios por segundo por metade do teste.Toda a melhoria de desempenho pode ser atribuída à redução do tamanho do documento , pois foi a única alteração entre
appV2
e appV3
.Observando
collection stats
de appV3
e refletindo sobre como o MongoDB executa nossas queries e quais índices estão sendo usados, podemos ver que o campo_id
e seu índice não estão sendo usados em nosso aplicação. O campo em si não é grande coisa do ponto de vista de desempenho, mas seu índice único obrigatório é. Toda vez que um novo documento é inserido na coleção, a estrutura do índice no campo _id
campo ser atualizada.Retornando à ideia de
appV1
de tentar aproveitar o campo_id
obrigatório e seu índice, existe uma maneira de podermos usá-lo em nosso aplicação?Vamos dar uma olhada em nossos critérios de filtragem nas funções
Get Report
e Bulk Upsert
:1 const bulkUpsertFilter = { 2 key: event.key, 3 date: event.date, 4 }; 5 6 const getReportsFilter = { 7 key: request.key, 8 date: { 9 $gte: new Date('2021-06-15'), 10 $lt: new Date('2022-06-15'), 11 }, 12 };
Em ambos os critérios de filtragem, o campo
key
é comparado usando a igualdade. O campodate
é comparado usando igualdade no Bulk Upsert
e intervalo no Get Reports
. E se combinarmos esses dois valores de campo em apenas um, concatenando-os, e armazená-lo em _id
?Para nos orientar sobre como devemos ordenar os campos no valor concatenado resultante e obter o melhor desempenho do índice nele, vamos seguir a regra de Igualdade, Classificação e Faixa (ESR).
Como visto acima, o campo
key
é comparado pela igualdade em ambos os casos, e o campodate
é comparado pela igualdade apenas em um caso, então, vamos escolher o campokey
para a primeira parte do nosso valor concatenado e o campodate
para a segunda parte. Como não temos uma operação de classificação em nossas consultas, podemos ignorá-la. Em seguida, temos a comparação de faixa, que é usada no campo date
, então agora faz sentido mantê-la como a segunda parte do nosso valor concatenado. Com isso, a maneira mais ideal de concatenar os dois valores e obter o melhor desempenho de seu índice é key
+ date
.Um ponto de atenção é como vamos formatar o campo
date
campo concatenação de forma que o filtro de faixa funcione e não armazenamos mais dados do que precisamos. Uma possível implementação será apresentada e testada na próxima versão do aplicação , appV4
.Conforme apresentado nas questões e melhorias de
appV3
, uma maneira de aproveitar o campo e o índice _id
obrigatórios é armazenar nele o valor concatenado dekey
+ date
. Uma coisa que precisamos cobrir agora é o tipo de dados que o campo_id
campo e como vamos formatar o campodate
.Conforme visto em implementações anteriores, armazenar o campo
key
como dados binários/hexadecimais melhorou o desempenho. Então, vejamos se também podemos armazenar o campo concatenado resultante , key
+ date
, como binário/hexadecimal.Para armazenar o campo
date
em um tipo binário/hexadecimal, temos algumas opções. Um pode convertê-lo em um carimbo de data/hora 4bytes que mede os segundos desde a época do Unix, e o outro pode ser convertido para o formato YYYYMMDD
que armazena ano, mês e dia. Ambos os casos exigiriam os mesmos caracteres hexadecimais 32 bits/8 .Para o nosso caso, vamos usar a segunda opção e armazenar o valor
date
como YYYYMMDD
porque isso ajudará em futuras implementações e melhorias. Considerando um campokey
com o valor 0001
e um campodate
com o valor 2022-01-01
, teriamos o seguinte campo_id
:1 const _id = Buffer.from('000120220101', 'hex');
Para concatenar e converter os campos
key
e date
para o formato e tipo desejados, a seguinte função do Typescript foi criada:1 const buildId = (key: string, date: Date): Buffer => { 2 const day = date.toISOString().split('T')[0].replace(/-/g, ''); // YYYYMMDD 3 return Buffer.from(`${key}${day}`, 'hex'); 4 };
Cada documento ainda registraria os totais de status para um usuário em um dia, especificados pelo campo
_id
, da mesma forma que foi feito nas implementações anteriores.A implementação do aplicação apresentado acima teria o seguinte esquema de documento Typescript denominado
SchemaV4
:1 type SchemaV4 = { 2 _id: Buffer; 3 a?: number; 4 n?: number; 5 p?: number; 6 r?: number; 7 };
Com base na especificação apresentada, temos a seguinte operação em massa
updateOne
para cada event
gerado pelo aplicação:1 const operation = { 2 updateOne: { 3 filter: { _id: buildId(event.key, event.date) }, 4 update: { 5 $inc: { 6 a: event.approved, 7 n: event.noFunds, 8 p: event.pending, 9 r: event.rejected, 10 }, 11 }, 12 upsert: true, 13 }, 14 };
serão necessários cinco pipelines de agregação , um para cada intervalo de datas, para concluir a operação
Get Reports
. Cada intervalo de data terá o seguinte pipeline, com apenas a data utilizada na função buildId
sendo diferente:1 const pipeline = [ 2 { 3 $match: { 4 _id: { 5 $gte: buildId(request.key, Date.now() - oneYear), 6 $lt: buildId(request.key, Date.now()), 7 }, 8 }, 9 }, 10 { 11 $group: { 12 _id: null, 13 approved: { $sum: '$a' }, 14 noFunds: { $sum: '$n' }, 15 pending: { $sum: '$p' }, 16 rejected: { $sum: '$r' }, 17 }, 18 }, 19 ];
Como essa implementação usará o campo
_id
para suas operações, ela não precisará de um índice extra para dar suporte às operaçõesBulk Upsert
e Get Reports
.Inserindo os 500 milhões de documentos de evento para o cenário inicial na collection
appV4
com o esquema e a funçãoBulk Upsert
apresentados acima, além de apresentar os valores das versões anteriores, temos o seguinte collection stats
:coleção | Documentos | tamanho de dados | Tamanho do documento | Tamanho do armazenamento | Índices | Tamanho do Índice |
---|---|---|---|---|---|---|
appV1 | 359,639,622 | 39.58GB | 119B | 8.78GB | 2 | 20.06GB |
appV2 | 359,614,536 | 41.92GB | 126B | 10.46GB | 2 | 16.66GB |
appV3 | 359,633,376 | 28.7GB | 86B | 8.96GB | 2 | 16.37GB |
appV4 | 359,615,279 | 19.66GB | 59B | 6.69GB | 1 | 9.5GB |
Calculando o
event stats
para appV4
e também apresentam os valores das versões anteriores, temos o seguinte:coleção | Tamanho dos Dados/ventos | Tamanho/ventos do Índice | Tamanho total/ventos |
---|---|---|---|
appV1 | 85B | 43.1B | 128.1B |
appV2 | 90B | 35.8B | 125.8B |
appV3 | 61.6B | 35.2B | 96.8B |
appV4 | 42.2B | 20.4B | 62.6B |
Analisando as tabelas acima, podemos ver que de
appV3
para appV4
, reduzimos o tamanho dos dados em 32% e o tamanho do índice em 42% — grandes melhorias. Também temos um índice a menos para manter agora.Analisando
event stats
, o tamanho total por valor de evento aumentou 35%, de 96.8B para 62.6B. Com essa redução, provavelmente notamos algumas melhorias significativas no desempenho.Executando o teste de carga para
appV4
e plotando-o juntamente com os resultados para appV3
, temos os seguintes resultados para Get Reports
e Bulk Upsert
:Os gráficos acima mostram que
appV4
é apenas um pouco melhor que appV3
. Para as operaçõesBulk Upsert
, ambos podem fornecer as taxas desejadas nos primeiros 100 minutos, mas nenhum dos dois pode fornecer as taxas desejadas nos últimos 100 minutos. No entanto, appV4
tem taxas melhores do que appV3
. Para as operaçõesGet Reports
, ainda não alcançamos a taxa mais baixa desejada, mas appV4
tem taxas melhores, em média, do que appV3
.chega de focar na redução do tamanho dos documento para melhorar o desempenho – vamos dar uma olhada no comportamento do aplicação .
Ao gerar os
oneYear
totais, a funçãoGet Reports
precisará recuperar algo próximo a 60 documentos em média e, no pior cenário, 365 documentos. Para acessar cada um desses documentos, uma entrada de índice terá que ser visitada e uma operação de leitura de disco deverá ser executada. Como podemos aumentar a densidade de dados dos documentos em nosso aplicação e reduzir as entradas de índice e as operações de leitura necessárias para realizar a operação desejada?Uma maneira de fazer isso é usando o Padrão de Bucket. De acordo com a documentação do MongoDB, "O padrão de bucket separa longas séries de dados em objetos distintos. A separação de grandes séries de dados em grupos menores pode melhorar os padrões de acesso a queries e simplificar a lógica do aplicação ."
Observando nosso aplicação da perspectiva do padrão de bucket, até agora, agrupamos nossos dados por usuário diário, cada documento contendo os totais de status para um usuário em um dia. Podemos aumentar o intervalo de agrupamento ou nosso esquema e armazenar eventos ou totais de status de uma semana, mês ou até mesmo trimestre em um documento.
Esse é o fim da primeira parte da série. Abordamos como os índices funcionam em campos de documentos do tipo e vimos algumas pequenas alterações que podemos fazer em nosso aplicação para reduzir suas necessidades de armazenamento e índice e, como consequência, melhorar seu desempenho.
Aqui está uma rápida revisão das melhorias feitas entre as versões do aplicação :
appV1
paraappV2
: moveu os camposkey
edate
para fora de um documento incorporado no campo_id
e deixou-o ter seu valor padrão de ObjectId.appV2
paraappV3
: Reduziu o tamanho do documento abreviando os nomes dos campos de status e alterando o tipo de dados do campokey
de string para binário/hexadecimal.appV3
aappV4
: removeu a necessidade de um índice extra concatenando os valores dekey
edate
e armazenando-os no campo_id
.
Até agora, nenhum dos nossos aplicativos chegou perto das taxas desejadas, mas não vamos desistir. Conforme apresentado nos problemas e melhorias de
appV4
, ainda podemos melhorar nosso aplicação usando o Padrão de Bucket. O Padrão de Bucket com o Padrão Computado serão os principais pontos de melhoria para a próxima versão do aplicação , appV5
, e suas revisões.Para ter mais dúvidas, você Go acessar o Fórum da MongoDB Community, ou, se quiser construir seu aplicação usando o MongoDB, o MongoDB Developer Center tem muitos exemplos em muitas linguagens de programação diferentes.
Vamos dar uma olhada em como o MongoDB indexa um campo com um valor do tipo documento e ver por que precisamos de um índice extra para a implementação do
appV1
.Primeiro, vamos verificar se o índice no campo
_id
campo será usado para nossas queries executando uma operaçãofind
com os mesmos critérios de filtragem usados nas funçõesBulk Upsert
e Get Reports
e aplicando a funcionalidade deexplicaçãoa ele.1 // A sample document 2 const doc = { 3 _id: { key: '0001', date: new Date('2020-01-01') }, 4 approved: 2, 5 rejected: 1, 6 }; 7 8 // Making sure we have an empty collection 9 db.appV1.drop(); 10 11 // Inserting the document in the `appV1` collection 12 db.appV1.insertOne(doc); 13 14 // Finding a document using `Bulk Upsert` filtering criteria 15 const bulkUpsertFilter = { 16 _id: { key: '0001', date: new Date('2020-01-01') }, 17 }; 18 db.appV1.find(bulkUpsertFilter).explain('executionStats'); 19 /*{ 20 ... 21 executionStats: { 22 nReturned: 1, 23 totalKeysExamined: 1, 24 totalDocsExamined: 1, 25 ... 26 executionStages: { 27 stage: 'EXPRESS_IXSCAN', 28 ... 29 } 30 ... 31 }, 32 ... 33 }*/ 34 35 // Finding a document using `Get Reports` filtering criteria 36 const getReportsFilter = { 37 '_id.key': '0001', 38 '_id.date': { $gte: new Date('2019-01-01'), $lte: new Date('2021-01-01') }, 39 }; 40 db.appV1.find(getReportsFilter).explain('executionStats'); 41 /*{ 42 ... 43 executionStats: { 44 nReturned: 1, 45 totalKeysExamined: 0, 46 totalDocsExamined: 1, 47 ... 48 executionStages: { 49 stage: 'COLLSCAN', 50 ... 51 } 52 ... 53 }, 54 ... 55 }*/
Conforme mostrado pela saída das queries explicáveis, temos uma verificação de collection (
COLLSCAN
) para os critérios de filtragemGet Reports
, o que indica que um índice não foi usado para executar a query.A maioria dos tipos de dados suportados no MongoDB será indexada diretamente sem qualquer tratamento ou conversão especial. Os casos especiais são campos do tipo array ou documentos. O caso da array não é nosso foco atual, mas pode ser visto em Criar um índice em um campo de array. A caixa do documento ou documento documento incorporado pode ser vista em Criar um índice em um documento incorporado. Usando o conhecimento do caso do documento em nossa implementação, poderíamos dizer que o valor do campo
_id
na estrutura do índice seria uma versão em string do documento incorporado.1 const documentValue = { key: '0001', date: '2010-01-01T00:00:00.000Z' }; 2 const indexValue = "{key:0001,date:2010-01-01T00:00:00.000Z}";
Como o valor do índice é um blob de dados, o MongoDB não é capaz de acessar valores internos/incorporados, pois eles não existem nessa representação e, como consequência, o MongoDB não pode usar o valor do índice para filtrar por
_id.key
ou _id.date
.Principais comentários nos fóruns
Ainda não há comentários sobre este artigo.