用户登录

技术文章

外部链接将在新标签页打开。

t.api.plus 事件统计后端选型:ScopeDB Cloud 与 ScopeQL 评估

一句话回答

t.api.plus 的事件统计后端,结论是保留自建 ClickHouse 做热路径主库,ScopeDB Cloud 承接新项目与冷数据回查;ScopeQL 的管道写法规整了日志类过滤,但 30 天长窗口漏斗仍用 SQL 的 CTE 更省代码。

核心要点 TL;DR
  • 事件量在亿级以内、查询以聚合为主时,ScopeDB Cloud 的免运维收益成立
  • ScopeQL 从左到右串管道,日志过滤可读性好,多步漏斗要靠应用层拼
  • props JSON 不抽列会让扫描字节数翻五倍,按量计费下账单同步放大

先给结论:t.api.plus 的事件统计后端,我保留自建 ClickHouse 做主库,ScopeDB(Serverless 事件数据库) Cloud 只接新项目和 90 天前的冷数据回查。卡点不在写入性能,在长窗口漏斗的查询表达。

这个判断来自一次为期三周的评估。评估对象是沐小悠统计的事件链路:采集、写入、聚合、展示。目标很具体——把两台 8C16G 机器上的运维负担挪走一部分,顺带看看按扫描量计费能不能省钱。下面把表结构、代码、阈值和踩到的坑摊开写。

t.api.plus 的事件表长什么样,查询长什么样#

先摆数据。统计产品的写入侧极其规整,一条 pageview 事件大概二十个字段,日增千万级以内是常态。

字段类型基数用途
tsDateTime64(3)—排序键第二列
site_idUInt32万级排序键第一列,租户隔离
visitor_idUInt64亿级UV、留存、漏斗的 join key
session_idUInt64十亿级会话时长、跳出率
pathLowCardinality(String)千级页面排行、漏斗步骤
referrer_hostLowCardinality(String)万级来源分析
country / province / cityLowCardinality(String)千级地域报表
device / browser / osLowCardinality(String)百级终端报表
propsJSON长尾自定义事件属性

查询侧才是麻烦的地方。线上真实请求按 QPS(Queries Per Second,每秒请求数) 排序:

  1. 近 7 天 PV/UV 曲线,单站维度,缓存失效后直查,峰值 40 QPS(Queries Per Second,每秒请求数);
  2. 当前在线人数,5 分钟滑动窗口,页面每 10 秒刷新一次;
  3. 来源 Top20、地域 Top20,按天聚合;
  4. 3 到 5 步转化漏斗,窗口 30 天,运营手动触发,一天几百次;
  5. 留存曲线 D1/D3/D7,窗口 90 天。

1 到 3 是热路径,命中 Redis 后基本不打库。吃 CPU 的是 4 和 5。这两类的共同点:同一批事件要扫好几遍,按 visitor_id 做时间序列对齐,中间结果得物化。SQL 的 CTE 写这种逻辑很顺,管道式查询写起来别扭。

为什么 Serverless 事件库对中小统计产品有吸引力?#

自建这套东西的成本不在机器钱。两台 8C16G 每月三千出头,能接受。难受的是这些:

  • 磁盘写到 70% 要人工介入,冷分区往对象存储搬;
  • 大版本升级停写,得提前一周排期;
  • 慢查询打满 IO,事后靠 system.query_log 回头捞;
  • 表结构只有两个人讲得清,交接成本高。

ScopeDB(Serverless 事件数据库) Cloud 在公开预览里的形态正好戳这几条:无预置集群、存储放对象存储、按扫描量计费。冷热分层和横向扩容这两件事,从用户手里被拿走了。

我没先看功能清单,先问三个问题:

  1. 冷启动延迟多少。查询空闲几分钟后第一次执行,首字节要是超过 3 秒,热路径就不能放,只能做离线报表。
  2. 扫描放大倍数多少。按扫描量计费的产品,一个 count(distinct visitor_id) 如果读了全部列而不是一列,账单翻几倍。
  3. 退出成本多少。数据能不能整表导成 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 万行≤ 800msRedis 命中后 ≤ 20ms
5 分钟在线人数全站 200 万行≤ 300ms允许 1% 误差
30 天 3 步漏斗单站 1.2 亿行≤ 5s异步任务,可接受
全站来源 Top2030 天 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。

我踩过的三个坑#

  1. props 全量塞 JSON,查询扫全列。早期所有属性都进 props,一次 30 天 Top 来源查询要把 JSON 整列读出来解。改成把 device、browser、os、utm_source、referrer_host、screen_w、event_name 抽成七个独立列后,同一查询只扫三列,扫描字节差五倍以上。按扫描量计费的产品上,账单同步差五倍。
  2. 实时在线用精确去重。5 分钟窗口 300 万行跑 count(distinct visitor_id),秒级。换成 uniq(HyperLogLog,误差控制在 0.8% 以内)后落到百毫秒级,报表上看不出差别。
  3. 把远程查询塞进页面同步请求。首屏等回源,上游一抖整页白屏。加一层 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 配额分配。

常见问题(FAQ)

ScopeDB Cloud 能替代自建 ClickHouse 做网站流量统计吗?
能做,但要看查询形态。单站 7 天 UV 曲线、来源 Top20 这类聚合,两者差别不大,Cloud 更省运维。需要多次扫描同一批事件、按 visitor_id 做时间对齐的 3 到 5 步漏斗,管道式表达成本高,建议留在 ClickHouse。
ScopeQL 和 SQL 的主要区别在哪?
书写顺序与执行顺序是否一致。SQL 写 SELECT 在 FROM 前面,靠 CTE 和子查询分层;ScopeQL 是管道,前一阶段输出直接喂给后一阶段,读起来像过滤链。简单聚合两者几乎等价,复杂关联和窗口对齐 SQL 更成熟。
事件表怎么设计才能压住查询成本?
把高频过滤字段从 props JSON 里抽成独立列。device、browser、os、utm_source、referrer_host、event_name 这七八个字段一抽,同样的 Top 来源查询只扫三列而不是二十列,按扫描量计费时账单能差五倍以上。

作者:沐小悠

本文约 24 分钟读完 · 关键词:ScopeDB Cloud、ScopeQL、事件统计后端、Serverless 事件数据库、网站流量统计

更多文章 GET 示例接口