Redis transactions, initiated with MULTI and finalized with EXEC, are not what you might expect from traditional ACID-compliant databases.
Here’s how it looks in action. Imagine you want to atomically increment two counters, counter_a and counter_b, and then retrieve their new values.
redis-cli> MULTI
OK
redis-cli> INCR counter_a
QUEUED
redis-cli> INCR counter_b
QUEUED
redis-cli> GET counter_a
QUEUED
redis-cli> GET counter_b
QUEUED
redis-cli> EXEC
1) (integer) 1
2) (integer) 1
3) "1"
4) "1"
When MULTI is issued, Redis enters a special "transactional" mode. All subsequent commands are not executed immediately but are instead queued up. Only when EXEC is called are all the queued commands executed atomically, as a single, uninterruptible operation. The results are then returned in the same order as the commands were queued.
The core problem Redis transactions solve is the need for atomic operations across multiple keys. In a highly concurrent environment, if you were to execute these INCR and GET operations sequentially without MULTI/EXEC, another client could potentially modify counter_a or counter_b between your INCR and GET commands. This would lead to inconsistent or stale data. MULTI/EXEC guarantees that the sequence of operations, from MULTI to EXEC, is treated as a single unit of work.
Internally, Redis uses a simple queueing mechanism. When MULTI is received, a flag is set indicating that subsequent commands should be added to a client-specific queue rather than being processed immediately. The server maintains this queue until EXEC is encountered. At that point, it iterates through the queue, executes each command against the data, and collects their results. Crucially, Redis ensures that no other client commands can be interleaved during the execution of the commands within a transaction. This is a key difference from multi-statement transactions in relational databases, which might involve locking and more complex coordination.
The WATCH command introduces a form of optimistic locking, enabling conditional transactions. It allows you to monitor one or more keys. If any of the watched keys are modified by another client before EXEC is called, the transaction will fail and EXEC will return nil. This is essential when you need to ensure that the data you are operating on hasn’t changed since you started preparing your transaction.
Consider this scenario: you want to transfer 10 units from account_a to account_b, but only if account_a has at least 10 units.
redis-cli> WATCH account_a
OK
redis-cli> MULTI
OK
redis-cli> GET account_a
QUEUED
redis-cli> GET account_b
QUEUED
redis-cli> EXEC
(nil)
Here, EXEC returned (nil) because account_a was modified by another client after WATCH was issued but before EXEC was called. If account_a had not been modified, EXEC would have returned the results of the GET commands. You’d then typically use this information in your application logic to retry the transaction.
The "atomicity" provided by MULTI/EXEC is about the execution of the commands themselves, not about rollback in the traditional sense. If an error occurs during the execution of a command within an EXEC (e.g., trying to INCR a string value), that specific command will fail, and its result will be an error. However, other commands within the same transaction that are valid will still be executed. This is why WATCH is so important for ensuring data integrity in more complex scenarios.
A common misconception is that MULTI/EXEC provides ACID guarantees like relational databases. Redis transactions offer atomicity and isolation for the execution of the queued commands, but they don’t offer durability in the same way. If Redis crashes after EXEC has completed, the changes are durable if persistence is configured. However, if a crash occurs during the execution of EXEC, the transaction might be partially applied or not applied at all, and there’s no automatic rollback.
The DISCARD command is the counterpart to EXEC. If you issue DISCARD after MULTI, all queued commands are simply discarded, and the transaction is aborted without execution. This is useful for cleaning up a transaction that you no longer wish to execute.
The next critical concept to grasp is how to handle the (nil) return from EXEC when WATCH is involved, which typically requires a retry loop in your application.