news 2026/9/18 23:05:06

SSM网上书店系统实战:动态SQL、事务与防超卖下单

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SSM网上书店系统实战:动态SQL、事务与防超卖下单

简介:这份基于SSM框架的网上书店系统毕业设计文档,面向计算机相关专业学生及Java Web初学者,提供从选题到实现的完整项目参考。全文围绕Spring、SpringMVC与MyBatis的整合开发展开,覆盖用户登录注销、前台商品浏览与搜索、在线购买、订单管理,以及后台商品类别、商品信息、订单和用户管理等模块,采用MySQL存储数据,开发环境为Myeclipse,并区分管理员与普通用户两类角色,功能划分清晰。资源包内含1个docx文档,大小约734KB,以论文正文为主体,包括中英文摘要、目录、绪论、系统分析与设计、数据库设计及功能实现等章节,结构完整,便于直接借鉴论文框架与功能设计思路。目前已有48人学习关注,适合需要完成课程设计、期末大作业或毕业设计的学生参考,也可作为SSM入门练手项目的需求梳理与文档撰写模板。

1. 从零搭一套 SSM 网上书店系统,先把这三个问题想明白

SSM 网上书店系统是 JavaWeb 练手项目里被反复搜索的题目之一。有人以为它只是把图书增删改查堆一遍,真上手才发现:图书检索要支持分类加关键字加价格区间的组合分页,下单要保证库存不被超卖,购物车在登录前后状态还不一样——每一处都能把只会写单表 CRUD 的人卡住。它适合两类人:需要一套完整业务闭环来串起 Spring、SpringMVC、MyBatis 的初学者,以及想借真实场景理解声明式事务、动态 SQL 与分层解耦的开发者。

这里的 SSM 不是三个孤立框架拼盘。Spring 管 IoC 容器和事务边界,SpringMVC 管 HTTP 入口与参数绑定,MyBatis 管把 SQL 从 Java 代码里搬出来。三者靠一个 SqlSessionFactory 和一套包结构缝合,缝合方式才是这套系统真正的技术含量——配错了,事务不生效,Mapper 找不到,前端传的中文变乱码。

下面从库表设计一路走到可下单、可查订单、能扛住并发扣库存的工程。中间给出可直接抄的依赖、配置、Mapper 与排错路径,避免停在「能跑 demo、一改需求就崩」的阶段。

2. SSM 网上书店系统的库表设计与 Maven 工程骨架

2.1 图书、分类、订单、用户四张核心表怎么定

先定表再定代码。新手最常见的翻车方式是先把实体类写完,再去想订单和图书怎么关联,结果下单时发现要么库存没地方放,要么订单里显示的是图书当前价而不是下单时的价。网上书店的最小可用模型是五张表,其中四张是主干,一张是明细。

表名核心字段设计要点
t_userid, username, password, salt, phone, create_timeusername 建唯一索引;密码存 salted MD5,别存明文
t_categoryid, name, parent_id, sortparent_id 留 0 表示一级分类,为后期多级预留
t_bookid, title, author, publisher, isbn, price, stock, category_id, statusprice 用 decimal(10,2);stock 加无符号约束;status 做软删除
t_orderid, order_no, user_id, total_amount, status, receiver, phone, create_timeorder_no 唯一且非自增,用来对外暴露订单号
t_order_itemid, order_id, book_id, book_title, price, quantityprice 必须存下单快照,图书调价后历史订单金额不能变

t_order_item里冗余book_titleprice是刻意为之:订单是历史凭证,图书改名、下架、调价都不应该回头改写已生成的订单。而t_book.stock用无符号整型,是为了让数据库在最坏情况下兜底——应用层扣成负数直接抛错,总比悄悄把库存写成 -3 要好。

索引不用贪多。t_book上按(category_id, status)建联合索引,因为列表页永远是「先按分类筛,再按上架状态筛」;t_order上按(user_id, create_time)建索引,支撑「我的订单」按时间倒序翻页。title做模糊查询时前缀通配符会走不了索引,练手项目数据量小可以忍,数据量上来就得换全文索引或搜索引擎。

2.2 pom.xml 依赖与 Spring 整合 MyBatis 的关键配置

