2018年,直播答题应用如王思聪的冲顶大会、西瓜视频的百万英雄等火爆全网。我所在团队为一家电商公司开发相关功能时,遇到了一个核心场景:答题结束后,红包以“红包雨”形式落下,用户点击抢红包,现金实时入账。
这是一个典型的高并发场景,瞬时海量请求涌向服务端。为了确保系统流畅,技术团队最终采用了基于 Redis + Lua 脚本的设计方案。

1 整体流程
让我们先梳理一下抢红包的整体业务流程:

- 运营配置:运营后台预先配置红包雨活动的总金额与红包个数,并提前计算好每个红包的金额,存储到 Redis 中。
- 用户抢红包:在抢红包界面,用户点击屏幕上落下的红包,发起抢红包请求。
- 服务处理:TCP网关接收请求后,调用答题系统的抢红包服务。该服务的核心就是执行一段 Lua 脚本,处理结果通过TCP网关返回给前端。
- 异步入账:若用户抢到红包,一个异步任务会从 Redis 中获取红包信息,并调用余额系统,将金额返还到用户账户。
2 红包 Redis 设计
抢红包业务有两条关键规则:
- 同一活动,每个用户只能抢一次红包。
- 红包数量有限,一个红包只能被一个用户抢到。
为此,我们设计了三种 Redis 数据结构:
- 运营预分配红包列表 (List)

队列中每个元素的 JSON 数据格式如下:
{
//红包编号
redPacketId : '365628617880842241',
//红包金额
amount : '12.21'
}
- 用户红包领取记录列表 (List)

队列元素的 JSON 数据格式:
{
//红包编号
redPacketId : '365628617880842241',
//红包金额
amount : '12.21',
//用户编号
userId : '265628617882842248'
}
- 用户红包防重 Hash 表

抢红包的 Redis 操作流程如下:
- 使用
HEXISTS 命令判断防重 Hash 表中该用户是否已领取过红包。若未领取,则继续。
- 从预分配红包列表中
RPOP 弹出一个红包数据。
- 操作防重 Hash 表,使用
HSET 命令存储用户领取记录(用户ID为field,红包ID为value)。
- 将红包领取信息
LPUSH 到用户红包领取记录列表中。
分析这个流程,我们必须重点关注几个问题:
- 原子性:多个命令能否作为一个整体执行?一个命令失败能否回滚?
- 隔离性:在高并发下,多个用户的操作能否互不干扰?
- 步骤依赖:后续步骤依赖前面步骤的结果(如先检查再领取)。
Redis 本身提供了两种处理多命令的方式:事务模式 和 Lua 脚本。下面我们来深入探讨。
3 事务原理
Redis 事务涉及以下命令:
| 序号 |
命令及描述 |
| 1 |
MULTI 标记一个事务块的开始。 |
| 2 |
EXEC 执行所有事务块内的命令。 |
| 3 |
DISCARD 取消事务,放弃执行事务块内的所有命令。 |
| 4 |
WATCH key [key ...] 监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。 |
| 5 |
UNWATCH 取消 WATCH 命令对所有 key 的监视。 |
事务的执行分为三个阶段:
- 开启事务:使用
MULTI 命令,客户端状态切换至事务状态。
- 命令入队:开启事务后,客户端的命令不会立即执行,而是被放入一个事务队列。
- 执行或丢弃:收到
EXEC 命令则执行队列中所有命令;收到 DISCARD 则丢弃事务。
一个简单的事务示例如下:
redis> MULTI
OK
redis> SET msg “hello world”
QUEUED
redis> GET msg
QUEUED
redis> EXEC
1) OK
1) hello world
这里引出一个问题:在事务开启后、执行前,Redis 的 key 还能被修改吗?
答案是:在事务执行 EXEC 命令之前,其他客户端仍然可以修改这些 key。

为了解决这个问题,我们可以在事务开启前使用 WATCH 命令监听 key。如果在事务执行前被监听的 key 被修改,则事务执行失败,返回 nil。

