Jaeger、Tempo 与 Zipkin Trace 后端工具手册
接口已经超时,为什么三个页面都搜不到
订单接口刚刚耗时 8 秒,应用日志却只有一句“调用下游超时”。团队打开 Trace 页面,Jaeger 的 Service 下拉框为空,Tempo 按属性搜索没有结果,旧 Zipkin 又只能看到昨天的数据。此时先不要轮流刷新 UI:一条调用链从业务线程到页面,中间至少经过埋点、采样、传播、导出、接收、持久化和查询七道关口,任一处失败都会表现成“搜不到”。
Trace 是一次端到端调用,Span 是其中一个有开始时间、持续时间和属性的操作。父子 Span 通过相同的 trace ID 和各自的 span ID 组成因果关系;跨线程、HTTP 或消息队列时,传播头只携带上下文,完整 Span 通常在操作结束后异步上报。上游没把 trace ID 传给下游,页面会出现两条孤立 Trace;上报失败则不会反向拖慢业务请求,这也解释了为什么接口报错和“观测平台一切正常”可以同时发生。
Jaeger、Tempo 和 Zipkin 都能接收和查询 Trace,但它们不是三个换皮 UI。Jaeger 把查询能力与可插拔搜索存储结合;Tempo 把长期数据做成对象存储中的列式块,TraceQL 查询会扫描候选块;Zipkin 用简洁的 v2 数据模型、HTTP API 和可插拔数据库保留了大量历史接入。先用同一条 Span 跑通三套后端,差异才会从宣传词变成证据。
第一次操作前准备 Docker 24+、Docker Compose v2、可用的 16686、3200、9411、4317 和 4318 端口,以及能执行 curl 的终端。公司网络若需要代理,先确认 Docker daemon 能拉取镜像;所有镜像在团队仓库中应锁定 digest。下面的固定 trace ID 只用于本机实验,不能把真实用户标识、令牌、SQL 参数或请求正文照搬进 Span。
用一条 Zipkin v2 Span 建立共同证据
Zipkin v2 的 HTTP 写入入口是 POST /api/v2/spans,负载是 Span 数组。下面的脚本用当前微秒时间发送一个 checkout-api 服务的入口 Span。16 或 32 位十六进制 trace ID、16 位 span ID、微秒时间戳和正数 duration 是排查格式错误时最先核对的字段。
TRACE_LAB_DIR=$(mktemp -d)
TRACE_ID=463ac35c9f6413ad48485a3953bb6124
SPAN_ID=a2fb4a1d1a96d312
NOW_US=$(($(date +%s) * 1000000))
cat > "$TRACE_LAB_DIR/span.json" <<JSON
[{
"traceId": "$TRACE_ID",
"id": "$SPAN_ID",
"kind": "SERVER",
"name": "POST /checkout",
"timestamp": $NOW_US,
"duration": 820000,
"localEndpoint": {"serviceName": "checkout-api"},
"tags": {
"http.method": "POST",
"http.status_code": "504",
"error": "gateway timeout",
"deployment.environment": "trace-lab"
}
}]
JSON这个负载适合验证 Zipkin 兼容入口,不代表应用应继续绑定 Zipkin Reporter。新项目优先使用 OpenTelemetry SDK,通过 OTLP/gRPC 或 OTLP/HTTP 发给 Collector。OTLP 是资源、Scope、Span、Event、Link 和状态的完整遥测协议;Zipkin v2 JSON 是另一种线格式。端口开放不代表协议可互换:把 OTLP protobuf 发到 /api/v2/spans,或把 Zipkin JSON 发到 OTLP 4318,都会得到 400、404、415 或连接重置。
Jaeger:从 all-in-one 到搜索存储
先证明接收和查询属于同一实例
Jaeger v2 使用 OpenTelemetry Collector 的组件模型,v1 的 SPAN_STORAGE_TYPE、COLLECTOR_OTLP_ENABLED 等旧 flags 不能当作 v2 配置继续复制。当前官方快速开始用一个进程组合 Collector、Query 和内存存储:
docker run -d --name jaeger-lab \
-p 127.0.0.1:16686:16686 \
-p 127.0.0.1:4317:4317 \
-p 127.0.0.1:4318:4318 \
-p 127.0.0.1:9411:9411 \
cr.jaegertracing.io/jaegertracing/jaeger:2.19.0
curl -fsS http://localhost:16686/ >/dev/null
curl -fsS -X POST http://localhost:9411/api/v2/spans \
-H 'Content-Type: application/json' \
--data-binary @"$TRACE_LAB_DIR/span.json"
curl -fsS "http://localhost:16686/api/traces/$TRACE_ID"写入成功通常返回 202,按 ID 查询应返回包含 checkout-api 和 POST /checkout 的 JSON。UI 打开 http://localhost:16686 后也能按 Service 搜索。若 POST 成功而查询 404,先执行 docker logs jaeger-lab,再确认写入的 9411 与查询的 16686 映射到同一个容器;查询刚写入的数据还要允许短暂的异步处理时间。
现在做反向实验:
docker restart jaeger-lab
curl -i "http://localhost:16686/api/traces/$TRACE_ID"重启后查不到是预期结果,它证明 all-in-one 默认使用瞬时内存,不是“已经部署了 Jaeger 数据库”。这一步比看到 UI 更重要:接收证据证明协议通,查询证据证明读路径通,重启证据才说明持久化边界。
v2 配置怎样把写路径和读路径接到同一存储
Jaeger v2 的 YAML 同时描述 receivers、processors、exporters、service pipelines 和 extensions。关键对象不是某个神秘环境变量,而是三次引用必须一致:
extensions:
jaeger_storage:
backends:
trace_store:
memory:
max_traces: 100000
jaeger_query:
storage:
traces: trace_store
http:
endpoint: 0.0.0.0:16686
exporters:
jaeger_storage_exporter:
trace_storage: trace_store
queue:
num_consumers: 10
queue_size: 1000
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
service:
extensions: [jaeger_storage, jaeger_query]
pipelines:
traces:
receivers: [otlp]
exporters: [jaeger_storage_exporter]jaeger_storage.backends.trace_store 定义存储,exporter 把接收的 Span 写进去,jaeger_query.storage.traces 从同一逻辑名读取。最典型的“写入无报错、UI 无数据”就是 exporter 与 query 指向了两个 backend。正式配置应从对应版本仓库的 config-opensearch.yaml 或 config-elasticsearch.yaml 起步,再替换 endpoint、TLS 和 Secret;不要凭 v1 博客拼字段。Jaeger v2 配置说明给出了这一组件关系。
生产搜索通常落到 OpenSearch、Elasticsearch 或 Cassandra。Jaeger 2.19 将它们列为主要分布式后端,并建议大规模新部署优先评估 OpenSearch;OpenSearch 支持矩阵要和 Jaeger 版本一起核对。搜索存储会为 service、operation、时间和配置为可检索的 tag 建立索引,因此属性越多、基数越高,写放大、segment 合并、heap 和磁盘成本越高。trace ID 精确查询与“过去两小时所有错误订单”不是同一种负载,容量测试必须同时压写入和真实搜索。
Jaeger 的 primary storage 适合较短保留期,archive storage 可由用户手动保存少量事故 Trace,二者甚至可以使用不同后端。Archive 不是备份:它不会自动覆盖所有 Trace,也不能替代搜索集群快照、恢复演练和索引生命周期。
采样不是存储满了之后才补的开关
Head sampling 在 Trace 开始时决定是否记录,成本低,但还不知道最终是否慢或失败;tail sampling 必须先让 Collector 收齐候选 Trace 的 Span 再判断,能保留错误和慢请求,却把网络、内存和处理压力推到 Collector。这里有一条不可逆边界:SDK 或上游 Collector 已被 head sampling 丢弃的 Trace 不会继续上报,后置 tail policy 根本看不到它,也就无法把它“捞回来”。Jaeger v2 还支持远程采样和 adaptive sampling。远程采样策略缺失时的默认概率可能与你期望完全不同,因此不能只看配置文件,要用固定请求数比较产生、接收、拒绝和落库 Trace 数。
若目标是让 tail policy 识别错误和长尾,SDK 到执行 tail sampling 的 Collector 这一段必须保持全量或采用不会提前丢弃目标流量的明确策略,再由有状态 Collector 按 trace ID 汇聚后取舍。若容量迫使团队先做低比例 head sampling,就应把它理解成“只对已入选样本继续做 tail 过滤”,不能再承诺保住所有错误 Trace。健康检查可在入口明确丢弃,事故调查 Trace 可在查询后手动归档;任何策略变更都要验证父子 Span 是否完整,只保留服务端 Span 而丢掉客户端 Span,时延归因会失真。
Tempo:对象存储中的 Trace 块
单体模式也要把存储配置写出来
Tempo 没有独立查询 UI,通常由 Grafana 作为数据源访问 Tempo HTTP API。为了先证明后端本身,创建 $TRACE_LAB_DIR/tempo.yaml:
target: all
stream_over_http_enabled: true
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
zipkin:
endpoint: 0.0.0.0:9411
storage:
trace:
backend: local
wal:
path: /var/tempo/wal
local:
path: /var/tempo/blocks
live_store:
flush_check_period: 1s
max_trace_idle: 2s
max_trace_live: 5s
max_block_duration: 5s
compactor:
compaction:
block_retention: 24h然后启动单体 Tempo,并把数据目录放进命名卷:
docker volume create tempo-lab-data
docker run -d --name tempo-lab \
-p 127.0.0.1:3200:3200 \
-p 127.0.0.1:14317:4317 \
-p 127.0.0.1:14318:4318 \
-p 127.0.0.1:19411:9411 \
-v "$TRACE_LAB_DIR/tempo.yaml:/etc/tempo.yaml:ro" \
-v tempo-lab-data:/var/tempo \
grafana/tempo:3.0.2 -config.file=/etc/tempo.yaml
curl -fsS http://localhost:3200/ready
curl -fsS -X POST http://localhost:19411/api/v2/spans \
-H 'Content-Type: application/json' \
--data-binary @"$TRACE_LAB_DIR/span.json"
curl -fsS "http://localhost:3200/api/traces/$TRACE_ID"ready 返回 200 只说明进程能服务,第一次按 trace ID 取回也可能命中 live-store 或 WAL,都不能证明长期块已经写成。上面的短时间参数只为让本机实验尽快切块,不是生产建议。先记录刷盘计数,调用 /flush 把内存 Trace 推入 WAL,再等待完整块写入 local backend:
FLUSHED_BEFORE=$(curl -fsS http://localhost:3200/metrics |
awk '$1 == "tempo_live_store_local_blocks_flushed_total" {print int($2)}')
: "${FLUSHED_BEFORE:=0}"
curl -fsS -X POST http://localhost:3200/flush >/dev/null
for i in $(seq 1 30); do
FLUSHED_AFTER=$(curl -fsS http://localhost:3200/metrics |
awk '$1 == "tempo_live_store_local_blocks_flushed_total" {print int($2)}')
QUEUE=$(curl -fsS http://localhost:3200/metrics |
awk '$1 == "tempo_live_store_complete_queue_length" {print int($2)}')
if [ "${FLUSHED_AFTER:-0}" -gt "$FLUSHED_BEFORE" ] && [ "${QUEUE:-1}" -eq 0 ]; then
break
fi
sleep 1
done
test "${FLUSHED_AFTER:-0}" -gt "$FLUSHED_BEFORE"
test "${QUEUE:-1}" -eq 0
curl -fsS http://localhost:3200/metrics |
awk '$1 == "tempo_live_store_local_failed_flushes_total" && $2 != 0 {bad=1} END {exit bad}'
docker logs --since=2m tempo-lab 2>&1 |
grep -E 'queueing wal block for completion|completing WAL block'
if docker logs --since=2m tempo-lab 2>&1 | grep -q 'failed to flush complete block'; then
exit 1
fi
docker exec tempo-lab sh -ec '
find /var/tempo/blocks -type f -name meta.json -print -quit | grep -q .
find /var/tempo/blocks -type f -name data.parquet -print -quit | grep -q .
'
docker restart tempo-lab
until curl -fsS http://localhost:3200/ready >/dev/null; do sleep 1; done
curl -fsS "http://localhost:3200/api/traces/$TRACE_ID"刷盘计数增长且完成队列归零,说明后台完成了一次 local backend 写入;日志给出 WAL 块进入完成流程的 block ID;同一数据卷中的 meta.json 与 data.parquet 则证明块已经对读路径可见。三组证据成立后重启仍能取回,才证明命名卷和本地块生效。若近期可查、重启后丢失,检查 /var/tempo/blocks 是否实际挂载、tempo_live_store_local_failed_flushes_total 是否增长、容器日志是否有 permission denied,以及配置文件是不是另一个路径。单机本地盘只适合开发和小规模验证;分布式组件看不到同一块本地盘时,历史查询天然不完整。
为什么 Tempo 不需要给每个 tag 建传统倒排索引
Tempo 3.0 单体模式把 Span 直接交给 live-store,近期 Trace 在内存和本地 WAL 中可查,随后切成 Parquet 块写入长期存储。微服务模式则是 distributor 按 trace ID 分片并写入 Kafka 兼容系统;live-store 独立消费并服务近期查询,block-builder 消费后构建 Parquet 块上传对象存储。query-frontend 拆分查询任务,querier 同时查近期 live-store 与历史对象存储,backend scheduler/worker 负责压实和保留。
长期存储支持 S3/兼容 S3、GCS 和 Azure Blob;本地文件系统只用于开发。对象布局以 tenant 和 block 分隔,块内有 meta.json、data.parquet、Bloom filter 和 index。TraceQL 不是“没有索引就零成本”:按 trace ID 能借助块元数据与 Bloom filter 缩小范围,任意属性搜索仍可能读取大量 Parquet 页。把高频筛选属性提升为专用列、控制查询时间窗、缓存页脚和 Bloom 数据,都会改变对象存储 GET、LIST、出网和 CPU 成本。
因此 Tempo 的成本公式至少包括:
每日写入字节 ≈ 请求数 × 采样率 × 每条 Trace 的 Span 数 × 平均 Span 字节
长期存储 ≈ 每日写入字节 × 保留天数 × 压实系数
查询成本 ≈ 候选块数 × 每块读取字节 + 对象存储请求与出网这只是估算起点。超大 Trace、长字符串属性、metrics-generator 派生的高基数时序、Kafka 保留和 block-builder scratch disk 都要单独测量。官方Tempo 架构与对象存储说明明确区分了近期和历史读路径。
多租户头不是身份认证
开启 multitenancy_enabled: true 后,Tempo 要求写入和查询都携带 X-Scope-OrgID,块也按 tenant 前缀隔离。先做反向实验:
curl -i "http://localhost:3200/api/traces/$TRACE_ID"
curl -i -H 'X-Scope-OrgID: team-b' \
"http://localhost:3200/api/traces/$TRACE_ID"在已启用多租户的环境,缺头应被拒绝,错误 tenant 应查不到 team-a 数据。但这个 header 可以被客户端伪造,Tempo 本身不会验证“你是谁”。生产入口必须由认证网关根据登录身份或工作负载身份覆盖该头,丢弃外部传入值;Collector 的写凭证和 Grafana 数据源的读凭证分开。每租户 overrides 可限制写入速率、单 Trace 大小、活跃时序和保留期,但修改时要注意未显式保留的字段可能变成零值。Tempo 多租户配置给出了这一行为。
Zipkin:把兼容性做成可退役的入口
最小服务和 API 证据
docker run -d --name zipkin-lab -p 127.0.0.1:29411:9411 openzipkin/zipkin:3.6.1
curl -fsS http://localhost:29411/health
curl -fsS -X POST http://localhost:29411/api/v2/spans \
-H 'Content-Type: application/json' \
--data-binary @"$TRACE_LAB_DIR/span.json"
curl -fsS "http://localhost:29411/api/v2/trace/$TRACE_ID"查询结果应是包含固定 trace ID 的 Span 数组,页面位于 http://localhost:29411。若返回 InvalidApiVersion、JSON 解析错误或 415,依次核对路径是不是 /api/v2/spans、Content-Type、负载最外层数组、ID 长度和 timestamp 单位。把 OTLP 直接 POST 到这个入口不会自动转换,应用应发到 OpenTelemetry Collector,再由 zipkin exporter 转为 Zipkin v2。
停止并重新创建容器后再次查询:
docker rm -f zipkin-lab
docker run -d --name zipkin-lab -p 127.0.0.1:29411:9411 openzipkin/zipkin:3.6.1
curl -i "http://localhost:29411/api/v2/trace/$TRACE_ID"默认内存存储会丢数据。Zipkin 的 Collector 负责校验和写入,Storage 可插拔,Query 暴露 JSON API,UI 消费 Query。官方架构列出的原生存储包括 Cassandra、Elasticsearch 和 MySQL;选型不能只看“支持”二字:
Cassandra 适合高写入与按主键访问,但集群修复、压实、磁盘和数据模型治理成本高。Elasticsearch 提供更强的条件搜索,代价是索引、heap、分片和版本兼容。MySQL 容易被已有团队接管,适合规模较小、采样较低的场景,容量上升后索引写放大和查询竞争会迅速暴露。
Zipkin UI 没有内置认证。9411 同时承载写入和查询时,至少在代理层按路径、身份和方法分权;不能因为端口位于内网就让所有应用拥有查询历史 Trace 的能力。数据库账号只授予 Zipkin 所需 schema,初始化账号与运行账号分离,密码放入 Secret,不写进 Compose。
B3 与 W3C Trace Context 的迁移陷阱
老 Zipkin 链路常传播 B3 单头或多头,新 OTel 链路常用 W3C traceparent/tracestate。后端能接收 Zipkin Span,不等于服务间传播头已经统一。迁移期间若入口同时提取两种格式,必须规定冲突时的优先级;若注入两套头,要验证下游不会创建两个根 Span。最可靠的判断依据不是“两个页面都有数据”,而是同一个业务请求从入口到数据库保持一个 trace ID,父子关系与错误状态一致。
把项目接入从后端选择中解耦
应用侧只认识 OTLP Collector:
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
zipkin/legacy:
endpoint: http://zipkin:9411/api/v2/spans
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp/jaeger]开发环境可以临时切换 exporter;迁移时可短期并行导出到新旧后端,但双写会加倍出口带宽和队列压力。Collector 配置应由平台仓库管理,应用仓库只固定 service.name、环境、版本和 OTLP endpoint。实例 ID 可以变化,service name 和 endpoint 名不能带 Pod UID、用户 ID 或完整 URL,否则搜索维度和指标基数会爆炸。
接入时使用一条确定的业务链:入口 Span、一次下游 HTTP、一次数据库调用、一个错误事件。记录 trace ID 后分别核对:
Span 数与父子关系一致,时间轴没有明显负时长或跨机时钟漂移。错误状态落在真正失败的 Span,而不是所有祖先都被机械标红。重启 Collector 时业务请求不被阻塞,队列满后有明确 dropped/rejected 指标。
敏感字段已在 Collector 删除或哈希,后端搜索不到原值。超过近期窗口后仍能从持久层查到,超过保留期后确实被清理。
从“搜不到”反推故障层
Service 列表为空
先查应用是否创建 Span、采样决策是否为 record/export,再查 Collector receiver 的 accepted 与 refused,最后看 exporter send_failed。若三个后端都空,问题大概率在共同的 SDK、传播或 Collector;若只有一个空,沿该 exporter 的 endpoint、TLS、认证和协议排查。不要先重建存储。
按 trace ID 能查,按属性搜不到
这说明写入和精确读取基本正常,问题转向索引或查询。Jaeger/OpenSearch 检查 tag 是否被索引、索引刷新与时间范围;Tempo 检查 TraceQL 字段作用域、候选块和查询限制;Zipkin 检查该 tag 是否进入 v2 Span 以及存储实现支持的搜索条件。动态 URL、订单号这类高基数字段即使能搜,也可能不值得索引。
近期能查,历史不能查
Jaeger 查搜索集群健康、索引生命周期、读写 backend 是否一致;Tempo 查 block-builder/live-store 消费进度、对象存储权限、blocklist 与 compaction;Zipkin 查数据库持久化配置和 TTL。先定位“最后一个仍可查的时间点”,再对照队列积压、块上传、索引创建和凭证变更,比盯着 UI 更快。
出现零散根 Span
这通常不是后端存储问题。检查 W3C/B3 提取与注入、异步线程上下文、消息属性是否透传,以及代理是否重写 header。用固定 trace ID 对照入口、客户端、服务端 Span;如果服务端生成了新 trace ID,修传播,不要调采样。
容量、权限与敏感数据一起做预算
采样率只是第一个旋钮。Span 数、属性字节、事件数量、保留天数、索引字段、对象存储 API、查询并发和副本共同决定账单。团队先以一小时真实镜像流量测量 received_spans、平均/分位 Span 大小、每 Trace Span 数和查询模式,再外推容量;演示用“每秒一万条”不能当生产阈值。
Trace 常携带 URL、SQL、消息 key、异常栈、用户或设备标识。治理顺序应是源头不采、Collector 处理器删除/哈希、后端限制可检索字段、查询入口按角色授权、导出和分享留审计。Bearer Token、Cookie、Authorization、完整请求正文和数据库参数默认禁止进入 Span。多租户只是数据分区,认证、授权和审计仍由网关与身份系统完成。
生产网络至少分三面:应用/Collector 到接收面的写入流量,UI/自动化到查询面的读取流量,后端到 OpenSearch、Kafka 或对象存储的数据流量。三面使用不同身份和最小权限。TLS 证书轮换要验证长连接重建;对象存储凭证更新要同时观察上传和历史查询,避免“新数据安全、旧数据不可读”。
迁移、回滚与退役
后端迁移不应从修改所有应用 endpoint 开始。先让 Collector 双写一小部分确定样本,在新后端比较接收数、完整 Trace 比例、查询延迟、历史可见时间和单位成本;再逐步提高新后端流量。新系统达到保留期并通过故障演练后,停止旧写入但保留旧查询;等旧数据自然过期或完成合规归档,再撤销查询入口和凭证。
Jaeger v1 到 v2 是配置模型迁移,先用 v2 读取独立测试存储或双写,禁止把 v1 flags 原样搬入。Tempo 2.x 到 3.0 涉及写路径、Kafka、live-store 与 block-builder,回滚前必须确认对象块格式、队列 offset 和旧组件是否仍兼容。Zipkin 退役时保留 B3 提取窗口,但尽快让应用导出回到 OTLP;否则旧 Reporter 会成为最后一个无法下线的依赖。
“回滚”通常是把 Collector exporter 切回旧后端,不是删除新对象桶或索引。新旧存储都要设置只读保留窗口,迁移脚本和凭证在窗口结束后再撤销。对象存储删除、搜索索引清理和数据库 drop 都是独立审批动作。
结束本机实验,不留下匿名入口和数据卷
三个后端都没有在实验中配置认证,因此即使只绑定回环地址,也应在观察完结果后停止并删除。Tempo 的命名卷不会随容器删除而自动消失,要单独删除;临时目录只包含本次生成的 Span 和 Tempo 配置,确认变量非空后再清理:
docker rm -f jaeger-lab tempo-lab zipkin-lab
docker volume rm tempo-lab-data
if [ -n "${TRACE_LAB_DIR:-}" ] && [ -d "$TRACE_LAB_DIR" ]; then
rm -rf -- "$TRACE_LAB_DIR"
fi若某个容器本来就由其他实验共享,不要运行这组命令;从一开始就应使用独立容器名、卷名和临时目录,避免“清理实验”变成删除他人的运行数据。
团队长期运行的判断线
平台团队维护后端版本、Collector 模板、存储、容量和恢复;应用团队维护 Span 语义、传播和服务名;安全团队维护属性规则、租户与查询审计;值班团队维护“无 Trace、Trace 断裂、历史不可查、查询过慢”的首证据。每次升级至少重放一条成功 Trace、一条错误 Trace 和一条超大 Trace,随后验证重启持久化、错误租户隔离和保留期清理。
接收、按 ID 查询、条件搜索和重启后查询都有独立证据。OTLP、Zipkin v2 与传播格式没有混用,应用默认经 Collector 接入。Jaeger 写入 exporter 与 Query 引用同一 storage backend。
Tempo 近期数据、对象块、blocklist 和历史查询链路均可观测。Zipkin 的数据库初始化、TTL、查询 API 和代理认证均已验证。采样、Span 大小、索引字段、保留期和查询并发有容量基线。
写入、查询、存储三面的凭证与网络权限分离。敏感属性在进入后端前处理,租户头由可信代理生成。双写、切流、只读保留、回滚和最终清理都有 owner 与时间窗。
