1. 在线商超购物系统的设计动机与整体定位
“Java 基于 SSM+JSP 的在线商超购物系统”,这个项目名只要你在任何技术社区搜过 Java Web 课程设计或者毕业设计,绝对会出现。它是电商类项目里最经典、也最适合练手的一类:用户在前台注册登录、浏览商品、搜索分类、管理购物车、提交订单、查看订单状态;管理员在后台维护商品分类、上下架商品、处理订单。说白了,它把一个真实超市的“人找货、货入筐、筐变单、单被处理”四个基本动作完整搬到了网页上。
我见过非常多同学做完这个项目就往简历上一写,结果面试官一问就露怯。原因不是代码写得烂,而是他们根本不理解自己搭出来的每一层到底在解决什么问题。这个系统看起来功能简单,但它背后藏着的 SSM 框架整合、数据库建模、事务控制、会话状态管理、分页搜索方案,随便拎一个出来都能在面试里聊上十分钟。所以这篇文章的目标很直接:带你把这个项目的设计逻辑和技术细节全部捋一遍,不仅知道怎么把系统跑起来,还要知道每一步“为什么”要这样做。
系统整体拆开看,角色只有两个:买家和管理员。买家侧的完整链路是:注册/登录 -> 浏览首页商品 -> 按分类筛选或关键字搜索 -> 查看商品详情 -> 加入购物车 -> 修改购物车数量 -> 结算生成订单 -> 在“我的订单”里看状态。管理员侧的链路相对短:登录后台 -> 管理分类和商品 -> 修改订单发货状态。两条链路加起来,刚好能把 SSM 三个框架各自的特点都覆盖到,这也是它成为“经典项目”的根本原因。
1.1 一个课程设计和简历项目为何能一直走红
你可能会问,电商项目那么多,为什么偏偏这个标题经久不衰?我用一句话概括:业务复杂度刚刚好,技术覆盖面刚刚好,工作量刚刚好。说它复杂,它没有秒杀、没有优惠券、没有拼团、没有分布式,实在算不上难;但说它简单,它又不是那种“只有一个增删改查”的 demo,它确实有购物车这种需要动脑的业务场景,也有订单与库存这种需要事务保护的关键流程。
这种“刚好卡在中间”的项目,对初学者非常友好。一个 Java 基础过关的人,每天抽三个小时,大概一到两个月能完整做下来。而做完之后,简历上可以写“熟悉 SSM 框架整合”“掌握 MySQL 表设计与 MyBatis 映射”“理解 Spring 事务机制”,这些都是实打实能被面试官追问的点。相比之下,如果你第一个项目就上微服务、消息队列、Redis 缓存,大概率只能抄代码,抄完依然是一片空白。
1.2 系统角色与功能边界
在做系统设计之前,必须先把功能边界画清楚,不然代码写一半就容易跑偏。我按自己常用的划分方式列给你:
- 前台用户端:注册、登录、退出登录、修改个人信息、浏览商品列表、按分类查看商品、关键字搜索、商品详情、加入购物车、修改购物车商品数量、删除购物车商品、结算下单、查看我的订单列表、查看订单详情。
- 后台管理端:管理员登录、分类管理(增删改查)、商品管理(增删改查、上下架)、订单管理(查看订单、修改订单状态)。
这里有一个很容易被忽略的设计细节:购物车在未登录状态下到底该不该开放? 我的建议是必须登录后才可以加购物车和下单,因为购物车的归属关系依赖于 userId。如果允许游客使用购物车,就得引入临时的会话标识,这会让业务复杂不少,对入门项目来说不划算。
1.3 技术栈的基本组成
这套系统的技术选型几乎被标题写死了:Java 作为开发语言,SSM 作为后端框架组合,JSP 作为视图层技术,数据库基本就是 MySQL。但具体到版本搭配和辅助工具,很多人第一次搭就栽了跟头。
常见的基础组合是这样:
- JDK 8 或 JDK 11
- Tomcat 8.5/9.x
- MySQL 5.7/8.0
- Spring 5.2.x + Spring MVC 5.2.x + MyBatis 3.5.x
- Maven 3.6+
- IDEA + Navicat(或者其他数据库客户端)
注意,Spring 5.x 里已经不支持 JDK 6/7,如果你的电脑装的是很老的 JDK,需要先去确认自己的版本。后面我在第四部分会专门讲配置文件怎么配,这里先不急。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SSM + JSP 技术选型复盘:为什么这个组合仍然值得做
熟悉我的同学应该知道,我一直的观点是:技术没有过不过时,只有适不适合当前的学习阶段。Spring Boot 确实已经统治了 Java 服务端开发,但那不代表 SSM 就没有存在价值了。事实上,SSM 这套组合在今天反而成了理解 Spring 生态底层原理最好的“解剖教材”。
2.1 Spring、Spring MVC、MyBatis 各自扮演什么角色
很多初学者把 SSM 当成一个整体,其实它们的职责完全不同。我习惯用一个开餐厅的比喻来解释:
Spring 是餐厅的“大堂经理”。它负责把后厨(Service)、服务员(Controller)、采购员(DAO)这些对象都创建好,并且管理它们的生命周期。谁需要依赖谁,由 Spring 统一注入,你不需要每个对象都 new 一次。
Spring MVC 是餐厅的“前台接待”。所有客户请求先到前台,前台根据请求的 URL 分发到对应的服务员。服务员处理完,前台再把响应结果返回给客户。这个“分发”动作靠的就是 DispatcherServlet 和 HandlerMapping 这套机制。
MyBatis 则是后厨和供应商之间的“采购清单”。它负责把 Java 方法调用转成 SQL 语句,查完数据库之后再把结果集封装成 Java 对象。你只需要写接口方法和映射文件,不必像 JDBC 时代那样手动注册驱动、创建 Connection、写 ResultSet 循环。
JSP 在这套体系里扮演的是“展示菜单”的角色。后端把商品列表、用户信息那些数据塞进 request 或 session 作用域里,JSP 用 EL 表达式和 JSTL 标签把它们显示在浏览器上。
这四层各管一段,恰好构成一个完整的请求链路:
code复制浏览器请求 -> Tomcat -> DispatcherServlet -> Controller -> Service -> DAO/MyBatis -> MySQL
响应返回 -> JSP 渲染 -> 浏览器展示
把这条链路时刻记在脑子里,后面所有配置问题都能迎刃而解。
2.2 与 Spring Boot + Vue 的对比:不是过时,而是底层的“慢动作”
Spring Boot 诞生以后,大家发现原来 Spring 的 XML 配置可以这么简洁,内嵌 Tomcat 让部署也方便了。那 SSM 是不是就该被彻底丢掉?
我不这么看。Spring Boot 的价值在于“约定大于配置”,它把大量默认行为替你做好了。但默认行为背后的机制,恰恰是 SSM 时代需要用 XML 显式声明的那些东西。比如 Spring MVC 的视图解析器、拦截器、静态资源映射,在 Boot 里可能只需要一行配置,在 SSM 里你得知道它们是怎么生效的。
所以我把 SSM 理解成 Spring 生态的“慢动作回放”:它把 Spring 容器的初始化过程、Bean 的装配过程、Spring MVC 的请求分发过程,全部原原本本暴露给你看。当你把这一套跑通,再回头看 Spring Boot,你会发现自己对它的理解是完全不同的——你知道那些“魔法”背后是怎么发生的。
JSP 同样如此。现在前端项目基本都是 Vue/React 前后端分离,JSP 确实已经淡出主流,但这不代表 JSP 没有学习价值。它的 JSTL 标签、EL 表达式、请求作用域传递数据的思路,和模板引擎(Thymeleaf、FreeMarker)是相通的。如果你的毕业设计或课程设计要求用 JSP,那就更不用纠结,直接做就是。
2.3 这个组合在面试和职场里的实际价值
这几年面试的时候,不少候选人简历上写着“精通 Spring Boot”,但当我问他“DispatcherServlet 是怎么处理请求的”“@Transactional 是怎么生效的”,很多人答不上来。原因是 Boot 把他该看到的东西都藏起来了,他用得越多,反而对底层越陌生。
而如果你完整做过一个 SSM + JSP 项目,这些问题的答案你已经亲手验证过了。与此同时,职场里那些运行了五六年的老项目,依然有大把是 SSM + JSP 的架构。你要维护它们,就要懂它们。所谓“历史包袱”,对别人是负担,对你如果熟悉这套东西,反而是机会。
3. 数据库模型设计:从用户到订单的完整链路
数据库设计是这类商城系统的地基,表结构设计得不好,写代码时会处处别扭。我见过有些人为了省事,把购物车数据直接存 session,把订单信息塞一张大表,结果后面统计报表、扩展功能的时候痛苦不堪。下面这份设计方案是我验证过很多次、也推荐给很多同学使用过的标准模型。
3.1 核心表结构总览
整个系统我建议拆成六张核心表:
- 用户表 t_user:用户 id、用户名、密码、昵称、手机号、邮箱、地址、注册时间、状态。
- 分类表 t_category:分类 id、分类名称、父分类 id、排序号、创建时间。如果只做一级分类,父分类 id 可以不要,但保留它对后续扩展二级分类很有帮助。
- 商品表 t_product:商品 id、商品名称、分类 id、商品图片路径、描述、价格、库存、销量、上下架状态、创建时间。
- 购物车表 t_cart_item:购物车项 id、用户 id、商品 id、购买数量、加入时间。
- 订单主表 t_order:订单 id、订单编号、用户 id、订单总金额、收货人姓名、收货电话、收货地址、订单状态、创建时间、支付时间。
- 订单明细表 t_order_item:明细 id、订单 id、商品 id、商品名称、商品图片、购买单价、购买数量、小计金额。
创建表的 SQL 我就不整段贴出来了,核心字段记住一件事:外键关系明确,但物理外键不一定要加。很多初学者喜欢在表上直接写 FOREIGN KEY,其实在高并发互联网系统里,物理外键对插入和更新性能有影响,所以大多数项目只用逻辑外键,靠应用层保证数据的完整性。这个点同样是面试时可以说出亮点的地方。
3.2 订单主表和明细表为什么要分开写
订单主表和明细表分离,是购物系统数据库设计里最关键的一课。为什么不能给订单一行记录存所有商品?因为一个订单可能包含多个商品,如果都塞到一行里,就得拆分字段或者用逗号把商品 id 拼接起来,非常不便于统计和查询。
更合理的做法是把“订单本身的信息”和“订单里每个商品的信息”拆成两张表。主表存订单的公共属性:谁下的单、订单总额、收货信息、状态。明细表存这个订单里每一件商品的快照信息:哪个商品、买了几件、当时单价多少、小计多少钱。
这里有个重要的设计思想叫商品快照。为什么不直接关联商品表去查商品名称和价格?因为商品的价格和名称是会变的。如果你下单买了一件商品,明天商家把商品名改掉了,那你以前订单里的记录也会跟着变,这是不合理的。正确的做法是在订单明细表里把当时下单的商品名称、图片、价格全部冗余一份,让订单的历史记录永久定格。以后你就算改了商品表,也不会影响历史订单的展示。
3.3 购物车要不要持久化
购物车表的设计有两种流派:一种是把购物车数据全放 Session,关掉浏览器就没了;另一种是落库,用 t_cart_item 表持久化保存。我强烈建议用第二种。
道理很简单:真实商城系统里,用户把商品加进购物车不一定马上买,可能过两天再回来。如果购物车放 Session,浏览器一关或者 session 过期,购物车直接清空,用户购物体验很糟糕。落库之后,只要用户登录,随时可以把自己名下的购物车数据重新查出来。
购物车项的设计也比较巧妙:t_cart_item 不直接存商品价格,只存用户 id、商品 id 和数量,需要展示价格时再关联商品表查。为什么?因为购物车只是个临时容器,商品价格变动时,购物车应该显示最新价格,不需要像订单一样做快照。这两者的差异对比起来理解,很容易记住。
4. SSM 整合核心配置解读:从 pom.xml 到 spring-mvc.xml
SSM 整合是整个项目里最容易劝退新手的地方。很多人代码写了一堆,结果启动 Tomcat 报各种错:Bean 找不到、文件找不到、路径配错、版本冲突。我的经验是,只要把整合过程拆成“Maven 依赖 -> web.xml -> Spring 配置 -> Spring MVC 配置 -> MyBatis 配置”这条主线,逐个击破,整合并不是什么难事。
4.1 依赖版本怎么搭配不踩坑
SSM 项目依赖版本不能乱选。早年很多人喜欢用很新的版本,结果 Spring 5.x 和 MyBatis 3.4 的某些组合在 JDK 版本不一致时就会报错。我验证过比较稳的一套组合:
- spring-context、spring-webmvc、spring-jdbc 都用 5.2.x(注意 5.x 之后不再支持 JDK 6/7)
- mybatis 3.5.x
- mybatis-spring 2.0.x
- mysql-connector-java 8.0.x(如果你数据库是 MySQL 8.0)
- druid 连接池 1.2.x
- javax.servlet-api 4.0.1(provided 作用域)
- jstl 1.2
- jackson-databind 2.9.x
pom.xml 里最容易出问题的是 javax.servlet-api 的作用域。如果你给它用默认的 compile 作用域,它就会和 Tomcat 自带的 servlet-api 冲突,启动时很可能报 NoClassDefFoundError 或者方法签名不一致。正确做法是:
xml复制<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
provided 的意思是:编译和测试时我用这个 jar,但打包部署时不需要你打进去,因为 Tomcat 里已经有了一份。
4.2 web.xml:整个应用启动的“入口”
SSM 是传统的 war 包部署,应用的启动入口是 web.xml。它主要干三件事:加载 Spring 容器配置、配置 DispatcherServlet 前端控制器、配置字符编码过滤器。
Spring 容器配置通常是这么写的:
xml复制<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:applicationContext.xml</param-value>
</context-param>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
这段配置的作用是:Tomcat 启动时,由 ContextLoaderListener 读取 applicationContext.xml,创建 Spring 容器,把所有 Service、DAO、数据源这些 Bean 初始化好。
DispatcherServlet 的配置通常会制定一个单独的 spring-mvc.xml:
xml复制<servlet>
<servlet-name>springmvc</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:spring-mvc.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>springmvc</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
我经常用一个口诀记忆这两个容器的分工:applicationContext.xml 管后端(Service、DAO、数据源),spring-mvc.xml 管前端(Controller、视图解析、静态资源)。如果两个配置文件里的 Bean 扫描范围没有配好,比如把 Controller 的 @ComponentScan 写在 applicationContext.xml 里,就会导致 Controller 被创建两次,进而出现各种莫名的 Bean 冲突。
4.3 Spring 配置文件与 MyBatis 映射文件的关联
applicationContext.xml 中最核心的两个配置项,一个是数据源,一个是 SqlSessionFactory。
数据源一般用 Druid:
xml复制<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource">
<property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost:3306/supermarket?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"/>
<property name="username" value="root"/>
<property name="password" value="123456"/>
</bean>
注意 url 里 serverTimezone 和 useUnicode 这两个参数,如果不加上,MySQL 8.0 很可能在连接时报时区错误,或者出现中文乱码。
SqlSessionFactory 的配置会把它和 MyBatis 的映射文件关联起来:
xml复制<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource"/>
<property name="mapperLocations" value="classpath:mapper/*.xml"/>
<property name="typeAliasesPackage" value="com.supermarket.entity"/>
</bean>
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
<property name="basePackage" value="com.supermarket.dao"/>
</bean>
mapperLocations 指向 Java 资源目录下所有 MyBatis 的 mapper XML 文件,typeAliasesPackage 让实体类可以不用写全限定名,直接写别名。
最后再配一个事务管理器,并把事务织入 Service 层:
xml复制<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>
<tx:annotation-driven transaction-manager="transactionManager"/>
4.4 常见配置失误
我把这几年帮别人解决过的 SSM 配置问题做了个总结,按出现频率排列:
- Controller 扫描包写错,启动后访问任意 URL 都是 404 或者 500。
- Mapper 扫描包和 Mapper XML 的 namespace 不对应,Spring 容器启动时报 binding exception。
- 忘了配置视图解析器,Controller 返回字符串后直接变成 404。
- 静态资源(CSS/JS/图片)没配置放行,页面样式全丢。
- 数据库驱动版本和 MySQL 版本不匹配,启动时报 “Public Key Retrieval is not allowed”。
每个问题排查起来都能让人心态炸裂,但只要理解了配置背后的职责划分,你就能快速定位问题在哪一层。
5. 用户、购物车、订单三个核心模块的实现思路
配置跑通之后,真正写业务代码反而比较顺畅。三个核心模块里,用户模块相对最简单,购物车模块是第一个需要动脑子的地方,订单模块则是事务处理的关键练习场。
5.1 注册登录与 Session 会话管理
用户注册的本质是一次 insert 操作。注册时需要注意两点:第一是用户名的唯一性检查,要么在数据库层给 username 建唯一索引,要么在 Service 层先 select 再 insert;第二是密码不能明文存储,建议至少用 MD5 加盐,进阶做法是 BCrypt。
登录成功之后,我的习惯是把用户对象塞进 session,而不是只塞一个 userId。这样 JSP 页面上可以直接通过 ${sessionScope.user.userName} 展示用户名,不需要每次从数据库重新查。当然,这个方案在分布式场景下不可行(session 不共享),但这套系统是单体部署,完全没问题。你可以在面试时主动提一句:“我现在的方案适合单机部署,如果要分布式,需要改成 Redis 共享 Session”,这就已经超出大多数候选人的思考深度了。
5.2 购物车模块:数量累加与总价计算
购物车最核心的业务逻辑是“加入购物车”。需要考虑这样的场景:用户已经加过同一件商品,再点加入时,不应该往表里 insert 一条新记录,而应该把原有记录的数量加一。
ServiceImpl 里可以这样判断:
java复制@Override
@Transactional
public boolean addCart(CartItem cartItem) {
// 先查购物车表里是否已有同用户、同商品记录
CartItem existing = cartItemMapper.selectByUserIdAndProductId(cartItem.getUserId(), cartItem.getProductId());
if (existing != null) {
existing.setQuantity(existing.getQuantity() + cartItem.getQuantity());
cartItemMapper.updateByPrimaryKey(existing);
} else {
cartItemMapper.insert(cartItem);
}
return true;
}
购物车列表展示时,需要联表查询商品信息,算出每项的小计和购物车总价。这一步在 Service 层做最好,不要在 Controller 里写一堆循环计算,保持 Controller 只做参数接收和视图跳转。
5.3 下单事务:为什么扣库存一定要和生成订单放在同一个事务里
下单是这个项目里业务逻辑最重的操作,建议完整步骤拆成四步:
- 根据用户 id 查出购物车商品列表。
- 计算订单总金额,生成订单主表记录。
- 遍历购物车项,把每件商品写入订单明细表。
- 扣减对应商品的库存,清空购物车。
这四步必须放在同一个数据库事务里。为什么?因为如果你先扣库存、后生成订单,万一生成订单时报错,库存已经扣了,用户却看不到订单,这就是数据不一致。反过来先写订单、后扣库存,万一库存不足,订单已经生成了,也属于脏数据。只有让它们要么全部成功,要么全部回滚,才能保证一致性。
Spring 里开启事务非常简单:
java复制@Override
@Transactional(rollbackFor = Exception.class)
public boolean createOrder(Order order) {
// 1. 查购物车
// 2. 插入订单主表,获得订单 id
// 3. 循环插入订单明细表
// 4. 扣库存
// 5. 清空购物车
}
rollbackFor = Exception.class 非常关键。Spring 默认情况下只对 RuntimeException 回滚,对受检异常(比如 SQLException)不回滚。如果不设置 rollbackFor,可能出现“数据写了一半但事务没有回滚”的离奇现象。
还有一个细节:扣减库存的 SQL 不要写成先 select 库存再 update,而应该用一条原子更新语句:
sql复制UPDATE t_product SET stock = stock - #{num} WHERE id = #{productId} AND stock >= #{num}
这样做的唯一意思是:如果库存不足,受影响行数是 0,程序就可以立刻感知并抛出“库存不足”的异常,触发整个事务回滚。这是防超卖的初级方案,也是必须掌握的方案。
6. JSP 页面渲染与搜索分页的实践细节
JSP 页面本身不难,但关联上路径、EL 表达式、JSTL 标签、分页参数之后,新手踩坑率极高。这部分我来总结几个最有价值的实践点。
6.1 分页实现:手写 limit 与 PageHelper 的取舍
商品列表分页有两种常见实现。第一种是手写分页:在 Mapper 里写 limit #{offset}, #{pageSize} 的 SQL,然后在 Service 层计算 offset 和总页数。第二种是用 PageHelper 插件,用法上是在查询前调用 PageHelper.startPage(pageNum, pageSize),插件会自动在 MyBatis 执行查询时拼接 limit。
如果只是为了完成项目,两种方式都行。但我建议手写分页跑一遍,因为你必须理解 limit 的两个参数到底是什么意思。limit 的第一个参数是偏移量(从第几条开始查),第二个参数是每页数量。当用户访问第 n 页时,偏移量计算公式是:
code复制offset = (currentPage - 1) * pageSize
理解了这一点,后面即使换 PageHelper,也只是把计算交给插件而已。
分页查询的结果我在 Service 层封装成一个 PageResult 对象,里面包含四个字段:当前页数据列表、总记录数、当前页码、总页数。JSP 页面底部展示分页条时,用 JSTL 的 forEach 循环把页码一个一个显示出来,点击页码时带 ?pageNum=xx 参数重新访问商品列表 Controller。
6.2 关键字搜索与分类筛选组合
搜索功能本质上就是模糊查询。在 Mapper XML 里写:
xml复制<select id="searchProducts" resultType="Product">
SELECT * FROM t_product
<where>
<if test="categoryId != null">
AND category_id = #{categoryId}
</if>
<if test="keyword != null and keyword != ''">
AND name LIKE CONCAT('%', #{keyword}, '%')
</if>
AND status = 1
</where>
ORDER BY id DESC
</select>
这里用
6.3 JSP 里 EL、JSTL 和 URL 路径的心酸事
JSP 页面显示列表的固定套路是:Controller 把 List 数据放进 request,JSP 用 c:forEach 循环渲染。
jsp复制<c:forEach items="${pageResult.list}" var="product">
<tr>
<td>${product.id}</td>
<td>${product.name}</td>
<td>${product.price}</td>
<td><img src="${pageContext.request.contextPath}${product.image}" width="60" height="60"/></td>
<td><a href="${pageContext.request.contextPath}/cart/add?productId=${product.id}">加入购物车</a></td>
</tr>
</c:forEach>
我见过最多的 JSP 问题是页面样式全部丢失,原因十有八九是静态资源路径写成了绝对路径 /css/style.css。如果项目部署在 http://localhost:8080/ 下,这个路径没问题;一旦项目的 contextPath 改成 /supermarket,绝对路径就变成了 http://localhost:8080/css/style.css,找不到资源。正确写法是带上 ${pageContext.request.contextPath}。
如果 JSP 页面里 EL 表达式没有解析,而是原样输出 ${product.name},那么有 90% 的概率是你没引入 JSTL 的 taglib 指令,或者项目里没有 jstl 依赖。忘了加下面这行,页面直接出问题:
jsp复制<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
7. 开发周期里最容易踩的坑与排查思路
这部分我本来想写成“常见问题 FAQ”,但后来想想,还是应该把排查思路一起写出来,不然你遇到问题还是不知道怎么定位。
7.1 启动后 404:多半不是代码问题
Tomcat 启动成功,但访问页面 404,这个情况我遇到不下二十次。排查顺序是有讲究的:
第一步,看请求 URL 是否匹配 Controller 里的 @RequestMapping。比如 Controller 是 @RequestMapping("/product"),方法上是 @RequestMapping("/list"),那么完整访问路径就是 /product/list,而不是 /productList。
第二步,看 web.xml 里 DispatcherServlet 的 url-pattern。如果配的是 /,它可以接管所有请求;如果配的是 /*,拦截范围会更广,容易把 JSP 的渲染请求也截走,导致 404 或者 500。
第三步,看视图解析器的前后缀。如果你配置的前缀是 /WEB-INF/views/,后缀是 .jsp,而你的 JSP 文件放在 /WEB-INF/pages/ 下,Controller 返回的字符串找不到对应视图,就会 404。
这组排查顺序基本覆盖了绝大多数 404 场景。
7.2 中文乱码根源
中文乱码是这个项目里最讨厌的问题,因为它有多个源头,你必须逐个排除。
- 数据库连接 url 少了 characterEncoding=utf8,会导致写入数据库的中文变成 ?。
- JSP 页面没有设置 pageEncoding 和 contentType,会导致页面显示乱码。
- web.xml 缺少 Spring 提供的 CharacterEncodingFilter,会导致表单提交的中文在 Controller 里乱码。
- IDEA 的文件编码设置不对,JSP/Java 文件本身保存时就是乱码,这种情况最隐蔽。
建议从一开始就统一用 UTF-8,给 web.xml 加上字符编码过滤器:
xml复制<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>encodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
同时把 IDEA 的 File Encoding 的三个位置(全局、项目、文件)都设成 UTF-8。
7.3 JAR 包冲突的表现与处理
SSM 项目依赖里最经典的是 slf4j 冲突。日志 jar 版本不一致,启动时报一堆 SLF4J: Failed to load class,虽然不致命但非常烦人。解决办法是打开 Maven 依赖树,找到冲突的 jar 排查版本,或者干脆在 pom.xml 里排除多余的传递依赖。
另一个常见冲突是 servlet-api 和 tomcat 自带的 servlet-api 冲突,这就是前面我强调 provided 作用域的原因。遇到这样的问题,核心思路只有一个:看异常堆栈,找重复类在哪个 jar 里,保留版本合适的那个,排除或者调整作用域。
7.4 自己搭过一遍后为什么建议再用 Spring Boot 重新写一遍
这不是玩笑。等你用 SSM 手写完成一个完整项目后,我非常建议你再用 Spring Boot 重写同一个“在线商超购物系统”。你会发现,Spring Boot 把 web.xml、applicationContext.xml、spring-mvc.xml 那么多配置压缩成了 application.yml 里几行,数据库访问用上 Spring Data JPA 或 MyBatis-Plus 之后,代码量会缩减一大半。
但请注意,重写不是让你把 SSM 那套忘掉,而是让你对照体会 Spring Boot 到底替你做了哪些事。面试的时候,你可以非常自信地说:“我先是手写了 SSM 整合,然后迁移到 Spring Boot,对两者配置和启动原理的差异很清楚。”这种话一说出来,面试官会自动把你归到“真正写过代码”的那一类。
8. 答辩与求职面试中的高频追问
项目写完只是第一步,能经得住答辩和面试的追问才是最终考验。我列几个最高频的问题,把回答思路也一并给你。
8.1 “讲讲你的项目中最复杂的业务逻辑”
这个问题不要回答“商品管理”或者“用户注册”,要选“下单”这个核心场景。你可以这样组织回答:
“系统里最复杂的是下单流程,它需要同时操作订单主表、订单明细表、商品库存和购物车表。我们把它放到一个事务里,用 @Transactional 控制原子性。下单时先生成订单主表拿到订单号,再遍历购物车写入明细,明细里会冗余商品名称、图片、单价,保证历史订单不被商品变更影响。最后扣减库存,如果库存不足就抛出异常让整个事务回滚。购物车清空放在最后,确保前面的操作都成功后才执行。”
这段话既说明了业务,又带出了事务、快照、库存扣减几个关键设计点,信息量很足。
8.2 “如何防止下单超卖”
初级版本就是我在前面提到的原子更新 SQL:
sql复制UPDATE t_product SET stock = stock - #{num} WHERE id = #{productId} AND stock >= #{num}
先说一下为什么不用“先查询再判断再更新”的方案:两个请求同时查到库存是 1,都判断库存足够,都执行更新,结果库存变成 -1,这就是超卖。原子更新则让数据库在更新时自行校验库存是否够。这也是从 MySQL 层面解决并发问题的最简单的方案。如果面试官继续追问“高并发下还有没有更优方案”,你可以提 Redis 预扣库存、分布式锁、消息队列串行化等手段,表示你知道进阶方向在哪里。
8.3 “MyBatis 与 JPA 怎么选”
对比视角可以这样回答:
“MyBatis 更灵活,开发人员掌控 SQL,适合复杂查询和字段动态变化的场景,这也是互联网公司用的比较多的原因。JPA 的自动化程度更高,CRUD 不需要写 SQL,适合业务规则简单、字段稳定的项目,但复杂查询需要对方法命名或 JPQL 有一定学习成本。我们项目里用 MyBatis,是因为商品搜索、分类筛选这些查询场景需要控制 SQL 来照顾性能,而且动态 SQL 写起来比 JPA 的 Specification 直观。”
8.4 “Session 和 Cookie 区别是什么,用户信息放哪”
回答要点:
“Cookie 是客户端存储,由服务器 Set-Cookie 写入,之后每次请求自动带上。Session 是服务端存储,本质是服务端开了一个空间,Key 是 SessionId,通过 Cookie 或者 URL 传回客户端。系统里登录后用户信息放在 session,因为用户对象不希望暴露给客户端,Cookie 里只保存 SessionId。如果项目要集群部署,就要做 Session 共享,比如存 Redis。”
8.5 项目还可以怎么演进
这个问题看似随意,其实是考察你的主动思考能力。你可以讲几个方向:给密码加密改成 BCrypt;给商品缓存加 Redis;搜索改 Elasticsearch;上传商品图片改 OSS;接口鉴权引入 JWT;购物车存储迁 Redis;订单模块接入消息队列做异步通知。每个方向不需要展开,但要让面试官知道你清楚项目目前的局限性。
我个人经验是,每次面试前,把这个项目从数据库表结构到核心流程在脑子里过一遍,然后拿出一张白纸把这几个关键点写一遍。写完基本就稳了。因为你会发现,一个 SSM + JSP 的在线商超购物系统,虽然技术不是最新的,但它逼着你把 Java Web 最基础的那套东西彻底搞明白了,而这些基础,才是后面学习 Spring Boot、微服务这些花哨技术的前提。
