Here at Wawandco, we love to build things. But more than that, we love to understand how things work. This is a story about one of those times when a simple question led us down a rabbit hole of discovery, revealing valuable lessons about concurrency, atomicity, and the subtle art of managing shared resources. It all started with a small project called “Evento”.
Evento is a proof-of-concept for a reservation system. Imagine a popular event with a limited number of rooms available in different hotels. We expect many users trying to book rooms simultaneously. The core challenge is simple: we must not sell more rooms than we have.
This sounds straightforward, but when you have multiple servers and hundreds of concurrent users, things get complicated. This is a classic concurrency problem.
Our first attempt was a “naive” implementation. The logic was simple:
Here’s what the code looked like in Go:
// server/naive.go
// Check if the number of rooms specified is available
query := `
SELECT true
FROM event_hotel_rooms
WHERE
event_id = $1
AND
hotel_id = $2
AND
contracted - (reserved + locked) >= $3
`
var available bool
err = conn.QueryRow(r.Context(), query, eventID, hotelID, rooms).Scan(&available)
if err != nil || !available {
// Not enough rooms
return
}
// Update the availability
query = `
UPDATE event_hotel_rooms
SET reserved = reserved + $1
WHERE
event_id = $2
AND
hotel_id = $3
`
_, err = conn.Exec(r.Context(), query, rooms, eventID, hotelID)
// ... insert reservation record
We quickly ran a simulation with 200 concurrent clients. The result? We overbooked!
Why? Because of a race condition. Imagine two users, Alice and Bob, trying to book the last available room at the same time.
Both think they got the room, but we’ve sold the same room twice. The “check” and “update” operations are not a single, indivisible unit.
“Aha!” we thought. “We need a transaction!” A database transaction ensures that a series of operations either all succeed or all fail together. This property is called atomicity.
So, we wrapped our logic in a transaction:
// server/atomic.go
tx, err := conn.Begin(r.Context())
// ...
// check if quantity is available WITHOUT a FOR UPDATE lock
query := `
SELECT true
FROM event_hotel_rooms
WHERE ...
`
// ...
// update rooms availability
query = `
UPDATE event_hotel_rooms
SET reserved = reserved + $1
WHERE ...
`
// ...
tx.Commit(r.Context())
We ran the simulation again. The result? Still overbooking!
This was a key learning moment. Transactions alone do not guarantee isolation from other concurrent transactions. While our operations were now atomic (they would all complete or none would), two parallel transactions could still read the same initial data and create a race condition, just like before.
The problem is that other transactions can interfere. So, we decided to be pessimistic. Pessimistic locking assumes that conflicts are likely and prevents them by locking the data.
We modified our query to use SELECT ... FOR UPDATE. This tells the database to lock the selected rows until the transaction is complete. Any other transaction trying to read or update the same rows will have to wait.
// server/pessimistic.go
tx, err := conn.Begin(r.Context())
// ...
// check if quantity is available with a FOR UPDATE lock
query := `
SELECT true
FROM event_hotel_rooms
WHERE
event_id = $1
AND
hotel_id = $2
AND
contracted - (reserved + locked) >= $3
FOR UPDATE
`
// ... (the rest is the same)
tx.Commit(r.Context())
Success! We ran the simulation, and not a single room was overbooked. Pessimistic locking solved the problem by serializing access to the resource. However, it has a downside: it can reduce concurrency, as other transactions have to wait for the lock to be released.
What if conflicts are rare? Pessimistic locking might be overkill. Optimistic locking assumes that conflicts are unlikely. Instead of locking, you check if the data has changed before you commit your transaction.
We added an updated_at timestamp to our event_hotel_rooms table. The logic is:
updated_at timestamp along with the availability.WHERE clause to check if the updated_at timestamp is the same as what we initially read.If the timestamp is different, it means another transaction has modified the data. Our update will fail, and we can then retry the whole process.
// server/optimistic.go
// check if quantity is available and get current updated_at timestamp
query := `
SELECT updated_at
FROM event_hotel_rooms
WHERE ...
`
var updatedAt time.Time
err = tx.QueryRow(r.Context(), query, eventID, hotelID, rooms).Scan(&updatedAt)
// ...
// update rooms availability and check timestamp
query = `
UPDATE event_hotel_rooms
SET reserved = reserved + $1, updated_at = NOW()
WHERE
event_id = $2
AND
hotel_id = $3
AND
updated_at = $4
`
res, err := tx.Exec(r.Context(), query, rooms, eventID, hotelID, updatedAt)
// Check if the update affected any rows
if res.RowsAffected() == 0 {
// Conflict! Data was modified.
return
}
// ...
This approach also worked perfectly. It can offer better performance in low-contention scenarios because it doesn’t block other transactions.
This journey through the evento repository was a fantastic reminder of some fundamental principles:
At Wawandco, we believe that curiosity is a powerful driver for learning. By building, testing, and breaking our own code, we gain a deeper understanding of the technologies we use every day. This little experiment with evento was a perfect example of that spirit in action.
You can find the full source code for the project on GitHub: https://github.com/wawandco/evento.
To dive deeper into the concepts discussed in this post, here are a few resources:
Pessimistic vs. Optimistic Locking: A great overview of the two locking strategies. Read more on ByteByteGo
Understanding Race Conditions: A deeper dive into what race conditions are and how they occur. Read more on GeeksforGeeks
ACID Properties: A detailed explanation of the ACID properties of database transactions. Read more on Wikipedia