Introduction

Welcome back! In the previous lesson, you learned how to batch multiple commands into a single request using Boost.Redis to reduce network round trips. Batching improves performance, but it is not atomic — other clients may still change data between your batched reads and writes.

This lesson introduces WATCH together with MULTI/EXEC, which you use to build conditional, optimistic-locking transactions. The idea is:

  • MULTI/EXEC makes a group of writes execute atomically.
  • WATCH monitors one or more keys and causes EXEC to abort if any of those keys change after WATCH and before EXEC.

In short: batching is non-atomic; atomicity requires MULTI/EXEC. WATCH ensures the keys stay unchanged between your read and your EXEC.

What You'll Learn

By the end of this lesson, you will be able to:

  • Send WATCH for one or more keys using request.push().
  • Combine WATCH with MULTI/EXEC to perform conditional updates.
  • Detect conflicts via EXEC's reply (nil/aborted), not via exceptions.
  • Follow the typical optimistic sequence (on the same connection):
    1. WATCH
    2. read
    3. compute
    4. MULTI
    5. write
    6. EXEC (retry if aborted)

Remember: request batching by itself is not atomic. Transactions require MULTI/EXEC, and WATCH ensures the keys you care about didn't change.

The Problem: Race Conditions in Read-Modify-Write

Imagine you're building a banking application. You want to update a user's balance by adding money to their account. The naive approach looks like this:

  1. GET balance:1 → returns "100"
  2. Calculate in C++: 100 + 50 = 150
  3. SET balance:1 150

What's the problem? Between steps 1 and 3, another client might also read the balance, compute a new value, and write it back. You might lose updates:

  • Client A reads 100, adds 50 → wants to write 150
  • Client B reads 100, adds 30 → wants to write 130
  • Client B writes 130 first
  • Client A writes 150 second
  • Result: Client B's +30 is lost! The balance should be 180, not 150.

This is called a race condition. Even if you batch the commands, they're not atomic — other clients can still interleave their operations.

How WATCH Solves This

WATCH is Redis's optimistic locking mechanism. Here's how it works:

  1. Before reading, you tell Redis: "Watch this key for changes"
  2. Redis remembers which keys you're watching
  3. You read the key and compute your new value
  4. When you execute MULTI/EXEC, Redis checks: "Did any watched key change since WATCH?"
    • If yes: EXEC returns nil (aborted), and nothing is written
    • If no: Your transaction executes atomically

This is called optimistic locking because you optimistically assume no conflict will occur. If one does, you detect it and retry.

Key insight: WATCH doesn't lock the key or block other clients. It just monitors for changes. If a change happens, you find out during EXEC.

Connection-specific (reliability) insight: WATCH is stored on the server per connection. That means:

  • You must run the whole sequence (WATCH → read → MULTI/EXEC) on the same Redis connection.
  • If the connection drops or is reset, the watched state is lost. In practice, you must treat a disconnect as “transaction attempt failed” and retry from the beginning after reconnecting.
Building the Solution: Step by Step

Let's build an optimistic balance updater that safely adds money to an account. We'll break it into clear steps.

Step 1: Setting Up the Connection

First, let's set up our basic includes and connection boilerplate:

We'll use a function to encapsulate the update logic with automatic retry:

The while (true) loop handles retries when conflicts are detected. Let's break down what happens inside.

Step 2: WATCH the Key

Input: Key name (balance:1)
Output: Redis returns "OK"
Purpose: Tell Redis to monitor this key for changes until EXEC

What's happening: We send WATCH balance:1. From this point forward, Redis tracks if any other client modifies this key. The command returns "OK" to confirm.

Step 3: GET the Current Value

Input: Key name (balance:1)
Output: String value ("100") or nil if key doesn't exist
Purpose: Read the current balance so we can compute the new value

What's happening: We GET the current value. We use .has_value() to check if the key exists, and parse the result. At this point, the key is being watched, but we haven't written anything yet. We compute the new balance in C++: balance + increment.

Step 4: Start the Transaction with MULTI

Input: None (command has no arguments)
Output: "OK"
Purpose: Start queuing commands; nothing executes yet

What's happening: MULTI tells Redis: "Queue the following commands. Don't execute them yet." It returns "OK" immediately.

Step 5: Queue the SET Command

Input: Key name and new value (balance:1, "150")
Output: "QUEUED" (not executed yet)
Purpose: Queue the write operation to update the balance

What's happening: We queue SET balance:1 150. Because we're inside a MULTI block, this command doesn't execute yet. Redis just returns "QUEUED" to confirm it's in the queue.

Step 6: Execute with EXEC and Handle Conflicts

Input: None (command has no arguments)
Output: Either nil (conflict detected) or array of command results (success)
Purpose: Execute all queued commands atomically, or abort if watched keys changed

Step 7: The Main Function (Putting It All Together)

Finally, here's the main function that initializes the balance, performs the update, and verifies the result:

What's happening here:

  • We set up the connection and run io_context in a background thread
  • We initialize balance:1 to 100
  • We call update_balance(conn, 1, 50) which uses WATCH + MULTI/EXEC to safely add 50
  • We verify the final balance and print it (should be 150)
  • We clean up by canceling the connection and stopping the io_context
Why It Matters
  • Prevents data loss: Race conditions can silently lose updates. WATCH ensures you know when conflicts occur.
  • Optimistic locking is efficient: Unlike pessimistic locks, WATCH doesn't block other clients. If conflicts are rare, this is very fast.
  • Clear conflict detection: EXEC's nil reply cleanly signals an aborted transaction — no exceptions needed.
  • Automatic retry logic: When a conflict occurs, you simply retry from WATCH. The watched state is automatically cleared after EXEC.
  • Combines batching with safety: You still batch related commands for performance while guaranteeing correctness.

Ready to get hands-on and explore further? Let's move on to the practice section and apply these commands in various scenarios to solidify your understanding.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal