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 的事务蕴含如下命令:
序号 | 命令及形容 |
---|---|
1 | MULTI 标记一个事务块的开始。 |
2 | EXEC 执行所有事务块内的命令。 |
3 | DISCARD 勾销事务,放弃执行事务块内的所有命令。 |
4 | WATCH key [key …] 监督一个(或多个) key,如果在事务执行之前这个(或这些) key 被其余命令所改变,那么事务将被打断。 |
5 | UNWATCH 勾销 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 事务的 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 不会终止其它命令的执行,事务也不会因为某个命令执行失败而回滚。
综上,我对 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 网关,比方 Openresty,Kong 都能够看到 Lua 脚本的身影。
从 Redis 2.6.0 版本开始,Redis 内置的 Lua 解释器,能够实现在 Redis 中运行 Lua 脚本。
应用 Lua 脚本的益处:
- 缩小网络开销。将多个申请通过脚本的模式一次发送,缩小网络时延。
- 原子操作。Redis 会将整个脚本作为一个整体执行,两头不会被其余命令插入。
- 复用。客户端发送的脚本会永恒存在 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 ...]
,是要操作的键,能够指定多个,在 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 world"
5.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 world"
5.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,
List<Object> keys,
Object... values);
// 通过 sha1 摘要执行 Lua 脚本
Object evalSha(String shardingkey,
String shaDigest,
List<Object> 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(start+1, 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;
}
}
}
9 写到最初
“纸上得来终觉浅, 绝知此事要躬行”。
学习 Redis Lua 过程中,查问了很多材料,一个例子一个例子的实际,播种良多。
十分坦诚的讲 , 写这篇文章之前,我对 Redis Lua 有很多 想当然 的了解,比方 Redis 的事务不能回滚就让我诧异不已。
所以当面对本人不相熟的知识点时,不要轻易下结论,以谦卑的心态去学习,才是一个工程师须要的心态。
同时,没有任何一项技术是完满的,在设计和编码之间,有这样或者那样的均衡,这才是实在的世界。
如果我的文章对你有所帮忙,还请帮忙 点赞、在看、转发 一下,你的反对会激励我输入更高质量的文章,非常感谢!