Mert Özen Further With Every Line
Database Redis — #08

Atomicity in Redis: MULTI/EXEC, WATCH, Lua Scripts and Redis Functions

Mert Özen Nov 14, 2026 15 min 11 views
Atomicity in Redis: MULTI/EXEC, WATCH, Lua Scripts and Redis Functions

In Redis, MULTI/EXEC runs commands in order with nobody in between, but it does not roll back; for multi-step work that decides based on a read, the right tool is usually a Lua script. With real output: a stock of 100 copies sold 200 times, 14,620 retries with WATCH, StackExchange.Redis conditions, stock reservation with LuaScript.Prepare, the script cache, Redis Functions surviving a restart, Redis 8.8's INCREX and a long script that locks the server.

Version note: The examples in this post were run with Redis 8.10.3 (redis:8), Valkey 9.1.3, .NET 10 and StackExchange.Redis 3.4.35.

Guess First

At Bookstore, the online bookstore used as the shared example across this site's series, the last 100 copies of Dune are in the warehouse, and after a campaign announcement 200 customers press "buy" at the same moment. The stock is a counter in Redis: bookstore:stock:{9780441013593}. The first code that comes to mind reads first and decrements afterwards:

long stock = (long)await db.StringGetAsync(StockKey);              // read ...
if (stock < 1)
{
    return false;
}
await db.StringDecrementAsync(StockKey);                            // ... then write
return true;

How many of the 200 concurrent requests succeed, and how many copies are left in stock? How do the result and the duration change when you do the same with a WATCH-based transaction and with a single Lua script?

dotnet run --project src/Bookstore.RedisLab -- stock-race
check-then-act  sold 200  stock left -100  retries    0     5 ms
transaction     sold 100  stock left    0  retries 14620  5245 ms
lua             sold 100  stock left    0  retries    0     6 ms

Read-then-write sold 200 copies out of a stock of 100, and the stock fell to -100. Each DECR is atomic on its own, but in the gap between the read and the write the other 199 requests saw the same "in stock" answer; the same loss as GET + SET in post 03. The transaction gave the right result but retried 14,620 times and took 5.2 seconds. The Lua script is both correct and 6 ms. This post covers the ways to run several steps in Redis as if they were one: MULTI/EXEC and why it does not roll back, optimistic concurrency with WATCH and its StackExchange.Redis counterpart, Lua scripts, Redis Functions, Redis 8.8's INCREX, and the cost of long-running scripts.

MULTI/EXEC: Queue, Run in Order, No Rollback

Commands after MULTI do not run right away; they are queued for that connection (QUEUED), and EXEC runs them back to back without any other client's command in between. Let's try it with two kinds of errors:

SET bookstore:stock:{9780441013593} 10
MULTI
DECRBY bookstore:stock:{9780441013593} 2
LPUSH bookstore:stock:{9780441013593} oops
INCR bookstore:order:id-seq
EXEC
GET bookstore:stock:{9780441013593}
MULTI
DECRBY bookstore:stock:{9780441013593} 2
DECRBY bookstore:stock:{9780441013593}
EXEC
GET bookstore:stock:{9780441013593}
EVAL "redis.call('DECRBY', KEYS[1], 2) redis.call('LPUSH', KEYS[1], 'oops') return 'unreachable'" 1 bookstore:stock:{9780441013593}
GET bookstore:stock:{9780441013593}
docker compose -f lab/compose.yaml exec -T redis redis-cli < lab/scripts/08-multi.txt
OK
OK
QUEUED
QUEUED
QUEUED
1) (integer) 8
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) (integer) 1
"8"
OK
QUEUED
(error) ERR wrong number of arguments for 'decrby' command
(error) EXECABORT Transaction discarded because of previous errors.
"8"
(error) WRONGTYPE Operation against a key holding the wrong kind of value script: 03dca892964cb6b3fa64bf026533af8e3403e715, on @user_script:1.
"6"

