hyuan

hyuan 查看完整档案

上海编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑

naive programmer

个人动态

hyuan 赞了文章 · 11月3日

Linux Cgroup系列(05):限制cgroup的CPU使用(subsystem之cpu)

在cgroup里面,跟CPU相关的子系统有cpusetscpuacctcpu

其中cpuset主要用于设置CPU的亲和性,可以限制cgroup中的进程只能在指定的CPU上运行,或者不能在指定的CPU上运行,同时cpuset还能设置内存的亲和性。设置亲和性一般只在比较特殊的情况才用得着,所以这里不做介绍。

cpuacct包含当前cgroup所使用的CPU的统计信息,信息量较少,有兴趣可以去看看它的文档,这里不做介绍。

本篇只介绍cpu子系统,包括怎么限制cgroup的CPU使用上限及相对于其它cgroup的相对值。

本篇所有例子都在ubuntu-server-x86_64 16.04下执行通过

创建子cgroup

在ubuntu下,systemd已经帮我们mount好了cpu子系统,我们只需要在相应的目录下创建子目录就可以了

#从这里的输出可以看到,cpuset被挂载在了/sys/fs/cgroup/cpuset,
#而cpu和cpuacct一起挂载到了/sys/fs/cgroup/cpu,cpuacct下面
dev@ubuntu:~$ mount|grep cpu
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct)

#进入/sys/fs/cgroup/cpu,cpuacct并创建子cgroup
dev@ubuntu:~$ cd /sys/fs/cgroup/cpu,cpuacct
dev@ubuntu:/sys/fs/cgroup/cpu,cpuacct$ sudo mkdir test
dev@ubuntu:/sys/fs/cgroup/cpu,cpuacct$ cd test
dev@ubuntu:/sys/fs/cgroup/cpu,cpuacct/test$ ls
cgroup.clone_children  cpuacct.stat   cpuacct.usage_percpu  cpu.cfs_quota_us  cpu.stat           tasks
cgroup.procs           cpuacct.usage  cpu.cfs_period_us     cpu.shares        notify_on_release

除了cgroup里面通用的cgroup.clone_children、tasks、cgroup.procs、notify_on_release这几个文件外,以cpuacct.开头的文件跟cpuacct子系统有关,我们这里只需要关注cpu.开头的文件。

cpu.cfs_period_us & cpu.cfs_quota_us

cfs_period_us用来配置时间周期长度,cfs_quota_us用来配置当前cgroup在设置的周期长度内所能使用的CPU时间数,两个文件配合起来设置CPU的使用上限。两个文件的单位都是微秒(us),cfs_period_us的取值范围为1毫秒(ms)到1秒(s),cfs_quota_us的取值大于1ms即可,如果cfs_quota_us的值为-1(默认值),表示不受cpu时间的限制。下面是几个例子:

1.限制只能使用1个CPU(每250ms能使用250ms的CPU时间)
    # echo 250000 > cpu.cfs_quota_us /* quota = 250ms */
    # echo 250000 > cpu.cfs_period_us /* period = 250ms */

2.限制使用2个CPU(内核)(每500ms能使用1000ms的CPU时间,即使用两个内核)
    # echo 1000000 > cpu.cfs_quota_us /* quota = 1000ms */
    # echo 500000 > cpu.cfs_period_us /* period = 500ms */

3.限制使用1个CPU的20%(每50ms能使用10ms的CPU时间,即使用一个CPU核心的20%)
    # echo 10000 > cpu.cfs_quota_us /* quota = 10ms */
    # echo 50000 > cpu.cfs_period_us /* period = 50ms */

cpu.shares

shares用来设置CPU的相对值,并且是针对所有的CPU(内核),默认值是1024,假如系统中有两个cgroup,分别是A和B,A的shares值是1024,B的shares值是512,那么A将获得1024/(1204+512)=66%的CPU资源,而B将获得33%的CPU资源。shares有两个特点:

  • 如果A不忙,没有使用到66%的CPU时间,那么剩余的CPU时间将会被系统分配给B,即B的CPU使用率可以超过33%

  • 如果添加了一个新的cgroup C,且它的shares值是1024,那么A的限额变成了1024/(1204+512+1024)=40%,B的变成了20%

从上面两个特点可以看出:

  • 在闲的时候,shares基本上不起作用,只有在CPU忙的时候起作用,这是一个优点。

  • 由于shares是一个绝对值,需要和其它cgroup的值进行比较才能得到自己的相对限额,而在一个部署很多容器的机器上,cgroup的数量是变化的,所以这个限额也是变化的,自己设置了一个高的值,但别人可能设置了一个更高的值,所以这个功能没法精确的控制CPU使用率。

cpu.stat

包含了下面三项统计结果

  • nr_periods: 表示过去了多少个cpu.cfs_period_us里面配置的时间周期

  • nr_throttled: 在上面的这些周期中,有多少次是受到了限制(即cgroup中的进程在指定的时间周期中用光了它的配额)

  • throttled_time: cgroup中的进程被限制使用CPU持续了多长时间(纳秒)

示例

这里以cfs_period_us & cfs_quota_us为例,演示一下如何控制CPU的使用率。

#继续使用上面创建的子cgroup: test
#设置只能使用1个cpu的20%的时间
dev@ubuntu:/sys/fs/cgroup/cpu,cpuacct/test$ sudo sh -c "echo 50000 > cpu.cfs_period_us"
dev@ubuntu:/sys/fs/cgroup/cpu,cpuacct/test$ sudo sh -c "echo 10000 > cpu.cfs_quota_us"

#将当前bash加入到该cgroup
dev@ubuntu:/sys/fs/cgroup/cpu,cpuacct/test$ echo $$
5456
dev@ubuntu:/sys/fs/cgroup/cpu,cpuacct/test$ sudo sh -c "echo 5456 > cgroup.procs"

#在bash中启动一个死循环来消耗cpu,正常情况下应该使用100%的cpu(即消耗一个内核)
dev@ubuntu:/sys/fs/cgroup/cpu,cpuacct/test$ while :; do echo test > /dev/null; done

#--------------------------重新打开一个shell窗口----------------------
#通过top命令可以看到5456的CPU使用率为20%左右,说明被限制住了
#不过这时系统的%us+%sy在10%左右,那是因为我测试的机器上cpu是双核的,
#所以系统整体的cpu使用率为10%左右
dev@ubuntu:~$ top
Tasks: 139 total,   2 running, 137 sleeping,   0 stopped,   0 zombie
%Cpu(s):  5.6 us,  6.2 sy,  0.0 ni, 88.2 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem :   499984 total,    15472 free,    81488 used,   403024 buff/cache
KiB Swap:        0 total,        0 free,        0 used.   383332 avail Mem

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5456 dev       20   0   22640   5472   3524 R  20.3  1.1   0:04.62 bash

#这时可以看到被限制的统计结果
dev@ubuntu:~$ cat /sys/fs/cgroup/cpu,cpuacct/test/cpu.stat
nr_periods 1436
nr_throttled 1304
throttled_time 51542291833

结束语

使用cgroup限制CPU的使用率比较纠结,用cfs_period_us & cfs_quota_us吧,限制死了,没法充分利用空闲的CPU,用shares吧,又没法配置百分比,极其难控制。总之,使用cgroup的cpu子系统需谨慎。

参考

查看原文

赞 14 收藏 8 评论 0

hyuan 发布了文章 · 5月24日

机器学习 - ”没有免费的午餐“定理的理解

最近在看周志华的《机器学习》西瓜书,第一章绪论部分最值得讨论的恐怕就是”没有免费的午餐“定理(No Free Lunch Theorem),这个定理的完整证明很复杂,书里尽可能地将问题简化,但是似乎还是有一些晦涩难懂,尤其是数学公式部分。所以对这一段的内容,本文阐述一下我个人的理解,或许能帮助到大家,或许有不周全的地方也欢迎留言讨论。

直观理解

首先我们避开复杂的理论分析过程,直接跳到结论部分,来看一下这个定理本质上是要告诉我们什么,这一点相信很多读者应该也很清楚。书中使用了如下的例子:

image.png

训练数据集是图中的黑点,如果采用两种不同的算法,他们有着不同的归纳偏好,或者说训练策略,可能会训练出 A 和 B 两个模型(即假设),那么对于第一张图中的测试数据集(白点),显然 A 比 B 拟合得更好,即 A 的泛化能力更强;而对于第二张图则相反,B 在测试数据集上表现更出色。

即便左边的图中的数据点的平滑分布似乎是一个更”常见“,更”合理“的现实问题,但很遗憾这只是你的一厢情愿。从数学上讲左右两张图所描述的问题,它们的地位是一样的,出现的可能也是一样,并不存在谁比谁更常见更符合现实。

”没有免费的午餐“定理正是要告诉我们,没有什么算法是适用于所有的现实问题的,任何两个算法,以及它们训练出来的模型,在所有的现实问题的集合面前是无优劣的,它们的性能的数学期望值是一样的。对于不同的现实问题,要选择相应的算法来解决。

概念阐述

在进入理论分析前,我们先将书中提到的几个重要的概念阐述清楚,这些概念咋一看你可能都没在意,或者没仔细思考过它的含义究竟是什么,所以这里把它们都厘清:

问题

这其实是最核心的一个概念,但书中并没有给出一个明确的阐述,到底什么是所谓的问题。这里的问题,其实就是指一个函数,从输入可以计算得到输出的函数,或者说从输入到输出的映射。我们获取了一堆样例数据,想要求解这个目标函数,通俗来说就是要解决这个问题

所以可以这样简单地等价,一个问题就是一个目标函数,问题的集合,就是目标函数的集合,就是书中所说的函数空间,其实也等价于假设空间 $\cal{H}$,因为我们求解出来的假设 $h$ 必然是属于上述函数空间中的某一个函数,只是它并不一定恰好是目标函数(或者说几乎不可能是),所以在测试样本集上 $h$ 必然会产生误差。

样本与样例

有了问题,或者说目标函数,就有了样本数据,所有样本的集合就是样本空间。不同的目标函数,在样本上会求出不同的标签,这些带了标签的样本就是样例。我们的任务,就是从有限的样例数据中尽可能地能还原出目标函数,使之能很好地描述(拟合)真实问题。

假设

就是我们根据训练数据集,训练出来的函数,这个函数可能并不是这个问题真正的目标函数,但值得注意的是,不管是真正的目标函数,还是我们训练出来的假设函数,它们都来自同一个函数空间

数学建模

在“免费的午餐”问题中,我们评定一个假设,或者说模型的性能,并不是测试它在某一个单独问题上的表现,是将其放在所有问题的集合上,计算它的误差的理论期望值,这也正是书中的公式所要计算的。

看完书中这一段理论推导,我的第一感觉是:似乎能理解了,但又总觉得没法从数学上完全说服自己。不知道多少人和我的感觉是一样的,所以这里我们仔细来分析这段理论推导,来把它的含义真正理解清楚。

问题集合

书中为了简化,将所讨论的问题局限于二分类问题,所有问题出现的可能性是平均分布的,并且样本空间 X 的大小也是有限的。这种简化是为了方便我们理解, 也是合理的。所以我们首先要弄清楚,在这种简化的前提之下,我们所面对的问题集合,或者说函数空间究竟是什么:

假设我们正处于一个问题之下,样本空间 $\cal{X}$ 的大小是 |$\cal{X}$|,每个样本点的标签是 0 或 1。虽然我们并不知道这个问题真正是什么,也就是说并不知道真正的目标函数是什么,但是我们可以知道目标函数来自的集合。对于二分类问题,我们样本空间总共有 |$\cal{X}$| 个样本,映射到 |$\cal{X}$| 个标签上,每个标签都有 0 或 1 两中可能性,那么 $\cal{X}$ 个点就有 $2^{|\cal{X}|}$ 种映射的可能。也就是说对于样本空间 $\cal{X}$,我们的函数空间无非就是这 $2^{|\cal{X}|}$ 个函数(映射)。真正的目标函数必定是这 $2^{|\cal{X}|}$ 个函数中的一个;同样,训练出来的假设也只能来自于这 $2^{|\cal{X}|}$ 个函数的集合,这就是问题集合

算法与假设的关系

书中的第一条公式 1.1,如图所示:

image.png

已经在讨论某一个算法的误差期望值了,这其实在思维上已经有了一个跳跃。我们这里先给它降维,首先讨论某一个假设的误差。注意这里算法假设的区别,按照书中的表述,在同一个问题之下,基于训练数据集 $X$,一个算法 $\cal {L_a}$ 可能会训练出不同的假设 $h$,它们符合一定的概率分布 $P(h|X,\cal{L_a})$。

你可能会问,一个特定算法,在特定的训练集上,训练出的假设不应该是固定的吗,为啥会有一个概率分布?关于这一点我也是存在困惑,但是我们可以先承认这一前提。我的个人解释是,算法是任意的,它的策略中可能也包含了某些随机因子,即便是同一份训练数据集,也可能产出不同的假设。当然你也可以说一般的算法不包含随机因子,基于同一份数据训练出的结果是一定是确定的,那么这个概率分布就只有一个样本,概率为 100%,但是这仍然可以囊括在上述的概率分布理论中,也就是说我们可以认为这个关于 $h$ 的概率分布理论是通用的。

