MapReduce 出现后,对数据的计算需求越来越多,而 MapReduce 提供的 API 太底层,学习成本和开发成本比较高,因此需要一个类 SQL 的工具,来代替大部分的 MapReduce 的使用场景。

数据模型,类型系统和查询语言

Hive 和传统的数据库一样有 Database 和 Table 的概念,数据存储在 Table 中。每个 Table 中会会很多行,每行有多列组成。

类型

Hive 支持原生类型 (primitive types) 和复杂类型 (complex types)。

Primitive:

  • Integers: bigint, int, smallint, tinyint
  • Float: float, double
  • String

Complex:

  • map
  • list
  • struct

SerDe , Format 都是可插拔的,用户可以自定义 SerDe 或者 Format,在查询时可以通过 HiveQL 增加自己的 SerDe:

ADD JAR /jars/myformat.jar;
CREATE TABLE t2
ROW FORMAT SERDE 'com.myformat.MySerDe';
HiveQL

HiveQL 和传统的 SQL 几乎没有差别,但是存在一些局限:

  • 只能使用标准的 ANSI Join 语法
  • JOIN 条件只能支持 = 运算符,不能使用 >, <
  • Hive 不能支持正常的 INSERT INTO, 只能使用 INSERT INTO OVERWRITE 从已有的数据中生成

Hive 中可以直接调用 MapReduce 程序:

FROM (
  MAP doctext USING 'python wc_mapper.py' AS (word, cnt)
  FROM docs
  CLUSTER BY word
) a
REDUCE word, cnt USING 'python wc_reduce.py';

Hive 提供了 CLUSTER BYDISTRIBUTE BY 等语法改善 Reduce 的数据分布,解决数据倾斜问题

Data Storage, SerDe and File formats

Data Storage

Hive 的存储模型有 3 个层级

  • Tables 对应 HDFS 的一个目录
  • Partitions 是 Table 的子目录
  • Buckets 是目录下具体的文件

Partition 的字段不是 Table 数据的一部分,而是保存在目录的名称中。比如 /usr/hive/warehouse/t1/p_date=20170701

Partition 可以优化查询性能,当用户指定 Partition 的情况下,Hive 只会扫描指定 Partition 下的文件。当 Hive 运行在 strict 模式时,用户需要指定只要一个 Partition 字段。

Bucket 相当于目录树的叶子节点,在创建表的时候用户可以指定需要多少个 Bucket,Bucket 可以用户数据的快速采样。

由于数据都保存在 HDFS 的表空间下,如果用户需要查询 HDFS 其他目录的文件,可以使用外部表:

CREATE EXTERNAL TABLE test_extern(c1 string, c2 int)
LOCATION '/user/mytables/mydata';

外部表和普通表的唯一区别是当我们执行 DROP TABLE 时,外部表不会删除 HDFS 的文件。

SerDe

SerDe 提供了几个 Java 接口,方便在文件格式和 Java Native 类型之间相互转化。默认的 SerDe 实现叫 LazySerDe ,是一种用文本表示数据的存储格式。这种格式用 Ctrl-A 来分割列,\n 来分割行。其他的 SerDe 实现包括 RegexSerDe, Thrift, Avro 等等。

File Formats

Hadoop 上的文件可以以不同格式存储,Hive 默认的存储格式是一种叫 TextFormat 的格式,用类似 CSV 的纯文本表示数据。Format 可以在创建表的时候指定:

CREATE TABLE dest1(key INT, value STRING)
  STORED AS
    INPUTFORMAT 'org.apache.hadoop.mapred.SequenceFileInputFormat'
    OUTPUTFORMAT 'org.apache.hadoop.mapred.SequenceFileOutputFormat';

存储格式可以根据自己的需求扩展,选择合适的存储格式有利于提高性能,比如面向列存储的 ParquetFile 和 ORCFile,可以减少读取的数据量,ORCFile 甚至可以做一些与计算,来满足 Push Down 的需求。

系统架构和组件

Hive 由下面的组件构成:

  • MetaStore - 存储系统目录看,还有数据库,表,列的各种原信息
  • Driver - 管理 HiveQL 的生命周期
  • Query Compiler - 将 Hive 的语句转换成一个 MapReduce 表达的 DAG
  • Execution Engine - 将任务按照依赖顺序执行,早起版本只有 MapReduce,新版本应该有 Tez 和 Spark
  • HiveServer - 提供 JDBC/ODBS 接口的服务,方便和其他系统集成
  • Clients - Web UI 和命令行工具

