44讲记一次双十一抢购性能瓶颈调优 - 图144讲记⼀次双⼗⼀抢购性能瓶颈调优

44讲记一次双十一抢购性能瓶颈调优 - 图2你好,我是刘超。今天我们来聊聊双⼗⼀的那些事⼉,基于场景⽐较复杂,这⼀讲的出发点主要是盘点各个业务中⾼频出现的性能瓶颈,给出相应的优化⽅案,但优化⽅案并没有⼀⼀展开,深度讲解其具体实现。你可以结合⾃⼰在这个专栏的所学和⽇常积累,有针对性地在留⾔区提问,我会⼀⼀解答。下⾯切⼊正题。

每年的双⼗⼀都是很多研发部⻔最头痛的节⽇,由于这个节⽇⽐较特殊,公司⼀般都会准备⼤量的抢购活动,相应的瞬时⾼并发请求对系统来说是个不⼩的考验。

还记得我们公司商城第⼀次做双⼗⼀抢购活动,优惠⼒度特别⼤,购买量也很⼤,提交订单的接⼝TPS⼀度达到了10W。在⾸波抢购时,后台服务监控就已经显示服务器的各项指标都超过了70%,CPU更是⼀直处于400%(4核CPU),数据库磁盘I/O
⼀直处于100%状态。由于瞬时写⼊⽇志量⾮常⼤,导致我们的后台服务监控在短时间内,⽆法实时获取到最新的请求监控数据,此时后台开始出现⼀系列的异常报警。

更严重的系统问题是出现在第⼆波的抢购活动中,由于第⼀波抢购时我们发现后台服务的压⼒⽐较⼤,于是就横向扩容了服务,但却没能缓解服务的压⼒,反⽽在第⼆波抢购中,我们的系统很快就出现了宕机。

这次活动暴露出来的问题很多。⾸先,由于没有限流,超过预期的请求量导致了系统卡顿;其次,基于Redis实现的分布式锁 分发抢购名额的功能抛出了⼤量异常;再次,就是我们误判了横向扩容服务可以起到的作⽤,其实第⼀波抢购的性能瓶颈是在数据库,横向扩容服务反⽽⼜增加了数据库的压⼒,起到了反作⽤;最后,就是在服务挂掉的情况下,丢失了异步处理的业务请求。

接下来我会以上⾯的这个案例为背景,重点讲解抢购业务中的性能瓶颈该如何调优。

抢购业务流程

在进⾏具体的性能问题讨论之前,我们不妨先来了解下⼀个常规的抢购业务流程,这样⽅便我们更好地理解⼀个抢购系统的性能瓶颈以及调优过程。

⽤户登录后会进⼊到商品详情⻚⾯,此时商品购买处于倒计时状态,购买按钮处于置灰状态。
当购买倒计时间结束后,⽤户点击购买商品,此时⽤户需要排队等待获取购买资格,如果没有获取到购买资格,抢购活动结束,反之,则进⼊提交⻚⾯。
⽤户完善订单信息,点击提交订单,此时校验库存,并创建订单,进⼊锁定库存状态,之后,⽤户⽀付订单款。
当⽤户⽀付成功后,第三⽅⽀付平台将产⽣⽀付回调,系统通过回调更新订单状态,并扣除数据库的实际库存,通知⽤户购买成功。
44讲记一次双十一抢购性能瓶颈调优 - 图3

44讲记一次双十一抢购性能瓶颈调优 - 图4

抢购系统中的性能瓶颈

熟悉了⼀个常规的抢购业务流程之后,我们再来看看抢购中都有哪些业务会出现性能瓶颈。

商品详情⻚⾯

如果你有过抢购商品的经验,相信你遇到过这样⼀种情况,在抢购⻢上到来的时候,商品详情⻚⾯⼏乎是⽆法打开的。

这是因为⼤部分⽤户在抢购开始之前,会⼀直疯狂刷新抢购商品⻚⾯,尤其是倒计时⼀分钟内,查看商品详情⻚⾯的请求量会猛增。此时如果商品详情⻚⾯没有做好,就很容易成为整个抢购系统中的第⼀个性能瓶颈。

类似这种问题,我们通常的做法是提前将整个抢购商品⻚⾯⽣成为⼀个静态⻚⾯,并push到CDN节点,并且在浏览器端缓存该⻚⾯的静态资源⽂件,通过 CDN 和浏览器本地缓存这两种缓存静态⻚⾯的⽅式来实现商品详情⻚⾯的优化。

