Rails 8 Pessimistic Locking Guide

When multiple processes try to update the same record simultaneously, race conditions happen. Optimistic locking catches conflicts after they occur. Pessimistic locking prevents them entirely by acquiring exclusive database locks before modifications begin.

The Race Condition Problem

Consider an inventory system where two customers purchase the last item simultaneously. Without proper locking, both transactions read the same stock count, both decrement it, and suddenly inventory shows -1 units. This isn't a theoretical concern—it happens in production systems under real load.

Pessimistic locking tells the database: "Lock this row. Nobody else can modify it until this transaction completes." MySQL implements this with SELECT ... FOR UPDATE, and Rails 8 makes it accessible through the lock method.

Basic Pessimistic Locking

The simplest form of pessimistic locking wraps a find-and-update operation in a transaction with an explicit lock:

# app/models/product.rb
class Product < ApplicationRecord
  validates :stock_count, numericality: { greater_than_or_equal_to: 0 }

  def self.decrement_stock!(product_id, quantity: 1)
    transaction do
      product = lock.find(product_id)
      
      if product.stock_count >= quantity
        product.decrement!(:stock_count, quantity)
        true
      else
        false
      end
    end
  end
end

The lock method generates SELECT * FROM products WHERE id = ? FOR UPDATE. Any other transaction attempting to lock the same row waits until this transaction commits or rolls back.

Call this method from a service or controller:

# app/services/checkout_service.rb
class CheckoutService
  def initialize(cart)
    @cart = cart
  end

  def process
    @cart.line_items.each do |item|
      unless Product.decrement_stock!(item.product_id, quantity: item.quantity)
        raise InsufficientStockError, "Product #{item.product_id} is out of stock"
      end
    end
    
    create_order
  end

  private

  def create_order
    Order.create!(cart: @cart, status: :confirmed)
  end
end

Lock Variations in MySQL

MySQL supports different locking modes that Rails 8 can utilize. The default FOR UPDATE acquires an exclusive lock, but FOR SHARE (also called LOCK IN SHARE MODE) allows multiple readers while blocking writers:

# app/models/account.rb
class Account < ApplicationRecord
  # Exclusive lock - blocks all other locks
  def self.transfer_funds(from_id, to_id, amount)
    transaction do
      # Lock both accounts in consistent order to prevent deadlocks
      accounts = lock.where(id: [from_id, to_id]).order(:id).to_a
      from_account = accounts.find { |a| a.id == from_id }
      to_account = accounts.find { |a| a.id == to_id }

      raise InsufficientFundsError if from_account.balance < amount

      from_account.decrement!(:balance, amount)
      to_account.increment!(:balance, amount)
    end
  end

  # Shared lock - allows concurrent reads, blocks writes
  def self.calculate_total_balance(account_ids)
    transaction do
      lock("LOCK IN SHARE MODE").where(id: account_ids).sum(:balance)
    end
  end
end

Notice the order(:id) when locking multiple rows. This prevents deadlocks by ensuring all transactions acquire locks in the same sequence. Without consistent ordering, Transaction A might lock row 1 while Transaction B locks row 2, then each waits forever for the other's lock.

Lock Timeouts and Error Handling

MySQL's default lock wait timeout is 50 seconds—far too long for web requests. Configure a sensible timeout and handle lock failures gracefully:

# config/initializers/mysql_lock_timeout.rb
Rails.application.config.after_initialize do
  ActiveRecord::Base.connection.execute("SET innodb_lock_wait_timeout = 5")
end

Then handle timeout errors in application code:

# app/models/reservation.rb
class Reservation < ApplicationRecord
  class LockTimeout < StandardError; end

  def self.book_seat!(event_id, seat_number, user_id)
    transaction do
      seat = Seat.lock.find_by!(event_id: event_id, number: seat_number)
      
      raise AlreadyBookedError if seat.reserved?
      
      seat.update!(reserved: true, reserved_by: user_id)
      create!(seat: seat, user_id: user_id, confirmed_at: Time.current)
    end
  rescue ActiveRecord::LockWaitTimeout
    raise LockTimeout, "Seat is being booked by another user. Please try again."
  end
end

Rails 8 translates MySQL's lock timeout error into ActiveRecord::LockWaitTimeout, making error handling straightforward.

with_lock: The Convenient Alternative

For locking a single record that's already loaded, with_lock provides cleaner syntax. It reloads the record with a lock and wraps the block in a transaction:

# app/models/counter.rb
class Counter < ApplicationRecord
  def safe_increment!(amount = 1)
    with_lock do
      increment!(:value, amount)
    end
  end
end

# app/controllers/votes_controller.rb
class VotesController < ApplicationController
  def create
    @poll_option = PollOption.find(params[:poll_option_id])
    
    @poll_option.with_lock do
      @poll_option.increment!(:vote_count)
      Vote.create!(poll_option: @poll_option, user: current_user)
    end

    redirect_to @poll_option.poll, notice: "Vote recorded"
  end
end

The with_lock method accepts the same lock string argument as lock, allowing shared locks when needed: record.with_lock("LOCK IN SHARE MODE") { ... }

When Not to Use Pessimistic Locking

Pessimistic locking isn't always the right tool. Avoid it when:

  • Lock duration is long: Locks held during external API calls or file processing block other transactions unnecessarily. Move such operations outside the locked section.
  • High contention is expected: If hundreds of users update the same row constantly, lock queuing creates bottlenecks. Consider optimistic locking with retry logic, or redesign the data model.
  • Read-heavy workloads dominate: Shared locks still block writers. For reporting queries, consider reading from replicas instead.

For inventory systems, seat reservations, and financial transactions where correctness trumps throughput, pessimistic locking remains the reliable choice.

Monitoring Lock Contention

Track lock wait times in production to identify bottlenecks before they become outages:

# app/models/concerns/lock_monitoring.rb
module LockMonitoring
  extend ActiveSupport::Concern

  class_methods do
    def monitored_lock
      start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      result = lock
      duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
      
      if duration > 1.0
        Rails.logger.warn("[LOCK_CONTENTION] #{name} lock acquired in #{duration.round(2)}s")
      end
      
      result
    end
  end
end

Apply this concern to models where lock contention is a risk, and alert on the logged warnings.

Summary

Pessimistic locking prevents race conditions by acquiring exclusive database locks before modifications. Use lock for queries and with_lock for already-loaded records. Always lock multiple rows in consistent order to prevent deadlocks, configure reasonable lock timeouts, and handle ActiveRecord::LockWaitTimeout gracefully. Reserve pessimistic locking for operations where data integrity is non-negotiable—inventory, reservations, and financial transactions are classic use cases.

11 claps
← Back to Blog