--- title: "01-微服务" created: 2025-11-30 tags: - 项目 aliases: - 微服务 --- # 微服务 ## **一、微服务核心概念** ### **1.1 什么是微服务** ![[微服务是什么-0be9c639.jpg]] **“感觉本质就是做了层封装,有什么好处?”** **对于小项目(如学习项目):说实话,好处不大,反而增加复杂度。** **对于大型项目,好处明显:** **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 - 各自独立开发、测试、部署 ``` **代价:** | **方面** | **单体** | **微服务** | | --- | --- | --- | | 部署复杂度 | 低 | 高 | | 运维成本 | 低 | 高 | | 调试难度 | 简单 | 复杂(链路追踪) | | 数据一致性 | 简单(本地事务) | 复杂(分布式事务) | | 适合场景 | 小团队、简单业务 | 大团队、复杂业务 | > [!info] 概念源流补充:微服务从哪来 > **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.jpg]] ## **二、注册中心(Nacos)** ### **2.1 为什么需要注册中心** ![[为什么需要注册中心-b8159e52.jpg]] ### **2.2 服务注册与发现流程** ![[服务注册与发现流程-ec26abe6.jpg]] ### **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.jpg]] ### **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 可改。 ``` #### 服务上下线的感知时序 ``` 上线:服务启动 → 注册到 Nacos → Nacos 写内存注册表并同步给其他 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.jpg]] ### **3.2 路由配置详解** ```yaml 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.jpg]] ### **3.4 自定义全局过滤器** ```java /** * 全局鉴权过滤器 */ @Component @Order(-1) // 数字越小优先级越高 public class AuthGlobalFilter implements GlobalFilter { // 白名单:不需要鉴权的路径 private static final List WHITE_LIST = Arrays.asList( "/api/user/login", "/api/user/register", "/api/artwork/list" ); @Override public Mono 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 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 跨域配置** ```yaml 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` 是响应式编程;这也解释了 11.3 的报错——classpath 里不能有 spring-boot-starter-web(MVC 是同步阻塞模型,二者容器模型冲突) --- ## **四、微服务调用(OpenFeign)** ### **4.1 核心原理** #### 为什么能在 artwork-service 中注入 user-service 的 UserClient? 之前注入是在同一个项目文件里面 一起编译 能找到class 从而反射注入依赖 但这里都分开了 是怎么在Artwork服务中用user服务的? 我能想象的到的只是user开放接口供Artwork调用 类似于调云平台的ai接口 你的直觉是对的:**不是真的注入了代码,而是注入了一个 HTTP 客户端代理**。本质就是调接口,类似于调用云平台的 AI 接口。 #### 运行时的真相 ```java // 你写的 @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(类似 MyBatis 的 Mapper) 3️⃣ 把代理对象注册到 Spring 容器 4️⃣ 当你 @Autowired UserClient 时,拿到的就是这个代理对象 调用时: userClient.getUserById(123L) ↓ 代理对象的 getUserById 方法 ↓ ① 从 Nacos 查询 user-service 的地址 ② 拼接 URL ③ 发送 HTTP 请求 ④ 解析响应 ⑤ 返回结果对象 ``` ![[服务间调用-cefdc4a4.jpg]] #### 同项目注入 vs 微服务调用对比 同项目内的注入 ```java // 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 的微服务调用 ```java // 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 写法(框架帮你做)** ```typescript @FeignClient(name = "user-service") public interface UserClient { @GetMapping("/user/{id}") UserDTO getUserById(@PathVariable Long id); } UserDTO user = userClient.getUserById(123L); ``` **没有 Feign 你得这样写** ```java @Service public class UserClient { @Autowired private RestTemplate restTemplate; @Autowired private DiscoveryClient discoveryClient; // Nacos 客户端 public UserDTO getUserById(Long id) { // 1. 从 Nacos 获取 user-service 的地址 List 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 实战使用** #### 基础定义 ```typescript /** * 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 batchGet(@RequestParam("ids") List ids); } ``` #### 使用示例 ```java @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 降级处理** #### 降级工厂实现 当远程服务不可用时,返回兜底数据,避免级联故障。 ```java /** * Feign 降级工厂:当调用失败时执行 */ @Component public class UserClientFallback implements FallbackFactory { 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 batchGet(List ids) { return new ArrayList<>(); // 返回空列表 } }; } } ``` --- ### **4.4 配置详解** #### Feign 配置(application.yml) ```yaml 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 时,需要将用户信息传递下去。 #### 解决方案 ```java /** * 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 完整实战例子** #### 场景:获取作品详情 ```java @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.jpg]] ### **5.2 负载均衡策略** ```java /** * 自定义负载均衡策略 */ @Configuration public class LoadBalancerConfig { // 随机策略 @Bean public ReactorLoadBalancer 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 同集群优先调用** ```yaml # 服务配置 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) | > [!note] Ribbon 已停更 > Ribbon(Netflix,进维护模式)→ Spring Cloud LoadBalancer(官方替代,响应式实现)。新项目不再引 Ribbon;旧代码里见到的 `IRule`、`@RibbonClient` 都是上一代 API。 --- ## **六、配置中心(Nacos Config)** ### **6.1 为什么需要配置中心** ![[配置中心-69c33e16.jpg]] ### **6.2 配置中心使用** ```yaml # 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 动态刷新配置** ```java /** * 配置类:自动刷新 */ @Data @Component @ConfigurationProperties(prefix = "app.feature") @RefreshScope // 关键注解:配置变更时刷新 Bean public class FeatureConfig { private Boolean enableNewFeature; private Integer maxRetryCount; private List 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 / @ConfigurationProperties 的 Bean 被重建/重新绑定 ``` 为什么选长轮询而不是其他方案: | 方案 | 问题 | | --- | --- | | 定时轮询 | 延迟 = 轮询间隔;大量无效请求 | | 长连接推送 | 服务端要维护海量连接状态,实现与运维复杂 | | **长轮询** ✅ | 无变化时挂住请求(服务端几乎零开销)、有变化毫秒级返回、HTTP 兼容性最好 | > [!note] @RefreshScope 的代价 > 刷新时 Bean 会被销毁重建——**有状态的 Bean(连接池、定时任务)别标 @RefreshScope**;`@ConfigurationProperties` 绑定的配置无需 @RefreshScope 也会自动重绑定(Spring Cloud 2020+ 默认行为)。 --- ## **七、熔断降级(Sentinel)** ### **7.1 为什么需要熔断** ![[熔断-6a38baaa.jpg]] ### **7.2 Sentinel 核心概念** ![[Sentinel-70545225.jpg]] ### **7.3 Sentinel 使用** ```java /** * 资源定义与降级 */ @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.jpg]] ### **8.2 简单实现(TraceId 传递)** ```java /** * 网关:生成 TraceId */ @Component public class TraceIdFilter implements GlobalFilter, Ordered { @Override public Mono 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 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 配置 // %d{yyyy-MM-dd HH:mm:ss} [%X{traceId}] %-5level %logger - %msg%n // 日志输出 // 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: Feign 调 user-svc [==] │ │ └── Span E: user 查库 [=] │ └── Span F: 返回渲染 [=] 每个 Span 记录:spanId、parentId、开始时间、耗时、服务名、标签(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 兼容 | > [!note] 演进建议 > 对应 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 { private int code; private String message; private T data; private long timestamp; public static Result success() { return success(null); } public static Result success(T data) { Result result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); result.setTimestamp(System.currentTimeMillis()); return result; } public static Result error(String message) { return error(500, message); } public static Result error(int code, String message) { Result result = new Result<>(); result.setCode(code); result.setMessage(message); result.setTimestamp(System.currentTimeMillis()); return result; } } ``` ### **9.3 全局异常处理** ```java /** * 业务异常 */ @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 常见依赖版本** ``` 17 2023.0.3 2023.0.1.2 3.5.7 5.8.25 4.4.0 ``` --- ## **十一、常见问题排查** ### **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 服务调用失败** ```yaml # 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 调用超时** ```yaml # 调整超时时间 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.jpg]] --- **项目分区导航**:⬅️ [[00-微服务与中间件|00-微服务与中间件]] | 01-微服务 | ➡️ [[02-微服务架构划分与协作:最小最佳实践|02-微服务架构划分与协作:最小最佳实践]]