做了这么多年Java后端,我接过不少电商类项目,但回头想起来,真正把一套经典技术栈用得比较透彻的,反而是这个“网上花店”项目。项目名很直白:基于SSM的鲜花商城,后端技术是Spring、SpringMVC、MyBatis。你要说它有多新潮,谈不上;你要说它能跑、能交付、能扛住节假日流量,那是真没问题。尤其是现在大家一股脑往Spring Boot/Spring Cloud上堆,反而忽略了SSM这套组合在中小型垂类电商里的实用价值。
这篇文章我不打算给你讲PPT式的架构图,而是把这个项目从头到尾的关键决策、表结构设计、请求链路、事务处理、并发扣库存、以及上线踩坑过程完整复盘一遍。无论你是准备做毕设、接外包,还是想自己练手写一个能真正跑起来的线上花店系统,这份经验都可以直接拿走用。
1. 技术选型的底层逻辑:为什么这个项目落在SSM上
先说一个很多人会问的问题:都这个时代了,做电商系统为什么还用SSM,而不是直接上Spring Boot?我的回答是:因为项目复杂度还没到需要Spring Boot“自动约定”来救场的程度,而且SSM的显式配置反而能让人把每个组件的职责边界看得清清楚楚。
1.1 SSM是三个组件的配合,不是框架堆积
SSM这个名字听起来像三个框架叠在一起,但实际上它是一个非常标准的分层协作模型:
- Spring负责Bean管理、依赖注入、声明式事务、AOP切面,是整个应用的心脏;
- SpringMVC负责Web层的请求路由,把HTTP请求映射到Controller方法上,处理参数绑定、视图解析、拦截器;
- MyBatis负责持久层,将Java对象和数据库记录做映射,用SQL直接掌控数据查询与写入。
这三者之间的接口非常干净:Controller只依赖Service接口,Service只依赖Mapper接口,Mapper只依赖数据库。换掉任何一层,其他层不需要大改。我在这个项目里用XML配置显式地定义了所有Bean、扫描包、事务管理器和视图解析器,虽然多写了几行配置,但团队里每个人打开配置文件就知道系统是怎么组装起来的,排错时不用猜。
1.2 垂类商城的复杂度不在框架,在业务模型
“网上花店”听起来比“综合电商”简单,但真做起来有几个很扎手的点:
- 商品是非标准化的。同一束花可以有不同朵数、不同包装、不同配送日期,SKU的粒度很难用标准电商模板套;
- 强时效性。用户选“明天送到”,你必须在订单里记录配送时间,这会影响库存锁定和订单状态流转;
- 节日脉冲流量。情人节、母亲节、七夕的订单量是平时的几十倍,对系统并发能力有明确要求;
- 本地化配送。不是全国包邮的思维,而是按门店覆盖范围配货。
这些业务约束决定了系统的核心模块:商品管理、购物车、订单、库存、配送信息、会员。技术框架只需要稳定地支撑这些模块,SSM完全够用。说实话,这种规模的项目用Spring Boot反而容易让人忽略事务边界和拦截器配置这些真正要命的地方。
1.3 和Spring Boot横向对比:SSM的真实优势区间
| 对比项 | SSM | Spring Boot |
|---|---|---|
| 配置方式 | 显式XML,组件关系一目了然 | 自动配置+注解,上手快但排查依赖时可能发懵 |
| Bean装配可控性 | 高,适合有定制化需求的团队 | 默认约定优先,特殊场景需要额外排除配置 |
| 事务配置 | 在XML/注解中显式声明,边界清晰 | 注解+自动代理,容易忽视回滚规则 |
| 适合场景 | 中小型电商、后台管理系统、教学/毕设 | 微服务、快速迭代、大规模分布式系统 |
如果你是在校生或刚转行的开发,我建议你至少完整写一个SSM项目,理解Spring容器是怎么把Controller、Service、Mapper串起来的。写完之后再转Spring Boot,你会知道那些自动配置背后到底做了什么。这个花店项目,就是一个非常好的练手载体。
2. 数据库与MyBatis:先给“花”建出能卖的模型
做电商系统,我习惯先从数据库设计开始,而不是先写Controller。因为表结构一旦定了,业务逻辑基本就定型了。网上花店的表设计,比普通电商多了一些垂直特征。
2.1 鲜花商品的SKU特征拆解
普通电商卖手机,一个SKU就是“颜色+存储容量”。鲜花商品则复杂一些,我实际用的是“多维度属性组合”的方式:
- 花材组成:主花、配花、叶材;
- 规格:11枝、19枝、33枝、99枝;
- 包装风格:单支花束、花盒、花篮;
- 附加服务:贺卡代写、玩偶配饰。
如果把这些全部做成笛卡尔积,SKU表会爆炸。我的做法是:商品主表存基础信息(名称、图片、描述、花语),SKU表只存影响价格和库存的维度(规格、包装风格)。花材组成和贺卡服务在购物车下单时作为订单附加文本处理,不进SKU维度。
2.2 核心表结构与字段设计
我的数据库里这几张表是最核心的,字段都是线上跑过之后验证过的:
flower_product(花品表)
| 字段 | 类型 | 说明 |
|---|---|---|
| product_id | int | 主键 |
| product_name | varchar | 商品名称 |
| category_id | int | 分类(玫瑰、百合、绿植等) |
| price | decimal | 售价 |
| original_price | decimal | 划线价,用于促销展示 |
| flower_language | varchar | 花语 |
| apply_scene | varchar | 适用场景:生日/表白/探病/婚礼 |
| image_url | varchar | 主图 |
| sales_count | int | 销量,排序用 |
| status | tinyint | 上架/下架 |
| create_time | datetime | 创建时间 |
flower_sku(SKU表):sku_id、product_id、sku_name、price、stock、locked_stock、version。
这里特别注意locked_stock字段,它是我做“预占库存”用的。用户下单后先锁库存,支付成功后转为实际扣减,取消订单则释放锁定。这种方式在鲜花这种强时效商品里非常关键,能避免用户下单后发现没货可发。
flower_order(订单表):order_id、order_no、user_id、total_price、status、consignee、phone、address、delivery_time、pay_time、create_time。
flower_order_item(订单明细表):item_id、order_id、product_id、sku_id、product_name、price、quantity、image_url。
flower_user(用户表):user_id、username、password、phone、email、avatar、register_time。
flower_cart(购物车表):cart_id、user_id、product_id、sku_id、quantity、checked、add_time。
2.3 动态SQL与一对多查询:筛选、详情、订单明细
MyBatis在这个项目里最大的贡献就是动态SQL。商城首页的商品筛选是很典型的复杂查询条件:分类、价格区间、适用场景、销量排序。如果手写JDBC拼接SQL,代码会非常难看;用MyBatis的<where>、<if>标签就清爽得多:
<select id="queryProductList" resultType="com.flower.shop.entity.Product"> SELECT * FROM flower_product <where> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{maxPrice} </if> <if test="scene != null and scene != ''"> AND apply_scene = #{scene} </if> AND status = 1 </where> <choose> <when test="orderBy == 'sales'"> ORDER BY sales_count DESC </when> <otherwise> ORDER BY create_time DESC </otherwise> </choose> </select>订单详情是一对多查询的经典场景:一个订单包含多个明细。我用resultMap做嵌套映射,而不是简单的连表查询。
<resultMap id="OrderDetailMap" type="com.flower.shop.entity.Order"> <id property="orderId" column="order_id"/> <result property="orderNo" column="order_no"/> <collection property="items" ofType="com.flower.shop.entity.OrderItem"> <id property="itemId" column="item_id"/> <result property="productName" column="product_name"/> <result property="price" column="price"/> <result property="quantity" column="quantity"/> </collection> </resultMap>这样在Service层直接把整个订单带明细返回给前端,不需要额外发第二次查询。
2.4 MyBatis缓存在商城场景的运用边界
MyBatis有一级缓存和二级缓存,网上花店这种项目里我建议把二级缓存关掉,只保留一级缓存默认值。原因很简单:商城数据的实时性要求很高,尤其是库存和价格。二级缓存如果配置不当,会出现价格改了用户端还是旧数据的尴尬情况。热卖商品列表、首页Banner这种读多写少的数据,我宁可用Redis做缓存并设置5分钟过期,也不赌MyBatis二级缓存的命中率。这个判断在我线上跑了一段时间后证实是对的,踩过的坑主要包括缓存刷新不及时、分布式环境下脏读等问题。
3. SpringMVC请求链路:一次“加购到结算”的请求流转
SpringMVC是这个项目里承担连接前后端的部分,它管的是请求怎么进来、参数怎么绑定、方法怎么调用、结果怎么返回。
3.1 从DispatcherServlet出发的完整调用链
一次加购操作的请求路径是这样的:
- 前端点击“加入购物车”,浏览器发送
/cart/add请求; - 请求先到
DispatcherServlet,它是SpringMVC的前端控制器; HandlerMapping根据URL找到对应的CartController.addItem()方法;HandlerAdapter负责调用Controller方法,并完成参数绑定;- Controller调用
CartService,Service调用CartMapper,完成数据库操作; - 返回JSON数据,
@ResponseBody通过MappingJackson2HttpMessageConverter把对象序列化为JSON响应给前端。
这一步我把从前端到数据库的路径梳理得很清楚,后面联调出问题的时候,只要按这个链路一层层排查,很快就能定位到底是在Controller层参数没接住,还是Service层业务逻辑写错,又或是SQL执行的结果不符合预期。
3.2 拦截器:登录、权限与请求日志
商城系统里有两个典型的拦截器场景:
- 用户登录拦截:游客可以浏览商品,但加购、下单、查看订单必须登录。我在spring-mvc.xml里配置了拦截器路径:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/cart/**"/> <mvc:mapping path="/order/**"/> <mvc:exclude-mapping path="/order/callback"/> <bean class="com.flower.shop.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>这里有个细节:支付回调接口/order/callback必须放行,因为支付平台的通知不会带用户的登录Cookie,拦截器放行这一条路径能避免回调被误拦。
- 后台管理员权限拦截:另一个拦截器只拦截
/admin/**,校验session里是否有管理员标记。同时要静态资源放行,否则页面的CSS/JS会被拦截器挡掉,页面样式整个崩掉。
3.3 参数绑定与全局异常处理
实际开发里,参数绑定的坑比想象中多。比如用户下单时要传“期望配送日期”,前端传的是2025-05-20这样的字符串,Controller方法的参数类型是Date,如果没有配置日期转换器,SpringMVC会直接报400错误,用户端就只能看到“系统繁忙”。所以我注册了一个全局的日期格式转换器:
@InitBinder public void initBinder(WebDataBinder binder) { DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, true)); }全局异常处理用的是@ControllerAdvice+@ExceptionHandler,把业务异常(比如库存不足、商品已下架)统一返回给前端,而不是输出一大段堆栈让用户一脸懵。个人实践来看,系统上线后最容易出现的异常是:购物车商品被别人买完导致库存不足、优惠券过期、配送时间已过,这三个都是业务异常,必须给出友好提示。全局异常处理的核心逻辑就是:如果是业务异常,提示业务信息;如果是未知异常,记录日志并返回统一样式的错误信息。
3.4 前后端分离下的URL与JSON设计
这个花店项目的管理后台用的是传统JSP+JSTL渲染,商城前台则采用了前后端分离的思路,前端HTML+AJAX调用后端RESTful接口。所以Controller里两类方法并存:
- 返回视图的:
@Controller+ 返回ModelAndView,用于后台管理页面; - 返回JSON的:
@RestController,用于商城前台API。
接口URL我按资源语义来设计,比如/api/product/{id}查商品详情,/api/cart获取购物车,/api/order创建订单。统一返回体是Result<T>,包含code、message、data三个字段,前后端约定好业务成功是200,未登录是401,业务失败是400。这种设计看起来简单,但有效,前后端联调时基本不会因为返回结构不一致而争吵。
4. Spring容器的事务与AOP:订单流程的“保护壳”
电商系统最容易出的问题之一就是数据不一致:订单创建成功了,库存没扣;库存扣了,订单却取消了。Spring容器在这个项目里最重要的作用,就是通过声明式事务把这些操作绑成一个“要么全成功,要么全回滚”的整体。
4.1 事务边界放在哪儿才是对的
我下单的核心Service方法大概长这样:
@Override @Transactional(rollbackFor = Exception.class) public Order createOrder(OrderCreateParam param) { // 1. 校验商品是否在架 // 2. 预占库存(锁定库存) // 3. 创建订单主记录 // 4. 创建订单明细 // 5. 计算订单总价 // 6. 清空购物车中的对应商品 // 7. 返回包装好的订单信息(包含明细) }事务边界放在Service层是最合适的。Controller层只管参数接收和结果返回,不应该包含事务;DAO层每个方法都是单个SQL,也不能承担事务。放Service层的原因是:一个业务用例对应一个事务,下单这个用例天然需要步骤2-6要么全部成功,要么全部回滚。
rollbackFor = Exception.class这个配置也很关键。Spring默认只在遇到RuntimeException时才回滚,如果你遇到受检异常(比如库存不足这个自定义业务异常如果是Exception的子类,而不是RuntimeException),默认情况下是不会回滚的。我见过太多次因为漏配rollbackFor导致的事务部分提交事故,这一步不能省。
4.2 事务失效的三个真实坑
第一个坑:同类内部方法调用导致事务失效。比如在OrderService里,一个方法调用了同类里的另一个@Transactional方法,外层方法没有事务,内层方法的事务不会生效。因为Spring的事务代理是外部调用才触发,this调用不会经过代理对象。解决办法就是拆到不同Service类,或者直接注入代理对象。
第二个坑:try-catch把异常吞掉导致回滚失效。很多人习惯在Service里写try { ... } catch (Exception e) { return fail; },但事务拦截器是在方法抛出异常时才能感知并回滚,异常被你吃掉之后,事务框架认为方法正常返回了,于是提交了。正确做法是:事务方法内不catch异常,或者catch后重新抛出,让事务管理器做回滚决策。
第三个坑:事务方法非public导致不生效。Spring的事务代理基于CGLIB或JDK动态代理,非public方法不走代理逻辑,事务不会被织入。我把所有Service实现的方法都保持public,这看起来是基础常识,但也确实是我踩过之后的深刻记忆。
4.3 AOP做日志、权限与统计
Spring的AOP在这个项目里主要用于三件事:
- 操作日志切面:后台管理员每执行一次商品上架、改价、发货操作,切面自动记录操作人、操作时间、操作内容;
- 接口耗时统计切面:在Controller方法上织入耗时监控,超过1秒的接口输出慢请求日志,方便后续优化;
- 权限校验切面:对部分敏感性操作做二次校验,比如修改价格必须有管理员角色。
自定义注解+切面的写法非常干净。我定义了一个@OpLog注解,标记在需要记录日志的方法上,切面通过环绕通知统一处理。这样业务代码不会被日志逻辑污染,后续要调整日志内容也只需要改一个切面类。
5. 购物车、库存与订单状态:主链路里的三座山
商城系统的核心主链路是:用户选商品 -> 加入购物车 -> 结算下单 -> 支付 -> 商家发货 -> 确认收货。这条链路里最考验后端功力的三个点是购物车、库存、订单状态。
5.1 购物车的三种存储形态
购物车设计有几种常见方案,我都试过:
| 存储位置 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Cookie | 无需服务端存储,简单 | 容量小,无法跨设备,购物车数据不安全 | 游客临时购物 |
| Session | 实现简单 | 服务端内存占用,会话过期数据丢失 | 小规模低并发 |
| 数据库 | 持久化,可跨设备,支持多端同步 | 需要额外表,实时性依赖后端存储 | 正式商城系统 |
这个项目我最终用的是数据库购物车。因为鲜花订单客单价高,用户很可能先在手机上逛逛,再到电脑上付款,数据库存储保证了两端的同步。购物车表只存必要字段,商品名、图片这些冗余信息在下单时快照进订单明细,避免商品改名后历史订单显示错乱。从业务上讲,数据库购物车也是活动促销的基础,后面要做优惠券、满减、凑单,数据都在服务端,处理起来灵活得多。
5.2 库存扣减与并发控制:从乐观锁开始
最开始我的库存扣减SQL写的是:
UPDATE flower_sku SET stock = stock - 1 WHERE sku_id = #{skuId}这在低并发下没问题,但母亲节当天同一款热门花束被同时下单时,多次并发扣减可能把库存扣成负数,导致超卖。我排查后的解法是给SKU表加了一个version字段做乐观锁:
UPDATE flower_sku SET stock = stock - #{quantity}, version = version + 1 WHERE sku_id = #{skuId} AND stock >= #{quantity}stock >= #{quantity}这个条件是关键,它天然地防止库存被扣成负数。同时version字段可以用于后续的逻辑校验。如果更新影响行数为0,就说明库存不足,Service层抛出“库存不足”业务异常,整个事务回滚,用户看到的提示是“该花束暂时售罄”。
对于真正的高并发场景(比如限量秒杀),这种乐观锁在冲突多的时候会有一定重试成本,也可以用数据库悲观锁SELECT ... FOR UPDATE,但需要锁表到事务结束,对性能影响更大。个人建议先用乐观锁,通过压测验证瓶颈再升级方案,不要一开始就上分布式锁。
5.3 订单状态机设计
订单状态必须用状态机管理,不能靠开发人员随意改状态。我的订单状态机:
| 状态 | 含义 | 允许流转到 |
|---|---|---|
| 0 | 待支付 | 1(已支付)、5(已取消) |
| 1 | 已支付/备货中 | 2(配送中)、5(退款/取消) |
| 2 | 配送中 | 3(已完成)、5(退款) |
| 3 | 已完成 | 无 |
| 5 | 已取消/已关闭 | 无 |
状态转移的代码统一放在一个OrderStateMachine类里,任何状态变更都走这个类的changeState(orderId, fromState, toState)方法,并记录状态变更流水。这样做的好处是:每次状态变更都有据可查,用户投诉说“我没收到花但订单已完结”时,能快速查出来是哪一步异常。
5.4 节日峰值:秒杀场景的简化方案
鲜花商城最典型的峰值场景是情人节:限定款玫瑰礼盒在指定时间点开售,同时很多人抢购。我的方案用了三层削峰:
- 前端:按钮置灰倒计时,减少重复提交;
- 后端:Redis存储限量标记,用
SETNX保证每个用户只能抢一次,抢成功后才进入下单流程; - 数据库:乐观锁兜底扣减库存。
这套方案相当于把绝大部分无效请求挡在了业务逻辑之外。个人实测下来,在Tomcat单机部署的情况下能扛住情人节当天的正常流量,没有发生超卖,用户体验也还行。当然,如果追求极致并发性能,引入消息队列异步削峰会更稳,但付出的运维成本也高。这个取舍要看项目的营收预期,不一定追求复杂方案。
6. 从本地到上线:部署联调的实战复盘
最后一个部分,讲讲这个项目在从开发到上线过程中的真实教训。我把这套系统的部署流程和常见坑梳理一遍,这部分的价值我会很自信地说,比很多教程里的“标准流程”值钱。
6.1 环境与依赖版本的选择
SSM项目的版本兼容是新手最容易踩坑的地方。我推荐一套稳定组合:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 1.8 或 11 | 1.8最稳,11也兼容 |
| Spring | 5.3.x | 5.x对Servlet 3.1+支持好 |
| SpringMVC | 与Spring同版本 | 必须同版本,避免jar冲突 |
| MyBatis | 3.5.x | 3.5以上支持Java 8时间类型 |
| Tomcat | 8.5 / 9.0 | 支持Servlet 3.1+ |
| Maven | 3.6+ | 统一依赖管理 |
特别注意:Spring和SpringMVC的jar包一定要用同一个版本号,否则会出现方法签名不匹配的NoSuchMethodError,这种错误很难排查。项目打包时还要检查jar重复依赖问题,比如多个包都带了javax.servlet相关的类,部署到Tomcat后可能会出现ClassCastException或LinkageError。我建议在Maven里配置干净的依赖树,尽量只保留自己实际用到的依赖。
6.2 联调中经常卡壳的几件事
第一件:接口返回JSON的日期格式。默认情况下,Java的Date转JSON会变成时间戳,前端拿到一串数字根本不知道该怎么显示。我在SpringXML里配置了Jackson的日期格式:
<mvc:annotation-driven> <mvc:message-converters> <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"> <property name="objectMapper"> <bean class="com.fasterxml.jackson.databind.ObjectMapper"> <property name="dateFormat"> <bean class="java.text.SimpleDateFormat"> <constructor-arg value="yyyy-MM-dd HH:mm:ss"/> </bean> </property> </bean> </property> </bean> </mvc:message-converters> </mvc:annotation-driven>第二件:中文乱码问题。前端明明传了正确的商品名,后端收到的却是乱码。原因多半是Tomcat的URI编码没设置成UTF-8。我在server.xml里配置了<Connector URIEncoding="UTF-8"/>,同时在web.xml里加Spring的CharacterEncodingFilter并强制编码为UTF-8,这能解掉大多数POST表单乱码问题。
第三件:数据库连接池断开。系统运行一段时间后,第一次访问突然报Connection is not available。这是MySQL默认的wait_timeout超过8小时,连接池里的连接已经失效却没被清除。我用的是Druid连接池,配置了testWhileIdle=true和timeBetweenEvictionRunsMillis=60000,让连接池定时检测连接有效性,问题就消失了。
6.3 上线部署与后续优化
部署这块我走了不少弯路,总结下来就是:不要在一个普通的Tomcat里去打乱七八糟的依赖,打包之前配置好packaging=war,用Maven构建后直接丢到Tomcat的webapps下,这是一个稳定可靠的路径。上线初期我建议在Tomcat的catalina.out里加滚动日志切割,避免日志文件无限增长把磁盘塞满。
上线后我做的第一轮优化就是给数据库表加索引,尤其是flower_order.user_id、flower_order_item.order_id、flower_cart.user_id这三个高频查询字段。没有索引之前,订单列表页随着数据量增长越来越慢,加上索引后查询基本都在毫秒级。
如果后续要继续演进,我会建议往两个方向发展:一是引入Redis接管首页热卖商品和SKU库存标记,降低数据库压力;二是把支付回调、发货通知改成消息队列异步处理,提升系统的响应速度和扛压能力。技术选型的路可以分阶段升级,但最后要服务于业务目标。
个人经验是,SSM这套组合是能够完整体验一个Web项目诞生全过程的基础架构,从Bean到请求路由、事务、持久化,每个环节都看得见摸得着。如果你也想做一个能上线、能演示、能给简历加分的花店项目,照着这个思路从数据库设计开始一路做下来,会是一个难得的完整实践经历。