3、数据采集层:用户行为日志采集(埋点)、消息队列选型(Kafka)、数据格式与序列化(Avro/Protobuf)

数据采集层,说白了就是推荐系统的「眼睛」和「耳朵」。

用户点了什么、看了多久、滑到了哪里、最终有没有下单——这些原始行为数据,全得靠这一层老老实实地收上来。我见过不少团队,算法模型搞得花里胡哨,结果底层数据不准,最后全白搭。所以这一章,咱们把埋点、消息队列、序列化这三块掰开揉碎讲清楚。

3.1 用户行为日志采集:埋点设计

埋点,就是你在代码里「钉钉子」——在用户发生关键行为的地方,把事件信息记录下来。我个人习惯把埋点分成三类:

类型 触发方式 典型场景 数据量级
曝光埋点 元素进入可视区域 商品卡片展示、推荐位曝光 极高(百万级/分钟)
交互埋点 用户主动操作 点击、收藏、加购、分享 中(万级/分钟)
业务埋点 后端服务回调 下单成功、支付完成、退款 低(千级/分钟)

这里有个坑,我踩过不止一次——曝光埋点千万别在服务端做。为什么?因为服务端根本不知道用户「真正看到了什么」。你返回了10个商品,用户可能只看了前3个就划走了。曝光埋点必须在客户端,用IntersectionObserver或者类似机制,等元素真正进入视口再上报。

我曾经踩过的坑: 有个项目把曝光埋点做在了服务端,用户每次请求推荐接口就算一次曝光。结果数据严重虚高,模型训练出来的CTR预估直接偏了30%。后来花了整整两周重跑数据,才把模型拉回来。

埋点数据长什么样?我一般用统一的事件模型:

{
  "event_id": "uuid-xxxx",          // 全局唯一ID,用于去重
  "event_type": "item_click",       // 事件类型
  "timestamp": 1712345678000,       // 客户端时间戳(毫秒)
  "user_id": "u_10086",             // 用户标识
  "device_id": "d_abc123",          // 设备标识
  "page_id": "homepage_recommend",  // 页面标识
  "item_id": "sku_9527",            // 目标对象ID
  "extra": {                        // 扩展字段,按需填充
    "position": 3,                  // 在推荐列表中的位置
    "duration_ms": 2500,            // 停留时长
    "source": "personalized"        // 推荐来源
  }
}

嗯,这里要注意:extra字段一定要设计成Map结构,别把业务字段写死在schema里。推荐场景的业务属性变化太快,今天加个「是否秒杀」,明天加个「直播间ID」,你总不能天天改协议吧?

3.2 消息队列选型:为什么是Kafka?

埋点数据收上来了,直接写数据库?别闹。推荐系统的数据量,峰值每秒几十万甚至上百万条,数据库根本扛不住。这时候就需要一个「缓冲池」——消息队列。

市面上消息队列不少,RabbitMQ、RocketMQ、Pulsar、Kafka……我为什么选Kafka?

核心原因就三个:

  • 吞吐量天花板高:顺序读写磁盘,单机轻松扛10万+/秒
  • 数据持久化:消息落盘,不怕丢数据。我习惯保留7天,方便回溯
  • 消费位移管理:消费者挂了重启,能从断点继续消费,不丢不重

你想想看,推荐场景里最怕什么?最怕数据丢失。用户点了一个商品,结果没记录下来,那模型就永远学不到这个正样本。Kafka的ACK机制和ISR副本同步,能保证数据不丢——当然,前提是你配置对了。

我一般这样配置生产环境:

# 关键参数配置
acks=all                    # 所有副本确认才算成功
min.insync.replicas=2       # 至少2个副本同步
retention.ms=604800000      # 保留7天
compression.type=snappy     # 启用压缩,减少网络带宽
batch.size=65536            # 64KB批量发送
linger.ms=10                # 延迟10ms攒批
一个小技巧: 埋点数据对实时性要求没那么苛刻(延迟几秒完全OK),所以我会把linger.ms设到10-20ms,让Kafka攒一批再发。这样吞吐量能提升3-5倍,CPU开销反而更低。

Topic怎么设计?我习惯按业务域拆分:

  • user_behavior_exposure — 曝光事件
  • user_behavior_click — 点击事件
  • user_behavior_conversion — 转化事件(加购、下单)
  • user_behavior_custom — 自定义事件(AB实验、特殊标记)

分区数怎么定?一个经验公式:分区数 = max(生产者并发数, 消费者并发数)。我一般按消费者线程数的2倍来设,留点余量。比如消费者线程是8个,分区就设16个。

3.3 数据格式与序列化:Avro还是Protobuf?

