Problemas detectados en el código actual
- Problema N+1: 1 query por Order al listar órdenes → 500+ queries por request en producción
- Sin índices en
tenant_id,status,created_at→ full table scan - Transacciones en el Controller — sin rollback garantizado, sin optimización de lectura
- Sin paginación — carga completa de todos los pedidos de una vez
- FetchType.EAGER en colecciones — problemas de memoria y joins innecesarios
Queries / request (antes)
503
↑ N+1 en OrderLines
Queries / request (después)
3
↓ 99.4% reducción
Latencia P99 (antes)
4.2s
Inaceptable en prod
Latencia P99 (después)
38ms
↓ 99.1% mejora
Pool size HikariCP
20
Configurado para 4 vCPUs
1
Diseño de Entidades — Dominio PedidosNow
Entity Design + Auditing + Indexes
TenantEntity.java
Raíz de agregado
@Entity
@Table(name = "tenants", indexes = {
@Index(name = "idx_tenants_slug",
columnList = "slug", unique = true),
@Index(name = "idx_tenants_status",
columnList = "status")
})
@EntityListeners(AuditingEntityListener.class)
public class TenantEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String name;
@Column(nullable = false, unique = true, length = 120)
private String slug;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TenantStatus status = TenantStatus.ACTIVE;
// 1:N lazy (no EAGER) — cargado solo con JOIN FETCH
@OneToMany(mappedBy = "tenant",
cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderEntity> orders = new ArrayList<>();
@CreatedDate private Instant createdAt;
@LastModifiedDate private Instant updatedAt;
}
OrderEntity.java
Índice compuesto
@Entity
@Table(name = "orders", indexes = {
// Cubre el filtro más frecuente: tenant + estado + fecha
@Index(name = "idx_orders_tenant_status_date",
columnList = "tenant_id, status, created_at DESC")
})
@EntityListeners(AuditingEntityListener.class)
public class OrderEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "tenant_id", nullable = false)
private TenantEntity tenant;
@Enumerated(EnumType.STRING)
private OrderStatus status = OrderStatus.DRAFT;
// LAZY en colecciones — evita el N+1
@OneToMany(mappedBy = "order",
cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLineEntity> lines = new ArrayList<>();
@Column(precision = 12, scale = 2)
private BigDecimal totalAmount;
@CreatedDate private Instant createdAt;
@LastModifiedDate private Instant updatedAt;
}
2
Repositorios — Prevención N+1 + Proyecciones DTO
Fetch Join + Interface Projections
❌ ANTES — Código que dispara N+1
503 queries
// ❌ MAL: findAll() carga solo Order; al acceder a .getLines() Hibernate
// dispara 1 query adicional POR CADA Order → N+1 explosión
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {}
// En el servicio:
List<OrderEntity> orders = orderRepo.findAll(); // 1 query
orders.forEach(o -> o.getLines().size()); // +500 queries 💀
✅ DESPUÉS — Fetch Join + Paginación
3 queries
public interface OrderRepository
extends JpaRepository<OrderEntity, Long> {
// Carga Order + lines en 1 sola query
@Query("select o from OrderEntity o " +
"left join fetch o.lines " +
"where o.id = :id")
Optional<OrderEntity> findWithLines(
@Param("id") Long id);
// Listado paginado por tenant + estado (usa el índice compuesto)
@Query("select o from OrderEntity o " +
"where o.tenant.id = :tenantId " +
"and o.status = :status")
Page<OrderEntity> findByTenantAndStatus(
@Param("tenantId") Long tenantId,
@Param("status") OrderStatus status,
Pageable pageable);
// Cursor-pagination (evita OFFSET en tablas grandes)
@Query("select o from OrderEntity o " +
"where o.tenant.id = :tenantId " +
"and o.id > :lastId " +
"order by o.id asc")
List<OrderEntity> findNextPage(
@Param("tenantId") Long tenantId,
@Param("lastId") Long lastId,
Pageable pageable);
}
Proyecciones DTO — Solo columnas necesarias
Lightweight
// Proyección para listado (select solo id, status, total, fecha)
public interface OrderSummary {
Long getId();
OrderStatus getStatus();
BigDecimal getTotalAmount();
Instant getCreatedAt();
// Expresión derivada con @Value (SpEL)
@Value("#{target.tenant.name}")
String getTenantName();
}
// En el repositorio:
Page<OrderSummary> findAllBy(Pageable pageable);
// Hibernate genera SELECT optimizado:
// SELECT o.id, o.status, o.total_amount,
// o.created_at, t.name
// FROM orders o JOIN tenants t ON o.tenant_id = t.id
// → Sin cargar entidad completa ni listas
// DTO record para API response (Java 17)
public record OrderListItem(
Long id,
String tenantName,
OrderStatus status,
BigDecimal total,
Instant createdAt
) {
public static OrderListItem from(OrderSummary s) {
return new OrderListItem(
s.getId(), s.getTenantName(),
s.getStatus(), s.getTotalAmount(),
s.getCreatedAt());
}
}
3
Servicio Transaccional Correcto
@Transactional en Service, no en Controller
OrderService.java
Service Layer
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepo;
private final TenantRepository tenantRepo;
// ✅ readOnly = true → Hibernate omite dirty checking
// + PostgreSQL puede enrutar a réplica de lectura
@Transactional(readOnly = true)
public Page<OrderListItem> listOrders(
Long tenantId, OrderStatus status,
int page, int size) {
Pageable pageable = PageRequest.of(
page, size,
Sort.by("createdAt").descending());
return orderRepo
.findByTenantAndStatus(tenantId, status, pageable)
.map(OrderListItem::from);
}
// ✅ Escritura: transacción completa con rollback automático
@Transactional
public OrderListItem confirmOrder(Long orderId) {
OrderEntity order = orderRepo
.findWithLines(orderId)
.orElseThrow(() -> new EntityNotFoundException(
"Order not found: " + orderId));
// Validación de negocio dentro de la misma tx
if (order.getLines().isEmpty()) {
throw new IllegalStateException(
"Cannot confirm empty order");
}
order.setStatus(OrderStatus.CONFIRMED);
order.setTotalAmount(calculateTotal(order));
// Sin save() explícito — dirty checking lo persiste
return OrderListItem.from(order);
}
}
Propagación de transacciones
| Operación | Anotación | Por qué |
|---|---|---|
| Listado / búsqueda | readOnly=true |
Sin dirty check; permite réplica |
| Crear / actualizar | @Transactional |
Rollback garantizado |
| Borrado lógico | @Transactional |
Atomicidad con auditoría |
| Método interno | No @Transactional | Spring AOP no intercepta self-calls |
Soft Delete Pattern
Auditoría
// Mixin de auditoría reutilizable
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
@Column
private Instant deletedAt;
public boolean isDeleted() {
return deletedAt != null;
}
}
// Filter global — excluye soft-deleted automáticamente
@FilterDef(name = "deletedFilter",
defaultCondition = "deleted_at IS NULL")
@Filter(name = "deletedFilter")
4
Paginación — Offset vs Cursor
Cuándo usar cada estrategia
Offset Pagination (panel de admin)
Saltable
// Bueno para: panel con control de página, exportaciones
// Malo para: tablas > 100k filas (OFFSET es costoso)
@GetMapping("/orders")
@Transactional(readOnly = true)
public ResponseEntity<Page<OrderListItem>> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "CONFIRMED")
OrderStatus status) {
Pageable p = PageRequest.of(
page, size,
Sort.by("createdAt").descending());
return ResponseEntity.ok(
orderService.listOrders(
currentTenantId(), status, page, size));
}
Cursor Pagination (feed en tiempo real)
Escalable
// Bueno para: scroll infinito, sync incremental, APIs públicas
// Sin OFFSET → rendimiento estable en tablas grandes
@GetMapping("/orders/stream")
@Transactional(readOnly = true)
public CursorPage<OrderListItem> stream(
@RequestParam(defaultValue = "0") Long lastId,
@RequestParam(defaultValue = "20") int size) {
List<OrderListItem> items = orderRepo
.findNextPage(
currentTenantId(), lastId,
PageRequest.ofSize(size))
.stream()
.map(OrderListItem::from)
.toList();
Long nextCursor = items.isEmpty()
? null
: items.getLast().id();
return new CursorPage<>(items, nextCursor);
}
// SQL generado: WHERE id > :lastId ORDER BY id ASC LIMIT 20
// → usa Primary Key index → O(1) sin importar volumen
5
Configuración HikariCP + application.properties
Pooling optimizado para 4 vCPUs
⚙️ application-prod.properties
# ── DataSource ──────────────────────────────
spring.datasource.url=jdbc:postgresql://localhost:5432/pedidosnow
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver
# ── HikariCP ────────────────────────────────
# Fórmula Hikari: (core_count * 2) + effective_spindle_count
# Para 4 vCPUs + SSD: (4*2)+1 = 9 → redondeamos a 10-20
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=30000 # 30s
spring.datasource.hikari.idle-timeout=600000 # 10min
spring.datasource.hikari.max-lifetime=1800000 # 30min
spring.datasource.hikari.validation-timeout=5000 # 5s
spring.datasource.hikari.keepalive-time=30000 # evita timeout de firewall
spring.datasource.hikari.pool-name=PedidosNowPool
# ── JPA / Hibernate ─────────────────────────
spring.jpa.hibernate.ddl-auto=validate # nunca update/create en prod
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
# ── Logging (solo en dev/staging) ───────────
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE
# ── Flyway ──────────────────────────────────
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=true
spring.flyway.locations=classpath:db/migration
Batch writes — Optimización masiva
Performance
// ✅ saveAll() + batch_size=50 → 1 batch INSERT
// en lugar de 100 INSERT individuales
@Transactional
public void importOrderLines(
OrderEntity order,
List<OrderLineDTO> dtos) {
List<OrderLineEntity> lines = dtos.stream()
.map(dto -> OrderLineEntity.from(dto, order))
.toList();
// Hibernate agrupa en batches de 50
lineRepo.saveAll(lines);
}
// Requiere: GenerationType.SEQUENCE (no IDENTITY)
// IDENTITY deshabilita el batch automáticamente
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE,
generator = "order_line_seq")
@SequenceGenerator(name = "order_line_seq",
allocationSize = 50)
private Long id;
Flyway — Migración típica
V1__init.sql
-- V1__create_orders_schema.sql
-- ✅ Idempotente y aditiva
CREATE TABLE IF NOT EXISTS tenants (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_tenants_status
ON tenants (status) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS orders (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
total_amount NUMERIC(12,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índice compuesto para el patrón de consulta dominante
CREATE INDEX IF NOT EXISTS idx_orders_tenant_status_date
ON orders (tenant_id, status, created_at DESC);
6
Tests con @DataJpaTest + Testcontainers
Validación de queries y rendimiento SQL
OrderRepositoryTest.java
@DataJpaTest
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("pedidosnow_test");
@Autowired OrderRepository orderRepo;
@Autowired TenantRepository tenantRepo;
@Autowired TestEntityManager em;
@Test
void findWithLines_shouldLoadOrderAndLines_inSingleQuery() {
// Arrange
TenantEntity tenant = buildTenant("distribuidora-norte");
tenantRepo.save(tenant);
OrderEntity order = buildOrderWithLines(tenant, 3);
orderRepo.save(order);
em.clear(); // limpia 1st-level cache → fuerza query real
// Act — verificamos que carga lines sin N+1
OrderEntity found = orderRepo
.findWithLines(order.getId())
.orElseThrow();
// Assert
assertThat(found.getLines()).hasSize(3);
assertThat(found.getTenant().getSlug())
.isEqualTo("distribuidora-norte");
}
@Test
void findByTenantAndStatus_shouldPaginate() {
// Arrange — 25 órdenes confirmadas
TenantEntity tenant = tenantRepo.save(buildTenant("t1"));
IntStream.range(0, 25).forEach(i ->
orderRepo.save(buildConfirmedOrder(tenant)));
em.clear();
Pageable p = PageRequest.of(0, 10);
// Act
Page<OrderEntity> page = orderRepo
.findByTenantAndStatus(
tenant.getId(), OrderStatus.CONFIRMED, p);
// Assert
assertThat(page.getTotalElements()).isEqualTo(25);
assertThat(page.getContent()).hasSize(10);
assertThat(page.getTotalPages()).isEqualTo(3);
}
}
✓
Reglas de oro — Resumen accionable
Entidades lean
Solo mapea las columnas que la app necesita. Sin
SELECT *, sin colecciones EAGER en entidades de alto volumen.Queries intencionales
Cada query tiene un propósito. Usa
JOIN FETCH cuando necesites el grafo completo. Proyecciones DTO para listados.Transacciones cortas
No hagas llamadas HTTP o I/O dentro de una transacción.
readOnly=true en toda lectura para evitar dirty checking.Flyway, no DDL-auto
Nunca
update ni create en producción. Las migraciones Flyway son auditables, reversibles y versionadas.Índices para tus queries
Diseña índices a partir del patrón
WHERE + ORDER BY real. Índices parciales (WHERE deleted_at IS NULL) para tablas con soft-delete.Testcontainers, no H2
H2 no emula el dialecto PostgreSQL. Testcontainers con imagen
postgres:15-alpine replica exactamente la base de producción.