(从左到右的量级为累计)
如果一句话形容这一路:拼命维持服务稳定,小心谨慎增加功能。
传统调用 RPC 接口的流程可以概括为三个经典步骤:
而且,如果接口协议发生变更,就必须不断重复这三个步骤。
这种繁琐的流程显然难以忍受。
调用 RPC 接口相比于调用 HTTP 接口更加繁琐,根本原因在于 proto 文件的存在。那么,我们是否有办法摆脱依赖于 proto 文件呢?
为了回答这个问题,让我们先来仔细分析 tRPC 协议的结构和组成:
在 tRPC 协议中,消息由帧头和包头组成,类似于 HTTP 头部,包含了请求的基本信息,如请求类型、协议版本和包大小等。而包头则是一段可变长度的二进制数据,同样使用 proto 进行定义和序列化。我们来看一下它的结构:
message RequestProtocol {
// 协议版本
// 具体值与TrpcProtoVersion对应
uint32 version = 1;
// 请求的调用类型
// 比如: 普通调用,单向调用
// 具体值与TrpcCallType对应
uint32 call_type = 2;
// 请求唯一id
uint32 request_id = 3;
// 请求的超时时间,单位ms
uint32 timeout = 4;
// 主调服务的名称
// tRPC协议下的规范格式: tRPC.应用名.服务名.pb的service名, 4段
bytes caller = 5;
// 被调服务的路由名称
// tRPC协议下的规范格式,tRPC.应用名.服务名.pb的service名[.接口名]
// 前4段是必须有,接口可选。
bytes callee = 6;
// 调用服务的接口名
// 规范格式: /package.Service名称/接口名
bytes func = 7;
// 框架信息透传的消息类型
// 比如调用链、染色key、灰度、鉴权、多环境、set名称等的标识
// 具体值与TrpcMessageType对应
uint32 message_type = 8;
// 框架透传的信息key-value对,目前分两部分
// 1是框架层要透传的信息,key的名字要以tRPC-开头
// 2是业务层要透传的信息,业务可以自行设置
map<string, bytes> trans_info = 9;
// 请求数据的序列化类型
// 比如: proto/jce/json, 默认proto
// 具体值与TrpcContentEncodeType对应
uint32 content_type = 10;
// 请求数据使用的压缩方式
// 比如: gzip/snappy/..., 默认不使用
// 具体值与TrpcCompressType对应
uint32 content_encoding = 11;
}
我挑选出需要重点关注的结构如下:
tRPC Header(帧头 + 包头):
tRPC Body:
关键的一步是 tRPC Body,我们不再依赖业务接口的 Proto,而是直接使用 JSON.stringify 来序列化 HTTP 的 Body(如果是 GET 请求,则获取 URL 中的查询参数)。
至此,tRPC Header + tRPC Body 就构成了完整的请求包,可以进行发送。
后台部署即可调用,与 HTTP 调用一样简单。至此,我们终于能够轻松地消费 tRPC 接口了。
业务网关也初具雏形:协议转换。
小程序 和 H5 能这样使用后台的 tRPC 接口吗?
初步看来,只需调整鉴权方式,将内网的智能网关鉴权切换到外网的 ptlogin 即可。
然而,实际情况并非如此简单,因为我们忽略了一个关键的请求链路:JSAPI(通过 APP 发送请求)。
为了让开发小程序和 H5 的团队能够顺利调用 tRPC 接口,必须弄清楚:APP 的请求是如何到达业务 Server 的。只有在理解了这个过程之后,才能确保最终发包时的结构是一致的。
探究完整的通信链路
这个问题有点像:从浏览器输入 URL 到显示页面发生了什么。
以一个具体请求为例,我整理出了整体的网络结构:
协议转换方案
直接调用 tRPC Server 分三种场景:
同时兼容三种场景的协议转换:
梳理完整链路,耗费了两周时间:
小程序/H5 接入后,相较于 JSAPI,开发和联调不再依赖 QQ 客户端,大大提高了效率:
如果从来没被淋湿过,那是有人默默在背后打伞。
一开始只是流量稍微上涨,不久来了 oncall:
十万火急(问题)
什么原因:
治表(解决办法)
现在需要立即消除链接乱码,治表才是关键。
这时惯常的 Debug 模式不再适用,必须直切要害:依赖之前的经验,判定最可能的原因,然后直奔主题。
立即在日志中以 connection pool 为关键字检索,猜测得到了证实。
这时需要马上做两件事:
治根(解决办法)
扩大 connection pool、缩容 pod 后,需要尽快找到流量上涨的原因,若网关和下面几个服务负载持续上升,可能会随时引发服务不可用的风险。
在排除了运营活动和压力测试的可能性后,我们通过分析流量特征(如 IP、UA 等信息)迅速识别出了黑产攻击的存在。随即,安全团队介入处理,成功保障了业务服务的稳定运行。
反思(结果)
此次事件也促使我们重新关注安全问题,对服务的整体安全状况进行了深入的排查与反思:
每个失败请求的背后都是用户。
作为 QQ 频道的核心入口,发现页小程序对性能的要求极为苛刻,绝不能容忍任何形式的超时现象。
然而,超时还是发生了!
哪个节点出了问题?为什么没有触发告警?
问题一定出在三层中某一个服务。
明确了这点,问题定位就可以有条不紊的展开了: 只要捞出一个具体 case 扒到底,bug 就能揪出。
整体时间线:
过程比预期的曲折,同时排查到两个节点有问题,包括一开始压根没怀疑的节点:stgw(公司级统一接入)。
在新增或更新 STGW 路由至 L5(旧公司级域名服务)后,出现了以下具体问题:
这些问题都会导致请求超时。
L5 作为名字服务,采用的是 C/S 架构。 问题在于客户端(client)缓存出现异常,导致新增或更新操作后,客户端缓存未能及时刷新。
面对这一问题,业务如何采取措施以避免受到影响?
至此,监控能力基本补齐:
测试环境不稳定影响开发,正式环境不稳定影响用户。
就像吞噬细胞一样,虽然消灭了外部的病毒和细菌,但里面却成了垃圾场。各种各样的兼容、组包成了服务稳定性的隐患。
如何在配套工具没有跟上的情况下,保证服务稳定? 工具缺位,人工来补。
我以发布为例,分享下 人也是可以作为程序执行器的 。
把一次发布作为考察对象,按照一套固定的思考/行动步骤,来最大程度降低发布问题。
依靠规范发布流程和小心谨慎,未出现一次因发布导致不可用。
易用性决定是否尝试,性能决定是否使用。
性能优化是一个持续不断的课题,因为需求会变化、功能不断增加。
一次排查接口调用报错,意外发现让我震惊的事实:后台 RPC 接口报错的响应在 50ms 内就返回了,为啥前端却200多 ms 才收到!
通过补充打点,发现问题在这里:
1.环境准备
模拟轻负载状况下(对应正式环境的 NGW/TSW 负载冗余),仅仅比较平均耗时一项。
2.准备测试脚本 APISIX 用例
import { sleep, check } from "pts";
import HTTP from "pts/HTTP";
export const options = {};
export default function main() {
let response;
response = HTTP.get("HTTPs://xx.com/apisix/");
check("status equals 200", () => response.statusCode.toString() === "200");
}
NGW/TSW 用例:
import { sleep, check } from "pts";
import HTTP from "pts/HTTP";
export const options = {};
export default function main() {
let response;
response = HTTP.get("HTTPs://xx.com/tsw/");
check("status equals 200", () => response.statusCode.toString() === "200");
}
3.结论
APISIX 相对 TSW/NGW 仅平均耗时就提升近 10 倍。
解决思路
十倍的差距,改造势在必行,越早越好!
TSW 和现有测试流程、工具紧密结合,所以改造过程分两步:
成果
WS 在时延上表现十分优秀(降~95%)。 如果能从 msf 切到 WS 通道,会带来性能显著提升。
着重展开下 WS 方案中的三个关键点。实现细节、针对游戏场景的优化(比如初始下行量大)等计划另开文章来写。
为什么不直接使用 WebSocket ?
WebSocket 本质上是一个传输层协议,在实际应用中还需处理心跳、重连、降级等机制,因此我们需要一个基于 WebSocket 的应用层协议。这与我们不直接使用 TCP 协议进行通信,而是选择基于 HTTP 或私有 RPC 协议通信的原因相似。
通过对比开源及团队内私有协议,选择了 socketio。原因主要有:
SDK (封装协议交互)是一个容易忽视的成本。SDK 需要支持三种运行环境:
所以 SDK 需要具备扩展机制,能衔接运行环境的差异 API。(私有协议的 SDK 难扩展)
如何让现有的 tRPC server(一问一答式),在不改造的情况下,也能享用实时通信的便利?低成本接入主要解决这个问题。
在开发 WS server 时,有两个选择:
这两个选择各有利弊:
选择 1 | 选择 2 | |
---|---|---|
利 | WS server 逻辑简单,开发维护成本低 | 业务 server 无需改造,即可具备实时下行能力 |
弊 | 业务 server 需要具备链接管理能力,各自保证消息可靠送达等 | WS server 功能复杂,是整个系统消息吞吐的瓶颈 |
这里借鉴了 SSO 的实现,采用第二个方案,使业务服务器可以以最低成本接入。
如何保证消息一定被 client 收到? 其实无法 100% 保证这点,只能承诺尽最大可能送达。
其中最容易被想到策略:消息确认,也是 tcp 协议使用的。
在选择方案 2 实现 WS server 后,其实还面临这些的情景:
WS server 同时采用三个策略提高消息送达的可能性:
世界子频道接入后,游戏地图更新更流畅。HTTP 切 WS 后,高频轮询从用户侧下放到 WS server 执行,减少网络数据交换,减少低端机发热。
企微检索了下,仅几个高频错误码解决办法(131,12,4001等)就解释了几十次:
解决办法
把常见的问题和 step by step 教程整理成文档。通过对这些文档进行向量化处理,可以借助大模型做成问答机器人,提供更快的问题解答和错误诊断。
效果
有了文档,除了节约自己的时间,还能让周围的小伙伴更快解决问题。就像代码模块化,文档化后的经验,才是能产生复利的资产。
有一些操作虽然不繁琐,但是高频:
有些操作不频繁,但是繁琐:
如何通过工具提高效率呢?
工具建设很容易陷入:追求一步到位、“完美”,进而过度设计。
例如,在业务网关改造初期,我考虑引入 Terraform 以实现全自动化操作,包括:
粗略估算后的建设成本打醒了我:一开始的目标不是 效率提升 吗? 这和我几年前把 jQuery + Handlebars 替换为更 fancy 的 Angular(性能反而略微下降)没有本质区别。
业务中追求的是实用,白猫黑猫,抓到老鼠就是好猫。切记拼命优化的指标在具体业务场景根本不关心。具体到工具建设,只要开发使用体验好,节省了重复劳动,就是好工具。
在当前的开发流程中,CI、机器人和 Git 已经被广泛应用,开发团队对它们非常熟悉。。熟悉就是好用,有办法不引入新的工具完成自动化,减少重复工作吗?
测试中设置转发(切环境)是十分高频的操作,机器人大大提高了效率。
引入企微频道机器人后,设置染色的时间从之前的平均 3 分钟缩短到 10 秒,而且不再依赖于 PC 浏览器。
白名单/oidb的变更 -> 审批 -> 生效,从原来的平均 2h,缩短到 20min。
大多数棘手 bug 都有一个特点:本地无法复现、测试环境无法复现、正式环境偶现!
在升级了 tRPC 基础库的第二天,开始出现:
(服务整体平均耗时)
服务整体平均耗时,类似于网页的首次可交互时间,是衡量应用可用性的黄金标准。只要平均耗时增加,就是“发烧了”,需要立即根据其他指标(cpu/memory/disk...)、日志找病根。
应用日志上报的错误信息——如“pksey 无效”或“tRPC 调用超时”——在正常情况下也会偶尔出现,但这次它们的频率异常增高。问题在于,这些错误信息太常见了,无法直接指向问题的根源。那么,我们应该如何进一步探索呢?
正当我们感到进退两难时,容器的文件日志为我们提供了新的线索:(北极星是服务发现和治理中心,现已开源 https://polarismesh.cn/)
从报错中观察到一大堆 ip。
依靠对名字服务粗浅的理解:naming server 负责解析其他服务的名字到ip/port,唯独自己不能有名字,需要硬编码 ip。
猜测:这些 ip 是硬编码的 naming server ip,报错日志显示在对不同 server 进行链接重试。
猜想、验证
polaris 版本有变动,而新版本修改了 server ip —— 这是首先想到的最可能原因。
版本确实升级了(0.3.x -> 0.4.x),经过 diff 两个版本相关源码后,发现 server ip 没有任何更改。
否认了 server ip 变更的猜想后,必须调整 Debug 思路:
寻求协助
两个版本之间的变动十分多。为了更快解决问题,决定立即寻求相关开发团队的帮助:
可能并不是应用代码存在问题,也稍微放心了点。
但是,运行一段时间后,重建的pod 再次出现了上面的报错。放下的心再次悬起来:立即登录 pod,对报错的 ip telnet 测试,网络连通没问题,所以还是应用代码的问题!
非常手段
时间紧迫,无奈只能走钢丝:对线上环境开启实时调试。
太久没这样搞过,一边隔离 pod,一边手忙脚乱查自定义调试端口的写法(生产环境对可访问端口有严格限制):
(...略掉大量无借鉴意义的试验细节)
最终在 memory 的快照中发现了端倪:
按 retained size 倒序后,出现了 PolarisGRPCClient。
展开 PolarisGRPCClient 后发现,每个 client 还都一个 pool:
既然有 pool,client 被设计为复用,但却有这么多 client,每个还有独立的 pool,显然有问题:要么该销毁的时候没销毁,要么缺失了缓存机制。
瞬间惊醒:缓存!
18 个月前写下这个函数的时候,特意留下提醒:
之前一直把目标聚焦在北极星,问题压根不在这,而是使用北极星的库:trpc-rpc-client。
查看 trpc-rpc-client 最新版的源码,果然 createObjectProxy 没了缓存。
在流量增长、功能扩展与性能优化的交织下,把一个协议转换发展为业务网关。每一步的发展都不是事先规划的,而且和最初的设想完全不同。
当时同时存在其他可用的替代方案:JSAPI(客户端方案)、HTTPSSO(后台方案),为什么最后落到 Node (前端)来做呢?我觉得一句话总结,是开发选择了 Node 。
Node 只是三个选项中的一个,最后前端、游戏、其他业务的开发(比如腾讯文档)、包括后台,选择了 Node。通过上面的过程,我归纳了 4 个原因:
1. 使用体验
每个开发会用脚投票,不好用就会选择其他替代方案。我熟悉前端最习惯的调用方式:
同时为了让后台的服务不改动也能被尽可能多的方式调用**(APP、小程序、H5、管理端),我做了大量工作,这也是不少后台希望走 Node 调用的原因。
2. 稳定第一
Node 在测试环境、正式环境的稳定性都被认可。相对于其他调用方法,Node 测试环境的稳定赢得了开发的心。并且,Node 在正式环境的可用性也达到了 4个 9。
3. 及时响应、敏捷高效
毕竟我就坐在旁边,前端小伙伴吼一声就可以了。一个最近的例子,海外版开始也是计划先走 JSAPI,但是一个 cookie 的修改可能就得排期,通过 Node 5分钟,需求响应及时、高效。
4. 持续优化的性能
ngw/tsw -> apisix,编解码、连接等,性能一直在变好,目前可能还不是性能最佳的,但也不会成为瓶颈,是已有替代方案中体验和性能综合最好的。
当然还有十分重要的,频道放量和业务潜力,为网关发展提供根本动力:
不需要胸怀大志,把一件件小事做好,顺势而为。
最后引用一位大佬的话:希望每个看完的小伙伴,忘掉,再重新总结出属于自己的东西。
-End-
原创作者|付志远