酷家乐客户数据平台之离线人群技术解析

349 阅读9分钟

1、建设背景

客户数据平台(CDP)通过从多个来源集成客户数据,包括客户与企业的产品服务进行交互、行为、触点数据,例如行为数据(如点击次数)、交易数据(如购买次数)以及客户基本信息等,从而将客户数据整合到统一的视图中。企业可以使用 CDP 平台上的客户信息来了解客户偏好,指导个性化的营销活动、内容,并针对性地优化用户体验。

酷家乐从 2019 年开始探索 CDP 平台的建设,大数据团队从最基础的离线人群管理功能开始,逐步建设起来离线标签管理、实时人群管理、客户营销平台等工具,近两年围绕客户群体洞察、客户画像、CDP 稳定性和成本等方向持续进行改进。其中离线人群作为目前使用量非常活跃、且是公司核心业务链路中强依赖的一个环节,对于公司的运营体系中发挥重要作用,本文会围绕离线人群的建设展开说明。

CDP 平台业务生态

酷家乐 CDP 平台的整体业务架构如上图所示,从客户数据来源上看, CDP 平台可以用到客户数据有三大类

  • 客户属性,例如客户注册省份、性别、年龄等常规信息
  • 埋点数据,通过酷家乐产品上的功能埋点进行收集,例如客户访问的页面、客户执行的动作等
  • 已有的业务标签表,历史上已存在业务数据库里的一些用户标签

首先,标签化会将这几类基础数据进行加工,进行逻辑组合而形成的带业务属性的标签,完成对客户打标。标签除了给上面的人群提供筛选能力之外,也为应用层模块,比如人群画像、间接用户画像中提供多种分析维度。

然后,人群层核心能力是提供通用的人群圈选能力,利用已有的标签、用户行为、子人群等数据,结合规则引擎,计算出所有符合筛选条件的客户。另外,还负责更新需要每日更新的离线人群(T+1更新),并保证离线人群计算的稳定性。

应用层负责给精准营销提供必备的能力,并提供人群洞察、间接用户画像等通用分析能力。

离线人群核心使用场景

在 CDP 的业务架构模型中,离线人群对精准营销发挥至关重要的作用,人群暴露的能力包括:

  • 用户检查:判断当前登录用户是否在给定的离线人群中
  • 人群导出:获取某个离线人群全量用户
  • 交并差集:多个离线人群之间交集、并集、差集的结果

其中第一个是最高频使用的场景。一般在运营项目发起之后,运营人员在 CDP 平台上提前圈选出一些运营人群,然后在营销平台将运营活动和运营人群进行绑定。当用户访问了酷家乐的某个页面,相关应用获取到运营配置之后,需要去判断当前登录用户是否在特定的运营人群中,来决定给用户呈现不同的活动效果,从而实现将特定功能定向推送给这部分人群。

下文会围绕这个高频使用场景,重点介绍离线人群的架构和一些技术优化。

2、核心架构

存储架构

关键组件说明

  • 业务 mysql 库:维护离线人群本身的元数据,包括人群的类型、规则配置、更新状态等基础信息
  • kyuubi:执行人群计算的查询和存储引擎,并提供人群全量用户导出、交并差集等非实时查询的计算
  • 数仓 Hive 表:存储基础数据包括用户埋点、标签等物料表,人群计算的结果(离线人群表)也存储在 Hive 上
  • 离线计算平台:负责维护人群表与用户行为、标签等物料表的上下游依赖关系,通过定时和事件驱动来调度人群每日动态更新
  • 同步任务:定时调度 k8s Job,将 Hive 表上已完成的人群计算结果同步到查询 redis 中
  • 查询 redis:存储每天用户和离线人群的映射关系,用来加速用户检查的查询

查询架构

目前离线人群「判断当前登录用户是否在给定的离线人群中」在高峰期流量非常高,且处在公司核心链路上。为了保障查询的吞吐量,查询耗时的要求控制在 10ms 内返回,耗时过长可能会影响部分页面的加载速度。初步估算一下查询规模,以 10000 个离线人群、平均每个人群 10000 个用户为例,那么离线人群的 Hive 表每天的分区至少有亿级的数据。如果实时地按照 user_id 过滤聚合得到 group_id 列表,即使用 kyuubi 这样高效的查询引擎,实际的查询性能也很难满足公司在线业务的要求。

