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
endThe 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
endLock 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
endNotice 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")
endThen 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
endRails 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
endThe 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
endApply 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.