0%

转载

本文转自 全网最全数仓规范

规范该怎么落地?


1、 规范制定

从 0 到 1,从无到有,这个环节应该有 Leader 或架构师,充分考虑公司实际情况,参考行业标准或约定俗成的规范,综合统一制定。

也可以将规范拆分后交由各个部分核心开发人员编写, Leader 或架构师统一整合。比如我们之前的团队就是,模型设计师负责模型设计规范,ETL 工程师负责 ETL 开发规范,BI 开发人员制定前端开发规范,部署上线规范直接采用项目上已有的即可。

总体上,初稿应该尽量保证规范的完整性和各个部分间的兼容性。

2、规范讨论

初稿完成后,难免有考虑不周的情况,这时候最好有 Leader 牵头,组织部分核心成员(人数不易太多,三五个即可。人多容易造成混乱、决策困难、没有人提意见造成 Leader 一言堂等等问题。)进一步完善各个细节,纠正初稿的不足。

多人共同完善的规范,理论上来讲不会有什么大问题了。

3、规范推行

定稿后,规范已经具备了全面推广的条件,可以下发所有团队成员。

可以通过群聊天,也可以通过正式回邮件的方式,当然为了引起大家的重视,可以专门组会宣讲。

分发宣讲后进入执行阶段,所有人必须严格遵守,如有违犯给予警告,严重的给予惩罚,屡劝不改的取消年终调级调薪等。

为了确保规范的贯彻落实,除了通过以上两点引起全员重视外,还需要组织、制度、流程上的多方面保障。

数据模型应该有统一归口,比如数据架构师,架构师定期检查模型是否合理合规。

组织数据开发人员,定期 Review 每个人的代码,但不必针对个人更不要上纲上线,目的是通过对比和讨论让大家明白什么样的才是好代码,最终使“写好代码”成为基本素养。没有条件的话就有 Leader 负责定期检查,有问题的私下指出来帮助组员逐渐规范。

入职新人,熟读规范后,还应该安排专人指导,是合规性检查的重点关注对象。

讲到这里,大家有没有看出来一个问题?

规范的执行监督,上边提到的,更多是依靠制度流程以及相关人的自觉性,制度流程又依赖于人。这会带来如下几个问题:

短期坚持还好,但长期的专注很难。

有时候人忙起来了,快速产出和规范该选哪个?代码 Review 还要不要做?新建的表要不要找数据架构师审核?

数据建模最好是有专门的人或者小团队去做,其他人使用,这往往会影响整体效率,所以通常都是谁用谁建,但撒出去后再想靠人去检查合规性,真的就太难了。

有条件的最好引入相应的工具加强监管。

比如,我们有指标体系元数据、有词根库元数据、有建表的元数据、有 ETL 流程的元数据等等。

那我们是否可以开发部分报表或其它页面,通过 UI 辅助人去检查,或者通过校验元数据的方法去监管(比如备注是否为空、字段或表命名里的词根是否都在词根库里存在、表或页面等用到的指标是否都存在于指标体系、数据血缘中是否存在闭环或者孤立的节点)。

哈哈,讲了这么多,了解过数据治理的彭友们会不会感觉很熟悉?数仓建设的一开始就需要考虑这些的,最好的管理在于治未病。

5、规范完善

发行稿,从大面上应该不会有啥问题,但细节上可能会有考虑不周的情况,在宣讲阶段、执行阶段遇到问题阻碍的时候,应该根据实际情况对规范做出调整,唯有经过实践检验才能愈发完善,相信经过一段时间的持续实践,规范会成为组织文化的一部分,进而降低沟通成本、提高开发效率、保证交付质量,从而实现团队和个人的双赢。

数仓规范有哪些


这里,我们把数仓规范,一共分为四大类:设计规范、流程规范、质量管理规范、安全规范。

设计规范,又划分为四部分:数据模型设计、命名规范、指标体系设计、词根库。

流程规范,主要是从数仓管理的角度,对数仓场景下的各种流程进行约束。核心流程一共提炼出来五类:需求提交、模型设计、ETL开发、前端开发、上线流程。

质量管控规范,之所以单独列出来,是因为数据质量,跟模型设计一样,对数仓建设的成败关系极大。试想下,一个数据质量都无法保证的数据仓库,有谁会用? 数据质量规范,主要是从数据流动的角度分为三类:源端管控、数仓管理、应用管控。

安全规范,随着国家、社会、企业对数据的越来越重视,另一方面随着互联网的普及使得个人隐私变的越来越难以保证,数据泄露时有发生。数据安全对于数据仓库的重要程度急速提升,所以安全规范被单列了出来。从大的层面上安全规范分为三类:网络安全、账号安全、数据安全。

设计规范

数据模型设计

横向分层

说明

分层设计是数据架构设计的产出之一,在模型设计环节做为强制规范遵守。

分层规范
ODS

贴源层,原始数据不做变化或者仅做最简单的补全后存入。
数据域划分,依据是数据源。

DWD

对数据源做清洗、转换、补全、编码转换后加载到明细数据层。
数据域划分,依据参考下边的纵向分域。

DWS

汇总数据层+主题宽表。
数据域划分,依据参考下边的纵向分域。

ADS

应用层,面向最终应用。

主题域划分,依据是最终应用。生命周期也与应用同步。

层次调用规范

禁止反向调用
ODS 只能被 DWD 调用。
DWD 可以被 DWS 和 ADS 调用。
DWS 只能被 ADS 调用。
数据应用可以调用 DWD、DWS、ADS,但建议优先考虑使用汇总度高的数据。
ODS->DWD->DWS>ADS
ODS->DWD->ADS

纵向分域

定义

主题域通常是联系较为紧密的数据主题的集合,方便寻找和使用数据。

基本原则

高内聚、低耦合。
数量不能太多。建议不超过十个。
必须保持稳定。既能涵盖当 前所有的业务需求,又能在新业务进入时无影响地被包含进已有的数据域中或扩展新的数据域。
需要结合团队和业务的实际情况,比如业务是否稳定、团队成员建模水平等。
适度的抽象。太低不好适应变化,太高不易于理解使用。

分类

数据/业务主题域
依据业务流程划分,实现相对容易。
分析主体域
面向分析场景,实现较难,对业务理解、抽象能力等要求高。

划分依据

按照业务或业务过程划分:比如一个靠销售广告位置的门户网站主题域可能会有广告域,客户域等,而广告域可能就会有广告的库存,销售分析、内部投放分析等主题。

根据需求方划分:比如需求方为财务部,就可以设定对应的财务主题域,而财务主题域里面可能就会有员工工资分析,投资回报比分析等主题。

按照功能或应用划分:比如微信中的朋友圈数据域、群聊数据域等,而朋友圈数据域可能就会有用户动态信息主题、广告主题等。

按照部门划分:比如可能会有运营域、技术域等,运营域中可能会有工资支出分析、活动宣传效果分析等主题。

基本原则

高内聚和低耦合
核心模型与扩展模型分离
公共处理逻辑下沉及单一
成本与性能平衡
数据可回滚
一致性
命名清晰、可理解

附加字段

维表:创建时间、更新时间
事实表:ETL 日期、更新时间

其它要求

表、字段的备注信息,必须言简意赅,在描述清楚的前提下尽量简洁。
字段类型的约束:比如字符串用 String,数值用 Int,年月日都用 String 比如 yyyyMMdd 等。

命名规范

统一规范

采用蛇形命名法,即采用一个下划线分隔词根。
优先使用词根中已有关键字(数仓标准配置中的词根管理),定期 Review 新增命名的不合理性。
禁止采用非标准的缩写。
命名一律采用小写,只能以字母开头。
命名不宜过长。

