Brian Mehrman

Postgresql Record Locking

ruby postgresql

Database Lock Modes

Postgresql provides different locking modes to control concurrency within the database. Locks are automatically acquired by most Postgres commands to make sure tables are not dropped or modified while a command is executed. Locks can also be acquired manually for application level control.

ActiveRecord provides the interface between the Rails application and the database. Using ActiveRecord we can create these database level locks.

Setup

For the examples in this post we will be using the following data setup with a new Rails application.

# Create the tables
class CreatePeople < ActiveRecord::Migration
  def change
    create_table :people do |t|
      t.string :name
      t.timestamps
    end
  end
end

class CreateToys < ActiveRecord::Migration
  def change
    create_table :toys do |t|
      t.string :name
      t.references :person, foreign_key: true
      t.timestamps
    end
  end
end

# Add Models
class Toy < ApplicationRecord
  belongs_to :person, required: false
end

class Toy < ApplicationRecord
  belongs_to :person, required: false
end

# Generate some data
people = %w(Dave Bob Alice Ace Bee Chip)

people.each { |name| Person.find_or_create_by({ name: name }) }

toys = %w(rocket plane ship yo-yo bike boat truck blocks house doll racecar thermonuclearbomb)

toys.each { |toy_name| Toy.find_or_create_by({ name: toy_name }) }

Table Level Locks

This table shows the which locks on a table (columns) will block a requested lock (rows).

Requested Lock Mode ACCESS SHARE ROW SHARE ROW EXCLUSIVE SHARE UPDATE EXCLUSIVE SHARE SHARE ROW EXCLUSIVE EXCLUSIVE ACCESS EXCLUSIVE
ACCESS SHARE X
ROW SHARE X X
ROW EXCLUSIVE X X X X
SHARE UPDATE EXCLUSIVE X X X X X
SHARE X X X X X
SHARE ROW EXCLUSIVE X X X X X X
EXCLUSIVE X X X X X X X
ACCESS EXCLUSIVE X X X X X X X X

Access Share

AccessShareLock

Access share is the most common lock and is only blocked by an ACCESS EXCLUSIVE.

A simple select on any table will acquire the ACCESS SHARE lock. Any table references for a query can create a lock on that referenced table.

Toy.find_by(name: 'racecar')

The find_by method on an ActiveRecord object is an easy way to acquire the ACCESS SHARE lock. These locks only there while the query is running. A normal selection of a single row will not block the selection of the same row by another connection.


Share Row Exclusive

ShareRowExclusiveLock

The SHARE ROW EXCLUSIVE is a lock that is not automatically acquired by any of the PostgreSQL commands. This lock is created by the user or the application that is connected to PostgreSQL.

toy = Toy.find_by(name: 'racecar')
toy.lock!
toy.save!

In Rails you can acquire this lock by using the lock! method. This method is used by developers who want to use a Pessimistic locking strategy.

Access Exclusive

AccessExclusiveLock

The ACCESS EXCLUSIVE lock will block all other locks from being acquired.

This lock is acquired by the DROP TABLE, TRUNCATE, REINDEX, CLUSTER, VACUUM FULL, and REFRESH MATERIALIZED VIEW. The command ALTER TABLE can also acquire an ACCESS EXCLUSIVE.

ActiveRecord::Migration.add_column :toys, :counter, :integer

Adding a column to a table will acquire an ACCESS EXCLUSIVE lock.


For more information on the other database level locks and what commands create these locks please check out the PostgreSQL documentation here.


Deadlock

A deadlock is where 2 or more transactions attempt to acquire a lock the other holds. When this occurs neither process can proceed. When this occurs PostgreSQL will resolve the dead lock by aborting one of the transactions involved.

deadlock table

Example

Transaction A sends a request to update the user on several toys. This requires locks to be acquired on the Toys table.

# Transaction A: Bob picks up 5 toys
ActiveRecord::Base.transaction do
  bob = Person.find_by(name: 'Bob')
  names = %w(bike yo-yo racecar truck rocket)

  names.each do |toy_name|
    toy = Toy.find_by(name: toy_name)
    toy.lock!
    toy.person = bob
    sleep(5)
    toy.save
  end
end

Transaction B sends a request to update the user on several toys as well. This list includes a couple of the same toys the first transaction is going to update.

# Transaction B: Alice picks up 5 toys.
ActiveRecord::Base.transaction do
  alice = Person.find_by(name: 'Alice')
  names = %w(rocket plane ship doll bike)

  names.each do |toy_name|
    toy = Toy.find_by(name: toy_name)
    toy.lock!
    toy.person = alice
    sleep(5)
    toy.save
  end
end

If both of these transactions are run at the same time you will see a dead lock occur when transaction A tries to acquire a lock on the same record transaction B has already acquired a lock for.

  (1.1ms)  BEGIN
  Person Load (0.8ms)  SELECT  "people".* FROM "people" WHERE "people"."name" = $1 LIMIT $2  [["name", "Alice"], ["LIMIT", 1]]
  Toy Load (0.8ms)  SELECT  "toys".* FROM "toys" WHERE "toys"."name" = $1 LIMIT $2  [["name", "rocket"], ["LIMIT", 1]]
  Toy Load (0.7ms)  SELECT  "toys".* FROM "toys" WHERE "toys"."id" = $1 LIMIT $2 FOR UPDATE  [["id", 1], ["LIMIT", 1]]
  Toy Load (1.4ms)  SELECT  "toys".* FROM "toys" WHERE "toys"."name" = $1 LIMIT $2  [["name", "bike"], ["LIMIT", 1]]
  Toy Load (1002.7ms)  SELECT  "toys".* FROM "toys" WHERE "toys"."id" = $1 LIMIT $2 FOR UPDATE  [["id", 5], ["LIMIT", 1]]
   (1.1ms)  ROLLBACK
