微服务

一、微服务核心概念

1.1 什么是微服务

微服务是什么-0be9c639

“感觉本质就是做了层封装,有什么好处?”

对于小项目(如学习项目):说实话,好处不大,反而增加复杂度。

对于大型项目,好处明显:

1. 独立部署

单体:改个用户头像功能 → 整个应用重新部署
微服务:只部署 user-service,其他服务不受影响

2. 独立扩展

场景:双11,订单量暴增,但用户登录正常

单体:整个应用扩 10 台机器(浪费)
微服务:只扩 order-service 10 台,其他不变

3. 技术栈灵活

user-service: Java
ai-service: Python(机器学习更方便)
search-service: Go(高并发)

4. 故障隔离

单体:订单模块内存泄漏 → 整个系统崩溃
微服务:order-service 挂了 → 其他服务正常,用户还能浏览商品

5. 团队协作

单体:10 个人改同一个代码库,冲突不断
微服务:
  - 团队A 负责 user-service
  - 团队B 负责 order-service
  - 各自独立开发、测试、部署

代价:

方面 单体 微服务
部署复杂度
运维成本
调试难度 简单 复杂(链路追踪)
数据一致性 简单(本地事务) 复杂(分布式事务)
适合场景 小团队、简单业务 大团队、复杂业务
ℹ️概念源流补充:微服务从哪来

Martin Fowler 的定义(2014,与 James Lewis 合著):微服务是一种架构风格,它将一个应用拆分为一组小型服务,各服务运行在独立进程中,通过轻量级机制(通常是 HTTP API)通信,围绕业务能力构建,可独立部署,且去中心化地管理数据与技术栈。

演进脉络:单体 → 垂直拆分(按业务切几个 WAR 包)→ SOA(ESB 企业服务总线做集成,接口重、总线重)→ 微服务(去中心化,"智能端点 + 哑管道",总线换成轻量网关 + 注册中心)。

形态 粒度 通信 数据 典型痛点
单体 整个应用一个进程 进程内方法调用 一个库 代码耦合、整体部署、无法按需扩展
模块化单体 单进程内按模块隔离 进程内接口调用 一个库(逻辑隔离) 部署仍是整体,但演进友好
SOA 粗粒度服务 ESB 总线(重协议:SOAP/ESB 路由) 通常共享库 总线成单点与瓶颈
微服务 细粒度、按业务域 轻量协议(HTTP/RPC/MQ) 每服务独占库 分布式复杂性(一致性、运维、排错)

关键认知:微服务解决的是组织规模化问题(康威定律:系统架构 = 团队沟通结构的投影),其次才是技术问题。小团队小业务强行拆微服务,是把技术债换成了分布式债。

1.2 微服务带来的问题与解决方案

问题 解决方案 技术选型
服务怎么找到彼此? 注册中心 Nacos / Eureka / Consul
前端调用哪个服务? API 网关 Spring Cloud Gateway / Kong
服务之间怎么调用? RPC / HTTP OpenFeign / Dubbo
一个服务挂了怎么办? 熔断降级 Sentinel / Hystrix
配置太分散怎么管理? 配置中心 Nacos Config / Apollo
请求链路怎么追踪? 链路追踪 Sleuth + Zipkin / SkyWalking
多个服务如何负载均衡? 负载均衡 LoadBalancer / Ribbon

1.3 核心组件关系图

核心组件关系-9a90596e

二、注册中心(Nacos)

2.1 为什么需要注册中心

为什么需要注册中心-b8159e52

2.2 服务注册与发现流程

服务注册与发现流程-ec26abe6

2.3 Nacos 配置详解

spring:
  application:
    name: user-service              # 服务名(注册到 Nacos 的名字)
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848 # Nacos 地址
        namespace: dev              # 命名空间(隔离环境)
        group: DEFAULT_GROUP        # 分组
        ip: 192.168.1.10            # 注册的 IP(解决多网卡问题)
        port: ${server.port}        # 注册的端口
        weight: 1                   # 权重(负载均衡用)
        cluster-name: SH            # 集群名(同机房优先调用)

        # 健康检查
        heart-beat-interval: 5000   # 心跳间隔(毫秒)
        heart-beat-timeout: 15000   # 心跳超时
        ip-delete-timeout: 30000    # IP 删除超时

2.4 命名空间与分组(环境隔离)

命名空间与分组-354755bd

2.5 注册中心选型与工作原理(补充)

常见注册中心对比

维度 Nacos Eureka ZooKeeper Consul
CAP 定位 AP / CP 可切换 AP CP CP(默认)
一致性协议 Distro(AP)+ Raft(CP) 最终一致(副本复制) ZAB Raft
健康检查 心跳(临时)+ 服务端探测(持久) 客户端心跳 会话保活 心跳 + gRPC 探测
雪崩保护 ✅ 有(阈值保护) ✅ 有(自我保护) ❌ 无 ❌ 无
配置中心 ✅ 内置 ❌ 需另配 ❌ 需另配( watches 较弱) ✅ KV + watch
适用场景 国内主流、电商/中小团队 老项目(已停更) Hadoop 生态、强一致场景 K8s 生态、多数据中心

选型结论:Spring Cloud Alibaba 栈直接用 Nacos——它同时把注册中心(AP 优先保可用)和配置中心(CP 保一致)两件事都做了,一套运维搞定。

临时实例 vs 持久实例

临时实例(ephemeral=true,默认):
• 客户端主动上报心跳,Nacos 长时间收不到 → 摘除实例
• 数据不落盘,Nacos 重启后临时实例全部消失
• 适合:常规业务服务(服务挂了就该从列表里消失)

