SpringBoot+Vue3图书管理系统:从数据库设计到部署全流程解析
做图书管理系统是我这几年看到频率最高的 Java 后端练手项目之一但同时把 SpringBoot、Vue3、MyBatis、MySQL 串成一套完整前后端分离源码的其实并没有那么多。很多同学拿到手的是一个只写了 CRUD 的半成品借阅流程绕不开事务分页查询绕不开 PageHelper登录绕不开 JWT前端绕不开路由守卫和跨域——这些都是简历上和面试题里真正会被追问的点。这篇内容我按照一套可运行的智慧图书管理系统源码来拆解从数据库设计到后端接口再到 Vue3 管理界面和最终部署适合正在做课程设计、毕业设计或者准备 SpringBoot / Vue3 面试题的人直接参考照着搭建就能跑通全流程。1. 拆解系统边界图书管理系统到底要做哪些事我习惯在写代码之前先把业务边界画清楚不然很容易陷入“用户管理也做、角色也做、日志也做、最后什么都没做完”的窘境。这个图书管理系统我框定的核心是三条线图书信息管理、读者借阅归还、后台权限管理。围绕这三条线扩展出公告、分类、统计看板等附属功能但所有技术难点都集中在这几条主线上。1.1 角色与核心权限系统里只保留两种角色管理员和普通读者用户。管理员负责图书录入、上架下架、分类维护、处理借阅超期、发布公告、查看统计数据。普通用户浏览图书、借书、还书、查看个人借阅记录、修改个人资料。权限控制不需要做成复杂的 RBAC 表因为业务角色就两个用role字段区分就够了。后台接口统一走拦截器校验 token再通过一个RequireAdmin注解做角色校验比引入完整 Spring Security 体系轻量很多也更容易在面试时讲清楚。1.2 关键业务闭环借阅流程是系统的核心闭环也是事务最集中的地方用户选择一本在馆图书发起借阅。系统校验用户身份、图书状态、是否重复借阅。扣减图书库存写入借阅记录状态为“借阅中”。归还时校验记录有效性恢复库存更新归还时间。这套流程最怕并发问题两个人同时借最后一本书不能都成功。解决办法是在 SQL 层做条件更新而不是先在内存里查出来再减一。后面章节我会专门讲这个细节。1.3 数据表设计数据库我选了 MySQL 8.0字符集统一utf8mb4排序规则utf8mb4_unicode_ci。核心表一共五张表名说明关键字段t_user用户表id, username, password, real_name, role, statust_category分类表id, name, sort_ordert_book图书表id, isbn, title, author, publisher, category_id, total_count, available_countt_borrow_record借阅记录表id, user_id, book_id, borrow_time, due_time, return_time, statust_announcement公告表id, title, content, create_time图书表里我单独维护了一个available_count可借库存而不是每次通过原始库存减去未归还记录来实时计算。为什么不实时计算因为每次查询都要 join 借阅记录聚合统计数据量大了之后性能会很差而且无法处理超期未归还这类中间状态。维护一个冗余字段用事务保证它和借阅记录的一致性查询时直接查字段就行。建表 SQL 里 I want 用两条很关键的索引t_borrow_record的(user_id, status)和t_book的category_id。前者支撑“我的借阅列表”查询后者支撑分类筛选。联合索引要遵守最左前缀原则user_id在前、status在后这样单独按用户查也能命中索引。CREATE TABLE t_book ( id BIGINT AUTO_INCREMENT PRIMARY KEY, isbn VARCHAR(32) NOT NULL, title VARCHAR(128) NOT NULL, author VARCHAR(64) NOT NULL, publisher VARCHAR(128) DEFAULT , category_id BIGINT NOT NULL, total_count INT NOT NULL DEFAULT 0, available_count INT NOT NULL DEFAULT 0, location VARCHAR(64) DEFAULT , status TINYINT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id), UNIQUE KEY uk_isbn (isbn) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;status字段我用 0 表示下架、1 表示上架、2 表示借出中下单时只查状态为 1 且可借库存大于 0 的图书。这个设计简单但能给后端省掉大量不必要的判断分支。2. SpringBoot 后端工程搭建依赖、目录结构和配置后端工程我从 SpringInitializr 或者直接手写 pom.xml 开始都行。我建议手写一次因为你会在过程中搞清楚每个依赖是干什么用的而不是一键生成完就忘掉。2.1 为什么不选 SpringBoot 2标题里写的是 SpringBoot没有写版本但我强烈建议用 SpringBoot 3.x Java 17。原因很实际SpringBoot 3 是基于 Jakarta EE 的老项目里那些javax.servlet包到 3.x 全改成了jakarta.servlet如果你现在学的是 2.x过半年再接触新项目会有一次不小的切换成本。面试时问“SpringBoot 3 有什么变化”答javax到jakarta迁移、最低 Java 17、AOT 编译支持这就能拉开差距。不过要注意SpringBoot 3 对 MyBatis 官方 starter 版本有要求mybatis-spring-boot-starter需要 3.0.3 以上老版本 2.x 的 starter 在 SpringBoot 3 里启动会直接报找不到SqlSessionFactory。2.2 pom.xml 依赖清单parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security-crypto/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里我单独解释两个选型第一密码加密只引入spring-boot-starter-security-crypto这个包里有BCryptPasswordEncoder但没有 Spring Security 那套过滤器链不会拦截我们的接口非常适合前后端分离项目。第二JWT 直接拆成jjwt-api和运行时实现两部分编译时依赖 api运行时才加载实现这是 jjwt 官方推荐的用法也能避免不同实现包冲突。2.3 目录结构工程分包我以下面这种方式组织每一层都职责单一com.example.library ├── LibraryApplication.java ├── common │ ├── R.java // 统一响应体 │ ├── ResultCode.java │ └── GlobalExceptionHandler.java ├── config │ ├── WebMvcConfig.java │ └── PageHelperConfig.java ├── controller ├── entity ├── mapper ├── service └── util └── JwtUtil.java很多人写项目时 entity、mapper、service、controller 分得清但common和config两个包经常被忽略。common里的统一响应体是所有接口的出口config里的 WebMvc 配置是拦截器和跨域资源的注册入口。没有这两个包你写到后面会发现 controller 返回值五花八门有的直接返回对象有的返回 Map前端根本没法统一处理。2.4 application.yml 配置server: port: 8080 servlet: context-path: /api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/library_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.library.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl pagehelper: helper-dialect: mysql support-methods-arguments: true配置里有三个容易踩坑的细节。第一allowPublicKeyRetrievaltrue是 MySQL 8 使用 caching_sha2_password 认证时常见报错Public Key Retrieval is not allowed的解法不用这个参数某些连接工具和驱动版本下可能连不上。第二map-underscore-to-camel-case必须开否则数据库的create_time无法自动映射到实体里的createTime。第三StdOutImpl会把每一条 SQL 打到控制台本地调试很有用但部署到生产环境需要换成 Slf4jImpl 或者直接关闭。3. MyBatis 持久层实战Mapper XML、动态 SQL 与 PageHelper 分页MyBatis 是这套系统里最能拉开代码质量差异的部分。我的习惯是 Mapper 接口只写方法签名所有复杂 SQL 都放 XML 里这样 SQL 可以被独立维护和优化也方便做代码审查。3.1 Mapper 接口与 XML 的映射实体类Book对应表t_book字段映射靠驼峰转换自动完成。需要注意的是categoryId这个字段如果列表页要展示分类名称就需要在查询时 join 分类表或者用嵌套查询。我更推荐 join resultMap的方式mapper namespacecom.example.library.mapper.BookMapper resultMap idBookWithCategoryMap typeBook id propertyid columnid/ result propertytitle columntitle/ result propertyauthor columnauthor/ result propertycategoryId columncategory_id/ association propertycategory javaTypeCategory id propertyid columncid/ result propertyname columncategory_name/ /association /resultMap select idselectBookList resultMapBookWithCategoryMap SELECT b.*, c.id AS cid, c.name AS category_name FROM t_book b LEFT JOIN t_category c ON b.category_id c.id where if testkeyword ! null and keyword ! AND (b.title LIKE CONCAT(%, #{keyword}, %) OR b.author LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null AND b.category_id #{categoryId} /if if teststatus ! null AND b.status #{status} /if /where ORDER BY b.create_time DESC /select /mapper这段 SQL 里最值得注意的就是LIKE的写法。很多人用%${keyword}%拼字符串MyBatis 提供${}是为了在无法使用预编译的场景下用的但 LIKE 完全可以用CONCAT(%, #{keyword}, %)做参数绑定既保留模糊查询又避免 SQL 注入风险。面试时问到#{}和${}的区别直接答“#{}进的是 PreparedStatement 占位符${}是直接拼接 SQL”再配合这个例子比背一句话有说服力。3.2 PageHelper 分页插件原理与实战分页是管理后台的刚需。MyBatis 自带的分页方式是在 SQL 后面手动拼LIMIT数据量小无所谓但一旦改了页码就要同时改两处很烦。PageHelper 的用法是public PageInfoBookVO pageBooks(int pageNum, int pageSize, String keyword, Long categoryId) { PageHelper.startPage(pageNum, pageSize); ListBookVO list bookMapper.selectBookList(keyword, categoryId); return new PageInfo(list); }接口返回的PageInfo里有total、pageNum、pageSize、pages、list这些字段前端页面组件直接绑定即可。但 PageHelper 有一个很多人不知道的坑它是基于 ThreadLocal 实现的分页参数传递。PageHelper.startPage()之后紧跟的那一条 MyBatis 查询会被拦截并拼接 LIMIT但如果 startPage 之后、查询之前多调了一次其他 Mapper 查询或者发生了异常但没清理 ThreadLocal分页参数就会串到下一个查询上导致“第二个查询莫名其妙被分页了”。正确的姿势是startPage与目标查询之间不要插入任何无关数据库操作。查询方法内只执行一条 select。如果你的 Service 方法里需要先查用户信息再查图书列表两个查询之间不能把startPage放在开头就指望它精准命中第二个。事务方法内部尤其注意线程复用之后可能会把上一轮的 PageHelper 参数带到下一轮请求里。更稳妥的替代方案是不依赖 ThreadLocal直接在 SQL 层用Page参数对象手动计算 offset 和 limit。但 PageHelper 仍然是面试高频题原理要能讲清楚它拦截了 Executor 的 query 方法借助 PageInterceptor 读取 ThreadLocal 中的分页参数改写原 SQL添加数据库方言的 limit 语句同时执行 count 查询。3.3 关于 MyBatis 缓存的三句话MyBatis 自带一级缓存和二级缓存。一级缓存默认开启作用范围是 SqlSession。在 Spring 环境中每个 SqlSession 生命周期很短一级缓存基本只在一个事务内有效。二级缓存需要手动开启作用范围是 namespace 级但缓存的是序列化后的对象查询结果里如果涉及关联对象配置不当很容易出现脏读。我的建议图书管理系统这种业务不需要开启二级缓存查询压力不大的时候缓存带来的复杂度远远大于收益。面试被问到只需要把一级缓存和二级缓存的生命周期讲清楚再补一句“脏读风险主要来自多表 join 查询结果被缓存后关联表更新没有同步失效”就能显示出你确实看过源码。4. 登录认证与借阅事务安全性和一致性怎么同时保证这个系统里我必须先做完认证和权限因为图书管理后台不可能裸奔。但我不想写一整套 Spring Security那样对于只有两种角色、十几个接口的场景反而引入了过于复杂的过滤器链和配置。项目团队要的就是能跑、能讲、能改我用 JWT 拦截器 注解实现轻量级鉴权。4.1 JWT 登录流程登录接口流程接收用户名密码按用户名查用户表。用BCryptPasswordEncoder.matches()校验密码。校验通过后用 JwtUtil 生成 token。将 token 返回给前端前端存储到 localStorage 或 Pinia。JwtUtil 核心代码public String createToken(Long userId, String username, String role) { long now System.currentTimeMillis(); return Jwts.builder() .setSubject(username) .claim(userId, userId) .claim(role, role) .setIssuedAt(new Date(now)) .setExpiration(new Date(now expireTime)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token) .getBody(); }注意这里secretKey不能当字符串直接塞进去signWith需要 Key 对象实际代码里要基于字符串生成一个长度足够的SecretKeySpec。如果你的密钥太短运行时会报WeakKeyException这是 jjwt 0.11 之后的强制要求。4.2 拦截器实现继承HandlerInterceptor在preHandle里从请求头Authorization取出 token解析失败就抛未授权异常成功就把 userId 和 role 放进request.getAttribute()。这样后续 Controller 不需要每个方法都解析 token。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(Authorization); if (token null || token.isEmpty()) { throw new UnauthorizedException(未登录); } Claims claims jwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } }再把这个拦截器注册到 WebMvcConfig放行登录注册和图书公开查询其余接口全部拦截/api/auth/login /api/auth/register /api/book/list /api/book/detail/**这个配置容易忘的是预检请求。前后端分离场景下浏览器跨域 POST 请求会先发一个 OPTIONS 预检请求。拦截器如果直接把 OPTIONS 请求拦下来前端所有 POST 接口都会报 CORS error而且浏览器控制台里看到的错误信息并不直观。所以拦截器里要加一句if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; }4.3 借阅事务与库存扣减这是整个系统最容易被问到的核心。借阅时减库存常规错误写法是Book book bookMapper.selectById(bookId); if (book.getAvailableCount() 0) { book.setAvailableCount(book.getAvailableCount() - 1); bookMapper.updateById(book); insertBorrowRecord(...); }这个写法在单线程测试下没问题一旦并发两个请求同时查出来availableCount1同时判断大于 0同时更新最后库存变成 0 而不是 -1看起来没错但借阅记录插入了两条实际有一人借了不存在的库存。问题根源是“检查然后更新”这两步没有做成原子操作。正确做法是把判断直接写进 UPDATE 的 WHERE 条件UPDATE t_book SET available_count available_count - 1 WHERE id #{bookId} AND available_count 0 AND status 1受影响行数为 1 说明扣减成功为 0 说明库存不足或图书下架直接抛业务异常回滚。然后再插入借阅记录Transactional(rollbackFor Exception.class) public void borrowBook(Long userId, Long bookId) { int affected bookMapper.decreaseAvailable(bookId); if (affected 0) { throw new BusinessException(图书已借完或已下架); } BorrowRecord record new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setBorrowTime(LocalDateTime.now()); record.setDueTime(LocalDateTime.now().plusDays(30)); record.setStatus(1); borrowRecordMapper.insert(record); }Transactional保证扣库存和写记录在同一个事务里如果 insert 失败update 也会回滚不会出现库存扣了但记录没写入的情况。这里还有一个容易被忽略的点事务必须通过代理对象调用才能生效。如果你在同一个类里 A 方法调用 B 方法B 上有Transactional也不会生效因为走的是 this 调用而不是 Spring 代理。所以借阅逻辑我会拆成 controller - service.borrowBook()事务注解放在接口或公开方法上而不是放在自调用链里的私有方法上。5. Vue3 前端工程Vite、Element Plus、Axios 封装和路由守卫前端我用了当前最稳的组合Vite 5 Vue3 Element Plus Vue Router 4 Pinia。这个组合也是现在 vue3 后台管理系统的事实标准。如果你搜索过“vue3 安装”“vue3 后台管理系统”大概率见到的就是这套。5.1 初始化项目npm create vitelatest library-web -- --template vue cd library-web npm install npm install element-plus element-plus/icons-vue axios vue-router piniaElement Plus 可以全量引入也可以按需自动导入。管理后台为了省事全量引入没问题如果你想在简历上写“按需引入优化打包体积”就需要用unplugin-auto-import和unplugin-vue-components两个插件写起来稍微绕一点。我的建议是功能先跑通等有时间再优化构建体积。5.2 Axios 封装不封装的 Axios 会让每个页面都重复写 token 头和错误处理一个月后你就想重构。我在src/utils/request.js里统一封装import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization token } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } ) export default request关键细节baseURL用/api而不是写死http://localhost:8080/api。开发环境靠 Vite 代理转发到后端生产环境靠 Nginx 代理转发前端代码里不出现具体服务器地址这样环境切换时只需要改代理配置不用改业务代码。5.3 路由守卫与页面权限路由表分两块公开路由和需要登录的路由。在router/index.js里给需要登录的路由添加meta: { requiresAuth: true }需要管理员的路由加meta: { requiresAdmin: true }router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.path /login) { next() return } if (!token) { next(/login) return } if (to.meta.requiresAdmin role ! ADMIN) { next(/403) return } next() })我这里用的是最简单直观的判断。更严谨一点的做法是把用户信息放到 Pinia 里token 变了就重新拉取用户详情。5.4 后台管理页面示例图书管理列表页是能体现前后端交互完整流程的典型页面。表格用 Element Plus 的el-table分页用el-pagination顶部放搜索栏操作列放编辑和删除按钮。核心思路非常固定初始化调用fetchBooks()搜索按钮把搜索条件并入分页参数页码变化时重新拉取数据。script setup import { ref, onMounted } from vue import request from /utils/request const loading ref(false) const bookList ref([]) const total ref(0) const queryParams ref({ pageNum: 1, pageSize: 10, keyword: , categoryId: null }) const fetchBooks async () { loading.value true try { const data await request.get(/book/page, { params: queryParams.value }) bookList.value data.list total.value data.total } finally { loading.value false } } const handleSearch () { queryParams.value.pageNum 1 fetchBooks() } const handlePageChange page { queryParams.value.pageNum page fetchBooks() } onMounted(fetchBooks) /script这里有个常见 bugqueryParams直接传给 Axios 时Vue3 响应式对象的属性如果被同时修改并立即使用可能出现请求参数是上一帧值的情况。稳妥做法是在fetchBooks内部展开一次const data await request.get(/book/page, { params: { ...queryParams.value } })实际上 Axios 会对对象做序列化但展开对象仍然是更保险的写法也能避免偶然在queryParams上传了额外响应式引用。6. 联调、部署与 MySQL 安装过程中的高频坑一套源码从本地跑通到真正能部署最花时间的其实是环境问题。我在本地装 MySQL、打包前端、配置 Nginx 的环节里反复踩过几个坑列出来比你自己折腾半天强。6.1 MySQL 8 安装与连接MySQL 8 的默认认证插件是caching_sha2_password如果你用了老版本驱动或者老版本 Navicat连接时会报Unable to load authentication plugin caching_sha2_password。解决办法有两种一是把驱动升级到com.mysql.cj.jdbc.Driver5.1.49 以上SpringBoot 2.7 默认带的驱动就是 mysql-connector-j二是把用户认证插件换回mysql_native_passwordALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码;开发阶段我推荐直接升级驱动因为mysql_native_password本身相对老MySQL 8 的 caching_sha2_password 安全性更高。前面已经说过JDBC URL 里加allowPublicKeyRetrievaltrue能避免公钥获取报错。6.2 Vite 开发代理配置前端开发服务器默认跑在 5173后端在 8080存在跨域。跨域的正规解决方式是后端配置 CORS 或者前端代理。我推荐 Vite 代理因为它同时解决“跨域”和“接口前缀统一”两个问题// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })注意后端如果配置了context-path: /api那前端请求/api/book/page经过代理后实际后端收到的路径也是/api/book/page两者是匹配的不要再在代理里加 rewrite。代理 rewrite 是在“前端请求路径和后端 context-path 不一致”时才需要。如果后端同时配了 CORS 允许跨域又走了前端代理会出现一些重复的Access-Control-Allow-Origin头问题所以二选一别都配。6.3 生产环境打包前端npm run build生成dist目录这是一个纯静态资源目录用 Nginx 托管即可。后端mvn clean package -DskipTests java -jar target/library-api-0.0.1-SNAPSHOT.jarNginx 的静态与 API 反向代理配置server { listen 80; server_name your-domain.com; root /opt/library-web/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }try_files这行是 SPA 路由的关键。Vue Router 默认是 history 模式路径如/admin/books在服务器上并不存在对应的物理文件如果不加try_files的最后一个index.html刷新页面直接 404。这个问题面试时经常被当成“前端部署细节”来问。7. 项目跑通之后值得记录的踩坑与面试延伸最后这部分是我觉得这套源码最有价值的地方因为任何一个直接抄网上开源项目的人都会在运行阶段遇到下面这些问题而大多数项目文档根本不写。7.1 翻车频率最高的四个运行问题问题现象根因与解决日期变成时间戳数字前端展示1700000000000后端没配置 Jackson 日期格式配置spring.jackson.date-format和time-zone更新数据后列表不刷新管理端编辑完还在旧列表前端重新调用列表查询的同时分页参数没重置到第一页MyBatis 查询不到数据数据库有数据但接口返回空检查mapper-locations是否指向classpath:mapper/*.xml或 resultMap column 和 property 对不上PageHelper 分页结果不对列表总数变成 count 查询里的 join 行数检查 count Sql 是否自动生成了联合查询 count必要时手写 count 查询还有一个我调试很久的“mybatis update 执行慢”的情况。原因不是 MyBatis 本身而是UPDATE t_book SET available_count available_count - 1 WHERE id ?执行时id 没有走主键索引实际是表里有大量脏数据导致行锁竞争严重。后来我在available_count字段和status字段上加了联合索引慢查询立刻缓解。以后遇到执行慢第一反应应该是EXPLAIN SELECT ...看是否走索引而不是怀疑框架。7.2 面试官追问时怎么讲这套系统这套系统对应的面试题基本集中在几个点SpringBoot 自动装配原理、MyBatis 分页插件原理、JWT 认证流程、事务失效场景、数据库索引优化。我会在回答里主动抛出自己做过的几个决策选 SpringBoot 3 Java 17说明你关注版本演进。用 JWT 拦截器而不是 Spring Security说明你会根据业务体量做技术选型不是只会套全家桶。库存扣减用“条件 UPDATE 判断受影响行数”说明你理解并发环境下的数据一致性。事务注解放在公开方法上并说明自调用失效风险说明你不是把注解当装饰用而是真知道代理机制。分页用 PageHelper 但能讲出 ThreadLocal 原理和串页原因说明你读过插件的实现思路。这些都是“八股文”里经常出现的 java 面试题和 springboot 面试题但结合项目讲出来面试官才有印象。7.3 如果想继续扩展图书管理系统是一个很好的骨架往上加需求并不难。如果我自己继续维护这套源码我会优先加三块第一图书封面使用对象存储把图片上传从本地磁盘剥离出来第二借阅记录增加定时任务每天扫描超期未归还的书并自动发通知第三把“推荐图书”做成基于借阅次数的简单统计接口前端首页展示热门排行。这三块工作量都不大但能明显提升项目的完整度和你在描述项目时的底气。我在实际把这套系统从零跑通的过程中最深的感受是这种项目真正的门槛从来不是某个单一技术而是“全链路都闭环”的能力。数据库字段设计不合理后面烦事务边界画不清并发挂前端代理不会配联调慢部署少一行 try_files刷新就 404。把这些点一个个捋顺之后你自己再拿这套源码去改造业务、应付面试都会从容很多。最后再分享一个小技巧源码跑通之后故意把数据库清空重建一次、把前端node_modules删掉重新安装一次能快速发现自己当初装环境时哪里依赖了运气。能扛住这两次重演这个项目才是真正属于你的。