一次抢口罩的负载优化

前言

由于这次令人讨厌的疫情,导致口罩一罩难求,虽然各大厂商都在努力生产,但是毕竟僧多粥少,也幸好疫情快要结束了,也希望早点结束。因为口罩实在难以购买,所以很多地方的口罩采用统一调配,但是如何把口罩发放到大众手里,也成为了一个问题,所以预约购买成了最为安全的购买方式,购买后可以邮寄到家或者根据选择的时间段到店自提,但是由于购买人数过多,也带来了一个新的问题,服务器并发的问题,至少我在二月份参与了很多预约购买口罩的平台,没有一个平台不崩溃的,大多都是10几分钟了,就没有正常请求过服务器一次,不过幸好现在大部分平台已经调整为抽签的形式,而不是之前的先到先得规则,这样相对于先到先得更加公平,不受限于网络和运气,因为我们公司也做了相应的预约平台,不过由于客户要求先到先得原则,所以了本文。不过由于使用我们平台的用户并没有一线城市那么夸张,动不动几百万人同时抢购,而且我们平台的服务器也没有那么好的配置,所以本文的方式只是在几万人同时抢购的情况下测试过,当然其中思路还是可以参考的一下的,毕竟没有万金油,负载优化肯定是要根据实际情况来处理的

要求

首先第一个要求是不能超发,第二个要求是尽可能提交成功的失败率不会太高,也就是说不要100人都提交成功了,但是最后只有一个人抢到了口罩,第三就是要求服务器不要崩溃,尽可能的不要出现请求服务器失败的情况。

首先一个要求解决方案:因为考虑到大并发的情况的,而且还做了负载均衡,所以要么采用分布式锁或者队列,

分布式锁

分布式锁的优点是可以实时知道抢购结果,坏处就是性能低下,处理不好容易造成服务器压力过大。

分布式锁可以采用zookeeper,当然还有别的分布式中间件或者框架,千万别直接用语言自带的锁,做了负载均衡后,这个锁是不适用的,当然如果采用分布式锁的方式,一定要注意处理时间和预判,也就是说比如库存10个,那么理论上来说你有10请求到了,后面请求就应该直接说没有库存了,不然又有锁,又不预判,最终结果就是服务器被拖死,所有请求都需要到锁里面去执行逻辑。

队列

队列的好处就是你把请求扔进队列里面,就可以给客户反馈了,最终处理交给队列的消费者来处理就好,坏处就是不能实时知道处理结果,另外消费者也只能配置一个,注意查看一下所用的消息队列的文档,如果队列消费者是有支持多线程消费的,也要加分布式锁,如果需要配置多个消费者一样,需要在消费者中使用分布式锁,也可以直接用数据库乐观锁,不过大并发情况下到底是分布式锁效率高还是乐观锁效率高,还是要根据时间情况来分析,当然仅仅是分析是没用的,还要进行测试。当然用队列的方式也最好加一个库存预判,不然会出现很多人提交成功,但是却只有少数人能抢购到,如果有预判,首先可以降低消息数量,降低消费者运行时间,第二也不用让人一直期待

第二个要求解决方案就是加库存预判,我们在redis里面也保存了一分库存数据,每次有人提交先判断redis库存,如果有就马上扣减然后扔队列,当然因为redis的锁一直有问题,所以预判的库存肯定不会准确,但是影响不大,因为最终成功是在消息队列里面处理,不会出现超发

第三个就是简单来说就是尽可能的增加服务器并发量,当然前面2个问题的处理方式已经一定程度上增加了并发量,但是却远远不够,所以为了增加并发量,还要继续优化代码和做负载均衡

代码优化

前端

首先前端因为是网页的(小程序更新版本审核太麻烦,不符合我们快速迭代要求)

  • CDN

首先可以使用cdn,不然所有请求都到我们服务器,宽带能否承受变成了一个问题,毕竟一个网页请求的数据量跟普通接口数据量差别还是挺大的

  • 代码压缩

