如果你的项目是烂大街rpc,面试该怎么吹?

以下的万字长文基本涵盖了你面试可能会遇到的所有RPC项目问题。

首先,对于所有写了rpc项目的牛油们,我都建议你们去看看百度开源的brpc文档,可以作为深入rpc的一个重点,重点关注brpc的这几块内容:LALB+双缓冲的负载均衡设计,无锁化的MPSC消息发送,高度并发的消息读取,从锁到lock free再到wait free的思想,用户态协程bthread的设计(你不需要去看源码,你只需要去看brpc的设计文档就可以了)

当你掌握了brpc的一些设计思想,在面试过程中就要有意识地 将问题引到brpc的设计上,然后开始你的回合。

我在春招面百度、快手、腾讯之类大厂的时候,都跟面试官深入地聊过brpc的设计(大多是我单方面在输出),面试官们的评价大多是:“感觉你很有技术视野”,“看得出来你对技术还是很有追求的”,面试也都顺理成章地通过。

在RPC项目已经烂大街的情况下,深入地吹水一门开源的rpc框架是一个不错的体现差异化的选择,要是看得人多的话我再把关于brpc的一些笔记放上来。

- 为什么需要rpc:单机架构,因为业务扩大,系统日益复杂庞大,单机性能受限,变成集群架构,并通过负载均衡技术均摊流量,克服单台机器硬件资源的限制,做到横向扩展。集群架构扩展性较差,所以拆分成微服务架构,一方面最大化系统资源利用率,根据不同服务需求选择合理硬件资源,另一方面在服务纬度重用,上线或下线更方便,可扩展性强,其中的微服务高性能通信,负载均衡,服务治理:使用rpc来解决

- rpc架构设计

- 客户端处:入口层(动态代理),集群层(路由插件,服务发现,连接管理,负载均衡,配置管理),拦截器,协议层(序列化插件,解压缩插件),传输层(tcp传输,http传输插件)

- 客户端发起请求

- 注册中心发路由策略筛选客户端可调用节点

- 路由策略有IP策略(限制部分IP的客户端不能访问某些节点,只让部分请求调用新上线的实例,灰度发布),参数化路由(更细粒度的理由方式,比如根据sessionid保持会话有效性等)

- 进行负载均衡(客户端和所有服务节点建立长连接,进行rpc调用时通过负载均衡插件从可选的节点里自主选择最佳节点发起调用)

- 做这个项目收获最大的是什么:熟悉rpc的设计到实现,序列化技术的原理和选型,高性能的网络模型设计,zookeeper分布式注册中心的设计和使用,打开了学习服务治理框架的大门,以此为钥匙学习grpc,trpc,dubbo等主流的rpc框架

- 制约rpc框架的关键是什么:协议设计(紧凑易于解析扩展),并发模型(高并发),序列化技术

- 可以在哪些方面做优化进行性能提升

- 性能提升的关键:把操作系统做的所有事情,自己在用户态都实现一遍,cpu管理用无锁数据结构+协程在用户态实现cpu资源的分配和协调;网络IO用DPDK+mTCP在用户态实现tcp/ip协议栈;同时优化更上层结构的性能,比如说数据的序列和反序列化是否零解析开销,是否零copy;日志库是不是支持异步无锁地提交日志;以及负载均衡、熔断和限流的实现性能

- CPU管理-无锁数据结构

- 以无锁队列为例,分为SPSC(single producer single consumer),MPMC,MPSC,SPMC,对于SPSC可以基于循环数组实现,push修改tail值,pop修改head值,不需要考虑push和push之间、pop和pop之间的互斥,比较简单;MPMC的话增加一个ticket机制,每一个slot都同时存储当前的轮次turn,同时存储两个单调递增的read ticket和write ticket,当众多生产者和消费者需要push或pop的时候,需要先请求到一个ticket,push时如果当前slot的turn = 2 * write ticket即可写入,然后将turn + 1(这个时候正在自旋等待的消费者如果发现当前slot的turn = 2 * read ticket + 1,即可读取),以此确保在队列任意位置都需要先push再pop(通过turn值确保),且一旦该位置被读取了,只能在下一轮被写入,因为本轮的写入和读取会不断推进slot直到循环回来

