33.4 多运行时

一句话

多运行时(multi-runtime)把分布式能力——服务调用、状态、发布订阅、外部绑定、工作流——从业务进程下沉到进程外的运行时(sidecar 或节点级代理),业务代码只写业务;它和 Service Mesh 不是替代关系:Mesh 管网络层,多运行时管应用能力。

两类能力,两个下沉层

层 管什么 典型实现 业务感知
网络层(Mesh) 连接、超时、重试、mTLS、路由、流量染色 Envoy/Istio、Linkerd、Cilium 完全无感(连 SDK 都不需要)
应用能力层(多运行时) 状态存取、发布订阅、服务调用、外部绑定、Actor、工作流 Dapr、Layotto、各类 Serverless 运行时 需要调用运行时 API(SaveState、Publish)
语言库(SDK) 上述两者 + 序列化 + 客户端负载均衡 gRPC 拦截器、各类治理库 强绑定语言与版本

🔑 判断某个能力该放哪一层,只问一句:它是“网络事实”还是“业务事实”? 超时/重试/mTLS 是网络事实(Mesh);状态存哪、消息发到哪个 topic、调用哪个外部系统是业务事实(多运行时或 SDK)。把业务事实塞进 Mesh,会让代理变成配置中心;把网络事实塞进业务代码,就是本节的上一章 30.4 服务治理 (注册、发现、熔断、限流) 讲的漂移来源。

哪些能力适合下沉

能力 适合下沉 理由
状态存取(KV/缓存) ✅ 接口稳定(get/set/delete),实现可换(Redis/ETCD/DB)
发布订阅 ✅ 只有 publish/subscribe 两个动作,中间件可替换
服务调用(含重试/超时) ✅ 与 Mesh 重叠,但多运行时还能做协议转换与序列化
外部绑定(对象存储、邮件、短信) ✅ 每家的 SDK 都不同,下沉后业务只认统一接口
密钥读取 ✅ 与业务无关,且有安全边界
工作流/编排 ⚠️ 状态机与业务强相关,下沉后调试变难,只在“编排逻辑很薄”时用
事务 / 领域规则 ❌ 业务语义,下沉后无法测试、无法演进

代码:业务只依赖接口,实现可整体替换

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
)

// ---------- 业务代码只依赖这个接口(4 个方法就是全部“分布式能力”) ----------
type Runtime interface {
	Invoke(target string, payload map[string]any) (map[string]any, error)
	GetState(key string) (string, error)
	SaveState(key, value string) error
	Publish(topic string, msg map[string]any) error
}

// ---------- 业务函数:没有 HTTP client、没有重试、没有 broker ----------
func placeOrder(rt Runtime, orderID string, qty int) (string, error) {
	res, err := rt.Invoke("inventory", map[string]any{"op": "reserve", "qty": qty})
	if err != nil {
		return "", err
	}
	if ok, _ := res["available"].(bool); !ok {
		return "", fmt.Errorf("库存不足")
	}
	if err := rt.SaveState("order:"+orderID, "placed"); err != nil {
		return "", err
	}
	if err := rt.Publish("order.placed", map[string]any{"orderId": orderID}); err != nil {
		return "", err
	}
	return "OK", nil
}

// ---------- 实现 A:进程内(本地开发、单机、单测) ----------
type inProcess struct{ state map[string]string }

func (r *inProcess) Invoke(target string, p map[string]any) (map[string]any, error) {
	return map[string]any{"available": true, "from": target}, nil
}
func (r *inProcess) GetState(k string) (string, error) { return r.state[k], nil }
func (r *inProcess) SaveState(k, v string) error     { r.state[k] = v; return nil }
func (r *inProcess) Publish(t string, m map[string]any) error {
	fmt.Printf("[local] publish %s %v\n", t, m)
	return nil
}

// ---------- 实现 B:HTTP sidecar(Dapr 式 API) ----------
type sidecarRuntime struct {
	base string
	c    *http.Client
}

func (r *sidecarRuntime) Invoke(target string, p map[string]any) (map[string]any, error) {
	b, _ := json.Marshal(p)
	resp, err := r.c.Post(r.base+"/v1.0/invoke/"+target+"/method/reserve", "application/json", bytes.NewReader(b))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	var out map[string]any
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, err
	}
	return out, nil
}

