skip to content

When Redis cancels a transaction because a guarded key changed, what exactly counts as a change? Cover whether writing the same value counts, whether one field of a large hash counts, whether the client's own write counts, and what happens if the key expires on its own.

level: middleimportance: should knowfreq 36%

answer

  1. per-key, never per-field or per-member
  2. signal on write, not on value difference
  3. own-connection writes dirty your watch too
  4. 6.0.9+: plain TTL expiry does not abort
  5. watches live on the connection — release on every path

basics

~20 s

Granularity is the whole key, not a field or element, and the test is 'a write command touched it', not 'the value differs' — rewriting the identical value still cancels. A write on your own connection counts too. In modern Redis a key expiring on its own does not cancel.

solid answer

~60 s

`WATCH` tracks **keys**, not fields. Watching a hash with ten thousand fields means any `HSET` to any one of them invalidates you, even though your logic only cares about one field. Same for a list, set or sorted set: any member change dirties the whole key. If that granularity hurts, split the data into smaller keys so contention is scoped to what you actually read. The invalidation test is *modification signalled*, not *value differs*. `SET k v` with the value it already had still marks watchers dirty — Redis does not compare old and new. Deleting the key counts; so does a rename onto it. A write from your **own** connection also dirties your watch, which surprises people who read-then-write before EXEC. Since Redis 6.0.9, a key disappearing purely through TTL expiry does **not** cancel EXEC — logical expiry is not treated as a client modification — while an explicit `DEL` does. Older versions could abort depending on when the lazy expiry happened to fire, which made loops on TTL'd keys spuriously retry.

code

text · 10 lines
text
# client A
SET k "v"
WATCH k
# client B
SET k "v"        # identical value — still signals a modification
# client A
MULTI
SET k "v2"
EXEC
# (nil)  -> cancelled; Redis compares nothing, it only sees 'k was written'

go deeper

for a junior

Know the headline: whole key, any write counts — including writing the same value back.

for a middle

Add the own-connection case, that reads never dirty, and the modern rule that plain TTL expiry does not abort; mention UNWATCH on early exit.

for a senior

Turn granularity into a data-modelling argument (split volatile fields out), and cover connection-pool hygiene and the cluster same-slot requirement.

for a principal

Reason about effective conflict rate — driven by total writes to the watched key, including no-op refreshes — and let that drive key layout and whether optimistic CAS is viable at all.

## The unit is the key Redis's watch bookkeeping is a per-key watchers list. Every command that modifies a key signals that key as modified, and every client watching it is flagged dirty. There is no sub-key tracking: no per-field granularity for hashes, no per-member granularity for sets or sorted sets, no per-element granularity for lists. The consequence is a design rule. If you watch `user:42` — a hash holding profile, preferences, counters and a last-seen timestamp — then a background job bumping `last_seen` invalidates your careful check-and-set on `email`, over and over. Under any real write rate the loop starves. The fix is data modelling, not tuning: split the volatile field into its own key (`user:42:last_seen`) so watchers of the stable data are never disturbed. **Watch granularity is a reason to choose smaller keys.** ## Modification, not difference Redis does not compare values. The signal fires when a write command executes against the key, so: - `SET k "same"` where `k` already held `"same"` → dirty. - `SADD s member` where the member is already present → dirty (the command ran against the key). - `DEL k` → dirty; `RENAME other k` → dirty for `k`. - `EXPIRE k 60` → this is a write to the key's metadata and signals it. Read commands (`GET`, `HGETALL`, `SCAN`) never dirty anything, which is why the read step of a check-and-set loop is safe. This matters for idempotent writers: a job that "refreshes" values by rewriting them unchanged looks harmless from a data standpoint but is a full-blown source of CAS conflicts. If such a job exists, either make it skip no-op writes client-side or keep its keys away from watched ones. ## Your own writes count A write issued on the same connection that holds the watch also flags it. The pattern that trips over this is: `WATCH k`, read `k`, do a *non-transactional* write to `k` "just to prepare", then `MULTI`/`EXEC` — which now always returns nil. Inside the watch window, do reads only; put every write in the queued block. ## Expiration Expiry is the subtle one. A key with a TTL that lapses between WATCH and EXEC is not being changed by anybody — it is simply logically gone. Modern Redis (6.0.9 and later) treats it that way: **expiry alone does not invalidate a watch**, so a transaction guarded on a volatile key is not cancelled just because the key timed out. An explicit `DEL`, or an overwrite, still invalidates as normal. Before that change the behaviour depended on when Redis happened to notice the expiry (lazy expiry on access, or the active-expire cycle), which produced maddening intermittent retries on caches with short TTLs. If you support old servers, assume TTL'd watched keys can spuriously abort and keep the retry budget generous. ## Lifecycle of the watch itself - Watches live on the **connection**, not the key. They accumulate until cleared. - `EXEC` clears them, applied or not. `DISCARD` clears them. `UNWATCH` clears them without running anything. `RESET` (Redis 6.0+) clears them along with the rest of the connection state. - Closing the connection clears them. - With a connection pool, a code path that watches and then returns the connection without EXEC/DISCARD/UNWATCH hands the next borrower a poisoned connection whose first EXEC may return nil for no visible reason. Always release on every path, including error paths. ## Cluster note In Redis Cluster, all keys you watch and touch in one transaction must live in the same hash slot, since the queued block executes on a single node. Use a hash tag (`{tenant:7}:a`, `{tenant:7}:b`) to force co-location; without it the client will hit cross-slot errors or redirects and the guard will not mean what you think. ## Putting it together When designing a check-and-set path, ask: what is the smallest key that contains everything my decision reads? Watch exactly that. Then ask: who else writes that key, and how often — including refresh jobs that write unchanged values? That write rate, not the logical conflict rate, is what your retry loop will actually experience.

  • A CAS loop on a user hash almost never succeeds in production. What is the likely cause and fix?
    Something is writing an unrelated field of the same hash at a high rate — a last-seen timestamp, a counter, a refresh job rewriting identical values. Because watch granularity is the whole key, every such write cancels the transaction. Split the volatile field into its own key so the watched key only changes when data your decision depends on changes.
  • Why is it risky to WATCH and then return a connection to the pool without EXEC, DISCARD or UNWATCH?
    Watches are connection state, so the next borrower inherits them. Its first EXEC can return nil for reasons entirely unrelated to its own logic, producing a bug that looks random and is very hard to trace. Release watches on every path, including exceptions and early returns, or use RESET when the client supports it.

saying these in an interview costs you the question

  • Believing WATCH tracks individual hash fields or set members
  • Thinking a write of the identical value is ignored because the value did not change
  • Assuming your own connection's writes are exempt from invalidation
  • Claiming a key expiring always aborts the transaction on modern Redis
  • Leaving watches on a pooled connection after an early return

context