ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写RPC框架:一篇文章读懂远程调用核心原理

手写RPC框架:一篇文章读懂远程调用核心原理 1. 手写RPC之前先想清楚它到底解决什么问题先交代一下背景。我写这个简易版RPC框架的念头来自于一次挺尴尬的面试。面试官问了一个特别基础的问题“RPC的原理是什么”我背了一堆概念什么远程过程调用、服务注册、负载均衡听起来头头是道。结果他接着问“如果让你自己实现一个你会怎么设计”我当场就卡住了。那一刻我意识到能背概念和能落地实现中间隔着一道巨大的鸿沟。后来我干脆花了两周时间从零手写了一个简易版RPC框架。不是想造一个生产级别的轮子——Dubbo、gRPC这些成熟框架已经非常完善我们没必要重复造车。但作为技术人真正搞懂一个框架的原理最有效的方式就是自己动手实现一个最小可用版本。哪怕只是几百行的Demo跑通一次远程调用你对RPC的理解会彻底不一样。先说清楚RPCRemote Procedure Call远程过程调用到底解决什么问题。假设你有两台服务器服务A需要调用服务B上的一个方法。最简单粗暴的方式是什么A直接往B发一个HTTP请求带参数B处理完把结果返回。这本质上也是一种远程调用但RPC要解决的核心问题不是“能不能调”而是“怎么调得让开发者感觉像在调本地方法”。本地调用是什么样的你写一个orderService.createOrder(order)编译器帮你压栈、传参、跳转、返回值。整个过程是同步的、确定的、可靠的因为方法就在同一个进程里大家共享内存。但远程调用完全不一样——参数要变成字节流过网络函数名要变成协议字段返回值要等网络回来还要处理超时、重试、服务端挂了怎么办。RPC框架的价值就是帮你把这一堆乱七八糟的跨进程通信细节全部屏蔽掉让你写代码时感觉还在调用本地方法。我在手写之前给自己定了几个边界。第一语言限定Java暂时不搞跨语言。第二要支持基本的服务注册和发现但注册中心用最简单的本地内存版不上Zookeeper。第三序列化要支持但不是先做性能优化够用就行。第四暂时只做同步调用异步后续再扩展。这个边界很重要很多初学者一上来就想把所有功能做完结果一个月连网络通信都还没调通。先做一个能跑通的最小闭环把骨架搭起来后续的能力都是往这个骨架上填肉。手写一个简易版RPC核心要拆解成四个模块通信层、序列化层、代理层、注册中心。这四个模块各自解决什么问题是一个RPC框架最底层的逻辑。下面我按实现顺序一个一个说每个模块都讲讲我当时的选型思路和踩过的坑。2. 从Socket到RPC通信层设计的第一个关键抉择通信层是RPC框架的地基也是最容易被新手低估的一层。很多人觉得不就是Socket发个数据吗有什么难的。等你真正实现的时候你会发现光是“怎么让服务端知道客户端这次要调用哪个方法、传什么参数”这个问题就足够你琢磨半天。2.1 为什么选TCP而不是HTTP主流RPC框架的通信层基本都跑在TCP上。Dubbo用的Dubbo协议底层是TCPgRPC虽然基于HTTP/2但本质也是TCP之上。我自己动手实现时直接用Java原生的Socket不用Netty因为我的目标是理解原理不是提升性能。为什么不用HTTP原因很简单——RPC追求的是低延迟和高效率。HTTP协议的请求头太冗余了一个完完整整的HTTP请求光状态行加Header就几十上百字节而RPC的调用数据本身可能就几十字节。框架层面保持TCP长连接省去频繁建连断连的开销还能自己定制协议格式这是RPC的天然优势。但这个选择也有代价——HTTP协议帮我们处理好了换行、头尾解析裸TCP就必须自己处理拆包粘包问题。Java的InputStream读数据时你不知道一次read拿到的到底是一条完整消息还是半条消息或者是两条消息拼在一起。Socket缓冲区的大小、网络拥塞、发送方的write次数都会影响数据到达的形态。2.2 协议格式设计定长头加变长体解决拆包粘包问题工业界最通用的做法是“定长头 变长体”。我实现的协议头设计如下魔数(2字节) 消息类型(1字节) 序列化类型(1字节) 消息体长度(4字节) 消息体(变长)魔数固定值如0xCAFE用于快速识别是否合法协议数据。如果你在解析数据时第一个魔数就不对直接断开连接防止脏数据流进来。消息类型请求还是响应或者心跳。序列化类型告诉对端这个消息用的是什么序列化方案方便以后扩展 JSON、Hessian、Protobuf。消息体长度这是解决粘包的关键。读数据时我先读够8个字节的固定头从第6到第9个字节里抠出长度再按这个长度精确读取消息体。代码大概是这样的我用的是最简单直观的写法用DataInputStreampublic static RpcMessage decode(DataInputStream in) throws IOException { short magic in.readShort(); if (magic ! MAGIC_NUMBER) { throw new IOException(Invalid magic number: magic); } byte msgType in.readByte(); byte serializerType in.readByte(); int bodyLength in.readInt(); byte[] body new byte[bodyLength]; in.readFully(body); // 关键保证读满bodyLength个字节 return new RpcMessage(magic, msgType, serializerType, body); }这里有个关键点很多人第一次写都会在这里踩坑。Java的InputStream.read(byte[])不保证一次能读满你指定的长度它可能只读了一部分就返回了。你必须用DataInputStream.readFully()或者自己写循环把数据读齐。我当时第一次跑通远程调用时就是因为只读了一次导致粘包数据直接解析错乱debug了一整天才发现。2.3 连接管理从短连接到连接池最开始我是每次请求都新建一个Socket用完就关。跑通没问题但稍微压一下性能测试就直接崩了。原因很直观——服务端每收到一个连接都要执行一次三次握手客户端每次关闭连接都来一次挥手高频场景下握手挥手的时间比数据传输时间还长这就是纯纯的浪费。改成连接池之后情况立刻好了很多。我实现了一个极简连接池public class ConnectionPool { private final ConcurrentHashMapInetSocketAddress, LinkedBlockingQueueSocket pool new ConcurrentHashMap(); private static final int MAX_CONNECTION 5; public Socket borrow(InetSocketAddress address) throws IOException { LinkedBlockingQueueSocket queue pool.computeIfAbsent(address, k - new LinkedBlockingQueue()); Socket socket queue.poll(); if (socket null || socket.isClosed()) { return new Socket(address.getAddress(), address.getPort()); } return socket; } public void returnSocket(InetSocketAddress address, Socket socket) { LinkedBlockingQueueSocket queue pool.get(address); if (queue ! null queue.size() MAX_CONNECTION) { queue.offer(socket); } else { try { socket.close(); } catch (IOException ignore) {} } } }连接池按目标地址维维护连接借用和归还都是线程安全的。当然真实生产环境中连接池要考虑空闲宕机、心跳保活等问题简易版先把借还机制跑通这些都是后话。我在做这一层的时候最大的感悟是通信层的核心设计其实就一句话——明确每条消息的边界再想清楚连接怎么复用。边界不清晰解析必乱连接不复用性能必差。这两件事想通了通信层的地基就稳了。3. 让Java对象能“过网络”序列化协议的选择与踩坑通信层把字节流传过去了但框架真正要传输的是“一个调用请求”包含接口名、方法名、参数类型、参数值这些结构化的数据。怎么把这些Java对象变成字节到了对面再变回来这就是序列化层要解决的事。3.1 简易版的第一版选型JSON足够做RPC框架选序列化方案其实是在在“性能”和“通用性”之间做权衡。我当时第一个版本直接用了Jackson把请求对象转JSON字符串。原因很简单调试方便出错了一眼就能看到报文长什么样而且在低并发场景下性能和JDK原生序列化差距不明显。JSON有一个天然的优势——它是跨语言的。不管对面是Java、Python还是Go只要能解析JSON就能通信。这就意味着如果未来想扩展跨语言RPC序列化层不用推倒重来。但JSON也有坑最大的坑是类型信息丢失。Java的多态、泛型、复杂嵌套结构JSON转回来的时候经常变成LinkedHashMap类型信息全没了。比如你定义了一个ListUser的返回值JSON反序列化时如果不带TypeReference回来就是一个ListMap后面一调user.getName()直接ClassCastException。我的解决方式是在请求和响应的包装类里显式携带类型信息public class RpcRequest implements Serializable { private String requestId; private String interfaceName; private String methodName; private Class?[] parameterTypes; // 参数类型数组用于反射定位方法 private Object[] parameters; // 实际参数值 private Class? returnType; // 返回值类型供反序列化时使用 }响应里的returnType可以不用因为客户端构造调用时已经知道接口方法定义里的返回类型了反序列化时直接用方法的泛型返回类型作为线索就能把JSON正确还原成真实类型。3.2 JDK原生序列化的“版本诅咒”我也试过JDK原生序列化——实现Serializable接口直接ObjectOutputStream写出去。用起来超级简单代码量最少但它有个非常恶心的坑序列化版本号serialVersionUID的问题。如果服务提供方和消费方对同一个类的serialVersionUID定义不一致反序列化会直接抛InvalidClassException。就算两边定义一样只要类结构一变——比如加了一个字段老版本的数据就反序列化失败了。在开发阶段这是灾难你经常要清缓存、重启服务因为改了几行代码两边就“不兼容”了。JSON方案就没有这种问题因为JSON本质上是自描述的字符串结构新增字段反序列化时没有的字段填默认值不认识的字段忽略掉兼容性天然好很多。这也是为什么我最后选了JSON而不是JDK原生序列化作为第一版方案。3.3 序列化的性能问题数据体积与CPU消耗等框架跑通了我开始做了一次最粗粒度的性能测试。测下来发现JSON序列化的数据体积大概是二进制序列化的3到5倍。原因很简单——JSON里的fieldName和双引号、逗号都是实打实的字符而二进制协议里字段名可以被编译成编号。对于传输密集型场景来说更大的体积意味着更多的带宽消耗和更长的传输延迟。后来我也给框架加了一个Hessian2的序列化实现。Hessian2是二进制协议体积比JSON小很多性能也不错Java生态支持得比较好。做法就是定义一个统一的Serializer接口public interface Serializer { byte[] serialize(Object obj) throws IOException; T T deserialize(byte[] bytes, ClassT clazz) throws IOException; }JSON实现和Hessian实现各写一个类通过协议头里的serializerType字段来标识用的哪一种。这样通讯层完全无感序列化方案可以随时切换。从实际使用情况来看低并发场景JSON完全够用高并发场景换成Hessian2能明显降低CPU和带宽开销。还有一个容易被忽略的序列化细节——方法参数里的对象可能包含循环引用。我之前在测试时传了一个User对象里面有个字段引用了自己的父对象结果JSON序列化直接栈溢出。解决办法要么在字段上加JsonIgnore要么换成带引用标识的序列化方案。实际开发中参数对象尽量设计成扁平结构不要搞复杂的对象图这是RPC调用的良好实践。到这里序列化层的全貌就出来了选型要考虑兼容性、体积、性能请求和响应要显式携带类型信息用接口隔离不同实现方便以后替换注意类结构变化导致的版本兼容问题。这些细节按部就班梳理清楚序列化层就不容易出幺蛾子。4. 服务注册与发现简易版也需要“路由”能力在网络通信和序列化搞定之后我遇到了RPC框架的第三个灵魂问题客户端怎么知道要去哪台机器调用谁如果只有一台服务器跑服务端可以做静态配置把地址写在客户端代码里。但一旦服务端有多台实例或者服务地址动态变化就必须引入注册中心。4.1 注册中心一张能“动态路由”的通讯录注册中心本质上是维护了一张“服务名 - 服务地址列表”的映射表。服务提供方启动时把自己注册上去服务消费方启动时拉取这张表。我用一个独立Java进程跑注册中心数据结构极其简单public class RegistryServer { private final ConcurrentHashMapString, CopyOnWriteArrayListServiceInstance registry new ConcurrentHashMap(); // 提供方注册 public void register(String serviceName, ServiceInstance instance) { registry.computeIfAbsent(serviceName, k - new CopyOnWriteArrayList()) .addIfAbsent(instance); } // 提供方注销 public void unregister(String serviceName, ServiceInstance instance) { ListServiceInstance list registry.get(serviceName); if (list ! null) list.remove(instance); } // 消费方拉取 public ListServiceInstance discover(String serviceName) { return registry.getOrDefault(serviceName, Collections.emptyList()); } }ServiceInstance里有host、port、weight这些字段。真正生产级的注册中心Zookeeper、Nacos、Consul会在此基础上增加推拉结合的通知机制、健康检查、临时/持久节点、集群部署。但简易版的核心模型就是一张Map——这是万变不离其宗的本质。4.2 心跳机制区分活着的和死了的光有注册表还不够你得知道哪些服务实例还活着。最简单可靠的方案是心跳。服务提供方启动一个定时任务每隔5秒向注册中心上报一次心跳。注册中心记录每个实例的最后心跳时间超过15秒没上报就判定为“死亡”从注册表里剔除。这个逻辑用Java实现就一个定时任务加一个超时判断public class HeartbeatMonitor { private static final long TIMEOUT 15000; public void monitor(ListServiceInstance instances) { instances.removeIf(instance - System.currentTimeMillis() - instance.getLastHeartbeat() TIMEOUT); } }这里有个实际中会遇到的麻烦如果服务提供方只是短时间内GC停顿或者网络抖动心跳没到就被注册中心撤下了随后调用全部失败。后来我在提供方增加了“只从注册表剔除但保留服务”的方法——被剔除后只要进程还活着就继续接受请求等心跳恢复后再重新登记。这种“自我保护机制”对生产环境很重要简易版可以先不做但心里得有这根弦。4.3 负载均衡随机、轮询和一致性哈希当同一个服务有两个实例时消费方该选哪个这里的路由策略叫负载均衡。我做了一版最简单的随机和轮询然后加了一个一致性哈希。随机和轮询的实现是几行代码的事核心思路就是从一个列表里选一个public class LoadBalancer { private final AtomicInteger position new AtomicInteger(0); // 轮询 public ServiceInstance roundRobin(ListServiceInstance instances) { int index position.getAndIncrement() % instances.size(); return instances.get(index); } // 随机 public ServiceInstance random(ListServiceInstance instances) { return instances.get(ThreadLocalRandom.current().nextInt(instances.size())); } }一致性哈希用在“带状态的服务”上比如同一个用户的数据始终路由到同一台机器。实现细节是把每个服务实例映射到哈希环上请求的key比如userId也哈希到环上顺时针找到的第一个实例就是路由目标。好处是某台机器下线时只有它承担的那部分请求会转移到别的机器其他实例的映射关系不受影响。第一次我实现一致性哈希时踩了个坑——hash函数分布不均匀会导致节点在环上扎堆大量请求打到同一台机器上。解决办法是给每个物理节点增加几十个虚拟节点。这是教科书上就有的做法但真正自己写一遍才会对“虚拟节点”这个概念有切肤之感。4.4 没有注册中心的RPC还能叫RPC吗很多同学会问如果服务就那么两台机器搞个注册中心是不是小题大做我的看法是如果你只有固定的几个服务地址完全可以用配置文件硬编码注册中心的收益确实不大。但一旦服务数量上了规模部署环境由物理机迁移到容器IP地址频繁变化没有注册中心的路由是不可维护的。而且注册中心这个模块抽象出来的“服务名字作为路由键”的思想本身就是RPC框架的灵魂。客户端写代码时只需要知道接口名不需要关心实例地址——这一层解耦是RPC框架能支撑分布式服务的根本原因。5. 动态代理让远程调用“伪装”成本地调用通信层把数据传过去了序列化层把对象变成字节了注册中心告诉客户端去哪找服务了。但还差最后一步——客户端的调用入口长什么样如果RPC框架让用户写代码时还要手动构造请求、发请求、处理响应那它就不叫RPC框架了顶多算一个网络工具类。RPC框架的承诺是面向接口编程像调本地方法一样调远程方法。实现这个承诺的杀手锏就是动态代理。5.1 JDK动态代理原理速览JDK动态代理的核心是你传给它一个接口它能在运行时生成一个实现了该接口的代理对象。当你的代码调用这个代理对象的任何方法时都会被转发到InvocationHandler的invoke方法在那个方法里你就可以做各种自定义处理了。把这个机制用在RPC上简直是天作之合。客户端定义一个接口比如UserService然后通过RpcProxyFactory.create(UserService.class)得到一个代理实例。调用任何方法时代理把方法名、参数类型、参数值打包成RpcRequest发送到远程服务端阻塞等待响应返回拿到结果后从invoke方法返回给调用方。代码如下public class RpcProxyFactory { public static T T create(ClassT interfaceClass, ServiceDiscovery discovery) { InvocationHandler handler (proxy, method, args) - { RpcRequest request new RpcRequest(); request.setRequestId(UUID.randomUUID().toString()); request.setInterfaceName(interfaceClass.getName()); request.setMethodName(method.getName()); request.setParameterTypes(method.getParameterTypes()); request.setParameters(args); request.setReturnType(method.getReturnType()); ServiceInstance instance discovery.select(interfaceClass.getName()); RpcClient client new RpcClient(instance.getHost(), instance.getPort()); return client.invoke(request); }; return (T) Proxy.newProxyInstance( interfaceClass.getClassLoader(), new Class?[]{interfaceClass}, handler ); } }5.2 方法重载和泛型返回值容易被忽略的边界情况动态代理过程里有一个细节特别容易被忽略——方法重载。假设一个接口里有save(String name)和save(String name, int age)两个重载方法。代理转发时光传一个方法名save过去服务端反射定位方法时就会懵——到底调用哪个所以parameterTypes必须跟着传。服务端收到请求后根据方法名加参数类型列表才能精确匹配到目标方法Method method targetClass.getMethod(request.getMethodName(), request.getParameterTypes()); Object result method.invoke(targetBean, request.getParameters());泛型返回值也很麻烦。初期我返回ListUser时服务端里通过method.getReturnType()拿到的是List.class而不是带泛型参数的ListUser。反序列化时只能转成ListLinkedHashMap客户端一用就炸。解决方式是在反射时不只看getReturnType()还要用getGenericReturnType()拿泛型信息Type returnType method.getGenericReturnType(); result serializer.deserialize(data, returnType);序列化框架比如Jackson支持TypeReference传泛型类型这样就可以完整还原ListUser。这个小坑在真实业务中很常见值得多花点时间处理。5.3 代理对象里还能塞什么“私货”代理不仅能做“转发”还能顺带做很多附加能力。我在框架里通过代理默认加了几个隐藏方法如果调用的方法是Object的toString()、hashCode()、equals()可以直接在本地处理好不用发远程网络请求这样避免很多无关的远程调用。另一个私货是链路追踪。调用远程方法时代理会在请求上下文里塞一个traceId服务端在处理日志时打印这个traceId排查问题时就能把一条调用链上的所有日志串联起来。生产环境几百个服务相互调用没有traceId查问题就是大海捞针。动态代理这一层是把“框架的复杂”和“开发者的简单”隔离开的关键屏障。代理做得好用户写的代码跟本地调用一摸一样代理做得差就变成了“伪RPC”——名不副实。6. 线程模型与超时控制高并发下的两个致命细节框架能“跑通”和能“扛住”是两回事。当我用最简单的单线程模型测试时RPC确实能正常调用但并发一上来各种诡异问题就像雨后春笋一样冒出来了。6.1 BIO线程模型首先要回答一个连接一个线程吗我第一版的服务端用的是一连接一线程模型——每个Socket连接到了就开一个新线程去处理。这个模型最大的优点是简单代码量最少最大的问题是线程数不可控。如果真的严格做到一个连接一个线程几千个连接就把系统线程数打满了。而且很多连接可能处于空闲状态——客户端建连后不发数据线程就在那儿干等白白占用内存。我的改进是引入线程池限制最大线程数ExecutorService executor new ThreadPoolExecutor( 10, // 核心线程数 50, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲回收时间 new LinkedBlockingQueue(1000), // 队列 new ThreadFactoryBuilder().setNameFormat(rpc-worker-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );这里有个值得说的设计——拒绝策略选了CallerRunsPolicy。意思是线程池满了之后新任务不丢弃而是让提交方线程自己执行。这在RPC场景下是个不错的选择宁可调用方线程阻塞一下也不能把请求直接丢掉导致数据丢失。6.2 超时控制三大量词你知道几个RPC调用是跨网络的网络是不可靠的这就意味着你必须给每一次调用设定一个超时时间。我在代码里用的就是热搜词里挂着的那个场景——调了30秒还没返回这就是典型的超时。控制超时的核心API是Future.get(timeout, unit)。我封装的RPC调用逻辑是public Object invoke(RpcRequest request) { RpcFuture future new RpcFuture(); pendingRequests.put(request.getRequestId(), future); channel.writeAndFlush(request); try { return future.get(3, TimeUnit.SECONDS); // 3秒超时 } catch (TimeoutException e) { pendingRequests.remove(request.getRequestId()); throw new RpcTimeoutException(RPC call timeout for request.getRequestId()); } }里面还涉及请求ID关联的问题。客户端发送一个请求时分配一个UUID远程返回的响应里带上同一个UUID客户端收到响应后通过这个ID找到对应的Future并完成回调。没有这个关联机制你根本不知道网络返回的这包数据对应哪一次调用。关于超时时间的设置我有几条实用建议超时时间不要设置得太长否则一个慢服务可能拖垮整个调用链也不要设得太短否则稍微有点网络抖动成功率就掉下来了。比较合理的做法是先设置1秒压测看P99耗时再根据线上情况调整。还有个细节是超时和重试的关系——如果服务不具备幂等性不能随便重试重试的次数乘超时时间决定了最坏情况下的等待时长。6.3 连接不够用时池化、多路复用与连接断线压测的时候我发现了一个很隐蔽的问题——当请求并发超过连接池里的连接数多余请求会在池里排队排队时间也被算进了RPC耗时里。这导致P99耗时直线上升但服务CPU占用率并不高。问题不在服务端而在客户端的连接池容量配置上。连接池里的连接数怎么定一个经验值是核心线程数除以2再加1。你服务端用50个线程处理请求客户端并发上限差不多50那连接池里放10-15个连接差不多够了。但还要考虑每条连接上同时跑多个请求——这就是多路复用。我在客户端实现里给每条连接绑定了一个“请求ID到Future”的映射表支持一条连接上请求并发交织传输。这样连接数不用很多也能承载很高的并发量。还有一点——连接会断。服务端重启、网络闪断、防火墙空闲超时都会让长连接失效。如果客户端还傻乎乎地把请求发到一条已经断掉的连接上就会报Broken Pipe或Connection reset。我的处理方式是每次borrow连接时检查isConnected和isClosed发送失败时自动重建连接并重试一次。线程模型和超时控制这两个东西看着不起眼但它们决定了RPC框架在高并发场景下的生死存亡。很多线上事故都是因为没有好好地控制超时、没有管理好连接池或者线程回收策略设置不当造成的。7. 实测中那些“RPC特有”的诡异问题手写框架过程中最有价值的不是框架本身而是调试过程中遇到的那些边边角角的异常。这些异常如果没踩过网上搜都搜不出个所以然来踩过一次下次一眼就能认出症状。7.1 服务端明明活着客户端却说连接被关闭我在本地测试时遇到过一个问题服务端启动后偶尔第一次调用就报Connection reset或Broken Pipe。排查了很久最后发现是服务端的线程池拒绝策略导致的——当请求堆积超过队列容量时CallerRunsPolicy让提交者线程执行处理逻辑但提交者线程在BIO模型里是接受连接的线程它去处理请求时新进来的连接没人接连接就被系统重置了。这个教训很有代表性BIO模型里“接受连接”和“处理请求”是两件必须分开的事。我把代码改成用一个独立线程专门accept新连接然后把Socket丢给线程池处理问题就没了。以后如果你写的服务出现间歇性连接异常先排查一下是不是请求和连接处理混在了一个线程里。7.2 序列化类型变了旧数据全废了另一类问题来自版本升级。我最初用字段名简写比如uName代表userName来压缩JSON体积后来觉得这个名字太不直观改成了全称。结果线上服务还在用旧协议的两个节点和用新协议的节点互发数据时字段对不上反序列化出来全是null。这种框架自身的升级不兼容问题比业务代码里的Bug杀伤力大得多。后来我固定了一条原则已经上线的协议字段哪怕命名再丑也绝不重命名新增字段全部向后兼容。这也是为什么很多成熟的RPC框架会在协议设计时特意保留扩展字段就是为了给未来留一条后路。7.3 调用30秒无响应也可能是服务端线程卡死热搜词里有句话叫“cannot finish rpc call in 30 seconds”我一开始以为这纯是客户端超时设置问题后来排查一个线上案例时才发现服务端线程池里的线程全部卡在了数据库连接获取上——连接池耗尽新请求全部排队。客户端等的其实就是服务端在处理上一个请求时卡住的时间。RPC的任何超时背后都应该去查两层原因一层是客户端配置的超时时间合理吗另一层是服务端处理能力是不是已经到瓶颈了。只调大超时时间而不解决服务端瓶颈等于掩耳盗铃。我当时处理这类问题的排查链路是先看监控图上服务端的线程池活跃数和队列积压再看慢SQL和外部依赖耗时最后回到客户端确认超时设置。三层走一遍问题基本就水落石出了。7.4 幂等与重试一笔账要算清楚最后说说让我印象最深的事——重试引发的数据重复。我早期给客户端加了超时重试机制超时一次就重发一次。后来压测一个“扣库存”的服务客户端的请求其实已经到服务端并且扣减成功了只是响应在网络里超时了客户端重试又扣了一次。库存直接对不上账。所以重试策略必须结合接口的幂等性来看。数据库写入、发送消息这类操作应该先在请求里带上唯一的业务ID或者用我请求里的requestId服务端做幂等控制。如果服务端不支持幂等就不要开启自动重试宁可报错让人工介入。在实测RPC框架的过程中你收获的绝不仅是“框架能跑了”更是一整套排查分布式问题的思维方式先看网络再看服务端再看业务逻辑先处理连接再处理数据先保证正确性再优化性能。这些东西真的是写一遍框架才学得扎实。手写RPC框架这件事动手之前觉得高不可攀拆解完发现它不过就是“代理、协议、序列化、注册中心、线程模型”五个模块的组合。但真把每个模块亲自写一遍踩过那些隐蔽的坑之后你再看Dubbo、gRPC、Spring Cloud这些框架的源码会发现原来每个设计每个组件都有非常明确的意图都是为了解决某个实际运行问题而存在。这个简易版RPC还有很多没做的地方——异步调用、泛化调用、优雅下线、压缩传输、监控链路等。但骨架已经在了剩下的都是在骨架上长肉。如果你也在学RPC我的建议是别光看书找个小项目动手把这几层串起来跑通一次远程调用再回来学框架源码你会发现自己突然能看懂很多东西。
返回列表