☰
在线选座系统实战:Django+Flask混合架构的并发锁座与事务设计
2026/10/13 13:21:19 网站建设 项目流程

影院在线选座系统听上去不算什么新鲜东西,但真正动手做一轮才发现,里面最磨人的不是CRUD,而是“选座”这两个字背后的并发、锁、事务一致性。我当时接到这个项目时,客户的诉求很直接:要一套支持在线购票、选座、支付回调、后台排片管理的完整系统,技术栈限定Python。经过一番权衡,我用Django + Flask的混合架构来做——Django负责运营管理后台和核心数据模型,Flask负责面向C端的选座与订单接口。今天这篇就把整个设计和实现过程拆开讲,包含数据建模、锁座方案、并发控制、超时释放、避坑记录,希望能给正在做类似Web系统的朋友一些可落地的参考。

我默认读者已经有Python基础,接触过Django或Flask其中一个框架,但还没把两者放到同一个项目里打过组合拳。如果没有Web开发基础,前两章的数据设计和接口规划部分也值得先读一遍,理解业务逻辑后再看代码。

1. 系统整体设计与技术选型思路

1.1 核心需求拆解:不只做CRUD,要把业务状态理清

在线选座系统的表面需求很容易列出来:用户能浏览影片、查看场次、选座位、下单支付、收到电子票,管理员能维护影片和影厅、排片、处理订单。但这些表单背后隐藏着几个必须提前想清楚的业务问题:

锁座与并发控制。两个用户同时点同一个座位,系统只能放行一个,另一个要拿到“座位已被占”的反馈。选座不是简单地写一条订单记录,而是要在一个分布式的多请求环境下保证座位状态不被覆盖。

座位状态的语义。一个座位在某个场次下有三种状态:可售、锁定(用户已选中但未支付)、已售。锁定状态必须携带过期时间,否则用户选座后一直不支付,座位就永远卖不出去了。通常的规则是锁定5到15分钟,超时自动释放回可售池。

支付回调的可靠性。用户先锁座、再下单、再支付,支付网关回调后系统才真正把座位状态从“锁定”改成“已售”。真实项目中,支付回调可能延迟、重复、甚至丢失,所以订单状态机必须设计得足够健壮。

管理端与用户端的权责分离。运营人员做排片、修改价格、维护影片信息,这类操作频率低但字段多,适合用一个成熟的后台框架来做。C端用户的选座操作频率高、单次操作逻辑简单、对响应速度敏感,适合用轻量框架单独出接口。

把这几条理清之后,系统边界就出来了:底层共用一套数据库,上层拆成管理端服务和用户端服务。

1.2 为什么选择Django + Flask混合架构

很多朋友看到“Django + Flask”会觉得奇怪:这不是重复造轮子吗?用Django一个框架全干完不就行了?确实可以,但组合使用有它的现实理由。

Django的优势在于“全家桶”。它自带的ORM、Admin后台、表单校验、认证体系非常成熟,尤其适合运营管理这类场景。做个影片管理后台,用Django Admin半天就能搭完,连登录权限、数据列表、增删改查都齐了。如果我用原生SQL或Feask自己去写管理面板,工作量会成倍增加。

Flask的优势在于“轻与活”。选座、下单这类接口的请求量远大于管理端的操作量,接口逻辑本身不复杂,不需要Django Admin那种重型组件。Flask路由灵活、上下文管理简单、扩展点清晰,写REST接口非常顺手,而且它的性能开销低于Django——在高并发压测下,能明显感受到两者的差异。

那怎么“共用数据库”?我当时的设计是:Django负责建表和管理数据模型,同时提供后台管理系统;Flask作为独立的服务,直接连接同一个MySQL数据库,只负责提供C端API。两边通过数据库共享数据,不互相调接口。这样做的核心考虑是:选座接口要保持独立部署和独立扩容的能力,管理端的变化不应影响C端服务。

注意:混合架构有一个必须想清楚的约束——两边对同一张表的模型定义要保持一致,字段名、枚举值、状态码都不能各写一套。我在项目启动时就定了一个严格的约定:数据字典以Django模型为准,Flask端手动维护一套兼容的SQLAlchemy模型,任何字段变更必须两边同步。这个约定帮我避免了很多运行时踩坑。

