站在「技术文档工程师 + 指标体系」的视角,讲一件比「选哪种事件模型」更靠前、也更便宜的事:事件名字本身没有契约。我见过太多团队花大力气选了扁平宽表还是事件溯源,结果
add_cart / cart_add / add_to_cart三个名字照样并存,命名层是整条数据链路里最容易被当成「随手变量名」的一环。本文给一套可复现的落地:命名规范 + Python 命名 linter + CI 硬门禁 + 旧名别名双跑收敛,全程不需要改业务代码逻辑,只改「名字」。
一、现象与背景
接手一个电商 App 的数据团队,日均埋点 2.6 亿条事件,覆盖 5 条业务线。接手第一周做了一次「事件名普查」,结果扎心:
- 事件字典里登记 412 个 event 名,但实际流里跑着 527 个——115 个是「字典里没有、线上在跑」的野事件;
- 同一个「加购」语义,流里同时存在
add_cart、cart_add、add_to_cart、addToCart4 个名字,日量分别 3.1 万 / 4.2 万 / 2.8 万 / 0.9 万,四个数加起来才是真实加购; - 用脚本粗算,约 27% 的事件量落在「疑似同义、名字不同」的分组里——也就是说,任何一个「按 event 名 group by」的指标,都天然带着 27% 的口径裂缝。
问题的严重性不在脏数据,而在没有任何一道闸拦得住「下一个野事件」进来。SDK 上报端没有校验,数仓入库没有校验,BI 取数也没有校验——命名完全是「先有后审」,而实际上几乎从不审。
二、原理与根因:为什么「命名漂移」会传染到所有指标
埋点是先写后读的:写一次(SDK 上报)成本趋近于零,读一次(取数 / 建指标 / 出报表)成本高且会被反复读。这意味着:
- 命名是「读侧成本」的放大器。一个语义裂成 N 个名字,等于 BI 里要维护 N 段几乎相同的 SQL、N 套告警、N 份字典条目。名字越多,读侧的「口径表面积」越大。
- 命名漂移是「单源缺失」的典型症状。事件名散落在 5 个前端仓库 + 3 个 SDK 里,没有任何一个地方是「唯一权威」。这不是靠「让大家自觉用统一命名」能解决的——靠约定的规范,和没有规范是一样的。
- 它和「去重」「会话切分」是同一类问题的不同面。UV 去重之所以会分叉,根因是「去重 key 没有单源」;事件名之所以会裂,根因也是「名字没有单源」。凡是「先写后读 + 无单源 + 无门禁」的环节,都会漂移。
所以解法不是「写一份更漂亮的命名文档」,而是把命名规范变成可执行的断言 + 一道拦在写入前的门禁。文档是给人看的,lint 是给机器看的,门禁是给流程看的——三者缺一不可。
三、具体做法(可复现)
1. 命名规范:三段式 {domain}.{action}.{object?}
item.add_to_cart # 动词短语 + 对象
order.refund # 动作 + 对象
app.session_start # 系统级
cart.add_item # 对象 + 动作(同一 domain 内必须统一一种语序)
四条硬约定:
- 全小写、下划线分词,禁止驼峰 / 连字符 / 中文 / 裸名(
addToCart、add-cart、加购一律打回); - 必须带 domain 前缀,且 domain 在白名单里(
item/order/app/cart/payment/session6 个); - 同一 domain 内语序唯一(要么「对象.动作」要么「动作.对象」,不能混);
- 事件名一旦进入「已收敛」状态,只增不改名——改名走第四节的别名双跑,不允许原地改。
2. Python 命名 linter(核心 60 行,可跑)
import re
from pathlib import Path
DOMAINS = {"item", "order", "app", "cart", "payment", "session"}
# 每个 domain 允许的语序:'obj_verb' 或 'verb_obj'
ORDER = {
"item": "verb_obj", "order": "verb_obj", "cart": "obj_verb",
"app": "obj_verb", "payment": "verb_obj", "session": "obj_verb",
}
VERBS = {"add","remove","update","create","delete","refund","pay","start","end"}
NAME = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)+$") # 强制小写下划线、至少 2 段
def lint(name: str) -> list[str]:
errs = []
if not NAME.match(name):
return [f"{name}: 格式非法(须小写下划线、≥2 段)"]
parts = name.split("_")
domain, rest = parts[0], parts[1:]
if domain not in DOMAINS:
errs.append(f"{name}: domain '{domain}' 不在白名单 {sorted(DOMAINS)}")
return errs
# 语序检查:首段/末段是否为动词
if ORDER[domain] == "verb_obj" and rest[0] not in VERBS:
errs.append(f"{name}: {domain} 要求 verb_obj 语序,'{rest[0]}' 非动词")
if ORDER[domain] == "obj_verb" and rest[-1] not in VERBS:
errs.append(f"{name}: {domain} 要求 obj_verb 语序,'{rest[-1]}' 非动词")
return errs
if __name__ == "__main__":
# 从字典 / 上报日志抽取事件名
names = [l.strip() for l in Path("events.txt").read_text().splitlines() if l.strip()]
bad = [(n, e) for n in names for e in lint(n)]
if bad:
for n, e in bad: print("FAIL", e)
raise SystemExit(1)
print(f"OK: {len(names)} 个事件名全部通过命名规范")
3. CI 硬门禁:三道闸
| 闸口 | 时机 | 动作 |
|---|---|---|
| SDK 上报端 | 本地 npm run track:lint | 提交前拦截非法新名,报错直接指到行号 |
| 数仓入库 | 流式 job 启动时 | 读「已收敛事件名」白名单,白名单外的新名进 quarantine 表(隔离区),不进正式明细 |
| 字典仓库 | PR 合并前 | linter 全量跑 + 别名表一致性校验,红就红,不允许「先合后改」 |
隔离区 quarantine 表是关键:它让「野事件」不再污染指标,而是显式地、可查地躺在一张表里等治理,而不是无声地混进 add_to_cart 里。
4. 旧名收敛:别名双跑,不硬切
对已经存在的 add_cart / cart_add / add_to_cart 这类同义名,不要直接删旧名(下游看板会立刻「跳数」)。做法:
- 建别名表
alias(old_name, canonical_name, owner, start_date, end_date); - 入库时同时写旧名与规范名两个
event值(双跑期),下游按canonical_name取数; - 设收敛判定阈值:连续 14 天旧名日量 < 规范名日量的 1%,且
quarantine里该旧名无新增,才允许摘除旧名写路径; - 摘除后保留别名表 90 天做回溯,再归档。
这一套让「收敛」变成一个可量化、可回滚的过程,而不是「某天把旧名删了,然后业务方打电话」。
四、踩过的坑与反直觉之处
- 「命名规范」写成 Wiki 等于没写。 我第一版就是写了篇 8 页的命名规范文档,贴到 wiki,两周后照样长出 20 个野事件。规范只有变成 linter + 门禁,才从「建议」升级为「契约」。 这一点和「指标字典不是文档而是断言」是同一个道理。
- 隔离区比「直接拦截」更实用。 一开始我让非法新名直接丢弃,结果丢掉了 3 个真实的新业务事件(团队想埋但埋不规范),损失了 2 天的数据。改成进
quarantine后,既没污染指标,又保住了数据,还顺手把「谁在乱埋」暴露出来。 - 收敛阈值别设太严。 第一次我设了「旧名 < 0.1% 才摘」,结果 4 个旧名有 2 个因为某个老版本 App 还在跑(日量稳定在 3% 左右)永远收敛不掉,堵住了整个收敛流程。改成 1% 并允许「老版本 EOL 后人工豁免」,才把 412→187 真正跑完。
- 语序一致性比「用哪个词」更重要。 团队争论
add_to_cart还是cart_add能争半天,但真正致命的是同一 domain 里一半用「对象.动作」一半用「动作.对象」——linter 把「语序唯一」做成硬校验后,这类争论直接消失。 - 命名是「读侧成本」,所以治理优先级其实很高。 它不像「去重精度」那样需要换 HLL/Bitmap 重做,只改名字、不改逻辑,投入产出比极高,应该放在指标体系治理的第一步做。
五、多语言 SDK 的命名统一与 linter 自身的维护成本
这一节是初版发布后补的——原文只给了 Python linter 的示例,但实际落地时多语言 SDK 的命名一致性和 linter 自身的维护才是真正吃掉团队时间的两块。
1. 多语言 SDK:一份规范,N 个 linter,一个字典
我们 5 条业务线的埋点 SDK 分布在 3 种语言里:前端 JS/TS、原生 Swift、后端 Python。如果每种语言各写一套 linter,维护成本是 O(N),而且三份 linter 的 domain 白名单、verb 表、语序规则必然漂移(我见过最夸张的:JS 侧 linter 认 payment.refund,Swift 侧 linter 只认 payment.refund_request,两边 CI 都绿,数仓里却裂成两个名字)。
解法:linter 的规则不是代码,是数据。
把 domain 白名单、verb 表、语序规则抽成一份 JSON Schema(naming-rules.json),各语言 linter 只是这份 JSON 的解释器:
{
"version": "2.3.0",
"domains": {
"item": {"order": "verb_obj", "verbs": ["add","remove","update","create","delete"]},
"order": {"order": "verb_obj", "verbs": ["create","cancel","refund","pay"]},
"app": {"order": "obj_verb", "verbs": ["start","end","crash","foreground","background"]},
"cart": {"order": "obj_verb", "verbs": ["add","remove","clear","checkout"]},
"payment": {"order": "verb_obj", "verbs": ["init","pay","refund","cancel"]}
},
"pattern": "^[a-z][a-z0-9]*(_[a-z0-9]+)+$",
"min_segments": 2,
"max_segments": 4
}
各语言 linter 的核心逻辑从 60 行降到 ~20 行(读 JSON → 校验),而且规则变更只需改一份 JSON + 跑一遍全量 linter,不需要动 3 份代码。
CI 里加一道「linter 一致性」校验:各语言 linter 对同一组 50 个标准事件名(naming-rules.json 里附带的 test_cases)跑一遍,结果必须完全一致。不一致就红。这道校验让我们在第 2 个月就抓到了 Swift 侧 linter 漏掉 max_segments 限制的问题——如果不抓,Swift 侧会放行 app.session_start_in_background_mode 这种 5 段名。
2. linter 自身的维护:谁加 domain?谁加 verb?
naming-rules.json 不是写完就完的——业务在长,domain 和 verb 会扩。但扩的规则必须比用的规则更严,否则 linter 本身就变成新的「野事件」来源。我们定的三条硬约束:
- 加 domain 需要数据 owner + 平台 owner 双签。不是「我想加个
marketingdomain」就加——必须说明这个 domain 下预计有多少事件、owner 是谁、预期生命周期。我们 14 个月里只加了 2 个 domain(marketing和push),拒绝过 5 个(share、ab_test、push_v2等),因为它们的语义被现有 domain 覆盖了。 - 加 verb 走 PR + 全量回归。往
ORDER[domain].verbs里加一个 verb,CI 会拿这个 verb 对所有已收敛事件名跑一遍反向校验——如果加了share这个 verb,会检查所有item.*/order.*事件名里有没有已经用了share但没被 linter 覆盖的(说明 verb 表之前漏了),有就补进 test_cases。 naming-rules.json的 version 字段是硬约束。每次变更 version +1,各语言 linter 启动时校验本地 version 与字典仓库的 version 一致,不一致直接 fail。这避免了「JS 侧 linter 还是 2.1 的规则、Swift 侧已经升到 2.3」的漂移。
3. 命名预算:每季度最多新增多少事件名
这是最容易被忽略、但对指标体系健康度影响最大的一条:事件名总数本身需要预算管控。
我们的经验值:412 → 187 收敛后,稳态下每季度新增事件名不超过 15 个(5 条业务线 × 每线 3 个)。超过 15 个意味着:
- 要么是业务真的在长新场景(合理,但需要数据 owner 在 PR 里说明);
- 要么是有人在「绕规范」——用新名字回避旧名字的语序/verb 约束(不合理,应该走别名双跑改旧名,而不是加新名)。
CI 里加一道季度预算检查:统计本季度 naming-rules.json 的 git log 里新增的 canonical name 数量,超过 15 个就黄(不 block,但需要数据 owner 在 PR 评论里逐条说明为什么不能复用已有名字)。这条规则上线后,团队「随手加个新事件名」的习惯明显收敛了——不是不能加,而是加之前要想清楚是不是真的没有现成的。
六、结论与可复用清单
命名是整条数据链路里最便宜、却最容易被漏掉的治理环节:它不碰业务逻辑,只碰「名字」,但能一次性掐掉 27% 量级的口径裂缝。可复用清单(按顺序做):
- 先普查:把流里实际跑着的所有事件名拉出来,和字典 diff,先看清「野事件」规模(我用的是「字典 412 / 实跑 527」这种硬数字);
- 定契约:三段式
{domain}.{action}.{object?}+ domain 白名单 + 同 domain 语序唯一,写成可执行的 linter,别写成 Wiki; - 上三道闸:SDK 端本地 lint、入库
quarantine隔离、字典仓库 CI 硬门禁,红就红; - 收敛用双跑:别名表 + 双跑期 + 量化收敛阈值(1% × 14 天),可回滚、可豁免;
- 命名只增不改名:改名一律走别名双跑,永远不原地改;
- 多语言 linter 共享一份 JSON 规则,规则变更改 JSON 不改代码,CI 加 linter 一致性校验;
- linter 自身的扩缩有门槛:加 domain 双签、加 verb 全量回归、version 硬对齐;
- 命名预算:每季度新增 canonical name ≤ 15 个,超了要逐条说明;
- 顺序优先:命名治理放在指标口径、去重、会话切分之前做——它是后面所有口径的「地基」,地基不牢,上面盖的每层都在漂。
一句话:命名规范的价值不在文档写得多漂亮,而在它被 linter 断言、被 CI 拦截、被双跑收敛的那一刻起,才从「约定」变成「契约」。