本质上,这种查询属于在海量数据中做单点检查,并不是一种分析型的数据处理需求。因此,对这个查询场景单独按照事务查询模式的进行建模,将人群数据同步到 redis 这种高效的查询存储上,如下所示

核心思路是:在每天的离线人群计算成功之后,将人群数据转换成 user_id 和 group_id 列表的模型,然后存储在 redis 上。考虑到用户数量庞大,为了避免突破单个 Redis 实例的 key 数量限制,选择将 user_id 进行分片,然后将分片值、user_id、group_id 列表以 hash 结构存储在 redis 上。压缩 key 的数量也能减少出现 rehash 的情况。

/**
 * @param jedis redis client
 * @param keyValues 包含 user_id group_is 列表的键值对
 * @param shadingds 分片数量
 * @throws TpLoadException
 */
private void write(Jedis jedis, String[] keyValues, int shadingds, int maxRetry) throws TpLoadException {
    Preconditions.checkArgument(keyValues.length % 2 == 0, "keyValues length must be even.");
    for (int i = 0; i < maxRetry; i++) {
        Pipeline pipeline = null;
        try {
            pipeline = jedis.pipelined();
            pipeline.multi();
            for (int j = 0; j < keyValues.length; j += 2) {
                String userId = keyValues[j];
                String groupIds = keyValues[j + 1];
                long shardingKey = (Long.parseLong(userId) % shadingds);
                pipeline.hset(Long.toString(shardingKey), userId, groupIds);
            }
            pipeline.exec();
            LOG.info("sync to redis success, accumulate user count: " + count.addAndGet(keyValues.length / 2));
            return;
        } finally {
            if (pipeline != null) {
                try {
                    pipeline.close();
                } catch (IOException ignore) {
                    LOG.error("close pipeline error, ignore it.", ignore);
                }
            }
        }
    }
    throw new TpLoadException("sync to redis faield, exceed max retry count.");
}

在用户检查的查询流程中,给定 user_id 计算分片值之后,用分片值、user_id 就可以拿到当前用户处在的所有离线人群列表,然后在内存中和给定的人群做交集并返回,就可以返回给上游业务使用。

3、其他优化

计算准确性保障

在实际的离线计算场景下,因为底层资源调度等因素偶尔会出现一些物料表加工出现延迟,如果在上游没有加工好的情况下就开始了离线人群的计算,那就会出现逻辑上的错误。而对于离线人群,计算的准确性是这个业务的生命线,可以出现一定的延迟,但不能容忍计算出现逻辑上的错误。因此,对于单个人群来说,要解决下面两种依赖关系,才能保证人群计算准确无误。

  • 一是依赖的上游物料表必须加工完成之后,才能触发下游人群开始计算
  • 二是人群本身存在依赖关系,只有被依赖的上游人群计算完成之后,才能触发下游人群开始计算

处理第一种依赖关系的方案是,在离线计算平台上,通过配置 Hive 表级别的依赖关系来构建人群表和上游物料表的依赖,这样在每日凌晨物料表加工完之后,通过离线计算平台的调度能力,当人群表的上游全部加工完成之后,自动触发该人群执行计算逻辑,从而达到稳定计算的效果。

处理第二种依赖关系的方案是,如果每个人群都已经在数仓上单独创建了一张 Hive 表,那么这个问题也可以通过第一个方案来实现。但因为历史原因,当前所有人群全部写在一张 Hive 表上,因此在数仓层面解决依赖关系比较困难。

解决办法是在人群业务层面处理依赖关系,核心思路是:构造人群之间的有向无环图 DAG,从出度为 0 的人群(依赖的最底层)开始计算,然后逐级往上,直到计算到入度为 0 的人群(依赖的最顶层)上,如果所有入度为 0 的人群都已经计算成功,则更新逻辑结束。实际实现步骤如下:

  • 遍历所有离线人群,将当前人群及其直接依赖的子人群构造成 key-value 列表。
    • 单个人群有多个依赖则有多条 key-value 记录
    • 如果没有子人群依赖,value 置为空
  • 循环遍历 key-value 列表,按照 key 聚合,找到当前人群及其直接依赖的所有子人群
    • 当前人群的计算状态进行中、成功,跳过该人群
    • 如果所有子人群为空,或者所有子人群都计算成功,则异步触发当前人群开始计算,标记状态进行中
    • 否则,跳过该人群
  • 人群计算完成之后,自动更新 key-value 列表中人群的计算状态

