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/EXECmakes a group of writes execute atomically.WATCHmonitors one or more keys and causesEXECto abort if any of those keys change afterWATCHand beforeEXEC.
In short: batching is non-atomic; atomicity requires MULTI/EXEC. WATCH ensures the keys stay unchanged between your read and your EXEC.
By the end of this lesson, you will be able to:
- Send
WATCHfor one or more keys usingrequest.push(). - Combine
WATCHwithMULTI/EXECto perform conditional updates. - Detect conflicts via
EXEC's reply (nil/aborted), not via exceptions. - Follow the typical optimistic sequence (on the same connection):
WATCH- read
- compute
MULTI- write
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.
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:
GET balance:1→ returns "100"- Calculate in C++: 100 + 50 = 150
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.
WATCH is Redis's optimistic locking mechanism. Here's how it works:
- Before reading, you tell Redis: "Watch this key for changes"
- Redis remembers which keys you're watching
- You read the key and compute your new value
- When you execute
MULTI/EXEC, Redis checks: "Did any watched key change since WATCH?"- If yes:
EXECreturnsnil(aborted), and nothing is written - If no: Your transaction executes atomically
- If yes:
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.
Let's build an optimistic balance updater that safely adds money to an account. We'll break it into clear steps.
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.
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.
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.
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.
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.
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
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_contextin a background thread - We initialize
balance:1to 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
- Prevents data loss: Race conditions can silently lose updates.
WATCHensures you know when conflicts occur. - Optimistic locking is efficient: Unlike pessimistic locks,
WATCHdoesn't block other clients. If conflicts are rare, this is very fast. - Clear conflict detection:
EXEC'snilreply 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 afterEXEC. - 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.
