NoSQL Essencial
NoSQL Essencial
Pearson
Novatec
São Paulo | 2019
Authorized translation from the English language edition, entitled NOSQL DISTILLED: A BRIEF GUIDE TO THE
EMERGING WORLD OF POLYGLOT PERSISTENCE, 1st Edition, 0321826620 by SADALAGE, PRAMOD J.;
FOWLER, MARTIN, published by Pearson Education, Inc, publishing as Addison-Wesley Professional, Copyright ©
2013 Pearson Education, Inc.
All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or
mechanical, including photocopying, recording or by any information storage retrieval system, without permission from
Pearson Education, Inc. PORTUGUESE language edition published by NOVATEC EDITORA LTDA., Copyright ©
2013.
Tradução autorizada da edição em língua inglesa, intitulada NOSQL DISTILLED: A BRIEF GUIDE TO THE
EMERGING WORLD OF POLYGLOT PERSISTENCE, 1st Edition, 0321826620 por SADALAGE, PRAMOD J.;
FOWLER, MARTIN, publicada pela Pearson Education, Inc como Addison-Wesley Professional, Copyright © 2013
Pearson Education, Inc.
Todos os direitos reservados. Nenhuma parte deste livro deve ser reproduzida ou transmitida em qualquer formato ou
por qualquer meio, eletrônico ou mecânico, incluindo fotocópia, gravação ou qualquer sistema de recuperação de
informações, sem permissão da Pearson Education, Inc. Edição em língua PORTUGUESA publicada pela Novatec
Editora Ltda, Copyright © 2013.
© Novatec Editora Ltda. 2013.
Todos os direitos reservados e protegidos pela Lei 9.610 de 19/02/1998. É proibida a reprodução desta obra, mesmo
parcial, por qualquer processo, sem prévia autorização, por escrito, do autor e da Editora.
Editor: Rubens Prates
Tradução: Acauan Fernandes
Revisão técnica: BrodTec.com / Aurelio Jargas
Revisão gramatical: Cristiane Bernardi
Editoração eletrônica: Carolina Kuwabata
ISBN: 978-85-7522-XXX
Histórico de edições impressas:
Outubro/2017 Terceira reimpressão
Novembro/2015 Segunda reimpressão
Abril/2014 Primeira reimpressão
Junho/2013 Primeira edição
Novatec Editora Ltda.
Rua Luís Antônio dos Santos 110
02460-000 – São Paulo, SP – Brasil
Tel.: +55 11 2959-6529
E-mail: novatec@novatec.com.br
Site: www.novatec.com.br
Twitter: twitter.com/novateceditora
Facebook: facebook.com/novatec
LinkedIn: linkedin.com/in/novatec
Para meus professores Gajanan Chinchwadkar, Dattatraya Mhaskar
e Arvind Parchure. Vocês foram os que mais me inspiraram. Obrigado.
– Pramod
Para Cindy
– Martin
Sumário
Prefácio
Por que bancos de dados NoSQL são interessantes?
O que este livro contém
Quem deve ler este livro?
Quais são os bancos de dados?
Agradecimentos
Parte I ■ Compreender
Capítulo 1 ■ Por que NoSQL?
1.1 O valor dos bancos de dados relacionais
1.1.1 Chegando aos dados persistentes
1.1.2 Concorrência
1.1.3 Integração
1.1.4 Um modelo padrão (na sua maior Parte)
1.2 Incompatibilidade de impedância
1.3 Bancos de dados de integração e de aplicativos
1.4 Ataque dos clusters
1.5 Surgimento do NoSQL
1.6 Pontos chave
Capítulo 2 ■ Modelos de dados agregados
2.1 Agregados
2.1.1 Exemplo de relações e agregados
2.1.2 Consequências da orientação a agregados
2.2 Modelos de dados de chave-valor e de documentos
2.3 Armazenamentos de famílias de colunas
2.4 Resumindo os bancos de dados orientados a agregados
2.5 Leituras complementares
2.6 Pontos chave
Capítulo 3 ■ Mais detalhes sobre modelos de dados
3.1 Relacionamentos
3.2 Bancos de dados de grafos
3.3 Bancos de dados sem esquema
3.4 Visões materializadas (Materialized views)
3.5 Modelando para o acesso aos dados
3.6 Pontos chave
Capítulo 4 ■ Modelos de distribuição
4.1 Um único servidor
4.2 Fragmentação
4.3 Replicação mestre-escravo
4.4 Replicação ponto a ponto (p2p)
4.5 Combinando fragmentação e replicação
4.6 Pontos chave
Capítulo 5 ■ Consistência
5.1 Consistência de atualização
5.2 Consistência de leitura
5.3 Relaxando a consistência
5.3.1 Teorema CAP
5.4 Relaxando a durabilidade
5.5 Quóruns
5.6 Leituras complementares
5.7 Pontos chave
Capítulo 6 ■ Marcadores de versões
6.1 Transações comerciais e de sistema
6.2 Marcadores de versões em múltiplos nodos
6.3 Pontos chave
Capítulo 7 ■ Map-Reduce (Mapear-Reduzir)
7.1 Map-reduce básico
7.2 Particionando e combinando
7.3 Criando cálculos map-reduce
7.3.1 Um exemplo de map-reduce em duas etapas
7.3.2 Map-reduce incremental
7.4 Leituras complementares
7.5 Pontos chave
Parte II ■ Implementar
Capítulo 8 ■ Bancos de dados de chave-valor
8.1 Depósitos de chave-valor
8.2 Recursos dos depósitos de chave-valor
8.2.1 Consistência
8.2.2 Transações
8.2.3 Recursos de consultas
8.2.4 Estrutura de dados
8.2.5 Escalabilidade
8.3 Casos apropriados para uso
8.3.1 Armazenando informações de sessão
8.3.2 Perfis de usuários, preferências
8.3.3 Dados de carrinhos de compras
8.4 Quando não utilizar
8.4.1 Relacionamentos entre dados
8.4.2 Transações com múltiplas operações
8.4.3 Consulta por dados
8.4.4 Operações por conjuntos
Capítulo 9 ■ Bancos de dados de documentos
9.1 O que é um banco de dados de documentos?
9.2 Características
9.2.1 Consistência
9.2.2 Transações
9.2.3 Disponibilidade
9.2.4 Recursos de consulta
9.2.5 Escalabilidade
9.3 Casos de uso apropriados
9.3.1 Registro de eventos (log)
9.3.2 Sistema de Gerenciamento de Conteúdo (CMS), plataformas de blog
9.3.3 Análises web ou em tempo real (analytics)
9.3.4 Aplicativos de comércio eletrônico
9.4 Quando não utilizar
9.4.1 Transações complexas que abranjam diferentes operações
9.4.2 Consultas em estruturas agregadas variáveis
Capítulo 10 ■ Armazenamento em famílias de colunas
10.1 O que é um depósito de dados de família de colunas?
10.2 Características
10.2.1 Consistência
10.2.2 Transações
10.2.3 Disponibilidade
10.2.4 Recursos de consulta
10.2.5 Escalabilidade
10.3 Casos de uso apropriados
10.3.1 Registro de eventos (log)
10.3.2 Sistemas de gerenciamento de conteúdo (CMS), plataformas de blog
10.3.3 Contadores
10.3.4 Expirando o uso
10.4 Quando não utilizar
Capítulo 11 ■ Bancos de dados de grafos
11.1 O que é um banco de dados de grafos?
11.2 Recursos
11.2.1 Consistência
11.2.2 Transações
11.2.3 Disponibilidade
11.2.4 Recursos de consulta
11.2.5 Escalabilidade
11.3 Casos de uso apropriados
11.3.1 Dados conectados
11.3.2 Roteamento, envio e serviços baseados em localização
11.3.3 Mecanismos de recomendação
11.4 Quando não utilizar
Capítulo 12 ■ Migrações de esquema
12.1 Alterações no esquema
12.2 Alterações de esquema em SGBDRs
12.2.1 Migrações para projetos sem restrições anteriores
12.2.2 Migrações em projetos legados
12.3 Alterações no esquema em um armazenamento de dados NoSQL
12.3.1 Migração incremental
12.3.2 Migrações em bancos de dados de grafos
12.3.3 Alterando a estrutura do agregado
12.4 Leituras complementares
12.5 Pontos chave
Capítulo 13 ■ Persistência poliglota
13.1 Necessidades diferentes de armazenamento de dados
13.2 Uso de armazenamentos de dados poliglotas
13.3 Uso de serviços em vez do acesso direto ao depósitos de dados
13.4 Expandindo para melhorar a funcionalidade
13.5 Escolhendo a tecnologia certa
13.6 Preocupações empresariais com a persistência poliglota
13.7 Complexidade de instalação
13.8 Pontos chave
Capítulo 14 ■ Além do NoSQL
14.1 Sistemas de arquivos
14.2 Event sourcing
14.3 Imagem de memória
14.4 Controle de versões
14.5 Bancos de dados XML
14.6 Bancos de dados de objetos
14.7 Ponto chave
Capítulo 15 ■ Escolhendo o seu banco de dados
15.1 Produtividade do programador
15.2 Desempenho no acesso aos dados
15.3 Permanecendo com o padrão
15.4 Restringindo suas apostas
15.5 Pontos chave
15.6 Considerações finais
Bibliografia
Prefácio
LevelDB
Memcached
Project Voldemort
Redis
Riak
MongoDB
OrientDB
RavenDB
Terrastore
Cassandra
HBase
Hypertable
HyperGraphDB
Infinite Graph
Neo4J
OrientDB
Essa classificação por modelo de dados é útil, porém, imperfeita. A distinção entre os
diferentes tipos de modelos de dados, como a distinção entre bancos de dados de chave-
valor e de documentos (“Modelos de dados de chave-valor e de documentos”, p. 50),
muitas vezes é difusa. Muitos bancos de dados não se encaixam exatamente nas
categorias; por exemplo, o OrientDB é considerado tanto um banco de dados de
documentos quanto de grafos.
Agradecimentos
Nossos primeiros agradecimentos aos nossos colegas da ThoughtWorks, muitos dos quais
têm aplicado NoSQL em nossos projetos de desenvolvimento nos últimos anos. Sua
experiência foi a fonte principal tanto de nossa motivação para escrever este livro quanto
de informações práticas sobre o valor dessa tecnologia. A experiência positiva que tivemos
até agora com armazenamentos de dados NoSQL é a base do nosso ponto de vista,
segundo o qual essa é uma tecnologia importante, que traz uma mudança significativa no
armazenamento de dados.
Também gostaríamos de agradecer a vários grupos que fizeram palestras públicas e
publicaram artigos e blogs sobre suas experiências com o NoSQL. Muito do progresso no
desenvolvimento de software permanece desconhecido quando as pessoas não o
compartilham. Agradecimentos especiais aqui para o Google e a Amazon, cujos artigos
sobre o Bigtable e o Dynamo foram muito influentes, impulsionando o movimento do
NoSQL. Também agradecemos às empresas que têm patrocinado e contribuído com o
desenvolvimento open-source de bancos de dados NoSQL. Uma diferença interessante
com relação às mudanças anteriores na área de armazenamento de dados é o quanto o
movimento NoSQL apóia-se no trabalho open-source.
Agradecimentos especiais à ThoughtWorks, pelo tempo que pudemos trabalhar neste
livro. Entramos para a ThoughtWorks mais ou menos ao mesmo tempo e estamos aqui há
mais de uma década. A ThoughtWorks continua a ser um lar muito hospitaleiro para nós,
uma fonte de conhecimento e prática, em um ambiente acolhedor de compartilhamento de
aprendizagem – tão diferente de organizações tradicionais de desenvolvimento de
sistemas.
A Bethany Anders-Beck, Ilias Bartolini, Tim Berglund, Duncan Craig, Paul Duvall, Oren
Eini, Perryn Fowler, Michael Hunger, Eric Kascic, Joshua Kerievsky, Anand
Krishnaswamy, Bobby Norton, Ade Oshineye, Thiyagu Palanisamy, Prasanna Pendse, Dan
Pritchett, David Rice, Mike Roberts, Marko Rodriquez, Andrew Slocum, Toby Tripp,
Steve Vinoski, Dean Wampler, Jim Webber e Wee Witthawaskul, que revisaram os
primeiros esboços deste livro e nos auxiliaram a melhorá-lo com seus conselhos.
Além disso, eu, Pramod, gostaria de agradecer à Biblioteca Schaumburg por oferecer um
ótimo serviço e disponibilizar um espaço silencioso para escrever; a Arhana e Arula,
minhas filhas lindas, por entenderem que o papai ia para a biblioteca e não as levava com
ele; a Rupali, minha esposa amada, por seu enorme apoio e ajuda para manter-me focado.
PARTE I
Compreender
CAPÍTULO 1
Por que NoSQL?
Praticamente, desde que iniciamos na área de software, os bancos de dados relacionais têm
sido a escolha padrão para o armazenamento de grandes volumes de dados, especialmente
no mundo dos aplicativos corporativos. Se você for um arquiteto iniciando um novo
projeto, é provável que sua principal escolha esteja relacionada a qual banco de dados
relacionais utilizar (e, muitas vezes, se a sua empresa já possuir um fornecedor fixo, nem
isso). Houve situações em que uma tecnologia de banco de dados ameaçou tomar uma
fatia do mercado, como aconteceu com os bancos de dados orientados a objetos na década
de 1990, mas essas alternativas nunca foram bem-sucedidas.
Após um longo período de domínio, surge o atual interesse por bancos de dados NoSQL.
Neste capítulo, exploraremos o porquê dos bancos de dados relacionais terem se tornado
tão dominantes e o porquê de acharmos que a ascensão atual dos bancos de dados NoSQL
não é passageira.
1.1.2 Concorrência
Aplicativos corporativos, geralmente, implicam muitas pessoas examinando o mesmo
conjunto de dados ao mesmo tempo e, possivelmente, modificando-o. Na maior parte do
tempo, essas pessoas estão trabalhando em diferentes áreas desses dados, mas,
ocasionalmente, operam na mesma parte. Por isso, é necessário coordenar essa interação
para evitar diversas situações, como fazer a reserva do mesmo quarto de hotel para dois
clientes simultaneamente.
A concorrência é notoriamente difícil de ser ajustada, com todos os tipos de erros que
podem atrapalhar até mesmo o mais cuidadoso dos programadores. Já que aplicativos
corporativos podem ter muitos usuários e outros sistemas, todos funcionando ao mesmo
tempo, podem surgir problemas a qualquer momento. Bancos de dados relacionais
ajudam a lidar com essas situações, controlando todo o acesso aos seus dados por meio de
transações. Embora essa não seja uma solução para tudo (você ainda tem de lidar com
erros de transações, por exemplo, quando tentar reservar um quarto que acabou de ser
ocupado), o mecanismo transacional tem executado bem sua função de controlar a
complexidade da concorrência.
As transações também desempenham um papel importante na hora de lidar com os
erros, pois é possível fazer uma alteração e, caso ocorra um erro durante seu
processamento, pode-se desfazê-la e voltar ao estado anterior.
1.1.3 Integração
Aplicativos corporativos fazem parte de um ecossistema rico, que requer múltiplos
aplicativos, escritos por diferentes equipes, que colaboram umas com as outras na
realização de suas tarefas. Esse tipo de colaboração entre aplicativos é inadequado, pois
implica a extrapolação dos limites humanos organizacionais. Os aplicativos, muitas vezes,
precisam utilizar os mesmos dados e, assim, as atualizações feitas por um devem ser
acessíveis pelos demais.
Uma forma comum de fazer isso é por meio da integração compartilhada de base de
dados [Hohpe e Woolf], em que múltiplos aplicativos armazenam seus dados em uma
única base de dados. A utilização de uma única base de dados permite que todos os
aplicativos utilizem facilmente os dados uns dos outros, enquanto o controle da
concorrência da base de dados lida com aplicativos múltiplos da mesma forma com que
lida com vários usuários em um único aplicativo.
Figura 1.1 – Um pedido que aparenta ser uma estrutura única na interface de usuário é
dividido em muitas linhas de muitas tabelas em um banco de dados relacional.
Entretanto, enquanto as linguagens de programação orientadas a objetos conseguiram se
tornar a força principal na área da programação, os bancos de dados orientados a objetos
caíram na obscuridade. Os bancos de dados relacionais enfrentaram o desafio, salientando
seu papel de mecanismo de integração, suportado pela linguagem padrão de manipulação
de dados (SQL). Houve uma crescente divisão profissional entre desenvolvedores de
aplicativos e administradores de bancos de dados.
Tornou-se muito mais fácil lidar com a incompatibilidade de impedância devido à ampla
disponibilidade de frameworks de mapeamento objeto-relacional, como o Hibernate e o
iBATIS, que implementam padrões de mapeamento bastante conhecidos [Fowler PoEAA].
Porém, a questão do mapeamento ainda é controversa. Frameworks de mapeamento
objeto-relacional poupam muito trabalho pesado às pessoas, mas podem se tornar um
problema quando estas exageram ao ignorar o banco de dados, comprometendo o
desempenho da pesquisa.
Os bancos de dados relacionais dominaram o mundo da computação corporativa na
década de 2000, mas, durante esse período, algumas brechas começaram a surgir em seu
domínio.
Um modelo de dados é o modo pelo qual percebemos e manipulamos nossos dados. Para
as pessoas que utilizam um banco de dados, esse modelo descreve a forma pela qual
interagimos com os dados desse banco. Ele difere de um modelo de armazenamento, que
descreve como o banco de dados armazena e manipula os dados internamente. Em um
mundo ideal, deveríamos ignorar o modelo de armazenamento, mas, na prática,
precisamos ter, pelo menos, uma vaga ideia sobre ele, principalmente para atingir um
desempenho razoável.
Coloquialmente, o termo “modelo de dados” significa, muitas vezes, o modelo dos
dados específicos em um aplicativo. Um desenvolvedor pode apontar para um diagrama
entidade-relacionamento (ER) de seu banco de dados e referir-se a ele como o seu modelo
de dados contendo clientes, pedidos, produtos etc. Entretanto, neste livro utilizaremos o
termo “modelo de dados”, na maioria das vezes, para falar sobre o modelo pelo qual o
gerenciador do banco de dados organiza seus dados – o que poderia ser mais formalmente
chamado de metamodelo.
O modelo de dados dominante nas últimas duas décadas é o modelo de dados relacional,
que é melhor visualizado como um conjunto de tabelas, em vez de uma página de uma
planilha. Cada tabela possui linhas e cada linha representa uma entidade de interesse.
Descrevemos essa entidade por meio de colunas, cada uma tendo um único valor. Uma
coluna pode se referir a outra linha na mesma tabela ou em uma tabela diferente, o que
constitui um relacionamento entre essas entidades (estamos utilizando terminologia
informal, porém, comum, quando falamos em tabelas e linhas; os termos mais formais
seriam relações e tuplas.)
Uma das mudanças mais óbvias trazidas pelo NoSQL é o afastamento do modelo
relacional. Cada solução NoSQL possui um modelo diferente, os quais dividimos em
quatro categorias amplamente utilizadas no ecossistema NoSQL: chave-valor, documento,
famílias de colunas e grafos. Dessas, as três primeiras compartilham uma característica
comum em seus modelos de dados – que chamaremos de “orientação agregada”. Neste
capítulo, explicaremos o que queremos dizer por orientação agregada e o que ela significa
para os modelos de dados.
2.1 Agregados
O modelo relacional recebe as informações que queremos armazenar e divide-as em tuplas
(linhas). Uma tupla é uma estrutura de dados limitada: ela captura um conjunto de
valores, de modo que não é possível aninhar uma dentro da outra para obter registros
aninhados nem é possível colocar uma lista de valores ou tuplas dentro de uma ou de
outra. Essa simplicidade é a base do modelo relacional – ela nos permite pensar em todas
as operações, como atuar em tuplas e retornar tuplas.
A orientação agregada utiliza uma abordagem diferente. Ela reconhece que você,
frequentemente, deseja trabalhar com dados na forma de unidades que tenham uma
estrutura mais complexa do que um conjunto de tuplas. Pode ser útil pensar em termos de
um registro complexo que permita que listas e outras estruturas de dados sejam aninhadas
dentro dele. Conforme veremos, bancos de dados dos tipos de chave-valor, de documento
e de família de colunas fazem uso desse registro mais complexo. Entretanto, não existe um
termo comum que identifique esse registro; neste livro, utilizaremos o termo “agregado”,
que vem do Domain-Driven Design (Projeto Orientado a Domínios) [Evans]. Nele, um
agregado é um conjunto de objetos relacionados que desejamos tratar como uma unidade.
Em particular, é uma unidade de manipulação de dados e gerenciamento de consistência.
Normalmente, preferimos atualizar agregados com operações atômicas e comunicarmo-
nos com nosso armazenamento de dados em termos de agregados. Essa definição
corresponde bem ao funcionamento dos bancos de dados de chave-valor, documento e
família de colunas. Lidar com agregados facilita muito a execução desses bancos de dados
em um cluster, uma vez que o agregado constitui uma unidade natural para replicação e
fragmentação. Agregados também são, frequentemente, mais simples de ser manipulados
pelos programadores de aplicativos, já que eles, muitas vezes, lidam com os dados por
meio de estruturas agregadas.
Até aqui, examinamos a característica principal da maioria dos bancos de dados NoSQL: o
uso de agregados e a forma como os bancos de dados orientados a agregados modelam-
nos de diferentes formas. Embora os agregados sejam a parte central da história do
NoSQL, há outros aspectos relacionados à modelagem de dados, os quais exploraremos
neste capítulo.
3.1 Relacionamentos
Agregados são úteis, pois agrupam dados que são comumente acessados juntos. Mas ainda
há muitos casos em que dados relacionados são acessados de formas diferentes. Considere
a relação entre um cliente e todos os seus pedidos. Alguns aplicativos optarão por acessar
o histórico de pedidos sempre que acessarem o cliente, o que se enquadra bem na
combinação do cliente com seu histórico de pedidos em um único agregado.
Outros aplicativos, entretanto, processarão os pedidos individualmente e, assim,
modelarão pedidos como agregados independentes. Nesse caso, os agregados de pedidos e
de clientes serão separados, mas estarão relacionados de alguma maneira, de modo que
qualquer ação executada sobre um pedido possa levar aos dados do cliente. A forma mais
simples de obter essa conexão é inserindo o ID do cliente nos dados do agregado do
pedido. Assim, para obter os dados do registro do cliente, deve-se ler o pedido, descobrir o
ID do cliente e acessar novamente o banco de dados para ler os dados do cliente. Esse
procedimento será eficiente e conveniente em muitos cenários, mas o banco de dados não
terá ciência do relacionamento entre os dados. Esse é um detalhe importante, pois há vezes
em que é útil que o banco de dados conheça essas conexões.
Consequentemente, muitos bancos de dados – até os de armazenamentos de chave-valor
– fornecem meios de tornar esses relacionamentos visíveis para o banco de dados. O
armazenamento de documentos disponibiliza o conteúdo do agregado para o banco de
dados criar índices e consultas. O Riak, um armazenamento de chave-valor, permite que
sejam inseridas informações de conexões em metadados, suportando a recuperação parcial
e a capacidade de percorrer as conexões.
Um aspecto importante dos relacionamentos entre agregados é a maneira de eles lidarem
com atualizações. Bancos de dados orientados a agregados tratam estes como a unidade de
recuperação de dados. Consequentemente, a atomicidade somente é suportada dentro do
conteúdo de um único agregado. Se atualizarmos múltiplos agregados de uma só vez,
teremos de lidar com uma eventual falha durante esse processo. Bancos de dados
relacionais podem ajudar, permitindo que sejam modificados múltiplos registros em uma
única transação, fornecendo garantias ACID enquanto alteram muitas linhas.
Tudo isso significa que bancos de dados orientados a agregados tornam-se mais
complexos se for necessário trabalhar em múltiplos agregados. Há diversas formas de lidar
com isso, e elas serão exploradas posteriormente neste capítulo. Porém, a dificuldade
fundamental permanece.
Se houver dados baseados em muitos relacionamentos, deve-se preferir um banco de
dados relacional a um armazenamento NoSQL. Embora isso seja verdade para bancos de
dados orientados a agregados, vale a pena lembrar que bancos de dados relacionais
também não são tão espetaculares no que diz respeito a relacionamentos complexos.
Embora seja possível fazer consultas envolvendo junções (JOIN) em SQL, a situação pode,
rapidamente, tornar-se complicada, tanto com a escrita do SQL, quanto com o
desempenho resultante, quando o número de junções aumenta.
Isso torna o momento propício para apresentar outra categoria de bancos de dados que,
muitas vezes, é colocada no mesmo grupo de NoSQL.
O que mais chama a atenção no NoSQL é sua capacidade de executar bancos de dados em
um grande cluster. À medida que os volumes de dados aumentam, torna-se mais difícil e
caro fazer uma expansão: comprar um servidor maior em que o banco de dados possa ser
executado. Uma opção mais interessante seria a escalabilidade, executando-se o banco de
dados em um cluster de servidores. A orientação a agregados adapta-se bem à escala
porque o agregado é uma unidade natural para se utilizar de forma distribuída.
Dependendo do modelo de distribuição, pode-se obter um armazenamento de dados que
permita lidar com quantidades maiores de dados, processar um tráfego maior de leitura ou
gravação, ou mais disponibilidade quanto a atrasos e interrupções na rede. Esses são
benefícios importantes, mas eles têm seu custo. A execução em um cluster introduz
complexidade – portanto, não é algo a ser feito, a menos que os benefícios sejam muitos.
Em termos gerais, há dois caminhos a serem seguidos na distribuição de dados: a
replicação e a fragmentação. A replicação obtém os mesmos dados e os copia em múltiplos
nodos. A fragmentação coloca dados diferentes em nodos diferentes. Replicação e
fragmentação são técnicas ortogonais: você pode utilizar uma ou ambas. A replicação tem
duas formas: mestre-escravo e ponto a ponto. Discutiremos agora essas técnicas,
começando pela mais simples até chegar à mais complexa: primeiro, a técnica com um
único servidor, depois, a replicação mestre-escravo e a fragmentação para, finalmente,
chegarmos à replicação ponto a ponto.
4.2 Fragmentação
Frequentemente, um armazenamento de dados fica extremamente ocupado, pois várias
pessoas estão acessando partes diferentes do conjunto dos dados. Nessas circunstâncias,
podemos suportar a escalabilidade horizontal, colocando partes diferentes dos dados em
servidores diferentes – uma técnica chamada de fragmentação (sharding) (Figura 4.1).
Em um cenário ideal, teremos usuários diferentes conversando com nodos de servidores
diferentes. Cada usuário somente tem de conversar com um servidor, de modo que obtém
respostas rápidas dele. A carga é bem balanceada entre os servidores: por exemplo, se
tivermos dez servidores, cada um apenas tem de lidar com 10% da carga.
Figura 4.1 – A fragmentação coloca dados diferentes em nodos separados, cada um destes
executando suas próprias leituras e gravações.
É claro que esse cenário é raro. Para chegarmos perto dele, temos de garantir que os
dados acessados em conjunto sejam aglutinados no mesmo nodo e que esse aglutinado,
por sua vez, esteja organizado dentro dos nodos para, dessa maneira, fornecer o melhor
acesso de dados.
A primeira parte dessa questão é saber como aglutinar os dados de forma que um
usuário obtenha a maior parte de seus dados de um único servidor. É aí que a orientação a
agregados é realmente útil. A questão toda, a respeito dos agregados, é que os projetamos
para que combinem os dados que são, normalmente, acessados em conjunto, de modo que
os agregados se destaquem como uma unidade óbvia de distribuição.
Quando o assunto é a organização dos dados nos nodos, há diversos fatores que podem
ajudar a melhorar o desempenho. Se você souber que a maioria dos acessos a
determinados agregados é baseada em uma localização física, pode colocar os dados
próximos ao lugar onde são acessados. Se tiver pedidos de alguém que mora em Boston,
pode colocar esses dados em seu centro de dados do leste dos EUA.
Outro fator é tentar manter a carga parelha. Isso significa que você deve tentar organizar
os agregados para que sejam distribuídos de modo parelho pelos nodos para receber, dessa
forma, quantidades iguais de carga, o que pode variar com o decorrer do tempo. Por
exemplo, se algum dado tende a ser acessado em determinados dias da semana, então
podem existir regras de domínio específico que você gostaria de utilizar.
Em alguns casos, é útil juntar agregados se você achar que eles podem ser lidos em
sequência. O artigo BigTable [Chang etc.] descreve a manutenção de suas linhas em ordem
léxica e a ordenação de endereços web está baseada nos nomes de domínio invertidos (por
exemplo, com.martinfowler). Dessa maneira, os dados de múltiplas páginas poderiam ser
acessados juntos para aumentar a eficiência do processamento.
Historicamente, a maioria das pessoas realiza a fragmentação como parte da lógica do
aplicativo. Você poderia colocar todos os clientes cujo sobrenome começa com A a D em
um fragmento e com E a G em outro. Isso complica o modelo de programação, já que o
código do aplicativo precisa garantir que as consultas sejam distribuídas pelos diversos
fragmentos. Além disso, rebalancear a fragmentação significa alterar o código do aplicativo
e migrar os dados. Muitos bancos de dados NoSQL oferecem a autofragmentação, em que
o banco de dados fica responsável por alocar os dados nos fragmentos e por assegurar-se
de que o acesso aos dados vá para o fragmento correto. Isso pode facilitar muito o uso da
fragmentação em um aplicativo.
A fragmentação é particularmente valiosa para a performance, pois pode melhorar o
desempenho de leitura e gravação. Utilizar a replicação, especialmente com cache, pode
melhorar muito o desempenho de leitura, mas faz pouco para aplicativos que tenham
muitas gravações. A fragmentação fornece uma maneira de ampliar horizontalmente as
gravações.
A fragmentação faz pouco para melhorar a resiliência quando utilizada isoladamente.
Embora os dados estejam em nodos diferentes, uma falha de nodo torna indisponíveis os
dados do fragmento, assim como acontece em uma solução de um único servidor. O
benefício que a resiliência fornece é que apenas os usuários dos dados desse fragmento
sofrerão; entretanto, não é bom ter um banco de dados com partes de seus dados faltando.
Com um único servidor, é mais fácil compensar o esforço e o custo de manter o servidor
funcionando; os clusters, geralmente, tentam utilizar máquinas menos confiáveis,
aumentando as chances de falha no nodo. Provavelmente, na prática, a fragmentação,
sozinha, diminuirá a resiliência.
Apesar de a fragmentação ser realizada mais facilmente com agregados, ainda não é um
passo a ser dado sem preocupações. Alguns bancos de dados têm a intenção, desde o
início, de utilizar a fragmentação. Nesse caso, é sábio executá-los em um cluster desde o
início do desenvolvimento e, certamente, também na fase de produção. Outros bancos de
dados utilizam a fragmentação como um segundo passo a partir de uma configuração de
servidor único. Nesse caso, é melhor iniciar com um servidor único e apenas utilizar a
fragmentação quando suas projeções de carga indicarem, claramente, que os recursos
estão terminando.
De qualquer maneira, passar de um único nodo para a fragmentação será complicado.
Ouvimos histórias de equipes que enfrentaram problemas, pois realizaram-na tarde
demais. Ao executá-la na produção, seu banco de dados ficou basicamente indisponível,
pois o suporte à fragmentação consumiu todos os recursos do banco de dados para mover
os dados para os novos fragmentos. A lição aqui é utilizar a fragmentação bem antes de
precisar dela – quando os recursos forem suficientes para executá-la.
Figura 4.2 – Os dados são replicados do mestre para os escravos. O mestre serve a todas as
gravações; as leituras podem vir do mestre ou dos escravos.
A segunda vantagem da replicação mestre-escravo é a resiliência de leitura: se o mestre
falhar, os escravos ainda podem lidar com as solicitações de leitura. Novamente, isso é útil
se a maioria de seus acessos aos dados for para leitura. A falha no mestre elimina a
capacidade de lidar com gravações até que ele seja restaurado ou que um novo mestre seja
designado. Entretanto, ter escravos como replicações do mestre acelera a recuperação após
uma falha, já que um escravo pode ser designado como um novo mestre muito
rapidamente.
A capacidade de designar um escravo para substituir um mestre que tenha falhado
significa que a replicação mestre-escravo é útil mesmo que não seja preciso escalabilidade.
Todo o tráfego de leitura e gravação pode ir para o mestre, enquanto o escravo atua como
uma cópia de segurança ativa. Nesse caso, é mais fácil pensar no sistema como um
armazenamento de servidor único, com backup ativo. Você usufrui da conveniência de
uma configuração de um único servidor, mas com maior resiliência, o que é especialmente
útil se você quiser ser capaz de lidar bem com falhas no servidor.
Mestres podem ser designados manual ou automaticamente. A designação manual
significa, geralmente, que, quando você configura seu cluster, configura um nodo como o
mestre. Com a designação automática, você cria um cluster de nodos e eles elegem um
como o mestre. Além da configuração mais simples, a designação automática significa que
o cluster pode designar automaticamente um novo mestre quando outro falhar, reduzindo
o tempo fora do ar.
Para obter resiliência de leitura, é necessário garantir que os caminhos de leitura e de
gravação em seu aplicativo sejam diferentes, de modo que seja possível lidar com uma
falha no caminho de gravação e ainda assim ler. Isso inclui ações, como colocar as leituras
e gravações em conexões separadas do banco de dados – um recurso que não é suportado
com frequência por bibliotecas de interação com bancos de dados. Assim como com
qualquer recurso, você não tem certeza de que tem resiliência de leitura até realizar bons
testes que desabilitem as gravações e verifiquem se as leituras ainda ocorrem.
A replicação traz alguns benefícios interessantes, mas também um lado negativo
inevitável – a inconsistência. O risco é que clientes diferentes, lendo diferentes escravos,
vejam valores diferentes, pois as alterações não foram todas propagadas para os escravos.
No pior caso, isso significa que um cliente não conseguirá ler uma gravação que acabou de
fazer. Mesmo que você utilize a replicação mestre-escravo apenas para a cópia de
segurança ativa, isso pode ser um problema, pois, se o mestre falhar, quaisquer
atualizações que não tenham sido transmitidas para a cópia de segurança estarão perdidas.
Falaremos sobre como lidar com esses problemas posteriormente (“Consistência”, p. 83).
5.5 Quóruns
Balancear consistência e durabilidade não é uma situação de tudo ou nada. Quanto mais
nodos forem envolvidos em uma solicitação, maior a chance de evitar uma inconsistência.
Isso leva, naturalmente, à questão: quantos nodos precisam ser envolvidos para que seja
obtida uma alta consistência?
Imagine alguns dados replicados em três nodos. Não é necessário que todos eles
reconheçam uma gravação para assegurar alta consistência, apenas dois deles, a maioria, já
é o suficiente. Se as gravações forem conflitantes, apenas uma pode obter a maioria. Isso é
chamado de quórum de gravação e é expresso em uma desigualdade um pouco
pretensiosa, W > N/2, significando que o número de nodos participantes da gravação (W)
deve ser maior do que a metade do número de nodos envolvidos na replicação (N). O
número de réplicas, muitas vezes, é chamado de fator de replicação.
De modo semelhante ao quórum de gravação, existe a noção de quórum de leitura:
quantos nodos devem ser contatados para garantir que tenha a alteração mais atualizada?
O quórum de leitura é um pouco mais complicado, pois depende de quantos nodos
precisam confirmar uma gravação.
Vamos considerar um fator de replicação igual a 3. Se todas as gravações precisarem que
dois nodos confirmem (W = 2), então precisamos contatar pelo menos dois nodos para
assegurar-nos de que temos os dados mais recentes. No entanto, se as gravações são
confirmadas por um único nodo (W = 1), precisaremos conversar com todos os três nodos
para ter certeza de que estamos com os dados atualizados. Nesse caso, já que não temos
um quórum de gravação, podemos ter um conflito de atualização. Porém, contatando
leitores suficientes, podemos nos assegurar de detectá-lo. Assim, obteremos leituras
fortemente consistentes, mesmo sem ter alta consistência nas nossas gravações.
Esse relacionamento entre o número de nodos que devem ser contatados para uma
leitura (R), aqueles que confirmam uma gravação (W) e o fator de replicação (N) pode ser
considerado uma desigualdade: pode-se ter uma leitura altamente consistente se R + W > N.
Essas desigualdades são escritas com um modelo de distribuição ponto a ponto em
mente. Se tivermos uma distribuição mestre-escravo, devemos apenas gravar no mestre
para evitar conflitos de gravação e, de modo semelhante, apenas ler do mestre para evitar
conflito de leitura/gravação. Com essa notação, é comum confundir o número de nodos
no cluster com o fator de replicação, mas estes, geralmente, são diferentes. Podemos ter
100 nodos no cluster, mas apenas um fator de replicação três, com a maior parte da
distribuição ocorrendo devido à fragmentação.
De fato, a maioria dos especialistas sugere que um fator de replicação igual a três é
suficiente para se ter uma boa resiliência. Isso permite que um único nodo falhe, mas
ainda assim resta um quórum de leitura e gravação. Se você tiver rebalanceamento
automático, não demorará muito para que o cluster crie uma terceira réplica, de modo que
a chance de perder uma segunda réplica antes de uma substituição torna-se pequena.
O número de nodos que participam de uma operação pode variar de acordo com o tipo
de operação. Na gravação, poderíamos necessitar de um quórum para alguns tipos de
atualização, mas não para outros, dependendo da importância que dermos à consistência e
disponibilidade. De forma semelhante, uma leitura que necessita de velocidade, mas que
pode tolerar dados desatualizados, deve contatar menos nodos.
Muitas vezes, torna-se necessário considerar ambas. Se você precisar de leituras
altamente consistentes e rápidas, pode solicitar que as gravações sejam reconhecidas por
todos os nodos, permitindo, assim, que as leituras contatem apenas um (N = 3, W = 3, R =
1). Isso significa que suas gravações serão lentas, já que terão de contatar todos os três
nodos e você não pode tolerar a perda de um deles. Mas, em algumas circunstâncias, esse
pode ser o compromisso a assumir.
A questão é que você tem uma gama de opções com as quais trabalhar e pode escolher
qual combinação de problemas e vantagens prefere. Alguns autores, que escrevem sobre
NoSQL, falam sobre um balanceamento simples entre consistência e disponibilidade, mas
esperamos que você tenha percebido que é muito mais flexível – e mais complicado – do
que parece.
Muitos críticos dos bancos de dados NoSQL enfatizam sua falta de suporte a transações,
que consistem em uma ferramenta útil para ajudar os programadores a garantir
consistência. Um motivo pelo qual muitos proponentes do NoSQL não se preocupam
tanto com a falta de transações é que bancos de dados NoSQL orientados a agregados
suportam atualizações atômicas dentro de um agregado – e agregados são projetados de
modo que seus formatos de dados sejam uma unidade natural de atualização. Dito isso, é
verdade que as necessidades transacionais devem ser levadas em consideração quando for
preciso decidir qual banco de dados utilizar.
Como parte desse processo, é importante lembrar que transações têm limitações. Mesmo
dentro de um sistema transacional, ainda temos de lidar com atualizações que requerem
intervenção humana e, geralmente, não podem ser executadas dentro de transações, pois
isso significaria deixar uma transação aberta tempo demais. Podemos lidar com isso
utilizando marcadores de versões, os quais acabam sendo úteis em outras situações
também, especialmente quando nos afastamos do modelo de distribuição com um único
servidor.
Pode haver valores faltando no vetor e, nesses casos, costumamos tratar o valor que falta
como zero. Assim [azul: 6, preto: 2] seria tratado como [azul: 6, verde: 0, preto:2]. Isso
permite que novos nodos sejam adicionados facilmente, sem invalidar os marcadores
existentes.
Marcadores vetoriais são uma ferramenta valiosa para a localização de inconsistências,
mas não as resolvem. Qualquer resolução de conflito dependerá do domínio no qual você
estiver trabalhando. Essa é a parte do equilíbrio entre consistência e latência. Você tem de
conviver com o fato de que partições de rede podem tornar seu sistema indisponível. Caso
contrário, você tem de detectar e lidar com inconsistências.
Figura 7.1 – A função map lê registros do banco de dados e emite pares de chave-valor.
Uma operação map depende apenas de um único registro; a função reduce (redução)
recebe múltiplos resultados do mapeamento com a mesma chave e combina seus valores.
Assim, uma função map poderia produzir 1.000 itens de pedidos para o produto
“Database Refactoring”; a função reduce reduziria essas 1.000 linhas a apenas uma, com
os totais para quantidade e receita. Embora a função map esteja limitada a trabalhar
apenas nos dados de um único agregado, a função reduce pode utilizar todos os valores
enviados para uma única chave (Figura 7.2).
Figura 7.2 – Uma função reduce recebe diversos pares de chave-valor com a mesma chave e
os agrega em um.
O framework map-reduce organiza as tarefas de mapeamento para que sejam executadas
nos nodos corretos, de forma que todos os documentos sejam processados e, também, de
modo que os dados sejam passados para a função reduce. Para facilitar a escrita dessa
função, o framework coleta todos os valores de um único par e aplica a função reduce uma
vez com a chave e o conjunto de todos os valores desta chave. Assim, para executar uma
função map-reduce, você apenas precisa escrever essas duas funções.
Figura 7.5 – Essa função redutora, que conta quantos clientes exclusivos encomendam um
determinado chá, não é combinável.
Quando você tiver redutoras combinadas, o framework map-reduce poderá ser
executado com segurança não apenas em paralelo (para reduzir diferentes partições), mas
também em séries, a fim de reduzir a mesma partição em períodos e lugares diferentes.
Além de permitir que a combinação ocorra em um nodo antes da transmissão dos dados,
você também pode iniciar a combinação antes que os mapeadores tenham terminado. Isso
fornece uma quantidade extra de flexibilidade ao processamento de map-reduce. Alguns
frameworks map-reduce necessitam de que todas as redutoras sejam combinadas, fato que
maximiza essa flexibilidade. Se você precisa executar um redutor não combinado com um
desses frameworks, terá de separar o processamento em etapas map-reduce com pipelines
(encadeamentos).
7.3 Criando cálculos map-reduce
A abordagem map-reduce é uma forma de pensar no processamento concorrente que
equilibra a flexibilidade na forma pela qual você estrutura sua computação por um modelo
relativamente direto de paralelização da computação em um cluster. Por ser um equilíbrio,
há restrições quanto ao que você pode fazer em seus cálculos. Dentro de uma tarefa de
mapeamento, você somente pode trabalhar em um único agregado. Dentro de uma tarefa
de redução, você somente pode trabalhar em uma única chave. Isso significa que você tem
de mudar a maneira como estrutura seus programas, de forma a fazê-los funcionarem bem
dentro dessas restrições.
Uma limitação simples seria você ter de estruturar seus cálculos em torno de operações
que se adaptem bem à noção de uma operação de redução. Um bom exemplo é o cálculo
de médias. Vamos considerar o tipo de pedidos que temos examinado até então; suponha
que queiramos saber a quantidade média encomendada de cada produto. Uma
propriedade importante de médias é que elas não são combináveis, ou seja, se eu tiver dois
grupos de pedidos, não posso combinar apenas as suas médias. Em vez disso, preciso
obter a quantidade total e o contador dos pedidos de cada grupo, combiná-los e, então,
calcular a média a partir do contador e da soma combinados (Figura 7.6).
Figura 7.6 – Ao calcular médias, a soma e o contador podem ser combinados na função
reduce, mas a média deve ser calculada a partir da soma e do contador combinados.
Essa noção de procurar cálculos que reduzam perfeitamente também afeta a maneira
com a qual executamos as contagens. Para fazer um contador, a função de mapeamento
produzirá campos de contador com o valor igual a 1, o que pode ser resumido para se
obter uma contagem total (Figura 7.7).
Figura 7.7 – Ao fazer uma conta, cada mapa gera 1, o que pode ser somado para se obter um
total.
Figura 7.8 – Um cálculo dividido em etapas de map-reduce, que serão expandidas nas
próximas três figuras.
Uma primeira etapa (Figura 7.9) leria os registros dos pedidos originais e produziria uma
série de pares de chave-valor para as vendas de cada produto por mês.
Figura 7.9 – Criando registros para as vendas mensais por produto.
Essa etapa é semelhante aos exemplos de map-reduce que vimos até aqui. A única
característica diferente é o uso de uma chave composta, de modo que possamos reduzir os
registros baseados nos valores de múltiplos campos.
Os mapeadores da segunda etapa (Figura 7.10) processam esse resultado dependendo do
ano. Um registro de 2011 é utilizado para aumentar a quantidade desse ano, enquanto um
registro de 2010 aumenta a quantidade do ano anterior. Registros de anos anteriores
(como 2009) não produzem resultados mapeados.
A redução, nesse caso (Figura 7.11), é uma fusão de registros, em que a combinação de
valores pelo somatório permite que os resultados de dois anos sejam reduzidos a um único
valor (com um cálculo baseado nos valores reduzidos, adicionados para uma boa
medição).
Decompor esse relatório em múltiplas etapas de map-reduce facilita a escrita. Do mesmo
modo, isso acontece com muitos exemplos de transformação. Uma vez que você tenha
encontrado um framework de transformação que facilite a composição de etapas, torna-se
mais fácil compor muitas etapas pequenas do que tentar colocar muita lógica em uma
única etapa.
Figura 7.10 – A segunda etapa cria registros básicos para comparações ano a ano.
Tabela Bucket
Linha chave-valor
Rowid Chave
Figura 8.2 – Altere o modelo da chave para segmentar os dados de um único bucket.
Utilizar buckets de domínio ou buckets diferentes para diferentes objetos (como
UserProfile e ShoppingCart) segmenta os dados em diferentes buckets, permitindo que você
leia apenas o objeto de que precisa, sem ter de alterar o projeto da chave.
Depósitos de chave-valor, como o Redis, também suportam estruturas de dados
aleatórias, as quais podem ser conjuntos, hashes, strings e assim por diante. Esse recurso
pode ser utilizado para armazenar listas variadas, como states (listas de estados) ou
AddressTypes (tiposDeEndereco), ou, ainda, um array com as visitas do usuário.
8.2.1 Consistência
A consistência é aplicável apenas às operações em uma única chave, já que essas operações
são a obtenção, gravação ou exclusão em uma única chave. Gravações otimistas podem ser
executadas, mas são muito caras para implementar, pois uma alteração no valor não pode
ser determinada pelo armazenamento de dados.
Em implementações distribuídas de armazenamentos de chave-valor, como o Riak, o
modelo de consistência eventual é implementado (p. 88). Uma vez que o valor pode já ter sido
replicado em outros nodos, o Riak tem duas maneiras de resolver conflitos de atualização:
ou a gravação mais recente prevalece sobre as mais antigas, ou ambos (todos) os valores
são retornados, permitindo ao cliente resolver o conflito.
No Riak, essas opções podem ser configuradas durante a criação do bucket. Buckets são
apenas uma forma de colocar as chaves em namespaces, o que reduz colisões de chaves –
por exemplo, todas as chaves de clientes podem ficar no bucket customer. Ao criar um
bucket, valores padrões para consistência podem ser fornecidos. Por exemplo, uma
gravação é considerada boa apenas quando os dados forem consistentes por todos os
nodos em que estiverem armazenados.
Bucket bucket = connection
.createBucket(bucketName)
.withRetrier(attempts(3))
.allowSiblings(siblingsAllowed)
.nVal(numberOfReplicasOfTheData)
.w(numberOfNodesToRespondToWrite)
.r(numberOfNodesToRespondToRead)
.execute();
Se precisarmos que os dados sejam consistentes em todos os nodos, podemos aumentar
a configuração de numberOfNodesToRespondToWrite, definida pelo w, para ser o mesmo valor de
nVal. É claro que fazer isso piorará o desempenho de gravação do cluster. Para melhorar os
conflitos de gravação ou leitura, podemos alterar a flag allowSiblings durante a criação do
bucket: se for configurada como falsa, permitiremos que a última gravação prevaleça e não
criaremos cópias.
8.2.2 Transações
Diferentes produtos no armazenamento de chave-valor têm diferentes especificações de
transação. De modo geral, não há garantias nas gravações. Muitos armazenamentos de
dados implementam transações de diferentes formas. O Riak utiliza o conceito de quórum
(“Quóruns”, p. 97), implementado usando o valor W – quórum de gravação – durante a
chamada à API de gravação.
Suponha que tenhamos um cluster Riak com um fator de replicação igual a 5 e
forneçamos o valor W igual a 3. Essa gravação será informada como bem-sucedida, durante
a gravação, apenas quando for realizada e informada como bem-sucedida em pelo menos
três dos nodos. Isso permite ao Riak ser tolerante nas gravações; em nosso exemplo, com N
igual a 5 e W igual a 3, o cluster pode tolerar N – W = 2 nodos falhando para operações de
gravação, embora ainda teríamos perdido alguns dados nesses nodos para leitura.
8.2.5 Escalabilidade
Muitos armazenamentos de chave-valor podem ser escalados utilizando a fragmentação
(“Fragmentação”, p. 74). Com ela, o nome da chave determina em qual nodo a chave deve
ser armazenada. Suponhamos que estejamos fragmentando pelo primeiro caractere da
chave; se a chave for f4b19d79587d, que começa com um f, será enviada para um nodo
diferente do da chave ad9c7a396542. Esse tipo de configuração de fragmentação pode
melhorar o desempenho na medida em que mais nodos são acrescentados ao cluster.
A fragmentação também traz alguns problemas. Se o nodo utilizado para armazenar f
falhar, os dados armazenados nesse nodo ficam indisponíveis e novos dados não podem
ser gravados com chaves que comecem com f.
Bancos de dados como o Riak permitem que você controle os aspectos do teorema CAP
(p. 91): N (o número de nodos para armazenar as réplicas chave-valor), R (o número de
nodos que devem ter os dados pesquisados antes da leitura ser considerada como bem-
sucedida) e W (o número de nodos nos quais a gravação deve ser executada antes de ser
considerada bem-sucedida).
Suponhamos que temos um cluster Riak com cinco nodos. Configurar N como 3 significa
que todos os dados serão replicados em pelo menos três nodos. Configurar R como 2
significa que dois nodos quaisquer devem responder a uma solicitação GET para que esta
seja considerada bem-sucedida. Por fim, configurar W como 2 garante que a solicitação PUT
seja gravada em dois nodos antes de a gravação ser considerada bem-sucedida.
Essas configurações permitem-nos fazer um ajuste detalhado nas falhas dos nodos em
operações de leitura e de gravação. Baseados em nossas necessidades, podemos alterar
esses valores para obter mais disponibilidade de leitura ou de gravação. De modo geral,
selecione um valor para W que satisfaça às suas necessidades de consistência; esses valores
podem ser configurados como padrões durante a criação dos buckets.
Tabela Coleção
Linha Documento
Rowid _id
Junção DBRef
Essa diferente representação dos dados não é a mesma que se utiliza em um SGBDR, em
que todas as colunas devem ser definidas e, se não contiverem dados, são marcadas como
vazias ou nulas (null). Em documentos, não há atributos vazios; se um determinando
atributo não for encontrado, supomos que não estava configurado ou não era relevante
para o documento. Os documentos permitem que novos atributos sejam criados sem a
necessidade de definição prévia ou de alteração nos documentos existentes.
Alguns dos bancos de dados de documentos populares que temos visto são MongoDB
[MongoDB], CouchDB [CouchDB], Terrastore [Terrastore], OrientDB [OrientDB],
RavenDB [RavenDB] e, é claro, o bem conhecido e muitas vezes criticado Lotus Notes
[Notes Storage Facility], que utiliza armazenamento de documentos.
9.2 Características
Embora existam muitos bancos de dados especializados para documentos, utilizaremos o
MongoDB como um representante do conjunto de características. Tenha em mente que
cada produto possui algumas características que podem não ser encontradas em outros
bancos de dados de documentos.
Vamos parar um pouco para entender como o MongoDB funciona. Cada instância do
MongoDB possui múltiplos bancos de dados e cada banco de dados pode ter múltiplas
coleções. Quando comparamos isso com os SGBDRs, uma instância de SGBDR é igual a
uma instância em MongoDB, os esquemas de SGBDRs são semelhantes aos bancos de
dados MongoDB e as tabelas de SGBDRs são coleções em MongoDB. Quando
armazenamos um documento, temos de escolher em qual banco de dados e em qual
coleção ele ficará – por exemplo, database.collection.insert(document), que, geralmente, é
representado como db.coll.insert(document).
9.2.1 Consistência
A consistência em bancos de dados MongoDB é configurada utilizando os conjuntos de
réplicas e optando por esperar que as gravações sejam replicadas em todos os escravos ou
em um determinado número de escravos. Cada gravação pode especificar o número de
escravos na qual deve ser propagada antes de ser considerada bem-sucedida.
Um comando como db.runCommand({ getlasterror : 1 , w : "majority" }) informa ao
banco de dados o quão alta é a consistência que você quer. Por exemplo: se você tiver
apenas um servidor e especificar w como majority (maioria), a gravação retornará
imediatamente, uma vez que há apenas um nodo. Se você tiver três nodos no conjunto de
réplicas e especificar w como majority, a gravação terá de se completar em pelo menos dois
nodos antes de ser informada como bem-sucedida. Você pode aumentar o valor de w para
obter uma consistência mais alta, mas terá perda no desempenho, uma vez que, agora, as
gravações têm de ser concluídas em mais nodos. Conjuntos de réplicas também
possibilitam que você aumente o desempenho da leitura, permitindo a leitura a partir de
escravos ao configurar slaveOk; esse parâmetro pode ser configurado na conexão, no banco
de dados, na coleção ou, individualmente, para cada operação.
Mongo mongo = new Mongo("localhost:27017");
mongo.slaveOk();
Aqui estamos configurando slaveOk por operação. Dessa forma, podemos decidir quais
operações podem trabalhar com dados do nodo escravo.
DBCollection collection = getOrderCollection();
BasicDBObject query = new BasicDBObject();
query.put("name", "Martin");
DBCursor cursor = collection.find(query).slaveOk();
Semelhante às diversas opções disponíveis para leitura, você pode alterar as
configurações para obter uma consistência alta de gravação, se desejar. Por padrão, uma
gravação é informada como bem-sucedida assim que o banco de dados a receber; você
pode alterar isso para que espere até as gravações serem sincronizadas no disco ou
propagadas para dois ou mais escravos, o que é conhecido como WriteConcern: você garante
que determinadas gravações sejam feitas no mestre e em alguns escravos, configurando
WriteConcern como REPLICAS_SAFE. A seguir, está o código onde estamos configurando
WriteConcern para todas as gravações em uma coleção:
DBCollection shopping = database.getCollection("shopping");
shopping.setWriteConcern(REPLICAS_SAFE);
O WriteConcern também pode ser configurado por operação especificando-o no comando
de gravação:
WriteResult result = shopping.insert(order, REPLICAS_SAFE);
Existe um equilíbrio que precisa ser cuidadosamente pensado com base nas necessidades
do seu aplicativo e em seus requisitos de negócio. Dessa forma, pode-se descobrir quais
configurações fazem sentido para slaveOk durante a leitura ou qual nível de segurança se
deseja durante a gravação com WriteConcern.
9.2.2 Transações
Transações, no sentido tradicional de SGBDRs, significam que você pode começar a
modificar o banco de dados com os comandos insert, update e delete em diferentes tabelas
e, depois, decidir se quer manter as mudanças ou desfazê-las, usando os comandos commit
ou rollback. Essas construções, geralmente, não estão disponíveis em soluções NoSQL –
uma gravação é bem-sucedida ou falha. Transações no nível de um único documento são
conhecidas como transações atômicas. Transações envolvendo mais de uma operação não
são possíveis, embora existam produtos como o RavenDB, que suportam transações com
múltiplas operações.
Por padrão, todas as gravações são reportadas como bem-sucedidas. Um controle mais
minucioso sobre a gravação pode ser obtido utilizando-se o parâmetro WriteConcern.
Garantimos que o pedido (order) seja gravado em mais de um nodo antes que seja
informado como tendo sido bem-sucedido utilizando WriteConcern.REPLICAS_SAFE. Níveis
diferentes de WriteConcern permitem que você escolha o nível de segurança durante as
gravações: por exemplo, ao escrever entradas em log, você pode utilizar o menor nível de
segurança, WriteConcern.NONE.
final Mongo mongo = new Mongo(mongoURI);
mongo.setWriteConcern(REPLICAS_SAFE);
DBCollection shopping = mongo.getDB(orderDatabase)
.getCollection(shoppingCollection);
try {
WriteResult result = shopping.insert(order, REPLICAS_SAFE);
// As gravações chegaram ao primário e pelo menos ao secundário
} catch (MongoException writeException) {
// As gravações não chegaram a um mínimo de dois nodos, incluindo o primário
dealWithWriteFailure(order, writeException);
}
9.2.3 Disponibilidade
Segundo o teorema CAP (p. 91), podemos escolher apenas duas destas opções:
Consistência, Disponibilidade e Tolerância a partições. Bancos de dados de documentos
tentam melhorar a disponibilidade replicando os dados, utilizando a configuração mestre-
escravo. Os mesmos dados ficam disponíveis em múltiplos nodos, os quais os clientes
podem acessar mesmo quando o nodo primário estiver indisponível. Geralmente, o código
do aplicativo não tem de determinar se o nodo primário está disponível ou não. O
MongoDB implementa a replicação, fornecendo alta disponibilidade ao utilizar os
conjuntos de réplicas.
Em um conjunto de réplicas, há dois ou mais nodos participando de uma replicação
assíncrona mestre-escravo. Os nodos do conjunto elegem, entre eles, o mestre (também
chamado de primário). Supondo que todos os nodos tenham direitos iguais de votação,
alguns nodos podem ser favorecidos por estarem mais próximos dos outros servidores, por
terem mais RAM e assim por diante; os usuários podem alterar isso atribuindo uma
prioridade – um número entre 0 e 1.000 – para um nodo.
Todas as solicitações vão para o nodo mestre e os dados são replicados nos nodos
escravos. Se o nodo mestre falhar, os nodos restantes no conjunto de réplicas votam entre
si para eleger um novo mestre; todas as futuras solicitações são direcionadas para o novo
mestre e os nodos escravos começam a obter os dados do novo mestre. Quando o nodo
que falhou ficar online novamente, ele volta a participar como um escravo e se equipara
aos outros nodos ao trazer os dados de que precisa para se atualizar.
A figura 9.1 é uma configuração de exemplo de conjuntos de réplicas. Temos dois nodos,
o mongo A e o mongo B, executando o banco de dados MongoDB no datacenter principal,
e um nodo, o mongo C, no datacenter secundário. Se quisermos que os nodos do
datacenter principal sejam eleitos como primários, podemos atribuir a eles uma prioridade
maior do que a dos outros nodos. Mais nodos podem ser adicionados aos conjuntos de
réplicas sem ter de colocá-los em modo offline.
Figura 9.1 – Configuração de conjunto de réplicas com prioridade maior atribuída a nodos
no mesmo datacenter.
O aplicativo grava ou lê a partir do nodo primário (mestre). Quando a conexão é
estabelecida, o aplicativo somente precisa se conectar a um nodo (primário ou não, não
importa) no conjunto de réplicas e o restante dos nodos são descobertos automaticamente.
Quando o nodo primário falha, o driver comunica-se com o novo primário eleito pelo
conjunto de réplicas. O aplicativo não tem de gerenciar falhas na comunicação ou critérios
de seleção de nodos. Utilizar conjuntos de réplicas lhe confere a possibilidade de ter um
armazenamento de dados de documentos altamente disponível.
Conjuntos de réplicas geralmente são utilizados para redundância de dados, recuperação
automática de falhas, ampliação na capacidade de leitura, manutenção de servidor sem
tirar o aplicativo do ar e recuperação após desastres. Configurações de disponibilidade
semelhantes podem ser obtidas com CouchDB, RavenDB, Terrastore e outros produtos.
9.2.5 Escalabilidade
A ideia de escalar consiste em adicionar nodos ou alterar o armazenamento de dados sem
simplesmente migrar o banco de dados para uma máquina mais potente. Não estamos
falando em fazer alterações no aplicativo para lidar com mais carga; em vez disso, estamos
interessados em quais características o banco de dados possui, de modo que ele possa lidar
com mais carga.
A escalabilidade para lidar com grandes quantidades de leitura pode ser alcançada
adicionando-se mais escravos de leitura, de forma que todas as leituras possam ser
direcionadas para os escravos. Dado um aplicativo com muita leitura, com o nosso cluster
de conjunto de réplicas de três nodos, podemos adicionar mais capacidade de leitura ao
cluster à medida que a carga de leitura aumenta. Isso tudo simplesmente acrescentando
mais nodos escravos ao conjunto de réplicas para executar leituras com a flag slaveOk
(Figura 9.2). Essa é a escalabilidade horizontal para leituras.
Linha Linha
Coluna (a mesma para todas as linhas) Coluna (podem ser diferentes por linha)
10.2 Características
Começaremos examinando a maneira como os dados são estruturados no Cassandra. A
unidade básica de armazenamento do Cassandra é uma coluna. Uma coluna no Cassandra
consiste em um par de nome-valor em que o nome também se comporta como a chave.
Cada um desses pares de chave-valor é uma única coluna e é sempre armazenada com um
valor de timestamp. O timestamp é utilizado para expirar os dados, resolver conflitos de
gravação, lidar com dados desatualizados e outras ações. Assim que os dados da coluna
não tiverem mais uso, o espaço pode ser recuperado, posteriormente, durante a fase de
compactação.
{
name: "firstName",
value: "Martin",
timestamp: 12345667890
}
A coluna possui uma chave firstName e o valor Martin, além de possuir um timestamp
associado. Uma linha é uma coleção de colunas anexadas ou associadas a uma chave; uma
coleção de linhas semelhantes constitui uma família de colunas. Quando as colunas de
uma família de colunas são simples, ela é conhecida como família de colunas padrão.
// família de colunas
{
// linha
"pramod-sadalage" : {
firstName: "Pramod",
lastName: "Sadalage",
lastVisit: "2012/12/12"
}
// linha
"martin-fowler" : {
firstName: "Martin",
lastName: "Fowler",
location: "Boston"
}
}
Cada família de colunas pode ser comparada a um contêiner de linhas em uma tabela de
SGBDR, onde a chave identifica a linha, que é constituída de múltiplas colunas. A
diferença é que as linhas não têm de ter as mesmas colunas, e novas colunas podem ser
adicionadas a qualquer linha, a qualquer momento, sem a obrigatoriedade de ter de
adicioná-las às outras linhas também. Temos a linha pramod-sadalage e a linha martin-
fowler, com colunas diferentes; ambas são parte da mesma família de colunas.
Quando uma coluna corresponde a um mapa de colunas, então temos uma supercoluna,
que consiste em um nome e um valor, sendo que o valor é um mapa de colunas. Pense em
uma supercoluna como um contêiner de colunas.
{
name: "book:978-0767905923",
value: {
author: "Mitch Albon",
title: "Tuesdays with Morrie",
isbn: "978-0767905923"
}
}
Quando utilizamos supercolunas para criar uma família de colunas, obtemos uma
família de supercolunas.
// família de supercolunas
{
// linha
name: "billing:martin-fowler",
value: {
address: {
name: "address:default",
value: {
fullName: "Martin Fowler",
street:"100 N. Main Street",
zip: "20145"
}
},
billing: {
name: "billing:default",
value: {
creditcard: "8888-8888-8888-8888",
expDate: "12/2016"
}
}
}
// linha
name: "billing:pramod-sadalage",
value: {
address: {
name: "address:default",
value: {
fullName: "Pramod Sadalage",
street:"100 E. State Parkway",
zip: "54130"
}
},
billing: {
name: "billing:default",
value: {
creditcard: "9999-8888-7777-4444",
expDate: "01/2016"
}
}
}
}
Famílias de supercolunas são eficazes para manter dados relacionados agrupados.
Porém, mesmo quando algumas das colunas não forem necessárias durante a maior parte
do tempo, estas colunas ainda assim são lidas e desserializadas pelo Cassandra – o que
pode não ser o ideal.
O Cassandra coloca as famílias de colunas padrão e supercolunas em keyspaces. Um
keyspace é semelhante a um banco de dados em SGBDRs, em que todas as famílias de
colunas relacionadas ao aplicativo ficam armazenadas. Keyspaces têm de ser criados de
modo que as famílias de colunas possam ser atribuídas a eles:
create keyspace ecommerce
10.2.1 Consistência
Quando uma gravação é recebida pelo Cassandra, os dados são gravados primeiro em um
commit log (registro de operações), depois em uma estrutura na memória conhecida como
memtable. Uma operação de gravação é considerada bem-sucedida quando for gravada no
commit log e na memtable. As gravações são colocadas em lotes na memória e gravadas
periodicamente em estruturas conhecidas como SSTable. As SSTables não são gravadas
novamente após terem sido carregadas; se houver alterações nos dados, uma nova SSTable
é gravada. SSTables não utilizadas são recuperadas pela compactação.
Vamos examinar a operação de leitura para ver como as configurações de consistência as
afetam. Se tivermos uma configuração de consistência igual a ONE (um) como o padrão para
todas as operações de leitura, então, quando for feita uma solicitação de leitura, o
Cassandra retorna os dados da primeira réplica, mesmo se os dados forem antigos. Se esse
for o caso, leituras subsequentes obterão os dados mais recentes; esse processo é
conhecido como reparação de leitura. O nível baixo de consistência é bom para ser
utilizado quando você não se importar se obtiver dados antigos e/ou se tiver requisitos de
desempenho de leitura altos.
De forma semelhante, se você estiver realizando gravações, o Cassandra grava no commit
log de um nodo e retorna uma resposta para o cliente. A consistência igual a ONE é eficaz se
você tiver requisitos de desempenho muito altos e, além disso, não se importar se algumas
das gravações forem perdidas – o que pode acontecer se o nodo ficar indisponível antes de
a gravação ser replicada nos outros nodos.
quorum = new ConfigurableConsistencyLevel();
quorum.setDefaultReadConsistencyLevel(HConsistencyLevel.QUORUM);
quorum.setDefaultWriteConsistencyLevel(HConsistencyLevel.QUORUM);
Utilizar a configuração de consistência QUORUM para operações de leitura e de gravação
assegura que a maioria dos nodos responderá à leitura, e a coluna com o timestamp mais
recente será retornada para o cliente, enquanto as réplicas que não possuírem os dados
mais recentes são reparadas via operações de reparo de leitura. Durante as operações de
gravação, a configuração de consistência QUORUM significa que a gravação tem de ser
propagada para a maioria dos nodos antes de ser considerada bem-sucedida e antes de o
cliente ser notificado.
Utilizar um nível de consistência ALL significa que todos os nodos terão de responder a
leituras ou gravações, o que tornará o cluster intolerante a falhas – mesmo quando um
único nodo estiver indisponível, a gravação ou a leitura são bloqueadas e informadas como
falha. Depende, portanto, dos projetistas do sistema ajustarem os níveis de consistência na
medida em que os requisitos do aplicativo mudarem. Dentro do mesmo aplicativo pode
haver diferentes necessidades de consistência; eles também podem mudar baseados em
cada operação. Por exemplo, há diferentes necessidades de consistência entre mostrar as
resenhas de um produto e fazer a leitura do status do último pedido feito pelo cliente.
Durante a criação do keyspace, podemos configurar o número de réplicas dos dados que
precisamos armazenar. Esse número determina o fator de replicação dos dados. Se você
tiver um fator de replicação igual a três, os dados serão copiados para três nodos. Ao
gravar e ler dados com o Cassandra, se você especificar o valor de consistência igual a 2,
obterá esse R + W maior do que o fator de replicação (2 + 2 > 3), o que dá melhor
consistência durante as gravações e leituras.
Podemos executar o comando de reparo do nodo para o keyspace e fazer o Cassandra
comparar cada chave pela qual é responsável com o resto das réplicas. Como essa
operação é custosa, também podemos reparar apenas uma família de colunas específica ou
uma lista de famílias de colunas:
repair ecommerce
repair ecommerce customerInfo
Enquanto um nodo estiver indisponível, os dados que devem ser armazenados por ele
são passados para outros nodos. Quando o nodo ficar online novamente, as alterações
feitas nos dados são retornadas para ele. Essa técnica é conhecida como hinted handoff.
Ela permite uma recuperação mais rápida de nodos que tenham tido problemas.
10.2.2 Transações
O Cassandra não tem transações no sentido tradicional – onde poderíamos iniciar
múltiplas gravações e depois decidir se confirmaríamos as alterações ou não. No
Cassandra, uma gravação é atômica em relação a linha, o que significa que a inserção ou
atualização em colunas para uma determinada chave de linha será tratada como uma única
gravação, podendo ser bem-sucedida ou falha. As gravações são gravadas primeiramente
no commit log e em memtables e somente são consideradas válidas quando tanto a
gravação no commit log quanto a gravação na memtable tiverem sido bem-sucedidas. Se
um nodo ficar indisponível, o commit log é utilizado para aplicar as alterações no nodo, da
mesma forma que o redo log no Oracle.
Você pode utilizar bibliotecas externas de transações, como a ZooKeeper [ZooKeeper],
para sincronizar suas gravações e leituras. Também há bibliotecas, como a Cages [Cages],
que permitem que você envolva suas transações no ZooKeeper.
10.2.3 Disponibilidade
O Cassandra é, desde sua concepção original, altamente disponível, uma vez que não há
um mestre no cluster e todos os nodos têm, neste, o mesmo status. A disponibilidade de
um cluster pode ser aumentada ao se reduzir o nível de consistência das solicitações. A
disponibilidade é coordenada pela fórmula (R + W) > N (“Quóruns”, p. 97), em que W é o
número mínimo de nodos nos quais a gravação deve ser realizada com sucesso, R é o
número mínimo de nodos que devem responder com sucesso a uma leitura e N é o número
de nodos que participam da replicação dos dados. Você pode ajustar a disponibilidade
alterando os valores de R e W para um valor fixo de N.
Em um cluster de 10 nodos do Cassandra, com um fator de replicação para o keyspace
configurado como 3 (N = 3), se configurarmos R = 2 e W = 2, então teremos (2 + 2) > 3.
Neste cenário, quando um nodo fica indisponível, a disponibilidade não é muito afetada,
uma vez que os dados podem ser recuperados a partir dos outros dois nodos. Se W = 2 e R =
1, quando dois nodos estiverem indisponíveis, o cluster não estará disponível para
gravação, mas ainda poderá ser lido. De modo semelhante, se R = 2 e W = 1, podemos
gravar, mas o cluster não estará disponível para leitura. Com a equação R + W > N, você
tomará decisões conscientes sobre o balanceamento da consistência.
Você deve configurar seus keyspaces e operações de gravação/leitura baseando-se em
suas necessidades – disponibilidade maior para gravação ou disponibilidade maior para
leitura.
10.2.5 Escalabilidade
Para fazer um cluster existente crescer em escala no Cassandra, basta adicionar mais
nodos. Como nenhum nodo é mestre, quando adicionamos nodos ao cluster, estamos
melhorando sua capacidade de suportar mais gravações e leituras. Esse tipo de
escalabilidade horizontal permite que você obtenha o máximo de uptime, uma vez que o
cluster continua a servir as solicitações dos clientes enquanto novos nodos são adicionados
a ele.
10.3.3 Contadores
Muitas vezes, em aplicativos web, você precisa contar e categorizar visitantes de uma
página para fazer análises. Você pode utilizar CounterColumnType durante a criação de uma
família de colunas.
CREATE COLUMN FAMILY visit_counter
WITH default_validation_class=CounterColumnType
AND key_validation_class=UTF8Type AND comparator=UTF8Type;
Uma vez que uma família de colunas é criada, você pode ter colunas arbitrárias para cada
página visitada e por cada usuário no aplicativo web.
INCR visit_counter['mfowler'][home] BY 1;
INCR visit_counter['mfowler'][products] BY 1;
INCR visit_counter['mfowler'][contactus] BY 1;
Incrementando contadores utilizando CQL:
UPDATE visit_counter SET home = home + 1 WHERE KEY='mfowler'
11.2 Recursos
Há muitos bancos de dados de grafos disponíveis, tais como o Neo4J [Neo4J], o Infinite
Graph [Infinite Graph], o OrientDB [OrientDB] ou o FlockDB [FlockDB] (que é um caso
especial: um banco de dados de grafos que suporta apenas relacionamentos em uma única
profundidade ou listas de adjacência, em que você não pode percorrer mais de um nível de
profundidade para relacionamentos). Pegaremos o Neo4J como representante das soluções
para banco de dados de grafos, para discutir como elas funcionam e como podem ser
utilizadas para resolver problemas de aplicativos.
Com o Neo4J, criar um grafo é simples: basta criar dois nos nodos e depois um
relacionamento. Criaremos dois nodos, Martin e Pramod:
Node martin = graphDb.createNode();
martin.setProperty("name", "Martin");
Node pramod = graphDb.createNode();
pramod.setProperty("name", "Pramod");
Atribuímos à propriedade name dos dois nodos os valores Martin e Pramod. Assim que
tivermos mais de um nodo, poderemos criar um relacionamento:
martin.createRelationshipTo(pramod, FRIEND);
pramod.createRelationshipTo(martin, FRIEND);
Temos de criar relacionamentos entre os nodos em ambas as direções, pois a direção do
relacionamento é importante: por exemplo, um nodo de produto pode ser curtido por um
usuário, mas o produto não pode curtir o usuário. Essa direcionalidade ajuda no projeto
de um modelo de domínio rico (Figura 11.2). Os nodos conhecem os relacionamentos de
entrada (INCOMING) e de saída (OUTGOING) que podem ser percorridos em ambas as direções.
11.2.1 Consistência
Já que bancos de dados de grafos operam em nodos conectados, a maioria das soluções
deste tipo de banco de dados, geralmente, não suporta a distribuição de nodos em
servidores diferentes. Há algumas soluções, entretanto, que suportam a distribuição de
nodos em um cluster de servidores, como o Infinite Graph. Dentro de um único servidor,
os dados são sempre consistentes, especialmente no Neo4J, que é totalmente compatível
com as propriedades ACID. Ao executar o Neo4J em um cluster, uma gravação no mestre
acaba sendo sincronizada com os escravos, enquanto que os escravos estão sempre
disponíveis para leitura. Gravações nos escravos são permitidas e imediatamente
sincronizadas com o mestre; outros escravos, porém, não serão sincronizados
imediatamente – eles terão de esperar que os dados se propaguem a partir do mestre.
Bancos de dados de grafos garantem consistência por meio de transações. Eles não
permitem relacionamentos pendentes: o nodo inicial e o nodo final sempre têm de existir e
nodos somente podem ser excluídos se não tiverem relacionamentos associados a eles.
11.2.2 Transações
O Neo4J é compatível com as propriedades ACID. Antes de alterar quaisquer nodos ou
adicionar algum relacionamento a nodos já existentes, temos de iniciar uma transação. Se
não envolvermos as operações em transações, obteremos uma exceção
NotInTransactionException. Operações de leitura podem ser executadas sem iniciar uma
transação.
Transaction transaction = database.beginTx();
try {
Node node = database.createNode();
node.setProperty("name", "NoSQL Essencial");
node.setProperty("published", "2012");
transaction.success();
} finally {
transaction.finish();
}
No código anterior, iniciamos uma transação no banco de dados, depois criamos um
nodo e configuramos as propriedades dele. Marcamos a transação como bem-sucedida
(success) e finalmente a completamos com um finish. Uma transação precisa ser marcada
como bem-sucedida, senão o Neo4J supõe que ela falhou e a desfaz quando o comando
finish for executado. Da mesma maneira, utilizar o success, mas omitir o finish também
não confirmará os dados no banco de dados. Essa maneira de gerenciar transações deve ser
lembrada durante o desenvolvimento, uma vez que difere do modo padrão de executar
transações em um SGBDR.
11.2.3 Disponibilidade
O Neo4J, a partir da versão 1.8, obtém alta disponibilidade ao permitir escravos
replicados. Esses escravos também podem lidar com gravações: quando algo é gravado
neles, eles sincronizam a gravação com o mestre atual e essa gravação é confirmada,
primeiro no mestre e depois no escravo. Outros escravos receberão a atualização
eventualmente. Outros bancos de dados de grafos, como o Infinite Graph e o FlockDB,
fornecem armazenamento distribuído para os nodos.
O Neo4J utiliza o Apache ZooKeeper [ZooKeeper] para registrar os IDs da última
transação persistida em cada nodo escravo e em cada nodo mestre atual. Assim que um
servidor é iniciado, ele se comunica com o ZooKeeper e descobre qual servidor é o mestre.
Se o servidor é o primeiro a entrar no cluster, torna-se o mestre; quando um servidor falha,
o cluster elege um mestre a partir dos nodos disponíveis, fornecendo, assim, alta
disponibilidade.
11.2.5 Escalabilidade
Em bancos de dados NoSQL, uma das técnicas de crescimento em escala comumente
utilizada é a fragmentação, na qual os dados são divididos e distribuídos em diferentes
servidores. Com bancos de dados de grafos, a fragmentação é difícil, uma vez que esse tipo
de banco de dados não é orientado a agregados, mas, sim, a relacionamentos. Como todo
nodo pode ser relacionado a um outro qualquer, armazenar nodos relacionados no mesmo
servidor é melhor para a travessia do grafo. Percorrer um grafo quando os nodos estão em
máquinas diferentes não é bom para o desempenho. Conhecendo essa limitação dos
bancos de dados, ainda assim podemos ampliá-los utilizando algumas técnicas comuns
descritas por Jim Webber [Webber Neo4J Scaling].
De modo geral, há três maneiras de fazer os bancos de dados em grafos crescerem em
escala. Já que as máquinas atuais possuem bastante memória RAM, podemos adicionar tal
memória ao servidor, de modo que o conjunto de nodos e relacionamentos sendo
trabalhados esteja inteiramente na memória. Esta técnica somente é útil se o conjunto de
dados no qual estivermos trabalhando couber em uma quantidade realista de RAM.
Podemos melhorar o desempenho na leitura do banco de dados adicionando aos dados
mais escravos com acesso apenas de leitura, com todas as gravações sendo enviadas para o
mestre. Esse padrão de muitos servidores, de gravar uma vez e ler, é uma técnica
comprovada em clusters MySQL e é realmente útil quando o conjunto de dados for
suficientemente grande para não caber na memória RAM de uma única máquina, mas
suficientemente pequeno para ser replicado em múltiplas máquinas. Os escravos também
podem contribuir com a disponibilidade e a melhora na leitura, uma vez que podem ser
configurados para que nunca se tornem um mestre, permanecendo sempre com a função
de fazer apenas a leitura.
Quando o tamanho do conjunto de dados torna impraticável a replicação, podemos
fragmentar (“Fragmentação”, p. 74) os dados a partir do lado do aplicativo utilizando o
conhecimento específico do domínio. Por exemplo, nodos que se relacionam com a
América do Norte podem ser criados em um servidor, enquanto os que se relacionam com
a Ásia são criados em outro. Essa fragmentação em nível de aplicativo precisa
compreender que nodos são armazenados em bancos de dados fisicamente diferentes
(Figura 11.3).
Geralmente, uma migração de esquema de banco de dados já é um projeto por si só. Para
instalar as alterações no esquema, scripts de alteração do banco de dados são
desenvolvidos utilizando técnicas de diferenciação para todas as alterações no banco de
dados de desenvolvimento. Essa abordagem que envolve a criação de scripts de migração
durante o desenvolvimento/lançamento é propensa a erros e não suporta métodos de
desenvolvimento ágil.
Figura 12.4 – O DBDeploy melhorando o banco de dados com a alteração número 007.
A melhor forma de integrar-se com o resto dos desenvolvedores é utilizando o
repositório de controle de versões do seu projeto para armazenar todos esses scripts de
alteração, de modo que você possa registrar a versão do software e o banco de dados em
um mesmo lugar, eliminando possíveis incompatibilidades entre o banco de dados e o
aplicativo. Há muitas outras ferramentas para realizar tais atualizações, incluindo o
Liquibase [Liquibase], o MyBatis Migrator [MyBatis Migrator] e o DBMaintain
[DBMaintain].
12.2.2 Migrações em projetos legados
Nem todo projeto está livre de restrições: como implementar migrações quando um
aplicativo existente estiver em produção? Descobrimos que pegar um banco de dados e
extrair sua estrutura em scripts, junto com todo o código do banco de dados e quaisquer
dados de referência, funciona como uma base para o projeto. Essa base não deve conter
dados transacionais. Assim que estiver pronta, alterações posteriores podem ser efetuadas
utilizando as técnicas de migração descritas anteriormente (Figura 12.5).
Figura 13.2 – Uso de depósitos de chave-valor para guardar os dados do carrinho de compras
e da sessão.
Se precisarmos recomendar produtos para clientes quando eles estiverem colocando-os
em seus carrinhos de compras – por exemplo, “seus amigos também compraram esses
produtos” ou “seus amigos compraram esses acessórios para esse produto” –, então, a
introdução de um armazenamento de dados de grafos ao conjunto é importante (Figura
13.3).
Figura 14.1 – Em um sistema típico, a notícia de uma mudança causa uma alteração no
estado do aplicativo.
Figura 14.2 – Com event source, o sistema armazena cada evento, juntamente ao estado
derivado do aplicativo.
Como consequência, em um sistema event-sourced, armazenamos todos os eventos que
tenham causado uma alteração de estado do sistema no registro de eventos e o estado do
aplicativo é inteiramente derivável desse registro de eventos. A qualquer momento,
podemos nos desvencilhar do estado do aplicativo e recriá-lo a partir do registro de
eventos.
Em teoria, registros de eventos são tudo de que você precisa, porque sempre que for
necessário poderá recriar o estado do aplicativo, executando novamente o registro de
eventos. Na prática, isso pode ficar lento demais. Consequentemente, em geral, é melhor
permitir o armazenamento e a recriação do estado do aplicativo em um snapshot. Um
snapshot é projetado para persistir a imagem da memória otimizada para recuperação
rápida do estado. É uma ajuda na otimização, de modo que nunca deve ter precedência
sobre o registro de eventos quanto à sua autoridade sobre os dados.
A frequência com a qual você tira snapshots depende do tempo de atividade. O snapshot
não precisa estar completamente atualizado, já que você pode recriar a memória
carregando o snapshot mais recente e depois executando novamente todos os eventos
processados desde que esse snapshot foi tirado. Um exemplo seria tirar um snapshot todas
as noites; se o sistema saísse do ar durante o dia, você poderia recarregar o snapshot da
noite anterior seguido pelos eventos de hoje. Se você conseguir fazer isso de modo
suficientemente rápido, tudo ficará bem.
Para obter um registro inteiro de cada alteração no estado do seu aplicativo, você precisa
continuar voltando no registro de eventos até o início de seu aplicativo. Em muitos casos,
porém, não é necessário ter um registro tão antigo, de modo que você pode guardar
eventos mais antigos em um snapshot e utilizar o registro de eventos somente depois da
data dele.
Utilizar event sourcing tem diversas vantagens. Você pode transmitir eventos para
múltiplos sistemas, cada um podendo criar um estado diferente do aplicativo para
diferentes propósitos (Figura 14.3). Para sistemas com muita atividade de leitura, você
pode fornecer múltiplos nodos de leitura, com esquemas potencialmente diferentes, ao
mesmo tempo em que concentra as gravações em um sistema diferente de processamento
(uma abordagem amplamente conhecida como CQRS [CQRS]).
Event sourcing também é uma plataforma efetiva para analisar informações de
históricos, já que você pode replicar qualquer estado passado no registro de eventos. Você
também pode investigar facilmente cenários alternativos, introduzindo eventos hipotéticos
em um processador de análises.
Figura 14.3 – Os eventos podem ser transmitidos para múltiplos sistemas de exibição.
O event sourcing é um pouco mais complexo – notavelmente, você tem de se assegurar
de que todas as mudanças de estado sejam capturadas e armazenadas como eventos.
Algumas estruturas e ferramentas podem tornar isso inconveniente. Qualquer colaboração
com sistemas externos precisa levar o event sourcing em consideração; por isso, você
precisará ser cauteloso com os efeitos colaterais externos ao executar novamente os
eventos para recriar um estado da aplicação.
A esta altura do livro, já examinamos muitas questões gerais sobre as quais você precisa ter
ciência para tomar decisões no novo mundo da persistência poliglota. Agora é o momento
de falarmos sobre a escolha de seus bancos de dados para futuros trabalhos de
desenvolvimento. Naturalmente, não conhecemos suas circunstâncias específicas, de
modo que não podemos dar-lhe uma resposta nem podemos reduzi-la a um simples
conjunto de regras a seguir. Além disso, ainda estamos nos primórdios do uso em
produção dos sistemas NoSQL, de forma que falta maturidade a esse conhecimento – em
alguns anos, talvez pensemos de modo diferente.
Vemos dois grandes motivos para considerar um banco de dados NoSQL: a
produtividade do programador e o desempenho no acesso aos dados. Em diferentes
cenários, essas forças podem ser complementares ou contraditórias. Ambas são difíceis de
avaliar no início de um projeto, o que é complexo, já que sua escolha por um modelo de
armazenamento de dados é tão difícil de abstrair quanto de permitir que você mude de
ideia posteriormente.
Bibliografia
[Cages] http://code.google.com/p/cages.
[Cassandra] http://cassandra.apache.org.
[Chang etc.] Chang, Fay, Jeffrey Dean, Sanjay Ghemawat, Wilson C. Hsieh, Deborah A.
Wallach, Mike Burrows, Tushar Chandra, Andrew Fikes, and Robert E. Gruber. Bigtable: A
Distributed Storage System for Structured Data. http://research.google.com/archive/bigtable-
osdi06.pdf.
[CouchDB] http://couchdb.apache.org.
[CQL] www.slideshare.net/jericevans/cql-sql-in-cassandra.
[CQRS] http://martinfowler.com/bliki/CQRS.html.
[C-Store] Stonebraker, Mike, Daniel Abadi, Adam Batkin, Xuedong Chen, Mitch
Cherniack, Miguel Ferreira, Edmond Lau, Amerson Lin, Sam Madden, Elizabeth O’Neil,
Pat O’Neil, Alex Rasin, Nga Tran, and Stan Zdonik. C-Store: A Columnoriented DBMS.
http://db.csail.mit.edu/projects/cstore/vldb.pdf.
[Cypher] http://docs.neo4j.org/chunked/1.6.1/cypher-query-lang.html.
[Daigneau] Daigneau, Robert. Service Design Patterns. Addison-Wesley. 2012. ISBN
032154420X.
[DBDeploy] http://dbdeploy.com.
[DBMaintain] www.dbmaintain.org.
[Dean and Ghemawat] Dean, Jeffrey and Sanjay Ghemawat. MapReduce: Simplified Data Processing
on Large Clusters. http://static.usenix.org/event/osdi04/tech/full_papers/dean/dean.pdf.
[Dijkstra’s] http://en.wikipedia.org/wiki/Dijkstra%27s_algorithm.
[Evans] Evans, Eric. Domain-Driven Design. Addison-Wesley. 2004. ISBN 0321125215.
[FlockDB] https://github.com/twitter/flockdb.
[Fowler DSL] Fowler, Martin. Domain-Specific Languages. Addison-Wesley. 2010. ISBN
0321712943.
[Fowler lmax] Fowler, Martin. The LMAX Architecture.
http://martinfowler.com/articles/lmax.html.
[Fowler PoEAA] Fowler, Martin. Patterns of Enterprise Application Architecture. Addison-Wesley.
2003. ISBN 0321127420.
[Fowler UML] Fowler, Martin. UML Distilled. Addison-Wesley. 2003. ISBN 0321193687.
[Gremlin] https://github.com/tinkerpop/gremlin/wiki.
[Hadoop] http://hadoop.apache.org/mapreduce.
[HamsterDB] http://hamsterdb.com.
[Hbase] http://hbase.apache.org.
[Hector] https://github.com/rantav/hector.
[Hive] http://hive.apache.org.
[Hohpe and Woolf] Hohpe, Gregor and Bobby Woolf. Enterprise Integration Patterns. Addison-
Wesley. 2003. ISBN 0321200683.
[HTTP] Fielding, R., J. Gettys, J. Mogul, H. Frystyk, L. Masinter, P. Leach, and T.
Berners-Lee. Hypertext Transfer Protocol—HTTP/1.1. www.w3.org/Protocols/rfc2616/rfc2616.html.
[Hypertable] http://hypertable.org.
[Infinite Graph] www.infinitegraph.com.
[JSON] http://json.org.
[LevelDB] http://code.google.com/p/leveldb.
[Liquibase] www.liquibase.org.
[Lucene] http://lucene.apache.org.
[Lynch and Gilbert] Lynch, Nancy and Seth Gilbert. Brewer’s conjecture and the feasibility of consistent,
available, partition-tolerant web services. http://lpd.epfl.ch/sgilbert/pubs/BrewersConjecture-
SigAct.pdf.
[Memcached] http://memcached.org.
[MongoDB] www.mongodb.org.
[Monitoring] www.mongodb.org/display/DOCS/MongoDB+Monitoring+Service.
[MyBatis Migrator] http://mybatis.org.
[Neo4J] http://neo4j.org.
[NoSQL Debrief] http://blog.oskarsson.nu/post/22996140866/nosql-debrief.
[NoSQL Meetup] http://nosql.eventbrite.com.
[Notes Storage Facility] http://en.wikipedia.org/wiki/IBM_Lotus_Domino.
[OpsCenter] www.datastax.com/products/opscenter.
[OrientDB] www.orientdb.org.
[Oskarsson] Private Correspondence.
[Pentaho] www.pentaho.com.
[Pig] http://pig.apache.org.
[Pritchett] www.infoq.com/interviews/dan-pritchett-ebay-architecture.
[Project Voldemort] http://project-voldemort.com.
[RavenDB] http://ravendb.net.
[Redis] http://redis.io.
[Rekon] https://github.com/basho/rekon.
[Riak] http://wiki.basho.com/Riak.html.
[Solr] http://lucene.apache.org/solr.
[Strozzi NoSQL] www.strozzi.it/cgi-bin/CSA/tw7/I/en_US/NoSQL.
[Tanenbaum and Van Steen] Tanenbaum, Andrew and Maarten Van Steen. Distributed
Systems. Prentice-Hall. 2007. ISBN 0132392275.
[Terrastore] http://code.google.com/p/terrastore.
[Vogels] Vogels, Werner. Eventually Consistent—Revisited.
www.allthingsdistributed.com/2008/12/eventually_consistent.html.
[Webber Neo4J Scaling] http://jim.webber.name/2011/03/22/ef4748c3-6459-40b6-bcfa-
818960150e0f.aspx.
[ZooKeeper] http://zookeeper.apache.org.