
简介面向初学者的仿小米在线商城ShoppingMall项目资料采用JSP、Servlet、MySQL、JDBC等Java Web技术整合开发覆盖商品浏览、详情查看、购物车添加、价格自动计算等典型电商功能适合用于课程设计、毕业设计或实训项目。压缩包共2个文件包含项目源码压缩包与商城数据库SQL脚本整体大小约39.94MB源码与数据库脚本分开存放便于导入后快速搭建运行环境。该资料已有28045人学习属于热度较高的电商类项目。阅读源码可以观察JSP页面、Servlet控制层与JDBC数据访问层的调用关系理解从前端请求到后端处理的完整流程SQL脚本提供商品表、用户表、订单表等核心结构和初始数据配合作者给出的项目文章可快速还原系统环境。资源结构紧凑适合从零搭建电商系统的学习者对照拆解。1. JavaWeb仿小米在线商城ShoppingMall一个ServletJSPMySQL的完整练手闭环JavaWeb仿小米在线商城ShoppingMall是很多人在学完黑马程序员javaweb笔记之后动手做的第一个完整项目也是简历上出现频率最高的“javaweb项目完整案例mysql”。它的价值在于不依赖SpringBoot把细节藏起来而是用Servlet控制请求、JSP渲染页面、JDBC读写MySQL把用户登录、商品列表、购物车、下单结算这一整条电商链路亲手打通。我见过不少人把这个项目照抄一遍就跑最后面试被追问“购物车并发扣库存怎么处理”就卡住。这里要讲清楚的是怎么从建表到部署完整复现它以及那些容易让新手翻车的配置和边界。适合已经学完JavaWeb语法、想用完整项目巩固一遍的人。2. 技术选型与分层为什么仿小米商城要用ServletJSP而不是直接SpringBoot2.1 技术栈对比黑马javaweb笔记里的经典组合与SpringBoot的取舍如果你刚好翻过一篇计算机英文文献里关于SpringBoot和JavaWeb对比的内容可能会冒出同一个疑问既然SpringBoot一个注解就能起接口为什么还折腾ServletJSP我的看法是仿小米在线商城ShoppingMall这个项目练的就是Servlet生命周期、HttpSession状态、RequestDispatcher转发、JDBC事务边界这些被框架藏起来的机制。做这个项目时直接用SpringBoot相当于把阻力最小的一条路让出来——数据库访问被MyBatis接管参数绑定被注解接管最后你得到的只是一个没有踩过底层坑的业务demo面试时很难讲出深度。常见做法是使用Servlet 3.1 JSP 2.3 MySQL 5.7/8.0 Tomcat 8.5/9。如果你手头有黑马javaweb笔记里面几乎所有案例都是这个组合。选它的理由有三个第一Servlet规范稳定市面上教材和网课都基于它遇到问题一搜一大把第二JSP在早期JavaWeb项目里承担了模板引擎的角色能直接看到request和session对象在页面上怎么取值这对理解“服务端渲染”特别有帮助第三MySQL配合Navicat导出sql脚本很方便适合把完整项目导入导出。等这份代码完全跑通你再去看MyBatis Plus或者Spring Data JPA会发现它们本质上还是在解决你手动写过的那几个问题连接管理、参数映射、事务提交。到那时候你的视角就不再是“框架实现了一个功能”而是“框架帮我省了哪些步骤”。2.2 仿小米商城的前端页面与静态资源组织仿小米商城ShoppingMall的前端并不需要从零写CSS。小米商城官网早年版本结构不复杂常见做法是下载一份开源商城模板把首页、列表页、详情页、购物车页、结算页的HTML改成JSP。需要注意静态资源不要和JSP页面混在同一个目录。项目里通常有两种布局方式一是把所有JSP直接放在webapp根目录二是放在WEB-INF/views下用Servlet转发进去。我更推荐第二种原因是用户直接访问WEB-INF下的资源会被Tomcat拒绝可以避免未登录就打开后台页面这类越权问题。静态文件放在webapp/static/css、webapp/static/js、webapp/static/images页面里用${pageContext.request.contextPath}/static/...来引用这样才能保证部署到带contextPath的Tomcat后路径不404。目录结构可以这样组织webapp/ static/ css/ js/ images/ WEB-INF/ views/ user/login.jsp user/register.jsp product/list.jsp product/detail.jsp cart/cart.jsp order/confirm.jsp web.xml这个目录的意思很明确所有能直接访问的入口只有Servlet路径JSP一律作为视图放在WEB-INF下面用户没法在地址栏手输/views/order/checkout.jsp来绕过结算流程。注意WEB-INF下还应该放lib目录。传统JavaWeb项目不使用Maven时JDBC驱动、Druid连接池、JSTL的jar包都放在WEB-INF/lib下。如果你用IDEA创建的是Dynamic Web ProjectIDEA默认会维护一个Libraries集合但最终部署时还是要把jar拷进lib。这一步经常被忽略最后Tomcat启动报ClassNotFoundException排查半天才发现依赖没有打进去。2.3 项目分层与核心包Controller、Service、DAO、Entity仿小米商城这类项目最忌讳把所有代码塞进一个Servlet里。常见做法是分成五层entity放数据库映射对象dao放JDBC操作service放业务逻辑web放Servlet控制器util放工具类。一个标准包结构如下com.shoppingmall entity User.java, Product.java, CartItem.java, Order.java, OrderItem.java dao UserDao.java, ProductDao.java, CartDao.java, OrderDao.java service UserService.java, ProductService.java, CartService.java, OrderService.java web UserServlet.java, ProductServlet.java, CartServlet.java, OrderServlet.java, AdminServlet.java util DBUtil.java, PageResult.java, Md5Util.java不要小看这个分层。很多黑马javaweb笔记里的demoDAO里直接写了System.out.printlnService层空无一物Servlet里拼SQL字符串。模仿商城项目一旦到了订单模块你会遇到一个事务要同时插入订单表和订单明细表的问题如果没有Service层做事务边界事务就不知道该放在哪里。我见过最典型的写法是在CartServlet里先new OrderDao插入订单再new OrderItemDao插入明细两段代码中间隔了好几行其他业务结果订单表插入成功、明细表插入失败时数据库里留下半截数据。这种问题在分层清晰的项目里不会出现因为Service层把这些操作包成了一个原子方法。2.4 JSP页面里的EL与JSTL别在页面上写Java脚本仿小米商城ShoppingMall的页面如果沿用老式写法会在JSP里看到一堆% for(int i0; ilist.size(); i){ %。不是说不能用而是页面一旦复杂脚本片段和HTML混在一起浏览器报错时根本分不清是HTML标签闭合问题还是Java语法问题。常见做法是引入JSTL标签库用c:forEach遍历商品列表用c:if判断是否登录用${fn:length(page.list)}取列表长度。在JSP顶部声明% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfn urihttp://java.sun.com/jsp/jstl/functions %注意这个uri在JSP 2.3规范下其实不建议再写java.sun.com很多Tomcat会报警告但功能正常。更干净的写法是用jakarta前缀不过为了兼容Tomcat 8.5我一般还是按传统写法。JSTL标签不仅能简化页面还能避免在JSP里直接调用Service一箭双雕。页面里唯一允许出现Java代码的地方是偶尔需要在c:set里做一点数值计算其他全部交给EL表达式。3. 从建库到JDBC把基础设施一次写对3.1 数据库表设计用户、商品、分类、购物车、订单五张核心表仿小米在线商城ShoppingMall的数据库设计最常用到的是五到六张表。参考黑马javaweb笔记里常见的数据模型我一般这样设计user表存账号密码和收货信息category表存商品分类product表存商品名称、价格、库存、缩略图和上架状态cart_item表存用户加购的商品和数量order表存订单主信息order_item表存订单里的商品快照。之所以订单明细里存商品快照而不是直接关联商品ID是因为商品价格和名称随时可能改订单历史必须保留下单那一刻的数据。下面是精简建表SQL我刻意去掉了外键用逻辑外键代替物理外键这样导入数据时不会被约束卡住。CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password CHAR(32) NOT NULL COMMENT MD5后的密文, phone VARCHAR(20), address VARCHAR(255), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, sort INT DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, name VARCHAR(100) NOT NULL, subtitle VARCHAR(200), price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, main_image VARCHAR(255), status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_category_status (category_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE cart_item ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, checked TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_user_product (user_id, product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已完成 4已取消, receiver_name VARCHAR(30), receiver_phone VARCHAR(20), receiver_address VARCHAR(255), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT, product_name VARCHAR(100) NOT NULL, product_image VARCHAR(255), price DECIMAL(10,2) NOT NULL COMMENT 下单时快照价格, quantity INT NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;表设计有几个参数要注意。user.password用CHAR(32)对应后文用MD5加密后的32位十六进制不要存明文。price用DECIMAL(10,2)不要用FLOAT否则购物车算总价会出现0.10.2的误差。cart_item上加了(user_id, product_id)唯一键同一用户第二次加购时应该走UPDATE而不是INSERT。product表里(category_id, status)的组合索引是为了支持“分类下只看上架商品”的查询后文分页搜索也依赖这个索引。很多同学在建表时喜欢给每列都加VARCHAR(255)但下单时间、状态这类列用DATETIME和TINYINT更合适既节省空间排序时语义也更明确。3.2 写一个够用的JDBC工具类Druid连接池的配置传统JavaWeb项目里用Druid连接池是很常见的做法配置简单而且有监控页可以帮助你排查连接泄漏。先准备src/druid.propertiesdriverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/shoppingmall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue usernameroot password123456 initialSize5 maxActive20 maxWait3000 minIdle5 validationQuerySELECT 1注意url里的useSSLfalse在MySQL 8.0下是必须的allowPublicKeyRetrievaltrue解决的是MySQL 8.0 caching_sha2_password插件导致连不上报Public Key Retrieval is not allowed的问题。serverTimezone必须显式设置否则你拿到的java.sql.Date会比北京时间差8小时。下面是对应的DBUtil类public class DBUtil { private static DruidDataSource dataSource; static { try (InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(druid.properties)) { Properties props new Properties(); props.load(in); dataSource (DruidDataSource) DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }这段代码的逻辑说明把连接池初始化的动作放到静态代码块里进程启动时只执行一次避免每次请求都重新读properties。getConnection返回的是Druid包装后的代理连接调用close()时不是真的关闭连接而是归还到池里。很多新手在这里犯的错是在Servlet里手动DriverManager.getConnection用完不关最后把MySQL连接数打满。有了Druid至少在连接管理上帮你兜了一层。如果发现连接还是不够用优先检查是不是哪段try-with-resources没写或者事务里catch了异常却没有回滚导致连接一直不释放。3.3 分页与结果封装列表页的地基商品列表、订单列表都需要分页常见做法是把分页参数封装成PageResult对象。不要每次都分别在Servlet里拼pageNo页面里再重复统计totalPage后面维护非常痛苦。这个类可以长这样public class PageResultT { private int pageNo; private int pageSize; private long total; private ListT list; public PageResult(int pageNo, int pageSize, long total, ListT list) { this.pageNo pageNo; this.pageSize pageSize; this.total total; this.list list; } public long getTotalPage() { return total % pageSize 0 ? total / pageSize : total / pageSize 1; } // 省略getter/setter }使用时的查询模板是SELECT id, name, price, main_image FROM product WHERE status 1 AND category_id ? ORDER BY created_at DESC LIMIT ?, ?LIMIT的第一个参数是偏移量计算方式是(pageNo-1)pageSize第二个参数是pageSize。注意LIMIT后面不能直接用JSTL表达式拼接必须用PreparedStatement的setInt传参否则就是SQL注入的活靶子。分页统计总数时用SELECT COUNT() FROM product WHERE status 1 AND category_id ?再把total赋给PageResult。只要把PageResult存进request.setAttribute(page, result)JSP页面里就可以直接用${page.list}、${page.totalPage}取值不需要在页面里再写计算逻辑。页面底部生成页码时可以循环${page.totalPage}次当前页高亮其他页拼链接。注意页码不能任由前端传超过总页数时要截断到最后一页否则下一页是空的。3.4 初始化数据与MD5加密脚本让项目导入就能跑数据库建好之后顺手做一套初始化脚本可以省掉后面反复造数据的时间。常见做法是在项目根目录放sql/init_data.sql里面插入管理员账号、测试用户、少量商品和一个“小米MIX”风格的手机分类。密码不要写明文直接用事先算好的MD5值。比如明文123456对应的MD5是e10adc3949ba59abbe56e057f20f883e初始化脚本里写INSERT INTO user (username, password, phone, address) VALUES (admin, e10adc3949ba59abbe56e057f20f883e, 13800000000, 北京市);这样做的另一个好处是你可以把这套初始化脚本和建表脚本一起提交到Git别人clone下来直接执行两个sql文件就能跑起来不用再问你“后台密码是多少”。MD5加密虽然已经被证明不安全但做练习项目足够了面试时能说清楚“为什么不用明文”才是重点。如果你想把安全性做得更好就加盐update user set password md5(concat(salt, password))salt存在user表里。我不建议在这个项目里上BCrypt因为JDBC手动实现起来比较啰嗦容易在密码校验上出错。4. 核心功能落地登录、商品列表、购物车与下单4.1 用户登录与Session管理验证码和超时是两座山仿小米商城ShoppingMall的登录模块最容易出问题的不是密码验证而是三件事密码加密、中文参数编码、Session超时。密码这块常见做法是在Service层用Md5Util把用户输入的明文转成32位密文再比对永远不要查SELECT * FROM user WHERE password 输入的明文。验证码我建议用Java内置的BufferedImage生成不要引第三方包实现一个GET请求输出图片、把答案放进Session、登录时再比较的流程就够了。下面是登录Servlet的核心代码WebServlet(/login) public class LoginServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { String username req.getParameter(username); String password Md5Util.md5(req.getParameter(password)); String code req.getParameter(code); String sessionCode (String) req.getSession().getAttribute(code); if (sessionCode null || !sessionCode.equalsIgnoreCase(code)) { req.getSession().setAttribute(msg, 验证码错误); resp.sendRedirect(req.getContextPath() /login.jsp); return; } User user userService.login(username, password); if (user null) { req.getSession().setAttribute(msg, 用户名或密码错误); resp.sendRedirect(req.getContextPath() /login.jsp); } else { HttpSession session req.getSession(); session.setAttribute(loginUser, user); session.setMaxInactiveInterval(30 * 60); resp.sendRedirect(req.getContextPath() /product/list); } } }这段代码有几个细节验证码比较统一转成大写或小写防止用户区分大小写登录失败用redirect而不是forward是为了避免刷新页面时表单重复提交Session里只存User对象不要存整个购物车集合购物车数据应该从cart_item表查否则Session一旦过期购物车就丢了。session.setMaxInactiveInterval(30 * 60)设置30分钟超时单位是秒。有的同学在web.xml里也配session-timeout两处都配时实际生效的以更严格的那个为准建议只在一个地方配别同时维护两套配置。4.2 商品列表与搜索分页SQL优化从索引开始商品列表页在仿小米商城首页要给用户看到“最新上架”和“按分类筛选”对应的Servlet处理逻辑是接收categoryId和pageNo调用ProductService查询当前页数据把结果放进request域以后forward到list.jsp。下面是ProductServlet的doGetOverride protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int categoryId 0; String categoryIdStr req.getParameter(categoryId); if (categoryIdStr ! null !categoryIdStr.isEmpty()) { categoryId Integer.parseInt(categoryIdStr); } int pageNo 1; String pageNoStr req.getParameter(pageNo); if (pageNoStr ! null !pageNoStr.isEmpty()) { pageNo Integer.parseInt(pageNoStr); } PageResultProduct page productService.findByCategory(categoryId, pageNo, 12); req.setAttribute(page, page); req.setAttribute(categoryId, categoryId); req.getRequestDispatcher(/WEB-INF/views/product/list.jsp).forward(req, resp); }这里的pageSize设成12是小米商城早年间网格布局的常用数字4列3行正好铺满。categoryId传0表示“全部分类”在SQL里用条件判断if (categoryId 0) 就不拼category_id约束只保留status 1。很多同学在这里拼SQL时习惯用字符串相加我建议用MyBatis那种思路先给一个基础SQL再按条件追加片段。虽然Servlet里没有标签库但可以把SQL片段写在ProductDao的findByCategory方法里用List还有一点容易踩坑Integer.parseInt在参数不是数字时会抛NumberFormatException页面直接500。标准做法是先try-catch解析失败时给默认页号1。黑马javaweb笔记数据里多半是用正则校验但我觉得更稳妥的是catch异常后重置默认值因为参数一旦来自恶意输入正则也可能被绕过。4.3 购物车加购与下单事务边界必须放在Service层购物车加购的逻辑是先判断用户是否登录未登录跳登录页再查cart_item表里有没有该用户和该商品的记录有则UPDATE quantity加一没有则INSERT。注意不能把商品价格快照放到cart_item购物车只存ID和数量价格永远实时取product表。下单模块是这个项目里事务最重的部分要操作order表、order_item表、product库存表、cart_item表四张表必须在一个事务里完成。我在ServiceImpl里写的一个简化下单方法// 事务边界订单、订单项、库存扣减、购物车删除要么全部成功要么全部回滚 public Order createOrder(Long userId, Long addressId, ListCartItem cartItems) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setStatus(0); order.setTotalPrice(calculateTotalPrice(cartItems)); OrderDao orderDao new OrderDao(conn); order.setId(orderDao.insert(order)); for (CartItem item : cartItems) { int rows productDao.deductStock(item.getProductId(), item.getQuantity()); if (rows 0) { throw new RuntimeException(库存不足); } orderDao.insertOrderItem(order.getId(), item); cartDao.delete(item.getId()); } conn.commit(); return order; } catch (Exception e) { if (conn ! null) { conn.rollback(); } throw new RuntimeException(e); } finally { if (conn ! null) { conn.close(); } } }参数说明设置setAutoCommit(false)后所有JDBC操作都在同一个事务里TRANSACTION_READ_COMMITTED是读已提交隔离级别能避免读到别的事务未提交的脏数据对商城场景够用。deductStock返回受影响行数如果库存不足SQL里的stock ?条件让UPDATE影响0行此时立刻抛异常触发回滚。这种“乐观扣减”比先SELECT再UPDATE更稳因为减少了检查与更新之间的并发窗口。注意这里的catch里不能只打印异常必须调用rollback否则连接归还到连接池时事务还开着下一个请求拿到这个连接会看到奇怪的数据。提示如果你在IDEA里给这段Service代码加了Transactional的Spring注解但它实际上没有生效多半是因为项目还是纯Servlet环境根本没引入Spring。先想清楚项目的运行环境再看注解是否需要。4.4 后台商品管理图片上传与部署路径后台管理模块一般是一个AdminServlet拦截/admin/*路径。商品上下架最简单UPDATE product SET status ? WHERE id ?。麻烦的是图片上传。传统做法是用Apache Commons FileUpload组件但更简单的方案是让前端传一个图片URL或者用Base64编码直接存数据库字段。如果是毕设Demo我建议不要做服务器本地磁盘保存原因是IDEA部署时Tomcat会临时拷贝一份webapp到catalina.base下的webapps目录你保存在本开发目录的图片在重启后可能被清掉这是很典型的翻车点。如果你一定要上传本地常见做法是保存到Tomcat安装目录外的一个绝对路径比如D:/upload/然后通过Tomcat的虚拟目录映射访问。在conf/server.xml里加一行Context配置或者用Spring Boot那套静态资源映射思路在Servlet里写一个/img/*的映射。做这个项目时我一般选择保存到数据库BLOB字段或直接转Base64字符串虽然查询慢一点但对课堂项目来说数据迁移最方便不用拷图片目录。4.5 用Filter做登录拦截比在每个Servlet里判断更省事登录拦截是仿小米商城必做的功能。常见错误是在每个Servlet开头写if (session.getAttribute(loginUser) null)漏写一个就留下越权入口。更好用的做法是写一个LoginFilter拦所有需要登录的路径比如/order/、/cart/、/user/*。后台管理再单独写一个AdminFilter校验Session里是否存在admin标识。WebFilter({/cart/*, /order/*, /user/*}) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; HttpSession session request.getSession(false); if (session null || session.getAttribute(loginUser) null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }注意getSession(false)表示不主动创建Session如果一个请求本来没有会话就不会无端生成一个带JSESSIONID的cookie。Filter的urlPatterns可以写多个用通配符/*匹配子路径。写完之后你还要自己模拟一次“未登录直接访问购物车”的请求确保Filter真的生效。很多人Filter写在包路径里但web.xml里没有配置扫描或者用了WebFilter注解却忘了在Servlet上配WebServlet导致整个拦截逻辑根本没有注册。5. 避坑排查IDEA运行JavaWeb项目的常见问题与处理IDEA运行JavaWeb项目配置这块很多坑不是代码问题而是工具链不一致。下面这5条是我在带学员做仿小米商城时被问得最多的每一条都按现象、原因、解决的顺序写。如果你之前是靠头歌实训答案过的JSP练习到这里会发现真正的分水岭就是排错能力。5.1 Tomcat端口被占用导致项目起不来现象IDEA启动Tomcat时日志报Address already in use: JVM_Bind或者8080端口被其他进程占用点Run后控制台一闪而过。原因之前残留的Tomcat进程没被杀掉或者别的服务占了8080。解决先netstat -ano | findstr 8080找到PID并结束进程再把IDEA里Tomcat的HTTP port改到8081避免和本机已有服务冲突。这一步几乎是每个第一次用IDEA运行JavaWeb项目配置的人都会遇到的事别急着重装Tomcat先看端口。还有一个隐藏坑IDEA里配置Tomcat时Application server如果指向了Tomcat的bin目录而不是安装根目录启动时会找不到catalina脚本控制台报Cannot find /apache-tomcat/conf/server.xml这时候重新选择一下Tomcat根目录就行。5.2 请求参数中文乱码从浏览器到MySQL要过三关现象登录用户名是中文时Servlet里getParameter取出来是乱码商品名称在列表页显示一堆问号往数据库里插入中文报Incorrect string value。原因三个环节的字符集不一致。第一关是Tomcat 8.5之后POST请求默认UTF-8但GET请求的queryString还是按ISO-8859-1解码所以GET传中文必须在Servlet里先按ISO-8859-1还原再转UTF-8第二关是JSP页面要写pageEncodingUTF-8第三关是MySQL建库和连接URL都要utf8mb4。解决写一个CharsetFilter统一在doFilter里设置req.setCharacterEncoding(UTF-8)resp.setContentType(text/html;charsetUTF-8)。不要每个Servlet都设置一遍也不要依赖JSP contentType属性。还有一点IDEA里Tomcat的运行配置VM options加-Dfile.encodingUTF-8能顺便解决控制台输出乱码。5.3 静态资源404CSS样式全部丢失现象页面能打开但所有CSS、JS、图片都404按F12看请求路径是/css/style.css开头的相对路径。原因JSP在/WEB-INF/views下浏览器地址栏路径是/product/list页面里的相对路径css/style.css会解析成/product/css/style.css当然找不到。解决所有静态资源引用必须写绝对路径表达式为${pageContext.request.contextPath}/static/css/style.css或者在JSP顶部用c:set varctx value${pageContext.request.contextPath}/后面统一用${ctx}/static/...。另一个坑是webapp/static目录不存在很多模板解压后直接把静态文件放在根目录你没调整就把旧路径抄过来也会404。如果用的是IDEA还要确认Artifact配置里有没有把webapp目录下的static文件夹打进去有时候开发环境正常部署到WAR包就少一层目录就是因为Artifact输出的资源没包含static。5.4 Druid监控里Active连接数打满现象运行一段时间后页面变慢数据库连接池报wait millis 3000active 20pool 20所有请求都卡在获取连接。原因某个Service方法里conn没有在finally关闭异常时连接没归还或者事务内把conn当作成员变量放在Dao里没有确保同一个事务中多个Dao共享同一个连接。解决先看Druid监控页面/druid/index.html里的活跃连接数如果一直不降基本可以断定有连接泄漏。排查方法是把每个Dao的close写成finally块或者更彻底一点用commons-dbutils的QueryRunner它内部会自动释放连接。最隐蔽的一种是事务方法里conn.setAutoCommit(false)后catch到异常只rollback没有closefinally里再close我遇到过一次就是因为重复close导致连接被错误归还。具体表现是监控里的逻辑连接数波动异常但代码看了一遍都close了最后发现是同一个conn被Service传给了两个Dao两个Dao的close都被执行Druid代理连接连续归还两次直接报错。5.5 Servlet版本与Tomcat版本不匹配导致报错现象Eclipse或IDEA里创建的是Servlet 3.1版本但本机Tomcat是7.0启动后报java.lang.NoSuchMethodError或者WebServlet注解完全不起作用。原因Tomcat 7只支持Servlet 3.0Tomcat 8.5支持Servlet 3.1Tomcat 9对应Servlet 4.0。解决检查项目的web.xml头部版本号如果是3.1就至少用Tomcat 8.5如果是Tomcat 10及以上包名从javax.servlet变成jakarta.servlet你的代码要批量改import。做仿小米商城ShoppingMall时我建议固定Tomcat 8.5 JDK 1.8 Servlet 3.1这是配合黑马程序员javaweb笔记最保守的组合也最适合面试时讲清楚原理。另外注意IDEA里创建项目时选择的JavaEE版本要和Tomcat对应别建了一个JavaEE 8的Project又让Tomcat 7去跑这种版本错位比代码报错更难排查。6. 进阶与验证把仿小米商城变成简历上的亮点6.1 给商品详情加一层Redis缓存当项目能跑通后最值得做的第一个优化是给商品详情页加Redis缓存。代码并不复杂详情接口先查Rediskey用product:detail:{id}查不到再查MySQL并把结果反序列化后写入缓存设置600秒过期。这样一个很小的改动就解决了“每次刷新都全表查一遍”的性能问题面试时也能顺势引出缓存穿透、缓存雪崩的讨论。String key product:detail: productId; String json redis.get(key); if (json null) { Product product productDao.findById(productId); redis.setex(key, 600, JSON.toJSONString(product)); return product; } return JSON.parseObject(json, Product.class);这里要注意两点写缓存前先判断product是否为null不要把空对象写进去如果商品下架或价格变化记得主动删掉redis里的key否则用户看到的还是旧数据。旧的不对新的不来缓存设计里删除比更新更可靠。6.2 上线前的验证清单我每次做完一个JavaWeb项目都会按这张清单过一遍未登录直接访问/order/checkout是否被拦截购物车数量改成负数提交是否被后端校验商品库存为0时能否继续下单两个用户同时下单同一件商品会不会超卖后台管理接口是否可以用普通用户身份直接访问。这些点如果答不上来就立刻回到代码里补充。用这套方法验证完简历上写“掌握事务并发控制”才有底气。如果时间紧至少跑一遍从注册、登录、加购、结算到支付的完整链路别只点开首页就说跑通了。6.3 从仿小米商城到下一个项目如果你时间充足下一步建议把整套代码迁到Maven多模块再写一个Spring Boot版本对照你会发现Servlet里的很多手动操作框架只是换了一种写法。我现在做新项目还会回头翻这套代码看当时哪些设计是拍脑袋定的哪些坑是真正值得写进笔记的。做仿小米商城ShoppingMall不是终点把它讲明白才是。希望帮到你。本文还有配套的精品资源点击获取