简介:eyoo旅游机票电商平台(重构版)是一份基于Ruby与Rails技术构建的在线机票销售系统项目资源,面向Ruby后端开发者、电商平台学习者以及关注系统重构优化的人群。项目覆盖业务逻辑处理、ActiveRecord数据库操作、RESTful API接口设计、第三方支付集成、数据加密与防注入等安全措施,同时涉及前端交互、用户体验优化以及云服务部署,有助于理解旅游电商平台从后端到前端的完整技术链路。资源以zip压缩包形式提供,大小仅2.62MB,内容聚焦于项目核心代码与工程配置,适合在本地快速拉取后直接阅读和调试。目前已有122人学习浏览,适合用来参考Rails项目的MVC分层、航班与订单数据建模、支付状态同步机制,以及CI/CD自动化部署流程。通过分析该重构版,开发者可以学习到性能优化、功能增强与界面改进的具体落地方式,进而积累Ruby电商实战经验,为自己的业务系统提供设计启发与排错思路。1. eyoo重构版到底重构了什么:一个旅游机票电商平台的真实痛点eyoo是一个旅游机票电商平台的重构版,核心业务是机票预订,也带旅游套餐。很多人以为重构就是换前端、改UI,但eyoo重构版动的是业务底座:搜索高峰接口响应超3秒、下单偶尔丢单、支付成功却卡在待支付,这些不是加机器能解决的。重构版要解决的是旅游机票这类业务的典型问题:商品模型复杂、价格波动快、库存和供应商强绑定、支付与出票链路长。老系统把查询、下单、出票揉在一个服务里,任何一环出问题都会连锁反应,排查起来像在黑盒子里找线头。下面把这套重构方案讲清楚:领域怎么拆、搜索和下单链路怎么设计、供应商对接怎么处理、上线后怎么验证。适合正在做机票或旅游类电商平台的工程师,也适合想理解异步订单链路的读者。2. 重构先从领域下手:把机票商品、订单、支付拆成五个独立域2.1 老系统的耦合点:为什么查询慢和丢单是同一个根因老版本最典型的表现是:白天搜索高峰期,接口响应从300ms涨到3s,同时用户反馈下单偶尔失败。表面看是一个性能问题加一个可用性问题,但根因是同一个——所有业务共用一套数据库和连接池,查询和下单在代码层面耦合在一起。搜索接口的实现方式通常是:先查航班基础表,再查价格表,最后查余票表,三次查询用一条大SQL join完成。数据量小的时候没问题,当航班、价格、余票数据涨上来,联合查询会同时占住大量数据库连接和IO。下单接口更糟:老版本把创建订单、扣减库存、写支付记录放在一个大事务里,事务里还包含了对上游供应商的同步调用,一个下单请求可能持有数据库连接长达几十秒。问题就出在这:搜索请求把数据库连接池占满,下单请求拿不到连接只能等待,等待超时后前端重试,重试又带来重复订单和状态错乱。查询慢和丢单不是两个独立问题,是同一个连接池和事务边界设计错误导致的。重构第一步就是把这条纠缠在一起的链路解开。2.2 新架构的五个域:商品、库存、订单、支付、账户重构版把业务拆成五个域,每个域有独立的代码模块,数据库按域划分归属。这样拆的好处是:搜索流量再大也只压商品域和缓存,不会拖垮订单域;下单链路再长也只影响订单域内部,不会污染搜索。域核心职责关键数据商品域航班、舱位、退改规则、旅游产品维护flight_product、fare_rule库存域余票扣减与释放、与供应商库存同步flight_inventory订单域订单创建、状态流转、超时取消order_main、order_passenger支付域支付渠道对接、回调处理、每日对账payment_record账户域用户信息、会员等级、积分user_account要说明一点:五个域不等于五个微服务。对于eyoo这个体量,我一般建议先做模块级拆分(Modular Monolith),代码分模块、表归属分清,但部署还是一个应用。等到某个域的负载确实独立了,再把订单域或支付域拆成独立服务。一上来就拆微服务,分布式事务和链路追踪的成本会吃掉重构的收益。拆域之后最明显的变化是:搜索只读商品域和缓存,不再直接碰订单表;下单只写订单域和库存域,不再同步调用供应商。搜索的慢查询不会再拖垮下单,下单的事务也不再把连接池拖死。2.3 落地:领域模型与数据表设计的关键字段拆完域就要动手改表。下面是订单主表的核心设计,重点看幂等字段和状态字段。-- 订单主表:状态字段与幂等字段是核心 CREATE TABLE order_main ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL COMMENT '业务订单号,对外展示', request_id VARCHAR(64) NOT NULL COMMENT '下单请求幂等号,全局唯一', user_id BIGINT NOT NULL, status TINYINT NOT NULL COMMENT '10待支付 20已支付 30出票中 40已出票 50已完成 90已取消', total_amount DECIMAL(10,2) NOT NULL, supplier_code VARCHAR(16) NOT NULL COMMENT '供应商编码', pay_deadline DATETIME NOT NULL COMMENT '支付截止时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_request_id (request_id), KEY idx_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三个字段是重构后的命根子。request_id是幂等字段,前端每次下单传一个全局唯一的请求号,服务端用唯一索引保证同一个请求只会创建一单,前端超时重试时不会生成重复订单。status是状态机字段,所有订单状态流转必须走状态机方法,不允许直接UPDATE改状态。version是乐观锁字段,防止订单更新时两个线程互相覆盖。库存表的设计同样讲究。-- 库存表:条件更新是防超卖的根本保证 CREATE TABLE flight_inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flight_no VARCHAR(20) NOT NULL COMMENT '航班号', dep_date DATE NOT NULL COMMENT '出发日期', cabin_class VARCHAR(4) NOT NULL COMMENT '舱位:Y经济舱 C公务舱 F头等舱', remain_qty INT NOT NULL, total_qty INT NOT NULL, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_flight_date_class (flight_no, dep_date, cabin_class) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;库存扣减用条件更新,UPDATE ... WHERE remain_qty > 0,比先查后改安全得多。高并发场景下这张表只负责扣减,不承担读流量,余票展示全部走Redis缓存。把库存和商品分域之后,查询链路不再碰这张表,它的写压力也就可控了。提示:订单主表不要和库存扣减放在同一个事务里。库存扣减用独立事务,扣减成功再创建订单,订单创建失败时通过消息补偿释放库存。一个大事务包住两步,并发一高必然死锁。3. 机票搜索与预订链路:缓存、状态机与下单代码的落地细节3.1 搜索缓存策略:缓存什么、缓存多久、怎么失效机票搜索是典型的读多写少、价格时效性强的场景。缓存策略直接决定用户体验和供应商接口的压力,这一步做不好,后面全是坑。常见做法是两级缓存:本地进程缓存扛高并发,Redis做共享兜底。缓存内容按变更频率分三类,TTL必须分开设。缓存内容本地缓存TTLRedis TTL失效方式航班基础信息(起降时间、机场、机型)10分钟30分钟定时刷新实时价格30秒60秒供应商价格变更通知时主动失效余票数量5秒15秒本地扣减成功后异步更新价格和余票的TTL不能太长。旅游机票价格是浮动的,同一个航班今天和明天的价格可能差一倍,TTL太长用户看到低价实际订不到,转化率下降、客诉上升;TTL太短又失去缓存意义,供应商接口会被打爆。一个血泪经验:余票缓存不要和价格缓存共用一个TTL。余票变了影响下单,价格变了影响展示,更新频率不一致,强行同步刷新要么多刷浪费接口,要么漏刷导致价格错误。缓存key的设计也要注意。常见格式是search:price:{flightNo}:{cabinClass}:{depDate},把航班号、舱位、日期全部放进key,避免不同查询条件互相覆盖。热点航线的key会被高频访问,本地缓存扛住90%的流量,只有本地未命中的请求才走Redis,Redis再兜不住才打数据库。3.2 订单状态机:从待支付到出票成功的六个状态机票订单和普通电商订单最大的区别在出票环节。普通商品支付完就结束了,机票支付完还要等供应商出票,出票有概率失败,失败要退款,所以状态机必须把出票单独拉出来。状态流转如下:10待支付:下单成功但未支付,15分钟超时自动取消20已支付:支付回调成功落地,等待出票30出票中:已发送出票消息,供应商处理中40已出票:供应商返回票号,订单完成大头50已完成:行程结束后订单关闭90已取消、95退款中、96已退款:取消与退款分支每个状态变更都要留操作记录,记录操作人、触发渠道、原始请求快照、变更时间。这是重构后排查问题最重要的数据源,没有状态变更记录,出问题只能靠猜。状态机在代码层面强制约束,不允许跨状态跳转,比如从10待支付直接改到40已出票就是非法操作,必须按20→30→40的顺序走。支付成功到出票中之间有一关键动作:支付回调落地后发送出票消息到消息队列,由出票worker消费消息去调供应商。这样支付回调线程只干一件事——确认收款并更新订单状态,不阻塞在供应商接口上。供应商接口慢,积压的是消息队列,不是用户请求。3.3 下单核心代码:幂等、锁库存、异步化的最小实现下面是下单核心逻辑的最小实现,省略了参数校验,重点看三个点:价格核验、幂等、库存扣减。// 订单服务:创建订单,锁库存,异步触发后续流程 public OrderVO createOrder(CreateOrderRequest req) { // 1. 价格重新核验:用服务端报价比对前端金额,防止改价 PriceQuote quote = priceService.recheck( req.getFlightNo(), req.getCabinClass(), req.getDepDate()); if (quote.getAmount().compareTo(req.getTotalAmount()) != 0) { throw new BizException("PRICE_CHANGED", "价格已变动,请重新搜索"); } // 2. 幂等控制:requestId 相同直接返回旧订单 Order exist = orderMapper.selectByRequestId(req.getRequestId()); if (exist != null) { return convert(exist); } // 3. 扣减库存:条件更新,影响行数为0就是余票不足 int rows = inventoryMapper.deduct(req.getFlightNo(), req.getDepDate(), req.getCabinClass(), req.getPassengerCount()); if (rows == 0) { throw new BizException("STOCK_NOT_ENOUGH", "余票不足"); } // 4. 创建订单,状态为待支付 Order order = new Order(); order.setOrderNo(bizNoGenerator.next()); order.setRequestId(req.getRequestId()); order.setStatus(OrderStatus.WAIT_PAY.getCode()); order.setTotalAmount(quote.getAmount()); order.setPayDeadline(DateTime.now().plusMinutes(15)); orderMapper.insert(order); // 5. 发送延迟消息:15分钟未支付自动取消并释放库存 mqSender.sendDelay("order.pay.timeout", order.getOrderNo(), 15 * 60 * 1000L); return convert(order); }三个关键点。第一,价格重新核验必须走服务端quote,不能用前端传的金额直接落库,否则用户改请求体就能改价格,这是机票平台的高危漏洞。第二,库存扣减用条件更新,返回影响行数,0行就是没库存了,不需要先SELECT再UPDATE,从根本上避免并发超卖。第三,超时取消做成延迟消息而不是定时扫表,15分钟内订单量再大也不会把数据库扫挂。提示:这个场景我没有用Redis分布式锁。库存扣减是单行UPDATE,数据库行锁已经够用,分布式锁反而增加一次RTT和锁等待时间。只有套餐类多条件库存、比如一个订单要同时扣两个航班余票时,才需要引入分布式锁或分区库存。4. 供应商对接与异步对账:把不稳定接口变成稳定订单4.1 供应商适配层:统一协议、错误码映射与重试策略旅游机票平台下游通常接多个供应商,各家接口协议不同,有的XML有的JSON,有的同步返回出票结果、有的要轮询。重构版第一件事是加一层适配层,把所有供应商差异挡在订单域之外。适配层干三件事:协议转换、错误码映射、重试策略。协议转换就是把各家接口封装成统一的调用接口,内部按供应商类型分发。错误码映射是重点:各家错误码五花八门,三位数字、字符串、自定义码都有,含义还互相冲突,适配层要把它们映射成本系统统一错误码,比如F0001航班不存在、F0002舱位关闭、F0003价格变动、F9999未知异常。重试策略必须区分可重试和不可重试错误。网络超时、供应商系统繁忙属于可重试,用指数退避,第一次隔1秒、第二次2秒、第三次4秒,最多3次。航班不存在、舱位关闭这种业务错误不可重试,重试再多次结果都一样,直接返回用户更合理。这个区分能省掉大量无意义的供应商调用,也避免把上游故障放大成自己的故障。供应商返回映射错误码是否可重试处理策略网络超时F9901是指数退避重试3次舱位关闭F0002否提示用户更换舱位价格变动F0003否回到搜索页刷新价格未知异常F9999是记录告警,人工介入4.2 支付回调与每日对账:幂等消费与差异补偿支付是另一个不稳定环节。支付渠道的回调可能延迟、乱序、重复,所以回调处理的第一原则是幂等。同一个支付单号可能收到两次回调,第一次处理成功,第二次必须直接返回成功,不能重复更新订单状态、不能重复触发消息。回调处理标准流程:验签→查本地支付记录→比对金额→更新支付记录状态→更新订单状态→发送出票消息。每一步都要有日志,特别是验签失败和金额不一致,这两种情况通常是攻击或渠道配置错误。回调原文必须落库保存,相当于给异步链路装了个黑匣子,后续对账和排障全靠它。每日对账是重构版比老版本多出来的核心能力。每天凌晨拉取供应商的出票结果和支付渠道的结算流水,与本地订单表做全量比对。对账脚本发现三类差异:本地有订单但供应商没有出票记录;供应商有出票但本地还是出票中;金额对不上。每类差异进不同的补偿处理:重新提交出票、修正状态、发起退款。对账是最终一致性的兜底,没有对账,异步链路里丢掉的单永远找不回来。4.3 回调处理与补偿任务的核心代码支付回调处理的最小实现如下。// 支付回调:幂等消费,状态推进 @Transactional public void handlePayCallback(PayCallback cb) { // 1. 幂等:已经处理过的回调直接返回 PayRecord exist = payRecordMapper.selectByPaymentNo(cb.getPaymentNo()); if (exist != null && exist.isProcessed()) { log.info("duplicate callback, ignore. paymentNo={}", cb.getPaymentNo()); return; } // 2. 验签和金额校验 boolean signOk = payChannel.verifySign(cb); if (!signOk || cb.getAmount().compareTo(exist.getAmount()) != 0) { payRecordMapper.markAbnormal(cb.getPaymentNo()); alertService.send("支付回调异常,paymentNo=" + cb.getPaymentNo()); return; } // 3. 更新支付记录和订单状态 payRecordMapper.markSuccess(cb.getPaymentNo()); orderService.updateStatus(exist.getOrderNo(), OrderStatus.WAIT_PAY, OrderStatus.PAID, "支付回调"); // 4. 发送出票消息,触发供应商出票 mqSender.send("ticket.issue", exist.getOrderNo()); }这里的幂等判断依赖payment_no的唯一索引。两次并发回调同时进来,数据库唯一索引保证只有一个能插入成功,另一个在插入时走到查询分支发现已处理,直接返回。验签失败和金额不一致必须告警,静默吞掉这两类异常等于给后续对账埋雷。每日对账补偿任务的实现思路:// 每日对账:拉取供应商出票结果,修正本地状态 public void reconcile(SupplierClient client, String bizDate) { List<SupplierTicket> remoteList = client.pullTickets(bizDate); Map<String, SupplierTicket> remoteMap = remoteList.stream() .collect(Collectors.toMap(SupplierTicket::getOrderNo, t -> t)); List<Order> localList = orderMapper.selectByStatusAndDate( OrderStatus.ISSUING, bizDate); for (Order order : localList) { SupplierTicket remote = remoteMap.get(order.getOrderNo()); if (remote == null) { // 供应商没有这笔单:可能是下单时就没提交成功 orderService.markIssueFail(order, "供应商无出票记录"); continue; } if ("OK".equals(remote.getStatus())) { orderService.updateStatus(order.getOrderNo(), OrderStatus.ISSUING, OrderStatus.ISSUED, "对账修正"); } } }对账任务的关键是状态修正必须走updateStatus方法,走状态机,不能直接UPDATE状态字段,否则状态变更记录会丢失,业务上也无法追溯。对账发现异常单后不需要立即处理,可以先把差异单落入待处理表,第二天人工复核,但必须有告警,不能静默压着。5. 重构避坑指南:迁移期间最常见的5个翻车现场以下五个问题都是重构切换期间真实出现过的高频故障,按排查难度排序,每个都按现象、原因、解决三步记录。踩坑不可怕,可怕的是同一个坑踩两次。5.1 价格缓存不一致:用户看到的价格和下单价对不上现象:用户看到低价点进详情,提交订单时提示价格已变动需重新搜索,投诉率飙升,用户觉得平台在套路他。原因:搜索页展示的价格来自缓存,TTL设了60秒。用户看到结果后犹豫一下,缓存失效,重新拉到的价格已经涨了。下单时服务端重新核验价格发现不一致就拒绝了。机制本身没问题,问题是搜索页没有告诉用户价格有有效期。解决:搜索接口返回价格的同时返回一个价格有效期,前端在界面上做倒计时提示"该价格保留XX秒"。下单请求带上展示价格的缓存ID,服务端优先比对缓存里的快照价,缓存还在就按快照价成交,缓存过期才拒绝。把价格波动的感知从投诉变成合理提示,客诉率至少降一半。5.2 并发下单超卖:一张票卖给了两个人现象:高峰期同一航班同一舱位,两个用户同时下单都成功,但供应商只能出一张票。线上客诉,赔付成本高,还得加班手工撤单。原因:库存扣减用了先查后改的方式。查询时余票还剩1张,两个请求都查到余票大于0,都进了扣减逻辑,两张订单争一张票。老版本的UPDATE语句没有where条件限制,扣减不校验余票。解决:库存扣减必须用条件更新,UPDATE flight_inventory SET remain_qty = remain_qty - 1 WHERE flight_no = ? AND dep_date = ? AND cabin_class = ? AND remain_qty > 0,然后检查影响行数,0行就是库存不足直接失败。条件更新加数据库行锁在这个场景足够,不需要额外加Redis锁,加锁反而多一次网络开销和排查负担。5.3 支付成功但订单卡在待支付现象:用户在支付渠道完成扣款,订单状态一直停在待支付,15分钟后被超时取消。用户钱扣了,订单没了,这是最严重的客诉类型。原因:支付回调丢了或处理失败。常见三个原因:支付渠道的回调地址配错、回调处理逻辑抛异常、回调消息在队列里积压超过订单超时时间。老版本同步处理回调,回调逻辑里碰到网络抖动,整个线程挂掉,回调就永远丢了。解决:回调接口先落库后处理。回调进来先把原文存进表,立刻返回渠道要求的固定响应报文,渠道认为投递成功,业务处理再由后台worker异步消费。其次把超时取消时间从15分钟放宽到30分钟,给回调处理留足余量。最后靠每日对账兜底,对账发现本地待支付但渠道已扣款的订单,自动标记已支付并推进出票。5.4 新老系统并行期间数据对不上现象:重构版上线采用双跑模式,新老系统并行期间,订单数据两边对不上。用户在老系统下的单,新系统查不到,会员积分、优惠券状态各说各话。原因:并行期间两个系统各自落库,没有统一的用户标识和数据同步机制。老系统有自己的用户ID,新系统重新建了用户表,两边没有任何关联字段,数据完全割裂。解决:并行期间必须做两件事。第一,建用户映射表,老系统用户ID和新系统用户ID做关联,所有跨系统查询都先走映射。第二,主导数据(订单、余额、积分)通过日志采集或定时任务从老系统同步到新系统,同步任务要有延迟监控和一致性校验,每天跑一次对账比对两边的总量和关键明细。数据完全对齐之后才能切流量,否则切完就是事故。5.5 日志链路断裂:排查一个问题要看五个系统现象:用户反馈出票失败,排查时发现搜索、下单、支付、出票日志散落在四个服务里,没有统一的traceId,只能靠订单号一个系统一个系统翻,查一次问题花两个小时,业务方一直在催。原因:重构时只拆了服务,没有同步建立日志规范。每个服务打印日志用的关键字都不一样,有打订单号的、有打用户ID的、有什么都不打的,日志之间没有关联点。解决:重构上线前必须统一日志规范。每个请求进来先分配一个traceId,所有服务通过日志框架的上下文字段自动携带,日志统一采集进同一套平台。下单处理的所有关键节点打结构化日志:入参、价格核验结果、库存扣减结果、订单创建结果、消息发送结果。排查问题先从traceId拉全链路日志,基本能在5分钟内定位到具体环节。traceId规范这事看起来不起眼,没有它重构完的排查成本会比老系统更高。6. 重构后的验证与调优:压测脚本、监控埋点和灰度放量技巧重构上线前必须做三件事:压测、监控、灰度。压测不是跑通就行,要按真实业务模型构造流量。下面是一个针对下单接口的并发压测脚本,模拟用户提交订单的真实请求。# 下单接口压测:控制并发数,统计成功率 import asyncio import httpx async def create_order(sem, request_id): async with sem: async with httpx.AsyncClient() as client: resp = await client.post( "http://api.eyoo.local/order/create", json={ "requestId": request_id, "flightNo": "FL1234", # 压测专用航班,不能打真实库存 "cabinClass": "Y", "depDate": "2026-01-15", "totalAmount": 860.00, }, timeout=5, ) return resp.status_code, resp.json().get("code") async def main(): sem = asyncio.Semaphore(50) # 并发数50 tasks = [create_order(sem, f"req-{i}") for i in range(10000)] results = await asyncio.gather(*tasks) ok = sum(1 for _, code in results if code == "SUCCESS") print(f"成功率: {ok/len(results):.2%}") asyncio.run(main())压测三个要点。第一,压测数据必须用独立的测试航班,不要打真实库存,否则一万个请求压完真实余票全没了,线上用户下单全失败。第二,关注P99延迟而不是平均延迟,下单链路有异步环节,平均值会被大量快请求拉低,P99才是用户真实感受。第三,压测期间盯着供应商接口调用量,如果供应商先扛不住,那是适配层缺限流,要在适配层做并发控制和降级。监控埋点至少要盯四个指标:下单成功率、支付回调积压量、出票worker消费延迟、库存扣减失败率。这四个指标任何一个异常,都说明链路某个环节出了问题。下单成功率是核心中的核心,它下降只能说明三件事——价格核验拒绝过多、库存扣减失败、系统本身报错,顺着这三个方向查不会跑偏。灰度放量的节奏我一般这样控制:先放5%流量跑一周,对比新老系统的下单成功率和客诉率,确认平稳后放到50%,再观察一周才全量。灰度期间保留新旧切换开关,出问题一键切回,绝不在一个晚上完成全量迁移。我自己经历过一次没有回退方案的上线,出问题后花了四小时手工改数据,从那以后所有重构项目都先把回退脚本写好再动手。这个习惯帮我躲过了后面好几次翻车,希望帮到你。本文还有配套的精品资源,点击获取
简介:eyoo旅游机票电商平台(重构版)是一份基于Ruby与Rails技术构建的在线机票销售系统项目资源,面向Ruby后端开发者、电商平台学习者以及关注系统重构优化的人群。项目覆盖业务逻辑处理、ActiveRecord数据库操作、RESTful API接口设计、第三方支付集成、数据加密与防注入等安全措施,同时涉及前端交互、用户体验优化以及云服务部署,有助于理解旅游电商平台从后端到前端的完整技术链路。资源以zip压缩包形式提供,大小仅2.62MB,内容聚焦于项目核心代码与工程配置,适合在本地快速拉取后直接阅读和调试。目前已有122人学习浏览,适合用来参考Rails项目的MVC分层、航班与订单数据建模、支付状态同步机制,以及CI/CD自动化部署流程。通过分析该重构版,开发者可以学习到性能优化、功能增强与界面改进的具体落地方式,进而积累Ruby电商实战经验,为自己的业务系统提供设计启发与排错思路。1. eyoo重构版到底重构了什么:一个旅游机票电商平台的真实痛点eyoo是一个旅游机票电商平台的重构版,核心业务是机票预订,也带旅游套餐。很多人以为重构就是换前端、改UI,但eyoo重构版动的是业务底座:搜索高峰接口响应超3秒、下单偶尔丢单、支付成功却卡在待支付,这些不是加机器能解决的。重构版要解决的是旅游机票这类业务的典型问题:商品模型复杂、价格波动快、库存和供应商强绑定、支付与出票链路长。老系统把查询、下单、出票揉在一个服务里,任何一环出问题都会连锁反应,排查起来像在黑盒子里找线头。下面把这套重构方案讲清楚:领域怎么拆、搜索和下单链路怎么设计、供应商对接怎么处理、上线后怎么验证。适合正在做机票或旅游类电商平台的工程师,也适合想理解异步订单链路的读者。2. 重构先从领域下手:把机票商品、订单、支付拆成五个独立域2.1 老系统的耦合点:为什么查询慢和丢单是同一个根因老版本最典型的表现是:白天搜索高峰期,接口响应从300ms涨到3s,同时用户反馈下单偶尔失败。表面看是一个性能问题加一个可用性问题,但根因是同一个——所有业务共用一套数据库和连接池,查询和下单在代码层面耦合在一起。搜索接口的实现方式通常是:先查航班基础表,再查价格表,最后查余票表,三次查询用一条大SQL join完成。数据量小的时候没问题,当航班、价格、余票数据涨上来,联合查询会同时占住大量数据库连接和IO。下单接口更糟:老版本把创建订单、扣减库存、写支付记录放在一个大事务里,事务里还包含了对上游供应商的同步调用,一个下单请求可能持有数据库连接长达几十秒。问题就出在这:搜索请求把数据库连接池占满,下单请求拿不到连接只能等待,等待超时后前端重试,重试又带来重复订单和状态错乱。查询慢和丢单不是两个独立问题,是同一个连接池和事务边界设计错误导致的。重构第一步就是把这条纠缠在一起的链路解开。2.2 新架构的五个域:商品、库存、订单、支付、账户重构版把业务拆成五个域,每个域有独立的代码模块,数据库按域划分归属。这样拆的好处是:搜索流量再大也只压商品域和缓存,不会拖垮订单域;下单链路再长也只影响订单域内部,不会污染搜索。域核心职责关键数据商品域航班、舱位、退改规则、旅游产品维护flight_product、fare_rule库存域余票扣减与释放、与供应商库存同步flight_inventory订单域订单创建、状态流转、超时取消order_main、order_passenger支付域支付渠道对接、回调处理、每日对账payment_record账户域用户信息、会员等级、积分user_account要说明一点:五个域不等于五个微服务。对于eyoo这个体量,我一般建议先做模块级拆分(Modular Monolith),代码分模块、表归属分清,但部署还是一个应用。等到某个域的负载确实独立了,再把订单域或支付域拆成独立服务。一上来就拆微服务,分布式事务和链路追踪的成本会吃掉重构的收益。拆域之后最明显的变化是:搜索只读商品域和缓存,不再直接碰订单表;下单只写订单域和库存域,不再同步调用供应商。搜索的慢查询不会再拖垮下单,下单的事务也不再把连接池拖死。2.3 落地:领域模型与数据表设计的关键字段拆完域就要动手改表。下面是订单主表的核心设计,重点看幂等字段和状态字段。-- 订单主表:状态字段与幂等字段是核心 CREATE TABLE order_main ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL COMMENT '业务订单号,对外展示', request_id VARCHAR(64) NOT NULL COMMENT '下单请求幂等号,全局唯一', user_id BIGINT NOT NULL, status TINYINT NOT NULL COMMENT '10待支付 20已支付 30出票中 40已出票 50已完成 90已取消', total_amount DECIMAL(10,2) NOT NULL, supplier_code VARCHAR(16) NOT NULL COMMENT '供应商编码', pay_deadline DATETIME NOT NULL COMMENT '支付截止时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_request_id (request_id), KEY idx_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三个字段是重构后的命根子。request_id是幂等字段,前端每次下单传一个全局唯一的请求号,服务端用唯一索引保证同一个请求只会创建一单,前端超时重试时不会生成重复订单。status是状态机字段,所有订单状态流转必须走状态机方法,不允许直接UPDATE改状态。version是乐观锁字段,防止订单更新时两个线程互相覆盖。库存表的设计同样讲究。-- 库存表:条件更新是防超卖的根本保证 CREATE TABLE flight_inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flight_no VARCHAR(20) NOT NULL COMMENT '航班号', dep_date DATE NOT NULL COMMENT '出发日期', cabin_class VARCHAR(4) NOT NULL COMMENT '舱位:Y经济舱 C公务舱 F头等舱', remain_qty INT NOT NULL, total_qty INT NOT NULL, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_flight_date_class (flight_no, dep_date, cabin_class) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;库存扣减用条件更新,UPDATE ... WHERE remain_qty > 0,比先查后改安全得多。高并发场景下这张表只负责扣减,不承担读流量,余票展示全部走Redis缓存。把库存和商品分域之后,查询链路不再碰这张表,它的写压力也就可控了。提示:订单主表不要和库存扣减放在同一个事务里。库存扣减用独立事务,扣减成功再创建订单,订单创建失败时通过消息补偿释放库存。一个大事务包住两步,并发一高必然死锁。3. 机票搜索与预订链路:缓存、状态机与下单代码的落地细节3.1 搜索缓存策略:缓存什么、缓存多久、怎么失效机票搜索是典型的读多写少、价格时效性强的场景。缓存策略直接决定用户体验和供应商接口的压力,这一步做不好,后面全是坑。常见做法是两级缓存:本地进程缓存扛高并发,Redis做共享兜底。缓存内容按变更频率分三类,TTL必须分开设。缓存内容本地缓存TTLRedis TTL失效方式航班基础信息(起降时间、机场、机型)10分钟30分钟定时刷新实时价格30秒60秒供应商价格变更通知时主动失效余票数量5秒15秒本地扣减成功后异步更新价格和余票的TTL不能太长。旅游机票价格是浮动的,同一个航班今天和明天的价格可能差一倍,TTL太长用户看到低价实际订不到,转化率下降、客诉上升;TTL太短又失去缓存意义,供应商接口会被打爆。一个血泪经验:余票缓存不要和价格缓存共用一个TTL。余票变了影响下单,价格变了影响展示,更新频率不一致,强行同步刷新要么多刷浪费接口,要么漏刷导致价格错误。缓存key的设计也要注意。常见格式是search:price:{flightNo}:{cabinClass}:{depDate},把航班号、舱位、日期全部放进key,避免不同查询条件互相覆盖。热点航线的key会被高频访问,本地缓存扛住90%的流量,只有本地未命中的请求才走Redis,Redis再兜不住才打数据库。3.2 订单状态机:从待支付到出票成功的六个状态机票订单和普通电商订单最大的区别在出票环节。普通商品支付完就结束了,机票支付完还要等供应商出票,出票有概率失败,失败要退款,所以状态机必须把出票单独拉出来。状态流转如下:10待支付:下单成功但未支付,15分钟超时自动取消20已支付:支付回调成功落地,等待出票30出票中:已发送出票消息,供应商处理中40已出票:供应商返回票号,订单完成大头50已完成:行程结束后订单关闭90已取消、95退款中、96已退款:取消与退款分支每个状态变更都要留操作记录,记录操作人、触发渠道、原始请求快照、变更时间。这是重构后排查问题最重要的数据源,没有状态变更记录,出问题只能靠猜。状态机在代码层面强制约束,不允许跨状态跳转,比如从10待支付直接改到40已出票就是非法操作,必须按20→30→40的顺序走。支付成功到出票中之间有一关键动作:支付回调落地后发送出票消息到消息队列,由出票worker消费消息去调供应商。这样支付回调线程只干一件事——确认收款并更新订单状态,不阻塞在供应商接口上。供应商接口慢,积压的是消息队列,不是用户请求。3.3 下单核心代码:幂等、锁库存、异步化的最小实现下面是下单核心逻辑的最小实现,省略了参数校验,重点看三个点:价格核验、幂等、库存扣减。// 订单服务:创建订单,锁库存,异步触发后续流程 public OrderVO createOrder(CreateOrderRequest req) { // 1. 价格重新核验:用服务端报价比对前端金额,防止改价 PriceQuote quote = priceService.recheck( req.getFlightNo(), req.getCabinClass(), req.getDepDate()); if (quote.getAmount().compareTo(req.getTotalAmount()) != 0) { throw new BizException("PRICE_CHANGED", "价格已变动,请重新搜索"); } // 2. 幂等控制:requestId 相同直接返回旧订单 Order exist = orderMapper.selectByRequestId(req.getRequestId()); if (exist != null) { return convert(exist); } // 3. 扣减库存:条件更新,影响行数为0就是余票不足 int rows = inventoryMapper.deduct(req.getFlightNo(), req.getDepDate(), req.getCabinClass(), req.getPassengerCount()); if (rows == 0) { throw new BizException("STOCK_NOT_ENOUGH", "余票不足"); } // 4. 创建订单,状态为待支付 Order order = new Order(); order.setOrderNo(bizNoGenerator.next()); order.setRequestId(req.getRequestId()); order.setStatus(OrderStatus.WAIT_PAY.getCode()); order.setTotalAmount(quote.getAmount()); order.setPayDeadline(DateTime.now().plusMinutes(15)); orderMapper.insert(order); // 5. 发送延迟消息:15分钟未支付自动取消并释放库存 mqSender.sendDelay("order.pay.timeout", order.getOrderNo(), 15 * 60 * 1000L); return convert(order); }三个关键点。第一,价格重新核验必须走服务端quote,不能用前端传的金额直接落库,否则用户改请求体就能改价格,这是机票平台的高危漏洞。第二,库存扣减用条件更新,返回影响行数,0行就是没库存了,不需要先SELECT再UPDATE,从根本上避免并发超卖。第三,超时取消做成延迟消息而不是定时扫表,15分钟内订单量再大也不会把数据库扫挂。提示:这个场景我没有用Redis分布式锁。库存扣减是单行UPDATE,数据库行锁已经够用,分布式锁反而增加一次RTT和锁等待时间。只有套餐类多条件库存、比如一个订单要同时扣两个航班余票时,才需要引入分布式锁或分区库存。4. 供应商对接与异步对账:把不稳定接口变成稳定订单4.1 供应商适配层:统一协议、错误码映射与重试策略旅游机票平台下游通常接多个供应商,各家接口协议不同,有的XML有的JSON,有的同步返回出票结果、有的要轮询。重构版第一件事是加一层适配层,把所有供应商差异挡在订单域之外。适配层干三件事:协议转换、错误码映射、重试策略。协议转换就是把各家接口封装成统一的调用接口,内部按供应商类型分发。错误码映射是重点:各家错误码五花八门,三位数字、字符串、自定义码都有,含义还互相冲突,适配层要把它们映射成本系统统一错误码,比如F0001航班不存在、F0002舱位关闭、F0003价格变动、F9999未知异常。重试策略必须区分可重试和不可重试错误。网络超时、供应商系统繁忙属于可重试,用指数退避,第一次隔1秒、第二次2秒、第三次4秒,最多3次。航班不存在、舱位关闭这种业务错误不可重试,重试再多次结果都一样,直接返回用户更合理。这个区分能省掉大量无意义的供应商调用,也避免把上游故障放大成自己的故障。供应商返回映射错误码是否可重试处理策略网络超时F9901是指数退避重试3次舱位关闭F0002否提示用户更换舱位价格变动F0003否回到搜索页刷新价格未知异常F9999是记录告警,人工介入4.2 支付回调与每日对账:幂等消费与差异补偿支付是另一个不稳定环节。支付渠道的回调可能延迟、乱序、重复,所以回调处理的第一原则是幂等。同一个支付单号可能收到两次回调,第一次处理成功,第二次必须直接返回成功,不能重复更新订单状态、不能重复触发消息。回调处理标准流程:验签→查本地支付记录→比对金额→更新支付记录状态→更新订单状态→发送出票消息。每一步都要有日志,特别是验签失败和金额不一致,这两种情况通常是攻击或渠道配置错误。回调原文必须落库保存,相当于给异步链路装了个黑匣子,后续对账和排障全靠它。每日对账是重构版比老版本多出来的核心能力。每天凌晨拉取供应商的出票结果和支付渠道的结算流水,与本地订单表做全量比对。对账脚本发现三类差异:本地有订单但供应商没有出票记录;供应商有出票但本地还是出票中;金额对不上。每类差异进不同的补偿处理:重新提交出票、修正状态、发起退款。对账是最终一致性的兜底,没有对账,异步链路里丢掉的单永远找不回来。4.3 回调处理与补偿任务的核心代码支付回调处理的最小实现如下。// 支付回调:幂等消费,状态推进 @Transactional public void handlePayCallback(PayCallback cb) { // 1. 幂等:已经处理过的回调直接返回 PayRecord exist = payRecordMapper.selectByPaymentNo(cb.getPaymentNo()); if (exist != null && exist.isProcessed()) { log.info("duplicate callback, ignore. paymentNo={}", cb.getPaymentNo()); return; } // 2. 验签和金额校验 boolean signOk = payChannel.verifySign(cb); if (!signOk || cb.getAmount().compareTo(exist.getAmount()) != 0) { payRecordMapper.markAbnormal(cb.getPaymentNo()); alertService.send("支付回调异常,paymentNo=" + cb.getPaymentNo()); return; } // 3. 更新支付记录和订单状态 payRecordMapper.markSuccess(cb.getPaymentNo()); orderService.updateStatus(exist.getOrderNo(), OrderStatus.WAIT_PAY, OrderStatus.PAID, "支付回调"); // 4. 发送出票消息,触发供应商出票 mqSender.send("ticket.issue", exist.getOrderNo()); }这里的幂等判断依赖payment_no的唯一索引。两次并发回调同时进来,数据库唯一索引保证只有一个能插入成功,另一个在插入时走到查询分支发现已处理,直接返回。验签失败和金额不一致必须告警,静默吞掉这两类异常等于给后续对账埋雷。每日对账补偿任务的实现思路:// 每日对账:拉取供应商出票结果,修正本地状态 public void reconcile(SupplierClient client, String bizDate) { List<SupplierTicket> remoteList = client.pullTickets(bizDate); Map<String, SupplierTicket> remoteMap = remoteList.stream() .collect(Collectors.toMap(SupplierTicket::getOrderNo, t -> t)); List<Order> localList = orderMapper.selectByStatusAndDate( OrderStatus.ISSUING, bizDate); for (Order order : localList) { SupplierTicket remote = remoteMap.get(order.getOrderNo()); if (remote == null) { // 供应商没有这笔单:可能是下单时就没提交成功 orderService.markIssueFail(order, "供应商无出票记录"); continue; } if ("OK".equals(remote.getStatus())) { orderService.updateStatus(order.getOrderNo(), OrderStatus.ISSUING, OrderStatus.ISSUED, "对账修正"); } } }对账任务的关键是状态修正必须走updateStatus方法,走状态机,不能直接UPDATE状态字段,否则状态变更记录会丢失,业务上也无法追溯。对账发现异常单后不需要立即处理,可以先把差异单落入待处理表,第二天人工复核,但必须有告警,不能静默压着。5. 重构避坑指南:迁移期间最常见的5个翻车现场以下五个问题都是重构切换期间真实出现过的高频故障,按排查难度排序,每个都按现象、原因、解决三步记录。踩坑不可怕,可怕的是同一个坑踩两次。5.1 价格缓存不一致:用户看到的价格和下单价对不上现象:用户看到低价点进详情,提交订单时提示价格已变动需重新搜索,投诉率飙升,用户觉得平台在套路他。原因:搜索页展示的价格来自缓存,TTL设了60秒。用户看到结果后犹豫一下,缓存失效,重新拉到的价格已经涨了。下单时服务端重新核验价格发现不一致就拒绝了。机制本身没问题,问题是搜索页没有告诉用户价格有有效期。解决:搜索接口返回价格的同时返回一个价格有效期,前端在界面上做倒计时提示"该价格保留XX秒"。下单请求带上展示价格的缓存ID,服务端优先比对缓存里的快照价,缓存还在就按快照价成交,缓存过期才拒绝。把价格波动的感知从投诉变成合理提示,客诉率至少降一半。5.2 并发下单超卖:一张票卖给了两个人现象:高峰期同一航班同一舱位,两个用户同时下单都成功,但供应商只能出一张票。线上客诉,赔付成本高,还得加班手工撤单。原因:库存扣减用了先查后改的方式。查询时余票还剩1张,两个请求都查到余票大于0,都进了扣减逻辑,两张订单争一张票。老版本的UPDATE语句没有where条件限制,扣减不校验余票。解决:库存扣减必须用条件更新,UPDATE flight_inventory SET remain_qty = remain_qty - 1 WHERE flight_no = ? AND dep_date = ? AND cabin_class = ? AND remain_qty > 0,然后检查影响行数,0行就是库存不足直接失败。条件更新加数据库行锁在这个场景足够,不需要额外加Redis锁,加锁反而多一次网络开销和排查负担。5.3 支付成功但订单卡在待支付现象:用户在支付渠道完成扣款,订单状态一直停在待支付,15分钟后被超时取消。用户钱扣了,订单没了,这是最严重的客诉类型。原因:支付回调丢了或处理失败。常见三个原因:支付渠道的回调地址配错、回调处理逻辑抛异常、回调消息在队列里积压超过订单超时时间。老版本同步处理回调,回调逻辑里碰到网络抖动,整个线程挂掉,回调就永远丢了。解决:回调接口先落库后处理。回调进来先把原文存进表,立刻返回渠道要求的固定响应报文,渠道认为投递成功,业务处理再由后台worker异步消费。其次把超时取消时间从15分钟放宽到30分钟,给回调处理留足余量。最后靠每日对账兜底,对账发现本地待支付但渠道已扣款的订单,自动标记已支付并推进出票。5.4 新老系统并行期间数据对不上现象:重构版上线采用双跑模式,新老系统并行期间,订单数据两边对不上。用户在老系统下的单,新系统查不到,会员积分、优惠券状态各说各话。原因:并行期间两个系统各自落库,没有统一的用户标识和数据同步机制。老系统有自己的用户ID,新系统重新建了用户表,两边没有任何关联字段,数据完全割裂。解决:并行期间必须做两件事。第一,建用户映射表,老系统用户ID和新系统用户ID做关联,所有跨系统查询都先走映射。第二,主导数据(订单、余额、积分)通过日志采集或定时任务从老系统同步到新系统,同步任务要有延迟监控和一致性校验,每天跑一次对账比对两边的总量和关键明细。数据完全对齐之后才能切流量,否则切完就是事故。5.5 日志链路断裂:排查一个问题要看五个系统现象:用户反馈出票失败,排查时发现搜索、下单、支付、出票日志散落在四个服务里,没有统一的traceId,只能靠订单号一个系统一个系统翻,查一次问题花两个小时,业务方一直在催。原因:重构时只拆了服务,没有同步建立日志规范。每个服务打印日志用的关键字都不一样,有打订单号的、有打用户ID的、有什么都不打的,日志之间没有关联点。解决:重构上线前必须统一日志规范。每个请求进来先分配一个traceId,所有服务通过日志框架的上下文字段自动携带,日志统一采集进同一套平台。下单处理的所有关键节点打结构化日志:入参、价格核验结果、库存扣减结果、订单创建结果、消息发送结果。排查问题先从traceId拉全链路日志,基本能在5分钟内定位到具体环节。traceId规范这事看起来不起眼,没有它重构完的排查成本会比老系统更高。6. 重构后的验证与调优:压测脚本、监控埋点和灰度放量技巧重构上线前必须做三件事:压测、监控、灰度。压测不是跑通就行,要按真实业务模型构造流量。下面是一个针对下单接口的并发压测脚本,模拟用户提交订单的真实请求。# 下单接口压测:控制并发数,统计成功率 import asyncio import httpx async def create_order(sem, request_id): async with sem: async with httpx.AsyncClient() as client: resp = await client.post( "http://api.eyoo.local/order/create", json={ "requestId": request_id, "flightNo": "FL1234", # 压测专用航班,不能打真实库存 "cabinClass": "Y", "depDate": "2026-01-15", "totalAmount": 860.00, }, timeout=5, ) return resp.status_code, resp.json().get("code") async def main(): sem = asyncio.Semaphore(50) # 并发数50 tasks = [create_order(sem, f"req-{i}") for i in range(10000)] results = await asyncio.gather(*tasks) ok = sum(1 for _, code in results if code == "SUCCESS") print(f"成功率: {ok/len(results):.2%}") asyncio.run(main())压测三个要点。第一,压测数据必须用独立的测试航班,不要打真实库存,否则一万个请求压完真实余票全没了,线上用户下单全失败。第二,关注P99延迟而不是平均延迟,下单链路有异步环节,平均值会被大量快请求拉低,P99才是用户真实感受。第三,压测期间盯着供应商接口调用量,如果供应商先扛不住,那是适配层缺限流,要在适配层做并发控制和降级。监控埋点至少要盯四个指标:下单成功率、支付回调积压量、出票worker消费延迟、库存扣减失败率。这四个指标任何一个异常,都说明链路某个环节出了问题。下单成功率是核心中的核心,它下降只能说明三件事——价格核验拒绝过多、库存扣减失败、系统本身报错,顺着这三个方向查不会跑偏。灰度放量的节奏我一般这样控制:先放5%流量跑一周,对比新老系统的下单成功率和客诉率,确认平稳后放到50%,再观察一周才全量。灰度期间保留新旧切换开关,出问题一键切回,绝不在一个晚上完成全量迁移。我自己经历过一次没有回退方案的上线,出问题后花了四小时手工改数据,从那以后所有重构项目都先把回退脚本写好再动手。这个习惯帮我躲过了后面好几次翻车,希望帮到你。本文还有配套的精品资源,点击获取