func (r *sidecarRuntime) GetState(k string) (string, error) {
	resp, err := r.c.Get(r.base + "/v1.0/state/" + k)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	return string(b), nil
}

func (r *sidecarRuntime) SaveState(k, v string) error {
	resp, err := r.c.Post(r.base+"/v1.0/state/"+k, "application/json",
		bytes.NewReader([]byte(strconv.Quote(v))))
	if err != nil {
		return err
	}
	resp.Body.Close()
	return nil
}

func (r *sidecarRuntime) Publish(t string, m map[string]any) error {
	b, _ := json.Marshal(m)
	resp, err := r.c.Post(r.base+"/v1.0/publish/"+t, "application/json", bytes.NewReader(b))
	if err != nil {
		return err
	}
	resp.Body.Close()
	return nil
}

// 一个极简的 sidecar:只负责把 API 翻译成日志
func newSidecar() *httptest.Server {
	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		body, _ := io.ReadAll(r.Body)
		fmt.Printf("[sidecar] %s %s %s\n", r.Method, r.URL.Path, strings.TrimSpace(string(body)))
		switch {
		case strings.HasPrefix(r.URL.Path, "/v1.0/invoke/"):
			json.NewEncoder(w).Encode(map[string]any{"available": true})
		case strings.HasPrefix(r.URL.Path, "/v1.0/state/"), strings.HasPrefix(r.URL.Path, "/v1.0/publish/"):
			w.WriteHeader(http.StatusNoContent)
		default:
			http.NotFound(w, r)
		}
	}))
}

func main() {
	fmt.Println("=== ① runtime = 进程内实现(本地开发/单测)===")
	r1 := &inProcess{state: map[string]string{}}
	out, err := placeOrder(r1, "1001", 2)
	fmt.Printf("placeOrder -> %s %v;状态 order:1001=%s\n\n", out, err, r1.state["order:1001"])

	fmt.Println("=== ② runtime = HTTP sidecar(同一份业务代码)===")
	sc := newSidecar()
	defer sc.Close()
	r2 := &sidecarRuntime{base: sc.URL, c: &http.Client{}}
	out, err = placeOrder(r2, "1001", 2)
	fmt.Printf("placeOrder -> %s %v\n\n", out, err)

	fmt.Println("两个 runtime 下 placeOrder 的函数体完全一致:切换成本 = main 里换一行构造")
}

本机实测(macOS,Apple M1,Go 1.27):

=== ① runtime = 进程内实现(本地开发/单测)===
[local] publish order.placed map[orderId:1001]
placeOrder -> OK <nil>;状态 order:1001=placed

=== ② runtime = HTTP sidecar(同一份业务代码)===
[sidecar] POST /v1.0/invoke/inventory/method/reserve {"op":"reserve","qty":2}
[sidecar] POST /v1.0/state/order:1001 "placed"
[sidecar] POST /v1.0/publish/order.placed {"orderId":"1001"}
placeOrder -> OK <nil>

两个 runtime 下 placeOrder 的函数体完全一致:切换成本 = main 里换一行构造

读数:

现象 说明
业务函数里没有 http.Client、没有 broker、没有重试 分布式能力全在 Runtime 接口后面;换实现不改业务
进程内实现能跑单测 不需要起 Redis/Kafka 就能测业务逻辑(53.6 测试替身)
sidecar 实现只多三个 HTTP 调用 换到真实 Dapr 就是把 main 里的构造换成 dapr.NewClient()
接口只有 6 个方法 能力清单固定,业务不会“顺手”长出一堆定制调用

🔑 多运行时的核心收益不是“少写代码”,而是把“分布式能力”变成可替换、可独立升级、可统一观测的一层。代价同样明确:多一次进程间调用、多一个要运维的组件、以及“业务看不到运行时在做什么”——排障要同时看业务日志与运行时日志。

落地:Dapr 的四个动作(未验证)

⚠️ 本节 Go 代码在本机跑通;下面的 Dapr 命令未在集群/本机验证(本机未安装 Dapr),版本与参数以官方文档为准。指名产品:Dapr(替代品 Layotto、各类框架内置运行时)。仓库工具清单记录 Dapr 版本为 1.18。

# 1) 指名 + 可抄:本地初始化(会拉取 Redis 等默认组件)
dapr init