- 需要注意的是无锁数据结构的内存回收,可以采用引用计数或EBR算法

- CPU管理-用户态协程:

- 本质上是一个由调度器或用户主动进行暂停和恢复的函数,工业级别的协程库有Bthread(M:N)

- 协程本质上可以当做是一种由调度器或用户主动进行暂停和恢复的 特殊函数,创建的时候需要保存函数指针 以及 堆栈信息,使用resume语义进行协程的运行,使用yield用于代码级别的主动让出cpu

- Stackfull:每个协程拥有自己的调用栈,并将上下文保存在自己的栈中;切换协程=切换到自己的栈并恢复栈中的上下文,这种实现更像是用户态的线程,空间利用率不是很高:栈空间通常预留一块固定大小的区域,而协程实际中使用不到这么大的空间,所以并发量大时内存使用量很高

- 适用于每个协程具有大量局部变量,计算密集型的任务

- Stackcopy:所有协程公用一个调用栈,当协程被调度时创建一个当前线程栈的赋值,在后续的执行中使用这个赋值的栈来恢复协程的执行状态,而不需要占用原始线程的栈空间

- Stackless:协程把自己的状态保存在某个结构体,协程调度器根据这个结构体来决定下一步的执行计划;而不是像stackcopy一样把执行栈中的全部变量和寄存器值都copy一遍,节省内存同时也减少协程上下文切换的消耗

- 适用于IO密集型或事件驱动型程序,创建大量协程不会消耗太多内存

- 网络IO:阻塞——》非阻塞——》IO复用——》异步IO(io_uring)——》DPDK+mTPC + 多Recator协程

- 序列化协议比较:最优选择之一是pb,因为它不是完全的自描述,在通信数据中移除字段名称,大大降低消息的长度;同时也支持零Copy解析,也就是可以直接在源输入进行解析,而不需要把数据从源输入copy到一个buffer里再解析

- 负载均衡:

- 对于负载均衡这种读多写少的数据结构,大部分时候都是线程从一个不变的server列表中选取一台server

- 一种方法是读写锁,但当读临界区不是很大的时候,其实跟mutex差不多,而实用的分流算法往往读临界区都不会很大,这种方法是开销较大的

- 可以通过双缓冲实现无锁的查找,把数据分成前台和后台,查找的时候直接拿到当前线程的锁然后读前台数据,查找完再释放锁;同时只有一个写:修改后台数据后,把新后台切换到前台(让新的读 在新前台),然后等待每一个读锁释放,结束之后再修改一遍现在的后台(老前台数据)

- 什么是rpc:远程过程调用协议,过程就是业务处理计算任务,实现客户端在不知道调用细节的情况下,像调用本地方法一样调用远程的过程

- rpc的原理是什么:客户端处理过程中调用client stub传入参数,在stub里处理参数编码成消息 然后发给服务端 服务端收到消息后传递给 server stub,由stub将数据解组为参数 并由它调用服务端上的过程,然后再将执行结果以反方向的相同步骤响应给客户端

- stub存根是什么:一个代理类,用来拦截客户端的方法调用,在代理类中处理逻辑里完成一整套的远程调用

- rpc通讯协议是怎么设计的:协议头里面:协议长度,序列化方式,消息类型,消息ID,协议体里放请求接口方法,请求业务参数等设计成可扩展的,额外记录一下协议头长度以及协议头扩展字段

- rpc协议和http协议有什么区别:rpc负责应用间通信,性能要求很高,而http数据包大小相对于请求数据本身大很多,且要加入换行回车等无用的内容;rpc选择设计更紧凑的私有的应用层协议

