站在「技术文档工程师 + 指标体系」的视角,讲一件比「选哪种事件模型」更靠前、也更便宜的事:事件名字本身没有契约。我见过太多团队花大力气选了扁平宽表还是事件溯源,结果
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 重做,只改名字、不改逻辑,投入产出比极高,应该放在指标体系治理的第一步做。
五、结论与可复用清单
命名是整条数据链路里最便宜、却最容易被漏掉的治理环节:它不碰业务逻辑,只碰「名字」,但能一次性掐掉 27% 量级的口径裂缝。可复用清单(按顺序做):
- 先普查:把流里实际跑着的所有事件名拉出来,和字典 diff,先看清「野事件」规模(我用的是「字典 412 / 实跑 527」这种硬数字);
- 定契约:三段式
{domain}.{action}.{object?}+ domain 白名单 + 同 domain 语序唯一,写成可执行的 linter,别写成 Wiki; - 上三道闸:SDK 端本地 lint、入库
quarantine隔离、字典仓库 CI 硬门禁,红就红; - 收敛用双跑:别名表 + 双跑期 + 量化收敛阈值(1% × 14 天),可回滚、可豁免;
- 命名只增不改名:改名一律走别名双跑,永远不原地改;
- 顺序优先:命名治理放在指标口径、去重、会话切分之前做——它是后面所有口径的「地基」,地基不牢,上面盖的每层都在漂。
一句话:命名规范的价值不在文档写得多漂亮,而在它被 linter 断言、被 CI 拦截、被双跑收敛的那一刻起,才从「约定」变成「契约」。