一次压测引发的血案
800ms的延迟,你敢信? 就只是一个简单的用户查询,REST接口平均50ms,一切换到自研的RPC框架,直接翻了16倍。团队里那个写Go的小伙脸都绿了,查了半天日志,最后定位到——序列化。 我们用了protobuf啊,按说比JSON快,不是吗? 可现实啪啪打脸。
内核旁路与零拷贝的诱惑
RPC最慢的地方,不是网络,是数据搬运。 传统的RPC流程:用户态数据 -> 内核态socket缓冲区 -> 网卡。 这个过程涉及多次上下文切换和内存拷贝。 我们调研了DPDK、RDMA这些技术,发现RDMA能让延迟从亚毫秒级降到个位数微秒,但前提是——数据必须停留在RDMA注册的内存区域。 这就意味着,如果你能把应用层的数据结构直接映射到这块区域,就能实现真正的零拷贝。
陷阱一:连接池?不,是泄漏地狱
我们以为搞定了序列化就万事大吉,结果线上间歇性超时又来了。 服务端用的Netty,客户端连接池配置了最大200个长连接,看着挺合理。 可监控显示,过了半夜,连接数偷偷长到2000多,而且大部分处于CLOSE_WAIT状态。 真相是——我们没有正确处理连接归还。 业务代码里,每次调用后都调用pool.releaseConnection(conn),但异常路径忘了放finally,导致连接泄漏。 还有更隐蔽的:连接在池子里待太久,被服务端闲置关闭了,客户端却不知道,拿出来就用,直接抛IOException。 解决方案其实不复杂:- 用try-with-resources包装连接,确保释放。就算抛异常,也得锁门。
- 启用连接探活,比如用Netty的IdleStateHandler,定时发心跳,或者借用前做一次快速ping(比如发送一个空请求,带超时)。
- 设置maxLifetime,到期主动剔除,不让僵尸连接积压。
陷阱二:服务发现延迟,雪崩的导火索
微服务架构下,RPC调用第一步是找服务地址。 我们用ZooKeeper做注册中心,一直很稳,直到那天某个服务扩容,节点瞬间从50变150,ZK通知风暴,客户端收到全量更新,CPU瞬间飙高,新的调用还在用旧地址列表——因为更新有延迟,而旧节点已经下线,连续超时,熔断器噼里啪啦全开。 那次故障我们复盘了半天,画了一墙的时序图。 最后上了三件套:- 缓存+增量通知。 客户端启动时拉全量,之后只接收变更事件,对通知做合并和防抖,本地缓存用ConcurrentHashMap,变更只更新变动的部分,别一股脑全刷。
- 快速失败与重试策略。 调用时如果发现连接拒绝,立即标记该节点为不可用并尝试其他节点,重试不超过2次,且要有退避时间。
- 客户端侧主动健康检查。 别光靠注册中心,自己定期探活,发现问题主动剔除。
陷阱三:超时和重试的致命诱惑

- 超时公式:TP99 * 2 + 网络抖动容忍。 我们根据实际监控的TP99值动态调整,比如日常TP99是200ms,就设600ms读超时。
- 重试要有严格前提。 只有读操作可以重试,写操作一律不行,除非业务层做了幂等(比如用唯一请求ID)。 重试次数最多2次,且必须用不同的连接,快速失败远比无限等待明智。
- 全局超时控制。 调用链超时,我们用了上下文传递,每次RPC都递减剩余时间,到0直接放弃,免得底层的慢服务拖死上层。
作者|大讲堂
排版|大讲堂
审核|满满
大讲堂