尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用Flask构建在线投票系统:数据库设计与防重复投票实践

用Flask构建在线投票系统:数据库设计与防重复投票实践 1. 需求边界与数据模型不会建表就别谈投票系统我这次用 Flask 重写在线投票系统本质上是被活动组织逼出来的。当时需要快速收集几十个人的意见要求做到匿名、一人只能投一次、结果出来要即时可见。试了一圈问卷平台要么免费版功能卡得死死的要么结果导出各种不方便索性自己写一个。1.1 功能清单先收敛再谈技术选型做这种面向实际使用的系统最怕一上来就规划用户管理、权限分配、短信验证码、小程序端。我的建议是第一版先把核心闭环做出来创建投票 → 用户点开投票 → 选择一个选项 → 提交 → 查看结果。围绕这个闭环必要的辅助功能只有三四个投票列表页展示所有进行中的投票投票详情页显示投票标题、描述和可选选项结果页显示每个选项的票数和百分比重复投票拦截保证同一个浏览器会话只能投一次至于其它东西比如管理员后台、投票截止时间、数据导出 Excel都属于“锦上添花”。第一版不做不影响主体功能上线反而能让项目结构保持清晰。技术选型这块我用的是 Flask 而不是 Django。原因很直接投票系统不需要 Django 自带的后台管理、ORM 迁移、中间件那一大套东西。Flask 的核心路线是“一个路由对应一个函数”写起来直观调试也方便。配合 Flask-SQLAlchemy 操作数据库加上 Jinja2 做页面模板渲染整个系统不到两百行核心代码就能跑通非常适合这种轻量业务。1.2 三张数据表的设计逻辑数据库设计是投票系统最关键的环节。我的表结构一共三张poll投票主题、option投票选项、vote_log投票记录。很多新手会忽略vote_log这张表直接在option表里放一个数字类型的 votes 字段投票时就votes 1。这确实简单但带来两个问题一是没法追溯投票记录二是并发场景下会出现计数丢失。后面讲到防刷票的时候你会看到它有多重要。先看这三个表的字段设计表名关键字段作用pollid, title, description, created_at, is_active投票主题is_active 用于控制投票是否开放optionid, poll_id, content, votes每个投票下的选项votes 为缓存计数vote_logid, poll_id, option_id, voter_key, voted_at投票日志voter_key 是用户会话标识用于去重Option.votes和VoteLog看起来有一定冗余但这不是缺陷而是一种刻意的读写优化。结果页直接读Option.votes就行不用每次都COUNT一遍日志表性能上省很多。日志表存在的目的是做校验和审计两个数据源如果长期不一致说明程序有 bug反而能早发现问题。时间字段我建议用DateTime存 UTC 时间展示的时候再转本地时区。这一步看着不重要等部署到服务器上、访问者跨了时区就能体会到了。2. Flask工程骨架从零搭建到能够启动工程结构是我在开始写代码之前就确定的。很多人喜欢把所有代码塞进一个app.py投票系统业务量小这样确实能跑但后期加功能的时候会很痛苦。我采用的是这种结构poll_system/ ├── app.py # 应用入口注册路由 ├── models.py # 数据库模型定义 ├── requirements.txt # 依赖清单 ├── templates/ # Jinja2 模板 │ ├── base.html │ ├── index.html │ ├── poll_detail.html │ └── poll_result.html └── static/ # 静态文件CSS 和 JavaScript2.1 项目目录与依赖清单依赖清单非常简单就四个核心库Flask2.2 Flask-SQLAlchemy3.0写requirements.txt时我建议标上大版本号而不是锁定到精确版本比如Flask2.2而不是Flask2.2.5。这样以后系统环境有安全更新时pip install -r requirements.txt能拉到最新的兼容版本不至于卡死在一个老版本上。使用虚拟环境是必须要做的事。凡是直接用系统全局 Python 装依赖的过不了几个月就会被各种包冲突搞得痛不欲生。我在项目目录下执行python -m venv venvWindows 下激活虚拟环境运行venv\Scripts\activateLinux 和 macOS 下运行source venv/bin/activate。激活后终端提示符前面会出现(venv)标记看到这个标记才说明当前环境切换成功。2.2 模型定义与初始化数据库models.py的代码是投票系统的基础我拆开写更清晰。用 Flask-SQLAlchemy 定义三个模型同时加上数据库初始化逻辑from datetime import datetime from flask_sqlalchemy import SQLAlchemy db SQLAlchemy() class Poll(db.Model): __tablename__ poll id db.Column(db.Integer, primary_keyTrue) title db.Column(db.String(128), nullableFalse) description db.Column(db.Text, default) created_at db.Column(db.DateTime, defaultdatetime.utcnow) is_active db.Column(db.Boolean, defaultTrue) options db.relationship(Option, backrefpoll, lazydynamic) property def total_votes(self): return sum(option.votes for option in self.options) class Option(db.Model): __tablename__ option id db.Column(db.Integer, primary_keyTrue) poll_id db.Column(db.Integer, db.ForeignKey(poll.id), nullableFalse) content db.Column(db.String(200), nullableFalse) votes db.Column(db.Integer, default0) class VoteLog(db.Model): __tablename__ vote_log id db.Column(db.Integer, primary_keyTrue) poll_id db.Column(db.Integer, db.ForeignKey(poll.id), nullableFalse) option_id db.Column(db.Integer, db.ForeignKey(option.id), nullableFalse) voter_key db.Column(db.String(64), indexTrue, nullableFalse) voted_at db.Column(db.DateTime, defaultdatetime.utcnow)Poll.options用了lazydynamic这是刻意的。这样在投票详情页只需要显示选项文本而结果页才需要统计票数不同的查询场景可以分开做不会一加载 Poll 就把所有关联数据全带出来。初始化数据库有两种方式。开发阶段我直接用db.create_all()省去迁移工具的学习成本。生产环境升级表结构时才考虑用 Flask-Migrate 做迁移管理。在app.py里初始化from flask import Flask from models import db def create_app(): app Flask(__name__) app.config[SECRET_KEY] dev-secret-key-please-change app.config[SQLALCHEMY_DATABASE_URI] sqlite:///poll.db db.init_app(app) with app.app_context(): db.create_all() return app2.3 开发环境配置VSCode里快速跑起来如果你是在 VSCode 里开发有一个细节特别值得注意。装好 Python 扩展之后打开项目文件夹按 CtrlShiftP 调出命令面板执行 “Python: Select Interpreter”一定要选到.venv路径下的那个解释器。VSCode 底部状态栏会显示当前的 Python 版本和环境名称确认是venv再运行代码。配置完解释器之后在终端执行pip install -r requirements.txt flask --app app run --debug--app app是指定 Flask 应用对象在app.py文件里配合--debug参数可以在改代码后自动重载不用手动重启服务。3. 投票主流程的实现创建、展示、提交、统计说句实在话投票系统的主流程代码并不多但是每一步都有细节值得打磨。从创建投票到最终统计结果一共四个路由把整个业务流程串起来。3.1 创建投票的POST接口与选项处理创建投票的表单里标题是普通文本选项是“多个同名字段”。前端 HTML 是这样的input typetext nameoptions required input typetext nameoptions required input typetext nameoptions required这样用户可以在一个投票里填三个甚至更多选项。后端用request.form.getlist(options)一次性拿到所有同名字段app.route(/create, methods[POST]) def create_poll(): title request.form.get(title, ).strip() options [item.strip() for item in request.form.getlist(options) if item.strip()] if not title or len(options) 2: flash(标题不能为空且至少需要两个选项) return redirect(url_for(index)) poll Poll(titletitle) for content in options: poll.options.append(Option(contentcontent)) db.session.add(poll) db.session.commit() return redirect(url_for(poll_detail, poll_idpoll.id))这里有两个容易被忽略的处理点。第一所有输入都要strip()去空格不然用户无意输入带空格的内容结果页显示出来就是一段极丑的文本。第二选项数量必须至少两个这个校验虽然是常识但很多实现把校验放在前端就不管后端了。绕过前端直接 POST 请求就会出现只有一个选项的投票这显然是业务上的漏洞。3.2 投票页面与提交逻辑投票详情页展示投票标题和选项列表每个选项前面是一个单选按钮。用户点击提交浏览器向/vote/option_id发起 POST 请求app.route(/vote/int:option_id, methods[POST]) def vote(option_id): option Option.query.get(option_id) if not option: abort(404) poll option.poll if not poll.is_active: flash(该投票已结束) return redirect(url_for(poll_detail, poll_idpoll.id)) voter_key session.get(voter_key) if not voter_key: voter_key generate_voter_key() session[voter_key] voter_key exists VoteLog.query.filter_by(poll_idpoll.id, voter_keyvoter_key).first() if exists: flash(你已经参与过这个投票了) return redirect(url_for(poll_detail, poll_idpoll.id)) option.votes 1 db.session.add(VoteLog(poll_idpoll.id, option_idoption.id, voter_keyvoter_key)) db.session.commit() return redirect(url_for(poll_result, poll_idpoll.id))voter_key是一个随机生成的字符串存在 Flask 的session里。Flask 的 session 默认是加密签名后的 Cookie存放在浏览器端服务端不保存状态。这样对于普通用户来说关掉浏览器再打开只要 Cookie 还在voter_key就不会丢能有效拦截“刷新页面重复提交”的场景。3.3 结果统计比例计算的边界结果页我单独做了一个路由所有投票完成后跳转到这个页面。计算百分比时最怕除零错误。如果某个投票刚创建出来还没人投total_votes是 0百分比直接除零崩溃。我在模板里做了保护app.route(/poll/int:poll_id/result) def poll_result(poll_id): poll Poll.query.get_or_404(poll_id) total poll.total_votes results [] for option in poll.options: percent round((option.votes / total * 100), 1) if total else 0 results.append({ content: option.content, votes: option.votes, percent: percent }) return render_template(poll_result.html, pollpoll, resultsresults)百分比保留一位小数用round(..., 1)。有些人喜欢四舍五入到整数但实际显示时多个百分比加起来可能超过 100% 或者不足 100%保留一位小数会稍微好一点。如果后续要做可视化图表一位小数的浮点数也方便直接喂给前端图表库。4. 防重复投票与并发更新的真实考验在线投票系统最难搞的并不是投票流程本身而是“怎么保证一个人只能投一次”。如果这个系统只是给自己班级内部用那 Session 方案完全够了。但如果部署到公网上马上会遇到两个棘手的现实问题。4.1 Session标识方案想做匿名投票先明白它的局限我用的 Session 标识是这样的import secrets import string def generate_voter_key(): alphabet string.ascii_letters string.digits return .join(secrets.choice(alphabet) for _ in range(16))艺术效果上就是发一个随机字符串给浏览器浏览器每次投票带着这个字符串来服务端查日志表就知道这个人投过没有。为什么不用 IP因为很多学校的网络出口是同一个公网 IP用 IP 做限制一个宿舍的人可能只能投一票完全没法用。代理和更换浏览器也会让 Session 限制失效。比如用户开无痕模式或者换一个浏览器voter_key就变了又能投一票。这是匿名投票方案的天花板除非引入登录注册否则无法根除。所以在这个问题上第一版能做的是“防君子不防小人”把误操作和简单刷票挡住真正恶意刷票需要登录体系才能解决。4.2 并发刷票会让计数变量悄悄丢失这个坑特别隐蔽。看一下投票提交逻辑按顺序做的三件事1. 查询 VoteLog 表判断该用户是否投过 2. 执行 option.votes 1 3. 写入 VoteLog 记录并提交事务如果只有一个请求在跑这个顺序没问题。但在高并发场景下两个请求同时通过了第 1 步的判断然后都执行了第 2 步。假设同时来了三次投票请求数据库写入到VoteLog的三条记录可能还是三个不同用户正好这三个用户都没投过但是程序逻辑已经乱了。那三个请求都认为自己“没投过”实际最后写了三条日志计数也可能出错。更常见的情况是同一用户快速点提交。浏览器前端已经禁用了按钮但如果用自动化脚本直接向/vote/接口发多个并发请求后端每个请求都会走一遍“判断不存在 → 加票 → 写日志”的过程。由于第一步判断时日志还没写进去多个请求都能通过校验结果就是一个用户投了 N 次。4.3 数据库约束兜底比程序判断可靠解决这个问题的核心思路是不能用程序逻辑做并发控制应该把“同一投票下同一个用户只能有一条日志”这个规则下沉到数据库层。给VoteLog加上唯一联合索引class VoteLog(db.Model): __tablename__ vote_log __table_args__ ( db.UniqueConstraint(poll_id, voter_key, nameuniq_poll_voter), ) # 其它字段...然后在投票提交逻辑中不再依赖“先查询后判断”而是直接插入日志捕获数据库的唯一约束异常from sqlalchemy.exc import IntegrityError app.route(/vote/int:option_id, methods[POST]) def vote(option_id): # ... 前面代码不变 ... option.votes 1 log VoteLog(poll_idpoll.id, option_idoption.id, voter_keyvoter_key) db.session.add(log) try: db.session.commit() except IntegrityError: db.session.rollback() flash(你已经参与过这个投票了) return redirect(url_for(poll_detail, poll_idpoll.id)) return redirect(url_for(poll_result, poll_idpoll.id))修正之后即使一万个并发请求同时带着同一个voter_key打过来数据库唯一索引也只能让其中一条插入成功其它全部抛IntegrityError。这是数据库层面硬性保证比任何程序判断都可靠。需要提醒一句SQLite 对并发支持不如 MySQL / PostgreSQL但是在轻量级使用场景下配合唯一索引兜底已经足够稳健。如果未来系统访问量上来了把数据库连接串换成 MySQL这个设计同样适用。5. Jinja2模板与前端交互投票页面应该有的细节我承认最初写这个系统时我把注意力全放在后端业务逻辑上前端就是随便套了个模板。结果实际给别人用的时候反馈最多的问题反而全在页面上不知道结果哪里看、投票按钮不提示反馈、刷新页面数据不更新。前端细节直接影响用户对系统完整度的判断。5.1 模板继承与表单渲染Jinja2 的模板继承极大减少了重复代码。我建了一个基础模板base.html把公共样式和页面框架固定住其它页面只写自己的内容块。poll_detail.html的核心是表单循环渲染选项{% extends base.html %} {% block content %} h1{{ poll.title }}/h1 p{{ poll.description }}/p form methodpost action/vote/{{ poll.id }} input typehidden namecsrf_token value{{ csrf_token }} {% for option in poll.options %} label input typeradio nameoption_id value{{ option.id }} required {{ option.content }} /label {% endfor %} button typesubmit提交投票/button /form {% endblock %}有人在表单里用nameoption_id加上一个隐藏的poll_id字段这样后端可以同时拿到投票 ID 和选项 ID。但我的做法是直接把poll_id放进 action 路径里这样整个表单只需要一个业务字段接口更干净。表单提交到/vote/{{ poll.id }}但后端路由实际接收的是option_id。这是个语义不同但结构一致的 URL 设计投票这个动作的语义对象是“主题”真正操作的数据是“选项”。路由/vote/int:option_id从路径取选项 ID不需要再从表单里读一次省了一层校验。5.2 CSRF别人帮你刷票的入门手段很多人觉得本地练习项目不需要 CSRF 保护这是完全错误的想法。CSRF跨站请求伪造的原理很简单你在别的网页上提交了一个表单这个表单却向投票系统发起了请求。浏览器会携带投票网站的 Cookie于是服务端以为这个请求是用户本人发起的。如果你没有 CSRF 防护别人可以在任意网站上写一个隐藏表单诱导访问者点击就相当于帮他们投了票。我在项目里没有引入 Flask-WTF而是用最原始的方式手动实现了 CSRF token。在渲染所有页面前先确保 session 里存在一个随机 tokenapp.before_request def ensure_csrf_token(): if csrf_token not in session: session[csrf_token] secrets.token_hex(16)模板里每个表单都带上这个 token上面的表单已经写了csrf_token字段。后端在接收 POST 请求时校验from functools import wraps def validate_csrf(f): wraps(f) def decorated(*args, **kwargs): token session.get(csrf_token) request_token request.form.get(csrf_token) if not token or not request_token or token ! request_token: abort(400, descriptionCSRF token 校验失败) return f(*args, **kwargs) return decorated装饰器用起来很方便直接加到需要保护的 POST 路由上app.route(/vote/int:option_id, methods[POST]) validate_csrf def vote(option_id): # ...5.3 刷新缓存与结果一致性的处理投票完成之后进入结果页用户经常点浏览器左上角的“后退”按钮回投票页然后再次提交。虽然后端有重复投票拦截但这里还有一层浏览器缓存问题页面被浏览器缓存了用户看到的结果数据可能是几分钟前的。解决方式最直接的是在后端设置Cache-Control: no-store响应头让浏览器不要缓存动态页面app.after_request def add_no_store_header(resp): resp.headers[Cache-Control] no-store return resp对于投票系统这样的强动态业务no-store是保险的做法。静态资源CSS、JS不受影响因为它们走的是静态文件路由响应头不会经过这个钩子。6. 从本机到服务器Flask应用的部署链路开发环境的 Flask 自带服务器只适合调试性能和安全性都撑不住生产环境。部署这一步是很多新手卡壳的地方我把自己踩出来的完整链路写出来。6.1 本机运行flask run 还是 python app.py开发阶段我推荐用flask --app app run --debug它读环境变量能自动发现应用工厂逻辑清晰。但如果你写的app.py是直接扁平的脚本不是工厂函数也可以直接python app.py只要文件末尾有if __name__ __main__: app.run(debugTrue)这两种方式都不能用于生产。Flask 官方文档反复强调这一点。内置服务器性能差、没有并发能力、还有安全风险。生产环境下必须换 WSGI 服务器。6.2 生产环境Gunicorn / Waitress NginxLinux 服务器上我推荐 Gunicorn。安装后一行命令启动pip install gunicorn gunicorn -w 4 -b 127.0.0.1:8000 app:app-w 4表示启动 4 个 worker 进程能利用多核 CPUapp:app的意思是“从 app 模块导入 app 实例”。Windows 服务器上 Gunicorn 跑不了用 Waitress 是一个等价替代pip install waitress waitress-serve --listen127.0.0.1:8000 app:app但只监听本机端口还不够外部访问需要 Nginx 做反向代理。Nginx 负责接收 80 端口的请求转发给本机 8000 端口。配置文件核心片段如下server { listen 80; server_name your-domain.com; location /static { alias /var/www/poll_system/static/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }一个容易遗漏的问题如果 Flask 里用了session那么 Nginx 的proxy_set_header Host $host极其重要。没有这个头部Flask 生成的链接可能是127.0.0.1开头的错误地址。6.3 部署时最容易踩到的坑我总结了自己和身边朋友都遇到过的三个高频坑第一secret_key没换。Flask 的 session 用 SECRET_KEY 签名默认的dev-secret-key-please-change一旦暴露攻击者可以伪造任意 session 的内容。部署前务必生成一个随机值比如通过python -c import secrets; print(secrets.token_hex(32))获取。第二SQLite 数据库文件权限。Nginx 和 Gunicorn 通常以不同用户身份运行如果 SQLite 文件所在的目录没有写权限会报attempt to write a readonly database。检查文件属主确保运行 Gunicorn 的用户对数据库文件有写权限。第三全链路 502 Bad Gateway。这个问题从外到内排查先看 Nginx 有没有起来再确认 Gunicorn 进程有没有活着最后看 Nginx 日志里connect() failed的具体地址。90% 的情况是 Gunicorn 没启动或者端口对不上。我还给 Flask 应用配一个 systemd 服务这样服务器重启后投票系统能自动拉起[Unit] DescriptionFlask Poll System Afternetwork.target [Service] Userwww-data WorkingDirectory/var/www/poll_system ExecStart/var/www/poll_system/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 app:app Restartalways [Install] WantedBymulti-user.target写好配置文件后放在/etc/systemd/system/poll.service执行systemctl enable poll开机自启再systemctl start poll启动服务。整个部署链路的搭建过程其实不到半小时真正花时间的是排查各种环境差异导致的小毛病。7. 性能、安全与功能扩展的进阶思考开发完第一版之后我把它放在办公室内网跑了两周统计下来大概有四百多次投票操作。这个量级对 SQLite 来说毫无压力响应时间都在几十毫秒以内。但如果要继续扩大使用范围有几个方向值得提前考虑。缓存层是第一个可以考虑的优化点。目前的结果页每次访问都查数据库汇总选项票数。Flask 里可以直接用functools.lru_cache对热点数据做进程内缓存投票提交后主动废弃缓存。缺点是 Gunicorn 多 worker 模式下每个进程的缓存是独立的失效逻辑会变得复杂。真正稳妥的缓存方案是引入 Redis把投票结果存一份到 Redis查询走缓存写入时同步更新。这个改造等系统访问量到了每日上万次再考虑也不迟。数据可视化是第二个点。现在结果页是纯列表和百分比数字看多了确实单调。可以给poll_result.html加一个简单的条形图显示用纯 CSS 实现即可。每个选项按百分比渲染一段不同颜色的横条不需要引入 ECharts 这类重型图表库加载快、实现也简单。安全层面除了前面说的 CSRF 和防重复投票日志审计也很重要。VoteLog表日积月累会越来越大定期清理过期数据或者做归档能避免数据库无谓膨胀。同时给voter_key加上了indexTrue查询性能会好很多这一步在建表时就该想到。最后一件事是关于登录体系。如果投票场景真的要求严格一人一票那 Session 匿名方案就不够用了。我的建议是把用户模型加进来投票前必须登录VoteLog里的voter_key改成user_id业务上彻底堵死匿名刷票这条路。登录实现可以用 Flask-Login配合密码哈希存储。这个改造并不复杂但能立刻让系统从“个人练习项目”升级为“可正规使用的小型应用”。我在实际使用中还发现一个小细节投票表单提交后重定向到结果页比直接在当前页用 JavaScript 局部刷新接口更可靠。因为刷新页面的场景天然绕过了用户误操作导致的重复提交问题。系统的核心功能到这里已经全部落地没有复杂的架构没有炫技的框架但每一处设计都针对真实使用场景做了取舍。这种“小而完整”的项目写起来最有成就感它能真正跑起来、被真实用户使用、还能不断增强功能远远好过只存在于练习册里的代码片段。
返回列表