我先说结论:如果你的服务拆分只发生在一台机器内部,用 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 + HTTP | Unix socket + JSON-RPC |
|---|---|---|
| 连接建立 | 三次握手 + HTTP 握手 + 可能的 TLS 协商 | 内核直接绑定 socket 文件,几乎没有握手成本 |
| 数据路径 | 内存、协议栈、路由、环回接口逐一处理 | 内核套接字直接传递,数据拷贝次数更少 |
| 端口管理 | 需要分配端口、防冲突、配置防火墙 | 没有端口概念,用 socket 文件标识服务 |
| 访问控制 | 防火墙、认证、令牌体系 | 直接复用文件系统权限,chmod/chown 即可 |
| 网络暴露面 | 本机 IP 上可被扫描和探测 | 只有文件系统可访问,天然不被网络探测 |
| 调试方式 | curl、浏览器、Postman | socat、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,就是军团指挥官),和一堆业务 daemon(worker)。总管负责干三件事:读取配置、拉起 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_LEVEL=info 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(f"message too large: {length}") payload = await reader.readexactly(length) return json.loads(payload) async def write_rpc_message(writer, message): payload = json.dumps(message, ensure_ascii=False, 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()注册表本身就是一个 dict,key 是方法名,value 是协程函数;收到请求后查表,找不到就返回 JSON-RPC 标准错误码 -32601(Method 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", {}, timeout=2.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 依赖服务 B,A 处理请求时协程级调用 B,然后聚合结果返回。因为走的都是 Unix socket 本机 IPC,这种嵌套调用的端到端延迟通常也就在几百微秒量级,完全可接受。如果哪天链路深了(A 调 B、B 调 C、C 调 D),Microduck 会透传一个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) | 315 | 560 | 约 9,500 |
| Unix socket + JSON-RPC (Python asyncio) | 175 | 300 | 约 17,600 |
| HTTP/1.1 + JSON (aiohttp) | 520 | 890 | 约 6,100 |
| Unix socket + JSON-RPC (Go) | 42 | 78 | 约 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挂上去看几秒,绝大多数情况能直接暴露卡点。这个习惯帮我省了无数个排查之夜,比任何监控面板都好用。