笨鸟先飞的橘猫头像

RPC 模块面试讲解稿

RPC 模块面试讲解稿

基于 skynet 自研的分布式 RPC 框架,支持同步调用、异步通知、超时控制、熔断保护。


一、一句话定位(15 秒开场)

我基于 skynet actor 模型自研了一套分布式 RPC 框架,支持同步调用、异步通知、超时控制、熔断保护。业务代码只需 rpc.call("svc", "method", args...),框架自动完成跨节点路由和 relay 中继。核心优化包括零 fork 协程开销、自定义协议通道、二进制安全的 body 序列化。


二、架构设计(1 分钟)

2.1 三层结构

┌─────────────────────────────────────────────────────────┐
│                    业务层                                │
│   rpc.call("match_mgr", "create_battle", players...)     │
└───────────────────────┬─────────────────────────────────┘
                        │
        ┌───────────────┴───────────────┐
        │                               │
   目标在本节点                      目标在其他节点
   local_call()                      remote_call()
        │                               │
        ▼                               ▼
┌───────────────┐              ┌──────────────────┐
│  relay_agent  │              │    relaysvr      │
│  (节点代理)    │              │   (集群路由)      │
│               │              │                  │
│ 分派到 handler │              │ 查路由表 → 转发   │
└───────┬───────┘              └────────┬─────────┘
        │                               │
        └───────────┬───────────────────┘
                    ▼
              ┌──────────┐
              │  handler │
              └──────────┘

2.2 各层职责

层文件职责
rpc.lualualib/rpc.lua业务入口,路由决策,Token 生命周期管理,熔断
relaysvrservice/relaysvr.lua集群路由表,service_name → node_name 查找
relay_agentservice/relay_agent.lua节点代理,service_name → handle 转发,响应回传

2.3 调用路径(完整链路)

本地调用(同节点):

rpc.call("chat_mgr", "broadcast", msg)
  → resolve("chat_mgr") → 查到本地 handle
  → local_call(handle, "broadcast", timeout, args)
    → 有超时: skynet.send(relay_agent, "dispatch_call_async", token, packstring(args))
             → relay_agent → skynet.unpack → skynet.call(handler, "broadcast", ...)
             → 结果通过 skynet.send("snrpc", token, packed) 回传
             → snrpc dispatch → rpc_mgr:Response(token, packed)
             → skynet.wakeup → WaitResponse 返回
    → 无超时: skynet.call(handler, "lua", "broadcast", args...)  直连,零中转

远程调用(跨节点,零 fork 路径):

rpc.call("match_mgr", "create_battle", players)
  → resolve("match_mgr") → 未命中(不在本节点)
  → remote_call("match_mgr", "create_battle", timeout, args)
    → skynet.send(relaysvr, "route_call_async", node_name, self(), ...)
    → relaysvr 查路由表: "match_mgr" → "battle" 节点
    → cluster.send("battle", "@relay_agent", "dispatch_call_async_remote", ...)
    → relay_agent(battle) → skynet.unpack → skynet.call(match_mgr, ...)
    → 结果: cluster.send(源节点, "@relay_agent", "__rpc_ret__", caller_addr, token, packed)
    → relay_agent(源节点) → skynet.send(caller_addr, "snrpc", token, packed)
    → snrpc dispatch → rpc_mgr:Response(token, packed)
    → skynet.wakeup → WaitResponse 返回

降级路径(纯调用节点,无 relay_agent):

rpc.call(...)
  → node_name == nil → 回退 fork 路径
  → skynet.fork → skynet.call(relaysvr, "route_call", svc, method, header, body_str)
  → relaysvr 同步转发 → 等待结果返回

三、核心设计 & 为什么这样做

3.1 Token 生命周期管理(RpcManager)

问题: skynet 原生 skynet.call 的超时是全协程级别的。一旦超时,整个协程被销毁,无法区分"哪个 RPC 调用超时了",更无法做精细化的超时告警和统计。

方案: 自研 RpcManager,为每个调用分配一个唯一 token。

Token 编码: (seq << 3) | rpc_type(自增序列号 << 3 + 调用类型低 3 位),支持 4 种 RPC 类型。

