简介:这份资源是面向Java/Web初学者与电商项目练手者的购物商城系统源码包,围绕用户注册登录、商品浏览搜索、购物车管理与支付结算等核心链路展开,适合作为课程设计、毕业设计或全栈入门实战的参考实现。压缩包为zip格式,整体约3.29MB,内含购物商城相关源码、数据库脚本与配置文件等,可用于本地搭建与部署整套电商流程。目前已有219人学习下载,说明其在同类练手项目中具备一定参考价值。读者可从中了解用户账户体系、购物车增删改查、订单确认与支付验证等模块的组织方式,并借鉴前端界面与后端接口的分层结构,快速搭建可运行的购物环境,对照排查登录校验、库存与支付流程中的常见问题,为后续二次开发与功能扩展提供基础。
1. 购物商城从零到一:为什么“能跑通”和“能扛住”是两码事
很多开发者第一次接触购物商城项目,脑子里想的都是“先把商品列表和购物车做出来再说”。我当初也是这么想的,结果上线第一天就翻车了——商品详情页的库存数字在并发下单时直接变成负数,订单和库存对不上,用户投诉像雪片一样飞过来。购物商城这四个字,看起来只是“展示商品、加购物车、下单支付”三步走,但真正落地时,它考验的是你对并发、事务、缓存一致性、幂等这些硬骨头的理解。这篇文章不跟你聊虚的,就围绕一个典型的购物商城系统,把从环境搭建、核心链路实现、参数调优到避坑排查的完整路径拆开讲。适合谁看?如果你正在做课程设计、毕业项目,或者想从零搭一个能演示、能压测、能讲清楚技术选型理由的购物商城,那这篇笔记就是写给你的。新手能照着步骤跑通最小闭环,熟手能直接跳到参数和边界条件那几章看踩坑记录。
2. 购物商城的最小可运行骨架:技术选型与本地环境搭建
2.1 为什么我选 Spring Boot + MySQL + Redis 这套组合
做购物商城,第一件事不是写代码,是定技术栈。我见过太多人在这步纠结半个月,最后选了个自己都不熟的框架,后面每一步都是坑。我的建议很直接:如果你没有特殊约束,就用 Spring Boot 做后端,MySQL 做持久化,Redis 做缓存和分布式锁。理由有三条。第一,Spring Boot 的生态最全,购物商城需要的事务管理、连接池、安全框架、消息队列集成,都有现成的 starter,不用自己造轮子。第二,MySQL 的 InnoDB 引擎支持行级锁和事务,这是处理库存扣减和订单创建的基础,换其他数据库你得额外考虑兼容性。第三,Redis 在购物商城里的角色太重要了——商品热点数据缓存、秒杀库存预扣、分布式锁防重复下单,这些场景没有 Redis 会非常难做。有人会问“能不能用 MongoDB”,可以,但购物商城的订单和库存天然是关系型数据,强一致性要求高,关系型数据库更合适。选型定下来之后,本地环境搭建就按下面这个清单走。
| 组件 | 版本建议 | 用途 | 本地启动方式 |
|---|---|---|---|
| JDK | 17 | 运行 Spring Boot | 安装包直接装 |
| MySQL | 8.0 | 订单、商品、库存持久化 | Docker 或本机安装 |
| Redis | 7.x | 缓存、分布式锁 | Docker 启动最省事 |
| Maven | 3.9+ | 依赖管理 | 命令行或 IDE 内置 |
2.2 用 Docker Compose 把 MySQL 和 Redis 拉起来
本地开发最烦的就是环境不一致,我一般用 Docker Compose 把中间件统一管起来。下面这个 compose 文件你直接抄,改一下密码就能用。
version: '3.8' services: mysql: image: mysql:8.0 container_name: mall-mysql environment: MYSQL_ROOT_PASSWORD: mall123456 MYSQL_DATABASE: mall_db ports: - "3306:3306" volumes: - ./mysql-data:/var/lib/mysql command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci redis: image: redis:7-alpine container_name: mall-redis ports: - "6379:6379" volumes: - ./redis-data:/data command: redis-server --appendonly yes --requirepass redis123456这段配置里有两个参数值得注意。--default-authentication-plugin=mysql_native_password是为了兼容老版本的客户端连接方式,避免你用 Navicat 或 DataGrip 连不上。--appendonly yes是开启 Redis 的 AOF 持久化,购物商城的库存预扣数据不能丢,虽然本地开发丢一次影响不大,但养成习惯没坏处。启动命令就一行:docker compose up -d。起来之后用docker ps确认两个容器都是 healthy 状态,再往下走。
2.3 建表:商品表、库存表、订单表的最小字段设计
购物商城的表结构不用一上来就搞几十个字段,先把核心链路跑通。我一般会建三张表:商品表、库存表、订单表。商品表存基本信息,库存表单独拆出来是为了方便加锁和扣减,订单表记录交易。
CREATE TABLE `product` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(128) NOT NULL, `price` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT '1' COMMENT '1上架 0下架', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `stock` ( `product_id` bigint NOT NULL, `total` int NOT NULL DEFAULT '0', `available` int NOT NULL DEFAULT '0', PRIMARY KEY (`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL, `product_id` bigint NOT NULL, `quantity` int NOT NULL, `amount` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已取消', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个细节:库存表的available字段和total分开,是为了支持预占库存的逻辑。下单时先扣available,支付成功再扣total,取消订单时把available加回去。这样能避免“下单未支付”导致库存被永久占用的问题。订单表的order_no加了唯一索引,这是防重复下单的最后一道防线,后面讲幂等的时候会展开。
3. 购物商城的核心链路:从商品查询到下单扣库存的完整实现
3.1 商品查询:缓存穿透和缓存击穿怎么防
商品查询是购物商城读请求最密集的地方,如果每次都查 MySQL,数据库扛不住。我一般用 Redis 做缓存,但缓存用不好会引出两个经典问题:缓存穿透和缓存击穿。缓存穿透是查一个数据库里也不存在的商品 ID,请求每次都打到数据库;缓存击穿是某个热点商品的缓存刚好过期,大量请求同时涌向数据库。解决穿透的办法是缓存空值,查不到也往 Redis 里写一个短过期的空对象。解决击穿的办法是加互斥锁,只让一个线程去查数据库并回填缓存。
public Product getProduct(Long productId) { String cacheKey = "product:" + productId; String cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { // 空值标记,防止穿透 if ("EMPTY".equals(cached)) { return null; } return JSON.parseObject(cached, Product.class); } // 互斥锁,防止击穿 String lockKey = "lock:product:" + productId; try { Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { Product product = productMapper.selectById(productId); if (product == null) { redisTemplate.opsForValue() .set(cacheKey, "EMPTY", 60, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue() .set(cacheKey, JSON.toJSONString(product), 300, TimeUnit.SECONDS); return product; } else { // 没抢到锁,短暂休眠后重试 Thread.sleep(50); return getProduct(productId); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; } finally { redisTemplate.delete(lockKey); } }这段代码里,空值缓存的过期时间设了 60 秒,正常商品缓存设了 300 秒。为什么空值要短一些?因为如果某个商品后来上架了,空值缓存太久会导致用户一直看不到。互斥锁的过期时间设 10 秒,是防止持锁线程崩溃后锁一直不释放。注意finally块里删锁的操作,严格来说应该用 Lua 脚本保证原子性,但本地开发和中小流量场景下这样写够用了,后面进阶章节会讲怎么优化。
3.2 下单扣库存:乐观锁、悲观锁和 Redis 预扣怎么选
下单扣库存是购物商城最容易出问题的地方。我见过三种做法:悲观锁、乐观锁、Redis 预扣。悲观锁就是SELECT ... FOR UPDATE,简单但并发差;乐观锁用版本号或available >= quantity做条件更新,并发好但失败率高;Redis 预扣是把库存放到 Redis 里用 Lua 脚本原子扣减,性能最好但一致性维护复杂。我的建议是:中小流量用乐观锁,秒杀场景用 Redis 预扣。下面先看乐观锁的实现。
UPDATE stock SET available = available - #{quantity} WHERE product_id = #{productId} AND available >= #{quantity}这条 SQL 的AND available >= #{quantity}就是乐观锁的核心,它保证库存不会被扣成负数。在 MyBatis 里执行后判断影响行数,如果返回 0 说明库存不足,直接抛异常回滚事务。这里有个血泪经验:很多人只判断影响行数,忘了在事务里加@Transactional,结果扣了库存但订单没创建成功,数据不一致。记住,扣库存和创建订单必须在同一个事务里。
@Transactional(rollbackFor = Exception.class) public Order createOrder(Long productId, Integer quantity) { // 1. 扣库存 int affected = stockMapper.deductStock(productId, quantity); if (affected == 0) { throw new BizException("库存不足"); } // 2. 创建订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setProductId(productId); order.setQuantity(quantity); order.setAmount(calculateAmount(productId, quantity)); order.setStatus(0); orderMapper.insert(order); return order; }generateOrderNo()我一般用“时间戳 + 用户ID后四位 + 随机数”拼,不要用 UUID,因为 UUID 做唯一索引时插入性能差。calculateAmount要从商品表重新查价格,不能信前端传过来的金额,这是安全底线。
3.3 订单号生成与幂等:防止用户连点两次下单
用户手抖连点两次下单按钮,或者网络重试导致请求重复,购物商城必须保证同一请求只创建一个订单。这就是幂等。我的做法是前端传一个requestId,后端用 Redis 做去重。第一次请求把requestId写到 Redis 并设过期时间,后续相同requestId直接返回已有订单。
public Order createOrderIdempotent(String requestId, Long productId, Integer quantity) { String idempotentKey = "idempotent:order:" + requestId; Boolean success = redisTemplate.opsForValue() .setIfAbsent(idempotentKey, "1", 5, TimeUnit.MINUTES); if (Boolean.FALSE.equals(success)) { // 重复请求,查已有订单返回 return orderMapper.selectByRequestId(requestId); } try { return createOrder(productId, quantity); } catch (Exception e) { // 失败要删掉幂等键,允许用户重试 redisTemplate.delete(idempotentKey); throw e; } }注意catch块里删幂等键的操作。如果下单失败但不删键,用户重试会被当成重复请求,永远下不了单。这个坑我踩过,排查了半天才发现是幂等键没清理。另外requestId最好由前端生成,用 UUID 就行,后端不要自己生成,否则重试时对不上。
4. 购物商城的避坑与排查:5 个让我加班到凌晨的坑
4.1 库存扣减后订单创建失败,数据对不上
现象:压测时发现库存少了但订单没多,查日志看到deductStock成功但insert order抛了异常。原因:扣库存和创建订单不在同一个事务里,或者事务传播行为配错了。解决:确保两个操作在同一个@Transactional方法内,并且异常类型要触发回滚。默认情况下 Spring 只对RuntimeException回滚,如果你抛的是Exception,必须写@Transactional(rollbackFor = Exception.class)。这个坑我踩过两次,现在写事务必加rollbackFor。
4.2 Redis 缓存和数据库数据不一致
现象:后台改了商品价格,前端刷新还是旧价格,要等几分钟才变。原因:更新数据库后没有删缓存,或者删缓存失败但没重试。解决:更新数据库后立即删缓存,并且用“先更新数据库再删缓存”的顺序。如果删缓存失败,把删缓存的操作发到消息队列里重试。更简单的做法是给缓存设一个较短的过期时间,比如 300 秒,作为兜底。别用“先删缓存再更新数据库”,并发下会读到旧数据写回缓存。
4.3 订单号重复导致插入失败
现象:高并发下偶尔报Duplicate entry for key 'uk_order_no'。原因:订单号生成算法有碰撞,比如只用时间戳,同一毫秒内多个请求生成相同订单号。解决:订单号里加用户ID和随机数,或者用 Redis 的INCR生成全局自增序列。我现在的做法是时间戳(毫秒) + Redis自增序列(4位) + 随机数(4位),基本不会碰撞。如果还是担心,数据库唯一索引是最后防线,插入失败就重试一次。
4.4 分布式锁误删别人的锁
现象:A 线程加的锁被 B 线程删了,导致并发问题。原因:A 线程业务执行时间超过了锁的过期时间,锁自动释放,B 线程拿到锁,A 执行完后删锁把 B 的锁删了。解决:锁的 value 存一个唯一标识(比如 UUID),删锁前判断是不是自己的锁。更严谨的做法是用 Lua 脚本保证“判断+删除”的原子性。这个坑在秒杀场景下特别容易出,因为业务执行时间不可控。
4.5 压测时数据库连接池被打满
现象:JMeter 压测到 500 并发时,接口大量超时,日志报Connection is not available。原因:HikariCP 默认最大连接数是 10,扛不住高并发。解决:把maximum-pool-size调到 50 左右,同时检查有没有慢 SQL 导致连接被长时间占用。另外connection-timeout不要设太大,否则请求会一直等。我一般设 3000 毫秒,超时快速失败比堆积强。调完连接池后记得看 MySQL 的max_connections,别超过数据库上限。
5. 购物商城的进阶技巧:用压测数据反推参数调优
5.1 用 JMeter 做一轮基准压测
购物商城做完之后,你得知道它能扛多少并发。我一般用 JMeter 做基准压测,线程数从 50 开始,逐步加到 500,看 TPS 和响应时间的变化。压测接口选下单接口,因为这是最重的链路。压测前把日志级别调到 WARN,避免日志 IO 影响结果。下面是一个简单的 JMeter 命令行压测示例。
jmeter -n -t order_test.jmx -l result.jtl -e -o report/-n是非 GUI 模式,-t指定测试计划,-l输出结果文件,-e -o生成 HTML 报告。跑完之后打开report/index.html看聚合报告。重点看三个指标:TPS、95% 响应时间、错误率。如果 TPS 上不去但 CPU 没跑满,多半是数据库连接池或锁竞争的问题。
5.2 根据压测结果调 HikariCP 和 Redis 参数
压测数据出来之后,调参就有依据了。下面这张表是我在某次压测后调整的参数对照,你可以参考。
| 参数 | 默认值 | 调整后 | 调整理由 |
|---|---|---|---|
| HikariCP maximum-pool-size | 10 | 50 | 500 并发下连接不够用 |
| HikariCP connection-timeout | 30000 | 3000 | 快速失败,避免请求堆积 |
| Redis timeout | 2000 | 1000 | 缓存操作要快,超时立即降级 |
| Redis lettuce pool max-active | 8 | 32 | 并发读缓存需要更多连接 |
| Tomcat max-threads | 200 | 400 | 配合连接池提升吞吐 |
调完之后再压一轮,对比 TPS 和响应时间。如果 TPS 提升明显但错误率也上去了,检查是不是数据库扛不住了,这时候要考虑加缓存或读写分离。调参不是一劳永逸的,每次业务量变化都要重新压测。
5.3 一个让我少加班的习惯:把关键链路打上埋点
最后分享一个习惯。购物商城的核心链路——商品查询、扣库存、创建订单、支付回调——每个环节我都加了埋点,记录耗时和成功失败。用 Micrometer 或简单的 AOP 都行。这样出问题时不用翻半天日志,直接看埋点数据就知道哪个环节慢了或挂了。比如有一次下单接口突然变慢,埋点显示扣库存耗时从 5 毫秒涨到 200 毫秒,一查是库存表的行锁竞争,加了个 Redis 预扣就解决了。埋点数据还能帮你做容量规划,知道什么时候该扩容。这个习惯让我少加了很多班,希望帮到你。
本文还有配套的精品资源,点击获取