In the first transaction the second command failed at run time (LPUSH on a string key), but the first and third commands were applied: the stock became 8 and the order number went up. Redis does not roll back; the guarantee of EXEC is not "all or nothing" but "in order, with nobody in between". In the second transaction the error was caught while the command was being queued (a missing argument), and EXEC rejected the whole transaction with EXECABORT. So syntax errors cancel everything, run-time errors do not. Do not confuse this with transactions in SQL Server: there an error rolls the whole thing back, here it does not. You need to be sure the commands in a transaction will not fail at run time (right type, right key).

The last two lines are the same situation in Lua. The Redis docs say scripts run atomically and that a script's effects have either not happened yet or have all happened; in the measurement, the first command of the failing script (DECRBY) stayed and the stock went from 8 to 6. "Atomic" in the docs means that no other command runs while the script runs; there is no undo on error. Do your checks at the start of the script, before you write.

WATCH and Conditions in StackExchange.Redis

Inside MULTI/EXEC you cannot read a value and decide based on it, because nothing runs before EXEC. Redis's answer is WATCH: you watch a key, read its value, make your decision and send MULTI/EXEC; if someone changed the key after WATCH, EXEC does nothing and returns nil, and you try again from the start. This is optimistic concurrency: no lock, a retry when there is a conflict.

In StackExchange.Redis you cannot send WATCH directly: the whole application shares the same connection (post 02), so one caller's WATCH would get mixed with another caller's commands. Instead the library offers conditions: for the condition you give to AddCondition, it sends WATCH, the check and MULTI/EXEC itself, in a way that no other commands get in between. The transaction branch from Guess First:

while (true)
{
    RedisValue current = await db.StringGetAsync(StockKey);
    if ((long)current < 1)
    {
        return false;
    }
    ITransaction tx = db.CreateTransaction();
    tx.AddCondition(Condition.StringEqual(StockKey, current));     // WATCH + check
    Task<long> decremented = tx.StringDecrementAsync(StockKey);     // queued, not sent
    if (await tx.ExecuteAsync())                                    // MULTI ... EXEC
    {
        await decremented;
        return true;
    }
    Interlocked.Increment(ref retries);                             // someone else changed it
}

The result is right, but because 200 requests raced on the same key, every successful sale came with 146 failed attempts on average. Optimistic concurrency works well where conflicts are rare: a customer's own cart, a setting that rarely changes. Where everyone writes to the same key (the last copies, a popular counter), retries keep both Redis and the application busy; there a single command or a script is the better tool.

There is one more detail in the code: we do not await the task returned by tx.StringDecrementAsync before ExecuteAsync. Commands added to a transaction are not sent until ExecuteAsync is called; await the task first and it never completes, and the code silently hangs. The library's docs describe this as the most common mistake, and the analyzer in 3.x reports it as a build error with the code SER305. If the condition does not hold, ExecuteAsync returns false and the queued tasks are cancelled.

Bookstore: the Weekly Snapshot in One Step

In post 06, when building the weekly bestseller snapshot, we gave every build its own randomly named temporary keys, so that two requests arriving at the same time would not delete each other's keys. With MULTI/EXEC that is no longer needed: all the steps run with nobody in between, so no other client ever sees the temporary keys. We also added "does the snapshot still not exist?" as a condition; if another request has just built it, the transaction does not run at all:

// score = copies sold * 10 + days with sales (0-7): equal sales are ordered by how steadily the book sold
// MULTI/EXEC: the whole build runs as one unit, no other client sees the temporary keys
private static async Task BuildWeekAsync(IDatabase db, DateOnly today, RedisKey week)
{
    RedisKey[] days = [.. Enumerable.Range(0, 7).Select(i => RedisKeys.Bestsellers(today.AddDays(-i)))];
    RedisKey sold = RedisKeys.BestsellersWeekPart(today, "sold");
    RedisKey daysWithSales = RedisKeys.BestsellersWeekPart(today, "days");

    ITransaction tx = db.CreateTransaction();
    tx.AddCondition(Condition.KeyNotExists(week));                                                       // someone else built it already?
    Task[] queued =
    [
        tx.SortedSetCombineAndStoreAsync(SetOperation.Union, sold, days),                                 // ZUNIONSTORE (SUM)
        tx.SortedSetCombineAndStoreAsync(SetOperation.Union, daysWithSales, days, null, Aggregate.Count), // AGGREGATE COUNT (8.8+)
        tx.SortedSetCombineAndStoreAsync(SetOperation.Union, week, [sold, daysWithSales], [10, 1]),       // WEIGHTS 10 1
        tx.KeyDeleteAsync([sold, daysWithSales]),
        tx.KeyExpireAsync(week, SnapshotLifetime),
    ];
    if (await tx.ExecuteAsync())
    {
        await Task.WhenAll(queued);
    }
}

