从零搭建微服务:Spring Cloud Alibaba 全家桶踩坑实录(Nacos + OpenFeign + Gateway + Sentinel)

学了Spring Boot、Redis、Docker之后,我遇到了一个灵魂拷问:项目做大了怎么办?所有功能塞在一个jar包里,改一个模块要重新部署整个项目,一个模块OOM整个应用崩溃。这一周我集中攻克了微服务架构,用Spring Cloud Alibaba全家桶把一个单体项目拆成了多个微服务,加上了注册中心、远程调用、API网关和限流熔断。这篇文章完整记录这个过程。


为什么要拆微服务?

先说我遇到的真实痛点。

之前做的图书管理项目是一个单体Spring Boot应用------用户模块、图书模块、订单模块全在一个项目里,共享一个数据库、一个端口、一个JVM进程。项目小的时候还好用,但随着功能越来越多,问题逐渐暴露:

改一行代码要重新部署整个项目。 我只改了用户模块的一个bug,但要把整个应用(包括图书、订单模块)全部重新打包、重新部署。每次发布都要全量回归测试。

一个模块的问题会拖垮整个应用。 图书模块有个慢查询把连接池占满了,整个JVM的线程都在等,用户模块也跟着不可用。明明是两个不相干的功能,却互相牵连。

没办法按需扩容。 图书查询的流量最大,我想多开几个图书服务的副本?不行,只能把整个大应用多开几份,连带用户模块也跟着多开了,浪费资源。

这三个问题让我决定:是时候学微服务了。


一、单体 vs 微服务:一张图说清楚

单体架构就是所有模块打在一个jar包里,跑在一个JVM进程里:

复制代码
┌─────────────────────────────────┐
│        bookstore.jar             │
│  ┌─────┐ ┌─────┐ ┌─────┐      │
│  │用户  │ │图书  │ │订单  │      │
│  │模块  │ │模块  │ │模块  │      │
│  └─────┘ └─────┘ └─────┘      │
│       共享一个数据库和端口        │
└─────────────────────────────────┘

微服务架构是每个模块拆成独立的服务,各自有自己的端口、数据库、进程:

复制代码
┌────────┐   ┌────────┐   ┌────────┐
│ user-  │   │ book-  │   │ order- │
│service │   │service │   │service │
│ :8081  │   │ :8082  │   │ :8083  │
└───┬────┘   └───┬────┘   └───┬────┘
    │            │            │
  各自独立的数据库和部署

微服务的好处很直接:独立部署(改用户模块只重新部署用户服务)、独立扩缩容(图书服务流量大就多开几个副本)、故障隔离(订单服务挂了不影响用户和图书)。

但代价也很明显:以前一个方法调用搞定的事,现在要发HTTP请求;以前一个数据库事务搞定的事,现在要做分布式事务;以前一个项目就完了,现在要管好几个服务的部署和监控。

我的结论:小项目别硬上微服务。 当你的项目功能模块超过5个、团队超过5个人、需要独立扩缩容时,微服务才有意义。学习的时候先用小项目练手,理解原理最重要。


二、Spring Cloud Alibaba 全家桶一览

Java生态里做微服务最主流的方案是Spring Cloud。国内企业用得最多的是阿里开源的Spring Cloud Alibaba。这周我学的四个核心组件:

组件 解决的问题 比喻
Nacos 服务在哪?怎么找到它? 公司通讯录
OpenFeign 服务之间怎么调用? 内部电话系统
Gateway 客户端怎么统一访问? 公司前台
Sentinel 流量太大怎么保护? 门禁限流系统

下面逐个分享我的学习过程和踩坑经验。


三、Nacos:服务的"通讯录"

3.1 服务注册发现到底在解决什么

两个微服务互相调用时,book-service怎么知道user-service的IP和端口?

笨办法是写死地址。但user-service如果扩缩容了(IP变了或多了几个副本),你得改配置重启book-service。

Nacos的做法是引入一个"中间人"。所有服务启动时向Nacos注册自己的地址,需要调用别的服务时问Nacos要地址。就像公司通讯录------新员工入职时登记分机号,你要找人就查通讯录,不用记住每个人的号码。

完整流程是四步:注册(服务启动时向Nacos报告地址)→ 心跳续约(每5秒发心跳表示存活)→ 服务发现(调用方查询目标地址列表)→ 下线注销(正常关闭时通知Nacos移除)。

3.2 安装和启动

