服务器 频道

一个高并发项目到落地的心酸路

  前言

  最近闲来没事,摸鱼看了不少高并发相关的文章,突然有感而发,想到了几年前做的一个项目,也多少和高并发有点关系。

  这里我一边回忆落地细节一边和大家分享下,兴许能给大家带来点灵感。

  一、需求及背景

  先来介绍下需求,首先项目是一个志愿填报系统,既然会扯上高并发,相信大家也能猜到大致是什么的志愿填报。

  核心功能是两块,一是给考试填报志愿,二是给老师维护考生数据。

  本来这个项目不是我们负责,奈何去年公司负责这个项目的组遭到了甲方严重的投诉,说很多考生用起来卡顿,甚至把没填上志愿的责任归到系统上。

  甲方明确要求,如果这年再出现这种情况,公司在该省的所有项目将面临被替换的风险。

  讨论来讨论去,最后公司将任务落到我们头上时,已经是几个月后的事了,到临危受命阶段,剩下不到半年时间。

  虽然直属领导让我们不要有心理负担,做好了表扬,做不好锅也不是我们的,但明显感觉到得到他的压力,毕竟一个不小心就能上新闻。

  二、分析

  既然开始做了,再说那些有的没的就没用了,直接开始分析需求。

  首先,业务逻辑并不算复杂,难点是在并发和数据准确性上。与客户沟通后,大致了解并发要求后,于是梳理了下。

  考生端登录接口、考生志愿信息查询接口需要4W QPS

  考生保存志愿接口,需要2W TPS

  报考信息查询4W QPS

  老师端需要4k QPS

  导入等接口没限制,可以异步处理,只要保证将全部信息更新一遍在20分钟以内即可,同时故障恢复的时间必须在20分钟以内(硬性要求)

  考生端数据要求绝对精准,不能出现遗漏、错误等和考生操作不一致的数据

  数据脱敏,防伪

  资源是有限的,提供几台物理机

  大的要求就这么多,主要是在有限资源下需要达到如此高的并发确实需要思考思考,一般的crud根本达不到要求。

  三、方案研讨

  接下来我会从当时我们切入问题的点开始,从前期设计到项目落地整个过程的问题及思考,一步步去展示这个项目如何实现的。

  首先,我们没有去设计表,没有去设计接口,而是先去测试。测试什么?测试我们需要用到或可能用到的中间件是否满足需求。

  MySQL

  首先是MySQL,单节点MySQL测试它的读和取性能,新建一张user表。

  向里面并发插入数据和查询数据,得到的TPS大概在5k,QPS大概在1.2W。查询的时候是带id查询,索引列的查询不及id查询,差距大概在1k。

  insert和update存在细微并发差距,但基本可以忽略,影响更新性能目前最大的问题是索引。如果表中带索引,将降低1k-1.5k的TPS。

  目前结论是,MySQL不能达到要求,能不能考虑其他架构,比如MySQL主从复制,写和读分开。

  测试后,还是放弃,主从复制结构会影响更新,大概下降几百,而且单写的TPS也不能达到要求。

  至此结论是,MySQL直接上的方案肯定是不可行的。

  Redis

  既然MySQL直接查询和写入不满足要求,自然而然想到加入redis缓存。于是开始测试缓存,也从单节点redis开始测试。

  get指令QPS达到了惊人的10w,set指令TPS也有8W,意料之中也惊喜了下,仿佛看到了曙光。

  但是,redis容易丢失数据,需要考虑高可用方案。

  实现方案

  既然redis满足要求,那么数据全从redis取,持久化仍然交给MySQL,写库的时候先发消息,再异步写入数据库。

  最后大体就是redis + rocketMQ + MySQL的方案。看上去似乎挺简单,当时我们也这样以为,但是实际情况却是,我们过于天真了。

  这里主要以最重要也是要求最高的保存志愿信息接口开始攻略。

  1)故障恢复

  第一个想到的是,这些个节点挂了怎么办?

  MySQL挂了比较简单,他自己的机制就决定了他即使挂掉,重启后仍能恢复数据,这个可以不考虑。

  rocketMQ一般情况下挂掉了可能会丢失数据,经过测试发现,在高并发下,确实存在丢消息的现象。原因是它为了更加高效,默认采用的是异步落盘的模式,这里为了保证消息的绝对不丢失,修改成同步落盘模式。

  然后是最关键的redis,不管哪种模式,redis在高并发下挂掉,都会存在丢失数据的风险。数据丢失对于这个项目格外致命,优先级甚至高于并发的要求。

  于是,问题难点来到了如何保证redis数据正确,讨论过后,决定开启redis事务。

  保存接口的流程就变成了以下步骤:

  redis 开启事务,更新redis数据

  rocketMQ同步落盘

  redis 提交事务

  MySQL异步入库

  我们来看下这个接口可能存在的问题。

  第一步,如果redis开始事务或更新redis数据失败,页面报错,对于数据正确性没有影响

  第二步,如果rocketMQ落盘报错,那么就会有两种情况。

  情况一,落盘失败,消息发送失败,好像没什么影响,直接报错就可。

  情况二,如果发送消息成功,但提示发送失败(无论什么原因),这时候将导致MySQL和redis数据的最终不一致。

  如何处理?怎么知道是redis的有问题还是MySQL的有问题?出现这种情况时,如果考生不继续操作,那么这条错误的数据必定无法被更新正确。

  考虑到这个问题,我们决定引入一个时间戳字段,同时启动一个定时任务,比较MySQL和redis不一致的情况,并自主修复数据。

  首先,redis中记录时间戳,同时在消息中也带上这个时间戳并在入库时记录到表中。

  然后,定时任务30分钟执行一次,比较redis中的时间戳是否小于MySQL,如果小于,便更新redis中数据。如果大于,则不做处理。

  同时,这里再做一层优化,凌晨的时候执行一个定时任务,比较redis中时间戳大于MySQL中的时间戳,连续两天这条数据都存在且没有更新操作,将提示给我们手动运维。

  然后是第三步,消息提交成功但是redis事务提交失败,和第二步处理结果一致,将被第二个定时任务处理。

  这样看下来,即使redis崩掉,也不会丢失数据。

  第一轮压测

  接口实现后,当时怀着期待,信心满满去做了压测,结果也是当头棒喝。

  首先,数据准确性确实没有问题,不管突然kill掉哪个环节,都能保证数据最终一致性。

  但是,TPS却只有4k不到的样子,难道是节点少了?

  于是多加了几个节点,但是仍然没有什么起色。问题还是想简单了。

  重新分析

  经过这次压测,之后一个关键的问题被提了出来,影响接口TPS的到底是什么???

  一番讨论过后,第一个结论是:一个接口的响应时间,取决于它最慢的响应时间累加,我们需要知道,这个接口到底慢在哪一步或哪几步?

  于是用arthas看了看到底慢在哪里?

  结果却是,最慢的竟然是redis修改数据这一步!这和测试的时候完全不一样。于是针对这一步,我们又继续深入探讨。

  结论是:

  redis本身是一个很优秀的中间件,并发也确实可以,选型时的测试没有问题。

  问题出在IO上,我们是将考生的信息用json字符串存储到redis中的(为什么不保存成其他数据结构,因为我们提前测试过几种可用的数据结构,发现redis保存json字符串这种性能是最高的),而考生数据虽然单条大小不算大,但是在高并发下的上行带宽却是被打满的。

  于是针对这种情况,我们在保存到redis前,用gzip压缩字符串后保存到redis中。

  为什么使用gzip压缩方式,因为我们的志愿信息是一个数组,很多重复的数据其实都是字段名称,gzip和其他几个压缩算法比较后,综合考虑到压缩率和性能,在当时选择了这种压缩算法。

  针对超过限制的字符串,我们同时会将其拆封成多个(实际没有超过三个的)key存储。

  继续压测

  又一轮压测下来,效果很不错,TPS从4k来到了8k。不错不错,但是远远不够啊,目标2W,还没到它的一半。

  节点不够?加了几个节点,有效果,但不多,最终过不了1W。

  继续深入分析,它慢在哪?最后发现卡在了rocketMQ同步落盘上。

  同步落盘效率太低?于是压测一波发现,确实如此。

  因为同步落盘无论怎么走,都会卡在rocketMQ写磁盘的地方,而且因为前面已经对字符串压缩,也没有带宽问题。问题到这突然停滞,不知道怎么处理rocketMQ这个点。

  同时,另一个同事在测试查询接口时也带来了噩耗,查询接口在1W2左右的地方就上不去了,原因还是卡在带宽上,即使压缩了字符串,带宽仍被打满。

  怎么办?考虑许久,最后决定采用较常规的处理方式,那就是数据分区,既然单个rocketMQ服务性能不达标,那么就水平扩展,多增加几个rocketMQ。

  不同考生访问的MQ不一样,同时redis也可以数据分区,幸运的是正好redis有哈希槽的架构支持这种方式。

  而剩下的问题就是如何解决考生分区的方式,开始考虑的是根据id进行求余的分区,但后来发现这种分区方式数据分布极其不均匀。

  后来稍作改变,根据证件号后几位取余分区,数据分布才较为均匀。有了大体解决思路,一顿操作后继续开始压测。

  一点小意外

  压测之后,结果再次不如人意,TPS和QPS双双不增反降,继续通过arthas排查。

  最后发现,redis哈希槽访问时会在主节点先计算key的槽位,而后再将请求转到对应的节点上访问,这个计算过程竟然让性能下降了20%-30%。

  于是重新修改代码,在java内存中先计算出哈希槽位,再直接访问对应槽位的redis。如此重新压测,QPS达到了惊人的2W,TPS也有1W2左右。

  不错不错,但是也只到了2W,再想上去,又有了瓶颈。

  不过这次有了不少经验,马上便发现了问题所在,问题来到了nginx,仍然是一样的问题,带宽!

  既然知道原因,解决起来也比较方便,我们将唯一有大带宽的物理机上放上两个节点nginx,通过vip代理出去,访问时会根据考生分区信息访问不同的地址。

  压测

  已经记不清第几轮压测了,不过这次的结果还算满意,主要查询接口QPS已经来到了惊人的4W,甚至个别接口来到6W甚至更高。

  胜利已经在眼前,唯一的问题是,TPS上去不了,最高1W4就跑不动了。

  什么原因呢?查了每台redis主要性能指标,发现并没有达到redis的性能瓶颈(上行带宽在65%,cpu使用率也只有50%左右)。

  MQ呢?MQ也是一样的情况,那出问题的大概率就是java服务了。分析一波后发现,cpu基本跑到了100%,原来每个节点的最大链接数基本占满,但带宽竟然还有剩余。

  静下心来继续深入探讨,连接数为什么会满了?原因是当时使用的SpringBoot的内置容器tomcat,无论如何配置,最大连接数最大同时也就支持1k多点。

  那么很简单的公式就能出来,如果一次请求的响应时间在100ms,那么1000 * 1000 / 100 = 10000。

  也就是说单节点最大支持的并发也就1W,而现在我们保存的接口响应时间却有300ms,那么最大并发也就是3k多,目前4个分区,看来1W4这个TPS也好像找到了出处了。

  接下来就是优化接口响应时间的环节,基本是一步一步走,把能优化的都优化了一遍,最后总算把响应时间控制在了100ms以内。

  那么照理来说,现在的TPS应该会来到惊人的4W才对。

  再再次压测

  怀着忐忑又激动的心情,再一次进入压测环节,于是,TPS竟然来到了惊人的2W5。

  当时真心激动了一把,但是冷静之后却也奇怪,按正常逻辑,这里的TPS应该能达到3W6才对。

  为了找到哪里还有未发现的坑(怕上线后来惊喜),我们又进一步做了分析,最后在日志上找到了些许端倪。

  个别请求在链接redis时报了链接超时,存在0.01%的接口响应时间高于平均值。

  于是我们将目光投向了redis连接数上,继续一轮监控,最终在业务实现上找到了答案。

  一次保存志愿的接口需要执行5次redis操作,分别是获取锁、获取考生信息、获取志愿信息、修改志愿信息、删除锁,同时还有redis的事务。

  而与之相比,查询接口只处理了两次操作,所以对于一次保存志愿的操作来看,单节点的redis最多支持6k多的并发。

  为了验证这个观点,我们尝试将redis事务和加锁操作去掉,做对照组压测,发现并发确实如预期的一样有所提升(其实还担心一点,就是抢锁超时)。

  准备收工

  至此,好像项目的高并发需求都已完成,其他的就是完善完善细节即可。

  于是又又又一次迎来了压测,这一次不负众望,重要的两个接口均达到了预期。

  这之后便开始真正进入业务实现环节,待整个功能完成,在历时一个半月带两周的加班后,终于迎来了提测。

  提测后的问题

  功能提测后,第一个问题又又又出现在了redis,当高并发下突然kill掉redis其中一个节点。

  因为用的是哈希槽的方式,如果挂掉一个节点,在恢复时重新算槽将非常麻烦且效率很低,如果不恢复,那么将严重影响并发。

  于是经过讨论之后,决定将redis也进行手动分区,分区逻辑与MQ的一致。

  但是如此做,对管理端就带来了一定影响,因为管理端是列表查询,所以管理端获取数据需要从多个节点的redis中同时获取。

  于是管理端单独写了一套获取数据分区的调度逻辑。

  第二个问题是管理端接口的性能问题,虽然管理端的要求没考生端高,但扛不住他是分页啊,一次查10个,而且还需要拼接各种数据。

  不过有了前面的经验,很快就知道问题出在了哪里,关键还是redis的连接数上,为了降低链接数,这里采用了pipeline拼接多个指令。

  上线

  一切准备就绪后,就准备开始上线。说一下应用布置情况,8+4+1+2个节点的java服务,其中8个节点考生端,4个管理端,1个定时任务,2个消费者服务。

  3个ng,4个考生端,1个管理端。

  4个RocketMQ。

  4个redis。

  2个mysql服务,一主一从,一个定时任务服务。

  1个ES服务。

  最后顺利上线,虽然发生了个别线上问题,但总体有惊无险,而正式反馈的并发数也远没有到达我们的系统极限,开始准备的水平扩展方案也没有用上,无数次预演过各个节点的宕机和增加分区,一般在10分钟内恢复系统,不过好在没有派上用场。

  最后

  整个项目做下来感觉越来越偏离面试中的高并发模式,说实在的也是无奈之举。偏离的主要原因我认为是项目对数据准确性的要求更高,同时需要完成高并发的要求。

  但是经过这个项目的洗礼,在其中也收获颇丰,懂得了去监控服务性能指标,然后也加深了中间件和各种技术的理解。

  做完之后虽然累,但也很开心,毕竟在有限的资源下去分析性能瓶颈并完成项目要求后,还是挺有成就感的。

  再说点题外话,虽然项目成功挽回了公司在该省的形象,也受到了总公司和领导表扬,但最后也就这样了,实质性的东西一点没有,这也是我离开这家公司的主要原由。不过事后回想,这段经历确实让人难忘,也给我后来的工作带来了很大的帮助。

  从以前的crud,变得能去解决接口性能问题。这之前一遇上,可能两眼茫然或是碰运气,现在慢慢会根据蛛丝马迹去探究优化方案。

  不知道我在这个项目的经历是否能引起大家共鸣?希望这篇文章能对你有所帮助。

0
相关文章