Pular para o conteúdo

Banco de Dados em Grafo com ArangoDB e Golang

Postado em 9 minutos de leitura

O aumento do volume de dados de uma aplicação gera um crescimento exponencial nos relacionamentos (ou conexões) entre os dados. Com os bancos de dados tradicionais, é necessário dividir as queries em outras menores quando o número e a profundidade dos relacionamentos aumentam. Banco de dados em grafo não possuem esse problema. As queries permanecem constantes e por isso são ideais para aplicações com muitos relacionamentos ou Big Data.

Um grafo consiste em dois tipos de objetos: Vertices (ou Nodes) e Edges (ou Relationships). A maioria dos dados podem ser modelados como um grafo. Uma característica interessante desse modelo é permitir fazer a travessia reversa no relacionamento. Banco de dados em grafo utilizam um algoritmo otimizado para melhor performance e velocidade na busca de dados relacionados.

Problema

Tenho uma aplicação web voltado ao mercado de elearning com características similares ao Spotify. Ela contém áudios, professores, playlists e alunos. Os professores gravam as aulas em MP3, criam playlists contendo os áudios, associam elas à determinadas disciplinas e compartilham essas playlists entre os alunos. Os alunos também podem criar playlists próprias e compartilhar entre si.

As funcionalidades iniciais eram o cadastro (CRUD) de playlists, áudios, disciplinas, professores e alunos e filtro de playlists por disciplina e professor. PostgreSQL foi a escolha inicial como banco devido à sua estabilidade, performance e suporte a dados tabulares (SQL) e o documentos (NoSQL, utilizando campos JSONB). Depois de um tempo, surgiram novas demandas como:

  • Os alunos podem seguir outros alunos e ver atualizações na timeline;
  • Deve ser exibido a popularidade do professor de acordo com a quantidade de playlists que tem algum áudio dele;
  • O aluno deve visualizar quais as playlists mais populares entre os alunos da mesma cidade;
  • É necessário saber qual o professor mais popular em cada cidade.

O modelo de dados possui vários relacionamentos many-to-many:

  • 1 professor pode ter vários áudios
  • 1 áudio pode ser de vários professores (trabalho em equipe)
  • 1 playlist pode ter vários áudios
  • 1 áudio pode estar em várias playlists
  • 1 playlist pode ser utilizada por vários alunos
  • 1 playlist pode ter várias disciplinas
  • etc.

Se a maioria dos relacionamentos fossem do tipo one-to-many, então o modelo tabular ou documento seriam mais apropriados. Porém, com muitos relacionamentos many-to-many, é necessário criar queries complexas com muitos joins ou várias queries acessando o banco de dados múltiplas vezes, gerando um crescimento no código da aplicação. O mais natural foi utilizar uma estrutura de grafos para reduzir a complexidade sem afetar a performance.

Tables & Graph

Graph Databases

Atualmente existem várias opções de banco de dados baseados em grafos. Fiz uma pesquisa inicial lendo documentações, estudando códigos fonte dos drivers, ouvindo opiniões de usuários e analisando as empresas por trás de cada tecnologia.

A primeira idéia foi criar uma PoC com o Amazon Neptune. Era a opção que fazia mais sentido para nós, cuja infra inteira está na AWS. Abandonamos essa idéia pois o Neptune não é open source, roda apenas na infra da AWS e seria um problema para os desenvolvedores que não poderiam instalar em localhost.

Depois brinquei com o Neo4J. Gostei da linguagem Cypher utilizada para queries, porém o driver oficial do Neo4J para Go ainda não é nativo e tem pouca documentação. Isso foi impeditivo para continuar o experimento com o Neo4J.

ArangoDB, Dgraph e OrientDB foram as escolhas para seguir adiante. A decisão foi começar primeiro com o ArangoDB por possuir uma documentação mais completa.

ArangoDB

Web interface

O ArangoDB é um sistema de banco de dados gratuito e open source que suporta três modelos de dados (chave/valor, documentos, grafos) e utiliza uma linguagem de consulta chamada AQL (ArangoDB Query Language). Ele foi projetado especificamente para permitir que dados de chave/valor, documento e grafo sejam armazenados juntos e consultados com AQL. Suas características são:

  • Driver estável para Golang
  • Boa documentação voltada para desenvolvedores
  • Interface web simples e intuitiva
  • Framework Javascript (ArangoDB Foxx) para escrever microserviços que rodam diretamente dentro do ArangoDB
  • Suporte à dados do tipo Geo-espacial para armazenar objetos geográficos
  • Suporte a armazenamento de chave/valor e documentos
  • Full-text search para buscar sentenças por similaridade
  • Cluster distribuído com suporte a Kubernetes
  • Cache dos resultados das queries (não funciona para um cluster, apenas para single node)
  • Integração com os serviços Tableau, PowerBI, Qlik e Grafana
  • Tecnologia ArangoSearch que permite busca por termo em diversos documentos de forma otimizada

