别再只会用 Feign!手写一个 Mini RPC 框架搞懂 Spring Cloud 底层原理

前言

在开发 JobFlow 任务调度系统时,我意识到要做好一个开源项目,必须深入理解底层技术栈的核心原理。之前我写过一篇文章《基于Nacos的轻量任务调度方案 ------ 从 XXL-Job 的痛点说起》,在设计调度器与执行器之间的 RPC 通信方案时,我发现虽然可以直接使用 Spring Cloud Alibaba + OpenFeign,但对其底层实现原理理解不够透彻。

为了给 JobFlow 打好技术基础,我决定通过实现一个 Mini RPC 框架来深入理解 Spring Cloud 的核心逻辑。这篇文章记录了整个探索过程,包括设计思路、核心实现和与 Spring Cloud 的对比分析。

项目设计

我们要实现的 Mini RPC 框架架构如下:

graph LR A[order-service
服务消费方] --> B[Mini RPC
Framework] B --> C[Nacos
服务注册中心] C --> D[user-service
服务提供方] B -.HTTP 调用.-> D

核心模块

  • mini-rpc-framework:RPC 框架核心
  • user-service:服务提供方
  • order-service:服务消费方

技术栈:Spring Boot 3.3.4、Nacos Client 2.2.0、RestTemplate、JDK 动态代理

核心实现

第一步:服务发现

服务发现是 RPC 的基础,需要从 Nacos 获取服务实例列表。

sequenceDiagram participant C as 服务消费方 participant F as RPC Framework participant N as Nacos C->>F: 调用远程服务 F->>F: 检查本地缓存 alt 缓存未过期 F->>C: 返回缓存实例 else 缓存过期或不存在 F->>N: 查询服务实例 N->>F: 返回健康实例列表 F->>F: 更新缓存(5秒过期) F->>C: 返回实例列表 end

NacosDiscoveryClient 核心实现

java 复制代码
public List<ServiceInstance> getInstances(String serviceName) {
    // 检查缓存
    CachedInstances cached = instanceCache.get(serviceName);
    if (cached != null && !cached.isExpired()) {
        return cached.getInstances();
    }
    
    // 从 Nacos 拉取健康实例
    try {
        List<Instance> nacosInstances = namingService.selectInstances(
            serviceName, group, true);  // 只要健康的实例
        
        List<ServiceInstance> instances = nacosInstances.stream()
            .map(this::convertToServiceInstance)
            .collect(Collectors.toList());
        
        // 更新缓存
        instanceCache.put(serviceName, new CachedInstances(instances));
        return instances;
    } catch (Exception e) {
        // 降级:返回缓存中的旧数据
        if (cached != null) {
            log.warn("从 Nacos 获取实例失败,使用缓存数据");
            return cached.getInstances();
        }
        return Collections.emptyList();
    }
}

关键设计点:本地缓存避免频繁请求 Nacos;降级策略保证高可用;只选择健康实例。

第二步:负载均衡

从多个服务实例中选择一个进行调用。

sequenceDiagram participant C as RPC Client participant LB as LoadBalancer participant I as Instance List C->>LB: 选择实例 LB->>I: 过滤健康实例 I->>LB: 健康实例列表 LB->>LB: 轮询算法(AtomicInteger取模) LB->>C: 返回选中实例

RoundRobinLoadBalancer 核心实现

java 复制代码
public class RoundRobinLoadBalancer implements LoadBalancer {
    private final AtomicInteger position = new AtomicInteger(0);
    
    @Override
    public ServiceInstance choose(List<ServiceInstance> instances) {
        // 过滤健康实例
        List<ServiceInstance> healthyInstances = instances.stream()
            .filter(ServiceInstance::isHealthy)
            .collect(Collectors.toList());
        
        if (healthyInstances.isEmpty()) {
            throw new RuntimeException("没有健康的服务实例");
        }
        
        // 轮询选择
        int pos = Math.abs(position.getAndIncrement());
        return healthyInstances.get(pos % healthyInstances.size());
    }
}

关键设计点:使用 AtomicInteger 保证线程安全;先过滤健康实例再选择;取模实现轮询。

第三步:HTTP 调用与重试

有了服务发现和负载均衡,现在实现 HTTP 调用逻辑。

sequenceDiagram participant C as RPC Client participant D as Discovery participant LB as LoadBalancer participant S as Service Instance C->>D: 获取服务实例列表 D->>C: 返回实例列表 C->>LB: 负载均衡选择实例 LB->>C: 返回选中实例 C->>C: 构建 HTTP 请求 C->>C: 执行拦截器(TraceId等) C->>S: 发起 HTTP 请求 alt 请求成功 S->>C: 返回响应 else 超时或连接失败 C->>C: 重试(最多2次) C->>S: 重新发起请求 else 业务异常(4xx/5xx) S->>C: 返回错误 C->>C: 不重试,直接失败 end

