电商用户画像系统#
项目概述#
电商用户画像系统是一个基于大数据技术的用户分析平台,旨在通过收集、分析用户的行为数据,构建精准的用户画像,为电商平台的个性化推荐、精准营销和用户体验优化提供数据支持。本系统整合了Spark、Hadoop、Hive、HBase、Kafka等大数据技术,实现了从数据采集到画像构建、应用的完整流程。
技术栈版本#
| 组件 | 版本 | 说明 |
|---|---|---|
| Spark | 3.5.8 | 分布式计算引擎 |
| Scala | 2.13.8 | 开发语言 |
| JDK | 11 | Java运行环境 |
| Kafka | 3.7.0 | 消息队列 |
| Redis | 7.0+ | 热数据缓存 |
| MySQL | 8.0 | 结构化数据存储 |
| HBase | 2.5.x | 标签数据存储 |
| Hive | 3.1.x | 数据仓库 |
| Hadoop | 3.3.x | 分布式存储 |
知识点讲解#
知识点1:用户画像概述#
什么是用户画像#
用户画像(User Profile)是将用户的多维度信息进行标签化、结构化描述的一种数据模型。它通过对用户的基础属性、行为特征、消费偏好等信息进行抽象和归纳,形成对用户的全面刻画。简单来说,用户画像就是用数据为用户"画一张像"。
类比理解:想象你在描述一位朋友——“25岁、女性、喜欢运动、经常网购、偏好中高端品牌”。用户画像就是让计算机用同样的方式理解和描述每一个用户,只不过计算机可以同时处理上千万用户的画像。
用户画像的核心价值#
+--------------------------------------------------+
| 用户画像核心价值 |
+--------------------------------------------------+
| |
| +------------+ +------------+ +------------+ |
| | 精准营销 | | 个性化推荐 | | 风险控制 | |
| +------------+ +------------+ +------------+ |
| | 定向投放 | | 商品推荐 | | 欺诈检测 | |
| | 差异化定价 | | 内容推荐 | | 信用评估 | |
| | 活动触达 | | 搜索排序 | | 流失预警 | |
| +------------+ +------------+ +------------+ |
| |
| +------------+ +------------+ |
| | 产品优化 | | 运营决策 | |
| +------------+ +------------+ |
| | 功能改进 | | 资源配置 | |
| | 体验提升 | | 战略规划 | |
| +------------+ +------------+ |
+--------------------------------------------------+用户画像的三大应用场景#
1. 精准营销
精准营销是用户画像最直接的应用场景。通过用户画像,企业可以:
- 定向广告投放:根据用户的兴趣标签,将广告精准推送给目标用户群体,降低营销成本,提高转化率
- 差异化营销策略:针对不同价值层级的用户制定不同的营销方案,如高价值用户推送高端品牌优惠,价格敏感用户推送折扣信息
- 营销活动触达:选择用户最活跃的时间段和渠道进行营销信息推送,提升触达效果
2. 个性化推荐
个性化推荐是用户画像在产品层面的核心应用:
- 商品推荐:基于用户的浏览、购买历史和兴趣标签,推荐可能感兴趣的商品
- 内容推荐:根据用户的内容偏好,推荐相关的文章、视频等内容
- 搜索排序优化:根据用户画像调整搜索结果的排序,优先展示用户偏好的品类和品牌
3. 风险控制
风险控制是用户画像在安全层面的重要应用:
- 欺诈检测:通过用户行为画像识别异常行为,如短时间内大量下单、收货地址频繁变更等
- 信用评估:根据用户的消费能力、还款记录等画像信息评估信用等级
- 流失预警:通过分析用户活跃度变化趋势,提前识别可能流失的用户并采取挽回措施
知识点2:标签体系设计#
标签的分类#
标签体系是用户画像的核心骨架。根据标签的生成方式,可以将标签分为三大类:
+-----------------------------------------------------------------+
| 标签体系分类 |
+-----------------------------------------------------------------+
| |
| +------------------+ +------------------+ +----------------+ |
| | 事实标签 | | 统计标签 | | 算法标签 | |
| +------------------+ +------------------+ +----------------+ |
| | 直接从原始数据 | | 对事实标签进行 | | 通过机器学习 | |
| | 中提取的客观 | | 统计计算得到的 | | 算法挖掘得到 | |
| | 事实描述 | | 聚合指标 | | 的预测标签 | |
| +------------------+ +------------------+ +----------------+ |
| | - 性别: 男/女 | | - 消费总金额 | | - 购买倾向 | |
| | - 年龄: 25岁 | | - 购买频次 | | - 流失概率 | |
| | - 注册日期 | | - 平均客单价 | | - 信用等级 | |
| | - 所在城市 | | - 最近购买天数 | | - 用户分群 | |
| +------------------+ +------------------+ +----------------+ |
+-----------------------------------------------------------------+事实标签:最基础的标签类型,直接来源于原始数据,不需要复杂的计算。例如用户的性别、年龄、注册时间、所在城市等。事实标签是构建其他标签的基础。
统计标签:在事实标签的基础上进行统计计算得到的标签。例如用户的消费总金额、购买频次、平均客单价、最近一次购买距今天数等。统计标签反映了用户的行为特征和消费能力。
算法标签:通过机器学习算法对用户数据进行深度挖掘得到的预测性标签。例如用户的购买倾向评分、流失概率、信用等级、用户分群归属等。算法标签是最有价值的标签类型,但也是计算成本最高的。
标签层级设计#
在实际的企业级项目中,标签通常按照层级进行组织,形成树状结构:
一级标签(大类)
├── 二级标签(中类)
│ ├── 三级标签(小类/具体标签)
│ ├── 三级标签
│ └── 三级标签
├── 二级标签
│ ├── 三级标签
│ └── 三级标签
└── 二级标签电商用户画像标签体系示例:
| 一级标签 | 二级标签 | 三级标签 | 标签类型 |
|---|---|---|---|
| 人口属性 | 基本属性 | 性别 | 事实标签 |
| 人口属性 | 基本属性 | 年龄段 | 统计标签 |
| 人口属性 | 基本属性 | 婚姻状况 | 事实标签 |
| 人口属性 | 地域属性 | 所在省份 | 事实标签 |
| 人口属性 | 地域属性 | 城市等级 | 统计标签 |
| 消费特征 | 消费能力 | 消费总金额 | 统计标签 |
| 消费特征 | 消费能力 | 平均客单价 | 统计标签 |
| 消费特征 | 消费能力 | 购买力等级 | 算法标签 |
| 消费特征 | 消费偏好 | 偏好品类 | 统计标签 |
| 消费特征 | 消费偏好 | 偏好品牌 | 统计标签 |
| 消费特征 | 消费偏好 | 价格敏感度 | 算法标签 |
| 行为特征 | 活跃度 | 最近访问天数 | 统计标签 |
| 行为特征 | 活跃度 | 登录频次 | 统计标签 |
| 行为特征 | 活跃度 | 活跃等级 | 算法标签 |
| 行为特征 | 购物行为 | 购买频次 | 统计标签 |
| 行为特征 | 购物行为 | RFM分群 | 算法标签 |
| 风险属性 | 信用风险 | 信用等级 | 算法标签 |
| 风险属性 | 流失风险 | 流失概率 | 算法标签 |
知识点3:RFM模型#
RFM模型定义#
RFM模型是用户画像中最经典、最实用的用户价值分析模型之一。它通过三个维度来衡量用户的价值:
+-------------------------------------------------------+
| RFM模型 |
+-------------------------------------------------------+
| |
| R - Recency(最近消费时间) |
| ├── 用户最近一次消费距今天数 |
| ├── R值越小,说明用户最近刚消费过,活跃度越高 |
| └── 反映用户的当前活跃状态 |
| |
| F - Frequency(消费频率) |
| ├── 用户在统计周期内的消费次数 |
| ├── F值越大,说明用户消费越频繁,忠诚度越高 |
| └── 反映用户的消费习惯和忠诚度 |
| |
| M - Monetary(消费金额) |
| ├── 用户在统计周期内的消费总金额 |
| ├── M值越大,说明用户消费能力越强,贡献度越高 |
| └── 反映用户的消费能力和贡献价值 |
+-------------------------------------------------------+类比理解:想象你经营一家餐厅——R是"这位客人多久没来了",F是"这位客人一个月来几次",M是"这位客人每次消费多少钱"。显然,最近常来、来得频繁、每次花得多的客人是最有价值的。
RFM评分规则#
在实际应用中,我们通常将R、F、M三个维度的原始值转换为评分(一般采用1-5分制),然后根据评分进行用户分群:
R评分规则(最近消费时间越近,评分越高):
| R值范围 | 评分 | 含义 |
|---|---|---|
| 0-30天 | 5 | 非常活跃 |
| 31-60天 | 4 | 较活跃 |
| 61-90天 | 3 | 一般活跃 |
| 91-180天 | 2 | 不太活跃 |
| 180天以上 | 1 | 很不活跃 |
F评分规则(消费频率越高,评分越高):
| F值范围 | 评分 | 含义 |
|---|---|---|
| 20次以上 | 5 | 非常频繁 |
| 10-19次 | 4 | 较频繁 |
| 5-9次 | 3 | 一般频繁 |
| 2-4次 | 2 | 较少 |
| 1次 | 1 | 极少 |
M评分规则(消费金额越高,评分越高):
| M值范围 | 评分 | 含义 |
|---|---|---|
| 10000元以上 | 5 | 高消费 |
| 5000-9999元 | 4 | 中高消费 |
| 2000-4999元 | 3 | 中等消费 |
| 500-1999元 | 2 | 中低消费 |
| 500元以下 | 1 | 低消费 |
用户分群策略#
根据RFM评分的高低(以各维度平均分为阈值),可以将用户划分为8种类型:
| R评分 | F评分 | M评分 | 用户类型 | 特征描述 | 运营策略 |
|---|---|---|---|---|---|
| 高 | 高 | 高 | 重要价值用户 | 最近有消费、频率高、金额大 | VIP服务、专属优惠、保持关系 |
| 高 | 高 | 低 | 重要保持用户 | 最近有消费、频率高、但金额小 | 提升客单价、关联推荐 |
| 高 | 低 | 高 | 重要发展用户 | 最近有消费、频率低、金额大 | 提升消费频次、会员激励 |
| 高 | 低 | 低 | 重要挽留用户 | 最近有消费、频率低、金额小 | 引导复购、优惠券刺激 |
| 低 | 高 | 高 | 重要唤回用户 | 较久未消费、频率高、金额大 | 唤醒召回、专属活动 |
| 低 | 高 | 低 | 一般维持用户 | 较久未消费、频率高、金额小 | 常规触达、保持关注 |
| 低 | 低 | 高 | 一般发展用户 | 较久未消费、频率低、金额大 | 重新激活、新品推荐 |
| 低 | 低 | 低 | 流失预警用户 | 较久未消费、频率低、金额小 | 低成本维护或放弃 |
知识点4:用户画像构建流程#
用户画像的构建是一个系统化的工程,包含从数据采集到画像服务的完整流程:
+--------------------------------------------------------------------+
| 用户画像构建流程 |
+--------------------------------------------------------------------+
| |
| [1.数据采集] --> [2.数据清洗] --> [3.标签计算] --> [4.画像存储] --> [5.画像服务]
| |
| +-----------+ +-----------+ +-----------+ +-----------+ +-----------+
| | 数据采集 | | 数据清洗 | | 标签计算 | | 画像存储 | | 画像服务 |
| +-----------+ +-----------+ +-----------+ +-----------+ +-----------+
| | - 日志采集| | - 去重 | | - 事实标签| | - Redis | | - REST API|
| | - 业务数据| | - 缺失值 | | - 统计标签| | - HBase | | - 查询服务|
| | - 第三方 | | - 异常值 | | - 算法标签| | - MySQL | | - 推送服务|
| | - 爬虫 | | - 格式化 | | - RFM模型 | | - Hive | | - 降级方案|
| +-----------+ +-----------+ +-----------+ +-----------+ +-----------+
| |
+--------------------------------------------------------------------+阶段1:数据采集
数据采集是用户画像构建的第一步,数据的质量和丰富度直接决定了画像的准确性。主要数据来源包括:
- 日志数据:用户浏览、点击、搜索等行为日志,通过Kafka、Flume等工具采集
- 业务数据:订单、支付、退款等交易数据,来自MySQL等业务数据库
- 第三方数据:社交数据、地理位置数据等外部数据源
- 埋点数据:前端页面埋点采集的用户交互数据
阶段2:数据清洗
原始数据通常存在各种质量问题,需要经过清洗才能用于标签计算:
- 去重处理:去除重复采集的数据记录
- 缺失值处理:对缺失字段进行填充或丢弃
- 异常值处理:识别并处理明显不合理的数据(如负数的订单金额)
- 格式统一:统一日期格式、编码格式等
阶段3:标签计算
标签计算是用户画像构建的核心环节,根据标签类型采用不同的计算方式:
- 事实标签:直接从清洗后的数据中提取,如性别、年龄
- 统计标签:通过SQL聚合计算,如消费总金额、购买频次
- 算法标签:通过机器学习模型计算,如用户分群、流失概率
- RFM模型:计算R、F、M三个维度的评分,进行用户价值分群
阶段4:画像存储
根据数据的访问特征和使用场景,采用不同的存储方案(详见知识点5)。
阶段5:画像服务
将构建好的用户画像通过服务接口对外提供,支持实时查询和批量推送:
- REST API:提供用户画像查询接口,支持按用户ID查询
- 批量推送:定期将用户画像数据推送到营销系统、推荐系统等下游
- 降级方案:当主存储不可用时,自动切换到备用数据源
知识点5:画像数据存储架构#
用户画像数据具有多维度、多粒度、多访问模式的特点,单一存储方案无法满足所有需求。企业级系统通常采用分层存储架构:
+--------------------------------------------------------------------+
| 画像数据存储架构 |
+--------------------------------------------------------------------+
| |
| +------------------+ |
| | Redis 7.0+ | 热数据层:实时查询、高并发访问 |
| | (热数据) | - 用户实时标签缓存 |
| | | - 用户RFM评分 |
| | | - 最近行为特征 |
| | TTL: 24h | - 读写延迟 < 1ms |
| +------------------+ |
| | |
| v |
| +------------------+ |
| | HBase 2.5.x | 标签数据层:海量标签存储、快速检索 |
| | (标签数据) | - 全量用户标签数据 |
| | | - 标签历史版本 |
| | | - 支持按RowKey快速查询 |
| +------------------+ |
| | |
| v |
| +------------------+ |
| | MySQL 8.0 | 结构化数据层:关系查询、事务支持 |
| | (结构化数据) | - 用户基础信息 |
| | | - 标签元数据定义 |
| | | - 画像统计报表 |
| +------------------+ |
| | |
| v |
| +------------------+ |
| | Hive 3.1.x | 历史数据层:离线分析、数据回溯 |
| | (历史数据) | - 用户画像历史快照 |
| | | - 标签计算中间结果 |
| | | - 离线分析报表 |
| +------------------+ |
| |
+--------------------------------------------------------------------+各存储层详细说明:
| 存储层 | 技术选型 | 数据内容 | 访问模式 | 典型延迟 |
|---|---|---|---|---|
| 热数据层 | Redis 7.0+ | 实时标签、RFM评分、最近行为 | 实时读写、高并发 | < 1ms |
| 标签数据层 | HBase 2.5.x | 全量用户标签、标签历史 | 按RowKey查询、范围扫描 | 10-50ms |
| 结构化数据层 | MySQL 8.0 | 用户基础信息、标签元数据 | 关系查询、事务操作 | 5-20ms |
| 历史数据层 | Hive 3.1.x | 历史快照、中间结果 | 批量离线分析 | 分钟级 |
数据流转策略:
- 写入:标签计算结果先写入MySQL(结构化数据)和HBase(标签数据),再异步同步到Redis(热数据缓存)
- 查询:优先从Redis读取热数据;Redis未命中时回源HBase;历史数据分析使用Hive
- 过期:Redis中的热数据设置TTL(通常24小时),过期后由定时任务重新加载
- 降级:当Redis不可用时,直接查询HBase,牺牲部分性能保证可用性
系统架构#
系统架构图#
+----------------+ +------------+ +------------+ +------------+
| 数据采集层 | --> | 数据存储层 | --> | 数据处理层 | --> | 应用层 |
+----------------+ +------------+ +------------+ +------------+
| - Kafka 3.7.0 | | - HDFS | | - Spark | | - 推荐系统 |
| - Flume | | - HBase | | 3.5.8 | | - 精准营销 |
| - Logstash | | - MySQL | | - Hive | | - 个性化 |
| | | 8.0 | | - MLlib | | - 风控系统 |
| | | - Redis | +------------+ +------------+
| | | 7.0+ |
+----------------+ +------------+项目工程结构#
ecommerce-user-profile/
├── pom.xml # Maven项目配置
├── src/
│ ├── main/
│ │ ├── resources/
│ │ │ ├── application.conf # 配置文件(外部化配置)
│ │ │ └── log4j2.xml # 日志配置
│ │ └── scala/
│ │ └── com/
│ │ └── ecommerce/
│ │ └── profile/
│ │ ├── config/
│ │ │ └── ConfigManager.scala # 配置管理
│ │ ├── dal/
│ │ │ └── DataAccessLayer.scala # 数据访问层
│ │ ├── service/
│ │ │ └── UserProfileService.scala # 画像服务
│ │ ├── producer/
│ │ │ └── UserBehaviorProducer.scala # 数据采集
│ │ ├── storage/
│ │ │ └── UserBehaviorToHBase.scala # HBase存储
│ │ ├── builder/
│ │ │ ├── UserProfileBuilder.scala # 基础画像构建
│ │ │ ├── RFMAnalyzer.scala # RFM分析
│ │ │ └── UserSegmentation.scala # 用户分群
│ │ ├── recommendation/
│ │ │ └── PersonalizedRecommendation.scala # 个性化推荐
│ │ └── marketing/
│ │ └── TargetedMarketing.scala # 精准营销
├── config/
│ └── application.conf # 外部配置文件(生产环境覆盖)
└── scripts/
└── init_tables.sql # 数据库初始化脚本数据模型设计#
1. 用户基础信息模型#
| 字段名 | 数据类型 | 描述 |
|---|---|---|
| user_id | INT | 用户ID(主键) |
| username | VARCHAR(50) | 用户名 |
| gender | VARCHAR(10) | 性别 |
| age | INT | 年龄 |
| VARCHAR(100) | 邮箱 | |
| phone | VARCHAR(20) | 手机号 |
| registration_date | DATE | 注册日期 |
| last_login_date | DATE | 最后登录日期 |
| user_level | INT | 用户等级 |
2. 用户行为数据模型#
| 字段名 | 数据类型 | 描述 |
|---|---|---|
| behavior_id | BIGINT | 行为ID |
| user_id | INT | 用户ID |
| product_id | INT | 商品ID |
| behavior_type | VARCHAR(20) | 行为类型(view/click/add_to_cart/favorite/purchase) |
| behavior_time | TIMESTAMP | 行为时间 |
| session_id | VARCHAR(100) | 会话ID |
| ip_address | VARCHAR(50) | IP地址 |
| device_type | VARCHAR(50) | 设备类型 |
3. 商品数据模型#
| 字段名 | 数据类型 | 描述 |
|---|---|---|
| product_id | INT | 商品ID(主键) |
| product_name | VARCHAR(100) | 商品名称 |
| category_id | INT | 分类ID |
| category_name | VARCHAR(50) | 分类名称 |
| price | DECIMAL(10,2) | 价格 |
| brand | VARCHAR(50) | 品牌 |
| stock | INT | 库存 |
| sales | INT | 销量 |
| rating | DECIMAL(3,2) | 评分 |
4. 用户画像模型#
| 字段名 | 数据类型 | 描述 |
|---|---|---|
| user_id | INT | 用户ID(主键) |
| age_group | VARCHAR(20) | 年龄组 |
| gender | VARCHAR(10) | 性别 |
| interest_categories | JSON | 兴趣分类 |
| purchase_power | VARCHAR(20) | 购买力 |
| shopping_frequency | VARCHAR(20) | 购物频率 |
| favorite_brands | JSON | 偏好品牌 |
| average_order_value | DECIMAL(10,2) | 平均订单价值 |
| last_purchase_date | DATE | 最后购买日期 |
| user_tags | JSON | 用户标签 |
| rfm_score | VARCHAR(10) | RFM评分 |
| user_segment | VARCHAR(30) | 用户分群 |
项目配置#
pom.xml#
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ecommerce</groupId>
<artifactId>user-profile</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>E-Commerce User Profile System</name>
<description>基于Spark的电商用户画像系统</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<scala.version>2.13.8</scala.version>
<scala.binary.version>2.13</scala.binary.version>
<spark.version>3.5.8</spark.version>
<hadoop.version>3.3.6</hadoop.version>
<hbase.version>2.5.5</hbase.version>
<kafka.version>3.7.0</kafka.version>
<jedis.version>5.0.2</jedis.version>
<mysql.connector.version>8.0.33</mysql.connector.version>
<typesafe.config.version>1.4.3</typesafe.config.version>
<slf4j.version>2.0.9</slf4j.version>
<log4j.version>2.21.1</log4j.version>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<dependencies>
<!-- Scala -->
<dependency>
<groupId>org.scala-lang</groupId>
<artifactId>scala-library</artifactId>
<version>${scala.version}</version>
</dependency>
<!-- Spark Core -->
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-core_${scala.binary.version}</artifactId>
<version>${spark.version}</version>
<scope>provided</scope>
</dependency>
<!-- Spark SQL -->
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-sql_${scala.binary.version}</artifactId>
<version>${spark.version}</version>
<scope>provided</scope>
</dependency>
<!-- Spark Streaming -->
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-streaming_${scala.binary.version}</artifactId>
<version>${spark.version}</version>
<scope>provided</scope>
</dependency>
<!-- Spark Streaming Kafka -->
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-streaming-kafka-0-10_${scala.binary.version}</artifactId>
<version>${spark.version}</version>
</dependency>
<!-- Spark SQL Kafka -->
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-sql-kafka-0-10_${scala.binary.version}</artifactId>
<version>${spark.version}</version>
</dependency>
<!-- Spark MLlib -->
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-mllib_${scala.binary.version}</artifactId>
<version>${spark.version}</version>
<scope>provided</scope>
</dependency>
<!-- Spark Hive -->
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-hive_${scala.binary.version}</artifactId>
<version>${spark.version}</version>
<scope>provided</scope>
</dependency>
<!-- Hadoop Common -->
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-common</artifactId>
<version>${hadoop.version}</version>
<scope>provided</scope>
</dependency>
<!-- Hadoop Client -->
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-client</artifactId>
<version>${hadoop.version}</version>
<scope>provided</scope>
</dependency>
<!-- HBase Client -->
<dependency>
<groupId>org.apache.hbase</groupId>
<artifactId>hbase-client</artifactId>
<version>${hbase.version}</version>
</dependency>
<!-- HBase Common -->
<dependency>
<groupId>org.apache.hbase</groupId>
<artifactId>hbase-common</artifactId>
<version>${hbase.version}</version>
</dependency>
<!-- Kafka Clients -->
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>${kafka.version}</version>
</dependency>
<!-- Redis Jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>${jedis.version}</version>
</dependency>
<!-- MySQL Connector -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>${mysql.connector.version}</version>
</dependency>
<!-- Typesafe Config -->
<dependency>
<groupId>com.typesafe</groupId>
<artifactId>config</artifactId>
<version>${typesafe.config.version}</version>
</dependency>
<!-- SLF4J API -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<!-- Log4j2 -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>${log4j.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j2-impl</artifactId>
<version>${log4j.version}</version>
</dependency>
<!-- ScalaTest -->
<dependency>
<groupId>org.scalatest</groupId>
<artifactId>scalatest_${scala.binary.version}</artifactId>
<version>3.2.17</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Scala Maven Plugin -->
<plugin>
<groupId>net.alchim31.maven</groupId>
<artifactId>scala-maven-plugin</artifactId>
<version>4.8.1</version>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
<configuration>
<scalaVersion>${scala.version}</scalaVersion>
<args>
<arg>-target:jvm-11</arg>
<arg>-deprecation</arg>
<arg>-feature</arg>
<arg>-unchecked</arg>
</args>
</configuration>
</plugin>
<!-- Maven Shade Plugin(打包Fat Jar) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<!-- Maven Compiler Plugin -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>11</source>
<target>11</target>
</configuration>
</plugin>
</plugins>
</build>
</project>application.conf 配置文件#
# ============================================
# 电商用户画像系统 - 配置文件
# 注意:生产环境中密码等敏感信息应通过环境变量或密钥管理系统注入
# ============================================
# MySQL 配置
mysql {
url = "jdbc:mysql://localhost:3306/ecommerce?useSSL=true&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4"
user = "ecommerce_user"
# 生产环境请通过环境变量覆盖: MYSQL_PASSWORD
password = ${?MYSQL_PASSWORD}
driver = "com.mysql.cj.jdbc.Driver"
pool-size = 10
connection-timeout = 30000
}
# Redis 配置
redis {
host = "localhost"
port = 6379
# 生产环境请通过环境变量覆盖: REDIS_PASSWORD
password = ${?REDIS_PASSWORD}
database = 0
timeout = 3000
pool {
max-total = 50
max-idle = 20
min-idle = 5
max-wait-millis = 3000
test-on-borrow = true
test-on-return = false
test-while-idle = true
}
cache {
# 热数据缓存过期时间(秒)
ttl-seconds = 86400
# 缓存键前缀
key-prefix = "user:profile:"
}
}
# Kafka 配置
kafka {
bootstrap-servers = "localhost:9092"
consumer {
group-id = "user-profile-group"
auto-offset-reset = "latest"
enable-auto-commit = false
max-poll-records = 500
session-timeout-ms = 30000
}
producer {
acks = "all"
retries = 3
batch-size = 16384
linger-ms = 5
buffer-memory = 33554432
}
topics {
user-behavior = "user-behavior"
access-logs = "access-logs"
profile-update = "profile-update"
}
}
# Spark 配置
spark {
app-name = "EcommerceUserProfile"
master = "local[*]"
config {
"spark.executor.memory" = "4g"
"spark.executor.cores" = "2"
"spark.driver.memory" = "2g"
"spark.sql.shuffle.partitions" = "200"
"spark.default.parallelism" = "100"
"spark.serializer" = "org.apache.spark.serializer.KryoSerializer"
"spark.sql.adaptive.enabled" = "true"
"spark.sql.adaptive.coalescePartitions.enabled" = "true"
"spark.streaming.backpressure.enabled" = "true"
}
}
# HBase 配置
hbase {
zookeeper-quorum = "localhost"
zookeeper-property-clientPort = "2181"
tables {
user-behavior = "user_behavior"
user-tags = "user_tags"
}
}
# RFM 模型配置
rfm {
# 统计周期(天)
period-days = 365
# R 评分阈值(天)
r-thresholds = [30, 60, 90, 180]
# F 评分阈值(次)
f-thresholds = [1, 5, 10, 20]
# M 评分阈值(元)
m-thresholds = [500, 2000, 5000, 10000]
}
# 画像服务配置
profile-service {
port = 8080
# 降级开关:当Redis不可用时是否降级到HBase查询
degradation-enabled = true
# 批量查询最大数量
batch-max-size = 100
# 查询超时时间(毫秒)
query-timeout-ms = 5000
}核心代码实现#
1. 配置管理 - ConfigManager.scala#
package com.ecommerce.profile.config
import com.typesafe.config.{Config, ConfigFactory}
import org.slf4j.{Logger, LoggerFactory}
import scala.jdk.CollectionConverters._
import scala.util.Try
/**
* 配置管理器
*
* 使用 Typesafe Config 库管理应用配置,支持:
* - 多环境配置(开发/测试/生产)
* - 环境变量覆盖(敏感信息不硬编码)
* - 配置热加载
* - 配置验证
*
* 设计原则:
* - 所有敏感信息(密码等)通过环境变量注入,不在代码或配置文件中硬编码
* - 提供类型安全的配置读取方法
* - 提供默认值机制,避免因缺少配置导致启动失败
*/
object ConfigManager {
private val logger: Logger = LoggerFactory.getLogger(ConfigManager.getClass)
// 加载配置,优先使用环境变量覆盖
private val config: Config = {
val baseConfig = ConfigFactory.load()
// 允许通过环境变量覆盖配置项
// 例如:MYSQL_PASSWORD 环境变量会覆盖 mysql.password 配置
val envOverrides = ConfigFactory.systemEnvironment()
envOverrides.withFallback(baseConfig).resolve()
}
/**
* 获取原始 Config 对象
*/
def getConfig: Config = config
// ==================== MySQL 配置 ====================
def getMySQLUrl: String = config.getString("mysql.url")
def getMySQLUser: String = config.getString("mysql.user")
def getMySQLPassword: String = {
// 优先从环境变量获取密码
sys.env.getOrElse("MYSQL_PASSWORD", {
if (config.hasPath("mysql.password")) {
val pwd = config.getString("mysql.password")
if (pwd == null || pwd.isEmpty) {
logger.warn("MySQL密码未配置,请设置环境变量 MYSQL_PASSWORD 或配置 mysql.password")
}
pwd
} else {
logger.error("MySQL密码未配置!请设置环境变量 MYSQL_PASSWORD")
""
}
})
}
def getMySQLDriver: String = config.getString("mysql.driver")
def getMySQLPoolSize: Int = Try(config.getInt("mysql.pool-size")).getOrElse(10)
// ==================== Redis 配置 ====================
def getRedisHost: String = config.getString("redis.host")
def getRedisPort: Int = config.getInt("redis.port")
def getRedisPassword: String = {
sys.env.getOrElse("REDIS_PASSWORD", {
if (config.hasPath("redis.password") && config.getString("redis.password") != null) {
config.getString("redis.password")
} else {
"" // Redis 可以设置无密码
}
})
}
def getRedisDatabase: Int = Try(config.getInt("redis.database")).getOrElse(0)
def getRedisTimeout: Int = Try(config.getInt("redis.timeout")).getOrElse(3000)
def getRedisMaxTotal: Int = Try(config.getInt("redis.pool.max-total")).getOrElse(50)
def getRedisMaxIdle: Int = Try(config.getInt("redis.pool.max-idle")).getOrElse(20)
def getRedisMinIdle: Int = Try(config.getInt("redis.pool.min-idle")).getOrElse(5)
def getRedisMaxWaitMillis: Long = Try(config.getLong("redis.pool.max-wait-millis")).getOrElse(3000L)
def getRedisCacheTTL: Int = Try(config.getInt("redis.cache.ttl-seconds")).getOrElse(86400)
def getRedisKeyPrefix: String = Try(config.getString("redis.cache.key-prefix")).getOrElse("user:profile:")
// ==================== Kafka 配置 ====================
def getKafkaBootstrapServers: String = config.getString("kafka.bootstrap-servers")
def getKafkaGroupId: String = Try(config.getString("kafka.consumer.group-id")).getOrElse("user-profile-group")
def getKafkaAutoOffsetReset: String = Try(config.getString("kafka.consumer.auto-offset-reset")).getOrElse("latest")
def getKafkaMaxPollRecords: Int = Try(config.getInt("kafka.consumer.max-poll-records")).getOrElse(500)
def getKafkaUserBehaviorTopic: String = Try(config.getString("kafka.topics.user-behavior")).getOrElse("user-behavior")
def getKafkaAccessLogsTopic: String = Try(config.getString("kafka.topics.access-logs")).getOrElse("access-logs")
def getKafkaProfileUpdateTopic: String = Try(config.getString("kafka.topics.profile-update")).getOrElse("profile-update")
// ==================== Spark 配置 ====================
def getSparkAppName: String = Try(config.getString("spark.app-name")).getOrElse("EcommerceUserProfile")
def getSparkMaster: String = Try(config.getString("spark.master")).getOrElse("local[*]")
def getSparkConfig: Map[String, String] = {
Try {
config.getConfig("spark.config").entrySet().asScala.map { entry =>
entry.getKey -> entry.getValue.unwrapped().toString
}.toMap
}.getOrElse(Map.empty)
}
// ==================== HBase 配置 ====================
def getHBaseZkQuorum: String = Try(config.getString("hbase.zookeeper-quorum")).getOrElse("localhost")
def getHBaseZkClientPort: String = Try(config.getString("hbase.zookeeper-property-clientPort")).getOrElse("2181")
def getHBaseUserBehaviorTable: String = Try(config.getString("hbase.tables.user-behavior")).getOrElse("user_behavior")
def getHBaseUserTagsTable: String = Try(config.getString("hbase.tables.user-tags")).getOrElse("user_tags")
// ==================== RFM 配置 ====================
def getRFMPeriodDays: Int = Try(config.getInt("rfm.period-days")).getOrElse(365)
def getRFMRThresholds: List[Int] = Try {
config.getIntList("rfm.r-thresholds").asScala.map(_.intValue()).toList
}.getOrElse(List(30, 60, 90, 180))
def getRFMFThresholds: List[Int] = Try {
config.getIntList("rfm.f-thresholds").asScala.map(_.intValue()).toList
}.getOrElse(List(1, 5, 10, 20))
def getRFMMThresholds: List[Int] = Try {
config.getIntList("rfm.m-thresholds").asScala.map(_.intValue()).toList
}.getOrElse(List(500, 2000, 5000, 10000))
// ==================== 画像服务配置 ====================
def getProfileServicePort: Int = Try(config.getInt("profile-service.port")).getOrElse(8080)
def isDegradationEnabled: Boolean = Try(config.getBoolean("profile-service.degradation-enabled")).getOrElse(true)
def getBatchMaxSize: Int = Try(config.getInt("profile-service.batch-max-size")).getOrElse(100)
def getQueryTimeoutMs: Long = Try(config.getLong("profile-service.query-timeout-ms")).getOrElse(5000L)
/**
* 验证关键配置是否完整
*
* @return 验证结果,None表示通过,Some包含错误信息
*/
def validate(): Option[String] = {
val errors = scala.collection.mutable.ArrayBuffer[String]()
if (getMySQLUrl.isEmpty) errors += "MySQL URL 未配置"
if (getMySQLUser.isEmpty) errors += "MySQL 用户名未配置"
if (getMySQLPassword.isEmpty) errors += "MySQL 密码未配置(请设置环境变量 MYSQL_PASSWORD)"
if (getRedisHost.isEmpty) errors += "Redis 主机未配置"
if (getKafkaBootstrapServers.isEmpty) errors += "Kafka bootstrap servers 未配置"
if (errors.nonEmpty) {
val msg = s"配置验证失败:\n${errors.mkString("\n")}"
logger.error(msg)
Some(msg)
} else {
logger.info("配置验证通过")
None
}
}
/**
* 打印当前配置(隐藏敏感信息)
*/
def printConfig(): Unit = {
logger.info("========== 当前配置 ==========")
logger.info(s"MySQL URL: $getMySQLUrl")
logger.info(s"MySQL User: $getMySQLUser")
logger.info(s"MySQL Password: ${if (getMySQLPassword.nonEmpty) "******" else "未配置"}")
logger.info(s"Redis Host: $getRedisHost:$getRedisPort")
logger.info(s"Redis Password: ${if (getRedisPassword.nonEmpty) "******" else "未配置"}")
logger.info(s"Kafka Servers: $getKafkaBootstrapServers")
logger.info(s"Spark Master: $getSparkMaster")
logger.info(s"HBase ZK: $getHBaseZkQuorum:$getHBaseZkClientPort")
logger.info(s"降级开关: $isDegradationEnabled")
logger.info("==============================")
}
}2. 数据访问层 - DataAccessLayer.scala#
package com.ecommerce.profile.dal
import com.ecommerce.profile.config.ConfigManager
import org.apache.spark.sql.{DataFrame, SparkSession}
import org.slf4j.{Logger, LoggerFactory}
import java.sql.{Connection, DriverManager, PreparedStatement, ResultSet}
import java.util.Properties
import scala.jdk.CollectionConverters._
import scala.util.{Try, Using}
/**
* 数据访问层
*
* 封装所有数据源的读写操作,提供统一的数据访问接口。
* 职责:
* - MySQL 数据读写
* - HBase 数据读写
* - Redis 缓存操作
* - Kafka 数据源接入
*
* 设计原则:
* - 所有连接配置通过 ConfigManager 获取,不硬编码
* - 提供连接池管理,避免频繁创建/销毁连接
* - 统一异常处理,确保资源释放
* - 读写操作支持重试机制
*/
object DataAccessLayer {
private val logger: Logger = LoggerFactory.getLogger(DataAccessLayer.getClass)
// ==================== MySQL 操作 ====================
/**
* 获取 MySQL JDBC 连接属性
*/
private def getMySQLProperties: Properties = {
val props = new Properties()
props.setProperty("user", ConfigManager.getMySQLUser)
props.setProperty("password", ConfigManager.getMySQLPassword)
props.setProperty("driver", ConfigManager.getMySQLDriver)
props.setProperty("useSSL", "true")
props.setProperty("characterEncoding", "utf8mb4")
props
}
/**
* 从 MySQL 读取数据
*
* @param spark SparkSession 实例
* @param table 表名
* @param columns 可选的列名列表,为空则读取全部列
* @return DataFrame
*/
def readFromMySQL(spark: SparkSession, table: String, columns: Seq[String] = Seq.empty): DataFrame = {
require(table.nonEmpty, "表名不能为空")
val query = if (columns.nonEmpty) {
s"SELECT ${columns.mkString(", ")} FROM $table"
} else {
s"SELECT * FROM $table"
}
logger.info(s"从MySQL读取数据: $query")
try {
spark.read
.format("jdbc")
.option("url", ConfigManager.getMySQLUrl)
.option("dbtable", s"($query) AS t")
.option("user", ConfigManager.getMySQLUser)
.option("password", ConfigManager.getMySQLPassword)
.option("driver", ConfigManager.getMySQLDriver)
.option("fetchsize", "1000")
.option("numPartitions", "4")
.option("partitionColumn", "user_id")
.option("lowerBound", "1")
.option("upperBound", "10000000")
.load()
} catch {
case e: Exception =>
logger.error(s"从MySQL读取数据失败: ${e.getMessage}", e)
throw new RuntimeException(s"MySQL读取失败: ${e.getMessage}", e)
}
}
/**
* 写入数据到 MySQL
*
* @param df 待写入的 DataFrame
* @param table 目标表名
* @param saveMode 写入模式(overwrite/append/upsert)
*/
def writeToMySQL(df: DataFrame, table: String, saveMode: String = "overwrite"): Unit = {
require(table.nonEmpty, "表名不能为空")
logger.info(s"写入数据到MySQL表: $table, 模式: $saveMode, 记录数: ${df.count()}")
try {
df.write
.format("jdbc")
.option("url", ConfigManager.getMySQLUrl)
.option("dbtable", table)
.option("user", ConfigManager.getMySQLUser)
.option("password", ConfigManager.getMySQLPassword)
.option("driver", ConfigManager.getMySQLDriver)
.option("batchsize", "1000")
.option("isolationLevel", "READ_COMMITTED")
.mode(saveMode)
.save()
logger.info(s"数据写入MySQL成功: $table")
} catch {
case e: Exception =>
logger.error(s"写入MySQL失败: ${e.getMessage}", e)
throw new RuntimeException(s"MySQL写入失败: ${e.getMessage}", e)
}
}
/**
* 使用 JDBC 直连执行 SQL 查询(适用于小数据量查询)
*
* @param sql SQL 语句
* @param handler 结果集处理函数
* @tparam T 返回类型
* @return 查询结果
*/
def executeQuery[T](sql: String)(handler: ResultSet => T): Option[T] = {
var connection: Connection = null
var stmt: PreparedStatement = null
try {
connection = DriverManager.getConnection(
ConfigManager.getMySQLUrl,
ConfigManager.getMySQLUser,
ConfigManager.getMySQLPassword
)
stmt = connection.prepareStatement(sql)
val rs = stmt.executeQuery()
Some(handler(rs))
} catch {
case e: Exception =>
logger.error(s"执行SQL查询失败: ${e.getMessage}", e)
None
} finally {
if (stmt != null) Try(stmt.close())
if (connection != null) Try(connection.close())
}
}
/**
* 使用 JDBC 直连执行 SQL 更新
*
* @param sql SQL 语句
* @return 受影响的行数
*/
def executeUpdate(sql: String): Int = {
var connection: Connection = null
var stmt: PreparedStatement = null
try {
connection = DriverManager.getConnection(
ConfigManager.getMySQLUrl,
ConfigManager.getMySQLUser,
ConfigManager.getMySQLPassword
)
stmt = connection.prepareStatement(sql)
stmt.executeUpdate()
} catch {
case e: Exception =>
logger.error(s"执行SQL更新失败: ${e.getMessage}", e)
-1
} finally {
if (stmt != null) Try(stmt.close())
if (connection != null) Try(connection.close())
}
}
// ==================== Kafka 操作 ====================
/**
* 从 Kafka 读取流数据
*
* @param spark SparkSession 实例
* @param topic Kafka 主题
* @return DataFrame
*/
def readFromKafka(spark: SparkSession, topic: String): DataFrame = {
require(topic.nonEmpty, "Kafka主题不能为空")
logger.info(s"从Kafka读取流数据: $topic")
try {
spark.readStream
.format("kafka")
.option("kafka.bootstrap.servers", ConfigManager.getKafkaBootstrapServers)
.option("subscribe", topic)
.option("startingOffsets", ConfigManager.getKafkaAutoOffsetReset)
.option("maxOffsetsPerTrigger", ConfigManager.getKafkaMaxPollRecords.toString)
.option("failOnDataLoss", "false")
.load()
} catch {
case e: Exception =>
logger.error(s"从Kafka读取数据失败: ${e.getMessage}", e)
throw new RuntimeException(s"Kafka读取失败: ${e.getMessage}", e)
}
}
/**
* 从 Kafka 批量读取数据
*
* @param spark SparkSession 实例
* @param topic Kafka 主题
* @param startingOffsets 起始偏移量(默认 earliest)
* @param endingOffsets 结束偏移量(默认 latest)
* @return DataFrame
*/
def readBatchFromKafka(spark: SparkSession, topic: String,
startingOffsets: String = "earliest",
endingOffsets: String = "latest"): DataFrame = {
require(topic.nonEmpty, "Kafka主题不能为空")
logger.info(s"从Kafka批量读取数据: $topic")
try {
spark.read
.format("kafka")
.option("kafka.bootstrap.servers", ConfigManager.getKafkaBootstrapServers)
.option("subscribe", topic)
.option("startingOffsets", startingOffsets)
.option("endingOffsets", endingOffsets)
.load()
} catch {
case e: Exception =>
logger.error(s"从Kafka批量读取数据失败: ${e.getMessage}", e)
throw new RuntimeException(s"Kafka批量读取失败: ${e.getMessage}", e)
}
}
// ==================== HBase 操作 ====================
/**
* 获取 HBase 连接配置
*/
def getHBaseConfig: org.apache.hadoop.conf.Configuration = {
val conf = org.apache.hadoop.hbase.HBaseConfiguration.create()
conf.set("hbase.zookeeper.quorum", ConfigManager.getHBaseZkQuorum)
conf.set("hbase.zookeeper.property.clientPort", ConfigManager.getHBaseZkClientPort)
conf
}
// ==================== SparkSession 管理 ====================
/**
* 创建 SparkSession
*
* 使用 try-catch-finally 确保资源释放。
* 所有 SparkSession 的创建都应通过此方法,以统一配置和资源管理。
*
* @param appName 应用名称
* @param hiveSupport 是否启用Hive支持
* @return SparkSession 实例
*/
def createSparkSession(appName: String = ConfigManager.getSparkAppName,
hiveSupport: Boolean = false): SparkSession = {
logger.info(s"创建SparkSession: $appName, Hive支持: $hiveSupport")
try {
val builder = SparkSession.builder()
.appName(appName)
.master(ConfigManager.getSparkMaster)
// 应用 Spark 配置
ConfigManager.getSparkConfig.foreach { case (key, value) =>
builder.config(key, value)
}
if (hiveSupport) {
builder.enableHiveSupport()
}
val spark = builder.getOrCreate()
spark.sparkContext.setLogLevel("WARN")
logger.info(s"SparkSession创建成功: $appName")
spark
} catch {
case e: Exception =>
logger.error(s"创建SparkSession失败: ${e.getMessage}", e)
throw new RuntimeException(s"SparkSession创建失败: ${e.getMessage}", e)
}
}
/**
* 安全执行 Spark 作业
*
* 使用 try-catch-finally 确保 SparkSession 正确释放。
* 这是执行 Spark 作业的推荐方式。
*
* @param appName 应用名称
* @param hiveSupport 是否启用Hive支持
* @param block 业务逻辑块
* @tparam T 返回类型
* @return 业务逻辑执行结果
*/
def withSparkSession[T](appName: String = ConfigManager.getSparkAppName,
hiveSupport: Boolean = false)(block: SparkSession => T): T = {
try {
val spark = createSparkSession(appName, hiveSupport)
block(spark)
} catch {
case e: Exception =>
logger.error(s"Spark作业执行失败: ${e.getMessage}", e)
throw new RuntimeException(s"Spark作业执行失败: ${e.getMessage}", e)
} finally {
val s = SparkSession.getActiveSession.orNull
if (s != null) {
try {
s.stop()
logger.info(s"SparkSession已释放: $appName")
} catch {
case e: Exception =>
logger.warn(s"释放SparkSession时出错: ${e.getMessage}", e)
}
}
}
}
}3. 画像服务 - UserProfileService.scala#
package com.ecommerce.profile.service
import com.ecommerce.profile.config.ConfigManager
import org.apache.hadoop.hbase.{HBaseConfiguration, TableName}
import org.apache.hadoop.hbase.client.{Connection, ConnectionFactory, Get}
import org.apache.hadoop.hbase.util.Bytes
import org.slf4j.{Logger, LoggerFactory}
import redis.clients.jedis.{Jedis, JedisPool, JedisPoolConfig}
import java.util
import scala.jdk.CollectionConverters._
import scala.util.{Try, Using}
/**
* 用户画像服务
*
* 提供用户画像的查询、缓存和降级功能。
*
* 核心特性:
* - Redis 连接池管理(JedisPool)
* - 多级缓存策略(Redis -> HBase -> MySQL)
* - 降级方案(Redis不可用时自动降级到HBase)
* - 数据验证和异常处理
*
* 缓存策略:
* 1. 首先查询 Redis 热数据缓存
* 2. Redis 未命中时,查询 HBase 标签数据
* 3. HBase 未命中时,查询 MySQL 结构化数据
* 4. 查询结果回填到 Redis 缓存
*
* 降级方案:
* - Redis 不可用 -> 直接查询 HBase
* - HBase 不可用 -> 查询 MySQL(功能受限)
* - 全部不可用 -> 返回默认画像数据
*/
object UserProfileService {
private val logger: Logger = LoggerFactory.getLogger(UserProfileService.getClass)
// Redis 连接池(懒加载,线程安全)
private lazy val jedisPool: JedisPool = {
val poolConfig = new JedisPoolConfig()
poolConfig.setMaxTotal(ConfigManager.getRedisMaxTotal)
poolConfig.setMaxIdle(ConfigManager.getRedisMaxIdle)
poolConfig.setMinIdle(ConfigManager.getRedisMinIdle)
poolConfig.setMaxWaitMillis(ConfigManager.getRedisMaxWaitMillis)
poolConfig.setTestOnBorrow(true)
poolConfig.setTestOnReturn(false)
poolConfig.setTestWhileIdle(true)
val password = ConfigManager.getRedisPassword
if (password.nonEmpty) {
new JedisPool(poolConfig, ConfigManager.getRedisHost, ConfigManager.getRedisPort,
ConfigManager.getRedisTimeout, password)
} else {
new JedisPool(poolConfig, ConfigManager.getRedisHost, ConfigManager.getRedisPort,
ConfigManager.getRedisTimeout)
}
}
// HBase 连接(懒加载)
@volatile private var hbaseConnection: Connection = _
private def getHBaseConnection: Connection = {
if (hbaseConnection == null || hbaseConnection.isClosed) {
synchronized {
if (hbaseConnection == null || hbaseConnection.isClosed) {
val conf = HBaseConfiguration.create()
conf.set("hbase.zookeeper.quorum", ConfigManager.getHBaseZkQuorum)
conf.set("hbase.zookeeper.property.clientPort", ConfigManager.getHBaseZkClientPort)
hbaseConnection = ConnectionFactory.createConnection(conf)
logger.info("HBase连接已创建")
}
}
}
hbaseConnection
}
// ==================== Redis 操作 ====================
/**
* 从 Redis 获取资源
*
* 使用 JedisPool 连接池管理 Redis 连接,确保连接正确归还。
*
* @param block 使用 Jedis 执行的操作
* @tparam T 返回类型
* @return 操作结果,Redis不可用时返回None
*/
private def withRedis[T](block: Jedis => T): Option[T] = {
var jedis: Jedis = null
try {
jedis = jedisPool.getResource
Some(block(jedis))
} catch {
case e: Exception =>
logger.warn(s"Redis操作失败,将降级处理: ${e.getMessage}")
None
} finally {
if (jedis != null) {
try {
jedis.close()
} catch {
case e: Exception =>
logger.warn(s"归还Redis连接失败: ${e.getMessage}")
}
}
}
}
/**
* 从 Redis 缓存获取用户画像
*
* @param userId 用户ID
* @return 画像JSON字符串,未命中返回None
*/
def getProfileFromCache(userId: Int): Option[String] = {
val key = s"${ConfigManager.getRedisKeyPrefix}$userId"
withRedis { jedis =>
val value = jedis.get(key)
if (value != null) {
logger.debug(s"Redis缓存命中: userId=$userId")
value
} else {
logger.debug(s"Redis缓存未命中: userId=$userId")
null
}
}.flatMap(Option(_))
}
/**
* 将用户画像写入 Redis 缓存
*
* @param userId 用户ID
* @param profileJson 画像JSON字符串
*/
def setProfileToCache(userId: Int, profileJson: String): Unit = {
val key = s"${ConfigManager.getRedisKeyPrefix}$userId"
withRedis { jedis =>
jedis.setex(key, ConfigManager.getRedisCacheTTL, profileJson)
logger.debug(s"用户画像已写入Redis缓存: userId=$userId, TTL=${ConfigManager.getRedisCacheTTL}s")
}
}
/**
* 批量将用户画像写入 Redis 缓存
*
* 使用 Pipeline 提升批量写入性能。
*
* @param profiles 用户ID到画像JSON的映射
*/
def batchSetProfileToCache(profiles: Map[Int, String]): Unit = {
if (profiles.isEmpty) return
withRedis { jedis =>
val pipeline = jedis.pipelined()
profiles.foreach { case (userId, profileJson) =>
val key = s"${ConfigManager.getRedisKeyPrefix}$userId"
pipeline.setex(key, ConfigManager.getRedisCacheTTL, profileJson)
}
pipeline.sync()
logger.info(s"批量写入Redis缓存完成: ${profiles.size}条记录")
}
}
/**
* 从 Redis 缓存删除用户画像
*
* @param userId 用户ID
*/
def removeProfileFromCache(userId: Int): Unit = {
val key = s"${ConfigManager.getRedisKeyPrefix}$userId"
withRedis { jedis =>
jedis.del(key)
logger.debug(s"用户画像已从Redis缓存删除: userId=$userId")
}
}
// ==================== HBase 操作 ====================
/**
* 从 HBase 获取用户标签数据
*
* @param userId 用户ID
* @return 标签数据Map,查询失败返回None
*/
def getTagsFromHBase(userId: Int): Option[Map[String, String]] = {
try {
val connection = getHBaseConnection
val table = connection.getTable(TableName.valueOf(ConfigManager.getHBaseUserTagsTable))
try {
val get = new Get(Bytes.toBytes(userId.toString))
val result = table.get(get)
if (result.isEmpty) {
logger.debug(s"HBase中未找到用户标签: userId=$userId")
None
} else {
val tags = result.getNoVersionMap.asScala.flatMap { case (cf, columns) =>
columns.asScala.map { case (qualifier, value) =>
Bytes.toString(qualifier) -> Bytes.toString(value)
}
}.toMap
logger.debug(s"HBase标签查询成功: userId=$userId, 标签数=${tags.size}")
Some(tags)
}
} finally {
table.close()
}
} catch {
case e: Exception =>
logger.warn(s"HBase查询失败,将降级处理: userId=$userId, error=${e.getMessage}")
None
}
}
// ==================== 多级查询(核心方法) ====================
/**
* 获取用户画像(多级缓存查询)
*
* 查询顺序:Redis -> HBase -> MySQL
* 每级查询成功后,会回填到上一级缓存。
*
* @param userId 用户ID
* @return 用户画像JSON字符串
*/
def getUserProfile(userId: Int): Option[String] = {
require(userId > 0, s"用户ID必须大于0,当前值: $userId")
logger.info(s"查询用户画像: userId=$userId")
// 第一级:查询 Redis 缓存
val redisResult = getProfileFromCache(userId)
if (redisResult.isDefined) {
logger.info(s"用户画像查询成功(Redis缓存): userId=$userId")
return redisResult
}
// 第二级:查询 HBase
val hbaseResult = getTagsFromHBase(userId)
if (hbaseResult.isDefined) {
val profileJson = hbaseResult.get.map { case (k, v) => s""""$k":"$v"""" }.mkString("{", ",", "}")
// 回填 Redis 缓存
setProfileToCache(userId, profileJson)
logger.info(s"用户画像查询成功(HBase,已回填Redis): userId=$userId")
return Some(profileJson)
}
// 第三级:查询 MySQL(降级方案)
if (ConfigManager.isDegradationEnabled) {
val mysqlResult = getProfileFromMySQL(userId)
if (mysqlResult.isDefined) {
// 回填 Redis 和 HBase 缓存
setProfileToCache(userId, mysqlResult.get)
logger.info(s"用户画像查询成功(MySQL降级,已回填缓存): userId=$userId")
return mysqlResult
}
}
logger.warn(s"用户画像未找到: userId=$userId")
None
}
/**
* 批量获取用户画像
*
* @param userIds 用户ID列表
* @return 用户ID到画像的映射
*/
def batchGetUserProfile(userIds: Seq[Int]): Map[Int, String] = {
require(userIds.nonEmpty, "用户ID列表不能为空")
require(userIds.size <= ConfigManager.getBatchMaxSize,
s"批量查询数量不能超过${ConfigManager.getBatchMaxSize},当前: ${userIds.size}")
logger.info(s"批量查询用户画像: count=${userIds.size}")
val result = scala.collection.mutable.Map[Int, String]()
// 先从 Redis 批量查询
val missedIds = scala.collection.mutable.ArrayBuffer[Int]()
withRedis { jedis =>
val pipeline = jedis.pipelined()
val keys = userIds.map(id => s"${ConfigManager.getRedisKeyPrefix}$id")
keys.foreach(key => pipeline.get(key))
val responses = pipeline.syncAndReturnAll().asScala.zipWithIndex
responses.foreach { case (response, index) =>
val value = response.asInstanceOf[String]
if (value != null) {
result(userIds(index)) = value
} else {
missedIds += userIds(index)
}
}
}
// Redis 未命中的从 HBase 查询
if (missedIds.nonEmpty) {
val hbaseProfiles = scala.collection.mutable.Map[Int, String]()
missedIds.foreach { userId =>
val hbaseResult = getTagsFromHBase(userId)
if (hbaseResult.isDefined) {
val profileJson = hbaseResult.get.map { case (k, v) => s""""$k":"$v"""" }.mkString("{", ",", "}")
hbaseProfiles(userId) = profileJson
result(userId) = profileJson
}
}
// 回填 Redis 缓存
if (hbaseProfiles.nonEmpty) {
batchSetProfileToCache(hbaseProfiles.toMap)
}
}
logger.info(s"批量查询完成: 总查询${userIds.size}条, Redis命中${userIds.size - missedIds.size}条, HBase命中${result.size - (userIds.size - missedIds.size)}条")
result.toMap
}
/**
* 从 MySQL 查询用户画像(降级方案)
*
* @param userId 用户ID
* @return 画像JSON字符串
*/
private def getProfileFromMySQL(userId: Int): Option[String] = {
import com.ecommerce.profile.dal.DataAccessLayer._
executeQuery(
s"SELECT user_id, age_group, gender, purchase_power, shopping_frequency, " +
s"rfm_score, user_segment FROM user_profiles WHERE user_id = $userId"
) { rs =>
if (rs.next()) {
val profile = Map(
"user_id" -> rs.getInt("user_id").toString,
"age_group" -> Option(rs.getString("age_group")).getOrElse("未知"),
"gender" -> Option(rs.getString("gender")).getOrElse("未知"),
"purchase_power" -> Option(rs.getString("purchase_power")).getOrElse("未知"),
"shopping_frequency" -> Option(rs.getString("shopping_frequency")).getOrElse("未知"),
"rfm_score" -> Option(rs.getString("rfm_score")).getOrElse("000"),
"user_segment" -> Option(rs.getString("user_segment")).getOrElse("未分类")
)
profile.map { case (k, v) => s""""$k":"$v"""" }.mkString("{", ",", "}")
} else {
null
}
}.flatMap(Option(_))
}
// ==================== 资源管理 ====================
/**
* 关闭所有连接资源
*
* 在应用关闭时调用,确保所有连接正确释放。
*/
def shutdown(): Unit = {
logger.info("正在关闭用户画像服务资源...")
// 关闭 Redis 连接池
try {
if (jedisPool != null && !jedisPool.isClosed) {
jedisPool.close()
logger.info("Redis连接池已关闭")
}
} catch {
case e: Exception =>
logger.warn(s"关闭Redis连接池时出错: ${e.getMessage}")
}
// 关闭 HBase 连接
try {
if (hbaseConnection != null && !hbaseConnection.isClosed) {
hbaseConnection.close()
logger.info("HBase连接已关闭")
}
} catch {
case e: Exception =>
logger.warn(s"关闭HBase连接时出错: ${e.getMessage}")
}
logger.info("用户画像服务资源关闭完成")
}
// 注册 JVM 关闭钩子,确保资源释放
sys.addShutdownHook {
shutdown()
}
}4. 数据采集模块 - UserBehaviorProducer.scala#
package com.ecommerce.profile.producer
import com.ecommerce.profile.config.ConfigManager
import org.apache.kafka.clients.producer.{KafkaProducer, ProducerConfig, ProducerRecord}
import org.slf4j.{Logger, LoggerFactory}
import java.util.Properties
import scala.util.Random
import scala.util.control.NonFatal
/**
* 用户行为数据采集器
*
* 通过 Kafka 采集用户行为数据,包括浏览、点击、加购、收藏、购买等行为。
*
* 改进点:
* - 所有配置通过 ConfigManager 获取,不硬编码
* - 使用 try-catch-finally 确保 Kafka Producer 正确关闭
* - 添加数据验证逻辑
* - 支持优雅关闭(ShutdownHook)
*/
object UserBehaviorProducer {
private val logger: Logger = LoggerFactory.getLogger(UserBehaviorProducer.getClass)
@volatile private var running = true
def main(args: Array[String]): Unit = {
logger.info("用户行为数据采集器启动中...")
// 注册优雅关闭钩子
sys.addShutdownHook {
logger.info("接收到关闭信号,正在优雅停止...")
running = false
}
val props = new Properties()
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, ConfigManager.getKafkaBootstrapServers)
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer")
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer")
props.put(ProducerConfig.ACKS_CONFIG, ConfigManager.getConfig.getString("kafka.producer.acks"))
props.put(ProducerConfig.RETRIES_CONFIG, ConfigManager.getConfig.getInt("kafka.producer.retries").asInstanceOf[Integer])
props.put(ProducerConfig.BATCH_SIZE_CONFIG, ConfigManager.getConfig.getInt("kafka.producer.batch-size").asInstanceOf[Integer])
props.put(ProducerConfig.LINGER_MS_CONFIG, ConfigManager.getConfig.getInt("kafka.producer.linger-ms").asInstanceOf[Integer])
var producer: KafkaProducer[String, String] = null
try {
producer = new KafkaProducer[String, String](props)
val random = new Random()
val behaviorTypes = Array("view", "click", "add_to_cart", "favorite", "purchase")
val categories = Array("电子产品", "服装", "食品", "图书", "家居", "美妆", "运动", "母婴")
val devices = Array("PC", "Mobile", "Tablet")
val topic = ConfigManager.getKafkaUserBehaviorTopic
var sendCount = 0L
logger.info(s"开始发送用户行为数据到Kafka主题: $topic")
while (running) {
try {
val behaviorId = System.currentTimeMillis()
val userId = 1000 + random.nextInt(9000)
val productId = 100 + random.nextInt(900)
val behaviorType = behaviorTypes(random.nextInt(behaviorTypes.length))
val behaviorTime = System.currentTimeMillis()
val sessionId = java.util.UUID.randomUUID().toString
val ipAddress = s"192.${168 + random.nextInt(2)}.${random.nextInt(256)}.${random.nextInt(256)}"
val deviceType = devices(random.nextInt(devices.length))
val category = categories(random.nextInt(categories.length))
// 数据验证
require(userId > 0, s"用户ID无效: $userId")
require(productId > 0, s"商品ID无效: $productId")
require(behaviorTypes.contains(behaviorType), s"行为类型无效: $behaviorType")
val behaviorData =
s"""{"behavior_id":$behaviorId,"user_id":$userId,"product_id":$productId,""" +
s""""behavior_type":"$behaviorType","behavior_time":$behaviorTime,""" +
s""""session_id":"$sessionId","ip_address":"$ipAddress",""" +
s""""device_type":"$deviceType","category":"$category"}"""
val record = new ProducerRecord[String, String](topic, userId.toString, behaviorData)
producer.send(record)
sendCount += 1
if (sendCount % 100 == 0) {
logger.info(s"已发送 $sendCount 条用户行为数据")
}
Thread.sleep(500)
} catch {
case NonFatal(e) =>
logger.error(s"发送数据时出错: ${e.getMessage}", e)
Thread.sleep(1000) // 出错后等待1秒再重试
}
}
logger.info(s"数据采集器正常停止,共发送 $sendCount 条数据")
} catch {
case NonFatal(e) =>
logger.error(s"数据采集器异常: ${e.getMessage}", e)
} finally {
if (producer != null) {
try {
producer.close()
logger.info("Kafka Producer 已关闭")
} catch {
case NonFatal(e) =>
logger.warn(s"关闭Kafka Producer时出错: ${e.getMessage}")
}
}
}
}
}5. HBase存储模块 - UserBehaviorToHBase.scala#
package com.ecommerce.profile.storage
import com.ecommerce.profile.config.ConfigManager
import com.ecommerce.profile.dal.DataAccessLayer
import org.apache.spark.sql.functions._
import org.apache.spark.sql.streaming.OutputMode
import org.apache.spark.sql.types._
import org.apache.hadoop.hbase.{HBaseConfiguration, TableName}
import org.apache.hadoop.hbase.client.{ConnectionFactory, Put}
import org.apache.hadoop.hbase.util.Bytes
import org.slf4j.{Logger, LoggerFactory}
import scala.jdk.CollectionConverters._
import scala.util.control.NonFatal
/**
* 用户行为数据 HBase 存储
*
* 从 Kafka 实时读取用户行为数据,写入 HBase。
*
* 改进点:
* - 使用 DataAccessLayer.withSparkSession 管理 SparkSession 生命周期
* - 所有配置通过 ConfigManager 获取
* - HBase 连接在 foreachBatch 中按批次创建和关闭
* - 添加数据验证和异常处理
*/
object UserBehaviorToHBase {
private val logger: Logger = LoggerFactory.getLogger(UserBehaviorToHBase.getClass)
def main(args: Array[String]): Unit = {
DataAccessLayer.withSparkSession("UserBehaviorToHBase") { spark =>
import spark.implicits._
// 从 Kafka 读取用户行为数据
val behaviorStream = spark.readStream
.format("kafka")
.option("kafka.bootstrap.servers", ConfigManager.getKafkaBootstrapServers)
.option("subscribe", ConfigManager.getKafkaUserBehaviorTopic)
.option("startingOffsets", "latest")
.option("failOnDataLoss", "false")
.load()
// 定义 Schema
val schema = new StructType()
.add("behavior_id", LongType)
.add("user_id", IntegerType)
.add("product_id", IntegerType)
.add("behavior_type", StringType)
.add("behavior_time", LongType)
.add("session_id", StringType)
.add("ip_address", StringType)
.add("device_type", StringType)
.add("category", StringType)
// 解析 JSON 数据
val behaviorData = behaviorStream
.selectExpr("CAST(value AS STRING)")
.select(from_json($"value", schema).as("data"))
.select("data.*")
.filter($"user_id".isNotNull && $"behavior_type".isNotNull) // 数据验证
// 写入 HBase
val query = behaviorData.writeStream
.foreachBatch { (batchDF: org.apache.spark.sql.DataFrame, batchId: Long) =>
logger.info(s"处理批次: $batchId, 记录数: ${batchDF.count()}")
if (batchDF.count() > 0) {
var connection: org.apache.hadoop.hbase.client.Connection = null
var table: org.apache.hadoop.hbase.client.Table = null
try {
// 创建 HBase 连接
val conf = HBaseConfiguration.create()
conf.set("hbase.zookeeper.quorum", ConfigManager.getHBaseZkQuorum)
conf.set("hbase.zookeeper.property.clientPort", ConfigManager.getHBaseZkClientPort)
connection = ConnectionFactory.createConnection(conf)
table = connection.getTable(TableName.valueOf(ConfigManager.getHBaseUserBehaviorTable))
batchDF.collect().foreach { row =>
try {
// 数据验证
val userId = row.getInt(1)
val behaviorType = row.getString(3)
if (userId > 0 && behaviorType != null && behaviorType.nonEmpty) {
val rowKey = s"${row.getLong(4)}-${row.getLong(0)}"
val put = new Put(Bytes.toBytes(rowKey))
put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("user_id"), Bytes.toBytes(userId))
put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("product_id"), Bytes.toBytes(row.getInt(2)))
put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("behavior_type"), Bytes.toBytes(behaviorType))
put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("session_id"), Bytes.toBytes(row.getString(5)))
put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("ip_address"), Bytes.toBytes(row.getString(6)))
put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("device_type"), Bytes.toBytes(row.getString(7)))
put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("category"), Bytes.toBytes(row.getString(8)))
table.put(put)
}
} catch {
case NonFatal(e) =>
logger.warn(s"写入HBase单条记录失败: ${e.getMessage}")
}
}
logger.info(s"批次 $batchId 写入HBase完成")
} catch {
case NonFatal(e) =>
logger.error(s"批次 $batchId 写入HBase失败: ${e.getMessage}", e)
} finally {
// 确保资源释放
if (table != null) {
try table.close()
catch { case NonFatal(e) => logger.warn(s"关闭HBase Table失败: ${e.getMessage}") }
}
if (connection != null) {
try connection.close()
catch { case NonFatal(e) => logger.warn(s"关闭HBase Connection失败: ${e.getMessage}") }
}
}
}
}
.outputMode(OutputMode.Update())
.start()
query.awaitTermination()
}
}
}6. MySQL存储模块 - UserInfoToMySQL.scala#
package com.ecommerce.profile.storage
import com.ecommerce.profile.dal.DataAccessLayer
import org.slf4j.{Logger, LoggerFactory}
/**
* 用户基础信息 MySQL 存储
*
* 改进点:
* - 使用 DataAccessLayer.withSparkSession 管理 SparkSession 生命周期
* - 所有数据库配置通过 ConfigManager 获取,不硬编码密码
* - 添加数据验证
*/
object UserInfoToMySQL {
private val logger: Logger = LoggerFactory.getLogger(UserInfoToMySQL.getClass)
def main(args: Array[String]): Unit = {
DataAccessLayer.withSparkSession("UserInfoToMySQL") { spark =>
import spark.implicits._
// 生成模拟用户数据
val userData = Seq(
(1, "user1", "男", 25, "user1@example.com", "13800138001", "2023-01-01", "2023-12-01", 2),
(2, "user2", "女", 30, "user2@example.com", "13800138002", "2023-01-02", "2023-12-02", 3),
(3, "user3", "男", 28, "user3@example.com", "13800138003", "2023-01-03", "2023-12-03", 2),
(4, "user4", "女", 35, "user4@example.com", "13800138004", "2023-01-04", "2023-12-04", 4),
(5, "user5", "男", 22, "user5@example.com", "13800138005", "2023-01-05", "2023-12-05", 1),
(6, "user6", "女", 45, "user6@example.com", "13800138006", "2023-02-01", "2023-12-06", 5),
(7, "user7", "男", 19, "user7@example.com", "13800138007", "2023-03-01", "2023-12-07", 1),
(8, "user8", "女", 33, "user8@example.com", "13800138008", "2023-04-01", "2023-12-08", 3),
(9, "user9", "男", 27, "user9@example.com", "13800138009", "2023-05-01", "2023-12-09", 2),
(10, "user10", "女", 40, "user10@example.com", "13800138010", "2023-06-01", "2023-12-10", 4)
)
val userDF = spark.createDataFrame(userData)
.toDF("user_id", "username", "gender", "age", "email", "phone",
"registration_date", "last_login_date", "user_level")
// 数据验证:过滤无效数据
val validUserDF = userDF
.filter($"user_id" > 0)
.filter($"age" > 0 && $"age" < 150)
.filter($"username".isNotNull && $"username" =!= "")
logger.info(s"有效用户数据: ${validUserDF.count()} 条")
// 写入 MySQL
DataAccessLayer.writeToMySQL(validUserDF, "users", "overwrite")
logger.info("用户数据已写入MySQL")
}
}
}7. 基础画像构建 - UserProfileBuilder.scala#
package com.ecommerce.profile.builder
import com.ecommerce.profile.dal.DataAccessLayer
import com.ecommerce.profile.service.UserProfileService
import org.apache.spark.sql.functions._
import org.apache.spark.sql.types._
import org.slf4j.{Logger, LoggerFactory}
/**
* 基础用户画像构建器
*
* 从用户基础信息和行为数据中构建用户画像,包括:
* - 年龄分组
* - 兴趣分类
* - 购物频率
* - 平均订单价值
* - 偏好品牌
* - 用户标签
*
* 改进点:
* - 使用 DataAccessLayer.withSparkSession 管理 SparkSession 生命周期
* - 所有数据库配置通过 ConfigManager 获取
* - 构建完成后自动更新 Redis 缓存
* - 添加数据验证和异常处理
*/
object UserProfileBuilder {
private val logger: Logger = LoggerFactory.getLogger(UserProfileBuilder.getClass)
def main(args: Array[String]): Unit = {
DataAccessLayer.withSparkSession("UserProfileBuilder", hiveSupport = true) { spark =>
import spark.implicits._
try {
// 1. 读取用户基础信息
val userDF = DataAccessLayer.readFromMySQL(spark, "users")
logger.info(s"读取用户基础信息: ${userDF.count()} 条")
// 2. 读取用户行为数据
val behaviorDF = DataAccessLayer.readFromMySQL(spark, "user_behavior")
logger.info(s"读取用户行为数据: ${behaviorDF.count()} 条")
// 3. 构建用户画像
// 3.1 年龄分组
val ageGroupDF = userDF
.withColumn("age_group",
when($"age" < 18, "未成年")
.when($"age" >= 18 && $"age" < 30, "青年")
.when($"age" >= 30 && $"age" < 50, "中年")
.otherwise("老年")
)
// 3.2 兴趣分类
val interestDF = behaviorDF
.filter($"category".isNotNull)
.groupBy($"user_id", $"category")
.agg(count("*").as("count"))
.orderBy($"user_id", $"count".desc)
.groupBy($"user_id")
.agg(collect_list(struct($"category", $"count")).as("interests"))
// 3.3 购物频率
val frequencyDF = behaviorDF
.filter($"behavior_type" === "purchase")
.groupBy($"user_id")
.agg(
count("*").as("purchase_count"),
datediff(current_date(), min($"behavior_time")).as("days_since_first_purchase")
)
.withColumn("shopping_frequency",
when($"days_since_first_purchase" === 0, "高")
.otherwise(
when($"purchase_count" / $"days_since_first_purchase" >= 0.5, "高")
.when($"purchase_count" / $"days_since_first_purchase" >= 0.1, "中")
.otherwise("低")
)
)
// 3.4 平均订单价值
val orderValueDF = try {
spark.sql(
"""
|SELECT user_id, AVG(order_amount) as average_order_value
|FROM orders
|GROUP BY user_id
|""".stripMargin)
} catch {
case e: Exception =>
logger.warn(s"从Hive查询订单数据失败,使用默认值: ${e.getMessage}")
spark.emptyDataFrame
}
// 3.5 偏好品牌
val brandDF = try {
spark.sql(
"""
|SELECT user_id, product_brand, COUNT(*) as count
|FROM order_items oi
|JOIN products p ON oi.product_id = p.product_id
|GROUP BY user_id, product_brand
|ORDER BY user_id, count DESC
|""".stripMargin)
} catch {
case e: Exception =>
logger.warn(s"从Hive查询品牌数据失败: ${e.getMessage}")
spark.emptyDataFrame
}
val favoriteBrandsDF = if (!brandDF.isEmpty) {
brandDF
.groupBy($"user_id")
.agg(collect_list($"product_brand").as("favorite_brands"))
} else {
spark.emptyDataFrame
}
// 3.6 购买力评估
val purchasePowerDF = frequencyDF
.withColumn("purchase_power",
when($"purchase_count" >= 20, "高")
.when($"purchase_count" >= 5, "中")
.otherwise("低")
)
// 3.7 合并所有特征
var userProfile = ageGroupDF
.join(interestDF, Seq("user_id"), "left")
.join(purchasePowerDF.select($"user_id", $"shopping_frequency", $"purchase_power"), Seq("user_id"), "left")
if (!orderValueDF.isEmpty) {
userProfile = userProfile.join(orderValueDF, Seq("user_id"), "left")
}
if (!favoriteBrandsDF.isEmpty) {
userProfile = userProfile.join(favoriteBrandsDF, Seq("user_id"), "left")
}
// 4. 生成用户标签
val userProfileWithTags = userProfile
.withColumn("user_tags", array(
when($"age_group" === "青年", "青年用户"),
when($"gender" === "男", "男性用户").otherwise("女性用户"),
when($"shopping_frequency" === "高", "高频用户"),
when($"purchase_power" === "高", "高购买力用户"),
when($"average_order_value" > 1000, "高价值用户")
))
.withColumn("user_tags", expr("filter(user_tags, x -> x is not null)"))
// 5. 保存用户画像到 MySQL
val finalProfile = userProfileWithTags.select(
$"user_id",
$"age_group",
$"gender",
$"interests".as("interest_categories"),
$"purchase_power",
$"shopping_frequency",
$"favorite_brands",
$"average_order_value",
$"user_tags"
)
DataAccessLayer.writeToMySQL(finalProfile, "user_profiles", "overwrite")
logger.info("用户画像构建完成")
finalProfile.show(20, false)
// 6. 更新 Redis 缓存
try {
val profileData = finalProfile.collect()
val profileMap = profileData.map { row =>
val userId = row.getInt(0)
val profileJson =
s"""{"user_id":$userId,"age_group":"${row.getString(1)}","gender":"${row.getString(2)}",""" +
s""""purchase_power":"${row.getString(4)}","shopping_frequency":"${row.getString(5)}"}"""
userId -> profileJson
}.toMap
UserProfileService.batchSetProfileToCache(profileMap)
logger.info(s"用户画像已更新到Redis缓存: ${profileMap.size} 条")
} catch {
case e: Exception =>
logger.warn(s"更新Redis缓存失败(不影响主流程): ${e.getMessage}")
}
} catch {
case e: Exception =>
logger.error(s"用户画像构建失败: ${e.getMessage}", e)
throw e
}
}
}
}8. RFM分析 - RFMAnalyzer.scala#
package com.ecommerce.profile.builder
import com.ecommerce.profile.config.ConfigManager
import com.ecommerce.profile.dal.DataAccessLayer
import com.ecommerce.profile.service.UserProfileService
import org.apache.spark.sql.functions._
import org.apache.spark.sql.types._
import org.slf4j.{Logger, LoggerFactory}
/**
* RFM 分析器
*
* 基于 RFM 模型对用户进行价值分群。
*
* RFM 模型三个维度:
* - R (Recency):最近消费时间距今天数,越小越好
* - F (Frequency):统计周期内消费次数,越多越好
* - M (Monetary):统计周期内消费总金额,越大越好
*
* 评分规则:
* - 每个维度按阈值划分为 1-5 分
* - 根据各维度评分与平均值的比较,将用户分为 8 种类型
*
* 改进点:
* - RFM 阈值通过配置文件管理,方便调整
* - 使用 DataAccessLayer 管理 SparkSession
* - 评分结果写入 MySQL 和 Redis
*/
object RFMAnalyzer {
private val logger: Logger = LoggerFactory.getLogger(RFMAnalyzer.getClass)
/**
* R 评分计算:最近消费时间越近,评分越高
*/
private def calculateRScore(recencyDays: Int): Int = {
val thresholds = ConfigManager.getRFMRThresholds
if (recencyDays <= thresholds(0)) 5
else if (recencyDays <= thresholds(1)) 4
else if (recencyDays <= thresholds(2)) 3
else if (recencyDays <= thresholds(3)) 2
else 1
}
/**
* F 评分计算:消费频率越高,评分越高
*/
private def calculateFScore(frequency: Int): Int = {
val thresholds = ConfigManager.getRFMFThresholds
if (frequency > thresholds(3)) 5
else if (frequency > thresholds(2)) 4
else if (frequency > thresholds(1)) 3
else if (frequency > thresholds(0)) 2
else 1
}
/**
* M 评分计算:消费金额越大,评分越高
*/
private def calculateMScore(monetary: Double): Int = {
val thresholds = ConfigManager.getRFMMThresholds
if (monetary > thresholds(3)) 5
else if (monetary > thresholds(2)) 4
else if (monetary > thresholds(1)) 3
else if (monetary > thresholds(0)) 2
else 1
}
/**
* 根据 RFM 评分确定用户分群
*
* 以各维度平均分为阈值,高于平均为"高",低于平均为"低"
*/
private def determineUserSegment(rScore: Int, fScore: Int, mScore: Int,
avgR: Double, avgF: Double, avgM: Double): String = {
val rHigh = rScore >= avgR
val fHigh = fScore >= avgF
val mHigh = mScore >= avgM
(rHigh, fHigh, mHigh) match {
case (true, true, true) => "重要价值用户"
case (true, true, false) => "重要保持用户"
case (true, false, true) => "重要发展用户"
case (true, false, false) => "重要挽留用户"
case (false, true, true) => "重要唤回用户"
case (false, true, false) => "一般维持用户"
case (false, false, true) => "一般发展用户"
case (false, false, false) => "流失预警用户"
}
}
def main(args: Array[String]): Unit = {
DataAccessLayer.withSparkSession("RFMAnalyzer", hiveSupport = true) { spark =>
import spark.implicits._
try {
val periodDays = ConfigManager.getRFMPeriodDays
logger.info(s"开始RFM分析,统计周期: $periodDays 天")
// 1. 读取订单数据
val orderDF = try {
DataAccessLayer.readFromMySQL(spark, "orders")
} catch {
case e: Exception =>
logger.warn(s"从MySQL读取订单数据失败,尝试从Hive读取: ${e.getMessage}")
spark.sql(
s"""
|SELECT user_id, order_amount, order_time
|FROM orders
|WHERE order_time >= date_sub(current_date(), $periodDays)
|""".stripMargin)
}
if (orderDF.isEmpty) {
logger.warn("订单数据为空,无法进行RFM分析")
return
}
// 2. 计算 RFM 原始值
val rfmRawDF = orderDF
.filter($"user_id".isNotNull && $"order_amount" > 0)
.groupBy($"user_id")
.agg(
datediff(current_date(), max($"order_time")).as("recency"),
count("*").as("frequency"),
sum($"order_amount").as("monetary")
)
// 3. 计算 RFM 评分
// 先注册评分 UDF
spark.udf.register("r_score", (recency: Int) => calculateRScore(recency))
spark.udf.register("f_score", (frequency: Int) => calculateFScore(frequency))
spark.udf.register("m_score", (monetary: Double) => calculateMScore(monetary))
val rfmScoredDF = rfmRawDF
.withColumn("r_score", expr("r_score(recency)"))
.withColumn("f_score", expr("f_score(frequency)"))
.withColumn("m_score", expr("m_score(monetary)"))
// 4. 计算各维度平均评分
val avgScores = rfmScoredDF.agg(
avg("r_score").as("avg_r"),
avg("f_score").as("avg_f"),
avg("m_score").as("avg_m")
).head()
val avgR = avgScores.getDouble(0)
val avgF = avgScores.getDouble(1)
val avgM = avgScores.getDouble(2)
logger.info(s"RFM平均评分 - R: $avgR, F: $avgF, M: $avgM")
// 5. 用户分群
spark.udf.register("determine_segment",
(rScore: Int, fScore: Int, mScore: Int) =>
determineUserSegment(rScore, fScore, mScore, avgR, avgF, avgM)
)
val rfmResultDF = rfmScoredDF
.withColumn("rfm_score",
concat($"r_score", lit(""), $"f_score", lit(""), $"m_score")
)
.withColumn("user_segment",
expr("determine_segment(r_score, f_score, m_score)")
)
.select(
$"user_id",
$"recency",
$"frequency",
$"monetary",
$"r_score",
$"f_score",
$"m_score",
$"rfm_score",
$"user_segment"
)
// 6. 输出分析结果
logger.info("RFM分析结果:")
rfmResultDF.show(20, false)
// 分群统计
val segmentStats = rfmResultDF
.groupBy($"user_segment")
.agg(
count("*").as("user_count"),
avg($"monetary").as("avg_monetary"),
avg($"frequency").as("avg_frequency")
)
.orderBy($"user_count".desc)
logger.info("用户分群统计:")
segmentStats.show(false)
// 7. 保存 RFM 分析结果
DataAccessLayer.writeToMySQL(rfmResultDF, "user_rfm_analysis", "overwrite")
// 8. 更新用户画像表中的 RFM 字段
val updateDF = rfmResultDF.select($"user_id", $"rfm_score", $"user_segment")
DataAccessLayer.writeToMySQL(updateDF, "user_segments", "overwrite")
// 9. 更新 Redis 缓存
try {
val rfmData = rfmResultDF.select($"user_id", $"rfm_score", $"user_segment").collect()
val rfmMap = rfmData.map { row =>
val userId = row.getInt(0)
val profileJson =
s"""{"user_id":$userId,"rfm_score":"${row.getString(1)}","user_segment":"${row.getString(2)}"}"""
userId -> profileJson
}.toMap
UserProfileService.batchSetProfileToCache(rfmMap)
logger.info(s"RFM结果已更新到Redis缓存: ${rfmMap.size} 条")
} catch {
case e: Exception =>
logger.warn(s"更新Redis缓存失败(不影响主流程): ${e.getMessage}")
}
logger.info("RFM分析完成")
} catch {
case e: Exception =>
logger.error(s"RFM分析失败: ${e.getMessage}", e)
throw e
}
}
}
}9. 用户分群 - UserSegmentation.scala#
package com.ecommerce.profile.builder
import com.ecommerce.profile.dal.DataAccessLayer
import org.apache.spark.ml.clustering.KMeans
import org.apache.spark.ml.evaluation.ClusteringEvaluator
import org.apache.spark.ml.feature.{StandardScaler, VectorAssembler}
import org.apache.spark.sql.functions._
import org.slf4j.{Logger, LoggerFactory}
/**
* 用户分群(基于机器学习)
*
* 使用 K-Means 聚类算法对用户进行分群,结合 RFM 分析结果,
* 实现更精细的用户价值分层。
*
* 改进点:
* - 使用 DataAccessLayer.withSparkSession 管理 SparkSession 生命周期
* - 所有数据库配置通过 ConfigManager 获取
* - 添加模型评估和参数调优
*/
object UserSegmentation {
private val logger: Logger = LoggerFactory.getLogger(UserSegmentation.getClass)
def main(args: Array[String]): Unit = {
DataAccessLayer.withSparkSession("UserSegmentation") { spark =>
import spark.implicits._
try {
// 读取用户画像数据
val profileDF = DataAccessLayer.readFromMySQL(spark, "user_profiles")
logger.info(s"读取用户画像数据: ${profileDF.count()} 条")
if (profileDF.isEmpty) {
logger.warn("用户画像数据为空,无法进行分群")
return
}
// 准备特征
val featureDF = profileDF
.withColumn("age_group_num",
when($"age_group" === "未成年", 0)
.when($"age_group" === "青年", 1)
.when($"age_group" === "中年", 2)
.otherwise(3)
)
.withColumn("gender_num", when($"gender" === "男", 0).otherwise(1))
.withColumn("frequency_num",
when($"shopping_frequency" === "低", 0)
.when($"shopping_frequency" === "中", 1)
.otherwise(2)
)
.withColumn("value_num",
when($"average_order_value" < 500, 0)
.when($"average_order_value" < 1500, 1)
.otherwise(2)
)
.na.fill(0, Seq("age_group_num", "gender_num", "frequency_num", "value_num"))
// 特征向量化
val assembler = new VectorAssembler()
.setInputCols(Array("age_group_num", "gender_num", "frequency_num", "value_num"))
.setOutputCol("features")
val features = assembler.transform(featureDF)
// 特征标准化
val scaler = new StandardScaler()
.setInputCol("features")
.setOutputCol("scaledFeatures")
.setWithStd(true)
.setWithMean(false)
val scaledFeatures = scaler.fit(features).transform(features)
// 寻找最优 K 值
val maxK = math.min(10, profileDF.count().toInt / 10).max(2)
var bestK = 5
var bestSilhouette = -1.0
logger.info(s"开始寻找最优K值,范围: 2 到 $maxK")
for (k <- 2 to maxK) {
try {
val kmeans = new KMeans()
.setK(k)
.setSeed(42L)
.setFeaturesCol("scaledFeatures")
.setPredictionCol("cluster")
val model = kmeans.fit(scaledFeatures)
val predictions = model.transform(scaledFeatures)
val evaluator = new ClusteringEvaluator()
.setFeaturesCol("scaledFeatures")
.setPredictionCol("cluster")
val silhouette = evaluator.evaluate(predictions)
logger.info(s"K=$k, Silhouette=$silhouette")
if (silhouette > bestSilhouette) {
bestSilhouette = silhouette
bestK = k
}
} catch {
case e: Exception =>
logger.warn(s"K=$k 聚类失败: ${e.getMessage}")
}
}
logger.info(s"最优K值: $bestK, Silhouette: $bestSilhouette")
// 使用最优 K 值进行聚类
val bestKMeans = new KMeans()
.setK(bestK)
.setSeed(42L)
.setFeaturesCol("scaledFeatures")
.setPredictionCol("cluster")
val bestModel = bestKMeans.fit(scaledFeatures)
val predictions = bestModel.transform(scaledFeatures)
// 评估模型
val evaluator = new ClusteringEvaluator()
.setFeaturesCol("scaledFeatures")
.setPredictionCol("cluster")
val silhouette = evaluator.evaluate(predictions)
logger.info(s"最终模型 Silhouette score: $silhouette")
// 分析聚类结果
val clusterAnalysis = predictions
.groupBy($"cluster")
.agg(
count("*").as("user_count"),
avg($"age_group_num").as("avg_age_group"),
avg($"gender_num").as("avg_gender"),
avg($"frequency_num").as("avg_frequency"),
avg($"value_num").as("avg_value")
)
logger.info("聚类分析结果:")
clusterAnalysis.show(false)
// 为用户添加聚类标签
val userWithCluster = predictions
.select(
$"user_id",
$"cluster",
when($"cluster" === 0, "高价值高频用户")
.when($"cluster" === 1, "普通价值高频用户")
.when($"cluster" === 2, "高价值低频用户")
.when($"cluster" === 3, "普通价值低频用户")
.otherwise("低价值用户").as("user_segment")
)
// 保存聚类结果
DataAccessLayer.writeToMySQL(userWithCluster, "user_segments_ml", "overwrite")
logger.info("用户分群完成")
userWithCluster.show(20, false)
} catch {
case e: Exception =>
logger.error(s"用户分群失败: ${e.getMessage}", e)
throw e
}
}
}
}10. 个性化推荐 - PersonalizedRecommendation.scala#
package com.ecommerce.profile.recommendation
import com.ecommerce.profile.dal.DataAccessLayer
import org.apache.spark.ml.evaluation.RegressionEvaluator
import org.apache.spark.ml.recommendation.ALS
import org.apache.spark.sql.functions._
import org.slf4j.{Logger, LoggerFactory}
/**
* 个性化推荐
*
* 基于 ALS 协同过滤算法,根据用户行为数据为用户推荐商品。
*
* 改进点:
* - 使用 DataAccessLayer.withSparkSession 管理 SparkSession 生命周期
* - 所有数据库配置通过 ConfigManager 获取
* - 添加模型评估和冷启动策略
*/
object PersonalizedRecommendation {
private val logger: Logger = LoggerFactory.getLogger(PersonalizedRecommendation.getClass)
def main(args: Array[String]): Unit = {
DataAccessLayer.withSparkSession("PersonalizedRecommendation") { spark =>
import spark.implicits._
try {
// 读取用户行为数据
val behaviorDF = DataAccessLayer.readFromMySQL(spark, "user_behavior")
logger.info(s"读取用户行为数据: ${behaviorDF.count()} 条")
if (behaviorDF.isEmpty) {
logger.warn("用户行为数据为空,无法进行推荐")
return
}
// 转换为评分数据
val ratingDF = behaviorDF
.withColumn("rating",
when($"behavior_type" === "purchase", 5.0)
.when($"behavior_type" === "add_to_cart", 4.0)
.when($"behavior_type" === "favorite", 3.0)
.when($"behavior_type" === "click", 2.0)
.otherwise(1.0)
)
.select($"user_id", $"product_id", $"rating")
.filter($"user_id".isNotNull && $"product_id".isNotNull)
// 划分训练集和测试集
val Array(training, test) = ratingDF.randomSplit(Array(0.8, 0.2), seed = 42L)
// 构建 ALS 模型
val als = new ALS()
.setMaxIter(10)
.setRegParam(0.01)
.setUserCol("user_id")
.setItemCol("product_id")
.setRatingCol("rating")
.setColdStartStrategy("drop")
.setNonnegative(true)
val model = als.fit(training)
// 评估模型
val predictions = model.transform(test)
val evaluator = new RegressionEvaluator()
.setMetricName("rmse")
.setLabelCol("rating")
.setPredictionCol("prediction")
val rmse = evaluator.evaluate(predictions)
logger.info(s"Root-mean-square error = $rmse")
// 为所有用户推荐商品
val userRecommendations = model.recommendForAllUsers(10)
// 保存推荐结果
DataAccessLayer.writeToMySQL(userRecommendations, "user_recommendations", "overwrite")
logger.info("个性化推荐完成")
userRecommendations.show(10, false)
} catch {
case e: Exception =>
logger.error(s"个性化推荐失败: ${e.getMessage}", e)
throw e
}
}
}
}11. 精准营销 - TargetedMarketing.scala#
package com.ecommerce.profile.marketing
import com.ecommerce.profile.dal.DataAccessLayer
import org.apache.spark.sql.functions._
import org.slf4j.{Logger, LoggerFactory}
/**
* 精准营销
*
* 根据用户画像和分群数据,筛选目标用户群体,支持:
* - 高价值用户营销
* - 流失风险用户挽回
* - 特定兴趣用户定向推送
*
* 改进点:
* - 使用 DataAccessLayer.withSparkSession 管理 SparkSession 生命周期
* - 所有数据库配置通过 ConfigManager 获取
* - 添加数据验证
*/
object TargetedMarketing {
private val logger: Logger = LoggerFactory.getLogger(TargetedMarketing.getClass)
def main(args: Array[String]): Unit = {
DataAccessLayer.withSparkSession("TargetedMarketing") { spark =>
import spark.implicits._
try {
// 读取用户画像和分群数据
val profileDF = DataAccessLayer.readFromMySQL(spark, "user_profiles")
val segmentDF = DataAccessLayer.readFromMySQL(spark, "user_segments")
// 合并数据
val userData = profileDF.join(segmentDF, Seq("user_id"), "left")
logger.info(s"用户画像+分群数据: ${userData.count()} 条")
// 1. 针对高价值用户的营销
val highValueUsers = userData
.filter($"user_segment" === "重要价值用户" || $"user_segment" === "重要发展用户")
.select($"user_id", $"age_group", $"gender", $"shopping_frequency", $"user_segment")
logger.info("高价值用户列表:")
highValueUsers.show(false)
// 2. 针对流失风险用户的营销
val churnRiskUsers = userData
.filter($"user_segment" === "流失预警用户" || $"user_segment" === "重要唤回用户")
.select($"user_id", $"age_group", $"gender", $"shopping_frequency", $"user_segment")
logger.info("流失风险用户列表:")
churnRiskUsers.show(false)
// 3. 针对特定兴趣用户的营销
val categoryInterestUsers = userData
.filter(array_contains($"interest_categories.category", "电子产品"))
.select($"user_id", $"age_group", $"gender", $"shopping_frequency")
logger.info("对电子产品感兴趣的用户列表:")
categoryInterestUsers.show(false)
// 4. 保存营销目标用户
val marketingTargets = userData
.withColumn("marketing_type",
when($"user_segment" === "重要价值用户", "VIP维护")
.when($"user_segment" === "重要发展用户", "价值提升")
.when($"user_segment" === "流失预警用户", "流失挽回")
.when($"user_segment" === "重要唤回用户", "召回激活")
.otherwise("常规触达")
)
.select($"user_id", $"user_segment", $"marketing_type")
DataAccessLayer.writeToMySQL(marketingTargets, "marketing_targets", "overwrite")
logger.info("营销目标用户已保存")
// 5. 输出营销策略建议
val strategyStats = marketingTargets
.groupBy($"marketing_type")
.agg(count("*").as("target_count"))
.orderBy($"target_count".desc)
logger.info("营销策略统计:")
strategyStats.show(false)
} catch {
case e: Exception =>
logger.error(s"精准营销分析失败: ${e.getMessage}", e)
throw e
}
}
}
}12. 数据库初始化脚本 - init_tables.sql#
-- ============================================
-- 电商用户画像系统 - 数据库初始化脚本
-- MySQL 8.0+
-- ============================================
-- 创建数据库
CREATE DATABASE IF NOT EXISTS ecommerce
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE ecommerce;
-- 1. 用户基础信息表
CREATE TABLE IF NOT EXISTS users (
user_id INT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
gender VARCHAR(10),
age INT,
email VARCHAR(100),
phone VARCHAR(20),
registration_date DATE,
last_login_date DATE,
user_level INT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_gender (gender),
INDEX idx_age (age),
INDEX idx_user_level (user_level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2. 用户行为数据表
CREATE TABLE IF NOT EXISTS user_behavior (
behavior_id BIGINT PRIMARY KEY,
user_id INT NOT NULL,
product_id INT NOT NULL,
behavior_type VARCHAR(20) NOT NULL,
behavior_time TIMESTAMP NOT NULL,
session_id VARCHAR(100),
ip_address VARCHAR(50),
device_type VARCHAR(50),
category VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_behavior_type (behavior_type),
INDEX idx_behavior_time (behavior_time),
INDEX idx_user_behavior (user_id, behavior_type),
INDEX idx_user_time (user_id, behavior_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 3. 商品数据表
CREATE TABLE IF NOT EXISTS products (
product_id INT PRIMARY KEY,
product_name VARCHAR(100) NOT NULL,
category_id INT,
category_name VARCHAR(50),
price DECIMAL(10,2),
brand VARCHAR(50),
stock INT DEFAULT 0,
sales INT DEFAULT 0,
rating DECIMAL(3,2) DEFAULT 0.00,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_category (category_id),
INDEX idx_brand (brand),
INDEX idx_price (price)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 4. 用户画像表
CREATE TABLE IF NOT EXISTS user_profiles (
user_id INT PRIMARY KEY,
age_group VARCHAR(20),
gender VARCHAR(10),
interest_categories JSON,
purchase_power VARCHAR(20),
shopping_frequency VARCHAR(20),
favorite_brands JSON,
average_order_value DECIMAL(10,2),
last_purchase_date DATE,
user_tags JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_age_group (age_group),
INDEX idx_gender (gender),
INDEX idx_purchase_power (purchase_power),
INDEX idx_shopping_frequency (shopping_frequency)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 5. 用户RFM分析表
CREATE TABLE IF NOT EXISTS user_rfm_analysis (
user_id INT PRIMARY KEY,
recency INT COMMENT '最近消费距今天数',
frequency INT COMMENT '消费频次',
monetary DOUBLE COMMENT '消费总金额',
r_score INT COMMENT 'R评分(1-5)',
f_score INT COMMENT 'F评分(1-5)',
m_score INT COMMENT 'M评分(1-5)',
rfm_score VARCHAR(10) COMMENT 'RFM组合评分',
user_segment VARCHAR(30) COMMENT '用户分群',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_rfm_score (rfm_score),
INDEX idx_user_segment (user_segment),
INDEX idx_r_score (r_score),
INDEX idx_f_score (f_score),
INDEX idx_m_score (m_score)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 6. 用户分群表(机器学习分群结果)
CREATE TABLE IF NOT EXISTS user_segments (
user_id INT PRIMARY KEY,
rfm_score VARCHAR(10),
user_segment VARCHAR(30),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_segment (user_segment)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 7. 用户分群表(ML分群结果)
CREATE TABLE IF NOT EXISTS user_segments_ml (
user_id INT PRIMARY KEY,
cluster INT,
user_segment VARCHAR(30),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_cluster (cluster),
INDEX idx_user_segment (user_segment)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 8. 用户推荐结果表
CREATE TABLE IF NOT EXISTS user_recommendations (
user_id INT,
recommendations ARRAY<STRUCT<product_id: INT, rating: FLOAT>>,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 9. 营销目标用户表
CREATE TABLE IF NOT EXISTS marketing_targets (
user_id INT,
user_segment VARCHAR(30),
marketing_type VARCHAR(30),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, marketing_type),
INDEX idx_marketing_type (marketing_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 10. 标签元数据表
CREATE TABLE IF NOT EXISTS tag_metadata (
tag_id INT AUTO_INCREMENT PRIMARY KEY,
tag_name VARCHAR(100) NOT NULL COMMENT '标签名称',
tag_category VARCHAR(50) COMMENT '一级分类',
tag_subcategory VARCHAR(50) COMMENT '二级分类',
tag_type VARCHAR(20) COMMENT '标签类型:事实/统计/算法',
tag_description VARCHAR(500) COMMENT '标签描述',
is_active TINYINT DEFAULT 1 COMMENT '是否启用',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE INDEX idx_tag_name (tag_name),
INDEX idx_tag_category (tag_category),
INDEX idx_tag_type (tag_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;可视化模块#
Grafana仪表板配置#
配置Grafana连接MySQL数据源,创建以下仪表板:
用户画像概览仪表板
- 用户年龄分布
- 用户性别分布
- 用户兴趣分类分布
- 用户购物频率分布
- 用户价值分布
RFM分析仪表板
- 各分群用户数量
- RFM评分分布
- 分群特征对比
- 分群价值分析
营销效果仪表板
- 营销活动转化率
- 不同用户群的响应率
- 营销ROI分析
数据可视化代码#
# dashboard.py
import os
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import mysql.connector
# 从环境变量获取数据库连接信息
MYSQL_HOST = os.environ.get("MYSQL_HOST", "localhost")
MYSQL_USER = os.environ.get("MYSQL_USER", "ecommerce_user")
MYSQL_PASSWORD = os.environ.get("MYSQL_PASSWORD", "")
MYSQL_DATABASE = os.environ.get("MYSQL_DATABASE", "ecommerce")
# 连接MySQL
conn = mysql.connector.connect(
host=MYSQL_HOST,
user=MYSQL_USER,
password=MYSQL_PASSWORD,
database=MYSQL_DATABASE
)
try:
# 读取用户画像数据
profile_df = pd.read_sql("SELECT * FROM user_profiles", conn)
# 读取RFM分析数据
rfm_df = pd.read_sql("SELECT * FROM user_rfm_analysis", conn)
# 读取用户分群数据
segment_df = pd.read_sql("SELECT * FROM user_segments", conn)
# 设置中文字体
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False
# 1. 用户年龄分布
plt.figure(figsize=(12, 6))
sns.countplot(x='age_group', data=profile_df, order=['未成年', '青年', '中年', '老年'])
plt.title('用户年龄分布')
plt.xlabel('年龄组')
plt.ylabel('用户数')
plt.savefig('age_distribution.png', dpi=150, bbox_inches='tight')
plt.close()
# 2. 用户性别分布
plt.figure(figsize=(8, 8))
gender_counts = profile_df['gender'].value_counts()
gender_counts.plot(kind='pie', autopct='%1.1f%%', colors=['#66b3ff', '#ff9999'])
plt.title('用户性别分布')
plt.savefig('gender_distribution.png', dpi=150, bbox_inches='tight')
plt.close()
# 3. RFM用户分群分布
plt.figure(figsize=(12, 6))
segment_counts = rfm_df['user_segment'].value_counts()
segment_counts.plot(kind='barh', color='steelblue')
plt.title('RFM用户分群分布')
plt.xlabel('用户数')
plt.ylabel('用户分群')
plt.savefig('rfm_segment_distribution.png', dpi=150, bbox_inches='tight')
plt.close()
# 4. RFM评分热力图
plt.figure(figsize=(10, 8))
rfm_pivot = rfm_df.groupby(['r_score', 'f_score'])['user_id'].count().unstack(fill_value=0)
sns.heatmap(rfm_pivot, annot=True, fmt='d', cmap='YlOrRd')
plt.title('RFM评分分布热力图')
plt.xlabel('F评分')
plt.ylabel('R评分')
plt.savefig('rfm_heatmap.png', dpi=150, bbox_inches='tight')
plt.close()
# 5. 购物频率与购买力关系
plt.figure(figsize=(12, 6))
cross_tab = pd.crosstab(profile_df['shopping_frequency'], profile_df['purchase_power'])
cross_tab.plot(kind='bar', stacked=True)
plt.title('购物频率与购买力关系')
plt.xlabel('购物频率')
plt.ylabel('用户数')
plt.legend(title='购买力')
plt.savefig('frequency_power_relation.png', dpi=150, bbox_inches='tight')
plt.close()
print("可视化图表生成完成")
finally:
conn.close()项目实现步骤#
1. 环境搭建#
# 1. 安装基础组件(以CentOS为例)
# JDK 11
sudo yum install java-11-openjdk java-11-openjdk-devel
export JAVA_HOME=/usr/lib/jvm/java-11
# Scala 2.13.8
wget https://downloads.lightbend.com/scala/2.13.8/scala-2.13.8.tgz
tar -xzf scala-2.13.8.tgz -C /usr/local/
export SCALA_HOME=/usr/local/scala-2.13.8
# 2. 安装大数据组件
# Hadoop 3.3.6, Spark 3.5.8, Kafka 3.7.0, HBase 2.5.x, Hive 3.1.x
# (参考各组件官方安装文档)
# 3. 安装 Redis 7.0+
sudo yum install redis
sudo systemctl start redis
sudo systemctl enable redis
# 4. 安装 MySQL 8.0
sudo yum install mysql-server
sudo systemctl start mysqld
sudo systemctl enable mysqld
# 5. 创建数据库和表
mysql -u root -p < scripts/init_tables.sql
# 6. 配置环境变量(敏感信息)
export MYSQL_PASSWORD="your_secure_password"
export REDIS_PASSWORD="your_redis_password"2. 项目构建#
# 编译打包
mvn clean package -DskipTests
# 提交 Spark 作业
spark-submit \
--class com.ecommerce.profile.builder.UserProfileBuilder \
--master yarn \
--deploy-mode cluster \
--conf spark.dynamicAllocation.enabled=true \
target/user-profile-1.0.0.jar3. 数据采集#
- 启动Kafka服务和主题
- 运行数据采集程序,生成模拟数据
- 验证数据是否正确发送到Kafka
4. 数据存储#
- 运行数据存储程序,将数据写入HBase和MySQL
- 验证数据是否正确存储
- 配置数据备份策略
5. 用户画像构建#
- 运行基础画像构建程序(UserProfileBuilder)
- 运行RFM分析程序(RFMAnalyzer)
- 运行用户分群程序(UserSegmentation)
- 验证画像数据的准确性
6. 应用开发#
- 实现个性化推荐系统(PersonalizedRecommendation)
- 实现精准营销系统(TargetedMarketing)
- 启动用户画像服务(UserProfileService)
7. 可视化展示#
- 配置Grafana数据源
- 创建仪表板和图表
- 验证可视化效果
项目扩展#
- 实时用户画像:基于实时数据流,实现实时用户画像更新
- 跨渠道数据整合:整合线上线下数据,构建全渠道用户画像
- 深度学习模型:使用深度学习模型提升用户画像的准确性
- 隐私保护:实现差分隐私技术,保护用户隐私
- 多语言支持:支持多语言用户画像构建和分析
- 标签血缘追踪:记录标签的计算逻辑和数据来源,支持标签溯源
- A/B测试平台:基于用户画像进行A/B测试,验证营销策略效果
项目总结#
本项目实现了一个完整的企业级电商用户画像系统,涵盖了数据采集、存储、处理、分析和应用的全流程。通过整合Spark 3.5.8、Hadoop、HBase、Kafka 3.7.0、Redis 7.0+、MySQL 8.0等大数据技术,系统能够构建精准的用户画像,为电商平台的个性化推荐、精准营销和用户体验优化提供数据支持。
系统具有以下特点:
- 多维度画像:从人口统计学、行为、兴趣、RFM等多个维度构建用户画像
- 实时性:使用Kafka和Spark Streaming实现实时数据处理
- 智能分析:使用RFM模型和K-Means机器学习算法进行用户分群和行为预测
- 分层存储:Redis热数据 + HBase标签数据 + MySQL结构化数据 + Hive历史数据
- 配置外部化:所有配置通过Typesafe Config管理,敏感信息通过环境变量注入
- 安全可靠:消除硬编码密码,Redis连接池管理,多级降级方案
- 资源管理:SparkSession使用try-catch-finally确保释放,JedisPool连接池管理
- 服务化:提供多级缓存查询服务,支持降级和批量查询
通过本项目,我们掌握了企业级用户画像系统的设计和实现方法,包括配置管理、数据访问层封装、多级缓存策略、降级方案等工程实践,为未来的大数据项目开发打下了坚实的基础。