0
点赞
收藏
分享

微信扫一扫

电商系统中红包活动设计


红包雨是一个典型的高并发场景,短时间内有海量请求访问服务端,技术团队为了让系统运行顺畅,抢红包采用了基于 Redis + Lua 脚本的设计方案。

电商系统中红包活动设计_微服务

文章目录

  • ​​1.整体流程​​
  • ​​2.红包 Redis 设计​​
  • ​​3.事务原理​​
  • ​​4.事务的ACID​​
  • ​​4.1 原子性​​
  • ​​4.2 隔离性​​
  • ​​4.3 持久性​​
  • ​​4.4 一致性​​
  • ​​4.5 总结​​
  • ​​5.Lua 脚本​​
  • ​​5.1 EVAL​​
  • ​​5.2 EVALSHA​​
  • ​​5.3 事务 VS Lua 脚本​​
  • ​​6.实战准备​​
  • ​​7.抢红包脚本​​
  • ​​8.异步任务​​

1.整体流程

电商系统中红包活动设计_spring_02

  1. 运营系统配置红包雨活动总金额以及红包个数,提前计算出各个红包的金额并存储到 Redis 中;
  2. 抢红包雨界面,用户点击屏幕上落下的红包,发起抢红包请求;
  3. TCP 网关接收抢红包请求后,调用答题系统抢红包 dubbo 服务,抢红包服务本质上就是执行 Lua 脚本,将结果通过 TCP 网关返回给前端;
  4. 用户若抢到红包,异步任务会从 Redis 中 获取抢得的红包信息,调用余额系统,将金额返回到用户账户。

2.红包 Redis 设计

抢红包有如下规则:

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

设计三种数据类型

1.运营预分配红包列表

电商系统中红包活动设计_redis_03


队列元素 json 数据格式

{
//红包编号
redPacketId : '365628617880842241'
//红包金额
amount : '12.21'
}

2.用户红包领取记录列表

电商系统中红包活动设计_redis_04


队列元素 json 数据格式:

{
//红包编号
redPacketId : '365628617880842241'
//红包金额
amount : '12.21',
//用户编号
userId : '265628617882842248'
}

3.用户红包防重 Hash 表

电商系统中红包活动设计_java_05


抢红包 Redis 操作流程 :

  1. 通过 hexist 命令判断红包领取记录防重 Hash 表中用户是否领取过红包 ,若用户未领取过红包,流程继续;
  2. 从运营预分配红包列表 rpop 出一条红包数据 ;
  3. 操作红包领取记录防重 Hash 表 ,调用 HSET 命令存储用户领取记录;
  4. 将红包领取信息 lpush 进入用户红包领取记录列表。

抢红包的过程 ,需要重点关注如下几点 :

  1. 执行多个命令,是否可以保证原子性 , 若一个命令执行失败,是否可以回滚;
  2. 在执行过程中,高并发场景下,是否可以保持隔离性;
  3. 后面的步骤依赖前面步骤的结果。

Redis 支持两种模式 : 事务模式 和 Lua 脚本,接下来,我们一一展开。

3.事务原理

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

事务包含三个阶段:

  1. 事务开启,使用 MULTI , 该命令标志着执行该命令的客户端从非事务状态切换至事务状态 ;
  2. 命令入队,MULTI 开启事务之后,客户端的命令并不会被立即执行,而是放入一个事务队列 ;
  3. 执行事务或者丢弃。如果收到 EXEC 的命令,事务队列里的命令将会被执行 ,如果是 DISCARD 则事务被丢弃。

redis> MULTI 
OK
redis> SET msg "hello world"
QUEUED
redis> GET msg
QUEUED
redis> EXEC
1) OK
1)

​在事务执行 EXEC 命令之前 ,Redis key 依然可以被修改。​

在事务开启之前,我们可以 watch 命令监听 Redis key 。在事务执行之前,我们修改 key 值 ,事务执行失败,返回 nil 。

电商系统中红包活动设计_微服务_06

通过上面的例子,watch 命令可以实现类似​​乐观锁​​的效果 。

4.事务的ACID

4.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 不会终止其它命令的执行,事务也不会因为某个命令执行失败而回滚 。

  1. 命令入队时报错, 会放弃事务执行,保证原子性;
  2. 命令入队时正常,执行 EXEC 命令后报错,不保证原子性;

Redis 事务在​​特定条件​​下,才具备一定的原子性 。

4.2 隔离性

数据库的隔离性是指:数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。

事务隔离分为不同级别 ,分别是:

  • 未提交读(read uncommitted)
  • 提交读(read committed)
  • 可重复读(repeatable read)
  • 串行化(serializable)

首先,需要明确一点:Redis 并没有事务隔离级别的概念。这里我们讨论 Redis 的​​隔离性​​是指:并发场景下,事务之间是否可以做到互不干扰。