计算误差的期望值

假设 $h$ 在单样本 $x$ 上的误差总和

然而在讨论算法的性能之前,我们首先跳过算法,只关注最后训练出来的假设本身:即对于某一个假设 $h$,它的“训练集外误差”是多少?

在二分类问题的框架之下,我们的函数空间里一共有 $2^{|\cal{X}|}$ 个函数,即 $2^{|\cal{X}|}$ 个问题。我们现在训练出一个假设 $h$,这个 $h$ 当然是这 $2^{|\cal{X}|}$ 个函数中的某一个,那么我们需要计算的就是 $h$ 在这所有 $2^{|\cal{X}|}$ 问题上的总误差是多少。考虑任意单一样本点 $x$,通过 $h$ 能够计算出它的预测标签 $y$,是 0 或 1;而 $x$ 在所有 $2^{|\cal{X}|}$ 个问题上的真正标签,显然其中有一半即 $2^{|\cal{X}|-1}$ 个是 0, 另外一半是 1,因此假设 $h$ 对于样本点 $x$ 的预测标签,在一半的问题上能预测正确,在一半的问题上预测错误,所以误差的期望值综合总和即为 $2^{|\cal{X}|-1}$。

上面的计算过程中,需要理解的核心思想就是:$h$ 来自于函数空间中 $2^{|\cal{X}|}$ 个函数中的某一个,而函数空间即等价于问题集合。$h$ 与函数空间中的所有其它函数的预测结果之差,就是 $h$ 在所有问题全集上误差总和;

我们注意到:

  • 上面计算的是单个假设 $h$,预测单个样本点 $x$,在所有问题上的误差之和。这个单点样本 $x$ 是任意的,但并不影响最后的计算结果,也就说对任意一个样本 $x$,假设 $h$ 在所有问题集合上的预测误差总和是一样的,都是 $2^{|\cal{X}|-1}$;
  • 样本 $x$ 一样, 假设 $h$ 也是任意的,但是不管是哪一个 $h$,同样不影响最后的计算结果,也就是说在所有问题集合面前,任意一个 $h$ 的预测误差总和是一样的,即所有 $h$ 的性能都是一样的;

综上所述我们得出一个基本结论:

  • 对于任意一个假设 $h$,预测任意一个样本 $x$,在所有问题的集合面前,它的预测误差总和是一样的,是一个常数,与 $h$ 和 $x$ 无关;

这个误差之和,我们可以表示为:

$\sum_f{\Bbb {I}}(h(x){\neq}f(x))$ = $2^{|\cal{X}|-1}$

其中 ${\Bbb {I}}$ 为书中的误差函数,为真则取值 1,假则取值 0;$f$ 为真实的目标函数,对全体 $f$ 求和,即表示这个假设 $h$ 在所有问题集合上的误差总和。

假设 $h$ 在数据集上的误差总和期望值

上述表达式针对的是单个样本 $x$ 的,如果将求和扩展到所有“训练集外数据” ${{\cal{X}}-X}$ 上,假设样本 $x$ 在 ${{\cal{X}}-X}$ 上符合分布 $P(x)$ ,则误差总和期望值为:

$\space\space\sum_{{\cal{X}}-X}\sum_fP(x){\cdot}{\Bbb {I}}(h(x){\neq}f(x))$
= $\sum_{{\cal{X}}-X}P(x){\cdot}\sum_f{\Bbb {I}}(h(x){\neq}f(x))$
= $2^{|\cal{X}|-1}\sum_{{\cal{X}}-X}P(x)$

对于上述结论,我们可以理解为,由于 $h$ 对于任意一个样本 $x$ 做预测的误差总和都是 $2^{|\cal{X}|-1}$,这个结果与我们采用哪一个样本 $x$ 是无关的。也就是说,不管 $x$ 在 ${{\cal{X}}-X}$ 上符合怎样的分布,$\Bbb {I}$ 与 $x$ 无关,因此上述计算公式,可以将关于 $x$ 和 $\Bbb {I}$ 的部分提取出来分别求和,最终得到 $2^{|\cal{X}|-1}\sum_{{\cal{X}}-X}P(x)$。

因此对 $x$ 求 $\sum$ 后,我们的结论进一步变成:对于任意一个假设 $h$,在所有问题集合面前,对训练集外数据的预测误差总和的期望值是一样的,都是 $2^{|\cal{X}|-1}\sum_{{\cal{X}}-X}P(x)$,这个结果与假设 $h$ 无关。

其实到这里的结论已经非常接近“没有免费午饭”的本质了,只是目前考察的对象是假设 $h$,而不是算法。这个结论已经告诉我们,任意假设 $h$ 在所有问题集合面前,性能都是平等的。

算法的误差期望值

最后我们终于可以回到“没有免费午餐”理论所讨论的真正对象,即一个算法的性能。前面已经提到过,在同一个问题之下,一个算法 $\cal{L_a}$​,可能会训练出不同的假设 $h$,这一点书中的描述方式是,一个算法 ,基于训练数据集 $X$,它训练出的假设 $\cal{h}$ 是符合分布 $P(h|X,\cal{L_a})$ 的。

前面的结论已经告诉我们,对于任意一个假设 $h$,在所有问题集合面前,预测误差总和的期望值是一样的,都是 $2^{|\cal{X}|-1}\sum_{{\cal{X}}-X}P(x)$,这个结果与 $h$ 无关,即所有 $h$ 平等。既然如此,那么对于一个算法而言,不管它最终训练出的假设 $h$ 是什么,符合怎样的概率分布,它的误差期望值其实也是 $2^{|\cal{X}|-1}\sum_{{\cal{X}}-X}P(x)$。

从数学公式看,一个算法的误差期望值,就是对它所能训练出来的所有 $h$ 的误差求期望值,于是可以用下面的公式表示:

$\space\space\sum_h\sum_{{\cal{X}}-X}\sum_fP(h|X,{\cal{L_a}}){\cdot}P(x){\cdot}{\Bbb {I}}(h(x){\neq}f(x))$
= $\sum_h(h|X,{\cal{L_a}}){\cdot}\sum_{{\cal{X}}-X}P(x){\cdot}\sum_f{\Bbb {I}}(h(x){\neq}f(x))$
= $1{\cdot}\sum_{{\cal{X}}-X}P(x){\cdot}\sum_f{\Bbb {I}}(h(x){\neq}f(x))$
= $2^{|\cal{X}|-1}\sum_{{\cal{X}}-X}P(x)$

这个结论就是书中的最终结论,即对于任意一个算法 $\cal {L_a}$,不管它会训练出怎样的假设,在所有问题集合上的误差期望值是一样的,为 $2^{|\cal{X}|-1}\sum_{{\cal{X}}-X}P(x)$,这个结果与算法无关。

总结

上面这个复杂公式,即为书中给出的数学表达式,只是我把三个求和符号 $\sum$ 按照我个人的理解方式调换了一下顺序。其中我认为最重要的就是最内层求和,即:

一个假设 $h$,在单一样本 $x$ 上做预测,在所有问题集合上的误差总和:

$\sum_f{\Bbb {I}}(h(x){\neq}f(x)) = 2^{|\cal{X}|-1}$

这是维度最低的一层,只针对单一假设 $h$ 和单一样本 $x$,而且该结果与 $h$ 和 $x$ 都无关。

因此我们将求和扩展到所有 $x$ 和 $h$ 时 ,由于彼此独立,那三个 $\sum$ 可以分别单独求和,感性的认知就是,不管 $h$ 在某一算法 $\cal {L_a}$ 训练下的分布如何,也不管 $x$ 在样本空间 ${{\cal{X}}-X}$ 下的分布如何,最终的期望值都是 $2^{|\cal{X}|-1}\sum_{{\cal{X}}-X}P(x)$,是一个常数。而且,假如我们把 $x$ 的求和范围扩展到全体样本空间 ${\cal {X}}$,则最终的期望值为 $2^{|\cal{X}|-1}\sum_{\cal{X}}P(x)$ = $2^{|\cal{X}|-1}$,这是一个更简洁的结果。

这也就是“没有免费午餐”定理所要最终阐述的结论,即不管任何算法,它们会训练出怎样的假设(模型),如果假设全体问题集合的分布是均匀的,那么假设与假设之间就不存在任何优劣之分,算法与算法之间也不存在任何优劣之分,它们的性能期望值都是相等的。当然,必须注意这个结论的前提,就是全体问题,以及它们的分布是均匀的,地位均等,才能推出所有假设和算法的的地位也均等。

但是在现实应用中,我们评估一个算法或者假设的性能,不会把它应用在所有问题上做测试,这显然是不合理的。任何算法和假设都有其特定的适用场景,我们关心的是它们在自己适配的场景上,即在某些问题上,能否表现出最优异的性能。

查看原文

赞 0 收藏 0 评论 0

hyuan 赞了文章 · 2月18日

从Java多线程可见性谈Happens-Before原则

Happens-Before是一个非常抽象的概念,然而它又是学习Java并发编程不可跨域的部分。本文会先阐述Happens-Before在并发编程中解决的问题——多线程可见性,然后再详细讲解Happens-Before原则本身。

Java多线程可见性

在现代操作系统上编写并发程序时,除了要注意线程安全性(多个线程互斥访问临界资源)以外,还要注意多线程对共享变量的可见性,而后者往往容易被人忽略。
可见性是指当一个线程修改了共享变量的值,其它线程能够适时得知这个修改。在单线程环境中,如果在程序前面修改了某个变量的值,后面的程序一定会读取到那个变量的新值。这看起来很自然,然而当变量的写操作和读操作在不同的线程中时,情况却并非如此。

/**
 *《Java并发编程实战》27页程序清单3-1
 */
public class NoVisibility {
    private static boolean ready; 
    private static int number;
    
    private static class ReaderThread extends Thread {
        public void run() {
            while(!ready) {
                Thread.yield();
            }
            System.out.println(number);
        }
    }
    
    public static void main(String[] args) {
        new ReaderThread().start(); //启动一个线程
        number = 42;
        ready = true;
    }
}

上面的代码中,主线程和读线程都访问共享变量ready和number。程序看起来会输出42,但事实上很可能会输出0,或者根本无法终止。这是因为上面的程序缺少线程间变量可见性的保证,所以在主线程中写入的变量值,可能无法被读线程感知到。

为什么会出现线程可见性问题

要想解释为什么会出现线程可见性问题,需要从计算机处理器结构谈起。我们都知道计算机运算任务需要CPU和内存相互配合共同完成,其中CPU负责逻辑计算,内存负责数据存储。CPU要与内存进行交互,如读取运算数据、存储运算结果等。由于内存和CPU的计算速度有几个数量级的差距,为了提高CPU的利用率,现代处理器结构都加入了一层读写速度尽可能接近CPU运算速度的高速缓存来作为内存与CPU之间的缓冲:将运算需要使用的数据复制到缓存中,让CPU运算可以快速进行,计算结束后再将计算结果从缓存同步到主内存中,这样处理器就无须等待缓慢的内存读写了。
高速缓存的引入解决了CPU和内存之间速度的矛盾,但是在多CPU系统中也带来了新的问题:缓存一致性。在多CPU系统中,每个CPU都有自己的高速缓存,所有的CPU又共享同一个主内存。如果多个CPU的运算任务都涉及到主内存中同一个变量时,那同步回主内存时以哪个CPU的缓存数据为准呢?这就需要各个CPU在数据读写时都遵循同一个协议进行操作。
图片描述

参考上图,假设有两个线程A、B分别在两个不同的CPU上运行,它们共享同一个变量X。如果线程A对X进行修改后,并没有将X更新后的结果同步到主内存,则变量X的修改对B线程是不可见的。所以CPU与内存之间的高速缓存就是导致线程可见性问题的一个原因。
CPU和主内存之间的高速缓存还会导致另一个问题——重排序。假设A、B两个线程共享两个变量X、Y,A和B分别在不同的CPU上运行。在A中先更改变量X的值,然后再更改变量Y的值。这时有可能发生Y的值被同步回主内存,而X的值没有同步回主内存的情况,此时对于B线程来说是无法感知到X变量被修改的,或者可以认为对于B线程来说,Y变量的修改被重排序到了X变量修改的前面。上面的程序NoVisibility类中有可能输出0就是这种情况,虽然在主线程中是先修改number变量,再修改ready变量,但对于读线程来说,ready变量的修改有可能被重排序到number变量修改之前。
此外,为了提高程序的执行效率,编译器在生成指令序列时和CPU执行指令序列时,都有可能对指令进行重排序。Java语言规范要求JVM只在单个线程内部维护一种类似串行的语义,即只要程序的最终结果与严格串行环境中执行的结果相同即可。所以在单线程环境中,我们无法察觉到重排序,因为程序重排序后的执行结果与严格按顺序执行的结果相同。就像在类NoVisibility的主线程中,先修改ready变量还是先修改number变量对于主线程自己的执行结果是没有影响的,但是如果number变量和ready变量的修改发生重排序,对读线程是有影响的。所以在编写并发程序时,我们一定要注意重排序对多线程执行结果的影响。
看到这里大家一定会发现,我们所讨论的CPU高速缓存、指令重排序等内容都是计算机体系结构方面的东西,并不是Java语言所特有的。事实上,很多主流程序语言(如C/C++)都存在多线程可见性的问题,这些语言是借助物理硬件和操作系统的内存模型来处理多线程可见性问题的,因此不同平台上内存模型的差异,会影响到程序的执行结果。Java虚拟机规范定义了自己的内存模型JMM(Java Memory Model)来屏蔽掉不同硬件和操作系统的内存模型差异,以实现让Java程序在各种平台下都能达到一致的内存访问结果。所以对于Java程序员,无需了解底层硬件和操作系统内存模型的知识,只要关注Java自己的内存模型,就能够解决Java语言中的内存可见性问题了。

