深度思考rpc框架面经系列之三

6 一个rpc框架的请求调用的流程(小红书面试)

6.1 讲讲rpc调用原理,比如服务怎么发现,怎么调用,提供者怎么响应。怎么去请求,又怎么回来的

一个RPC(远程过程调用)框架的核心目的是允许程序调用另一个地址空间(通常是共享网络的另一台机器上)的程序或服务,就像调用本地的方法或函数一样。以下是一个典型的RPC框架如何处理请求的概述:

  1. 服务定义和接口

    • 通常,开发者首先定义服务和它们的接口。这些接口定义了可以远程调用的方法。
    • 使用IDL(接口定义语言)描述服务,生成客户端和服务器的存根。
  2. 服务注册

    • 服务提供者启动后,将自己的地址和提供的服务信息注册到注册中心。
  3. 服务发现

    • 服务消费者(或客户端)启动时,从注册中心订阅它想要调用的服务。
    • 注册中心返回服务提供者的地址给消费者。为了提高效率,这些地址通常会在消费者本地进行缓存。
  4. 远程调用

    • 当客户端要调用某个远程方法时,它会通过本地代理或存根发起这个调用。
    • 存根负责将方法调用转换为网络请求。这通常涉及序列化方法名称、参数等,然后发送到网络。
    • 负载均衡策略(如轮询、随机或最少活跃调用)可以在这一步中应用,以选择最合适的服务提供者实例。
  5. 请求处理

    • 服务提供者接收到网络请求后,反序列化请求数据以获得原始的方法名称和参数。
    • 服务提供者然后在本地执行这个方法,并获得结果。
  6. 响应返回

    • 服务提供者将执行结果序列化后,通过网络返回给服务消费者。
    • 服务消费者的存根反序列化返回的数据,将其转换回原始的方法调用结果,并返回给调用方。
  7. 容错处理

    • 如果在RPC过程中发生错误(如网络中断、服务提供者崩溃等),RPC框架可能提供容错机制。
    • 容错策略可能包括重新路由、重试、返回默认结果等。
  8. 监控和日志

    • 为了跟踪和监控远程调用的性能和可靠性,RPC框架通常提供日志记录和监控功能。

这个过程描述的是一个典型的同步RPC调用。但现代RPC框架也支持异步调用,其中客户端可以在不等待响应的情况下继续其它操作。

6.2 使用IDL(接口定义语言)描述服务,生成客户端和服务器的存根。这里的存根是什么意思

在RPC(Remote Procedure Call)的上下文中,“存根” (Stub) 是一种代理或接口,用于隐藏底层的远程调用的复杂性。通过存根,开发者可以像调用本地方法一样调用远程方法,而无需关心底层的网络通讯、数据序列化/反序列化等细节。

在使用IDL(接口定义语言)描述服务时,你基本上是在描述一个远程服务应该具备哪些功能或方法。然后,使用特定工具根据这个IDL生成客户端和服务器的存根。

  • 客户端存根 (Client Stub):当你在客户端调用某个远程方法时,实际上是在调用客户端存根。这个存根会负责:

    • 序列化方法调用的参数。
    • 通过网络将这些序列化的数据发送到服务器。
    • 等待服务器的响应。
    • 接收服务器的响应并反序列化,然后返回给调用者。
  • 服务器存根 (Server Stub):服务器端有一个匹配的存根,负责:

    • 接收客户端发来的请求并反序列化。
    • 调用本地的实现方法。
    • 序列化响应结果。
    • 将序列化的响应结果发送回客户端。

这种设计允许客户端和服务器在不同的平台和/或使用不同的编程语言时仍然可以通讯。因为只要它们遵循相同的IDL定义并使用相同的数据序列化/反序列化机制,就可以相互理解。

