先晒一下这个项目的背景:私人定制旅游,在国内OTA行业里一直是个“看着盘子大、落地特别碎”的领域。用户不想要千篇一律的跟团线路,要的是“我带着老人孩子、预算八千、六天五晚、想去滇西北、不要太累”这种模糊需求,然后由平台帮他把交通、住宿、景点、节奏全部编排成一条可执行的线路。这篇文章要说的就是基于Spring MVC+MyBatis+MySQL这套经典Java Web技术栈,把这样一套私人定制旅游网从零做出来的完整过程。
这个项目最适合谁看?一是正在做Java课程设计、毕业设计、或者简历项目的同学,二是工作后想补一套“标准SSM+MySQL企业级写法”的新人。它不是那种只写了几个CRUD的玩具,而是把定制业务中“需求—方案—订单”这条主线完整实现,并且把事务、缓存、动态SQL、索引优化这些面试必问的点全部落到代码里。我尽量把每个设计决定背后的原因讲透,不只是给你看代码。
1. 先把业务想明白:私人定制旅游网到底要管哪些事
1.1 定制旅游和传统旅游平台的本质区别
传统旅游平台的核心是“标品”。线路定好了、日期定好了、价格定好了,用户只需要选。产品经理画原型时脑子是清楚的:搜索、筛选、下单、支付、出票。
定制旅游完全反过来,一开始什么都没有。用户先给一堆约束条件:目的地倾向、天数、预算、同行人构成、游玩偏好、对住宿的要求。这些信息进入系统之后,需要被翻译成一个可执行的旅行方案,再进入报价、确认、下单流程。也就是说,系统的核心不是“卖货”,而是“根据需求生成商品”。
这个差别直接决定了数据库怎么设计、状态机怎么流转、哪些字段必须在什么阶段落库。很多新手做旅游网站喜欢把所有东西塞进一个“线路表”,然后用户和商品直接关联,这在定制场景下根本跑不通。因为方案是一对一生成的,每条方案都该有自己的生命周期。
1.2 核心业务模块拆解与边界划分
我按用户角色和业务流程把整个系统分成四个大块:
- 用户端模块:需求提交与查询、推荐方案浏览、方案确认与下单、订单查询、订单评价。
- 运营管理端模块:需求审核与状态更新、方案生成与编辑、资源管理(景点、酒店、交通方式)、价格与库存维护。
- 基础支撑模块:用户注册登录、个人中心、公共数据字典(城市、分类、标签)。
- 订单与支付模块:订单生成、订单状态流转、优惠金额计算、模拟支付回调、订单超时取消(可选)。
这里要注意一个边界问题:方案生成到底是系统自动的还是运营人工的?我见过很多课程设计把方案生成做成全自动算法,结果效果特别假。实际上业内的做法通常是“系统推荐+人工调整”。系统根据标签匹配出候选资源,运营人员在后台微调、排序、确认。这样既保证了可行性,也降低了算法复杂度。项目里我采用的就是这种模式:系统生成推荐方案草稿,管理端人工确认后发布给用户。
1.3 技术选型背后的考量:为什么要用SSM这套组合
有人可能会问,现在新项目不都Spring Boot吗,为什么还写Spring MVC+MyBatis的SSM组合?
我的看法是:SSM结构对“理解框架本质”这件事有不可替代的价值。Spring Boot把东西都自动配置好了,很多新手写了半年代码都不知道DispatcherServlet在哪、SqlSessionFactory什么时候初始化。Spring MVC是Spring家族Web层的基石,MyBatis是当前国内最主流的持久层框架,MySQL是中小型项目绕不开的关系型数据库。它们三个组合起来,刚好让你从头到尾手工把一条请求链路搭出来。
而且这套技术栈覆盖的面试知识点特别密集:Spring容器的父子关系、MyBatis的SqlSession生命周期、Mapper动态代理原理、事务传播机制、MySQL的锁和事务隔离级别……随便挑一个都能聊出深度。简历里写“熟练使用Spring Boot”的人太多了,但能讲清楚SSM底层原理的明显更有说服力。
技术选型上还有一个小建议:如果你是在做毕业设计,项目里最好不要依赖特别偏门的东西。就用标准的Maven工程、标准的War包部署、标准的XML配置,反而显得基本功扎实。
2. 数据库设计:订单驱动业务的表结构搭建
数据库是这类项目的命脉。我把表结构设计放在写任何业务代码之前完成,因为在后期再改表结构,牵一发而动全身。下面直接说核心表的设计思路。
2.1 核心表与字段设计说明
整个系统我拆出了九张核心表。这里必须把最关键的几个讲清楚,不能只是扔一个建表脚本。
用户表 user
id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(20), phone VARCHAR(20), email VARCHAR(50), avatar VARCHAR(255), role TINYINT NOT NULL DEFAULT 0 COMMENT '0-普通用户 1-运营', status TINYINT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL密码我没用明文,直接存的MD5加盐之后的结果。你可能会觉得MD5落伍了,但作为单机部署的Web应用,MD5加盐足够,而且它让项目演示和答辩时更好解释。如果想让简历上加分,可以把这段改成BCrypt加密,思路是一样的。
定制需求表 travel_demand
id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, destination VARCHAR(100) COMMENT '意向目的地', days INT COMMENT '出行天数', budget_min DECIMAL(10,2) COMMENT '预算下限', budget_max DECIMAL(10,2) COMMENT '预算上限', travel_date DATE COMMENT '期望出发日期', people_count INT COMMENT '出行人数', companion_type VARCHAR(20) COMMENT '同行人:老人/亲子/朋友/情侣', preference_tags VARCHAR(255) COMMENT '偏好标签,逗号分隔', description TEXT COMMENT '用户补充说明', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待审核 1-方案生成中 2-待确认 3-已确认 4-已取消', create_time DATETIME NOT NULL这张表是整个系统的“需求端入口”。我特意把预算拆成min和max两个字段,而不是只存一个总预算。原因是定制旅游的匹配逻辑里,要根据预算范围做过滤,单个字段不好写范围条件。
2.2 需求匹配资源:标签机制的设计
方案生成的时候,系统需要根据用户的偏好标签去匹配景点、酒店和交通资源。所以资源表都要有一个标签字段。比如景点表:
CREATE TABLE scenic_spot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, city VARCHAR(50), intro TEXT, open_time VARCHAR(50), ticket_price DECIMAL(10,2), tags VARCHAR(255) COMMENT '标签,逗号分隔,如:古镇,亲子,研学', score DECIMAL(3,1) COMMENT '评分', hot INT DEFAULT 0 COMMENT '热度,用于推荐排序', status TINYINT DEFAULT 1 );标签用逗号分隔的简单字符串存储,查询时用FIND_IN_SET或者LIKE匹配。这个方案在数据量不大时完全够用,而且直观好维护。如果你想做成更规范的模式,可以拆一张tag表和一张scenic_spot_tag关系表,但在毕业设计这个体量下会显得有点过度设计,我建议不要为了炫技把系统弄复杂。
关键点来了:匹配逻辑不要只靠标签。预算和出行人数也会影响资源选择。比如用户预算只有三千,你不能给他推荐五星级酒店。所以匹配的时候我会同时用标签和价格做一个交集筛选,再按热度排序。
2.3 方案快照和订单表的设计思路
方案生成之后,方案主表和明细表是这样的:
CREATE TABLE travel_plan ( id BIGINT PRIMARY KEY AUTO_INCREMENT, demand_id BIGINT NOT NULL, plan_name VARCHAR(100), total_price DECIMAL(10,2), status TINYINT DEFAULT 0 COMMENT '0-草稿 1-已发布 2-已确认 3-已下单', create_time DATETIME, update_time DATETIME ); CREATE TABLE travel_plan_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plan_id BIGINT NOT NULL, item_type TINYINT COMMENT '1-景点 2-酒店 3-交通', resource_id BIGINT COMMENT '原始资源ID', resource_name VARCHAR(100) COMMENT '资源名称快照', resource_desc VARCHAR(500), price DECIMAL(10,2), quantity INT, day_number INT COMMENT '行程第几天', sort_order INT COMMENT '同一天内的排序' );这里我坚持了一个原则:明细表里除了存resource_id,还把名称、描述、价格这些字段做了快照。原因很简单:景区未来可能会改价、酒店可能调整描述,但用户已经看到的方案不能跟着变,否则就是一个“出尔反尔”的系统。快照字段让订单的追溯变得安全,也天然解决了资源变更影响历史订单的经典问题。
订单表单独设计,不直接挂在需求表上。因为一个需求可能出多版方案,用户最终只对其中一版确认并下单。订单表里关联plan_id,同时冗余一份总价和状态字段。这种设计让订单模块和方案模块解耦,运营改方案的时候不会误碰订单数据。
2.4 索引、唯一约束与事务隔离级别的取舍
数据库层面有三件容易被新手忽略的事:
- 唯一索引。用户名要加唯一索引,订单号要有唯一约束。业务上用户重复提交同一个需求、或者运营重复执行同一方案生成操作,这些都需要在数据库层面兜底。
- 普通索引。需求表按
user_id建索引,订单表按status和create_time建联合索引。不要小看这种“小而准”的索引,它让用户查看“我的需求列表”“我的订单列表”这类高频查询稳稳走索引。 - 事务隔离级别。MySQL InnoDB默认是Repeatable Read。这个默认值其实已经够用,我没有特意改成Read Committed。因为在“需求提交、方案生成、订单创建”这几个核心操作里,我们真正依赖的是事务的原子性和一致性,而不是复杂的隔离级别。项目里也没有出现幻读的典型业务场景,所以保持默认即可。
建表完成以后,马上造一批贴近真实的造数脚本。这个非常关键——方案匹配调试的时候,只有几条假数据根本看不出问题。我准备了十几个城市、三十多个景点、十几家酒店,足够演示整个流程。
3. Spring MVC+MyBatis项目搭建与核心配置
3.1 三层架构代码组织与包结构
工程我用标准的Maven多模块拆分,但考虑到这个项目不算大,就用单模块多包结构:
com.travel ├── controller // Spring MVC 控制层 ├── service // 业务接口和实现 ├── mapper // MyBatis Mapper 接口 ├── model // 实体类,对应数据库表 ├── dto // 前端入参对象 ├── vo // 前端出参对象 ├── common // 通用返回结果、异常处理、工具类 ├── config // 配置类 └── interceptor // 拦截器Controller只做三件事:接收参数、调用Service、包装返回结果。Service负责业务逻辑,事务注解打在这一层。Mapper就是一个接口,SQL写在XML里。这样的分层逻辑清晰到哪怕一个没写过SSM的人,看十分钟也能顺着代码走通一条请求。
3.2 Spring MVC核心配置与请求流转
Spring MVC这套配置虽然现在看有点“老派”,但它把框架的运转讲得非常清楚。我在web.xml里配置DispatcherServlet:
<servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>/WEB-INF/spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>这里有个特别重要的点,在面试中经常被问到:父子容器。DispatcherServlet启动时,会创建一个基于spring-mvc.xml的子容器,用来扫描Controller。而业务层的Service、Mapper,应该在父容器中创建,由ContextLoaderListener加载。
我在spring-mvc.xml里只配置:
<context:component-scan base-package="com.travel.controller"/>在另一个applicationContext.xml里配置:
<context:component-scan base-package="com.travel.service,com.travel.mapper"/> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.travel.mapper"/> </bean>为什么Controller要单独放一个子容器?因为Web层的Bean不需要被Service层引用,而且Spring MVC容器在初始化时如果扫描到Service,会再创建一套重复的Bean。这个问题排查起来很隐蔽,我当时一开始直接把包全让Spring MVC扫了,结果出现一个Service被实例化两次的怪现象。
拦截器方面,我注册了一个登录拦截器和一个运营权限拦截器,定义在mvc:interceptors里。用户请求/api/user/**时必须带Session,运营端/api/admin/**除了登录还要校验角色字段。
还要注意JSON的返回格式。Spring MVC用@ResponseBody返回对象时候,Jackson会帮我们把对象序列化成JSON。我自定义了一个统一返回体:
public class Result<T> { private Integer code; private String message; private T data; // getter/setter }所有Controller返回都是Result,这样前端处理状态和消息时逻辑完全一致,简单又统一。
3.3 MyBatis全局配置与XMLConfigBuilder初始化流程
MyBatis这块我觉得有必要把初始化的流程讲透,因为热词里反复出现XMLConfigBuilder,这确实是理解MyBatis源码的关键入口。我配置的mybatis-config.xml长这样:
<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="STDOUT_LOGGING"/> <setting name="cacheEnabled" value="true"/> </settings> <typeAliases> <package name="com.travel.model"/> </typeAliases> </configuration>当SqlSessionFactoryBean初始化时,MyBatis的XMLConfigBuilder会在背后做这些事:
- 解析
<configuration>根节点,创建Configuration对象。 - 依次解析
<properties>、<settings>、<typeAliases>、<typeHandlers>、<objectFactory>、<environments>、<mappers>这些子节点。 - 每个
<mapper>标签通过XMLMapperBuilder解析对应的XML文件,一个语句一个语句地构建MappedStatement。 - 最后基于Configuration创建
DefaultSqlSessionFactory。
这一步完成后,Mapper接口通过JDK动态代理生成代理对象,真正的SQL逻辑全部集中在XML里。我面试的时候被打到过“Mapper接口为什么能直接调用”,答案就是MyBatis在MapperRegistry里为每个接口生成MapperProxyFactory,调用时通过MapperProxy拦截,最终拿到MappedStatement去执行SQL。
配置里启用了mapUnderscoreToCamelCase,这样数据库create_time字段就能自动映射成Java实体的createTime,不用写几百行resultMap。我建议字段命名规规矩矩用下划线风格,Java属性一律驼峰,这一条配置能省掉成吨的样板代码。
3.4 Mapper XML实战:定制需求动态查询
需求列表页通常有多个筛选条件:按用户查、按状态查、按目的地模糊查。这时候MyBatis动态SQL的价值就出来了。我在DemandMapper.xml里写了一个带<where>标签的查询:
<select id="findByCondition" resultType="TravelDemand"> SELECT * FROM travel_demand <where> <if test="userId != null"> AND user_id = #{userId} </if> <if test="status != null"> AND status = #{status} </if> <if test="destination != null and destination != ''"> AND destination LIKE CONCAT('%', #{destination}, '%') </if> <if test="preferenceTag != null and preferenceTag != ''"> AND FIND_IN_SET(#{preferenceTag}, preference_tags) </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select><where>标签会自动去掉第一个多余的AND,这比人工写WHERE 1=1干净得多。线上项目里我见过不少WHERE 1=1的写法,虽然能跑,但看着别扭,也让索引优化变得更难。
动态SQL的if判断有几个常见的坑,后面我会单独开一节讲,这里先记住一件事:不要在<if test>里写复杂的函数调用,只做简单的非空判断和相等比较,否则报错的时候特别难排查。
分页我这里用了最简单的LIMIT #{offset}, #{pageSize}。Entity层传pageNum和pageSize,在Service里计算offset。如果你用PageHelper插件,方法上直接套一个PageHelper.startPage(pageNum, pageSize)就行,它底层用的是拦截器,把分页逻辑挂到了执行链上。
4. 从需求提交到方案生成的完整实现
业务流程是这类系统的灵魂,我沿着“提交需求 → 推荐匹配 → 运营生成方案 → 用户确认下单”这条主线,把你最终要写在项目里的核心逻辑逐段拆开。
4.1 需求提交接口的实现链路
用户在前端填完一份定制需求表单后,POST到/api/demand/submit。Controller收到的是一个DemandSubmitDTO:
@PostMapping("/submit") public Result<Long> submit(@RequestBody @Valid DemandSubmitDTO dto, @SessionAttribute("loginUser") User loginUser) { Long demandId = demandService.submit(dto, loginUser.getId()); return Result.success(demandId); }Service里的实现要点:
@Transactional(rollbackFor = Exception.class) public Long submit(DemandSubmitDTO dto, Long userId) { TravelDemand demand = new TravelDemand(); BeanUtils.copyProperties(dto, demand); demand.setUserId(userId); demand.setStatus(0); demand.setCreateTime(new Date()); demandMapper.insert(demand); return demand.getId(); }DTO上的校验注解@NotBlank@NotNull是JSR-303标准的体现,我这个小项目里用到了参数校验,但没有去引入额外的参数校验框架,Spring MVC自带的支持已经够用。
为什么事务要加在这里?因为将来需求提交之后,可能还要连带记录一条操作日志、或者初始化一个匹配任务。这些属于“同一次业务操作”的多个写入,要么全部成功,要么全部失败。@Transactional注解让submit()方法成为一个原子操作,中间任何一个Mapper抛出异常,整个事务都会回滚,不会出现“需求插进去了,日志没写进去”的半截状态。
4.2 资源匹配与方案推荐逻辑
用户的需求入库后,状态变成“待审核”。运营在后台点“开始生成推荐方案”,触发匹配逻辑。这里我写了一个PlanGeneratorService,它的核心方法分三步:
第一步:根据需求里的目的地和偏好标签,去资源表里找候选资源。
List<ScenicSpot> spots = scenicSpotMapper.findByTagsAndCity( demand.getPreferenceTags(), demand.getDestination());第二步:按评分和热度做降序排序。
第三步:因为一个方案最少要覆盖“景点+酒店+交通”三类资源,我会把景点按城市聚合,酒店按星级过滤,交通按预订码存放,拼出一条包含每天行程的“草稿方案”。
这段逻辑我建议控制在三到五百行以内,不要无限加算法权重。我在迭代这个项目时,踩过的最大坑是试图做一个“智能化推荐系统”,最后搞出一个巨复杂的评分模型,根本没法演示。对课程设计和业务起步系统来说,基于标签和价格的过滤排序已经是完整可用的匹配逻辑了,它体现了业务思考,又不会把自己绕进去。
草稿方案生成后,插入travel_plan主表,状态为0。运营可以在后台拆开看方案详情、手动调整资源顺序,确认没问题后点击“发布”,方案状态变为1。
4.3 管理端方案录入与行程明细绑定
运营编辑方案的做法,是前端生成一个包含“每天去哪、住哪、怎么坐车”的JSON数组,一次性传给后台。我的Controller接收的是PlanSaveDTO,里面是一个List:
List<PlanItemDTO> items;Service里先把方案主表记录更新,然后删除旧的明细、批量插入新的明细:
@Transactional(rollbackFor = Exception.class) public void savePlan(PlanSaveDTO dto) { TravelPlan plan = new TravelPlan(); plan.setId(dto.getPlanId()); plan.setTotalPrice(dto.getTotalPrice()); plan.setStatus(1); travelPlanMapper.update(plan); travelPlanItemMapper.deleteByPlanId(dto.getPlanId()); for (PlanItemDTO item : dto.getItems()) { TravelPlanItem entity = new TravelPlanItem(); BeanUtils.copyProperties(item, entity); entity.setPlanId(dto.getPlanId()); travelPlanItemMapper.insert(entity); } }这里事务的意义更加明显:删除旧明细和插入新明细之间,如果半路失败,方案会丢失全部明细。加了事务以后,用户看到的永远是完整的方案,要么都是新数据,要么全部回滚到原状态。
提到删除再插入,可能有人会担心性能,其实量级很小,没问题。但要注意:删除要按plan_id范围删,不要写delete from travel_plan_item这种不带条件的毁灭级SQL。
4.4 订单确认与状态机流转
用户在前端看到方案后,点击“确认并下单”。这个操作背后有一个经典的并发问题:用户连点两次、或者多个用户同时确认同一版方案,会导致重复下单。我解决的办法是乐观锁加状态判断。
@Transactional(rollbackFor = Exception.class) public Long confirmOrder(Long planId, Long userId) { // 查出方案 TravelPlan plan = travelPlanMapper.selectById(planId); if (plan == null || plan.getStatus() != 1) { throw new BizException("方案不可下单"); } // 原子更新方案状态,条件带status=1 int updated = travelPlanMapper.updateStatusToOrdered(planId, 1); if (updated == 0) { throw new BizException("方案已被处理,请刷新后重试"); } // 生成的订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setPlanId(planId); order.setUserId(userId); order.setTotalPrice(plan.getTotalPrice()); order.setStatus(0); orderMapper.insert(order); return order.getId(); }updateStatusToOrdered的SQL是:
<update id="updateStatusToOrdered"> UPDATE travel_plan SET status = 2, update_time = NOW() WHERE id = #{planId} AND status = 1 </update>这条UPDATE利用数据库的行锁,把“方案状态是否还是1”和“把状态改成2”做成一个原子操作。两个并发请求同时杀过来,只有一条UPDATE能影响1行,另一个更新0行直接失败。这就是乐观锁在数据库层落地的方式,不需要program里显式加锁。
订单状态我用一个status字段做状态机流转:0-待支付,1-已支付,2-服务中,3-已完成,4-已取消。每种状态都只能在固定的前置状态下迁移,实现方式就是上面的“条件UPDATE”套路。支付我这里用模拟回调,收到消息后执行orderMapper.updateStatus(0, 1)。
5. 系统优化与缓存应用
项目能跑通之后,下一步就是让它跑得更稳更快,这里涉及的知识点也是面试中高频出现的内容。
5.1 MyBatis一级缓存和二级缓存的实际表现
热词里经常出现“MyBatis一级缓存”“MyBatis二级缓存”,很多人背了概念却不知道在自己项目里该怎么用。我直接把这两个缓存放到项目里的不同位置,给出真实效果。
一级缓存是SqlSession级别的,作用域极小。Spring集成MyBatis之后,默认每次Mapper操作都会new一个SqlSession,一级缓存基本留不住,除非你开启Spring的事务,让多次Mapper调用共享同一个SqlSession。测试下来你会发现,同一个方法里连续查两次同一条数据,第二次不会再查数据库。这个现象在事务边界内才会发生。
二级缓存是namespace级别的,也就是同一个Mapper接口共享。我在scenic_spot对应的MapperXML里加了:
<cache/>默认的二级缓存使用PerpetualCache,本机内存存储。加上之后,同一个ScenicSpotMapper查询的结果会被缓存。但是要特别小心:如果某一个update或insert操作发生,MyBatis会清空该namespace下的所有缓存,保证数据不脏。
我在这个项目中只给“景点目录”开了二级缓存,因为景点数据几乎不变,查询又特别频繁。用户点开城市列表时反复查同一个景点列表,缓存命中率非常高。而订单、需求这些写多读多的业务表,我坚决不开二级缓存,没必要。
注意分布式环境下的坑:本地二级缓存解决不了多实例部署的数据一致问题。你现在用单机项目没问题,但如果上了集群,这个缓存必须换成Redis共享缓存。可以在回答时加一句“项目单体阶段用MyBatis二级缓存,后续扩展再造一个Redis模块迁移”来体现你的扩展意识。
5.2 热点数据缓存策略(城市、景点目录)
除了依赖MyBatis缓存,我在Service层做了一个“本地缓存”的轻量方案。用一个带有过期逻辑的Map来缓存高频调用的城市列表和景点标签云,代码如下:
@Component public class ResourceLocalCache { private Map<String, List<ScenicSpot>> spotCache = new ConcurrentHashMap<>(); public List<ScenicSpot> getSpotsByCity(String city) { return spotCache.computeIfAbsent(city, k -> spotMapper.selectByCity(k)); } }这属于最简单的缓存方案。实际生产环境中,这一步应该抽象成Redis + Spring Cache,@Cacheable注解一行就能搞定。我在文章中提这个低配本地缓存,是想说明一个道理:缓存不是越高级越好,而是要在合适的规模下用合适的方案。你只要能解释清楚“为什么当前用本地缓存够用”“什么时候该迁移Redis”,面试官反而会觉得你考虑得很实际。
5.3 MySQL执行计划分析与慢查询优化
项目做完了肯定要面临一个问题:用户量再涨,查询变慢了怎么办?我给自己留了一个排查步骤。
第一步是打开MySQL慢查询日志,或者直接看线上的慢SQL。我本地调试时直接在Navicat里执行EXPLAIN SELECT ...,重点看三个列:
type:如果是ALL,说明全表扫描,危险。key:实际用到的索引。如果是NULL,说明索引没生效。rows:扫描行数,越大越慢。
部署时我给travel_demand表加了索引:
ALTER TABLE travel_demand ADD INDEX idx_user_id (user_id); ALTER TABLE travel_demand ADD INDEX idx_status_create_time (status, create_time);分页深翻页的时候有一个经典优化。比如后台查询第10000页,LIMIT 99990, 10会扫描前面99990条再丢弃,非常浪费。我优化的办法是:先通过索引查出最小ID或最大ID,再用这个ID做边界过滤:
SELECT * FROM travel_demand WHERE create_time < '2024-01-01' ORDER BY id DESC LIMIT 10;在数据量还不大的阶段,这套手段已经足够。如果未来数据到了千万级别,就得考虑引入搜索引擎或者分库分表了,但那是另一个维度的架构题,不需要在SSM项目里过度预演。
6. 常见问题与排查实录(踩坑集合)
这个项目开发过程中我踩了不少坑,很多是程序员反复会遇到的经典问题,我挑几个最有代表性的整理出来。
6.1 MyBatis动态SQL不生效的典型场景
热词里出现“mybatis条件不生效”,这真的是高频Bug。我遇到一次“按标签筛选”没生效,排查半天发现是if标签里的判断出了问题。
场景是这样的:用户不选标签时,preferenceTags是空字符串;选中时是"古镇,亲子"。我的if写法:
<if test="preferenceTag != null and preferenceTag != ''">理论上没问题。但实际测试发现,当preferenceTag为""时,这个条件其实还是通过了,最后生成的SQL是AND FIND_IN_SET('', preference_tags),结果什么都查不到。
后来我把测试数据用日志打出来看,发现空字符串并没有拦住,因为preferenceTag在进入if判断前已经被做了空值处理。真正的修复是在Service层统一判断,如果标签为空,根本不需要拼这个条件。这里我总结出的经验是:动态SQL的正确性严重依赖数据的规范化,入参请先在Service层统一清洗,不要把一个null、空字符串、空格串的混合体丢给MyBatis去判断。
还有朋友遇到过if test里写字符串比较用单引号导致的歧义问题,比如:
<if test="status == '1'">这个写法在某些版本中会因为类型推断不一致而报错。建议改成status == 1配合整型字段,或者用双引号包住字符串,规避解析歧义。
6.2 MySQL连接报SSL错误的处理
本机连接MySQL报Communications link failure,后面跟着一堆SSL相关提示,这是MySQL 8.0的默认行为。它会默认开启SSL,而本地开发环境的证书又对不上。我在JDBC连接串里加了两个参数:
jdbc.url=jdbc:mysql://localhost:3306/travel?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8&allowPublicKeyRetrieval=trueallowPublicKeyRetrieval=true是因为MySQL 8.0的默认认证插件是caching_sha2_password,首次连接时需要从服务器获取公钥,如果不开会报Public Key Retrieval is not allowed。这是本地开发的标准操作,不是安全隐患,但在生产环境里请走正规的SSL证书方案。
6.3 分页查询排序错乱
系统上线后,后台人员反馈“用户列表翻页时,数据顺序变来变去”。这是个很经典的问题:只按create_time排序,但是create_time不是唯一列。同一秒内插入的数据可能有多个,它们的create_time相同,排序时MySQL对这些记录的返回顺序不保证稳定,于是翻页时出现数据重复或者漏掉。
解决方案很简单:排序字段加一个唯一性强的主键兜底。
ORDER BY create_time DESC, id DESC我在所有涉及分页的列表查询里,都补上了id DESC,问题立刻消失。这是个不值得写进书里、但实际项目里一定会踩到的点。
6.4 事务不回滚的典型原因
我调试方案保存接口时,发现明细插入到一半抛异常,但库里居然残留了半批数据。一查,事务根本没生效。
最常见的原因是这几种:
@Transactional方法被同类内部的另一个方法调用,事务不生效。Spring事务默认通过AOP代理实现,同类内部调用不走代理,注解形同虚设。- 异常被方法内try-catch吞掉了,上层拿不到异常就不会回滚。“下单失败但订单生成了一半”很多都是这种原因。
- 数据库表是MyISAM引擎,压根不支持事务。现在默认应该是InnoDB,但如果是老数据库迁移,表引擎要检查一下。
rollbackFor没配置,遇到自定义业务异常(运行时异常)默认不回滚。
我项目里把自定义BizException定义成RuntimeException的子类,然后在Service方法上写:
@Transactional(rollbackFor = Exception.class)对所有异常统一回滚。记住这个配置很重要,因为Spring的@Transactional默认只回滚RuntimeException和Error,如果你抛了一个普通Checked Exception,事务是不会回滚的。
最后再分享一个我个人的体会:这套项目做完之后,我对SSM各组件协作的认知比之前三年零零散散写代码都深刻。很多面试题其实问的不是框架API,而是框架背后的设计思路和边界条件处理。比如“动态SQL为什么不生效”,本质是数据规范化问题;“事务为什么没回滚”,本质是AOP代理机制问题。你能把这些问题在真实项目中捋清楚,给你一个Spring Boot的新项目,你也就不会只停留在会用注解的层面。如果需要继续扩展,这套系统后续完全可以平滑迁移到Spring Boot + Redis + 分布式锁,骨架和业务模型都不用改,反而会让你看到技术演进中哪些东西在变、哪些东西一直没变。