持久实例(ephemeral=false):
• Nacos 服务端主动探测(TCP/HTTP),不健康只标记不摘除
• 数据落盘,Nacos 重启后仍在
• 适合:数据库、MQ 等底层依赖(不能"摘除",要人工介入)

 Spring Cloud 默认注册的就是临时实例,通过 spring.cloud.nacos.discovery.ephemeral 可改。

服务上下线的感知时序

上线:服务启动 → 注册到 NacosNacos 写内存注册表并同步给其他 Nacos 节点
      → 消费者通过订阅(推送 + 定时拉取兜底)拿到新地址

下线:主动下线:向 Nacos 发注销请求 → 秒级生效
      崩溃下线:心跳停止 → heart-beat-timeout(15s) 标记不健康
                → ip-delete-timeout(30s) 摘除 → 消费者刷新本地缓存

⚠️ 消费者本地始终缓存一份服务列表:Nacos 全挂了,已拉取过列表的调用还能继续(降级兜底)。

Nacos 1.x vs 2.x 的关键变化

  • 1.x:HTTP 轮询 + UDP 推送(不可靠)+ 心跳走 HTTP,实例变化感知秒级延迟
  • 2.x:客户端与 Nacos 建立 gRPC 长连接,注册/变更/推送全走长连接——变更推送准实时(毫秒级),连接断开即视为实例失联,心跳退化为连接层保活
  • 所以 2.x 端口除了 8848 还要开放 9848(gRPC),漏开是"服务注册失败"排查的常见坑(对应 11.1 排查清单)

三、API 网关(Spring Cloud Gateway)

3.1 网关的职责

网关的职责-1b13a00c

3.2 路由配置详解

spring:
  cloud:
    gateway:
      routes:
        - id: user-service                    # 路由唯一标识
          uri: lb://user-service              # lb = LoadBalancer,从注册中心获取
          predicates:                         # 断言:什么请求匹配这个路由
            - Path=/api/user/**               # 路径匹配
            - Method=GET,POST                 # 方法匹配(可选)
            - Header=X-Request-Id, \d+        # 请求头匹配(可选)
            - Query=name                      # 查询参数匹配(可选)
            - After=2024-01-01T00:00:00+08:00 # 时间之后(可选)
          filters:                            # 过滤器:对请求/响应做处理
            - StripPrefix=1                   # 去掉路径前缀 /api
            - AddRequestHeader=X-From, gateway
            - AddResponseHeader=X-Response-Time, 123
          metadata:
            response-timeout: 5000            # 超时时间

        # 静态路由(不走注册中心)
        - id: baidu
          uri: https://www.baidu.com
          predicates:
            - Path=/baidu/**

3.3 路由转发示例

转发过程-d1ebd560

3.4 自定义全局过滤器

/**
 * 全局鉴权过滤器
 */
@Component
@Order(-1)  // 数字越小优先级越高
public class AuthGlobalFilter implements GlobalFilter {

    // 白名单:不需要鉴权的路径
    private static final List<String> WHITE_LIST = Arrays.asList(
        "/api/user/login",
        "/api/user/register",
        "/api/artwork/list"
    );

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

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

        // 2. 获取 Token
        String token = request.getHeaders().getFirst("Authorization");

        // 3. 验证 Token
        if (token == null || !token.startsWith("Bearer ")) {
            return unauthorized(exchange, "未登录");
        }

        try {
            // 4. 解析 Token,获取用户信息
            String jwt = token.substring(7);
            Claims claims = JwtUtil.parse(jwt);
            Long userId = claims.get("userId", Long.class);

            // 5. 将用户信息传递给下游服务
            ServerHttpRequest newRequest = request.mutate()
                .header("X-User-Id", userId.toString())
                .header("X-User-Name", claims.get("username", String.class))
                .build();

            return chain.filter(exchange.mutate().request(newRequest).build());

        } catch (Exception e) {
            return unauthorized(exchange, "Token 无效");
        }
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange, String message) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);

        String body = "{\"code\":401,\"message\":\"" + message + "\"}";
        DataBuffer buffer = response.bufferFactory().wrap(body.getBytes());
        return response.writeWith(Mono.just(buffer));
    }
}

3.5 跨域配置

spring:
  cloud:
    gateway:
      globalcors:
        cors-configurations:
          '[/**]':                           # 所有路径
            allowedOrigins: "*"              # 允许的域名
            allowedMethods:                  # 允许的方法
              - GET
              - POST
              - PUT
              - DELETE
              - OPTIONS
            allowedHeaders: "*"              # 允许的请求头
            allowCredentials: true           # 允许携带 Cookie
            maxAge: 3600                     # 预检请求缓存时间

3.6 网关执行流程(补充)

一个请求进来,Gateway 内部到底发生了什么:

请求 → HttpWebHandlerAdapter(适配成 ServerWebExchange)
  ↓
DispatcherHandler.handle()
  ↓
RoutePredicateHandlerMapping:拿请求逐条匹配 routes 里的 predicates
  ↓ 匹配到路由(如 user-service)
FilteringWebHandler:把「全局过滤器 GlobalFilter」与「该路由的 GatewayFilter」
                     合并成一个过滤器链,按 Order 排序依次执行
  ↓
NettyRoutingFilter(最后一步):lb://user-service → LoadBalancer 选实例
  → 把 http://user-service/api/user/1 换成真实地址 http://192.168.1.10:8010/api/user/1
  → 转发请求,响应原路返回

