Transactions (ACID)

Ensuring data integrity and consistency

← Back to Index

What is a Transaction?

A transaction is a sequence of database operations that are treated as a single, indivisible unit of work. Either ALL operations complete successfully (commit), or NONE of them do (rollback). Transactions are fundamental to maintaining data integrity.

The Classic Example: Bank Transfer

// Transfer $100 from Account A to Account B
// This MUST be atomic - both operations or neither!

BEGIN TRANSACTION;

// Step 1: Debit from Account A
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';

// Step 2: Credit to Account B
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';

// If both succeed
COMMIT;

// If anything fails
ROLLBACK;

// Without transactions, if step 1 succeeds but step 2 fails:
// - Account A loses $100
// - Account B doesn't gain $100
// - $100 just vanishes! BAD!

ACID Properties

ACID is the set of properties that guarantee transaction reliability.

A - Atomicity

// ATOMICITY: "All or Nothing"
// A transaction is an atomic unit - it either completes entirely or not at all.

┌───────────────────────────────────────────────────────────┐
│                     TRANSACTION                            │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐       │
│  │ Operation 1 │─▶│ Operation 2 │─▶│ Operation 3 │       │
│  └─────────────┘  └─────────────┘  └─────────────┘       │
│                                                           │
│  If ANY operation fails → ROLLBACK ALL                   │
│  If ALL operations succeed → COMMIT ALL                  │
└───────────────────────────────────────────────────────────┘

// Example: Creating an order
try {
    tx.begin();

    // 1. Create order
    em.persist(order);

    // 2. Create order items
    for (OrderItem item : items) {
        em.persist(item);
    }

    // 3. Update inventory
    for (OrderItem item : items) {
        Product p = em.find(Product.class, item.getProductId());
        p.setStock(p.getStock() - item.getQuantity());
    }

    // 4. Charge customer
    paymentService.charge(order.getCustomerId(), order.getTotal());

    tx.commit();  // All 4 steps complete together
} catch (Exception e) {
    tx.rollback();  // All 4 steps undone
}

C - Consistency

// CONSISTENCY: Database moves from one valid state to another
// Transactions must maintain all database rules and constraints.

// Example constraints that must be maintained:
// - Primary key uniqueness
// - Foreign key references
// - Check constraints (balance >= 0)
// - Unique constraints (email must be unique)

// Before transaction: Total money in system = $10,000
// After transaction: Total money in system = $10,000
// Money doesn't appear or disappear!

┌─────────────────┐                      ┌─────────────────┐
│  VALID STATE    │                      │  VALID STATE    │
│                 │                      │                 │
│  Account A: 500 │      Transaction     │  Account A: 400 │
│  Account B: 300 │  ─────────────────▶  │  Account B: 400 │
│  ─────────────  │                      │  ─────────────  │
│  Total:    800  │                      │  Total:    800  │
└─────────────────┘                      └─────────────────┘

I - Isolation

// ISOLATION: Concurrent transactions don't interfere with each other
// Each transaction sees a consistent snapshot of data.

// Problem without isolation (Lost Update):

Time    Transaction 1             Transaction 2
───────────────────────────────────────────────────
T1      Read balance = 100
T2                                Read balance = 100
T3      Balance = 100 + 50
T4      Write balance = 150
T5                                Balance = 100 + 30
T6                                Write balance = 130  ← T1's update LOST!

// With proper isolation:
Time    Transaction 1             Transaction 2
───────────────────────────────────────────────────
T1      Read balance = 100
T2                                Read balance = 100 (waits or sees 100)
T3      Balance = 100 + 50
T4      Write balance = 150
T5      COMMIT
T6                                Read balance = 150
T7                                Balance = 150 + 30
T8                                Write balance = 180  ← Both updates preserved!

D - Durability

// DURABILITY: Once committed, data survives any failure
// Power outage, crash, hardware failure - data persists!

// How databases achieve durability:

┌─────────────────────────────────────────────────────────────────┐
│                    COMMIT PROCESS                                │
│                                                                  │
│  1. Write changes to TRANSACTION LOG (Write-Ahead Logging)      │
│     └─▶ Log is on persistent storage (disk)                     │
│                                                                  │
│  2. Flush log to disk (fsync)                                   │
│     └─▶ Data guaranteed on stable storage                       │
│                                                                  │
│  3. Return "commit successful" to application                   │
│                                                                  │
│  4. Write actual data pages to disk (can be lazy)               │
│     └─▶ If crash before this, recover from log                  │
│                                                                  │
│  After commit: Even if server explodes, data is safe!           │
└─────────────────────────────────────────────────────────────────┘

