Aprende a utilizar PreparedStatements eficientemente en Java

Cómo utilizar PreparedStatements de manera eficiente en Java

1. Overview

En este artículo, vamos a explorar cómo utilizar PreparedStatements de manera eficiente en Java. Un PreparedStatement es un objeto que almacena una sentencia SQL precompilada que podemos ejecutar repetidamente. A lo largo de este tutorial, veremos diversos métodos para hacerlo, y elegir el correcto puede mejorar significativamente nuestro código y el rendimiento de nuestra aplicación.

2. Setup

Para comenzar, necesitamos configurar una base de datos y obtener una Connection que podamos usar. Vamos a crear una tabla llamada CUSTOMER con tres columnas: id, first_name y last_name:

Connection connection = null;

void setupDatabaseAndConnect() throws SQLException {
    connection = DriverManager.getConnection("jdbc:h2:mem:testDB", "dbUser", "dbPassword");
    String createTable = "CREATE TABLE CUSTOMER (id INT, first_name TEXT, last_name TEXT)";
    connection.createStatement().execute(createTable);
}

Aquí nos hemos conectado a una base de datos H2 en memoria y hemos mantenido nuestro objeto Connection como una variable de clase para que podamos usarlo en nuestros métodos más adelante. Después, hemos creado la tabla como estaba previsto con esa Connection. El tipo de base de datos al que nos conectamos aquí y la definición de la tabla no son importantes; esto es solo para que tengamos algo que podamos usar más adelante en nuestros ejemplos.

3. Uso ineficiente de un PreparedStatement

Para nuestro primer intento de insertar algunos datos en nuestra base de datos, establezcamos un uso básico de un PreparedStatement que funciona, pero es muy poco ideal:

String SQL = "INSERT INTO CUSTOMER (id, first_name, last_name) VALUES(?,?,?)";

void inefficientUsage() throws SQLException {
    for (int i = 0; i < 10000; i++) {
        PreparedStatement preparedStatement = connection.prepareStatement(SQL);
        preparedStatement.setInt(1, i);
        preparedStatement.setString(2, "firstname" + i);
        preparedStatement.setString(3, "secondname" + i);
        preparedStatement.executeUpdate();
        preparedStatement.close();
    }
}

Aquí hemos definido nuestra cadena SQL y luego hemos saltado directamente a un for loop. Por cada ciclo del bucle, creamos un PreparedStatement, configuramos los parámetros, ejecutamos la actualización y luego lo cerramos.

Para comprobar si esto ha funcionado, podemos contar las columnas en nuestra tabla Customer. Podemos hacerlo con otro PreparedStatement:

int checkRowCount() {
    try (PreparedStatement counter = connection.prepareStatement("SELECT COUNT(*) AS customers FROM CUSTOMER")) {
        ResultSet resultSet = counter.executeQuery();
        resultSet.next();
        int count = resultSet.getInt("customers");
        resultSet.close();
        return count;
    } catch (SQLException e) {
        return -1;
    }
}

Finalmente, llamamos a ambos métodos en una prueba para ver qué sucede:

@Test
void whenCallingInefficientPreparedStatementMethod_thenRowsAreCreatedAsExpected() throws SQLException {
    ReusePreparedStatement service = new ReusePreparedStatement();
    service.setupDatabaseAndConnect();
    service.inefficientUsage();
    int rowsCreated = service.checkRowCount();
    assertEquals(10000, rowsCreated);
}

Como podemos ver, todo funcionó como se esperaba; creamos 10,000 filas. Sin embargo, esto se realizó de una manera muy poco óptima. Creamos y cerramos un objeto PreparedStatement 10,000 veces. El impacto en el rendimiento que esto tiene en otras aplicaciones depende de la frecuencia con que lo hagamos y el tamaño de nuestros bucles. Sin embargo, es mejor evitarlo completamente, ya que hay maneras más eficientes que veremos a continuación.

4. Reutilización simple de un PreparedStatement

Siguiendo nuestra implementación básica, la mejora obvia es mover la creación del PreparedStatement fuera del bucle for. Podemos crearlo una vez y usarlo tantas veces como sea necesario. Otra ligera mejora que podemos hacer es usar try-with-resources para gestionar el ciclo de vida del PreparedStatement.

Veamos cómo se ve eso usando la misma SQL que antes:

void betterUsage() {
    try (PreparedStatement preparedStatement = connection.prepareStatement(SQL)) {
        for (int i = 0; i < 10000; i++) {
            preparedStatement.setInt(1, i);
            preparedStatement.setString(2, "firstname" + i);
            preparedStatement.setString(3, "secondname" + i);
            preparedStatement.executeUpdate();
        }
    } catch (SQLException e) {
        throw new RuntimeException(e);
    }
}

