Databases · lesson 4 of 6
Intermediate
2Advanced
4Also part of Databases
12- + Schema Design from SQL
- + Indexing Strategy: When an Index Actually Helps from PostgreSQL
- + Composite Indexes: The Filesort a Single-Column Index Can't Avoid from MySQL
- + Clustered vs Nonclustered: When an Index Makes a Query Slower from SQL Server
- + A Real Deadlock, and Why Lock Order Fixed It from PostgreSQL
- + Isolation Levels: The Same Transaction Reading Two Different Values from MySQL
- + A Real Dirty Read: Seeing a Value That Never Actually Existed from SQL Server
- + Replication: A Second Server, and the read_only Gap Nobody Mentions from MySQL
- + Killing the Primary and Measuring the Real Failover Time from MongoDB
- + Point-in-Time Recovery: Undoing a Bad DELETE That Already Committed from PostgreSQL
- + Backup and Recovery: What a Restore Actually Gives Back from MySQL
- + Quorum Consistency: When a Read Is Guaranteed to See the Latest Write from System Design
Also part of: System Design Redis SQL
ACID vs BASE: the Lost Update Neither Engine Prevents by Default
A naive read-then-write race loses an update in both PostgreSQL and Redis when neither uses locking — the real difference is that Postgres ships a built-in fix (SELECT ... FOR UPDATE) and a raw key-value GET/SET pair has no equivalent.
By SkillByExample · Published
The same naive bug — read a value, wait, write based on what you read — exists in both engines. The real difference is what each one gives you to fix it.
The bug, in Redis: two processes, one lost update
redis-cli SET balance 1000
Two processes run concurrently, each reading the current balance, waiting briefly (simulating real work between the read and the write), then writing back the old value plus their own amount:
# redis_naive_increment.sh
AMOUNT=$1
OLD=$(redis-cli GET balance)
sleep 0.3
NEW=$((OLD + AMOUNT))
redis-cli SET balance $NEW
echo "read $OLD, wrote $NEW"
sh redis_naive_increment.sh 100 &
sh redis_naive_increment.sh 50 &
wait
redis-cli GET balance
--- process A (100) ---
read 1000, wrote 1100
--- process B (50) ---
read 1000, wrote 1050
--- final balance ---
1100
Both processes read 1000 before either had written anything back. Process A wrote 1100;
process B — reading the same stale 1000 — wrote 1050, silently overwriting A’s result.
The correct answer (both increments applied) is 1150. 50 is gone, with no error anywhere.
The exact same bug, in PostgreSQL
UPDATE accounts SET balance = 1000 WHERE id = 1;
# pg_naive_increment.sh
AMOUNT=$1
OLD=$(psql -U postgres -t -A -c "SELECT balance FROM accounts WHERE id = 1;")
sleep 0.3
NEW=$((OLD + AMOUNT))
psql -U postgres -c "UPDATE accounts SET balance = $NEW WHERE id = 1;"
echo "read $OLD, wrote $NEW"
sh pg_naive_increment.sh 100 &
sh pg_naive_increment.sh 50 &
wait
psql -U postgres -t -A -c "SELECT balance FROM accounts WHERE id = 1;"
--- process A (100) ---
read 1000, wrote 1100
--- process B (50) ---
read 1000, wrote 1050
--- final balance ---
1100
Identical failure, identical final answer. SELECT followed by a separate UPDATE, with no
locking, has exactly the same race in Postgres as GET followed by SET has in Redis. Being
an ACID database doesn’t protect a client that never asks for that protection.
The fix: SELECT … FOR UPDATE
# pg_locked_increment.sh
AMOUNT=$1
psql -U postgres <<SQL
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE \gset old_
SELECT pg_sleep(0.3);
UPDATE accounts SET balance = :old_balance + $AMOUNT WHERE id = 1;
COMMIT;
SELECT balance FROM accounts WHERE id = 1;
SQL
psql -U postgres -c "UPDATE accounts SET balance = 1000 WHERE id = 1;"
sh pg_locked_increment.sh 100 &
sh pg_locked_increment.sh 50 &
wait
psql -U postgres -t -A -c "SELECT balance FROM accounts WHERE id = 1;"
--- process A (100) ---
balance
---------
1100
--- process B (50) ---
balance
---------
1150
--- final balance ---
1150
1150 — correct. FOR UPDATE takes a row lock the instant the SELECT runs. Process B’s
SELECT ... FOR UPDATE blocked until process A’s transaction committed, so by the time B
actually read the balance, it saw A’s already-applied 1100 — not the stale 1000 it would
have seen without the lock. This is Postgres’s built-in answer to the exact bug demonstrated
above: a row lock taken as part of the read, not bolted on afterward.
What Redis needs instead
A raw GET has no equivalent to FOR UPDATE — there’s no way to say “lock this key, I’m
about to write it” as part of a plain read. Fixing this in Redis means reaching for a
different feature entirely: WATCH/MULTI/EXEC (an optimistic lock that aborts the
transaction if the key changed — see this site’s own redis/transactions.md for a full
worked example of exactly that abort happening), or simply using INCRBY, which is atomic by
construction and never has this race at all because there’s no separate read step to race
against. Postgres bundles read-then-lock into one clause; Redis requires picking the specific
tool the operation actually needs.
Practice
1. Fix the Redis version using INCRBY instead of GET/SET, and confirm no update is lost.
redis-cli SET balance 1000
redis-cli INCRBY balance 100 &
redis-cli INCRBY balance 50 &
wait
redis-cli GET balance
1050
1150
1150
The first two lines are each INCRBY’s own return value (the new balance immediately after
that increment applied) — printed in whichever order the two commands actually completed, so
1050 here means the +50 happened first. The final GET confirms 1150: both increments
landed, nothing lost. INCRBY is a single atomic command — there’s no window between a read
and a write for another client to land in, because there is no separate read step at all.
2. Run the naive Postgres version with a much larger sleep to confirm the bug isn't timing-sensitive luck.
Widening sleep 0.3 to sleep 1 in pg_naive_increment.sh and rerunning produces the
identical failure — final balance: 1100, not 1150. The bug isn’t a narrow timing
coincidence; it exists for the entire duration between the SELECT and the UPDATE, however
long that gap is.
3. Confirm FOR UPDATE actually blocks, not just "usually happens to work," by checking Postgres's own activity view mid-race.
SELECT pid, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE query LIKE '%accounts%' AND state != 'idle';
Run this from a third session while the two locked-increment processes are mid-flight (widen
the pg_sleep to a second or more to make the window easy to hit) and the blocked session
shows up directly:
pid | state | wait_event_type | wait_event | query
-----+--------+-----------------+---------------+------------------------------------------------------
166 | active | Lock | transactionid | SELECT balance FROM accounts WHERE id = 1 FOR UPDATE
wait_event_type: Lock / wait_event: transactionid is Postgres directly reporting “this
session is stuck waiting on another transaction’s lock” — not an inference from timing, the
database’s own bookkeeping confirming the block is real.
Next: scaling patterns compared — what “scale this up” actually means differently in each engine.