WATCH 命令的这种特性,可以实现类似乐观锁的效果。
4 事务的ACID
4.1 原子性
原子性要求事务中的操作要么全部完成,要么全部不完成。
场景一:命令入队时报错
例如,在事务中使用了不存在的命令。
redis> MULTI
OK
redis> SET msg “other msg”
QUEUED
redis> wrongcommand ### 故意写错误的命令
(error) ERR unknown command ‘wrongcommand’
redis> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
redis> GET msg
“hello world”
这种情况下,由于入队阶段就发生了错误,整个事务都无法执行,保证了原子性。
场景二:命令执行时报错
命令入队时正常,但执行时类型不匹配。
redis> MULTI
OK
redis> SET msg “other msg”
QUEUED
redis> SET mystring “I am a string”
QUEUED
redis> HMSET mystring name “test” ### 对字符串键执行哈希操作
QUEUED
redis> SET msg “after”
QUEUED
redis> EXEC
1) OK
2) OK
3) (error) WRONGTYPE Operation against a key holding the wrong kind of value
4) OK
redis> GET msg
“after”
可以看到,第三条命令执行失败,但其他命令(包括第四条)依然执行成功了。事务没有回滚。
结论:
- 命令入队时报错,事务被放弃,保证了原子性。
- 命令入队正常,但
EXEC 执行后报错,不保证原子性。
因此,Redis 事务仅在特定条件下才具备一定的原子性,且不支持回滚。
4.2 隔离性
隔离性防止并发事务交叉执行导致数据不一致。
Redis 没有传统数据库的事务隔离级别概念。我们讨论的隔离性是指:并发场景下,事务之间能否互不干扰。
可以分为两个阶段讨论:
EXEC 命令执行前:如前所述,key 可能被其他客户端修改。此时可通过 WATCH 机制实现乐观锁来保证隔离性。
EXEC 命令执行后:由于 Redis 是单线程执行命令操作,EXEC 触发后,Redis 会保证队列中的所有命令连续执行完毕。这自然保证了事务的隔离性。
4.3 持久性
持久性要求事务完成后,修改是永久的,即使系统故障也不会丢失。
这完全取决于 Redis 的持久化配置:
- 未配置 RDB/AOF:数据全在内存,宕机即丢,无法保证持久性。
- 使用 RDB 模式:事务执行后,若在下一次 RDB 快照触发前宕机,修改会丢失。
- 使用 AOF 模式:
appendfsync no:由操作系统决定,可能丢失数据。
appendfsync everysec:每秒同步,可能丢失1秒数据。
appendfsync always:每命令同步,可保证持久性,但性能极差,生产环境不推荐。
综上,Redis 事务的持久性是无法保证的。
4.4 一致性
一致性是一个容易混淆的概念,主要有两种解读。
1. 维基百科的定义(约束为核心)
Consistency ensures that a transaction can only bring the database from one valid state to another, maintaining database invariants... This prevents database corruption by an illegal transaction, but does not guarantee that a transaction is correct.
这里的核心是“约束”(Constraints),即写入数据库的数据必须符合所有预定义的规则(如唯一性约束、外键约束)。保证了约束,就保证了一致性。
在这种语义下,我们分析 Redis 事务:
EXEC 前命令错误:事务终止,数据保持一致状态。
EXEC 后运行时错误:错误命令报错,正确命令仍执行。从遵守约束的角度看,数据仍处于一致状态。
- Redis 服务宕机:
- 无持久化模式:重启后无数据,状态一致。
- RDB/AOF模式:从持久化文件恢复,数据库回到一个一致的状态。
因此,在以“约束”为核心的语义下,Redis 事务可以保证一致性。
2. 《设计数据密集型应用》中的观点
Atomicity, isolation, and durability are properties of the database, whereas consistency (in the ACID sense) is a property of the application... Thus, the letter C doesn’t really belong in ACID.

这种观点认为,原子性、隔离性、持久性是数据库自身的属性,而一致性(ACID中的C)是应用程序的属性。应用依赖数据库的A、I、D属性来达成一致性目标,但最终的一致性(即符合现实世界业务规则)需要应用自身来保证。
4.5 总结
Redis 作为内存数据库,在性能与功能之间做了权衡,并不能完全支持传统关系型数据库的事务ACID特性。
Redis 事务的特点如下:
- 隔离性:可以保证。
- 持久性:无法保证。
- 原子性:具备一定原子性,但不支持回滚。
- 一致性:存在概念分歧。若以“约束”为核心,则可以保证。
回到抢红包场景,由于每个步骤都依赖上一步的结果,且需要保证强一致性,虽然可以通过 WATCH 实现,但从工程简洁性来看,Redis 事务并非最优解。
5 Lua 脚本
5.1 简介
Lua 是一种轻量级、高效的嵌入式脚本语言,广泛应用于游戏开发(如《魔兽世界》)、网关(如OpenResty、Kong)等领域。

