微服务
一、微服务核心概念
1.1 什么是微服务
“感觉本质就是做了层封装,有什么好处?”
对于小项目(如学习项目):说实话,好处不大,反而增加复杂度。
对于大型项目,好处明显:
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 核心组件关系图
二、注册中心(Nacos)
2.1 为什么需要注册中心
2.2 服务注册与发现流程
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 命名空间与分组(环境隔离)
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 网关的职责
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 路由转发示例
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
→ 转发请求,响应原路返回
三个容易混淆的点:
- predicate 是"选路由",filter 是"改请求/响应"——先选定路由,才谈得上过滤器
- GlobalFilter 全局生效;GatewayFilter 只属于某条路由(如
StripPrefix=1),两者最终在同一链条上按 order 排序执行——自定义全局过滤器@Order(-1)就是在控制它在链中的位置 - 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(类似 MyBatis 的 Mapper)
3️⃣ 把代理对象注册到 Spring 容器
4️⃣ 当你 @Autowired UserClient 时,拿到的就是这个代理对象
调用时:
userClient.getUserById(123L)
↓
代理对象的 getUserById 方法
↓
① 从 Nacos 查询 user-service 的地址
② 拼接 URL
③ 发送 HTTP 请求
④ 解析响应
⑤ 返回结果对象
同项目注入 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 小结
三大核心特性
- 服务发现与负载均衡:Feign 自动从注册中心(Nacos)查询服务地址,支持负载均衡
- 降级处理:远程服务不可用时,使用兜底数据,防止级联故障
- 请求头传递:通过拦截器自动传递链路信息和用户信息
框架的核心价值
框架 = 把复杂的事情封装起来,让你用简单的方式调用
| 框架 | 屏蔽了什么 |
|---|---|
| Feign | HTTP 请求细节、服务发现、负载均衡 |
| MyBatis | JDBC 操作细节 |
| Spring | 对象创建和依赖管理 |
| JPA | SQL 编写 |
关键洞察
✅ 没有编译字节码 → 不能通过反射直接注入实现类
✅ 框架动态生成代理 → 代理对象实现了你定义的接口
✅ 代理对象负责 HTTP 请求 → 调用方法时,实际发送网络请求
✅ 使用者无感知 → 写法和以前完全一样,框架自动处理复杂的网络调用
✅ 降级保障可用性 → 远程调用失败不会导致本服务不可用
五、负载均衡(LoadBalancer)
5.1 负载均衡原理
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 为什么需要配置中心
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 / @ConfigurationProperties 的 Bean 被重建/重新绑定
为什么选长轮询而不是其他方案:
| 方案 | 问题 |
|---|---|
| 定时轮询 | 延迟 = 轮询间隔;大量无效请求 |
| 长连接推送 | 服务端要维护海量连接状态,实现与运维复杂 |
| 长轮询 ✅ | 无变化时挂住请求(服务端几乎零开销)、有变化毫秒级返回、HTTP 兼容性最好 |
@RefreshScope 的代价
刷新时 Bean 会被销毁重建——有状态的 Bean(连接池、定时任务)别标 @RefreshScope;@ConfigurationProperties 绑定的配置无需 @RefreshScope 也会自动重绑定(Spring Cloud 2020+ 默认行为)。
七、熔断降级(Sentinel)
7.1 为什么需要熔断
7.2 Sentinel 核心概念
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 为什么需要链路追踪
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: 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 兼容 |
演进建议
对应 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
总结
项目分区导航:⬅️ 00-微服务与中间件 | 01-微服务 | ➡️ 02-微服务架构划分与协作:最小最佳实践
💬 评论