4.3. Transacciones#

Hasta ahora hemos obviado el concepto de transacción. Una transacción es una operación sobre la base de datos, no necesariamente atómica, que debe completarse o no hacerse en absoluto, es decir, si una transacción se compone de dos operaciones (sentencias SQL), ambas operaciones deben realizarse.

Por ejemplo, en una tienda la venta de un bolígrafo implica dos cosas:

  • Ingresar el importe del bolígrafo.

  • Eliminar el bolígrafo del almacén.

Ambas operaciones son indisolubles y hemos de hacerlas para que se complete la venta, o no hacerlas en absoluto para que quede la venta pendiente. En cambio, si se hiciera una y no la otra, la base de datos quedaría en un estado inconsistente.

4.3.1. Manejo de transacciones#

En los ejemplos con que hemos ilustrado los distintos casos, cada sentencia SQL constituye una transacción diferente. Si queremos que varias sentencias pertenezcan a una misma transacción debemos hacer lo siguiente:

Centro[] centros = new Centro[] {
   new Centro(11004866, "IES Castillo de Luna", "pública"),
   new Centro(11007533, "IES Arroyo Hondo", "pública"),
   new Centro(11701164, "IES Astaroth", "pública")
};
String sqlString = "INSERT INTO Centro VALUES (?, ?, ?);"

try(Connection conn = DriverManager.getConnection(dbUrl)) {
   conn.setAutoCommit(false);  // Evitamos que cada sentencia implique una transacción

   try(PreparedStatement pstmt = conn.prepareStatement(sqlString)) {
       for(Centro centro: centros) {
           pstmt.setInt(1, centro.getId());
           pstmt.setString(2, centro.getNombre());
           pstmt.setString(3, centro.getTitularidad());
           pstmt.executeUpdate();
       }
       conn.commit(); // Después de ejecutar todas las sentencias, las confirmamos.
   }
} catch(SQLException err) {
   if(conn != null) {
      try {
         conn.rollback();  // Hubo un problema, se deshace todo lo hecho.
      } catch(SQLException e) {
         err.addSuppressed(e);
      }
   }
   err.printStackTrace();
}
finally {
   try {
      if(conn != null) conn.setAutoCommit(true);  // Volvemos al comportamiento habitual.
   } catch(SQLException e) {
      err.printStackTrace();
   }
}

Importante

En el ejemplo, las sentencias son una misma sentencia con distinto parámetros. Evidentemente, las transacciones pueden estar constituidas por cualesquiera sentencias.

Ver también

Más adelante se propone un mecanismo para gestionar transacciones más cómodamente.

4.3.2. Operaciones masivas#

En el caso de que tengamos que llevar a cabo muchas operaciones que comparten la misma sentencia y distintos parámetros (como en el ejemplo anterior precisamente), el modo más eficiente para llevarlas a cabo es el siguiente:

Centro[] centros = new Centro[] {
   new Centro(11004866, "IES Castillo de Luna", "pública"),
   new Centro(11007533, "IES Arroyo Hondo", "pública"),
   new Centro(11701164, "IES Astaroth", "pública")
};
String sqlString = "INSERT INTO Centro VALUES (?, ?, ?);"

conn.setAutoCommit(false);  // Evitamos que cada sentencia implique una transacción

try(PreparedStatement pstmt = conn.prepareStatement(sqlString)) {
    for(Centro centro: centros) {
        pstmt.setInt(1, centro.getId());
        pstmt.setString(2, centro.getNombre());
        pstmt.setString(3, centro.getTitularidad());
        pstmt.addBatch();  // Añadimos la inserción al procedimiento.
    }
    pstmt.executeBatch(); // Ejecutamos todas las inserciones pendientes.
    conn.commit(); // Confirmamos las inserciones.
}
catch(SQLException err) {
   try {
      conn.rollback();
   } catch(SQLException e) {
      err.addSuppressed(e);
   }
   err.printStackTrace();
}
finally {
   conn.setAutoCommit(true);  // Volvemos al comportamiento habitual.
}

4.3.3. Gestor de transacciones#

Crear transacciones de forma manual es bastante engorroso por lo que resulta muy útil construirse algún mecanismo más sofisticado para llevarlas a cabo. Una buena propuesta es crear una clase que modele transacciones (Transaction) y que, además, de simplificar la creación y terminación de la transacción, implemente su anidamiento y ofrezca un par de contextos limitados que luego nos serán de utilidad. Para gestionar esta clase puede crear TransactionManager.

Las claves de esta última clase son:

  • Usa un patrón Singleton ampliado (Multiton), de manera que se puede acceder a las distintas instancias mediante una clave definida al crear cada instancia.

  • Automatiza la gestión de transacciones para que el programadador sólo se tenga que preocupar de definir una expresión lambda que contenga todas las operaciones que constituyen la transacción.

  • La expresión lambda tiene disponible un contexto al cual puede recurrise para realizar las operaciones. Por ejemplo, este contexto provee de la conexión asociada a la transacción. La gestión de esta conexión (su creación y su cierre) debe correr a cuenta del gestor de transacciones, por lo que está protegida para que el programador no la cierre por descuido (su método close() no tiene ningún efecto). Esta magia se logra gracias al uso del patrón Proxy que permite interceptar los métodos definidos en una interfaz (y Connection lo es).

  • Además, incorpora un mecanismo basado en el patrón Observer que permite ampliar su funcionalidad.

Este gestor de transacciones soporta múltiples hilos y el acceso a múltiples bases de datos:

