Já usou Memcached?

maio 2nd, 2009 § 2

Cache não é algo que os programadores desconhem. Quem esteve numa universidade, e teve uma aula séria de Arquitetura de Computadores, sabe que a memória é disposta numa hierarquia de caches. E quem já programou em Java, já ouviu falar de inicialização tardia, muito usado em singletons (apesar de não ser só para isso):

public class MinhaClasse {
 
    private static MinhaClasse instance;
 
    public static MinhaClasse getInstance() {
 
    	if (instance == null) {
    		instance = new MinhaClasse();
    	}
 
        return instance;
    }
 
    // outras coisas
}

Esse é um jeito de fazer cache. Apenas deve-se tomar cuidado para que o objeto a ser retornado não seja mutável. Afinal, todos os interessados estão com a mesma instância, e se um dos clientes puderem mudar uma propriedade desse único objeto, todos terão o efeito indesejável de ter um objeto com comportamento diferente sem saber. Detalhe: a implementação de singleton dessa maneira é ingênua, evite-a.

Um outro jeito, comumente usado, de fazer cache é usar session. Pra quem tem uma aplicação web, parece mágico: joga-se uma entidade da persistência na sessão passando uma chave. Ao precisar do mesmo objeto de novo, ao invés de acessar o banco, pega-se o objeto da sessão com a mesma chave usada na escrita. Esse é a estratégia mais comum que eu já vi por aí. Mas existem vários problemas : já vi aplicações com mais código pra cache em sessão do que pro próprio negócio! Além do mais, sessions são únicas para cada usuário no browser, enquanto que a entidade a ser cacheada é, não raro, visível a vários usuários. Significa duas coisas: 1) Não vai ter uma cacheamento efetivo, se 20 usuários se logarem e todos acessarem a mesma entidade duas vezes, haverá vinte cópias do objeto, que será escrita e lida uma vez cada; 2) Ninguém sabe o que um outro usuário pode fazer, se um alterar a entidade, todos os dezenove usuários restantes não terão como saber disso e manterão cópias desatualizadas. Session não foi feita pra cache, e existe até um problema de design: apesar do mapa de sessão pertencer à camada de apresentação, o cache lida com problemas de objetos que surgem na camada de persistência. Dizer que um pode ser usado para implementação do outro resulta em confusões de conceitos.

Uma solução melhor é usar bibliotecas de cache. No Java, um bastante comum é o EHCache — usado principalmente para o cache de segundo nível de Hibernate — que fica na mesma JVM da sua aplicação. Não que seja ruim (tem até uns javeiros roxos que acham que esta é a melhor solução do mundo), mas quando se quer usar um cluster de aplicações web, por exemplo, passa-se a ter vários caches para cada instância de servidor, que é não o desejável. Uma das opções de cache, que fica fora da JVM de sua aplicação, é o Memcached.

A seguir, apresentarei as vantagens do Memcached, seu mecanismo básico junto com uma API do Java, e no final, o uso integrado com o Hibernate.

Características do Memcached

Memcached permite a chamada escalabilidade horizontal, ou seja, é possível usar várias instâncias de memcached em paralelo como se fosse uma única unidade, onde cada processo possui somente parte dos registros. Parece idiota, mas não é. Não é toda infraestrutura que lhe permite isso; bancos de dados tradicionais, sistemas operacionais e máquinas virtuais possuem uma dificuldade tremenda de configurações para que isso seja possível.

Porém, ser fácil de se escalar horizontalmente trouxe certos custos na sua implementação. Exemplo: Não existe replicação de dados automática, se quiser duas instâncias com dados iguais, terá que fazer seu script. Não existe transação e nem lock, pois isso gera contenção. Não existem busca por todos os registros ou por wildcards, pois pode causar demora nos resultados. E não há garantia de durabilidade de objetos armazenados em casos de crash.

E ainda duas características peculiares, uma é que o tempo de expiração no cache não é zerado com consultas (como é tradicional nas sessions de containeres web), ou seja, se você definir como tempo de expiração dois minutos, esse será o tempo que ficará armazenado, independente se houve consulta do registro nesse meio-tempo ou não. Portanto, sempre coloque no memcached objetos que já estejam persistidos em outro lugar. A segunda característica é que o tempo de expiração é definido por segundos que devem ser armazenados, ou seja, uma hora é 3600. Mas apenas se o tempo de permanência for menor que trinta dias, se for maior, o Memcached assume que é o tempo UNIX absoluto, ou seja, 1240699927 significa que o registro deve permanecer em cache até em 25 de abril de 2009 às 22h52min07 em UTC.