- rpc协议是怎么实现连接不断开的:调用端为每一个消息生成一个唯一的消息ID,通过消息ID来将请求和响应关联起来

- grpc和thrift有什么区别:grpc主要搞了protobuf,采用http协议;thrift的话在传输层和服务端都自己设计,所以可以对协议层和传输层有多种控制要求(比如说服务端可以选择阻塞io的多线程模型,也可以选择非阻塞io的多线程模型)

- protobuf有什么优点:二进制字节数少,数据描述文件只有1/10,传输解析快,速度提高20倍以上;严格IDL定义;可编译性强

- protobuf为什么编码后大小较小:特殊的编码方式;json和xml会将字段名传输在文件中,而protobuf通过IDL定义字段名,通过git或svn从客户端传给服务端IDL,这样在传输的数据中就不带字段名了,进而减少数据大小

- protobuf是怎么做到数据的序列化反序列:

- 在各个字符串间以Tag分隔,在makeTag函数里根据filenumber和weitetype生成间隔字符串的Tag(通过tag可以知道接下去的那个数据类型)

- 对于数字使用zigzag(-1对应zigzag编码后的1,1对应2,-2对应3…),以及varint编码(最高位为1,代表下一个八位也属于当前表示的这个数字,最高位为0,代表下一个八位不属于当前数字,以此减少表示数字的字节浪费),对于字符串则是Tag-Lentgh(varint表示)-Value(字符串值)

- protobuf有什么缺点:需要预定义数据结构,不支持动态解析;不支持自描述,接收方需要实现知道数据的结构才能正确解析

- 怎么样解决TCP沾包问题:沾包问题是对数据包进行读取时无法确定发送方的发送边界,可以通过规定rpc协议来解决,比如规定前四个字节存放消息长度,就可以根据消息长度定位到本条消息的边界

- UDP会有沾包问题吗:不会,TCP是字节流传输,UDP是数据报传输,有明确的结束标志作为边界

- A服务调用B服务失败,节点crash:服务限流(执行业务逻辑前进行限流逻辑,如果>阈值返回异常),服务熔断(动态代理处打开熔断器,保护上流服务)

- 下游服务没有crash,而是负载太高导致响应缓慢,如何避免它被多次调用崩溃:rpc框架里会有一个健康检测机制,通过heartbeat或者可用率等指标将响应缓慢的节点移入亚健康列表,通过负载均衡来避免亚健康的节点接受过多的调用

- 负载均衡实现

随机:list[rand()%list.size()] :分配均匀,不会像轮询法把多次请求给同一个服务器,但随机性可能导致部分服务器超载

轮询:第一个请求给第一个服务器,第二个请求给第二个… list[index++] : 实现简单,但无法根据服务器的负载进行动态调整

哈希算法:解决一些场景希望同样的请求落在同一台机器上的问题,比如说访问缓存集群时希望同一种请求落在同一个后端上,充分利用已有的缓存,通过hash算法输入x总是会发送到hash(x)%n上,但普通的哈希算法存在问题:在服务器数量从n变成m,可能会使得几乎所有请求的发送目的地都发生变化,如果是缓存服务可能会导致所有的缓存失效触发雪崩

——》一致性哈希:在增加服务器时,发向每个老节点的请求只有一部分转向新节点,从而实现平滑的迁移

一致性哈希:

- 简单的说给每个节点分配指定数量的虚拟节点(也就是负载因子),在一个虚拟哈希环上每个区间和一个server对应,如果一个key落在某个区间,它就会被分流到对应的server上,拥有虚拟节点越多的服务节点越容易获得流量

- 删除一个server时,它对应的虚拟结点的m个区间会分别合入到相邻的区间,发往那个server上的请求会较为平均地转移给其他server;增加一个server时,它会分割m个现有区间,从对应server上分别转移一些请求过来

- 从而实现流量的平滑迁移

