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.
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
- 01
Flow:
WATCH key→GET key(read outside the transaction) → compute in the client →MULTI→ queued writes (each repliesQUEUED) →EXEC. If a watched key was modified by anyone (including expiry) afterWATCH,EXECreturns a null reply and nothing runs.UNWATCHorDISCARDclears watches;EXECalways clears them. - 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. - 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'sMULTIdoesn't abort that client's own transaction. - 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. - 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
RedisTemplateneedsSessionCallbackso all commands use the same connection.
Code & diagrams
Without atomicity both buyers see stock = 1 and both succeed.
# 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 appliedredis-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):
passSpring: 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
Why can't you do the stock check inside MULTI without WATCH?
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
Walk through what happens when two clients both WATCH the same key and both call EXEC.
Explain the two kinds of errors in a transaction and what each does.
Practice
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
EXECwhen 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.