0%

数据平台数据管理实践

Data management in data platform

我们在前面的文章中讨论了如何将数据接入到数据平台。一般而言,接入到数据平台的数据会来自众多的业务系统,这样一来,我们就拥有了大量不同来源的数据。如何将这些数据有效的管理起来是一个很大的挑战。本文将尝试结合我们的项目实践经验做一些分享。

(数据仓库可以理解为数据平台中所有数据的一个集合,所以,数据平台中的数据管理也可以说是数据仓库中的数据管理。下文中数据平台和数据仓库会经常交替使用,其意义基本一致。)

将数据管理起来是为了高效的查找和使用数据。我们先来看一下通常是如何使用数据的。

一个简单的指标计算

假设现在有一个大型零售行业的场景,我们的主要产品是空调。作为空调生产商,空调的销售主要是通过各大经销店完成的。现在我们想知道空调的销量情况用以辅助决策,可以开发一个指标来统计销量。

开始

如何统计销量呢?看起来只需要把销售的订单找出来,做一个加和统计就可以了。但是实际情况会更复杂一些,从业务上讲,以下场景产生的订单不需要统计:

  • 订单被取消
  • 订单还在进行中
  • 订单产生了退货
  • 内部奖励产生的订单
  • 翻新的产品产生的订单

这个时候我们的销量统计逻辑可以用SQL表达为:

1
2
3
select count(1) as sales_count
from order
where closed=true canceled=false and rejected=false and reward_order=false and retread_product=false

(实际情况可能更复杂,因为order表可能没有是否翻新产品或者是否是内部奖励的信息,此时需要连接其他表进行查询。)

考虑取数时间

上述代码正确吗?我们需要注意,根据每天接入数据平台的数据来看,对于某一个订单,数据仓库里面的数据可能是重复的。比如,订单a在前天创建,同时在昨天被更新,这时,前天和昨天的数据里面都会有这个订单。根据这里的分析,我们需要取当前最新状态的订单来进行计算才行。上述的SQL需要改进为:

1
2
3
4
5
6
7
8
9
10
11
select count(1) as sales_count
from (
select * from (
select *,
-- 先根据订单id进行分组,然后根据时间排序,得到序号
row_number() over(partition by id order by update_time desc) n
from order
) all_order
where n = 1 -- 取序号为1的订单,即最新订单
) latest_order
where closed=true canceled=false and rejected=false and reward_order=false and retread_product=false

考虑计算历史指标

此时,我们还会希望这个指标可以每天定时计算出来,然后通过大屏进行展示。这个需求就要更复杂一些了。我们常常需要一个任务调度系统来支持,以便可以将上述的代码每天跑一次。其实不仅仅是调度系统,为了支持决策,我们还常常有统计历史指标的需要,比如销量指标在上个月是多少,本月是增长了还是下降了?

这时,代码里面需要加入一个变量,那就是希望计算哪一天的指标。这个参数我们可以命名为data_date。上述代码可以进一步优化为:

1
2
3
4
5
6
7
8
9
10
11
12
select count(1) as sales_count
from (
select * from (
select *,
-- 先根据订单id进行分组,然后根据时间排序,得到序号
row_number() over(partition by id order by update_time desc) n
from order
where ods_insert_date <= ${data_date} -- 筛选出目标数据日期及以前的订单
) all_order
where n = 1 -- 取序号为1的订单,即最新订单
) latest_order
where closed=true canceled=false and rejected=false and reward_order=false and retread_product=false

考虑计算其他维度

这下看起来好像差不多了。但是,通常来讲,还有更多的来自业务方的需求,比如,希望可以根据经销店的维度来查看这个指标,以便对比分析。希望按照省、市、区维度来查看这个指标,以便对比分析。

此时我们需要考虑指标保存的粒度了。对于经销店粒度的指标而言,因为总的销量与各经销店的销量有汇总关系,所以这里可能没必要保存总销量。当需要总销量时,在展示的时候进行汇总加和即可。

对于省、市、区这几个维度而言,不难看出,它们存在层级关系:区以上是市,市以上是省。其实经销店与这三个地域维度也存在层级关系:经销店以上是区,区以上是市,市以上是省。所以,事实上,我们只需要保存到经销店粒度的统计数据就可以支持省、市、区维度的统计了。

于是我们可以修改代码为:

1
2
3
4
5
6
select dealer_id, section, city, province, count(1) as sales_count
from (
...
) latest_order
where ...
group by dealer_id, section, city, province

(实际情况可能更复杂,因为order表往往没有省、市、区的信息,此时需要连接其他表进行查询。)

调度运行

到这里,一个基本的指标计算算是搞定了。实际情况下,由于这个任务需要order等表的数据,需要在数据接入之后才能运行。我们会将这个计算任务和数据接入的任务组成一个数据处理管道(Pipeline),通过调度器调度整个任务管道进行执行。下面是使用Airflow组织的一个简单的任务管道图。

simple dag

对数据管理的启发

上面我们完成了一个简单指标的计算,让我们来回顾一下整个过程,看看从数据平台数据管理的角度我们需要做哪些事才能让整个过程更高效。

数据清洗

首先是根据需要确定取数范围,并筛选特定的数据出来。这一步常常需要进行一定的数据清洗。数据清洗的例子比如:

  • 对于业务系统数据库中的是否取消字段canceled,其取值可能是nulltruefalse。为了便于在上层计算(如行数据分析或指标计算)时的逻辑更简单和一致,最好能将此字段限制为非空,取值为truefalse,数据清洗可以将null可以映射为false
  • 有一些字符串类型的字段,比如经销商所在城市,在业务数据库中可能存储为类型char(32),在接入数据平台之后,会发现该字段的值包含了一些用于补位的空白字符。这些多余的空白字符是不便于分析的,一般可以将其去掉。
  • 还有一些数据,可能是由测试产生的,这些数据一般需要标记出来,或直接排除掉。

数据清洗是为了让数据更便于分析和加工处理。这一过程也理解为:将某些相同的数据加工逻辑抽象为一个统一的数据清洗过程。

数据建模

在进行数据分析或指标开发的时候,我们常常要面对数百张张数据库表,如何找到需要的表呢?一个简单的办法是将数据库表做一下分类。

一般而言,可以将数据库表分为事实表和维度表。事实表存储的是一些业务事实,比如订单、付款、物流、维修等。维度表则存储不常变化的一些维度数据,比如经销商信息,产品信息,用户信息等。这就是最基本的维度建模的思想。

这个数据库表分类方法还有一个好处,那就是,一般的统计分析都是以事实表为中心的分析,比如销量、退单量、维修次数等等。维度表一般是用于希望多维度分析数据的情况,比如按照省、市、区、经销店维度进行分析。此类分析的一般模式是从事实表开始查询,然后连接需要的维度所在的表,然后根据维度进行聚合得到结果。

有的说法认为维度建模绝不止将业务系统的数据库进行分类这么简单,需要从业务角度出发,重新定义一套模型,然后将业务系统数据库的模型映射到新模型中。对于维度建模如何实现的问题,我们可能需要另起一篇文章来讨论。

从我们的实践经验来看,虽然说业务系统主要是以三范式来建立数据库模型,但是三范式其实与维度模型有一定的相似之处,简单来说,可以建立一一对应关系。比如三范式建模的订单表,其实可以直接对应到维度模型的订单事实表。三范式建模的经销商表,可以直接对应到维度模型的经销商维度表。为了避免过多繁杂的价值不明显的讨论,一个简单的、稳定的维度建模实施方式就是直接对业务系统的数据库表做一下事实维度划分。

有了事实和维度的划分,数据分析或指标开发就有套路可寻了。一般的套路就是,先找到和计算目标对应的事实表,然后关联到该事实表对应的维度表,选出需要的事实和维度,进行统计即可。比如前面的销量计算,首先可以找到订单事实表,然后通过事实表中的经销商id关联到经销商维度表,把需要的维度数据取出来进行分组统计即可。

在项目实践过程中,我们一般会将数据清洗和数据建模放在一起完成。此时我们会进行一层基础数据层的抽象,这一个数据层一般称为建模层,也常称为明细数据层或DWD(Data Warehouse Details)层。

数据主题划分

除了事实表和维度表的划分,还可以有一种划分方法,那就是根据主题来进行划分。

假设我们从5个业务系统分别接入了40张数据库表,此时,我们有200张数据库表。此时,如果所有的数据库表都放在同一个数据库中,在查找时就会碰到麻烦,我们很难在200张数据库表中快速找到想要的那一张表。不仅如此,很多数据工具在展示过多的数据库表时也会遇到性能问题。