A. MetaStore

Hive 的 MetaStore 一般基于一个 RDBMS 来实现,提供了一个 ORM 层来作为数据库的抽象。MetaStore 本身不是为了高并发和高可用设计的,在架构中也是一个单点(新版本有主从了),所以 Hive 在设计的时候需要保证在任务执行期间没有任何对 MetaStore 的访问。

B. Query Compiler

Query Compiler 首先将 HQL 转换成一个 AST,然后进行类型检查和语法分析,将 AST 转换成一个 operator DAG (QB Tree),然后优化器对 QB Tree 进行优化。Hive 只支持基于规则的优化器,比如只读取指定列的数据减少 IO,或者用户指定分区字段时,只读取指定分区下的文件。
RBO 本质上是一个 Transformer,在 QB Tree 上做一系列的变换来减少查询的代价

Hive 0.1.4 开始引入了一些基于代价的优化器(CBO)。COST BASED OPTIMIZER

之后就根据优化后的 QB Tree 生成物理执行计划,并且将 jar 包写入到 HDFS 的临时目录,开始运行。

C. Execution Engine

执行引擎会解析 plan.xml ,然后按照顺序将任务提交到 Hadoop,最终的数据库文件也会 move 到 HDFS 的指定目录。

金丝雀部署(Canary Deployments)在知乎落地差不多一年时间,通过金丝雀避免了很多线上的问题,相比之前的发布模型,极大降低了部署的风险。知乎是非常推崇 Devops 的公司,金丝雀发布作为 Devops 一种实践自然不会落下。但是由于基础设施变得越来越抽象和复杂,理解整个部署的工作流程已经变得比较困难,正好有机会给新的同事科普一下金丝雀发布的架构。

相传上个世纪煤矿工人在作业时,为了避免瓦斯中毒会随身带一只金丝雀下到矿洞,由于金丝雀对二氧化碳非常敏感,所以看到金丝雀昏厥的时候矿工们就知道该逃生了。[1]

金丝雀发布就是用生产环境一小部分流量验证应用的一种方法。从这个名字的由来也可以看到,金丝雀发布并不是完美的,如果代码出现问题,那么背用作测试的小部分流量会出错,就跟矿坑中昏厥的金丝雀一样。这种做法在非常敏感的业务中几乎无法接受,但是当系统复杂的到一定程度,错误无法完全避免的时候,为了避免出现更大的问题,牺牲一小部分流量,就可以将大部分错误的影响控制在一定范围内。

金丝雀发布的步骤

一个典型的金丝雀发布大概包含以下步骤[2]:

  1. 准备好发布用的 artifact
  2. 从负载均衡器上移除金丝雀服务器
  3. 升级金丝雀服务器
  4. 最应用进行自动化测试
  5. 将金丝雀服务器加入到负载均衡列表中
  6. 升级剩余的服务版本

在知乎,负载均衡器采用的 HAProxy,并且依赖 Consul 作服务注册发现。而服务器可能是一台物理机也可能是 bay 上一个抽象的容器组。

Canary Deployments

  • 对于物理机,我们可以单独为其配置一个一台独立的服务器,通过在 HAProxy 上设置不同于 Production 服务器的权重来控制测试流量。但是这种方法不够方便,做自动化也难一些
  • 对于容器相对简单,我们复制一个 Production 版本的容器组,然后通过控制 Production 和 Canary 容器组的数量就可以控制流量。

整个过程是部署系统在中间协调,当我们上线发布完成,部署系统会移除金丝雀服务器,让应用回到 Normal 状态。

如果遇到问题需要回滚,只需要将金丝雀容器组从 HAProxy 上摘掉就可以,基本上可以在几秒内完成。

HAProxy

在整个金丝雀发布的架构中,HAProxy 是非常重要的一个组件,要发现后端的服务地址,并动态控制金丝雀和线上的流量比例。部署时我们并不会直接操作 HAProxy,而是更新 Consul 上的注册信息,通过事件广播告诉 HAProxy 服务地址有变化,这一过程通过 consul-template 完成。

