1. 谷粒商城第6阶段:从单体到微服务的核心跨越
谷粒商城这个实战项目,前5个阶段基本把单体架构下的CRUD、权限、前端页面都走通了。到了第6阶段,项目真正进入了一个分水岭——从传统的单体应用向微服务架构演进。这个阶段涉及的内容不再只是简单的增删改查,而是要开始处理分布式环境下的一系列复杂问题:服务怎么拆、接口怎么调、数据怎么保持一致性、请求怎么路由到正确的服务。
如果你正在通过这个开源项目学习微服务,或者打算把谷粒商城的代码拉下来跑一遍,那么第6阶段的内容一定要当作一个独立的、完整的微服务实战案例来对待,而不是零零散散地去拼凑技术点。这一篇我根据自己的理解和实际操作踩过的坑,把第6阶段涉及的核心内容做一个彻底拆解,从架构设计思路到具体的代码实现细节,再到后期调试排错,尽量把每一步都讲透。
1.1 第6阶段到底在解决什么问题
先说一个比较反直觉的结论:谷粒商城这套代码,真正值钱的地方并不是那套花里胡哨的前端页面,也不是某个单独的组件用法,而是它以一种接近真实生产环境的方式,把微服务架构中那些常见组件和业务场景串在了一起。
第6阶段的核心任务,可以归纳为三件事:
第一件事是服务拆分实践。也就是说,需要把原来一个大的单体应用,按照业务边界拆分成用户、商品、订单、库存、支付等多个独立的服务模块。这个拆分不是简单地把代码放到不同的文件夹里,而是要真正理解每一个服务该怎么独立部署、独立伸缩、独立演进。拆分的粒度太大,服务内部依然耦合严重;拆分得太细,服务数量暴增,运维成本反而压垮团队。
第二件事是微服务基础组件的引入和集成。包括注册中心、配置中心、网关等基础组件。这些组件是微服务架构的地基,贯穿整个第6阶段及以后所有模块的开发。注册中心负责服务发现和健康检查,配置中心负责把散落在各个服务中的配置集中管理,网关统一接收外部请求,并做路由转发和鉴权过滤。整套链路搭好之后,后端的服务对前端和外部调用方来说,都是透明且无感的。
第三件事是分布式环境下的几个经典问题。这也是第6阶段最让人头疼、也最值得反复咀嚼的地方。比如,多个服务之间的远程调用要通过什么方式做?一个业务操作涉及多个服务(比如下单要扣库存、减积分、生成订单),其中一步失败怎么回滚?本地缓存和分布式缓存怎么配合使用?以及,当大量用户同时访问时,哪些环节会成为单点瓶颈,又该如何通过集群、MQ削峰等手段来化解?
可以这样理解,第6阶段之前,你是一个“单体架构高级工程师”;第6阶段之后,你才有资格讨论“分布式系统设计”这件事。这两者的思维模式是完全不同的。
1.2 这套方案适合什么人参考
如果你符合下面任意一个特征,我建议你认真对待这个阶段的内容:
第一类是学过Spring Boot但没接触过微服务的人。Spring Boot帮我们把Web开发的门槛降得非常低,但是微服务带来的挑战和Spring Boot中的单机玩法完全是两个层面。第6阶段是跨越这个知识层级最好的垫脚石。
第二类是准备求职面试的开发者。坦白讲,谷粒商城这套项目在Java社招面试中的出场率极高,它是很多人简历上“项目经验”的主要素材。而第6阶段刚好覆盖了面试中最常被追问的分布式部分——服务拆分粒度、Feign远程调用、网关路由、分布式事务、缓存一致性。把这些内容吃透,远比你背一百道八股文有效。
第三类是想把课堂或文档中学到的微服务理论知识落地成代码的学习者。Spring Cloud Alibaba这条技术栈的学习曲线不陡,但分支非常多。你自己对着官方文档玩,很容易陷入“配置完一个Hello World就不知道该干嘛”的困境。谷粒商城第6阶段通过一个完整的电商业务场景,把Nacos、Gateway、OpenFeign、Sentinel、Seata等一系列组件串联起来使用,这是任何书籍和文档都替代不了的。
提示:这一阶段的学习成本主要不在代码量,而在思维转变。你可能花费大量时间排查的问题,最后往往只是某个注册地址没写对、某个配置没生效的小问题。放平心态,排错本身就是微服务开发中最重要的能力。
2. 服务拆分的整体设计与链路分析
这个阶段的代码组织方式,整体上是围绕“按业务领域拆分+按调用链路串联”来展开的。理解这种设计方式,比单纯看代码更有用。
2.1 商品的SPU与SKU模型为什么要拆到独立服务
商品数据是电商系统中最基础、也最繁琐的数据。很多初学者在看谷粒商城第6阶段之前,对商品模型的理解还停留在一张简单的“商品表”上。但真实的电商商品域,至少要拆成SPU(标准化产品单元,可以理解为同一个商品的抽象,比如“iPhone 15”)、SKU(库存量单位,比如“iPhone 15 白色 256G”)、品牌、分类、属性、规格参数等多个维度。
这件事和微服务的关系太密切了。商品的查询、管理是独立的业务域,它的读写频率、数据量级和订单系统完全不同。商品数据主要是读多写少,而且会有很强的缓存需求;订单数据则要求极高的事务一致性,要频繁写入。把这两者放在同一个服务里,起初项目小似乎没问题,一旦流量上来,一次订单服务的数据库慢查询就可能会拖垮商品列表页的接口响应,线上事故就是这么来的。
所以谷粒商城第6阶段的做法是,把商品服务独立出来,用户、订单、库存等也各自独立。服务之间通过OpenFeign进行远程调用,把“读商品”“扣库存”这种跨服务操作变成一次RPC调用。这样做带来的好处有三个:第一,每个服务可以独立扩展,哪边访问压力大就只扩哪边;第二,每个服务可以独立发布和回滚,不至于一次改版动全身;第三,每个服务的数据库是隔离的,某个服务挂了不会直接拖垮整个数据库。
2.2 从一次“用户下单”看请求要经过几个服务
第6阶段中,一条完整的业务链路设计得非常典型。我建议你跟着这个思路,把整个流程画出来,而不是直接扑到代码里去抠细节。
一次用户从前端点击“提交订单”,到最终订单生成成功,通常要经历这样几步:
第一步,用户请求先到达API网关。网关在这一阶段充当所有前端请求的唯一入口。它根据请求路径的前缀(比如/api/member、/api/order)将请求路由到对应的服务上。同时,网关还承担登录状态校验、签名校验、限流等横切关注点的功能。你可以把网关理解成小区的门卫——所有的访客都要先在他这里登记和检查,再由他指引去对应的楼栋。
第二步,订单服务接收请求后,并不会自己处理所有业务,而是通过OpenFeign发起远程调用,获取当前用户的地址信息、购物车中选中的商品信息、结算时的价格信息等。这些远程调用在代码层面看起来几乎和本地方法一样,但背后框架会帮你完成服务发现(从Nacos中拿到目标服务的实例列表)、负载均衡(选择一台健康的实例发起调用)等事情。
第三步,订单服务调用库存服务,锁定商品库存。这一步有时候是同步调用,有时候通过RocketMQ发送预扣减消息,谷粒商城第7阶段还会专门深入讲最终一致性方案。第6阶段中住要是基于Seata来实现分布式事务的控制,保证多个跨服务写操作要么全部成功、要么全部回滚。
2.3 服务划分之后的“隐性成本”
服务拆分从来不是银弹,这个阶段你还会明显体验到微服务框架本身带来的额外复杂度。原本本地方法调用直接返回结果,现在需要经历网络传输,一次完整业务操作可能在多个服务之间来回调用多次,整体响应时间必然拉长;原本事务边界清晰,一个方法加@Transactional就完事,现在事务要跨服务,不得不引入分布式事务方案,这又会牺牲一部分性能。
谷粒商城第6阶段比较接地气的处理思路是:不是所有场景都上分布式事务,只用它来保护写入操作的核心链路;而查询类的跨服务调用,用缓存+异步的方式来优化。这样既保证了核心数据的准确性,又让查询性能不至于因为链路太长而拖垮体验。
3. 核心环节实操:Nacos、Gateway与OpenFeign的配合
这一部分我结合自己跑通第6阶段代码时的实际过程来讲,尽量把容易出错的地方标注出来。先强调一个观点:不要把这三个组件当作三个孤立的工具去记配置,要理解它们是如何协同工作的。
3.1 Nacos的注册与配置双中心搭建
Nacos在谷粒商城第6阶段中承担两个职责:服务注册/发现和配置中心。安装和启动Nacos本身不难,从官网下载解压,Linux上执行startup.sh -m standalone,Windows上执行startup.cmd -m standalone,访问8848端口进入控制台即可。
但是有几个细节,我强烈建议你在开始之前就搞清楚:
第一,服务注册到Nacos时,application.yml中的spring.application.name是必需项。Nacos会以这个名字作为服务名,别的服务通过OpenFeign走RPC调用时,引用的也是这个名字。如果你不确定,可以在Nacos控制台的服务列表中直接查看,调试非常方便。
第二,第6阶段通常会在本地调试时用bootstrap.yml来指定Nacos配置中心的地址,而不是把它全写在application.yml里。因为bootstrap.yml的加载优先级比application.yml更高,先加载配置中心地址,再从配置中心远程拉取公共配置。这个顺序千万不要搞混,否则会出现配置中心连不上、项目却还在用本地配置,导致改动不生效的情况。
第三,集群环境要做服务间的隔离或分组,通常会在nacos.config.import或namespace等参数上做文章。本地学习和测试阶段不用管这些,但生产环境的逻辑和这里几乎一致,所以先了解有这回事即可。
实操时建议的启动顺序是这样:先启动Nacos,再启动基础服务(比如权限、会员),最后启动商品、订单这类业务服务。每启动一个服务,就去Nacos控制台确认实例是否注册成功,再继续下一个。这样万一出现服务列表为空,你能很快定位是哪个环节出了问题。
3.2 Gateway网关的路由配置与跨域解决
Spring Cloud Gateway在谷粒商城第6阶段中扮演流量入口的角色。看代码时你会发现,网关服务本身不会去依赖其他任何业务服务,它只加载路由配置,然后根据路径把请求分发到对应服务。
它的核心配置是这样一段内容:
spring: cloud: gateway: routes: - id: product-route uri: lb://gulimall-product predicates: - Path=/api/product/** filters: - RewritePath=/api/(?<segment>.*), /$\{segment} - id: order-route uri: lb://gulimall-order predicates: - Path=/api/order/** filters: - RewritePath=/api/(?<segment>.*), /$\{segment}这里有一个非常容易踩的坑:RewritePath的正则表达式。因为前端通过网关访问后端服务时,路径是/api/product/...,而真正的商品服务里接口路径并不包含/api前缀,所以需要用到过滤器的重写功能把/api去掉再转发。如果你配上路由之后发现404,90%是这里重写路径写错了。
关于跨域问题,在第6阶段一定会遇到。前端项目通常跑在localhost:3000(Vite或Webpack的默认端口),后端的网关跑在localhost:88,这两个端口不一致,浏览器的同源策略会直接拦截请求。
网上很多方案是在每个服务里单独加@CrossOrigin注解或配置CorsFilter,这种做法在第6阶段的单体演进过程中还算能用,但拆分成微服务后就要改成在网关层统一处理。在网关中加入一个全局的CorsWebFilter,在前端发请求时,网关先返回允许的跨域响应头,这样就不会出现“Access-Control-Allow-Origin”这种报错了。很多人在第6阶段卡住,其实就是因为漏了这个统一配置。
3.3 OpenFeign的声明式调用与相关参数
OpenFeign在谷粒商城中被用来代替传统的RestTemplate和Dubbo。它最舒服的一点是,调用远程接口时,只需要定义一个接口,写上接口的方法签名,再在方法上标注对应的Spring MVC注解,Feign客户端就能把你的本地方法调用变成一次HTTP请求。
举一个商品服务调用库存服务的例子:
@FeignClient("gulimall-inventory") public interface InventoryFeignService { @RequestMapping("/stock/{skuId}") R getSkuStock(@PathVariable("skuId") Long skuId); }这里@FeignClient("gulimall-inventory")中的字符串,就是目标服务注册到Nacos中的spring.application.name。如果你这里写错了,或者目标服务没有成功注册到Nacos,启动时大概率不会报错,但一调用就会抛java.net.UnknownHostException,非常隐蔽。
第6阶段在调用OpenFeign时,还有一个容易出现问题的场景:服务间传递JSON数据时,参数类型和返回类型如何处理?谷粒商城引入了一个统一的返回体R,也就是把业务数据、状态码和提示信息封装在一起。这个R类会被打成公共模块,所有服务都依赖这个公共模块,保证调用方和服务提供方对于返回数据结构的理解一致。
还有个值得一提的细节:OpenFeign的调用超时时间。默认的超时时间很短,如果被调用的服务处理时间比较长(比如商品服务查询数据库+缓存,中间有慢查询),就会触发超时。这时候需要在配置中调整超时时间:
feign: client: config: default: connectTimeout: 5000 readTimeout: 10000注意:排查Feign调用超时问题时,一定要先确认是连接超时还是读超时。连接超时通常是网络不通或服务未启动;读超时则是对方服务处理逻辑太慢,或者数据库查询有性能问题,需要分别对待。
4. 第6阶段必须要掌握的分布式事务与缓存策略
介绍完服务之间怎么通信,接下来就是“跨服务后,数据一致性怎么保证”这个硬骨头。第6阶段在这一部分的设计有两条清晰的线路:一是通过Seata解决强一致场景,二是通过缓存+消息队列解决高性能场景下的最终一致问题。
4.1 Seata的AT模式与分布式事务链路
在单体应用里,我们依赖@Transactional来完成一个数据库事务。但是订单服务和库存服务各连各的数据库,一个事务根本不可能同时管住两个数据库中的数据。
谷粒商城第6阶段的分布式事务方案用的是Seata的AT模式。AT模式的核心思路是:引入一个全局事务协调者(TC),所有参与分布式事务的服务(RM)在本地执行SQL的同时,会额外记录undo_log回滚日志;事务管理器(TM)发起全局事务,如果链路中任何一个服务执行失败,TC通知所有参与者,反向执行undo_log中的SQL,把数据恢复原样。
听上去很美好,但你要注意一个非常现实的问题:AT模式的性能开销不小。每一次本地SQL执行步骤都要额外写undo_log,所以在真实生产环境中,很多高并发场景并不会无脑使用AT模式,而是改用TCC或者基于消息队列的最终一致性方案。第6阶段让你接触Seata,核心目的是理解分布式事务的运作逻辑,而不是让你在每一个微服务接口上都套一层分布式事务。
实际使用Seata时,你需要保证每个参与分布式事务的数据库中都有undo_log这张表,而且Seata Server(TC)要独立部署,并在application.yml中指定事务分组。启动过程中,如果发现服务启动正常但事务一直没生效,最常见的原因是service.vgroupMapping配置和Seata Server中配置的分组名不一致。
4.2 缓存不一致的成因与解决套路
商品信息的查询是整个商城的高频操作,数据库不可能直接承受所有请求压力,所以Redis缓存是绕不过去的一环。
第6阶段用的是Spring Cache(基于@Cacheable、@CacheEvict注解) + Redis整合的方式。在商品服务中,你可以看到很多类似这样的用法:
@Service public class SkuInfoServiceImpl extends ServiceImpl<SkuInfoMapper, SkuInfo> implements SkuInfoService { @Cacheable(value = {"skuInfo"}, key = "#skuId", unless = "#result == null") @Override public SkuInfo getSkuInfo(Long skuId) { // 从数据库查询 return this.getById(skuId); } }这个注解的逻辑很简单:请求过来时先查Redis缓存,如果再缓存中命中就直接返回,不走进方法体;如果没有命中则执行方法体从数据库查询,并把结果自动写入Redis。
但这个设计有一个经典的坑:缓存穿透和缓存雪崩,以及修改数据库后缓存如何失效。虽然Spring Cache的@CacheEvict注解能实现“在调用写方法时删除对应缓存”,但它天然无法处理缓存和数据库更新的先后顺序问题。生产上通常建议采用“先更新数据库,再删缓存”的模式,并且配合消息队列或订阅binlog来做兜底。第6阶段虽然没有把这块做到极致,但你需要有这个意识,面试时能把这些链路说清楚会很加分。
4.3 高并发场景下如何用MQ做削峰填谷
到第6阶段的后面部分,代码中已经开始引入RocketMQ。引入消息队列的典型场景是订单创建后的后续处理,比如发送短信通知、更新库存流水、给用户增加积分。这些操作有一个共同特点:实时性要求没那么高,但执行失败率不低(比如调用第三方短信接口容易失败)。
如果在下单的主链路中同步执行这些操作,一旦某个通知服务响应很慢,用户的订单请求就会一直挂着转圈。所以谷粒商城会把这类非核心的依赖操作放到RocketMQ的消息队列中异步处理。
这对第6阶段来说,是一个很重要的思维转换:并不是所有业务都追求“同步完成”。核心链路要短平快,能异步的都异步,能最终一致的就不要强一致。
5. 常见问题速查表与避坑记录
最后这部分是我根据自己调试第6阶段代码时遇到的问题,以及经常在社群里看到新手提问的集中点,整理出来的一份速查表。建议你在动手跑之前先看一遍,能帮你省下不少排查时间。
| 问题现象 | 最可能的原因 | 快速解决办法 |
|---|---|---|
| 启动后服务注册不上Nacos | spring.application.name未设置,或Nacos地址写错 | 检查配置文件和Nacos控制台服务列表 |
| 通过网关访问接口报404 | Gateway的RewritePath正则配错 | 先在浏览器直接访问目标服务的原始地址,确认服务本身通 |
Feign调用报UnknownHostException | @FeignClient中的服务名和注册中心不一致 | 对比目标服务的application.name |
| Feign调用超时 | 默认超时时间太短,或被调用服务有慢查询 | 调整connectTimeout/readTimeout,优化数据库SQL |
| 请求被跨域问题拦截 | 网关层CorsWebFilter未配置或配置顺序错误 | 统一在网关配置CorsFilter,不要在业务服务里单个加 |
| Redis缓存管理没有生效 | 实体类没有实现Serializable,或JSON序列化器配置有误 | 检查实体类序列化,确认RedisTemplate使用的Serializer |
| Seata事务一直不回滚 | 数据库缺少undo_log表,或事务分组配置不一致 | 在每个业务库执行建表SQL,检查Seata控制台配置 |
| 接口返回的数据总是旧数据 | 查询接口命中缓存,而更新接口未清缓存 | 在写操作上加@CacheEvict,或手动删除缓存 |
还有一个几乎所有初学者都会至少踩一次的坑:Maven模块之间依赖版本不一致。谷粒商城的子模块非常多,公共模块里定义的版本号、依赖引用,如果在其他地方手动写了具体的版本,很容易出现依赖冲突——最常见的外在表现是项目能编译但启动后各种奇怪的ClassNotFoundException。如果你遇到类似问题,先到根pom.xml检查依赖版本管理,尽量保证组件版本统一。
启动顺序也是老生常谈:基础设施优先,业务服务次之。Nacos、Sentinel、Seata这些中间件先起来,确认没问题之后再启动业务服务。否则服务启动时连不上配置中心,就是一个连环失败的尴尬局面。
6. 我的一些实践心得
谷粒商城第6阶段,是我认为整套项目中含金量最高、也最适合反复回看的阶段。它第一次把微服务架构的多个核心组件“揉”进了一个业务闭环中,让学习这件事从分散的“组件Demo”变成了完整的“系统设计”。
有一点我想特别提醒你:代码能跑通只是最低要求。如果你只是把项目拉下来、启动成功、页面能点,就觉得自己掌握了微服务,那大概率面试官追问两三个细节就会露馅。请在每个模块的代码中多停顿一下,想一想“为什么这么设计”,比如为什么要在订单创建后通过MQ异步发送通知?为什么这里选择Redis而不是本地缓存?为什么这个接口的远程调用要设置这么长的超时时间?
把这些“为什么”想清楚,你的收获会比单纯敲一遍代码大得多。实战项目最大的价值不在于代码本身,而在于它模拟的那种接近生产环境的设计权衡过程。第6阶段的这些内容,哪怕放到真实的电商公司里,也仍然是后端开发每天都绕不开的核心话题。