从零搭建一个高并发的在线答题系统:技术探索与实践
引子:为什么选择做一个在线答题系统?

去年我在一家教育科技公司参与开发了一个面向中小学生的在线答题系统。起初需求看似简单:“学生在网页端做题,提交答案后系统即时批改并反馈结果。”但随着功能迭代和用户量增长,这个项目逐渐演变成了我们团队面临过的最复杂的技术挑战之一。
刚开始接手时,我以为这只是个前端页面 + 后台接口的简单交互系统。但当产品提出“支持千人同时在线考试”、“错题实时同步”、“题目缓存预加载”等需求后,我才意识到这不仅仅是一个答题应用,更是一个典型的高并发、强一致性的在线服务场景。
这篇文章我会用第一人称的方式,分享我在这个项目中遇到的主要技术挑战、如何设计解决方案、过程中踩过的坑以及后来总结出的经验教训。希望通过这些真实的经历,能够为正在做类似项目的你带来一些启发和帮助。
项目背景与初期尝试

项目目标
我们的目标是构建一个支持以下核心功能的在线答题系统:
- 支持多种题型(单选、多选、填空、主观题等)
- 支持限时考试和不限时练习
- 实时计算答题进度和分数
- 高并发下稳定运行,特别是在学校统一组织的线上考试期间
一开始我们采用传统的 MVC 架构,前后端分离,前端 Vue.js + Element UI,后端 Spring Boot + MySQL,Redis 缓存部分热点数据。这套架构对于早期版本来说绰绰有余,支撑着几十个并发测试完全没问题。
但当我们第一次模拟百人并发测试的时候,就发现了一些严重问题:请求延迟飙升、数据库连接池被打满、Redis 在极端情况下出现数据不一致。
这时候我才真正开始思考:这个系统到底需要什么样的架构?
关键挑战一:高并发下的性能瓶颈

1. 数据库压力激增
在模拟300用户同时答题的情况下,MySQL 的 QPS 超过了极限,出现了大量慢查询,甚至连接超时的情况。
分析原因:
- 每次答题提交都需要频繁读写“作答记录表”,每次更新一行记录。
- 错误率统计依赖多个聚合查询,例如每个题目的正确率、完成率等。
- Redis 并没有很好地缓解数据库压力,因为很多操作必须落库。
解决方案:
引入本地缓存 + Redis 多级缓存机制
我们在 Java 应用层加了 Caffeine 做本地缓存,用于缓存一些不经常变化的题目录入信息,比如每道题的分值、答案结构等。而像实时答题状态、错误率这种动态数据则通过 Redis 统一管理。
将高频写操作异步化
对于每次答题提交,我们将写入作答记录的操作改为 Kafka 异步写入,先确认接收再持久化到数据库。这样可以避免阻塞主线程,也能应对瞬时高并发。
数据库拆表+水平分库
随着数据量增长,我们对答题记录做了按月分表处理,并逐步向时间维度的分库迁移。通过 MyCat 中间件进行路由控制,使得单表规模始终可控。
2. Redis 缓存穿透与一致性难题
在高峰期,Redis 出现过缓存击穿的问题,导致大量请求打到数据库。此外,在异步写入之后,Redis 和数据库之间有时会出现数据不一致的问题。
分析原因:
- 缓存失效策略不合理,所有缓存在同一时间集中失效,引发雪崩。
- 更新数据库之后未及时更新缓存,或者缓存更新失败。
- Redis 数据类型使用不当,造成序列化成本过高。
解决方案:
缓存设置随机失效时间
将原本统一的 TTL 时间加上随机偏移量,降低集体失效风险。
使用双缓存策略
主缓存用于快速读取,副缓存用于容灾备份。即使主缓存挂掉,也有缓冲机制保障可用性。
引入 Redisson 实现分布式锁
在写入数据库前获取分布式锁,确保一次只有一个线程能执行缓存更新操作。虽然增加了些许性能开销,但在一致性要求高的业务场景里是值得的。
使用 Redis 的 Hash 结构优化存储效率
把多个字段打包成一个 Hash 表,减少网络传输次数,提升整体吞吐能力。
关键挑战二:答题逻辑一致性保障