第二就是代码压缩,压缩之后能减少传输的数据量,虽然看起来可能只少了几十K,但是把这几十K放大几万倍也是不小的网络开销了(压缩包括图片,字体等各种前端资源)

  • 本地缓存

第三就是不常更新的数据做本地缓存,比如用户信息等,这样就不用每次都请求接口了,不过要注意缓存失效,如果是放在localStorage里面一定要主动做失效控制,否则数据会导致数据一直不更新

由于不是专业前端,所以就只能想到这几个优化办法(因为处理这块的时候还没有开始上班,而且前端页面不复杂,所以就由我开发了)

后端
  • 缓存

首先数据能放缓存就尽量放缓存,避免经常查询数据库,但是也要注意缓存失效的问题,如果数据库有更新,一定要更新缓存,另外还要注意缓存击穿的问题,当然有些缓存数据不用一定准确,比如之前提到的抢购的时候的库存预判,为了性能只能让缓存跟数据库不同步,只要最终处理结果正确就行

  • 计算优化

这个优化程度倒是不是特别多,当然如果懂就更好了,写出更符合jvm优化的代码,不过一般代码规划好的工程师写出来的代码也差不多符合这些要求,不过也要检查是否有可以优化的地方

  • 接口合并

有时候一个页面需要请求多个接口获取数据,可以考虑同一个接口返回多个接口返回的数据,这样虽然单次请求时间会延长,但是确能降低请求量,也不失为一种增加并发的方法

  • 配置优化

主要是数据库、消息队列、缓存等连接池线程数量的优化,比如你配置的线程池最大只有10个线程,那么不管怎么样,你同一时间段都只能处理10个人的请求(这里的请求只是针对需要用到这些线程的),当然这些配置也不是越大越好,首先考虑一个切换成本,第二就是有了负载后,数据库这些本身的最大连接数,比如你数据库本身最大连接数是100,你给程序数据库连接池配置了1000,那么也没用,因为数据库只能接收你100个连接,最终也只有100个连接才能正常使用

中间件优化
  • Tomcat优化

优化jvm参数(堆栈初始化大小等),优化了tomcat的线程池配置

  • 数据库优化

启用MySQL缓存(如果是读明显大于写很多的情况,可以用这个,可以加快查询数量,如果是写跟读差不多,那也要考虑开启后带来的负面影响,增加了写入成本),增加最多连接数量等

由于中间件采用的各不相同,所以只能根据实际情况和硬件配置等来具体优化,切记不能千篇一律的照抄,毕竟别人的配置不一定就适合你的服务器,比如别人机器64核,所以给nginx开64个工作线程,但是你的机器只有16核,你也去开64个线程,这样不会更快,反而更慢

Nginx

由于Nginx是主要的负载软件,所以讲一下Nginx的相关配置,本文只设计到配置理论,不会出具体配置,还是那句话,具体情况下具体配置,没有万金油

  • Nginx负载策略

Nginx 常用的负载策略有

  • 轮询(默认方式)
  • weight(权重)
  • ip_hash(根据IP分配)
  • least_conn(最少连接方式)
  • fair(响应时间方式 第三方)
  • url_hash(根据URL分配  第三方)

由于服务器配置参差不齐,所以轮询、 ip-hash 、 least_conn这几种策略明显不使用,毕竟这样会造成配置高的服务器接收请求少,而配置差的服务器反而接收了更多请求。urlhash也不符合我们使用场景,主要因为这样会造成不同的服务器处理不同的接口,无法有效把压力分散,毕竟所有压力都集中在1,2个接口上,所以不适合

