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.
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 UPDATEis 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 automaticallyIf 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 UPDATESystem 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/infrastructureDatabase 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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