Happens-Before原则

上面讨论了Java中多线程共享变量的可见性问题及产生这种问题的原因。下面我们看一下如何解决这个问题,即当一个多线程共享变量被某个线程修改后,如何让这个修改被需要读取这个变量的线程感知到。
为了方便程序员开发,将底层的烦琐细节屏蔽掉,JMM定义了Happens-Before原则。只要我们理解了Happens-Before原则,无需了解JVM底层的内存操作,就可以解决在并发编程中遇到的变量可见性问题。
JVM定义的Happens-Before原则是一组偏序关系:对于两个操作A和B,这两个操作可以在不同的线程中执行。如果A Happens-Before B,那么可以保证,当A操作执行完后,A操作的执行结果对B操作是可见的。
Happens-Before的规则包括:

  1. 程序顺序规则
  2. 锁定规则
  3. volatile变量规则
  4. 线程启动规则
  5. 线程结束规则
  6. 中断规则
  7. 终结器规则
  8. 传递性规则

下面我们将详细讲述这8条规则的具体内容。

程序顺序规则

在一个线程内部,按照程序代码的书写顺序,书写在前面的代码操作Happens-Before书写在后面的代码操作。这时因为Java语言规范要求JVM在单个线程内部要维护类似严格串行的语义,如果多个操作之间有先后依赖关系,则不允许对这些操作进行重排序。

锁定规则

对锁M解锁之前的所有操作Happens-Before对锁M加锁之后的所有操作。

class HappensBeforeLock {
    private int value = 0;
    
    public synchronized void setValue(int value) {
        this.value = value;
    }
    
    public synchronized int getValue() {
        return value;
    }
}

上面这段代码,setValue和getValue两个方法共享同一个监视器锁。假设setValue方法在线程A中执行,getValue方法在线程B中执行。setValue方法会先对value变量赋值,然后释放锁。getValue方法会先获取到同一个锁后,再读取value的值。所以根据锁定原则,线程A中对value变量的修改,可以被线程B感知到。
如果这个两个方法上没有synchronized声明,则在线程A中执行setValue方法对value赋值后,线程B中getValue方法返回的value值并不能保证是最新值。
本条锁定规则对显示锁(ReentrantLock)和内置锁(synchronized)在加锁和解锁等操作上有着相同的内存语义。
对于锁定原则,可以像下面这样去理解:同一时刻只能有一个线程执行锁中的操作,所以锁中的操作被重排序外界是不关心的,只要最终结果能被外界感知到就好。除了重排序,剩下影响变量可见性的就是CPU缓存了。在锁被释放时,A线程会把释放锁之前所有的操作结果同步到主内存中,而在获取锁时,B线程会使自己CPU的缓存失效,重新从主内存中读取变量的值。这样,A线程中的操作结果就会被B线程感知到了。

volatile变量规则

对一个volatile变量的写操作及这个写操作之前的所有操作Happens-Before对这个变量的读操作及这个读操作之后的所有操作。

Map configOptions;
char[] configText; //线程间共享变量,用于保存配置信息
// 此变量必须定义为volatile
volatile boolean initialized = false;

// 假设以下代码在线程A中执行
// 模拟读取配置信息,当读取完成后将initialized设置为true以通知其他线程配置可用configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

// 假设以下代码在线程B中执行
// 等待initialized为true,代表线程A已经把配置信息初始化完成
while (!initialized) {    
    sleep();
}
//使用线程A中初始化好的配置信息
doSomethingWithConfig();

上面这段代码,读取配置文件的操作和使用配置信息的操作分别在两个不同的线程A、B中执行,两个线程通过共享变量configOptions传递配置信息,并通过共享变量initialized作为初始化是否完成的通知。initialized变量被声明为volatile类型的,根据volatile变量规则,volatile变量的写入操作Happens-Before对这个变量的读操作,所以在线程A中将变量initialized设为true,线程B中是可以感知到这个修改操作的。
但是更牛逼的是,volatile变量不仅可以保证自己的变量可见性,还能保证书写在volatile变量写操作之前的操作对其它线程的可见性。考虑这样一种情况,如果volatile变量仅能保证自己的变量可见性,那么当线程B感知到initialized已经变成true然后执行doSomethingWithConfig操作时,可能无法获取到configOptions最新值而导致操作结果错误。所以volatile变量不仅可以保证自己的变量可见性,还能保证书写在volatile变量写操作之前的操作Happens-Before书写在volatile变量读操作之后的那些操作。
可以这样理解volatile变量的写入和读取操作流程:
首先,volatile变量的操作会禁止与其它普通变量的操作进行重排序,例如上面代码中会禁止initialized = true与它上面的两行代码进行重排序(但是它上面的代码之间是可以重排序的),否则会导致程序结果错误。volatile变量的写操作就像是一条基准线,到达这条线之后,不管之前的代码有没有重排序,反正到达这条线之后,前面的操作都已完成并生成好结果。
然后,在volatile变量写操作发生后,A线程会把volatile变量本身和书写在它之前的那些操作的执行结果一起同步到主内存中。
最后,当B线程读取volatile变量时,B线程会使自己的CPU缓存失效,重新从主内存读取所需变量的值,这样无论是volatile本身,还是书写在volatile变量写操作之前的那些操作结果,都能让B线程感知到,也就是上面程序中的initialized和configOptions变量的最新值都可以让线程B感知到。
原子变量与volatile变量在读操作和写操作上有着相同的语义。

线程启动规则

Thread对象的start方法及书写在start方法前面的代码操作Happens-Before此线程的每一个动作。
start方法和新线程中的动作一定是在两个不同的线程中执行。线程启动规则可以这样去理解:调用start方法时,会将start方法之前所有操作的结果同步到主内存中,新线程创建好后,需要从主内存获取数据。这样在start方法调用之前的所有操作结果对于新创建的线程都是可见的。

线程终止规则

线程中的任何操作都Happens-Before其它线程检测到该线程已经结束。这个说法有些抽象,下面举例子对其进行说明。
假设两个线程s、t。在线程s中调用t.join()方法。则线程s会被挂起,等待t线程运行结束才能恢复执行。当t.join()成功返回时,s线程就知道t线程已经结束了。所以根据本条原则,在t线程中对共享变量的修改,对s线程都是可见的。类似的还有Thread.isAlive方法也可以检测到一个线程是否结束。
可以猜测,当一个线程结束时,会把自己所有操作的结果都同步到主内存。而任何其它线程当发现这个线程已经执行结束了,就会从主内存中重新刷新最新的变量值。所以结束的线程A对共享变量的修改,对于其它检测了A线程是否结束的线程是可见的。

中断规则

一个线程在另一个线程上调用interrupt,Happens-Before被中断线程检测到interrupt被调用。
假设两个线程A和B,A先做了一些操作operationA,然后调用B线程的interrupt方法。当B线程感知到自己的中断标识被设置时(通过抛出InterruptedException,或调用interrupted和isInterrupted),operationA中的操作结果对B都是可见的。

终结器规则

一个对象的构造函数执行结束Happens-Before它的finalize()方法的开始。
“结束”和“开始”表明在时间上,一个对象的构造函数必须在它的finalize()方法调用时执行完。
根据这条原则,可以确保在对象的finalize方法执行时,该对象的所有field字段值都是可见的。

传递性规则

如果操作A Happens-Before B,B Happens-Before C,那么可以得出操作A Happens-Before C。

再次思考Happens-Before规则的真正意义

到这里我们已经讨论了线程的可见性问题和导致这个问题的原因,并详细阐述了8条Happens-Before原则和它们是如何帮助我们解决变量可见性问题的。下面我们在深入思考一下,Happens-Before原则到底是如何解决变量间可见性问题的。
我们已经知道,导致多线程间可见性问题的两个“罪魁祸首”是CPU缓存重排序。那么如果要保证多个线程间共享的变量对每个线程都及时可见,一种极端的做法就是禁止使用所有的重排序和CPU缓存。即关闭所有的编译器、操作系统和处理器的优化,所有指令顺序全部按照程序代码书写的顺序执行。去掉CPU高速缓存,让CPU的每次读写操作都直接与主存交互。
当然,上面的这种极端方案是绝对不可取的,因为这会极大影响处理器的计算性能,并且对于那些非多线程共享的变量是不公平的。
重排序CPU高速缓存有利于计算机性能的提高,但却对多CPU处理的一致性带来了影响。为了解决这个矛盾,我们可以采取一种折中的办法。我们用分割线把整个程序划分成几个程序块,在每个程序块内部的指令是可以重排序的,但是分割线上的指令与程序块的其它指令之间是不可以重排序的。在一个程序块内部,CPU不用每次都与主内存进行交互,只需要在CPU缓存中执行读写操作即可,但是当程序执行到分割线处,CPU必须将执行结果同步到主内存或从主内存读取最新的变量值。那么,Happens-Before规则就是定义了这些程序块的分割线。下图展示了一个使用锁定原则作为分割线的例子:
图片描述

如图所示,这里的unlock M和lock M就是划分程序的分割线。在这里,红色区域和绿色区域的代码内部是可以进行重排序的,但是unlock和lock操作是不能与它们进行重排序的。即第一个图中的红色部分必须要在unlock M指令之前全部执行完,第二个图中的绿色部分必须全部在lock M指令之后执行。并且在第一个图中的unlock M指令处,红色部分的执行结果要全部刷新到主存中,在第二个图中的lock M指令处,绿色部分用到的变量都要从主存中重新读取。
在程序中加入分割线将其划分成多个程序块,虽然在程序块内部代码仍然可能被重排序,但是保证了程序代码在宏观上是有序的。并且可以确保在分割线处,CPU一定会和主内存进行交互。Happens-Before原则就是定义了程序中什么样的代码可以作为分隔线。并且无论是哪条Happens-Before原则,它们所产生分割线的作用都是相同的。

小结

在写作本文时,我主要参考的是《Java并发编程实战》和《深入理解Java虚拟机》的最后一章,此外有部分内容是我自己对并发编程的一些浅薄理解,希望能够对阅读的人有所帮助。如有错误的地方,欢迎大家指正。

查看原文

赞 18 收藏 17 评论 5

hyuan 发布了文章 · 2019-08-03

深入理解 Hbase 架构(翻译)

最近在网上看到一篇很好的讲 HBase 架构的文章(原文在这里),简洁明了,图文并茂,所以这里将其翻译成中文分享。图片引用的是原文中的,技术性术语会尽量使用英文,在比较重要的段落后面都会加上我个人理解的点评。

HBase 架构组件

物理上,Hbase 是由三种类型的 server 组成的的主从式(master-slave)架构:

  • Region Server 负责处理数据的读写请求,客户端请求数据时直接和 Region Server 交互。
  • HBase Master 负责 Region 的分配,DDL(创建,删除 table)等操作。
  • Zookeeper,作为 HDFS 的一部分,负责维护集群状态。

当然底层的存储都是基于 Hadoop HDFS 的:

  • Hadoop DataNode 负责存储 Region Server 所管理的数据。所有的 HBase 数据都存储在 HDFS 文件中。Region Server 和 HDFS DataNode 往往是分布在一起的,这样 Region Server 就能够实现数据本地化(data locality,即将数据放在离需要者尽可能近的地方)。HBase 的数据在写的时候是本地的,但是当 region 被迁移的时候,数据就可能不再满足本地性了,直到完成 compaction,才能又恢复到本地。
  • Hadoop NameNode 维护了所有 HDFS 物理 data block 的元信息。

图片描述

Regions

HBase Table)根据 rowkey 的范围被水平拆分成若干个 region。每个 region 都包含了这个region 的 start keyend key 之间的所有row)。Regions 被分配给集群中的某些节点来管理,即 Region Server,由它们来负责处理数据的读写请求。每个 Region Server 大约可以管理 1000 个 regions。

图片描述

HBase Master

也叫 HMaster,负责 Region 的分配,DDL(创建,删除表)等操作:

统筹协调所有 region server:

  • 启动时分配 regions,在故障恢复和负载均衡时重分配 regions
  • 监控集群中所有 Region Server 实例(从 Zookeeper 获取通知信息)

管理员功能:

  • 提供创建,删除和更新 HBase Table 的接口

图片描述

Zookeeper

HBase 使用 Zookeeper 做分布式管理服务,来维护集群中所有服务的状态。Zookeeper 维护了哪些 servers 是健康可用的,并且在 server 故障时做出通知。Zookeeper 使用一致性协议来保证分布式状态的一致性。注意这需要三台或者五台机器来做一致性协议。

图片描述

这些组件是如何一起工作的

Zookeeper 用来协调分布式系统中集群状态信息的共享。Region Servers 和 在线 HMaster(active HMaster)和 Zookeeper 保持会话(session)。Zookeeper 通过心跳检测来维护所有临时节点(ephemeral nodes)。