1.3 技术栈与部署形态

  • 后端:Django 3.x(管理端)+ Flask 2.x(用户端接口)
  • 数据库:MySQL 5.7或8.0,InnoDB引擎
  • 连接池:SQLAlchemy在Flask端负责连接管理,Django使用内置数据库池
  • 缓存:Redis(用于锁座超时标记、高频热点数据缓存)
  • 部署:Nginx + Gunicorn(Flask服务)+ UWSGI(Django服务),MySQL独立部署
  • 前端:C端可以做成简单的Vue页面或服务端渲染页面,后台直接用Django Admin

这套结构整体上有三个特点:数据集中、逻辑分离、压力分散。开发时两个服务可以同时本地起,联调时只要连同一个MySQL库就行;上线后Flask服务可以横向扩展多实例,Django管理端保持单实例也完全够用。

2. 数据建模与关键表设计

2.1 数据库表结构与字段设计

我建表的原则是“业务状态才加字段,计算项不落库”。 比如订单总价可以从票价和座位数算出来,就不单独存一个冗余字段,避免用户改价时产生脏数据。下面列出核心表的关键字段:

影片表(film)字段包括:影片名、封面图URL、上映日期、下线日期、片长(分钟)、简介、评分、状态(上映中/已下架/即将上映)。状态字段用于控制C端是否可以展示该影片。

影厅表(hall)字段包括:影厅名称、座位行数、列数、座位布局JSON(描述哪些位置是过道或无障碍区)。座位布局用JSON存储比较灵活,改座位排布不需要改表结构,直接在JSON里改配置就行。

场次表(show_session)字段包括:所属影片外键、所属影厅外键、开始时间、结束时间、票价、放映版本(2D/3D/IMAX)、状态(开放售票/已截止/已取消)。结束时间最好在创建排片时自动计算,规则是“开始时间 + 片长 + 20分钟清场时间”。

座位基础表(hall_seat)字段包括:所属影厅外键、行号、列号、座位类型(普通/情侣座/无障碍)、坐标X、坐标Y。这张表记录的是影厅本身固有的座位物理信息,和具体场次无关。

场次座位表(session_seat)字段包括:场次外键、座位外键、状态(1可售/2锁定/3已售)、锁定订单号、锁定到期时间。这是选座系统的核心表,每个场次下的每个座位都有一条记录,初始化场次时批量生成。这张表的存在让“按场次查询剩余座位”变成了简单的状态统计,避免了去计算哪些物理座位有没有被占用。

订单表(order)字段包括:订单号、场次外键、用户ID、座位ID列表(JSON或关联子表)、总价、状态(待支付/已支付/已取消/已退款)、支付渠道流水号、创建时间、支付时间。

锁座记录表(seat_lock)字段包括:场次外键、座位ID、用户ID、锁座订单号、锁定时间、到期时间、状态(有效/已释放/已转订单)。这张表用于超时清理和异常恢复,如果用户锁座后浏览器崩溃、长时间不支付,定时任务可以根据到期时间把对应的session_seat改回可售。

字段细节上要注意一点:所有时间字段都存UTC还是本地时间,项目开始前要定死。我当时统一存本地时间,虽然有点土,但对接第三方支付和运营后台查数据都方便,避免了时区换算的混乱。

2.2 为什么场次座位和物理座位要拆成两张表

这是我建表时一个核心决策。很多初学者会直接用一张座位表,加上场次外键,复用物理座位的行列坐标。表面上看一样能用,但拆开之后有几大好处:

第一,场次座位表天然支持“按场次快照”。如果运营临时要把第三排改成情侣座,或者某个座位因维修临时下架,只需要在该场次的session_seat上做调整,不影响影厅的基础布局。座位价格也可以跟着session_seat走,一个影厅同一位置在不同场次可以定不同票价,物理座位表根本承载不了这个逻辑。

第二,状态管理和并发控制集中在session_seat一张表上。选座时只对这张表的行做状态变更,SQL清晰、容易加索引、容易排查死锁。

