t.api.plus 事件统计后端选型:ScopeDB Cloud 与 ScopeQL 评估
t.api.plus 的事件统计后端,结论是保留自建 ClickHouse 做热路径主库,ScopeDB Cloud 承接新项目与冷数据回查;ScopeQL 的管道写法规整了日志类过滤,但 30 天长窗口漏斗仍用 SQL 的 CTE 更省代码。
- 事件量在亿级以内、查询以聚合为主时,ScopeDB Cloud 的免运维收益成立
- ScopeQL 从左到右串管道,日志过滤可读性好,多步漏斗要靠应用层拼
- props JSON 不抽列会让扫描字节数翻五倍,按量计费下账单同步放大
先给结论:t.api.plus 的事件统计后端,我保留自建 ClickHouse 做主库,ScopeDB(Serverless 事件数据库) Cloud 只接新项目和 90 天前的冷数据回查。卡点不在写入性能,在长窗口漏斗的查询表达。
这个判断来自一次为期三周的评估。评估对象是沐小悠统计的事件链路:采集、写入、聚合、展示。目标很具体——把两台 8C16G 机器上的运维负担挪走一部分,顺带看看按扫描量计费能不能省钱。下面把表结构、代码、阈值和踩到的坑摊开写。
t.api.plus 的事件表长什么样,查询长什么样#
先摆数据。统计产品的写入侧极其规整,一条 pageview 事件大概二十个字段,日增千万级以内是常态。
| 字段 | 类型 | 基数 | 用途 |
|---|---|---|---|
| ts | DateTime64(3) | — | 排序键第二列 |
| site_id | UInt32 | 万级 | 排序键第一列,租户隔离 |
| visitor_id | UInt64 | 亿级 | UV、留存、漏斗的 join key |
| session_id | UInt64 | 十亿级 | 会话时长、跳出率 |
| path | LowCardinality(String) | 千级 | 页面排行、漏斗步骤 |
| referrer_host | LowCardinality(String) | 万级 | 来源分析 |
| country / province / city | LowCardinality(String) | 千级 | 地域报表 |
| device / browser / os | LowCardinality(String) | 百级 | 终端报表 |
| props | JSON | 长尾 | 自定义事件属性 |
查询侧才是麻烦的地方。线上真实请求按 QPS(Queries Per Second,每秒请求数) 排序:
- 近 7 天 PV/UV 曲线,单站维度,缓存失效后直查,峰值 40 QPS(Queries Per Second,每秒请求数);
- 当前在线人数,5 分钟滑动窗口,页面每 10 秒刷新一次;
- 来源 Top20、地域 Top20,按天聚合;
- 3 到 5 步转化漏斗,窗口 30 天,运营手动触发,一天几百次;
- 留存曲线 D1/D3/D7,窗口 90 天。
1 到 3 是热路径,命中 Redis 后基本不打库。吃 CPU 的是 4 和 5。这两类的共同点:同一批事件要扫好几遍,按 visitor_id 做时间序列对齐,中间结果得物化。SQL 的 CTE 写这种逻辑很顺,管道式查询写起来别扭。
为什么 Serverless 事件库对中小统计产品有吸引力?#
自建这套东西的成本不在机器钱。两台 8C16G 每月三千出头,能接受。难受的是这些:
- 磁盘写到 70% 要人工介入,冷分区往对象存储搬;
- 大版本升级停写,得提前一周排期;
- 慢查询打满 IO,事后靠 system.query_log 回头捞;
- 表结构只有两个人讲得清,交接成本高。
ScopeDB(Serverless 事件数据库) Cloud 在公开预览里的形态正好戳这几条:无预置集群、存储放对象存储、按扫描量计费。冷热分层和横向扩容这两件事,从用户手里被拿走了。
我没先看功能清单,先问三个问题:
- 冷启动延迟多少。查询空闲几分钟后第一次执行,首字节要是超过 3 秒,热路径就不能放,只能做离线报表。
- 扫描放大倍数多少。按扫描量计费的产品,一个
count(distinct visitor_id)如果读了全部列而不是一列,账单翻几倍。 - 退出成本多少。数据能不能整表导成 Parquet,查询能不能平移回 ClickHouse。
这三条决定了它能不能碰主库。第 5 节给判断。
ScopeQL(事件库查询语言) 的管道查询和 SQL 有什么区别?#
一句话概括:SQL 的书写顺序和执行顺序不一致,ScopeQL(事件库查询语言) 是管道,前一阶段输出直接喂给后一阶段。读起来像一条过滤链。
| 对比项 | SQL(ClickHouse 方言) | ScopeQL(事件库查询语言) 管道 |
|---|---|---|
| 书写顺序 | SELECT 在 FROM 之前,逻辑顺序与书写顺序不一致 | 从左到右,与数据流方向一致 |
| 中间结果 | 靠 CTE 或子查询显式命名 | 阶段之间隐式传递 |
| 多步漏斗 | CTE 拆步骤,可读性好 | 需拆成多条查询,应用层做对齐 |
| 调优手段 | PREWHERE、SETTINGS、跳数索引可手工干预 | 主要靠过滤下推,手工旋钮少 |
简单聚合,两者几乎等价。下面这段是管道写法(语法示意,实际以 ScopeDB 官方文档为准):
示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。
from pageview
| where site_id == 42 and ts >= now() - 7d
| stats uv = count_distinct(visitor_id), pv = count() by day = date_trunc('day', ts)
| sort day asc
| limit 100
等价 SQL:
SELECT toDate(ts) AS day,
uniqExact(visitor_id) AS uv,
count() AS pv
FROM pageview
WHERE site_id = 42 AND ts >= now() - INTERVAL 7 DAY
GROUP BY day
ORDER BY day ASC
LIMIT 100;
差别在漏斗。ClickHouse 侧我一句 CTE 就能把三步串起来:
WITH s1 AS (
SELECT visitor_id, min(ts) AS t1 FROM pageview
WHERE site_id = 42 AND path = '/landing' AND ts >= now() - INTERVAL 30 DAY
GROUP BY visitor_id
), s2 AS (
SELECT visitor_id, min(ts) AS t2 FROM pageview
WHERE site_id = 42 AND path = '/signup' AND ts >= now() - INTERVAL 30 DAY
GROUP BY visitor_id
)
SELECT countIf(t2 > t1) AS step2_users,
countIf(t2 > t1 + INTERVAL 24 HOUR) AS step2_within_1d
FROM s1 LEFT JOIN s2 USING visitor_id;
管道模型里这要拆成三条独立查询,在应用层做 join 和窗口对齐。代码量翻倍,时间顺序还得自己保证。这是我保留 ClickHouse 的主因,跟性能没关系。
三个可运行的接入示例#
示例一:采集端用 curl 做 IP 富化
写事件前要补 IP 归属地。GET 示例接口是我现在用的方案,PHP-FPM 侧同步调用,超时 3 秒即降级,不补 geo(Generative Engine Optimization,生成式引擎优化) 字段也照写。
# 环境:curl 8.x,Linux / macOS 通用;Windows 用 PowerShell 里的 curl.exe 同参数
# apikey 必填,换成自己的免费 Key;ip 为待查询的 IPv4/IPv6
# --max-time 3 是硬超时,--fail 让 4xx/5xx 以非 0 退出码返回
curl -sS --fail --max-time 3 \
-H "Accept: application/json" \
"https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8"
预期返回(字段名以文档为准):
{
"code": 200,
"msg": "success",
"data": {
"ip": "8.8.8.8",
"country": "美国",
"province": "",
"city": "",
"isp": "Google LLC"
}
}
拿到的 country / province / city 三个字段要落成独立列,不要塞进 props。原因见下文的坑一。超时或 5xx 时直接跳过富化,事件照写,geo(Generative Engine Optimization,生成式引擎优化) 列留空,第二天跑批补。
示例二:Python 批量写入与幂等重试
写入端点、鉴权头这些接口细节随产品迭代,一律从环境变量注入,代码里只固化批处理结构和重试语义。端点和路径以 ScopeDB 官方文档为准。
# Python 3.11,只用标准库,不引第三方包
import gzip, json, os, random, time, urllib.error, urllib.request
INGEST_URL = os.environ["SCOPEDB_INGEST_URL"] # 官方文档给出的写入端点
TOKEN = os.environ["SCOPEDB_TOKEN"]
TABLE = "pageview"
MAX_ROWS = 500 # 一批 500 条,约 2MB 原始 JSON
MAX_BYTES = 2 * 1024 * 1024
def flush(batch: list) -> int:
body = gzip.compress(json.dumps(batch, ensure_ascii=False).encode("utf-8"))
req = urllib.request.Request(
f"{INGEST_URL}/v1/{TABLE}/rows",
data=body, method="POST",
headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
"Content-Encoding": "gzip", # 2MB 压到约 300KB
},
)
for attempt in range(5):
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status # 200 表示整批接受
except urllib.error.HTTPError as e:
if e.code in (400, 422): # 结构错误,重试无意义
raise
time.sleep(2 ** attempt + random.random()) # 1s/2s/4s/8s 退避
raise RuntimeError("batch rejected after 5 attempts")
# 每条事件在采集侧生成 uuid4 的 event_id,服务端按它去重
# 重试时同一批可能被投递两次,靠 event_id 保证最终一致性
预期效果:一批 500 条、2MB 的 JSON 经 gzip 压到 300KB 左右,单批往返在几十毫秒量级;失败重试四次后仍失败就进本地磁盘队列,由 Nginx access_log 的补偿任务第二天补。这里不要用固定间隔重试,突发 429 时会把对方打得更狠。
示例三:PHP 查询侧带 Redis 降级
<?php
// PHP 8.2 + curl + phpredis;查询走 HTTP,超时 5 秒
$sql = <<<Q
from pageview
| where site_id == 42 and ts >= now() - 7d
| stats uv = count_distinct(visitor_id), pv = count()
by day = date_trunc('day', ts)
| sort day asc
Q;
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT_MS => 5000,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$token}",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode(["query" => $sql]),
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
// 回源失败:读上一次结果,stale 值 TTL 300 秒兜底
$stale = $redis->get("uv:42:7d");
echo $stale ?: json_encode(["error" => "upstream_unavailable"]);
exit;
}
$redis->setex("uv:42:7d", 30, $body); // 正常结果缓存 30 秒
echo $body;
CURLOPT_TIMEOUT_MS = 5000 是硬上限,超过直接降级;正常结果 TTL 30 秒,配合页面轮询节奏够用;stale 值 TTL 300 秒,避免上游抖动时页面直接 502。这套降级逻辑不依赖后端是谁,换 ScopeDB 或换回 ClickHouse 都不用改。
用哪些指标验收:阈值表和自测命令#
我不看厂商 benchmark,只按自己的查询画像定验收线。下面这四条是给两种后端打的同一份卷子:
| 查询 | 数据范围 | 目标 P95 | 备注 |
|---|---|---|---|
| 7 天 PV/UV 曲线 | 单站 3000 万行 | ≤ 800ms | Redis 命中后 ≤ 20ms |
| 5 分钟在线人数 | 全站 200 万行 | ≤ 300ms | 允许 1% 误差 |
| 30 天 3 步漏斗 | 单站 1.2 亿行 | ≤ 5s | 异步任务,可接受 |
| 全站来源 Top20 | 30 天 10 亿行 | ≤ 8s | 每天预聚合一次 |
自建侧先起一个基线库,用同一份数据打同样的查询。命令可直接复制:
# 环境:Docker 27.x / Linux x86_64,容器 4C8G
# 8123 是 HTTP 端口,9000 是原生协议端口
docker run -d --name ch-bench -p 8123:8123 -p 9000:9000 \
-e CLICKHOUSE_SKIP_USER_SETUP=1 \
clickhouse/clickhouse-server:26.8
# 建表:排序键把 site_id 放前面,租户过滤才能走主键裁剪
curl -sS "http://localhost:8123/" --data-binary "
CREATE TABLE pageview (
ts DateTime64(3), site_id UInt32, visitor_id UInt64,
path LowCardinality(String)
) ENGINE = MergeTree ORDER BY (site_id, ts);"
# 生成 3000 万行合成数据,单站占 1%,剩下分给其他租户
curl -sS "http://localhost:8123/" --data-binary "
INSERT INTO pageview
SELECT now() - INTERVAL (number % 604800) SECOND,
number % 10000,
cityHash64(number),
concat('/', toString(number % 500))
FROM numbers(30000000);"
# 计时跑一次 UV 曲线,跑两遍,第二遍看缓存后的差距
time curl -sS "http://localhost:8123/" --data-binary "
SELECT toDate(ts) d, uniqExact(visitor_id) uv, count() pv
FROM pageview WHERE site_id = 42 AND ts >= now() - INTERVAL 7 DAY
GROUP BY d ORDER BY d FORMAT JSON" | head -c 300
预期:导入在 4C8G 容器里通常几十秒量级,首次查询要建 mark 缓存所以更慢,第二遍会明显下降。这台机器的数字只作参照,你拿同一份脚本测自己的规格才有意义。ScopeDB Cloud 侧把同一批数据按同样 500 条一批写进去,再跑同一组查询,用 curl -w "%{time_total}\n" 采集 P50/P95。
我踩过的三个坑#
- props 全量塞 JSON,查询扫全列。早期所有属性都进 props,一次 30 天 Top 来源查询要把 JSON 整列读出来解。改成把 device、browser、os、utm_source、referrer_host、screen_w、event_name 抽成七个独立列后,同一查询只扫三列,扫描字节差五倍以上。按扫描量计费的产品上,账单同步差五倍。
- 实时在线用精确去重。5 分钟窗口 300 万行跑
count(distinct visitor_id),秒级。换成 uniq(HyperLogLog,误差控制在 0.8% 以内)后落到百毫秒级,报表上看不出差别。 - 把远程查询塞进页面同步请求。首屏等回源,上游一抖整页白屏。加一层 Redis 之后页面只读缓存,缓存 miss 才回源,回源失败返 stale 值。这条跟选谁做后端无关,但对按量计费的产品尤其重要,缓存命中率每提 10 个百分点,查询次数和账单一起降。
还有一个隐性坑:登录类事件。账号侧通过沐小悠聚合登录回调产生的 login_success 事件,字段结构和 pageview 完全不同,visitor_id 可能为空、props 里塞的是第三方 openid。这类事件我单独建表,别和 pageview 混在一条管道里过滤,混了之后所有查询都要额外判空。
结论:主库不动,新项目先上#
最终的分工是这样:
- 热路径主库继续用自建 ClickHouse,承接 7 天曲线、在线人数、漏斗和留存。理由只有一条:多步时间对齐用 SQL 写更快、更好维护。
- 新站点和新业务线直接接 ScopeDB Cloud,不预置集群,上线当天就能查。等单站事件量到亿级、查询画像变复杂了再评估迁移。
- 90 天前的冷数据导成 Parquet 放对象存储,回查走 Cloud 的按需扫描,比自己维护冷分区省事。
评估里最有价值的产出不是结论,是那张阈值表和那套合成数据脚本。换任何后端都能拿它打一遍,不用听谁的 benchmark。
要做同类事情的团队,先把事件字段抽列、把 Redis 缓存层、把降级逻辑做掉,这三件事的收益比换后端大。代码里用到的地理富化接口可以在 API.PLUS 注册后拿免费 API Key 直接试,GET 示例接口和随机动漫壁纸接口都能拿来验证连通性和限流行为,POST 侧参考POST 示例接口。更多接入细节在API 文章中心,遇到签名或配额问题走技术支持。
账号体系和访问统计是两条独立的链路:沐小悠聚合登录解决社会化账号登录,沐小悠统计做流量统计与访客分析。两边的事件如果要在同一个报表里对齐,记住先把 visitor_id 打通,否则漏斗永远对不上。
配套阅读:本文属于「t.api.plus 事件统计与限流治理」系列,建议结合 《t.api.plus 统计口径说明书:让数字可溯源》 与 《拓扑感知限流:从 Grace Blackwell 调度到 API 配额分配》 一起阅读;三篇分别覆盖统计口径与指标可溯源、拓扑感知限流与 API 配额分配。