前言
在开发 JobFlow 任务调度系统时,我意识到要做好一个开源项目,必须深入理解底层技术栈的核心原理。之前我写过一篇文章《基于Nacos的轻量任务调度方案 ------ 从 XXL-Job 的痛点说起》,在设计调度器与执行器之间的 RPC 通信方案时,我发现虽然可以直接使用 Spring Cloud Alibaba + OpenFeign,但对其底层实现原理理解不够透彻。
为了给 JobFlow 打好技术基础,我决定通过实现一个 Mini RPC 框架来深入理解 Spring Cloud 的核心逻辑。这篇文章记录了整个探索过程,包括设计思路、核心实现和与 Spring Cloud 的对比分析。
项目设计
我们要实现的 Mini RPC 框架架构如下:
服务消费方] --> 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 获取服务实例列表。
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;降级策略保证高可用;只选择健康实例。
第二步:负载均衡
从多个服务实例中选择一个进行调用。
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 调用逻辑。
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 透传
实现全链路追踪,串联调用链路。
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 传递;过滤器统一处理。
第五步:动态代理
让接口调用像本地方法一样简单。
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 项目感兴趣,欢迎关注我们的进展,一起探讨云原生任务调度的实践经验。