Spot the Tax · Card 11 of 20
Read-modify-write isn't atomic
Why decrementing a counter in Ruby quietly miscounts the moment two requests arrive at once.
The code
What will this cost you in six months?
def consume_credit
if current_user.credits > 0
current_user.update(credits: current_user.credits - 1)
perform_action
else
render :insufficient_credits
end
end The problem
When the user has two browser tabs open or accidentally double-clicks the action, two requests arrive at almost the same time. Both of them read the user's credits at, say, 5. Both compute 5 - 1 = 4. Both write 4 back to the database. The user just performed two actions but only paid for one. You won't reproduce this in development, but a user with bad intentions will figure it out the moment your service has anything worth gaming.
Take a moment. Before revealing, think about how you'd make sure a credit can only be spent once, even when two requests run at the same instant. Where does the check have to live?
The solution
Push the math into a single SQL statement, with the credit check in the WHERE clause. The database returns the number of rows it actually changed, which tells you whether the decrement succeeded or whether the user was out of credits.
- Two simultaneous requests can't both succeed when only one credit is available
- The check and the decrement happen in one atomic SQL operation
- You don't need application-level locking to make it safe
def consume_credit
decremented = User.where(id: current_user.id)
.where("credits >= 1")
.update_all("credits = credits - 1")
if decremented == 1
perform_action
else
render :insufficient_credits
end
end The principle at play — Atomic operations
Reading a value, doing some math on it in Ruby, and writing the result back is three separate steps. Anything else can happen between any of those steps, including another request running the exact same three steps. That's the whole reason race conditions exist.
The database, on the other hand, runs each statement as a single atomic operation. When you write UPDATE users SET credits = credits - 1 WHERE id = ? AND credits >= 1, the read, the check, and the write all happen as one indivisible step. No other request can sneak in between them.
So whenever you have an invariant that needs to hold under concurrent traffic, the rule has to be enforced at the layer that handles concurrency, which is the database. Ruby code that loads, modifies, and saves is fine for one user clicking once, and a race waiting to happen for everything else.