☰
飞机订票系统高并发库存扣减与订单一致性设计
2026/10/28 19:54:41 网站建设 项目流程

简介:飞机订票系统项目资料包,面向软件开发学习者、课程设计或毕业设计学生,以及对在线预订系统感兴趣的技术人员。该系统覆盖航班查询、座位分配、价格计算、支付对接、用户管理等核心业务,资料围绕完整业务链展开,可帮助读者理解分布式后端架构、航班数据同步、动态座位管理、灵活计价与第三方支付集成的设计思路,同时涉及传输加密、防SQL注入等安全实践,以及异常处理、性能优化和移动端适配等工程化要点。压缩包整体约14.77MB,因平台未提供文件清单,暂不列明内部文件类型;根据资源描述,其中可能包含实现上述功能的相关代码,供开发者参考学习。目前已有1450人浏览学习,适合作为项目复现、系统设计参考或面试项目素材,尤其适合希望将理论落地为可运行项目的开发者。

1. 飞机订票系统不是CRUD:先解决“一张票不能卖两次”再谈其他

临近节假日放票那几分钟,订票系统被刷到超卖——同一个航班同一个舱位,出票数比座位数还多。如果你负责过这类系统,就知道这不是加几个Redis键能糊弄过去的。飞机订票系统的本质不是“增删改查”,而是一套高并发下的库存竞争与状态一致性方案。它要同时管住航班库存、订单状态、支付回调和对账。适合后端开发、架构设计和做交易系统的朋友,往下读,你会看到从表设计到压测参数的实际踩坑和可复现代码。

2. 数据模型先行:航班、舱位、订单、支付流水怎么设计才对

2.1 别把余票存在订单表里:核心表拆分与字段设计

很多人第一次做订票系统,习惯在航班表加一个remain_seats字段,订单生成时UPDATE flight SET remain_seats = remain_seats - 1。这个设计在小流量下没问题,可一旦并发上来,这个字段就是锁竞争的中心,而且它没法表达“哪个舱位还有票”。常见做法是把库存单独拆成一张表,每一行代表一个航班在某一天的一个舱位库存。这样扣减可以精确到销售舱位,也能独立加锁。

下面是核心库存表的建表语句,使用 MySQL InnoDB:

CREATE TABLE `flight_inventory` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `flight_no` VARCHAR(16) NOT NULL COMMENT '航班号', `dep_date` DATE NOT NULL COMMENT '出发日期', `cabin_code` VARCHAR(8) NOT NULL COMMENT '销售舱位代码,如 Y/F/C', `total_seats` INT NOT NULL COMMENT '该销售舱位总座位数', `remain_seats` INT NOT NULL COMMENT '当前剩余座位数', `price` DECIMAL(10,2) NOT NULL COMMENT '销售舱位当前价格', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_flight_date_cabin` (`flight_no`, `dep_date`, `cabin_code`) ) ENGINE=InnoDB COMMENT '航班舱位库存表';

这里有几个关键点。UNIQUE KEY约束了同一个航班、同一天、同一个销售舱位只能有一行库存,这既是防止重复初始化,也是后续扣减语句的天然定位条件。version字段专门为乐观锁准备,下面章节会说明。price冗余在这里,主要是为了让订票时直接读到舱位价格,不必每次去查价格配置表;如果你们价格规则复杂,也可以拆成独立表,但查询会多一次 join,在高并发下不划算。

订单表则独立建模,不要和库存混在一起:

CREATE TABLE `flight_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` VARCHAR(64) NOT NULL, `flight_no` VARCHAR(16) NOT NULL, `dep_date` DATE NOT NULL, `cabin_code` VARCHAR(8) NOT NULL, `seat_no` VARCHAR(8) DEFAULT NULL COMMENT '座位号,值机后分配', `status` TINYINT NOT NULL COMMENT '订单状态:0待支付,1已支付,2已出票,3已取消,4已过期', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB COMMENT '订单表';

order_no是业务订单号,不是自增主键,它由外部生成(通常是雪花ID或Redis自增序列),对外展示、对账查询都用它。UNIQUE KEY在这里是防重复订单的底线——哪怕代码里忘了做幂等,数据库也会把同一订单号的第二次插入挡住。

支付流水也要单独建表,它是解决“收到回调但不知道订单到底付了没有”的关键:

CREATE TABLE `payment_transaction` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `txn_id` VARCHAR(64) NOT NULL COMMENT '支付渠道流水号', `order_no` VARCHAR(32) NOT NULL, `amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL COMMENT '0待支付,1成功,2失败', `callback_time` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_txn_id` (`txn_id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_status` (`status`) ) ENGINE=InnoDB COMMENT '支付流水表';

txn_id是支付渠道(比如支付宝、微信)返回的流水号,同一笔支付不会产生两个流水号,所以这个字段建立唯一索引后,重复回调直接插不进去——这就是最可靠的幂等防线。order_no也加唯一约束是因为我们的设计里一个订单只允许支付一次,如果你允许“合并支付多个订单”,那这个约束就要去掉,改为ORDER_NO + TXN_ID联合唯一。

2.2 舱位等级与价格策略:一个航班多库存的建模方式

飞机的物理座位分头等舱、经济舱,但销售时往往拆得更细,比如经济舱又分 K/L/M 舱,价格和退改规则都不同。所以库存表里我建议用“销售舱位”而不是物理舱位。一个物理舱位可能对应多个销售舱位,各占一部分座位余量。比如航班上有 200 个经济舱物理座位,销售舱位 K 卖 80,L 卖 70,M 卖 50,三者的remain_seats各自独立,互不影响。

这样建模之后,订票系统的核心逻辑就变成:针对flight_no + dep_date + cabin_code这一行库存做扣减。如果你还要做“多人同行”的批量预订,只需要把扣减数量作为参数传递,不需要搞跨舱位的事务。常见的需求“同一个订单不同舱位”,那就拆成多个订单,或者一个订单下多个子订单,反正库存扣减单元是舱位行,不是订单。

这里还有一个容易被忽略的字段:dep_date。飞机订票系统库存按天切分,同一个航班号在 6 月 1 日和 6 月 2 日就是两行库存。设计表结构时一定要把日期放进唯一键,否则你初始化库存时就会互相覆盖。我见过有人把日期放到一行 JSON 里,结果每当跨天放票就要做迁移,折腾到凌晨。正确做法就是上面建表语句里那样,每一行直接对应一天的库存。

3. 余票扣减的并发控制:从乐观锁到Redis原子操作

3.1 先写一个会超卖的扣减代码:看清楚问题在哪

很多初版系统是这么写的:

def purchase(flight_no, dep_date, cabin_code, user_id): inv = select * from flight_inventory where flight_no = ? and dep_date = ? and cabin_code = ? if inv.remain_seats > 0: update flight_inventory set remain_seats = remain_seats - 1 where id = inv.id insert into flight_order(...)

这段代码在并发下必然超卖。两个请求同时读到remain_seats = 1,都通过了if判断,都执行了update,最终剩余座位变成 -1,却生成了两张订单。这是经典的“先查后改”竞态条件。在交易系统里,任何“查询一个条件,然后根据条件做更新”的操作都必须保证原子性,否则就是给自己埋炸弹。

正确的最小修正是把判断和更新放进同一条 SQL。下一小节说怎么做。

3.2 乐观锁版本号扣减:满足中小流量,但要注意重试

最简单可靠的做法是用条件更新,让数据库在更新时才校验库存是否充足:

UPDATE flight_inventory SET remain_seats = remain_seats - 1, version = version + 1 WHERE id = #{id} AND remain_seats > 0 AND version = #{version};

执行完毕后检查affected rows:

  • 返回 1,说明扣减成功;
  • 返回 0,说明要么库存不足,要么版本号被其他请求改了,此时需要重新查询最新库存和版本号,再重试一次。

在 Java 里用 MyBatis 大致是这个样子:

@Update("UPDATE flight_inventory SET remain_seats = remain_seats - 1, " + "version = version + 1 WHERE id = #{id} AND remain_seats > 0 AND version = #{version}") int decreaseStock(@Param("id") Long id, @Param("version") Integer version); // 调用方 int rows = inventoryMapper.decreaseStock(id, version); if (rows == 1) { // 扣减成功,继续创建订单 } else { // 扣减失败,重新查库存,返回“已售罄”或重试 }

注意WHERE里同时写了remain_seats > 0和version = ?。前者保证库存不会减成负数,后者防止“丢失更新”。其实对于库存扣减来说,remain_seats > 0这个条件已经足够防止超卖,加版本号是为了避免 ABA 问题,以及在“先扣后还”的场景下能发现并发修改。如果你确定不会有回补库存操作,只保留remain_seats > 0也能工作,但保留版本号是更规范的做法。

这个方案在数据库连接池开到 50、单库 TPS 在 3000 以内时一般跑得动。再往上,数据库行锁的竞争就会让请求排队,延迟飙升。这时候就要把库存前置到 Redis。

3.3 用Redis Lua脚本做原子扣减:高峰期的标准做法

把库存预加载到 Redis,用 Lua 脚本来保证“判断库存”和“扣减库存”两步的原子性。Redis 执行 Lua 脚本是单线程的,中间不会插入其他命令,所以天然不会超卖。

下面是一个标准的扣减脚本:

-- KEYS[1] 库存键:inventory:{flight_no}:{dep_date}:{cabin_code} -- ARGV[1] 扣减数量 local remain = tonumber(redis.call('get', KEYS[1]) or '0') if remain >= tonumber(ARGV[1]) then redis.call('decrby', KEYS[1], ARGV[1]) return 1 else return 0 end

调用时用 Spring Data Redis 或 Jedis 都可以,关键是脚本内容一致。Java 侧的调用示例:

String lua = "local remain = tonumber(redis.call('get', KEYS[1]) or '0') " + "if remain >= tonumber(ARGV[1]) then " + "redis.call('decrby', KEYS[1], ARGV[1]) return 1 " + "else return 0 end"; DefaultRedisScript<Long> script = new DefaultRedisScript<>(lua, Long.class); Long result = redisTemplate.execute( script, Arrays.asList("inventory:CA1234:2025-06-01:Y"), "1" ); if (result != null && result == 1L) { // 扣减成功,异步落库并创建订单 } else { // 库存不足 }

这里有几个参数要注意。ARGV[1]是扣减数量,多人同行时传 N。库存键的粒度要跟数据库表对齐,否则 Redis 扣减和数据库对不上。Redis 里的初始值必须在系统启动或每日放票前预热,否则get返回 nil,会直接判定无票。

但 Redis 方案不是银弹,最大问题是:如果扣减 Redis 成功,而后续订单创建失败,库存就“凭空消失”了。常见解决办法是把订单创建也变成一个异步流程,或者用“先扣 Redis,再写数据库,再落库订单”的三步流程,配合定时对账把两边不一致的库存纠正回来。我在生产环境里见过更稳妥的做法:Redis 只做前置挡流量,真正扣库存还是回落到数据库的UPDATE ... WHERE remain_seats > 0,Redis 扣减和数据库扣减之间通过消息队列串行化。这样即使 Redis 挂了,数据库也能兜底。

4. 订单状态机与支付回调:别让小订单拖垮整个订票链路

4.1 订单状态:从待支付到已出票的流转边界

一个订单至少要经历四个状态,我用一张表列出来:

状态值状态名含义可流转到
0待支付订单已创建,但用户还没付钱1、3、4
1已支付支付回调成功,款已收到2、3(退票)
2已出票航司已出票,行程确定不可直接取消
3已取消用户主动取消或系统取消终态
4已过期超过支付时限自动关闭终态

状态流转的核心约束是:只能从当前状态流转到允许的下一个状态,不能跳变。比如待支付订单不能被直接置为已出票。实现上最简单的方式是带状态条件的更新语句:

UPDATE flight_order SET status = 1, pay_time = NOW() WHERE order_no = #{orderNo} AND status = 0;

affected rows为 0 就说明订单不是待支付状态,这次回调可能是重复的或者乱序的,不能直接修改。

这里我强调一点:不要用定时任务去“扫描并纠正”所有状态,那会在状态机上引入不确定性。每个状态转移必须由明确的业务事件触发——支付成功、用户取消、超时关闭。状态机逻辑尽量收敛在一个服务里,不要散落在多个接口中。

4.2 支付回调的幂等与对账:回调乱序怎么办

支付渠道的回调可能重复发送,也可能先通知“支付成功”,再过几秒又发一个“支付成功”,甚至极端情况下“支付成功”和“支付失败”乱序到达。我们的处理原则是:以支付流水表为准,流水表有记录就说明处理过,直接忽略。

下面这段伪代码展示了标准处理流程:

public void handlePayCallback(String txnId, String orderNo, BigDecimal amount) { try { // 1. 先插入支付流水,利用唯一索引拦截重复回调 paymentTransactionMapper.insert( new PaymentTransaction(txnId, orderNo, amount, "SUCCESS")); } catch (DuplicateKeyException e) { // txn_id 或 order_no 重复,说明已经处理过,直接返回 return; } // 2. 更新订单状态,只允许待支付 -> 已支付 int rows = orderMapper.payByOrderNo(orderNo); if (rows == 0) { // 订单状态不是待支付,可能是已取消或被其他回调更新过 // 记录告警,进入对账人工处理 alertAndLog(orderNo); } }

先插流水、再改订单,这个顺序很关键。如果先改订单后插流水,万一流水插入失败,订单变成已支付但找不到流水记录,对账时非常被动。反过来,流水插入成功后即使订单更新失败,还有补偿机会——对账任务会扫描“流水成功但订单待支付”的记录,重新触发订单更新。

对账任务是一个定时脚本,简单实现可以是每 5 分钟扫描一次payment_transaction中状态为成功且对应订单状态为待支付的记录,重新回调订单服务。SQL 大致是:

SELECT pt.txn_id, pt.order_no FROM payment_transaction pt JOIN flight_order fo ON fo.order_no = pt.order_no WHERE pt.status = 1 AND fo.status = 0 AND pt.callback_time > DATE_SUB(NOW(), INTERVAL 30 MINUTE);

对账不是只做一次,线上常常会出现回调丢失,所以对账任务的扫描时间窗口要覆盖支付渠道的重试周期,一般至少要扫过去 30 分钟的数据。对于超过 30 分钟还不一致的记录,继续放到人工队列,但这属于极小概率了。

5. 飞机订票系统避坑指南:5个让系统翻车的真实场景

5.1 扣减库存成功,但订单创建失败:两边怎么保持原子

  • 现象:用户刷到有票,点击购买后提示“系统繁忙”,但后台日志显示库存已经被扣掉了,用户再刷时票少了。
  • 原因:扣减库存和插入订单是两个独立操作,前者成功、后者由于事务异常或数据库错误没有执行。
  • 解决:不要先扣库存再创建订单,改成先创建待支付订单,再扣减库存,扣减失败则把订单置为取消状态。扣减库存的 SQL 自带remain_seats > 0条件,如果返回 0,说明库存已不足,订单自动关闭。这样库存永远只会在订单存在的前提下被扣掉,不会“凭空消失”。为了保证最终一致,可以引入本地消息表:在同一个数据库事务里,先插入订单,同时向本地消息表写入“待扣库存”事件,再由异步 worker 去执行库存扣减。这是交易系统的标准方案,适合需要跟第三方接口打交道的场景。

5.2 用户重复提交,生成了多个订单

  • 现象:用户双击“立即购买”,前端发出了两个请求,后端生成了两个待支付订单,库存被扣了两次。
  • 原因:缺少幂等控制,接口被调用几次就执行几次。
  • 解决:前端做按钮防抖只是治标,后端必须做幂等。最直接的做法是在生成订单号时,用“用户ID + 航班号 + 出发日期 + 舱位代码”的 MD5 做唯一的业务键,插入订单时如果违反唯一约束,说明这个用户已经对这个航班舱位下过单,直接返回已有订单。另一种做法是前端提交时带一个idempotent_token,后端用 Redis SETNX 判断 token 是否被消费过。两个方案可以叠加使用,数据库唯一键是最后一道防线,我在生产环境中发现 Token 方案在请求重试时会失效,因为重试的请求可能换了 token,所以数据库唯一键更可靠。

5.3 支付回调到了,订单还是待支付

  • 现象:用户收到银行扣款短信,但系统里订单一直显示待支付,无法出票。
  • 原因:回调接口在处理过程中抛了异常,或者回调线程被重启打断了,支付渠道看到没有返回成功响应就会重试,但重试时如果一直报错,订单就永远卡住。
  • 解决:回调处理必须做到两点——幂等和可重试。幂等靠支付流水表唯一索引,重试则建议把回调消息先存到消息队列,由消费者去处理,处理失败后按指数退避重试,比如 1 分钟、5 分钟、15 分钟、30 分钟各重试一次。不要在主线程里同步调用订单服务,否则任何一个环节慢都会拖垮回调链路。这里额外提醒:调用订单更新时,把order_no和txn_id作为参数传入,方便日志排查。我在线上见过因为回调日志没打order_no,导致问题复现时根本不知道是哪个订单,只能靠用户提供截图。

5.4 余票变负数

  • 现象:运维告警,数据库里remain_seats字段出现了 -1。
  • 原因:代码里用了“先 select 后 update”的流程,并且没有在 update 语句里加remain_seats > 0条件,导致并发请求都通过了查询判断。
  • 解决:所有扣减语句必须带上WHERE remain_seats > 0,这是根本解法。如果用了 Redis Lua 脚本,脚本里也要判断remain >= ARGV[1],不要只写decrby。另外,定时巡检要加一条 SQL:SELECT COUNT(*) FROM flight_inventory WHERE remain_seats < 0,一旦有结果立刻告警。即使有条件更新,也不排除业务代码在极端 bug 下绕过条件,所以巡检是最后一道保险。

5.5 座位号分配错误

  • 现象:两位乘客拿到了同一个座位号,值机时系统才提示冲突。
  • 原因:订票阶段只扣了库存数量,没有锁定物理座位;值机分配座位时也没检查座位是否已被占用。
  • 解决:如果你的产品需求是“订票时直接锁定座位”,那就不应该只维护remain_seats一个数字,而要改成“座位列表”模型。每个座位一行,字段包括flight_no、dep_date、seat_no、status,扣减库存实际是执行UPDATE seat SET status = 'LOCKED' WHERE flight_no = ? AND dep_date = ? AND seat_no = ? AND status = 'AVAILABLE'。这样天然不会重复分配。如果只在值机阶段分配座位,那值机服务也需要对座位表做同样的条件更新。记住一个原则:座位分配永远要用“占用式更新”,不能“查出空位再填”。

6. 上线前压测与性能调优:一个可以照抄的验证流程

订票系统上线前不做压测,等于把超卖和慢响应带到客户面前。我一般用 JMeter 做基础压测,重点看三个指标:QPS、错误率、平均响应时间。压测脚本不考虑太花哨,直接模拟用户行为:查询航班、创建订单、支付回调。

一个能用的压测参数模板是:并发线程数从 50 开始,每轮增加 50,一直加到 200;ramp-up period 设为 10 秒,让线程慢慢起来,避免瞬间压垮服务;循环次数设为 100。这样每轮大约持续 3 到 5 分钟,足够暴露问题。压测后看聚合报告:如果错误率超过 1%,或者平均响应时间超过 500ms,先查数据库连接池等待和 GC 日志,这两个往往先扛不住。

针对订票链路,我常用的调优动作有四个。第一,把库存扣减从数据库移到 Redis 前置,数据库连接压力直接降一半,但这个要配合 3.3 小节的异步落库方案,否则数据不一致。第二,给flight_inventory表的所有查询都走唯一索引,坚决避免全表扫描;订单表按user_id分表,用user_id % 16这样的水平切分,避免单表数据量过大影响查询速度。第三,设置合理的数据库连接池参数:MySQL 连接池初始 30,最大 100,空闲连接超时 5 分钟。我在生产环境见过连接池只有 10,一到放票瞬间就线程阻塞,QPS 上不去还不断报Too many connections。第四,给查询航班接口加 Redis 缓存,缓存 key 为{flight_no}_{dep_date},缓存时间 30 秒即可,不要缓存太久,否则价格或余票更新不及时。

还有一个容易被忽略的点:压测时要关注 Redis 的响应时间。如果 Redis 部署在和业务服务不同的机房,网络延迟会把 Lua 脚本扣减的毫秒级优势消耗掉。我的习惯是把 Redis 和业务服务放在同一个可用区,并给 Redis 单独预留带宽,不要让 Redis 和其他数据缓存混用。最后说一句经验:上线前最好做一次“库存归零演练”,把库存设成 10,并发 100 去抢,最终确认订单数不超过 10 且没有负库存。这个演练跑通了,再应对真实放票才有底气。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询