每年6月底到8月初,是这个系统最紧张的时段。高考出分、志愿填报、录取结果查询这几个节点挤在一起,流量曲线就像脉冲信号——平时系统日活只有两三千,放榜和填报窗口那几天,瞬时并发能冲到每秒几千甚至上万次请求。而且招生数据牵涉到考生的前途,错一条就意味着事故,容不得半点含糊。我手上这套高校招生服务平台,就是在这样的业务背景下从单体应用一步步重构而来的。
这套系统整体技术栈是 SpringBoot + Spring Cloud 微服务架构,前端分两条线:管理端用 Vue 实现,面向招生办老师的后台操作;C端用微信小程序,面向考生和家长完成报名、志愿填报、缴费、录取查询等自助操作。整篇文章我会把微服务拆分思路、Spring Cloud 各组件的实际配置逻辑、双端前端的工程化处理、核心链路里的并发与事务方案,以及上线后遇到的真实问题完整梳理一遍。对准备做类似微服务项目的同学,或者正在把单体系统往 Spring Cloud 方向迁移的团队,应该会有比较直接的参考价值。
1. 招生季的流量脉冲:为什么高校招生系统必须走微服务架构
1.1 招生系统和普通管理系统的本质区别
先看业务特点。高校招生平台不是典型的"后台管理系统",它具备几个非常鲜明的特征:
- 高并发窗口期集中:体量再大的电商系统,大促也是人为设计的活动周期,可以提前做预案;招生平台的流量高峰是刚性的,出分时间点一公布,考生查分、报名的请求近乎同时涌入,没法通过运营手段错峰。
- 数据准确性要求极高:考生个人信息、成绩、志愿顺序、录取状态,任何一条数据错位都会引发严重纠纷。平时开发可以妥协的性能点,在这里不能妥协。
- 涉及资金链路:艺术类、体育类校考报名通常需要缴费,这就让系统不只是信息管理,而是带支付能力的业务系统,对事务一致性有硬性要求。
- 长链路、多角色:考生、家长、招办老师、院系审核员、财务、系统管理员,不同角色的操作权限和流程节点差异很大。
在这个前提下,最早的单体版本就撑不住了。一个 Spring Boot 单体应用里塞了权限、考生、院校、志愿、缴费、资讯、客服等十几个模块,出分那天 Tomcat 线程池被打满,数据库连接池耗尽,整个系统像多米诺骨牌一样全部不可用——某个模块的一个慢查询,能把所有人都拖下水。更尴尬的是,单体应用无法针对"查询热点"做局部扩容,流量一上来只能整体升配,成本高且无效。
这就是我们最终引入 Spring Cloud 微服务架构的直接原因。拆分后,不同的业务模块可以独立部署、独立扩缩容:报名高峰期只扩容报名服务和支付服务,资讯服务保持两副本即可;某一服务故障也不会拖垮整个平台。
1.2 技术栈全景:每一层选型解决什么问题
这套系统的整体架构分层如下表,每一层都是针对招生场景的特定诉求去选的,不是单纯追新。
| 层次 | 技术选型 | 解决的招生场景问题 |
|---|---|---|
| 接入层 | 微信小程序 C端 + Vue 管理端 | C端覆盖考生高频操作,管理端覆盖招生办全流程审核 |
| 网关层 | Spring Cloud Gateway | 统一鉴权、接口限流、跨域处理、灰度路由 |
| 注册配置中心 | Nacos | 服务注册发现、配置动态刷新,招生季配置调整不用重启 |
| 微服务框架 | Spring Boot + Spring Cloud | 业务模块独立开发、独立部署、独立扩容 |
| 服务调用 | OpenFeign + Sentinel | 服务间声明式调用,超时与熔断保护,防止雪崩 |
| 数据层 | MySQL + Redis | MySQL存储核心业务数据,Redis扛热点与分布式锁 |
| 异步与削峰 | RabbitMQ | 报名通知、录取通知异步投递,削峰填谷 |
| 部署运维 | Docker + K8s | 各微服务容器化编排,按招生季流量水平扩缩容 |
为什么 C 端选微信小程序而不是 App 或 H5?核心原因很简单:招生季考生需要快速触达,小程序免安装、微信内直接打开、转发分享方便,而且家长群体对微信的使用熟练度远高于安装一个陌生 App。管理端选 Vue 则是看中它的生态成熟度,配合 Element UI 这类组件库,完全可以快速搭建出表格密集、表单复杂的后台页面。
2. 服务边界怎么切:高校招生平台的九个核心微服务
2.1 按业务能力拆分的服务清单
微服务拆分最需要克制,一旦边界划错,后面改起来成本极高。我们的做法是严格按业务能力和数据域去划,而不是按"页面"去划。最终拆出了 9 个核心服务:
| 服务名称 | 核心职责 | 关键数据 |
|---|---|---|
| gateway-server | 统一网关:路由、鉴权、限流 | 路由规则、限流规则 |
| auth-server | 账号认证、Token签发、角色权限 | 用户账号、角色、菜单权限 |
| user-server | 考生档案管理、招办老师信息管理 | 考生基础档案、户籍、照片 |
| school-server | 院校信息、专业信息、招生计划 | 院校库、专业库、招生计划数 |
| enroll-server | 报名、志愿填报、资格审核 | 报名表、志愿表、审核状态 |
| payment-server | 报名费缴费、退费、对账 | 订单、支付流水、退款记录 |
| result-server | 成绩同步、录取状态管理 | 成绩数据、录取结果、通知书信息 |
| article-server | 招生政策、公告、常见问题 | 资讯内容、政策文件、FAQ |
| message-server | 站内信、微信模板消息通知 | 消息记录、模板发送日志 |
每个服务都能讲清楚"它维护什么数据的生命周期",这是拆分时最关键的判据。比如缴费这个环节,最初我们想放进 enroll-server 里做成一个"报名时顺便缴费"的接口,但后来发现支付涉及回调、对账、退款,和报名数据的变更频率完全不同,硬凑在一起只会让一个服务的状态机变得极其复杂。拆成独立 payment-server 后,支付状态变化可以通过消息通知 enroll-server,各自维护自己的状态机,边界非常清晰。
2.2 数据拆分与服务间禁止直连数据库的铁律
服务拆了,数据库也必须跟着拆,否则一切白费。我们从单库拆成 9 个业务库,每个服务独占自己的库,服务之间禁止直接访问对方的表。一开始团队里有人想省事,在 user-server 里直接写 SQL 关联 school-server 的表,被我压下来了。一旦放开这种"捷径",服务间的数据耦合会以极快的速度蔓延,最终数据域名存实亡。
那么不同服务的数据怎么关联?两个标准姿势:
**方式一:服务间 OpenFeign 调用获取。**比如 enroll-server 需要展示考生姓名,就通过 Feign 调 user-server 的接口,按 userId 批量查询考生概要信息,拿到后组装到自己的响应里。这种方式适合"当前服务需要展示对端数据"的场景。
**方式二:冗余关键字段 + 消息异步同步。**比如支付成功后,payment-server 发一条 MQ 消息,enroll-server 自己消费后在本地报名表冗余一份支付状态和支付流水号。这样查询报名状态时完全不用跨服务调用,性能更好。
冗余字段这种方案很多人不敢用,怕数据不一致。但在分布式系统里,跨服务的数据本来就做不到强一致,用[CQRS 模式]的思维去理解就顺了:写入走服务间调用,查询走本地数据。只要幂等和补偿机制做扎实,最终一致性完全可接受。
2.3 强一致与最终一致的正确划分
这个问题直接决定系统的事务复杂度。我们一开始犯过错误,试图让所有操作都达到强一致,结果动不动就锁表、超时。后来用一张表格把所有核心场景的一致需求理清楚,才发现大部分场景其实只需要最终一致:
| 业务场景 | 一致性要求 | 实现方案 |
|---|---|---|
| 考生注册 | 强一致 | 单库本地事务 |
| 志愿填报保存 | 强一致 | 单库本地事务 |
| 正式提交志愿 | 最终一致 | 事务消息 + 状态机,多个服务间异步协调 |
| 报名缴费 | 强一致 | 支付服务本地事务 + 支付渠道回调幂等处理 |
| 录取状态流转 | 最终一致 | 人工审核后异步通知 + 补偿对账 |
| 考生档案修改 | 强一致 | 校验后单库更新 |
凡是能做成单库本地事务的,绝不上分布式事务。真正需要跨服务参与的,才引入消息和状态机来推进。这一点是微服务实战里最重要的架构原则之一。
3. Spring Cloud 各组件在招生场景里的落地细节
3.1 注册中心和配置中心:Nacos 的具体用法
我们用的注册中心和配置中心都是 Nacos。为什么没选 Eureka?很简单,Eureka 2.0 早已停止维护,而且它只是注册中心,配置中心还需要另搭 Spring Cloud Config。Nacos 一个组件把注册中心和配置中心都干了,还内置控制台,可以直观看到各服务的健康状态和配置变更历史,运维成本低很多。
注册中心本身没什么悬念,每个服务引入依赖后配置spring.cloud.nacos.discovery.server-addr即可。真正要留意的,是 Nacos 作为配置中心时的动态刷新机制。招生季免不了频繁调整限流阈值、开关某些院校的报名入口,这些配置如果放在本地application.yml里,每次改完都要发版重启,这在运营眼里是不可接受的。
我们的做法是:把可变配置抽到 Nacos 配置中心,服务里用@RefreshScope标注需要动态刷新的 Bean。比如报名入口的开关配置:
enroll: switch: true max-quota: 5000 # true 表示开启报名入口,max-quota 限制单个院校批次的最大报名人数然后在服务中通过@ConfigurationProperties注入,加上@RefreshScope:
@RefreshScope @ConfigurationProperties(prefix = "enroll") @Component public class EnrollSwitchConfig { private boolean switch; private int maxQuota; // getter / setter 省略 }改配置后 Nacos 推送,服务毫秒级刷新,重启动作完全省掉。这里有项目里踩过的坑:@RefreshScope只能作用在它修饰的 Bean 及其直接依赖上,如果这个配置对象被 Service 层注入,但 Service 本身没有标注@RefreshScope,动态刷新就不生效,得手动重启。所以配置直接注入的 Bean 和调用链上的 Bean 都要加上@RefreshScope,否则刷新不到预期效果。
3.2 Gateway 网关:鉴权限流路由的统一收口
Spring Cloud Gateway 是整套系统的流量入口,所有来自小程序端和 Vue 管理端的请求先到网关,再由网关路由到具体的微服务。网关层主要干三件事。
第一件事是鉴权拦截。登录用户会拿到 JWT Token,后续请求在 Header 里带上。网关里加一个全局过滤器,对需要登录的路径做 Token 校验和角色校验。这里要注意,网关不应该承载太重的业务逻辑,只需要解析 JWT 的身份声明,比如 userId、role,然后放到请求头里转发给下游服务,下游服务直接信任网关传过来的身份即可。
第二件事是路由转发。我们按服务名做前缀路由,例如/api/enroll/**路由到 enroll-server,/api/payment/**路由到 payment-server。配置大概长这样:
spring: cloud: gateway: routes: - id: enroll-route uri: lb://enroll-server predicates: - Path=/api/enroll/** filters: - StripPrefix=1 - id: payment-route uri: lb://payment-server predicates: - Path=/api/payment/** filters: - StripPrefix=1lb://前缀是告诉网关走负载均衡,从 Nacos 注册中心按服务名找到实例列表。如果多个实例在线,默认用负载均衡策略分发,这样报名高峰时只需把 enroll-server 扩容到 8 个实例,网关自动在它们之间做流量分发。
第三件事是接口限流。招生季的报名接口是我们重点保护的对象,利用网关的 RequestRateLimiter 过滤器工厂,结合 Redis 实现按 IP 和按用户维度的限流。限流阈值根据压测结果设置,宁可挡掉一部分普通请求,也不能让瞬间流量打垮整个系统。
3.3 OpenFeign 调用与 Sentinel 熔断降级配合
服务之间的同步调用我们用 OpenFeign。它的好处是写起来像本地接口调用,代码直观。但 Feign 默认超时配置非常激进,如果没有显式设置,进程级默认是 60 秒,这在流量高峰期无异于慢性自杀——一个下游服务卡顿,上游线程池全部占住等待,很快连锁耗尽。
我们的 OpenFeign 配置一定要配合 Sentinel 一起用。Sentinel 做的是熔断降级:当下游服务接口的异常比例或响应时间超过阈值,服务端直接熔断,不再发起真实调用,而是快速返回降级结果。比如调用 school-server 查询院校列表超时,降级逻辑返回一个空列表加提示"院校数据加载失败,请稍后重试",保证页面框架能出来,不让整个请求卡死。同时给 Feign 设置了较短的超时时间:
feign: client: config: default: connectTimeout: 2000 readTimeout: 3000这是经验值。连接超时 2 秒、读超时 3 秒,再配合 Sentinel 熔断,才能保证招生季高峰期单个接口的响应不会无限拖下去。要注意的是,熔断降级返回的内容要设计好,不能直接把"系统错误"这样的裸信息抛给前端,降级逻辑里要把业务语义保留住,比如"当前报名人数较多,请稍后重试",对考生更友好也更安全。
4. 双端并行的前端工程:Vue 管理端与微信小程序 C 端
4.1 管理端和小程序端各自承担的职责边界
后端拆成微服务,前端自然也要拆,但拆法不是"按服务拆页面",而是按用户角色拆应用。管理员和考生操作的界面、终端形态、交互复杂度完全不同,拆成两个独立的前端工程正好符合微服务的"独立交付、独立演进"思想。
Vue 管理端运行在 PC 浏览器上,服务的是招生办老师、院系审核员、财务人员。这类用户的工作场景是大量列表、表单、审核操作,所以我们用 Vue 3 + Vite + Element Plus 搭建,重点做好表格筛选、分页、批量操作、审核流转进度的展示。管理端还有一个核心功能是数据看板,按院校、按批次、按日期展示报名人数、缴费率、录取进度,这部分用 ECharts 做图表,实时从各微服务聚合数据。
微信小程序端服务的是考生和家长,操作的场景更碎片化、移动化。小程序端提供的主要页面有:
- 首页:招生公告流 + 院校推荐位
- 报名页:按院校和专业维度报名,支持选择批次
- 志愿填报页:按志愿顺序填报多个院校专业
- 缴费页:报名费在线缴纳,对接微信支付
- 录取查询页:录取状态实时查询
小程序端的 UI 我们用的是原生框架 + WeUI 组件,没有引入 uni-app 这层封装。原因在于这个项目的小程序端交互相对可控,原生框架的调试和性能调优路径最直接,出问题时好排查。如果你们后续有 App 或 H5 多端需求,再上 uni-app 这类跨端框架也不迟,前期不要为了统一而过度设计。
4.2 跨端身份认证与角色权限模型
双前端共享后端的那套用户体系,身份认证必须统一设计。核心方案是:登录认证统一走 auth-server 签发的 JWT,小程序端和管理端持有 Token 的获取方式不同,但校验逻辑完全一致。
管理端登录方式是账号密码 + 验证码,登录成功后拿到 Token 和用户角色信息,前端用 Vue Router 的动态路由做权限控制——不同角色的用户初始化时得到的路由表不一样,比如财务角色看不到审核页面。这是 Vue 权限控制的经典做法,但要注意一个细节:动态路由在刷新页面后会丢失,必须在路由守卫里加一个router.addRoute的恢复逻辑,把已登录用户的异步路由表重新挂上去,否则一刷新页面就白屏。
小程序端的登录则走微信授权体系:通过wx.login获取临时 code,后端用 code 换取 openid,并绑定考生档案。后续请求一律携带后端签发的自定义 Token。这里有一个被很多人忽略的点:不要在小程序端直接使用微信的 openid 作为业务主键。openid 是微信体系内的标识,支付、消息推送会用到,但业务库里的考生 ID 必须是自己生成的业务主键。两者做好映射,后续如果系统对接其他登录渠道(比如政务平台统一登录),不会被动。
小程序端还有个常见问题,就是页面列表加载更多的下拉分页。上面热搜词也提到了这个。小程序不像 PC 浏览器,用户滑动列表时频繁触发setData会导致页面渲染卡顿,我们的做法是:每次加载下一页时,先把新数据concat到本地数组,一次性setData,而不是每条数据单独更新;同时列表项使用wx:key保证 diff 更新效率。实测下来,一次setData推送 20 条数据,页面帧率稳定很多。
4.3 跨端复用的通用逻辑:Axios 层、请求封装与接口约定
虽然是两个前端工程,但很多基建逻辑是可以抽象出同样设计模式的。最典型的就是 HTTP 请求封装。管理端 Axios 封装了拦截器,请求带上 Token,响应统一处理业务码、弹出错误提示。小程序端也做了类似封装,但多了几个小程序特有的处理:
- 登录态过期时,拦截响应后跳转到登录页
- 请求携带
content-type: application/json - 上传图片场景单独封装
wx.uploadFile的处理
接口约定方面,统一返回体我们设计为:
{ "code": 0, "message": "success", "data": {} }code=0表示成功,非 0 表示业务错误,message给前端提示用。这里有个教训:接口返回结构必须全局统一,不能有的接口返回数组、有的返回对象包裹,前端会疯掉。数据为空时也要返回data: []或data: {},宁可统一占位,也不要动不动返回null。
5. 核心业务链路实战:从考生注册到录取确认的完整流程
5.1 主流程的关键状态机
招生平台的主流程不是单线性的,它是一组状态机的流转。这里挑核心链路说明:报名 → 缴费 → 志愿填报 → 资格审核 → 录取 → 确认。
以 enroll-server 中的报名单为例,状态字段设计如下:
| 状态码 | 含义 | 触发动作 |
|---|---|---|
| 0 | 草稿 | 考生填写但未提交 |
| 1 | 已提交待缴费 | 提交报名、生成订单 |
| 2 | 已缴费待审核 | 支付回调成功 |
| 3 | 审核通过 | 招办老师人工审核 |
| 4 | 审核不通过 | 驳回并通知考生 |
| 5 | 已录取 | 录取结果确定 |
| 6 | 已确认入学 | 考生在小程序确认 |
状态机设计最重要的原则是状态流转必须单向且由事件驱动。不允许直接修改状态字段,统一走状态流转服务方法,方法内部校验当前状态是否允许迁移到目标状态,比如"草稿状态不能直接跳到已录取"。
5.2 分布式事务:报名、缴费、名额扣减的最终一致方案
这块是整个系统里最容易出事故的地方。简单的报名提交可以做成单体事务,但如果报名同时涉及"名额预扣 + 订单生成 + 支付回调后的最终确认",数据分布在 enroll-server 和 payment-server 两个服务中,就属于跨服务事务了。
我们最终的方案是事务消息 + 本地消息表,这里展开说。
考生点击"报名"后,enroll-server 在本地事务里完成两步操作:往报名表插入报名记录,同时往本地消息表插入一条"待发送订单创建消息"记录,消息状态为 pending。本地事务提交成功后,通过 RabbitMQ 发布消息给 payment-server。payment-server 收到消息后创建订单并回调 enroll-server。enroll-server 消费回调结果,更新报名状态为"已缴费待审核",并把本地消息表状态更新为"已完成"。
如果消息发送成功但 payment-server 处理失败怎么办?我们起了一个定时任务,每隔一段时间扫描本地消息表中状态仍为 pending 的过期消息,重新发送。重点在于下游消费必须做幂等——payment-server 收到重复消息时,通过订单号唯一索引判断是否已处理过,已处理则直接返回成功,不再重复创建订单。
这里绕开了 Seata 这类分布式事务框架,理由是用全局锁去保证跨服务强一致性,在高并发招生场景下代价太大,而且会显著拉长事务链路、降低吞吐。招生业务对最终一致容忍度较高,因为每一步都有状态界面反馈,考生看到"报名成功,待缴费"完全符合预期。真正不能接受的,反而是因为强一致锁导致的长时间卡死和超时。
5.3 名额抢注并发控制:Redis 分布式锁的正确姿势
招生季最经典的并发场景是抢热门院校的名额。某美术院校计划招 200 人,报名窗口一开,头几分钟就可能涌进几千个请求。如果直接用 SQL 判断名额并扣减,行锁竞争会导致数据库 CPU 直线飙升。
我们采取的是"Redis 预扣 + 事务内校验"双层策略:
- 在 Redis 里对每个院校批次设置一个名额计数器,用
INCR或DECR原子操作预扣名额。DECR返回的值如果小于 0,说明名额已满,直接返回"该批次已报满",不进入数据库操作。 - 预扣成功后,才能进入 enroll-server 生成报名记录。数据库事务里再做一次实际名额校验,用
SELECT quota FROM ... WHERE school_id = ? FOR UPDATE保证最终一致性。
第一步挡住了绝大多数无效请求,数据库压力大幅下降;第二步确保即使 Redis 和数据库数据有偏差,也不会出现超卖。这里要注意分布式锁的一个关键前提:Redis 预扣不是锁,它是限流阀门,真正的下单操作仍然需要数据库兜底校验。
在这套流程里我们没用 Redisson 的分布式锁做加锁排队,而是选择了原子计数预扣,因为加锁排队在小程序端的用户体验非常差——考生看到几秒的"排队中"就很容易怀疑系统出错了。原子计数可以在毫秒级直接给出"报满"或"报名成功"的明确反馈。
6. 招生系统"12306化":高并发防护与性能调优实践
6.1 数据热点分析与多级缓存设计
招生系统的高并发场景有鲜明的热点特征:查分、查录取结果时,成千上万用户的请求都集中到同一个接口,查的数据往往是同一批数据。比如录取结果公布前后的那几天,所有人查的都是同一个批次的录取名单。
我们做了多级缓存来消化这类热点:
第一级,本地缓存(Caffeine):部署在每台服务实例中,缓存录取结果接口的高频查询数据。设置短过期时间,比如 120 秒。本地缓存不存在网络开销,性能极好,适合扛超高并发。
第二级,Redis 缓存:本地缓存未命中时查 Redis。这里缓存的是院校详情、招生计划、公告资讯这类变化频率低的数据。录取结果查询我们做了更细的缓存设计:热门院校的录取结果粒度到院校批次维度,冷门院校则按考生维度缓存,避免无关数据互相干扰。
第三级,数据库:只有前两级都未命中才查询 MySQL。数据库连接池配置了 HikariCP,闲置超时和最大连接数根据压测结果调整,同时所有核心查询都走索引,把慢 SQL 排查做在事前。
缓存有一个必须注意的坑:缓存雪崩与击穿。录取结果刚公布那一刻,如果某一批缓存同时过期,所有请求会同时打穿到数据库,瞬间把数据库压垮。我们的做法是把缓存过期时间人为打散,加随机偏移量(比如 120 秒到 180 秒随机),同时热点 key 使用逻辑过期而不是物理过期,防止大面积同失效。
6.2 RabbitMQ 削峰:把同步阻塞请求变成异步投递
报名高峰期的流量不能全部同步扛下来,必须削峰。我们的削峰策略主要用 RabbitMQ 做了三件事:
第一,录取结果通知异步化。录取结果确定后,result-server 只是更新状态并发送消息,消息内容包括考生 ID 和录取结果。message-server 监听消息,再通过微信模板消息推送给考生。这个链路完全异步,流程本身的耗时不会影响接口响应。
第二,报名高峰的排队化。最极端的那几分钟,gateway 限流会挡掉一部分请求,但直接拒绝用户不友好。我们做了个简化版本的消息队列排队:被限流的请求返回一个"排队中"标记,前端轮询询问后台"队列状态",enroll-server 消费队列消息把报名请求落库,落库后前端轮询到成功结果。这种方式相当于把瞬时压力摊到几秒到十几秒内处理,用户感知到的只是"稍等了一下"。
第三,站点内信批量写。招生公告发布后,几十万考生需要看到站内信通知,逐个同步写数据库会很慢。消息队列广播通知 + 各服务自行消费按需存储,大大降低了写压力。
6.3 压测结果与性能基线数据
上线前我们对核心接口做了压测,压测工具用的 JMeter,模拟场景是"模拟 5000 并发用户同时查询录取结果 + 1000 并发用户同时提交报名"。结果如下:
| 接口 | 压测并发 | 平均响应时间 | 99 分位响应时间 | 错误率 |
|---|---|---|---|---|
| 录取结果查询 | 5000 | 280ms | 980ms | 0.02% |
| 院校列表查询 | 3000 | 150ms | 620ms | 0.00% |
| 报名提交 | 1000 | 780ms | 2100ms | 0.08% |
| 缴费回调处理 | 800 | 540ms | 1600ms | 0.00% |
数据说明这套架构在招生季的核心节点上能扛住压力。但压测只是起点,真正重要的还是那些边界设计,后面第 7 节我会专门讲上线后暴露出来的问题。
7. 上线后的真实踩坑记录与复盘
7.1 慢 SQL 引发的连环超时:一次索引缺失事故
上线后第一次模拟真实压力,就出事了。录取结果查询接口在 5000 并发下平均响应时间飙到 8 秒,接着网关开始大量超时,Sentinel 熔断触发,一堆服务直接降级。查到最后,问题出在一张联合索引设计有误的表上。
查询条件是WHERE school_id = ? AND batch_id = ? AND candidate_id = ?,但索引只建了school_id单列。百万数据量下,按 school_id 过滤已经能筛掉很多数据,但 batch_id 和 candidate_id 的过滤只能在内存里做 filesort,大量回表查询把磁盘 IO 打满。
修复方式是调整联合索引顺序为(school_id, batch_id, candidate_id),查询立刻降到了百毫秒内。这个案例给了我们一个教训:微服务架构下不要迷信"服务拆分能解决所有性能问题",数据库是共享底座,索引设计不到位,再多的服务副本也扛不住。
7.2 管理端动态路由刷新白屏
这是 Vue 管理端一个十分典型的坑。我们按角色动态路由实现了权限控制,但上线后发现一个诡异的现象:F5 刷新页面后,部分账号会白屏,控制台报"路由不存在"。原因前面提到过——动态路由是在登录后通过router.addRoute添加的,但页面刷新时整个应用重新初始化,路由表还没来得及恢复,页面就已经开始渲染了。
解决方案是在路由守卫里加异步判断:用户处于登录态时,先去检查用户信息里的路由权限是否已经加载过,如果没加载过,先动态注册路由,再放行。这个逻辑必须在每次导航开始前执行,确保刷新后能恢复完整的路由表。
7.3 配置中心改一处、全链路雪崩
还有一次印象特别深的故障。招生季开始前,我们在 Nacos 配置中心调整了 RabbitMQ 的消费者线程数配置,从默认的 10 调到了 50。本意是提升消息消费速度,结果消息服务在高峰期瞬间拉取大量消息,同时去调微信模板消息接口,被微信侧限流,导致消息积压反而更严重,还连带把数据库连接池打满。
复盘后发现,我们忽略了配置变更的级联影响。改一个线程数,影响的不只是消息服务自身,还会影响下游依赖的第三方接口。现在我们的原则是:任何配置变更都必须走完整的变更流程,先在测试环境压测验证,再灰度发布到生产。招生系统不像普通业务系统,一次低级配置失误就能造成大范围的考生服务异常。
7.4 微信小程序端的实际调试技巧
最后分享几个小程序端真实开发中的调试经验。第一个是抓包。小程序线上环境出问题时,很多请求头和加密参数在开发者工具里看不到全貌。用 Charles 配合代理可以完整抓到小程序的 HTTPS 请求,前提是手机和小程序端要信任 Charles 的证书。调试时重点关注请求的 Header 是否带上了自定义 Token,很多线上 401 都是这里出的问题。
第二个是顶部导航栏高度适配。小程序里的自定义导航栏高度,在不同机型上差异很大。iPhone 的刘海屏、安卓的全面屏、胶囊按钮位置都不一致,我们不再写死高度,而是通过wx.getMenuButtonBoundingClientRect()动态获取胶囊按钮的位置,再计算导航栏高度。这个值在onLaunch时获取一次,存到全局变量里,所有页面统一使用。
第三个是分页列表的性能。小程序里长列表不能一次渲染太多数据,页面上只保留当前可视区域及上下各一屏的数据。数据更新用setData时,尽量用路径更新(比如this.setData({ ['list[' + index + '].status']: 1 })),避免整个数组重新 diff。看似不起眼的小优化,在世界之窗那种几百条招生公告的长页面上,帧率差别非常明显。
8. 关于这套系统的一点个人体会
高校招生服务平台这种项目,真正的难点从来不是某个技术点学不会,而是要把"业务高峰期的稳定可用"和"数据生命周期的严谨性"同时做到位。微服务拆分给了我们按业务域灵活扩容的能力,但每个服务也因此多了一层网络边界,分布式事务、幂等、补偿这些概念就从书本知识变成了必须天天面对的工程现实。
如果让我重新从零做一遍,我会在项目启动的头两周,先把状态机设计和缓存隔离策略画清楚,而不是先急着搭框架。状态机没理清,后面所有接口的设计都会跟着摇摆;缓存隔离没做好,一次热点穿透就可能把数据库打爆。很多毕业设计或者内部项目做着做着就成了"大泥球",根本不是技术栈选错了,而是边界和状态从一开始就没守住。
这套系统的源码和部署方案目前托管在团队内部的 GitLab 上,如果你们也在做高校招生、考试报名、资格审核这类业务,可以对照这篇文章的服务拆分和数据一致性方案去做设计评审,尤其是第 2 节的服务边界表和第 5 节的状态机,我认为是整套架构里最值得复用的部分。