数据库通常是一个软件,负责业务数据的实时增删改查(OLTP),业务系统的数据库要求实时响应
数据仓库:OLTP的数据在闲时将每天的业务数据导出到另一个数据库(不做OLTP,不连web服务),做一些里显得数据分析(和存储);
所以例如Oracle或者Mysql,既可以用来作业务系统数据库,也可以用来做数据仓库,但是用普通数据库做数仓的瓶颈:存储量小;
这里引入数仓需要满足的两个条件:
普通数据库在第一条上不是最优解,因此Hive更能够更好地充当数据仓库的角色
另一个区别: 业务系统数据库的某条数据是不断变化的,而数仓中的数据是不变化的(通过保存历史记录来体现时间的变化)
数仓的特点:
面向主题的,整合的,相对稳定的,反映历史的
数仓也分两种:
数仓分层:
数仓层 | 说明 |
---|---|
ADS | 应用服务层(对DW层的数据进一步进行计算,以得到应用层需要的数据) |
DWS | 数仓汇总层(对明细数据进行 聚合汇总操作,有时也跟DWD层合成一个DW层) |
DWD | 数仓明细层(分主题,打标签->“是否周末”,补全字段->通过id将desc join进来) |
ODS | 操作数据层(结构与源数据一致),最底层,也叫贴源层 |
流程:
基本概念:协作开发中的代码(版本)管理系统
Gitee
父子工程搭建:
子工程间的相互依赖
通过表自己多次join自己,达到顺藤摸瓜级联向上查找父id的目的
参考:自连接
引入GeoHash的maven依赖,之后传入经纬度和最后生成的GeoHash字符个数(也就是精度),然后再调用BASE32解码方式,就会得到GeoHashCode
原理:使用二分法将一个经纬度无限接近于精确,二分过程中会将生成的1 和0 (大于和小于某值)追加在原来的字节码后面,字节越多,就越精确,然后将这些1 和0 5个5个分成一组(这里用BASE32方式编码,所以是5个5个一组,如果用BASE64,则是6个6个一组),每组用一个字符表示,生成新的GeoHash码,调用方法时传入的精度,决定了截取多少个长度为5的字节,也就决定了精度。
//导入隐式转换
import org.apache.spark.sql.functions._
//创建一个输入经纬度,得到GeoCode的函数
val gps2geo = (lng:Double,lat:Double)=>
{GeoHash.withCharacterPrecision(lat, lng, 5).toBase32}
//将函数注册道spark sql的引擎中去,并命名
spark.udf.register("gps2geo",gps2geo)
//可以直接在spark sql中使用
spark.sql(
"""
|select
|gps2geo(BD09_LNG,BD09_LAT) as geo,
|from area
""".stripMargin)
数据集 | 说明 |
---|---|
RDD | 是一个分布式数据集,处理非结构化数据,一次拿到一行(一般是String类型),再由操作者决定如何处理这一行数据;创建RDD时,本质上是将其他类型的集合映射成rdd集合 |
DataFrame | 装了(Row)类型的RDD,Row这个类里面,封装了数据的schema,即字段名称和类型和位置 |
// 将一个带有表头的csv文件映射成dataframe
val df1 = spark.read.option("header","true").csv("PATH")
//将一个没有表头的csv文件映射成dataframe并在映射好之后更改字段名
val df2 = spark.read.csv("PATH")
val df3 = df2.toDF("id","name","score")
// 将一个没有表头的csv文件映射成dataframe
// 并按照我们的预定要求映射表结构(字段名、字段类型)
val schema = new StructType()
.add("id",DataTypes.IntegerType)
.add("name",DataTypes.StringType)
.add("score",DataTypes.DoubleType)
val df4 = spark.read.schema(schema).csv("PATH")
1. 环境问题
在创建自己的项目时,试图将老师的项目中的文件夹直接copy过来,发现依赖关系总会出现问题,于是删除项目,重新新建了项目并手动完成所有文件夹的创建,未发现问题;
2. 开发思路问题
将数据从mysql中读取出来后拿到了DF,但是不知道如何取出DF中的数据,这里是对Spark 的API 不熟练,其实可以用df.getAs用法得到相应名字或者角标字段的数据,还可以指定得到的数据类型,但这里数据类型使用的是泛型,并无代码层面的校验;
3. SQL运行缓慢
上述SQL自join的需求,在我的本机运行的时候,奇慢无比,以为是性能问题,但同学与我配置差不多的电脑上运行不缓慢,遂对比表设计,发现如下几点:
上述几点优化不知是否真是可行,还是只是巧合,有待研究;
整体流程:
1. Json解析 - FastJson
使用Json.parseObject()
方法解析Json而不指定封装类的话,就会得到一个类型为JsonObject的对象,这个对象本质上是一个map,可以灵活地通过getString()
,getDouble()
或者(如果value还是一个map的话)getObject()
等方法来获取需要的字段进行处理。
解析Json的这个方法,因为不涉及到业务逻辑,只是将输入的json文本转换成一个case class
,所以可以封装到comms模块下,在当前代码逻辑里,只需要传入json字符串,返回一个样例类即可。 因此,样例类和fastjson的jar包依赖,都不需要在data_ware模块中创建和引用,只需要在comms端创建和引用,dw模块只需import
这个 样例类的包即可(因为之前data_ware模块导入了comms的依赖)。
2. StringUtils工具类 - isNotBlank方法
判断所有字段不同时为空的一个小技巧:使用org.apache.commons.lang3下的StringUtils
类里的isNotBlank()
方法,该方法在被判断的内容为null
,空串,或者一个空格时,都会返回false
,可以利用该方法的特点,将需要判断的字符依次拼接起来,判断拼接后的字符串是否不为空,如果返回true
,则证明其中至少有一个字符串是非空的;
补充:使用StringBuilder
拼接字符串时,如果有某个变量的引用为null
,那么会将字符串"null"
拼接上,因此需要将最终拼接的结果toString()
后调用replace()
方法将"null"
替换成空串""
来完成判断。
3. Scala与Java的Map相互转化
import scala.collection.JavaConverters._
//JAVA MAP
val javaMap: util.HashMap[String, AnyRef] = new util.HashMap[String, AnyRef]()
//Scala Map
//这里可以通过map方法将数据转为想要的数据类型,之后直接调用toMap方法将mutableMap转为immutableMap
val scalaMap: Map[String, String] = javaMap.asScala.map(x => (x._1, x._2.toString)).toMap
4. 广播变量的使用
//将areaMap作为广播变量广播出去
bc = spark.sparkContext.broadcast(areaMap)
rdd.map(x=>{
//excutor端获取广播变量
areaMap = bc.value
})
5. 高德地图逆地理编码服务
//首先创建HTTP的链接,需要导入maven依赖
val client: CloseableHttpClient = HttpClientBuilder.create().build()
//然后生成一个GET请求(不同服务可能需要生成不同请求),根据需要传入参数
val get = new HttpGet("${uri}?key=${key}&location=${lng},${lat}")
//发送请求,得到响应
val response: CloseableHttpResponse = client.execute(get)
//解析这个响应,拿到响应体,就可以对响应体里的内容进行操作了
val content: InputStream = response.getEntity.getContent
1.关于Json解析
在思考如何解析json字符串的时候,试图将json按照原格式,一级一级封装到样例类里面,因此创建了许多样例类,作为上级样例类持有的属性的类型,在将数据压平时通过的获取成员变量的方式获取数据的值;对比涛哥的将数据解析成jsonObject之后通过调用JO的解析方法来获取数据,涛哥的方法无需创建大量的样例类。
2. RDD的Join操作
当RDD调用JOIN方法时,两个RDD执行时不会使用同一个taskSet中的task,而是各自有各自的taskSet,但是两者shuffle后的结果,都是同一个RDD在拉取。
3. 关于闭包
学习Spark RDD时接触到“闭包”这一概念,当时的案例会抛出Task not serializable
的异常,所以以为“闭包”的概念是“对于函数内引用函数外变量的错误操作的总称”,其实不然,在拜读了官网对于闭包(Closure)的说明后,重新整理“闭包”的概念如下,首先祭出原文及案例:
int counter = 0; JavaRDD<Integer> rdd = sc.parallelize(data); // Wrong: Don't do this!! rdd.foreach(x -> counter += x); println("Counter value: " + counter);
Prior to execution, Spark computes the task’s closure. The closure is those variables and methods which must be visible for the executor to perform its computations on the RDD (in this case foreach()). This closure is serialized and sent to each executor.
官网指出,在执行任务之前,Spark会先计算这个任务的“闭包”。“闭包”是指为了进行RDD的计算,必须对executor可见的那些变量和方法。这个“闭包”会被序列化并发送给每个executor;
“闭包”这个概念有些明了了,首先,这是一个名词,其次,报错的原因是因为闭包内的变量或方法所在类没有实现序列化
,而不是因为所谓执行了“闭包操作”(即函数内引用函数外的变量或方法这个操作本身);所以,只要闭包内的变量和方法所在类实现了序列化
接口,那么我们就可以放心大胆地在函数内引用函数外的方法和变量,只不过需要注意的点是,此时引用的,已经不是Driver端生成的那个对象A
,而是序列化后传送到executor端的那个对象B
,因此,对于对象B
的改变,将不作用于对象A
上,如官方给出的案例中, counter变量在executor端不断累加,但在Driver端打印的话,值依然为0;
4. 关于Dataset
Dataset中的类型可以是任意类型,而恰好是Row类型的Dataset也叫DataFrame;
当DS中的数据类型是一个样例类时,DS可以通过样例类反射出表结构,因此DS也可以用来做SQL查询;
若直接调用DS的map等算子,取出数据时,因为是直接调用成员属性, 因此数据类型很明了;
DS调用select算子后,由于有可能对表结构(列数,数据类型)进行改变,因此无法保证类型和以前一致,会返回一个通用类型Row,也就是返回一个DataFrame;
5. Spark广播变量对应MR中的具体实现
首先对于广播变量,官方文档中是这样描述的:
Broadcast variables allow the programmer to keep a read-only variable cached on each machine rather than shipping a copy of it with tasks. Spark also attempts to distribute broadcast variables using efficient broadcast algorithms to reduce communication cost.
广播变量允许程序员在每台机器上缓存一个只读
的变量, 而不用随任务发送这个变量的副本(详见闭包); Spark还尝试使用一个非常有效率的广播算法去分发广播变量以减少沟通消耗;
Broadcast variables are created from a variable v by calling SparkContext.broadcast(v). The broadcast variable is a wrapper around v, and its value can be accessed by calling the value method.
广播变量由调用SparkContext.broadcast(v)
方法创建,广播变量是对于v变量的一个封装,可以通过调用.value
方法来得到变量的值;
After the broadcast variable is created, it should be used instead of the value v in any functions run on the cluster so that v is not shipped to the nodes more than once. In addition, the object v should not be modified after it is broadcast in order to ensure that all nodes get the same value of the broadcast variable (e.g. if the variable is shipped to a new node later).
在广播变量被创建好后,任何在集群上执行的函数中都应该使用这个广播变量,而不直接使用变量v,以防止变量v被多次发送到节点上(使用v则v就会被添加到闭包中,随task被序列化并发送到executor);另外,对象v在被广播后不应该被修改,以确保所有节点拿到的广播变量值相同(即使变量以后被发送到新的节点(因为自己已经拿到的chunk不会再去拿,详见下面广播变量的类BT实现));
A BitTorrent-like implementation of
Broadcast
.
The mechanism is as follows:
The driver divides the serialized object into small chunks and stores those chunks in theBlockManager
of thedriver
.
On each executor, the executor first attempts to fetch the object from itsBlockManager
. If it does not exist, it then uses remote fetches to fetch the small chunks from thedriver
and/or otherexecutors
if available. Once it gets the chunks, it puts the chunks in its ownBlockManager
, ready for other executors to fetch from.
根据官方文档,Spark是通过类似BT种子的方式进行广播的:Driver将广播的内容序列化后分成小块(chunk
),存储在自己的BlockManager中,每个executor首先从他自己的BlockManager中抓取这个对象,如果不存在,就通过远程抓取,去driver或者其他executor中抓取这个对象的chunk
,一旦拿到这个chunk
,就将它放入自己的BlockManager中,便于其他executor来抓取;
相对于广播变量,MapReduce框架中有一个类DistributedCache(分布式缓存),官方给出的解释如下:
Applications specify the files to be cached via urls (hdfs://) in the JobConf. The DistributedCache assumes that the files specified via hdfs:// urls are already present on the FileSystem.
程序在JobConf中指定需要缓存的文件路径,分布式缓存默许这个路径是存在的
The framework will copy the necessary files to the slave node before any tasks for the job are executed on that node.
框架会在任务在slave节点上执行之前,将所需要的文件复制到节点上
分布式缓存和广播变量一样,都是只读的,分布式缓存将需要共享的文件放在hdfs
里并通过DistributedCache.addCacheFile()
将路径通知给各个slave,各个slave自己运行job之前会去hdfs
下载;而广播变量通过SparkContext.broadcast()
将需要共享的内容放在自己的BlockManager
中,等待executor来抓取;
整体流程:
1. DSL风格 数据集API 夯实
SQL中的关键字,几乎都可以用dataframe中的DSL风格的API来实现:
Select:
//参数可以是字符串,也可以是Column对象
df.select("id","name")
df.select(df("id"),df("name"))
//如果导入了隐式转换,就可以用一些特殊的用法
import spark.implicits._
df.select($"id", 'name)
//或者用spark sql 的内置函数构建col对象
import org.apache.spark.sql.functions._
df.select(col("id"))
//如果想对列进行操作,需要调用col的函数或使用selectexpr
df.select('id, upper('name))
df.selectExpr("id", "upper(name)")
上述代码中df("id")
这个方法,其实是调用了df的apply
方法,会查找名为id的列并返回他的Column
对象;
Where:
//SQL风格和DSL风格对比
df.where("score > '80'")
df.where('score <=> "80")
Group By:
//聚合的三种方式
//直接max
df.groupBy("gender").max("score")
//求最大和最小值
df.groupBy("gender").agg("score" -> "max", "score" -> "min")
df.groupBy("gender").agg('gender, min('score), max("score"))
上述方法中的参数"score" -> "max"
是scala中的一个语法糖,这样写就相当于创建了一个元组(score,max)
窗口函数:
//先创建一个window对象,指定partitionBy和orderBy
//然后在select中调用函数row_number()的over方法,把window传入,并取别名
val window = Window.partitionBy('gender).orderBy('score.desc)
df.select('id,'name,'gender,'score,row_number().over(window).as("rn"))
Join:
//df1和df2join,条件是两表id字段相同,结果中仅保留一列 id
df1.join(df2,"id")
//JoinWith ,是针对dataset的特定join,可以保留dataset中原有的数据类型
//注意看返回值DataSet中的类型, 一行返回两个类,而不是多个字段
val dsr: Dataset[(Person, Student)] = ds1.joinWith(ds2,ds1("id") === ds2("id"))
2. Coalesce的使用
coalesce(a,b,c)
返回a,b,c中,第一个不为空的值,这里只能判断是否为null,对于空串或者空格,需要自己额外判断
1. Hive建表
ODS层数据建模时:
因为是贴源层, 字段应与来源字段保持一致,不做任何处理;
ODS层建表时:
因为是采用Hive建表,然后直接load parquet文件里面的数据的方式,所以选择建外部表,这样删除表后对外部的parquet文件没有影响; 因为每天都有数据,所以建立分区表按照天分成不同的文件夹存储;
创建ODS层表:
drop table if exists yiee.ods_eventlogs;
create external table if not exists yiee.ods_eventlogs(
--fields..
)
partitioned by (dt string)
stored as parquet
location '/hivedata/eventlogs';
后面三句话的顺序不能错,否则创建失败;这里的路径,是说以后如果往这个表里insert数据的话,产生的parquet文件会生成在哪个目录(insert数据时,会根据目标表的定义,生成相应格式的文件 )
2. 关于建模时的SQL
之前星哥教过一种方法,是把一个需求的每一步自己分析出来的SQL语句记录下来,方便后续需求变更或者维护的时候做修改和查询;今天亲自尝试了一下这种方法,效果非常明显,之前写好的SQL一定是可以运行的,只需要在原来的基础上做一些更改,则可以实现非常快速的实现表重建,数据重新插入,表字段变更等操作;
3. 关于scala中的隐式转换
之前在使用阿里巴巴的FastJson时,调用toJSONString()
传入一个AnyRef的对象试图获得字符串时,报二义性错误,分析原因是该方法还有一个重载方法,传入两个参数,第一个参数是AnyRef类型,第二个参数是一个序列化器,sparkSession.implicits._中有该序列化器类型的变量存在,导致编译器不知道该绑定哪个方法;
因此想到了scala中是如何处理这种问题的,进行如下测试:
object Test1 {
implicit val str: String = "参数2"
def main(args: Array[String]): Unit = {
//编译不通过
//println(test1("参数1 "))
}
def test1(str: String)(implicit str2: String): String = {
str + str2
}
def test1(str: String): String = {
str
}
}
对象中定义了两个重载的方法test1,main方法中只传入一个参数的话,编译器是不通过编译的,所以会提醒你cannot resolve overloaded method test1
,此时可以选择1:将test1两个参数的方法列表的第二个列表中的implicit关键字去掉,骗过编译器,但执行时仍然会报二义性错误(跟之前json解析时的一样)2:将一个参数的方法注释掉,运行不会报错;
4. 关于hive 中的 字段别名
在select表达式中想要对某些字段起别名,比如 date 但是date又是hive中的关键字,直接写 as date就会报错
Error: Error while compiling statement: FAILED: ParseException line 1:79 cannot recognize input near ‘as’ ‘date’ ‘from’ in selection target (state=42000,code=40000)
这时需要将表别名使用反单引号引起来 "``"
(引号里面这两个键盘1左边)
整体流程
1. 临时表创建
插入数据时,有时想要对表进行简单的过滤之后再作为子表,但是不想写子查询,那么就可以先声明一个临时表和这个表的别名,下面sql中直接使用这个别名即可:
with dau as (select * from t1 where xxx)
insert into table t2
select
*
from dau;
2. OLAP多维数据分析(Hive高阶聚合函数)
背景:对于有多个维度的明细表,有时业务可能需要不同维度的组合来进行多维度的分析,那么如果将数据每个维度都新建一张表会建立大量的表,不利于维护,所以需要将这些不同维度的数据都插入到同一张表中;
原理:建立一张最细粒度的表,然后插入数据,当遇到更粗粒度的数据时,被忽略的维度给null值,即可实现所有数据都在同一张表汇总;
实现:现实中,表虽然有了,但是如果每种维度都需要手动去插入数据,那么效率还是极低,所以引入了hive高阶函数,(一键)生成想要的维度的数据:
select
...
from t1
group by c1,c2,c3
with cube;
上述代码相当于查询出了group by 后面的所有维度的所有组合 (一共8种,从000到111);
select
...
from t1
group by c1,c2,c3
grouping sets((c1,c2),(c1,c3),(c2),());
grouping sets 不像with cube, 会生成所有的维度的所有可能性的组合,而是可以手动地指定想要生成哪些维度的组合, 没有指定的组合将不被生成;
select
...
from t1
group by c1,c2,c3
with rollup;
有时数据是按照层级关系存储的,需要的就是下到上的越来越粗的粒度的数据,没有交叉维度的分析,那么就可以使用rollup, 将维度从右到左卷起,上述代码相当于 grouping sets ((c1,c2,c3),(c1,c2),(c1),()) -> 粒度由细到粗
1. sparkSession的Encoder问题
现象:使用sparkSession.createDataset()
创建Dataset时,如果传入的Seq类型是Map类型,那么编译时会报错,提示没有对应的encoder
解释: reateDataset()
是一个科里化方法,需要传入第二个参数Encoder,sparkSession的隐式转换类中有基本数据类型和case class 的encoder,但是没有Map的encoder,所以会报错,如果想正常运行,需要使用 Encoder.kryo(Classof[T])
这个方法构建一个encoder 并传入
2. 关于Hive的窗口函数
结论:
当over()中指定了order by 字段,但未指定窗口规格时,默认的窗口规格是 range between unbounded preceeding and current rows
当over()中未指定order by 字段,也未指定窗口规格时,默认的窗口规格是 row between unbounded preceeding and unbounded following
关于range 和 row 区别的理解:
首先明确,窗口函数是逐行运算时,也能返回聚合的结果,那么窗口规格,则是决定了聚合时的数据范围; 比如同样是1 preceeding and 1 following
,如果前面是range
,则是聚合时,将所有与该行数据数值差距正负不大于1 的所有行的数据聚合,而如果前面是row
,则是聚合时,将该行和前一行和后一行的数聚合
set hive.exec.mode.local.auto = true
insert into table
是数据追加,insert overwrite table
是数据覆盖1 留存分析
背景:这是一个围绕某日新增用户
的度量,想要计算的是某日新增的用户中,过了几天仍然活跃的有多少人,占哪一天新增用户的比例是多少
方案:数据取自hisu表,按照首登日
分组,将每个首登日的人数(当天新增用户)计算出来,然后求出这个首登日据计算日的差值,则为这个首登日对应的留存时间,然后再求出该组中末登日为计算日的个数,这个值即为留存人数;每个首登日每天都要更新一次,更新的是最新的数据,采用插入而不是覆盖写的方式,让最新的数据和历史数据都保存,方便业务以时间作为维度进行分析;
实现:
insert into dws_user_retention_dtl
select
first_login as dt, --首登日
count(1) as dnu_cnts, --首登日总新增人数
datediff('2019-06-16',first_login) as retention_days, --留存时间
count(if(last_login='2019-06-16',1,null)) as retention_cnts --是否计算日活跃(是否留存)
from dwd_hisu_dtl
where datediff('2019-06-16',first_login) between 1 and 30 --限制分析范围
group by first_login;
2.新鲜度分析
背景:这是一个围绕某日活跃用户
的度量,想要计算的是某日活跃用户中,用户的新鲜度分层(新鲜度按照注册时间进行分层)
方案:数据同样取自hisu表,先过滤出末登日等于计算日的数据,这些数据是我们需要的“某日活跃用户”的数据;然后count求出总的活跃用户,再按照首登日进行分组,组内count得到每层新鲜度的人数
实现:
with tmp as (
select
uid,
first_login,
last_login,
datediff(last_login,first_login) as fresh
from demo_hisu_dtl
where last_login='2019-08-30' and datediff('2019-08-31',first_login) between 1 and 30
) --过滤出需要的数据
insert into table dws_active_fresh_dtl
select
b.dt, -- 计算日期
a.dau_amt, -- 活跃总数
b.fresh, -- 新鲜度
b.fresh_cnts -- 新鲜人数
from
(
select count(1) as dau_amt from tmp --当日总活跃用户
) a
join --a表只有一行数据,而且b表也只有一个日期,所以直接join无需加条件
(
select
'2019-08-30' as dt, -- 计算日期
fresh, -- 新鲜度
count(1) as fresh_cnts -- 该新鲜度下的人数
from tmp
group by fresh
) b
;
3.关于成绩查询
一道思考题:现有表test_score
数据如下:
张三 语文 80
张三 数学 60
张三 地理 90
李四 物理 50
李四 化学 80
李四 政治 0
需要将竖表转成横表,但学生未修的科目需要体现出来
思考过程:
原本的思考很简单,只需要select 多写几个表达式就好了,以科目
作为sum的过滤条件,当不符合条件时给0
;但是这个方法无法正确的区分真的得了0分还是未修该课程,因此进一步思考:区分不出来是因为当不符合条件时,给了0
,这个0
和真的得了0分时是同样的返回值,因此当不符合条件时,给别的返回值就可以了; 这里可以给null
,null
跟任何数相加都等于任何数,全都是null
相加, 结果还是null
,因此可以将未修课程和得0分的课程区分出来;
select
name,
sum(if(subject = '语文',score,null)) as `语文` ,
sum(if(subject = '数学',score,null)) as `数学` ,
sum(if(subject = '地理',score,null)) as `地理` ,
sum(if(subject = '物理',score,null)) as `物理` ,
sum(if(subject = '化学',score,null)) as `化学` ,
sum(if(subject = '政治',score,null)) as `政治` ,
sum(if(subject = '历史',score,null)) as `历史`
from test_score
group by name
4.hive实现map的聚合函数
对于set和list,hive中有聚合函数collect_set
和collect_list
让数据聚合时被收集到一个集合里,针对于想让数据聚合时被收集到一个map里这种需求,可以借助如下方法:
思路:先将每一行的kv进行字符串拼接
(拼接符“:”),然后进行聚合,将每一行的字符串收集到一个list
中去,然后用函数将数组中的数据进行字符串拼接
拼成一个大长串(拼接符“,”),然后用函数将字符串转化为map
;
实现:
select
dt,
dnu_cnt,
str_to_map(concat_ws(',',collect_set(concat_ws(':',cast(retention_days as string),cast(retention_cnts as string)))),',',':') as info
from demo_retention_dtl
group by dt,dnu_cnt
//逐层分析
//L1 首先将每行的kv使用冒号拼接成一个字符串
concat_ws(':',cast(retention_days as string),cast(retention_cnts as string)) as L1
//L2 然后将数据聚合,收集到一个set中去
collect_set(L1) as L2
//L3 将数组使用逗号进行拼接(字符串拼接函数可以传入一个数组)
concat_ws(',',L2) as L3
//L4 最后,指定分隔符,将拼接好的字符串转成map
str_to_map(L3,',',':') as L4
1. 关于数仓建模
数仓设计分为两大领域,一种是OLTP
领域,这一领域,数据经常随机单条更新,所以要保证更改一条数据,其他需要改动的地方尽量少,因此数仓设计遵循三范式(多用雪花型模型
设计),尽量减少数据的冗余;另一个领域是数据分析
领域,这一领域,数据是稳定的,但是需要跨大量的数据进行计算,因此不讲究三范式,容许数据的冗余(多用星型设计模型
),因为大量数据join非常的耗费性能。
2. hive 的优化
count(distinct id)
这种写法肥肠的耗费性能,因为他会将所有的id都发送给同一个reducer,reducer在将所有的id放入set中求这个set的size;相较之下,select count(*) from (select id from _ group by id) t
这种方法, 会根据ID将id发给不同的reducer进行去重,然后将去重好的id再发给同一个reducer,性能上会好很多,不会造成数据倾斜分桶
并且分桶数
一致时,hive会只join桶号相同的两个文件;5. 关于新鲜度的优化
技能提升中的求新鲜度的sql执行有些缓慢,因为创建了临时表,子查询还会join,尝试了不进行join的写法,使用了窗口函数
,得到了相同的结果;但是其中某些用法比如sum(count(1))
没有找到官方的解释,不知道是否只是恰巧可以这样用,只找到了一个国内的博客上面记录了相同的现象;这里我理解直接count(1) over()
的话,count的是分组去重过后的数据,因此30号按照首登日去重后,只有15条数据,count(1) over()
的话得到的数据是15, 但是sum(count(1)) over()
是将按照首登日去重后的count出的每个首登日的条数相加得到总和;
另外还发现,如果表达式中不在前面事先出现count(1)
这个表达式的话,sum(count(1)) over()
这个表达式会报错,说没有count()这个udaf
select
last_login, --首登日
concat(datediff(last_login,first_login),'天') as days, --新鲜度
concat(substr(count(1) * 100 / sum(count(1)) over(partition by last_login ),0,5 ),'%')as rate -- 某新鲜度人数/总活跃人数
from
demo_hisu_dtl
group by last_login,first_login;
6. 临时表创建
--临时表中不能有对其他临时表的引用
with tmp1 as () ,tmp2 as (), tmp3 as ()
7. sum(if())
select 表达式中,根据条件求和时要将条件写在sum()
里面,否则的话,用于做来判断的列将暴露在聚合函数之外,会提示这个列需要group by
1. 编写shell 脚本
执行spark程序:
#!/bin/bash
#将日期预设成当前天减一天
dt=`date -d'-1 day' +'%Y-%m-%d'`
#判断用户执行脚本时是否传入了参数,若传入了参数,使用传入的参数作为日期
if [ $1 ]
then
dt=$1
fi
echo "正在计算${dt}数据"
#执行spark程序,并引用变量
spark-submit...... /data/eventlog/${dt}
执行sql语句:
#!/bin/bash
#一样的逻辑判断日期参数取值
dt=`date -d'-1 day' +'%Y-%m-%d'`
if [ $1 ]
then
dt=$1
fi
echo "准备生成dws层dws_traffic_agg_session表数据......"
#注意未切换库的情况下,要指明表属于哪个库
SQL="
insert into ...
select
...
from yiee.dwd_traffic_dtl
where dt='${dt}'
"
#hive -e 后面的双引号要保留(注意配置hive_home环境变量)
$HIVE_HOME/bin/hive -e "${SQL}"
2.Azkaban任务调度
是一个任务调度工具,由元数据库建表语句
,web界面
,和executor
三部分组成,可以按照顺一定的顺序执行job
,每个job
都可以是一个命令,命令通过参数配置在每个job
的配置文件中,可以单纯打印一句话,也可以执行某个jar包中的类,也可以运行某个sh脚本,多个job
组成一个project
,这个project
所依赖的所有job
配置文件,和资源必须都被打进一个zip
文件夹中,通过web页面上传到azkaban服务器,进行调度或者执行;
具体安装步骤,使用方法,经验总结今天略,明天仔细研究;
1. 关于环境
建立的maven的父子工程,想要打包,于是在父模块上使用maven的install
命令,试图全部打包,但是一直不成功,看日志像是一直无法获取一个阿里云上的scala-lang
的jar包,在父模块的pom文件
中手动引入这个jar包后重新install
还是报同样的错误; 于是先尝试在子模块dw中使用install
命令,不报错,然后在父模块中使用install
命令,也不报错了;
2.关于spark程序
"no such method"
等错误,多半是版本不兼容hadoop_home
的logs
目录下的userlogs
中,找到对应的appid和容器idlogs
命令可以dump
出日志,但需要配置history server
hdfs://host:port/....
hadoop_conf_dir
变量3.关于shell脚本
hive -e
写sql 的话,要注意本来有双引号单引号的地方,要保留双引号和单引号date -d'-1 day' +'%Y-%m-%d'
获得当前日期减一天的日期;date -d"-1 day $1" +'%Y-%m-%d'
获得输入第一个参数的日期减一天(这里将单引号替换成双引号因为单引号会把$1
解释成一个普通的字符串而不是第一个参数)vi
一个文件后,gg
到文首, dG
删除一直到文末的内容(删除全部内容);IDEA
可以直接连接HIVE
,需要启动hiveserver,安装插件并重启idea,然后只需要hostname,端口号和用户名即可1. azkaban 调度器
安装部署步骤比较繁琐,建议按照文档一步一步进行
注意时区的配置,复制证书,修改配置文件,启动mysql数据库,导入mysql数据库的表到azkaban库中;
注意启动webserver时,会在启动路径下(编写命令的当前路径)寻找证书文件,所以要在根目录下执行启动命令;
2. 活跃用户留存
活跃用户留存概念: 某日活跃的用户中,过了几天又活跃了的人占某日总活跃人数的比例,这里强行去join
的话可以做,但是考虑到效率和性能问题,使用拉链表
来完成这个需求;
拉链表记录用户,首登日,连续活跃期间起始日期,连续活跃期间结束日期
能够非常明确地求相应天数之前活跃的人中,在今天仍然活跃的有多少,结合历史数据,就能求出图中右下角的一层数据,每日数据累加,就能求出表中所有数据
1. 关于拉链表
拉链表的思想:
针对状态很长时间不变的数据,使用状态区间来代替每日重复的状态快照,节省空间;当状态不改变时,表就无需改变,当状态改变时,就将原来的区间封闭 (拉链)
,然后生成新的状态区间,已经封闭的区间就不会再改变了;
前提:数据变化的频率不高,如果每天都变化,那么拉链表的意义就不大了(每天都会产生新的状态区间)
2. 关于后台运行命令
&
字符, 将进程在后台运行,(但是输出还是会输出到控制台)ctrl+z
这个操作会将进程挂起(暂停),并不是在后台运行;使用命令 fg+job号
将后台程序拉到前台&
字符前加上 1>/root/std.out 2>/root/err.out
使程序在后台运行并将标准输出输出到std.out文件,错误输出输出到err.out; 这里补充:任何一个程序,都有两个输出(1.标准输出,2.错误输出)和一个输入(0.键盘输入),两个输出默认是输出到控制台上,如果将输出重定向到 1>/dev/null
则是将输出抛弃;访问间隔分析
分析的是一段时间范围内,中间隔了n天才由下一次访问的人数分布情况
窗口函数
拆分依据
(如果什么字段改变了,就重新计算顺序;)和排序依据
(所谓n行之后的后是按照什么顺序而来的;))ranges over unbounded preceding and current row
, 即窗口规格
为所有不比自己大的数)业务路径转化率,按照标准2: 判断是否满足业务C步骤,只要求C事件发生前,前面发生过 B ,B前面发生过A,不要求紧邻的规则来求出完成某业务的某步骤的人数有多少
* 比如,业务定义的步骤事件分别为: A B C D
* 假如,某个人的行为记录为:
* 张三: A A B A B C
* 李四: C D A B C E D
* 王五: A B B C E A D
* 赵六: B C E E D
企业级管理者对企业IT系统的开发有着如下的要求:
1.为提高效率,管理流程必须自动化,即使现代商业规则异常复杂。
2.市场要求业务规则经常变化,IT系统必须依据业务规则的变化快速、低成本的更新。
3.为了快速、低成本的更新,业务人员应能直接管理IT系统中的规则,不需要程序开发人员参与。
def routeMatch(userActions: List[String], transSteps: List[String]): List[Int] = {
// 首先定义一个算法,传入用户的操作顺序,和业务的步骤;
// 求出该用户完成的业务的步骤的list
// userActions : A B B D A C F
// transSteps: A B C D
// 返回: List[1,2,3]
val ints = new ListBuffer[Int]
var index: Int = 0
var flag = true
//这里因为想要取出业务步骤中对应的编号,所以定义角标i
//这里后面发现需要控制条件当某条件达到时跳出循环;
//因为scala中没有break,因此使用for循环的守卫,将达成需要跳出的条件时,将守卫条件改为false
for (i <- transSteps.indices if flag) {
//每次找到元素后,都要记录这个元素的位置,下一次继续从这个位置找
index = userActions.indexOf(transSteps(i), index)
if (index != -1) {
//角标从0开始,但步骤从1开始
ints.+=(i+1)
} else {
flag = false
}
}
ints.toList
}
//...省略前面导入用户数据和字典数据
// 将业务路径定义变成hashmap格式
/**
* Map(
* "t101" -> list[(1,A),(2,B),(3,C),(4,D)]
* "t102" -> list[(1,D),(2,B),(3,C)]
* )
*/
val routeMap: collection.Map[String, List[(Int, String)]] = routeDefine
// 这里拿到的rdd 的格式是row,里面装了四个字段
// T101 1 步骤1 A null
.rdd
.map(row => {
val t_id = row.getAs[String]("tid")
val t_event = row.getAs[String]("event_id")
val t_step = row.getAs[Int]("step")
// 这里是想将row格式,转换为元组模式,有kv,方便分组
// 取序号出来是为了保证顺序不错
// (T101,(1,A))
(t_id, (t_step, t_event))
})
// 这里就真的进行分组了,分组后,每个key的value 是一个迭代器
// T101,(1,A)
.groupByKey()
// 拿到每个迭代器,将这个迭代器直接转换为一个list,并按照指定顺序排序
// T101,List((1,A),(2,B),(3,C),(4,D))
.mapValues(iter => iter.toList.sortBy(_._1))
//将这个字典数据收集到driver端,准备当做广播变量发送出去
.collectAsMap()
// 广播
val bc = spark.sparkContext.broadcast(routeMap)
// 处理用户行为
val x: RDD[((String, String), String)] = userActionRoute
//这里返回一个df[Row]
.select("uid", "sid", "step", "url")
// 这里拿到的rdd是一个row类型的一列value
// Row(u01,session1,1,A)
.rdd
// 将row类型转换为元组类型,把其中的步骤转成数字
// Row(u01,session1,1,A)
.map(row => {
val uid = row.getAs[String]("uid")
val sid = row.getAs[String]("sid")
val step = row.getAs[String]("step")
val url = row.getAs[String]("url")
(uid, sid, step.toInt, url)
})
//这里groupby,是将同样uid和sid,即同一用户的数据放在一组,这个用户的所有行为信息将被放入一个迭代器
//(u01,session1),(u01,session1,1,A)
//(u01,session1),(u01,session1,2,B)
.groupBy(tp => (tp._1, tp._2))
//而这里的mapValues, 又是将这个迭代器中的所有元素按照顺序排好序;
//并将list中的元组map成一个步骤的字符串(从元组中只取出字符串)之后将这个list通过mkString直接变成字符串
.mapValues(iter => {
// 这里就是一个人的一次会话中所有行为事件,并且按次序排好序了
val actList = iter.toList.sortBy(tp => tp._3)
// 将这个人的这些事件中的url拿来拼一个字符串
val actStr = actList.map(_._4).mkString("")
//(u01,session1),"ABSJDUWGS"
actStr
})
// 现在这个RDD就变成一个用户一条了(后面跟他的操作步骤)
// 拿处理好的用户行为记录,去比对业务路径,看满足哪些业务的哪些步骤
// 业务字典:
/**
* bc:
* Map(
* "t101" -> list[(1,A),(2,B),(3,C),(4,D)]
* "t102" -> list[(1,D),(2,B),(3,C)]
* )
*/
// 用户行为 x
//((u01,s01),XYABCBO)
//((u02,s02),ACABDBC)
// TODO 套算法
val res = x
//这里是因为一个tp想对应多行,所以输入tp返回一个list,使用flatmap就会自动分成多行
//输入((u01,s01),XYABCBO)
//输出 (u01,T101,1)
// (u01,T101,2)
.flatMap(tp => {
// 用户id "u01"
val uid = tp._1._1
// 不toString的话拿到的是个char数组
// 用户的行为事件序列 List("X","Y","A","B","C","B","O")
val userActions: List[String] = tp._2.toList.map(c => c.toString)
//业务步骤字典数据
//因为字典中有多条业务的业务步骤,因此,需要遍历这个MAP中的所有业务依次求出用户满足的所有步骤
val transRoutes: collection.Map[String, List[(Int, String)]] = bc.value
// 构造一个listbuff来装结果(uid,t_id,t_step)
val resList = new ListBuffer[(String, String, Int)]
// 遍历每一项业务
// 遍历map的方法, 拿到里面的每个entry
//"t102" -> list[(1,D),(2,B),(3,C)]
for ((k, v) <- transRoutes) {
// 业务中的步骤事件序列
// list(D,B,C) ---> 这是T102(k) 的业务步骤
val transSteps = v.map(_._2)
// 调算法
// 这里拿到的是 该用户 k业务的 完成步骤List
// List(1,2,3)
val okSteps: List[Int] = TransactionRouteMatch.routeMatch(userActions, transSteps)
//那么我们顺势将用户信息和业务信息加上(这个即为最终想要的输出格式)
//List[(u01,T102,1),(u01,T102,2),(u01,T102,3)]
val resOneTrans = okSteps.map(okStep => (uid, k, okStep))
//将该用户的每一个业务list都添加到这个用户的resList中去,
resList ++= resOneTrans
}
//List{List[(U1T1S1),(U1T1S2),(U1T1S3)],List[(U1T2S1),(U1T2S2)]}
//打平后会将每个元组作为一行,即为所求
resList
})
.toDF("uid", "tid", "stepid")
res.show(20, false)
spark.close()
ConcurrentModificationException
,原因是迭代器在调用next方法是会调用checkModification
方法,检查modCount
(集合被修改的次数)和expectedModCount
(迭代器期待集合被修改的次数)是否相等;modCount
为Arraylist
的成员变量(继承自父类AbstractList), expectedModCount
为Arraylist
的内部类Itr
的成员变量,被初始化时会被modCount
赋值; 当调用Arraylist
的remove
方法时,只会修改modCount
,而不会修改expectedModCount
,所以当Itr
调用next
方法时,就会抛出异常; 而Itr自己的remove
方法中对二者进行了赋值处理,保证两者相同,而且对cursor
属性进行了lastRet
赋值,保证了cursor
(Itr的属性) 和size
(ArrayList的属性)相等;Itr
调用next
方法,原因是Itr
的hasNext
方法中判断了ArrayList
的成员变量cursor
和size
的值是否相等,若不相等则返回true
,而通过Arraylist
的remove
方法删除数据时,size
会被减1,但cursor
不会更改,导致两者不相等,hasNext
方法返回true
,还是会调用next
方法;