最终适合我们的负载策略只有weight和fair,理论上fair是更适合的,因为响应时间才能反应一台服务器真正的压力,如果响应快,那么理所应当接收更多负载,毕竟很多时候一台服务器不只是一个服务,但是因为fair是第三方的,首先有效性还有待考证,其次就是这样的话nginx需要重新编译,而我们nginx已经编译了太多模块,上次编译的命令无法找回,导致担心重新编译会有问题(所以以后这些需要自行编译的软件,一定要记录编译命令,否则万一哪天需要更改,麻烦就来了),所以最终选择了weight,然后根据服务器的配置以及所运行的其他服务来设置了权重

  • Nginx其他配置优化

工作线程数量优化、worker_connections和worker_rlimit_nofile 优化

多层负载

Nginx只是软件负载,但是一台服务器的再强,并发也是有限的,所以就需要用到多层负载。如果是真机,那么可以使用硬件负载,如F5、Array等硬件(由于不是专业运营,没有接触过这些高端玩意,也就知道这2个),如果是阿里云的服务器,也可以购买阿里云的负载均衡服务(其他云我不知道有没有,因为没用过),当然这2种办法都是需要增加成本的,所以还有一种免费的方法,就是DNS负载,配置简单,还不受限服务器配置,唯独一点不好的就是DNS负载不会监控服务健康状态,简单来说,假如你这台服务器挂了,那么请求该来的还是会来。多层负载用了之后的结果就是,可以有效的把请求分发到多台nginx服务器,可以避免单台nginx卡死,导致所有服务不可用

出现过的问题

  • Nginx单线程能处理的最大并发数问题

由于当时时间紧急,所以没有截图,原因是因为worker_connections设置的太小,之前同事直接网上复制了一份配置,上面设置的是2048,由于之前系统没有很大的并发,所以也没有怎么关注这个配置,直到出了问题百度才知道,这个是单个工作线程能处理的最大并发量,比如我设置了10个工作线程,worker_connections设置的2048,那么我nginx最大并发也就是20480,超过这个并发nginx就会抛异常提示,另外worker_connections不能超过worker_rlimit_nofile,具体可以百度相关关键词来配置

  • Nginx主机CPU 100%导致全部卡住

这个问题一开始没有出现,后面抢购直接由县城升级到开放全市抢购的时候,问题来了,主Nginx的服务器CPU 100%一直不降,所有服务器都没有请求进来,说明nginx已经没有进行请求分发了,htop命令查询服务器状态,发现Nginx工作线程占用CPU都很高,还有其他一个服务,这2个程序已经吧CPU全部占用了,但是尽管Nginx占用了CPU,但是却没有执行分发,最后只能kill掉nginx,重启(restart命令已经没用了),原因是服务器配置明显不够(也有可能是nginx本身的版本的问题,因为版本比较老,理论上来说那么点并发是没有多大问题的),再加上另外一个服务也很占用资源,所以第一步是把另外一个服务迁移到其他服务器上,第二就是启用了DNS负载,当时也看了阿里云的负载服务,不过DNS负载能满足我们的需求,虽然没有阿里云的负载那么好用和智能,但是好在DNS负载是免费的[笑着哭],而且配置也简单,这样从单台nginx变成多台,nginx就再也没有卡死了(虽然是因为并发也不够大,如果几千万的并发,我都不用想就知道,瞬间嘣了)

结尾

虽然没有很夸张的动辄几千万,几个亿的负载优化,但是我想所有开发都是一步一步来的,没有谁没有经历过几十万的负载,就去坐几百万的负载优化,纸上谈兵谁都会,但是实验才是检验真理的唯一准则,也希望此文能给阅读的你带来一点帮助

Authorship: 作者
Article Link: https://raye.wang/2020/03/23/%E4%B8%80%E6%AC%A1%E6%8A%A2%E5%8F%A3%E7%BD%A9%E7%9A%84%E8%B4%9F%E8%BD%BD%E4%BC%98%E5%8C%96/
Copyright: All posts on this blog are licensed under the CC BY-NC-SA 4.0 license unless otherwise stated. Please cite Raye Blog !