O desempenho das bases de dados é um fator muito importante para as aplicações, visto que, essas podem determinar os rumos de uma aplicação tanto para o sucesso quanto para o fracasso.

Atualmente a grande maioria das aplicações utilizam JPA para manipular base de dados. No entanto, a API JDBC ainda é bastante utilizada em aplicações comerciais e inclusive pela própria JPA. Até mesmo para aplicações que utilizam JPA, entender como melhorar o desempenho da API JDBC nos ajudará a buscarmos uma melhor performance fora do framework.

JDBC

O driver JDBC é o fator mais importante na performance de aplicações que utilizam base de dados para armazenar e filtrar informações.

Os Bancos de dados já possuem seus próprios drivers JDBC, e drivers alternativos também estão disponíveis para a maioria das bases de dados mais populares, como o driver JDBC para MySQL por exemplo.. Esses drivers alternativos são disponibilizados como uma forma de oferecer uma melhor performance.

Existem diferentes tipos de drivers JDBC disponibilizados: drivers thick-style e drivers thin-style. Os drivers JDBC escritos para executar mais trabalho dentro da aplicação Java (a base de dados cliente) são chamados de thick-style driver. Os drivers JDBC escritos para executar mais trabalho no servidor de banco de dados são chamados de thin-style driver. Um exemplo disso são os drivers disponibilizados para Oracle. Um dos drivers é escrito para realizar pouco processamento dentro do aplicativo java, por isso possui um servidor de banco de dados para fazer mais processamento. O outro driver disponibilizado é o oposto, ou seja, mais trabalho é passado para a aplicação exigindo mais processamento e memória do cliente. Não há nenhuma prova concreta de qual dos modelos seja o mais performático, a verdade é que a melhor performance será oferecida por um conjunto de fatores específicos do ambiente em questão.

Um exemplo em que poderíamos utilizar um driver thin-style para ganhar um melhor desempenho é quando temos uma aplicação pequena em que a CPU da aplicação provavelmente está ficando saturada antes que qualquer carga significativa venha a ser carregada na base de dados. Por outro lado, uma empresa com mais de 100 departamentos que acessam um banco de dados único terá um melhor desempenho se os recursos do banco estiverem preservados e os clientes utilizarem um driver thick-style.

Outra situação a ser observada é em relação aos tipos de driver JDBC, onde temos quatro tipos disponibilizados. Os drivers mais utilizados atualmente são os driver de tipo 2 que utilizam código nativo e o tipo 4 que é Java puro.

O driver de tipo 1 fornece uma ponte entre ODBC e JBDC. Se uma aplicação precisa conversar com uma base de dados ODBC, então devemos usar este driver. Esses drivers do tipo 1 geralmente possuem um péssimo desempenho. Por isso é sempre bom evitarmos esse tipo de driver.

O drivers de tipo 3 são como os drivers do tipo 4, escrito puramente em Java, mas são designados para uma arquitetura específica onde algumas peças do middleware (servidor de aplicação) fornecem um tradutor intermediário. Neste tipo de arquitetura, um cliente JDBC envia protocolo JDBC para o middleware, que traduz essa requisição em um protocolo específico da base de dados e encaminha a requisição para a base de dados. Existem situações que essa arquitetura é obrigatória como, por exemplo, nos casos em que existe um middleware situado numa DMZ (network demilitarized zone) e que fornece alguma segurança adicional para conexões com o banco de dados. Nesse caso, se tratando de desempenho temos vantagens e desvantagens. O middleware é livre para armazenar as informações do banco de dados fazendo assim uma cache, que acaba aliviando o banco de dados e tornando-o mais rápido, retornando os dados mais rapidamente para o cliente e diminuindo assim a latência. Esta arquitetura não é muito utilizada atualmente.

Por fim, os drivers do tipo 2 e tipo 4 não possuem maiores vantagens um sobre o outro. Os drivers do tipo 2 podem sofrer com a sobrecarga de JNI, mas se esse driver de tipo 2 for bem escrito não deverá ter este problema. Os drivers de tipo 2 tendem a ser drivers com estilo thick e drivers do tipo 4 tendem a seguir o estilo thin, mas isso não é uma obrigatoriedade. Novamente enfatizamos que a melhor performance entre os dois tipos de drivers depende do ambiente e do driver específico que está sendo utilizado.