6.3 怎么路由的呢?(路由策略(如轮询、随机、最少活跃调用等)

服务路由:
在有多个服务提供者提供相同服务的情况下,客户端需要决定调用哪一个服务提供者。这时,路由策略(如轮询、随机、最少活跃调用等)会被应用。负载均衡策略会根据本地缓存的服务提供者地址列表选择一个。

讲解了我怎么设计负载均衡算法的,以及每种策略的适用场景:
负载均衡的目的是将网络流量分散到多个服务器,以确保每个服务器都不会因超载而宕机,并且可以最大化吞吐量、最小化响应时间并避免任何单一点的故障。以下是一些常用的负载均衡策略,以及各自的适用场景:

  1. 轮询 (Round Robin)

    • 策略:这是最简单的负载均衡算法,请求按顺序分配到服务器。如果服务器列表到达末尾,则重新开始。
    • 适用场景:当所有服务器都具有相似的规格并且预期的请求处理时间相似时,轮询是一个好选择。
  2. 加权轮询 (Weighted Round Robin)

    • 策略:与轮询相似,但给每个服务器一个权重,权重较高的服务器会接收到更多的请求。
    • 适用场景:当你有不同能力的服务器并希望每台服务器都接收到与其能力相称的流量时。
  3. 最少连接 (Least Connections)

    • 策略:将请求路由到连接数最少的服务器。
    • 适用场景:适用于服务器处理速度大致相同,但处理请求的时间可以变化的场景。例如,如果有一个长轮询或Websockets服务。
  4. 加权最少连接 (Weighted Least Connections)

    • 策略:与最少连接类似,但考虑到每个服务器的权重。
    • 适用场景:当服务器规格和处理速度不同时,且处理请求的时间可变。
  5. IP哈希 (IP Hash)

    • 策略:基于请求者的IP地址确定应该路由到哪个服务器。通常是通过取IP的哈希值然后对服务器数取模得到的。
    • 适用场景:当你希望来自特定IP的客户端始终连接到同一个服务器,这在需要保持会话或某些级联数据缓存时非常有用。
  6. URL哈希 (URL Hash)

    • 策略:基于请求URL的哈希值来确定路由到哪个服务器。
    • 适用场景:特别适用于HTTP缓存服务器,因为请求的相同URL可以确保路由到包含其缓存的同一服务器。
  7. 最短延迟 (Least Latency)

    • 策略:负载均衡器持续检测每台服务器的延迟或响应时间,并将请求路由到响应最快的服务器。
    • 适用场景:对于需要实时或快速响应的应用,如在线游戏或语音通信。
  8. 健康检查

    • 策略:定期检查服务器的健康状况,如果服务器未响应或返回错误,它将从活动服务器池中移除,直至再次被确定为健康。
    • 适用场景:适用于任何需要高可用性的应用。

根据你的应用类型、服务器规格和预期的流量模式选择合适的策略是关键。很多现代的负载均衡器都支持这些策略,并允许你基于实时流量模式动态地切换策略。

6.4 还有确定协议,编码,怎么编,是出于什么角度去考虑这样编码的,是指核心实现流程,调用链你已经说过了

6.4.1 在HTTP协议中,可以通过以下方式来区分请求报文和响应报文:

. 起始行的格式:

  • 请求报文的起始行是一个请求行,它的格式为:<HTTP方法> <请求URI> <HTTP版本>。例如:

    GET /index.html HTTP/1.1
    
  • 响应报文的起始行是一个状态行,它的格式为:<HTTP版本> <状态码> <状态描述>。例如:

    HTTP/1.1 200 OK
    

综上,最直接和可靠的方式是查看起始行。请求行和状态行的格式是唯一的,它们可以明确地告诉你报文是请求还是响应。

6.4.2 选择HTTP协议作为netty的上层应用协议

1 版本1:选择了HTTP协议作为我们的应用层协议,对于发送请求,有请求行、请求头、请求行以及请求体,其中请求头包含了一个content-type字段,这个字段包含了具体的序列化协议,比如常用的web服务器序列化方式application/json,还有一个content-length字段,为了区分包类型,即让服务器知道一个调用请求还是调用响应,我们可以利用http的请求行和状态行做区分。此外利用http请求的空行和content-length字段可以防止粘包和拆包问题。

6.4.3 自定义MRF协议(能跟面试官吹牛逼)

在这里插入图片描述
这几个字段的作用就如图所示,跟HTTP协议一对比,发现有很多相似之处。

6.4.3 一般服务的内部rpc调用需要经过api-gateway网关吗?

一般来说,服务的内部调用(即微服务之间的直接通信)不需要经过API网关(所以一般在讲解调用链的时候最好讲简洁一些)。API网关主要的目的是作为系统和外部消费者之间的一个接入点,处理进入系统的请求。这有助于集中处理某些横切关注点,如请求路由、API组合、限流、认证和授权等。

当内部服务需要相互通信时,它们通常会直接进行服务到服务的调用,可能通过服务发现机制来查找和定位其他服务。

然而,在某些架构或特定的场景中,可能有以下几个原因导致内部服务调用经过API网关:

  1. 统一的入口和出口策略:某些组织可能希望所有进出的通信都经过API网关,以便于流量的监控、日志记录或其他统一的策略。

  2. 增强的安全性:API网关可能提供额外的安全层,如对某些敏感操作进行额外的验证或过滤。

  3. 组合API:如果一个服务需要从多个其他服务中聚合数据,API网关可能会提供API组合功能,这样服务可以在一个单一的请求中获取所有必要的数据。

  4. 转型或适配:API网关可能提供转型功能,如将一个旧版本的API调用转换为新版本。

但这并不是常规做法。过多地依赖API网关可能导致网关成为一个单点故障或性能瓶颈。对于内部服务间的通信,更常见的做法是使用服务网格(如Istio或Linkerd)来处理服务之间的通信,提供负载均衡、故障恢复、度量和安全性等功能,而不是依赖API网关。

6.5 比如我现在想要调用服务端的methodA方法,服务端怎么知道客户端想要调用的就是这个方法呢?

6.5.1 具体的流程

客户端会定一个类,叫RpcRequest,这个类定义了需要调用远程方法的所有属性,包括接口名、方法名、方法所需要的参数及其类型,同时还包含了一个请求号(这里的请求号可以不说,因为是旁路,会影响面试官的听到主要答案),然后通过客户端本地存根(也就是代理),会给我们自动创建相应的RpcRequest对象并且进行属性填充,这里的属性是根据我们调用的具体方法、消费者提供的参数进行填充的,

填充完成后,代理类还会帮我们根据选择的序列化器将实际的请求体进行序列化操作,序列化后的rpcRequest数据会放到请求体中,然后再通过网络发给服务提供者。

生产端拿到请求后会先进行反序列化请求体,拿到实际的RpcRequest的数据后会根据接口名、接口方法以及参数列表会进行反射得到一个可执行的方法,然后进行条用就行了。

6.5.2 涉及到一些具体的细节

在RPC(Remote Procedure Call)系统中,当客户端想要调用服务端的某个方法时,它需要通过某种方式告知服务端应该调用哪个方法。为了实现这一目标,客户端发送的请求通常会包含一些元数据来描述所需的方法和参数。以下是一些常用的方法来实现这一目的:

  1. 方法标识符:

    当客户端发送请求时,它可能会在请求中包含一个字符串或数字ID作为方法的标识符。例如,它可能会发送如下所示的消息:

    { "method": "methodA", "params": [...] }
    

    对应的java对象如下:


/**
 * 消费者向提供者发送的请求对象
 *
 * @author ziyang
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RpcRequest implements Serializable {
    
    

    /**
     * 请求号
     */
    private String requestId;
    /**
     * 待调用接口名称
     */
    private String interfaceName;
    /**
     * 待调用方法名称
     */
    private String methodName;
    /**
     * 调用方法的参数
     */
    private Object[] parameters;
    /**
     * 调用方法的参数类型
     */
    private Class<?>[] paramTypes;

    /**
     * 是否是心跳包
     */
    private Boolean heartBeat;

}
  1. 序列化和反序列化(涉及到具体的协议):

    除了方法标识符,客户端还需要将方法的参数序列化为一种可以通过网络传输的格式(如JSON、protobuf等)。服务端在接收到请求后,会反序列化这些参数,并根据提供的方法标识符调用相应的方法。

  2. 反射:

    在接收到请求并且反序列化,服务端可能会使用反射来查找并调用相应的方法,因为之前客户端已经发送调用该方法所需要的所有参数及其类型,所以方法能够调用成功。这就是为什么在一些RPC框架中,你会看到使用Java反射API来根据方法名称查找和调用方法。
    下面就是一个在服务端通过RpcRequest参数反射流程:

  private Object invokeTargetMethod(RpcRequest rpcRequest, Object service) {
    
    
        Object result;
        try {
    
    
            Method method = service.getClass().getMethod(rpcRequest.getMethodName(), rpcRequest.getParamTypes());
            result = method.invoke(service, rpcRequest.getParameters());
            logger.info("服务:{} 成功调用方法:{}", rpcRequest.getInterfaceName(), rpcRequest.getMethodName());
        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
    
    
            return RpcResponse.fail(ResponseCode.METHOD_NOT_FOUND, rpcRequest.getRequestId());
        }
        return result;
    }

