一 背景需求
我们做的是一个有上千万用户,三四百万日活的app大数据项目,此项目主要解决营销分析断层和产品迭代无法量化和用户运营不精准和全局运营指标监控不实时的问题,为公司运营解决了粗放人力管理的弊端,实现了项目运营的数据化。
二 大数据项目的需求
大数据项目需求分为流量域分析,业务域分析,画像域分析。
三 流量域分析
1.基础数据分析
(1)整体概况
(2)用户获取
分为渠道访问和APP数据
(3)活跃和留存
分为访问流量和用户留存
(4)事件转化
分为各类关键事件转化和收益类事件转化
(5)用户特征
2.进阶用户行为分析
(1)漏斗分析
(2)留存分析
(3)分布分析
(4)归因分析
(5)用户路径分析
(6)间隔分析
(7)自定义查询
四 业务域分析
(1)交易域
分为购物车分析和订单GMV分析和复购分析
(2)营销域
分为优惠券分析和团购分析和秒杀限时购分析和其他运营活动
(3)运营活动域
分为广告运营位分析和拉新注册分析
(4)会员域
五 画像域
分析得出大数据主要用来构建用户画像,这一部分需要用到数据建模的思想,我们这个项目中用的是经典的维度建模中的星座建模。
六 项目具体实现-流量域
此大数据的数仓项目首先要进行技术选型,经过多方比对我们选择了以下组件来实现:数据采集:Flume;存储平台:HDFS;基础设施:Hive;运算引擎:SPARK SQL ;资源调度:YARN;任务调度:AZKABAN;元数据管理:Atlas。
(1)首先,我们要采集海量的数据来做项目支撑,数据主要来源于用户行为日志和业务数据及历史数据,此部分数据主要来源于Java前段页面埋点来获得日志数据。
(2)其次,我们要利用kafka这个组件来做日志数据的缓存,当然在服务器上要先启动zookeeper,在服务器上运行kafka组件来将前端页面中的数据采集到kafka中,代码实现手首先为:查看kafka中topic: bin/kafka-topics.sh --list --zookeeper linux01:2181;创建kafka中所需的topic:bin/kafka-topics.sh --create --topic app_log --replication-factor 2 --partitions 2 --zookeeper linux01:2181;为确保数据确实进入kafka可以在组件上启动一个命令行消费者来监视这个topic:bin/kafka-console-consumer.sh --topic app_log --bootstrap-server linux01:9092,linux02:9092,linux03:9092 --from-beginning。
(3)其次,在数仓项目中需要分层。首先我们要创建ODS层即操作数据层,对应的是原始数据etl到数仓中的表,此ods层的表存储在hive中,ods层中原数据为普通文本文件数据,为将这一数据转化为json格式数据需要一个外部jar包为“org.openx.data.jsonserde.JsonSerDe”,我们要用flume这个采集工具采集kafka中的json格式的日志数据到HDFS中,此数据的表结构存放于hive中所以在导入数据前我们需要先在hive中创建一个表来存储数据结构,代码实现如下:create database ods;
drop table ods.app_event_log;
create external table ods.app_event_log
(
account string,
appId string,
appVersion string,
carrier string,
deviceId string,
deviceType string,
eventId string,
ip string,
latitude double,
longitude double,
netType string,
osName string,
osVersion string,
properties map<string,string>,
releaseChannel string,
resolution string,
sessionId string,
`timeStamp` bigint
)
partitioned by (y string,m string,d string)
row format serde 'org.openx.data.jsonserde.JsonSerDe'
stored as textfile
;
由于我们的数据在HDFS上为导入数据到hive上则有以下代码实现:load data inpath ‘/eventlog/app_event/20200730’ into table ods.app_event_log partition (y=‘2020’,m=‘07’,d=‘30’);
(4)在ODS层后我们要创建DWD层,DWD层为对ODS层数据etl处理后的扁平化明细数据,DWD层用不完全星型模型思想建模,此层的创建需要当天的事件日志和前一日与今日的idmapping绑定表和geohash地理位置表和IP地理位置表,为准备数据我们需要先准备和生成3个字典:geo位置字典,IP位置字典,设备guid绑定字典。为完成这些首先需要准备一个在mysql中的GPS位置字典,这个MySQL中要有一张表:t_md_areas,再将这张表变为扁平结构的表,代码实现如下:
create table geo_tmp as
SELECT
a.BD09_LNG,
a.BD09_LAT,
a.AREANAME as district,
b.AREANAME as city,
c.AREANAME as province
from t_md_areas a join t_md_areas b on a.`LEVEL`=3 and a.PARENTID = b.ID
join t_md_areas c on b.PARENTID = c.ID
其次,将上一步的GPS位置字典转换为geohash位置字典并存入hive,实现如下:运行项目中的程序
cn.doitedu.dwetl.area.GeoDictGen:
object GeoDictGen {
def main(args: Array[String]): Unit = {
val spark = SparkUtil.getSparkSession(GeoDictGen.getClass.getSimpleName)
import spark.implicits._
// 加载mysql中的gps坐标地理位置字典
val props = new Properties()
props.setProperty("user","root")
props.setProperty("password","123456")
val tmp = spark.read.jdbc("jdbc:mysql://localhost:3306/realtimedw", "geo_tmp", props)
tmp.show(10,false)
// 将gps坐标转换成geohash编码
val res: DataFrame = tmp.map(row=>{
// 取出字段:|BD09_LAT |BD09_LNG |province|city |district|
val lat = row.getAs[Double]("BD09_LAT")
val lng = row.getAs[Double]("BD09_LNG")
val province = row.getAs[String]("province")
val city = row.getAs[String]("city")
val district = row.getAs[String]("district")
// 调用geohash工具包,传入经纬度,返回geohash码
val geo = GeoHash.geoHashStringWithCharacterPrecision(lat, lng, 5)
// 组装行结果返回
(geo,province,city,district)
}).toDF("geo","province","city","district")
// 将结果写入hive的表 dim.geo_dict
res.write.saveAsTable("dim.geo_dict")
spark.close()
}
}
注意修改源MySQL的地址.用户密码.库名.表名,同时注意项目中要放入自己的配置文件core-site.xml,hive-site.xml。再次,要准备IP位置字典库,库名字为ip2region.db,放置位置为hdfs中的目录: /ip2region/ 下,IP查找可以用二分查找法,IP地理位置处理工具包为ip2region(含ip数据库),地址为https://gitee.com/lionsoul/ip2region。最后要进行device设备&guid绑定字典开发(每天都要运行),具体实现如下:准备一个初始的绑定字典(文件 a.txt),内容为: {“deviceid”:"",“guid”:"",lst:[]}; 放在hdfs中的:/idmp/bindtable/2020-07-29/ 下;
运行程序,来生成2020-07-30号的绑定表结果:cn.doitedu.dwetl.idmp.AppIdBInd
代码如下:
case class BindScore(account: String, timestamp: Long, score: Int)
object AppIdBInd {
def main(args: Array[String]): Unit = {
var pre_bind_dt = ""
var cur_bind_dt = ""
var log_y = ""
var log_m = ""
var log_d = ""
try {
cur_bind_dt = args(0)
pre_bind_dt = args(1)
log_y = cur_bind_dt.split("-")(0)
log_m = cur_bind_dt.split("-")(1)
log_d = cur_bind_dt.split("-")(2)
} catch {
case e: Exception => println(
"""
|
|Usage:
|参数1:要处理的数据的日期,如:2020-07-31
|参数2:前一个参照绑定表的日期,如:2020-07-30
|参数3:程序要运行的模式:local|yarn
|
|""".stripMargin)
sys.exit(1)
}
val spark = SparkUtil.getSparkSession("APP设备id账号id绑定",args(2))
import spark.implicits._
import org.apache.spark.sql.functions._
// 1. 加载 T日的日志
val isBlank = udf((s: String) => {
if (StringUtils.isBlank(s)) null else s
})
val tLog = spark.read.table("ods.app_event_log")
.where(s"y=${log_y} and m=${log_m} and d=${log_d}")
.select(isBlank('account) as "account", $"deviceid", $"timestamp")
// 2. 对当天日志中的(设备,账号)组合,进行去重(只取时间最早的一条)
val window = Window.partitionBy('account, 'deviceid).orderBy('timestamp)
// 这里用了sparksql,而且里面有shuffle,并行度就会变成默认的 200
// --conf spark.sql.shuffle.partitions
val pairs = tLog.select('account, 'deviceid, 'timestamp, row_number() over (window) as "rn")
.where("rn=1")
.select('account, 'deviceid, 'timestamp)
pairs.coalesce(2)
// 3. 同一个设备上,对不同账号打不同的分(登录时间越早,分数越高)
// 3.1 先将相同设备的数据,分组
val scoredRdd = pairs.rdd.map(row => {
val account = row.getAs[String]("account")
val deviceid = row.getAs[String]("deviceid")
val timestamp = row.getAs[Long]("timestamp")
(account, deviceid, timestamp)
}).groupBy(_._2).map(tp => {
val deviceid = tp._1
// 3.2 按时间先后顺序打分
val lst = tp._2.toList.filter(_._1 != null).sortBy(_._3)
val scoreLst: immutable.Seq[BindScore] = for (i <- 0 until lst.size) yield BindScore(lst(i)._1, lst(i)._3, 100 - 10 * i)
// (设备号,登录过的所有账号及分数)
(deviceid, scoreLst.toList)
})
// 4. 加载 T-1日 在绑定记录表 假数据: {"deviceid":"","lst":[],"guid":""}
val bindTable = spark.read.textFile(s"/idmp/bindtable/${pre_bind_dt}")
bindTable.coalesce(1)
val bindTableRdd = bindTable.rdd.map(line => {
val obj = JSON.parseObject(line)
val deviceid = obj.getString("deviceid")
val guid = obj.getString("guid")
val lstArray = obj.getJSONArray("lst")
val lst = new ListBuffer[BindScore]()
for (i <- 0 until lstArray.size()) {
val bindObj = lstArray.getJSONObject(i)
val bindScore = BindScore(bindObj.getString("account"), bindObj.getLong("timestamp"), bindObj.getIntValue("score"))
lst += bindScore
}
(deviceid, (lst.toList, guid))
})
/**
* (d0,(List(BindScore(u0,7,100), BindScore(u1,8,20)),u0))
* (d1,(List(BindScore(u1,9,100)),u1))
* (d2,(List(BindScore(u2,8,100)),u2))
* (d3,(List(BindScore(u2,9,90)),u2))
* (d4,(List(),d4))
* (d5,(List(),d5))
*/
val joined = scoredRdd.fullOuterJoin(bindTableRdd)
/**
* (d0,(Some(List()),Some((List(BindScore(u0,7,100), BindScore(u1,8,20)),u0))))
* (d1,(Some(List(BindScore(u1,11,100), BindScore(u2,13,90))),Some((List(BindScore(u1,9,100)),u1))))
* (d2,(Some(List(BindScore(u2,14,100))),Some((List(BindScore(u2,8,100)),u2))))
* (d3,(Some(List(BindScore(u3,14,100))),Some((List(BindScore(u2,9,90)),u2))))
* (d4,(Some(List(BindScore(u4,15,100))),Some((List(),d4))))
* (d5,(Some(List()),Some((List(),d5))))
* (d8,(Some(List()),None))
* (d9,(Some(List(BindScore(u4,18,100))),None))
* (d6,(None,Some(List(BindScore(u4,18,100))))
*/
val result = joined.map(tp => {
val deviceid = tp._1
val left: Option[List[BindScore]] = tp._2._1
val right: Option[(List[BindScore], String)] = tp._2._2
// 事先定义好要返回的几个变量 (deviceid,lst,guid)
var resLst = List.empty[BindScore]
var resGuid: String = ""
// 分情况,处理合并
// 情况1: 右表(历史)根本没有这个设备
if (right.isEmpty) {
resLst = left.get
if (resLst.size < 1) resGuid = deviceid else resGuid = getGuid(resLst)
}
// 情况2:左表(今日)根本没有这个设备
// lst和guid,都保留历史的
if (left.isEmpty) {
resGuid = right.get._2
resLst = right.get._1
}
// 情况3: 左右表都有some,需要对两边的lst进行分数合并
if (left.isDefined && right.isDefined) {
val lst1 = left.get
val lst2 = right.get._1
// 判断两边的list是否都是空list
if (lst1.size < 1 && lst2.size < 1) {
resGuid = deviceid
} else {
// 合并分数
resLst = mergeScoreList(lst1, lst2)
resGuid = getGuid(resLst)
}
}
// 返回最后的结果(设备id,登录账号分数记录列表,guid)
(deviceid, resLst, resGuid)
})
val jsonResult = result.map(tp => {
val deviceid = tp._1
val lst: List[BindScore] = tp._2
val guid = tp._3
val scores: util.ArrayList[AccountScore] = new util.ArrayList[AccountScore]()
for (elem <- lst) {
val as = new AccountScore(elem.account, elem.timestamp, elem.score)
scores.add(as)
}
val gb = new GuidBinBean(deviceid, guid, scores)
val gson = new Gson()
gson.toJson(gb)
})
jsonResult.coalesce(1).saveAsTextFile(s"/idmp/bindtable/${cur_bind_dt}")
spark.close()
}
def getGuid(lst: List[BindScore]): String = {
val sorted = lst.sortBy(b => (-b.score, b.timestamp))
sorted(0).account
}
def mergeScoreList(lst1: List[BindScore