一、项目背景
对《中国好声音》、《快乐男声》、《最美和声》、《中国梦之声》等各种音乐选节目收视率的一个调查。依托北330万高清交互数字电视双向用户,从中随机抽取25000户作为样本进行统计。
二、项目需求
这里展示从节目的维度,统计每个节目的平均收视人数、平均到达人数、收视率、到达率和市场份额。我们根据每天抽样用户的收视数据,统计出每个节目按天、按小时、按分钟的上述5个收视指标。
三、 系统功能(这里以一个维度为例)
主要包括收视概况浏览、收视率走势分析、收视指标对比、收视数据对比查看。
五、 收视指标定义
收视人数
总的:某天收视人数(S11):sum(distinct stbnum) WHERE 指定日期。
频道:某天收视人数(S21):sum(distinct stbnum) WHERE 指定日期 AND 指定频道。
节目(这1天内收看此节目的人数):某天收视人数(S31):sum(distinct stbnum) WHERE 指定日期 AND 指定节目。
平均收视人数
该指标为在选定期间内平均每分钟的用户ID数。
总的:
每分钟(X11): sum(distinct stbnum)
每分钟(X12):……
每分钟(X1n):……
平均收视人数:(X11 + X12 + … + X1n)/n
频道:
每分钟(X21): sum(distinct stbnum) where 指定频道名称 = 频道名
每分钟(X22):……
每分钟(X2n):……
平均收视人数:(X21 + X22 + … + X2n)/n
节目:
每分钟(X31): sum(distinct stbnum) where 指定节目名称 = 节目名
每分钟(X32):……
每分钟(X3n):……
平均收视人数:(X31 + X32 + … + X3n)/n
收视率
平均收视人数/系统总ID数。
CONSTANT系统总ID数 IDNUM = sum(distinct stbnum)。
总的,频道,节目:
每分钟收视率Y1:X1/IDNUM;
每分钟收视率Y2:……
每分钟收视率Yn:Xn/IDNUM
某一段时间的收视率:(Y1 + Y2… + Yn)/n
市场份额
对应频道平均收视人数/所有频道平均收视人数。
总体:
100%
频道:
每分钟(Z21):X21/ sum(distinct stbnum) where 时间
……
每分钟(Z2n):X2n/ sum(distinct stbnum) where 时间
市场份额:(Z21 + … + Z2n)/n
节目:
每分钟(Z31):X31/ sum(distinct stbnum) where 时间
……
每分钟(Z3n):X3n/ sum(distinct stbnum) where 时间
市场份额:(Z31 + … + Z3n)/n
平均到达人数
默认扣除在某个频道或整个系统停留时间小于60s的用户ID,不包括60s,跟平均收视人数的差别在于排除原始记录中停留时间小于60s的记录。
总的:
每分钟(U11): sum(distinct stbnum) WHERE ((a_e – a_s)>=60)
每分钟(U12):……
每分钟(U1n):……
平均到达人数:(U11 + U12 + … + U1n)/n
频道:
每分钟(U21): sum(distinct stbnum) where 指定频道名称 = 频道名 AND ((a_e – a_s)>=60)
每分钟(U22):……
每分钟(U2n):……
平均到达人数:(U21 + U22 + … + U2n)/n
节目:
每分钟(U31): sum(distinct stbnum) where 指定节目名称 = 节目名 AND ((a_e – a_s)>=60)
每分钟(U32):……
每分钟(U3n):……
平均到达人数:(U31 + U32 + … + U3n)/n
到达率
平均到达人数/系统总ID数。
CONSTANT系统总ID数 IDNUM = sum(distinct stbnum)。
总的,频道,节目:
每分钟(V1):U1/ IDNUM
……
每分钟(Vn):Un/ IDNUM
某一段时间的到达率:(V1 + V2 + … + Vn)/n
人均收视时长
所有频道 —— 每天所有用户ID的总时间/用户ID数;具体某个频道 —— 访问过该频道的所有用户ID每天总时间/该频道每天的用户ID数;具体某个栏目 —— 访问过每期节目的所有用户ID总时间/该栏目的用户ID数。
总的:
某天人均收视时长(W11):SUM(a_e – a_s)/S11
频道:
某天人均收视时长(W21):SUM(a_e – a_s)/S21
节目:
某天人均收视时长(W31):SUM(a_e – a_s)/S31
六、开发流程
1.通过flume收集工具将用户产生的原始数据收集到hdfs分布式文件系统。
2.编写MR程序对原始的收视数据进行解析、清洗、提取业务所需的有效字段。
3.利用hive工具将MR处理后的数据导入数据仓库,同时对该数据进行统计分析。
4.编写应用程序或者使用sqoop工具将hive分析的最终数据导入数据库,比如mysql数据库。
5.前端查询,实现数据的可视化。
七.源数据
利用hdfs的小文件合并MergeSmallFilesToHDFS.java将每天的小文件合并为大文件,具体参考http://blog.csdn.net/zoeyen_/article/details/78947676
八、将源数据上传到hdfs文件系统
这里使用flume采集工具,我将flume工具安装在主节点(pc1)上,仅使用了一层agent。
①启动集群
②修改flume的配置文件
[hadoop@pc1 conf]$ vi flume-conf.properties
agent1.channels = ch1
agent1.sinks = sink1
# Define and configure an Spool directory source(使用spooldir监控日志目录)
agent1.sources.source1.channels = ch1
agent1.sources.source1.type = spooldir
agent1.sources.source1.spoolDir = /home/hadoop/tvdata
#前三项必须配置,具体参数可参考官方文档
agent1.sources.source1.ignorePattern = event(_\d{
4}\-\d{
2}\-\d{
2}_\d{
2}_\d{
2})?\.log(\.COMPLETED)?
agent1.sources.source1.deserializer.maxLineLength = 10240
#配置收集每行数据的最大长度
# Configure channel(channel 选择file,防止数据丢失)
agent1.channels.ch1.type = file
#也可以配置内存
agent1.channels.ch1.checkpointDir = /home/hadoop/app/flume/checkpointDir
agent1.channels.ch1.dataDirs = /home/hadoop/app/flume/dataDirs
#在flume目录下创建以上两个路径
# Define and configure a hdfs sink(数据采集到hdfs)
agent1.sinks.sink1.channel = ch1
agent1.sinks.sink1.type = hdfs
agent1.sinks.sink1.hdfs.path =
hdfs://pc1:9000/home/app/tvdata/%Y%m%d
#如果是集群就配置对外提供服务的地址
agent1.sinks.sink1.hdfs.useLocalTimeStamp = true
agent1.sinks.sink1.hdfs.rollInterval = 300
agent1.sinks.sink1.hdfs.rollSize = 67108864
agent1.sinks.sink1.hdfs.rollCount = 0
#agent1.sinks.sink1.hdfs.codeC = snappy #没有做snappy压缩
③创建路径
④将源数据上传到tvtest目录下
⑤进入flume安装目录,执行运行命令
[hadoop@node2 flume]$bin/flume-ng agent -n agent1 -c conf -f conf/flume-conf.properties
⑥查看
出现乱码
查看官方文档,hdfs.fileType默认为SequenceFil,改为datastream就可以按原样输出数据到hdfs。
删除已经采集到hdfs的数据,重新采集
九、 编写MR程序对原始的收视数据进行解析、清洗、提取业务需要的有效字段
①对源数据进行预处理,提取需要的数据。编写一个只有mapper的mapreduce程序,调用一个DataUtil接口,这个接口引用了jsoup的jar包,来解析源数据的每一行数据,将机顶盒号和日期作为输出key,其它作为输出value。其中日期的解析由TimeUtil这个类实现。
/*
* 解析机顶盒用户原始数据
*/
public class ParseAndFilterLog extends Configured implements Tool {
/*
* 只需Mapper完成原始数据解析
*/
public static class ExtractTVMsgLogMapper extends
//Mapper<LongWritable, BytesWritable, Text, Text> {
Mapper<LongWritable, Text, Text, Text> {
//public void map(LongWritable key, BytesWritable value, Context context)
public void map(LongWritable key, Text value, Context context)
throws IOException, InterruptedException {
// 原始数据
//String data = new String(value.getBytes(), 0, value.getLength());
String data = value.toString();
// 调用接口直接解析出我们需要数据格式
// stbNum + "@" + date + "@" + sn + "@" + p+ "@" + s + "@" + e + "@"
// + duration
DataUtil.transData(data, context);
}
}
public int run(String[] args) throws Exception {
// TODO Auto-generated method stub
Configuration conf = new Configuration();
String[] otherArgs = new GenericOptionsParser(conf, args)
.getRemainingArgs();
if (otherArgs.length < 2) {
System.err.println("Usage: ParseAndFilterLog [<in>...] <out>");
System.exit(2);
}
Job job = Job.getInstance();
// 设置输出key value分隔符
job.getConfiguration().set("mapreduce.output.textoutputformat.separator", "@");
job.setJarByClass(ParseAndFilterLog.class);
job.setMapperClass(ExtractTVMsgLogMapper.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(Text.class);
//job.setInputFormatClass(SequenceFileInputFormat.class);
// 设置输入路径
for (int i = 0; i < otherArgs.length - 1; ++i) {
FileInputFormat.addInputPath(job, new Path(otherArgs[i]));
}
// 设置输出路径
FileOutputFormat.setOutputPath(job, new Path(
otherArgs[otherArgs.length - 1]));
return job.waitForCompletion(true) ? 0 : 1;
}
public static void main(String[] args) throws Exception {
int ec = ToolRunner.run(new Configuration(),new ParseAndFilterLog(), args);
System.exit(ec);
}
}
/**
*
* 解析机顶盒用户原始数据
* <GHApp>
* <WIC cardNum="174041665" stbNum="01050908200014994"
* date="2012-09-16" pageWidgetVersion="1.0">
* <A e="23:56:45" s="23:51:45" n="133" t="2" pi="488"
* p="24%E5%B0%8F%E6%97%B6" sn="CCTV-13 新闻" />
* </WIC>
* </GHApp>
*
*/
public class DataUtil {
@SuppressWarnings("unchecked")
public static void transData(String text,Context context) {
try {
//通过Jsoup解析每行数据
Document doc = Jsoup.parse(text);
//获取WIC标签内容,每行数据只有一个WIC标签
Elements content = doc.getElementsByTag("WIC");
//解析出机顶盒号
String stbNum = content.get(0).attr("stbNum");
if(stbNum == null||"".equals(stbNum)){
return ;
}
//解析出日期
String date = content.get(0).attr("date");
if(date == null||"".equals(date)){
return ;
}
//解析A标签
Elements els = doc.getElementsByTag("A");
for (Element el : els) {
//解析结束时间
String e = el.attr("e");
if(e ==null||"".equals(e)){
break;
}
//解析起始时间
String s = el.attr("s");
if(s == null||"".equals(s)){
break;
}
//解析节目内容
String p = el.attr("p");
if(p == null||"".equals(p)){
break;
}
//解析频道
String sn = el.attr("sn");
if(sn ==null||"".equals(sn)){
break ;
}
//对节目解码
p = URLDecoder.decode(p, "utf-8");
//解析出统一的节目名称,比如:天龙八部(1),天龙八部(2),同属于一个节目
int index = p.indexOf("(");
if (index != -1) {
p = p.substring(0, index);
}
//起始时间转换为秒
int startS = TimeUtil.TimeToSecond(s);
//结束时间转换为秒
int startE = TimeUtil.TimeToSecond(e);
if (startE < startS) {
startE = startE + 24 * 3600;
}
//每条记录的收看时长
int duration = startE - startS;
context.write(new Text(stbNum + "@" + date), new Text(sn + "@" + p+ "@" + s + "@" + e + "@" + duration));
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
import java.util.ArrayList;
import java.util.List;
/**
*
* 时间工具
*
*/
public class TimeUtil {
/**
* 将时间00:00:00转换为秒 int
*
* @param time
* @return
*/
public static int TimeToSecond(String time) {
if (time == null||time.equals("")) {
return 0;
}
String[] my = time.split(":");
int hour = Integer.parseInt(my[0]);
int min = Integer.parseInt(my[1]);
int sec = Integer.parseInt(my[2]);
int totalSec = hour * 3600 + min * 60 + sec;
return totalSec;
}
/**
* 将时间00:00:00转换为秒 String
*
* @param time
* @return
*/
public static String TimeToSecond2(String time) {
if (time == null) {
return "";
}
String[] my = time.split(":");
int hour = Integer.parseInt(my[0]);
int min = Integer.parseInt(my[1]);
int sec = Integer.parseInt(my[2]);
int totalSec = hour * 3600 + min * 60 + sec;
return totalSec + "";
}
/**
* 求两个时间的字符串差值
* @param a_e
* @param a_s
* @return
*/
public static String getDuration(String a_e, String a_s) {
if (a_e == null || a_s == null) {
return 0 + "";
}
int ae = Integer.parseInt(a_e);
int as = Integer.parseInt(a_s);
return (ae - as) + "";
}
/**
* 将时间 00:00转换为秒 int
*
* @param time
* @return
*/
public static int Time2ToSecond(String time) {
if (time == null) {
return 0;
}
String[] my = time.split(":");
int hour = Integer.parseInt(my[0]);
int min = Integer.parseInt(my[1]);
int totalSec = hour * 3600 + min * 60;
return totalSec;
}
/**
* 提取start end 之间的分钟数
*
* @param time
* @return
*/
public static List<String> getTimeSplit(String start, String end) {
List<String> list = new ArrayList<String>();
String[] s = start.split(":");
int sh = Integer.parseInt(s[0]);
int sm = Integer.parseInt(s[1]);
String[] e = end.split(":");
int eh = Integer.parseInt(e[0]);
int em = Integer.parseInt(e[1]);
if (eh < sh) {
eh = 24;
}
if (sh == eh) {
for (int m = sm; m <= em; m++) {
int am = m + 1