Ahora solo estamos creando nuestro PreparedStatement una vez. Además, no tenemos que preocuparnos por llamar a close() en él, ya que eso se encarga de nosotros. En una implementación del mundo real, miraríamos hacia manejar la excepción mejor, por supuesto. Esta es la eficiencia mínima que deberíamos estar utilizando en nuestro código.

Escribamos una prueba para confirmar que funciona como se espera:

@Test
void whenCallingBetterPreparedStatementMethod_thenRowsAreCreatedAsExpected() throws SQLException {
    ReusePreparedStatement service = new ReusePreparedStatement();
    service.setupDatabaseAndConnect();
    service.betterUsage();
    int rowsCreated = service.checkRowCount();
    assertEquals(10000, rowsCreated);
}

Esto se ve muy familiar de antes. Eso es porque estamos haciendo exactamente lo mismo. Funcionalmente, nuestras implementaciones son esencialmente las mismas hasta aquí. Podemos ver de nuevo que obtenemos las 10,000 filas que esperábamos.

Sin embargo, hay más problemas potenciales con este enfoque. Por un lado, estamos enviando la actualización a la base de datos cada vez, así que eso genera una gran interacción con la base de datos. También, si nos interrumpen por cualquier razón, será muy difícil reanudar la tarea, y mucho menos en el lugar correcto. No tendríamos forma de saber dónde nos habíamos quedado sin revisar la base de datos y mirar cada actualización que planificamos hacer. Abordaremos esa preocupación en la siguiente sección.

5. Mejora de la eficiencia con lotes

Finalmente, llegamos a la mejor opción para reutilizar un PreparedStatement. La clave aquí es usar procesamiento por lotes.

Agruparemos todas nuestras actualizaciones en un lote y ejecutaremos ese lote al final, cuando estemos listos. Al hacerlo, eliminamos el riesgo de que algo se rompa a mitad de nuestra tarea y no saber dónde nos quedamos:

void bestUsage() {
    try (PreparedStatement preparedStatement = connection.prepareStatement(SQL)) {
        connection.setAutoCommit(false);
        for (int i = 0; i < 10000; i++) {
            preparedStatement.setInt(1, i);
            preparedStatement.setString(2, "firstname" + i);
            preparedStatement.setString(3, "secondname" + i);
            preparedStatement.addBatch();
        }
        preparedStatement.executeBatch();
        try {
            connection.commit();
        } catch (SQLException e) {
            connection.rollback();
            throw e;
        }
    } catch (SQLException e) {
        throw new RuntimeException(e);
    }
}

Una vez más, hemos creado un PreparedStatement en nuestro try-with-resources. La diferencia esta vez es que antes de iniciar nuestro bucle, hemos llamado a setAutoCommit(false). Esto le dice a la Connection que agrupe nuestras sentencias SQL en transacciones que podemos decidir cuándo confirmar. Luego, en nuestro bucle for, agregamos nuestros parámetros al lote. Solo una vez que los hemos agregado todos, ejecutamos el lote, y asumiendo que eso va bien, confirmamos los cambios. Si algo sale mal durante el commit, capturamos la excepción y realizamos un rollback a donde comenzamos. Esto significa que no quedamos con un trabajo a medias y un estado desconocido de la base de datos.

Podríamos ejecutar y confirmar nuestros lotes más a menudo si quisiéramos, cada 5000 registros, por ejemplo. Esto significaría que no perderíamos todo nuestro progreso en caso de una interrupción. Si hiciéramos esto, probablemente querríamos mantener un registro de cuánto progresamos entre las actualizaciones a medida que avanzamos. Esto nos ayudaría a reanudar lo que estábamos haciendo una vez que resolviéramos cualquier problema.

6. Conclusión

En este artículo, examinamos tres maneras de usar PreparedStatements para insertar datos en una base de datos. Comenzamos creando uno para cada actualización y vimos que funcionaba, pero era ineficiente. Progresamos a reutilizar uno dentro del bucle, lo cual fue mejor y significó que solo teníamos que crear y cerrar el objeto una vez. Finalmente, no solo reutilizamos el mismo dentro de un bucle, sino que también agrupamos nuestras inserciones y las ejecutamos periódicamente.

Por lo tanto, para hacer el mejor uso de un PreparedStatement, necesitamos crearlo una vez, reutilizarlo tantas veces como necesitemos y ejecutar nuestras actualizaciones en lotes si hay muchas.

Espero que esta guía sobre cómo usar PreparedStatements de manera eficiente en Java te sea útil en tus proyectos de programación.