三个容易混淆的点:

  1. predicate 是"选路由",filter 是"改请求/响应"——先选定路由,才谈得上过滤器
  2. GlobalFilter 全局生效;GatewayFilter 只属于某条路由(如 StripPrefix=1),两者最终在同一链条上按 order 排序执行——自定义全局过滤器 @Order(-1) 就是在控制它在链中的位置
  3. Gateway 基于 WebFlux(Netty 异步非阻塞):过滤器里写的 Mono<Void> 是响应式编程;这也解释了 11.3 的报错——classpath 里不能有 spring-boot-starter-web(MVC 是同步阻塞模型,二者容器模型冲突)

四、微服务调用(OpenFeign)

4.1 核心原理

为什么能在 artwork-service 中注入 user-service 的 UserClient?

之前注入是在同一个项目文件里面 一起编译 能找到class 从而反射注入依赖

但这里都分开了 是怎么在Artwork服务中用user服务的?

我能想象的到的只是user开放接口供Artwork调用 类似于调云平台的ai接口

你的直觉是对的:不是真的注入了代码,而是注入了一个 HTTP 客户端代理。本质就是调接口,类似于调用云平台的 AI 接口。

运行时的真相

// 你写的
@FeignClient(name = "user-service")
public interface UserClient {
    @GetMapping("/user/{id}")
    UserDTO getUserById(@PathVariable Long id);
}

// Spring 扫描到 @FeignClient 后,在运行时动态生成的代理类(伪代码)
public class UserClientProxy implements UserClient {

    @Autowired
    private LoadBalancer loadBalancer;  // 负载均衡器

    @Autowired
    private HttpClient httpClient;      // HTTP 客户端

    @Override
    public UserDTO getUserById(Long id) {
        // ① 从注册中心(Nacos)获取服务地址
        ServiceInstance instance = loadBalancer.choose("user-service");
        String url = instance.getUri() + "/user/" + id;

        // ② 发起 HTTP GET 请求
        String json = httpClient.get(url);

        // ③ 反序列化 JSON 为对象
        return JSON.parseObject(json, UserDTO.class);
    }
}

// Spring 容器注入的就是这个代理对象
@Autowired
private UserClient userClient;  // 实际是 UserClientProxy 实例

启动时发生的过程

artwork-service 启动时:

1️⃣ Spring 扫描到 @FeignClient(name = "user-service")

