Rails Pessimistic Locking: SELECT FOR UPDATE, with_lock en race conditions voorkomen
Rails pessimistic locking met SELECT FOR UPDATE, lock! en with_lock — voorkom race conditions op saldo's, voorraad en gelijktijdige writes in Postgres.
Twee checkout-requests raken dezelfde wallet binnen dezelfde milliseconde. Beide lezen het saldo, zien €100, trekken er €80 van af en schrijven €20 terug. De klant heeft twee keer betaald en de wallet toont één afschrijving. Het finance team vindt het drie dagen later wanneer het reconciliatierapport niet klopt. Engineering kijkt lang naar de rij in de database voordat iemand de woorden “ik denk dat we een race condition hebben” uitspreekt.
Na negentien jaar Rails kan ik je vertellen dat precies deze bug — lezen, berekenen, schrijven, met twee requests die door elkaar lopen — de meest voorkomende productie-datacorruptie is die ik heb gezien. Hij is niet subtiel. Hij is niet zeldzaam. Hij gebeurt elke keer dat je gelijktijdige writers hebt op dezelfde rij en je grijpt naar de voor de hand liggende code in plaats van naar Rails pessimistic locking. Deze post gaat over wanneer je lock!, with_lock en SELECT FOR UPDATE moet gebruiken, hoe ze zich daadwerkelijk gedragen onder Postgres, en de faalmodes die mensen ertoe brengen ze te verlaten en iets ergers uit te vinden.
Wat Rails Pessimistic Locking daadwerkelijk doet
Rails pessimistic locking vertelt de database dat hij een row-level lock moet pakken op het moment dat je een record leest, en die lock vasthoudt totdat je transactie commit of rollbackt. Elke andere transactie die dezelfde rij probeert te lezen met een lock, of te updaten, blokkeert en wacht. Het optimistische alternatief, lock_version, laat iedereen vrij lezen en detecteert conflicten pas op schrijftijd door ActiveRecord::StaleObjectError op te gooien. Pessimistic locking ruilt een beetje throughput in voor een harde garantie: zolang jij de lock vasthoudt kan niemand anders deze rij aanraken.
Onder de motorkap, wanneer je wallet.lock! of Wallet.lock.find(id) aanroept binnen een transactie, stuurt ActiveRecord SELECT ... FOR UPDATE naar Postgres. Dat vertelt Postgres dat hij de rij als locked moet markeren voor de duur van de transactie. Andere transacties die SELECT FOR UPDATE, UPDATE of DELETE op dezelfde rij uitvoeren, blokkeren totdat jouw transactie eindigt. Een gewone SELECT zonder lock werkt nog steeds — die leest de pre-locked snapshot via MVCC en geeft direct terug. Dat laatste detail is belangrijk en verrast mensen: pessimistic locking blokkeert niet alle readers, alleen writers en andere lockers.
Het mentale model dat ertoe doet: een row lock is een wachtrij met één loket. Wie als eerste de lock krijgt doet zijn werk en commit. De rest staat in de rij. Zolang je het kleinst mogelijke beetje werk doet terwijl je de lock vasthoudt, gaat de rij snel en blijft je app responsief.
De race condition waarmee deze post begon
Hier is de kapotte code, geschreven zoals de meeste Rails-developers hem de eerste keer schrijven.
class WalletsController < ApplicationController
def charge
wallet = Wallet.find(params[:id])
amount = params[:amount].to_i
if wallet.balance >= amount
wallet.update!(balance: wallet.balance - amount)
head :ok
else
head :payment_required
end
end
end
Dit werkt perfect in een unit test. Het werkt in staging. Het werkt de eerste tienduizend keer in productie. Dan komen er twee requests binnen een milliseconde binnen en Postgres serveert ze allebei. Beide transacties lezen balance = 100. Beide checken 100 >= 80. Beide schrijven balance = 20. De wallet is twee keer afgeschreven en de tweede afschrijving was gratis. Er staat geen fout in de logs. Er is geen failed query. De database deed precies wat elke transactie vroeg, in isolatie. Het probleem is dat ze niet in isolatie waren — ze waren door elkaar gevlochten.
Het fixen met Rails Pessimistic Locking
De fix is drie regels code. Wikkel de read in een transactie, gebruik lock! om een row lock te pakken, doe vervolgens de check en de write terwijl je de lock vasthoudt.
class WalletsController < ApplicationController
def charge
amount = params[:amount].to_i
Wallet.transaction do
wallet = Wallet.lock.find(params[:id])
if wallet.balance >= amount
wallet.update!(balance: wallet.balance - amount)
head :ok
else
head :payment_required
end
end
end
end
Wallet.lock.find(params[:id]) stuurt SELECT * FROM wallets WHERE id = ? FOR UPDATE. Het eerste request dat aankomt krijgt direct de lock. Het tweede request blokkeert binnen find totdat de eerste transactie commit. Wanneer de tweede eindelijk de lock krijgt, leest hij de post-commit waarde (balance = 20), checkt 20 >= 80, ziet false, en geeft payment_required terug. Geen dubbele afschrijving. Geen stille corruptie. Het gedrag matcht de intentie.
Dezelfde code kun je schrijven met with_lock, een kleine wrapper die een transactie opent, het record opnieuw laadt met een lock, en yieldt:
wallet = Wallet.find(params[:id])
wallet.with_lock do
if wallet.balance >= amount
wallet.update!(balance: wallet.balance - amount)
head :ok
else
head :payment_required
end
end
with_lock is wat ik in negentig procent van de gevallen pak. Het maakt de scope van de lock zichtbaar in de code en je kunt niet per ongeluk de transactie vergeten. De eerste find buiten het blok is de “welke rij is dit”-lookup; de lock-and-reload binnen with_lock is wat er werkelijk toe doet.
SELECT FOR UPDATE varianten die je moet kennen
Postgres biedt vier smaken row lock en Rails ontsluit ze allemaal via de lock scope. Weten welke je moet gebruiken is het verschil tussen code die schaalt en code die deadlocked op Black Friday.
Wallet.lock.find(id) # SELECT ... FOR UPDATE
Wallet.lock("FOR UPDATE NOWAIT").find(id)
Wallet.lock("FOR UPDATE SKIP LOCKED").find(id)
Wallet.lock("FOR SHARE").find(id)
FOR UPDATE is de default en degene die je wilt voor “ik ga zo deze rij wijzigen.” Hij blokkeert andere writers totdat je commit.
FOR UPDATE NOWAIT is identiek behalve dat hij direct een fout opgooit als iemand anders de lock vasthoudt, in plaats van te wachten. Gebruik dit wanneer je liever snel faalt en de gebruiker laat retry-en dan een request in een wachtrij te laten zitten. Het is de juiste keuze voor low-latency API’s waar een lange wacht erger is dan een schone fout. Vang ActiveRecord::LockWaitTimeout (of PG::LockNotAvailable op oudere Rails) en geef 409 Conflict terug.
FOR UPDATE SKIP LOCKED is de magische voor job queues. Hij slaat rijen over die iemand anders heeft gelockt en geeft de volgende beschikbare rij terug. Dit is hoe Solid Queue, GoodJob en Que allemaal “geef me de volgende ongeclaimede job” implementeren zonder de hele worker fleet te serialiseren. Als je ooit je eigen queue-tabel bouwt, is dit de regel SQL die hem laat schalen.
Job.lock("FOR UPDATE SKIP LOCKED").where(state: "ready").limit(1).first
FOR SHARE is een read lock. Andere FOR SHARE readers kunnen hem ook pakken, maar writers blokkeren. Gebruik hem wanneer je wilt garanderen dat niemand de rij wijzigt terwijl jij hem leest, maar je zelf niet van plan bent te schrijven. In de praktijk grijp ik er bijna nooit naar — als ik lock, is het omdat ik wil wijzigen.
De twee faalmodes die iedereen tegenkomt
Rails pessimistic locking is mechanisch en goed gedefinieerd, maar twee faalmodes vangen elk team uiteindelijk. Ze vooraf kennen is het verschil tussen de bug in vijf minuten fixen en hem fixen na een outage van vier uur.
Deadlocks
Een deadlock gebeurt wanneer transactie A een lock heeft op rij X en wacht op rij Y, terwijl transactie B Y vasthoudt en wacht op X. Geen van beide kan ooit eindigen. Postgres detecteert dit na een korte timeout en kilt een van de transacties met deadlock detected. Rails gooit het op als ActiveRecord::Deadlocked.
De twee-rijen transfer is het klassieke voorbeeld:
def transfer(from_id, to_id, amount)
Wallet.transaction do
from = Wallet.lock.find(from_id)
to = Wallet.lock.find(to_id)
from.update!(balance: from.balance - amount)
to.update!(balance: to.balance + amount)
end
end
Twee parallelle aanroepen van transfer(1, 2, 10) en transfer(2, 1, 10) zullen direct deadlocken. De fix is om de rijen in een deterministische volgorde te locken — altijd de lagere id eerst.
def transfer(from_id, to_id, amount)
ordered = [from_id, to_id].sort
Wallet.transaction do
wallets = Wallet.lock.where(id: ordered).index_by(&:id)
from = wallets.fetch(from_id)
to = wallets.fetch(to_id)
from.update!(balance: from.balance - amount)
to.update!(balance: to.balance + amount)
end
end
Sorteren op id is de goedkoopste, meest betrouwbare deadlock-preventie die ik ken. Elke keer dat je transactie meer dan één rij van dezelfde tabel raakt: sorteer.
Lang vastgehouden locks door trage callbacks
De andere faalmode is venijniger. Een junior dev voegt een callback toe aan Wallet die een externe API aanroept.
class Wallet < ApplicationRecord
after_update :notify_fraud_service
private
def notify_fraud_service
FraudClient.post(id: id, balance: balance)
end
end
De fraud-service is traag. De HTTP-call duurt twee seconden. De lock wordt al die tijd vastgehouden. Nu serialiseren charge-requests op twee seconden per wallet in plaats van twee milliseconden. De throughput zakt drie ordes van grootte. De wallets-tabel wordt het knelpunt voor de hele site.
De regel is simpel en absoluut: doe geen enkele I/O terwijl je een row lock vasthoudt. Geen HTTP. Geen externe API. Geen mail. Geen third-party SDK. De transactie mag alleen Postgres-queries en pure Ruby bevatten. Schuif side effects naar een background job via after_commit, en laat die job het record opnieuw ophalen zonder lock.
class Wallet < ApplicationRecord
after_commit :enqueue_fraud_notification, on: :update
private
def enqueue_fraud_notification
NotifyFraudJob.perform_later(id)
end
end
after_commit draait nadat de transactie is losgelaten, dus het side effect wordt afgevuurd zonder dat de row lock nog vast staat. Als de fraud-service plat ligt retrye de job en blijft de wallets-tabel doorlopen.
Lock timeouts instellen zodat je snel faalt
Postgres wacht standaard eindeloos op een row lock. Dat is bijna nooit wat je wilt in een web request — een request die twee minuten hangt is erger dan een request die na één seconde faalt en de gebruiker laat retry-en. Zet een lock_timeout op de verbinding.
# config/initializers/lock_timeout.rb
ActiveSupport.on_load(:active_record) do
ActiveRecord::Base.connection.execute("SET lock_timeout = '2s'")
end
Voor Rails 7.1+ multi-database setups, zet hem per rol in database.yml:
production:
primary:
<<: *default
variables:
lock_timeout: 2000 # 2 seconden, in milliseconden
statement_timeout: 15000
idle_in_transaction_session_timeout: 30000
idle_in_transaction_session_timeout is de ondergewaardeerde held. Hij killt transacties die te lang stilstaan (geen query draaien). Als een Ruby-exception midden in je transactie de rollback verhindert — bijvoorbeeld omdat de app server crashte — ruimt Postgres het uiteindelijk op in plaats van locks eeuwig vast te houden.
Ik raakte gerelateerde Postgres-tuning aan in een eerdere post over PgBouncer en over slow query-analyse. Lock timeouts horen op dezelfde stapel “dingen die je vóór productie hoort te configureren.”
Wanneer je in plaats daarvan voor optimistic locking moet kiezen
Pessimistic locking is niet gratis. Het serialiseert toegang tot een rij, en voor high-contention hot rows duikt die serialisatie op in je p99-latency. Als je dataprofiel “de meeste updates botsen niet, maar incidentele conflicten moeten gedetecteerd worden” is, is optimistic locking met lock_version vaak een betere fit.
class AddLockVersionToOrders < ActiveRecord::Migration[8.0]
def change
add_column :orders, :lock_version, :integer, default: 0, null: false
end
end
order = Order.find(id)
order.status = "shipped"
order.save! # gooit ActiveRecord::StaleObjectError als een andere writer ons voor was
De vuistregel die ik gebruik: pessimistic locking voor financiële state en voorraadtellingen; optimistic locking voor bewerkbare user content. Het wallet-saldo mag nooit negatief worden — lock. De profielpagina van een user kan misschien een edit verliezen in een tab die ze een week open hadden — dat is acceptabel en StaleObjectError laat je het netjes vertellen.
Voor background-job claim-semantiek geen van beide — gebruik FOR UPDATE SKIP LOCKED en een state-kolom. Gecombineerd met Solid Queue’s recurring jobs schaalt dat patroon naar duizenden workers zonder contention.
Pessimistic Locking testen
De truc bij het testen van een lock is dat je twee gelijktijdige transacties nodig hebt. RSpec met threads en twee database-verbindingen doet de klus, hoewel de test de eerste keer raar leest.
require "rails_helper"
RSpec.describe "Wallet charging", :truncation do
it "voorkomt dubbele afschrijving bij gelijktijdigheid" do
wallet = Wallet.create!(balance: 100)
barrier = Concurrent::CyclicBarrier.new(2)
results = Concurrent::Array.new
threads = 2.times.map do
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
barrier.wait
begin
Wallet.transaction do
w = Wallet.lock.find(wallet.id)
if w.balance >= 80
w.update!(balance: w.balance - 80)
results << :charged
else
results << :rejected
end
end
end
end
end
end
threads.each(&:join)
expect(results.sort).to eq([:charged, :rejected])
expect(wallet.reload.balance).to eq(20)
end
end
Gebruik strategy :truncation, niet transactional fixtures — transactional fixtures draaien de hele test in één transactie, wat alles serialiseert en de lock onzichtbaar maakt. De Concurrent::CyclicBarrier zorgt dat beide threads op hetzelfde moment de transactie starten, wat de race afdwingt. Zonder dat slaagt de test per ongeluk.
FAQ
Wat is het verschil tussen lock! en with_lock in Rails?
lock! pakt een row-level lock op een al geladen record en herlaadt het. Het moet binnen een transactie aangeroepen worden, anders heeft het geen effect zodra de methode terugkeert. with_lock opent een transactie, roept lock! aan en yieldt een blok — dus is veiliger en in zichzelf besloten. Gebruik with_lock tenzij je al een open transactie hebt.
Werkt Rails pessimistic locking met SQLite?
Gedeeltelijk. SQLite serialiseert alle writes op database-niveau, dus lock! is op de meeste adapters stilletjes een no-op — de lock komt van SQLite’s eigen write-serialisatie. Voor Rails 8 apps die SQLite in productie draaien zijn race conditions op dezelfde rij veel moeilijker te raken vanwege die serialisatie, maar het patroon is nog steeds de moeite waard om te schrijven voor portabiliteit en duidelijkheid. Zie mijn SQLite-productie post voor de trade-offs.
Wanneer moet ik FOR UPDATE SKIP LOCKED gebruiken in plaats van FOR UPDATE?
Gebruik SKIP LOCKED wanneer je meerdere workers hebt die concurreren om “het volgende beschikbare item” — job queues, outbox-processors, mailverzending. Het laat elke worker een andere rij pakken in plaats van dat ze allemaal serialiseren op de eerste. Gebruik gewoon FOR UPDATE wanneer je een specifieke rij op id moet locken en het wachten gewenst is.
Hoe voorkom ik deadlocks bij Rails pessimistic locking?
Pak locks altijd in een deterministische volgorde. Als een transactie meerdere rijen van dezelfde tabel raakt, sorteer de ids en lock in gesorteerde volgorde. Raakt hij meerdere tabellen, lock ze dan in een gedocumenteerde, app-brede volgorde — bijvoorbeeld altijd users vóór orders. Vang ActiveRecord::Deadlocked op de controller-grens en retry één keer met jitter.
Hulp nodig bij het ontwarren van race conditions, deadlocks of productie-datacorruptie in een Rails-app? TTB Software doet dit al negentien jaar — pessimistic locking, optimistic locking, en de queue-patronen daartussenin. Wij hebben de post-mortems geschreven zodat jij dat niet hoeft.
Related Articles
Rails Strong Migrations: Vang onveilige databasewijzigingen voordat ze productie lockken
Rails Strong Migrations: vang onveilige Postgres-wijzigingen — NOT NULL toevoegen, hernoemen, indexen zonder CONCURRE...
Rails pg_stat_statements: Vind Trage Queries in Productie Voordat Je Gebruikers Het Doen
Rails pg_stat_statements opzetten, queryen en analyseren: vind de trage queries die productie écht raken, normaliseer...
Rails 8 SQLite in productie: WAL-modus, Litestream-backups en wanneer je SQLite boven Postgres kiest
Rails 8 SQLite productie: WAL-modus pragmas, Litestream continue backups, Kamal deployment met volumes, en wanneer SQ...