Java进阶(1) | 微服务入门:从单体到 Spring Cloud 全家桶

📚 本系列系统梳理了 Java 开发的详细知识点,从基础语法到工程实践层层递进,内容详实成体系,建议先收藏再慢慢阅读,方便日后随时回顾查阅。

前言

前面 27 篇基本都是"一个应用、一个数据库"的视角------写 Controller、Service、Mapper,连上 MySQL/Redis 就能跑。但当系统越来越大(比如一个完整的广告投放平台,要做投放管理、计费、报表、用户中心),继续把所有功能塞进一个项目,会越来越难维护。这篇文章站在初学者的角度,梳理"微服务"到底是什么、为什么要拆、拆了之后会冒出哪些新问题,以及 Spring Cloud 生态里对应的解决方案------目标是建立一个完整的概念地图,不深挖某一个组件的所有细节。

1. 为什么需要微服务

1.1 单体应用:一开始很香

把"广告投放平台"的所有功能------用户管理、广告投放、计费结算、数据报表------都写在一个项目 里,打包成一个 jar,部署成一个进程。这就是单体架构(Monolith)

优点 缺点
开发简单:一个项目,一次启动,本地调试方便 代码越大越难维护:改一个小功能,可能要重新理解一大片代码
部署简单:一个 jar,一台服务器 牵一发动全身:改了报表模块的代码,整个应用都要重新打包、重新部署
调用简单:模块间是 Java 方法调用,没有网络开销 扩展不灵活:报表模块需要更多 CPU,但只能把整个应用一起扩容
技术栈绑定:整个项目只能用一种语言、一套框架

团队小、业务简单的时候,单体应用是最优选择------不要为了"赶时髦"而过早拆微服务,拆分本身是有代价的(后面会讲)。但当团队变大、业务模块变多,单体的"缺点"那一列会越来越明显:几十个人同时改一个项目,光是合并代码冲突就能耗费大量时间。

1.2 微服务:按业务拆成多个独立的应用

微服务架构(Microservices) 把单体应用按业务拆成多个独立部署、独立运行的小应用,每个小应用:

  • 只负责一块业务(用户服务、投放服务、计费服务、报表服务......)
  • 有自己的代码仓库、可以独立开发和部署
  • 一般有自己独立的数据库(不共享表)
  • 服务之间通过网络请求(HTTP/RPC)通信,不再是 Java 方法调用

1.3 微服务不是免费的午餐

拆分之后,每个模块内部 确实变简单了------投放服务只用关心投放逻辑。但模块之间的复杂度从"方法调用"变成了"网络调用",这就引入了一整套单体应用里根本不存在的新问题:

单体应用里 微服务里
模块 A 调用模块 B:一次本地方法调用,几乎不耗时,不会失败 服务 A 调用服务 B:要先知道 B 在哪台机器上、走网络请求,可能超时、可能失败
一个数据库事务搞定多表更新 跨服务的数据一致性,本地事务管不到别的服务
改配置改完重启一下就行 几十个服务实例,配置怎么统一改、统一生效
一个日志文件,出错了直接搜关键字 一个请求经过 5 个服务,日志分散在 5 个地方,怎么串起来排查

接下来每一节,都是在回答"拆分之后冒出来的这个新问题,业界是怎么解决的"。

2. 微服务要解决的核心问题:总览

把上面的问题对应到具体技术方案,先建立一张"地图",后面每一节展开讲一个:

问题 一句话描述 常见方案
服务怎么找到对方 投放服务想调用计费服务,但计费服务部署在哪台机器、哪个端口? 注册中心(Eureka / Nacos)
服务之间怎么调用 找到了地址,怎么发起调用、怎么少写重复代码? OpenFeign / RestTemplate
请求量大了怎么分摊 计费服务部署了 3 个实例,请求该打给哪一个? 负载均衡(LoadBalancer)
外部请求怎么进来 手机 App 不会直接调用内部几十个服务,应该打到哪? API 网关(Spring Cloud Gateway)
配置怎么统一管理 几十个服务的数据库地址、开关配置,难道一个个改? 配置中心(Nacos Config)
某个服务挂了怎么办 计费服务卡死,会不会拖垮调用它的所有服务? 熔断与降级(Sentinel)
跨服务的数据一致性 "扣费成功但投放记录创建失败",怎么保证两边一致? 分布式事务(Seata)
出问题了怎么排查 一个慢请求,经过了 5 个服务,慢在哪一环? 链路追踪(Sleuth + Zipkin)
怎么部署这么多服务 几十个服务,手动一个个启动、升级,根本管不过来 容器化(Docker + Kubernetes)

