技术5/25/2026·9 min

把 analytics 拆成三个 binary、迁到 Connect-RPC,以及 ClickHouse 三级数据隔离

一个 analytics binary 同时做 ingest、enrich、query、admin。我们把它拆成 collector/worker/datahouse,用 Connect-RPC 替掉 HTTP+JSON+HMAC,并让测试数据在物理上到不了生产看板。

起点

analytics 服务最初是单个 Go binary,用 cobra 子命令切模式:serve(公网事件接收)、worker(富化 + 写 ClickHouse)、query(看板读),外加 migrate / ch-migrate / seed / replay。一个进程镜像同时持有 PostgreSQL 凭据、ClickHouse DDL 凭据、Redpanda consumer 和公网 HTTP listener。

三个问题同时叠加:

  • 攻击面。公网接收端点和能 DROP TABLE 的代码在同一个 binary,前门被攻破就够到了仓库。
  • 失败域。ingest 延迟敏感、CPU 轻;worker 吞吐重、有突发。共享进程意味着 worker 积压和请求处理抢同一份资源。
  • wire 协议漂移。SDK 手写 HTTP + JSON + 对 body 做 HMAC 签名,server 又把同一套解析校验实现一遍。一份契约两份拷贝,靠人手同步。

两个改动各自解决一部分:先做 Connect-RPC 迁移,再拆三 binary。

Connect-RPC:删掉手写传输层

迁移前 SDK 和 server 共同遵守的契约活在两边代码里 —— JSON 字段名、gzip 分帧、对原始 body 算的 HMAC 签名。每改一次就是两处必须对得上的编辑。

迁移把契约挪进 .proto 作为唯一 SSOT(Server/libs/proto/analytics/v1/),Go 和 Swift 从同一批文件生成。server 现在用单一 :8080 HTTP/2(h2c)端口,同时讲 Connect、gRPC、gRPC-Web。handler 是标准 Connect 形态:

func (s *Service) IngestEvents(
    ctx context.Context,
    req *connect.Request[analyticsv1.IngestEventsRequest],
) (*connect.Response[analyticsv1.IngestEventsResponse], error)

SDK 这边,三个文件被直接删掉:RequestSigner.swiftGzipCompressor.swiftAnalyticsHTTPClient.swift。gzip、HTTP/2、错误码映射现在都是 Connect runtime 的事。uploader 持一个生成的 client,拼一个生成的 request:

private let client: Aio_Analytics_V1_CollectorServiceClient
// ...
var req = Aio_Analytics_V1_IngestEventsRequest()
req.events = events
let resp = await client.ingestEvents(request: req, headers: [:])

这里掉出来两个不那么显然的点。

Signature 拦截器不再签 body。Connect 的 codec 把 payload 跨协议封装(binary / JSON / gzip),分帧层之后拿不到稳定字节序列做 HMAC。body 签名被移除,鉴权改成 header 方案、由拦截器校验。server 端注释写得很直白:多协议 codec 下重新引入 body 签名会立刻把漂移带回来 —— 而这正是做迁移的根因。

codegen 需要自定义 Bazel 规则。connect-go 和 protoc-gen-go-grpc 一起生成会类型名冲突,所以用了一个带 package_suffix=connect_go compiler,让它输出到和 .pb.go 同包。Swift 侧,connect-swift 和 swift-protobuf 必须走一条 compiler 路径拉取,否则两份 swift-protobuf 会在 link 时 duplicate symbol。

健康检查和就绪检查保持纯 HTTP GET。k8s 和 Docker 探针就是这么约定的,没理由让存活检查穿过 RPC codec。

三个 binary,边界由 import 图强制

统一 wire 协议之后,拆 binary 就是机械操作。单进程变成三个可部署单元,各自单一职责:

  • analytics-collector —— 公网 ingest。起 Connect-RPC server,只挂 CollectorService,把事件写 Redpanda。不持 ClickHouse 凭据、不消费 MQ、不带 admin 命令。
  • analytics-worker —— 内网 consumer。对三个 Redpanda topic 跑 enricher + loader + persons-merger,写 ClickHouse。不开业务 HTTP listener;唯一 socket 是 127.0.0.1:8081/healthz 给 Docker 探活。
  • datahouse —— 没单独留 binary,而是折进已有的 mainline server。CH admin(DDL)和只读 ad-hoc query 作为 DatahouseService RPC 暴露,经 mainline 主域名 + PAT 调用。CLI、agent、CI 不再直连 ClickHouse。