AQL é linguagem declarativa usada para modificar e consultar dados armazenados no ArangoDB. Ela parece uma mistura das linguagens SQL e Javascript. Exemplo:

FOR t IN teachers FILTER t.name == "Henrique" LIMIT 1
    FOR a IN OUTBOUND t records
        FOR p IN INBOUND a plays
            FOR student IN INBOUND p listen
                RETURN student.name

A query acima faz uma travessia por 4 vértices. Ela busca 1 professor com o nome “Henrique”, depois os áudios gravados por ele e em seguida as playlists que contém algum desses áudios e os alunos que escutam essas playlists.

ArangoSearch

ArangoSearch é uma extensão do IResearch, um framework para indexação e filtro de dados com foco em alta velocidade e baixa utilização de memória.
As buscas utilizando ArangoSearch são feitas em uma View, que é uma representação de um campo em várias collections. Abaixo um exemplo de como criar uma View pelo arangosh que indexa o campo name das collections playlists e subjects:

var view = db._createView("v_name", "arangosearch");
var link = { 
  fields : { name : { analyzers : [ "text_en" ] } } 
};
view.properties({ links: { "playlists": link, "subjects": link } });

A função SEARCH utiliza o ArangoSearch e funciona apenas em Views. Exemplo:

FOR item IN v_name SEARCH ANALYZER(item.name == 'math', 'text_en')
    LIMIT 3
RETURN { id: item._id, name: item.name }

E o resultado foi:

[
  {
    "id": "subjects/1",
    "name": "Math"
  },
  {
    "id": "playlists/18",
    "name": "Royal Math Physics"
  },
  {
    "id": "playlists/32",
    "name": "Unusual Algebra Math"
  }
]

Exemplos de queries AQL

  1. INSERT: Cadastra uma playlist
INSERT { name: "The best of Math" } IN playlists RETURN NEW
  1. UPDATE: Atualiza uma playlist
UPDATE { _key: "xpto4" } WITH { name: "The best of Math vol. 1" } IN playlists RETURN NEW

Ou

LET playlist = DOCUMENT("playlists/xpto4")
UPDATE playlist WITH { name: CONCAT(playlist.name, " vol. 1") } IN playlists RETURN NEW
  1. UPSERT: Cadastra uma playlist se não existir, ou atualiza o campo updated_at se já estiver cadastrada:
UPSERT { _key: "xpto4", name: "The Best of Math" }
    INSERT { _key: "xpto4", name: "The Best of Math", created_at: DATE_NOW(), updated_at: null }
    UPDATE { updated_at: DATE_NOW() }
IN playlists
RETURN NEW
  1. REPLACE: Substitui os campos antigos pelos novos:
REPLACE "xpto4" WITH { title: "The best of Math", created_at: DATE_NOW() } IN playlists RETURN NEW
  1. REMOVE: Remove um documento de acordo com a _key:
REMOVE "xpto4" IN playlists RETURN OLD
  1. DOCUMENT: Retorna um documento de acordo com a _key:
RETURN DOCUMENT("playlists/xpto2")
  1. FOR: Retorna todos os documentos da collection:
FOR playlist IN playlists RETURN playlist
  1. FILTER: Retorna todos os documentos de acordo com o filtro aplicado:
FOR playlist IN playlists FILTER CONTAINS(playlist.name, "Math") RETURN playlist

Performance

Gostei da simplicidade em trabalhar documentos e grafos utilizando AQL no ArangoDB. Mas será que funciona bem no nosso contexto?
O objetivo dessa prova de conceito não é comparar a performance do ArangoDB com outros bancos de dados. É apenas avaliar a performance do nosso microserviço em Go utilizando ArangoDB. Os resultados foram excelentes! A quantidade de códigos Go diminuiu bastante e foi possível implementar novas buscas e filtros com mais agilidade.

Prova de Conceito (PoC)

Foram utilizadas duas máquinas EC2 T2 Medium, com 4GB de RAM e storage gp2, uma para o servidor web e outra para o banco de dados. Instâncias EC2 I3 com storage io1 são mais indicadas para um ambiente de produção rodando ArangoDB. As instâncias foram criadas manualmente pelo Console AWS e sistema operacional escolhido foi Ubuntu 18.04.
O ArangoDB foi instalado e configurado seguindo os comandos abaixo:

# Instala ArangoDB
curl -OL https://download.arangodb.com/arangodb35/DEBIAN/Release.key
sudo apt-key add - < Release.key
echo 'deb https://download.arangodb.com/arangodb35/DEBIAN/ /' | sudo tee /etc/apt/sources.list.d/arangodb.list
sudo apt-get install apt-transport-https
sudo apt-get update
sudo apt-get install arangodb3=3.5.0-1