TransactionManager tm = TransactionManager.create("CENTRO", ds); // ds es un DataSource ya creado

// En cualquier punto del código podemos rescatar el objeto gracias a la clave
tm = TransactionManager.get("CENTRO");

// Cómo realizar operaciones dentro de una misma transacción
tm.transaction(ctxt -> {
   String sqlString = "UPDATE centro SET titularidad = ? WHERE id = ?";
   try {
      Connection conn = ctxt.connection();

      try(PreparedStatement pstmt = conn.preparedStatement(sqlString)) {
         pstmt.setInt(2, 11004866);
         pstmt.setString(1, "PUBLICA");
         if(pstmt.executeUpdate() == 0) {
            // Ya se sabe que está actualización nunca se llevará a cabo.
            logger.trace("Actualización fallida: no se encuentra el centro {}.", 11004866);
         } else {
            // En principio no debería haber problemas para llevarla a cabo,
            // pero no se sabrá hasta que se confirme o deseche la transacción.
            // Por eso debería posponerse el siguiente registro hasta que se sepa en resultado.
            // Cómo puede hacerse esto último, lo dejaremos para más adelante.
            logger.debug("Actualizada a %s la titularidad del centro %d.".formatted("PUBLICA", 11004866));
         }
      }

      try(PreparedStatement pstmt = conn.preparedStatement(sqlString)) {
         pstmt.setInt(2, 11004039);
         pstmt.setString(1, "PUBLICA");
         if(pstmt.executeUpdate() == 0) {
            logger.trace("Actualización fallida: no se encuentra el centro {}.", 11004039);
         } else {
            // Registros diferidos que aún no sabemos escribir.
         }
      }

      // La conexión en realidad NO DEBE cerrarse, pero no tiene efecto.
      conn.close();

   } catch(SQLException e) {
      // Generamos una excepción personalizada que escala al código
      // externo a la transacción (o también podríamos mostrar algún
      // mensaje de error y no propagar ninguna excepción).
      throw new DataAccessException("Error al actualizar datos", e);
   }
});

Nota

El método también puede capturar el resultado que devuelve la expresión lamda:

List<Estudiante> estudiantes = tm.transaction(ctxt -> obtenerEstudiantes(ctxt.connection()));

Pero ha de tenerse cuidado si la operación implica evaluación perezosa.

Un aspecto muy interesante del gestor es que tiene definidos cinco eventos:

onBegin

Se produce al comenzar la transacción.

onCommit

Se produce tras acabar la transacción con éxito y confirmarse los cambios.

onRollBack

Se produce tras acabar abruptamente la transacción y desecharse los cambios.

onTransactionStart

Se produce tras comenzar una trasacción anidada.

onTransactionEnd

Se produce justamente antes de acabar una transacción anidada.

Se pueden definir obervadores que desencadenen una acción al producirse alguno de los cinco eventos antes señalados. Por ejemplo, podríamos definir un observador que se limite a llevar la cuenta de las operaciones incluidas en la transacción así:

CounterListener#
/**
 Observador que se limita a definir un contador de operaciones
 y mostrar el resultado al completarse la transacción.
 */
public class CounterListener extends ContextAwareEventListener {
    // Clave para poder obtener luego el observador.
    public static final String KEY = new Object().toString();

    // Recurso que queremos manejar con este observador (el propio contador)
    @Override
    public Object createResource() {
        return new AtomicInteger(0);
    }

    /**
     * Operación que incrementa el contador: cada vez que operemos dentro
     * de la transacción debemos usar este método para llevar la cuenta.
     * @return La cantidad de operaciones hechas hasta el momento.
     */
    public int increment() {
        AtomicInteger counter = getContext().getResource();
        return counter.incrementAndGet();
    }

    @Override
    public void onCommit() {
        AtomicInteger counter = getContext().getResource();
        System.out.printf("Se han realizado %d operaciones dentro de esta transacción.\n", counter.get());
    }

    @Override
    public void onRollback() {
        AtomicInteger counter = getContext().getResource();
        System.out.printf("Se intentaron completar sin llegarse a confirmar %d operaciones dentro de esta transacción.\n", counter.get());
    }

    // Para los restantes eventos no es necesario hacer nada.

}

En cualquier caso, uno bastante más útil es aquel que permita registrar mensajes sólo después de que se conozca el resultado de la transacción (LoggingManager, también está definido en la librería).

Para registrar los observadores en el gestor basta con:

TransactionManager tm = TransactionManager.create("CENTRO", ds)  // ds es un DataSource ya creado
   .addEventListener("counter", new CounterListener())
   .addEventListener("logging", new LoggingManager());

tm.transaction(ctxt -> {
   // Recuperamos los observadores del contexto usando su nombre con que se registró.
   CounterListener counter = ctxt.getEventListener("counter", CounterListener.class);
   LoggingManager lm = ctxt.getEventListener("logging", LoggingManager.class);

   // [...]

      if(pstmt.executeUpdate() == 0) {
         logger.trace("Actualización fallida: no se encuentra el centro {}.", 11004866);
      } else {
         lm.sendMessage(
            getClass(), // Queremos que el mensaje en el registro se asocie a esta clase
            Level.DEBUG,
            "Actualizada a '%s' la titularidad del centro '%d'.".formatted("PUBLICA", 11004866), // Commit.
            "Transacción fallida: la titularidad del centro '%d' no cambia.".formatted(11004866) // Rollback
         );
      }
      counter.increment();

   // [...]
});