实时数仓模型(持续更新ing)

2020-06-30 00:00:00 数据 架构 业务 实时 离线

背景

都在说实时数据架构,你了解多少?mp.weixin.qq.com

早期数据仓库构建主要指的是把企业的业务数据库如 ERP、CRM、SCM 等数据按照决策分析的要求建模并汇总到数据仓库引擎中,其应用以报表为主,目的是支持管理层和业务人员决策(中长期策略型决策)。随着业务和环境的发展,这两方面都在发生着剧烈变化。

  • 随着IT技术走向互联网、移动化,数据源变得越来越丰富,在原来业务数据库的基础上出现了非结构化数据,比如网站 log,IoT 设备数据,APP 埋点数据等,这些数据量比以往结构化的数据大了几个量级,对 ETL 过程、存储都提出了更高的要求;
  • 互联网的在线特性也将业务需求推向了实时化,随时根据当前客户行为而调整策略变得越来越常见,比如大促过程中库存管理,运营管理等(即既有中远期策略型,也有短期操作型);同时公司业务互联网化之后导致同时服务的客户剧增,有些情况人工难以完全处理,这就需要机器自动决策。比如欺诈检测和用户审核。

正常的大数据开发主要包含离线大数据开发和实时大数据开发,也就是批处理和流处理,这两块的处理方式不一样,一般离线的主要是:Hive、Spark等等,实时的主要是:Flink、Storm、StructedStreaming等等,目前常用的大数据架构分为:离线大数据架构、Lambda 架构和Kappa 架构。

离线大数据架构(常见的离线数仓模型):

数据源通过离线的方式导入到离线数仓中,下游应用根据业务需求选择直接读取 DM 或加一层数据服务,比如 MySQL 或 Redis,数据存储引擎是 HDFS/Hive,ETL 工具可以是 MapReduce 、Spark或 HiveSQL。数据仓库从模型层面分为操作数据层 ODS、数据仓库明细层 DWD、数据集市层 DM,后到APP层做数据展现。

离线数仓主要基于sqoop、datax、hive等技术来构建 T+1 的离线数据,通过定时任务每天垃取增量数据导入到hive表中,然后创建各个业务相关的主题,对外提供T+1的数据查询接口。

问题:

  • 主要是离线的数据开发,对于实时的数据开发不支持;
  • 数仓模型、数据指标体系、如果设计的不合理,会导致数据冗余和重复开发;

Lambda 架构:

为了计算一些实时指标,就在原来离线数仓的基础上增加了一个实时计算的链路,并对数据源做流式改造(即把数据发送到消息队列),实时计算去订阅消息队列,直接完成指标增量的计算,推送到下游的数据服务中去,由数据服务层完成离线&实时结果的合并。

实时数仓主要是基于数据采集工具,如canal等原始数据写入到kafka这样的数据通道中,后一般都是写入到类似于HBase这样的OLAP存储系统中。对外提供分钟级别,甚至秒级别的查询方案。

问题:

  • 同样的需求需要开发两套一样的代码:这是 Lambda 架构大的问题,两套代码不仅仅意味着开发困难(同样的需求,一个在批处理引擎上实现,一个在流处理引擎上实现,还要分别构造数据测试保证两者结果一致),后期维护更加困难,比如需求变更后需要分别更改两套代码,独立测试结果,且两个作业需要同步上线。
  • 资源占用增多:同样的逻辑计算两次,整体资源占用会增多(多出实时计算这部分)

Kappa 架构:

Lambda 架构虽然满足了实时的需求,但带来了更多的开发与运维工作,其架构背景是流处理引擎还不完善,流处理的结果只作为临时的、近似的值提供参考。后来随着 Flink 等流处理引擎的出现,流处理技术很成熟了,这时为了解决两套代码的问题,LickedIn 的 Jay Kreps 提出了 Kappa 架构。

问题:

  • Kappa 架构可以认为是 Lambda 架构的简化版(只要移除 lambda 架构中的批处理部分即可)。
  • 在 Kappa 架构中,需求修改或历史数据重新处理都通过上游重放完成。
  • Kappa 架构大的问题是流式重新处理历史的吞吐能力会低于批处理,但这个可以通过增加计算资源来弥补。
  • Kappa 架构可能也需要利用离线的数据进行校验。

Lambda 架构与 Kappa 架构的对比

  1. 在真实的场景中,很多时候并不是完全规范的 Lambda 架构或 Kappa 架构,可以是两者的混合,比如大部分实时指标使用 Kappa 架构完成计算,少量关键指标(比如金额相关)使用 Lambda 架构用批处理重新计算,增加一次校对过程。
  2. Kappa 架构并不是中间结果完全不落地,现在很多大数据系统都需要支持机器学习(离线训练),所以实时中间结果需要落地对应的存储引擎供机器学习使用,另外有时候还需要对明细数据查询,这种场景也需要把实时明细层写出到对应的引擎中。
