1. 缓存数据结构设计:事件模型、存储字段、序列化格式

埋点数据在设计缓存层之前,必须首先定义统一的事件模型与存储结构。数据结构直接影响序列化效率、存储空间、查询能力和容错恢复。本章围绕三个核心层次展开:事件模型(Event Model)定义业务语义,存储字段(Storage Fields)定义持久化视图,序列化格式(Serialization Format)定义字节流协定。

1.1 事件模型

一个最小完备的事件模型应包含以下五类信息:

  • 标识层:事件唯一 ID(UUID / Snowflake)、应用版本、用户标识(ID / 匿名 ID)。
  • 时间层:客户端时间戳(毫秒)、采集时间戳、事件产生序号。
  • 类型层:事件分类(click / view / purchase / applog)、业务子类型。
  • 属性层:键值对(string / number / bool / nested object),支持 JSON Schema 校验。
  • 上下文层:设备信息、屏幕分辨率、网络状态、位置等预定义字段。
// 伪代码结构
Event {
  eventId: string;          // 全局唯一
  eventType: string;        // "page_view"
  timestamp: number;        // 客户端产生时的 unix ms
  userId: string | null;
  anonymousId: string;
  properties: Record<string, any>;
  context: {
    deviceModel: string;
    osVersion: string;
    networkType: "wifi" | "4g" | "5g";
    screenWidth: number;
    screenHeight: number;
  };
}
设计要点:属性层尽量使用扁平化键值对避免深层嵌套,以便后续在序列化前进行压缩。上下文层建议预定义固定字段集合以提升缓存规整度。

1.2 缓存存储字段

缓存层不直接存储原始事件对象,而是将事件模型与运维状态合并为一个 存储记录(Storage Record)。每个记录包含以下字段:

字段类型说明
record_idUUID缓存记录主键,与事件 ID 1:1 对应
event_typestring冗余存储,用于按类型过滤未上报事件
client_tsint64客户端时间戳,排序与去重依据
payloadbytes序列化后的事件二进制数据(见 1.3)
statusint80=pending, 1=in‑flight, 2=failed, 3=success
retry_countint8当前重试次数,上限 5 次
next_retry_tsint64下次允许上报的时间戳(指数退避)
created_atint64记录写入缓存的时间
-- SQLite 表定义示例
CREATE TABLE IF NOT EXISTS event_cache (
    record_id TEXT PRIMARY KEY,
    event_type TEXT NOT NULL,
    client_ts INTEGER NOT NULL,
    payload BLOB NOT NULL,
    status INTEGER DEFAULT 0,
    retry_count INTEGER DEFAULT 0,
    next_retry_ts INTEGER DEFAULT 0,
    created_at INTEGER DEFAULT (strftime('%s','now') * 1000)
);
CREATE INDEX idx_status ON event_cache(status);
CREATE INDEX idx_next_retry ON event_cache(next_retry_ts);

1.3 序列化格式

缓存中的 payload 字段存储序列化后的事件对象。推荐使用 二进制 JSON(BSON / MessagePack / FlatBuffers) 或 压缩后的 UTF‑8 JSON。以下为使用 gzip + JSON 的方案:

// 序列化流程(伪代码)
function serialize(event: Event): Uint8Array {
    const json = JSON.stringify(event);          // 编码为 UTF‑8
    const compressed = pako.gzip(json, { level: 6 }); // 压缩级别 6
    return compressed;
}

// 反序列化
function deserialize(bytes: Uint8Array): Event {
    const decompressed = pako.ungzip(bytes);
    return JSON.parse(new TextDecoder().decode(decompressed));
}
推荐实践:生产环境中建议在序列化前对 properties 进行 字段排序(按 key 字典序),使相同事件的字节流完全一致,利于后续去重与差异压缩。

1.4 缓存数据结构关系图

缓存数据结构关系 事件模型 eventId, type timestamp, properties 缓存记录字段 record_id, status payload, retry_count 序列化格式 gzip + JSON → BLOB 本地存储引擎 SQLite / IndexedDB redis (跨进程) 设计原则 payload 自包含、字段冗余用于 快速状态查询与重试调度

1.5 常见错误与避免建议

  • 直接存储原始 JSON 字符串:无压缩会浪费 40%~60% IO 带宽,尤其在高频事件场景下影响电池与磁盘寿命。
  • 漏写 status 索引:导致每次上报遍历全表,客户端卡顿。必须为 status / next_retry_ts 建立复合索引。
  • 序列化时修改字段类型:例如将 number 转成 string 存入属性,引起上游解析报错。要保持 schema 严格一致。
  • 忽略 payload 最大尺寸:单条事件 payload 不应超过 64KB(含头部),否则应分包或丢弃。
性能拐点:当缓存记录超过 5000 条时,全表扫描 status=0 的查询耗时可能超过 100ms。建议在插入时同时维护一个 待上报计数缓冲(例如使用内存队列 + 定时批量更新状态),避免每次上报都读数据库。

1.6 调试与验证

推荐在开发阶段开启 payload 明文存储开关,便于直接查看事件内容。上生产前必须关闭明文存储,仅保留序列化后的 BLOB。

// 调试模式配置(仅在 DEBUG=true 时启用)
config: {
  payloadFormat: Uint8Array | "rawJson",
  debugStore: process.env.NODE_ENV === "development" ? "rawJson" : "binary"
}

使用 SQLite 原生工具验证缓存记录完整性:

# 进入数据库,查询前 5 条待上报记录
sqlite3 ~/.appdata/event_cache.db
SELECT record_id, event_type, length(payload), status FROM event_cache
WHERE status = 0
ORDER BY client_ts ASC
LIMIT 5;

通过比较 length(payload) 与预期压缩比值(通常 3:1~5:1),可快速发现序列化异常或压缩 bug。