图片描述

每个 Region Server 都会创建一个 ephemeral 节点。HMaster 会监控这些节点来发现可用的 Region Servers,同样它也会监控这些节点是否出现故障。

HMaster 们会竞争创建 ephemeral 节点,而 Zookeeper 决定谁是第一个作为在线 HMaster,保证线上只有一个 HMaster。在线 HMaster(active HMaster) 会给 Zookeeper 发送心跳,不在线的待机 HMaster (inactive HMaster) 会监听 active HMaster 可能出现的故障并随时准备上位。

如果有一个 Region Server 或者 HMaster 出现故障或各种原因导致发送心跳失败,它们与 Zookeeper 的 session 就会过期,这个 ephemeral 节点就会被删除下线,监听者们就会收到这个消息。Active HMaster 监听的是 region servers 下线的消息,然后会恢复故障的 region server 以及它所负责的 region 数据。而 Inactive HMaster 关心的则是 active HMaster 下线的消息,然后竞争上线变成 active HMaster。

(点评:这一段非常重要,涉及到分布式系统设计中的一些核心概念,包括集群状态、一致性等。可以看到 Zookeeper 是沟通一切的桥梁,所有的参与者都和 Zookeeper 保持心跳会话,并从 Zookeeper 获取它们需要的集群状态信息,来管理其它节点,转换角色,这也是分布式系统设计中很重要的思想,由专门的服务来维护分布式集群状态信息。)

第一次读和写操作

有一个特殊的 HBase Catalog 表叫 Meta table(它其实是一张特殊的 HBase 表),包含了集群中所有 regions 的位置信息。Zookeeper 保存了这个 Meta table 的位置。

当 HBase 第一次读或者写操作到来时:

  • 客户端从 Zookeeper 那里获取是哪一台 Region Server 负责管理 Meta table。
  • 客户端会查询那台管理 Meta table 的 Region Server,进而获知是哪一台 Region Server 负责管理本次数据请求所需要的 rowkey。客户端会缓存这个信息,以及 Meta table 的位置信息本身。
  • 然后客户端回去访问那台 Region Server,获取数据。

对于以后的的读请求,客户端从可以缓存中直接获取 Meta table 的位置信息(在哪一台 Region Server 上),以及之前访问过的 rowkey 的位置信息(哪一台 Region Server 上),除非因为 Region 被迁移了导致缓存失效。这时客户端会重复上面的步骤,重新获取相关位置信息并更新缓存。

图片描述

(点评:客户端读写数据,实际上分了两步:第一步是定位,从 Meta table 获取 rowkey 属于哪个 Region Server 管理;第二步再去相应的 Region Server 读写数据。这里涉及到了两个 Region Server,要理解它们各自的角色功能。关于 Meta table 下面会详细介绍。)

HBase Meta Table

Meta table 是一个特殊的 HBase table,它保存了系统中所有的 region 列表。这张 table 类似一个 b-tree,结构大致如下:

  • Key:table, region start key, region id
  • Value:region server

图片描述

Region Server 组成

Region Server 运行在 HDFS DataNode 上,由以下组件组成:

  • WAL:Write Ahead Log 是分布式文件系统上的一个文件,用于存储新的还未被持久化存储的数据,它被用来做故障恢复。
  • BlockCache:这是读缓存,在内存中存储了最常访问的数据,是 LRU(Least Recently Used)缓存。
  • MemStore:这是写缓存,在内存中存储了新的还未被持久化到硬盘的数据。当被写入硬盘时,数据会首先被排序。注意每个 Region 的每个 Column Family 都会有一个 MemStore。
  • HFile 在硬盘上(HDFS)存储 HBase 数据,以有序 KeyValue 的形式。

图片描述

(点评:这一段是重中之重,理解 Region Server 的组成对理解 HBase 的架构至关重要,要充分认识 Region Server 的功能,以及每个组件的作用,这些组件的行为和功能在后续的段落中都会一一展开。)

HBase 写数据步骤

当客户端发起一个写数据请求(Put 操作),第一步首先是将数据写入到 WAL 中:

  • 新数据会被追加到 WAL 文件尾部。
  • WAL 用来在故障恢复时恢复还未被持久化的数据。

图片描述

数据被写入 WAL 后,会被加入到 MemStore 即写缓存。然后服务端就可以向客户端返回 ack 表示写数据完成。

(点评:注意数据写入时 WAL 和 MemStore 更新的顺序,不能调换,必须先 WAL 再 MemStore。如果反过来,先更新完 MemStore,此时 Region Server 发生 crash,内存中的更新就丢失了,而此时数据还未被持久化到 WAL,就无法恢复了。理论上 WAL 就是 MemStore 中数据的一个镜像,应该保持一致,除非发生系统 crash。另外注意更新 WAL 是在文件尾部追加的方式,这种磁盘操作性能很高,不会太影响请求的整体响应时间。)

图片描述

HBase MemStore

MemStore 在内存中缓存 HBase 的数据更新,以有序 KeyValues 的形式,这和 HFile 中的存储形式一样。每个 Column Family 都有一个 MemStore,所有的更新都以 Column Family 为单位进行排序。

图片描述

HBase Region Flush

MemStore 中累积了足够多的的数据后,整个有序数据集就会被写入一个新的 HFile 文件到 HDFS 上。HBase 为每个 Column Family 都创建一个 HFile,里面存储了具体的 Cell,也即 KeyValue 数据。随着时间推移,HFile 会不断产生,因为 KeyValue 会不断地从 MemStore 中被刷写到硬盘上。

注意这也是为什么 HBase 要限制 Column Family 数量的一个原因。每个 Column Family 都有一个 MemStore;如果一个 MemStore 满了,所有的 MemStore 都会被刷写到硬盘。同时它也会记录最后写入的数据的最大序列号sequence number),这样系统就能知道目前为止哪些数据已经被持久化了。

最大序列号是一个 meta 信息,被存储在每个 HFile 中,来表示持久化进行到哪条数据了,应该从哪里继续。当 region 启动时,这些序列号会被读取,取其中最大的一个,作为基础序列号,后面的新的数据更新就会在该值的基础上递增产生新的序列号。

图片描述

(点评:这里有个序列号的概念,每次 HBase 数据更新都会绑定一个新的自增序列号。而每个 HFile 则会存储它所保存的数据的最大序列号,这个元信息非常重要,它相当于一个 commit point,告诉我们在这个序列号之前的数据已经被持久化到硬盘了。它不仅在 region 启动时会被用到,在故障恢复时,也能告诉我们应该从 WAL 的什么位置开始回放数据的历史更新记录。)

HBase HFile

数据存储在 HFile 中,以 Key/Value 形式。当 MemStore 累积了足够多的数据后,整个有序数据集就会被写入一个新的 HFile 文件到 HDFS 上。整个过程是一个顺序写的操作,速度非常快,因为它不需要移动磁盘头。(注意 HDFS 不支持随机修改文件操作,但支持 append 操作。)

图片描述

HBase HFile 文件结构

HFile 使用多层索引来查询数据而不必读取整个文件,这种多层索引类似于一个 B+ tree:

  • KeyValues 有序存储。
  • rowkey 指向 index,而 index 则指向了具体的 data block,以 64 KB 为单位。
  • 每个 block 都有它的叶索引。
  • 每个 block 的最后一个 key 都被存储在中间层索引。
  • 索引根节点指向中间层索引。

trailer 指向原信息数据块,它是在数据持久化为 HFile 时被写在 HFile 文件尾部。trailer 还包含例如布隆过滤器和时间范围等信息。布隆过滤器用来跳过那些不包含指定 rowkey 的文件,时间范围信息则是根据时间来过滤,跳过那些不在请求的时间范围之内的文件。

图片描述

HFile 索引

刚才讨论的索引,在 HFile 被打开时会被载入内存,这样数据查询只要一次硬盘查询。

图片描述

HBase Read 合并

我们已经发现,每行(row)的 KeyValue cells 可能位于不同的地方,这些 cell 可能被写入了 HFile,可能是最近刚更新的,还在 MemStore 中,也可能最近刚读过,缓存在 Block Cache 中。所以,当你读一行 row 时,系统怎么将对应的 cells 返回呢?一次 read 操作会将 Block Cache,MemStore 和 HFile 中的 cell 进行合并:

  • 首先 scanner 从 Block Cache 读取 cells。最近读取的 KeyValue 都被缓存在这里,这是 一个 LRU 缓存。
  • 然后 scanner 读取 MemStore,即写缓存,包含了最近更新的数据。
  • 如果 scanner 没有在 BlockCache 和 MemStore 都没找到对应的 cells,则 HBase 会使用 Block Cache 中的索引和布隆过滤器来加载对应的 HFile 到内存,查找到请求的 row cells。

图片描述

之前讨论过,每个 MemStore 可能会有多个 HFile,所以一次 read 请求可能需要多读个文件,这可能会影响性能,这被称为读放大read amplification)。

(点评:从时间轴上看,一个个的 HFile 也是有序的,本质上它们保存了每个 region 的每个 column family 的数据历史更新。所以对于同一个 rowkey 的同一个 cell,它可能也有多个版本的数据分布在不同的 HFile 中,所以可能需要读取多个 HFiles,这样性能开销会比较大,尤其是当不满足 data locality 时这种 read amplification 情况会更加严重。这也是后面会讲到的 compaction 必要的原因)

图片描述

HBase Minor Compaction

HBase 会自动合并一些小的 HFile,重写成少量更大的 HFiles。这个过程被称为 minor compaction。它使用归并排序算法,将小文件合并成大文件,有效减少 HFile 的数量。

图片描述

HBase Major Compaction

Major Compaction 合并重写每个 Column Family 下的所有的 HFiles,成为一个单独的大 HFile,在这个过程中,被删除的和过期的 cell 会被真正从物理上删除,这能提高读的性能。但是因为 major compaction 会重写所有的 HFile,会产生大量的硬盘 I/O 和网络开销。这被称为写放大Write Amplification)。

Major compaction 可以被设定为自动调度。因为存在 write amplification 的问题,major compaction 一般都安排在周末和半夜。MapR 数据库对此做出了改进,并不需要做 compaction。Major compaction 还能将因为服务器 crash 或者负载均衡导致的数据迁移重新移回到离 Region Server 的地方,这样就能恢复 data locality

图片描述

Region = Contiguous Keys

我们再来回顾一下 region 的概念:

  • HBase Table 被水平切分成一个或数个 regions。每个 region 包含了连续的,有序的一段 rows,以 start key 和 end key 为边界。
  • 每个 region 的默认大小为 1GB。
  • region 里的数据由 Region Server 负责读写,和 client 交互。
  • 每个 Region Server 可以管理约 1000 个 regions(它们可能来自一张表或者多张表)。

图片描述

Region 分裂

一开始每个 table 默认只有一个 region。当一个 region 逐渐变得很大时,它会分裂(split)成两个子 region,每个子 region 都包含了原来 region 一半的数据,这两个子 region 并行地在原来这个 region server 上创建,这个分裂动作会被报告给 HMaster。处于负载均衡的目的,HMaster 可能会将新的 region 迁移给其它 region server。

图片描述

Read 负载均衡

Splitting 一开始是发生在同一台 region server 上的,但是出于负载均衡的原因,HMaster 可能会将新的 regions 迁移给它 region server,这会导致那些 region server 需要访问离它比较远的 HDFS 数据,直到 major compaction 的到来,它会将那些远方的数据重新移回到离 region server 节点附近的地方。

(点评:注意这里的迁移的概念,只是逻辑上的迁移,即将某个 region 交给另一个 region server 管理。)

图片描述

HDFS 数据备份

所有的读写都发生在 HDFS 的主 DataNode 节点上。 HDFS 会自动备份 WAL 和 HFile 的文件 blocks。HBase 依赖于 HDFS 来保证数据完整安全。当数据被写入 HDFS 时,一份会写入本地节点,另外两个备份会被写入其它节点。

图片描述

WAL 和 HFiles 都会持久化到硬盘并备份。那么 HBase 是怎么恢复 MemStore 中还未被持久化到 HFile 的数据呢?下面的章节会讨论这个问题。

图片描述

HBase 故障恢复

当某个 Region Server 发生 crash 时,它所管理的 region 就无法被访问了,直到 crash 被检测到,然后故障恢复完成,这些 region 才能恢复访问。Zookeeper 依靠心跳检测发现节点故障,然后 HMaster 会收到 region server 故障的通知。

当 HMaster 发现某个 region server 故障,HMaster 会将这个 region server 所管理的 regions 分配给其它健康的 region servers。为了恢复故障的 region server 的 MemStore 中还未被持久化到 HFile 的数据,HMaster 会将 WAL 分割成几个文件,将它们保存在新的 region server 上。每个 region server 然后回放各自拿到的 WAL 碎片中的数据,来为它所分配到的新 region 建立 MemStore。

图片描述

WAL 包含了一系列的修改操作,每个修改都表示一个 put 或者 delete 操作。这些修改按照时间顺序依次写入,持久化时它们被依次写入 WAL 文件的尾部。