注意:
实时数仓架构和数据中台一样,虽然都是属于当前比较热门的概念,但是对于实时数仓的狂热追求大可不必,首先,在技术上几乎没有难点,基于强大的开源中间件实现实时数据仓库的需求已经变得没有那么困难。其次,实时数仓的建设一定是伴随着业务的发展而发展,武断的认为Kappa架构一定是好的实时数仓架构是不对的。实际情况中随着业务的发展数仓的架构变得没有那么非此即彼。

实时数仓模型

实时数仓需要解决的问题:

1),要支持同时读写,就意味着你写的时候还可以读,不应该读到一个错误的结果。同时还可以支持多个写,且能保证数据的一致性;

2)第二,可以高吞吐地从大表读取数据。大数据方案不能有诸多限制,比如,我听说有些方案里多只可以支持几个并发读,或者读的文件太多了就不让你提交作业了。如果这样,对业务方来说,你的整个设计是不满足他的需求的;

3)第三,错误是无可避免,你要可以支持回滚,可以重做,或者可以删改这个结果,不能为了支持删改而要求业务方去做业务逻辑的调整;

4)第四,在重新改变业务逻辑的时候要对数据做重新处理,这个时候,业务是不能下线的。在数据被重新处理完成之前,数据湖的数据是要一直可被访问的;

5)第五,因为有诸多原因,数据可能会有晚到的情况,你要能处理迟到数据而不推迟下阶段的数据处理。

基于以上五点,我们基于Flink或者Structured Streaming 产生了一个新的批流一体化架构。

实时数据体系大致分为三类场景:流量类、业务类和特征类,这三种场景各有不同。

  • 在数据模型上,流量类是扁平化的宽表,业务数仓更多是基于范式的建模,特征数据是 KV 存储;
  • 从数据来源区分,流量数仓的数据来源一般是日志数据,业务数仓的数据来源是业务 binlog 数据,特征数仓的数据来源则多种多样;
  • 从数据量而言,流量和特征数仓都是海量数据,每天十亿级以上,而业务数仓的数据量一般每天百万到千万级;
  • 从数据更新频率而言,流量数据极少更新,则业务和特征数据更新较多,流量数据一般关注时序和趋势,业务数据和特征数据关注状态变更;
  • 在数据准确性上,流量数据要求较低,而业务数据和特征数据要求较高。

实时数仓的实施关键点:

  1. 端到端数据延迟、数据流量的监控
  2. 故障的快速恢复能力
  3. 数据的回溯处理,系统支持消费指定时间段内的数据
  4. 实时数据从实时数仓中查询,T+1数据借助离线通道修正
  5. 数据地图、数据血缘关系的梳理
  6. 业务数据质量的实时监控,初期可以根据规则的方式来识别质量状况

其实,你需要的不是实时数仓,需要的是一款合适且强大的OLAP数据库。

  • 接入层:该层利用各种数据接入工具收集各个系统的数据,包括 binlog 日志、埋点日志、以及后端服务日志,数据会被收集到 Kafka 中;这些数据不只是参与实时计算,也会参与离线计算,保证实时和离线的原始数据是统一的;
  • 存储层:该层对原始数据、清洗关联后的明细数据进行存储,基于统一的实时数据模型分层理念,将不同应用场景的数据分别存储在 Kafka、HDFS、Kudu、 Clickhouse、Hbase、Redis、Mysql 等存储引擎中,各种存储引擎存放的具体的数据类型在实时数据模型分层部分会详细介绍;
  • 计算层:计算层主要使用 Flink、Spark、Presto 以及 ClickHouse 自带的计算能力等四种计算引擎,Flink 计算引擎主要用于实时数据同步、 流式 ETL、关键系统秒级实时指标计算场景,Spark SQL 主要用于复杂多维分析的准实时指标计算需求场景,Presto 和 ClickHouse 主要满足多维自助分析、对查询响应时间要求不太高的场景;
  • 平台层:在平台层主要做三个方面的工作,分别是对外提供统一查询服务、元数据及指标管理、数据质量及血缘;
  • 应用层:以统一查询服务对各个业务线数据场景进行支持,业务主要包括实时大屏、实时数据产品、实时 OLAP、实时特征等。

所以所谓的实时数仓就是在之前的Lambda的数据结构中,将离线的数据开发和实时的数据开发合并到一起,也就是所谓的流批一体化,这个实现方式主要是:

log/mysql/hive(数据源) -> SparkCore/Sparksql/hive(数据处理) -> hdfs/mysql等(数据存储)

kafka/binlog(数据源) ->StructuredStreaming/flink(数据处理) -> mysql/redis/es等(数据存储)

