Escribir consultas JDBC seguras frente a inyección SQL con PreparedStatement.
Mapear un ResultSet a objetos sin perder nulos ni recursos abiertos.
Controlar una transacción a mano y saber qué pasa si algo falla a mitad.
Entender por qué existe el pool de conexiones y cómo se dimensiona.
1
PreparedStatement: Inmunidad frente a Inyección SQL
Precompila la consulta en la base de datos y envía los parámetros en un canal tipado separado. Jamás se deben concatenar cadenas de texto en SQL.
En una líneaps.setString(1, email); garantizando inmunidad a SQL Injection.
Consulta parametrizada y orden con lista blancajava
privatestaticfinalMap<String, String> COLUMNAS_ORDEN= Map.of(
"titulo", "titulo",
"fecha", "creada_en"
);
publicList<Tarea> buscar(String texto, String ordenarPor)throws SQLException {
Stringcolumna= COLUMNAS_ORDEN.getOrDefault(ordenarPor, "creada_en"); // nunca la entrada directaStringsql="SELECT id, titulo, completada FROM tarea WHERE titulo ILIKE ? ORDER BY " + columna;
try (Connectioncon= dataSource.getConnection();
PreparedStatementps= con.prepareStatement(sql)) {
ps.setString(1, "%" + texto + "%"); // el valor viaja como datotry (ResultSetrs= ps.executeQuery()) {
varresultado=newArrayList<Tarea>();
while (rs.next()) {
resultado.add(mapear(rs));
}
return resultado;
}
}
}
Cómo funciona por dentro
Concatenar la entrada del usuario en una cadena SQL hace que esa entrada forme parte del código que la base de datos interpreta. Con "... WHERE titulo = '" + texto + "'", un texto como ' OR '1'='1 cambia el significado de la consulta. Eso es inyección SQL.
PreparedStatement envía la sentencia con marcadores ? y los valores por separado. El motor analiza la estructura una vez y trata los parámetros siempre como datos, nunca como SQL, así que no hay forma de alterar la consulta. Además, el plan de ejecución se puede reutilizar entre llamadas.
Los marcadores solo sirven para valores. No puedes parametrizar nombres de tabla, de columna ni la dirección de un ORDER BY. Si esos fragmentos dependen del usuario, valídalos contra una lista blanca cerrada antes de construir la cadena.
Evita la vulnerabilidad de seguridad más destructiva de la historia del software web.
Hacer concatenación directa en la consulta SQL en vez de usar parámetros ? con PreparedStatement.
Idéntico en 17 y 21.
Se conecta conSeguridadRendimiento SQLJDBC
2
El Dolor del Mapeo Manual con ResultSet
En JDBC puro, cada columna debe extraerse manualmente con rs.getLong("id"), rs.getString("nombre") fila por fila.
En una líneavar tarea = new TareaRecord(rs.getLong("id"), rs.getString("titulo"));
Mapeo manual con nulos y fechasjava
privateTareamapear(ResultSet rs)throws SQLException {
longid= rs.getLong("id");
Stringtitulo= rs.getString("titulo");
// getLong devolvería 0 para NULL: se pide como objetoLongresponsableId= rs.getObject("responsable_id", Long.class);
// JDBC 4.2: tipos de java.time directamenteOffsetDateTimecreadaEn= rs.getObject("creada_en", OffsetDateTime.class);
booleancompletada= rs.getBoolean("completada");
returnnewTarea(id, titulo, responsableId, creadaEn.toInstant(), completada);
}
// Patrón reutilizable: un "RowMapper" casero@FunctionalInterfaceinterfaceMapeadorFila<T> {
Tmapear(ResultSet rs)throws SQLException;
}
Cómo funciona por dentro
Un ResultSet es un cursor que empieza antes de la primera fila. Cada llamada a next() avanza una posición y devuelve false cuando no quedan filas. Los valores se leen con métodos tipados (getLong, getString, getTimestamp), por nombre de columna o por índice empezando en 1.
Hay una trampa con los nulos: getLong y getInt devuelven 0 si la columna es NULL, porque un primitivo no puede ser nulo. Para distinguirlo tienes que llamar a wasNull() justo después, o usar getObject("col", Long.class). Las fechas tienen otra: con JDBC 4.2 puedes pedir directamente getObject("col", OffsetDateTime.class) en lugar de pasar por java.sql.Timestamp.
Escribir este mapeo para cada tabla es repetitivo y propenso a errores, y en cuanto hay relaciones entre tablas se complica mucho. Ese dolor es exactamente lo que motivará usar JdbcClient de Spring o JPA en los días siguientes.
Comprender este trabajo artesanal permite entender exactamente qué problema resuelven librerías como MapStruct, JdbcTemplate o Hibernate.
Acceder a una columna con el nombre tipográfico incorrecto lanzando SQLException: column not found.
En 17 y 21 los records facilitan construir el DTO de fila de forma concisa.
Se conecta conORMSpring DataData Mapping
3
Control Manual de Transacciones con setAutoCommit(false)
Para agrupar varias operaciones SQL en una transacción atómica (todo o nada), se desactiva el autocommit y se llama a con.commit() o con.rollback().
En una líneacon.setAutoCommit(false); try { ... con.commit(); } catch { con.rollback(); }
Transferencia atómica con JDBC purojava
publicvoidtransferir(long origen, long destino, BigDecimal importe)throws SQLException {
try (Connectioncon= dataSource.getConnection()) {
booleanautoCommitPrevio= con.getAutoCommit();
con.setAutoCommit(false);
try (varretirar= con.prepareStatement(
"UPDATE cuenta SET saldo = saldo - ? WHERE id = ? AND saldo >= ?");
varingresar= con.prepareStatement(
"UPDATE cuenta SET saldo = saldo + ? WHERE id = ?")) {
retirar.setBigDecimal(1, importe);
retirar.setLong(2, origen);
retirar.setBigDecimal(3, importe);
if (retirar.executeUpdate() != 1) {
thrownewSaldoInsuficienteException(origen); // provoca el rollback
}
ingresar.setBigDecimal(1, importe);
ingresar.setLong(2, destino);
ingresar.executeUpdate();
con.commit();
} catch (SQLException | RuntimeException e) {
con.rollback();
throw e;
} finally {
con.setAutoCommit(autoCommitPrevio); // la conexión vuelve limpia al pool
}
}
}
Cómo funciona por dentro
Por defecto una conexión JDBC está en modo autocommit: cada sentencia es una transacción que se confirma al terminar. Para que varias sentencias sean atómicas (todas o ninguna) desactivas autocommit, ejecutas las sentencias y llamas a commit(). Si algo falla, rollback() deshace todo lo pendiente.
El orden importa: el rollback debe estar en el catch, y antes de devolver la conexión al pool conviene restaurar autocommit, porque otra parte del código recibirá esa misma conexión física. Olvidarlo es un bug clásico que aparece solo en producción.
El nivel de aislamiento (READ COMMITTED por defecto en PostgreSQL) decide qué ven las transacciones concurrentes. Para una transferencia, además de la transacción, hay que evitar condiciones de carrera: por ejemplo con SELECT ... FOR UPDATE o con una actualización condicional (UPDATE ... SET saldo = saldo - ? WHERE id = ? AND saldo >= ?).
Evita estados inconsistentes (e.g. restar dinero de una cuenta pero fallar al sumarlo en otra).
Olvidar llamar a con.rollback() en el bloque catch dejando transacciones abiertas y bloqueos en la BD.
Idéntico en 17 y 21.
Se conecta conACIDConsistencia de DatosSpring @Transactional
4
Fugas de Conexiones y el Pool de Conexiones
Abrir una conexión TCP a la base de datos es carísimo. En aplicaciones reales se usa un pool (como HikariCP) que mantiene conexiones abiertas y las reutiliza.
En una líneaUsar try-with-resources sobre la conexión devuelta por el pool.
HikariCP configurado en Spring Bootyaml
spring:datasource:url:jdbc:postgresql://localhost:5432/taskflowusername:taskflowpassword:${DB_PASSWORD}hikari:maximum-pool-size:10# conexiones simultáneas máximasminimum-idle:10# Hikari recomienda pool de tamaño fijoconnection-timeout:3000# ms esperando una conexión libre antes de fallarmax-lifetime:1800000# renueva conexiones antes que el timeout del servidorleak-detection-threshold:20000# avisa si una conexión lleva 20 s prestada
Cómo funciona por dentro
Abrir una conexión física a la base de datos es caro: conexión TCP, a menudo TLS, autenticación y creación de un proceso o hilo en el servidor. Hablamos de milisegundos o decenas de milisegundos, frente a microsegundos de una consulta sencilla. Un pool abre un número limitado de conexiones al arrancar y las presta: getConnection() toma una libre y close() la devuelve en lugar de cerrarla.
Una fuga ocurre cuando el código pide una conexión y nunca llama a close(), por ejemplo porque una excepción salta antes. El pool se va vaciando hasta que todas las peticiones quedan esperando y fallan por timeout. HikariCP (el pool por defecto de Spring Boot) puede avisar con leakDetectionThreshold.
Más conexiones no es más rendimiento. La base de datos tiene un número limitado de núcleos y discos; con cientos de conexiones activas pasa más tiempo cambiando de contexto que trabajando. La referencia de HikariCP es empezar cerca de núcleos × 2 + discos y medir.
Sin pool o cerrando mal las conexiones, el servidor agota los sockets del motor de base de datos en minutos.
Crear una nueva conexión física con DriverManager en cada petición web.
HikariCP es el pool por defecto en Spring Boot tanto en Java 17 como en 21.
Se conecta conHikariCPRendimientoAutoCloseable
Compruébalo: Tu aplicación tiene 200 hilos de Tomcat y un pool de 10 conexiones. ¿Qué pasa con 50 peticiones simultáneas que consultan la base de datos?
Diez obtienen conexión y las otras 40 esperan en el pool hasta connection-timeout. Si las consultas son rápidas, se van atendiendo por turnos sin problema. Si son lentas, algunas fallarán por timeout. El cuello de botella está en el pool, no en los hilos.
5
SQLException y Excepciones Comprobadas
JDBC es una API antigua donde casi todo método lanza SQLException (comprobada), obligando a capturarla o declararla.
En una línea Envolver SQLException en una excepción de dominio propia RepositorioException.
Casi todos los métodos de JDBC declaran throws SQLException, una excepción comprobada. Contiene información útil más allá del mensaje: getSQLState() devuelve un código estándar de cinco caracteres (por ejemplo, 23505 es violación de unicidad en PostgreSQL), getErrorCode() el código propio del fabricante, y getNextException() encadena errores adicionales en operaciones por lotes.
El problema de diseño es que esa excepción comprobada obliga a declarar throws SQLException en el servicio, el controlador y cualquier capa intermedia, filtrando un detalle de infraestructura a todo el sistema. Lo habitual es capturarla en el repositorio y traducirla a una excepción de dominio no comprobada, usando el SQLState para distinguir casos.
Spring hace esta traducción automáticamente con su jerarquía DataAccessException (DuplicateKeyException, DataIntegrityViolationException, CannotAcquireLockException...), independiente de la base de datos.
Explica por qué Spring Framework creó su jerarquía de DataAccessException no comprobada para envolver JDBC.
Propagar SQLException hasta los controladores web exponiendo detalles internos de la base de datos.
Idéntico en 17 y 21.
Se conecta conManejo de ExcepcionesSpring DataAccessExceptionArquitectura Limpia
Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.
Bloque 1: Conexión y PreparedStatement (0 - 25 min)
Qué estudiar. Uso de Connection y PreparedStatement con H2 en memoria.
Pregunta a responder. ¿Por qué jamás concatenar cadenas en sentencias SQL?
Resultado esperado. Script que crea una tabla e inserta 3 registros con parámetros tipados.
Bloque 2: Mapeo Manual con ResultSet (25 - 50 min)
Qué estudiar. Iteración de ResultSet y mapeo a Records inmutables.
Pregunta a responder. ¿Cómo mapear tipos fecha (Timestamp a Instant) correctamente?
Resultado esperado. Método List<TareaRecord> listarTodas() mapeando campos a mano.
Bloque 3: Transacciones Manuales (50 - 80 min)
Qué estudiar. Manejo de setAutoCommit(false), commit() y rollback().
Pregunta a responder. ¿Cómo asegurar que dos inserciones fallen juntas si la segunda tiene error?
Resultado esperado. Test JUnit que fuerce un fallo y verifique que la BD no guardó nada.
Cierre y Reflexión (80 - 90 min)
Qué estudiar. Reflexión: ¿por qué este trabajo manual justifica usar Spring Data en el Día 9?
Pregunta a responder. Enumerar los 3 dolores del código JDBC artesanal.
Resultado esperado. Checklist del día registrado en la app.
Tres formas de comprobar si lo has entendido, de menos a más exigente: explicarlo con tus palabras, responder preguntas trampa y resolver el reto del tutor.
Explícalo desde cero
Lee cada ficha y explícala en voz alta antes de seguir: si puedes contárselo a alguien que empieza, lo has entendido.
Por qué PreparedStatement previene la inyección SQL
Lo que debes entender
El motor de la base de datos compila la estructura gramatical del SQL primero ("buscar en tabla X por columna Y"). Cuando luego recibe el parámetro, lo trata puramente como dato, sin importar si dentro contiene comillas o sentencias DROP TABLE.
Analogía para contarlo
Un formulario impreso con una casilla fija para tu nombre: aunque escribas "Quémese este edificio" en la casilla de tu nombre, el formulario solo guarda tu nombre; nadie va a quemar nada.
Lo que suele explicarse mal
Decir que PreparedStatement "limpia las comillas" (sanitización de texto). No limpia nada; la clave es que compila la consulta antes de recibir los valores.
Señal de que aún no lo dominas
Si dices que concatenar cadenas es seguro si "escapas" las comillas a mano.
Preguntas trampa
Responde de memoria, en voz alta o por escrito, y después abre la respuesta razonada: lo importante es el porqué, no la palabra exacta.
Nivel 1 (Básico)¿Por qué ResultSet.next() debe llamarse antes de leer la primera fila de resultados?Ver respuesta
Respuesta razonada
Porque el cursor de un ResultSet se coloca inicialmente antes de la primera fila. next() lo mueve a la siguiente fila y devuelve true si existe. Este diseño permite un único patrón para cualquier número de filas, incluido cero: while (rs.next()) { ... }. Si el resultado está vacío, el bucle no se ejecuta.
Leer sin haber llamado a next() lanza SQLException («ResultSet not positioned properly» o similar). Para una consulta que debe devolver como mucho una fila, se usa if (rs.next()) y, si importa, se comprueba que no haya una segunda.
Qué está evaluando la pregunta
Porque el cursor de ResultSet se posiciona inicialmente antes de la primera fila. next() avanza el cursor a la primera fila válida y devuelve false cuando no quedan más filas.
Nivel 2 (Intermedio)Si cierras una Connection dentro de un try-with-resources, ¿qué pasa con los PreparedStatement creados a partir de ella?Ver respuesta
Respuesta razonada
Según la especificación JDBC, cerrar un Statement cierra su ResultSet, y cerrar una Connection libera los recursos asociados, incluidos sus statements. Con una conexión física eso es suficiente.
Pero con un pool, close() no cierra la conexión física: solo la devuelve. Los pools como HikariCP rastrean los statements abiertos y los cierran al devolver la conexión, pero no todos los pools ni drivers lo hacen igual, y mientras tanto mantienes cursores abiertos en la base de datos. La práctica correcta es declarar Connection, PreparedStatement y ResultSet en try-with-resources, que los cierra en orden inverso sin depender del pool.
Qué está evaluando la pregunta
La especificación JDBC establece que cerrar una conexión cierra automáticamente sus sentencias y result sets asociados, aunque cerrarlos explícitamente en el try es la mejor práctica.
Nivel 3 (Entrevista Senior)¿Cómo impactan los Virtual Threads de Java 21 en las llamadas bloqueantes de JDBC comparado con Java 17?Ver respuesta
Respuesta razonada
JDBC es bloqueante: el hilo espera a que la base de datos responda. Con Java 17 y hilos de plataforma, cada petición en espera ocupa un hilo del sistema operativo (alrededor de 1 MB de pila), así que el número de peticiones concurrentes está limitado por el tamaño del pool de hilos.
Con Virtual Threads de Java 21, cuando un hilo virtual se bloquea en E/S, la JVM lo desmonta de su hilo portador y este queda libre para otro hilo virtual. Puedes tener miles de peticiones esperando a la base de datos con muy pocos hilos reales. Dos matices: los drivers antiguos que usaban synchronized alrededor de la E/S provocaban pinning (el hilo virtual no se desmonta); el driver de PostgreSQL lo corrigió en la versión 42.6. Y la base de datos sigue siendo el límite: el pool de conexiones decide cuántas consultas se ejecutan a la vez, así que mejora el rendimiento de la aplicación, no de la base de datos.
Qué está evaluando la pregunta
En Java 17, una llamada JDBC bloqueante mantiene ocupado un hilo pesado del SO (~1MB). En Java 21, el Virtual Thread se suspende (unmount) del hilo físico portador (Carrier Thread) mientras la BD responde, permitiendo atender miles de peticiones bloqueantes simultáneas con poquísimos hilos del SO.
Reto del tutor
Un candidato dice: "En mi app hago DriverManager.getConnection(...) en cada consulta porque así me aseguro de tener una conexión fresca y limpia". Evalúa esta respuesta.
Lo que una respuesta suele tener bien
Reconoce que cada consulta necesita una conexión limpia.
Lo que suele quedarse en la superficie
Decir que "es lento" sin explicar por qué.
El concepto de fondo que suele faltar
No entender el handshake TCP, autenticación de usuario y consumo de descriptores en el servidor de base de datos.
Cómo lo respondería alguien senior en una entrevista
Es catastrófico para producción: crear una conexión con DriverManager implica un handshake TCP, negociación TLS y autenticación en la BD (tardando entre 20ms y 100ms por consulta). Bajo 200 usuarios concurrentes, el servidor colapsará por agotamiento de conexiones. La solución obligatoria es un pool como HikariCP, que mantiene conexiones precalentadas y las presta en microsegundos.
Proyecto «Pedidos» · paso 9 de 30
Persistir pedidos con JDBC
Guardar pedidos y líneas en una base de datos relacional y sentir el coste del mapeo manual.
De qué va el proyecto
El backend de una tienda online: clientes, catálogo de productos y pedidos con sus líneas, pagos y envíos. Empieza como un proyecto Maven con records y termina como un sistema con API REST, seguridad, mensajería, contenedores y observabilidad. Cada paso construye sobre el anterior, así que trabaja siempre en el mismo repositorio.
Qué hacer
Crea las tablas clientes, productos, pedidos y lineas_pedido en H2 con un script SQL.
Implementa PedidoRepositoryJdbc con PreparedStatement para guardar y buscar pedidos.
Guarda un pedido y sus líneas en una sola transacción con setAutoCommit(false) y rollback() si algo falla.
Está listo cuando
Si falla la inserción de una línea, no queda en la base de datos un pedido a medias.
Ninguna consulta concatena valores en el SQL.
Katas: ejercicios cortos
Reglas de oro
Usa SIEMPRE PreparedStatement con comodines ?.
Usa siempre try-with-resources para cerrar Connection, Statement y ResultSet.
En producción usa siempre un pool de conexiones como HikariCP.