有意思的是,这些边界不是文档约定 —— 是编译期事实。collector 无法 import libs/go/analytics/{clickhouse, chviews, replay},物理上拿不到仓库凭据。worker 没有 interceptor.APIKeyAuth、不 import collector RPC,不会不小心长出公网面。两边都需要的共享逻辑(config、entity 类型、MQ client、enricher/loader、testkit)下沉进了 Server/libs/go/analytics/

拆分那个 commit 是净删除:+1893 / −3388 行,主要来自移除独立的 query 后端和 SDK 里重复的传输层。

datahouse 的读路径靠数据库权限而非 SQL 解析来保安全。ExecSQL 对一个钉死在 readonly = 2 的 ClickHouse user 跑任意 SELECT:

CREATE USER IF NOT EXISTS analytics_readonly ... SETTINGS readonly = 2;
GRANT SELECT ON analytics.* TO analytics_readonly;

readonly = 2 是这里关键的 ClickHouse 设置:只允许 SELECT,但仍允许 per-query 的设置如 max_execution_time(readonly = 1 会直接拒掉这类查询)。注入防护是 user 授权,不是查询校验器。另一个 dbt_runner user 持 CREATE VIEW / DROP VIEW 授权用于物化 metric view —— DDL 路径和查询路径永不共用一份凭据。

ClickHouse 三级隔离

第三个关注点是把测试和开发数据挡在生产看板之外。这件事在三个物理层级处理,不是一个开关。

测试隔离 —— is_test,三层防御。事件带一个 is_test Bool DEFAULT false CODEC(ZSTD(1)) 列。这一列几乎免费:99%+ 的值是 false,ZSTD 在近常量列上压到约每行零字节。三个独立的层喂它:

  1. iOS SDK 的 ReporterModeResolver 在 XCTest、XCUITest 或任何 simulator 下(SIMULATOR_DEVICE_NAME / SIMULATOR_UDID 是 simulator runtime contract 的一部分)直接 short-circuit capture。测试事件根本到不了网络。单一环境信号就覆盖了单测、UI 测试、本地 make run_app_on_sim,所以每个新产品零配置继承这套隔离。
  2. 万一有东西绕过 SDK 仍发了 $is_test = true,worker 的 TestModeEnricher 会读到并写这一列。这个 enricher 没有配置开关 —— 判定规则是 SDK↔server 契约的一部分,运维无法误关。
  3. query service 默认给看板读注入 AND is_test = false

这里有一个刻意保留的 ANALYTICS_FORCE_PROD=1 escape hatch,用于在 simulator 上 dogfood 真实生产上报 —— 但它不解除 XCTest/XCUITest 的保护。真测试进程永远不该能污染监控数据,跟 env 怎么设无关。

开发隔离 —— 按 worktree 偏移端口。本地栈(PostgreSQL、Redis、Redpanda、ClickHouse、MinIO,加三个 app 容器)从一个 docker-compose 起,host 端口按 git worktree 名的哈希做偏移。每个服务拿一条 1000 宽的带(POSTGRES_PORT = 15000 + offsetCLICKHOUSE_NATIVE_PORT = 18000 + offsetCOLLECTOR_PORT = 24000 + offset …),offset 被限制在 500 以下,所以带之间绝不撞,多个 worktree 能并行起各自的整套栈。CI 里用 WORKTREE_NAME env(如 ci-job-$CI_JOB_ID)替掉 worktree 哈希做 per-job 隔离。

生产隔离 —— 独立 VM 和入口。生产是自己的 analytics-prod VM。公网 ingest 经 analytics.monitor.agentsmesh.ai → 一个 sg-001 Traefik 终止 TLS 并 h2c 回源到 collector 容器;worker 和 ClickHouse 在 LAN 内,无公网端口。dev compose 里默认带 is_test 的数据和生产仓库是两台不同的机器,不是两行不同的数据。

可迁移的几条

  • 把 wire 契约放进单一 artifact,两端都从它生成。靠两边 code review 同步的协议会漂移;一份编译进 Go 和 Swift 的 .proto 不会。
  • 用 import 图强制服务边界,不是用 wiki 页。"collector 不许碰 ClickHouse" 是注释;"collector 不 import clickhouse 包" 是编译器在查。
  • 纵深防御胜过单一开关。依赖一个布尔的测试隔离,在布尔错了时会静默失效。SDK short-circuit、worker enricher、query filter 三层各自接住别人漏掉的。
  • 查询安全靠数据存储的权限模型。一个 readonly = 2 的 ClickHouse user 比一个 SQL 解析器更强,而且只有一行。