简介:这份资源是面向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.0,InnoDB 引擎支持事务和行级锁,这是库存扣减的基础。缓存层用 Redis 做商品详情和购物车的存储,同时用 Redisson 实现分布式锁。
前端我没有用 Vue 或 React 做前后端分离,而是选了 Thymeleaf 做服务端渲染。原因很简单:这个项目的核心是展示 Java 后端能力,前后端分离会引入跨域、Token 管理、前端工程化等额外复杂度,对于课程设计或中小型项目来说,Thymeleaf 足够用,而且部署简单,一个 Jar 包就能跑起来。
<!-- pom.xml 核心依赖 --> <dependencies> <!-- Web 框架 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 模板引擎 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- ORM 框架 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <!-- Redis 客户端 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 分布式锁 --> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.24.3</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 RedisTemplate<String, 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 List<ProductVO> getProductsByCategory(Long categoryId) { String categoryKey = CATEGORY_KEY + categoryId; // 先查缓存中的商品ID列表 List<Long> productIds = (List<Long>) redisTemplate.opsForValue().get(categoryKey); if (productIds == null) { // 缓存未命中,查数据库 productIds = productMapper.selectIdsByCategoryId(categoryId); redisTemplate.opsForValue().set(categoryKey, productIds, CATEGORY_TTL, TimeUnit.MINUTES); } // 批量获取商品详情 List<ProductVO> 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 是skuId,value 是数量。
加入购物车的逻辑是:先检查 SKU 是否存在且库存充足,然后用HINCRBY原子性地增加数量。如果用户修改数量,直接用HSET覆盖。删除商品用HDEL。获取购物车列表时,用HGETALL拿到所有 SKU 和数量,再批量查商品信息组装返回。
@Service public class CartService { @Autowired private RedisTemplate<String, 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 Map<Long, Integer> getCart(Long userId) { String cartKey = CART_KEY + userId; Map<Object, Object> entries = redisTemplate.opsForHash().entries(cartKey); Map<Long, Integer> result = new LinkedHashMap<>(); for (Map.Entry<Object, 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, List<OrderItemDTO> items) { // 按 SKU ID 排序,避免死锁 items.sort(Comparator.comparing(OrderItemDTO::getSkuId)); List<RLock> 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调到 20,minimum-idle调到 5,connection-timeout设为 30000 毫秒。同时检查代码里有没有忘记关闭的 SqlSession,MyBatis-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.yml,MySQL 和 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-server=utf8mb4 --collation-server=utf8mb4_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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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 跑,但核心配置是一样的。希望帮到你。
本文还有配套的精品资源,点击获取