NewRpc(rpc_type, timeout)
  ├─ 分配 token: (seq << 3) | rpc_type
  ├─ timer.once(timeout, cb)   ← 超时回调
  └─ 返回 token

Response(token, packed_reply)
  ├─ 查 tokens[token] 状态
  ├─ 未超时 (true) → 存储 rsps[token] + skynet.wakeup
  └─ 已超时 (false) → 丢弃(迟到响应)

WaitResponse(token)
  ├─ skynet.wait("rpc_wait_"..token)  ← 阻塞当前协程
  ├─ 被 skynet.wakeup 唤醒
  └─ RemoveRpc(token) → 返回 (Error.OK, result) 或 (Error.Timeout)

关键洞察:超时 vs 正常响应是竞速关系

时间线:
  0ms ── 发送请求 ── 注册超时回调
  ...
  500ms ── 超时触发 → tokens[token] = false → skynet.wakeup → WaitResponse 返回 Timeout
  500ms ── 响应到达 → 发现 tokens[token] == false → 丢弃 ← "迟到响应被优雅处理"
  
  或者:
  300ms ── 响应到达 → tokens[token] = true → rsps[token] = reply → skynet.wakeup
  500ms ── 超时触发 → tokens[token] == nil (已被 RemoveRpc) → 无事发生

两种竞速结果都是正确的,没有协程泄漏,没有内存泄漏。


3.2 零 fork 优化

问题: 初版实现每次 RPC 调用都 skynet.fork(function() skynet.call(...) end),高并发下协程创建开销不能忽略。假设 RPC 超时 5 秒,每秒 1000 个调用 → 峰值 5000 个协程。skynet 的协程有独立的 Lua stack,每个至少几个 KB。

方案: 不创建协程。把"调用 handler → 回传结果"的职责下放给 relay_agent(独立 skynet 服务)。

改前(每调用多一个 fork 协程):
  ┌─ 主协程 ──────────────────────────┐
  │ local_call(...)                   │
  │   skynet.fork(function()         │  ← 协程 A (额外开销)
  │     skynet.call(handler, ...)     │
  │     rpc_mgr:Response(token, ...)  │
  │   end)                            │
  │   return rpc_mgr:WaitResponse()   │  ← 主协程阻塞等待
  └───────────────────────────────────┘

改后(零额外协程):
  ┌─ 主协程 ───────────────────────────────────────┐   ┌─ relay_agent 服务 ─┐
  │ local_call(...)                                │   │                    │
  │   skynet.send(relay_agent, "dispatch_call_async")│  │ skynet.call(handler)│
  │   return rpc_mgr:WaitResponse()                │   │ skynet.send(snrpc)  │
  └────────────────────────────────────────────────┘   └────────────────────┘
       ↑ 主协程阻塞,零额外协程创建                          ↑ relay_agent 用自己的协程

收益: 整个 RPC 调用只消耗调用方自己一个协程。relay_agent 是独立的 skynet 服务,它的协程是所有调用方共享的,复用率极高。


3.3 snrpc 自定义协议

问题: 老方案用 skynet.send(addr, "lua", "__rpc_resp__", token, data)。skynet 的 “lua” 协议是共享通道——所有 lua 消息走同一个 dispatch handler。收到消息后要解析 cmd 字符串做比对:

-- 老方案
skynet.dispatch("lua", function(session, source, cmd, ...)
    if cmd == "__rpc_resp__" then      -- 每个消息都要比对
        rpc_mgr:Response(...)
    elseif cmd == "do_match" then
        ...
    end
end)

方案: 利用 skynet 的内核能力,注册独立协议类型。

skynet.register_protocol {
    name = "snrpc",
    id   = 13,
    pack = skynet.pack,
    unpack = skynet.unpack,
    dispatch = function(session, source, token, packed_reply)
        rpc_mgr:Response(token, packed_reply)
    end,
}

-- 发送
skynet.send(addr, "snrpc", token, packed)

收益:

  • skynet 内核按协议号 13 直接路由,不需要字符串比对,O(1)
  • RPC 响应和业务消息完全隔离,互不干扰
  • 框架自动注册在 rpc.init() 中,业务代码无感

3.4 packstring 序列化 body

