☰
Flask+Vue+MySQL实战:电影购票网站全栈开发与选座并发控制
2026/10/8 4:09:49 网站建设 项目流程

做“观幕”这个基于 Flask 的电影购票网站,最初是因为本地一家小影院还在用微信群接龙选座,票务状态经常对不上账。用 Flask + Vue + MySQL 这套前后端分离方案做了近两个月,最后落地了一个能看片、能选座、能下单、能查订单的完整购票平台。这篇文章把整个实现过程、数据库设计思路、选座并发处理、前后端联调和部署踩坑都整理一遍。它特别适合三类人:想练前后端分离全栈的开发者、正在找毕设或培训项目的同学,以及想给小型影院做信息化系统的团队。

1. 项目概览与核心需求拆解

1.1 购票网站背后要解决的业务问题

网上很多“电影购票系统”demo 看起来简单,真正动手做就会发现,这不是一个纯增删改查项目。从用户端看,需要支持注册登录、电影列表和详情、挑选场次、在线选座、生成订单、模拟支付、查看我的订单。从运营端看,还需要维护电影信息、放映厅座位布局、场次排期和订单统计。这两类需求其实对应“内容管理 + 库存管理 + 交易流程”三件事,其中库存管理是绝大多数人会做砸的地方。

选座购票和普通商品电商有本质区别。一件普通商品只有一个库存数字,而一个场次的每个座位都是一件独立库存。同一场次,两个用户不能锁定同一个座位。这就要求数据库设计、后端接口和前端交互都围绕“座位状态”展开。

所以我在动手写代码前先把业务规则固化成几条硬约束:

  1. 一个用户未支付的订单最多锁定座位15分钟;
  2. 同一场次同一座位在同一时刻只能出现在一个有效订单中;
  3. 座位一旦被锁定,就必须生成一张待支付订单;
  4. 已售座位不可被再次下单。

这几条约束是整个项目的骨架,后面所有技术方案都是为了让它们能稳定落地。

1.2 为什么是 Flask + Vue + MySQL,而不是别的

后端选 Flask,理由是稳。Flask 够轻,小项目可以从单文件起步,业务复杂了再按蓝图拆模块,不会把结构搞僵。生态也成熟,flask-sqlalchemy、flask-migrate、flask-jwt-extended、flask-cors 这些扩展覆盖了业务后端九成需求。还有一个隐藏优势是 Python 生态的连通能力,以后想给“观幕”加个类似“猜你喜欢”的推荐系统,或者抓豆瓣评分,都能在同一个语言生态里直接解决。

有人会问,FastAPI 不是更现代、性能更好、还自带 Swagger 文档吗?确实是,但 FastAPI 的异步模型对刚接触 Web 开发的人来说反而容易踩坑。Flask 同步模型简单直接,社区资料多,遇到问题搜一下就有答案。在这个项目里,性能瓶颈不在框架本身,而在数据库事务和座位锁定的设计上,所以选 Flask 是对的。

前端选 Vue,核心原因是复杂交互。选座页要求用户能实时看到哪座能选、哪座已售,点击座位要立刻反馈选中状态,还要计算总价、提交订单,这种“数据驱动界面”的场景天然适合 Vue 的响应式机制。组件化设计也很适合把座位网格拆成独立组件。相比 React,Vue 的模板语法更亲和一些,中小团队上手成本低。

数据库用 MySQL,看重的是事务能力和统计能力。订单状态、座位状态必须靠事务保证强一致,用 Redis 或 MongoDB 来做这一层会很吃力。另外,票务系统的运营报表、排片统计用 SQL 写起来非常顺手,这也是选 MySQL 的重要原因。

2. 数据库层:票务系统的地基

2.1 七张核心表撑起完整业务

数据库是整个项目的地基。我画 ER 图改了四版,最终稳定下来的核心表有七张:用户表 users、电影表 films、放映厅表 halls、场次表 screenings、场次座位表 screening_seats、订单表 orders,以及用来记录订单和座位多对多关系的 order_seats 关联表。