consu
HAProxy 自己也会注册到 Cosnul,伪装成服务的后端被调用,而服务自身则注册成 服务名 + --instance,在我们内部的 Consul 控制台可以看到。

每个服务都有自己独立的 HAproxy 集群,分布在不同的机器上,每个 HAProxy 只知道自己代理的服务的地址。这样做的好处是单个 HAProxy 崩溃不会影响业务,一组 HAProxy 负载高不会把故障扩散到整个集群。另外附带的一个好处是当我们更新服务注册地址时,不会 reload 整个 HAProxy 集群,只要更新对应的 HAProxy 实例就可以,一定程度上可以规避惊群问题。

HAProxy 的地址通过客户端服务发现获得,客户端发现多个 HAProxy 地址并可以做简单的负载均衡,将请求压力分摊到多个 HAProxy 实例上。

采用类似方案的公司是 Airbnb,不过他们的做法是把 HAProxy 作为一个 Agent 跑在服务器上,HAProxy 更靠近客户端[3]。

金丝雀发布的监控

有了金丝雀发布后,我们还得能区分金丝雀和线上的监控数据,以此判断服务是否正常。

由于有了 zone 和 tzone 框架对监控的支持,这件事推起来相对简单。我们在部署时将服务当前所在的环境注入到环境变量中,然后根据环境变量来决定指标的名称。

比如一个正常的指标名称是:

production.span.multimedia.server.APIUploadHandler_post.request_time

在金丝雀环境中的名称则是:

canary.span.multimedia.server.APIUploadHandler_post.request_time

最后我们可以在 Halo 对比服务在金丝雀和生产的表现有何差别:

Reference

[1] 金丝雀发布的由来: https://blog.ccjeng.com/2015/06/canary-deployment.html
[2] 在生产中使用金丝雀部署来进行测试: http://www.infoq.com/cn/news/2013/03/canary-release-improve-quality
[3] Service Discovery in the Cloud: http://nerds.airbnb.com/smartstack-service-discovery-cloud/

Graphviz 是一个开源的图可视化工具,非常适合绘制结构化的图标和网络。Graphviz 使用一种叫 DOT 的语言来表示图形。

DOT 语言

DOT 语言是一种图形描述语言。能够以简单的方式描述图形,并且为人和计算机所理解。

无向图

graph graphname {
    a -- b -- c;
   b -- d;
}

有向图

digraph graphname {
    a -> b -> c;
    b -> d;
}

设置属性

属性可以设置在节点和边上,用一对 [] 表示,多个属性可以用空格或者 , 隔开。

strict graph {
    // 设置节点属性
  b [shape=box];
  c [shape=triangle];

  // 设置边属性
  a -- b [color=blue];
  a -- c [style=dotted];
}

完整的属性列表可以参考 attrs | Graphviz - Graph Visualization Software

子图

subgraph 的作用主要有 3 个:

  1. 表示图的结构,对节点和边进行分组
  2. 提供一个单独的上下位文设置属性
  3. 针对特定引擎使用特殊的布局。比如下面的例子,如果 subgraph 的名字以 cluster 开头,所有属于这个子图的节点会用一个矩形和其他节点分开。
digraph graphname{ 
    a -> {b c};
    c -> e;
    b -> d;
    
    subgraph cluster_bc {
        bgcolor=red;
        b;
        c;
    }
    
    subgraph cluster_de {
        label="Block"
        d;
        e;
    }
}

布局

默认情况下图是从上到下布局的,通过设置 rankdir="LR" 可以让图从左到右布局。

一个简单的表示 CI&CD 过程的图:

digraph pipleline {
    rankdir=LR;
    g [label="Gitlab"];
    j [label="Jenkins"];
    t [label="Testing"];
    p [label="Production" color=red];
    
    g -> j [label="Trigger"];
    j -> t [label="Build"];
    t -> p [label="Approved"];
}

工具

有非常多的工具可以支持 DOT 语言,这些工具都被集成在 Graphviz 的软件包中,可以简单安装使用。

dot

一个用来将生成的图形转换成多种输出格式的命令行工具。其输出格式包括PostScript,PDF,SVG,PNG,含注解的文本等等。

neato

用于sprint model的生成(在Mac OS版本中称为energy minimized)。

twopi

用于放射状图形的生成

