说个挺实在的场景:学生在线投票这事,几乎每个学校都在做。优秀学生评选、社团换届、班级评优、宿舍评比,全是投票表决。纸质投票统计到半夜是常态,废票、唱票、涂改争议更是家常便饭。我去年接到一个院系需求,做一个学生在线投票系统,功能看起来不复杂:登录后看到投票列表、给某个候选人投一票、实时看结果,管理员能创建和管理投票。真正动手时才发现,“简单”两个字坑了多少人,光是防重复投票和并发处理就够喝一壶。更纠结的是选型——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_key=True) student_no = db.Column(db.String(20), unique=True, nullable=False) name = db.Column(db.String(50), nullable=False) def __repr__(self): return f'<User {self.student_no}>' class Poll(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(100), nullable=False) desc = db.Column(db.Text) status = db.Column(db.String(10), default='active') # draft / active / closed start_time = db.Column(db.DateTime, default=datetime.now) end_time = db.Column(db.DateTime) options = db.relationship('Option', backref='poll', lazy='dynamic') def is_active(self, now=None): 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_key=True) poll_id = db.Column(db.Integer, db.ForeignKey('poll.id')) content = db.Column(db.String(200), nullable=False) votes = db.Column(db.Integer, default=0) class VoteRecord(db.Model): id = db.Column(db.Integer, primary_key=True) 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, default=datetime.now) __table_args__ = ( db.UniqueConstraint('user_id', 'poll_id', name='uniq_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_length=100) desc = models.TextField(blank=True) status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='active') start_time = models.DateTimeField() end_time = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) 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_delete=models.CASCADE, related_name='options') content = models.CharField(max_length=200) votes = models.IntegerField(default=0) class VoteRecord(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) poll = models.ForeignKey(Poll, on_delete=models.CASCADE) option = models.ForeignKey(Option, on_delete=models.CASCADE) voted_at = models.DateTimeField(auto_now_add=True) class Meta: constraints = [ models.UniqueConstraint(fields=['user', 'poll'], name='uniq_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_id=poll_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_id=poll_id)) option_id = request.form.get('option_id', type=int) if not option_id: flash('请先选择一个选项') return redirect(url_for('poll_detail', poll_id=poll_id)) # 第1道防线:先查是否投过 exists = VoteRecord.query.filter_by(user_id=user_id, poll_id=poll_id).first() if exists: flash('你已经投过票了') return redirect(url_for('poll_detail', poll_id=poll_id)) try: # 第2道防线:锁定该选项行,防止并发下票数更新丢失 option = Option.query.filter_by(id=option_id, poll_id=poll_id)\ .with_for_update().first() if not option: flash('选项不存在') return redirect(url_for('poll_detail', poll_id=poll_id)) option.votes += 1 record = VoteRecord(user_id=user_id, poll_id=poll_id, option_id=option_id) db.session.add(record) db.session.commit() except IntegrityError: # 第3道防线:数据库唯一约束兜底 db.session.rollback() flash('你已投过票,不要重复提交') return redirect(url_for('poll_detail', poll_id=poll_id)) return redirect(url_for('poll_result', poll_id=poll_id))这套三层防线是我觉得Flask版最值得借鉴的地方。“先查再插”在并发场景下必然有竞态窗口,所以必须依赖数据库的唯一约束收尾。with_for_update()行锁确保票数原子递增,IntegrityError捕获则是最后的安全网——两个请求同时插入同一条投票记录时,有一个必然被数据库拒绝。
2.3 列表页的常见性能坑
投票列表页看起来简单,实际藏着一个N+1查询问题。如果按普通思路写“先查所有投票,再循环查每个投票的选项数”,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, pk=poll_id) if not poll.is_active(): messages.error(request, '投票不存在、未开始或已结束') return redirect('poll_detail', poll_id=poll.id) option_id = request.POST.get('option_id') option = get_object_or_404(Option, pk=option_id, poll=poll) try: with transaction.atomic(): record, created = VoteRecord.objects.get_or_create( user=request.user, poll=poll, defaults={'option': option} ) if not created: messages.error(request, '你已经投过票了') return redirect('poll_detail', poll_id=poll.id) Option.objects.filter(pk=option.pk).update(votes=F('votes') + 1) except IntegrityError: messages.error(request, '投票失败,请勿重复提交') return redirect('poll_detail', poll_id=poll.id) return redirect('poll_result', poll_id=poll.id)这里有几个点值得单独说。get_or_create底层依赖唯一约束会比较两列,如果记录已存在就直接返回,并且created=False。至于为什么还要再套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(status='closed') @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 method="post" action="{% url 'poll_vote' poll.id %}"> {% csrf_token %} {% for op in poll.options.all %} <label> <input type="radio" name="option_id" value="{{ op.id }}" required> {{ op.content }} </label> {% endfor %} <button type="submit">投票</button> </form>对比Flask,如果忘了引入Flask-WTF并且手动关闭Autoescape,很容易露出XSS破绽。Django帮我把这些安全默认值都设好了,这在给学生用而没人专职运维的校园系统里特别重要。
4. 两版实现对照:选型不是选最好,而是选最合适
同一套需求两个版本都跑通后,我觉得最有价值的产出不是代码,而是对选型的清醒认识。网上争论哪个框架好,本质是没把需求场景固定下来。放一张我的实测对照:
| 维度 | Flask方案 | Django方案 |
|---|---|---|
| 用户认证 | 手写session/login逻辑 | 内置auth系统,可直接对接现有User模型 |
| 数据层 | SQLAlchemy灵活但需自己组织 | ORM + 迁移机制一条龙,改字段后迁移即可 |
| 管理后台 | 需要自己写页面 | Admin开箱即用,配置即完成 |
| 表单处理 | 手动解析request.form | forms组件自动校验,模板渲染也顺手 |
| 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版本那份代码我一直留着,每当要排查类似问题时,它仍是最好用的调试玩具。