Arquitetura do cache no MyBatis
O MyBatis oferece dois níveis de cache. O primeiro nível está vinculado ao SqlSession e é ativado automaticamente, sem possibilidade de desligamento. O segundo nível opera por namespace de Mapper e deve ser habilitado explicitamente.
A razão para o primeiro nível não poder ser desabiliatdo reside na arquitetura do próprio framework: recursos como mapeamento de associações e coleções, prevenção de referências circulares e aceleração de consultas aninhadas dependem do cache de primeiro nível. Além disso, a construção da chave de cache, CacheKey, é peça central no processo de mapeamento de resultados.
Controle do cache de primeiro nível
A propriedade localCacheScope define o escopo do cache de primeiro nível:
- SESSION: o cache permanece válido durante toda a vida do
SqlSessione só é invalidado por instruções DML. - STATEMENT: o cache vale apenas para a instrução corrente, sendo descartado assim que ela termina.
Habilitando o cache de segundo nível
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
No XML do Mapper, define-se a política de armazenamento:
<cache eviction="FIFO" flushInterval="60000" size="512" readOnly="true"/>
E no nível da instrução, controla-se o uso do cache:
<select id="listarAutores" resultType="com.exemplo.Autor"
useCache="true" flushCache="false">
SELECT * FROM autor
</select>
Implementação dos caches no MyBatis
Todos os caches implementam a interface Cache, que define as operações básicas de leitura, escrita e limpeza.
public interface Cache {
String getId();
void putObject(Object chave, Object valor);
Object getObject(Object chave);
Object removeObject(Object chave);
void clear();
int getSize();
}
A implementação base é a classe PerpetualCache, que armazena os valores em um HashMap. Seu equals e hashCode são baseados unicamente no identificador do cache, o que permite tratar decoradores distintos como o mesmo cache.
Decoradores de cache
O MyBatis aplica o padrão Decorator para adicionar comportamentos à PerpetualCache:
- BlockingCache: serializa o acesso por chave, premitindo que apenas uma thread consulte o banco para uma chave ausente.
- FifoCache: mantém fila de tamanho fixo, removendo os itens mais antigos.
- LoggingCache: registra taxa de acerto do cache via log.
- LruCache: remove o item menos recentemente usado quando a capacidade é atingida.
- ScheduledCache: limpa o cache automaticamente após intervalo definido.
- SerializedCache: serializa e desserializa os valores armazenados.
- SoftCache e WeakCache: armazenam valores como referências soft ou weak, respectivamente.
- SynchronizedCache: torna as operações thread-safe por meio de
synchronized. - TransactionalCache: adia a escrita efetiva até o commit, permitindo rollback.
Exemplo de uso combinado:
void demonstrarDecoradores() {
PerpetualCache base = new PerpetualCache("cache_exemplo");
Cache sincronizada = new SynchronizedCache(base);
Cache serializada = new SerializedCache(base);
Cache comLog = new LoggingCache(base);
Cache agendada = new ScheduledCache(base);
Assert.assertEquals(base, sincronizada);
Assert.assertEquals(base, serializada);
Assert.assertEquals(base, comLog);
Assert.assertEquals(base, agendada);
Set<Cache> caches = new HashSet<>();
caches.add(base);
caches.add(sincronizada);
caches.add(serializada);
caches.add(comLog);
caches.add(agendada);
Assert.assertEquals(1, caches.size());
}
O CacheBuilder facilita a criação de caches com vários decoradores:
public Cache novoCache(Class<? extends Cache> tipo,
Class<? extends Cache> eviccao,
Long intervaloFlush,
Integer tamanho,
boolean leituraEscrita,
boolean bloqueante,
Properties propriedades) {
Cache cache = new CacheBuilder(namespaceAtual)
.implementation(valorOuPadrao(tipo, PerpetualCache.class))
.addDecorator(valorOuPadrao(eviccao, LruCache.class))
.clearInterval(intervaloFlush)
.size(tamanho)
.readWrite(leituraEscrita)
.blocking(bloqueante)
.properties(propriedades)
.build();
configuracao.addCache(cache);
cacheAtual = cache;
return cache;
}
Cache de primeiro nível
A classe abstrata BaseExecutor mantém duas instâncias de PerpetualCache: uma para resultados de consultas e outra para parâmetros de saída de procedimentos armazenados.
public abstract class BaseExecutor implements Executor {
protected PerpetualCache cacheLocal;
protected PerpetualCache cacheParametrosSaida;
protected BaseExecutor(Configuration configuracao, Transaction transacao) {
this.transacao = transacao;
this.carregamentosPendentes = new ConcurrentLinkedQueue<>();
this.cacheLocal = new PerpetualCache("CacheLocal");
this.cacheParametrosSaida = new PerpetualCache("CacheParametrosSaida");
this.fechado = false;
this.configuracao = configuracao;
this.wrapper = this;
}
}
Formação da chave de cache
A chave é representada por CacheKey e considera: identificador do Mapper, deslocamento e limite da consulta, instrução SQL, valores dos parâmetros e identificador do ambiente.
public CacheKey criarChaveCache(MappedStatement ms, Object parametro,
RowBounds rowBounds, BoundSql boundSql) {
if (fechado) {
throw new ExecutorException("Executor foi fechado.");
}
CacheKey chave = new CacheKey();
chave.update(ms.getId());
chave.update(rowBounds.getOffset());
chave.update(rowBounds.getLimit());
chave.update(boundSql.getSql());
List<ParameterMapping> mapeamentos = boundSql.getParameterMappings();
TypeHandlerRegistry registro = ms.getConfiguration().getTypeHandlerRegistry();
MetaObject meta = null;
for (ParameterMapping mapeamento : mapeamentos) {
if (mapeamento.getMode() != ParameterMode.OUT) {
Object valor;
String propriedade = mapeamento.getProperty();
if (boundSql.hasAdditionalParameter(propriedade)) {
valor = boundSql.getAdditionalParameter(propriedade);
} else if (parametro == null) {
valor = null;
} else if (registro.hasTypeHandler(parametro.getClass())) {
valor = parametro;
} else {
if (meta == null) {
meta = configuracao.newMetaObject(parametro);
}
valor = meta.getValue(propriedade);
}
chave.update(valor);
}
}
if (configuracao.getEnvironment() != null) {
chave.update(configuracao.getEnvironment().getId());
}
return chave;
}
Fluxo de consulta e invalidação
O método query tenta obter o resultado do cacheLocal; se não encontrar, consulta o banco e armazena o retorno.
public <E> List<E> query(MappedStatement ms, Object parametro,
RowBounds rowBounds, ResultHandler manipulador,
CacheKey chave, BoundSql boundSql) throws SQLException {
ErrorContext.instance().resource(ms.getResource())
.activity("executing a query").object(ms.getId());
if (fechado) {
throw new ExecutorException("Executor foi fechado.");
}
if (pilhaConsultas == 0 && ms.isFlushCacheRequired()) {
limparCacheLocal();
}
List<E> resultado;
try {
pilhaConsultas++;
resultado = manipulador == null
? (List<E>) cacheLocal.getObject(chave)
: null;
if (resultado != null) {
tratarParametrosSaidaCacheLocal(ms, chave, parametro, boundSql);
} else {
resultado = consultarDoBanco(ms, parametro, rowBounds, manipulador, chave, boundSql);
}
} finally {
pilhaConsultas--;
}
if (pilhaConsultas == 0) {
for (DeferredLoad carga : carregamentosPendentes) {
carga.load();
}
carregamentosPendentes.clear();
if (configuracao.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
limparCacheLocal();
}
}
return resultado;
}
Já o método update limpa o cache antes de executar a operação, garantindo consistência dentro do escopo do SqlSession.
public int update(MappedStatement ms, Object parametro) throws SQLException {
ErrorContext.instance().resource(ms.getResource())
.activity("executing an update").object(ms.getId());
if (fechado) {
throw new ExecutorException("Executor foi fechado.");
}
limparCacheLocal();
return executarUpdate(ms, parametro);
}
Em ambientes distribuídos, prefira
localCacheScopeigual aSTATEMENT, pois outras instâncias da aplicação podem modificar os dados sem que o cache local da instância atual seja notificado.
Cache de segundo nível
O cache de segundo nível é gerenciado por CachingExecutor, que envolve um executor comum. Se cacheEnabled estiver ativo, o Configuration.newExecutor retorna uma instância decorada.
public Executor novoExecutor(Transaction transacao, ExecutorType tipo) {
tipo = tipo == null ? defaultExecutorType : tipo;
Executor executor;
if (ExecutorType.BATCH == tipo) {
executor = new BatchExecutor(this, transacao);
} else if (ExecutorType.REUSE == tipo) {
executor = new ReuseExecutor(this, transacao);
} else {
executor = new SimpleExecutor(this, transacao);
}
if (cacheHabilitado) {
executor = new CachingExecutor(executor);
}
return (Executor) cadeiaInterceptores.pluginAll(executor);
}
Gerenciamento transacional dos caches
O CachingExecutor delega o armazenamento para TransactionalCacheManager, que mantém uma instância de TransactionalCache para cada Cache. Dessa forma, as escritas só são efetivadas após commit, e podem ser descartadas em caso de rollback.
public class TransactionalCacheManager {
private final Map<Cache, TransactionalCache> caches = new HashMap<>();
public void clear(Cache cache) {
getTransactionalCache(cache).clear();
}
public Object getObject(Cache cache, CacheKey chave) {
return getTransactionalCache(cache).getObject(chave);
}
public void putObject(Cache cache, CacheKey chave, Object valor) {
getTransactionalCache(cache).putObject(chave, valor);
}
public void commit() {
for (TransactionalCache tc : caches.values()) {
tc.commit();
}
}
public void rollback() {
for (TransactionalCache tc : caches.values()) {
tc.rollback();
}
}
private TransactionalCache getTransactionalCache(Cache cache) {
return MapUtil.computeIfAbsent(caches, cache, TransactionalCache::new);
}
}
Consulta com cache de segundo nível
@Override
public <E> List<E> query(MappedStatement ms, Object parametro,
RowBounds rowBounds, ResultHandler manipulador) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parametro);
CacheKey chave = createCacheKey(ms, parametro, rowBounds, boundSql);
return query(ms, parametro, rowBounds, manipulador, chave, boundSql);
}
@Override
public <E> List<E> query(MappedStatement ms, Object parametro,
RowBounds rowBounds, ResultHandler manipulador,
CacheKey chave, BoundSql boundSql) throws SQLException {
Cache cache = ms.getCache();
if (cache != null) {
flushCacheIfRequired(ms);
if (ms.isUseCache() && manipulador == null) {
ensureNoOutParams(ms, boundSql);
@SuppressWarnings("unchecked")
List<E> lista = (List<E>) tcm.getObject(cache, chave);
if (lista == null) {
lista = delegate.query(ms, parametro, rowBounds, manipulador, chave, boundSql);
tcm.putObject(cache, chave, lista);
}
return lista;
}
}
return delegate.query(ms, parametro, rowBounds, manipulador, chave, boundSql);
}
A invalidação ocorre no método update, respeitando o atributo flushCache da instrução.
@Override
public int update(MappedStatement ms, Object parametro) throws SQLException {
flushCacheIfRequired(ms);
return delegate.update(ms, parametro);
}
private void flushCacheIfRequired(MappedStatement ms) {
Cache cache = ms.getCache();
if (cache != null && ms.isFlushCacheRequired()) {
tcm.clear(cache);
}
}
Vinculação do cache ao MappedStatement
Durante o parsing do XML do Mapper, o XMLMapperBuilder lê a tag <cache> e delega a criação do cache ao MapperBuilderAssistant. O cache resultante é associado ao MappedStatement por meio do atributo currentCache.
private void cacheElement(XNode contexto) {
if (contexto != null) {
String tipo = contexto.getStringAttribute("type", "PERPETUAL");
Class<? extends Cache> classeTipo = typeAliasRegistry.resolveAlias(tipo);
String eviccao = contexto.getStringAttribute("eviction", "LRU");
Class<? extends Cache> classeEviccao = typeAliasRegistry.resolveAlias(eviccao);
Long intervalo = contexto.getLongAttribute("flushInterval");
Integer tamanho = contexto.getIntAttribute("size");
boolean leituraEscrita = !contexto.getBooleanAttribute("readOnly", false);
boolean bloqueante = contexto.getBooleanAttribute("blocking", false);
Properties props = contexto.getChildrenAsProperties();
builderAssistant.useNewCache(classeTipo, classeEviccao,
intervalo, tamanho, leituraEscrita, bloqueante, props);
}
}
Integração com Redis
Além dos caches embutidos, o MyBatis permite utilizar cache de terceiros como segundo nível. O módulo mybatis-redis oferece essa integração.
Dependência e configuração
<dependency>
<groupId>org.mybatis.caches</groupId>
<artifactId>mybatis-redis</artifactId>
<version>1.0.0-beta3</version>
</dependency>
<cache type="org.mybatis.caches.redis.RedisCache"
flushInterval="60000"
size="512"
readOnly="true"/>
redis.host=localhost
redis.port=6379
redis.connectionTimeout=5000
redis.soTimeout=5000
redis.password=
redis.database=0
redis.serializer=jdk
Funcionamento do RedisCache
A classe RedisCache implementa Cache e utiliza o Jedis para comunicação com o Redis. O JedisPool é um campo estático, compartilhado entre todas as instâncias do cache, enquanto cada namespace recebe seu próprio id.
public final class RedisCache implements Cache {
private final ReadWriteLock lock = new DummyReadWriteLock();
private final String id;
private final RedisConfig config;
private static JedisPool pool;
public RedisCache(final String id) {
if (id == null) {
throw new IllegalArgumentException("O cache precisa de um ID");
}
this.id = id;
this.config = RedisConfigurationBuilder.getInstance().parseConfiguration();
pool = new JedisPool(config, config.getHost(), config.getPort(),
config.getConnectionTimeout(), config.getSoTimeout(),
config.getPassword(), config.getDatabase(), config.getClientName(),
config.isSsl(), config.getSslSocketFactory(),
config.getSslParameters(), config.getHostnameVerifier());
}
}
Os valores são armazenados em um Hash do Redis, identificado pelo id do cache. A serialização pode ser feita pelo mecanismo padrão do JDK ou pelo Kryo, conforme a propriedade redis.serializer.
@Override
public void putObject(final Object chave, final Object valor) {
execute(jedis -> {
byte[] idBytes = id.getBytes();
byte[] chaveBytes = chave.toString().getBytes();
byte[] valorBytes = config.getSerializer().serialize(valor);
jedis.hset(idBytes, chaveBytes, valorBytes);
if (timeout != null && jedis.ttl(idBytes) == -1) {
jedis.expire(idBytes, timeout);
}
return null;
});
}
@Override
public Object getObject(final Object chave) {
return execute(jedis -> {
byte[] idBytes = id.getBytes();
byte[] chaveBytes = chave.toString().getBytes();
return config.getSerializer().unserialize(jedis.hget(idBytes, chaveBytes));
});
}
Além do Redis, a comunidade MyBatis mantém adaptadores para EhCache, OSCache e Memcached, permitindo escolher a solução de cache mais adequada à arquitetura da aplicação.