2️⃣ 动态生成代理类 UserClientProxy(类似 MyBatisMapper3️⃣ 把代理对象注册到 Spring 容器

4️⃣ 当你 @Autowired UserClient 时,拿到的就是这个代理对象

调用时:

userClient.getUserById(123L)
    ↓
代理对象的 getUserById 方法
    ↓
① 从 Nacos 查询 user-service 的地址
② 拼接 URL
③ 发送 HTTP 请求
④ 解析响应
⑤ 返回结果对象
服务间调用-cefdc4a4

同项目注入 vs 微服务调用对比

同项目内的注入

// UserServiceImpl.java - 真实实现
@Service
public class UserServiceImpl implements UserService {
    public UserDTO getUserById(Long id) {
        return userMapper.selectById(id);  // 直接查数据库
    }
}

// ArtworkService.java - 使用
@Service
public class ArtworkService {
    @Autowired
    private UserService userService;  // 注入真实实现类

    public void test() {
        UserDTO user = userService.getUserById(123L);  // 内存调用
    }
}

本质:同一个 JVM,方法直接调用

Feign 的微服务调用

// UserClient.java - 只是接口定义,没有实现类
@FeignClient(name = "user-service")
public interface UserClient {
    @GetMapping("/user/{id}")
    UserDTO getUserById(@PathVariable Long id);
}

// ArtworkService.java - 使用
@Service
public class ArtworkService {
    @Autowired
    private UserClient userClient;  // 注入 HTTP 代理对象

    public void test() {
        UserDTO user = userClient.getUserById(123L);  // HTTP 请求调用
    }
}

本质:跨 JVM,HTTP 网络调用

对比维度 同项目注入 Feign 注入
注入的是 真实的实现类 HTTP 代理对象
调用方式 方法调用(内存) HTTP 请求(网络)
代码位置 同一个 JVM 不同 JVM/不同机器
类比 直接叫同事帮忙 打电话给其他公司

手写等价代码

Feign 写法(框架帮你做)

@FeignClient(name = "user-service")
public interface UserClient {
    @GetMapping("/user/{id}")
    UserDTO getUserById(@PathVariable Long id);
}

UserDTO user = userClient.getUserById(123L);

没有 Feign 你得这样写

@Service
public class UserClient {

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    private DiscoveryClient discoveryClient;  // Nacos 客户端

    public UserDTO getUserById(Long id) {
        // 1. 从 Nacos 获取 user-service 的地址
        List<ServiceInstance> instances =
            discoveryClient.getInstances("user-service");
        String url = instances.get(0).getUri().toString();

        // 2. 拼接请求地址
        String fullUrl = url + "/user/" + id;

        // 3. 发 HTTP GET 请求
        return restTemplate.getForObject(fullUrl, UserDTO.class);
    }
}

4.2 实战使用

基础定义

/**
 * Feign 客户端定义
 */
@FeignClient(
    name = "user-service",                      // 服务名
    path = "/user",                             // 统一路径前缀
    fallbackFactory = UserClientFallback.class  // 降级处理
)
public interface UserClient {

    // GET 请求 - 获取单条
    @GetMapping("/{id}")
    UserDTO getById(@PathVariable("id") Long id);

    // POST 请求 - 创建(JSON Body)
    @PostMapping
    UserDTO create(@RequestBody UserCreateDTO dto);

    // 表单提交
    @PostMapping(value = "/form",
                 consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    UserDTO createByForm(@RequestParam("name") String name,
                         @RequestParam("age") Integer age);

    // 文件上传
    @PostMapping(value = "/upload",
                 consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    String upload(@RequestPart("file") MultipartFile file);

    // 传递请求头
    @GetMapping("/info")
    UserDTO getInfo(@RequestHeader("X-User-Id") Long userId);

    // 批量查询
    @GetMapping("/batch")
    List<UserDTO> batchGet(@RequestParam("ids") List<Long> ids);
}

使用示例

@Service
public class ArtworkService {

    @Autowired
    private UserClient userClient;

    public ArtworkVO getArtworkDetail(Long id) {
        // 查询作品
        Artwork artwork = artworkMapper.selectById(id);

        // 调用用户服务获取作者信息(HTTP 请求)
        UserDTO author = userClient.getById(artwork.getAuthorId());

        // 组装返回
        return new ArtworkVO(artwork, author);
    }
}

4.3 降级处理

降级工厂实现

当远程服务不可用时,返回兜底数据,避免级联故障。

/**
 * Feign 降级工厂:当调用失败时执行
 */
@Component
public class UserClientFallback implements FallbackFactory<UserClient> {

    private static final Logger log = LoggerFactory.getLogger(UserClientFallback.class);

    @Override
    public UserClient create(Throwable cause) {
        // 记录异常日志
        log.error("调用 user-service 失败", cause);

        // 返回一个实现了 UserClient 接口的匿名对象
        return new UserClient() {

            @Override
            public UserDTO getById(Long id) {
                // 返回兜底数据
                return UserDTO.builder()
                    .id(id)
                    .name("用户服务不可用")
                    .avatar("default_avatar.jpg")
                    .build();
            }

            @Override
            public UserDTO create(UserCreateDTO dto) {
                // 抛出异常,表示服务不可用
                throw new BusinessException("用户服务暂不可用,请稍后重试");
            }

            @Override
            public UserDTO createByForm(String name, Integer age) {
                throw new BusinessException("用户服务暂不可用");
            }

            @Override
            public String upload(MultipartFile file) {
                throw new BusinessException("用户服务暂不可用");
            }

            @Override
            public UserDTO getInfo(Long userId) {
                return UserDTO.builder()
                    .id(userId)
                    .name("用户信息不可用")
                    .build();
            }

            @Override
            public List<UserDTO> batchGet(List<Long> ids) {
                return new ArrayList<>();  // 返回空列表
            }
        };
    }
}

4.4 配置详解

Feign 配置(application.yml)

feign:
  client:
    config:
      default:                                    # 全局配置
        connectTimeout: 5000                      # 连接超时(毫秒)
        readTimeout: 10000                        # 读取超时(毫秒)
        loggerLevel: BASIC                        # 日志级别

      user-service:                              # 针对特定服务的配置
        connectTimeout: 3000
        readTimeout: 5000
        loggerLevel: FULL

  circuitbreaker:
    enabled: true                                 # 启用熔断器

  compression:
    request:
      enabled: true                               # 启用请求压缩
      mime-types: application/json                # 压缩的 MIME 类型
      min-request-size: 2048                      # 最小压缩大小(字节)
    response:
      enabled: true                               # 启用响应压缩

# Feign 日志配置
logging:
  level:
    com.example.client: DEBUG                    # Feign 客户端包路径

日志级别说明

  • NONE:不记录
  • BASIC:记录请求方法、URL、响应状态码、执行时间
  • HEADERS:在 BASIC 基础上,记录请求和响应的 Header
  • FULL:记录完整的请求和响应,包括 Body

4.5 请求头传递(拦截器)

问题场景

前端发送请求给 artwork-service,携带用户信息(X-User-Id)。artwork-service 调用 user-service 时,需要将用户信息传递下去。

解决方案

/**
 * Feign 请求拦截器
 * 将当前请求的 Header 传递给下游服务
 */
@Component
public class FeignRequestInterceptor implements RequestInterceptor {

    private static final Logger log = LoggerFactory.getLogger(FeignRequestInterceptor.class);

    @Override
    public void apply(RequestTemplate template) {
        // 获取当前请求的上下文
        ServletRequestAttributes attributes =
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

        if (attributes != null) {
            HttpServletRequest request = attributes.getRequest();

            // ① 传递用户信息
            String userId = request.getHeader("X-User-Id");
            if (userId != null) {
                template.header("X-User-Id", userId);
                log.debug("传递 X-User-Id: {}", userId);
            }

            // ② 传递链路追踪 ID
            String traceId = request.getHeader("X-Trace-Id");
            if (traceId != null) {
                template.header("X-Trace-Id", traceId);
                log.debug("传递 X-Trace-Id: {}", traceId);
            }

            // ③ 传递其他必要的 Header
            String token = request.getHeader("Authorization");
            if (token != null) {
                template.header("Authorization", token);
            }
        }
    }
}

调用链路

前端请求
  ↓ (Header: X-User-Id=123, X-Trace-Id=abc)
artwork-service (接收请求)
  ↓
FeignRequestInterceptor (拦截器添加 Header)
  ↓
user-service (接收请求,拿到 X-User-Id=123, X-Trace-Id=abc)

4.6 完整实战例子

场景:获取作品详情

@RestController
@RequestMapping("/artwork")
public class ArtworkController {

    @Autowired
    private ArtworkService artworkService;

    @GetMapping("/{id}")
    public ArtworkVO detail(@PathVariable Long id) {
        return artworkService.getArtworkDetail(id);
    }
}

@Service
public class ArtworkService {

    @Autowired
    private ArtworkMapper artworkMapper;

    @Autowired
    private UserClient userClient;

    public ArtworkVO getArtworkDetail(Long id) {
        // 1. 查询作品信息
        Artwork artwork = artworkMapper.selectById(id);
        if (artwork == null) {
            throw new BusinessException("作品不存在");
        }

        // 2. 调用 user-service 获取作者信息
        //    如果 user-service 不可用,会执行降级方法返回兜底数据
        UserDTO author = userClient.getById(artwork.getAuthorId());

        // 3. 组装返回对象
        return ArtworkVO.builder()
            .id(artwork.getId())
            .title(artwork.getTitle())
            .description(artwork.getDescription())
            .author(author)
            .createTime(artwork.getCreateTime())
            .build();
    }
}

4.7 小结

三大核心特性

  1. 服务发现与负载均衡:Feign 自动从注册中心(Nacos)查询服务地址,支持负载均衡
  2. 降级处理:远程服务不可用时,使用兜底数据,防止级联故障
  3. 请求头传递:通过拦截器自动传递链路信息和用户信息

框架的核心价值

框架 = 把复杂的事情封装起来,让你用简单的方式调用

框架 屏蔽了什么
Feign HTTP 请求细节、服务发现、负载均衡
MyBatis JDBC 操作细节
Spring 对象创建和依赖管理
JPA SQL 编写

关键洞察

没有编译字节码 → 不能通过反射直接注入实现类

框架动态生成代理 → 代理对象实现了你定义的接口

代理对象负责 HTTP 请求 → 调用方法时,实际发送网络请求

使用者无感知 → 写法和以前完全一样,框架自动处理复杂的网络调用

降级保障可用性 → 远程调用失败不会导致本服务不可用


五、负载均衡(LoadBalancer)

5.1 负载均衡原理

负载均衡策略-ca9c0faa

5.2 负载均衡策略

/**
 * 自定义负载均衡策略
 */
@Configuration
public class LoadBalancerConfig {

    // 随机策略
    @Bean
    public ReactorLoadBalancer<ServiceInstance> randomLoadBalancer(
            Environment environment,
            LoadBalancerClientFactory clientFactory) {
        String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
        return new RandomLoadBalancer(
            clientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class),
            name
        );
    }
}

// 应用到特定服务
@LoadBalancerClient(name = "user-service", configuration = LoadBalancerConfig.class)
public class UserServiceLoadBalancerConfig {
}

5.3 同集群优先调用

# 服务配置
spring:
  cloud:
    nacos:
      discovery:
        cluster-name: Shanghai    # 集群名

# user-service 部署两个集群
# 上海机房:cluster-name=Shanghai
# 北京机房:cluster-name=Beijing

# artwork-service 在上海机房,会优先调用上海的 user-service

5.4 负载均衡策略对比(补充)

客户端负载均衡 vs 服务端负载均衡

服务端 LB(Nginx/F5):消费者 → [负载均衡器] → 服务实例
  • LB 集中部署,消费者不感知实例列表
  • LB 是流量咽喉,需做高可用

客户端 LB(Ribbon / Spring Cloud LoadBalancer):
  消费者自己拉取实例列表(从 Nacos)→ 本地自己做均衡决策 → 直连实例
  • 少一跳网络、无中心单点
  • 决策依据可以更"业务化"(如同集群优先)
  Spring Cloud 体系用的就是客户端 LB:Feign/Gateway 里的 lb:// 都是它。

常见策略对比

策略 做法 优点 缺点 适用
轮询 RoundRobin 依次分发 绝对公平 不考虑机器差异 实例配置相同
随机 Random 随机挑一台 实现简单、无状态 短期不均(大样本趋均) 实例配置相同
加权轮询/随机 按权重比例分发 承载差异化机器 权重需人工维护 机器配置不均
最少活跃 LeastActive 优先给活跃调用数少的实例 自适应慢节点 实现复杂 调用耗时差异大
一致性哈希 同参数请求落到同一实例 会话/缓存亲和 节点变动需迁移部分数据 有状态连接、缓存命中敏感
同集群优先 同机房/同集群优先,跨集群兜底 降低网络延迟 集群内容量需兜底 多机房部署(对应 5.3)
ℹ️Ribbon 已停更

Ribbon(Netflix,进维护模式)→ Spring Cloud LoadBalancer(官方替代,响应式实现)。新项目不再引 Ribbon;旧代码里见到的 IRule@RibbonClient 都是上一代 API。


六、配置中心(Nacos Config)

6.1 为什么需要配置中心

配置中心-69c33e16

6.2 配置中心使用

# bootstrap.yml(必须用 bootstrap,优先于 application 加载)
spring:
  application:
    name: user-service
  profiles:
    active: dev
  cloud:
    nacos:
      config:
        server-addr: 127.0.0.1:8848
        file-extension: yaml            # 配置文件格式
        namespace: dev                  # 命名空间
        group: DEFAULT_GROUP            # 分组

        # 共享配置(多服务共用)
        shared-configs:
          - data-id: common-mysql.yaml
            refresh: true               # 动态刷新
          - data-id: common-redis.yaml
            refresh: true

        # 扩展配置
        extension-configs:
          - data-id: user-special.yaml
            refresh: true

6.3 配置文件命名规则

Nacos 中的 Data ID 命名规则:
${spring.application.name}-${spring.profiles.active}.${file-extension}

例如:
  spring.application.name = user-service
  spring.profiles.active = dev
  file-extension = yaml

  → Data ID = user-service-dev.yaml

配置加载顺序(后面覆盖前面):
1. user-service.yaml              # 通用配置
2. user-service-dev.yaml          # 环境配置
3. shared-configs                 # 共享配置
4. extension-configs              # 扩展配置

6.4 动态刷新配置

/**
 * 配置类:自动刷新
 */
@Data
@Component
@ConfigurationProperties(prefix = "app.feature")
@RefreshScope  // 关键注解:配置变更时刷新 Bean
public class FeatureConfig {

    private Boolean enableNewFeature;
    private Integer maxRetryCount;
    private List<String> whiteList;
}

// 使用
@Service
public class SomeService {

    @Autowired
    private FeatureConfig featureConfig;

    public void doSomething() {
        if (featureConfig.getEnableNewFeature()) {
            // 新功能逻辑
            // Nacos 中修改配置后,这里会自动获取新值
        }
    }
}

6.5 动态刷新原理:长轮询(补充)

Nacos Config 的"改了配置、服务自动生效",靠的是客户端长轮询(Long Polling),而不是 WebSocket:

客户端启动:
① 全量拉取一次配置(本地快照落盘——Nacos 挂了也能用快照兜底启动)
② 对每个监听的 dataId 发一个长轮询请求:
   「这个配置的 md5 是 xxx,30 秒内有变化就立刻告诉我,没变化就等到超时」

服务端:
• 收到长轮询后挂住请求(不立即返回)
• 期间任何人改了配置 → 立即比较 md5 → 不一致马上返回变化的 dataId(毫秒级感知)
• 30s 内无变化 → 返回空,客户端立刻发起下一轮长轮询(所以叫"长轮询"而非轮询间隔等待)
③ 客户端拿到变化的 dataId → 拉取最新配置 → 发布 RefreshEvent@RefreshScope / @ConfigurationPropertiesBean 被重建/重新绑定

为什么选长轮询而不是其他方案:

方案 问题
定时轮询 延迟 = 轮询间隔;大量无效请求
长连接推送 服务端要维护海量连接状态,实现与运维复杂
长轮询 无变化时挂住请求(服务端几乎零开销)、有变化毫秒级返回、HTTP 兼容性最好
ℹ️@RefreshScope 的代价

刷新时 Bean 会被销毁重建——有状态的 Bean(连接池、定时任务)别标 @RefreshScope@ConfigurationProperties 绑定的配置无需 @RefreshScope 也会自动重绑定(Spring Cloud 2020+ 默认行为)。


七、熔断降级(Sentinel)

7.1 为什么需要熔断

熔断-6a38baaa

7.2 Sentinel 核心概念

Sentinel-70545225

7.3 Sentinel 使用

/**
 * 资源定义与降级
 */
@Service
public class OrderService {

    @Autowired
    private UserClient userClient;

    /**
     * @SentinelResource 定义资源
     * blockHandler: 触发限流/熔断时调用
     * fallback: 业务异常时调用
     */
    @SentinelResource(
        value = "createOrder",
        blockHandler = "createOrderBlockHandler",
        fallback = "createOrderFallback"
    )
    public Order createOrder(OrderDTO dto) {
        // 1. 调用用户服务校验
        UserDTO user = userClient.getById(dto.getUserId());

        // 2. 业务逻辑
        return orderMapper.insert(order);
    }

    // 限流/熔断时的处理
    public Order createOrderBlockHandler(OrderDTO dto, BlockException e) {
        log.warn("创建订单被限流: {}", e.getMessage());
        throw new BusinessException("系统繁忙,请稍后重试");
    }

    // 业务异常的降级
    public Order createOrderFallback(OrderDTO dto, Throwable e) {
        log.error("创建订单异常", e);
        throw new BusinessException("创建订单失败: " + e.getMessage());
    }
}

7.4 Sentinel 规则配置

spring:
  cloud:
    sentinel:
      transport:
        dashboard: 127.0.0.1:8080     # Sentinel 控制台
        port: 8719                     # 与控制台通信端口
      eager: true                      # 立即初始化

      # 规则持久化到 Nacos
      datasource:
        flow:
          nacos:
            server-addr: 127.0.0.1:8848
            data-id: ${spring.application.name}-flow-rules
            group-id: SENTINEL_GROUP
            data-type: json
            rule-type: flow

7.5 熔断器三态模型与限流算法(补充)

雪崩是怎么发生的

auction-service 变慢(如 DB 慢查询)
  → artwork-service 调它,线程阻塞等待 30s 超时
  → 等待期间请求持续涌入,Tomcat 线程池被占满
  → artwork-service 自己也失去响应
  → 网关到 artwork 的调用也开始超时堆积……
  → 故障沿调用链逐级向上蔓延 = 雪崩

熔断的本质:在调用方"熔断"这条必堵的路,用快速失败换整个系统的存活。

熔断器三态状态机

                失败率/慢调用比例 ≥ 阈值
   ┌─────────┐ ─────────────────────→ ┌─────────┐
   │ Closed  │                        │  Open   │
   │ (闭合)   │ ←──────────────────── │ (打开)   │
   └─────────┘   探测成功,计数重置      └────┬────┘
        ↑                                 │ 熔断时长到(如 10s)
        │ 连续失败                          ▼
        │                            ┌───────────┐
        └────────────────────────────│ Half-Open │
              探测失败,重新打开        │ (半开)     │
                                    └───────────┘
              放行 1 个探测请求:成功 → 恢复 Closed;失败 → 仍回 Open
状态 行为 转换条件
Closed 闭合 正常放行,统计失败率 滑动窗口内失败率/慢调用比例超阈值 → Open
Open 打开 直接快速失败(不真调用) 熔断计时结束 → Half-Open
Half-Open 半开 只放行一个探测请求 探测成功 → Closed;失败 → Open(再熔断一轮)

慢调用熔断:不止"报错"算失败,响应超过最大 RT 也算(慢调用拖死线程池比报错更危险)。

四种限流算法

算法 做法 缺陷 Sentinel 支持
固定窗口计数 每分钟最多 N 个 临界突刺:窗口边界两倍流量 ✅(基础)
滑动窗口 把窗口切成小格滚动统计 精度高、略费内存 ✅(默认统计结构)
漏桶 恒定速率放行,超速排队/丢弃 无法应对合理突发 ✅(匀速排队 WarmUp 相关)
令牌桶 匀速发令牌,请求拿到令牌才过 突发友好(攒的令牌可消费)

Sentinel vs Hystrix

维度 Sentinel Hystrix(停更进维护)
隔离策略 信号量隔离(并发线程数) 线程池隔离(重)+ 信号量
熔断策略 慢调用比例 / 异常比例 / 异常数 异常比例
实时规则 动态规则 + 控制台实时改 需结合配置中心
流量整形 QPS/并发/关联/链路/匀速 简单
生态 Spring Cloud Alibaba 官方推荐 Netflix 时代产物

八、链路追踪

8.1 为什么需要链路追踪

链路追踪-c053729c

8.2 简单实现(TraceId 传递)

/**
 * 网关:生成 TraceId
 */
@Component
public class TraceIdFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String traceId = UUID.randomUUID().toString().replace("-", "");

        ServerHttpRequest request = exchange.getRequest().mutate()
            .header("X-Trace-Id", traceId)
            .build();

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

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

/**
 * 业务服务:接收并记录 TraceId
 */
@Component
public class TraceIdInterceptor implements HandlerInterceptor {

    private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request,
                            HttpServletResponse response,
                            Object handler) {
        String traceId = request.getHeader("X-Trace-Id");
        if (traceId == null) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }
        TRACE_ID.set(traceId);
        MDC.put("traceId", traceId);  // 放入日志上下文
        return true;
    }

    @Override
    public void afterCompletion(...) {
        TRACE_ID.remove();
        MDC.remove("traceId");
    }

    public static String getTraceId() {
        return TRACE_ID.get();
    }
}