LALB策略,优先选择延时低的下游: 每个节点有一个权值 = 吞吐量/延时,求出一个累加权值数组,比如说有三个节点权值分别为10,20,30,那么累加权值数组就是0,10,30,60,然后求一个随机数R,如果R落在0-10,就分流给W=10的节点,如果落在10-30就给W=20的节点等等

负载均衡深入:

最常见的分流算法如轮询和随机,这两个方法的前提下是下游的机器和网络都是类似的,但是在目前的线上环境下,每台机器运行着不同的程序组合,配置不同,网络延时也不同,所以我们需要有一个能自适应下游负载、规避慢节点的通用分流算法

即:LALB(locality-aware load balancing),根据下游节点的负载分配流量且能快速规避失效的节点,实际应用场景如:

- 本机有下游服务,其权值必然是最高的,LALB会优先访问这些节点,由于降低了流量走网络的消耗,大大提高成功率

- 需要解决的问题是:按权值分流听起来很简单,但在多线程和可能修改节点权值的前提下,如何实现O(logN)的尽量不互斥的查找算法优化?

——》 双缓冲DBD,在brpc中所有的load balancer都使用了这个数据结构,使得不同线程在分流时几乎不会排斥,而其他的rpc实现往往使用了全局锁,这就让它们无法写出复杂的分流算法:否则分流代码会成为竞争热点

- 对于LALB来说,如果采用原始的查找方式会是一个O(N)的时间复杂度,且由于在查找过程中权值可能变动,因为cacheline的存在,会带来很大的性能损耗

——》 完全二叉树优化,每个节点都存储左子树的权值之和,以此实现O(logN)的查找

- 限流实现

限流:在高并发场景下,避免服务节点因为访问量过大而引起cpu飘高、甚至直接宕机的情况,被调方服务在执行业务逻辑之前先执行限流逻辑,如果超出了限流阈值,就抛回一个限流异常

计数器法:规定系统单位时间能够处理的请求数量,比如规定一分钟接收40个请求,那接收到41个之后就返回限流异常;优点是简单直接,缺点是限流不够平滑(前半分钟来了40个请求,导致后半分钟一个请求都处理不了),也没有办法保证限流速率

滑动窗口法:把时间按照更细粒度的一定比例分片,每个窗口一秒只能处理 请求数/窗口数 的请求,当滑动窗口格子划分越多,滑动窗口就越平滑;优点是可以应对激增的流量,更精确的限流控制,缺点是依然存在限流不够平滑的问题

令牌桶算法:容量上限为n每秒生成r个令牌的限流容器,只有获取到令牌的请求才能执行业务逻辑;随着时间推移,处理请求的平均速率趋近于r个请求每秒,可以控制平均速率;如果瞬时流量打过来最多只有n个请求能获取到令牌,可控瞬时速率

- 熔断实现

熔断:为了防止被调服务超时,导致上游服务调用链连环宕机,对于无法处理请求的服务进行熔断处理

实现思路:在请求失败N次后X时间内不再请求进行熔断;在X时间后恢复M%的请求,如果M%的请求都成功则恢复正常,否则再熔断Y时间,记录请求数、失败数、成功数,根据计算这些数字的比例,来进行全开,半开、断开的调整

- 注册中心设计(最终一致性的服务发现方案)

当有服务上线,注册中心节点收到注册请求,服务列表数据发生变化,会生成一个消息,推送给消息总线,每个消息都有整体递增的版本

消息总线会主动推送消息到各个注册中心,同时注册中心也会定时拉取消息。对于获取到消息的在消息回放模块里面回放,只接受大于本地版本号的消息,小于本地版本号的消息直接丢弃,从而实现最终一致性。

消费者订阅可以从注册中心内存拿到指定接口的全部服务实例,并缓存到消费者的内存里面。

采用推拉模式,消费者可以及时地拿到服务实例增量变化情况,并和内存中的缓存数据进行合并

