02 — ARTÍCULO_ARTICLE_
Laboratorio 1 - Concurrent Booking SystemLaboratorio 1 - Concurrent Booking System
▦
A ticket booking system for high-concurrency events. The challenge: preventing overbooking when 200 users try to buy at the same time. I evaluated three concurrency-control strategies with Spring Boot and PostgreSQL, measuring the p50 and p95 percentiles with Locust.
The experiment lives in three branches: main with atomic validation, pessimistic-lab1 with pessimistic locking, and optimistic-lab1 with optimistic locking.
Branches
| Branch | Strategy | Description |
|---|---|---|
| main | Atomic validation | A single UPDATE with WHERE availableStock > 0. No locks, no prior reads. The best approach. |
| pessimistic-lab1 | Pessimistic locking | findById with @Lock(PESSIMISTIC_WRITE) before decrementing. Locks the row for the whole transaction. |
| optimistic-lab1 | Optimistic locking | findById with @Version on the entity. On conflict it throws OptimisticLockException and the client must retry. |
Problem: unprotected in-memory validation
When stock is read, validated in memory, and then updated with no concurrency mechanism, the concurrent flow lets multiple requests read the same stock before anyone writes. This causes overbooking since there is no protection at the database level.
// Problemático - sin protección atómica
Optional<Event> optionalEvent = eventRepository.findById(dto.eventId());
Event event = optionalEvent.get();
if(event.getAvailableStock() <= 0) return -1L;
event.setAvailableStock(event.getAvailableStock() - 1); // Race condition
bookingRepository.save(booking);Solutions
Pessimistic Locking (pessimistic-lab1)
It locks the row in the database with SELECT ... FOR UPDATE before reading. The problem is that it turns the concurrent flow sequential: every request waits until the transaction finishes, raising the 50th and 95th percentiles.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM Event e WHERE id = :id")
Optional<Event> findByIdWithPessimisticLock(@Param("id") long id);Optimistic Locking (optimistic-lab1)
It uses a @Version field on the entity. It does not lock the row; the flow stays concurrent. If two transactions try to modify the same record, the second one throws OptimisticLockException and must retry. Better performance than pessimistic but with retry overhead.
@Entity
public class Event {
@Id
private Long id;
@Column(name = "available_stock")
private Long availableStock;
@Version
private Integer version;
}Atomic Validation (main)
A single UPDATE query with a WHERE availableStock > 0 condition. It locks no rows, no reads, no retries. The best approach for this use case.
@Modifying
@Query("""
UPDATE Event SET availableStock = availableStock - 1
WHERE id = :id AND availableStock > 0
""")
int actualizarEspaciosDisponibles(@Param("id") Long id);Benchmarks (Locust - 200 concurrent users)
Optimistic Locking
- p50: ~30ms | p95: ~45ms
- It does not lock the row; the flow stays concurrent. Performance is better than pessimistic but the number of retries a user must perform to buy a ticket grows considerably.

Pessimistic Locking
- p50: ~81ms | p95: ~140ms
- It turns the concurrent flow sequential. The row stays locked for the whole transaction and requests wait until it finishes, notably raising the 50th and 95th percentiles.

Atomic Validation (best approach)
- p50: ~21ms | p95: ~33ms
- The row is only locked for milliseconds during the UPDATE. No retries, no overbooking. The best performance of the three approaches.

Stack
- Java 25 / Spring Boot
- PostgreSQL
- Locust ( benchmarks )
Lessons learned
- Using Locust for REST API tests with 200 concurrent users and identifying p50 and p95 behavior across scenarios.
- Applying LockModeType in transactions for concurrency problems and verifying how one or the other affects performance.