All operations in a transaction succeed or all are rolled back. No partial writes.
How it works: DB maintains a write-ahead log (WAL). On commit, changes are flushed to WAL first. On rollback or crash, WAL allows undoing partial work.
-- Transfer $100: both statements must succeed or both rolled back
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- If UPDATE #2 fails: UPDATE #1 is reversed. Balance never disappears.
A transaction moves the database from one valid state to another. Constraints (NOT NULL, UNIQUE, CHECK, FK) are enforced at commit.
Note: Consistency is partly the DB's job (constraints) and partly the application's job (business rules). The DB can't enforce "balance ≥ 0" unless you add a CHECK constraint.
Concurrent transactions don't see each other's intermediate states. The degree depends on the isolation level (see Q2).
Without isolation: two withdrawals running simultaneously could both read the same balance, both subtract, and result in insufficient funds going negative.
Once committed, data survives crashes. Guaranteed by flushing the WAL to disk before acknowledging the commit. Even if the server crashes a millisecond after COMMIT, the data is safe.
| Level | Dirty Read | Non-Repeatable | Phantom | Use when |
|---|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible | Never in production |
| READ COMMITTED | Prevented | Possible | Possible | Default (PostgreSQL, Oracle) |
| REPEATABLE READ | Prevented | Prevented | Possible* | Reports, consistent multi-read |
| SERIALIZABLE | Prevented | Prevented | Prevented | Critical financial ops |
* PostgreSQL's REPEATABLE READ also prevents phantoms (uses MVCC snapshots).
@Transactional(isolation = Isolation.READ_COMMITTED) // default
@Transactional(isolation = Isolation.SERIALIZABLE) // for critical ops
An index is a separate data structure (usually B-tree) that maps column values to row locations. Speeds up reads at the cost of write overhead (every INSERT/UPDATE/DELETE must maintain the index).
| Type | Use for |
|---|---|
| B-tree (default) | Range queries, equality, ORDER BY, <, >, BETWEEN |
| Hash | Equality only (=). Faster point lookups, no range support |
| Composite | Queries filtering on multiple columns |
| Partial/Filtered | Index a subset of rows (WHERE status = 'ACTIVE') |
| Covering | Includes all columns a query needs — avoids table lookup entirely |
CREATE INDEX idx_order ON orders(customer_id, status, created_at);
-- USES the index (leftmost prefix matched):
WHERE customer_id = 5
WHERE customer_id = 5 AND status = 'PENDING'
WHERE customer_id = 5 AND status = 'PENDING' AND created_at > '2024-01-01'
-- Does NOT use the index (skips customer_id):
WHERE status = 'PENDING'
WHERE created_at > '2024-01-01'
-- Query only needs customer_id and order_total:
SELECT customer_id, order_total FROM orders WHERE customer_id = 5;
-- Covering index: all needed columns are IN the index — no table access:
CREATE INDEX idx_covering ON orders(customer_id) INCLUDE (order_total);
-- "Index-only scan" — significantly faster on high-volume reads.
EXPLAIN ANALYZE
SELECT o.id, c.name, o.total
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING' AND o.created_at > NOW() - INTERVAL '7 days';
| Symptom | What it means | Fix |
|---|---|---|
| Seq Scan on large table | No index used | Add index on filtered columns |
| Nested Loop with many rows | No join index | Index the join column |
| High "rows estimated" vs "actual" | Stale statistics | ANALYZE table_name |
| Sort on large dataset | No index for ORDER BY | Add index matching ORDER BY |
| Bitmap Heap Scan | Multiple indexes being combined | Often fine; composite index may help |
SELECT * with explicit columns (enables covering indexes).OFFSET for large pages:
-- SLOW for large offsets (scans and discards rows):
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 10000;
-- FAST: keyset pagination (uses index directly):
SELECT * FROM orders WHERE id > :lastSeenId ORDER BY id LIMIT 20;
// 1 query to load 100 orders:
List<Order> orders = orderRepo.findAll();
// Then for EACH order, a separate query for its items (lazy loading):
orders.forEach(o -> o.getItems().size()); // triggers N more queries
// Result: 1 + 100 = 101 queries. With 1000 orders: 1001 queries.
spring.jpa.show-sql=true and spring.jpa.properties.hibernate.format_sql=true.@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
List<Order> findWithItems(@Param("status") String status);
// 1 query with JOIN — all data fetched at once
@EntityGraph(attributePaths = {"items", "customer"})
List<Order> findByStatus(String status);
// Declarative fetch plan — no JPQL needed
@Query("SELECT new com.example.OrderSummary(o.id, o.total, c.name) FROM Order o JOIN o.customer c")
List<OrderSummary> getOrderSummaries();
// Returns exactly what you need, no entity overhead, no lazy loading
@BatchSize(size = 50)
@OneToMany List<Item> items;
// Hibernate loads items in batches of 50 → 1 + ceil(N/50) queries instead of 1+N
spring.jpa.open-in-view=false. Eager-load what you need in the service layer instead.Lock the row immediately when reading. Other transactions must wait.
-- Database level:
SELECT * FROM account WHERE id = 1 FOR UPDATE; -- row locked until COMMIT
-- JPA:
Account acc = em.find(Account.class, id, LockModeType.PESSIMISTIC_WRITE);
Use when: high contention and cost of failure is high (balance deduction, seat booking, inventory reservation). Lock guarantees you won't conflict.
No lock held. Add a version column. At commit, check that version hasn't changed — if it has, another transaction beat you, retry.
@Entity
class Account {
@Version
private int version; // auto-incremented by Hibernate on every update
}
-- Hibernate generates:
UPDATE account SET balance = 900, version = 2 WHERE id = 1 AND version = 1;
-- If 0 rows updated → version changed → OptimisticLockException → retry
Use when: low contention and retry is cheap (profile updates, viewing data). High throughput — no blocking.
| Pessimistic | Optimistic | |
|---|---|---|
| Contention | High | Low |
| Cost of conflict | High (money, seats) | Low (retry is cheap) |
| Throughput | Lower (blocking) | Higher (no locks) |
| Deadlock risk | Yes | No |
synchronized) don't work across multiple pods. Use DB-level locking or a distributed lock (Redis Redlock) for cross-instance coordination.@Retryable(OptimisticLockException.class). Cap retries to avoid infinite loops.Two (or more) transactions each hold a lock the other needs. Neither can proceed. The DB detects this via a wait-for graph and kills one transaction (the "victim") to break the cycle.
-- Transaction A: Transaction B:
BEGIN; BEGIN;
UPDATE account SET ... WHERE id=1; UPDATE account SET ... WHERE id=2;
-- (waits for B's lock on id=2) -- (waits for A's lock on id=1)
UPDATE account SET ... WHERE id=2; UPDATE account SET ... WHERE id=1;
-- DEADLOCK: DB kills one transaction
SET lock_timeout = '3s' — transaction fails fast instead of waiting indefinitely.// Always lock in consistent order by account ID:
Long firstId = Math.min(fromId, toId);
Long secondId = Math.max(fromId, toId);
em.find(Account.class, firstId, LockModeType.PESSIMISTIC_WRITE);
em.find(Account.class, secondId, LockModeType.PESSIMISTIC_WRITE);
// Deadlock = transient error — safe to retry with backoff:
@Retryable(
retryFor = { DeadlockLoserDataAccessException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2)
)
@Transactional
public void transfer(Long from, Long to, BigDecimal amount) { ... }
log_lock_waits=on to see lock waits before they become deadlocks. Trace the queries and identify the locking order.Opening a DB connection is expensive (~50–200ms: TCP handshake, authentication, session setup). A pool pre-creates N connections and lends them to threads. Connection acquired in microseconds.
spring:
datasource:
hikari:
maximum-pool-size: 20 # max connections to DB
minimum-idle: 5 # keep 5 warm connections always
connection-timeout: 3000 # fail fast if no connection available (3s)
idle-timeout: 600000 # close idle connections after 10min
max-lifetime: 1800000 # recycle connections before DB kills them (30min)
keepalive-time: 30000 # send keepalive query to prevent idle timeout
HikariCP's advice: pool_size = (core_count * 2) + effective_spindle_count. For most services: 10–20 connections per pod is the sweet spot.
max_connections limit.// Symptoms: requests timing out, HikariCP timeout exceptions in logs
// Root cause: threads holding connections too long (slow queries, forgotten transactions)
// Debug: monitor pool metrics:
management.metrics.enable.hikari=true
// Grafana: hikaricp_connections_active, hikaricp_connections_pending
// If pending > 0 for more than a second → pool is too small OR queries are too slow
leakDetectionThreshold=2000: it logs a warning if a connection is held longer than 2 seconds, with a stack trace pointing to where it was acquired.