简介这是一套面向知识竞赛组织者与开发者的在线答题系统源码包基于VC6.0与Access数据库实现可用于搭建试题管理、人员管理、在线答题与成绩排名一体化的竞赛平台。包内共61个文件以cpp与h源码文件为主辅以obj、pdb等编译中间文件以及mdf、ldf数据库文件、rc资源脚本和exe可执行程序压缩包约7.68MB结构完整便于直接运行或二次开发。系统覆盖试题的添加、删除与修改参赛人员录入限时答题与自动评分并支持按答对数量、答题速度等维度生成动态排名同时提供通知消息与后台数据分析功能。已有393人学习关注适合课程设计、竞赛系统开发参考或需要快速部署答题平台的读者可从中理解MFC界面设计、ADO数据库访问与排名算法的具体实现方式。1. 知识竞赛系统从线下抢答器到线上高并发一套能扛住 500 人同时交卷的落地路径做过知识竞赛系统的人都有一个共识这玩意儿看着简单真到决赛现场翻车的方式能让你怀疑人生。我印象最深的一次某单位搞党史知识竞赛决赛 300 人同时在线答题倒计时还剩 10 秒的时候后台数据库连接池直接被打满前端页面卡成 PPT主持人只能尴尬地宣布“系统正在统计”。那次之后我才明白知识竞赛系统的核心不是题库有多全、界面有多炫而是在固定时间窗口内如何稳定地接收几百甚至上千份并发提交并保证排名和分数不出错。这篇文章面向的是需要从零搭建一套知识竞赛系统的开发者或者手里已经有一套但一到高并发就出问题的运维同学。我会把整个系统拆成题库设计、答题引擎、并发提交、实时排名、防作弊这几个模块每个模块给出可复现的代码和参数配置。你不需要是架构师但至少要会写 Python 或者 Java懂基本的数据库操作。读完你至少能搭出一套支撑 500 人同时交卷、排名延迟低于 2 秒的知识竞赛系统并且知道哪里最容易踩坑。2. 题库与试卷模型别让一道错题毁掉整场比赛知识竞赛系统的地基是题库和试卷模型。很多新手一上来就建一张questions表字段塞满题干、选项、答案、解析然后试卷表直接存 JSON。这种设计在 50 人以下的小活动里能跑但一旦题目需要复用、随机组卷、按难度抽题立刻就会变成灾难。我一般会把题库和试卷彻底解耦用“题目实体 试卷模板 实例化试卷”三层结构来管。2.1 题目表怎么设计才能支持随机组卷和难度分级先看题目表的核心字段。题干和选项不建议直接存纯文本因为后面你要做富文本、图片题、音频题纯文本扩展性太差。我通常用content字段存 JSON结构里包含题干和选项数组这样前端渲染和后端判分都能统一处理。CREATE TABLE questions ( id BIGINT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL COMMENT 分类单选/多选/判断/填空, difficulty TINYINT DEFAULT 1 COMMENT 1简单 2中等 3困难, content JSON NOT NULL COMMENT 题干和选项, answer JSON NOT NULL COMMENT 标准答案多选存数组, score INT DEFAULT 5 COMMENT 默认分值, tags VARCHAR(255) COMMENT 标签用于随机抽题, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_category_difficulty (category_id, difficulty), INDEX idx_tags (tags) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;content字段的 JSON 结构我一般这样约定单选题存{stem: 题干, options: [A选项, B选项]}多选题多一个multiple: true判断题直接存{stem: 题干, options: [正确, 错误]}。answer字段单选题存字符串A多选题存数组[A, C]。这样判分逻辑只需要根据category_id走不同分支不用改表结构。难度分级和标签是为了随机组卷服务的。比如一场比赛要求 20 道题里简单题占 12 道、中等 6 道、困难 2 道你只需要按difficulty分组随机取数就行。标签可以用来限定范围比如“党史”标签下抽 10 道“安全知识”标签下抽 10 道。2.2 试卷实例化从模板到个人试卷的生成逻辑试卷模板表paper_templates存的是组卷规则比如每个难度抽几道、总分多少、答题时长多少。真正发给选手的是实例化试卷user_papers每个选手一份题目顺序和选项顺序都可以打乱。import random from sqlalchemy import create_engine, text def generate_paper(user_id, template_id, engine): # 读取模板规则 with engine.connect() as conn: template conn.execute( text(SELECT * FROM paper_templates WHERE id:tid), {tid: template_id} ).fetchone() rules template.rules # JSON: [{difficulty:1,count:12},{difficulty:2,count:6}] selected [] with engine.connect() as conn: for rule in rules: rows conn.execute( text(SELECT id, content, answer, score FROM questions WHERE difficulty:d AND category_id IN :cats ORDER BY RAND() LIMIT :n), {d: rule[difficulty], cats: tuple(template.categories), n: rule[count]} ).fetchall() selected.extend(rows) # 打乱题目顺序同时打乱选项顺序需要记录映射关系 random.shuffle(selected) paper_questions [] for q in selected: content q.content options content[options] indexed list(enumerate(options)) random.shuffle(indexed) new_options [opt for _, opt in indexed] # 记录新选项顺序对应的原答案位置 answer_map {new_idx: old_idx for new_idx, (old_idx, _) in enumerate(indexed)} paper_questions.append({ question_id: q.id, options: new_options, answer_map: answer_map, score: q.score }) # 写入 user_papers with engine.begin() as conn: conn.execute( text(INSERT INTO user_papers (user_id, template_id, questions, start_time, duration) VALUES (:uid, :tid, :qs, NOW(), :dur)), {uid: user_id, tid: template_id, qs: json.dumps(paper_questions), dur: template.duration} )这段代码的关键点有三个。第一ORDER BY RAND()在题目量超过 5000 时会明显变慢生产环境建议用WHERE id (SELECT FLOOR(RAND() * MAX(id)) FROM questions)来优化或者提前把题目 ID 缓存到 Redis 里做随机抽取。第二选项打乱后必须记录answer_map否则判分时找不到正确答案。第三user_papers表里存的是实例化后的题目 JSON这样即使题库后续修改已经生成的试卷也不会受影响。提示如果比赛允许选手在答题过程中刷新页面user_papers表必须记录start_time和duration前端每次加载时从服务端拉取剩余时间而不是依赖本地计时器。3. 答题引擎与判分逻辑多选、填空、部分给分怎么处理答题引擎的核心是判分。单选题和判断题最简单字符串比对就行。多选题是重灾区因为涉及到“少选给一半分、错选零分”这种规则。填空题更麻烦选手可能多打一个空格、大小写不一致、或者用中文全角符号。我见过最离谱的翻车是选手答案里带了个换行符后端直接判错选手当场申诉。3.1 多选和填空的判分函数怎么写才不背锅先定义一个通用的判分入口根据题型分发到不同函数。所有答案在入库前统一做标准化处理去首尾空格、转小写、全角转半角、去掉所有空白字符。import re import unicodedata def normalize_answer(ans): if isinstance(ans, list): return sorted([normalize_answer(a) for a in ans]) if not isinstance(ans, str): ans str(ans) # 全角转半角 ans unicodedata.normalize(NFKC, ans) # 去掉所有空白字符 ans re.sub(r\s, , ans) return ans.lower() def judge(question_type, user_answer, correct_answer, score): ua normalize_answer(user_answer) ca normalize_answer(correct_answer) if question_type single: return score if ua ca else 0 if question_type multiple: ua_set set(ua) if isinstance(ua, list) else set(ua) ca_set set(ca) if isinstance(ca, list) else set(ca) if ua_set ca_set: return score # 错选用户选了不在正确答案里的选项 if not ua_set.issubset(ca_set): return 0 # 少选给一半分向下取整 return score // 2 if question_type fill: # 支持多个可接受答案用 | 分隔 accepted [normalize_answer(a) for a in ca.split(|)] return score if ua in accepted else 0 return 0normalize_answer里用unicodedata.normalize(NFKC, ans)做全角转半角这一步能解决大部分中文标点导致的误判。re.sub(r\s, , ans)去掉所有空白字符选手在填空题里打多少空格都不影响。多选题的判分逻辑是完全一致给满分错选给零分少选给一半分。这里有个细节score // 2是向下取整如果分值 5 分少选给 2 分。有些比赛规则要求少选给 3 分那就改成round(score * 0.6)具体看赛事规程。3.2 答题记录的存储与幂等提交每道题的作答记录要单独存表而不是只存最终分数。这样做的目的是支持申诉复查和答题过程回放。answer_records表结构如下CREATE TABLE answer_records ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_paper_id BIGINT NOT NULL, question_id BIGINT NOT NULL, user_answer JSON, is_correct TINYINT DEFAULT 0, score INT DEFAULT 0, submit_time DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), UNIQUE KEY uk_paper_question (user_paper_id, question_id), INDEX idx_paper (user_paper_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;UNIQUE KEY uk_paper_question是防重复提交的关键。选手在倒计时最后几秒疯狂点提交按钮或者网络抖动导致前端重试都会产生重复请求。有了这个唯一索引后端用INSERT ... ON DUPLICATE KEY UPDATE就能保证同一道题只保留最后一次作答。INSERT INTO answer_records (user_paper_id, question_id, user_answer, is_correct, score) VALUES (:pid, :qid, :ans, :correct, :score) ON DUPLICATE KEY UPDATE user_answer VALUES(user_answer), is_correct VALUES(is_correct), score VALUES(score), submit_time CURRENT_TIMESTAMP(3);注意ON DUPLICATE KEY UPDATE在高并发下会触发行锁竞争如果同一份试卷的多个题目同时提交建议按question_id排序后再批量写入减少死锁概率。4. 高并发提交与实时排名500 人同时交卷怎么不崩这是知识竞赛系统最核心的章节也是翻车最多的地方。500 人同时交卷意味着 500 个 HTTP 请求在 1 到 2 秒内涌入每个请求要写 20 道题的答题记录然后触发排名更新。如果直接让请求打到 MySQL数据库连接池瞬间被打满后面的请求全部超时。我的做法是前端限流 消息队列削峰 Redis 排名 异步落库。4.1 用 Redis 做提交缓冲和排名计算选手点击交卷后后端不直接写数据库而是把整份试卷的答案序列化后丢进 Redis List 或者 Stream。然后有一个消费者进程从队列里取数据批量写入 MySQL。排名则完全在 Redis 的 Sorted Set 里完成不依赖数据库。import redis import json r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) def submit_paper(user_paper_id, answers): # answers: [{question_id: 1, answer: A}, ...] payload { user_paper_id: user_paper_id, answers: answers, submit_time: time.time() } # 推入提交队列 r.lpush(paper:submit:queue, json.dumps(payload)) # 立即返回前端显示“已交卷排名计算中” return {status: accepted} def consume_submit_queue(): while True: _, data r.brpop(paper:submit:queue, timeout5) if not data: continue payload json.loads(data) # 判分并计算总分 total_score process_answers(payload) # 更新 Redis 排名 r.zadd(paper:rank, {str(payload[user_paper_id]): total_score}) # 异步落库可以批量攒够 50 条再写 save_to_db(payload, total_score)r.lpush把提交请求写入队列brpop在消费者端阻塞读取。zadd更新排名paper:rank这个 Sorted Set 的 member 是user_paper_idscore 是总分。前端查询排名时直接用zrevrange拿前 100 名延迟在毫秒级。这里有个关键参数brpop的timeout5表示阻塞 5 秒如果队列为空就返回None循环继续。消费者进程可以起多个但要注意process_answers里的判分逻辑必须幂等因为同一条消息可能被重复消费比如消费者崩溃后重启。4.2 排名并列与同分处理规则知识竞赛的排名规则通常要求总分相同看答题用时用时相同看提交时间。Redis 的 Sorted Set 只能存一个 score所以需要把总分和用时编码成一个复合分数。我一般用总分 * 1000000 - 用时秒数 * 1000 提交时间戳后三位这种方式保证总分高的排前面同分时用时短的排前面。def calc_rank_score(total_score, duration_seconds, submit_ts): # 总分占高位用时越短分数越高提交时间越早分数越高 return total_score * 1000000 - int(duration_seconds) * 1000 (int(submit_ts * 1000) % 1000)这个编码方式有个前提总分不超过 1000 分用时不超过 1000 秒。如果比赛规模更大需要调整倍数。比如总分 10000 分那就用total_score * 100000000。核心思路是把多个排序字段压缩到一个数值里利用 Sorted Set 的 score 排序。提示如果比赛规则允许并列名次那就不要用复合分数直接按总分排序同分选手名次相同。具体用哪种赛前一定要和主办方确认清楚。4.3 数据库落库的批量写入与连接池配置消费者从 Redis 拿到数据后最终还是要落到 MySQL。如果每消费一条就写一次数据库500 人交卷就是 500 次数据库事务依然有压力。我的做法是攒批消费者每次从队列取最多 50 条合并成一个事务批量写入。def batch_save(records): with engine.begin() as conn: conn.execute( text(INSERT INTO answer_records (user_paper_id, question_id, user_answer, is_correct, score) VALUES (:pid, :qid, :ans, :correct, :score) ON DUPLICATE KEY UPDATE user_answerVALUES(user_answer), is_correctVALUES(is_correct), scoreVALUES(score)), records ) # 更新 user_papers 总分 conn.execute( text(UPDATE user_papers SET total_score:score, submit_timeNOW(3) WHERE id:pid), {score: total_score, pid: user_paper_id} )连接池配置方面SQLAlchemy 的create_engine需要设置pool_size和max_overflow。对于 500 人规模的比赛pool_size20, max_overflow30足够。但要注意消费者进程和 Web 进程要分开配置连接池否则会互相抢连接。engine create_engine( mysqlpymysql://user:passhost/db, pool_size20, max_overflow30, pool_recycle3600, pool_pre_pingTrue )pool_pre_pingTrue是后悔药它会在每次从连接池取连接时先 ping 一下避免 MySQL 8 小时空闲断连导致的MySQL server has gone away。这个参数在比赛现场能救你一命。5. 避坑与排查知识竞赛系统上线前必须过的 5 道坎这一章记录的是我踩过的血泪坑每一条都对应一个真实翻车场景。如果你正准备上线知识竞赛系统建议逐条对照检查。5.1 倒计时结束瞬间的提交风暴现象倒计时归零那一刻前端自动提交500 个请求同时到达Nginx 返回 502部分选手显示“提交失败”。原因前端没有做提交限流所有选手的提交请求在同一秒内发出。后端同步处理判分和落库线程池被占满。解决前端在倒计时结束前 30 秒开始每 5 秒自动保存一次答案到 Redis倒计时归零时只提交最终状态。后端把提交接口改成异步接收请求后立即返回202 Accepted实际判分和落库走消息队列。Nginx 层面配置limit_req限流每秒最多放行 200 个提交请求。5.2 多选题少选给分规则理解错误现象选手选了 2 个正确选项正确答案有 3 个系统给了 0 分选手申诉说规则是少选给一半分。原因判分函数里把“少选”和“错选”混为一谈只要用户答案不等于标准答案就返回 0。解决判分逻辑必须严格区分三种情况完全一致给满分用户答案是正确答案子集给一半分用户答案包含非正确答案给零分。代码里用ua_set.issubset(ca_set)判断子集关系用ua_set - ca_set判断是否有错选。5.3 Redis 排名数据丢失现象比赛进行到一半Redis 突然重启排名数据全部丢失前端显示“暂无排名”。原因Redis 没有开持久化或者开了 RDB 但save间隔太长重启后数据回滚到几分钟前。解决Redis 必须同时开 RDB 和 AOF。appendonly yesappendfsync everysec。另外排名数据要定期从 Redis 同步到 MySQL 的rank_snapshot表即使 Redis 挂了也能从数据库恢复。比赛期间可以每 30 秒做一次快照。5.4 填空题答案里的不可见字符现象选手填的答案肉眼看着完全正确系统判错。把答案复制到十六进制编辑器里发现末尾有个\u200b零宽空格。原因选手从网页或 PDF 里复制答案时带入了不可见字符normalize_answer只去掉了\s匹配的空白零宽空格不在\s范围内。解决在normalize_answer里增加一步用ans.replace(\u200b, ).replace(\ufeff, )去掉零宽空格和 BOM 字符。更彻底的做法是用unicodedata.category(ch) ! Cf过滤所有格式控制字符。5.5 数据库连接池被慢查询拖垮现象比赛开始后 10 分钟系统越来越慢最后所有接口超时。查看 MySQL 慢查询日志发现ORDER BY RAND()抽题语句执行了 3 秒。原因随机抽题用了ORDER BY RAND()题目表有 2 万道题每次抽题全表扫描。500 人同时抽题数据库 CPU 直接跑满。解决提前把题目 ID 按难度和分类缓存到 Redis Set 里抽题时用SRANDMEMBER随机取 ID再根据 ID 批量查题目。或者用WHERE id (SELECT FLOOR(RAND() * MAX(id)) FROM questions) LIMIT n这种基于主键的随机方式避免全表扫描。6. 压测与验收用 Locust 模拟 500 人交卷把问题提前暴露上线前不压测等于闭着眼睛上战场。我一般用 Locust 写一个模拟脚本模拟 500 个选手在 3 分钟内完成答题并交卷。压测的目标不是看 QPS 有多高而是验证三件事提交接口是否全部返回 202、排名更新是否在 2 秒内完成、数据库连接池是否被打满。6.1 Locust 脚本编写与参数设置from locust import HttpUser, task, between import random class QuizUser(HttpUser): wait_time between(1, 3) def on_start(self): # 登录并获取试卷 resp self.client.post(/api/login, json{user: test, pass: 123}) self.token resp.json()[token] self.paper_id resp.json()[paper_id] self.headers {Authorization: fBearer {self.token}} task def answer_and_submit(self): # 模拟逐题作答 for qid in range(1, 21): answer random.choice([A, B, C, D]) self.client.post( /api/answer, json{paper_id: self.paper_id, question_id: qid, answer: answer}, headersself.headers ) # 交卷 self.client.post( /api/submit, json{paper_id: self.paper_id}, headersself.headers )启动命令locust -f locustfile.py --hosthttp://your-server --users 500 --spawn-rate 50。--users 500表示模拟 500 个用户--spawn-rate 50表示每秒启动 50 个用户10 秒内达到 500 并发。压测时重点观察三个指标/api/submit的 P99 响应时间应该低于 500ms因为只是入队、Redis 队列长度如果持续增长说明消费者处理不过来、MySQL 的Threads_running超过连接池上限就会报错。6.2 验收清单与回滚预案压测通过后比赛前 1 小时还要做一次全链路检查。我习惯用下面这张表逐项确认检查项合格标准检查方式Redis 持久化AOF 开启appendfsync everysecredis-cli config get appendonlyMySQL 连接池Threads_connected低于max_connections的 70%SHOW STATUS LIKE Threads_connected消息队列消费者至少 2 个消费者进程在运行ps aux排名接口延迟P99 低于 200ms压测报告前端自动保存每 5 秒保存一次倒计时归零前 30 秒开始浏览器 Network 面板回滚预案准备好关闭自动提交、切换手动判分的开关配置中心回滚预案的核心是如果系统真的扛不住能不能在 1 分钟内切换到“先收卷、后判分”的模式。具体做法是前端在倒计时归零后只上传答案文件JSON后端不判分等比赛结束后离线批量处理。这个开关一定要在比赛前测试一遍别等到出问题了才发现开关是坏的。最后说个我自己的习惯每次比赛前我会把 Redis 的maxmemory-policy设成noeviction防止排名数据被内存淘汰。另外消费者进程的日志级别调到 DEBUG比赛期间实时tail -f一旦发现队列积压立刻加消费者。这些细节看着不起眼但真到现场能让你少接几个投诉电话。希望帮到你。本文还有配套的精品资源点击获取