直接看建表语句更清晰:

CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, phone VARCHAR(20), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE films ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100) NOT NULL, poster_url VARCHAR(255), genre VARCHAR(50), duration INT COMMENT '时长,单位分钟', release_date DATE, synopsis TEXT ); CREATE TABLE halls ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, seat_rows INT NOT NULL, seat_cols INT NOT NULL ); CREATE TABLE screenings ( id INT AUTO_INCREMENT PRIMARY KEY, film_id INT NOT NULL, hall_id INT NOT NULL, start_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL, INDEX idx_film_time (film_id, start_time), FOREIGN KEY (film_id) REFERENCES films(id), FOREIGN KEY (hall_id) REFERENCES halls(id) ); CREATE TABLE screening_seats ( id INT AUTO_INCREMENT PRIMARY KEY, screening_id INT NOT NULL, hall_id INT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待售 1锁定 2已售', UNIQUE KEY uk_screening_seat (screening_id, row_no, col_no), FOREIGN KEY (screening_id) REFERENCES screenings(id), FOREIGN KEY (hall_id) REFERENCES halls(id) ); CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, screening_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消 3超时释放', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, paid_at DATETIME NULL, INDEX idx_user_status (user_id, status), FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (screening_id) REFERENCES screenings(id) ); CREATE TABLE order_seats ( id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, screening_seat_id INT NOT NULL, UNIQUE KEY uk_order_seat (order_id, screening_seat_id), FOREIGN KEY (order_id) REFERENCES orders(id), FOREIGN KEY (screening_seat_id) REFERENCES screening_seats(id) );

这里有个关键设计:座位状态放在 screening_seats,而不是 halls 表里。原因很简单,同一个放映厅在不同场次的座位可用状态不同,座位库存必须“按场次管理”。如果只存一个固定座位布局,再单独维护已售数组,后面并发控制和查询都会非常别扭。

2.2 座位状态与超卖防护的并发方案

这个部分是整个项目最容易做坏的地方。很多人会把下单写成了“先查座位是否空闲,再插入订单”,在高并发下必然出问题。两个请求同时查出座位空闲,同时下单,座位就超卖了。

正确思路是用一条条件 UPDATE 抢占座位:

UPDATE screening_seats SET status = 1 WHERE screening_id = ? AND row_no = ? AND col_no = ? AND status = 0;

执行后如果影响行数是 1,说明当前请求成功把座位从未售改成锁定;如果影响行数是 0,说明有别的请求已经抢先改了状态。这一步利用的是数据库行锁和原子更新。只要所有下单流程都走这一条语句,就不会出现两个订单拿到同一个座位。

事务方面,我推荐的做法是:先创建待支付订单,再逐个抢占座位;每次抢占都检查受影响行数,一旦某个座位抢占失败,就回滚整个事务,并把之前锁成功的座位全部释放。用伪代码表达整个流程:

from sqlalchemy import text from extensions import db def create_order(user_id, screening_id, seat_keys): """seat_keys 形如 [{'row': 3, 'col': 8}, ...]""" with db.session.begin(): screening = db.session.execute( text("SELECT id, price, hall_id FROM screenings WHERE id=:id"), {"id": screening_id} ).fetchone() if not screening: raise BusinessError("场次不存在") total_price = screening.price * len(seat_keys) order_no = generate_order_no() db.session.execute( text("INSERT INTO orders(order_no, user_id, screening_id, total_price, status) " "VALUES(:order_no, :user_id, :screening_id, :total, :status)"), {"order_no": order_no, "user_id": user_id, "screening_id": screening_id, "total": total_price, "status": 0} ) order_id = db.session.execute(text("SELECT LAST_INSERT_ID()")).scalar() for key in seat_keys: row, col = key["row"], key["col"] result = db.session.execute( text("UPDATE screening_seats SET status=1 " "WHERE screening_id=:sid AND row_no=:r AND col_no=:c AND status=0"), {"sid": screening_id, "r": row, "c": col} ) if result.rowcount == 0: raise BusinessError(f"座位 {row}排{col}号已被锁定或售出,请重新选座") seat_id = db.session.execute( text("SELECT id FROM screening_seats " "WHERE screening_id=:sid AND row_no=:r AND col_no=:c"), {"sid": screening_id, "r": row, "c": col} ).scalar() db.session.execute( text("INSERT INTO order_seats(order_id, screening_seat_id) " "VALUES(:oid, :sid)"), {"oid": order_id, "sid": seat_id} ) return order_id

