Pipelining, Transactions and Lua: Saving RTTs vs Atomicity

· tech

#redis#distributed-systems

📑 Contents

Three things are often lumped together that actually solve completely different problems: pipelining solves “too many network round trips”; MULTI/EXEC (transactions) solves “a group of commands must run together without interleaving”; Lua solves “atomic, and with logic”. Confuse them and you’ll use a pipeline as a transaction, or assume a Redis transaction can roll back like a database — both are pits. This post settles the division of labour once and for all.

Pipelining: squeeze N round trips into 1

First, the most underrated bottleneck: the network round trip (RTT). Fire 100 commands one at a time — send, wait for the reply, send the next — and that’s 100 RTTs. The commands themselves run in microseconds inside Redis; the time all goes on the network. A pipeline sends them packed together and collects the replies together:

One by one N × RTT vs pipeline 1 × RTT ① one by one: each waits a round trip clientserver 3 cmds = 3 round trips = 3 × RTT ② pipeline: packed into one round trip clientserver cmd1 ; cmd2 ; cmd3 sent together three replies back together 3 cmds = 1 round trip = 1 × RTT ✓
One by one, every command waits for a network round trip, so N commands are N × RTT — the bottleneck isn't Redis at all, it's the network. A pipeline sends several commands packed together and collects the replies together, squeezing N commands into 1 round trip. But do remember: a pipeline only saves network; it doesn't guarantee atomicity — other clients' commands can still slip in between the commands in the batch. It solves "too many round trips", not "must not be interrupted"

redis-cli --pipe pours large volumes of commands in, and in code you use the client’s pipeline API. It has nothing whatsoever to do with “atomic” — the most common misconception. For “no interleaving”, look at the next two.

MULTI/EXEC: run together, but don’t call it ACID

MULTI opens a transaction; the commands after it queue up without executing, until EXEC runs them all in one go, with no other client’s commands interleaved. That sounds like a database transaction, but one key difference startles people: a Redis transaction cannot roll back.

MULTI/EXEC: run together, no interleaving, but no rollback MULTI cmd1 · cmd2 · cmd3 (queued, not run yet) EXEC run as a batch · no interleaving ✓ ✗ no rollback: a runtime error (e.g. LPUSH on a String) fails that one; the rest still run WATCH: optimistic lock (CAS) WATCH balance:1watch it before EXEC changed by someone before EXEC?(did anyone get there first) changed → EXEC returns nil, void→ you retry MULTI gives "run together, no interleaving"; WATCH gives "void if someone got there first" — neither is rollback
What a Redis transaction guarantees is "run together, no interleaving" (isolation), not "all succeed or all fail" — if one command errors during execution, the commands before and after it still run, with no rollback. (The one exception: a command with a syntax error gets the whole batch rejected before `EXEC`.) To handle the "read then modify" race, pair it with WATCH: watch a key, and if anyone else changes it before EXEC, the whole transaction is voided and returns nil, for you to retry — an optimistic lock (CAS), not locking others out but "if someone got there first, I start over"

In commands, a “safe debit” with an optimistic lock looks like this:

WATCH balance:1          # watch the balance
# ...GET balance:1, work out in code whether there's enough to debit...
MULTI
DECRBY balance:1 100
EXEC                     # if balance:1 was changed by someone else meanwhile → returns nil, whole batch void, retry yourself

Lua: the one that’s truly “atomic + with logic”

MULTI has an inherent limit: the commands are queued in advance, so you can’t “read a value first and then decide whether to write based on the result” — at queueing time there’s no result yet. WATCH + retry can work around it, but it’s clumsy. A Lua script is the modern answer: EVAL sends the whole script to the server, and thanks to Redis’s single-threaded nature, the whole script runs atomically without interleaving, and inside it you can read a value, branch on it, then write:

-- read, then decide, then write, the whole thing atomic (conditional logic MULTI can't do)
local b = tonumber(redis.call('GET', KEYS[1]))
if b >= 100 then
  return redis.call('DECRBY', KEYS[1], 100)   -- debit only if there's enough
else
  return -1                                    -- otherwise report back
end

One Lua script gives you three things at once: saved RTTs (the logic runs server-side, no round trips), atomicity (the single thread guarantees the whole thing runs uninterrupted), logic (if/else allowed). That’s why a distributed lock must release with Lua wrapping “compare the token, delete only if it matches” into one script — exactly the “read, then decide whether to write based on the result” atomic operation that MULTI can’t give you.

Reflections

”Saving RTTs” and “atomicity” are two problems; first work out which one you have

The most common misuse I’ve seen is using a pipeline as a transaction — assuming that because the commands are sent packed together they’ll run “together, uninterrupted”. They won’t. A pipeline solves the network (too many round trips); transactions/Lua solve concurrency (not wanting to be interleaved); they’re two different dimensions. Once that clicked, my first question when picking a tool is always: what’s hurting right now, the network or concurrency? Network pain (hundreds of commands to fire) → pipeline; concurrency pain (these few steps must not have anyone slip in between) → MULTI or Lua. Classify the problem correctly and the tool picks itself — far more useful than memorising APIs.

”Redis transactions can’t roll back” isn’t a defect; it’s honesty

When I first learned Redis transactions don’t roll back I was a bit stunned — is that still a transaction? Later I understood it’s a deliberate trade: Redis takes the view that a command erroring at execution time is almost always “your program is wrong” (a List command on a String), the kind of error a rollback can’t rescue the logic from anyway, so better to keep the engine simple and fast than carry the burden of rollback. It doesn’t pretend to be a relational database. That taught me a habit for reading features: don’t be dazzled by the name; look at what it “actually guarantees”. Words like “transaction”, “Secret”, “lock” come with a halo, but the real guarantee under the halo is often narrower than the name. For the full ACID checklist, go back to database transactions; use a Redis “transaction” as “a batch of commands that won’t be interleaved”, and that’s just right.

Lua’s philosophy: move the logic next to the data

What I admire most about Lua is that it embodies an old and useful wisdom — rather than moving the data to the logic (read it back to the client, decide, write it back, three trips and a race), move the logic to the data and run it atomically in one go. It’s the same thread as the distributed lock verifying its owner in Lua, as database stored procedures, even as Spark’s data locality (push the computation to the node where the data lives): moving is expensive; processing in place is cheap. Redis’s single thread makes the move especially clean — the whole script you send is atomic by nature, with no more worrying about concurrency. Now, whenever I meet a “read-modify-write” scenario with a race to fear, my first thought isn’t to add a lock; it’s: can this whole thing become one atomic operation, sent to run next to the data?