尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于SSM的鲜花商城实战:表设计、请求链路与并发库存全复盘

基于SSM的鲜花商城实战:表设计、请求链路与并发库存全复盘 做了这么多年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的真实优势区间对比项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_skuSKU表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 idqueryProductList resultTypecom.flower.shop.entity.Product SELECT * FROM flower_product where if testcategoryId ! null AND category_id #{categoryId} /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if if testscene ! null and scene ! AND apply_scene #{scene} /if AND status 1 /where choose when testorderBy sales ORDER BY sales_count DESC /when otherwise ORDER BY create_time DESC /otherwise /choose /select订单详情是一对多查询的经典场景一个订单包含多个明细。我用resultMap做嵌套映射而不是简单的连表查询。resultMap idOrderDetailMap typecom.flower.shop.entity.Order id propertyorderId columnorder_id/ result propertyorderNo columnorder_no/ collection propertyitems ofTypecom.flower.shop.entity.OrderItem id propertyitemId columnitem_id/ result propertyproductName columnproduct_name/ result propertyprice columnprice/ result propertyquantity columnquantity/ /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调用CartServiceService调用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 classcom.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)); }全局异常处理用的是ControllerAdviceExceptionHandler把业务异常比如库存不足、商品已下架统一返回给前端而不是输出一大段堆栈让用户一脸懵。个人实践来看系统上线后最容易出现的异常是购物车商品被别人买完导致库存不足、优惠券过期、配送时间已过这三个都是业务异常必须给出友好提示。全局异常处理的核心逻辑就是如果是业务异常提示业务信息如果是未知异常记录日志并返回统一样式的错误信息。3.4 前后端分离下的URL与JSON设计这个花店项目的管理后台用的是传统JSPJSTL渲染商城前台则采用了前后端分离的思路前端HTMLAJAX调用后端RESTful接口。所以Controller里两类方法并存返回视图的Controller 返回ModelAndView用于后台管理页面返回JSON的RestController用于商城前台API。接口URL我按资源语义来设计比如/api/product/{id}查商品详情/api/cart获取购物车/api/order创建订单。统一返回体是ResultT包含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项目的版本兼容是新手最容易踩坑的地方。我推荐一套稳定组合组件版本说明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.1Maven3.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 classorg.springframework.http.converter.json.MappingJackson2HttpMessageConverter property nameobjectMapper bean classcom.fasterxml.jackson.databind.ObjectMapper property namedateFormat bean classjava.text.SimpleDateFormat constructor-arg valueyyyy-MM-dd HH:mm:ss/ /bean /property /bean /property /bean /mvc:message-converters /mvc:annotation-driven第二件中文乱码问题。前端明明传了正确的商品名后端收到的却是乱码。原因多半是Tomcat的URI编码没设置成UTF-8。我在server.xml里配置了Connector URIEncodingUTF-8/同时在web.xml里加Spring的CharacterEncodingFilter并强制编码为UTF-8这能解掉大多数POST表单乱码问题。第三件数据库连接池断开。系统运行一段时间后第一次访问突然报Connection is not available。这是MySQL默认的wait_timeout超过8小时连接池里的连接已经失效却没被清除。我用的是Druid连接池配置了testWhileIdletrue和timeBetweenEvictionRunsMillis60000让连接池定时检测连接有效性问题就消失了。6.3 上线部署与后续优化部署这块我走了不少弯路总结下来就是不要在一个普通的Tomcat里去打乱七八糟的依赖打包之前配置好packagingwar用Maven构建后直接丢到Tomcat的webapps下这是一个稳定可靠的路径。上线初期我建议在Tomcat的catalina.out里加滚动日志切割避免日志文件无限增长把磁盘塞满。上线后我做的第一轮优化就是给数据库表加索引尤其是flower_order.user_id、flower_order_item.order_id、flower_cart.user_id这三个高频查询字段。没有索引之前订单列表页随着数据量增长越来越慢加上索引后查询基本都在毫秒级。如果后续要继续演进我会建议往两个方向发展一是引入Redis接管首页热卖商品和SKU库存标记降低数据库压力二是把支付回调、发货通知改成消息队列异步处理提升系统的响应速度和扛压能力。技术选型的路可以分阶段升级但最后要服务于业务目标。个人经验是SSM这套组合是能够完整体验一个Web项目诞生全过程的基础架构从Bean到请求路由、事务、持久化每个环节都看得见摸得着。如果你也想做一个能上线、能演示、能给简历加分的花店项目照着这个思路从数据库设计开始一路做下来会是一个难得的完整实践经历。
返回列表