问题: table.unpack(args) 把 body 拆成 N 个独立参数穿过 relay 链。二进制数据(protobuf/sproto 编码的 string)作为独立参数传递时,经过多次 pack/unpack 可能存在隐含问题。

改前:
  发端: table.unpack(args)             → N 个独立参数
  relaysvr: ...                        → 透传 N 个参数
  relay_agent: ..., arg1, arg2, ...    → 接收 N 个参数
  handler: 收到 N 个参数

改后:
  发端: skynet.packstring(table.unpack(args))  → 1 个不透明 blob
  relaysvr: ...                                  → 透传 1 个参数(零改动!)
  relay_agent: body_str                          → 接收 1 个参数
  handler: skynet.unpack(body_str)               → 还原 N 个参数

为什么 relaysvr 不需要改动? 因为它的签名是 function CMD.route_call(svc, method, ...),... 自动适配参数数量的变化。


3.5 三态熔断器

问题: 当远程服务宕机或持续超时时,调用方不应无限重试,否则会浪费协程、内存和网络资源。

方案: 实现标准的三态熔断器。

CLOSED ──连续失败 N 次──▶ OPEN ──冷却 T 秒──▶ HALF_OPEN
   ▲                                              │
   └──────────── 探测成功 ────────────────────────┘
                       探测失败 → 回到 OPEN
  • CLOSED: 正常放行,记录成败
  • OPEN: 直接拒绝(allow() → false),不发起调用
  • HALF_OPEN: 放行一次探测请求,成功则 CLOSED,失败则 OPEN

仅对远程调用生效,本地调用不经过熔断器。


3.6 本地优先路由

function M.call(...)
    local handle = resolve(service_name)   -- 先查本地
    if handle then
        return local_call(...)              -- 同节点直连
    end
    return remote_call(...)                 -- 走 relay 中继
end

同节点 skynet.call 是纯内存消息传递,延迟远低于跨节点 relay。


四、使用的 Skynet 核心接口(附说明)

消息通信

接口作用
skynet.call(addr, "lua", cmd, ...)同步阻塞调用,等 skynet.ret() 返回。用于 relay_agent → handler
skynet.send(addr, type, ...)异步发送,不等待返回。用于零 fork 发包 + snrpc 响应回传
skynet.ret(pack(...))给 caller 返回结果。用于 relay_agent 返回 handler 的返回值
cluster.proxy("node@svc")创建跨节点远程代理地址
cluster.send(node, "@svc", ...)跨节点异步发送。用于 relaysvr → relay_agent 转发
cluster.register(name, addr)注册服务到 skynet 集群
cluster.call(node, addr, ...)跨节点同步调用(降级路径使用)

协程控制

接口作用
skynet.wait(key)挂起当前协程,直到 skynet.wakeup(key) 被调用。用于 WaitResponse 阻塞
skynet.wakeup(key)唤醒挂起在对应 key 上的协程。响应到达 / 超时时调用
skynet.fork(func)创建新协程。仅在降级路径使用(纯调用节点无 relay_agent)
skynet.self()获取当前服务地址。告诉 relay_agent “响应回传给我”

定时器

接口作用
skynet.timeout(cs, cb)延迟 cs 个 10ms 单位后执行回调。timer 模块的底层依赖
timer.once(cs, cb)一次性定时器。为每个 RPC token 注册超时回调
timer.every(cs, cb)周期定时器。周期上报路由表

序列化

接口作用
skynet.pack(...)多值序列化为字符串。响应打包:pack(ok, result)
skynet.unpack(str)反序列化。回包解包 + relay_agent 解包 body
skynet.packstring(...)序列化为纯字符串(与 pack 同类)。body 打包,不拆散穿过 relay 链

协议 & 服务管理

接口作用
skynet.register_protocol{name,id,...}注册自定义协议。snrpc 协议 id=13
skynet.dispatch(type, handler)注册协议处理器。snrpc 消息直连 rpc_mgr:Response
skynet.uniqueservice(name)获取或创建全局唯一服务。启动 relay_agent
skynet.localname(name)查找本地已注册服务
skynet.register(name)注册当前服务名称
skynet.now()获取当前时间(10ms 精度)。熔断器冷却计时

五、面试问答 Q&A

Q1: RPC 超时怎么实现的?

