# Redis isolation and alternatives in 2026

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](https://github.com/hardpointlabs/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:

```plaintext
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:

```plaintext
Initial:
A = 0
B = 0
```

And one client which does this:

```plaintext
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:

```plaintext
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:

```plaintext
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:

```plaintext
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:

```plaintext
user:1 = active
user:2 = inactive
```

and we ran two 'interleaving' transactions (yes, hypothetical again in plain Redis)

```plaintext
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](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-51.pdf), 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](https://en.wikipedia.org/wiki/Total_order) 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](https://cloud.google.com/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](https://en.wikipedia.org/wiki/Multiversion_concurrency_control) 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:

1.  **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:
    

```plaintext
    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.

2.  **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](https://doi.org/10.1145/1376616.1376690), 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, `T1` read `oncall:bob` which `T2` wrote, and `T2` read `oncall:alice` which `T1` wrote; 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's `SERIALIZABLE` level has [worked this way](https://arxiv.org/abs/1208.4179) 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)**](https://www.cs.umd.edu/~abadi/papers/vldbj-vll.pdf) 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](https://doi.org/10.14778/3773749.3773760), 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 `EXEC` arrives 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 on `WATCH`. 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. `WATCH`records the counter's current value, and after `EXEC` has 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](https://slatedb.io/), 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 `INCR` or `LPUSH` 'just works', the same as it would in Redis. This is crucial for things like [BullMQ](https://bullmq.io/), where workers routinely fire overlapping writes at the same queue keys.
    
*   `MULTI`**/**`EXEC` **commits 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 a `MULTI` block, 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, `INCR` on a non-numeric value) still doesn't roll back its siblings, exactly as in the `SET A`/`SET B` example 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 `MULTI`blocks 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:

1.  Inside the new transaction, Invar re-reads every watched key and compares it to the value recorded at `WATCH` time. A mismatch means something committed between your `WATCH` and your `EXEC`, so the transaction is aborted immediately.
    
2.  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 `EXEC` is 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

```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](https://github.com/hardpointlabs/invar).

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](https://accounts.hardpoint.dev/waitlist) and we'll let you know when we're ready!
