RPC与WebSocket核心原理及实战排错指南
2026/9/16 5:05:17 网站建设 项目流程

在任何分布式系统、微服务架构或者实时应用开发里,有两个词几乎绕不开:RPC 和 WebSocket。很多人对这两个概念似懂非懂——知道 RPC 是远程调用,知道 WebSocket 能推消息,但真要让他们解释清楚二者的底层机制、选型依据、以及实际部署中遇到诡异报错时怎么排查,往往就卡住了。

这篇文章我从头到尾梳理一遍 RPC 与 WebSocket,前半部分讲清楚两者的工作原理和核心差异,后半部分结合真实场景,把联网开发中常见的坑和排错思路一并抖出来。无论你是刚接触后端通信的新手,还是已经被线上问题折腾过几轮的开发者,应该都能从中找到点有价值的东西。

1. RPC:让远程调用像本地函数一样自然

1.1 RPC 的本质:屏蔽网络细节的"谎言"

RPC(Remote Procedure Call,远程过程调用)从设计目标上讲,是一个精心设计过的"谎言"——它让你在写代码时,觉得调用一个远程服务上的函数,就像调用本地函数一样简单。你不需要关心数据怎么序列化、怎么穿过网络、对方处理完后结果怎么回来,这一切都被框架隐藏了。

但这句"谎言"要实现起来并不轻松。一个完整的 RPC 调用链路包含五个关键环节:

  • 客户端本地发起调用,传入参数
  • 客户端代理(Stub/Proxy)将参数序列化为能在网络上传输的字节流
  • 通过网络将字节流发送到服务端
  • 服务端代理(Skeleton)接收到请求,反序列化还原参数
  • 服务端真正执行函数,然后把返回值再按反向流程送回客户端

你从热词里看到的cannot finish rpc call in 30 seconds: nul这类报错,本质上就是这五步链路中某个环节出了问题——可能是网络超时、序列化失败、服务端处理过慢,或者干脆是某个异常值没被正确处理。

1.2 RPC 与 HTTP、REST 的关系:别混为一谈

很多初学者最容易搞混的就是 RPC 和 HTTP/REST 的区别。严格来说,这两个不是同一个维度上的概念。

HTTP 是一种传输协议,它定义了数据怎么包装成请求/响应的格式;REST 是一种架构风格,它利用 HTTP 的 GET/POST/PUT/DELETE 等方法来操作资源;而 RPC 是一种调用范式,它关注的是"我如何像调用本地函数一样调用远程函数"。

实际工程中,RPC 完全可以通过 HTTP 协议来实现,比如 JSON-RPC、gRPC(基于 HTTP/2)就是典型例子。也可以走 TCP 自定义协议,比如 Dubbo 默认用的就是 TCP 上的自定义二进制协议。

从体验上讲,区分它们最直观的方式是看"面向的对象":

  • REST 面向资源:GET /user/123,你操作的是"用户"这个资源
  • RPC 面向方法:getUserById(123),你调用的是"获取用户"这个动作

1.3 主流 RPC 框架选型参考

这几年的 RPC 框架格局基本稳定,但每个框架的特点差别很大:

框架底层协议序列化方式适用场景
gRPCHTTP/2Protobuf跨语言、高性能、双向流
Dubbo自定义 TCPHessian/JSONJava 生态、微服务治理
Thrift自定义 TCPThrift 二进制多语言支持、性能要求高
JSON-RPCHTTP/WebSocketJSON轻量接入、调试方便

gRPC 在多语言通信场景有碾压性优势,因为 Protobuf 有非常成熟的代码生成工具链,一套.proto文件可以生成 Java、Go、Python、C++ 等几乎所有主流语言的客户端和服务端代码。而且 HTTP/2 支持多路复用、头部压缩,性能和连接利用率都比 HTTP/1.1 好很多。

Dubbo 在 Java 生态里凭借完善的注册中心、负载均衡、熔断限流能力,是很多国内企业微服务架构的首选。它最大的优势不是单个调用的性能,而是整套治理能力的完整性。

1.4 手写一个最小化 JSON-RPC 调用过程

用代码来理解 RPC 是最直观的方式。这里我用 Python 写一个最简化的 JSON-RPC 服务端和客户端,不依赖任何框架,帮助你理解底层原理:

