Flask与Django双版本实现学生在线投票系统:从选型到并发防重实践
发布时间:2026/10/2 4:06:07来源:尧图网络
说个挺实在的场景学生在线投票这事几乎每个学校都在做。优秀学生评选、社团换届、班级评优、宿舍评比全是投票表决。纸质投票统计到半夜是常态废票、唱票、涂改争议更是家常便饭。我去年接到一个院系需求做一个学生在线投票系统功能看起来不复杂登录后看到投票列表、给某个候选人投一票、实时看结果管理员能创建和管理投票。真正动手时才发现“简单”两个字坑了多少人光是防重复投票和并发处理就够喝一壶。更纠结的是选型——Python Web开发两条主流路线Flask和Django都特别成熟网上教程也满天飞但都是各讲各的。最后我两个框架各写了一遍同一套需求、同一个数据库双版本跑通。这篇文章就是这次双版本实践的完整记录适合正在学Python Web开发的、想拿投票系统练手的、或者准备做类似信息管理系统的人参考。1. 需求拆解投票系统到底要管哪些事很多新手上来就建一张表、写一个加一接口觉得投票嘛不就是票数字段自增一下。真做起来你会发现投票系统核心在“投票”这个词背后的约束一个学生只能投一次、投票有时间窗口、中途老师可能暂停投票、结果不能因为并发请求被算错。所以第一步不是写代码是把业务拆成数据模型。1.1 从业务场景到数据字段先明确角色学生是投票人老师/管理员是投票创建者。需求一般包含这几类用户身份必须知道是谁投的否则防重复无从谈起。学生系统通常用学号或者已有教务账号体系。投票主题Poll标题、描述、状态、开始时间、结束时间。状态至少有草稿、投票中、已结束三种哪怕最小版本也要有开关。选项Option挂在某个投票主题下候选人姓名/作品名外加一个票数字段。投票记录VoteRecord谁、什么时间、投给哪个主题的哪个选项。这里有个设计重点。票数可以冗余存在选项表里方便直接展示但投票行为本身必须单独建表记录。绝大多数新手只建了“选项表票数”两样东西不建投票记录表后果就是系统根本不知道一个学生投没投过无法审计、无法防止重复。投票记录表就是整个系统的“底账”将来导出投票明细、做班级分布统计都靠它。1.2 两个框架的模型写法对比先看Flask SQLAlchemy的写法定义在models.py里from flask_sqlalchemy import SQLAlchemy from datetime import datetime db SQLAlchemy() class User(db.Model): id db.Column(db.Integer, primary_keyTrue) student_no db.Column(db.String(20), uniqueTrue, nullableFalse) name db.Column(db.String(50), nullableFalse) def __repr__(self): return fUser {self.student_no} class Poll(db.Model): id db.Column(db.Integer, primary_keyTrue) title db.Column(db.String(100), nullableFalse) desc db.Column(db.Text) status db.Column(db.String(10), defaultactive) # draft / active / closed start_time db.Column(db.DateTime, defaultdatetime.now) end_time db.Column(db.DateTime) options db.relationship(Option, backrefpoll, lazydynamic) def is_active(self, nowNone): now now or datetime.now() return self.status active and self.start_time now and (self.end_time is None or now self.end_time) class Option(db.Model): id db.Column(db.Integer, primary_keyTrue) poll_id db.Column(db.Integer, db.ForeignKey(poll.id)) content db.Column(db.String(200), nullableFalse) votes db.Column(db.Integer, default0) class VoteRecord(db.Model): id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer, db.ForeignKey(user.id)) poll_id db.Column(db.Integer, db.ForeignKey(poll.id)) option_id db.Column(db.Integer, db.ForeignKey(option.id)) voted_at db.Column(db.DateTime, defaultdatetime.now) __table_args__ ( db.UniqueConstraint(user_id, poll_id, nameuniq_user_poll), )Django版本长这样注意它自带用户体系直接用auth.User扩展学生信息即可不需要自己建User表from django.db import models from django.contrib.auth.models import User class Poll(models.Model): STATUS_CHOICES [ (draft, 草稿), (active, 投票中), (closed, 已结束), ] title models.CharField(max_length100) desc models.TextField(blankTrue) status models.CharField(max_length10, choicesSTATUS_CHOICES, defaultactive) start_time models.DateTimeField() end_time models.DateTimeField(nullTrue, blankTrue) created_at models.DateTimeField(auto_now_addTrue) def is_active(self): now timezone.now() return self.status active and self.start_time now and (self.end_time is None or now self.end_time) class Option(models.Model): poll models.ForeignKey(Poll, on_deletemodels.CASCADE, related_nameoptions) content models.CharField(max_length200) votes models.IntegerField(default0) class VoteRecord(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE) poll models.ForeignKey(Poll, on_deletemodels.CASCADE) option models.ForeignKey(Option, on_deletemodels.CASCADE) voted_at models.DateTimeField(auto_now_addTrue) class Meta: constraints [ models.UniqueConstraint(fields[user, poll], nameuniq_user_poll) ]两套模型的核心差别就一个Django把用户体系、迁移机制、后台管理都准备好了Flask则全靠自己搭。写投票业务逻辑两者差别不大但“周边设施”一个免费送一个要手搓这就是后面所有体验差异的根源。2. Flask版实现小步快跑逻辑自己掌控我先写的是Flask版。选它做第一个版本的理由很实际项目不大我想把投票、防重复、统计这些核心逻辑亲手摸一遍。Flask足够轻路由和视图函数一目了然出问题排查起来不绕弯。依赖也就Flask、Flask-SQLAlchemy、Flask-WTF这几个库。2.1 路由规划与用户会话Flask没有自带的登录系统这里我用最简单可靠的会话方案登录成功后把user_id写进session后续请求都从session里取用户身份。路由规划简洁直接方法路径作用GET/投票列表页GET/polls/int:poll_id投票详情含选项表单POST/polls/int:poll_id/vote提交投票GET/polls/int:poll_id/result查看票数结果GET/POST/login/logout登录与退出登录视图核心逻辑是把学号对应的用户查出来写入session后续每个需要身份的路由统一校验。一个小细节session要设过期时间学生票选这种场景一般当天有效用permanent_session_lifetime配成8小时比较合适。2.2 投票接口的并发安全不能简单加一投票接口是整个系统的核心也是最容易写错的地方。最朴素的写法是“查出选项票数加一存回去”听着没问题但假如两个学生同时投票两个请求同时读出votes 10各自加一再写回最后存的是11而不是12投票就“丢”了。这类并发问题在本地开发测试时几乎复现不出来一旦上服务器就现原形。所以必须用数据库行的原子更新。SQLAlchemy里有两种常用办法一种是直接执行原子自增语句另一种是给行加锁。我当时两个都用了效果最好的是“先查重、再锁行、最后统一提交”的组合from sqlalchemy.exc import IntegrityError app.route(/polls/int:poll_id/vote, methods[POST]) def vote(poll_id): user_id session.get(user_id) if not user_id: return redirect(url_for(login)) poll Poll.query.get(poll_id) if not poll or poll.status ! active: flash(投票不存在或已结束) return redirect(url_for(poll_detail, poll_idpoll_id)) now datetime.now() if now poll.start_time or (poll.end_time and now poll.end_time): flash(当前不在投票时间范围内) return redirect(url_for(poll_detail, poll_idpoll_id)) option_id request.form.get(option_id, typeint) if not option_id: flash(请先选择一个选项) return redirect(url_for(poll_detail, poll_idpoll_id)) # 第1道防线先查是否投过 exists VoteRecord.query.filter_by(user_iduser_id, poll_idpoll_id).first() if exists: flash(你已经投过票了) return redirect(url_for(poll_detail, poll_idpoll_id)) try: # 第2道防线锁定该选项行防止并发下票数更新丢失 option Option.query.filter_by(idoption_id, poll_idpoll_id)\ .with_for_update().first() if not option: flash(选项不存在) return redirect(url_for(poll_detail, poll_idpoll_id)) option.votes 1 record VoteRecord(user_iduser_id, poll_idpoll_id, option_idoption_id) db.session.add(record) db.session.commit() except IntegrityError: # 第3道防线数据库唯一约束兜底 db.session.rollback() flash(你已投过票不要重复提交) return redirect(url_for(poll_detail, poll_idpoll_id)) return redirect(url_for(poll_result, poll_idpoll_id))这套三层防线是我觉得Flask版最值得借鉴的地方。“先查再插”在并发场景下必然有竞态窗口所以必须依赖数据库的唯一约束收尾。with_for_update()行锁确保票数原子递增IntegrityError捕获则是最后的安全网——两个请求同时插入同一条投票记录时有一个必然被数据库拒绝。2.3 列表页的常见性能坑投票列表页看起来简单实际藏着一个N1查询问题。如果按普通思路写“先查所有投票再循环查每个投票的选项数”10个投票就要发出21条SQL。列表页数据量小的时候无所谓等投票多了、并发来了就雪上加霜。Flask里用joinedload预加载关系即可解决from sqlalchemy.orm import joinedload polls Poll.query.options(joinedload(Poll.options)).all()这个场景我在Django版里会直接用select_related/prefetch_related思路完全一致。凡是“循环里查数据库”的模式都要警惕这是新手最容易忽略的点。3. Django版实现全家桶让我偷了很多懒Flask版跑通后我趁热打铁用Django重写了一遍。事先声明不是Django比Flask更好而是这个项目恰好踩中了Django的优势区校内系统需要管理后台Django Admin开箱即用用户登录、CSRF、表单验证都是内置的。两相对比我明显感到“框架替你操心”和“框架把选择权交给你”的做事节奏完全不同。3.1 项目骨架与App划分Django用两条命令搞定项目初始化django-admin startproject vote_site cd vote_site python manage.py startapp vote然后settings.py里注册app、配置数据库默认SQLite正式环境我换成MySQL或PostgreSQL、设置AUTH_PASSWORD_VALIDATORS。这里有个关键心得Django的项目划分思路和Flask很不一样Flask是你自己决定建几个文件Django直接按app组织业务模块。投票系统只有一个核心业务域一个app就够但后续如果要做公告管理、权限管理就顺势开新app边界比Flask清晰得多。创建模型后跑一次迁移这是Django把数据层体验做得最舒服的地方python manage.py makemigrations vote python manage.py migrate模型变更后无需手工同步数据库这个体验对维护期特别友好。3.2 用get_or_create替代手动查重Django版的核心投票逻辑我换了更地道的写法get_or_create配合IntegrityError比Flask版“查一次、再插入”更紧凑。事务块保证两个操作同生共死from django.views.decorators.http import require_POST from django.contrib.auth.decorators import login_required from django.db import transaction, IntegrityError from django.db.models import F from django.shortcuts import render, redirect, get_object_or_404 from django.contrib import messages from django.utils import timezone require_POST login_required def poll_vote(request, poll_id): poll get_object_or_404(Poll, pkpoll_id) if not poll.is_active(): messages.error(request, 投票不存在、未开始或已结束) return redirect(poll_detail, poll_idpoll.id) option_id request.POST.get(option_id) option get_object_or_404(Option, pkoption_id, pollpoll) try: with transaction.atomic(): record, created VoteRecord.objects.get_or_create( userrequest.user, pollpoll, defaults{option: option} ) if not created: messages.error(request, 你已经投过票了) return redirect(poll_detail, poll_idpoll.id) Option.objects.filter(pkoption.pk).update(votesF(votes) 1) except IntegrityError: messages.error(request, 投票失败请勿重复提交) return redirect(poll_detail, poll_idpoll.id) return redirect(poll_result, poll_idpoll.id)这里有几个点值得单独说。get_or_create底层依赖唯一约束会比较两列如果记录已存在就直接返回并且createdFalse。至于为什么还要再套transaction.atomic()——因为get_or_create和后面的票数更新必须作为一个整体要么都成功要么都回滚。而票数更新之所以用F(votes) 1而不是读出再写回是因为F表达式会在数据库层面执行原子自增这与Flask版里的with_for_update()是同一个目的解决并发下更新丢失。很多教学项目不教这些但真实上线时这两个点必踩。3.3 Admin后台没花一分钱前端Django Admin对这个项目来说才是真正的杀手锏。老师要创建投票、中途关闭投票、看投票明细如果全靠自己写admin页面工作量能多出三分之一。现在只要在admin.py里注册模型、配几个字段就够了from django.contrib import admin from .models import Poll, Option, VoteRecord class OptionInline(admin.TabularInline): model Option extra 2 admin.register(Poll) class PollAdmin(admin.ModelAdmin): list_display (title, status, start_time, end_time) list_filter (status,) search_fields (title,) inlines [OptionInline] actions [close_polls] admin.action(description关闭选中的投票) def close_polls(self, request, queryset): queryset.update(statusclosed) admin.register(VoteRecord) class VoteRecordAdmin(admin.ModelAdmin): list_display (user, poll, option, voted_at) list_filter (poll,) search_fields (user__username, user__nickname, poll__title)把OptionInline放进PollAdmin后老师创建投票时直接在同一个页面里把候选人选项一排填好用户体验完全不需要培训。list_filter按状态筛选投票search_fields直接搜学生姓名导出的视角也有了。这些功能用Flask写至少需要几十个模板文件和视图函数Django一行配置就送上门。这不是吹Django是真的省事。3.4 Django的模板和CSRF不用操心Django的模板自带了{% csrf_token %}表单POST天然带防护模板里的{{ poll.title }}也不会被转义成危险内容默认自动转义。写的时候注意form methodpost action{% url poll_vote poll.id %} {% csrf_token %} {% for op in poll.options.all %} label input typeradio nameoption_id value{{ op.id }} required {{ op.content }} /label {% endfor %} button typesubmit投票/button /form对比Flask如果忘了引入Flask-WTF并且手动关闭Autoescape很容易露出XSS破绽。Django帮我把这些安全默认值都设好了这在给学生用而没人专职运维的校园系统里特别重要。4. 两版实现对照选型不是选最好而是选最合适同一套需求两个版本都跑通后我觉得最有价值的产出不是代码而是对选型的清醒认识。网上争论哪个框架好本质是没把需求场景固定下来。放一张我的实测对照维度Flask方案Django方案用户认证手写session/login逻辑内置auth系统可直接对接现有User模型数据层SQLAlchemy灵活但需自己组织ORM 迁移机制一条龙改字段后迁移即可管理后台需要自己写页面Admin开箱即用配置即完成表单处理手动解析request.formforms组件自动校验模板渲染也顺手CSRF防护需要Flask-WTF手动启用默认开启模板标签直接调用部署上手简单一个Gunicorn搞定需collectstatic、WSGI配置步骤略多适合人群想学原理、接口为主、喜欢掌控细节项目周期短、后台需求重、团队协作开发总结成一句话如果需求是“做一个信息展示表单提交的小应用”Flask轻如蝉翼如果需求是“做一个有用户管理、有后台维护、未来还要扩展模块的系统”Django全家桶的直接收益太大了。学生在线投票系统其实兼具两者——核心逻辑简单但管理维护场景特别重所以如果让我再选一次生产方案我会直接上Django。5. 上线部署与细节调优别在最后一步翻车开发跑通只是半程我这次在部署阶段也踩得七荤八素放出来给大家参考。5.1 开发服务器绝不能直接生产用Flask内置服务器和Django的runserver都明确打印过“不要在生产环境使用”但很多人低估了这句话的含金量。生产环境我用的是Gunicorn启动命令两个框架略有不同# Flask版 gunicorn -w 4 -b 127.0.0.1:8000 app:app # Django版 gunicorn -w 4 -b 127.0.0.1:8000 vote_site.wsgi:application-w 4表示4个worker进程能同时处理4个并发请求。实际部署还需要在Gunicorn前面挂Nginx把静态文件和反向代理都交给它Gunicorn只处理动态请求。这里有一个关键配置曾经让我挠头——如果只启动Gunicorn而不配置Nginx静态资源加载会极慢且卡顿因为Favicon、CSS、JS全都要走Python进程白白浪费动态请求资源。Django部署时还要先执行python manage.py collectstatic把静态文件收集到统一目录然后让Nginx指向它。Flask则简单些把/static目录映射到Nginx即可。5.2 时区问题别让投票提前开启这是我这次项目里遇到的一个特别隐蔽的坑。Django的settings.py里USE_TZ True时数据库里存的是UTC时间模板显示时Django会自动转成TIME_ZONE指定的本地时间逻辑上没问题。但如果你在代码里混用了time.time()时间戳和datetime.now()本地时间去比较投票的开始结束时间就会出现“投票提前一个小时开启”这种看起来像灵异事件的bug。统一方案是Django里一律用django.utils.timezone.now()Flask里全程使用datetime.now()绝不混用。另外一个相关细节投票截止判断尽量在应用层做不要完全依赖数据库端的时间函数否则不同环境时间基准不一致日志定位非常痛苦。5.3 结果实时展示的轮询方案学生投票时希望看到实时结果最朴素可靠的方案是前端轮询。每3~5秒请求一次结果接口更新票数。别一听到“实时”就上WebSocket对投票这个场景来说数据变更频率极低、延迟需求也不是秒级轮询的服务器开销很小代码也简单得多。WebSocket适合聊天、行情这类高频率低延迟推送用来做投票结果反而是过度设计。热搜里总能看到“django websocket实现前端推送”但如果你的场景允许几秒延迟轮询是更稳的选择。6. 踩坑记录与收尾心得最后集中写几个这次实际踩过、且网上教程很少明说的坑。6.1 刷新页面导致重复提交投票投票提交后如果不做重定向用户按F5刷新浏览器可能会重新提交POST表单造成重复投票的报错或脏数据。解法就是经典的POST/Redirect/GET模式所有修改性操作处理成功后不要直接渲染模板而是redirect到GET页面。我在Flask和Django两个版本里都强制遵守了这个规则用户的刷新、前进、后退都不会造成重复写入。6.2 并发重复投票的真正防线是数据库唯一约束我在Flask版服务里做了三重防线但关键兜底永远是数据库的UniqueConstraint。哪怕应用层逻辑写错了数据库也会拒绝重复记录并抛出IntegrityError。这里提醒一点SQLite对并发写的支持有限上线时务必把数据库换成MySQL或PostgreSQL这一点我在项目后期才确认。SQLite在开发时省事但学生同时投票的瞬时并发足以让它报“database is locked”。6.3 Cookie与会话细节Flask里session默认写在客户端Cookie中适合存user_id这种轻量数据。但我没有选择把投票状态也塞进Cookie而是全部放在服务端数据库因为投票状态的正确性不能被客户端篡改。Django侧如果不希望session跟着用户关闭浏览器就一直有效可以设置SESSION_COOKIE_AGE并定期清理过期会话。传Token或身份信息时也尽量使用HttpOnly的Cookie避免恶意脚本通过JS读取。6.4 展示策略隐藏票数能减少“跟票”心理这是我额外分享的一个产品层面心得。投票结果如果在投票期间直接公开每个人累计票数很容易出现后来者直接投给当前票数最多的人所谓“跟票效应”。我在结果页做了两个模式投票进行中只显示每个选项的百分比而不显示绝对票数并且不透露“当前最高票”是哪一项投票结束后再显示完整票数。这样一来前期数据不会影响后续投票者的判断投票的公正性明显提升。这次双版本实践给我最大的收获是Flask让我把Web开发的每一个齿轮都看清楚Django则让我见识到成熟框架的工程化红利。如果你刚入门Python Web强烈建议先拿Flask写一个小项目把路由、ORM、鉴权、迁移这些基础概念亲手过一遍只是注意控制好项目边界别让“自由”变成“失控”。之后再用Django做有后台管理、有用户系统的正经项目你会发现之前踩过的坑都变成了选型和排错的经验。这套学生在线投票系统最终交付的是Django版本但Flask版本那份代码我一直留着每当要排查类似问题时它仍是最好用的调试玩具。
网站建设高端定制企业官网