// logback.xml 配置
// <pattern>%d{yyyy-MM-dd HH:mm:ss} [%X{traceId}] %-5level %logger - %msg%n</pattern>

// 日志输出
// 2024-01-15 10:30:00 [abc123] INFO OrderService - 创建订单
// 2024-01-15 10:30:00 [abc123] INFO UserClient - 调用用户服务

8.3 链路追踪核心概念:Trace 与 Span(补充)

8.2 的 TraceId 手写版只能回答"一个请求经过哪些服务";工业级链路追踪还要回答"每一段耗时多少、谁是谁的父亲"。这就需要 Span 模型(源自 Google Dapper 论文):

Trace(一次完整请求的全局链路,唯一 traceId)
│
├── Span A: gateway 路由转发          [====]           (root span)
│     ├── Span B: artwork-service 查作品   [====]
│     │     ├── Span C: 查 MySQL           [=]
│     │     └── Span D: Feignuser-svc  [==]
│     │           └── Span E: user 查库     [=]
│     └── Span F: 返回渲染                  [=]

每个 Span 记录:spanIdparentId、开始时间、耗时、服务名、标签(http.method 等)
traceId 全链路唯一;spanId 标识一段调用;parentId 串成调用树。
概念 类比 作用
Trace 一次快递单号 串联整条调用链
Span 运单里的一段行程 一个调用单元(HTTP/DB/RPC 都算)
parentId 上一段行程 还原树形依赖关系
采样 Sampling 只记录部分快递单 高并发下全量记录成本过高,按比例采样