Como usar

Se você estiver usando alguma distribuição Linux, com certeza o Memcached pode ser baixado diretamente de seu repositório. Caso você seja um usuário Windows, existem portes do cache para esse Sistema Operacional aqui e aqui. Como só tenho o Fedora, não testei pra ver se funciona, então o Google será melhor pra resolver problemas de instalação no Windows do que eu.

Além disso, é necessário um cliente Memcached, que existem para várias linguagens. No Java, a melhor biblioteca que encontrei é a Spy Memcached, disponível na versão 2.3.1. Baixe o arquivo e coloque no classpath de sua aplicação.

Incialize o Memcached no seu shell dessa maneira:

memcached -p 18000 -vv

A opção -p indica a porta da conexão TCP por onde será “ouvida” (não é necessário, se não informar será assumido o padrão 11211), e o -vv é a opção verbose.

Na sua aplicação Java, você abre um cliente Memcached dessa maneira:

MemcachedClient mem = new MemcachedClient(new InetSocketAddress("localhost", 18000));

Ou seja, passando um endereço de rede ao construtor do MemcachedClient. Caso você queira um cluster de aplicações Memcached, é possível também passar uma lista de endereços de redes, assim:

MemcachedClient mem = new MemcachedClient(new InetSocketAddress[] {
				new InetSocketAddress("localhost", 18000),
				new InetSocketAddress("localhost", 18001),
			});

Obviamente, é necessário duas instâncias de Memcached disponíveis nesses endereços.

As operações que o Memcached suporta são simples, exemplos:

set

Memcached - exemplo do comando SET

A operação set insere um valor para uma dada chave, independente se esta já possuía um valor anterior ou não:

     String marca = "Ford";
     int expira = (int) (System.currentTimeMillis() + 60 * 60 * 1000) / 1000; // uma hora pra expirar
 
     Future<Boolean> valido = mem.set("marca", expira, marca);

Lembre-se também do comportamento dos trinta dias, as duas formas abaixo setam o mesmo período de expiração:

     Future<Boolean> valido = mem.set("marca", 24 * 60 * 60, marca); // um dia para expirar
     Future<Boolean> valido = mem.set("marca", (int) (System.currentTimeMillis() + 24 * 60 * 60 * 1000) / 1000, marca); // também um dia

É possível setar qualquer coisa, inclusive objetos Java que sejam seriálizáveis. Porém, qualquer coisa diferente de um tipo simples não é identificável para programas em outras linguagens que se conectarem a esse mesmo Memcached.

add

Memcached - exemplo do comando ADD

A operação add, apesar do nome, insere um valor para uma dada chave, deste que esta não esteja com um elemento previamente armazenado:

     Pessoa pessoa1 = new Pessoa();
     pessoa1.setId(28L);
     pessoa1.setName("Leonardo");
 
     Future<Boolean> valido1 = mem.add("pessoa", 60 * 60, pessoa1); // valido1 é true
 
     Pessoa pessoa2 = new Pessoa();
     pessoa2.setId(41L);
     pessoa2.setName("Vinicius");
 
     Future<Boolean> valido2 = mem.add("pessoa", 60 * 60, pessoa2); // valido2 é false

replace

Memcached - exemplo do comando REPLACE

A operação replace, ao contrário do add, insere um valor para uma dada chave apenas se esta já estiver anteriormente armazenado:

     int anoLancamento = 2010;
     Future<Boolean> valido1 = mem.replace("anoLancamento", 60 * 60, anoLancamento); // valido1 é false
 
     Future<Boolean> ok = mem.set("anoLancamento", 60 * 60, anoLancamento); // set armazena de qualquer jeito
     ok.get(); // bloqueia até a conclusão
 
     int novoAnoLancamento = 2011;
     Future<Boolean> valido2 = mem.replace("anoLancamento", 60 * 60, novoAnoLancamento); // valido2 é true

get

Memcached - exemplo do comando GET

A operação get busca um registro armazenado anteriormete, dada uma chave:

     String marca = (String) mem.get("marca"); // "Ford", ou null se demorou demais ou expirou

delete

Memcached - exemplo do comando DELETE

A operação delete apaga um valor no Memcached dada a chave. O valor armazenado não é retornado:

     Future<Boolean> valido1 = mem.delete("anoLancamento"); // true
 
     Future<Boolean> valido2 = mem.delete("anoLancamento"); // false

incr