RpcClient 核心实现

java 复制代码
public <T> T invoke(String serviceName, String path, HttpMethod method,
                   Object requestBody, Class<T> responseType) {
    int attempts = 0;
    
    while (attempts <= config.maxRetries) {
        try {
            // 1. 服务发现
            List<ServiceInstance> instances = discoveryClient.getInstances(serviceName);
            
            // 2. 负载均衡
            ServiceInstance instance = loadBalancer.choose(instances);
            String url = instance.getUrl() + path;
            
            // 3. 构建请求头
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            
            // 4. 执行拦截器(注入 TraceId)
            for (RequestInterceptor interceptor : interceptors) {
                interceptor.apply(headers);
            }
            
            // 5. 发起 HTTP 请求
            ResponseEntity<T> response = restTemplate.exchange(
                url, method, new HttpEntity<>(requestBody, headers), responseType);
            
            return response.getBody();
            
        } catch (ResourceAccessException e) {
            // 超时或连接异常,可重试
            if (isRetryable(e)) {
                attempts++;
                if (attempts <= config.maxRetries) {
                    log.warn("RPC 调用失败,第 {} 次重试", attempts);
                    Thread.sleep(config.retryIntervalMillis);
                    continue;
                }
            }
            throw new RpcException("RPC 调用失败", e);
        }
    }
}

关键设计点:只重试超时和连接异常;业务异常不重试避免幂等性问题;重试间隔避免瞬时压力。

第四步:TraceId 透传

实现全链路追踪,串联调用链路。

sequenceDiagram participant O as Order Service participant F as RPC Framework participant U as User Service O->>O: 生成 traceId O->>O: 写入 MDC O->>F: 调用远程服务 F->>F: 从 MDC 读取 traceId F->>F: 写入 HTTP Header F->>U: 发起请求(带traceId) U->>U: 从 Header 读取 traceId U->>U: 写入 MDC U->>U: 业务处理(日志自动带traceId) U->>F: 返回响应

TraceId 拦截器实现

java 复制代码
public class TraceIdInterceptor implements RequestInterceptor {
    @Override
    public void apply(HttpHeaders headers) {
        String traceId = MDC.get("traceId");
        if (traceId != null) {
            headers.set("X-Trace-Id", traceId);
        }
    }
}

// user-service 接收 traceId
@Component
public class TraceIdFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, 
                        FilterChain chain) {
        HttpServletRequest req = (HttpServletRequest) request;
        String traceId = req.getHeader("X-Trace-Id");
        if (traceId != null) {
            MDC.put("traceId", traceId);
        }
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("traceId");
        }
    }
}

关键设计点:使用 MDC 存储 traceId;通过 HTTP Header 传递;过滤器统一处理。

第五步:动态代理

让接口调用像本地方法一样简单。

sequenceDiagram participant U as User Code participant P as Dynamic Proxy participant R as RPC Client U->>P: userClient.getUser(1L) P->>P: 解析 @GetMapping 注解 P->>P: 解析 @PathVariable 参数 P->>P: 构造请求路径 /user/1 P->>R: invoke(serviceName, path, method) R->>R: 服务发现 + 负载均衡 + HTTP调用 R->>P: 返回响应 P->>U: 返回结果

MiniFeign 核心实现

java 复制代码
public static <T> T create(Class<T> interfaceClass, String serviceName, 
                          RpcClient rpcClient) {
    return (T) Proxy.newProxyInstance(
        interfaceClass.getClassLoader(),
        new Class<?>[]{interfaceClass},
        (proxy, method, args) -> {
            // 解析 @GetMapping 注解
            GetMapping mapping = method.getAnnotation(GetMapping.class);
            String path = mapping.value()[0];
            
            // 替换路径参数
            Parameter[] parameters = method.getParameters();
            for (int i = 0; i < parameters.length; i++) {
                PathVariable pathVar = parameters[i].getAnnotation(PathVariable.class);
                if (pathVar != null) {
                    path = path.replace("{" + pathVar.value() + "}", 
                                      String.valueOf(args[i]));
                }
            }
            
            // 调用 RPC
            return rpcClient.get(serviceName, path, method.getReturnType());
        });
}

关键设计点:JDK 动态代理拦截接口调用;解析注解构造请求路径;委托给 RpcClient 执行。

测试验证

轮询负载均衡测试

启动两个 user-service 实例(8081、8082),连续调用三次:

bash 复制代码
curl -X POST "http://localhost:9090/order?userId=1&productName=book"
curl -X POST "http://localhost:9090/order?userId=2&productName=pen"
curl -X POST "http://localhost:9090/order?userId=3&productName=cup"

日志显示轮询效果:

