☰
Spring Cloud电商项目:用户与商品微服务拆分与设计实践
2026/10/10 7:52:19 网站建设 项目流程

我在做电商类项目时最深的体会是:如果一开始没有把用户和商品这两块的边界和模型想清楚,后边订单、购物车、库存、营销这些模块无论怎么做都会觉得别扭。反过来,只要用户与商品模块的地基打得稳,Spring Cloud的注册、配置、网关、熔断这些组件更像是在给这块地基配上标准化的水电管线。这篇文章是基于我实际做过的一个模拟电商项目梳理出来的上半部分,重点讲两件事:为什么用户和商品要作为独立的微服务存在,以及这两个模块在Spring Cloud体系里落地时需要关注的设计细节。

内容会覆盖服务拆分边界的判断方法、Spring Cloud核心组件选型、用户登录鉴权(JWT)的完整设计链路、商品SPU/SKU建模与分类树设计,以及我在实际联调中踩过的几个比较典型的坑。适合正在做Spring Cloud项目、但对用户和商品模块细节还拿不准的开发者参考。下篇会接着聊订单、库存和支付那部分,这篇先把地基讲透。

1. 把用户和商品拆成独立服务,不是跟风而是边界决定的

很多初学者会问:用户模块不就登录注册加个个人信息维护吗,商品模块也不过是CRUD加个缓存,为什么一定要拆成独立的微服务?老实说,如果只是做了一个几百人访问量的小型电商demo,不拆反而省事。但一旦涉及多人协作、独立部署、水平扩容,单体应用里的用户和商品代码会逐渐变成整个团队的瓶颈。

1.1 单体应用里用户和商品为什么会先"卡脖子"

用户和商品是电商系统里访问频率最高、数据结构最稳定的两块。单体时代的问题不在于写不出这些功能,而在于它们和别的模块耦合太深。用户登录状态要被订单、购物车、营销各模块共享,商品详情要被首页、搜索、推荐各模块引用。结果就是每次改登录逻辑要回归整个系统,每次商品表加字段要协调所有团队。

更麻烦的是资源消耗。商品详情是高并发读场景,用户登录是高并发写和校验场景,两者的CPU、内存、数据库连接池占用特点完全不一样。放在同一个进程里,商品接口的流量高峰会把登录服务的线程池占满,导致用户连登都登不进去。拆开之后,流量高峰期可以单独给商品服务扩容,而用户服务维持原有副本数即可。这是微服务拆分最朴素也最有说服力的理由。

1.2 服务拆分后数据边界归属怎么定

拆分服务不只是拆代码,更重要的是拆数据。用户和商品拆开后,用户数据库和商品数据库也要跟着拆开。这里有一个常见的误区:一些人只拆了代码,数据库还是共用一个库,结果服务之间互相直连对方的表,边界等于没拆。

我建议的做法是:用户域的表(用户基本信息、登录账号、地址簿、积分明细)只归用户服务管理,商品域的表(类目、品牌、SPU、SKU、属性)只归商品服务管理。其他服务需要数据时,一律通过Feign接口去调用,而不是直连数据库。表面上多了几次RPC调用,但换来了数据归属清晰、变更影响可控。

1.3 服务间协作的三条原则

拆分之后,用户服务和商品服务之间、以及其他服务之间,协作会变多。我总结了三句话来约束协作方式:

  • 能异步的不要同步。比如下单成功后扣减库存,应该走MQ异步通知,而不是同步调用库存服务阻塞等待。
  • 能查缓存的不要查库。商品详情、用户基本信息这类读多写少的数据,先查Redis,穿透了再查数据库。
  • 能带上下文的不要回查。比如订单服务需要展示用户名时,在下单请求里把用户的基本信息带过去,或者通过Feign接口一次性查出来存入本地缓存,而不是每个订单行都反向调用用户服务查一次。

这三条本质上是减少服务之间的强依赖链,Spring Cloud只是提供了技术底座,真正的架构功底在于怎么设计边界和交互方式。

2. Spring Cloud家族怎么选型:用户与商品模块够用且不折腾的搭配

Spring Cloud生态里的组件非常多,要是每个都往项目里塞,光排错就能拖垮开发节奏。以用户和商品两个模块为主的项目,我的选型原则是只引入立竿见影的组件,暂缓锦上添花的组件。

2.1 注册中心用Nacos而不是Eureka