一个简单的应对方法就是,按照主题将数据库表分类放到不同的数据库中。

那么什么样的主题划分是比较合理的划分呢?事实上,对于同一类业务,我们常常有多种不同的划分方法。比如零售行业,我们可以按照人、货、场这种常用的分析思路来划分,也可以按照售前、售中、售后三个销售阶段进行划分,或者按照不同的渠道进行划分,如划分为线下渠道、网上商店渠道、三方渠道等等。

这么多种划分方式的选择可能会在团队中引起很多的讨论,这将导致主题划分迟迟没有结论。很多主题划分都将带来较大的成本,因为我们需要将数据映射到这个主题划分上去。为避免过多的前期设计,从我们的经验来看,会更推荐采用一种更为精益的做法。

首先,我们可以直接简单的按照来源业务系统进行一次划分,每个业务系统对应一个主题。这通常也是合理的。主要的理由是:

  • 在这些来源业务系统建设之初,通常都经过了广泛讨论,这些业务系统可以认为是高内聚低耦合的。
  • 一般而言,绝大部分的分析其实是集中在业务系统内部。
  • 实现上,可以根据不同来源的系统建立数据库,然后将相关的数据库表都置于其中。这在工程上将带来很多好处,因为我们可以把这一条规则当做惯例,而非配置。根据Convention over Configuration的思想,我们可以因此节约很多代码。

然后,在某一天,我们发现直接根据业务系统进行划分这种方式不便于数据分析,到那时可以根据更好的方式进行划分。在需要用另一种方式划分主题时,我们总是难以避免做一层复杂的数据映射。

可以考虑的做法是,通过数据库视图(View)这样的技术来实现,此时的主题划分只是对应了一个逻辑上的抽象,我们无需移动数据,也无需复制数据,还可以快速应对设计的变更。使用视图的另一个好处是,我们可以很容易的支持多种主题划分方式。有了这样的灵活性,我们就更不用担心过重的主题设计了,因为我们可以随时应对变更。我们甚至还可以实现一些辅助进行主题划分工具,将这个步骤完全自动化。

数据逻辑复用

找到数据之后,下一步是组织计算逻辑,比如前面计算销量时,我们会考虑订单的状态,产品的范围等因素。这一步非常关键,因为一旦我们取错数据了,后面的计算就都是错的。

要梳理出一个合理的正确的取数逻辑常常需要经过大量的讨论、验证及反复工作。比如,刚开始,我们可能选择了所有成功完成的订单。按照这个规则实现之后,我们把数据呈现给业务人员。业务人员说:“这不对呀!为啥把翻新产品的订单也计算进去了?” 没关系,我们来排除掉这些订单。后来,业务人员经过讨论之后,又发现,其实业务上并不太关心内部奖励产生的订单,还需要把这样的订单排除。

做数据开发时,我们会经常碰到这样的故事。

如何从技术上缓解这个问题呢?

我们经过很多轮的验证,才获得了这样一个合理的取数逻辑,这样的经验是非常宝贵的。而且,我们在做其他统计时常常也要做同样考虑,用类似的取数逻辑。比如,当我们要统计某一个用户产生了多少笔订单以确定高价值用户时,这里的订单取数逻辑一般会是相同的。

将这些经验进行沉淀的一种技术实现方式就是,新建一个数据层,将这些计算过程保存下来。我们常常把这一层称为基础数据层,也有人将其称为轻度汇总层。一般以DWB的缩写来表示,其全称是Data Warehouse Basis。之所以有人将这一层称为轻度汇总层,是因为我们常常还可以在这一层进行数据的轻度汇总,比如按照经销店或者客户维度等比较细粒度的维度进行汇总。上层计算一般可以较容易的复用这样的轻度汇总结果。同时,做了轻度汇总之后,数据量可以进一步减少,对存储的压力也进一步降低了。

由于我们常常是为了解决逻辑复用的问题而引入的DWB层,所以这一层一般是通过类似软件开发过程中的重构手段而形成的。比如,在开发第一个指标的时候,我们将所有的代码放在了一起。开发第二个指标的时候,我们发现可以和第一个指标有一定的逻辑复用,于是我们抽象了一个DWB层的数据表,将公共的计算逻辑抽取出来用于构建这个表。构建这个表的代码一般还会形成一个独立的数据计算任务,在数据管道的另一个任务中执行。经过几轮重构之后,我们将得到一些比较稳定的公共层数据表,DWB数据层也就慢慢丰富起来了。