circo

用于圆形图形的生成。

fdp

另一个用于生成无向图的工具。

dotty

一个用于可视化与修改图形的图形用户界面程序。

lefty

一个可编程的(使用一种被EZ影响的语言[4])控件,它可以显示DOT图形,并允许用户用鼠标在图上执行操作。Lefty可以作为MVC模型的使用图形的GUI程序中的视图部分。

另外介绍 2 个在线生成 Graphviz 的网站:

Reference

[1] 使用 Graphviz 生成自动化系统图
[2] DOT语言 - 维基百科,自由的百科全书
[3] http://www.graphviz.org/content/dot-language

初窥 Elixir

Elixir 是基于 Erlang VM 开发一个新语言,继承了 Erlang 的很多有点,在保持和 Erlang ByteCode 兼容的提前下提供了非常简洁易懂的语法。单从语法层面可能和 Ruby 比较像,不过仔细看还是和 Erlang 有很多一致的地方,如果你不习惯 Erlang 奇怪的语法(其实看过 Prolog 的语法以后就不觉得 Erlang 奇怪了),但是又想享受 Erlang/OTP 带来的各种好处,Elixir 是不错的选择。

Elixir 可以调用 Erlang 的标准库,如果基本不用担心 第三方库不足的问题, Erlang 几十年的积累在这里放着呢,难怪 Joe Armstrong 大叔也对 Elixir 赞不绝口。

类型

iex 来启动的 Elixir Shell,可以看到一个很像 Erlang Shell 的东西:

$ iex
Erlang/OTP 17 [erts-6.1] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Interactive Elixir (1.0.3) - press Ctrl+C to exit (type h() ENTER for help)
iex>

Elixir 支持 Erlang 大部分的类型,integer, float, atom, list, tuple, binary, keywords, map ...

iex> 1
1
iex> "foobar"
"foobar"
iex> 'foobar'
'foobar'
iex> :foo
:foo
iex> [1, 2, 3]
[1, 2, 3]
iex> {:foo, :bar}
{:foo, :bar}
iex> <<104, 101, 108, 108, 111, 0>>
<<104, 101, 108, 108, 111, 0>>
iex> [{:foo, 1}, {:bar, 2}]
[foo: 1, bar: 2]
iex> %{:ok => 1, :fail => 2}
%{fail: 2, ok: 1}

"foobar"'foobar' 是不同的类型,前者是 string,后者是是一个 charlist

iex> is_binary "foobar"
true
iex> is_list 'foobar'
true

Elixir 引入了一个很像 Map 的类型叫 Keyword,但其实 Keyword 就是一个二元的 tuple 列表,不过要求 tuple 的第一个元素是 atom。Keyword 支持类似 Map 的访问方式

iex> keyword1 = [{:foo, 1}, {:bar, 2}]
[foo: 1, bar: 2]
iex> keyword1[:foo]
1
iex> keyword1[:bar]
2

Map 通过 %{ key1 => value1, key2 => value2 ...} 的语法来创建,访问 Map 中的值有 3 种方式:

iex> map = %{:a => 1, 2 => :b}
%{2 => :b, :a => 1}
iex> map[:a]
1
iex> map[2]
:b

不论 Map 还是 Keyword lists 都实现了 Dict 的访问协议,我们可以可以用 Dict 模块同时处理这 2 种数据结构。

iex> map
%{2 => :b, :a => 1}
iex> Dict.get(map, :a)
1
iex> Dict.put_new(map, :c, 3)
%{2 => :b, :a => 1, :c => 3}

模式匹配和递归

Elixir 和 Erlang 一样,实现了模式匹配。模式匹配的强大不用多说,在解析数据的时候可以少写很多很多的 if...else...

iex> {:ok, msg} = {:ok, "success"}
{:ok, "success"}
iex> %{:a => x} = %{:a => 1, :b => 2}
%{a: 1, b: 2}
iex> %{:a => x} = %{:b => 2}
** (MatchError) no match of right hand side value: %{b: 2}

Elixir 里的函数必须定义在 Module 里,让我们实现一个计算斐波那契数列的模块。