# service.py - JSON-RPC 服务端 import json import socket def add(a, b): return a + b def to_upper(s): return s.upper() # 方法路由表 methods = { "add": add, "to_upper": to_upper } def handle_request(conn): data = conn.recv(1024).decode() if not data: return # 解析 JSON-RPC 请求 req = json.loads(data) method = req["method"] params = req["params"] # 根据方法名分发 result = methods[method](*params) # 构造响应并返回 resp = json.dumps({"result": result, "error": None, "id": req["id"]}) conn.sendall(resp.encode()) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(("127.0.0.1", 8080)) sock.listen(5) print("RPC server listening on 8080...") while True: conn, _ = sock.accept() handle_request(conn) conn.close()
# client.py - JSON-RPC 客户端 import json import socket def rpc_call(method, *params): req = json.dumps({"method": method, "params": list(params), "id": 1}) s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect(("127.0.0.1", 8080)) s.sendall(req.encode()) resp = json.loads(s.recv(1024).decode()) s.close() return resp["result"] print(rpc_call("add", 1, 2)) # 3 print(rpc_call("to_upper", "hello")) # HELLO

看到没有,整个 RPC 的核心逻辑就这么几件事:定义协议格式、维护方法路由表、序列化参数、反序列化结果。gRPC、Dubbo 这些框架做的事情本质上和上面这段代码一模一样,只不过它们把序列化、网络传输、服务发现、负载均衡这些能力都做成了可复用的组件。

2. WebSocket:从"一问一答"到"主动推送"的通信革命

2.1 为什么有了 HTTP 还需要 WebSocket

HTTP 协议从设计之初就是一个无状态的请求-响应模型:客户端发起请求,服务端返回响应,一个来回结束。这种模式对于浏览网页、提交表单完全够用,但当你需要做实时应用时——聊天、行情推送、协同编辑、在线游戏——就非常尴尬了。

传统方案是轮询:客户端每隔几秒发起一次请求,模拟实时性。但这种做法的效率低得离谱,几秒钟一次空请求占用了大量带宽和服务器资源,用户量一上来服务器很容易被打爆。长轮询(long polling)稍微好一点,但它仍然没有跳出"客户端发起、服务端响应"的框架。

WebSocket 的出现解决了两件事:

  • 真正的全双工通信:建立连接后,客户端和服务端可以随时向对方发送数据,不需要对方"批准"
  • 极低的消息开销:HTTP 请求头动辄几百字节,WebSocket 的数据帧经过掩码处理后只需要很小的开销

打个比方:HTTP 就像你给远方的朋友写信,每封都要写好收件人、贴邮票、邮寄;WebSocket 就像打通了电话,连接建立后随时能说话,还能同时听对方说。

2.2 WebSocket 握手与数据帧格式

很多人以为 WebSocket 是全新的协议,其实它底层依赖 TCP,唯一的 HTTP "关系"发生在握手阶段。握手使用 HTTP 协议,通过Upgrade头请求协议升级:

GET /ws HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13

服务端收到后,会算出一个Sec-WebSocket-Accept响应头返回,连接就升级成了 WebSocket。

握手后的数据传输用的是一个非常紧凑的帧格式,基本结构是:

  • FIN(1 bit):标识这是不是消息的最后一个分片
  • Opcode(4 bits):标识数据类型(文本、二进制、关闭、ping、pong)
  • Mask bit(1 bit):标识数据是否被掩码(客户端发给服务端的数据必须加掩码)
  • Payload length(7 bits 或 16 bits 或 64 bits):数据长度

理解这个帧结构对排查问题很有帮助。比如热词里那个[websocket] onclose, code: 1006, reason:, reconnect: true——1006 表示连接异常关闭,没有收到正常的 close 帧,数据直接被截断了。这种通常不是业务逻辑的问题,而是网络层断连导致的。

2.3 常见 WebSocket 库与选型对比

不同语言和框架的 WebSocket 库成熟度差异很大,踩过坑的人应该深有体会。

场景推荐方案理由
Node.jsws / Socket.IOws 是底层库,性能好;Socket.IO 自带重连、房间、广播
Pythonwebsockets(asyncio)/ FastAPI WebSocket异步支持好,和 FastAPI 集成方便
Java(Spring)Spring WebSocket / NettySpring 生态方便,Netty 适合高并发长连接
Gogorilla/websocket / nhooyr.io/websocketgorilla 最流行,nhooyr 更现代
Vue 前端原生 WebSocket / socket.io-client原生即可,复杂场景用库封装