- grpc设计:

- 设计特点:使用protobuf,基于IDL文件来定义服务,使用proto工具生成stub;基于http2.0设计 支持双向流,单tcp多路复用,生成的代码少而干净

- thrift设计:

- facabook开发的rpc框架,设计特点:可以跨语言使用,在底层上实现了自己的协议层和传输层协议,生成的代码太多

- trpc设计

- 核心诉求:和存量系统互通,保持框架的开放性和可扩展性,所以采用了插件化架构设计理念,总的来说,除了核心模块之外,其他都作为插件引入核心:服务发现,负载均衡,服务配置,服务端,客户端,不同模式的RPC(同步异步单向流式)插件化

- 插件化架构:

- 基于接口机制的插件工厂:基于接口编程的思想,把框架功能抽象成一系列的插件组件,注册到插件工厂后由插件工厂实例化插件,trpc框架负责串联这些插件拼装出完整的框架功能

- 框架设计层:只定义标准接口,没有任何插件实现,与平台完全解耦

- 插件实现层:将插件按照标准接口封装起来即可实现框架插件

- 用户使用层:只需引入自己需要的插件,按需索取

- 基于AOP思想的拦截器

- 在框架指定的动作前后设置埋点,然后通过在埋点地方插入一系列的filter来将系统功能引入框架

- 面向切面编程,解决系统中分布于各个模块的横切逻辑:将框架的功能模块抽象为两个部分,核心关注点(每次业务调用里执行的相应业务逻辑)和横切关注点(除了核心关注点之外的,例行化常规化要做的处理,比如数据预处理,编解码和传输等)

- 全链路数据透传的设计原理

- trpc通过ctx来透传字段,用户把需要透传的字段设置到context中,在打包解包时,会把用户设置的字段 设置到trpc协议头部中的transinfo字段中去,收到数据的一方通过解析transinfo来获取透传的数据

- filter拦截器的设计原理:构造链表 + 递归调用

- 框架要解决的问题是如何将多个filters和最后的后端调用函数callfunc串联,挨个按序执行:采用链表算法的逻辑,使用next和curFilter两个指针,把所有拦截器包装成HandleFunc后串联起来

- 最后一个filter通过next指针指向callFunc(函数签名与HandleFunc一致)

- 拦截器之间因为类型定义和HandleFunc不一致,不能直接使用next指针串联:使用闭包,通过匿名函数方法将filter封装为和HandleFunc一致的签名

- 在实际执行Handle函数时,程序运行栈从anonyFunc1开始,依次递归调用所有的filter,最后一个filer调用callFunc(负责调用我们定义的rpc实现逻辑)

- brpc设计

- 设计特点:

- 使用protobuf做序列化协议;

- 通过Bthread实现了用户态协程,优化cpu管理;

- Message Sending方面:多线程对同一fd的写入是高度并发的,当多个线程都要对一个fd写出时,第一个线程会直接在原线程写出,其他线程会以wait-free的方式托付自己的写请求,多个线程在高度竞争下仍可以在1秒内对同一个fd写入500万个16字节的消息

- 具体实现:每个连接维护一个MPSC的单向链表,线程想要写入时需要获得独占写入权限(原子操作实现),获取失败的线程把消息插入到链表头部(原子操作)并挂起;随后获取权限成功的线程根据连接对应的链表,把自己和链表头的数据尽可能多的写入,如果发现连接的缓冲池已满就开启一个keepwrite bthread执行后续的写入操作(让它将链表所有的数据写入)

- Message Receive方面:对不同客户端请求的读取和解析是完全并发的

- 传统RPC框架:独立的IO线程监听并读取连接上的数据,所以一段时间内一个线程只能读取一个连接,多个繁忙连接聚集在一个线程上,会导致部分连接读取被延迟;

- 具体实现

