- Регистрация
- 1 Мар 2015
- Сообщения
- 1,481
- Баллы
- 155
If you've ever built rate limiting or login throttling with Redis, chances are you've used INCR and EXPIRE. Seems easy, right?
But if you're doing them in two separate steps, you might be in for a surprise—especially under high load.
Let’s talk about the subtle (and annoying) race condition this causes, why plain Redis transactions don’t fix it, and how Lua scripting totally saves the day.
The Situation: Tracking Failures Over Time
Let’s say you’re trying to block brute-force login attempts. So every time a login fails, you increment a counter:
const count = unwrap(await redis.fire("incr", failureKey)) as number;
if (count === 1) {
await redis.fire("expire", failureKey, WINDOW_SEC);
}
What’s going on here:
It works… mostly.
The Sneaky Race Condition ?
This setup breaks under concurrency. Here’s how:
Oops. Now the window starts after 2 failures, not 1. You’ve just let in an extra try, and maybe more depending on timing.
That’s a classic race condition—and these are exactly the kind of bugs you don’t want to debug in production at 2AM.
Can’t We Just Use Transactions? ?
That’s a good thought—and Redis does support transactions via MULTI and EXEC. But here’s the catch:
await redis.multi()
.incr(failureKey)
.expire(failureKey, WINDOW_SEC)
.exec();
This groups the commands together, but not conditionally. Redis will always run both commands—INCR and EXPIRE—no matter what. So you can’t say “expire only if the count is 1.”
And while Redis MULTI ensures the commands run one after another, they still aren’t atomic in the sense that no logic runs between them. Another client can sneak in between your INCR and EXPIRE, and mess things up.
So yeah—transactions alone don’t solve this. You need logic + atomic execution.
Lua Scripting Saves the Day
Redis has a built-in feature for this: Lua scripts.
They let you run multiple commands on the Redis server as a single, atomic operation. Here’s the script:
local count = redis.call("INCR", KEYS[1])
if count == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return count
What’s happening:
All of this happens as one atomic command. No other Redis client can sneak in during this execution. Problem solved.
Why Lua Rocks for This
Basically, it's safer and faster.
How to Use It in Node.js
If you're using something like ioredis, here’s how you'd fire off that Lua script:
const luaScript = `
local count = redis.call("INCR", KEYS[1])
if count == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return count
`;
const count = unwrap(
await redis.fire("eval", luaScript, 1, failureKey, WINDOW_SEC),
) as number;
Any time you're chaining Redis commands where order and timing matter, and especially when things can get concurrent—Lua scripting is your go-to
It makes your logic bulletproof, saves you network hops, and helps you sleep at night knowing your counters won't glitch out under pressure.
Hey! I recently created a tool called .
Feel free to check it out, and if you like it, consider leaving a generous star on my GitHub! ?
Let's connect!!: ?
But if you're doing them in two separate steps, you might be in for a surprise—especially under high load.
Let’s talk about the subtle (and annoying) race condition this causes, why plain Redis transactions don’t fix it, and how Lua scripting totally saves the day.
The Situation: Tracking Failures Over Time
Let’s say you’re trying to block brute-force login attempts. So every time a login fails, you increment a counter:
const count = unwrap(await redis.fire("incr", failureKey)) as number;
if (count === 1) {
await redis.fire("expire", failureKey, WINDOW_SEC);
}
What’s going on here:
- failureKey might be something like login:fail:user123
- WINDOW_SEC is how long the counter should live (like 60 seconds)
- If it’s the first failure, you kick off the timer by setting a TTL
It works… mostly.
The Sneaky Race Condition ?
This setup breaks under concurrency. Here’s how:
Client A does INCR, gets 1
Client B also does INCR, gets 2
Now Client A sets the TTL
Oops. Now the window starts after 2 failures, not 1. You’ve just let in an extra try, and maybe more depending on timing.
That’s a classic race condition—and these are exactly the kind of bugs you don’t want to debug in production at 2AM.
Can’t We Just Use Transactions? ?
That’s a good thought—and Redis does support transactions via MULTI and EXEC. But here’s the catch:
await redis.multi()
.incr(failureKey)
.expire(failureKey, WINDOW_SEC)
.exec();
This groups the commands together, but not conditionally. Redis will always run both commands—INCR and EXPIRE—no matter what. So you can’t say “expire only if the count is 1.”
And while Redis MULTI ensures the commands run one after another, they still aren’t atomic in the sense that no logic runs between them. Another client can sneak in between your INCR and EXPIRE, and mess things up.
So yeah—transactions alone don’t solve this. You need logic + atomic execution.
Lua Scripting Saves the Day
Redis has a built-in feature for this: Lua scripts.
They let you run multiple commands on the Redis server as a single, atomic operation. Here’s the script:
local count = redis.call("INCR", KEYS[1])
if count == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return count
What’s happening:
- We increment the key
- If it’s the first increment, we set the expiration
- Then return the new count
All of this happens as one atomic command. No other Redis client can sneak in during this execution. Problem solved.
Why Lua Rocks for This
- No more race conditions

- Fewer round-trips to Redis
- Cleaner logic—you move complexity server-side
Basically, it's safer and faster.
How to Use It in Node.js
If you're using something like ioredis, here’s how you'd fire off that Lua script:
const luaScript = `
local count = redis.call("INCR", KEYS[1])
if count == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return count
`;
const count = unwrap(
await redis.fire("eval", luaScript, 1, failureKey, WINDOW_SEC),
) as number;
eval tells Redis to run the script
1 is the number of keys we’re passing in
failureKey goes into KEYS[1], WINDOW_SEC into ARGV[1]
Any time you're chaining Redis commands where order and timing matter, and especially when things can get concurrent—Lua scripting is your go-to
It makes your logic bulletproof, saves you network hops, and helps you sleep at night knowing your counters won't glitch out under pressure.
Hey! I recently created a tool called .
Feel free to check it out, and if you like it, consider leaving a generous star on my GitHub! ?
Let's connect!!: ?