从热词看到"golang websocket语音长连接实现",说明很多人已经意识到音频流的场景。语音长连接和普通消息推送有个本质区别:持续时间长、数据量大、对延迟敏感。这种场景除了 WebSocket 本身,还得考虑流控、心跳保活、音频编码格式协商、断线重连后的状态恢复等一连串问题。

2.4 WebSocket 心跳机制:为什么必须做

WebSocket 连接建立后,如果长时间没有数据传输,中间的网络设备(比如 NAT 路由器、负载均衡器)可能会认为连接已空闲,直接把它断开。这就是 TCP 层的 idle timeout。为了保持连接存活,客户端必须定期发送心跳包。

常见的做法是客户端每隔一定时间(比如 30 秒)发送一个 ping 帧(opcode 0x9),服务端回复 pong 帧(opcode 0xA)。如果客户端连续几次没收到 pong,就可以判定连接已失效并主动发起重连。

gRPC 里也有类似的概念,叫作 keepalive ping。搞过后端服务的人应该见过GOAWAY或者RST_STREAM这类错误,很多时候不是因为代码有 bug,而是因为长连接没配心跳或者心跳间隔和负载均衡的 idle timeout 不匹配,连接被中间设备悄悄收掉了。

3. 选 RPC 还是 WebSocket?按场景做判断

3.1 核心差异对照

RPC 和 WebSocket 不是互相替代的关系,而是解决两类不同问题的工具。我把它们放在一起对比,这样你能一眼看清各自的定位:

维度RPCWebSocket
通信模型请求-响应(同步为主)全双工(双向随时收发)
主要目的服务间函数调用双向实时数据交换
序列化Protobuf / JSON / Hessian文本或二进制,格式自定义
连接模型每次调用一次或复用长连接一个长连接持续复用
典型场景微服务内部调用、远程 API聊天、行情推送、协同编辑
状态管理无状态为主有状态长连接,需维护连接池
代表协议gRPC、Dubbo、ThriftRFC 6455

简单总结:RPC 适合"动作"语义——我要获取某个数据、我要执行某个操作;WebSocket 适合"流"语义——我要持续接收数据、我要和对方双向实时交互。

3.2 面对真实需求的决策路径

在我看过的大量项目里,选错通信方案的案例不少。有些团队为了追求实时性把 REST 接口改成 WebSocket,结果很多接口本质上是请求-响应语义,改完之后反而引入了一堆连接管理问题。另外一些团队把服务间所有通信都做成 RPC,遇到消息推送场景就笨拙地靠轮询,白白浪费了 WebSocket 的能力。

我的决策建议是这样的:

  1. 请求-响应、需要服务治理(负载均衡、熔断、限流)→ 选 RPC
  2. 浏览器/客户端实时双向交互→ 选 WebSocket
  3. 服务间需要双向流式通信(比如实时推荐、持续结果流)→ gRPC 的双向流
  4. 客户端需要接收服务端主动推送(不是频繁请求)→ WebSocket

3.3 也能混着用:JSON-RPC over WebSocket

有一个很有意思的组合方案——JSON-RPC over WebSocket。把 RPC 的请求-响应语义跑在 WebSocket 的长连接之上:客户端往建立好的 WebSocket 连接发送 JSON-RPC 格式的请求,服务端处理完后把响应也通过同一条连接发回来。

这种方案在需要"长连接 + 请求响应 + 服务端推送"混合场景特别好用。比如一个在线 IDE,客户端需要通过 WebSocket 持续接收终端输出,同时还要发请求让服务端执行某些操作,就可以统一用 JSON-RPC over WebSocket 来实现。

从热词里看到"thingsboard下发rpc 子设备下发"——ThingsBoard 这类物联网平台在设备通信里确实大量用了 JSON-RPC over WebSocket。IoT 场景天然就是双向的:平台要下发指令给设备,设备要上报数据给平台,同时又不想每次交互都重新建连,JSON-RPC over WebSocket 几乎是量身定做。

4. 实战中那些绕不开的坑:报错排查链路