Prepared Statements e Statement Pooling

Na maioria das circunstâncias o código deveria usar Prepared Statement ao invés de Statement para chamadas JDBC. A diferença é que Prepared Statement permite que a base de dados reuse informações sobre o SQL que está sendo executado. Isso economiza trabalho para o banco de dados nas execuções subsequentes do Prepared Statement.

A primeira utilização de um Prepared Statement leva mais tempo para a base de dados executar, visto que ela deve configurar e salvar informações. Se o comando for utilizado apenas uma vez, então o trabalho será descartado, nesse caso seria melhor utilizar um Statement.

Nos programas batch que fazem apenas algumas chamadas para a base de dados, a interface Statement permitirá com que a aplicação finalize mais rapidamente. No entanto, alguns programas batch podem realizar diversas chamadas JDBC para alguns poucos comandos SQL, nesse caso a melhor opção seria utilizar Prepared Statement. Se estivermos utilizando JPA isso será feito automaticamente.

O desempenho das Prepared Statements é ainda melhor quando existe um pool de objetos, onde os Prepared Statements são reutilizados.

Para realizar um pool adequado, duas coisas devem ser consideradas: o pool de conexão JDBC e a configuração do driver JDBC. Essas opções de configuração se aplicam a qualquer programa que usa JDBC, seja diretamente ou via JPA.

Configurando o Statement Pool

Pools para Prepared Statement operaram em uma base "por conexão". Se uma thread em um programa solicita uma conexão JDBC fora do pool e usa um Prepared Statement nesta conexão, a informação associada com o comando será válido apenas para essa conexão. Uma segunda thread que usa uma segunda conexão irá estabelecer uma segunda instância no pool do Prepared Statement. Dessa forma, cada objeto de conexão terá seu próprio pool de todos os Prepared Statement usados na aplicação.

Esta é uma razão porque uma aplicação JDBC deveria usar um pool de conexão. A JPA cria de forma transparente um pool para programas Java SE ou usa um pool de conexão do servidor de aplicação quando usado num ambiente Java EE. Isso também significa que o tamanho do pool tem sua importância, pois ele está fazendo cache desses Prepared Statement, na qual ocupa espaço no heap (e muitas vezes ocupa muito espaço na pilha).

Quando uma conexão que ainda não usou um Prepared Statement em particular é utilizada, a primeira requisição será mais lenta.

Gerenciando o Statement Pool

A segunda situação que devemos considerar sobre o pool de Prepared Statement é qual pedaço de código irá criar e gerenciar o pool. O pool de Prepared Statement foi introduzido no JDBC 3.0, na qual fornece um método único que é o setMaxStatements() da classe ConnectionPoolDataSource para ativar ou desativar o pool de Statement. O pool de Statement é desabilitado se o valor passado para o método setMaxStatements() é zero. Essa interface não define onde o pool de Statement deveria ocorrer, seja no driver JDBC ou alguma outra camada como o servidor de aplicação. Outra limitação dessa interface é que ela é insuficiente para alguns drivers JDBC que necessitam de configurações adicionais. Portanto, quando estamos desenvolvendo uma aplicação Java SE que usa chamadas JDBC diretamente, existem duas escolhas: ou o driver JDBC deve ser configurado para criar e gerenciar o pool de Statement, ou o pool deve ser criado e gerenciado dentro do código da aplicação. As aplicações Java EE possuem duas possibilidades: o driver JDBC pode criar e gerenciar o pool, ou o servidor de aplicação pode criar e gerenciar o pool.

Infelizmente não existe nenhum padrão, pois alguns drivers JDBC não fornecem um mecanismo de pool para Statement. Os fabricantes desses drivers esperam que esses sejam utilizados apenas dentro de um servidor de aplicação que provê um pool de Statement. Porém, por outro lado alguns servidores de aplicação não fornecem e gerenciam um pool, nesses casos os fabricantes desses servidores de aplicação esperam que o driver JDBC gerencie essas tarefas.

Como não existe um padrão, tanto o driver JDBC que estamos usando quanto o servidor de aplicação podem implementar um pool, mas é importante que seja configurado apenas um deles.

