FR | EN
Parler de votre projet

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

ATOMIC All statements in the transaction commit, or none of them do. CONSISTENT Constraints hold true before and after, never mid-transaction. ISOLATED Concurrent transactions don't see each other's uncommitted writes. DURABLE Once committed, a write survives a crash right after.
The four guarantees your database is actually giving you

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.