1. 答题状态丢失与重复提交
最初的设计中,我们采用了基于 session 的状态维护方式。但用户切换页面或刷新后,会导致当前答题进度丢失。此外,在网路波动的情况下,用户可能会多次点击“提交”按钮,导致一道题被反复作答。
分析原因:
- Session 属于短生命周期状态,不适合作答这种长周期任务。
- 用户行为不可控,无法完全依赖客户端保证唯一提交。
解决方案:
引入 Token-based 状态管理机制
我们为每个答题会话生成唯一的答题 Token,在服务端以 Redis Set 存储已提交题目 ID。每次提交前检查是否已存在该 Token + 题目组合,如果已经存在说明重复提交,直接拒绝。
客户端防抖处理 + 服务端幂等设计
在前端按钮点击时添加“loading”状态防止重复提交,后端也加入了根据答题 Token 和题目 ID 判断是否已处理过相同请求的逻辑。
2. 多设备同步问题
我们曾收到用户反馈说:“我在手机上答题后,换电脑继续,之前做的都丢了”。这个问题背后其实是一个复杂的同步协调机制缺失问题。
解决方案:
- 使用 JWT + Redis 实现跨设备身份绑定
- 用户答题状态存储在 Redis Hash 中,Key 是用户 ID + 考试 ID
- 每次答题后都会更新对应的 Hash 记录,并通知所有设备拉取最新状态(通过 WebSocket)
这套机制上线后,用户普遍反馈体验变好了,尤其是家长帮助孩子切换设备时不再担心进度丢失。
技术选型过程中的权衡

在整个开发过程中,我们在技术选型方面进行了多轮讨论和尝试。这里分享几个关键点上的决策依据:
1. 是否使用 GraphQL?
GraphQL 在理论上可以实现更灵活的数据查询,但在我们实际评估中发现:
- 学习成本较高,团队内部已有成熟 REST 接口习惯
- 当前系统不需要嵌套非常深的数据关系
- 已有的 API 设计已经足够清晰,没必要为了“技术先进”而去重构
最终决定继续使用 RESTful API + DTO 转换模式。
2. 是否迁移到 Spring Cloud 微服务?
我们在项目中期考虑过微服务拆分,但经过分析发现:
- 整体业务复杂度有限,拆分后反而增加运维成本
- 共享事务边界难以界定,容易产生分布式事务问题
- 团队规模不大,微服务治理投入产出比不高
所以选择了继续使用单体架构,并通过模块化代码结构和合理分工来维持可维护性。
3. 是否使用 ClickHouse 替代 MySQL 的聚合查询?
ClickHouse 在 OLAP 场景表现确实优秀,但我们评估后认为:
- 实时性要求并不特别高,普通定时任务即可满足报表需求
- 数据结构相对固定,没必要引入新数据库增加复杂度
- 已有的 MySQL 可以通过物化视图优化慢查询
因此暂未采用 ClickHouse,未来如需深度数据分析再考虑引入。
系统上线后的效果与收益
经过将近半年的努力,我们的答题系统最终成功上线,并在后续的几次大规模模考中经受住了考验:
- 单场考试最高支持 1500 人并发在线
- 峰值响应时间控制在 200ms 内
- 数据一致性达到 99.8%
- 客户端卡顿投诉下降至 <5%
更重要的是,这次技术探索让我们团队积累了不少实战经验:
- 如何构建一个高并发的答题引擎
- 异步写入与缓存策略的落地实践
- 如何平衡一致性与性能
- 从“够用就好”到“可扩展”的系统设计思维转变
我的几点建议与心得
如果你也在做类似的在线答题、考试类系统,下面是我亲历踩坑后想跟你分享的一些经验:
1. 提早规划缓存和一致性策略
不要等系统跑起来才去补缓存机制,否则后期改动会牵一发动全身。设计阶段就要明确哪些是热数据,哪些是一致性敏感操作。
2. 控制并发写入,优先使用乐观锁
在高并发场景下,悲观锁很容易成为性能瓶颈。我们早期使用了悲观锁进行答题记录更新,后来改用版本号机制(乐观锁),性能明显提升。
3. 前端也要懂一点并发思维
很多时候,前端同学不太理解服务器压力,随意发起请求、重复提交。建议前后端一起制定统一的请求规范,比如:
- 所有提交请求必须带上唯一 Token
- 所有操作都应具备幂等性
- 所有批量操作需控制请求数量,避免一次性发送上千条数据
4. 不要盲目追求“新技术”,适合自己最重要
我在项目中曾试图引入一些“看起来很酷”的框架,比如 Akka、Vert.x,后来发现它们不一定适合我们的业务模型,反而增加了维护难度。适合的才是最好的。
5. 多写日志、善用监控工具
日志是你排查问题的第一手资料。在答题系统中,我们为每一个答题动作都埋了详细的操作日志,包括用户 ID、题目 ID、答题内容、耗时等。结合 Prometheus + Grafana,实现了实时监控和报警机制,极大地提升了排障效率。
写在最后
作为一名开发者,我一直觉得,技术的价值不是在于用了多少“高级”的术语或框架,而是在于能否解决真实场景中的具体问题。这次在线答题系统的开发,让我深刻体会到技术只有落地才有生命力。
希望我的这段经历能够给你一些参考。如果你也有类似项目中的技术探索故事,欢迎留言交流。愿我们一起在技术的路上不断前行,保持好奇,持续成长。

评论 0