6 一个rpc框架的请求调用的流程(小红书面试)
6.1 讲讲rpc调用原理,比如服务怎么发现,怎么调用,提供者怎么响应。怎么去请求,又怎么回来的
一个RPC(远程过程调用)框架的核心目的是允许程序调用另一个地址空间(通常是共享网络的另一台机器上)的程序或服务,就像调用本地的方法或函数一样。以下是一个典型的RPC框架如何处理请求的概述:
-
服务定义和接口:
- 通常,开发者首先定义服务和它们的接口。这些接口定义了可以远程调用的方法。
- 使用IDL(接口定义语言)描述服务,生成客户端和服务器的存根。
-
服务注册:
- 服务提供者启动后,将自己的地址和提供的服务信息注册到注册中心。
-
服务发现:
- 服务消费者(或客户端)启动时,从注册中心订阅它想要调用的服务。
- 注册中心返回服务提供者的地址给消费者。为了提高效率,这些地址通常会在消费者本地进行缓存。
-
远程调用:
- 当客户端要调用某个远程方法时,它会通过本地代理或存根发起这个调用。
- 存根负责将方法调用转换为网络请求。这通常涉及序列化方法名称、参数等,然后发送到网络。
- 负载均衡策略(如轮询、随机或最少活跃调用)可以在这一步中应用,以选择最合适的服务提供者实例。
-
请求处理:
- 服务提供者接收到网络请求后,反序列化请求数据以获得原始的方法名称和参数。
- 服务提供者然后在本地执行这个方法,并获得结果。
-
响应返回:
- 服务提供者将执行结果序列化后,通过网络返回给服务消费者。
- 服务消费者的存根反序列化返回的数据,将其转换回原始的方法调用结果,并返回给调用方。
-
容错处理:
- 如果在RPC过程中发生错误(如网络中断、服务提供者崩溃等),RPC框架可能提供容错机制。
- 容错策略可能包括重新路由、重试、返回默认结果等。
-
监控和日志:
- 为了跟踪和监控远程调用的性能和可靠性,RPC框架通常提供日志记录和监控功能。
这个过程描述的是一个典型的同步RPC调用。但现代RPC框架也支持异步调用,其中客户端可以在不等待响应的情况下继续其它操作。
6.2 使用IDL(接口定义语言)描述服务,生成客户端和服务器的存根。这里的存根是什么意思
在RPC(Remote Procedure Call)的上下文中,“存根” (Stub) 是一种代理或接口,用于隐藏底层的远程调用的复杂性。通过存根,开发者可以像调用本地方法一样调用远程方法,而无需关心底层的网络通讯、数据序列化/反序列化等细节。
在使用IDL(接口定义语言)描述服务时,你基本上是在描述一个远程服务应该具备哪些功能或方法。然后,使用特定工具根据这个IDL生成客户端和服务器的存根。
-
客户端存根 (Client Stub):当你在客户端调用某个远程方法时,实际上是在调用客户端存根。这个存根会负责:
- 序列化方法调用的参数。
- 通过网络将这些序列化的数据发送到服务器。
- 等待服务器的响应。
- 接收服务器的响应并反序列化,然后返回给调用者。
-
服务器存根 (Server Stub):服务器端有一个匹配的存根,负责:
- 接收客户端发来的请求并反序列化。
- 调用本地的实现方法。
- 序列化响应结果。
- 将序列化的响应结果发送回客户端。
这种设计允许客户端和服务器在不同的平台和/或使用不同的编程语言时仍然可以通讯。因为只要它们遵循相同的IDL定义并使用相同的数据序列化/反序列化机制,就可以相互理解。
6.3 怎么路由的呢?(路由策略(如轮询、随机、最少活跃调用等)
服务路由:
在有多个服务提供者提供相同服务的情况下,客户端需要决定调用哪一个服务提供者。这时,路由策略(如轮询、随机、最少活跃调用等)会被应用。负载均衡策略会根据本地缓存的服务提供者地址列表选择一个。
讲解了我怎么设计负载均衡算法的,以及每种策略的适用场景:
负载均衡的目的是将网络流量分散到多个服务器,以确保每个服务器都不会因超载而宕机,并且可以最大化吞吐量、最小化响应时间并避免任何单一点的故障。以下是一些常用的负载均衡策略,以及各自的适用场景:
-
轮询 (Round Robin)
- 策略:这是最简单的负载均衡算法,请求按顺序分配到服务器。如果服务器列表到达末尾,则重新开始。
- 适用场景:当所有服务器都具有相似的规格并且预期的请求处理时间相似时,轮询是一个好选择。
-
加权轮询 (Weighted Round Robin)
- 策略:与轮询相似,但给每个服务器一个权重,权重较高的服务器会接收到更多的请求。
- 适用场景:当你有不同能力的服务器并希望每台服务器都接收到与其能力相称的流量时。
-
最少连接 (Least Connections)
- 策略:将请求路由到连接数最少的服务器。
- 适用场景:适用于服务器处理速度大致相同,但处理请求的时间可以变化的场景。例如,如果有一个长轮询或Websockets服务。
-
加权最少连接 (Weighted Least Connections)
- 策略:与最少连接类似,但考虑到每个服务器的权重。
- 适用场景:当服务器规格和处理速度不同时,且处理请求的时间可变。
-
IP哈希 (IP Hash)
- 策略:基于请求者的IP地址确定应该路由到哪个服务器。通常是通过取IP的哈希值然后对服务器数取模得到的。
- 适用场景:当你希望来自特定IP的客户端始终连接到同一个服务器,这在需要保持会话或某些级联数据缓存时非常有用。
-
URL哈希 (URL Hash)
- 策略:基于请求URL的哈希值来确定路由到哪个服务器。
- 适用场景:特别适用于HTTP缓存服务器,因为请求的相同URL可以确保路由到包含其缓存的同一服务器。
-
最短延迟 (Least Latency)
- 策略:负载均衡器持续检测每台服务器的延迟或响应时间,并将请求路由到响应最快的服务器。
- 适用场景:对于需要实时或快速响应的应用,如在线游戏或语音通信。
-
健康检查
- 策略:定期检查服务器的健康状况,如果服务器未响应或返回错误,它将从活动服务器池中移除,直至再次被确定为健康。
- 适用场景:适用于任何需要高可用性的应用。
根据你的应用类型、服务器规格和预期的流量模式选择合适的策略是关键。很多现代的负载均衡器都支持这些策略,并允许你基于实时流量模式动态地切换策略。
6.3.1 ## 加权随机(轮询)策略怎么做的
答:
加权随机:首先会从注册中心拉服务取提供者的元数据,包括了各个机器的配置信息,然后根据他们的配置生成一个成正比的权值,然后对其进行排序,再计算前缀和,最后根据权值总和生成一个在此范围内的随机数,看这个随机数落在哪个区间,如果在这个区间就将这个机器作为当前rpc调用接收的机器
加权轮询和加权随机的区别:加权轮询是一种按照权重轮询选择服务的策略。它会按照每个服务的权重进行轮询,权重高的服务被选择的概率更高。加权随机是一种按照权重随机选择服务的策略。它会根据每个服务的权重随机选择一个服务,权重高的服务被选择的概率更高。
6.3.2 最少连接 (Least Connections)和加权最少连接 (Weighted Least Connections)的区别
最少连接 (Least Connections)
和加权最少连接 (Weighted Least Connections)
都是负载均衡的策略,用于决定将请求路由到哪个后端服务器。虽然这两个策略在名字和核心思想上相似,但它们有明显的差异。
-
基本策略:
- 最少连接 (Least Connections):这种策略会简单地选择当前活跃连接数最少的服务器。换句话说,它会看哪个服务器当前处理的请求最少,然后将新请求发送到那里。
- 加权最少连接 (Weighted Least Connections):这种策略进一步考虑了服务器的权重。权重可以基于多种因素,如服务器的CPU能力、内存、带宽等。权重高的服务器将处理更多的请求,而权重低的服务器则处理较少的请求。
-
决策过程:
- 在
最少连接
策略中,仅考虑当前的连接数。 - 在
加权最少连接
策略中,除了考虑当前的连接数外,还要考虑服务器的权重。具体的算法可能会因实现而异,但通常情况下,计算方式是用服务器的权重来调整其连接数,从而确定路由。
- 在
-
适用场景:
- 最少连接:适用于后端服务器的规格和处理能力大致相同的情况,因为它仅仅根据连接数进行路由决策。
- 加权最少连接:当后端服务器之间的规格和处理能力存在差异时,这种策略就很有用。例如,如果有两台服务器,其中一台是高规格的、能够处理大量请求的,而另一台是低规格的、处理能力较弱,那么可以给高规格的服务器分配较高的权重,确保它处理更多的请求。
总结:最少连接
策略关注的是平均分配,确保所有服务器的负载均衡;而加权最少连接
策略则尝试考虑服务器的实际能力,并相应地进行负载分配。
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 一般服务的内部rpc调用需要经过api-gateway网关吗?
一般来说,服务的内部调用(即微服务之间的直接通信)不需要经过API网关(所以一般在讲解调用链的时候最好讲简洁一些)。API网关主要的目的是作为系统和外部消费者之间的一个接入点,处理进入系统的请求。这有助于集中处理某些横切关注点,如请求路由、API组合、限流、认证和授权等。
当内部服务需要相互通信时,它们通常会直接进行服务到服务的调用,可能通过服务发现机制来查找和定位其他服务。
然而,在某些架构或特定的场景中,可能有以下几个原因导致内部服务调用经过API网关:
-
统一的入口和出口策略:某些组织可能希望所有进出的通信都经过API网关,以便于流量的监控、日志记录或其他统一的策略。
-
增强的安全性:API网关可能提供额外的安全层,如对某些敏感操作进行额外的验证或过滤。
-
组合API:如果一个服务需要从多个其他服务中聚合数据,API网关可能会提供API组合功能,这样服务可以在一个单一的请求中获取所有必要的数据。
-
转型或适配:API网关可能提供转型功能,如将一个旧版本的API调用转换为新版本。
但这并不是常规做法。过多地依赖API网关可能导致网关成为一个单点故障或性能瓶颈。对于内部服务间的通信,更常见的做法是使用服务网格(如Istio或Linkerd)来处理服务之间的通信,提供负载均衡、故障恢复、度量和安全性等功能,而不是依赖API网关。
6.5 比如我现在想要调用服务端的methodA方法,服务端怎么知道客户端想要调用的就是这个方法呢?
6.5.1 具体的流程
客户端会定一个类,叫RpcRequest,这个类定义了需要调用远程方法的所有属性,包括接口名、方法名、方法所需要的参数及其类型,同时还包含了一个请求号(这里的请求号可以不说,因为是旁路,会影响面试官的听到主要答案),然后通过客户端本地存根(也就是代理),会给我们自动创建相应的RpcRequest对象并且进行属性填充,这里的属性是根据我们调用的具体方法、消费者提供的参数进行填充的,
填充完成后,代理类还会帮我们根据选择的序列化器将实际的请求体进行序列化操作,序列化后的rpcRequest数据会放到请求体中,然后再通过网络发给服务提供者。
生产端拿到请求后会先进行反序列化请求体,拿到实际的RpcRequest的数据后会根据接口名、接口方法以及参数列表会进行反射得到一个可执行的方法,然后进行条用就行了。
6.5.2 涉及到一些具体的细节
在RPC(Remote Procedure Call)系统中,当客户端想要调用服务端的某个方法时,它需要通过某种方式告知服务端应该调用哪个方法。为了实现这一目标,客户端发送的请求通常会包含一些元数据来描述所需的方法和参数。以下是一些常用的方法来实现这一目的:
-
方法标识符:
当客户端发送请求时,它可能会在请求中包含一个字符串或数字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;
}
-
序列化和反序列化(涉及到具体的协议):
除了方法标识符,客户端还需要将方法的参数序列化为一种可以通过网络传输的格式(如JSON、protobuf等)。服务端在接收到请求后,会反序列化这些参数,并根据提供的方法标识符调用相应的方法。
-
反射:
在接收到请求并且反序列化,服务端可能会使用反射来查找并调用相应的方法,因为之前客户端已经发送调用该方法所需要的所有参数及其类型,所以方法能够调用成功。这就是为什么在一些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
方法,用于为指定的接口创建一个动态代理对象。这个动态代理对象会在方法调用时,代理到 RpcClientProxy
的 invoke
方法。
当你调用:
HelloService helloService = rpcClientProxy.getProxy(HelloService.class);
这里的 HelloService.class
是被传入 getProxy
方法的,从而使用 Java 的动态代理机制创建了一个 HelloService
的代理对象。这意味着当你调用这个代理对象上的任何方法(如 helloService.hello(object)
),实际上都会被代理到 RpcClientProxy
的 invoke
方法。
在 invoke
方法中,你可以看到它实际上创建了一个 RpcRequest
对象,其中包含了方法的名称、参数等信息,然后通过 RPC 客户端发送这个请求到服务端。这是一个典型的 RPC 客户端的实现方式,通过代理模式将本地的方法调用转化为远程方法调用。
所以,你所说的 “传入 HelloService.class
的作用应该只是为了创建代理对象” 是正确的。这样做的目的是为了使得客户端的代码能够以一种看似本地调用的方式,实际上进行远程方法调用,提供了一个很好的抽象。
6.6 假设现在服务的provider想要下线了,如何做到让服务调用端感知?
当服务提供者(Service Provider)想要下线时,为了保证服务的可用性和避免服务调用失败,通常采取以下策略让服务消费者(Service Consumer)或调用端感知:
-
使用服务注册与发现机制(涉及到注册中心、消费者和提供者三方的交互机制):
- 在许多RPC和微服务框架中,都采用了服务注册与发现的机制,如Zookeeper、Consul、Eureka等。
- 当服务提供者打算下线时,首先从服务注册中心撤销或下线其服务。
- 服务消费者定期从服务注册中心拉取服务列表,这样当提供者下线后,消费者会感知到这一变化,并不再向该提供者发送请求。
-
优雅关闭(服务提供者):
- 服务提供者在停机前,首先停止接收新的请求,但继续处理已经接收的请求。
- 一旦所有已接受的请求都处理完毕,再完全关闭服务。这确保了没有请求在中途被突然中断。
-
使用负载均衡器或API网关(利用第三方网关阻断流量):
- 如果你使用了负载均衡器或API网关来路由请求,当服务提供者想要下线时,首先在负载均衡器或API网关中移除该提供者。
- 通过这种方式,即使服务提供者还在运行,消费者的请求也不会被路由到该提供者。
-
健康检查(注册中心的机制):
- 服务提供者通常会提供健康检查的接口,用于报告其健康状态。
- 当服务提供者计划下线时,可以修改健康检查的响应,表示其不再健康或即将下线。
- 服务消费者或负载均衡器定期检查服务的健康状态,感知到该变化后,会停止向提供者发送请求。
-
通知和告警(预计的维护或下线):
- 当服务提供者计划下线时,可以手动通知所有已知的服务消费者。
- 这通常适用于预计的维护或下线,确保消费者有足够的时间来做出响应或备份方案。
-
使用熔断器和重试机制(熔断重试,会造成多余的请求):
- 熔断器可以检测到连续的请求失败,并自动切断到提供者的请求,避免向已下线的服务发送请求。
- 重试机制会在请求失败时尝试其他可用的服务提供者。
结合上述策略,可以确保当服务提供者下线时,服务消费者能够及时感知并作出相应的处理,避免服务中断或大量的请求失败。
6.7 假设现在服务端处理一个请求需要一秒钟,结果在不到一秒的时候我把我的机器给关了,那用户就报错了嘛,这块怎么处理让它不报错,因为报错就会对业务产生消极影响(小红书一面)
我的思路:得看场景,一般可以采取dubbo的容错策略,比如失败安全、故障转移、失败分叉、失败回退,然后还可以参考服务的降级措施,对于一些无关紧要
如果客户端(用户的设备或应用)在等待服务端响应时突然关机或断开连接,确保用户不看到错误是一项挑战。以下是一些建议的处理方法:
-
服务端冗余与故障转移:
- 在服务端部署多个实例,当一个实例发生故障时,流量可以自动切换到另一个健康的实例。
- 使用负载均衡器来分发请求,同时检查各个服务实例的健康状态。
-
客户端策略:
- 延迟显示错误:客户端可以增加一个较长的超时时间,在此时间内,如果没有收到服务端的响应,可以认为服务端出现了问题。但在此时间内,用户界面可以显示一个“处理中”或“稍等”的提示。
- 重试机制:客户端在收到错误响应或超时后,可以尝试再次发送请求,但要注意不要无限次地重试,以避免资源耗尽或不必要的网络流量。
- 本地缓存和备份:对于某些可预测的请求,客户端可以缓存之前的结果,并在服务端无响应时返回这些缓存的数据。
-
使用离线策略:
- 对于某些应用,可以考虑使用离线策略,即在无法立即处理的情况下,将请求保存到本地,等到网络稳定或服务端可用时再处理。
-
友好的用户界面:
- 即使在错误发生时,也应该展示友好的提示,例如:“网络不稳定,请稍后重试”或“我们正在处理您的请求,请稍候”等,避免直接展示技术性错误信息。
-
后台处理与通知:
- 对于不需要立即响应的请求,可以让客户端提交请求后立即返回,而实际的处理则在服务端后台进行。完成后,通过通知或其他方式告诉客户端。
-
数据一致性:
- 保证服务端的数据处理逻辑具有幂等性,这样即使客户端发送了多次请求,也不会影响数据的一致性。
-
使用消息队列或事件驱动模型:
- 客户端发送的请求先存入消息队列,由服务端异步处理。客户端可以轮询或等待通知来获取结果。
在设计系统时,应该从用户体验的角度出发,尽量减少错误的展示,但同时也要确保系统的稳定性和数据的准确性。
7 使用netty自定义协议
7.1 为什么要进行自定义协议?
答:我们都知道有许多不同的应用层协议,每一种协议都有其适用场景,http适用于web服务器,ftp适用于文件服务器,rpc适用于服务间的调用等;如果一个大企业有自己独特的业务,可能目前市面上任何一种协议都不能满足业务的某方面的极致性能或者安全需求,那么这个时候可以自定义一套上层应用协议,以适应自己的业务需要。
7.2 HTTP的Transfer-Encoding: chunked和content-length的关系
Transfer-Encoding: chunked
和Content-Length
是HTTP协议中用于指示消息体长度或消息体的传输方式的两个头部字段。它们在某种程度上是互斥的,即在一个具体的HTTP消息中,一般只会有其中一个。以下是它们之间的关系和区别:
-
目的:
Content-Length
:用于明确指定消息体的长度(以字节为单位)。当服务器或客户端看到这个头部时,它知道要读取的确切的字节数。Transfer-Encoding: chunked
:用于指示消息体将以分块方式传输。这允许发送方在不预先知道消息体大小的情况下发送消息。
-
使用场景:
Content-Length
:当消息体的大小已知时,通常使用此头部。这在静态资源或预先计算好大小的动态内容中很常见。Transfer-Encoding: chunked
:当消息体的大小不确定或动态生成时,可以使用这种方法。这使得服务器可以立即开始传输而不必等待整个响应生成。
-
格式:
Content-Length
:其值是一个整数,表示消息体的字节数。Transfer-Encoding: chunked
:消息体被分为多个块,每个块前面都有一个表示块大小的十六进制数,后跟一个CRLF,然后是块的内容。消息以一个大小为0的块结束。
-
互斥性:
- 在一个HTTP/1.1消息中,通常不会同时出现
Content-Length
和Transfer-Encoding: chunked
。如果两者都出现,那么Transfer-Encoding
的值将优先被考虑,并可能导致Content-Length
被忽略。
- 在一个HTTP/1.1消息中,通常不会同时出现
-
版本限制:
Content-Length
在HTTP/1.0和HTTP/1.1中都有使用。Transfer-Encoding: chunked
是在HTTP/1.1中引入的。HTTP/1.0不支持分块传输编码。
总结:Content-Length
和Transfer-Encoding: chunked
都是HTTP消息中用于表示消息体如何传输的机制,但它们用于处理不同的场景和需求。Content-Length
提供了一个静态的、预先定义的消息体大小,而Transfer-Encoding: chunked
允许动态地、分块地发送消息体。
7.3 content-length解决粘包和Transfer-Encoding: chunked解决拆包粘包问题有何不同?
7.3.1 对于content-length来说,在http1.1中,因为顺序发送或者顺序接收(流水线),或者开多个tcp连接,所以当一个HTTP请求被分割成多个块发送时
7.2 自定义MRF协议(能跟面试官吹牛逼)
这几个字段的作用就如图所示,跟HTTP协议一对比,发现有很多相似之处。
8 TCP的拆包和粘包问题(在面试的时候可以根据http的解决粘包和拆包流程回答自定义的拆包和粘包流程)
8.1 为什么会产生拆包和站包问题
8.1.1 还有一个问题,造成一个比较大的http包被拆分的根本原因是因为数据链路层的MTU只有1500B,所以超过1460B数据的HTTP包都会被拆分成多个TCP报文段,对不对?
基本上是对的。MTU(最大传输单元)是数据链路层的概念,它指定了网络接口(如以太网接口)一次能发送的最大字节数,这包括了数据以及链路层的头部信息。
对于标准以太网,MTU通常是1500字节。如果我们减去IP和TCP头部(通常20字节IP头 + 20字节TCP头,总共40字节,但可能更多如果使用了选项字段),那么TCP段的最大有效载荷是1460字节。
如果一个HTTP请求或响应的大小超过了TCP的有效载荷限制(如1460字节),那么这个HTTP消息就会被分割成多个TCP报文段来发送。
所以,你是对的:数据链路层的MTU限制是造成大HTTP消息被拆分成多个TCP报文段的主要原因。但也要注意其他可能的限制,如上文提到的TCP窗口大小、应用行为和其他网络因素。
8.1.2 MTU的限制导致了拆包的行为?
TCP协议本身是一个面向流的协议,而不是面向消息的协议。这意味着TCP并不保证您发送的"包"(数据块或消息)会作为单个包到达接收方。因此,在TCP层面,拆包(拆分包)和粘包(粘连多个包)是可能发生的。
-
拆包: 如果您有一个大于TCP段最大有效载荷(例如1460字节)的HTTP消息,那么这个消息会被拆分成多个TCP段进行发送。接收端需要重新组装这些TCP段以恢复原始的HTTP消息。
-
粘包: 另一方面,如果有多个小HTTP消息,它们可能会在一个单独的TCP段中一起发送,尤其是如果它们短到足以在一个TCP段内适应,并且都在同一时刻可用于发送。
8.1.3 粘包如何被发送到服务端的?
“如果有多个小HTTP消息,它们可能会在一个单独的TCP段中一起发送,尤其是如果它们短到足以在一个TCP段内适应,并且都在同一时刻可用于发送”, 这是说将多个HTTP消息封装到同一个tcp报文段中(这时产生了粘包问题),这个tcp报文段不超过1560B,被放到tcp套接字发送缓冲区中,在某一个时刻被发送至接收端,接收端需要知道从这个tcp包中区分多个http请求的方法,区分之后,需要将其分发至不同的接口控制器。
8.1.4 一个发生了拆包的HTTP请求如何被发送到客户端的?
答:如果一个服务器响应给客户端的响应体太大,超过了1460B,假如是2000B,那么会拆分成两个tcp报文段,这两个tcp报文段会经过同一个套接字缓冲区发送给客户端。
8.2 如何解决粘包和拆包问题的呢?
8.2.1 在使用content-length+分隔符的情况下
content-length字段表示这个http请求的总长度,如果被拆包,那么就可能是这多个tcp报文段的数据段大小之和,一般这个字段只有在第一个tcp段的,因为一个HTTP请求被拆包时,HTTP的请求头只会出现在第一个报文段,所以接收端会根据content-length字段和固定大小为1460B的报文段计算,这个http请求涉及到几个tcp报文段,并且边界在最后一个tcp报文段中的偏移量。
-
解决拆包问题的例子
假如是2000B,那么会拆分成两个tcp报文段,第一个报文段包含1460B的HTTP请求的数据,第二个包含540B的数据,这两个tcp报文段会经过同一个套接字缓冲区发送给客户端,客户端的接收缓冲区接收到后,会根据第一个报文段包含的请求头的content-length字段知道这个http请求有两个包,第一个包的长度是1460B, 第二个读取到560B的偏移量的数据就行了(被拆包的后续子包不会有content-length字段);然后又继续向下遍历,就知道了下一个http请求的content-length和定界符… -
解决粘包问题的例子
假设响应给一个html页面多个资源,会发送多个http响应,每一个的数据量都很小,小到一个tcp报文段就可以装得下,那么这个tcp报文段传送到客户端时,客户端会首先解析第一个http请求的请求头,发现其有一个content-length字段和定界符,那么就可以找到第一个http请求的边界,随后继续解析这个报文段,又会发现第二个http请求的定界符和报文段,这样就可以找到第二个请求的定界符,后续的寻找过程同上。
8.2.2 从tcp报文段中提取出第一个请求,如何读取其请求头中的数据,毕竟头部的长度似乎不固定?
确实,HTTP的请求头(或响应头)的长度是不固定的。但是,HTTP协议为头部和消息体之间的分隔定义了一个明确的标志:两个连续的CRLF(回车符和换行符,即\r\n\r\n
)。
当从TCP套接字中读取HTTP请求或响应时,通常采用以下的流程:
- 从TCP流中逐字节或逐块读取数据。
- 检查读取的数据,找到连续的CRLF序列(
\r\n\r\n
)。 - 一旦找到这个序列,您就知道头部结束,之后的数据属于消息体。
然后您可以进一步解析头部,找到例如Content-Length
这样的字段,来确定消息体的长度。
对于大多数HTTP服务器和客户端,这个过程是通过一些现有的库或框架自动完成的,它们为您处理这些底层细节。但如果您正在手动编写代码来解析HTTP请求,那么上述步骤就是您应该遵循的。
8.2.3 然后您可以进一步解析头部,找到例如Content-Length这样的字段,来确定消息体的长度。讲一讲解析的过程
当从TCP套接字中接收到HTTP请求或响应数据时,您的应用程序或库需要进行一些步骤来正确地解析头部并获取消息体。以下是解析HTTP头部的基本过程:
-
接收数据直到头部结束:
- 持续从TCP流中读取数据,直到您找到连续的CRLF序列(
\r\n\r\n
),这标志着头部的结束。
- 持续从TCP流中读取数据,直到您找到连续的CRLF序列(
-
将头部划分为行:
- 使用CRLF(
\r\n
)作为分隔符将头部数据切分为多行。
- 使用CRLF(
-
解析请求/响应行:
- 如果您正在处理一个请求,第一行应该是请求行,例如:
GET /path HTTP/1.1
。您可以根据空格将其切分,得到请求方法(如GET、POST等)、请求的URI和HTTP版本。 - 如果您正在处理一个响应,第一行是状态行,例如:
HTTP/1.1 200 OK
。您可以根据空格来获取HTTP版本、状态码和状态消息。
- 如果您正在处理一个请求,第一行应该是请求行,例如:
-
解析头部字段:
- 对于接下来的每一行,按照冒号(
:
)来切分,得到字段名和字段值。例如,Host: example.com
会被切分为字段名“Host”和字段值“example.com”。 - 注意处理可能的空白字符或额外的空格。通常字段值前面会有一个空格,您可能需要去掉它。
- 对于接下来的每一行,按照冒号(
-
查找关键字段:
- 查找您关心的头部字段,如
Content-Length
。此字段的值告诉您消息体的长度。
- 查找您关心的头部字段,如
-
读取消息体:
- 根据
Content-Length
字段的值从TCP流中读取相应的字节数,这将是完整的消息体。
- 根据
此外,如果您不找到Content-Length
字段但找到了Transfer-Encoding: chunked
字段,那么消息体是分块传输的。您需要处理每个块的大小、块数据和块之间的分隔符,直到您读取到一个大小为0的块,表示消息体的结束。
以上描述的只是解析HTTP 1.1消息的基础过程。HTTP/2 和 HTTP/3 使用了不同的机制,涉及到帧、流和其他概念。但对于基本的HTTP 1.1消息,上述步骤应该足够了。
8.2.4 其他方案:
面试题:聊聊TCP的粘包、拆包以及解决方案
HTTP的请求的解决方案就是这个链接中讲到的第四种:将消息分为头部和消息体,头部中保存整个消息的长度,只有读取到足够长度的消息之后才算是读到了一个完整的消息;因为HTTP的请求头(或响应头)的长度是不固定的。所以,HTTP协议为头部和消息体之间的分隔定义了一个明确的标志:两个连续的CRLF(回车符和换行符,即\r\n\r\n
)。
我的rpc项目中,使用的也是这种方案,只不过因为请求头固定长度为16个字节,请求头和请求体很容易被区分,所以就不需要HTTP协议中的明确标志:两个连续的CRLF(回车符和换行符,即\r\n\r\n
)。
8.2.5 断点续传和拆包请求,适合Epoll的哪种触发模式?
Epoll的两种触发模式:
-
水平触发(Level-Triggered,LT):
- 默认模式,事件持续有效,直到所有数据被完全读取。
- 如果套接字中有数据可读,Epoll会反复通知应用程序,直到应用程序将所有数据读完。
- 适合需要反复读取数据的场景,如拆包、缓慢传输等。
-
边缘触发(Edge-Triggered,ET):
- 高性能模式,只有在新数据到达时通知应用程序。
- 如果应用程序没有一次性读取完数据,Epoll不会再次通知,可能导致数据遗漏。
- 适合高吞吐量场景,但需要确保应用程序能及时读取所有数据。
适用场景:
- 断点续传和拆包比较多的请求,更适合水平触发模式(LT):
- 在拆包场景中,数据可能分多次到达接收端。LT模式会持续通知接收端,确保数据被完整读取。
- 边缘触发(ET)需要一次性读取完所有数据,否则会导致数据丢失,不适合拆包或断点续传这种数据到达不确定的场景。
关键点:
- **LT模式:**适合复杂的网络通信场景,如拆包、缓慢数据传输等。
- **ET模式:**适合高性能场景,但应用程序必须严格遵守“读尽数据”的原则。
总结:
-
**多TCP连接和长连接不是互斥的,可以同时使用。**例如,浏览器可以为同一域名打开多个TCP连接,并在每个连接中使用长连接。
-
拆包时,Content-Length字段只在HTTP响应头中存在且只发送一次,后续小包中不会有
Content-Length
字段。 -
拆包和断点续传更适合Epoll的水平触发(LT)模式。 LT模式会确保所有数据被完整读取,而ET模式对程序处理能力要求较高,不适合数据到达不确定的场景。