第三,初始化简单。新建一个场次时,根据hall_id查出所有hall_seat记录,批量插入session_seat,状态全部置为可售,一条SQL搞定。如果都挤在一张表里,创建场次时要维护的数据逻辑反而更绕。

2.3 订单状态机与支付回调设计

订单不能只有“待支付/已支付”两个状态,否则支付环节出错时无法定位。我把订单状态设计成:

  • 待支付:锁座成功、已创建订单,但未收到支付回调
  • 已支付:支付回调成功,座位转已售
  • 已取消:用户主动取消,或锁座超时被系统释放
  • 已退款:支付完成后运营介入处理退票

支付回调接口要做两件事:先校验签名,再更新订单状态。校验签名是对支付网关身份的验证,必须放在第一步,防止伪造回调。更新订单时要用事务把“订单改成已支付”“session_seat改成已售”“seat_lock状态改成已转订单”这三步一起提交,任何一步失败都要回滚,否则会出现钱付了座位却是可售的矛盾状态。

我还处理了支付回调重复推送的问题。支付网关偶尔会重发回调,如果订单已经处于已支付状态,直接返回成功,不能再次执行变更逻辑。这也是状态机设计的好处——每个状态只在“合法的方向”上流转,重复回调不会造成脏数据。

3. 核心模块实现:选座、锁座与超时释放

3.1 Flask端选座接口:基于事务和行锁的方案

选座接口是整套系统的门面,直接关系到用户体验,也是并发压力最大的位置。坐标式选座请求大概长这样:用户选中一个场次的3排5座、3排6座,点击确定,后端先把这两个座位锁定。

我用的方案是事务 + 行级锁(SELECT FOR UPDATE)。处理逻辑如下:

  1. 开启事务;
  2. 用SELECT ... FOR UPDATE按条件查询session_seat中目标座位的行;
  3. 逐行检查状态是否为可售,如果任一座位不是可售状态,回滚并返回“座位已被占用”;
  4. 全部可售则更新状态为锁定,写入lock订单号和到期时间;
  5. 提交事务。

这个方案的关键在于,FOR UPDATE会对查询到的行加排他锁,其他事务想改这些行就必须等当前事务提交或回滚。两个用户同时提交选座请求时,数据库层面已经串行化了,不会出现两个用户都锁定同一座位的情况。

下面是Flask端的核心代码片段,基于SQLAlchemy实现:

from flask import Flask, request, jsonify from extensions import db from models import SessionSeat, ShowSession, SeatLock from datetime import datetime, timedelta app = Flask(__name__) LOCK_DURATION = 15 # 锁座有效期,单位分钟 @app.route('/api/lock_seat', methods=['POST']) def lock_seat(): data = request.get_json() user_id = data['user_id'] session_id = data['session_id'] seat_ids = data['seat_ids'] # 用户选择的座位ID列表,例如 [101, 102] lock_order_no = generate_order_no() expire_time = datetime.now() + timedelta(minutes=LOCK_DURATION) try: db.session.begin() # 一次性锁定这批座位的行,保证并发安全 rows = SessionSeat.query.filter( SessionSeat.session_id == session_id, SessionSeat.seat_id.in_(seat_ids) ).with_for_update().all() if len(rows) != len(seat_ids): db.session.rollback() return jsonify({'code': 4001, 'msg': '座位参数不合法'}), 400 for row in rows: if row.status != 1: # 1表示可售 db.session.rollback() return jsonify({'code': 4002, 'msg': f'座位{row.seat_id}已被占用'}), 409 for row in rows: row.status = 2 # 锁定 row.lock_order_no = lock_order_no row.lock_expire_time = expire_time # 记录锁座记录,便于超时释放和风控追溯 lock_record = SeatLock( session_id=session_id, user_id=user_id, lock_order_no=lock_order_no, lock_time=datetime.now(), expire_time=expire_time, status='active' ) db.session.add(lock_record) db.session.commit() return jsonify({'code': 0, 'lock_order_no': lock_order_no}) except Exception: db.session.rollback() return jsonify({'code': 5000, 'msg': '系统异常'}), 500