用 skynet 原生的协程挂起/唤醒机制。每个 RPC 调用分配一个 token,启动时注册 timer.once 超时回调。调用方通过 skynet.wait(token_key) 挂起。超时回调和正常响应都调用 skynet.wakeup(token_key) 唤醒。两者是竞速关系——先到者注入结果,迟到者检测到 token 已被清理则直接丢弃。

Q2: 为什么不用 skynet.call 自带的超时?

skynet.call 的超时是销毁协程级别的,无法按单个 RPC 调用做精细化控制。比如一个协程里先后调了 A 和 B 两个服务,如果 A 超时协程就被杀了,B 永远不会执行。用自研 RpcManager 后,每个 RPC 独立超时,互不影响。

Q3: 零 fork 优化解决了什么问题?

初版每调用一次 RPC 就 fork 一个协程去等待结果。假设超时 5 秒,每秒 1000 QPS → 峰值 5000 个协程。skynet 的协程有独立 Lua stack,每个至少几 KB。改成 skynet.send + 定时器 + WaitResponse 后,整个调用只消耗调用方自己的一个协程,零额外创建。

Q4: snrpc 和 lua 协议有什么区别?

lua 协议是共享通道,所有消息走同一个 dispatch,收到后要解析 cmd 字符串。snrpc 是独立协议类型(id=13),skynet 内核直接按协议号路由,不需要字符串比对。另外语义上也更清晰——snrpc 就是 RPC 响应通道,和业务消息完全隔离。

Q5: 跨节点 RPC 的响应是怎么回来的?

分两步:目标节点的 relay_agent 执行完 handler 后,通过 cluster.send(源节点, "@relay_agent", "__rpc_ret__", caller_addr, token, packed) 把结果发回源节点的 relay_agent。源节点 relay_agent 再通过 skynet.send(caller_addr, "snrpc", token, packed) 本地转发给调用方。调用方的 snrpc dispatch 直接路由到 RpcManager:Response,然后 skynet.wakeup 唤醒。

Q6: 熔断器为什么设计成三态?

二态(开/关)有 hysteresis 问题:恢复正常后如果立即全量放行,可能再次冲垮。三态(CLOSED/OPEN/HALF_OPEN)在检测到恢复后先放行一个探测请求,成功才完全关闭熔断器,让系统平滑恢复。

Q7: 协议头(header)里传了什么?

来源集群名(src_cluster)、命令名(cmd,即方法名)、请求 token、元数据(meta_data)、链路追踪上下文(trace_ctx)、回调参数(cb_args)。目前 header 通过 skynet 的 table 序列化透传,主要用于错误诊断——handler 出错时 relay_agent 从 header 里提取来源信息打日志。


六、完整接口清单(参考用)

消息通信:
  skynet.call          → 同步调用
  skynet.send          → 异步发送
  skynet.ret           → 返回值
  cluster.proxy        → 跨节点代理
  cluster.send         → 跨节点异步
  cluster.call         → 跨节点同步(降级)
  cluster.register     → 集群注册

协程控制:
  skynet.wait          → 协程挂起
  skynet.wakeup        → 协程唤醒
  skynet.fork          → 创建协程(仅降级)
  skynet.self          → 获取自身地址

定时器:
  skynet.timeout       → 基础定时器回调
  timer.once           → 一次性定时器
  timer.every          → 周期定时器

序列化:
  skynet.pack          → 多值序列化
  skynet.unpack        → 反序列化
  skynet.packstring    → body 序列化

协议 & 管理:
  skynet.register_protocol  → 自定义协议注册
  skynet.dispatch           → 协议处理器注册
  skynet.uniqueservice      → 全局唯一服务
  skynet.localname          → 本地服务查找
  skynet.register           → 服务注册
  skynet.now                → 当前时间

七、文件索引

文件说明
lualib/rpc.luaRPC 核心模块(入口、路由、Token 管理)
service/relaysvr.lua集群路由中继服务
service/relay_agent.lua节点代理服务
lualib/rpc_example.lua参考实现(设计蓝图)
lualib/timer.lua定时器模块(timer.once / timer.every)
lualib/circuit_breaker.lua三态熔断器
lualib/error.lua结构化错误类型

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_40453090/article/details/168869537

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--