Redis isolation and alternatives in 2026
How does Redis handle command isolation, and what alternatives exist today?
In many ways, Redis is grounded in a (rather admirable) design philosophy of brutal simplicity. It's a thing that runs as a single process, you store keys and values; you can store many more data structures there if it takes your fancy. Yes, after nearly 2 decades it's not that simple and it continues to grow in breadth of command functionality, and depth of operational capabilities. The tumult over its licensing changes has also sped up an already developing schism resulting in things which speak the Redis wire protocol, but aren't Redis™; its descendants such as Valkey are obvious elephants in the room, but so are unrelated projects such as DragonflyDB, Garnet and black boxes like Upstash; I'd also obviously mention Invar in that list :) This growing sprawl of Redis-like-things is diverging not only in command dialects they speak (Redis, Valkey and Upstash have gone their separate ways on vector storage, for example); they're also splitting apart in terms of underlying execution semantics.
Coming back to my first point, one intentionally simplistic trait of Redis which has endured, is how it approaches isolation: it just serializes everything. I want to explore what isolation is in general terms, how the database industry approaches it, and lastly why you might want to consider your options when choosing a Redis-like data store.
Isolation?
By isolation, I'm referring to the 'I' in ACID; in a nutshell, how do separate concurrent commands run independently, without interfering with one another. How you separate commands, or whether you separate them at all, falls on a continuum of strictness; what we generally talk about is the presence of various phenomena under various separation conditions.
The SQL-92 spec details 4 fundamental levels of transaction separation in a database which often frames conversations about how DBMS handle isolation:
| Transaction isolation level | Dirty reads | Non-repeatable reads | Phantom reads |
|---|---|---|---|
| Read uncommitted | X | X | X |
| Read committed | -- | X | X |
| Repeatable read | -- | -- | X |
| Serializable | -- | -- | -- |
Dirty reads
A dirty read occurs when a transaction reads a value that hasn't actually been committed yet. To take a hypothetical Redis-tinged example:
T1: SET balance 500
T2: GET balance -> 500
T1: (transaction aborts / rolls back)
T2 has observed a value written by T1 that was never committed. I say hypothetical, because Redis itself doesn't have a rollback mechanism, and since it forces commands to execute sequentially, it's not possible for uncommitted values to overlap; it's just an illustration of what the phenomenon might mean in a transactional DB that just happened to speak the Redis wire protocol.
What if a MULTI block partially fails, though? It's true that transactions aren't atomic in Redis, and any command successfully applied within a transaction will remain, even if a subsequent command in the same transaction block fails. For example, if we have this starting state:
Initial:
A = 0
B = 0
And one client which does this:
Client 1:
MULTI
SET A 1
SET B 1
EXEC
If SET B 1 fails for some reason, the earlier SET A 1 remains applied and the global state looks like:
A = 1
B = 0
Even if a second client concurrently submits these operations, they will execute either before or after the transaction, not in the middle:
Client 2:
GET A -> 1
GET B -> 0
So Redis sidesteps the dirty-read problem, although MULTI / EXEC atomicity is a whole other thing...
Non-repeatable reads
This occurs when a transaction reads the same key twice and gets different values because another transaction committed a modification between the two reads. Assuming we have a balance key which is 500 to begin with:
T1: GET balance -> 500
T2: SET balance 750
T1: GET balance -> 750
The same key balance returns different values to T1 at different points in time.
Phantom Reads
Phantom reads happen when a transaction repeats a query over a set of keys, and gets a different set of results because another transaction inserted, deleted, or modified a key so that it now matches the query. For instance, if we have this initial state:
user:1 = active
user:2 = inactive
and we ran two 'interleaving' transactions (yes, hypothetical again in plain Redis)
T1: SCAN user:* MATCH *active* -> [user:1]
T2: SET user:3 active
T1: SCAN user:* MATCH *active* -> [user:1, user:3]
T1's second query sees a new matching key that didn't exist when its first query ran. This isn't a SCAN-specific phenomenon, but SCAN was the most idiomatic way I could depict it in a Redis-like, rather than SQL-like, manner :)
There's also a fourth term that comes up regularly in isolation conversations: write skew. Write skew happens when two transactions writing different keys are successfully committed without observing each others' writes. This has important ramifications, for example if some global invariant like a bank balance or other size threshold has to be honored. Awkwardly, since this wasn't recognized as a thing until Berenson et. al's 1995 paper, it fell through the cracks of the SQL-92 spec.
To compensate for this oversight, modern databases therefore use a variety of concurrency-control techniques to ensure conflict-serializability; informally speaking, that means untangling concurrent writes into an equivalent serial form. Three of most common industrially applied ones are timestamping, locking and MVCC.
Timestamp-based concurrency attempts to impose a total ordering of transactions based on some kind of timestamp: this might be a literal wall-time, some logical clock, or a combination of both. Objects [by object I mean something that's domain-specific to the database and contended, it could be a row, a key/value pair, e.t.c] track individual read and write timestamps recording the last successfully applied transaction. Transactions are assigned a global timestamp on initiation. If two transactions affecting the same object are in flight and the later one is presented first, the transaction must be restarted. Google Spanner is maybe the most well-known application of this technique in a commercial database, but it's not used in any of the Redis variants I'm going to discuss later, so I won't get into the weeds here.
Lock-based controls use shared read or exclusive write locks on data before a transaction can execute. Methods like two-phased locks (2PL) take a pessimistic approach to concurrent transaction execution; conflicts are expected unlike timestamping, which rejects an out-of-order transaction and forces it to retry; in 2PL, they just wait. 2PL comprises a whole family of variants with varying levels of strictness, but broadly speaking they all work in two phases: a growing phase, where locks on affected objects are acquired, and a shrinking phase, where they're released. Since we're talking about actual locks which block a thread here, the overhead of 2PL for rarely contended objects, or slow-running transactions may be unacceptable. In contrast, for cases of high object contention and fast Tx execution [in memory DBs, anyone?], 2PL can be more efficient than an optimistic approach where the retry overhead may come to hurt overall throughput.
Multi-Version Concurrency Control (MVCC)
Snapshotting 101
Most major databases which use MVCC to manage concurrency where a transaction reads a consistent "snapshot" world-view of the database as it existed at the exact moment the transaction started. This avoids the need for read-locks (it's still optimistic), while guaranteeing that all reads inside the transaction see the last committed values before the transaction began, irrespective of any other transaction that may be running at the same time. Snapshotting itself cleaves into two approaches:
- Snapshot Isolation (SI): each transaction reads from its snapshot, and buffers its writes privately until commit. At commit time, the database checks whether any other transaction that committed after our snapshot was taken, wrote to any of the same objects we're writing. If so, one of them loses (usually under a "first committer wins" rule) and is aborted. That sorts out dirty reads, non-repeatable reads, phantoms and lost updates, since you're always reading a frozen world and can't silently screw up someone else's write. What it doesn't catch is our old friend write skew, because the conflict check only looks at write-write overlaps. The canonical example is a pair of on-call doctors, with the invariant that at least one of them must be on call at any time:
Initial:
oncall:alice = 1
oncall:bob = 1
T1: GET oncall:alice -> 1
T1: GET oncall:bob -> 1 (bob's on call, so I can go home)
T2: GET oncall:alice -> 1
T2: GET oncall:bob -> 1 (alice is on call, so I can go home)
T1: SET oncall:alice 0
T2: SET oncall:bob 0
T1: COMMIT ✅
T2: COMMIT ✅
The write sets {oncall:alice} and {oncall:bob} don't overlap, so SI waves both through and nobody's on call. Each transaction made a perfectly sensible decision based on a snapshot that was out of date by the time it committed.
- Serializable Snapshot Isolation (SSI): keeps everything good about SI (non-blocking reads from a snapshot), but also tracks what each transaction read. The theory, from Cahill, Röhm and Fekete's 2008 paper, is that every non-serializable execution under SI contains a recognisable pattern of read-write antidependencies: one transaction read something that a concurrent transaction then overwrote. In the example above,
T1readoncall:bobwhichT2wrote, andT2readoncall:alicewhichT1wrote; a cycle, and therefore no equivalent serial order exists. SSI watches for these dangerous structures and aborts one of the participants before it can commit. PostgreSQL'sSERIALIZABLElevel has worked this way since 9.1. Many implementations use a simpler, more conservative check: at commit, validate that nothing in your read set has been overwritten by a transaction that committed after your snapshot was taken, and abort if it has. That can abort some transactions that would technically have been fine, but it's cheap to reason about and still guarantees serializability. The trade-off versus plain SI is- you've guessed it- throughput. You're now paying to track read sets, and you'll see more aborts under contention, which your application has to retry.
What does any of this have to do with Redis?
Isolation probably isn't something you'll typically lose sleep over when working with Redis (a nice side-effect of that simplicity I was talking about); serial execution isn't a concurrency control strategy as such; it just sidesteps the problem completely... by eliminating concurrency! 💥
That was all well and good when the world consisted of 10-megabyte working sets of GET and INCR operations. That's not the world we're in now. Redis datasets:
don't necessarily fit in memory any more (tiered storage)
can be distributed in various fashions across multiple nodes
can be concurrently modified, depending on vendor
In short, if you haven't already noticed, Redis is becoming less of a database and more of an idea. Yeah, there's still an eponymous vendor driving upstream product development, but just as Oracle thought they could lock up the JVM spec and turn it into yet another rent-seeking cash cow, the Redis wire protocol and core commands have become a standard which have outgrown a single vendor. Even leaving aside licensing politics, there's enough people doing weird and wonderful things with Redis after being in the wild for >15 years that the appetite for things which speak Redis, but aren't Redis, was going to come naturally anyway. And to sate that demand for weird and wonderful things comes Redis-speaking databases with different concurrency models.
How different vendors handle isolation
| Vendor | Isolation strategy |
|---|---|
| Redis | Serial execution |
| Valkey | Serial execution |
| DragonflyDB | Serial execution via VLL |
| Garnet | Hybrid 2PL; optimistic WATCH |
| Invar | SI by default; SSI via WATCH |
Redis, Valkey
Redis and the bulk of its descendants stick with full command serialization; while all of these flavors have some kind of concurrent handling of commands at the network I/O-level (i.e. the epoll accept loop will hand off socket I/O to different threads), when it comes to touching internal data, commands are all effected sequentially.
DragonflyDB
DragonflyDB adds a bit of nuance to the serialization story with its shared-nothing architecture, partitioning the keyspace across independent shard threads. It uses transactional framework based on the Very Lightweight Locking (VLL) algorithm to coordinate operations spanning multiple shards. Transactions are assigned globally ordered transaction IDs, while each shard maintains a transaction queue and lightweight intent-lock state. This allows independent transactions touching unrelated keys to execute concurrently, while conflicting transactions are ordered consistently across the shards they touch. Multi-key transactions therefore retain serializable execution without requiring the entire database to fall back to a single global execution queue.
Garnet
Microsoft's Garnet is where we step into real concurrent modification capabilities, and therefore real concurrency control.
Garnet's architecture, described in their VLDB paper, is "shared-nothing sessions over a shared-everything store". Phrased another way, that means every client session runs on whichever thread picked up its network buffer, and all of those threads operate directly on a single, shared hash index (via its storage engine, Tsavorite). There's no single execution queue and no per-core partitioning of the keyspace; threads genuinely do touch the same data at the same time. So Garnet needs locks, and it uses them at the granularity of hash buckets. Each 64-byte bucket in the index reserves a slot for a small lock word, so acquiring a lock costs no extra cache miss once you've already hashed your way to the bucket. Single-key reads take a shared bucket lock, in-place updates take an exclusive one, and structural changes to the hash chains themselves are latch-free, using compare-and-swap.
For multi-key transactions (MULTI/EXEC, Lua scripts, stored procedures and cross-key commands like RPOPLPUSH), Garnet uses what the paper calls a hybrid of pessimistic 2PL and optimistic watching:
Pessimistic locking. Because Redis transactions are queued rather than interactive, by the time
EXECarrives Garnet already knows every command in the transaction. It parses them to derive the full read and write sets up front, acquires shared/exclusive locks on the relevant buckets in sorted bucket order (so two transactions can never deadlock by grabbing locks in opposite orders), runs the commands, then releases everything. That's a conservative flavour of 2PL: the entire growing phase happens before the first command executes, and the shrinking phase happens after the last one. Conflicting transactions simply wait for each other, which, per the trade-off I mentioned earlier, is a good fit for an in-memory store where transactions are short.Optimistic
WATCH. Queued transactions can't branch on a value they read mid-transaction, so the read-then-decide pattern relies onWATCH. Garnet implements this with a watch table: a small shared array of 64-bit version counters, indexed by key hash modulo the table size. Every write to a watched record bumps the counter for its slot.WATCHrecords the counter's current value, and afterEXEChas acquired its locks, Garnet checks whether any of those counters have moved; if so, the transaction is aborted. Since slots are shared between all keys that hash to them, a write to an unrelated key can occasionally trigger an abort. That's a deliberate trade: false-positive aborts are safe, just a bit wasteful, and the table stays tiny.
The net result is that transactions in Garnet behave as if they ran serially, without forcing the whole server to run serially. The design is explicitly tuned for workloads dominated by single-key operations with rare conflicts, and there are a couple of things worth being aware of. Bucket-level locking means two unrelated keys that happen to share a bucket will contend with each other. In cluster mode, multi-key operations are restricted to keys within the same hash slot, as in Redis Cluster.
Invar
Invar departs from the lock-based approach entirely, and instead builds on MVCC. Invar is diskless: it runs on SlateDB, an LSM tree that lives in object storage, so (as I mentioned earlier) versioned data and garbage collection come more or less for free. Every command Invar executes, whether it's a lone GET or an entire MULTI block, runs inside a storage-level transaction, and by default that transaction runs under snapshot isolation.
A couple of things follow from that:
Writers are always optimistic. Concurrent connections don't wait on each other; they each work against their own snapshot. If two of them write the same key, the loser's commit fails with a write-write conflict. Since most already-written application code hasn't had to consider the possibility of transaction conflicts, Invar quietly re-runs the whole batch against a fresh snapshot up to a bounded number of attempts (16 by default). From the client's point of view, a plain
INCRorLPUSH'just works', the same as it would in Redis. This is crucial for things like BullMQ, where workers routinely fire overlapping writes at the same queue keys.MULTI/EXECcommits all-or-nothing. The queued commands are applied within a single transaction and become visible together at commit, so no other client can ever observe half aMULTIblock, and a crash or storage failure midway through can't leave one behind either. For Redis compatibility, a runtime error in one queued command (say,INCRon a non-numeric value) still doesn't roll back its siblings, exactly as in theSET A/SET Bexample earlier, but the batch as a whole lands atomically.
As we saw earlier, SI on its own doesn't save you from write skew. Run the on-call example from earlier through two plain MULTIblocks and both will commit. That's where WATCH comes in.
In Redis, WATCH is an optimistic lock, effectively saying, "abort my next EXEC if any of these keys have changed since I started watching them". In Invar, it doubles up as an opt-in for SSI. When you WATCH keys, Invar records their current values. At EXEC time, if the queued commands touch any of those watched keys, the transaction runs with SSI instead of SI, and two checks happen:
Inside the new transaction, Invar re-reads every watched key and compares it to the value recorded at
WATCHtime. A mismatch means something committed between yourWATCHand yourEXEC, so the transaction is aborted immediately.Because those re-reads happened inside an SSI transaction, the watched keys are now in its read set. If another transaction commits a write to any of them while this
EXECis in flight, our commit fails with a conflict.
Regardless whether you have a phantom read or write skew, EXEC returns a nil reply, which is exactly what a Redis client expects when a WATCH fails. Here's the on-call example again, guarded with WATCH:
plaintext
Client 1: Client 2:
WATCH oncall:alice oncall:bob WATCH oncall:alice oncall:bob
GET oncall:alice -> 1 GET oncall:alice -> 1
GET oncall:bob -> 1 GET oncall:bob -> 1
MULTI MULTI
SET oncall:alice 0 SET oncall:bob 0
EXEC -> OK EXEC -> (nil)
Whichever client commits second finds that a key in its read set was overwritten, and is told to try again. Unlike the plain-SI case, Invar deliberately does not retry this one automatically. The client made its decision based on values it read before EXEC, so only the client can safely re-read and decide again; blindly replaying the same writes would just reintroduce the anomaly that WATCH was there to prevent.
So why not run everything under SSI and be done with it? As we saw earlier, tracking read sets isn't free, and SSI aborts more readily under contention. While it provides the ultimate guarantee against write skew, if you have a lot of concurrent single-key operations, the retry overhead can become considerable. Most Redis workloads are single-key operations that SI already handles perfectly (think queues again), so Invar only hits you with the serializability penalty when you explicitly ask for it, using the same semantics that Redis clients are already accustomed to.
Try Invar
Invar is open-source and free to use commercially, with regular container + MacOS releases. Check out the GitHub repo here.
We're also close to opening our managed cloud service to beta users. If you need a durable document DB with object-storage-class pricing, join the waitlist and we'll let you know when we're ready!