当数据仍然在 MemStore 还未被持久化到 HFile 怎么办呢?WAL 文件会被回放。操作的方法是读取 WAL 文件,排序并添加所有的修改记录到 MemStore,最后 MemStore 会被刷写到 HFile。

图片描述

(点评:故障恢复是 HBase 可靠性保障的一个重要特性。WAL 在这里扮演了关键角色,在分割 WAL 时,数据会根据 region 分配到对应的新的 region server 上,然后 region server 负责回放这一部分数据到 MemStore 中。)

Apache HBase 架构的优点

  • 强一致性:

    - 当 write 返回时,所有的 reader 都会读到同样的值。 
  • 自动扩展性

    - 数据变大时 region 会分裂。
    - 使用 HDFS 存储备份数据。
  • 内置恢复功能

    - 使用 Write Ahead Log (类似于文件系统中的日志)
  • 与 Hadoop 结合:

    - 使用 MapReduce 处理 HBase 数据会非常直观。
    

Apache HBase 也有问题

  • 业务持续可靠性:

    - WAL 回放很慢。
    - 故障恢复很慢。
    - Major Compaction 时候 I/O 会飙升。
    
查看原文

赞 13 收藏 11 评论 1

hyuan 赞了文章 · 2018-08-16

次时代Java编程(一) Java里的协程 | 出续篇 更新ed

什么是协程(coroutine)

这东西其实有很多名词,比如有的人喜欢称为纤程(Fiber),或者绿色线程(GreenThread)。其实最直观的解释可以定义为线程的线程。有点拗口,但本质上就是这样。

我们先回忆一下线程的定义,操作系统产生一个进程,进程再产生若干个线程并行的处理逻辑,线程的切换由操作系统负责调度。传统语言C++ Java等线程其实与操作系统线程是1:1的关系,每个线程都有自己的Stack, Java在64位系统默认Stack大小是1024KB,所以指望一个进程开启上万个线程是不现实的。但是实际上我们也不会这么干,因为起这么多线程并不能充分的利用CPU,大部分线程处于等待状态,CPU也没有这么核让线程使用。所以一般线程数目都是CPU的核数。

JAVA.jpg

传统的J2EE系统都是基于每个请求占用一个线程去完成完整的业务逻辑,(包括事务)。所以系统的吞吐能力取决于每个线程的操作耗时。如果遇到很耗时的I/O行为,则整个系统的吞吐立刻下降,比如JDBC是同步阻塞的,这也是为什么很多人都说数据库是瓶颈的原因。这里的耗时其实是让CPU一直在等待I/O返回,说白了线程根本没有利用CPU去做运算,而是处于空转状态。暴殄天物啊。另外过多的线程,也会带来更多的ContextSwitch开销。

Java的JDK里有封装很好的ThreadPool,可以用来管理大量的线程生命周期,但是本质上还是不能很好的解决线程数量的问题,以及线程空转占用CPU资源的问题。

先阶段行业里的比较流行的解决方案之一就是单线程加上异步回调。其代表派是node.js以及Java里的新秀Vert.x。他们的核心思想是一样的,遇到需要进行I/O操作的地方,就直接让出CPU资源,然后注册一个回调函数,其他逻辑则继续往下走,I/O结束后带着结果向事件队列里插入执行结果,然后由事件调度器调度回调函数,传入结果。这时候执行的地方可能就不是你原来的代码区块了,具体表现在代码层面上,你会发现你的局部变量全部丢失,毕竟相关的栈已经被覆盖了,所以为了保存之前的栈上数据,你要么选择带着一起放入回调函数里,要么就不停的嵌套,从而引起反人类的Callback hell.

因此相关的Promise,CompletableFuture等技术都是为解决相关的问题而产生的。但是本质上还是不能解决业务逻辑的割裂。

说了这么多,终于可以提一下协程了,协程的本质上其实还是和上面的方法一样,只不过他的核心点在于调度那块由他来负责解决,遇到阻塞操作,立刻yield掉,并且记录当前栈上的数据,阻塞完后立刻再找一个线程恢复栈并把阻塞的结果放到这个线程上去跑,这样看上去好像跟写同步代码没有任何差别,这整个流程可以称为coroutine,而跑在由coroutine负责调度的线程称为Fiber。比如Golang里的 go关键字其实就是负责开启一个Fiber,让func逻辑跑在上面。而这一切都是发生的用户态上,没有发生在内核态上,也就是说没有ContextSwitch上的开销。

既然我们的标题叫Java里的协程,自然我们会讨论JVM上的实现,JVM上早期有kilim以及现在比较成熟的Quasar。而本文章会全部基于Quasar,因为kilim已经很久不更新了。

简单的例子,用Java写出Golang的味道

上面已经说明了什么是Fiber,什么是coroutine。这里尝试通过Quasar来实现类似于golang的coroutine以及channel。这里假设各位已经大致了解golang。

为了对比,这里先用golang实现一个对于10以内自然数分别求平方的例子,当然了可以直接单线程for循环就完事了,但是为了凸显coroutine的高逼格,我们还是要稍微复杂化一点的。

func counter(out chan<- int) {
  for x := 0; x < 10; x++ {
    out <- x
  }
  close(out)
}

func squarer(out chan<- int, in <-chan int) {
  for v := range in {
    out <- v * v
  }
  close(out)
}

func printer(in <-chan int) {
  for v := range in {
    fmt.Println(v)
  }
}

func main() {
  //定义两个int类型的channel
  naturals := make(chan int)
  squares := make(chan int)

  //产生两个Fiber,用go关键字
  go counter(naturals)
  go squarer(squares, naturals)
  //获取计算结果
  printer(squares)
}

上面的例子,有点类似生产消费者模式,通过channel两解耦两边的数据共享。大家可以将channel理解为Java里的SynchronousQueue。那传统的基于线程模型的Java实现方式,想必大家都知道怎么做,这里就不啰嗦了,我直接上Quasar版的,几乎可以原封不动的copy golang的代码。


public class Example {

  private static void printer(Channel<Integer> in) throws SuspendExecution,  InterruptedException {
    Integer v;
    while ((v = in.receive()) != null) {
      System.out.println(v);
    }
  }

  public static void main(String[] args) throws ExecutionException, InterruptedException, SuspendExecution {
    //定义两个Channel
    Channel<Integer> naturals = Channels.newChannel(-1);
    Channel<Integer> squares = Channels.newChannel(-1);

    //运行两个Fiber实现.
    new Fiber(() -> {
      for (int i = 0; i < 10; i++)
        naturals.send(i);
      naturals.close();
    }).start();

    new Fiber(() -> {
      Integer v;
      while ((v = naturals.receive()) != null)
        squares.send(v * v);
      squares.close();
    }).start();

    printer(squares);
  }
}

看起来Java似乎要啰嗦一点,没办法这是Java的风格,而且毕竟不是语言上支持coroutine,是通过第三方的库。到后面我会考虑用其他JVM上的语言去实现,这样会显得更精简一点。

说到这里各位肯定对Fiber很好奇了。也许你会表示怀疑Fiber是不是如上面所描述的那样,下面我们尝试用Quasar建立一百万个Fiber,看看内存占用多少,我先尝试了创建百万个Thread