将这两个处理逻辑合并。

基于StructuredStreaming的批流一体化(离线开发为核心)

StructuredStreaming的批流一体化,虽然能加实时和离线整合起来,但是spark是以离线开发为主。

案例:

实时获取log日志,做实时的数据大屏展现,并落库到HDFS作为ODS数据源。

而且StructuredStreaming可以完成离线的数据存储(FileSink,写到文件体系中),同时完成实时的数据存储(ForeachSink,自定义存储方式)。

例如:

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.streaming.{ProcessingTime, Trigger}
import org.json._
import utils._


/**
  * Created by gaowei
  */
object ZooMessage {
  case class Log(timestamp : String, hostname : String, name : String, version : String, index : String,
                  input_type : String, offset : String, source : String, * : String, uid : String,
                  zid : String, text : String, sessionKey:String,ds:String)

  def logParing(log : String) = {
      val str = Config.decodeUFT8(log)
      try {
        var json = new JSONObject()
        if (str.startsWith("\"")){
          json = new JSONObject(str.replace("\\", "").dropRight(1).drop(1))
        }
        json =  new JSONObject(str)
        val timestamp = json.optString("@timestamp", "")
        val ds = timestamp.split("T")()
        val beat = json.getJSONObject("beat")
        val hostname = beat.optString("hostname", "")
        val name = beat.optString("name", "")
        val version = beat.optString("version", "")
        val index = json.optString("index", "")
        val input_type = json.optString("input_type", "")
        val message = json.optString("message", "")
        var uid = ""
        var zid = ""
        var sessionKey = ""
        val userPatterns = "user:[0-9]+".r
        var text = ""
        if (message.contains("建立连接")) {
          val zooPatterns = "聊天室:[0-9]+".r
          val sessionPatterns = "sessionKey:[^,\"}]+".r
          uid = if (userPatterns.findFirstMatchIn(message).isDefined) userPatterns.findFirstMatchIn(message).get.toString().substring(5) else "None"
          zid = if (zooPatterns.findFirstMatchIn(message).isDefined) zooPatterns.findFirstMatchIn(message).get.toString().substring(4) else "None"
          sessionKey = if (sessionPatterns.findFirstMatchIn(message).isDefined) sessionPatterns.findFirstMatchIn(message).get.toString().substring(12) else "None"
          text = "Connection"
        }
        else {
          val zooPatterns = "zoo:[0-9]+".r
          val sessionPatterns = "sessionKey:[^,\"}\\s+]+".r
          uid = if (userPatterns.findFirstMatchIn(message).isDefined) userPatterns.findFirstMatchIn(message).get.toString().substring(5) else "None"
          zid = if (zooPatterns.findFirstMatchIn(message).isDefined) zooPatterns.findFirstMatchIn(message).get.toString().substring(4) else "None"
          sessionKey = if (sessionPatterns.findFirstMatchIn(message).isDefined) sessionPatterns.findFirstMatchIn(message).get.toString().substring(11) else "None"
          text = "Leave"
        }
        val offset = json.optString("offset", "")
        val source = json.optString("source", "")
        val * = json.optString("type", "")
        Log( timestamp, hostname, name, version, index, input_type,  offset, source, *, uid, zid, text, sessionKey, ds)
      }
      catch {
        case ex : JSONException => Log("1","1","1","1","1","1","1","1","1","1","1","1","1","1")
      }
  }

  def main(args: Array[String]): Unit = {
    val spark = SparkSession.builder().appName("ZooMessage").getOrCreate()
    val brokers = "xxx.xxx.xxx.xx.xx:9092,xxx.xxx.xxx.xx.xx::9092"
    val topics = "test"
    val df = spark.readStream.format("kafka").
      option("kafka.bootstrap.servers", brokers).
      option("subscribe", topics).
     load()

    import spark.implicits._
    val kafkaDf = df.selectExpr("CAST(value AS STRING)").as[String].
      filter(f => f.contains("cn.idongjia.zoo.handler.AbstractZooPackWebSocketHandler.afterConnectionEstablished") ||
        f.contains("cn.idongjia.zoo.handler.AbstractZooPackWebSocketHandler.afterConnectionClosed")).
      filter(_.contains("/data/servers/zoo/logs/service.log"))
    import spark.implicits._

    val dataFrame = kafkaDf.map {
      f =>
        logParing(f)
    }
    
    val query =  dataFrame.coalesce(1).writeStream.outputMode("append").partitionBy("ds").trigger(Trigger.ProcessingTime("600 seconds")).format("parquet").
      option("checkpointLocation", "/user/gaowei/auctionCheck").
      option("path", "/user/gaowei/auctionMini").start()
    query.awaitTermination()
  }
}

相关文章