Loading
Loading
Concurrency in Object Design
Two people click 'book' on the last seat at the same moment. Pessimistic locks, version checks, compare-and-set and idempotent commands — and when each is the right answer.
By the end of this chapter
Seat 14C is free. Two customers, served by two different application servers, each load the seat map, see it free, and press 'book'. Each server checks the seat, finds it free, and writes a booking. Both customers get a confirmation email for the same seat.
Seat 14C is FREE; requests from Asha (server 1) and Ben (server 2) arrive togetherS1 reads 14C: FREEAsha's request checks availability.S2 reads 14C: FREEBen's request checks, before S1 has written.S1 writes BOOKED (Asha)Asha's booking is saved.S2 writes BOOKED (Ben)Ben's write overwrites or duplicates it.Two bookings for one seat. Every individual step was correct.This is the hit-counter race from the first chapter, one level up. A `synchronized` block cannot fix it, because the two requests are in different processes. The check and the write have to become one atomic step at the only thing both servers share: the data store.
Concept
Pessimistic control assumes conflict is likely, so it locks the record before reading and makes everyone else wait. Optimistic control assumes conflict is rare, so it reads freely and checks at write time whether anyone changed the record in between — and if they did, the write fails and the caller decides what to do.
BEGIN; SELECT status FROM seats WHERE show_id = 42 AND seat_no = '14C' FOR UPDATE; -- a second transaction blocks here -- application checks status = 'FREE' UPDATE seats SET status = 'HELD', held_by = 'asha' WHERE show_id = 42 AND seat_no = '14C'; COMMIT; -- the waiting transaction now reads 'HELD'Give each row a version number. Read it with the data. When writing, update only if the version is still the one you read, and bump it. If someone else wrote first, the version has moved, the update matches zero rows, and you know you lost the race.
UPDATE seats SET status = 'HELD', held_by = 'ben', version = version + 1 WHERE show_id = 42 AND seat_no = '14C' AND version = 3; -- the version Ben read -- 1 row affected: Ben has the seat.-- 0 rows affected: someone changed it first. Re-read and decide.final class HoldSeatHandler { private final SeatRepository seats; HoldResult handle(HoldSeat cmd) { Seat seat = seats.find(cmd.seatId()); // carries version 3 if (!seat.isFree()) return HoldResult.taken(); seat.holdFor(cmd.customer()); // domain rule, in memory boolean saved = seats.saveIfVersion(seat, seat.version()); return saved ? HoldResult.held(seat.id()) // version is now 4 : HoldResult.taken(); // lost the race }}Before and after
No version check
Conditional on version
The in-memory version of the same idea is any atomic operation that checks and sets together. Inside one process, a concurrent map's conditional methods give you compare-and-set per key without writing a lock.
final class SeatHolds { private final ConcurrentMap<SeatId, CustomerId> holders = new ConcurrentHashMap<>(); boolean hold(SeatId seat, CustomerId who) { return holders.putIfAbsent(seat, who) == null; // atomic check-and-set } boolean release(SeatId seat, CustomerId who) { return holders.remove(seat, who); // only if still theirs }}Approaches
Hot rows with frequent conflict, where retrying would waste more work than waiting. Keep the transaction short.
Conflicts are rare, reads are many, and a conflict can be reported or retried cheaply. The usual default for entities.
The invariant is 'at most one X per key' — one booking per (show, seat). Use it as the last line of defence even when you use one of the others.
The other half of concurrent access is the same request arriving twice. A phone on a train sends 'confirm booking', the response is lost, and the app retries. Did the first one succeed? If the server cannot tell, the customer is charged twice. The fix is to make the command idempotent: the client sends a unique key with it, and the server records the key alongside the result, so a repeat returns the original answer instead of doing the work again.
final class ConfirmBookingHandler { @Transactional BookingId handle(ConfirmBooking cmd) { Optional<BookingId> earlier = processed.find(cmd.idempotencyKey()); if (earlier.isPresent()) return earlier.get(); // replay: same answer BookingId id = bookings.confirm(cmd.holdId()); processed.record(cmd.idempotencyKey(), id); // unique key column return id; }}| Situation | Reach for |
|---|---|
| Rare conflicts, many readers | Optimistic version check |
| A few hot rows, many writers | Pessimistic lock with a short transaction, or a queue per hot key |
| At most one per key | Unique constraint |
| Customer needs minutes to pay | A Hold entity with an expiry, not a lock |
| Client may retry | Idempotency key stored with the result |
Sign in to keep a note here. It is free, and nobody else can see it.
Sign inWhat this chapter was for:
Nothing about the timing changed. What changed is that the second write is conditional, so it fails loudly instead of succeeding wrongly.
Only one process can touch the state: a single-node service, an in-memory game, an embedded device.