ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SSM框架实战:私人定制旅游网从零到上线全程解析

SSM框架实战:私人定制旅游网从零到上线全程解析 先晒一下这个项目的背景私人定制旅游在国内OTA行业里一直是个“看着盘子大、落地特别碎”的领域。用户不想要千篇一律的跟团线路要的是“我带着老人孩子、预算八千、六天五晚、想去滇西北、不要太累”这种模糊需求然后由平台帮他把交通、住宿、景点、节奏全部编排成一条可执行的线路。这篇文章要说的就是基于Spring MVCMyBatisMySQL这套经典Java Web技术栈把这样一套私人定制旅游网从零做出来的完整过程。这个项目最适合谁看一是正在做Java课程设计、毕业设计、或者简历项目的同学二是工作后想补一套“标准SSMMySQL企业级写法”的新人。它不是那种只写了几个CRUD的玩具而是把定制业务中“需求—方案—订单”这条主线完整实现并且把事务、缓存、动态SQL、索引优化这些面试必问的点全部落到代码里。我尽量把每个设计决定背后的原因讲透不只是给你看代码。1. 先把业务想明白私人定制旅游网到底要管哪些事1.1 定制旅游和传统旅游平台的本质区别传统旅游平台的核心是“标品”。线路定好了、日期定好了、价格定好了用户只需要选。产品经理画原型时脑子是清楚的搜索、筛选、下单、支付、出票。定制旅游完全反过来一开始什么都没有。用户先给一堆约束条件目的地倾向、天数、预算、同行人构成、游玩偏好、对住宿的要求。这些信息进入系统之后需要被翻译成一个可执行的旅行方案再进入报价、确认、下单流程。也就是说系统的核心不是“卖货”而是“根据需求生成商品”。这个差别直接决定了数据库怎么设计、状态机怎么流转、哪些字段必须在什么阶段落库。很多新手做旅游网站喜欢把所有东西塞进一个“线路表”然后用户和商品直接关联这在定制场景下根本跑不通。因为方案是一对一生成的每条方案都该有自己的生命周期。1.2 核心业务模块拆解与边界划分我按用户角色和业务流程把整个系统分成四个大块用户端模块需求提交与查询、推荐方案浏览、方案确认与下单、订单查询、订单评价。运营管理端模块需求审核与状态更新、方案生成与编辑、资源管理景点、酒店、交通方式、价格与库存维护。基础支撑模块用户注册登录、个人中心、公共数据字典城市、分类、标签。订单与支付模块订单生成、订单状态流转、优惠金额计算、模拟支付回调、订单超时取消可选。这里要注意一个边界问题方案生成到底是系统自动的还是运营人工的我见过很多课程设计把方案生成做成全自动算法结果效果特别假。实际上业内的做法通常是“系统推荐人工调整”。系统根据标签匹配出候选资源运营人员在后台微调、排序、确认。这样既保证了可行性也降低了算法复杂度。项目里我采用的就是这种模式系统生成推荐方案草稿管理端人工确认后发布给用户。1.3 技术选型背后的考量为什么要用SSM这套组合有人可能会问现在新项目不都Spring Boot吗为什么还写Spring MVCMyBatis的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 核心表与字段设计说明整个系统我拆出了九张核心表。这里必须把最关键的几个讲清楚不能只是扔一个建表脚本。用户表 userid 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_demandid 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 MVCMyBatis项目搭建与核心配置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里配置DispatcherServletservlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-value/WEB-INF/spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping这里有个特别重要的点在面试中经常被问到父子容器。DispatcherServlet启动时会创建一个基于spring-mvc.xml的子容器用来扫描Controller。而业务层的Service、Mapper应该在父容器中创建由ContextLoaderListener加载。我在spring-mvc.xml里只配置context:component-scan base-packagecom.travel.controller/在另一个applicationContext.xml里配置context:component-scan base-packagecom.travel.service,com.travel.mapper/ bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nameconfigLocation valueclasspath:mybatis-config.xml/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.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 ResultT { 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 namemapUnderscoreToCamelCase valuetrue/ setting namelogImpl valueSTDOUT_LOGGING/ setting namecacheEnabled valuetrue/ /settings typeAliases package namecom.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 idfindByCondition resultTypeTravelDemand SELECT * FROM travel_demand where if testuserId ! null AND user_id #{userId} /if if teststatus ! null AND status #{status} /if if testdestination ! null and destination ! AND destination LIKE CONCAT(%, #{destination}, %) /if if testpreferenceTag ! null and preferenceTag ! AND FIND_IN_SET(#{preferenceTag}, preference_tags) /if /where ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /selectwhere标签会自动去掉第一个多余的AND这比人工写WHERE 11干净得多。线上项目里我见过不少WHERE 11的写法虽然能跑但看着别扭也让索引优化变得更难。动态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收到的是一个DemandSubmitDTOPostMapping(/submit) public ResultLong 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上的校验注解NotBlankNotNull是JSR-303标准的体现我这个小项目里用到了参数校验但没有去引入额外的参数校验框架Spring MVC自带的支持已经够用。为什么事务要加在这里因为将来需求提交之后可能还要连带记录一条操作日志、或者初始化一个匹配任务。这些属于“同一次业务操作”的多个写入要么全部成功要么全部失败。Transactional注解让submit()方法成为一个原子操作中间任何一个Mapper抛出异常整个事务都会回滚不会出现“需求插进去了日志没写进去”的半截状态。4.2 资源匹配与方案推荐逻辑用户的需求入库后状态变成“待审核”。运营在后台点“开始生成推荐方案”触发匹配逻辑。这里我写了一个PlanGeneratorService它的核心方法分三步第一步根据需求里的目的地和偏好标签去资源表里找候选资源。ListScenicSpot spots scenicSpotMapper.findByTagsAndCity( demand.getPreferenceTags(), demand.getDestination());第二步按评分和热度做降序排序。第三步因为一个方案最少要覆盖“景点酒店交通”三类资源我会把景点按城市聚合酒店按星级过滤交通按预订码存放拼出一条包含每天行程的“草稿方案”。这段逻辑我建议控制在三到五百行以内不要无限加算法权重。我在迭代这个项目时踩过的最大坑是试图做一个“智能化推荐系统”最后搞出一个巨复杂的评分模型根本没法演示。对课程设计和业务起步系统来说基于标签和价格的过滤排序已经是完整可用的匹配逻辑了它体现了业务思考又不会把自己绕进去。草稿方案生成后插入travel_plan主表状态为0。运营可以在后台拆开看方案详情、手动调整资源顺序确认没问题后点击“发布”方案状态变为1。4.3 管理端方案录入与行程明细绑定运营编辑方案的做法是前端生成一个包含“每天去哪、住哪、怎么坐车”的JSON数组一次性传给后台。我的Controller接收的是PlanSaveDTO里面是一个ListListPlanItemDTO 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(方案不可下单); } // 原子更新方案状态条件带status1 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 idupdateStatusToOrdered 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 MapString, ListScenicSpot spotCache new ConcurrentHashMap(); public ListScenicSpot getSpotsByCity(String city) { return spotCache.computeIfAbsent(city, k - spotMapper.selectByCity(k)); } }这属于最简单的缓存方案。实际生产环境中这一步应该抽象成Redis Spring CacheCacheable注解一行就能搞定。我在文章中提这个低配本地缓存是想说明一个道理缓存不是越高级越好而是要在合适的规模下用合适的方案。你只要能解释清楚“为什么当前用本地缓存够用”“什么时候该迁移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 testpreferenceTag ! null and preferenceTag ! 理论上没问题。但实际测试发现当preferenceTag为时这个条件其实还是通过了最后生成的SQL是AND FIND_IN_SET(, preference_tags)结果什么都查不到。后来我把测试数据用日志打出来看发现空字符串并没有拦住因为preferenceTag在进入if判断前已经被做了空值处理。真正的修复是在Service层统一判断如果标签为空根本不需要拼这个条件。这里我总结出的经验是动态SQL的正确性严重依赖数据的规范化入参请先在Service层统一清洗不要把一个null、空字符串、空格串的混合体丢给MyBatis去判断。还有朋友遇到过if test里写字符串比较用单引号导致的歧义问题比如if teststatus 1这个写法在某些版本中会因为类型推断不一致而报错。建议改成status 1配合整型字段或者用双引号包住字符串规避解析歧义。6.2 MySQL连接报SSL错误的处理本机连接MySQL报Communications link failure后面跟着一堆SSL相关提示这是MySQL 8.0的默认行为。它会默认开启SSL而本地开发环境的证书又对不上。我在JDBC连接串里加了两个参数jdbc.urljdbc:mysql://localhost:3306/travel?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingUTF-8allowPublicKeyRetrievaltrueallowPublicKeyRetrievaltrue是因为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 分布式锁骨架和业务模型都不用改反而会让你看到技术演进中哪些东西在变、哪些东西一直没变。
返回列表