手写 TraceId vs 成熟方案

维度 8.2 的手写 TraceId SkyWalking Zipkin
接入方式 手写过滤器/拦截器 + MDC Java Agent 字节码增强,零代码侵入 依赖 Brave/ sleuth 埋点
Span 级粒度 ❌ 只有全局 traceId ✅ 方法/SQL/MQ 全覆盖
拓扑图/告警 ✅ 内置 UI + 告警 基础 UI
存储 无(只在日志里) ES/H2/BanyanDB ES/Cassandra/MySQL
适用 小项目、纯日志排查 国内主流、生产推荐 K8s/海外生态、OpenTelemetry 兼容
ℹ️演进建议

对应 02 篇演进路线"阶段二:链路追踪(OpenTelemetry)"——手写 TraceId 适合理解原理,生产环境上 SkyWalking(Agent 无侵入)或 OpenTelemetry + 各家后端。注意 8.2 的 X-Trace-Id Header 与 Feign 拦截器(4.5)正是 SkyWalking 之外自定义协议传递上下文的思路,两者原理同源。


九、项目结构最佳实践

9.1 多模块项目结构

art-auction-platform/
├── pom.xml                           # 父 POM
│
├── common/                           # 公共模块
│   ├── common-core/                  # 核心工具
│   │   ├── src/main/java/
│   │   │   ├── exception/           # 异常定义
│   │   │   │   ├── BusinessException.java
│   │   │   │   └── GlobalExceptionHandler.java
│   │   │   ├── result/              # 统一返回
│   │   │   │   └── Result.java
│   │   │   └── utils/               # 工具类
│   │   │       └── JwtUtil.java
│   │   └── pom.xml
│   │
│   └── common-api/                   # 公共 API(DTO/VO)
│       ├── src/main/java/
│       │   └── dto/
│       │       ├── UserDTO.java
│       │       └── ArtworkDTO.java
│       └── pom.xml
│
├── gateway/                          # 网关
│   ├── src/main/java/
│   │   ├── GatewayApplication.java
│   │   └── filter/
│   │       ├── AuthFilter.java
│   │       └── TraceIdFilter.java
│   ├── src/main/resources/
│   │   └── application.yml
│   └── pom.xml
│
├── user-service/                     # 用户服务
│   ├── src/main/java/
│   │   ├── UserServiceApplication.java
│   │   ├── controller/
│   │   │   └── UserController.java
│   │   ├── service/
│   │   │   ├── UserService.java
│   │   │   └── impl/
│   │   │       └── UserServiceImpl.java
│   │   ├── mapper/
│   │   │   └── UserMapper.java
│   │   ├── entity/
│   │   │   └── User.java
│   │   └── client/                  # Feign 客户端
│   │       └── ArtworkClient.java
│   ├── src/main/resources/
│   │   ├── application.yml
│   │   └── mapper/
│   │       └── UserMapper.xml
│   └── pom.xml
│
├── artwork-service/                  # 艺术品服务
│   └── ...
│
└── auction-service/                  # 拍卖服务
    └── ...

