Amazon DynamoDB: Lições aprendidas usando o design de tabela única e GraphQL em produção
Tags: ptbr, aws, dynamodb, serverless
Published: 2020-01-23 02:18:19

O DynamoDB é um animal poderoso, porém complicado. Embora ele permita [escalabilidade insana](https://aws.amazon.com/blogs/aws/amazon-prime-day-2019-powered-by-aws/), ele tem algumas limitações estritas que você precisa estar ciente. Também é impossível usar um banco de dados NoSQL com uma mentalidade RDBMS. A mudança de pensamento é necessária, e esse processo é trabalhoso. Depois de quase um ano desenvolvendo uma API GraphQL totalmente serverless e rodando em produção com milhares de usuários, apresento meus aprendizados nesse esforço.

## Planejar seus padrões de acesso e consultas é essencial

Não posso enfatizar o suficiente o quão importante é. Como você não pode alterar índices primários ou LSIs (Local Secondary Indexes/Índice Local Secundário), a única opção é criar uma nova tabela, migrar os dados com algumas transformações e excluir a tabela antiga. Esse processo é torturante, especialmente depois que você está em produção e deseja evitá-lo a todo custo. Se você não sabe fazer isso, recomendo dois artigos: um de [Jeremy Daly](https://www.jeremydaly.com/how-to-switch-from-rdbms-to-dynamodb-in-20-easy-steps/) e o segundo de [Forrest Brazeal](https://www.trek10.com/blog/dynamodb-single-table-relational-modeling/).

Esta etapa não é apenas uma tarefa puramente de engenharia, mas combina com as competências de negócios e produtos. É por isso que você deve participar da sessão de planejamento o mais rápido possível para entender melhor os casos de uso de negócios e propor soluções amigáveis ao desenvolvedor. Requisitos precisos resultam em um desenvolvedor feliz que são iguais a um produto entregue e o prazo cumprido.

## O atributo "model" é vantajoso para tipos de entidade distintos

No nosso caso de uso, ter um atributo _"model"_ como chave primária em um dos GSIs (Global Secondary Index/Índice Global Secundário), que sempre indicava o tipo de linha, foi muito útil. Com uma consulta simples em que o modelo era um **hashKey**, poderíamos obter todos os membros, canais, funções, audiências etc.

## Invista algum tempo em abstrações apropriadas e crie seu próprio ORM opinativo

Ao começar a escrever algum código na camada de persistência, você notará imediatamente que muitas coisas estão se repetindo e a maioria das chamadas de API exige parâmetros complexos, como **UpdateExpression** ou **ExpressionAttributeValues**. Estes devem ser abstraídos em assinaturas de função mais diretas.

Aqui estão algumas idéias:

- Cada chamada exigirá **TableName** (exatamente o mesmo com design de tabela única) e **IndexName**. Escrever isso em cada chamada é duplicação desnecessária.
- Você sempre deve usar **Limit** (mais sobre isso posteriormente) e provavelmente também deve restringir esse número a um limite superior como **1000**. Algo como `Math.max(params.limit, 1000)` também pode ser útil.
- Construir **FilterExpressions** é complicado. Invista algum tempo escrevendo abstração para isso, como o [padrão de construtor](https://www.journaldev.com/1425/builder-design-pattern-in-java).
- Seus registros no banco de dados provavelmente incluem dados de **pk**, **sk**, enquanto seus modelos provavelmente possuem atributos como **id**, **productId** etc. Algumas funções de mapeamento nesse cenário foram benéficas para nós.

Se você não tiver certeza do que precisa, considere usar o [DynamoDB Toolbox](https://github.com/jeremydaly/dynamodb-toolbox), uma excelente biblioteca não-ORM do Jeremy Daly. Infelizmente, isso não estava disponível um ano atrás, quando meu projeto começou, mas se eu fosse começar um projeto hoje, eu a usaria sem pensar!

## Use "keepAlive HTTP" como truque de otimização de Matt Lavin

Por padrão, sempre que você faz uma operação com o DynamoDB, um novo handshake de três vias é estabelecido. Isso leva tempo desnecessário. Você pode corrigir isso substituindo **httpOptions.agent** por um personalizado nas opções do AWS SDK da seguinte maneira:

```js
const agent = new https.Agent({
 keepAlive: true,
 maxSockets: 50,
 rejectUnauthorized: true
});
AWS.config.update({ httpOptions: { agent }});
```

Você pode ver a diferença [neste post que traduzi do Yan Cui](https://medium.com/@oieduardorabelo/aws-lambda-otimiza%C3%A7%C3%A3o-com-http-keep-alive-27353417c112).

## Remova "dynamodb:Scan" da sua lista de IAM Roles/Policies

Esta dica eu vi com [Jared Short](https://twitter.com/ShortJared/status/1184028703862472704). Se você deseja impedir que seus desenvolvedores escrevam _scans_ (o que é uma prática ruim), apenas negue a ação **dynamodb:Scan** no IAM em que os desenvolvedores e funções do aplicativo usam. Técnica brutal, porém super simples e eficaz, que utiliza o IAM não apenas para segurança, mas também para aplicar as melhores práticas.

## Use o X-Ray e Contributor Insights para encontrar gargalos e padrões de acesso problemáticos

Mesmo que não tenhamos usado o [Contributor Insights](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/contributorinsights_HowItWorks.html) no projeto mencionado, porque ele foi anunciado como parte do **"pre re:Invent hype release train"**, eu dei uma chance para ele no meu projeto paralelo. Essa ferramenta de diagnóstico é definitivamente útil para ajustar o desempenho e encontrar gargalos, localizando as chaves de partição acessadas com mais frequência.

No entanto, o que usamos ativamente em nosso aplicativo em produção é o AWS X-Ray. Com os [subsegmentos](https://docs.aws.amazon.com/xray/latest/devguide/xray-sdk-dotnet-subsegments.html), conseguimos descobrir quais subconsultas de nossas consultas complexas do GraphQL estavam causando lentidão nas respostas, nos possibilitando reescrever essas partes do aplicativo.

O que também foi útil foi o [Mapa de Serviço](https://docs.aws.amazon.com/xray/latest/devguide/xray-console.html#xray-console-servicemap). Apenas uma olhada mostrou que nosso problema está na função do autorizador personalizado, que buscava muitos dados e causava lentidão em toda a API.

## Esteja ciente da falta de integridade referencial e suas consequências

Como o [DynamoDB é eventualmente consistente](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ReadConsistency.html) e não garante a integridade dos dados (o que eu quero dizer com isso, por exemplo, entidades fracas em relações Muitos-para-Muitos, não é garantido que o modelo referenciado exista), você precisa prestar atenção especial no código do aplicativo ao verificar **null** ou **undefined**. Isso pode levar a inúmeros **ReferenceErrors**.

No nosso caso, quando estávamos usando o GraphQL, isso também levou a casos que violavam o contrato especificado no esquema. Alguns atributos como `member: Member!` (**!** significa que a API garante que o membro modelo está presente) começou a retornar erros.

**Lição aprendida: assuma sempre que alguns dados podem estar ausentes.**

## Considere remover registros órfãos e entidades fracas de forma assíncrona

Imagine um cenário em que você tenha um sistema com um grupo de entidades que possa ter muitos membros. Os membros são vinculados ao grupo usando registros separados em que `pk: Group-ID` e `sk: Member-ID`. Nesse cenário no mundo SQL, recursos como **FOREIGN KEYS** e **ON DELETE CASCADE** garantiriam que, uma vez excluído o "Grupo", todos os registros de "associação" também fossem excluídos.

Infelizmente, esse não é o caso no DynamoDB.

Você pode dizer: "...vamos fazer isso de forma síncrona!". Isso não é uma boa idéia. Isso provavelmente levaria muito tempo dentro da Lambda e [deveria ser distribuído](https://servicefull.cloud/blog/dynamodb-mass-update/).

Mas você pode perguntar: "...por que remover? O DynamoDB não é infinitamente escalável? Quais são os contras de remover?"

- Economiza espaço, o que significa menos dinheiro gasto. Lembre-se de que custa 0,25 USD por GB-mês
- As consultas consomem menos capacidade de leitura
- Menos lixo na tabela e menos problemas de integridade referencial

## Use VPC Endpoints se possível

[VPC Endpoints](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-endpoints.html) são pequenas coisas poderosas que permitem que recursos dentro de sua VPC interajam com outros serviços da AWS sem sair da rede da Amazon. Esse truque não apenas aumenta a segurança, mas também torna as interações com o DynamoDB mais rápidas. Ah, e suas instâncias não precisam de IP público. Lembre-se de que, assim como o próprio DynamoDB, isso também possui algumas limitações, que incluem:

- Você não pode acessar o DynamoDB Streams
- Você não pode criar um _endpoint_ entre uma VPC e um serviço em uma região diferente
- Há um limite de VPC Endpoints por VPC

Você pode ler [mais sobre VPC Gateway Endpoints para DynamoDB aqui](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-endpoints-ddb.html).

## Evite armazenar itens grandes

No começo, cometemos esse erro - começamos a armazenar blobs de avatar dentro do atributo avatar. Não foi uma boa ideia. Como uma única consulta retorna apenas um conjunto de resultados que se encaixa dentro do limite de tamanho de 1 MB, algumas vezes vimos algumas chamadas retornando apenas três itens com eficácia, tornando tudo mais lento do que deveria ser. Felizmente, resolver o problema foi bem fácil - fizemos o upload de todos os blobs para o S3 e salvamos o link que o referenciava na tabela.

## Sempre use "Limit" em consultas e paginação

No nosso caso, o DynamoDB atendeu ao propósito da camada de persistência da API da plataforma da comunidade. Em casos de uso como esse, de acordo com nossas análises, é mais provável que os usuários se interessem nos 10 últimos anúncios ou mensagens em um canal. Se eles gostariam de ver mais, eles podem solicitá-las e o aplicativo usaria paginação. Essa abordagem de retornar uma quantidade mínima de dados inicialmente nos deu uma série de benefícios:

- O banco de dados retornou uma quantidade menor de dados; portanto, nossa API também retornou uma quantidade menor de dados, tornando tudo mais rápido e responsivo
- Como havia menos dados retornados, nossos Lambdas também precisavam de menos tempo para processá-los, portanto pagamos ainda menos pela parte computacional em serverless
- Além disso, menos capacidade de leitura foi consumida - menos dinheiro gasto 💸

## Favoreça "FilterExpressions" ao invés de funções "Array.filter"

Embora seja tentador usar os recursos nativos do **Array.filter** porque é mais elegante, prefira usar o **FilterExpressions**, apesar de ser um pouco complicado. Eu sei que requer a criação de uma seqüência de caracteres que mapeia para **ExpressionAttributeValues** e **ExpressionAttributeNames**, mas vale a pena no desempenho. Como a filtragem é feita no nível do banco de dados, menos dados são consumidos e menos dados são retornados. Isso converte diretamente em menos dinheiro gasto em transferência de dados, computação e memória necessários para processá-lo.

## Esteja ciente dos limites do DynamoDB

Passe algum tempo [lendo os documentos sobre limitações](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html), sério, isso nos salvaria de muitas frustrações. Algumas coisas que foram surpreendentes para nós incluem:

- **BatchWriteItem** suporta até 25 itens. Isso significa que, se você quiser escrever 100 itens, precisará dividir seu array em 4 partes e executar o **BatchWriteItem** quatro vezes, aguardando com **Promise.all**.
- O valor de qualquer atributo não pode ser uma sequência vazia
- O tamanho máximo do item é 400 KB

## Sempre use "ExpressionAttributeNames"

Existem [várias palavras-chave reservadas no DynamoDB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ReservedWords.html) que, ao invés de adivinhar se os nomes dos seus atributos são reservados ou não, pense que todo nome de atributo é reservado. Isso deve ser ocultado dos desenvolvedores pela sua abstração do tipo ORM, que mencionei anteriormente.

## TTL não garante que seus itens serão removidos imediatamente

Eu me deparei com essas informações enquanto [navegava no Twitter](https://twitter.com/prestomation/status/1146473166228692998) um dia. O atributo time-to-live (TTL) não garante que o item seja removido imediatamente após o tempo selecionado. Ele será removido com um atraso, às vezes levando até 48h. Isso pode levar você a retornar resultados incorretos se depender apenas desse mecanismo.

Para evitar isso, como [afirma a nota oficial](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/howitworks-ttl.html): "use uma expressão de filtro que retorne apenas itens em que o valor de expiração do tempo de vida útil é maior que o tempo atual".

## Use SQS para armazenar em buffer grandes operações de "Write", "Update" e "Delete"

Um truque comum para impedir que sua tabela consuma muito WCU (Write Capacity Units/Unidades de Capacidade de Gravação) ou tenha suas solicitações limitadas devido à entrada de muitos dados é executar operações de buffer-write e executá-las de forma assíncrona, se possível. É a recomendação oficial da AWS, [eu também escrevi um post sobre isso](https://servicefull.cloud/blog/dynamodb-mass-update/).

Se você precisar de conhecimentos sobre DynamoDB ou Serverless, não hesite em entrar em contato comigo.

---

# Créditos

- [Lessons learned using Single-table design with DynamoDB and GraphQL in production](https://servicefull.cloud/blog/dynamodb-single-table-design-lessons/), escrito originalmente por [Rafal Wilinski](https://twitter.com/rafalwilinski)