for (int i = 0; i < 1_000_000; i++) {
  new Thread(() -> {
    try {
      Thread.sleep(10000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }).start();
}

很不幸,直接报Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread,这是情理之中的。下面是通过Quasar建立百万个Fiber

public static void main(String[] args) throws ExecutionException, InterruptedException, SuspendExecution {
  int FiberNumber = 1_000_000;
  CountDownLatch latch = new CountDownLatch(1);
  AtomicInteger counter = new AtomicInteger(0);

  for (int i = 0; i < FiberNumber; i++) {
    new Fiber(() -> {
      counter.incrementAndGet();
      if (counter.get() == FiberNumber) {
        System.out.println("done");
      }
      Strand.sleep(1000000);
    }).start();
  }
  latch.await();
}

我这里加了latch,阻止程序跑完就关闭,Strand.sleep其实跟Thread.sleep一样,只是这里针对的是Fiber

最终控制台是可以输出done的,说明程序已经创建了百万个Fiber,设置Sleep是为了让Fiber一直运行,从而方便计算内存占用。官方宣称一个空闲的Fiber大约占用400Byte,那这里应该是占用400MB堆内存,但是这里通过jmap -heap pid显示大约占用了1000MB,也就是说一个Fiber占用1KB。

Quasar是怎么实现Fiber的

其实Quasar实现的coroutine的方式与Golang很像,只不过一个是框架级别实现,一个是语言内置机制而已。

如果你熟悉了Golang的调度机制,那理解Quasar的调度机制就会简单很多,因为两者是差不多的。

Quasar里的Fiber其实是一个continuation,他可以被Quasar定义的scheduler调度,一个continuation记录着运行实例的状态,而且会被随时中断,并且也会随后在他被中断的地方恢复。Quasar其实是通过修改bytecode来达到这个目的,所以运行Quasar程序的时候,你需要先通过java-agent在运行时修改你的代码,当然也可以在编译期间这么干。golang的内置了自己的调度器,Quasar则默认使用ForkJoinPool这个JDK7以后才有的,具有work-stealing功能的线程池来当调度器。work-stealing非常重要,因为你不清楚哪个Fiber会先执行完,而work-stealing可以动态的从其他的等等队列偷一个context过来,这样可以最大化使用CPU资源。

那这里你会问了,Quasar怎么知道修改哪些字节码呢,其实也很简单,Quasar会通过java-agent在运行时扫描哪些方法是可以中断的,同时会在方法被调用前和调度后的方法内插入一些continuation逻辑,如果你在方法上定义了@Suspendable注解,那Quasar会对调用该注解的方法做类似下面的事情。

这里假设你在方法f上定义了@Suspendable,同时去调用了有同样注解的方法g,那么所有调用f的方法会插入一些字节码,这些字节码的逻辑就是记录当前Fiber栈上的状态,以便在未来可以动态的恢复。(Fiber类似线程也有自己的栈)。在suspendable方法链内Fiber的父类会调用Fiber.park,这样会抛出SuspendExecution异常,从而来停止线程的运行,好让Quasar的调度器执行调度。这里的SuspendExecution会被Fiber自己捕获,业务层面上不应该捕获到。如果Fiber被唤醒了(调度器层面会去调用Fiber.unpark),那么f会在被中断的地方重新被调用(这里Fiber会知道自己在哪里被中断),同时会把g的调用结果(g会return结果)插入到f的恢复点,这样看上去就好像g的return是flocal variables了,从而避免了callback嵌套。

上面啰嗦了一大堆,其实简单点讲就是,想办法让运行中的线程栈停下来,好让Quasar的调度器介入。JVM线程中断的条件只有两个,一个是抛异常,另外一个就是return。这里Quasar就是通过抛异常的方式来达到的,所以你会看到我上面的代码会抛出SuspendExecution。但是如果你真捕获到这个异常,那就说明有问题了,所以一般会这么写。

@Suspendable
public int f() {
  try {
    // do some stuff
    return g() * 2;
  } catch(SuspendExecution s) {
    //这里不应该捕获到异常.
    throw new AssertionError(s);
  }
}

与Golang性能对比

在github上无意中发现一个有趣的benchmark,大致是测试各种语言在生成百万actor/Fiber的开销skynet
大致的逻辑是先生成10个Fiber,每个Fiber再生成10个Fiber,直到生成1百万个Fiber,然后每个Fiber做加法累积计算,并把结果发到channel里,这样一直递归到根Fiber。后将最终结果发到channel。如果逻辑没有错的话结果应该是499999500000。我们搞个Quasar版的,来测试一下性能。

所有的测试都是基于我的Macbook Pro Retina 2013later。Quasar-0.7.5:JDK8,JDK 1.8.0_91,Golang 1.6

public class Skynet {

  private static final int RUNS = 4;
  private static final int BUFFER = 1000; // = 0 unbufferd, > 0 buffered ; < 0 unlimited

  static void skynet(Channel<Long> c, long num, int size, int div) throws SuspendExecution, InterruptedException {
    if (size == 1) {
      c.send(num);
      return;
    }

    Channel<Long> rc = newChannel(BUFFER);
    long sum = 0L;
    for (int i = 0; i < div; i++) {
      long subNum = num + i * (size / div);
      new Fiber(() -> skynet(rc, subNum, size / div, div)).start();
    }
    for (int i = 0; i < div; i++)
      sum += rc.receive();
    c.send(sum);
  }

  public static void main(String[] args) throws Exception {
    //这里跑4次,是为了让JVM预热好做优化,所以我们以最后一个结果为准。
    for (int i = 0; i < RUNS; i++) {
      long start = System.nanoTime();

      Channel<Long> c = newChannel(BUFFER);
      new Fiber(() -> skynet(c, 0, 1_000_000, 10)).start();
      long result = c.receive();

      long elapsed = (System.nanoTime() - start) / 1_000_000;
      System.out.println((i + 1) + ": " + result + " (" + elapsed + " ms)");
    }
  }
}

golang的代码我就不贴了,大家可以从github上拿到,我这里直接贴出结果。

platformtime
Golang261ms
Quasar612ms

从Skynet测试中可以看出,Quasar的性能对比Golang还是有差距的,但是不应该达到两倍多吧,经过向Quasar作者求证才得知这个测试并没有测试出实际性能,只是测试调度开销而已。

因为skynet方法内部几乎没有做任何事情,只是简单的做了一个加法然后进一步的递归生成新的Fiber而已,相当于只是测试了Quasar生成并调度百万Fiber所需要的时间而已。而Java里的加法操作开销远比生成Fiber的开销要低,因此感觉整体性能不如golang(golang的coroutine是语言级别的)。

实际上我们在实际项目中生成的Fiber中不可能只做一下简单的加法就退出,至少要花费1ms做一些简单的事情吧,(Quasar里Fiber的调度差不多在us级别),所以我们考虑在skynet里加一些比较耗时的操作,比如随机生成1000个整数并对其进行排序,这样Fiber里算是有了相应的性能开销,与调度的开销相比,调度的开销就可以忽略不计了。(大家可以把调度开销想象成不定积分的常数)。

下面我分别为两种语言了加了数组排序逻辑,并插在响应的Fiber里。

public class Skynet {

  private static Random random = new Random();
  private static final int NUMBER_COUNT = 1000;
  private static final int RUNS = 4;
  private static final int BUFFER = 1000; // = 0 unbufferd, > 0 buffered ; < 0 unlimited

  private static void numberSort() {
    int[] nums = new int[NUMBER_COUNT];
    for (int i = 0; i < NUMBER_COUNT; i++)
      nums[i] = random.nextInt(NUMBER_COUNT);
    Arrays.sort(nums);
  }

  static void skynet(Channel<Long> c, long num, int size, int div) throws SuspendExecution, InterruptedException {
    if (size == 1) {
      c.send(num);
      return;
    }
    //加入排序逻辑
    numberSort();
    Channel<Long> rc = newChannel(BUFFER);
    long sum = 0L;
    for (int i = 0; i < div; i++) {
      long subNum = num + i * (size / div);
      new Fiber(() -> skynet(rc, subNum, size / div, div)).start();
    }
    for (int i = 0; i < div; i++)
      sum += rc.receive();
    c.send(sum);
  }

  public static void main(String[] args) throws Exception {
    for (int i = 0; i < RUNS; i++) {
      long start = System.nanoTime();

      Channel<Long> c = newChannel(BUFFER);
      new Fiber(() -> skynet(c, 0, 1_000_000, 10)).start();
      long result = c.receive();

      long elapsed = (System.nanoTime() - start) / 1_000_000;
      System.out.println((i + 1) + ": " + result + " (" + elapsed + " ms)");
    }
  }
}
const (
    numberCount = 1000
    loopCount   = 1000000
)

//排序函数
func numberSort() {
    nums := make([]int, numberCount)
    for i := 0; i < numberCount; i++ {
        nums[i] = rand.Intn(numberCount)
    }
    sort.Ints(nums)
}

func skynet(c chan int, num int, size int, div int) {
    if size == 1 {
        c <- num
        return
    }
  //加了排序逻辑
    numberSort()
    rc := make(chan int)
    var sum int
    for i := 0; i < div; i++ {
        subNum := num + i*(size/div)
        go skynet(rc, subNum, size/div, div)
    }
    for i := 0; i < div; i++ {
        sum += <-rc
    }
    c <- sum
}

func main() {
    c := make(chan int)
    start := time.Now()
    go skynet(c, 0, loopCount, 10)
    result := <-c
    took := time.Since(start)
    fmt.Printf("Result: %d in %d ms.\n", result, took.Nanoseconds()/1e6)
}
platformtime
Golang23615ms
Quasar15448ms

最后再进行一次测试,发现Java的性能优势体现出来了。几乎是golang的1.5倍,这也许是JVM/JDK经过多年优化的优势。因为加了业务逻辑后,对比的就是各种库以及编译器对语言的优化了,协程调度开销几乎可以忽略不计。

为什么协程在Java里一直那么小众

其实早在JDK1的时代,Java的线程被称为GreenThread,那个时候就已经有了Fiber,但是当时不能与操作系统实现N:M绑定,所以放弃了。现在Quasar凭借ForkJoinPool这个成熟的线程调度库。

另外,如果你希望你的代码能够跑在Fiber里面,需要一个很大的前提条件,那就是你所有的库,必须是异步无阻塞的,也就说必须类似于node.js上的库,所有的逻辑都是异步回调,而自Java里基本上所有的库都是同步阻塞的,很少见到异步无阻塞的。而且得益于J2EE,以及Java上的三大框架(SSH)洗脑,大部分Java程序员都已经习惯了基于线程,线性的完成一个业务逻辑,很难让他们接受一种将逻辑割裂的异步编程模型。

但是随着异步无阻塞这股风气起来,以及相关的coroutine语言Golang大力推广,人们越来越知道如何更好的榨干CPU性能(让CPU避免不必要的等待,减少上下文切换),阻塞的行为基本发生在I/O上,如果能有一个库能把所有的I/O行为都包装成异步阻塞的话,那么Quasar就会有用武之地,JVM上公认的是异步网络通信库是Netty,通过Netty基本解决了网络I/O问题,另外还有一个是文件I/O,而这个JDK7提供的NIO2就可以满足,通过AsynchronousFileChannel即可。

剩下的就是如何将他们封装成更友好的API了。目前能达到生产级别的这种异步工具库,JVM上只有Vert.x3,封装了Netty4,封装了AsynchronousFileChannel,而且Vert.x官方也出了一个相对应的封装了Quasar的库vertx-sync

Quasar目前是由一家商业公司Parallel Universe控制着,且有自己的一套体系,包括Quasar-actor,Quasar-galaxy等各个模块,但是Quasar-core是开源的,此外Quasar自己也通过Fiber封装了很多的第三方库,目前全都在comsat这个项目里。随便找一个项目看看,你会发现其实通过Quasar的Fiber去封装第三方的同步库还是很简单的。

写在最后

异步无阻塞的编码方式其实有很多种实现,比如node.js的提倡的Promise,对应到Java8的就是CompletableFuture。

另外事件响应式也算是一个比较流行的做法,比如ReactiveX系列,RxJava,Rxjs,RxSwift,等。我个人觉得RxJava是一个非常好的函数式响应实现(JDK9会有对应的JDK实现),但是我们不能要求所有的程序员一眼就提炼出业务里的functor,monad(这些能力需要长期浸淫在函数式编程思想里),反而RxJava特别适合用在前端与用户交互的部分,因为用户的点击滑动行为是一个个真实的事件流,这也是为什么RxJava在Android端非常火的原因,而后端基本上都是通过Rest请求过来,每一个请求其实已经限定了业务范围,不会再有复杂的事件逻辑,所以基本上RxJava在Vert.x这端只是做了一堆的flatmap,再加上微服务化,所有的业务逻辑都已经做了最小的边界,所以顺序的同步的编码方式更适合写业务逻辑的后端程序员。

所以这里Golang开了个好头,但是Golang也有其自身的限制,比如不支持泛型,当然这个仁者见仁智者见智了,包的依赖管理比较弱,此外Golang没有线程池的概念,如果coroutine里的逻辑发生了阻塞,那么整个程序会hang死。而这点Vert.x提供了一个Worker Pool的概念,可以将需要耗时执行的逻辑包到线程池里面,执行完后异步返回给EventLoop线程。

下一篇我们来研究一下vertx-sync,让vert.x里所有的异步编码方式同步化,彻底解决Vert.x里的Callback Hell。

本文系力谱宿云LeapCloud旗下 MaxLeap 团队成员:刘小溪【原创】,转载请务必注明作者及原创地址
原创首发地址:https://blog.maxleap.cn/archi...

第二篇续作次时代Java编程(一):续 vertx-sync实践

查看原文

赞 27 收藏 57 评论 2

hyuan 发布了文章 · 2018-08-01

AOP的简单实现

之前一篇文章分析了Java AOP的核心 - 动态代理的实现,主要是基于JDK Proxycglib两种不同方式。所以现在干脆把这个专题做完整,再造个简单的轮子,给出一个AOP的简单实现。这里直接使用到了cglib,这也是Spring所使用的方式。

这里是完整代码,实现总的来说比较简单,无非就是各种反射,以及cglib代理。需要说明的是这只是我个人的实现方式,功能也极其有限。我并没有看过Spring的源码,也不知道它的AOP实现方式具体是什么样的,但原理应该是类似的。

原理分析

如果你熟悉了动态代理,应该不难构思出一个AOP的方案。要实现AOP的功能,无非就是把两个部分串联起来:

  1. 切面(Aspect
  2. 切点(PointCut

只要一个类的方法中含有切点PointCut,那说明这个方法需要被代理,插入切面Aspect,所以相应的Bean就需要产生代理类。我们只需找到所有的PointCut,以及它们对应的Aspect,整理出一张表,就能产生出代理类,并且能知道对应的每个方法,是否有Aspect,以及如何调用Aspect函数。

这里关键就是把这张PointCut和Aspect的对应表建立起来。因为在代理方法时,关注点首先是基于PointCut,所以这张表也是由PointCut到Aspect的映射:

PointCut Class A

    PointCutMethod 1
        Aspect Class / Method
        Aspect Class / Method

    PointCutMethod 2
        Aspect Class / Method

    PointCutMethod 3
        Aspect Class / Method
        Aspect Class / Method
   ...

PointCut Class B

    PointCutMethod 1
        Aspect Class / Method

    PointCutMethod 2
        Aspect Class / Method
   ...

例如定义一个切面类和方法:

@Aspect
public class LoggingAspect {
  @PointCut(type=PointCutType.BEFORE,
            cut="public void Greeter.sayHello(java.lang.String)")
  public static void logBefore() {
    System.out.println("=== Before ===");
  }
}

这里的注解语法都是我自己定义的,和Spring不太一样,不过意思应该很明了。这是一个前置通知,打印一行文字,切点是Greeter这个类的sayHello方法:

public class Greeter {
  public void sayHello(String name) {
    System.out.println("Hello, " + name);
  }
}

所以我们最后生成的AOP关系表就是这样:

Greeter
    sayHello
        LoggingAspect.logBefore

这样我们在为Greeter类生成代理类时就有了依据,具体来说就是在cglibMethodInterceptor.intercept()方法中,就可以确定需要在哪些方法,哪些位置,调用哪些Aspect函数。

代码实现

作为准备工作,首先我们定义相应的注解类:

Aspect是类注解,表明这是一个切面类,包含了切面函数。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Aspect {}

然后是切点PointCut,这是方法注解:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface PointCut {
  // PointCut Type, BEFORE or AFTER。
  PointCutType type();
  
  // PointCut expression.
  String cut();
}

不要和Spring的混起来了,我这里简单化了,直接用一个叫PointCut的注解,定义了两个field,一个是切点类型type,这里只有前置通知BEFORE和后置通知AFTER两种,当然你也可以添加更多。一个是切点表达式cut,语法上类似于Spring,但也简单化了,去掉了execution语法,直接写函数表达式,用分号;隔开多个函数,也没有什么复杂的通配符匹配。

Bean 和 BeanFactory

由于要产生各种类的实例,我们不妨也像Spring那样定义一个BeanBeanFactory的概念,但功能非常简单,只是用来管理所有的类而已。

Bean:

public class Bean {
  /* bean id */
  private String id;
  /* bean class */
  private Class<?> clazz;
  /* instance, singleton */
  private Object instance;
}

DefaultBeanFactory

public class DefaultBeanFactory {
  /* beanid ==> Bean */
  private Map<String, Bean> beans;

  /* bean id ==> bean aspects */
  protected Map<String, BeanAspects> aops;
  
  /* get bean */
  public Object getBean(String beanId) {
    // ...
  }
}

这里的beans是管理所有Bean的一个简单Map,key是bean id;而aops就是之前说到的维护PointCut和Aspect映射关系的表,key是PointCut类的bean id,而value是我定义的另一个类BeanAspects,具体代码就不贴了,这实际上又是一层嵌套的表,是一个PointCut类中各个PointCut方法,到对应的切面Aspect方法集的映射。这里实际上有几层表的嵌套,不过结构是很清楚的,就是从PointCut到Aspect的映射,可以参照我上面的图:

PointCut Class A

    PointCut Method 1
        Aspect Class / Method

    PointCut Method 2
        Aspect Class / Method

建立 PointCut 和 Aspect 关系表

现在的关键问题就是要建立这张关系表,实现起来并不难,就是利用反射而已。像Spring那样,我们需要扫描给定的package中的所有类,找出注解Aspect修饰的切面类,找到它所包含的PointCut修饰的切面方法,分析它们对应的切入点PointCut,把这张表建立起来就可以了。

第一个问题是如何扫描java package,我用了guava中的ClassPath类:

ClassPath cp = ClassPath.from(getClass().getClassLoader());

// Scan all classes under a package.
for (ClassPath.ClassInfo ci : cp.getTopLevelClasses(pkg)) {
  Class<?> clazz = ci.load();
  // ...
}

然后用注解Aspect判断一个类是否是切面类,如果是就用PointCut注解找出切面方法:

if (clazz.getAnnotation(Aspect.class) != null) {
  for (Method m : clazz.getMethods()) {
    PointCut pointCut = (PointCut)(m.getAnnotation(PointCut.class));
    if (pointCut != null) {
      /* Parse point cut expression. */
      List<Method> pointCutMethods = parsePointCutExpr(pointCut.cut());
      for (Method pointCutMethod : pointCutMethods) {
        /* Add mapping to aops table: mapping from poitcut to aspect. */
        /* ... */
      }
    }
  }
}

至于parsePointCutExpr方法如何实现,解析切点表达式,无非就是一堆正则匹配和反射,简单粗暴,代码比较冗长,这里就不贴了,感兴趣的童鞋可以直接去看这里的链接

代理类的生成

代理类何时生成?应该是在调用getBean时,如果这个Bean类被切面介入了,就需要用cglib为它生成代理类。我把这部分逻辑放在了Bean.java中:

if (!beanFactory.aops.containsKey(id)) {
   this.instance = (Object)clazz.newInstance();
} else {
   BeanAspects beanAspects = beanFactory.aops.get(id);
   // Create proxy class instance.
   Enhancer eh = new Enhancer();
   eh.setSuperclass(clazz);
   eh.setCallback(new BeanProxyInterceptor(beanFactory, beanAspects));
   this.instance = eh.create();
}

这里先检查这个bean是否需要AOP代理,如果不需要直接调构造函数生成 instance 就可以;如果需要代理,则使用BeanProxyInterceptor生成代理类,它的intercept方法包含了方法代理的全部逻辑:

@Override
class BeanProxyInterceptor implements MethodInterceptor {
  public Object intercept(Object obj, Method method, Object[] args,
                          MethodProxy proxy) throws Throwable {
    /* Find aspects for this method. */
    Map<String, BeanAspects.AspectMethods> aspects = 
        beanAspects.pointCutAspects.get(method);
    if (aspects == null) {
      // No aspect for this method.
      return proxy.invokeSuper(obj, args);
    }
    
    // TODO: Invoke before advices.

    // Invoke the original method.
    Object re = proxy.invokeSuper(obj, args);
    
    // TODO: Invoke after advices.

    return re;
  }

我们这里只实现前置和后置通知,所以TODO部分实现出来就可以了。因为我们前面已经从PointCut和Aspect的关系表aops和子表BeanAspects里拿到了这个PointCut类、这个PointCut方法对应的所有Aspect切面方法,存储在aspects里,所以我们只需遍历aspects并依次调用所有方法就可以了。为了简明,下面是伪代码逻辑:

for method in aspects.beforeAdvices:
  invokeAspectMethod(aspectBeanId, method)

// invoke original method
// ...

for method in aspects.afterAdvices:
  invokeAspectMethod(aspectBeanId, method)

invokeAspectMethod需要做一个简单的static判断,对于非static的切面方法,需要拿到切面类Bean的实例 instance。

void invokeAspectMethod(String aspectBeanId, Method method) {
  if (Modifier.isStatic(method.getModifiers())) {
    method.invoke(null);
  } else {
    method.invoke(beanFactory.getBean(aspectBeanId));
  }
}

测试

切面类,定义了三个切面方法,一个前置打印,一个后置打印,还有一个自增计数器,前两个是static方法:

@Aspect
public class MyAspect {
  private AtomicInteger count = new AtomicInteger();

  // Log before.
  @PointCut(type=PointCutType.BEFORE,
            cut="public int aop.example.Calculator.add(int, int);" +
                "public void aop.example.Greeter.sayHello(java.lang.String);")
  public static void logBefore() {
    System.out.println("=== Before ===");
  }

  // Log after.
  @PointCut(type=PointCutType.AFTER,
            cut="public long aop.example.Calculator.sub(long, long);" +
                "public void aop.example.Greeter.sayHello(java.lang.String)")
  public static void logAfter() {
    System.out.println("=== After ===");
  }

  // Increment counter.
  @PointCut(type=PointCutType.AFTER,
            cut="public int aop.example.Calculator.add(int, int);" +
                "public long aop.example.Calculator.sub(long, long);" +
                "public void aop.example.Greeter.sayHello(java.lang.String);")
  public void incCount() {
    System.out.println("count: " + count.incrementAndGet());
  }
}

被切入的切点类是GreeterCalculator,比较简单,里面的方法签名都是符合上面MyAspect类中的切点表达式的:

public class Greeter {
  public void sayHello(String name) {
    System.out.println("Hello, " + name);
  }
}
public class Calculator {
  public int add(int x, int y) {
    return x + y;
  }
  public long sub(long x, long y) {
    return x - y;
  }
}

关于 Aspect 和 PointCut 主次关系的一点思考

不难发现,从代理实现的角度来说,那张AOP关系表应该是基于切点PointCut的,以此为主索引,从PointCut到Aspect,这也似乎更符合我们的常规思维。然而像Spring这样的框架,包括我上面给出的仿照Spring的例子,在定义AOP时,无论是基于XML还是注解,写法上都是以切面Aspect为主的,由具体Aspect通过切点表达式来定义要切入哪些PointCut,这可能也是Aspect Oriented Programming的本意。所以上面的关系表的建立过程其实是在反转这种主次关系,把PointCut作为主。

不过这似乎有点麻烦,就我个人而言我还是更倾向于在语法层面就直接使用前者,即基于PointCut。如果以Aspect为主,对代码的可维护性是一个挑战,因为你在定义Aspect时,就需要用相应的表达式来定义PointCut,而随着实际需求变化,例如PointCut函数的增加或减少,这个表达式往往需要改变,这样的耦合性往往会给代码维护带来麻烦;而反过来如果只简单定义Aspect,而由具体的PointCut自己决定需要调用哪些切面,虽然注解量会略微增加,但是更容易管理。当然如果用XML配置可能会比较头痛。

其实Python就是这样做的,Python的函数注解就是天然的,基于PointCut的的AOP。Python注解实际上是一个函数的wrapper,包裹了原函数,返回给你一个新的函数,但在语法层面上是透明的,在wrapper里就可以定义切面的行为。这样的AOP似乎更符合人的直观感受,当然这也源于Python本身对函数式编程的良好支持。

查看原文

赞 0 收藏 0 评论 0

hyuan 发布了文章 · 2018-07-28

Java动态代理 jdk和cglib的实现比较

发现Java面试很喜欢问Spring AOP怎么实现的之类的问题,所以写一篇文章来整理一下。关于AOP和代理模式的概念这里并不做赘述,而是直奔主题,即AOP的实现方式:动态代理。与静态代理对比,动态代理是在runtime动态生成Java代理类,由代理类完成对具体方法的封装,实现AOP的功能。

本文将分析Java中两种动态代理的实现方式,jdk proxycglib,比较它们的异同。本文并不会过多地分析jdk和cglib的源码去探究底层的实现细节,而只关注最后生成的代理类应该是什么样的,如何实现代理。只是我个人的整理和思考,和真正的jdk,cglib的产生的结果可能不尽相同,但从原理上来讲是一致的。

文章的最后也会探讨如何自己实现一个简单的动态代理,并提供我自己实现的简单版本,当然仅供参考。

JDK Proxy

这是Java反射包java.lang.reflect提供的动态代理的方式,这种代理方式是完全基于接口的。这里先给出一个简单的例子。

定义接口:

interface ifc {
  int add(int, int);
}

然后是接口ifc的实现类Real

class Real implements ifc {
  @Override
  public int add(int x, int y) {
    return x + y;
  }

Real就是我们需要代理的类,比如我们希望在调用add的前后打印一些log,这实际上就是AOP了。我们需要最终产生一个代理类,实现同样的接口ifc,执行Real.add的功能,但需要增加一行新的打印语句。这一切对用户是透明的,用户只需要关心接口的调用。为了能在Real.add的周围添加额外代码,动态代理都是通过一种类似方法拦截器的东西来实现的,在Java Proxy里这就是InvocationHandler.

class Handler implements InvocationHandler {
  private final Real real;

  public Handler(Real real) {
    this.real = real;
  }

  @Override
  public Object invoke(Object proxy, Method method, Object[] args)
      throws IllegalAccessException, IllegalArgumentException,
             InvocationTargetException {
    System.out.println("=== BEFORE ===");
    Object re = method.invoke(real, args);
    System.out.println("=== AFTER ===");
    return re;
  }
}

这里最关键的就是invoke方法,实际上代理类的add方法,以及其它方法(如果接口还定义了其它方法),最终都只是调用这个Handlerinvoke方法,由你来具体定义在invoke里需要做什么,通常就是调用真正实体类Real的方法,这里就是add,以及额外的AOP行为(打印 BEFORE 和 AFTER)。所以可想而知,代理类里必然是有一个InvocationHandler的实例的,所有的接口方法调用都会由这个handler实例来代理。

所以我们应该能大概刻画出这个代理类的模样:

public ProxyClass implements ifc {
  private static Method mAdd;

  private InvocationHandler handler;

  static {
    Class clazz = Class.forName("ifc");
    mAdd = clazz.getMethod("add", int.class, int.class);
  }
  
  @Override
  public int add(int x, int y) {
    return (Integer)handler.invoke(this, mAdd, new Object[] {x, y});
  }
}

这个版本非常简单,但已足够实现我们的要求。我们来观察这个类,首先毋庸置疑它实现了ifc接口,这是代理模式的根本。它的add方法直接调用InvocationHandler实例的invoke方法,传入三个参数,第一个是代理类本身this指针,第二个是add方法的反射类,第三个是参数列表。所以在invoke方法里,用户就能自由定义它的行为实现AOP,所有这一切的桥梁就是InvocationHandler,它完成方法的拦截与代理。

代理模式一般要求代理类中有一个真正类(被代理类)的实例,在这里也就是Real的实例,这样代理类才能去调用Real中原本的add方法。那Real在哪里呢?答案也是在InvocationHandler里。这与标准的代理模式相比,似乎多了一层嵌套,不过这并没有关系,只要这个代理的链条能够搭建起来,它就符合代理模式的要求。

注意到这里add方法的反射实例mAdd的初始化方式,我们使用静态块static {...}来完成,只会被设置一次,并且不会有多线程问题。当然你也可以用懒加载等方式,不过就得考虑并发的安全性。

最后看一下JDK Proxy的具体使用:

Handler handler = new Handler(new Real());
ifc p = (ifc)Proxy.newProxyInstance(ifc.class.getClassLoader(),
                                    new Class[] {ifc},
                                    handler);
p.add(1, 2);

方法newProxyInstance就会动态产生代理类,并且返回给我们一个实例,实现了ifc接口。这个方法需要三个参数,第一个ClassLoader并不重要;第二个是接口列表,即这个代理类需要实现那些接口,因为JDK的Proxy是完全基于接口的,它封装的是接口的方法而不是实体类;第三个参数就是InvocationHandler的实例,它会被放置在最终的代理类中,作为方法拦截和代理的桥梁。注意到这里的handler包含了一个Real实例,这在上面已经说过是代理模式的必然要求。

总结一下JDK Proxy的原理,首先它是完全面向接口的,其实这才是符合代理模式的标准定义的。我们有两个类,被代理类Real和需要动态生成的代理类ProxyClass,都实现了接口ifc。类ProxyClass需要拦截接口ifc上所有方法的调用,并且最终转发到实体类Real上,这两者之间的桥梁就是方法拦截器InvocatioHandlerinvoke方法。

上面的例子里我给出类ProxyClass的源代码,当然实际上JDK Proxy是不会去产生源代码的,而是直接生成类的原始数据,它具体是怎么实现我们暂时不讨论,我们目前只需要关心这个类是什么样的,以及它实现代理的原理。

cglib实现动态代理

这是Spring使用的方式,与JDK Proxy不同之处在于它不是面向接口的,而是基于类的继承。这似乎是有点违背代理模式的标准格式,不过这没有关系,所谓的代理模式只是一种思想而不是严格的规范。我们直接看它是如何使用的。

现在没有接口,我们直接有实体类:

class Real {
  public int add(int x, int y) {
    return x + y;
  }
}

类似于InvocationHandler,这里cglib直接使用一个叫MethodInterceptor的类,顾名思义。

public class Interceptor implements MethodInterceptor {
  @Override
  public Object intercept(Object obj,
                          Method method,
                          Object[] args,
                          MethodProxy proxy) throws Throwable {
      System.out.println("=== BEFORE ===");
      Object re = proxy.invokeSuper(obj, args);
      System.out.println("=== AFTER ===");
      return re;
  }
}

使用方法:

public static void main(String[] args) {
  Enhancer eh = new Enhancer();
  eh.setSuperclass(Real.class);
  eh.setCallback(new Interceptor());

  Real r = (Real)eh.create();
  int result = r.add(1, 2);
}

如果你仔细和JDK Proxy比较,会发现它们其实是类似的:

  1. 首先JDK Proxy提供interface列表,而cglib提供superclass供代理类继承,本质上都是一样的,就是提供这个代理类的签名,也就是对外表现为什么类型。
  2. 然后是一个方法拦截器,JDK Proxy里是InvocationHandler,而cglib里一般就是MethodInterceptor,所有被代理的方法的调用是通过它们的invokeintercept方法进行转接的,AOP的逻辑也是在这一层实现。

它们不同之处上面已经说了,就在于cglib生成的动态代理类是直接继承原始类的,所以我们这里也可以大概刻画出这个代理类长什么样子:

public ProxyClass extends Real {
  private static Method mAdd;
  private static MethodProxy mAddProxy;

  private MethodInterceptor interceptor;

  static {
    Class clazz = Class.forName("ifc");
    mAdd = clazz.getMethod("add", int.class, int.class);
    // Some logic to generate mAddProxy.
    // ...
  }
  
  @Override
  public int add(int x, int y) {
    return (Integer)interceptor.invoke(
        this, mAdd, new Object[] {x, y}, mAddProxy);
  }
}

因为直接继承了Real,那自然就包含了Real的所有public方法,都通过interceptor.invoke进行拦截代理。这其实和上面JDK Proxy的原理是类似的,连invokeintercept方法的签名都差不多,第一个参数是this指针代理类本身,第二个参数是方法的反射,第三个参数是方法调用的参数列表。唯一不同的是,这里多出一个MethodProxy,它是做什么用的?

如果你仔细看这里invoke方法内部的写法,当用户想调用原始类(这里是Real)定义的方法时,它必须使用:

Object re = proxy.invokeSuper(obj, args);

这里就用到了那个MethodProxy,那我们为什么不直接写:

Object re = method.invoke(obj, args);

答案当然是不可以,你不妨试一下,程序会进入一个无限递归调用。这里的原因恰恰就是因为代理类是继承了原始类的,obj指向的就是代理类对象的实例,所以如果你对它使用method.invoke,由于多态性,就会又去调用代理类的add方法,继而又进入invoke方法,进入一个无限递归:

obj.add() {
  interceptor.invoke() {
    obj.add() {
      interceptor.invoke() {
        ...
      }
    }
  }
}

那我如何才能在interceptor.invoke()里去调用基类Realadd方法呢?当然通常做法是super.add(),然而这是在MethodInterceptor的方法里,而且这里的method调用必须通过反射完成,你并不能在语法层面上做到这一点。所以cglib封装了一个类叫MethodProxy帮助你,这也是为什么那个方法的名字叫invokeSuper,表明它调用的是原始基类的真正方法。它究竟是怎么办到的呢?你可以简单理解为,动态代理类里会生成这样一个方法:

int super_add(int x, int y) {
  return super.add(x, y);
}

当然你并不知道有这么一个方法,但invokeSuper会最终找到这个方法并调用,这都是在生成代理类时通过一系列反射的机制实现的,这里就不细展开了。

小结

以上我对比了JDK Proxycglib动态代理的使用方法和实现上的区别,它们本质上是类似的,都是提供两个最重要的东西:

  1. 接口列表或者基类,定义了代理类(当然也包括原始类)的签名。
  2. 一个方法拦截器,完成方法的拦截和代理,是所有调用链的桥梁。

需要说明的一点是,以上我给出的代理类ProxyClass的源代码,仅是参考性的最精简版本,只是为了说明原理,而不是JDK Proxycglib真正生成的代理类的样子,真正的代理类的逻辑要复杂的多,但是原理上基本是一致的。另外之前也说到过,事实上它们也不会生成源码,而是直接产生类的字节码,例如cglib是封装了ASM来直接生成Class数据的。

如何生成代理类

接下来的部分纯粹是实验性质的。既然知道了代理类长什么样,可能还是有人会关心底层究竟如何在runtime动态生成这个类,这里我个人想了两种方案。

第一种方法是动态生成ProxyClass源码,然后动态编译,就能得到Class了。这里就需要利用反射,加上一系列字符串拼接,生成源码。如果你充分理解代理类应该长什么样,其实并不是很难做到。那如何动态编译呢?你可以使用JOOR,这是一个封装了javax.tools.JavaCompiler的库,帮助你方便地实现动态编译Java源代码。我试着写了一个Demo,纯粹是实验性质的。而且它有个重大问题,我不知道如何修改它编译使用的classpath,在默认情况下它无法引用到你自己定义的任何类,因为它们不在编译的classpath里,编译就不会通过,这实际上就使得这个代码生成器没有任何卵用。。。我强行通过修改System.setPropertyclasspath来添加我的class路径绕开了这个问题,然而这显然不是个解决根本问题的方法。

第二种方法更直接,就是生成类的字节码。这也是cglib使用的方法,它封装了ASM,这是一个可以用来直接操纵Class数据的库,通过它你就可以任意生成或修改你想要的Class,当然这需要你对虚拟机的字节码比较了解,才能玩得通这种比较黑科技的套路。这里我也写了一个Demo,也纯粹是实验而已,感兴趣的童鞋也可以自己试一下。写字节码还是挺酸爽的,它类似汇编但其实比汇编容易的多。它不像汇编那样一会儿寄存器一会儿内存地址,一会儿堆一会儿栈,各种变量和地址绕来绕去。字节码的执行方式是很清晰的,变量都存储在本地变量表里,栈只是用来做函数调用,所以非常直观。

查看原文

赞 3 收藏 2 评论 0

hyuan 评论了文章 · 2018-06-25

理解 OAuth 2.0 协议的原理

Web安全的问题其实很有意思,这些在互联网上使用的安全协议看似复杂,其实在现实生活中都有类似的准则被人们理所当然地广泛使用。比如这里要讲的授权,这是个所有人都很熟悉的概念。例如你要给张三1000元现金,你手里没现金,就想让张三直接去你银行账户里取,那你会怎么做呢?你可以这样:

  • 把你的银行账号密码告诉张三让他去银行取钱

但显然傻瓜才会这样干,正确的做法是这样:

  • 开一张1000元的支票给张三去银行支取

这张支票就是授权的凭证,代表你授权张三从你的银行账户里提取1000元。这样的事情发生在互联网上,那就是一个第三方App,想要获取你在某一网站上的一些私有信息(例如邮箱,名字,照片),需要得到你的授权。这个授权的过程原理和上面的支票类似,当然在具体实现上需要更复杂更严密的步骤,这就是OAuth协议做的事情。

有一个很常见的场景,就是在很多App上,往往可以使用你另一个平台上(微信,QQ等)的账号注册登录,例如简书:

图片描述

你可以点击QQ登录,它会给你转到QQ授权登录简书的界面,从这里开始就进入了OAuth 2.0协议的执行流程了。

OAuth 2.0 协议 - 授权码模式

网上介绍OAuth 2.0协议的文章也挺多的,所以我们直奔主题,结合上面的例子,讲讲使用最广,最严密的一种实现方式 - 授权码模式

图片描述

这里直接贴一张 RFC 6749 里的图,是整个授权码实现过程的流程图。这里有几个名词需要解释一下:

  • Resource Owner,就是资源的主人。
  • User Agent,一般就是浏览器。
  • Client,这里是指第三方App,例如简书,注意这里是指它的后台而不是前端。
  • Authorization Server,授权服务器,例如QQ的授权服务器,它管理用户QQ信息的授权。

上面图中的 ABCDE 五个步骤:

(A)Client(即第三方App)将用户导向Authorization Server地址,并且提供一个重定向URL。
(B)授权页面询问用户,用户同意授权。
(C)Authorization Server产生一个授权码,并且将用户导向(A)中的重定向URL,带上授权码, 
    作为URL的参数。
(D)这个重定向URL实际上是Client域名下的一个地址,于是这个请求发送到了Client后台,它使用
    授权码向授权服务器申请一个token。
(E)Client得到token,这个token就是一个令牌,可以用来获取用户授权的资源。

咋一看似乎还是难以理解它的具体流程和原理,所以我们还是直接看上面的例子。

步骤(A)

在简书的登录界面,点击QQ图标,就会被导入到QQ登录简书的授权界面,即进入步骤(A),注意这是一个QQ域名下的页面,现在暂时和简书没有关系了,对话只发生在用户和QQ之间,我们来看这个页面的Http请求:

URI:
https://graph.qq.com/oauth2.0/show?

参数:
which=Login
display=pc
client_id=100410602
redirect_uri=https://www.jianshu.com/users/auth/qq_connect/callback
response_type=code

这里最重要的参数是:

  • client_id,代表了简书,是它向QQ授权服务器申请的唯一ID。
  • redirect_uri,这是简书域名下的一个地址,等一会儿用户同意授权后,QQ会重新将浏览器导向这个地址。
  • 有时还会带一个参数scope,这表示授权的范围,比如用户名字,qq号,好友列表之类的。

所以这个页面实际上是简书将用户导到了QQ,让QQ和用户达成协议,同意授权给简书。QQ将要求用户登录自己的QQ账号,验明身份,并向用户征求相应QQ信息的授权给简书。在这里没有scope参数,而是QQ让用户在页面上选择相应的授权范围,这其实是一样的。

图片描述

步骤(B)

用户在QQ的授权页面上用QQ账号登录,并且选择同意授权给简书相应QQ信息。

步骤(C)

用户点击同意授权后,QQ将会生成一个授权码(Authorization Code),并且将用户导回之前(A)中的redirect_uri,它是这样的:

URI:
https://www.jianshu.com/users/auth/qq_connect/callback?

附上授权码:
code=1EE50EE39E4260EBCFA4892F72F84953

授权码实际上就是用户同意授权的凭证,这个凭证将随着这个URL发回给简书。

步骤(D)

现在又回到了简书的控制范围,上面的URL由用户浏览器发到简书后,现在开始是简书后台的工作了。简书将使用授权码,向QQ申请一个token令牌,这个请求是发生在简书后台和QQ之间的,具体长什么样我们当然不得而知,但它至少要包含以下信息:

  • 简书的client_id
  • 和这个client_id对应的secret,这也是简书在向QQ申请client_id时得到的,由简书保存,简书用它向QQ服务器证明,这是我本人。
  • 授权码

步骤(E)

QQ服务器验证以上信息,向简书发送token,简书后面可以用这个token获取用户的相应信息。

之后的工作就完全由简书决定了,通常的做法是它获取了用户QQ信息,然后做一系列处理,比如用用户的qq号产生一个简书账号(或查找已经关联该qq号的简书账号),实现登录,再将用户302重定向到简书的主页面。

原理分析

上面五个步骤,你来我往似乎很复杂,我们整理一下实际上就是两个部分:

  1. (A)(B)(C)三步发生在用户和QQ之间,当然是由简书发起的。简书要求QQ向用户征求授权,于是QQ和用户开始对话,同意授权,以授权码为凭证,这个授权码包含的内容是:“用户xx同意向简书提供QQ信息”。就类似于用户在银行开出一张支票,支票上写着“用户xx同意向张三支付100元”。
  2. 然后(D)(E)两步,转到了简书后台和QQ之间,QQ重定向用户浏览器到简书之前提供的URL,附上那个授权码,类似于你拿到了支票,将支票转发给了张三。然后简书拿着这个授权码,加上能证明自己就是简书的相关证件,也就是简书的client_id和secret,向QQ换取一个token令牌,后面就能用这个token向QQ取得用户授权的相关信息。

可以看到这里的逻辑是完全符合我们的生活常识的,正好对应我们向张三开支票取钱的过程,只是步骤上更复杂更严密,因为互联网的世界有更多风险因素需要考虑。有个问题大家经常会问到:

  • 为什么要分成两步,先拿授权码,再换取token?如果对比支票的例子,自始至终支票只有一张,凭票即可兑钱。但是OAuth 2.0协议却拆分成了两步,严格来讲授权码其实不是真正的支票,只是支票授权书,要获取真正的支票token,需要简书亲自去向QQ索取。这是因为ABC三步发生在用户和QQ服务器之间,这个对话信道是不可信任的,可能用户的浏览器已被劫持。所以在这个信道上不能发送真正的token,否则token一旦泄露就失去所有安全性了,而只能发送一个授权码作为凭证,这个授权码是和简书绑定的。简书拿到这个凭证之后,再带上自己的身份证client_id和secret,才能向QQ换取真正的token支票。其他人即使窃取了授权码,但因为他们提供不了简书的secret,QQ也会拒绝授予token。简书和QQ之间的对话是发生在后台的,可以认为这个信道的安全是有保障的。

后记

写这篇是因为看到前一阵Facebook的数据泄漏事件,不过从前因后果来看FB似乎也是被坑了一把,真正的始作俑者是那个叫Cambridge Analytica的公司,它获取FB的用户信息并且分析它们的喜好性格,来有针对性地向他们推送带有政治倾向的广告和宣传信息来影响选举。据说它为了收集用户数据,还在FB的游戏平台上发布小游戏。这种第三方App在用户玩之前,往往会告知用户将收集您的Facebook信息balabala,一般人常常看也不看就点同意了:

图片描述

这样的霸王条款其实我们已经很习惯了,一个第三方的App,想要获取你在某些网站上的个人信息,就要用到Oauth协议,实现这种细粒度的授权。有些小游戏他要求的授权范围就很广,包括获取用户在FB上所有的Post和点赞信息之类的,这其实就比较危险了,因为他可以分析你的心理性格。还有些要求获取好友列表的,这也值得警惕,因为那些数据收集的公司就是这样由点到面类似爬虫一样获取大量FB用户的。FB的事情也是在提醒我们,以后看到这样的提示信息需要好好考虑一下,因为一旦点了同意,就相当于你把“支票”开出去了。

查看原文

hyuan 关注了用户 · 2018-06-11

shutiao @shutiao

关注 1

认证与成就

  • 获得 107 次点赞
  • 获得 5 枚徽章 获得 0 枚金徽章, 获得 1 枚银徽章, 获得 4 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-09-29
个人主页被 1.8k 人浏览