ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

校招季高并发架构设计:从缓存穿透到压测验证的稳定性实战

校招季高并发架构设计:从缓存穿透到压测验证的稳定性实战 校招季一到招聘系统就得“渡劫”。每年九到十一月各家公司的校招系统都要面对几万甚至几十万应届生的集中访问简历投递、在线笔试、面试预约这些核心接口的QPS瞬间能冲到平时的几十倍。我自己经历过系统被压垮的惨痛教训一次是数据库连接池被打满导致全站502一次是缓存穿透把主库打到只读。这篇文章就好好聊聊招聘系统在面对校招/大促这类流量峰值时从架构设计到压测验证到底该怎么一步步把稳定性扛起来。1. 先搞清楚问题边界校招流量到底特殊在哪1.1 为什么说是“万人级”的洪峰很多没做过招聘系统的同学会觉得校招流量再大也不过是几万人同时在线和双十一那种千万级并发没法比。这个认知很危险。校招的流量连接数不多但瞬时集中度极高而且每写少请求都带强一致性的诉求。举个例子某一线互联网公司校招开放简历投递通道上午十点整开放十点零一秒时可能有上万名学生在同一秒点击“提交简历”。这个提交动作不是单纯的HTTP请求后端要完成简历文件解析、内容校验、结构化存储、关键词索引、发送通知邮件等一系列操作一个请求的完整链路可能涉及五六个服务。如果这一秒钟同时涌进来几千个这样的请求数据库压力是成倍数放的。更麻烦的是校招季不是一两天而是持续两三个月中间有大量热点时段网申开放、笔试通知、面试安排、offer发放每一个节点都会出现一次流量脉冲。这种脉冲的峰值和持续时间虽然不如电商大促夸张但对稳定性要求是“零容忍”的因为学生等不起HR更等不起。1.2 招聘系统与电商大促的流量差异电商大促的流量模型是“短时高峰、哑铃型分布”用户浏览商品多、下单少读多写少的比例可能达到100比1。招聘系统则不太一样笔试开始的那一刻几万人同时进入一个考试系统几乎所有人的行为都是写操作提交答案、自动保存、交卷。这种读写比例倒挂的场景对架构的考验完全不同。电商可以用缓存扛住大部分读流量但招聘系统的高峰期写流量占据很大比重尤其是简历投递和在线笔试提交这两个场景。这就意味着系统设计上不能只考虑“读多写少”的标准思路还要为写并发做好充分的预案。如果照搬电商那套缓存方案不去处理写入链路的压力一样会被打趴下。2. 整体架构设计每一层都要兜得住峰值2.1 接入层Nginx与负载均衡的取舍招聘系统的流量入口首先经过的肯定是负载均衡和反向代理层。这里要解决的核心问题是海量连接来了接入层不能成为第一个瓶颈。我比较推荐在接入层采用LVS Nginx 两层架构。LVS跑在操作系统内核态处理四层转发抗并发能力强得多单机轻松支撑几十万并发连接把LVS放在最前面做流量分发。后层挂多台Nginx做七层反向代理负责URL路由、静态资源缓存、TLS终止、限流等逻辑。这里有一个很多团队容易踩的坑把SSL证书直接放在业务应用上每台应用服务器都要维护证书不仅浪费CPU而且扩展应用实例的时候还要同步证书文件。正确的做法是让Nginx统一处理TLS走HTTP回源到后端应用这样后续扩容任意实例都无感。Nginx的关键配置项里worker_processes建议设置为CPU核数worker_connections调大到4096以上。但光调大还不够需要配合keepalive_timeout缩短连接保持时间避免大量半开连接占用文件描述符。接层还需要开启limit_req做接口级别的限流比如简历投递接口单IP的每秒请求数限制在5次以内防止脚本刷接口。2.2 应用层无状态化与横向扩容接入层扛住连接以后压到应用层的QPS依然不小应用层必须做到无状态化这是能否横向扩容的前提。什么是无状态简单说请求不应该依赖某一台具体服务器上保存的本地数据。用户session不能存在单台服务器的内存里而是要放到Redis上传的临时文件不能写本地磁盘而是要放到对象存储应用本地的日志要尽量采集走不能成为排查问题时才想起来要登录某台机器去看的“黑盒子”。我见过不少团队把session存在应用本地高峰期想扩容加机器结果用户一被分到新机器就掉登录状态。校招期间用户操作路径长填了半天的简历突然被踢下线那种体验灾难性是致命的。所以Session统一迁移到Redis并做持久化是应用层改造的第一步没有商量余地。做好无状态化之后扩容就变成一件非常舒服的事。提前在云上配置好镜像模板流量上来之前把应用实例从10台扩展到50台负载均衡自动把请求分发过去整个过程不用改一行代码。但记住一个原则扩容要在流量来之前做而不是等系统报警了再做冷启动和初始化缓存都需要时间等到扛不住了才扩用户早就怨声载道了。2.3 数据层读写分离与分库分表的边界数据层往往是校招季最先扛不住的地方原因很简单数据库的连接数是硬上限。MySQL默认的max_connections通常设置为1000左右每增加一个后端应用实例意味着连接池会新增一批数据库连接。假设每个应用实例配置40个连接50个实例就是2000个连接直接把配置改大修改数据库单机能承载的连接数也有1000左右的合理上限如果经不起压力即便改大也没用。常规方案是一主多从的读写分离架构。主库扛写流量从库扛读流量写操作强制走主库读操作根据延迟容忍度走从库。招聘系统里像查看职位列表、搜索公司、浏览面试经验这些读接口完全可以走从库把主库的压力释放出来的。但从库有同步延迟简历投递后立刻查询状态这种强一致性读必须强制走主库。如果读写分离还不够就要考虑分库分表。不过我的建议是除非单表数据量真到了千万级以上否则别轻易分。分库分表带来的麻烦远比收益大跨库join、分布式事务、全局ID生成每一个都是硬骨头。校招季的数据量虽然大但一年一季历史数据完全可以通过归档解决而不是一上来就上分库分表这套重型武器。3. 核心环节的实操与关键参数3.1 Redis缓存设计热点数据怎么扛招聘系统里热点数据的特征非常明显校招职位列表、公司介绍、笔试时间安排、面试常见问题这些数据在一段时间内被大量重复读取而且变更频率极低。这类数据就是缓存的天然对象不缓存纯属浪费。缓存设计上我遵循一个简单原则能缓存的数据一定要缓存缓存不了一秒也要设一个极短的过期时间。比如职位详情可以缓存15分钟公司介绍缓存1小时而面试官日程这种实时性要求高的数据可以设置10秒的短期缓存哪怕只挡住一部分重复查询也是好的。缓存使用的标准姿势是Cache Aside模式读的时候先查Redis命中直接返回未命中则查数据库回填缓存设置过期时间。写的时候先更新数据库再删除缓存。在这个模式里最怕的是缓存穿透——大量请求查询缓存中不存在且数据库中也不存在的数据。比如有坏人恶意构造一批不存在的职位ID去刷接口缓存永远不命中所有请求都落到数据库直接把库打垮。解决穿透我常用两招。第一招是空值缓存查库查不到数据时也在Redis里存一个空值过期时间设置短一点比如60秒这样同一个不存在的ID在一分钟内不会再次打到数据库。第二招是布隆过滤器把所有正常的职位ID提前加载到布隆过滤器里请求进来先判断ID是否存在不存在直接返回连数据库都不用查。另外还要注意缓存雪崩。如果大量缓存的过期时间设置成一样的到了过期那一刻所有请求同时去查数据库数据库瞬间迎来一波“反扑”。解决方法是给过期时间加一个随机偏移量比如基础过期时间300秒实际设置为300加0到60秒随机值。这个细节看起来不起眼关键时候能救命的。3.2 消息队列削峰投递和通知的异步化改造写操作链路里最大的隐患是同步调用耗时过长的下游服务。以简历投递为例传统同步流程是这样的用户提交简历 → 应用接收 → 解析简历文件 → 写入数据库 → 发送邮件通知 → 发送短信通知 → 返回成功这个链路里简历解析可能需要几百毫秒邮件和短信通知又要一两秒整个请求下来要3秒以上。高并发时每个请求占满了Tomcat线程线程池被打满后续请求只能排队甚至超时。削峰的正确思路是把非核心步骤异步化。用户提交简历成功之后应用只做最核心的事情把简历数据落库把简历文件的解析任务丢进消息队列然后立即返回“投递成功”。真正的简历解析、关键词提取、邮件通知、短信发送全部通过MQ异步执行由后台Worker慢慢处理。消息队列选型上Kafka吞吐最高但使用复杂度也高RabbitMQ功能完善但吞吐相对低一些RocketMQ在两者之间平衡得较好。如果是中小规模团队直接用RocketMQ开箱即用事务消息还能解决“本地消息表”式的分布式事务问题。特别提醒一下MQ不是一装了事消费者端的吞吐能力也要提前压测。如果生产者每秒往队列里塞5000条消息消费者每秒只能处理1000条消息堆积会越来越严重简历解析延迟可能从几秒变成几小时校招期间这种延迟完全是无法接受的。消费者的批量消费、并发线程数、单条消息的处理时间都要在压测阶段验证清楚。3.3 限流熔断降级把系统压垮前的最后防线再好的扩容方案也有极限再大的集群也可能被突发流量打穿。所以限流、熔断、降级这三板斧是保障校招系统稳定运行的“最后防线”必要时一样都不能少。限流最常用的是令牌桶算法。Google Guava的RateLimiter是单机版的简单方便分布式场景下可以用RedisLua脚本实现全局限流。以简历投递接口为例我一般设一个全局的令牌桶容量1000每秒填充1000个令牌超过这个速度的请求直接返回“系统繁忙请稍后重试”。宁可让一小部分用户重试也不能让整个系统崩溃。熔断我用的是Sentinel或者Resilience4j这套机制。当某个下游服务比如短信网关的调用错误率在10秒内超过50%时熔断器自动打开后续请求不再调用这个服务而是走降级逻辑直接返回一个预制的“通知已发送”的假响应。等错误率降低并持续一段时间后熔断器半开尝试放几个请求过去成功则恢复失败则继续熔断。降级策略在校招系统里很好设计短信发送降级为站内信实时通知降级为定时汇总推送简历附件解析降级为延后处理。说白了就是保住核心链路舍弃非核心体验。校招期间流程能走通、数据不丢、最终一致比什么都重要。4. 压测验证迁移到云端后拿数据说话4.1 环境迁移的坑与注意事项聊完架构设计说说实操。很多团队的招聘系统原本部署在自建机房或单节点K8s环境里为了应对校招季的弹性扩容往往会迁移到云平台比如阿里云ECS上。这个迁移过程如果没处理好很容易在迁移过程中出问题或者在迁移完成后运行不稳定。我自己踩过的一个大坑是K8s集群迁移时的服务依赖顺序。单节点K8s上跑着若依微服务整套环境Services之间的依赖关系非常复杂A服务启动时要注册到NacosNacos又依赖MySQLMySQL又依赖Redis。如果迁移时一股脑把全部服务都迁过去再启动很容易出现服务启动顺序不对导致相互等待超时。正确的做法是先迁基础设施MySQL、Redis、Nacos再迁基础服务最后迁业务服务。等所有服务都注册到Nacos之后再切流量。另一个常见坑是配置文件里的内网地址。在自建环境里服务之间可能通过内网IP或K8s Service Name互相调用迁移到新环境后如果IP段变了服务间的调用会全部失败。迁移前要把所有配置项过一遍特别是数据库连接地址、Redis地址、Nacos地址、RocketMQ地址确认它们在新环境下能互通。迁移还有一个“准不停服、不丢数据”的要求。我用的方案是双写双读过渡新旧环境同时运行旧环境继续接流量新环境同步数据。具体做法在业务代码里加一个Switch开关先把流量切一部分比如10%到新环境验证没问题后逐步提高比例直到全部切完。数据库层面用DTS或者自建同步任务做增量同步等两边数据追平之后再做最终切换。整个过程用户无感知数据零丢失。4.2 JMeter压测脚本设计的实战细节迁移完成之后压测人员一般会使用JMeter脚本模拟高并发流量验证云上环境的承载能力。JMeter我用得比较多这玩意儿功能强大但细节掌握不好压测结果就失真了。线程组设计是第一个关键点。不要一次性把所有线程都拉满而是采用阶梯式加压。比如先用100并发跑2分钟观察各项指标稳定后再升到500并发之后是1000、2000每个阶段维持3到5分钟记录每个阶段的QPS、响应时间、错误率。这样做的好处是能清晰看到系统在哪个并发量级开始出现性能拐点为后续扩容量化提供依据。HTTP请求配置上要记得设一个合理的超时时间比如连接超时3000ms响应超时10000ms。另外要开启JMeter的KeepAlive选项模拟真实的HTTP长连接行为。如果不开启JMeter每次请求都新建TCP连接压测结果里会混入大量连接建立的耗时严重干扰真实数据。请求头也要尽量模拟真实的浏览器请求特别是需要登录态的接口要提前处理好Cookie和Token的传递。监听器与结果提取我推荐组合使用几个聚合报告Aggregate Report看整体指标响应时间图Response Time Graph看趋势Server Agent配合PerfMon Metrics Collector监控压测期间后端CPU、内存、IO的变化。这几个配合起来能帮你快速定位瓶颈是在应用层、数据库还是网络层。还有一个容易忽略的点压测数据要做成独立的测试数据不要污染线上数据。准备一批测试账号和测试简历压测结束后要清理干净避免校招正式开始时历史遗留的脏数据影响业务。数据库里查数据的时候一定要加好条件过滤不然压测数据哪天被统计进了运营报表HR那边会反馈一堆问题。4.3 压测结果的关键指标怎么定压测完不能光看“没挂就算过”要设定明确的量化指标。我做压测时通常关注这几个指标QPS每秒请求数核心接口在稳定状态下能支撑的最大QPS。RT响应时间核心接口的P95和P99响应时间。P99要求在2秒以内超过这个值用户体验会有明显影响。错误率压测期间错误请求占总请求数的比例一般要求低于0.1%。真实用户提交简历失败可不会给你试第二次的机会。资源水位压测到目标QPS时应用服务器的CPU使用率不能长期超过70%数据库CPU不能超过60%。留出余量给突发的二次高峰做准备。我最看重的是一个指标叫拐点QPS。也就是系统在没有报错、响应时间保持在可接受范围内的前提下能承受的最大QPS值。这个值一旦测出来就直接决定了你在校招季之前要扩多少台机器。比如拐点单机QPS是200预估峰值需要支撑10000 QPS那至少需要50台应用实例预留30%冗余就是65台。有了这个数据申请预算、写扩容方案都有底气了。压测的时候建议把监控一起打开。推荐用PrometheusGrafana这套组合把你的MySQL慢查询、Redis命中率、Tomcat活跃线程数、JVM堆内存全部实时可视化。压测过程中看到数据库慢查询飙起来的同时活跃线程数也在快速上涨立刻就能明白瓶颈在哪。5. 常见问题与排查技巧实录5.1 高并发下的“连接数打满”这是校招季最常见的事故具体表现是新请求大量超时数据库日志里全是Too many connections。排查步骤我一般按这个顺序来登录数据库执行SHOW STATUS LIKE Threads_connected;确认当前连接数。执行SHOW PROCESSLIST;查看哪些连接的Command状态是Query还是Sleep区分活跃连接和空闲连接。如果是大量Sleep连接说明连接池的maxConnection配置超过了数据库承载能力应用获取到连接之后没有快速归还或者连接池的空闲连接回收策略不合理。如果是大量Query连接集中在少数的几个慢SQL上那直接按执行时间排序把慢SQL拿出来做执行计划分析加索引或者改写SQL。预防措施连接池的初始大小不要配置太高用“够用再涨”的策略。HikariCP里maximumPoolSize配置在30到50之间通常够了minimumIdle配置为10到20。最关键的是在数据库侧做好max_connections的监控告警水位到达70%就要响铃通知而不是等到100%打满才处理。5.2 高并发下的CPU飙升与GC频繁压测阶段另一个高频问题应用服务器的CPU突然飙到90%以上接口响应变慢。用top命令看一下发现Java进程占了大量CPU再用jstat -gcutil pid 1000查看GC情况往往能看到Full GC频繁触发老年代几乎撑满。这种问题的根因通常有两种。第一种是内存里塞了太多不该存的对象典型的是把一些超大批量的数据一次性加载到内存里处理比如某个定时任务一次性查了10万条数据然后循环处理。第二种是缓存对象过大占满了堆内存比如把用户上传的Base64编码的简历文件直接塞进Redis又通过应用内存做了一层缓存。排查时可以先用jmap -dump:formatb,fileheap.bin pid导出堆快照再用MAT分析工具看哪个对象占据了大部分内存。常见的罪魁祸首是HashMap存了太多实体对象、或者列表查询没有做分页。解决办法也直接分批处理限制单次查询条数缓存只存必要字段不存大对象必要时调整JVM堆大小但仍要控制对象大小。5.3 缓存穿透导致的数据库突然压力增大在校招大流量场景里如果某个热门职位的ID被人为遍历调用比如通过简历ID猜其他学生的数据首先被穿透的就是缓存这一层。值得写一下的是我之前遇到过的一次事故起因是一个运营活动页把一批职位ID拼接到了URL上形成静态化页面但因为页面缓存时间设置过长数据都已经下架了前端还在轮询这些不存在的ID。大量请求绕过缓存直接打到数据库数据库的SELECT QPS瞬间从每秒500打到5000导致其他核心业务接口全部变慢。解决方式比较有意思直接把入口的请求参数合法性检查做好。在Nginx那一层用Lua脚本对请求的ID做一个正则校验不符合ID规则的请求直接返回404。同时缓存空值布隆过滤器的双保险也一并启用。从那以后我再也没遇到过高并发时缓存穿透导致数据库被打挂的情况。5.4 面试高峰期经常遇到的一个诡异问题慢请求堆积面试官日程查询接口平时只有几百QPS一到面试高峰能到几千QPS而且RT从正常的50ms飙升到5秒。排查发现这个接口会扫描面试官接下来7天的所有空余时间段SQL里用了一个NOT EXISTS子查询。问题在于数据量涨到一定程度之后这个子查询无法利用索引做了全表扫描。这种问题的排查思路是压测报告里先看哪个接口的RT恶化最严重然后单独压测这个接口用EXPLAIN分析SQL执行计划。如果看到typeALL且有Using temporary基本就是索引缺失或者SQL写法有问题。改写SQL思路不算难难点在于找到它。所以压测阶段做性能分析时慢SQL日志一定要打开把执行时间超过500ms的SQL全部记录下来逐条分析。写在最后的小建议从技术层面总结一套校招季的高并发应对方案并不复杂接入层负载均衡、应用层无状态扩容、数据层缓存与读写分离、写链路异步削峰、限流熔断兜底再加上压测验证。但真正到了校招季那一天考验的其实更多的是执行细节和预案的完备程度比如有没有提前演练过故障切换、有没有在监控大盘上把所有关键指标都加上告警、有没有预备一个第一时间能联系上的值班梯队。我个人经验里最值钱的一条建议不要在校招季当天做任何未经验证的重大变更。所有配置修改、代码上线、扩容操作提前两天全部做完压测确认没问题之后就不动了。如果实在有必须更新的内容也要做好代码的回滚方案和配置的备份留好一键回退的开关。最后一个小技巧送给大家在大促或校招季来之前把你们所有核心接口的响应时间上限值在监控系统里设置好不是加告警而是加自动降级开关。一旦某个接口的P99响应时间连续5分钟超过阈值自动触发降级策略返回兜底数据或者排队提示。这样就算真的出现不可控的流量暴涨系统也能保全核心投递链路而不是整个平台一起挂掉。这套机制我用了好几年校招季再也没出过一起长时间宕机的事故。
返回列表