版本对齐是第一步。Spring 5.3.x 搭配 MyBatis 3.5.x 和 mybatis-spring 2.0.x 是踩坑最少的组合,Servlet API 用 4.0.1,打包方式必须是 war 而不是 jar,否则 Tomcat 不认。

<properties> <spring.version>5.3.39</spring.version> <mybatis.version>3.5.16</mybatis.version> <mybatis.spring.version>2.1.2</mybatis.spring.version> </properties> <dependencies> <!-- Spring 核心:IoC 容器 + 事务 + Web MVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis 与 Spring 的粘合层,负责 SqlSession 的创建和注入 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>${mybatis.spring.version}</version> </dependency> <!-- 连接池与驱动 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.23</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies>

Spring 里整合 MyBatis 的核心就是三个 Bean:数据源、SqlSessionFactoryBean、MapperScannerConfigurer。

<!-- applicationContext-dao.xml --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <!-- 初始化连接数,练手项目 5 足够,配大反而拖慢启动 --> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <bean class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <!-- 别名包:XML 里 resultType 可以直接写 Book 而不是全限定名 --> <property name="typeAliasesPackage" value="com.bookstore.entity"/> <!-- 关键:告诉 MyBatis 去哪里找 Mapper XML,配错了就是 Invalid bound statement --> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <!-- 下划线自动转驼峰,create_time 映射到 createTime 不用手写 resultMap --> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <property name="mapUnderscoreToCamelCase" value="true"/> <property name="logImpl" value="org.apache.ibatis.logging.stdout.StdOutImpl"/> </bean> </property> </bean> <!-- 扫描 Mapper 接口,自动生成代理实现,省掉手写 DaoImpl --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.bookstore.mapper"/> </bean>

mapperLocationsbasePackage是最容易写错的一对参数:前者管 XML 文件路径,后者管 Java 接口。两者必须能一一对应上同名接口,否则启动时报Invalid bound statement (not found),且不会在 Spring 启动阶段报错,而是等到第一次调用才炸。mapUnderscoreToCamelCase建议一直开着,能省掉大量重复的 resultMap 映射代码。

2.3 包结构与分层职责:别让 Controller 直接碰 Mapper

分层不是形式主义,它是为了让事务有地方挂。工程按职责切成五层,每个包只依赖它下面的那一层。

com.bookstore ├── controller // 只做参数接收、校验、返回视图或 JSON ├── service // 业务编排 + 事务注解,订单和库存在这层合并 │ └── impl ├── mapper // MyBatis 接口,只声明方法,不写业务判断 ├── entity // 与表一一对应,字段名和列名靠驼峰规则映射 ├── dto // 前端传参对象,比如 BookQueryDTO、OrderDTO └── common // 统一返回体 Result、分页对象 PageResult、常量

判断分层是否合理有个简单标准:把 Controller 换成手机 App 接口、把 MyBatis 换成 JPA,业务代码要不要重写?如果下单逻辑里混着HttpServletRequest,那说明层级漏了。Controller 层只允许出现@RequestParam@RequestBody这类绑定注解和一次 service 调用,任何if (stock < 1)这类判断都应该下沉到 service。

3. 用 MyBatis 动态 SQL 和 Spring 事务实现检索、购物车与下单

3.1 图书多条件分页检索的 Mapper 与动态 SQL

书店列表页的查询条件是动态的:可能只有分类,也可能分类加关键字加价格区间一起给。用字符串拼 SQL 会被注入,用WHERE 1=1 AND ...又难维护,MyBatis 的<where><if>是标准解法。

