Implementing a Red Packet Rain System with Redis and Lua
In 2018, live quiz shows like Wang Sicong's "Chongding Dahui," Xigua Video's "Million Heroes," and Inke's "Cheese Superman" became wildly popular. An e-commerce company I worked for joined this trend, and the tech team developed a live quiz feature. After a quiz ended, red packets would fall as a "red packet rain." Users could click on these falling packets, and if they managed to grab one, the cash amount would be credited to their account.
A red packet rain is a classic high-concurrency scenario, with a massive number of requests hitting the server in a short time. To ensure smooth system operation, the tech team designed the red packet grabbing mechanism based on Redis + Lua scripts.
1. Overall Process
Let's analyze the overall flow of grabbing a red packet:
- The operations system configures the total amount and the number of packets for the rain event, pre-calculates individual packet amounts, and stores them in Redis.
- On the red packet rain interface, users tap on falling packets to initiate a grab request.
- A TCP gateway receives the request and invokes the red packet grabbing dubbo service (from the quiz system). This service essentially executes a Lua script and returns the result via the TCP gateway to the frontend.
- If a user successfully grabs a packet, an asynchronous task retrieves the packet details from Redis, calls the balance system, and deposits the amount into the user's account.
2. Redis Data Design for Red Packets
The red packet grabbing rules are:
- A user can only grab one packet per event.
- The number of packets is limited, and each packet can only be grabbed by one user.
Three data structures are designed:
-
Pre-allocated red packet list (operational): A list containing pre-generated red packet data. Each element is a JSON object:
{ "redPacketId": "365628617880842241", "amount": "12.21" } -
User red packet claim record list: A list storing the claim history. Each element is:
{ "redPacketId": "365628617880842241", "amount": "12.21", "userId": "265628617882842248" } -
User red packet deduplication Hash table: A hash table used to prevent duplicate claims. Key: user ID, value: red packet ID.
Redis operations for grabbing a packet:
- Use
HEXISTSto check if the user has already claimed a packet. If not, proceed. - Use
RPOPto pop a red packet entry from the pre-allocated list. - Use
HSETto store the user's claim record in the deduplication hash. - Use
LPUSHto add the claim record to the user claim history list.
Key concerns during this process:
- Atomicity: executing multiple commands must be atomic. If one command fails, can we roll back?
- Isolation: In high concurrency, can we maintain isolation between operations?
- The subsequent steps depend on the results of previous steps.
Redis supports two modes for this: transactions and Lua scripts. Let's explore both.
3. Transaction Mechanism
Redis transactions use the following commands:
| Command | Description |
|---|---|
| MULTI | Marks the start of a transaction block. |
| EXEC | Executes all commands in the transaction block. |
| DISCARD | Cancels the transaction and discards all queued commands. |
| WATCH | Monitors one or more keys. If any monitored key is modified before EXEC, the transaction is interrupted. |
| UNWATCH | Cancels the monitoring of all keys. |
A transaction has three phases:
- Begin:
MULTIswitches the client to transaction state. - Queue: After
MULTI, commands are not executed immediately but are queued. - Execute or Discard:
EXECexecutes the queue;DISCARDdiscards it.
Example:
redis> MULTI
OK
redis> SET msg "hello world"
QUEUED
redis> GET msg
QUEUED
redis> EXEC
1) OK
2) "hello world"
A critical point: Redis keys can be modified before EXEC is called. To achieve an optimistic locking effect, use WATCH. If a watched key is modified before EXEC, the transaction will fail.
Example with WATCH:
redis> WATCH msg
OK
redis> MULTI
OK
redis> SET msg "new value"
QUEUED
redis> EXEC
(nil) -- because msg was changed by another client before EXEC
4. ACID Properties of Redis Transactions
4.1 Atomicity
Atomicity means that all operations in a transaction are completed successfully, or none are. Errors cause a rollback to the initial state.
Case 1: Syntax error before EXEC
If a client sends a command with a syntax error or an unknown command before EXEC, the entire transaction fails.
redis> MULTI
OK
redis> SET msg "other msg"
QUEUED
redis> wrongcommand --- intentional error
(error) ERR unknown command 'wrongcommand'
redis> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
redis> GET msg
"hello world"
In this case, atomicity is preserved – the transaction is discarded.
Case 2: Type error during EXEC
If commands are queued successfully but a type error occurs during EXEC (e.g., using HMSET on a string), Redis does not roll back. It continues executing the remaining commands.
redis> MULTI
OK
redis> SET mystring "I am a string"
QUEUED
redis> HMSET mystring name "test"
QUEUED
redis> SET msg "after"
QUEUED
redis> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) OK
redis> GET msg
"after"
In this case, atomicity is not guaranteed.
Summary: Redis transactions provide atomicity only under certain conditions (errors before EXEC). They do not support rollback.
4.2 Isolation
In database terms, isolation ensures that concurrent transactions do not interfere with eachother. Redis does not have transaction isolation levels, but we can consider isolation in the context of concurrent operations.
- Before EXEC: Use
WATCHto implement optimistic locking. If a watched key is changed, the transaction aborts. - After EXEC: Redis processes commands in a single thread. Once
EXECis called, all commands in the queue are executed sequentially. This ensures isolation during execution.
Conclusion: Redis transactions can provide isolation when used with WATCH.
4.3 Durability
Durability ensures that once a transaction completes, changes persist even in the event of a system failure.
- No persistence (RDB/AOF disabled): No durability.
- RDB mode: Data may be lost if a crash occurs between the last snapshot and the transaction.
- AOF mode:
alwaysprovides strong (but slow) durability;everysecandnocan lose data.
Conclusion: Redis transactions do not guarantee durability.
4.4 Consistency
The definition of consistency in ACID is often debated.
Wikipedia Definition:
Consistency ensures that a transaction can only bring the database from one valid state to another, maintaining database invariants: any data written to the database must be valid according to all defined rules, including constraints, cascades, triggers, and any combination thereof.
Under this definition, consistency is about constraints. If constraints are maintained, the database is consistent. Redis transactions maintain Redis constraints (e.g., type constraints), but they do not handle application-level constraints. For example, a transfer that results in a negative balance is still "consistent" from the database perspective if no constraint prohibits negative values.
Application-defined Consistency:
Consistency (in the ACID sense) is a property of the application.
In practice, achieving application-level consistency requires atomicity, isolation, durability, and application logic. Redis transactions alone cannot guarantee application consistency.
Conclusion for our scenario: Redis transactions can maintain database-level consistency (constraints), but they are insufficient for complex business logic.
4.5 Summary
Redis transactions provide:
- Isolation (with WATCH)
- No durability
- Limited atomicity (no rollback)
- Consistency only at the database constraint level
For the red packet rain scenario, where each step depends on the previous result, we would need to use WATCH for optimistic locking, which is cumbersome. Lua scripts are a better fit.
5. Lua Scripting in Redis
5.1 Introduction
Lua is a lightweight, embeddable scripting language. Redis has included a Lua interpreter since version 2.6.0, allowing scripts to be executed atomically on the server.
Benefits of using Lua scripts:
- Reduced network overhead: Multiple commands can be sent in one script.
- Atomic execution: The entire script runs atomically without interference.
- Reusability: Scripts can be cached (using
SCRIPT LOAD) and executed by their SHA1 hash.
Common Lua commands in Redis:
| Command | Description |
|---|---|
| EVAL | Execute a Lua script. |
| EVALSHA | Execute a cached Lua script by its SHA1 hash. |
| SCRIPT EXISTS | Check if a script exists in the cache. |
| SCRIPT FLUSH | Remove all scripts from the cache. |
| SCRIPT KILL | Kill a currently running Lua script. |
| SCRIPT LOAD | Load a script into the cache without executing it. |
5.2 EVAL Command
Syntax:
EVAL script numkeys key [key ...] arg [arg ...]
script: Lua 5.1 script.numkeys: Number of keys.key...: Keys, accessible asKEYS[1],KEYS[2], etc.arg...: Arguments, accessible asARGV[1],ARGV[2], etc.
Examples:
redis> EVAL "return ARGV[1]" 0 100
"100"
redis> EVAL "return {KEYS[1], ARGV[1]}" 1 mykey hello
1) "mykey"
2) "hello"
To execute Redis commands from Lua, use redis.call():
redis> SET mykey "hello"
OK
redis> EVAL "return redis.call('GET', KEYS[1])" 1 mykey
"hello"
5.3 EVALSHA Command
To avoid sending long scripts repeatedly, cache them with SCRIPT LOAD:
redis> SCRIPT LOAD "return 'hello world'"
"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"
redis> EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0
"hello world"
5.4 Transactions vs. Lua Scripts
"From the point of view of Redis, a script is a transaction. Anything you can do with a transaction, you can do with a script. And in general, scripts are simpler and faster."
Lua scripts are an alternative way to achieve transactional behavior. They provide:
- Atomicity (but no rollback on errors)
- Isolation (script runs uninterrupted)
- Ability to use results of previous commands
For the red packet rain, Lua scripts are the optimal solution.
Caveats:
- Keep scripts simple and fast to avoid blocking Redis.
- Test thoroughly, as there is no rollback.
6. Practical Preparation
We'll use Redisson 3.12.0 as the Redis client, with a thin wrapper.
Create a PlatformScriptCommand class to execute Lua scripts:
// Load a Lua script
String scriptLoad(String luaScript);
// Execute a script
Object eval(String shardingKey,
String luaScript,
ReturnType returnType,
List<Object> keys,
Object... values);
// Execute a cached script by SHA1
Object evalSha(String shardingKey,
String shaDigest,
List<Object> keys,
Object... values);
The shardingKey is necessary for Redis cluster mode to determine which node executes the script (based on hash slot).
7. Red Packet Grabbing Script
The Lua script returns a JSON string.
Success:
{
"code": "0",
"amount": "7.1",
"redPacketId": "162339217730846210"
}
User already claimed:
{
"code": "1"
}
No more packets (failure):
{
"code": "-1"
}
Lua script using cjson:
-- KEYS[1]: deduplication hash key (user claim records)
local userHashKey = KEYS[1]
-- KEYS[2]: pre-allocated red packet list key
local redPacketOperatingKey = KEYS[2]
-- KEYS[3]: user claim history list key
local userAmountKey = KEYS[3]
-- KEYS[4]: user ID
local userId = KEYS[4]
local result = {}
-- Check if user already claimed
if redis.call('hexists', userHashKey, userId) == 1 then
result['code'] = '1'
return cjson.encode(result)
else
-- Try to pop a red packet
local redPacket = redis.call('rpop', redPacketOperatingKey)
if redPacket then
local data = cjson.decode(redPacket)
data['userId'] = userId
-- Add user to dedup hash
redis.call('hset', userHashKey, userId, data['redPacketId'])
-- Add to claim history list
redis.call('lpush', userAmountKey, cjson.encode(data))
-- Prepare success response
result['redPacketId'] = data['redPacketId']
result['code'] = '0'
result['amount'] = data['amount']
return cjson.encode(result)
else
-- No packets left
result['code'] = '-1'
return cjson.encode(result)
end
end
Debugging tips:
- Write JUnit tests for the script logic.
- Use Redis Lua debugger (LDB) from version 3.2 onwards.
8. Asynchronous Tasks
After a user grabs a packet, an asynchronous task processes the claim and updates the balance. We can use a simple message queue pattern on top of Redis.
Consumer class:
String groupName = "userGroup";
String queueName = "userAmountQueue";
RedisMessageQueueBuilder builder = redisClient.getRedisMessageQueueBuilder();
RedisMessageConsumer consumer = new RedisMessageConsumer(groupName, builder);
consumer.subscribe(queueName, new UserAmountMessageListener());
consumer.start();
Listener class:
public class UserAmountMessageListener implements RedisMessageListener {
@Override
public RedisConsumeAction onMessage(RedisMessage redisMessage) {
try {
String message = (String) redisMessage.getData();
// TODO: call user balance system
// Return success
return RedisConsumeAction.CommitMessage;
} catch (Exception e) {
logger.error("Failed to process message", e);
// Retry later
return RedisConsumeAction.ReconsumeLater;
}
}
}
9. Conclusion
"Learning without practice is shallow; practice without learning is dangerous."
Studying Redis Lua hands-on has taught me a lot. I was surprised to find that Redis transactions don't support rollback. It's humbling to realize how easy it is to make assumptions about technologies we think we know.
No technology is perfect. There are always trade-offs between design and implementation. Understanding these trade-offs is key to making good engineering decisions.
If this article has been helpful, feel free to share it with others. Your support motivates me to write higher-quality content. Thank you!