一个租户的请求从网关进入,经过鉴权、编排引擎、数据服务,最终落到下游第三方 API——中间跨越了十几条微服务调用。当这位租户反馈"响应变慢",你打开监控面板,看到的却是一堆散落在各服务日志里的碎片化 trace ID,根本拼不出一条完整链路。这就是大多数云原生 SaaS 平台在可观测性上的真实困境。
为什么多租户场景让追踪变得更难
单租户系统里,一个 trace_id 就能串联整条调用链。多租户平台引入了三个额外维度:
租户隔离——不同租户的请求可能走不同路由规则、不同数据分片,甚至不同版本的服务实例。同一接口、同一时间段内,租户 A 的请求可能命中缓存而租户 B 的请求穿透到数据库,两条链路的表现截然不同。
共享基础设施——网关、消息队列、共享数据服务往往被所有租户共用。一个共享组件的延迟抖动,影响的是一批租户,但传统追踪系统只按服务维度聚合,看不到"哪些租户受影响"。
合规与数据边界——某些行业要求租户的链路数据不能混存到同一存储后端,或者不同租户的 trace 数据保留时长不同。这直接影响追踪系统的存储架构设计。
链路上下文的传播设计
端到端追踪的核心是让 trace context 从入口一直传播到出口,不丢失、不混淆。在多租户场景下,传播的上下文需要比标准 W3C traceparent 多一个字段:租户标识。
一个实用的传播头设计:
x-trace-id: <trace-id>
x-tenant-id: <tenant-id>
x-span-id: <span-id>
或者,如果你已经采用 W3C Trace Context 标准,可以把租户标识放进 tracestate:
traceparent: 00-<trace-id>-<span-id>-01
tracestate: tenant=acme Corp,slo=gold
tracestate 的好处是兼容现有 OpenTelemetry SDK 的传播机制,不需要自定义 HTTP 头解析逻辑。租户标识跟着标准传播路径走,每个服务自动继承。
关键设计决策点:
| 决策 | 选项 | 建议 |
|---|---|---|
| 租户 ID 传播方式 | 自定义 HTTP 头 vs tracestate | 优先 tracestate,减少 SDK 改动 |
| 租户 ID 来源 | 网关提取(JWT / subdomain / header) | 网关统一提取,下游服务不再重复解析 |
| 上下文丢失时的行为 | 创建新 trace vs 拒绝请求 | 生产环境建议记录告警后创建新 trace,不阻断业务 |
用 OpenTelemetry 实现租户感知的链路追踪
下面给出一个最小可运行的示例:在 Python 微服务中,通过 OpenTelemetry 自动传播 trace context,并在网关层注入租户标识到 tracestate。
网关层:提取租户 ID 并注入 tracestate
假设网关基于 FastAPI,从 JWT 中提取租户 ID:
# gateway/main.py
from fastapi import FastAPI, Request
from opentelemetry import trace, context, propagate
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
import jwt
app = FastAPI()
# 初始化 OpenTelemetry
resource = Resource.create({"service.name": "saas-gateway", "service.version": "1.0"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4317"))
)
trace.set_tracer_provider(provider)
FastAPIInstrumentor.instrument_app(app)
TENANT_JWT_SECRET = "change-me-in-production"
@app.middleware("http")
async def inject_tenant_tracestate(request: Request, call_next):
# 从 Authorization 头提取租户 ID
auth_header = request.headers.get("authorization", "")
tenant_id = "anonymous"
if auth_header.startswith("Bearer "):
try:
payload = jwt.decode(
auth_header[7:], TENANT_JWT_SECRET, algorithms=["HS256"]
)
tenant_id = payload.get("tenant_id", "anonymous")
except jwt.InvalidTokenError:
pass
# 获取当前 span,注入租户标识到 tracestate
current_span = trace.get_current_span()
if current_span.is_recording():
current_span.set_attribute("saas.tenant.id", tenant_id)
# 写入 tracestate,下游自动传播
current_span.set_attribute("saas.tenant.plan", payload.get("plan", "free"))
response = await call_next(request)
return response
@app.get("/api/orders")
async def get_orders(request: Request):
tracer = trace.get_tracer("gateway")
with tracer.start_as_current_span("gateway.get_orders") as span:
span.set_attribute("http.route", "/api/orders")
# 模拟调用下游编排服务
return {"orders": ["order-1", "order-2"], "tenant_debug": "check tracestate"}
下游服务:自动继承租户上下文
下游服务不需要额外解析租户 ID——OpenTelemetry SDK 自动传播 traceparent 和 tracestate:
# order-service/main.py
from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
import httpx
app = FastAPI()
resource = Resource.create({"service.name": "order-service"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4317"))
)
trace.set_tracer_provider(provider)
FastAPIInstrumentor.instrument_app(app)
HTTPXClientInstrumentor.instrument()
@app.get("/orders")
async def list_orders():
tracer = trace.get_tracer("order-service")
with tracer.start_as_current_span("order-service.list_orders") as span:
# tracestate 中的 tenant 信息已被 SDK 自动传播
# 可以从 span attributes 读取(如果上游设置了 attribute)
# 也可以从 propagator 提取的 context 中读取
span.set_attribute("operation.type", "list")
# 调用下游数据服务,trace context 自动传播
async with httpx.AsyncClient() as client:
resp = await client.get("http://data-service:8001/data")
return resp.json()
运行示例的最小依赖
# 安装依赖
pip install fastapi uvicorn opentelemetry-api opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-grpc opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-httpx httpx PyJWT
# 启动 OTel Collector(用 Docker 最简单)
docker run -d --name otel-collector \
-p 4317:4317 -p 4318:4318 \
-p 8889:8889 \
otel/opentelemetry-collector-contrib:0.96.0
# 启动网关
uvicorn gateway.main:app --port 8000 --host 0.0.0.0
# 启动下游服务
uvicorn order_service.main:app --port 8002 --host 0.0.0.0
# 发送带 JWT 的请求(tenant_id=acme)
# 先生成一个测试 token:
python -c "import jwt; print(jwt.encode({'tenant_id':'acme','plan':'gold'}, 'change-me-in-production', algorithm='HS256'))"
# 用生成的 token 发请求:
curl -H "Authorization: Bearer <上一步输出的token>" http://localhost:8000/api/orders
在 Jaeger 或 Grafana 中查看这条 trace,你会看到 saas.tenant.id=acme 作为 span attribute 出现在整条链路的每个 span 上。
租户感知的查询与聚合
有了租户标识作为 span attribute,下一步是让追踪后端支持按租户过滤和聚合。OpenTelemetry Collector 的配置可以做到这一点:
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
# 按 tenant ID 添加 resource attribute,便于后端过滤
attributes/tenant:
actions:
- key: saas.tenant.id
from_attribute: saas.tenant.id
action: upsert
- key: saas.tenant.plan
from_attribute: saas.tenant.plan
action: upsert
# 可选:按租户路由到不同存储后端
groupbyattrs/tenant:
group_by:
- saas.tenant.id
exporters:
otlp/jaeger:
endpoint: jaeger:4317
# 合规场景:高敏感租户数据导出到独立后端
otlp/highsec:
endpoint: highsec-jaeger:4317
service:
pipelines:
traces/default:
receivers: [otlp]
processors: [attributes/tenant]
exporters: [otlp/jaeger]
# 高安全等级租户走独立管道
traces/highsec:
receivers: [otlp]
processors: [groupbyattrs/tenant, attributes/tenant]
exporters: [otlp/highsec]
在 Jaeger UI 中查询时,用 saas.tenant.id=acme 作为 tag filter,就能只看特定租户的链路。Grafana Tempo 同样支持通过 TraceQL 按 attribute 过滤:
{ .saas.tenant.id = "acme" && .http.route = "/api/orders" }
落地时的几个现实考量
网关是唯一可信的租户注入点。 下游服务不应该自行解析 JWT 来确定租户——这既浪费又容易出错。网关解析一次,写入 tracestate 和 span attribute,下游只读。
异步消息的传播不能断。 如果请求链路经过 Kafka 或 RabbitMQ,消息的 header 里必须携带 traceparent 和 tracestate。OpenTelemetry 的 Kafka instrumentation 已经支持这一点,但你需要确认消息生产者侧的 SDK 版本足够新。
采样策略要租户感知。 全量采集成本太高。一个务实的做法:对 gold/silver 租户全量采集,free 租户按比例采样。在 Collector 中用 Tail Sampling Processor 实现:
processors:
tail_sampling:
decision_wait: 10s
policies:
- name: gold-tenant-full
type: and
and:
policies:
- name: is-gold
type: string_attribute
string_attribute:
key: saas.tenant.plan
values: ["gold"]
- name: always-sample
type: always_sample
- name: free-tenant-10pct
type: and
and:
policies:
- name: is-free
type: string_attribute
string_attribute:
key: saas.tenant.plan
values: ["free"]
- name: probabilistic-10
type: probabilistic
probabilistic:
sampling_percentage: 10
跨服务边界的 ID 一致性。 如果平台中有第三方 SaaS 集成(比如调用 Stripe API),你无法控制对方的 trace 传播。此时在出口处记录一个 span,把外部服务的请求 ID 作为 saas.external.request_id attribute 存下来,事后可以手动关联。
上手清单
- 确认网关提取租户 ID 的方式——JWT claim、子域名、自定义 header,选一种,统一入口。
- 在网关层注入
tracestate和 span attribute——改动集中在一个点,下游零改动即可继承。 - 升级所有服务的 OpenTelemetry SDK 到支持 tracestate 传播的版本——0.40+ 基本都支持。
- Collector 配置 attribute processor——确保
saas.tenant.id被提升为可查询的 tag。 - 配置按租户的采样策略——从全量采集开始,观察一周数据量,再逐步收紧。
- 异步链路补齐——检查 Kafka/RabbitMQ 的 message header 传播,确保不断链。
端到端追踪不是一次性工程,而是随着服务数量和租户规模增长持续演进的系统。先把网关注入和 Collector attribute 处理跑通,你就有了按租户排查问题的最小能力。之后再逐步加采样策略、合规隔离、跨边界关联,每一步都有明确的可观测性收益。