前阵子把一直在维护的 v810b 版本机票预定座位管理系统重新过了一遍,顺手解决了好几个当时遗留的问题。这个项目前端用的 Vue,后端用的 Node.js,核心业务是航空公司官网里最常见的流程:用户查询航班、选择舱位、在座位图上挑座位、提交订单,管理员在后台维护航班和座位数据。整套系统说大不大,但把机票库存、座位锁定、订单状态这些逻辑串起来之后,还是很考验工程能力的。如果你刚开始接触全栈项目、想拿真实业务练手,或者正在做毕业设计,这篇文章大概能帮你少走不少弯路。
1. 这类机票预订系统到底在解决什么问题
1.1 业务角色和核心流程
很多人一听到“机票预定座位管理系统”,第一反应就是“这不就是个 CRUD 吗”。如果只是把航班、订单做增删改查,那确实简单,但真正把业务跑通之后会发现,核心难点在于状态的一致性和并发控制。
系统里主要有两类角色。普通用户要完成的是三个动作:按出发城市、到达城市、日期查航班;在航班详情里选择舱位和座位;提交订单并完成支付。管理员要维护的是航班计划、飞机机型、舱位价格、座位库存,还要处理订单取消和退票。表面上看角色不多,但每个动作背后都牵扯到一张或多张表的状态变化。
举个例子,用户搜索航班时看到的“剩余座位数”,并不是存一个字段就能解决的。因为同一个航班下,不同舱位的座位数不同,不同日期、不同飞机的座位布局也不同。如果管理端改了一个机型的座位布局,那所有关联航班的可售座位数都要跟着变。这类联动逻辑如果不用数据表去建模,而是硬编码在业务代码里,后期维护成本会非常高。
所以我在设计时把流程拆成了几条线:航班查询链路解决“有哪些航班能买”,座位选择链路解决“这个航班还有哪些座位能选”,订单链路解决“选好的座位怎么锁住并完成支付”。这三条链路各自独立,又在订单提交那一刻交会,交会点就是整个系统的核心。
1.2 为什么 Node.js 加 Vue 的组合适合这个项目
技术选型的时候,我考虑过很多组合。后端可以用 Java Spring Boot,也可以用 Python Django,但最后选了 Node.js 加 Express,核心原因是这个系统的业务特征属于典型的 I/O 密集型场景:用户不断发起查询、提交订单、刷新座位状态,真正消耗 CPU 计算的逻辑并不多。Node.js 基于事件循环和非阻塞 I/O,在这种高并发读多写少的场景下,用很小的资源就能扛住大量连接。
前端选 Vue 也有很实际的考虑。机票预订页面有大量表单交互和数据联动,比如选择日期后要重新拉取航班列表,选择舱位后座位图要刷新,选择座位后底部价格汇总要变化。Vue 的响应式系统和 computed 计算属性特别适合处理这种“一个数据变化带动一片界面更新”的场景,写起来比手动操作 DOM 或者用 jQuery 维护状态要省心得多。
还有一个原因是前后端分离带来的部署灵活性。前端打包成纯静态文件,可以扔到 Nginx 或者 CDN 上;后端跑 Node 服务,接口独立升级。这个项目里我甚至在同一台服务器上同时部署了前端静态资源和后端 API,通过反向代理把 /api 转发给 Node 服务,其他路径都交给前端静态文件。这种架构对个人项目和中小团队来说,性价比很高。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 项目骨架搭建:Node.js、npm 与 Vue 工程化的第一道坎
2.1 版本选型:为什么推荐 Node.js 20 加 Vue 3 加 Vite
v810b 版本里,前端我用的 Vue 3.4 加 Vite 5,后端用的 Node.js 20 LTS 加 Express 4。这里有一个很现实的建议:如果你是照着教程从零起步,Node.js 一定要装 LTS 版本,不要追最新版。LTS 版本意味着生态兼容性稳定,很多原生模块在 LTS 版本上测试最充分。Node.js 20 对 ES Modules 的支持已经很完善,Express 4 的中间件生态也完全兼容。
前端用 Vite 而不是 Vue CLI,是因为 Vite 在开发环境下启动速度确实快很多。Vue CLI 基于 Webpack,冷启动一个中型项目可能要十几秒,Vite 借助原生 ES Module,基本一两秒就能起来。这种体感差异在频繁调试时非常明显。
配套的 UI 组件库我用的 Ant Design Vue。它的表格、日期选择器、表单校验组件都比较完整,尤其是日期范围选择和时间选择,做航班查询表单时几乎不用额外封装。如果你不喜欢这个风格,Element Plus 也行,但尽量从头到尾只用一套组件库,不要混用,否则样式和交互逻辑容易打架。
2.2 从零安装 Node.js:环境变量和 npm 源配置
装 Node.js 本身不难,很多人栽在环境变量和 npm 源上。Windows 下直接去官网下载 LTS 版本的 .msi 安装包,一路下一步就行。安装完成后,在命令行里执行 node -v 和 npm -v,如果能正常输出版本号,说明安装成功。
但有个非常常见的坑:你安装的是 Node.js,命令行里敲 npm 却报错,提示 npm 不是内部或外部命令。这种情况绝大多数是因为安装时没有勾选“Add to PATH”选项,或者安装之后环境变量没有生效。解决办法是手动把 Node.js 的安装目录加到系统环境变量 Path 里,然后重新开一个命令行窗口。
npm 默认源在国内访问速度很慢,安装依赖经常卡住。我建议装完 Node.js 之后立刻把源切换成国内镜像:
bash复制npm config set registry https://registry.npmmirror.com
npm config get registry
看到输出是 npmmirror 的地址,说明切换成功。后续所有 npm install 都会走镜像源,速度会快很多。这里还有一个细节:镜像源只影响下载包的速度,不影响包本身的代码内容,所以不用担心安全性问题。
2.3 前端工程化和后端的目录结构
项目我用了最经典的 monorepo 目录方式,一个仓库里同时放前端和后端,各自独立维护依赖。这样做的最大好处是,前后端代码改动可以在同一个提交记录里看到,联调时定位问题特别方便。
text复制airline-system/
├── frontend/ # Vue 3 工程
│ ├── public/
│ ├── src/
│ │ ├── api/ # 接口请求封装
│ │ ├── assets/
│ │ ├── components/ # 通用组件
│ │ ├── router/ # 路由配置
│ │ ├── stores/ # 状态管理
│ │ ├── views/ # 页面组件
│ │ ├── App.vue
│ │ └── main.js
│ ├── package.json
│ ├── vite.config.js
│ └── index.html
└── backend/ # Node.js 服务
├── app.js # Express 入口
├── routes/ # 路由模块
│ ├── auth.js
│ ├── flights.js
│ ├── orders.js
│ └── seats.js
├── middleware/ # 中间件
│ └── auth.js
├── db/ # 数据库连接
├── models/ # 数据模型
├── utils/ # 工具函数
├── package.json
└── .env # 环境变量
前端目录里我单独分了一个 api 目录,把所有的 axios 请求都按模块封装起来,页面组件里不直接出现接口地址。后端的 routes 目录按业务模块拆分,每个文件只负责一组相关接口。这两点看起来不起眼,但对后期维护特别重要,项目一旦超过十个页面或二十个接口,没有清晰分层基本就是灾难。
3. 数据库建模与座位库存的核心逻辑
3.1 基础表结构:航班、飞机、座位、订单
机票系统的数据模型,我建议从四个核心实体开始:飞机、航班、座位、订单。飞机描述“有哪些座位”,航班描述“哪一天从哪到哪用哪架飞机飞”,座位描述“某个航班上某个座位当前是什么状态”,订单则把用户、航班、座位串起来。
下面这组表结构是我在 v810b 里实际用的简化版本:
sql复制CREATE TABLE aircraft (
id INT PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(20) NOT NULL,
name VARCHAR(50) NOT NULL,
economy_rows INT NOT NULL,
business_rows INT NOT NULL,
first_rows INT NOT NULL
);
CREATE TABLE flight (
id INT PRIMARY KEY AUTO_INCREMENT,
flight_no VARCHAR(20) NOT NULL,
aircraft_id INT NOT NULL,
departure_city VARCHAR(50) NOT NULL,
arrival_city VARCHAR(50) NOT NULL,
departure_time DATETIME NOT NULL,
arrival_time DATETIME NOT NULL,
base_price DECIMAL(10,2) NOT NULL,
status TINYINT DEFAULT 1
);
CREATE TABLE seat (
id INT PRIMARY KEY AUTO_INCREMENT,
flight_id INT NOT NULL,
seat_no VARCHAR(10) NOT NULL,
cabin_class VARCHAR(10) NOT NULL,
status TINYINT DEFAULT 0,
order_id INT NULL,
UNIQUE KEY uk_flight_seat (flight_id, seat_no)
);
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id INT NOT NULL,
flight_id INT NOT NULL,
total_price DECIMAL(10,2) NOT NULL,
status TINYINT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
seat 表是整个设计里最关键的一张表。它把航班和座位绑定到一起,每个航班会生成一份独立的座位副本。这样做的原因是,同一架飞机在不同日期执行不同航班,座位状态要各自独立管理。今天的航班可能 3A 座位被占了,明天的航班 3A 还是空闲的,如果只在飞机表上存座位状态,就完全没法处理这种时间维度的隔离。
3.2 座位状态的机与“预占”机制
座位状态我用数字枚举来表示:
0:空闲,可被用户选择1:已锁定,用户已提交订单但尚未支付2:已售,订单完成支付,座位不可再选3:不可用,比如安全出口旁被航空公司规则限制的座位
这里“锁定”状态是最有讲究的。用户选好座位后,我不会立刻把座位置为已售,而是先置为锁定,同时生成一个未支付订单,给用户留出十五分钟的支付时间。如果用户一直不支付,锁定状态必须被主动释放,否则这个座位就被“僵尸订单”占住了。
释放锁定有三种常见做法:定时任务扫描超时订单、支付回调时校验并释放、用户主动取消订单。我在项目里用的是定时任务加支付回调双保险。每五分钟扫描一次所有锁定状态超过十五分钟的订单,将其状态置为已取消,同时把关联座位释放回空闲。如果用户支付成功了,支付回调里会再次校验座位状态,确认没被释放后才会更新为已售。这样可以避免极端情况下定时任务刚释放、用户又支付成功的竞态。
3.3 订单状态流转
订单状态我设计了四个阶段:
0:待支付1:已支付,待出票2:已出票3:已取消
很多系统会把“待支付”和“已取消”之间做得很随意,导致座位状态和订单状态不一致。我在开发中吃过这个亏:用户取消订单后,只改了订单状态,忘了改座位状态,结果这个座位在座位图上一直是灰色,管理员排查了半天才找到原因。
所以我在代码里定了一条铁律:订单状态和座位状态必须放在同一个事务里修改,要么都成功,要么都回滚。取消订单时更新订单状态为已取消,同时把关联座位状态改回空闲;支付成功时更新订单状态为已支付,同时把座位状态改为已售。这条规则从后端接口层面就限制死,前端界面无论如何操作都不会把数据搞脏。
4. 后端接口:登录鉴权、航班查询与预订闭环
4.1 Express 路由设计与中间件的使用
后端入口我用了 Express 4,整体结构非常简单。app.js 里只负责挂载中间件和路由,不写具体业务逻辑:
javascript复制const express = require('express');
const cors = require('cors');
const authRoutes = require('./routes/auth');
const flightRoutes = require('./routes/flights');
const orderRoutes = require('./routes/orders');
const app = express();
app.use(cors());
app.use(express.json());
app.use('/api/auth', authRoutes);
app.use('/api/flights', flightRoutes);
app.use('/api/orders', orderRoutes);
app.listen(3000, () => {
console.log('API server running at http://localhost:3000');
});
这里有一个很值得说的点:express.json() 中间件千万不要漏。很多接口报 400 请求体解析失败,就是因为没有加载这个中间件,POST 请求里的 JSON 数据读不出来。Express 4 默认不解析请求体,必须显式挂载。
路由文件里我只做参数校验和响应组装,真正的数据操作放在 models 层。比如航班查询接口:
javascript复制const express = require('express');
const router = express.Router();
const Flight = require('../models/Flight');
router.post('/search', async (req, res) => {
const { from, to, date } = req.body;
if (!from || !to || !date) {
return res.status(400).json({ code: 1, message: '参数不完整' });
}
try {
const list = await Flight.searchByRoute(from, to, date);
res.json({ code: 0, data: list });
} catch (err) {
res.status(500).json({ code: 1, message: '服务器内部错误' });
}
});
module.exports = router;
4.2 预订接口如何避免超卖
超卖这个问题,是所有库存系统都绕不开的。当年我做第一版的时候太天真,直接在代码里“先查询座位状态,如果空闲就更新为锁定”,结果压力测试一跑,同一个座位被两个用户同时下单。原因是两个请求同时查到空闲状态,都认为可以占座,然后先后执行更新,自然就超卖了。
解决办法是在数据库层面加行锁,而不是靠业务代码判断。我用的是 SELECT ... FOR UPDATE,在事务内把要锁定的座位行锁住,其他请求再查这一行时会被阻塞,等事务提交后才能继续:
javascript复制const db = require('../db');
async function lockSeats(flightId, seatNos, orderId) {
const conn = await db.getConnection();
try {
await conn.beginTransaction();
for (const seatNo of seatNos) {
const [rows] = await conn.query(
'SELECT id, status FROM seat WHERE flight_id = ? AND seat_no = ? FOR UPDATE',
[flightId, seatNo]
);
if (!rows.length || rows[0].status !== 0) {
throw new Error(`座位 ${seatNo} 已被占用`);
}
}
for (const seatNo of seatNos) {
await conn.query(
'UPDATE seat SET status = 1, order_id = ? WHERE flight_id = ? AND seat_no = ?',
[orderId, flightId, seatNo]
);
}
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
这段逻辑里有两个关键点:第一,所有座位都检查成功后才批量更新,避免一个座位成功、另一个失败导致的数据不一致;第二,使用事务后,同一时刻只有一个请求能锁住同一批座位,其他请求只能等待,从根上避免了超卖。
4.3 登录鉴权与密码加密
用户登录我用的是 JWT。用户注册时密码不会明文存库,而是用 bcrypt 加盐哈希后存储。登录成功后签发一个 token,前端把 token 存在 localStorage 里,之后每次请求在请求头里带上 Authorization: Bearer <token>。
后端专门写了一个鉴权中间件,用来保护需要登录才能访问的接口,比如提交订单、查看订单列表:
javascript复制const jwt = require('jsonwebtoken');
module.exports = function authMiddleware(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) {
return res.status(401).json({ code: 1, message: '未登录' });
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.userId = payload.userId;
next();
} catch (err) {
return res.status(401).json({ code: 1, message: '登录已过期,请重新登录' });
}
};
JWT_SECRET 我放在 .env 文件里,不会提交到代码仓库。这一点很重要,很多人图方便直接把密钥硬编码在代码里,一旦代码库泄露,所有用户 token 都会被伪造。
5. 前端交互:从航班查询到座位选择的完整实现
5.1 Vue Router 路由规划与登录守卫
前端路由我用 Vue Router 4,页面结构分成三块:访客可访问的登录注册页、用户核心操作区、管理员后台。路由表里通过 meta 字段标记是否需要登录,以及需要的角色:
javascript复制const routes = [
{ path: '/login', component: () => import('../views/Login.vue') },
{
path: '/',
component: () => import('../layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', redirect: '/flights' },
{ path: 'flights', component: () => import('../views/FlightSearch.vue') },
{ path: 'flights/:id', component: () => import('../views/FlightDetail.vue') },
{ path: 'orders', component: () => import('../views/OrderList.vue') }
]
},
{
path: '/admin',
component: () => import('../layouts/AdminLayout.vue'),
meta: { requiresAuth: true, role: 'admin' },
children: [
{ path: 'flights', component: () => import('../views/admin/FlightManage.vue') },
{ path: 'seats', component: () => import('../views/admin/SeatManage.vue') }
]
}
];
全局前置守卫里统一做登录和角色判断。从 /flights/:id 这类地址进入详情页时,参数通过 route.params.id 拿,但我更推荐在跳转时用 query 传日期、舱位等额外筛选条件,因为刷新页面后 params 依然在,而组件内部的响应式状态不会丢失。
Vue Router 的懒加载也别忘了。每个页面都用 () => import() 的方式引入,路由级代码拆分后,首屏加载体积会小很多。这个项目在未做懒加载前,首屏构建产物有 1.2MB,拆完之后每个页面模块平均只有几十 KB。
5.2 航班查询表单与 computed 的联动
航班查询页是整个系统用户进入的第一个页面。它看起来只是一个表单:出发城市、到达城市、日期、舱位,但里面的联动逻辑很值得扣。
日期选择器我用 Ant Design Vue 的 DatePicker,设置 disabledDate 只允许选择今天和未来的日期。出发城市和到达城市是两个下拉框,选择完出发城市后,到达城市自动过滤掉相同城市。价格区间和舱位选择变化时,列表要实时过滤。
这种场景用 Vue 的 computed 而不是 watch 来做会舒服很多。因为过滤条件是纯计算逻辑,不产生副作用,computed 会根据依赖的响应式数据自动缓存,性能更好,代码也更好读。
vue复制<script setup>
import { ref, computed } from 'vue';
const keyword = ref('');
const selectedCabin = ref('all');
const flightList = ref([]);
const filteredFlights = computed(() => {
return flightList.value.filter((item) => {
const matchKeyword =
!keyword.value ||
item.flightNo.includes(keyword.value) ||
item.departureCity.includes(keyword.value) ||
item.arrivalCity.includes(keyword.value);
const matchCabin =
selectedCabin.value === 'all' ||
item.cabins.some((c) => c.cabinClass === selectedCabin.value && c.available > 0);
return matchKeyword && matchCabin;
});
});
</script>
这里还有一个小技巧:不要在每次筛选时都重新请求后端,因为机票价格和剩余座位数本身就是高频变化的数据,真正的查询应该以日期维度请求一次,拿到当天航班列表后在本地做舱位过滤。这样交互反馈会非常快。
5.3 座位图组件:把二维布局变成可视化选择
座位图是这个项目里最贴近“航空”属性的模块。用户在航班详情页选择舱位后,下方会渲染一张按飞机布局生成的座位图,经济舱、商务舱、头等舱用不同颜色区分,已售座位置灰,不可用座位打叉,当前选中的座位高亮。
组件的核心数据是一个二维数组。我约定 A、B、C、D、E、F 为列号,数字为行号,每一个格子对应 seat 表里的一条记录。渲染时用 v-for 两层循环,行内跳过安全通道列,模拟真实飞机的过道效果。
vue复制<template>
<div class="seat-map">
<div
v-for="(row, rowIndex) in seatLayout"
:key="rowIndex"
class="seat-row"
>
<span class="row-number">{{ rowIndex + 1 }}</span>
<div
v-for="seat in row"
:key="seat.seatNo"
class="seat"
:class="seatClass(seat)"
@click="handleSeatClick(seat)"
>
{{ seat.seatNo }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
flightId: {
type: Number,
required: true
},
selectedCabin: {
type: String,
default: 'economy'
}
});
const seats = ref([]);
const selectedSeats = ref([]);
const availableCount = computed(() =>
seats.value.filter(
(s) => s.cabinClass === props.selectedCabin && s.status === 0
).length
);
function seatClass(seat) {
if (seat.status === 2) return 'seat-sold';
if (seat.status === 3) return 'seat-disabled';
if (selectedSeats.value.includes(seat.seatNo)) return 'seat-selected';
if (seat.status === 0) return 'seat-available';
return 'seat-locked';
}
function handleSeatClick(seat) {
if (seat.status !== 0) return;
if (selectedSeats.value.includes(seat.seatNo)) {
selectedSeats.value = selectedSeats.value.filter((no) => no !== seat.seatNo);
} else {
if (props.selectedCabin !== seat.cabinClass) return;
selectedSeats.value.push(seat.seatNo);
}
}
</script>
注意这里我用了 selectedSeats.value.includes() 判断是否选中,而不是依赖 CSS 类名。因为座位状态和选中状态是两个维度,状态来自后端,选中状态来自用户交互,分开维护才能避免冲突。
在调试这种组件时,Vue DevTools 几乎是必需品。浏览器安装 Vue DevTools 插件后,可以实时查看 seats 数组和 selectedSeats 的变化,座位高亮不对时,先看数据再看样式,很快就能定位问题。
6. 联调与部署阶段踩过的坑
6.1 PowerShell 下 npm.ps1 无法加载脚本的完整排查
这个坑我相信所有用 Windows 做前端开发的人都遇过。在 VS Code 终端里执行 npm run dev,结果报错:
text复制npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。
我当时的第一反应是 npm 坏了,重新装了一遍 Node.js,结果问题依旧。后来才想到,这跟 npm 没关系,是 PowerShell 的执行策略默认禁止运行 .ps1 脚本。
排查链路是这样的:命令行里执行 Get-ExecutionPolicy,如果显示 Restricted,就说明当前策略禁止运行任何脚本。解决办法是以管理员身份打开 PowerShell,执行:
powershell复制Set-ExecutionPolicy RemoteSigned
RemoteSigned 表示本地创建的脚本可以运行,从网上下载的脚本必须有数字签名才能运行。这个策略比 Unrestricted 安全,是开发场景下比较合适的折中方案。
如果你不想修改全局策略,还有两个更轻量的临时方案。第一个是在命令行里直接调用 npm 的 cmd 版本,也就是执行 npm.cmd run dev;第二个是把 VS Code 的默认终端从 PowerShell 改成 Command Prompt 或者 Git Bash。我个人更推荐第一个,因为它不改变系统任何设置,只绕过了 PowerShell 的脚本检查。
6.2 接口跨域与 Vite 代理配置
前后端分开跑时,必然遇到跨域。开发环境下前端跑在 http://localhost:5173,后端跑在 http://localhost:3000,前端直接 fetch 后端接口,浏览器会因为跨域策略拦截。
我的处理方式分两层。后端先启用 cors 中间件允许跨域请求,方便联调;同时前端利用 Vite 的代理把 /api 前缀的请求转发到后端地址。
javascript复制// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
};
这样前端代码里请求的地址始终是 /api/xxx 这种相对路径,本地开发时走代理,部署到生产环境后由 Nginx 统一转发,前端代码不需要改任何东西。这是一个很重要的工程习惯:接口地址永远不要写死成 http://localhost:3000,否则环境切换时非常痛苦。
6.3 打包部署后刷新页面 404 的处理
前端打包后部署到 Nginx,有一个经典问题:直接访问首页没问题,但进入某个路由后按 F5 刷新,页面变成 404。原因是 Vue Router 的 history 模式下,浏览器请求的是真实路径,比如 /flights/123,而服务器上并没有这个物理文件。
解决办法是在 Nginx 配置里加一个 try_files 指令:
nginx复制location / {
root /var/www/airline-system/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
这段配置的意思是:如果请求的路径没有匹配到真实文件,就回退到 index.html,由前端路由接管处理。这是 history 模式部署的标配。如果你的项目安全性要求比较高,不想让用户通过任意路径直达页面,也可以在后端路由守卫里再判断一次,但从服务器层面解决刷新 404 是最根本的。
7. 性能优化与 v810b 之后的扩展方向
7.1 前端加载优化
第一版前端打包后的产物很大,主要原因是没有做路由懒加载、UI 组件库全量引入、静态资源没有压缩。后来我做了三个调整。
组件库按需引入。Ant Design Vue 全量引入会让打包体积增加几百 KB,换成按需引入之后,构建产物明显变小。Vite 和 unplugin-vue-components 配合起来,代码里直接用 <a-button>,插件会自动把组件和样式按需打包进去,基本不需要手写引入语句。
静态资源加哈希缓存。Vite 默认的 build 产物会带哈希文件名,部署到 Nginx 时给静态资源设置 Cache-Control: max-age=31536000,用户再次访问时走浏览器缓存,不用重复下载。因为文件名带哈希,内容变化时文件名会变,会自动触发新资源下载,不需要手动清缓存。
图片资源压缩。航班列表里如果有航空公司 Logo 或航班图片,尽量用 WebP 格式,一张几 KB 的图片加载速度远快于 PNG。
7.2 后端瓶颈与缓存设计
后端接口里,航班查询是压力最大的接口。用户每次打开页面都会触发一次搜索,如果每次都查数据库,数据库连接的压力会非常大。
我的优化思路是加一层 Redis 缓存。航班的基础信息变化频率很低,比如航班号、起降时间、航线,可以缓存一小时;但剩余座位数变化频率高,不适合直接缓存。所以我把搜索接口拆成两步:先从缓存拿航班基础列表,再通过一个批量查询接口拿到每个航班的实时座位统计,最后在内存里合并返回。
javascript复制async function searchFlights(from, to, date) {
const cacheKey = `flight:${from}:${to}:${date}`;
let flights = await redis.get(cacheKey);
if (!flights) {
flights = await Flight.searchByRoute(from, to, date);
await redis.set(cacheKey, JSON.stringify(flights), 'EX', 3600);
} else {
flights = JSON.parse(flights);
}
const flightIds = flights.map((f) => f.id);
const seatStats = await Seat.getStatsByFlightIds(flightIds);
return flights.map((f) => ({
...f,
availableCount: seatStats[f.id]?.availableCount || 0
}));
}
这个方案的缺点是接口逻辑更复杂了,但换来的是数据库查询量大幅下降。缓存穿透和缓存雪崩的问题也要处理,最简单的做法是给缓存加随机过期时间,并且对空结果也缓存一个很短的过期时间。
7.3 从 v810b 可以继续演进的方向
这个系统目前已经能跑通完整的预订流程,但如果要继续往生产级别演进,我建议从这几个方向入手。
第一,支付回调的幂等处理。现在的支付模拟只做了简单的状态更新,真实对接支付平台时,回调可能重复推送,后端必须通过订单号和交易流水号做幂等校验,保证同一笔订单只被处理一次。
第二,座位选择的更精细规则。比如紧急出口座位需要特殊资质验证、儿童不能坐在安全出口旁、会员等级可以免费选座、付费选座和免费选座的区分,这些都是真实业务里非常常见的规则。
第三,引入消息队列处理订单超时。现在定时任务五分钟扫描一次,极端情况下座位锁定时间会超过预定时间。换成延迟消息后,创建订单时发送一条十五分钟后消费的延迟消息,消息一旦消费,就释放未支付订单的座位,精确度会高很多。
第四,前端增加实时座位状态推送。用户在看座位图时,如果别人刚订走了一个座位,可以通过 WebSocket 推送实时刷新,避免用户选完之后提交时才提示座位已被占用。
我个人在迭代这个 v810b 版本时最深的一个体会是:不要迷信架构或者框架,真正决定系统质量的是状态管理和边界条件的处理。座位图、订单状态、支付回调,每一个环节单独拎出来都不难,难的是把它们串联成一条不会出错的事务链。把这条链子想清楚,机票预订系统做明白了,其他库存类的系统也基本能触类旁通。
