1. 用户画像事实标签计算的整体设计思路
1.1 为什么事实标签是用户画像的“骨架”
做过用户画像系统的人都有一个共识:标签体系里最底层、最不能出错的就是事实标签。所谓事实标签,指的是直接从业务数据中统计出来、不需要复杂模型推断的标签,比如“近30天订单数”“累计消费金额”“最近一次登录距今天数”“是否绑定手机号”这类。它跟预测标签、规则标签最大的区别在于——事实标签的每一个值都能在原始数据里找到对应记录,可追溯、可复算、可解释。
我在实际项目里踩过最大的一个坑,就是早期把事实标签和统计报表混在一起做,结果标签口径天天变,运营拿着“高价值用户”名单去发券,发完发现名单里有一半是薅完羊毛就走的账号。后来复盘才明白,问题出在事实标签没有做“时间窗口”和“行为口径”的严格定义。所以这一篇我打算把事实标签计算这件事从头到尾拆一遍,包括整体架构怎么设计、核心指标怎么算、实操中会遇到哪些坑。
这套思路适合谁参考?如果你正在做一个用户画像系统,或者你手头有一堆订单表、日志表、行为埋点表,想把这些数据变成能直接给运营、产品用的标签,那这篇内容基本可以照着抄作业。哪怕你用的是离线数仓那一套,或者现在流行的湖仓一体方案,核心逻辑都是通的。
1.2 事实标签计算的三个核心原则
在动手写计算逻辑之前,我一般会先跟团队对齐三个原则,这三个原则决定了后面所有代码的写法。
第一个原则是口径唯一。同一个标签,在整个系统里只能有一个计算口径。比如“活跃用户”这个标签,如果A团队按“近7天有登录”算,B团队按“近7天有下单”算,那这个标签就是废的。我的做法是建一张标签元数据表,把每个标签的口径、时间窗口、更新频率、数据来源全部登记进去,计算任务只认这张表。
第二个原则是可回溯。事实标签算完之后,必须能回答“这个用户为什么被打上这个标签”。所以我在存储标签结果的时候,会同时存一份“标签值+计算时间+数据快照分区”。比如用户A在2024年6月1日被标记为“近30天消费5单”,那我要能查到这5单具体是哪5单。这个设计在排查数据问题时能救命。
第三个原则是增量优先。全量重算在数据量小的时候没问题,一旦用户量上千万、行为表上亿,全量跑一次可能要几个小时。所以事实标签的计算要尽量设计成增量模式,每天只算变化的部分,配合定期全量校准。这个后面会展开讲具体怎么做。
1.3 整体架构分层
我把事实标签计算分成四层,从下往上依次是:
- 数据接入层:负责把业务库的订单、用户、行为日志同步到数仓,通常是ODS层。这一层的关键是保证数据完整性和时效性,我一般用T+1的离线同步,对时效要求高的标签再加实时通道。
- 明细加工层:在DWD层把原始数据清洗成标准的事件明细,比如把订单表的“下单、支付、退款”拆成独立事件,把日志里的埋点解析成结构化字段。
- 标签计算层:在DWS层按用户维度做聚合,算出每个事实标签的原始值。这一层是核心,后面重点讲。
- 标签服务层:把算好的标签写入标签宽表或者KV存储,对外提供查询接口,供运营后台、推荐系统、营销系统调用。
这四层里,标签计算层最容易出问题,因为它涉及大量的聚合逻辑和时间窗口处理。下面我按标签类型分别拆解。
2. 核心事实标签的拆解与计算逻辑
2.1 消费类事实标签怎么算才准
消费类标签是电商、本地生活这类业务里最常用的一类,典型的有:累计消费金额、累计订单数、近30天消费金额、近30天订单数、客单价、最近一次消费距今天数(R值)、消费频次(F值)。
先说累计类标签。累计消费金额看起来简单,就是把用户所有已支付订单的金额加起来。但这里有几个坑:退款订单要不要扣减?部分退款怎么算?测试订单要不要排除?我的处理方式是,在DWD层就把订单状态标准化,只保留“已完成”和“已退款”两种终态,计算时用“已完成金额 - 已退款金额”作为净消费额。测试订单通过用户标签或者订单来源字段过滤掉。
近30天类标签的关键是时间窗口的滚动。我一般用“自然日滚动”而不是“精确到秒的30天”。比如今天是6月30日,那近30天就是6月1日到6月30日。这样做的好处是分区裁剪方便,每天只需要读最近30个分区,不用做复杂的日期计算。代价是月初和月末的窗口会有1天的误差,但对大多数业务场景来说可以接受。
客单价的计算要注意分母。客单价 = 消费金额 / 订单数,但这里的订单数应该是“有效订单数”,即至少有一笔支付的订单。如果用户下了单但没支付,这个订单不能算进分母。我见过有团队把未支付订单也算进去,结果客单价被拉低了一大截。
R值(最近一次消费距今天数)的计算有个细节:要用“当前日期 - 最近一次消费日期”,而不是“当前时间 - 最近一次消费时间”。因为大多数业务是按天统计的,用日期做差更稳定。如果业务需要精确到小时,那就要单独设计实时标签。
2.2 行为类事实标签的计算要点
行为类标签包括:近7天登录次数、近30天活跃天数、近30天浏览商品数、近30天收藏次数、近30天加购次数等。这类标签的数据来源通常是埋点日志,计算逻辑比消费类复杂,因为日志数据量大、格式不统一、容易丢。
第一个要点是去重。比如“近30天活跃天数”,同一天用户登录10次只能算1天。我一般用count(distinct dt)来实现,但要注意dt字段的格式要统一,不能有的带时分秒有的不带。
第二个要点是事件定义。什么叫“活跃”?是打开App就算,还是必须有点击行为?这个必须跟产品对齐。我的经验是,把“启动App”和“有效浏览”分开定义,启动算“登录”,有效浏览(停留超过3秒或者有点击)才算“活跃”。这样算出来的活跃用户质量更高。
第三个要点是日志清洗。埋点日志里经常有脏数据,比如设备时间不对、用户ID为空、事件类型拼写错误。我在DWD层会做几件事:过滤掉user_id为空的记录、把设备时间统一转换成服务器时间、对事件类型做枚举校验。这些清洗规则看起来琐碎,但不做的话后面标签全是错的。
行为类标签的增量计算有个技巧:因为日志是按天分区的,每天的新增日志只影响当天的行为,所以可以用“日增量+滑动窗口”的方式。具体来说,每天算一份当天的行为汇总,然后近30天的标签就是把最近30天的日汇总加起来。这样不用每次扫全量日志,速度快很多。
2.3 用户属性类标签的同步策略
用户属性类标签包括:性别、年龄、注册天数、是否绑定手机、是否实名认证、会员等级等。这类标签的特点是变化频率低,但一旦变化需要及时同步。
注册天数的计算是“当前日期 - 注册日期”,这个每天都会变,所以每天都要更新。我一般把它放在每日全量刷新的任务里,因为计算量不大。
会员等级这类标签要注意有效期。很多业务的会员是按月或者按年订阅的,过期后等级要降下来。我的做法是在用户属性表里存“会员到期时间”,计算标签时判断当前时间是否在有效期内,而不是直接存一个“是否会员”的布尔值。这样避免过期后忘记更新。
是否绑定手机、是否实名认证这类标签,数据来源是用户中心。我一般通过每日全量同步的方式拉到数仓,然后跟其他标签一起写入标签宽表。同步的时候要注意增量识别,只更新有变化的用户,减少写入压力。
2.4 时间窗口设计的实操经验
时间窗口的设计直接决定了标签的时效性和计算成本。我总结了几种常见的窗口类型:
| 窗口类型 | 计算方式 | 适用场景 | 更新频率 |
|---|---|---|---|
| 自然日滚动 | 最近N个自然日分区 | 消费、行为统计 | 每日 |
| 自然周滚动 | 最近N个自然周 | 周活跃、周消费 | 每周 |
| 自然月滚动 | 最近N个自然月 | 月消费、月活跃 | 每月 |
| 累计窗口 | 从注册至今 | 累计消费、累计订单 | 每日 |
| 固定周期 | 如每月1日到月末 | 月度账单类标签 | 每月 |
我一般优先用自然日滚动,因为分区裁剪最方便。自然周和自然月适合做周期性报表类的标签,但不适合做实时性要求高的标签。
还有一个细节是窗口的边界。比如“近7天”到底包不包含今天?我的习惯是包含今天,即“今天往前推6天,加上今天,共7天”。这样运营看起来更直观。如果业务方要求不包含今天,那就在元数据里注明,避免歧义。
3. 实操过程与核心环节实现
3.1 数据准备与清洗的完整流程
假设我们有一张订单表ods_order和一张用户行为日志表ods_user_log,目标是算出每个用户的消费类和行为类事实标签。下面是我在实际项目里的操作流程。
第一步,建DWD层的订单明细表。核心SQL逻辑如下:
insert overwrite table dwd_order_detail partition(dt='${bizdate}') select order_id, user_id, case when order_status in ('paid','completed') then 'valid' when order_status = 'refunded' then 'refund' else 'invalid' end as order_type, pay_amount, refund_amount, coalesce(pay_time, create_time) as event_time, dt from ods_order where dt = '${bizdate}' and user_id is not null and user_id <> '' and is_test = 0;这段SQL做了几件事:标准化订单状态、过滤空用户ID、排除测试订单、统一事件时间。注意coalesce(pay_time, create_time)这个处理,因为有些订单没有支付时间,用创建时间兜底,保证每条记录都有时间。
第二步,建DWD层的行为明细表。日志解析通常用UDF或者正则,核心是把JSON里的字段提取出来:
insert overwrite table dwd_user_log_detail partition(dt='${bizdate}') select user_id, event_name, from_unixtime(cast(server_time/1000 as bigint)) as event_time, page_id, item_id, dt from ods_user_log where dt = '${bizdate}' and user_id is not null and event_name in ('login','view_item','collect','add_cart','place_order');这里我限定了只保留需要的事件类型,减少后续计算量。如果日志量特别大,还可以按user_id做分桶,提升聚合效率。
3.2 消费类标签的聚合计算
在DWD层准备好之后,DWS层的聚合就相对简单了。以近30天消费标签为例:
insert overwrite table dws_user_consume_tag partition(dt='${bizdate}') select user_id, sum(case when order_type = 'valid' then pay_amount else 0 end) - sum(case when order_type = 'refund' then refund_amount else 0 end) as consume_amt_30d, count(distinct case when order_type = 'valid' then order_id end) as order_cnt_30d, max(case when order_type = 'valid' then event_time end) as last_consume_time from dwd_order_detail where dt between date_sub('${bizdate}', 29) and '${bizdate}' group by user_id;这段SQL里有个细节:count(distinct order_id)而不是count(order_id),因为一个订单可能有多条明细记录,直接count会重复。另外退款金额是单独减的,不是把退款订单的支付金额减掉,因为部分退款场景下退款金额和支付金额不一定相等。
累计消费标签的计算类似,只是窗口变成从注册至今。但全量扫描成本高,我一般用增量累加的方式:每天算当天的消费净额,然后跟历史累计值相加。这需要一个累计快照表,每天更新。
3.3 行为类标签的聚合计算
行为类标签的聚合要注意去重和事件过滤。以近30天活跃天数为例:
insert overwrite table dws_user_active_tag partition(dt='${bizdate}') select user_id, count(distinct dt) as active_days_30d, count(distinct case when event_name = 'login' then dt end) as login_days_30d, count(case when event_name = 'view_item' then 1 end) as view_cnt_30d, count(case when event_name = 'collect' then 1 end) as collect_cnt_30d from dwd_user_log_detail where dt between date_sub('${bizdate}', 29) and '${bizdate}' and event_name in ('login','view_item','collect') group by user_id;这里count(distinct dt)就是活跃天数,count(distinct case when event_name='login' then dt end)是登录天数。注意view_cnt_30d用的是count(1)而不是count(distinct),因为浏览次数是要算重复的。
如果日志量特别大,这个查询可能会很慢。我的优化经验是:先在DWD层按天做一次预聚合,算出每个用户每天的浏览次数、收藏次数,然后在DWS层把30天的预聚合结果加起来。这样每天的计算量从“扫30天明细”变成“扫30天预聚合”,速度快一个数量级。
3.4 标签写入与对外服务
算完标签之后,需要写入标签宽表供查询。我一般用一张HBase表或者Redis来存,key是user_id,value是标签的JSON。写入的时候要注意批量提交,避免单条写入导致性能问题。
# 伪代码示例:批量写入标签 def write_tags(tag_list): batch = [] for tag in tag_list: batch.append({ 'user_id': tag['user_id'], 'tags': { 'consume_amt_30d': tag['consume_amt_30d'], 'order_cnt_30d': tag['order_cnt_30d'], 'active_days_30d': tag['active_days_30d'], 'last_consume_time': tag['last_consume_time'] } }) if len(batch) >= 500: hbase_client.batch_put(batch) batch = [] if batch: hbase_client.batch_put(batch)对外服务的时候,我一般提供两种接口:单用户查询和批量查询。单用户查询用于运营后台展示,批量查询用于营销系统圈人。批量查询要注意限制每次查询的用户数,避免一次拉太多导致超时。
4. 常见问题与排查技巧实录
4.1 数据倾斜导致任务跑不动
这是事实标签计算里最常见的问题。表现是Reduce阶段卡在99%不动,或者某个task跑了几个小时。原因通常是某些用户的行为数据特别多,比如爬虫账号、测试账号、或者某个大B用户。
我的解决方案分三步:第一步,在DWD层就把明显的异常账号过滤掉,比如单日行为超过1万次的账号。第二步,对user_id做加盐处理,把大用户的key打散成多个,聚合完再合并。第三步,如果倾斜特别严重,单独把大用户拎出来用MapJoin处理。
加盐的具体做法是在group by的时候给user_id拼一个随机后缀:
select split(user_id, '_')[0] as user_id, sum(cnt) as cnt from ( select concat(user_id, '_', cast(rand() * 10 as int)) as user_id, count(1) as cnt from dwd_user_log_detail where dt = '${bizdate}' group by concat(user_id, '_', cast(rand() * 10 as int)) ) t group by split(user_id, '_')[0];这样原来一个key变成10个key,分散到10个Reduce里,最后再合并回来。
4.2 标签口径不一致导致数据对不上
这个问题我在多个项目里都遇到过。运营说“近30天消费用户有10万”,数据团队算出来是8万,两边对不上。排查下来通常是口径差异:运营算的是“下单用户”,数据团队算的是“支付用户”;或者运营的30天是“自然月”,数据团队的是“滚动30天”。
我的解决办法是建一个标签口径文档,每个标签都写清楚:数据来源、过滤条件、时间窗口、更新频率、负责人。这个文档放在Wiki上,任何人查标签之前先看文档。另外在标签元数据表里也存一份,计算任务直接从元数据表读口径,避免硬编码。
还有一个技巧是给每个标签加一个“口径版本号”。口径变更的时候版本号加1,历史数据保留旧版本,新数据用新版本。这样即使口径调整了,也能追溯历史数据是怎么算的。
4.3 增量计算与全量校准的配合
增量计算虽然快,但跑久了容易累积误差。比如某天的日志延迟到了,当天的增量没算进去,后面如果不补就永远少了这一天。所以我会定期做全量校准,一般是每周一次,用全量数据重算一遍,跟增量结果对比,差异超过阈值的就触发告警。
全量校准的SQL跟增量类似,只是窗口变成全量。为了减少对线上任务的影响,我一般把校准任务安排在凌晨低峰期,并且用单独的队列跑。
| 问题类型 | 表现 | 排查思路 | 解决方案 |
|---|---|---|---|
| 数据倾斜 | Reduce卡住 | 看task耗时分布 | 加盐、过滤异常账号 |
| 口径不一致 | 数据对不上 | 对比两边SQL | 建口径文档、版本号 |
| 增量误差 | 标签值偏小 | 对比全量结果 | 定期全量校准 |
| 日志丢失 | 行为标签偏少 | 检查埋点上报率 | 补数据、加监控 |
| 标签过期 | 会员等级没降 | 检查有效期逻辑 | 用到期时间判断 |
4.4 实操避坑心得
最后分享几个我在实际项目里踩过的坑,都是文档里不会写的。
第一个坑是不要用当前时间做标签计算。我见过有团队在SQL里直接用now(),结果任务重跑的时候结果不一样。正确的做法是用调度参数里的业务日期,保证每次重跑结果一致。
第二个坑是标签值要处理NULL。很多用户没有消费记录,sum()出来是NULL,写入HBase的时候NULL会报错。我一般用coalesce(sum(amount), 0)兜底,保证标签值永远有值。
第三个坑是注意数据延迟。有些业务库的订单数据会延迟几小时才同步过来,如果标签任务跑得太早,当天的数据就不全。我的做法是标签任务安排在数据同步完成之后,并且加一个数据质量检查,确认当天分区数据量正常再开始计算。
第四个坑是标签宽表的字段不要太多。我见过一张标签表有500多个字段,查询的时候性能很差。我的经验是宽表只存最常用的50个标签,其他的放KV存储,按需查询。
第五个坑是监控要到位。标签任务失败不可怕,可怕的是失败了没人知道。我一般会配三个监控:任务是否按时完成、标签覆盖率是否正常、标签值分布是否异常。比如“近30天消费金额”的均值突然翻倍,那肯定有问题,要马上排查。
这套事实标签计算的方案,我在几个项目里都跑通过,从几万用户到几千万用户都撑得住。核心就是口径清晰、增量优先、定期校准、监控到位。你如果正在做类似的事情,可以先从消费类标签入手,跑通之后再扩展到行为类和属性类。遇到数据倾斜或者口径问题,回头看看上面的排查表,基本能覆盖大部分场景。