总之,为了调用服务端的特定方法,客户端需要提供足够的信息来明确指定哪个方法以及所需的参数。服务端使用这些信息来确定应该调用哪个方法并传递哪些参数。

6.5.3 下面这段代码中在HelloService helloService = rpcClientProxy.getProxy(HelloService.class);传入HelloService.class的作用

package top.guoziyang.rpc.transport;

/**
 * RPC客户端动态代理
 *
 * @author ziyang
 */
public class RpcClientProxy implements InvocationHandler {
    
    

    private static final Logger logger = LoggerFactory.getLogger(RpcClientProxy.class);

    private final RpcClient client;

    public RpcClientProxy(RpcClient client) {
    
    
        this.client = client;
    }

    @SuppressWarnings("unchecked")
    public <T> T getProxy(Class<T> clazz) {
    
    
        return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class<?>[]{
    
    clazz}, this);
    }

    @SuppressWarnings("unchecked")
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) {
    
    
        logger.info("调用方法: {}#{}", method.getDeclaringClass().getName(), method.getName());
        RpcRequest rpcRequest = new RpcRequest(UUID.randomUUID().toString(), method.getDeclaringClass().getName(),
                method.getName(), args, method.getParameterTypes(), false);
        RpcResponse rpcResponse = null;
        if (client instanceof NettyClient) {
    
    
            try {
    
    
                CompletableFuture<RpcResponse> completableFuture = (CompletableFuture<RpcResponse>) client.sendRequest(rpcRequest);
                rpcResponse = completableFuture.get();
            } catch (Exception e) {
    
    
                logger.error("方法调用请求发送失败", e);
                return null;
            }
        }
        if (client instanceof SocketClient) {
    
    
            rpcResponse = (RpcResponse) client.sendRequest(rpcRequest);
        }
        RpcMessageChecker.check(rpcRequest, rpcResponse);
        return rpcResponse.getData();
    }
}