ACID Summary:

  • Atomicity: All or nothing
  • Consistency: Valid state to valid state
  • Isolation: Transactions don't interfere
  • Durability: Committed = permanent

Transaction Isolation Levels

Isolation levels control how much transactions are isolated from each other. Higher isolation = more safety but less performance.

Concurrency Problems

// 1. DIRTY READ: Reading uncommitted data from another transaction
Transaction 1                    Transaction 2
─────────────────────────────────────────────────
UPDATE balance SET val=150
                                 SELECT val FROM balance  → 150 (dirty!)
ROLLBACK
                                 Uses 150, but actual value is still 100!

// 2. NON-REPEATABLE READ: Same query returns different results
Transaction 1                    Transaction 2
─────────────────────────────────────────────────
SELECT val FROM balance → 100
                                 UPDATE balance SET val=150
                                 COMMIT
SELECT val FROM balance → 150   Different result!

// 3. PHANTOM READ: New rows appear in repeated query
Transaction 1                    Transaction 2
─────────────────────────────────────────────────
SELECT * WHERE age > 25  → 3 rows
                                 INSERT INTO users (age=30)
                                 COMMIT
SELECT * WHERE age > 25  → 4 rows  Phantom row!

Isolation Levels Comparison

Isolation Level Dirty Read Non-Repeatable Phantom Read Performance
READ_UNCOMMITTED Possible Possible Possible Fastest
READ_COMMITTED Prevented Possible Possible Fast
REPEATABLE_READ Prevented Prevented Possible Medium
SERIALIZABLE Prevented Prevented Prevented Slowest

Setting Isolation Levels

// JDBC
Connection conn = dataSource.getConnection();
conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);

// JPA/Hibernate
@PersistenceContext
EntityManager em;

Map<String, Object> props = new HashMap<>();
props.put("jakarta.persistence.lock.timeout", 3000);
em.find(Account.class, accountId, LockModeType.PESSIMISTIC_WRITE, props);

// Spring @Transactional
@Transactional(isolation = Isolation.SERIALIZABLE)
public void criticalOperation() {
    // Highest isolation
}

@Transactional(isolation = Isolation.READ_COMMITTED)  // Default for most DBs
public void normalOperation() {
    // Standard isolation
}
Default Isolation Levels
  • MySQL (InnoDB): REPEATABLE_READ
  • PostgreSQL: READ_COMMITTED
  • Oracle: READ_COMMITTED
  • SQL Server: READ_COMMITTED

Transactions in Java

JDBC Transactions

public void transferFunds(long fromId, long toId, BigDecimal amount) {
    Connection conn = null;

    try {
        conn = dataSource.getConnection();

        // 1. Disable auto-commit (start transaction)
        conn.setAutoCommit(false);

        // 2. Debit source account
        try (PreparedStatement debitStmt = conn.prepareStatement(
                "UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?")) {
            debitStmt.setBigDecimal(1, amount);
            debitStmt.setLong(2, fromId);
            debitStmt.setBigDecimal(3, amount);

            if (debitStmt.executeUpdate() == 0) {
                throw new RuntimeException("Insufficient funds");
            }
        }

        // 3. Credit destination account
        try (PreparedStatement creditStmt = conn.prepareStatement(
                "UPDATE accounts SET balance = balance + ? WHERE id = ?")) {
            creditStmt.setBigDecimal(1, amount);
            creditStmt.setLong(2, toId);
            creditStmt.executeUpdate();
        }

        // 4. Commit transaction
        conn.commit();

    } catch (Exception e) {
        // 5. Rollback on any error
        if (conn != null) {
            try {
                conn.rollback();
            } catch (SQLException ex) {
                ex.printStackTrace();
            }
        }
        throw new RuntimeException("Transfer failed", e);

    } finally {
        if (conn != null) {
            try {
                conn.setAutoCommit(true);  // Reset for connection pool
                conn.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    }
}

JPA/Hibernate Transactions

public void transferFunds(long fromId, long toId, BigDecimal amount) {
    EntityManager em = emf.createEntityManager();
    EntityTransaction tx = em.getTransaction();

    try {
        tx.begin();

        Account fromAccount = em.find(Account.class, fromId, LockModeType.PESSIMISTIC_WRITE);
        Account toAccount = em.find(Account.class, toId, LockModeType.PESSIMISTIC_WRITE);

        if (fromAccount.getBalance().compareTo(amount) < 0) {
            throw new RuntimeException("Insufficient funds");
        }

        fromAccount.setBalance(fromAccount.getBalance().subtract(amount));
        toAccount.setBalance(toAccount.getBalance().add(amount));

        tx.commit();

    } catch (Exception e) {
        if (tx.isActive()) {
            tx.rollback();
        }
        throw e;

    } finally {
        em.close();
    }
}

Spring @Transactional

@Service
public class AccountService {

    private final AccountRepository accountRepository;

    // Basic transaction
    @Transactional
    public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId)
            .orElseThrow(() -> new AccountNotFoundException(fromId));
        Account to = accountRepository.findById(toId)
            .orElseThrow(() -> new AccountNotFoundException(toId));

        if (from.getBalance().compareTo(amount) < 0) {
            throw new InsufficientFundsException();
        }

        from.withdraw(amount);
        to.deposit(amount);
        // Commit happens automatically if no exception
        // Rollback happens automatically on RuntimeException
    }

    // Read-only transaction (optimization)
    @Transactional(readOnly = true)
    public Account getAccount(Long id) {
        return accountRepository.findById(id).orElseThrow();
    }

    // Custom timeout
    @Transactional(timeout = 30)  // 30 seconds
    public void longRunningOperation() {
        // ...
    }

    // Specify which exceptions cause rollback
    @Transactional(
        rollbackFor = Exception.class,           // Rollback for all exceptions
        noRollbackFor = EmailException.class    // Except this one
    )
    public void createUserAndNotify(User user) {
        userRepository.save(user);
        emailService.sendWelcome(user);  // Email failure won't rollback user creation
    }
}