关于锁定的超时释放,我再多讲一句。如果用户锁了座但一直不支付,座位会一直锁着。我的处理是订单创建 15 分钟未支付就标记为超时释放,同时把座位状态改回 0。小项目不用引入 Celery,用 APScheduler 定时任务轮询“待支付超过15分钟”的订单即可。

3. Flask 后端:把购票流程变成稳定接口

3.1 工程结构和蓝图规划

Flask 项目虽然能用单文件 app.py 写完所有逻辑,但“观幕”拆成蓝图后结构会清爽很多。我的目录是这样的:

guanmu-server/ ├── app.py # 应用入口 ├── config.py # 配置:数据库、密钥 ├── extensions.py # db, cors 等扩展实例 ├── models.py # SQLAlchemy 模型 ├── common/ │ ├── response.py # 统一返回封装 │ └── errors.py # 业务异常定义 ├── api/ │ ├── auth.py # 注册、登录 │ ├── films.py # 电影列表、详情 │ ├── screenings.py # 场次、座位 │ ├── orders.py # 下单、订单查询、支付 │ └── admin.py # 后台管理接口 └── utils/ ├── jwt_auth.py # token 校验装饰器 └── order_no.py # 订单号生成

核心思路是:基础依赖集中在 extensions.py 初始化,蓝图在 app.py 注册,models.py 只放数据模型和映射关系,业务逻辑写在各自蓝图里。层次清晰之后,排查问题的效率会明显提升。

3.2 核心接口逐条拆解

电影列表接口要支持“正在热映”和“即将上映”两种状态,我的实现大致是这样:

from sqlalchemy import select @films_bp.get("/api/films") def list_films(): status = request.args.get("status", "now") stmt = select(Film) if status == "now": stmt = stmt.where(Film.release_date <= datetime.today()) elif status == "coming": stmt = stmt.where(Film.release_date > datetime.today()) films = db.session.scalars(stmt).all() return success({"list": [film.to_dict() for film in films]})

场次和座位接口是前端选座的地基。我建议场次接口返回电影、厅、时间、价格、剩余座位数,方便列表页展示;座位接口直接返回整个座位矩阵:

@screenings_bp.get("/api/screenings/<int:screening_id>/seats") def get_seats(screening_id): seats = db.session.scalars( select(ScreeningSeat).where(ScreeningSeat.screening_id == screening_id) ).all() hall = db.session.get(Hall, seats[0].hall_id) matrix = [] for r in range(1, hall.seat_rows + 1): row_data = [] for c in range(1, hall.seat_cols + 1): seat = next((s for s in seats if s.row_no == r and s.col_no == c), None) row_data.append({ "row": r, "col": c, "status": seat.status if seat else 0 }) matrix.append(row_data) return success({"rows": hall.seat_rows, "cols": hall.seat_cols, "matrix": matrix})

下单接口的完整事务逻辑在第 2.2 节已经说明。需要补充的是支付模拟接口:前端点击“确认支付”,后端把订单状态从 0 改为 1,同时把座位从锁定状态 1 改为 2 已售。这一样要放在同个事务里,避免出现订单已支付但座位还是锁定的脏状态。

