起点
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.swift、GzipCompressor.swift、AnalyticsHTTPClient.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,而是折进已有的
mainlineserver。CH admin(DDL)和只读 ad-hoc query 作为DatahouseServiceRPC 暴露,经 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 在近常量列上压到约每行零字节。三个独立的层喂它:
- iOS SDK 的
ReporterModeResolver在 XCTest、XCUITest 或任何 simulator 下(SIMULATOR_DEVICE_NAME/SIMULATOR_UDID是 simulator runtime contract 的一部分)直接 short-circuit capture。测试事件根本到不了网络。单一环境信号就覆盖了单测、UI 测试、本地make run_app_on_sim,所以每个新产品零配置继承这套隔离。 - 万一有东西绕过 SDK 仍发了
$is_test = true,worker 的TestModeEnricher会读到并写这一列。这个 enricher 没有配置开关 —— 判定规则是 SDK↔server 契约的一部分,运维无法误关。 - 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 + offset、CLICKHOUSE_NATIVE_PORT = 18000 + offset、COLLECTOR_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 解析器更强,而且只有一行。