剧场订票系统核心设计:座位锁定、事务一致性与并发压测实践
简介面向C#与MySQL学习者的剧场订票管理系统完整工程适合计算机相关专业课程设计、毕业设计或数据库应用开发入门参考。系统围绕剧场售票场景覆盖电话订票、今明后三日内座位预约、以彩色图形展示已订座位、观众资料修改、撤销与改订及报表输出等功能整体按软件工程方法组织客户端与服务器端设计。资源包共116个文件压缩后仅2.25MB核心包含30余个C#窗体源码、界面布局资源、可执行程序、数据库SQL脚本及多套运行配置并留有调试缓存与中间文件目录中按工程、窗口、脚本等类型分层便于对照运行效果和二次修改。目前已有1226人学习下载。通过阅读源码可掌握C/S架构下C#窗体与MySQL数据库的交互方式理解座位状态图形化、订票事务处理、撤销改订逻辑及简单报表生成的具体实现对完成同类订票类系统具有较高参考价值。1. 剧场订票管理系统到底在解决什么问题库存、锁座与超卖的三角关系开票瞬间几十个座位被抢空、用户取消订单后座位迟迟不释放、支付成功但出票记录丢失——剧场订票管理系统和普通商城购物车最大的区别在于它的“商品”是一张张有唯一编号的座位不能像批量库存那样接受超卖。这个系统的核心不是订票页面做得好看而是座位锁定与订单状态的一致性锁座、支付、出票、解锁四个动作必须在一个可靠状态机里闭环。本文面向正在做课设、毕设或者帮小型剧场、演艺空间搭订票系统的开发者从数据库设计与扣减事务、订单状态机、选座可视化到并发压测验证给出一条新手能照着走、熟手能直接对参数的技术路径。2. 座位是核心资产库存扣减的数据库事务设计与两张关键表先给一个反直觉的结论座位售卖不建议在演出场次表里只放一个sold_seats数字然后靠1 / -1来记账。这种设计在低并发下没问题一旦开票瞬间涌进几十个请求极易出现两个请求同时读到sold_seats20各自加 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 ) ENGINEInnoDB; 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普通区 1VIP区 status TINYINT NOT NULL DEFAULT 0, -- 0空闲 1锁定 2已售 3停用 order_no VARCHAR(32), version INT NOT NULL DEFAULT 0 ) ENGINEInnoDB; 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 ) ENGINEInnoDB;这三张表把职责拆得很干净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}, timeout5 ) return seat, resp.status_code, resp.json() with ThreadPoolExecutor(max_workers50) 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判断被漏掉或者缓存删除时机晚了那么几百毫秒。希望这些来自真实维护场景的路径能帮你在做剧场订票管理系统时把坑提前填平少走几段弯路。本文还有配套的精品资源点击获取