这段代码里有个容易被忽略的细节:with_for_update()必须在事务内才生效。如果事务还没开,FOR UPDATE就没有意义。SQLAlchemy默认会在执行查询前开启隐式事务,所以只要在try块里db.session.begin(),然后查询、修改、最后commit,锁就能覆盖整个操作区间。

注意:不要把FOR UPDATE当成一个万能工具到处用。它只适合锁少量确定的行。如果对整张表做FOR UPDATE,并发能力会断崖式下跌。选座场景下锁的只是用户选中的那几行,数据量小、持续时间短,正好匹配行锁的适用场景。

3.2 座位状态更新:用原子Update做兜底

SELECT FOR UPDATE方案已经能解决大多数并发问题,但我在压测时发现一个极端场景:网络抖动导致事务长时间不提交时,后续请求会堆积等待。为了减少等待窗口,我额外加了一条原子UPDATE做快速路径——直接更新状态,不回读:

from sqlalchemy import update result = db.session.execute( update(SessionSeat) .where( SessionSeat.session_id == session_id, SessionSeat.seat_id.in_(seat_ids), SessionSeat.status == 1 # 关键条件:只有可售状态才允许被锁定 ) .values(status=2, lock_order_no=lock_order_no, lock_expire_time=expire_time) )

这个方案的核心思路是:把“判断状态等于可售”和“更新状态为锁定”合并成一条UPDATE语句。如果数据库受影响行数等于期望锁定的座位数,说明全部锁定成功;如果少于期望数,说明有座位已被其他用户锁走,立即回滚。这种方式最大的好处是省去了SELECT和UPDATE两个步骤之间的往返时间,把锁的范围缩到了最小。

实际使用中,我把两条路都保留:能走原子UPDATE的专业接口优先用UPDATE,遇到需要在锁定前做额外业务判断(比如校验用户会员等级、优惠券)时才用SELECT FOR UPDATE方案。两种方案的优先级是:代码可读性和并发安全优先,性能优化次之。

3.3 超时释放与定时清理

锁座后的15分钟内用户没有完成支付,座位要自动释放回可售池。这个功能我原本想用Redis的过期key实现,但后来发现不能纯粹依赖Redis——万一Redis里key意外丢失,座位就会一直在锁定状态。最终采用的是“定时任务扫描数据库”和“查询时懒释放”双保险。

定时任务用APScheduler实现,每30秒跑一次:

from apscheduler.schedulers.background import BackgroundScheduler from models import SessionSeat def release_expired_locks(): now = datetime.now() expired_rows = SessionSeat.query.filter( SessionSeat.status == 2, SessionSeat.lock_expire_time < now ).with_for_update().all() for row in expired_rows: row.status = 1 row.lock_order_no = None row.lock_expire_time = None # 同时把SeatLock记录状态释放 db.session.commit() scheduler = BackgroundScheduler() scheduler.add_job(release_expired_locks, 'interval', seconds=30) scheduler.start()

“懒释放”的逻辑则写在C端查询接口里:用户查询某个场次的座位时,先顺手把该场次下已过期且状态为锁定的座位置为可售,再做返回。这样即使定时任务偶尔停摆,用户在下一个查询请求时也能触发修复。

实操心得:定时任务和懒释放都只能解决“正常过期”的情况。真实的复杂场景还有:应用服务重启时,内存里的定时任务可能没跑起来;数据库死锁时,某批事务可能被回滚但锁座记录没清理。所以我额外做了一个运维兜底:每天早上6点跑一个脚本,把所有仍在锁定状态且超过24小时的记录强制置为可售,并通知运营人员核查。这个兜底脚本可能每天都会误伤少数“用户锁了没买”的座位,但和座位被永久锁死相比,这个代价完全可以接受。

4. 系统实现中的常见坑与排查实录

4.1 并发压测时发现“卖超座”,排查后是索引缺失

第一次做并发压测就翻车了。我模拟了50个并发用户同时抢同一个场次的同一排座位,压测结果里居然出现了两个订单都支付成功、都认为自己拿到了3排5座的场景。

排查过程很有意思。先看Flask的日志,发现两个请求都在同一毫秒内发出了UPDATE语句,受影响行数也都返回了1。再看数据库,发现session_seat表上虽然有session_id和seat_id两个独立索引,但没有建联合唯一索引或组合索引,导致MySQL在走WHERE session_id=? AND seat_id=?这些条件时,优化器在并发场景下执行计划不稳定,个别请求用回了全表扫描或扫描了多个索引副本,于是两个请求都能读到status=1这行数据的快照。

修复方法很简单:给session_seat表加一个UNIQUE KEY (session_id, seat_id)联合唯一索引。一旦索引唯一性生效,同一场次同一座位的两行就必然只有一句UPDATE能成功,另一个UPDATE受影响行数为0。这个经历给我的教训是:并发问题不一定出在业务逻辑,很可能出在数据库的索引设计上。做高并发系统的第一课就是先检查表结构,把联合唯一索引加齐再做压力测试。

4.2 事务隔离级别引发的脏数据问题

在上线的第二天,我发现一个奇怪现象:用户A锁座未支付,用户B查询座位时看到了“可售”,点击选座却提示座位被占用。用户B很生气,以为系统有bug,实际上这是数据库REPEATABLE READ隔离级别下的正常现象。

当时Flask服务通过SQLAlchemy操作数据库,默认隔离级别跟随MySQL的REPEATABLE READ。在这个级别下,事务A锁定座位尚未提交时,事务B在读已提交的默认快照中看不到事务A的修改,所以查到status=1可售,但实际上这些行已经被事务A锁定,事务B执行UPDATE时要等待锁。体验上就是“能查到、不能选”。

解决方式是调整隔离级别:把选座相关事务调整为READ COMMITTED,让每个查询都能看到其他事务最新提交的数据,避免读到旧的快照。配置SQLAlchemy engine时加上:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

改成READ COMMITTED之后,未提交的锁定仍然是看不到的,但一旦事务A提交,事务B就能立即可见。因为多数事务操作的都是确定的行,READ COMMITTED带来的不可重复读副作用在这个业务场景几乎无影响,但隔离级别的收益是直观的——用户不会再看到“明明显示可售,点进去却被占”的诡异情况。

4.3 死锁:锁的顺序不一致

上二线后的第三天,数据库报警出现了死锁。日志显示Flask的lock_seat接口和Django后台的场次维护接口频繁互相等待。原因是两个服务对同一个场次的锁定顺序不一样:

  • Flask锁座时:先锁session_seat表中的行,再更新seat_lock记录;
  • Django后台排片时:先更新场次表,再批量初始化session_seat和seat_lock。

当两个操作同时发生时,Flask持有session_seat行锁,等待获取seat_lock表的写锁;Django持有seat_lock表的写锁,等待获取session_seat的行锁,就形成了死锁。

解决死锁的核心思路就是统一加锁顺序:强制要求所有涉及session_seat和seat_lock的操作都先获取session_seat相关锁,再处理seat_lock表。同时把Django后台的场次初始化操作包成一个独立事务,内部先锁场次表行,再写session_seat,最后写seat_lock。统一顺序后,死锁就再没出现过。

实操心得:MySQL死锁不一定每次都报死锁错误。有些场景只是极长的锁等待,表现非常接近死锁。排查时先看InnoDB状态:

SHOW ENGINE INNODB STATUS\G;

重点关注LATEST DETECTED DEADLOCK和TRANSACTIONS部分,能看到哪个事务持有哪些锁、在等哪个锁。这个命令在没有死锁时也会显示最近一次的死锁记录,保留现场非常有用。

4.4 高频座位查询的缓存策略

场次座位查询是所有模块里调用频率最高的接口,用户每打开一次选座页面就要刷新一次座位状态。数据库虽然扛得住,但没必要每次都让CPU去做无谓的查询。

我用Redis做了一层缓存,key设计为seat:status:{session_id},value存储整个场次所有座位的状态快照,过期时间60秒。锁座、释放、支付回调节点都要同步删除对应场次的缓存key,让下一次查询能读到最新数据。查询接口优先走Redis,没有缓存再查MySQL并把结果写回。

这里有个需要谨慎对待的问题:缓存过期时间太长会让用户看到滞后座位状态,过期时间太短又体现不出缓存意义。60秒是我实测下来的平衡值。另外,删除缓存时必须用Redis的DEL,而不是设置一个过期时间,删除能让版本一旧一新的问题少很多。

4.5 C端接口的防刷与参数校验

当初这个项目上线后不久,就有用户从浏览器调试窗口猜出了接口结构,直接绕过前端校验,请求lock_seat接口锁定大量座位却不支付。虽然没有造成实际支付损失,但所有热门场次的座位都被占满,严重影响了正常用户。

后来我加了两层防护:

第一层是接口令牌。C端所有写操作统一要求携带一个由登录态签名的token,服务端校验token时效和角色权限,拒绝无登录状态的写请求。

第二层是业务防刷。一个用户同时锁定的座位总数设置上限(根据场次大小动态调整,一般是该场次座位数的10%),超过上限直接拒绝。锁座未支付的失败率如果连续超过阈值,限制该用户的锁座频率。配合定时任务释放超时锁座,再有人想囤票占座就比较难了。

5. 实测效果与后续可扩展方向

5.1 压测数据和性能表现

上线前我用压测工具模拟200个并发用户同时抢同一场次的100个座位,结果如下:

  • 正常用户锁座平均响应时间约80ms,P95响应时间约190ms;
  • 座位冲突率约2.3%,这些用户会收到“座位已被占”的提示并重新选座;
  • 没有出现一例座位超卖或重复支付的订单;
  • 数据库峰值连接数稳定在40以内,CPU负载在安全范围。

这说明行锁+事务+原子更新的组合在中小规模并发下完全够用。如果未来做大型院线系统,可以把锁座状态进一步迁移到Redis的原子操作层,数据库只做最终落账,性能还能再上一个台阶。

5.2 可扩展方向:从单影院版本到多影院平台

当前这套系统本质上是一个“单影院单厅”的完整闭环。如果要做成支持多影院连锁的版本,我建议分两步扩展:

第一,数据模型层面增加影院(cinema)维度,film、hall、show_session、order全部挂到cinema_id下。锁座、座位状态、订单完整性这些核心设计和“单影院版”完全一致。

第二,交易层面增加分账能力,一个订单拆分给影院和平台方。这部分需要和支付渠道仔细核对分账比例、结算周期、退款逻辑,属于偏商务和财务的硬核改造,可以在数据库里增加结算子表来支撑。

选座的并发控制和状态机设计仍然可以复用,因为无论多影院还是平台化,“不能超卖、不能重复锁座、超时要释放”这几个核心约束都不会变。

5.3 运维与监控的补充建议

上线之后我才发现,开发期只关注功能逻辑远远不够。生产环境至少要盯三个指标:

  • 数据库锁等待次数和死锁记录,预防并发场景下的数据库异常;
  • 锁座接口的成功率和平均响应时间,这是C端体验的生命线;
  • 定时释放任务是否正常执行,如果释放任务挂掉,满场次的座位会逐渐变成“人工恢复不了”的脏数据。

我后来加了简单的健康检查接口,定时请求锁座接口探活,一旦响应时间超过500ms就告警通知运维。这些小工具在项目实施前期就可以写,但往往被忽略,等到出了生产事故才后悔没有早点加。

如果从零开始做这个系统,我个人的建议是先写清楚状态机,再动手建表和写接口。选座系统80%的复杂度都集中在“状态如何在正确的时机流转”上,状态机一旦清晰,剩下的只是CRUD和接口联调。数据库的联合唯一索引、事务隔离级别、加锁顺序这些细节,应该在编码前就定好方案,而不是等到出了问题再打补丁。这套项目做完后我最大的收获是:一个看似简单的“选座”,其实是分布式系统并发控制、数据库事务和业务流程设计三块硬功夫的综合实训,做完一轮,很多细节真的就通了。

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

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

立即咨询