FEATURED · 精选文章

Spring Boot二手图书交易平台:并发防超卖与索引优化实战

发布时间 / 2026/9/15 15:20:46
来源 / 创域科博编辑部
栏目 / 资讯中心
Spring Boot二手图书交易平台:并发防超卖与索引优化实战 简介基于Spring Boot的二手图书交易平台系统是一份面向毕业设计及管理系统开发学习者的完整项目资源适配高校计算机相关专业学生或需要快速搭建交易类Web应用的后端开发者。资源提供前后端完整实现覆盖用户注册登录、二手图书发布与检索、购物车与订单管理、在线沟通议价、评价反馈等核心模块可作为课程设计或毕业设计的参考蓝本。压缩包共204个文件约49.69MB主要包含Java源码及class字节码、前端HTML/CSS/JavaScript样式脚本、Spring Boot核心配置yaml/properties、数据库初始化SQL、开发用依赖JAR包等导入IDE并配置环境后即可运行调试。已有657人浏览学习整体目录结构清晰、业务链路完整入手后可直观理解Spring Boot与持久层、模板引擎的整合方式省去从零搭建骨架和编写通用逻辑的耗时适合希望快速产出可演示毕业设计项目的读者深入研读。1. 基于springboot的二手图书交易平台难在哪二手图书交易这个需求第一眼看上去就是标准的增删改查用户注册、图书发布、列表、下单、订单管理。真正动手写会发现藏在下面的四个坑同一本书在多个卖家手里是不同商品状态要么在售要么被锁一个买家下单时另一个买家可能正在支付同一本书产生并发覆盖未支付订单超时后得有人把它扫掉并把书放回在售列表页的搜索不能上手就全表like。选择Spring Boot的原因也很直白内嵌Tomcat、自动配置数据源、Transactional事务、Scheduled定时任务几十行配置就能把基础设施搭起来。下面按建表、查询、下单、验收的顺序往下走全程用Spring Boot MyBatis-Plus包含可以直接抄的SQL、配置和Java代码。2. 二手图书交易系统的领域模型先拆“书”和“在售书”两张表再写代码2.1 为什么book_info和book_offer必须分开一本书可以同时被十个卖家挂出。如果把书的信息、价格、卖家、成色全塞进一张表同一个ISBN会重复十行作者、出版社、封面重复存储改一次书名或封面就要更新十行漏掉任何一行都会让列表页出现同一个作品两封面的怪事。常见做法是拆成两张表并做职责分级这是二手品类交易系统里的固定套路表名职责数据特征book_info书的元信息ISBN、标题、作者、出版社、分类、封面重复度低按ISBN去重book_offer具体某一本在售书价格、成色、卖家ID、状态重复度高一个book_info可对应多条offer实体关系上book_offer持有book_info_id就足够了列表页按需组装不要为了省一次查询强行在Java侧设置双向关联。MyBatis-Plus里就是两个普通实体加一个selectById代码比JPA的一对多直观得多。2.2 商品状态用枚举不建字典表二手图书平台的状态来回就那么几个在售、预定中、已售出、下架。把状态放进字典表要多做一次无关查询收益几乎没有直接用枚举类。状态流转要支持四条路径在售被下单锁定为预定中买家支付后预定中变为已售出买家超时未支付预定中回到在售卖家主动下架在售或预定中变为下架。Java侧配合MyBatis-Plus用EnumValue把code写进数据库而不是存枚举的name字符串Getter public enum OfferStatus { AVAILABLE(0, 在售), RESERVED(1, 预定中), SOLD(2, 已售出), OFF(3, 下架); EnumValue private final int code; private final String desc; OfferStatus(int code, String desc) { this.code code; this.desc desc; } }EnumValue来自com.baomidou.mybatisplus.annotation包Spring Boot自动装配机制会识别这个注解插入和查询时自动做int与枚举互转。要注意一个经典坑忘加EnumValue时MyBatis-Plus默认把枚举name也就是“AVAILABLE”字符串存进库数据库字段如果是int启动时不报错查询一执行就类型转换异常。所以建表列类型和Java枚举要一起定好别让两边各写各的。2.3 建表SQL与索引设计别只会给status加索引给一份可以直接执行的最小DDLCREATE TABLE book_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) DEFAULT NULL, title VARCHAR(255) NOT NULL, author VARCHAR(100) DEFAULT NULL, publisher VARCHAR(100) DEFAULT NULL, category VARCHAR(50) DEFAULT NULL, cover_url VARCHAR(500) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_isbn_title (isbn, title) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE book_offer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, book_info_id BIGINT NOT NULL, seller_id BIGINT NOT NULL, price DECIMAL(10,2) NOT NULL, degree TINYINT NOT NULL COMMENT 1-10 成色, status TINYINT NOT NULL DEFAULT 0, description TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_price (status, price), KEY idx_seller (seller_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两个值得展开的点idx_status_price是(status, price)联合索引支撑的是列表页“在售状态下按价格排序”的查询过滤和排序都在索引内完成避免文件排序price用DECIMAL(10,2)而不是float价格加减不允许二进制浮点误差参与。updated_at列在定时任务扫超时订单时会用到按时间范围切片查询比全表扫状态高效很多。还有一个容易被忽略的事常见项目会给book_info_id单独建普通索引但它的查询都必须跟status一起出现真正热点是status和price的组合单列索引帮不上忙。联合索引建错位置慢查询日志会在上线第一周就教做人。2.4 为什么库存状态不丢给Redis缓存列表页可以用Redis缓存分类和热词的聚合结果TTL控制在30到60秒。但book_offer行级的状态尤其是预定和售出这两个变更必须直接走数据库。原因很简单二手书同一个offer在“在售/被锁/已售”之间切换是二值状态竞争数据一致性优先级高于命中率。一旦缓存和数据库之间出现一小段不一致两个买家就可能都以为自己买到了同一本书。所以顺序应该是详情和下单直接查库列表页做短缓存兜底。3. 用Spring Boot把图书列表页面和多条件查询跑通3.1 分页插件不配置MyBatis-Plus不执行LIMIT很多Spring Boot项目里使用MyBatis-Plus的selectPage必须先注册PaginationInnerInterceptor否则分页参数被忽略一次查出全表。最小配置可以照抄Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); interceptor.addInnerInterceptor(pagination); return interceptor; } }pagination.setMaxLimit(100)限制单页最多100条防止有人传pageSize100000把后端连接池拖垮。DbType.MYSQL告诉分页插件生成MySQL的LIMIT语法用错了方言就会拼出SQL Server那套TOP写法。另外如果你是Spring Boot 3.x起步的新项目MyBatis-Plus要选针对spring-boot3的starter模块老模块在启动时会报ClassNotFoundException这不是配置问题是依赖坐标选错了。3.2 多条件组合查询QueryWrapper里的null必须显式判空列表页筛选条件一般是关键词、分类、价格区间、成色和排序方式。用LambdaQueryWrapper写起来很清晰public PageBookOfferVO queryOffers(OfferQuery query) { PageBookOffer page new Page(query.getPageNum(), query.getPageSize()); LambdaQueryWrapperBookOffer wrapper Wrappers.lambdaQuery(BookOffer.class) .eq(BookOffer::getStatus, OfferStatus.AVAILABLE.getCode()) .between(query.getMinPrice() ! null query.getMaxPrice() ! null, BookOffer::getPrice, query.getMinPrice(), query.getMaxPrice()) .ge(query.getMinDegree() ! null, BookOffer::getDegree, query.getMinDegree()) .orderByDesc(BookOffer::getUpdatedAt); return bookOfferMapper.selectPage(page, wrapper); }eq里第一个参数是status第二个是枚举code 0强制只给在售商品已售和下架不参与列表展示。between的第一个参数是booleanMP在条件不满足时会丢弃整个条件所以minPrice和maxPrice必须同时非null才生效。非空判断缺失的典型症状是生成“price BETWEEN NULL AND 99”查询结果为空前端表现为筛选失效。orderByDesc用updated_at排序“新上架优先”的体验全靠这个字段。要注意代码风格一致性要加价格升降序就在后续链路上统一追加orderBy不要一半用orderByAsc一半用orderByDesc生成的SQL排序经常不是预期结果。3.3 关键词搜索自己写SQL别把JOIN堆在上层图书搜索必然涉及书名、作者、ISBN这些字段在book_info表在售状态和价格在book_offer表JOIN逃不掉。用QueryWrapper拼这种JOIN理论上可以做但可读性差我建议直接在Mapper上写一段可读的SQLSelect({ script, SELECT b.id AS book_info_id, b.title, b.author, o.price, o.degree, o.id AS offer_id, FROM book_offer o INNER JOIN book_info b ON o.book_info_id b.id, WHERE o.status #{status}, if testkeyword ! null and keyword ! \\ , AND (b.title LIKE CONCAT(%, #{keyword}, %), OR b.isbn LIKE CONCAT(#{keyword}, %)), /if, ORDER BY o.updated_at DESC, LIMIT #{offset}, #{limit}, /script }) ListBookOfferSearchResult searchBookOffers(Param(status) int status, Param(keyword) String keyword, Param(offset) long offset, Param(limit) long limit);两个设计细节ISBN用LIKE CONCAT(#{keyword}, %)这是前缀匹配有概率命中小索引书名用%keyword%包含匹配几万行数据量下不追求索引命中换模糊搜索体验。LIMIT #{offset}, #{limit}手动分页适合总行数在几十万以内的系统再大就该改成基于上一页最后一条记录时间的键集分页。status参数从枚举code传入避免魔法数字散落在Service里。3.4 列表页常用查询和索引的配合方式把上面几种查询拆开看索引利用情况不是靠猜的对照关系一目了然查询场景写法索引利用分类页在售按价格排序复合索引(status, price)完全走索引我的发布列表按卖家查单列索引(seller_id)走索引书名模糊搜索自定义SQL LIKE不走索引数据量小时无害ISBN精确查找eq(isbn)唯一索引生效这种表也是springboot面试时经常被问到的点谁在回表、谁有文件排序、谁全表扫描对着一看就能讲清楚。另一个经验是列表接口不要自己写count(*)直接使用MP的selectPage返回的total字段MP内部生成的count语句相比手写更容易被缓存性能也更可控。4. 下单链路事务边界、并发防超卖与定时关单4.1 下单Service的标准写法与事务边界下单做三件事校验offer在售、把offer改为预定中、生成一条待支付订单。这三步必须在一个事务里否则会出现“offer锁住了但订单没生成”的脏状态。用Transactional包住方法同时显式声明rollbackForTransactional(rollbackFor Exception.class) public Order createOrder(Long offerId, Long buyerId) { BookOffer offer bookOfferMapper.selectById(offerId); if (offer null || offer.getStatus().getCode() ! OfferStatus.AVAILABLE.getCode()) { throw new BusinessException(该书已售出或已下架); } boolean locked bookOfferMapper.compareAndSetStatus( offerId, OfferStatus.AVAILABLE.getCode(), OfferStatus.RESERVED.getCode()); if (!locked) { throw new BusinessException(手慢了图书已被他人预定); } Order order new Order(); order.setOrderNo(buildOrderNo()); order.setOfferId(offerId); order.setBuyerId(buyerId); order.setSellerId(offer.getSellerId()); order.setAmount(offer.getPrice()); order.setStatus(OrderStatus.PENDING.getCode()); orderMapper.insert(order); return order; }compareAndSetStatus是自定义Mapper方法对应这条UPDATEUPDATE book_offer SET status #{targetStatus}, updated_at NOW() WHERE id #{offerId} AND status #{sourceStatus}整个下单动作是“条件更新”而不是“先查再改”。条件更新返回1说明自己确实是在售状态下改成功的返回0说明这行已被别的请求抢先改走。单条UPDATE在InnoDB行锁保护下不会出现两个买家都买到同一本书的情况。rollbackFor Exception.class必须写Spring默认只回滚RuntimeException和Error不写这个参数受检异常导致订单插入失败时事务也会被提交offer状态就白白锁死了。4.2 为什么这里不推荐悲观锁见过不少项目上来就给offer行加SELECT ... FOR UPDATE。它能解决问题但会把锁持有时间拉长从SELECT到事务结束之间所有代码都拿着行锁一旦有人在事务里加了一次外部接口调用数据库连接和行锁被拖住几百毫秒是常事。条件更新把“检查状态修改状态”合并成一条SQL锁只存在于UPDATE执行的那一瞬间事务里后续的订单插入即使慢一点锁也早就释放了。这个知识点是springboot面试题里的高频题标准答法就是把检查和修改合并为原子操作而不是分开执行两条指令。提示业务上要给买家区分“已下架”“已预定”“已售出”时条件更新返回0后再查一次offer做展示即可不影响并发正确性。但这个UPDATE只能命中主键或索引否则行锁会升级成表锁并发一高整个表都会被卡住。4.3 未支付订单30分钟自动关闭的三种选择关单方案无非是定时任务或延迟消息取舍可以简单对比方案复杂度时效性推荐场景Scheduled扫表低30秒误差订单量小、不引入中间件RabbitMQ延迟队列中秒级已有MQ基础设施Redis过期key回调中秒级已有Redis且能接受回调不确定性Spring Boot自带Scheduled零依赖覆盖二手书这种低频交易足够Scheduled(fixedDelay 30000, initialDelay 10000) Transactional(rollbackFor Exception.class) public void closeExpiredOrders() { ListOrder expiredOrders orderMapper.selectTimeoutOrders( LocalDateTime.now().minusMinutes(30), 200); for (Order order : expiredOrders) { int poisoned orderMapper.updateStatusIfPending( order.getId(), OrderStatus.PENDING.getCode(), OrderStatus.CLOSED.getCode()); if (poisoned 0) { bookOfferMapper.releaseOffer(order.getOfferId(), OfferStatus.RESERVED.getCode(), OfferStatus.AVAILABLE.getCode()); } } }Scheduled(fixedDelay 30000)表示上一次执行完再等30秒适合低频同步任务initialDelay 10000让项目启动稳定后再跑。selectTimeoutOrders查所有创建时间早于当前时间30分钟且状态还是待支付的订单limit 200限制每轮处理量。第二轮里的updateStatusIfPending也是条件更新只有状态还在待支付才会被关闭防止定时任务和用户手动取消同时发生时状态双写。关订单和释放offer必须在同一个事务里否则任务执行到一半崩溃会出现订单关了书没放回在售的中间状态。扫描SQL要给(status, created_at)建联合索引否则每天定时任务就是一次全表扫。5. 上线前的验收并发校验与ISBN去重两个落地技巧5.1 用ab命令验证接口没有超卖对下单接口做并发校验是上线前最值得花的时间。Jmeter适合复杂场景验证单个接口并发时ab一条命令就够ab -n 100 -c 20 -T application/json -p create_order.json \ http://localhost:8080/api/orders/create?offerId101-n 100表示总共100次请求-c 20表示并发数20-p指定请求体文件。直接看HTTP 200的数量成功数应该是1也就是offerId对应的那一本书。成功数是0说明offer初始状态不是AVAILABLE检查测试数据有没有重置成功数大于1说明条件更新SQL没生效回去核对Mapper上的UPDATE是否真的带了status条件。5.2 上架时做ISBN规范化避免重复书一分为二二手书平台最隐蔽的数据质量问题是同一本书因为ISBN带连字符、大小写不同而变成两条book_info。给book_info加唯一索引再配合代码规范化ALTER TABLE book_info ADD UNIQUE KEY uk_isbn_title (isbn, title);上架时统一处理trim去掉前后空格、ISBN转大写、连字符去掉然后插入。插入遇到DuplicateKeyException时不要吞掉先把已有book_info查出来把它的ID关联到新offer上。这样十个卖家挂同一本书book_info永远只有一条记录详情页的收藏和点击量不会被打散展示出来就是“同一本书下方挂多个卖家的offer列表”。5.3 上线后核对offer状态和订单状态的一致性最后提供一个每日巡检SQL它能直接把订单和offer状态不一致的行捞出来SELECT o.id, o.offer_id, o.status AS order_status, oo.status AS offer_status FROM t_order o LEFT JOIN book_offer oo ON o.offer_id oo.id WHERE o.status 0 AND oo.status ! 1;这里0是待支付1是预定中。查到结果时先人工修正数据再排查业务哪个分支漏了条件更新。双写状态靠事务解决不了逻辑分支遗漏的问题只能靠巡检把这些漏网之鱼兜住。顺手给这张表的status和created_at建联合索引一分钟以内的扫描时间都在正常范围内。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