下面按这张表的顺序,逐个用最简单的例子讲清楚"是什么、为什么需要、长什么样"。

3. 服务注册与发现

3.1 为什么需要

假设投放服务要调用计费服务的接口 POST /billing/charge。在单体应用里这就是一次方法调用;现在计费服务是独立部署的,投放服务必须知道:计费服务现在跑在哪台机器、哪个端口

最朴素的办法是把地址写死在配置文件里:

yaml 复制代码
billing-service:
  url: http://192.168.1.100:8081

问题来了:

  • 计费服务扩容到 3 个实例,写一个地址够吗?
  • 某个实例重启后 IP 变了,配置文件要不要手动改?
  • 服务越来越多,每个服务都要维护一份"别人的地址表"

3.2 注册中心:服务的"电话簿"

注册中心就是一个所有服务都能访问的"电话簿":

  1. 服务注册:计费服务启动时,把自己的"名字 + IP + 端口"报告给注册中心
  2. 心跳续约:计费服务定期向注册中心发心跳,证明自己还活着;注册中心如果一段时间没收到心跳,就把这个实例摘除
  3. 服务发现:投放服务要调用计费服务时,先问注册中心"计费服务现在有哪些可用实例",拿到一个地址列表

常见注册中心:Netflix Eureka(Spring Cloud 早期默认)、阿里 Nacos(同时支持注册中心 + 配置中心,国内更主流)。

3.3 最简示例(Spring Cloud Alibaba + Nacos)

xml 复制代码
<!-- pom.xml:让服务接入 Nacos 注册中心 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
yaml 复制代码
# application.yml
spring:
  application:
    name: billing-service     # 注册到 Nacos 的服务名
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848   # Nacos 服务器地址

服务启动后,就会自动注册到 Nacos------不需要写任何代码,靠的是 Spring Boot 的自动装配机制(参考 17 节 Spring Boot)。

4. 服务间调用:OpenFeign

4.1 用 RestTemplate 调用:能跑,但很啰嗦

22 节介绍过 RestTemplate(参考 22 节 HTTP 客户端),调用别的服务时可以这样写:

java 复制代码
String url = "http://billing-service/billing/charge"; // billing-service 是注册中心里的服务名
ChargeResult result = restTemplate.postForObject(url, request, ChargeResult.class);

每次调用都要手写 URL、手动拼参数、手动处理返回值------服务调用一多,代码里到处都是这种字符串拼接。

4.2 OpenFeign:把远程调用"伪装"成本地方法

OpenFeign 的思路:定义一个 Java 接口,加上注解描述"调用哪个服务的哪个接口",剩下的事情(拼 URL、发 HTTP 请求、解析响应)框架自动完成------业务代码里调用这个接口,就像调用本地的 Service 一样。

xml 复制代码
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
java 复制代码
// 1. 定义接口:声明"我要调用 billing-service 的 /billing/charge"
@FeignClient(name = "billing-service")
public interface BillingClient {

    @PostMapping("/billing/charge")
    ChargeResult charge(@RequestBody ChargeRequest request);
}
java 复制代码
// 2. 像调用普通 Service 一样使用
@Service
@RequiredArgsConstructor
public class AdService {

    private final BillingClient billingClient;

    public void launchAd(Long adId) {
        ChargeResult result = billingClient.charge(new ChargeRequest(adId, 100));
        // ... 后续逻辑
    }
}

@FeignClient(name = "billing-service") 里的 billing-service 就是 3.3 节里计费服务注册到 Nacos 时用的名字------OpenFeign 会自动去注册中心查这个名字对应的地址列表,调用方完全不用关心具体 IP。

5. 负载均衡

5.1 为什么需要

计费服务为了扛住更大流量,部署了 3 个实例。投放服务通过注册中心拿到的是一个地址列表(3 个地址),每次调用该选哪一个?

5.2 负载均衡器:在多个实例间分配请求

负载均衡(Load Balancing) 就是按某种策略,把请求分摊到多个服务实例上:

策略 说明
轮询(Round Robin) 依次轮流分配给每个实例,最简单也最常用
随机(Random) 随机选一个实例
加权(Weighted) 性能好的机器配置更高权重,分配更多请求

Spring Cloud 里,OpenFeign(4 节)和 RestTemplate 都可以直接集成 spring-cloud-starter-loadbalancer------你在 4.2 节写的 BillingClient 调用,背后实际上已经经过了负载均衡器,业务代码完全无感知。这也是注册中心 + 负载均衡组合的核心价值:调用方只写"服务名","该打给哪个实例"完全交给框架处理。

6. API 网关

6.1 为什么需要

现在系统内部有用户服务、投放服务、计费服务、报表服务......前端(网页/App)要调用这些接口时,难道要分别记住每个服务的地址吗?而且像"登录校验""限流""统一加密"这些逻辑,难道每个服务都要重复写一遍?

6.2 网关:所有外部请求的统一入口

API 网关 是部署在最外层的一个独立服务,所有外部请求先经过网关,再由网关转发给内部对应的服务:

网关的核心能力:

能力 说明
路由 根据请求路径,把 /api/users/** 转发给用户服务,/api/ads/** 转发给投放服务
统一鉴权 在网关层校验 Token,校验失败直接拒绝,内部服务不用每个都写一遍登录校验
限流 控制每秒最多放行多少请求,保护后端服务
日志/监控 统一记录每个请求的耗时、状态码

Spring Cloud Gateway 的路由配置示例:

yaml 复制代码
spring:
  cloud:
    gateway:
      routes:
        - id: ad-service-route
          uri: lb://ad-service        # lb:// 表示走负载均衡,ad-service 是注册中心里的服务名
          predicates:
            - Path=/api/ads/**         # 匹配这个路径前缀的请求
        - id: billing-service-route
          uri: lb://billing-service
          predicates:
            - Path=/api/billing/**

7. 配置中心

7.1 为什么需要

每个服务都有 application.yml------数据库地址、Redis 地址、第三方接口的开关。当服务数量变多,或者同一个服务部署了几十个实例:

  • 改一个配置(比如把某个开关从 false 改成 true),要逐个服务、逐个实例去改配置文件再重启吗?
  • 测试环境和生产环境的配置经常不一样,怎么管理?

7.2 配置中心:配置集中存储,动态推送

配置中心 把配置文件统一存放在一个中心服务里,各个服务启动时从配置中心拉取自己的配置;配置中心修改配置后,还能实时推送给正在运行的服务,不用重启。

yaml 复制代码
# bootstrap.yml:告诉服务去 Nacos 拉取配置
spring:
  application:
    name: billing-service
  cloud:
    nacos:
      config:
        server-addr: 127.0.0.1:8848
        file-extension: yaml   # 对应 Nacos 上的 billing-service.yaml

配合 @RefreshScope 注解,被它标注的 Bean 在配置中心的值变化时会自动刷新,不需要重启服务:

java 复制代码
@RestController
@RefreshScope
public class FeatureController {

    @Value("${feature.new-billing-enabled:false}")
    private boolean newBillingEnabled;
}

Nacos 同时承担"注册中心"(3 节)和"配置中心"两个角色,是它在国内 Spring Cloud Alibaba 生态里很受欢迎的原因之一。

8. 服务容错:熔断、降级、限流

8.1 为什么需要:一个服务挂了,不能拖垮所有人

假设报表服务调用计费服务查询账单数据,但计费服务因为数据库慢查询卡住了,每个请求都要等 30 秒才超时。这时:

  • 报表服务的所有请求都卡在等计费服务的调用上
  • 报表服务的线程池被这些"卡住的请求"占满
  • 报表服务自己也变得不可用了------故障从一个服务扩散到了调用它的所有服务,这就是"雪崩"

8.2 三个关键手段

手段 含义 类比
限流 限制单位时间内允许通过的请求数量,超出的直接拒绝 景区限制每天入园人数
熔断 监控调用失败率,失败率过高时暂时停止调用这个服务一段时间,直接返回失败/默认值,不再傻等 家里的保险丝,电流过大就跳闸
降级 调用失败/熔断时,返回一个"兜底"结果(比如缓存数据、默认值),而不是直接报错给用户 高速路口堵车时,导航改走备用路线

8.3 熔断器的三种状态

  • 关闭:正常状态,请求正常发给计费服务
  • 打开 :失败率超过阈值后,熔断器"跳闸",后续请求不再真正调用计费服务,直接走降级逻辑(比如返回"账单暂不可用,请稍后重试")
  • 半开:经过一段冷却时间后,放一小部分请求"试探"计费服务是否恢复------如果成功就回到"关闭"状态,否则继续保持"打开"

常见组件:Sentinel(阿里,支持限流+熔断+降级,有可视化控制台)、Resilience4j(轻量级库)。Netflix Hystrix 是早期的代表,但已停止维护。

java 复制代码
// Resilience4j 风格的熔断示例(伪代码示意,实际配置以注解/配置文件为主)
@CircuitBreaker(name = "billingService", fallbackMethod = "fallbackGetBill")
public Bill getBill(Long adId) {
    return billingClient.getBill(adId);
}

// 熔断或异常时的降级方法,方法签名要和原方法一致(多一个 Throwable 参数)
public Bill fallbackGetBill(Long adId, Throwable t) {
    return Bill.unavailable(); // 返回一个表示"暂不可用"的默认对象
}

9. 分布式事务

9.1 为什么需要

广告"投放"操作需要同时做两件事:

  1. 计费服务:给广告主账户扣费
  2. 投放服务:创建一条投放记录

在单体应用里,这两步在一个 @Transactional 方法里,要么都成功要么都回滚(参考 MyBatis/Spring 事务管理)。但现在这是两个服务、两个独立数据库------本地事务管不到另一个服务的数据库。如果"扣费成功"但"创建投放记录"失败了,钱扣了但广告没投出去,数据就不一致了。

9.2 几种常见方案(了解即可,不深入)

方案 思路 特点
2PC(两阶段提交) 协调者先问所有参与者"能不能提交",都说能才真正提交 强一致,但所有参与者要互相等待,性能差,很少在微服务里直接用
TCC(Try-Confirm-Cancel) 每个操作拆成"预留资源(Try)"、"确认执行(Confirm)"、"取消释放(Cancel)"三步 性能较好,但每个业务都要写三套接口,开发成本高
Saga 把一个大事务拆成一串本地事务,每一步失败时执行对应的"补偿操作"(比如"扣费"对应的补偿是"退款") 不要求强一致,适合电商/广告这类"最终一致"场景
本地消息表 / 消息队列 先在本地数据库记一条"待发送"消息,本地事务提交后再异步通知其他服务,失败可重试 实现简单,对最终一致性场景很常用

实际项目中,这部分常用阿里的 Seata 框架封装了上述模式(尤其是 AT 模式,类似自动化的 2PC),业务代码改动很小。初学阶段记住一个结论 :分布式事务追求的通常是最终一致性(过程中可能短暂不一致,但最终会达成一致),而不是单体应用里的强一致性。

10. 链路追踪与日志聚合

10.1 为什么需要

一个"创建广告投放"的请求,可能依次经过:网关 → 投放服务 → 计费服务 → 用户服务(查询广告主余额)→ 报表服务(记录一条投放日志)。如果这个请求总共耗时 2 秒,慢在哪一步 ?如果报错了,是哪个服务报的错

每个服务的日志都是独立文件,单看一个服务的日志,看不到完整的调用链路。

10.2 链路追踪:给每个请求一个"身份证号"

链路追踪 的核心思路很简单:请求进入网关时,生成一个全局唯一的 TraceId;这个 TraceId 会随着请求(通过 HTTP Header)一路传递给后续调用的每个服务;每个服务打日志时,都把这个 TraceId 一起打印出来。

复制代码
TraceId: abc123
  网关       [abc123] 收到请求 /api/ads/launch
  投放服务   [abc123] 处理投放请求,调用计费服务
  计费服务   [abc123] 扣费成功,耗时 800ms   ← 原来慢在这里!
  投放服务   [abc123] 创建投放记录成功

排查问题时,只要拿着这个 TraceId 去日志系统里搜索,就能把分散在 4 个服务里的日志串成一条完整的调用链,并看到每一步的耗时。

常见组合:

  • Spring Cloud Sleuth (生成和传递 TraceId)+ Zipkin(可视化展示调用链路和耗时)
  • SkyWalking:国产 APM 工具,功能更全(链路追踪 + 性能监控 + 告警),接入时几乎不用改代码
  • ELK(Elasticsearch + Logstash + Kibana):把所有服务的日志统一收集、索引,支持按 TraceId 搜索

11. 容器化与部署简介

11.1 为什么需要

微服务拆分之后,服务数量从 1 个变成几十个。每个服务可能用了不同版本的 JDK、不同的依赖库版本------在物理机/虚拟机上手动部署、手动处理"环境不一致"的问题,几十个服务根本管不过来。

11.2 Docker:把"运行环境"打包进去

Docker 把应用代码、运行时依赖(JDK 版本、系统库)、配置文件,一起打包成一个镜像(Image)。这个镜像在任何装了 Docker 的机器上,运行出来的**容器(Container)**行为都完全一致------彻底解决"在我电脑上能跑"的问题。

dockerfile 复制代码
# Dockerfile:描述如何构建 billing-service 的镜像
FROM eclipse-temurin:17-jre
COPY target/billing-service.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
bash 复制代码
# 构建镜像
docker build -t billing-service:1.0 .

# 运行容器
docker run -d -p 8081:8081 billing-service:1.0

11.3 Kubernetes:管理大量容器

几十个服务、每个服务还要部署多个实例,光靠 docker run 一个个启动也不现实。Kubernetes(K8s) 是容器编排平台,负责:

能力 说明
自动调度 把容器分配到集群里资源充足的机器上运行
自动扩缩容 根据 CPU/内存使用率,自动增加或减少服务实例数量
自动恢复 容器挂了自动重启,机器挂了自动迁移到其他机器
服务发现 K8s 内置了类似 3 节"注册中心"的能力
滚动更新 发布新版本时逐个替换旧实例,过程中服务不中断

初学阶段不需要精通 K8s 的所有概念,知道"它是用来管理大量容器的"就足够建立起完整的认知地图了------后续可以专门写一篇深入。

12. 小结

主题 核心问题 关键方案
服务注册与发现 服务怎么找到对方 Nacos / Eureka:注册 + 心跳 + 发现
服务调用 调用代码怎么写得简洁 OpenFeign:把远程调用伪装成接口方法
负载均衡 多个实例怎么分摊请求 LoadBalancer:轮询/随机/加权,对调用方透明
API 网关 外部请求统一入口在哪 Spring Cloud Gateway:路由 + 鉴权 + 限流
配置中心 配置怎么集中管理、动态生效 Nacos Config + @RefreshScope
服务容错 一个服务挂了怎么不拖垮全部 限流/熔断/降级,Sentinel / Resilience4j
分布式事务 跨服务数据一致性 追求最终一致性:Saga / 消息队列 / Seata
链路追踪 一个慢请求慢在哪一步 TraceId 贯穿全链路,Sleuth + Zipkin / SkyWalking
容器化部署 几十个服务怎么管 Docker 打包 + Kubernetes 编排

给初学者的建议 :不要一上来就想着把所有组件都用上------先理解"单体的痛点 → 拆分之后冒出的新问题 → 每个新问题对应的方案"这条逻辑链,再结合实际项目规模决定要不要引入某个组件。Mini-SSP 这类中小型项目,初期甚至可以先不拆微服务,把"按业务模块划分代码包"做好(参考 16 节 Spring IoC/AOP 的分层思想),等真的遇到"一个团队改一个项目互相阻塞"的问题时再考虑拆分。


🎯 如果这篇文章对你有帮助,别忘了点赞、收藏、关注三连!关注我,让你在 Java 学习的道路上不迷路,持续为你带来成体系的 Java 干货~

相关推荐
一路向北North15 小时前
Spring Security OAuth2.0(23):分布式系统授权-转发明文给微服务
java·spring·微服务
乐观的Terry18 小时前
3、数据库设计与领域实体
java·数据库·spring boot·spring cloud·ai编程
Wang's Blog1 天前
Go-Zero进阶实战2: 使用 Docker Compose 编排与部署微服务
docker·微服务·golang
xexpertS1 天前
可用区静态稳定性架构:如何提升云服务高可用性
微服务
invicinble1 天前
关于微服务基本框架搭建--fegin最小使用demo
java·spring boot·微服务
吃饱了得干活1 天前
Spring Cloud Gateway限流:从Redis到Sentinel的三种方案
后端·spring cloud
橙臣程2 天前
集群、分布式与微服务介绍
分布式·微服务·架构
振宇i2 天前
Java集成openfeign远程RPC调用教程
java·rpc·openfeign