搬砖攻略
当前位置: 九游会俱乐部-九游会官网登录入口 > 行业资讯 > 搬砖攻略2018年,王思聪的冲顶大会,西瓜视频的百万英雄,再到映客的芝士超人,直播作答火爆全网。
我服务的一家电商公司也加入了这次热潮,技术团队研发了直播作答功能。作答结束之后,提成会以提成雨的形式落下,用户点击屏幕上落下的提成,若抢到提成,提成会以现金的形式进入用户账户。
提成雨是一种典型的高mammalian情景,短时间内有海量请求访问服务端,技术团队为了让系统运行顺畅,抢提成采用了基于 redis lua 脚本的设计方案。
红包雨中,redis 和 lua 的邂逅-九游会俱乐部
我分析下抢提成的整体程序 :
运营系统配置提成雨活动总金额以及提成个数,提前计算出各个提成的金额并存储到 redis 中;抢提成雨界面,用户点击屏幕上落下的提成,发起抢提成请求;tcp 网关接收抢提成请求后,调用作答系统抢提成 dubbo 服务,抢提成服务本质上就是继续执行 lua 脚本,将结果通过 tcp 网关返回给前端;用户若抢到提成,异步任务会从 redis 中 获取抢得的提成信息,调用额度系统,将金额返回到用户账户。抢提成有如下表所示规则:
同一活动,用户只能抢提成一次 ;提成数量有限,一种提成只能被一种用户抢到。如下表所示图,我设计三种信息类型:
运营预分配提成条目 ;数组元素 json 信息格式 :
{ //提成编号 redpacketid : 365628617880842241 //提成金额 amount : 12.21 } 用户提成申领纪录条目;数组元素 json 信息格式:
{ //提成编号 redpacketid : 365628617880842241 //提成金额 amount : 12.21, //用户编号 userid : 265628617882842248 } 用户提成防重 hash 表;抢提成 redis 操作方式程序 :
通过 hexist 指示判断提成申领纪录防重 hash 表中用户与否申领过提成 ,若用户未申领过提成,程序继续;从运营预分配提成条目 rpop 出一条提成信息 ;操作方式提成申领纪录防重 hash 表 ,调用 hset 指示存储用户申领纪录;将提成申领信息 lpush 进入用户提成申领纪录条目。抢提成的过程 ,需要重点关注如下表所示几点 :
继续执行多个指示,与否能确保氢原子性 , 若一种指示继续执行失败,与否能初始化;在继续执行过程中,高mammalian情景下,与否能保持隔绝性;后面的步骤依赖前面步骤的结果。redis 支持两种方式 : 外交事务方式 和 lua 脚本,接下来,我一一展开。
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 指示实现乐观锁的效果。
氢原子性是指:一种外交事务中的所有操作方式,或是全部完成,或是全部不完成,不能结束在中间某个环节。外交事务在继续执行过程中发生严重错误,会被初始化到外交事务开始前的状态,就像那个外交事务从来没有继续执行过一样。
第一种范例:
在继续执行 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 外交事务在特定条件下,才具备一定的氢原子性 。
资料库的隔绝性是指:资料库允许多个mammalian外交事务同时对其信息进行读写和修正的能力,隔绝性能防止多个外交事务mammalian继续执行时由于交叉继续执行而导致信息的不一致。
外交事务隔绝分为不同级别 ,分别是:
未提交读(read uncommitted)提交读(read committed)可重复读(repeatable read)串行化(serializable)首先,需要明确一点:redis 并没有外交事务隔绝级别的概念。这里我讨论 redis 的隔绝性是指:mammalian情景下,外交事务之间与否能做到互不干扰。
我能将外交事务继续执行能分为 exec 指示继续执行前和 exec 指示继续执行后两个阶段,分开讨论。
exec 指示继续执行前在外交事务原理这一小节,我发现在外交事务继续执行以后 ,redis key 依然能被修正。此时,能采用 watch 机制来实现乐观锁的效果。
exec 指示继续执行后因为 redis 是单线程继续执行操作方式指示, exec 指示继续执行后,redis 会确保指示数组中的所有指示继续执行完 。 这样就能确保外交事务的隔绝性。
资料库的无毒性是指 :外交事务处理结束后,对信息的修正就是永久的,即便系统故障也不能丢失。
redis 的信息与否长久化取决于 redis 的长久化配置方式 。
没有配置 rdb 或是 aof ,外交事务的无毒性无法确保;采用了 rdb方式,在一种外交事务继续执行后,下一次的 rdb 快照还未继续执行前,如果发生了实例宕机,外交事务的无毒性同样无法确保;采用了 aof 方式;aof 方式的三种配置选项 no 、everysec 都会存在信息丢失的情况 。always 能确保外交事务的无毒性,但因为性能太差,在生产环境一般不推荐采用。综上,redis 外交事务的无毒性是无法确保的 。
连续性的概念一直很让人困惑,在我搜寻的资料里,有两类不同的表述。
维基百科我先看下维基百科上连续性的表述:
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 ”是如此的好用,以至于已经融化在大部分用户的血液里了,用户会在表单设计的时候自觉的加上需要的不动点,资料库也会严格的继续执行那个不动点。
所以外交事务的连续性和预先表述的约束有关,确保了约束即确保了连续性。
写到这里可能大家还是有点模糊,我举经典转账的案例。
我打开一种外交事务,张三和李四账号上的初始额度都是1000元,并且额度字段没有任何约束。张三给李四转账1200元。张三的额度更新为 -200 , 李四的额度更新为2200。
从应用层面来看,那个外交事务明显不合法,因为现实情景中,用户额度不可能小于 0 , 但是它完全遵循资料库的约束,所以从资料库层面来看,那个外交事务依然确保了连续性。
我细细品一品这句话: this prevents database corruption by an illegal transaction, but does not guarantee that a transaction is correct。
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 。
很多时候,我一直在纠结的连续性,其实就是指符合现实世界的连续性,现实世界的连续性才是外交事务追求的最终目标。
为了实现现实世界的连续性,需要满足如下表所示几点:
确保氢原子性,无毒性和隔绝性,如果这些特征都无法确保,那么外交事务的连续性也无法确保;资料库本身的约束,比如字符串长度不能超过列的限制或是唯一性约束;业务层面同样需要进行保障 。我通常称 redis 为内存资料库 , 不同于传统的关系资料库,为了提供了更高的性能,更快的写入速度,在设计和实现层面做了一些平衡,并不能完全支持外交事务的 acid。
redis 的外交事务具备如下表所示特点:
确保隔绝性;无法确保无毒性;具备了一定的氢原子性,但不支持初始化;连续性的概念有分歧,假设在连续性的核心是约束的语意下,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会将整个脚本作为一种整体继续执行,中间不能被其他指示插入。复用。应用程序发送的脚本会永久存在 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 添加到脚本缓存中,但并不立即继续执行那个脚本。
指示格式:
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 脚本是抢提成情景最优的九游会官网登录入口的解决方案。
我选择 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脚本编写过程中,难免会有疏漏,如何进行调试?
个人建议两种方式结合进行。
编写 junit 测试用例 ;从 redis 3.2 开始,内置了 lua debugger(简称ldb), 能采用 lua debugger 对 lua 脚本进行调试。在 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; } } }学习 redis lua 过程中,我查询了很多资料,一种范例一种范例的实践,收获良多。
"纸上得来终觉浅, 绝知此事要躬行" 。
非常坦诚的讲 , 写这篇文章以后,我对 redis lua 有很多想当然的理解,在学习过程中频频被打脸。我告诫我自己:面对自己不熟悉的知识点时,还是应该保持谦卑的心态。
同时,没有任何一项技术是完美的,在设计和编码之间,有这样或是那样的平衡,这才是真实的世界。