I tried it with 8 concurrent requests, as in post 06; the list came out right and only bookstore:bestsellers:week:20261114 was left. The transaction has a cost here: the server does nothing else while the seven days are being combined. The daily sets hold thousands of books, so that is fine; for sets with millions of members this step belongs in a background job.

Lua Scripts: EVAL, EVALSHA and the Script Cache

A Lua script runs the whole "read, decide, write" flow on the server in one step; nobody can get in between and no retry is needed. Stock reservation in Bookstore.Api now works this way. The script never reserves the same order twice (idempotent) and writes nothing if the stock is not enough:

// Check the stock and reserve in one atomic step; the same order is never reserved twice
private static readonly LuaScript Reserve = LuaScript.Prepare("""
    if redis.call('HEXISTS', @reservationsKey, @orderId) == 1 then
        return -2
    end
    local stock = tonumber(redis.call('GET', @stockKey) or '0')
    if stock < tonumber(@quantity) then
        return -1
    end
    redis.call('HSET', @reservationsKey, @orderId, @quantity)
    return redis.call('DECRBY', @stockKey, @quantity)
    """);

app.MapPost("/inventory/{isbn}/reservations", async (string isbn, ReservationRequest request, IConnectionMultiplexer redis) =>
{
    RedisResult result = await Reserve.EvaluateAsync(redis.GetDatabase(), new
    {
        stockKey = RedisKeys.Stock(isbn),
        reservationsKey = RedisKeys.StockReservations(isbn),
        orderId = request.OrderId,
        quantity = request.Quantity
    });
    long left = (long)result;
    return left switch
    {
        -1 => Results.Conflict(new { error = "not enough stock" }),
        -2 => Results.Ok(new { request.OrderId, alreadyReserved = true }),
        _ => Results.Created($"/inventory/{isbn}/reservations/{request.OrderId}", new { request.OrderId, stockLeft = left })
    };
});
curl -X PUT http://localhost:5080/inventory/9780441013593/stock -H "Content-Type: application/json" -d '{"quantity":3}'
{"isbn":"9780441013593","stock":3}
curl -s -w " %{http_code}" -X POST http://localhost:5080/inventory/9780441013593/reservations -H "Content-Type: application/json" -d '{"orderId":"order-1001","quantity":2}'
{"orderId":"order-1001","stockLeft":1} 201
curl -s -w " %{http_code}" -X POST http://localhost:5080/inventory/9780441013593/reservations -H "Content-Type: application/json" -d '{"orderId":"order-1001","quantity":2}'
{"orderId":"order-1001","alreadyReserved":true} 200
curl -s -w " %{http_code}" -X POST http://localhost:5080/inventory/9780441013593/reservations -H "Content-Type: application/json" -d '{"orderId":"order-1002","quantity":2}'
{"error":"not enough stock"} 409
curl -s -w " %{http_code}" -X POST http://localhost:5080/inventory/9780441013593/reservations -H "Content-Type: application/json" -d '{"orderId":"order-1003","quantity":1}'
{"orderId":"order-1003","stockLeft":0} 201

The curly braces in the key names are deliberate: {9780441013593} is a hash tag. In Redis Cluster every key a script touches has to be in the same slot; a hash tag makes only the part inside the braces count for the slot, so a book's stock and reservation keys always land in the same slot. The details are in post 13.

