资讯动态

在线选座系统实战:Django+Flask混合架构的并发锁座与事务设计

发布时间:2026/10/13 13:21:20 来源:尧图企业网站定制
影院在线选座系统听上去不算什么新鲜东西但真正动手做一轮才发现里面最磨人的不是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.0InnoDB引擎连接池SQLAlchemy在Flask端负责连接管理Django使用内置数据库池缓存Redis用于锁座超时标记、高频热点数据缓存部署Nginx GunicornFlask服务 UWSGIDjango服务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。处理逻辑如下开启事务用SELECT ... FOR UPDATE按条件查询session_seat中目标座位的行逐行检查状态是否为可售如果任一座位不是可售状态回滚并返回“座位已被占用”全部可售则更新状态为锁定写入lock订单号和到期时间提交事务。这个方案的关键在于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(minutesLOCK_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_idsession_id, user_iduser_id, lock_order_nolock_order_no, lock_timedatetime.now(), expire_timeexpire_time, statusactive ) 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(status2, lock_order_nolock_order_no, lock_expire_timeexpire_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, seconds30) 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?这些条件时优化器在并发场景下执行计划不稳定个别请求用回了全表扫描或扫描了多个索引副本于是两个请求都能读到status1这行数据的快照。修复方法很简单给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的修改所以查到status1可售但实际上这些行已经被事务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个座位结果如下正常用户锁座平均响应时间约80msP95响应时间约190ms座位冲突率约2.3%这些用户会收到“座位已被占”的提示并重新选座没有出现一例座位超卖或重复支付的订单数据库峰值连接数稳定在40以内CPU负载在安全范围。这说明行锁事务原子更新的组合在中小规模并发下完全够用。如果未来做大型院线系统可以把锁座状态进一步迁移到Redis的原子操作层数据库只做最终落账性能还能再上一个台阶。5.2 可扩展方向从单影院版本到多影院平台当前这套系统本质上是一个“单影院单厅”的完整闭环。如果要做成支持多影院连锁的版本我建议分两步扩展第一数据模型层面增加影院cinema维度film、hall、show_session、order全部挂到cinema_id下。锁座、座位状态、订单完整性这些核心设计和“单影院版”完全一致。第二交易层面增加分账能力一个订单拆分给影院和平台方。这部分需要和支付渠道仔细核对分账比例、结算周期、退款逻辑属于偏商务和财务的硬核改造可以在数据库里增加结算子表来支撑。选座的并发控制和状态机设计仍然可以复用因为无论多影院还是平台化“不能超卖、不能重复锁座、超时要释放”这几个核心约束都不会变。5.3 运维与监控的补充建议上线之后我才发现开发期只关注功能逻辑远远不够。生产环境至少要盯三个指标数据库锁等待次数和死锁记录预防并发场景下的数据库异常锁座接口的成功率和平均响应时间这是C端体验的生命线定时释放任务是否正常执行如果释放任务挂掉满场次的座位会逐渐变成“人工恢复不了”的脏数据。我后来加了简单的健康检查接口定时请求锁座接口探活一旦响应时间超过500ms就告警通知运维。这些小工具在项目实施前期就可以写但往往被忽略等到出了生产事故才后悔没有早点加。如果从零开始做这个系统我个人的建议是先写清楚状态机再动手建表和写接口。选座系统80%的复杂度都集中在“状态如何在正确的时机流转”上状态机一旦清晰剩下的只是CRUD和接口联调。数据库的联合唯一索引、事务隔离级别、加锁顺序这些细节应该在编码前就定好方案而不是等到出了问题再打补丁。这套项目做完后我最大的收获是一个看似简单的“选座”其实是分布式系统并发控制、数据库事务和业务流程设计三块硬功夫的综合实训做完一轮很多细节真的就通了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