# Database Optimization: SQL Tuning and ORM Best Practices
Table of Contents
Database optimization is crucial for building scalable Java applications. This guide covers SQL tuning techniques and ORM best practices.
SQL Query Optimization
Understanding Query Execution Plans
-- PostgreSQLEXPLAIN ANALYZESELECT u.name, o.order_date, o.totalFROM users uJOIN orders o ON u.id = o.user_idWHERE o.order_date > '2025-01-01'ORDER BY o.total DESC;
-- MySQLEXPLAINSELECT u.name, COUNT(o.id) as order_countFROM users uLEFT JOIN orders o ON u.id = o.user_idGROUP BY u.id, u.name;Indexing Strategies
-- Single column indexCREATE INDEX idx_users_email ON users(email);
-- Composite index (order matters!)CREATE INDEX idx_orders_user_date ON orders(user_id, order_date);
-- Covering index (includes all needed columns)CREATE INDEX idx_orders_covering ON orders(user_id, order_date)INCLUDE (total, status);
-- Partial index for specific conditionsCREATE INDEX idx_active_users ON users(email)WHERE status = 'active';
-- Check index usageSELECT schemaname, tablename, indexname, idx_scan, idx_tup_readFROM pg_stat_user_indexesORDER BY idx_scan ASC;Query Optimization Techniques
-- Bad: Using SELECT *SELECT * FROM orders WHERE user_id = 123;
-- Good: Select only needed columnsSELECT id, order_date, total FROM orders WHERE user_id = 123;
-- Bad: Function on indexed column prevents index useSELECT * FROM users WHERE UPPER(email) = 'USER@EXAMPLE.COM';
-- Good: Use functional index or handle in applicationCREATE INDEX idx_users_email_upper ON users(UPPER(email));SELECT * FROM users WHERE UPPER(email) = 'USER@EXAMPLE.COM';
-- Bad: OR conditions can prevent index useSELECT * FROM orders WHERE user_id = 123 OR status = 'pending';
-- Good: Use UNION for better performanceSELECT * FROM orders WHERE user_id = 123UNIONSELECT * FROM orders WHERE status = 'pending';
-- Batch operations instead of N+1 queries-- Bad: Multiple single queriesSELECT * FROM orders WHERE id = 1;SELECT * FROM orders WHERE id = 2;SELECT * FROM orders WHERE id = 3;
-- Good: Single batch querySELECT * FROM orders WHERE id IN (1, 2, 3);Hibernate/JPA Best Practices
Entity Mapping Optimization
import javax.persistence.*;import org.hibernate.annotations.*;import java.util.*;
@Entity@Table(name = "users", indexes = { @Index(name = "idx_email", columnList = "email"), @Index(name = "idx_created", columnList = "created_at")})@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(unique = true, nullable = false) private String email;
@Column(name = "created_at") private LocalDateTime createdAt;
// Use LAZY loading by default @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) @BatchSize(size = 25) // Batch fetch to avoid N+1 private Set orders = new HashSet<>();
// Bidirectional mapping @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "department_id") private Department department;}Solving N+1 Query Problem
import org.springframework.data.jpa.repository.*;import org.springframework.stereotype.Repository;import java.util.List;
@Repositorypublic interface UserRepository extends JpaRepository {
// Bad: Will cause N+1 queries List findAll();
// Good: Use JOIN FETCH to eagerly load associations @Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.orders") List findAllWithOrders();
// Use EntityGraph for dynamic fetching @EntityGraph(attributePaths = {"orders", "department"}) @Query("SELECT u FROM User u WHERE u.status = :status") List findByStatusWithAssociations(@Param("status") String status);
// Fetch only IDs when you don't need full entities @Query("SELECT u.id FROM User u WHERE u.createdAt > :date") List findUserIdsSince(@Param("date") LocalDateTime date);}Batch Processing
import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import javax.persistence.EntityManager;import java.util.List;
@Servicepublic class OrderService {
private final EntityManager entityManager; private static final int BATCH_SIZE = 50;
public OrderService(EntityManager entityManager) { this.entityManager = entityManager; }
@Transactional public void batchInsertOrders(List orders) { for (int i = 0; i < orders.size(); i++) { entityManager.persist(orders.get(i));
// Flush and clear every BATCH_SIZE entities if (i > 0 && i % BATCH_SIZE == 0) { entityManager.flush(); entityManager.clear(); } } entityManager.flush(); entityManager.clear(); }
@Transactional public void batchUpdateOrders(List orderIds, String newStatus) { // Use bulk update instead of individual updates entityManager.createQuery( "UPDATE Order o SET o.status = :status WHERE o.id IN :ids" ) .setParameter("status", newStatus) .setParameter("ids", orderIds) .executeUpdate(); }}Query Optimization with Projections
import org.springframework.data.jpa.repository.JpaRepository;import org.springframework.data.jpa.repository.Query;import java.util.List;
// DTO projection interfacepublic interface UserSummary { Long getId(); String getEmail(); Integer getOrderCount();}
@Repositorypublic interface UserRepository extends JpaRepository {
// Projection query - fetches only needed fields @Query("SELECT u.id as id, u.email as email, COUNT(o) as orderCount " + "FROM User u LEFT JOIN u.orders o " + "GROUP BY u.id, u.email") List findUserSummaries();
// Class-based projection @Query("SELECT new com.example.dto.UserDTO(u.id, u.email, u.name) " + "FROM User u WHERE u.status = :status") List findUserDTOs(@Param("status") String status);}
// DTO classpublic class UserDTO { private Long id; private String email; private String name;
public UserDTO(Long id, String email, String name) { this.id = id; this.email = email; this.name = name; }
// Getters and setters}Second-Level Cache Configuration
spring.jpa.properties.hibernate.cache.use_second_level_cache=truespring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactoryspring.jpa.properties.hibernate.cache.use_query_cache=truespring.jpa.properties.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider
// Entity with cache@Entity@Cacheable@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)public class Product { @Id private Long id; private String name; private BigDecimal price;}
// Repository with query cache@Repositorypublic interface ProductRepository extends JpaRepository {
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true")) @Query("SELECT p FROM Product p WHERE p.category = :category") List findByCategory(@Param("category") String category);}Connection Pool Configuration
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 connection-test-query: SELECT 1 leak-detection-threshold: 60000 jpa: properties: hibernate: jdbc: batch_size: 50 order_inserts: true order_updates: true jdbc.batch_versioned_data: truePerformance Monitoring
import org.springframework.boot.actuate.health.Health;import org.springframework.boot.actuate.health.HealthIndicator;import org.springframework.stereotype.Component;import com.zaxxer.hikari.HikariDataSource;
@Componentpublic class DatabaseHealthIndicator implements HealthIndicator {
private final HikariDataSource dataSource;
public DatabaseHealthIndicator(HikariDataSource dataSource) { this.dataSource = dataSource; }
@Override public Health health() { var pool = dataSource.getHikariPoolMXBean();
return Health.up() .withDetail("active_connections", pool.getActiveConnections()) .withDetail("idle_connections", pool.getIdleConnections()) .withDetail("total_connections", pool.getTotalConnections()) .withDetail("threads_awaiting", pool.getThreadsAwaitingConnection()) .build(); }}Database Migration Best Practices
-- Flyway migration example: V1__create_users_table.sqlCREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, status VARCHAR(50) DEFAULT 'active', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX idx_users_email ON users(email);CREATE INDEX idx_users_status ON users(status);
-- V2__add_user_preferences.sqlCREATE TABLE user_preferences ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, theme VARCHAR(50) DEFAULT 'light', language VARCHAR(10) DEFAULT 'en', UNIQUE(user_id));
CREATE INDEX idx_user_preferences_user_id ON user_preferences(user_id);