LuaScript.Prepare fills the @name parameters from the properties of the anonymous object. I watched what it sends with MONITOR: every @ parameter in the script had been turned into ARGV[n], and the parameters of type RedisKey were also sent in the KEYS list (EVALSHA … 1 bookstore:stock:{9780441013593} bookstore:stock:{9780441013593} 2). According to the docs, every key name a script accesses should be declared in KEYS; Cluster routing and ACL checks rely on it. If you use ScriptEvaluateAsync by hand, pass keys through KEYS and other values through ARGV; do not build key names inside the script.

Every EVAL sends the whole script over the network. Redis keeps each script in a script cache under its SHA1 digest; EVALSHA sends only the digest. The cache is not persistent: a restart, a failover and SCRIPT FLUSH empty it.

dotnet run --project src/Bookstore.RedisLab -- script-cache
sha1: c781f53432865d71fd17cc47443e4bd1ee4cb0fb
EVALSHA: 3
cached on server: True
after SCRIPT FLUSH, cached: False
LoadedLuaScript: 1
LuaScript:       -1

Even after the cache was emptied, the LoadedLuaScript call worked: in MONITOR I saw EVALSHA get a NOSCRIPT error first, then the library send the script again with SCRIPT LOAD and EVAL. The -1 on the last line is not an error; the stock had dropped to 1, so a reservation of 2 copies was rejected. So with StackExchange.Redis you do not have to manage the script cache by hand; but do not build the script by string concatenation on every request. Every different text is a separate SHA1 and a separate cache entry; as the docs warn, that grows server memory without limit.

Redis Functions: Separating Scripts from Deployments

A Lua script is part of the application: it ships with the code and stays in the cache temporarily. Functions, which came with Redis 7.0, are part of the server: loaded once as a library, called by name, saved with RDB and AOF, and sent to replicas. Bookstore's reservation logic as Functions:

#!lua name=bookstore

local function reserve_stock(keys, args)
    local order_id = args[1]
    local quantity = tonumber(args[2])
    if redis.call('HEXISTS', keys[2], order_id) == 1 then
        return -2
    end
    local stock = tonumber(redis.call('GET', keys[1]) or '0')
    if stock < quantity then
        return -1
    end
    redis.call('HSET', keys[2], order_id, quantity)
    return redis.call('DECRBY', keys[1], quantity)
end

redis.register_function('reserve_stock', reserve_stock)
$ docker compose -f lab/compose.yaml exec -T redis redis-cli -x FUNCTION LOAD REPLACE < lab/functions/bookstore.lua
bookstore
$ dotnet run --project src/Bookstore.RedisLab -- fcall
FCALL reserve_stock order-1001 x2 -> 1
FCALL reserve_stock order-1001 x2 -> -2
FCALL reserve_stock order-1002 x2 -> -1
FCALL reserve_stock order-1003 x1 -> 0
stock 0, reservations: order-1001=2, order-1003=1

StackExchange.Redis has no typed method for FCALL; I send it with ExecuteAsync("FCALL", "reserve_stock", 2, stockKey, reservationsKey, orderId, quantity). To see the difference, I loaded a script into the cache and restarted the Redis container:

127.0.0.1:6379> SCRIPT LOAD "return redis.call('GET', KEYS[1])"
"d3c21d0c2b9ca22f82737626a27bcaf5d288f99f"
$ docker compose -f lab/compose.yaml restart redis
 Container bookstore-redis-lab-redis-1 Restarting
 Container bookstore-redis-lab-redis-1 Started
127.0.0.1:6379> SCRIPT EXISTS d3c21d0c2b9ca22f82737626a27bcaf5d288f99f
1) (integer) 0
127.0.0.1:6379> FUNCTION LIST
1) 1) "library_name"
   2) "bookstore"
   3) "engine"
   4) "LUA"
127.0.0.1:6379> SET bookstore:stock:{9780141439518} 5
OK
127.0.0.1:6379> FCALL reserve_stock 2 bookstore:stock:{9780141439518} bookstore:stock:{9780141439518}:reservations order-1004 2
(integer) 3

