极客时间已完结课程限时免费阅读

44 | 记一次双十一抢购性能瓶颈调优

44 | 记一次双十一抢购性能瓶颈调优-极客时间

44 | 记一次双十一抢购性能瓶颈调优

讲述:李良

时长15:48大小10.85M

你好,我是刘超。今天我们来聊聊双十一的那些事儿,由于场景比较复杂,这一讲的出发点主要是盘点各个业务中高频出现的性能瓶颈,给出相应的优化方案,但优化方案并没有一一展开,深度讲解其具体实现。你可以结合自己在这个专栏的所学和日常积累,有针对性地在留言区提问,我会一一解答。下面切入正题。
每年的双十一都是很多研发部门最头痛的节日,由于这个节日比较特殊,公司一般都会准备大量的抢购活动,相应的瞬时高并发请求对系统来说是个不小的考验。
还记得我们公司商城第一次做双十一抢购活动,优惠力度特别大,购买量也很大,提交订单的接口 TPS 一度达到了 10W。在首波抢购时,后台服务监控就已经显示服务器的各项指标都超过了 70%,CPU 更是一直处于 400%(4 核 CPU),数据库磁盘 I/O 一直处于 100% 状态。由于瞬时写入日志量非常大,导致我们的后台服务监控在短时间内,无法实时获取到最新的请求监控数据,此时后台开始出现一系列的异常报警。
更严重的系统问题是出现在第二波的抢购活动中,由于第一波抢购时我们发现后台服务的压力比较大,于是就横向扩容了服务,但却没能缓解服务的压力,反而在第二波抢购中,我们的系统很快就出现了宕机。
这次活动暴露出来的问题很多。首先,由于没有限流,超过预期的请求量导致了系统卡顿;其次,我们是基于 Redis 实现了一个分布式锁分发抢购名额的功能,但这个功能抛出了大量异常;再次,就是我们误判了横向扩容服务可以起到的作用,其实第一波抢购的性能瓶颈是在数据库,横向扩容服务反而又增加了数据库的压力,起到了反作用;最后,就是在服务挂掉的情况下,丢失了异步处理的业务请求。
接下来我会以上面的这个案例为背景,重点讲解抢购业务中的性能瓶颈该如何调优。

抢购业务流程

在进行具体的性能问题讨论之前,我们不妨先来了解下一个常规的抢购业务流程,这样方便我们更好地理解一个抢购系统的性能瓶颈以及调优过程。
用户登录后会进入到商品详情页面,此时商品购买处于倒计时状态,购买按钮处于置灰状态。
当购买倒计时间结束后,用户点击购买商品,此时用户需要排队等待获取购买资格,如果没有获取到购买资格,抢购活动结束,反之,则进入提交页面。
用户完善订单信息,点击提交订单,此时校验库存,并创建订单,进入锁定库存状态,之后,用户支付订单款。
当用户支付成功后,第三方支付平台将产生支付回调,系统通过回调更新订单状态,并扣除数据库的实际库存,通知用户购买成功。

抢购系统中的性能瓶颈

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

1. 商品详情页面

如果你有过抢购商品的经验,相信你遇到过这样一种情况,在抢购马上到来的时候,商品详情页面几乎是无法打开的。
这是因为大部分用户在抢购开始之前,会一直疯狂刷新抢购商品页面,尤其是倒计时一分钟内,查看商品详情页面的请求量会猛增。此时如果商品详情页面没有做好,就很容易成为整个抢购系统中的第一个性能瓶颈。
类似这种问题,我们通常的做法是提前将整个抢购商品页面生成为一个静态页面,并 push 到 CDN 节点,并且在浏览器端缓存该页面的静态资源文件,通过 CDN 和浏览器本地缓存这两种缓存静态页面的方式来实现商品详情页面的优化。

2. 抢购倒计时

在商品详情页面中,存在一个抢购倒计时,这个倒计时是服务端时间的,初始化时间需要从服务端获取,并且在用户点击购买时,还需要服务端判断抢购时间是否已经到了。
如果商品详情每次刷新都去后端请求最新的时间,这无疑将会把整个后端服务拖垮。我们可以改成初始化时间从客户端获取,每隔一段时间主动去服务端刷新同步一次倒计时,这个时间段是随机时间,避免集中请求服务端。这种方式可以避免用户主动刷新服务端的同步时间接口。

3. 获取购买资格

