2018年,王思聪的冲顶大会,西瓜视频的百万英雄,再到映客的芝士超人,直播答题火爆全网。
我服务的一家电商公司也加入了这次热潮,技术团队研发了直播答题功能。答题结束之后,红包会以红包雨的形式落下,用户点击屏幕上落下的红包,若抢到红包,红包会以现金的形式进入用户账户。
红包雨是一个典型的高并发场景,短时间内有海量请求访问服务端,技术团队为了让系统运行顺畅,抢红包采用了基于 Redis + Lua 脚本的设计方案。
我们分析下抢红包的整体流程 :
抢红包有如下规则:
如下图,我们设计三种数据类型:
1 . 运营预分配红包列表 ;
队列元素 json 数据格式 :
{
//红包编号
redPacketId : '365628617880842241'
//红包金额
amount : '12.21'
}
2 . 用户红包领取记录列表;
队列元素 json 数据格式:
{
//红包编号
redPacketId : '365628617880842241'
//红包金额
amount : '12.21',
//用户编号
userId : '265628617882842248'
}
3 . 用户红包防重 Hash 表;
抢红包 Redis 操作流程 :
抢红包的过程 ,需要重点关注如下几点 :
Redis 支持两种模式 : 事务模式 和 Lua 脚本,接下来,我们一一展开。
Redis 的事务包含如下命令:
序号 | 命令及描述 |
---|---|
1 | MULTI 标记一个事务块的开始。 |
2 | EXEC 执行所有事务块内的命令。 |
3 | DISCARD 取消事务,放弃执行事务块内的所有命令。 |
4 | WATCH key [key ...] 监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。 |
5 | UNWATCH 取消 WATCH 命令对所有 key 的监视。 |
事务包含三个阶段:
下面展示一个事务的例子。
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 命令可以实现类似乐观锁的效果 。
原子性是指:一个事务中的所有操作,或者全部完成,或者全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
第一个例子:
在执行 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 事务原子性的理解如下:
也就是:Redis 事务在特定条件下,才具备一定的原子性 。
数据库的隔离性是指:数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。
事务隔离分为不同级别 ,分别是:
首先,需要明确一点:Redis 并没有事务隔离级别的概念。这里我们讨论 Redis 的隔离性是指:并发场景下,事务之间是否可以做到互不干扰。
我们可以将事务执行可以分为 EXEC 命令执行前和 EXEC 命令执行后两个阶段,分开讨论。
1 . EXEC 命令执行前
在事务原理这一小节,我们发现在事务执行之前 ,Redis key 依然可以被修改。此时,可以使用 WATCH 机制来实现乐观锁的效果。
2 . EXEC 命令执行后
因为 Redis 是单线程执行操作命令, EXEC 命令执行后,Redis 会保证命令队列中的所有命令执行完 。 这样就可以保证事务的隔离性。
数据库的持久性是指 :事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
Redis 的数据是否持久化取决于 Redis 的持久化配置模式 。
综上,redis 事务的持久性是无法保证的 。
一致性的概念一直很让人困惑,在我搜寻的资料里,有两类不同的定义。
1 . 维基百科
我们先看下维基百科上一致性的定义:
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 事务在执行过程中符合数据库的约束,没有包含非法或者无效的错误数据。
我们分三种异常场景分别讨论:
综上所述,在一致性的核心是约束的语意下,Redis 的事务可以保证一致性。
2 . 《设计数据密集型应用》
这本书是分布式系统入门的神书。在事务这一章节有一段关于 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 。
很多时候,我们一直在纠结的一致性,其实就是指符合现实世界的一致性,现实世界的一致性才是事务追求的最终目标。
为了实现现实世界的一致性,需要满足如下几点:
我们通常称 Redis 为内存数据库 , 不同于传统的关系数据库,为了提供了更高的性能,更快的写入速度,在设计和实现层面做了一些平衡,并不能完全支持事务的 ACID。
Redis 的事务具备如下特点:
另外,在抢红包的场景下, 因为每个步骤需要依赖上一个步骤返回的结果,需要通过 watch 来实现乐观锁 ,从工程角度来看, Redis 事务并不适合该业务场景。
“ 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 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 添加到脚本缓存中,但并不立即执行这个脚本。 |
命令格式:
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"
使用 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"
从定义上来说, Redis 中的脚本本身就是一种事务, 所以任何在事务里可以完成的事, 在脚本里面也能完成。 并且一般来说, 使用脚本要来得更简单,并且速度更快。
因为脚本功能是 Redis 2.6 才引入的, 而事务功能则更早之前就存在了, 所以 Redis 才会同时存在两种处理事务的方法。
不过我们并不打算在短时间内就移除事务功能, 因为事务提供了一种即使不使用脚本, 也可以避免竞争条件的方法, 而且事务本身的实现并不复杂。
-- https://redis.io/
Lua 脚本是另一种形式的事务,他具备一定的原子性,但脚本报错的情况下,事务并不会回滚。Lua 脚本可以保证隔离性,而且可以完美的支持后面的步骤依赖前面步骤的结果。
综上,Lua 脚本是抢红包场景最优的解决方案。
但在编写 Lua 脚本时,要注意如下两点:
我选择 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;
}
客户端执行 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
脚本编写过程中,难免会有疏漏,如何进行调试?
个人建议两种方式结合进行。
LDB
), 可以使用 Lua debugger 对 Lua 脚本进行调试。在 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;
}
}
}
"纸上得来终觉浅, 绝知此事要躬行" 。
学习 Redis Lua 过程中,查询了很多资料,一个例子一个例子的实践,收获良多。
非常坦诚的讲 , 写这篇文章之前,我对 Redis Lua 有很多想当然的理解,比如 Redis 的事务不能回滚就让我惊讶不已。
所以当面对自己不熟悉的知识点时,不要轻易下结论,以谦卑的心态去学习,才是一个工程师需要的心态。
同时,没有任何一项技术是完美的,在设计和编码之间,有这样或者那样的平衡,这才是真实的世界。
本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/maeXQn_SJJKQ0U6yNleVEA
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。