Definition
- Locking read – A
SELECTthat also acquires a lock on the rows it reads, closing the gap between reading and updating. FOR UPDATE– Takes an exclusive (write-intent) lock so no other transaction can lock, update, or delete those rows until commit or rollback.FOR SHARE– Takes a shared lock allowing other readers but blocking writers, useful when you depend on a row not changing without needing to modify it yourself.- Read-check-then-update – The pattern of reading a value, verifying a condition, then writing, which races without a lock.
- Contention cost – Every exclusive lock serializes access, and overusing
FOR UPDATEcreates queues, raises latency, and increases deadlock risk. - Senior guidance – Lock only the specific rows a correctness-critical update needs, keep the transaction short, and prefer optimistic locking under low contention.
A normal read at National Bank of India is just glancing at a ledger line. A locking read with FOR UPDATE is like putting a "reserved – I am about to change this" sticker on that exact ledger line so nobody else can touch it until you finish and sign off. This guarantees your "read the stock, check it, then decrement" steps are not undercut by another clerk. But if you slap that sticker on every line you glance at, everyone queues behind you needlessly – so you only reserve the lines you are about to change.
Understanding the Concepts
What is a locking read?
A locking read is a SELECT statement that also acquires a lock on the rows it returns. An ordinary SELECT under MVCC reads a consistent snapshot and takes no locks, leaving a window between reading and updating where another transaction can change the data. A locking read closes that window by acquiring the lock as part of the read.
-- Ordinary SELECT: no lock, snapshot read
SELECT units_available FROM fd_offers WHERE offer_id = 101;
-- Another transaction can change units_available between this read and your update
-- Locking SELECT: exclusive lock held until COMMIT
SELECT units_available FROM fd_offers WHERE offer_id = 101 FOR UPDATE;
-- No other transaction can modify or lock this row until you commit
How does SELECT ... FOR UPDATE work?
FOR UPDATE takes an exclusive (write-intent) lock on each matched row. Other transactions that try to FOR UPDATE, UPDATE, or DELETE those rows block until the locking transaction commits or rolls back. Plain snapshot reads may still proceed because MVCC serves them from a snapshot.
-- Protecting a fixed-deposit offer allotment at National Bank of India
BEGIN;
-- Step 1: lock the row
SELECT units_available, price_per_unit
FROM fd_offers
WHERE offer_id = 101
FOR UPDATE;
-- Step 2: check availability (only this transaction sees the locked row)
-- units_available = 1
-- Step 3: decrement
UPDATE fd_offers
SET units_available = units_available - 1
WHERE offer_id = 101;
INSERT INTO allotments (offer_id, customer_id, units, allotted_at)
VALUES (101, 'CUST-PRIYA', 1, CURRENT_TIMESTAMP);
COMMIT; -- lock released
Behavior under concurrency: Priya: BEGIN → SELECT FOR UPDATE (locks offer 101) → check: 1 unit → decrement → COMMIT Rajesh: BEGIN → SELECT FOR UPDATE (BLOCKS, waiting for Priya) → reads 0 units → fails check Result: only Priya is allotted. No overselling.
How does SELECT ... FOR SHARE work?
FOR SHARE (called LOCK IN SHARE MODE in older InnoDB) takes a shared lock. Other transactions can also hold shared locks and read, but no transaction can modify the rows until all shared locks are released. This is useful when you depend on a row not changing without needing to modify it yourself.
-- FOR SHARE: ensure the account exists and is active while inserting a transaction
BEGIN;
SELECT account_id, status FROM accounts
WHERE account_id = 'SAV2001'
FOR SHARE;
-- Shared lock: others can also read FOR SHARE, but nobody can UPDATE or DELETE
-- Now safely insert a transaction knowing the account cannot be closed
INSERT INTO transactions (account_id, amount, txn_type, created_at)
VALUES ('SAV2001', 50000.00, 'CREDIT', CURRENT_TIMESTAMP);
COMMIT;
How is FOR UPDATE different from FOR SHARE?
| Aspect | FOR UPDATE | FOR SHARE |
|---|---|---|
| Lock type | Exclusive (write-intent) | Shared (read) |
| Other FOR UPDATE | Blocked | Blocked |
| Other FOR SHARE | Blocked | Allowed |
| Other UPDATE/DELETE | Blocked | Blocked |
| Plain SELECT (MVCC) | Allowed (snapshot) | Allowed (snapshot) |
| Use case | Read-then-modify this row | Depend on this row not changing |
Why is the read-check-then-update pattern dangerous without locking?
Without a lock, the check-then-act race means two transactions can both pass a check and both write, producing an incorrect result.
-- DANGEROUS: no lock, overselling possible
-- Priya and Rajesh both read units_available = 1
SELECT units_available FROM fd_offers WHERE offer_id = 101; -- both see 1
-- Both pass the check
-- Both decrement
UPDATE fd_offers SET units_available = units_available - 1 WHERE offer_id = 101;
-- units_available = 0 after first update, then -1 after second!
-- Oversold: two allotments for one unit
Why should you NOT use FOR UPDATE everywhere?
Every exclusive lock forces other transactions needing the same rows to wait. Broad or long-held locks create lock queues, raise latency, and increase deadlock probability.
-- WRONG: FOR UPDATE on a read-only reporting query
SELECT a.account_id, a.balance, c.full_name
FROM accounts a
JOIN customers c ON a.customer_id = c.customer_id
WHERE a.branch = 'Mumbai-Central'
FOR UPDATE; -- locks ALL accounts in Mumbai-Central!
-- Dashboard users block each other and block tellers doing real debits
-- CORRECT: plain read for reporting, no lock needed
SELECT a.account_id, a.balance, c.full_name
FROM accounts a
JOIN customers c ON a.customer_id = c.customer_id
WHERE a.branch = 'Mumbai-Central';
What about deadlocks?
Deadlocks occur when two transactions lock rows in different orders. Each holds one lock and waits for the other. The database detects the deadlock and aborts one transaction.
-- Deadlock scenario
-- Transaction A: locks SAV2001, then tries to lock SAV2002
-- Transaction B: locks SAV2002, then tries to lock SAV2001
-- Both block forever → database detects → one is aborted
-- Prevention: always lock accounts in sorted order
-- Transfer from SAV2001 to SAV2002:
BEGIN;
SELECT * FROM accounts WHERE account_id IN ('SAV2001', 'SAV2002')
ORDER BY account_id FOR UPDATE; -- consistent order prevents deadlock
UPDATE accounts SET balance = balance - 50000 WHERE account_id = 'SAV2001';
UPDATE accounts SET balance = balance + 50000 WHERE account_id = 'SAV2002';
COMMIT;
What are common mistakes with locking reads?
The most common mistake is using FOR UPDATE on queries that never lead to an update, which adds contention for no correctness benefit. Another mistake is holding locks across long operations like external API calls.
package com.bank.service;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class FdAllotmentService {
// WRONG: holds lock while calling external payment gateway
public void allotUnsafe(Connection conn, int offerId,
String customerId) throws Exception {
conn.setAutoCommit(false);
PreparedStatement ps = conn.prepareStatement(
"SELECT units_available FROM fd_offers WHERE offer_id = ? FOR UPDATE");
ps.setInt(1, offerId);
ResultSet rs = ps.executeQuery();
rs.next();
// Lock is held while calling external API!
callExternalPaymentGateway(customerId); // may take seconds
// Other transactions blocked for seconds
conn.commit();
}
// CORRECT: minimize lock duration
public void allotSafe(Connection conn, int offerId,
String customerId) throws Exception {
// External call first, outside the lock
callExternalPaymentGateway(customerId);
conn.setAutoCommit(false);
PreparedStatement ps = conn.prepareStatement(
"SELECT units_available FROM fd_offers WHERE offer_id = ? FOR UPDATE");
ps.setInt(1, offerId);
ResultSet rs = ps.executeQuery();
rs.next();
int units = rs.getInt(1);
if (units <= 0) {
conn.rollback();
throw new IllegalStateException("Sold out");
}
conn.prepareStatement(
"UPDATE fd_offers SET units_available = units_available - 1 WHERE offer_id = " + offerId
).executeUpdate();
conn.commit(); // lock held for milliseconds, not seconds
}
private void callExternalPaymentGateway(String customerId) {
// simulated external call
}
}
What should a senior engineer know?
A senior engineer knows to lock only the specific rows a correctness-critical update needs, to keep the locked transaction as short as possible, to acquire locks in a consistent order to prevent deadlocks, to ensure the locked predicate is well-indexed so few rows are locked rather than a table scan locking many, and to prefer optimistic locking when contention is low.
Visual Overview
┌─────────────────────────────────────────────────────┐ │ Locking Read: FOR UPDATE Flow │ ├─────────────────────────────────────────────────────┤ │ │ │ Transaction A Transaction B │ │ ────────────── ────────────── │ │ BEGIN BEGIN │ │ │ │ │ │ ▼ │ │ │ SELECT ... FOR UPDATE │ │ │ (locks row 101) │ │ │ │ ▼ │ │ │ SELECT ... FOR UPDATE │ │ │ (BLOCKED: waiting) │ │ ▼ │ │ │ Check: units = 1 │ │ │ UPDATE: units = 0 │ (waiting...) │ │ │ │ │ │ ▼ │ │ │ COMMIT (lock released) │ │ │ ▼ │ │ Reads: units = 0 │ │ Check fails: sold out │ │ ROLLBACK │ │ │ │ Result: only Transaction A allotted. Correct. │ │ │ ├─────────────────────────────────────────────────────┤ │ FOR UPDATE vs FOR SHARE Lock Compatibility │ ├─────────────────────────────────────────────────────┤ │ │ │ │ Existing FOR UPDATE │ Existing FOR SHARE │ │ ───────┼─────────────────────┼───────────────── │ │ New FU │ BLOCKED │ BLOCKED │ │ New FS │ BLOCKED │ ALLOWED │ │ UPDATE │ BLOCKED │ BLOCKED │ │ SELECT │ ALLOWED (MVCC) │ ALLOWED (MVCC) │ │ │ └─────────────────────────────────────────────────────┘
Key Takeaway
A locking read is a SELECT that also acquires a lock on the rows it reads, closing the gap that an ordinary MVCC snapshot read leaves open between reading a value and updating it. SELECT ... FOR UPDATE takes an exclusive lock on each matched row, so any other transaction that tries to lock, update, or delete those rows blocks until the locking transaction commits or rolls back. Because the lock is held for the whole transaction, the entire read-check-then-update sequence is serialized for that row.
SELECT ... FOR SHARE takes a shared lock that still lets other transactions read but blocks writers, which is useful when you depend on a row not changing while you act without needing to modify it yourself. The canonical pattern this protects is read stock, verify availability, then decrement: with a plain read two concurrent buyers both read one unit, both pass the check, and both decrement, overselling. With FOR UPDATE the second buyer's locking read blocks until the first commits, then reads the already-decremented value and correctly fails.
The reason not to use FOR UPDATE everywhere is that every exclusive lock serializes access and raises contention. At National Bank of India, Rajesh found that a limited fixed-deposit offer was oversubscribed when Priya and another customer both read one unit remaining and both were allotted. He fixed it by making the read-check-then-decrement use SELECT units FROM fd_offers WHERE offer_id = ? FOR UPDATE, while also stripping FOR UPDATE out of read-only reporting queries where it had crept in and was needlessly blocking dashboards.
The senior guidance is to lock only the specific rows a correctness-critical update truly needs, hold the lock for the shortest possible transaction, acquire locks in a consistent order to avoid deadlocks, keep the locked predicate well-indexed so few rows are locked, and prefer optimistic locking when contention is low.
Remember one sentence: SELECT ... FOR UPDATE locks the read rows until commit so a read-check-then-update is atomic against competitors – use it sparingly because each lock adds contention.
---