现在新启动的Spring Cloud项目,注册中心我基本不推荐Eureka了。原因是Eureka已经进入维护模式,而且它只有服务注册发现,没有配置管理能力。Nacos一个组件同时解决服务发现和配置中心两个问题,控制台用起来也直观,阿里系生态对中文文档的覆盖也比较友好,排查问题时省心很多。

Nacos部署上,本地开发单机模式就行,启动命令加上内存参数防止占用过高:

startup.cmd -m standalone

服务里只需要引入对应starter,并在配置里声明注册地址:

spring: cloud: nacos: discovery: server-addr: 127.0.0.1:8848

这里有个细节:不同环境之间一定要靠namespace隔离。我在联调时遇到过测试环境的服务跑到开发环境注册中心的情况,原因就是namespace没有区分开来。建议开发、测试、生产各建一个namespace,服务配置里显式指定。

2.2 网关选Spring Cloud Gateway

用户和商品模块对外暴露接口时,网关是统一入口。选Spring Cloud Gateway而不是Zuul,主要原因是它基于WebFlux,性能和资源占用表现更好,同时路由配置支持代码和配置文件两种方式。

网关在用户与商品模块里的作用有三个:路由转发、统一跨域处理、登录态放行判断。商品详情的只读接口可以放行,但涉及用户信息的接口要先校验token。网关层的校验只做"有没有登录"这个粗粒度判断,细粒度的权限判断还是放在用户服务里做,避免网关逻辑膨胀。

2.3 服务调用用OpenFeign,超时和重试必须显式配置

用户服务和商品服务之间、以及其他服务调用这两个模块时,实际项目里用得最多的还是OpenFeign。它的声明式接口写起来方便,但默认的超时时间很短,高并发场景下容易误报超时。

我一般会在配置文件里把超时时间显式拉开:

feign: client: config: default: connectTimeout: 5000 readTimeout: 10000

同时要注意,Feign默认集成的是Ribbon做负载均衡,但新版本Spring Cloud LoadBalancer已经成为默认。业务代码层面不需要改动,但依赖引入时要看清楚版本对应的默认行为,否则可能出现在老项目的复制过程中依赖冲突。

2.4 Spring Cloud与Spring Boot版本对应关系是第一个坑

版本对齐问题值得单独说。很多项目启动时报各种奇怪的错误,最后排查下来都是Spring Cloud和Spring Boot版本不匹配,比如配置项失效、自动装配失败。

以Spring Boot 2.7.x搭配Spring Cloud 2021.0.x为例,Nacos需要引入spring-cloud-starter-alibaba-nacos-discovery的2.2.x版本,而不是拿一个最新版本直接塞进去。保险做法是先查版本对应表格,再动手建项目。千万别小看这一步,它是后面所有功能正常运转的前提。

3. 用户模块落地:从数据表设计到JWT鉴权

用户模块是整个电商体系里最不能出错的一块,因为所有"你是谁"的判断都建立在用户服务的正确性上。这里我把关键设计串一遍。

3.1 用户表设计:密码绝不能明文存储

用户表我通常这样设计:

CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(64) NOT NULL COMMENT '用户名', `password_hash` varchar(128) NOT NULL COMMENT '密码哈希', `salt` varchar(32) NOT NULL COMMENT '盐值', `phone` varchar(20) DEFAULT NULL, `email` varchar(128) DEFAULT NULL, `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1正常 0禁用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`), UNIQUE KEY `uk_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

密码加密我推荐BCrypt,它是加盐的哈希算法,每次生成的结果都不一样,但校验时能正确匹配。比MD5加固定盐安全得多。用户注册时用BCrypt生成hash存入password_hash字段,登录校验时用BCrypt的checkpw方法比较。

3.2 登录注册流程里的两个关键细节

注册流程比较容易忽略的是用户名和手机号的唯一性兜底。高并发下同时注册同一个用户名,可能两个请求都通过了"不存在"校验。所以在数据库层面要加唯一索引,然后在代码里捕获DuplicateKeyException,转换成"用户名已存在"的友好提示。这比用分布式锁省事,也更可靠。

登录流程的关键在于失败次数的控制和状态判断。连续输错密码5次锁定账号15分钟,这个策略能有效防止暴力破解。用户被禁用时,登录接口返回明确提示而不是笼统的"用户名或密码错误",便于运营同学处理问题。

3.3 JWT设计:token里放什么,不放什么

用户登录成功后,服务端签发JWT返回给前端。JWT由Header、Payload、Signature三部分组成,实际操作中我对payload的字段选择很克制:

  • 放:用户id、用户名、签发时间、过期时间。
  • 不放:手机号、邮箱、密码hash、任何涉及用户隐私的字段。

原因是JWT本身是base64编码的,用工具就能解开看到了,只是签名无法伪造。私密信息一旦放进token,等于把隐私暴露给了所有能拿到token的人。

密钥管理上,JWT的签名密钥要配置在服务端,不要出现在前端代码里。我用的是HMAC-SHA256算法,密钥至少32位随机字符串,保存在Nacos配置中心或其他配置管理平台,而不是写死在代码里。

3.4 登录校验放在网关层还是用户服务层

我实际采取的是网关粗校验、用户服务细校验的分工。网关只解析token是否存在并校验签名,然后把用户id放进请求头转发给下游;用户服务在处理需要登录态的业务时,校验请求头里的用户id是否有效。这样网关不依赖用户服务也能完成大部分拦截,用户服务也无需对所有接口都做一遍token解析,性能和解耦兼顾。

JWT工具类的核心逻辑大概是这样的:

public String generateToken(Long userId, String username, long expireMillis) { long now = System.currentTimeMillis(); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .setIssuedAt(new Date(now)) .setExpiration(new Date(now + expireMillis)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }

网关过滤器的核心逻辑则是校验Authorization头,解析失败直接返回401,成功则把userId放入请求头再转发。

4. 商品模块落地:SPU/SKU建模与分类树设计

商品模块比用户模块复杂的地方在于数据结构。一张商品表打天下的做法在电商系统里走不通,因为同一个商品可能有多种规格,比如一件衣服有不同颜色和尺码,它们的库存、价格都可能不一样。

4.1 SPU与SKU:电商商品模型的基础概念

先明确SPU和SKU的区别:

  • SPU(Standard Product Unit)是商品聚合,比如"某品牌连帽卫衣",它描述的是这个商品的公共属性,如品牌、标题、主图、详情介绍。
  • SKU(Stock Keeping Unit)是具体售卖单元,比如"某品牌连帽卫衣 黑色 M码",它承载了具体的价格、库存、规格属性。

在商品服务里,我设计了spu表和sku表,spu表存通用信息,sku表通过spu_id关联,并额外保存规格属性JSON和独立的库存字段。

CREATE TABLE `spu` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `category_id` bigint(20) NOT NULL, `brand_id` bigint(20) DEFAULT NULL, `spu_name` varchar(200) NOT NULL, `main_image` varchar(500) DEFAULT NULL, `detail_html` text, `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0未上架 1上架', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

商品详情页展示时,客户端往往需要同时拿到SPU信息和默认SKU的价格库存,所以商品服务对外提供的详情接口通常是一次查询返回聚合结果,而不是让前端调两次接口。

4.2 分类表设计:无限级分类的路径冗余

商品分类通常是不定层级的。一级分类下面有二级,二级下面还有三级,设计上最常见的是parent_id自关联邻接表,但缺点是查某个分类下的所有子分类要递归。

我在自关联之外额外加了一个category_path字段来冗余路径,用逗号分隔存储从根节点到当前节点的id链。查询某个分类的全部后代分类时,直接LIKE '1,2,%'就能拿到,避免深度递归。代价是新增分类时要维护路径字段,但分类的变化频率很低,这点维护成本完全可以接受。

商品列表页按分类筛选时,还有一个容易忽略的问题:筛选结果应该包含当前分类下所有子分类的商品。如果前端只把当前分类id传给商品服务,商品服务需要根据分类树把子分类id全部查出,再执行IN查询。路径冗余字段在这里能大幅简化子分类查询逻辑。

4.3 商品详情缓存:击穿、穿透、雪崩的应对

商品详情是电商系统里典型的读多写少场景,缓存用Redis基本是标配。但缓存不是随便加一层就能解决所有问题。我踩过的坑主要有三个:

缓存穿透:用户恶意请求一个不存在的商品id,每次都穿透到数据库。解决方法是缓存空值并设置短过期时间,或者布隆过滤器过滤不存在的id。小规模系统里空值缓存更简单实用。

缓存击穿:某个热点商品缓存过期瞬间,大量请求同时打到数据库。解决方法是加互斥锁,缓存重建时只允许一个线程查库写缓存,其他线程等待或直接返回旧值。实现上可以用Redisson的分布式锁,或者本地锁加定时刷新。

缓存雪崩:大量key同一时刻过期,请求集中落到数据库。解决方法是给过期时间加随机值,比如基础过期时间基础上加1-300秒的随机值,避免集中失效。

