Command Palette

Search for a command to run...

Hectal
PHASE 3Intermediate ~11 min· topic 2 of 5

Topic 3.2

MULTI/EXEC and WATCH: Optimistic Concurrency

In one line

MULTI queues commands and EXEC runs them together without interleaving. WATCH adds optimistic check-and-set: if any watched key changes before EXEC, the transaction aborts and the client retries. It solves the classic lost-update race, at the cost of retries under contention.

0/5 · 0%

Think of it like this

Editing a shared document that shows "version 12". You make your edits offline, and when you save, the system checks the version is still 12. If someone else saved version 13 in the meantime, your save is rejected and you redo your edit on top of theirs. That's WATCH.

Key ideas

  1. 01

    Flow: WATCH key → GET key (read outside the transaction) → compute in the client → MULTI → queued writes (each replies QUEUED) → EXEC. If a watched key was modified by anyone (including expiry) after WATCH, EXEC returns a null reply and nothing runs. UNWATCH or DISCARD clears watches; EXEC always clears them.

  2. 02

    Error behaviour: a syntax error while queueing aborts the whole transaction at EXEC (EXECABORT). A runtime error (wrong type) fails only that command; the others still apply. There is no rollback.

  3. 03

    What counts as "modified": any write command on the key, expiry of the key, and some others like FLUSHALL. Reading the key doesn't. Modifying it inside the same client's MULTI doesn't abort that client's own transaction.

  4. 04

    Under contention, WATCH degrades: if 1,000 clients fight over one key, most EXECs fail and retry, wasting round trips. Use exponential backoff and a retry limit, or switch to Lua, which never needs retries because it decides atomically on the server.

  5. 05

    Client libraries: transactions need all commands on one connection. In pooled clients (Jedis) that's natural; in multiplexing clients (Lettuce) you need a dedicated connection or the library's transaction API; Spring's RedisTemplate needs SessionCallback so all commands use the same connection.

Code & diagrams

lost-update.mermaiddiagram

Without atomicity both buyers see stock = 1 and both succeed.

Rendering diagram…
watch.redisredis
# Client A                                  # Client B
127.0.0.1:6379> WATCH stock:sku9
OK
127.0.0.1:6379> GET stock:sku9
"1"
                                            127.0.0.1:6379> DECR stock:sku9
                                            (integer) 0
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> DECR stock:sku9
QUEUED
127.0.0.1:6379(TX)> SADD buyers:sku9 userA
QUEUED
127.0.0.1:6379(TX)> EXEC
(nil)                  # aborted: stock changed after WATCH, nothing applied
buy.pypython

redis-py's transaction helper retries automatically on WatchError.

import redis

r = redis.Redis()

def buy(sku: str, user: str) -> bool:
    stock_key, buyers_key = f"stock:{sku}", f"buyers:{sku}"

    def txn(pipe: redis.client.Pipeline):
        stock = int(pipe.get(stock_key) or 0)      # runs immediately (after WATCH)
        if stock <= 0:
            pipe.unwatch()
            raise SoldOut()
        pipe.multi()                                # start queueing
        pipe.decr(stock_key)
        pipe.sadd(buyers_key, user)

    try:
        r.transaction(txn, stock_key)               # WATCH stock_key, retry on conflict
        return True
    except SoldOut:
        return False

class SoldOut(Exception):
    pass
RedisTemplateTx.javajava

Spring: SessionCallback keeps WATCH, MULTI and EXEC on one connection.

List<Object> result = redisTemplate.execute(new SessionCallback<>() {
    @Override
    public List<Object> execute(RedisOperations ops) {
        ops.watch("stock:sku9");
        Integer stock = Integer.valueOf((String) ops.opsForValue().get("stock:sku9"));
        if (stock <= 0) { ops.unwatch(); return List.of(); }
        ops.multi();
        ops.opsForValue().decrement("stock:sku9");
        ops.opsForSet().add("buyers:sku9", "userA");
        return ops.exec();          // empty list if aborted -> retry
    }
});

Interview problem

The problem

The last item in stock

Stock is 1. Requests A and B both try to buy at the same moment. Without atomicity, both read 1 and both write 0, selling the item twice. Solve it with WATCH/MULTI/EXEC, then compare with Lua, and discuss a flash sale where 50,000 users try to buy 100 items.

You're given

  • stock = 1
  • Two concurrent buyers
  • Flash sale: 100 items, 50K buyers in 2 seconds

The interviewer follows up

01

Why can't you do the stock check inside MULTI without WATCH?

02

Does the DECR-then-check trick have any downside?

When it breaks

WATCH-based checkout under a flash-sale spike

What you see

Most transactions abort and retry immediately, multiplying Redis load; latency climbs and many users time out even though stock is still available.

Fix & prevent

Move the check-and-decrement into a Lua script or use DECR with a result check; if you keep WATCH, add jittered backoff and a retry cap.

Transaction commands sent over different pooled connections

What you see

MULTI on one connection and EXEC on another: ERR EXEC without MULTI, or worse, commands run outside the transaction.

Fix & prevent

Use the client's transaction API (redis-py pipeline(transaction=True), Spring SessionCallback, Jedis Transaction from one Jedis instance).

Explain it without notes

01

Walk through what happens when two clients both WATCH the same key and both call EXEC.

02

Explain the two kinds of errors in a transaction and what each does.

Practice

01

Implement the inventory purchase with WATCH in your language, then hammer it with 50 concurrent threads for stock = 10 and verify exactly 10 succeed.

Trade-offs

  • ↔

    WATCH is simple and keeps logic in the application, but has retry storms under contention; Lua is contention-proof but moves logic into Redis.

  • ↔

    Transactions give isolation without rollback, so validate inputs before EXEC when partial application would be harmful.

Done when you can

  • I can solve the double-sale race with WATCH/MULTI/EXEC.

  • I can explain why MULTI can't branch and why WATCH retries under contention.

  • I know how transactions work in my client library.