订单查询接口要支持用户查看“待支付”“已支付”“已取消”三类订单。容易踩的坑是忘了查询关联座位,导致订单里看不到具体座位号。我建议在 Order 模型里加一个 seats 属性,通过 order_seats 关联查询后拼接成“3排8号、3排9号”返回给前端。

3.3 统一响应、跨域和错误捕获

前后端分离项目,统一接口响应格式能节省大量沟通成本。我用的格式是:

{ "code": 0, "message": "success", "data": {} }

code 为 0 表示成功,非 0 表示业务错误,1001 是参数错误,2001 是座位冲突。前端 axios 拦截器只看 code 就能判断结果,不需要再去解析 HTTP 状态码。业务异常用全局错误处理器统一捕获:

@app.errorhandler(BusinessError) def handle_business_error(e): return jsonify({"code": e.code, "message": str(e), "data": None}), 200

跨域问题在开发环境用 Flask-CORS 直接放开,生产环境如果前端由 Nginx 托管,就只放开指定域名:

CORS(app, resources={r"/api/*": {"origins": ["http://localhost:5173", "https://guanmu.example.com"]}})

这里有个容易被忽略的点:跨域请求如果携带 Authorization 头,必须在 CORS 配置里允许 headers,否则浏览器会直接拦截。配置方法是加上allow_headers=["Content-Type", "Authorization"]。我在项目初期就因为这个排查了很久,前端一直报跨域,后端却看不到任何异常日志。

4. Vue 前端:选座购票体验的开发

4.1 从零搭建 Vue 项目与依赖安装

前端我用 Vue 3 + Vite 搭建。相比 Vue CLI,Vite 启动快、依赖少,配合 Vue Router 和 Pinia 做状态管理很顺手。组件库选 Element Plus,表单、弹窗、消息提示可以直接复用,节省开发时间。

创建项目和安装关键依赖:

npm create vue@latest guanmu-front cd guanmu-front npm install npm install axios element-plus pinia

开发环境跨域是绕不开的问题。后端跑在 5000 端口,前端跑在默认 5173 端口,不同端口必然触发跨域。我推荐用 Vite 的代理,把/api开头的请求转发到后端:

// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:5000', changeOrigin: true } } } })

配好代理后,前端 axios baseURL 直接写成/api,请求就完全不受跨域干扰。这个方法比到处开 CORS 优雅得多,生产环境换成 Nginx 反代时,前端代码几乎不用改。

4.2 页面结构与路由规划

“观幕”前端路由围绕购票流程设计:

  • /电影列表页
  • /film/:id电影详情页,展示海报、简介、未来几天的场次列表
  • /booking/:screeningId选座页,核心交互页
  • /order/confirm订单确认页
  • /orders我的订单页
  • /login登录页

路由配置里需要给需要登录的页面加导航守卫,未登录直接跳转到登录页,并带上回跳地址。这个细节很多个人项目会漏,但对真实用户来说是基本体验。

4.3 核心难点:座位组件怎么设计状态

选座组件是整个前端最值得打磨的部分。我把它拆成独立的 SeatMap 组件,输入一份座位矩阵数据,输出一个已选座位列表。

先看模板结构。后端返回的座位矩阵是二维数组,每个座位包含 row、col、status 三个字段。我用表格渲染:

<template> <div class="seat-map"> <div v-for="(row, rowIdx) in matrix" :key="rowIdx" class="seat-row"> <span class="row-label">{{ rowIdx + 1 }}排</span> <div v-for="seat in row" :key="seat.col" class="seat" :class="seatClass(seat)" @click="toggleSeat(seat)" > </div> </div> </div> </template>

script 部分的核心是 seatClass 和 toggleSeat:

const selectedSet = ref(new Set()) function seatClass(seat) { if (seat.status === 2) return 'sold' if (isSelected(seat)) return 'selected' return 'available' } function toggleSeat(seat) { if (seat.status === 2 || seat.status === 1) return const key = `${seat.row}-${seat.col}` if (selectedSet.value.has(key)) { selectedSet.value.delete(key) } else { if (selectedSet.value.size >= maxSelect) { ElMessage.warning('每单最多选择6个座位') return } selectedSet.value.add(key) } }

交互规则很明确:status 为 2 的座位已售不可点,status 为 1 的座位被锁定不可点,可选座位点击后加入或移出已选集合。样式上建议区分四种颜色:可选坐位浅灰,已选绿色,锁定深灰,已售红色。用户一进选座页就知道哪些位置能选。

还要注意数据新鲜度。座位状态是动态的,用户停留时间一长,之前查到的座位数据可能已经变化。我在下单前会再调用一次座位接口做二次校验,发现不一致就提示用户刷新选座。这虽然多一次请求,但能避免用户选完座位后才发现座位已失效。

4.4 axios 封装与用户状态管理

axios 封装是做前端项目必须养成的习惯。我统一做三件事:设置 baseURL、请求拦截器注入 token、响应拦截器统一处理业务码。

import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error(error.message) return Promise.reject(error) } ) export default request

Pinia 里我只存两类数据:用户信息和当前草稿订单。用户信息在登录成功后写入 localStorage 持久化,刷新不丢。草稿订单包括场次信息、已选座位、总价,选座页写入,订单确认页读取,支付成功后清空。

5. 全流程联调与部署实战

5.1 快速填充测试数据的小技巧

没有测试数据,前后端联调等于空转。我在建表后写了一个 init_data.py 脚本,自动往库里塞 3 部电影、2 个放映厅、10 个场次和对应座位。每次重置数据库后一条命令就能恢复可演示状态。关键点在于座位数据的初始化:一个厅 8 排 12 列,共 96 个座位,建厅之后要把所有座位的初始状态写入 screening_seats,否则前端选座页就只能看到一片空白。

另一种更快的方法是直接导入 SQL 文件。我建议两种方式都准备,脚本方便开发环境反复刷新,SQL 文件适合部署时快速初始化。

5.2 前后端联调的关键动作

联调阶段的效率工具我推荐两个:Postman 用于后端接口验证,浏览器开发者工具的 Network 面板用于前端排查。遇到问题先用 Postman 调接口,确认是后端问题还是前端问题;再用浏览器看请求和响应,定位是参数错误还是渲染错误。这个排查顺序能省掉大量“互相甩锅”的时间。

5.3 上线部署的两种简配方案

部署取决于有没有 Nginx,我提供两种方案。

方案 A:Vue 构建后交给 Flask 托管。执行npm run build生成静态文件,把 dist 目录放到 Flask 的 static 目录下,再注册一个路由把非 /api 请求指向 index.html:

@app.route("/") def index(): return send_from_directory("static/dist", "index.html") @app.errorhandler(404) def spa_fallback(e): if request.path.startswith("/api"): return e return send_from_directory("static/dist", "index.html")

这种方式最省事,但静态资源性能略差。

方案 B:标准 Nginx 反代。Nginx 托管 Vue 静态文件,/api反向代理到后端 5000 端口。生产环境我推荐方案 B,既能做资源缓存,又方便后端独立重启。