ini 复制代码
RPC 调用成功, url=http://10.100.45.164:8081/user/1
RPC 调用成功, url=http://10.100.45.164:8082/user/2
RPC 调用成功, url=http://10.100.45.164:8081/user/3

服务下线测试

关闭 8082 端口的实例,等待 10 秒让 Nacos 感知到实例下线,然后再次调用:

arduino 复制代码
2024-12-17 10:35:20 [main] INFO  init new ips(1) service: user-service -> [
  Instance{ip='10.100.45.164', port=8081, healthy=true}
]
2024-12-17 10:35:20 [main] INFO  RPC 调用成功, url=http://10.100.45.164:8081/user/4

Nacos 自动摘除不健康实例,RPC 调用正常。

超时重试测试

在 user-service 添加 6 秒延迟(超过 5 秒读取超时),触发重试机制:

ini 复制代码
2024-12-17 10:40:10 [main] WARN  RPC 调用超时,第 1 次重试, error=Read timed out
2024-12-17 10:40:16 [main] WARN  RPC 调用超时,第 2 次重试, error=Read timed out
2024-12-17 10:40:22 [main] ERROR RPC 调用失败,已重试 2 次

核心技术点总结

功能模块 Mini RPC 实现 Spring Cloud 实现 核心差异
服务发现 NamingService + 单级缓存(5秒过期) NacosDiscoveryClient + 多级缓存 核心逻辑一致,Spring Cloud 缓存更完善
负载均衡 AtomicInteger 轮询算法 RoundRobinLoadBalancer 实现完全相同,Spring Cloud 支持更多策略
超时重试 只重试超时和连接异常 Retryer.Default 相同逻辑 重试策略一致
TraceId MDC + HTTP Header 透传 Spring Cloud Sleuth 我们只有 traceId,Sleuth 支持完整链路
动态代理 JDK Proxy + @GetMapping Feign + 所有 Spring MVC 注解 我们只支持 GET,演示原理
代码量 约 500 行 约 10000+ 行 我们实现核心功能,Spring Cloud 更完善

核心结论:Spring Cloud 的复杂性来自于通用性和完善性,而不是核心逻辑复杂。500 行代码就能实现 RPC 的本质功能。

收获与思考

通过手写 Mini RPC 框架,我深刻理解了 RPC 调用的本质:服务发现就是从注册中心获取 IP 和端口,负载均衡就是从列表中选一个实例,RPC 调用就是发送 HTTP 请求,TraceId 透传就是通过 Header 传递参数。Spring Cloud 的复杂性主要来自支持多种注册中心、提供丰富的配置项、完善的容错机制和强大的扩展性。

现在遇到 RPC 问题,我知道如何快速定位:调用超时检查 readTimeout 配置和重试日志,负载均衡不生效检查实例列表和健康状态,TraceId 丢失检查 MDC 设置和拦截器执行。这让我对维护 JobFlow 开源项目更有信心,能从源码层面给用户准确的回答,快速定位和解决 Bug。

手写一遍,胜过读十遍源码。理解核心原理后,我对 JobFlow 的 RPC 调用有了更清晰的设计思路:根据任务类型动态设置超时时间,区分网络抖动和业务异常的重试策略,通过 TraceId 实现任务调度的全链路追踪,定时刷新执行器列表快速感知扩缩容。

总结

这次实践让我深入理解了 Spring Cloud Alibaba 的 RPC 原理,为 JobFlow 项目打下了坚实的技术基础。如果你也在开发微服务项目,建议不要只会用框架,要理解原理;手写一个最小化实现,去掉框架的"魔法";对比源码,理解设计权衡;将所学应用到实际项目中。

如果你对 JobFlow 项目感兴趣,欢迎关注我们的进展,一起探讨云原生任务调度的实践经验。

相关推荐
用户695619440372 小时前
前后端分离VUE3+Springboot项目集成PageOffice核心代码
后端
rannn_1112 小时前
【Git教程】概述、常用命令、Git-IDEA集成
java·git·后端·intellij-idea
我家领养了个白胖胖2 小时前
向量化和向量数据库redisstack使用
java·后端·ai编程
嘻哈baby2 小时前
NextCloud私有云盘完整部署指南
后端
Ray662 小时前
Linux 日志处理三剑客:grep、awk、sed
后端
陈随易2 小时前
PostgreSQL v18发布,新增AIO uuidv7 OAuth等功能
前端·后端·程序员
java1234_小锋2 小时前
[免费]基于Python的Flask+Vue物业管理系统【论文+源码+SQL脚本】
后端·python·flask·物业管理
guslegend3 小时前
第2节:项目性能优化(中)
架构
Xの哲學3 小时前
Linux链路聚合深度解析: 从概念到内核实现
linux·服务器·算法·架构·边缘计算