可能你会好奇,在抢购中我们已经通过库存数量限制用户了,那为什么会出现一个获取购买资格的环节呢?
我们知道,进入订单详情页面后,需要填写相关的订单信息,例如收货地址、联系方式等,在这样一个过程中,很多用户可能还会犹豫,甚至放弃购买。如果把这个环节设定为一定能购买成功,那我们就只能让同等库存的用户进来,一旦用户放弃购买,这些商品可能无法再次被其他用户抢购,会大大降低商品的抢购销量。
增加购买资格的环节,选择让超过库存的用户量进来提交订单页面,这样就可以保证有足够提交订单的用户量,确保抢购活动中商品的销量最大化。
获取购买资格这步的并发量会非常大,还是基于分布式的,通常我们可以通过 Redis 分布式锁来控制购买资格的发放。

4. 提交订单

由于抢购入口的请求量会非常大,可能会占用大量带宽,为了不影响提交订单的请求,我建议将提交订单的子域名与抢购子域名区分开,分别绑定不同网络的服务器。
用户点击提交订单,需要先校验库存,库存足够时,用户先扣除缓存中的库存,再生成订单。如果校验库存和扣除库存都是基于数据库实现的,那么每次都去操作数据库,瞬时的并发量就会非常大,对数据库来说会存在一定的压力,从而会产生性能瓶颈。与获取购买资格一样,我们同样可以通过分布式锁来优化扣除消耗库存的设计。
由于我们已经缓存了库存,所以在提交订单时,库存的查询和冻结并不会给数据库带来性能瓶颈。但在这之后,还有一个订单的幂等校验,为了提高系统性能,我们同样可以使用分布式锁来优化。
而保存订单信息一般都是基于数据库表来实现的,在单表单库的情况下,碰到大量请求,特别是在瞬时高并发的情况下,磁盘 I/O、数据库请求连接数以及带宽等资源都可能会出现性能瓶颈。此时我们可以考虑对订单表进行分库分表,通常我们可以基于 userid 字段来进行 hash 取模,实现分库分表,从而提高系统的并发能力。

5. 支付回调业务操作

在用户支付订单完成之后,一般会有第三方支付平台回调我们的接口,更新订单状态。
除此之外,还可能存在扣减数据库库存的需求。如果我们的库存是基于缓存来实现查询和扣减,那提交订单时的扣除库存就只是扣除缓存中的库存,为了减少数据库的并发量,我们会在用户付款之后,在支付回调的时候去选择扣除数据库中的库存。
此外,还有订单购买成功的短信通知服务,一些商城还提供了累计积分的服务。
在支付回调之后,我们可以通过异步提交的方式,实现订单更新之外的其它业务处理,例如库存扣减、积分累计以及短信通知等。通常我们可以基于 MQ 实现业务的异步提交。

性能瓶颈调优

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

1. 限流实现优化

限流是我们常用的兜底策略,无论是倒计时请求接口,还是抢购入口,系统都应该对它们设置最大并发访问数量,防止超出预期的请求集中进入系统,导致系统异常。
通常我们是在网关层实现高并发请求接口的限流,如果我们使用了 Nginx 做反向代理的话,就可以在 Nginx 配置限流算法。Nginx 是基于漏桶算法实现的限流,这样做的好处是能够保证请求的实时处理速度。
Nginx 中包含了两个限流模块:ngx_http_limit_conn_modulengx_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 实现。

2. 流量削峰

瞬间有大量请求进入到系统后台服务之后,首先是要通过 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 值时,就会唤醒请求线程获取购买资格。

3. 数据丢失问题

无论是服务宕机,还是异步发送给 MQ,都存在请求数据丢失的可能。例如,当第三方支付回调系统时,写入订单成功了,此时通过异步来扣减库存和累计积分,如果应用服务刚好挂掉了,MQ 还没有存储到该消息,那即使我们重启服务,这条请求数据也将无法还原。
重试机制是还原丢失消息的一种解决方案。在以上的回调案例中,我们可以在写入订单时,同时在数据库写入一条异步消息状态,之后再返回第三方支付操作成功结果。在异步业务处理请求成功之后,更新该数据库表中的异步消息状态。
假设我们重启服务,那么系统就会在重启时去数据库中查询是否有未更新的异步消息,如果有,则重新生成 MQ 业务处理消息,供各个业务方消费处理丢失的请求数据。

总结