专有规范


  • 分层-分域-分词根-分时间周期
    正式表,所在层级名称+数据域+表描述+时间周期或加载策略,如增量、快照、拉链/小时、日、周、月、季、年
    中间表,对应正式表+_mid+阿拉伯数字
    临时表,z+创建者姓名检查+表名
  • 视图
    参照表命名规范+_v
  • 字段
    优先从词根中取,多次出现的要增加到词根库
  • 任务
    与目标表名相同
  • 指标
    原子指标
    业务修饰词 + 词根
    衍生指标
    原子指标+时间周期(可选)
    派生指标
    一个原子指标+多个修饰词(可选)+时间周期

代码设计规范

脚本是否有备注、复杂计算逻辑是否有注释释。
任务是否支持多次重跑而输出不变,不能有 insert into 语句。
分区表是否使用分区键过滤并且有有效裁剪。
外连接的过逑条件是否使用正确,例如在左连接的 where 语句存在右表的过滤条件。
关联小表,是否使用/*+ map join * /。
不允许引用别的计算任务临时表。
原则上不允许存在一个任务更新多个目标表。
是否存在笛卡尔积。
禁止在代码里面使用 drop、create、rename 等 DDL 语句。
使用动态分区时,有没有检查分区键值为 NULL 的情况。
对于重要的任务 DQC 质量监控规则是否配置,严禁裸奔。
代码中有没有进行适当的规避数据倾斜语句。

指标体系建设

  • 指标层级划分方式
    • 按分析主题
      • 一级分类
      • 二级分类
    • 按业务过程
      • 一级分类
      • 二级分类
      • 三级分类
  • 指标定义
    • 内容
      • 所属分类
      • 指标类别
      • 名称
      • 描述
      • 口径/算法
      • 计量单位
      • 适用维度
    • 原则
      • 唯一性
      • 可扩展
      • 易理解
    • 类别
      • 原子指标(某一业务事件行为下的度量,不可再拆分的指标) 例如:订单金额
      • 衍生指标(对原子指标进行四则运算)
      • 派生指标(统计周期+统计粒度+业务限定+原子指标)例如:最近一天+新创建的+订单个数(阿里大数据之路对于派生指标的定义:派生指标=原子指标+时间周期修饰词+其它修饰词。唯一归属于某一个原子指标,继承原子指标的数据域)
    • 说明:网上对于指标分类说法不统一,大家知道咋回事儿就行了。搜了一下阿里的大数据之路,没有衍生指标的概念。说法一:衍生指标=派生指标。那么用我上边派生指标的定义即可。说法二:衍生指标是对原子指标进行四则运算得到的。那么衍生指标就是原子指标增加减少几个修饰词或者时间周期扩大缩小后得到的。所以感觉衍生指标有点鸡肋搞不好就变成原子/派生指标了。
  • 指标管理流程
    • 指标新增申请
    • 初审:明确指标口径,检查指标库是否包含
    • 二审:审核指标定义需要的各项元素是否准确完备
    • 入指标库

词根库

  • 定义
    • 把可能会多次用到的短语,集中命名,保证全局范围内的命名含义一致性。
  • 内容
    • 所属分类
    • 名称
    • 英文简称
    • 数据类型
    • 备注
  • 分类
    • 普通词根:描述事物的最小单元体,如:交易-trade。
    • 专有词根:具备约定成俗或行业专属的描述体,如:美元-USD。
  • 公共字段
    • 公共字段=词根组合+其它关键词
    • 公共字段放入词根库不太严谨,但字段命名时候可以直接取用,降低了命名不一致的风险,所以工具化不太完善的公司推荐这样使用。

数仓中的10大陷阱

在《数据仓库工具箱》一书中,提到了数据仓库设计中的十大陷阱。在着手进行数据仓库项目之前,可以先了解这10个常见陷阱,避免在工作中的弯路。

陷阱10

过于迷恋技术和数据,而忘了重点是业务需求和目标

数仓归根结底是要解决业务问题的,对于技术人员,各种高大上的数据架构和层出不穷的新技术通常会比去了解用户需求更具有吸引力。 但是,世界上不存在完美的技术架构,只要是能够满足当下及未来可见的业务需求即可,合适才是最好的。应当把时间投入到理解和梳理业务上,这样才能构建出相对合理的数据模型,从而提高模型的复用性,及时响应业务需求。

陷阱9

没有或找不到一个有影响力的、精通业务、明白事理的高级管理人员作为数仓建设的发起人

说到这点,内心深有体会。之前入职的一家公司,规模也不小,在C轮拿到了阿里几亿的战略投资。我入职后负责从0到1的搭建,心痛的是,全公司竟然找不到一个精通业务还动数据的管理人员,在我多次邮件中申请,才又招了一位高级管理人员。可是毕竟人刚上岗,对业务又不是很熟悉,各个部门都不是很配合,可想而知数仓建设的推进多么痛苦……

数仓建设是多部门合作的结果,只有这样才能够真正的实现数据赋能业务。所以没有高层的支持和重视,数仓的建设将会很难推进。缺乏远见,热情,支持以及公司的资金投资,注定会走向失败。

陷阱8

将项目处理为一个巨大的持续多年的项目,而不是追求更容易管理的、虽然仍然具有挑战性的迭代开发工作

这是一个经常出现的陷阱,试图建设一个庞大的,无所不包的系统,通常是不可取的。似乎只要建设一个“巨型无比“的系统就可以完成任何工作,解决任何问题一样,其实结果往往会适得其反。更糟的是,管理这些项目的人往往没有与业务进行足够详细的协商,从而开发有用的产品。一言以蔽之,挂历上的模特,中看不中用。

陷阱7

分配大量的精力去构建规范化数据结构,在最终呈现数据之前,用尽所有预算

这个陷阱不像其他陷阱一样重要,当然公司有资金,可以随便玩。在Kimball方法论中,对维度模型进行更改所带来的业务风险要比更改源事务数据库小得多,所以应该留出足够的资源来构建它们,但是很少有中小型企业在资源上进行投资以创建完全一致的事实和维度表,更不用说OLAP数据立方体了,所以再多的理论也解决不了实际的问题,先跑起来最重要,不管姿势是否优雅。

陷阱6

将主要精力投入到后端操作型性能和易开发性,而没有重点考虑前端查询的性能和易用性

为用户提供易于阅读的数据展示形式并具有良好的查询性能是优先考虑。

目前我们正在着手做用户路径查询的功能模块,由于前端设计sql过于复杂,性能一直在优化,直到最近上了kudu,体验感好了许多。

陷阱5

存在于应用层的可查询数据设计得过于复杂,应该用过简化解决方案开发出更适合需要的产品

通常,大多数业务用户都希望简化数据表示方式。此外,对这些数据的访问应限于尽可能少入口。提高获取数据的易用性,会大大提升数仓的价值。

其实,这点就引入了数据湖的概念,将数据异构同质化,数据湖的概念会专篇细讲。同时也能发现,无论多么高大上的概念都是基于实际开发中需要解决的问题,脱离了实际,一切都是空谈。

陷阱4

烟囱式开发,不考虑使用可共享的、一致性维度将数据模型联系在一起

开发中常提到维度的一致性,当维度在整个数据仓库中不一致时,就是典型的烟囱式开发。其实,我们使用的维度在本质上是相同的,但是由于数据来自于不同的业务源,并会被随意更新。

典型的例子是“时间”维度,在维模型不一致的情况下,最终用户通常完全不知道为什么一个报表中的数据可能与其他地方生成的报表有显着差异。一种好的做法是将数据模型与主数据管理解决方案联系在一起,该解决方案包含可以在整个数据仓库中普遍使用的参考数据。

陷阱3

只将汇总数据加载到数据仓库中

在事务数据库和数据仓库之间创建的每个ETL过程中,必须保证要有至少一份原子数据存储到数仓中,方便溯源,即将数据同步一份放在准备区(ODS层)

陷阱2

臆想业务、业务需求及分析,其涉及的数据及支持技术都是静态的

我们常提到面向企业级的数据模型,什么意思呢?就是尽量不要开发仅限于某个特定业务需求和分析的数据模型,因为业务在不断地发生变化。一个差劲的模型设计通常是开发了重复的数据模型以及命名约定不一致的。

在设计一个“完美”的事实表、维表与规范化程度之间取得平衡并不是一件容易的事情,但是开发出可伸缩的以适应业务发展的数据模型是非常重要的。这就对业务的理解能力要求很高了。

陷阱1

数据仓库是否成功直接取决于业务人员。如果用户不买账,那么所有的工作都是徒劳

这个是很致命的陷阱,如果从一开始都没有得到业务和高层的重视和认可,那么数仓项目多半是会夭折。从用户的角度出发,如果用户对数仓的数据存在怀疑,对易用性存在吐槽,或者根本点讲未将数据仓库系统当成他们制定决策的基础,那么根本就不会去使用它,结局只会game over。

数仓中的一些常见坏现象有哪些

  • 接到了一个需求,不知道该从那张表出数,表A貌似可以,表B好像也行。问了同事甲,他说他每次都是从C表出的。对着三张表探索了好久,发现谁跟谁都对不上,算了吧,我从源头再算一次吧,结果又变出来一张表D。

  • 数据库里几千张表,好像我用到的也就那么十几张,其它的都是干啥用的呢,问了一圈没有人知道,删掉吧?更没有人敢动。

  • 有个流程报错了,领导让我去看一下,点进去后,屎一样的代码完全看不懂,另外,找了好久死活找不到上游依赖。

  • 有位同事要离职,他负责的那部分内容,换了一个人接手,累死累活好多天依然捋不出个所以然,一气之下又走了一个人。

由于以上种种问题,造成数仓团队的整体开发效率、产出质量、工作幸福感、数仓维护成本等等越来越差。随着人员流动,通常受累的往往是那些任劳任怨、对公司忠诚的员工。

相信做过数据开发的人,多多少少都会有过上边提到的部分苦恼。我觉得问题的根源通常在于没有规范或者规范没有得到贯彻。

OLAP

OLAP和OLTP不同的是,表中单条记录本身并不是查询所关心的,比较典型的特点包括有聚合类算子、涉及多表Join,查询所用谓语/条件没有索引。由于这些操作都非常耗计算资源,而且数据仓库相比数据库在数据量上大很多,因此,OLAP类查询经常表现为cpu-bound而不是io-bound。

按照建模类型划分:

MOLAP

这应该算最传统的数仓了,1993年olap概念提出来时,指的就是MOLAP数仓,M即表示多维。大多数MOLAP产品均对原始数据进行预计算得到用户可能需要的所有结果,将其存储到优化过的多维数组中,也就是常听到的 数据立方体

由于所有可能结果均已计算出来并持久化存储,查询时无需进行复杂计算,且以数组形式可以进行高效的免索引数据访问,因此用户发起的查询均能够稳定地快速响应。这些结果集是高度结构化的,可以进行压缩/编码来减少存储占用空间。

但高性能并不是没有代价的。首先,MOLAP需要进行预计算,这会花去很多时间。如果每次写入增量数据后均要进行全量预计算,显然是低效率的,因此支持仅对增量数据进行迭代计算非常重要。其次,如果业务发生需求变更,需要进行预定模型之外新的查询操作,现有的MOLAP实例就无能为力了,只能重新进行建模和预计算。

在开源软件中,由eBay开发并贡献给Apache基金会的Kylin即属于这类OLAP引擎,支持在百亿规模的数据集上进行亚秒级查询。

下图是官方对Kylin的描述。

kylin

代表

  • Kylin是完全的预计算引擎,通过枚举所有维度的组合,建立各种Cube进行提前聚合,以HBase为基础的OLAP引擎。
  • Druid则是轻量级的提前聚合(roll-up),同时根据倒排索引以及bitmap提高查询效率的时间序列数据和存储引擎。

优点

  • Kylin
  1. 支持数据规模超大(HBase)
  2. 易用性强,支持标准SQL
  3. 性能很高,查询速度很快
  • Druid
  1. 支持的数据规模大(本地存储+DeepStorage–HDFS)
  2. 性能高,列存压缩,预聚合加上倒排索引以及位图索引,秒级查询
  3. 实时性高,可以通过kafka实时导入数据

缺点

  • Kylin
  1. 灵活性较弱,不支持adhoc查询;且没有二级索引,过滤时性能一般;不支持join以及对数据的更新。
  2. 处理方式复杂,需要定义Cube预计算;当维度超过20个时,存储可能会爆炸式增长;且无法查询明细数据了;维护复杂。
  3. 实时性很差,很多时候只能查询前一天或几个小时前的数据。
  • Druid
  1. 灵活性适中,虽然维度之间随意组合,但不支持adhoc查询,不能自由组合查询,且丢失了明细数据。
  2. 易用性较差,不支持join,不支持更新,sql支持很弱(有些插件类似于pinot的PQL语言),只能JSON格式查询;对于去重操作不能精准去重。
  3. 处理方式复杂,需要流处理引擎将数据join成宽表,维护相对复杂;对内存要求较高。

场景

  • Kylin:适合对实时数据需求不高,但响应时间较高的查询,且维度较多,需求较为固定的特定查询;而不适合实时性要求高的adhoc类查询。
  • Druid:数据量大,对实时性要求高且响应时间短,以及维度较少且需求固定的简单聚合类查询(sum,count,TopN),多以Storm和Flink组合进行预处理;而不适合需要join、update和支持SQL和窗口函数等复杂的adhoc查询;不适合用于SQL复杂数据分析的场景。

ROLAP

与MOLAP相反,ROLAP无需预计算,直接在构成多维数据模型的事实表和维度表上进行计算。R即表示关系型(Relational)。显然,这种方式相比MOLAP更具可扩展性,增量数据导入后,无需进行重新计算,用户有新的查询需求时只需写好正确的SQL语句既能完成获取所需的结果。

但ROLAP的不足也很明显,尤其是在数据体量巨大的场景下,用户提交SQL后,获取查询结果所需的时间无法准确预知,可能秒回,也可能需要花费数十分钟甚至数小时。本质上,ROLAP是把MOLAP预计算所需的时间分摊到了用户的每次查询上,肯定会影响用户的查询体验。

当然ROLAP的性能是否能够接受,取决于用户查询的SQL类型,数据规模以及用户对性能的预期。对于相对简单的SQL,比如TPCH中的Query响应时间较快。但如果是复杂SQL,比如TPC-DS中的数据分析和挖掘类的Query,可能需要数分钟。

相比MOLAP,ROLAP的使用门槛更低,在完成星型或雪花型模型的构建,创建对应schema的事实表和维度表并导入数据后,用户只需会写出符合需求的SQL,就可以得到想要的结果。相比创建 数据立方体,显然更加方便。

目前生产环境使用较多的开源ROLAP主要可以分为2大类,一个是宽表模型,另一个是多表组合模型(就是前述的星型或雪花型)。

宽表类型

宽表模型能够提供比多表组合模型更好的查询性能,不足的是支持的SQL操作类型比较有限,比如对Join等复杂操作支持较弱或不支持。

目前该类OLAP系统包括DruidClickHouse等,两者各有优势,Druid支持更大的数据规模,具备一定的预聚合能力,通过倒排索引和位图索引进一步优化查询性能,在广告分析场景、监控报警等时序类应用均有广泛使用;ClickHouse部署架构简单,易用,保存明细数据,依托其向量化查询、减枝等优化能力,具备强劲的查询性能。两者均具备较高的数据实时性,在互联网企业均有广泛使用。

除了上面介绍的Druid和ClickHouse外,ElasticSearch和Solar也可以归为宽表模型。但其系统设计架构有较大不同,这两个一般称为搜索引擎,通过倒排索引,应用Scatter-Gather计算模型提高查询性能。对于搜索类的查询效果较好,但当数据量较大或进行扫描聚合类查询时,查询性能会有较大影响。

代表

  • ClickHouse是个列存数据库,保存原始明细数据,通过MergeTree使得数据存储本地化来提高性能。 是个单机版超高性能的数据库

优点

  1. 性能高,列存压缩比高,通过索引实现秒级响应
  2. 实时性强,支持kafka导入
  3. 处理方式简单,无需预处理,保存明细数据

缺点

  1. 数据规模一般
  2. 灵活性差,不支持任意的adhoc查询,join的支持不好。
  3. 易用性较弱,SQL语法不标准,不支持窗口函数等;维护成本高

多表组合模型

采用星型或雪花型建模是最通用的一种ROLAP系统,常见的包括GreenPlumPrestoImpala等,他们均基于MPP架构,采用该模型和架构的系统具有支持的数据量大、扩展性较好、灵活易用和支持的SQL类型多样等优点。

相比其他类型ROLAP和MOLAP,该类系统性能不具有优势,实时性较一般。通用系统往往比专用系统更难实现和进行优化,这是因为通用系统需要考虑的场景更多,支持的查询类型更丰富。而专用系统只需要针对所服务的某个特定场景进行优化即可,相对复杂度会有所降低。

对于ROLAP系统,尤其是星型或雪花型的系统,如果能够尽可能得缩短响应时间非常重要,这将是该系统的核心竞争力。

代表

  • PrestoImpala以及Spark SQL等利用关系模型来处理OLAP查询,通过并发来提高查询性能。同时三者是有很多相似点。我日常工作中,接触最多也就是这三兄弟和一个大哥(Hive)。Hive就不多谈了,是基于MR最基础的OLAP引擎,也是对于大数据量的分析支持最好得。

优点

  1. 支持的计算数据规模大(非存储引擎)
  2. 灵活性高,随意查询数据
  3. 易用性强,支持标准SQL以及多表join和窗口函数
  4. 处理方式简单,无需预处理,全部后处理,没有冗余数据

缺点

  1. 性能较差,当查询复杂度高且数据量大时,可能分钟级别的响应。同时其不是存储引擎,因此没有本地存储,当join时shuffle开销大,性能差
    举例:SparkSql为例子,其只是计算引擎,导致需要从外部加载数据,从而数据的实时性得不到保证;多表join的时候性能也很难得到秒级的响应。
  2. 实时性较差,不支持数据的实时导入,偏离线处理。 如果需要实时数据,经常的做法是Presto或者Impala和Kudu的结合,解决了Kudu的磁盘存储问题,实时性能也不会太差。

HOLAP

MOLAP和ROLAP各有优缺点,而且是互斥的。如果能够将两者的优点进行互补,那么是个更好的选择。而HOLAP的出现就是这个目的,H表示混合型(Hybrid),这个想法很朴素直接。对于查询频繁而稳定但又耗时的那些SQL,通过预计算来提速;对于较快的查询、发生次数较少或新的查询需求,像ROLAP一样直接通过SQL操作事实表和维度表。

目前似乎没有开源的OLAP系统属于这个类型。相信未来HOLAP可能会得到进一步发展,并获得更大规模的使用。

用户标签画像分为哪几类

用户画像建模其实就是对用户“打标签”, 从打标签的方式来看,一般分为三种类型:统计类、规则类、挖掘类

统计类

统计类标签又被习惯性叫作定量标签,通过客观数据计算得来。例如,性别、年龄、注册日期、近七天登录频率、近30天充值金额。这些字段都可以从用户的注册数据、登录数据、充值数据中统计得出。

统计类标签在实时性上还可以细分成离线标签和流式标签。这就要根据业务需求来定,如果实时性要求比较高那就必须把流式标签考虑进去。

此类标签构成了用户画像的基础。

规则类

规则类标签基于用户行为及确定的规则产生,也被叫作定性标签。在实际开发过程中,一般由业务人员及开发人员共同协商确定。

例如,对平台上”大R”用户的定义口径为“月登陆次数>10 && 月充值次数>20 && 月充值额度>1000元”。

挖掘类

该类标签通过机器学习挖掘产生,对用户的某些行为进行预测。机器学习开发周期长,成本高。

例如,针对用户最近的登陆频率以及相关行为预测用户流失预警。

一、星型模型

星型模型:当所有的维度表都是和事实表直接相连的时候,整个图形看上去就像是一个星星,我们称之为星型模型。星型模型是一种非正规化的架构,因为多维数据集的每一个维度都和事实表直接相连,不存在渐变维度,所以有一定的数据冗余,因为有数据的冗余,很多的统计情况下,不需要和外表关联进行查询和数据分析,因此效率相对较高。

二、雪花模型

雪花模型:当有多个维度表没有直接和事实表相连,而是通过其它的维度表,间接的连接在事实表上,其图形就像是一个雪花,因此我们称之为雪花模型,雪花模型的优点是减少了数据冗余,在进行数据统计或分析的时候,需要和其他的表进行关联。

三、区别

1、查询性能角度来看

星形模型做了字段冗余,不需要多表关联,性能较高。
雪花型要做多个表联接,性能会低于星型架构;

2、模型复杂度角度

星型模型设计简单
雪花模型设计复杂

3、可扩展性

星形模型可扩展性低
雪花模型可扩展性高

4、存储角度

雪花型架构具有关系数据模型的所有优点,不会产生冗余数据,
而相比之下星型架构会产生数据冗余。

两者的区别

1、分区和分桶都是细化数据管理,但是分区表是手动添加区分,由于hive是读模式,所以对添加进分区的数据不做模式校验。分桶表的数据时按住某些分桶字段进行hash散列 相乘的多个文件,所以数据的准确性高很多

2、分区表是指按照数据表的某列或某些列分为多个区,区从形式上可以理解为文件夹

3、分桶是相对分区进行更细粒度的划分。分桶将整个数据内容按照某列属性值的hash值进行区分,如要按照name属性分为3个桶,就是对name属性值的hash值对3取摸,按照取模结果对数据分桶。如取模结果为0的数据记录存放到一个文件,取模为1的数据存放到一个文件,取模为2的数据存放到一个文件

归纳总结两者的区别:

1、从概念是:
分区是将数据集分类为n个数据集
分桶是将一个完整的数据集分成若干部分

2、从表现形式上:
分区表是一个目录,分桶表是文件

3、从创建语句上:
分区表使用partitioned by 子句指定,以指定字段为伪列,需要指定字段类型
分桶表由clustered by 子句指定,指定字段为真实字段,需要指定桶的个数

4、从数量上:
分区表的分区个数可以增长,分桶表一旦指定,不能再增长

5、从作用上:
分区避免全表扫描,根据分区列查询指定目录提高查询速度
分桶保存分桶查询结果的分桶结构(数据已经按照分桶字段进行了hash散列)。
分桶表数据进行抽样和JOIN时可以提高MR程序效率

参考

1、一分钟搞明白hive分区表和分桶表的区别

转载

本文转自IO 流简介

IO 流简介

IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。

Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。

  • InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。
  • OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。

字节流

InputStream(字节输入流)

InputStream用于从源头(通常是文件)读取数据(字节信息)到内存中,java.io.InputStream抽象类是所有字节输入流的父类。

InputStream 常用方法 :

  • read() :返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回 -1 ,表示文件结束。
  • read(byte b[ ]) : 从输入流中读取一些字节存储到数组 b 中。如果数组 b 的长度为零,则不读取。如果没有可用字节读取,返回 -1。如果有可用字节读取,则最多读取的字节数最多等于 b.length , 返回读取的字节数。这个方法等价于 read(b, 0, b.length)
  • read(byte b[], int off, int len) :在read(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。
  • skip(long n) :忽略输入流中的 n 个字节 ,返回实际忽略的字节数。
  • available() :返回输入流中可以读取的字节数。
  • close() :关闭输入流释放相关的系统资源。

从 Java 9 开始,InputStream 新增加了多个实用的方法:

  • readAllBytes() :读取输入流中的所有字节,返回字节数组。
  • readNBytes(byte[] b, int off, int len) :阻塞直到读取 len 个字节。
  • transferTo(OutputStream out) : 将所有字节从一个输入流传递到一个输出流。

FileInputStream 是一个比较常用的字节输入流对象,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中。

FileInputStream 代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
try (InputStream fis = new FileInputStream("input.txt)) {
System.out.println("Number of remaining bytes:"
+ fis.available());
int content;
long skip = fis.skip(2);
System.out.println("The actual number of bytes skipped:" + skip);
System.out.print("The content read from file:");
while ((content = fis.read()) != -1) {
System.out.print((char) content);
}
} catch (IOException e) {
e.printStackTrace();
}

input.txt 文件内容:

输出:

1
2
3
Number of remaining bytes:11
The actual number of bytes skipped:2
The content read from file:JavaGuide

不过,一般我们是不会直接单独使用 FileInputStream ,通常会配合 BufferedInputStream(字节缓冲输入流,后文会讲到)来使用。

像下面这段代码在我们的项目中就比较常见,我们通过 readAllBytes() 读取输入流所有字节并将其直接赋值给一个 String 对象。

1
2
3
4
5
// 新建一个 BufferedInputStream 对象
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));
// 读取文件的内容并复制到 String 对象中
String result = new String(bufferedInputStream.readAllBytes());
System.out.println(result);

DataInputStream 用于读取指定类型数据,不能单独使用,必须结合 FileInputStream

1
2
3
4
5
6
7
FileInputStream fileInputStream = new FileInputStream("input.txt");
//必须将fileInputStream作为构造参数才能使用
DataInputStream dataInputStream = new DataInputStream(fileInputStream);
//可以读取任意具体的类型数据
dataInputStream.readBoolean();
dataInputStream.readInt();
dataInputStream.readUTF();

ObjectInputStream 用于从输入流中读取 Java 对象(反序列化),ObjectOutputStream 用于将对象写入到输出流(序列化)。

1
2
3
ObjectInputStream input = new ObjectInputStream(new FileInputStream("object.data"));
MyClass object = (MyClass) input.readObject();
input.close();

另外,用于序列化和反序列化的类必须实现 Serializable 接口,对象中如果有属性不想被序列化,使用 transient 修饰。

OutputStream(字节输出流)

OutputStream用于将数据(字节信息)写入到目的地(通常是文件),java.io.OutputStream抽象类是所有字节输出流的父类。

OutputStream 常用方法 :

  • write(int b) :将特定字节写入输出流。
  • write(byte b[ ]) : 将数组b 写入到输出流,等价于 write(b, 0, b.length)
  • write(byte[] b, int off, int len) : 在write(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。
  • flush() :刷新此输出流并强制写出所有缓冲的输出字节。
  • close() :关闭输出流释放相关的系统资源。

FileOutputStream 是最常用的字节输出流对象,可直接指定文件路径,可以直接输出单字节数据,也可以输出指定的字节数组。

FileOutputStream 代码示例:

1
2
3
4
5
6
try (FileOutputStream output = new FileOutputStream("output.txt")) {
byte[] array = "JavaGuide".getBytes();
output.write(array);
} catch (IOException e) {
e.printStackTrace();
}

运行结果:

类似于 FileInputStreamFileOutputStream 通常也会配合 BufferedOutputStream(字节缓冲输出流,后文会讲到)来使用。

1
2
FileOutputStream fileOutputStream = new FileOutputStream("output.txt");
BufferedOutputStream bos = new BufferedOutputStream(fileOutputStream)

DataOutputStream 用于写入指定类型数据,不能单独使用,必须结合 FileOutputStream

1
2
3
4
5
6
// 输出流
FileOutputStream fileOutputStream = new FileOutputStream("out.txt");
DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
// 输出任意数据类型
dataOutputStream.writeBoolean(true);
dataOutputStream.writeByte(1);

ObjectOutputStream 用于从输入流中读取 Java 对象(ObjectInputStream,反序列化)或者将对象写入到输出流(ObjectOutputStream,序列化)。

1
2
3
ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream("file.txt")
Person person = new Person("Guide哥", "JavaGuide作者");
output.writeObject(person);

字符流

不管是文件读写还是网络发送接收,信息的最小存储单元都是字节。 那为什么 I/O 流操作要分为字节流操作和字符流操作呢?

个人认为主要有两点原因:

  • 字符流是由 Java 虚拟机将字节转换得到的,这个过程还算是比较耗时。
  • 如果我们不知道编码类型就很容易出现乱码问题。

乱码问题这个很容易就可以复现,我们只需要将上面提到的 FileInputStream 代码示例中的 input.txt 文件内容改为中文即可,原代码不需要改动。

输出:

1
2
3
Number of remaining bytes:9
The actual number of bytes skipped:2
The content read from file:§å®¶å¥½

可以很明显地看到读取出来的内容已经变成了乱码。

因此,I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。

字符流默认采用的是 Unicode 编码,我们可以通过构造方法自定义编码。顺便分享一下之前遇到的笔试题:常用字符编码所占字节数?utf8 :英文占 1 字节,中文占 3 字节,unicode:任何字符都占 2 个字节,gbk:英文占 1 字节,中文占 2 字节。

Reader(字符输入流)

Reader用于从源头(通常是文件)读取数据(字符信息)到内存中,java.io.Reader抽象类是所有字符输入流的父类。

Reader 用于读取文本, InputStream 用于读取原始字节。

Reader 常用方法 :

  • read() : 从输入流读取一个字符。
  • read(char[] cbuf) : 从输入流中读取一些字符,并将它们存储到字符数组 cbuf中,等价于 read(cbuf, 0, cbuf.length)
  • read(char[] cbuf, int off, int len) :在read(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。
  • skip(long n) :忽略输入流中的 n 个字符 ,返回实际忽略的字符数。
  • close() : 关闭输入流并释放相关的系统资源。

InputStreamReader 是字节流转换为字符流的桥梁,其子类 FileReader 是基于该基础上的封装,可以直接操作字符文件。

1
2
3
4
5
6
// 字节流转换为字符流的桥梁
public class InputStreamReader extends Reader {
}
// 用于读取字符文件
public class FileReader extends InputStreamReader {
}

FileReader 代码示例:

1
2
3
4
5
6
7
8
9
10
11
try (FileReader fileReader = new FileReader("input.txt");) {
int content;
long skip = fileReader.skip(3);
System.out.println("The actual number of bytes skipped:" + skip);
System.out.print("The content read from file:");
while ((content = fileReader.read()) != -1) {
System.out.print((char) content);
}
} catch (IOException e) {
e.printStackTrace();
}

input.txt 文件内容:

输出:

1
2
The actual number of bytes skipped:3
The content read from file:我是Guide。

Writer(字符输出流)

Writer用于将数据(字符信息)写入到目的地(通常是文件),java.io.Writer抽象类是所有字节输出流的父类。

Writer 常用方法 :

  • write(int c) : 写入单个字符。
  • write(char[] cbuf) :写入字符数组 cbuf,等价于write(cbuf, 0, cbuf.length)
  • write(char[] cbuf, int off, int len) :在write(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。
  • write(String str) :写入字符串,等价于 write(str, 0, str.length())
  • write(String str, int off, int len) :在write(String str) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。
  • append(CharSequence csq) :将指定的字符序列附加到指定的 Writer 对象并返回该 Writer 对象。
  • append(char c) :将指定的字符附加到指定的 Writer 对象并返回该 Writer 对象。
  • flush() :刷新此输出流并强制写出所有缓冲的输出字符。
  • close():关闭输出流释放相关的系统资源。

OutputStreamWriter 是字符流转换为字节流的桥梁,其子类 FileWriter 是基于该基础上的封装,可以直接将字符写入到文件。

1
2
3
4
5
6
// 字符流转换为字节流的桥梁
public class InputStreamReader extends Reader {
}
// 用于写入字符到文件
public class FileWriter extends OutputStreamWriter {
}

FileWriter 代码示例:

1
2
3
4
5
try (Writer output = new FileWriter("output.txt")) {
output.write("你好,我是Guide。");
} catch (IOException e) {
e.printStackTrace();
}

输出结果:

字节缓冲流

IO 操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁的 IO 操作,提高流的传输效率。

字节缓冲流这里采用了装饰器模式来增强 InputStreamOutputStream子类对象的功能。

举个例子,我们可以通过 BufferedInputStream(字节缓冲输入流)来增强 FileInputStream 的功能。

1
2
// 新建一个 BufferedInputStream 对象
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));

字节流和字节缓冲流的性能差别主要体现在我们使用两者的时候都是调用 write(int b)read() 这两个一次只读取一个字节的方法的时候。由于字节缓冲流内部有缓冲区(字节数组),因此,字节缓冲流会先将读取到的字节存放在缓存区,大幅减少 IO 次数,提高读取效率。

我使用 write(int b)read() 方法,分别通过字节流和字节缓冲流复制一个 524.9 mb 的 PDF 文件耗时对比如下:

1
2
使用缓冲流复制PDF文件总耗时:15428 毫秒
使用普通字节流复制PDF文件总耗时:2555062 毫秒

两者耗时差别非常大,缓冲流耗费的时间是字节流的 1/165。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Test
void copy_pdf_to_another_pdf_buffer_stream() {
// 记录开始时间
long start = System.currentTimeMillis();
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("深入理解计算机操作系统.pdf"));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("深入理解计算机操作系统-副本.pdf"))) {
int content;
while ((content = bis.read()) != -1) {
bos.write(content);
}
} catch (IOException e) {
e.printStackTrace();
}
// 记录结束时间
long end = System.currentTimeMillis();
System.out.println("使用缓冲流复制PDF文件总耗时:" + (end - start) + " 毫秒");
}

@Test
void copy_pdf_to_another_pdf_stream() {
// 记录开始时间
long start = System.currentTimeMillis();
try (FileInputStream fis = new FileInputStream("深入理解计算机操作系统.pdf");
FileOutputStream fos = new FileOutputStream("深入理解计算机操作系统-副本.pdf")) {
int content;
while ((content = fis.read()) != -1) {
fos.write(content);
}
} catch (IOException e) {
e.printStackTrace();
}
// 记录结束时间
long end = System.currentTimeMillis();
System.out.println("使用普通流复制PDF文件总耗时:" + (end - start) + " 毫秒");
}

如果是调用 read(byte b[])write(byte b[], int off, int len) 这两个写入一个字节数组的方法的话,只要字节数组的大小合适,两者的性能差距其实不大,基本可以忽略。

这次我们使用 read(byte b[])write(byte b[], int off, int len) 方法,分别通过字节流和字节缓冲流复制一个 524.9 mb 的 PDF 文件耗时对比如下:

1
2
使用缓冲流复制PDF文件总耗时:695 毫秒
使用普通字节流复制PDF文件总耗时:989 毫秒

两者耗时差别不是很大,缓冲流的性能要略微好一点点。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Test
void copy_pdf_to_another_pdf_with_byte_array_buffer_stream() {
// 记录开始时间
long start = System.currentTimeMillis();
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("深入理解计算机操作系统.pdf"));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("深入理解计算机操作系统-副本.pdf"))) {
int len;
byte[] bytes = new byte[4 * 1024];
while ((len = bis.read(bytes)) != -1) {
bos.write(bytes, 0, len);
}
} catch (IOException e) {
e.printStackTrace();
}
// 记录结束时间
long end = System.currentTimeMillis();
System.out.println("使用缓冲流复制PDF文件总耗时:" + (end - start) + " 毫秒");
}

@Test
void copy_pdf_to_another_pdf_with_byte_array_stream() {
// 记录开始时间
long start = System.currentTimeMillis();
try (FileInputStream fis = new FileInputStream("深入理解计算机操作系统.pdf");
FileOutputStream fos = new FileOutputStream("深入理解计算机操作系统-副本.pdf")) {
int len;
byte[] bytes = new byte[4 * 1024];
while ((len = fis.read(bytes)) != -1) {
fos.write(bytes, 0, len);
}
} catch (IOException e) {
e.printStackTrace();
}
// 记录结束时间
long end = System.currentTimeMillis();
System.out.println("使用普通流复制PDF文件总耗时:" + (end - start) + " 毫秒");
}

BufferedInputStream(字节缓冲输入流)

BufferedInputStream 从源头(通常是文件)读取数据(字节信息)到内存的过程中不会一个字节一个字节的读取,而是会先将读取到的字节存放在缓存区,并从内部缓冲区中单独读取字节。这样大幅减少了 IO 次数,提高了读取效率。

BufferedInputStream 内部维护了一个缓冲区,这个缓冲区实际就是一个字节数组,通过阅读 BufferedInputStream 源码即可得到这个结论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public
class BufferedInputStream extends FilterInputStream {
// 内部缓冲区数组
protected volatile byte buf[];
// 缓冲区的默认大小
private static int DEFAULT_BUFFER_SIZE = 8192;
// 使用默认的缓冲区大小
public BufferedInputStream(InputStream in) {
this(in, DEFAULT_BUFFER_SIZE);
}
// 自定义缓冲区大小
public BufferedInputStream(InputStream in, int size) {
super(in);
if (size <= 0) {
throw new IllegalArgumentException("Buffer size <= 0");
}
buf = new byte[size];
}
}

缓冲区的大小默认为 8192 字节,当然了,你也可以通过 BufferedInputStream(InputStream in, int size) 这个构造方法来指定缓冲区的大小。

BufferedOutputStream(字节缓冲输出流)

BufferedOutputStream 将数据(字节信息)写入到目的地(通常是文件)的过程中不会一个字节一个字节的写入,而是会先将要写入的字节存放在缓存区,并从内部缓冲区中单独写入字节。这样大幅减少了 IO 次数,提高了读取效率

1
2
3
4
5
6
try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("output.txt"))) {
byte[] array = "JavaGuide".getBytes();
bos.write(array);
} catch (IOException e) {
e.printStackTrace();
}

类似于 BufferedInputStreamBufferedOutputStream 内部也维护了一个缓冲区,并且,这个缓存区的大小也是 8192 字节。

字符缓冲流

BufferedReader (字符缓冲输入流)和 BufferedWriter(字符缓冲输出流)类似于 BufferedInputStream(字节缓冲输入流)和BufferedOutputStream(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。不过,前者主要是用来操作字符信息。

打印流

下面这段代码大家经常使用吧?

1
2
System.out.print("Hello!");
System.out.println("Hello!");

System.out 实际是用于获取一个 PrintStream 对象,print方法实际调用的是 PrintStream 对象的 write 方法。

PrintStream 属于字节打印流,与之对应的是 PrintWriter (字符打印流)。PrintStreamOutputStream 的子类,PrintWriterWriter 的子类。

1
2
3
4
5
public class PrintStream extends FilterOutputStream
implements Appendable, Closeable {
}
public class PrintWriter extends Writer {
}

随机访问流

这里要介绍的随机访问流指的是支持随意跳转到文件的任意位置进行读写的 RandomAccessFile

RandomAccessFile 的构造方法如下,我们可以指定 mode(读写模式)。

1
2
3
4
5
6
7
8
9
// openAndDelete 参数默认为 false 表示打开文件并且这个文件不会被删除
public RandomAccessFile(File file, String mode)
throws FileNotFoundException {
this(file, mode, false);
}
// 私有方法
private RandomAccessFile(File file, String mode, boolean openAndDelete) throws FileNotFoundException{
// 省略大部分代码
}

读写模式主要有下面四种:

  • r : 只读模式。
  • rw: 读写模式
  • rws: 相对于 rwrws 同步更新对“文件的内容”或“元数据”的修改到外部存储设备。
  • rwd : 相对于 rwrwd 同步更新对“文件的内容”的修改到外部存储设备。

文件内容指的是文件中实际保存的数据,元数据则是用来描述文件属性比如文件的大小信息、创建和修改时间。

RandomAccessFile 中有一个文件指针用来表示下一个将要被写入或者读取的字节所处的位置。我们可以通过 RandomAccessFileseek(long pos) 方法来设置文件指针的偏移量(距文件开头 pos 个字节处)。如果想要获取文件指针当前的位置的话,可以使用 getFilePointer() 方法。

RandomAccessFile 代码示例:

1
2
3
4
5
6
7
8
9
10
RandomAccessFile randomAccessFile = new RandomAccessFile(new File("input.txt"), "rw");
System.out.println("读取之前的偏移量:" + randomAccessFile.getFilePointer() + ",当前读取到的字符" + (char) randomAccessFile.read() + ",读取之后的偏移量:" + randomAccessFile.getFilePointer());
// 指针当前偏移量为 6
randomAccessFile.seek(6);
System.out.println("读取之前的偏移量:" + randomAccessFile.getFilePointer() + ",当前读取到的字符" + (char) randomAccessFile.read() + ",读取之后的偏移量:" + randomAccessFile.getFilePointer());
// 从偏移量 7 的位置开始往后写入字节数据
randomAccessFile.write(new byte[]{'H', 'I', 'J', 'K'});
// 指针当前偏移量为 0,回到起始位置
randomAccessFile.seek(0);
System.out.println("读取之前的偏移量:" + randomAccessFile.getFilePointer() + ",当前读取到的字符" + (char) randomAccessFile.read() + ",读取之后的偏移量:" + randomAccessFile.getFilePointer());

input.txt 文件内容:

输出:

1
2
3
读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1
读取之前的偏移量:6,当前读取到的字符G,读取之后的偏移量:7
读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1

input.txt 文件内容变为 ABCDEFGHIJK

RandomAccessFilewrite 方法在写入对象的时候如果对应的位置已经有数据的话,会将其覆盖掉。

1
2
RandomAccessFile randomAccessFile = new RandomAccessFile(new File("input.txt"), "rw");
randomAccessFile.write(new byte[]{'H', 'I', 'J', 'K'});

假设运行上面这段程序之前 input.txt 文件内容变为 ABCD ,运行之后则变为 HIJK

RandomAccessFile 比较常见的一个应用就是实现大文件的 断点续传 。何谓断点续传?简单来说就是上传文件中途暂停或失败(比如遇到网络问题)之后,不需要重新上传,只需要上传那些未成功上传的文件分片即可。分片(先将文件切分成多个文件分片)上传是断点续传的基础。

RandomAccessFile 可以帮助我们合并文件分片,示例代码如下:

我在《Java 面试指北》中详细介绍了大文件的上传问题。

RandomAccessFile 的实现依赖于 FileDescriptor (文件描述符) 和 FileChannel (内存映射文件)。

转载

本文转载自归并排序

简介

归并排序是稳定排序,它也是一种十分高效的排序,能利用完全二叉树特性的排序一般性能都不会太差。java中Arrays.sort()采用了一种名为TimSort的排序算法,就是归并排序的优化版本。从上文的图中可看出,每次合并操作的平均时间复杂度为$O(n)$,而完全二叉树的深度为$|log2n|$。总的平均时间复杂度为$O(nlogn)$。而且,归并排序的最好,最坏,平均时间复杂度均为$O(nlogn)$。

基本思想

归并排序(MERGE-SORT)是利用归并的思想实现的排序方法,该算法采用经典的分治(divide-and-conquer)策略(分治法将问题分(divide)成一些小的问题然后递归求解,而治(conquer)的阶段则将分的阶段得到的各答案”修补”在一起,即分而治之)。

分而治之

归并排序

可以看到这种结构很像一棵完全二叉树,本文的归并排序我们采用递归去实现(也可采用迭代的方式去实现)。分阶段可以理解为就是递归拆分子序列的过程,递归深度为log2n。

合并相邻有序子序列

再来看看治阶段,我们需要将两个已经有序的子序列合并成一个有序序列,比如上图中的最后一次合并,要将[4,5,7,8]和[1,2,3,6]两个已经有序的子序列,合并为最终序列[1,2,3,4,5,6,7,8],来看下实现步骤。

归并排序

归并排序

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package sortdemo;

import java.util.Arrays;

/**
* Created by chengxiao on 2016/12/8.
*/
public class MergeSort {
public static void main(String []args){
int []arr = {9,8,7,6,5,4,3,2,1};
sort(arr);
System.out.println(Arrays.toString(arr));
}
public static void sort(int []arr){
int []temp = new int[arr.length];
//在排序前,先建好一个长度等于原数组长度的临时数组,
//避免递归中频繁开辟空间
sort(arr,0,arr.length-1,temp);
}
private static void sort(int[] arr,int left,int right,int []temp){
if(left<right){
int mid = (left+right)/2;
sort(arr,left,mid,temp);
//左边归并排序,使得左子序列有序
sort(arr,mid+1,right,temp);
//右边归并排序,使得右子序列有序
merge(arr,left,mid,right,temp);
//将两个有序子数组合并操作
}
}
private static void merge(int[] arr,int left,int mid,int right,int[] temp){
int i = left;//左序列指针
int j = mid+1;//右序列指针
int t = 0;//临时数组指针
while (i<=mid && j<=right){
if(arr[i]<=arr[j]){
temp[t++] = arr[i++];
}else {
temp[t++] = arr[j++];
}
}
while(i<=mid){//将左边剩余元素填充进temp中
temp[t++] = arr[i++];
}
while(j<=right){//将右序列剩余元素填充进temp中
temp[t++] = arr[j++];
}
t = 0;
//将temp中的元素全部拷贝到原数组中
while(left <= right){
arr[left++] = temp[t++];
}
}
}