ActiveRecord::Deadlocked: PG::TRDeadlockDetected: ERROR:  deadlock detected
DETAIL:  Process 1976 waits for ShareLock on transaction 82071; blocked by process 1978.
Process 1978 waits for ShareLock on transaction 82070; blocked by process 1976.
HINT:  See server log for query details.
CONTEXT:  while locking tuple (0,29) in relation "toys"
: SELECT  "toys".* FROM "toys" WHERE "toys"."id" = $1 LIMIT $2 FOR UPDATE
    from (irb):75:in `block (2 levels) in irb_binding'
    from (irb):73:in `each'
    from (irb):73:in `block in irb_binding'
    from (irb):69

Displaying Locks

The locks acquired in PostgreSQL can be viewed using the psql client to query the PostgreSQL server.

Example

For this example we will need to open three connections to our rails console. To see these locks in action we will need to first add a lock to the database that will block all subsequent queries.

In one of the rails consoles we will be creating a lock on the Toys table using the following command

ActiveRecord::Base.transaction do
  toy = Toy.find_by(name: 'racecar')
  toy.lock!
  sleep(3600) # wait for an hour
  toy.save!
end

This will create an ExclusiveLock. This will not block an AccessShareLock, but it will block an update. We will next try to update the same record from our second rails console.

toy = Toy.find_by(name: 'racecar')
toy.update(person_id: 1)

This update will attempt to acquire a ShareLock on the toys table. The ExclusiveLock will block the update. You can view this lock by quering the pg_catalog.pg_locks and the pg_catalog.pg_stat_activity.

Using this SQL query below we can see which statement is blocking another.

SELECT blocked_locks.pid             AS blocked_pid,
  blocked_activity.usename           AS blocked_user,
  blocking_locks.pid                 AS blocking_pid,
  blocking_activity.usename          AS blocking_user,
  blocked_activity.application_name  AS blocked_application,
  blocking_activity.application_name AS blocking_application,
  blocked_activity.query             AS blocked_statement,
  blocking_activity.query            AS current_statement_in_blocking_process,
  blocked_locks.mode                 AS blocked_mode,
  blocking_locks.mode                AS blocking_mode
FROM pg_catalog.pg_locks         blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity
  ON blocked_activity.pid      = blocked_locks.pid
JOIN pg_catalog.pg_locks         blocking_locks
  ON blocking_locks.locktype   = blocked_locks.locktype
  AND blocking_locks.DATABASE      IS NOT DISTINCT FROM blocked_locks.DATABASE
  AND blocking_locks.relation      IS NOT DISTINCT FROM blocked_locks.relation
  AND blocking_locks.page          IS NOT DISTINCT FROM blocked_locks.page
  AND blocking_locks.tuple         IS NOT DISTINCT FROM blocked_locks.tuple
  AND blocking_locks.virtualxid    IS NOT DISTINCT FROM blocked_locks.virtualxid
  AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
  AND blocking_locks.classid       IS NOT DISTINCT FROM blocked_locks.classid
  AND blocking_locks.objid         IS NOT DISTINCT FROM blocked_locks.objid
  AND blocking_locks.objsubid      IS NOT DISTINCT FROM blocked_locks.objsubid
  AND blocking_locks.pid != blocked_locks.pid

JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.GRANTED;

This query should show you something that looks like this.

blocked_pid | blocked_user | blocking_pid | blocking_user |                                            blocked_statement                                            |                                  current_statement_in_blocking_process                                  | blocked_application | blocking_application |    blocked_mode     |    blocking_mode
-------------+--------------+--------------+---------------+---------------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------+---------------------+----------------------+---------------------+---------------------
        2210 | postgres     |         2436 | postgres      | UPDATE "toys" SET "person_id" = $1, "updated_at" = $2 WHERE "toys"."id" = $3                            | SELECT  "toys".* FROM "toys" WHERE "toys"."id" = $1 LIMIT $2 FOR UPDATE                                 | rails_console       | rails_console        | ShareLock           | ExclusiveLock

Next we will create an ACCESS EXCLUSIVE to view how this lock can block all other queries on a table.

ActiveRecord::Base.transaction do
  ActiveRecord::Migration.add_column :toys, :countz, :integer
  sleep(3600) # wait for an hour
end

This migration creates an ALTER TABLE command that will acquire an ACCESS EXCLUSIVE lock on the toys table.

This lock will block any other attempts to acquire a lock on the toys table. Allowing us to queue up queries that we will see in the pg_locks.

Below are examples of other locks you can create.

Row Share Lock

Toy.find_by_sql("SELECT * FROM toys WHERE name = 'racecar' FOR UPDATE")

Row Exclusive Lock

toy = Toy.find_by(name: 'racecar')
toy.update(person_id: 1)
toy

Share

ActiveRecord::Migration.add_index(:toys, :name)

Summary

Locks exist to protect data integrity between concurrent transactions. They allow us ensure that a long running query or update will not be corrupted by a conflicting change to the table(s) you are using.

While reading from a table will not block other reads you must be mindful of when you are planning on running migrations that alter table that are read by multiple users at once. Specially if those tables require a user to look a multiple records in one query. An ALTER TABLE can easily block a user from reading a record.