Database Transactions in Rails: What ACID Really Means for Your Code
ActiveRecord makes transactions easy to reach for. What each of the four ACID guarantees actually promises, and where Rails developers get isolation wrong.
Publié le 17 octobre 2021 · 5 min de lecture
ActiveRecord makes transactions so easy to reach for that it's worth stepping back and being precise about what a transaction actually guarantees, and what it very deliberately doesn't.
Four letters, four separate promises
Where Rails developers get isolation wrong
The default isolation level on Postgres, read committed, is weaker than most engineers assume. Two transactions can both read the same row, both decide it's safe to update, and both commit, with the second write silently overwriting the first's intent. This shows up as a race condition in things like decrementing inventory or crediting a wallet balance.
# Racy: two requests can both pass the check before either updates
if account.balance_cents >= amount_cents
account.update!(balance_cents: account.balance_cents - amount_cents)
end
# Safe: the row lock serializes concurrent updates
ActiveRecord::Base.transaction do
account = Account.lock.find(account.id)
raise InsufficientFunds if account.balance_cents < amount_cents
account.update!(balance_cents: account.balance_cents - amount_cents)
end
Consistency is your callbacks' job too, not just the database's
The "C" in ACID refers to database-level constraints, not your application's business rules. A numeric validation that gets bypassed by a raw column update leaves your data technically committed and practically wrong. Rely on database constraints, NOT NULL, foreign keys, check constraints, for anything that must never be violated, and treat model validations as a second layer, not the only one.
Durability has a cost worth knowing about
Every commit that returns successfully to your Rails process has, by default, been written to disk in a way that survives a crash. That guarantee is what makes committing on every write relatively expensive at high volume, and why batching writes into fewer, larger transactions is often the first real performance win on a busy table, not more hardware.
Articles liés
How to Backfill Millions of Rows in a Rails Database Without Downtime
Backfilling a table with forty million rows is a different problem than backfilling one with forty thousand. What changes, and how we throttle it safely.
Zero-Downtime Database Migrations in Rails: A Practical Playbook
Why the migrations that look small are the ones that break production, and the sequence we follow to change a schema without locking a busy table.