# Cria SWAP de 6GB
sudo dd if=/dev/zero of=/swapfile bs=1M count=6144
sudo fallocate --length 6G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

# Permite threads alocarem mais RAM
sudo sysctl -w 'vm.max_map_count=128000'
sudo sysctl -p

# Configura o ArangoDB para escutar em 0.0.0.0:8529
sed -i 's/127.0.0.1/0.0.0.0/g' /etc/arangodb3/arangod.conf

Se preferir rodar o ArangoDB via Docker:

docker run -d --name arangodb -e ARANGO_ROOT_PASSWORD=root -p 8529:8529 -v $PWD:/code --workdir=/code arangodb

A massa de dados de teste foi gerada por um script em Python. São nove arquivos JSON:

arquivoquantidade de objetos
audios_vertex_.json1.000.000
teachers_vertex_.json100
students_vertex_.json329.767
subjects_vertex_.json16
playlists_vertex_.json20.736
plays_edge_.json114.151
records_edge_.json1.500.129
tagged_edge_.json57.454
listen_edge_.json1.815.248

E foi criado o script import_data.sh para importar os dados. Os comandos abaixo foram executados no servidor rodando o ArangoDB:

curl -sL -o json.tar.bz2 https://not-important-files.s3.amazonaws.com/blog/json.tar.bz2
tar jxvf json.tar.bz2
cd json
bash import_data.sh

A aplicação web utilizada para esse exemplo possui três endpoints:

  • /students: Retorna alunos com suas playlists, os áudios delas e os professores
  • /teachers: Retorna professores com as playlists, audios e alunos que assinam cada playlist
  • /playlists/:subject: Retorna as playlists de acordo com o nome da disciplina

O ideal é realizar um teste de stress pensando no problema C10K, que é lidar com 10 mil conexões simultâneas. Mas no momento minha preocupação é conseguir lidar com 100 requests por segundo apenas. O teste de stress foi feito no endpoint /students:

go get -u github.com/tsenart/vegeta  # ou brew install vegeta
echo "GET http://ec2-18-228-117-96.sa-east-1.compute.amazonaws.com:5001/students?page=1&per_page=10" | vegeta attack -duration 10s -rate 100/s | tee results.bin | vegeta report

E o resultado foi 100% de resposta com sucesso:

Requests      [total, rate]            1500, 100.03
Duration      [total, attack, wait]    34.853427393s, 29.981709s, 4.871718393s
Latencies     [mean, 50, 95, 99, max]  2.945691168s, 2.973757129s, 4.986231875s, 5.13476572s, 5.256265375s
Bytes In      [total, mean]            186813000, 124542.00
Bytes Out     [total, mean]            0, 0.00
Success       [ratio]                  100.00%
Status Codes  [code:count]             200:1500
Error Set:

O código completo da aplicação está no GitHub. O trecho abaixo corresponde ao endpoint /students:

server.GET("/students", func(c echo.Context) error {
    skip, limit := getSkipAndLimit(c)
    query := `FOR student IN students LIMIT @skip, @limit
        LET playlists = (
            FOR p IN OUTBOUND student listen
                LET audios = (
                    FOR a IN OUTBOUND p plays
                        let teachers = (FOR teacher IN INBOUND a records RETURN { id: teacher._key, name: teacher.name })
                    RETURN { id: a._key, title: a.title, media: a.media, teachers: teachers }
                )
                LET subjects = (FOR s IN OUTBOUND p tagged RETURN { id: s._key, name: s.name })
            RETURN { id: p._key, name: p.name, audios: audios, subjects: subjects }
        )
    RETURN { id: student._key, name: student.name, city: student.city, playlists: playlists }`
    params := map[string]interface{}{
        "skip":  skip,
        "limit": limit,
    }
    cursor, err := db.Query(ctx, query, params)
    if err != nil {
        log.Println("Failed to perform query", query, err)
        return c.JSON(http.StatusInternalServerError, err)
    }
    defer cursor.Close()
    var students []Student
    var student Student
    for {
        _, err := cursor.ReadDocument(ctx, &student)
        if driver.IsNoMoreDocuments(err) {
            break
        }
        students = append(students, student)
    }
    stats := cursor.Statistics()
    total := stats.FullCount()
    res := map[string]interface{}{
        "data":  students,
        "total": total,
    }
    return c.JSON(http.StatusOK, res)
})

Conclusão

Gostei dos resultados da PoC inicial. AQL é uma linguagem fácil de aprender e bem flexível, ArangoDB possui uma ótima performance para muitos relacionamentos e o código da aplicação ficou bem menor do que utilizando Postgres.
Os próximos passos serão criar um cluster ArangoDB e melhorar a qualidade dos testes de stress da aplicação.