☰
基于SSM的鲜花商城实战:表设计、请求链路与并发库存全复盘
2026/10/2 19:24:57 网站建设 项目流程

做了这么多年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 垂类商城的复杂度不在框架,在业务模型

“网上花店”听起来比“综合电商”简单,但真做起来有几个很扎手的点:

  1. 商品是非标准化的。同一束花可以有不同朵数、不同包装、不同配送日期,SKU的粒度很难用标准电商模板套;
  2. 强时效性。用户选“明天送到”,你必须在订单里记录配送时间,这会影响库存锁定和订单状态流转;
  3. 节日脉冲流量。情人节、母亲节、七夕的订单量是平时的几十倍,对系统并发能力有明确要求;
  4. 本地化配送。不是全国包邮的思维,而是按门店覆盖范围配货。

这些业务约束决定了系统的核心模块:商品管理、购物车、订单、库存、配送信息、会员。技术框架只需要稳定地支撑这些模块,SSM完全够用。说实话,这种规模的项目用Spring Boot反而容易让人忽略事务边界和拦截器配置这些真正要命的地方。

1.3 和Spring Boot横向对比:SSM的真实优势区间

对比项SSMSpring 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_idint主键
product_namevarchar商品名称
category_idint分类(玫瑰、百合、绿植等)
pricedecimal售价
original_pricedecimal划线价,用于促销展示
flower_languagevarchar花语
apply_scenevarchar适用场景:生日/表白/探病/婚礼
image_urlvarchar主图
sales_countint销量,排序用
statustinyint上架/下架
create_timedatetime创建时间

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 &gt;= #{minPrice} </if> <if test="maxPrice != null"> AND price &lt;= #{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出发的完整调用链

一次加购操作的请求路径是这样的:

  1. 前端点击“加入购物车”,浏览器发送/cart/add请求;
  2. 请求先到DispatcherServlet,它是SpringMVC的前端控制器;
  3. HandlerMapping根据URL找到对应的CartController.addItem()方法;
  4. HandlerAdapter负责调用Controller方法,并完成参数绑定;
  5. Controller调用CartService,Service调用CartMapper,完成数据库操作;
  6. 返回JSON数据,@ResponseBody通过MappingJackson2HttpMessageConverter把对象序列化为JSON响应给前端。

这一步我把从前端到数据库的路径梳理得很清楚,后面联调出问题的时候,只要按这个链路一层层排查,很快就能定位到底是在Controller层参数没接住,还是Service层业务逻辑写错,又或是SQL执行的结果不符合预期。

3.2 拦截器:登录、权限与请求日志

商城系统里有两个典型的拦截器场景:

  1. 用户登录拦截:游客可以浏览商品,但加购、下单、查看订单必须登录。我在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,拦截器放行这一条路径能避免回调被误拦。

  1. 后台管理员权限拦截:另一个拦截器只拦截/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在这个项目里主要用于三件事:

  1. 操作日志切面:后台管理员每执行一次商品上架、改价、发货操作,切面自动记录操作人、操作时间、操作内容;
  2. 接口耗时统计切面:在Controller方法上织入耗时监控,超过1秒的接口输出慢请求日志,方便后续优化;
  3. 权限校验切面:对部分敏感性操作做二次校验,比如修改价格必须有管理员角色。

自定义注解+切面的写法非常干净。我定义了一个@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 节日峰值:秒杀场景的简化方案

鲜花商城最典型的峰值场景是情人节:限定款玫瑰礼盒在指定时间点开售,同时很多人抢购。我的方案用了三层削峰:

  1. 前端:按钮置灰倒计时,减少重复提交;
  2. 后端:Redis存储限量标记,用SETNX保证每个用户只能抢一次,抢成功后才进入下单流程;
  3. 数据库:乐观锁兜底扣减库存。

这套方案相当于把绝大部分无效请求挡在了业务逻辑之外。个人实测下来,在Tomcat单机部署的情况下能扛住情人节当天的正常流量,没有发生超卖,用户体验也还行。当然,如果追求极致并发性能,引入消息队列异步削峰会更稳,但付出的运维成本也高。这个取舍要看项目的营收预期,不一定追求复杂方案。

6. 从本地到上线:部署联调的实战复盘

最后一个部分,讲讲这个项目在从开发到上线过程中的真实教训。我把这套系统的部署流程和常见坑梳理一遍,这部分的价值我会很自信地说,比很多教程里的“标准流程”值钱。

6.1 环境与依赖版本的选择

SSM项目的版本兼容是新手最容易踩坑的地方。我推荐一套稳定组合:

组件版本说明
JDK1.8 或 111.8最稳,11也兼容
Spring5.3.x5.x对Servlet 3.1+支持好
SpringMVC与Spring同版本必须同版本,避免jar冲突
MyBatis3.5.x3.5以上支持Java 8时间类型
Tomcat8.5 / 9.0支持Servlet 3.1+
Maven3.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到请求路由、事务、持久化,每个环节都看得见摸得着。如果你也想做一个能上线、能演示、能给简历加分的花店项目,照着这个思路从数据库设计开始一路做下来,会是一个难得的完整实践经历。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询