数据进了Kafka,用什么格式存?JSON?别,千万别。JSON的解析开销大、体积大、没有强类型约束。生产环境里,我从来不用JSON做内部数据传输。

主流选择就两个:Avro 和 Protobuf。我两个都用过,说说我的感受。

对比维度 Avro Protobuf
Schema定义 JSON格式,可读性强 .proto文件,需要编译
序列化性能 中等,但无需生成代码 高,需要预编译
Schema演进 天生支持,兼容性好 需要手动管理字段编号
Kafka集成 原生支持,Confluent Schema Registry 需要额外适配
数据体积 小(无字段名) 极小(变长编码)

我个人习惯:推荐系统内部用Avro。为什么?因为推荐场景的Schema变化太频繁了。今天加个字段,明天改个类型,Avro的Schema Registry能自动处理兼容性,不用改代码。Protobuf虽然性能更好,但每次改字段都要重新编译、发版,太折腾。

来看一个Avro Schema的例子:

{
  "namespace": "com.recommend.schema",
  "type": "record",
  "name": "UserBehaviorEvent",
  "fields": [
    {"name": "event_id", "type": "string"},
    {"name": "event_type", "type": "string"},
    {"name": "timestamp", "type": "long"},
    {"name": "user_id", "type": "string"},
    {"name": "device_id", "type": "string"},
    {"name": "page_id", "type": "string"},
    {"name": "item_id", "type": "string"},
    {"name": "extra", "type": {"type": "map", "values": "string"}, "default": {}},
    {"name": "server_time", "type": ["null", "long"], "default": null}
  ]
}

注意看server_time字段——我用了["null", "long"]的联合类型,默认值是null。这是Avro Schema演进的标准做法:新加的字段必须要有默认值,否则老数据反序列化会报错。

我曾经踩过的坑: 有次上线新版本,给事件模型加了个experiment_id字段,忘了设默认值。结果老数据全部反序列化失败,实时特征管道断了整整40分钟。从那以后,我定了个铁律:所有新增字段必须给默认值,且必须经过兼容性检查。

序列化之后的数据,配合Confluent Schema Registry使用,效果最佳。生产者只发数据的二进制内容,Schema ID放在前4个字节。消费者拿到数据后,根据Schema ID去Registry拉取对应的Schema,自动反序列化。这样,改Schema不用重启任何服务——只要保证向前兼容就行。

3.4 整体架构:一张图说清楚

讲了这么多,咱们用一张图把数据采集层的全貌串起来:

数据采集层整体架构 客户端埋点SDK 曝光 / 点击 / 自定义事件 服务端业务埋点 下单 / 支付 / 退款 Avro序列化 Avro序列化 Kafka 集群 Topic: user_behavior_* 分区: 16 / 32 副本: 3 保留: 7天 压缩: Snappy Schema Registry 实时特征计算 Flink / Spark Streaming 离线数仓导入 HDFS / Hive / Iceberg 数据质量监控 延迟 / 丢失 / 异常检测 说明: 客户端和服务端埋点数据统一使用Avro序列化,通过Kafka解耦生产与消费。 Schema Registry管理版本兼容性,消费者按需拉取Schema,实现零停机Schema演进。

这张图里,我特别想强调一个点:Schema Registry不是可选项,是必需品。没有它,Avro的Schema演进优势就发挥不出来。你想想看,几十个微服务、几百个消费者,如果每次改Schema都要手动同步,那运维成本得有多高?

3.5 避坑指南:数据采集层的三个常见问题

最后,分享几个我实战中踩过的坑,希望能帮你少走弯路。

问题一:客户端时间戳不准
用户手机时间可能不准,甚至被恶意篡改。我的做法是:客户端和服务端各记一个时间戳。客户端时间用于计算「用户停留时长」这类相对值,服务端时间用于数据排序和窗口计算。两个时间戳差值超过5分钟的,直接标记为异常数据。
问题二:Kafka消息积压
有次大促,流量突增10倍,消费者来不及消费,消息积压了上亿条。后来我加了动态扩缩容机制:监控消费延迟,超过阈值自动增加消费者线程数。同时,Kafka的max.poll.records不要设太大,我一般控制在500-1000,防止单次拉取太多导致处理超时。
问题三:序列化兼容性断裂
前面提过,新增字段必须给默认值。还有一个容易忽略的点:不要删除字段。如果你觉得某个字段没用了,标记为deprecated就行,别删。删了字段,老数据反序列化直接报错。实在要删,等所有消费者都升级完了再动手。

数据采集层,看起来就是「收数据、传数据、存数据」三个步骤,但每个环节都有不少细节。埋点不准,后面全白干;消息队列选错,性能瓶颈卡死你;序列化没选好,改个字段要发版三天。把这些基础打扎实了,上层才能盖得稳。