除了依赖离线计算平台的依赖管理能力来做事件驱动,还有两个方式保证计算的稳定性:

  • 业务上有一层兜底判断,在单个人群计算之前再增加一轮校验,检查 Hive 中上游物料表、人群表的最新分区时间,来判断这些上游是否已经计算完成。如果完成,则正常执行该人群更新流程,否则,标记当前人群计算失败,等待重新进行计算。

  • 如果每天全量计算出现了超过 N 次失败(超过 1 个人群未更新),则通过业务错误日志上报到监控系统,通过警报发送给人群业务的负责人,通知人员及时介入处理。

综合来看,通过两套依赖关系处理、兜底检查、业务警报这三种办法组合,已经有效地保障了离线人群计算的准确性,最近半年已经没有出现过业务线反馈人群计算错误的情况。虽然牺牲了一部分计算准时性,不过离线人群本身属于离线业务,对准时性的要求没有很高。而且通过构造依赖关系来形成事件驱动的方式,只要上游物料表加工完成之后,就可以自动驱动下游人群表计算,正常情况下无需人工干预就可以达到最终一致。

人群表数据同步

将离线人群 Hive 表数据同步到 redis,本质上属于将 Hive 表全量数据导出,Hive 提供的三种导出方式包括本地文件、HDFS、其他表的方案都不满足实际的需求。另外,离线人群单个分区数据量很大,不能直接拉取全表数据然后导出外部,考虑用流的方式不断地迭代写入到外部存储中。

参考外部数据同步工具对 Hive 表导出方案,思路是对 Hive 表的分区中的存储文件进行解析,实现用迭代的模式来逐行读取数据。当前离线人群 Hive 表使用 ORC 文件存储,我们在解析 orcfile 上做了一些精简,如下所示

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.hive.ql.io.orc.OrcInputFormat;
import org.apache.hadoop.hive.ql.io.orc.OrcSerde;
import org.apache.hadoop.hive.serde2.objectinspector.StructField;
import org.apache.hadoop.hive.serde2.objectinspector.StructObjectInspector;
import org.apache.hadoop.mapred.*;
 
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
 
public class OrcUtil {
 
    private static final int MAX_BATCH_SIZE = 5000;
    private static final String HDFS_DEFAULT_FS_KEY = "fs.defaultFS";
    private static final String DEFAULT_FS = "hdfs://xxx";
 
    /**
     * 解析 ORC 文件数据,分批次导出
     * @param ds
     * @return
     */
    public boolean readOrcFileAndSync(String ds) {
        Configuration hadoopConf = new Configuration();
        hadoopConf.set(HDFS_DEFAULT_FS_KEY, DEFAULT_FS);
        // 其他 nameservices、namenode 配置
        hadoopConf.set("dfs.nameservices", "xxx");
        JobConf jobConf = new JobConf(hadoopConf);
 
        String sourceOrcFilePath = "/user/hive/warehouse/${project}/${table}/ds=" + ds;
        Path orcFilePath = new Path(sourceOrcFilePath);
 
        try {
            StructObjectInspector inspector = this.getStructObjectInspector(jobConf);
            InputFormat<?, ?> inputFormat = new OrcInputFormat();
            FileInputFormat.setInputPaths(jobConf, orcFilePath.toString());
 
            InputSplit[] splits = inputFormat.getSplits(jobConf, -1);
            for (InputSplit inputSplit : splits) {
                try (RecordReader reader = inputFormat.getRecordReader(inputSplit, jobConf, Reporter.NULL)) {
                    Object key = reader.createKey();
                    Object value = reader.createValue();
                    List<? extends StructField> fields = inspector.getAllStructFieldRefs();
 
                    int offset = 0;
                    String[] keyValues = new String[MAX_BATCH_SIZE * 2];
                    while (reader.next(key, value)) {
                        keyValues[offset * 2] = inspector.getStructFieldData(value, fields.get(0)).toString();
                        keyValues[offset * 2 + 1] = inspector.getStructFieldData(value, fields.get(1)).toString();
                        offset++;
                        if (offset >= MAX_BATCH_SIZE) {
                            write(keyValues);
                            offset = 0;
                            keyValues = new String[MAX_BATCH_SIZE * 2];
                        }
                    }
                    if (offset > 0) {
                        write(Arrays.copyOf(keyValues, offset * 2));
                    }
                }
            }
            return true;
        } catch (Exception e) {
            LOG.error("sync hive table failed.", e);
        }
        return false;
    }
 
 
    /**
     * 指定 hive 表要解析的列及字段类型
     * @param jobConf
     * @return
     */
    private StructObjectInspector getStructObjectInspector(JobConf jobConf) {
        Properties p = new Properties();
        p.setProperty("columns", "user_id, tag_ids");
        p.setProperty("columns.types", "string:string");
        OrcSerde orcSerde = new OrcSerde();
        orcSerde.initialize(jobConf, p);
        return (StructObjectInspector) orcSerde.getObjectInspector();
    }
 
}