另外,商品详情是聚合数据,我在Redis里存的是SPU聚合JSON。商品服务提供详情接口时,优先从Redis读,没有或过期再查数据库并回填缓存。回填缓存的过期时间我会根据商品的运营活动调整,比如大促前主动预热热点商品的缓存,而不是等缓存过期了被动重建。

5. 联调阶段最容易踩的坑:三则真实排查记录

前面讲的是设计和实现,但在真实项目联调时,用户和商品模块之间因为基础设施引入的坑,比业务逻辑本身更让人头疼。这里分享三个我实际排查过的案例。

5.1 Nacos命名空间混乱导致的"服务找不到"

一次联调时,用户服务调用商品服务Feign接口,报错一直提示找不到商品服务实例。但从商品服务的日志看,它明明已经注册到Nacos了。排查了很久发现,两个服务配置的namespace一个填了dev,一个填了prod的id,导致它们在逻辑上根本不处于同一个注册中心空间。

处理方式:把各服务的namespace配置统一收口到配置中心,每个环境一套配置,禁止在代码里写死不同的namespace。这里也提醒大家,Feign调用遇到服务找不到,要结合Nacos控制台上的服务列表和命名空间筛选逐层排查,而不是一上来就怀疑代码问题。

5.2 用户服务偶发登录态失效:JWT时钟偏移

用户反馈登录后偶尔会被踢下线,尤其是在系统刚启动或者多副本部署的情况下。排查后发现,用户服务的两个实例签发JWT时,各自机器系统时间存在偏差,导致"签发时间在过期时间之后"这类极端情况,校验时直接判定token无效。

处理方式:统一所有服务器的NTP时间同步,同时在JWT校验逻辑里设置允许的时钟偏移量,比如30秒以内不作为过期处理。这个坑在分布式环境下很容易出现,尤其是没有统一维护服务器时间的团队。

5.3 商品缓存和数据库不一致:双删策略的处理细节

运营后台编辑商品信息后,缓存没有更新,导致用户看到的还是旧价格。最初我的处理逻辑是更新数据库后删除Redis缓存,但删除期间刚好有读请求重建缓存,写入的却是旧数据,造成缓存再次脏数据。

处理方式:采用延迟双删,更新数据库后先删除缓存,隔几百毫秒再删除一次缓存。第一次删除是为了让并发请求在短暂时间内能读到新值,第二次删除是为了兜底清除第一次删除期间重建的脏缓存。当然更彻底的做法是引入binlog监听同步刷新缓存,但对于中小规模电商项目,延迟双删已经够用。

5.4 本地调试时服务间调用链路的追踪

用户服务和商品服务在本地调试时,我习惯在每个Feign调用的请求头里追加traceId,然后在日志输出traceId。这样一个请求从网关到用户服务再到商品服务,所有日志都能用同一个traceId串起来,排查问题会轻松很多。Spring Cloud Sleuth或Micrometer Tracing可以自动完成这件事,但如果不想引入额外组件,手动在Feign的RequestInterceptor里塞traceId也完全可行。

@Component public class FeignTraceInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate requestTemplate) { requestTemplate.header("X-Trace-Id", TraceIdHolder.get()); } }

这段代码虽然简单,但能省下大量联调时的日志追溯时间。

6. 关于这篇"上"的收尾:用户的体会

用户与商品两个模块做完后,我最大的体会是:Spring Cloud的组件再多,也只是解决了"服务之间怎么通信"的问题,真正决定项目成败的,还是每个业务模块自身的边界设计和数据建模。用户模块看似只有登录注册,但安全、状态、鉴权层层叠叠;商品模块看似只有增删改查,但SPU/SKU、分类、缓存一致性环环相扣。把这两个基础模块做扎实,后面的订单、支付模块才有可依赖的底座。

实际操作中我还养成了一些习惯:新需求来了先在模块边界内评估能不能闭环,不能闭环的再去找服务间协作的接口;修改用户表、商品表结构时先看哪些下游服务在用,通过发布计划排期而不是直接改库;每次缓存策略调整后,都先在上线后观察一两天热点key的命中率和数据库负载。这些习惯帮助我在后续迭代中减少了很多返工。

下篇我会把订单模块、库存扣减、支付回调这部分整理出来,尤其是库存锁定与订单状态的联动,那部分比用户和商品更考验分布式事务的取舍能力。这篇里涉及的JWT、SPU/SKU和缓存策略,如果你正在做类似的电商项目,可以直接拿去做参考模板,有问题随时交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询