飞机订票系统高并发实战:从数据模型到锁策略与对账
发布时间:2026/9/26 7:33:50来源:尧图网络
简介这份飞机订票系统资源面向计算机专业学生、课程设计开发者及全栈入门者提供一套完整的在线机票预订平台参考实现帮助理解从航班查询、座位分配到支付集成的全流程业务逻辑。压缩包为zip格式整体约14.77MB上游未提供具体文件清单但结合描述可知内容应涵盖前端界面、后端业务逻辑、数据库设计及接口集成等模块适合对照学习分布式架构、价格计算、异常处理与安全防护等关键知识点。目前已有1450人学习下载说明其在同类课程设计资源中具有一定参考热度。读者可从中获取航班数据管理、座位动态分配、第三方支付对接、用户账户体系及报表统计等模块的实现思路并借鉴HTTPS加密、防SQL注入与XSS攻击等安全实践以及缓存、读写分离、负载均衡等性能优化手段为毕业设计或项目实战提供可复用的代码框架与排错参考。1. 飞机订票系统从“能跑”到“敢卖票”之间隔着多少坑飞机订票系统这六个字听起来像大学课设的常客但真正动手做过的人都知道它跟“图书管理系统”完全不是一个量级。图书管理最多是增删改查加个借阅状态而飞机订票系统从第一行代码开始就要面对一个残酷现实座位是有限资源钱和座位必须同时动谁先谁后都是事故。我见过太多版本前端页面漂漂亮亮搜索航班、选座、下单一路顺畅结果两个人同时点同一个座位系统双双提示“预订成功”——这种系统上线就是灾难。所以这篇不是讲怎么画ER图而是讲一个能扛住并发、能对账、能退改签的飞机订票系统从数据模型到锁策略到支付回调中间到底有哪些必须做对的事。适合已经写过CRUD、想往真实交易系统迈一步的开发者也适合正在做课设但不想只拿及格分的同学。下面按“先立模型、再扛并发、后堵漏洞”的顺序推。2. 航班、座位、订单三个模型定生死2.1 为什么“航班表座位表”是第一个分水岭很多人的第一版设计是这样的一张航班表字段里塞一个remaining_seats下单就减一。这个模型在单机、低并发、不退款的世界里能跑但它有三个致命伤。第一你无法知道具体哪个座位被卖了选座功能直接残废。第二退票时你只能把数字加回去但加回去的座位和原来的座位不是同一个如果乘客要改签系统给不出“保留原座位”的选项。第三也是最要命的remaining_seats的扣减和订单创建如果不在一个事务里超卖就是必然。正确的做法是把“航班”和“座位库存”拆开。航班表存航线、时间、机型、总座位数这些静态信息座位库存表按“航班座位号”一行一行存每一行有独立的状态字段。这样选座、锁座、退座都是对具体行的操作而不是对一个计数器的加减。代价是数据量变大——一个航班200个座位一天100个航班一年就是730万行但这在MySQL里加好索引完全不是问题。-- 航班表只存静态信息 CREATE TABLE flight ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flight_no VARCHAR(16) NOT NULL, dep_city VARCHAR(32) NOT NULL, arr_city VARCHAR(32) NOT NULL, dep_time DATETIME NOT NULL, arr_time DATETIME NOT NULL, aircraft_type VARCHAR(32), total_seats INT NOT NULL, UNIQUE KEY uk_flight_no_dep (flight_no, dep_time) ); -- 座位库存表一行一个座位状态独立 CREATE TABLE seat_inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flight_id BIGINT NOT NULL, seat_no VARCHAR(8) NOT NULL, cabin_class TINYINT NOT NULL COMMENT 1经济舱 2商务舱, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可售 1锁定 2已售, order_id BIGINT DEFAULT NULL, lock_expire_at DATETIME DEFAULT NULL, UNIQUE KEY uk_flight_seat (flight_id, seat_no), KEY idx_flight_status (flight_id, status) );seat_inventory里status和lock_expire_at这两个字段是后面所有并发控制的基础。status1表示被某个下单流程临时锁住lock_expire_at是锁的过期时间超过这个时间还没支付成功定时任务会把座位释放回status0。order_id在支付成功后写入用于退票时精确定位。uk_flight_seat唯一索引保证同一个航班不会出现两个相同座位号这是最后一道防线。2.2 订单表不是“记录”是状态机订单表最常见的错误是把它当成一个流水账只记“谁买了哪张票”。但飞机订票系统的订单有生命周期待支付、已支付、已出票、退票中、已退票、已改签。每个状态能做的操作不同状态之间的迁移必须被严格约束。如果订单表只有一个status字段而没有状态迁移日志出了问题你连“这张票到底经历了什么”都查不到。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_no VARCHAR(8) NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已出票 3退票中 4已退票 5已改签 6已取消, pay_channel VARCHAR(16), pay_trade_no VARCHAR(64), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, paid_at DATETIME DEFAULT NULL, UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id), KEY idx_flight_status (flight_id, status) ); CREATE TABLE order_state_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, from_status TINYINT, to_status TINYINT NOT NULL, operator VARCHAR(32) NOT NULL, remark VARCHAR(255), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order (order_id) );order_state_log这张表平时看起来多余但一旦出现“用户说付了钱系统说没付”的纠纷它就是唯一的黑匣子。每一次状态变更都写一条日志记录从什么状态到什么状态、谁触发的、备注是什么。支付回调、定时任务、人工客服操作全部走同一个状态迁移函数不允许任何地方直接UPDATE ticket_order SET status...。注意订单号不要用自增ID也不要用时间戳。常见做法是“日期用户ID后四位随机六位”保证唯一且不暴露业务量。3. 并发扣座从“超卖”到“锁得住”的四种方案3.1 悲观锁、乐观锁、Redis锁、数据库唯一索引到底选哪个超卖是飞机订票系统最经典的翻车现场。两个人同时看到“还剩1张”同时点下单如果没有并发控制两张订单都会创建成功。解决思路有四条路每条路的适用场景和代价完全不同。方案一数据库悲观锁。在事务里用SELECT ... FOR UPDATE锁住座位行扣减后再提交。优点是实现简单、强一致缺点是锁持有时间长如果后面还要调支付接口数据库连接会被长时间占用并发量一上来连接池直接爆。方案二数据库乐观锁。给座位表加version字段更新时带WHERE version ?更新影响行数为0就重试。优点是锁粒度小、不阻塞读缺点是冲突高时重试次数飙升用户体验变差。方案三Redis分布式锁。用Redis的SET NX EX抢锁抢到才操作数据库。优点是性能好、适合分布式部署缺点是引入了额外组件锁的过期时间和业务执行时间要配合好否则会出现“业务没做完锁过期了”的经典事故。方案四唯一索引兜底。在订单表上建UNIQUE KEY (flight_id, seat_no)不管前面怎么并发最终只有一个插入能成功。这是最后一道防线不能作为唯一手段但必须有。我一般会组合使用Redis锁做第一层拦截数据库乐观锁做第二层唯一索引做第三层兜底。三层都过了才允许订单创建成功。3.2 用RedisLua做原子锁座的最小实现锁座的核心逻辑是检查座位状态、锁定座位、创建订单这三步必须原子。用Redis的Lua脚本可以保证“检查设置”在Redis端原子执行避免检查完还没设置就被另一个请求插进来。import redis import json import time r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) # Lua脚本原子性地锁定座位 # KEYS[1] 座位锁key, ARGV[1] 订单号, ARGV[2] 过期秒数 LOCK_SEAT_SCRIPT if redis.call(EXISTS, KEYS[1]) 1 then return 0 end redis.call(SET, KEYS[1], ARGV[1], EX, tonumber(ARGV[2])) return 1 def lock_seat(flight_id, seat_no, order_no, expire_seconds300): 尝试锁定一个座位成功返回True失败返回False flight_id seat_no 构成唯一锁key expire_seconds 是锁的自动过期时间防止死锁 lock_key fseat_lock:{flight_id}:{seat_no} result r.eval(LOCK_SEAT_SCRIPT, 1, lock_key, order_no, expire_seconds) return result 1 def unlock_seat(flight_id, seat_no, order_no): 释放座位锁只有锁的持有者才能释放 用Lua保证“检查持有者删除”原子 unlock_script if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end lock_key fseat_lock:{flight_id}:{seat_no} return r.eval(unlock_script, 1, lock_key, order_no) 1lock_seat里的expire_seconds设成300秒意味着用户从锁座到支付完成必须在5分钟内。这个时间不能太短否则用户还在填支付信息锁就没了也不能太长否则座位被占着不付款别人买不了。常见做法是锁座5分钟支付页面倒计时4分30秒留30秒缓冲。unlock_seat用Lua脚本保证只有锁的持有者才能释放防止误删别人的锁。锁座成功后数据库里的seat_inventory要同步更新为status1并写入lock_expire_at。这一步用乐观锁更新UPDATE seat_inventory SET status 1, order_id ?, lock_expire_at DATE_ADD(NOW(), INTERVAL 5 MINUTE) WHERE flight_id ? AND seat_no ? AND status 0;如果这条SQL影响行数为0说明座位已经被别人抢先锁了Redis锁虽然抢到了但数据库状态不对需要回滚Redis锁并提示用户重新选座。这种“Redis和数据库不一致”的情况在分布式环境里一定会出现所以定时对账任务不能省。3.3 支付回调为什么必须做幂等支付回调是飞机订票系统里最容易被忽视的环节。用户支付成功后支付平台会回调你的接口但回调可能重复发送——网络抖动、支付平台重试、你的接口超时但实际处理成功了都会导致同一个订单收到多次“支付成功”通知。如果回调处理不做幂等用户付一次钱可能出两张票或者订单状态被反复覆盖。幂等的实现方式很简单在ticket_order表里加一个pay_trade_no字段回调进来先查这个字段。如果已经有值且和本次回调的交易号一致直接返回成功不重复处理。如果订单状态已经是“已支付”或“已出票”也直接返回成功。def handle_payment_callback(order_no, trade_no, amount): 处理支付回调保证幂等 返回 (success: bool, message: str) order query_order_by_no(order_no) if not order: return False, 订单不存在 # 幂等检查1交易号已绑定 if order[pay_trade_no] trade_no: return True, 重复回调已忽略 # 幂等检查2状态已经是已支付或之后 if order[status] 1: return True, 订单已处理 # 金额校验防止篡改 if abs(float(order[amount]) - float(amount)) 0.01: return False, 金额不匹配 # 开启事务更新订单状态 更新座位状态 写状态日志 with db.transaction(): db.execute( UPDATE ticket_order SET status 1, pay_trade_no ?, paid_at NOW() WHERE id ? AND status 0 , (trade_no, order[id])) db.execute( UPDATE seat_inventory SET status 2 WHERE flight_id ? AND seat_no ? AND order_id ? , (order[flight_id], order[seat_no], order[id])) db.execute( INSERT INTO order_state_log (order_id, from_status, to_status, operator, remark) VALUES (?, 0, 1, payment_callback, ?) , (order[id], ftrade_no{trade_no})) return True, 处理成功handle_payment_callback里两个幂等检查缺一不可。第一个检查防止同一笔交易重复回调第二个检查防止不同交易号但订单已处理的情况。金额校验是安全底线防止有人伪造回调金额。整个处理放在一个数据库事务里订单状态、座位状态、日志三者要么全成功要么全失败。提示支付回调接口不要做任何耗时操作比如发短信、发邮件。这些应该丢到消息队列里异步处理回调接口只负责改状态越快返回越好。4. 退改签与对账那些“事后才想起来”的坑4.1 退票不是把状态改回去那么简单退票的逻辑看起来简单把订单状态改成“已退票”把座位状态改回“可售”。但实际业务里退票涉及退款金额计算、退款手续费、退款到账时间、座位释放时机。如果用户退的是改签过的票还要追溯原始航班和原始价格。这些逻辑如果散落在各个接口里维护起来就是噩梦。我一般会把退票做成一个独立的状态机流程用户发起退票 → 订单进入“退票中” → 计算退款金额 → 调用支付平台退款接口 → 退款成功 → 订单变“已退票” → 座位释放。每一步都有状态记录任何一步失败都能重试或人工介入。def refund_order(order_no, reason): 退票流程状态检查 - 计算退款 - 调用退款 - 释放座位 order query_order_by_no(order_no) if order[status] not in (1, 2): return False, 当前状态不可退票 # 计算退款金额根据退票时间距离起飞时间扣手续费 refund_amount calculate_refund_amount(order) # 状态先改为退票中防止重复退票 with db.transaction(): db.execute( UPDATE ticket_order SET status 3 WHERE id ? AND status IN (1,2) , (order[id],)) db.execute( INSERT INTO order_state_log (order_id, from_status, to_status, operator, remark) VALUES (?, ?, 3, user, ?) , (order[id], order[status], reason)) # 调用支付平台退款外部接口可能失败 refund_result call_payment_refund(order[pay_trade_no], refund_amount) if refund_result[success]: with db.transaction(): db.execute( UPDATE ticket_order SET status 4 WHERE id ? AND status 3 , (order[id],)) db.execute( UPDATE seat_inventory SET status 0, order_id NULL, lock_expire_at NULL WHERE flight_id ? AND seat_no ? AND order_id ? , (order[flight_id], order[seat_no], order[id])) db.execute( INSERT INTO order_state_log (order_id, from_status, to_status, operator, remark) VALUES (?, 3, 4, system, ?) , (order[id], frefund_amount{refund_amount})) return True, 退票成功 else: # 退款失败状态回滚到可退票等待重试 db.execute( UPDATE ticket_order SET status 2 WHERE id ? AND status 3 , (order[id],)) return False, 退款失败请稍后重试calculate_refund_amount里通常按阶梯扣费起飞前7天以上退票扣5%48小时以上扣10%4小时以上扣20%4小时以内扣50%。这些规则要写成配置不要硬编码在代码里因为航司政策会变。call_payment_refund是外部调用必须设置超时和重试但不能无限重试一般重试3次后转人工处理。4.2 定时对账Redis锁和数据库状态不一致的后悔药前面说过Redis锁座和数据库更新之间可能不一致。比如Redis锁成功了但数据库更新时连接超时座位在Redis里被锁着数据库里还是可售。或者用户锁座后一直不支付Redis锁过期了但数据库的lock_expire_at还没到。这些不一致必须靠定时任务来修复。对账任务做两件事第一扫描seat_inventory里status1且lock_expire_at NOW()的记录把座位释放回status0同时删除对应的Redis锁。第二扫描Redis里存在但数据库里status0的锁删除Redis锁。两个方向都要扫才能保证最终一致。def reconcile_seat_locks(): 定时对账修复Redis锁和数据库状态的不一致 建议每分钟执行一次 # 方向1数据库里锁已过期但Redis锁可能还在 expired_seats db.query( SELECT flight_id, seat_no, order_id FROM seat_inventory WHERE status 1 AND lock_expire_at NOW() LIMIT 500 ) for seat in expired_seats: lock_key fseat_lock:{seat[flight_id]}:{seat[seat_no]} # 删除Redis锁不管持有者是谁因为已经过期 r.delete(lock_key) # 数据库座位释放 db.execute( UPDATE seat_inventory SET status 0, order_id NULL, lock_expire_at NULL WHERE flight_id ? AND seat_no ? AND status 1 , (seat[flight_id], seat[seat_no])) # 关联订单取消 if seat[order_id]: db.execute( UPDATE ticket_order SET status 6 WHERE id ? AND status 0 , (seat[order_id],)) # 方向2Redis里有锁但数据库里座位是可售状态 # 这种情况通常是数据库更新失败导致的需要清理Redis锁 # 实际实现可以扫描Redis的seat_lock:*键但生产环境建议用SCAN避免阻塞 pass对账任务每次处理500条避免一次锁太多行。LIMIT 500配合循环执行直到没有过期记录为止。方向2的扫描在生产环境要小心KEYS seat_lock:*会阻塞Redis应该用SCAN命令分批遍历。这个对账任务就是系统的后悔药没有它跑一段时间后座位状态就会乱。5. 避坑与排查五个真实翻车现场5.1 座位锁了但订单没创建用户看到“系统繁忙”现象用户点击下单页面提示“系统繁忙”但重新选座时发现刚才选的座位已经不可选了。原因Redis锁座成功但后续数据库插入订单时失败比如唯一索引冲突、连接超时代码里没有回滚Redis锁。座位在Redis里被锁着数据库里可能还是可售但用户看到的是“不可选”。解决锁座和创建订单必须放在同一个try-catch里任何一步失败都要释放Redis锁。更好的做法是把锁座和订单创建放在一个补偿事务里失败时显式调用unlock_seat。同时对账任务要能覆盖这种情况。5.2 支付回调重复处理用户被出两张票现象用户支付一次但订单状态被更新两次座位表里同一个座位出现两条已售记录。原因支付回调没有做幂等或者幂等检查只查了订单状态没查交易号。支付平台重试回调时第一次回调已经把状态改成“已支付”第二次回调进来发现状态不是“待支付”但代码逻辑没有拦截继续执行了出票操作。解决回调入口先查pay_trade_no如果已有值且和本次交易号一致直接返回成功。同时座位表的更新要带AND status 1条件确保只有锁定状态的座位才能变成已售。唯一索引uk_flight_seat是最后防线但不要依赖它来报错要在业务层就拦住。5.3 定时任务释放了已支付订单的座位现象用户支付成功但几分钟后座位被释放别人买走了同一个座位。原因定时对账任务扫描status1 AND lock_expire_at NOW()时没有检查关联订单的状态。如果支付回调处理慢了订单还是“待支付”但用户其实已经付了钱对账任务会把座位释放。解决对账任务释放座位前先查关联订单的状态。如果订单状态是“已支付”或“已出票”跳过释放只更新座位的lock_expire_at为更晚的时间。或者更简单支付回调成功后立即把座位的lock_expire_at设为NULL对账任务只处理lock_expire_at IS NOT NULL的记录。5.4 改签后原始座位没释放新座位又锁了现象用户改签航班系统锁定了新座位但原始座位一直显示“已售”别人买不了。原因改签逻辑只做了“锁新座位创建新订单”忘了释放旧座位。或者释放旧座位的代码在事务里但新座位锁定失败导致整个事务回滚旧座位释放也失败了。解决改签拆成两步先锁新座位成功后再释放旧座位。两步各自独立事务中间用状态标记。如果释放旧座位失败记录到补偿任务里重试。不要试图在一个大事务里做完所有事外部调用和数据库操作混在一起时大事务是灾难。5.5 航班取消后所有订单状态没同步现象航班因故取消但已购票用户的订单还是“已出票”用户不知道航班没了。原因航班取消是一个批量操作涉及几百个订单的状态变更和退款。如果代码里循环处理每个订单中间某个订单处理失败后面的订单就漏了。解决航班取消做成异步任务先把航班状态改为“已取消”然后发消息到队列由消费者逐个处理订单退款和通知。每个订单的处理结果记录到order_state_log失败的订单进入重试队列。不要在一个HTTP请求里同步处理几百个订单。6. 用状态机对账任务把系统锁死飞机订票系统做到最后核心就两件事状态机管住每一张票的生命周期对账任务兜住所有不一致。状态机让代码有章可循任何状态变更都必须走统一的迁移函数不允许散落的UPDATE。对账任务让系统有自愈能力Redis和数据库不一致、锁过期没释放、支付回调丢失这些在分布式环境里一定会发生的问题靠定时扫描来修复。我自己的习惯是每次加一个新功能先问三个问题这个操作会改变哪些状态状态变更是否原子如果中间失败对账任务能不能发现并修复这三个问题答不上来功能就先别写。飞机订票系统没有玄学所有的“偶尔出错”背后都是状态没管住。把状态机和对账做扎实剩下的就是业务规则的堆叠。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网