如果团队有较丰富的经验,或者对业务系统的数据本来就理解比较深刻,那么也可以通过自上而下的设计来构建这一层。通过预先设计来构建DWB层的潜在问题是可能引入过度设计,通过重构来实现这一层的潜在问题是可能做了过多的无用功。

一般而言,DWB层的数据表会与DWD层的事实表相对应。当然,实际情况常常比较复杂,可能有很多合理的例外,只要能体现其价值就可以。

总结起来,我们通常可以抽象一层DWB来实现计算逻辑的复用。请注意,无论采用什么样的途径构建这个数据层,我们的目标是提高最终指标计算的质量和效率。这个目标才是最重要的,事实上,只要对实现这个目标有益,我们甚至完全可以不构建这一层。当我们遇到分歧时,可以回头来看看这个目标,也许我们就可以很快得到答案了。

宽表输出

计算出来指标之后,需要向BI应用提供查询服务,有没有什么潜在的问题或者好的实践呢?

指标的计算通常是具备较强的独立性的,即,一个指标由一个开发人员完成,输出一张数据表。这带来的问题就是,指标表太多了!

我们可以大概计算一下,在一般的企业中,业务人员希望查看的指标都是数百个,这里可能对应数百张数据表。另外,对于同一个指标,可能存在完全不同的计算维度,此时一个指标也可能产生多张数据库表。比如,对于经销商的管理,在企业组织结构上,可以设立一个业务管理员,分别管理经销店1-5,同时设立一个技术指导员,分别对应经销店2-6。我们希望统计每个管理员对应的空调销量指标。此时,一般我们会视为两个指标分别进行计算,因为它们的统计粒度不一样。这样一来,最终我们会得到两张数据库表。

所以,我们常常见到的某某数据仓库里面有数千张数据库表的情况,也就不足为怪了。

这么多数据库表,如果不能很好的管理起来,在维护上也会是一个灾难。比如,通常情况下,为了提高指标的查询效率,我们会将数据同步到一个外部的关系型数据库或者分析型数据库中,如果表数量太多,这里的数据同步就会成为一个很大的问题。

这么多数据库表,如果不能很好的管理起来,查找的时候也可能会是一个灾难。首先是命名上,很难得到一个较短的又容易理解的表名。其次,就算有一个合理的命名规范,也可能由于相似名字的表太多了而很难找到想要的表。

在项目实践的时候,有几个值得借鉴的方法可以缓解这个问题。

第一个方法是,尝试尽量少建数据库表。比如,我们可以将计算逻辑相近的指标放在一起计算,只输出一张数据库表。上面的例子中,针对业务管理员的销量统计和针对技术指导员的销量统计计算逻辑相似,同时输出的维度一般也有很多重合。这两个指标就可以考虑合并为一个指标计算任务进行实现,同时只输出一张表。合并计算带来的一个副作用就是,输出的表中的数据混合了多个指标,不容易理解。但是考虑到通常会由同一个开发人员完成,所以这个问题不会很显著。

第二个方法是,尝试将最终的数百个指标合并为一张大宽表进行输出。这样一来,对于上层BI系统而言,通常就只需要面对一张数据库表了。合并为一张大宽表输出还可以合并相同维度的指标数据,从而减少数据量。比如,经销商的销量指标包含了省、市、区维度,经销商的售后服务次数指标同样包含了省、市、区维度,这时我们就可以用一行数据来存储这两个指标的结果。

indicator combine

第二个方法带来的副作用同样是数据查询的问题。比如,合并之后的指标表可能如下:

indicator combine

如果要查询经销商的销量指标,我们需要将查询限制为:业务管理员维度为null值 且 技术指导员维度为null值 且 销量指标不为null值。如果要查询业务管理员的销量指标,我们需要将查询限制为:经销商维度为null值 且 技术指导员维度为null值 且 销量指标不为null值。

虽然带来了一定程度使用上的不便,但是总体上来看,由于我们减少了数据表的管理成本,这个方法还是有效的。最后,如果由于维度和指标太多,实在是查询不便,可以将每个指标的查询逻辑记录下来,或者开发一个指标查询的工具辅助完成查询语句的编写。