抢购倒计时

在商品详情⻚⾯中,存在⼀个抢购倒计时,这个倒计时是服务端时间的,初始化时间需要从服务端获取,并且在⽤户点击购买时,还需要服务端判断抢购时间是否已经到了。

如果商品详情每次刷新都去后端请求最新的时间,这⽆疑将会把整个后端服务拖垮。我们可以改成初始化时间从客户端获取, 每隔⼀段时间主动去服务端刷新同步⼀次倒计时,这个时间段是随机时间,避免集中请求服务端。这种⽅式可以避免⽤户主动刷新服务端的同步时间接⼝。

获取购买资格

可能你会好奇,在抢购中我们已经通过库存数量限制⽤户了,那为什么会出现⼀个获取购买资格的环节呢?

我们知道,进⼊订单详情⻚⾯后,需要填写相关的订单信息,例如收货地址、联系⽅式等,在这样⼀个过程中,很多⽤户可能还会犹豫,甚⾄放弃购买。如果把这个环节设定为⼀定能购买成功,那我们就只能让同等库存的⽤户进来,⼀旦⽤户放弃购 买,这些商品可能⽆法再次被其他⽤户抢购,会⼤⼤降低商品的抢购销量。

增加购买资格的环节,选择让超过库存的⽤户量进来提交订单⻚⾯,这样就可以保证有⾜够提交订单的⽤户量,确保抢购活动中商品的销量最⼤化。

获取购买资格这步的并发量会⾮常⼤,还是基于分布式的,通常我们可以通过Redis分布式锁来控制购买资格的发放。

提交订单

由于抢购⼊⼝的请求量会⾮常⼤,可能会占⽤⼤量带宽,为了不影响提交订单的请求,我建议将提交订单的⼦域名与抢购⼦域名区分开,分别绑定不同⽹络的服务器。

⽤户点击提交订单,需要先校验库存,库存⾜够时,⽤户先扣除缓存中的库存,再⽣成订单。如果校验库存和扣除库存都是基于数据库实现的,那么每次都去操作数据库,瞬时的并发量就会⾮常⼤,对数据库来说会存在⼀定的压⼒,从⽽会产⽣性能瓶

颈。与获取购买资格⼀样,我们同样可以通过分布式锁来优化扣除消耗库存的设计。

由于我们已经缓存了库存,所以在提交订单时,库存的查询和冻结并不会给数据库带来性能瓶颈。但在这之后,还有⼀个订单的幂等校验,为了提⾼系统性能,我们同样可以使⽤分布式锁来优化。

⽽保存订单信息⼀般都是基于数据库表来实现的,在单表单库的情况下,碰到⼤量请求,特别是在瞬时⾼并发的情况下,磁盘
I/O、数据库请求连接数以及带宽等资源都可能会出现性能瓶颈。此时我们可以考虑对订单表进⾏分库分表,通常我们可以基于userid字段来进⾏hash取模,实现分库分表,从⽽提⾼系统的并发能⼒。

⽀付回调业务操作

在⽤户⽀付订单完成之后,⼀般会有第三⽅⽀付平台回调我们的接⼝,更新订单状态。

除此之外,还可能存在扣减数据库库存的需求。如果我们的库存是基于缓存来实现查询和扣减,那提交订单时的扣除库存就只是扣除缓存中的库存,为了减少数据库的并发量,我们会在⽤户付款之后,在⽀付回调的时候去选择扣除数据库中的库存。

此外,还有订单购买成功的短信通知服务,⼀些商城还提供了累计积分的服务。

在⽀付回调之后,我们可以通过异步提交的⽅式,实现订单更新之外的其它业务处理,例如库存扣减、积分累计以及短信通知等。通常我们可以基于MQ实现业务的异步提交。

性能瓶颈调优

了解了各个业务流程中可能存在的性能瓶颈,我们再来讨论下商城基于常规优化设计之后,还可能出现的⼀些性能问题,我们
⼜该如何做进⼀步调优。

限流实现优化

限流是我们常⽤的兜底策略,⽆论是倒计时请求接⼝,还是抢购⼊⼝,系统都应该对它们设置最⼤并发访问数量,防⽌超出预期的请求集中进⼊系统,导致系统异常。

通常我们是在⽹关层实现⾼并发请求接⼝的限流,如果我们使⽤了Nginx做反向代理的话,就可以在Nginx配置限流算法。
Nginx是基于漏桶算法实现的限流,这样做的好处是能够保证请求的实时处理速度。