我们可以将事务执行可以分为 ​​EXEC 命令执行前​​​和​​ EXEC 命令执行后​​两个阶段,分开讨论。

  1. EXEC 命令执行前
    在事务原理这一小节,我们发现在事务执行之前 ,Redis key 依然可以被修改。此时,可以使用 WATCH 机制来实现乐观锁的效果。
  2. EXEC 命令执行后
    因为 Redis 是单线程执行操作命令, EXEC 命令执行后,Redis 会保证命令队列中的所有命令执行完 。 这样就可以保证事务的隔离性。

4.3 持久性

数据库的持久性是指 :事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

Redis 的​​数据是否持久化​​​取决于 Redis 的​​持久化配置模式 ​​。

  • 没有配置 RDB 或者 AOF ,事务的持久性无法保证;
  • 使用了 RDB模式,在一个事务执行后,下一次的 RDB 快照还未执行前,如果发生了实例宕机,事务的持久性同样无法保证;
  • 使用了 AOF 模式;AOF 模式的三种配置选项 no 、everysec 都会存在数据丢失的情况 。always 可以 - 保证事务的持久性,但因为性能太差,在生产环境一般不推荐使用。

redis 事务的持久性是​​无法保证​​的 。

4.4 一致性

如何理解数据库的内部一致性和外部一致性

​事务的一致性和预先定义的约束有关,保证了约束即保证了一致性​

Redis 的事务一致性是指:Redis 事务在执行过程中符合数据库的约束,没有包含非法或者无效的错误数据。

​在一致性的核心是约束的语意下,Redis 的事务可以保证一致性。​

如何保证一致性:

  1. 保证原子性,持久性和隔离性,如果这些特征都无法保证,那么事务的一致性也无法保证;
  2. 数据库本身的约束,比如字符串长度不能超过列的限制或者唯一性约束;
  3. 业务层面同样需要进行保障 。

4.5 总结

我们通常称 Redis 为内存数据库 , 不同于传统的关系数据库,为了提供了更高的性能,更快的写入速度,在设计和实现层面做了一些平衡,并不能完全支持事务的 ACID。

Redis 的事务具备如下特点:

  1. 保证隔离性;
  2. 无法保证持久性;
  3. 具备了一定的原子性,但不支持回滚;
  4. 一致性的概念有分歧,假设在一致性的核心是约束的语意下,Redis 的事务可以保证一致性。

另外,在抢红包的场景下, 因为每个步骤需要依赖上一个步骤返回的结果,需要通过 watch 来实现乐观锁 ,从工程角度来看, Redis 事务并不适合该业务场景。

5.Lua 脚本

电商系统中红包活动设计_java_07

Lua 由标准 C 编写而成,代码简洁优美,几乎在所有操作系统和平台上都可以编译,运行。

使用 Lua 脚本的好处 :

  1. 减少网络开销。将多个请求通过脚本的形式一次发送,减少网络时延。
  2. 原子操作。Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。
  3. 复用。客户端发送的脚本会永久存在 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.1 EVAL

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"

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 world"

5.2 EVALSHA

使用 EVAL 命令每次请求都需要传输 Lua 脚本 ,若 Lua 脚本过长,不仅会消耗网络带宽,而且也会对 Redis 的性能造成一定的影响。

思路是先将 ​​Lua 脚本先缓存​​​起来 , 返回给客户端 Lua 脚本的 ​​sha1 摘要​​。 客户端存储脚本的 sha1 摘要 ,每次请求执行 EVALSHA 命令即可。

电商系统中红包活动设计_spring_08

redis> SCRIPT LOAD "return 'hello world'"
"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"
redis> EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0
"hello world"

5.3 事务 VS Lua 脚本

Redis 中的脚本本身就是一种事务

Lua 脚本是另一种形式的事务,他具备一定的原子性,但脚本报错的情况下,事务并不会回滚。Lua 脚本可以保证隔离性,而且可以完美的支持后面的步骤依赖前面步骤的结果。

​Lua 脚本是抢红包场景最优的解决方案。​

  • 为了避免 Redis 阻塞,Lua 脚本业务逻辑不能过于复杂和耗时;
  • 仔细检查和测试 Lua 脚本 ,因为执行 Lua 脚本具备一定的原子性,不支持回滚。

6.实战准备

电商系统中红包活动设计_微服务_09


创建一个 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 参数:需要定位哪一个节点执行 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(start+1, end);
}
int result = CRC16.crc16(key.getBytes()) % MAX_SLOT;
log.debug("slot {} for {}", result, key);
return result;
}

7.抢红包脚本

用户抢红包成功

{
"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);

从 Redis 3.2 开始,内置了 Lua debugger(简称LDB), 可以使用 Lua debugger 对 Lua 脚本进行调试。

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 调用用户余额系统
// 返回消费成功
return RedisConsumeAction.CommitMessage;
}catch (Exception e) {
logger.error("userAmountService invoke error:", e);
// 消费失败,执行重试操作
return RedisConsumeAction.ReconsumeLater;
}
}
}


举报

相关推荐

0 条评论