1. 项目启动:m241这个编号背后的真实需求
接手"m241航班订票管理系统"这个项目的时候,其实挺有意思的。编号m241是实训基地的项目标识,但落到实际开发上,需求一点都不抽象——就是一个能查航班、能订票、能管订单的Web系统。
很多同学拿到这类系统第一反应是"这不就是个CRUD吗",但实际上手之后会发现,航班订票和普通的图书管理、商品管理完全不同。它有两个非常棘手的地方:余票状态的一致性问题和订单状态的多阶段流转问题。
先说余票。图书管理系统里库存减一就是减一,但航班订票里,用户从"选航班"到"最终支付"之间有一个时间窗口。这个窗口里别人也在看同一个航班,A用户锁定的座位,B用户能不能买?什么时候释放?这就是典型的并发场景。
再说订单状态。一个订单从创建到完成,要经历待支付、已支付、已出票、改签中、已退票等状态,每一步都有操作边界和校验规则。这些状态流转如果写在散乱的if-else里,后期维护就是灾难。
所以这篇分享我不会停留在"表结构设计+接口实现"的层面,重点会放在为什么这么设计、哪些地方容易出问题、实际编码时踩过的坑。项目用的是Spring Boot + Vue全家桶 + MySQL,这套组合在同类管理系统里覆盖率最高,如果你正在做类似的订票类项目,这篇文章的很多思路可以直接平移过去。
2. 需求梳理与范围界定:分清"必须做"和"不着急做"
2.1 核心角色与功能域划分
航班订票系统涉及的角色比表面上看起来要多。除了乘客和航班管理员,还有一个隐形角色——系统运维者。乘客关心的是"能不能快速找到合适的航班并完成订票",管理员关心的是"航班信息怎么维护、订单怎么处理异常",运维者关心的是"系统挂了能不能快速发现问题"。
我把系统划分为六个功能域:
- 航班信息管理:航班的增加、修改、停售、删除(逻辑删除),敏感操作需要留痕
- 航班查询:按出发地、目的地、日期查航班,也支持按航班号精确查询
- 在线订票:选航班 -> 填乘客信息 -> 生成订单 -> 支付 -> 出票
- 订单管理:用户侧查看订单列表/详情、退票、改签
- 后台管理:航班维护、订单查询、基础统计
- 用户体系:注册、登录、身份信息维护
如果是一个团队开发,这六个模块可以并行。但单人开发时,有个顺序问题:先做用户体系还是先做航班模块?
我的建议是先把航班模块和订票主流程跑通,再做用户体系。原因很实际:订票流程是系统的核心价值,用户体系是外围支撑。先用预置用户打通主流程,比一上来就做注册登录、找回密码、验证码这些边角功能高效得多。
2.2 一个容易被忽视的功能:余票展示口径
需求评审时有个细节值得留意——航班列表页显示的余票数,和用户真正下单时可用的座位数,这两个口径很可能不一致。
原因在于座位锁定机制。用户点下单但未支付时,系统通常会锁定座位(术语叫"预占")。锁定期内,列表页余票应不应该扣除这部分?
如果扣除,用户看到的余票可能偏少,但能保证"看到就能买到";如果不扣除,列表页显示充足,用户点进去提交订单时却提示"余票不足",体验很糟糕。
这个决策没有标准答案。我当时选的方案是:列表页余票 = 总座位数 - 已售座位数 - 锁定中的座位数,展示的就是当前可购买的真实数量。代价是锁定的座位在列表页立刻"消失",高峰期可能造成"看起来票少"的错觉,但换来的是下单成功率。后来观察运行日志,这个口径的逻辑一致性更好,没有出现过"页面显示有票但下单失败"的投诉。
3. 数据库设计:从ER图到建表语句的完整推导
3.1 为什么不建议直接用"一张订单表打天下"
很多初学者设计订票系统时,习惯把订单做成一张大宽表:订单ID、用户ID、航班ID、出发地、目的地、出发时间、价格、座位号、状态...全塞进一张表里。
这个设计的优势是查询简单,一条SQL就能查出订单详情。但问题在数据冗余和业务扩展。一个订单可能包含多张票(一人订多张,或帮同行人订票),如果把乘客信息也冗余进去,拆票退票时就会很痛苦。
我采用订单主表 + 乘客明细表的两层结构:
-- 订单主表 CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号,业务展示用', `user_id` bigint NOT NULL COMMENT '下单用户', `flight_id` bigint NOT NULL COMMENT '关联航班', `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已出票 3已退票 4已改签', `created_at` datetime NOT NULL, `paid_at` datetime DEFAULT NULL, `updated_at` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_flight_id` (`flight_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;-- 乘机人明细表 CREATE TABLE `order_passengers` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_id` bigint NOT NULL COMMENT '关联订单主表', `passenger_name` varchar(64) NOT NULL, `id_card_no` varchar(32) NOT NULL COMMENT '身份证号', `seat_code` varchar(8) DEFAULT NULL COMMENT '座位号', `ticket_price` decimal(10,2) NOT NULL, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这个结构的好处是:一个订单可以灵活对应1~N个乘客,退票时单独操作某个乘客对应的票即可,订单主表只需要汇总金额和对账。
3.2 航班表设计:舱位与价格的建模
航班信息表看起来简单,但"舱位"这个概念如果建模不好,后面会非常别扭。国内航班通常分经济舱、商务舱、头等舱,不同舱位价格不同、座位数不同。如果把舱位做成三个字段(economy_seats, business_seats, first_seats),确实直观,但如果有特惠舱、超值舱这类动态舱位呢?
权衡之后我用了航班主表 + 舱位子表的拆分方案:
CREATE TABLE `flights` ( `id` bigint NOT NULL AUTO_INCREMENT, `flight_no` varchar(16) NOT NULL COMMENT '航班号,如MU5123', `departure_city` varchar(64) NOT NULL, `arrival_city` varchar(64) NOT NULL, `departure_time` datetime NOT NULL, `arrival_time` datetime NOT NULL, `aircraft_type` varchar(32) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_flight_no` (`flight_no`), KEY `idx_departure` (`departure_city`, `departure_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `flight_cabins` ( `id` bigint NOT NULL AUTO_INCREMENT, `flight_id` bigint NOT NULL, `cabin_type` varchar(16) NOT NULL COMMENT '经济舱/商务舱/头等舱', `cabin_code` varchar(8) NOT NULL COMMENT '舱位代码,用于生成座位号前缀', `total_seats` int NOT NULL, `sold_seats` int NOT NULL DEFAULT '0', `locked_seats` int NOT NULL DEFAULT '0', `price` decimal(10,2) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_flight_cabin` (`flight_id`, `cabin_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;余票通过一个计算字段得出:total_seats - sold_seats - locked_seats。这里没有用MySQL的虚拟列,因为锁定的座位数在事务里频繁变化,直接用SQL计算更灵活。
3.3 座位数据的两种方案:预生成与按需分配
订票系统里"座位"怎么管理?有两种做法。
方案A:座位表预生成。创建航班时按舱位座位数生成所有座位记录(31A、31B、31C...),订票时从中选择一个状态为"空闲"的座位并锁定。优点是座位状态直观,可以实现选座功能;缺点是数据量大,A320经济舱156个座位,每建一个航班就要插入156条记录,航班多了之后表膨胀很严重。
方案B:只记录已占用座位。座位不预先生成,订票时由服务端分配一个可用座位号。优点是省存储;缺点是选座体验受限,用户没法自助挑座。
我采用的是中间路线:预生成座位表,但只针对热门航班和当日航班。远期航班(超过7天)只维护舱位余量,不生成具体座位;用户下单时分配座位号但不落库,出票时才写座位表。这样兼顾了体验和存储成本。这个策略在演示阶段效果很好,数据量也控制住了。
4. 航班搜索与余票查询:慢SQL和索引设计的长期收益
4.1 索引不是越多越好,而是要覆盖查询模式
航班查询是系统里访问频率最高的接口,没有之一。首页一次检索最少要查一次flights表,加上舱位信息的话要关联flight_cabins表。
我的实际建议是:先列查询场景,再设计索引,不要上来就一把梭给所有字段加索引。
这个系统的典型查询场景有:
- 按出发城市 + 到达城市 + 日期查航班
- 按航班号精确查
- 后台按时间区间查航班列表
- 按状态查停售航班
针对场景一,复合索引(departure_city, arrival_city, departure_time)是最高效的。这里有个细节:departure_time如果存的是datetime,查询时用WHERE departure_time >= ? AND departure_time < ?这种半开区间,MySQL才能用上索引做range scan。如果写成DATE(departure_time) = ?,函数处理会让索引失效,全表扫描没跑了。
我用真实的航班数据做过对比。一个10万行的flights表,DATE(departure_time) = '2025-01-15'的扫描行数是全表,响应时间在700ms左右;改成departure_time >= '2025-01-15 00:00:00' AND departure_time < '2025-01-16 00:00:00'之后,走range scan只需扫描几百行,响应时间降到40ms以内。这个差距在并发场景下会被放大很多倍。
4.2 列表余票的查询优化:关联查询 vs 冗余字段
航班列表页需要同时展示航班信息和余票数据。最直观的写法是:
SELECT f.*, fc.cabin_type, (fc.total_seats - fc.sold_seats - fc.locked_seats) AS available_seats FROM flights f LEFT JOIN flight_cabins fc ON f.id = fc.flight_id WHERE f.departure_city = ? AND f.arrival_city = ? AND f.departure_time >= ? AND f.departure_time < ?这个查询本身能工作,但有两个隐患。一是LEFT JOIN后,如果航班有多个舱位,会返回多行,需要代码层做聚合映射;二是available_seats是一个实时计算列,如果航班的高频订票操作集中在某几个热门航班上,这几次查询就要实时计算余票,压力全在数据库。
我的优化策略是双管齐下:
- 给flight_cabins表增加
available_seats冗余字段,在订票/退票事务里同步更新它,而不是查询时临时计算。这属于典型的空间换时间,用一次update换N次查询的稳定性能。 - 查询接口加Redis缓存,key为
flight:search:{departure}:{arrival}:{date}:{cabin},缓存时间设10秒。10秒的粒度对订票系统来说可以接受,因为真正的余票扣减以订单支付为准,列表页的展示数据允许很小的滞后。
这里有个原则性的权衡:列表页余票是"展示数据",不需要每条都强一致;下单页的余票校验则需要强一致。把这两层分开对待,系统的性能瓶颈会少很多。
5. 下单订票的事务边界:扣预占、锁座位、防超卖
5.1 先分析业务时序,再设计代码逻辑
一个完整的下单流程,按时间顺序经过以下步骤:
- 用户选中航班和舱位
- 系统读取当前余票
- 用户填写乘机人信息
- 系统创建订单(状态:待支付)
- 系统锁定对应数量的座位(预占)
- 用户完成支付
- 系统更新订单状态(已支付)
- 系统扣减已售座位数,写入乘机人座位号
- 系统出票(状态:已出票)
注意第4步和第5步的顺序。靠谱的做法是在同一事务里同时完成订单创建和座位锁定,这样不会出现"订单建了但座位没锁住"的空洞状态。
到这一步,事务边界应该细化到:事务A(创建订单 + 锁定座位)和事务B(支付回调后 + 扣减已售 + 出票)分开,中间以订单状态作为桥梁。为什么拆开?因为支付是外部调用,支付网关的回调时间不可控,如果事务长时间持有数据库连接,连接池会很快耗尽。
5.2 防止超卖的本质:把"检查+操作"变成原子操作
超卖是订票系统最不能容忍的故障。两个用户同时看到剩余1张票,同时提交订单,如果不加控制,两个人都会成功。
避免超卖的核心不是"查出来余票大于0再下单",这是经典的check-then-act竞态条件。正确做法是把余票校验和扣减放在同一条SQL里:
UPDATE flight_cabins SET locked_seats = locked_seats + 1 WHERE flight_id = ? AND cabin_type = ? AND (total_seats - sold_seats - locked_seats) >= 1;如果这个UPDATE的影响行数为1,说明抢到了余票;影响行数为0,说明余票已经不足。这个过程由MySQL的行锁保证原子性,不需要额外的分布式锁。
这个方案有个常见的衍生问题:如果用户一直不支付,锁定会不会被其他订票者"饿死"?会的。所以必须有释放机制。我的做法是:锁定记录携带locked_expire_at字段,超过15分钟未支付的锁定自动释放。后台有一个定时任务每5分钟扫一次,把过期的锁定状态回滚。这就是很多人说的"超时释放"。
5.3 用乐观锁处理并发下的状态流转
订单状态从"待支付"变更为"已支付",发生在支付回调时。如果支付网关因为网络原因发了两次回调(这是真实发生过的情况),或者用户点了两次"我已支付",就必须保证状态只被更新一次。
我的处理方式是乐观锁:
int count = orderMapper.updateStatusIfCurrentStatus( orderId, OrderStatus.PENDING_PAY, // 期望当前状态 OrderStatus.PAID // 目标状态 ); if (count == 0) { log.warn("订单状态更新失败,可能已被其他请求处理: " + orderId); }对应的SQL是:
UPDATE orders SET status = #{toStatus}, updated_at = NOW() WHERE id = #{orderId} AND status = #{fromStatus};一次更新最多影响一行,重复回调的第二次更新会影响0行。这样通过"条件更新"天然实现了幂等,比"先查状态再更新"少了一个时间窗口。
5.4 座位分配策略:不要用Random
分配座位号时,最容易犯的错是Random.nextInt(总座位数) + 1,然后判断这个座位是不是已占用。这在座位剩余很多时没问题,但剩余座位越少,随机碰撞概率越高,一个座位可能要随机很多次才能碰到空闲的,性能急剧下降。
我换成了窗口函数分配法。在出票时,从座位表里找该舱位下状态为"空闲"的最小座位号:
SELECT seat_code FROM seats WHERE flight_id = ? AND cabin_code = ? AND status = 'AVAILABLE' ORDER BY CAST(SUBSTRING(seat_code, 3) AS UNSIGNED) LIMIT 1;然后把这行更新为"已占用"。如果担心两个人同时取到同一个座位,把UPDATE和条件绑定即可,影响行数为0就说明被抢了,重试一次。这个结果就是用户从小到大有序分配座位,靠窗、靠过道的分布也比较均匀,体验比随机分配好得多。
6. 订单状态机:从待支付到已出票的流转设计
6.1 状态机模型的落地:枚举 + 流转表 + 校验
订单状态如果只在代码里用散落的if判断,排查问题时你会想骂人。我这次项目里用了轻量级状态机,不引第三方框架,自己用Java枚举加Map实现。
先定义订单状态枚举:
public enum OrderStatus { PENDING_PAY("待支付", 0), PAID("已支付", 1), TICKETED("已出票", 2), CANCELLED("已取消", 3), REFUNDED("已退票", 4), public boolean canTransitTo(OrderStatus target) { switch (this) { case PENDING_PAY: return target == PAID || target == CANCELLED; case PAID: return target == TICKETED || target == REFUNDED; case TICKETED: return target == REFUNDED; // 已出票后仅允许退票 default: return false; } } }然后在订单变更的Service层统一走一个入口方法:
public void transitionOrder(Long orderId, OrderStatus from, OrderStatus to) { if (!from.canTransitTo(to)) { throw new IllegalStateException("订单状态不允许从" + from + "流转到" + to); } // 乐观锁更新 int rows = orderMapper.updateStatus(from, to, orderId); if (rows == 0) { throw new ConcurrentModificationException("订单状态已变更,请刷新后重试"); } }这样做的好处是,所有状态流转规则集中在一个方法里,查日志、加校验、做监控都方便。
6.2 改签的本质:老订单退票 + 新订单创建
改签在状态机上怎么处理?如果你的需求把"改签"做成独立状态,那状态机会变得非常复杂:已出票 -> 改签中 -> 已出票(新航班),中间还要处理差价退款、原座位释放。
我后来想明白了一个更简单的模型:改签 = 原订单退票 + 新订单重新订票。流程上分成两步,用户发起改签,系统先为原订单生成退票操作,退款自动进入原支付渠道;同时引导用户对新航班重新下单。这样不需要在状态机里维护"改签中"这种中间态,代码复杂度骤降。
当然这个方案有个体验上的劣势:改签后的新订单没有保留原订单的乘机人信息,需要重新填写。我在前端做了优化——退票成功后从原订单拉取乘客信息回填到新订单表单,用户点两下就完成了。实际使用下来,这个体验完全可以接受,而且逻辑非常清晰。
6.3 支付回调的对账:不能只信回调
支付回调是支付网关发来的通知,开发时要默认一件事:回调可能是重复的、延迟的、甚至伪造的。
我的处理分三层:
- 签名校验:网关每次回调都带签名,用预共享密钥验证签名通过才处理
- 订单匹配:回调里的
out_trade_no必须能在系统里找到订单,且金额一致 - 幂等处理:即使同一笔支付被回调多次,也通过乐观锁保证订单状态只变迁一次
除此之外,我写了一个定时任务,每个整点拉取支付网关的"已支付订单列表",和本地订单状态对一遍。发现本地还是"待支付"但网关显示"已支付"时,以网关为准,补更本地状态。这一步保证了即使丢失回调,最终状态也能收敛。
7. 后台管理:航班排期与订单统计的实现思路
7.1 航班管理不是简单的增删改查
后台管理模块里,"航班管理"这个页面看起来是最基础的CRUD,但有两个点需要注意。
第一点是停售和删除的区别。删除是物理删除,数据就没了;停售是把航班状态置为"停售",列表页不再展示,但历史订单数据还要能查到。我做的方案是逻辑删除,flights表加一个status字段(1正常 0停售 -1已删除)。订单查询关联航班时,即使是已删除的航班也能查出来,历史数据永远可追溯。
第二点是航班信息的变更审计。运营人员在后台改了航班的出发时间、价格,乘客端已经下单的订单怎么办?我的原则是:订单快照。创建订单时,把当时的航班号、出发时间、到达时间、舱位价格冗余到订单表里。后续航班信息再怎么变,已生成的订单不受影响。这个设计非常关键——航班延误、时刻调整在民航业太常见了,如果没有快照,用户订单里显示的出发时间会跟着后台修改一起变,会产生大量纠纷。
7.2 订单统计:别用循环查询
后台需要一个简单的统计看板:今日订单量、今日销售额、各航线订票量Top10。新手容易用for循环逐个查数据库,性能差不说,代码还丑。
正确姿势是聚合查询。比如查询各航线订票量Top10:
SELECT f.departure_city, f.arrival_city, COUNT(*) as order_cnt FROM orders o JOIN flights f ON o.flight_id = f.id WHERE o.status IN ('PAID', 'TICKETED') AND o.created_at >= ? GROUP BY f.departure_city, f.arrival_city ORDER BY order_cnt DESC LIMIT 10;这种SQL用一条就替代了十几次查询。统计查询一般频率不高,直接查库没问题,不需要引额外的中间件。但要给orders表的created_at加索引,否则随着订单量增长,按时间范围过滤会越来越慢。
7.3 定时任务:清理过期未支付订单的实践
前面提到过超时释放座位锁定的问题,实际开发中我做了两个定时任务:
任务一:清理过期待支付订单。每5分钟扫描一次orders表,找到创建时间超过15分钟且状态为"待支付"的订单。对于这些订单,先把关联的锁定座位释放(locked_seats减回去),再把订单状态改为"已取消"。
任务二:释放悬空的座位预占。有时候用户下单失败(比如支付回调超时后状态没更新),座位预占可能一直悬着。这个任务扫描flight_cabins表的locked_seats字段,对所有进行中的锁定记录做超时判断,过了15分钟还没关联到有效订单就释放。
定时任务用Spring的@Scheduled注解就能实现,单机部署完全够用。如果在分布式环境跑多个实例,要注意任务重复执行的问题——我的做法是加一个task_lock表,每次任务启动前尝试获取分布式锁,抢不到锁就跳过本轮。
8. 测试用例设计:把可能出问题的地方提前引爆
8.1 核心功能测试:不仅要测"正常的路径"
开发完成后,我花了大量时间写测试用例。订票系统的测试不能只测"正常订票成功",更要测各种异常路径和边界条件。
我整理的测试场景清单:
| 场景 | 输入/操作 | 期望结果 |
|---|---|---|
| 正常订票 | 有余票航班,下单并支付 | 订单状态从待支付到已支付到已出票 |
| 余票不足 | 剩余1张票,同时2个用户下单 | 一个成功一个失败,余票不为负 |
| 重复支付回调 | 同一订单相同回调请求两次 | 第二次不生效,状态不重复流转 |
| 重复点击支付 | 用户双击支付按钮 | 只生成一笔支付请求 |
| 超时未支付 | 订单创建超过15分钟 | 座位自动释放,订单取消 |
| 退票后余票 | 已出票订单退票 | 座位释放,余票加回 |
| 航班停售 | 停售航班在列表页隐藏 | 已购订单不受影响 |
| 伪造回调 | 构造错误的签名 | 请求被拒绝 |
这里我要特别强调并发测试。用JMeter起两个线程组,模拟100个用户同时抢最后5张票。观察数据库是否出现余票为负数、订单状态是否一致。我们当时第一次跑并发测试就发现了问题:由于座位锁定和订单创建在两个事务里,极端并发下出现了"有订单但座位没锁住"的情况。后来把两个操作合并到一个事务里才解决。
8.2 实际踩坑:一个单元测试查不出来的问题
有一个问题印象很深。所有单元测试都过了,但联调时发现,用户在支付成功后订单状态变成了"已取消"。
排查了一下午,最后定位到原因:@Scheduled的清理任务和支付回调同时触发。支付回调先把订单从"待支付"改成"已支付",但清理任务在支付回调之前就扫到了这个订单的"待支付"快照,在回调还没提交事务的时候,直接把订单更新成了"已取消"。等回调事务提交时,乐观锁检查发现当前状态不是期望的"待支付",更新失败,用户的支付变成了"已支付但订单已取消"。
这个问题的根因不是逻辑错误,而是两个事务并发时的可见性问题。修复方法很简单:清理任务扫描时,用FOR UPDATE锁住订单记录,或者扫描条件带一个时间边界,只处理创建时间超过15分钟且状态未变化的订单。我选了后者,因为加悲观锁会让清理任务和正常订票请求在同时操作同一行时产生锁等待,高峰期有风险。
这类问题单测确实测不出来,需要并发模拟才能暴露。所以测试计划里,并发场景测试的优先级必须拉高。
9. 一些做了之后觉得"幸好做了"的设计
回头看整个项目,有几个设计在开发当时觉得"多此一举",后来却实实在在省了大事。
第一是订单号用业务号而不是主键ID。订单号格式是M241 + yyyyMMddHHmmss + 4位随机数,展示给用户和日志追踪时非常友好。如果用自增ID暴露给用户,很容易被恶意遍历订单接口。
第二是所有表都带created_at和updated_at。看似基础,但实际排查问题时,这两个字段经常是定位问题的第一把钥匙。比如查"这个订单什么时候被改的",一条SQL就能看到全链路。
第三是关闭了外键约束。航班表和舱位表、订单表和乘客表之间,我在建表时都定义外键,但上线前把外键都去掉了。理由很直接:在线订票系统的写入并发高,外键约束会导致额外的锁和校验开销;业务层面的数据一致性完全可以通过事务和代码逻辑来保证,外键删掉后,性能提升明显,而且再也不用担心"外键约束导致删除失败"这类问题。
第四是每一次订单状态变更都记录日志。我建了一张order_logs表,每个状态变更写入一条记录,包含变更前状态、变更后状态、操作人、操作时间、触发来源(用户/定时任务/支付回调)。这个表对后期排查用户纠纷起了决定性作用——"我没点过退票,为什么订单变退票了?"这种问题,查一下日志表就知道是哪来的操作、什么时候操作的。
如果时间预算有限,我强烈建议把精力优先投到订单状态机、超时释放、支付幂等这三个核心难点上。这三个点做到位,订票系统的骨架就稳了;航班管理、统计看板这些外围功能,后面补起来很快。
做完m241航班订票管理系统最大的体会是:像订票这类"看起来简单的管理系统",真正的难点从来不在增删改查,而在并发下的数据一致性、业务流程的状态约束、和外部系统对接时的幂等处理。把这些想清楚,代码怎么写都不会跑偏。