A operação incr incrementa um valor previamente armazenado. Válido somente para números:

     long valor1 = mem.incr("contador", 9, 5); /* o segundo parâmetro é valor a ser incrementado */
                                               /* o terceiro parâmetro (opcional) é o valor inicial caso não haja nenhum par armazenado */
                                               /* valor1 = 5 */
 
     long valor2 = mem.incr("contador", 7); /* se não informasse o valor inicial e esta fosse a primeira chamada, o retorno seria -1 */
                                            /* valor2 = 12 */

decr

A operação decr decrementa um valor previamente armazenado. Válido somente para números:

     long valor1 = mem.decr("contador", 12, 20); /* valor1 = 20 */
     long valor2 = mem.decr("contador", 9);      /* valor2 = 11 */

Lembre-se: os números são só positivos. Se o valor armazenado é 4 e você decrementar 6, o valor final é 0 (zero).

Exemplo de cache para banco de dados

Vamos a uma situação prática. Imagine um banco de consulta de preços de um supermercado. Alguns preços são consultados com bastante frequência e seus preços são raramente alterados. Idealmente, seria interessante cachear esses valores retornados para aliviar a carga do banco. Meu objeto de domínio é um só:

public class ProductDescription implements Serializable {
 
	private long barCode;
 
	private String description;
 
	private BigDecimal price;
 
	private String type;
 
	// getters e setters omitidos
}

Meu acesso aos dados obedece a essa interface:

package br.com.objectzilla.testeDaoMemcached;
 
public interface ProductDescriptionRepository {
 
	ProductDescription getByBarCode(long barCode);
 
	void refreshProductInformation(ProductDescription product);
}

Ou seja, existe um método para retornar um pedido por código de barras (getByBarCode()) e outro que atualiza ou insere um produto (refreshProductInformation()).Também tenho uma classe, implementando essa interface, que executa comandos SQL no banco via JDBC (ProductDescriptionDAO, não mostrado).

O pulo do gato é uma classe, que fica como um Decorator, que realiza o cache, veja:

package br.com.objectzilla.testeDaoMemcached;
 
import net.spy.memcached.MemcachedClient;
 
public class ProductDescriptionCacheDecorator implements ProductDescriptionRepository {
 
	private MemcachedClient memcachedClient;
	private int expiration;
	private ProductDescriptionRepository other;
 
	public ProductDescription getByBarCode(long barCode) {
 
		ProductDescription product = (ProductDescription) memcachedClient.get("product_descr/" + barCode);
		if (product == null) {
			product = other.getByBarCode(barCode);
 
			memcachedClient.add("product_descr/" + barCode, expiration, product);
		}
		return product;
	}
 
	public void refreshProductInformation(ProductDescription product) {
		other.refreshProductInformation(product);
 
		memcachedClient.delete("product_descr/" + product.getBarCode());
	}
 
	public void setMemcachedClient(MemcachedClient memcachedClient) {
		this.memcachedClient = memcachedClient;
	}
 
	public void setExpiration(int expiration) {
		this.expiration = expiration;
	}
 
	public void setOther(ProductDescriptionRepository other) {
		this.other = other;
	}
}

Primeiro, a variável other refere-se a uma outra classe que implementa também ProductDescriptionRepository, como a classe acima é um decorator, natural que essa referência seja de um objeto que realmente faz acesso ao banco. Tem-se também a instância de um MemcachedClient e a instância de um período de expiração, para ser usado para a manipulação de dados do Memcached.

Veja, primeiro, o método getByBarCode, a primeira coisa que se faz é buscar um registro no memcached usando uma chave formada do nome abreviado do objeto seguido do código de barras do produto (o id). Havendo esse registro, retorna-o, e aí a consulta ao banco não é realizada e fim de papo. Se esse registro não estiver no Memcached, aí este é buscado no banco (através de other) e, em sequência, realizada a inserção do objeto no cache, para que as próximas chamadas desse método não façam busca no banco de novo. Lembre-se que o cache expira, e não é apropriado assumir que o objeto está sempre no cache, portanto é sempre necessário ter essa verificação de registro vazio em todas as suas consultas.

Ao inserir ou alterar um registro, o dado no cache fica desatualizado. Por isso, existe uma chamada para remover o ítem após a inserção bem sucedida no método refreshProductInformation(). Poderia haver uma inserção no memcache nesse ponto, mas preferi não fazer, porque de qualquer forma, isso será feito no método de busca.

A aplicação que exercita esse cache está disponível nesse zip, caso queiram estudá-lo com mais detalhes.

Usando como cache de segundo nível do Hibernate

