资讯动态

电商平台系统答辩PPT:SpringBoot+Vue库存超卖与缓存实战

发布时间:2026/9/17 14:19:47 来源:尧图企业网站定制
简介面向计算机相关专业毕业生与Java Web项目开发者这是一份基于Java技术栈、SpringBoot与Vue的电商平台系统毕业设计答辩PPT可用于课程设计、毕业答辩汇报及项目方案梳理。整包仅含1个pptx文件大小约6.99MB单文件即可打开涵盖课题背景、需求分析、功能结构、ER概念设计、主页面实现与用户模块、商品模块、购买记录、订单模块等前台后台模块并涉及数据安全、访问控制、商品推荐、搜索评论、Ajax无刷新更新与登录测试等要点。已有83人学习浏览适合需要快速搭建答辩框架、整理系统功能说明与测试用例的读者参考。通过它可了解电商系统的整体设计脉络、模块划分方式、测试情景与期望结果以及答辩总结和致谢页面为制作自己的答辩材料提供结构参照。1. 从电商平台系统答辩PPT这个标题里拆出评委真正想听的三层答辩现场最尴尬的不是功能少而是 PPT 上写着 SpringBoot 三层架构、Vue 组件化开发评委追问一句下单那一刻库存怎么保证不超卖人就卡住了。这个标题其实压着两件事一套用 Java 技术栈真正跑得起来的电商平台以及一份能把它讲清楚、并且守得住追问的答辩材料。前者决定系统能不能被认可后者决定你在台上能不能把主动权拿回来。它适合正在做课程设计、毕业设计的同学也适合工作几年后被临时抓去做技术汇报的工程师。往下走先把后端链路梳成能复现的代码再把这些代码压缩成 PPT 里经得起细看的那几页最后落到几个能当场演示的技巧上。2. SpringBoot 后端分层商品、购物车、订单三个模块怎么切才讲得清电商平台的功能表能列几十条但答辩 PPT 里放不下评委也没耐心看。真正需要讲透的是一次下单这条链路上数据从哪儿来、经过谁、落到哪儿。模块切得越碎讲的时候越乱把 Controller、Service、Mapper 三层的职责边界说清楚再挑订单和库存这两个有技术含量的点深挖比铺开十个 CRUD 模块有效得多。2.1 包结构与依赖选型先把 JDK 版本这个坑填掉常见的包结构是controller、service、service.impl、mapper、entity、dto、config、common、interceptor。这套结构不是官方规定而是社区里最容易被评委一眼认出来的组织方式。分层职责可以用一张表说清楚这张表直接抄进 PPT 也成立。层职责不该做的事PPT 里怎么讲Controller参数校验、鉴权、返回统一结构写业务判断、直接调 Mapper接口契约层对外只暴露 JSONService事务、库存扣减、订单状态流转处理 HttpServletRequest业务规则唯一入口Mapper单表 SQL 与映射跨表复杂拼装与数据库一一对应entity/dto数据库映射与传输对象分离把密码字段直接返回前端避免敏感字段泄漏依赖这块有个高频问题网上教程多为 JDK 8 环境直接套到 SpringBoot 3.x 上会报javax.servlet找不到因为 3.x 全面换成了jakarta.*命名空间同时强制 JDK 17 起步。选版本的原则是跟着 JDK 走机器上是 JDK 8 就别硬上 3.x用 2.7.x 更省事JDK 17 环境再考虑 3.x。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies !-- Web 层内嵌 Tomcat SpringMVC -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 参数校验NotNull / Min 才生效 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- 持久层省掉大量单表 XML -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency !-- 缓存与库存预扣减 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies父工程的spring-boot-starter-parent统一托管了绝大多数依赖版本所以下面几个 starter 不写version只有 MyBatis-Plus 这类不在托管清单里的才需要显式声明。scope为runtime表示编译期用不到、运行期才加载Lombok 标记optional是为了不让它传递给引入本模块的其他工程。2.2 商品、订单、订单明细的表设计电商的表不用多四张就能撑起完整链路。字段设计里最容易在答辩上被挑的是金额类型——用DOUBLE存钱迟早出现0.30000000000000004必须用DECIMAL。表名关键字段说明t_userid, username, password, phone, create_time密码存 BCrypt 哈希不存明文t_productid, name, price DECIMAL(10,2), stock, version, statusversion 是乐观锁版本号t_orderid, order_no, user_id, total_amount, status, create_timeorder_no 建唯一索引做幂等t_order_itemid, order_id, product_id, price, num下单时快照商品价格不关联查现价t_order_no上的唯一索引是很多人忽略的细节前端因为网络抖动重复提交两次第二次插入会因为唯一键冲突失败业务层捕获后直接返回第一次的订单号比在 Service 里加分布式锁轻得多。Data TableName(t_product) public class Product { TableId(type IdType.AUTO) private Long id; private String name; private BigDecimal price; // 金额一律 BigDecimal禁止 double private Integer stock; Version // MyBatis-Plus 乐观锁注解 private Integer version; private Integer status; // 1 上架 0 下架 }Version只是让 MyBatis-Plus 的updateById自动带上版本条件自定义 SQL 需要手写版本判断不能指望注解生效。价格字段用BigDecimal前端传过来的是字符串反序列化时让 Jackson 直接映射成BigDecimal不要中途经过Double。2.3 下单接口的事务边界与库存扣减写法下单是典型的多表写操作扣库存、插订单、插明细三步必须同生共死。事务注解写在 Service 实现类的方法上而不是 Controller 上——写在 Controller 上会让整个请求处理过程都占用数据库连接。Override Transactional(rollbackFor Exception.class) // 默认只回滚 RuntimeExceptionchecked 异常要显式声明 public String createOrder(Long userId, Long productId, Integer num) { // 1. 校验商品状态 Product product productMapper.selectById(productId); if (product null || product.getStatus() 0) { throw new BizException(商品不存在或已下架); } // 2. 带库存与版本条件的扣减影响行数为 0 说明被别人抢先 int rows productMapper.deductStock(productId, num, product.getVersion()); if (rows 0) { throw new BizException(库存不足请稍后重试); } // 3. 插入订单主表order_no 唯一索引兜住重复提交 String orderNo OrderNoUtil.next(); Order order new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setTotalAmount(product.getPrice().multiply(BigDecimal.valueOf(num))); order.setStatus(1); // 1 待支付 orderMapper.insert(order); // 4. 插入明细价格做快照 OrderItem item new OrderItem(); item.setOrderId(order.getId()); item.setProductId(productId); item.setPrice(product.getPrice()); item.setNum(num); itemOrderMapper.insert(item); return orderNo; }对应的 Mapper 方法要手写条件里同时带上stock num和version两个约束Update(UPDATE t_product SET stock stock - #{num}, version version 1 WHERE id #{productId} AND stock #{num} AND version #{version}) int deductStock(Param(productId) Long productId, Param(num) Integer num, Param(version) Integer version);stock num保证库存不会被扣成负数version #{version}保证只有读到旧版本号的那个线程能扣成功。影响行数为 0 时抛异常触发回滚此时订单和明细都还没写入数据是干净的。注意Transactional有自调用失效问题。同一个类里 A 方法调 B 方法B 上的事务注解不生效因为走的是this引用而非代理对象。下单逻辑要对外调用别在内部互相调。多商品结算时购物车里每个商品都要扣库存写法是把商品 ID 排序后再依次扣减避免两个请求以相反顺序锁行导致死锁。这个细节在答辩上讲出来技术含量立刻不一样。3. Vue 前端工程路由、状态管理和打包这三件事后端接口跑通之后前端要解决的是页面怎么组织、购物车数据存哪儿、上线后为什么白屏或错位。Vue 生态里这三件事分别对应路由表、Pinia或 Vuex和构建配置答辩 PPT 里每个写一页配上真实代码截图比放十个页面截图有说服力。3.1 从依赖安装到路由表拆分初始化工程有两条路npm create vitelatest起 Vite 模板或者用 Vue CLI。Vite 启动快构建配置更直观现在新项目基本都用它。依赖装不上时先别急着换源多数情况是 Node 版本过低Vite 需要 Node 16 以上。# 创建工程并安装依赖 npm create vitelatest shop-web -- --template vue cd shop-web npm install npm install vue-router4 pinia axios element-plus # 启动开发服务器 npm run dev路由表按业务域拆商品详情页用动态参数购物车和订单页挂上鉴权标记由全局前置守卫统一拦截// src/router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /, redirect: /home }, { path: /home, component: () import(/views/Home.vue) }, // 动态路由参数通过 route.params.id 取值 { path: /product/:id, name: ProductDetail, component: () import(/views/ProductDetail.vue), props: true }, { path: /cart, component: () import(/views/Cart.vue), meta: { requiresAuth: true } }, { path: /order/confirm, component: () import(/views/OrderConfirm.vue), meta: { requiresAuth: true } }, { path: /:pathMatch(.*)*, component: () import(/views/NotFound.vue) } ] const router createRouter({ history: createWebHistory(), routes }) router.beforeEach((to) { if (to.meta.requiresAuth !localStorage.getItem(token)) { // 未登录跳转登录页并记录来源用于登录后回跳 return { path: /login, query: { redirect: to.fullPath } } } }) export default routerroute.params.id取的是路径里的占位符route.query.keyword取的是?keywordxxx两者混用是新手常见错误动态路由传值是params筛选条件传值是query。组件上用props: true可以把路径参数直接变成 props组件内部就不用强依赖useRoute。3.2 Pinia 购物车状态与 axios 请求封装购物车数据既要跨页面共享又要在刷新后还在所以状态放 Pinia、持久化落到 localStorage。金额计算放在 getter 里避免在每个页面各算一遍。// src/stores/cart.js import { defineStore } from pinia export const useCartStore defineStore(cart, { state: () ({ // 初始化时从本地存储恢复刷新不丢 items: JSON.parse(localStorage.getItem(cart) || []) }), getters: { totalCount: (s) s.items.reduce((n, i) n i.num, 0), totalPrice: (s) s.items.reduce((n, i) n i.price * i.num, 0).toFixed(2) }, actions: { add(product, num 1) { const hit this.items.find((i) i.id product.id) if (hit) hit.num num else this.items.push({ id: product.id, name: product.name, price: product.price, num }) this.persist() }, remove(id) { this.items this.items.filter((i) i.id ! id) this.persist() }, persist() { localStorage.setItem(cart, JSON.stringify(this.items)) } } })axios 用拦截器统一做两件事请求头带上 token响应体剥掉统一包装层。后端返回{ code, msg, data }业务代码只拿到data失败时统一 reject页面里try/catch处理即可。// src/utils/request.js import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 8000 }) request.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) config.headers.Authorization Bearer ${token} return config }) request.interceptors.response.use( (res) { const body res.data if (body.code ! 200) { ElMessage.error(body.msg || 请求失败) return Promise.reject(new Error(body.msg)) } return body.data // 业务层直接拿 data }, (err) { if (err.response?.status 401) { localStorage.removeItem(token) location.href /login } return Promise.reject(err) } ) export default request超时设 8 秒是有意的下单接口一旦因为慢 SQL 卡住前端不该无限等待。baseURL里的/api是开发期由代理转发的虚拟前缀后端本身没有这个 context-path。3.3 打包后布局异常和刷新 404 的排查顺序Vite 开发期代理配置决定本地联调能不能通// vite.config.js export default defineConfig({ base: ./, // 相对路径部署到子目录不会白屏 server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, // 后端没有 /api 前缀转发时剥掉 rewrite: (p) p.replace(/^\/api/, ) } } } })changeOrigin: true让代理请求的 Host 头变成目标地址否则后端做域名校验时会拒掉。rewrite负责前缀剥离如果后端 Controller 本身就带/api这一行要删掉否则 404。打包后布局异常基本集中在三个原因按这个顺序排查最快第一base配成/而实际部署在子目录静态资源 404 导致样式全丢Network 面板里 CSS 请求是红的就属于这一类第二本地开发窗口宽、线上演示用投影仪分辨率不同Element Plus 的栅格在窄屏下换行错位检查el-row是否给了:gutter和响应式断点第三写成固定width: 1200px的容器在小屏上出现横向滚动条改成max-width加margin: 0 auto即可。history 模式下打包后直接访问/cart会 404因为服务器找不到这个物理路径需要 Nginx 回退到 index.htmllocation / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }try_files的含义是依次尝试真实文件、目录都不存在就返回 index.html交给前端路由自己解析路径。4. 库存一致性与缓存答辩最容易被追问的两个技术点电商平台的答辩问题往往不会停在你用了什么框架而是往数据一致性上压。库存有没有可能超卖、Redis 和 MySQL 数据不一致怎么办、缓存穿透怎么防这三问问出来的时候答不上来的项目会被直接判定为只做了增删改查。这一章把答案落到能跑的代码上。4.1 超卖是怎么发生的乐观锁与 Redis 预扣减对比超卖的根源是读—改—写之间没有原子性两个请求同时读到库存 1都判断够都写回 0结果卖出两件。解决办法有三档选哪一档取决于你 PPT 想强调什么。方案实现方式一致性吞吐适用与表述难度数据库乐观锁where stockn and versionv强中实现简单答辩好讲推荐作为主方案悲观锁select ... for update强低适合库存极少的热点商品容易讲成锁表被追问Redis 预扣减Lua 脚本原子扣减最终一致高技术亮点但必须补异步落库和补偿讲不清会扣分主方案用乐观锁理由要说得出口电商的库存竞争是同一商品短时间高并发乐观锁在冲突时让请求失败重试而不是让线程排队阻塞对数据库连接更友好。Redis 预扣减作为进阶亮点核心是用 Lua 保证判断加扣减原子执行-- 返回值-1 键不存在0 库存不足1 扣减成功 local stock redis.call(GET, KEYS[1]) if stock false then return -1 end if tonumber(stock) tonumber(ARGV[1]) then return 0 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1private static final DefaultRedisScriptLong DEDUCT new DefaultRedisScript( local s redis.call(GET, KEYS[1]) if s false then return -1 end if tonumber(s) tonumber(ARGV[1]) then return 0 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1, Long.class); public boolean preDeduct(Long productId, Integer num) { Long r redisTemplate.execute(DEDUCT, Collections.singletonList(seckill:stock: productId), num); return r ! null r 1L; }KEYS[1]是库存键ARGV[1]是购买数量Redis 单线程执行 Lua 期间不会被其他命令插入所以判断和扣减之间不存在竞态。要注意的是这只解决了Redis 层不超卖数据库的库存还需要靠消息队列异步落库或者定时对账补偿。答辩时如果只讲前半句不讲后半句很容易被追问到卡壳所以要么把它作为优化方向来讲要么把补偿逻辑一起画进架构图。4.2 Redis 在 SpringBoot 里的配置与缓存穿透处理商品详情是典型的读多写少场景缓存价值高。默认的JdkSerializationRedisSerializer会把 key 写成带二进制前缀的乱码用 Redis 客户端看数据时很难受所以自定义成字符串 key 加 JSON value。Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object tpl new RedisTemplate(); tpl.setConnectionFactory(factory); tpl.setKeySerializer(new StringRedisSerializer()); ObjectMapper om new ObjectMapper(); om.registerModule(new JavaTimeModule()); // 支持 LocalDateTime tpl.setValueSerializer(new GenericJackson2JsonRedisSerializer(om)); tpl.afterPropertiesSet(); return tpl; }缓存逻辑里要做两件防护空值占位防穿透随机过期时间防雪崩。public Product getDetail(Long id) { String key product:detail: id; Object cache redisTemplate.opsForValue().get(key); if (cache ! null) { // 命中空值占位说明数据库里确实没有直接返回 return NullHolder.INSTANCE.equals(cache) ? null : (Product) cache; } Product p productMapper.selectById(id); if (p null) { // 空值缓存 5 分钟挡住对不存在 ID 的反复查询 redisTemplate.opsForValue().set(key, NullHolder.INSTANCE, 5, TimeUnit.MINUTES); return null; } // 基础 30 分钟 随机 10 分钟避免大批 key 同时失效 long ttl 30 ThreadLocalRandom.current().nextInt(10); redisTemplate.opsForValue().set(key, p, ttl, TimeUnit.MINUTES); return p; }数据库层面的防护是给商品加status字段并在查询条件里带上下架商品即使 ID 被猜到也查不出内容。缓存最后一道兜底是更新商品时先更新数据库、再删除缓存而不是更新缓存避免两个并发写导致缓存里留下旧值。4.3 用压测和 SQL 校验证明库存对得上PPT 里放一句已通过压测验证没有分量放两张对得上的数据截图才有。压测用 ab 就够重点是看结果能不能和数据库对上。# 200 并发、总共 2000 次请求请求体来自 order.json ab -n 2000 -c 200 -p order.json -T application/json \ http://127.0.0.1:8080/api/order/create压测前把商品初始库存设成一个整数比如 100跑完立刻用三条 SQL 交叉验证-- 1. 剩余库存必须大于等于 0且等于 初始值 - 成功订单数 SELECT stock, version FROM t_product WHERE id 1001; -- 2. 成功创建的订单条数 SELECT COUNT(*) FROM t_order WHERE product_id 1001; -- 3. 订单号必须唯一重复提交没有产生脏数据 SELECT COUNT(*) - COUNT(DISTINCT order_no) AS dup FROM t_order;第一条 SQL 里version应该正好等于成功扣减次数因为每次成功扣减都会自增 1这个数字和订单条数对不上就说明事务边界有问题。第三条的差值必须为 0是唯一索引是否生效的直接证据。ab的-c是并发数、-n是总请求数-p指定 POST 数据文件、-T指定 Content-Type缺了后者后端会因为拿不到 JSON 体直接返回 415。5. 把系统搬进答辩 PPT架构图、演示脚本与追问应答代码写完之后剩下的工作是把它们压成能讲的形式。答辩 PPT 的页数通常被限制在 15 到 20 页技术部分能分到的也就 6 到 8 页每一页都要承担一个明确任务。5.1 一页架构图要覆盖的层次架构图不要画成十几个方块的连线迷宫。按四层画横向不超过五个框接入层放 Nginx 与 Vue 打包后的静态资源应用层放 Controller、Service、Mapper 三段数据层放 MySQL 与 Redis旁挂一条支撑能力带统一异常处理、JWT 拦截器、日志。用一条加粗的箭头把用户下单这条主链路单独标出来其他箭头用细线。评委的目光会自动跟着加粗箭头走你讲的时候也只需要讲这一条。5.2 90 秒演示脚本与页面顺序现场演示最怕临时出问题脚本要写到第几秒点哪里。时间操作口播重点0-15s首页加载展示商品列表前端 Vue 路由懒加载首屏只加载首页 chunk15-35s进入商品详情说明缓存第一次查数据库第二次命中 Redis可切到控制台看日志35-55s加入购物车刷新页面购物车数据持久化在 localStorage刷新不丢55-80s提交订单展示订单详情强调事务与乐观锁订单号唯一80-90s打开后台 SQL 窗口看库存与版本号库存与订单条数对得上演示环境要提前把 Redis 里商品详情的 key 删掉确保第一次查询走数据库、日志里有 SQL 打印这样缓存命中的对比才看得出来。同一台机器上跑前后端浏览器标签页提前开好避免现场敲命令。5.3 高频追问的应答口径追问的应对原则是先给结论再给一句理由最后给一个证据。证据可以是代码片段、SQL 结果或日志截图不要现编。追问应答骨架别这么说库存超卖怎么办结论用带 version 的条件更新理由判断与扣减在同一条 SQL 里原子执行证据deductStock的影响行数判断加了 synchronized本地锁在集群下无效Redis 挂了系统还能用吗结论缓存只做读加速降级后直接查库理由查库路径一直保留证据把缓存开关关掉再演示一次不要说Redis 不会挂事务失败会不会扣了库存结论不会扣减和插入在同一事务里理由异常抛出触发回滚证据手动抛异常后查库存不变不要说应该不会为什么不用微服务结论单体分层足够支撑当前规模拆分会带来分布式事务成本理由区分业务复杂度与技术复杂度不要贬低微服务容易引起追问一个能加分的小技巧在 PPT 附录里放一页接口耗时统计。用Around切面把每个接口的耗时打成日志或者直接在控制台看 SpringBoot 的 SQL 执行时间把商品详情走库 12ms、走缓存 1ms这两个数字放进表格。数字比形容词有说服力而且它证明的正是你在第 4 章讲的那套缓存设计真的生效了。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价