原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自技术世界,原文链接 http://www.jasongj.com/spark/adaptive_execution/
本文所述内容均基于 2018年9月17日 Spark 最新 Spark Release 2.3.1 版本,以及截止到 2018年10月21日 Adaptive Execution 最新开发代码。自动设置 Shuffle Partition 个数已进入 Spark Release 2.3.1 版本,动态调整执行计划与处理数据倾斜尚未进入 Spark Release 2.3.1
前面《Spark SQL / Catalyst 内部原理 与 RBO》与《Spark SQL 性能优化再进一步 CBO 基于代价的优化》介绍的优化,从查询本身与目标数据的特点的角度尽可能保证了最终生成的执行计划的高效性。但是
本文介绍的 Adaptive Execution 将可以根据执行过程中的中间数据优化后续执行,从而提高整体执行效率。核心在于两点
Spark Shuffle 一般用于将上游 Stage 中的数据按 Key 分区,保证来自不同 Mapper (表示上游 Stage 的 Task)的相同的 Key 进入相同的 Reducer (表示下游 Stage 的 Task)。一般用于 group by 或者 Join 操作。
如上图所示,该 Shuffle 总共有 2 个 Mapper 与 5 个 Reducer。每个 Mapper 会按相同的规则(由 Partitioner 定义)将自己的数据分为五份。每个 Reducer 从这两个 Mapper 中拉取属于自己的那一份数据。
使用 Spark SQL 时,可通过 spark.sql.shuffle.partitions
指定 Shuffle 时 Partition 个数,也即 Reducer 个数
该参数决定了一个 Spark SQL Job 中包含的所有 Shuffle 的 Partition 个数。如下图所示,当该参数值为 3 时,所有 Shuffle 中 Reducer 个数都为 3
这种方法有如下问题
如 Spark Shuffle 原理 一节图中所示,Stage 1 的 5 个 Partition 数据量分别为 60MB,40MB,1MB,2MB,50MB。其中 1MB 与 2MB 的 Partition 明显过小(实际场景中,部分小 Partition 只有几十 KB 及至几十字节)
开启 Adaptive Execution 后
三个 Reducer 这样分配是因为
由上图可见,Reducer 1 从每个 Mapper 读取 Partition 1、2、3 都有三根线,是因为原来的 Shuffle 设计中,每个 Reducer 每次通过 Fetch 请求从一个特定 Mapper 读数据时,只能读一个 Partition 的数据。也即在上图中,Reducer 1 读取 Mapper 0 的数据,需要 3 轮 Fetch 请求。对于 Mapper 而言,需要读三次磁盘,相当于随机 IO。
为了解决这个问题,Spark 新增接口,一次 Shuffle Read 可以读多个 Partition 的数据。如下图所示,Task 1 通过一轮请求即可同时读取 Task 0 内 Partition 0、1 和 2 的数据,减少了网络请求数量。同时 Mapper 0 一次性读取并返回三个 Partition 的数据,相当于顺序 IO,从而提升了性能。
由于 Adaptive Execution 的自动设置 Reducer 是由 ExchangeCoordinator 根据 Shuffle Write 统计信息决定的,因此即使在同一个 Job 中不同 Shuffle 的 Reducer 个数都可以不一样,从而使得每次 Shuffle 都尽可能最优。
上文 原有 Shuffle 的问题 一节中的例子,在启用 Adaptive Execution 后,三次 Shuffle 的 Reducer 个数从原来的全部为 3 变为 2、4、3。
可通过 spark.sql.adaptive.enabled=true
启用 Adaptive Execution 从而启用自动设置 Shuffle Reducer 这一特性
通过 spark.sql.adaptive.shuffle.targetPostShuffleInputSize
可设置每个 Reducer 读取的目标数据量,其单位是字节,默认值为 64 MB。上文例子中,如果将该值设置为 50 MB,最终效果仍然如上文所示,而不会将 Partition 0 的 60MB 拆分。具体原因上文已说明
在不开启 Adaptive Execution 之前,执行计划一旦确定,即使发现后续执行计划可以优化,也不可更改。如下图所示,SortMergJoin 的 Shuffle Write 结束后,发现 Join 一方的 Shuffle 输出只有 46.9KB,仍然继续执行 SortMergeJoin
此时完全可将 SortMergeJoin 变更为 BroadcastJoin 从而提高整体执行效率。
SortMergeJoin 是常用的分布式 Join 方式,它几乎可使用于所有需要 Join 的场景。但有些场景下,它的性能并不是最好的。
SortMergeJoin 的原理如下图所示
当参与 Join 的一方足够小,可全部置于 Executor 内存中时,可使用 Broadcast 机制将整个 RDD 数据广播到每一个 Executor 中,该 Executor 上运行的所有 Task 皆可直接读取其数据。(本文中,后续配图,为了方便展示,会将整个 RDD 的数据置于 Task 框内,而隐藏 Executor)
对于大 RDD,按正常方式,每个 Task 读取并处理一个 Partition 的数据,同时读取 Executor 内的广播数据,该广播数据包含了小 RDD 的全量数据,因此可直接与每个 Task 处理的大 RDD 的部分数据直接 Join
根据 Task 内具体的 Join 实现的不同,又可分为 BroadcastHashJoin 与 BroadcastNestedLoopJoin。后文不区分这两种实现,统称为 BroadcastJoin
与 SortMergeJoin 相比,BroadcastJoin 不需要 Shuffle,减少了 Shuffle 带来的开销,同时也避免了 Shuffle 带来的数据倾斜,从而极大地提升了 Job 执行效率
同时,BroadcastJoin 带来了广播小 RDD 的开销。另外,如果小 RDD 过大,无法存于 Executor 内存中,则无法使用 BroadcastJoin
对于基础表的 Join,可在生成执行计划前,直接通过 HDFS 获取各表的大小,从而判断是否适合使用 BroadcastJoin。但对于中间表的 Join,无法提前准确判断中间表大小从而精确判断是否适合使用 BroadcastJoin
《Spark SQL 性能优化再进一步 CBO 基于代价的优化》一文介绍的 CBO 可通过表的统计信息与各操作对数据统计信息的影响,推测出中间表的统计信息,但是该方法得到的统计信息不够准确。同时该方法要求提前分析表,具有较大开销
而开启 Adaptive Execution 后,可直接根据 Shuffle Write 数据判断是否适用 BroadcastJoin
如上文 SortMergeJoin 原理 中配图所示,SortMergeJoin 需要先对 Stage 0 与 Stage 1 按同样的 Partitioner 进行 Shuffle Write
Shuffle Write 结束后,可从每个 ShuffleMapTask 的 MapStatus 中统计得到按原计划执行时 Stage 2 各 Partition 的数据量以及 Stage 2 需要读取的总数据量。(一般来说,Partition 是 RDD 的属性而非 Stage 的属性,本文为了方便,不区分 Stage 与 RDD。可以简单认为一个 Stage 只有一个 RDD,此时 Stage 与 RDD 在本文讨论范围内等价)
如果其中一个 Stage 的数据量较小,适合使用 BroadcastJoin,无须继续执行 Stage 2 的 Shuffle Read。相反,可利用 Stage 0 与 Stage 1 的数据进行 BroadcastJoin,如下图所示
具体做法是
注:广播数据存于每个 Executor 中,其上所有 Task 共享,无须为每个 Task 广播一份数据。上图中,为了更清晰展示为什么能够直接 Join 而将 Stage 2 每个 Task 方框内都放置了一份 Stage 1 的全量数据
虽然 Shuffle Write 已完成,将后续的 SortMergeJoin 改为 Broadcast 仍然能提升执行效率
该特性的使用方式如下
spark.sql.adaptive.enabled
与 spark.sql.adaptive.join.enabled
都设置为 true
时,开启 Adaptive Execution 的动态调整 Join 功能spark.sql.adaptiveBroadcastJoinThreshold
设置了 SortMergeJoin 转 BroadcastJoin 的阈值。如果不设置该参数,该阈值与 spark.sql.autoBroadcastJoinThreshold
的值相等spark.sql.adaptive.allowAdditionalShuffle
参数决定了是否允许为了优化 Join 而增加 Shuffle。其默认值为 false《Spark性能优化之道——解决Spark数据倾斜(Data Skew)的N种姿势》一文讲述了数据倾斜的危害,产生原因,以及典型解决方法
目前 Adaptive Execution 可解决 Join 时数据倾斜问题。其思路可理解为将部分倾斜的 Partition (倾斜的判断标准为该 Partition 数据是所有 Partition Shuffle Write 中位数的 N 倍) 进行单独处理,类似于 BroadcastJoin,如下图所示
在上图中,左右两边分别是参与 Join 的 Stage 0 与 Stage 1 (实际应该是两个 RDD 进行 Join,但如同上文所述,这里不区分 RDD 与 Stage),中间是获取 Join 结果的 Stage 2
明显 Partition 0 的数据量较大,这里假设 Partition 0 符合“倾斜”的条件,其它 4 个 Partition 未倾斜
以 Partition 对应的 Task 2 为例,它需获取 Stage 0 的三个 Task 中所有属于 Partition 2 的数据,并使用 MergeSort 排序。同时获取 Stage 1 的两个 Task 中所有属于 Partition 2 的数据并使用 MergeSort 排序。然后对二者进行 SortMergeJoin
对于 Partition 0,可启动多个 Task
通过该方法,原本由一个 Task 处理的 Partition 0 的数据由多个 Task 共同处理,每个 Task 需处理的数据量减少,从而避免了 Partition 0 的倾斜
对于 Partition 0 的处理,有点类似于 BroadcastJoin 的做法。但区别在于,Stage 2 的 Task 0-0 与 Task 0-1 同时获取 Stage 1 中属于 Partition 0 的全量数据,是通过正常的 Shuffle Read 机制实现,而非 BroadcastJoin 中的变量广播实现
开启与调优该特性的方法如下
spark.sql.adaptive.skewedJoin.enabled
设置为 true 即可自动处理 Join 时数据倾斜spark.sql.adaptive.skewedPartitionMaxSplits
控制处理一个倾斜 Partition 的 Task 个数上限,默认值为 5spark.sql.adaptive.skewedPartitionRowCountThreshold
设置了一个 Partition 被视为倾斜 Partition 的行数下限,也即行数低于该值的 Partition 不会被当作倾斜 Partition 处理。其默认值为 10L * 1000 * 1000 即一千万spark.sql.adaptive.skewedPartitionSizeThreshold
设置了一个 Partition 被视为倾斜 Partition 的大小下限,也即大小小于该值的 Partition 不会被视作倾斜 Partition。其默认值为 64 * 1024 * 1024 也即 64MBspark.sql.adaptive.skewedPartitionFactor
该参数设置了倾斜因子。如果一个 Partition 的大小大于 spark.sql.adaptive.skewedPartitionSizeThreshold
的同时大于各 Partition 大小中位数与该因子的乘积,或者行数大于 spark.sql.adaptive.skewedPartitionRowCountThreshold
的同时大于各 Partition 行数中位数与该因子的乘积,则它会被视为倾斜的 Partition原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自技术世界,原文链接 http://www.jasongj.com/spark/ci_cd/
持续集成是指,及时地将最新开发的且经过测试的代码集成到主干分支中。
持续集成的优点
目前主流的代码管理工具有,Github、Gitlab等。本文所介绍的内容中,所有代码均托管于私有的 Gitlab 中。
鉴于 Jenkins 几乎是 CI 事实上的标准,本文介绍的 Spark CI CD & CD 实践均基于 Jenkins 与 Gitlab。
Spark 源码保存在 spark-src.git 库中。
由于已有部署系统支持 Git,因此可将集成后的 distribution 保存到 Gitlab 的发布库(spark-bin.git)中。
每次开发人员提交代码后,均通过 Gitlab 发起一个 Merge Requet (相当于 Gitlab 的 Pull Request)
每当有 MR 被创建,或者被更新,Gitlab 通过 Webhook 通知 Jenkins 基于该 MR 最新代码进行 build。该 build 过程包含了
Jenkins 将 build 结果通知 Gitlab,只有 Jenkins 构建成功,Gitlab 的 MR 页面才允许 Merge。否则 Gitlab 不允许 Merge
另外,还需人工进行 Code Review。只有两个以上的 Reviewer 通过,才能进行最终 Merge
所有测试与 Reivew 通过后,通过 Gitlab Merge 功能自动将代码 Fast forward Merge 到目标分支中
该流程保证了
持续交付是指,及时地将软件的新版本,交付给质量保障团队或者用户,以供评审。持续交付可看作是持续集成的下一步。它强调的是,不管怎么更新,软件都是可随时交付的。
这一阶段的评审,一般是将上文集成后的软件部署到尽可能贴近生产环境的 Staging 环境中,并使用贴近真实场景的用法(或者流量)进行测试。
持续发布的优点
这里有提供三种方案,供读者参考。推荐方案三
如下图所示,基于单分支的 Spark 持续交付方案如下
spark-src.git/dev
(即 spark-src.git 的 dev branch) 上进行spark-bin.git/dev
的 spark-${ build # }
(如图中第 2 周的 spark-72)文件夹内spark-${ build # }
(如图中的 spark-72)注:
在 Staging 环境中发现 spark-dev 的 bug 时,修复及集成和交付方案如下
spark-${ build \# }
(如图中的 spark-73)spark-${ build \# }
(如图中的 spark-73 )生产环境中发现 bug 时修复及交付方案如下
spark-${ build \# }
(如图中的 spark-73)spark-${ build \# }
(如图中的 spark-73 )如下图所示,基于两分支的 Spark 持续交付方案如下
spark-src.git
与 spark-bin.git
均包含两个分支,即 dev branch 与 prod branchspark-src.git/dev
上进行spark-src.git/dev
打包出一个 release 放进 spark-bin.git/dev
的 spark-${ build \# }
文件夹内(如图中第 2 周上方的的 spark-2 )。它包含了之前所有的提交(commit 1、2、3、4)spark-bin.git/dev
的 spark 作为 symbolic 指向 spark-${ build \# }
文件夹内(如图中第 2 周上方的的 spark-2)spark-src.git/prod
通过 fast-forward merge 将 spark-src.git/dev
一周前最后一个 commit 及之前的所有 commit 都 merge 过来(如图中第 2 周需将 commit 1 merge 过来)spark-src.git/prod
打包出一个 release 放进 spark-bin.git/prod
的 spark-${ build \# }
文件夹内(如图中第 2 周下方的的 spark-1 )spark-bin.git/prod
的 spark 作为 symbolic 指向 spark-${ build \# }
在 Staging 环境中发现了 dev 版本的 bug 时,修复及集成和交付方案如下
spark-src.git/dev
上提交一个 commit (如图中黑色的 commit 9),且 commit message 包含 bugfix 字样spark-src.git/dev
打包生成一个 release 并放进 spark-bin.git/dev
的 spark-${ build \# }
文件夹内(如图中第二周与第三周之间上方的的 spark-3 )spark-bin.git/dev
中的 spark 作为 symbolic 指向 spark-${ build \# }
在生产环境中发现了 prod 版本的 bug 时,修复及集成和交付方案如下
spark-src.git/dev
上提交一个 commit(如图中红色的 commit 9),且 commit message 包含 hotfix 字样spark-src.git/dev
打包生成 release 并 commit 到 spark-bin.git/dev
的 spark-${ build \# }
(如图中上方的 spark-3 )文件夹内。 spark 作为 symbolic 指向该 spark-${ build \# }
spark-src.git/prod
(如无冲突,则该流程全自动完成,无需人工参与。如发生冲突,通过告警系统通知开发人员手工解决冲突后提交)spark-src.git/prod
打包生成 release 并 commit 到 spark-bin.git/prod
的 spark-${ build \# }
(如图中下方的 spark-3 )文件夹内。spark作为 symbolic 指向该spark-${ build \# }
spark-src.git/dev
(包含 commit 1、2、3、4、5) 与 spark-src.git/prod
(包含 commit 1) 的 base 不一样,有发生冲突的风险。一旦发生冲突,便需人工介入spark-src.git/dev
合并 commit 到 spark-src.git/prod
时需要使用 rebase 而不能直接 fast-forward merge。而该 rebase 可能再次发生冲突spark-bin.git/dev
的 bug,即图中的 commit 1、2、3、4 后的 bug,而 bug fix commit 即 commit 9 的 base 是 commit 5,存在一定程度的不一致spark-bin.git/dev
包含了 bug fix,而最新的 spark-bin.git/prod
未包含该 bugfix (它只包含了 commit 2、3、4 而不包含 commit 5、9)。只有到第 4 周,spark-bin.git/prod
才包含该 bugfix。也即 Staging 环境中发现的 bug,需要在一周多(最多两周)才能在 prod 环境中被修复。换言之,Staging 环境中检测出的 bug,仍然会继续出现在下一个生产环境的 release 中spark-src.git/dev
与 spark-src.git/prod
中包含的 commit 数一致(因为只允许 fast-forward merge),内容也最终一致。但是 commit 顺序不一致,且各 commit 内容也可能不一致。如果维护不当,容易造成两个分支差别越来越大,不易合并如下图所示,基于多分支的 Spark 持续交付方案如下
spark-src.git/master
上进行spark-src.git/master
最新代码合并到 spark-src.git/dev
。如下图中,第 2 周将 commit 4 及之前所有 commit 合并到 spark-src.git/dev
spark-src.git/dev
打包生成 release 并提交到 spark-bin.git/dev
的 spark-${ build \# }
(如下图中第 2 周的 spark-2) 文件夹内。spark 作为 symbolic,指向该 spark-${ build \# }
spark-src.git/master
一周前最后一个 commit 合并到 spark-src.git/prod
。如第 3 周合并 commit 4 及之前的 commitspark-src.git/prod
中,因为它们是对 commit 4 进行的 bug fix。后文介绍的 bug fix 流程保证,如果对 commit 4 后发布版本有多个 bug fix,那这多个 bug fix commit 紧密相连,中间不会被正常 commit 分开spark-src.git/prod
打包生成 release 并提交到 spark-bin.git/prod
的 spark-${ build \# }
(如下图中第 2 周的 spark-2) 文件夹内。spark 作为 symbolic,指向该 spark-${ build \# }
在 Staging 环境中发现了 dev 版本的 bug 时,修复及集成和交付方案如下
spark-src.git/dev
(包含 commit 1、2、3、4) 上提交一个 commit(如图中黑色的 commit 9),且 commit message 中包含 bugfix 字样spark-src.git/dev
打包生成 release 并提交到 spark-bin.git/dev
的 spark-${ build \# }
(如图中的 spark-3) 文件夹内,spark 作为 symbolic,指向该 spark-${ build \# }
git checkout master
切换到 spark-src.git/master
,再通过 git rebase dev
将 bugfix 的 commit rebase 到 spark-src.git/master
,如果 rebase 发生冲突,通过告警通知开发人员人工介入处理冲突spark-src.git/dev
上顺序相连。因此它们被 rebase 到 spark-src.git/master
后仍然顺序相连在生产环境中发现了 prod 版本的 bug 时,修复及集成和交付方案如下
spark-src.git/prod
中提交一个 commit,且其 commit message 中包含 hotfix 字样spark-src.git/prod
打包生成 release 并提交到 spark-bin.git/prod
的 spark-${ build \# }
(如图中的 spark-3) 文件夹内,spark 作为 symbolic,指向该 spark-${ build \# }
git checkout master
切换到 spark-src.git/master
,再通过 git rebase prod
将 hotfix rebase 到 spark-src.git/master
本文介绍的实践中,不考虑多个版本(经实践检验,多个版本维护成本太高,且一般无必要),只考虑一个 prod 版本,一个 dev 版本
上文介绍的持续发布中,可将 spark-bin.git/dev
部署至需要使用最新版的环境中(不一定是 Staging 环境,可以是部分生产环境)从而实现 dev 版的部署。将 spark-bin.git/prod
部署至需要使用稳定版的 prod 环境中
本文介绍的方法中,所有 release 都放到 spark-${ build \# }
中,由 spark 这一 symbolic 选择指向具体哪个 release。因此回滚方式比较直观
spark-src.git/master
上进行,Staging 环境的 bug fix 在 spark-src.git/dev
上进行,生产环境的 hot fix 在 spark-src.git/prod
上进行,清晰明了spark-src.git/master
时使用 rebase,从而保证了 spark-src.git/dev
与 spark-src.git/master
所有 commit 的顺序与内容的一致性,进而保证了这两个 branch 的一致性spark-src.git/master
时使用 rebase,从而保证了 spark-src.git/dev
与 spark-src.git/master
所有 commit 的顺序性及内容的一致性,进而保证了这两个 branch 的一致性spark-src.git/master
发生冲突时才需人工介入spark-bin.git/dev
与 spark-bin.git/prod
将开发版本与生产版本分开,方便独立部署。而其路径统一,方便版本切换与灰度发布spark-src.git/master
提交时,须先 rebase 远程分支,而不应直接使用 merge。在本方案中,这不仅是最佳实践,还是硬性要求spark-src.git/master
。但发生冲突时,需要相应修改 spark-src.git/master
上后续 commit。如上图中,提交红色 commit 9 这一 hot fix 后,在 rebase 回 spark-src.git/master
时,如有冲突,可能需要修改 commit 2 或者 commit 3、4、5。该修改会造成本地解决完冲突后的版本与远程版本冲突,需要强制 push 回远程分支。该操作存在一定风险持续部署是指,软件通过评审后,自动部署到生产环境中
上述 Spark 持续发布实践的介绍都只到 “将 *** 提交到 spark-bin.git
“ 结束。可使用基于 git 的部署(为了性能和扩展性,一般不直接在待部署机器上使用 git pull –rebase,而是使用自研的上线方案,此处不展开)将该 release 上线到 Staging 环境或生产环境
该自动上线过程即是 Spark 持续部署的最后一环
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自技术世界,原文链接 http://www.jasongj.com/spark/committer/
本文所述内容均基于 2018年9月17日 Spark 最新 Release 2.3.1 版本,以及 hadoop-2.6.0-cdh-5.4.4
Spark 输出数据到 HDFS 时,需要解决如下问题:
本文通过 Local mode 执行如下 Spark 程序详解 commit 原理1
2
3sparkContext.textFile("/json/input.zstd")
.map(_.split(","))
.saveAsTextFile("/jason/test/tmp")
在详述 commit 原理前,需要说明几个述语
在本文中,会使用如下缩写
在启动 Job 之前,Driver 首先通过 FileOutputFormat 的 checkOutputSpecs 方法检查输出目录是否已经存在。若已存在,则直接抛出 FileAlreadyExistsException
Job 开始前,由 Driver(本例使用 local mode,因此由 main 线程执行)调用 FileOuputCommitter.setupJob 创建 Application Attempt 目录,即 ${output.dir.root}/_temporary/${appAttempt}
由各 Task 执行 FileOutputCommitter.setupTask 方法(本例使用 local mode,因此由 task 线程执行)。该方法不做任何事情,因为 Task 临时目录由 Task 按需创建。
本例中,Task 写数据需要通过 TextOutputFormat 的 getRecordWriter 方法创建 LineRecordWriter。而创建前需要通过 FileOutputFormat.getTaskOutputPath设置 Task 输出路径,即 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}/${fileName}
。该 Task Attempt 所有数据均写在该目录下的文件内
Task 执行数据写完后,通过 FileOutputCommitter.needsTaskCommit 方法检查是否需要 commit 它写在 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
下的数据。
检查依据是 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
目录是否存在
如果需要 commit,并且开启了 Output commit coordination,还需要通过 RPC 由 Driver 侧的 OutputCommitCoordinator 判断该 Task Attempt 是否可以 commit
之所以需要由 Driver 侧的 CommitCoordinator 判断是否可以 commit,是因为可能由于 speculation 或者其它原因(如之前的 TaskAttemp 未被 Kill 成功)存在同一 Task 的多个 Attemp 同时写数据且都申请 commit 的情况。
当申请 commitTask 的 TaskAttempt 为失败的 Attempt,则直接拒绝
若该 TaskAttempt 成功,并且 CommitCoordinator 未允许过该 Task 的其它 Attempt 的 commit 请求,则允许该 TaskAttempt 的 commit 请求
若 CommitCoordinator 之前已允许过该 TaskAttempt 的 commit 请求,则继续同意该 TaskAttempt 的 commit 请求,即 CommitCoordinator 对该申请的处理是幂等的。
若该 TaskAttempt 成功,且 CommitCoordinator 之前已允许该 Task 的其它 Attempt 的 commit 请求,则直接拒绝当前 TaskAttempt 的 commit 请求
OutputCommitCoordinator 为了实现上述功能,为每个 ActiveStage 维护一个如下 StageState1
2
3
4private case class StageState(numPartitions: Int) {
val authorizedCommitters = Array.fill[TaskAttemptNumber](numPartitions)(NO_AUTHORIZED_COMMITTER)
val failures = mutable.Map[PartitionId, mutable.Set[TaskAttemptNumber]]()
}
该数据结构中,保存了每个 Task 被允许 commit 的 TaskAttempt。默认值均为 NO_AUTHORIZED_COMMITTER
同时,保存了每个 Task 的所有失败的 Attempt
当 TaskAttempt 被允许 commit 后,Task (本例由于使用 local model,因此由 task 线程执行)会通过如下方式 commitTask。
当 mapreduce.fileoutputcommitter.algorithm.version
的值为 1 (默认值)时,Task 将 taskAttemptPath 即 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
重命令为 committedTaskPath 即 ${output.dir.root}/_temporary/${appAttempt}/${taskAttempt}
若 mapreduce.fileoutputcommitter.algorithm.version
的值为 2,直接将taskAttemptPath 即 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
内的所有文件移动到 outputPath 即 ${output.dir.root}/
当所有 Task 都执行成功后,由 Driver (本例由于使用 local model,故由 main 线程执行)执行 FileOutputCommitter.commitJob
若 mapreduce.fileoutputcommitter.algorithm.version
的值为 1,则由 Driver 单线程遍历所有 committedTaskPath 即 ${output.dir.root}/_temporary/${appAttempt}/${taskAttempt}
,并将其下所有文件移动到 finalOutput 即 ${output.dir.root}
下
若 mapreduce.fileoutputcommitter.algorithm.version
的值为 2,则无须移动任何文件。因为所有 Task 的输出文件已在 commitTask 内被移动到 finalOutput 即 ${output.dir.root}
内
所有 commit 过的 Task 输出文件移动到 finalOutput 即 ${output.dir.root}
后,Driver 通过 cleanupJob 删除 ${output.dir.root}/_temporary/
下所有内容
上文所述的 commitTask 与 commitJob 机制,保证了一次 Application Attemp 中不同 Task 的不同 Attemp 在 commit 时的数据一致性
而当整个 Application retry 时,在之前的 Application Attemp 中已经成功 commit 的 Task 无须重新执行,其数据可直接恢复
恢复 Task 时,先获取上一次的 Application Attempt,以及对应的 committedTaskPath,即 ${output.dir.root}/_temporary/${preAppAttempt}/${taskAttempt}
若 mapreduce.fileoutputcommitter.algorithm.version
的值为 1,并且 preCommittedTaskPath 存在(说明在之前的 Application Attempt 中该 Task 已被 commit 过),则直接将 preCommittedTaskPath 重命名为 committedTaskPath
若 mapreduce.fileoutputcommitter.algorithm.version
的值为 2,无须恢复任何数据,因为在之前 Application Attempt 中 commit 过的 Task 的数据已经在 commitTask 中被移动到 ${output.dir.root}
中
中止 Task 时,由 Task 调用 FileOutputCommitter.abortTask
方法删除 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
中止 Job 由 Driver 调用 FileOutputCommitter.abortJob
方法完成。该方法通过 FileOutputCommitter.cleanupJob
方法删除 ${output.dir.root}/_temporary
V1 committer(即 mapreduce.fileoutputcommitter.algorithm.version
的值为 1),commit 过程如下
${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
移动到 ${output.dir.root}/_temporary/${appAttempt}/${taskAttempt}
${output.dir.root}/_temporary/${appAttempt}/${taskAttempt}
移动到 ${output.dir.root}
,然后创建 _SUCCESS
标记文件${output.dir.root}/_temporary/${preAppAttempt}/${preTaskAttempt}
移动到 ${output.dir.root}/_temporary/${appAttempt}/${taskAttempt}
V2 committer(即 mapreduce.fileoutputcommitter.algorithm.version
的值为 2),commit 过程如下
${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
移动到 ${output.dir.root}
_SUCCESS
标记文件V1 在 Job 执行结束后,在 Driver 端通过 commitJob 方法,单线程串行将所有 Task 的输出文件移动到输出根目录。移动以文件为单位,当 Task 个数较多(大 Job,或者小文件引起的大量小 Task),Name Node RPC 较慢时,该过程耗时较久。在实践中,可能因此发生所有 Task 均执行结束,但 Job 不结束的问题。甚至 commitJob 耗时比 所有 Task 执行时间还要长
而 V2 在 Task 结束后,由 Task 在 commitTask 方法内,将自己的数据文件移动到输出根目录。一方面,Task 结束时即移动文件,不需等待 Job 结束才移动文件,即文件移动更早发起,也更早结束。另一方面,不同 Task 间并行移动文件,极大缩短了整个 Job 内所有 Task 的文件移动耗时
V1 只有 Job 结束,才会将数据文件移动到输出根目录,才会对外可见。在此之前,所有文件均在 ${output.dir.root}/_temporary/${appAttempt}
及其子文件内,对外不可见。
当 commitJob 过程耗时较短时,其失败的可能性较小,可认为 V1 的 commit 过程是两阶段提交,要么所有 Task 都 commit 成功,要么都失败。
而由于上文提到的问题, commitJob 过程可能耗时较久,如果在此过程中,Driver 失败,则可能发生部分 Task 数据被移动到 ${output.dir.root} 对外可见,部分 Task 的数据未及时移动,对外不可见的问题。此时发生了数据不一致性的问题
V2 当 Task 结束时,立即将数据移动到 ${output.dir.root},立即对外可见。如果 Application 执行过程中失败了,已 commit 的 Task 数据仍然对外可见,而失败的 Task 数据或未被 commit 的 Task 数据对外不可见。也即 V2 更易发生数据一致性问题
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自技术世界,原文链接 http://www.jasongj.com/spark/cbo/
本文所述内容均基于 2018年9月17日 Spark 最新 Release 2.3.1 版本。后续将持续更新
上文Spark SQL 内部原理中介绍的 Optimizer 属于 RBO,实现简单有效。它属于 LogicalPlan 的优化,所有优化均基于 LogicalPlan 本身的特点,未考虑数据本身的特点,也未考虑算子本身的代价。
本文将介绍 CBO,它充分考虑了数据本身的特点(如大小、分布)以及操作算子的特点(中间结果集的分布及大小)及代价,从而更好的选择执行代价最小的物理执行计划,即 SparkPlan。
CBO 原理是计算所有可能的物理计划的代价,并挑选出代价最小的物理执行计划。其核心在于评估一个给定的物理执行计划的代价。
物理执行计划是一个树状结构,其代价等于每个执行节点的代价总合,如下图所示。
而每个执行节点的代价,分为两个部分
每个操作算子的代价相对固定,可用规则来描述。而执行节点输出数据集的大小与分布,分为两个部分:1) 初始数据集,也即原始表,其数据集的大小与分布可直接通过统计得到;2)中间节点输出数据集的大小与分布可由其输入数据集的信息与操作本身的特点推算。
所以,最终主要需要解决两个问题
通过如下 SQL 语句,可计算出整个表的记录总数以及总大小1
ANALYZE TABLE table_name COMPUTE STATISTICS;
从如下示例中,Statistics 一行可见, customer 表数据总大小为 37026233 字节,即 35.3MB,总记录数为 28万,与事实相符。
1 | spark-sql> ANALYZE TABLE customer COMPUTE STATISTICS; |
通过如下 SQL 语句,可计算出指定列的统计信息1
ANALYZE TABLE table_name COMPUTE STATISTICS FOR COLUMNS [column1] [,column2] [,column3] [,column4] ... [,columnn];
从如下示例可见,customer 表的 c_customer_sk 列最小值为 1, 最大值为 280000,null 值个数为 0,不同值个数为 274368,平均列长度为 8,最大列长度为 8。1
2
3
4
5
6
7
8
9
10
11
12
13spark-sql> ANALYZE TABLE customer COMPUTE STATISTICS FOR COLUMNS c_customer_sk, c_customer_id, c_current_cdemo_sk;
Time taken: 9.139 seconds
spark-sql> desc extended customer c_customer_sk;
col_name c_customer_sk
data_type bigint
comment NULL
min 1
max 280000
num_nulls 0
distinct_count 274368
avg_col_len 8
max_col_len 8
histogram NULL
除上述示例中的统计信息外,Spark CBO 还直接等高直方图。在上例中,histogram 为 NULL。其原因是,spark.sql.statistics.histogram.enabled 默认值为 false,也即 ANALYZE 时默认不计算及存储 histogram。
下例中,通过 SET spark.sql.statistics.histogram.enabled=true;
启用 histogram 后,完整的统计信息如下。
1 | spark-sql> ANALYZE TABLE customer COMPUTE STATISTICS FOR COLUMNS c_customer_sk,c_customer_id,c_current_cdemo_sk,c_current_hdemo_sk,c_current_addr_sk,c_first_shipto_date_sk,c_first_sales_date_sk,c_salutation,c_first_name,c_last_name,c_preferred_cust_flag,c_birth_day,c_birth_month,c_birth_year,c_birth_country,c_login,c_email_address,c_last_review_date; |
从上图可见,生成的 histogram 为 equal-height histogram,且高度为 1102.36,bin 数为 254。其中 bin 个数可由 spark.sql.statistics.histogram.numBins 配置。对于每个 bin,匀记录其最小值,最大值,以及 distinct count。
值得注意的是,这里的 distinct count 并不是精确值,而是通过 HyperLogLog 计算出来的近似值。使用 HyperLogLog 的原因有二
对于中间算子,可以根据输入数据集的统计信息以及算子的特性,可以估算出输出数据集的统计结果。
本节以 Filter 为例说明算子对数据集的影响。
对于常见的 Column A < value B
Filter,可通过如下方式估算输出中间结果的统计信息
启用 Historgram 后,Filter Column A < value B
的估算方法为
在上图中,B.value = 15,A.min = 0,A.max = 32,bin 个数为 10。Filter 后 A.ndv = ndv(<B.value) = ndv(<15)。该值可根据 A < 15 的 5 个 bin 的 ndv 通过 HyperLogLog 合并而得,无须重新计算所有 A < 15 的数据。
SQL 中常见的操作有 Selection(由 select 语句表示),Filter(由 where 语句表示)以及笛卡尔乘积(由 join 语句表示)。其中代价最高的是 join。
Spark SQL 的 CBO 通过如下方法估算 join 的代价1
2Cost = rows * weight + size * (1 - weight)
Cost = CostCPU * weight + CostIO * (1 - weight)
其中 rows 即记录行数代表了 CPU 代价,size 代表了 IO 代价。weight 由 spark.sql.cbo.joinReorder.card.weight 决定,其默认值为 0.7。
对于两表Hash Join,一般选择小表作为build size,构建哈希表,另一边作为 probe side。未开启 CBO 时,根据表原始数据大小选择 t2 作为build side
而开启 CBO 后,基于估计的代价选择 t1 作为 build side。更适合本例
在 Spark SQL 中,Join 可分为 Shuffle based Join 和 BroadcastJoin。Shuffle based Join 需要引入 Shuffle,代价相对较高。BroadcastJoin 无须 Join,但要求至少有一张表足够小,能通过 Spark 的 Broadcast 机制广播到每个 Executor 中。
在不开启 CBO 中,Spark SQL 通过 spark.sql.autoBroadcastJoinThreshold 判断是否启用 BroadcastJoin。其默认值为 10485760 即 10 MB。
并且该判断基于参与 Join 的表的原始大小。
在下图示例中,Table 1 大小为 1 TB,Table 2 大小为 20 GB,因此在对二者进行 join 时,由于二者都远大于自动 BroatcastJoin 的阈值,因此 Spark SQL 在未开启 CBO 时选用 SortMergeJoin 对二者进行 Join。
而开启 CBO 后,由于 Table 1 经过 Filter 1 后结果集大小为 500 GB,Table 2 经过 Filter 2 后结果集大小为 10 MB 低于自动 BroatcastJoin 阈值,因此 Spark SQL 选用 BroadcastJoin。
未开启 CBO 时,Spark SQL 按 SQL 中 join 顺序进行 Join。极端情况下,整个 Join 可能是 left-deep tree。在下图所示 TPC-DS Q25 中,多路 Join 存在如下问题,因此耗时 241 秒。
开启 CBO 后, Spark SQL 将执行计划优化如下
优化后的 Join 有如下优势,因此执行时间降至 71 秒
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自技术世界,原文链接 http://www.jasongj.com/spark/rbo/
本文所述内容均基于 2018年9月10日 Spark 最新 Release 2.3.1 版本。后续将持续更新
Spark SQL 的整体架构如下图所示
从上图可见,无论是直接使用 SQL 语句还是使用 DataFrame,都会经过如下步骤转换成 DAG 对 RDD 的操作
Spark SQL 使用 Antlr 进行记法和语法解析,并生成 UnresolvedPlan。
当用户使用 SparkSession.sql(sqlText : String) 提交 SQL 时,SparkSession 最终会调用 SparkSqlParser 的 parsePlan 方法。该方法分两步
现在两张表,分别定义如下1
2
3
4
5CREATE TABLE score (
id INT,
math_score INT,
english_score INT
)
1 | CREATE TABLE people ( |
对其进行关联查询如下1
2
3
4
5
6
7
8
9SELECT sum(v)
FROM (
SELECT score.id,
100 + 80 + score.math_score + score.english_score AS v
FROM people
JOIN score
ON people.id = score.id
AND people.age > 10
) tmp
生成的 UnresolvedPlan 如下图所示。
从上图可见
Spark SQL 解析出的 UnresolvedPlan 如下所示1
2
3
4
5
6
7
8== Parsed Logical Plan ==
'Project [unresolvedalias('sum('v), None)]
+- 'SubqueryAlias tmp
+- 'Project ['score.id, (((100 + 80) + 'score.math_score) + 'score.english_score) AS v#493]
+- 'Filter (('people.id = 'score.id) && ('people.age > 10))
+- 'Join Inner
:- 'UnresolvedRelation `people`
+- 'UnresolvedRelation `score`
从 Analyzer 的构造方法可见
1 | class Analyzer( |
Analyzer 包含了如下的转换规则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
55
56lazy val batches: Seq[Batch] = Seq(
Batch("Hints", fixedPoint,
new ResolveHints.ResolveBroadcastHints(conf),
ResolveHints.RemoveAllHints),
Batch("Simple Sanity Check", Once,
LookupFunctions),
Batch("Substitution", fixedPoint,
CTESubstitution,
WindowsSubstitution,
EliminateUnions,
new SubstituteUnresolvedOrdinals(conf)),
Batch("Resolution", fixedPoint,
ResolveTableValuedFunctions ::
ResolveRelations ::
ResolveReferences ::
ResolveCreateNamedStruct ::
ResolveDeserializer ::
ResolveNewInstance ::
ResolveUpCast ::
ResolveGroupingAnalytics ::
ResolvePivot ::
ResolveOrdinalInOrderByAndGroupBy ::
ResolveAggAliasInGroupBy ::
ResolveMissingReferences ::
ExtractGenerator ::
ResolveGenerate ::
ResolveFunctions ::
ResolveAliases ::
ResolveSubquery ::
ResolveSubqueryColumnAliases ::
ResolveWindowOrder ::
ResolveWindowFrame ::
ResolveNaturalAndUsingJoin ::
ExtractWindowExpressions ::
GlobalAggregates ::
ResolveAggregateFunctions ::
TimeWindowing ::
ResolveInlineTables(conf) ::
ResolveTimeZone(conf) ::
ResolvedUuidExpressions ::
TypeCoercion.typeCoercionRules(conf) ++
extendedResolutionRules : _*),
Batch("Post-Hoc Resolution", Once, postHocResolutionRules: _*),
Batch("View", Once,
AliasViewChild(conf)),
Batch("Nondeterministic", Once,
PullOutNondeterministic),
Batch("UDF", Once,
HandleNullInputsForUDF),
Batch("FixNullability", Once,
FixNullability),
Batch("Subquery", Once,
UpdateOuterReferences),
Batch("Cleanup", fixedPoint,
CleanupAliases)
)
例如, ResolveRelations 用于分析查询用到的 Table 或 View。本例中 UnresolvedRelation (people) 与 UnresolvedRelation (score) 被解析为 HiveTableRelation (json.people) 与 HiveTableRelation (json.score),并列出其各自包含的字段名。
经 Analyzer 分析后得到的 Resolved Logical Plan 如下所示
1 | == Analyzed Logical Plan == |
Analyzer 分析前后的 LogicalPlan 对比如下
由上图可见,分析后,每张表对应的字段集,字段类型,数据存储位置都已确定。Project 与 Filter 操作的字段类型以及在表中的位置也已确定。
有了这些信息,已经可以直接将该 LogicalPlan 转换为 Physical Plan 进行执行。
但是由于不同用户提交的 SQL 质量不同,直接执行会造成不同用户提交的语义相同的不同 SQL 执行效率差距甚远。换句话说,如果要保证较高的执行效率,用户需要做大量的 SQL 优化,使用体验大大降低。
为了尽可能保证无论用户是否熟悉 SQL 优化,提交的 SQL 质量如何, Spark SQL 都能以较高效率执行,还需在执行前进行 LogicalPlan 优化。
Spark SQL 目前的优化主要是基于规则的优化,即 RBO (Rule-based optimization)
PushdownPredicate
PushdownPredicate 是最常见的用于减少参与计算的数据量的方法。
前文中直接对两表进行 Join 操作,然后再 进行 Filter 操作。引入 PushdownPredicate 后,可先对两表进行 Filter 再进行 Join,如下图所示。
当 Filter 可过滤掉大部分数据时,参与 Join 的数据量大大减少,从而使得 Join 操作速度大大提高。
这里需要说明的是,此处的优化是 LogicalPlan 的优化,从逻辑上保证了将 Filter 下推后由于参与 Join 的数据量变少而提高了性能。另一方面,在物理层面,Filter 下推后,对于支持 Filter 下推的 Storage,并不需要将表的全量数据扫描出来再过滤,而是直接只扫描符合 Filter 条件的数据,从而在物理层面极大减少了扫描表的开销,提高了执行速度。
ConstantFolding
本文的 SQL 查询中,Project 部分包含了 100 + 800 + match_score + english_score 。如果不进行优化,那如果有一亿条记录,就会计算一亿次 100 + 80,非常浪费资源。因此可通过 ConstantFolding 将这些常量合并,从而减少不必要的计算,提高执行速度。
ColumnPruning
在上图中,Filter 与 Join 操作会保留两边所有字段,然后在 Project 操作中筛选出需要的特定列。如果能将 Project 下推,在扫描表时就只筛选出满足后续操作的最小字段集,则能大大减少 Filter 与 Project 操作的中间结果集数据量,从而极大提高执行速度。
这里需要说明的是,此处的优化是逻辑上的优化。在物理上,Project 下推后,对于列式存储,如 Parquet 和 ORC,可在扫描表时就只扫描需要的列而跳过不需要的列,进一步减少了扫描开销,提高了执行速度。
经过如上优化后的 LogicalPlan 如下
1 | == Optimized Logical Plan == |
得到优化后的 LogicalPlan 后,SparkPlanner 将其转化为 SparkPlan 即物理计划。
本例中由于 score 表数据量较小,Spark 使用了 BroadcastJoin。因此 score 表经过 Filter 后直接使用 BroadcastExchangeExec 将数据广播出去,然后结合广播数据对 people 表使用 BroadcastHashJoinExec 进行 Join。再经过 Project 后使用 HashAggregateExec 进行分组聚合。
至此,一条 SQL 从提交到解析、分析、优化以及执行的完整过程就介绍完毕。
本文介绍的 Optimizer 属于 RBO,实现简单有效。它属于 LogicalPlan 的优化,所有优化均基于 LogicalPlan 本身的特点,未考虑数据本身的特点,也未考虑算子本身的代价。下文将介绍 CBO,它充分考虑了数据本身的特点(如大小、分布)以及操作算子的特点(中间结果集的分布及大小)及代价,从而更好的选择执行代价最小的物理执行计划,即 SparkPlan。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/java/threadlocal/
由于 ThreadLocal 支持范型,如 ThreadLocal< StringBuilder >,为表述方便,后文用 变量 代表 ThreadLocal 本身,而用 实例 代表具体类型(如 StringBuidler )的实例。
写这篇文章的一个原因在于,网上很多博客关于 ThreadLocal 的适用场景以及解决的问题,描述的并不清楚,甚至是错的。下面是常见的对于 ThreadLocal的介绍
ThreadLocal为解决多线程程序的并发问题提供了一种新的思路
ThreadLocal的目的是为了解决多线程访问资源时的共享问题
还有很多文章在对比 ThreadLocal 与 synchronize 的异同。既然是作比较,那应该是认为这两者解决相同或类似的问题。
上面的描述,问题在于,ThreadLocal 并不解决多线程 共享 变量的问题。既然变量不共享,那就更谈不上同步的问题。
ThreadLoal 变量,它的基本原理是,同一个 ThreadLocal 所包含的对象(对ThreadLocal< String >而言即为 String 类型变量),在不同的 Thread 中有不同的副本(实际是不同的实例,后文会详细阐述)。这里有几点需要注意
那 ThreadLocal 到底解决了什么问题,又适用于什么样的场景?
This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable. ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID).
Each thread holds an implicit reference to its copy of a thread-local variable as long as the thread is alive and the ThreadLocal instance is accessible; after a thread goes away, all of its copies of thread-local instances are subject to garbage collection (unless other references to these copies exist).
核心意思是
ThreadLocal 提供了线程本地的实例。它与普通变量的区别在于,每个使用该变量的线程都会初始化一个完全独立的实例副本。ThreadLocal 变量通常被
private static
修饰。当一个线程结束时,它所使用的所有 ThreadLocal 相对的实例副本都可被回收。
总的来说,ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景。后文会通过实例详细阐述该观点。另外,该场景下,并非必须使用 ThreadLocal ,其它方式完全可以实现同样的效果,只是 ThreadLocal 使得实现更简洁。
下面通过如下代码说明 ThreadLocal 的使用方式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
55
56
57
58public class ThreadLocalDemo {
public static void main(String[] args) throws InterruptedException {
int threads = 3;
CountDownLatch countDownLatch = new CountDownLatch(threads);
InnerClass innerClass = new InnerClass();
for(int i = 1; i <= threads; i++) {
new Thread(() -> {
for(int j = 0; j < 4; j++) {
innerClass.add(String.valueOf(j));
innerClass.print();
}
innerClass.set("hello world");
countDownLatch.countDown();
}, "thread - " + i).start();
}
countDownLatch.await();
}
private static class InnerClass {
public void add(String newStr) {
StringBuilder str = Counter.counter.get();
Counter.counter.set(str.append(newStr));
}
public void print() {
System.out.printf("Thread name:%s , ThreadLocal hashcode:%s, Instance hashcode:%s, Value:%s\n",
Thread.currentThread().getName(),
Counter.counter.hashCode(),
Counter.counter.get().hashCode(),
Counter.counter.get().toString());
}
public void set(String words) {
Counter.counter.set(new StringBuilder(words));
System.out.printf("Set, Thread name:%s , ThreadLocal hashcode:%s, Instance hashcode:%s, Value:%s\n",
Thread.currentThread().getName(),
Counter.counter.hashCode(),
Counter.counter.get().hashCode(),
Counter.counter.get().toString());
}
}
private static class Counter {
private static ThreadLocal<StringBuilder> counter = new ThreadLocal<StringBuilder>() {
protected StringBuilder initialValue() {
return new StringBuilder();
}
};
}
}
ThreadLocal本身支持范型。该例使用了 StringBuilder 类型的 ThreadLocal 变量。可通过 ThreadLocal 的 get() 方法读取 StringBuidler 实例,也可通过 set(T t) 方法设置 StringBuilder。
上述代码执行结果如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15Thread name:thread - 1 , ThreadLocal hashcode:372282300, Instance hashcode:418873098, Value:0
Thread name:thread - 3 , ThreadLocal hashcode:372282300, Instance hashcode:1609588821, Value:0
Thread name:thread - 2 , ThreadLocal hashcode:372282300, Instance hashcode:1780437710, Value:0
Thread name:thread - 3 , ThreadLocal hashcode:372282300, Instance hashcode:1609588821, Value:01
Thread name:thread - 1 , ThreadLocal hashcode:372282300, Instance hashcode:418873098, Value:01
Thread name:thread - 3 , ThreadLocal hashcode:372282300, Instance hashcode:1609588821, Value:012
Thread name:thread - 3 , ThreadLocal hashcode:372282300, Instance hashcode:1609588821, Value:0123
Set, Thread name:thread - 3 , ThreadLocal hashcode:372282300, Instance hashcode:1362597339, Value:hello world
Thread name:thread - 2 , ThreadLocal hashcode:372282300, Instance hashcode:1780437710, Value:01
Thread name:thread - 1 , ThreadLocal hashcode:372282300, Instance hashcode:418873098, Value:012
Thread name:thread - 2 , ThreadLocal hashcode:372282300, Instance hashcode:1780437710, Value:012
Thread name:thread - 1 , ThreadLocal hashcode:372282300, Instance hashcode:418873098, Value:0123
Thread name:thread - 2 , ThreadLocal hashcode:372282300, Instance hashcode:1780437710, Value:0123
Set, Thread name:thread - 1 , ThreadLocal hashcode:372282300, Instance hashcode:482932940, Value:hello world
Set, Thread name:thread - 2 , ThreadLocal hashcode:372282300, Instance hashcode:1691922941, Value:hello world
从上面的输出可看出
既然每个访问 ThreadLocal 变量的线程都有自己的一个“本地”实例副本。一个可能的方案是 ThreadLocal 维护一个 Map,键是 Thread,值是它在该 Thread 内的实例。线程通过该 ThreadLocal 的 get() 方案获取实例时,只需要以线程为键,从 Map 中找出对应的实例即可。该方案如下图所示
该方案可满足上文提到的每个线程内一个独立备份的要求。每个新线程访问该 ThreadLocal 时,需要向 Map 中添加一个映射,而每个线程结束时,应该清除该映射。这里就有两个问题:
其中锁的问题,是 JDK 未采用该方案的一个原因。
上述方案中,出现锁的问题,原因在于多线程访问同一个 Map。如果该 Map 由 Thread 维护,从而使得每个 Thread 只访问自己的 Map,那就不存在多线程写的问题,也就不需要锁。该方案如下图所示。
该方案虽然没有锁的问题,但是由于每个线程访问某 ThreadLocal 变量后,都会在自己的 Map 内维护该 ThreadLocal 变量与具体实例的映射,如果不删除这些引用(映射),则这些 ThreadLocal 不能被回收,可能会造成内存泄漏。后文会介绍 JDK 如何解决该问题。
该方案中,Map 由 ThreadLocal 类的静态内部类 ThreadLocalMap 提供。该类的实例维护某个 ThreadLocal 与具体实例的映射。与 HashMap 不同的是,ThreadLocalMap 的每个 Entry 都是一个对 键 的弱引用,这一点从super(k)
可看出。另外,每个 Entry 都包含了一个对 值 的强引用。1
2
3
4
5
6
7
8
9static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
使用弱引用的原因在于,当没有强引用指向 ThreadLocal 变量时,它可被回收,从而避免上文所述 ThreadLocal 不能被回收而造成的内存泄漏的问题。
但是,这里又可能出现另外一种内存泄漏的问题。ThreadLocalMap 维护 ThreadLocal 变量与具体实例的映射,当 ThreadLocal 变量被回收后,该映射的键变为 null,该 Entry 无法被移除。从而使得实例被该 Entry 引用而无法被回收造成内存泄漏。
注:Entry虽然是弱引用,但它是 ThreadLocal 类型的弱引用(也即上文所述它是对 键 的弱引用),而非具体实例的的弱引用,所以无法避免具体实例相关的内存泄漏。
读取实例方法如下所示1
2
3
4
5
6
7
8
9
10
11
12
13public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
"unchecked") (
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
读取实例时,线程首先通过getMap(t)
方法获取自身的 ThreadLocalMap。从如下该方法的定义可见,该 ThreadLocalMap 的实例是 Thread 类的一个字段,即由 Thread 维护 ThreadLocal 对象与具体实例的映射,这一点与上文分析一致。1
2
3ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
获取到 ThreadLocalMap 后,通过map.getEntry(this)
方法获取该 ThreadLocal 在当前线程的 ThreadLocalMap 中对应的 Entry。该方法中的 this 即当前访问的 ThreadLocal 对象。
如果获取到的 Entry 不为 null,从 Entry 中取出值即为所需访问的本线程对应的实例。如果获取到的 Entry 为 null,则通过setInitialValue()
方法设置该 ThreadLocal 变量在该线程中对应的具体实例的初始值。
设置初始值方法如下1
2
3
4
5
6
7
8
9
10private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
该方法为 private 方法,无法被重载。
首先,通过initialValue()
方法获取初始值。该方法为 public 方法,且默认返回 null。所以典型用法中常常重载该方法。上例中即在内部匿名类中将其重载。
然后拿到该线程对应的 ThreadLocalMap 对象,若该对象不为 null,则直接将该 ThreadLocal 对象与对应实例初始值的映射添加进该线程的 ThreadLocalMap中。若为 null,则先创建该 ThreadLocalMap 对象再将映射添加其中。
这里并不需要考虑 ThreadLocalMap 的线程安全问题。因为每个线程有且只有一个 ThreadLocalMap 对象,并且只有该线程自己可以访问它,其它线程不会访问该 ThreadLocalMap,也即该对象不会在多个线程中共享,也就不存在线程安全的问题。
除了通过initialValue()
方法设置实例的初始值,还可通过 set 方法设置线程内实例的值,如下所示。1
2
3
4
5
6
7
8public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
该方法先获取该线程的 ThreadLocalMap 对象,然后直接将 ThreadLocal 对象(即代码中的 this)与目标实例的映射添加进 ThreadLocalMap 中。当然,如果映射已经存在,就直接覆盖。另外,如果获取到的 ThreadLocalMap 为 null,则先创建该 ThreadLocalMap 对象。
对于已经不再被使用且已被回收的 ThreadLocal 对象,它在每个线程内对应的实例由于被线程的 ThreadLocalMap 的 Entry 强引用,无法被回收,可能会造成内存泄漏。
针对该问题,ThreadLocalMap 的 set 方法中,通过 replaceStaleEntry 方法将所有键为 null 的 Entry 的值设置为 null,从而使得该值可被回收。另外,会在 rehash 方法中通过 expungeStaleEntry 方法将键和值为 null 的 Entry 设置为 null 从而使得该 Entry 可被回收。通过这种方式,ThreadLocal 可防止内存泄漏。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
如上文所述,ThreadLocal 适用于如下两种场景
对于第一点,每个线程拥有自己实例,实现它的方式很多。例如可以在线程内部构建一个单独的实例。ThreadLocal 可以以非常方便的形式满足该需求。
对于第二点,可以在满足第一点(每个线程有自己的实例)的条件下,通过方法间引用传递的形式实现。ThreadLocal 使得代码耦合度更低,且实现更优雅。
对于 Java Web 应用而言,Session 保存了很多信息。很多时候需要通过 Session 获取信息,有些时候又需要修改 Session 的信息。一方面,需要保证每个线程有自己单独的 Session 实例。另一方面,由于很多地方都需要操作 Session,存在多方法共享 Session 的需求。如果不使用 ThreadLocal,可以在每个线程内构建一个 Session实例,并将该实例在多个方法间传递,如下所示。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
36public class SessionHandler {
public static class Session {
private String id;
private String user;
private String status;
}
public Session createSession() {
return new Session();
}
public String getUser(Session session) {
return session.getUser();
}
public String getStatus(Session session) {
return session.getStatus();
}
public void setStatus(Session session, String status) {
session.setStatus(status);
}
public static void main(String[] args) {
new Thread(() -> {
SessionHandler handler = new SessionHandler();
Session session = handler.createSession();
handler.getStatus(session);
handler.getUser(session);
handler.setStatus(session, "close");
handler.getStatus(session);
}).start();
}
}
该方法是可以实现需求的。但是每个需要使用 Session 的地方,都需要显式传递 Session 对象,方法间耦合度较高。
这里使用 ThreadLocal 重新实现该功能如下所示。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
37public class SessionHandler {
public static ThreadLocal<Session> session = new ThreadLocal<Session>();
public static class Session {
private String id;
private String user;
private String status;
}
public void createSession() {
session.set(new Session());
}
public String getUser() {
return session.get().getUser();
}
public String getStatus() {
return session.get().getStatus();
}
public void setStatus(String status) {
session.get().setStatus(status);
}
public static void main(String[] args) {
new Thread(() -> {
SessionHandler handler = new SessionHandler();
handler.getStatus();
handler.getUser();
handler.setStatus("close");
handler.getStatus();
}).start();
}
}
使用 ThreadLocal 改造后的代码,不再需要在各个方法间传递 Session 对象,并且也非常轻松的保证了每个线程拥有自己独立的实例。
如果单看其中某一点,替代方法很多。比如可通过在线程内创建局部变量可实现每个线程有自己的实例,使用静态变量可实现变量在方法间的共享。但如果要同时满足变量在线程间的隔离与方法间的共享,ThreadLocal再合适不过。
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自技术世界,原文链接 http://www.jasongj.com/zookeeper/distributedlock/
如上文《Zookeeper架构及FastLeaderElection机制》所述,Zookeeper 提供了一个类似于 Linux 文件系统的树形结构。该树形结构中每个节点被称为 znode ,可按如下两个维度分类
Zookeeper 简单高效,同时提供如下语义保证,从而使得我们可以利用这些特性提供复杂的服务。
所有对 Zookeeper 的读操作,都可附带一个 Watch 。一旦相应的数据有变化,该 Watch 即被触发。Watch 有如下特点
对于分布式锁(这里特指排它锁)而言,任意时刻,最多只有一个进程(对于单进程内的锁而言是单线程)可以获得锁。
对于领导选举而言,任意时间,最多只有一个成功当选为Leader。否则即出现脑裂(Split brain)
对于分布式锁,需要保证获得锁的进程在释放锁之前可再次获得锁,即锁的可重入性。
对于领导选举,Leader需要能够确认自己已经获得领导权,即确认自己是Leader。
锁的获得者应该能够正确释放已经获得的锁,并且当获得锁的进程宕机时,锁应该自动释放,从而使得其它竞争方可以获得该锁,从而避免出现死锁的状态。
领导应该可以主动放弃领导权,并且当领导所在进程宕机时,领导权应该自动释放,从而使得其它参与者可重新竞争领导而避免进入无主状态。
当获得锁的一方释放锁时,其它对于锁的竞争方需要能够感知到锁的释放,并再次尝试获取锁。
原来的Leader放弃领导权时,其它参与方应该能够感知该事件,并重新发起选举流程。
从上面几个方面可见,分布式锁与领导选举的技术要点非常相似,实际上其实现机制也相近。本章就以领导选举为例来说明二者的实现原理,分布式锁的实现原理也几乎一致。
假设有三个Zookeeper的客户端,如下图所示,同时竞争Leader。这三个客户端同时向Zookeeper集群注册Ephemeral且Non-sequence类型的节点,路径都为/zkroot/leader
(工程实践中,路径名可自定义)。
如上图所示,由于是Non-sequence节点,这三个客户端只会有一个创建成功,其它节点均创建失败。此时,创建成功的客户端(即上图中的Client 1
)即成功竞选为 Leader 。其它客户端(即上图中的Client 2
和Client 3
)此时匀为 Follower。
如果 Leader 打算主动放弃领导权,直接删除/zkroot/leader
节点即可。
如果 Leader 进程意外宕机,其与 Zookeeper 间的 Session 也结束,该节点由于是Ephemeral类型的节点,因此也会自动被删除。
此时/zkroot/leader
节点不复存在,对于其它参与竞选的客户端而言,之前的 Leader 已经放弃了领导权。
由上图可见,创建节点失败的节点,除了成为 Follower 以外,还会向/zkroot/leader
注册一个 Watch ,一旦 Leader 放弃领导权,也即该节点被删除,所有的 Follower 会收到通知。
感知到旧 Leader 放弃领导权后,所有的 Follower 可以再次发起新一轮的领导选举,如下图所示。
从上图中可见
非公平模式
的原因如下图所示,公平领导选举中,各客户端均创建/zkroot/leader
节点,且其类型为Ephemeral与Sequence。
由于是Sequence类型节点,故上图中三个客户端均创建成功,只是序号不一样。此时,每个客户端都会判断自己创建成功的节点的序号是不是当前最小的。如果是,则该客户端为 Leader,否则即为 Follower。
在上图中,Client 1
创建的节点序号为 1 ,Client 2
创建的节点序号为 2,Client 3
创建的节点序号为3。由于最小序号为 1 ,且该节点由Client 1
创建,故Client 1
为 Leader 。
Leader 如果主动放弃领导权,直接删除其创建的节点即可。
如果 Leader 所在进程意外宕机,其与 Zookeeper 间的 Session 结束,由于其创建的节点为Ephemeral类型,故该节点自动被删除。
与非公平模式不同,每个 Follower 并非都 Watch 由 Leader 创建出来的节点,而是 Watch 序号刚好比自己序号小的节点。
在上图中,总共有 1、2、3 共三个节点,因此Client 2
Watch /zkroot/leader1
,Client 3
Watch /zkroot/leader2
。(注:序号应该是10位数字,而非一位数字,这里为了方便,以一位数字代替)
一旦 Leader 宕机,/zkroot/leader1
被删除,Client 2
可得到通知。此时Client 3
由于 Watch 的是/zkroot/leader2
,故不会得到通知。
Client 2
得到/zkroot/leader1
被删除的通知后,不会立即成为新的 Leader 。而是先判断自己的序号 2 是不是当前最小的序号。在该场景下,其序号确为最小。因此Client 2
成为新的 Leader 。
这里要注意,如果在Client 1
放弃领导权之前,Client 2
就宕机了,Client 3
会收到通知。此时Client 3
不会立即成为Leader,而是要先判断自己的序号 3 是否为当前最小序号。很显然,由于Client 1
创建的/zkroot/leader1
还在,因此Client 3
不会成为新的 Leader ,并向Client 2
序号 2 前面的序号,也即 1 创建 Watch。该过程如下图所示。
公平模式
的由来基于 Zookeeper 的领导选举或者分布式锁的实现均基于 Zookeeper 节点的特性及通知机制。充分利用这些特性,还可以开发出适用于其它场景的分布式应用。
]]>原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自技术世界,原文链接 http://www.jasongj.com/kafka/transaction/
本文所有Kafka原理性的描述除特殊说明外均基于Kafka 1.0.0版本。
Kafka事务机制的实现主要是为了支持
Exactly Once
即正好一次语义Exactly Once
《Kafka背景及架构介绍》一文中有说明Kafka在0.11.0.0之前的版本中只支持At Least Once
和At Most Once
语义,尚不支持Exactly Once
语义。
但是在很多要求严格的场景下,如使用Kafka处理交易数据,Exactly Once
语义是必须的。我们可以通过让下游系统具有幂等性来配合Kafka的At Least Once
语义来间接实现Exactly Once
。但是:
因此,Kafka本身对Exactly Once
语义的支持就非常必要。
操作的原子性是指,多个操作要么全部成功要么全部失败,不存在部分成功部分失败的可能。
实现原子性操作的意义在于:
上文提到,实现Exactly Once
的一种方法是让下游系统具有幂等处理特性,而在Kafka Stream中,Kafka Producer本身就是“下游”系统,因此如果能让Producer具有幂等处理特性,那就可以让Kafka Stream在一定程度上支持Exactly once
语义。
为了实现Producer的幂等语义,Kafka引入了Producer ID
(即PID
)和Sequence Number
。每个新的Producer在初始化的时候会被分配一个唯一的PID,该PID对用户完全透明而不会暴露给用户。
对于每个PID,该Producer发送数据的每个<Topic, Partition>
都对应一个从0开始单调递增的Sequence Number
。
类似地,Broker端也会为每个<PID, Topic, Partition>
维护一个序号,并且每次Commit一条消息时将其对应序号递增。对于接收的每条消息,如果其序号比Broker维护的序号(即最后一次Commit的消息的序号)大一,则Broker会接受它,否则将其丢弃:
InvalidSequenceNumber
DuplicateSequenceNumber
上述设计解决了0.11.0.0之前版本中的两个问题:
上述幂等设计只能保证单个Producer对于同一个<Topic, Partition>
的Exactly Once
语义。
另外,它并不能保证写操作的原子性——即多个写操作,要么全部被Commit要么全部不被Commit。
更不能保证多个读写操作的的原子性。尤其对于Kafka Stream应用而言,典型的操作即是从某个Topic消费数据,经过一系列转换后写回另一个Topic,保证从源Topic的读取与向目标Topic的写入的原子性有助于从故障中恢复。
事务保证可使得应用程序将生产数据和消费数据当作一个原子单元来处理,要么全部成功,要么全部失败,即使该生产或消费跨多个<Topic, Partition>
。
另外,有状态的应用也可以保证重启后从断点处继续处理,也即事务恢复。
为了实现这种效果,应用程序必须提供一个稳定的(重启后不变)唯一的ID,也即Transaction ID
。Transactin ID
与PID
可能一一对应。区别在于Transaction ID
由用户提供,而PID
是内部的实现对用户透明。
另外,为了保证新的Producer启动后,旧的具有相同Transaction ID
的Producer即失效,每次Producer通过Transaction ID
拿到PID的同时,还会获取一个单调递增的epoch。由于旧的Producer的epoch比新Producer的epoch小,Kafka可以很容易识别出该Producer是老的Producer并拒绝其请求。
有了Transaction ID
后,Kafka可保证:
Transaction ID
的新的Producer实例被创建且工作时,旧的且拥有相同Transaction ID
的Producer将不再工作。需要注意的是,上述的事务保证是从Producer的角度去考虑的。从Consumer的角度来看,该保证会相对弱一些。尤其是不能保证所有被某事务Commit过的所有消息都被一起消费,因为:
这一节所说的事务主要指原子性,也即Producer将多条消息作为一个事务批量发送,要么全部成功要么全部失败。
为了实现这一点,Kafka 0.11.0.0引入了一个服务器端的模块,名为Transaction Coordinator
,用于管理Producer发送的消息的事务性。
该Transaction Coordinator
维护Transaction Log
,该log存于一个内部的Topic内。由于Topic数据具有持久性,因此事务的状态也具有持久性。
Producer并不直接读写Transaction Log
,它与Transaction Coordinator
通信,然后由Transaction Coordinator
将该事务的状态插入相应的Transaction Log
。
Transaction Log
的设计与Offset Log
用于保存Consumer的Offset类似。
许多基于Kafka的应用,尤其是Kafka Stream应用中同时包含Consumer和Producer,前者负责从Kafka中获取消息,后者负责将处理完的数据写回Kafka的其它Topic中。
为了实现该场景下的事务的原子性,Kafka需要保证对Consumer Offset的Commit与Producer对发送消息的Commit包含在同一个事务中。否则,如果在二者Commit中间发生异常,根据二者Commit的顺序可能会造成数据丢失和数据重复:
At Least Once
语义,可能造成数据重复。At Most Once
语义,可能造成数据丢失。为了区分写入Partition的消息被Commit还是Abort,Kafka引入了一种特殊类型的消息,即Control Message
。该类消息的Value内不包含任何应用相关的数据,并且不会暴露给应用程序。它只用于Broker与Client间的内部通信。
对于Producer端事务,Kafka以Control Message的形式引入一系列的Transaction Marker
。Consumer即可通过该标记判定对应的消息被Commit了还是Abort了,然后结合该Consumer配置的隔离级别决定是否应该将该消息返回给应用程序。
1 | Producer<String, String> producer = new KafkaProducer<String, String>(props); |
Transaction Coordinator
由于Transaction Coordinator
是分配PID和管理事务的核心,因此Producer要做的第一件事情就是通过向任意一个Broker发送FindCoordinator
请求找到Transaction Coordinator
的位置。
注意:只有应用程序为Producer配置了Transaction ID
时才可使用事务特性,也才需要这一步。另外,由于事务性要求Producer开启幂等特性,因此通过将transactional.id
设置为非空从而开启事务特性的同时也需要通过将enable.idempotence
设置为true来开启幂等特性。
找到Transaction Coordinator
后,具有幂等特性的Producer必须发起InitPidRequest
请求以获取PID。
注意:只要开启了幂等特性即必须执行该操作,而无须考虑该Producer是否开启了事务特性。
如果事务特性被开启 InitPidRequest
会发送给Transaction Coordinator
。如果Transaction Coordinator
是第一次收到包含有该Transaction ID
的InitPidRequest请求,它将会把该<TransactionID, PID>
存入Transaction Log
,如上图中步骤2.1所示。这样可保证该对应关系被持久化,从而保证即使Transaction Coordinator
宕机该对应关系也不会丢失。
除了返回PID外,InitPidRequest
还会执行如下任务:
注意:InitPidRequest
的处理过程是同步阻塞的。一旦该调用正确返回,Producer即可开始新的事务。
另外,如果事务特性未开启,InitPidRequest
可发送至任意Broker,并且会得到一个全新的唯一的PID。该Producer将只能使用幂等特性以及单一Session内的事务特性,而不能使用跨Session的事务特性。
Kafka从0.11.0.0版本开始,提供beginTransaction()
方法用于开启一个事务。调用该方法后,Producer本地会记录已经开启了事务,但Transaction Coordinator
只有在Producer发送第一条消息后才认为事务已经开启。
这一阶段,包含了整个事务的数据处理过程,并且包含了多种请求。
AddPartitionsToTxnRequest
一个Producer可能会给多个<Topic, Partition>
发送数据,给一个新的<Topic, Partition>
发送数据前,它需要先向Transaction Coordinator
发送AddPartitionsToTxnRequest
。
Transaction Coordinator
会将该<Transaction, Topic, Partition>
存于Transaction Log
内,并将其状态置为BEGIN
,如上图中步骤4.1所示。有了该信息后,我们才可以在后续步骤中为每个Topic, Partition>
设置COMMIT或者ABORT标记(如上图中步骤5.2所示)。
另外,如果该<Topic, Partition>
为该事务中第一个<Topic, Partition>
,Transaction Coordinator
还会启动对该事务的计时(每个事务都有自己的超时时间)。
ProduceRequest
Producer通过一个或多个ProduceRequest
发送一系列消息。除了应用数据外,该请求还包含了PID,epoch,和Sequence Number
。该过程如上图中步骤4.2所示。
AddOffsetsToTxnRequest
为了提供事务性,Producer新增了sendOffsetsToTransaction
方法,该方法将多组消息的发送和消费放入同一批处理内。
该方法先判断在当前事务中该方法是否已经被调用并传入了相同的Group ID。若是,直接跳到下一步;若不是,则向Transaction Coordinator
发送AddOffsetsToTxnRequests
请求,Transaction Coordinator
将对应的所有<Topic, Partition>
存于Transaction Log
中,并将其状态记为BEGIN
,如上图中步骤4.3所示。该方法会阻塞直到收到响应。
TxnOffsetCommitRequest
作为sendOffsetsToTransaction
方法的一部分,在处理完AddOffsetsToTxnRequest
后,Producer也会发送TxnOffsetCommit
请求给Consumer Coordinator
从而将本事务包含的与读操作相关的各<Topic, Partition>
的Offset持久化到内部的__consumer_offsets
中,如上图步骤4.4所示。
在此过程中,Consumer Coordinator
会通过PID和对应的epoch来验证是否应该允许该Producer的该请求。
这里需要注意:
__consumer_offsets
的Offset信息在当前事务Commit前对外是不可见的。也即在当前事务被Commit前,可认为该Offset尚未Commit,也即对应的消息尚未被完成处理。Consumer Coordinator
并不会立即更新缓存中相应<Topic, Partition>
的Offset,因为此时这些更新操作尚未被COMMIT或ABORT。一旦上述数据写入操作完成,应用程序必须调用KafkaProducer
的commitTransaction
方法或者abortTransaction
方法以结束当前事务。
EndTxnRequestcommitTransaction
方法使得Producer写入的数据对下游Consumer可见。abortTransaction
方法通过Transaction Marker
将Producer写入的数据标记为Aborted
状态。下游的Consumer如果将isolation.level
设置为READ_COMMITTED
,则它读到被Abort的消息后直接将其丢弃而不会返回给客户程序,也即被Abort的消息对应用程序不可见。
无论是Commit还是Abort,Producer都会发送EndTxnRequest
请求给Transaction Coordinator
,并通过标志位标识是应该Commit还是Abort。
收到该请求后,Transaction Coordinator
会进行如下操作
PREPARE_COMMIT
或PREPARE_ABORT
消息写入Transaction Log
,如上图中步骤5.1所示WriteTxnMarker
请求以Transaction Marker
的形式将COMMIT
或ABORT
信息写入用户数据日志以及Offset Log
中,如上图中步骤5.2所示COMPLETE_COMMIT
或COMPLETE_ABORT
信息写入Transaction Log
中,如上图中步骤5.3所示补充说明:对于commitTransaction
方法,它会在发送EndTxnRequest
之前先调用flush方法以确保所有发送出去的数据都得到相应的ACK。对于abortTransaction
方法,在发送EndTxnRequest
之前直接将当前Buffer中的事务性消息(如果有)全部丢弃,但必须等待所有被发送但尚未收到ACK的消息发送完成。
上述第二步是实现将一组读操作与写操作作为一个事务处理的关键。因为Producer写入的数据Topic以及记录Comsumer Offset的Topic会被写入相同的Transactin Marker
,所以这一组读操作与写操作要么全部COMMIT要么全部ABORT。
WriteTxnMarkerRequest
上面提到的WriteTxnMarkerRequest
由Transaction Coordinator
发送给当前事务涉及到的每个<Topic, Partition>
的Leader。收到该请求后,对应的Leader会将对应的COMMIT(PID)
或者ABORT(PID)
控制信息写入日志,如上图中步骤5.2所示。
该控制消息向Broker以及Consumer表明对应PID的消息被Commit了还是被Abort了。
这里要注意,如果事务也涉及到__consumer_offsets
,即该事务中有消费数据的操作且将该消费的Offset存于__consumer_offsets
中,Transaction Coordinator
也需要向该内部Topic的各Partition的Leader发送WriteTxnMarkerRequest
从而写入COMMIT(PID)
或COMMIT(PID)
控制信息。
写入最终的COMPLETE_COMMIT
或COMPLETE_ABORT
消息
写完所有的Transaction Marker
后,Transaction Coordinator
会将最终的COMPLETE_COMMIT
或COMPLETE_ABORT
消息写入Transaction Log
中以标明该事务结束,如上图中步骤5.3所示。
此时,Transaction Log
中所有关于该事务的消息全部可以移除。当然,由于Kafka内数据是Append Only的,不可直接更新和删除,这里说的移除只是将其标记为null从而在Log Compact时不再保留。
另外,COMPLETE_COMMIT
或COMPLETE_ABORT
的写入并不需要得到所有Rreplica的ACK,因为如果该消息丢失,可以根据事务协议重发。
补充说明,如果参与该事务的某些<Topic, Partition>
在被写入Transaction Marker
前不可用,它对READ_COMMITTED
的Consumer不可见,但不影响其它可用<Topic, Partition>
的COMMIT或ABORT。在该<Topic, Partition>
恢复可用后,Transaction Coordinator
会重新根据PREPARE_COMMIT
或PREPARE_ABORT
向该<Topic, Partition>
发送Transaction Marker
。
PID
与Sequence Number
的引入实现了写操作的幂等性At Least Once
语义实现了单一Session内的Exactly Once
语义Transaction Marker
与PID
提供了识别消息是否应该被读取的能力,从而实现了事务的隔离性Transaction Marker
)来实现事务中涉及的所有读写操作同时对外可见或同时对外不可见InvalidProducerEpoch
这是一种Fatal Error,它说明当前Producer是一个过期的实例,有Transaction ID
相同但epoch更新的Producer实例被创建并使用。此时Producer会停止并抛出Exception。
InvalidPidMappingTransaction Coordinator
没有与该Transaction ID
对应的PID。此时Producer会通过包含有Transaction ID
的InitPidRequest
请求创建一个新的PID。
NotCorrdinatorForGTransactionalId
该Transaction Coordinator
不负责该当前事务。Producer会通过FindCoordinatorRequest
请求重新寻找对应的Transaction Coordinator
。
InvalidTxnRequest
违反了事务协议。正确的Client实现不应该出现这种Exception。如果该异常发生了,用户需要检查自己的客户端实现是否有问题。
CoordinatorNotAvailableTransaction Coordinator
仍在初始化中。Producer只需要重试即可。
DuplicateSequenceNumber
发送的消息的序号低于Broker预期。该异常说明该消息已经被成功处理过,Producer可以直接忽略该异常并处理下一条消息
InvalidSequenceNumber
这是一个Fatal Error,它说明发送的消息中的序号大于Broker预期。此时有两种可能
max.inflight.requests.per.connection
被强制设置为1,而acks
被强制设置为all。故前面消息重试期间,后续消息不会被发送,也即不会发生乱序。并且只有ISR中所有Replica都ACK,Producer才会认为消息已经被发送,也即不存在Broker端数据丢失问题。InvalidTransactionTimeoutInitPidRequest
调用出现的Fatal Error。它表明Producer传入的timeout时间不在可接受范围内,应该停止Producer并报告给用户。
Transaction Coordinator
失败PREPARE_COMMIT/PREPARE_ABORT
前失败Producer通过FindCoordinatorRequest
找到新的Transaction Coordinator
,并通过EndTxnRequest
请求发起COMMIT
或ABORT
流程,新的Transaction Coordinator
继续处理EndTxnRequest
请求——写PREPARE_COMMIT
或PREPARE_ABORT
,写Transaction Marker
,写COMPLETE_COMMIT
或COMPLETE_ABORT
。
PREPARE_COMMIT/PREPARE_ABORT
后失败此时旧的Transaction Coordinator
可能已经成功写入部分Transaction Marker
。新的Transaction Coordinator
会重复这些操作,所以部分Partition中可能会存在重复的COMMIT
或ABORT
,但只要该Producer在此期间没有发起新的事务,这些重复的Transaction Marker
就不是问题。
COMPLETE_COMMIT/ABORT
后失败旧的Transaction Coordinator
可能已经写完了COMPLETE_COMMIT
或COMPLETE_ABORT
但在返回EndTxnRequest
之前失败。该场景下,新的Transaction Coordinator
会直接给Producer返回成功。
transaction.timeout.ms
当Producer失败时,Transaction Coordinator
必须能够主动的让某些进行中的事务过期。否则没有Producer的参与,Transaction Coordinator
无法判断这些事务应该如何处理,这会造成:
Transaction Coordinator
需要维护大量的事务状态,大量占用内存Transaction Log
内也会存在大量数据,造成新的Transaction Coordinator
启动缓慢READ_COMMITTED
的Consumer需要缓存大量的消息,造成不必要的内存浪费甚至是OOMTransaction ID
不同的Producer交叉写同一个Partition,当一个Producer的事务状态不更新时,READ_COMMITTED
的Consumer为了保证顺序消费而被阻塞为了避免上述问题,Transaction Coordinator
会周期性遍历内存中的事务状态Map,并执行如下操作
BEGIN
并且其最后更新时间与当前时间差大于transaction.remove.expired.transaction.cleanup.interval.ms
(默认值为1小时),则主动将其终止:1)未避免原Producer临时恢复与当前终止流程冲突,增加该Producer对应的PID的epoch,并确保将该更新的信息写入Transaction Log
;2)以更新后的epoch回滚事务,从而使得该事务相关的所有Broker都更新其缓存的该PID的epoch从而拒绝旧Producer的写操作PREPARE_COMMIT
,完成后续的COMMIT流程————向各<Topic, Partition>
写入Transaction Marker
,在Transaction Log
内写入COMPLETE_COMMIT
PREPARE_ABORT
,完成后续ABORT流程Transaction ID
某Transaction ID
的Producer可能很长时间不再发送数据,Transaction Coordinator
没必要再保存该Transaction ID
与PID
等的映射,否则可能会造成大量的资源浪费。因此需要有一个机制探测不再活跃的Transaction ID
并将其信息删除。
Transaction Coordinator
会周期性遍历内存中的Transaction ID
与PID
映射,如果某Transaction ID
没有对应的正在进行中的事务并且它对应的最后一个事务的结束时间与当前时间差大于transactional.id.expiration.ms
(默认值是7天),则将其从内存中删除并在Transaction Log
中将其对应的日志的值设置为null从而使得Log Compact可将其记录删除。
Kafka的事务机制与《MVCC PostgreSQL实现事务和多版本并发控制的精华》一文中介绍的PostgreSQL通过MVCC实现事务的机制非常类似,对于事务的回滚,并不需要删除已写入的数据,都是将写入数据的事务标记为Rollback/Abort从而在读数据时过滤该数据。
Kafka的事务机制与《分布式事务(一)两阶段提交及JTA》一文中所介绍的两阶段提交机制看似相似,都分PREPARE阶段和最终COMMIT阶段,但又有很大不同。
PREPARE_COMMIT
还是PREPARE_ABORT
,并且只须在Transaction Log
中标记即可,无须其它组件参与。而两阶段提交的PREPARE需要发送给所有的分布式事务参与方,并且事务参与方需要尽可能准备好,并根据准备情况返回Prepared
或Non-Prepared
状态给事务管理器。PREPARE_COMMIT
或PREPARE_ABORT
,则确定该事务最终的结果应该是被COMMIT
或ABORT
。而分布式事务中,PREPARE后由各事务参与方返回状态,只有所有参与方均返回Prepared
状态才会真正执行COMMIT,否则执行ROLLBACKTransaction Coordinator
实例,而分布式事务中只有一个事务管理器Zookeeper的原子广播协议与两阶段提交以及Kafka事务机制有相似之处,但又有各自的特点
Transaction Coordinator
实例,扩展性较好。而Zookeeper写操作只能在Leader节点进行,所以其写性能远低于读性能。原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自技术世界,原文链接 http://www.jasongj.com/ml/associationrules/
上个世纪,美国连锁超市活尔玛通过大量的数据分析发现了一个非常有趣的现象:尿布与啤酒这两种看起来风马牛不相及的商品销售数据曲线非常相似,并且尿布与啤酒经常被同时购买,也即购买尿布的顾客一般也同时购买了啤酒。于是超市将尿布与啤酒摆在一起,这一举措使得尿布和啤酒的销量大幅增加。
原来,美国的妇女通常全职在家照顾孩子,并且她们经常会嘱咐丈夫在下班回家的路上为孩子买尿布,而丈夫在买尿布的同时又会顺手购买自己爱喝的啤酒。
注: 此案例很精典,切勿盲目模仿案例本身,而应了解其背后原理。它发生在美国,而且是上个世纪,有些东西并不一定适用于现在,更不一定适用于中国。目前国内网购非常普遍,并不一定需要去超市线下购买,而网购主力军是女性,因此不一定会出现尿布与啤酒同时购买的问题。另外,对于线下销售,很多超市流行的做法不是把经常同时购买的商品放在一起,而是尽量分开放到不同的地方,这样顾客为了同时购买不得不穿过其它商品展示区,从而可能购买原来未打算购买的商品。
但是本案例中背后的机器学习算法——关联规则,仍然适用于非常多的场景。目前很多电商网站也会根据类似的关联规则给用户进行推荐,如比较常见的“购买该商品的客户还购买过**”。其背后的逻辑在于,某两种或几种商品经常被一起购买,它们中间可能存在某种联系,当某位顾客购买了其中一种商品时,他/她可能也需要另外一种或几种商品,因此电商网站会将这几种商吕推荐给客户。
如同上述啤酒与尿布的故事所示,关联规则是指从一组数据中发现数据项之间的隐藏关系,它是一种典型的无监督学习。
本节以上述超市购物的场景为例,介绍关联规则的几个核心概念
项目
一系列事件中的一个事件。对于超市购物而言,即一次购物中的一件商品,如啤酒
事务
一起发生的一系列事件。在超市购物场景中,即一次购买行为中包含的所有商品的集合。如 $\{尿布,啤酒,牛奶,面包\}$
项集
一个事务中包含的若干个项目的集合,如 $\{尿布,啤酒\}$
支持度
项集 $\{A,B\}$ 在全部项集中出现的概率。支持度越高,说明规则 $A \rightarrow B$ 越具代表性。
频繁项集
某个项集的支持度大于设定的阈值(人为根据数据分布和经验设定),该项集即为频繁项集。
假设超市某段时间总共有 5 笔交易。下面数据中,数字代表交易编号,字母代表项目,每行代表一个交易对应的项目集1
2
3
4
51: A B C D
2: A B
3: C B
4: A D
5: A B D
对于项集 $\{A,B\}$,其支持度为 $3/5=60\%$ (总共 5 个项集,而包含 $\{A,B\}$ 的有 3 个)。如果阈值为 $50\%$,此时 $\{A,B\}$ 即为频繁项集。
置信度
在先决条件 $A$ 发生的条件下,由关联规则 $A \rightarrow B$ 推出 $B$ 的概率,即在 $A$ 发生时,$B$ 也发生的概率,即 $P(A|B)$ 。置信度越高,说明该规则越可靠。
在上例中,频繁项集 $\{A,B\}$ 的置信度为 $3/4=75\%$ (包含 $\{A,B\}$ 的项集数为 3,包含 $A$ 的项集数为 5)
满足最小支持度和最小置信度的规则,即为强关联规则。
提升度
$A$ 发生时 $B$ 也发生的概率,除以 $B$ 发生的概率,即 $P(A|B) / P(B)$ ,它衡量了规则的有效性。
在上例中,置信度为 $75\%$ ,但是否能说明 $A$ 与 $B$ 之间具有强关联性呢?提升度为 $75\% / 80\% = 93.75\%$
从中可以看出,$B$ 的购买率为 $80\%$,而购买 $A$ 的用户同时购买 $B$ 的概率只有 $75\%$,所以并不能说明 $A \rightarrow B$ 是有效规则,或者说这条规则是没有价值的,因此不应该如本文开头处啤酒与尿布的案例中那样将 $A$ 与 $B$ 一起销售。
提升度是一种比较简单的判断规则是否有价值的指标。如果提升度为 1,说明二者没有任何关联;如果小于 1,说明 $A$ 与 $B$ 在一定程度上是相斥的;如果大于 1,说明 $A$ 与 $B$ 有一定关联。一般在工程实践中,当提升度大于 3 时,该规则才被认为是有价值的。
关联规则中,关键
点是:1)找出频繁项集;2)合理地设置三种阈值;3)找出强关联规则
直接遍历所有的项目集,并计算其支持度、置信度和提升度,计算量太大,无法应用于工程实践。
Apriori算法可用于快速找出频繁项集。
原理一:如果一个项集是频繁项目集,那它的非空子集也一定是频繁项目集。
原理二:如果一个项目集的非空子集不是频繁项目集,那它也不是频繁项目集。
例:如果 $\{A,B,C\}$ 的支持度为 $70\%$,大于阈值 $60\%$,则 $\{A,B,C\}$ 为频繁项目集。此时 $\{A,B\}$ 的支持度肯定大于等于 $\{A,B,C\}$ 的支持度,也大于阈值 $60\%$,即也是频繁项目集。反之,若 $\{A,B\}$ 的支持度为 $40\%$,小于阈值 $60\%$,不是频繁项目集,则 $\{A,B,C\}$ 的支持度小于等于 $40\%$,必定小于阈值 $60\%$,不是频繁项目集。
原理三:对于频繁项目集X,如果 $(X-Y) \rightarrow Y$ 是强关联规则,则 $(X-Y) \rightarrow Y_{sub}$ 也是强关联规则。
原理四:如果 $(X-Y) \rightarrow Y_{sub}$ 不是强关联规则,则 $(X-Y) \rightarrow Y$ 也不是强关联规则。
其中 $Y_{sub}$ 是 $Y$ 的子集。
这里把包含 $N$ 个项目的频繁项目集称为 $N-$ 频繁项目集。Apriori 的工作过程即是根据 $K-$ 频繁项目集生成 $(K+1)-$ 频繁项目集。
根据数据归纳法,首先要做的是找出1-频繁项目集。只需遍历所有事务集合并统计出项目集合中每个元素的支持度,然后根据阈值筛选出 $1-$ 频繁项目集即可。
有项目集合 $I=\{A,B,C,D,E\}$ ,事务集 $T$ :1
2
3
4
5
6
7A,B,C
A,B,D
A,C,D
A,B,C,E
A,C,E
B,D,E
A,B,C,D
设定最小支持度 $support=3/7$,$confidence=5/7$
直接穷举所有可能的频繁项目集,可能的频繁项目集如下
1-频繁项目集
1-频繁项目集,即1
{A},{B},{C},{D},{E}
其支持度分别为 $6/7$,$5/7$,$5/7$,$4/7$和$3/7$,均符合支持度要求。
2-频繁项目集
任意取两个只有最后一个元素不同的 $1-$ 频繁项目集,求其并集。由于每个 $1-$ 频繁项目集只有一个元素,故生成的项目集如下:1
2
3
4{A,B},{A,C},{A,D},{A,E}
{B,C},{B,D},{B,E}
{C,D},{C,E}
{D,E}
过滤出满足最小支持度 $3/7$的项目集如下1
{A,B},{A,C},{A,D},{B,C},{B,D}
此时可以将其它所有 $2-$ 频繁项目集以及其衍生出的 $3-$ 频繁项目集, $4-$ 频繁项目集以及 $5-$ 频繁项目集全部排除(如下图中红色十字所示),该过程即剪枝。剪枝减少了后续的候选项,极大降低了计算量从而大幅度提升了算法性能。
3-频繁项目集
因为 $\{A,B\}$,$\{A,C\}$,$\{A,D\}$ 除最后一个元素外都相同,故求 $\{A,B\}$ 与 $\{A,C\}$ 的并集得到 $\{A,B,C\}$,求 $\{A,C\}$ 与 $\{A,D\}$ 的并集得到 $\{A,C,D\}$,求 $\{A,B\}$ 与 $\{A,D\}$ 的并集得到 $\{A,B,D\}$。但是由于 $\{A,C,D\}$ 的子集 $\{C,D\}$ 不在 $2-$ 频繁项目集中,所以需要把 $\{A,C,D\}$ 剔除掉。$\{A,B,C\}$ 与 $\{A,B,D\}$ 的支持度分别为 $3/7$ 与 $2/7$,故根据支持度要求将 $\{A,B,D\}$ 剔除,保留 $\{A,B,C\}$。
同理,对 $\{B,C\}$ 与 $\{B,D\}$ 求并集得到 $\{B,C,D\}$,其支持度 $1/7$ 不满足要求。
因此最终得到 $3-$ 频繁项目集 $\{A,B,C\}$。
排除 $\{A,B,D\}$ , $\{A,C,D\}$, $\{B,C,D\}$ 后,相关联的 $4-$ 频繁项目集 $\{A,B,C,D\}$ 也被排除,如下图所示。
穷举法
得到频繁项目集后,可以穷举所有可能的规则,如下图所示。然后通过置信度阈值筛选出强关联规则。
Apriori剪枝
Apriori算法可根据原理三与原理四进行剪枝,从而提升算法性能。
上述步骤得到了3-频繁项集 $\{A,B,C\}$。先生成 $1-$ 后件(即箭头后只有一个项目)的关联规则
此时可将 $\{A,C\} \rightarrow B $ 排除,并将相应的 $2-$ 后件关联规则 $\{A\} \rightarrow \{B,C\} $ 与 $\{C\} \rightarrow \{A,B\} $ 排除,如下图所示。
根据原理四,由 $1-$ 后件强关联规则,生成 $2-$ 后件关联规则 $\{B\} \rightarrow \{A,C\} $,置信度 $3/5 < 5/7$,不是强关联规则。
至此,本例中通过Apriori算法得到强关联规则 $\{A,B\} \rightarrow C $ 与 $\{B,C\} \rightarrow A $。
注:这里只演示了从 $3-$ 频繁项目集中挖掘强关联规则。实际上同样还可以从上文中得到的 $2-$ 频繁项目集中挖掘出更多强关联规则,这里不过多演示。
Aprior原理和实现简单,相对穷举法有其优势,但也有其局限
生成交易数据集
设有交易数据集如下1
2
3
4
5
61:A,F,H,J,P
2:F,E,D,W,V,U,C,B
3:F
4:A,D,N,O,B
5:E,A,D,F,Q,C,P
6:E,F,D,E,Q,B,C,M
项目过滤及重排序
与Apriori算法一样,获取频繁项集的第一步是根据支持度阈值获取 $1-$ 频繁项目集。不一样的是,FP-growth 算法在获取 $1-$ 频繁项目集的同时,对每条交易只保留 $1-$ 频繁项目集内的项目,并按其支持度倒排序。
这里将最小支持度设置为 $3/6$。第一遍扫描数据集后得到过滤与排序后的数据集如下:1
2
3
4
5
61:F,A
2:F,D,E,B,C
3:F
4:D,B,A
5:F,D,E,A,C
6:F,D,E,B,C
构建FP树
TP-growth算法将数据存储于一种称为 FP 树的紧凑数据结构中。FP 代表频繁模式(Frequent Pattern)。FP 树与其它树结构类似,但它通过链接(link)来连接相似元素,被连接起来的项目可看成是一个链表。
FP树的构建过程是以空集作为树的根节点,将过滤和重排序后的数据集逐条添加到树中:如果树中已存在当前元素,则增加待添加元素的值;如果待添加元素不存在,则给树增加一个分支。
添加第一条记录时,直接在根节点下依次添加节点 $ \lt F:1 \gt $, $ \lt A:1 \gt $ 即可。添加第二条记录时,因为 $\{F,D,E,B,C\}$ 与路径 $\{F:1,A:1\}$ 有相同前缀 $F$ ,因此将 $F$ 次数加一,然后在节点 $ \lt F:2 \gt $ 下依次添加节点 $ \lt D:1 \gt $, $ \lt E:1 \gt $, $ \lt B:1 \gt $, $ \lt C:1 \gt $。
第一条记录与第二条记录的添加过程如下图所示。
添加第三条记录 $\{F\}$ 时,由于 $FP$ 树中已经存在该节点且该节点为根节点的直接子节点,直接将 $F$ 的次数加一即可。
添加第四条记录 $\{D,B,A\}$ 时,由于与 $FP$ 树无共同前缀,因此直接在根节点下依次添加节点 $ \lt D:1 \gt $, $ \lt B:1 \gt $, $ \lt A:1 \gt $。
第三条记录与第四条记录添加过程如下所示
添加第五条记录 $\{F,D,E,A,C\}$ 时,由于与 $FP$ 树存在共同前缀 $\{F,D,E\}$ 。因此先将其次数分别加一,然后在节点 $ \lt E:1 \gt $ 下依次添加节点 $ \lt A:1 \gt $, $ \lt C:1 \gt $。
添加第六条记录 $\{F,D,E,B,C\}$ 时,由于 $FP$ 树已存在 $\{F,D,E,B,C\}$ ,因此直接将其次数分别加一即可。
第五条记录与第六条记录添加过程如下图所示。
建立头表
为了便于对整棵FP树进行遍历,可建立一张项目头表。这张表记录各 $1-$ 频繁项的出现次数,并指向该频繁项在 $FP$ 树中的节点,如下图所示。
构建好 $FP$ 树后,即可抽取频繁项目集,其思路与 Apriori 算法类似——先从 $1-$ 频繁项目集开始,然后逐步构建更大的频繁项目集。
从 $FP$ 树中抽取频繁项目集的三个基本步骤如下:
抽取条件模式基
条件模式基(conditaional pattern base)是以所查元素为结尾的路径集合。这里的每一个路径称为一条前缀路径(prefix path)。前缀路径是介于所查元素与 $FP$ 树根节点间的所有元素。
每个 $1-$ 频繁项目的条件模式基如下所示
$1-$ 频繁项目 | 条件模式基 |
---|---|
F | Ø:5 |
A | $\{D,B\}:1$,$\{F,D,E\}:1$,$\{F\}:1$ |
D | $\{F\}:3$,Ø:1 |
E | $\{F,D\}:3$ |
B | $\{F,D,E\}:2$,$\{D\}:1$ |
C | $\{F,D,E,B\}:2$,$\{F,D,E,A\}:1$ |
构建条件$FP$树
对于每个频繁项目,可以条件模式基作为输入数据构建一棵 $条件FP树$ 。
对于 $C$ ,其条件模式基为 $\{F,D,E,B\}:2$,$\{F,D,E,A\}:1$ 。根据支持度阈值 $3/6$ 可排除掉 $A$ 与 $B$ 。以满足最小支持度的 $\{F,D,E\}:2$ 与 $\{F,D,E\}:1$ 构建 $条件FP树$ 如下图所示。
递归查找频繁项集
基于上述步骤中生成的 $FP树$ 和 $条件FP树$ ,可通过递归查找频繁项目集。具体过程如下:
1 初始化一个空列表 $prefix$ 以表示前缀
2 初始化一个空列表 $freqList$ 存储查询到的所有频繁项目集
3 对 $Head Table$ 中的每个元素 $ele$,递归:
3.1 记 $ele + prefix$ 为当前频繁项目集 $newFreqSet$
3.2 将 $newFreq$ 添加到 $freqList$中
3.3 生成 $ele$ 的 $条件FP树$ 及对应的 $Head Table$
3.4 当 $条件FP树$ 为空时,退出
3.5 以 $条件FP树$ 树与对应的 $Head Table$ 为输入递归该过程
对于 $C$ ,递归生成频繁项目集的过程如下图所示。
上图中红色部分即为频繁项目集。同理可得其它频繁项目集。
得到频繁项目集后,即可以上述同样方式得到强关联规则。
$FP-growth$ 算法相对 $Apriori$ 有优化之处,但也有其不足
$R$ 语言中,$arules$ 包提供了 $Apriori$ 算法的实现。1
library(arules)
将上文Apriori生成频繁项目集中的数据集存于 $transaction.csv$ 文件。然后使用 $arules$ 包的 $read.transactions$ 方法加载数据集并存于名为 $transactions$ 的稀疏矩阵中,如下1
transactions <- read.transactions("transaction.csv", format="basket", sep=",")
这里之所以不使用 $data.frame$ 是因为当项目种类太多时 $data.frame$ 中会有大量单元格为 $0$,大量浪费内存。稀疏矩阵只存 $1$,节省内存。
该方法的使用说明如下1
2
3
4
5Usage:
read.transactions(file, format = c("basket", "single"), sep = "",
cols = NULL, rm.duplicates = FALSE,
quote = "\"'", skip = 0,
encoding = "unknown")
其中,$format=”basket”$ 适用于每一行代表一条交易记录( $basket$ 可理解为一个购物篮)的数据集,本例所用数据集即为该类型。$format=”single”$ 适用于每一行只包含一个事务 $ID$ 和一件商品(即一个项目)的数据集,如下所示。1
2
3
4
5
61,A
1,B
1,C
2,B
2,D
3,A
$rm.duplicates=TRUE$ 代表删除同一交易(记录)内的重复商品(项目)。本例所用数据集中每条记录无重复项目,故无须设置该参数。
查看数据集信息1
2
3
4
5
6
7
8
9> inspect(transactions)
items
[1] {A,B,C}
[2] {A,B,D}
[3] {A,C,D}
[4] {A,B,C,E}
[5] {A,C,E}
[6] {B,D,E}
[7] {A,B,C,D}
查看数据集统计信息1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23> summary(transactions)
transactions as itemMatrix in sparse format with
7 rows (elements/itemsets/transactions) and
5 columns (items) and a density of 0.6571429
most frequent items:
A B C D E (Other)
6 5 5 4 3 0
element (itemset/transaction) length distribution:
sizes
3 4
5 2
Min. 1st Qu. Median Mean 3rd Qu. Max.
3.000 3.000 3.000 3.286 3.500 4.000
includes extended item information - examples:
labels
1 A
2 B
3 C
从中可看出,总共包含 $7$ 条交易,$5$ 种不同商品。同时可得到出现频次最高的 $5$ 个项目及其频次。另外,所有事务中包含 $3$ 件商品的有 $5$ 个,包含 $4$ 件商品的有 $2$ 个。
接下来,可通过 $itemFrequency$ 方法查看各项目的支持度,如下所示。1
2
3> sort(itemFrequency(transactions), decreasing = T)
A B C D E
0.8571429 0.7142857 0.7142857 0.5714286 0.4285714
上面对数据的观察,其目的是找出合适的支持度阈值与置信度阈值。这里继续使用 $3/7$ 作为最小支持度, $5/7$ 作为最小置信度。1
rules <- apriori(transactions, parameter = list(support = 3/7, confidence = 5/7, minlen = 2))
其中 $minlen = 2$ 是指规则至少包含两个项目,即最少一个前件一个后件,如 $\{A\} \rightarrow B $。结果如下1
2
3
4
5
6
7
8
9> inspect(rules)
lhs rhs support confidence lift
[1] {D} => {B} 0.4285714 0.7500000 1.0500000
[2] {D} => {A} 0.4285714 0.7500000 0.8750000
[3] {B} => {A} 0.5714286 0.8000000 0.9333333
[4] {C} => {A} 0.7142857 1.0000000 1.1666667
[5] {A} => {C} 0.7142857 0.8333333 1.1666667
[6] {B,C} => {A} 0.4285714 1.0000000 1.1666667
[7] {A,B} => {C} 0.4285714 0.7500000 1.0500000
从中可以看出,$Apriori$ 算法以 $3/7$ 为最小支持度,以 $5/7$ 为最小置信度,从本例中总共挖掘出了 $7$ 条强关联规则。其中 $2$ 条包含 $3$ 个项目,分别为 $\{B,C\} \rightarrow A $ 与 $\{A,B\} \rightarrow C $。该结果与上文中使用 $Apriori$ 算法推算出的结果一致。
挖掘出的强关联规则,不一定都有效。因此需要一些方法来评估这些规则的有效性。
提升度
第一种方法是使用上文中所述的提升度来度量。本例中通过 $Apriori$ 算法找出的强关联规则的提升度都小于 $3$,可认为都不是有效规则。1
2
3
4
5
6
7
8
9> inspect(sort(rules, by = "lift"))
lhs rhs support confidence lift
[1] {C} => {A} 0.7142857 1.0000000 1.1666667
[2] {A} => {C} 0.7142857 0.8333333 1.1666667
[3] {B,C} => {A} 0.4285714 1.0000000 1.1666667
[4] {D} => {B} 0.4285714 0.7500000 1.0500000
[5] {A,B} => {C} 0.4285714 0.7500000 1.0500000
[6] {B} => {A} 0.5714286 0.8000000 0.9333333
[7] {D} => {A} 0.4285714 0.7500000 0.8750000
其它度量
$interestMeasure$ 方法提供了几十个维度的对规则的度量1
2
3
4
5
6
7
8
9> interestMeasure(rules, c("coverage","fishersExactTest","conviction", "chiSquared"), transactions=transactions)
coverage fishersExactTest conviction chiSquared
1 0.5714286 0.7142857 1.1428571 0.05833333
2 0.5714286 1.0000000 0.5714286 0.87500000
3 0.7142857 1.0000000 0.7142857 0.46666667
4 0.7142857 0.2857143 NA 2.91666667
5 0.8571429 0.2857143 1.7142857 2.91666667
6 0.4285714 0.5714286 NA 0.87500000
7 0.5714286 0.7142857 1.1428571 0.05833333
1 | > rule_measures <- interestMeasure(rules, c("coverage","fishersExactTest","conviction", "chiSquared"), transactions=transactions) |
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自技术世界,原文链接 http://www.jasongj.com/zookeeper/fastleaderelection/
Zookeeper是一个分布式协调服务,可用于服务发现,分布式锁,分布式领导选举,配置管理等。
这一切的基础,都是Zookeeper提供了一个类似于Linux文件系统的树形结构(可认为是轻量级的内存文件系统,但只适合存少量信息,完全不适合存储大量文件或者大文件),同时提供了对于每个节点的监控与通知机制。
既然是一个文件系统,就不得不提Zookeeper是如何保证数据的一致性的。本文将介绍Zookeeper如何保证数据一致性,如何进行领导选举,以及数据监控/通知机制的语义保证。
Zookeeper集群是一个基于主从复制的高可用集群,每个服务器承担如下三种角色中的一种
为了保证写操作的一致性与可用性,Zookeeper专门设计了一种名为原子广播(ZAB)的支持崩溃恢复的一致性协议。基于该协议,Zookeeper实现了一种主从模式的系统架构来保持集群中各个副本之间的数据一致性。
根据ZAB协议,所有的写操作都必须通过Leader完成,Leader写入本地日志后再复制到所有的Follower节点。
一旦Leader节点无法工作,ZAB协议能够自动从Follower节点中重新选出一个合适的替代者,即新的Leader,该过程即为领导选举。该领导选举过程,是ZAB协议中最为重要和复杂的过程。
通过Leader进行写操作流程如下图所示
由上图可见,通过Leader进行写操作,主要分为五步:
这里要注意
通过Follower/Observer进行写操作流程如下图所示:
从上图可见
Leader/Follower/Observer都可直接处理读请求,从本地内存中读取数据并返回给客户端即可。
由于处理读请求不需要服务器之间的交互,Follower/Observer越多,整体可处理的读请求量越大,也即读性能越好。
myid
每个Zookeeper服务器,都需要在数据文件夹下创建一个名为myid的文件,该文件包含整个Zookeeper集群唯一的ID(整数)。例如某Zookeeper集群包含三台服务器,hostname分别为zoo1、zoo2和zoo3,其myid分别为1、2和3,则在配置文件中其ID与hostname必须一一对应,如下所示。在该配置文件中,server.
后面的数据即为myid
1 | server.1=zoo1:2888:3888 |
zxid
类似于RDBMS中的事务ID,用于标识一次更新操作的Proposal ID。为了保证顺序性,该zkid必须单调递增。因此Zookeeper使用一个64位的数来表示,高32位是Leader的epoch,从1开始,每次选出新的Leader,epoch加一。低32位为该epoch内的序号,每次epoch变化,都将低32位的序号重置。这样保证了zkid的全局递增性。
可通过electionAlg
配置项设置Zookeeper用于领导选举的算法。
到3.4.10版本为止,可选项有
0
基于UDP的LeaderElection1
基于UDP的FastLeaderElection2
基于UDP和认证的FastLeaderElection3
基于TCP的FastLeaderElection在3.4.10版本中,默认值为3,也即基于TCP的FastLeaderElection。另外三种算法已经被弃用,并且有计划在之后的版本中将它们彻底删除而不再支持。
FastLeaderElection选举算法是标准的Fast Paxos算法实现,可解决LeaderElection选举算法收敛速度慢的问题。
每个服务器在进行领导选举时,会发送如下关键信息
自增选举轮次
Zookeeper规定所有有效的投票都必须在同一轮次中。每个服务器在开始新一轮投票时,会先对自己维护的logicClock进行自增操作。
初始化选票
每个服务器在广播自己的选票前,会将自己的投票箱清空。该投票箱记录了所收到的选票。例:服务器2投票给服务器3,服务器3投票给服务器1,则服务器1的投票箱为(2, 3), (3, 1), (1, 1)。票箱中只会记录每一投票者的最后一票,如投票者更新自己的选票,则其它服务器收到该新选票后会在自己票箱中更新该服务器的选票。
发送初始化选票
每个服务器最开始都是通过广播把票投给自己。
接收外部投票
服务器会尝试从其它服务器获取投票,并记入自己的投票箱内。如果无法获取任何外部投票,则会确认自己是否与集群中其它服务器保持着有效连接。如果是,则再次发送自己的投票;如果否,则马上与之建立连接。
判断选举轮次
收到外部投票后,首先会根据投票信息中所包含的logicClock来进行不同处理
选票PK
选票PK是基于(self_id, self_zxid)与(vote_id, vote_zxid)的对比
统计选票
如果已经确定有过半服务器认可了自己的投票(可能是更新后的投票),则终止投票。否则继续接收其它服务器的投票。
更新服务器状态
投票终止后,服务器开始更新自身状态。若过半的票投给了自己,则将自己的服务器状态更新为LEADING,否则将自己的状态更新为FOLLOWING
初始投票给自己
集群刚启动时,所有服务器的logicClock都为1,zxid都为0。
各服务器初始化后,都投票给自己,并将自己的一票存入自己的票箱,如下图所示。
Follower重启投票给自己
Follower重启,或者发生网络分区后找不到Leader,会进入LOOKING状态并发起新的一轮投票。
发现已有Leader后成为Follower
服务器3收到服务器1的投票后,将自己的状态LEADING以及选票返回给服务器1。服务器2收到服务器1的投票后,将自己的状态FOLLOWING及选票返回给服务器1。此时服务器1知道服务器3是Leader,并且通过服务器2与服务器3的选票可以确定服务器3确实得到了超过半数的选票。因此服务器1进入FOLLOWING状态。
Follower发起新投票
Leader(服务器3)宕机后,Follower(服务器1和2)发现Leader不工作了,因此进入LOOKING状态并发起新的一轮投票,并且都将票投给自己。
广播更新选票
服务器1和2根据外部投票确定是否要更新自身的选票。这里有两种情况
在上图中,服务器1的zxid为11,而服务器2的zxid为10,因此服务器2将自身选票更新为(3, 1, 11),如下图所示。
选出新Leader
经过上一步选票更新后,服务器1与服务器2均将选票投给服务器1,因此服务器2成为Follower,而服务器1成为新的Leader并维护与服务器2的心跳。
旧Leader恢复后发起选举
旧的Leader恢复后,进入LOOKING状态并发起新一轮领导选举,并将选票投给自己。此时服务器1会将自己的LEADING状态及选票(3, 1, 11)返回给服务器3,而服务器2将自己的FOLLOWING状态及选票(3, 1, 11)返回给服务器3。如下图所示。
旧Leader成为Follower
服务器3了解到Leader为服务器1,且根据选票了解到服务器1确实得到过半服务器的选票,因此自己进入FOLLOWING状态。
ZAB协议保证了在Leader选举的过程中,已经被Commit的数据不会丢失,未被Commit的数据对客户端不可见。
Failover前状态
为更好演示Leader Failover过程,本例中共使用5个Zookeeper服务器。A作为Leader,共收到P1、P2、P3三条消息,并且Commit了1和2,且总体顺序为P1、P2、C1、P3、C2。根据顺序性原则,其它Follower收到的消息的顺序肯定与之相同。其中B与A完全同步,C收到P1、P2、C1,D收到P1、P2,E收到P1,如下图所示。
这里要注意
选出新Leader
旧Leader也即A宕机后,其它服务器根据上述FastLeaderElection算法选出B作为新的Leader。C、D和E成为Follower且以B为Leader后,会主动将自己最大的zxid发送给B,B会将Follower的zxid与自身zxid间的所有被Commit过的消息同步给Follower,如下图所示。
在上图中
通知Follower可对外服务
同步完数据后,B会向D、C和E发送NEWLEADER命令并等待大多数服务器的ACK(下图中D和E已返回ACK,加上B自身,已经占集群的大多数),然后向所有服务器广播UPTODATE命令。收到该命令后的服务器即可对外提供服务。
在上例中,P3未被A Commit过,同时因为没有过半的服务器收到P3,因此B也未Commit P3(如果有过半服务器收到P3,即使A未Commit P3,B会主动Commit P3,即C3),所以它不会将P3广播出去。
具体做法是,B在成为Leader后,先判断自身未Commit的消息(本例中即P3)是否存在于大多数服务器中从而决定是否要将其Commit。然后B可得出自身所包含的被Commit过的消息中的最小zxid(记为min_zxid)与最大zxid(记为max_zxid)。C、D和E向B发送自身Commit过的最大消息zxid(记为max_zxid)以及未被Commit过的所有消息(记为zxid_set)。B根据这些信息作出如下操作
上述操作保证了未被Commit过的消息不会被Commit从而对外不可见。
上述例子中Follower上并不存在未被Commit的消息。但可考虑这种情况,如果将上述例子中的服务器数量从五增加到七,服务器F包含P1、P2、C1、P3,服务器G包含P1、P2。此时服务器F、A和B都包含P3,但是因为票数未过半,因此B作为Leader不会Commit P3,而会通过TRUNC命令通知F删除P3。如下图所示。
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自技术世界,原文链接 http://www.jasongj.com/kafka/kafka_stream/
Kafka Stream是Apache Kafka从0.10版本引入的一个新Feature。它是提供了对存储于Kafka内的数据进行流式处理和分析的功能。
Kafka Stream的特点如下:
一般流式计算会与批量计算相比较。在流式计算模型中,输入是持续的,可以认为在时间上是无界的,也就意味着,永远拿不到全量数据去做计算。同时,计算结果是持续输出的,也即计算结果在时间上也是无界的。流式计算一般对实时性要求较高,同时一般是先定义目标计算,然后数据到来之后将计算逻辑应用于数据。同时为了提高计算效率,往往尽可能采用增量计算代替全量计算。
批量处理模型中,一般先有全量数据集,然后定义计算逻辑,并将计算应用于全量数据。特点是全量计算,并且计算结果一次性全量输出。
当前已经有非常多的流式处理系统,最知名且应用最多的开源流式处理系统有Spark Streaming和Apache Storm。Apache Storm发展多年,应用广泛,提供记录级别的处理能力,当前也支持SQL on Stream。而Spark Streaming基于Apache Spark,可以非常方便与图计算,SQL处理等集成,功能强大,对于熟悉其它Spark应用开发的用户而言使用门槛低。另外,目前主流的Hadoop发行版,如MapR,Cloudera和Hortonworks,都集成了Apache Storm和Apache Spark,使得部署更容易。
既然Apache Spark与Apache Storm拥用如此多的优势,那为何还需要Kafka Stream呢?笔者认为主要有如下原因。
第一,Spark和Storm都是流式处理框架,而Kafka Stream提供的是一个基于Kafka的流式处理类库。框架要求开发者按照特定的方式去开发逻辑部分,供框架调用。开发者很难了解框架的具体运行方式,从而使得调试成本高,并且使用受限。而Kafka Stream作为流式处理类库,直接提供具体的类给开发者调用,整个应用的运行方式主要由开发者控制,方便使用和调试。
第二,虽然Cloudera与Hortonworks方便了Storm和Spark的部署,但是这些框架的部署仍然相对复杂。而Kafka Stream作为类库,可以非常方便的嵌入应用程序中,它对应用的打包和部署基本没有任何要求。更为重要的是,Kafka Stream充分利用了Kafka的分区机制和Consumer的Rebalance机制,使得Kafka Stream可以非常方便的水平扩展,并且各个实例可以使用不同的部署方式。具体来说,每个运行Kafka Stream的应用程序实例都包含了Kafka Consumer实例,多个同一应用的实例之间并行处理数据集。而不同实例之间的部署方式并不要求一致,比如部分实例可以运行在Web容器中,部分实例可运行在Docker或Kubernetes中。
第三,就流式处理系统而言,基本都支持Kafka作为数据源。例如Storm具有专门的kafka-spout,而Spark也提供专门的spark-streaming-kafka模块。事实上,Kafka基本上是主流的流式处理系统的标准数据源。换言之,大部分流式系统中都已部署了Kafka,此时使用Kafka Stream的成本非常低。
第四,使用Storm或Spark Streaming时,需要为框架本身的进程预留资源,如Storm的supervisor和Spark on YARN的node manager。即使对于应用实例而言,框架本身也会占用部分资源,如Spark Streaming需要为shuffle和storage预留内存。
第五,由于Kafka本身提供数据持久化,因此Kafka Stream提供滚动部署和滚动升级以及重新计算的能力。
第六,由于Kafka Consumer Rebalance机制,Kafka Stream可以在线动态调整并行度。
Kafka Stream的整体架构图如下所示。
目前(Kafka 0.11.0.0)Kafka Stream的数据源只能如上图所示是Kafka。但是处理结果并不一定要如上图所示输出到Kafka。实际上KStream和Ktable的实例化都需要指定Topic。1
2
3KStream<String, String> stream = builder.stream("words-stream");
KTable<String, String> table = builder.table("words-table", "words-store");
另外,上图中的Consumer和Producer并不需要开发者在应用中显示实例化,而是由Kafka Stream根据参数隐式实例化和管理,从而降低了使用门槛。开发者只需要专注于开发核心业务逻辑,也即上图中Task内的部分。
基于Kafka Stream的流式应用的业务逻辑全部通过一个被称为Processor Topology的地方执行。它与Storm的Topology和Spark的DAG类似,都定义了数据在各个处理单元(在Kafka Stream中被称作Processor)间的流动方式,或者说定义了数据的处理逻辑。
下面是一个Processor的示例,它实现了Word Count功能,并且每秒输出一次结果。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
38public class WordCountProcessor implements Processor<String, String> {
private ProcessorContext context;
private KeyValueStore<String, Integer> kvStore;
"unchecked") (
public void init(ProcessorContext context) {
this.context = context;
this.context.schedule(1000);
this.kvStore = (KeyValueStore<String, Integer>) context.getStateStore("Counts");
}
public void process(String key, String value) {
Stream.of(value.toLowerCase().split(" ")).forEach((String word) -> {
Optional<Integer> counts = Optional.ofNullable(kvStore.get(word));
int count = counts.map(wordcount -> wordcount + 1).orElse(1);
kvStore.put(word, count);
});
}
public void punctuate(long timestamp) {
KeyValueIterator<String, Integer> iterator = this.kvStore.all();
iterator.forEachRemaining(entry -> {
context.forward(entry.key, entry.value);
this.kvStore.delete(entry.key);
});
context.commit();
}
public void close() {
this.kvStore.close();
}
}
从上述代码中可见
process
定义了对每条记录的处理逻辑,也印证了Kafka可具有记录级的数据处理能力。Kafka Stream的并行模型中,最小粒度为Task,而每个Task包含一个特定子Topology的所有Processor。因此每个Task所执行的代码完全一样,唯一的不同在于所处理的数据集互补。这一点跟Storm的Topology完全不一样。Storm的Topology的每一个Task只包含一个Spout或Bolt的实例。因此Storm的一个Topology内的不同Task之间需要通过网络通信传递数据,而Kafka Stream的Task包含了完整的子Topology,所以Task之间不需要传递数据,也就不需要网络通信。这一点降低了系统复杂度,也提高了处理效率。
如果某个Stream的输入Topic有多个(比如2个Topic,1个Partition数为4,另一个Partition数为3),则总的Task数等于Partition数最多的那个Topic的Partition数(max(4,3)=4)。这是因为Kafka Stream使用了Consumer的Rebalance机制,每个Partition对应一个Task。
下图展示了在一个进程(Instance)中以2个Topic(Partition数均为4)为数据源的Kafka Stream应用的并行模型。从图中可以看到,由于Kafka Stream应用的默认线程数为1,所以4个Task全部在一个线程中运行。
为了充分利用多线程的优势,可以设置Kafka Stream的线程数。下图展示了线程数为2时的并行模型。
前文有提到,Kafka Stream可被嵌入任意Java应用(理论上基于JVM的应用都可以)中,下图展示了在同一台机器的不同进程中同时启动同一Kafka Stream应用时的并行模型。注意,这里要保证两个进程的StreamsConfig.APPLICATION_ID_CONFIG
完全一样。因为Kafka Stream将APPLICATION_ID_CONFIG作为隐式启动的Consumer的Group ID。只有保证APPLICATION_ID_CONFIG相同,才能保证这两个进程的Consumer属于同一个Group,从而可以通过Consumer Rebalance机制拿到互补的数据集。
既然实现了多进程部署,可以以同样的方式实现多机器部署。该部署方式也要求所有进程的APPLICATION_ID_CONFIG完全一样。从图上也可以看到,每个实例中的线程数并不要求一样。但是无论如何部署,Task总数总会保证一致。
注意:Kafka Stream的并行模型,非常依赖于《Kafka设计解析(一)- Kafka背景及架构介绍》一文中介绍的Kafka分区机制和《Kafka设计解析(四)- Kafka Consumer设计解析》中介绍的Consumer的Rebalance机制。强烈建议不太熟悉这两种机制的朋友,先行阅读这两篇文章。
这里对比一下Kafka Stream的Processor Topology与Storm的Topology。
through
操作显示完成,该操作将一个大的Topology分成了2个子Topology。KTable和KStream是Kafka Stream中非常重要的两个概念,它们是Kafka实现各种语义的基础。因此这里有必要分析下二者的区别。
KStream是一个数据流,可以认为所有记录都通过Insert only的方式插入进这个数据流里。而KTable代表一个完整的数据集,可以理解为数据库中的表。由于每条记录都是Key-Value对,这里可以将Key理解为数据库中的Primary Key,而Value可以理解为一行记录。可以认为KTable中的数据都是通过Update only的方式进入的。也就意味着,如果KTable对应的Topic中新进入的数据的Key已经存在,那么从KTable只会取出同一Key对应的最后一条数据,相当于新的数据更新了旧的数据。
以下图为例,假设有一个KStream和KTable,基于同一个Topic创建,并且该Topic中包含如下图所示5条数据。此时遍历KStream将得到与Topic内数据完全一样的所有5条数据,且顺序不变。而此时遍历KTable时,因为这5条记录中有3个不同的Key,所以将得到3条记录,每个Key对应最新的值,并且这三条数据之间的顺序与原来在Topic中的顺序保持一致。这一点与Kafka的日志compact相同。
此时如果对该KStream和KTable分别基于key做Group,对Value进行Sum,得到的结果将会不同。对KStream的计算结果是<Jack,4>
,<Lily,7>
,<Mike,4>
。而对Ktable的计算结果是<Mike,4>
,<Jack,3>
,<Lily,5>
。
流式处理中,部分操作是无状态的,例如过滤操作(Kafka Stream DSL中用filer
方法实现)。而部分操作是有状态的,需要记录中间状态,如Window操作和聚合计算。State store被用来存储中间状态。它可以是一个持久化的Key-Value存储,也可以是内存中的HashMap,或者是数据库。Kafka提供了基于Topic的状态存储。
Topic中存储的数据记录本身是Key-Value形式的,同时Kafka的log compaction机制可对历史数据做compact操作,保留每个Key对应的最后一个Value,从而在保证Key不丢失的前提下,减少总数据量,从而提高查询效率。
构造KTable时,需要指定其state store name。默认情况下,该名字也即用于存储该KTable的状态的Topic的名字,遍历KTable的过程,实际就是遍历它对应的state store,或者说遍历Topic的所有key,并取每个Key最新值的过程。为了使得该过程更加高效,默认情况下会对该Topic进行compact操作。
另外,除了KTable,所有状态计算,都需要指定state store name,从而记录中间状态。
在流式数据处理中,时间是数据的一个非常重要的属性。从Kafka 0.10开始,每条记录除了Key和Value外,还增加了timestamp
属性。目前Kafka Stream支持三种时间
message.timestamp.type
设置为CreateTime
(默认值)才能生效。message.timestamp.type
设置为LogAppendTime
时生效。此时Broker会在接收到消息后,存入磁盘前,将其timestamp
属性值设置为当前机器时间。一般消息接收时间比较接近于事件发生时间,部分场景下可代替事件发生时间。注:Kafka Stream允许通过实现org.apache.kafka.streams.processor.TimestampExtractor
接口自定义记录时间。
前文提到,流式数据是在时间上无界的数据。而聚合操作只能作用在特定的数据集,也即有界的数据集上。因此需要通过某种方式从无界的数据集上按特定的语义选取出有界的数据。窗口是一种非常常用的设定计算边界的方式。不同的流式处理系统支持的窗口类似,但不尽相同。
Kafka Stream支持的窗口如下。
Hopping Time Window
该窗口定义如下图所示。它有两个属性,一个是Window size,一个是Advance interval。Window size指定了窗口的大小,也即每次计算的数据集的大小。而Advance interval定义输出的时间间隔。一个典型的应用场景是,每隔5秒钟输出一次过去1个小时内网站的PV或者UV。
Tumbling Time Window
该窗口定义如下图所示。可以认为它是Hopping Time Window的一种特例,也即Window size和Advance interval相等。它的特点是各个Window之间完全不相交。
Sliding Window
该窗口只用于2个KStream进行Join计算时。该窗口的大小定义了Join两侧KStream的数据记录被认为在同一个窗口的最大时间差。假设该窗口的大小为5秒,则参与Join的2个KStream中,记录时间差小于5的记录被认为在同一个窗口中,可以进行Join计算。
Session Window
该窗口用于对Key做Group后的聚合操作中。它需要对Key做分组,然后对组内的数据根据业务需求定义一个窗口的起始点和结束点。一个典型的案例是,希望通过Session Window计算某个用户访问网站的时间。对于一个特定的用户(用Key表示)而言,当发生登录操作时,该用户(Key)的窗口即开始,当发生退出操作或者超时时,该用户(Key)的窗口即结束。窗口结束时,可计算该用户的访问时间或者点击次数等。
Kafka Stream由于包含KStream和Ktable两种数据集,因此提供如下Join计算
KTable Join KTable
结果仍为KTable。任意一边有更新,结果KTable都会更新。KStream Join KStream
结果为KStream。必须带窗口操作,否则会造成Join操作一直不结束。KStream Join KTable / GlobalKTable
结果为KStream。只有当KStream中有新数据时,才会触发Join计算并输出结果。KStream无新数据时,KTable的更新并不会触发Join计算,也不会输出数据。并且该更新只对下次Join生效。一个典型的使用场景是,KStream中的订单信息与KTable中的用户信息做关联计算。对于Join操作,如果要得到正确的计算结果,需要保证参与Join的KTable或KStream中Key相同的数据被分配到同一个Task。具体方法是
如果上述条件不满足,可通过调用如下方法使得它满足上述条件。1
KStream<K, V> through(Serde<K> keySerde, Serde<V> valSerde, StreamPartitioner<K, V> partitioner, String topic)
聚合操作可应用于KStream和KTable。当聚合发生在KStream上时必须指定窗口,从而限定计算的目标数据集。
需要说明的是,聚合操作的结果肯定是KTable。因为KTable是可更新的,可以在晚到的数据到来时(也即发生数据乱序时)更新结果KTable。
这里举例说明。假设对KStream以5秒为窗口大小,进行Tumbling Time Window上的Count操作。并且KStream先后出现时间为1秒, 3秒, 5秒的数据,此时5秒的窗口已达上限,Kafka Stream关闭该窗口,触发Count操作并将结果3输出到KTable中(假设该结果表示为<1-5,3>)。若1秒后,又收到了时间为2秒的记录,由于1-5秒的窗口已关闭,若直接抛弃该数据,则可认为之前的结果<1-5,3>不准确。而如果直接将完整的结果<1-5,4>输出到KStream中,则KStream中将会包含该窗口的2条记录,<1-5,3>, <1-5,4>,也会存在肮数据。因此Kafka Stream选择将聚合结果存于KTable中,此时新的结果<1-5,4>会替代旧的结果<1-5,3>。用户可得到完整的正确的结果。
这种方式保证了数据准确性,同时也提高了容错性。
但需要说明的是,Kafka Stream并不会对所有晚到的数据都重新计算并更新结果集,而是让用户设置一个retention period
,将每个窗口的结果集在内存中保留一定时间,该窗口内的数据晚到时,直接合并计算,并更新结果KTable。超过retention period
后,该窗口结果将从内存中删除,并且晚到的数据即使落入窗口,也会被直接丢弃。
Kafka Stream从如下几个方面进行容错
retention period
提供了对乱序数据的处理能力。下面结合一个案例来讲解如何开发Kafka Stream应用。本例完整代码可从作者Github获取。
订单KStream(名为orderStream),底层Topic的Partition数为3,Key为用户名,Value包含用户名,商品名,订单时间,数量。用户KTable(名为userTable),底层Topic的Partition数为3,Key为用户名,Value包含性别,地址和年龄。商品KTable(名为itemTable),底层Topic的Partition数为6,Key为商品名,价格,种类和产地。现在希望计算每小时购买产地与自己所在地相同的用户总数。
首先由于希望使用订单时间,而它包含在orderStream的Value中,需要通过提供一个实现TimestampExtractor接口的类从orderStream对应的Topic中抽取出订单时间。1
2
3
4
5
6
7
8
9
10
11public class OrderTimestampExtractor implements TimestampExtractor {
public long extract(ConsumerRecord<Object, Object> record) {
if(record instanceof Order) {
return ((Order)record).getTS();
} else {
return 0;
}
}
}
接着通过将orderStream与userTable进行Join,来获取订单用户所在地。由于二者对应的Topic的Partition数相同,且Key都为用户名,再假设Producer往这两个Topic写数据时所用的Partitioner实现相同,则此时上文所述Join条件满足,可直接进行Join。1
2
3
4
5
6
7
8
9orderUserStream = orderStream
.leftJoin(userTable,
// 该lamda表达式定义了如何从orderStream与userTable生成结果集的Value
(Order order, User user) -> OrderUser.fromOrderUser(order, user),
// 结果集Key序列化方式
Serdes.String(),
// 结果集Value序列化方式
SerdesFactory.serdFrom(Order.class))
.filter((String userName, OrderUser orderUser) -> orderUser.userAddress != null)
从上述代码中,可以看到,Join时需要指定如何从参与Join双方的记录生成结果记录的Value。Key不需要指定,因为结果记录的Key与Join Key相同,故无须指定。Join结果存于名为orderUserStream的KStream中。
接下来需要将orderUserStream与itemTable进行Join,从而获取商品产地。此时orderUserStream的Key仍为用户名,而itemTable对应的Topic的Key为产品名,并且二者的Partition数不一样,因此无法直接Join。此时需要通过through方法,对其中一方或双方进行重新分区,使得二者满足Join条件。这一过程相当于Spark的Shuffle过程和Storm的FieldGrouping。1
2
3
4
5
6
7
8
9
10orderUserStrea
.through(
// Key的序列化方式
Serdes.String(),
// Value的序列化方式
SerdesFactory.serdFrom(OrderUser.class),
// 重新按照商品名进行分区,具体取商品名的哈希值,然后对分区数取模
(String key, OrderUser orderUser, int numPartitions) -> (orderUser.getItemName().hashCode() & 0x7FFFFFFF) % numPartitions,
"orderuser-repartition-by-item")
.leftJoin(itemTable, (OrderUser orderUser, Item item) -> OrderUserItem.fromOrderUser(orderUser, item), Serdes.String(), SerdesFactory.serdFrom(OrderUser.class))
从上述代码可见,through时需要指定Key的序列化器,Value的序列化器,以及分区方式和结果集所在的Topic。这里要注意,该Topic(orderuser-repartition-by-item)的Partition数必须与itemTable对应Topic的Partition数相同,并且through使用的分区方法必须与iteamTable对应Topic的分区方式一样。经过这种through
操作,orderUserStream与itemTable满足了Join条件,可直接进行Join。
through
方法提供了类似Spark的Shuffle机制,为使用不同分区策略的数据提供了Join的可能原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/java/concurrenthashmap/
众所周知,HashMap是非线程安全的。而HashMap的线程不安全主要体现在resize时的死循环及使用迭代器时的fast-fail上。
注:本章的代码均基于JDK 1.7.0_67
常用的底层数据结构主要有数组和链表。数组存储区间连续,占用内存较多,寻址容易,插入和删除困难。链表存储区间离散,占用内存较少,寻址困难,插入和删除容易。
HashMap要实现的是哈希表的效果,尽量实现O(1)级别的增删改查。它的具体实现则是同时使用了数组和链表,可以认为最外层是一个数组,数组的每个元素是一个链表的表头。
对于新插入的数据或者待读取的数据,HashMap将Key的哈希值对数组长度取模,结果作为该Entry在数组中的index。在计算机中,取模的代价远高于位操作的代价,因此HashMap要求数组的长度必须为2的N次方。此时将Key的哈希值对2^N-1进行与运算,其效果即与取模等效。HashMap并不要求用户在指定HashMap容量时必须传入一个2的N次方的整数,而是会通过Integer.highestOneBit算出比指定整数大的最小的2^N值,其实现方法如下。1
2
3
4
5
6
7
8public static int highestOneBit(int i) {
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
return i - (i >>> 1);
}
由于Key的哈希值的分布直接决定了所有数据在哈希表上的分布或者说决定了哈希冲突的可能性,因此为防止糟糕的Key的hashCode实现(例如低位都相同,只有高位不相同,与2^N-1取与后的结果都相同),JDK 1.7的HashMap通过如下方法使得最终的哈希值的二进制形式中的1尽量均匀分布从而尽可能减少哈希冲突。1
2
3
4int h = hashSeed;
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
当HashMap的size超过Capacity*loadFactor时,需要对HashMap进行扩容。具体方法是,创建一个新的,长度为原来Capacity两倍的数组,保证新的Capacity仍为2的N次方,从而保证上述寻址方式仍适用。同时需要通过如下transfer方法将原来的所有数据全部重新插入(rehash)到新的数组中。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
该方法并不保证线程安全,而且在多线程并发调用时,可能出现死循环。其执行过程如下。从步骤2可见,转移时链表顺序反转。
单线程情况下,rehash无问题。下图演示了单线程条件下的rehash过程
这里假设有两个线程同时执行了put操作并引发了rehash,执行了transfer方法,并假设线程一进入transfer方法并执行完next = e.next后,因为线程调度所分配时间片用完而“暂停”,此时线程二完成了transfer方法的执行。此时状态如下。
接着线程1被唤醒,继续执行第一轮循环的剩余部分1
2
3e.next = newTable[1] = null
newTable[1] = e = key(5)
e = next = key(9)
结果如下图所示
接着执行下一轮循环,结果状态图如下所示
继续下一轮循环,结果状态图如下所示
此时循环链表形成,并且key(11)无法加入到线程1的新数组。在下一次访问该链表时会出现死循环。
在使用迭代器的过程中如果HashMap被修改,那么ConcurrentModificationException
将被抛出,也即Fast-fail策略。
当HashMap的iterator()方法被调用时,会构造并返回一个新的EntryIterator对象,并将EntryIterator的expectedModCount设置为HashMap的modCount(该变量记录了HashMap被修改的次数)。1
2
3
4
5
6
7
8HashIterator() {
expectedModCount = modCount;
if (size > 0) { // advance to first entry
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null)
;
}
}
在通过该Iterator的next方法访问下一个Entry时,它会先检查自己的expectedModCount与HashMap的modCount是否相等,如果不相等,说明HashMap被修改,直接抛出ConcurrentModificationException
。该Iterator的remove方法也会做类似的检查。该异常的抛出意在提醒用户及早意识到线程安全问题。
单线程条件下,为避免出现ConcurrentModificationException
,需要保证只通过HashMap本身或者只通过Iterator去修改数据,不能在Iterator使用结束之前使用HashMap本身的方法修改数据。因为通过Iterator删除数据时,HashMap的modCount和Iterator的expectedModCount都会自增,不影响二者的相等性。如果是增加数据,只能通过HashMap本身的方法完成,此时如果要继续遍历数据,需要重新调用iterator()方法从而重新构造出一个新的Iterator,使得新Iterator的expectedModCount与更新后的HashMap的modCount相等。
多线程条件下,可使用Collections.synchronizedMap
方法构造出一个同步Map,或者直接使用线程安全的ConcurrentHashMap。
注:本章的代码均基于JDK 1.7.0_67
Java 7中的ConcurrentHashMap的底层数据结构仍然是数组和链表。与HashMap不同的是,ConcurrentHashMap最外层不是一个大的数组,而是一个Segment的数组。每个Segment包含一个与HashMap数据结构差不多的链表数组。整体数据结构如下图所示。
在读写某个Key时,先取该Key的哈希值。并将哈希值的高N位对Segment个数取模从而得到该Key应该属于哪个Segment,接着如同操作HashMap一样操作这个Segment。为了保证不同的值均匀分布到不同的Segment,需要通过如下方法计算哈希值。1
2
3
4
5
6
7
8
9
10
11
12
13private int hash(Object k) {
int h = hashSeed;
if ((0 != h) && (k instanceof String)) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
同样为了提高取模运算效率,通过如下计算,ssize即为大于concurrencyLevel的最小的2的N次方,同时segmentMask为2^N-1。这一点跟上文中计算数组长度的方法一致。对于某一个Key的哈希值,只需要向右移segmentShift位以取高sshift位,再与segmentMask取与操作即可得到它在Segment数组上的索引。1
2
3
4
5
6
7
8
9int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
Segment继承自ReentrantLock,所以我们可以很方便的对每一个Segment上锁。
对于读操作,获取Key所在的Segment时,需要保证可见性(请参考如何保证多线程条件下的可见性)。具体实现上可以使用volatile关键字,也可使用锁。但使用锁开销太大,而使用volatile时每次写操作都会让所有CPU内缓存无效,也有一定开销。ConcurrentHashMap使用如下方法保证可见性,取得最新的Segment。1
Segment<K,V> s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)
获取Segment中的HashEntry时也使用了类似方法1
2HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE)
对于写操作,并不要求同时获取所有Segment的锁,因为那样相当于锁住了整个Map。它会先获取该Key-Value对所在的Segment的锁,获取成功后就可以像操作一个普通的HashMap一样操作该Segment,并保证该Segment的安全性。
同时由于其它Segment的锁并未被获取,因此理论上可支持concurrencyLevel(等于Segment的个数)个线程安全的并发读写。
获取锁时,并不直接使用lock来获取,因为该方法获取锁失败时会挂起(参考可重入锁)。事实上,它使用了自旋锁,如果tryLock获取锁失败,说明锁被其它线程占用,此时通过循环再次以tryLock的方式申请锁。如果在循环过程中该Key所对应的链表头被修改,则重置retry次数。如果retry次数超过一定值,则使用lock方法申请锁。
这里使用自旋锁是因为自旋锁的效率比较高,但是它消耗CPU资源比较多,因此在自旋次数超过阈值时切换为互斥锁。
put、remove和get操作只需要关心一个Segment,而size操作需要遍历所有的Segment才能算出整个Map的大小。一个简单的方案是,先锁住所有Sgment,计算完后再解锁。但这样做,在做size操作时,不仅无法对Map进行写操作,同时也无法进行读操作,不利于对Map的并行操作。
为更好支持并发操作,ConcurrentHashMap会在不上锁的前提逐个Segment计算3次size,如果某相邻两次计算获取的所有Segment的更新次数(每个Segment都与HashMap一样通过modCount跟踪自己的修改次数,Segment每修改一次其modCount加一)相等,说明这两次计算过程中无更新操作,则这两次计算出的总size相等,可直接作为最终结果返回。如果这三次计算过程中Map有更新,则对所有Segment加锁重新计算Size。该计算方法代码如下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
37public int size() {
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
ConcurrentHashMap与HashMap相比,有以下不同点
注:本章的代码均基于JDK 1.8.0_111
Java 7为实现并行访问,引入了Segment这一结构,实现了分段锁,理论上最大并发度与Segment个数相等。Java 8为进一步提高并发性,摒弃了分段锁的方案,而是直接使用一个大的数组。同时为了提高哈希碰撞下的寻址性能,Java 8在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红黑树(寻址时间复杂度为O(long(N)))。其数据结构如下图所示
Java 8的ConcurrentHashMap同样是通过Key的哈希值与数组长度取模确定该Key在数组中的索引。同样为了避免不太好的Key的hashCode设计,它通过如下方法计算得到Key的最终哈希值。不同的是,Java 8的ConcurrentHashMap作者认为引入红黑树后,即使哈希冲突比较严重,寻址效率也足够高,所以作者并未在哈希值的计算上做过多设计,只是将Key的hashCode值与其高16位作异或并保证最高位为0(从而保证最终结果为正整数)。1
2
3static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
对于put操作,如果Key对应的数组元素为null,则通过CAS操作将其设置为当前值。如果Key对应的数组元素(也即链表表头或者树的根元素)不为null,则对该元素使用synchronized关键字申请锁,然后进行操作。如果该put操作使得当前链表长度超过一定阈值,则将该链表转换为树,从而提高寻址效率。
对于读操作,由于数组被volatile关键字修饰,因此不用担心数组的可见性问题。同时每个元素是一个Node实例(Java 7中每个元素是一个HashEntry),它的Key值和hash值都由final修饰,不可变更,无须关心它们被修改后的可见性问题。而其Value及对下一个元素的引用由volatile修饰,可见性也有保障。1
2
3
4
5
6static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
}
对于Key对应的数组元素的可见性,由Unsafe的getObjectVolatile方法保证。1
2
3static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
put方法和remove方法都会通过addCount方法维护Map的size。size方法通过sumCount获取由addCount方法维护的Map的size。
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自技术世界,原文链接 http://www.jasongj.com/kafka/high_throughput/
上一篇文章《Kafka设计解析(五)- Kafka性能测试方法及Benchmark报告》从测试角度说明了Kafka的性能。本文从宏观架构层面和具体实现层面分析了Kafka如何实现高性能。
Kafka是一个Pub-Sub的消息系统,无论是发布还是订阅,都须指定Topic。如《Kafka设计解析(一)- Kafka背景及架构介绍》一文所述,Topic只是一个逻辑的概念。每个Topic都包含一个或多个Partition,不同Partition可位于不同节点。同时Partition在物理上对应一个本地文件夹,每个Partition包含一个或多个Segment,每个Segment包含一个数据文件和一个与之对应的索引文件。在逻辑上,可以把一个Partition当作一个非常长的数组,可通过这个“数组”的索引(offset)去访问其数据。
一方面,由于不同Partition可位于不同机器,因此可以充分利用集群优势,实现机器间的并行处理。另一方面,由于Partition在物理上对应一个文件夹,即使多个Partition位于同一个节点,也可通过配置让同一节点上的不同Partition置于不同的disk drive上,从而实现磁盘间的并行处理,充分发挥多磁盘的优势。
利用多磁盘的具体方法是,将不同磁盘mount到不同目录,然后在server.properties中,将log.dirs
设置为多目录(用逗号分隔)。Kafka会自动将所有Partition尽可能均匀分配到不同目录也即不同目录(也即不同disk)上。
注:虽然物理上最小单位是Segment,但Kafka并不提供同一Partition内不同Segment间的并行处理。因为对于写而言,每次只会写Partition内的一个Segment,而对于读而言,也只会顺序读取同一Partition内的不同Segment。
如同《Kafka设计解析(四)- Kafka Consumer设计解析》一文所述,多Consumer消费同一个Topic时,同一条消息只会被同一Consumer Group内的一个Consumer所消费。而数据并非按消息为单位分配,而是以Partition为单位分配,也即同一个Partition的数据只会被一个Consumer所消费(在不考虑Rebalance的前提下)。
如果Consumer的个数多于Partition的个数,那么会有部分Consumer无法消费该Topic的任何数据,也即当Consumer个数超过Partition后,增加Consumer并不能增加并行度。
简而言之,Partition个数决定了可能的最大并行度。如下图所示,由于Topic 2只包含3个Partition,故group2中的Consumer 3、Consumer 4、Consumer 5 可分别消费1个Partition的数据,而Consumer 6消费不到Topic 2的任何数据。
以Spark消费Kafka数据为例,如果所消费的Topic的Partition数为N,则有效的Spark最大并行度也为N。即使将Spark的Executor数设置为N+M,最多也只有N个Executor可同时处理该Topic的数据。
CAP理论是指,分布式系统中,一致性、可用性和分区容忍性最多只能同时满足两个。
一致性
可用性
分区容忍性
一般而言,都要求保证分区容忍性。所以在CAP理论下,更多的是需要在可用性和一致性之间做权衡。
Master-Slave
WNR
Paxos及其变种
基于ISR的数据复制方案
如《 Kafka High Availability(上)》一文所述,Kafka的数据复制是以Partition为单位的。而多个备份间的数据复制,通过Follower向Leader拉取数据完成。从一这点来讲,Kafka的数据复制方案接近于上文所讲的Master-Slave方案。不同的是,Kafka既不是完全的同步复制,也不是完全的异步复制,而是基于ISR的动态复制方案。
ISR,也即In-sync Replica。每个Partition的Leader都会维护这样一个列表,该列表中,包含了所有与之同步的Replica(包含Leader自己)。每次数据写入时,只有ISR中的所有Replica都复制完,Leader才会将其置为Commit,它才能被Consumer所消费。
这种方案,与同步复制非常接近。但不同的是,这个ISR是由Leader动态维护的。如果Follower不能紧“跟上”Leader,它将被Leader从ISR中移除,待它又重新“跟上”Leader后,会被Leader再次加加ISR中。每次改变ISR后,Leader都会将最新的ISR持久化到Zookeeper中。
至于如何判断某个Follower是否“跟上”Leader,不同版本的Kafka的策略稍微有些区别。
replica.lag.time.max.ms
时间内未向Leader发送Fetch请求(也即数据复制请求),则Leader会将其从ISR中移除。如果某Follower持续向Leader发送Fetch请求,但是它与Leader的数据差距在replica.lag.max.messages
以上,也会被Leader从ISR中移除。replica.lag.max.messages
被移除,故Leader不再考虑Follower落后的消息条数。另外,Leader不仅会判断Follower是否在replica.lag.time.max.ms
时间内向其发送Fetch请求,同时还会考虑Follower是否在该时间内与之保持同步。对于0.8.*版本的replica.lag.max.messages
参数,很多读者曾留言提问,既然只有ISR中的所有Replica复制完后的消息才被认为Commit,那为何会出现Follower与Leader差距过大的情况。原因在于,Leader并不需要等到前一条消息被Commit才接收后一条消息。事实上,Leader可以按顺序接收大量消息,最新的一条消息的Offset被记为LEO(Log end offset)。而只有被ISR中所有Follower都复制过去的消息才会被Commit,Consumer只能消费被Commit的消息,最新被Commit的Offset被记为High watermark。换句话说,LEO 标记的是Leader所保存的最新消息的offset,而High watermark标记的是最新的可被消费的(已同步到ISR中的Follower)消息。而Leader对数据的接收与Follower对数据的复制是异步进行的,因此会出现Hight watermark与LEO存在一定差距的情况。0.8.*版本中replica.lag.max.messages
限定了Leader允许的该差距的最大值。
Kafka基于ISR的数据复制方案原理如下图所示。
如上图所示,在第一步中,Leader A总共收到3条消息,故其high watermark为3,但由于ISR中的Follower只同步了第1条消息(m1),故只有m1被Commit,也即只有m1可被Consumer消费。此时Follower B与Leader A的差距是1,而Follower C与Leader A的差距是2,均未超过默认的replica.lag.max.messages
,故得以保留在ISR中。在第二步中,由于旧的Leader A宕机,新的Leader B在replica.lag.time.max.ms
时间内未收到来自A的Fetch请求,故将A从ISR中移除,此时ISR={B,C}。同时,由于此时新的Leader B中只有2条消息,并未包含m3(m3从未被任何Leader所Commit),所以m3无法被Consumer消费。第四步中,Follower A恢复正常,它先将宕机前未Commit的所有消息全部删除,然后从最后Commit过的消息的下一条消息开始追赶新的Leader B,直到它“赶上”新的Leader,才被重新加入新的ISR中。
Majority Quorum
方案相比,容忍相同个数的节点失败,所要求的总节点数少了近一半。min.insync.replicas
参数指定了Broker所要求的ISR最小长度,默认值为1。也即极限情况下ISR可以只包含Leader。但此时如果Leader宕机,则该Partition不可用,可用性得不到保证。acks
参数指定最少需要多少个Replica确认收到该消息才视为该消息发送成功。acks
的默认值是1,即Leader收到该消息后立即告诉Producer收到该消息,此时如果在ISR中的消息复制完该消息前Leader宕机,那该条消息会丢失。而如果将该值设置为0,则Producer发送完数据后,立即认为该数据发送成功,不作任何等待,而实际上该数据可能发送失败,并且Producer的Retry机制将不生效。更推荐的做法是,将acks
设置为all
或者-1
,此时只有ISR中的所有Replica都收到该数据(也即该消息被Commit),Leader才会告诉Producer该消息发送成功,从而保证不会有未知的数据丢失。根据《一些场景下顺序写磁盘快于随机写内存》所述,将写磁盘的过程变为顺序写,可极大提高对磁盘的利用率。
Kafka的整个设计中,Partition相当于一个非常长的数组,而Broker接收到的所有消息顺序写入这个大数组中。同时Consumer通过Offset顺序消费这些数据,并且不删除已经消费的数据,从而避免了随机写磁盘的过程。
由于磁盘有限,不可能保存所有数据,实际上作为消息系统Kafka也没必要保存所有数据,需要删除旧的数据。而这个删除过程,并非通过使用“读-写”模式去修改文件,而是将Partition分为多个Segment,每个Segment对应一个物理文件,通过删除整个文件的方式去删除Partition内的数据。这种方式清除旧数据的方式,也避免了对文件的随机写操作。
通过如下代码可知,Kafka删除Segment的方式,是直接删除Segment对应的整个log文件和整个index文件而非删除文件中的部分内容。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/**
* Delete this log segment from the filesystem.
*
* @throws KafkaStorageException if the delete fails.
*/
def delete() {
val deletedLog = log.delete()
val deletedIndex = index.delete()
val deletedTimeIndex = timeIndex.delete()
if(!deletedLog && log.file.exists)
throw new KafkaStorageException("Delete of log " + log.file.getName + " failed.")
if(!deletedIndex && index.file.exists)
throw new KafkaStorageException("Delete of index " + index.file.getName + " failed.")
if(!deletedTimeIndex && timeIndex.file.exists)
throw new KafkaStorageException("Delete of time index " + timeIndex.file.getName + " failed.")
}
使用Page Cache的好处如下
Broker收到数据后,写磁盘时只是将数据写入Page Cache,并不保证数据一定完全写入磁盘。从这一点看,可能会造成机器宕机时,Page Cache内的数据未写入磁盘从而造成数据丢失。但是这种丢失只发生在机器断电等造成操作系统不工作的场景,而这种场景完全可以由Kafka层面的Replication机制去解决。如果为了保证这种情况下数据不丢失而强制将Page Cache中的数据Flush到磁盘,反而会降低性能。也正因如此,Kafka虽然提供了flush.messages
和flush.ms
两个参数将Page Cache中的数据强制Flush到磁盘,但是Kafka并不建议使用。
如果数据消费速度与生产速度相当,甚至不需要通过物理磁盘交换数据,而是直接通过Page Cache交换数据。同时,Follower从Leader Fetch数据时,也可通过Page Cache完成。下图为某Partition的Leader节点的网络/磁盘读写信息。
从上图可以看到,该Broker每秒通过网络从Producer接收约35MB数据,虽然有Follower从该Broker Fetch数据,但是该Broker基本无读磁盘。这是因为该Broker直接从Page Cache中将数据取出返回给了Follower。
Broker的log.dirs
配置项,允许配置多个文件夹。如果机器上有多个Disk Drive,可将不同的Disk挂载到不同的目录,然后将这些目录都配置到log.dirs
里。Kafka会尽可能将不同的Partition分配到不同的目录,也即不同的Disk上,从而充分利用了多Disk的优势。
Kafka中存在大量的网络数据持久化到磁盘(Producer到Broker)和磁盘文件通过网络发送(Broker到Consumer)的过程。这一过程的性能直接影响Kafka的整体吞吐量。
以将磁盘文件通过网络发送为例。传统模式下,一般使用如下伪代码所示的方法先将文件数据读入内存,然后通过Socket将内存中的数据发送出去。1
2buffer = File.read
Socket.send(buffer)
这一过程实际上发生了四次数据拷贝。首先通过系统调用将文件数据读入到内核态Buffer(DMA拷贝),然后应用程序将内存态Buffer数据读入到用户态Buffer(CPU拷贝),接着用户程序通过Socket发送数据时将用户态Buffer数据拷贝到内核态Buffer(CPU拷贝),最后通过DMA拷贝将数据拷贝到NIC Buffer。同时,还伴随着四次上下文切换,如下图所示。
Linux 2.4+内核通过sendfile
系统调用,提供了零拷贝。数据通过DMA拷贝到内核态Buffer后,直接通过DMA拷贝到NIC Buffer,无需CPU拷贝。这也是零拷贝这一说法的来源。除了减少数据拷贝外,因为整个读文件-网络发送由一个sendfile
调用完成,整个过程只有两次上下文切换,因此大大提高了性能。零拷贝过程如下图所示。
从具体实现来看,Kafka的数据传输通过TransportLayer来完成,其子类PlaintextTransportLayer
通过Java NIO的FileChannel的transferTo
和transferFrom
方法实现零拷贝,如下所示。
1 |
|
注: transferTo
和transferFrom
并不保证一定能使用零拷贝。实际上是否能使用零拷贝与操作系统相关,如果操作系统提供sendfile
这样的零拷贝系统调用,则这两个方法会通过这样的系统调用充分利用零拷贝的优势,否则并不能通过这两个方法本身实现零拷贝。
批处理是一种常用的用于提高I/O性能的方式。对Kafka而言,批处理既减少了网络传输的Overhead,又提高了写磁盘的效率。
Kafka 0.8.1及以前的Producer区分同步Producer和异步Producer。同步Producer的send方法主要分两种形式。一种是接受一个KeyedMessage作为参数,一次发送一条消息。另一种是接受一批KeyedMessage作为参数,一次性发送多条消息。而对于异步发送而言,无论是使用哪个send方法,实现上都不会立即将消息发送给Broker,而是先存到内部的队列中,直到消息条数达到阈值或者达到指定的Timeout才真正的将消息发送出去,从而实现了消息的批量发送。
Kafka 0.8.2开始支持新的Producer API,将同步Producer和异步Producer结合。虽然从send接口来看,一次只能发送一个ProducerRecord,而不能像之前版本的send方法一样接受消息列表,但是send方法并非立即将消息发送出去,而是通过batch.size
和linger.ms
控制实际发送频率,从而实现批量发送。
由于每次网络传输,除了传输消息本身以外,还要传输非常多的网络协议本身的一些内容(称为Overhead),所以将多条消息合并到一起传输,可有效减少网络传输的Overhead,进而提高了传输效率。
从零拷贝章节的图中可以看到,虽然Broker持续从网络接收数据,但是写磁盘并非每秒都在发生,而是间隔一段时间写一次磁盘,并且每次写磁盘的数据量都非常大(最高达到718MB/S)。
Kafka从0.7开始,即支持将数据压缩后再传输给Broker。除了可以将每条消息单独压缩然后传输外,Kafka还支持在批量发送时,将整个Batch的消息一起压缩后传输。数据压缩的一个基本原理是,重复数据越多压缩效果越好。因此将整个Batch的数据一起压缩能更大幅度减小数据量,从而更大程度提高网络传输效率。
Broker接收消息后,并不直接解压缩,而是直接将消息以压缩后的形式持久化到磁盘。Consumer Fetch到数据后再解压缩。因此Kafka的压缩不仅减少了Producer到Broker的网络传输负载,同时也降低了Broker磁盘操作的负载,也降低了Consumer与Broker间的网络传输量,从而极大得提高了传输效率,提高了吞吐量。
Kafka消息的Key和Payload(或者说Value)的类型可自定义,只需同时提供相应的序列化器和反序列化器即可。因此用户可以通过使用快速且紧凑的序列化-反序列化方式(如Avro,Protocal Buffer)来减少实际网络传输和磁盘存储的数据规模,从而提高吞吐率。这里要注意,如果使用的序列化方法太慢,即使压缩比非常高,最终的效率也不一定高。
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自技术世界,原文链接 http://www.jasongj.com/ml/classification/
本文详述了如何通过数据预览,探索式数据分析,缺失数据填补,删除关联特征以及派生新特征等方法,在Kaggle的Titanic幸存预测这一分类问题竞赛中获得前2%排名的具体方法。
Titanic幸存预测是Kaggle上参赛人数最多的竞赛之一。它要求参赛选手通过训练数据集分析出什么类型的人更可能幸存,并预测出测试数据集中的所有乘客是否生还。
该项目是一个二元分类问题
在加载数据之前,先通过如下代码加载之后会用到的所有R库
1 | library(readr) # File read / write |
通过如下代码将训练数据和测试数据分别加载到名为train和test的data.frame中
1 | train <- read_csv("train.csv") |
由于之后需要对训练数据和测试数据做相同的转换,为避免重复操作和出现不一至的情况,更为了避免可能碰到的Categorical类型新level的问题,这里建议将训练数据和测试数据合并,统一操作。
1 | data <- bind_rows(train, test) |
先观察数据
1 | str(data) |
## Classes 'tbl_df', 'tbl' and 'data.frame': 1309 obs. of 12 variables:## $ PassengerId: int 1 2 3 4 5 6 7 8 9 10 ...## $ Survived : int 0 1 1 1 0 0 0 0 1 1 ...## $ Pclass : int 3 1 3 1 3 3 1 3 3 2 ...## $ Name : chr "Braund, Mr. Owen Harris" "Cumings, Mrs. John Bradley (Florence Briggs Thayer)" "Heikkinen, Miss. Laina" "Futrelle, Mrs. Jacques Heath (Lily May Peel)" ...## $ Sex : chr "male" "female" "female" "female" ...## $ Age : num 22 38 26 35 35 NA 54 2 27 14 ...## $ SibSp : int 1 1 0 1 0 0 0 3 0 1 ...## $ Parch : int 0 0 0 0 0 0 0 1 2 0 ...## $ Ticket : chr "A/5 21171" "PC 17599" "STON/O2. 3101282" "113803" ...## $ Fare : num 7.25 71.28 7.92 53.1 8.05 ...## $ Cabin : chr NA "C85" NA "C123" ...## $ Embarked : chr "S" "C" "S" "S" ...
从上可见,数据集包含12个变量,1309条数据,其中891条为训练数据,418条为测试数据
对于第一个变量Pclass,先将其转换为factor类型变量。
1 | data$Survived <- factor(data$Survived) |
可通过如下方式统计出每个Pclass幸存和遇难人数,如下
1 | ggplot(data = data[1:nrow(train),], mapping = aes(x = Pclass, y = ..count.., fill=Survived)) + |
从上图可见,Pclass=1的乘客大部分幸存,Pclass=2的乘客接近一半幸存,而Pclass=3的乘客只有不到25%幸存。
为了更为定量的计算Pclass的预测价值,可以算出Pclass的WOE和IV如下。从结果可以看出,Pclass的IV为0.5,且“Highly Predictive”。由此可以暂时将Pclass作为预测模型的特征变量之一。
1 | WOETable(X=factor(data$Pclass[1:nrow(train)]), Y=data$Survived[1:nrow(train)]) |
## CAT GOODS BADS TOTAL PCT_G PCT_B WOE IV## 1 1 136 80 216 0.3976608 0.1457195 1.0039160 0.25292792## 2 2 87 97 184 0.2543860 0.1766849 0.3644848 0.02832087## 3 3 119 372 491 0.3479532 0.6775956 -0.6664827 0.21970095
1 | IV(X=factor(data$Pclass[1:nrow(train)]), Y=data$Survived[1:nrow(train)]) |
## [1] 0.5009497## attr(,"howgood")## [1] "Highly Predictive"
乘客姓名重复度太低,不适合直接使用。而姓名中包含Mr. Mrs. Dr.等具有文化特征的信息,可将之抽取出来。
本文使用如下方式从姓名中抽取乘客的Title
1 | data$Title <- sapply(data$Name, FUN=function(x) {strsplit(x, split='[,.]')[[1]][2]}) |
抽取完乘客的Title后,统计出不同Title的乘客的幸存与遇难人数
1 | ggplot(data = data[1:nrow(train),], mapping = aes(x = Title, y = ..count.., fill=Survived)) + |
从上图可看出,Title为Mr的乘客幸存比例非常小,而Title为Mrs和Miss的乘客幸存比例非常大。这里使用WOE和IV来定量计算Title这一变量对于最终的预测是否有用。从计算结果可见,IV为1.520702,且”Highly Predictive”。因此,可暂将Title作为预测模型中的一个特征变量。
1 | WOETable(X=data$Title[1:nrow(train)], Y=data$Survived[1:nrow(train)]) |
## CAT GOODS BADS TOTAL PCT_G PCT_B WOE IV## 1 Col 1 1 2 0.002873563 0.001808318 0.46315552 4.933741e-04## 2 Dr 3 4 7 0.008620690 0.007233273 0.17547345 2.434548e-04## 3 Lady 2 1 3 0.005747126 0.001808318 1.15630270 4.554455e-03## 4 Master 23 17 40 0.066091954 0.030741410 0.76543639 2.705859e-02## 5 Miss 127 55 182 0.364942529 0.099457505 1.30000942 3.451330e-01## 6 Mlle 3 3 3 0.008620690 0.005424955 0.46315552 1.480122e-03## 7 Mr 81 436 517 0.232758621 0.788426763 -1.22003757 6.779360e-01## 8 Mrs 99 26 125 0.284482759 0.047016275 1.80017883 4.274821e-01## 9 Ms 1 1 1 0.002873563 0.001808318 0.46315552 4.933741e-04## 10 Rev 6 6 6 0.017241379 0.010849910 0.46315552 2.960244e-03## 11 Sir 2 3 5 0.005747126 0.005424955 0.05769041 1.858622e-05
1 | IV(X=data$Title[1:nrow(train)], Y=data$Survived[1:nrow(train)]) |
## [1] 1.487853## attr(,"howgood")## [1] "Highly Predictive"
对于Sex变量,由Titanic号沉没的背景可知,逃生时遵循“妇女与小孩先走”的规则,由此猜想,Sex变量应该对预测乘客幸存有帮助。
如下数据验证了这一猜想,大部分女性(233/(233+81)=74.20%)得以幸存,而男性中只有很小部分(109/(109+468)=22.85%)幸存。
1 | data$Sex <- as.factor(data$Sex) |
通过计算WOE和IV可知,Sex的IV为1.34且”Highly Predictive”,可暂将Sex作为特征变量。
1 | WOETable(X=data$Sex[1:nrow(train)], Y=data$Survived[1:nrow(train)]) |
## CAT GOODS BADS TOTAL PCT_G PCT_B WOE IV## 1 female 233 81 314 0.6812865 0.147541 1.5298770 0.8165651## 2 male 109 468 577 0.3187135 0.852459 -0.9838327 0.5251163
1 | IV(X=data$Sex[1:nrow(train)], Y=data$Survived[1:nrow(train)]) |
## [1] 1.341681## attr(,"howgood")## [1] "Highly Predictive"
结合背景,按照“妇女与小孩先走”的规则,未成年人应该有更大可能幸存。如下图所示,Age < 18的乘客中,幸存人数确实高于遇难人数。同时青壮年乘客中,遇难人数远高于幸存人数。
1 | ggplot(data = data[(!is.na(data$Age)) & row(data[, 'Age']) <= 891, ], aes(x = Age, color=Survived)) + |
## Warning: Ignoring unknown aesthetics: label
对于SibSp变量,分别统计出幸存与遇难人数。
1 | ggplot(data = data[1:nrow(train),], mapping = aes(x = SibSp, y = ..count.., fill=Survived)) + |
从上图可见,SibSp为0的乘客,幸存率低于1/3;SibSp为1或2的乘客,幸存率高于50%;SibSp大于等于3的乘客,幸存率非常低。可通过计算WOE与IV定量计算SibSp对预测的贡献。IV为0.1448994,且”Highly Predictive”。
1 | WOETable(X=as.factor(data$SibSp[1:nrow(train)]), Y=data$Survived[1:nrow(train)]) |
## CAT GOODS BADS TOTAL PCT_G PCT_B WOE IV## 1 0 210 398 608 0.593220339 0.724954463 -0.2005429 0.026418349## 2 1 112 97 209 0.316384181 0.176684882 0.5825894 0.081387334## 3 2 13 15 28 0.036723164 0.027322404 0.2957007 0.002779811## 4 3 4 12 16 0.011299435 0.021857923 -0.6598108 0.006966604## 5 4 3 15 18 0.008474576 0.027322404 -1.1706364 0.022063953## 6 5 5 5 5 0.014124294 0.009107468 0.4388015 0.002201391## 7 8 7 7 7 0.019774011 0.012750455 0.4388015 0.003081947
1 | IV(X=as.factor(data$SibSp[1:nrow(train)]), Y=data$Survived[1:nrow(train)]) |
## [1] 0.1448994## attr(,"howgood")## [1] "Highly Predictive"
对于Parch变量,分别统计出幸存与遇难人数。
1 | ggplot(data = data[1:nrow(train),], mapping = aes(x = Parch, y = ..count.., fill=Survived)) + |
从上图可见,Parch为0的乘客,幸存率低于1/3;Parch为1到3的乘客,幸存率高于50%;Parch大于等于4的乘客,幸存率非常低。可通过计算WOE与IV定量计算Parch对预测的贡献。IV为0.1166611,且”Highly Predictive”。
1 | WOETable(X=as.factor(data$Parch[1:nrow(train)]), Y=data$Survived[1:nrow(train)]) |
## CAT GOODS BADS TOTAL PCT_G PCT_B WOE IV## 1 0 233 445 678 0.671469741 0.810564663 -0.1882622 0.026186312## 2 1 65 53 118 0.187319885 0.096539162 0.6628690 0.060175728## 3 2 40 40 80 0.115273775 0.072859745 0.4587737 0.019458440## 4 3 3 2 5 0.008645533 0.003642987 0.8642388 0.004323394## 5 4 4 4 4 0.011527378 0.007285974 0.4587737 0.001945844## 6 5 1 4 5 0.002881844 0.007285974 -0.9275207 0.004084922## 7 6 1 1 1 0.002881844 0.001821494 0.4587737 0.000486461
1 | IV(X=as.factor(data$Parch[1:nrow(train)]), Y=data$Survived[1:nrow(train)]) |
## [1] 0.1166611## attr(,"howgood")## [1] "Highly Predictive"
SibSp与Parch都说明,当乘客无亲人时,幸存率较低,乘客有少数亲人时,幸存率高于50%,而当亲人数过高时,幸存率反而降低。在这里,可以考虑将SibSp与Parch相加,生成新的变量,FamilySize。
1 | data$FamilySize <- data$SibSp + data$Parch + 1 |
计算FamilySize的WOE和IV可知,IV为0.3497672,且“Highly Predictive”。由SibSp与Parch派生出来的新变量FamilySize的IV高于SibSp与Parch的IV,因此,可将这个派生变量FamilySize作为特征变量。
1 | WOETable(X=as.factor(data$FamilySize[1:nrow(train)]), Y=data$Survived[1:nrow(train)]) |
## CAT GOODS BADS TOTAL PCT_G PCT_B WOE IV## 1 1 163 374 537 0.459154930 0.68123862 -0.3945249 0.0876175539## 2 2 89 72 161 0.250704225 0.13114754 0.6479509 0.0774668616## 3 3 59 43 102 0.166197183 0.07832423 0.7523180 0.0661084057## 4 4 21 8 29 0.059154930 0.01457195 1.4010615 0.0624634998## 5 5 3 12 15 0.008450704 0.02185792 -0.9503137 0.0127410643## 6 6 3 19 22 0.008450704 0.03460838 -1.4098460 0.0368782940## 7 7 4 8 12 0.011267606 0.01457195 -0.2571665 0.0008497665## 8 8 6 6 6 0.016901408 0.01092896 0.4359807 0.0026038712## 9 11 7 7 7 0.019718310 0.01275046 0.4359807 0.0030378497
1 | IV(X=as.factor(data$FamilySize[1:nrow(train)]), Y=data$Survived[1:nrow(train)]) |
## [1] 0.3497672## attr(,"howgood")## [1] "Highly Predictive"
对于Ticket变量,重复度非常低,无法直接利用。先统计出每张票对应的乘客数。
1 | ticket.count <- aggregate(data$Ticket, by = list(data$Ticket), function(x) sum(!is.na(x))) |
这里有个猜想,票号相同的乘客,是一家人,很可能同时幸存或者同时遇难。现将所有乘客按照Ticket分为两组,一组是使用单独票号,另一组是与他人共享票号,并统计出各组的幸存与遇难人数。
1 | data$TicketCount <- apply(data, 1, function(x) ticket.count[which(ticket.count[, 1] == x['Ticket']), 2]) |
由上图可见,未与他人同票号的乘客,只有130/(130+351)=27%幸存,而与他人同票号的乘客有212/(212+198)=51.7%幸存。计算TicketCount的WOE与IV如下。其IV为0.2751882,且”Highly Predictive”
1 | WOETable(X=data$TicketCount[1:nrow(train)], Y=data$Survived[1:nrow(train)]) |
## CAT GOODS BADS TOTAL PCT_G PCT_B WOE IV## 1 Share 212 198 410 0.619883 0.3606557 0.5416069 0.1403993## 2 Unique 130 351 481 0.380117 0.6393443 -0.5199641 0.1347889
1 | IV(X=data$TicketCount[1:nrow(train)], Y=data$Survived[1:nrow(train)]) |
## [1] 0.2751882## attr(,"howgood")## [1] "Highly Predictive"
对于Fare变量,由下图可知,Fare越大,幸存率越高。
1 | ggplot(data = data[(!is.na(data$Fare)) & row(data[, 'Fare']) <= 891, ], aes(x = Fare, color=Survived)) + |
对于Cabin变量,其值以字母开始,后面伴以数字。这里有一个猜想,字母代表某个区域,数据代表该区域的序号。类似于火车票即有车箱号又有座位号。因此,这里可尝试将Cabin的首字母提取出来,并分别统计出不同首字母仓位对应的乘客的幸存率。
1 | ggplot(data[1:nrow(train), ], mapping = aes(x = as.factor(sapply(data$Cabin[1:nrow(train)], function(x) str_sub(x, start = 1, end = 1))), y = ..count.., fill = Survived)) + |
由上图可见,仓位号首字母为B,C,D,E,F的乘客幸存率均高于50%,而其它仓位的乘客幸存率均远低于50%。仓位变量的WOE及IV计算如下。由此可见,Cabin的IV为0.1866526,且“Highly Predictive”
1 | data$Cabin <- sapply(data$Cabin, function(x) str_sub(x, start = 1, end = 1)) |
## CAT GOODS BADS TOTAL PCT_G PCT_B WOE IV## 1 A 7 8 15 0.05109489 0.11764706 -0.8340046 0.055504815## 2 B 35 12 47 0.25547445 0.17647059 0.3699682 0.029228917## 3 C 35 24 59 0.25547445 0.35294118 -0.3231790 0.031499197## 4 D 25 8 33 0.18248175 0.11764706 0.4389611 0.028459906## 5 E 24 8 32 0.17518248 0.11764706 0.3981391 0.022907100## 6 F 8 5 13 0.05839416 0.07352941 -0.2304696 0.003488215## 7 G 2 2 4 0.01459854 0.02941176 -0.7004732 0.010376267## 8 T 1 1 1 0.00729927 0.01470588 -0.7004732 0.005188134
1 | IV(X=as.factor(data$Cabin[1:nrow(train)]), Y=data$Survived[1:nrow(train)]) |
## [1] 0.1866526## attr(,"howgood")## [1] "Highly Predictive"
Embarked变量代表登船码头,现通过统计不同码头登船的乘客幸存率来判断Embarked是否可用于预测乘客幸存情况。
1 | ggplot(data[1:nrow(train), ], mapping = aes(x = Embarked, y = ..count.., fill = Survived)) + |
从上图可见,Embarked为S的乘客幸存率仅为217/(217+427)=33.7%,而Embarked为C或为NA的乘客幸存率均高于50%。初步判断Embarked可用于预测乘客是否幸存。Embarked的WOE和IV计算如下。
1 | WOETable(X=as.factor(data$Embarked[1:nrow(train)]), Y=data$Survived[1:nrow(train)]) |
## CAT GOODS BADS TOTAL PCT_G PCT_B WOE IV## 1 C 93 75 168 0.27352941 0.1366120 0.6942642 9.505684e-02## 2 Q 30 47 77 0.08823529 0.0856102 0.0302026 7.928467e-05## 3 S 217 427 644 0.63823529 0.7777778 -0.1977338 2.759227e-02
1 | IV(X=as.factor(data$Embarked[1:nrow(train)]), Y=data$Survived[1:nrow(train)]) |
## [1] 0.1227284## attr(,"howgood")## [1] "Highly Predictive"
从上述计算结果可见,IV为0.1227284,且“Highly Predictive”。
1 | attach(data) |
## [1] "Age miss 263 values"## [1] "Fare miss 1 values"## [1] "Cabin miss 1014 values"## [1] "Embarked miss 2 values"
缺失年龄信息的乘客数为263,缺失量比较大,不适合使用中位数或者平均值填补。一般通过使用其它变量预测或者直接将缺失值设置为默认值的方法填补,这里通过其它变量来预测缺失的年龄信息。
1 | age.model <- rpart(Age ~ Pclass + Sex + SibSp + Parch + Fare + Embarked + Title + FamilySize, data=data[!is.na(data$Age), ], method='anova') |
从如下数据可见,缺失Embarked信息的乘客的Pclass均为1,且Fare均为80。
1 | data[is.na(data$Embarked), c('PassengerId', 'Pclass', 'Fare', 'Embarked')] |
## # A tibble: 2 × 4## PassengerId Pclass Fare Embarked## <int> <int> <dbl> <chr>## 1 62 1 80 <NA>## 2 830 1 80 <NA>
由下图所见,Embarked为C且Pclass为1的乘客的Fare中位数为80。
1 | ggplot(data[!is.na(data$Embarked),], aes(x=Embarked, y=Fare, fill=factor(Pclass))) + |
因此可以将缺失的Embarked值设置为’C’。
1 | data$Embarked[is.na(data$Embarked)] <- 'C' |
由于缺失Fare值的记录非常少,一般可直接使用平均值或者中位数填补该缺失值。这里使用乘客的Fare中位数填补缺失值。
1 | data$Fare[is.na(data$Fare)] <- median(data$Fare, na.rm=TRUE) |
缺失Cabin信息的记录数较多,不适合使用中位数或者平均值填补,一般通过使用其它变量预测或者直接将缺失值设置为默认值的方法填补。由于Cabin信息不太容易从其它变量预测,并且在上一节中,将NA单独对待时,其IV已经比较高。因此这里直接将缺失的Cabin设置为一个默认值。
1 | data$Cabin <- as.factor(sapply(data$Cabin, function(x) ifelse(is.na(x), 'X', str_sub(x, start = 1, end = 1)))) |
1 | set.seed(415) |
一般情况下,应该将训练数据分为两部分,一部分用于训练,另一部分用于验证。或者使用k-fold交叉验证。本文将所有训练数据都用于训练,然后随机选取30%数据集用于验证。
1 | cv.summarize <- function(data.true, data.predict) { |
## [1] "Recall: 0.947976878612717"## [1] "Precision: 0.841025641025641"## [1] "Accuracy: 0.850187265917603"## [1] "AUC: 0.809094822285082"
1 | predict.result <- predict(model, data[(1+nrow(train)):(nrow(data)), ], OOB=TRUE, type = "response") |
该模型预测结果在Kaggle的得分为0.80383,排第992名,前992/6292=15.8%。
由于FamilySize结合了SibSp与Parch的信息,因此可以尝试将SibSp与Parch从特征变量中移除。
1 | set.seed(415) |
该模型预测结果在Kaggle的得分仍为0.80383。
由于Cabin的IV值相对较低,因此可以考虑将其从模型中移除。
1 | set.seed(415) |
该模型预测结果在Kaggle的得分仍为0.80383。
对于Name变量,上文从中派生出了Title变量。由于以下原因,可推测乘客的姓氏可能具有一定的预测作用
考虑到只出现一次的姓氏不可能同时出现在训练集和测试集中,不具辨识度和预测作用,因此将只出现一次的姓氏均命名为’Small’
1 | data$Surname <- sapply(data$Name, FUN=function(x) {strsplit(x, split='[,.]')[[1]][1]}) |
1 | set.seed(415) |
该模型预测结果在Kaggle的得分为0.82297,排第207名,前207/6292=3.3%
经试验,将缺失的Embarked补充为出现最多的S而非C,成绩有所提升。但该方法理论依据不强,并且该成绩只是Public排行榜成绩,并非最终成绩,并不能说明该方法一定优于其它方法。因此本文并不推荐该方法,只是作为一种可能的思路,供大家参考学习。1
2data$Embarked[c(62,830)] = "S"
data$Embarked <- factor(data$Embarked)
1 | set.seed(415) |
该模型预测结果在Kaggle的得分仍为0.82775,排第114名,前114/6292=1.8%
本文详述了如何通过数据预览,探索式数据分析,缺失数据填补,删除关联特征以及派生新特征等方法,在Kaggle的Titanic幸存预测这一分类问题竞赛中获得前2%排名的具体方法。
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自技术世界,原文链接 http://www.jasongj.com/spark/skew/
本文结合实例详细阐明了Spark数据倾斜的几种场景以及对应的解决方案,包括避免数据源倾斜,调整并行度,使用自定义Partitioner,使用Map侧Join代替Reduce侧Join,给倾斜Key加上随机前缀等。
对Spark/Hadoop这样的大数据系统来讲,数据量大并不可怕,可怕的是数据倾斜。
何谓数据倾斜?数据倾斜指的是,并行处理的数据集中,某一部分(如Spark或Kafka的一个Partition)的数据显著多于其它部分,从而使得该部分的处理速度成为整个数据集处理的瓶颈。
对于分布式系统而言,理想情况下,随着系统规模(节点数量)的增加,应用整体耗时线性下降。如果一台机器处理一批大量数据需要120分钟,当机器数量增加到三时,理想的耗时为120 / 3 = 40分钟,如下图所示
但是,上述情况只是理想情况,实际上将单机任务转换成分布式任务后,会有overhead,使得总的任务量较之单机时有所增加,所以每台机器的执行时间加起来比单台机器时更大。这里暂不考虑这些overhead,假设单机任务转换成分布式任务后,总任务量不变。
但即使如此,想做到分布式情况下每台机器执行时间是单机时的1 / N
,就必须保证每台机器的任务量相等。不幸的是,很多时候,任务的分配是不均匀的,甚至不均匀到大部分任务被分配到个别机器上,其它大部分机器所分配的任务量只占总得的小部分。比如一台机器负责处理80%的任务,另外两台机器各处理10%的任务,如下图所示
在上图中,机器数据增加为三倍,但执行时间只降为原来的80%,远低于理想值。
从上图可见,当出现数据倾斜时,小量任务耗时远高于其它任务,从而使得整体耗时过大,未能充分发挥分布式系统的并行计算优势。
另外,当发生数据倾斜时,部分任务处理的数据量过大,可能造成内存不足使得任务失败,并进而引进整个应用失败。
在Spark中,同一个Stage的不同Partition可以并行处理,而具有依赖关系的不同Stage之间是串行处理的。假设某个Spark Job分为Stage 0和Stage 1两个Stage,且Stage 1依赖于Stage 0,那Stage 0完全处理结束之前不会处理Stage 1。而Stage 0可能包含N个Task,这N个Task可以并行进行。如果其中N-1个Task都在10秒内完成,而另外一个Task却耗时1分钟,那该Stage的总时间至少为1分钟。换句话说,一个Stage所耗费的时间,主要由最慢的那个Task决定。
由于同一个Stage内的所有Task执行相同的计算,在排除不同计算节点计算能力差异的前提下,不同Task之间耗时的差异主要由该Task所处理的数据量决定。
Stage的数据来源主要分为如下两类
以Spark Stream通过DirectStream方式读取Kafka数据为例。由于Kafka的每一个Partition对应Spark的一个Task(Partition),所以Kafka内相关Topic的各Partition之间数据是否平衡,直接决定Spark处理该数据时是否会产生数据倾斜。
如《Kafka设计解析(一)- Kafka背景及架构介绍》一文所述,Kafka某一Topic内消息在不同Partition之间的分布,主要由Producer端所使用的Partition实现类决定。如果使用随机Partitioner,则每条消息会随机发送到一个Partition中,从而从概率上来讲,各Partition间的数据会达到平衡。此时源Stage(直接读取Kafka数据的Stage)不会产生数据倾斜。
但很多时候,业务场景可能会要求将具备同一特征的数据顺序消费,此时就需要将具有相同特征的数据放于同一个Partition中。一个典型的场景是,需要将同一个用户相关的PV信息置于同一个Partition中。此时,如果产生了数据倾斜,则需要通过其它方式处理。
Spark以通过textFile(path, minPartitions)
方法读取文件时,使用TextFileFormat。
对于不可切分的文件,每个文件对应一个Split从而对应一个Partition。此时各文件大小是否一致,很大程度上决定了是否存在数据源侧的数据倾斜。另外,对于不可切分的压缩文件,即使压缩后的文件大小一致,它所包含的实际数据量也可能差别很多,因为源文件数据重复度越高,压缩比越高。反过来,即使压缩文件大小接近,但由于压缩比可能差距很大,所需处理的数据量差距也可能很大。
此时可通过在数据生成端将不可切分文件存储为可切分文件,或者保证各文件包含数据量相同的方式避免数据倾斜。
对于可切分的文件,每个Split大小由如下算法决定。其中goalSize等于所有文件总大小除以minPartitions。而blockSize,如果是HDFS文件,由文件本身的block大小决定;如果是Linux本地文件,且使用本地模式,由fs.local.block.size
决定。1
2
3protected long computeSplitSize(long goalSize, long minSize, long blockSize) {
return Math.max(minSize, Math.min(goalSize, blockSize));
}
默认情况下各Split的大小不会太大,一般相当于一个Block大小(在Hadoop 2中,默认值为128MB),所以数据倾斜问题不明显。如果出现了严重的数据倾斜,可通过上述参数调整。
现通过脚本生成一些文本文件,并通过如下代码进行简单的单词计数。为避免Shuffle,只计单词总个数,不须对单词进行分组计数。1
2
3
4
5
6
7SparkConf sparkConf = new SparkConf()
.setAppName("ReadFileSkewDemo");
JavaSparkContext javaSparkContext = new JavaSparkContext(sparkConf);
long count = javaSparkContext.textFile(inputFile, minPartitions)
.flatMap((String line) -> Arrays.asList(line.split(" ")).iterator()).count();
System.out.printf("total words : %s", count);
javaSparkContext.stop();
总共生成如下11个csv文件,其中10个大小均为271.9MB,另外一个大小为8.5GB。
之后将8.5GB大小的文件使用gzip压缩,压缩后大小仅为25.3MB。
使用如上代码对未压缩文件夹进行单词计数操作。Split大小为 max(minSize, min(goalSize, blockSize) = max(1 B, min((271.9 10+8.5 1024) / 1 MB, 128 MB) = 128MB。无明显数据倾斜。
使用同样代码对包含压缩文件的文件夹进行同样的单词计数操作。未压缩文件的Split大小仍然为128MB,而压缩文件(gzip压缩)由于不可切分,且大小仅为25.3MB,因此该文件作为一个单独的Split/Partition。虽然该文件相对较小,但是它由8.5GB文件压缩而来,包含数据量是其它未压缩文件的32倍,因此处理该Split/Partition/文件的Task耗时为4.4分钟,远高于其它Task的10秒。
由于上述gzip压缩文件大小为25.3MB,小于128MB的Split大小,不能证明gzip压缩文件不可切分。现将minPartitions从默认的1设置为229,从而目标Split大小为max(minSize, min(goalSize, blockSize) = max(1 B, min((271.9 * 10+25.3) / 229 MB, 128 MB) = 12 MB。如果gzip压缩文件可切分,则所有Split/Partition大小都不会远大于12。反之,如果仍然存在25.3MB的Partition,则说明gzip压缩文件确实不可切分,在生成不可切分文件时需要如上文所述保证各文件数量大大致相同。
如下图所示,gzip压缩文件对应的Split/Partition大小为25.3MB,其它Split大小均为12MB左右。而该Task耗时4.7分钟,远大于其它Task的4秒。
适用场景
数据源侧存在不可切分文件,且文件内包含的数据量相差较大。
解决方案
尽量使用可切分的格式代替不可切分的格式,或者保证各文件实际包含数据量大致相同。
优势
可撤底消除数据源侧数据倾斜,效果显著。
劣势
数据源一般来源于外部系统,需要外部系统的支持。
Spark在做Shuffle时,默认使用HashPartitioner(非Hash Shuffle)对数据进行分区。如果并行度设置的不合适,可能造成大量不相同的Key对应的数据被分配到了同一个Task上,造成该Task所处理的数据远大于其它Task,从而造成数据倾斜。
如果调整Shuffle时的并行度,使得原本被分配到同一Task的不同Key发配到不同Task上处理,则可降低原Task所需处理的数据量,从而缓解数据倾斜问题造成的短板效应。
现有一张测试表,名为student_external,内有10.5亿条数据,每条数据有一个唯一的id值。现从中取出id取值为9亿到10.5亿的共1.5亿条数据,并通过一些处理,使得id为9亿到9.4亿间的所有数据对12取模后余数为8(即在Shuffle并行度为12时该数据集全部被HashPartition分配到第8个Task),其它数据集对其id除以100取整,从而使得id大于9.4亿的数据在Shuffle时可被均匀分配到所有Task中,而id小于9.4亿的数据全部分配到同一个Task中。处理过程如下
1 | INSERT OVERWRITE TABLE test |
通过上述处理,一份可能造成后续数据倾斜的测试数据即以准备好。接下来,使用Spark读取该测试数据,并通过groupByKey(12)
对id分组处理,且Shuffle并行度为12。代码如下
1 | public class SparkDataSkew { |
本次实验所使用集群节点数为4,每个节点可被Yarn使用的CPU核数为16,内存为16GB。使用如下方式提交上述应用,将启动4个Executor,每个Executor可使用核数为12(该配置并非生产环境下的最优配置,仅用于本文实验),可用内存为12GB。1
spark-submit --queue ambari --num-executors 4 --executor-cores 12 --executor-memory 12g --class com.jasongj.spark.driver.SparkDataSkew --master yarn --deploy-mode client SparkExample-with-dependencies-1.0.jar
GroupBy Stage的Task状态如下图所示,Task 8处理的记录数为4500万,远大于(9倍于)其它11个Task处理的500万记录。而Task 8所耗费的时间为38秒,远高于其它11个Task的平均时间(16秒)。整个Stage的时间也为38秒,该时间主要由最慢的Task 8决定。
在这种情况下,可以通过调整Shuffle并行度,使得原来被分配到同一个Task(即该例中的Task 8)的不同Key分配到不同Task,从而降低Task 8所需处理的数据量,缓解数据倾斜。
通过groupByKey(48)
将Shuffle并行度调整为48,重新提交到Spark。新的Job的GroupBy Stage所有Task状态如下图所示。
从上图可知,记录数最多的Task 20处理的记录数约为1125万,相比于并行度为12时Task 8的4500万,降低了75%左右,而其耗时从原来Task 8的38秒降到了24秒。
在这种场景下,调整并行度,并不意味着一定要增加并行度,也可能是减小并行度。如果通过groupByKey(11)
将Shuffle并行度调整为11,重新提交到Spark。新Job的GroupBy Stage的所有Task状态如下图所示。
从上图可见,处理记录数最多的Task 6所处理的记录数约为1045万,耗时为23秒。处理记录数最少的Task 1处理的记录数约为545万,耗时12秒。
适用场景
大量不同的Key被分配到了相同的Task造成该Task数据量过大。
解决方案
调整并行度。一般是增大并行度,但有时如本例减小并行度也可达到效果。
优势
实现简单,可在需要Shuffle的操作算子上直接设置并行度或者使用spark.default.parallelism
设置。如果是Spark SQL,还可通过SET spark.sql.shuffle.partitions=[num_tasks]
设置并行度。可用最小的代价解决问题。一般如果出现数据倾斜,都可以通过这种方法先试验几次,如果问题未解决,再尝试其它方法。
劣势
适用场景少,只能将分配到同一Task的不同Key分散开,但对于同一Key倾斜严重的情况该方法并不适用。并且该方法一般只能缓解数据倾斜,没有彻底消除问题。从实践经验来看,其效果一般。
使用自定义的Partitioner(默认为HashPartitioner),将原本被分配到同一个Task的不同Key分配到不同Task。
以上述数据集为例,继续将并发度设置为12,但是在groupByKey
算子上,使用自定义的Partitioner
(实现如下)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16.groupByKey(new Partitioner() {
public int numPartitions() {
return 12;
}
public int getPartition(Object key) {
int id = Integer.parseInt(key.toString());
if(id >= 9500000 && id <= 9500084 && ((id - 9500000) % 12) == 0) {
return (id - 9500000) / 12;
} else {
return id % 12;
}
}
})
由下图可见,使用自定义Partition后,耗时最长的Task 6处理约1000万条数据,用时15秒。并且各Task所处理的数据集大小相当。
适用场景
大量不同的Key被分配到了相同的Task造成该Task数据量过大。
解决方案
使用自定义的Partitioner实现类代替默认的HashPartitioner,尽量将所有不同的Key均匀分配到不同的Task中。
优势
不影响原有的并行度设计。如果改变并行度,后续Stage的并行度也会默认改变,可能会影响后续Stage。
劣势
适用场景有限,只能将不同Key分散开,对于同一Key对应数据集非常大的场景不适用。效果与调整并行度类似,只能缓解数据倾斜而不能完全消除数据倾斜。而且需要根据数据特点自定义专用的Partitioner,不够灵活。
通过Spark的Broadcast机制,将Reduce侧Join转化为Map侧Join,避免Shuffle从而完全消除Shuffle带来的数据倾斜。
通过如下SQL创建一张具有倾斜Key且总记录数为1.5亿的大表test。1
2
3
4
5
6INSERT OVERWRITE TABLE test
SELECT CAST(CASE WHEN id < 980000000 THEN (95000000 + (CAST (RAND() * 4 AS INT) + 1) * 48 )
ELSE CAST(id/10 AS INT) END AS STRING),
name
FROM student_external
WHERE id BETWEEN 900000000 AND 1050000000;
使用如下SQL创建一张数据分布均匀且总记录数为50万的小表test_new。1
2
3
4
5INSERT OVERWRITE TABLE test_new
SELECT CAST(CAST(id/10 AS INT) AS STRING),
name
FROM student_delta_external
WHERE id BETWEEN 950000000 AND 950500000;
直接通过Spark Thrift Server提交如下SQL将表test与表test_new进行Join并将Join结果存于表test_join中。1
2
3
4
5INSERT OVERWRITE TABLE test_join
SELECT test_new.id, test_new.name
FROM test
JOIN test_new
ON test.id = test_new.id;
该SQL对应的DAG如下图所示。从该图可见,该执行过程总共分为三个Stage,前两个用于从Hive中读取数据,同时二者进行Shuffle,通过最后一个Stage进行Join并将结果写入表test_join中。
从下图可见,Join Stage各Task处理的数据倾斜严重,处理数据量最大的Task耗时7.1分钟,远高于其它无数据倾斜的Task约2秒的耗时。
接下来,尝试通过Broadcast实现Map侧Join。实现Map侧Join的方法,并非直接通过CACHE TABLE test_new
将小表test_new进行cache。现通过如下SQL进行Join。1
2
3
4
5
6CACHE TABLE test_new;
INSERT OVERWRITE TABLE test_join
SELECT test_new.id, test_new.name
FROM test
JOIN test_new
ON test.id = test_new.id;
通过如下DAG图可见,该操作仍分为三个Stage,且仍然有Shuffle存在,唯一不同的是,小表的读取不再直接扫描Hive表,而是扫描内存中缓存的表。
并且数据倾斜仍然存在。如下图所示,最慢的Task耗时为7.1分钟,远高于其它Task的约2秒。
正确的使用Broadcast实现Map侧Join的方式是,通过SET spark.sql.autoBroadcastJoinThreshold=104857600;
将Broadcast的阈值设置得足够大。
再次通过如下SQL进行Join。1
2
3
4
5
6SET spark.sql.autoBroadcastJoinThreshold=104857600;
INSERT OVERWRITE TABLE test_join
SELECT test_new.id, test_new.name
FROM test
JOIN test_new
ON test.id = test_new.id;
通过如下DAG图可见,该方案只包含一个Stage。
并且从下图可见,各Task耗时相当,无明显数据倾斜现象。并且总耗时为1.5分钟,远低于Reduce侧Join的7.3分钟。
适用场景
参与Join的一边数据集足够小,可被加载进Driver并通过Broadcast方法广播到各个Executor中。
解决方案
在Java/Scala代码中将小数据集数据拉取到Driver,然后通过Broadcast方案将小数据集的数据广播到各Executor。或者在使用SQL前,将Broadcast的阈值调整得足够大,从而使用Broadcast生效。进而将Reduce侧Join替换为Map侧Join。
优势
避免了Shuffle,彻底消除了数据倾斜产生的条件,可极大提升性能。
劣势
要求参与Join的一侧数据集足够小,并且主要适用于Join的场景,不适合聚合的场景,适用条件有限。
为数据量特别大的Key增加随机前/后缀,使得原来Key相同的数据变为Key不相同的数据,从而使倾斜的数据集分散到不同的Task中,彻底解决数据倾斜问题。Join另一则的数据中,与倾斜Key对应的部分数据,与随机前缀集作笛卡尔乘积,从而保证无论数据倾斜侧倾斜Key如何加前缀,都能与之正常Join。
通过如下SQL,将id为9亿到9.08亿共800万条数据的id转为9500048或者9500096,其它数据的id除以100取整。从而该数据集中,id为9500048和9500096的数据各400万,其它id对应的数据记录数均为100条。这些数据存于名为test的表中。
对于另外一张小表test_new,取出50万条数据,并将id(递增且唯一)除以100取整,使得所有id都对应100条数据。
1 | INSERT OVERWRITE TABLE test |
通过如下代码,读取test表对应的文件夹内的数据并转换为JavaPairRDD存于leftRDD中,同样读取test表对应的数据存于rightRDD中。通过RDD的join算子对leftRDD与rightRDD进行Join,并指定并行度为48。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
30public class SparkDataSkew{
public static void main(String[] args) {
SparkConf sparkConf = new SparkConf();
sparkConf.setAppName("DemoSparkDataFrameWithSkewedBigTableDirect");
sparkConf.set("spark.default.parallelism", String.valueOf(parallelism));
JavaSparkContext javaSparkContext = new JavaSparkContext(sparkConf);
JavaPairRDD<String, String> leftRDD = javaSparkContext.textFile("hdfs://hadoop1:8020/apps/hive/warehouse/default/test/")
.mapToPair((String row) -> {
String[] str = row.split(",");
return new Tuple2<String, String>(str[0], str[1]);
});
JavaPairRDD<String, String> rightRDD = javaSparkContext.textFile("hdfs://hadoop1:8020/apps/hive/warehouse/default/test_new/")
.mapToPair((String row) -> {
String[] str = row.split(",");
return new Tuple2<String, String>(str[0], str[1]);
});
leftRDD.join(rightRDD, parallelism)
.mapToPair((Tuple2<String, Tuple2<String, String>> tuple) -> new Tuple2<String, String>(tuple._1(), tuple._2()._2()))
.foreachPartition((Iterator<Tuple2<String, String>> iterator) -> {
AtomicInteger atomicInteger = new AtomicInteger();
iterator.forEachRemaining((Tuple2<String, String> tuple) -> atomicInteger.incrementAndGet());
});
javaSparkContext.stop();
javaSparkContext.close();
}
}
从下图可看出,整个Join耗时1分54秒,其中Join Stage耗时1.7分钟。
通过分析Join Stage的所有Task可知,在其它Task所处理记录数为192.71万的同时Task 32的处理的记录数为992.72万,故它耗时为1.7分钟,远高于其它Task的约10秒。这与上文准备数据集时,将id为9500048为9500096对应的数据量设置非常大,其它id对应的数据集非常均匀相符合。
现通过如下操作,实现倾斜Key的分散处理
具体实现代码如下
1 | public class SparkDataSkew{ |
从下图可看出,整个Join耗时58秒,其中Join Stage耗时33秒。
通过分析Join Stage的所有Task可知
实际上,由于倾斜Key与非倾斜Key的操作完全独立,可并行进行。而本实验受限于可用总核数为48,可同时运行的总Task数为48,故而该方案只是将总耗时减少一半(效率提升一倍)。如果资源充足,可并发执行Task数增多,该方案的优势将更为明显。在实际项目中,该方案往往可提升数倍至10倍的效率。
适用场景
两张表都比较大,无法使用Map则Join。其中一个RDD有少数几个Key的数据量过大,另外一个RDD的Key分布较为均匀。
解决方案
将有数据倾斜的RDD中倾斜Key对应的数据集单独抽取出来加上随机前缀,另外一个RDD每条数据分别与随机前缀结合形成新的RDD(相当于将其数据增到到原来的N倍,N即为随机前缀的总个数),然后将二者Join并去掉前缀。然后将不包含倾斜Key的剩余数据进行Join。最后将两次Join的结果集通过union合并,即可得到全部Join结果。
优势
相对于Map则Join,更能适应大数据集的Join。如果资源充足,倾斜部分数据集与非倾斜部分数据集可并行进行,效率提升明显。且只针对倾斜部分的数据做数据扩展,增加的资源消耗有限。
劣势
如果倾斜Key非常多,则另一侧数据膨胀非常大,此方案不适用。而且此时对倾斜Key与非倾斜Key分开处理,需要扫描数据集两遍,增加了开销。
如果出现数据倾斜的Key比较多,上一种方法将这些大量的倾斜Key分拆出来,意义不大。此时更适合直接对存在数据倾斜的数据集全部加上随机前缀,然后对另外一个不存在严重数据倾斜的数据集整体与随机前缀集作笛卡尔乘积(即将数据量扩大N倍)。
这里给出示例代码,读者可参考上文中分拆出少数倾斜Key添加随机前缀的方法,自行测试。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
48public class SparkDataSkew {
public static void main(String[] args) {
SparkConf sparkConf = new SparkConf();
sparkConf.setAppName("ResolveDataSkewWithNAndRandom");
sparkConf.set("spark.default.parallelism", parallelism + "");
JavaSparkContext javaSparkContext = new JavaSparkContext(sparkConf);
JavaPairRDD<String, String> leftRDD = javaSparkContext.textFile("hdfs://hadoop1:8020/apps/hive/warehouse/default/test/")
.mapToPair((String row) -> {
String[] str = row.split(",");
return new Tuple2<String, String>(str[0], str[1]);
});
JavaPairRDD<String, String> rightRDD = javaSparkContext.textFile("hdfs://hadoop1:8020/apps/hive/warehouse/default/test_new/")
.mapToPair((String row) -> {
String[] str = row.split(",");
return new Tuple2<String, String>(str[0], str[1]);
});
List<String> addList = new ArrayList<String>();
for(int i = 1; i <=48; i++) {
addList.add(i + "");
}
Broadcast<List<String>> addListKeys = javaSparkContext.broadcast(addList);
JavaPairRDD<String, String> leftRandomRDD = leftRDD.mapToPair((Tuple2<String, String> tuple) -> new Tuple2<String, String>(new Random().nextInt(48) + "," + tuple._1(), tuple._2()));
JavaPairRDD<String, String> rightNewRDD = rightRDD
.flatMapToPair((Tuple2<String, String> tuple) -> addListKeys.value().stream()
.map((String i) -> new Tuple2<String, String>( i + "," + tuple._1(), tuple._2()))
.collect(Collectors.toList())
.iterator()
);
JavaPairRDD<String, String> joinRDD = leftRandomRDD
.join(rightNewRDD, parallelism)
.mapToPair((Tuple2<String, Tuple2<String, String>> tuple) -> new Tuple2<String, String>(tuple._1().split(",")[1], tuple._2()._2()));
joinRDD.foreachPartition((Iterator<Tuple2<String, String>> iterator) -> {
AtomicInteger atomicInteger = new AtomicInteger();
iterator.forEachRemaining((Tuple2<String, String> tuple) -> atomicInteger.incrementAndGet());
});
javaSparkContext.stop();
javaSparkContext.close();
}
}
适用场景
一个数据集存在的倾斜Key比较多,另外一个数据集数据分布比较均匀。
优势
对大部分场景都适用,效果不错。
劣势
需要将一个数据集整体扩大N倍,会增加资源消耗。
对于数据倾斜,并无一个统一的一劳永逸的方法。更多的时候,是结合数据特点(数据集大小,倾斜Key的多少等)综合使用上文所述的多种方法。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/java/nio_reactor/
同步I/O 每个请求必须逐个地被处理,一个请求的处理会导致整个流程的暂时等待,这些事件无法并发地执行。用户线程发起I/O请求后需要等待或者轮询内核I/O操作完成后才能继续执行。
异步I/O 多个请求可以并发地执行,一个请求或者任务的执行不会导致整个流程的暂时等待。用户线程发起I/O请求后仍然继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数。
阻塞 某个请求发出后,由于该请求操作需要的条件不满足,请求操作一直阻塞,不会返回,直到条件满足。
非阻塞 请求发出后,若该请求需要的条件不满足,则立即返回一个标志信息告知条件不满足,而不会一直等待。一般需要通过循环判断请求条件是否满足来获取请求结果。
需要注意的是,阻塞并不等价于同步,而非阻塞并非等价于异步。事实上这两组概念描述的是I/O模型中的两个不同维度。
同步和异步着重点在于多个任务执行过程中,后发起的任务是否必须等先发起的任务完成之后再进行。而不管先发起的任务请求是阻塞等待完成,还是立即返回通过循环等待请求成功。
而阻塞和非阻塞重点在于请求的方法是否立即返回(或者说是否在条件不满足时被阻塞)。
Unix 下共有五种 I/O 模型:
如上文所述,阻塞I/O下请求无法立即完成则保持阻塞。阻塞I/O分为如下两个阶段。
非阻塞I/O请求包含如下三个阶段
一般很少直接使用这种模型,而是在其他I/O模型中使用非阻塞I/O 这一特性。这种方式对单个I/O 请求意义不大,但给I/O多路复用提供了条件。
I/O多路复用会用到select或者poll函数,这两个函数也会使线程阻塞,但是和阻塞I/O所不同的是,这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数。
从流程上来看,使用select函数进行I/O请求和同步阻塞模型没有太大的区别,甚至还多了添加监视Channel,以及调用select函数的额外操作,增加了额外工作。但是,使用 select以后最大的优势是用户可以在一个线程内同时处理多个Channel的I/O请求。用户可以注册多个Channel,然后不断地调用select读取被激活的Channel,即可达到在同一个线程内同时处理多个I/O请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
调用select/poll该方法由一个用户态线程负责轮询多个Channel,直到某个阶段1的数据就绪,再通知实际的用户线程执行阶段2的拷贝。 通过一个专职的用户态线程执行非阻塞I/O轮询,模拟实现了阶段一的异步化。
首先我们允许socket进行信号驱动I/O,并安装一个信号处理函数,线程继续运行并不阻塞。当数据准备好时,线程会收到一个SIGIO 信号,可以在信号处理函数中调用I/O操作函数处理数据。
调用aio_read 函数,告诉内核描述字,缓冲区指针,缓冲区大小,文件偏移以及通知的方式,然后立即返回。当内核将数据拷贝到缓冲区后,再通知应用程序。所以异步I/O模式下,阶段1和阶段2全部由内核完成,完成不需要用户线程的参与。
除异步I/O外,其它四种模型的阶段2基本相同,都是从内核态拷贝数据到用户态。区别在于阶段1不同。前四种都属于同步I/O。
上一章所述Unix中的五种I/O模型,除信号驱动I/O外,Java对其它四种I/O模型都有所支持。其中Java最早提供的blocking I/O即是阻塞I/O,而NIO即是非阻塞I/O,同时通过NIO实现的Reactor模式即是I/O复用模型的实现,通过AIO实现的Proactor模式即是异步I/O模型的实现。
Java IO是面向流的,每次从流(InputStream/OutputStream)中读一个或多个字节,直到读取完所有字节,它们没有被缓存在任何地方。另外,它不能前后移动流中的数据,如需前后移动处理,需要先将其缓存至一个缓冲区。
Java NIO面向缓冲,数据会被读取到一个缓冲区,需要时可以在缓冲区中前后移动处理,这增加了处理过程的灵活性。但与此同时在处理缓冲区前需要检查该缓冲区中是否包含有所需要处理的数据,并需要确保更多数据读入缓冲区时,不会覆盖缓冲区内尚未处理的数据。
Java IO的各种流是阻塞的。当某个线程调用read()或write()方法时,该线程被阻塞,直到有数据被读取到或者数据完全写入。阻塞期间该线程无法处理任何其它事情。
Java NIO为非阻塞模式。读写请求并不会阻塞当前线程,在数据可读/写前当前线程可以继续做其它事情,所以一个单独的线程可以管理多个输入和输出通道。
Java NIO的选择器允许一个单独的线程同时监视多个通道,可以注册多个通道到同一个选择器上,然后使用一个单独的线程来“选择”已经就绪的通道。这种“选择”机制为一个单独线程管理多个通道提供了可能。
Java NIO中提供的FileChannel拥有transferTo和transferFrom两个方法,可直接把FileChannel中的数据拷贝到另外一个Channel,或者直接把另外一个Channel中的数据拷贝到FileChannel。该接口常被用于高效的网络/文件的数据传输和大文件拷贝。在操作系统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态,再从用户态拷贝到目标通道的内核态,同时也避免了两次用户态和内核态间的上下文切换,也即使用了“零拷贝”,所以其性能一般高于Java IO中提供的方法。
使用FileChannel的零拷贝将本地文件内容传输到网络的示例代码如下所示。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class NIOClient {
public static void main(String[] args) throws IOException, InterruptedException {
SocketChannel socketChannel = SocketChannel.open();
InetSocketAddress address = new InetSocketAddress(1234);
socketChannel.connect(address);
RandomAccessFile file = new RandomAccessFile(
NIOClient.class.getClassLoader().getResource("test.txt").getFile(), "rw");
FileChannel channel = file.getChannel();
channel.transferTo(0, channel.size(), socketChannel);
channel.close();
file.close();
socketChannel.close();
}
}
使用阻塞I/O的服务器,一般使用循环,逐个接受连接请求并读取数据,然后处理下一个请求。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
26public class IOServer {
private static final Logger LOGGER = LoggerFactory.getLogger(IOServer.class);
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(2345));
} catch (IOException ex) {
LOGGER.error("Listen failed", ex);
return;
}
try{
while(true) {
Socket socket = serverSocket.accept();
InputStream inputstream = socket.getInputStream();
LOGGER.info("Received message {}", IOUtils.toString(inputstream));
IOUtils.closeQuietly(inputstream);
}
} catch(IOException ex) {
IOUtils.closeQuietly(serverSocket);
LOGGER.error("Read message failed", ex);
}
}
}
上例使用单线程逐个处理所有请求,同一时间只能处理一个请求,等待I/O的过程浪费大量CPU资源,同时无法充分使用多CPU的优势。下面是使用多线程对阻塞I/O模型的改进。一个连接建立成功后,创建一个单独的线程处理其I/O操作。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
30public class IOServerMultiThread {
private static final Logger LOGGER = LoggerFactory.getLogger(IOServerMultiThread.class);
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(2345));
} catch (IOException ex) {
LOGGER.error("Listen failed", ex);
return;
}
try{
while(true) {
Socket socket = serverSocket.accept();
new Thread( () -> {
try{
InputStream inputstream = socket.getInputStream();
LOGGER.info("Received message {}", IOUtils.toString(inputstream));
IOUtils.closeQuietly(inputstream);
} catch (IOException ex) {
LOGGER.error("Read message failed", ex);
}
}).start();
}
} catch(IOException ex) {
IOUtils.closeQuietly(serverSocket);
LOGGER.error("Accept connection failed", ex);
}
}
}
为了防止连接请求过多,导致服务器创建的线程数过多,造成过多线程上下文切换的开销。可以通过线程池来限制创建的线程数,如下所示。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
35public class IOServerThreadPool {
private static final Logger LOGGER = LoggerFactory.getLogger(IOServerThreadPool.class);
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(2345));
} catch (IOException ex) {
LOGGER.error("Listen failed", ex);
return;
}
try{
while(true) {
Socket socket = serverSocket.accept();
executorService.submit(() -> {
try{
InputStream inputstream = socket.getInputStream();
LOGGER.info("Received message {}", IOUtils.toString(new InputStreamReader(inputstream)));
} catch (IOException ex) {
LOGGER.error("Read message failed", ex);
}
});
}
} catch(IOException ex) {
try {
serverSocket.close();
} catch (IOException e) {
}
LOGGER.error("Accept connection failed", ex);
}
}
}
精典的Reactor模式示意图如下所示。
在Reactor模式中,包含如下角色
最简单的Reactor模式实现代码如下所示。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
40public class NIOServer {
private static final Logger LOGGER = LoggerFactory.getLogger(NIOServer.class);
public static void main(String[] args) throws IOException {
Selector selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress(1234));
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (selector.select() > 0) {
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
ServerSocketChannel acceptServerSocketChannel = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = acceptServerSocketChannel.accept();
socketChannel.configureBlocking(false);
LOGGER.info("Accept request from {}", socketChannel.getRemoteAddress());
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int count = socketChannel.read(buffer);
if (count <= 0) {
socketChannel.close();
key.cancel();
LOGGER.info("Received invalide data, close the connection");
continue;
}
LOGGER.info("Received message {}", new String(buffer.array()));
}
keys.remove(key);
}
}
}
}
为了方便阅读,上示代码将Reactor模式中的所有角色放在了一个类中。
从上示代码中可以看到,多个Channel可以注册到同一个Selector对象上,实现了一个线程同时监控多个请求状态(Channel)。同时注册时需要指定它所关注的事件,例如上示代码中socketServerChannel对象只注册了OP_ACCEPT事件,而socketChannel对象只注册了OP_READ事件。
selector.select()
是阻塞的,当有至少一个通道可用时该方法返回可用通道个数。同时该方法只捕获Channel注册时指定的所关注的事件。
经典Reactor模式中,尽管一个线程可同时监控多个请求(Channel),但是所有读/写请求以及对新连接请求的处理都在同一个线程中处理,无法充分利用多CPU的优势,同时读/写操作也会阻塞对新连接请求的处理。因此可以引入多线程,并行处理多个读/写操作,如下图所示。
多线程Reactor模式示例代码如下所示。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
35public class NIOServer {
private static final Logger LOGGER = LoggerFactory.getLogger(NIOServer.class);
public static void main(String[] args) throws IOException {
Selector selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress(1234));
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
if(selector.selectNow() < 0) {
continue;
}
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();
while(iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
ServerSocketChannel acceptServerSocketChannel = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = acceptServerSocketChannel.accept();
socketChannel.configureBlocking(false);
LOGGER.info("Accept request from {}", socketChannel.getRemoteAddress());
SelectionKey readKey = socketChannel.register(selector, SelectionKey.OP_READ);
readKey.attach(new Processor());
} else if (key.isReadable()) {
Processor processor = (Processor) key.attachment();
processor.process(key);
}
}
}
}
}
从上示代码中可以看到,注册完SocketChannel的OP_READ事件后,可以对相应的SelectionKey attach一个对象(本例中attach了一个Processor对象,该对象处理读请求),并且在获取到可读事件后,可以取出该对象。
注:attach对象及取出该对象是NIO提供的一种操作,但该操作并非Reactor模式的必要操作,本文使用它,只是为了方便演示NIO的接口。
具体的读请求处理在如下所示的Processor类中。该类中设置了一个静态的线程池处理所有请求。而process方法并不直接处理I/O请求,而是把该I/O操作提交给上述线程池去处理,这样就充分利用了多线程的优势,同时将对新连接的处理和读/写操作的处理放在了不同的线程中,读/写操作不再阻塞对新连接请求的处理。
1 | public class Processor { |
Netty中使用的Reactor模式,引入了多Reactor,也即一个主Reactor负责监控所有的连接请求,多个子Reactor负责监控并处理读/写请求,减轻了主Reactor的压力,降低了主Reactor压力太大而造成的延迟。
并且每个子Reactor分别属于一个独立的线程,每个成功连接后的Channel的所有操作由同一个线程处理。这样保证了同一请求的所有状态和上下文在同一个线程中,避免了不必要的上下文切换,同时也方便了监控请求响应状态。
多Reactor模式示意图如下所示。
多Reactor示例代码如下所示。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
35public class NIOServer {
private static final Logger LOGGER = LoggerFactory.getLogger(NIOServer.class);
public static void main(String[] args) throws IOException {
Selector selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress(1234));
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
int coreNum = Runtime.getRuntime().availableProcessors();
Processor[] processors = new Processor[coreNum];
for (int i = 0; i < processors.length; i++) {
processors[i] = new Processor();
}
int index = 0;
while (selector.select() > 0) {
Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
keys.remove(key);
if (key.isAcceptable()) {
ServerSocketChannel acceptServerSocketChannel = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = acceptServerSocketChannel.accept();
socketChannel.configureBlocking(false);
LOGGER.info("Accept request from {}", socketChannel.getRemoteAddress());
Processor processor = processors[(int) ((index++) % coreNum)];
processor.addChannel(socketChannel);
processor.wakeup();
}
}
}
}
}
如上代码所示,本文设置的子Reactor个数是当前机器可用核数的两倍(与Netty默认的子Reactor个数一致)。对于每个成功连接的SocketChannel,通过round robin的方式交给不同的子Reactor。
子Reactor对SocketChannel的处理如下所示。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
52public class Processor {
private static final Logger LOGGER = LoggerFactory.getLogger(Processor.class);
private static final ExecutorService service =
Executors.newFixedThreadPool(2 * Runtime.getRuntime().availableProcessors());
private Selector selector;
public Processor() throws IOException {
this.selector = SelectorProvider.provider().openSelector();
start();
}
public void addChannel(SocketChannel socketChannel) throws ClosedChannelException {
socketChannel.register(this.selector, SelectionKey.OP_READ);
}
public void wakeup() {
this.selector.wakeup();
}
public void start() {
service.submit(() -> {
while (true) {
if (selector.select(500) <= 0) {
continue;
}
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isReadable()) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
SocketChannel socketChannel = (SocketChannel) key.channel();
int count = socketChannel.read(buffer);
if (count < 0) {
socketChannel.close();
key.cancel();
LOGGER.info("{}\t Read ended", socketChannel);
continue;
} else if (count == 0) {
LOGGER.info("{}\t Message size is 0", socketChannel);
continue;
} else {
LOGGER.info("{}\t Read message {}", socketChannel, new String(buffer.array()));
}
}
}
}
});
}
}
在Processor中,同样创建了一个静态的线程池,且线程池的大小为机器核数的两倍。每个Processor实例均包含一个Selector实例。同时每次获取Processor实例时均提交一个任务到该线程池,并且该任务正常情况下一直循环处理,不会停止。而提交给该Processor的SocketChannel通过在其Selector注册事件,加入到相应的任务中。由此实现了每个子Reactor包含一个Selector对象,并由一个独立的线程处理。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/uml/class_diagram/
在UML 2.*的13种图形中,类图是使用频率最高的UML图之一。类图用于描述系统中所包含的类以及它们之间的相互关系,帮助开发人员理解系统,它是系统分析和设计阶段的重要产物,也是系统编码和测试的重要模型依据。
在UML类图中,类使用包含类名、属性和方法且带有分隔线的长方形来表示。如一个Employee类,它包含private属性age,protected属性name,public属性email,package属性gender,public方法work()。其UML类图表示如下图所示。
UML规定类图中属性的表示方式为1
可见性 名称 : 类型 [=缺省值]
方法表示形式为1
可见性 方法名 [参数名 : 参数类型] : 返回值类型
方法的多个参数间用逗号隔开,无返回值时,其类型为void
+
表示-
表示#
表示~
表示接口的表示形式与类类似,区别在于接口名须以尖括号包裹,同时接口无属性框,方法可见性只可能为public
,这是由接口本身的特性决定的。
依赖关系是一种偶然的、较弱的使用关系,特定事物的改变可能影响到使用该事情的其它事物,在需要表示一个事物使用另一个事物时使用依赖关系。
UML中使用带箭头的虚线表示类间的依赖(Dependency)关系,箭头由依赖类指向被依赖类。下图表示Dirver类依赖于Car类
关联(Association)关系是一种结构化关系,用于表示一类对象与另一类对象之间的联系。在Java中实现关联关系时,通常将一个类的对象作为另一个类的成员变量。
在UML类图中,用实线连接有关联关系的类,并可在关联线上标注角色名或关系名。
在UML中,关联关系包含如下四种形式
默认情况下,关联是双向的。例如数据库管理员(DBA)管理数据库(DB),同时每个数据库都被某位管理员管理。因此,DBA和DB之间具有双向关联关系,如下图所示。
从上图可看出,双向关联的类的实例,互相持有对方的实例,并且可在关联线上注明二者的关系,必须同时注明两种关系(如上图中的manage和managed by)。
单向关联用带箭头的实线表示,同时一方持有另一方的实例,并且由于是单向关联,如果在关联线上注明关系,则只可注明单向的关系,如下图所示。
自关联是指属性类型为该类本身。例如在链表中,每个节点持有下一个节点的实例,如下图所示。
多重性(Multiplicity)关联关系,表示两个对象在数量上的对应关系。在UML类图中,对象间的多重性可在关联线上用一个数字或数字范围表示。常见的多重性表示方式如下表所示。
表示方式 | 多重性说明 |
---|---|
1..1 | 另一个类的一个对象只与该类的一个对象有关系 |
0..* | 另一个类的一个对象只与该类的零个或多个对象有关系 |
1..* | 另一个类的一个对象与该类的一个或多个对象有关系 |
0..1 | 另一个类的一个对象与该类的对象没关系或者只与该类的一个对象有关系 |
m..n | 另一个类的一个对象与该类最少m,最多n个对象有关系 |
例如一个网页可能没有可点击按钮,也可能有多个按钮,但是该页面中的一个按钮只属于该页面,其关联多重性如下图所示。
聚合(Aggregation)关系表示整体与部分的关系。在聚合关系中,部分对象是整体对象的一部分,但是部分对象可以脱离整体对象独立存在,也即整体对象并不控制部分对象的生命周期。从代码实现上来讲,部分对象不由整体对象创建,一般通过整体类的带参构造方法或者Setter方法或其它业务方法传入到整体对象,并且有整体对象以外的对象持有部分对象的引用。
在UML类图中,聚合关系由带箭头的实线表示,并且实线的起点处以空心菱形表示,如下图所示。
《Java设计模式(六)代理模式 vs. 装饰模式》一文中所述装饰模式中,装饰类的对象与被装饰类的对象即为聚合关系。
组合(Composition)关系也表示类之间整体和部分的关系,但是在组合关系中整体对象控制成员对象的生命周期,一旦整体对象不存在了,成员对象也即随之消亡。
从代码实现上看,一般在整体类的构造方法中直接实例化成员类,并且除整体类对象外,其它类的对象无法获取该对象的引用。
在UML类图中,组合关系的表示方式与聚合关系类似,区别在于实线以实心菱形表示。
《Java设计模式(六)代理模式 vs. 装饰模式》一文中所述代理模式中,代理类的对象与被代理类的对象即为组合关系。
泛化(Generalization)关系,用于描述父类与子类之间的关系,父类又称作超类或者其类,子类又称为派生类。注意,父类和子类都可为抽象类或者具体类。
在Java中,我们使用面向对象的三大特性之一——继承来实现泛化关系,具体来说会用到extends
关键字。
在UML类图中,泛化关系用带空心三角形(指向父类)的实线表示。并且子类中不需要标明其从父类继承下来的属性和方法,只须注明其新增的属性和方法即可。
很多面向对象编程语言(如Java)中都引入了接口的概念。接口与接口之间可以有类与类之间类似的继承和依赖关系。同时接口与类之间还存在一种实现(Realization)关系,在这种关系中,类实现了接口中声明的方法。
在UML类图中,类与接口间的实现关系用带空心三角形的虚线表示。同时类中也需要列出接口中所声明的所有方法(这一点与类间的继承关系表示不同)。
聚合关系与组合关系都表示整体与部分的关系,有何区别?
聚合关系中,部分对象的生命周期独立于整体对象的生命周期,或者整体对象消亡后部分对象仍然可以独立存在,同时在代码中一般通过整体类的带参构造方法或Setter方法将部分类对象传入整体类的对象,UML中表示聚合关系的实线以空心菱形开始。
组合关系中,部分类对象的生命周期由整体对象控制,一旦整体对象消亡,部分类的对象随即消亡。代码中一般在整体类的构造方法内创建部分类的对象,UML中表示组合关系的实线以实心菱形开始。
同时在组合关系中,部分类的对象只属于某一个确定的整体类对象;而在聚合关系中,部分类对象可以属于一个或多个整体类对象。
如同《Java设计模式(六)代理模式 vs. 装饰模式》一文中所述代理模式中,代理类的对象与被代理类的对象即为组合关系。装饰模式中,装饰类的对象与被装饰类的对象即为聚合关系。
聚合关系、组合关系与关联关系有何区别和联系?
聚合关系、组合关系和关联关系实质上是对象间的关系(继承和实现是类与类和类与接口间的关系)。从语意上讲,关联关系中两种对象间一般是平等的,而聚合和组合则代表整体和部分间的关系。而聚合与组合的区别主要体现在实现上和生命周期的管理上。
依赖关系与关联关系的区别是?
依赖关系是较弱的关系,一般表现为在局部变量中使用被依赖类的对象、以被依赖类的对象作为方法参数以及使用被依赖类的静态方法。而关联关系是相对较强的关系,一般表现为一个类包含一个类型为另外一个类的属性。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/big_data/two_phase_commit/
分布式事务是指会涉及到操作多个数据库(或者提供事务语义的系统,如JMS)的事务。其实就是将对同一数据库事务的概念扩大到了对多个数据库的事务。目的是为了保证分布式系统中事务操作的原子性。分布式事务处理的关键是必须有一种方法可以知道事务在任何地方所做的所有动作,提交或回滚事务的决定必须产生统一的结果(全部提交或全部回滚)。
如同作者在《SQL优化(六) MVCC PostgreSQL实现事务和多版本并发控制的精华》一文中所讲,事务包含原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。
PostgreSQL针对ACID的实现技术如下表所示。
ACID | 实现技术 |
---|---|
原子性(Atomicity) | MVCC |
一致性(Consistency) | 约束(主键、外键等) |
隔离性 | MVCC |
持久性 | WAL |
分布式事务的实现技术如下表所示。(以PostgreSQL作为事务参与方为例)
分布式ACID | 实现技术 |
---|---|
原子性(Atomicity) | MVCC + 两阶段提交 |
一致性(Consistency) | 约束(主键、外键等) |
隔离性 | MVCC |
持久性 | WAL |
从上表可以看到,一致性、隔离性和持久性靠的是各分布式事务参与方自己原有的机制,而两阶段提交主要保证了分布式事务的原子性。
在分布式系统中,各个节点(或者事务参与方)之间在物理上相互独立,通过网络进行协调。每个独立的节点(或组件)由于存在事务机制,可以保证其数据操作的ACID特性。但是,各节点之间由于相互独立,无法确切地知道其经节点中的事务执行情况,所以多节点之间很难保证ACID,尤其是原子性。
如果要实现分布式系统的原子性,则须保证所有节点的数据写操作,要不全部都执行(生效),要么全部都不执行(生效)。但是,一个节点在执行本地事务的时候无法知道其它机器的本地事务的执行结果,所以它就不知道本次事务到底应该commit还是 roolback。常规的解决办法是引入一个“协调者”的组件来统一调度所有分布式节点的执行。
XA是由X/Open组织提出的分布式事务的规范。XA规范主要定义了(全局)事务管理器(Transaction Manager)和(局部)资源管理器(Resource Manager)之间的接口。XA接口是双向的系统接口,在事务管理器(Transaction Manager)以及一个或多个资源管理器(Resource Manager)之间形成通信桥梁。XA引入的事务管理器充当上文所述全局事务中的“协调者”角色。事务管理器控制着全局事务,管理事务生命周期,并协调资源。资源管理器负责控制和管理实际资源(如数据库或JMS队列)。目前,Oracle、Informix、DB2、Sybase和PostgreSQL等各主流数据库都提供了对XA的支持。
XA规范中,事务管理器主要通过以下的接口对资源管理器进行管理
二阶段提交的算法思路可以概括为:协调者询问参与者是否准备好了提交,并根据所有参与者的反馈情况决定向所有参与者发送commit或者rollback指令(协调者向所有参与者发送相同的指令)。
所谓的两个阶段是指
准备阶段
又称投票阶段。在这一阶段,协调者询问所有参与者是否准备好提交,参与者如果已经准备好提交则回复Prepared
,否则回复Non-Prepared
。提交阶段
又称执行阶段。协调者如果在上一阶段收到所有参与者回复的Prepared
,则在此阶段向所有参与者发送commit
指令,所有参与者立即执行commit
操作;否则协调者向所有参与者发送rollback
指令,参与者立即执行rollback
操作。两阶段提交中,协调者和参与方的交互过程如下图所示。
两阶段提交中的异常主要分为如下三种情况
对于第一种情况,若参与方在准备阶段crash,则协调者收不到Prepared
回复,协调方不会发送commit
命令,事务不会真正提交。若参与方在提交阶段提交,当它恢复后可以通过从其它参与方或者协调方获取事务是否应该提交,并作出相应的响应。
第二种情况,可以通过选出新的协调者解决。
第三种情况,是两阶段提交无法完美解决的情况。尤其是当协调者发送出commit
命令后,唯一收到commit
命令的参与者也crash,此时其它参与方不能从协调者和已经crash的参与者那儿了解事务提交状态。但如同上一节两阶段提交前提条件所述,两阶段提交的前提条件之一是所有crash的节点最终都会恢复,所以当收到commit
的参与方恢复后,其它节点可从它那里获取事务状态并作出相应操作。
作为java平台上事务规范JTA(Java Transaction API)也定义了对XA事务的支持,实际上,JTA是基于XA架构上建模的。在JTA 中,事务管理器抽象为javax.transaction.TransactionManager
接口,并通过底层事务服务(即Java Transaction Service)实现。像很多其他的Java规范一样,JTA仅仅定义了接口,具体的实现则是由供应商(如J2EE厂商)负责提供,目前JTA的实现主要有以下几种:
PREPARE TRANSACTION transaction_id
PREPARE TRANSACTION 为当前事务的两阶段提交做准备。 在命令之后,事务就不再和当前会话关联了;它的状态完全保存在磁盘上, 它提交成功有非常高的可能性,即使是在请求提交之前数据库发生了崩溃也如此。这条命令必须在一个用BEGIN显式开始的事务块里面使用。COMMIT PREPARED transaction_id
提交已进入准备阶段的ID为transaction_id
的事务ROLLBACK PREPARED transaction_id
回滚已进入准备阶段的ID为transaction_id
的事务典型的使用方式如下1
2
3
4
5
6
7
8
9
10
11postgres=> BEGIN;
BEGIN
postgres=> CREATE TABLE demo(a TEXT, b INTEGER);
CREATE TABLE
postgres=> PREPARE TRANSACTION 'the first prepared transaction';
PREPARE TRANSACTION
postgres=> SELECT * FROM pg_prepared_xacts;
transaction | gid | prepared | owner | database
-------------+--------------------------------+-------------------------------+-------+----------
23970 | the first prepared transaction | 2016-08-01 20:44:55.816267+08 | casp | postgres
(1 row)
从上面代码可看出,使用PREPARE TRANSACTION transaction_id
语句后,PostgreSQL会在pg_catalog.pg_prepared_xact
表中将该事务的transaction_id
记于gid字段中,并将该事务的本地事务ID,即23970,存于transaction
字段中,同时会记下该事务的创建时间及创建用户和数据库名。
继续执行如下命令1
2
3
4
5
6
7
8
9
10
11
12
13postgres=> \q
SELECT * FROM pg_prepared_xacts;
transaction | gid | prepared | owner | database
-------------+--------------------------------+-------------------------------+-------+----------
23970 | the first prepared transaction | 2016-08-01 20:44:55.816267+08 | casp | cqdb
(1 row)
cqdb=> ROLLBACK PREPARED 'the first prepared transaction';
ROLLBACK PREPARED
cqdb=> SELECT * FROM pg_prepared_xacts;
transaction | gid | prepared | owner | database
-------------+-----+----------+-------+----------
(0 rows)
即使退出当前session,pg_catalog.pg_prepared_xact
表中关于已经进入准备阶段的事务信息依然存在,这与上文所述准备阶段后各节点会将事务信息存于磁盘中持久化相符。注:如果不使用PREPARED TRANSACTION 'transaction_id'
,则已BEGIN但还未COMMIT或ROLLBACK的事务会在session退出时自动ROLLBACK。
在ROLLBACK已进入准备阶段的事务时,必须指定其transaction_id
。
PREPARE TRANSACTION transaction_id
命令后,事务状态完全保存在磁盘上。PREPARE TRANSACTION transaction_id
命令后,事务就不再和当前会话关联,因此当前session可继续执行其它事务。COMMIT PREPARED
和ROLLBACK PREPARED
可在任何会话中执行,而并不要求在提交准备的会话中执行。WITH HOLD
游标的事务进行PREPARE。 这些特性和当前会话绑定得实在是太紧密了,因此在一个准备好的事务里没什么可用的。SET
修改了运行时参数,这些效果在PREPARE TRANSACTION
之后保留,并且不会被任何以后的COMMIT PREPARED
或ROLLBACK PREPARED
所影响,因为SET
的生效范围是当前session。VACUUM
回收存储的能力。postgresql.conf
文件中设置max_prepared_transactions
配置项开启PostgreSQL的两阶段提交。本文使用Atomikos提供的JTA实现,利用PostgreSQL提供的两阶段提交特性,实现了分布式事务。本文中的分布式事务使用了2个不同机器上的PostgreSQL实例。
本例所示代码可从作者Github获取。
1 | package com.jasongj.jta.resource; |
从上示代码中可以看到,虽然使用了Atomikos的JTA实现,但因为使用了面向接口编程特性,所以只出现了JTA相关的接口,而未显式使用Atomikos相关类。具体的Atomikos使用是在WebContent/META-INFO/context.xml
中配置。
1 | <Context> |
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/java/thread_communication/
Java多线程编程中经常会碰到这样一种场景——某个线程需要等待一个或多个线程操作结束(或达到某种状态)才开始执行。比如开发一个并发测试工具时,主线程需要等到所有测试线程均执行完成再开始统计总共耗费的时间,此时可以通过CountDownLatch轻松实现。
1 | package com.test.thread; |
执行结果1
2
3
4
5
6
7Sun Jun 19 20:34:31 CST 2016 Thread 1 started
Sun Jun 19 20:34:31 CST 2016 Thread 0 started
Sun Jun 19 20:34:31 CST 2016 Thread 2 started
Sun Jun 19 20:34:32 CST 2016 Thread 2 ended
Sun Jun 19 20:34:32 CST 2016 Thread 1 ended
Sun Jun 19 20:34:32 CST 2016 Thread 0 ended
Total time : 1072ms
可以看到,主线程等待所有3个线程都执行结束后才开始执行。
CountDownLatch工作原理相对简单,可以简单看成一个倒计数器,在构造方法中指定初始值,每次调用countDown()方法时将计数器减1,而await()会等待计数器变为0。CountDownLatch关键接口如下
在《当我们说线程安全时,到底在说什么》一文中讲过内存屏障,它能保证屏障之前的代码一定在屏障之后的代码之前被执行。CyclicBarrier可以译为循环屏障,也有类似的功能。CyclicBarrier可以在构造时指定需要在屏障前执行await的个数,所有对await的调用都会等待,直到调用await的次数达到预定指,所有等待都会立即被唤醒。
从使用场景上来说,CyclicBarrier是让多个线程互相等待某一事件的发生,然后同时被唤醒。而上文讲的CountDownLatch是让某一线程等待多个线程的状态,然后该线程被唤醒。
1 | package com.test.thread; |
执行结果如下1
2
3
4
5
6
7
8
9
10Sun Jun 19 21:04:49 CST 2016 Thread 1 is waiting
Sun Jun 19 21:04:49 CST 2016 Thread 0 is waiting
Sun Jun 19 21:04:49 CST 2016 Thread 3 is waiting
Sun Jun 19 21:04:49 CST 2016 Thread 2 is waiting
Sun Jun 19 21:04:49 CST 2016 Thread 4 is waiting
Sun Jun 19 21:04:49 CST 2016 Thread 4 ended
Sun Jun 19 21:04:49 CST 2016 Thread 0 ended
Sun Jun 19 21:04:49 CST 2016 Thread 2 ended
Sun Jun 19 21:04:49 CST 2016 Thread 1 ended
Sun Jun 19 21:04:49 CST 2016 Thread 3 ended
从执行结果可以看到,每个线程都不会在其它所有线程执行await()方法前继续执行,而等所有线程都执行await()方法后所有线程的等待都被唤醒从而继续执行。
CyclicBarrier提供的关键方法如下
CountDownLatch和CyclicBarrier都是JDK 1.5引入的,而Phaser是JDK 1.7引入的。Phaser的功能与CountDownLatch和CyclicBarrier有部分重叠,同时也提供了更丰富的语义和更灵活的用法。
Phaser顾名思义,与阶段相关。Phaser比较适合这样一种场景,一种任务可以分为多个阶段,现希望多个线程去处理该批任务,对于每个阶段,多个线程可以并发进行,但是希望保证只有前面一个阶段的任务完成之后才能开始后面的任务。这种场景可以使用多个CyclicBarrier来实现,每个CyclicBarrier负责等待一个阶段的任务全部完成。但是使用CyclicBarrier的缺点在于,需要明确知道总共有多少个阶段,同时并行的任务数需要提前预定义好,且无法动态修改。而Phaser可同时解决这两个问题。
1 | public class PhaserDemo { |
执行结果如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16Thread 0, phase 0
Thread 1, phase 0
Thread 2, phase 0
====== Phase : 0 ======
Thread 2, phase 1
Thread 0, phase 1
Thread 1, phase 1
====== Phase : 1 ======
Thread 1, phase 2
Thread 2, phase 2
Thread 0, phase 2
====== Phase : 2 ======
Thread 0, phase 3
Thread 1, phase 3
Thread 2, phase 3
====== Phase : 3 ======
从上面的结果可以看到,多个线程必须等到其它线程的同一阶段的任务全部完成才能进行到下一个阶段,并且每当完成某一阶段任务时,Phaser都会执行其onAdvance方法。
Phaser主要接口如下
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/java/multi_thread/
其实这个问题应该这么问——sleep和wait有什么相同点。因为这两个方法除了都能让当前线程暂停执行完,几乎没有其它相同点。
wait方法是Object类的方法,这意味着所有的Java类都可以调用该方法。sleep方法是Thread类的静态方法。
wait是在当前线程持有wait对象锁的情况下,暂时放弃锁,并让出CPU资源,并积极等待其它线程调用同一对象的notify或者notifyAll方法。注意,即使只有一个线程在等待,并且有其它线程调用了notify或者notifyAll方法,等待的线程只是被激活,但是它必须得再次获得锁才能继续往下执行。换言之,即使notify被调用,但只要锁没有被释放,原等待线程因为未获得锁仍然无法继续执行。测试代码如下所示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
46import java.util.Date;
public class Wait {
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (Wait.class) {
try {
System.out.println(new Date() + " Thread1 is running");
Wait.class.wait();
System.out.println(new Date() + " Thread1 ended");
} catch (Exception ex) {
ex.printStackTrace();
}
}
});
thread1.start();
Thread thread2 = new Thread(() -> {
synchronized (Wait.class) {
try {
System.out.println(new Date() + " Thread2 is running");
Wait.class.notify();
// Don't use sleep method to avoid confusing
for(long i = 0; i < 200000; i++) {
for(long j = 0; j < 100000; j++) {}
}
System.out.println(new Date() + " Thread2 release lock");
} catch (Exception ex) {
ex.printStackTrace();
}
}
for(long i = 0; i < 200000; i++) {
for(long j = 0; j < 100000; j++) {}
}
System.out.println(new Date() + " Thread2 ended");
});
// Don't use sleep method to avoid confusing
for(long i = 0; i < 200000; i++) {
for(long j = 0; j < 100000; j++) {}
}
thread2.start();
}
}
执行结果如下1
2
3
4
5Tue Jun 14 22:51:11 CST 2016 Thread1 is running
Tue Jun 14 22:51:23 CST 2016 Thread2 is running
Tue Jun 14 22:51:36 CST 2016 Thread2 release lock
Tue Jun 14 22:51:36 CST 2016 Thread1 ended
Tue Jun 14 22:51:49 CST 2016 Thread2 ended
从运行结果可以看出
注意:wait方法需要释放锁,前提条件是它已经持有锁。所以wait和notify(或者notifyAll)方法都必须被包裹在synchronized语句块中,并且synchronized后锁的对象应该与调用wait方法的对象一样。否则抛出IllegalMonitorStateException
sleep方法告诉操作系统至少指定时间内不需为线程调度器为该线程分配执行时间片,并不释放锁(如果当前已经持有锁)。实际上,调用sleep方法时并不要求持有任何锁。
1 | package com.test.thread; |
执行结果如下1
2
3
4Thu Jun 16 19:46:06 CST 2016 Thread1 is running
Thu Jun 16 19:46:08 CST 2016 Thread1 ended
Thu Jun 16 19:46:13 CST 2016 Thread2 is running
Thu Jun 16 19:46:15 CST 2016 Thread2 ended
由于thread 1和thread 2的run方法实现都在同步块中,无论哪个线程先拿到锁,执行sleep时并不释放锁,因此其它线程无法执行。直到前面的线程sleep结束并退出同步块(释放锁),另一个线程才得到锁并执行。
注意:sleep方法并不需要持有任何形式的锁,也就不需要包裹在synchronized中。
本文所有示例均基于Java HotSpot(TM) 64-Bit Server VM
调用sleep方法的线程,在jstack中显示的状态为sleeping
。1
java.lang.Thread.State: TIMED_WAITING (sleeping)
调用wait方法的线程,在jstack中显示的状态为on object monitor
1
java.lang.Thread.State: WAITING (on object monitor)
每个Java对象都可以用做一个实现同步的互斥锁,这些锁被称为内置锁。线程进入同步代码块或方法时自动获得内置锁,退出同步代码块或方法时自动释放该内置锁。进入同步代码块或者同步方法是获得内置锁的唯一途径。
synchronized用于修饰实例方法(非静态方法)时,执行该方法需要获得的是该类实例对象的内置锁(同一个类的不同实例拥有不同的内置锁)。如果多个实例方法都被synchronized修饰,则当多个线程调用同一实例的不同同步方法(或者同一方法)时,需要竞争锁。但当调用的是不同实例的方法时,并不需要竞争锁。
synchronized用于修饰静态方法时,执行该方法需要获得的是该类的class对象的内置锁(一个类只有唯一一个class对象)。调用同一个类的不同静态同步方法时会产生锁竞争。
synchronized用于修饰代码块时,进入同步代码块需要获得synchronized关键字后面括号内的对象(可以是实例对象也可以是class对象)的内置锁。
锁的使用是为了操作临界资源的正确性,而往往一个方法中并非所有的代码都操作临界资源。换句话说,方法中的代码往往并不都需要同步。此时建议不使用同步方法,而使用同步代码块,只对操作临界资源的代码,也即需要同步的代码加锁。这样做的好处是,当一个线程在执行同步代码块时,其它线程仍然可以执行该方法内同步代码块以外的部分,充分发挥多线程并发的优势,从而相较于同步整个方法而言提升性能。
释放Java内置锁的唯一方式是synchronized方法或者代码块执行结束。若某一线程在synchronized方法或代码块内发生死锁,则对应的内置锁无法释放,其它线程也无法获取该内置锁(即进入跟该内置锁相关的synchronized方法或者代码块)。
使用jstack dump线程栈时,可查看到相关线程通过synchronized获取到或等待的对象,但Locked ownable synchronizers
仍然显示为None
。下例中,线程thead-test-b
已获取到类型为java.lang.Double
的对象的内置锁(monitor),且该对象的内存地址为0x000000076ab95cb8
1
2
3
4
5
6
7
8
9"thread-test-b" #11 prio=5 os_prio=31 tid=0x00007fab0190b800 nid=0x5903 runnable [0x0000700010249000]
java.lang.Thread.State: RUNNABLE
at com.jasongj.demo.TestJstack.lambda$1(TestJstack.java:27)
- locked <0x000000076ab95cb8> (a java.lang.Double)
at com.jasongj.demo.TestJstack$$Lambda$2/1406718218.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)
Locked ownable synchronizers:
- None
Java中的重入锁(即ReentrantLock)与Java内置锁一样,是一种排它锁。使用synchronized的地方一定可以用ReentrantLock代替。
重入锁需要显示请求获取锁,并显示释放锁。为了避免获得锁后,没有释放锁,而造成其它线程无法获得锁而造成死锁,一般建议将释放锁操作放在finally块里,如下所示。1
2
3
4
5
6try{
renentrantLock.lock();
// 用户操作
} finally {
renentrantLock.unlock();
}
如果重入锁已经被其它线程持有,则当前线程的lock操作会被阻塞。除了lock()方法之外,重入锁(或者说锁接口)还提供了其它获取锁的方法以实现不同的效果。
重入锁可定义为公平锁或非公平锁,默认实现为非公平锁。
使用jstack dump线程栈时,可查看到获取到或正在等待的锁对象,获取到该锁的线程会在Locked ownable synchronizers
处显示该锁的对象类型及内存地址。在下例中,从Locked ownable synchronizers
部分可看到,线程thread-test-e
获取到公平重入锁,且该锁对象的内存地址为0x000000076ae3d708
1
2
3
4
5
6
7
8"thread-test-e" #17 prio=5 os_prio=31 tid=0x00007fefaa0b6800 nid=0x6403 runnable [0x0000700002939000]
java.lang.Thread.State: RUNNABLE
at com.jasongj.demo.TestJstack.lambda$4(TestJstack.java:64)
at com.jasongj.demo.TestJstack$$Lambda$5/466002798.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)
Locked ownable synchronizers:
- <0x000000076af86810> (a java.util.concurrent.locks.ReentrantLock$FairSync)
而线程thread-test-f
由于未获取到锁,而处于WAITING(parking)
状态,且它等待的锁正是上文线程thread-test-e
获取的锁(内存地址0x000000076af86810
)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15"thread-test-f" #18 prio=5 os_prio=31 tid=0x00007fefaa9b2800 nid=0x6603 waiting on condition [0x0000700002a3c000]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x000000076af86810> (a java.util.concurrent.locks.ReentrantLock$FairSync)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:836)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(AbstractQueuedSynchronizer.java:870)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:1199)
at java.util.concurrent.locks.ReentrantLock$FairSync.lock(ReentrantLock.java:224)
at java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:285)
at com.jasongj.demo.TestJstack.lambda$5(TestJstack.java:69)
at com.jasongj.demo.TestJstack$$Lambda$6/33524623.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)
Locked ownable synchronizers:
- None
如上文《Java进阶(二)当我们说线程安全时,到底在说什么》所述,锁可以保证原子性和可见性。而原子性更多是针对写操作而言。对于读多写少的场景,一个读操作无须阻塞其它读操作,只需要保证读和写或者写与写不同时发生即可。此时,如果使用重入锁(即排它锁),对性能影响较大。Java中的读写锁(ReadWriteLock)就是为这种读多写少的场景而创造的。
实际上,ReadWriteLock接口并非继承自Lock接口,ReentrantReadWriteLock也只实现了ReadWriteLock接口而未实现Lock接口。ReadLock和WriteLock,是ReentrantReadWriteLock类的静态内部类,它们实现了Lock接口。
一个ReentrantReadWriteLock实例包含一个ReentrantReadWriteLock.ReadLock实例和一个ReentrantReadWriteLock.WriteLock实例。通过readLock()和writeLock()方法可分别获得读锁实例和写锁实例,并通过Lock接口提供的获取锁方法获得对应的锁。
读写锁的锁定规则如下:
1 | package com.test.thread; |
执行结果如下1
2
3
4
5
6Sat Jun 18 21:33:46 CST 2016 Thread 1 started with read lock
Sat Jun 18 21:33:46 CST 2016 Thread 2 started with read lock
Sat Jun 18 21:33:48 CST 2016 Thread 2 ended
Sat Jun 18 21:33:48 CST 2016 Thread 1 ended
Sat Jun 18 21:33:48 CST 2016 Thread 3 started with write lock
Sat Jun 18 21:33:50 CST 2016 Thread 3 ended
从上面的执行结果可见,thread 1和thread 2都只需获得读锁,因此它们可以并行执行。而thread 3因为需要获取写锁,必须等到thread 1和thread 2释放锁后才能获得锁。
条件锁只是一个帮助用户理解的概念,实际上并没有条件锁这种锁。对于每个重入锁,都可以通过newCondition()方法绑定若干个条件对象。
条件对象提供以下方法以实现不同的等待语义
调用条件等待的注意事项
signal()与signalAll()
1 | package com.test.thread; |
执行结果如下1
2
3
4
5Sun Jun 19 15:59:09 CST 2016 Thread 1 is waiting
Sun Jun 19 15:59:09 CST 2016 Thread 2 is running
Sun Jun 19 15:59:13 CST 2016 Thread 2 ended
Sun Jun 19 15:59:13 CST 2016 Thread 1 remaining time -2003467560
Sun Jun 19 15:59:13 CST 2016 Thread 1 is waken up
从执行结果可以看出,虽然thread 2一开始就调用了signal()方法去唤醒thread 1,但是因为thread 2在4秒钟后才释放锁,也即thread 1在4秒后才获得锁,所以thread 1的await方法在4秒钟后才返回,并且返回负值。
信号量维护一个许可集,可通过acquire()获取许可(若无可用许可则阻塞),通过release()释放许可,从而可能唤醒一个阻塞等待许可的线程。
与互斥锁类似,信号量限制了同一时间访问临界资源的线程的个数,并且信号量也分公平信号量与非公平信号量。而不同的是,互斥锁保证同一时间只会有一个线程访问临界资源,而信号量可以允许同一时间多个线程访问特定资源。所以信号量并不能保证原子性。
信号量的一个典型使用场景是限制系统访问量。每个请求进来后,处理之前都通过acquire获取许可,若获取许可成功则处理该请求,若获取失败则等待处理或者直接不处理该请求。
信号量的使用方法
注意:与wait/notify和await/signal不同,acquire/release完全与锁无关,因此acquire等待过程中,可用许可满足要求时acquire可立即返回,而不用像锁的wait和条件变量的await那样重新获取锁才能返回。或者可以理解成,只要可用许可满足需求,就已经获得了锁。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/java/thread_safe/
这一点,跟数据库事务的原子性概念差不多,即一个操作(有可能包含有多个子操作)要么全部执行(生效),要么全部都不执行(都不生效)。
关于原子性,一个非常经典的例子就是银行转账问题:比如A和B同时向C转账10万元。如果转账操作不具有原子性,A在向C转账时,读取了C的余额为20万,然后加上转账的10万,计算出此时应该有30万,但还未来及将30万写回C的账户,此时B的转账请求过来了,B发现C的余额为20万,然后将其加10万并写回。然后A的转账操作继续——将30万写回C的余额。这种情况下C的最终余额为30万,而非预期的40万。
可见性是指,当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。可见性问题是好多人忽略或者理解错误的一点。
CPU从主内存中读数据的效率相对来说不高,现在主流的计算机中,都有几级缓存。每个线程读取共享变量时,都会将该变量加载进其对应CPU的高速缓存里,修改该变量后,CPU会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)。此时其它线程(尤其是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据。
这一点是操作系统或者说是硬件层面的机制,所以很多应用开发人员经常会忽略。
顺序性指的是,程序执行的顺序按照代码的先后顺序执行。
以下面这段代码为例1
2
3
4boolean started = false; // 语句1
long counter = 0L; // 语句2
counter = 1; // 语句3
started = true; // 语句4
从代码顺序上看,上面四条语句应该依次执行,但实际上JVM真正在执行这段代码时,并不保证它们一定完全按照此顺序执行。
处理器为了提高程序整体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码顺序,按照更高效的顺序执行代码。
讲到这里,有人要着急了——什么,CPU不按照我的代码顺序执行代码,那怎么保证得到我们想要的效果呢?实际上,大家大可放心,CPU虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致。
常用的保证Java操作原子性的工具是锁和同步方法(或者同步代码块)。使用锁,可以保证同一时间只有一个线程能拿到锁,也就保证了同一时间只有一个线程能执行申请锁和释放锁之间的代码。1
2
3
4
5
6
7
8
9public void testLock () {
lock.lock();
try{
int j = i;
i = j + 1;
} finally {
lock.unlock();
}
}
与锁类似的是同步方法或者同步代码块。使用非静态同步方法时,锁住的是当前实例;使用静态同步方法时,锁住的是该类的Class对象;使用静态代码块时,锁住的是synchronized
关键字后面括号内的对象。下面是同步代码块示例1
2
3
4
5
6public void testLock () {
synchronized (anyObject){
int j = i;
i = j + 1;
}
}
无论使用锁还是synchronized,本质都是一样,通过锁来实现资源的排它性,从而实际目标代码段同一时间只会被一个线程执行,进而保证了目标代码段的原子性。这是一种以牺牲性能为代价的方法。
基础类型变量自增(i++)是一种常被新手误以为是原子操作而实际不是的操作。Java中提供了对应的原子操作类来实现该操作,并保证原子性,其本质是利用了CPU级别的CAS指令。由于是CPU级别的指令,其开销比需要操作系统参与的锁的开销小。AtomicInteger使用方法如下。1
2
3
4
5
6
7
8AtomicInteger atomicInteger = new AtomicInteger();
for(int b = 0; b < numThreads; b++) {
new Thread(() -> {
for(int a = 0; a < iteration; a++) {
atomicInteger.incrementAndGet();
}
}).start();
}
Java提供了volatile
关键字来保证可见性。当使用volatile修饰某个变量时,它会保证对该变量的修改会立即被更新到内存中,并且将其它缓存中对该变量的缓存设置成无效,因此其它线程需要读取该值时必须从主内存中读取,从而得到最新的值。
上文讲过编译器和处理器对指令进行重新排序时,会保证重新排序后的执行结果和代码顺序执行的结果一致,所以重新排序过程并不会影响单线程程序的执行,却可能影响多线程程序并发执行的正确性。
Java中可通过volatile
在一定程序上保证顺序性,另外还可以通过synchronized和锁来保证顺序性。
synchronized和锁保证顺序性的原理和保证原子性一样,都是通过保证同一时间只会有一个线程执行目标代码段来实现的。
除了从应用层面保证目标代码段执行的顺序性外,JVM还通过被称为happens-before原则隐式地保证顺序性。两个操作的执行顺序只要可以通过happens-before推导出来,则JVM会保证其顺序性,反之JVM对其顺序性不作任何保证,可对其进行任意必要的重新排序以获取高效率。
volatile适用于不需要保证原子性,但却需要保证可见性的场景。一种典型的使用场景是用它修饰用于停止线程的状态标记。如下所示1
2
3
4
5
6
7
8
9
10
11
12
13boolean isRunning = false;
public void start () {
new Thread( () -> {
while(isRunning) {
someOperation();
}
}).start();
}
public void stop () {
isRunning = false;
}
在这种实现方式下,即使其它线程通过调用stop()方法将isRunning设置为false,循环也不一定会立即结束。可以通过volatile关键字,保证while循环及时得到isRunning最新的状态从而及时停止循环,结束线程。
问:平时项目中使用锁和synchronized比较多,而很少使用volatile,难道就没有保证可见性?
答:锁和synchronized即可以保证原子性,也可以保证可见性。都是通过保证同一时间只有一个线程执行目标代码段来实现的。
问:锁和synchronized为何能保证可见性?
答:根据JDK 7的Java doc中对concurrent
包的说明,一个线程的写结果保证对另外线程的读操作可见,只要该写操作可以由happen-before
原则推断出在读操作之前发生。
The results of a write by one thread are guaranteed to be visible to a read by another thread only if the write operation happens-before the read operation. The synchronized and volatile constructs, as well as the Thread.start() and Thread.join() methods, can form happens-before relationships.
问:既然锁和synchronized即可保证原子性也可保证可见性,为何还需要volatile?
答:synchronized和锁需要通过操作系统来仲裁谁获得锁,开销比较高,而volatile开销小很多。因此在只需要保证可见性的条件下,使用volatile的性能要比使用锁和synchronized高得多。
问:既然锁和synchronized可以保证原子性,为什么还需要AtomicInteger这种的类来保证原子操作?
答:锁和synchronized需要通过操作系统来仲裁谁获得锁,开销比较高,而AtomicInteger是通过CPU级的CAS操作来保证原子性,开销比较小。所以使用AtomicInteger的目的还是为了提高性能。
问:还有没有别的办法保证线程安全
答:有。尽可能避免引起非线程安全的条件——共享变量。如果能从设计上避免共享变量的使用,即可避免非线程安全的发生,也就无须通过锁或者synchronized以及volatile解决原子性、可见性和顺序性的问题。
问:synchronized即可修饰非静态方式,也可修饰静态方法,还可修饰代码块,有何区别
答:synchronized修饰非静态同步方法时,锁住的是当前实例;synchronized修饰静态同步方法时,锁住的是该类的Class对象;synchronized修饰静态代码块时,锁住的是synchronized
关键字后面括号内的对象。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/sql/mvcc/
数据库事务包含如下四个特性
事务的实现原理可以解读为RDBMS采取何种技术确保事务的ACID特性,PostgreSQL针对ACID的实现技术如下表所示。
ACID | 实现技术 |
---|---|
原子性(Atomicity) | MVCC |
一致性(Consistency) | 约束(主键、外键等) |
隔离性 | MVCC |
持久性 | WAL |
从上表可以看到,PostgreSQL主要使用MVCC和WAL两项技术实现ACID特性。实际上,MVCC和WAL这两项技术都比较成熟,主流关系型数据库中都有相应的实现,但每个数据库中具体的实现方式往往存在较大的差异。本文将介绍PostgreSQL中的MVCC实现原理。
在PostgreSQL中,每个事务都有一个唯一的事务ID,被称为XID。注意:除了被BEGIN - COMMIT/ROLLBACK包裹的一组语句会被当作一个事务对待外,不显示指定BEGIN - COMMIT/ROLLBACK的单条语句也是一个事务。
数据库中的事务ID递增。可通过txid_current()
函数获取当前事务的ID。
PostgreSQL中,对于每一行数据(称为一个tuple),包含有4个隐藏字段。这四个字段是隐藏的,但可直接访问。
下面通过实验具体看看这些标记如何工作。在此之前,先创建测试表1
2
3
4
5CREATE TABLE test
(
id INTEGER,
value TEXT
);
开启一个事务,查询当前事务ID(值为3277),并插入一条数据,xmin为3277,与当前事务ID相等。符合上文所述——插入tuple时记录xmin,记录未被删除时xmax为01
2
3
4
5
6
7
8
9
10
11
12
13
14
15postgres=> BEGIN;
BEGIN
postgres=> SELECT TXID_CURRENT();
txid_current
--------------
3277
(1 row)
postgres=> INSERT INTO test VALUES(1, 'a');
INSERT 0 1
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
id | value | xmin | xmax | cmin | cmax
----+-------+------+------+------+------
1 | a | 3277 | 0 | 0 | 0
(1 row)
继续通过一条语句插入2条记录,xmin仍然为当前事务ID,即3277,xmax仍然为0,同时cmin和cmax为1,符合上文所述cmin/cmax在事务内随着所执行的语句递增。虽然此步骤插入了两条数据,但因为是在同一条语句中插入,故其cmin/cmax都为1,在上一条语句的基础上加一。1
2
3
4
5
6
7
8
9INSERT INTO test VALUES(2, 'b'), (3, 'c');
INSERT 0 2
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
id | value | xmin | xmax | cmin | cmax
----+-------+------+------+------+------
1 | a | 3277 | 0 | 0 | 0
2 | b | 3277 | 0 | 1 | 1
3 | c | 3277 | 0 | 1 | 1
(3 rows)
将id为1的记录的value字段更新为’d’,其xmin和xmax均未变,而cmin和cmax变为2,在上一条语句的基础之上增加一。此时提交事务。1
2
3
4
5
6
7
8
9
10
11
12UPDATE test SET value = 'd' WHERE id = 1;
UPDATE 1
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
id | value | xmin | xmax | cmin | cmax
----+-------+------+------+------+------
2 | b | 3277 | 0 | 1 | 1
3 | c | 3277 | 0 | 1 | 1
1 | d | 3277 | 0 | 2 | 2
(3 rows)
postgres=> COMMIT;
COMMIT
开启一个新事务,通过2条语句分别插入2条id为4和5的tuple。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15BEGIN;
BEGIN
postgres=> INSERT INTO test VALUES (4, 'x');
INSERT 0 1
postgres=> INSERT INTO test VALUES (5, 'y');
INSERT 0 1
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
id | value | xmin | xmax | cmin | cmax
----+-------+------+------+------+------
2 | b | 3277 | 0 | 1 | 1
3 | c | 3277 | 0 | 1 | 1
1 | d | 3277 | 0 | 2 | 2
4 | x | 3278 | 0 | 0 | 0
5 | y | 3278 | 0 | 1 | 1
(5 rows)
此时,将id为2的tuple的value更新为’e’,其对应的cmin/cmax被设置为2,且其xmin被设置为当前事务ID,即32781
2
3
4
5
6
7
8
9
10UPDATE test SET value = 'e' WHERE id = 2;
UPDATE 1
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
id | value | xmin | xmax | cmin | cmax
----+-------+------+------+------+------
3 | c | 3277 | 0 | 1 | 1
1 | d | 3277 | 0 | 2 | 2
4 | x | 3278 | 0 | 0 | 0
5 | y | 3278 | 0 | 1 | 1
2 | e | 3278 | 0 | 2 | 2
在另外一个窗口中开启一个事务,可以发现id为2的tuple,xin仍然为3277,但其xmax被设置为3278,而cmin和cmax均为2。符合上文所述——若tuple被删除,则xmax被设置为删除tuple的事务的ID。1
2
3
4
5
6
7
8
9BEGIN;
BEGIN
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
id | value | xmin | xmax | cmin | cmax
----+-------+------+------+------+------
2 | b | 3277 | 3278 | 2 | 2
3 | c | 3277 | 0 | 1 | 1
1 | d | 3277 | 0 | 2 | 2
(3 rows)
这里有几点要注意
提交旧窗口中的事务后,新旧窗口中看到数据完全一致——id为2的tuple排在了最后,xmin变为3278,xmax为0,cmin/cmax为2。前文定义中,xmin是tuple创建时的事务ID,并没有提及更新的事务ID,但因为PostgreSQL的更新操作并非真正更新数据,而是将旧数据标记为删除,并插入新数据,所以“更新的事务ID”也就是“创建记录的事务ID”。1
2
3
4
5
6
7
8
9 SELECT *, xmin, xmax, cmin, cmax FROM test;
id | value | xmin | xmax | cmin | cmax
----+-------+------+------+------+------
3 | c | 3277 | 0 | 1 | 1
1 | d | 3277 | 0 | 2 | 2
4 | x | 3278 | 0 | 0 | 0
5 | y | 3278 | 0 | 1 | 1
2 | e | 3278 | 0 | 2 | 2
(5 rows)
原子性(Atomicity)指得是一个事务是一个不可分割的工作单位,事务中包括的所有操作要么都做,要么都不做。
对于插入操作,PostgreSQL会将当前事务ID存于xmin中。对于删除操作,其事务ID会存于xmax中。对于更新操作,PostgreSQL会将当前事务ID存于旧数据的xmax中,并存于新数据的xin中。换句话说,事务对增、删和改所操作的数据上都留有其事务ID,可以很方便的提交该批操作或者完全撤销操作,从而实现了事务的原子性。
隔离性(Isolation)指一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。
标准SQL的事务隔离级别分为如下四个级别
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
未提交读(read uncommitted) | 可能 | 可能 | 可能 |
提交读(read committed) | 不可能 | 可能 | 可能 |
可重复读(repeatable read) | 不可能 | 不可能 | 可能 |
串行读(serializable) | 不可能 | 不可能 | 不可能 |
从上表中可以看出,从未提交读到串行读,要求越来越严格。
注意,SQL标准规定,具体数据库实现时,对于标准规定不允许发生的,绝不可发生;对于可能发生的,并不要求一定能发生。换句话说,具体数据库实现时,对应的隔离级别只可更严格,不可更宽松。
事实中,PostgreSQL实现了三种隔离级别——未提交读和提交读实际上都被实现为提交读。
下面将讨论提交读和可重复读的实现方式
提交读只可读取其它已提交事务的结果。PostgreSQL中通过pg_clog来记录哪些事务已经被提交,哪些未被提交。具体实现方式将在下一篇文章《SQL优化(七) WAL PostgreSQL实现事务和高并发的重要技术》中讲述。
相对于提交读,重复读要求在同一事务中,前后两次带条件查询所得到的结果集相同。实际中,PostgreSQL的实现更严格,不紧要求可重复读,还不允许出现幻读。它是通过只读取在当前事务开启之前已经提交的数据实现的。结合上文的四个隐藏系统字段来讲,PostgreSQL的可重复读是通过只读取xmin小于当前事务ID且已提交的事务的结果来实现的。
事务ID由32位数保存,而事务ID递增,当事务ID用完时,会出现wraparound问题。
PostgreSQL通过VACUUM机制来解决该问题。对于事务ID,PostgreSQL有三个事务ID有特殊意义:
可用的有效最小事务ID为3。VACUUM时将所有已提交的事务ID均设置为2,即frozon。之后所有的事务都比frozon事务新,因此VACUUM之前的所有已提交的数据都对之后的事务可见。PostgreSQL通过这种方式实现了事务ID的循环利用。
由于上文提到的,PostgreSQL更新数据并非真正更改记录值,而是通过将旧数据标记为删除,再插入新的数据来实现。对于更新或删除频繁的表,会累积大量过期数据,占用大量磁盘,并且由于需要扫描更多数据,使得查询性能降低。
PostgreSQL解决该问题的方式也是VACUUM机制。从释放磁盘的角度,VACUUM分为两种
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/design_pattern/summary/
封装,也就是把客观事物封装成抽象的类,并且类可以把自己的属性和方法只让可信的类操作,对不可信的进行信息隐藏。
继承是指这样一种能力,它可以使用现有的类的所有功能,并在无需重新编写原来类的情况下对这些功能进行扩展。
多态指一个类实例的相同方法在不同情形有不同的表现形式。具体来说就是不同实现类对公共接口有不同的实现方式,但这些操作可以通过相同的方式(公共接口)予以调用。
面向对象设计(OOD)有七大原则(是的,你没看错,是七大原则,不是六大原则),它们互相补充。
Open-Close Principle(OCP),即开-闭原则。开,指的是对扩展开放,即要支持方便地扩展;闭,指的是对修改关闭,即要严格限制对已有内容的修改。开-闭原则是最抽象也是最重要的OOD原则。简单工厂模式、工厂方法模式、抽象工厂模式中都提到了如何通过良好的设计遵循开-闭原则。
Liskov Substitution Principle(LSP),即里氏替换原则。该原则规定“子类必须能够替换其父类,否则不应当设计为其子类”。换句话说,父类出现的地方,都应该能由其子类代替。所以,子类只能去扩展基类,而不是隐藏或者覆盖基类。
Dependence Inversion Principle(DIP),依赖倒置原则。它讲的是“设计和实现要依赖于抽象而非具体”。一方面抽象化更符合人的思维习惯;另一方面,根据里氏替换原则,可以很容易将原来的抽象替换为扩展后的具体,这样可以很好的支持开-闭原则。
Interface Segration Principle(ISP),接口隔离原则,“将大的接口打散成多个小的独立的接口”。由于Java类支持实现多个接口,可以很容易的让类具有多种接口的特征,同时每个类可以选择性地只实现目标接口。
Single Responsibility Principle(SRP),单一职责原则。它讲的是,不要存在多于一个导致类变更的原因,是高内聚低耦合的一个体现。
Law of Demeter or Least Knowledge Principle(LoD or LKP),迪米特法则或最少知道原则。它讲的是“一个对象就尽可能少的去了解其它对象”,从而实现松耦合。如果一个类的职责过多,由于多个职责耦合在了一起,任何一个职责的变更都可能引起其它职责的问题,严重影响了代码的可维护性和可重用性。
Composite/Aggregate Reuse Principle(CARP / CRP),合成/聚合复用原则。如果新对象的某些功能在别的已经创建好的对象里面已经实现,那么应当尽量使用别的对象提供的功能,使之成为新对象的一部分,而不要再重新创建。新对象可通过向这些对象的委派达到复用已有功能的效果。简而言之,要尽量使用合成/聚合,而非使用继承。《Java设计模式(九) 桥接模式》中介绍的桥接模式即是对这一原则的典型应用。
可以用一句话概括设计模式———设计模式是一种利用OOP的封闭、继承和多态三大特性,同时在遵循单一职责原则、开闭原则、里氏替换原则、迪米特法则、依赖倒置原则、接口隔离原则及合成/聚合复用原则的前提下,被总结出来的经过反复实践并被多数人知晓且经过分类和设计的可重用的软件设计方式。
每个设计模式都有其适合范围,并解决特定问题。所以项目实践中应该针对特定使用场景选用合适的设计模式,如果某些场景下现在的设计模式都不能很完全的解决问题,那也不必拘泥于设计模式本身。实际上,学习和使用设计模式本身并不是目的,目的是通过学习和使用它,强化面向对象设计思路并用合适的方法解决工程问题。
有些人认为,在某些特定场景下,设计模式并非最优方案,而自己的解决方案可能会更好。这个问题得分两个方面来讨论:一方面,如上文所述,所有设计模式都有其适用场景,“one size does not fit all”;另一方面,确实有可能自己设计的方案比设计模式更适合,但这并不影响你学习并使用设计模式,因为设计模式经过大量实战检验能在绝大多数情况下提供良好方案。
设计模式虽然都有其相对固定的实现方式,但是它的精髓是利用OOP的三大特性,遵循OOD七大原则解决工程问题。所以学习设计模式的目的不是学习设计模式的固定实现方式本身,而是其思想。
设计模式是被广泛接受和使用的,引导团队成员使用设计模式可以减少沟通成本,而更专注于业务本身。也许你有自己的一套思路,但是你怎么能保证团队成员一定认可你的思路,进而将你的思路贯彻实施呢?统一使用设计模式能让团队只使用20%的精力决解80%的问题。其它20%的问题,才是你需要花精力解决的。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/design_pattern/strategy/
策略模式(Strategy Pattern),将各种算法封装到具体的类中,作为一个抽象策略类的子类,使得它们可以互换。客户端可以自行决定使用哪种算法。
策略模式类图如下
本文代码可从作者Github下载
策略接口,定义策略执行接口1
2
3
4
5
6
7package com.jasongj.strategy;
public interface Strategy {
void strategy(String input);
}
具体策略类,实现策略接口,提供具体算法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16package com.jasongj.strategy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
"StrategyA") .jasongj.annotation.Strategy(name=
public class ConcreteStrategyA implements Strategy {
private static final Logger LOG = LoggerFactory.getLogger(ConcreteStrategyB.class);
public void strategy(String input) {
LOG.info("Strategy A for input : {}", input);
}
}
1 | package com.jasongj.strategy; |
Context类,持有具体策略类的实例,负责调用具体算法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17package com.jasongj.context;
import com.jasongj.strategy.Strategy;
public class SimpleContext {
private Strategy strategy;
public SimpleContext(Strategy strategy) {
this.strategy = strategy;
}
public void action(String input) {
strategy.strategy(input);
}
}
客户端可以实例化具体策略类,并传给Context类,通过Context统一调用具体算法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15package com.jasongj.client;
import com.jasongj.context.SimpleContext;
import com.jasongj.strategy.ConcreteStrategyA;
import com.jasongj.strategy.Strategy;
public class SimpleClient {
public static void main(String[] args) {
Strategy strategy = new ConcreteStrategyA();
SimpleContext context = new SimpleContext(strategy);
context.action("Hellow, world");
}
}
上面的实现中,客户端需要显示决定具体使用何种策略,并且一旦需要换用其它策略,需要修改客户端的代码。解决这个问题,一个比较好的方式是使用简单工厂,使得客户端都不需要知道策略类的实例化过程,甚至都不需要具体哪种策略被使用。
如《Java设计模式(一) 简单工厂模式不简单》所述,简单工厂的实现方式比较多,可以结合《Java系列(一)Annotation(注解)》中介绍的Annotation方法。
使用Annotation和简单工厂模式的Context类如下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
55
56
57
58
59
60
61
62
63package com.jasongj.context;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.commons.configuration.ConfigurationException;
import org.apache.commons.configuration.XMLConfiguration;
import org.reflections.Reflections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jasongj.strategy.Strategy;
public class SimpleFactoryContext {
private static final Logger LOG = LoggerFactory.getLogger(SimpleFactoryContext.class);
private static Map<String, Class> allStrategies;
static {
Reflections reflections = new Reflections("com.jasongj.strategy");
Set<Class<?>> annotatedClasses =
reflections.getTypesAnnotatedWith(com.jasongj.annotation.Strategy.class);
allStrategies = new ConcurrentHashMap<String, Class>();
for (Class<?> classObject : annotatedClasses) {
com.jasongj.annotation.Strategy strategy = (com.jasongj.annotation.Strategy) classObject
.getAnnotation(com.jasongj.annotation.Strategy.class);
allStrategies.put(strategy.name(), classObject);
}
allStrategies = Collections.unmodifiableMap(allStrategies);
}
private Strategy strategy;
public SimpleFactoryContext() {
String name = null;
try {
XMLConfiguration config = new XMLConfiguration("strategy.xml");
name = config.getString("strategy.name");
LOG.info("strategy name is {}", name);
} catch (ConfigurationException ex) {
LOG.error("Parsing xml configuration file failed", ex);
}
if (allStrategies.containsKey(name)) {
LOG.info("Created strategy name is {}", name);
try {
strategy = (Strategy) allStrategies.get(name).newInstance();
} catch (InstantiationException | IllegalAccessException ex) {
LOG.error("Instantiate Strategy failed", ex);
}
} else {
LOG.error("Specified Strategy name {} does not exist", name);
}
}
public void action(String input) {
strategy.strategy(input);
}
}
从上面的实现可以看出,虽然并没有单独创建一个简单工厂类,但它已经融入了简单工厂模式的设计思想和实现方法。
客户端调用方式如下1
2
3
4
5
6
7
8
9
10
11
12package com.jasongj.client;
import com.jasongj.context.SimpleFactoryContext;
public class SimpleFactoryClient {
public static void main(String[] args) {
SimpleFactoryContext context = new SimpleFactoryContext();
context.action("Hellow, world");
}
}
从上面代码可以看出,引入简单工厂模式后,客户端不再需要直接实例化具体的策略类,也不需要判断应该使用何种策略,可以方便应对策略的切换。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/design_pattern/flyweight/
面向对象技术可以很好的解决一些灵活性或可扩展性问题,但在很多情况下需要在系统中增加类和对象的个数。当对象数量太多时,将导致对象创建及垃圾回收的代价过高,造成性能下降等问题。享元模式通过共享相同或者相似的细粒度对象解决了这一类问题。
享元模式(Flyweight Pattern),又称轻量级模式(这也是其英文名为FlyWeight的原因),通过共享技术有效地实现了大量细粒度对象的复用。
享元模式类图如下
本文代码可从作者Github下载
享元接口,定义共享接口1
2
3
4
5
6
7package com.jasongj.flyweight;
public interface FlyWeight {
void action(String externalState);
}
具体享元类,实现享元接口。该类的对象将被复用1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21package com.jasongj.flyweight;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ConcreteFlyWeight implements FlyWeight {
private static final Logger LOG = LoggerFactory.getLogger(ConcreteFlyWeight.class);
private String name;
public ConcreteFlyWeight(String name) {
this.name = name;
}
public void action(String externalState) {
LOG.info("name = {}, outerState = {}", this.name, externalState);
}
}
享元模式中,最关键的享元工厂。它将维护已创建的享元实例,并通过实例标记(一般用内部状态)去索引对应的实例。当目标对象未创建时,享元工厂负责创建实例并将其加入标记-对象映射。当目标对象已创建时,享元工厂直接返回已有实例,实现对象的复用。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
31package com.jasongj.factory;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jasongj.flyweight.ConcreteFlyWeight;
import com.jasongj.flyweight.FlyWeight;
public class FlyWeightFactory {
private static final Logger LOG = LoggerFactory.getLogger(FlyWeightFactory.class);
private static ConcurrentHashMap<String, FlyWeight> allFlyWeight = new ConcurrentHashMap<String, FlyWeight>();
public static FlyWeight getFlyWeight(String name) {
if (allFlyWeight.get(name) == null) {
synchronized (allFlyWeight) {
if (allFlyWeight.get(name) == null) {
LOG.info("Instance of name = {} does not exist, creating it");
FlyWeight flyWeight = new ConcreteFlyWeight(name);
LOG.info("Instance of name = {} created");
allFlyWeight.put(name, flyWeight);
}
}
}
return allFlyWeight.get(name);
}
}
从上面代码中可以看到,享元模式中对象的复用完全依靠享元工厂。同时本例中实现了对象创建的懒加载。并且为了保证线程安全及效率,本文使用了双重检查(Double Check)。
本例中,name
可以认为是内部状态,在构造时确定。externalState
属于外部状态,由客户端在调用时传入。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/design_pattern/singleton/
对于系统中的某些类来说,只有一个实例很重要,例如,一个系统只能有一个窗口管理器或文件系统;一个系统只能有一个计时工具或ID(序号)生成器。
1 | package com.jasongj.singleton1; |
1 | package com.jasongj.singleton2; |
1 | package com.jasongj.singleton3; |
synchronized
,但本质上是线程不安全的。1 | package com.jasongj.singleton4; |
注:
synchronized
已经保证了INSTANCE
写操作对其它线程读操作的可见性。具体原理请参考《Java进阶(二)当我们说线程安全时,到底在说什么》volatile
关键字的目的不是保证可见性(synchronized
已经保证了可见性),而是为了保证顺序性。具体来说,INSTANCE = new Singleton()
不是原子操作,实际上被拆分为了三步:1) 分配内存;2) 初始化对象;3) 将INSTANCE指向分配的对象内存地址。 如果没有volatile
,可能会发生指令重排,使得INSTANCE先指向内存地址,而对象尚未初始化,其它线程直接使用INSTANCE引用进行对象操作时出错。详细原理可参见《双重检查锁定与延迟初始化》1 | package com.jasongj.singleton6; |
1 | package com.jasongj.singleton7; |
1 | package com.jasongj.singleton8; |
getInstance
时才会装载内部类,才会创建实例。同时因为使用内部类时,先调用内部类的线程会获得类初始化锁,从而保证内部类的初始化(包括实例化它所引用的外部类对象)线程安全。即使内部类创建外部类的实例Singleton INSTANCE = new Singleton()
发生指令重排也不会引起双重检查(Double-Check)下的懒汉模式中提到的问题,因此无须使用volatile
关键字。1 | package com.jasongj.singleton9; |
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/design_pattern/bridge/
桥接模式(Bridge Pattern),将抽象部分与它的实现部分分离,使它们都可以独立地变化。更容易理解的表述是:实现系统可从多种维度分类,桥接模式将各维度抽象出来,各维度独立变化,之后可通过聚合,将各维度组合起来,减少了各维度间的耦合。
汽车可按品牌分(本例中只考虑BMT,BenZ,Land Rover),也可按手动档、自动档、手自一体来分。如果对于每一种车都实现一个具体类,则一共要实现3*3=9个类。
使用继承方式的类图如下
从上图可以看到,对于每种组合都需要创建一个具体类,如果有N个维度,每个维度有M种变化,则需要$M^N$个具体类,类非常多,并且非常多的重复功能。
如果某一维度,如Transmission多一种可能,比如手自一体档(AMT),则需要增加3个类,BMWAMT,BenZAMT,LandRoverAMT。
桥接模式类图如下
从上图可知,当把每个维度拆分开来,只需要M*N个类,并且由于每个维度独立变化,基本不会出现重复代码。
此时如果增加手自一体档,只需要增加一个AMT类即可
本文代码可从作者Github下载
抽象车1
2
3
4
5
6
7
8
9
10
11
12
13
14
15package com.jasongj.brand;
import com.jasongj.transmission.Transmission;
public abstract class AbstractCar {
protected Transmission gear;
public abstract void run();
public void setTransmission(Transmission gear) {
this.gear = gear;
}
}
按品牌分,BMW牌车1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16package com.jasongj.brand;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class BMWCar extends AbstractCar{
private static final Logger LOG = LoggerFactory.getLogger(BMWCar.class);
public void run() {
gear.gear();
LOG.info("BMW is running");
};
}
BenZCar1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16package com.jasongj.brand;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class BenZCar extends AbstractCar{
private static final Logger LOG = LoggerFactory.getLogger(BenZCar.class);
public void run() {
gear.gear();
LOG.info("BenZCar is running");
};
}
LandRoverCar1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16package com.jasongj.brand;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LandRoverCar extends AbstractCar{
private static final Logger LOG = LoggerFactory.getLogger(LandRoverCar.class);
public void run() {
gear.gear();
LOG.info("LandRoverCar is running");
};
}
抽象变速器1
2
3
4
5
6
7package com.jasongj.transmission;
public abstract class Transmission{
public abstract void gear();
}
手动档1
2
3
4
5
6
7
8
9
10
11
12
13
14package com.jasongj.transmission;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class Manual extends Transmission {
private static final Logger LOG = LoggerFactory.getLogger(Manual.class);
public void gear() {
LOG.info("Manual transmission");
}
}
自动档1
2
3
4
5
6
7
8
9
10
11
12
13
14package com.jasongj.transmission;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class Auto extends Transmission {
private static final Logger LOG = LoggerFactory.getLogger(Auto.class);
public void gear() {
LOG.info("Auto transmission");
}
}
有了变速器和品牌两个维度各自的实现后,可以通过聚合,实现不同品牌不同变速器的车,如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25package com.jasongj.client;
import com.jasongj.brand.AbstractCar;
import com.jasongj.brand.BMWCar;
import com.jasongj.brand.BenZCar;
import com.jasongj.transmission.Auto;
import com.jasongj.transmission.Manual;
import com.jasongj.transmission.Transmission;
public class BridgeClient {
public static void main(String[] args) {
Transmission auto = new Auto();
AbstractCar bmw = new BMWCar();
bmw.setTransmission(auto);
bmw.run();
Transmission manual = new Manual();
AbstractCar benz = new BenZCar();
benz.setTransmission(manual);
benz.run();
}
}
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/design_pattern/adapter/
适配器模式(Adapter Pattern),将一个类的接口转换成客户希望的另外一个接口。适配器模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。
适配器模式类图如下
本文代码可从作者Github下载
目标接口1
2
3
4
5
6
7package com.jasongj.target;
public interface ITarget {
void request();
}
目标接口实现1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16package com.jasongj.target;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ConcreteTarget implements ITarget {
private static Logger LOG = LoggerFactory.getLogger(ConcreteTarget.class);
public void request() {
LOG.info("ConcreteTarget.request()");
}
}
待适配类,其接口名为onRequest,而非目标接口request1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16package com.jasongj.adaptee;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jasongj.target.ConcreteTarget;
public class Adaptee {
private static Logger LOGGER = LoggerFactory.getLogger(ConcreteTarget.class);
public void onRequest() {
LOGGER.info("Adaptee.onRequest()");
}
}
适配器类1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20package com.jasongj.target;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jasongj.adaptee.Adaptee;
public class Adapter implements ITarget {
private static Logger LOG = LoggerFactory.getLogger(Adapter.class);
private Adaptee adaptee = new Adaptee();
public void request() {
LOG.info("Adapter.request");
adaptee.onRequest();
}
}
从上面代码可看出,适配器类实际上是目标接口的类,因为持有待适配类的实例,所以可以在适配器类的目标接口被调用时,调用待适配对象的接口,而客户端并不需要知道二者接口的不同。通过这种方式,客户端可以使用统一的接口使用不同接口的类。
1 | package com.jasongj.client; |
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/design_pattern/dynamic_proxy_cglib/
静态代理,是指程序运行前就已经存在了代理类的字节码文件,代理类和被代理类的关系在运行前就已经确定。
上一篇文章《Java设计模式(六) 代理模式 VS. 装饰模式》所讲的代理为静态代理。如上文所讲,一个静态代理类只代理一个具体类。如果需要对实现了同一接口的不同具体类作代理,静态代理需要为每一个具体类创建相应的代理类。
动态代理类的字节码是在程序运行期间动态生成,所以不存在代理类的字节码文件。代理类和被代理类的关系是在程序运行时确定的。
JDK从1.3开始引入动态代理。可通过java.lang.reflect.Proxy
类的静态方法Proxy.newProxyInstance
动态创建代理类和实例。并且由它动态创建出来的代理类都是Proxy类的子类。
代理类往往会在代理对象业务逻辑前后增加一些功能性的行为,如使用事务或者打印日志。本文把这些行为称之为代理行为。
使用JDK动态代理,需要创建一个实现java.lang.reflect.InvocationHandler
接口的类,并在该类中定义代理行为。
1 | package com.jasongj.proxy.jdkproxy; |
从上述代码中可以看到,被代理对象的类对象作为参数传给了构造方法,原因如下
注意,SubjectProxyHandler定义的是代理行为而非代理类本身。实际上代理类及其实例是在运行时通过反射动态创建出来的。
代理行为定义好后,先实例化SubjectProxyHandler(在构造方法中指明被代理类),然后通过Proxy.newProxyInstance动态创建代理类的实例。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20package com.jasongj.client;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import com.jasongj.proxy.jdkproxy.SubjectProxyHandler;
import com.jasongj.subject.ConcreteSubject;
import com.jasongj.subject.ISubject;
public class JDKDynamicProxyClient {
public static void main(String[] args) {
InvocationHandler handler = new SubjectProxyHandler(ConcreteSubject.class);
ISubject proxy =
(ISubject) Proxy.newProxyInstance(JDKDynamicProxyClient.class.getClassLoader(),
new Class[] {ISubject.class}, handler);
proxy.action();
}
}
从上述代码中也可以看到,Proxy.newProxyInstance的第二个参数是类对象数组,也就意味着被代理对象可以实现多个接口。
运行结果如下1
2
3
4SubjectProxyHandler.preAction()
ConcreteSubject action()
SubjectProxyHandler.postAction()
Proxy class name com.sun.proxy.$Proxy18
从上述结果可以看到,定义的代理行为顺利的加入到了执行逻辑中。同时,最后一行日志说明了代理类的类名是com.sun.proxy.$Proxy18
,验证了上文的论点——SubjectProxyHandler定义的是代理行为而非代理类本身,代理类及其实例是在运行时通过反射动态创建出来的。
Proxy.newProxyInstance是通过静态方法ProxyGenerator.generateProxyClass
动态生成代理类的字节码的。为了观察创建出来的代理类的结构,本文手工调用该方法,得到了代理类的字节码,并将之输出到了class文件中。
1 | byte[] classFile = ProxyGenerator.generateProxyClass("$Proxy18", ConcreteSubject.class.getInterfaces()); |
使用反编译工具可以得到代理类的代码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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71import com.jasongj.subject.ISubject;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;
public final class $Proxy18 extends Proxy implements ISubject {
private static Method m1;
private static Method m2;
private static Method m0;
private static Method m3;
public $Proxy18(InvocationHandler paramInvocationHandler) {
super(paramInvocationHandler);
}
public final boolean equals(Object paramObject) {
try {
return ((Boolean) this.h.invoke(this, m1, new Object[] {paramObject})).booleanValue();
} catch (Error | RuntimeException localError) {
throw localError;
} catch (Throwable localThrowable) {
throw new UndeclaredThrowableException(localThrowable);
}
}
public final String toString() {
try {
return (String) this.h.invoke(this, m2, null);
} catch (Error | RuntimeException localError) {
throw localError;
} catch (Throwable localThrowable) {
throw new UndeclaredThrowableException(localThrowable);
}
}
public final int hashCode() {
try {
return ((Integer) this.h.invoke(this, m0, null)).intValue();
} catch (Error | RuntimeException localError) {
throw localError;
} catch (Throwable localThrowable) {
throw new UndeclaredThrowableException(localThrowable);
}
}
public final void action() {
try {
this.h.invoke(this, m3, null);
return;
} catch (Error | RuntimeException localError) {
throw localError;
} catch (Throwable localThrowable) {
throw new UndeclaredThrowableException(localThrowable);
}
}
static {
try {
m1 = Class.forName("java.lang.Object").getMethod("equals",
new Class[] {Class.forName("java.lang.Object")});
m2 = Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
m0 = Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
m3 = Class.forName("com.jasongj.subject.ISubject").getMethod("action", new Class[0]);
} catch (NoSuchMethodException localNoSuchMethodException) {
throw new NoSuchMethodError(localNoSuchMethodException.getMessage());
} catch (ClassNotFoundException localClassNotFoundException) {
throw new NoClassDefFoundError(localClassNotFoundException.getMessage());
}
}
}
从该类的声明中可以看到,继承了Proxy类,并实现了ISubject接口。验证了上文中的论点——所有生成的动态代理类都是Proxy类的子类。同时也解释了为什么JDK动态代理只能代理实现了接口的类——Java不支持多继承,代理类已经继承了Proxy类,无法再继承其它类。
同时,代理类重写了hashCode,toString和equals这三个从Object继承下来的接口,通过InvocationHandler的invoke方法去实现。除此之外,该代理类还实现了ISubject接口的action方法,也是通过InvocationHandler的invoke方法去实现。这就解释了示例代码中代理行为是怎样被调用的。
前文提到,被代理类可以实现多个接口。从代理类代码中可以看到,代理类是通过InvocationHandler的invoke方法去实现代理接口的。所以当被代理对象实现了多个接口并且希望对不同接口实施不同的代理行为时,应该在SubjectProxyHandler类,也即代理行为定义类中,通过判断方法名,实现不同的代理行为。
cglib是一个强大的高性能代码生成库,它的底层是通过使用一个小而快的字节码处理框架ASM(Java字节码操控框架)来转换字节码并生成新的类。
使用cglib实现动态代理,需要在MethodInterceptor实现类中定义代理行为。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
32package com.jasongj.proxy.cglibproxy;
import java.lang.reflect.Method;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
public class SubjectInterceptor implements MethodInterceptor {
private static final Logger LOG = LoggerFactory.getLogger(SubjectInterceptor.class);
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy)
throws Throwable {
preAction();
Object result = proxy.invokeSuper(obj, args);
postAction();
return result;
}
private void preAction() {
LOG.info("SubjectProxyHandler.preAction()");
}
private void postAction() {
LOG.info("SubjectProxyHandler.postAction()");
}
}
代理行为在intercept方法中定义,同时通过getInstance方法(该方法名可以自定义)获取动态代理的实例,并且可以通过向该方法传入类对象指定被代理对象的类型。
1 | package com.jasongj.client; |
分别使用JDK动态代理创建代理对象1亿次,并分别执行代理对象方法10亿次,代码如下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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82package com.jasongj.client;
import java.io.IOException;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jasongj.proxy.cglibproxy.SubjectInterceptor;
import com.jasongj.proxy.jdkproxy.SubjectProxyHandler;
import com.jasongj.subject.ConcreteSubject;
import com.jasongj.subject.ISubject;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
public class DynamicProxyPerfClient {
private static final Logger LOG = LoggerFactory.getLogger(DynamicProxyPerfClient.class);
private static int creation = 100000000;
private static int execution = 1000000000;
public static void main(String[] args) throws IOException {
testJDKDynamicCreation();
testJDKDynamicExecution();
testCglibCreation();
testCglibExecution();
}
private static void testJDKDynamicCreation() {
long start = System.currentTimeMillis();
for (int i = 0; i < creation; i++) {
InvocationHandler handler = new SubjectProxyHandler(ConcreteSubject.class);
Proxy.newProxyInstance(DynamicProxyPerfClient.class.getClassLoader(),
new Class[] {ISubject.class}, handler);
}
long stop = System.currentTimeMillis();
LOG.info("JDK creation time : {} ms", stop - start);
}
private static void testJDKDynamicExecution() {
long start = System.currentTimeMillis();
InvocationHandler handler = new SubjectProxyHandler(ConcreteSubject.class);
ISubject subject =
(ISubject) Proxy.newProxyInstance(DynamicProxyPerfClient.class.getClassLoader(),
new Class[] {ISubject.class}, handler);
for (int i = 0; i < execution; i++) {
subject.action();
}
long stop = System.currentTimeMillis();
LOG.info("JDK execution time : {} ms", stop - start);
}
private static void testCglibCreation() {
long start = System.currentTimeMillis();
for (int i = 0; i < creation; i++) {
MethodInterceptor methodInterceptor = new SubjectInterceptor();
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(ConcreteSubject.class);
enhancer.setCallback(methodInterceptor);
enhancer.create();
}
long stop = System.currentTimeMillis();
LOG.info("cglib creation time : {} ms", stop - start);
}
private static void testCglibExecution() {
MethodInterceptor methodInterceptor = new SubjectInterceptor();
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(ConcreteSubject.class);
enhancer.setCallback(methodInterceptor);
ISubject subject = (ISubject) enhancer.create();
long start = System.currentTimeMillis();
for (int i = 0; i < execution; i++) {
subject.action();
}
long stop = System.currentTimeMillis();
LOG.info("cglib execution time : {} ms", stop - start);
}
}
结果如下1
2
3
4JDK creation time : 9924 ms
JDK execution time : 3472 ms
cglib creation time : 16108 ms
cglib execution time : 6309 ms
该性能测试表明,JDK动态代理创建代理对象速度是cglib的约1.6倍,并且JDK创建出的代理对象执行速度是cglib代理对象执行速度的约1.8倍
本文所有示例代理均可从作者Github下载
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/design_pattern/proxy_decorator/
从语意上讲,代理模式的目标是控制对被代理对象的访问,而装饰模式是给原对象增加额外功能。
代理模式类图如下
装饰模式类图如下
从上图可以看到,代理模式和装饰模式的类图非常类似。下面结合具体的代码讲解两者的不同。
本文所有代码均可从作者Github下载
代理模式和装饰模式都包含ISubject和ConcreteSubject,并且这两种模式中这两个Component的实现没有任何区别。
ISubject代码如下1
2
3
4
5
6
7package com.jasongj.subject;
public interface ISubject {
void action();
}
ConcreteSubject代码如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16package com.jasongj.subject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ConcreteSubject implements ISubject {
private static final Logger LOG = LoggerFactory.getLogger(ConcreteSubject.class);
public void action() {
LOG.info("ConcreteSubject action()");
}
}
代理类实现方式如下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
39package com.jasongj.proxy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Random;
import com.jasongj.subject.ConcreteSubject;
import com.jasongj.subject.ISubject;
public class ProxySubject implements ISubject {
private static final Logger LOG = LoggerFactory.getLogger(ProxySubject.class);
private ISubject subject;
public ProxySubject() {
subject = new ConcreteSubject();
}
public void action() {
preAction();
if((new Random()).nextBoolean()){
subject.action();
} else {
LOG.info("Permission denied");
}
postAction();
}
private void preAction() {
LOG.info("ProxySubject.preAction()");
}
private void postAction() {
LOG.info("ProxySubject.postAction()");
}
}
从上述代码中可以看到,被代理对象由代理对象在编译时确定,并且代理对象可能限制对被代理对象的访问。
代理模式使用方式如下1
2
3
4
5
6
7
8
9
10
11
12
13package com.jasongj.client;
import com.jasongj.proxy.ProxySubject;
import com.jasongj.subject.ISubject;
public class StaticProxyClient {
public static void main(String[] args) {
ISubject subject = new ProxySubject();
subject.action();
}
}
从上述代码中可以看到,调用方直接调用代理而不需要直接操作被代理对象甚至都不需要知道被代理对象的存在。同时,代理类可代理的具体被代理类是确定的,如本例中ProxySubject只可代理ConcreteSubject。
装饰类实现方式如下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
28package com.jasongj.decorator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jasongj.subject.ISubject;
public class SubjectPreDecorator implements ISubject {
private static final Logger LOG = LoggerFactory.getLogger(SubjectPreDecorator.class);
private ISubject subject;
public SubjectPreDecorator(ISubject subject) {
this.subject = subject;
}
public void action() {
preAction();
subject.action();
}
private void preAction() {
LOG.info("SubjectPreDecorator.preAction()");
}
}
1 | package com.jasongj.decorator; |
装饰模式使用方法如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17package com.jasongj.client;
import com.jasongj.decorator.SubjectPostDecorator;
import com.jasongj.decorator.SubjectPreDecorator;
import com.jasongj.subject.ConcreteSubject;
import com.jasongj.subject.ISubject;
public class DecoratorClient {
public static void main(String[] args) {
ISubject subject = new ConcreteSubject();
ISubject preDecorator = new SubjectPreDecorator(subject);
ISubject postDecorator = new SubjectPostDecorator(preDecorator);
postDecorator.action();
}
}
从上述代码中可以看出,装饰类可装饰的类并不固定,并且被装饰对象是在使用时通过组合确定。如本例中SubjectPreDecorator装饰ConcreteSubject,而SubjectPostDecorator装饰SubjectPreDecorator。并且被装饰对象由调用方实例化后通过构造方法(或者setter)指定。
装饰模式的本质是动态组合。动态是手段,组合是目的。每个装饰类可以只负责添加一项额外功能,然后通过组合为被装饰类添加复杂功能。由于每个装饰类的职责比较简单单一,增加了这些装饰类的可重用性,同时也更符合单一职责原则。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/design_pattern/composite/
组合模式(Composite Pattern)将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户可以使用一致的方法操作单个对象和组合对象。
组合模式类图如下
对于一家大型公司,每当公司高层有重要事项需要通知到总部每个部门以及分公司的各个部门时,并不希望逐一通知,而只希望通过总部各部门及分公司,再由分公司通知其所有部门。这样,对于总公司而言,不需要关心通知的是总部的部门还是分公司。
组合模式实例类图如下(点击可查看大图)
本例代码可从作者Github下载
抽象组件定义了组件的通知接口,并实现了增删子组件及获取所有子组件的方法。同时重写了hashCode
和equales
方法(至于原因,请读者自行思考。如有疑问,请在评论区留言)。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
47package com.jasongj.organization;
import java.util.ArrayList;
import java.util.List;
public abstract class Organization {
private List<Organization> childOrgs = new ArrayList<Organization>();
private String name;
public Organization(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void addOrg(Organization org) {
childOrgs.add(org);
}
public void removeOrg(Organization org) {
childOrgs.remove(org);
}
public List<Organization> getAllOrgs() {
return childOrgs;
}
public abstract void inform(String info);
public int hashCode(){
return this.name.hashCode();
}
public boolean equals(Object org){
if(!(org instanceof Organization)) {
return false;
}
return this.name.equals(((Organization) org).name);
}
}
简单组件在通知方法中只负责对接收到消息作出响应。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18package com.jasongj.organization;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class Department extends Organization{
public Department(String name) {
super(name);
}
private static Logger LOGGER = LoggerFactory.getLogger(Department.class);
public void inform(String info){
LOGGER.info("{}-{}", info, getName());
}
}
复合组件在自身对消息作出响应后,还须通知其下所有子组件1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22package com.jasongj.organization;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class Company extends Organization{
private static Logger LOGGER = LoggerFactory.getLogger(Company.class);
public Company(String name) {
super(name);
}
public void inform(String info){
LOGGER.info("{}-{}", info, getName());
List<Organization> allOrgs = getAllOrgs();
allOrgs.forEach(org -> org.inform(info+"-"));
}
}
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/design_pattern/observer/
观察者模式又叫发布-订阅模式,它定义了一种一对多的依赖关系,多个观察者对象可同时监听某一主题对象,当该主题对象状态发生变化时,相应的所有观察者对象都可收到通知。
观察者模式类图如下(点击可查看大图)
猎头或者HR往往会有很多职位信息,求职者可以在猎头或者HR那里注册,当猎头或者HR有新的岗位信息时,即会通知这些注册过的求职者。这是一个典型的观察者模式使用场景。
观察者模式实例类图如下(点击可查看大图)
本例代码可从作者Github下载
观察者接口(或抽象观察者,如本例中的ITalent)需要定义回调接口,如下1
2
3
4
5
6
7package com.jasongj.observer;
public interface ITalent {
void newJob(String job);
}
具体观察者(如本例中的JuniorEngineer,SeniorEngineer,Architect)在回调接口中实现其对事件的响应方法,如1
2
3
4
5
6
7
8
9
10
11
12
13
14
15package com.jasongj.observer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class Architect implements ITalent {
private static final Logger LOG = LoggerFactory.getLogger(Architect.class);
public void newJob(String job) {
LOG.info("Architect get new position {}", job);
}
}
抽象主题类(如本例中的AbstractHR)定义通知观察者接口,并实现增加观察者和删除观察者方法(这两个方法可被子类共用,所以放在抽象类中实现),如1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22package com.jasongj.subject;
import java.util.ArrayList;
import java.util.Collection;
import com.jasongj.observer.ITalent;
public abstract class AbstractHR {
protected Collection<ITalent> allTalents = new ArrayList<ITalent>();
public abstract void publishJob(String job);
public void addTalent(ITalent talent) {
allTalents.add(talent);
}
public void removeTalent(ITalent talent) {
allTalents.remove(talent);
}
}
具体主题类(如本例中的HeadHunter)只需实现通知观察者接口,在该方法中通知所有注册的具体观察者。代码如下1
2
3
4
5
6
7
8
9
10package com.jasongj.subject;
public class HeadHunter extends AbstractHR {
public void publishJob(String job) {
allTalents.forEach(talent -> talent.newJob(job));
}
}
当主题类有更新(如本例中猎头有新的招聘岗位)时,调用其通知接口即可将其状态(岗位)通知给所有观察者(求职者)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25package com.jasongj.client;
import com.jasongj.observer.Architect;
import com.jasongj.observer.ITalent;
import com.jasongj.observer.JuniorEngineer;
import com.jasongj.observer.SeniorEngineer;
import com.jasongj.subject.HeadHunter;
import com.jasongj.subject.AbstractHR;
public class Client1 {
public static void main(String[] args) {
ITalent juniorEngineer = new JuniorEngineer();
ITalent seniorEngineer = new SeniorEngineer();
ITalent architect = new Architect();
AbstractHR subject = new HeadHunter();
subject.addTalent(juniorEngineer);
subject.addTalent(seniorEngineer);
subject.addTalent(architect);
subject.publishJob("Top 500 big data position");
}
}
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/design_pattern/abstract_factory/
上文《工厂方法模式》中提到,在工厂方法模式中一种工厂只能创建一种具体产品。而在抽象工厂模式中一种具体工厂可以创建多个种类的具体产品。
抽象工厂模式(Factory Method Pattern)中,抽象工厂提供一系列创建多个抽象产品的接口,而具体的工厂负责实现具体的产品实例。抽象工厂模式与工厂方法模式最大的区别在于抽象工厂中每个工厂可以创建多个种类的产品。
抽象工厂模式类图如下 (点击可查看大图)
与工厂方法模式类似,在创建具体产品时,客户端通过实例化具体的工厂类,并调用其创建目标产品的方法创建具体产品类的实例。根据依赖倒置原则,具体工厂类的实例由工厂接口引用,具体产品的实例由产品接口引用。具体调用代码如下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
31package com.jasongj.client;
import com.jasongj.bean.Product;
import com.jasongj.bean.User;
import com.jasongj.dao.role.IRoleDao;
import com.jasongj.dao.user.IUserDao;
import com.jasongj.dao.user.product.IProductDao;
import com.jasongj.factory.IDaoFactory;
import com.jasongj.factory.MySQLDaoFactory;
public class Client {
public static void main(String[] args) {
IDaoFactory factory = new MySQLDaoFactory();
IUserDao userDao = factory.createUserDao();
User user = new User();
user.setUsername("demo");
user.setPassword("demo".toCharArray());
userDao.addUser(user);
IRoleDao roleDao = factory.createRoleDao();
roleDao.getRole("admin");
IProductDao productDao = factory.createProductDao();
Product product = new Product();
productDao.removeProduct(product);
}
}
本文所述抽象工厂模式示例代码可从作者Github下载
上例是J2EE开发中常用的DAO(Data Access Object),操作对象(如User和Role,对应于数据库中表的记录)需要对应的DAO类。
在实际项目开发中,经常会碰到要求使用其它类型的数据库,而不希望过多修改已有代码。因此,需要为每种DAO创建一个DAO接口(如IUserDao,IRoleDao和IProductDao),同时为不同数据库实现相应的具体类。
调用方依赖于DAO接口而非具体实现(依赖倒置原则),因此切换数据库时,调用方代码无需修改。
这些具体的DAO实现类往往不由调用方实例化,从而实现具体DAO的使用方与DAO的构建解耦。实际上,这些DAO类一般由对应的具体工厂类构建。调用方不依赖于具体工厂而是依赖于抽象工厂(依赖倒置原则,又是依赖倒置原则)。
每种具体工厂都能创建多种产品,由同一种工厂创建的产品属于同一产品族。例如PostgreSQLUserDao,PostgreSQLRoleDao和PostgreSQLProductDao都属于PostgreSQL这一产品族。
切换数据库即是切换产品族,只需要切换具体的工厂类。如上文示例代码中,客户端使用的MySQL,如果要换用Oracle,只需将MySQLDaoFactory换成OracleDaoFactory即可。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/design_pattern/factory_method/
上文《简单工厂模式不简单》中提到,简单工厂模式有如下缺点,而工厂方法模式可以解决这些问题
工厂方法模式(Factory Method Pattern)又称为工厂模式,也叫多态工厂模式或者虚拟构造器模式。在工厂方法模式中,工厂父类定义创建产品对象的公共接口,具体的工厂子类负责创建具体的产品对象。每一个工厂子类负责创建一种具体产品。
工厂模式类图如下 (点击可查看大图)
如简单工厂模式直接使用静态工厂方法创建产品对象不同,在工厂方法,客户端通过实例化具体的工厂类,并调用其创建实例接口创建具体产品类的实例。根据依赖倒置原则,具体工厂类的实例由工厂接口引用(客户端依赖于抽象工厂而非具体工厂),具体产品的实例由产品接口引用(客户端和工厂依赖于抽象产品而非具体产品)。具体调用代码如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16package com.jasongj.client;
import com.jasongj.dao.IUserDao;
import com.jasongj.factory.IDaoFactory;
import com.jasongj.factory.MySQLDaoFactory;
public class Client {
public static void main(String[] args) {
IDaoFactory factory = new MySQLDaoFactory();
IUserDao userDao = factory.createUserDao();
userDao.getUser("admin");
}
}
本文所述工厂方法模式示例代码可从作者Github下载
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/sql/cte/
WITH语句通常被称为通用表表达式(Common Table Expressions)或者CTEs。
WITH语句作为一个辅助语句依附于主语句,WITH语句和主语句都可以是SELECT
,INSERT
,UPDATE
,DELETE
中的任何一种语句。
WITH语句最基本的功能是把复杂查询语句拆分成多个简单的部分,如下例所示1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17WITH regional_sales AS (
SELECT region, SUM(amount) AS total_sales
FROM orders
GROUP BY region
), top_regions AS (
SELECT region
FROM regional_sales
WHERE total_sales > (SELECT SUM(total_sales)/10 FROM regional_sales
)
SELECT
region,
product,
SUM(quantity) AS product_units,
SUM(amount) AS product_sales
FROM orders
WHERE region IN (SELECT region FROM top_regions)
GROUP BY region, product;
该例中,定义了两个WITH辅助语句,regional_sales和top_regions。前者算出每个区域的总销售量,后者了查出所有销售量占所有地区总销售里10%以上的区域。主语句通过将这个CTEs及订单表关联,算出了顶级区域每件商品的销售量和销售额。
当然,本例也可以不使用CTEs而使用两层嵌套子查询来实现,但使用CTEs更简单,更清晰,可读性更强。
文章开头处提到,WITH中可以不仅可以使用SELECT
语句,同时还能使用DELETE
,UPDATE
,INSERT
语句。因此,可以使用WITH,在一条SQL语句中进行不同的操作,如下例所示。1
2
3
4
5
6
7
8
9WITH moved_rows AS (
DELETE FROM products
WHERE
"date" >= '2010-10-01'
AND "date" < '2010-11-01'
RETURNING *
)
INSERT INTO products_log
SELECT * FROM moved_rows;
本例通过WITH中的DELETE语句从products表中删除了一个月的数据,并通过RETURNING
子句将删除的数据集赋给moved_rows这一CTE,最后在主语句中通过INSERT将删除的商品插入products_log中。
如果WITH里面使用的不是SELECT语句,并且没有通过RETURNING子句返回结果集,则主查询中不可以引用该CTE,但主查询和WITH语句仍然可以继续执行。这种情况可以实现将多个不相关的语句放在一个SQL语句里,实现了在不显式使用事务的情况下保证WITH语句和主语句的事务性,如下例所示。1
2
3
4
5
6
7
8WITH d AS (
DELETE FROM foo
),
u as (
UPDATE foo SET a = 1
WHERE b = 2
)
DELETE FROM bar;
WITH语句还可以通过增加RECURSIVE
修饰符来引入它自己,从而实现递归
WITH RECURSIVE一般用于处理逻辑上层次化或树状结构的数据,典型的使用场景是寻找直接及间接子结点。
定义下面这样的表,存储每个区域(省、市、区)的id,名字及上级区域的id1
2
3
4
5
6CREATE TABLE chinamap
(
id INTEGER,
pid INTEGER,
name TEXT
);
需要查出某个省,比如湖北省,管辖的所有市及市辖地区,可以通过WITH RECURSIVE来实现,如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19WITH RECURSIVE result AS
(
SELECCT
id,
name
FROM chinamap
WHERE id = 11
UNION ALL
SELECT
origin.id,
result.name || ' > ' || origin.name
FROM result
JOIN chinamap origin
ON origin.pid = result.id
)
SELECT
id,
name
FROM result;
结果如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 id | name
-----+--------------------------
11 | 湖北省
110 | 湖北省 > 武汉市
120 | 湖北省 > 孝感市
130 | 湖北省 > 宜昌市
140 | 湖北省 > 随州市
150 | 湖北省 > 仙桃市
160 | 湖北省 > 荆门市
170 | 湖北省 > 枝江市
180 | 湖北省 > 神农架市
111 | 湖北省 > 武汉市 > 武昌区
112 | 湖北省 > 武汉市 > 下城区
113 | 湖北省 > 武汉市 > 江岸区
114 | 湖北省 > 武汉市 > 江汉区
115 | 湖北省 > 武汉市 > 汉阳区
116 | 湖北省 > 武汉市 > 洪山区
117 | 湖北省 > 武汉市 > 青山区
(16 rows)
从上面的例子可以看出,WITH RECURSIVE语句包含了两个部分
执行步骤如下
以上面的query为例,来看看具体过程
1.执行1
2
3
4
5SELECT
id,
name
FROM chinamap
WHERE id = 11
结果集和working table为1
11 | 湖北
2.执行1
2
3
4
5
6SELECT
origin.id,
result.name || ' > ' || origin.name
FROM result
JOIN chinamap origin
ON origin.pid = result.id
结果集和working table为1
2
3
4
5
6
7
8110 | 湖北省 > 武汉市
120 | 湖北省 > 孝感市
130 | 湖北省 > 宜昌市
140 | 湖北省 > 随州市
150 | 湖北省 > 仙桃市
160 | 湖北省 > 荆门市
170 | 湖北省 > 枝江市
180 | 湖北省 > 神农架市
3.再次执行recursive query,结果集和working table为1
2
3
4
5
6
7111 | 湖北省 > 武汉市 > 武昌区
112 | 湖北省 > 武汉市 > 下城区
113 | 湖北省 > 武汉市 > 江岸区
114 | 湖北省 > 武汉市 > 江汉区
115 | 湖北省 > 武汉市 > 汉阳区
116 | 湖北省 > 武汉市 > 洪山区
117 | 湖北省 > 武汉市 > 青山区
4.继续执行recursive query,结果集和working table为空
5.结束递归,将前三个步骤的结果集合并,即得到最终的WITH RECURSIVE的结果集
严格来讲,这个过程实现上是一个迭代的过程而非递归,不过RECURSIVE这个关键词是SQL标准委员会定立的,所以PostgreSQL也延用了RECURSIVE这一关键词。
从上一节中可以看到,决定是否继续迭代的working table是否为空,如果它永不为空,则该CTE将陷入无限循环中。
对于本身并不会形成循环引用的数据集,无段作特别处理。而对于本身可能形成循环引用的数据集,则须通过SQL处理。
一种方式是使用UNION而非UNION ALL,从而每次recursive term的计算结果都会将已经存在的数据清除后再存入working table,使得working table最终会为空,从而结束迭代。
然而,这种方法并不总是有效的,因为有时可能需要这些重复数据。同时UNION只能去除那些所有字段都完全一样的记录,而很有可能特定字段集相同的记录即应该被删除。此时可以通过数组(单字段)或者ROW(多字段)记录已经访问过的记录,从而实现去重的目的。
定义无向有环图如下图所示
定义如下表并存入每条边的权重1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17CREATE TABLE graph
(
id char,
neighbor char,
value integer
);
INSERT INTO graph
VALUES('A', 'B', 3),
('A', 'C', 5),
('A', 'D', 4),
('B', 'E', 8),
('B', 'C', 4),
('E', 'C', 7),
('E','F', 10),
('C', 'D', 3),
('C', 'F', 6),
('F','D', 5);
计算思路如下:
实现如下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 WITH RECURSIVE edges AS (
SELECT id, neighbor, value FROM graph
UNION ALL
SELECT neighbor, id, value
FROM graph
),
all_path (id, neighbor, value, path, depth, cycle) AS (
SELECT
id, neighbor, value, ARRAY[id], 1, 'f'::BOOLEAN
FROM edges
WHERE id = 'A'
UNION ALL
SELECT
all_path.id,
edges.neighbor,
edges.value + all_path.value,
all_path.path || ARRAY[edges.id],
depth + 1,
edges.id = ANY(all_path.path)
FROM edges
JOIN all_path
ON all_path.neighbor = edges.id
AND NOT cycle
), a_f AS (
SELECT
rank() over(order by value) AS rank,
path || neighbor AS path,
value,
depth
FROM all_path
WHERE neighbor = 'F'
)
SELECT path, value, depth
FROM a_f
WHERE rank = 1;
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/design_pattern/simple_factory
有一种抽象产品——汽车(Car),同时有多种具体的子类产品,如BenzCar,BMWCar,LandRoverCar。类图如下
作为司机,如果要开其中一种车,比如BenzCar,最直接的做法是直接创建BenzCar的实例,并执行其drive方法,如下1
2
3
4
5
6
7
8
9
10
11
12package com.jasongj.client;
import com.jasongj.product.BenzCar;
public class Driver1 {
public static void main(String[] args) {
BenzCar car = new BenzCar();
car.drive();
}
}
此时如果要改为开Land Rover,则需要修改代码,创建Land Rover的实例并执行其drive方法。这也就意味着任何时候需要换一辆车开的时候,都必须修改客户端代码。
一种稍微好点的方法是,通过读取配置文件,获取需要开的车,然后创建相应的实例并由父类Car的引用指向它,利用多态执行不同车的drive方法。如下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
40package com.jasongj.client;
import org.apache.commons.configuration.ConfigurationException;
import org.apache.commons.configuration.XMLConfiguration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jasongj.product.BMWCar;
import com.jasongj.product.BenzCar;
import com.jasongj.product.Car;
import com.jasongj.product.LandRoverCar;
public class Driver2 {
private static final Logger LOG = LoggerFactory.getLogger(Driver2.class);
public static void main(String[] args) throws ConfigurationException {
XMLConfiguration config = new XMLConfiguration("car.xml");
String name = config.getString("driver2.name");
Car car;
switch (name) {
case "Land Rover":
car = new LandRoverCar();
break;
case "BMW":
car = new BMWCar();
break;
case "Benz":
car = new BenzCar();
break;
default:
car = null;
break;
}
LOG.info("Created car name is {}", name);
car.drive();
}
}
对于Car的使用方而言,只需要通过参数即可指定所需要Car的各类并得到其实例,同时无论使用哪种Car,都不需要修改后续对Car的操作。至此,简单工厂模式的原型已经形成。如果把上述的逻辑判断封装到一个专门的类的静态方法中,则实现了简单工厂模式。工厂代码如下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
45package com.jasongj.factory;
import org.apache.commons.configuration.ConfigurationException;
import org.apache.commons.configuration.XMLConfiguration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jasongj.product.BMWCar;
import com.jasongj.product.BenzCar;
import com.jasongj.product.Car;
import com.jasongj.product.LandRoverCar;
public class CarFactory1 {
private static final Logger LOG = LoggerFactory.getLogger(CarFactory1.class);
public static Car newCar() {
Car car = null;
String name = null;
try {
XMLConfiguration config = new XMLConfiguration("car.xml");
name = config.getString("factory1.name");
} catch (ConfigurationException ex) {
LOG.error("parse xml configuration file failed", ex);
}
switch (name) {
case "Land Rover":
car = new LandRoverCar();
break;
case "BMW":
car = new BMWCar();
break;
case "Benz":
car = new BenzCar();
break;
default:
car = null;
break;
}
LOG.info("Created car name is {}", name);
return car;
}
}
调用方代码如下1
2
3
4
5
6
7
8
9
10
11
12
13package com.jasongj.client;
import com.jasongj.factory.CarFactory1;
import com.jasongj.product.Car;
public class Driver3 {
public static void main(String[] args) {
Car car = CarFactory1.newCar();
car.drive();
}
}
与Driver2相比,所有的判断逻辑都封装在工厂(CarFactory1)当中,Driver3不再需要关心Car的实例化,实现了对象的创建和使用的隔离。
当然,简单工厂模式并不要求一定要读配置文件来决定实例化哪个类,可以把参数作为工厂静态方法的参数传入。
从Driver2和CarFactory1的实现中可以看到,当有新的车加入时,需要更新Driver2和CarFactory1的代码也实现对新车的支持。这就违反了开闭原则
(Open-Close Principle)。可以利用反射(Reflection)解决该问题。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
33package com.jasongj.factory;
import org.apache.commons.configuration.ConfigurationException;
import org.apache.commons.configuration.XMLConfiguration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jasongj.product.Car;
public class CarFactory2 {
private static final Logger LOG = LoggerFactory.getLogger(CarFactory2.class);
public static Car newCar() {
Car car = null;
String name = null;
try {
XMLConfiguration config = new XMLConfiguration("car.xml");
name = config.getString("factory2.class");
} catch (ConfigurationException ex) {
LOG.error("Parsing xml configuration file failed", ex);
}
try {
car = (Car)Class.forName(name).newInstance();
LOG.info("Created car class name is {}", name);
} catch (InstantiationException | IllegalAccessException | ClassNotFoundException e) {
LOG.error("Instantiate car {} failed", name);
}
return car;
}
}
从上面代码中可以看到,之后如果需要引入新的Car,只需要在配置文件中指定该Car的完整类名(包括package名),CarFactory2即可通过反射将其实例化。实现了对扩展的开放,同时保证了对修改的关闭。熟悉Spring的读者应该会想到Spring IoC的实现。
上例中使用反射做到了对扩展开放,对修改关闭。但有些时候,使用类的全名不太方便,使用别名会更合适。例如Spring中每个Bean都会有个ID,引用Bean时也会通过ID去引用。像Apache Nifi这样的数据流工具,在流程上使用了职责链模式,而对于单个Processor的创建则使用了工厂,对于用户自定义的Processor并不需要通过代码去注册,而是使用注解(为了更方便理解下面这段代码,请先阅读笔者另外一篇文章《Java系列(一)Annotation(注解)》)。
下面就继续在上文案例的基础上使用注解升级简单工厂模式。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
55
56
57
58package com.jasongj.factory;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.commons.configuration.ConfigurationException;
import org.apache.commons.configuration.XMLConfiguration;
import org.reflections.Reflections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jasongj.annotation.Vehicle;
import com.jasongj.product.Car;
public class CarFactory3 {
private static final Logger LOG = LoggerFactory.getLogger(CarFactory3.class);
private static Map<String, Class> allCars;
static {
Reflections reflections = new Reflections("com.jasongj.product");
Set<Class<?>> annotatedClasses = reflections.getTypesAnnotatedWith(Vehicle.class);
allCars = new ConcurrentHashMap<String, Class>();
for (Class<?> classObject : annotatedClasses) {
Vehicle vehicle = (Vehicle) classObject.getAnnotation(Vehicle.class);
allCars.put(vehicle.type(), classObject);
}
allCars = Collections.unmodifiableMap(allCars);
}
public static Car newCar() {
Car car = null;
String type = null;
try {
XMLConfiguration config = new XMLConfiguration("car.xml");
type = config.getString("factory3.type");
LOG.info("car type is {}", type);
} catch (ConfigurationException ex) {
LOG.error("Parsing xml configuration file failed", ex);
}
if (allCars.containsKey(type)) {
LOG.info("created car type is {}", type);
try {
car = (Car) allCars.get(type).newInstance();
} catch (InstantiationException | IllegalAccessException ex) {
LOG.error("Instantiate car failed", ex);
}
} else {
LOG.error("specified car type {} does not exist", type);
}
return car;
}
}
从上面代码中可以看到,该工厂会扫描所有被Vehicle注解的Car(每种Car都在注解中声明了自己的type,可作为该种Car的别名)然后建立起Car别名与具体Car的Class原映射。此时工厂的静态方法即可根据目标别名实例化对应的Car。
本文所有代码都可从作者GitHub下载.
简单工厂模式(Simple Factory Pattern)又叫静态工厂方法模式(Static FactoryMethod Pattern)。专门定义一个类(如上文中的CarFactory1、CarFactory2、CarFactory3)来负责创建其它类的实例,由它来决定实例化哪个具体类,从而避免了在客户端代码中显式指定,实现了解耦。该类由于可以创建同一抽象类(或接口)下的不同子类对象,就像一个工厂一样,因此被称为工厂类。
简单工厂模式类图如下所示
简单工厂模式在JDK中最典型的应用要数JDBC了。可以把关系型数据库认为是一种抽象产品,各厂商提供的具体关系型数据库(MySQL,PostgreSQL,Oracle)则是具体产品。DriverManager是工厂类。应用程序通过JDBC接口使用关系型数据库时,并不需要关心具体使用的是哪种数据库,而直接使用DriverManager的静态方法去得到该数据库的Connection。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
36package com.jasongj.client;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class JDBC {
private static final Logger LOG = LoggerFactory.getLogger(JDBC.class);
public static void main(String[] args) {
Connection conn = null;
try {
Class.forName("org.apache.hive.jdbc.HiveDriver");
conn = DriverManager.getConnection("jdbc:hive2://127.0.0.1:10000/default");
PreparedStatement ps = conn.prepareStatement("select count(*) from test.test");
ps.execute();
} catch (SQLException ex) {
LOG.warn("Execute query failed", ex);
} catch(ClassNotFoundException e) {
LOG.warn("Load Hive driver failed", e);
} finally {
if(conn != null ){
try {
conn.close();
} catch (SQLException e) {
// NO-OPT
}
}
}
}
}
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/2016/01/17/Java1_注解Annotation
Annotation是Java5开始引入的特性。它提供了一种安全的类似于注释和Java doc的机制。实事上,Annotation已经被广泛用于各种Java框架,如Spring,Jersey,JUnit,TestNG。注解相当于是一种嵌入在程序中的元数据,可以使用注解解析工具或编译器对其进行解析,也可以指定注解在编译期或运行期有效。这些元数据与程序业务逻辑无关,并且是供指定的工具或框架使用的。
元注解的作用就是负责注解其他注解。Java5定义了4个标准的Meta Annotation类型,它们被用来提供对其它 Annotation类型作说明。
@Target
说明了Annotation所修饰的对象范围:Annotation可被用于 packages、types(类、接口、枚举、Annotation类型)、类型成员(方法、构造方法、成员变量、枚举值)、方法参数和本地变量(如循环变量、catch参数)。在Annotation类型的声明中使用了@Target
可更加明晰其修饰的目标。
@Target
作用:用于描述注解的使用范围,即被描述的注解可以用在什么地方
@Target
取值(ElementType)
CONSTRUCTOR
:用于描述构造器FIELD
:用于描述域LOCAL_VARIABLE
:用于描述局部变量METHOD
:用于描述方法PACKAGE
:用于描述包PARAMETER
:用于描述参数TYPE
:用于描述类、接口(包括注解类型) 或enum声明@Retention
定义了该Annotation的生命周期:某些Annotation仅出现在源代码中,而被编译器丢弃;而另一些却被编译在class文件中;编译在class文件中的Annotation可能会被虚拟机忽略,而另一些在class被装载时将被读取(请注意并不影响class的执行,因为Annotation与class在使用上是被分离的)。@Retention
有唯一的value作为成员。
@Retention
作用:表示需要在什么级别保存该注释信息,用于描述注解的生命周期(即:被描述的注解在什么范围内有效)
@Retention
取值来自java.lang.annotation.RetentionPolicy
的枚举类型值
@Documented
用于描述其它类型的annotation应该被作为被标注的程序成员的公共API,因此可以被例如javadoc此类的工具文档化。@Documented
是一个标记注解,没有成员。
@Inherited
是一个标记注解。如果一个使用了@Inherited
修饰的annotation类型被用于一个class,则这个Annotation将被用于该class的子类。
在实际项目中,经常会碰到下面这种场景,一个接口的实现类或者抽象类的子类很多,经常需要根据不同情况(比如根据配置文件)实例化并使用不同的子类。典型的例子是结合工厂使用职责链模式。
此时,可以为每个实现类加上特定的Annotation,并在Annotation中给该类取一个标识符,应用程序可通过该标识符来判断应该实例化哪个子类。
下面这个例子,定义了一个名为Component的Annotation,它包含一个名为identifier的成员变量。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16package com.jasongj.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
(ElementType.TYPE)
(RetentionPolicy.RUNTIME)
public Component {
String identifier () default "";
}
对于上文所说的实现类加上@Component
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15package com.jasongj;
import com.jasongj.annotation.Component;
"upper") (identifier=
public class UpperCaseComponent {
public String doWork(String input) {
if(input != null) {
return input.toUpperCase();
} else {
return null;
}
}
}
应用程序中可以通过反射获取UpperCaseComponent对应的identifier1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22package com.jasongj;
import com.jasongj.annotation.Component;
public class Client {
public static void main(String[] args) {
try {
Class componentClass = Class.forName("com.jasongj.UpperCaseComponent");
if(componentClass.isAnnotationPresent(Component.class)) {
Component component = (Component)componentClass.getAnnotation(Component.class);
String identifier = component.identifier();
System.out.println(String.format("Identifier for "
+ "com.jasongj.UpperCaseComponent is ' %s '", identifier));
} else {
System.out.println("com.jasongj.UpperCaseComponent is not annotated by"
+ " com.jasongj.annotation.Component");
}
} catch (ClassNotFoundException ex) {
ex.printStackTrace();
}
}
}
结果如下1
Identifier for com.jasongj.UpperCaseComponent is ' upper '
如果把@Component
的@Retention
设置为 RetentionPolicy.SOURCE
或者RetentionPolicy.CLASS
,则会得到如下结果,验证了上文中对@Retention
的描述1
com.jasongj.UpperCaseComponent is not annotated by com.jasongj.annotation.Component
Annotation的语法比较简单,除了@符号的使用外,他基本与Java固有的语法一致,JavaSE中内置三个标准Annotation,定义在java.lang
中:
@Override
是一个标记型Annotation,说明了被标注的方法覆盖了父类的方法,起到了断言的作用。如果给一个非覆盖父类方法的方法添加该Annotation,编译器将报编译错误。它有两个典型的使用场景,一是在试图覆盖父类方法却写错了方法名时报错,二是删除已被子类覆盖(且用Annotation修饰)的父类方法时报错。@Deprecated
标记型Annotation,说明被修改的元素已被废弃并不推荐使用,编译器会在该元素上加一条横线以作提示。该修饰具有一定的“传递性”:如果我们通过继承的方式使用了这个弃用的元素,即使继承后的元素(类,成员或者方法)并未被标记为@Deprecated
,编译器仍然会给出提示。@SuppressWarnnings
用于通知Java编译器关闭对特定类、方法、成员变量、变量初始化的警告。此种警告一般代表了可能的程序错误,例如当我们使用一个generic collection类而未提供它的类型时,编译器将提示“unchecked warning”的警告。通常当这种情况发生时,我们需要查找引起警告的代码,如果它真的表示错误,我们就需要纠正它。然而,有时我们无法避免这种警告,例如,我们使用必须和非generic的旧代码交互的generic collection类时,我们无法避免这个unchecked warning,此时可以在调用的方法前增加@SuppressWarnnings
通知编译器关闭对此方法的警告。@SuppressWarnnings
不是标记型Annotation,它有一个类型为String[]的成员,这个成员的值为被禁止的警告名。常见的警告名为下。
unchecked
执行了未检查的转换时的警告。例如当使用集合时没有用泛型来指定集合的类型finally
finally子句不能正常完成时的警告fallthrough
当switch程序块直接通往下一种情况而没有break时的警告deprecation
使用了弃用的类或者方法时的警告seriel
在可序列化的类上缺少serialVersionUID时的警告path
在类路径、源文件路径等中有不存在的路径时的警告all
对以上所有情况的警告@interface
而非interface
。注意开头的@
符号原创文章,转载请务必将下面这段话置于文章开头处。(已授权InfoQ中文站发布)
本文转发自技术世界,原文链接 http://www.jasongj.com/2015/12/31/KafkaColumn5_kafka_benchmark
本文主要介绍了如何利用Kafka自带的性能测试脚本及Kafka Manager测试Kafka的性能,以及如何使用Kafka Manager监控Kafka的工作状态,最后给出了Kafka的性能测试报告。
Kafka提供了非常多有用的工具,如Kafka设计解析(三)- Kafka High Availability (下)中提到的运维类工具——Partition Reassign Tool,Preferred Replica Leader Election Tool,Replica Verification Tool,State Change Log Merge Tool。本文将介绍Kafka提供的性能测试工具,Metrics报告工具及Yahoo开源的Kafka Manager。
$KAFKA_HOME/bin/kafka-producer-perf-test.sh
该脚本被设计用于测试Kafka Producer的性能,主要输出4项指标,总共发送消息量(以MB为单位),每秒发送消息量(MB/second),发送消息总数,每秒发送消息数(records/second)。除了将测试结果输出到标准输出外,该脚本还提供CSV Reporter,即将结果以CSV文件的形式存储,便于在其它分析工具中使用该测试结果$KAFKA_HOME/bin/kafka-consumer-perf-test.sh
该脚本用于测试Kafka Consumer的性能,测试指标与Producer性能测试脚本一样Kafka使用Yammer Metrics来报告服务端和客户端的Metric信息。Yammer Metrics 3.1.0提供6种形式的Metrics收集——Meters,Gauges,Counters,Histograms,Timers,Health Checks。与此同时,Yammer Metrics将Metric的收集与报告(或者说发布)分离,可以根据需要自由组合。目前它支持的Reporter有Console Reporter,JMX Reporter,HTTP Reporter,CSV Reporter,SLF4J Reporter,Ganglia Reporter,Graphite Reporter。因此,Kafka也支持通过以上几种Reporter输出其Metrics信息。
使用JConsole通过JMX,是在不安装其它工具(既然已经安装了Kafka,就肯定安装了Java,而JConsole是Java自带的工具)的情况下查看Kafka服务器Metrics的最简单最方便的方法之一。
首先必须通过为环境变量JMX_PORT设置有效值来启用Kafka的JMX Reporter。如export JMX_PORT=19797
。然后即可使用JConsole通过上面设置的端口来访问某一台Kafka服务器来查看其Metrics信息,如下图所示。
使用JConsole的一个好处是不用安装额外的工具,缺点很明显,数据展示不够直观,数据组织形式不友好,更重要的是不能同时监控整个集群的Metrics。在上图中,在kafka.cluster->Partition->UnderReplicated->topic4下,只有2和5两个节点,这并非因为topic4只有这两个Partition的数据是处于复制状态的。事实上,topic4在该Broker上只有这2个Partition,其它Partition在其它Broker上,所以通过该服务器的JMX Reporter只看到了这两个Partition。
Kafka Manager是Yahoo开源的Kafka管理工具。它支持如下功能
delete.topic.enable
设置为true)安装好Kafka Manager后,添加Cluster非常方便,只需指明该Cluster所使用的Zookeeper列表并指明Kafka版本即可,如下图所示。
这里要注意,此处添加Cluster是指添加一个已有的Kafka集群进入监控列表,而非通过Kafka Manager部署一个新的Kafka Cluster,这一点与Cloudera Manager不同。
Kafka的一个核心特性是高吞吐率,因此本文的测试重点是Kafka的吞吐率。
本文的测试共使用6台安装Red Hat 6.6的虚拟机,3台作为Broker,另外3台作为Producer或者Consumer。每台虚拟机配置如下
开启Kafka JMX Reporter并使用19797端口,利用Kafka-Manager的JMX polling功能监控性能测试过程中的吞吐率。
本文主要测试如下四种场景,测试的指标主要是每秒多少兆字节数据,每秒多少条消息。
这组测试不使用任何Consumer,只启动Broker和Producer。
实验条件:3个Broker,1个Topic,6个Partition,无Replication,异步模式,消息Payload为100字节
测试项目:分别测试1,2,3个Producer时的吞吐量
测试目标:如Kafka设计解析(一)- Kafka背景及架构介绍所介绍,多个Producer可同时向同一个Topic发送数据,在Broker负载饱和前,理论上Producer数量越多,集群每秒收到的消息量越大,并且呈线性增涨。本实验主要验证该特性。同时作为性能测试,本实验还将监控测试过程中单个Broker的CPU和内存使用情况
测试结果:使用不同个数Producer时的总吞吐率如下图所示
由上图可看出,单个Producer每秒可成功发送约128万条Payload为100字节的消息,并且随着Producer个数的提升,每秒总共发送的消息量线性提升,符合之前的分析。
性能测试过程中,Broker的CPU和内存使用情况如下图所示。
由上图可知,在每秒接收约117万条消息(3个Producer总共每秒发送350万条消息,平均每个Broker每秒接收约117万条)的情况下,一个Broker的CPU使用量约为248%,内存使用量为601 MB。
实验条件:3个Broker,1个Topic,6个Partition,无Replication,异步模式,3个Producer
测试项目:分别测试消息长度为10,20,40,60,80,100,150,200,400,800,1000,2000,5000,10000字节时的集群总吞吐量
测试结果:不同消息长度时的集群总吞吐率如下图所示
由上图可知,消息越长,每秒所能发送的消息数越少,而每秒所能发送的消息的量(MB)越大。另外,每条消息除了Payload外,还包含其它Metadata,所以每秒所发送的消息量比每秒发送的消息数乘以100字节大,而Payload越大,这些Metadata占比越小,同时发送时的批量发送的消息体积越大,越容易得到更高的每秒消息量(MB/s)。其它测试中使用的Payload为100字节,之所以使用这种短消息(相对短)只是为了测试相对比较差的情况下的Kafka吞吐率。
实验条件:3个Broker,1个Topic,无Replication,异步模式,3个Producer,消息Payload为100字节
测试项目:分别测试1到9个Partition时的吞吐量
测试结果:不同Partition数量时的集群总吞吐率如下图所示
由上图可知,当Partition数量小于Broker个数(3个)时,Partition数量越大,吞吐率越高,且呈线性提升。本文所有实验中,只启动3个Broker,而一个Partition只能存在于1个Broker上(不考虑Replication。即使有Replication,也只有其Leader接受读写请求),故当某个Topic只包含1个Partition时,实际只有1个Broker在为该Topic工作。如之前文章所讲,Kafka会将所有Partition均匀分布到所有Broker上,所以当只有2个Partition时,会有2个Broker为该Topic服务。3个Partition时同理会有3个Broker为该Topic服务。换言之,Partition数量小于等于3个时,越多的Partition代表越多的Broker为该Topic服务。如前几篇文章所述,不同Broker上的数据并行插入,这就解释了当Partition数量小于等于3个时,吞吐率随Partition数量的增加线性提升。
当Partition数量多于Broker个数时,总吞吐量并未有所提升,甚至还有所下降。可能的原因是,当Partition数量为4和5时,不同Broker上的Partition数量不同,而Producer会将数据均匀发送到各Partition上,这就造成各Broker的负载不同,不能最大化集群吞吐量。而上图中当Partition数量为Broker数量整数倍时吞吐量明显比其它情况高,也证实了这一点。
实验条件:3个Broker,1个Topic,6个Partition,异步模式,3个Producer,消息Payload为100字节
测试项目:分别测试1到3个Replica时的吞吐率
测试结果:如下图所示
由上图可知,随着Replica数量的增加,吞吐率随之下降。但吞吐率的下降并非线性下降,因为多个Follower的数据复制是并行进行的,而非串行进行。
实验条件:3个Broker,1个Topic,6个Partition,无Replication,异步模式,消息Payload为100字节
测试项目:分别测试1到3个Consumer时的集群总吞吐率
测试结果:在集群中已有大量消息的情况下,使用1到3个Consumer时的集群总吞吐量如下图所示
由上图可知,单个Consumer每秒可消费306万条消息,该数量远大于单个Producer每秒可消费的消息数量,这保证了在合理的配置下,消息可被及时处理。并且随着Consumer数量的增加,集群总吞吐量线性增加。
根据Kafka设计解析(四)- Kafka Consumer设计解析所述,多Consumer消费消息时以Partition为分配单位,当只有1个Consumer时,该Consumer需要同时从6个Partition拉取消息,该Consumer所在机器的I/O成为整个消费过程的瓶颈,而当Consumer个数增加至2个至3个时,多个Consumer同时从集群拉取消息,充分利用了集群的吞吐率。
实验条件:3个Broker,1个Topic,6个Partition,无Replication,异步模式,消息Payload为100字节
测试项目:测试1个Producer和1个Consumer同时工作时Consumer所能消费到的消息量
测试结果:1,215,613 records/second
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/2015/12/27/SQL4_存储过程_Store Procedure/
百度百科是这么描述存储过程的:存储过程(Stored Procedure)是在大型数据库系统中,一组为了完成特定功能的SQL语句集,存储在数据库中,首次编译后再次调用不需要再次编译,用户通过指定存储过程的名字并给出参数(如果有)来执行它。它是数据库中的一个重要对象,任何一个设计良好的数据库应用程序都应该用到存储过程。
维基百科是这样定义的:A stored procedure (also termed proc, storp, sproc, StoPro, StoredProc, StoreProc, sp, or SP) is a subroutine available to applications that access a relational database management system (RDMS). Such procedures are stored in the database data dictionary。
PostgreSQL对存储过程的描述是:存储过程和用户自定义函数(UDF)是SQL和过程语句的集合,它存储于数据库服务器并能被SQL接口调用。
总结下来存储过程有如下特性:
首先看看使用存储过程的优势
当然,使用存储过程也有它的劣势
PostgreSQL官方支持PL/pgSQL,PL/Tcl,PL/Perl和PL/Python这几种过程语言。同时还支持一些第三方提供的过程语言,如PL/Java,PL/PHP,PL/Py,PL/R,PL/Ruby,PL/Scheme,PL/sh。
1 | CREATE OR REPLACE FUNCTION add(a INTEGER, b NUMERIC) |
调用方法1
2
3
4
5
6
7
8
9
10
11SELECT add(1,2);
add
-----
3
(1 row)
SELECT * FROM add(1,2);
add
-----
3
(1 row)
上面这种方式参数列表只包含函数输入参数,不包含输出参数。下面这个例子将同时包含输入参数和输出参数1
2
3
4
5CREATE OR REPLACE FUNCTION plus_and_minus
(IN a INTEGER, IN b NUMERIC, OUT c NUMERIC, OUT d NUMERIC)
AS $$
SELECT a+b, a-b;
$$ LANGUAGE SQL;
调用方式1
2
3
4
5
6
7
8
9
10
11SELECT plus_and_minus(3,2);
add_and_minute
----------------
(5,1)
(1 row)
SELECT * FROM plus_and_minus(3,2);
c | d
---+---
5 | 1
(1 row)
该例中,IN代表输入参数,OUT代表输出参数。这个带输出参数的函数和之前的add
函数并无本质区别。事实上,输出参数的最大价值在于它为函数提供了返回多个字段的途径。
在函数定义中,可以写多个SQL语句,不一定是SELECT语句,可以是其它任意合法的SQL。但最后一条SQL必须是SELECT语句,并且该SQL的结果将作为该函数的输出结果。1
2
3
4
5
6
7CREATE OR REPLACE FUNCTION plus_and_minus
(IN a INTEGER, IN b NUMERIC, OUT c NUMERIC, OUT d NUMERIC)
AS $$
SELECT a+b, a-b;
INSERT INTO test VALUES('test1');
SELECT a-b, a+b;
$$ LANGUAGE SQL;
其效果如下1
2
3
4
5
6
7
8
9
10
11SELECT * FROM plus_and_minus(5,3);
c | d
---+---
2 | 8
(1 row)
SELECT * FROM test;
a
-------
test1
(1 row)
PL/pgSQL是一个块结构语言。函数定义的所有文本都必须是一个块。一个块用下面的方法定义:1
2
3
4
5
6[ <<label>> ]
[DECLARE
declarations]
BEGIN
statements
END [ label ];
声明一个变量的语法如下:1
name [ CONSTANT ] type [ NOT NULL ] [ { DEFAULT | := } expression ];
使用PL/PgSQL语言的函数定义如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23CREATE FUNCTION somefunc() RETURNS integer AS $$
DECLARE
quantity integer := 30;
BEGIN
-- Prints 30
RAISE NOTICE 'Quantity here is %', quantity;
quantity := 50;
-- Create a subblock
DECLARE
quantity integer := 80;
BEGIN
-- Prints 80
RAISE NOTICE 'Quantity here is %', quantity;
-- Prints 50
RAISE NOTICE 'Outer quantity here is %', outerblock.quantity;
END;
-- Prints 50
RAISE NOTICE 'Quantity here is %', quantity;
RETURN quantity;
END;
$$ LANGUAGE plpgsql;
如果只指定输入参数类型,不指定参数名,则函数体里一般用$1,$n这样的标识符来使用参数。1
2
3
4
5
6
7CREATE OR REPLACE FUNCTION discount(NUMERIC)
RETURNS NUMERIC
AS $$
BEGIN
RETURN $1 * 0.8;
END;
$$ LANGUAGE PLPGSQL;
但该方法可读性不好,此时可以为$n参数声明别名,然后可以在函数体内通过别名指向该参数值。1
2
3
4
5
6
7
8
9CREATE OR REPLACE FUNCTION discount(NUMERIC)
RETURNS NUMERIC
AS $$
DECLARE
total ALIAS FOR $1;
BEGIN
RETURN total * 0.8;
END;
$$ LANGUAGE PLPGSQL;
笔者认为上述方法仍然不够直观,也不够完美。幸好PostgreSQL提供另外一种更为直接的方法来声明函数参数,即在声明参数类型时同时声明相应的参数名。1
2
3
4
5
6
7CREATE OR REPLACE FUNCTION discount(total NUMERIC)
RETURNS NUMERIC
AS $$
BEGIN
RETURN total * 0.8;
END;
$$ LANGUAGE PLPGSQL;
PostgreSQL除了支持自带的类型外,还支持用户创建自定义类型。在这里可以自定义一个复合类型,并在函数中返回一个该复合类型的值,从而实现返回一行多列。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21CREATE TYPE compfoo AS (col1 INTEGER, col2 TEXT);
CREATE OR REPLACE FUNCTION getCompFoo
(in_col1 INTEGER, in_col2 TEXT)
RETURNS compfoo
AS $$
DECLARE result compfoo;
BEGIN
result.col1 := in_col1 * 2;
result.col2 := in_col2 || '_result';
RETURN result;
END;
$$ LANGUAGE PLPGSQL;
SELECT * FROM getCompFoo(1,'1');
col1 | col2
------+----------
2 | 1_result
(1 row)
在声明函数时,除指定输入参数名及类型外,还可同时声明输出参数类型及参数名。此时函数可以输出一行多列。
1 | CREATE OR REPLACE FUNCTION get2Col |
实际项目中,存储过程经常需要返回多行记录,可以通过SETOF实现。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18CREATE TYPE compfoo AS (col1 INTEGER, col2 TEXT);
CREATE OR REPLACE FUNCTION getSet(rows INTEGER)
RETURNS SETOF compfoo
AS $$
BEGIN
RETURN QUERY SELECT i * 2, i || '_text'
FROM generate_series(1, rows, 1) as t(i);
END;
$$ LANGUAGE PLPGSQL;
SELECT col1, col2 FROM getSet(2);
col1 | col2
------+--------
2 | 1_text
4 | 2_text
(2 rows)
本例返回的每一行记录是复合类型,该方法也可返回基本类型的结果集,即多行一列。
1 | CREATE OR REPLACE FUNCTION getTable(rows INTEGER) |
此时从函数中读取字段就和从表或视图中取字段一样,可以看此种类型的函数看成是带参数的表或者视图。
有时在PL/pgSQL函数中需要生成动态命令,这个命令将包括他们每次执行时使用不同的表或者字符。EXECUTE语句用法如下:1
EXECUTE command-string [ INTO [STRICT] target] [USING expression [, ...]];
此时PL/plSQL将不再缓存该命令的执行计划。相反,在该语句每次被执行的时候,命令都会编译一次。这也让该语句获得了对各种不同的字段甚至表进行操作的能力。
command-string包含了要执行的命令,它可以使用参数值,在命令中通过引用如$1,$2等来引用参数值。这些符号的值是指USING字句的值。这种方法对于在命令字符串中使用参数是最好的:它能避免运行时数值从文本来回转换,并且不容易产生SQL注入,而且它不需要引用或者转义。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
29CREATE TABLE testExecute
AS
SELECT
i || '' AS a,
i AS b
FROM
generate_series(1, 10, 1) AS t(i);
CREATE OR REPLACE FUNCTION execute(filter TEXT)
RETURNS TABLE (a TEXT, b INTEGER)
AS $$
BEGIN
RETURN QUERY EXECUTE
'SELECT * FROM testExecute where a = $1'
USING filter;
END;
$$ LANGUAGE PLPGSQL;
SELECT * FROM execute('3');
a | b
---+---
3 | 3
(1 row)
SELECT * FROM execute('3'' or ''c''=''c');
a | b
---+---
(0 rows)
当然,也可以使用字符串拼接的方式在command-string中使用参数,但会有SQL注入的风险。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
39CREATE TABLE testExecute
AS
SELECT
i || '' AS a,
i AS b
FROM
generate_series(1, 10, 1) AS t(i);
CREATE OR REPLACE FUNCTION execute(filter TEXT)
RETURNS TABLE (a TEXT, b INTEGER)
AS $$
BEGIN
RETURN QUERY EXECUTE
'SELECT * FROM testExecute where b = '''
|| filter || '''';
END;
$$ LANGUAGE PLPGSQL;
SELECT * FROM execute(3);
a | b
---+---
3 | 3
(1 row)
SELECT * FROM execute('3'' or ''c''=''c');
a | b
----+----
1 | 1
2 | 2
3 | 3
4 | 4
5 | 5
6 | 6
7 | 7
8 | 8
9 | 9
10 | 10
(10 rows)
从该例中可以看出使用字符串拼接的方式在command-string中使用参数会引入SQL注入攻击的风险,而使用USING的方式则能有效避免这一风险。
本文中并未区分PostgreSQL中的UDF和存储过程。实际上PostgreSQL创建存储与创建UDF的方式一样,并没有专用于创建存储过程的语法,如CREATE PRECEDURE。在PostgreSQL官方文档中也暂未找到这二者的区别。倒是从一些资料中找对了它们的对比,如下表如示,仅供参考。
SQL函数可以声明为接受多态类型(anyelement和anyarray)的参数或返回多态类型的返回值。
函数参数和返回值均为多态类型。其调用方式和调用其它类型的SQL函数完全相同,只是在传递字符串类型的参数时,需要显示转换到目标类型,否则将会被视为unknown类型。
1 | CREATE OR REPLACE FUNCTION get_array(anyelement, anyelement) |
函数参数为多态类型,而返回值为基本类型
1 | CREATE OR REPLACE FUNCTION is_greater(anyelement, anyelement) |
输入输出参数均为多态类型。这种情况与第一种情况一样。
1 | CREATE OR REPLACE FUNCTION get_array |
在PostgreSQL中,多个函数可共用同一个函数名,但它们的参数必须得不同。这一规则与面向对象语言(比如Java)中的函数重载类似。也正因如此,在PostgreSQL删除函数时,必须指定其参数列表,如:1
DROP FUNCTION get_array(anyelement, anyelement);
另外,在实际项目中,经常会用到CREATE OR REPLACE FUNCTION去替换已有的函数实现。如果同名函数已存在,但输入参数列表不同,会创建同名的函数,也即重载。如果同名函数已存在,且输入输出参数列表均相同,则替换。如果已有的函数输入参数列表相同,但输出参数列表不同,则会报错,并提示需要先DROP已有的函数定义。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/2015/12/13/SQL3_partition/
随着使用时间的增加,数据库中的数据量也不断增加,因此数据库查询越来越慢。
加速数据库的方法很多,如添加特定的索引,将日志目录换到单独的磁盘分区,调整数据库引擎的参数等。这些方法都能将数据库的查询性能提高到一定程度。
对于许多应用数据库来说,许多数据是历史数据并且随着时间的推移它们的重要性逐渐降低。如果能找到一个办法将这些可能不太重要的数据隐藏,数据库查询速度将会大幅提高。可以通过DELETE
来达到此目的,但同时这些数据就永远不可用了。
因此,需要一个高效的把历史数据从当前查询中隐藏起来并且不造成数据丢失的方法。本文即将介绍的数据库表分区即能达到此效果。
数据库表分区把一个大的物理表分成若干个小的物理表,并使得这些小物理表在逻辑上可以被当成一张表来使用。
主表
/ 父表
/ Master Table
该表是创建子表的模板。它是一个正常的普通表,但正常情况下它并不储存任何数据。子表
/ 分区表
/ Child Table
/ Partition Table
这些表继承并属于一个主表。子表中存储所有的数据。主表与分区表属于一对多的关系,也就是说,一个主表包含多个分区表,而一个分区表只从属于一个主表ALTER TABLE NO INHERIT
可将特定分区从主逻辑表中移除(该表依然存在,并可单独使用,只是与主表不再有继承关系并无法再通过主表访问该分区表),或使用DROP TABLE
直接将该分区表删除。这两种方式完全避免了使用DELETE
时所需的VACUUM
额外代价。以上优势只有当表非常大的时候才能体现出来。一般来说,当表的大小超过数据库服务器的物理内存时以上优势才能体现出来
现在PostgreSQL支持通过表继承来实现表的分区。父表是普通表并且正常情况下并不存储任何数据,它的存在只是为了代表整个数据集。PostgreSQL可实现如下两种表分区
创建主表。不用为该表定义任何检查限制,除非需要将该限制应用到所有的分区表中。同样也无需为该表创建任何索引和唯一限制。
1 | CREATE TABLE almart |
创建多个分区表。每个分区表必须继承自主表,并且正常情况下都不要为这些分区表添加任何新的列。
1 | CREATE TABLE almart_2015_12_10 () inherits (almart); |
为分区表添加限制。这些限制决定了该表所能允许保存的数据集范围。这里必须保证各个分区表之间的限制不能有重叠。
1 | ALTER TABLE almart_2015_12_10 |
为每一个分区表,在主要的列上创建索引。该索引并不是严格必须创建的,但在大部分场景下,它都非常有用。
1 | CREATE INDEX almart_date_key_2015_12_10 |
定义一个trigger或者rule把对主表的数据插入操作重定向到对应的分区表。
1 | --创建分区函数 |
确保postgresql.conf中的constraint_exclusion配置项没有被disable。这一点非常重要,如果该参数项被disable,则基于分区表的查询性能无法得到优化,甚至比不使用分区表直接使用索引性能更低。
当constraint_exclusion
为on
或者partition
时,查询计划器会根据分区表的检查限制将对主表的查询限制在符合检查限制条件的分区表上,直接避免了对不符合条件的分区表的扫描。
为了验证分区表的优势,这里创建一个与上文创建的almart结构一样的表almart_all,并为其date_key创建索引,向almart和almart_all中插入同样的9000万条数据(数据的时间跨度为2015-12-01到2015-12-30)。1
2
3
4
5
6
7
8
9CREATE TABLE almart_all
(
date_key date,
hour_key smallint,
client_key integer,
item_key integer,
account integer,
expense numeric
);
插入随机测试数据到almart_all1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18INSERT INTO
almart_all
select
(select
array_agg(i::date)
from
generate_series(
'2015-12-01'::date,
'2015-12-30'::date,
'1 day'::interval) as t(i)
)[floor(random()*4)+1] as date_key,
floor(random()*24) as hour_key,
floor(random()*1000000)+1 as client_key,
floor(random()*100000)+1 as item_key,
floor(random()*20)+1 as account,
floor(random()*10000)+1 as expense
from
generate_series(1,300000000,1);
插入同样的测试数据到almart1
INSERT INTO almart SELECT * FROM almart_all;
在almart和slmart_all上执行同样的query,查询2015-12-15日不同client_key的平均消费额。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
55\timing
explain analyze
select
avg(expense)
from
(select
client_key,
sum(expense) as expense
from
almart
where
date_key = date '2015-12-15'
group by 1
);
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=19449.05..19449.06 rows=1 width=32) (actual time=9474.203..9474.203 rows=1 loops=1)
-> HashAggregate (cost=19196.10..19308.52 rows=11242 width=36) (actual time=8632.592..9114.973 rows=949825 loops=1)
-> Append (cost=0.00..19139.89 rows=11242 width=36) (actual time=4594.262..6091.630 rows=2997704 loops=1)
-> Seq Scan on almart (cost=0.00..0.00 rows=1 width=9) (actual time=0.002..0.002 rows=0 loops=1)
Filter: (date_key = '2015-12-15'::date)
-> Bitmap Heap Scan on almart_2015_12_15 (cost=299.55..19139.89 rows=11241 width=36) (actual time=4594.258..5842.708 rows=2997704 loops=1)
Recheck Cond: (date_key = '2015-12-15'::date)
-> Bitmap Index Scan on almart_date_key_2015_12_15 (cost=0.00..296.74 rows=11241 width=0) (actual time=4587.582..4587.582 rows=2997704 loops=1)
Index Cond: (date_key = '2015-12-15'::date)
Total runtime: 9506.507 ms
(10 rows)
Time: 9692.352 ms
explain analyze
select
avg(expense)
from
(select
client_key,
sum(expense) as expense
from
almart_all
where
date_key = date '2015-12-15'
group by 1
) foo;
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=770294.11..770294.12 rows=1 width=32) (actual time=62959.917..62959.917 rows=1 loops=1)
-> HashAggregate (cost=769549.54..769880.46 rows=33092 width=9) (actual time=61694.564..62574.385 rows=949825 loops=1)
-> Bitmap Heap Scan on almart_all (cost=55704.56..754669.55 rows=2975999 width=9) (actual time=919.941..56291.128 rows=2997704 loops=1)
Recheck Cond: (date_key = '2015-12-15'::date)
-> Bitmap Index Scan on almart_all_date_key_index (cost=0.00..54960.56 rows=2975999 width=0) (actual time=677.741..677.741 rows=2997704 loops=1)
Index Cond: (date_key = '2015-12-15'::date)
Total runtime: 62960.228 ms
(7 rows)
Time: 62970.269 ms
由上可见,使用分区表时,所需时间为9.5秒,而不使用分区表时,耗时63秒。
使用分区表,PostgreSQL跳过了除2015-12-15日分区表以外的分区表,只扫描2015-12-15的分区表。而不使用分区表只使用索引时,数据库要使用索引扫描整个数据库。另一方面,使用分区表时,每个表的索引是独立的,即每个分区表的索引都只针对一个小的分区表。而不使用分区表时,索引是建立在整个大表上的。数据量越大,索引的速度相对越慢。
从上文分区表的创建过程可以看出,分区表必须在相关数据插入之前创建好。在生产环境中,很难保证所需的分区表都已经被提前创建好。同时为了不让分区表过多,影响数据库性能,不能创建过多无用的分区表。
在生产环境中,经常需要周期性删除和创建一些分区表。一个经典的做法是使用定时任务。比如使用cronjob每天运行一次,将1年前的分区表删除,并创建第二天分区表(该表按天分区)。有时为了容错,会将之后一周的分区表全部创建出来。
上述周期性创建分区表的方法在绝大部分情况下有效,但也只能在一定程度上容错。另外,上文所使用的分区函数,使用IF
语句对date_key进行判断,需要为每一个分区表准备一个IF
语句。
如插入date_key
分别为2015-12-10
到2015-12-14
的5条记录,前面4条均可插入成功,因为相应的分区表已经存在,但最后一条数据因为相应的分区表不存在而插入失败。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23INSERT INTO almart(date_key) VALUES ('2015-12-10');
INSERT 0 0
INSERT INTO almart(date_key) VALUES ('2015-12-11');
INSERT 0 0
INSERT INTO almart(date_key) VALUES ('2015-12-12');
INSERT 0 0
INSERT INTO almart(date_key) VALUES ('2015-12-13');
INSERT 0 0
INSERT INTO almart(date_key) VALUES ('2015-12-14');
ERROR: relation "almart_2015_12_14" does not exist
LINE 1: INSERT INTO almart_2015_12_14 VALUES (NEW.*)
^
QUERY: INSERT INTO almart_2015_12_14 VALUES (NEW.*)
CONTEXT: PL/pgSQL function almart_partition_trigger() line 17 at SQL statement
SELECT * FROM almart;
date_key | hour_key | client_key | item_key | account | expense
------------+----------+------------+----------+---------+---------
2015-12-10 | | | | |
2015-12-11 | | | | |
2015-12-12 | | | | |
2015-12-13 | | | | |
(4 rows)
针对该问题,可使用动态SQL的方式进行数据路由,并通过获取将数据插入不存在的分区表产生的异常消息并动态创建分区表的方式保证分区表的可用性。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
32CREATE OR REPLACE FUNCTION almart_partition_trigger()
RETURNS TRIGGER AS $$
DECLARE date_text TEXT;
DECLARE insert_statement TEXT;
BEGIN
SELECT to_char(NEW.date_key, 'YYYY_MM_DD') INTO date_text;
insert_statement := 'INSERT INTO almart_'
|| date_text
||' VALUES ($1.*)';
EXECUTE insert_statement USING NEW;
RETURN NULL;
EXCEPTION
WHEN UNDEFINED_TABLE
THEN
EXECUTE
'CREATE TABLE IF NOT EXISTS almart_'
|| date_text
|| '(CHECK (date_key = '''
|| date_text
|| ''')) INHERITS (almart)';
RAISE NOTICE 'CREATE NON-EXISTANT TABLE almart_%', date_text;
EXECUTE
'CREATE INDEX almart_date_key_'
|| date_text
|| ' ON almart_'
|| date_text
|| '(date_key)';
EXECUTE insert_statement USING NEW;
RETURN NULL;
END;
$$
LANGUAGE plpgsql;
使用该方法后,再次插入date_key
为2015-12-14
的记录时,对应的分区表不存在,但会被自动创建。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15INSERT INTO almart VALUES('2015-12-13'),('2015-12-14'),('2015-12-15');
NOTICE: CREATE NON-EXISTANT TABLE almart_2015_12_14
NOTICE: CREATE NON-EXISTANT TABLE almart_2015_12_15
INSERT 0 0
SELECT * FROM almart;
date_key | hour_key | client_key | item_key | account | expense
------------+----------+------------+----------+---------+---------
2015-12-10 | | | | |
2015-12-11 | | | | |
2015-12-12 | | | | |
2015-12-13 | | | | |
2015-12-13 | | | | |
2015-12-14 | | | | |
2015-12-15 | | | | |
(7 rows)
虽然如上文所述,分区表的使用可以跳过扫描不必要的分区表从而提高查询速度。但由于服务器磁盘的限制,不可能无限制存储所有数据,经常需要周期性删除过期数据,如删除5年前的数据。如果使用传统的DELETE
,删除速度慢,并且由于DELETE
只是将相应数据标记为删除状态,不会将数据从磁盘删除,需要使用VACUUM
释放磁盘,从而引入额外负载。
而在使用分区表的条件下,可以通过直接DROP
过期分区表的方式快速方便地移除过期数据。如1
DROP TABLE almart_2014_12_15;
另外,无论使用DELETE
还是DROP
,都会将数据完全删除,即使有需要也无法再次使用。因此还有另外一种方式,即更改过期的分区表,解除其与主表的继承关系,如。1
ALTER TABLE almart_2015_12_15 NO INHERIT almart;
但该方法并未释放磁盘。此时可通过更改该分区表,使其属于其它TABLESPACE,同时将该TABLESPACE的目录设置为其它磁盘分区上的目录,从而释放主表所在的磁盘。同时,如果之后还需要再次使用该“过期”数据,只需更改该分区表,使其再次与主表形成继承关系。1
2CREATE TABLESPACE cheap_table_space LOCATION '/data/cheap_disk';
ALTER TABLE almart_2014_12_15 SET TABLESPACE cheap_table_space;
除了使用Trigger外,可以使用Rule将对主表的插入请求重定向到对应的子表。如1
2
3
4
5
6CREATE RULE almart_rule_2015_12_31 AS
ON INSERT TO almart
WHERE
date_key = DATE '2015-12-31'
DO INSTEAD
INSERT INTO almart_2015_12_31 VALUES (NEW.*);
与Trigger相比,Rule会带来更大的额外开销,但每个请求只造成一次开销而非每条数据都引入一次开销,所以该方法对大批量的数据插入操作更具优势。然而,实际上在绝大部分场景下,Trigger比Rule的效率更高。
同时,COPY
操作会忽略Rule,而可以正常触发Trigger。
另外,如果使用Rule方式,没有比较简单的方法处理没有被Rule覆盖到的插入操作。此时该数据会被插入到主表中而不会报错,从而无法有效利用表分区的优势。
除了使用表继承外,还可使用UNION ALL
的方式达到表分区的效果。1
2
3
4
5
6
7
8
9CREATE VIEW almart AS
SELECT * FROM almart_2015_12_10
UNION ALL
SELECT * FROM almart_2015_12_11
UNION ALL
SELECT * FROM almart_2015_12_12
...
UNION ALL
SELECT * FROM almart_2015_12_30;
当有新的分区表时,需要更新该View。实践中,与使用表继承相比,一般不推荐使用该方法。
WHERE
语句只能包含常量而不能使用参数化的表达式,因为这些表达式只有在运行时才能确定其值,而planner在真正执行query之前无法判定哪些分区表应该被使用constraint_exclusion
设置为ON
或Partition
,否则planner将无法正常跳过不符合条件的分区表,也即无法发挥表分区的优势原创文章,转载请务必将下面这段话置于文章开头处。(已授权InfoQ中文站发布)
本文转发自技术世界,原文链接 http://www.jasongj.com/2015/08/09/KafkaColumn4
本文主要介绍了Kafka High Level Consumer,Consumer Group,Consumer Rebalance,Low Level Consumer实现的语义,以及适用场景。以及未来版本中对High Level Consumer的重新设计–使用Consumer Coordinator解决Split Brain和Herd等问题。
很多时候,客户程序只是希望从Kafka读取数据,不太关心消息offset的处理。同时也希望提供一些语义,例如同一条消息只被某一个Consumer消费(单播)或被所有Consumer消费(广播)。因此,Kafka Hight Level Consumer提供了一个从Kafka消费数据的高层抽象,从而屏蔽掉其中的细节并提供丰富的语义。
High Level Consumer将从某个Partition读取的最后一条消息的offset存于Zookeeper中(Kafka从0.8.2版本开始同时支持将offset存于Zookeeper中与将offset存于专用的Kafka Topic中)。这个offset基于客户程序提供给Kafka的名字来保存,这个名字被称为Consumer Group。Consumer Group是整个Kafka集群全局的,而非某个Topic的。每一个High Level Consumer实例都属于一个Consumer Group,若不指定则属于默认的Group。
Zookeeper中Consumer相关节点如下图所示
很多传统的Message Queue都会在消息被消费完后将消息删除,一方面避免重复消费,另一方面可以保证Queue的长度比较短,提高效率。而如上文所述,Kafka并不删除已消费的消息,为了实现传统Message Queue消息只被消费一次的语义,Kafka保证每条消息在同一个Consumer Group里只会被某一个Consumer消费。与传统Message Queue不同的是,Kafka还允许不同Consumer Group同时消费同一条消息,这一特性可以为消息的多元化处理提供支持。
实际上,Kafka的设计理念之一就是同时提供离线处理和实时处理。根据这一特性,可以使用Storm这种实时流处理系统对消息进行实时在线处理,同时使用Hadoop这种批处理系统进行离线处理,还可以同时将数据实时备份到另一个数据中心,只需要保证这三个操作所使用的Consumer在不同的Consumer Group即可。下图展示了Kafka在LinkedIn的一种简化部署模型。
为了更清晰展示Kafka Consumer Group的特性,笔者进行了一项测试。创建一个Topic (名为topic1),再创建一个属于group1的Consumer实例,并创建三个属于group2的Consumer实例,然后通过Producer向topic1发送Key分别为1,2,3的消息。结果发现属于group1的Consumer收到了所有的这三条消息,同时group2中的3个Consumer分别收到了Key为1,2,3的消息,如下图所示。
注:上图中每个黑色区域代表一个Consumer实例,每个实例只创建一个MessageStream。实际上,本实验将Consumer应用程序打成jar包,并在4个不同的命令行终端中传入不同的参数运行。
(本节所讲述Rebalance相关内容均基于Kafka High Level Consumer)
Kafka保证同一Consumer Group中只有一个Consumer会消费某条消息,实际上,Kafka保证的是稳定状态下每一个Consumer实例只会消费某一个或多个特定Partition的数据,而某个Partition的数据只会被某一个特定的Consumer实例所消费。也就是说Kafka对消息的分配是以Partition为单位分配的,而非以每一条消息作为分配单元。这样设计的劣势是无法保证同一个Consumer Group里的Consumer均匀消费数据,优势是每个Consumer不用都跟大量的Broker通信,减少通信开销,同时也降低了分配难度,实现也更简单。另外,因为同一个Partition里的数据是有序的,这种设计可以保证每个Partition里的数据可以被有序消费。
如果某Consumer Group中Consumer(每个Consumer只创建1个MessageStream)数量少于Partition数量,则至少有一个Consumer会消费多个Partition的数据,如果Consumer的数量与Partition数量相同,则正好一个Consumer消费一个Partition的数据。而如果Consumer的数量多于Partition的数量时,会有部分Consumer无法消费该Topic下任何一条消息。
如下例所示,如果topic1有0,1,2共三个Partition,当group1只有一个Consumer(名为consumer1)时,该 Consumer可消费这3个Partition的所有数据。
增加一个Consumer(consumer2)后,其中一个Consumer(consumer1)可消费2个Partition的数据(Partition 0和Partition 1),另外一个Consumer(consumer2)可消费另外一个Partition(Partition 2)的数据。
再增加一个Consumer(consumer3)后,每个Consumer可消费一个Partition的数据。consumer1消费partition0,consumer2消费partition1,consumer3消费partition2。
再增加一个Consumer(consumer4)后,其中3个Consumer可分别消费一个Partition的数据,另外一个Consumer(consumer4)不能消费topic1的任何数据。
此时关闭consumer1,其余3个Consumer可分别消费一个Partition的数据。
接着关闭consumer2,consumer3可消费2个Partition,consumer4可消费1个Partition。
再关闭consumer3,仅存的consumer4可同时消费topic1的3个Partition。
Consumer Rebalance的算法如下:
目前,最新版(0.8.2.1)Kafka的Consumer Rebalance的控制策略是由每一个Consumer通过在Zookeeper上注册Watch完成的。每个Consumer被创建时会触发Consumer Group的Rebalance,具体启动流程如下:
/consumers/[consumer group]/ids/[consumer id]
/consumers/[consumer group]/ids
上注册Watch/brokers/ids
上注册Watch/brokers/topics
上也创建Watch在这种策略下,每一个Consumer或者Broker的增加或者减少都会触发Consumer Rebalance。因为每个Consumer只负责调整自己所消费的Partition,为了保证整个Consumer Group的一致性,当一个Consumer触发了Rebalance时,该Consumer Group内的其它所有其它Consumer也应该同时触发Rebalance。
该方式有如下缺陷:
根据Kafka社区wiki,Kafka作者正在考虑在还未发布的0.9.x版本中使用中心协调器(Coordinator)。大体思想是为所有Consumer Group的子集选举出一个Broker作为Coordinator,由它Watch Zookeeper,从而判断是否有Partition或者Consumer的增减,然后生成Rebalance命令,并检查是否这些Rebalance在所有相关的Consumer中被执行成功,如果不成功则重试,若成功则认为此次Rebalance成功(这个过程跟Replication Controller非常类似)。具体方案将在后文中详细阐述。
使用Low Level Consumer (Simple Consumer)的主要原因是,用户希望比Consumer Group更好的控制数据的消费。比如:
与Consumer Group相比,Low Level Consumer要求用户做大量的额外工作。
使用Low Level Consumer的一般流程如下
根据社区社区wiki,Kafka在0.9.*版本中,重新设计Consumer可能是最重要的Feature之一。本节会根据社区wiki介绍Kafka 0.9.*中对Consumer可能的设计方向及思路。
简化消费者客户端
部分用户希望开发和使用non-java的客户端。现阶段使用non-java发SimpleConsumer比较方便,但想开发High Level Consumer并不容易。因为High Level Consumer需要实现一些复杂但必不可少的失败探测和Rebalance。如果能将消费者客户端更精简,使依赖最小化,将会极大的方便non-java用户实现自己的Consumer。
中心Coordinator
如上文所述,当前版本的High Level Consumer存在Herd Effect和Split Brain的问题。如果将失败探测和Rebalance的逻辑放到一个高可用的中心Coordinator,那么这两个问题即可解决。同时还可大大减少Zookeeper的负载,有利于Kafka Broker的Scale Out。
允许手工管理offset
一些系统希望以特定的时间间隔在自定义的数据库中管理Offset。这就要求Consumer能获取到每条消息的metadata,例如Topic,Partition,Offset,同时还需要在Consumer启动时得到每个Partition的Offset。实现这些,需要提供新的Consumer API。同时有个问题不得不考虑,即是否允许Consumer手工管理部分Topic的Offset,而让Kafka自动通过Zookeeper管理其它Topic的Offset。一个可能的选项是让每个Consumer只能选取1种Offset管理机制,这可极大的简化Consumer API的设计和实现。
Rebalance后触发用户指定的回调
一些应用可能会在内存中为每个Partition维护一些状态,Rebalance时,它们可能需要将该状态持久化。因此该需求希望支持用户实现并指定一些可插拔的并在Rebalance时触发的回调。如果用户使用手动的Offset管理,那该需求可方便得由用户实现,而如果用户希望使用Kafka提供的自动Offset管理,则需要Kafka提供该回调机制。
非阻塞式Consumer API
该需求源于那些实现高层流处理操作,如filter by, group by, join等,的系统。现阶段的阻塞式Consumer几乎不可能实现Join操作。
##如何通过中心Coordinator实现Rebalance
成功Rebalance的结果是,被订阅的所有Topic的每一个Partition将会被Consumer Group内的一个(有且仅有一个)Consumer拥有。每一个Broker将被选举为某些Consumer Group的Coordinator。某个Cosnumer Group的Coordinator负责在该Consumer Group的成员变化或者所订阅的Topic的Partititon变化时协调Rebalance操作。
Consumer
1) Consumer启动时,先向Broker列表中的任意一个Broker发送ConsumerMetadataRequest,并通过ConsumerMetadataResponse获取它所在Group的Coordinator信息。ConsumerMetadataRequest和ConsumerMetadataResponse的结构如下1
2
3
4
5
6
7
8
9
10ConsumerMetadataRequest
{
GroupId => String
}
ConsumerMetadataResponse
{
ErrorCode => int16
Coordinator => Broker
}
2)Consumer连接到Coordinator并发送HeartbeatRequest,如果返回的HeartbeatResponse没有任何错误码,Consumer继续fetch数据。若其中包含IllegalGeneration错误码,即说明Coordinator已经发起了Rebalance操作,此时Consumer停止fetch数据,commit offset,并发送JoinGroupRequest给它的Coordinator,并在JoinGroupResponse中获得它应该拥有的所有Partition列表和它所属的Group的新的Generation ID。此时Rebalance完成,Consumer开始fetch数据。相应Request和Response结构如下
1 | HeartbeatRequest |
Consumer状态机
Down:Consumer停止工作
Start up & discover coordinator:Consumer检测其所在Group的Coordinator。一旦它检测到Coordinator,即向其发送JoinGroupRequest。
Part of a group:该状态下,Consumer已经是该Group的成员,并周期性发送HeartbeatRequest。如HeartbeatResponse包含IllegalGeneration错误码,则转换到Stopped Consumption状态。若连接丢失,HeartbeatResponse包含NotCoordinatorForGroup错误码,则转换到Rediscover coordinator状态。
Rediscover coordinator:该状态下,Consumer不停止消费而是尝试通过发送ConsumerMetadataRequest来探测新的Coordinator,并且等待直到获得无错误码的响应。
Stopped consumption:该状态下,Consumer停止消费并提交offset,直到它再次加入Group。
故障检测机制
Consumer成功加入Group后,Consumer和相应的Coordinator同时开始故障探测程序。Consumer向Coordinator发起周期性的Heartbeat(HeartbeatRequest)并等待响应,该周期为 session.timeout.ms/heartbeat.frequency。若Consumer在session.timeout.ms内未收到HeartbeatResponse,或者发现相应的Socket channel断开,它即认为Coordinator已宕机并启动Coordinator探测程序。若Coordinator在session.timeout.ms内没有收到一次HeartbeatRequest,则它将该Consumer标记为宕机状态并为其所在Group触发一次Rebalance操作。
Coordinator Failover过程中,Consumer可能会在新的Coordinator完成Failover过程之前或之后发现新的Coordinator并向其发送HeatbeatRequest。对于后者,新的Cooodinator可能拒绝该请求,致使该Consumer重新探测Coordinator并发起新的连接请求。如果该Consumer向新的Coordinator发送连接请求太晚,新的Coordinator可能已经在此之前将其标记为宕机状态而将之视为新加入的Consumer并触发一次Rebalance操作。
Coordinator
1)稳定状态下,Coordinator通过上述故障探测机制跟踪其所管理的每个Group下的每个Consumer的健康状态。
2)刚启动时或选举完成后,Coordinator从Zookeeper读取它所管理的Group列表及这些Group的成员列表。如果没有获取到Group成员信息,它不会做任何事情直到某个Group中有成员注册进来。
3)在Coordinator完成加载其管理的Group列表及其相应的成员信息之前,它将为HeartbeatRequest,OffsetCommitRequest和JoinGroupRequests返回CoordinatorStartupNotComplete错误码。此时,Consumer会重新发送请求。
4)Coordinator会跟踪被其所管理的任何Consumer Group注册的Topic的Partition的变化,并为该变化触发Rebalance操作。创建新的Topic也可能触发Rebalance,因为Consumer可以在Topic被创建之前就已经订阅它了。
Coordinator发起Rebalance操作流程如下所示。
Coordinator状态机
Down:Coordinator不再担任之前负责的Consumer Group的Coordinator
Catch up:该状态下,Coordinator竞选成功,但还未能做好服务相应请求的准备。
Ready:该状态下,新竞选出来的Coordinator已经完成从Zookeeper中加载它所负责管理的所有Group的metadata,并可开始接收相应的请求。
Prepare for rebalance:该状态下,Coordinator在所有HeartbeatResponse中返回IllegalGeneration错误码,并等待所有Consumer向其发送JoinGroupRequest后转到Rebalancing状态。
Rebalancing:该状态下,Coordinator已经收到了JoinGroupRequest请求,并增加其Group Generation ID,分配Consumer ID,分配Partition。Rebalance成功后,它会等待接收包含新的Consumer Generation ID的HeartbeatRequest,并转至Ready状态。
Coordinator Failover
如前文所述,Rebalance操作需要经历如下几个阶段
1)Topic/Partition的改变或者新Consumer的加入或者已有Consumer停止,触发Coordinator注册在Zookeeper上的watch,Coordinator收到通知准备发起Rebalance操作。
2)Coordinator通过在HeartbeatResponse中返回IllegalGeneration错误码发起Rebalance操作。
3)Consumer发送JoinGroupRequest
4)Coordinator在Zookeeper中增加Group的Generation ID并将新的Partition分配情况写入Zookeeper
5)Coordinator发送JoinGroupResponse
在这个过程中的每个阶段,Coordinator都可能出现故障。下面给出Rebalance不同阶段中Coordinator的Failover处理方式。
1)如果Coordinator的故障发生在第一阶段,即它收到Notification并未来得及作出响应,则新的Coordinator将从Zookeeper读取Group的metadata,包含这些Group订阅的Topic列表和之前的Partition分配。如果某个Group所订阅的Topic数或者某个Topic的Partition数与之前的Partition分配不一致,亦或者某个Group连接到新的Coordinator的Consumer数与之前Partition分配中的不一致,新的Coordinator会发起Rebalance操作。
2)如果失败发生在阶段2,它可能对部分而非全部Consumer发出带错误码的HeartbeatResponse。与第上面第一种情况一样,新的Coordinator会检测到Rebalance的必要性并发起一次Rebalance操作。如果Rebalance是由Consumer的失败所触发并且Cosnumer在Coordinator的Failover完成前恢复,新的Coordinator不会为此发起新的Rebalance操作。
3)如果Failure发生在阶段3,新的Coordinator可能只收到部分而非全部Consumer的JoinGroupRequest。Failover完成后,它可能收到部分Consumer的HeartRequest及另外部分Consumer的JoinGroupRequest。与第1种情况类似,它将发起新一轮的Rebalance操作。
4)如果Failure发生在阶段4,即它将新的Group Generation ID和Group成员信息写入Zookeeper后。新的Generation ID和Group成员信息以一个原子操作一次性写入Zookeeper。Failover完成后,Consumer会发送HeartbeatRequest给新的Coordinator,并包含旧的Generation ID。此时新的Coordinator通过在HeartbeatResponse中返回IllegalGeneration错误码发起新的一轮Rebalance。这也解释了为什么每次HeartbeatRequest中都需要包含Generation ID和Consumer ID。
5)如果Failure发生在阶段5,旧的Coordinator可能只向Group中的部分Consumer发送了JoinGroupResponse。收到JoinGroupResponse的Consumer在下次向已经失效的Coordinator发送HeartbeatRequest或者提交Offset时会检测到它已经失败。此时,它将检测新的Coordinator并向其发送带有新的Generation ID 的HeartbeatRequest。而未收到JoinGroupResponse的Consumer将检测新的Coordinator并向其发送JoinGroupRequest,这将促使新的Coordinator发起新一轮的Rebalance。
原创文章,转载请务必将下面这段话置于文章开头处。(已授权InfoQ中文站发布)
本文转发自技术世界,原文链接 http://www.jasongj.com/2015/06/08/KafkaColumn3
本文在上篇文章基础上,更加深入讲解了Kafka的HA机制,主要阐述了HA相关各种场景,如Broker failover,Controller failover,Topic创建/删除,Broker启动,Follower从Leader fetch数据等详细处理过程。同时介绍了Kafka提供的与Replication相关的工具,如重新分配Partition等。
/brokers/ids
节点上注册Watch。一旦有Broker宕机(本文用宕机代表任何让Kafka认为其Broker die的情景,包括但不限于机器断电,网络不可用,GC导致的Stop The World,进程crash等),其在Zookeeper对应的Znode会自动被删除,Zookeeper会fire Controller注册的Watch,Controller即可获取最新的幸存的Broker列表。/brokers/topics/[topic]/partitions/[partition]/state
读取该Partition当前的ISR。leader_epoch
及controller_epoch
写入/brokers/topics/[topic]/partitions/[partition]/state
。注意,该操作只有Controller版本在3.1至3.3的过程中无变化时才会执行,否则跳转到3.1。 LeaderAndIsrRequest结构如下
LeaderAndIsrResponse结构如下
/brokers/topics
节点上注册Watch,一旦某个Topic被创建或删除,则Controller会通过Watch得到新创建/删除的Topic的Partition/Replica分配。/admin/delete_topics
。若delete.topic.enable
为true,则Controller注册在/admin/delete_topics
上的Watch被fire,Controller通过回调向对应的Broker发送StopReplicaRequest;若为false则Controller不会在/admin/delete_topics
上注册Watch,也就不会对该事件作出反应,此时Topic操作只被记录而不会被执行。/brokers/ids
读取当前所有可用的Broker列表,对于set_p中的每一个Partition:/brokers/topics/[topic]/partitions/[partition]
Broker通过kafka.network.SocketServer
及相关模块接受各种请求并作出响应。整个网络通信模块基于Java NIO开发,并采用Reactor模式,其中包含1个Acceptor负责接受客户请求,N个Processor负责读写数据,M个Handler处理业务逻辑。
Acceptor的主要职责是监听并接受客户端(请求发起方,包括但不限于Producer,Consumer,Controller,Admin Tool)的连接请求,并建立和客户端的数据传输通道,然后为该客户端指定一个Processor,至此它对该客户端该次请求的任务就结束了,它可以去响应下一个客户端的连接请求了。其核心代码如下。
Processor主要负责从客户端读取数据并将响应返回给客户端,它本身并不处理具体的业务逻辑,并且其内部维护了一个队列来保存分配给它的所有SocketChannel。Processor的run方法会循环从队列中取出新的SocketChannel并将其SelectionKey.OP_READ
注册到selector上,然后循环处理已就绪的读(请求)和写(响应)。Processor读取完数据后,将其封装成Request对象并将其交给RequestChannel。
RequestChannel是Processor和KafkaRequestHandler交换数据的地方,它包含一个队列requestQueue用来存放Processor加入的Request,KafkaRequestHandler会从里面取出Request来处理;同时它还包含一个respondQueue,用来存放KafkaRequestHandler处理完Request后返还给客户端的Response。
Processor会通过processNewResponses方法依次将requestChannel中responseQueue保存的Response取出,并将对应的SelectionKey.OP_WRITE
事件注册到selector上。当selector的select方法返回时,对检测到的可写通道,调用write方法将Response返回给客户端。
KafkaRequestHandler循环从RequestChannel中取Request并交给kafka.server.KafkaApis
处理具体的业务逻辑。
对于收到的LeaderAndIsrRequest,Broker主要通过ReplicaManager的becomeLeaderOrFollower处理,流程如下:
LeaderAndIsrRequest处理过程如下图所示
Broker启动后首先根据其ID在Zookeeper的/brokers/ids
zonde下创建临时子节点(Ephemeral node),创建成功后Controller的ReplicaStateMachine注册其上的Broker Change Watch会被fire,从而通过回调KafkaController.onBrokerStartup方法完成以下步骤:
Controller也需要Failover。每个Broker都会在Controller Path (/controller
)上注册一个Watch。当前Controller失败时,对应的Controller Path会自动消失(因为它是Ephemeral Node),此时该Watch被fire,所有“活”着的Broker都会去竞选成为新的Controller(创建新的Controller Path),但是只会有一个竞选成功(这点由Zookeeper保证)。竞选成功者即为新的Leader,竞选失败者则重新在新的Controller Path上注册Watch。因为Zookeeper的Watch是一次性的,被fire一次之后即失效,所以需要重新注册。
Broker成功竞选为新Controller后会触发KafkaController.onControllerFailover方法,并在该方法中完成如下操作:
/admin/reassign_partitions
)上注册Watch。/admin/preferred_replica_election
)上注册Watch。/brokers/topics
)上注册Watch。delete.topic.enable
设置为true(默认值是false),则partitionStateMachine在Delete Topic Patch(/admin/delete_topics
)上注册Watch。/brokers/ids
)上注册Watch。auto.leader.rebalance.enable
配置为true(默认值是true),则启动partition-rebalance线程。delete.topic.enable
设置为true且Delete Topic Patch(/admin/delete_topics
)中有值,则删除相应的Topic。 管理工具发出重新分配Partition请求后,会将相应信息写到/admin/reassign_partitions
上,而该操作会触发ReassignedPartitionsIsrChangeListener,从而通过执行回调函数KafkaController.onPartitionReassignment来完成以下操作:
/admin/reassign_partition
。AR | leader/isr | Step |
---|---|---|
{1,2,3} | 1/{1,2,3} | (initial state) |
{1,2,3,4,5,6} | 1/{1,2,3} | (step 2) |
{1,2,3,4,5,6} | 1/{1,2,3,4,5,6} | (step 4) |
{1,2,3,4,5,6} | 4/{1,2,3,4,5,6} | (step 7) |
{1,2,3,4,5,6} | 4/{4,5,6} | (step 8) |
{4,5,6} | 4/{4,5,6} | (step 10) |
Follower通过向Leader发送FetchRequest获取消息,FetchRequest结构如下
从FetchRequest的结构可以看出,每个Fetch请求都要指定最大等待时间和最小获取字节数,以及由TopicAndPartition和PartitionFetchInfo构成的Map。实际上,Follower从Leader数据和Consumer从Broker Fetch数据,都是通过FetchRequest请求完成,所以在FetchRequest结构中,其中一个字段是clientID,并且其默认值是ConsumerConfig.DefaultClientId。
Leader收到Fetch请求后,Kafka通过KafkaApis.handleFetchRequest响应该请求,响应过程如下:
Leader通过以FetchResponse的形式将消息返回给Follower,FetchResponse结构如下
#Replication工具
$KAFKA_HOME/bin/kafka-topics.sh
,该工具可用于创建、删除、修改、查看某个Topic,也可用于列出所有Topic。另外,该工具还可修改以下配置。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16unclean.leader.election.enable
delete.retention.ms
segment.jitter.ms
retention.ms
flush.ms
segment.bytes
flush.messages
segment.ms
retention.bytes
cleanup.policy
segment.index.bytes
min.cleanable.dirty.ratio
max.message.bytes
file.delete.delay.ms
min.insync.replicas
index.interval.bytes
$KAFKA_HOME/bin/kafka-replica-verification.sh
,该工具用来验证所指定的一个或多个Topic下每个Partition对应的所有Replica是否都同步。可通过topic-white-list
这一参数指定所需要验证的所有Topic,支持正则表达式。
用途
有了Replication机制后,每个Partition可能有多个备份。某个Partition的Replica列表叫作AR(Assigned Replicas),AR中的第一个Replica即为“Preferred Replica”。创建一个新的Topic或者给已有Topic增加Partition时,Kafka保证Preferred Replica被均匀分布到集群中的所有Broker上。理想情况下,Preferred Replica会被选为Leader。以上两点保证了所有Partition的Leader被均匀分布到了集群当中,这一点非常重要,因为所有的读写操作都由Leader完成,若Leader分布过于集中,会造成集群负载不均衡。但是,随着集群的运行,该平衡可能会因为Broker的宕机而被打破,该工具就是用来帮助恢复Leader分配的平衡。
事实上,每个Topic从失败中恢复过来后,它默认会被设置为Follower角色,除非某个Partition的Replica全部宕机,而当前Broker是该Partition的AR中第一个恢复回来的Replica。因此,某个Partition的Leader(Preferred Replica)宕机并恢复后,它很可能不再是该Partition的Leader,但仍然是Preferred Replica。
原理
/admin/preferred_replica_election
节点,并存入需要调整Preferred Replica的Partition信息。用法
$KAFKA_HOME/bin/kafka-preferred-replica-election.sh --zookeeper localhost:2181
在包含8个Broker的Kafka集群上,创建1个名为topic1,replication-factor为3,Partition数为8的Topic,使用$KAFKA_HOME/bin/kafka-topics.sh --describe --topic topic1 --zookeeper localhost:2181
命令查看其Partition/Replica分布。
查询结果如下图所示,从图中可以看到,Kafka将所有Replica均匀分布到了整个集群,并且Leader也均匀分布。
手动停止部分Broker,topic1的Partition/Replica分布如下图所示。从图中可以看到,由于Broker 1/2/4都被停止,Partition 0的Leader由原来的1变为3,Partition 1的Leader由原来的2变为5,Partition 2的Leader由原来的3变为6,Partition 3的Leader由原来的4变为7。
再重新启动ID为1的Broker,topic1的Partition/Replica分布如下。可以看到,虽然Broker 1已经启动(Partition 0和Partition5的ISR中有1),但是1并不是任何一个Parititon的Leader,而Broker 5/6/7都是2个Partition的Leader,即Leader的分布不均衡——一个Broker最多是2个Partition的Leader,而最少是0个Partition的Leader。
运行该工具后,topic1的Partition/Replica分布如下图所示。由图可见,除了Partition 1和Partition 3由于Broker 2和Broker 4还未启动,所以其Leader不是其Preferred Repliac外,其它所有Partition的Leader都是其Preferred Replica。同时,与运行该工具前相比,Leader的分配更均匀——一个Broker最多是2个Parittion的Leader,最少是1个Partition的Leader。
启动Broker 2和Broker 4,Leader分布与上一步相比并未变化,如下图所示。
再次运行该工具,所有Partition的Leader都由其Preferred Replica承担,Leader分布更均匀——每个Broker承担1个Partition的Leader角色。
除了手动运行该工具使Leader分配均匀外,Kafka还提供了自动平衡Leader分配的功能,该功能可通过将auto.leader.rebalance.enable
设置为true开启,它将周期性检查Leader分配是否平衡,若不平衡度超过一定阈值则自动由Controller尝试将各Partition的Leader设置为其Preferred Replica。检查周期由leader.imbalance.check.interval.seconds
指定,不平衡度阈值由leader.imbalance.per.broker.percentage
指定。
用途
该工具的设计目标与Preferred Replica Leader Election Tool有些类似,都旨在促进Kafka集群的负载均衡。不同的是,Preferred Replica Leader Election只能在Partition的AR范围内调整其Leader,使Leader分布均匀,而该工具还可以调整Partition的AR。
Follower需要从Leader Fetch数据以保持与Leader同步,所以仅仅保持Leader分布的平衡对整个集群的负载均衡来说是不够的。另外,生产环境下,随着负载的增大,可能需要给Kafka集群扩容。向Kafka集群中增加Broker非常简单方便,但是对于已有的Topic,并不会自动将其Partition迁移到新加入的Broker上,此时可用该工具达到此目的。某些场景下,实际负载可能远小于最初预期负载,此时可用该工具将分布在整个集群上的Partition重装分配到某些机器上,然后可以停止不需要的Broker从而实现节约资源的目的。
需要说明的是,该工具不仅可以调整Partition的AR位置,还可调整其AR数量,即改变该Topic的replication factor。
原理
该工具只负责将所需信息存入Zookeeper中相应节点,然后退出,不负责相关的具体操作,所有调整都由Controller完成。
/admin/reassign_partitions
节点,并存入目标Partition列表及其对应的目标AR列表。/admin/reassign_partitions
上的Watch被fire,Controller获取该列表。RAR - AR
中的Replica,即新分配的Replica。(RAR = Reassigned Replicas, AR = Assigned Replicas)AR - RAR
中的Replica,即不再需要的Replica/admin/reassign_partitions
节点用法
该工具有三种使用模式
下面这个例子将使用该工具将Topic的所有Partition重新分配到Broker 4/5/6/7上,步骤如下:
/tmp/topics-to-move.json
文件中,然后执行1 | $KAFKA_HOME/bin/kafka-reassign-partitions.sh |
结果如下图所示
2. 使用execute模式,执行reassign plan
将上一步生成的reassignment plan存入/tmp/reassign-plan.json
文件中,并执行1
2
3 $KAFKA_HOME/bin/kafka-reassign-partitions.sh
--zookeeper localhost:2181
--reassignment-json-file /tmp/reassign-plan.json --execute
此时,Zookeeper上/admin/reassign_partitions
节点被创建,且其值与/tmp/reassign-plan.json
文件的内容一致。
3. 使用verify模式,验证reassign是否完成。执行verify命令1
2
3$KAFKA_HOME/bin/kafka-reassign-partitions.sh
--zookeeper localhost:2181 --verify
--reassignment-json-file /tmp/reassign-plan.json
结果如下所示,从图中可看出topic1的所有Partititon都重新分配成功。
接下来用Topic Tool再次验证。1
bin/kafka-topics.sh --zookeeper localhost:2181 --describe --topic topic1
结果如下图所示,从图中可看出topic1的所有Partition都被重新分配到Broker 4/5/6/7,且每个Partition的AR与reassign plan一致。
需要说明的是,在使用execute之前,并不一定要使用generate模式自动生成reassign plan,使用generate模式只是为了方便。事实上,某些场景下,generate模式生成的reassign plan并不一定能满足需求,此时用户可以自己设置reassign plan。
用途
该工具旨在从整个集群的Broker上收集状态改变日志,并生成一个集中的格式化的日志以帮助诊断状态改变相关的故障。每个Broker都会将其收到的状态改变相关的的指令存于名为state-change.log
的日志文件中。某些情况下,Partition的Leader Election可能会出现问题,此时我们需要对整个集群的状态改变有个全局的了解从而诊断故障并解决问题。该工具将集群中相关的state-change.log
日志按时间顺序合并,同时支持用户输入时间范围和目标Topic及Partition作为过滤条件,最终将格式化的结果输出。
用法1
2
3bin/kafka-run-class.sh kafka.tools.StateChangeLogMerger
--logs /opt/kafka_2.11-0.8.2.1/logs/state-change.log
--topic topic1 --partitions 0,1,2,3,4,5,6,7
原创文章,转载请务必将下面这段话置于文章开头处。(已授权InfoQ中文站发布)
本文转发自技术世界,原文链接 http://www.jasongj.com/2015/04/24/KafkaColumn2
Kafka在0.8以前的版本中,并不提供High Availablity机制,一旦一个或多个Broker宕机,则宕机期间其上所有Partition都无法继续提供服务。若该Broker永远不能再恢复,亦或磁盘故障,则其上数据将丢失。而Kafka的设计目标之一即是提供数据持久化,同时对于分布式系统来说,尤其当集群规模上升到一定程度后,一台或者多台机器宕机的可能性大大提高,对于Failover机制的需求非常高。因此,Kafka从0.8开始提供High Availability机制。本文从Data Replication和Leader Election两方面介绍了Kafka的HA机制。
在Kafka在0.8以前的版本中,是没有Replication的,一旦某一个Broker宕机,则其上所有的Partition数据都不可被消费,这与Kafka数据持久性及Delivery Guarantee的设计目标相悖。同时Producer都不能再将数据存于这些Partition中。
message.send.max.retries
(默认值为3)次后抛出Exception,用户可以选择停止发送后续数据也可选择继续选择发送。而前者会造成数据的阻塞,后者会造成本应发往该Broker的数据的丢失。message.send.max.retries
(默认值为3)次后记录该异常并继续发送后续数据,这会造成数据丢失并且用户只能通过日志发现该问题。由此可见,在没有Replication的情况下,一旦某机器宕机或者某个Broker停止工作则会造成整个系统的可用性降低。随着集群规模的增加,整个集群中出现该类异常的几率大大增加,因此对于生产系统而言Replication机制的引入非常重要。
(本文所述Leader Election主要指Replica之间的Leader Election)
引入Replication之后,同一个Partition可能会有多个Replica,而这时需要在这些Replica中选出一个Leader,Producer和Consumer只与这个Leader交互,其它Replica作为Follower从Leader中复制数据。
因为需要保证同一个Partition的多个Replica之间的数据一致性(其中一个宕机后其它Replica必须要能继续服务并且即不能造成数据重复也不能造成数据丢失)。如果没有一个Leader,所有Replica都可同时读/写数据,那就需要保证多个Replica之间互相(N×N条通路)同步数据,数据的一致性和有序性非常难保证,大大增加了Replication实现的复杂性,同时也增加了出现异常的几率。而引入Leader后,只有Leader负责数据读写,Follower只向Leader顺序Fetch数据(N条通路),系统更加简单且高效。
为了更好的做负载均衡,Kafka尽量将所有的Partition均匀分配到整个集群上。一个典型的部署方式是一个Topic的Partition数量大于Broker的数量。同时为了提高Kafka的容错能力,也需要将同一个Partition的Replica尽量分散到不同的机器。实际上,如果所有的Replica都在同一个Broker上,那一旦该Broker宕机,该Partition的所有Replica都无法工作,也就达不到HA的效果。同时,如果某个Broker宕机了,需要保证它上面的负载可以被均匀的分配到其它幸存的所有Broker上。
Kafka分配Replica的算法如下:
Kafka的Data Replication需要解决如下问题:
Producer在发布消息到某个Partition时,先通过Zookeeper找到该Partition的Leader,然后无论该Topic的Replication Factor为多少(也即该Partition有多少个Replica),Producer只将该消息发送到该Partition的Leader。Leader会将该消息写入其本地Log。每个Follower都从Leader pull数据。这种方式上,Follower存储的数据顺序与Leader保持一致。Follower在收到该消息并写入其Log后,向Leader发送ACK。一旦Leader收到了ISR中的所有Replica的ACK,该消息就被认为已经commit了,Leader将增加HW并且向Producer发送ACK。
为了提高性能,每个Follower在接收到数据后就立马向Leader发送ACK,而非等到数据写入Log中。因此,对于已经commit的消息,Kafka只能保证它被存于多个Replica的内存中,而不能保证它们被持久化到磁盘中,也就不能完全保证异常发生后该条消息一定能被Consumer消费。但考虑到这种场景非常少见,可以认为这种方式在性能和数据持久化上做了一个比较好的平衡。在将来的版本中,Kafka会考虑提供更高的持久性。
Consumer读消息也是从Leader读取,只有被commit过的消息(offset低于HW的消息)才会暴露给Consumer。
Kafka Replication的数据流如下图所示
和大部分分布式系统一样,Kafka处理失败需要明确定义一个Broker是否“活着”。对于Kafka而言,Kafka存活包含两个条件,一是它必须维护与Zookeeper的session(这个通过Zookeeper的Heartbeat机制来实现)。二是Follower必须能够及时将Leader的消息复制过来,不能“落后太多”。
Leader会跟踪与其保持同步的Replica列表,该列表称为ISR(即in-sync Replica)。如果一个Follower宕机,或者落后太多,Leader将把它从ISR中移除。这里所描述的“落后太多”指Follower复制的消息落后于Leader后的条数超过预定值(该值可在$KAFKA_HOME/config/server.properties中通过replica.lag.max.messages
配置,其默认值是4000)或者Follower超过一定时间(该值可在$KAFKA_HOME/config/server.properties中通过replica.lag.time.max.ms
来配置,其默认值是10000)未向Leader发送fetch请求。。
Kafka的复制机制既不是完全的同步复制,也不是单纯的异步复制。事实上,同步复制要求所有能工作的Follower都复制完,这条消息才会被认为commit,这种复制方式极大的影响了吞吐率(高吞吐率是Kafka非常重要的一个特性)。而异步复制方式下,Follower异步的从Leader复制数据,数据只要被Leader写入log就被认为已经commit,这种情况下如果Follower都复制完都落后于Leader,而如果Leader突然宕机,则会丢失数据。而Kafka的这种使用ISR的方式则很好的均衡了确保数据不丢失以及吞吐率。Follower可以批量的从Leader复制数据,这样极大的提高复制性能(批量写磁盘),极大减少了Follower与Leader的差距。
需要说明的是,Kafka只解决fail/recover,不处理“Byzantine”(“拜占庭”)问题。一条消息只有被ISR里的所有Follower都从Leader复制过去才会被认为已提交。这样就避免了部分数据被写进了Leader,还没来得及被任何Follower复制就宕机了,而造成数据丢失(Consumer无法消费这些数据)。而对于Producer而言,它可以选择是否等待消息commit,这可以通过request.required.acks
来设置。这种机制确保了只要ISR有一个或以上的Follower,一条被commit的消息就不会丢失。
上文说明了Kafka是如何做Replication的,另外一个很重要的问题是当Leader宕机了,怎样在Follower中选举出新的Leader。因为Follower可能落后许多或者crash了,所以必须确保选择“最新”的Follower作为新的Leader。一个基本的原则就是,如果Leader不在了,新的Leader必须拥有原来的Leader commit过的所有消息。这就需要作一个折衷,如果Leader在标明一条消息被commit前等待更多的Follower确认,那在它宕机之后就有更多的Follower可以作为新的Leader,但这也会造成吞吐率的下降。
一种非常常用的Leader Election的方式是“Majority Vote”(“少数服从多数”),但Kafka并未采用这种方式。这种模式下,如果我们有2f+1个Replica(包含Leader和Follower),那在commit之前必须保证有f+1个Replica复制完消息,为了保证正确选出新的Leader,fail的Replica不能超过f个。因为在剩下的任意f+1个Replica里,至少有一个Replica包含有最新的所有消息。这种方式有个很大的优势,系统的latency只取决于最快的几个Broker,而非最慢那个。Majority Vote也有一些劣势,为了保证Leader Election的正常进行,它所能容忍的fail的follower个数比较少。如果要容忍1个follower挂掉,必须要有3个以上的Replica,如果要容忍2个Follower挂掉,必须要有5个以上的Replica。也就是说,在生产环境下为了保证较高的容错程度,必须要有大量的Replica,而大量的Replica又会在大数据量下导致性能的急剧下降。这就是这种算法更多用在Zookeeper这种共享集群配置的系统中而很少在需要存储大量数据的系统中使用的原因。例如HDFS的HA Feature是基于majority-vote-based journal,但是它的数据存储并没有使用这种方式。
实际上,Leader Election算法非常多,比如Zookeeper的Zab, Raft和Viewstamped Replication。而Kafka所使用的Leader Election算法更像微软的PacificA算法。
Kafka在Zookeeper中动态维护了一个ISR(in-sync replicas),这个ISR里的所有Replica都跟上了leader,只有ISR里的成员才有被选为Leader的可能。在这种模式下,对于f+1个Replica,一个Partition能在保证不丢失已经commit的消息的前提下容忍f个Replica的失败。在大多数使用场景中,这种模式是非常有利的。事实上,为了容忍f个Replica的失败,Majority Vote和ISR在commit前需要等待的Replica数量是一样的,但是ISR需要的总的Replica的个数几乎是Majority Vote的一半。
上文提到,在ISR中至少有一个follower时,Kafka可以确保已经commit的数据不丢失,但如果某个Partition的所有Replica都宕机了,就无法保证数据不丢失了。这种情况下有两种可行的方案:
这就需要在可用性和一致性当中作出一个简单的折衷。如果一定要等待ISR中的Replica“活”过来,那不可用的时间就可能会相对较长。而且如果ISR中的所有Replica都无法“活”过来了,或者数据都丢失了,这个Partition将永远不可用。选择第一个“活”过来的Replica作为Leader,而这个Replica不是ISR中的Replica,那即使它并不保证已经包含了所有已commit的消息,它也会成为Leader而作为consumer的数据源(前文有说明,所有读写都由Leader完成)。Kafka0.8.*使用了第二种方式。根据Kafka的文档,在以后的版本中,Kafka支持用户通过配置选择这两种方式中的一种,从而根据不同的使用场景选择高可用性还是强一致性。
最简单最直观的方案是,所有Follower都在Zookeeper上设置一个Watch,一旦Leader宕机,其对应的ephemeral znode会自动删除,此时所有Follower都尝试创建该节点,而创建成功者(Zookeeper保证只有一个能创建成功)即是新的Leader,其它Replica即为Follower。
但是该方法会有3个问题:
Kafka 0.8.*的Leader Election方案解决了上述问题,它在所有broker中选出一个controller,所有Partition的Leader选举都由controller决定。controller会将Leader的改变直接通过RPC的方式(比Zookeeper Queue的方式更高效)通知需为此作出响应的Broker。同时controller也负责增删Topic以及Replica的重新分配。
(本节所示Zookeeper结构中,实线框代表路径名是固定的,而虚线框代表路径名与业务相关)
admin (该目录下znode只有在有相关操作时才会存在,操作结束时会将其删除)
/admin/preferred_replica_election
数据结构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 Schema:
{
"fields":[
{
"name":"version",
"type":"int",
"doc":"version id"
},
{
"name":"partitions",
"type":{
"type":"array",
"items":{
"fields":[
{
"name":"topic",
"type":"string",
"doc":"topic of the partition for which preferred replica election should be triggered"
},
{
"name":"partition",
"type":"int",
"doc":"the partition for which preferred replica election should be triggered"
}
],
}
"doc":"an array of partitions for which preferred replica election should be triggered"
}
}
]
}
Example:
{
"version": 1,
"partitions":
[
{
"topic": "topic1",
"partition": 8
},
{
"topic": "topic2",
"partition": 16
}
]
}
/admin/reassign_partitions
用于将一些Partition分配到不同的broker集合上。对于每个待重新分配的Partition,Kafka会在该znode上存储其所有的Replica和相应的Broker id。该znode由管理进程创建并且一旦重新分配成功它将会被自动移除。其数据结构如下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 Schema:
{
"fields":[
{
"name":"version",
"type":"int",
"doc":"version id"
},
{
"name":"partitions",
"type":{
"type":"array",
"items":{
"fields":[
{
"name":"topic",
"type":"string",
"doc":"topic of the partition to be reassigned"
},
{
"name":"partition",
"type":"int",
"doc":"the partition to be reassigned"
},
{
"name":"replicas",
"type":"array",
"items":"int",
"doc":"a list of replica ids"
}
],
}
"doc":"an array of partitions to be reassigned to new replicas"
}
}
]
}
Example:
{
"version": 1,
"partitions":
[
{
"topic": "topic3",
"partition": 1,
"replicas": [1, 2, 3]
}
]
}
/admin/delete_topics
数据结构1
2
3
4
5
6
7
8
9
10
11
12
13Schema:
{ "fields":
[ {"name": "version", "type": "int", "doc": "version id"},
{"name": "topics",
"type": { "type": "array", "items": "string", "doc": "an array of topics to be deleted"}
} ]
}
Example:
{
"version": 1,
"topics": ["topic4", "topic5"]
}
brokers
broker(即/brokers/ids/[brokerId]
)存储“活着”的Broker信息。数据结构如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16Schema:
{ "fields":
[ {"name": "version", "type": "int", "doc": "version id"},
{"name": "host", "type": "string", "doc": "ip address or host name of the broker"},
{"name": "port", "type": "int", "doc": "port of the broker"},
{"name": "jmx_port", "type": "int", "doc": "port for jmx"}
]
}
Example:
{
"jmx_port":-1,
"host":"node1",
"version":1,
"port":9092
}
topic注册信息(/brokers/topics/[topic]
),存储该Topic的所有Partition的所有Replica所在的Broker id,第一个Replica即为Preferred Replica,对一个给定的Partition,它在同一个Broker上最多只有一个Replica,因此Broker id可作为Replica id。数据结构如下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
28Schema:
{ "fields" :
[ {"name": "version", "type": "int", "doc": "version id"},
{"name": "partitions",
"type": {"type": "map",
"values": {"type": "array", "items": "int", "doc": "a list of replica ids"},
"doc": "a map from partition id to replica list"},
}
]
}
Example:
{
"version":1,
"partitions":
{"12":[6],
"8":[2],
"4":[6],
"11":[5],
"9":[3],
"5":[7],
"10":[4],
"6":[8],
"1":[3],
"0":[2],
"2":[4],
"7":[1],
"3":[5]}
}
partition state(/brokers/topics/[topic]/partitions/[partitionId]/state
) 结构如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22Schema:
{ "fields":
[ {"name": "version", "type": "int", "doc": "version id"},
{"name": "isr",
"type": {"type": "array",
"items": "int",
"doc": "an array of the id of replicas in isr"}
},
{"name": "leader", "type": "int", "doc": "id of the leader replica"},
{"name": "controller_epoch", "type": "int", "doc": "epoch of the controller that last updated the leader and isr info"},
{"name": "leader_epoch", "type": "int", "doc": "epoch of the leader"}
]
}
Example:
{
"controller_epoch":29,
"leader":2,
"version":1,
"leader_epoch":48,
"isr":[2]
}
controller
/controller -> int (broker id of the controller)
存储当前controller的信息1
2
3
4
5
6
7
8
9
10
11Schema:
{ "fields":
[ {"name": "version", "type": "int", "doc": "version id"},
{"name": "brokerid", "type": "int", "doc": "broker id of the controller"}
]
}
Example:
{
"version":1,
"brokerid":8
}
/controller_epoch -> int (epoch)
直接以整数形式存储controller epoch,而非像其它znode一样以JSON字符串形式存储。
/brokers/topics/[topic]/partitions/[partition]/state
读取该Partition当前的ISRleader_epoch
及controller_epoch
写入/brokers/topics/[topic]/partitions/[partition]/state
。注意,该操作只有其version在3.1至3.3的过程中无变化时才会执行,否则跳转到3.1下篇文章将详细介绍Kafka HA相关的异常情况处理,例如,怎样处理Broker failover,Follower如何从Leader fetch消息,如何重新分配Replica,如何处理Controller failure等。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/2015/03/27/ml1_linear_regression
写在前面的话 按照正常的顺序,本文应该先讲一些线性回归的基本概念,比如什么叫线性回归,线性回规的常用解法等。但既然本文名为《从一个R语言案例学会线性回归》,那就更重视如何使用R语言去解决线性回归问题,因此本文会先讲案例。
如下图所示,如果把自变量(也叫independent variable)和因变量(也叫dependent variable)画在二维坐标上,则每条记录对应一个点。线性回规最常见的应用场景则是用一条直线去拟和已知的点,并对给定的x值预测其y值。而我们要做的就是找出一条合适的曲线,也就是找出合适的斜率及纵截矩。
上图中的SSE指sum of squared error,也即预测值与实际值之差的平方和,可由此判断该模型的误差。但使用SSE表征模型的误差有些弊端,比如它依赖于点的个数,且不好定其单位。所以我们有另外一个值去称量模型的误差。RMSE(Root-Mean-Square Error)。$$RMSE=\sqrt{\frac{SSE}{N}}$$
由N将其标准化,并且其单位与变量单位相同。
在选择用于预测的直线时,我们可以使用已知记录的y值的平均值作为直线,如上图红线所示,这条线我们称之为baseline model。SST(total sum of squares)指是的baseline的SSE。用SSE表征模型好坏也有不便之处,比如给定$SSE=10$,我们并不知道这个模型是好还是好,因此我们引入另一个变量,$R^2$,定义如下:$$R^2 = 1 - \frac{SSE}{SST}$$
$R^2$用来表明我们所选的模型在baseline model的基础之上提升了多少(对于任意给定数据集,我们都可以用baseline作为模型,而事实上,我们总希望我们最后选出的模型在baseline基础之上有所提升),并且这个值的范围是[0,1]。$R^2=0$意味着它并未在baseline model的基础之上有所提升,而$R^2=1$(此时$SSE=0$)意味着一个一个非常完美的模型。
在多元回归模型中,选用的Feature越多,我们所能得到的$R^2$越大。所以$R^2$不能用于帮助我们在Feature特别多时,选择合适的Feature用于建模。因此又有了Adjusted $R^2$,它会补偿由Feature增多/减少而引起的$R^2$的增加/减少,从而可通过它选择出真正适合用于建模的Feature。
许多研究表明,全球平均气温在过去几十年中有所升高,以此引起的海平面上升和极端天气频现将会影响无数人。本文所讲案例就试图研究全球平均气温与一些其它因素的关系。
读者可由此下载本文所使用的数据climate_change.csv。
https://courses.edx.org/c4x/MITx/15.071x_2/asset/climate_change.csv
此数据集包含了从1983年5月到2008年12月的数据。
本例我们以1983年5月到2006年12月的数据作为训练数据集,以之后的数据作为测试数据集。
首先加载数据
temp <- read.csv("climate_change.csv")
数据解释
线性回归模型保留两部分。
初始选择所有Feature
选择所有Feature作为第一个model1,并使用summary函数算出其Adjusted $R^2$为0.7371。
$$model1 <- lm(Temp ~ MEI + CO_2 + CH_4 + N_2O + CFC.11 + CFC.12 + TSI + Aerosols, temp)$$
summary(model1)
逐一去掉Feature
在model1中去掉任一个Feature,并记下相应的Adjusted $R^2$如下
Feature | Adjusted $R^2$ |
---|---|
CO2 + CH4 + N2O + CFC.11 + CFC.12 + TSI + Aerosols | 0.6373 |
MEI + CH4 + N2O + CFC.11 + CFC.12 + TSI + Aerosols | 0.7331 |
MEI + CO2 + N2O + CFC.11 + CFC.12 + TSI + Aerosols | 0.738 |
MEI + CO2 + CH4 + CFC.11 + CFC.12 + TSI + Aerosols | 0.7339 |
MEI + CO2 + CH4 + N2O + CFC.12 + TSI + Aerosols | 0.7163 |
MEI + CO2 + CH4 + N2O + CFC.11 + TSI + Aerosols | 0.7172 |
MEI + CO2 + CH4 + N2O + CFC.11 + CFC.12 + Aerosols | 0.697 |
MEI + CO2 + CH4 + N2O + CFC.11 + CFC.12 + TSI | 0.6883 |
本轮得到
$$Temp \sim MEI + CO_2 + N_2O + CFC.11 + CFC.12 + TSI + Aerosols$$
从model2中任意去掉1个Feature,并记下相应的Adjusted $R^2$如下
Feature | Adjusted $R^2$ |
---|---|
CO2 + N2O + CFC.11 + CFC.12 + TSI + Aerosols | 0.6377 |
MEI + N2O + CFC.11 + CFC.12 + TSI + Aerosols | 0.7339 |
MEI + CO2 + CFC.11 + CFC.12 + TSI + Aerosols | 0.7346 |
MEI + CO2 + N2O + CFC.12 + TSI + Aerosols | 0.7171 |
MEI + CO2 + N2O + CFC.11 + TSI + Aerosols | 0.7166 |
MEI + CO2 + N2O + CFC.11 + CFC.12 + Aerosols | 0.698 |
MEI + CO2 + N2O + CFC.11 + CFC.12 + TSI | 0.6891 |
任一组合的Adjusted $R^2$都比上一轮小,因此选择上一轮的Feature组合作为最终的模型,也即
$$Temp \sim MEI + CO_2 + N_2O + CFC.11 + CFC.12 + TSI + Aerosols$$
由summary(model2)
可算出每个Feature的coefficient如下 。
在线性回归中,数据使用线性预测函数来建模,并且未知的模型参数也是通过数据来估计。这些模型被叫做线性模型。最常用的线性回归建模是给定X值的y的条件均值是X的仿射函数。
线性回归是回归分析中第一种经过严格研究并在实际应用中广泛使用的类型。这是因为线性依赖于其未知参数的模型比非线性依赖于其位置参数的模型更容易拟合,而且产生的估计的统计特性也更容易确定。
上面这段定义来自于维基百科。
线性回归假设特征和结果满足线性关系。我们用$X_1,X_2..X_n$ 去描述Feature里面的分量,比如$x_1$=房间的面积,$x_2$=房间的朝向,等等,我们可以做出一个估计函数: $$h(x)=h_θ(x)=θ_0+θ_1x_1+θ_2x_2$$
θ在这儿称为参数(coefficient),在这的意思是调整Feature中每个分量的影响力,就是到底是房屋的面积更重要还是房屋的地段更重要。如果我们令$x_0 = 1$,就可以用向量的方式来表示了: $$h_θ(x)=θ^TX$$
我们的程序也需要一个机制去评估我们θ是否比较好,所以说需要对我们做出的h函数进行评估,一般这个函数称为损失函数(loss function)或者错误函数(error function),也有叫代价函数(cost function)的。在本文中,我们称这个函数为J函数。
在这里,我们可以认为J函数如下: $$J(0)=\frac{1}{2m}\sum_{i=1}^m(h_0x^{(i)}-y^{(i)})^2$$
这个错误估计函数是去对$x(i)$的估计值与真实值$y(i)$差的平方和作为错误估计函数,前面乘上的$1/2m$是为了在求导的时候,这个系数就不见了。至于为何选择平方和作为错误估计函数,就得从概率分布的角度来解释了。
如何调整$θ$以使得$J(θ)$取得最小值有很多方法,本文会重点介绍梯度下降法和正规方程法。
在选定线性回归模型后,只需要确定参数$θ$,就可以将模型用来预测。然而$θ$需要使得$J(θ)$最小。因此问题归结为求极小值问题。
梯度下降法流程如下:
1. 首先对$θ$赋值,这个值可以是随机的,也可以让$θ$为一个全零向量。
2. 改变$θ$的值,使得$J(θ)$按梯度下降的方向进行调整。
梯度方向由$J(θ)$对$θ$的偏导数确定,由于求的是极小值,因此梯度方向是偏导数的反方向。更新公式为为: $$0_j = 0_j - α\frac{1}{m}\sum^m_{i=1}(h_θ(x^{(i)})-y^{(i)})x_j^{i}$$
这种方法需要对全部的训练数据求得误差后再对$θ$进行更新。($α$为学习速度)
$Xθ=y$
=>
$X^TXθ=X^Ty$
=>
$θ = (X^TX)^{-1}X^Ty$
利用以上公式可直接算出$θ$
看到这里,读者可能注意到了,正规方程法,不需要像梯度下降那样迭代多次,更关键的是从编程的角度更直接,那为什么不直接用正规,还要保留梯度下降呢?想必学过线性代数的朋友一眼能看出来,正规方程需要求$(X^TX)$的逆,这就要求$(X^TX)$是可逆的。同时,如果Feature数比较多,比如共有100个Feature,那么$(X^TX)$的维度会非常高,求其逆会非常耗时。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/2015/03/15/count_distinct/
在互联网中,经常需要计算UV和PV。所谓PV即Page View,网页被打开多少次(YouTube等视频网站非常重视视频的点击率,即被播放多少次,也即PV)。而UV即Unique Visitor(微信朋友圈或者微信公众号中的文章则统计有多少人看过该文章,也即UV。虽然微信上显示是指明该值是PV,但经笔者测试,实为UV)。这两个概念非常重要,比如淘宝卖家在做活动时,他往往需要统计宝贝被看了多少次,有多少个不同的人看过该活动介绍。至于如何在互联网上唯一标识一个自然人,也是一个难点,目前还没有一个非常准确的方法,常用的方法是用户名加cookie,这里不作深究。
很多情景下,尤其对于文本类型的字段,直接使用count distinct的查询效率是非常低的,而先做group by更count往往能提升查询效率。但实验表明,对于不同的字段,count distinct与count group by的性能并不一样,而且其效率也与目标数据集的数据重复度相关。
本节通过几组实验说明了不同场景下不同query的不同效率,同时分析性能差异的原因。 (本文所有实验皆基于PostgreSQL 9.3.5平台)
分别使用count distinct 和 count group by对 bigint, macaddr, text三种类型的字段做查询。
首先创建如下结构的表
Column | Type | Modifiers |
---|---|---|
mac_bigint | bigint | |
mac_macaddr | macaddr | |
mac_text | text |
并插入1000万条记录,并保证mac_bigint为mac_macaddr去掉冒号后的16进制转换而成的10进制bigint,而mac_text为mac_macaddr的文本形式,从而保证在这三个字段上查询的结果,并且复杂度相同。
count distinct SQL如下1
2
3
4select
count(distinct mac_macaddr)
from
testmac
count group by SQL如下1
2
3
4
5
6
7
8
9select
count(*)
from
(select
mac_macaddr
from
testmac
group by
1) foo
对于不同记录数较大的情景(1000万条记录中,有300多万条不同记录),查询时间(单位毫秒)如下表所示。
query/字段类型 | macaddr | bigint | text |
---|---|---|---|
count distinct | 24668.023 | 13890.051 | 149048.911 |
count group by | 32152.808 | 25929.555 | 159212.700 |
对于不同记录数较小的情景(1000万条记录中,只有1万条不同记录),查询时间(单位毫秒)如下表所示。
query/字段类型 | macaddr | bigint | text |
---|---|---|---|
count distinct | 20006.681 | 9984.763 | 225208.133 |
count group by | 2529.420 | 2554.720 | 3701.869 |
从上面两组实验可看出,在不同记录数较小时,count group by性能普遍高于count distinct,尤其对于text类型表现的更明显。而对于不同记录数较大的场景,count group by性能反而低于直接count distinct。为什么会造成这种差异呢,我们以macaddr类型为例来对比不同结果集下count group by的query plan。
当结果集较小时,planner会使用HashAggregation。1
2
3
4
5
6explain analyze select count(*) from (select mac_macaddr from testmac_small group by 1) foo;
QUERY PLAN
Aggregate (cost=668465.04..668465.05 rows=1 width=0) (actual time=9166.486..9166.486 rows=1 loops=1)
-> HashAggregate (cost=668296.74..668371.54 rows=7480 width=6) (actual time=9161.796..9164.393 rows=10001 loops=1)
-> Seq Scan on testmac_small (cost=0.00..572898.79 rows=38159179 width=6) (actual time=323.338..5091.112 rows=10000000 l
oops=1)
而当结果集较大时,无法通过在内存中维护Hash表的方式使用HashAggregation,planner会使用GroupAggregation,并会用到排序,而且因为目标数据集太大,无法在内存中使用Quick Sort,而要在外存中使用Merge Sort,而这就极大的增加了I/O开销。1
2
3
4
5
6
7
8
9explain analyze select count(*) from (select mac_macaddr from testmac group by 1) foo;
QUERY PLAN
Aggregate (cost=1881542.62..1881542.63 rows=1 width=0) (actual time=34288.232..34288.232 rows=1 loops=1)
-> Group (cost=1794262.09..1844329.41 rows=2977057 width=6) (actual time=25291.372..33481.228 rows=3671797 loops=1)
-> Sort (cost=1794262.09..1819295.75 rows=10013464 width=6) (actual time=25291.366..29907.351 rows=10000000 loops=1)
Sort Key: testmac.mac_macaddr
Sort Method: external merge Disk: 156440kB
-> Seq Scan on testmac (cost=0.00..219206.64 rows=10013464 width=6) (actual time=0.082..4312.053 rows=10000000 loo
ps=1)
由于distinct count的需求非常普遍(如互联网中计算UV),而该计算的代价又相比较高,很难适应实时性要求较高的场景,如流计算,因此有很多相关研究试图解决该问题。比较著名的算法有daptive sampling Algorithm,Distinct Counting with a Self-Learning Bitmap,HyperLogLog,LogLog,Probabilistic Counting Algorithms。这些算法都不能精确计算distinct count,都是在保证误差较小的情况下高效计算出结果。本文分别就这几种算法做了两组实验。
algorithm | result | error | time(ms) | memory (B) |
---|---|---|---|---|
count(distinct) | 1000000 | 0% | 14026 | ? |
Adaptive Sampling | 1008128 | 0.8% | 8653 | 57627 |
Self-learning Bitmap | 991651 | 0.9% | 1151 | 65571 |
Bloom filter | 788052 | 22% | 2400 | 1198164 |
Probalilistic Counting | 1139925 | 14% | 3613 | 95 |
PCSA | 841735 | 16% | 842 | 495 |
algorithm | result | error | time(ms) | memory (B) |
---|---|---|---|---|
count(distinct) | 100 | 0% | 75306 | ? |
Adaptive Sampling | 100 | 0% | 1491 | 57627 |
Self-learning Bitmap | 101 | 1% | 1031 | 65571 |
Bloom filter | 100 | 0% | 1675 | 1198164 |
Probalilistic Counting | 95 | 5% | 3613 | 95 |
PCSA | 98 | 2% | 852 | 495 |
从上面两组实验可看出,大部分的近似算法工作得都很好,其速度都比简单的count distinct要快很多,而且它们对内存的使用并不多而结果去非常好,尤其是Adaptive Sampling和Self-learning Bitmap,误差一般不超过1%,性能却比简单的count distinct高十几倍乃至几十倍。
如上几种近似算法可以极大提高distinct count的效率,但对于data warehouse来说,数据量非常大,可能存储了几年的数据,为了提高查询速度,对于sum及avg这些aggregation一般会创建一些aggregation table。比如如果要算过去三年的总营业额,那可以创建一张daily/monthly aggregation table,基于daily/monthly表去计算三年的营业额。但对于distinct count,即使创建了daily/monthly aggregation table,也没办法通过其计算三年的数值。这里有种新的数据类型hll,这是一种HyperLogLog数据结构。一个1280字节的hll能计算几百亿的不同数值并且保证只有很小的误差。
首先创建一张表(fact),结构如下
Column | Type | Modifiers |
---|---|---|
day | date | |
user_id | integer | |
sales | numeric |
插入三年的数据,并保证总共有10万个不同的user_id,总数据量为1亿条(一天10万条左右)。1
2
3
4
5
6
7insert into fact
select
current_date - (random()*1095)::integer * '1 day'::interval,
(random()*100000)::integer + 1,
random() * 10000 + 500
from
generate_series(1, 100000000, 1);
直接从fact表中查询不同用户的总数,耗时115143.217 ms。
利用hll,创建daily_unique_user_hll表,将每天的不同用户信息存于hll类型的字段中。1
2
3
4
5
6
7create table daily_unique_user_hll
as select
day,
hll_add_agg(hll_hash_integer(user_id))
from
fact
group by 1;
通过上面的daily aggregation table可计算任意日期范围内的unique user count。如计算整个三年的不同用户数,耗时17.485 ms,查询结果为101044,误差为(101044-100000)/100000=1.044%。1
2
3
4
5
6
7
8explain analyze select hll_cardinality(hll_union_agg(hll_add_agg)) from daily_unique_user_hll;
QUERY PLAN
Aggregate (cost=196.70..196.72 rows=1 width=32) (actual time=16.772..16.772 rows=1 loops=1)
-> Seq Scan on daily_unique_user_hll (cost=0.00..193.96 rows=1096 width=32) (actual time=0.298..3.251 rows=
1096 loops=1)
Planning time: 0.081 ms
Execution time: 16.851 ms
Time: 17.485 ms
而如果直接使用count distinct基于fact表计算该值,则耗时长达 127807.105 ms。
从上面的实验中可以看到,hll类型实现了distinct count的合并,并可以通过hll存储各个部分数据集上的distinct count值,并可通过合并这些hll值来快速计算整个数据集上的distinct count值,耗时只有直接使用count distinct在原始数据上计算的1/7308,并且误差非常小,1%左右。
如果必须要计算精确的distinct count,可以针对不同的情况使用count distinct或者count group by来实现较好的效率,同时对于数据的存储类型,能使用macaddr/intger/bigint的,尽量不要使用text。
另外不必要精确计算,只需要保证误差在可接受的范围之内,或者计算效率更重要时,可以采用本文所介绍的daptive sampling Algorithm,Distinct Counting with a Self-Learning Bitmap,HyperLogLog,LogLog,Probabilistic Counting Algorithms等近似算法。另外,对于data warehouse这种存储数据量随着时间不断超增加且最终数据总量非常巨大的应用场景,可以使用hll这种支持合并dintinct count结果的数据类型,并周期性的(比如daily/weekly/monthly)计算部分数据的distinct值,然后通过合并部分结果的方式得到总结果的方式来快速响应查询请求。
原创文章,转载请务必将下面这段话置于文章开头处。(已授权InfoQ中文站发布)
本文转发自技术世界,原文链接 http://www.jasongj.com/2015/03/10/KafkaColumn1
Kafka是由LinkedIn开发并开源的分布式消息系统,因其分布式及高吞吐率而被广泛使用,现已与Cloudera Hadoop,Apache Storm,Apache Spark集成。本文介绍了Kafka的创建背景,设计目标,使用消息系统的优势以及目前流行的消息系统对比。并介绍了Kafka的架构,Producer消息路由,Consumer Group以及由其实现的不同消息分发方式,Topic & Partition,最后介绍了Kafka Consumer为何使用pull模式以及Kafka提供的三种delivery guarantee。
Kafka是一个消息系统,原本开发自LinkedIn,用作LinkedIn的活动流(Activity Stream)和运营数据处理管道(Pipeline)的基础。现在它已被多家不同类型的公司 作为多种类型的数据管道和消息系统使用。
活动流数据是几乎所有站点在对其网站使用情况做报表时都要用到的数据中最常规的部分。活动数据包括页面访问量(Page View)、被查看内容方面的信息以及搜索情况等内容。这种数据通常的处理方式是先把各种活动以日志的形式写入某种文件,然后周期性地对这些文件进行统计分析。运营数据指的是服务器的性能数据(CPU、IO使用率、请求时间、服务日志等等数据)。运营数据的统计方法种类繁多。
近年来,活动和运营数据处理已经成为了网站软件产品特性中一个至关重要的组成部分,这就需要一套稍微更加复杂的基础设施对其提供支持。
Kafka是一种分布式的,基于发布/订阅的消息系统。主要设计目标如下:
解耦
在项目启动之初来预测将来项目会碰到什么需求,是极其困难的。消息系统在处理过程中间插入了一个隐含的、基于数据的接口层,两边的处理过程都要实现这一接口。这允许你独立的扩展或修改两边的处理过程,只要确保它们遵守同样的接口约束。
冗余
有些情况下,处理数据的过程会失败。除非数据被持久化,否则将造成丢失。消息队列把数据进行持久化直到它们已经被完全处理,通过这一方式规避了数据丢失风险。许多消息队列所采用的”插入-获取-删除”范式中,在把一个消息从队列中删除之前,需要你的处理系统明确的指出该消息已经被处理完毕,从而确保你的数据被安全的保存直到你使用完毕。
扩展性
因为消息队列解耦了你的处理过程,所以增大消息入队和处理的频率是很容易的,只要另外增加处理过程即可。不需要改变代码、不需要调节参数。扩展就像调大电力按钮一样简单。
灵活性 & 峰值处理能力
在访问量剧增的情况下,应用仍然需要继续发挥作用,但是这样的突发流量并不常见;如果为以能处理这类峰值访问为标准来投入资源随时待命无疑是巨大的浪费。使用消息队列能够使关键组件顶住突发的访问压力,而不会因为突发的超负荷的请求而完全崩溃。
可恢复性
系统的一部分组件失效时,不会影响到整个系统。消息队列降低了进程间的耦合度,所以即使一个处理消息的进程挂掉,加入队列中的消息仍然可以在系统恢复后被处理。
顺序保证
在大多使用场景下,数据处理的顺序都很重要。大部分消息队列本来就是排序的,并且能保证数据会按照特定的顺序来处理。Kafka保证一个Partition内的消息的有序性。
缓冲
在任何重要的系统中,都会有需要不同的处理时间的元素。例如,加载一张图片比应用过滤器花费更少的时间。消息队列通过一个缓冲层来帮助任务最高效率的执行———写入队列的处理会尽可能的快速。该缓冲有助于控制和优化数据流经过系统的速度。
异步通信
很多时候,用户不想也不需要立即处理消息。消息队列提供了异步处理机制,允许用户把一个消息放入队列,但并不立即处理它。想向队列中放入多少消息就放多少,然后在需要的时候再去处理它们。
RabbitMQ
RabbitMQ是使用Erlang编写的一个开源的消息队列,本身支持很多的协议:AMQP,XMPP, SMTP, STOMP,也正因如此,它非常重量级,更适合于企业级的开发。同时实现了Broker构架,这意味着消息在发送给客户端时先在中心队列排队。对路由,负载均衡或者数据持久化都有很好的支持。
Redis
Redis是一个基于Key-Value对的NoSQL数据库,开发维护很活跃。虽然它是一个Key-Value数据库存储系统,但它本身支持MQ功能,所以完全可以当做一个轻量级的队列服务来使用。对于RabbitMQ和Redis的入队和出队操作,各执行100万次,每10万次记录一次执行时间。测试数据分为128Bytes、512Bytes、1K和10K四个不同大小的数据。实验表明:入队时,当数据比较小时Redis的性能要高于RabbitMQ,而如果数据大小超过了10K,Redis则慢的无法忍受;出队时,无论数据大小,Redis都表现出非常好的性能,而RabbitMQ的出队性能则远低于Redis。
ZeroMQ
ZeroMQ号称最快的消息队列系统,尤其针对大吞吐量的需求场景。ZMQ能够实现RabbitMQ不擅长的高级/复杂的队列,但是开发人员需要自己组合多种技术框架,技术上的复杂度是对这MQ能够应用成功的挑战。ZeroMQ具有一个独特的非中间件的模式,你不需要安装和运行一个消息服务器或中间件,因为你的应用程序将扮演这个服务器角色。你只需要简单的引用ZeroMQ程序库,可以使用NuGet安装,然后你就可以愉快的在应用程序之间发送消息了。但是ZeroMQ仅提供非持久性的队列,也就是说如果宕机,数据将会丢失。其中,Twitter的Storm 0.9.0以前的版本中默认使用ZeroMQ作为数据流的传输(Storm从0.9版本开始同时支持ZeroMQ和Netty作为传输模块)。
ActiveMQ
ActiveMQ是Apache下的一个子项目。 类似于ZeroMQ,它能够以代理人和点对点的技术实现队列。同时类似于RabbitMQ,它少量代码就可以高效地实现高级应用场景。
Kafka/Jafka
Kafka是Apache下的一个子项目,是一个高性能跨语言分布式发布/订阅消息队列系统,而Jafka是在Kafka之上孵化而来的,即Kafka的一个升级版。具有以下特性:快速持久化,可以在O(1)的系统开销下进行消息持久化;高吞吐,在一台普通的服务器上既可以达到10W/s的吞吐速率;完全的分布式系统,Broker、Producer、Consumer都原生自动支持分布式,自动实现负载均衡;支持Hadoop数据并行加载,对于像Hadoop的一样的日志数据和离线分析系统,但又要求实时处理的限制,这是一个可行的解决方案。Kafka通过Hadoop的并行加载机制统一了在线和离线的消息处理。Apache Kafka相对于ActiveMQ是一个非常轻量级的消息系统,除了性能非常好之外,还是一个工作良好的分布式系统。
如上图所示,一个典型的Kafka集群中包含若干Producer(可以是web前端产生的Page View,或者是服务器日志,系统CPU、Memory等),若干broker(Kafka支持水平扩展,一般broker数量越多,集群吞吐率越高),若干Consumer Group,以及一个Zookeeper集群。Kafka通过Zookeeper管理集群配置,选举leader,以及在Consumer Group发生变化时进行rebalance。Producer使用push模式将消息发布到broker,Consumer使用pull模式从broker订阅并消费消息。
Topic在逻辑上可以被认为是一个queue,每条消费都必须指定它的Topic,可以简单理解为必须指明把这条消息放进哪个queue里。为了使得Kafka的吞吐率可以线性提高,物理上把Topic分成一个或多个Partition,每个Partition在物理上对应一个文件夹,该文件夹下存储这个Partition的所有消息和索引文件。若创建topic1和topic2两个topic,且分别有13个和19个分区,则整个集群上会相应会生成共32个文件夹(本文所用集群共8个节点,此处topic1和topic2 replication-factor均为1),如下图所示。
每个日志文件都是一个log entry
序列,每个log entry
包含一个4字节整型数值(值为N+5),1个字节的”magic value”,4个字节的CRC校验码,其后跟N个字节的消息体。每条消息都有一个当前Partition下唯一的64字节的offset,它指明了这条消息的起始位置。磁盘上存储的消息格式如下:
message length : 4 bytes (value: 1+4+n)
“magic” value : 1 byte
crc : 4 bytes
payload : n bytes
这个log entry
并非由一个文件构成,而是分成多个segment,每个segment以该segment第一条消息的offset命名并以“.kafka”为后缀。另外会有一个索引文件,它标明了每个segment下包含的log entry
的offset范围,如下图所示。
因为每条消息都被append到该Partition中,属于顺序写磁盘,因此效率非常高(经验证,顺序写磁盘效率比随机写内存还要高,这是Kafka高吞吐率的一个很重要的保证)。
对于传统的message queue而言,一般会删除已经被消费的消息,而Kafka集群会保留所有的消息,无论其被消费与否。当然,因为磁盘限制,不可能永久保留所有数据(实际上也没必要),因此Kafka提供两种策略删除旧数据。一是基于时间,二是基于Partition文件大小。例如可以通过配置$KAFKA_HOME/config/server.properties
,让Kafka删除一周前的数据,也可在Partition文件超过1GB时删除旧数据,配置如下所示。1
2
3
4
5
6
7
8# The minimum age of a log file to be eligible for deletion
log.retention.hours=168
# The maximum size of a log segment file. When this size is reached a new log segment will be created.
log.segment.bytes=1073741824
# The interval at which log segments are checked to see if they can be deleted according to the retention policies
log.retention.check.interval.ms=300000
# If log.cleaner.enable=true is set the cleaner will be enabled and individual logs can then be marked for log compaction.
log.cleaner.enable=false
这里要注意,因为Kafka读取特定消息的时间复杂度为O(1),即与文件大小无关,所以这里删除过期文件与提高Kafka性能无关。选择怎样的删除策略只与磁盘以及具体的需求有关。另外,Kafka会为每一个Consumer Group保留一些metadata信息——当前消费的消息的position,也即offset。这个offset由Consumer控制。正常情况下Consumer会在消费完一条消息后递增该offset。当然,Consumer也可将offset设成一个较小的值,重新消费一些消息。因为offet由Consumer控制,所以Kafka broker是无状态的,它不需要标记哪些消息被哪些消费过,也不需要通过broker去保证同一个Consumer Group只有一个Consumer能消费某一条消息,因此也就不需要锁机制,这也为Kafka的高吞吐率提供了有力保障。
Producer发送消息到broker时,会根据Paritition机制选择将其存储到哪一个Partition。如果Partition机制设置合理,所有消息可以均匀分布到不同的Partition里,这样就实现了负载均衡。如果一个Topic对应一个文件,那这个文件所在的机器I/O将会成为这个Topic的性能瓶颈,而有了Partition后,不同的消息可以并行写入不同broker的不同Partition里,极大的提高了吞吐率。可以在$KAFKA_HOME/config/server.properties
中通过配置项num.partitions
来指定新建Topic的默认Partition数量,也可在创建Topic时通过参数指定,同时也可以在Topic创建之后通过Kafka提供的工具修改。
在发送一条消息时,可以指定这条消息的key,Producer根据这个key和Partition机制来判断应该将这条消息发送到哪个Parition。Paritition机制可以通过指定Producer的paritition. class
这一参数来指定,该class必须实现kafka.producer.Partitioner
接口。本例中如果key可以被解析为整数则将对应的整数与Partition总数取余,该消息会被发送到该数对应的Partition。(每个Parition都会有个序号,序号从0开始)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17import kafka.producer.Partitioner;
import kafka.utils.VerifiableProperties;
public class JasonPartitioner<T> implements Partitioner {
public JasonPartitioner(VerifiableProperties verifiableProperties) {}
public int partition(Object key, int numPartitions) {
try {
int partitionNum = Integer.parseInt((String) key);
return Math.abs(Integer.parseInt((String) key) % numPartitions);
} catch (Exception e) {
return Math.abs(key.hashCode() % numPartitions);
}
}
}
如果将上例中的类作为partition.class
,并通过如下代码发送20条消息(key分别为0,1,2,3)至topic3(包含4个Partition)。
1
2
3
4
5
6
7
8
9
10public void sendMessage() throws InterruptedException{
for(int i = 1; i <= 5; i++){
List messageList = new ArrayList<KeyedMessage<String, String>>();
for(int j = 0; j < 4; j++){
messageList.add(new KeyedMessage<String, String>("topic2", String.valueOf(j), String.format("The %d message for key %d", i, j));
}
producer.send(messageList);
}
producer.close();
}
则key相同的消息会被发送并存储到同一个partition里,而且key的序号正好和Partition序号相同。(Partition序号从0开始,本例中的key也从0开始)。下图所示是通过Java程序调用Consumer后打印出的消息列表。
(本节所有描述都是基于Consumer hight level API而非low level API)。
使用Consumer high level API时,同一Topic的一条消息只能被同一个Consumer Group内的一个Consumer消费,但多个Consumer Group可同时消费这一消息。
这是Kafka用来实现一个Topic消息的广播(发给所有的Consumer)和单播(发给某一个Consumer)的手段。一个Topic可以对应多个Consumer Group。如果需要实现广播,只要每个Consumer有一个独立的Group就可以了。要实现单播只要所有的Consumer在同一个Group里。用Consumer Group还可以将Consumer进行自由的分组而不需要多次发送消息到不同的Topic。
实际上,Kafka的设计理念之一就是同时提供离线处理和实时处理。根据这一特性,可以使用Storm这种实时流处理系统对消息进行实时在线处理,同时使用Hadoop这种批处理系统进行离线处理,还可以同时将数据实时备份到另一个数据中心,只需要保证这三个操作所使用的Consumer属于不同的Consumer Group即可。下图是Kafka在Linkedin的一种简化部署示意图。
下面这个例子更清晰地展示了Kafka Consumer Group的特性。首先创建一个Topic (名为topic1,包含3个Partition),然后创建一个属于group1的Consumer实例,并创建三个属于group2的Consumer实例,最后通过Producer向topic1发送key分别为1,2,3的消息。结果发现属于group1的Consumer收到了所有的这三条消息,同时group2中的3个Consumer分别收到了key为1,2,3的消息。如下图所示。
作为一个消息系统,Kafka遵循了传统的方式,选择由Producer向broker push消息并由Consumer从broker pull消息。一些logging-centric system,比如Facebook的Scribe和Cloudera的Flume,采用push模式。事实上,push模式和pull模式各有优劣。
push模式很难适应消费速率不同的消费者,因为消息发送速率是由broker决定的。push模式的目标是尽可能以最快速度传递消息,但是这样很容易造成Consumer来不及处理消息,典型的表现就是拒绝服务以及网络拥塞。而pull模式则可以根据Consumer的消费能力以适当的速率消费消息。
对于Kafka而言,pull模式更合适。pull模式可简化broker的设计,Consumer可自主控制消费消息的速率,同时Consumer可以自己控制消费方式——即可批量消费也可逐条消费,同时还能选择不同的提交方式从而实现不同的传输语义。
有这么几种可能的delivery guarantee:
At most once
消息可能会丢,但绝不会重复传输At least one
消息绝不会丢,但可能会重复传输Exactly once
每条消息肯定会被传输一次且仅传输一次,很多时候这是用户所想要的。Exactly once
。截止到目前(Kafka 0.8.2版本,2015-03-04),这一Feature还并未实现,有希望在Kafka未来的版本中实现。(所以目前默认情况下一条消息从Producer到broker是确保了At least once
,可通过设置Producer异步发送实现At most once
)。Exactly once
。但实际使用中应用程序并非在Consumer读取完数据就结束了,而是要进行进一步处理,而数据处理与commit的顺序在很大程度上决定了消息从broker和consumer的delivery guarantee semantic。At most once
At least once
。在很多使用场景下,消息都有一个主键,所以消息的处理往往具有幂等性,即多次处理这一条消息跟只处理一次是等效的,那就可以认为是Exactly once
。(笔者认为这种说法比较牵强,毕竟它不是Kafka本身提供的机制,主键本身也并不能完全保证操作的幂等性。而且实际上我们说delivery guarantee 语义是讨论被处理多少次,而非处理结果怎样,因为处理方式多种多样,我们不应该把处理过程的特性——如是否幂等性,当成Kafka本身的Feature)Exactly once
,就需要协调offset和实际操作的输出。经典的做法是引入两阶段提交。如果能让offset和操作输入存在同一个地方,会更简洁和通用。这种方式可能更好,因为许多输出系统可能不支持两阶段提交。比如,Consumer拿到数据后可能把数据放到HDFS,如果把最新的offset和数据本身一起写到HDFS,那就可以保证数据的输出和offset的更新要么都完成,要么都不完成,间接实现Exactly once
。(目前就high level API而言,offset是存于Zookeeper中的,无法存于HDFS,而low level API的offset是由自己去维护的,可以将之存于HDFS中)At least once
,并且允许通过设置Producer异步提交来实现At most once
。而Exactly once
要求与外部存储系统协作,幸运的是Kafka提供的offset可以非常直接非常容易得使用这种方式。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/2015/03/07/Join1/
Nested Loop:
对于被连接的数据子集较小的情况,Nested Loop是个较好的选择。Nested Loop就是扫描一个表(外表),每读到一条记录,就根据Join字段上的索引去另一张表(内表)里面查找,若Join字段上没有索引查询优化器一般就不会选择 Nested Loop。在Nested Loop中,内表(一般是带索引的大表)被外表(也叫“驱动表”,一般为小表——不紧相对其它表为小表,而且记录数的绝对值也较小,不要求有索引)驱动,外表返回的每一行都要在内表中检索找到与它匹配的行,因此整个查询返回的结果集不能太大(大于1 万不适合)。
Hash Join:
Hash Join是做大数据集连接时的常用方式,优化器使用两个表中较小(相对较小)的表利用Join Key在内存中建立散列表,然后扫描较大的表并探测散列表,找出与Hash表匹配的行。
这种方式适用于较小的表完全可以放于内存中的情况,这样总成本就是访问两个表的成本之和。但是在表很大的情况下并不能完全放入内存,这时优化器会将它分割成若干不同的分区,不能放入内存的部分就把该分区写入磁盘的临时段,此时要求有较大的临时段从而尽量提高I/O 的性能。它能够很好的工作于没有索引的大表和并行查询的环境中,并提供最好的性能。大多数人都说它是Join的重型升降机。Hash Join只能应用于等值连接(如WHERE A.COL3 = B.COL4),这是由Hash的特点决定的。
Merge Join:
通常情况下Hash Join的效果都比排序合并连接要好,然而如果两表已经被排过序,在执行排序合并连接时不需要再排序了,这时Merge Join的性能会优于Hash Join。Merge join的操作通常分三步:
1. 对连接的每个表做table access full;
2. 对table access full的结果进行排序。
3. 进行merge join对排序结果进行合并。
在全表扫描比索引范围扫描再进行表访问更可取的情况下,Merge Join会比Nested Loop性能更佳。当表特别小或特别巨大的时候,实行全表访问可能会比索引范围扫描更有效。Merge Join的性能开销几乎都在前两步。Merge Join可适于于非等值Join(>,<,>=,<=,但是不包含!=,也即<>)
类别 | Nested Loop | Hash Join | Merge Join |
---|---|---|---|
使用条件 | 任何条件 | 等值连接(=) | 等值或非等值连接(>,<,=,>=,<=),‘<>’除外 |
相关资源 | CPU、磁盘I/O | 内存、临时空间 | 内存、临时空间 |
特点 | 当有高选择性索引或进行限制性搜索时效率比较高,能够快速返回第一次的搜索结果。 | 当缺乏索引或者索引条件模糊时,Hash Join比Nested Loop有效。通常比Merge Join快。在数据仓库环境下,如果表的纪录数多,效率高。 | 当缺乏索引或者索引条件模糊时,Merge Join比Nested Loop有效。非等值连接时,Merge Join比Hash Join更有效 |
缺点 | 当索引丢失或者查询条件限制不够时,效率很低;当表的纪录数多时,效率低。 | 为建立哈希表,需要大量内存。第一次的结果返回较慢。 | 所有的表都需要排序。它为最优化的吞吐量而设计,并且在结果没有全部找到前不返回数据。 |
本文所做实验均基于PostgreSQL 9.3.5平台
一张记录数1万以下的小表nbar.mse_test_test,一张大表165万条记录的大表nbar.nbar_test,大表上建有索引
1 | select |
如下图所示,执行器将小表mse_test_test作为外表(驱动表),对于其中的每条记录,通过大表(nbar_test)上的索引匹配相应记录。
如下图所示,执行器选择一张表将其映射成散列表,再遍历另外一张表并从散列表中匹配相应记录。
如下图所示,执行器先分别对mse_test_test和nbar_test按client_key排序。其中mse_test_test使用快速排序,而nbar_test使用external merge排序,之后对二者进行Merge Join。
通过对比Query 1 Test 1
,Query 1 Test 2
,Query 1 Test 3
可以看出Nested Loop适用于结果集很小(一般要求小于一万条),并且内表在Join字段上建有索引(这点非常非常非常重要)。
如下图所示,执行器通过聚簇索引对大表(nbar_test)排序,直接通过快排对无索引的小表(mse_test_test)排序,之后对二才进行Merge Join。
通过对比Query 1 Test 3
和Query 1 Test 4
可以看出,Merge Join的主要开销是排序开销,如果能通过建立聚簇索引(如果Query必须显示排序),可以极大提高Merge Join的性能。从这两个实验可以看出,创建聚簇索引后,查询时间从4956.768 ms缩减到了1815.238 ms。
如下图所示,执行器通过聚簇索引对大表(nbar_test)和小表(mse_test_test)排序,之后才进行Merge Join。
对比Query 1 Test 4
和Query 1 Test 5
,可以看出二者唯一的不同在于对小表(mse_test_test)的访问方式不同,前者使用快排,后者因为聚簇索引的存在而使用Index Only Scan,在表数据量比较小的情况下前者比后者效率更高。由此可看出如果通过索引排序再查找相应的记录比直接在原记录上排序效率还低,则直接在原记录上排序后Merge Join效率更高。
删除nbar_test上的索引
时间与Query 1 Test 2
几乎相等。
如下图所示,与Query 1 Test 2
相同,执行器选择一张表将其映射成散列表,再遍历另外一张表并从散列表中匹配相应记录。
通过对比Query 1 Test 2
,Query 1 Test 6
可以看出Hash Join不要求表在Join字段上建立索引。
mse_test约100万条记录,nbar_test约165万条记录
###Query 2:不等值Join1
2
3
4
5
6
7
8
9 select
count(*)
from
mse_test,
nbar_test
where
mse_test.client_key = nbar_test.client_key
and
mse_test.client_key between 100000 and 300000;
本次实验通过设置enable_hashjoin=true
,enable_nestloop=false
,enable_mergejoin=false
来试图强制使用Hash Join,但是失败了。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自技术世界,原文链接 http://www.jasongj.com/2015/01/02/Kafka深度解析
Kafka是一种分布式的,基于发布/订阅的消息系统。主要设计目标如下:
解耦
在项目启动之初来预测将来项目会碰到什么需求,是极其困难的。消息队列在处理过程中间插入了一个隐含的、基于数据的接口层,两边的处理过程都要实现这一接口。这允许你独立的扩展或修改两边的处理过程,只要确保它们遵守同样的接口约束
冗余
有些情况下,处理数据的过程会失败。除非数据被持久化,否则将造成丢失。消息队列把数据进行持久化直到它们已经被完全处理,通过这一方式规避了数据丢失风险。在被许多消息队列所采用的”插入-获取-删除”范式中,在把一个消息从队列中删除之前,需要你的处理过程明确的指出该消息已经被处理完毕,确保你的数据被安全的保存直到你使用完毕。
扩展性
因为消息队列解耦了你的处理过程,所以增大消息入队和处理的频率是很容易的;只要另外增加处理过程即可。不需要改变代码、不需要调节参数。扩展就像调大电力按钮一样简单。
灵活性 & 峰值处理能力
在访问量剧增的情况下,应用仍然需要继续发挥作用,但是这样的突发流量并不常见;如果为以能处理这类峰值访问为标准来投入资源随时待命无疑是巨大的浪费。使用消息队列能够使关键组件顶住突发的访问压力,而不会因为突发的超负荷的请求而完全崩溃。
可恢复性
当体系的一部分组件失效,不会影响到整个系统。消息队列降低了进程间的耦合度,所以即使一个处理消息的进程挂掉,加入队列中的消息仍然可以在系统恢复后被处理。而这种允许重试或者延后处理请求的能力通常是造就一个略感不便的用户和一个沮丧透顶的用户之间的区别。
送达保证
消息队列提供的冗余机制保证了消息能被实际的处理,只要一个进程读取了该队列即可。在此基础上,部分消息系统提供了一个”只送达一次”保证。无论有多少进程在从队列中领取数据,每一个消息只能被处理一次。这之所以成为可能,是因为获取一个消息只是”预定”了这个消息,暂时把它移出了队列。除非客户端明确的表示已经处理完了这个消息,否则这个消息会被放回队列中去,在一段可配置的时间之后可再次被处理。
顺序保证
在大多使用场景下,数据处理的顺序都很重要。消息队列本来就是排序的,并且能保证数据会按照特定的顺序来处理。部分消息系统保证消息通过FIFO(先进先出)的顺序来处理,因此消息在队列中的位置就是从队列中检索他们的位置。
缓冲
在任何重要的系统中,都会有需要不同的处理时间的元素。例如,加载一张图片比应用过滤器花费更少的时间。消息队列通过一个缓冲层来帮助任务最高效率的执行–写入队列的处理会尽可能的快速,而不受从队列读的预备处理的约束。该缓冲有助于控制和优化数据流经过系统的速度。
理解数据流
在一个分布式系统里,要得到一个关于用户操作会用多长时间及其原因的总体印象,是个巨大的挑战。消息队列通过消息被处理的频率,来方便的辅助确定那些表现不佳的处理过程或领域,这些地方的数据流都不够优化。
异步通信
很多时候,你不想也不需要立即处理消息。消息队列提供了异步处理机制,允许你把一个消息放入队列,但并不立即处理它。你想向队列中放入多少消息就放多少,然后在你乐意的时候再去处理它们。
RabbitMQ
RabbitMQ是使用Erlang编写的一个开源的消息队列,本身支持很多的协议:AMQP,XMPP, SMTP, STOMP,也正因如此,它非常重量级,更适合于企业级的开发。同时实现了Broker构架,这意味着消息在发送给客户端时先在中心队列排队。对路由,负载均衡或者数据持久化都有很好的支持。
Redis
Redis是一个基于Key-Value对的NoSQL数据库,开发维护很活跃。虽然它是一个Key-Value数据库存储系统,但它本身支持MQ功能,所以完全可以当做一个轻量级的队列服务来使用。对于RabbitMQ和Redis的入队和出队操作,各执行100万次,每10万次记录一次执行时间。测试数据分为128Bytes、512Bytes、1K和10K四个不同大小的数据。实验表明:入队时,当数据比较小时Redis的性能要高于RabbitMQ,而如果数据大小超过了10K,Redis则慢的无法忍受;出队时,无论数据大小,Redis都表现出非常好的性能,而RabbitMQ的出队性能则远低于Redis。
ZeroMQ
ZeroMQ号称最快的消息队列系统,尤其针对大吞吐量的需求场景。ZMQ能够实现RabbitMQ不擅长的高级/复杂的队列,但是开发人员需要自己组合多种技术框架,技术上的复杂度是对这MQ能够应用成功的挑战。ZeroMQ具有一个独特的非中间件的模式,你不需要安装和运行一个消息服务器或中间件,因为你的应用程序将扮演了这个服务角色。你只需要简单的引用ZeroMQ程序库,可以使用NuGet安装,然后你就可以愉快的在应用程序之间发送消息了。但是ZeroMQ仅提供非持久性的队列,也就是说如果宕机,数据将会丢失。其中,Twitter的Storm 0.9.0以前的版本中默认使用ZeroMQ作为数据流的传输(Storm从0.9版本开始同时支持ZeroMQ和Netty作为传输模块)。
ActiveMQ
ActiveMQ是Apache下的一个子项目。 类似于ZeroMQ,它能够以代理人和点对点的技术实现队列。同时类似于RabbitMQ,它少量代码就可以高效地实现高级应用场景。
Kafka/Jafka
Kafka是Apache下的一个子项目,是一个高性能跨语言分布式发布/订阅消息队列系统,而Jafka是在Kafka之上孵化而来的,即Kafka的一个升级版。具有以下特性:快速持久化,可以在O(1)的系统开销下进行消息持久化;高吞吐,在一台普通的服务器上既可以达到10W/s的吞吐速率;完全的分布式系统,Broker、Producer、Consumer都原生自动支持分布式,自动实现负载均衡;支持Hadoop数据并行加载,对于像Hadoop的一样的日志数据和离线分析系统,但又要求实时处理的限制,这是一个可行的解决方案。Kafka通过Hadoop的并行加载机制来统一了在线和离线的消息处理。Apache Kafka相对于ActiveMQ是一个非常轻量级的消息系统,除了性能非常好之外,还是一个工作良好的分布式系统。
如上图所示,一个典型的kafka集群中包含若干producer(可以是web前端产生的page view,或者是服务器日志,系统CPU、memory等),若干broker(Kafka支持水平扩展,一般broker数量越多,集群吞吐率越高),若干consumer group,以及一个Zookeeper集群。Kafka通过Zookeeper管理集群配置,选举leader,以及在consumer group发生变化时进行rebalance。producer使用push模式将消息发布到broker,consumer使用pull模式从broker订阅并消费消息。
作为一个messaging system,Kafka遵循了传统的方式,选择由producer向broker push消息并由consumer从broker pull消息。一些logging-centric system,比如Facebook的Scribe和Cloudera的Flume,采用非常不同的push模式。事实上,push模式和pull模式各有优劣。
push模式很难适应消费速率不同的消费者,因为消息发送速率是由broker决定的。push模式的目标是尽可能以最快速度传递消息,但是这样很容易造成consumer来不及处理消息,典型的表现就是拒绝服务以及网络拥塞。而pull模式则可以根据consumer的消费能力以适当的速率消费消息。
Topic在逻辑上可以被认为是一个queue。每条消费都必须指定它的topic,可以简单理解为必须指明把这条消息放进哪个queue里。为了使得Kafka的吞吐率可以水平扩展,物理上把topic分成一个或多个partition,每个partition在物理上对应一个文件夹,该文件夹下存储这个partition的所有消息和索引文件。
每个日志文件都是“log entries”序列,每一个log entry
包含一个4字节整型数(值为N),其后跟N个字节的消息体。每条消息都有一个当前partition下唯一的64字节的offset,它指明了这条消息的起始位置。磁盘上存储的消息格式如下:
message length : 4 bytes (value: 1+4+n)
“magic” value : 1 byte
crc : 4 bytes
payload : n bytes
这个“log entries”并非由一个文件构成,而是分成多个segment,每个segment名为该segment第一条消息的offset和“.kafka”组成。另外会有一个索引文件,它标明了每个segment下包含的log entry
的offset范围,如下图所示。
因为每条消息都被append到该partition中,是顺序写磁盘,因此效率非常高(经验证,顺序写磁盘效率比随机写内存还要高,这是Kafka高吞吐率的一个很重要的保证)。
每一条消息被发送到broker时,会根据paritition规则选择被存储到哪一个partition。如果partition规则设置的合理,所有消息可以均匀分布到不同的partition里,这样就实现了水平扩展。(如果一个topic对应一个文件,那这个文件所在的机器I/O将会成为这个topic的性能瓶颈,而partition解决了这个问题)。在创建topic时可以在$KAFKA_HOME/config/server.properties
中指定这个partition的数量(如下所示),当然也可以在topic创建之后去修改parition数量。1
2
3
4# The default number of log partitions per topic. More partitions allow greater
# parallelism for consumption, but this will also result in more files across
# the brokers.
num.partitions=3
在发送一条消息时,可以指定这条消息的key,producer根据这个key和partition机制来判断将这条消息发送到哪个parition。paritition机制可以通过指定producer的paritition. class这一参数来指定,该class必须实现kafka.producer.Partitioner
接口。本例中如果key可以被解析为整数则将对应的整数与partition总数取余,该消息会被发送到该数对应的partition。(每个parition都会有个序号)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17import kafka.producer.Partitioner;
import kafka.utils.VerifiableProperties;
public class JasonPartitioner<T> implements Partitioner {
public JasonPartitioner(VerifiableProperties verifiableProperties) {}
public int partition(Object key, int numPartitions) {
try {
int partitionNum = Integer.parseInt((String) key);
return Math.abs(Integer.parseInt((String) key) % numPartitions);
} catch (Exception e) {
return Math.abs(key.hashCode() % numPartitions);
}
}
}
如果将上例中的class作为partitioner.class,并通过如下代码发送20条消息(key分别为0,1,2,3)至topic2(包含4个partition)。
1
2
3
4
5
6
7
8
9
10public void sendMessage() throws InterruptedException{
for(int i = 1; i <= 5; i++){
List messageList = new ArrayList<KeyedMessage<String, String>>();
for(int j = 0; j < 4; j++){
messageList.add(new KeyedMessage<String, String>("topic2", j+"", "The " + i + " message for key " + j));
}
producer.send(messageList);
}
producer.close();
}
则key相同的消息会被发送并存储到同一个partition里,而且key的序号正好和partition序号相同。(partition序号从0开始,本例中的key也正好从0开始)。如下图所示。
对于传统的message queue而言,一般会删除已经被消费的消息,而Kafka集群会保留所有的消息,无论其被消费与否。当然,因为磁盘限制,不可能永久保留所有数据(实际上也没必要),因此Kafka提供两种策略去删除旧数据。一是基于时间,二是基于partition文件大小。例如可以通过配置$KAFKA_HOME/config/server.properties
,让Kafka删除一周前的数据,也可通过配置让Kafka在partition文件超过1GB时删除旧数据,如下所示。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 ############################# Log Retention Policy #############################
# The following configurations control the disposal of log segments. The policy can
# be set to delete segments after a period of time, or after a given size has accumulated.
# A segment will be deleted whenever *either* of these criteria are met. Deletion always happens
# from the end of the log.
# The minimum age of a log file to be eligible for deletion
log.retention.hours=168
# A size-based retention policy for logs. Segments are pruned from the log as long as the remaining
# segments don't drop below log.retention.bytes.
#log.retention.bytes=1073741824
# The maximum size of a log segment file. When this size is reached a new log segment will be created.
log.segment.bytes=1073741824
# The interval at which log segments are checked to see if they can be deleted according
# to the retention policies
log.retention.check.interval.ms=300000
# By default the log cleaner is disabled and the log retention policy will default to
#just delete segments after their retention expires.
# If log.cleaner.enable=true is set the cleaner will be enabled and individual logs
#can then be marked for log compaction.
log.cleaner.enable=false
这里要注意,因为Kafka读取特定消息的时间复杂度为O(1),即与文件大小无关,所以这里删除文件与Kafka性能无关,选择怎样的删除策略只与磁盘以及具体的需求有关。另外,Kafka会为每一个consumer group保留一些metadata信息–当前消费的消息的position,也即offset。这个offset由consumer控制。正常情况下consumer会在消费完一条消息后线性增加这个offset。当然,consumer也可将offset设成一个较小的值,重新消费一些消息。因为offet由consumer控制,所以Kafka broker是无状态的,它不需要标记哪些消息被哪些consumer过,不需要通过broker去保证同一个consumer group只有一个consumer能消费某一条消息,因此也就不需要锁机制,这也为Kafka的高吞吐率提供了有力保障。
Kafka从0.8开始提供partition级别的replication,replication的数量可在$KAFKA_HOME/config/server.properties
中配置。1
default.replication.factor = 1
该 Replication与leader election配合提供了自动的failover机制。replication对Kafka的吞吐率是有一定影响的,但极大的增强了可用性。默认情况下,Kafka的replication数量为1。 每个partition都有一个唯一的leader,所有的读写操作都在leader上完成,leader批量从leader上pull数据。一般情况下partition的数量大于等于broker的数量,并且所有partition的leader均匀分布在broker上。follower上的日志和其leader上的完全一样。
和大部分分布式系统一样,Kakfa处理失败需要明确定义一个broker是否alive。对于Kafka而言,Kafka存活包含两个条件,一是它必须维护与Zookeeper的session(这个通过Zookeeper的heartbeat机制来实现)。二是follower必须能够及时将leader的writing复制过来,不能“落后太多”。
leader会track“in sync”的node list。如果一个follower宕机,或者落后太多,leader将把它从”in sync” list中移除。这里所描述的“落后太多”指follower复制的消息落后于leader后的条数超过预定值,该值可在$KAFKA_HOME/config/server.properties
中配置1
2
3
4
5#If a replica falls more than this many messages behind the leader, the leader will remove the follower from ISR and treat it as dead
replica.lag.max.messages=4000
#If a follower hasn't sent any fetch requests for this window of time, the leader will remove the follower from ISR (in-sync replicas) and treat it as dead
replica.lag.time.max.ms=10000
需要说明的是,Kafka只解决”fail/recover”,不处理“Byzantine”(“拜占庭”)问题。
一条消息只有被“in sync” list里的所有follower都从leader复制过去才会被认为已提交。这样就避免了部分数据被写进了leader,还没来得及被任何follower复制就宕机了,而造成数据丢失(consumer无法消费这些数据)。而对于producer而言,它可以选择是否等待消息commit,这可以通过request.required.acks
来设置。这种机制确保了只要“in sync” list有一个或以上的flollower,一条被commit的消息就不会丢失。
这里的复制机制即不是同步复制,也不是单纯的异步复制。事实上,同步复制要求“活着的”follower都复制完,这条消息才会被认为commit,这种复制方式极大的影响了吞吐率(高吞吐率是Kafka非常重要的一个特性)。而异步复制方式下,follower异步的从leader复制数据,数据只要被leader写入log就被认为已经commit,这种情况下如果follwer都落后于leader,而leader突然宕机,则会丢失数据。而Kafka的这种使用“in sync” list的方式则很好的均衡了确保数据不丢失以及吞吐率。follower可以批量的从leader复制数据,这样极大的提高复制性能(批量写磁盘),极大减少了follower与leader的差距(前文有说到,只要follower落后leader不太远,则被认为在“in sync” list里)。
上文说明了Kafka是如何做replication的,另外一个很重要的问题是当leader宕机了,怎样在follower中选举出新的leader。因为follower可能落后许多或者crash了,所以必须确保选择“最新”的follower作为新的leader。一个基本的原则就是,如果leader不在了,新的leader必须拥有原来的leader commit的所有消息。这就需要作一个折衷,如果leader在标明一条消息被commit前等待更多的follower确认,那在它die之后就有更多的follower可以作为新的leader,但这也会造成吞吐率的下降。
一种非常常用的选举leader的方式是“majority vote”(“少数服从多数”),但Kafka并未采用这种方式。这种模式下,如果我们有2f+1个replica(包含leader和follower),那在commit之前必须保证有f+1个replica复制完消息,为了保证正确选出新的leader,fail的replica不能超过f个。因为在剩下的任意f+1个replica里,至少有一个replica包含有最新的所有消息。这种方式有个很大的优势,系统的latency只取决于最快的几台server,也就是说,如果replication factor是3,那latency就取决于最快的那个follower而非最慢那个。majority vote也有一些劣势,为了保证leader election的正常进行,它所能容忍的fail的follower个数比较少。如果要容忍1个follower挂掉,必须要有3个以上的replica,如果要容忍2个follower挂掉,必须要有5个以上的replica。也就是说,在生产环境下为了保证较高的容错程度,必须要有大量的replica,而大量的replica又会在大数据量下导致性能的急剧下降。这就是这种算法更多用在Zookeeper这种共享集群配置的系统中而很少在需要存储大量数据的系统中使用的原因。例如HDFS的HA feature是基于majority-vote-based journal,但是它的数据存储并没有使用这种expensive的方式。
实际上,leader election算法非常多,比如Zookeper的Zab, Raft和Viewstamped Replication。而Kafka所使用的leader election算法更像微软的PacificA算法。
Kafka在Zookeeper中动态维护了一个ISR(in-sync replicas) set,这个set里的所有replica都跟上了leader,只有ISR里的成员才有被选为leader的可能。在这种模式下,对于f+1个replica,一个Kafka topic能在保证不丢失已经ommit的消息的前提下容忍f个replica的失败。在大多数使用场景中,这种模式是非常有利的。事实上,为了容忍f个replica的失败,majority vote和ISR在commit前需要等待的replica数量是一样的,但是ISR需要的总的replica的个数几乎是majority vote的一半。
虽然majority vote与ISR相比有不需等待最慢的server这一优势,但是Kafka作者认为Kafka可以通过producer选择是否被commit阻塞来改善这一问题,并且节省下来的replica和磁盘使得ISR模式仍然值得。
上文提到,在ISR中至少有一个follower时,Kafka可以确保已经commit的数据不丢失,但如果某一个partition的所有replica都挂了,就无法保证数据不丢失了。这种情况下有两种可行的方案:
这就需要在可用性和一致性当中作出一个简单的平衡。如果一定要等待ISR中的replica“活”过来,那不可用的时间就可能会相对较长。而且如果ISR中的所有replica都无法“活”过来了,或者数据都丢失了,这个partition将永远不可用。选择第一个“活”过来的replica作为leader,而这个replica不是ISR中的replica,那即使它并不保证已经包含了所有已commit的消息,它也会成为leader而作为consumer的数据源(前文有说明,所有读写都由leader完成)。Kafka0.8.*使用了第二种方式。根据Kafka的文档,在以后的版本中,Kafka支持用户通过配置选择这两种方式中的一种,从而根据不同的使用场景选择高可用性还是强一致性。
上文说明了一个parition的replication过程,然尔Kafka集群需要管理成百上千个partition,Kafka通过round-robin的方式来平衡partition从而避免大量partition集中在了少数几个节点上。同时Kafka也需要平衡leader的分布,尽可能的让所有partition的leader均匀分布在不同broker上。另一方面,优化leadership election的过程也是很重要的,毕竟这段时间相应的partition处于不可用状态。一种简单的实现是暂停宕机的broker上的所有partition,并为之选举leader。实际上,Kafka选举一个broker作为controller,这个controller通过watch Zookeeper检测所有的broker failure,并负责为所有受影响的parition选举leader,再将相应的leader调整命令发送至受影响的broker,过程如下图所示。
这样做的好处是,可以批量的通知leadership的变化,从而使得选举过程成本更低,尤其对大量的partition而言。如果controller失败了,幸存的所有broker都会尝试在Zookeeper中创建/controller->{this broker id},如果创建成功(只可能有一个创建成功),则该broker会成为controller,若创建不成功,则该broker会等待新controller的命令。
(本节所有描述都是基于consumer hight level API而非low level API)。
每一个consumer实例都属于一个consumer group,每一条消息只会被同一个consumer group里的一个consumer实例消费。(不同consumer group可以同时消费同一条消息)
很多传统的message queue都会在消息被消费完后将消息删除,一方面避免重复消费,另一方面可以保证queue的长度比较少,提高效率。而如上文所将,Kafka并不删除已消费的消息,为了实现传统message queue消息只被消费一次的语义,Kafka保证保证同一个consumer group里只有一个consumer会消费一条消息。与传统message queue不同的是,Kafka还允许不同consumer group同时消费同一条消息,这一特性可以为消息的多元化处理提供了支持。实际上,Kafka的设计理念之一就是同时提供离线处理和实时处理。根据这一特性,可以使用Storm这种实时流处理系统对消息进行实时在线处理,同时使用Hadoop这种批处理系统进行离线处理,还可以同时将数据实时备份到另一个数据中心,只需要保证这三个操作所使用的consumer在不同的consumer group即可。下图展示了Kafka在Linkedin的一种简化部署。
为了更清晰展示Kafka consumer group的特性,笔者作了一项测试。创建一个topic (名为topic1),创建一个属于group1的consumer实例,并创建三个属于group2的consumer实例,然后通过producer向topic1发送key分别为1,2,3r的消息。结果发现属于group1的consumer收到了所有的这三条消息,同时group2中的3个consumer分别收到了key为1,2,3的消息。如下图所示。
(本节所讲述内容均基于Kafka consumer high level API)
Kafka保证同一consumer group中只有一个consumer会消费某条消息,实际上,Kafka保证的是稳定状态下每一个consumer实例只会消费某一个或多个特定partition的数据,而某个partition的数据只会被某一个特定的consumer实例所消费。这样设计的劣势是无法让同一个consumer group里的consumer均匀消费数据,优势是每个consumer不用都跟大量的broker通信,减少通信开销,同时也降低了分配难度,实现也更简单。另外,因为同一个partition里的数据是有序的,这种设计可以保证每个partition里的数据也是有序被消费。
如果某consumer group中consumer数量少于partition数量,则至少有一个consumer会消费多个partition的数据,如果consumer的数量与partition数量相同,则正好一个consumer消费一个partition的数据,而如果consumer的数量多于partition的数量时,会有部分consumer无法消费该topic下任何一条消息。
如下例所示,如果topic1有0,1,2共三个partition,当group1只有一个consumer(名为consumer1)时,该 consumer可消费这3个partition的所有数据。
增加一个consumer(consumer2)后,其中一个consumer(consumer1)可消费2个partition的数据,另外一个consumer(consumer2)可消费另外一个partition的数据。
再增加一个consumer(consumer3)后,每个consumer可消费一个partition的数据。consumer1消费partition0,consumer2消费partition1,consumer3消费partition2
再增加一个consumer(consumer4)后,其中3个consumer可分别消费一个partition的数据,另外一个consumer(consumer4)不能消费topic1任何数据。
此时关闭consumer1,剩下的consumer可分别消费一个partition的数据。
接着关闭consumer2,剩下的consumer3可消费2个partition,consumer4可消费1个partition。
再关闭consumer3,剩下的consumer4可同时消费topic1的3个partition。
consumer rebalance算法如下:
目前consumer rebalance的控制策略是由每一个consumer通过Zookeeper完成的。具体的控制方式如下:
目前(2015-01-19)最新版(0.8.2)Kafka采用的是上述方式。但该方式有不利的方面:
根据Kafka官方文档,Kafka作者正在考虑在还未发布的0.9.x版本中使用中心协调器(coordinator)。大体思想是选举出一个broker作为coordinator,由它watch Zookeeper,从而判断是否有partition或者consumer的增减,然后生成rebalance命令,并检查是否这些rebalance在所有相关的consumer中被执行成功,如果不成功则重试,若成功则认为此次rebalance成功(这个过程跟replication controller非常类似,所以我很奇怪为什么当初设计replication controller时没有使用类似方式来解决consumer rebalance的问题)。流程如下:
通过上文介绍,想必读者已经明天了producer和consumer是如何工作的,以及Kafka是如何做replication的,接下来要讨论的是Kafka如何确保消息在producer和consumer之间传输。有这么几种可能的delivery guarantee:
At most once
消息可能会丢,但绝不会重复传输At least one
消息绝不会丢,但可能会重复传输Exactly once
每条消息肯定会被传输一次且仅传输一次,很多时候这是用户所想要的。Exactly one
。截止到目前(Kafka 0.8.2版本,2015-01-25),这一feature还并未实现,有希望在Kafka未来的版本中实现。(所以目前默认情况下一条消息从producer和broker是确保了At least once
,但可通过设置producer异步发送实现At most once
)。Exactly once
。但实际上实际使用中consumer并非读取完数据就结束了,而是要进行进一步处理,而数据处理与commit的顺序在很大程度上决定了消息从broker和consumer的delivery guarantee semantic。At most once
At least once
。在很多情况使用场景下,消息都有一个primary key,所以消息的处理往往具有幂等性,即多次处理这一条消息跟只处理一次是等效的,那就可以认为是Exactly once
。(人个感觉这种说法有些牵强,毕竟它不是Kafka本身提供的机制,而且primary key本身不保证操作的幂等性。而且实际上我们说delivery guarantee semantic是讨论被处理多少次,而非处理结果怎样,因为处理方式多种多样,我们的系统不应该把处理过程的特性–如是否幂等性,当成Kafka本身的feature)Exactly once
,就需要协调offset和实际操作的输出。精典的做法是引入两阶段提交。如果能让offset和操作输入存在同一个地方,会更简洁和通用。这种方式可能更好,因为许多输出系统可能不支持两阶段提交。比如,consumer拿到数据后可能把数据放到HDFS,如果把最新的offset和数据本身一起写到HDFS,那就可以保证数据的输出和offset的更新要么都完成,要么都不完成,间接实现Exactly once
。(目前就high level API而言,offset是存于Zookeeper中的,无法存于HDFS,而low level API的offset是由自己去维护的,可以将之存于HDFS中)At least once
,并且允许通过设置producer异步提交来实现At most once
。而Exactly once
要求与目标存储系统协作,幸运的是Kafka提供的offset可以使用这种方式非常直接非常容易。纸上得来终觉浅,绝知些事要躬行。笔者希望能亲自测一下Kafka的性能,而非从网上找一些测试数据。所以笔者曾在0.8发布前两个月做过详细的Kafka0.8性能测试,不过很可惜测试报告不慎丢失。所幸在网上找到了Kafka的创始人之一的Jay Kreps的bechmark。以下描述皆基于该benchmark。(该benchmark基于Kafka0.8.1)
该benchmark用到了六台机器,机器配置如下
该项测试只测producer的吞吐率,也就是数据只被持久化,没有consumer读数据。
在这一测试中,创建了一个包含6个partition且没有replication的topic。然后通过一个线程尽可能快的生成50 million条比较短(payload100字节长)的消息。测试结果是821,557 records/second(78.3MB/second)。
之所以使用短消息,是因为对于消息系统来说这种使用场景更难。因为如果使用MB/second来表征吞吐率,那发送长消息无疑能使得测试结果更好。
整个测试中,都是用每秒钟delivery的消息的数量乘以payload的长度来计算MB/second的,没有把消息的元信息算在内,所以实际的网络使用量会比这个大。对于本测试来说,每次还需传输额外的22个字节,包括一个可选的key,消息长度描述,CRC等。另外,还包含一些请求相关的overhead,比如topic,partition,acknowledgement等。这就导致我们比较难判断是否已经达到网卡极限,但是把这些overhead都算在吞吐率里面应该更合理一些。因此,我们已经基本达到了网卡的极限。
初步观察此结果会认为它比人们所预期的要高很多,尤其当考虑到Kafka要把数据持久化到磁盘当中。实际上,如果使用随机访问数据系统,比如RDBMS,或者key-velue store,可预期的最高访问频率大概是5000到50000个请求每秒,这和一个好的RPC层所能接受的远程请求量差不多。而该测试中远超于此的原因有两个。
该项测试与上一测试基本一样,唯一的区别是每个partition有3个replica(所以网络传输的和写入磁盘的总的数据量增加了3倍)。每一个broker即要写作为leader的partition,也要读(从leader读数据)写(将数据写到磁盘)作为follower的partition。测试结果为786,980 records/second(75.1MB/second)。
该项测试中replication是异步的,也就是说broker收到数据并写入本地磁盘后就acknowledge producer,而不必等所有replica都完成replication。也就是说,如果leader crash了,可能会丢掉一些最新的还未备份的数据。但这也会让message acknowledgement延迟更少,实时性更好。
这项测试说明,replication可以很快。整个集群的写能力可能会由于3倍的replication而只有原来的三分之一,但是对于每一个producer来说吞吐率依然足够好。
该项测试与上一测试的唯一区别是replication是同步的,每条消息只有在被in sync
集合里的所有replica都复制过去后才会被置为committed(此时broker会向producer发送acknowledgement)。在这种模式下,Kafka可以保证即使leader crash了,也不会有数据丢失。测试结果为421,823 records/second(40.2MB/second)。
Kafka同步复制与异步复制并没有本质的不同。leader会始终track follower replica从而监控它们是否还alive,只有所有in sync
集合里的replica都acknowledge的消息才可能被consumer所消费。而对follower的等待影响了吞吐率。可以通过增大batch size来改善这种情况,但为了避免特定的优化而影响测试结果的可比性,本次测试并没有做这种调整。
该测试相当于把上文中的1个producer,复制到了3台不同的机器上(在1台机器上跑多个实例对吞吐率的增加不会有太大帮忙,因为网卡已经基本饱和了),这3个producer同时发送数据。整个集群的吞吐率为2,024,032 records/second(193,0MB/second)。
消息系统的一个潜在的危险是当数据能都存于内存时性能很好,但当数据量太大无法完全存于内存中时(然后很多消息系统都会删除已经被消费的数据,但当消费速度比生产速度慢时,仍会造成数据的堆积),数据会被转移到磁盘,从而使得吞吐率下降,这又反过来造成系统无法及时接收数据。这样就非常糟糕,而实际上很多情景下使用queue的目的就是解决数据消费速度和生产速度不一致的问题。
但Kafka不存在这一问题,因为Kafka始终以O(1)的时间复杂度将数据持久化到磁盘,所以其吞吐率不受磁盘上所存储的数据量的影响。为了验证这一特性,做了一个长时间的大数据量的测试,下图是吞吐率与数据量大小的关系图。
上图中有一些variance的存在,并可以明显看到,吞吐率并不受磁盘上所存数据量大小的影响。实际上从上图可以看到,当磁盘数据量达到1TB时,吞吐率和磁盘数据只有几百MB时没有明显区别。
这个variance是由Linux I/O管理造成的,它会把数据缓存起来再批量flush。上图的测试结果是在生产环境中对Kafka集群做了些tuning后得到的,这些tuning方法可参考这里。
需要注意的是,replication factor并不会影响consumer的吞吐率测试,因为consumer只会从每个partition的leader读数据,而与replicaiton factor无关。同样,consumer吞吐率也与同步复制还是异步复制无关。
该测试从有6个partition,3个replication的topic消费50 million的消息。测试结果为940,521 records/second(89.7MB/second)。
可以看到,Kafkar的consumer是非常高效的。它直接从broker的文件系统里读取文件块。Kafka使用sendfile API来直接通过操作系统直接传输,而不用把数据拷贝到用户空间。该项测试实际上从log的起始处开始读数据,所以它做了真实的I/O。在生产环境下,consumer可以直接读取producer刚刚写下的数据(它可能还在缓存中)。实际上,如果在生产环境下跑I/O stat,你可以看到基本上没有物理“读”。也就是说生产环境下consumer的吞吐率会比该项测试中的要高。
将上面的consumer复制到3台不同的机器上,并且并行运行它们(从同一个topic上消费数据)。测试结果为2,615,968 records/second(249.5MB/second)。
正如所预期的那样,consumer的吞吐率几乎线性增涨。
上面的测试只是把producer和consumer分开测试,而该项测试同时运行producer和consumer,这更接近使用场景。实际上目前的replication系统中follower就相当于consumer在工作。
该项测试,在具有6个partition和3个replica的topic上同时使用1个producer和1个consumer,并且使用异步复制。测试结果为795,064 records/second(75.8MB/second)。
可以看到,该项测试结果与单独测试1个producer时的结果几乎一致。所以说consumer非常轻量级。
上面的所有测试都基于短消息(payload 100字节),而正如上文所说,短消息对Kafka来说是更难处理的使用方式,可以预期,随着消息长度的增大,records/second会减小,但MB/second会有所提高。下图是records/second与消息长度的关系图。
正如我们所预期的那样,随着消息长度的增加,每秒钟所能发送的消息的数量逐渐减小。但是如果看每秒钟发送的消息的总大小,它会随着消息长度的增加而增加,如下图所示。
从上图可以看出,当消息长度为10字节时,因为要频繁入队,花了太多时间获取锁,CPU成了瓶颈,并不能充分利用带宽。但从100字节开始,我们可以看到带宽的使用逐渐趋于饱和(虽然MB/second还是会随着消息长度的增加而增加,但增加的幅度也越来越小)。
上文中讨论了吞吐率,那消息传输的latency如何呢?也就是说消息从producer到consumer需要多少时间呢?该项测试创建1个producer和1个consumer并反复计时。结果是,2 ms (median), 3ms (99th percentile, 14ms (99.9th percentile)。
(这里并没有说明topic有多少个partition,也没有说明有多少个replica,replication是同步还是异步。实际上这会极大影响producer发送的消息被commit的latency,而只有committed的消息才能被consumer所消费,所以它会最终影响端到端的latency)
如果读者想要在自己的机器上重现本次benchmark测试,可以参考本次测试的配置和所使用的命令。
实际上Kafka Distribution提供了producer性能测试工具,可通过bin/kafka-producer-perf-test.sh
脚本来启动。所使用的命令如下
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
53Producer
Setup
bin/kafka-topics.sh --zookeeper esv4-hcl197.grid.linkedin.com:2181 --create --topic test-rep-one --partitions 6 --replication-factor 1
bin/kafka-topics.sh --zookeeper esv4-hcl197.grid.linkedin.com:2181 --create --topic test --partitions 6 --replication-factor 3
Single thread, no replication
bin/kafka-run-class.sh org.apache.kafka.clients.tools.ProducerPerformance test7 50000000 100 -1 acks=1 bootstrap.servers=esv4-hcl198.grid.linkedin.com:9092 buffer.memory=67108864 batch.size=8196
Single-thread, async 3x replication
bin/kafktopics.sh --zookeeper esv4-hcl197.grid.linkedin.com:2181 --create --topic test --partitions 6 --replication-factor 3
bin/kafka-run-class.sh org.apache.kafka.clients.tools.ProducerPerformance test6 50000000 100 -1 acks=1 bootstrap.servers=esv4-hcl198.grid.linkedin.com:9092 buffer.memory=67108864 batch.size=8196
Single-thread, sync 3x replication
bin/kafka-run-class.sh org.apache.kafka.clients.tools.ProducerPerformance test 50000000 100 -1 acks=-1 bootstrap.servers=esv4-hcl198.grid.linkedin.com:9092 buffer.memory=67108864 batch.size=64000
Three Producers, 3x async replication
bin/kafka-run-class.sh org.apache.kafka.clients.tools.ProducerPerformance test 50000000 100 -1 acks=1 bootstrap.servers=esv4-hcl198.grid.linkedin.com:9092 buffer.memory=67108864 batch.size=8196
Throughput Versus Stored Data
bin/kafka-run-class.sh org.apache.kafka.clients.tools.ProducerPerformance test 50000000000 100 -1 acks=1 bootstrap.servers=esv4-hcl198.grid.linkedin.com:9092 buffer.memory=67108864 batch.size=8196
Effect of message size
for i in 10 100 1000 10000 100000;
do
echo ""
echo $i
bin/kafka-run-class.sh org.apache.kafka.clients.tools.ProducerPerformance test $((1000*1024*1024/$i)) $i -1 acks=1 bootstrap.servers=esv4-hcl198.grid.linkedin.com:9092 buffer.memory=67108864 batch.size=128000
done;
Consumer
Consumer throughput
bin/kafka-consumer-perf-test.sh --zookeeper esv4-hcl197.grid.linkedin.com:2181 --messages 50000000 --topic test --threads 1
3 Consumers
On three servers, run:
bin/kafka-consumer-perf-test.sh --zookeeper esv4-hcl197.grid.linkedin.com:2181 --messages 50000000 --topic test --threads 1
End-to-end Latency
bin/kafka-run-class.sh kafka.tools.TestEndToEndLatency esv4-hcl198.grid.linkedin.com:9092 esv4-hcl197.grid.linkedin.com:2181 test 5000
Producer and consumer
bin/kafka-run-class.sh org.apache.kafka.clients.tools.ProducerPerformance test 50000000 100 -1 acks=1 bootstrap.servers=esv4-hcl198.grid.linkedin.com:9092 buffer.memory=67108864 batch.size=8196
bin/kafka-consumer-perf-test.sh --zookeeper esv4-hcl197.grid.linkedin.com:2181 --messages 50000000 --topic test --threads 1
broker配置如下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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95############################# Server Basics #############################
# The id of the broker. This must be set to a unique integer for each broker.
broker.id=0
############################# Socket Server Settings #############################
# The port the socket server listens on
port=9092
# Hostname the broker will bind to and advertise to producers and consumers.
# If not set, the server will bind to all interfaces and advertise the value returned from
# from java.net.InetAddress.getCanonicalHostName().
#host.name=localhost
# The number of threads handling network requests
num.network.threads=4
# The number of threads doing disk I/O
num.io.threads=8
# The send buffer (SO_SNDBUF) used by the socket server
socket.send.buffer.bytes=1048576
# The receive buffer (SO_RCVBUF) used by the socket server
socket.receive.buffer.bytes=1048576
# The maximum size of a request that the socket server will accept (protection against OOM)
socket.request.max.bytes=104857600
############################# Log Basics #############################
# The directory under which to store log files
log.dirs=/grid/a/dfs-data/kafka-logs,/grid/b/dfs-data/kafka-logs,/grid/c/dfs-data/kafka-logs,/grid/d/dfs-data/kafka-logs,/grid/e/dfs-data/kafka-logs,/grid/f/dfs-data/kafka-logs
# The number of logical partitions per topic per server. More partitions allow greater parallelism
# for consumption, but also mean more files.
num.partitions=8
############################# Log Flush Policy #############################
# The following configurations control the flush of data to disk. This is the most
# important performance knob in kafka.
# There are a few important trade-offs here:
# 1. Durability: Unflushed data is at greater risk of loss in the event of a crash.
# 2. Latency: Data is not made available to consumers until it is flushed (which adds latency).
# 3. Throughput: The flush is generally the most expensive operation.
# The settings below allow one to configure the flush policy to flush data after a period of time or
# every N messages (or both). This can be done globally and overridden on a per-topic basis.
# Per-topic overrides for log.flush.interval.ms
#log.flush.intervals.ms.per.topic=topic1:1000, topic2:3000
############################# Log Retention Policy #############################
# The following configurations control the disposal of log segments. The policy can
# be set to delete segments after a period of time, or after a given size has accumulated.
# A segment will be deleted whenever *either* of these criteria are met. Deletion always happens
# from the end of the log.
# The minimum age of a log file to be eligible for deletion
log.retention.hours=168
# A size-based retention policy for logs. Segments are pruned from the log as long as the remaining
# segments don't drop below log.retention.bytes.
#log.retention.bytes=1073741824
# The maximum size of a log segment file. When this size is reached a new log segment will be created.
log.segment.bytes=536870912
# The interval at which log segments are checked to see if they can be deleted according
# to the retention policies
log.cleanup.interval.mins=1
############################# Zookeeper #############################
# Zookeeper connection string (see zookeeper docs for details).
# This is a comma separated host:port pairs, each corresponding to a zk
# server. e.g. "127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002".
# You can also append an optional chroot string to the urls to specify the
# root directory for all kafka znodes.
zookeeper.connect=esv4-hcl197.grid.linkedin.com:2181
# Timeout in ms for connecting to zookeeper
zookeeper.connection.timeout.ms=1000000
# metrics reporter properties
kafka.metrics.polling.interval.secs=5
kafka.metrics.reporters=kafka.metrics.KafkaCSVMetricsReporter
kafka.csv.metrics.dir=/tmp/kafka_metrics
# Disable csv reporting by default.
kafka.csv.metrics.reporter.enabled=false
replica.lag.max.messages=10000000
读者也可参考另外一份Kafka性能测试报告