对于这类指标表的存储,我们通常也会在数据仓库中新建一层(一般直接对应一个独立数据库)。这一个数据层常常被称为数据集市层,简称为DM,即Data Market。也有人将其称为数据服务层,简写为DWS,即Data Warehouse Service。最终的指标宽表一般也会对应数据仓库中的一层,常常被称为数据应用层,简写为ADS,即Application Data Service。

总结起来,对于大量指标的计算,我们可以考虑将其单独存储到DM层中。当输出给其他外部系统时,我们可以考虑将指标合并为一张宽表存储到ADS层,然后进行输出。

数据分区和命名

我们上面讲到了数据仓库的分层,按照数据被加工的顺序和深度,依次可以是:ODS层->DWD层->DWB层->DM层->ADS层。在数据管理时,通常还有一个问题,那就是分区。

数据一般是按照固定的周期(如每天)定期接入到数据平台的,那么,数据在每张数据库表里面要如何存储呢?

一般而言,可以按照接入数据的周期进行数据分区存储,比如,如果是每天接入数据,可以建立一个分区字段,其值为接入的数据的时间(一般是接入数据的任务运行时的前一天)。

从技术上讲,一般的数据仓库技术(如Hive)都会把不同的分区存储为不同的文件。在计算时,如果我们只需要计算某一个分区的数据,则只需要将这个分区的数据查出来做计算即可,这可以大大提升计算的性能。在计算指标时,其实大部分指标都是按接入数据的周期进行统计计算的,这刚好可以利用到数据分区的优势。

从数据接入策略和数据表的内容来看,我们可以将数据库表分为全量表和增量表。对于全量表,每个分区都是一个全量的数据。对于增量表,每个分区对应的是当期的数据增量。

在实施时,为了团队可以更容易的统一认知,一般会引入一套命名规范。通过名字可以让大家快速的了解到表的基本设计。

比如,我们对于全量表会增加一个后缀_f,全称是full,即全量表,对于增量表则增加后缀_h,全称是history,即保存历史的增量表。对于全量表,需要有一个分区字段,该字段名为dt,值为整型存储的日期(如20210505)。增量表的分区字段为di,值与全量表的相同。

贴源层ODS、明细层DWD及轻度汇总层DWB的数据表都需要符合这一基本规范。DMADS层的指标一般没有增量/全量的概念,但是会有一个统计的数据日期字段,这一字段也可以作为分区字段,我们将其命名为data_date,存储字符串类型,如2021-05-05

除了数据分区及其命名规范,我们还会规范数据库和数据表的名字。

在库的命名上,我们一般会反映数据层的名字和当前主题的名字。比如在ODS层,我们会将数据库命名为ods_datasource1ods_datasource2等,在DWD层将数据库命名为dwd_datasource1dwd_datasource2等。如果有按照其他方式设计的主题,也是类似,比如如果实施了OneID进行各个系统的ID关联和打通,则可以将对应的数据库命名为dwd_oneid

在表的命名上,我们会加上主题的名字和事实维度分类,比如datasource1_dim_customer_fdatasource1_fact_order_h

由于DWD层的数据是保留历史数据的,如果我们要查询当前最新的数据,需要做一个开窗选最新数据的操作(row_number() over(partition by id order by update_time desc) n),使用不太方便。DWD的表是使用最频繁的表,而且此类查询最多,对于这类需求,我们可以通过数据库视图来实现。可以为DWD的所有表都创建一个表示该表最新数据的视图,并命名为datasource1_dim_customer_latest_fv

总结

最后,我们来简要总结一下。

为了高效的查找和使用数据,我们需要将数据仓库中的数据有效的管理起来。本文以一个指标的开发过程为例,梳理了整个数据开发过程中的主要步骤。然后,我们分析了这些步骤中可能遇到的问题,以及在数据仓库建设中的一些有效的对策。包含了数据仓库数据清洗、建模、主题划分、逻辑复用、宽表输出及分区和命名。

数据仓库建设是数据平台建设的核心内容,这个领域其实是一个非常偏工程的领域,很多问题并没有一个是或否的答案。从以上内容中对很多实际问题的分析来看,在实际项目过程中,我们往往需要根据团队的背景、数据的情况、组织分工等进行综合考虑,最终选择一个适合自己团队的方案。

如何能做好数据仓库甚至数据平台呢?这还需要团队一起努力,保持信息透明,经常思考总结,遇到问题时多方探索,大家都本着精益求精的求索精神,才能做好这件事。

欢迎关注我的其它发布渠道