O Hibernate possui suporte a cache de segundo nível, e a implementação mais comum é o EHCache, que vem junto com o framework de persistência. Por ter um cache quase automático (pois você precisa habilitá-lo para cada entidade mapeada), é meio desvantajoso fazer cacheamento manualmente como fiz anteriormente. Incrivelmente, na minha busca por Memcached no Google, me deparei com o plugin hibernate-memcached que usa o Memcached como cache de segundo nível.

Se a opção for essa, as coisas facilitam bastante. Primeiro, anotaremos a entidade ProductDescription, assim:

@Entity
@Table(name="product_description")
@Cache(usage=CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
public class ProductDescription implements Serializable {
 
	@Id
	@Column(name="bar_code")
	private long barCode;
 
	private String description;
 
	private BigDecimal price;
 
	private String type;
 
	// getters e setters
}

Existem as anotações triviais do JPA, como @Entity, @Table, @Id e @Column. Mas existe também a anotação específica do Hibernate, que é o @Cache. Na estratégia de concorrência, estou dizendo que é possível haver escrita e leitura, mas que a escrita não bloqueia leituras. Sem esta anotação, o cache não funciona pra essa entidade. (Por isso, se você um dia colocou o cache de segundo nível que você queria nas configurações, mas nunca colocou a anotação @Cache nas entidades, é a mesma coisa que não ter cache de segundo nível.)

O Dao faz acessos ao Hibernate normalmente (estou usando o template do Spring):

package br.com.objectzilla.hibernateMemcached;
 
import org.hibernate.SessionFactory;
import org.springframework.orm.hibernate3.HibernateTemplate;
import org.springframework.transaction.annotation.Transactional;
 
@Transactional
public class ProductDescriptionDAO implements ProductDescriptionRepository {
 
	private HibernateTemplate hibernateTemplate;
 
	public ProductDescription getByBarCode(long barCode) {
		return (ProductDescription) hibernateTemplate.get(ProductDescription.class, barCode);
	}
 
	public void refreshProductInformation(ProductDescription product) {
		product = (ProductDescription) hibernateTemplate.merge(product);
		hibernateTemplate.saveOrUpdate(product);
	}
 
	public void setSessionFactory(SessionFactory sessionFactory) {
		hibernateTemplate = new HibernateTemplate(sessionFactory);
	}
}

E pra configurar, basta adicionar essas propriedades no hibernate.cfg.xml:

<property name="hibernate.cache.provider_class">com.googlecode.hibernate.memcached.MemcachedCacheProvider</property>
<property name="hibernate.cache.use_query_cache">true</property>
<property name="hibernate.memcached.servers">localhost:14001</property>

Onde eu indico o provedor do cache, a opção de se fazer cache de queries, e o servidor do Memcached. Não são as únicas opções, mas as outras possuem opção default, que você encontra aqui.

É só isso, agora é só adiconar o hibernate-memcached e o spy-memcached no seu classpath e rodar a aplicação. Também disponibilizei um zip para esse exemplo, caso vocês queiram dar uma olhada.

Conclusão

Usar cache, seja o Memcached ou qualquer outro, é uma boa opção para evitar acesso a recursos custosos, como o banco de dados. Mas, numa aplicação dividida em camadas não é sempre necessário que a responsabilidade de cachear fique no lado cliente (camada de apresentação). Se deixar que o lado servidor (camada de persistência) tenha essa responsabilidade, você pode deixar que os clientes acessem a camada de persistência na hora e do jeito que bem entenderem e, ainda assim, de maneira transparente, estará sendo feito acesso eficiente de recursos.

Eu havia feito uma medição bem informal de quanto o Memcached economizaria o tempo de busca. Levei à conclusão de que por causa de warm-up e tudo mais, eu não sei fazer benchmarks. ;) O mais importante do cache não costuma nem ser o tempo de acesso mais rápido, mas o fato de não deixar “engargalar” o banco com muitos acessos.

Tagged:

§ 2 Responses to “Já usou Memcached?”

  • Thiago disse:

    Fala Verissimo.

    Cara, muito bom seus artigos.

    Abraços,

    Thiago

  • Roberto Besick disse:

    Oi, Leonardo,

    gostei muito do seu artigo. Você descreveu o processo do Memcached de forma bastante clara; o exemplo prático também está muito bom!

    O Memcached é um auxiliar fantástico na hora de otimizar a recuperação de dados. Por falar nele, você já conhece esse serviço da Amazon: Elastic Cache (http://aws.amazon.com/elasticache/)? É um web service que implementa um serviço de cache em cloud, compatível com o Memcached. Além disso, ele é integrado aos outros serviços da nuvem da Amazon.

    Abs

    Roberto

  • § Leave a Reply

What's this?

You are currently reading Já usou Memcached? at Objectzilla.

meta