资讯动态

飞机订票系统开发实战:并发控制与防超卖设计

发布时间:2026/10/22 8:25:10 来源:尧图企业网站定制
简介飞机订票系统是一套典型的在线订票平台实现适合计算机相关专业学生、课程设计与毕业设计开发者作为项目参考也适合希望了解机票预订业务全流程的初级后端工程师。资源围绕航班信息管理、座位动态分配、票价计算、订单支付等核心模块展开前端侧重搜索、选座、乘客信息填写与支付引导后端涉及数据库、业务逻辑和支付接口对接覆盖面较广。压缩包为ZIP格式大小约14.77MB属于可快速下载的中小型资料包便于直接阅读代码和部署调试具体文件清单以解压后实际内容为准。目前已有1450人浏览学习说明该主题在课程设计和求职准备场景中有一定参考价值。通过这份资源可以重点学习订票系统的表结构设计、座位状态管理、多条件价格计算、支付回调处理以及超卖等并发问题的处理思路同时也能看到移动端适配、缓存与读写分离等性能优化手段在真实项目中的落地方式适合边读代码边整理自己的项目框架。1. 飞机订票系统真正难的不是支付而是多线程下的状态一致性多数人第一次看飞机订票系统以为难点在支付对接和前端页面。实际把项目跑起来、压一批并发请求之后才会发现翻车最频繁的是余票超卖、座位重复分配、支付回调丢失这类状态一致性问题。这套资源把订票主链路完整串了起来航班查询、座位选择、价格计算、订单生成、支付回调都有对应代码后端按常见分层架构组织数据库表也覆盖了航班、座位、用户、订单四类核心实体。适合正在做课程设计、毕业设计或者想快速搭一套练手项目再逐步扩展的开发者。对照着把库表建起来跑通一次从搜索到支付回调的完整流程比只看框架文档要有用得多。2. 先看架构与数据模型单体分层够用别急着拆微服务2.1 为什么这套资源用单体分层而不是微服务很多读者一拿到订票系统就下意识想拆微服务订单服务、航班服务、用户服务各占一个模块。但从落地角度看课程设计和中小型练手项目里微服务带来的分布式事务、服务间调用链、部署成本会吞掉你绝大部分调试时间收益却很低。这套资源的后端采用的是经典单体分层三层职责非常清晰表现层接收 HTTP 请求做参数校验返回 JSON 给前端。业务层处理查航班、锁座位、算价格、创建订单这些核心逻辑事务边界放在这一层。数据层封装对数据库的访问统一走 Mapper 或 DAO 接口不把 SQL 散落在业务代码里。我拿到一份订票系统源码时第一件事就是看它的目录结构和事务注解放在哪一层。如果事务写在 Controller 里说明作者对边界理解有问题如果 Service 里每个 public 方法都挂了Transactional那多半会有锁时间过长、死锁的隐患。这套资源的做法比较常规只有真正涉及多表写入的方法才开启事务查询方法保持无状态方便后续加缓存。单体不等于没有并发能力。常见的做法是前端部署在 Nginx 后面做反向代理后端多实例部署数据库独立一台把会话共享切到 Redis。这套资源里不需要你真去搭集群但代码层面已经把无状态接口、可水平扩展的边界留好了。一个常见误解是单体分层项目显得“不够高级”。实际上你先把单体跑通再把订单模块单独拆出去比一开始就分布式要顺得多。我在实际排错中也发现大部分订票系统的运行期故障根因都在数据库并发控制跟模块拆没拆开没关系。2.2 核心表设计航班、座位、订单、用户四类表数据库设计是这套资源里最值得抄作业的部分。四类核心表的关系可以概括为航班表记录班次与票价座位表按航班拆分出每一个物理座位订单表关联用户与座位用户表保存账户信息。下面是精简后的建表语句重点看字段和约束CREATE TABLE flight ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flight_no VARCHAR(10) NOT NULL COMMENT 航班号, departure_city VARCHAR(50) NOT NULL, arrival_city VARCHAR(50) NOT NULL, departure_time DATETIME NOT NULL, arrival_time DATETIME NOT NULL, cabin_type TINYINT NOT NULL COMMENT 1经济舱 2商务舱 3头等舱, price DECIMAL(10,2) NOT NULL, total_seats INT NOT NULL COMMENT 总座位数, sold_seats INT NOT NULL DEFAULT 0 COMMENT 已售座位数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可售 0停售, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, KEY idx_city_time (departure_city, arrival_city, departure_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flight_id BIGINT NOT NULL, seat_no VARCHAR(4) NOT NULL COMMENT 座位号如 12A, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可售 1锁定 2已售, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_flight_seat (flight_id, seat_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;CREATE TABLE ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, flight_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已出票 4已退票, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME NULL, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_flight_seat (flight_id, seat_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个细节值得留意。第一金额字段用DECIMAL(10,2)绝不用FLOAT或DOUBLE这是被精度问题反复教育后的选择。第二ticket_order表对(flight_id, seat_id)加了唯一约束这个约束是防超卖的最后一层保险——就算代码里锁逻辑写漏了数据库也会拒绝同一航班同一座位的第二条订单。座位表单独拆出来而不是在订单表里存一个座位字符串原因在于选座场景需要“按座位状态刷新座位图”。如果只存字符串前端展示时还得去订单表反查性能差且逻辑绕。拆成独立表后一条 SQL 就能拿到某个航班所有座位的状态按status字段分组渲染即可。2.3 前后端交互搜索接口的参数约定前后端联调时最容易扯皮的是接口参数。这套资源里查询航班的接口约定比较典型前端传三个核心参数后端返回分页结果参数名类型必填说明departureCitystring是出发城市arrivalCitystring是到达城市departureDatestring是出发日期格式 yyyy-MM-ddpageNumint否页码默认 1pageSizeint否每页条数默认 10cabinTypeint否舱位类型不传则查全部对应的后端接口实现可以这样理解GetMapping(/api/flight/search) public PageResultFlight search(FlightQuery query) { String key flight:search: query.getDepartureCity() : query.getArrivalCity() : query.getDepartureDate(); PageResultFlight cached cacheService.get(key); if (cached ! null) { return cached; } // 数据库查询只命中未停售的航班 PageResultFlight result flightMapper.search(query); cacheService.set(key, result, 60, TimeUnit.SECONDS); return result; }这段代码的逻辑是先拼一个缓存 key把“出发城市到达城市日期”作为维度如果 Redis 里有缓存直接返回没有再查数据库并把结果写回缓存过期时间 60 秒。查询条件里query对象需要由前端把上述参数按参数名绑定后端建议做一层参数校验比如日期格式不对直接返回 400而不是让 SQL 层报错。缓存 key 的粒度按“搜索条件”而不是按“单个航班”设计是因为搜索页的高频场景就是同一个条件下多人反复刷新命中率更高。需要注意的是缓存过期时间不宜太长航班价格和余票属于时效数据60 秒已经是比较宽松的值。3. 订票核心链路查询、锁座、算价每一步都有坑3.1 航班余票统计别把余票做成实时 COUNT查询航班详情页时前端需要展示余票数。很多人第一反应是SELECT COUNT(*) FROM seat WHERE flight_id ? AND status 0这个写法在小数据量下没问题但当座位表数据量上来、并发查询变多之后COUNT 会变成数据库的负担。更合理的做法是依赖flight.sold_seats字段。每次成功锁定一个座位就把sold_seats加 1每次取消订单就减 1。余票数直接算total_seats - sold_seats。这套资源里采用的就是这个思路核心在扣减时必须用带条件的 UPDATEUPDATE flight SET sold_seats sold_seats 1, version version 1 WHERE id #{flightId} AND sold_seats total_seats AND status 1;这条 SQL 的巧妙之处在于把“余票是否充足”的判断下沉到了数据库层面。WHERE sold_seats total_seats保证同一时刻只有一个事务能成功把余票扣到临界值后到的更新影响行数为 0代码里据此判断余票不足。相比先 SELECT 再 UPDATE 的两步操作这种单条 UPDATE 天然避免了竞态条件。实际场景里还会遇到一个选择余票数是放 Redis 还是放 MySQL。我一般建议以 MySQL 为准Redis 只做查询缓存。因为一旦缓存与数据库不一致就会出现页面显示有余票、下单却提示没票的情况用户会觉得系统有毛病。与其引入缓存一致性问题不如在单体阶段老实查库等真的扛不住读压力了再上缓存。3.2 座位状态流转从选座到出票的完整事务座位状态有三个0 可售、1 锁定、2 已售。这个状态机的设计是订票系统的核心骨架。用户选座后先锁定座位锁定状态保留一定时间等待支付支付成功后变成已售超时未支付则释放回可售。核心的锁座代码长这样Transactional(rollbackFor Exception.class) public Order createOrder(Long flightId, Long seatId, Long userId) { // 第一步抢占座位status0 时才能改成 1 int seatUpdated seatMapper.lockSeat(seatId, System.currentTimeMillis()); if (seatUpdated 0) { throw new BizException(座位已被锁定请重新选择); } // 第二步扣减航班余票 int flightUpdated flightMapper.increaseSoldSeats(flightId); if (flightUpdated 0) { throw new BizException(航班余票不足); } // 第三步创建订单初始状态为待支付 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setFlightId(flightId); order.setSeatId(seatId); order.setStatus(0); orderMapper.insert(order); return order; }对应 Mapper 里的锁座 SQLUPDATE seat SET status 1, version version 1 WHERE id #{seatId} AND status 0;这段逻辑的关键在于三步操作必须在同一个事务里。seatMapper.lockSeat更新成功代表当前事务拿到了这个座位的“修改权”其他并发事务在相同条件下更新同一行时会阻塞或影响行数为 0从而保证一个座位不会被两个订单同时锁定。Transactional(rollbackFor Exception.class)保证如果第二步余票扣减失败第一步的座位状态也会回滚不会出现座位锁了但余票没扣的中间态。这里有一个常见误用用 SELECT 查出座位状态为 0再执行 UPDATE 去改状态。两个事务可能同时 SELECT 到同一座位状态为 0然后都去 UPDATE最终造成超卖。正确做法是直接以 UPDATE 的结果行数作为判断依据不要先查后改。这套资源里锁座用的是更新即判断的思路这也是它在并发测试下没有大面积翻车的关键原因。3.3 价格计算从基准价到实付价的因子叠加价格计算在订票系统里容易被低估但它直接关系用户投诉和财务报表。定价规则一般包括舱位因子、日期因子、会员折扣三部分。下面是一个典型的计算函数public BigDecimal calcPrice(BigDecimal basePrice, int cabinType, LocalDate flightDate, boolean isMember) { // 舱位倍率经济舱 1.0商务舱 1.5头等舱 2.0 BigDecimal cabinFactor getCabinFactor(cabinType); // 日期倍率节假日和周末上浮常规日期为 1.0 BigDecimal dateFactor getDateFactor(flightDate); BigDecimal memberDiscount isMember ? new BigDecimal(0.95) : BigDecimal.ONE; // 全程用 BigDecimal最后四舍五入到分 return basePrice .multiply(cabinFactor) .multiply(dateFactor) .multiply(memberDiscount) .setScale(2, RoundingMode.HALF_UP); }参数说明basePrice是航班的基准价从flight表读取cabinType决定舱位倍率flightDate用于判断淡旺季isMember控制会员折扣。计算过程不允许使用double原因很直白0.1 * 3用浮点数算出来是0.30000000000000004存进数据库再读出来对账就对不上。退改签费用是价格计算的另一个主战场。常见规则是起飞机前 48 小时以上退票收票面价 10%24 到 48 小时收 20%24 小时内收 50%改签统一收 50 元手续费。这些规则建议做成配置表而不是硬编码在代码里。因为航司规则一变硬编码就得重新发版配置表只需要改一条记录。价格计算函数应该保持纯函数特性同样的输入必须得到同样的输出不要在里面查数据库也不要依赖全局变量。这样方便单元测试覆盖各种因子组合也方便后续接入优惠券、积分抵扣这类扩展逻辑。4. 把系统拉近生产环境支付回调、安全防御与异常兜底4.1 支付处理回调验签与幂等处理的正确顺序与第三方支付平台对接时开发者最容易踩的坑是回调处理顺序写反。拿到支付结果通知后第一步应该是验签第二步是查单幂等第三步才是更新本地订单状态。下面是通用写法PostMapping(/api/pay/callback) public String payCallback(RequestBody PayNotify notify) { // 1. 验签失败直接丢弃不处理业务 if (!signService.verify(notify)) { return fail; } // 2. 幂等检查订单已支付则直接返回成功 Order order orderMapper.findByOrderNo(notify.getOrderNo()); if (order null) { return fail; } if (order.getStatus() 1) { return success; } // 3. 更新订单状态为已支付同步把座位状态改成已售 orderMapper.updateStatus(notify.getOrderNo(), 1); seatMapper.updateStatusByOrder(notify.getOrderNo(), 2); return success; }顺序不能乱。先验签防止伪造回调再查订单确保回调对应的订单真实存在最后做状态更新。幂等检查这一步很多人会省略觉得支付平台不会重复通知。实际上支付平台为了保证送达会有多次重试机制同一个支付成功的通知可能到达两三次。如果代码里有“把待支付改成已支付”的更新重复执行不会出错但如果更新语句是无条件地把状态改成已支付第二次执行时如果订单已经被用户取消就会出现座位释放了又被标记成已售的矛盾状态。回调结果返回也有讲究。处理成功必须返回success让支付平台停止重试处理失败返回fail平台会按间隔重试。不要在处理逻辑里抛异常后返回 200那样平台认为你已经收到了不会重试订单就永远卡在待支付。4.2 安全基线与参数化查询订票系统涉及用户隐私和资金安全防线不能只靠 HTTPS。HTTPS 解决的是传输层明文问题应用层还需要防 SQL 注入和 XSS。SQL 注入的根源是字符串拼接。用 MyBatis 时#{}是预编译占位符安全${}是直接拼接危险。查询航班时应统一使用#{}select idsearch resultTypeFlight SELECT * FROM flight WHERE departure_city #{departureCity} AND arrival_city #{arrivalCity} AND status 1 ORDER BY departure_time LIMIT #{offset}, #{pageSize} /select注意观察#{departureCity}和#{arrivalCity}它们会被解析成绑定参数用户输入里的单引号、分号都不会影响 SQL 结构。如果图省事写成${departureCity}用户传入 OR 11 --就能把条件绕过整个航班表都会被查出来。这类漏洞在安全扫描里属于高危项代码评审阶段就该拦截。XSS 防御的做法是输出编码。后端返回 JSON 时用户名、乘客姓名这类用户可控字段必须转义、、、。如果前端是 Vue 或 React框架默认会转义文本节点但如果用了v-html或dangerouslySetInnerHTML就等于手动关掉了防护。后端兜底的做法是加一个全局过滤器对所有输入的尖括号做转义。4.3 异常兜底支付超时、重复回调与数据补偿任何系统都会遇到异常订票系统尤其需要盯着支付环节。用户下单后不支付、支付后回调丢失、回调成功但数据库更新失败这三种情况处理不好都会造成损失。支付超时的常见方案是定时任务关单。下单时给订单设置过期时间比如 15 分钟定时任务每分钟扫描一次待支付订单超过 15 分钟且没有支付记录的直接改成已取消同时释放座位、回补余票Scheduled(fixedDelay 60_000) public void closeExpiredOrders() { ListOrder expiredOrders orderMapper.findExpired(15, TimeUnit.MINUTES); for (Order order : expiredOrders) { // 关单前再确认一次支付状态防止刚支付成功被误关 boolean paid payClient.queryStatus(order.getOrderNo()); if (paid) { orderMapper.updateStatus(order.getOrderNo(), 1); seatMapper.updateStatusByOrder(order.getOrderNo(), 2); } else { orderMapper.updateStatus(order.getOrderNo(), 2); seatMapper.releaseSeat(order.getSeatId()); flightMapper.decreaseSoldSeats(order.getFlightId()); } } }逻辑说明findExpired查的是状态为待支付且创建时间早于 15 分钟前的订单对每一笔订单先向支付平台发起一次主动查单确认到底付没付。这一步非常关键因为回调可能丢失但支付平台侧的交易记录是权威的。已支付的就补单未支付的就关单释放。定时任务用Scheduled(fixedDelay 60_000)表示上一次执行完再过 60 秒执行下一次避免任务堆积。这套“主动查单被动回调”的双保险是支付系统对账的基础。即使回调全部丢失定时任务也能在 15 分钟边界把订单状态纠正回来。需要注意定时任务一定要做幂等处理同一订单被连续扫描两次时第二次要能自动跳过。5. 常见问题排查订票系统五个典型坑的记录5.1 余票超卖并发下单时同一航班卖出超过总座位数现象用并发脚本同时发起 100 个下单请求航班总共 50 个座位结果订单表里出现了 60 条待支付订单。原因代码里先SELECT sold_seats判断余票是否充足再UPDATE flight SET sold_seats sold_seats 1。两个请求同时读到余票为 10都认为可以下单各自加 1最后余票变成 12。解决把判断逻辑收进 UPDATE 的条件里用“影响行数是否为零”判断余票是否充足。也就是第 3.1 节那条WHERE sold_seats total_seats的写法。这样数据库行锁会保证同一时刻只有一个请求能成功更新。5.2 支付成功但订单还挂在“待支付”现象用户在支付平台完成了付款但订票系统里订单状态还是待支付座位也没释放。原因支付平台的回调请求没有到达系统。可能是网络抖动、系统正在重启也可能是回调处理逻辑里抛了异常但没有返回失败标识平台以为处理失败会重试如果重试也失败就放弃了。解决在定时任务里加主动查单逻辑对所有到期未支付订单向支付平台发查询请求。查到已支付就补单查到未支付才关单。这个机制要放在支付回调之外独立运行两套逻辑互相备份。5.3 价格计算出现精度丢失现象订单金额有时候是 300.29999999999995有时候是 299.99999999999994与前端展示价格不一致对账不平。原因代码里用了double或float做价格计算。浮点数在二进制下无法精确表示大部分十进制小数乘除法累计误差不可控。解决金额相关一律用BigDecimal构造时用字符串构造器new BigDecimal(300.30)不要用new BigDecimal(300.30)。数据库字段保持DECIMAL(10,2)最后统一.setScale(2, RoundingMode.HALF_UP)四舍五入到分。5.4 查询到已取消的航班现象用户能搜出已停飞的航班下单后提示失败或出票失败。原因航班数据从上游同步过来后没有处理“停售”状态或者上游已经删除的航班在本地还留着记录。查询接口没有过滤status字段。解决查询 SQL 强制加AND status 1上游删除的航班做软删除用一个is_deleted字段标记而不是物理删除这样历史订单还能关联到航班快照。同步任务里对停售航班统一更新状态。5.5 同一座位被两个订单锁定现象两个用户同时选同一个座位系统都给弹出了“锁定成功”但最终只有一个能支付成功。原因座位锁定先 SELECT 状态为 0再 UPDATE 状态为 1两个并发请求都通过了第一步的查询。解决锁座的 UPDATE 直接以WHERE status 0为条件影响行数为 0 就视为锁定失败。同时给订单表加(flight_id, seat_id)唯一约束双保险兜底数据库层面的约束比应用层逻辑更可靠。下面把五个坑的排查要点汇总成一张对照表方便你对照检查自己的代码问题根因判断方法修复方向余票超卖先查后改存在竞态并发脚本重复执行UPDATE 带余票条件回调丢失网络或处理异常查待支付超时订单定时任务主动查单精度丢失使用浮点类型打印价格日志BigDecimal 字符串构造航班幽灵显示状态未过滤查已取消航班SQL 加 status 条件座位重复锁定锁座两步操作并发选同一座位UPDATE 条件加状态6. 收尾技巧用并发脚本验证订票系统不会超卖整套代码改完之后怎么证明它真的能扛住并发手工点页面没有意义我一般会写一个并发脚本模拟多个用户同时抢同一个航班的座位然后在数据库里对最终结果做校验。Python 脚本的思路很直接启动 50 个线程每个线程登录后查询可售座位再提交下单请求。为了防止所有线程都去抢同一个座位导致大量失败脚本可以约定每个线程选不同座位但余票扣减的并发压力照样会打到航班表上import threading import requests BASE_URL http://127.0.0.1:8080 def buy_ticket(thread_id): session requests.Session() # 登录获取会话 session.post(f{BASE_URL}/api/user/login, json{username: tester, password: 123456}) # 查该航班的可售座位 resp session.get(f{BASE_URL}/api/flight/1001/seats, params{status: 0}).json() seats resp.get(data, []) if len(seats) thread_id % len(seats): return failed # 每个线程选不同座位但均扣减同一航班的余票 seat_id seats[thread_id % len(seats)][id] order session.post(f{BASE_URL}/api/order/create, json{flightId: 1001, seatId: seat_id}).json() return order.get(code, error) results [] for i in range(50): t threading.Thread(targetlambda: results.append(buy_ticket(i))) t.start() t.join() success_count results.count(0) print(f成功创建订单: {success_count})脚本说明登录接口负责建立会话查座位接口返回可售座位列表thread_id % len(seats)让不同线程分散选座模拟真实场景里不同用户选不同座位order.get(code, error)拿到下单结果约定返回0表示成功。跑完脚本后到数据库里执行一条验证 SQLSELECT f.flight_no, f.total_seats, f.sold_seats, COUNT(o.id) AS order_count FROM flight f LEFT JOIN ticket_order o ON o.flight_id f.id AND o.status IN (0, 1, 3) WHERE f.id 1001 GROUP BY f.id;判定标准只有一条sold_seats和order_count都不能超过total_seats。如果sold_seats大于total_seats说明余票扣减条件没生效如果order_count大于total_seats说明唯一约束或锁座逻辑有漏洞。正常人眼是抓不住并发问题的这条验证链路才是不超卖的后悔药。从那以后我每次写完订票系统都强制自己跑一遍这个并发脚本再去看库里的数字绝不跳过。与其上线后焦头烂额地对账不如交付前多花十分钟。这套资源里已经包含了类似的代码结构和可运行的表结构建议下载后先把库建起来再从搜索到支付走一遍然后把并发脚本换成你自己的接口地址跑一次。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