• 为什么高并发系统要落地其实并没有那么简单?
  • 发布于 2个月前
  • 323 热度
    0 评论
需求及背景
先来介绍下需求,首先项目是一个志愿填报系统,既然会扯上高并发,相信大家也能猜到大致是什么的志愿填报。核心功能是两块,一是给考试填报志愿,二是给老师维护考生数据。

本来这个项目不是我们负责,奈何去年公司负责这个项目的组遭到了甲方严重的投诉,说很多考生用起来卡顿,甚至把没填上志愿的责任归到系统上。甲方明确要求,如果这年再出现这种情况,公司在该省的所有项目将面临被替换的风险。讨论来讨论去,最后公司将任务落到我们头上时,已经是几个月后的事了,到临危受命阶段,剩下不到半年时间。

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

分析
既然开始做了,再说那些有的没的就没用了,直接开始分析需求。首先,业务逻辑并不算复杂,难点是在并发和数据准确性上。与客户沟通后,大致了解了并发要求后,于是梳理了下:
1.考生端登录接口、考生志愿信息查询接口需要 4W QPS
2.考生保存志愿接口,需要 2W TPS
3.报考信息查询 4W QPS
4.老师端需要 4k QPS
5.导入等接口没限制,可以异步处理,只要保证将全部信息更新一遍在20 分钟以内即可,同时故障恢复的时间必须在 20 分钟以内(硬性要求)
6.考生端数据要求绝对精准,不能出现遗漏、错误等和考生操作不一致的数据
7.数据脱敏,防伪

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


大的要求就这么多,主要是在有限资源下需要达到如此高的并发确实需要思考思考,一般的 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 的方案。

看上去似乎挺简单,当时我们也这样以为,但是实际情况却是,我们过于天真了。这里主要以最重要也是要求最高的保存志愿信息接口开始攻略。第一个想到的是,这些个节点挂了怎么办?

MySQL 挂了比较简单,他自己的机制就决定了他即使挂掉,重启后仍能恢复数据,这个可以不考虑。RocketMQ 一般情况下挂掉了可能会丢失数据,经过测试发现,在高并发下,确实存在丢消息的现象。原因是它为了更加高效,默认采用的是异步落盘的模式,这里为了保证消息的绝对不丢失,修改成同步落盘模式。

然后是最关键的 Redis,不管哪种模式,Redis在高并发下挂掉,都会存在丢失数据的风险。数据丢失对于这个项目格外致命,优先级甚至高于并发的要求。于是,问题难点来到了如何保证 Redis 数据正确,讨论过后,决定开启 Redis 事务。
保存接口的流程就变成了以下步骤:
1.Redis 开启事务,更新 Redis 数据
2.RocketMQ 同步落盘
3.Redis 提交事务

4.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 个消费者服务。

5 个 ng,4 个考生端,1 个管理端。
4 个 RocketMQ。
4 个 Redis。
2 个 MySQL 服务,一主一从,一个定时任务服务。
1 个 ES 服务。

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

最后
整个项目做下来感觉越来越偏离面试中的高并发模式,说实在的也是无赖之举。偏离的主要原因我认为是项目对数据准确性的要求更高,同时需要完成高并发的要求。但是经过这个项目的洗礼,在其中也收获颇丰,懂得了去监控服务性能指标,然后也加深了中间件和各种技术的理解。

做完之后虽然累,但也很开心,毕竟在有限的资源下去分析性能瓶颈并完成项目要求后,还是挺有成就感的。再说点题外话,虽然项目成功挽回了公司在该省的形象,也受到了总公司和领导表扬,但最后也就这样了。实质性的东西一点没有,这也是我离开这家公司的主要原由。

不过事后回想,这段经历确实让人难忘,也给我后来的工作带来了很大的帮助。从以前的 crud,变得能去解决接口性能问题。这之前一遇上,可能两眼茫然或是碰运气,现在慢慢的会根据蛛丝马迹去探究优化方案。不知道我在这个项目的经历是否能引起大家共鸣?

希望这篇文章能对你有所帮助。
用户评论