简介这份资源是面向Java Web初学者与课程设计者的零食购物网站系统完整文档围绕B/S模式下的在线零食销售与定制场景解决从需求分析到系统测试的全流程设计问题。压缩包内仅含1个docx文件约771KB以论文式章节文档承载绪论、可行性分析、需求分析、总体设计、数据库设计、各功能模块实现及测试总结等内容便于直接参考或改写为毕业设计、课程作业。文档详细展开用户管理、商品展示与检索、购物车、订单处理、定制服务、特价促销及客户服务等模块并给出Spring Boot、MyBatis、MySQL与Bootstrap的技术选型与三层架构说明还包含单元测试、集成测试与压力测试思路。目前已有300人学习适合需要快速获取完整系统设计框架、数据库表结构与模块划分参考的读者。1. 零食电商系统从零到一为什么我选 Java 而不是 Node 或 Python去年帮一个做进口零食批发的朋友搭线上商城他一开始想用 Node.js 全栈搞定理由是“快”。我劝他换成 Java他半信半疑。三个月后系统上线日均订单从几十单涨到两千多单他跟我说了一句话“幸好没图快。”这不是 Java 比 Node 强多少的问题而是网上零食购物网站系统这类业务天生适合 Java 生态来扛。零食电商看起来简单——商品展示、加购物车、下单、支付但真正做起来库存扣减的并发问题、订单状态的流转、促销规则的计算、支付回调的幂等处理每一个都是硬骨头。Java 的 Spring Boot 生态在这些场景下有大量经过验证的方案Spring Security 做认证授权、MyBatis-Plus 做数据访问、Redis 做缓存和分布式锁、RocketMQ 做异步解耦这套组合拳打下来系统稳定性和可维护性都有保障。而且 Java 的强类型和面向对象特性在多人协作开发时能减少很多低级错误。这篇文章面向的是正在做课程设计、毕业设计或者想接私活做电商系统的 Java 开发者。我会从需求分析讲到数据库设计再到核心模块的实现和部署把我在这个项目里踩过的坑和验证过的方案都摊开讲。你跟着走一遍能拿到一个可运行的系统骨架也能理解为什么每个技术选型要这么做。2. 需求分析与技术选型别急着写代码先把这四张表画清楚2.1 零食电商和普通商城的差异点在哪很多人做电商系统直接套用通用模板结果做到一半发现零食这个品类有它的特殊性。普通商城的商品规格相对固定比如手机就是颜色和内存两个维度。零食不一样同一款薯片可能有原味、番茄味、烧烤味每种口味又有小包、中包、家庭装还涉及保质期管理和临期促销。这些差异直接影响数据库表的设计。我在需求分析阶段重点梳理了四个核心实体用户、商品、订单、库存。用户表除了基础信息还要有收货地址的关联商品表要支持多规格 SKU每个 SKU 独立管理库存和价格订单表要记录优惠分摊因为零食促销经常是“满三件打八折”这种按行计算的规则库存表要支持预占和释放防止超卖。提示需求分析阶段不要只画用例图一定要把核心业务的字段级设计做出来否则后面改表结构会非常痛苦。2.2 技术栈选型Spring Boot MyBatis-Plus Redis MySQL后端框架我选的是 Spring Boot 3.x 搭配 MyBatis-Plus。Spring Boot 的自动配置和起步依赖能省掉大量 XML 配置MyBatis-Plus 在单表 CRUD 上几乎不用写 SQL复杂查询再用 XML 补充。数据库用 MySQL 8.0InnoDB 引擎支持事务和行级锁这是库存扣减的基础。缓存层用 Redis 做商品详情和购物车的存储同时用 Redisson 实现分布式锁。前端我没有用 Vue 或 React 做前后端分离而是选了 Thymeleaf 做服务端渲染。原因很简单这个项目的核心是展示 Java 后端能力前后端分离会引入跨域、Token 管理、前端工程化等额外复杂度对于课程设计或中小型项目来说Thymeleaf 足够用而且部署简单一个 Jar 包就能跑起来。!-- pom.xml 核心依赖 -- dependencies !-- Web 框架 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 模板引擎 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency !-- ORM 框架 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency !-- Redis 客户端 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- 分布式锁 -- dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.24.3/version /dependency !-- MySQL 驱动 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies这段依赖配置里mybatis-plus-boot-starter的版本我锁定了 3.5.5因为 3.5.4 之前的版本在批量插入上有性能问题。redisson-spring-boot-starter用来做分布式锁比手写 Redis 的 setnx 要可靠得多它内部处理了锁续期和可重入。MySQL 驱动用的是mysql-connector-j这是 MySQL 8 之后官方推荐的驱动坐标老的mysql-connector-java已经停止维护了。2.3 数据库表设计五张核心表撑起整个系统我把表分成五张核心表和若干辅助表。核心表是user用户、product商品、product_sku商品规格、order订单、order_item订单明细。辅助表包括category分类、cart购物车、address收货地址、coupon优惠券。商品和 SKU 的关系是一对多。比如“乐事薯片”是 product 表里的一条记录它的原味小包、原味中包、番茄味小包就是 product_sku 表里的三条记录。每个 SKU 有自己的价格、库存和规格描述。订单和订单明细也是一对多订单明细里要冗余商品名称和下单时的价格因为商品价格可能会变但订单里的价格必须锁定。-- 商品规格表核心字段说明 CREATE TABLE product_sku ( id bigint NOT NULL AUTO_INCREMENT, product_id bigint NOT NULL COMMENT 关联商品ID, sku_name varchar(128) NOT NULL COMMENT 规格名称如原味-小包, price decimal(10,2) NOT NULL COMMENT 售价, stock int NOT NULL DEFAULT 0 COMMENT 库存数量, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_product_id (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品规格表;version字段是乐观锁的关键。当两个请求同时扣减同一个 SKU 的库存时第一个请求更新成功 version 变成 1第二个请求发现 version 还是 0更新条件不满足就会失败重试。这样能避免超卖但要注意重试次数不能太多否则用户体验会变差。我一般设置重试三次三次还失败就直接返回“库存不足”。3. 核心模块实现从商品展示到订单落库的完整链路3.1 商品列表和详情页的缓存策略零食电商的商品列表页访问频率极高尤其是首页和分类页。如果每次都查数据库MySQL 的压力会很大。我的做法是用 Redis 做两级缓存第一级是商品列表的 ID 集合第二级是单个商品的详情 JSON。具体流程是用户访问分类页时先从 Redis 查category:products:{categoryId}这个 key如果存在就直接拿到商品 ID 列表再批量从 Redis 的 Hash 结构里取商品详情。如果缓存没命中就查数据库然后把结果写回 Redis设置过期时间 10 分钟。商品详情页的缓存时间可以长一些30 分钟因为详情页的数据变化频率低。Service public class ProductCacheService { Autowired private RedisTemplateString, Object redisTemplate; Autowired private ProductMapper productMapper; private static final String CATEGORY_KEY category:products:; private static final String PRODUCT_KEY product:detail:; private static final long CATEGORY_TTL 10; // 分钟 private static final long PRODUCT_TTL 30; // 分钟 public ListProductVO getProductsByCategory(Long categoryId) { String categoryKey CATEGORY_KEY categoryId; // 先查缓存中的商品ID列表 ListLong productIds (ListLong) redisTemplate.opsForValue().get(categoryKey); if (productIds null) { // 缓存未命中查数据库 productIds productMapper.selectIdsByCategoryId(categoryId); redisTemplate.opsForValue().set(categoryKey, productIds, CATEGORY_TTL, TimeUnit.MINUTES); } // 批量获取商品详情 ListProductVO result new ArrayList(); for (Long id : productIds) { String productKey PRODUCT_KEY id; ProductVO vo (ProductVO) redisTemplate.opsForValue().get(productKey); if (vo null) { vo productMapper.selectDetailById(id); redisTemplate.opsForValue().set(productKey, vo, PRODUCT_TTL, TimeUnit.MINUTES); } result.add(vo); } return result; } }这段代码里有个细节我没有用redisTemplate.opsForValue().multiGet()来批量获取商品详情而是用了循环。原因是 multiGet 在 Redis 集群模式下如果 key 不在同一个 slot 会报错循环虽然多几次网络往返但兼容性更好。如果你的 Redis 是单机或哨兵模式可以用 multiGet 优化。另外缓存过期时间我设了 10 分钟和 30 分钟这是根据零食商品的更新频率定的。如果是促销期间我会把 TTL 调短到 1 分钟避免用户看到过期的价格。3.2 购物车设计Redis Hash 比数据库表更合适购物车的数据特点是读写频繁、临时性强、不需要长期保存。用 MySQL 存购物车每次增删改查都要走磁盘 IO性能瓶颈很明显。我用 Redis 的 Hash 结构来存购物车key 是cart:{userId}field 是skuIdvalue 是数量。加入购物车的逻辑是先检查 SKU 是否存在且库存充足然后用HINCRBY原子性地增加数量。如果用户修改数量直接用HSET覆盖。删除商品用HDEL。获取购物车列表时用HGETALL拿到所有 SKU 和数量再批量查商品信息组装返回。Service public class CartService { Autowired private RedisTemplateString, Object redisTemplate; Autowired private ProductSkuMapper skuMapper; private static final String CART_KEY cart:; public void addToCart(Long userId, Long skuId, Integer quantity) { // 校验 SKU 是否存在 ProductSku sku skuMapper.selectById(skuId); if (sku null) { throw new BusinessException(商品规格不存在); } // 校验库存 String cartKey CART_KEY userId; Object currentQty redisTemplate.opsForHash().get(cartKey, skuId.toString()); int totalQty (currentQty null ? 0 : Integer.parseInt(currentQty.toString())) quantity; if (totalQty sku.getStock()) { throw new BusinessException(库存不足当前库存 sku.getStock()); } // 原子性增加数量 redisTemplate.opsForHash().increment(cartKey, skuId.toString(), quantity); } public MapLong, Integer getCart(Long userId) { String cartKey CART_KEY userId; MapObject, Object entries redisTemplate.opsForHash().entries(cartKey); MapLong, Integer result new LinkedHashMap(); for (Map.EntryObject, Object entry : entries.entrySet()) { result.put(Long.parseLong(entry.getKey().toString()), Integer.parseInt(entry.getValue().toString())); } return result; } }这里有个坑要注意Redis 的 Hash 结构在 field 数量超过 512 时内部编码会从 ziplist 转成 hashtable内存占用会上升。购物车一般不会超过这个数所以问题不大。但如果你要做“猜你喜欢”这种推荐列表就不要用 Hash 了用 List 或 ZSet 更合适。另外购物车数据我没有设过期时间因为用户可能今天加购明天再买。但我会在用户下单后清空对应的购物车项避免重复下单。3.3 订单创建分布式锁 乐观锁双保险防超卖订单创建是整个系统最复杂的环节涉及库存扣减、优惠计算、订单落库、购物车清理四个步骤。这四个步骤必须保证原子性否则会出现“钱扣了库存没减”或者“订单生成了购物车没清”的问题。我的方案是用 Redisson 的分布式锁锁住 SKU 维度然后在事务里完成库存扣减和订单落库。具体流程是用户点击“提交订单”后后端先根据购物车里的 SKU 列表按 SKU ID 排序避免死锁然后依次获取每个 SKU 的分布式锁。拿到锁之后开启数据库事务用乐观锁扣减库存扣减成功则创建订单和订单明细最后删除购物车对应的项。如果任何一步失败事务回滚锁释放。Service public class OrderService { Autowired private RedissonClient redissonClient; Autowired private ProductSkuMapper skuMapper; Autowired private OrderMapper orderMapper; Autowired private OrderItemMapper orderItemMapper; Autowired private CartService cartService; Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long userId, ListOrderItemDTO items) { // 按 SKU ID 排序避免死锁 items.sort(Comparator.comparing(OrderItemDTO::getSkuId)); ListRLock locks new ArrayList(); try { // 依次获取分布式锁 for (OrderItemDTO item : items) { RLock lock redissonClient.getLock(lock:sku: item.getSkuId()); lock.lock(10, TimeUnit.SECONDS); locks.add(lock); } // 扣减库存 for (OrderItemDTO item : items) { int affected skuMapper.deductStock(item.getSkuId(), item.getQuantity()); if (affected 0) { throw new BusinessException(库存不足SKU item.getSkuId()); } } // 创建订单 Order order new Order(); order.setUserId(userId); order.setOrderNo(generateOrderNo()); order.setStatus(OrderStatus.PENDING_PAYMENT); orderMapper.insert(order); // 创建订单明细 for (OrderItemDTO item : items) { OrderItem orderItem new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setSkuId(item.getSkuId()); orderItem.setQuantity(item.getQuantity()); orderItemMapper.insert(orderItem); } // 清空购物车 cartService.clearCart(userId); return buildOrderVO(order); } finally { // 释放锁注意顺序后获取的先释放 for (int i locks.size() - 1; i 0; i--) { if (locks.get(i).isHeldByCurrentThread()) { locks.get(i).unlock(); } } } } }deductStock方法的 SQL 是UPDATE product_sku SET stock stock - #{quantity}, version version 1 WHERE id #{skuId} AND stock #{quantity}。这里同时用了数据库层面的stock quantity条件判断和 version 乐观锁双保险。分布式锁的过期时间设了 10 秒因为订单创建一般不会超过这个时间。如果业务复杂导致锁提前释放Redisson 的看门狗机制会自动续期但前提是你没有显式指定 leaseTime。我这里指定了 10 秒看门狗就不会生效所以 10 秒必须够用。注意分布式锁的 key 一定要按 SKU 维度来不要锁整个订单。锁粒度太粗会导致并发性能急剧下降。4. 避坑与排查这五个问题我替你踩过了4.1 现象订单创建成功但库存没扣或者库存扣了订单没生成原因事务和分布式锁的边界没对齐。我一开始把Transactional加在 Controller 方法上锁加在 Service 方法里结果事务提交在锁释放之后导致锁释放了但事务还没提交其他请求拿到锁后读到的还是旧库存。解决把Transactional和锁都放在同一个 Service 方法里确保锁的释放发生在事务提交之后。Spring 的Transactional默认在方法返回时提交事务而finally块在方法返回前执行所以锁的释放时机是对的。但如果你用了TransactionSynchronizationManager做异步提交就要额外注意。4.2 现象Redis 缓存和数据库数据不一致用户看到过期价格原因更新商品时只更新了数据库没有删除缓存。或者先删缓存再更新数据库在并发场景下会出现脏读。解决我采用的是“先更新数据库再删除缓存”的策略并且给缓存设置较短的过期时间作为兜底。对于价格这种敏感字段我会在更新数据库后通过消息队列发一条延迟消息500 毫秒后再删一次缓存确保并发场景下的最终一致性。4.3 现象Thymeleaf 页面渲染时静态资源 404原因Spring Boot 默认的静态资源路径是classpath:/static/但我把 CSS 和 JS 放在了classpath:/templates/static/下面导致 Thymeleaf 找不到。解决要么把静态资源移到src/main/resources/static/目录下要么在application.yml里配置spring.web.resources.static-locations指定自定义路径。我选择了前者因为这是 Spring Boot 的约定不容易出错。4.4 现象MySQL 连接数暴涨应用报 “Too many connections”原因MyBatis-Plus 的默认连接池是 HikariCP最大连接数默认是 10。但在压测时10 个连接不够用请求排队导致连接超时。解决在application.yml里调整 HikariCP 的参数。maximum-pool-size调到 20minimum-idle调到 5connection-timeout设为 30000 毫秒。同时检查代码里有没有忘记关闭的 SqlSessionMyBatis-Plus 一般会自动管理但如果你手动开了 SqlSession 就要注意。4.5 现象订单号重复插入时报唯一索引冲突原因我用的是时间戳加随机数生成订单号在高并发下随机数碰撞了。解决换成雪花算法Snowflake生成分布式 ID。MyBatis-Plus 内置了IdWorker类直接调用IdWorker.getId()就能拿到一个 Long 型的唯一 ID。如果要用字符串订单号可以在雪花 ID 前面加个日期前缀比如202405201234567890。5. 部署与验证用 Docker Compose 一键拉起整套环境5.1 编写 Docker Compose 文件本地开发时我习惯用 Docker Compose 把 MySQL 和 Redis 跑起来避免在宿主机上装一堆软件。下面是我用的docker-compose.ymlMySQL 和 Redis 的版本都锁定了避免自动升级导致兼容性问题。version: 3.8 services: mysql: image: mysql:8.0.36 container_name: snack-mysql environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: snack_mall TZ: Asia/Shanghai ports: - 3306:3306 volumes: - ./mysql-data:/var/lib/mysql - ./init.sql:/docker-entrypoint-initdb.d/init.sql command: --character-set-serverutf8mb4 --collation-serverutf8mb4_unicode_ci redis: image: redis:7.2.4 container_name: snack-redis ports: - 6379:6379 volumes: - ./redis-data:/data command: redis-server --appendonly yes --requirepass redis123456MySQL 的init.sql放在当前目录下容器启动时会自动执行建表和插入测试数据。Redis 开了 AOF 持久化密码设了redis123456生产环境一定要改掉。TZ环境变量设成Asia/Shanghai避免时间字段出现时区偏差。5.2 应用配置与启动Spring Boot 的application.yml里数据库和 Redis 的连接信息要跟 Docker Compose 里的一致。我把敏感信息抽到了环境变量里本地开发用默认值生产环境通过环境变量覆盖。spring: datasource: url: jdbc:mysql://localhost:3306/snack_mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${MYSQL_PASSWORD:root123456} driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 data: redis: host: localhost port: 6379 password: ${REDIS_PASSWORD:redis123456} lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2启动顺序是先docker-compose up -d拉起 MySQL 和 Redis等 MySQL 初始化完成大概 10 秒再启动 Spring Boot 应用。启动命令是mvn spring-boot:run或者打包成 Jar 后java -jar snack-mall.jar。启动成功后访问http://localhost:8080应该能看到首页的商品列表。5.3 验证核心链路是否跑通验证分三步。第一步注册一个用户登录后能看到商品列表。第二步选一个商品加入购物车修改数量确认购物车总价正确。第三步提交订单检查数据库里order表和order_item表有没有新增记录product_sku表的stock字段有没有减少Redis 里的购物车 key 有没有被删除。我一般还会用 JMeter 做一次简单的并发测试模拟 50 个用户同时抢购同一个 SKU看库存会不会变成负数。如果库存扣减正确订单数量等于库存扣减数量说明分布式锁和乐观锁生效了。如果出现超卖就要检查锁的粒度和事务边界。提示并发测试时把日志级别调到 DEBUG观察锁的获取和释放顺序能快速定位死锁问题。这套环境跑通之后你可以把 Docker Compose 文件里的密码改成强密码然后把应用打包成 Docker 镜像用同一个 Compose 文件编排三个服务一键部署到服务器上。我自己的习惯是本地用 Compose 开发生产环境用 Kubernetes 或者直接 Docker 跑但核心配置是一样的。希望帮到你。本文还有配套的精品资源点击获取