自 Redis 2.6.0 起,内置了 Lua 解释器。使用 Lua 脚本的好处显而易见:
- 减少网络开销:多个操作封装在一个脚本中一次性发送执行。
- 原子操作:整个脚本作为一个整体执行,中途不会插入其他命令。
- 复用:脚本可存储在 Redis 中,被所有客户端复用。
常用 Redis Lua 脚本命令:
| 序号 |
命令及描述 |
| 1 |
EVAL script numkeys key [key ...] arg [arg ...] 执行 Lua 脚本。 |
| 2 |
EVALSHA sha1 numkeys key [key ...] arg [arg ...] 执行 Lua 脚本。 |
| 3 |
SCRIPT EXISTS script [script ...] 查看指定的脚本是否已经被保存在缓存当中。 |
| 4 |
SCRIPT FLUSH 从脚本缓存中移除所有脚本。 |
| 5 |
SCRIPT KILL 杀死当前正在运行的 Lua 脚本。 |
| 6 |
SCRIPT LOAD script 将脚本 script 添加到脚本缓存中,但并不立即执行这个脚本。 |
5.2 EVAL 命令
命令格式:
EVAL script numkeys key [key ...] arg [arg ...]
script:Lua 5.1 脚本字符串。
numkeys:指定后续 key 参数的数量。
key [key ...]:操作的键,在脚本中通过 KEYS[1], KEYS[2] 访问。
arg [arg ...]:附加参数,在脚本中通过 ARGV[1], ARGV[2] 访问。
简单示例:
redis> eval “return ARGV[1]” 0 100
“100”
redis> eval “return {ARGV[1],ARGV[2]}” 0 100 101
1) “100”
2) “101”
redis> eval “return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}” 2 key1 key2 first second
1) “key1”
2) “key2”
3) “first”
4) “second”
在 Lua 脚本中通过 redis.call() 函数调用 Redis 命令:
redis> set mystring ‘hello world’
OK
redis> get mystring
“hello world”
redis> EVAL “return redis.call(‘GET’,KEYS[1])” 1 mystring
“hello world”
5.3 EVALSHA 命令
如果每次请求都传输完整的 Lua 脚本,网络开销较大。EVALSHA 通过脚本的 SHA1 摘要来执行已缓存的脚本。
流程如下:
- 使用
SCRIPT LOAD 命令加载脚本,Redis 返回该脚本的 SHA1 摘要。
- 客户端保存此摘要,后续使用
EVALSHA 命令执行。

redis> SCRIPT LOAD “return ‘hello world’”
“5332031c6b470dc5a0dd9b4bf2030dea6d65de91”
redis> EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0
“hello world”
5.4 事务 VS Lua 脚本
Redis 官方文档中有这样一段阐述:
从定义上来说, Redis 中的脚本本身就是一种事务, 所以任何在事务里可以完成的事, 在脚本里面也能完成。 并且一般来说, 使用脚本要来得更简单,并且速度更快。
Lua 脚本是另一种形式的事务,它具备原子性(但出错时同样不支持回滚),能保证隔离性,并且能完美支持“后面步骤依赖前面步骤的结果”这种逻辑。
结论:对于抢红包这类需要强原子性和操作序列依赖的场景,Lua 脚本是最优解决方案。
当然,使用 Lua 脚本也需注意:
- 脚本逻辑不应过于复杂和耗时,以免长时间阻塞 Redis。
- 需仔细测试,因为脚本执行具备原子性,不支持回滚。
6 实战准备
在工程实践中,我们基于 Redisson 3.12.0 客户端进行了一层薄薄的封装。创建一个 PlatformScriptCommand 类来统一执行 Lua 脚本操作。

