
聊聊红包雨背后的设计与实现全文很干耐心看完。2018年王思聪的冲顶大会西瓜视频的百万英雄再到映客的芝士超人直播答题火爆全网。我服务的一家电商公司也加入了这次热潮技术团队研发了直播答题功能。答题结束之后红包会以红包雨的形式落下用户点击屏幕上落下的红包若抢到红包红包会以现金的形式进入用户账户。红包雨是一个典型的高并发场景短时间内有海量请求访问服务端技术团队为了让系统运行顺畅抢红包采用了基于Redis Lua 脚本的设计方案。1 整体流程我们分析下抢红包的整体流程 运营系统配置红包雨活动总金额以及红包个数提前计算出各个红包的金额并存储到 Redis 中抢红包雨界面用户点击屏幕上落下的红包发起抢红包请求TCP 网关接收抢红包请求后调用答题系统抢红包 dubbo 服务抢红包服务本质上就是执行 Lua 脚本将结果通过 TCP 网关返回给前端用户若抢到红包异步任务会从 Redis 中 获取抢得的红包信息调用余额系统将金额返回到用户账户。2 红包 Redis 设计抢红包有如下规则同一活动用户只能抢红包一次 红包数量有限一个红包只能被一个用户抢到。如下图我们设计三种数据类型运营预分配红包列表 ;队列元素 json 数据格式 { //红包编号 redPacketId : 365628617880842241 //红包金额 amount : 12.21 }用户红包领取记录列表队列元素 json 数据格式{ //红包编号 redPacketId : 365628617880842241 //红包金额 amount : 12.21, //用户编号 userId : 265628617882842248 }用户红包防重 Hash 表抢红包 Redis 操作流程 通过 hexist 命令判断红包领取记录防重 Hash 表中用户是否领取过红包 若用户未领取过红包流程继续从运营预分配红包列表 rpop 出一条红包数据 操作红包领取记录防重 Hash 表 调用 HSET 命令存储用户领取记录将红包领取信息 lpush 进入用户红包领取记录列表。抢红包的过程 需要重点关注如下几点 :执行多个命令是否可以保证原子性 , 若一个命令执行失败是否可以回滚在执行过程中高并发场景下是否可以保持隔离性后面的步骤依赖前面步骤的结果。Redis 支持两种模式 :事务模式和Lua 脚本接下来我们一一展开。3 事务原理Redis 的事务包含如下命令序号命令及描述1MULTI 标记一个事务块的开始。2EXEC 执行所有事务块内的命令。3DISCARD 取消事务放弃执行事务块内的所有命令。4WATCH key [key ...] 监视一个(或多个) key 如果在事务执行之前这个(或这些) key 被其他命令所改动那么事务将被打断。5UNWATCH 取消 WATCH 命令对所有 key 的监视。事务包含三个阶段事务开启使用 MULTI , 该命令标志着执行该命令的客户端从非事务状态切换至事务状态 命令入队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 命令之前 Redis key 依然可以被修改。在事务开启之前我们可以 watch 命令监听 Redis key 。在事务执行之前我们修改 key 值 事务执行失败返回nil。通过上面的例子watch 命令可以实现类似乐观锁的效果。4 事务的ACID4.1 原子性原子性是指一个事务中的所有操作或者全部完成或者全部不完成不会结束在中间某个环节。事务在执行过程中发生错误会被回滚到事务开始前的状态就像这个事务从来没有执行过一样。第一个例子在执行 EXEC 命令前客户端发送的操作命令错误比如语法错误或者使用了不存在的命令。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在这个例子中我们使用了不存在的命令导致入队失败整个事务都将无法执行 。第二个例子事务操作入队时命令和操作的数据类型不匹配 入队列正常但执行 EXEC 命令异常 。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这个例子里Redis 在执行 EXEC 命令时如果出现了错误Redis 不会终止其它命令的执行事务也不会因为某个命令执行失败而回滚 。综上我对 Redis 事务原子性的理解如下命令入队时报错 会放弃事务执行保证原子性命令入队时正常执行 EXEC 命令后报错不保证原子性也就是Redis 事务在特定条件下才具备一定的原子性。4.2 隔离性数据库的隔离性是指数据库允许多个并发事务同时对其数据进行读写和修改的能力隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别 分别是未提交读read uncommitted提交读read committed可重复读repeatable read串行化serializable首先需要明确一点Redis 并没有事务隔离级别的概念。这里我们讨论 Redis 的隔离性是指并发场景下事务之间是否可以做到互不干扰。我们可以将事务执行可以分为EXEC 命令执行前和EXEC 命令执行后两个阶段分开讨论。EXEC 命令执行前在事务原理这一小节我们发现在事务执行之前 Redis key 依然可以被修改。此时可以使用WATCH 机制来实现乐观锁的效果。EXEC 命令执行后因为 Redis 是单线程执行操作命令 EXEC 命令执行后Redis 会保证命令队列中的所有命令执行完 。 这样就可以保证事务的隔离性。4.3 持久性数据库的持久性是指 事务处理结束后对数据的修改就是永久的即便系统故障也不会丢失。Redis 的数据是否持久化取决于 Redis 的持久化配置模式 。没有配置 RDB 或者 AOF 事务的持久性无法保证使用了 RDB模式在一个事务执行后下一次的 RDB 快照还未执行前如果发生了实例宕机事务的持久性同样无法保证使用了 AOF 模式AOF 模式的三种配置选项 no 、everysec 都会存在数据丢失的情况 。always 可以保证事务的持久性但因为性能太差在生产环境一般不推荐使用。综上redis 事务的持久性是无法保证的。4.4 一致性一致性的概念一直很让人困惑在我搜寻的资料里有两类不同的定义。维基百科我们先看下维基百科上一致性的定义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. This prevents database corruption by an illegal transaction, but does not guarantee that a transaction is correct. Referential integrity guarantees the primary key – foreign key relationship.在这段文字里一致性的核心是“约束”“any data written to the database must be valid according to all defined rules”。如何理解约束这里引用知乎问题如何理解数据库的内部一致性和外部一致性蚂蚁金服 OceanBase 研发专家韩富晟回答的一段话“约束”由数据库的使用者告诉数据库使用者要求数据一定符合这样或者那样的约束。当数据发生修改时数据库会检查数据是否还符合约束条件如果约束条件不再被满足那么修改操作不会发生。关系数据库最常见的两类约束是“唯一性约束”和“完整性约束”表格中定义的主键和唯一键都保证了指定的数据项绝不会出现重复表格之间定义的参照完整性也保证了同一个属性在不同表格中的一致性。“ Consistency in ACID ”是如此的好用以至于已经融化在大部分使用者的血液里了使用者会在表格设计的时候自觉的加上需要的约束条件数据库也会严格的执行这个约束条件。所以事务的一致性和预先定义的约束有关保证了约束即保证了一致性。我们细细品一品这句话This prevents database corruption by an illegal transaction, but does not guarantee that a transaction is correct。写到这里可能大家还是有点模糊我们举经典转账的案例。我们开启一个事务张三和李四账号上的初始余额都是1000元并且余额字段没有任何约束。张三给李四转账1200元。张三的余额更新为 -200 李四的余额更新为2200。从应用层面来看这个事务明显不合法因为现实场景中用户余额不可能小于 0 但是它完全遵循数据库的约束所以从数据库层面来看这个事务依然保证了一致性。Redis 的事务一致性是指Redis 事务在执行过程中符合数据库的约束没有包含非法或者无效的错误数据。我们分三种异常场景分别讨论执行 EXEC 命令前客户端发送的操作命令错误事务终止数据保持一致性执行 EXEC 命令后命令和操作的数据类型不匹配错误的命令会报错但事务不会因为错误的命令而终止而是会继续执行。正确的命令正常执行错误的命令报错从这个角度来看数据也可以保持一致性执行事务的过程中Redis 服务宕机。这里需要考虑服务配置的持久化模式。无持久化的内存模式服务重启之后数据库没有保持数据因此数据都是保持一致性的RDB / AOF 模式 服务重启后Redis 通过 RDB / AOF 文件恢复数据数据库会还原到一致的状态。综上所述在一致性的核心是约束的语意下Redis 的事务可以保证一致性。《设计数据密集型应用》这本书是分布式系统入门的神书。在事务这一章节有一段关于 ACID 的解释Atomicity, isolation, and durability are properties of the database,whereas consistency (in the ACID sense) is a property of the application. The application may rely on the database’s atomicity and isolation properties in order to achieve consistency, but it’s not up to the database alone. Thus, the letter C doesn’t really belong in ACID.原子性隔离性和持久性是数据库的属性而一致性在 ACID 意义上是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性但这并不仅取决于数据库。因此字母 C 不属于 ACID 。很多时候我们一直在纠结的一致性其实就是指符合现实世界的一致性现实世界的一致性才是事务追求的最终目标。为了实现现实世界的一致性需要满足如下几点保证原子性持久性和隔离性如果这些特征都无法保证那么事务的一致性也无法保证数据库本身的约束比如字符串长度不能超过列的限制或者唯一性约束业务层面同样需要进行保障 。4.5 总结我们通常称 Redis 为内存数据库 , 不同于传统的关系数据库为了提供了更高的性能更快的写入速度在设计和实现层面做了一些平衡并不能完全支持事务的 ACID。Redis 的事务具备如下特点保证隔离性无法保证持久性具备了一定的原子性但不支持回滚一致性的概念有分歧假设在一致性的核心是约束的语意下Redis 的事务可以保证一致性。另外在抢红包的场景下 因为每个步骤需要依赖上一个步骤返回的结果需要通过 watch 来实现乐观锁 从工程角度来看 Redis 事务并不适合该业务场景。5 Lua 脚本5.1 简介“ Lua ” 在葡萄牙语中是“月亮”的意思1993年由巴西的 Pontifical Catholic University 开发。该语言的设计目的是为了嵌入应用程序中从而为应用程序提供灵活的扩展和定制功能。Lua 脚本可以很容易的被 C/C 代码调用也可以反过来调用 C/C 的函数这使得 Lua 在应用程序中可以被广泛应用。不仅仅作为扩展脚本也可以作为普通的配置文件代替 XML, Ini 等文件格式并且更容易理解和维护。Lua 由标准 C 编写而成代码简洁优美几乎在所有操作系统和平台上都可以编译运行。一个完整的 Lua 解释器不过 200 k在目前所有脚本引擎中Lua 的速度是最快的。这一切都决定了 Lua 是作为嵌入式脚本的最佳选择。Lua 脚本在游戏领域大放异彩大家耳熟能详的《大话西游II》《魔兽世界》都大量使用 Lua 脚本。Java 后端工程师接触过的 api 网关比如OpenrestyKong都可以看到 Lua 脚本的身影。从 Redis 2.6.0 版本开始 Redis内置的 Lua 解释器可以实现在 Redis 中运行 Lua 脚本。使用 Lua 脚本的好处 减少网络开销。将多个请求通过脚本的形式一次发送减少网络时延。原子操作。Redis会将整个脚本作为一个整体执行中间不会被其他命令插入。复用。客户端发送的脚本会永久存在 Redis 中其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑。Redis Lua 脚本常用命令序号命令及描述1EVAL script numkeys key [key ...] arg [arg ...] 执行 Lua 脚本。2EVALSHA sha1 numkeys key [key ...] arg [arg ...] 执行 Lua 脚本。3SCRIPT EXISTS script [script ...] 查看指定的脚本是否已经被保存在缓存当中。4SCRIPT FLUSH 从脚本缓存中移除所有脚本。5SCRIPT KILL 杀死当前正在运行的 Lua 脚本。6SCRIPT LOAD script 将脚本 script 添加到脚本缓存中但并不立即执行这个脚本。5.2 EVAL 命令命令格式EVAL script numkeys key [key ...] arg [arg ...]说明script是第一个参数为 Lua 5.1脚本第二个参数numkeys指定后续参数有几个 keykey [key ...]是要操作的键可以指定多个在 Lua 脚本中通过KEYS[1],KEYS[2]获取arg [arg ...]参数在 Lua 脚本中通过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]} 2 key1 key2 first second 1) key1 2) key2 3) first 4) second下面演示下 Lua 如何调用 Redis 命令 通过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 redis EVAL return redis.call(GET,mystring) 0 hello world5.3 EVALSHA 命令使用 EVAL 命令每次请求都需要传输 Lua 脚本 若 Lua 脚本过长不仅会消耗网络带宽而且也会对 Redis 的性能造成一定的影响。思路是先将 Lua 脚本先缓存起来 , 返回给客户端 Lua 脚本的 sha1 摘要。 客户端存储脚本的 sha1 摘要 每次请求执行 EVALSHA 命令即可。EVALSHA 命令基本语法如下redis EVALSHA sha1 numkeys key [key ...] arg [arg ...]实例如下redis SCRIPT LOAD return hello world 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 redis EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0 hello world5.4 事务 VS Lua 脚本从定义上来说 Redis 中的脚本本身就是一种事务 所以任何在事务里可以完成的事 在脚本里面也能完成。 并且一般来说 使用脚本要来得更简单并且速度更快。因为脚本功能是 Redis 2.6 才引入的 而事务功能则更早之前就存在了 所以 Redis 才会同时存在两种处理事务的方法。不过我们并不打算在短时间内就移除事务功能 因为事务提供了一种即使不使用脚本 也可以避免竞争条件的方法 而且事务本身的实现并不复杂。-- https://redis.io/Lua 脚本是另一种形式的事务他具备一定的原子性但脚本报错的情况下事务并不会回滚。Lua 脚本可以保证隔离性而且可以完美的支持后面的步骤依赖前面步骤的结果。综上Lua 脚本是抢红包场景最优的解决方案。但在编写 Lua 脚本时要注意如下两点为了避免 Redis 阻塞Lua 脚本业务逻辑不能过于复杂和耗时仔细检查和测试 Lua 脚本 因为执行 Lua 脚本具备一定的原子性不支持回滚。6 实战准备我选择 Redisson 3.12.0 版本作为 Redis 的客户端在 Redisson 源码基础上做一层薄薄的封装。创建一个 PlatformScriptCommand 类 用来执行 Lua 脚本。// 加载 Lua 脚本 String scriptLoad(String luaScript); // 执行 Lua 脚本 Object eval(String shardingkey, String luaScript, ReturnType returnType, ListObject keys, Object... values); // 通过 sha1 摘要执行Lua脚本 Object evalSha(String shardingkey, String shaDigest, ListObject keys, Object... values);这里为什么我们需要添加一个 shardingkey 参数呢 因为 Redis 集群模式下我们需要定位哪一个节点执行 Lua 脚本。public int calcSlot(String key) { if (key null) { return 0; } int start key.indexOf({); if (start ! -1) { int end key.indexOf(}); key key.substring(start1, end); } int result CRC16.crc16(key.getBytes()) % MAX_SLOT; log.debug(slot {} for {}, result, key); return result; }7 抢红包脚本客户端执行 Lua 脚本后返回 json 字符串。用户抢红包成功{ code:0, //红包金额 amount:7.1, //红包编号 redPacketId:162339217730846210 }用户已领取过{ code:1 }用户抢红包失败{ code:-1 }Redis Lua 中内置了 cjson 函数用于 json 的编解码。-- KEY[1]: 用户防重领取记录 local userHashKey KEYS[1]; -- KEY[2]: 运营预分配红包列表 local redPacketOperatingKey KEYS[2]; -- KEY[3]: 用户红包领取记录 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; -- 把用户编号放到去重的哈希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, 可以使用 Lua debugger 对 Lua 脚本进行调试。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 调用用户余额系统 // 返回消费成功 return RedisConsumeAction.CommitMessage; }catch (Exception e) { logger.error(userAmountService invoke error:, e); // 消费失败执行重试操作 return RedisConsumeAction.ReconsumeLater; } } }