Transaction Propagation (Spring)

Propagation defines how transactions behave when one @Transactional method calls another.

// REQUIRED (default): Join existing or create new
@Transactional(propagation = Propagation.REQUIRED)
public void methodA() {
    // If called without transaction → creates new
    // If called within transaction → joins it
}

// REQUIRES_NEW: Always create new (suspends existing)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditLog(String action) {
    // Always runs in its own transaction
    // Commits even if outer transaction rolls back
}

// MANDATORY: Must run within existing transaction
@Transactional(propagation = Propagation.MANDATORY)
public void mustBeInTransaction() {
    // Throws exception if no transaction exists
}

// SUPPORTS: Use transaction if exists, otherwise non-transactional
@Transactional(propagation = Propagation.SUPPORTS)
public Account findAccount(Long id) {
    // Works with or without transaction
}

// NOT_SUPPORTED: Always run non-transactionally
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void nonTransactionalOperation() {
    // Suspends existing transaction
}

// NEVER: Must NOT run within transaction
@Transactional(propagation = Propagation.NEVER)
public void mustNotBeInTransaction() {
    // Throws exception if transaction exists
}

// NESTED: Nested transaction with savepoint
@Transactional(propagation = Propagation.NESTED)
public void nestedOperation() {
    // Creates savepoint, can rollback independently
}

Propagation Example

@Service
public class OrderService {

    private final PaymentService paymentService;
    private final AuditService auditService;

    @Transactional
    public void placeOrder(Order order) {
        // Save order (within this transaction)
        orderRepository.save(order);

        // Process payment (within this transaction - REQUIRED)
        paymentService.processPayment(order);  // Joins transaction

        // Audit log (separate transaction - REQUIRES_NEW)
        auditService.logOrderPlaced(order);  // Own transaction

        // If payment fails, order is rolled back
        // But audit log is already committed!
    }
}

@Service
public class PaymentService {

    @Transactional(propagation = Propagation.REQUIRED)  // Default
    public void processPayment(Order order) {
        // Part of outer transaction
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logOrderPlaced(Order order) {
        // Always commits, even if outer rolls back
    }
}

Locking Strategies

Optimistic Locking

// Optimistic: Assume no conflicts, detect them at commit time
// Best for read-heavy workloads with rare conflicts

@Entity
public class Product {

    @Id
    private Long id;

    private String name;
    private Integer quantity;

    @Version  // Optimistic locking version field
    private Long version;
}

// How it works:
// 1. Read product (version = 1)
// 2. Modify product
// 3. UPDATE product SET ..., version = 2 WHERE id = ? AND version = 1
// 4. If 0 rows updated → OptimisticLockException (someone else modified!)

@Service
public class ProductService {

