Atenção: esse artigo tem um vídeo complementar. Clique e assista!
Uma introdução ao Terracotta ES, um middleware open source para Java que sincroniza objetos de vários processos de forma eficiente e automática e sem exigir nenhuma alteração no seu código.
Para que serve:
Muitas aplicações de missão crítica precisam ser implantadas em cluster, fazer caching agressivo de dados persistentes, conteúdo gerado dinamicamente entre outras informações; ou simplesmente, sincronizar dados de forma imediata e eficiente com outros processos. A maneira mais fácil de satisfazer a estas necessidades é usando um middleware que coloca parte do heap da JVM “na rede”, permitindo que vários processos compartilhem os mesmos objetos. O Terracotta implementa esta idéia de forma transparente e com alto desempenho.
Em que situação o tema é útil:
Aplicações em cluster (Java EE ou não); aplicações que usam um SGBD e necessitam de um cache de segundo nível para melhorar o desempenho; aplicações web que geram muito conteúdo dinâmico, entre muitas outras.
Na Introdução de “Programando com Pools” (Edição 57), comecei dizendo: “A programação às vezes parece ser um campo tomado por uma enorme e confusa variedade de técnicas. (...) Fazendo um esforço de síntese, veremos que existe um número relativamente pequeno de fundamentos que embasam toda a programação.” Pooling é uma dessas técnicas fundamentais; podemos citar outra técnica igualmente fundamental e relacionada, o caching. São idéias parecidas, mas não iguais:
• Um pool é uma coleção de objetos cuja criação é custosa, sendo mais eficiente um protocolo de aquisição / liberação permitindo reciclagem dos mesmos objetos em momentos distintos;
• Um cache é uma coleção de objetos que replicam informações cuja consulta é custosa (como registros de um BD, objetos de outro processo, ou resultados de algum cálculo complexo).
É comum que o mesmo sistema implemente pools e caches: por exemplo, uma ferramenta de persistência OO como o Hibernate ou JPA pode conter pools de conexões com o SGBD, e um cache de entidades persistentes, entre outros.
A maior diferença entre um pool e um cache reside na maneira como os objetos são gerenciados. Num cache, os objetos são diferenciados, sendo identificados por IDs; já num pool, os objetos são “anônimos”, considerados iguais para os propósitos dos seus clientes, bastando pegar qualquer objeto que esteja disponível. Mas ambos são muito parecidos estruturalmente: são coleções que podem ser preenchidas e consultadas, suportando acesso concorrente, estratégias de gerenciamento de recursos (ex.: limitar o número de objetos contidos para evitar a exaustão de recursos externos ou da memória), e outras características comuns.
Uma terceira técnica igualmente fundamental é a distribuição: na minha definição particular, é a capacidade de tratar vários computadores como se fossem um só. Ou, alternativamente, fazer com que uma aplicação utilize diversos recursos – como CPUs, armazenamento, middlewares como SGBDs, e outras aplicações – de forma transparente em relação à sua disposição numa rede. A distribuição é um fator especialmente crítico em sistemas de grande porte (onde espalhamos a aplicação por várias máquinas que funcionam simultaneamente para dividir a carga de trabalho), ou em sistemas de “missão crítica”, com baixa tolerância a falhas (onde replicamos a aplicação em máquinas que funcionam alternadamente, para que falhas isoladas não causem indisponibilidade).
Juntando tudo – como nós desenvolvedores não nascemos para ter uma vida fácil – é fatal que algum dia, tenhamos que juntar todas essas técnicas numa coisa só. Sim, um dia você terá que trabalhar com um pool distribuído, ou um cache distribuído. Quase todo desenvolvedor veterano já se deparou com sistemas que se beneficiariam de tais combinações (embora possa ter percebido o fato ou não). É possível criar soluções ad-hoc para estas necessidades; eu mesmo já fiz isso mais vezes do que gostaria de confessar. Mas é muito mais fácil adotar uma ferramenta que resolva o problema de forma simples e robusta. Neste artigo examinaremos esta ferramenta, o Terracotta.
Antes de seguir a leitura deste artigo, veja a apresentação elaborada por Osvaldo e entenda a importância do Terracotta; sem dúvida uma ferramenta importante e recomendada para todo desenvolvedor.
O problema
Vamos dar um exemplo simples, “mundo real”. Sua aplicação de vendas online tem uma tabela PRODUTO, que contém alguns milhares de registros, um para cada tipo de produto sendo vendido. Essa tabela é extremamente acessada, pois está envolvida no carrinho de compras, faturamento, controle de estoque, etc. – quase todas as entidades e operações importantes da aplicação. É óbvio que o acesso à tabela de produtos irá consumir uma quantidade significativa de recursos, com consultas envolvendo esta tabela várias vezes por segundo.
A solução
A resposta óbvia para este problema é: vamos fazer cache da tabela PRODUTOS. Ao inicializar a aplicação, carregamos esta tabela inteira para a memória, colocando-a em uma ou mais estruturas de dados que refletem as seleções mais comuns da tabela. Por exemplo, podemos ter um
Map<Long, Produto> prodsPorChave;
que permite consultas individuais por PK, e um
Map<Categoria, List<Produto>> prodsPorCategoria;
que responde a uma query “Listar todos os Produtos de uma Categoria”, muito usada no Carrinho de Compras, que a cada request retorna pelo menos um combobox preenchido com dezenas ou centenas destes registros. (Quase sempre os mesmos registros, para a Categoria selecionada.)
Como fazer o cache desta tabela? Podemos carregá-la para a memória, indexá-la naqueles Maps, e então só precisamos fazer um get() no lugar de cada query. Isso costuma funcionar muito bem para “tabelas de domínio” fixas, por exemplo uma tabela ESTADO com as 27 unidades federativas do Brasil, que na prática nunca muda.
Infelizmente, a tabela de Produtos não é uma tabela de Domínio “clássica”. O conjunto de Produtos é bastante estável, mas pode sofrer mudanças com freqüência relativamente alta. Novos Produtos podem ser adicionados toda semana; pequenas atualizações em Produtos existentes, como alterações de preço, podem ocorrer várias vezes por dia.
Algumas aplicações tentam resolver o problema de uma forma simples: todas as atualizações na tabela são feitas através de um método negocial que realiza a mesma operação simultaneamente no BD e nas estruturas de cache em memória. Isso pode ser complicado devido ao controle transacional (atualizações do cache devem acontecer se e somente se a transação faz commit), mas há uma solução simples para isso: basta limpar o cache toda vez que qualquer update for feito, e a próxima transação que tentar ler o cache irá repopulá-lo em demanda (Listagem 1). Se a transação falhar com um rollback, não haverá nenhum problema, além do custo de repopular o cache (com o mesmo conteúdo de antes) na transação seguinte.
Listagem 1. Cache ad-hoc simples, com estratégia de população em demanda.
import java.util.*;
public class CacheProdutos {
private static Map<Long, Produto> cache;
public static synchronized Produto get (Long pk) {
return cache().get(pk);
}
public static synchronized void clear () {
cache = null;
}
private static synchronized Map<Long, Produto> cache () {
if (cache == null) {
// Inicializa o cache a partir de uma query…
}
return cache;
}
}
...