Em termos de performance, a melhor escolha é dependerá novamente da combinação exata do driver e do servidor. Como regra geral, podemos esperar que o driver JDBC tenha um melhor desempenho. Como o driver é específico para uma base de dados em particular, podemos esperar uma melhor otimização para aquela base de dados do que um código no servidor de aplicação que é mais genérico.

Para habilitar o pool de Statement para um driver JDBC em particular, devemos consultar a documentação do driver. Na maioria dos casos devemos apenas configurar o driver através da propriedade maxStatements para um valor desejado (tamanho do pool). Alguns drivers necessitam de configurações adicionais, como o Oracle JDBC Driver, por isso é sempre importante verificarmos a documentação oficial.

Pools de Conexão JDBC

As conexões realizadas na base de dados são bastante custosas de serem criadas, portanto conexões JDBC são objetos propícios de serem reutilizados em Java.

Em um ambiente Java EE, todas as conexões JDBC são providas pelo pool do servidor de aplicação. No ambiente Java SE com JPA, a maioria dos provedores JPA usarão um pool de conexão de forma transparente, e teremos que configurar o pool de conexão dentro do arquivo persistence.xml. Nos ambientes Java SE standalone, as conexões devem ser gerenciadas pela aplicação, porém também existem bibliotecas que auxiliam nesses casos. Ainda assim é mais simples criar uma conexão e armazena-la numa variável local.

Cada conexão com a base de dados requer recursos da base de dados, tal como alocar memória adicional para cada Prepared Statement usado pelo driver JDBC. O desempenho pode ser afetado se o servidor de aplicação possuir muitas conexões abertas.

A regra geral para pools de conexão é termos uma conexão para cada thread na aplicação. Nos servidores de aplicação devemos aplicar o mesmo tamanho para o pool de thread e para o pool de conexão. Em aplicações standalone, o tamanho do pool de conexões é baseado no número de threads que a aplicação cria. Nos casos típicos, isso oferecerá um melhor desempenho, e assim nenhuma thread no programa precisará aguardar uma conexão com a base de dados estar disponível, e, além disso, normalmente, existirão recursos suficientes no banco de dados para lidar com a carga imposta pelo aplicativo.

Transações

Para um desempenho ótimo envolvendo transações devemos considerar duas questões: como programar as transações de forma que elas sejam eficientes e como manter os bloqueios no banco de dados durante uma transação para que o aplicativo como um todo possa ser escalável.

Controle de Transação JDBC

As transações estão presentes tanto em aplicações JDBC quando JPA, porém a JPA gerencia as transações de forma diferente. Para o JDBC, transações iniciam e finalizam baseado em como o objeto Connection é usado.

A JDBC possui um modo de confirmação automática (chamado de autocommit), configurado através do método setAutoCommit(). Se o modo de confirmação automática estiver ligado (default para a maioria dos drivers JDBC) tem-se que cada comando no JDBC é uma transação individual. Assim, um programa não necessita de qualquer ação para confirmar uma transação, porém vale ressaltar que caso a aplicação também confirme a transação teremos problemas de desempenho.

Se a auto confirmação está desligada uma transação implicitamente começa quando a primeira chamada é feita sobre o objeto de conexão, realizado através do método executeQuery(). Dessa forma, a transação continua até que o método commit() ou rollback() é chamado.

Uma nova transação será iniciada quando a conexão for usada para a próxima chamada ao banco de dados.

Transações são caras para confirmar (commit), portanto uma forma de resolver este problema seria realizar o máximo de trabalho possível em uma transação. No entanto, esse princípio contradiz outro objetivo que é o de ser o “mais curto possível”, pois as transações mantêm bloqueios enquanto um commit ou rollback não forem invocados. Portanto, devemos encontrar um equilíbrio aqui, e isso vai depender da aplicação e seus requisitos de bloqueio.

Uma forma de otimizar isso é demonstrado no código da Listagem 1, onde temos um código que insere informações na base de dados. A cada dia devemos inserir um registro na tabela Estoque, e cinco linhas na tabela ObjetoEstoque.