package top.guoziyang.test;

/**
 * 测试用Netty消费者
 *
 * @author ziyang
 */
public class NettyTestClient {
    
    

    public static void main(String[] args) {
    
    
        RpcClient client = new NettyClient(CommonSerializer.PROTOBUF_SERIALIZER);
        RpcClientProxy rpcClientProxy = new RpcClientProxy(client);
        HelloService helloService = rpcClientProxy.getProxy(HelloService.class);
        HelloObject object = new HelloObject(12, "This is a message");
        String res = helloService.hello(object);
        System.out.println(res);
        ByeService byeService = rpcClientProxy.getProxy(ByeService.class);
        System.out.println(byeService.bye("Netty"));
    }

}

解析:
是的,你的理解是对的。

在这段代码中,RpcClientProxy 类提供了一个 getProxy 方法,用于为指定的接口创建一个动态代理对象。这个动态代理对象会在方法调用时,代理到 RpcClientProxyinvoke 方法。

当你调用:

HelloService helloService = rpcClientProxy.getProxy(HelloService.class);

这里的 HelloService.class 是被传入 getProxy 方法的,从而使用 Java 的动态代理机制创建了一个 HelloService 的代理对象。这意味着当你调用这个代理对象上的任何方法(如 helloService.hello(object)),实际上都会被代理到 RpcClientProxyinvoke 方法。

invoke 方法中,你可以看到它实际上创建了一个 RpcRequest 对象,其中包含了方法的名称、参数等信息,然后通过 RPC 客户端发送这个请求到服务端。这是一个典型的 RPC 客户端的实现方式,通过代理模式将本地的方法调用转化为远程方法调用。

所以,你所说的 “传入 HelloService.class 的作用应该只是为了创建代理对象” 是正确的。这样做的目的是为了使得客户端的代码能够以一种看似本地调用的方式,实际上进行远程方法调用,提供了一个很好的抽象。

6.6 假设现在服务的provider想要下线了,如何做到让服务调用端感知?