4.1 "cannot finish rpc call in 30 seconds" 这类超时问题怎么查

如果你在工程里遇见过 RPC 调用超时,通常会和nul或者某些奇怪的字符混在一起。排查思路是这样的:

第一步,确认超时发生在链路哪一头。在客户端测一次最小调用,用小数据量、快接口,看是否复现。如果小请求能通,大请求超时,先怀疑序列化/反序列化环节或者服务端处理耗时。

第二步,盯住服务端日志。RPC 超时大概率是服务端某个流程卡住了。比如数据库慢查询、第三方接口依赖太慢、线程池耗尽。这里有个经验:排查 RPC 超时,先看服务端的活跃线程数,如果线程池被打满了,那进来的请求全部排队等待,超时几乎是必然的。

第三步,检查网络环境。如果你在跨机房、跨地域调用,或者服务在不同可用区,丢包和延迟波动也会导致超时。可以先用 ping 和 traceroute 看基础网络质量,再用 TCPdump 抓包看是不是有大量重传。

热词里有一类和 git 相关的 RPC 报错——error: rpc failed; curl 56 gnutls recv error (-9): error decoding the received packeterror: rpc failed; curl 56 schannel: server closed abruptly——这是 git push/pull 时通过 HTTP 协议传输对象数据出现的问题。报错里的 RPC 其实就是 git 内部调用远程仓库服务的机制,和普适的 gRPC/Dubbo 在技术上不一样,但排查链路是类似的:检查网络稳定性、调整 http.postBuffer 大小、或者改用 SSH 传输协议。这个我在实际项目中遇到过多次,基本都是网络代理或者 MTU 问题导致的。

4.2 WebSocket 1006 异常的典型链路

[websocket] onclose, code: 1006是前端开发里遇到最多的报错之一。1006 不是业务代码主动关闭的,而是连接异常断开。排查路径如下:

  • 第一环,查看服务端日志:通常是服务端主动 kill 了连接或者崩了。如果你用 Node.js 的 ws 库,检查是不是有未捕获的异常被迫退出进程;用 Spring Boot 的话,检查 WebSocket handler 里有没有抛出未处理异常。
  • 第二环,看负载均衡和网关的配置:Nginx 默认对 WebSocket 的 read timeout 是 60 秒,如果你的服务端超过 60 秒没有发送数据,Nginx 会主动断开连接。需要在 Nginx location 里显式配置proxy_read_timeout 3600s这类参数。这是我见过最多、也最隐蔽的坑——代码完全没问题,就是被网关掐了。
  • 第三环,看客户端网络:移动端 APP 在 WiFi 和 4G/5G 之间切换时会断 TCP 连接,WebSocket 自然也就断了。这种情况下客户端要自动重连,重连逻辑里要有退避策略(比如第一次等 1 秒,第二次等 2 秒,指数递增),避免大量客户端同时重连把服务器打爆。

还有一个很容易被忽略的点:服务端负载高导致的事件循环阻塞。像 Node.js 这种单线程模型,如果某个业务 handler 里跑了一个同步的 CPU 密集型任务,事件循环被卡住,所有 WebSocket 的心跳检测都会超时,服务端就误判客户端失联,主动踢掉连接。所以服务端一定要保证长连接的处理逻辑是非阻塞的。

4.3 浏览器环境 WebSocket 的兼容与安全限制

热词里有"vue 增加 websocket"和"chrome 109 websocket 不行",我挑重点说几个前端 WebSocket 开发里容易踩的坑:

  • 混合内容限制:HTTPS 页面里连非加密的ws://地址会被浏览器拦截,必须用wss://。我在本地开发时经常遇到这个问题——本地是 HTTP 环境没问题,部署后页面升级成 HTTPS,WebSocket 就连不上了。
  • 路由守卫里创建连接:Vue 项目如果进入页面时创建 WebSocket,离开页面时忘记关闭,会积累大量僵尸连接。每次组件销毁前,记得在onUnmounted/beforeDestroy钩子里执行socket.close()
  • 断线重连的幂等性:重连时服务端如果没做好 session 管理,客户端会出现"双开"——同一个用户建立了两个 WebSocket 连接,收到的消息还会重复。服务端在用户重连成功后,要把旧连接主动关闭掉。
  • Chrome 版本更新导致的兼容变化:这个在嵌入式设备(比如跑 Electron 或旧版 WebView 的设备)上特别明显。新版浏览器对某些次世代安全标头或协议的处理改变,可能导致原来能连的 WebSocket 突然不行了。碰到这类问题,先把浏览器自带开发者工具的 WS 面板打开,看看握手环节是不是有报错日志,再决定是改代码逻辑还是调部署配置。

