ARTICLE DETAIL

资讯详情

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

用Unix socket和JSON-RPC实现单机微服务进程间通信

用Unix socket和JSON-RPC实现单机微服务进程间通信 我先说结论如果你的服务拆分只发生在一台机器内部用 TCP 端口挂 HTTP 接口纯属自找麻烦。端口冲突、防火墙规则、序列化开销、还有绕不开的 Nginx 倒腾这套为互联网准备的通信范式放到本地进程间调用上绕的路不是一点半点。我最近把积压很久的想法落地成了一个小项目叫 Microduck——把单体服务拆成一堆彼此独立又互相协作的守护进程我叫它守护进程军团。进程之间不走网络改走 Unix domain socket协议不用 REST用 JSON-RPC 2.0。整条链路跑通之后单次调用的平均延迟比 TCP/HTTP 低了将近一半代码量还精简了一截。这篇文章把架构设计、协议实现、部署步骤、压测数据和几个印象深刻的坑都记录下来给想在单机内做进程级拆分的朋友一个参考。1. 为什么我放弃HTTP走Unix socket本地通信不该走互联网那套1.1 微服务拆分的第一反应停在了端口上先交代一下背景。我接手过一个老项目单体应用代码越滚越大发布越来越频繁改一行代码就要重新构建整个服务。第一反应自然是拆服务但查了一圈资料发现绝大多数教程默认你拆完就要上多台机器、搞服务发现、搞 API 网关……我当时的实际情况是几台内部服务器没有上云的打算服务拆了之后还是跑在同一台机器上最多用进程管理器管起来。动用一整套分布式基础设施去解决单机进程拆分就像开着坦克去菜市场买菜方向就错了。那退一步同一台机器上进程间通信用什么最直观的方案是 localhost 上开端口HTTP 调用。但实际跑起来才发现TCP/IP 处理本地回环流量时该走的协议栈流程一步都没少三次握手、校验和、路由查找、流量控制、拥塞控制、连接状态维护。这些机制在跨机房通信时是必要的但在两个进程共享同一套内核资源的情况下纯属冗余开销。而且既然目标是服务自己调自己把 HTTP 的 Header 解析、状态码语义、还有对外提供 HTTP 端口这套也带上反而凭空增加了暴露面。1.2 Unix socket JSON-RPC 的组合到底赢在哪Unix domain socket 从设计上就是为同主机进程间通信准备的它不走网络协议栈直接在共享内存和内核缓冲区之间搬运数据。和 TCP 相比有几个非常直观的收益维度TCP HTTPUnix socket JSON-RPC连接建立三次握手 HTTP 握手 可能的 TLS 协商内核直接绑定 socket 文件几乎没有握手成本数据路径内存、协议栈、路由、环回接口逐一处理内核套接字直接传递数据拷贝次数更少端口管理需要分配端口、防冲突、配置防火墙没有端口概念用 socket 文件标识服务访问控制防火墙、认证、令牌体系直接复用文件系统权限chmod/chown 即可网络暴露面本机 IP 上可被扫描和探测只有文件系统可访问天然不被网络探测调试方式curl、浏览器、Postmansocat、nc、或者 CLI 直接读写 socket 文件JSON-RPC 这边呢它跟 Unix socket 搭配起来非常顺手。REST 的语义是面向资源一个 URL 对应一种资源动作靠 HTTP 动词区分还得设计一套状态码语义层。而 JSON-RPC 就是朴素的你要调用哪个函数、传什么参数返回结果或者错误。一条{jsonrpc:2.0,method:authd.verify_token,params:{token:abc},id:1}就能完成一次带上下文的内部调用。Microduck 选择 JSON-RPC 而不是 Protocol Buffers 这类二进制协议还有一个现实原因调试太方便了。某个服务出问题时不需要解析二进制流直接看日志里打印的原始 JSON 就知道谁调了谁、传了什么、返回了什么。开发环境里用 socat 挂上 socket 文件甚至可以手工发一段 JSON 去模拟客户端调用——这个特性在排查问题的时候帮了大忙。2. Microduck的守护进程编排军团角色与生命周期管理2.1 一个总管加一群干活的进程角色设计Microduck 的架构里没有复杂的注册中心也没有独立的编排调度器只有两类进程一个 supervisor我叫它 ducklord就是军团指挥官和一堆业务 daemonworker。总管负责干三件事读取配置、拉起 worker、监督 worker 的健康状况并在崩溃时拉起新实例。业务 daemon 各自监听一个 Unix socket处理 JSON-RPC 请求。我常用军团的比喻来解释这套结构ducklord 不亲自冲锋陷阵但它知道每个士兵应该站在哪个位置一旦哪个士兵掉队了立刻补位。具体到 worker 之间它们也会互相调用——A 服务需要 B 服务的数据直接用客户端库连到 B 的 socket 文件发一个 JSON-RPC 请求就行不需要经过 ducklord 转发。这样做的好处是通信路径最短A 到 B 就是一次直接的内核 IPC中间没有消息队列、没有网关代理延迟和故障面都被压到最小。为什么需要自己写 supervisor 而不是直接用 systemd 的 unit 依赖因为 systemd 的管理粒度是整个 unit 的启停而我希望在进程层面有更细的控制比如某个 worker 崩了之后要在多少毫秒内自动重启、是否需要等待依赖的其它服务就绪、某个服务上线后要向哪个总控 socket 汇报。把这些逻辑都收进 supervisor 里在开发机、生产服务器和容器里都能保持一致的行为不依赖具体的 init 系统。2.2 启动顺序、服务发现和健康检查Microduck 的启动流程大致是这样ducklord 读取配置文件YAML 格式包含每个服务的命令、参数、启动顺序、socket 路径、环境变量和重启策略。按声明的依赖顺序拉起 worker每个 worker 启动后创建自己的 Unix socket 文件并向 ducklord 上报一条 readiness 消息。ducklord 确认前置依赖全部就绪后才把该服务标记为 up继续拉起依赖它的下一个服务。所有服务都 up 之后ducklord 周期性下发心跳检查调用每个服务的mduck.ping方法连续多次失败就判定为挂死重新拉起。服务发现被我做得近乎原始socket 文件路径就是服务地址命名规范是/runtime_dir/service_name.sock。客户端库从配置文件里拿到服务名到 socket 路径的映射根本不需要什么注册中心。有人质疑这算什么微服务发现我的回答是在单机场景下文件系统本身就是最好的注册表——服务在不在看 socket 文件在不在就行。配置文件长这样runtime_dir: /var/run/microduck services: authd: cmd: /opt/microduck/bin/authd --config /etc/microduck/authd.yaml after: - keyd socket: /var/run/microduck/authd.sock respawn_delay_ms: 2000 env: - MDC_LOG_LEVELinfo datad: cmd: /opt/microduck/bin/datad socket: /var/run/microduck/datad.sock logd: cmd: /opt/microduck/bin/logd socket: /var/run/microduck/logd.sockafter字段声明依赖关系ducklord 会根据它生成一个启动 DAG检测到循环依赖就直接报错退出。心跳间隔默认 5 秒可以按服务单独调整。我还让每个 worker 在初始化完成后显式发送一条mduck.ready通知给 ducklord因为进程起来了和服务能对外提供服务是两件完全不同的事。这一点在踩坑章节会细讲——很多守护进程系统只看进程是否存活结果服务明明已经卡死进程还活得好好的车已经抛锚了引擎还在空转。3. JSON-RPC over Unix socket 的协议层落地3.1 帧边界怎么划长度前缀比换行符稳JSON-RPC 协议本身没有定义传输层的帧格式需要在 Unix socket 这个面向字节流的通道上自己约定一条消息从哪开始到哪结束。最简单的办法是换行分隔每条 JSON 序列化后加一个\n接收方按行读。但 JSON 规范里字符串不能出现裸换行会被转义成\n两个字符所以严格来说按行分隔在纯文本场景下是能用的。Microduck 却没有用换行而是用了标准的 4 字节大端长度前缀加 JSON payload 的方案。选择长度前缀不仅是为了对抗粘包半包更重要的是给后续扩展留空间——将来如果要支持二进制消息体、支持大响应分片传输长度前缀天然比逐行解析更顺手。网络编程里有个原则协议设计要往前看一步帧格式定了之后很难再改因为两边的二进制兼容性是最难迁就的。服务端接收的代码大致这样import asyncio import struct import json MAX_MESSAGE_BYTES 64 * 1024 * 1024 async def read_rpc_message(reader): header await reader.readexactly(4) length struct.unpack(!I, header)[0] if length MAX_MESSAGE_BYTES: raise RpcProtocolError(fmessage too large: {length}) payload await reader.readexactly(length) return json.loads(payload) async def write_rpc_message(writer, message): payload json.dumps(message, ensure_asciiFalse, separators(,, :)).encode() writer.write(struct.pack(!I, len(payload))) writer.write(payload) await writer.drain()readexactly(4)是把半包问题一次性解决掉的关键先等够 4 个字节拿到长度再按长度等够整包内容。如果对端只写了 2 个字节的长度前缀协程会等在那里直到超时或连接关闭绝不会出现解析到一半的 JSON 就报错的情况。另外加了一个MAX_MESSAGE_BYTES硬上限防止某个服务异常时输出几 GB 的 JSON 把对端内存打爆——我见过的内部 RPC 事故里这种静默撑爆内存比显式的错误难查得多。3.2 方法注册、错误码与超时取消Microduck 的 worker 端有一套极简的方法注册机制用 Python 的装饰器就能暴露 RPC 方法from microduck import RpcServer, rpc, RpcError server RpcServer(socket_path) rpc.method(authd.verify_token) async def verify_token(params: dict) - dict: token params.get(token, ) if not token: raise RpcError(1001, missing token) result await token_store.lookup(token) if result is None: raise RpcError(1002, token expired or invalid) return {uid: result[uid], expires_at: result[expires_at]} server.serve()注册表本身就是一个 dictkey 是方法名value 是协程函数收到请求后查表找不到就返回 JSON-RPC 标准错误码 -32601Method not found。应用层错误码做成了一套自定义区间1000-1999 是参数校验类2000-2999 是业务状态类3000-3999 是依赖服务异常类。我特意在文档里强调错误码要带语义区间因为实际开发中大家最容易犯的错误是每个服务自己定义了一套随机错误码跨服务调用时看到error.code 12345完全不知道是哪个服务抛的。客户端调用时必带超时。这是我从第一版就写死的设计一个 RPC 请求如果没有超时控制当被调服务因为慢查询或死锁卡住时调用方会一直等下去然后调用方的连接池被占满接着整个进程的服务质量一起崩掉。一个卡住的 daemon 最后拉垮整个军团这是分布式系统里最经典的故障放大路径。Microduck 客户端在每个请求创建asyncio.Future用asyncio.wait_for包一层超时后取消任务并返回RpcError(3008, caller timeout)日志里记录调用链路信息方便事后追责。3.3 客户端复用连接与并发调用的处理早期的版本我偷懒每次调用都新建一个连接调用完就关闭。压测一上来就发现问题Unix socket 的 connect 虽然便宜但建立连接后又要经过 ready 检查、消息交换、关闭清理高频调用下开销还是很明显。后来改成了连接池方案每个目标 socket 维护一组长连接默认 4 条请求通过队列分配到空闲连接上用完归还。并发调用这块有个容易被忽略的点一条 Unix socket 长连接上可以并发跑多个 JSON-RPC 请求靠id字段把请求和响应对应起来。也就是说客户端不必等上一个请求返回再发下一个可以把多个请求同时打过去服务端异步处理后各回各的id。实现里为每个 in-flight 请求建一个 Future收到响应后根据id找到对应的 Future 并塞入结果。这里要特别注意对端可能乱序返回——已经约定响应可以乱序但必须保证id一一对应否则并发调用会互相串包这类 bug 极其隐蔽普通压测不一定能测出来要在高并发乱序场景下才能暴露。4. 从零跑通 Microduck环境、配置与第一个跨服务调用4.1 拉代码装依赖我建议在 Linux 上跑内核 4.19 以上都行开发机、服务器、甚至低功耗工控机都没问题。代码放在 GitHub 上搜 microduck 就能找到仓库主力跟踪分支是 Python用 Python 3.10 的 asyncio 写的后面我还在折腾一个 Go 的实现。第一次跑通过程大概十分钟以内。先拉代码再建虚拟环境git clone repo-url microduck cd microduck python3 -m venv .venv source .venv/bin/activate pip install -e .[dev]装好之后确认 CLI 能跑mdc --version看到类似mdc 0.4.0的输出说明 CLI 装好了。整套 CLI 包含mdc start拉起整个军团、mdc status查看状态、mdc call发送 RPC、mdc stop优雅停机日常运维完全够用。4.2 编写服务配置文件先创建一个运行时目录示例配置放在sample/multi-service.yaml。平时调试我习惯直接跑本地配置写成这样runtime_dir: /tmp/microduck log_dir: /tmp/microduck/logs services: keyd: cmd: python3 sample/keyd.py socket: keyd.sock respawn_delay_ms: 1500 authd: cmd: python3 sample/authd.py after: [keyd] socket: authd.sock echo: cmd: python3 sample/echo.py socket: echo.sockafter表示 authd 要等 keyd 就绪后再启动。为什么默认把运行时目录放在/tmp而不是/var/run因为/var/run在普通用户下没有写权限需要 root 启动或者额外配权限规则。新手第一次跑 Microduck 十有八九会踩到这个权限问题所以我故意把示例配置放在/tmp先跑通再按生产环境打磨目录权限。启动之前手动先跑一次mdc check sample/multi-service.yaml做语法和依赖校验。这个命令是我后来补的因为总有朋友改完 YAML 缩进跑起来才发现配置文件解析失败还以为是守护进程的 bug。4.3 用 CLI 发一次 RPC配置写好后拉起整个军团mdc start -c sample/multi-service.yaml等两三秒等服务上报 ready然后看状态$ mdc status ducklord up pid 1024 socket /tmp/microduck/ctrl.sock keyd up pid 1042 uptime 0:00:08 authd up pid 1051 uptime 0:00:06 echo up pid 1060 uptime 0:00:05试着调一下 echo 服务$ mdc call echo.echo {message: hello microduck} {echo: hello microduck, server: echo-worker-1}mdc call的语法是服务名.方法名加 JSON 参数字符串内部会拼接成完整的 JSON-RPC 请求发到对应 socket。到这里Microduck 就算正式跑通了。4.4 写一个真实业务服务 demo光会调 echo 不够我拿认证服务做个例子。authd 的整体逻辑启动时接收 keyd 下发的 HMAC 密钥对外暴露authd.verify_token方法验证 token 后返回用户信息。这里有一条关键的跨服务调用authd 每次校验 token 前先调用 keyd 的keyd.current方法获取最新的验签密钥避免重启后密钥不同步的问题。核心代码长这样节选rpc.method(authd.verify_token) async def verify_token(params: dict): token params.get(token, ) if not token: raise RpcError(1001, missing token) key_info await mdc_client.call( keyd, keyd.current, {}, timeout2.0 ) secret key_info[secret].encode() payload verify_hmac_signature(token, secret) if payload is None: raise RpcError(1002, token invalid) return { uid: payload[uid], name: payload[name], expires_at: payload[exp], }这就是整个架构最常用的模式服务 A 依赖服务 BA 处理请求时协程级调用 B然后聚合结果返回。因为走的都是 Unix socket 本机 IPC这种嵌套调用的端到端延迟通常也就在几百微秒量级完全可接受。如果哪天链路深了A 调 B、B 调 C、C 调 DMicroduck 会透传一个trace_id到每个请求的 params 里日志里按trace_id能串起整条调用链——这个机制在压测和排障时非常有用后面踩坑章节我会讲一个靠它定位问题的实例。5. 性能实测Unix socket 到底比 TCP/HTTP 快多少5.1 测试环境和压测方法光说Unix socket 更快不够得有数据支撑。我的测试机是一台双路 E5-2680 v4 服务器Linux 5.15 内核Python 3.11。压测场景很简单服务端暴露一个ping方法收到请求后原样返回pong不掺任何业务逻辑。客户端用并发 100 个协程连续发起 10 万次调用统计平均延迟、p99 延迟和每秒请求数。对照组是这样搭的同一套 worker 代码分别监听在 TCP127.0.0.1:port和 Unix socket 文件上协议都用 JSON-RPC。也就是说只有传输层不同协议、序列化、业务代码完全一致变量控制得住。另外额外跑了一组用 aiohttp 的 HTTP/1.1 加 JSON 作为对照组看看完整走 HTTP 栈还要再降多少。5.2 对比数据和解读通信方式平均延迟 (µs)p99 (µs)吞吐量 (req/s)TCP JSON-RPC (Python asyncio)315560约 9,500Unix socket JSON-RPC (Python asyncio)175300约 17,600HTTP/1.1 JSON (aiohttp)520890约 6,100Unix socket JSON-RPC (Go)4278约 58,000几个解读要点同样的 Python 代码从 TCP 换到 Unix socket平均延迟从 315µs 掉到 175µs降幅接近 44%。链路短一截就是实打实快一截。HTTP 比裸 TCP 又低了将近一半说明请求解析、Header 处理、连接模型都在付出真实成本框架层做的事比想象中多得多。Go 版本性能完全不在一个量级背后是调度模型和内存模型的差异。如果对性能敏感把 Microduck 的传输层和 worker 协程调度换成 Go 或 Rust收益会非常大。我也在 ED330 这类低功耗工控机上跑通过同样的代码双核 ARM 处理器吞吐大约 3000 req/s端到端平均延迟 200µs 上下。对内部服务来说完全够用这也让我坚持协议层用 JSON 文本、传输层走 Unix socket的组合——不引额外依赖不背业务包袱足够简单也足够快。5.3 这套架构的边界与不适用场景把话也说清楚Microduck 不是要替代 K8s 那套分布式微服务体系它解决的是单机内进程级拆分的问题。如果服务要跨机器、跨机房部署Unix socket 直接出局老老实实用 TCP 或者 gRPC 加注册中心。如果调用方是浏览器或手机 App也永远走不到 Unix socket 上外层该挂网关挂网关。换句话说Microduck 适合的场景是你有一个单体想拆成多个独立进程想让每个服务可以独立发布、独立更新但物理上就跑在同一台机器上。这种场景在中小团队其实非常常见而大家往往默认跳过去直接上分布式全家桶反而把简单问题搞复杂了。6. 踩坑实录我在这条路上交过的学费6.1 socket 文件权限、残留与 Abstract 命名空间第一个坑就是 socket 文件的权限。Microduck 早期版本由 ducklord 用 root 创建运行时目录然后以普通用户身份拉起 worker。worker 启动时尝试在自己的 socket 路径上 bind结果直接Permission denied。原因很简单运行时目录的权限是0755 root:root普通用户没有写权限自然创建不了 socket 文件。解决办法有两层。第一层是目录规划运行时目录由 ducklord 预先创建好权限设为0770属组改成microduck所有 worker 都以该组身份运行。第二层是更彻底的方案——用 Linux 的 abstract namespace socket也就是 socket 路径以空字符开头不占文件系统路径彻底绕开目录写权限的问题。但 abstract socket 有个缺点不能用文件系统权限做访问控制谁拿到这个抽象地址都能连。Microduck 默认用文件系统 socket 而不是 abstract socket就是为了保留权限控制这个特性。第二个坑是 socket 文件残留。worker 被SIGKILL强杀后socket 文件不会自动删除下次 bind 同一路径会报Address already in use。我在每个 worker 启动时先无条件 unlink 一次目标路径再 bind。这里有个细节要先检查目标文件是不是 socket 文件别把一个正常的业务文件给 unlink 了。判断方式是调用 stat 看文件类型如果存在但不是 socket直接报错退出而不是删掉它——那种帮用户删文件的事故我见过太多一旦误删业务文件数据恢复的成本是灾难级的。6.2 粘包半包的定位链路第二版实现里服务端每收到一段数据就尝试json.loads整段字节结果跑到线上后发现某些服务偶发报 JSON 解析错误。当时第一反应是客户端发送的数据有问题但服务端日志里打印出来的字符串又能被 Python 正常解析——这就很矛盾了。后来用 socat 把 socket 上的原始字节流全部导出到文件一行一行十六进制地看才意识到是典型的粘包半包问题客户端多个并发请求写到了同一条连接上内核缓冲区把两个 JSON 包粘在一起了或者一个大包被拆成多次 write 发送服务端第一次只读到一半。用json.loads去解析拼接后的字节流自然各种失败。定位到根因后把发送和接收都改成了长度前缀协议也就是 3.1 节那套方案之后再没出现过解析错误。这里想强调的是排查这类问题最有效的手段不是看应用层日志而是抓原始字节流看帧的边界在哪。socat、tcpdump 导出的数据能直接告诉你这次到底是协议设计的问题还是代码实现的问题。6.3 进程活着但服务死了一次心跳误判的完整排查最后这个坑最有代表性。当时一套已上线的 Microduck 军团突然出现大面积调用超时我第一时间打开mdc status看到所有服务都是 up 状态ducklord 也没有重启任何 worker。但业务侧确实在报错超时率超过 60%。排查链路大致是这样先看 ducklord 日志没有任何进程退出事件说明 worker 进程都还活着。再看每个 worker 的 CPU异常偏低。高并发下 CPU 应该烧起来异常低说明它们根本没在处理请求。尝试手动用mdc call发一个 ping卡住直到 CLI 超时。通过strace -p pid挂到某个 worker 上发现事件循环卡在一次文件读操作上。继续追发现这个 fd 属于某个数据库连接连接上堆着大量未读的响应数据——真正卡住的是这个 worker 内部一次同步的数据库调用它阻塞了 asyncio 事件循环导致心跳和业务请求都在排队。根因找到了某个 worker 里的 SQL 查询没有走异步驱动用了同步的客户端在事件循环线程里直接调用。并发一上来慢查询把事件循环堵死心跳响应也迟迟发不出去。而 ducklord 心跳逻辑是连续 3 次失败才重启恰好每次心跳都卡在超时边沿被处理掉失败和成功交替出现就是达不到连续 3 次失败的条件所以 ducklord 一直没触发重启。这个案例给我两个启发。第一心跳机制不能只看进程活着也不能只看事件循环能响应最好把心跳设计成带业务链路的探活——比如让心跳随机附带一个最小化 DB 查询能探出依赖是否健康。第二依赖调用必须明确区分同步和异步边界。我后来在 Microduck 的 worker 模板里强制要求凡是 IO 密集的依赖调用一律走异步驱动或者用asyncio.to_thread明确丢到线程池并设置上限绝不允许在事件循环线程里做阻塞调用。最后再分享一个小技巧遇到类似进程活着但服务死了的疑难问题先别急着重启strace -p挂上去看几秒绝大多数情况能直接暴露卡点。这个习惯帮我省了无数个排查之夜比任何监控面板都好用。
返回列表