# 2) 可抄:用一个自定义 state store 组件(Redis)
cat > statestore.yaml <<'YAML'
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""
YAML

# 3) 可验:启动业务进程 + sidecar,观察 sidecar 端口与调用
dapr run --app-id order --app-port 8080 --dapr-http-port 3500 -- go run .
dapr list
# 预期:输出中能看到 order 应用,dapr-http-port 3500

# 4) 可退:停掉 sidecar;把 Runtime 实现换回进程内(本节代码里就是换一行)
dapr stop --app-id order
步骤 判据 失败时看什么
初始化 dapr init 后本地起 Redis/Zipkin 容器 Docker 是否在跑、端口 6379 是否被占
组件生效 curl localhost:3500/v1.0/state/statestore/foo 能读 组件 YAML 的 name 是否与代码里一致
调用打通 POST localhost:3500/v1.0/invoke/inventory/method/reserve 返回 200 app-id 是否注册、目标服务是否在 dapr list 里
可回退 停 sidecar 后,业务用进程内实现仍能跑 业务是否偷偷依赖了 sidecar 注入的 header

反模式

反模式 后果
把业务事务放进运行时(如用运行时的“分布式事务”编排业务) 事务语义被黑盒化,出问题无法在业务代码里修
业务直接调 Dapr 的 HTTP API,而不用 SDK/接口 换运行时或升级 API 版本时全量改业务
运行时组件配置散落在每个服务仓库 配置漂移,且没人知道生产用的是哪个版本(48.1 配置中心)
同时上 Mesh + 多运行时 + SDK 三层治理 超时/重试叠加三层,故障时放大几十倍(35.5)
用多运行时替代“领域建模” 状态与消息有了,但聚合边界、不变量仍然没人设计(29.3)
忽略运行时的额外一跳 内部高频调用(每请求上万次)被多一次进程间转发放大

自检

Q1 一个团队已经上了 Istio,还想上 Dapr。两者的能力重叠在哪、不重叠在哪?该不该都上?

▸ 答案:重叠在“服务调用”这一块(重试、超时、mTLS、路由);不重叠在应用能力:状态存取、发布订阅、外部绑定、Actor/工作流。该不该都上取决于是否有“多语言 + 需要统一状态/消息抽象”的需求:如果只是 Go 服务、状态就是 Redis、消息就是 Kafka,Dapr 带来的收益很小,反而多一个组件;如果语言 ≥ 3 种、每个服务都要自己封装一套 Redis/Kafka/对象存储客户端,多运行时的收益才明显。都上时必须明确分工:Mesh 管连接与安全,多运行时管状态与消息,业务不重复配置重试。

Q2 为什么本节把 Runtime 设计成接口而不是直接调 Dapr SDK?

▸ 答案:为了可测试与可替换。接口让单测可以用进程内实现(不起 Redis/Kafka),也让“换运行时”变成一次构造替换而不是全量改业务;直接调 SDK 会把业务绑死在某个运行时的 API 版本上(30.6 服务版本与兼容 的版本矩阵问题)。代价是多一层薄封装(6 个方法),这层封装的成本远低于被锁定的成本。

Q3 多运行时把“状态”下沉之后,聚合边界(29.3)还需要设计吗?

▸ 答案:更需要。运行时只提供“按 key 存取”的能力,它不知道哪些 key 必须一起原子更新。如果业务把“订单 + 订单行”拆成两个 key 分别 SaveState,运行时不会替你做事务,中途失败就留下半个订单。正确做法仍是先在领域层定聚合(哪些数据必须同一事务),再决定这个聚合用一个 key 还是一个带版本的记录——运行时解决“存哪里”,不解决“什么必须一起改”。

Q4 已有多运行时,为什么还要关心“多一跳”的成本?

▸ 答案:因为多运行时的每次 Invoke/SaveState 都是一次进程间调用(本地回环或网络)。按 30.1 的实测,本机 loopback 已是几万倍于函数调用;如果一个业务请求里有 50 次细粒度状态读写,就会变成 50 次额外跳转,延迟与 CPU 都被放大。做法是批量与粗粒度:一次读多个 key、把同一聚合的字段放在一个记录里、把循环里的 SaveState 提到循环外。

关联