    @Transactional
    public void updateQuantity(Long productId, Integer newQuantity) {
        try {
            Product product = productRepository.findById(productId).orElseThrow();
            product.setQuantity(newQuantity);
            // On save, JPA checks version
        } catch (OptimisticLockException e) {
            // Another transaction modified this product
            throw new ConcurrentModificationException("Product was modified by another user");
        }
    }
}

Pessimistic Locking

// Pessimistic: Assume conflicts, prevent them with locks
// Best for high-contention scenarios

@Service
public class InventoryService {

    @PersistenceContext
    private EntityManager em;

    @Transactional
    public void decrementStock(Long productId, int quantity) {
        // Acquire exclusive lock (FOR UPDATE)
        Product product = em.find(
            Product.class,
            productId,
            LockModeType.PESSIMISTIC_WRITE  // SELECT ... FOR UPDATE
        );

        if (product.getStock() < quantity) {
            throw new InsufficientStockException();
        }

        product.setStock(product.getStock() - quantity);
        // Lock released on commit/rollback
    }
}

// Lock modes:
LockModeType.PESSIMISTIC_READ   // Shared lock (read allowed, write blocked)
LockModeType.PESSIMISTIC_WRITE  // Exclusive lock (all access blocked)
LockModeType.PESSIMISTIC_FORCE_INCREMENT  // Exclusive + increment version

// With JPQL
List<Product> products = em.createQuery(
    "SELECT p FROM Product p WHERE p.category = :cat", Product.class)
    .setParameter("cat", category)
    .setLockMode(LockModeType.PESSIMISTIC_WRITE)
    .getResultList();

// Spring Data JPA
public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdForUpdate(@Param("id") Long id);
}

Optimistic vs Pessimistic

Aspect Optimistic Pessimistic
Approach Detect conflicts Prevent conflicts
Locking No actual locks Database row locks
Performance Better for reads Better for high contention
Conflict handling Exception at commit Blocked until lock released
Deadlock risk None Possible
Use case Web applications Financial systems

Common Transaction Pitfalls

1. Self-Invocation (Proxy Bypass)

@Service
public class OrderService {

    @Transactional
    public void placeOrder(Order order) {
        saveOrder(order);    // Calls method below
        processPayment(order);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveOrder(Order order) {
        // THIS DOESN'T GET ITS OWN TRANSACTION!
        // Self-invocation bypasses the proxy!
    }
}

// Solution: Inject self or use separate service
@Service
public class OrderService {

    @Autowired
    private OrderPersistenceService persistenceService;

    @Transactional
    public void placeOrder(Order order) {
        persistenceService.saveOrder(order);  // Proxy is used!
        processPayment(order);
    }
}

2. Catching Exceptions

// BAD: Swallowing exception prevents rollback
@Transactional
public void badMethod() {
    try {
        riskyOperation();
    } catch (Exception e) {
        log.error("Error", e);
        // Transaction commits! Data may be inconsistent!
    }
}

// GOOD: Re-throw or manually rollback
@Transactional
public void goodMethod() {
    try {
        riskyOperation();
    } catch (Exception e) {
        log.error("Error", e);
        throw e;  // Transaction rolls back
    }
}

// OR: Mark for rollback manually
@Autowired
TransactionStatus status;

try {
    riskyOperation();
} catch (Exception e) {
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}

3. Long Transactions

// BAD: Transaction held during external call
@Transactional
public void processOrder(Order order) {
    orderRepository.save(order);
    externalPaymentService.charge(order);  // HTTP call! 5 seconds!
    // Database connection held for entire time
}

// GOOD: External calls outside transaction
public void processOrder(Order order) {
    Order savedOrder = saveOrder(order);  // Transaction 1
    PaymentResult result = externalPaymentService.charge(order);  // No tx
    updateOrderStatus(savedOrder, result);  // Transaction 2
}

@Transactional
private Order saveOrder(Order order) {
    return orderRepository.save(order);
}

Summary

  • Transaction: Atomic unit of database work
  • ACID: Atomicity, Consistency, Isolation, Durability
  • Isolation Levels: READ_UNCOMMITTED to SERIALIZABLE
  • @Transactional: Spring's declarative transaction management
  • Propagation: How transactions interact when nested
  • Optimistic Locking: @Version field, detect conflicts
  • Pessimistic Locking: FOR UPDATE, prevent conflicts
  • Common pitfalls: Self-invocation, swallowed exceptions, long transactions