简介:一套基于Java 17与Spring Cloud微服务架构的尚品甄选电商平台全栈开发项目,面向具备一定Java基础的全栈或后端开发者。项目将电商核心业务拆分为多个微服务模块,覆盖后台用户管理、商品管理、订单处理及前台购物流程,并集成Redis缓存、MinIO对象存储和Docker容器化部署,有助于理解现代电商系统的架构设计与技术选型。压缩包共505个文件,大小约3.06MB,以185个Java源码、99个前端脚本、52个页面组件、17个服务配置及数据库脚本等为主,涵盖前后端代码与部署配置,目录结构清晰。目前已有82人学习下载。资源附赠开发文档,并包含容器化部署脚本、数据库脚本、工具类等可复用内容,便于对照项目梳理微服务拆分、缓存策略、文件存储及容器化流程,也是一份适合毕业设计或项目实战参考的完整素材。
1. 尚品甄选这套微服务电商项目,先回答它解决了什么问题
这套拿 Java 17 与 Spring Cloud 微服务架构搭出来的尚品甄选电商项目,第一眼吸引我的是它的完整度:前台用户端、后台管理端、商品与订单两条主线业务全都落地,不是那种只有登录注册的教学工程。中间还串了 Redis 做缓存、MinIO 做图片与文件存储、Docker 做容器化部署,几乎把一套小规模电商后端该有的基础设施都装进去了。适合两类人:一类是想在简历上放一个真实微服务作品的 Java 从业者,照着源码能看懂服务怎么拆、缓存怎么用;另一类是公司内部做电商后台、商品系统的人,可以直接借鉴它的权限模型和订单流程。下文按 服务拆分 → Redis 落地 → MinIO 接入 → Docker 部署 的路径拆,最后补一个订单状态机与缓存一致性的实操技巧。
2. 微服务骨架先行:Java17 + SpringCloud 的服务划分与端口规划
2.1 服务拆几个合理:按业务域切分,而不是按页面切分
电商后台做过一段时间的人,大概率见过那种“按页面拆服务”的工程:商品页一个模块、订单页一个模块、后台管理再一个模块,Controller 堆在一起,页面倒是齐了,一到大促想单独扩容商品服务,只能整体竖着切,牵一发动全身。尚品甄选这套是按业务域竖切,核心拆成用户、商品、订单三条线,再加一个网关做统一入口,这个切法可以原样搬到自己项目里。
| 模块 | 端口 | 职责 |
|---|---|---|
| cloud-gateway | 8080 | 统一入口、路由转发、跨域处理 |
| user-service | 8101 | 前台用户与后台管理员登录、JWT 鉴权、角色权限 |
| product-service | 8102 | 商品分类、商品 SPU/SKU、图片关联、库存查询 |
| order-service | 8103 | 购物车、下单、订单状态流转、库存联动 |
| common | 无端口 | 公共 DTO、统一返回体、异常处理器、工具类 |
前后台两端共用 user-service 和 product-service,权限靠角色区分,而不是把前台和后台拆成两套独立系统,这是中小规模电商比较省事的做法。端口规划上,网关固定 8080,业务服务从 8101 开始顺序排,Nacos 注册中心 8848,Redis 6379,MinIO 9000/9001,MySQL 3306。把这些固定下来,后面写 Docker Compose 和配置微服务之间的调用时,心智负担小很多。注意 9001 是 MinIO 的 Web 控制台端口,9000 才是 API 端口,很多人第一次部署会把这两个搞混。
2.2 父工程 POM 与依赖版本对齐:JDK17 带来的第一个硬门槛
JDK 17 是 LTS 版本,Spring Boot 3.x 也强制要求 17 起步,所以这个项目的环境门槛不是可选项。真正动手前,先把 JDK 下载安装这步走干净:装完把 JAVA_HOME 指到安装目录,命令行里java -version必须是 17,mvn -v里显示的 Java 版本也得是 17,否则 IDE 里可能还在用老版本编译,跑起来全是「Unsupported class file major version」这类报错。
父工程 POM 的依赖版本对齐是关键,直接决定 Nacos 能不能注册上、OpenFeign 能不能调通。我按 Spring Boot 3.2.x + Spring Cloud 2023.0.x + Spring Cloud Alibaba 2023.0.x 这套组合来对齐,可以少踩很多版本地狱的坑。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.4</version> <relativePath/> </parent> <properties> <java.version>17</java.version> <spring-cloud.version>2023.0.1</spring-cloud.version> <spring-cloud-alibaba.version>2023.0.1.0</spring-cloud-alibaba.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-dependencies</artifactId> <version>${spring-cloud-alibaba.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>这里import作用域的意思是只引入依赖管理,不在父工程直接引入具体依赖,子模块需要什么自己声明,这样每个服务可以控制自己的依赖面。Spring Cloud Alibaba 和 Spring Cloud 是配套发行的,版本不能凭感觉乱拼,错一档就会出现 Nacos 客户端连不上、心跳报错这类玄学问题,查配置半天最后发现是版本不对。JDK 17 的语法红利在代码里用得很自然,比如record定义 DTO、switch表达式做状态判断,这些都是老 JDK 写起来很啰嗦的地方,源码里可以直接照着学。
2.3 网关、Nacos 与 OpenFeign 的配置模型
网关用 Spring Cloud Gateway,路由规则写在配置文件里,比在代码里硬编码路由好维护。下面是这套项目网关路由的典型写法,注意路由顺序和lb://前缀。
spring: application: name: cloud-gateway cloud: nacos: server-addr: ${NACOS_ADDR:localhost:8848} gateway: routes: - id: user-route uri: lb://user-service predicates: - Path=/api/user/**,/api/admin/** - id: product-route uri: lb://product-service predicates: - Path=/api/product/** - id: order-route uri: lb://order-service predicates: - Path=/api/order/**,/api/cart/**uri用lb://开头,网关会从 Nacos 拉取对应服务的实例列表做负载均衡,前端只认 8080 一个入口,不用关心后面有几个服务实例。Path谓词的匹配是有顺序的,越具体的路径放越前面,如果冲突,先命中的路由生效,所以/api/user/**和/api/admin/**放在同一个路由的 predicates 里可以合并处理。${NACOS_ADDR:localhost:8848}是环境变量占位符,本地默认 localhost,到 Docker 部署时注入容器服务名,同一份配置不用改代码。
服务之间的调用用 OpenFeign,接口声明式调用比 RestTemplate 手拼 URL 清晰得多,尤其适合商品服务与订单服务之间的库存查询。
@FeignClient(name = "product-service") public interface ProductClient { @GetMapping("/sku/{skuId}") SkuDTO getSku(@PathVariable("skuId") Long skuId); }@FeignClient的name必须和注册到 Nacos 的服务名一致,否则运行时找不到服务。调用超时不要用默认值,生产上我一般把连接超时设 3 秒、读取超时设 10 秒,在对应服务的配置里调ribbon或者 Spring Cloud OpenFeign 的connectTimeout、readTimeout参数,避免一个慢接口把调用线程拖死。公共的 DTO 放在 common 模块里,两个服务都依赖 common,这样 Feign 接口的返回类型不会因为包路径不一致导致序列化出错。
3. Redis 缓存层落地:商品详情缓存与分布式锁的取舍
3.1 缓存粒度与 Key 设计:为什么不整对象缓存
商品详情是电商系统里读请求最密集的接口之一,也是最容易把缓存写坏的地方。一个商品详情页面通常是聚合视图:基础信息、SKU 列表、图片、富文本参数。如果偷懒把整个聚合结果塞进一个 key,任何一个子模块更新都要重写这个大 key,而且并发高的时候,短时间内大量请求穿透到数据库,缓存击穿的概率会明显上升。
这套项目里用的是拆粒度缓存方案,按数据更新频率拆成三个层级。
| 缓存 Key | 内容 | 过期时间 |
|---|---|---|
product:base:{id} | 商品标题、上下架状态、品牌 | 30 分钟 + 随机 0~5 分钟 |
product:sku:{skuId} | SKU 价格、库存、规格属性 | 10 分钟 |
product:images:{id} | 图片 ID 列表 | 1 小时 |
TTL 错开是防止缓存雪崩的常见做法,如果所有 key 同时过期,过期瞬间所有请求同时打到数据库,数据库容易被压垮。过期时间加随机数,本质是让失效时间在一个区间内分散开。还有一个值得照抄的细节:查数据库没查到商品时,不要直接返回,往缓存里写一个空值并设置 3 到 5 分钟的短过期时间,这样恶意刷不存在的商品 ID 时,请求会打在缓存上而不是反复打数据库,这招叫缓存空值防穿透。
String key = "product:base:" + id; Object cached = redisTemplate.opsForValue().get(key); if (cached != null) { return (ProductBaseVO) cached; } ProductBaseDO product = productMapper.selectById(id); if (product == null) { redisTemplate.opsForValue().set(key, "", 3, TimeUnit.MINUTES); return null; } ProductBaseVO vo = convert(product); redisTemplate.opsForValue().set(key, vo, 30 + RandomUtils.nextInt(5), TimeUnit.MINUTES); return vo;注意这里的类型转换隐患:从 Redis 读出来的对象是Object,直接强转前必须保证序列化器存进去的就是这个类型。空值回写时会遇到 String 与对象混存的问题,所以我会在取值后判断是不是空字符串再返回,否则会把""强转成 VO 直接报类型转换异常。
3.2 序列化策略与 RedisConfig:调试友好是第一诉求
Redis 的序列化器选择,直接影响你排查问题的效率。默认的JdkSerializationRedisSerializer存进去的是二进制,在 Redis Desktop Manager 里看到的全是\xAC\xED开头的乱码,想确认某个 key 到底存了什么,还得在代码里 debug。换成GenericJackson2JsonRedisSerializer之后,value 是 JSON 文本,肉眼可读,跨服务共用也方便。
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(RedisSerializer.string()); template.setHashKeySerializer(RedisSerializer.string()); template.setValueSerializer(serializer); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } }key 用 String 序列化,是为了在 Redis Desktop Manager 里能直接看到product:base:123这种可读的 key;value 用 JSON 是为了跨服务通用。要注意GenericJackson2JsonRedisSerializer会在 JSON 里写入@class类型信息,反序列化时靠它还原具体类型。代价是,如果实体类改了包名或字段结构,老缓存反序列化会直接报错,所以实体结构变更时,要么主动清掉相关缓存,要么先把 TTL 调短,等旧缓存自然过期。
3.3 秒杀扣库存:Redisson 分布式锁的看门狗与超时
秒杀扣库存是分布式锁最典型的落地场景。第一反应用SETNX加EXPIRE两条命令实现的,大多有锁超时设置不合理、忘记释放、误删别人锁的问题。尚品甄选这套用的是 Redisson,它对锁续期和释放做了封装,用起来比裸 Redis 命令可靠。
RLock lock = redissonClient.getLock("lock:seckill:" + skuId); boolean locked = lock.tryLock(1, 10, TimeUnit.SECONDS); if (!locked) { throw new BusinessException("当前抢购人数太多,请稍后再试"); } try { int rows = stockMapper.decrementStock(skuId); if (rows == 0) { throw new BusinessException("库存不足"); } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }tryLock(1, 10, TimeUnit.SECONDS)第一个参数是获取锁的等待时间,拿不到锁就快速失败返回,避免大量线程阻塞在抢锁上;第二个参数是锁的持有时间,到了自动释放,防止业务异常导致死锁。但这里有个参数倾向:如果业务执行时间有可能超过锁持有时间,不要手工指定持有时间,让 Redisson 走默认的 30 秒看门狗逻辑,它会自动续期,业务执行完再释放。isHeldByCurrentThread()判断是必须的,防止锁已经过期被别的线程拿到,当前线程释放时把别人的锁误删。热点商品的秒杀,还可以给 key 加分区后缀,把压力分散到多个 key 上,但那是另一个复杂度话题,新手先把单 key 锁跑通再去优化。
4. MinIO 对象存储接入:上传、预签名 URL 与反代访问
4.1 桶规划与权限模型:private 桶是底线
MinIO 接入前先把权限模型想清楚,否则后期改权限要连带改一堆代码。常见的翻车是把桶设成public,图片 URL 直接拼在 HTML 里,访问倒是方便,但固定链接会被盗链、被刷流量,而且桶里的文件一旦公开,删除或替换都会立刻生效到线上。
尚品甄选里我建议按用途拆分桶,而不是一个桶装所有文件。
| 桶名 | 用途 | 访问方式 |
|---|---|---|
product-images | 商品主图、SKU 图、详情图 | private + 预签名 URL |
product-files | 商品导入模板、批量素材包 | private + 预签名 URL |
两个桶都是 private,对外回显用预签名 URL,这样每个文件的访问链接带签名参数,过期后自动失效,下载大批量文件时也不会因为链接缓存被反复刷。MinIO 的默认管理员账号是minioadmin/minioadmin,容器部署时一定要用环境变量覆盖,这个在第 5 章的坑位里会细说。
4.2 上传接口实现与 SDK 参数坑
MinIO 的 Java SDK 用法比较固定,核心就三步:构建客户端、检查桶、传对象。下面是本地开发环境的上传接口实现,注意 endpoint 的写法。
MinioClient client = MinioClient.builder() .endpoint("http://localhost:9000") .credentials("minioadmin", "your-password") .build(); if (!client.bucketExists(BucketExistsArgs.builder().bucket("product-images").build())) { client.makeBucket(MakeBucketArgs.builder().bucket("product-images").build()); } String objectName = "2025/06/" + UUID.randomUUID() + ".jpg"; client.putObject(PutObjectArgs.builder() .bucket("product-images") .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build());endpoint这里要注意环境差异:本地调试用localhost:9000,但在 Docker Compose 网络里,应用容器访问 MinIO 要用服务名http://minio:9000,因为容器之间走的是内部网络,localhost指向的是应用容器自己。stream的第三个参数是对象大小,传入-1让 SDK 自动分片,适合不确定大小的流;如果明确知道文件大小,最好传实际值,上传性能更好。对象名按年/月/UUID组织,避免所有文件堆在一个目录里,也方便后面按时间归档。
上传之后,回显给前端的 URL 用预签名方式生成,而不是直接暴露对象名。
String presignedUrl = client.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket("product-images") .object(objectName) .expiry(3600) .build());expiry(3600)是 URL 的有效秒数,一小时过期。产品图片和商品详情的配图可以给长一点,比如 24 小时;用户头像这种访问频繁的,可以配合前端 CDN 缓存,不建议给太长有效期,否则链接泄露出去就能被人一直访问。预签名 URL 的生成不要求额外权限,但生成出来的链接是带签名参数的,泄露后对方拿到的也是限时访问权。
4.3 图片回显与 nginx 反代配置
预签名 URL 默认带的是 MinIO 的 endpoint,前端直连 9000 端口容易被防火墙拦,而且把 MinIO 的真实地址暴露给浏览器,在安全上并不好看。常见做法是让前端只走应用域名,用 nginx 把/minio/路径转发到 MinIO。
location /minio/ { proxy_pass http://minio:9000/; proxy_set_header Host $http_host; }注意proxy_pass末尾的斜杠,带斜杠表示把/minio/前缀剥掉后转发,不带斜杠则是原样拼接。处理图片访问时,前端拿到的链接就会变成http://你的域名/minio/product-images/xxx.jpg?X-Amz-...,应用层可以通过反代统一加访问日志和流量控制。下载大文件时,如果文件超过几百 MB,直接走应用服务器转发会占内存和带宽,更合理的做法是生成预签名 URL 后让浏览器 302 跳转过去,应用只负责发跳转指令,不承担文件流的转发。
5. Docker 容器化部署:compose 编排与五个高频坑排查记录
5.1 Compose 编排基础设施与微服务:healthcheck 是第一步
Docker 部署这套项目,最省事的路径是用 Docker Compose 把 MySQL、Redis、MinIO、Nacos 和业务服务编排在一起。第一次编排容易踩的坑是:微服务启动比数据库快,应用连不上数据库直接退出。所以healthcheck和depends_on必须配合使用。
services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: shangpin ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine command: redis-server --appendonly yes ports: - "6379:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 minio: image: minio/minio command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: your-password ports: - "9000:9000" - "9001:9001" volumes: - minio-data:/data user-service: build: ./user-service environment: NACOS_ADDR: nacos:8848 REDIS_HOST: redis MYSQL_HOST: mysql depends_on: mysql: condition: service_healthy redis: condition: service_healthydepends_on默认只保证启动顺序,不保证服务可用,加上condition: service_healthy才是真正等 MySQL 和 Redis 就绪后再启动业务服务。不写 healthcheck 的话,应用启动时 Redis 还没就绪,就会看到一堆连接超时报错。MySQL 数据挂到mysql-data卷里,容器重建数据不丢;MinIO 也一样,它把数据写进/data,卷必须挂,否则重启容器后上传的图片全没了。
5.2 多阶段构建与 JVM 内存参数
业务服务的 Dockerfile 用多阶段构建,构建阶段带 Maven 和 JDK,运行阶段只留 JRE 和 jar,镜像体积能明显降下来。
FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /src COPY . . RUN mvn -B -DskipTests package FROM eclipse-temurin:17-jre COPY --from=build /src/user-service/target/*.jar /app/app.jar EXPOSE 8101 ENTRYPOINT ["java", "-Xmx512m", "-Xms256m", "-jar", "/app/app.jar"]-Xmx512m设堆上限 512MB,-Xms256m设初始堆 256MB。容器内存限制 1GB 时,这个配置留了余量给 JVM 的元空间和线程栈,不容易因为容器内存超限被 OOM Kill。如果你的服务接口并发高,堆可以给到 768MB,但容器内存也得跟着调到 1.5GB 以上。JVM 在容器里的内存计算经常被忽略,-Xmx不等于容器内存,两者之间要留足系统开销。
5.3 高频坑排查:五条按现象到原因到解决整理
第一条:Docker Desktop 启动失败,提示virtualization support wasn't detected。现象是双击图标后直接报错,服务起不来。原因是 Windows 的虚拟化功能没开,或者 Hyper-V / WSL2 没启用。解决方法是进 BIOS 开启 Virtualization Technology,然后在 Windows 功能里勾选「适用于 Linux 的 Windows 子系统」和「虚拟机平台」,重启后再启动 Docker Desktop。
第二条:docker pull minio/minio拉不下来或者超时。现象是镜像下载卡在等待,反复失败。原因是默认镜像源在本地网络下不通。解决方法是给 Docker 配置 registry-mirrors,指向国内镜像站,配置后重启 Docker Desktop 再拉取。
第三条:Redis 连接超时,报io.lettuce.core.RedisCommandTimeoutException。现象是应用一启动就连 Redis 报错,过一会儿又正常。原因是容器启动顺序没控制,Redis 还没有就绪,应用连接池里的连接全部超时。解决方法是给 redis 服务加 healthcheck,业务服务的depends_on加condition: service_healthy;另外检查连接池最大连接数,不要默认无限,并发高时连接池耗尽也会有类似的超时。
第四条:MySQL 8 连接失败,报Access denied或者Public Key Retrieval is not allowed。现象是应用日志里反复出现连接异常。原因是 MySQL 8 默认认证插件是caching_sha2_password,连接 URL 没有对应参数。解决方法是 JDBC URL 加上allowPublicKeyRetrieval=true&useSSL=false,或者在容器初始化脚本里创建用户时指定mysql_native_password。
第五条:MinIO 改了MINIO_ROOT_USER和MINIO_ROOT_PASSWORD环境变量后,旧账号还是能登录。现象是改完重启容器,用新账号登不上,旧账号依然有效。原因是 MinIO 的账号密码是在数据卷首次初始化时写入的,卷已经存在,环境变量不会再生效。解决方法是先docker compose down -v清掉旧数据卷再重建容器,或者进入容器用mc客户端修改账号信息。这个坑最容易在改完配置重启后发现没变化,白改半天。
6. 订单状态机与缓存一致性:一个能救命的双写校验技巧
6.1 订单状态机的幂等设计
订单模块是整个电商系统里最容易出线上问题的部分,核心不在于 CRUD 写得多花哨,而在于状态流转是否被严格约束。这套源码里订单状态用枚举收敛,配合数据库版本号做乐观锁,能挡住大部分重复请求。
public enum OrderStatus { CREATED(0), PAID(1), SHIPPED(2), FINISHED(3), CANCELLED(4); private final int code; OrderStatus(int code) { this.code = code; } public boolean canTransitTo(OrderStatus target) { return switch (this) { case CREATED -> target == PAID || target == CANCELLED; case PAID -> target == SHIPPED || target == CANCELLED; case SHIPPED -> target == FINISHED; case FINISHED, CANCELLED -> false; }; } }状态机的好处是,非法流转在业务代码入口就被拦住,而不是等到数据库里出现脏数据才暴露。支付回调这类外部通知天然会重试,所以更新订单状态时不能先查再改,要用条件更新做 CAS:UPDATE orders SET status = ?, version = version + 1 WHERE id = ? AND status = ? AND version = ?,更新行数为 0 说明状态已变,直接幂等返回,不重复执行后续发货逻辑。
6.2 缓存与数据库的双写校验脚本
订单状态更新后,一般会同步写 Redis 缓存。如果缓存删除失败,DB 和 Redis 就分叉了:数据库已经是 PAID,缓存还是 CREATED,用户刷新订单详情会看到旧状态。为了能快速发现这种不一致,我习惯在每次涉及订单状态变更的上线窗口,跑一个双写校验脚本。
# 1. 导出数据库订单状态分布 mysql -h$DB_HOST -u$DB_USER -p$DB_PASS -e \ "SELECT status, COUNT(*) FROM orders GROUP BY status;" > /tmp/db_status.txt # 2. 扫描 Redis 中订单缓存 key,按状态分组统计 redis-cli --scan --pattern "order:info:*" | head -n 5000 | while read key; do redis-cli hget "$key" status >> /tmp/redis_status.txt done sort /tmp/redis_status.txt | uniq -c这个脚本的思路是:先把数据库的状态分布导出,再抽样扫描 Redis 里的订单缓存,对比两者状态的数量分布是否大致吻合。如果 Redis 里大量出现数据库里不存在的状态值,说明有缓存没被正确更新。真正生产上我会把它做成定时任务,发现不一致就删除对应缓存 key,让下一次读请求回源数据库重新加载,而不是手工在线上改数据。
从那以后,每次上线涉及订单状态变更,我都强制跑一遍这个扫描,确认缓存里没有旧状态的残留再放量。这套尚品甄选源码里的订单模块,本身已经把状态机收敛得不错,你自己改业务时,记得把这条校验链路也带上。希望帮到你。
本文还有配套的精品资源,点击获取