开发者学堂课程【Redis 入门及实战:Redis 的高并发实战:抢购系统】学习笔记,与课程紧密联系,让用户快速学习知识。
课程地址:https://developer.aliyun.com/learning/course/777/detail/13653
Redis 的高并发实战:抢购系统
内容介绍:
一、 IO 模型和问题
二、 资源竞争与分布式锁
三、 Redis 抢购系统实例
一、IO 模型和问题
(一) Run-to-Completion in a solo thread
(在一个单独线程中运行到完成)
Redis 的社区版 IO 模型比较简单,通常是有一个单独线程去实现所有的命令的解析和处理的。那么有一个比较严重的问题,是当进行一个慢查询的命令时,其的命令都要去排队,那么后面的命令都会被阻塞慢。那么会让使用 ping 命令也被延迟,如果引擎被卡住,则 ping 失败。若一个慢查询超过 30 秒钟之后,就会拖慢 ping 命令,无法去判断服务,此时是不是可用的。
Epoll-wait 向 TCP recvO、Process、TCP sendO 这三部分传输的过程:
1.用户所有的来自不同 client 的请求,实际上在每个 event 到来后,交由后端单线程执行。等每个 event 处理完成后,才处理下一个
2.单线程 run-to-completion 就是没有 dispatcher,没有后端的 multi-worker
Process 双向传导的过程:
如果慢查询诸如 keys、 lrange、 hgetall 等拖慢了一次查询,那么后面的请求就会被拖慢
使用 Sentinel 判活的缺陷∶
ping 命令判活: ping 命令同样收到慢查询影响,如果引擎被卡住,则 ping 失败
duplex Failure : sentinel 由于慢查询切备(备变主)再遇到慢查询则无法继续工作
(二) Make it a cluster
(把变成一个集群)
当把单分片,用多个分片组成一个 cluster 的时候这样的话是一样的问题。
1. 同样的,集群版解决不了单个 DB 被卡住的问题
2.查询空洞:如果用户调用了跨分片的命令,如 mget,访问到出问题的分片,仍会卡住
(三) "Could not get a resource from the pool"
(“无法从池中获取资源”)
客户端一般使用 Redis 的客户端,该客户端会配一个连接池,业务线程去访问的时候,每一个查询会去里面取一个远程连接,然后进行访问。
之所以使用连接池,是因为 Redis 协议不支持连接收敛
Message 没有 ID,所以 Request 和 Response 关联不起来
非常类似 HTTP 1.× 消息
sLow
当 Engine 层出现慢查询,就会让请求返回得慢
很容易让用户把连接池用光
当应用机器特别多的情况,按每个 client 连接池 50 个 max_conn 来算,很容易打到10 K 链接的限制,导致回调速度慢
Redis client
1.每次查询,都要先从连接池拿出一根连接来,当返回后,再放回连接池
2. 如果用户返回的及时,那么连接池一直保有的连接数并不高
但是一旦返回不了,又有新的请求,就只能再 checkout 一根连接
当连接池被 checkout 完,就会爆没有连接的异常:"Could not get a resource from the pool"
(“无法从池中获取资源”)
(四) Redis 2.x/4.x/5.x 版本的线程模型
Redis 2.x/4.x/5.x 版本等,几个比较知名的的版本的模型是没有发生变化过的,就是说所有的业务线程以及所有的读写、处理都在一个主要的 IO 里面去运行。后台有几个 B I O 线程,任务主要是关闭文件以及刷文件等,4.x 之后增加了LAZY_FREE,就是说有些大 K,可以一步去释放掉。
在 Redis 2.x 上会遇到一些比如淘汰或者过期删一些比较大的 K 的时候服务会卡顿,所以建议不用 4.x 以下的服务端。
(五) Redis 5.x 版本的火焰图
简单的性能分析,一个 IO 线程用一些简单命令,比如 O E 的命令去运行的时候,发现所有的消耗基本上在该网络的 IO 上。
图前两部分是面临处理,然后中间的这一块儿小的是读,最右侧是写占 1.16%。这个占比能够知道性能,基本上都被耗在了网络 I O 上。
(六) Redis 6.x 版本的线程模型
Redis 6.x 版本是改进的模型,这个模型可以在主线程读完,可读事件出发之后,可以把这个读任务去委托给 I O 现场来处理。等全读完之后,返回这个结果,再进行处理,也可以分发给 LAZY_FREE 来写,这样可以提升性能,但是引擎还是只有一个。
有一些 O E 的命令,比如简单的读写,效果是非常高的,但如果命令本身很杂,因为 D B 的还是只有一个线程,效果是比较差的。这样的处理还有个问题是,比如说把这个读任务委托之后,要等返回,写也要等返回,所以主线程实际上有很大的时间是在等的,这段时间,也是没有办法提供服务的。所以说这个模型还是有一些能够提升的空间的。
(七) 阿里云 Redis 企业版( Tair 增强性能)的线程模型
云上的企业版的主线程,只负责命令处理,所有的读写处理由 L O 线程全权负责,连接不再是永远都属于主线程,当一个客户端,连进来之后,这个客户端就直接交给了其的 I O 线程。从此这个客户端可读可写的所有的事件,不需要去理会。之后有命令的话会转发给主线程来处理,处理之后通过通知方式,转给其的线程,进行写。这样可以最大程度的把主线程的等待时间给省掉,性能会更进一步的提升。当然缺点是一样的,因为毕竟还是只有一个线程在处理。所以对于 OE 的命令提升效果非常的理想,但对于这种本身比较耗 cpu 的命令的效果并不理想。
(八) 性能对比测试
左边的灰色的是 Redis.5X,右边是这个性能增强版,然后 Redis.6X 大概在这两个之间的一个范围之内,当然这个命令是 OE 的命令,本身不是耗 cpu 的,瓶颈是在 I O 上的,所以效果非常理想。
最坏情况下,假设命令本身是特别耗 cpu 的,那么这两个值会无限的逼近,直到两个值会齐平。
二、资源竞争与分布式锁
(一) CAS/CAD 是对 Redis String 的扩展
分布式锁实现的问题
续约(使用 CAS )
详细档: https://help.aliyun.com/document_detail/146758.html
CAS/CAD 以及后续提到的 TairString 以 module 形式开源:https://github.com/alibaba/TairString
设置
SET resource_1 random_value NX EX5
删除
CAD resource_1 my_random_value
续约(一般有一个 Watchdog 机制去续约)
CAS resource_1 my_random_value my_random_value Ex 10
分布式锁的实现,在 Redis 上的实现是比较多的,网上有很多的教程来帮助去实现一个分布式锁。比如,Redis 本身的字符串的写命令,有一个参数叫 N X,就是说这个字符串儿不存在的时候,可以写那这样就是一个天然的一个加锁场景,因为本身就支持这样的一个特性,所以去加锁非常容易。然后给这个资源一个随机值,然后去写带一个 N X 就可以了,要带一个过期的原因,比如业务机器加上锁之后,如果因为某个原因被下线掉了,如果这个锁没有释放掉,那么使得这个锁永远无法被解除,所以需要一个过期时间来保证业务机器,有故障之后这个锁一样是会被释放掉的。5 秒不是一个固定的过期时间,过期时间要看机器。
删除的时候比较麻烦,比如这个机器加上锁之后突然遇到了一些情况,比如卡顿或者某种原因失联了。失联之后,因为这个锁过了五秒,已经失效了,然后其的机器加上了这个锁,若之前那个机器又可用了,但是处理完之后,之前那个机器掉了一个 dell,K 删掉了。这就使得删掉了本来并不属于这个机器的锁。所以这个删除需要一个判断,就是说当这个 value 等于之前写的 value 的时候,才可以删掉。
续约是 CAS。因为去加一个锁的时候给了一个过期时间,比如说五秒,那如果说业务会有一个机制去续约,比如说发现这个事务已经过了三秒了,还没执行完,那么续约可以把这个时间延长。这个续约跟删除是一样的道理,不能直接去删需要符合条件的时候,才能续。
这个分布式组并不是特别可靠的,比如说之前加上锁之后失联了,锁过期的被其机器持有了,但是突然又可用,这个时候代码上是不会去判断的,以至于包括其的分失所,并不是 100% 可靠的。
#删除
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.cal1 (""del" , KEYS[1])
else
return e
end
#续租
if redis.call("get", KEYS[1])== ARGV[1] then
return redis.call ("expire" , KEYS[1],ARGV[2])
else
return e
end
脚本中每次调用会改变的值一定要通过参数传递,因为只要脚本不相同,Redis 就会缓存这个脚本,截止目前社区 6.2 版本仍然没有限制这个缓存大小的配置,也没有逐出策略,执行 script flush 命令清理缓存时也是同步操作,—定要避免脚本缓存过大。
使用方式也是先执行 script load 命令加载 Lua 到 Redis 中,后续使用 evalsha 命令携带参数调用脚本,—来减少网络带宽,二来避免每次载入不同的脚本。需要注意的是 evalsha 可能返回脚本不存在,需要处理这个错误,重新 script load 解决。
其实由于 Redis 本身的数据一致性保证以及宕机恢复能力上看,分布式锁并不是特别可靠的
Redis 作者提出来 Redlock 这个算法,但是争议也颇多︰参考资料 1 参考资料 2 参考资料 3
如果对可靠性要求更高的话,可以考虑 Zookeeper 等其方案(可靠性++,性能-- )
或者,使用消息队列串行化这个需要互斥的操作,当然这个要根据业务系统去设计
(二) Redis LUA
不是特别建议使用 Redis LUA,Redis LUA 的执行,有很多的转换,如解析、翻译,然后再去执行整个过程。使用 Redis LUA 等于是在 C 调用 Redis LUA,然后在Redis LUA 里面去调用 C。这个返回值会有两次的转换,先从 Redis 的这个协议返回值转成 Redis LUA 对象,再由 Redis LUA 对象转成 C 的,这个数据再返回,两次转换。有很多这种 Redis LUA 的解析和处理,包括的这个内存占用,所以会比一般的命令执行要慢。
如果要使用 Redis LUA,最好只写一些比较简单的 excel 判断,尽量避免循环,尽量避免一些比较重的操作,尽量避免一些大的这种数据的访问和获取。因为引擎只有一个线程,当这个 cpu 被耗在 Redis LUA 上的时候,就只有更少的 cpu 来处理业务的命令,所以要慎用 Redis LUA。
The LUA Iceberg inside Redis
1. 脚本的 compile-load-run-unload 非常耗费 CPU
2. 整个 Lua 相当于把复杂事务推送到 Redis 中执行,如果稍有不慎内存会爆,引擎算力耗光后挂住 Redis
Script + EVALSHA
1. 可以先把脚本在 Redis 中预编译和加载(不会 unload 和 clean ) ,使用 EVALSHA执行
2. 会比纯 EVAL省 CPU,但是 Redis 重启/切换/变配 code cache 会失效,需要 reload
3.仍是缺陷方案。建议使用复杂数据结构,或者 module 来取代 Lua
对于 JIT 技术在存储引擎中而言,“EVAL is evil”,尽量避免使用 Lua 耗费内存和计算资源(省事不省心)
某些 SDK (如 Redisson )很多高级实现都内置使用 Lua,开发者可能莫名走入 CPU运算风暴中,须谨慎
三、Redis 抢购系统实例
(一) 抢购/秒杀场景的特点
秒杀活动对稀缺或者特价的商品进行定时定量售卖,吸引成大量的消费者进行抢购,但又只有少部分消费者可以下单成功。因此,秒杀活动将在较短时间内产生比平时大数十倍,上百倍的页面访问流量和下单请求流量。
秒杀活动可以分为 3 个阶段︰
秒杀前:用户不断刷新商品详情页,页面请求达到瞬时峰值
秒杀开始:用户点击秒杀按钮,下单请求达到瞬时峰值
秒杀后:少部分成功下单的用户不断刷新订单或者退单,大部分用户继续刷新商品详情页等待机会
(二) 抢购/秒杀场景的一般方法
抢购/秒杀其实主要解决的就是热点数据高并发读写的问题
1. 抢购/秒杀的过程就是一个不断对请求“剪枝”的过程︰
尽可能减少用户到应用服务端的读写请求(客户端拦截一部分)
应用到达服务端的请求要减少对后端存储系统的访问(服务端 LocalCache 拦截一部分)
需要请求存储系统的请求尽可能减少对数据库的访问(使用Redis拦截绝大多数)
最终的请求到达数据库(也可以消息队列再排个队兜底,万一后端存储系统无响应,应用服务端要有兜底方案)
2. 基本原则:
数据少(静态化、CDN、前端资源合并,页面动静分离,LocalCache )
路径短(前端到末端的路径尽可能短、尽量减少对不同系统的依赖,支持限流降级)
禁单点(应用服务无状态化水平扩展、存储服务避免热点)
3. 扣减库存的时机:
下单减库存(避免恶意下单不付款、保证大并发请求时库存数据不能为负数)
付款减库存(下单成功付不了款影响体验)
预扣库存超时释放(可以结合 Quartz 等框架做,还要做好安全和反作弊)
4. Redis 的一般实现方案
String 结构
直接使用 incr/decr/incrby/decrby
,注意 Redis 目前不支持上下界的限制
如果要避免负数或者有关联关系的库存 sku 扣减只能使用 Lua
List 结构
每个商品是一个 List,每个 Node 是一个库存单位
扣减库存使用 lpop/rpop
命令,直到返回 nil ( key not exist ).
Set/Hash 结构
一般用来去重,限制用户只能购买指定个数 ( hincrby 计数,hget 判断已购买数量)
注意要把用户 UID 映射到多个 key 来读写,一定不能都放到某一个 key 里(热点)
业务场景允许的情况下,热点商品可以使用多个 key : key_1 , key_2 , key_3 ...
随机选择
用户 UID 做映射(不同的用户等级也可以设置不同的库存量)
(三) TairString: 支持高并发 CAS 的 String
1. 携带 Version 的 String
保证并发更新的原子性
通过 Version 来实现更新,乐观锁
不能与 Redis 的普通 String 混用
应用:先来去 exSet get 一个值会返回的 Version,然后基于这个 Version 做一些操作,当更新的时候,可以带上之前那个 Version ,如果一致,那么更新,否则重新去读和改,再更新实现一个 exCAS 操作在服务端就是这是一个乐观锁。
对于这个场景,可以进一步优化提供一个叫 exCAS 的操作,这个 exCAS 跟 exSet是一样的,但遇到了 Version 冲突之后,不光是返回一个 Version 不一致的错误,并且会顺带的返回新的 Redis 跟新的 Version。
先去 exSet get 之后,用 exCAS 进行操作,如果失败了再改 exCAS 可以降低对服务端的访问量。
更多的语义
exIncr/exIncrBy
:抢购/秒(有上下界)
exSet -> exCAS
∶减少网络交互
详细文档:https://help.aliyun.com/document detail/147094.html
以 Module 形式开源: https://github.com/alibaba/TairString
(四) String 和 exString 原子计数的对比
EXINCRBY
语法及复杂度;
ExiNCReY | ExmNCRBY nkey crumo [EX time] [iPx time] [EXAT time] [EXAT time] [PXAT time] [0NK 1 X0) IVERwersiom | ABS versioni]l [MtIN minvall][MX maomval [NONEGATNME][WITHVERSIDN]
时间复杂度: 0(1)
命令插述:
对 Key 做自增自减操作。num 的范围为 long.
参数播述:
key: 定位 TairString 的键
num: TairString 自增的做值。必须为自然数
EX: 秒级相对过期时间
EXAT: 秒级绝对过期时网
P火: 毫秒级相对过期时间
PXAT: 毫秒级绝对过期时同
NX: 当数据不存在时写入
XX: 当数据存在时写入
VER: 版本号,如果数据存在,和已经存在的数据的版本号做比较。如果相等,写入并版本号加 1,如果不相等,返回出错;如果数据不存在,忽略传入的版本号。写入成动之后,数据版本号变为 1
ABS: 绝对版本号。不论数据是否存在,覆盖为指定的版本号
MIN: TairString 值的最小值
MAX: TairString 值的最大值
NONEGATIVE: 设置后,若 inerby 的结果小于 0 则将 value 置为 0
WITHVERSION: 额外返回一个 version
返回值:
返回类整: Long
成功:引擎的 value 值
String 方式 INCRBY,没有上下界;exString 方式是 EXINCRBY,提供了各种各样的参数跟上下界,比如直接指定最小是 0,当等于 0 时就不能再减了。另外还支持过期,比如某个商品只能在某个时间段抢购,过了这个时间点之后失效。业务系统也会做一些限制,缓存可以做限制,过了时间点把这个缓存清理掉。如果库存数量有限,比如如果没人购买,商品过 10 秒钟消掉;如果有人一直在买,这个缓存一直续期,可以在 EXINCRBY 里面带一个参数,每调用一次 INCRBY 或者 API 就会给它续期,提升命中率。
计数器过期时间可以做什么?
1某件商品指定在某个时间段抢购,需要在某个时间后库存失效
2. 缓存的库存如果有限,没人购买的商品就过期删除,有人购买过就自动再续期一段时间(提升缓存命中率)
#String
if tonumber(redis.cal1 ("get"", KEYS[1]))> e then
return redis.call("decrby-, KEYS[1],KEYS[2])
else
return redis.error_reply ( overflow"")
end
a TairString
exincrby k -1 min e
注意 String 和 TairString 类型不同,不能混用 API