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

发布时间:2026/9/18 23:05:07
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_title和price是刻意为之订单是历史凭证图书改名、下架、调价都不应该回头改写已生成的订单。而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.version5.3.39/spring.version mybatis.version3.5.16/mybatis.version mybatis.spring.version2.1.2/mybatis.spring.version /properties dependencies !-- Spring 核心IoC 容器 事务 Web MVC -- dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency !-- MyBatis 与 Spring 的粘合层负责 SqlSession 的创建和注入 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version${mybatis.spring.version}/version /dependency !-- 连接池与驱动 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.23/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency /dependenciesSpring 里整合 MyBatis 的核心就是三个 Bean数据源、SqlSessionFactoryBean、MapperScannerConfigurer。!-- applicationContext-dao.xml -- 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}/ !-- 初始化连接数练手项目 5 足够配大反而拖慢启动 -- property nameinitialSize value5/ property namemaxActive value20/ /bean bean classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ !-- 别名包XML 里 resultType 可以直接写 Book 而不是全限定名 -- property nametypeAliasesPackage valuecom.bookstore.entity/ !-- 关键告诉 MyBatis 去哪里找 Mapper XML配错了就是 Invalid bound statement -- property namemapperLocations valueclasspath:mapper/*.xml/ !-- 下划线自动转驼峰create_time 映射到 createTime 不用手写 resultMap -- property nameconfiguration bean classorg.apache.ibatis.session.Configuration property namemapUnderscoreToCamelCase valuetrue/ property namelogImpl valueorg.apache.ibatis.logging.stdout.StdOutImpl/ /bean /property /bean !-- 扫描 Mapper 接口自动生成代理实现省掉手写 DaoImpl -- bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.bookstore.mapper/ /beanmapperLocations和basePackage是最容易写错的一对参数前者管 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 11 AND ...又难维护MyBatis 的where加if是标准解法。!-- BookMapper.xml -- select idselectByCondition resultTypeBook SELECT id, title, author, publisher, price, stock, category_id, cover FROM t_book where status 1 if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! !-- concat 在数据库侧拼 %避免 Java 里做字符串拼接 -- AND (title LIKE CONCAT(%, #{keyword}, %) OR author LIKE CONCAT(%, #{keyword}, %)) /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if /where ORDER BY id DESC LIMIT #{offset}, #{pageSize} /select !-- 同样的条件要查总数否则分页控件算不出总页数 -- select idcountByCondition resultTypeint SELECT COUNT(*) FROM t_book where status 1 if testcategoryId ! nullAND category_id #{categoryId}/if if testkeyword ! 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 来自前端就是注入漏洞。排序字段必须来自白名单不能直接透传。还有一个容易忽略的点countByCondition和selectByCondition的where条件必须严格一致否则会出现「列表有数据但总数是 0」或者分页控件多出一页空白。稳妥做法是把公共条件抽成sql idqueryCondition片段两处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, ListCartItemDTO 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 idreduceStock UPDATE t_book SET stock stock - #{quantity} WHERE id #{bookId} AND stock gt; #{quantity} /updateWHERE stock #{quantity}让扣减本身带条件靠数据库的行锁保证原子性。如果只在 Java 里判断book.getStock() quantity两个线程同时读到库存 1就会双双通过校验最终库存变成 -1。affected 0时抛异常触发回滚这几行是整个下单流程里最值钱的部分。Transactional的rollbackFor Exception.class必须显式写。默认只对 RuntimeException 回滚自定义的BusinessException如果继承了 Exception 而不是 RuntimeException库存不足时不会回滚前面的订单主表就白写了。另外这个方法必须是 public且不能在本类内部被this.createOrder()直接调用否则走不到代理对象事务同样失效。3.3 SpringMVC 控制器里的参数绑定与统一返回Controller 层尽量薄只做三件事接参数、调 service、包返回体。场景注解注意点列表页多条件查询RequestParam DTO分页参数给默认值pageNum 从 1 开始加入购物车RequestBody JSON需配置 Jackson别漏mvc:annotation-driven/下单RequestBodyValid数量加Min(1)校验防负数绕过我的订单SessionAttribute取用户登录态统一从 session 拿别信前端传的 userIdRestController RequestMapping(/api/order) public class OrderController { Autowired private OrderService orderService; PostMapping(/create) public ResultString 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-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class !-- contextConfigLocation 指定 SpringMVC 自己的容器配置 -- 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-namedispatcher/servlet-name !-- 拦截 / 下所有请求静态资源要另外放行 -- url-pattern//url-pattern /servlet-mapping !-- Spring 父容器管 service、事务、数据源 -- context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener !-- 中文乱码的头号元凶POST 请求必须靠这个过滤器 -- filter filter-nameencoding/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencoding/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-packageservice 只交给父容器扫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}这种全量覆盖式更新——两个线程各算出一个值再覆盖条件扣减的原子性就废了。把日志级别调到 DEBUGMyBatis 会打印实际执行的 SQL 和参数对照着看扣减语句是不是每次都用stock stock - ?。5. 让 SSM 书店系统经得起追问索引、缓存与 N1 优化系统能跑通之后真正拉开差距的是性能细节。图书列表页最容易出的问题是 N1 查询先查一页图书再循环按category_id查分类名。!-- 错误示范循环调 selectCategoryById一页 20 本书就 21 次查询 -- !-- 正确做法连表一次取回用关联映射或扁平 DTO 承接 -- select idselectPageWithCategory resultTypeBookVO 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} /selectBookVO是个扁平 DTO多一个categoryName字段即可比在 resultMap 里配 association 更省事也避免了懒加载在事务外触发额外查询。分类列表这种几乎不变的数据适合放进缓存。Spring Cache 加 Redis 是最小改动方案在CategoryService.listAll()上标Cacheable(value category, key all)在增删改方法上标CacheEvict。要注意自调用同样不走代理缓存注解和事务注解有一样的失效条件。分页性能上深分页LIMIT 100000, 20会让 MySQL 扫描大量行再丢弃。书量大的场景换成游标分页——前端带上一页最后一条的 idSQL 改成WHERE id #{lastId} ORDER BY id DESC LIMIT 20配合之前建好的索引翻到第一万页也是常数时间。最后是验证方法单测里用System.nanoTime()环绕查询跑一百次取平均SQL 层面开logImpl打印每条语句肉眼确认没有循环里的单条查询。压测用 JMeter 起 50 个线程并发下单同一本书结束后SELECT stock FROM t_book WHERE id ?只要不是负数条件扣减就是真的生效了。本文还有配套的精品资源点击获取