Skip to content

Step 1 of 3 · Reading · ~3 min

Optimistic Locking with WATCH

Advanced Features

The problem MULTI/EXEC doesn't solve

MULTI/EXEC gives you atomicity — once EXEC runs, every queued command executes back-to-back with nothing interleaved. What it does not give you is isolation from the past: another client can freely modify a key between the moment you decided to build a transaction and the moment you actually call EXEC. Classic example — implementing INCR yourself as read-then-write:

GET counter        -- client reads 10
                    -- (another client sneaks in: SET counter 999)
MULTI
SET counter 11      -- queued, computed from a now-stale read
EXEC

You just clobbered someone else's write with a value computed from stale data. This is the textbook read-modify-write race, and it's exactly what WATCH exists to prevent.

Optimistic concurrency control

WATCH implements optimistic locking: instead of blocking other clients while you think (a pessimistic lock), you snapshot a version number for each key you care about, do your work, and at commit time check whether anything you were watching actually changed. If it did, you abort and the caller retries. This is the same idea as a database's compare-and-swap or an atomic CAS instruction — cheap in the common case (no contention), and it degrades gracefully (a retry loop) under contention instead of causing a pileup of blocked clients.

Implementation: per-key version counters

Give every key in the keyspace a monotonically increasing version number, stored separately from the value itself:

versions = {}   # key -> int, starts at 0 if never touched

def bump(key):
    versions[key] = versions.get(key, 0) + 1

Call bump(key) from every write path — SET, DEL, LPUSH, HSET, SADD, INCR, expiry-driven deletes, everything that mutates the keyspace. This is the single place a bug tends to hide: if you add a new write command later and forget to bump its version, WATCH will silently stop protecting it.

WATCH key1 key2 ... snapshots the current version of each named key into a per-connection set:

watched = {}   # key -> version-at-watch-time

def do_watch(keys):
    for k in keys:
        watched[k] = versions.get(k, 0)

Checking at EXEC time

When EXEC runs, before executing any queued command, compare every watched key's snapshot to its live version:

def is_dirty():
    return any(versions.get(k, 0) != snap for k, snap in watched.items())

If dirty, EXEC discards the queued commands and replies with the RESP nil array marker $-1\r\n (per this exercise's simplified protocol — real Redis uses *-1\r\n, the null array, since EXEC normally returns an array of results). If clean, run the queue exactly as MULTI/EXEC already does, then clear the watch set.

UNWATCH simply empties the watched-keys set and returns +OK\r\n. Watching is also implicitly cleared after every EXEC (successful or aborted) and after connection reset — this exercise only requires explicit UNWATCH, but keep that real-Redis behavior in mind.

Edge cases

  • WATCH issued while already inside MULTI is an error — Redis doesn't allow queuing a watch, because by the time it would execute inside the transaction it's too late to matter.
  • Watching a key that doesn't exist yet is legal — its snapshot version is 0; if another client creates it before EXEC, that counts as a change (version becomes 1) and the transaction should still abort.
  • A command inside the transaction itself modifying a watched key does not need special handling — the dirty check happens once, before execution starts, and the transaction is atomic once it begins, so nothing else can race with it in the meantime.
Up nextEVAL — Server-Side Lua ScriptingScripting, Replication & Streams

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…