4.4. Pool de conexiones#
Abrir una conexión a la base de datos es un proceso costoso en recursos por lo que, si prevemos que nuestra aplicación abrirá y cerrará varias conexiones, es conveniente utilizar un pool de conexiones, que no es más que un mecanismo que se encarga de administrar un grupo de conexiones a una base de datos a fin de que puedan ser reutilizadas por diferentes partes de una aplicación. Esto ahorra al programa el coste de la creación y establecimiento de conexiones.
En la figura el pool de conexiones tiene abiertas tres conexiones a las bases de datos, dos de las cuales están siendo usados en la aplicación. Esto significa que aún hay una libre y que, si ésta necesitara otra, podría usarla sin necesidad de establecer una nueva conexión con la base de datos.
Para utilizar este mecanismo tenemos dos vías:
Usar el mecanismo básico que proporciona JDBC y que puede servirnos cuando no haya gran concurrencia ni necesitamos controlar todos los parámetros del pool.
Usar una librería especializada como HikariCP.
4.4.1. Gestor integrado#
Los controladores citados para distintas bases de datos disponen de un gestor integrado de pools de conexiones, aunque su uso puede diferir ligeramente entre ellos. Por lo demás, es sencillo de usar:
Path dbPath = Path.of(System.getProperty("java.io.tmpdir"), "test.db");
String dbUrl = String.format("%s%s", dbProtocol, dbPath);
SQLiteConnectionPoolDataSource ds = new SQLiteConnectionPoolDataSource();
ds.setUrl(dbUrl); // No hay que definir usuario ni contraseña.
try(Connection conn = ds.getConnection()) {
// Utilizamos la conexión.
}
try(Connection conn = ds.getConnection()) {
// Posiblemente se reutilice la conexión anterior,
// que se marcó como inactiva, al cerrarse.
}
Aclaración
En el código anterior los dos objetos DataSource generan dos objetos Connection distintos. Sin embargo, es más que probable que ambos estén utilizando, en realidad, la misma conexión a la base de datos, ya que se crean a partir de un pool de conexiones y al crear el segundo objeto, el primero ya se cerró y, por tanto, ha dejado disponible la conexión en el pool.
En cambio, si se hubieran generado directamente con DriverManager, ambos objetos conectores estarían asociados a dos conexiones distintas.
Advertencia
Y, sin embargo, lo anterior no es cierto a consecuencia de un bug del controlador de SQLite, que provoca que siempre se abra una nueva conexión, sin reaprovechar las ya establecidas. La alternativa, que es:
PooledConnection pc = ds.getPooledConnection();
try(Connection conn = pc.getConnection()) {
// ...
}
try(Connection conn = pc.getConnection()) {
// ...
}
tampoco funciona, porque el controlador nunca creará una segunda conexión, aunque sea necesaria porque el primer objeto Connection sigue activo, sino que cierra el primer objeto para aprovechar la conexión con el segundo objeto, es decir, que no es capaz de gestionar más que una conexión a la base de datos. En el ejemplo no se aprecia el error porque se cierra el primer objeto antes de crear el segundo, pero si el código fuera este:
try(Connection conn1 = pc.getConnection()) {
// Aquí podemos usar conn1.
try(Connection conn2 = pc.getConnection()) {
// Aquí no podremos usar conn1.
}
// Ni aquí tampoco.
}
al crearse el objeto conn2, conn1 se cerrará y quedará inútil. El
bug, no obstante, es un defecto del controlador para SQLite. El
código equivalente para otros SGBD sí debería funcionar correctamente.
4.4.2. HikariCP#
La alternativa, que es común a cualquier controlador, es usar una librería especializada como HikariCP_, que tiene repositorio de Maven.
Nota
Con esta librería no tendremos problemas al utilizar un pool de conexiones con SQLite.
Su uso, por otro lado es muy sencillo:
Path dbPath = Path.of(System.getProperty("java.io.tmpdir"), "test.db");
String dbUrl = String.format("%s%s", dbProtocol, dbPath);
// Configuramos el acceso.
HikariConfig hconfig = new HikariConfig();
hconfig.setJdbcUrl(url);
// En SQLite no hay credenciales de acceso.
hconfig.setUsername(null);
hconfig.setPassword(null);
// Máximo y mínimo de conexiones
hconfig.setMaximumPoolSize(10); // Nunca se abrirán más de diez conexiones.
hconfig.setMinimumIdle(1); // Al menos habrá una conexión.
HikariDataSource ds = new HikariDataSource(hconfig);
HikariPoolMXBean stats = ds.getHikariPoolMXBean(); // Para consultar estadísticas.
// Como el mínimo es una conexión, ya hay una conexión creada.
System.out.println(String.format("Conexiones activas/totales: %d/%d", stats.getActiveConnections(), stats.getTotalConnections())) // 0/1
try(Connection conn1 = ds.getConnection()) {
// ...
System.out.println(String.format("activas/totales: %d/%d", stats.getActiveConnections(), stats.getTotalConnections())) // 1/1
}
System.out.println(String.format("activas/totales: %d/%d", stats.getActiveConnections(), stats.getTotalConnections())) // 0/1
try(Connection conn1 = ds.getConnection()) {
// ...
System.out.println(String.format("activas/totales: %d/%d", stats.getActiveConnections(), stats.getTotalConnections())) // 1/1
try(Connection conn2 = ds.getConnection()) { // Crea una conexión nueva.
// ...
System.out.println(String.format("activas/totales: %d/%d", stats.getActiveConnections(), stats.getTotalConnections())) // 2/2
}
System.out.println(String.format("activas/totales: %d/%d", stats.getActiveConnections(), stats.getTotalConnections())) // 1/2
}
System.out.println(String.format("activas/totales: %d/%d", stats.getActiveConnections(), stats.getTotalConnections())) // 0/2
ds.close(); // Se liberan recursos.
Advertencia
Para el caso particular de usar una base de datos SQLite en memoria con HikariCP_,
la ruta debe ser file::memory:?cache=shared y no simplemente
:memory:.
4.4.3. Gestión del pool#
Si queremos simplificar la creación del pool y abstraernos de qué se usa para crearlo, podemos encerrar toda esta lógica en una clase como ConnectionPool.
La clase sigue el patrón ya ensayado para TransactionManager: un patrón Multiton que asocia a cada pool de conexiones una clave, por lo que crear un pool es muy sencillo[1]:
// Pool de conexiones de una base SQLite en memoria.
try(ConnectionPool pool = ConnectionPool.create("BD", "jdbc:sqlite:file::memory:?cache=shared", null, null)) {
DataSource ds = pool.getDataSource();
try(Connection conn = ds.getConnection()) {
// Hacemos operaciones con la conexión.
}
}
// Aquí ya no podemos usar el pool "DB" porque lo hemos cerrado.
ConnectionPool.get("DB"); // ERROR: el pool está cerrado.
La última línea muestra cómo puede recuperarse en cualquier momento el pool, siempre que no se haya cerrado.
Ahora bien, ¿qué se ha usado para crear el pool, esto es, el objeto
DataSource? Como no nos hemos preocupado de ello, se ha usado HikariCP_,
pero se permite añadir un cuarto argumento al metodo create que habilita la
creación del objeto con cualquier otro método[2].
El objeto puede utilizarse tal como se ha ilustrado para obtener su DataSource y, a partir de él, distintas conexiones Connection (que habrá que cerrar convenientemente), pero es más conveniente aprovechar las posibilidades de TransactionManager y dejar que sea el gestor de transacción el que se encargue de crear y cerrar la conexión que necesitamos.
// Pool de conexiones de una base SQLite en memoria.
try(ConnectionPool pool = ConnectionPool.create("BD", "jdbc:sqlite:file::memory:?cache=shared")) {
// Creamos un gestor de transacciones para él y, de paso,
// registramos un gestor de registros.
pool.initTransactionManager(Map.of(
"logging", new LoggingManager()
));
TransactionManager tm = pool.getTransactionManager(); // También puede obtenerse con la clave 'DB'
// Usamos conexiones gestionadas por el gestor
tm.transaction(ctxt -> {
// Operaciones que comparten una misma transacción
});
}
Advertencia
Las clases ConnectionPool y TransactionManager son independientes y puede
usarse una sin necesidad de utilizar la otra, pero están pensadas para usarse
conjuntamente. Por eso, cuando cuando existe el gestor de transacciones
correspondiente, el método getDataSource() genera un mensaje advirtiendo
de que es conveniente manejar las conexiones a través del gestor, que para
eso se ha creado.
Notas al pie