Listagem 1. Inserindo registros na base de dados com a opção autocommit habilitada.


      Connection c = DriverManager.getConnection(URL, usuario, senha);
      PreparedStatement ps = c.prepareStatement(insereEstoqueSQL);
      PreparedStatement ps2 = c.prepareStatement(insereObjetoSQL)) {
      Date dataAtual = new Date(dataInicio.getTime());
       
                      while (!dataAtual.after(dataFim)) {
                                     PrecoEstoque sp = createRandomStock(dataAtual);
                                     if (sp != null) {
                                                     // Mais código aqui que configura o ps com os parâmetros...
                                                     ps.executeUpdate(); //executa a inserção
       
                                                     for (int j = 0; j < 5; j++) {
                                                                     //Mais código aqui que configura ps2 com os parâmetros…
                                                                     ps2.executeUpdate(); //executa a inserção
                                                     }
                                     } 
                      }
      }

Se as datas de inicio e fim representarem o ano de 2013, este loop irá inserir 261 linhas na tabela Estoque e 1305 linhas na tabela ObjetoEstoque. Utilizando o autocommit, significa que teremos 1566 transações separadas, o que é bastante custoso.

Um melhor desempenho será alcançado se desabilitarmos o modo autocommit e explicitamente confirmarmos tudo ao final do loop, conforme mostra o exemplo da Listagem 2.

Listagem 2. Confirmando tudo após as operações e deixando autocommit desabilitado.


      Connection c = DriverManager.getConnection(URL, usuario, senha);
      c.setAutoCommit(false);
      ...
      while (!dataAtual.after(dataFim)) {
                      ...
      }
      c.commit();

Portanto, a base de dados conterá todos os dados ou nenhuma informação será armazenada (caso ocorrer rollback).

Cada vez que o método executeUpdate() é executado, uma chamada remota é realizada na base de dados e algum trabalho deve ser executado. Além disso, alguns bloqueios ocorrerão quando os updates estão sendo realizados.

A transação pode ser ainda mais otimizada através do tratamento das inserções por lotes. Quando as inserções são por lotes o driver JDBC segura até que o lote inteiro seja completado, assim todos os comandos são transmitidos em uma chamada remota. Segue na Listagem 3 um exemplo.

Listagem 3. Realizando inserções por lotes e confirmando ao final do processamento.


      for (int i = 0; i < nroEstoques; i++) {
                      while (!dataAtual.after(dataFim)) {
                                     ...
                                     ps.addBatch(); // substitui a chamada executeUpdate()
       
                                     for (int j = 0; j < 5; j++) {
                                                     ...
                                                     ps2.addBatch(); // Substitui a chamada executeUpdate()
                                     }
                      }
      }
       
      ps.executeBatch();
      ps2.executeBatch();
      c.commit();

Vale ressaltar que alguns drivers JDBC possuem limitações quanto ao número de comandos que eles podem adicionar ao batch. Além disso, o batch consume memória da aplicação. Portanto, se o commit for realizado ao final de toda a operação, os batches podem precisar ser executados mais frequentemente.

Essas otimizações podem resultar em grandes melhorias de desempenho.

Bloqueios e Isolamento da Transação

O bloqueio realizado nos dados protege a integridade do dado. Nas bases de dados, isso permite uma transação ser isolada de outras transações.

JDBC e JPA suportam quatro tipos de isolamento de transação. Os modos de isolamento de transação básicos, em ordem do mais custoso para o menos custoso, são discutidos abaixo:


  • TRANSACTION_SERIALIZABLE: Este é o modo de transação mais custoso de todos. Este modo requer que todo acesso às informações dentro da transação seja bloqueada durante a duração da transação. Isso se aplica tanto para os registros acessados via chave primária, e para registros acessados via cláusula WHERE. Além disso, quando existir uma clausula WHERE, a tabela é bloqueada de forma que nenhum registro novo pode ser adicionado durante a duração da transação. Uma transação serializada sempre visualiza a mesma informação cada vez que uma consulta for submetida.
  • TRANSACTION_REPEATABLE_READ: Este modo de transação requer que toda informação acessada seja bloqueada durante a transação. Contudo, outras transações podem inserir linhas na tabela a qualquer momento. Este modo pode levar a leituras fantasmas, que é quando uma operação que reedita uma consulta com uma cláusula WHERE pode obter dados diferentes na segunda vez que a consulta é executada.
  • TRANSACTION_READ_COMMITTED: Este modo bloqueia somente as linhas que são escritas durante uma transação. Isto leva a leituras não repetíveis, ou seja, o dado que é lido em um ponto na transação pode ser diferente do dado que é lido em outro ponto na transação.
  • TRANSACTION_READ_UNCOMMITTED: Este é o modo de operação menos oneroso de todos. Nenhum bloqueio está envolvido, assim uma transação pode ler os dados escritos (mas não confirmados) em outra transação. Isso é conhecido como uma "leitura suja". O problema deste modo ocorre quando a primeira operação reverter (ou seja, a escrita nunca realmente acontece), e, portanto, a segunda transação operaria com dados incorretos.