减少抢购中操作数据库的次数,缩短抢购流程,是抢购系统设计和优化的核心点。
抢购系统的性能瓶颈主要是在数据库,即使我们对服务进行了横向扩容,当流量瞬间进来,数据库依然无法同时响应处理这么多的请求操作。我们可以对抢购业务表进行分库分表,通过提高数据库的处理能力,来提升系统的并发处理能力。
除此之外,我们还可以分散瞬时的高并发请求,流量削峰是最常用的方式,用一个队列,让请求排队等待,然后有序且有限地进入到后端服务,最终进行数据库操作。当我们的队列满了之后,可以将溢出的请求放弃,这就是限流了。通过限流和削峰,可以有效地保证系统不宕机,确保系统的稳定性。

思考题

在提交了订单之后会进入到支付阶段,此时系统是冻结了库存的,一般我们会给用户一定的等待时间,这样就很容易出现一些用户恶意锁库存,导致抢到商品的用户没办法去支付购买该商品。你觉得该怎么优化设计这个业务操作呢?
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 9

提建议

上一篇
43 | 如何使用缓存优化系统性能?
下一篇
结束语 | 栉风沐雨,砥砺前行!
unpreview
 写留言

精选留言(32)

  • zk_207
    2019-10-27
    超哥,你们订单超时是基于定时任务去做的吗?比如我订单是3min有效,怎么保证3min没支付就取消?

    作者回复: 我们是放在mq中去实现的,rabbitmq中有一个延时队列,当过期时间到了,就会被放到死信队列中,只要去死信队列中实时消费就好了。 定时任务也是一种实现方式,在分布式部署定时任务时,要实现分布式定时任务。

    共 4 条评论
    14
  • -W.LI-
    2019-08-31
    课后思考题: 文中老师讲了预扣库存可以多放开一点。比如实际只有100件商品,允许预扣300。支付成功后扣去真实库存。之前某包买东西,就遇见过了几天客服联系说没货了退款这种。我之前做过一个是支付完真实库存扣件失败,直接退款回滚数据的。 恶意用户刷单的话可以对用户进行封号处理,在redis中缓存用户待带支付的订单数,每次进入带支付前校验下待支付的集合里有多少(金额数目都可)。判定为恶意刷单的直接黑名单。某东用的好像是黑名单。
    展开

    作者回复: 不是预扣库存多放开,是在预扣库存前设置一个购买资格,购买资格是300,预扣库存还是100不变。不会出现商品超卖的问题。 问答题回答思路很好。

    共 3 条评论
    10
  • 阿卧
    2020-03-16
    扣件库存用分布式锁,性能瓶颈就在分布式锁上。那么如何优化提高下单的qps呢?

    作者回复: 使用更优的分布式锁,以及细化锁粒度,例如将一个库存分为多个库存等

    共 2 条评论
    7
  • 王三好
    2019-11-18
    队列使用什么实现的

    作者回复: 有界队列LinkedBlockingQueue或者Disruptor实现队列

    7
  • 超威丶
    2019-08-31
    没有比较好的办法,如果等到付款才扣减库存,可能会出现超卖!一般好的办法限制一个账户买同个商品的数量,减少损失

    作者回复: 没有直接的解决方案,但是我们可以通过间接的方案来减少这种恶意锁单的问题。建立信用以及黑名单机制,首先在获取购买资格时将黑名单用户过滤掉,其次在获取购买资格后,信用级别高的用户优先获取到库存。用户一旦恶意锁单就会被加入到黑名单。

    共 2 条评论
    7
  • zk_207
    2019-10-24
    您好,请问本文中说的订单幂等性校验如何控制吗?还有就是库存放在缓存中,DB和缓存如何保证一致性?能说下解决方案吗?

    作者回复: 通过分布式锁来控制的,在下单时,以缓存中的库存为准,不会修改DB中的库存,只有在支付完成之后,回调时去数据库中扣除库存,严格上来说,只要业务操作没有bug,两者的库存就是一致的

    5
  • -W.LI-
    2019-08-31
    当请求的 key 值放入到队列中,请求线程进入阻塞状态,当线程从队列中获取到请求线程的 key 值时,就会唤醒请求线程获取购买资格。 老师好!能讲下写读请求使用队列缓存的原理么? 之前有看过servlet3.0的和这个是不是有点像。客户端的链接是阻塞的,服务端通过队列缓存,处理完以后通过之前的链接把数据写回给客户端。 servlet3.0是servlet规范,我现在基本用的都会spring自带的dispa***。如果要实现这总异步IO需要我们自己实现servlet是么? IO方面的知识很薄弱,netty好像很经典可是从来没看过,一方面觉得自己菜,领一方面就是工作中没用上,我从下手。希望老师给点学习指南谢谢。 依依不舍(´..)❤
    展开
    5
  • zk_207
    2019-10-25
    超哥,请教个问题,就是秒杀的时候我们一般是下单预扣减库存,比如10分钟之后如果没有支付的话库存回流,这时候怎么保证库存准确性与系统性能呢?

    作者回复: 我们是通过一个生产者消费者的方式实现缓存库存的添加和删除,并且通过分布式锁来保证原子性

    4
  • torres
    2020-10-26
    性能调优黄金法则 缓存 限流 异步 解耦 补偿
    展开
    3
  • 2019-09-12
    课后思考及问题 1:在提交了订单之后会进入到支付阶段,此时系统是冻结了库存的,一般我们会给用户一定的等待时间,这样就很容易出现一些用户恶意锁库存,导致抢到商品的用户没办法去支付购买该商品。 首先,感觉老师的问题有点奇怪,没明白“某些用户恶意锁库存,导致抢到商品的用户没办法去支付购买该商品的”——我的理解,300个人抢到了抢购的商品,实际只有100个,如果是先款订单,谁先付款谁就先实际抢购到对应的商品呗!如果担心付款后,不要了要求退货,这就是另外的事情了,一般而言待抢购的商品都是物超所值的,需要担心的应该是多抢。 如果是要控制有购买资格的人数,可以利用大数据用户画像的方式,将级别高信用好的用户优先放过去,当然,黑名单也用起来过滤掉恶意用户,再者就是限制用户购买的商品数量。
    展开

    作者回复: 是的

    3
  • asura
    2020-01-30
    课后思考题:设置黑名单来防止恶意锁库存。 下单前会有很多检验性的判断,而且会经常变动,可以采用责任链模式,动态添加检验逻辑。在链头判断用户是不是黑名单,是的话就直接结束请求,不是走下一个链。
    2
  • 拒绝
    2019-09-04
    我们可以考虑在分布式锁前面新增一个等待队列,减缓抢购出现的集中式请求,相当于一个流量削峰。当请求的 key 值放入到队列中,请求线程进入阻塞状态,当线程从队列中获取到请求线程的 key 值时,就会唤醒请求线程获取购买资格。 老师这里不太理解!

    作者回复: 相当于线程池中的阻塞队列

    共 2 条评论
    2
  • 梁中华
    2019-09-03
    我们上次把redis客户端从jedis改成redission后,会有部分查询请求出现延迟几十毫秒的现象,换回jedis里面好了,不知道老师有没有遇到过这种情况,是不是netty的很么参数设置的不对?

    作者回复: 是的,可以参考下官方的使用文档,在单机且运用服务的CPU核数比较小的环境下,可能测试性能效果没有很大差别,如果想要效果更明显,可以在redis集群环境且应用服务的CPU核数在16以上的环境下进行性能压测,效果会更明显。 https://github.com/redisson/redisson/wiki/2.-配置方法#21-程序化配置方法

    2
  • Mr.Strive.Z.H.L
    2019-10-09
    老师你文中提到的 锁库存,我理解就是缓存中扣减库存,因为没有涉及到db,所以没有实际的上锁。是这样吧? 如果用户迟迟没付款,订单超时后会增加缓存的库存吗?

    作者回复: 扣除缓存中的库存也需要分布式锁,订单超时被取消会增加库存。

    1
  • 晓杰
    2019-09-01
    问答题:在获取购买资格这一步,可以适当加大购买资格的数量

    作者回复: 到了支付界面,我们已经锁定库存了,所以即使增大购买资格,也没法解决这个问题。

    1
  • 晓杰
    2019-09-01
    请问老师,在提交订单的时候加上订单的幂等校验是为了防止同一个用户重复提交订单吗

    作者回复: 对的

    1
  • 许童童
    2019-08-31
    期待老师的思考题解答。

    作者回复: 嗯,黑名单机制是一个方向

    1
  • really
    2022-10-18 来自广东
    把库存放到redis,redis不能保证数据不丢失,如果出现了主从切换,那会超卖的
  • 书策稠浊
    2021-09-10
    提交订单前不是有一个获取资格的过程吗,有资格的用户数就那么多,所以口库存的时候流量其实不会很大。 比如只有1000库存,那就发2000个资格,只有2000个用户可以进入到提交订单的页面
  • 平民人之助
    2021-07-11
    我是航司的架构,这种方案航司那边是有退票扣费的操作,避免买特价票了不坐。只能从业务上去限制,就像你打开门做生意,做不做都得让别人进来吧,进来不做的走导致有人买不到,那就不是技术问题了。