当服务提供者(Service Provider)想要下线时,为了保证服务的可用性和避免服务调用失败,通常采取以下策略让服务消费者(Service Consumer)或调用端感知:

  1. 使用服务注册与发现机制(涉及到注册中心、消费者和提供者三方的交互机制):

    • 在许多RPC和微服务框架中,都采用了服务注册与发现的机制,如Zookeeper、Consul、Eureka等。
    • 当服务提供者打算下线时,首先从服务注册中心撤销或下线其服务。
    • 服务消费者定期从服务注册中心拉取服务列表,这样当提供者下线后,消费者会感知到这一变化,并不再向该提供者发送请求。
  2. 优雅关闭(服务提供者):

    • 服务提供者在停机前,首先停止接收新的请求,但继续处理已经接收的请求。
    • 一旦所有已接受的请求都处理完毕,再完全关闭服务。这确保了没有请求在中途被突然中断。
  3. 使用负载均衡器或API网关(利用第三方网关阻断流量):

    • 如果你使用了负载均衡器或API网关来路由请求,当服务提供者想要下线时,首先在负载均衡器或API网关中移除该提供者。
    • 通过这种方式,即使服务提供者还在运行,消费者的请求也不会被路由到该提供者。
  4. 健康检查(注册中心的机制):

    • 服务提供者通常会提供健康检查的接口,用于报告其健康状态。
    • 当服务提供者计划下线时,可以修改健康检查的响应,表示其不再健康或即将下线。
    • 服务消费者或负载均衡器定期检查服务的健康状态,感知到该变化后,会停止向提供者发送请求。
  5. 通知和告警(预计的维护或下线):

    • 当服务提供者计划下线时,可以手动通知所有已知的服务消费者。
    • 这通常适用于预计的维护或下线,确保消费者有足够的时间来做出响应或备份方案。
  6. 使用熔断器和重试机制(熔断重试,会造成多余的请求):

    • 熔断器可以检测到连续的请求失败,并自动切断到提供者的请求,避免向已下线的服务发送请求。
    • 重试机制会在请求失败时尝试其他可用的服务提供者。

结合上述策略,可以确保当服务提供者下线时,服务消费者能够及时感知并作出相应的处理,避免服务中断或大量的请求失败。

6.7 假设现在服务端处理一个请求需要一秒钟,结果在不到一秒的时候我把我的机器给关了,那用户就报错了嘛,这块怎么处理让它不报错,因为报错就会对业务产生消极影响

如果客户端(用户的设备或应用)在等待服务端响应时突然关机或断开连接,确保用户不看到错误是一项挑战。以下是一些建议的处理方法:

  1. 服务端冗余与故障转移

    • 在服务端部署多个实例,当一个实例发生故障时,流量可以自动切换到另一个健康的实例。
    • 使用负载均衡器来分发请求,同时检查各个服务实例的健康状态。
  2. 客户端策略

    • 延迟显示错误:客户端可以增加一个较长的超时时间,在此时间内,如果没有收到服务端的响应,可以认为服务端出现了问题。但在此时间内,用户界面可以显示一个“处理中”或“稍等”的提示。
    • 重试机制:客户端在收到错误响应或超时后,可以尝试再次发送请求,但要注意不要无限次地重试,以避免资源耗尽或不必要的网络流量。
    • 本地缓存和备份:对于某些可预测的请求,客户端可以缓存之前的结果,并在服务端无响应时返回这些缓存的数据。
  3. 使用离线策略

    • 对于某些应用,可以考虑使用离线策略,即在无法立即处理的情况下,将请求保存到本地,等到网络稳定或服务端可用时再处理。
  4. 友好的用户界面

    • 即使在错误发生时,也应该展示友好的提示,例如:“网络不稳定,请稍后重试”或“我们正在处理您的请求,请稍候”等,避免直接展示技术性错误信息。
  5. 后台处理与通知

    • 对于不需要立即响应的请求,可以让客户端提交请求后立即返回,而实际的处理则在服务端后台进行。完成后,通过通知或其他方式告诉客户端。
  6. 数据一致性

    • 保证服务端的数据处理逻辑具有幂等性,这样即使客户端发送了多次请求,也不会影响数据的一致性。
  7. 使用消息队列或事件驱动模型

    • 客户端发送的请求先存入消息队列,由服务端异步处理。客户端可以轮询或等待通知来获取结果。

在设计系统时,应该从用户体验的角度出发,尽量减少错误的展示,但同时也要确保系统的稳定性和数据的准确性。

猜你喜欢

转载自blog.csdn.net/yxg520s/article/details/132288394
今日推荐