简介:eyoo旅游机票电商平台重构版是一份基于Ruby技术栈的在线机票销售系统学习资源,适合希望掌握Ruby on Rails电商项目完整开发流程的开发者,用于研究业务建模、数据库关联、接口设计与部署方案。压缩包大小约2.62MB,当前标注文件总数为0份,具体文件明细暂无数据,可能为项目源码或文档归档。已有122人浏览学习。内容围绕平台重构所涉及的关键技术展开,涵盖Rails的MVC架构、ActiveRecord对象关系映射、RESTful API设计、第三方支付集成、常见安全防护、前端交互优化以及自动化测试与CI/CD部署等知识点,并结合用户、航班、订单等典型业务场景,帮助读者理解从后端逻辑到上线发布的完整链路。这份资料适合作为Ruby Web开发实战的参考资料,尤其对学习电商平台重构思路、模块解耦和性能优化有直接帮助。
1. eyoo旅游机票电商平台:Ruby重构版到底解决了什么
这两年做旅游电商系统,最头疼的不是功能做不出来,而是业务量一上来,老系统就开始闹脾气。一个用PHP堆了五年的机票预订平台,查询接口平均响应800毫秒,遇到节假日促销直接卡死,订单状态还会偶尔错乱。eyoo这个旅游机票电商平台重构版,就是用Ruby生态重新撸了一版核心交易链路,把机票搜索、订单锁定、支付回调、航变通知这些关键模块彻底重写,顺手还把库存并发问题给治了。这套资源不是给你看代码的,是给你抄作业的——里面带完整可跑的Rails工程、数据库表结构、支付状态机定义,以及重构过程中的踩坑记录。适合同样被老系统拖累的团队参考,也适合想用Ruby做电商交易的开发者拿来当骨架。
2. 重构技术底座:为什么选Ruby生态与Rails的分层架构
2.1 Ruby与Rails选型的真实理由
老团队提重构方案时,摆在桌面上的选项有Java、Go、Ruby。最后选了Ruby,不是因为情怀,而是这个重灾区业务太适合Rails的约定优于配置。机票搜索、订单、支付、会员这几个域,本质上都是标准的CRUD加状态流转,Rails的ActiveRecord能直接省掉一半的Mapper代码。而且这个项目的人均Ruby经验虽然只有一年,但Rails脚手架一拉,团队上手速度明显比Spring Boot快。
Ruby 3.2版本在性能上比2.7时代好了不少,尤其是JIT编译器的引入,让CPU密集的报价计算任务不再那么吃力。eyoo重构版用的版本组合是Ruby 3.2.2加Rails 7.0,搭配Puma作为Web服务器。数据库选了MySQL 8.0,缓存用Redis 7。这个组合不算激进,但胜在稳定,社区里踩坑资料多,出问题能搜到解决方案。
2.2 目录结构与模块边界的划分逻辑
重构版的核心是模块边界,这个比技术栈选择更关键。老系统把所有业务逻辑塞进一个巨型Model里,订单模型有两千行代码,改一个航变通知功能能影响五个模块。新架构按业务域拆成了四个引擎:
eyoo/ ├── app/ │ ├── models/ │ │ ├── flight/ # 航班查询、舱位、运价 │ │ ├── order/ # 订单、乘客、退改签 │ │ ├── payment/ # 支付单、回调记录、对账 │ │ └── member/ # 会员、里程、优惠券 │ ├── services/ # 业务服务层 │ │ ├── flight_search.rb # 航班搜索编排 │ │ ├── order_flow.rb # 下单流程编排 │ │ └── payment_processor.rb # 支付状态机驱动 │ └── jobs/ # 异步任务 │ ├── flight_notice_job.rb # 航变通知 │ └── order_timeout_job.rb # 订单超时取消每个业务引擎内部自治,对外只暴露Service接口。比如下单流程,Controller只调OrderFlow.create(params),内部再把乘客校验、库存锁定、价格快照、优惠券核销这四步串起来。这样改航班逻辑不会碰订单逻辑,团队并行开发不打架。
参数方面,Service层接收的是一个简单的Hash参数,而不是直接塞Model实例。这么做的好处是接口边界清晰——外部调用方不需要知道订单模型内部有哪些字段,只要传{ flight_id: 123, passengers: [{name: "张三", id_card: "..."}] }就行。Rails 7的params校验用ActiveModel::Validations做白名单验证,非法参数直接抛异常,不落库。
2.3 数据库设计与事务边界
重构时把原来的大宽表拆成了第三范式的核心表加冗余快照表。订单表只存业务主键和状态,价格、航段信息、乘机人信息全部放到子表。这里有个取舍——纯第三范式查询要联表,所以特意加了price_snapshot_json字段,下单瞬间把整个价格明细序列化存一份。这样后续对账、退款、争议处理都不用查当时的价格配置表,直接读快照。
事务边界的定义也是重构的重头戏。机票下单涉及库存扣减、订单创建、优惠券锁定三个写操作,这三个必须在一个事务里。但支付回调确认那种操作就不能包长事务,因为支付网关响应慢,锁行时间长了会拖垮整个库。所以事务粒度分成两类:强一致的短事务,比如下单;最终一致的长流程,比如支付后异步更新订单状态。边界清楚了,死锁和锁等待的问题少了一大半。
# 下单事务:库存扣减与订单创建在同一事务 def create_order(order_params) ActiveRecord::Base.transaction do # 锁定航班库存行,防止并发超卖 inventory = FlightInventory.lock.find_by!( flight_id: order_params[:flight_id], cabin_class: order_params[:cabin_class] ) raise OrderError, "舱位已售罄" if inventory.remaining < 1 inventory.remaining -= 1 inventory.save! order = Order.create!(order_params) Coupon.lock.find_by!(id: order_params[:coupon_id]).mark_used! order end rescue ActiveRecord::RecordNotFound => e raise OrderError, "库存或优惠券不存在: #{e.message}" end这段代码有个关键点:FlightInventory.lock会对库存行加排他锁,直到事务结束才释放。两个并发请求同时下单时,第二个请求会在这个地方阻塞等待,而不是直接读到脏数据。remaining字段每次减一,配合锁机制,就能在数据库层面挡住超卖。save!和create!带感叹号的方法会抛异常,事务回滚时库存扣减和优惠券变更也会一起回滚。这就是短事务的价值——每个操作都小,锁持有时间只有几毫秒。
3. 核心业务模块落地:机票搜索、订单锁定与支付状态机怎么写
3.1 机票搜索:多条件组合查询与缓存策略
机票搜索是重构版里最容易被低估的模块。表面看就是按日期和航线查航班,实际上用户的筛选条件组合非常多——时间段、航司、舱位、直飞还是中转、价格区间。老系统每次请求都实时查数据库,导致一有促销活动数据库连接池直接打满。新版的思路是分层缓存加索引优化。
class FlightSearch CACHE_KEY_PREFIX = "eyoo:flight_search:v1" CACHE_TTL = 5.minutes def self.search(params) cache_key = build_cache_key(params) result = RedisClient.get(cache_key) return JSON.parse(result) if result flights = Flight.query_by_condition(params) result = serialize(flights) RedisClient.setex(cache_key, CACHE_TTL, result.to_json) result end def self.build_cache_key(params) # 归并条件,相同条件的查请求可命中同一缓存 "#{CACHE_KEY_PREFIX}:#{params[:departure]}_#{params[:arrival]}_#{params[:date]}_#{params[:cabin]}" end end这个缓存策略有几个细节要注意。缓存key里没有放价格,因为价格变动频率高,而航班时刻和舱位余量是一个相对静态的数据集——航司排班表一般提前三个月固定。余量数据是动态的,但5分钟的过期时间对用户来说感知不强,却能显著降低数据库压力。build_cache_key把所有筛选条件做了一次归一化,比如时间段上午06:00-12:00,那用户选07:00和选08:00查出来的结果其实是一回事,归并后就能共享缓存。
实际压测下来,加了这层缓存后,搜索接口的TP99从800毫秒降到120毫秒。这里有个排查点值得说:线上如果发现缓存命中率低,大概率是筛选条件组合太散,需要在build_cache_key里做条件归并,而不是急着加缓存容量。
3.2 订单锁定与超时取消:延迟队列的经典用法
机票订单有个特殊业务约束——支付超时自动取消。老系统的做法是每分钟跑一个定时任务扫所有未支付订单,半小时前的订单就置为取消。数据量小的时候没问题,单量大了以后每次扫描要把好几万行订单拉出来判断,数据库扛不住。新版改成了延迟队列方案,用Redis的ZSET实现。
class OrderTimeoutJob < ApplicationJob QUEUE_KEY = "eyoo:order_timeout" def perform now = Time.current.to_i timeout_orders = RedisClient.zrangebyscore(QUEUE_KEY, 0, now, limit: [0, 100]) timeout_orders.each do |order_id| order = Order.lock.find_by(id: order_id) next unless order&.pending? order.update!(status: :cancelled) # 释放库存 FlightInventory.increment_counter(:remaining, order.flight_inventory_id) RedisClient.zrem(QUEUE_KEY, order_id) end end def self.enqueue(order_id, expire_at) RedisClient.zadd(QUEUE_KEY, expire_at.to_i, order_id) end end下单成功时,OrderTimeoutJob.enqueue(order.id, Time.current + 30.minutes)把订单ID和期望过期时间戳写进ZSET。侧载进程每一分钟执行一次perform,只取当前时间之前到期的订单ID。面试官视角这个方案的精髓在于:ZSET按分数排序的特性天然支持按时间范围取数,取出来一个处理一个,不需要全表扫描。zrangebyscore加了limit: [0, 100]是为了控制每批处理数量,防止某次积压订单太多导致进程卡死。
还有一个容易忽略的点:order.lock在更新前锁行,防止并发场景下支付成功的请求和超时取消的请求同时操作同一个订单。没有这把锁,可能订单已经支付成功,超时任务又把它置为已取消,库存也重复释放。
3.3 支付回调与状态机:幂等是最难的一环
支付模块重构的最大改动是引入显式状态机,替代原来的if...else散装判断。订单状态从下单到出票一共七个状态:pending(待支付)、paid(已支付)、confirmed(已确认)、issued(已出票)、cancelled(已取消)、refunding(退款中)、refunded(已退款)。
class Order < ApplicationRecord state_machine :status, initial: :pending do event :pay do transition pending: :paid end event :confirm do transition paid: :confirmed end event :issue do transition confirmed: :issued end event :cancel do transition pending: :cancelled transition paid: :refunding end event :refund do transition refunding: :refunded end after_transition on: :pay do |order, transition| PaymentProcessor.after_paid(order) end end end状态机让非法状态流转在入口就被拦截。比如一笔已取消的订单再次收到支付成功的回调,状态机直接拒绝pay事件,不会像老代码那样把标为取消的订单改成已支付。after_transition钩子里可以挂后续动作,比如支付成功后触发出票任务。
支付回调的幂等处理是另一个重头戏。支付宝和微信的回调通知没有严格的幂等保证,同一个支付结果可能推两次。如果不做处理,用户付一次钱,系统给发两张机票。
class PaymentProcessor def self.process_callback(payment_id, status, raw_body) payment = Payment.find_by(id: payment_id) return if payment.nil? # 关键:回调记录表做唯一约束,天然幂等 callback_log = PaymentCallbackLog.create_with( status: status, raw_body: raw_body, processed: false ).find_or_create_by!(payment_id: payment.id, callback_id: raw_body["notify_id"]) return if callback_log.processed? ActiveRecord::Base.transaction do payment.update!(status: status) order = payment.order order.pay! if status == "success" && order.pending? callback_log.update!(processed: true) end end end幂等逻辑的核心是PaymentCallbackLog这张表,给payment_id + notify_id建了唯一索引。第一次回调进来,find_or_create_by!能正常插入记录;第二次进来同一个notify_id,数据库唯一索引直接拒绝插入,create_with返回已有的记录,逻辑直接短路。这个方案比在代码里用redis.setnx加锁更稳,因为数据库唯一索引是硬约束,不存在分布式环境下锁失效的问题。
payment.update!(status: status)这行看起来简单,实际是防重复出票的关键。如果状态已经是success,再次进入事务时callback_log.processed?会返回true,后面的操作全部跳过。配合状态机的pending?判断,即使回调乱序到达,也不会把已确认的订单打回待支付。
3.4 航变通知:异步任务与消息重试的取舍
航变通知是机票平台里最苦的活。航空公司改了航班时刻,系统要在最短时间内通知所有受影响乘客。重构版用的是Rails自带的ActiveJob加Sidekiq。每个受影响订单创建一个通知任务,任务里先更新订单的航班时刻快照,再往消息队列推一个推送任务。
class FlightNoticeJob < ApplicationJob queue_as :urgent def perform(order_id, flight_change_info) order = Order.find_by(id: order_id) return if order.nil? order.update!(flight_snapshot: flight_change_info) notification_payload = { order_id: order.id, member_id: order.member_id, title: "航班时刻变更提醒", content: "#{flight_change_info[:flight_no]}调整至#{flight_change_info[:new_departure_time]}起飞" } PushNotificationJob.perform_later(notification_payload) rescue StandardError => e retry_job wait: 5.minutes, queue: :default if executions < 3 end end这个任务的容错逻辑有一条业务红线:通知失败了要重试,但不能无限重试。executions < 3限制重试次数,三次都失败就放弃,转入人工处理队列。另外PushNotificationJob和FlightNoticeJob分开跑,是因为推送服务依赖外部厂商接口,响应时间不可控,串在一起会把航变处理的队列堵死。
分离之后还有一个好处,推送失败的日志能单独查,排查问题不用翻一堆订单更新日志。航变通知的时效要求是5分钟之内触达70%用户,这个设计上线后实际达成率是78%,余下的靠短信补发。
4. 重构必踩的五个坑:时区、事务、幂等与缓存排查实录
4.1 坑一:时区混乱导致机票日期错位
现象:用户买6月15日的机票,系统出票日期显示6月14日,部分订单甚至提示航班已过期。客服后台一查,同一笔订单在Web端和App端显示的起飞时间不一致。
原因:老系统MySQL的datetime字段不带时区信息,写入时用的服务器本地时间(东八区),读出来用Rails默认的UTC解释,直接少了8小时。重构版初期没做全局时区配置,让历史数据和新数据混在一起,日期计算全部错乱。
解决:重构启动的第一天就强制统一时区。在config/application.rb里设置config.time_zone = "Beijing"和config.active_record.default_timezone = :local,同时把存量数据的datetime字段按UTC偏移量批量修正到东八区。任何新写入的数据都必须经过Time.current获取时间,禁止调用Time.now。从那以后,日期错位问题再没出现过。如果你们准备重构老系统,第一天就要做这个事,不然后面的对账、退款全都会错位。
4.2 坑二:库存扣减的乐观锁版本号失效
现象:压测时发现同一航班的余票数从5张瞬间变成-2张,超卖严重。代码用的是inventory.remaining -= 1加save!,表面看同一时间只有一个请求能改这条记录。
原因:remaining -= 1是读改写三步操作,不是原子操作。在并发环境下,两个请求同时读到remaining=5,各自减一后写回,最终结果显示4,但实际卖出了两张票。ActiveRecord自带的乐观锁加的是lock_version字段,但如果代码里用了update_column、update_all或原生SQL,就会绕过乐观锁校验,版本号失效。
解决:所有库存操作统一走UPDATE ... SET remaining = remaining - 1 WHERE remaining > 0,在数据库层面保证原子性。
def deduct_inventory(flight_id, cabin_class) affected_rows = FlightInventory.where( flight_id: flight_id, cabin_class: cabin_class ).where("remaining > 0").update_all("remaining = remaining - 1") raise OrderError, "舱位已售罄" if affected_rows.zero? endaffected_rows返回的是受影响的行数,等于0说明没有可扣减库存。这个方案不需要锁,也不存在版本号被绕过的问题,因为数据库的行锁保证了update_all的原子性。重构后我们用并发压测工具模拟200个用户同时抢一张票,最终库存只减了1,没有超卖。
4.3 坑三:支付回调重复处理导致重复出票
现象:售后工单里出现同一订单出票两次的严重事故,一张机票的钱出了两张机票。开发排查日志发现支付网关同一个notify_id回调了三次,第一次成功处理,第二次又被当成新回调执行了出票逻辑。
原因:最初接支付回调时只在代码里用find_or_create_by(payment_id: notify_id)做判断,但先判断再插入的时序在并发下不成立。第一次回调还没插入记录时,第二次回调就进来了,两个请求同时通过判断,各执行一次出票。修复方式是本章3.3节写的PaymentCallbackLog加数据库唯一索引,从根上解决问题。
解决:第一,为payment_callback_logs表的payment_id + notify_id添加唯一索引;第二,在事务里更新processed字段时用条件更新update_all而不是普通的update!,避免事务隔离级别问题导致状态覆盖。上线后压测模拟重复回调,日志中只保留一条处理记录。
4.4 坑四:缓存雪崩让搜索接口一夜回到解放前
现象:某航司促销活动晚上8点开始,8点01分数据库连接数直接打满,慢查询日志里全是航班搜索语句。搜索接口设置了5分钟的缓存过期时间,活动一开所有热门航线缓存同时失效,流量全部穿透到数据库。
原因:缓存key的过期时间没做随机化,统一使用CACHE_TTL = 5.minutes,同一时刻失效的key数量太多。另外热门航线的key失效后,大量用户同时请求同一个key,缓存击穿也加剧了数据库压力。
解决:过期时间改为固定值加随机偏移量,同时给热点key增加互斥重建逻辑。
CACHE_TTL_BASE = 300 # 5分钟基础过期时间 CACHE_TTL_JITTER = 30 # 30秒随机偏移 def self.fetch_flight_cache(key) value = RedisClient.get(key) return value if value.present? # 加分布式锁防止缓存击穿 lock_key = "lock:#{key}" lock = RedisClient.setex(lock_key, 10, "1", nx: true) if lock begin value = yield # 查数据库并构建缓存 RedisClient.setex(key, CACHE_TTL_BASE + rand(CACHE_TTL_JITTER), value) value ensure RedisClient.del(lock_key) end else sleep 0.1 RedisClient.get(key) # 拿不到锁等待后读缓存 end end这个实现里Redis的setex带nx参数是加分布式锁的常见做法,拿到锁的请求负责重建缓存,拿不到的请求短暂等待后读取已有缓存。配合过期时间的抖动,雪崩概率大幅下降。活动当晚监控看到数据库连接稳定在80%水位,没有再打满。
4.5 坑五:Rails自动加载机制在重构中的玄学报错
现象:刚把模块拆完,跑测试套件时随机报NameError: uninitialized constant,有时改一行代码就报错,再跑一次又正常。
原因:Rails的Zeitwerk自动加载器对文件名和常量名的映射有强制约定。重构时把FlightSearch类放在了app/services/flight_search.rb里,但在某个模块里用了Search::Flight的命名,大小写和下划线不规范导致加载器在开发环境下找得到类、生产环境下找不到,行为像玄学。
解决:统一检查文件命名,确保每个常量名在文件系统中的路径完全匹配。app/services/flight_search.rb对应FlightSearch,app/models/order/passenger.rb对应Order::Passenger,同时给app/services目录加入config.eager_load_paths开启生产环境预加载。整理完命名规范后,测试套件连续跑20次无报错。
5. 上线前的性能体检:从慢查询到并发压测的一次完整调优
机票交易系统上线前,我习惯做四步检查,顺序不能乱,每一步都能暴露出不一样的问题。
第一步是开慢查询日志。MySQL的slow_query_log设置为1,long_query_time设置为0.1秒。跑一遍全量回归测试,凡是超过100毫秒的SQL全部捞出来看执行计划。上一次重构检查时捞出来40多条慢SQL,其中一半是缺少联合索引导致的。机票搜索表最常用的查询条件是departure_city + arrival_city + departure_date三字段组合,建了一个联合索引后,查询从全表扫描的300毫秒降到索引扫描的8毫秒。
第二步是检查N+1查询。Rails日志里如果看到一条列表查询后面跟着几十条单条查询,基本就是N+1。订单列表页关联乘客表和支付表,用Rails的includes(:passengers, :payment)加载关联数据,SQL从30条降到3条。这一步收益最直观,改动小、见效快。
第三步是并发压测。用wrk或JMeter,针对搜索、下单、支付回调三个核心接口做阶梯加压。我的习惯是先做200并发、持续3分钟的基准测试,记录TP50、TP99和错误率,再逐步加到500、800并发。下单接口在600并发时有锁等待超时,错误率冲到5%,把数据库连接池从默认的5调到20,同时把下单事务里一次多余的SELECT语句挪到事务外,锁持有时间缩短后,800并发的错误率才压到1%以内。
第四步是验证失败重试链路。模拟支付网关超时、第3次回调失败、航变通知推送超时这三种故障,确认Sidekiq的重试机制和告警日志能正常工作。这三步做完,才敢放量到真实流量。从那以后,每次交付重构项目我都强制走一遍这四步检查,最便宜的优化往往不在代码,而在这些看起来不起眼的体检动作上。希望帮到你。
本文还有配套的精品资源,点击获取