9.2 统一返回格式

/**
 * 统一返回结果
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {

    private int code;
    private String message;
    private T data;
    private long timestamp;

    public static <T> Result<T> success() {
        return success(null);
    }

    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(200);
        result.setMessage("success");
        result.setData(data);
        result.setTimestamp(System.currentTimeMillis());
        return result;
    }

    public static <T> Result<T> error(String message) {
        return error(500, message);
    }

    public static <T> Result<T> error(int code, String message) {
        Result<T> result = new Result<>();
        result.setCode(code);
        result.setMessage(message);
        result.setTimestamp(System.currentTimeMillis());
        return result;
    }
}

9.3 全局异常处理

/**
 * 业务异常
 */
@Getter
public class BusinessException extends RuntimeException {

    private final int code;

    public BusinessException(String message) {
        this(500, message);
    }

    public BusinessException(int code, String message) {
        super(message);
        this.code = code;
    }
}

/**
 * 全局异常处理器
 */
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 业务异常
    @ExceptionHandler(BusinessException.class)
    public Result<?> handleBusinessException(BusinessException e) {
        log.warn("业务异常: {}", e.getMessage());
        return Result.error(e.getCode(), e.getMessage());
    }

    // 参数校验异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<?> handleValidException(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldErrors().stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .collect(Collectors.joining(", "));
        return Result.error(400, message);
    }

    // Feign 调用异常
    @ExceptionHandler(FeignException.class)
    public Result<?> handleFeignException(FeignException e) {
        log.error("服务调用异常: {}", e.getMessage());
        return Result.error("服务暂时不可用");
    }

    // 兜底异常
    @ExceptionHandler(Exception.class)
    public Result<?> handleException(Exception e) {
        log.error("系统异常", e);
        return Result.error("系统繁忙,请稍后重试");
    }
}

