关于本Rust实战教程系列:
简要说明
欢迎来到 Rust 实战第五篇!
本次课程我们将围绕 用户埋点数据分析实战 展开。课程中涉及的诸多技术点------如数据库操作、异步编程等------在先前篇章中已有铺垫,因此部分内容将不再赘述,而是聚焦于本次的两个核心实战环节:
- Kafka 数据消费的实现与流程管理
- 用户行为分析模型的开发与业务落地
我们也将基于真实的业务场景,带大家从技术视角深入用户埋点数据,挖掘那些常被忽略却具备业务价值的信息点,让你不仅熟悉 Rust 技术栈,更能掌握一套贴近实际的数据分析思维和方法。
现在,我们正式进入课程内容。
需求拆解 && 设计方案
当谈及用户埋点数据分析,通常涵盖以下几个关键步骤:
- 数据收集:采集用户在客户端产生的各类行为数据,如页面访问、组件曝光、按钮点击等等
- 数据预处理:对原始数据进行清洗、过滤与格式化处理,进行流数据处理、会话切割、特征提取等
- 数据分析:运用分析模型与算法,深入挖掘用户行为模式,分为实时数据处理、批处理历史数据等
- 数据存储:将分析后的结果存入适合分析的数据结构或存储系统中,保证数据持久化存储
- 可视化展示:通过图表、报表等形式直观呈现分析结果,呈现埋点数据的各类指标
在本课程中,我们假定已具备足够的埋点数据积累(原始数据采集->Kafka的过程忽略,假设已经实现 ),将重点使用 Rust 框架来实现从预处理到可视化的完整流程。下面,我们来逐一拆解每个步骤的具体实施内容:
数据收集:
本次课程不涉及客户端数据采集的方案设计,我们默认用户行为埋点数据已完成客户端上报,并统一汇总至 Kafka 消息队列中。
因此,课程重点将放在从 Kafka 消费埋点数据 开始的后端处理流程。这也意味着,我们假设从前端用户行为上报到后端数据接入的采集链路已经就绪,接下来将使用 Rust 实现后续的数据处理与分析环节。
数据预处理
针对数据预处理的话,第一步我们要先获取到在上一步中已经上传到Kafka中的数据,然后基于这些数据进行数据的预处理操作,下面我们首先来看下Kafka消费模块是如何设计和实现的:
Kafka消费模块
从Kafka主题中消费埋点数据,可能还需要对数据进行反序列化处理(如JSON格式)
- 创建Kafka消费者,订阅相关主题
- 设置消费者组,支持并行处理
- 处理消费消息,将消息传递到下一个模块
数据清洗
从 Kafka 消费到的原始数据中,往往掺杂着不具备分析价值的条目,例如测试数据或无效记录。为确保后续分析的准确性与可靠性,必须对数据进行清洗。本次课程将依据以下规则进行数据清洗,以剔除无效记录:
- 缺少有效用户 ID
- 未定义事件类型
- 未标注所属项目名称
- 缺失数据上报时间
通过以上过滤,可有效提升数据质量,为后续分析提供可靠的数据基础。
会话切割
会话切割旨在依据用户ID与预设的会话过期时间,将连续上报的事件按单次会话进行划分。此步骤是对原始事件数据的首次聚合处理,将离散的事件转化为以用户为维度的会话数据,以便后续提取用户特征。处理完成后,会话数据需进行持久化存储,为进一步分析与建模提供基础。
具体处理流程如下:
- 状态维护:为每个用户在 Redis 中记录最近一次事件的时间戳及当前会话ID。
- 会话判定:若当前事件与上次事件的时间间隔超过设定阈值(例如30分钟),则为该用户创建新会话。在此时间窗口内的所有事件归属于同一会话,并标记相同会话ID,以完整追踪用户单次连续行为。
- 数据输出:生成带有会话ID的会话结构数据。
- 持久化存储:将清洗后的会话数据写入 ClickHouse 数据库,支持后续高效查询与分析。
- 特征提取:基于已组织的会话数据,开始进行用户行为特征的提取与计算。
用户特征提取
特征提取旨在从清洗与会话化后的用户行为数据中,挖掘具有分析价值与业务解释性的行为特征。本步骤将基于已组织的会话数据,构建可用于用户画像、行为分析或模型训练的结构化特征集。处理流程如下:
-
特征设计与定义:根据业务目标,定义提取特征的类型与计算口径,如:
- 行为频次特征(如会话内事件数量、特定事件触发次数)
- 时长与间隔特征(如会话总时长、事件平均间隔)
- 路径与序列特征(如常用操作路径、事件流转顺序)
- 强度与深度特征(如功能使用深度、页面停留集中度)
-
特征计算与聚合:基于会话数据,按用户或会话维度进行统计与计算:
- 利用时间窗口聚合统计事件频次与分布
- 通过序列分析提取典型行为路径与模式
- 结合业务规则衍生复合指标(如转化率、跳出率)
-
特征存储与管理:将提取的特征写入特征库(如ClickHouse或特征平台),并建立版本与更新机制,确保特征可复现、可追溯。
通过以上步骤,我们将原始埋点数据转化为可直接用于分析、报表与模型训练的高质量特征,为后续深入理解用户行为、优化产品体验提供数据基础。
数据实时聚合分析(下一期内容)
实时数据聚合通过固定窗口、滑动窗口等计算模式,实现对数据流的持续处理与毫秒级指标输出,从而为业务提供即时、连续的数据洞察。其核心价值体现在:
- 毫秒级延迟:指标经实时计算产生,延迟可控制在秒级甚至毫秒级,满足高时效性场景需求。
- 连续流式处理:直接处理无界数据流,无需等待数据积累或批次结束,实现持续计算。
- 灵活窗口化聚合:支持按固定窗口、滑动窗口及会话窗口等多种方式进行时间维度聚合。
- 实时业务洞察:为监控、告警与实时决策提供即时指标,助力业务快速响应。
与传统批处理聚合的对比:
| 聚合方式 | 处理流程 | 典型延迟 |
|---|---|---|
| 批处理聚合(传统) | 原始数据 → 存储至数据库 → 定时查询计算 → 报表/指标 | 小时/天级别 |
| 实时聚合(现代) | 原始数据 → 实时计算引擎 → 立即输出指标 → 实时仪表板/告警 | 秒/毫秒级别 |
实时聚合体系将数据处理与指标产出前置到数据流动的过程中,实现了从"事后统计"到"实时感知"的跨越,为业务运营、系统监控与用户行为分析提供了更及时、更连续的数据支撑。
可视化展示(下一期内容)
为实现分析结果的有效触达与直观解读,我们构建了完整的可视化展示体系。该体系以稳定高效的 REST API 为核心,面向前端应用提供标准化的数据查询服务,最终通过丰富的图表与交互界面,将数据洞察转化为直观的业务视图。
核心实现方案如下:
- 标准化 API 层
基于 Actix-web 框架提供高性能、异步的 RESTful API。接口遵循资源导向设计,支持分页、过滤、时间范围查询等通用参数,并返回统一结构的 JSON 数据,确保前端能够灵活、可靠地获取所需数据。 - 模块化数据服务
针对不同分析维度(如用户会话、行为漏斗、实时指标)封装独立的数据服务模块。各模块内部处理复杂的查询逻辑与多表关联,对外提供简洁的语义化接口,实现业务逻辑与数据访问的清晰分离。 - 前端就绪的数据格式
输出数据已针对可视化需求进行预处理与聚合,前端可直接用于渲染折线图、热力图、漏斗图、数据表格等多种组件,减少额外的转换开销。 - 实时与历史数据统一出口
API 层同时支持查询实时计算指标与历史聚合结果,前端可根据场景自由切换,实现从宏观趋势到微观实况的无缝观测。 - 可扩展的架构设计
当前支持 Web 仪表板、大屏可视化等场景。未来可通过扩展 API 或接入 WebSocket 推送,进一步支持移动端报表、实时告警通知等多元化展示终端。
通过以上设计,数据分析的最终结果得以跨越技术边界,直接赋能产品、运营与决策团队,形成从数据采集、处理、分析到展示的完整闭环。
实现步骤
我们将首先定义一个全局配置文件,用于统一管理数据存储、处理流程等环节所需的各项配置参数。
全局配置
全局配置模块采用 config 包实现,其主要功能是读取和解析配置文件,完成配置的加载与初始化,并为项目中的其他模块提供统一、便捷的配置访问入口。
具体实现代码如下:
yaml
# config/default.yaml
app:
name: "DATA POINT ANALYSIS SYSTEM" # 应用名称
version: "1.0.0" # 应用版本
description: "Data Point Analysis System" # 应用描述
author: "Amos" # 作者名称
author_email: "XXX" # 作者邮箱
kafka:
brokers: # Kafka 集群地址列表
- "172.0.0.1:XXXX"
group_id: "data_point_analysis_2026-01-14" # 消费者组ID
topic: "data-point-analysis" # Kafka topic
max_retries: 5 # 重试次数
retry_backoff_ms: 1000 # 重试间隔时间(毫秒)
storage:
clickhouse: # ClickHouse 配置
url: "http://localhost:8123"
database: "default"
username: "default"
password: "default1234"
connection_pool_size: 10
connection_timeout_secs: 10
compression: "lz4"
redis: # Redis 配置
url: "redis://localhost:6379"
password: ""
sqlite: # SQLite 配置
url: "data/data.db"
max_connections: 10
processor: # 流处理器配置
session_timeout_secs: 300
batch_interval_secs: 60
events_max_size: 100
以上就是目前使用到的配置参数,下面我们来看下如何进行配置参数的初始化:
rust
// src/config.rs
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use once_cell::sync::OnceCell;
use anyhow::{Result,Context};
// 定义全局的配置对象 全局唯一
static GLOBAL_APP_CONFIG: OnceCell<Arc<AppConfig>> = OnceCell::new();
// 下面的数据模型就是一一对应配置文件中的配置即可
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppConfig {
pub app: AppSettings,
pub kafka: KafkaSettings,
pub storage: StorageSettings,
pub processor: ProcessingSettings,
}
// 定义应用程序的基本信息
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppSettings {
pub name: String,
pub version: String,
pub description: String,
pub author: String,
pub author_email: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct KafkaSettings {
pub brokers: Vec<String>,
pub group_id: String,
pub topic: String,
pub max_retries: usize,
pub retry_backoff_ms: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StorageSettings {
pub clickhouse: ClickHouseSettings,
pub redis: RedisSettings,
pub sqlite: SqliteSettings,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ClickHouseSettings {
pub url: String,
pub database: String,
pub username: String,
pub password: String,
pub connection_pool_size: u32,
pub compression: String,
pub connection_timeout_secs: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RedisSettings {
pub url: String,
pub password: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SqliteSettings {
pub url: String,
pub max_connections: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ProcessingSettings {
pub session_timeout_secs: u64,
pub batch_interval_secs: u64,
pub events_max_size: u64,
}
impl AppConfig {
pub fn load() -> Result<Self>{
let config = config::Config::builder()
.add_source(config::File::with_name("config/default"))
.add_source(config::Environment::with_prefix("APP").separator("_"))
.build()
.context("Failed to build configuration")?;
Ok(config.try_deserialize::<AppConfig>()?)
}
// 初始化全局配置
pub fn init_global_config() -> Result<()> {
let global_config = Self::load()?;
let arc_config = Arc::new(global_config);
GLOBAL_APP_CONFIG
.set(arc_config)
.map_err(|_| anyhow::anyhow!("Global config has already been initialized"))
}
// 获取全局配置的引用
pub fn get_global_config() -> Result<Arc<AppConfig>> {
GLOBAL_APP_CONFIG
.get()
.cloned()
.ok_or_else(|| anyhow::anyhow!("Global config is not initialized"))
}
pub fn get_kafka_config(&self) -> &KafkaSettings {
&self.kafka
}
}
全局对象
在此,我们定义一组自定义的错误类型与结果类型,用于在整个项目中标准化错误处理流程。通过统一管理各类异常情况,能够显著提升代码的健壮性与可维护性。
rust
// src/utils/error.rs
// 定义统一的报错对象
#[derive(Error, Debug)]
pub enum AppError {
#[error("Configuration error: {0}")]
ConfigError(String),
#[error("Kafka error: {0}")]
KafkaError(#[from] KafkaError),
#[error("ClickHouse error: {0}")]
ClickHouseError(#[from] CHError),
#[error("Redis error: {0}")]
RedisError(#[from] RedisError),
#[error("IO error: {0}")]
IoError(#[from] io::Error),
#[error("Serde error: {0}")]
SerdeError(String),
#[error("Processing error: {0}")]
ProcessingError(String),
#[error("Arc error: {0}")]
ArcError(#[from] ArcError),
#[error("Invalid data error: {0}")]
InvalidDataError(String),
#[error("Storage error: {0}")]
StorageError(String),
#[error("ClickHouse storage error: {0}")]
ClickHouseStorageError(String),
// 数据库相关错误
#[error("Database error: {0}")]
DatabaseError(String),
#[error("Unknown error: {0}")]
UnknownError(String),
}
// 定义统一的返回结果
pub type AppResult<T> = Result<T, AppError>;
数据预处理
数据预处理模块包含着Kafka消费模块、流数据处理模块、会话切割模块、用户特征提取模块,下面我们来一个一个模块的来看具体的实现逻辑,
Kafka消费模块
Kafka消费模块我们需要提供两个主要的功能即可:链接Kafka、消费kafka数据,所以这个模块我们就只需要定义两个方法就可以了,具体的代码逻辑如下:
rust
// src/kafka/customer.rs
// 链接Kafka服务方法,基于我们上面的配置参数 进行连接即可
pub async fn create_consumer_connector(cfg: &AppConfig) -> AppResult<Consumer> {
let kafka_config = cfg.get_kafka_config();
let consumer = (|| async {
Consumer::from_hosts(kafka_config.brokers.clone())
.with_group(kafka_config.group_id.to_owned())
.with_fallback_offset(FetchOffset::Earliest) // 消费的偏移量
.with_offset_storage(Some(GroupOffsetStorage::Kafka))
.with_topic(kafka_config.topic.to_owned())
.create()
.map_err(AppError::KafkaError)
})
.retry(retry_policy(kafka_config.max_retries, kafka_config.retry_backoff_ms))
.await
.map_err(|_| AppError::KafkaError(KafkaError::Kafka(KafkaCode::BrokerNotAvailable)))?;
Ok(consumer)
}
// 数据消费方法
pub async fn run_consumer<'a>(cfg: &AppConfig, stream_processor: &mut StreamProcessHandler<'a>) -> AppResult<()> {
let mut kafka_connector = create_consumer_connector(cfg).await?;
println!("Starting Kafka consumer...");
// 优雅退出标志(可在外部通过Ctrl+C手动触发)
let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
// 监听Ctrl+C信号以优雅退出
ctrl_c_handler(running_clone);
// 定义一个缓冲区用于存储处理结果 方便进行批量处理数据
while running.load(Ordering::SeqCst) {
// 在这里拉取消息 进行消费 拉取最新的一条消息
let mss = match kafka_connector.poll() {
Ok(mss) => mss,
Err(e) => {
println!("Error polling Kafka: {:?}", e);
thread::sleep(Duration::from_secs(60));
continue;
}
};
if mss.is_empty() {
println!("No messages received");
//没有数据的话 休眠一下 在进行拉取操作
thread::sleep(Duration::from_secs(60));
continue;
}
for msg in mss.iter() {
for m in msg.messages() {
let _ = m.offset;
// 这里的话 把流数据转换成字符串类型的数据
let payload = match std::str::from_utf8(m.value) {
Ok(payload) => payload,
Err(_) => {
println!("Invalid message payload");
thread::sleep(std::time::Duration::from_secs(60));
continue;
}
};
// 流处理模块 对接收到的数据进行处理即可
stream_processor.process_message(payload).await;
}
}
// 批次处理完成厚提交位点避免重复 消费
if let Err(e) = kafka_connector.commit_consumed() {
println!("Error committing offsets: {:?}", e);
return Err(AppError::KafkaError(e));
}
}
Ok(())
}
// crtl + C 退出方法
use ctrlc;
pub fn ctrl_c_handler(running: std::sync::Arc<std::sync::atomic::AtomicBool>) {
let r = running.clone();
ctrlc::set_handler(move || {
println!("Ctrl+C received, shutting down...");
r.store(false, std::sync::atomic::Ordering::SeqCst);
}).expect("Error setting Ctrl-C handler");
}
流处理模块
流处理模块负责实时消费来自 Kafka 的数据流,并提供数据反序列化、流缓存以及会话数据持久化等核心操作,为后续的数据分析与特征提取提供实时、有序的数据支持。
rust
// src/processing/stream_processor.rs
// 流处理对象
pub struct StreamProcessHandler<'a> {
// 缓存接收到的流数据,进行缓存处理
stroage_payloads: Vec<String>,
// 定义一个缓冲时间间隔 用于批量处理数据
batch_interval_secs: u64,
// 定义一个批量处理数据大小
batch_max_size: u64,
// 上一次处理数据的时间
last_process_time: Instant,
// 会话切割器实例
sessionizer: Sessionizer<'a>,
}
// 定义流处理对象的相关方法
impl StreamProcessHandler{
// 更新处理时间
pub fn update_process_time(&mut self) {
self.last_process_time = Instant::now();
}
// 清空数据
pub fn clear_storage_payloads(&mut self) {
self.stroage_payloads.clear();
}
// 更新当前待处理数据列表
pub fn add_storage_payloads(&mut self, payload: String) {
self.stroage_payloads.push(payload);
}
// 定义消费消息的处理方法 核心函数
pub async fn process_message(&mut self, payload: &str) {
// 判断一下 当前是否满足批量处理条件 超过条数 或者时间超过batch_interval_secs
if self.stroage_payloads.len() as u64 >= self.batch_max_size || self.last_process_time.elapsed() >= Duration::from_secs(self.batch_interval_secs) {
info!("Batch processing conditions met. Processing {} messages.", self.stroage_payloads.len());
// 在这里进行会话数据的持久化存储动作
self.sessionizer.save_sessions_to_clickhouse().await.unwrap_or_else(|e| {
error!("Error saving sessions to ClickHouse: {:?}", e);
});
// 设置批量处理时间
self.update_process_time();
// 满足条件 则进行批量处理
let playloads = self.stroage_payloads.clone();
let _ = self.flush_data_batch(playloads).await;
// 清空存储的payloads
self.clear_storage_payloads();
}
self.add_storage_payloads(payload.to_string());
}
// 反序列化 埋点数据,并进行特征提取和存储
pub async fn flush_data_batch(&mut self, playloads: Vec<String>) -> AppResult<()> {
if self.stroage_payloads.is_empty() {
return Ok(());
}
// 在这里实现批量处理逻辑 将缓冲区的数据进行存储
for payload in playloads.iter() {
// 把数据进行反序列化 拿到一个对象
let event_message = match serde_json::from_str::<DataPointLog>(payload) {
Ok(event_data) => {
let message = match event_data.parse_inner_event() {
Ok(msg) => msg,
Err(e) => {
warn!("Error parsing inner event: {:?}", e);
continue;
}
};
message
},
Err(e) => {
warn!("Error deserializing message: {:?}", e);
continue;
}
};
// 对每一条消息 进行会话切割
let _ = self.sessionizer.split_message_to_session(&event_message).await;
}
info!("Processing batch of {} messages", playloads.len());
Ok(())
}
}
会话切割模块
会话切割模块,是基于接收到的message消息,针对每一条消息按照用户的维度进行会话级别的切割,同时对当前用户的特征进行计算和提取
rust
// src/processing/session_spliting.rs
// 定义会话分割器,根据当前的redis中的用户最后活动时间,判断是否需要开启新的会话
pub struct Sessionizer<'a> {
redis_client: RedisClient,
session_timeout_secs: u64,
split_session_list: HashMap<String, Vec<SessionEventInfo>>,
clickhouse_client: &'a ClickhouseClient,
feature_extractor: FeatureExtractor<'a>,
}
impl Sessionizer {
// 校验当前信息的必填字段是否有值,
pub fn check_session_required_fields(&self,event_message: &InnerEvent) -> SessionCheckResult {
// 必填字段 使用__来拆分不同层级的数据字段
let required_fields = vec!["data__properties__person_id", "data__event", "project", "data__time"];
let mut result: SessionCheckResult = (true, None, None, None, None);
// 遍历必填字段
for field in required_fields.iter() {
// 使用__来拆分字段
let ok = match *field {
"data__properties__person_id" => {
match event_message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.person_id.as_ref()) {
Some(id) => { if id.len() > 0 { result.1 = Some(id.clone()); true } else { false } },
None => false,
}
},
"data__event" => {
match event_message.data.as_ref().and_then(|d| d.event.as_ref()) {
Some(e) => { result.2 = Some(e.clone()); true },
None => false,
}
},
"project" => {
match event_message.project.as_ref() {
Some(p) => { result.3 = Some(p.clone()); true },
None => false,
}
},
"data__time" => {
match event_message.data.as_ref().and_then(|d| d.time.as_ref()) {
Some(t) => { result.4 = Some(t.clone()); true },
None => false,
}
},
_ => true,
};
if !ok {
result.0 = false;
}
}
result
}
pub async fn create_new_session(&mut self,message: &InnerEvent, basic_info: SessionCheckResult, redis_session_key: &str, session_id: String)-> AppResult<bool> {
let (_, person_id_opt, event_opt, project_opt, time_opt) = basic_info;
// 创建新的会话
let new_session_info = SessionEventInfo {
session_id: session_id,
project_code: project_opt.clone().unwrap_or("".to_string()),
project_id: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.project_id.clone()).unwrap_or("".to_string()),
project_name: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.project_name.clone()).unwrap_or("".to_string()),
event_type: DataPointEventType::from_str(event_opt.as_ref().unwrap_or(&"".to_string())),
event_time: time_opt.as_ref().and_then(|t| Some(*t)).unwrap_or(0),
screen_width: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.screen_width),
screen_height: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.screen_height),
viewport_width: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.viewport_width),
viewport_height: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.viewport_height),
person_code: person_id_opt.clone().unwrap_or("".to_string()),
event_id: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.evt_id.clone()).unwrap_or("".to_string()),
event_platform: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.platform.clone()).unwrap_or("".to_string()),
event_chrome_version: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.chrome_version.clone()).unwrap_or("".to_string()),
event_safari_version: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.safari_version.clone()).unwrap_or("".to_string()),
event_lark: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.lark.clone()).unwrap_or("".to_string()),
event_refferer: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.referrer.clone()).unwrap_or("".to_string()),
event_url: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.url.clone()).unwrap_or("".to_string()),
event_url_path: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.url_path.clone()).unwrap_or("".to_string()),
event_page_title: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.title.clone()).unwrap_or("".to_string()),
event_referrer_host: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.referrer_host.clone()).unwrap_or("".to_string()),
event_duration: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.event_duration),
usr_device: message.data.as_ref().and_then(|d| d.properties.as_ref()).and_then(|p| p.usr_device.clone()).unwrap_or("".to_string()),
};
// 暂时存储在当前的结构体中,超过1000条?或者 什么条件下 进行批量存储到clickhouse中
self.split_session_list
.entry(person_id_opt.clone().unwrap_or("".to_string()))
.or_insert_with(Vec::new)
.push(new_session_info.clone());
// 存储到redis中
self.redis_client.set_json_value(&redis_session_key, &RedisStorageEventInfo{
session_id: new_session_info.session_id.clone(),
person_id: person_id_opt.clone().unwrap_or("".to_string()),
last_activity_time:time_opt.as_ref().and_then(|t| Some(*t)).unwrap_or(0),
event_type: DataPointEventType::from_str(event_opt.as_ref().unwrap_or(&"".to_string())),
project: project_opt.clone().unwrap_or("".to_string()),
}).map_err(|e| AppError::RedisError(redis::RedisError::from((redis::ErrorKind::Io, "Failed to set value to Redis", e.to_string()))))?;
Ok(true)
}
pub async fn split_message_to_session(&mut self,message: &InnerEvent) -> AppResult<String> {
let (valid, person_id_opt, event_opt, project_opt, time_opt) = self.check_session_required_fields(&message);
if !valid {
return Err(AppError::InvalidDataError("Missing required fields for session splitting".to_string()));
}
// 获取当前的用户redis中的key
let redis_session_key = format!("session:{}:last_active", person_id_opt.as_ref().unwrap_or(&"".to_string()));
// 首先 获取redis中的用户最后活动时间 如果不存在 创建新的会话 ,如果存在的话 判断时间差是否超过session_timeout_secs 如果超过则创建新的会话
let session_value = match self.redis_client.get_json_value::<RedisStorageEventInfo>(&redis_session_key) {
Ok(val) => val,
Err(e) => {
return Err(AppError::RedisError(redis::RedisError::from((redis::ErrorKind::Io, "Failed to get value from Redis", e.to_string()))));
}
};
// 超过session_timeout_secs设置的时间 则创建新的会话 如果没有用户会话 则创建新会话
let create_session_id = if session_value.as_ref().is_none() {
Uuid::new_v4().to_string()
} else {
let (last_activity_time_in_redis, session_id) = session_value.as_ref().map(|s| (s.last_activity_time, s.session_id.clone())).unwrap_or((0, Uuid::new_v4().to_string()));
let current_event_time = time_opt.as_ref().and_then(|t| Some(*t)).unwrap_or(0);
if current_event_time - last_activity_time_in_redis > self.session_timeout_secs as i64 {
Uuid::new_v4().to_string()
} else {
session_id
}
};
match self.create_new_session(message, (valid, person_id_opt.clone(), event_opt.clone(), project_opt.clone(), time_opt.clone()), &redis_session_key, create_session_id).await {
Ok(_) => {},
Err(e) => {
return Err(AppError::RedisError(redis::RedisError::from((redis::ErrorKind::Io, "Failed to create new session", e.to_string()))));
}
};
Ok("".to_string())
}
pub async fn save_sessions_to_clickhouse(&self) -> AppResult<()> {
// 遍历当前的split_session_list 将数据批量存储到clickhouse中
for (person_id, sessions) in self.split_session_list.iter() {
println!("Storing {} sessions for person_id: {}", sessions.len(), person_id);
// 批量存储用户的会话数据
self.clickhouse_client.insert_events_batch(sessions.clone()).await.unwrap_or_else(|e| {
error!("Error insert_events_batch sessions to ClickHouse: {:?}", e);
});
// 批量提取相关用户的特征信息 并进行存储
let user_feature = self.feature_extractor.extract_features(sessions.clone()).await?;
self.clickhouse_client.update_user_features(user_feature).await.unwrap_or_else(|e| {
error!("Error update_user_features sessions to ClickHouse: {:?}", e);
});;
}
Ok(())
}
}
用户特征提取
基于当前的会话数据,进行用户相关的特征相关信息提取操作
rust
pub struct FeatureExtractor<'a> {
stroage_features: HashMap<String, UserFeatureVector>,
clickhouse_client: &'a ClickhouseClient,
}
impl<'a> FeatureExtractor<'a> {
pub fn new(clickhouse_client: &'a ClickhouseClient) -> Self {
FeatureExtractor {
stroage_features: HashMap::new(),
clickhouse_client,
}
}
pub async fn extract_features(&self,session_list: Vec<SessionEventInfo>) -> AppResult<UserFeatureVector> {
// 获取当前用户的特征信息
let person_code = session_list[0].person_code.clone();
let mut user_feature = match self.stroage_features.get(&person_code) {
Some(features) => features.clone(),
None => {
// 这里的逻辑是 如果没有就去click house中 查询是否包含当前的用户的特征信息 如果有就返回 没有就返回一个空的
match self.clickhouse_client.get_user_features(person_code.clone()).await {
Ok(features) => features,
Err(_) => UserFeatureVector::new(person_code.clone()),
}
}
};
// 进行特征提取
for session in session_list {
user_feature.update_with_session(&session);
}
Ok(user_feature)
}
}
会话模型定义
定义每一条会话的结构模型,存储到clickhouse数据库中,
rust
// 定义基础的会话模型
#[derive(Debug, Serialize, Deserialize, Clone, Row)]
pub struct SessionEventInfo {
pub session_id: String,
pub project_code: String,
pub project_id: String,
pub project_name: String,
pub event_type: DataPointEventType,
pub event_time: i64,
pub screen_width: Option<i32>,
pub screen_height: Option<i32>,
pub viewport_width: Option<i32>,
pub viewport_height: Option<i32>,
pub person_code: String,
pub event_id: String,
pub event_platform: String,
pub event_chrome_version: String,
pub event_safari_version: String,
pub event_lark: String,
pub event_refferer: String,
pub event_url: String,
pub event_url_path: String,
pub event_page_title: String,
pub event_referrer_host: String,
pub event_duration: Option<u64>,
pub usr_device: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum DataPointEventType {
// 页面浏览事件
#[serde(rename = "$pageview")]
Pageview,
// 页面离开事件
#[serde(rename = "$WebPageLeave")]
WebPageLeave,
// 自定义点击事件
#[serde(rename = "countEvent")]
CountEvent,
// 曝光事件
#[serde(rename = "Exposure")]
Exposure,
// 页面点击事件
#[serde(rename = "$WebClick")]
WebClick,
// 未知事件
#[serde(other)]
Unknown,
}
用户特征模型定义
定义用户的特征模型,通过用户的会话信息进行定义,同时根据会话信息不断更新用户的特征模型
rust
#[derive(Debug, Serialize, Deserialize, Clone, Row)]
pub struct UserFeatureVector {
pub user_id: String, // 用户ID 工号
pub timestamp: i64, // 当前时间戳
// 基础特征
pub total_sessions: u64, // 总会话数量 计算公式:该用户下的所有会话数量
pub total_events: u64, // 总事件数量 计算公式:该用户下的所有事件类型去重汇总的数量
pub total_pageviews: u64, // 总页面浏览事件数量 计算公式:该用户下的所有访问页面数量
pub total_clicks: u64, // 总点击事件数量 计算公式:该用户下的所有点击事件数量
pub total_exposures: u64, // 总曝光事件数量 计算公式:该用户下的所有曝光事件数量
pub total_web_page_leaves: u64, // 总网页离开事件数量 计算公式:该用户下的所有网页离开事件数量
pub total_pageview_duration_secs: u64, // 总页面停留时长 计算公式:该用户下的所有页面停留时长
// 频率特征
pub avg_sessions_per_day: f64, // 平均每日会话次数 计算公式:总会话数量 / 总天数
pub avg_session_duration_secs: f64, // 平均会话持续时间 计算公式:总会话持续时间 / 总会话数量
pub avg_events_per_session: f64, // 平均每会话事件数量 计算公式:总事件数量 / 总会话数量
pub avg_pageviews_per_session: f64, // 平均每会话页面浏览事件数量 计算公式:总页面浏览事件数量 / 总会话数量
pub avg_clicks_per_session: f64, // 平均每会话点击事件 计算公式:总点击事件数量 / 总会话数量
pub avg_exposures_per_session: f64, // 平均每会话曝光事件 计算公式:总曝光事件数量 / 总会话数量
pub avg_web_page_leaves_per_session: f64, // 平均每会话网页离开事件 计算公式:总网页离开事件数量 / 总会话数量
pub avg_count_events_per_session: f64, // 平均每会话计数事件 计算公式:总计数事件数量 / 总会话数量
pub avg_session_depth: f64, // 平均会话深度(页面浏览数量) 计算公式:总页面浏览事件数量 / 总会话数量
// 时间模式
pub hours_hist: Vec<(i64, i64)>,
pub preferred_hour: u8, // 用户偏好的活跃时间段(小时)
pub is_night_owl: bool, // 是否为夜猫子用户(晚上活跃)
// 内容偏好
pub path_hist: Vec<(String, i64)>,
pub favorite_page_categories: Vec<String>, // 用户最爱访问的页面类别
pub user_device_hist: Vec<(String, i64)>, // 设置用户设备的访问频率 设备类型
pub user_platform_hist: Vec<(String, i64)>, // 设置用户平台访问频率 (PC/Android/iOS_version, count)
pub favorite_device_types: Vec<String>, // 用户最爱访问的设备类型
pub search_history: Vec<String>, // 用户搜索历史关键词
// 版本偏好
pub preferred_lark_type: String,
pub user_lark_hist: Vec<(String, i64)>, // 用户lark版本访问频率
}
基于用户的会话信息,以及对应的特征计算公式实时的更新用户的特征模型,方便后续的数据分析
实时数据分析
未完待续...
可视化展示
未完待续...
总结
本期的话已经实现了前面几个大块的内容,下一期的话我们就着重在实时数据分析以及数据可视化相关你的内容上了,敬请期待~
过程问题&&解决方案
1. clickhouse 是什么?
ClickHouse 是一个开源的、面向联机分析处理(OLAP)的列式数据库管理系统(DBMS) 。它由俄罗斯的 Yandex 公司开发,主要用于超大规模数据的实时分析查询。
简单来说,它就是一个为高速分析而生的"数据仓库引擎"。它的核心特点是:
- 列式存储:与 MySQL 等按行存储的传统数据库不同,ClickHouse 将数据按列存储。在分析场景下(通常只查询部分列),这能极大地减少磁盘 I/O,提升查询速度。
- 向量化引擎与硬件优化:它利用 CPU 的 SIMD 指令进行并行计算,并针对现代硬件(如 SSD、多核CPU)进行了深度优化。
- 高压缩比:相同类型的数据连续存储,压缩效率非常高,通常可将数据大小压缩到原来的 1/5 到 1/10。
- 强大的实时写入与查询能力:支持高吞吐的数据写入和亚秒级的复杂查询响应,非常适合实时数据分析、监控、BI 报表等场景。
一个形象的比喻 :
如果把 MySQL 比作一个记事本 ,适合频繁地增删改查单条记录;那么 ClickHouse 就像一台高速扫描仪 + 统计机,专为快速扫描、统计海量数据而生。
2. Kafka是什么?
Apache Kafka 是一个开源的分布式流数据平台 ,最初由 LinkedIn 开发。它的核心设计目标是:高吞吐、可水平扩展、持久化、能实时处理海量数据流。
简单来说,Kafka 是一个超高性能的"消息队列 "或"事件流中枢"。它就像一个巨大的、永不丢失的实时数据管道或高速公路,负责在复杂的系统之间可靠地传输海量数据。
核心概念(用现实世界比喻)
- 主题 (Topic) :数据的分类或通道。例如,你可以有
user_click(用户点击)、payment_order(支付订单)等主题。就像报纸的不同版面(体育版、财经版)。 - 生产者 (Producer) :向某个 Topic 发布(写入)数据的应用或服务。就像向报社投稿的记者。
- 消费者 (Consumer) :从某个 Topic 订阅并读取数据的应用或服务。就像阅读报纸的读者。
- 分区 (Partition) :每个 Topic 可以被分成多个 分区 ,用于实现并行处理和水平扩展 。数据会平均分配到不同分区。就像把一份超厚的报纸拆成几叠,分给多个快递员同时派送,速度大大提升。
- Broker :一个 Kafka 服务器就是一个 Broker。多个 Broker 组成一个 Kafka 集群 ,提供高可用性。就像多个邮局分布在不同区域,协同工作。
3. once_cell是什么?
once_cell 让你能够安全、高效且线程安全地初始化并全局访问一个值 ,且保证只初始化一次。
写在最后
我们都有懈怠的天性,唯有时常自我鞭策,才能保持前进的节奏。