Construindo um Armazenador de Chave-Valor em Elixir
O código neste artigo é fortemente inspirado em conceitos incrivelmente explicados no livro Designing Data-Intensive Applications por Martin Kleppmann.
Aviso: todo o código que você encontrar nesse artigo e no repositório do GitHub, foi escrito por pura diversão e criado como um experimento.
Introdução
No último ano fiquei interessado em logs (registros) e como algo tão simples pode ser a fundação sólida de banco de dados como Riak, Cassandra e sistemas de streaming como Kafka.
Nesta série de artigos vamos ver os diferentes conceitos por trás de armazenadores chave-valor (Logs, Segmentos, Compactação, Memtable e SSTable) implementando um motor simples em Elixir, a qual é uma grande linguagem para construir arquiteturas altamente concorrentes e tolerantes a falha.
Nessa parte vamos ver:
- O que é um log?
- Como fazer um KV (key-value ou chave-valor) persistente usando log e um índice. Com um exemplo que vamos usar ao longo da série, preços e transações do mercado de criptomoedas, iremos ver como armazenar os valores em um log usando um índice.
- LogKV em Elixir. Uma implementação inicial super simples em Elixir de um Escritor, um Índice e um Leitor.
O que é um log?
Vamos pensar no tipo de arquivo de log mais comum, aquele que usamos todos os dias para debugar eventos e mensagens de erro em nossas aplicações. Este arquivo simples consagra uma propriedade interessante, ele é um arquivo append-only (que aceita adições de conteúdo apenas no fim do arquivo). Isso significa que apenas escritas sequenciais são permitidas e tudo que escrevemos no log é imutável.
Por que escrita sequencial pode ser importante pra nós? Bom... velocidade!
Acesso Aleatório vs Sequencial - As Patologias do Big Data
Pudemos ver a grande diferença entre acesso aleatório e sequencial em ambos discos magnéticos clássicos, SSD e até mesmo a memória. Então, a ideia é potencializar a velocidade do acesso sequencial usando um arquivo apenas de adição para salvar os dados do nosso armazenador chave-valor.
Usando um Log para implementar um simples armazenador CV (chave-valor)
Vamos começar com um simples exemplo. Vamos considerar uma aplicação em tempo real onde nós temos que armazenar o último preço em dólares do Bitcoin (BTC), Ethereum (ETH) e Litecoin (LTC).
| Chave | Valor |
| ----- | ------- |
| BTC | 4478.12 |
| ETH | 133.62 |
| LTC | 33.19 |
Se nós apenas precisássemos manter esse retrato em memória, com Elixir poderíamos usar um Map
. Mas persistência é outra história. Existem várias formas diferentes e tecnologias que poderíamos usar para armazenar esse mapa e fazer isso persistente.
Se esse retrato fosse atualizado apenas algumas vezes por dia, com apenas algumas moedas, então serializar o mapa dentro de um arquivo seria bom e fácil de fazer, mas isso obviamente não é o nosso caso! Nossa aplicação imaginária de criptomoedas precisa acompanhar qualquer movimento do mercado com centenas de atualizações por segundo para centenas de moedas.
Mas como nós podemos usar um arquivo append-only (apenas anexando informações a ele), onde os dados escritos são imutáveis por natureza, para armazenar dados mutáveis de um armazenador chave-valor, potencializar o acesso sequencial e manter nosso mapa persistente?
A ideia é muito simples:
- anexar ao nosso log cada simples atualização de preço (valor) para qualquer moeda (chave)
- usar nosso
Map
como um índice, mantendo o controle da posição e tamanho dos valores dentro do arquivo de log.
Conceito de persistência chave-valor usando um log
- 17:14:59 – Negociações LTC a 32.85$. Anexamos a string "32.85" ao log e atualizamos a chave
"LTC"
do nosso indice (implemented with a Map) with value’s offset (0 since it’s the first value in the file) and it’s size (5 bytes, since it’s a string). - 17:15:00 – Negociações ETH a 130.98$. Anexamos a string "130.98" ao log e atualizamos a chave
"ETH"
do nosso índice com o deslocamento 5 e tamanho de 6 bytes. - 17:15:01 – Negociações BTC a 4411.99$. Anexamos a string "4411.99" ao log e atualizamos a chave
"BTC"
do nosso índice com deslocamento 11 e tamanho de 7 bytes. - 17:14:59 - Comercializado LTC a $32.85. Nós adicionamos a string
"32.85"
ao log e atualizamos a chave"LTC"
do nosso índice (implementado com um Map) com o valor do offset (deslocamento) (0 já que é o primeiro valor no arquivo) e seu tamanho (5 bytes, pois é uma string). - 17:15:00 - Comercializado ETC a $130.98. Nós adicionamos a string
130.98
ao log e atualizamos a chave "ETH" ao nosso índice com deslocamento 5 e tamanho 6 bytes. - 17:15:01 - Comercializado BTC a $4411.99. Nós adicionamos a string
4411.99
ao log e atualizamos a chave "BTC" do nosso índice com deslocamento 11 e tamanho 7 bytes.
O que acontece se nós recebermos um novo preço para ETH? Como podemos sobrescrever o valor no log uma vez que o valor que escrevemos é imutável e só podemos adicionar?
- 17:15:09 - Negociações ETH a $131.00.
Já que para aumentar a velocidade da escrita sequencial nós só podemos adicionar, nós então apenas adicionamos o novo valor atualizando o índice com o novo deslocamento e tamanho.
A leitura é eficiente também. Para recuperar os valores do log, nós só precisamos usar offset (deslocamento) e size (tamanho) no index (índice) e com uma "olhada" no disco para carregar nossos valores para memória.