找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3879

积分

0

好友

532

主题
发表于 3 天前 | 查看: 18| 回复: 0

2018年,直播答题应用如王思聪的冲顶大会、西瓜视频的百万英雄等火爆全网。我所在团队为一家电商公司开发相关功能时,遇到了一个核心场景:答题结束后,红包以“红包雨”形式落下,用户点击抢红包,现金实时入账。

这是一个典型的高并发场景,瞬时海量请求涌向服务端。为了确保系统流畅,技术团队最终采用了基于 Redis + Lua 脚本的设计方案。

红包雨技术架构思维导图

1 整体流程

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

红包系统流程架构图

  1. 运营配置:运营后台预先配置红包雨活动的总金额与红包个数,并提前计算好每个红包的金额,存储到 Redis 中。
  2. 用户抢红包:在抢红包界面,用户点击屏幕上落下的红包,发起抢红包请求。
  3. 服务处理:TCP网关接收请求后,调用答题系统的抢红包服务。该服务的核心就是执行一段 Lua 脚本,处理结果通过TCP网关返回给前端。
  4. 异步入账:若用户抢到红包,一个异步任务会从 Redis 中获取红包信息,并调用余额系统,将金额返还到用户账户。

2 红包 Redis 设计

抢红包业务有两条关键规则:

  • 同一活动,每个用户只能抢一次红包。
  • 红包数量有限,一个红包只能被一个用户抢到。

为此,我们设计了三种 Redis 数据结构:

  1. 运营预分配红包列表 (List)

List队列结构

队列中每个元素的 JSON 数据格式如下:

{
    //红包编号
    redPacketId : '365628617880842241',
    //红包金额
    amount : '12.21'
}
  1. 用户红包领取记录列表 (List)

链表数据结构示意图

队列元素的 JSON 数据格式:

{
    //红包编号
    redPacketId : '365628617880842241',
    //红包金额
    amount : '12.21',
    //用户编号
    userId : '265628617882842248'
}
  1. 用户红包防重 Hash 表

Hash表数据结构

抢红包的 Redis 操作流程如下:

  1. 使用 HEXISTS 命令判断防重 Hash 表中该用户是否已领取过红包。若未领取,则继续。
  2. 从预分配红包列表中 RPOP 弹出一个红包数据。
  3. 操作防重 Hash 表,使用 HSET 命令存储用户领取记录(用户ID为field,红包ID为value)。
  4. 将红包领取信息 LPUSH 到用户红包领取记录列表中。

分析这个流程,我们必须重点关注几个问题:

  • 原子性:多个命令能否作为一个整体执行?一个命令失败能否回滚?
  • 隔离性:在高并发下,多个用户的操作能否互不干扰?
  • 步骤依赖:后续步骤依赖前面步骤的结果(如先检查再领取)。

Redis 本身提供了两种处理多命令的方式:事务模式Lua 脚本。下面我们来深入探讨。

3 事务原理

Redis 事务涉及以下命令:

序号 命令及描述
1 MULTI 标记一个事务块的开始。
2 EXEC 执行所有事务块内的命令。
3 DISCARD 取消事务,放弃执行事务块内的所有命令。
4 WATCH key [key ...] 监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。
5 UNWATCH 取消 WATCH 命令对所有 key 的监视。

事务的执行分为三个阶段:

  1. 开启事务:使用 MULTI 命令,客户端状态切换至事务状态。
  2. 命令入队:开启事务后,客户端的命令不会立即执行,而是被放入一个事务队列。
  3. 执行或丢弃:收到 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。

Redis事务执行前key被修改示例

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

WATCH命令实现乐观锁效果

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”

可以看到,第三条命令执行失败,但其他命令(包括第四条)依然执行成功了。事务没有回滚

结论:

  1. 命令入队时报错,事务被放弃,保证了原子性。
  2. 命令入队正常,但 EXEC 执行后报错,不保证原子性。
    因此,Redis 事务仅在特定条件下才具备一定的原子性,且不支持回滚。

4.2 隔离性

隔离性防止并发事务交叉执行导致数据不一致。

Redis 没有传统数据库的事务隔离级别概念。我们讨论的隔离性是指:并发场景下,事务之间能否互不干扰

可以分为两个阶段讨论:

  1. EXEC 命令执行前:如前所述,key 可能被其他客户端修改。此时可通过 WATCH 机制实现乐观锁来保证隔离性。
  2. EXEC 命令执行后:由于 Redis 是单线程执行命令操作,EXEC 触发后,Redis 会保证队列中的所有命令连续执行完毕。这自然保证了事务的隔离性。

