网站、app的运营者需要知道自己的产品或服务的运营状况,就需要对使用自己产品的用户进行各种角度的数据分析,比如:
用户数量
新增用户
留存用户
活跃用户
地域分析
渠道分析
…
要做这样的分析,数据来源应该是用户的产品使用的行为日志,行为日志是由app或者网站的页面获取用户相关信息后,发送给后台服务器记录下来的:
从服务器通过flume agent 采集日志,将数据采集到HDFS,生产目录
/applog/2017-09-20/… (512M一个文件)
1.清洗:检查每条日志的必选字段是否完整,不完整的日志应该滤除
2.数据解析成原始:
为每条日志添加一个用户唯一标识字段:user_id
user_id的取值逻辑:
如果是ios设备,user_id=device_id
如果是android设备, user_id = android_id
如果android_id为空,则user_id = device_id
3、将event字段抛弃,将header中的各字段解析成普通文本行
主要技术点:json解析 gson/fastjson/jackson/…
4.还有一个变态需求:
需要将清洗后的结果数据,分ios和android和其他 三种类别,输出到3个不同的文件夹;
/app_clean_log/2017-09-20/ios/part-r-00000
/ios/part-r-00000
/ios/part-r-00001
/android/part-r-00000
/android/part-r-00001
/other/part-r-00000
/other/part-r-00001
百度:multipleOutputs
MapReduce代码
public class AppLogDataClean {
public static class AppLogDataCleanMapper extends Mapper<LongWritable, Text, Text, NullWritable> {
//在这里提前声明需要的东西,不需要map()方法里面反复声明浪费资源,但是不可以传入外部参数
Text k = null;
NullWritable v = null;
SimpleDateFormat sdf = null;
MultipleOutputs<Text,NullWritable> mos = null; //多路输出器
@Override
protected void setup(Mapper<LongWritable, Text, Text, NullWritable>.Context context)
throws IOException, InterruptedException {
//在这里提前声明需要的东西,不需要map()方法里面反复声明浪费资源,在setup()方法中可以传入外部参数
k = new Text();
v = NullWritable.get();
sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
mos = new MultipleOutputs<Text,NullWritable>(context);
}
@Override
protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, Text, NullWritable>.Context context)
throws IOException, InterruptedException {
JSONObject jsonObj = JSON.parseObject(value.toString()); //将value变成一个jasonObject,可理解为一个hashmap
JSONObject headerObj = jsonObj.getJSONObject(GlobalConstants.HEADER); //首先先get header这个key对应的value,作为jsonObject
//GlobalConstants.HEADER意思是将这个参数改成一个常量类
/**
* 过滤缺失必选字段的记录
* 1、检查每条日志的必选字段是否完整,不完整的日志应该滤除
*/
if (StringUtils.isBlank(headerObj.getString("sdk_ver"))) {
return; //如果根据"sdk_ver"这个key get到的value为null或者空字符串“”,那么什么都不返回
}
if (null == headerObj.getString("time_zone") || "".equals(headerObj.getString("time_zone").trim())) {
return;
}
if (null == headerObj.getString("commit_id") || "".equals(headerObj.getString("commit_id").trim())) {
return;
}
if (null == headerObj.getString("commit_time") || "".equals(headerObj.getString("commit_time").trim())) {
return;
}else{
// 练习时追加的逻辑,替换掉原始数据中的时间戳
String commit_time = headerObj.getString("commit_time");
String format = sdf.format(new Date(Long.parseLong(commit_time)+38*24*60*60*1000L));
headerObj.put("commit_time", format);
}
if (null == headerObj.getString("pid") || "".equals(headerObj.getString("pid").trim())) {
return;
}
if (null == headerObj.getString("app_token") || "".equals(headerObj.getString("app_token").trim())) {
return;
}
if (null == headerObj.getString("app_id") || "".equals(headerObj.getString("app_id").trim())) {
return;
}
if (null == headerObj.getString("device_id") || headerObj.getString("device_id").length()<17) {
return;
}
if (null == headerObj.getString("device_id_type")
|| "".equals(headerObj.getString("device_id_type").trim())) {
return;
}
if (null == headerObj.getString("release_channel")
|| "".equals(headerObj.getString("release_channel").trim())) {
return;
}
if (null == headerObj.getString("app_ver_name") || "".equals(headerObj.getString("app_ver_name").trim())) {
return;
}
if (null == headerObj.getString("app_ver_code") || "".equals(headerObj.getString("app_ver_code").trim())) {
return;
}
if (null == headerObj.getString("os_name") || "".equals(headerObj.getString("os_name").trim())) {
return;
}
if (null == headerObj.getString("os_ver") || "".equals(headerObj.getString("os_ver").trim())) {
return;
}
if (null == headerObj.getString("language") || "".equals(headerObj.getString("language").trim())) {
return;
}
if (null == headerObj.getString("country") || "".equals(headerObj.getString("country").trim())) {
return;
}
if (null == headerObj.getString("manufacture") || "".equals(headerObj.getString("manufacture").trim())) {
return;
}
if (null == headerObj.getString("device_model") || "".equals(headerObj.getString("device_model").trim())) {
return;
}
if (null == headerObj.getString("resolution") || "".equals(headerObj.getString("resolution").trim())) {
return;
}
if (null == headerObj.getString("net_type") || "".equals(headerObj.getString("net_type").trim())) {
return;
}
/**
* 生成user_id
* 为每条日志添加一个用户唯一标识字段:user_id
*user_id的取值逻辑:
*如果是ios设备,user_id=device_id
*如果是android设备, user_id = android_id
如果android_id为空,则user_id = device_id
*/
String user_id = "";
if ("android".equals(headerObj.getString("os_name").trim())) {
user_id = StringUtils.isNotBlank(headerObj.getString("android_id")) ? headerObj.getString("android_id")
: headerObj.getString("device_id");
} else {
user_id = headerObj.getString("device_id");
}
/**
* 输出结果
* JsonToStringUtil.toString()是工具类,把一条记录的所有value拼接起来,把jsonObject 转化为String,然后输出
* 需要将清洗后的结果数据,分ios和android和其他 三种类别,输出到3个不同的文件夹;
*/
headerObj.put("user_id", user_id);
k.set(JsonToStringUtil.toString(headerObj));
if("android".equals(headerObj.getString("os_name"))){
mos.write(k, v, "android/android"); //第三个参数是输出路径
}else{
mos.write(k, v, "ios/ios");
}
}
@Override
protected void cleanup(Mapper<LongWritable, Text, Text, NullWritable>.Context context)
throws IOException, InterruptedException {
mos.close();
}
}
public static void main(String[] args) throws Exception {
Configuration conf = new Configuration();
Job job = Job.getInstance(conf);
job.setJarByClass(AppLogDataClean.class);
job.setMapperClass(AppLogDataCleanMapper.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(NullWritable.class);
job.setNumReduceTasks(0);
// 避免生成默认的part-m-00000等文件,因为,数据已经交给MultipleOutputs输出了
LazyOutputFormat.setOutputFormatClass(job, TextOutputFormat.class);
FileInputFormat.setInputPaths(job, new Path(args[0]));
FileOutputFormat.setOutputPath(job, new Path(args[1]));
boolean res = job.waitForCompletion(true);
System.exit(res ? 0 : 1);
}
}
JsonToStringUtil工具类
package cn.edu360.app.log.mr;
import com.alibaba.fastjson.JSONObject;
public class JsonToStringUtil {
public static String toString(JSONObject jsonObj) {
StringBuilder sb = new StringBuilder();
//然后将所有value拼接成一个长String串,转化为String数据,
sb.append(jsonObj.get("sdk_ver")).append("\001").append(jsonObj.get("time_zone")).append("\001")
.append(jsonObj.get("commit_id")).append("\001").append(jsonObj.get("commit_time")).append("\001")
.append(jsonObj.get("pid")).append("\001").append(jsonObj.get("app_token")).append("\001")
.append(jsonObj.get("app_id")).append("\001").append(jsonObj.get("device_id")).append("\001")
.append(jsonObj.get("device_id_type")).append("\001").append(jsonObj.get("release_channel"))
.append("\001").append(jsonObj.get("app_ver_name")).append("\001").append(jsonObj.get("app_ver_code"))
.append("\001").append(jsonObj.get("os_name")).append("\001").append(jsonObj.get("os_ver"))
.append("\001").append(jsonObj.get("language")).append("\001").append(jsonObj.get("country"))
.append("\001").append(jsonObj.get("manufacture")).append("\001").append(jsonObj.get("device_model"))
.append("\001").append(jsonObj.get("resolution")).append("\001").append(jsonObj.get("net_type"))
.append("\001").append(jsonObj.get("account")).append("\001").append(jsonObj.get("app_device_id"))
.append("\001").append(jsonObj.get("mac")).append("\001").append(jsonObj.get("android_id"))
.append("\001").append(jsonObj.get("imei")).append("\001").append(jsonObj.get("cid_sn")).append("\001")
.append(jsonObj.get("build_num")).append("\001").append(jsonObj.get("mobile_data_type")).append("\001")
.append(jsonObj.get("promotion_channel")).append("\001").append(jsonObj.get("carrier")).append("\001")
.append(jsonObj.get("city")).append("\001").append(jsonObj.get("user_id"));
return sb.toString();
}
}
功能:每日00:20启动预处理程序(MapRedcue),对数据进行解析,处理数据## 3. 数据入库
在同一hdfs,等预处理过程结束,将预处理之后的数据,导入hive中的事先建 好的表(外部表),导入当日分区
提示:
1、 在hive中建表ods_app_log(外部表、分区表)映射预处理阶段生成的清洗数据
2、 每天定时将预处理之后的天数据 导入到 ods_app_log的天分区 中
把当天的活跃用户信息抽取出来,存入一个日活用户信息表
CREATE TABLE etl_user_active_day (
sdk_ver string
,time_zone string
,commit_id string
,commit_time string
,pid string
,app_token string
,app_id string
,device_id string
,device_id_type string
,release_channel string
,app_ver_name string
,app_ver_code string
,os_name string
,os_ver string
,language string
,country string
,manufacture string
,device_model string
,resolution string
,net_type string
,account string
,app_device_id string
,mac string
,android_id string
,imei string
,cid_sn string
,build_num string
,mobile_data_type string
,promotion_channel string
,carrier string
,city string
,user_id string
) partitioned BY (day string) row format delimited fields terminated BY '\001';
:
概念:某一天使用过app的用户就是活跃用户
计算过程:
源表——ods_app_log
目标表:日活表
create table etl_user_active_day like ods_app_log;
注意:
每个活跃用户抽取他当天所有记录中时间最早的一条(用分组排序的语法)
从ods_app_log原始数据表的当天分区中,抽取当日的日活用户信息插入日活用户信息表etl_user_active_day
代码实现:
INSERT INTO TABLE etl_user_active_day PARTITION (day = '2017-09-21',)
SELECT sdk_ver
,time_zone
,commit_id
,commit_time
,pid
,app_token
,app_id
,device_id
,device_id_type
,release_channel
,app_ver_name
,app_ver_code
,os_name
,os_ver
,LANGUAGE
,country
,manufacture
,device_model
,resolution
,net_type
,account
,app_device_id
,mac
,android_id
,imei
,cid_sn
,build_num
,mobile_data_type
,promotion_channel
,carrier
,city
,user_id
FROM (
SELECT *
,row_number() OVER (PARTITION BY user_id ORDER BY commit_time) AS rn --分组加排序
FROM ods_app_log WHERE day = '2017-09-21') tmp
WHERE rn = 1;
根据四个重要指标,以不同维度统计活跃用户总表
–各维度组合分析:
不区分操作系统os_name 不区分城市city 不区分渠道release_channel 不区分版本app_ver_name 活跃用户
区分操作系统os_name 不区分城市city 不区分渠道release_channel 不区分版本app_ver_name 活跃用户
不区分操作系统os_name 区分城市city 不区分渠道release_channel 不区分版本app_ver_name 活跃用户
不区分操作系统os_name 不区分城市city 区分渠道release_channel 不区分版本app_ver_name 活跃用户
不区分操作系统os_name 不区分城市city 不区分渠道release_channel 区分版本app_ver_name 活跃用户
区分操作系统os_name 区分城市city 不区分渠道release_channel 不区分版本app_ver_name 活跃用户
…
像这样
维度组合统计
0 0 0 0
0 0 0 1
0 0 1 0
0 0 1 1
0 1 0 0
0 1 0 1
0 1 1 0
0 1 1 1
1 0 0 0
1 0 0 1
1 0 1 0
1 0 1 1
1 1 0 0
1 1 0 1
1 1 1 0
1 1 1 1
注意:
根据dim和day分区,表示以什么为组合和时间
统计不区分操作系统os_name 不区分城市city 不区分渠道release_channel 不区分版本app_ver_name活跃用户 这个维度的日活表
-- 1 日新维度统计报表--数据建模 create table dim_user_new_day(os_name string,city string,release_channel string,app_ver_name string,cnts
int) partitioned by (day string, dim string);
-- 2 日新维度统计报表sql开发(利用多重插入语法) from etl_user_new_day
insert into table dim_user_new_day
partition(day='2017-09-21',dim='0000') select
'all','all','all','all',count(1) -- where day='2017-09-21'
不区分操作系统os_name 不区分城市city 不区分渠道release_channel 区分版本app_ver_name 活跃用户 这个维度的日活表
partition(day='2017-09-21',dim='0001') select
'all','all','all',app_ver_name,count(1) where day='2017-09-21'
group by app_ver_name
不区分操作系统os_name 区分城市city 不区分渠道release_channel 区分版本app_ver_name 活跃用户 这个维度的日活表
partition(day='2017-09-21',dim='0001') select
'all','all',city,app_ver_name,count(1) where day='2017-09-21'
group by app_ver_name,city
新增用户的定义:
比如,在2017-08-28日出现了一些以前从没出现过的用户,则这些用户就是2017-08-28日的新增用户
需求:
1、将每日的新增用户从ods_app_log表中抽取出来,存入一个新用户信息表:
dw_new_user_day的日分区中
2、统计如下报表:
某日 城市 渠道 版本 新增用户数
2017-08-28 all all all ?
2017-08-28 具体城市 all all ?
2017-08-28 all 具体渠道 all ?
2017-08-28 all all 具体版本 ?
2017-08-28 all 具体渠道 具体版本 ?
2017-08-28 具体城市 all 具体版本 ?
2017-08-28 具体城市 具体渠道 all ?
2017-08-28 具体城市 具体渠道 具体版本 ?
整体思路:
a、应该建立一个历史用户表(只存user_id,第一天全部存入)
b、将当日的活跃用户去 比对 历史用户表, 就知道哪些人是今天新出现的用户 --> 当日新增用户(用联结的方式去对比,如果今日的用户表userID没有,那就是新用户)
c、将当日新增用户追加到历史用户表
:
-- 1 历史用户表
create table etl_user_history(user_id string); --(只存user_id)
-- 2 当日新增用户表:存所有字段(每个人时间最早的一条),带有一个分区字段:day string;
create table etl_user_new_day like etl_user_active_day;
-- 统计实现 *********************************
-- 1 当日活跃-历史用户表 --> 新增用户表的当日分区
insert into etl_user_new_day partition(day='2017-09-21')
SELECT sdk_ver
,time_zone
,commit_id
,commit_time
,pid
,app_token
,app_id
,device_id
,device_id_type
,release_channel
,app_ver_name
,app_ver_code
,os_name
,os_ver
,LANGUAGE
,country
,manufacture
,device_model
,resolution
,net_type
,account
,app_device_id
,mac
,android_id
,imei
,cid_sn
,build_num
,mobile_data_type
,promotion_channel
,carrier
,city
,a.user_id
from etl_user_active_day a left join etl_user_history b on a.user_id = b.user_id
where a.day='2017-09-21' and b.user_id is null;
-- 2 将当日新增用户的user_id追加到历史表
insert into table etl_user_history
select user_id from etl_user_new_day where day='2017-09-21';
from etl_user_new_day
insert into table dim_user_new_day partition(day='2017-09-21',dim='0000')
select 'all','all','all','all',count(1)
where day='2017-09-21'
insert into table dim_user_new_day partition(day='2017-09-21',dim='0001')
select 'all','all','all',app_ver_name,count(1)
where day='2017-09-21'
group by app_ver_name
insert into table dim_user_new_day partition(day='2017-09-21',dim='0010')
select 'all','all',release_channel,'all',count(1)
where day='2017-09-21'
group by release_channel
insert into table dim_user_new_day partition(day='2017-09-21',dim='0011')
select 'all','all',release_channel,app_ver_name,count(1)
where day='2017-09-21'
group by release_channel,app_ver_name
insert into table dim_user_new_day partition(day='2017-09-21',dim='0100')
select 'all',city,'all','all',count(1)
where day='2017-09-21'
group by city
概念:
比如,15号的新增用户,在16号又活跃了,这些用户就是次日留存用户;
比如,12号的新增用户,在15号又活跃了,这些用户就是3日留存用户;
需求:
——ETL:先抽取出次日留存用户,存入一个次日留存用户信息表,记录跟活跃用户表相同的字段;
——维度分析:统计各种维度下的留存用户数、留存用户比例
逻辑思路:昨天在新用户表中,今天在活跃用户表中 --> 今日的“次日留存用户”
12号在新增用户中,在15号在活跃用户表,这些用户就是3日留存用户;
那么将用id将两表内联结,两表都存在的话,那么就是留存用户(用左半联结效率略高 吧)
内联结:
-- 数据建模
--建次日留存etl信息表:记录跟活跃用户表相同的字段
create table etl_user_keepalive_nextday like etl_user_active_day;
-- etl开发
insert into table etl_user_keepalive_nextday partition(day='2017-09-22')
select
actuser.sdk_ver
,actuser.time_zone
,actuser.commit_id
,actuser.commit_time
,actuser.pid
,actuser.app_token
,actuser.app_id
,actuser.device_id
,actuser.device_id_type
,actuser.release_channel
,actuser.app_ver_name
,actuser.app_ver_code
,actuser.os_name
,actuser.os_ver
,actuser.language
,actuser.country
,actuser.manufacture
,actuser.device_model
,actuser.resolution
,actuser.net_type
,actuser.account
,actuser.app_device_id
,actuser.mac
,actuser.android_id
,actuser.imei
,actuser.cid_sn
,actuser.build_num
,actuser.mobile_data_type
,actuser.promotion_channel
,actuser.carrier
,actuser.city
,actuser.user_id
from etl_user_new_day newuser join etl_user_active_day actuser
on newuser.user_id = actuser.user_id
where newuser.day='2017-09-21' and actuser.day='2017-09-22';
用左半联结的方法实现(用左半连接效率略高):
insert into table etl_user_keepalive_nextday partition(day='2017-09-22')
select
sdk_ver
,time_zone
,commit_id
,commit_time
,pid
,app_token
,app_id
,device_id
,device_id_type
,release_channel
,app_ver_name
,app_ver_code
,os_name
,os_ver
,language
,country
,manufacture
,device_model
,resolution
,net_type
,account
,app_device_id
,mac
,android_id
,imei
,cid_sn
,build_num
,mobile_data_type
,promotion_channel
,carrier
,city
,user_id
from etl_user_new_day a left semi join etl_user_active_day b
on a.user_id = b.user_id and a.day='2017-09-21' and b.day='2017-09-22';
where a.day='2017-09-21' and b.day='2017-09-22'; // 注意:left semi join中,右表的引用不能出现在where条件中
概念:创建用户后,一段时间内(连续7天)没有使用过app的用户
思路:
比如,现在运算的是20号的报表,
用13号的新增用户 left join 活跃用户表的(14-20号分区)
取右表join后结果为null的用户
-- 1 创建沉默用户表
create table silent_user_7_day like etl_user_active_day;
-- 统计实现 *********************************
-- 2 7日活跃-历史用户表 -->沉默用户表的当日分区
insert into silent_user_7_day partition(day='2017-09-21')
SELECT sdk_ver
,time_zone
,commit_id
,commit_time
,pid
,app_token
,app_id
,device_id
,device_id_type
,release_channel
,app_ver_name
,app_ver_code
,os_name
,os_ver
,LANGUAGE
,country
,manufacture
,device_model
,resolution
,net_type
,account
,cid_sn
,build_num
,mobile_data_type
,promotion_channel
,carrier
,city
,a.user_id
from eetl_user_new_day a left join etl_user_history b on a.user_id = b.user_id
where b.day='2017-09-21' and b.day='2017-09-20' b.day='2017-09-19' and b.day='2017-09-18' and b.day='2017-09-17' and b.day='2017-09-16' and b.day='2017-09-15' and b.user_id is null;
需求:每天统计出如下报表
日期 user_id app_token channel city source_ver curr_ver
示例:
2017-08-14,许老师,共享女友,360应用,北京,v1.0
2017-08-14,赵老师,共享女友,安智市场,北京,v1.2
2017-08-14,许老师,共享女友,360应用,天津,v1.2
2017-08-14,许老师,共享女友,小米应用,天津,v2.0
2017-08-15,许老师,共享女友,360应用,北京,v2.0
2017-08-15,赵老师,共享女友,安智市场,北京,v1.2
2017-08-15,赵老师,共享女友,安智市场,北京,v1.5
变为一下格式,表示用户今日版本升级的过程
2017-08-14 许老师 共享女友 360应用 天津 v1.0 v1.2
2017-08-14 许老师 共享女友 小米应用 天津 v1.2 v2.0
思路:
将最左的字段版本信息新生成一行相同的但是向下移动一位,然后将左边版本号小于右边的提取出来。可用窗口分析函数实现:lag(app_ver_name,1,null) over(partition by user_id order by app_ver_name)
lag(参数一:要移动的字段,参数二:下移的行数,参数三:空缺的用啥填充) over((partition by 参数四:组内根据什么排序 order by 参数五:按什么分组)
解决方案:
–创建表格,输入数据
create table t_lag_test(day string,user_id string,app_token string,release_channel string,city string,app_ver_name string)
row format delimited fields terminated by ‘,’;
load data local inpath ‘/root/hivetest/ver.test’ into table t_lag_test;
– 数据统计
select
day,user_id,app_token,release_channel,city,ver_2,app_ver_name
from
(
select
day,user_id,app_token,release_channel,city,app_ver_name,
lag(app_ver_name,1,null) over(partition by user_id order by app_ver_name) as ver_2
from t_lag_test) tmp
where ver_2 is not null and app_ver_name>ver_2
;
更多窗口分析函数使用方法,点这里看更多窗口分析函数
将app数据仓库中的 日新用户维度统计报表:dim_user_new_day 导出到mysql的表中去
– 1 在mysql中建库建表
create database app;
create table dim_user_new_day(
os_name varchar(20),city varchar(20),release_channel varchar(20),app_ver_name varchar(20),cnts int,dt varchar(20)
);
–注意:将库和表的编码集改成utf8,命令如下:
修改库的编码:
mysql> alter database db_name character set utf8;
修改表的编码:
mysql> ALTER TABLE table_name CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci;
– 2 用sqoop将hive中的 dim_user_new_day 中的指定日分区的数据导出到mysql 的
dim_user_new_day
#!/bin/bash
day=`date -d '-1 day' +'%Y-%m-%d'`
/root/apps/sqoop/bin/sqoop export \
--connect "jdbc:mysql://hdp-04:3306/app?useUnicode=true&characterEncoding=utf-8" \
--username root \
--password root \
--input-fields-terminated-by '\001' \
--table dim_user_new_day \
--export-dir /user/hive/warehouse/app.db/dim_user_new_day_1p/day=${day} /