十、版本兼容参考表

10.1 Spring Cloud Alibaba 版本

Spring Boot Spring Cloud Spring Cloud Alibaba Nacos
3.3.x 2023.0.3 2023.0.1.2 2.3.0+
3.2.x 2023.0.0 2022.0.0.0 2.2.0+
3.0.x 2022.0.x 2022.0.0.0 2.2.0+
2.7.x 2021.0.x 2021.0.5.0 2.1.0+
2.6.x 2021.0.x 2021.0.4.0 2.0.4+

10.2 常见依赖版本

<properties>
    <java.version>17</java.version>
    <spring-cloud.version>2023.0.3</spring-cloud.version>
    <spring-cloud-alibaba.version>2023.0.1.2</spring-cloud-alibaba.version>
    <mybatis-plus.version>3.5.7</mybatis-plus.version>
    <hutool.version>5.8.25</hutool.version>
    <knife4j.version>4.4.0</knife4j.version>
</properties>

十一、常见问题排查

11.1 服务注册失败

# 检查清单
1. Nacos 是否启动? curl http://localhost:8848/nacos
2. 服务名是否配置? spring.application.name
3. Nacos 地址是否正确? spring.cloud.nacos.discovery.server-addr
4. 网络是否通? ping nacos-server
5. 防火墙是否开放? 8848, 9848, 9849 端口

11.2 服务调用失败

# 503 Service Unavailable
1. 服务是否在 Nacos 注册?
2. 注册的 IP 是否可达?(虚拟网卡问题)
3. 服务是否健康?

# 解决虚拟网卡问题
spring:
  cloud:
    nacos:
      discovery:
        ip: 192.168.1.100  # 指定可达 IP

11.3 Gateway 启动报错

# 问题:Spring MVC found on classpath
# 原因:Gateway 基于 WebFlux,不能与 MVC 共存
# 解决:移除 spring-boot-starter-web 依赖

11.4 Feign 调用超时

# 调整超时时间
feign:
  client:
    config:
      default:
        connectTimeout: 10000
        readTimeout: 60000

# 或针对特定服务
      user-service:
        readTimeout: 30000

十二、启动与测试命令

# 1. 启动 Nacos
cd nacos/bin && ./startup.sh -m standalone

# 2. 启动服务(可用 IDE 或命令行)
cd user-service && mvn spring-boot:run
cd artwork-service && mvn spring-boot:run
cd gateway && mvn spring-boot:run

# 3. 验证注册
curl http://localhost:8848/nacos/v1/ns/instance/list?serviceName=user-service

# 4. 测试网关路由
curl http://localhost:8080/api/user/health

# 5. 查看 Gateway 路由
curl http://localhost:8080/actuator/gateway/routes

# 6. 打包
mvn clean package -DskipTests

# 7. 运行 JAR
java -jar target/user-service-1.0.0.jar --spring.profiles.active=prod

总结

微服务组件-4738bfbb

项目分区导航:⬅️ 00-微服务与中间件 | 01-微服务 | ➡️ 02-微服务架构划分与协作:最小最佳实践