4.4 移动端打包 App 后 WebSocket 连接不上的经典原因

热搜词里有一条很典型:"websocket运行到h5可以连接,打包为app连接不了"。这个我太熟了,几乎每周都有同事来问。

H5 能连、App 不能连,最可能的原因就是证书信任问题。H5 走系统浏览器,继承浏览器的证书库;App 打包后如果用的 WebView 组件没有配置信任用户证书,而且你的 WebSocket 服务端用的还是自签名证书,连接时就会被直接拒绝掉。解决办法是在 App 的网络安全配置里加上对开发环境证书的信任,或者在测试环境下放行明文流量。

另一个常见原因是权限配置缺失。Android 的 App 打包需要显式声明网络权限;另外 Android 9 之后默认禁止明文流量,如果你的 WebSocket 地址是ws://而没配置networkSecurityConfig允许明文,也会连不上。

还有鸿蒙或者部分国产 ROM 上,App 的保活策略会杀掉后台长连接。这种问题要在应用层做心跳探活和断线自动重连,不是技术上能不能连的问题,而是系统策略问题。

5. 深入一点:连接管理、鉴权与服务端架构设计

5.1 WebSocket 鉴权:握手时顺手验证

Netty、Spring Boot、Node.js 都要做 WebSocket 鉴权,不然任何人都能连上你的长连接接口,想发什么就发什么,安全问题会非常严重。

鉴权方式常见的三种:

  • 通过在 URL query 参数里带 tokenws://example.com/ws?token=xxx。简单直接,但 token 会出现在网关和代理的访问日志里,建议如果你用的是短期 token 倒还好,用了长期 token 就别这么搞。
  • 通过首次消息鉴权:连接建立后客户端先发一条{"type":"auth","token":"xxx"},服务端校验通过后标记该连接为"已认证",然后才开始处理业务消息。这种方式灵活,可以配合签名算法。
  • 通过 Subprotocol 头携带认证信息:利用 WebSocket 握手时的Sec-WebSocket-Protocol带上约定的认证数据,服务端在握手阶段完成校验,失败直接拒绝升级。

我个人的建议是:能接受token出现在日志里就用第一种,否则用第二种。第一种性能最好,实现最简单;第二种在日志层面更安全,但需要维护"未认证连接池"和"已认证连接池"两套状态。

用 Netty 做 WebSocket 鉴权的核心思路是加一个ChannelInboundHandler来处理握手请求和身份校验,在channelRead0里判断连接状态。Spring Boot 的话,可以在 WebSocket 的拦截器(HandshakeInterceptor)里做前置校验。

5.2 连接状态管理:广播、群组与属性设置

热词里有条需求:"spring boot 好用的 websocket 后端框架 可以广播、群组、设置属性等"。这就是典型的"不止要一个长连接,还要一套房间体系"的场景。

需求拆解下来其实就是三件事:

  • 单发:给指定连接 ID 发送消息
  • 群发:给一个群组内所有连接发送消息
  • 广播:给所有已认证连接发送消息

如果只用 Spring 的WebSocketHandler原生 API,你需要自己维护一个ConcurrentHashMap<String, WebSocketSession>来管理连接,再维护一个群组映射表。连接一多,锁、排序、查询这些问题接踵而来。

我更推荐直接上 Spring Integration WebSocket 或者 Spring Messaging STOMP。STOMP(Simple Text Oriented Messaging Protocol)是一套基于文本的简单消息协议,天然支持点对点、广播、订阅。Spring Boot 对 STOMP 支持很成熟,能省掉大量重复造轮子的时间。

比较标准的做法是用以下这套配置:

  • 一个TextWebSocketHandler处理消息收发
  • 内部维护Map<String, WebSocketSession>在线连接表
  • 根据业务需要维护Map<String, Set<WebSocketSession>>群组映射表
  • 消息类型通过 JSON 的type字段区分,比如chatnoticesystem
  • 在配置类里加上@EnableWebSocketMessageBroker启用消息代理

