Transactions, Locking & Concurrency in Code · Deadlocks and Contention

MySQL Gap Locks and Insert Deadlocks

Why 'lock it, then insert it if missing' deadlocks under InnoDB Repeatable Read, and the patterns that don't.

Scenario

Example app: a loyalty service on MySQL 8 (Repeatable Read). On each purchase it gets or creates a points row for the customer and campaign, then adds points. Campaign launches bring bursts of first purchases.

What you see in production

During launches, ERROR 1213 (40001): Deadlock found when trying to get lock appears on the very first purchase for many customers — never on later ones. The code locks the row "to be safe" before inserting.

The naive code

BEGIN;
SELECT * FROM points WHERE customer_id = 42 AND campaign_id = 7 FOR UPDATE;   -- no row yet
-- application: not found → insert
INSERT INTO points (customer_id, campaign_id, balance) VALUES (42, 7, 10);
COMMIT;

Reproduce it

Two first purchases for nearby keys run concurrently. Gap locks are compatible with each other, but an insert into a gap needs an insert intention lock that conflicts with any gap lock held by another transaction:

Gap locks + inserts = deadlockTry it yourself
gap_locks = {}          # gap -> set of transactions holding a gap lock on it
waiting = {}

def select_for_update_missing(tx, gap):
    gap_locks.setdefault(gap, set()).add(tx)          # gap locks don't conflict with each other
    print(f"{tx}: SELECT … FOR UPDATE found nothing -> gap lock on {gap}")

def insert(tx, gap):
    others = gap_locks.get(gap, set()) - {tx}
    if others:
        blocker = next(iter(others))
        waiting[tx] = blocker
        print(f"{tx}: INSERT needs insert-intention lock on {gap}, blocked by {blocker}'s gap lock")
        if waiting.get(blocker) == tx:
            print(f"deadlock: {tx} <-> {blocker}; InnoDB rolls back {tx} (ERROR 1213)")
    else:
        print(f"{tx}: inserted")

gap = "(customer 40 .. customer 50) on idx(customer_id, campaign_id)"
select_for_update_missing("T1", gap)
select_for_update_missing("T2", gap)
insert("T1", gap)
insert("T2", gap)

Output

T1: SELECT … FOR UPDATE found nothing -> gap lock on (customer 40 .. customer 50) on idx(customer_id, campaign_id)
T2: SELECT … FOR UPDATE found nothing -> gap lock on (customer 40 .. customer 50) on idx(customer_id, campaign_id)
T1: INSERT needs insert-intention lock on (customer 40 .. customer 50) on idx(customer_id, campaign_id), blocked by T2's gap lock
T2: INSERT needs insert-intention lock on (customer 40 .. customer 50) on idx(customer_id, campaign_id), blocked by T1's gap lock
deadlock: T2 <-> T1; InnoDB rolls back T2 (ERROR 1213)

Root cause

Under Repeatable Read, InnoDB's locking reads take next-key / gap locks on the index range they search, so that no one can insert a phantom into it. A FOR UPDATE that finds nothing still locks the gap where the row would go. Two transactions can both hold that gap lock; each then tries to insert, needs an insert-intention lock that conflicts with the other's gap lock, and they deadlock.

The fix

Make the first write a single atomic statement — no lock-then-insert:

INSERT INTO points (customer_id, campaign_id, balance) VALUES (42, 7, 10) AS new
ON DUPLICATE KEY UPDATE balance = points.balance + new.balance;    -- needs UNIQUE(customer_id, campaign_id)

Other options: create the row ahead of time (when the customer joins the campaign) so later code only updates; or use Read Committed for this workload, where InnoDB disables most gap locking for searches (it still uses them for foreign-key and duplicate-key checks).

Trade-offs

  • Upserts on InnoDB still take locks on the unique index and can deadlock under extreme concurrency on the same key; a retry handles the remainder.
  • Switching to Read Committed reduces gap locks but changes read behaviour for the whole session — evaluate per workload, not globally on a hunch.
  • Ensure the lookup uses the unique index: an exact match on a unique key locks only that record, while a non-unique or missing index widens the locked range.

Detect it in production

  • SHOW ENGINE INNODB STATUS deadlocks mentioning locks gap before rec and insert intention waiting are the signature of this pattern.
  • performance_schema.data_locks shows LOCK_MODE values such as X,GAP and X,INSERT_INTENTION while it happens.
  • Deadlocks concentrated on "first time" events (first purchase, first login of the day) point here.

Key points

  • In InnoDB Repeatable Read, a locking read that finds nothing locks the gap.
  • Two gap locks are compatible, but each blocks the other's insert → deadlock.
  • Use a single upsert with a unique key, pre-create rows, or use Read Committed for that workload.

Practice questions

In MySQL Repeatable Read, two transactions run SELECT … FOR UPDATE for a row that doesn't exist, then both INSERT it. Why do they deadlock?

Answer

Each locking read takes a gap lock on the range where the row would be. Gap locks are compatible, so both succeed; each INSERT then needs an insert-intention lock, which conflicts with the other transaction's gap lock — a cycle.

What's the simplest fix for get-or-create deadlocks on MySQL?

Answer

Replace lock-then-insert with one INSERT … ON DUPLICATE KEY UPDATE on a unique key, so the database creates or updates atomically without a prior gap lock.

How does READ COMMITTED change gap locking in InnoDB?

Answer

Under Read Committed, InnoDB disables gap locking for searches and index scans; gap locks are still used for foreign-key and duplicate-key checking. That removes most gap-lock deadlocks, at the cost of different read semantics.

Open in the app to mark lessons complete, take notes and track your progress.