
春节档抢票那几天我朋友圈里哀嚎一片——不是抢不到票是抢到了座位却被别人先锁定最后只能窝在角落第一排仰着脖子看两个半小时。这种体验多了以后我一直觉得电影院的在线选座系统是个非常值得拆解的选题。趁着最近在做模拟项目X我完整过了一遍基于 Django 和 Flask 双框架、用 Python 全栈实现的电影院在线选座系统从需求拆解、数据建模到并发锁座、超时释放把整个设计链路跑通了今天把过程和踩过的坑全部整理出来。这套系统解决的核心问题是把线下购票环节搬到线上让用户能直观看到影厅座位图、提前选好心仪的座位并完成支付同时影院侧需要管理排片、调整价格、处理退票和座位异常。它适合刚学完 Python Web 框架想找综合练习的开发者也适合正在做课程设计或内部系统原型的人。看完这篇文章你至少能搞清楚三件事Django 和 Flask 在同一个项目里怎么分工、选座系统的数据模型怎么设计才不乱以及并发场景下怎么避免同一张票被卖两次。1. 在线选座系统的需求拆解与整体设计思路1.1 影院选座到底在解决什么业务问题很多人把在线选座想成普通的 CRUD 系统觉得无非是用户选座位、生成订单这么简单。真正做过以后会发现这里面最麻烦的是座位状态在时间和空间两个维度上的一致性。从空间维度看一个影厅有若干排、每排若干座位还有情侣座、残疾人座、后排按摩座这些特殊座位类型。座位之间不是孤立的用户一次可能选 2 到 5 个座位这些座位必须出现在同一个场次里而且不能有两个订单同时锁定同一个座位。从时间维度看座位状态会不断流转可选、锁定、已售、维护锁定状态必须在一定时间内释放否则想买的人会被占着茅坑不拉屎的锁座防掉。影院还会为不同场次、不同时段设置不同的票价比如早场便宜、黄金场贵这些价格策略要挂在排片记录上而不是挂在座位上。我画系统模块图的时候一开始只分了用户端和管理端两个大块后来发现这不够。用户端至少包含影片浏览、场次选择、座位选择、订单支付四个模块管理端包含影院管理、影厅管理、排片管理、座位管理、订单管理、统计报表六个模块。如果把消息推送和会员系统也算进来整个系统会膨胀得很快。所以我在设计时给自己定了一条原则核心链路先跑通周边模块按接口预留。1.2 从用户点进页面到收到出票短信的完整链路我整理了一下用户下单的完整路径。首先是用户打开小程序的影片列表页按热度或者上映日期筛选影片点进详情页后选择想去的影院和场次。这里要注意一个隐含需求用户选择场次时系统需要展示这个场次还剩多少座位如果只剩边缘零星座位很多用户根本不会点进来这时运营上会考虑排片调整。用户进入选座页后前端会根据影厅的座位布局渲染一张座位图并通过接口拉取当前场次的实时状态。座位状态分四类可售、已锁定、已售出、维护中。用户点选可售座位前端把座位组合成一组提交到后端后端经过锁座校验后把座位状态改成锁定同时发起订单返回一个订单号和支付倒计时。这个时候订单还是待支付状态用户必须在规定时间内完成支付。支付成功后支付网关回调后端接口后端把订单改成已支付座位状态从锁定改成已售出然后生成电子票。如果用户超时未支付系统要自动把座位状态从锁定改回可售这就是超时释放逻辑。值得一提的是入场核销环节。大多数系统会给每个座位生成一个二维码入场时用闸机或 App 扫码验证。我实现的版本把核销简化成一个一次性接口验票成功后订单状态变为已使用同时记录核销时间方便影院核对数据。1.3 角色权限与核心用例划分系统涉及三类角色。普通用户关心的是找片、选座、付款、退票影院运营人员关心的是排片、票价、上座率系统管理员关心的是影厅规格维护、座位异常恢复、订单退款处理。权限上我采用了简单的 RBAC 模型Django 自带的 User 模型加上自定义的 Role 字段就能满足不必引入复杂的权限框架。用例设计上必须优先保证的是选座-锁座-下单-支付-释放/出票这条主链路的正确性。周边用例像短信通知、电子票二维码、退票规则都是为了让这个主链路更好用而不是核心复杂度所在。想清楚了这一点后面设计数据模型时就有了清晰的优先级。2. 技术选型与数据库设计解析2.1 Django 和 Flask 在同一个项目里的分工方式这个项目的标题是django-flask基于python的电影院在线选座系统第一次看到的人可能会疑惑Django 和 Flask 不是竞争关系吗怎么放一起用我实际采用的方案是用 Django 作为业务主框架承载用户认证、影院排片、订单支付这些核心业务理由是 Django 的 ORM、Admin 后台、数据迁移、表单验证这些重量级能力非常适合管理后台和事务复杂的业务模块。另一方面用 Flask 实现一个独立的轻量 API 服务专门负责座位图快照查询和座位锁定/释放的原子操作接口。为什么不让 Django 一把梭呢这里有一个实际考虑选座接口的并发压力远高于普通业务接口尤其是热门场次开售那一刻大量用户同时刷新座位图、提交锁座请求。Django 的请求处理模型在同步阻塞场景下很快会堆积线程而 Flask 搭配 Gunicorn 做微服务部署时可以更灵活地控制 worker 数量也方便在后端单独做 Redis 缓存和队列处理。简单说Django 管复杂业务状态Flask 管高并发小接口两者通过 HTTP 内部接口通信互不干扰部署层面也可以分开扩缩容。当然如果你是在做课程设计或者小型内部系统也可以只选 Django 一个框架做完整实现。标题里的 django-flask 就当作风向标理解成用 Python 的 Web 框架体系去实现即可。2.2 核心数据表结构设计我设计数据模型时反复改了六七版最终稳定下来的核心表有这几张影厅表 hall 字段包括 id、影院 ID、影厅名称、座位总数、排数、列数、座位布局模板。座位布局模板一列建议直接存 JSON 或者文本把特殊座位的位置标记出来。因为每个影厅的布局都可能不同单独一张 seat_layout 表做递归拆分反而复杂度更高。座位表 seat 字段包括 id、影厅 ID、排号、列号、座位类型、状态。注意一个问题座位表存的是影厅的物理座位它的状态其实是不存在的因为同一个座位在不同场次里可以是可售、锁定或已售所以座位状态绝对不能挂在物理座位的状态字段上否则换场次会互相污染。正确做法是引入场次座位表或者干脆用订单来反推状态。我用的是经典的 session_seat 场次座位表字段包括 id、场次 ID、座位 ID、状态。这张表在每次排片生成时批量复制影厅的座位数据状态初始化为可售。这样做的好处是状态天然隔离坏处是数据量会膨胀但影院系统一天几千个场次、每场几百个座位完全在 MySQL 的承受范围内。场次表 session 字段包括 id、影片 ID、影厅 ID、放映开始时间、结束时间、语言版本、基础票价。在这里必须做一项校验同一影厅同一段时间不能排两个场次我通过给 session 表加一个影厅 ID 放映时间的唯一索引兜底。订单核心表 order 字段包括 id、订单号、用户 ID、场次 ID、总金额、支付状态、创建时间、支付时间、过期时间。订单和座位的关联我用了 order_item 子表一个订单对应多个座位项。这里一定要存场次 ID 和座位 ID而不是只存冗余的座位信息因为后续退款、释放、核销都要精确操作座位状态。锁座记录支撑表 seat_lock 是我额外加的字段包括 id、场次 ID、座位 ID、订单号、锁定期限。它的作用是支持超时排查和手动恢复实际判断座位是否可用时优先看场次座位表的状态字段再看锁记录有没有过期。2.3 用 Redis 还是数据库完成锁座操作座位锁定是整个系统的心脏我对比过两种方案。第一种是纯数据库方案直接 UPDATE session_seat SET status 1 WHERE id ? AND status 0然后判断影响行数是否为 1。这个方案简单可靠配合唯一索引可以做到不超卖但问题是如果订单超时未支付需要定时任务回滚状态而且在高并发下行锁竞争会拖垮数据库性能。第二种是 Redis 预占加数据库确认的方案。用户提交选座请求时先用 Redis 的 SETNX 命令对场次 ID 座位 ID这个 Key 做抢占抢到后把信息写入数据库并创建订单Redis Key 设置过期时间为支付倒计时。支付成功后再把 Redis Key 删除数据库状态改成已售。这个方案好处是 Redis 的单线程模型天然串行锁座效率高适量并发压测下响应时间基本稳定坏处是引入了额外的中间件依赖而且必须处理 Redis 和数据库不一致的问题。我最终选择的是混合方案Redis 锁做第一层快速拦截数据库条件更新做最终确认。Redis Key 过期时间设置为 15 分钟与订单支付倒计时保持一致。这样既能扛住热门场次的瞬时并发又保证了数据最终一致。具体实现细节放在后面章节详细展开。3. 选座核心机制状态机、并发控制与超时释放3.1 座位状态机的完整定义与流转路径座位状态不是简单的有/无我定义了一个四态枚举可售AVAILABLE、锁定LOCKED、已售SOLD、维护MAINTENANCE。此外还有一个隐性的待释放状态它不是一个实体状态而是锁定 锁记录过期这个组合条件的体现。状态流转路径是这样的场次创建时所有座位初始化为可售用户选座并提交锁座后从可售变为锁定用户支付成功后从锁定变为已售用户超时未支付或主动取消后从锁定回到可售管理员对故障座位打标记时从可售变为维护。这里有一个坑用户退款场景下已售座位应该回到可售但如果退款发生在开场前 30 分钟内很多影院规则是不允许退票的。所以退款操作必须检查场次开始时间在接口层做业务校验而不能只依赖数据层的状态判断。我在实现时给订单加了一个 can_refund 的计算方法开场前 2 小时以上允许退2 小时以内弹窗提示确认。所有状态变更操作我都记录到一张 ticket_flow_log 流水表里。这张表看起来不起眼但排查线上问题时价值巨大比如用户说我明明选中了座位却被别人买走了流水表能清楚展示这个座位的每一次状态变更时间和操作来源。3.2 高并发锁座时的两个关键防线锁座接口是典型的读-改-写场景两个用户同时看到同一个座位可售然后同时下单如果处理不好就会出现超卖。我在系统里设置了防线或者说三层防线从内到外层层拦截。第一层是 Redis 分布式锁。用 SET NX EX 命令对 seat_lock_key 加锁Key 的命名规则是cinema:seat:{session_id}:{seat_id}加锁成功才允许创建订单。这就保证了同一时刻只有一个请求能占用一个座位。考虑到用户可能一次选多个座位我给同一场次的多个座位用 Lua 脚本一次性批量抢占避免了循环加锁导致的死锁和资源浪费。第二层是数据库条件更新。Redis 锁过期或者崩溃后数据库兜底很重要。核心 SQL 是UPDATE session_seat SET status LOCKED WHERE session_id ? AND seat_id ? AND status AVAILABLE。这个 UPDATE 自带原子语义影响行数为 1 说明更新成功为 0 说明座位已经不是可售状态了。执行完再开启事务创建订单和流水记录。第三层防线是唯一约束。数据库表加一个场次 ID 座位 ID 状态为已售的联合唯一索引理论上如果前两层都失效最后的约束也会让重复的已售记录插入失败。实测下来这三层防线加上去以后压测并发 50 个请求抢同一个座位也只会有一个请求成功。3.3 超时释放与 Celery 定时任务锁座之后用户不支付怎么办我最初做的是在查询座位状态时顺带检查锁记录是否过期如果过期就恢复为可售。这个方案很省事但有个隐患如果很长时间没人查询这个场次过期座位就一直处于锁定状态后台看到的数据不准确。后来改成 Celery Beat 拉一个定时任务每 30 秒扫一次 seat_lock 表找出超过支付倒计时仍未支付的锁记录批量把对应的 session_seat 状态改回可售同时把关联的待支付订单标记为已取消。这里有两个细节值得注意第一定时任务要加一个分布式锁避免多个 worker 同时执行扫描任务重复处理第二处理完过期的座位后最好邮件或者站内信通知订单关联用户告知其订单已因超时取消座位已释放。Celery 的具体配置在 Django 项目里是标准的消息中间件我用 Redis而不是 RabbitMQ减少运维成本。配置文件里注意时区设置Django 的 USE_TZ 要设为 True并且所有过期时间判断都用 UTC 时间。第一次上线时因为时区没设置好出现过支付倒计时和实际释放时间差 8 小时的诡异情况这个坑后面专门说。4. 实操过程与核心代码实现4.1 Django 项目初始化与基础配置创建项目时我建议用 Django 3.2 或者 4.2 的 LTS 版本稳定支持周期长社区资料也全。项目结构上业务应用拆成 users、cinema、session、order、payment 五个 app各自职责单一。Flask 微服务单独放在 services/seating 目录下独立维护自己的 requirements。Django 侧依赖的核心库有这些Django、djangorestframework、drf-spectacular 做接口文档、celery、redis、django-cors-headers、mysqlclient 或者 pymysql。Flask 侧只需要 Flask、redis、pyyaml接口逻辑非常简单。数据库我最终选了 MySQL 8.0字符集 utf8mb4事务隔离级别默认的 REPEATABLE READ 够用。连接配置里要开启CONN_MAX_AGE60避免每次请求重新建立 MySQL 连接这个对高并发场景影响很明显。同样的道理Django 的数据库连接池本身不支持所以并发一大就需要外接 SQLAlchemy 连接池。我实测的方案是直接用 SQLAlchemy 管理所有写操作Django ORM 只负责后台管理相关的查询两套可以共存只是需要注意事务边界。4.2 核心数据模型的代码实现场次座位模型的迁移脚本我直接写了一个管理命令在创建场次时调用逻辑是查出影厅的所有座位循环生成 session_seat 记录。一上午生成几十万个记录的批量操作用 bulk_create 秒级完成千万别用循环 save速度会慢到怀疑人生。# order/models.py 中订单与订单项模型 class Order(models.Model): class PayStatus(models.TextChoices): PENDING pending, 待支付 PAID paid, 已支付 CANCELLED cancelled, 已取消 REFUNDED refunded, 已退款 order_no models.CharField(max_length32, uniqueTrue) user models.ForeignKey(User, on_deletemodels.PROTECT) session models.ForeignKey(cinema.Session, on_deletemodels.PROTECT) total_amount models.DecimalField(max_digits8, decimal_places2) pay_status models.CharField(max_length16, choicesPayStatus.choices, defaultPayStatus.PENDING) expire_at models.DateTimeField() created_at models.DateTimeField(auto_now_addTrue) paid_at models.DateTimeField(nullTrue, blankTrue) class OrderItem(models.Model): order models.ForeignKey(Order, related_nameitems, on_deletemodels.CASCADE) session_seat models.OneToOneField(cinema.SessionSeat, on_deletemodels.PROTECT) price models.DecimalField(max_digits6, decimal_places2)这里 order 和 session_seat 是一对一关系因为一个场次座位只能出现在一个成功的订单项里用 OneToOneField 在数据层面保证一票一座。4.3 Flask 座位锁定微服务的接口实现Flask 微服务的核心接口是批量锁座和批量释放座位。批量锁座接口接收的请求题是场次 ID 和座位 ID 列表先做 Redis 批量抢占再把成功抢占的座位信息返回给调用方。Redis 的 Lua 脚本在 Python 里用 redis-py 直接调用 eval 方法完成。# services/seating/lock_service.py import redis r redis.Redis(hostredis, port6379, decode_responsesTrue) LOCK_SCRIPT local expired redis.call(TTL, KEYS[1]) if expired -2 then redis.call(SET, KEYS[1], ARGV[1], EX, ARGV[2]) return 1 else return 0 end def batch_lock(session_id, seat_ids, ttl900): pipe r.pipeline(transactionFalse) locked [] for seat_id in seat_ids: key fseat:lock:{session_id}:{seat_id} ok r.eval(LOCK_SCRIPT, 1, key, locked, ttl) if ok: locked.append(seat_id) return locked这段逻辑的关键在于TTL 返回 -2 说明 Key 不存在可以设置返回 -1 说明 Key 没有过期时间理论上不会出现返回具体秒数说明 Key 已存在且还未过期不能锁。多座位一起抢的时候用同一个 Lua 脚本逐个处理相比循环加事务死锁概率要小得多。Flask 服务启动时额外挂了一个健康检查接口Django 侧通过 requests 调用 Flask 的 /api/seat/lock 和 /api/seat/release 两个接口。如果 Flask 不可用Django 侧不直接降级处理而是返回清晰报错避免状态不一致问题被掩盖。4.4 订单确认与支付回调的状态处理锁座成功后Django 创建待支付订单然后前端把订单号交给支付组件发起收银台跳转。这里有一个我自己平时不注意、但线上必须处理的场景用户跳转支付收银台后直接把页面关了或者支付成功后微信回调没送达订单状态就一直停在待支付。解决办法是支付回调接口的幂等处理。回调到达后先查订单号如果订单已经是已支付状态直接返回成功如果待支付状态执行状态更新和座位状态变更如果已取消状态说明用户超时订单被自动关闭了这时不能变更座位状态而是要原路退款给用户同时记录退款流水。在事务边界上我的做法是回调接口里面先用 select_for_update 锁住订单行然后执行状态流转再更新 session_seat 状态全部提交后再调用一次缓存的删除操作。这样一张订单从待支付到已支付的过程是一个完整事务任何一步失败都会整体回滚支付回调不会出现订单已支付但座位没卖出去的中间状态。5. 常见问题与排查技巧实录5.1 Django 和 Flask 的时区不一致问题这个问题我印象最深。Django 项目配置了 USE_TZ True时区设成 Asia/Shanghai写入 MySQL 的时间都是 UTC 时间。Flask 微服务那边读 Redis 的时候由于 Redis 没有时区概念锁过期时间的计算逻辑用的是本地时间取得的数据。结果就是用户看到支付倒计时剩余 10 分钟但后端锁记录过期判断用的是 UTC 时间实际锁在支付完成前半小时就被清了座位显示可售用户下单时才发现座位又被别人锁了。排查方法是查看超时释放任务处理过的订单记录对比创建时间、过期时间、实际释放时间三个字段。解决方式是把所有过期时间统一用datetime.now(timezone.utc)来算前端展示时再转成当地时间。建议从一开始就在项目里约定好后端存储和计算一律用 UTC显示层才做转换。5.2 并发下单导致同一座位被重复购买的排查思路问题表象是压测时偶尔出现两个订单的订单项指向同一个 session_seat。一开始我以为 Redis 锁断了后来看日志发现是锁座接口和订单创建接口之间有竞态Redis 锁在订单创建前被异常释放了另一个请求接住了这把锁。修复措施分两步。第一步确保 Redis 锁的持有者信息写入Key 的 Value 存订单号删除锁之前先比对 Value 是不是当前订单号防止误删别人的锁。第二步数据库层 update 从可售改成锁定时返回值必须是 1 才能创建订单这个条件更新要在 Redis 锁释放之前完成。换句话说Redis 锁保护的只是提交锁座申请这个入口数据库条件更新才是座位状态变更的最终裁判。5.3 座位图前端渲染错位和状态刷新不及时座位图渲染错位大概率是行列编号的排序问题。用户视角里银幕在正上方最前排是第一排 A 排座位从左到右是 1 到 10。但数据库里生成座位时如果不注意排序规则按创建时间返回给前端就会出现中间几排顺序错乱、视觉上不连贯的情况。我的解决方式是在 session_seat 和 seat 表里都存 row_number 和 column_number 字段前端渲染时按 row_number 升序、column_number 升序排列再根据座位类型标记特殊样式。状态刷新方面我采用轮询策略选座页面每 3 秒调一次座位状态接口同时 WebSocket 推送作为一个可选的加强方案留给后续迭代。轮询的带宽开销在影厅座位聚合接口这么小的数据量下完全可接受。5.4 支付回调丢失后订单状态卡在待支付如果支付平台回调真的没有送达不能完全依赖回调驱动的状态流转必须做主动查询兜底。订单支付倒计时剩下最后 1 分钟时前端的轮询接口如果发现订单状态还是待支付调用一次主动查单接口请求支付平台根据订单号查询支付结果。查到已支付就把订单状态补成已支付查到未支付就提示用户剩余时间等待超时释放。这个兜底逻辑我一开始觉得是小事直到一次真实演练中模拟了支付平台回调延迟 10 分钟的情况发现用户已经付了款订单却一直停留在待支付座位对应的 session_seat 也处于锁定状态其他用户无法购买。所以务必要把主动查单作为一种标准流程而不仅仅是异常恢复手段。5.5 大影厅初始化性能优化的小技巧几百个座位的影厅生成 session_seat 数据时如果用 ORM 逐个 save要跑几个小时。切到 bulk_create 之后几千条记录秒级完成。再往后如果要支持千人规模的巨幕厅可以在建表时预生成座位模板排片时直接执行 INSERT ... SELECT 从模板表复制比 Python 层循环快非常多这也是我后续计划优化的方向。几点个人体会把整个系统从零到位完整实现一遍我最深的体会是在线选座系统的难点不在框架怎么用而在业务边界怎么划分。锁座、超时释放、支付回调这些环节任何一个点考虑不周都会在真实用户面前放大成灾难。建议动手前先明确自己的业务规则比如退票规则、超时时间、是否允许连座锁多个座位这些规则没定明白之前先别急着写代码。另一点经验是调试并发问题时不要靠肉眼盯日志猜一定要在关键状态变更处埋好流水日志尤其是座位状态变化、订单状态变化、Redis 锁操作的出入参。我在项目里加了 ticket_flow_log 之后排查问题的效率提升了不止一个量级。后续如果你想扩展这个系统可以从两个方向入手一是接入 WebSocket 实现真正的座位实时推送把抢座体验做得更像线下售票窗口二是加入会员积分和营销活动模块让整个系统从单纯的售票工具变成影院运营平台这两个方向都值得单独写一篇详细聊聊。