<!-- BookMapper.xml --> <select id="selectByCondition" resultType="Book"> SELECT id, title, author, publisher, price, stock, category_id, cover FROM t_book <where> status = 1 <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> <!-- concat 在数据库侧拼 %,避免 Java 里做字符串拼接 --> AND (title LIKE CONCAT('%', #{keyword}, '%') OR author LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="minPrice != null"> AND price &gt;= #{minPrice} </if> <if test="maxPrice != null"> AND price &lt;= #{maxPrice} </if> </where> ORDER BY id DESC LIMIT #{offset}, #{pageSize} </select> <!-- 同样的条件要查总数,否则分页控件算不出总页数 --> <select id="countByCondition" resultType="int"> SELECT COUNT(*) FROM t_book <where> status = 1 <if test="categoryId != null">AND category_id = #{categoryId}</if> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR author LIKE CONCAT('%', #{keyword}, '%')) </if> </where> </select>

参数对应关系需要说清楚:offset不是页码,而是(pageNum - 1) * pageSize,这个换算放在 service 里做,Mapper 只认偏移量。#{offset}#{}而不是${},前者走 PreparedStatement 占位符,后者是字符串替换,ORDER BY ${column}这种写法如果 column 来自前端就是注入漏洞。排序字段必须来自白名单,不能直接透传。

还有一个容易忽略的点:countByConditionselectByCondition<where>条件必须严格一致,否则会出现「列表有数据但总数是 0」或者分页控件多出一页空白。稳妥做法是把公共条件抽成<sql id="queryCondition">片段,两处<include>引用。

3.2 下单时用 @Transactional 把订单与库存绑成一个原子操作

下单要连续做四件事:校验库存、写订单主表、写订单明细、扣减库存。任何一步失败,前面写的数据都必须回滚,否则会出现订单生成了但库存没扣,或者库存扣了订单没落库。

@Service public class OrderServiceImpl implements OrderService { @Autowired private BookMapper bookMapper; @Autowired private OrderMapper orderMapper; @Autowired private OrderItemMapper orderItemMapper; @Override @Transactional(rollbackFor = Exception.class) public String createOrder(Long userId, List<CartItemDTO> items) { BigDecimal total = BigDecimal.ZERO; // 先汇总金额,再落库,避免边算边写 for (CartItemDTO item : items) { Book book = bookMapper.selectById(item.getBookId()); if (book == null || book.getStatus() != 1) { throw new BusinessException("图书已下架:" + item.getBookId()); } total = total.add(book.getPrice().multiply( BigDecimal.valueOf(item.getQuantity()))); } String orderNo = "BS" + System.currentTimeMillis(); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setTotalAmount(total); order.setStatus(0); // 0 待支付 orderMapper.insert(order); for (CartItemDTO item : items) { Book book = bookMapper.selectById(item.getBookId()); // 条件扣减:库存扣到负数会返回 0 行,据此判断超卖 int affected = bookMapper.reduceStock( item.getBookId(), item.getQuantity()); if (affected == 0) { throw new BusinessException("库存不足:" + book.getTitle()); } OrderItem oi = new OrderItem(); oi.setOrderId(order.getId()); oi.setBookId(book.getId()); oi.setBookTitle(book.getTitle()); // 价格快照 oi.setPrice(book.getPrice()); oi.setQuantity(item.getQuantity()); orderItemMapper.insert(oi); } return orderNo; } }

扣库存的 SQL 是防超卖的关键,不能在 Java 里先查再算再更新:

<update id="reduceStock"> UPDATE t_book SET stock = stock - #{quantity} WHERE id = #{bookId} AND stock &gt;= #{quantity} </update>

WHERE stock >= #{quantity}让扣减本身带条件,靠数据库的行锁保证原子性。如果只在 Java 里判断book.getStock() >= quantity,两个线程同时读到库存 1,就会双双通过校验,最终库存变成 -1。affected == 0时抛异常触发回滚,这几行是整个下单流程里最值钱的部分。

@TransactionalrollbackFor = Exception.class必须显式写。默认只对 RuntimeException 回滚,自定义的BusinessException如果继承了 Exception 而不是 RuntimeException,库存不足时不会回滚,前面的订单主表就白写了。另外这个方法必须是 public,且不能在本类内部被this.createOrder()直接调用,否则走不到代理对象,事务同样失效。

3.3 SpringMVC 控制器里的参数绑定与统一返回

Controller 层尽量薄,只做三件事:接参数、调 service、包返回体。

场景注解注意点
列表页多条件查询@RequestParam+ DTO分页参数给默认值,pageNum 从 1 开始
加入购物车@RequestBody+ JSON需配置 Jackson,别漏<mvc:annotation-driven/>
下单@RequestBody+@Valid数量加@Min(1)校验,防负数绕过
我的订单@SessionAttribute取用户登录态统一从 session 拿,别信前端传的 userId
@RestController @RequestMapping("/api/order") public class OrderController { @Autowired private OrderService orderService; @PostMapping("/create") public Result<String> create(@RequestBody @Valid OrderCreateDTO dto, HttpSession session) { User login = (User) session.getAttribute("LOGIN_USER"); if (login == null) { return Result.fail(401, "请先登录"); } // userId 只从 session 取,前端传的都要丢掉,否则能替别人下单 String orderNo = orderService.createOrder(login.getId(), dto.getItems()); return Result.ok(orderNo); } }

Result是自定义的统一返回体,包含 code、msg、data 三个字段。这样做的好处是前端拦截器只判断 code,业务异常和系统异常都能走同一套处理逻辑。登录用户 ID 一定要从 session 或 token 里解出来,让前端传 userId 是常见的安全漏洞,改一个参数就能看别人的订单。

4. 网上书店系统跑通与排错:Tomcat 部署、事务失效与空指针定位

4.1 web.xml 与前端控制器的启动配置

SpringMVC 的入口是DispatcherServlet,它的初始化顺序决定了配置文件怎么加载。

<!-- web.xml --> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <!-- contextConfigLocation 指定 SpringMVC 自己的容器配置 --> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <!-- 拦截 / 下所有请求,静态资源要另外放行 --> <url-pattern>/</url-pattern> </servlet-mapping> <!-- Spring 父容器:管 service、事务、数据源 --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- 中文乱码的头号元凶,POST 请求必须靠这个过滤器 --> <filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

这里有个高频疑问:为什么要有父子两个容器?父容器(ContextLoaderListener加载的applicationContext.xml)管 service 和 dao,子容器(DispatcherServlet加载的spring-mvc.xml)管 controller。子容器能看见父容器的 Bean,反过来不行。如果@Transactional没生效,先把注解所在类启动时到底被哪个容器扫描了搞清楚——被 SpringMVC 容器扫到的 service,父容器的事务管理器是注入不进去的。

4.2 一张表排查启动失败与 500 错误

报错信息常见原因处理方式
Invalid bound statement (not found)Mapper 接口与 XML 不同名,或mapperLocations路径写错检查接口全限定名与 XML namespace 是否一致,XML 是否落在classpath:mapper/
NoSuchBeanDefinitionException注解扫描包没覆盖,或父子容器扫重确认context:component-scan的 base-package,service 只交给父容器扫
No qualifying bean of type DataSource数据源配置未加载,或属性占位符没生效检查PropertySourcesPlaceholderConfigurer是否声明
url为 null 的Communications link failuredb.properties 没被读到,或 MySQL 未启动打印一次dataSource.getUrl()确认
页面中文乱码缺 CharacterEncodingFilter 或顺序不对filter-mapping 的 url-pattern 必须是/*,且要放在所有 filter 之前
Ambiguous mapping两个方法映射了同一个 URL 和方法检查@RequestMapping是否重复
静态资源 404url-pattern/后静态资源被拦<mvc:default-servlet-handler/>

排查顺序建议固定下来:先看 Tomcat 启动日志里有没有Root WebApplicationContext initialized,没有就是父容器挂了;再看DispatcherServlet是否初始化成功;最后才看业务抛出的异常。跳过前两步直接盯着 500 页面看堆栈,很容易在一堆嵌套异常里找错方向。

4.3 事务不生效、库存扣成负数怎么查

事务问题是这套系统里最隐蔽的一类。按下面的顺序过一遍,基本能定位到九成场景。

第一,确认代理有没有生成。在 service 的构造方法里打印this.getClass().getName(),如果输出里带$$EnhancerBySpringCGLIB$$$Proxy,说明被代理了;输出的是原始类名,就是配置问题。

第二,确认异常类型。@Transactional默认只回滚 RuntimeException 和 Error。自定义异常建议统一继承 RuntimeException,或者老老实实写上rollbackFor = Exception.class

第三,确认调用路径。this.createOrder()a()b()这类本类自调用不经过代理,注解等于没写。真需要拆,把子逻辑放到另一个 service,或者注入自身代理。

第四,确认数据库引擎。MySQL 里把表建成 MyISAM,事务压根不起作用,SHOW TABLE STATUS看 Engine 是不是 InnoDB。

库存扣成负数则要看三处:扣减 SQL 有没有stock >= #{quantity}条件;数据库 stock 字段是不是无符号;以及有没有别的地方用UPDATE t_book SET stock = #{newStock}这种全量覆盖式更新——两个线程各算出一个值再覆盖,条件扣减的原子性就废了。把日志级别调到 DEBUG,MyBatis 会打印实际执行的 SQL 和参数,对照着看扣减语句是不是每次都用stock = stock - ?

5. 让 SSM 书店系统经得起追问:索引、缓存与 N+1 优化

系统能跑通之后,真正拉开差距的是性能细节。图书列表页最容易出的问题是 N+1 查询:先查一页图书,再循环按category_id查分类名。

<!-- 错误示范:循环调 selectCategoryById,一页 20 本书就 21 次查询 --> <!-- 正确做法:连表一次取回,用关联映射或扁平 DTO 承接 --> <select id="selectPageWithCategory" resultType="BookVO"> SELECT b.id, b.title, b.price, b.stock, c.name AS categoryName FROM t_book b LEFT JOIN t_category c ON b.category_id = c.id WHERE b.status = 1 ORDER BY b.id DESC LIMIT #{offset}, #{pageSize} </select>

BookVO是个扁平 DTO,多一个categoryName字段即可,比在 resultMap 里配 association 更省事,也避免了懒加载在事务外触发额外查询。

分类列表这种几乎不变的数据,适合放进缓存。Spring Cache 加 Redis 是最小改动方案:在CategoryService.listAll()上标@Cacheable(value = "category", key = "'all'"),在增删改方法上标@CacheEvict。要注意自调用同样不走代理,缓存注解和事务注解有一样的失效条件。

分页性能上,深分页LIMIT 100000, 20会让 MySQL 扫描大量行再丢弃。书量大的场景换成游标分页——前端带上一页最后一条的 id,SQL 改成WHERE id < #{lastId} ORDER BY id DESC LIMIT 20,配合之前建好的索引,翻到第一万页也是常数时间。

最后是验证方法:单测里用System.nanoTime()环绕查询,跑一百次取平均;SQL 层面开logImpl打印每条语句,肉眼确认没有循环里的单条查询。压测用 JMeter 起 50 个线程并发下单同一本书,结束后SELECT stock FROM t_book WHERE id = ?,只要不是负数,条件扣减就是真的生效了。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 23:03:36

YOLO v5到v11选型指南:目标检测版本演进、训练与部署

YOLO 这个名字现在已经被用得有点泛滥了&#xff0c;你在搜索引擎里敲进去&#xff0c;前面几条可能不是算法&#xff0c;而是某款服务器型号、某个软件版本号&#xff0c;甚至某台打印机。真到要干活的时候&#xff0c;问题反而变得很朴素&#xff1a;我手上这个项目&#xff…

作者头像 李华
网站建设 2026/9/18 23:01:28

化工巡检排班:图论建模与整数规划的双阶段优化实践

简介&#xff1a;本资源为2017年全国大学生数学建模竞赛国家一等奖D题优秀论文&#xff0c;面向数学建模初学者、参赛学生及指导教师&#xff0c;聚焦化工厂巡检线路优化与人力资源排班这一典型运筹学问题。论文创新性融合最短路模型&#xff08;基于26个巡检点构建无向赋权图&…

作者头像 李华
网站建设 2026/9/18 22:59:27

LabVIEW面向对象编程实战:从类封装到硬件状态机设计

简介&#xff1a;《LabVIEW面向对象设计》PDF 是一份面向 LabVIEW 开发者的技术汇编&#xff0c;聚焦 LVOOP&#xff08;LabVIEW 面向对象编程&#xff09;与常见设计模式的实际落地。内容以适配器模式、建造者模式、单例模式、原型模式、简单工厂模式为主线&#xff0c;结合 B…

作者头像 李华
网站建设 2026/9/18 22:56:57

66页PPT拆解《底层逻辑》:IT人的可落地思维建模手册

简介&#xff1a;本资源是一份面向职场人、学生及终身学习者的思维升级工具包&#xff0c;聚焦《底层逻辑》核心思想的可视化精解&#xff0c;帮助读者穿透信息迷雾、构建系统性认知框架。66页PDF完整呈现全书六大模块&#xff1a;从“我所理解的底层逻辑”到“社会协作的底层逻…

作者头像 李华