server { listen 80; server_name guanmu.example.com; root /opt/guanmu-front/dist; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

后端启动用 gunicorn,指定多 worker:

gunicorn -w 4 -b 127.0.0.1:5000 app:app

这里提醒一句,多 worker 跑起来后数据库连接池也要跟着调。我见到不少项目 gunicorn 开了 4 个 worker,MySQL 连接池还是默认值,并发一高就开始疯狂报连接耗尽。调大pool_size和pool_recycle就能解决。

6. 常见问题与排查速查表

这个项目从零到上线,我在六个方向踩了不少坑,整理成速查表供大家直接参考。

6.1 Python/Flask 环境类问题

现象原因解决办法
pip 安装包极慢或超时默认源在国外配置清华源:pip install -i https://pypi.tuna.tsinghua.edu.cn/simple 包名
运行报No module named 'flask_sqlalchemy'包名带下划线,环境错乱确认虚拟环境已激活,重新安装 Flask-SQLAlchemy
SQLAlchemy 2.0 里db.query报错2.0 移除了 Query API改用db.session.scalars(select(Model)).all()
JWT 解密报错secret key 不一致或过期统一在 config.py 管理 SECRET_KEY,不要硬编码散落各处

6.2 MySQL 连接类问题

现象原因解决办法
Authentication plugin 'caching_sha2_password' cannot be loadedMySQL 8.0 默认认证插件不被旧客户端支持将用户改为 mysql_native_password,或升级 PyMySQL 并加charset='utf8mb4'
Access denied for user密码或主机限制检查用户授权:GRANT ALL ON guanmu.* TO 'user'@'%'
中文乱码数据库、表、连接三个 charset 不统一建库用 utf8mb4,连接串加?charset=utf8mb4
MySQL server has gone away连接过期或事务过长给 engine 加pool_recycle=3600

6.3 Vue 构建和联调问题

现象原因解决办法
Node Sass 编译失败node-sass 与 Node 版本不匹配改用 dart-sass,或锁定 node-sass 版本
请求 404 或 500,路径显示http://localhost:5173/api/...Vite 代理未生效检查 vite.config.js proxy 配置并重启 dev server
页面刷新后路由 404SPA 路由在服务端没有 fallbackNginx 加try_files $uri $uri/ /index.html;
Element Plus 样式没生效未引入样式在 main.js 里import 'element-plus/dist/index.css'

6.4 数据与并发类问题

现象原因解决办法
两个请求同时买同一座位都提示成功下单逻辑没用条件 UPDATE改成UPDATE ... WHERE status=0并检查 rowcount
订单取消后座位仍然锁定状态回滚逻辑遗漏取消或超时订单时,遍历关联座位并 UPDATE 回 0
订单已支付,座位却显示锁定支付接口和座位更新不在同一事务把订单状态更新和座位状态更新放在同一个事务提交
列表页剩余座位数不准只统计 status=2 的座位剩余数应为总数减去 status=1 和 status=2 的数量

7. 实操体会与后续扩展方向

7.1 整个项目给我留下的几条经验

项目跑通之后最大的感受是:这类 Flask 购票网站的技术难点不在 Flask 本身,而在座位状态的一致性。只要把数据库座位模型和下单事务想清楚,前端选座交互、接口设计都是围绕这个核心转的,后面加功能也不会伤筋动骨。

给想复现的朋友三个具体建议。第一,先把表结构和座位状态机画在纸上再写代码,不要边写边改,否则每加一个功能都要重构数据层。第二,下单接口一定要做完整的回滚测试,专门模拟“第一个座位成功、第二个座位失败”的情况,确认订单和座位不会出现半个成功。第三,前端座位组件尽早抽象成独立模块,不要和其他业务耦合,以后如果要加会员折扣、套票规则,改动范围可以很小。

7.2 后续可以继续扩展的方向

“观幕”如果要继续迭代,有三个比较有价值的方向:一是后台排片管理,用 Flask-Admin 或者自己写一个简单的管理端;二是给用户加“猜你喜欢”,用 Flask 直接调用一个简单的协同过滤模型;三是对接第三方支付沙箱,把模拟支付换成真实支付流程。这些方向都建立在现有架构之上,不需要推倒重来。

按照这个思路搭建的版本不止能用于电影,改改字段就能适配演出票、体育赛事票这类场景。最后提醒一句:面粉筛得越细,面包做得越松,数据库状态机想得越透,后面的开发就越顺。希望这篇实操记录能帮你把 Flask、Vue、MySQL 这条链路一次跑通。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询