执行结果

1
[1, 2, 3, 4, 5, 6, 7, 8, 9]

转载

本文转载自 Java IO模型详解

前言

I/O 一直是很多小伙伴难以理解的一个知识点,这篇文章我会将我所理解的 I/O 讲给你听,希望可以对你有所帮助。

I/O

何为 I/O?

I/O(Input/Outpu) 即输入/输出

我们先从计算机结构的角度来解读一下 I/O。

根据冯.诺依曼结构,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。

冯诺依曼体系结构

输入设备(比如键盘)和输出设备(比如显示器)都属于外部设备。网卡、硬盘这种既可以属于输入设备,也可以属于输出设备。

输入设备向计算机输入数据,输出设备接收计算机输出的数据。

从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。

我们再先从应用程序的角度来解读一下 I/O。

根据大学里学到的操作系统相关的知识:为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space)内核空间(Kernel space )

像我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。

并且,用户空间的程序不能直接访问内核空间。

当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。

因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间

我们在平常开发过程中接触最多的就是 磁盘 IO(读写文件)网络 IO(网络请求和响应)

从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。

当应用程序发起 I/O 调用后,会经历两个步骤:

  1. 内核等待 I/O 设备准备好数据
  2. 内核将数据从内核空间拷贝到用户空间。

有哪些常见的 IO 模型?