其核心方法包括:
// 加载 Lua 脚本
String scriptLoad(String luaScript);
// 执行 Lua 脚本
Object eval(String shardingkey,
String luaScript,
ReturnType returnType,
List<Object> keys,
Object... values);
// 通过 sha1 摘要执行Lua脚本
Object evalSha(String shardingkey,
String shaDigest,
List<Object> keys,
Object... values);
为什么需要 shardingkey 参数?因为在 Redis 集群模式下,我们需要确定脚本应该在哪个节点上执行。其原理是计算 key 所在的槽位:
public int calcSlot(String key) {
if (key == null) {
return 0;
}
int start = key.indexOf(‘{’);
if (start != -1) {
int end = key.indexOf(‘}’);
key = key.substring(start+1, end);
}
int result = CRC16.crc16(key.getBytes()) % MAX_SLOT;
log.debug(“slot {} for {}“, result, key);
return result;
}
7 抢红包脚本
客户端执行 Lua 脚本后,会返回一个 JSON 字符串,其中 code 字段表示抢红包结果:
- 成功 (
code:“0”):返回红包金额和编号。
- 已领取过 (
code:“1”):用户重复抢。
- 失败 (
code:“-1”):红包已被抢完。
脚本利用了 Redis Lua 内置的 cjson 库进行编解码。以下是核心脚本代码:
-- KEY[1]: 用户防重领取记录 Hash Key
local userHashKey = KEYS[1];
-- KEY[2]: 运营预分配红包列表 Key
local redPacketOperatingKey = KEYS[2];
-- KEY[3]: 用户红包领取记录 List Key
local userAmountKey = KEYS[3];
-- KEY[4]: 用户编号
local userId = KEYS[4];
local result = {};
-- 判断用户是否领取过
if redis.call(‘hexists’, userHashKey, userId) == 1 then
result[‘code’] = ‘1’;
return cjson.encode(result);
else
-- 从预分配红包中获取一个红包
local redPacket = redis.call(‘rpop’, redPacketOperatingKey);
if redPacket
then
local data = cjson.decode(redPacket);
-- 加入用户ID信息
data[‘userId’] = userId;
-- 把用户编号放到防重Hash中,value为红包编号
redis.call(‘hset’, userHashKey, userId, data[‘redPacketId’]);
-- 将完整的领取记录放入已消费队列
redis.call(‘lpush’, userAmountKey, cjson.encode(data));
-- 组装成功返回值
result[‘redPacketId’] = data[‘redPacketId’];
result[‘code’] = ‘0’;
result[‘amount’] = data[‘amount’];
return cjson.encode(result);
else
-- 红包已抢完
result[‘code’] = ‘-1’;
return cjson.encode(result);
end
end
调试建议:
- 编写完善的 JUnit 测试用例。
- 利用 Redis 3.2 版本后内置的 Lua debugger (
LDB) 进行脚本调试。
8 异步任务
为了将抢红包成功记录异步同步到余额系统,我们基于 Redisson 封装了消息队列组件。
-
RedisMessageConsumer : 消费者类
配置监听队列和对应的监听器。
String groupName = “userGroup”;
String queueName = “userAmountQueue”;
RedisMessageQueueBuilder buidler =
redisClient.getRedisMessageQueueBuilder();
RedisMessageConsumer consumer =
new RedisMessageConsumer(groupName, buidler);
consumer.subscribe(queueName, userAmountMessageListener);
consumer.start();
-
RedisMessageListener : 消费监听器
编写具体的业务消费逻辑,如调用余额系统接口。
public class UserAmountMessageListener implements RedisMessageListener {
@Override
public RedisConsumeAction onMessage(RedisMessage redisMessage) {
try {
String message = (String) redisMessage.getData();
// TODO 解析message,调用用户余额系统
// 返回消费成功
return RedisConsumeAction.CommitMessage;
}catch (Exception e) {
logger.error(“userAmountService invoke error:“, e);
// 消费失败,执行重试操作
return RedisConsumeAction.ReconsumeLater;
}
}
}
9 写到最后
“纸上得来终觉浅,绝知此事要躬行”。在深入研究 Redis Lua 的过程中,通过查阅资料和反复实践,我纠正了许多“想当然”的理解(例如 Redis 事务不支持回滚)。这提醒我们,面对不熟悉的知识点时,应保持谦卑的学习心态。
没有任何一项技术是完美的,架构与编码的过程总是在性能、功能、复杂度之间寻求最佳的平衡点,这才是后端系统设计的真实世界。希望本文分享的实战经验,能为你处理类似的高并发原子操作场景提供有价值的参考。欢迎在云栈社区交流讨论更多技术细节。