- 使用eventDispatch监听连接是否可读;可读时在当前线程启动一个新的bthread并切换过去读取;而eventDispatch线程会放回bthread队列,依赖work stealing继续在其他线程执行;这样让bthread读取同一个连接产生的竞争是wait-free

- 在连接上解析出多个数据包,也会立刻启动新的bthread去并发处理数据包;

- 由此连接间和连接内的消息在brpc中都获得了并发处理

- dubbo设计

- 面向接口的远程调用

- 服务自动注册与发现

- 智能容错与负载均衡

- 启动时将服务信息整理成URL注册到注册中心,consumer启动时向注册中心订阅感兴趣的provider信息

- 实现对扩展开放,对修改封闭:微内核+插件,动态加载插件(定义扩展点接口,提供多种实现,创建服务提供者配置文件,使用serverloader查找或加载插件),同时自动装配(拿到扩展实现类扫描所有setter方法,将其依赖的扩展点一并加载)

- 本地缓存:zookeeper服务发现组件会在provider暴露的信息变化时通知consumer的registry组件,将最近注册的provider url保存到本地缓存,如果网络抖动导致订阅失败时,就可以通过本地缓存提供一种容错性

全部评论
mark
点赞 回复 分享
发布于 今天 00:49 新加坡
mark
点赞 回复 分享
发布于 昨天 21:45 上海
m
点赞 回复 分享
发布于 昨天 18:12 江苏
m
点赞 回复 分享
发布于 昨天 16:15 陕西
接好运
点赞 回复 分享
发布于 昨天 15:33 广东
mark
点赞 回复 分享
发布于 昨天 09:52 美国

相关推荐

04-27 19:55
已编辑
东北大学 Java
滴滴秋储后端开发实习4.15 投递4.27 一面+二面一面:面的不算太常规,不是纯八股,问了不少场景题问明明建立了索引,为什么还是慢(索引失效、最左匹配)那系统自己能不能解决最左匹配?分布式环境下,两个主机读到数据库里的同一条数据的内容不一样,分析原因给你一个项目,让你去实现,除了CRUD还有什么点要实现慢SQL怎么解决redis里的key过期了,让你设计一个删除方案,怎么实现其他还有好几个场景题,基本没啥基础八股手撕:反转链表,反转第left到第right个节点手撕要自己写输入输出,有点难搞,到时间没写出来,就写了个方法,输出输出没写完,面试官让讲了个思路10min后,二面:二面难度飙升,先问了入职时间,项目来源,前半部分场景题偏多,后半部分才开始八股,无手撕redis和mysql之间的数据怎么保持一致性保持一致性的缓存删除策略会导致什么问题(提示我是大key问题)mysql的事务是什么,怎么实现当开启一个事务后,在其中插入一个操作,返回这个事务中的数据,会不会出现问题(没听懂是啥问题,答的读写锁)mysql的事务开启后的过期时间默认是多少(根本没听过)mysql的InnoDB引擎里索引的数据结构除了B+树还有什么(hash,前面问蒙了,没反应过来)hash索引的应用场景聚簇索引、非聚簇索引的区别CAS是什么,会出现什么问题(ABA,自旋,单一变量)java中常用的类arraylist、hashmap怎么实现的concurrenthashmap怎么实现的知道哪些数据结构,分别说说应用场景哪些数据结构是有序的,哪些是无序的这是鼠鼠找暑期实习以来第一次到中大厂二面,听说发帖攒好运,求求滴滴收留我,许愿oc#实习进度记录##我的实习日记#
Eason166:反问环节,我问6月份入职可以接受吗,面试官跟我说秋招明年入职都行。我嘞个豆豆啊,给我整蒙了,我说我投的是秋储实习生,面试官才反应过来,俩人嘿嘿一笑。不会是按秋招难度拷打的我吧真蚌埠住了
查看21道真题和解析 实习进度记录 我的实习日记
点赞 评论 收藏
分享
评论
15
99
分享

创作者周榜

更多
牛客网
牛客企业服务