Nginx中包含了两个限流模块:ngx_http_limit_conn_module ngx_http_limit_req_module,前者是⽤于限制单个IP单位时间内的请求数量,后者是⽤来限制单位时间内所有IP的请求数量。以下分别是两个限流的配置:

limit_conn_zone $binary_remote_addr zone=addr:10m;

server {
location / {
limit_conn addr 1;
}

http {
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s; server {
location / {
limit_req zone=one burst=5 nodelay;
}
}

在⽹关层,我们还可以通过lua编写OpenResty来实现⼀套限流功能,也可以通过现成的Kong安装插件来实现。除了⽹关层的
限流之外,我们还可以基于服务层实现接⼝的限流,通过Zuul RateLimit或Guava RateLimiter实现。

流量削峰

瞬间有⼤量请求进⼊到系统后台服务之后,⾸先是要通过Redis分布式锁获取购买资格,这个时候我们看到了⼤量的“JedisConnectionException Could not get connection from pool”异常。

这个异常是⼀个Redis连接异常,由于我们当时的Redis集群是基于哨兵模式部署的,哨兵模式部署的Redis也是⼀种主从模式,我们在写Redis的时候都是基于主库来实现的,在⾼并发操作⼀个Redis实例就很容易出现性能瓶颈。

你可能会想到使⽤集群分⽚的⽅式来实现,但对于分布式锁来说,集群分⽚的实现只会增加性能消耗,这是因为我们需要基于
Redission的红锁算法实现,需要对集群的每个实例进⾏加锁。

后来我们使⽤Redission插件替换Jedis插件,由于Jedis的读写I/O操作还是阻塞式的,⽅法调⽤都是基于同步实现,⽽
Redission底层是基于Netty框架实现的,读写I/O是⾮阻塞I/O操作,且⽅法调⽤是基于异步实现。

但在瞬时并发⾮常⼤的情况下,依然会出现类似问题,此时,我们可以考虑在分布式锁前⾯新增⼀个等待队列,减缓抢购出现的集中式请求,相当于⼀个流量削峰。当请求的key值放⼊到队列中,请求线程进⼊阻塞状态,当线程从队列中获取到请求线 程的key值时,就会唤醒请求线程获取购买资格。

数据丢失问题

⽆论是服务宕机,还是异步发送给MQ,都存在请求数据丢失的可能。例如,当第三⽅⽀付回调系统时,写⼊订单成功了,此时通过异步来扣减库存和累计积分,如果应⽤服务刚好挂掉了,MQ还没有存储到该消息,那即使我们重启服务,这条请求数据也将⽆法还原。

重试机制是还原丢失消息的⼀种解决⽅案。在以上的回调案例中,我们可以在写⼊订单时,同时在数据库写⼊⼀条异步消息状态,之后再返回第三⽅⽀付操作成功结果。在异步业务处理请求成功之后,更新该数据库表中的异步消息状态。

假设我们重启服务,那么系统就会在重启时去数据库中查询是否有未更新的异步消息,如果有,则重新⽣成MQ业务处理消息,供各个业务⽅消费处理丢失的请求数据。

总结

减少抢购中操作数据库的次数,缩短抢购流程,是抢购系统设计和优化的核⼼点。

抢购系统的性能瓶颈主要是在数据库,即使我们对服务进⾏了横向扩容,当流量瞬间进来,数据库依然⽆法同时响应处理这么多的请求操作。我们可以对抢购业务表进⾏分库分表,通过提⾼数据库的处理能⼒,来提升系统的并发处理能⼒。

除此之外,我们还可以分散瞬时的⾼并发请求,流量削峰是最常⽤的⽅式,⽤⼀个队列,让请求排队等待,然后有序且有限地进⼊到后端服务,最终进⾏数据库操作。当我们的队列满了之后,可以将溢出的请求放弃,这就是限流了。通过限流和削峰,

可以有效地保证系统不宕机,确保系统的稳定性。

思考题

在提交了订单之后会进⼊到⽀付阶段,此时系统是冻结了库存的,⼀般我们会给⽤户⼀定的等待时间,这样就很容易出现⼀些
44讲记一次双十一抢购性能瓶颈调优 - 图5⽤户恶意锁库存,导致抢到商品的⽤户没办法去⽀付购买该商品。你觉得该怎么优化设计这个业务操作呢? 期待在留⾔区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他⼀起讨论。

  1. 精选留⾔

44讲记一次双十一抢购性能瓶颈调优 - 图6-W.LI-
课后思考题:
⽂中⽼师讲了预扣库存可以多放开⼀点。⽐如实际只有100件商品,允许预扣300。⽀付成功后扣去真实库存。之前某包买东⻄
,就遇⻅过了⼏天客服联系说没货了退款这种。我之前做过⼀个是⽀付完真实库存扣件失败,直接退款回滚数据的。
恶意⽤户刷单的话可以对⽤户进⾏封号处理,在redis中缓存⽤户待带⽀付的订单数,每次进⼊带⽀付前校验下待⽀付的集合⾥有多少(⾦额数⽬都可)。判定为恶意刷单的直接⿊名单。某东⽤的好像是⿊名单。
2019-08-31 10:44
作者回复
不是预扣库存多放开,是在预扣库存前设置⼀个购买资格,购买资格是300,预扣库存还是100不变。不会出现商品超卖的问题

问答题回答思路很好。
2019-09-01 08:44

44讲记一次双十一抢购性能瓶颈调优 - 图7-W.LI-
当请求的 key 值放⼊到队列中,请求线程进⼊阻塞状态,当线程从队列中获取到请求线程的 key 值时,就会唤醒请求线程获取购买资格。
⽼师好!能讲下写读请求使⽤队列缓存的原理么?
之前有看过servlet3.0的和这个是不是有点像。客户端的链接是阻塞的,服务端通过队列缓存,处理完以后通过之前的链接把数
据写回给客户端。
servlet3.0是servlet规范,我现在基本⽤的都会spring⾃带的dispa*。如果要实现这总异步IO需要我们⾃⼰实现servlet是么?
IO⽅⾯的知识很薄弱,netty好像很经典可是从来没看过,⼀⽅⾯觉得⾃⼰菜,领⼀⽅⾯就是⼯作中没⽤上,我从下⼿。希望⽼师给点学习指南谢谢。
依依不舍(´..)❤
2019-08-31 10:35
44讲记一次双十一抢购性能瓶颈调优 - 图8许童童
期待⽼师的思考题解答。
2019-08-31 16:09
作者回复
嗯,⿊名单机制是⼀个⽅向
2019-09-01 08:46

44讲记一次双十一抢购性能瓶颈调优 - 图9超威⼂
没有⽐较好的办法,如果等到付款才扣减库存,可能会出现超卖!⼀般好的办法限制⼀个账户买同个商品的数量,减少损失
2019-08-31 08:17
作者回复
没有直接的解决⽅案,但是我们可以通过间接的⽅案来减少这种恶意锁单的问题。建⽴信⽤以及⿊名单机制,⾸先在获取购买资格时将⿊名单⽤户过滤掉,其次在获取购买资格后,信⽤级别⾼的⽤户优先获取到库存。⽤户⼀旦恶意锁单就会被加⼊到⿊名单。
2019-09-01 08:41

44讲记一次双十一抢购性能瓶颈调优 - 图10张德
可以减少⽀付的预留时间 ⽐如说就五分钟 剩下的告诉他还有机会 可以等等看
2019-09-14 17:18

44讲记一次双十一抢购性能瓶颈调优 - 图11godtrue
课后思考及问题

1:在提交了订单之后会进⼊到⽀付阶段,此时系统是冻结了库存的,⼀般我们会给⽤户⼀定的等待时间,这样就很容易出现
⼀些⽤户恶意锁库存,导致抢到商品的⽤户没办法去⽀付购买该商品。

⾸先,感觉⽼师的问题有点奇怪,没明⽩“某些⽤户恶意锁库存,导致抢到商品的⽤户没办法去⽀付购买该商品的”——我的理解,300个⼈抢到了抢购的商品,实际只有100个,如果是先款订单,谁先付款谁就先实际抢购到对应的商品呗!如果担⼼付款后,不要了要求退货,这就是另外的事情了,⼀般⽽⾔待抢购的商品都是物超所值的,需要担⼼的应该是多抢。

如果是要控制有购买资格的⼈数,可以利⽤⼤数据⽤户画像的⽅式,将级别⾼信⽤好的⽤户优先放过去,当然,⿊名单也⽤起来过滤掉恶意⽤户,再者就是限制⽤户购买的商品数量。
2019-09-12 21:58
作者回复
是的
2019-09-14 09:04

44讲记一次双十一抢购性能瓶颈调优 - 图12JackJin
我们可以考虑在分布式锁前⾯新增⼀个等待队列,减缓抢购出现的集中式请求,相当于⼀个流量削峰。当请求的 key 值放⼊到队列中,请求线程进⼊阻塞状态,当线程从队列中获取到请求线程的 key 值时,就会唤醒请求线程获取购买资格。
⽼师这⾥不太理解!
2019-09-04 17:52
作者回复
相当于线程池中的阻塞队列
2019-09-04 19:19

44讲记一次双十一抢购性能瓶颈调优 - 图13梁中华
我们上次把redis客户端从jedis改成redission后,会有部分查询请求出现延迟⼏⼗毫秒的现象,换回jedis⾥⾯好了,不知道⽼师 有没有遇到过这种情况,是不是netty的很么参数设置的不对?
2019-09-03 17:15
44讲记一次双十一抢购性能瓶颈调优 - 图14钱彬彬
44讲记一次双十一抢购性能瓶颈调优 - 图15⽼师,库存使⽤redis进⾏预热缓存,如何保证不超卖呢?⽼师说⽤分布式锁,这⾥可以说的详细点么?
2019-09-03 15:43
作者回复
通过分布式锁可以保证不超卖,具体回顾之前41讲的分布式锁设计。
2019-09-04 19:33

44讲记一次双十一抢购性能瓶颈调优 - 图16晓杰
请问⽼师,流量削峰使⽤等待队列的话,是使⽤jdk⾃带的队列吗
2019-09-01 23:55
作者回复
由于请求量⽐较⼤,我们可以使⽤其他脚本语⾔+redis实现⼀个中间代理来做排队等待,减少Java应⽤的内存压⼒。
2019-09-02 19:36

44讲记一次双十一抢购性能瓶颈调优 - 图17晓杰
问答题:在获取购买资格这⼀步,可以适当加⼤购买资格的数量
2019-09-01 23:53
作者回复
到了⽀付界⾯,我们已经锁定库存了,所以即使增⼤购买资格,也没法解决这个问题。
2019-09-02 19:34

44讲记一次双十一抢购性能瓶颈调优 - 图18晓杰
请问⽼师,在提交订单的时候加上订单的幂等校验是为了防⽌同⼀个⽤户重复提交订单吗
2019-09-01 23:51
作者回复
对的
2019-09-02 19:33

44讲记一次双十一抢购性能瓶颈调优 - 图19晓杰
请问⽼师,在提交订单时,缓存中库存的查询和扣减是不是应该做成⼀步操作
2019-09-01 23:30
作者回复
是的
2019-09-02 19:22

44讲记一次双十一抢购性能瓶颈调优 - 图20陈华应
1,IP限流
2,缩短锁库存时间(抢购场景就是抢,应该不会太担⼼⽤户只是为了下订单⽽不付款的情况)
3,相同⽤户限制最⼤购买数量(数据放到缓存中⽤于抢购期间校验就⾏,不需要落DB)
4,期待⽼师的解决⽅法

2019-09-01 12:10
作者回复
设置⿊名单是⼀种常⽤的解决⽅案
2019-09-07 11:45

44讲记一次双十一抢购性能瓶颈调优 - 图21QQ怪
1.容忍超卖现象,实际售卖的库存得⽐⽹⻚库存⼤些,⽀付成功之后扣去真实库存,就算超卖严重,也可以后期补货或者选择退款结束订单;
2.尽量缩短⽤户⽀付等待时间,加快被锁库存的释放;
3.抢购活动⽀持同⼀⽤户限购次数。
2019-08-31 23:40
44讲记一次双十一抢购性能瓶颈调优 - 图22撒旦的堕落
⽼师能具体说说会员获取购买资格流程么 分布式锁 锁的是什么 ⽤redission替换jedis后 增加的队列 在分布式环境下是如何唤醒等待线程的
2019-08-31 19:27
作者回复
锁的是购买资格。如果是使⽤redis作为队列,我们需要增加⼀个中间代理来实现,中间代理是⼀个单点服务。
2019-09-07 11:50

44讲记一次双十一抢购性能瓶颈调优 - 图23满⼒
44讲记一次双十一抢购性能瓶颈调优 - 图24 ⽼师,我觉得⽀付成功后在扣减数据库库存,在并发时会存在先下单的顾客可能⽀付回调⽐较慢,导致后下单的顾客⽀付成功买到商品了
2019-08-31 18:02
作者回复
是的,数据库中的库存可以通过⽀付回调来扣减
2019-09-01 08:49