使用 redis bitmap 优化存储和查询

上面的查询架构及数据模型是当前离线人群正在运行的模式,也存在几个稳定性的风险值得优化:

  • 将人群数据转换成 user_id 和 group_id 列表的模型,然后以 key-value 的形式存储在 redis 上,相当于把离线人群每天的分区数据全量导入到 redis 内存中。假设每天分区的大小是 30GB,那么使用的 redis 内存使用量也在 30GB 的规模
  • 如果某个用户所在的人群特别多,上述 hash 结构存在 big key 的可能,影响查询稳定性
  • 为了方便同步到 redis,离线人群表(group_id 和 user_id 的关系)要先转换成的 user_id 和 group_id 列表的关系,并写到另一个临时 Hive 表中,存在计算和存储资源消耗
  • 从实际查询需求统计,并不需要把用户所在的所有离线人群全部都查出来,极大部分都只需要查用户是否在某个人群中,或者在少量的人群中

因此,我们考虑将这个查询逻辑进行重构:对于给定的人群,查看当前登录用户是否在它的用户列表里面。假设在亿级用户规模的情况下,单个人群最大情况下人数会很多,不能直接把 group_id 和 user_id 列表直接存在 redis 里面,要先解决大量 user_id 存储的问题。

考虑对同步到 redis 中的数据结构进行调整,以 group_id 为主体,user_id 列表压缩到一个 bitmap 结构里,那么查询逻辑就转化成 user_id 是否在 bitmap 中。如果需要检查多个人群,同样可以采用 redis pipeline 模式进行批量查询。

通过分析实际查询需求,引入 redis bitmap 并调整存储模型,改进之后的优势非常明显

  • user_id 列表压缩在 bitmap 结构中,大幅降低 redis 内存使用量,同样规避了 big key 的可能
  • 查询效率更高,用户点查用 getbit 可以发挥 bitmap 结构查询的优势
  • 离线人群 Hive 表的数据模型和 redis 缓存中的数据模型保持一致,同步流程维护更简单,去掉了计算临时表的步骤,也节省了 Hive 上的计算和存储资源
  • 对于当天新建的增量人群,同步流程也非常简单

4、总结

本文介绍了酷家乐大数据团队建设的 CDP 平台的整体生态,重点介绍了面对特定的业务需求,如何对技术架构方案做的查询改造方案,并从稳定性的角度,说明了如何保证人群计算的准确性。在实际维护过程中,大数据团队发现并解决了各种问题,最终形成了目前这条技术体系,也已经稳定支持了业务的发展。尽管如此,这套体系里面仍然有较大的架构升级空间,包括:

  • 引入新的 olap 引擎,提高人群计算和查询的效率
  • 统一离线和实时人群的存储计算模式

笔者水平有限,未能将 CDP 离线人群体系的各种细节介绍清楚,如果有对里面的细节感兴趣或者发现错误的地方,或者有更先进的技术方案,欢迎大家在下面留言。