The script cache was emptied by the restart, while the library is still there (it was written to the RDB file when the container stopped and loaded back on start). This has a price too: Functions are now separate from the application deployment. "Who loads the library, when, and with which version?" needs an answer; the usual solution is a FUNCTION LOAD REPLACE step in the deployment pipeline and a version in the library or function name (reserve_stock_v2). If old and new application versions will run side by side for a while, keep both functions loaded. On Valkey 9.1.3 the same library loaded without problems with FUNCTION LOAD; both Functions and Lua exist on both sides.

INCREX: a Bounded Counter and Expiry in One Command

Some scripts do a very common job: increment a counter, keep it under a limit, set an expiry on the first increment. Redis 8.8 made that a single step with the INCREX command. At Bookstore a customer may request at most 3 verification codes in 10 minutes:

127.0.0.1:6379> INCREX bookstore:verify:7:requests BYINT 1 UBOUND 3 EX 600 ENX
1) (integer) 1
2) (integer) 1
127.0.0.1:6379> INCREX bookstore:verify:7:requests BYINT 1 UBOUND 3 EX 600 ENX
1) (integer) 2
2) (integer) 1
127.0.0.1:6379> INCREX bookstore:verify:7:requests BYINT 1 UBOUND 3 EX 600 ENX
1) (integer) 3
2) (integer) 1
127.0.0.1:6379> INCREX bookstore:verify:7:requests BYINT 1 UBOUND 3 EX 600 ENX
1) (integer) 3
2) (integer) 0
127.0.0.1:6379> TTL bookstore:verify:7:requests
(integer) 600

The reply is two numbers: the counter's new value and the increment actually applied. On the fourth request the limit (UBOUND 3) would have been exceeded, so the increment was not applied and the second number came back as 0; with SATURATE the counter would have been clamped at the bound. ENX sets the expiry only if the key does not have a TTL yet; the "resetting the window on every increment" mistake from post 03 applies here too. In StackExchange.Redis 3.4.35 the overload of StringIncrementAsync that takes bounds and an Expiration sends this command and returns a StringIncrementResult<long>:

IDatabase db = redis.GetDatabase();
// INCREX key BYINT 1 UBOUND 3 EX 600 ENX: at most 3 codes per 10 minutes, one atomic step (Redis 8.8+)
StringIncrementResult<long> requests = await db.StringIncrementAsync(RedisKeys.VerificationRequests(customerId), 1,
    new Expiration(RequestWindow, ExpirationFlags.ExpireIfNotExists), lowerBound: null, upperBound: MaxCodesPerWindow);
if (requests.AppliedIncrement == 0)
{
    return Results.Json(new { error = "too many verification codes requested, try again later" }, statusCode: 429);
}
curl -s -w " %{http_code}" -X POST http://localhost:5080/customers/7/verification-code
{"expiresInSeconds":300} 202
# two more requests: 202, 202
curl -s -w " %{http_code}" -X POST http://localhost:5080/customers/7/verification-code
{"error":"too many verification codes requested, try again later"} 429

This is a simple fixed window counter. The choice of algorithm for a real rate limiter and its ASP.NET Core counterpart are in "Rate Limiting in Depth" in the ASP.NET Core Security series. INCREX does not exist on Valkey 9.1.3 (ERR unknown command); there you do the same with a small Lua script.

Long Scripts, Cluster and Security

Because a script runs atomically, the server answers no other request while it runs. Imagine a report job accidentally running an 8-second loop:

dotnet run --project src/Bookstore.RedisLab -- busy-script
 5.0s PING failed: RedisServerException: BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
 5.5s SCRIPT KILL: OK
 5.5s script: ERR Script killed by user with SCRIPT KILL... script: 8d2e089ab779c9a37464addd8796b1df304bee16, on @user_script:1.
 5.5s PING after kill: 0.7 ms

