简介:面向C#与MySQL学习者的剧场订票管理系统完整工程,适合计算机相关专业课程设计、毕业设计或数据库应用开发入门参考。系统围绕剧场售票场景,覆盖电话订票、今明后三日内座位预约、以彩色图形展示已订座位、观众资料修改、撤销与改订及报表输出等功能,整体按软件工程方法组织客户端与服务器端设计。资源包共116个文件,压缩后仅2.25MB,核心包含30余个C#窗体源码、界面布局资源、可执行程序、数据库SQL脚本及多套运行配置,并留有调试缓存与中间文件,目录中按工程、窗口、脚本等类型分层,便于对照运行效果和二次修改。目前已有1226人学习下载。通过阅读源码可掌握C/S架构下C#窗体与MySQL数据库的交互方式,理解座位状态图形化、订票事务处理、撤销改订逻辑及简单报表生成的具体实现,对完成同类订票类系统具有较高参考价值。
1. 剧场订票管理系统到底在解决什么问题:库存、锁座与超卖的三角关系
开票瞬间几十个座位被抢空、用户取消订单后座位迟迟不释放、支付成功但出票记录丢失——剧场订票管理系统和普通商城购物车最大的区别,在于它的“商品”是一张张有唯一编号的座位,不能像批量库存那样接受超卖。这个系统的核心不是订票页面做得好看,而是座位锁定与订单状态的一致性:锁座、支付、出票、解锁,四个动作必须在一个可靠状态机里闭环。本文面向正在做课设、毕设,或者帮小型剧场、演艺空间搭订票系统的开发者,从数据库设计与扣减事务、订单状态机、选座可视化到并发压测验证,给出一条新手能照着走、熟手能直接对参数的技术路径。
2. 座位是核心资产:库存扣减的数据库事务设计与两张关键表
先给一个反直觉的结论:座位售卖不建议在演出场次表里只放一个sold_seats数字,然后靠+1 / -1来记账。这种设计在低并发下没问题,一旦开票瞬间涌进几十个请求,极易出现两个请求同时读到sold_seats=20,各自加 1 后都写回 21,最终一个订单占了两个座位。剧场的座位总数不大,但并发都集中在开票那一刻,锁的粒度放在“单个座位”上最合适,而不是放在一个全局计数器上。
2.1 三张核心表:演出场次、座位与订单
常见做法是拆出演出场次、座位、订单三张表,座位表负责承载状态,订单表负责承载交易,演出场次表只保留聚合信息。我一般会把座位表和订单表分开,而不是把订单号直接挂在座位表上,原因是订单生命周期里会有取消、锁定超时、支付中、已出票多种状态,混在一张表里会让字段职责非常混乱。
CREATE TABLE t_show ( show_id BIGINT PRIMARY KEY, show_name VARCHAR(128) NOT NULL, start_time DATETIME NOT NULL, base_price DECIMAL(10,2) NOT NULL, total_seats INT NOT NULL, sold_seats INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB; CREATE TABLE t_seat ( seat_id BIGINT PRIMARY KEY, show_id BIGINT NOT NULL, row_no VARCHAR(8) NOT NULL, col_no VARCHAR(8) NOT NULL, seat_type TINYINT NOT NULL DEFAULT 0, -- 0:普通区 1:VIP区 status TINYINT NOT NULL DEFAULT 0, -- 0:空闲 1:锁定 2:已售 3:停用 order_no VARCHAR(32), version INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB; CREATE TABLE t_order ( order_no VARCHAR(32) PRIMARY KEY, show_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0:待支付 1:已支付 2:已取消 3:已出票 locked_at DATETIME NOT NULL, expire_at DATETIME NOT NULL, pay_at DATETIME, cancel_at DATETIME ) ENGINE=InnoDB;这三张表把职责拆得很干净:t_seat.status表示物理座位当前是空闲、锁定还是已售,t_order.status表示交易进行到哪一步,t_show.sold_seats只是给后台展示用的聚合数据,不作为扣减依据。注意t_seat里放了version字段,后面做乐观锁扣减会用到,order_no用来反查订单,避免座位的状态变更无从溯源。
参数方面,status字段强烈建议用TINYINT而不是字符串状态码,字符串可读性好但占空间、索引效率低,踩过坑之后你就知道数字状态配合注释才是最省心的。expire_at是锁座超时时间,一般由后端在创建订单时写入,比如当前时间加 300 秒,这个值要仔细调,设短了用户选完座来不及支付,设长了高流量时段座位会被无效占用。
2.2 用 UPDATE 条件回写代替 SELECT FOR UPDATE:把扣减做成原子操作
很多人第一次做订票系统时,会在事务里先SELECT ... FOR UPDATE锁住座位行,然后判断状态再 UPDATE。这个写法没有错,但它要求整个事务串行化,并且在高并发下会产生大量锁等待,数据库连接池很容易被打满。更轻量的方案是直接用 UPDATE 的条件回写,把“查询并判断”变成“带条件更新并检查影响行数”。
-- 扣减座位:只有座位当前是空闲状态时才允许置为锁定 UPDATE t_seat SET status = 1, order_no = #{orderNo}, version = version + 1 WHERE seat_id = #{seatId} AND show_id = #{showId} AND status = 0; -- 检查受影响行数,若为 0 说明座位已被别人锁定或已售出 -- 若为 1 说明本次扣减成功,可以继续插入订单这段 SQL 的关键有两点:一是WHERE条件里带status = 0,用数据库的行锁机制保证同一时刻只有一个请求能把座位从空闲改成锁定;二是“更新成功”不是靠 SELECT 的结果判断,而是靠java.sql执行 UPDATE 后返回的affected rows,影响行数大于 0 才算抢到座位。这个模式比先 SELECT 再 UPDATE 少了一次查询往返,更重要的是它把并发控制下推到数据库引擎,应用层不需要维护额外的锁对象。
执行完 UPDATE 后,紧接着插入订单记录并提交事务。如果把这两步放在同一个事务里,一旦插入订单失败,UPDATE 会自动回滚,座位回到空闲状态;如果 UPDATE 返回 0,则说明座位状态已经不是空闲,直接抛业务异常提示用户“座位已被选择”,整个流程不需要人工补偿。参数建议:事务隔离级别用READ_COMMITTED就足够,REPEATABLE_READ在这个场景下没有额外收益,反而让锁范围更不可控。
2.3 什么时候需要悲观锁,什么时候用乐观锁
悲观锁和乐观锁的选择,本质是看冲突概率。开票瞬间,热门场次同一排座位的竞争非常激烈,冲突概率高,适合用上面的 UPDATE 条件回写,本质上是一种“数据库层面的悲观锁”,但它把锁范围压到了单行,而不是锁住整张表或整个区间。对于一些低热度场次,或者做选座页展示时的状态查询,不需要任何锁,直接读就行。
乐观锁适合放在t_show.sold_seats这种聚合字段上,比如后台需要一个“当前已售占比”的统计数字,涉及多个座位同时变动,你对它做version校验更新:
UPDATE t_show SET sold_seats = sold_seats + 1, version = version + 1 WHERE show_id = #{showId} AND version = #{oldVersion} AND sold_seats < total_seats;这里更新失败的概率就是两个用户同时买同一个场次不同座位的瞬间,它们同时读到同一个version,只有一个能更新成功。失败后不要立刻重试,而是重新查一下当前version再决定是否继续。对订票主链路来说,座位状态变更走乐观锁不是好选择,因为剧场的座位竞争往往高度集中在同一时刻,乐观锁的重试逻辑会放大请求量。
3. 订单状态机与支付回调:把“锁座-支付-出票”串成一条不丢状态的链路
订票系统最容易出问题的地方不在选座那一秒,而在订单创建之后的整个生命周期。用户锁了座不支付、支付回调重复推送、出票后订单状态被错误回退,这些都是真实上线后才暴露的问题。要避免这些,需要把订单状态的变化路径画清楚,并且让每次状态迁移都满足幂等条件。
3.1 状态机设计:从 LOCKED 到 SOLD 只有一条合法路径
我用过的状态机分四个状态:待支付、已支付、已取消、已出票。待支付状态下,系统保存了锁定的座位和过期时间;支付成功后进入已支付,此时座位应该从锁定变为已售;已支付再触发一次出票动作进入已出票,这时才给前端展示电子票信息。取消动作比较复杂:用户主动取消可以发生在待支付阶段,超时释放一般也只处理待支付订单,已支付订单如果想退票,应该走独立的退款流程,而不是复用取消逻辑。
| 当前状态 | 触发动作 | 目标状态 | 座位状态变化 | 备注 |
|---|---|---|---|---|
| 待支付 | 用户支付 | 已支付 | 锁定→已售 | 支付回调校验通过后触发 |
| 待支付 | 用户取消 | 已取消 | 锁定→空闲 | 需要释放座位并更新场次聚合 |
| 待支付 | 锁座超时 | 已取消 | 锁定→空闲 | 定时任务扫描 expire_at |
| 已支付 | 出票成功 | 已出票 | 已售→已售 | 生成电子票记录 |
| 已支付 | 退款 | 已取消 | 已售→空闲 | 独立退款接口,需财务侧介入 |
这张表的价值在于规定了“只有待支付订单可以取消,只有已支付订单可以出票”,任何跨状态的非法迁移都直接拒绝。我在实际项目里会往t_order的status字段上加“当前状态 + 目标状态”的联合校验,最简单的实现是 UPDATE 语句带上status = #{expectStatus}条件,影响行数为 1 才允许下一步。
3.2 创建订单接口与锁座超时释放
创建订单接口要做的事,用文字描述就是一句话:尝试把座位从空闲改为锁定,成功了就生成一条待支付订单。但实现时有两个细节必须处理:锁座超时的兜底释放,以及释放操作的幂等。
@Transactional public OrderCreateResult createOrder(CreateOrderRequest req) { // 1. 条件更新座位:从空闲置为锁定 int affected = seatMapper.lockSeat(req.getShowId(), req.getSeatId(), req.getOrderNo(), req.getUserId()); if (affected == 0) { throw new BizException("SEAT_OCCUPIED", "座位已被选择或已售出"); } // 2. 生成订单,超时时间默认 300 秒 OrderDO order = new OrderDO(); order.setOrderNo(req.getOrderNo()); order.setShowId(req.getShowId()); order.setSeatId(req.getSeatId()); order.setUserId(req.getUserId()); order.setStatus(OrderStatus.WAIT_PAY.getCode()); order.setLockedAt(new Date()); order.setExpireAt(DateUtils.addSeconds(new Date(), SEAT_LOCK_SECONDS)); orderMapper.insert(order); // 3. 更新场次聚合数量(可选,用于后台展示) showMapper.increaseSoldSeatsWithVersion(req.getShowId()); return OrderCreateResult.success(order.getOrderNo(), order.getExpireAt()); }这段逻辑里,lockSeat对应的 SQL 就是第 2 章那段带status = 0条件的 UPDATE。SEAT_LOCK_SECONDS请根据你的支付渠道耗时来调:微信/支付宝的收银台一般给 5 分钟比较稳妥,如果接的是线下转账或人工审核,需要延长到 15 分钟以上,否则用户刚提交订单想去转账,订单已经超时释放了。
超时释放的兜底任务,我放在定时任务里做。这里最容易犯的错误是定时任务只扫一次expire_at,但没考虑分布式部署下多个节点同时扫描同一批订单。给释放动作加一个条件:status = 待支付 AND expire_at < NOW(),然后执行 UPDATE 并把受影响行数当作本次释放的凭证,只有行数为 1 的节点才继续去释放座位。
3.3 支付回调处理:先幂等,再出票
支付回调是订票系统里最容易写错的一段逻辑。第三方支付平台为了保证回调送达,通常会重试多次,如果你的回调处理函数没有幂等保护,一个订单可能被处理两次,造成重复出票。
public void handlePayCallback(PayCallbackRequest req) { String orderNo = req.getOrderNo(); // 1. 幂等检查:订单状态只有在“待支付”时才能推进 int updated = orderMapper.updateStatusWithExpect(orderNo, OrderStatus.WAIT_PAY.getCode(), OrderStatus.PAID.getCode()); if (updated == 0) { // 状态不是待支付,说明已处理过,直接返回成功 return; } // 2. 更新座位为已售 seatMapper.updateStatusByOrderNo(orderNo, SeatStatus.SOLD.getCode()); // 3. 生成电子票记录,这里用 order_no 唯一索引兜底 try { ticketMapper.insert(TicketRecord.buildFromOrder(orderNo)); } catch (DuplicateKeyException e) { // 已有票记录,忽略即可 } }updateStatusWithExpect是带期望状态的 UPDATE,影响行数为 0 就说明当前状态不是待支付,直接安全返回。这样无论回调重试多少次,只有第一次能真正推进状态。生成电子票时再套一层唯一索引兜底,即使状态机判断漏了,数据库主键冲突也能拦住重复出票。
支付回调里不建议把“释放座位”和“锁定座位”的逻辑混在一起,回调要做的只是把订单状态往前推,座位状态的变化应该由状态机驱动。比如已支付的订单,座位一定是从锁定变成已售;如果座位状态已经是已售,而订单还是待支付,说明状态机被异常中断了,这种情况需要报警人工介入,而不是靠回调代码自己修。
4. 选座可视化与高并发削峰:座位图、Redis 预扣减与压测验证
剧场订票和普通电商的另一个显著差异是选座页需要实时展示座位占用情况。红点表示已售、灰点表示已锁定、绿点表示可售,这张图如果刷新不及时,用户会看到明明点选了座位却提示已被锁定。这里的技术难点不是画图,而是状态同步的实时性和最终一致性怎么平衡。
4.1 选座图的后端状态聚合
前端座位图请求一次全局状态,后端返回整个剧场的座位列表。对于几百个座位的场次,返回全量 JSON 也才几十 KB,完全不需要按区块分批加载。但如果座位数上万,比如大型场馆,全量返回就会变慢,这时候可以按区域拆分接口,区域之间独立刷新。
-- 查询某场次全部座位状态,用于选座页初始化 SELECT seat_id, row_no, col_no, seat_type, status FROM t_seat WHERE show_id = #{showId} ORDER BY row_no, col_no;在这个查询上不需要加任何锁,读已提交的数据即可。需要留意的是,status字段的缓存策略:如果把座位状态放到 Redis 里,查询性能会更好,但得处理缓存和数据库的一致性;如果直接查库,高频轮询对数据库有压力,但结果永远是对的。我一般会中间加一层 Redis,值用座位状态码,key设计成seat:status:{showId}:{seatId},更新时先改数据库再删缓存,选座页读时先查缓存再回源数据库。
4.2 Redis 预扣减:把真正的压力挡在数据库外面
数据库 UPDATE 是可靠,但开票瞬间把所有请求都打到数据库上,连接池一定会被打满。常见的削峰做法是在 Redis 里先做预扣减:每个场次开票前,把全部座位状态写入 Redis,用户选座时先通过 Redis 的SETNX抢占座位锁,抢到了再写数据库。这个过程像给系统加了一个前置闸门。
# Redis 预扣减:为指定座位加锁 SET seat_lock:{showId}:{seatId} {orderNo} NX EX 300NX表示只有 key 不存在时才设置成功,EX 300表示锁 300 秒后自动过期。这个命令天然是原子的,非常适合做座位抢占。设置成功表示该座位被当前订单锁定,设置失败说明别人已经抢到了。抢到座位锁以后,继续走数据库事务创建订单;数据库事务失败时,需要主动删除 Redis 里的锁作为补偿。
Redis 预扣减不能替代数据库扣减,它是数据库的第一道挡板。因为 Redis 锁有过期机制,如果用户支付流程处理太久,锁过期了,座位会被自动释放给下一个人;这时用户的订单即使创建成功,最后也会因为座位状态不一致而无法出票。所以 Redis 锁的有效期必须大于整条订单创建链路的最大耗时,并且要配合数据库状态机做最终兜底。
4.3 用压测脚本验证开票瞬间
做了设计,不压测等于白做。订票系统最核心的压测目标是:开票瞬间 1 秒内涌入的并发请求,能不能保证座位不超卖、锁座不遗漏。我常用ab做快速验证,再用 Python 多进程模拟更接近真实场景的并发行为。
ab -n 2000 -c 50 \ -p create_order.json \ -T application/json \ -k \ http://127.0.0.1:8080/api/order/create-n 2000表示总共 2000 个请求,-c 50表示 50 个并发连接,-k开启 keep-alive。如果测试目标是单个座位,你会看到只有第一个请求成功,其余请求全部返回“座位已被选择”,且数据库里该座位的status始终保持锁定。
更贴近真实情况的模拟是针对不同座位做并发抢票,比如 50 个并发用户抢 48 个座位。我用 Python 的ThreadPoolExecutor写简单的压测脚本,核心思路是每个线程随机选一个座位号发起请求,最后统计哪些座位被多个订单同时锁定。
from concurrent.futures import ThreadPoolExecutor import random import requests SEATS = [f"{row}-{col}" for row in range(1, 3) for col in range(1, 25)] def grab_seat(seat): resp = requests.post( "http://127.0.0.1:8080/api/order/create", json={"seatId": seat}, timeout=5 ) return seat, resp.status_code, resp.json() with ThreadPoolExecutor(max_workers=50) as executor: tasks = [] for _ in range(200): seat = random.choice(SEATS) tasks.append(executor.submit(grab_seat, seat)) results = [t.result() for t in tasks]压测完要检查两件事:一是同一个seatId是否有多次成功返回,二是数据库里座位表的状态和 Redis 里的锁定状态是否一致。如果出现同一个座位两次成功,基本可以断定扣减 SQL 或预扣减逻辑存在漏洞,不要急着调大数据库连接池,先把并发控制重新捋一遍。
5. 剧场上线前必看的五个订票翻车点:事务、超时、幂等与连接池
下面这五条,是我在类似项目上真实遇到过的现象、原因和解法。每条都对得上具体代码路径,排查的时候可以直接对着看。
5.1 超卖:一个订单占了两个座位,后台场馆图直接花掉
现象是对同一场演出,后台统计到已售座位数大于实际座位数,查看明细发现同一个座位关联了多笔订单。原因是扣减逻辑没有使用条件 UPDATE,而是先 SELECT 再 UPDATE,两个请求同时读到座位空闲,于是都插入订单。解决方法是把扣减语句改成UPDATE ... WHERE status = 0,并检查受影响行数;受影响行数为 1 才允许创建订单,否则事务直接回滚。这个改动做完,超卖基本绝迹。
5.2 锁座不释放:选了座忘了付钱,座位被白占半小时
现象是热门场次一堆座位显示“已锁定”,但用户座位图上等了很久都不释放。原因是只做了创建订单时的锁定,没有超时释放的兜底任务。解决方法是订单表记录expire_at,后端每分钟扫一次待支付且已过期的订单,把座位状态改回空闲,同时把订单置为已取消。注意定时任务要按expire_at < NOW() AND status = 待支付来过滤,且 UPDATE 要带状态条件,防止把正在支付的订单提前释放。
5.3 支付回调重复通知导致重复出票,用户收到两张票
现象是同一笔订单生成了两条电子票记录,用户反馈重复扣款。原因是支付回调处理函数没有幂等保护,平台重试了几次,每次进来都往下游执行出票。解决方法是先执行UPDATE t_order SET status = 已支付 WHERE order_no = ? AND status = 待支付,影响行数为 0 就直接返回成功;出票记录表再建唯一索引兜底。这两个层次的保护同时存在,才敢说回调安全。
5.4 开票瞬间数据库连接池打满,其他接口跟着雪崩
现象是压测时只有订票接口在请求,但后台配置的调价接口、查询接口也变慢,监控显示数据库连接池被打满。原因是每次订票请求持有一个长事务,事务里包含了不必要的远程调用或慢查询。解决方法是把事务范围压缩到最小:创建订单的事务只包含 UPDATE 座位、INSERT 订单两步,聚合字段的更新可以放到事务外异步执行;另外把场地基本信息、票价配置等只读数据缓存到本地内存或 Redis,减少数据库查询次数。
5.5 选座页状态和实际扣减结果对不上,用户明明看到空闲却提交失败
现象是用户刷新座位图看到某个座位是绿色(可售),点击提交却被提示座位已锁定。原因是选座页读的是 Redis 缓存,而缓存过期时间设置太长,数据库里的座位状态已经变了,缓存还没更新。解决方法是更新座位状态时主动删除对应 Redis 的 key,而不是等它自然过期;同时选座页每次提交订单时以数据库扣减结果为准,不要在提交前只检查前端的座位状态。选座页展示可以做到秒级一致,但下单必须做到强一致。
6. 把链路跑通后的两个进阶验证:并发压测脚本与座位释放的主动通知
第 5 章提到的兜底任务能解决座位释放问题,但它的释放是分钟级延迟。对用户来说,盯着倒计时等座位,结果释放后还得手动刷新才能看到,体验很割裂。我一般会再加一个主动通知机制:定时任务释放座位后,向正在选座的用户推送一条 WebSocket 消息,前端收到后立刻刷新对应座位的状态。
const ws = new WebSocket(`wss://your-domain/seat-channel/${showId}`); ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'SEAT_RELEASED') { const seatEl = document.querySelector(`[data-seat-id="${msg.seatId}"]`); seatEl.classList.remove('locked'); seatEl.classList.add('available'); } };WebSocket 只承担“主动通知”职责,前端收到消息后可以不重新拉全量,只更新消息里携带的seatId状态,这样省流量也省渲染时间。注意 WebSocket 连接本身会占用后端资源,需要按场次做连接数上限限制;如果预估同时选座人数较多,可以把推送通道做成可降级的:推送服务挂了,选座页的定时轮询还能兜底。
另一个进阶验证是压测脚本的固化。我会把第 4 章的 Python 抢座脚本改成带断言的可执行测试:压测结束后,检查数据库里每个seatId最多只有一个有效订单、Redis 锁无残留、t_show.sold_seats与实际订单数一致。这三个断言通过,才认定本轮版本可以上到测试环境。
我自己在多次做这类系统后养成的一个习惯是:每改一个并发相关逻辑,就先跑一轮压测,再检查一次座位表数据,别相信代码 review 能看出所有并发问题。很多翻车都不是思路错,而是条件更新里的一个status判断被漏掉,或者缓存删除时机晚了那么几百毫秒。希望这些来自真实维护场景的路径,能帮你在做剧场订票管理系统时把坑提前填平,少走几段弯路。
本文还有配套的精品资源,点击获取