defmodule Math do
  @doc"""
  Calculate Fibonacci sequence value
  """
  def fab(1) do 1 end
  def fab(2) do 1 end
  def fab(n) do
    fab(n-1) + fab(n-2)
  end

  @doc """
  Calculates the sum of a number list
  """
  def sum(list) do sum(list, 0) end
  def sum([], sum) do sum end
  def sum([h|t], sum) do sum(t, sum + h) end
  
end

Enumerables & streams & Comprehensions

EnumStream 最大的不同是 Stream 是惰性求值,类似 Python 里的 generator,只有需要计算的时候真正计算。

iex> Enum.map(1..10, fn x -> x * x end)
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
iex> Enum.reduce(1..10, 0, &(&1+&2))
55
iex> 1..10 |> Enum.map(&(&1*&1)) |> Enum.reduce(&(&1+&2))
385
iex> 1..100 |> Stream.map(&(&1*&1)) |> Stream.filter(&(rem(&1, 2) != 0)) |> Enum.sum
166650

Elixir 也提供了列表推倒的机制,利用 for 的 filter 机制,还能方便实现一些功能。

iex> for n <- 1..4, do: n * n
[1, 4, 9, 16]

找到 n 以内满足 a^2 + b^2 = c^2{a, b, c} 元组:

defmodule Triple do
  def pythagorean(n) when n > 0 do
    for a <- 1..n,
        b <- 1..n,
        c <- 1..n,
        a + b + c <= n,
        a*a + b*b == c*c,
        do: {a, b, c}
  end
end

异常处理

Elixir 提供了 3 种错误处理机制:

  • Errors & Exceptions
  • Throws
  • Exits
iex> try do
...>   raise "oops"
...> rescue
...>   RuntimeError -> "Error!"
...> end
"Error!"

iex> spawn_link fn -> exit(1) end
#PID<0.56.0>
** (EXIT from #PID<0.56.0>) 1

调用 Erlang 代码

iex> angle_45_deg = :math.pi() * 45.0 / 180.0
iex> :math.sin(angle_45_deg)
0.7071067811865475

Process

Elixir 借助 ErlangVM 实现了 Actor 模型,进程和进程之间仅通过消息通信,每个 Actor 都有一个 Mailbox ,消息总是被拷贝到 MailBox 中然后等待进程处理。这里的 Process 和操作系统的 Process 不能等同,它比操作系统的 Process 要轻量很多,可能很轻松在单机上开启几十万的 Process。

语言规范

简介

学习Go规范的最好办法就是看Go的源码,官方是这么说的

格式化

格式这个东西,对大部分语言来说都挺重要的,不过Go有个很牛逼的工具叫gofmt,可以从package级别对代码的格式进行格式化,所以这种脏活就交给工具来做吧。

注释

Go提供了C-style的/* */的块注释,和C++-style的// 行内注释

对于每个package,都应当提供响应的注释,如果这个package提供的功能很简单,那么用行内注释写一些简单的说明也是可以的。不必在块注释的每行前都加上*号,注释内的字体可能不是等宽字体,所以也不要依赖空格来对齐,godoc会帮你完成这些事情。

在一个package中,每个被export的方法,都应该包含注释.注释最好以方法名开头,然后用最简单的一句话概括这个函数的用途。

命名

Go推荐采用类似Java的驼峰式的命名,而不是用下划线。

Package names

包名应该尽量简洁且容易记忆,处于习惯一般采用全部小写字母。每一个使用你提供的Package的用户,都需要提前import,所以你也不用特地的检测名字的冲突,万一不行还可以在本地使用别的名字代替。

Getter&Setter

GetAttr就命名成Attr()
SetAttr还是保持SetAttr()
然后注意首字母大写,确保这个函数是被export

Interface

一般就是加个er的后缀,比如Reader, Writer, Notifer

循环结构

Go的循环类似于C,稍有不同。Go统一了forwhile,而且没有do-while

// Like a C for
for init; condition; post { }

// Like a C while
for condition { }

// Like a C for(;;)
for { }

如果要遍历的对象是array, slice, string, map或者从channel读取数据,可以使用range关键字。类似于Python的for ... in ...。在range循环里,可以单独访问key&value,或者一起访问。

// 只访问key
for key := range m {
    if key.expired() {
        delete(m, key)
    }
}
// 只访问value
sum := 0
for _, value := range array {
    sum += value
}
// 两个都要用
for key, value := range oldMap {
    newMap[key] = value
}