Skip to main content
Databases · lesson 4 of 6
Databases advanced Lesson 4 of 6

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

databases acid base concurrency postgresql redis

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.

Frequently Asked Questions

Isn't PostgreSQL supposed to prevent lost updates automatically, being ACID?
ACID guarantees apply to what happens inside a single transaction and to how conflicting transactions interact — it does not retroactively protect a naive two-step client pattern (SELECT, then a separate UPDATE with no locking) from racing against another client doing the same thing. Postgres gives you the tools to prevent this (row locks via FOR UPDATE, or SERIALIZABLE isolation), but a plain read-then-write with no lock loses updates in Postgres exactly as it does anywhere else — this lesson demonstrates that directly before showing the fix.
Why did Redis lose the update but Postgres (once fixed) didn't?
SELECT ... FOR UPDATE takes a row-level lock as part of the read, so a second transaction's FOR UPDATE on the same row blocks until the first transaction commits — by the time it reads, it sees the already-updated value. A bare Redis GET has no equivalent concept of "lock this key for my upcoming write"; two GETs can both return the same stale value with nothing in between them to prevent it.
Does this mean Redis can't do atomic read-modify-write at all?
No — Redis has multiple ways to do this correctly: INCRBY for the specific case of adding a number (fully atomic, single command, no race possible), or WATCH/MULTI/EXEC for a general optimistic-lock pattern that aborts the transaction if the watched key changed since the read (covered in depth in this site's own redis/transactions.md). The bug in this lesson is specifically about a naive GET-then-SET pair that uses neither.
What's the actual connection to ACID vs BASE as concepts?
ACID databases bundle multi-statement operations into transactions with isolation guarantees you can opt into (like FOR UPDATE) as a core, expected feature of the system. A BASE-style key-value store optimizes for raw single-key operation speed and availability, and treats a sequence of two commands as two independent, uncoordinated operations by design — getting the same protection back requires reaching for a specific feature (WATCH, a Lua script, or an atomic command), not something implicit in "doing a read then a write."
Is FOR UPDATE the same as SERIALIZABLE isolation?
No — FOR UPDATE is a targeted row lock taken at read time for rows you explicitly name, while SERIALIZABLE is a whole-transaction isolation level that detects a broader class of conflicts (including ones that never touch the same row directly) and aborts one of the conflicting transactions with a serialization error rather than blocking. FOR UPDATE is usually the simpler, sufficient tool for the specific "lock this row, I'm about to update it" pattern this lesson demonstrates.