Nacos是一个独立运行的服务端程序。去GitHub下载nacos-server的zip包,解压后执行:

bash 复制代码
startup.cmd -m standalone

浏览器打开 http://localhost:8848/nacos,用nacos/nacos登录就能看到控制台。

3.3 让服务注册到Nacos

在Spring Boot项目里只需要三步:

xml 复制代码
<!-- pom.xml:引入Nacos发现依赖 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
yaml 复制代码
# application.yml
spring:
  application:
    name: user-service    # 服务名(注册到Nacos上的标识)
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848   # Nacos在哪

启动服务后,Nacos控制台的服务列表里就能看到它了。就这么简单。


四、服务间调用:从RestTemplate到OpenFeign

4.1 RestTemplate(能用但啰嗦)

第一个想到的方案是用RestTemplate发HTTP请求:

java 复制代码
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
    return new RestTemplate();
}

// 调用时:
String url = "http://user-service/users/" + userId;
UserDTO user = restTemplate.getForObject(url, UserDTO.class);

@LoadBalanced是关键注解------它让RestTemplate能把URL里的服务名user-service通过Nacos解析成实际IP:端口,并做负载均衡选择一个实例。

但这个写法有几个问题:URL要手动拼字符串,返回类型要手动指定(编译期不检查),每个调用点都要写重复代码。

4.2 OpenFeign(优雅太多了)

OpenFeign把远程调用变成了一个Java接口:

java 复制代码
@FeignClient(name = "user-service")
public interface UserClient {
    @GetMapping("/users/{id}")
    UserDTO getUserById(@PathVariable("id") Long id);
}

// 使用时直接注入:
@Autowired
private UserClient userClient;

UserDTO user = userClient.getUserById(1L);
// 看起来像调本地方法,实际上发了一次HTTP请求

从两行变成了半行。而且URL、返回类型、参数绑定全在接口定义里,编译期就能检查类型是否正确。

底层原理是JDK动态代理。 Spring启动时@EnableFeignClients扫描所有@FeignClient接口,为每个接口创建动态代理对象。你调用方法时,代理拦截调用:从注解解析URL → Nacos查地址 → 负载均衡选实例 → 发HTTP请求 → JSON反序列化返回。面试很爱考这个原理。

4.3 超时和日志(必须配)

OpenFeign默认超时很短(1秒连接+1秒读取),生产环境必须调整:

yaml 复制代码
spring:
  cloud:
    openfeign:
      client:
        config:
          default:
            connect-timeout: 3000
            read-timeout: 5000
            logger-level: FULL   # 开发阶段开FULL,生产用NONE

不配超时的后果:下游服务慢了,上游的线程一直阻塞等响应,线程池耗尽,上游也跟着崩------级联故障。这是我踩过的一个真实的坑。

4.4 Token透传

微服务间调用还有一个容易忽略的问题:用户的JWT Token不会自动跟着Feign请求走。你发了一个全新的HTTP请求,默认不带原始请求的请求头。

解决方案是写一个RequestInterceptor,每次Feign发请求前从当前线程的HttpServletRequest中取出Authorization头加进去:

java 复制代码
@Bean
public RequestInterceptor tokenRelayInterceptor() {
    return template -> {
        ServletRequestAttributes attrs =
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attrs != null) {
            String auth = attrs.getRequest().getHeader("Authorization");
            if (auth != null) {
                template.header("Authorization", auth);
            }
        }
    };
}

这样Token就能在整条调用链路上自动传递,每个服务都能做鉴权。


五、Gateway:微服务的"统一前台"

5.1 为什么需要网关

没有网关时,客户端要知道每个服务的地址和端口,每个服务都要自己做JWT校验和跨域处理。有了网关,所有请求先经过Gateway,由Gateway做路由分发、统一鉴权、统一跨域。后端服务不对外暴露端口,只有Gateway的端口对外开放。

5.2 踩坑:不能引spring-boot-starter-web

这是我第一次用Gateway时踩的最痛的坑。Gateway基于Spring WebFlux(响应式),底层用Netty。而spring-boot-starter-web基于Servlet/Tomcat。两者不能共存,引了web依赖启动直接报错。

正确做法:gateway-service的pom.xml里只引spring-cloud-starter-gatewaynacos-discoveryloadbalancer,不要引web。

5.3 路由配置

Gateway的核心就是路由规则------什么请求转发到哪个服务:

yaml 复制代码
spring:
  cloud:
    gateway:
      routes:
        - id: user-route
          uri: lb://user-service
          predicates:
            - Path=/api/user/**
          filters:
            - StripPrefix=2

        - id: book-route
          uri: lb://book-service
          predicates:
            - Path=/api/book/**
          filters:
            - StripPrefix=2

lb://表示通过Nacos负载均衡找服务。Path=/api/user/**是匹配条件。StripPrefix=2在转发时去掉前两层路径(/api/user),因为后端Controller的路径是/users/{id}而不是/api/user/users/{id}

配好之后,客户端只需要知道Gateway的端口:

bash 复制代码
# 所有请求都走Gateway
curl http://localhost:9000/api/user/users/1   # → 转发到user-service
curl http://localhost:9000/api/book/books/1   # → 转发到book-service

5.4 全局过滤器做JWT鉴权

Gateway最有价值的用途之一是把JWT校验集中在一处。写一个GlobalFilter,所有请求进来先校验Token,无效直接返回401,有效就把用户ID放进请求头转发给后端:

java 复制代码
@Component
public class AuthFilter implements GlobalFilter, Ordered {

    private static final List<String> WHITE_LIST = List.of(
        "/api/user/users/login", "/api/user/users/register"
    );

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();

        // 白名单直接放行
        if (WHITE_LIST.stream().anyMatch(path::startsWith)) {
            return chain.filter(exchange);
        }

        // 取Token并校验
        String auth = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (auth == null || !auth.startsWith("Bearer ")) {
            return unauthorized(exchange);
        }

        String token = auth.substring(7);
        String userId = parseToken(token); // JWT解析

        // 把用户ID放进请求头,后端服务直接用
        ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
            .header("X-User-Id", userId)
            .build();

        return chain.filter(exchange.mutate().request(mutatedRequest).build());
    }

    @Override
    public int getOrder() { return -1; }
}

这样后端的每个Controller直接从请求头拿用户ID,不用再各自解析JWT了。改鉴权逻辑也只需要改Gateway一处。


六、Sentinel:微服务的"保险丝"

6.1 限流的必要性

每个服务和数据库都有处理能力上限。突发流量超过上限时服务直接被压垮,然后级联故障扩散到整个系统。限流就是保护服务在能力范围内工作------超过阈值的请求排队或直接拒绝。

比喻就是网红餐厅门口的叫号机:里面座位满了就排队,不让所有人同时涌进去。

6.2 安装Sentinel

引入依赖、配置Dashboard地址、启动Dashboard(一个独立的jar包):

xml 复制代码
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
yaml 复制代码
spring:
  cloud:
    sentinel:
      transport:
        dashboard: localhost:8080
bash 复制代码
java -jar sentinel-dashboard-1.8.8.jar

Dashboard提供可视化界面,可以实时查看每个接口的QPS、配限流规则、看熔断状态。

6.3 限流规则

在Dashboard上给/books/{id}接口配QPS阈值为10------每秒最多10个请求,超过的直接拒绝返回429。

三种限流算法:快速失败(超了直接拒绝,最常用)、Warm Up(冷启动,阈值慢慢升高)、排队等待(匀速放行,流量整形)。底层用的是滑动窗口统计QPS和令牌桶做限流。面试爱考这两者的区别。

6.4 熔断降级

限流是保护自身接口不被太多请求压垮,熔断是保护上游调下游时不被拖死。

当book-service调用user-service,user-service响应越来越慢时,Sentinel检测到异常率超过阈值会自动"熔断"------不再调用user-service,直接走降级逻辑返回默认值。等user-service恢复了,自动关闭熔断恢复正常。

熔断器有三种状态:CLOSED(正常调用)→ 异常率超标 → OPEN(不再调用,走降级)→ 等一会 → HALF_OPEN(试探一个请求)→ 成功则回CLOSED,失败则回OPEN。

6.5 Feign + Sentinel 集成

最优雅的方式是让OpenFeign和Sentinel联动。Feign调用失败时自动走fallback,不需要在Controller里写try-catch:

java 复制代码
@FeignClient(name = "user-service", fallbackFactory = UserClientFallbackFactory.class)
public interface UserClient {
    @GetMapping("/users/{id}")
    UserDTO getUserById(@PathVariable("id") Long id);
}

@Component
public class UserClientFallbackFactory implements FallbackFactory<UserClient> {
    @Override
    public UserClient create(Throwable cause) {
        return id -> {
            UserDTO user = new UserDTO();
            user.setId(id);
            user.setName("服务暂不可用");
            return user;
        };
    }
}

用fallbackFactory比fallback好------能拿到具体的异常信息,可以针对超时、连接拒绝等不同原因返回不同的降级结果。


七、整体串联:四个组件如何协作

把这周学的东西串起来看,一个完整的请求链路是这样的:

复制代码
客户端
  │
  │ GET http://gateway:9000/api/book/books/1
  │ Authorization: Bearer xxx
  ↓
Gateway(统一入口)
  ├── AuthFilter:校验JWT Token → 通过 ✅
  ├── AccessLogFilter:记录请求日志
  └── 路由匹配:/api/book/** → book-service
        │ StripPrefix=2,去掉 /api/book
        ↓
book-service (:8082)
  ├── Sentinel检查:QPS是否超阈值 → 未超 ✅
  ├── 查询图书信息
  ├── OpenFeign调用 user-service 获取作者信息
  │   ├── 从Nacos查user-service地址
  │   ├── 负载均衡选一个实例
  │   ├── Token透传(RequestInterceptor)
  │   └── Sentinel熔断保护(失败走fallback)
  └── 组装完整响应返回
        ↓
Gateway → 客户端

四个组件各司其职:Gateway管入口和路由,Nacos管服务地址,OpenFeign管服务间调用,Sentinel管流量保护。缺了任何一个,微服务架构都不完整。


八、版本对应关系(踩坑预警)

这是我花了最多时间排查的问题:Spring Boot、Spring Cloud、Spring Cloud Alibaba三者的版本必须对应。 版本不匹配会出现各种莫名其妙的兼容问题------类找不到、方法不存在、配置不生效。

我用的组合是:Spring Boot 3.2.5 + Spring Cloud 2023.0.1 + Spring Cloud Alibaba 2023.0.1.0。建议大家也用这个组合,是目前最稳定的。

版本对应关系可以查Spring Cloud Alibaba官方文档。强烈建议用父工程的dependencyManagement统一管理版本,子模块不要各自写version。


九、个人感悟

这一周最大的收获是理解了微服务架构的核心思想:用架构复杂度换开发和部署的灵活性。

之前我觉得微服务就是"把项目拆小",学了之后才发现拆分只是第一步。拆完之后服务间怎么通信、怎么找到对方、怎么统一入口、怎么保护流量------每一步都有对应的技术方案。Spring Cloud Alibaba把这四件事都做成了"开箱即用"的starter,降低了入门门槛,但理解背后的原理同样重要。

面试问微服务不是为了考你会不会配yml,而是看你理解不理解为什么要这么做。比如面试官问"为什么需要服务注册发现",你不能只说"因为要用Nacos",而应该说"因为微服务的地址是动态的,扩缩容和重启都会导致地址变化,需要一个中心化的服务来管理所有实例的地址"。

还有一个感触:微服务不是银弹。 它解决了单体架构的部署和扩展问题,但引入了分布式通信、数据一致性、运维复杂度等新问题。选型时要根据团队规模和业务需求来决定,不要为了微服务而微服务。

下周开始进入中间件的学习(RabbitMQ/Kafka消息队列、Elasticsearch搜索引擎),会继续记录。如果这篇文章对你有帮助,点个赞再走吧。

相关推荐
IT界的老黄牛11 小时前
Spring Boot 的 `/actuator/health` 不是免费的:健康检查打爆连接池的反面教材
spring boot·hikaricp·actuator·健康检查·线上排障·架构反模式
都叫我大帅哥13 小时前
Java JJWT 完全指南:从入门到生产级实践
java
前端开发张小七14 小时前
Java 学习笔记 · 第一课:基础语法与核心概念
java·后端
纯真时光14 小时前
微服务保护
微服务
纯真时光15 小时前
微服务分布式事务 -- XA,AT,TCC模式
分布式·微服务
ThsPool15 小时前
【ENVI二次开发学习整理 04】ENVI功能扩展与二次开发
java·前端·javascript
赤壁小虾15 小时前
【渲染流水线】[逐片元阶段]-[透明度测试]以UnityURP为例
java·前端·数据库
青山木17 小时前
Hot 100 --- 搜索插入位置
java·数据结构·算法·leetcode
Jul1en_17 小时前
【Claude Code Compact】源码级别的学习上下文压缩
java·前端·学习·github·ai编程