自动化毕设选题系统实战:基于规则引擎与协同过滤的可扩展架构
写在前面:去年 5 月,我帮学院把毕设选题从“微信群抢题”搬到线上,两周内用 Python 搭了一套可灰度、可回滚、可压测的自动化选题服务。上线当天 1200 名同学并发提交,峰值 QPS 3 200,零超卖、零重复。今天把踩过的坑和代码全部摊开,供下一届同学抄作业。
也欢迎直接拉到文末,把仓库 clone 下来跑一遍单元测试,再思考“没有历史数据时怎么冷启动推荐”。如果你能给出比“随机推荐”更好的方案,欢迎 PR。
1. 传统流程的四大痛点
- 并发竞争:抢题瞬间,几百人同时点“提交”,MySQL 行锁排队,页面 504。
- 冷启动:新生第一次选题,没有行为日志,协同过滤全是空矩阵。
- 公平性缺失:手速党秒抢热门题,慢网同学只能捡“剩”。
- 审计困难:导师临时加名额、教务手动改库,事后无法溯源。
一句话:人工 + 单体 + 无锁 = 高并发下的灾难现场。
2. 技术选型:规则引擎与推荐算法如何拍板
| 维度 | Drools | 自研轻量 DSL | 协同过滤 | 内容相似度 |
|---|---|---|---|---|
| 学习成本 | 高,引入 FACT 模型 | 低,Python 链式判断 | 需要历史矩阵 | 需要题目文本特征 |
| 灵活度 | 热更新 drl 文件 | 发版重启即可 | 冷启动无数据 | 可结合 Jieba+TF-IDF |
| 性能 | 规则多≈1 ms | 1000 条规则 <0.2 ms | 矩阵稀疏后 <5 ms | 向量检索 <10 ms |
| 运维 | 需 KIE Server | 无额外依赖 | Redis 存矩阵 | Redis 存向量 |
结论:
- 业务规则(导师配额、跨专业限制):用自研 DSL,写到 yml,规则变更随配置中心热更。
- 推荐排序(学生兴趣):用“轻量协同过滤 + 内容相似度”混合,解决冷启动。
- 规则与推荐解耦,前者在
rule-service,后者在rec-service,通过 gRPC 通信,互不影响扩缩容。
3. 核心实现:Python 搭建选题服务
整体架构图:
3.1 锁定与幂等设计
- 学生点击“提交” → 后端生成
order_token = hash(student_id + topic_id + nonce)。 - 先写 Redis 分布式锁:
SETNX lock:topic:{topic_id} {order_token} EX 5。 - 锁成功后,把订单写入 MySQL 唯一索引 `(student_id, topic_id),status=PRE。
- 释放锁使用 Lua Script,保证“只有自己能解自己的锁”。
伪代码:
def submit_topic(student_id, topic_id): order_token = uuid4() lock_key = f"lock:topic:{topic_id}" ok = redis.set(lock_key, order_token, nx=True, ex=5) if not ok: raise Conflict("手慢无") try: dao.insert_order(student_id, topic_id, status="PRE") except IntegrityError: # 幂等:重复提交直接返回成功 return {"code": 0, "msg": "已选过"} finally: # 用 Lua 保证原子 lua = """ if redis.call("GET",KEYS[1])==ARGV[1] then return redis.call("DEL",KEYS[1]) else return 0 end """ redis.eval(lua, 1, lock_key, order_token) return {"code": 0, "msg": "选题成功"}3.2 回滚机制
导师拒收、学生改主意,需要回滚。
- 订单表加
version乐观锁,状态机:PRE → CONFIRM → ROLLBACK。 - 回滚时把
topic表remain+1,并删除 Redis 中该学生缓存的推荐列表,保证下次请求重新算。
4. 完整可运行示例(精简版)
项目结构:
auto_topic/ ├── app.py # Flask 入口 ├── service/ │ ├── rule.py # 规则 DSL 解析 │ ├── rec.py # 协同过滤 │ └── order.py # 订单事务 ├── tests/ │ └── test_concurrent.py └── script/ └── jmeter.jmx # 压测脚本核心入口app.py:
from flask import Flask, request, jsonify from service.order import submit_topic from service.rec import get_recall_list from service.rule import check_rule app = Flask(__name__) @app.post("/topic/submit") def topic_submit(): data = request.json student_id = data["student_id"] topic_id = data["topic_id"] # 1. 规则校验 if not check_rule(student_id, topic_id): return jsonify(code=403, msg="不符合选题规则"), 403 # 2. 提交订单 return jsonify(**submit_topic(student_id, topic_id)) @app.get("/topic/recommend") def topic_recommend(): student_id = request.args.get("student_id") return jsonify(data=get_recall_list(student_id)) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000, threaded=True)service/rec.py冷启动兜底:
import random, redis, json r = redis.Redis() def get_recall_list(student_id): # 优先读协同过滤 cf_key = f"cf:{student_id}" cf_list = r.get(cf_key) if cf_list: return json.loads(cf_list) # 冷启动:读内容相似度 Top10,再随机打散 content_key = f"content:{student_id}" content_list = r.zrevrange(content_key, 0, 9) if content_list: return [int(t) for t in content_list] # 仍为空,随机兜底 all_topics = r.zrevrange("all", 0, -1) return random.sample([int(t) for t in all_topics], k=10)Clean Code 要点:
- 函数长度 < 30 行,只做一件事。
- 统一返回结构
{code, msg, data},前端好封装。 - 所有魔法值(锁超时 5s、随机 10 条)提到
settings.py,方便压测调参。
5. 性能与安全
5.1 JMeter 压测结果
硬件:4C8G 容器单实例,后端 + Redis 同机部署。
场景:1200 并发用户,1 秒内起压,每人一次提交。
指标:
- 平均 RT 63 ms
- P99 110 ms
- 错误率 0 %
- 吞吐量 3 200 req/s(比旧系统 900 req/s 提升 3.5 倍)
5.2 Redis 分布式锁可靠性
- 锁过期 5 s,业务 SQL 平均 15 ms,远小于过期时间。
- 采用
order_token唯一值 + Lua 脚本,杜绝“误删别人锁”。 - Redlock 争议:单实例已满足学院量级;若跨机房,可升级
redlock-py。
5.3 防刷策略
- 接口网关层(Kong)限流:同一 IP 10 rps,超出直接 429。
- 业务层兜底:学生维度 1 分钟最多 5 次提交,用 Redis
INCR+EXPIRE。 - 验证码:首次进入选题页加载图形验证码,防止脚本批量刷推荐列表。
6. 生产环境避坑指南
时间窗口配置
把“选题开始、结束、导师审核”三段时间写进数据库,服务启动即缓存,避免硬编码。教务临时调时间,只需改表,不用发版。导师配额超卖
不要先SELECT remain再UPDATE,高并发下必超卖。
正确姿势:UPDATE topic SET remain = remain - 1 WHERE id = ? AND remain > 0
返回影响行数 =1 才表示扣减成功,否则回滚订单并提示“已满”。学生误操作恢复
提供“一键撤选”按钮,状态机改为 PRE → CANCEL,并remain+1。
记录op_log表,字段(student_id, topic_id, from_status, to_status, op_uid, ctime),方便教务审计。灰度发布
选课系统一年只用一次,但流量集中。上线前用 Nginx 按 Cookie 灰度 10% 流量,观察错误率,再全量。监控告警
- Prometheus 采集
order_status_total各状态计数,突增可预警。 - 锁等待时间用
redis_slowlog监控,超过 1 ms 即打印。
- Prometheus 采集
7. 留给读者的思考题
没有历史选题数据时,协同过滤就是空矩阵。除了“随机分配”和“按 GPA 排序”,你还能想到哪些零数据冷启动方案?
- 用问卷采集学生关键词,再用内容相似度召回?
- 把导师研究方向做 Embedding,与学生简历文本做语义匹配?
- 或者干脆把第一次选题当做多臂老虎机,用 UCB 策略探索?
动手把代码跑通,再把你想到的冷启动策略提交 PR,让下一届学弟妹不被“抢题”支配。