一次对不上的支付成功率
「回声」大促后的复盘会上,两个团队拿出了两个支付成功率:客户端看板 71%,服务端订单表 64%。差 7 个百分点,足够让一场实验的结论反转。
- 客户端把「从支付渠道回跳」当成支付成功,但回跳不等于扣款成功,用户可能只是点了返回。
- 同一个支付动作被上报了两次:按钮点击一次,回跳页面一次,
eventId每次重新生成,服务端无法去重。 - 两个端用了不同的字段名,一个叫
order_id,一个叫orderId,同一个订单在报表里变成了两条。
这三类问题都不是「采集得不够多」,而是事件没有契约:名字、字段、版本、串联键、来源,五项里缺一项,后面的清洗与对账就要靠人猜。
事件契约先固定五件事
| 要素 | 要固定什么 | 不固定会怎样 |
|---|---|---|
| 名称 | 命名空间 + 下划线动作,如 trade.order_pay | 同名不同义,报表无法合并 |
| 字段 | 哪些必填、类型、枚举范围 | 缺字段的行被静默丢弃或补默认值 |
| 版本 | 每条事件带 schemaVersion | 新旧端字段语义不同,无法回溯 |
| 串联键 | traceId、orderId、sessionId | 一次旅程被拆成多个陌生用户 |
| 来源 | client 还是 server | 端上估算被当成服务端事实 |
六类事件的最小字段
trade-lab 只保留交易分析真正需要的六类事件。字段不求多,但必填项必须能支撑「谁、在哪个页面、对哪个商品、哪笔订单、发生了什么」。
| 事件 | 必填属性 | 是否要求 orderId |
|---|---|---|
trade.product_impression | skuId、page、position | 否 |
trade.product_click | skuId、page、position | 否 |
trade.cart_add | skuId、quantity、sourcePage | 否 |
trade.order_submit | amountCents、itemCount | 是 |
trade.order_pay | amountCents、payId、channel | 是 |
trade.order_refund | refundId、amountCents、reason | 是 |
命名与版本:先定规则再谈采集
命名规则只有一条:小写命名空间、点号分隔、动作部分用下划线,例如 trade.product_impression。它排除了 Trade.ProductImpression、product_impression、trade.click 这些常见写法。
版本规则同样只有一条:破坏性变更必须升 schemaVersion。删除字段、改字段类型、把选填改必填、删除枚举值,都是破坏性变更;新增选填字段、给枚举加值、把字段标为废弃,是兼容变更。规则写进代码,评审时就不用凭记忆争论「这算不算 breaking」。
一个可运行的契约模块
下面的模块属于 L1:只有类型、查表和纯函数,没有网络、没有定时器、不读系统时间。now 由调用方传入,测试可以构造任意时间点。
// file: src/analytics/event-contract.ts
// tier: L1
// verify: node --test test/analytics/event-contract.test.ts
export const SCHEMA_VERSION = 2;
export const CLOCK_SKEW_MS = 5 * 60_000;
export const MAX_EVENT_AGE_MS = 7 * 24 * 60 * 60_000;
export type EventSource = "client" | "server";
export type TradeEventName =
| "trade.product_impression"
| "trade.product_click"
| "trade.cart_add"
| "trade.order_submit"
| "trade.order_pay"
| "trade.order_refund";
export type FieldType = "string" | "int" | "bool" | "enum";
export interface FieldSpec {
type: FieldType;
required: boolean;
values?: readonly string[];
deprecated?: boolean;
}
export type EventSchema = Record<string, FieldSpec>;
export interface TradeEvent {
eventId: string;
name: TradeEventName;
schemaVersion: number;
occurredAt: number;
sessionId: string;
traceId: string;
orderId?: string;
source: EventSource;
props: Record<string, unknown>;
}
export interface ValidationResult {
ok: boolean;
errors: string[];
warnings: string[];
}
const NAME_PATTERN = /^[a-z][a-z0-9]*\.[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
const ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
export const EVENT_SCHEMAS: Record<TradeEventName, EventSchema> = {
"trade.product_impression": {
skuId: { type: "string", required: true },
page: { type: "string", required: true },
position: { type: "int", required: true },
campaignId: { type: "string", required: false },
},
"trade.product_click": {
skuId: { type: "string", required: true },
page: { type: "string", required: true },
position: { type: "int", required: true },
},
"trade.cart_add": {
skuId: { type: "string", required: true },
quantity: { type: "int", required: true },
sourcePage: { type: "string", required: true },
},
"trade.order_submit": {
amountCents: { type: "int", required: true },
itemCount: { type: "int", required: true },
couponId: { type: "string", required: false, deprecated: true },
},
"trade.order_pay": {
amountCents: { type: "int", required: true },
payId: { type: "string", required: true },
channel: { type: "enum", required: true, values: ["wechat", "alipay", "card"] },
},
"trade.order_refund": {
refundId: { type: "string", required: true },
amountCents: { type: "int", required: true },
reason: {
type: "enum",
required: true,
values: ["quality", "logistics", "wrong_item", "other"],
},
},
};
const ORDER_EVENTS: readonly TradeEventName[] = [
"trade.order_submit",
"trade.order_pay",
"trade.order_refund",
];
export function isValidEventName(name: string): boolean {
return NAME_PATTERN.test(name);
}
export function isKnownEvent(name: string): name is TradeEventName {
return Object.prototype.hasOwnProperty.call(EVENT_SCHEMAS, name);
}
export function isOrderEvent(name: string): boolean {
return ORDER_EVENTS.includes(name as TradeEventName);
}
function checkField(
key: string,
spec: FieldSpec,
value: unknown,
errors: string[],
warnings: string[],
): void {
if (value === undefined) {
if (spec.required) errors.push(`missing_required:${key}`);
return;
}
if (spec.deprecated) warnings.push(`deprecated:${key}`);
if (spec.type === "int") {
if (!Number.isSafeInteger(value)) errors.push(`type:${key}:int`);
} else if (spec.type === "bool") {
if (typeof value !== "boolean") errors.push(`type:${key}:bool`);
} else if (typeof value !== "string" || value.trim() === "") {
errors.push(`type:${key}:string`);
} else if (spec.type === "enum" && !(spec.values ?? []).includes(value)) {
errors.push(`enum:${key}:${value}`);
}
}
export function validateEvent(event: TradeEvent, now?: number): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!ID_PATTERN.test(event.eventId)) errors.push("event_id:format");
if (!isValidEventName(event.name)) {
errors.push("name:format");
} else if (!isKnownEvent(event.name)) {
errors.push(`name:unknown:${event.name}`);
return { ok: false, errors, warnings };
}
if (event.schemaVersion !== SCHEMA_VERSION) {
errors.push(`schema_version:${event.schemaVersion}!=${SCHEMA_VERSION}`);
}
if (!Number.isSafeInteger(event.occurredAt) || event.occurredAt <= 0) {
errors.push("occurred_at:invalid");
} else if (now !== undefined) {
if (event.occurredAt - now > CLOCK_SKEW_MS) errors.push("occurred_at:future");
if (now - event.occurredAt > MAX_EVENT_AGE_MS) warnings.push("occurred_at:stale");
}
if (event.sessionId.trim() === "") errors.push("session_id:empty");
if (event.traceId.trim() === "") errors.push("trace_id:empty");
if (isOrderEvent(event.name)) {
if (typeof event.orderId !== "string" || event.orderId.trim() === "") {
errors.push("order_id:required");
}
} else if (event.orderId !== undefined && event.orderId.trim() === "") {
errors.push("order_id:empty");
}
const schema = EVENT_SCHEMAS[event.name];
const props = event.props;
if (props === null || typeof props !== "object" || Array.isArray(props)) {
errors.push("props:not_object");
return { ok: errors.length === 0, errors, warnings };
}
for (const key of Object.keys(schema)) {
checkField(key, schema[key], props[key], errors, warnings);
}
for (const key of Object.keys(props)) {
if (!Object.prototype.hasOwnProperty.call(schema, key)) {
warnings.push(`unknown_field:${key}`);
}
}
return { ok: errors.length === 0, errors, warnings };
}
export interface TraceIssue {
traceId: string;
kind: "order_conflict" | "missing_submit" | "session_conflict";
detail: string;
}
export interface TraceReport {
traces: number;
issues: TraceIssue[];
}
export function linkTrace(events: TradeEvent[]): TraceReport {
const groups = new Map<string, TradeEvent[]>();
for (const event of events) {
const list = groups.get(event.traceId);
if (list === undefined) groups.set(event.traceId, [event]);
else list.push(event);
}
const issues: TraceIssue[] = [];
for (const [traceId, list] of groups) {
const sessions = new Set(list.map((event) => event.sessionId));
if (sessions.size > 1) {
issues.push({
traceId,
kind: "session_conflict",
detail: `sessions=${[...sessions].join(",")}`,
});
}
const orders = new Set(
list
.map((event) => event.orderId)
.filter((id): id is string => typeof id === "string" && id.trim() !== ""),
);
if (orders.size > 1) {
issues.push({
traceId,
kind: "order_conflict",
detail: `orders=${[...orders].join(",")}`,
});
}
const hasOrderEvent = list.some((event) => isOrderEvent(event.name));
const hasSubmit = list.some((event) => event.name === "trade.order_submit");
if (hasOrderEvent && !hasSubmit) {
issues.push({ traceId, kind: "missing_submit", detail: "没有 order_submit 起点" });
}
}
return { traces: groups.size, issues };
}
export interface SchemaDiff {
breaking: string[];
compatible: string[];
}
export function diffSchemas(previous: EventSchema, next: EventSchema): SchemaDiff {
const breaking: string[] = [];
const compatible: string[] = [];
for (const key of Object.keys(previous)) {
const before = previous[key];
const after = next[key];
if (after === undefined) {
breaking.push(`removed:${key}`);
continue;
}
if (before.type !== after.type) {
breaking.push(`type:${key}`);
continue;
}
if (!before.required && after.required) breaking.push(`required:${key}`);
if (before.type === "enum" && after.type === "enum") {
const afterValues = after.values ?? [];
const removed = (before.values ?? []).filter((value) => !afterValues.includes(value));
if (removed.length > 0) breaking.push(`enum_removed:${key}:${removed.join(",")}`);
const added = afterValues.filter((value) => !(before.values ?? []).includes(value));
if (added.length > 0) compatible.push(`enum_added:${key}`);
}
if (!before.deprecated && after.deprecated) compatible.push(`deprecated:${key}`);
}
for (const key of Object.keys(next)) {
if (previous[key] !== undefined) continue;
if (next[key].required) breaking.push(`added_required:${key}`);
else compatible.push(`added_optional:${key}`);
}
return { breaking, compatible };
}
export function requiresVersionBump(diff: SchemaDiff): boolean {
return diff.breaking.length > 0;
}
三个函数的分工很清楚:
validateEvent把问题分成errors与warnings。缺必填字段、类型不对、版本不匹配是错误;未知字段、已废弃字段只给警告,避免旧端一次升级就被全量拒绝。linkTrace按traceId分组,检查同一链路里是否出现多个orderId、是否跨了会话、是否有支付却没有提交起点。它不修改事件,只报告问题。diffSchemas比较两份 schema,把变更分成破坏性与兼容两类。requiresVersionBump直接回答「这次改动要不要升版本」。
测试与真实输出
本机运行结果如下:
$ node --test test/analytics/event-contract.test.ts
✔ 事件名必须是命名空间加下划线动作 (0.696708ms)
✔ 注册表覆盖六类交易事件 (0.09025ms)
✔ 规范曝光事件通过校验 (0.5655ms)
✔ 缺失必填字段进入错误列表 (0.094417ms)
✔ 枚举值不合法时给出字段级错误 (0.0965ms)
✔ 未声明字段只产生警告 (0.071459ms)
✔ 已废弃字段可用但产生迁移警告 (0.083334ms)
✔ schema 版本不匹配时拒绝 (0.109167ms)
✔ 时间戳超前于本地时钟时拒绝 (0.082167ms)
✔ 超过保留期的事件给过期警告 (0.112584ms)
✔ 订单事件缺少 orderId 时拒绝 (0.068416ms)
✔ 同一 trace 内订单号冲突被发现 (0.170542ms)
✔ trace 中只有支付没有提交时报告缺起点 (0.061583ms)
✔ 同一 trace 跨会话被报告 (0.056417ms)
✔ 完整下单支付链路没有串联问题 (0.054666ms)
✔ schema 对比把删除、改类型、新增必填标为 breaking (0.143667ms)
✔ 新增选填字段与标记废弃是兼容变更 (0.075166ms)
ℹ tests 17
ℹ suites 0
ℹ pass 17
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 212.073083
十七条用例覆盖:命名格式、注册表、规范事件、缺必填、枚举、未知字段、废弃字段、版本、未来时间、过期时间、订单号必填、订单冲突、缺提交起点、跨会话、完整链路、两类 schema 差异。
一次「同名事件两个口径」的复盘
某交易客户端有一个沿用很久的事件叫 pay_success。商详页的「立即购买」在拉起支付渠道后上报一次,支付回跳页再上报一次,两个团队各取自己关心的那一条,于是同一个事件有了两个分母。
复盘发现三个叠加问题:
- 没有来源字段。渠道回跳被当成服务端确认,端上把「不确定」写成了成功。
- 没有稳定
eventId。同一笔支付生成两个 id,服务端去重失效,GMV 被重复累加。 - 没有版本闸门。老版本端继续上报旧字段,新报表按新 schema 解析,缺字段的行被静默丢掉。
修复分三步:把 pay_success 拆成 trade.order_pay(端上发起)与服务端支付确认两条来源明确的事件;给每条事件生成稳定的 eventId,重试复用;在采集入口校验 schemaVersion,不匹配的事件进入隔离表而不是直接丢弃。
端上能保证与不能保证的
端上能保证三件事:事件在产生时带上完整信封;同一业务动作复用同一个 eventId;订单类事件在上报时带上已知的 orderId 与 traceId。但事件是否送达、是否等于服务端事实,端上无法保证。
小结
交易埋点的起点不是「多打几个点」,而是一份能被机器校验的契约:六类事件、必填字段、稳定命名、显式版本、可串联的 orderId 与 traceId,以及区分客户端与服务器来源的标记。
validateEvent 守住字段,linkTrace 守住链路,diffSchemas 守住演进。三者都很小,但它们让「这条数据能不能用」从口头争论变成一次函数调用。
阅读导航:上一节:11.3 工单会话的离线与恢复 · 下一节:12.2 上报队列、离线缓存与去重 。
常见错误与边界
- 同一业务动作每次重试都生成新的
eventId,服务端去重完全失效。 - 端上把渠道回跳写成支付成功,来源标记仍是
client,下游却当服务端事实使用。 - 事件名用驼峰或缺少命名空间,两个团队的同名事件在报表里被合并。
- 删除字段或改类型却不升
schemaVersion,新旧端数据混在一起无法回溯。 - 订单类事件不带
orderId,支付与退款无法挂回同一笔订单。 traceId在每个页面重新生成,一次下单旅程被拆成多条互不相干的链路。- 把未知字段当错误直接拒收,老端升级期间整批事件丢失。
- 校验只做类型不做枚举,渠道名写错后报表里多出一个不存在的支付方式。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。