ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Django+Flask实现电影院在线选座:并发锁座与超时释放实战解析

Django+Flask实现电影院在线选座:并发锁座与超时释放实战解析

春节档抢票那几天,我朋友圈里哀嚎一片——不是抢不到票,是抢到了座位却被别人先锁定,最后只能窝在角落第一排仰着脖子看两个半小时。这种体验多了以后,我一直觉得电影院的在线选座系统是个非常值得拆解的选题。趁着最近在做模拟项目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_AGE=60,避免每次请求重新建立 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_length=32, unique=True) user = models.ForeignKey(User, on_delete=models.PROTECT) session = models.ForeignKey("cinema.Session", on_delete=models.PROTECT) total_amount = models.DecimalField(max_digits=8, decimal_places=2) pay_status = models.CharField(max_length=16, choices=PayStatus.choices, default=PayStatus.PENDING) expire_at = models.DateTimeField() created_at = models.DateTimeField(auto_now_add=True) paid_at = models.DateTimeField(null=True, blank=True) class OrderItem(models.Model): order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE) session_seat = models.OneToOneField("cinema.SessionSeat", on_delete=models.PROTECT) price = models.DecimalField(max_digits=6, decimal_places=2)

这里 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(host="redis", port=6379, decode_responses=True) 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, ttl=900): pipe = r.pipeline(transaction=False) locked = [] for seat_id in seat_ids: key = f"seat: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 实现真正的座位实时推送,把抢座体验做得更像线下售票窗口;二是加入会员积分和营销活动模块,让整个系统从单纯的售票工具变成影院运营平台,这两个方向都值得单独写一篇详细聊聊。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进