Beyond @Transactional: Build a Java Ticket System, Master Deadlocks & Isolation Levels

The article shows why a simple @Transactional approach fails under 300,000 concurrent users, explains MVCC behavior, demonstrates how to use row‑level pessimistic locks, sorting, FOR NO KEY UPDATE, short transactions, optimistic locking, and proper isolation levels, and outlines a production‑ready architecture with read/write splitting, Redis rate limiting and connection‑pool tuning.

LuTiao Programming
LuTiao Programming
LuTiao Programming
Beyond @Transactional: Build a Java Ticket System, Master Deadlocks & Isolation Levels

Why @Transactional Is Not Enough

When 300,000 users simultaneously refresh a page to book 80,000 seats, a naïve Spring Boot service that only uses @Transactional can sell the same seat twice, cause occasional time‑outs, and generate logs with deadlock detected. The root cause is insufficient understanding of database concurrency, not a coding error.

MVCC Basics

Both PostgreSQL and MySQL (InnoDB) use Multi‑Version Concurrency Control. SELECT statements do not lock rows by default, each statement sees a snapshot, and under READ COMMITTED each statement gets a different snapshot.

Reproducing the Concurrency Issue

The article includes a diagram illustrating how concurrent reads can see stale data.

What a Transaction Guarantees

All operations succeed or all fail.

It does not guarantee that the data read is the latest.

Pessimistic (Row) Locking

Using database row locks is the recommended solution in Java.

Correct Implementation (JPA + SQL)

@Repository
public interface SeatRepository extends JpaRepository<Seat, Long> {
    @Query(value = """
        SELECT * FROM seats
        WHERE id IN (:ids)
        ORDER BY id
        FOR NO KEY UPDATE
    """, nativeQuery = true)
    List<Seat> lockSeats(@Param("ids") List<Long> ids);
}

@Service
public class ReservationService {
    private static final int HOLD_MINUTES = 10;

    @Transactional
    public void reserve(List<Long> seatIds, Long userId) {
        List<Long> sortedIds = seatIds.stream()
            .distinct()
            .sorted()
            .toList();
        List<Seat> seats = seatRepository.lockSeats(sortedIds);
        if (seats.size() != sortedIds.size()) {
            throw new RuntimeException("Seat not found");
        }
        for (Seat seat : seats) {
            if (!"available".equals(seat.getStatus())) {
                throw new RuntimeException("Seat already taken");
            }
        }
        LocalDateTime expireTime = LocalDateTime.now().plusMinutes(HOLD_MINUTES);
        seatRepository.batchUpdateReserve(sortedIds, userId, expireTime);
    }
}

Three Critical Details

1) Sort Before Locking (Avoid Deadlock)

All transactions must acquire locks in the same order; otherwise deadlocks occur. The article shows a diagram of a deadlock and states the solution: lock rows in a consistent order .

2) Prefer FOR NO KEY UPDATE Over FOR UPDATE

FOR UPDATE

is the strongest lock and affects foreign keys; FOR NO KEY UPDATE is lighter and allows higher concurrency when only a status column is modified.

3) Keep Transactions Extremely Short

Inside a transaction only perform queries, validation, and updates. Do not make HTTP calls, RPC, or invoke payment interfaces, because they lengthen lock time and reduce throughput.

Optimistic Locking

Version Field

@Entity
public class Seat {
    @Id
    private Long id;
    @Version
    private Integer version;
    private String status;
}

seat.setStatus("reserved");
seatRepository.save(seat); // version condition applied automatically

If the version does not match, an OptimisticLockException is thrown.

Conditional Update (Higher Performance)

@Modifying
@Query("""
UPDATE Seat s
SET s.status = 'reserved',
    s.reservedBy = :userId,
    s.reservedUntil = :expireTime
WHERE s.id IN :ids AND s.status = 'available'
""")
int updateAvailableSeats(...);

The method returns the number of rows updated; if it differs from the requested size, the service throws an exception indicating partial seat occupation. Advantages: no lock, single‑SQL, maximal performance. Drawback: multi‑seat updates need compensation logic.

Isolation Levels

READ COMMITTED (Default)

Each statement sees a different snapshot.

Can lead to logical errors.

REPEATABLE READ

The whole transaction sees the same snapshot.

Prevents conflicts on the same row.

SERIALIZABLE

@Transactional(isolation = Isolation.SERIALIZABLE)

Automatically detects conflicts, may roll back the transaction, and requires retry logic.

Avoiding Deadlocks in Production

Typical wrong code uses SELECT * FROM seats WHERE id IN (...) FOR UPDATE, which leaves lock order uncontrolled and guarantees deadlocks. The correct pattern is:

SELECT * FROM seats
WHERE id IN (...)
ORDER BY id
FOR NO KEY UPDATE

System Architecture

Beyond code, a robust architecture includes read/write splitting, Redis rate limiting, a connection pool (HikariCP + PgBouncer), and ensuring no external calls are part of a transaction.

Complete Deployable Java Implementation

@Service
public class ReservationService {
    private static final int MAX_SEATS = 6;
    private static final int HOLD_MINUTES = 10;

    @Transactional
    public ReservationResponse reserve(List<Long> seatIds, Long userId) {
        List<Long> ids = seatIds.stream()
            .distinct()
            .sorted()
            .toList();
        if (ids.isEmpty() || ids.size() > MAX_SEATS) {
            throw new IllegalArgumentException("Invalid seat count");
        }
        List<Seat> seats = seatRepository.lockSeats(ids);
        if (seats.size() != ids.size()) {
            throw new RuntimeException("Seat not found");
        }
        for (Seat seat : seats) {
            if (!"available".equals(seat.getStatus())) {
                throw new RuntimeException("Seat already taken");
            }
        }
        LocalDateTime expire = LocalDateTime.now().plusMinutes(HOLD_MINUTES);
        seatRepository.batchUpdateReserve(ids, userId, expire);
        return new ReservationResponse(ids, expire);
    }
}

Project Structure

/src/main/java/com/icoderoad/reservation
/src/main/java/com/icoderoad/domain
/src/main/java/com/icoderoad/infrastructure

Database Schema

CREATE TABLE seats (
  id BIGSERIAL PRIMARY KEY,
  event_id BIGINT NOT NULL,
  section VARCHAR(10),
  row_label VARCHAR(5),
  number INT,
  status VARCHAR(20) DEFAULT 'available',
  reserved_by BIGINT,
  reserved_until TIMESTAMP,
  version INT DEFAULT 0
);

The article concludes that most system crashes stem from underestimating concurrency complexity; transactions only guarantee atomic failure, not conflict resolution, and the database merely records problems. True high‑concurrency reliability comes from keeping results correct amid chaotic competition.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaTransactionDeadlockSpring Bootoptimistic lockpessimistic lockisolation-level
LuTiao Programming
Written by

LuTiao Programming

LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.