5.3 RPC 服务端的性能瓶颈和扩容思路

RPC 服务端的性能瓶颈通常不在网络框架本身,而在业务逻辑和 IO。gRPC 框架本身经过大规模验证,单机扛几万 QPS 没有太大问题。关键是服务端要避免同步阻塞调用,提升并行度。

扩容手段无非三种:

  • 横向伸缩:多实例部署,注册中心做负载均衡。gRPC 客户端一般都能配合注册中心做服务发现和连接池管理。
  • 连接复用:一个 TCP 连接承载尽可能多的请求。gRPC 在 HTTP/2 上天然支持多路复用,比传统 HTTP/1.1 的队头阻塞好太多。
  • 异步化:把耗时操作丢到 MQ 或者异步线程池里,让 RPC 请求快速返回,再通过回调或者状态查询拿到最终结果。

具体的坑是:gRPC 长连接的连接数是有限制的。很多团队上线 gRPC 后发现连接数爆炸,仔细排查发现是每个请求都新建了一个 Channel,根本没复用已有连接。正确的做法是每个服务只需要维护一份 Channel 复用池,不能让连接随请求无限增长。

5.4 心跳保活策略:RPC 和 WebSocket 都需要的日常护理

不管是 gRPC 还是 WebSocket,长连接最怕的不是"数据不通",而是"连接已经死了,但双方都不知道"。心跳保活是解决这个问题的标准方案。

gRPC 有内置的keepalive参数,通常要设置这么几个值:

keepAliveTime = 30s // 每30秒发一次 ping keepAliveTimeout = 10s // ping 发出后10秒没收到 pong 就断开 keepAliveWithoutCalls = true // 即使没有活动请求也发 ping

WebSocket 则需要在业务层自己实现 ping/pong 逻辑。前端用原生 API,通过直接发送 ping 帧在浏览器层是不完全可控的(浏览器没有直接暴露 ping 的 API),更实际的做法是在消息负载里模拟心跳:客户端定时发{"type":"heartbeat","timestamp":...},服务端收到后原样或者换一种方式回一个 pong 消息。

关键点在于:心跳间隔必须大于中间设备的 idle timeout 的一半。比如 Nginx 的proxy_read_timeout是 60 秒,你的心跳间隔就要低于 30 秒,留足余量。这是我在实际项目中反复确认过的经验——很多所谓"早上来了连接就断了"的问题,就是因为夜间没业务数据,连接被中间设备回收了,而客户端的心跳间隔没覆盖到。

6. 从零搭一套最小可用通信方案:我的实操笔记

6.1 方案选型与代码骨架

最后用一个实际的小案例把整个思路串起来。假设需求是:前端 Vue 页面需要实时展示后端推送的任务进度,同时前端偶尔要向服务端发起一些 RPC 风格的动作请求(比如暂停任务、取消任务)。

我选择的是 FastAPI 提供 WebSocket 端点,内部用 JSON-RPC over WebSocket 来兼容两种通信模式。这样一套协议既满足双向消息实时推送,又保留了请求-响应语义。代码骨架如下:

# server.py import json import asyncio from fastapi import FastAPI, WebSocket app = FastAPI() # 模拟任务进度存储 progress = {"task_1": 0} @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await websocket.accept() # 启动一个后台任务推送进度 async def push_progress(): while True: await asyncio.sleep(1) progress["task_1"] += 10 if progress["task_1"] > 100: progress["task_1"] = 0 await websocket.send_json({ "type": "push", "data": {"task": "task_1", "progress": progress["task_1"]} }) push_task = asyncio.create_task(push_progress()) try: while True: # 接收客户端消息,解析 JSON-RPC message = await websocket.receive_text() req = json.loads(message) # 根据 method 分发 if req["method"] == "pause": # 模拟暂停逻辑 await websocket.send_json({ "result": "paused", "id": req["id"] }) elif req["method"] == "cancel": # 模拟取消逻辑 await websocket.send_json({ "result": "cancelled", "id": req["id"] }) else: await websocket.send_json({ "error": "unknown method", "id": req["id"] }) except Exception: pass finally: push_task.cancel() await websocket.close()
// client.js(Vue 里可以用类似逻辑) class RpcWebSocket { constructor(url) { this.ws = new WebSocket(url) this.id = 0 this.pending = new Map() // 存储等待响应的请求 this.ws.onmessage = (event) => { const msg = JSON.parse(event.data) if (msg.type === 'push') { // 处理服务端主动推送的消息 this.onPush && this.onPush(msg.data) } else if (msg.id !== undefined) { // 处理请求响应 const handler = this.pending.get(msg.id) if (handler) { handler(msg) this.pending.delete(msg.id) } } } } // 发起 JSON-RPC 请求,返回 Promise call(method, params = {}) { const id = ++this.id return new Promise((resolve, reject) => { this.pending.set(id, (msg) => { if (msg.error) reject(new Error(msg.error)) else resolve(msg.result) }) this.ws.send(JSON.stringify({ method, params, id })) }) } // 设置服务端推送消息的回调 onPush(callback) { this.onPush = callback } } // 使用示例 const rpc = new RpcWebSocket('ws://localhost:8000/ws') rpc.onPush((data) => { console.log('收到进度推送:', data) }) setTimeout(async () => { const result = await rpc.call('pause', { task: 'task_1' }) console.log('暂停结果:', result) }, 3000)

这套方案我在多个项目里验证过,最大的好处是协议统一、逻辑简单——前端一个连接搞定主动推送和请求响应两种模式,不需要同时维护 WebSocket 和 HTTP 两套连接。缺点是 JSON-RPC over WebSocket 在性能上不如纯二进制协议,但对于业务量不大的内部系统,完全够用。

6.2 部署上线前必须检查的清单

在真实上线前一晚,我会按这个清单过一遍,能避免 80% 的线上事故:

  1. Nginx/LB 的proxy_read_timeoutproxy_send_timeout是否调整过,适配长连接
  2. WebSocket 地址是ws://还是wss://,页面到底是 HTTP 还是 HTTPS
  3. 服务端连接池上限、线程池上限是多少,压测过没有
  4. 心跳间隔和中间设备超时时间是否匹配
  5. RPC 客户端 Channel 是否复用了,有没有连接数泄漏
  6. 服务端异常断开时,客户端有没有自动重连,重连有没有退避策略
  7. 多实例部署时,同一个客户端的连接是否会负载均衡到不同实例,实例间的会话状态需不需要共享

清单看着简单,但每一项背后都对应着一类线上事故。特别是第 6 条,没有自动重连的 WebSocket 就像一个没有安全带的赛车手——平时跑得爽,撞一次就完了。

6.3 关于性能调优的一些经验数字

压测过不少 RPC 和 WebSocket 服务,整理几个经验值供参考:

  • gRPC 单连接多路复用,性能瓶颈通常在业务逻辑和 GC,框架本身单机到达数万 QPS 是常见的
  • WebSocket 的瓶颈通常在内存——每连接至少分配一个读缓冲区和写缓冲区(典型 8KB~64KB),一万个连接就是几百 MB 内存
  • 心跳消息如果定时同时发出,会造成"心跳惊群"问题——大量连接同时发心跳导致 CPU 瞬时飙高。解决方法是给不同连接加一个随机抖动(jitter),让心跳发出去的时间分散开
  • 服务端给客户端推送瓶颈在 DB 或者消息队列的消费速度,不在 WebSocket 框架本身

内存方面有一个常用的估算公式:单体服务 8GB 内存,WebSocket 连接维护实在不算奢侈,但如果单连接配上未合理配置的缓冲池和并发任务,1 万连接也可能把 JVM 打爆。所以连接数上来以后,优先想到的不是换框架,而是梳理你的连接对象里到底挂了多少不必要的东西。


最后聊一句实在的。RPC 和 WebSocket 这两个东西,看起来名词多、框架多、报错多,但底层逻辑其实很朴素:一个是为了让"调用远程方法"变得像本地调用一样简单,一个是为了让"双向实时通信"变得可靠高效。掌握了这两个核心意图,上面所有这些框架、协议、报错,都是围绕这个意图衍生出来的具体工程手段。当你再遇到类似cannot finish rpc call in 30 seconds或者websocket onclose 1006的时候,先别急着搜报错原文,冷静分析一下报错发生在链路哪一环——往往是普通的技术基础知识就能帮你快速定位的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询