As bases de dados em geral operam num modo default. O MySQL inicia com o modo TRANSACTION_REPEATABLE_READ, o Oracle e o DB2 iniciam com o modo TRANSACTION_READ_COMMITTED. Alguns modos não são suportados em determinadas base de dados, como, por exemplo, o Oracle que não suporta os modos TRANSACTION_READ_UNCOMMITTED ou TRANSACTION_REPEATABLE_READ.

Quando um comando JDBC é executado, ele usa o modo de default da base de dados. De forma alternativa, o método setTransaction() na conexão do JDBC pode ser chamado para alterar o modo da transação.

Para aplicações simples este conhecimento já é suficiente. Porém em aplicações mais complexas, especialmente aquelas que utilizam JPA, devemos fazer uma mistura de níveis de isolamento. Segue na Listagem 4 um exemplo de como podemos utilizar setTransaction()

Listagem 4. Utilizando setTransaction() para configurar diferentes níveis de isolamento.


      Connection c = DriverManager.getConnection();
      c.setAutoCommit(false);
      c.setTransactionIsolation(TRANSACTION_READ_UNCOMMITTED);
      PreparedStatement ps1 = c.prepareStatement("SELECT * FROM empregado WHERE id = ? FOR UPDATE");
      //Mais código aqui

Result Set

Grande parte das aplicações opera com certo limite de informações, selecionando aquilo que realmente interessa. Após selecionar os dados utilizando a cláusula SELECT devemos percorrer os dados como exemplificado na Listagem 5.

Listagem 5. Selecionando os dados necessários no Prepared Statement e percorrendo os dados filtrados no loop while.


      PreparedStatement ps = c.prepareStatement(...);
      ResultSet rs = ps.executeQuery();
      while (rs.next()) {
                      //Lê dado sendo processado
      }

A questão é onde essas informações estão. Se o conjunto inteiro de informações for retornado durante a chamada a executeQuery() teremos uma grande quantidade de dados no heap, que possivelmente casará GC entre outras coisas. Em vez disso, se apenas uma linha de dados é retornada da chamada ao método next(), haverá um monte de tráfego entre a aplicação e o banco de dados.

Observando esses dois casos não há uma forma correta de realizarmos essa operação, pois em alguns casos será mais eficiente manter a informação na base de dados e retorná-lo quando necessário, e em outros casos será mais eficiente carregar toda a informação de uma vez quando a query for executada. Para controlar isto, podemos usar o método setFetchSize() do objeto PreparedStatement para permitir que o driver JDBC possa saber quantas linhas em um dado momento deve ser transferido. O valor default varia dependendo do driver JDBC utilizado. No Oracle o valor default é dez. Dessa forma, quando o método executeQuery() é executado a base de dados retornará dez linhas de informações, que serão colocadas no buffer do driver JDBC. Assim, nas dez primeiras chamadas ao método next() serão processadas as informações do buffer, na décima primeira chamada teremos como retorno outras dez linhas, e assim sucessivamente.

Esse valor default é considerado razoável na maioria das circunstancias, porém se a performance do método next() estiver comprometida podemos considerar a alteração do valor padrão.

Bibliografia

[1] JDBC Overview. Disponível em http://www.oracle.com/technetwork/java/overview-141217.html

[2] Oaks, S. Java Performance: The Definitive Guide. O'Reilly, 2014.