Skip to content

Transactions, Lua & Tooling: Overview

Verified on Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The script’s output is from my run.

Redis goes beyond simple key-value operations. As an application grows you need to run several commands without interference, move logic next to the data, and diagnose the server when it slows down.

LessonTopic
TransactionsMULTI/EXEC/DISCARD, EXECABORT, no rollback, optimistic locking with WATCH
PipelinesBatching round trips (5000 SETs in 17 ms vs 1688 ms), redis-cli --pipe, result tuples
Lua ScriptingEVAL, redis.call/pcall, the server-side script cache, SCRIPT KILL, Functions
Tooling & MonitoringINFO, SLOWLOG, --bigkeys/--keystats, MEMORY, LATENCY, MONITOR cost

Command execution is single-threaded; the process is not. Redis runs one command at a time, so a single command never interleaves with another. Your application, though, usually needs several commands, and between two of them another client can run. There are three tools, and each solves a different problem:

  • Transactions (MULTI/EXEC) queue a batch and run it as one uninterrupted block. No branching on intermediate results and no rollback.
  • Pipelines send many commands in one network round trip. Speed, not atomicity, and per-command errors come back inside the reply array.
  • Lua scripts run entirely on the server, atomically, with branching and the full command set. They block the server while they run.

The script below sells 5 items to 20 concurrent buyers twice. The first version checks the stock, then decrements in a pipeline (a common belief is that a pipeline “makes it atomic”). The second does the check and the decrement inside one Lua script.

src/tsl-overview-oversell.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const BUYERS = 20;
const STOCK = 5;
// "check, then decrement" as two steps. Wrapping the second step in a pipeline does not help:
// the check already happened, and other buyers ran their checks before any decrement arrived.
async function buyPipelined() {
const stock = Number(await redis.get('b3:tsl:stock'));
if (stock > 0) await redis.pipeline().decr('b3:tsl:stock').exec();
}
// the same decision inside one Lua script: read, compare and write cannot be interleaved
redis.defineCommand('buyLua', {
numberOfKeys: 1,
lua: `local s = tonumber(redis.call('GET', KEYS[1]))
if s and s > 0 then redis.call('DECR', KEYS[1]) return 1 end
return 0`,
});
declare module 'ioredis' {
interface RedisCommander<Context> {
buyLua(key: string): Promise<number>;
}
}
await redis.set('b3:tsl:stock', String(STOCK));
await Promise.all(Array.from({ length: BUYERS }, buyPipelined));
console.log(`GET then pipelined DECR : ${BUYERS} buyers, stock ${STOCK} -> stock left ${await redis.get('b3:tsl:stock')}`);
await redis.set('b3:tsl:stock', String(STOCK));
const sold = (await Promise.all(Array.from({ length: BUYERS }, () => redis.buyLua('b3:tsl:stock')))).reduce((a, b) => a + b, 0);
console.log(`Lua check-and-decrement : ${BUYERS} buyers, stock ${STOCK} -> sold ${sold}, stock left ${await redis.get('b3:tsl:stock')}`);
await redis.del('b3:tsl:stock');
await redis.quit();

Real output (this Mac):

GET then pipelined DECR : 20 buyers, stock 5 -> stock left -15
Lua check-and-decrement : 20 buyers, stock 5 -> sold 5, stock left 0

The pipelined version sold 20 items it did not have (stock left -15): all 20 buyers passed the stock > 0 check before any decrement arrived. The Lua script sold exactly 5. MULTI/WATCH would also give exactly 5, at the cost of retries (Transactions).

A correct application can still suffer slow queries, oversized keys or connection leaks. Redis ships diagnostics that are safe to leave on (INFO, SLOWLOG, --keystats) and probes that are not (KEYS, MONITOR). The tooling lesson shows real output and measures the cost of the risky ones.

PING
INFO server

An assistant is asked to make “check stock then decrement” safe and reaches for a pipeline:

🐛 Spot the bug

async function buy() {
const stock = Number(await redis.get('stock'));
if (stock > 0) {
await redis.pipeline().decr('stock').exec(); // "the pipeline makes the decrement atomic"
}
}
Show the answer

A pipeline only batches the network I/O of the commands inside it. The GET and the check happen before it, so 20 concurrent buyers all saw stock = 5, all passed stock > 0, and all decremented: stock ended at -15. Put the read, the comparison and the write in one atomic unit, here a Lua script (or WATCH + MULTI with a retry loop, or a plain DECR followed by a check-and-undo):

redis.defineCommand('buy', {
numberOfKeys: 1,
lua: `local s = tonumber(redis.call('GET', KEYS[1]))
if s and s > 0 then redis.call('DECR', KEYS[1]) return 1 end
return 0`,
});

Think of three ways to hand work to a single fast clerk. A pipeline is a stack of slips handed over in one trip: quick, but the clerk may serve someone else between your slips. A transaction is a sealed envelope: its slips are processed together, but the clerk cannot read the answer of one slip to decide the next. A script is a written procedure the clerk follows with the counter closed: it can branch, and it is atomic, but everyone waits while the clerk follows it.

ChoiceBenefitCost
PipelineFewest round trips, highest throughputNo atomicity; errors hide in the reply array
MULTI/EXECBack-to-back executionNo branching, no rollback
WATCH + MULTICheck-and-set without a lockA retry loop and a connection per attempt
Lua script / FunctionAtomic branching logicBlocks the server while running; must stay short
Built-in diagnosticsNo extra softwareThe risky probes (KEYS, MONITOR) have a measurable cost
  • “A pipeline is a transaction.” It only batches round trips; the oversell run above ended at -15.
  • “Redis is single-threaded, so my multi-step logic is atomic.” Only each command (or transaction, or script) is.
  • “Lua scripts are free.” They block every other client while they run.
  • “Anything in redis-cli is safe on production.” KEYS and MONITOR are not (measured in the tooling lesson).
Which Redis feature provides atomic execution for a group of commands?
What is the primary goal of pipelining?
A Lua script run via EVAL is:
20 buyers each run GET stock, check > 0, then a pipelined DECR. Stock was 5. Where does it end?
Which tool would you pick for 'decrement only if the value is above zero', atomically and without a retry loop?