The PING the API sent at 0.5 seconds waited without an answer. Once the script passed the busy-reply-threshold (5000 ms by default), Redis started answering waiting and new commands with a BUSY error; at that point practically the only command it accepts is SCRIPT KILL. One important limit: SCRIPT KILL can only stop a script that has not written anything yet. Stopping a script that has started writing would break atomicity, so the only option is SHUTDOWN NOSAVE, shutting the server down without saving. Keep scripts short: bound your loops and do not walk large keys from start to end inside a script.

In a cluster, all keys of a script or a transaction have to be in the same hash slot; otherwise Redis returns a CROSSSLOT error. That is why the stock keys have the {isbn} hash tag; slots, MOVED and hash tag design are the topic of post 13. On the replication side, since Redis 7.0 it is not the script itself but the write commands it produced that go to replicas and the AOF (effects replication); according to the docs, this is why the old restrictions on using TIME or random numbers in a script are gone. Still, a script that gives different results for the same input is hard to test and debug; pass time and random values from the application as arguments where you can.

The Lua engine is one of the parts of Redis with the most security vulnerabilities. CVE-2025-49844, announced in October 2025, was a use-after-free bug in Lua that could let an authenticated user run code remotely with a specially crafted script. Redis's security advisory on its blog gives it a CVSS score of 10.0, while the official GitHub advisory for the same issue (GHSA-4789-qfc9-5f9q) scores it 9.9 under CVSS 3.1; both call it critical. The fix shipped in 8.2.2 and was backported to 8.0.4, 7.4.6, 7.2.11 and 6.2.20. The 8.8 release notes also list a fix for another Lua use-after-free (CVE-2026-23631). This class of vulnerability requires the attacker to be able to run scripts; protection has two parts, an up-to-date patch release and ACLs that give application users only the commands they need. Both are the topic of post 14.

In Production

To pick the right tool, ask in this order: does a single command do the job (SET … NX, SET … IFEQ from post 03, INCREX, ZADD … GT)? If so, use it; the StackExchange.Redis 3.x analyzer also points out unnecessary transactions in that direction. For a multi-step job that decides based on a read, under high contention: Lua or Functions. Under low contention, and if you want the logic to stay in C#: a transaction with conditions. To run several commands together whose decision does not depend on a read: MULTI/EXEC without conditions. Watch script durations with SLOWLOG; a script that takes a few milliseconds per call adds that much latency for every client. If you use Functions, make loading the library a deployment step and add FUNCTION LIST to your health check; if the application calls a new function that is not on the server, every request fails.

Common Pitfalls

Decrementing stock with read-then-write: a stock of 100 copies was sold 200 times.

Taking MULTI/EXEC for a transaction that rolls back: after a run-time error the other commands were still applied; the same holds for Lua.

A WATCH retry loop on a heavily contended key: 14,620 attempts and 5.2 seconds for 100 sales.

Awaiting the task of a command added to a transaction before ExecuteAsync: the code silently hangs.

Building key names inside a script or passing them through ARGV: the wrong slot in a cluster, a skipped check in ACLs.

A long-running script: after 5 seconds every client got BUSY; SCRIPT KILL cannot stop a script that has started writing.

Building the script text by string concatenation that changes on every request: each one becomes a separate script cache entry.

A Small Experiment

★☆☆ Run stock-race with 20 buyers instead of 200. How do the number of retries and the duration of the transaction branch change?

★★☆ Add a time limit to the reservation script: if a reservation does not turn into an order within 15 minutes, the stock comes back. Keep reservations in a sorted set (post 06) whose score is the expiry time, and write a second script that returns expired reservations to stock. What happens if the two scripts touch the same reservation at the same time?

★★★ Add a stock_level function with the no-writes flag to the bookstore library and call it with FCALL_RO. Can a client connected to a replica call this function? Can it call reserve_stock?

In the next post we move on to messaging: pub/sub and at-most-once delivery, sharded pub/sub in a cluster, keyspace notifications, and a cache invalidation signal sent to every API instance when a book's price changes. We will also build the "there is work in the queue" signal mentioned in post 05.