Cache no MyBatis: níveis, decoradores e integração com Redis

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 SqlSession e 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 localCacheScope igual a STATEMENT, 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.

Tags: MyBatis cache Redis Executor Mapper

Publicado em 7-9 20:00