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 提到循环外。