UNIX 系统下, IO 模型一共有 5 种: 同步阻塞 I/O同步非阻塞 I/OI/O 多路复用信号驱动 I/O异步 I/O

这也是我们经常提到的 5 种 IO 模型。

Java 中 3 种常见 IO 模型

BIO (Blocking I/O)

BIO 属于同步阻塞 IO 模型

同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。

图源:《深入拆解Tomcat & Jetty》

在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。

NIO (Non-blocking/New I/O)

Java 中的 NIO 于 Java 1.4 中引入,对应 java.nio 包,提供了 Channel , SelectorBuffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO 。

Java 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。

跟着我的思路往下看看,相信你会得到答案!

我们先来看看 同步非阻塞 IO 模型

图源:《深入拆解Tomcat & Jetty》

同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。

相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。

但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。

这个时候,I/O 多路复用模型 就上场了。

IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。

目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。

  • select 调用 :内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
  • epoll 调用 :linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。

IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。

Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。

AIO (Asynchronous I/O)

AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。

异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。

最后,来一张图,简单总结一下 Java 中的 BIO、NIO、AIO。

参考

转载

本文转自 深入理解JVM - 解读GC日志

前言

​ 这次的文章会根据实战来介绍如何看JVM的日志,看JVM日志说难也难,说容易也容易,更多的是需要时间去不断的尝试进行总结。
​ 另外,因为代码的实际运行效果在不同的机器是不一样的!这篇文章使用的是jdk1.8.0_221 的版本,具体的系统配置查看:

概述:

​ 主要内容还是以讲解如何阅读日志,同时不同的机器运行的结果不同,文章更多的是介绍如何解读参数:
参数配置案例

配置介绍:

1、新生代5M
2、最大新生代内存5M
3、初始化堆内存大小10M
4、最大堆内存大小10M
5、新生代eden区域大小,或者说survior配比:8 代表 8:1:1
6、使用ParNew+CMS收集器

实际操作:

不执行任何代码测试:

参数配置:

1
-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC

参数的含义:

  • -Xms20M -Xmx20M 给堆分配了20M的内存空间
  • -Xmn10M 新生代分配10M
  • -XX:+PrintGCDetails 同时打印GC信息
  • -XX:SurvivorRatio=8 新生代的分配比例为8:1:1
  • -XX:+UseSerialGC 使用serrial收集器。注意是serrial收集器。

代码配置

​ 先从最简单的方法开始,我们运行一个没有任何代码的Main方法

1
2
3
4
5
6
7
public class MinorGcTest {

public static void main(String[] args) {

}

}

注意下面的内容是运行一个空Main方法的日志内容。

1
2
3
4
5
6
7
8
9
Heap
def new generation total 9216K, used 3977K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 48% used [0x00000000fec00000, 0x00000000fefe27f0, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
Metaspace used 3241K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 349K, capacity 388K, committed 512K, reserved 1048576K

解释:
​ 首先看下第二行:def new generation total 9216K, used 3977K,很明显,说明新生代总大小为9216K也就是9M的空间大小,为什么是9M呢,因为这里计算的是8M的eden区域+1M的survior from区域,剩下一个survior to的区域加起来一共10M,而可用空间根据复制算法只有9M也是正确的。

然后看下:eden space 8192K, 48% used,可以看到即使不运行任何的代码我们也使用了4M左右的空间,那么这4M的空间是什么东西呢,这部分对象其实是JVM自身运行产生的一些对象,这里也会放到后面的文章进行解读。

​ Metaspace代表着元空间,由于JDK8没有了永久代,所以JDK8之前的JVM看到的内容在这里是不一样的。

堆溢出测试:

​ 下面来看下堆溢出的情况下GC的日志打印了哪些内容,JAVA异常的信息忽略了,因为影响我们看日志:

参数配置:

1
-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC

代码配置:

1
2
3
4
5
byte[] allocation1, allocation2, allocation3, allocation4, allocation5;
allocation1 = new byte[_1MB * 2];
allocation1 = new byte[_1MB * 2];
allocation1 = new byte[_1MB * 1];
allocation3 = new byte[20*_1MB * 2];

下面是日志的结果:

1
2
3
4
5
6
7
8
9
10
11
12
[GC (Allocation Failure) [DefNew: 7909K->1023K(9216K), 0.0025258 secs] 7909K->3242K(19456K), 0.0025739 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [DefNew: 2131K->0K(9216K), 0.0015020 secs][Tenured: 4266K->2217K(10240K), 0.0030024 secs] 4350K->2217K(19456K), [Metaspace: 3254K->3254K(1056768K)], 0.0045414 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [Tenured: 2217K->2198K(10240K), 0.0017918 secs] 2217K->2198K(19456K), [Metaspace: 3254K->3254K(1056768K)], 0.0018074 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 404K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 4% used [0x00000000fec00000, 0x00000000fec65330, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 2198K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 21% used [0x00000000ff600000, 0x00000000ff8259d8, 0x00000000ff825a00, 0x0000000100000000)
Metaspace used 3360K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 362K, capacity 388K, committed 512K, reserved 1048576K
1
[GC (Allocation Failure) [DefNew: 7909K->1023K(9216K), 0.0025258 secs] 7909K->3242K(19456K), 0.0025739 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

我们先看一下第一行,这里使用的是serrial收集器,所以可以看到新生代打印7909K->1023K(9216K),表示一共可用空间9216KB,而垃圾此时占用了7909K,回收之后剩下1023K。第二部分:7909K->3242K(19456K),表示整个堆的回收情况,19456K表示整个堆的可用空间,同样从堆大小可以看到从7909K回收到剩下3242K的存活对象。最后一部分:[Times: user=0.00 sys=0.00, real=0.00 secs] 表示停顿的时间,以毫秒为单位,因为此次的垃圾回收时间太短,所以没有金酸进去,user表示用户线程停顿时间,sys表示系统回收时间,real表示实际的停顿时间。

​ 接着看一下full gc的日志,可以看到这里直接对于新生代和老年代回收内存之后发现几乎没有空间剩余,还是放不下20M的大对象,所以直接抛出了oom。

1
2
[Full GC (Allocation Failure) [Tenured: 2217K->2198K(10240K), 0.0017918 secs] 2217K->2198K(19456K), [Metaspace: 3254K->3254K(1056768K)], 0.0018074 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap

parNew+cms测试:

不执行任何测试

参数配置:

​ 注意最后使用了parnew+cms的组合

1
2
3
4
5
6
7
8
-verbose:gc
-Xms20M
-Xmx20M
-Xmn10M
-XX:+PrintGCDetails
-XX:SurvivorRatio=8
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC

代码配置:

​ 同样是不执行任何的代码,结果和其他的收集器类似

1
2
3
4
5
6
7
8
Heap
par new generation total 9216K, used 3977K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 48% used [0x00000000fec00000, 0x00000000fefe27f0, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
concurrent mark-sweep generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
Metaspace used 3255K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 353K, capacity 388K, committed 512K, reserved 1048576K

如果去掉cms?

​ 如果我们把上面的参数去掉-XX:+UseConcMarkSweepGC,会出现下面的内容:

1
2
3
4
5
6
7
8
9
10
Heap
par new generation total 9216K, used 3977K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 48% used [0x00000000fec00000, 0x00000000fefe27f0, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
Metaspace used 3238K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 349K, capacity 388K, committed 512K, reserved 1048576K
Java HotSpot(TM) 64-Bit Server VM warning: Using the ParNew young collector with the Serial old collector is deprecated and will likely be removed in a future release

这里注意一下最后一句:
​ 这里说明的是不推荐使用parNew+serrial的组合,并且说明在未来的版本中会废弃这种组合,实际上在JDK9中就已经完全禁止parnew+serrial的组合了

1
Java HotSpot(TM) 64-Bit Server VM warning: Using the ParNew young collector with the Serial old collector is deprecated and will likely be removed in a future release

总结:

​ 根据上面的几个测试案例可以看到,阅读GC的日志还是比较简单的,但是实际运行又会发现由于各种因素的干扰,实际运行的结果会和预期的结果不一样,这里不需要过多的担心,因为只要掌握了基础的理论,在根据实践模拟不断的熟悉会发现JVM的垃圾回收规律基本还是符合我们的理论基础介绍的。
​ 如果对于对象分配策略的感兴趣可以阅读之前个人的文章:深入理解JVM虚拟机 - jvm的对象分配策略

写在最后

​ 阅读日志建议更多的是实操和练习,多尝试几遍之后更能加深记忆,由于个人机器本身不跑任何代码也会产生4M的对象,所以只能简单的介绍阅读日志的方法了…..