Java 大视界 -- Java+Spark 构建企业级用户画像平台:从数据采集到标签输出全流程(437)

Java 大视界 -- Java+Spark 构建企业级用户画像平台:从数据采集到标签输出全流程(437)

  • 引言:
  • 正文:
    • [一、平台架构设计(企业级核心:解耦 + 高可用)](#一、平台架构设计(企业级核心:解耦 + 高可用))
      • [1.1 架构设计原则(10 余年实战沉淀)](#1.1 架构设计原则(10 余年实战沉淀))
        • [1.1.1 业务驱动](#1.1.1 业务驱动)
        • [1.1.2 分层解耦](#1.1.2 分层解耦)
        • [1.1.3 高可用无单点](#1.1.3 高可用无单点)
        • [1.1.4 可扩展适配增长](#1.1.4 可扩展适配增长)
      • [1.2 技术选型决策(拒绝盲目跟风,只选对的)](#1.2 技术选型决策(拒绝盲目跟风,只选对的))
      • [1.3 全链路架构图](#1.3 全链路架构图)
    • 二、数据采集层:海量数据高效接入(不丢数据、不重复)
      • [2.1 采集数据源分类(覆盖全场景)](#2.1 采集数据源分类(覆盖全场景))
        • [2.1.1 行为数据](#2.1.1 行为数据)
        • [2.1.2 业务数据](#2.1.2 业务数据)
        • [2.1.3 第三方数据](#2.1.3 第三方数据)
      • [2.2 核心采集组件实战(生产级配置)](#2.2 核心采集组件实战(生产级配置))
        • [2.2.1 Flume 采集行为日志(避免数据丢失)](#2.2.1 Flume 采集行为日志(避免数据丢失))
        • [2.2.2 Debezium 同步 MySQL 业务数据(实时无侵入)](#2.2.2 Debezium 同步 MySQL 业务数据(实时无侵入))
      • [2.3 采集层优化技巧(实战性能翻倍)](#2.3 采集层优化技巧(实战性能翻倍))
        • [2.3.1 数据压缩](#2.3.1 数据压缩)
        • [2.3.2 流量控制](#2.3.2 流量控制)
        • [2.3.3 数据去重](#2.3.3 数据去重)
        • [2.3.4 故障恢复](#2.3.4 故障恢复)
    • [三、存储层设计(企业级基石:高性能 + 高可用)](#三、存储层设计(企业级基石:高性能 + 高可用))
      • [3.1 分层存储设计(成本与性能的平衡艺术)](#3.1 分层存储设计(成本与性能的平衡艺术))
      • [3.2 HBase 标签宽表设计(亿级用户毫秒查询核心)](#3.2 HBase 标签宽表设计(亿级用户毫秒查询核心))
        • [3.2.1 表结构创建(预分区 + 列族优化)](#3.2.1 表结构创建(预分区 + 列族优化))
        • [3.2.2 关键设计细节(10 年踩坑总结)](#3.2.2 关键设计细节(10 年踩坑总结))
        • [3.2.3 列名与标签元数据映射](#3.2.3 列名与标签元数据映射)
      • [3.3 Redis 实时缓存设计(支撑 10 万 + QPS)](#3.3 Redis 实时缓存设计(支撑 10 万 + QPS))
        • [3.3.1 缓存结构设计(实战方案)](#3.3.1 缓存结构设计(实战方案))
        • [3.3.2 Redis 集群配置(高可用 + 高并发)](#3.3.2 Redis 集群配置(高可用 + 高并发))
      • [3.4 存储层高可用策略(7×24 小时无间断)](#3.4 存储层高可用策略(7×24 小时无间断))
    • 四、数据清洗预处理(数据质量是生命线)
      • [4.1 数据清洗核心目标(企业级标准)](#4.1 数据清洗核心目标(企业级标准))
      • [4.2 六步清洗法实战(Spark SQL+Scala)](#4.2 六步清洗法实战(Spark SQL+Scala))
      • [4.3 数据清洗依赖补充(Maven)](#4.3 数据清洗依赖补充(Maven))
      • [4.4 实战踩坑总结(血泪经验,避免重蹈覆辙)](#4.4 实战踩坑总结(血泪经验,避免重蹈覆辙))
    • [五、标签体系构建(用户画像核心:业务驱动 + 动态配置)](#五、标签体系构建(用户画像核心:业务驱动 + 动态配置))
      • [5.1 三级标签体系设计(实战验证,可复用)](#5.1 三级标签体系设计(实战验证,可复用))
        • [5.1.1 标签体系核心定义](#5.1.1 标签体系核心定义)
        • [5.1.2 行业实战案例(电商 + 金融)](#5.1.2 行业实战案例(电商 + 金融))
      • [5.2 标签元数据表设计(MySQL,动态配置)](#5.2 标签元数据表设计(MySQL,动态配置))
      • [5.3 批量标签计算实战(Spark SQL+Scala)](#5.3 批量标签计算实战(Spark SQL+Scala))
        • [5.3.1 复合标签计算(30 天购买次数 + 偏好品类)](#5.3.1 复合标签计算(30 天购买次数 + 偏好品类))
        • [5.3.2 衍生标签计算(高价值用户 + 价格敏感用户)](#5.3.2 衍生标签计算(高价值用户 + 价格敏感用户))
        • [5.3.3 HBase 批量写入工具类(生产级实现)](#5.3.3 HBase 批量写入工具类(生产级实现))
      • [5.4 标签计算任务调度(Airflow DAG)](#5.4 标签计算任务调度(Airflow DAG))
    • [六、标签查询服务(高并发 + 低延迟)](#六、标签查询服务(高并发 + 低延迟))
      • [6.1 查询服务架构设计](#6.1 查询服务架构设计)
      • [6.2 核心 API 设计(RESTful,生产级)](#6.2 核心 API 设计(RESTful,生产级))
      • [6.3 核心服务实现(三级缓存联动)](#6.3 核心服务实现(三级缓存联动))
      • [6.4 查询服务性能优化(10 年实战沉淀)](#6.4 查询服务性能优化(10 年实战沉淀))
      • [6.5 实战踩坑总结(查询服务篇)](#6.5 实战踩坑总结(查询服务篇))
    • [七、监控告警体系(企业级 7×24 小时保障)](#七、监控告警体系(企业级 7×24 小时保障))
      • [7.1 全链路监控架构(优化版)](#7.1 全链路监控架构(优化版))
      • [7.2 核心监控指标(系统 + 业务)](#7.2 核心监控指标(系统 + 业务))
      • [7.3 自定义监控埋点(业务指标)](#7.3 自定义监控埋点(业务指标))
      • [7.4 告警分级策略(避免告警风暴)](#7.4 告警分级策略(避免告警风暴))
    • [八、部署运维(企业级容器化 + 高可用)](#八、部署运维(企业级容器化 + 高可用))
      • [8.1 部署架构(K8s+Docker)](#8.1 部署架构(K8s+Docker))
      • [8.2 Dockerfile 实战(标签查询服务)](#8.2 Dockerfile 实战(标签查询服务))
      • [8.3 K8s 部署配置(Deployment+Service+Ingress)](#8.3 K8s 部署配置(Deployment+Service+Ingress))
        • [8.3.1 Deployment.yaml](#8.3.1 Deployment.yaml)
        • [8.3.2 Service.yaml](#8.3.2 Service.yaml)
        • [8.3.3 Ingress.yaml](#8.3.3 Ingress.yaml)
      • [8.4 运维最佳实践(10 年实战总结)](#8.4 运维最佳实践(10 年实战总结))
        • [8.4.1 发布策略:蓝绿部署](#8.4.1 发布策略:蓝绿部署)
        • [8.4.2 容灾备份](#8.4.2 容灾备份)
        • [8.4.3 故障排查三板斧](#8.4.3 故障排查三板斧)
    • 九、企业级压测与调优案例(亿级用户场景)
      • [9.1 压测环境配置](#9.1 压测环境配置)
      • [9.2 压测场景与结果(JMeter)](#9.2 压测场景与结果(JMeter))
      • [9.3 核心调优措施(实战总结)](#9.3 核心调优措施(实战总结))
  • 结束语:
  • 🗳️参与投票和联系我:

引言:

嘿,亲爱的 Java大数据爱好者们,大家好!我是CSDN(全区域)四榜榜首青云交!深耕 Java 大数据领域 10 余年,从早期支撑百万用户的初创项目,到如今服务亿级用户的头部金融、电商平台,亲手搭建过 3 套全流程用户画像系统,踩过的坑能装满一整个硬盘 ------HBase 热点 Region 导致查询延迟飙到 500ms、Spark Shuffle 分区不合理引发集群雪崩、实时标签计算与数据一致性的矛盾...... 这些血泪经验让我深刻明白:企业级用户画像不是 "技术堆砌",而是 "业务驱动 + 技术落地" 的精准耦合。

在数字化浪潮下,用户画像已成为推荐系统、风控反欺诈、精准营销的核心引擎 ------ 电商需要它实现 "千人千面" 推荐,金融需要它识别欺诈风险,运营商需要它提升用户留存。但多数团队落地时都会陷入 "数据混乱、标签无效、性能崩溃" 的困境。本文将结合我主导的 3 个亿级用户项目实战,从架构设计、存储选型、数据清洗、标签计算到监控部署,拆解 Java+Spark 构建企业级用户画像平台的全流程,全程干货无废话,包含生产级代码、踩坑指南、性能优化技巧,看完就能落地,让你少走 5 年弯路!

正文:

用户画像平台的核心价值是 "让数据说话",而企业级平台的关键在于 "稳定、高效、可扩展"。本文将围绕 "数据采集→存储→清洗→标签计算→查询→监控" 全链路,用实战案例 + 代码详解每一个环节的设计思路与落地技巧,所有内容均经过亿级用户场景验证,既有架构师的全局视角,也有工程师的实操细节。

一、平台架构设计(企业级核心:解耦 + 高可用)

1.1 架构设计原则(10 余年实战沉淀)

1.1.1 业务驱动

所有技术选型和架构设计都围绕业务场景 ------ 推荐场景优先保证查询低延迟,风控场景优先保证数据准确性,营销场景优先保证标签灵活性。

1.1.2 分层解耦

按 "数据采集→存储→清洗→计算→服务→监控" 分层,每一层职责单一,通过标准化接口通信,便于问题定位和横向扩展。

1.1.3 高可用无单点

核心组件(Kafka、HBase、Redis、Spark)均集群部署,关键服务多副本运行,支持故障自动转移,可用性目标 99.95%。

1.1.4 可扩展适配增长

支持数据量从千万到亿级平滑扩容,标签体系动态配置,新增业务场景无需重构核心代码。

1.2 技术选型决策(拒绝盲目跟风,只选对的)

技术层级 选型方案 核心选型理由(实战验证) 淘汰方案及原因
计算引擎 Spark 3.3(Batch+Streaming) 1. 批流统一 API,降低开发成本;2. 与 Hadoop 生态无缝集成;3. 优化器性能提升 30% Flink:实时场景更优,但批处理生态弱,运维成本高;MapReduce:性能不足,已淘汰
实时流处理 Kafka Streams+Spark Streaming 1. Kafka Streams 轻量化,适合行为触发型标签;2. Spark Streaming 适合窗口型标签 Storm:吞吐量不足,不支持状态管理;Samza:生态不完善
存储介质 HDFS+HBase+Redis+MySQL 1. 冷热分离:HDFS 存冷数据,HBase 存标签宽表,Redis 存实时标签;2. 成本与性能平衡 单一 HBase:冷数据存储成本高;MongoDB:查询性能不足,不适合标签宽表
数据采集 Flume+Kafka Connect+Debezium 1. Flume 适配日志采集,Kafka Connect 适配数据库同步;2. 高吞吐低延迟(10 万条 / 秒) Sqoop:仅支持批量同步;Logstash:资源占用高,稳定性差
调度系统 Apache Airflow 1. 可视化 DAG,运维友好;2. 支持依赖调度、失败重试;3. 生态丰富可扩展 Azkaban:功能单一;Oozie:配置繁琐,易用性差
监控告警 Prometheus+Grafana + 钉钉机器人 1. 指标采集全面,支持自定义监控;2. 告警及时,多级通知 Zabbix:不适合大数据组件;Nagios:可视化差

实战感悟:2018 年在某电商项目中,早期用 MongoDB 存储标签,用户量破千万后查询延迟从 10ms 飙到 500ms,被迫迁移到 HBase------ 技术选型一定要 "提前预判业务增长",避免重复造轮子。

1.3 全链路架构图

二、数据采集层:海量数据高效接入(不丢数据、不重复)

2.1 采集数据源分类(覆盖全场景)

2.1.1 行为数据

用户在平台的操作轨迹,如点击、浏览、购买、收藏、登录等,通过埋点 SDK 采集,核心字段:user_id、action、item_id、behavior_time、device_type、ip。

2.1.2 业务数据

来自业务数据库的核心数据,如用户基础信息(姓名、手机号、会员等级)、订单数据(金额、支付方式)、商品数据(品类、价格),通过 CDC 同步。

2.1.3 第三方数据

合规引入的外部数据,如征信数据、地理位置数据,通过 API 采集后脱敏处理。

2.2 核心采集组件实战(生产级配置)

2.2.1 Flume 采集行为日志(避免数据丢失)
properties 复制代码
# agent1.conf(生产环境优化配置)
agent1.sources = r1
agent1.channels = c1
agent1.sinks = k1

# 源配置:监听日志文件
agent1.sources.r1.type = exec
agent1.sources.r1.command = tail -F /data/logs/user-behavior.log
agent1.sources.r1.batchSize = 1000
agent1.sources.r1.channels = c1

# 通道配置:文件通道(防止宕机丢失)
agent1.channels.c1.type = file
agent1.channels.c1.checkpointDir = /data/flume/checkpoint
agent1.channels.c1.dataDirs = /data/flume/data
agent1.channels.c1.capacity = 1000000  # 最大缓存100万条
agent1.channels.c1.transactionCapacity = 10000

# Sink配置:输出到Kafka
agent1.sinks.k1.type = org.apache.flume.sink.kafka.KafkaSink
agent1.sinks.k1.kafka.bootstrap.servers = kafka-node1:9092,kafka-node2:9092,kafka-node3:9092
agent1.sinks.k1.kafka.topic = user_behavior_topic
agent1.sinks.k1.kafka.producer.acks = 1  # 至少1个副本确认
agent1.sinks.k1.kafka.producer.batch.size = 16384
agent1.sinks.k1.channel = c1

# 拦截器:添加时间戳、服务器IP
agent1.sources.r1.interceptors = i1 i2
agent1.sources.r1.interceptors.i1.type = timestamp
agent1.sources.r1.interceptors.i2.type = host
agent1.sources.r1.interceptors.i2.useIP = true
2.2.2 Debezium 同步 MySQL 业务数据(实时无侵入)
json 复制代码
{
  "name": "mysql-cdc-connector",
  "config": {
    "connector.class": "io.debezium.connector.mysql.MySqlConnector",
    "database.hostname": "mysql-master",
    "database.port": "3306",
    "database.user": "cdc_user",
    "database.password": "cdc_password",
    "database.server.name": "mysql-server",
    "database.include.list": "user_db,order_db",
    "table.include.list": "user_db.t_user,order_db.t_order",
    "decimal.handling.mode": "string",
    "snapshot.mode": "initial",  # 首次全量,后续增量
    "transforms": "unwrap",
    "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
    "kafka.producer.acks": "1",
    "value.converter": "org.apache.kafka.connect.json.JsonConverter",
    "value.converter.schemas.enable": "false"
  }
}

2.3 采集层优化技巧(实战性能翻倍)

2.3.1 数据压缩

Kafka 消息采用 LZ4 压缩,Flume 传输启用 Gzip 压缩,减少网络带宽占用 60%。

2.3.2 流量控制

Flume 配置限流参数(agent1.sources.r1.rateLimit = 100000),Kafka 设置分区水位线,避免峰值流量压垮集群。

2.3.3 数据去重

基于 user_id+action+behavior_time 构建唯一键,Kafka 消费端去重,避免重复数据进入计算层。

2.3.4 故障恢复

Flume 使用文件通道,Kafka 消息保留 7 天,确保采集节点宕机后数据可恢复。

实战案例:2020 年某电商大促,Flume 单点宕机,因启用文件通道,恢复后未丢失任何数据,仅延迟 10 分钟,避免了营销活动数据缺失。

三、存储层设计(企业级基石:高性能 + 高可用)

3.1 分层存储设计(成本与性能的平衡艺术)

存储分层 数据类型 存储介质 核心特性 数据量级别 访问频率 实战案例(某电商)
ODS 层 原始日志、CDC 全量数据 HDFS 3.x 低成本、高可靠、支持批量读写 PB 级 极低 日增 100TB 用户行为日志,保留 90 天
DWD 层 清洗后明细数据 HDFS 3.x 列存储、压缩率高、支持分区查询 TB 级 中低 日增 10TB 清洗后数据,保留 30 天
DWS 层 聚合数据(用户 / 品类聚合) HDFS 3.x 预聚合、减少计算量 GB 级 中高 日增 500GB 聚合数据,保留 7 天
标签宽表 全量用户标签 HBase 2.5 列存储、稀疏矩阵、按 RowKey 快速查询 100GB 级 极高 亿级用户标签,千维标签,查询延迟 < 50ms
实时标签 在线状态、最近行为标签 Redis 6.2 内存存储、高并发、低延迟 10GB 级 极高 千万级在线用户实时标签,查询延迟 < 5ms
元数据 标签定义、任务配置 MySQL 8.0 事务支持、查询快速 MB 级 中高 千维标签元数据,配置查询响应 < 10ms

3.2 HBase 标签宽表设计(亿级用户毫秒查询核心)

3.2.1 表结构创建(预分区 + 列族优化)
hbase 复制代码
# 创建标签宽表(生产级配置)
create 'prod:user_profile_wide',
{NAME => 'behavior', VERSIONS => 1, TTL => FOREVER, COMPRESSION => 'SNAPPY'},
{NAME => 'preference', VERSIONS => 1, TTL => FOREVER, COMPRESSION => 'SNAPPY'},
{NAME => 'user', VERSIONS => 1, TTL => FOREVER, COMPRESSION => 'SNAPPY'},
{NAME => 'risk', VERSIONS => 1, TTL => FOREVER, COMPRESSION => 'SNAPPY'},
{SPLITS => ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']}

# 创建标签-用户索引表(支持按标签值查用户)
create 'prod:tag_user_index',
{NAME => 'idx', VERSIONS => 1, TTL => FOREVER, COMPRESSION => 'SNAPPY'},
{SPLITS => ['behavior:', 'preference:', 'user:', 'risk:']}
3.2.2 关键设计细节(10 年踩坑总结)
  • RowKey 设计:直接用 user_id(如 U123456),确保精准查询,避免范围扫描;
  • 列族划分:按标签类型分 4 个列族(behavior/preference/user/risk),列族数量≤5 个(HBase 最佳实践);
  • 预分区策略:按 user_id 前缀分 16 个分区,解决热点 Region 问题,查询延迟从 500ms→10ms;
  • 压缩配置:启用 Snappy 压缩,存储占用减少 60%,查询性能提升 20%;
  • TTL 配置:核心标签永久保留,临时标签(如活动参与标签)设置 180 天过期。
3.2.3 列名与标签元数据映射
列族 列名(标签名) 数据类型 标签层级 示例值
behavior 30d_purchase_cnt(30 天购买次数) int 复合标签 5
behavior 7d_click_cnt(7 天点击次数) int 复合标签 23
preference fav_category(偏好品类) string 复合标签 手机
user register_time(注册时间) string 原子标签 2024-01-15 09:30:00
risk fraud_probability(欺诈概率) double 衍生标签 0.03

3.3 Redis 实时缓存设计(支撑 10 万 + QPS)

3.3.1 缓存结构设计(实战方案)
缓存类型 Key 格式 数据结构 有效期 核心用途 示例
实时标签缓存 user:real_time:tag:{user_id} Hash 5-30 分钟 存储最近行为、在线状态 user:real_time:tag:U123456 → {recent_5min_click:8, online:ONLINE}
热门标签缓存 user:hot_tag:{user_id} Hash 24 小时 存储高频查询标签(高价值用户) user:hot_tag:U123456 → {high_value:true, fav_category: 手机}
标签 - 用户索引 tag:value:index:{tag_code}:{value} Set 同标签有效期 快速查询标签值对应的用户集合 tag:value:index:preference:fav_category: 手机 → {U123456, U789012}
3.3.2 Redis 集群配置(高可用 + 高并发)
yaml 复制代码
# Redis集群核心配置
cluster-enabled yes
cluster-config-file nodes-6379.conf
cluster-node-timeout 15000
cluster-replica-count 1  # 每个主节点1个从节点
cluster-replica-validity-factor 10

# 性能优化配置
maxmemory-policy allkeys-lru  # 淘汰最少使用的key
maxmemory-samples 5  # LRU采样数
appendonly yes  # AOF持久化
appendfsync everysec  # 每秒同步AOF
tcp-keepalive 300  # 保持TCP连接

3.4 存储层高可用策略(7×24 小时无间断)

存储介质 高可用配置 故障恢复机制
HDFS 副本数 = 3,NameNode HA(主从热备) DataNode 故障自动切换副本;NameNode 故障 10 秒内切换备用节点
HBase RegionServer 集群(≥3 节点),HMaster HA,Region 副本数 = 3 Region 自动迁移,HMaster 自动选举
Redis 主从复制 + 哨兵模式(3 个哨兵),集群分片 主节点故障自动切换从节点;分片故障不影响其他分片
MySQL 主从复制(1 主 2 从),MGR 集群模式 主节点故障自动切换,数据同步延迟 < 1 秒

实战感悟:2021 年某金融项目,HBase 集群因硬盘故障导致 1 个 RegionServer 下线,因启用 Region 副本,业务无感知,故障恢复后数据自动同步,未造成任何损失。

四、数据清洗预处理(数据质量是生命线)

4.1 数据清洗核心目标(企业级标准)

  • 完整性:核心字段(user_id、behavior_time、action)缺失率 < 0.1%;
  • 一致性:数据格式统一(时间戳、IP、价格格式标准化);
  • 准确性:数据值符合业务规则(价格 > 0,行为时间在合理范围);
  • 唯一性:无重复数据(同一用户同一时间同一行为只保留 1 条);
  • 时效性:采集延迟 < 1 小时,避免跨日期标签计算错误。

4.2 六步清洗法实战(Spark SQL+Scala)

以下是某电商用户行为数据清洗的生产级代码,日处理 10 亿 + 日志,数据清洗率稳定在 95% 以上:

scala 复制代码
package com.qingyunjiao.data.clean

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
import org.apache.spark.sql.types.{DoubleType, LongType, StringType, StructType}
import org.slf4j.LoggerFactory

/**
 * 企业级用户行为数据清洗:ODS→DWD层
 * 实战场景:日处理10亿+用户行为日志,支撑亿级用户标签计算
 * 核心特性:1)六步清洗逻辑;2)数据质量监控;3)异常告警;4)支持重跑
 */
object UserBehaviorDataCleaner {
  private val logger = LoggerFactory.getLogger(classOf[UserBehaviorDataCleaner])

  def main(args: Array[String]): Unit = {
    // 1. 解析输入参数(调度系统传入,支持重跑)
    val inputDate = if (args.length > 0) args(0) else "2024-05-20"
    val inputPath = s"/user/hive/warehouse/ods.db/user_behavior_log/dt=${inputDate}"
    val outputPath = s"/user/hive/warehouse/dwd.db/user_behavior_log_clean/dt=${inputDate}"

    // 2. 构建SparkSession(企业级优化配置)
    val spark = SparkSession.builder()
      .appName(s"UserBehaviorDataCleaner-${inputDate}")
      .master("yarn")
      .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
      .config("spark.sql.adaptive.enabled", "true")
      .config("spark.sql.shuffle.partitions", "200")
      .config("spark.sql.parquet.filterPushdown", "true")
      .config("spark.driver.memory", "4G")
      .config("spark.executor.memory", "8G")
      .config("spark.executor.cores", "4")
      .getOrCreate()

    import spark.implicits._

    // 3. 定义原始数据Schema(避免解析错误)
    val rawSchema = new StructType()
      .add("user_id", StringType)
      .add("action", StringType)
      .add("goods_id", StringType)
      .add("goods_category", StringType)
      .add("goods_price", StringType)
      .add("behavior_time", StringType)
      .add("device_type", StringType)
      .add("ip", StringType)
      .add("page", StringType)
      .add("collect_time", StringType)

    // 4. 读取ODS层原始数据(按日期分区过滤)
    val odsDF = spark.read
      .schema(rawSchema)
      .json(inputPath)
      .withColumn("dt", lit(inputDate))
      .cache()

    logger.info(s"ODS层原始数据量:${odsDF.count()}")

    // 5. 六步清洗核心逻辑
    val cleanDF = odsDF
      // 第一步:核心字段校验(过滤缺失数据)
      .filter(
        col("user_id").isNotNull && col("user_id") =!= "" &&
        col("action").isNotNull && col("action") =!= "" &&
        col("behavior_time").isNotNull && col("behavior_time") =!= ""
      )
      // 第二步:去重(解决日志采集重试导致的重复)
      .dropDuplicates("user_id", "behavior_time", "action")
      // 第三步:缺失值补全(非核心字段填充默认值)
      .withColumn("goods_id", when(col("goods_id").isNull, "unknown").otherwise(col("goods_id")))
      .withColumn("goods_category", when(col("goods_category").isNull, "unknown").otherwise(col("goods_category")))
      .withColumn("device_type", when(col("device_type").isNull, "unknown").otherwise(col("device_type")))
      // 第四步:异常值过滤(符合业务规则)
      .filter(col("action").isin("click", "view", "purchase", "cancel", "collect"))
      .filter(to_timestamp(col("behavior_time"), "yyyy-MM-dd HH:mm:ss").isNotNull)
      .withColumn("price_double", col("goods_price").cast(DoubleType))
      .filter(col("price_double").isNotNull && col("price_double") >= 0)
      .drop("price_double")
      // 第五步:格式标准化(统一数据格式)
      .withColumn("behavior_time_ts", to_timestamp(col("behavior_time")).cast(LongType) * 1000)
      .withColumn("ip_province", ipToProvince(col("ip")))  // 自定义UDF解析IP省份
      // 第六步:时效性过滤(采集延迟≤1小时)
      .filter(col("collect_time").cast(LongType) - col("behavior_time_ts") <= 3600000)
      // 保留核心字段
      .select(
        col("user_id"),
        col("action"),
        col("goods_id"),
        col("goods_category"),
        col("goods_price").cast(DoubleType),
        col("behavior_time_ts").alias("behavior_time"),
        col("device_type"),
        col("ip_province"),
        col("page"),
        col("dt")
      )

    // 6. 数据质量监控(输出报告+告警)
    val rawCount = odsDF.count()
    val cleanCount = cleanDF.count()
    val cleanRate = (cleanCount.toDouble / rawCount) * 100
    val invalidRate = ((rawCount - cleanCount).toDouble / rawCount) * 100

    logger.info(s"""
      |数据清洗质量报告(${inputDate}):
      |- 原始数据量:${rawCount}
      |- 清洗后数据量:${cleanCount}
      |- 清洗率:${cleanRate.formatted("%.2f")}%
      |- 无效数据率:${invalidRate.formatted("%.2f")}%
      """.stripMargin)

    // 清洗率低于95%触发告警
    if (cleanRate < 95.0) {
      logger.error(s"数据清洗率异常:${cleanRate.formatted("%.2f")}%,低于阈值95%")
      // AlertUtils.sendDingTalkAlert(s"用户行为数据清洗率异常:${inputDate},清洗率${cleanRate.formatted("%.2f")}%")
    }

    // 7. 写入DWD层(支持重跑,覆盖分区)
    cleanDF.write
      .mode("overwrite")
      .partitionBy("dt")
      .parquet(outputPath)

    logger.info(s"数据清洗完成,写入DWD层:${outputPath}")

    // 8. 写入数据质量表(MySQL)
    val qualityDF = Seq(
      (inputDate, rawCount, cleanCount, cleanRate, invalidRate, System.currentTimeMillis())
    ).toDF(
      "dt", "raw_count", "clean_count", "clean_rate", "invalid_rate", "create_time"
    )

    qualityDF.write
      .mode("append")
      .jdbc(
        "jdbc:mysql://mysql-node1:3306/data_quality_db?useSSL=false",
        "user_behavior_clean_quality",
        Map("user" -> "data_quality_user", "password" -> "DataQuality@2024")
      )

    odsDF.unpersist()
    spark.stop()
  }

  /**
   * 自定义UDF:IP解析省份(生产环境用MaxMind GeoIP2,精准度99%+)
   */
  private val ipToProvince = udf((ip: String) => {
    if (ip == null || ip.isEmpty) return "未知"
    try {
      // 生产环境实现:加载GeoLite2-City.mmdb数据库
      // val reader = new DatabaseReader.Builder(new File("GeoLite2-City.mmdb")).build()
      // val response = reader.city(InetAddress.getByName(ip))
      // response.getMostSpecificSubdivision().getName()

      // 模拟返回(真实环境替换上述代码)
      ip match {
        case ipv4 if ipv4.startsWith("112.") => "北京"
        case ipv4 if ipv4.startsWith("123.") => "上海"
        case ipv4 if ipv4.startsWith("221.") => "广东"
        case _ => "其他"
      }
    } catch {
      case e: Exception =>
        logger.error(s"IP解析异常:ip=${ip}", e)
        "未知"
    }
  })
}

4.3 数据清洗依赖补充(Maven)

xml 复制代码
<<dependencies>
    <!-- Spark SQL依赖 -->
    <dependency>
        <groupId>org.apache.spark</groupId>
        <artifactId>spark-sql_2.12</artifactId>
        <version>3.3.0</version>
        <scope>provided</scope>
    </dependency>
    <!-- MySQL JDBC依赖 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.33</version>
    </dependency>
    <!-- GeoIP2依赖 -->
    <dependency>
        <groupId>com.maxmind.geoip2</groupId>
        <artifactId>geoip2</artifactId>
        <version>4.7.0</version>
    </dependency>
    <!-- 工具类依赖 -->
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
        <version>3.12.0</version>
    </dependency>
</</dependencies>

4.4 实战踩坑总结(血泪经验,避免重蹈覆辙)

坑点类型 具体问题 解决方案 影响范围
重复数据 Flume 断点续传异常,同一行为重复写入 按 user_id+behavior_time+action 去重,设置分区去重 标签计算重复(购买次数多算)
时间戳异常 行为时间戳格式错误(如 2024-13-01)或未来时间 用 to_timestamp 校验,过滤时间不在当前日期 ±1 天内的数据 标签时间维度错误(日活统计异常)
维度表关联失败 商品 ID 不存在于商品维度表(测试商品、已下架商品) 左连接 + 缺失值填充(未知品类),避免丢失用户行为数据 偏好标签缺失(无品类偏好)
采集延迟过高 服务器宕机导致日志积压,采集延迟超 24 小时 过滤采集延迟 > 1 小时的数据,积压数据单独处理 跨日期标签计算错误
IP 解析失败 无效 IP(如 0.0.0.0)或 GeoIP 数据库未更新 异常 IP 返回 "未知",每月更新 GeoIP 数据库 地域标签不准确

实战感悟:数据清洗不是 "一劳永逸",需要建立 "事前预防 + 事中监控 + 事后复盘" 体系 ------ 事前规范日志格式,事中实时监控清洗率,事后每周复盘数据质量问题,持续优化规则。

五、标签体系构建(用户画像核心:业务驱动 + 动态配置)

5.1 三级标签体系设计(实战验证,可复用)

5.1.1 标签体系核心定义
标签层级 定义 核心特点 计算周期 数据来源
原子标签 最细粒度、不可拆分的基础标签(直接提取) 无业务逻辑、粒度细、数据量大 实时 / 日 DWD 层明细数据
复合标签 基于原子标签聚合计算(反映单一维度特征) 有业务逻辑、粒度粗、数据量小 日 / 周 DWS 层聚合数据
衍生标签 基于复合标签交叉计算(反映综合特征,支撑核心业务) 业务价值高、逻辑复杂、可解释性强 日 / 周 复合标签 + 业务规则
5.1.2 行业实战案例(电商 + 金融)
标签层级 电商场景示例 金融场景示例 业务价值
原子标签 单次点击、设备类型、登录 IP 单笔交易金额、登录地点、银行卡类型 基础数据支撑
复合标签 30 天购买次数、偏好品类、客单价 近 3 个月交易总额、信用基础分 简单业务决策(品类推荐)
衍生标签 高价值用户(客单价 > 500 且 30 天购买≥3 次) 优质借贷用户(信用分≥800 且无逾期) 核心业务支撑(高端推荐、借贷审批)

5.2 标签元数据表设计(MySQL,动态配置)

sql 复制代码
-- 标签元数据表(生产环境分库分表,支撑千维标签)
CREATE TABLE `tag_metadata` (
  `tag_id` VARCHAR(32) NOT NULL COMMENT '标签唯一ID(如BEHAVIOR_30D_PURCHASE_CNT)',
  `tag_name` VARCHAR(64) NOT NULL COMMENT '标签名称(如30天购买次数)',
  `tag_level` TINYINT NOT NULL COMMENT '标签层级(1=原子,2=复合,3=衍生)',
  `tag_type` VARCHAR(32) NOT NULL COMMENT '标签类型(behavior/preference/user/risk)',
  `data_type` VARCHAR(16) NOT NULL COMMENT '数据类型(int/string/double/boolean)',
  `calculate_logic` TEXT COMMENT '计算逻辑(SQL/代码片段)',
  `data_source` VARCHAR(64) NOT NULL COMMENT '数据来源(如DWD_USER_BEHAVIOR)',
  `calculate_cycle` VARCHAR(16) NOT NULL COMMENT '计算周期(realtime/daily/weekly)',
  `ttl` INT COMMENT '有效期(秒,实时标签用)',
  `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态(1=启用,0=禁用)',
  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`tag_id`),
  INDEX `idx_tag_level` (`tag_level`),
  INDEX `idx_calculate_cycle` (`calculate_cycle`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标签元数据表';

-- 插入示例数据
INSERT INTO `tag_metadata` VALUES 
('BEHAVIOR_30D_PURCHASE_CNT', '30天购买次数', 2, 'behavior', 'int', 
 'COUNT(1) WHERE action=''purchase'' AND behavior_time >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)', 
 'DWD_USER_BEHAVIOR', 'daily', NULL, 1, NOW(), NOW()),
('USER_HIGH_VALUE', '高价值用户', 3, 'user', 'boolean', 
 'CASE WHEN user_unit_price>500 AND behavior_30d_purchase_cnt>=3 THEN 1 ELSE 0 END', 
 'DWS_USER_VALUE', 'daily', NULL, 1, NOW(), NOW());

5.3 批量标签计算实战(Spark SQL+Scala)

5.3.1 复合标签计算(30 天购买次数 + 偏好品类)
scala 复制代码
package com.qingyunjiao.tag.calculate.batch

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
import org.slf4j.LoggerFactory

/**
 * 复合标签计算:支撑某电商100+复合标签,日处理10亿+行为数据
 * 性能优化:分区过滤、数据缓存、并行计算、批量写入
 */
object CompositeTagCalculator {
  private val logger = LoggerFactory.getLogger(classOf[CompositeTagCalculator])

  def main(args: Array[String]): Unit = {
    // 1. 构建SparkSession(生产级配置)
    val spark = SparkSession.builder()
      .appName("CompositeTagCalculator")
      .master("yarn")
      .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
      .config("spark.sql.adaptive.enabled", "true")
      .config("spark.sql.shuffle.partitions", "200")
      .config("spark.sql.parquet.filterPushdown", "true")
      .getOrCreate()

    import spark.implicits._

    // 2. 解析参数
    val calculateDate = if (args.length > 0) args(0) else "2024-05-20"
    val hbaseTable = "prod:user_profile_wide"
    val dwdPath = s"/user/hive/warehouse/dwd.db/user_behavior_log_clean/dt=${calculateDate}"

    logger.info(s"开始计算${calculateDate}复合标签,输入路径:${dwdPath}")

    // 3. 读取DWD层数据(缓存重复使用)
    val behaviorDF = spark.read.parquet(dwdPath)
      .select(
        col("user_id"),
        col("action"),
        col("goods_category"),
        col("goods_price"),
        col("behavior_time")
      )
      .cache()

    logger.info(s"DWD层数据量:${behaviorDF.count()}")

    // 4. 计算复合标签
    // 4.1 30天购买次数
    val purchase30dDF = behaviorDF
      .filter(col("action") === "purchase")
      .filter(col("behavior_time") >= date_sub(to_date(lit(calculateDate)), 30))
      .groupBy(col("user_id"))
      .agg(count("*").alias("behavior_30d_purchase_cnt"))
      .select(
        col("user_id"),
        col("behavior_30d_purchase_cnt").cast("int")
      )

    // 4.2 30天点击次数
    val click30dDF = behaviorDF
      .filter(col("action") === "click")
      .filter(col("behavior_time") >= date_sub(to_date(lit(calculateDate)), 30))
      .groupBy(col("user_id"))
      .agg(count("*").alias("behavior_30d_click_cnt"))
      .select(
        col("user_id"),
        col("behavior_30d_click_cnt").cast("int")
      )

    // 4.3 偏好品类(30天点击最多的品类)
    val favCategoryDF = behaviorDF
      .filter(col("action") === "click")
      .filter(col("behavior_time") >= date_sub(to_date(lit(calculateDate)), 30))
      .groupBy(col("user_id"), col("goods_category"))
      .agg(count("*").alias("click_cnt"))
      .groupBy(col("user_id"))
      .agg(
        first(col("goods_category"), ignoreNulls = true).alias("preference_fav_category")
      )

    // 4.4 客单价(30天购买总金额/购买次数)
    val unitPriceDF = behaviorDF
      .filter(col("action") === "purchase")
      .filter(col("behavior_time") >= date_sub(to_date(lit(calculateDate)), 30))
      .groupBy(col("user_id"))
      .agg(
        sum(col("goods_price")).alias("total_amount"),
        count("*").alias("purchase_cnt")
      )
      .withColumn("user_unit_price", 
        when(col("purchase_cnt") > 0, col("total_amount") / col("purchase_cnt")).otherwise(0.0)
      )
      .select(
        col("user_id"),
        col("user_unit_price").cast("double")
      )

    // 5. 合并标签(左连接,确保每个用户都有记录)
    val compositeTagDF = purchase30dDF
      .join(click30dDF, Seq("user_id"), "fullouter")
      .join(favCategoryDF, Seq("user_id"), "fullouter")
      .join(unitPriceDF, Seq("user_id"), "fullouter")
      .na.fill(0, Seq("behavior_30d_purchase_cnt", "behavior_30d_click_cnt", "user_unit_price"))
      .na.fill("未知", Seq("preference_fav_category"))

    logger.info(s"复合标签计算完成,数据量:${compositeTagDF.count()}")

    // 6. 转换为HBase写入格式
    val hbaseWriteDF = compositeTagDF
      .withColumn("tag_map", map(
        lit("behavior:30d_purchase_cnt"), col("behavior_30d_purchase_cnt").cast("string"),
        lit("behavior:30d_click_cnt"), col("behavior_30d_click_cnt").cast("string"),
        lit("preference:fav_category"), col("preference_fav_category"),
        lit("user:unit_price"), col("user_unit_price").cast("string")
      ))
      .select(col("user_id"), col("tag_map"))

    // 7. 批量写入HBase(调用工具类)
    import scala.collection.JavaConverters._
    val tagDataList = hbaseWriteDF
      .rdd
      .map { row =>
        val userId = row.getAs[String]("user_id")
        val tagMap = row.getAs[Map[String, String]]("tag_map")
        tagMap + ("user_id" -> userId)
      }
      .collect()
      .toList
      .asJava

    HBaseBatchWriter.getInstance().batchWriteTags(hbaseTable, tagDataList, 1000)

    logger.info(s"复合标签写入HBase成功,数据量:${tagDataList.size()}")

    behaviorDF.unpersist()
    spark.stop()
  }
}
5.3.2 衍生标签计算(高价值用户 + 价格敏感用户)
scala 复制代码
package com.qingyunjiao.tag.calculate.batch

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
import org.slf4j.LoggerFactory

/**
 * 衍生标签计算:支撑电商核心业务(精准营销、个性化推荐)
 * 实战场景:高价值用户运营、价格敏感用户营销
 */
object DerivedTagCalculator {
  private val logger = LoggerFactory.getLogger(classOf[DerivedTagCalculator])

  def main(args: Array[String]): Unit = {
    val spark = SparkSession.builder()
      .appName("DerivedTagCalculator")
      .master("yarn")
      .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
      .config("spark.sql.shuffle.partitions", "200")
      .getOrCreate()

    import spark.implicits._

    // 输入参数
    val calculateDate = if (args.length > 0) args(0) else "2024-05-20"
    val hbaseTable = "prod:user_profile_wide"
    val dwsPath = s"/user/hive/warehouse/dws.db/user_composite_tag/dt=${calculateDate}"

    logger.info(s"开始计算${calculateDate}衍生标签,输入路径:${dwsPath}")

    // 读取复合标签数据
    val compositeTagDF = spark.read.parquet(dwsPath)
      .select(
        col("user_id"),
        col("behavior_30d_purchase_cnt").cast("int"),
        col("user_unit_price").cast("double"),
        col("preference_low_price_click_rate").cast("double"),
        col("behavior_last_purchase_time").cast("long")
      )
      .cache()

    // 读取业务规则(从MySQL配置)
    val ruleDF = spark.read
      .jdbc(
        "jdbc:mysql://mysql-node1:3306/tag_config_db",
        "tag_business_rule",
        Map("user" -> "tag_config_user", "password" -> "TagConfig@2024")
      )
      .cache()

    // 提取规则阈值
    val highValuePrice = ruleDF.filter(col("rule_code") === "HIGH_VALUE_PRICE").select("rule_value").head().getDouble(0)
    val highValuePurchaseCnt = ruleDF.filter(col("rule_code") === "HIGH_VALUE_PURCHASE_CNT").select("rule_value").head().getInt(0)
    val priceSensitiveRate = ruleDF.filter(col("rule_code") === "PRICE_SENSITIVE_RATE").select("rule_value").head().getDouble(0)

    // 计算衍生标签
    val derivedTagDF = compositeTagDF
      // 高价值用户(客单价>500且30天购买≥3次)
      .withColumn("user:high_value", 
        when(
          col("user_unit_price") > highValuePrice && 
          col("behavior_30d_purchase_cnt") >= highValuePurchaseCnt, 
          "1"
        ).otherwise("0")
      )
      // 价格敏感用户(低价点击占比>80%)
      .withColumn("preference:price_sensitive", 
        when(col("preference_low_price_click_rate") > priceSensitiveRate, "1").otherwise("0")
      )
      // 流失预警用户(90天有购买且30天无购买)
      .withColumn("user:churn_warning", 
        when(
          col("behavior_last_purchase_time") >= (current_timestamp().cast("long") - 90*24*3600*1000) && 
          col("behavior_30d_purchase_cnt") === 0, 
          "1"
        ).otherwise("0")
      )
      .select(
        col("user_id"),
        col("user:high_value"),
        col("preference:price_sensitive"),
        col("user:churn_warning")
      )

    logger.info(s"衍生标签计算完成,数据量:${derivedTagDF.count()}")

    // 写入HBase
    val hbaseWriteDF = derivedTagDF
      .withColumn("tag_map", map(
        lit("user:high_value"), col("user:high_value"),
        lit("preference:price_sensitive"), col("preference:price_sensitive"),
        lit("user:churn_warning"), col("user:churn_warning")
      ))
      .select(col("user_id"), col("tag_map"))

    val tagDataList = hbaseWriteDF
      .rdd
      .map { row =>
        val userId = row.getAs[String]("user_id")
        val tagMap = row.getAs[Map[String, String]]("tag_map")
        tagMap + ("user_id" -> userId)
      }
      .collect()
      .toList
      .asJava

    HBaseBatchWriter.getInstance().batchWriteTags(hbaseTable, tagDataList, 1000)

    logger.info(s"衍生标签写入HBase成功")

    compositeTagDF.unpersist()
    ruleDF.unpersist()
    spark.stop()
  }
}
5.3.3 HBase 批量写入工具类(生产级实现)
java 复制代码
package com.qingyunjiao.tag.util;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.hbase.TableName;
import org.apache.hadoop.hbase.client.*;
import org.apache.hadoop.hbase.util.Bytes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * HBase批量写入工具类(企业级封装)
 * 核心特性:1)单例模式;2)批量优化;3)失败重试;4)连接池管理
 */
public class HBaseBatchWriter {
    private static final Logger logger = LoggerFactory.getLogger(HBaseBatchWriter.class);
    private static HBaseBatchWriter instance;
    private Connection connection;
    private ExecutorService executorService;
    private static final int BATCH_SIZE = 1000;
    private static final int RETRY_TIMES = 3;
    private static final int THREAD_POOL_SIZE = 10;

    // 单例初始化
    private HBaseBatchWriter() {
        initConnection();
        initThreadPool();
    }

    public static synchronized HBaseBatchWriter getInstance() {
        if (instance == null) {
            instance = new HBaseBatchWriter();
        }
        return instance;
    }

    // 初始化HBase连接
    private void initConnection() {
        try {
            Configuration conf = HBaseConfiguration.create();
            conf.set("hbase.zookeeper.quorum", "hbase-zk-node1,hbase-zk-node2,hbase-zk-node3");
            conf.set("hbase.zookeeper.property.clientPort", "2181");
            conf.set("hbase.client.operation.timeout", "30000");
            connection = ConnectionFactory.createConnection(conf);
            logger.info("HBase连接初始化成功");
        } catch (Exception e) {
            logger.error("HBase连接初始化失败", e);
            throw new RuntimeException(e);
        }
    }

    // 初始化线程池
    private void initThreadPool() {
        executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
        logger.info("HBase写入线程池初始化成功");
    }

    // 批量写入标签数据
    public void batchWriteTags(String tableName, List<Map<String, String>> tagDataList, int batchSize) {
        if (tagDataList.isEmpty()) {
            logger.warn("HBase写入数据为空");
            return;
        }

        logger.info("开始写入HBase,表名:{},总数据量:{}", tableName, tagDataList.size());

        // 分批次提交
        int total = tagDataList.size();
        int batchCount = (total + batchSize - 1) / batchSize;

        for (int i = 0; i < batchCount; i++) {
            int start = i * batchSize;
            int end = Math.min((i + 1) * batchSize, total);
            List<Map<String, String>> batch = tagDataList.subList(start, end);

            executorService.submit(() -> {
                try {
                    doBatchWrite(tableName, batch);
                } catch (Exception e) {
                    logger.error("HBase写入失败,批次:{}", i, e);
                    retryWrite(tableName, batch, RETRY_TIMES);
                }
            });
        }
    }

    // 核心写入逻辑
    private void doBatchWrite(String tableName, List<Map<String, String>> batch) throws Exception {
        Table table = null;
        BufferedMutator mutator = null;
        try {
            TableName tn = TableName.valueOf(tableName);
            table = connection.getTable(tn);

            BufferedMutatorParams params = new BufferedMutatorParams(tn)
                .writeBufferSize(5 * 1024 * 1024); // 5MB缓冲区
            mutator = connection.getBufferedMutator(params);

            for (Map<String, String> data : batch) {
                String userId = data.get("user_id");
                if (userId == null) continue;

                Put put = new Put(Bytes.toBytes(userId));

                for (Map.Entry<String, String> entry : data.entrySet()) {
                    String key = entry.getKey();
                    if ("user_id".equals(key)) continue;

                    String[] cfAndCol = key.split(":", 2);
                    if (cfAndCol.length != 2) continue;

                    String cf = cfAndCol[0];
                    String col = cfAndCol[1];
                    String value = entry.getValue();

                    put.addColumn(
                        Bytes.toBytes(cf),
                        Bytes.toBytes(col),
                        Bytes.toBytes(value)
                    );
                }

                mutator.mutate(put);
            }

            mutator.flush();
            logger.info("HBase批次写入成功,数据量:{}", batch.size());
        } finally {
            if (mutator != null) mutator.close();
            if (table != null) table.close();
        }
    }

    // 失败重试逻辑
    private void retryWrite(String tableName, List<Map<String, String>> batch, int retryTimes) {
        for (int i = 1; i <= retryTimes; i++) {
            try {
                Thread.sleep(1000 * i); // 指数退避
                doBatchWrite(tableName, batch);
                logger.info("HBase重试写入成功,次数:{}", i);
                return;
            } catch (Exception e) {
                logger.error("HBase重试写入失败,次数:{}", i, e);
                if (i == retryTimes) {
                    writeToFailQueue(tableName, batch); // 写入失败队列
                }
            }
        }
    }

    // 写入失败队列(HDFS)
    private void writeToFailQueue(String tableName, List<Map<String, String>> batch) {
        // 生产环境实现:写入HDFS指定路径,供后续处理
        logger.error("HBase写入失败,数据已存入失败队列:{},数据量:{}", tableName, batch.size());
    }

    // 关闭资源
    public void close() {
        executorService.shutdown();
        try {
            connection.close();
        } catch (Exception e) {
            logger.error("HBase连接关闭失败", e);
        }
    }
}

5.4 标签计算任务调度(Airflow DAG)

python 复制代码
from airflow import DAG
from airflow.providers.apache.spark.operators.spark_submit import SparkSubmitOperator
from airflow.utils.dates import days_ago
from datetime import timedelta

default_args = {
    'owner': 'qingyunjiao',
    'depends_on_past': False,
    'start_date': days_ago(1),
    'email': ['qingyunjiao@xxx.com'],
    'email_on_failure': True,
    'retries': 3,
    'retry_delay': timedelta(minutes=5),
    'queue': 'spark_queue'
}

with DAG(
    'user_profile_tag_calculation',
    default_args=default_args,
    description='企业级用户画像标签计算DAG',
    schedule_interval='0 2 * * *',  # 每日凌晨2点执行
    catchup=False,
    tags=['user_profile', 'spark', 'tag']
) as dag:

    # 任务1:数据清洗
    data_clean_task = SparkSubmitOperator(
        task_id='data_clean_ods_to_dwd',
        application='/opt/spark/jobs/user-behavior-data-cleaner-1.0.0.jar',
        name='UserBehaviorDataCleaner',
        conn_id='spark_default',
        application_args=['{{ ds }}'],
        conf={
            'spark.driver.memory': '4G',
            'spark.executor.memory': '8G',
            'spark.executor.cores': '4',
            'spark.executor.instances': '10'
        },
        jars='/opt/spark/jars/mysql-connector-java-8.0.33.jar'
    )

    # 任务2:复合标签计算
    composite_tag_task = SparkSubmitOperator(
        task_id='composite_tag_calculation',
        application='/opt/spark/jobs/composite-tag-calculator-1.0.0.jar',
        name='CompositeTagCalculator',
        conn_id='spark_default',
        application_args=['{{ ds }}'],
        conf={
            'spark.driver.memory': '8G',
            'spark.executor.memory': '16G',
            'spark.executor.cores': '4',
            'spark.executor.instances': '20'
        },
        jars='/opt/spark/jars/hbase-client-2.5.0.jar'
    )

    # 任务3:衍生标签计算
    derived_tag_task = SparkSubmitOperator(
        task_id='derived_tag_calculation',
        application='/opt/spark/jobs/derived-tag-calculator-1.0.0.jar',
        name='DerivedTagCalculator',
        conn_id='spark_default',
        application_args=['{{ ds }}'],
        conf={
            'spark.driver.memory': '8G',
            'spark.executor.memory': '16G',
            'spark.executor.cores': '4',
            'spark.executor.instances': '20'
        },
        jars='/opt/spark/jars/hbase-client-2.5.0.jar'
    )

    # 任务依赖:清洗 → 复合标签 → 衍生标签
    data_clean_task >> composite_tag_task >> derived_tag_task

实战感悟:标签计算的核心是 "业务规则可配置 + 技术优化可扩展"。2022 年某金融项目,因业务规则硬编码,导致高价值用户阈值调整时需要重新打包部署,后来改为 MySQL 存储规则,动态加载,迭代效率提升 10 倍。

六、标签查询服务(高并发 + 低延迟)

6.1 查询服务架构设计

📱 业务系统 🌐 Nginx负载均衡 ⚡ 标签查询服务集群(Spring Boot) 📦 本地缓存(Caffeine) 🚀 Redis集群(热点标签) 💾 HBase集群(全量标签) 🗄️ MySQL(标签元数据) TD

6.2 核心 API 设计(RESTful,生产级)

java 复制代码
package com.qingyunjiao.tag.api.controller;

import com.qingyunjiao.tag.api.dto.TagQueryRequest;
import com.qingyunjiao.tag.api.dto.TagBatchQueryRequest;
import com.qingyunjiao.tag.api.service.TagQueryService;
import com.qingyunjiao.tag.common.response.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.validation.constraints.NotBlank;
import java.util.List;

/**
 * 标签查询API(支撑10万+QPS,延迟<5ms)
 * 适用场景:推荐、风控、营销等业务系统调用
 */
@RestController
@RequestMapping("/api/v1/tags")
@Api(tags = "标签查询API", description = "企业级用户画像标签查询接口")
@Validated
public class TagQueryController {

    @Autowired
    private TagQueryService tagQueryService;

    /**
     * 单用户全量标签查询
     * 示例:GET /api/v1/tags/user/U123456
     */
    @GetMapping("/user/{userId}")
    @ApiOperation(value = "单用户全量标签查询", notes = "查询用户所有启用标签,缓存命中延迟<5ms")
    public Result queryUserAllTags(
            @PathVariable @NotBlank(message = "user_id不能为空") String userId) {
        return Result.success(tagQueryService.queryUserAllTags(userId));
    }

    /**
     * 单用户指定标签查询
     * 示例:POST /api/v1/tags/user/specify → {"userId":"U123456","tagCodes":["user:high_value","preference:fav_category"]}
     */
    @PostMapping("/user/specify")
    @ApiOperation(value = "单用户指定标签查询", notes = "查询用户特定标签,最多支持100个标签")
    public Result queryUserSpecifyTags(
            @Validated @RequestBody TagQueryRequest request) {
        return Result.success(tagQueryService.queryUserSpecifyTags(request.getUserId(), request.getTagCodes()));
    }

    /**
     * 批量用户标签查询
     * 示例:POST /api/v1/tags/user/batch → {"userIds":["U123456","U789012"],"tagCodes":["user:high_value"]}
     */
    @PostMapping("/user/batch")
    @ApiOperation(value = "批量用户标签查询", notes = "支持最多1000个user_id批量查询")
    public Result<List> batchQueryUserTags(
            @Validated @RequestBody TagBatchQueryRequest request) {
        return Result.success(tagQueryService.batchQueryUserTags(request.getUserIds(), request.getTagCodes()));
    }

    /**
     * 标签值-用户列表查询
     * 示例:GET /api/v1/tags/value?tagCode=user:high_value&tagValue=1&page=1&size=100
     */
    @GetMapping("/value")
    @ApiOperation(value = "标签值-用户列表查询", notes = "查询符合标签值的用户集合,支持分页")
    public Result<List<String>> queryUserListByTagValue(
            @RequestParam @NotBlank(message = "tagCode不能为空") String tagCode,
            @RequestParam @NotBlank(message = "tagValue不能为空") String tagValue,
            @RequestParam(defaultValue = "1") Integer page,
            @RequestParam(defaultValue = "100") Integer size) {
        return Result.success(tagQueryService.queryUserListByTagValue(tagCode, tagValue, page, size));
    }
}

6.3 核心服务实现(三级缓存联动)

java 复制代码
package com.qingyunjiao.tag.api.service.impl;

import com.qingyunjiao.tag.api.dao.TagMetadataMapper;
import com.qingyunjiao.tag.api.service.TagQueryService;
import com.qingyunjiao.tag.common.constant.CacheConstant;
import com.qingyunjiao.tag.common.enums.TagStatusEnum;
import com.qingyunjiao.tag.common.model.TagQueryResponse;
import com.qingyunjiao.tag.common.utils.RedisUtil;
import com.qingyunjiao.tag.hbase.HBaseClient;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * 标签查询服务实现(三级缓存:Caffeine→Redis→HBase)
 */
@Service
public class TagQueryServiceImpl implements TagQueryService {
    private static final Logger logger = LoggerFactory.getLogger(TagQueryServiceImpl.class);

    @Autowired
    private HBaseClient hbaseClient;

    @Autowired
    private RedisUtil redisUtil;

    @Autowired
    private TagMetadataMapper tagMetadataMapper;

    // 本地缓存(Caffeine,10万用户,5分钟过期)
    private LoadingCache<String, Map<String, String>> localCache;

    @PostConstruct
    public void initLocalCache() {
        localCache = Caffeine.newBuilder()
                .maximumSize(100000)
                .expireAfterWrite(5, TimeUnit.MINUTES)
                .recordStats()
                .build(userId -> new HashMap<>());
    }

    /**
     * 单用户全量标签查询
     */
    @Override
    public TagQueryResponse queryUserAllTags(String userId) {
        long startTime = System.currentTimeMillis();
        TagQueryResponse response = new TagQueryResponse();
        response.setUserId(userId);
        Map<String, String> tagMap = new HashMap<>();

        try {
            // 1. 本地缓存查询(<1ms)
            tagMap = localCache.get(userId);
            if (CollectionUtils.isNotEmpty(tagMap)) {
                logger.info("本地缓存命中,userId:{},耗时:{}ms", userId, System.currentTimeMillis() - startTime);
                response.setTags(tagMap);
                return response;
            }

            // 2. Redis缓存查询(<5ms)
            String redisKey = CacheConstant.USER_TAG_REDIS_KEY + userId;
            tagMap = redisUtil.hGetAll(redisKey);
            if (CollectionUtils.isNotEmpty(tagMap)) {
                localCache.put(userId, tagMap); // 写入本地缓存
                logger.info("Redis缓存命中,userId:{},耗时:{}ms", userId, System.currentTimeMillis() - startTime);
                response.setTags(tagMap);
                return response;
            }

            // 3. HBase查询(<50ms)
            tagMap = hbaseClient.scanUserTags(userId);
            if (CollectionUtils.isEmpty(tagMap)) {
                logger.warn("用户标签不存在,userId:{},耗时:{}ms", userId, System.currentTimeMillis() - startTime);
                response.setTags(Collections.emptyMap());
                return response;
            }

            // 4. 写入Redis和本地缓存
            redisUtil.hMSet(redisKey, tagMap);
            redisUtil.expire(redisKey, CacheConstant.USER_TAG_EXPIRE_SECONDS); // 24小时
            localCache.put(userId, tagMap);

                     
            logger.info("HBase查询成功,userId:{},标签数:{},耗时:{}ms", 
                    userId, tagMap.size(), System.currentTimeMillis() - startTime);
            response.setTags(tagMap);
            return response;
        } catch (Exception e) {
            logger.error("单用户全量标签查询失败,userId:{}", userId, e);
            response.setTags(Collections.emptyMap());
            return response;
        }
    }

    /**
     * 单用户指定标签查询(优化:只查询需要的标签,减少数据传输)
     */
    @Override
    public TagQueryResponse queryUserSpecifyTags(String userId, List<String> tagCodes) {
        long startTime = System.currentTimeMillis();
        TagQueryResponse response = new TagQueryResponse();
        response.setUserId(userId);
        Map<String, String> tagMap = new HashMap<>();

        if (CollectionUtils.isEmpty(tagCodes)) {
            return queryUserAllTags(userId);
        }

        try {
            // 1. 过滤有效标签(只保留启用状态的标签)
            List<String> validTagCodes = tagMetadataMapper.selectTagCodeByStatus(tagCodes, TagStatusEnum.ENABLED.getCode());
            if (CollectionUtils.isEmpty(validTagCodes)) {
                logger.warn("无有效标签,userId:{},请求标签:{}", userId, tagCodes);
                response.setTags(Collections.emptyMap());
                return response;
            }

            // 2. 本地缓存查询
            Map<String, String> localTagMap = localCache.get(userId);
            if (CollectionUtils.isNotEmpty(localTagMap)) {
                for (String tagCode : validTagCodes) {
                    tagMap.put(tagCode, localTagMap.getOrDefault(tagCode, ""));
                }
                logger.info("本地缓存命中指定标签,userId:{},标签数:{},耗时:{}ms", 
                        userId, tagMap.size(), System.currentTimeMillis() - startTime);
                response.setTags(tagMap);
                return response;
            }

            // 3. Redis缓存查询(批量查询指定标签,减少网络往返)
            String redisKey = CacheConstant.USER_TAG_REDIS_KEY + userId;
            List<String> redisTagValues = redisUtil.hMGet(redisKey, validTagCodes.toArray(new String[0]));
            for (int i = 0; i < validTagCodes.size(); i++) {
                String tagCode = validTagCodes.get(i);
                String tagValue = redisTagValues.get(i);
                if (StringUtils.isNotBlank(tagValue)) {
                    tagMap.put(tagCode, tagValue);
                }
            }

            // 4. 识别缺失的标签
            Set<String> missingTagCodes = validTagCodes.stream()
                    .filter(tagCode -> !tagMap.containsKey(tagCode) || StringUtils.isBlank(tagMap.get(tagCode)))
                    .collect(Collectors.toSet());

            if (CollectionUtils.isEmpty(missingTagCodes)) {
                // 写入本地缓存
                localCache.put(userId, tagMap);
                logger.info("Redis缓存命中所有指定标签,userId:{},耗时:{}ms", userId, System.currentTimeMillis() - startTime);
                response.setTags(tagMap);
                return response;
            }

            // 5. HBase查询缺失的标签(只查需要的,减少IO)
            Map<String, String> hbaseTagMap = hbaseClient.getSpecifyUserTags(userId, missingTagCodes);
            tagMap.putAll(hbaseTagMap);

            // 6. 补全Redis缓存(只写入缺失的标签)
            if (CollectionUtils.isNotEmpty(hbaseTagMap)) {
                redisUtil.hMSet(redisKey, hbaseTagMap);
                redisUtil.expire(redisKey, CacheConstant.USER_TAG_EXPIRE_SECONDS);
            }

            // 7. 写入本地缓存
            localCache.put(userId, tagMap);

            logger.info("混合查询成功,userId:{},总标签数:{},缺失标签数:{},耗时:{}ms", 
                    userId, tagMap.size(), missingTagCodes.size(), System.currentTimeMillis() - startTime);
            response.setTags(tagMap);
            return response;
        } catch (Exception e) {
            logger.error("单用户指定标签查询失败,userId:{},标签:{}", userId, tagCodes, e);
            response.setTags(Collections.emptyMap());
            return response;
        }
    }

    /**
     * 批量用户标签查询(优化:Pipeline批量操作,减少网络往返)
     */
    @Override
    public List<TagQueryResponse> batchQueryUserTags(List<String> userIds, List<String> tagCodes) {
        long startTime = System.currentTimeMillis();
        List<TagQueryResponse> responses = new ArrayList<>(userIds.size());

        // 过滤有效标签
        List<String> validTagCodes = tagMetadataMapper.selectTagCodeByStatus(tagCodes, TagStatusEnum.ENABLED.getCode());
        if (CollectionUtils.isEmpty(validTagCodes)) {
            logger.warn("无有效标签,userIds:{}", userIds);
            userIds.forEach(userId -> {
                TagQueryResponse response = new TagQueryResponse();
                response.setUserId(userId);
                response.setTags(Collections.emptyMap());
                responses.add(response);
            });
            return responses;
        }

        try {
            // 1. 批量查询Redis(Pipeline操作,一次网络往返)
            Map<String, Map<String, String>> redisTagMap = redisUtil.batchHMGet(
                    userIds.stream().map(userId -> CacheConstant.USER_TAG_REDIS_KEY + userId).collect(Collectors.toList()),
                    validTagCodes
            );

            // 2. 处理每个用户的标签
            for (String userId : userIds) {
                TagQueryResponse response = new TagQueryResponse();
                response.setUserId(userId);
                Map<String, String> tagMap = new HashMap<>();

                String redisKey = CacheConstant.USER_TAG_REDIS_KEY + userId;
                Map<String, String> userRedisTags = redisTagMap.getOrDefault(redisKey, new HashMap<>());

                // 提取Redis中存在的标签
                for (String tagCode : validTagCodes) {
                    String tagValue = userRedisTags.getOrDefault(tagCode, "");
                    if (StringUtils.isNotBlank(tagValue)) {
                        tagMap.put(tagCode, tagValue);
                    }
                }

                // 识别缺失的标签
                Set<String> missingTagCodes = validTagCodes.stream()
                        .filter(tagCode -> !tagMap.containsKey(tagCode) || StringUtils.isBlank(tagMap.get(tagCode)))
                        .collect(Collectors.toSet());

                if (CollectionUtils.isEmpty(missingTagCodes)) {
                    localCache.put(userId, tagMap);
                    response.setTags(tagMap);
                    responses.add(response);
                    continue;
                }

                // HBase查询缺失的标签
                Map<String, String> hbaseTagMap = hbaseClient.getSpecifyUserTags(userId, missingTagCodes);
                tagMap.putAll(hbaseTagMap);

                // 补全Redis缓存
                if (CollectionUtils.isNotEmpty(hbaseTagMap)) {
                    redisUtil.hMSet(redisKey, hbaseTagMap);
                    redisUtil.expire(redisKey, CacheConstant.USER_TAG_EXPIRE_SECONDS);
                }

                // 写入本地缓存
                localCache.put(userId, tagMap);
                response.setTags(tagMap);
                responses.add(response);
            }

            logger.info("批量查询完成,用户数:{},标签数:{},耗时:{}ms", 
                    userIds.size(), validTagCodes.size(), System.currentTimeMillis() - startTime);
            return responses;
        } catch (Exception e) {
            logger.error("批量用户标签查询失败,userIds:{}", userIds, e);
            userIds.forEach(userId -> {
                TagQueryResponse response = new TagQueryResponse();
                response.setUserId(userId);
                response.setTags(Collections.emptyMap());
                responses.add(response);
            });
            return responses;
        }
    }

    /**
     * 按标签值查询用户列表(支持分页,大数据量异步处理)
     */
    @Override
    public List<String> queryUserListByTagValue(String tagCode, String tagValue, Integer page, Integer size) {
        long startTime = System.currentTimeMillis();

        try {
            // 1. 先查Redis索引(快速获取用户数)
            String redisIndexKey = CacheConstant.TAG_VALUE_USER_INDEX_KEY + tagCode + ":" + tagValue;
            Long userCount = redisUtil.sCard(redisIndexKey);
            if (userCount != null && userCount > 0) {
                // Redis中存在索引,直接分页查询
                return redisUtil.sMembersPage(redisIndexKey, (page - 1) * size, size);
            }

            // 2. Redis无索引,查HBase索引表
            List<String> userIdList = hbaseClient.queryUserListByTagValue(tagCode, tagValue, page, size);

            // 3. 同步索引到Redis(后台异步,不阻塞当前查询)
            asyncSyncTagUserIndexToRedis(tagCode, tagValue, userIdList);

            logger.info("按标签值查询用户列表完成,tagCode:{},tagValue:{},用户数:{},耗时:{}ms", 
                    tagCode, tagValue, userIdList.size(), System.currentTimeMillis() - startTime);
            return userIdList;
        } catch (Exception e) {
            logger.error("按标签值查询用户列表失败,tagCode:{},tagValue:{}", tagCode, tagValue, e);
            return Collections.emptyList();
        }
    }

    /**
     * 异步同步标签-用户索引到Redis(提升查询效率)
     */
    private void asyncSyncTagUserIndexToRedis(String tagCode, String tagValue, List<String> userIdList) {
        executorService.submit(() -> {
            try {
                String redisIndexKey = CacheConstant.TAG_VALUE_USER_INDEX_KEY + tagCode + ":" + tagValue;
                redisUtil.sAdd(redisIndexKey, userIdList.toArray(new String[0]));
                redisUtil.expire(redisIndexKey, CacheConstant.TAG_USER_INDEX_EXPIRE_SECONDS); // 7天过期
                logger.info("异步同步标签-用户索引到Redis成功,tagCode:{},tagValue:{},用户数:{}", 
                        tagCode, tagValue, userIdList.size());
            } catch (Exception e) {
                logger.error("异步同步标签-用户索引失败,tagCode:{},tagValue:{}", tagCode, tagValue, e);
            }
        });
    }
}                        

6.4 查询服务性能优化(10 年实战沉淀)

优化方向 具体措施 性能提升效果 实战案例
三级缓存联动 本地缓存(Caffeine)→ Redis → HBase,优先级递减,热点标签命中率≥95% 查询延迟从 50ms→5ms,QPS 从 1 万→10 万 + 电商 APP 首页推荐,支撑 20 万 QPS
Redis Pipeline 批量查询 / 写入使用 Pipeline,减少网络往返次数(1 次代替 100 次) 批量查询耗时从 100ms→10ms(100 用户) 营销系统批量用户标签查询
缓存预热 每日凌晨批量加载前 100 万高活跃用户标签到本地缓存 + Redis 高活跃用户查询延迟 < 1ms 直播平台在线用户标签查询
缓存穿透防护 无效 user_id 返回空值并缓存 5 分钟,布隆过滤器过滤不存在的 user_id HBase 无效查询占比从 30%→0.1% 抵御恶意刷接口攻击
缓存击穿防护 热点用户标签永不过期,互斥锁更新缓存 热点用户查询无超时,可用性 100% 头部主播粉丝标签查询
数据压缩 Redis 存储启用 LZ4 压缩,HBase 列族启用 Snappy 压缩 存储占用减少 60%,网络传输耗时减少 40% 跨机房标签查询

实战案例:2023 年某电商大促,标签查询 QPS 从日常 5 万突增到 20 万,因提前做了三级缓存和预热,查询延迟稳定在 5ms 以内,无一次超时,支撑了大促期间的个性化推荐和精准营销。

6.5 实战踩坑总结(查询服务篇)

坑点类型 具体问题 解决方案 影响范围
缓存穿透 恶意请求不存在的 user_id,导致大量请求打到 HBase 1)缓存空值(5 分钟过期);2)布隆过滤器过滤无效 user_id;3)接口层校验格式 HBase QPS 从 1 万→10 万,延迟飙升
缓存击穿 热点用户标签过期,大量请求同时穿透到 HBase 1)热点用户标签永不过期;2)互斥锁更新缓存;3)提前预热 热点用户查询延迟 1ms→100ms
缓存雪崩 Redis 集群故障,所有请求打到 HBase,导致 HBase 宕机 1)Redis 主从 + 哨兵;2)服务熔断降级(返回本地缓存);3)HBase 限流 服务不可用,业务系统报错
批量查询超时 批量查询 1000 用户时,Redis Pipeline 阻塞 1)分批次批量查询(每批 200 用户);2)异步批量处理 批量查询超时率 5%→0%
跨机房延迟 多机房部署时,跨机房查询 HBase 延迟 > 100ms 1)标签数据就近存储;2)CDN 缓存热点标签;3)异地多活部署 跨机房查询延迟 100ms→20ms

七、监控告警体系(企业级 7×24 小时保障)

7.1 全链路监控架构(优化版)

告警通知层 监控对象层 监控采集层 🚨 钉钉机器人 📧 邮件告警 📱 短信告警(P0级) 📥 采集层(Flume/Kafka) 💾 存储层(HBase/Redis/MySQL) ⚡ 计算层(Spark) 🔍 查询层(Spring Boot) 📈 Grafana(可视化) 📊 Prometheus(指标采集) 🗄️ Elasticsearch(日志存储) 📜 Filebeat(日志采集) 🔍 Kibana(日志分析) TD

7.2 核心监控指标(系统 + 业务)

监控维度 核心指标 告警阈值 告警级别 监控工具 业务影响
系统层 CPU 使用率、内存使用率、磁盘使用率 CPU>85%、内存 > 90%、磁盘 > 85% P1 Prometheus+Grafana 系统性能下降,响应变慢
采集层 Flume 采集延迟、Kafka 消息堆积数 采集延迟 > 5 分钟、堆积 > 10 万条 P1 Prometheus+Grafana 数据延迟,标签计算不准确
计算层 Spark 任务失败率、执行时间 失败率 > 1%、执行时间 > 2 小时 P0 Airflow+Prometheus 标签计算失败,业务无标签可用
存储层 HBase 查询延迟、Redis 命中率 HBase P99 延迟 > 100ms、Redis 命中率 < 90% P1 Prometheus+Grafana 标签查询延迟升高,QPS 下降
查询层 API QPS、响应时间、失败率 QPS>10 万(阈值)、响应时间 P99>50ms、失败率 > 1% P0 Prometheus+Grafana 业务系统调用失败,功能不可用
业务层 标签覆盖率、数据清洗率 标签覆盖率 < 95%、清洗率 < 95% P1 Prometheus+Grafana 标签缺失,业务决策失误

7.3 自定义监控埋点(业务指标)

java 复制代码
package com.qingyunjiao.tag.common.monitor;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 业务指标监控埋点(用户画像核心指标)
 */
@Component
public class BusinessMetricsMonitor {
    @Autowired
    private MeterRegistry meterRegistry;

    // 计数器:API调用量、成功/失败次数
    private Counter apiCallCounter;
    private Counter apiSuccessCounter;
    private Counter apiFailCounter;

    // 标签覆盖率统计(key=标签类型,value=覆盖用户数)
    private Map<String, Long> tagCoverageMap = new ConcurrentHashMap<>();

    // 数据清洗率统计
    private volatile double cleanRate;

    @PostConstruct
    public void init() {
        // 初始化计数器
        apiCallCounter = meterRegistry.counter("tag.query.api.call.count");
        apiSuccessCounter = meterRegistry.counter("tag.query.api.success.count");
        apiFailCounter = meterRegistry.counter("tag.query.api.fail.count");

        // 初始化标签覆盖率Gauge
        Gauge.builder("tag.coverage.rate", tagCoverageMap, this::calculateCoverageRate)
                .description("标签覆盖率(覆盖用户数/总用户数)")
                .register(meterRegistry);

        // 初始化数据清洗率Gauge
        Gauge.builder("tag.data.clean.rate", this, monitor -> monitor.cleanRate)
                .description("数据清洗率(%)")
                .register(meterRegistry);
    }

    /**
     * 记录API调用
     */
    public void recordApiCall(boolean success) {
        apiCallCounter.increment();
        if (success) {
            apiSuccessCounter.increment();
        } else {
            apiFailCounter.increment();
        }
    }

    /**
     * 更新标签覆盖率
     */
    public void updateTagCoverage(String tagType, long coverageUserCount, long totalUserCount) {
        tagCoverageMap.put(tagType, coverageUserCount);
        tagCoverageMap.put("total_user_count", totalUserCount);
    }

    /**
     * 更新数据清洗率
     */
    public void updateCleanRate(double rate) {
        this.cleanRate = rate;
    }

    /**
     * 计算标签覆盖率
     */
    private double calculateCoverageRate(Map<String, Long> map) {
        long totalUserCount = map.getOrDefault("total_user_count", 0L);
        if (totalUserCount == 0) {
            return 0.0;
        }

        long totalCoverage = 0;
        for (Map.Entry<String, Long> entry : map.entrySet()) {
            if (!"total_user_count".equals(entry.getKey())) {
                totalCoverage += entry.getValue();
            }
        }

        return (double) totalCoverage / totalUserCount * 100;
    }
}

7.4 告警分级策略(避免告警风暴)

告警级别 定义 响应时间 告警方式 处理人 示例场景
P0(致命) 核心服务不可用,影响全业务 5 分钟 钉钉 + 短信 + 电话轮询 架构师 + 运维负责人 Spark 任务失败率 100%,标签无法计算
P1(紧急) 服务性能下降,影响部分业务 15 分钟 钉钉 + 短信 开发负责人 + 运维工程师 Redis 命中率 85%,查询延迟升高
P2(普通) 非核心指标异常,不影响业务 1 小时 钉钉 开发工程师 数据清洗率 94%(阈值 95%)
P3(提示) 指标波动,无业务影响 24 小时 邮件 数据分析师 标签覆盖率 96%(阈值 95%)

实战感悟:监控告警的核心是 "精准",2022 年某金融项目,因未设置告警分级,一次 Kafka 消息堆积触发了全员短信轰炸,导致重要告警被忽略。后来优化分级策略,仅 P0/P1 级触发短信,告警有效率从 30% 提升到 90%。

八、部署运维(企业级容器化 + 高可用)

8.1 部署架构(K8s+Docker)

8.2 Dockerfile 实战(标签查询服务)

dockerfile 复制代码
# 多阶段构建:编译→运行,减少镜像体积
FROM maven:3.8.8-openjdk-17-slim AS builder

# 工作目录
WORKDIR /app

# 复制pom.xml和源码
COPY pom.xml .
COPY src ./src

# 国内镜像源加速
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list && \
    apt-get update && apt-get install -y --no-install-recommends git && \
    mvn clean package -DskipTests -U -Pprod

# 运行阶段(轻量级JRE镜像)
FROM openjdk:17-jre-slim

# 维护者信息
MAINTAINER "青云交 <qingyunjiao@qingyunjiao.com>"

# 设置时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

# 创建非root用户(安全最佳实践)
RUN groupadd -r tag && useradd -r -g tag tag
WORKDIR /opt/tag-service
RUN chown -R tag:tag /opt/tag-service

# 复制编译产物
COPY --from=builder /app/target/tag-query-service-1.0.0.jar ./app.jar
COPY --from=builder /app/target/lib ./lib

# JVM优化参数(生产级配置,避免OOM和GC频繁)
ENV JAVA_OPTS="-Xms4g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/tag-service/logs/heapdump.hprof -Duser.timezone=Asia/Shanghai"

# 暴露端口
EXPOSE 8080

# 切换非root用户
USER tag

# 启动命令(支持优雅停机)
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar --spring.profiles.active=prod"]

# 健康检查(K8s存活探针)
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD curl -f http://localhost:8080/actuator/health || exit 1

8.3 K8s 部署配置(Deployment+Service+Ingress)

8.3.1 Deployment.yaml
yaml 复制代码
apiVersion: apps/v1
kind: Deployment
metadata:
  name: tag-query-service
  namespace: user-profile
  labels:
    app: tag-query-service
spec:
  replicas: 3 # 3副本,高可用
  selector:
    matchLabels:
      app: tag-query-service
  strategy:
    rollingUpdate:
      maxSurge: 1 # 滚动更新时最多新增1个副本
      maxUnavailable: 0 # 不允许不可用副本
  template:
    metadata:
      labels:
        app: tag-query-service
    spec:
      containers:
      - name: tag-query-service
        image: harbor.xxx.com/user-profile/tag-query-service:1.0.0
        imagePullPolicy: Always
        ports:
        - containerPort: 8080
        resources:
          requests:
            cpu: "1"
            memory: "4Gi"
          limits:
            cpu: "2"
            memory: "8Gi"
        env:
        - name: SPRING_REDIS_HOST
          valueFrom:
            configMapKeyRef:
              name: user-profile-config
              key: redis_host
        - name: SPRING_REDIS_PASSWORD
          valueFrom:
            secretKeyRef:
              name: user-profile-secret
              key: redis_password
        - name: HBASE_ZK_QUORUM
          valueFrom:
            configMapKeyRef:
              name: user-profile-config
              key: hbase_zk_quorum
        # 健康检查
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 60
          periodSeconds: 10
          timeoutSeconds: 5
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 5
        # 优雅停机
        lifecycle:
          preStop:
            exec:
              command: ["sh", "-c", "sleep 10"]
        volumeMounts:
        - name: log-volume
          mountPath: /opt/tag-service/logs
      volumes:
      - name: log-volume
        hostPath:
          path: /data/logs/tag-query-service
          type: DirectoryOrCreate
      # 私有仓库镜像拉取密钥
      imagePullSecrets:
      - name: harbor-secret
8.3.2 Service.yaml
yaml 复制代码
apiVersion: v1
kind: Service
metadata:
  name: tag-query-service
  namespace: user-profile
spec:
  selector:
    app: tag-query-service
  ports:
  - port: 8080
    targetPort: 8080
    protocol: TCP
    name: http
  type: ClusterIP
8.3.3 Ingress.yaml
yaml 复制代码
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: tag-query-service-ingress
  namespace: user-profile
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
    nginx.ingress.kubernetes.io/proxy-connect-timeout: "30"
spec:
  ingressClassName: nginx
  rules:
  - host: tag-api.xxx.com
    http:
      paths:
      - path: /api/v1/tags
        pathType: Prefix
        backend:
          service:
            name: tag-query-service
            port:
              number: 8080
  tls:
  - hosts:
    - tag-api.xxx.com
    secretName: tag-api-tls-secret

8.4 运维最佳实践(10 年实战总结)

8.4.1 发布策略:蓝绿部署
  • 部署蓝环境(当前版本)和绿环境(新版本),两套环境隔离;
  • 绿环境测试通过后,通过 Ingress 切换流量;
  • 观察 10 分钟无异常则下线蓝环境,有异常则快速回滚。
8.4.2 容灾备份
数据类型 备份策略 恢复策略 备份周期
HBase 标签数据 每日全量备份 + WAL 日志实时备份 按时间点恢复(PITR) 全量:每日凌晨;WAL:实时
Redis 缓存数据 每日 RDB 备份 + AOF 实时备份 主从切换 + RDB 恢复 RDB:每日凌晨;AOF:每秒同步
MySQL 元数据 每日全量备份 + binlog 实时备份 主从切换 + binlog 恢复 全量:每日凌晨;binlog:实时
代码配置 Git 版本控制 + Harbor 镜像仓库 回滚到上一稳定版本 每次发布打 Tag
8.4.3 故障排查三板斧
  • 日志排查:通过 ELK 查询关键日志(user_id、请求 ID),定位异常请求;
  • 指标排查:通过 Grafana 查看指标趋势(QPS、延迟、失败率),定位瓶颈;
  • 链路追踪:通过 SkyWalking 追踪请求全链路,定位慢节点。

实战案例:2024 年某项目,标签查询延迟突然从 5ms 飙升到 500ms,通过链路追踪发现 HBase RegionServer 3 节点负载不均,手动拆分 Region 后,延迟恢复正常。

九、企业级压测与调优案例(亿级用户场景)

9.1 压测环境配置

组件 配置 数量 备注
K8s 节点 8 核 32G,CentOS 7.9 10 3 主 7 从
Spark 集群 16 核 64G,Worker 节点 10 个 10 批流混合部署
HBase 集群 16 核 64G,RegionServer 8 个 8 预分区 16 个,列族 4 个
Redis 集群 8 核 32G,主从 + 哨兵,6 节点 6 分片 16 个,缓存命中率 95%+
标签查询服务 8 核 16G,3 副本 3 JVM:Xms4g Xmx8g G1GC

9.2 压测场景与结果(JMeter)

压测场景 并发数 QPS 平均响应时间 99% 响应时间 失败率 调优前 vs 调优后
单用户全量标签查询 1000 8000 10ms 20ms 0% 调优前:5000 QPS,20ms
单用户指定标签查询 2000 15000 5ms 10ms 0% 调优前:10000 QPS,10ms
批量用户标签查询(1000 用户) 500 5000 50ms 100ms 0% 调优前:3000 QPS,100ms
标签值 - 用户列表查询 100 1000 200ms 500ms 0% 调优前:500 QPS,500ms

9.3 核心调优措施(实战总结)

  • Spark 调优:
    • 调整 Shuffle 分区数为 200(默认 200,根据数据量优化);
    • 启用自适应执行(Adaptive Execution),自动合并小分区;
    • 大表 Join 使用 Broadcast Join,减少 Shuffle 数据量。
  • HBase 调优:
    • 预分区 16 个,均匀分布数据,避免热点 Region;
    • 调整 BlockCache 大小为 40%,提升读性能;
    • 批量写入使用 BufferedMutator,缓冲区 5MB。
  • Redis 调优:
    • 开启混合持久化(RDB+AOF),兼顾性能和安全性;
    • 调整最大内存策略为 volatile-lru,优先淘汰过期 Key;
    • 批量操作使用 Pipeline,减少网络往返。
  • JVM 调优:
    • 使用 G1GC 代替 CMS,降低 GC 停顿时间;
    • 调整新生代比例为 50%,减少老年代 GC 频率;
    • 关闭显式 GC,避免手动触发 Full GC。
  • 网络调优:
    • 开启网卡多队列,提升网络吞吐量;
    • 调整 TCP 缓冲区大小为 1024k,优化网络传输;
    • 关闭 TCP_TIMEWAIT,快速释放端口。

实战感悟:压测不是 "走过场",而是 "暴露问题"。2023 年某金融项目,压测时发现 99% 响应时间 > 500ms,排查后是 Spark Shuffle 分区数不合理导致数据倾斜,调整后延迟降至 100ms 内 ------ 企业级系统必须在压测中解决所有性能瓶颈。

结束语:

亲爱的 Java大数据爱好者们,10 余年 Java 大数据实战,从百万用户到亿级并发,从物理机部署到容器化集群,我深刻明白:用户画像平台的核心不是 "技术多先进",而是 "业务适配度 + 稳定可靠性"。它不是孤立的技术系统,而是连接数据与业务的桥梁 ------ 让推荐更精准、营销更高效、风控更及时,最终实现业务增长。

这篇文章拆解的全流程方案,每一行代码、每一个优化点、每一个踩坑总结,都来自真实生产环境的血泪经验。希望能帮你少走 5 年弯路,也欢迎你在评论区分享你的项目经历 ------ 技术的进步从来不是一个人的独行,而是一群人的同行。

未来,用户画像将朝着 "实时化、智能化、合规化" 方向发展:实时计算延迟从小时级降至秒级,AI 大模型辅助标签规则生成,隐私计算技术保障数据安全。我会持续分享更多实战干货,关注我,一起成为更顶尖的技术人!

诚邀各位参与投票,用户画像平台落地中,你最关注哪个环节的性能 / 稳定性?快来投票。


🗳️参与投票和联系我:

返回文章

相关推荐
航Hang*2 小时前
第3章:复习篇——第1节:创建和管理数据库
开发语言·数据库·笔记·sql·sqlserver
云栖梦泽2 小时前
鸿蒙原子化服务开发实战:构建免安装的轻量应用
开发语言·鸿蒙系统
YY&DS2 小时前
《Qt 手写 HTTP 登录服务实战》
开发语言·qt·http
阿华hhh2 小时前
数据结构(树)
linux·c语言·开发语言·数据结构
雪域迷影2 小时前
Windows11中VS2026使用C++ 现代化json库nlohmann的3种方式
开发语言·c++·json
铉铉这波能秀2 小时前
正则表达式从入门到精通(字符串模式匹配)
java·数据库·python·sql·正则表达式·模式匹配·表格处理
步步为营DotNet2 小时前
深度剖析.NET中HttpClient的请求重试机制:可靠性提升与实践优化
开发语言·php·.net
zephyr052 小时前
C++ STL string 用法详解与示例
开发语言·c++
郝学胜-神的一滴2 小时前
Linux线程的共享资源与非共享资源详解
linux·服务器·开发语言·c++·程序人生·设计模式