提到电影院在线选座系统很多人第一反应是“这不就是课设或者毕业设计的经典题目吗”。确实这类系统的核心链路看着不复杂但真从头到尾完整落地一遍就会发现难度根本不在于增删改查而在于几个信息量很大的隐性模块——同一场次座位的并发抢占、未支付订单的超时释放、支付回调的幂等处理、前后端接口的规范化。我之前基于 Python 完整实现了一套电影院在线选座系统技术栈上同时用了 Django 和 FlaskDjango 负责管理后台和业务模型Flask 负责给前端提供轻量接口整个项目从原型到部署用了三周左右。这篇文章按我的真实开发顺序把数据库设计、抢座并发、超时释放、接口层、部署压测这几块的核心逻辑和踩坑过程详细梳理一遍适合正在做类似课设、毕业设计或者想快速理清在线选座业务底层逻辑的开发者参考。1. 为什么把 Django 和 Flask 放在一起用真实架构不是二选一1.1 选座业务表面简单核心约束藏在状态变化里做这个项目之前我先花了两天把需求彻底拆了一遍。电影院在线选座表面上是三件事排片管理、座位选择、订单支付。但这三件事背后全是状态约束。举个例子同一个影厅的同一个座位在下午两点场和晚上八点场是两个完全独立的资源。用户在查看座位图时看到的是“某个场次下的座位状态”而不是“全局的座位状态”。这意味着座位状态必须挂在“场次座位”的组合实体上而不是直接挂在座位表上。这个建模上的差别直接影响后面所有业务逻辑的走向。另一个容易被忽视的约束是时间。一个用户锁定了座位但迟迟不支付这个座位就一直被占着对其他用户不公平。所以系统必须设计一套“锁定超时自动释放”的机制。用户支付成功之后座位状态才能从锁定变成已售出。也就是说整个系统的核心不是简单的 CRUD而是一套状态机在驱动。我在动手写代码前把所有业务状态画成了一张流转图座位状态有可售、锁定、已售三种订单状态有待支付、已支付、已取消、已退款四种。后面所有模块的设计都是在围绕这些状态做合法流转。1.2 Django 管管理后台与业务模型Flask 管接口层技术选型上我一开始也纠结过是只用一个 Django 把所有事包了还是只用 Flask 轻装上阵后来我选择了同时用两个框架原因很实际。Django 这边的优势非常明显自带 ORM、Admin 后台、用户认证和数据迁移。电影院后台需要管理电影、影厅、场次、订单这些在 Django Admin 里几乎零成本就能搭出来甚至不需要单独写管理页面。Django 自带的数据库迁移工具也让表结构迭代变得很省心开发期我几乎每天都在改字段一条命令就能同步到库。Flask 这边的优势则是轻量、自由。前端需要大量定制化的业务接口比如获取场次座位图、提交选座、查询支付结果。Flask 写接口更灵活不绑框架规范路由和视图的组织方式也更直观。对我来说Flask 就像一把小刀做接口层非常顺手。于是架构就变成这样Django 起一个服务跑 8001 端口承担管理后台和核心业务模型Flask 起另一个服务跑 5000 端口向前端提供 JSON 接口。两个服务连接同一个 MySQL 数据库共享同一套 Redis 缓存。1.3 同一个数据库下的跨框架协作要避免“模型双写”两个框架共用数据库最初最让我头疼的问题是模型代码怎么维护。一开始我的想法是 Django 里定义一套模型Flask 里也定义一套模型各用各的反正表是同一张。这个想法很快就被打脸了。问题在于两边各维护一套模型字段稍不一致就会出问题。比如我在 Django 里给订单表加了一个索引或者改了字段名Flask 这边可能还在用旧字段查询编译期不报错运行期才炸。最后我定的规矩是所有表结构变更都以 Django 模型为准只通过 Django 的迁移工具去改库。Flask 端不定义正式模型只用一个轻量的查询辅助层或者干脆直接写 SQL 来读数据。复杂的业务逻辑全部下沉到 Django 这一侧处理Flask 只做接口转发和数据组装。这个约定的好处是把框架边界切得很干净。Flask 只关心请求进来、调服务、返回 JSONDjango 只关心业务规则和持久化。后面所有并发、超时、回调的逻辑都集中在一套代码里维护没有出现两边行为不一致的情况。2. 数据库与状态机设计座位、场次、订单如何连成闭环2.1 核心表结构座位状态不能直接放在 seat 表里数据库设计是整个系统的基础这一步错了后面全要返工。我最终的核心表结构如下表名关键字段说明movieid, title, duration, poster电影基础信息hallid, name, seat_rows, seat_cols影厅信息用行列数描述座位布局screeningid, movie_id, hall_id, start_time, end_time场次关联电影和影厅seatid, hall_id, row_num, col_num座位的物理坐标screening_seatid, screening_id, seat_id, state, version场次与座位的绑定实体状态字段挂在这里ordersid, user_id, screening_id, total_price, status, expire_time订单主体order_seatid, order_id, screening_seat_id订单与场次座位的关联表这里最关键的设计是screening_seat表。座位本身只有物理属性它在哪个影厅、第几排第几列这些信息放在seat表就够了。但一个座位在某场次是空的、被锁了还是卖掉了这是动态状态必须放在“场次座位”这个关联实体里。订单表里的expire_time字段也很重要。它的作用是在创建待支付订单时记录一个具体的超时时间点。后面做定时任务时只需要一条 SQL 就能查出所有超时未支付的订单比“只记录创建时间、每次当场计算是否超时”的方式效率高得多而且不容易出边界错误。2.2 座位状态机与订单状态机的联动座位状态机的核心逻辑是这样一条链路用户打开选座页面看到的是screening_seat.state此时状态为available可售。用户点击座位系统校验状态为available后立即把状态改成locked锁定同时生成一条待支付订单。用户支付成功回调到达后系统把座位状态从locked改成sold已售。如果订单超时未支付或者用户主动取消座位状态从locked恢复成available。订单状态机的流转则更复杂一点pending_payment待支付座位锁定初始状态。paid已支付用户付款成功后的状态。cancelled已取消用户取消或超时释放后的状态。refunded已退款已支付订单经过人工处理后变为退款状态。两个状态机不是独立的而是互相咬合的。座位状态能否从locked回到available取决于订单状态是否为待支付。座位状态能否从locked变成sold取决于订单是否真的支付成功。所以在代码实现里我专门写了一个状态流转校验层任何状态变化都必须走这个层不允许直接在业务代码里随手改状态字段。这样可以避免很多“状态被错误覆盖”的隐蔽问题。2.3 设计时的两个小提醒第一个小提醒是订单表不要直接存座位 ID 列表。正规做法是单独建一张order_seat关联表因为一个订单可能包含多个座位而且后续需要按座位维度去查询订单归属。用逗号拼接 ID 的做法在原型阶段看着省事一旦要做并发校验和关联查询就会非常痛苦。第二个小提醒是索引要提前想好。我建索引时重点考虑了三类高频查询按场次查所有座位状态、按订单号查订单详情、按时间查超时订单。这三个查询路径上涉及的字段都加了联合索引后期压测时发现查询性能基本没有成为瓶颈。3. 抢座并发控制同一个座位被反复售出的排查与修复3.1 先复现一次典型的“超卖”过程选座系统的并发压力主要集中在“抢座”这个动作上。我把问题简化一下两个用户同时看中了一个场次的三排五座并且几乎同时点击了选座按钮。如果没有锁机制会出什么情况看这段典型的错误代码# 错误示范先查后改并发下必然出问题 seat ScreeningSeat.objects.get(pkseat_id) if seat.state available: order Order.objects.create(user_iduser_id, screening_idscreening_id) OrderSeat.objects.create(orderorder, screening_seat_idseat_id) seat.state locked seat.save()这段代码在单用户测试时没有任何问题。但在并发场景下数据库的隔离级别通常是可重复读或者读已提交两个请求可能同时读到available状态然后各自创建订单、各自把状态改成locked。最后的结果就是同一场次同一座位被两个订单分别占用这就是业务上的“超卖”。我自己第一次压测时就复现了这个问题。当时开了 50 个并发线程同时抢一个座位数据库里出现了两条order_seat记录指向同一个screening_seat。看到这个结果我反而踏实了——问题暴露得越早越能逼着你去解决。3.2 三种锁方案的取舍悲观锁、乐观锁、Redis 分布式锁针对超卖问题业界常用的方案有三种我逐一做了对比方案实现方式优点缺点适用场景悲观锁数据库行锁查询时select_for_update强一致实现简单事务持有期间会阻塞其他请求数据库连接压力大单机部署、并发量可控乐观锁更新时校验version字段并发性能好无阻塞冲突时需要重试用户体验略差读多写少、冲突率低Redis 分布式锁SETNX 过期时间性能高可跨多实例共享需要维护 Redis存在锁续期问题多实例部署、需要精细控制单纯从实现量来看乐观锁最简单。在screening_seat表里加一个version字段更新时带上版本号条件如果影响行数为 0 就说明被别人抢先了。但问题是选座是一个“高频冲突”场景尤其是在热门场次让用户选完座位后频繁遭遇“请重试”体验非常差。我也考虑过只用悲观锁。Django 的select_for_update写起来很方便而且事务提交前锁定行保证绝对不超卖。但它的缺点是数据库连接会被长时间占用如果并发量上来了连接池很容易被打满。这个方案在开发环境看不出问题上生产环境后风险很高。最终我选择的是Redis 分布式锁做第一层拦截数据库悲观锁做第二层兜底的组合方案。3.3 最终采用的组合方案与关键代码抢座的完整流程是这样前端提交选座请求接口收到screening_id和seat_ids。先尝试获取 Redis 锁锁的 key 是lock:seat:{screening_id}:{seat_id}。如果获取锁失败说明这个座位正在被别人操作直接提示用户“座位正被选中请刷新后重试”。获取锁成功后进入数据库事务用select_for_update再次校验座位状态。校验通过后更新座位状态、创建订单提交事务并释放 Redis 锁。关键代码如下import redis from django.db import transaction r redis.Redis(connection_poolpool) def try_lock_seat(screening_id, seat_id): lock_key flock:seat:{screening_id}:{seat_id} # 拿到锁才返回 True拿不到说明有人在操作 return r.set(lock_key, 1, nxTrue, ex30) def release_lock(screening_id, seat_id): lock_key flock:seat:{screening_id}:{seat_id} r.delete(lock_key) transaction.atomic def do_lock_seat(screening_id, seat_ids, user_id): # 这里用的是数据库悲观锁 rows ScreeningSeat.objects.select_for_update().filter( screening_idscreening_id, seat_id__inseat_ids, stateavailable ) if len(rows) ! len(seat_ids): return False, 部分座位已被占用 # 创建订单、更新座位状态 order Order.objects.create(user_iduser_id, screening_idscreening_id, statuspending_payment) for row in rows: ScreeningSeat.objects.filter(pkrow.pk).update(statelocked) OrderSeat.objects.create(orderorder, screening_seat_idrow.pk) return True, order.id这里有一个细节值得一提Redis 分布式锁需要设置过期时间我设的是 30 秒。正常情况下抢座动作几百毫秒就完成了30 秒的过期时间主要用来防宕机——万一业务服务在持锁期间崩了锁也能自动过期释放不会把座位永久锁死。至于锁续期的问题我加了一个简单的看门狗机制如果锁的剩余时间低于 10 秒且业务还没结束就自动续期。虽然这个机制只在长事务场景下会触发但加上它之后整套锁逻辑才算闭环。4. 订单超时释放与支付回调两个最容易被忽略的边界场景4.1 为什么必须释放未支付订单座位锁定之后如果用户一直不支付座位就处于悬置状态。试想一个热门场次一百个座位里有二十个被锁定但始终没人付款其他用户能选的座位就少了二十个。这不仅是用户体验问题还是收入损失。所以必须要有超时释放机制。这个机制的业务规则并不复杂给每个待支付订单设置一个超时时间一般是 15 到 30 分钟。超过这个时间还没支付就把订单置为取消同时把对应的座位恢复成可售状态。4.2 定时扫描方案用 Django 周期任务实现我第一版用的是 Django-Celery-Beats 的周期任务每分钟扫描一次超时订单。核心逻辑不复杂但有两个容易踩的坑。第一个坑是时区问题。设置expire_time时必须用带时区的时间对象否则会出现“看起来超时了但扫描不出来”的诡异现象。第二个坑是任务幂等。扫描任务有可能在前一次还没跑完时就被下一次触发所以任务里要用select_for_update或者状态条件更新确保同一个订单不会被重复处理两次。我的做法是用一条条件更新语句把状态从pending_payment改成cancelled受影响行数只有 1 才做后续的座位释放。from django.utils import timezone from datetime import timedelta def release_expired_orders(): expire_before timezone.now() - timedelta(minutes15) expired_orders Order.objects.filter( statuspending_payment, expire_time__lteexpire_before ).select_for_update() for order in expired_orders: # 先把订单状态更新为已取消 updated Order.objects.filter(pkorder.pk, statuspending_payment).update(statuscancelled) if updated 1: # 释放关联座位 order_seats OrderSeat.objects.filter(order_idorder.pk) for order_seat in order_seats: ScreeningSeat.objects.filter(pkorder_seat.screening_seat_id).update(stateavailable)这里我特别强调一点先改订单状态再释放座位。这样做的好处是即使释放座位的步骤失败订单也已经是取消状态最坏情况是座位状态和订单状态短暂不一致可以通过补偿任务修复。反过来如果先释放座位再改订单就可能出现座位已经放出去了但订单还是待支付状态用户又重新支付了一个已经失效的订单。4.3 支付回调的幂等处理与主动查单兜底在线选座系统必然要接第三方支付平台。支付回调是系统里最需要提防的边界场景因为第三方支付平台为了保证通知送达会多次推送同一个支付结果。如果后端在第一次回调时把订单改成已支付第二次回调又来处理逻辑不幂等就可能把订单状态覆盖成错误值。我的做法是在回调处理函数最开始就做状态判断def handle_payment_callback(order_no, pay_status): try: order Order.objects.select_for_update().get(order_noorder_no) except Order.DoesNotExist: return invalid order if order.status paid: # 已经处理过直接返回成功不重复执行更新 return success if pay_status success: order.status paid order.paid_at timezone.now() order.save() # 座位状态 locked - sold OrderSeat.objects.filter(order_idorder.id).update(statussold) return success这里用select_for_update锁住订单行主要是为了防止两个并发的回调请求同时进入处理逻辑。虽然支付平台一般不会同时推送两条但把这一层防御加上能让系统在任何异常情况下都不出现重复加账或者状态错乱。除了被动接收回调我还做了一条主动查单兜底通道。在订单支付结果页由前端发起轮询后端根据订单号去支付平台查询真实支付状态。这个设计针对的是某些网络环境下回调延迟甚至丢失的情况。两条通道都指向同一个handle_payment_callback处理函数保证无论从哪条路径进来处理逻辑完全一致。5. Flask 接口层的工程化落地统一响应、鉴权、参数校验5.1 统一返回结构是接口层的第一步Flask 接口层刚开始写的时候很容易乱一个接口返回一种格式前端对接起来非常痛苦。我在项目里统一了响应结构所有接口都返回同样的 JSON 格式def ok(dataNone, messagesuccess): return jsonify({code: 0, message: message, data: data}) def fail(messageerror, code1): return jsonify({code: code, message: message, data: None})这个统一结构的价值在选座接口和支付接口的对接上体现得最明显。前端拿到响应后先判断code为 0 就正常处理data非 0 就直接弹错误提示不需要关心每个接口的异常结构。后来我在另一个项目里帮别人写接口时对方接口有的返回status、有的返回error前端光适配错误格式就花了不少时间对比之下觉得统一响应结构是接口层最值得优先做的事情。5.2 用装饰器统一处理登录态与权限Flask 的接口层需要做用户认证。我用装饰器统一处理登录态避免了每个接口里重复写 token 校验逻辑。这个功能用一个自定义装饰器实现from functools import wraps from flask import request, g def login_required(f): wraps(f) def wrapper(*args, **kwargs): token request.headers.get(Authorization, ) user verify_token(token) if not user: return fail(请先登录, code401) # 把当前用户存到 g 对象里后续视图直接取用 g.user user return f(*args, **kwargs) return wrapper使用方式很直接。选座、下单、查订单这几个接口都加上login_required视图函数内部就能直接拿到当前用户不用再翻一遍请求头。权限控制方面我把用户分成普通用户和影院管理员两类。Flask 接口层主要面向普通用户的前端交互管理员操作走 Django Admin所以接口层只做一个简单角色判断装饰器就够了。如果是大型系统角色权限会更复杂但在这个项目里普通用户和管理员的职责边界很清晰不需要引入额外的权限框架。5.3 一个选座接口的参数校验清单选座接口看似简单入参其实很容易出错。有一次我只校验了座位 ID 是否传入忘了校验该座位是否属于请求的场次结果用户在 A 场次选了一个 B 场次的座位生成了一条非法订单。后来我把选座接口的参数校验整理成了一份清单每次改动代码都对照检查screening_id是否存在是不是有效场次。seat_ids是否为空数组是否包含重复元素。每个seat_id是否属于screening_id对应的影厅。当前登录用户是否重复下单。座位状态是否已经被锁定或售出。场次放映时间是否已经结束或者即将开始。这几点看着琐碎但任何一个漏掉都会在后端或者前端造成莫名其妙的异常。我的习惯是把校验逻辑单独抽成一个函数放在服务层接口层只负责调用。这样校验规则可以复用也方便写单元测试。6. 部署上线与压测复盘难点并不只在业务代码里6.1 两个服务的启动方式与静态文件归属项目部署的时候我遇到了第一个没想到的问题Django 和 Flask 两个服务怎么共存。我当时用的部署方案是 Nginx 做反向代理同时代理两个后端端口。Nginx 监听 80 端口。/api/前缀的请求转发到 Flask 的 5000 端口。/admin/和/static/前缀的请求转发到 Django 的 8001 端口。这个路由设计让前端只需要面向同一个域名请求接口不用关心后端到底有几个服务。另一个容易踩坑的是静态文件处理。Django Admin 页面自带一套样式和 JS开发环境下直接由 Django 提供生产环境则必须执行collectstatic把静态文件收集到指定目录再由 Nginx 直接托管。Flask 那边的静态资源比较少主要是接口文档页和简单的测试页面同样由 Nginx 托管。如果不做这一层分离所有静态资源都走 Django 应用页面加载速度会明显变慢而且给 Django 进程增加了很多无谓的请求压力。Flask 服务我用的运行方式是通过 Gunicorn 启动worker 类型选择了 gevent主要是为了在后面要做的 WebSocket 实时推送场景下能支持更高的并发连接。启动命令大致是gunicorn -w 4 -k gevent -b 127.0.0.1:5000 app:appDjango 端因为主要承担管理后台和业务处理用普通的多进程 worker 就够了没有额外做特殊配置。6.2 连接池、缓存与超时参数的实测调整部署之后做了一轮压测结果暴露出了两个性能问题。第一个问题是数据库连接数猛增。Gunicorn 起了 4 个 workerDjango 那边也起了类似数量的进程每个进程默认维护一个数据库连接池。压测时连接数一度冲到好几百MySQL 默认的最大连接数根本扛不住。解决办法是显式配置连接池大小限制每个进程的数据库连接数不超过 5 到 10 个同时把 MySQL 的max_connections适当调大。另外在 Django 和 Flask 的数据库配置里都设置了连接复用参数避免每次请求都新建连接。第二个问题是座位图接口响应时间偏慢。获取一个场次的座位图需要查影厅基础信息、座位列表、场次座位状态三张表关联查询每次请求都全量查一遍热门场次并发高的时候数据库压力很大。我引入了 Redis 缓存方案。座位图的数据结构分成两部分影厅的静态布局基本不变缓存到 Redis 后几乎不会失效座位状态是动态的每次有人锁座或者支付成功都要更新。我采用的做法是把静态布局缓存一份把动态状态以seat_status:{screening_id}为 key 单独缓存一份哈希结构。查询座位图接口时先从 Redis 读取两个部分再拼装成完整数据返回。座位状态变更时只需要更新这个哈希结构里对应的字段不用整份缓存失效重建。压测数据显示优化后接口响应时间从原来的 200 多毫秒降到了 30 毫秒以内效果非常明显。6.3 实测中发现的两个隐蔽问题以及处理过程压测过程中还发现了一个很隐蔽的问题Flask 侧查询座位图时读到的座位状态是脏数据。原因是 Django 在事务里更新座位状态后还没有提交Flask 在另一条连接里执行查询由于隔离级别和事务可见性的关系读不到最新的状态。这个问题在低并发下几乎不出现一旦并发上来就偶发。排查下来根源在于我当时为了图方便在 Flask 的查询辅助层里直接用了自动提交模式而 Django 的事务状态没有及时同步。解决思路有两个方向一是把座位状态变更后强制写 Redis 缓存Flask 读缓存而不是直接读数据库二是确保所有状态变更都通过统一的接口走完整个提交流程。最终我把这两个方向都落实了现在所有座位状态操作都会先更新数据库再刷新对应的 Redis 缓存Flask 接口优先读缓存。这样既解决了脏数据问题也提升了接口性能。还有一个低阶但很容易犯的错订单号生成没有全局唯一性约束。我一开始用的是 Python 的随机数加时间戳拼订单号压测时发现偶尔会生成重复订单号导致后续查单出现两条记录。后来改成分布式 ID 生成策略用 Redis 的原子自增加日期前缀拼接唯一性就不需要再担心了。这类问题不压测根本发现不了所以我建议任何人做类似的项目都要尽早引入并发测试而不是只拿 Postman 测几个接口就觉得完事了。这次开发给我最大的感受是选座系统的难点不在某个单独的技术点而是多个模块之间状态的一致性。数据库锁、Redis 锁、接口幂等、缓存更新每一个单独拿出来都不难难的是把它们组合成一个没有漏洞的闭环。如果你正在做类似的项目我建议优先把状态机梳理清楚再谈并发和性能——状态机没有定义清楚后续所有加了锁、加了缓存的优化都是在给一个可能错误的结果加速。