4.3 持久性

持久性要求事务完成后,修改是永久的,即使系统故障也不会丢失。

这完全取决于 Redis 的持久化配置:

  1. 未配置 RDB/AOF:数据全在内存,宕机即丢,无法保证持久性。
  2. 使用 RDB 模式:事务执行后,若在下一次 RDB 快照触发前宕机,修改会丢失。
  3. 使用 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.

《Designing Data-Intensive Applications》书籍封面

这种观点认为,原子性、隔离性、持久性是数据库自身的属性,而一致性(ACID中的C)是应用程序的属性。应用依赖数据库的A、I、D属性来达成一致性目标,但最终的一致性(即符合现实世界业务规则)需要应用自身来保证。

4.5 总结

Redis 作为内存数据库,在性能与功能之间做了权衡,并不能完全支持传统关系型数据库的事务ACID特性。

Redis 事务的特点如下:

  • 隔离性:可以保证。
  • 持久性:无法保证。
  • 原子性:具备一定原子性,但不支持回滚。
  • 一致性:存在概念分歧。若以“约束”为核心,则可以保证。

回到抢红包场景,由于每个步骤都依赖上一步的结果,且需要保证强一致性,虽然可以通过 WATCH 实现,但从工程简洁性来看,Redis 事务并非最优解。

5 Lua 脚本

5.1 简介

Lua 是一种轻量级、高效的嵌入式脚本语言,广泛应用于游戏开发(如《魔兽世界》)、网关(如OpenResty、Kong)等领域。

Lua语言logo

自 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 摘要来执行已缓存的脚本。

流程如下:

  1. 使用 SCRIPT LOAD 命令加载脚本,Redis 返回该脚本的 SHA1 摘要。
  2. 客户端保存此摘要,后续使用 EVALSHA 命令执行。

EVALSHA命令交互时序图

redis> SCRIPT LOAD “return ‘hello world’”
“5332031c6b470dc5a0dd9b4bf2030dea6d65de91”
redis> EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0
“hello world”

5.4 事务 VS Lua 脚本

Redis 官方文档中有这样一段阐述:

从定义上来说, Redis 中的脚本本身就是一种事务, 所以任何在事务里可以完成的事, 在脚本里面也能完成。 并且一般来说, 使用脚本要来得更简单,并且速度更快。

Lua 脚本是另一种形式的事务,它具备原子性(但出错时同样不支持回滚),能保证隔离性,并且能完美支持“后面步骤依赖前面步骤的结果”这种逻辑。

结论:对于抢红包这类需要强原子性和操作序列依赖的场景,Lua 脚本是最优解决方案。

当然,使用 Lua 脚本也需注意:

  1. 脚本逻辑不应过于复杂和耗时,以免长时间阻塞 Redis。
  2. 需仔细测试,因为脚本执行具备原子性,不支持回滚。

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

调试建议

  1. 编写完善的 JUnit 测试用例。
  2. 利用 Redis 3.2 版本后内置的 Lua debugger (LDB) 进行脚本调试。

8 异步任务

为了将抢红包成功记录异步同步到余额系统,我们基于 Redisson 封装了消息队列组件。

  1. RedisMessageConsumer : 消费者类
    配置监听队列和对应的监听器。

    String groupName = “userGroup”;
    String queueName = “userAmountQueue”;
    RedisMessageQueueBuilder buidler =
            redisClient.getRedisMessageQueueBuilder();
    RedisMessageConsumer consumer =
            new RedisMessageConsumer(groupName, buidler);
    consumer.subscribe(queueName, userAmountMessageListener);
    consumer.start();
  2. 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 事务不支持回滚)。这提醒我们,面对不熟悉的知识点时,应保持谦卑的学习心态。

没有任何一项技术是完美的,架构与编码的过程总是在性能、功能、复杂度之间寻求最佳的平衡点,这才是后端系统设计的真实世界。希望本文分享的实战经验,能为你处理类似的高并发原子操作场景提供有价值的参考。欢迎在云栈社区交流讨论更多技术细节。




上一篇:阿里P7面试与能力模型全解析:技术专家需要哪些核心技能?
下一篇:掌握领域驱动设计:从概念到微服务架构的实战指南
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-10 08:52 , Processed in 0.488724 second(s), 43 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表