做SSM电商平台这个项目其实是一个很“酸爽”的过程。说它酸爽是因为SSM这套组合——Spring Spring MVC MyBatis——在当下微服务满天飞的环境里确实显得有点“复古”但它依然是大量中小型项目、课程设计、毕业设计和公司内部系统的绝对主力。你只要打开招聘网站看Java岗位要求SSM依然是出现频率极高的词汇。所以不管是为了应付项目需求还是为了打牢基础把SSM电商平台从零到一完整撸一遍都特别值。这篇文章我结合自己做过的几个电商类项目把整个“基于SSM的电子商务平台”从框架选型、数据库设计、核心模块实现到常见坑位排查完整拆开揉碎讲一遍。全程干货没有废话适合刚学完Java Web想练手的人也适合已经在做项目但被各种细节卡住的朋友。你看完不仅能跑起来一个商城而且能明白每一步为什么这么做遇到报错也知道去哪里查。1. 项目整体设计与框架选型思路1.1 为什么还用SSM不直接用Spring Boot很多人一上来就问都2025年了新项目谁还用SSM这个问题的答案其实很现实。第一大量存量系统的技术栈就是SSM你进去不是写新代码而是维护和迭代不会SSM寸步难行第二SSM本身就相当于Spring Boot的“底层源码版”你用Spring Boot时那些自动配置、约定大于配置其实底层都是Spring和MyBatis在干活。你把SSM搞透了再去看Spring Boot的自动装配就是降维打击。还有一个很实际的原因很多学校的课程设计和毕业设计题目明确要求“基于SSM框架”。这时候你用Spring Boot即便功能做得再好也可能因为“不符合题目要求”被打回。我在带新人的时候经常说一句话框架只是工具电商平台的业务逻辑和数据库设计才是真正值钱的东西。SSM刚好让你被迫去手写配置、理解Bean生命周期、理解事务传播行为这个过程对于提升内功非常有帮助。1.2 电商平台的核心模块怎么拆一个标准的SSM电商平台按功能域拆解至少有这几大块用户模块注册、登录、个人信息维护、收货地址管理。商品模块商品分类、商品列表、商品详情、商品搜索。购物车模块加入购物车、修改数量、删除、清空、结算。订单模块订单确认、订单生成、订单列表、订单状态流转。支付模块对接第三方支付或者用模拟支付。后台管理模块商品上下架、分类管理、订单处理、用户管理。初次做项目的人最容易犯的错误是一上来就写代码。正确的姿势是先画用例图、理清角色再设计数据库表结构。我的习惯是先用Excel把每个模块的字段、接口、页面路径列出来确认没有遗漏后再动手。这个过程看起来很“慢”其实是最省时间的。因为电商项目最大的成本在改表、改接口、改页面之间的连锁反应上前期设计多花一天后期能省一周。1.3 数据库表结构设计的几个关键点电商平台的数据库设计核心表有这几张用户表、分类表、商品表、购物车表、订单表、订单项表、收货地址表。这里有几个我实际踩过坑后的经验总结。第一金额字段一律用decimal不要用float或double。float和double是浮点数存在精度丢失问题你存99.9读出来可能是99.899999。在支付领域这是大忌。decimal(10,2)表示最长10位、小数2位的金额必须作为铁律。第二商品图片不要直接存图片本身存URL路径。有些新手会把图片转成Base64塞进数据库这是灾难性的。一方面数据库会变得巨大另一方面页面加载会非常慢。正确做法是图片上传到服务器某个目录或者对象存储数据库只存路径。第三订单表和订单项表必须分开。订单表存一次下单的整体信息订单号、总金额、下单时间、状态订单项表存每一件商品的信息商品ID、商品快照名称、购买数量、单价。为什么要存快照因为商品名称和价格是会变的如果你不存快照一个月后买家说“我买的明明是99元”你一看商品表已经改成199元就说不清楚了。所以订单项的“商品名称”和“成交单价”必须冗余存储这叫业务快照是电商设计的底线逻辑。第四购物车表设计要冗余用户ID和商品ID并且加上唯一约束。同一个用户同一件商品只能有一条购物车记录否则用户买3件同样的商品购物车里会出3行体验非常糟糕。这个唯一约束在数据库层面加比在业务代码里判断可靠得多。表名核心字段关键约束/备注t_userid, username, password, phone, emailpassword必须加密存储t_categoryid, name, parent_idparent_id为0表示顶级分类t_productid, category_id, name, price, stock, image, detailprice用decimal(10,2)t_cartid, user_id, product_id, quantity唯一约束(user_id, product_id)t_orderid, order_no, user_id, total_price, status, create_timeorder_no全局唯一t_order_itemid, order_id, product_id, product_name, product_price, quantity存商品快照t_addressid, user_id, receiver_name, receiver_phone, detail_addr可设默认地址标记2. SSM框架核心配置与常用注解详解2.1 Spring容器配置的三种方式你该选哪种SSM的Spring配置网上资料特别乱有纯XML的、有半注解半XML的、还有全注解的。我的建议是用XML配置数据源和事务用注解管理Bean和事务边界。这个方案最稳也最容易排查问题。理由很简单。数据源、SqlSessionFactory这几个东西是基础设施用XML写死启动时一眼就能看明白连的是什么库、扫描的包是哪个而Service、Controller这些业务组件用注解Component、Service、Controller标记配合ComponentScan自动扫描代码量少、可读性高。核心配置我贴一下这是稳定运行过的方案!-- spring-context.xml -- context:component-scan base-packagecom.mall !-- 排除ControllerController交给SpringMVC容器管理 -- context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan context:property-placeholder locationclasspath:jdbc.properties/ bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nametypeAliasesPackage valuecom.mall.entity/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.mall.mapper/ property namesqlSessionFactoryBeanName valuesqlSessionFactory/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/这里有一个非常关键的细节MapperScannerConfigurer的sqlSessionFactory属性我特意用了sqlSessionFactoryBeanName。如果直接写sqlSessionFactory且值是ref引用在某种初始化顺序下会提前触发SqlSessionFactory的创建导致MyBatis配置还没完全加载就报错。用BeanName的方式传字符串能绕开这个隐性问题。这种坑属于“配置半天查不出来”写出来希望你们少走弯路。2.2 Spring MVC的请求流转与常用注解实战Spring MVC的请求处理流程可以用一句话概括请求先到DispatcherServletDispatcherServlet根据HandlerMapping找到对应的Controller方法执行完后将返回值交给ViewResolver渲染视图。实际操作中你必须配置好DispatcherServlet的URL映射。最常见的是用/这样RESTful风格的URL才能生效。如果配成*.do那所有请求URL后面都得带.do又丑又落后。我用/的配置方式!-- web.xml -- servlet servlet-namespringmvc/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namespringmvc/servlet-name url-pattern//url-pattern /servlet-mappingSpring MVC的注解里业务上用得最多的就是这几个Controller标记控制器类。RequestMapping映射URL可加method限制请求类型。RequestParam接收请求参数可设置是否必传、默认值。PathVariable从URL路径中取参数值。ResponseBody返回JSON数据配合Jackson使用。RequestBody接收前端传来的JSON自动绑定到Java对象。Controller RequestMapping(/cart) public class CartController { Autowired private CartService cartService; RequestMapping(value /add, method RequestMethod.POST) ResponseBody public Result add(RequestBody CartAddParam param, HttpSession session) { User user (User) session.getAttribute(loginUser); if (user null) { return Result.error(请先登录); } cartService.addToCart(user.getId(), param.getProductId(), param.getQuantity()); return Result.success(); } }这里有个容易踩的坑如果用HttpSession获取当前登录用户一定先判空。不然用户未登录直接操作购物车代码直接抛NullPointerException前端提示就变成“服务器异常”。一个完备的系统里这种场景应该用拦截器统一处理未登录状态或者至少返回一个明确的错误码。2.3 MyBatis映射文件与动态SQL的实用写法MyBatis最强大的地方是动态SQL。电商平台里商品列表的搜索和筛选条件是不固定的——可能按分类查可能按关键字查也可能同时按价格区间和销量排序。如果每个条件写一个SQL那组合数是爆炸的。用MyBatis的where和if标签就能优雅解决。select idsearchProducts parameterTypemap resultTypeProduct SELECT * FROM t_product where if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if /where if testorderBy ! null ORDER BY price ${orderBy} /if /select写MyBatis映射文件有几点经验分享。第一慎用${}。${}是字符串拼接存在SQL注入风险#{}是预编译占位符安全。只有排序字段名这种没法预编译的场景才用${}而且必须白名单校验。比如orderBy前端只能传asc或desc在Java代码里已经校验过才拼进来。第二批量插入要利用foreach标签。订单生成时要插入订单项一单可能有多个商品。如果写循环一条条插入会产生多次数据库往返性能极差。用foreach一次性批量插入效率提升明显。insert idbatchInsertOrderItems INSERT INTO t_order_item (order_id, product_id, product_name, product_price, quantity) VALUES foreach collectionlist itemitem separator, (#{item.orderId}, #{item.productId}, #{item.productName}, #{item.productPrice}, #{item.quantity}) /foreach /insert第三实体类属性和表字段的映射一定要确认好。如果开启了下划线转驼峰mapUnderscoreToCamelCasetrue那user_name字段会自动映射到userName属性很省事。如果没开启就得手动写resultMap。我建议在SqlSessionFactoryBean里加上这个配置property nameconfigurationProperties map entry keymapUnderscoreToCamelCase valuetrue/ /map /property3. 核心业务模块的实现要点3.1 用户注册与登录的安全处理注册和登录是电商平台的入口安全要求最高。先说注册。用户的密码绝对不能明文存储。我用的是BCrypt加密这是目前比较推荐的密码散列算法。它自带盐值每次加密同一个密码结果都不一样但验证时又能正确比对而且计算速度慢能有效对抗暴力破解。Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Override public boolean register(User user) { // 1. 检查用户名是否已存在 int count userMapper.countByUsername(user.getUsername()); if (count 0) { throw new BizException(用户名已存在); } // 2. 密码加密存储 user.setPassword(BCrypt.hashpw(user.getPassword(), BCrypt.gensalt())); // 3. 插入用户记录 return userMapper.insert(user) 0; } }再说登录。用户输入用户名和密码后业务层把查出来的用户密码和输入的明文密码用BCrypt.checkpw比对。比对成功就把用户对象存入Session。这里要提醒一下存入Session前一定把密码字段置空否则保存了一个带着加密密码的完整对象万一被反序列化泄露出去就得不偿失了。3.2 商品列表的分页与搜索设计商品列表页看起来简单实际上分页查询是性能敏感点。用户量一上来全表查询和一次性加载所有数据都是不可接受的。我用的方案是PageHelper这个分页插件基于MyBatis拦截器实现用起来极其简单PageHelper.startPage(pageNum, pageSize); ListProduct productList productMapper.selectByCondition(condition); PageInfoProduct pageInfo new PageInfo(productList);PageHelper.startPage后面执行的第一条SQL会被拦截并自动加上LIMIT语句。这里有一个隐性问题如果你在startPage和SQL执行之间又做了别的数据库操作分页就会作用到错误的SQL上。所以PageHelper一定要紧贴着Mapper方法调用中间不要插入任何其他逻辑。这个坑我至少见过三次每次都是bing搜索半天才恍然大误。搜索结果的关键字搜索用LIKE模糊查询时要注意通配符。用户输入了%或_会破坏查询语义应该在Java层做转义把%替换成\%_替换成\_并把语句改为LIKE CONCAT(%, #{keyword}, %) ESCAPE \\。3.3 购物车与订单的下单事务处理购物车模块和订单模块是联动的这里最核心的是“下单操作必须保证事务一致性”。一个完整的下单流程是从购物车勾选出要结算的商品。校验商品是否上架、库存是否充足。计算订单总金额。生成订单主记录状态为“待付款”。批量生成订单项。扣减商品库存。清空购物车中对应的商品。第4到第7步任何一步失败前面的操作都不能生效。比如扣库存失败了订单不能留在库里生成订单项失败库存不能白扣。这就必须在一个数据库事务里完成。Transactional(rollbackFor Exception.class) public Long createOrder(Long userId, ListCartItem items, Long addressId) { // 1. 创建订单主记录 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setStatus(OrderStatus.PENDING_PAYMENT); order.setCreateTime(new Date()); orderMapper.insert(order); // 2. 批量插入订单项 ListOrderItem orderItems buildOrderItems(order.getId(), items); orderItemMapper.batchInsert(orderItems); // 3. 扣减库存 for (OrderItem item : orderItems) { int rows productMapper.reduceStock(item.getProductId(), item.getQuantity()); if (rows 0) { throw new BizException(商品库存不足下单失败); } } // 4. 清空购物车对应商品 cartMapper.deleteByUserAndProductIds(userId, items.stream().map(CartItem::getProductId).collect(Collectors.toList())); return order.getId(); }Transactional注解有几个细节必须注意。第一rollbackFor要设置为Exception.class默认情况下RuntimeException才触发回滚受检异常不会。第二事务只对通过Spring代理调用的方法生效同类内部调用this.createOrder()事务会失效。第三事务方法必须是public的private方法不代理这个在开发时容易被忽略。扣减库存的SQL也有讲究。不能先查库存再判断够不够、然后update因为并发场景下两个线程同时查到库存为1都判断够都执行update就会超卖。必须用一条带条件的update原子操作Update(UPDATE t_product SET stock stock - #{quantity} WHERE id #{productId} AND stock #{quantity}) int reduceStock(Param(productId) Long productId, Param(quantity) Integer quantity);受影响行数为0则说明库存不足。这是高并发下防止超卖的最基础手段也是面试常考的考点。3.4 订单状态机与模拟支付订单状态的流转我强烈建议设计成一个清晰的状态机而不是在业务代码里随意if else改状态。电商平台的订单状态至少包括待付款、已付款/待发货、已发货、已完成、已取消。PENDING_PAYMENT - PAID - SHIPPED - COMPLETED PENDING_PAYMENT - CANCELLED PAID - CANCELLED (退款场景这里简化为取消)后端只用定义好状态的枚举在处理每个动作的方法里校验当前状态是否符合预期。比如“发货”这个动作只有当前状态是PAID才能变为SHIPPED如果状态是COMPLETED还执行发货就要抛异常。这能防止脏数据出现。支付模块对接真实支付通道需要商户资质所以项目里通常做模拟支付。最简单的方式是创建一个支付页面点“确认支付”直接调订单服务把状态从待付款更新为已付款。如果你想更逼真一点可以加一个支付单表记录支付方式、支付金额、支付时间、支付流水号再异步回调订单服务更新状态。这样练手的效果更好简历上也更有讲头。4. 常见问题与排查技巧实录4.1 NoSuchBeanDefinitionException和Bean类型冲突这是SSM项目里出现频率最高的异常之一。NoSuchBeanDefinitionException的意思是Spring容器里没找到某个Bean。常见原因有三个扫描包路径写错导致Controller/Service没被扫描到。类上忘加Controller或Service注解。我在前面提到的XML和注解扫描配置互相冲突比如Spring容器扫描了Controller导致部分依赖初始化顺序错乱。排查方法很简单在web.xml里设置日志级别为DEBUG启动时看Spring到底扫描了哪些包、注册了哪些Bean。重点看MappedInterceptor和RequestMappingHandlerMapping的日志输出。如果发现工具类里的Bean由于类型相同出现冲突用Qualifier指定Bean名称或者用Primary标记首选Bean。4.2 MyBatis的Invalid bound statement (not found)这个错误真是让人头皮发麻。意思是Mapper接口的方法找不到对应的SQL语句。排查路径是确认mapperLocations指定的路径和实际存放Mapper XML的目录一致注意classpath:mapper/*.xml里的*是否能匹配到你的子目录层级。确认Mapper XML里的namespace和接口的全限定名完全一致一个字符都不能差。确认XML里每个statement的id和接口方法名一致。确认接口方法参数和XML里的参数类型匹配。我遇到过最诡异的一种情况XML文件里的SQL完全没问题但就是报not found最后发现是Maven构建时没把mapper目录下的XML文件打包进classes目录。因为Maven默认只打包Java源文件XML如果放在src/main/java下就会被遗漏。解决方案是在pom.xml里加资源过滤或者老实把XML放在src/main/resources里。4.3 Jackson转换JSON报错和日期格式问题前端用Ajax请求后端接口ResponseBody返回对象时如果对象里有日期字段默认会序列化成时间戳数字看起来很不友好。解决方案是配置Jackson的日期格式mvc:annotation-driven mvc:message-converters bean classorg.springframework.http.converter.json.Jackson2ObjectMapperFactoryBean property namedateFormat bean classjava.text.SimpleDateFormat constructor-arg valueyyyy-MM-dd HH:mm:ss/ /bean /property /bean /mvc:message-converters /mvc:annotation-driven如果你用了JsonFormat(pattern yyyy-MM-dd HH:mm:ss)注解在实体类字段上效果也是一样的二选一即可。4.4 上传图片失败和Tomcat临时目录权限问题商品后台要上传图片。很多人在本地IDE跑得好好的部署到Linux服务器就上传失败报错是java.io.IOException: Failed to delete临时文件。这是因为Tomcat的work目录对当前用户没有写权限或者磁盘满了。排查时先df -h看磁盘再看Tomcat目录属主chown -R给权限。另外图片上传接口一定要限制文件大小和类型避免有人上传恶意脚本文件。我习惯在后端至少校验扩展名并做二次随机命名不沿用用户上传的原始文件名。5. 性能优化与部署经验5.1 数据库连接池和慢SQL优化电商平台性能瓶颈几乎都在数据库。我用的是Druid连接池它自带监控页面可以实时看慢SQL、活跃连接数、执行次数。在你的项目里接入Druid非常简单只需在web.xml配置一个StatViewServlet然后在浏览器访问/druid/index.html就能看到监控面板。慢SQL出现最多的场景是商品搜索的LIKE查询。如果商品数据量到了几十万条LIKE %关键词%的前缀模糊查询是走不了索引的全表扫描非常慢。这时候要么用全文检索方案比如ElasticSearch但会引入复杂度要么退一步用商品分类热门关键词做缓存。对练手项目来说先把数据库索引建好是最务实的优化ALTER TABLE t_product ADD INDEX idx_category_id (category_id); ALTER TABLE t_product ADD INDEX idx_price (price);只给查询条件里最常用的字段建索引别给每个字段都建索引有维护成本写多读少的表索引多了反而拖慢插入速度。5.2 商品详情页的缓存设计商品详情页是读多写少的典型场景。同一个商品用户可能短时间反复查看但商品信息一两周才改一次。这种情况非常适合加缓存。轻量级方案是直接用Redis缓存的key设计成product:详情:{productId}首次访问从数据库加载之后走缓存。商品编辑后主动删掉缓存下次访问再重新加载。加了缓存之后要留意一个问题缓存穿透。如果用户恶搞不断请求不存在的商品ID每次都会穿透到数据库。解决办法是在缓存里存一个空值或者用布隆过滤器。对练手项目来说存空值就够。这是面试提到的加分项项目中实打实做了一定会让简历更有分量。5.3 项目部署到服务器的最小可行方案SSM项目打war包部署到Tomcat是最传统的方案。流程就是Maven执行package生成war包。把war复制到Tomcat的webapps目录。启动Tomcat它会自动解压war包。修改数据库连接配置指向线上数据库。这里有一个特别容易忽视的问题本地jdbc.properties里数据库地址往往写的是localhost部署到服务器就连接失败。我建议在资源目录里维护多份配置文件比如jdbc.properties和jdbc-prod.properties打包的时候用profile切换。或者在部署文档里明确醒目标注每次部署前检查数据库连接。我甚至见过同事把生产库密码提交到git仓库的安全意识和环境隔离这件事再怎么强调都不过分。5.4 会话共享与登录状态如果你的项目部署在单台Tomcat上Session直接存内存没问题。但如果你想后续做负载均衡——多台Tomcat通过Nginx分发请求——那Session就有问题了用户第一次请求落在TomcatA下一次请求被分到TomcatBB上没有用户的Session用户就被踢下线。这就是分布式会话的经典痛点。最常间的解决方案是把Session存储从内存搬到Redis。Spring提供了HttpSessionStrategy方案也可以在web.xml里配置Spring Session的过滤器结合Redis存储Session。具体配置稍微繁琐但思路很清晰Session序列化后存入Redis多台服务器共享同一个Redis来读Session。这样数据库层面也顺便解决了——购物车、登录态都不依赖单台机器的内存水平扩展才真正可行。6. SSM项目后续扩展的方向项目做完运行稳定了别急着交差。我建议你至少做三件事来提升这个项目的含金量。第一接入Redis缓存商品详情和Session共享。这样项目从单机变成了初步支持分布式会话简历上可以理直气壮写“使用Redis解决了高并发下商品详情页的访问压力以及多节点下Session共享问题”。第二引入消息队列削峰填谷。下单接口如果瞬时请求量特别大数据库会被打垮。可以在用户点击下单后先把请求丢进MQ后端异步消费处理订单和扣库存。这个改造听起来复杂但实际用RabbitMQ的简单队列就能实现又能学到异步解耦的思想。第三把接口改成RESTful风格并加统一返回体。目前很多SSM项目接口返回的格式五花八门有的返回String视图有的返回JSON。统一改造为ResultT结构code、message、data三要素。前后端联调会非常舒服也为未来前后端分离打基础。我个人的体会是把一个SSM电商项目从头到尾完整做一遍胜过零散敲十个小demo。你在这个过程中踩过的每一个坑——Bean找不到、事务没生效、库存超卖、JSON格式不对——都是最有价值的经验。这些坑在Spring Boot时代依然存在只是换了层皮而已。基础打得越牢以后学什么框架都快。如果你正卡在某个报错上排查不出来把日志发到社区提问时记得附上完整的堆栈信息和配置片段这样别人才能帮你定位。希望这篇文章能让你少走点弯路。