原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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 持续部署的最后一环
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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 更易发生数据一致性问题
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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
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
spark-sql> ANALYZE TABLE customer COMPUTE STATISTICS;
Time taken: 12.888 seconds
spark-sql> desc extended customer;
c_customer_sk bigint NULL
c_customer_id string NULL
c_current_cdemo_sk bigint NULL
c_current_hdemo_sk bigint NULL
c_current_addr_sk bigint NULL
c_first_shipto_date_sk bigint NULL
c_first_sales_date_sk bigint NULL
c_salutation string NULL
c_first_name string NULL
c_last_name string NULL
c_preferred_cust_flag string NULL
c_birth_day int NULL
c_birth_month int NULL
c_birth_year int NULL
c_birth_country string NULL
c_login string NULL
c_email_address string NULL
c_last_review_date string NULL
# Detailed Table Information
Database jason_tpc_ds
Table customer
Owner jason
Created Time Sat Sep 15 14:00:40 CST 2018
Last Access Thu Jan 01 08:00:00 CST 1970
Created By Spark 2.3.2
Type EXTERNAL
Provider hive
Table Properties [transient_lastDdlTime=1536997324]
Statistics 37026233 bytes, 280000 rows
Location hdfs://dw/tpc_ds/customer
Serde Library org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe
InputFormat org.apache.hadoop.mapred.TextInputFormat
OutputFormat org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat
Storage Properties [field.delim=|, serialization.format=|]
Partition Provider Catalog
Time taken: 1.691 seconds, Fetched 36 row(s)
通过如下 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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;
Time taken: 125.624 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 height: 1102.3622047244094, num_of_bins: 254
bin_0 lower_bound: 1.0, upper_bound: 1090.0, distinct_count: 1089
bin_1 lower_bound: 1090.0, upper_bound: 2206.0, distinct_count: 1161
bin_2 lower_bound: 2206.0, upper_bound: 3286.0, distinct_count: 1124
...
bin_251 lower_bound: 276665.0, upper_bound: 277768.0, distinct_count: 1041
bin_252 lower_bound: 277768.0, upper_bound: 278870.0, distinct_count: 1098
bin_253 lower_bound: 278870.0, upper_bound: 280000.0, distinct_count: 1106
从上图可见,生成的 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 秒
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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
2
3
4
5
CREATE TABLE people (
id INT,
age INT,
name INT
)
对其进行关联查询如下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
2
3
4
5
class Analyzer(
catalog: SessionCatalog,
conf: SQLConf,
maxIterations: Int)
extends RuleExecutor[LogicalPlan] with CheckAnalysis {
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
2
3
4
5
6
7
8
9
10
11
== Analyzed Logical Plan ==
sum(v): bigint
Aggregate [sum(cast(v#493 as bigint)) AS sum(v)#504L]
+- SubqueryAlias tmp
+- Project [id#500, (((100 + 80) + math_score#501) + english_score#502) AS v#493]
+- Filter ((id#496 = id#500) && (age#497 > 10))
+- Join Inner
:- SubqueryAlias people
: +- HiveTableRelation `jason`.`people`, org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe, [id#496, age#497, name#498]
+- SubqueryAlias score
+- HiveTableRelation `jason`.`score`, org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe, [id#500, math_score#501, english_score#502]
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
2
3
4
5
6
7
8
9
== Optimized Logical Plan ==
Aggregate [sum(cast(v#493 as bigint)) AS sum(v)#504L]
+- Project [((180 + math_score#501) + english_score#502) AS v#493]
+- Join Inner, (id#496 = id#500)
:- Project [id#496]
: +- Filter ((isnotnull(age#497) && (age#497 > 10)) && isnotnull(id#496))
: +- HiveTableRelation `jason`.`people`, org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe, [id#496, age#497, name#498]
+- Filter isnotnull(id#500)
+- HiveTableRelation `jason`.`score`, org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe, [id#500, math_score#501, english_score#502]
得到优化后的 LogicalPlan 后,SparkPlanner 将其转化为 SparkPlan 即物理计划。
本例中由于 score 表数据量较小,Spark 使用了 BroadcastJoin。因此 score 表经过 Filter 后直接使用 BroadcastExchangeExec 将数据广播出去,然后结合广播数据对 people 表使用 BroadcastHashJoinExec 进行 Join。再经过 Project 后使用 HashAggregateExec 进行分组聚合。
至此,一条 SQL 从提交到解析、分析、优化以及执行的完整过程就介绍完毕。
本文介绍的 Optimizer 属于 RBO,实现简单有效。它属于 LogicalPlan 的优化,所有优化均基于 LogicalPlan 本身的特点,未考虑数据本身的特点,也未考虑算子本身的代价。下文将介绍 CBO,它充分考虑了数据本身的特点(如大小、分布)以及操作算子的特点(中间结果集的分布及大小)及代价,从而更好的选择执行代价最小的物理执行计划,即 SparkPlan。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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再合适不过。
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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 节点的特性及通知机制。充分利用这些特性,还可以开发出适用于其它场景的分布式应用。
]]>原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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
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
Producer<String, String> producer = new KafkaProducer<String, String>(props);
// 初始化事务,包括结束该Transaction ID对应的未完成的事务(如果有)
// 保证新的事务在一个正确的状态下启动
producer.initTransactions();
// 开始事务
producer.beginTransaction();
// 消费数据
ConsumerRecords<String, String> records = consumer.poll(100);
try{
// 发送数据
producer.send(new ProducerRecord<String, String>("Topic", "Key", "Value"));
// 发送消费数据的Offset,将上述数据消费与数据发送纳入同一个Transaction内
producer.sendOffsetsToTransaction(offsets, "group1");
// 数据发送及Offset发送均成功的情况下,提交事务
producer.commitTransaction();
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
// 数据发送或者Offset发送出现异常时,终止事务
producer.abortTransaction();
} finally {
// 关闭Producer和Consumer
producer.close();
consumer.close();
}
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节点进行,所以其写性能远低于读性能。原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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$ 卡塔尔世界杯bobAPP手机端下载在线加一,然后在节点 $ \lt F:2 \gt $ 下依次添加节点 $ \lt D:1 \gt $, $ \lt E:1 \gt $, $ \lt B:1 \gt $, $ \lt C:1 \gt $。
第一条记录与第二条记录的添加过程如下图所示。
添加第三条记录 $\{F\}$ 时,由于 $FP$ 树中已经存在该节点且该节点为根节点的直接子节点,直接将 $F$ 的卡塔尔世界杯bobAPP手机端下载在线加一即可。
添加第四条记录 $\{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\}$ 。因此先将其卡塔尔世界杯bobAPP手机端下载在线分别加一,然后在节点 $ \lt E:1 \gt $ 下依次添加节点 $ \lt A:1 \gt $, $ \lt C:1 \gt $。
添加第六条记录 $\{F,D,E,B,C\}$ 时,由于 $FP$ 树已存在 $\{F,D,E,B,C\}$ ,因此直接将其卡塔尔世界杯bobAPP手机端下载在线分别加一即可。
第五条记录与第六条记录添加过程如下图所示。
建立头表
为了便于对整棵FP树进行遍历,可建立一张项目头表。这张表记录各 $1-$ 频繁项的出现卡塔尔世界杯bobAPP手机端下载在线,并指向该频繁项在 $FP$ 树中的节点,如下图所示。
构建好 $FP$ 树后,即可抽取频繁项目集,其思路与 Apriori 算法类似——先从 $1-$ 频繁项目集开始,然后逐步构建更大的频繁项目集。
从 $FP$ 树中抽取频繁项目集的三个基本步骤如下:
抽取条件模式基
条件模式基(conditaional pattern base)是以所查元素为结尾的路径集合。这里的每一个路径称为一条前缀路径(prefix path)。前缀路径是介于所查元素与 $FP$ 树根节点间的所有元素。
每个 $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
2
3
4
5
6
7
8
9
10
11
12
> rule_measures <- interestMeasure(rules, c("coverage","fishersExactTest","conviction", "chiSquared"), transactions=transactions)
> quality(rules) <- cbind(quality(rules), rule_measures)
> inspect(sort(rules, by = "fishersExactTest", decreasing = T))
lhs rhs coverage fishersExactTest conviction chiSquared coverage fishersExactTest conviction chiSquared
[1] {D} => {A} 0.5714286 1.0000000 0.5714286 0.87500000 0.5714286 1.0000000 0.5714286 0.87500000
[2] {B} => {A} 0.7142857 1.0000000 0.7142857 0.46666667 0.7142857 1.0000000 0.7142857 0.46666667
[3] {D} => {B} 0.5714286 0.7142857 1.1428571 0.05833333 0.5714286 0.7142857 1.1428571 0.05833333
[4] {A,B} => {C} 0.5714286 0.7142857 1.1428571 0.05833333 0.5714286 0.7142857 1.1428571 0.05833333
[5] {B,C} => {A} 0.4285714 0.5714286 NA 0.87500000 0.4285714 0.5714286 NA 0.87500000
[6] {C} => {A} 0.7142857 0.2857143 NA 2.91666667 0.7142857 0.2857143 NA 2.91666667
[7] {A} => {C} 0.8571429 0.2857143 1.7142857 2.91666667 0.8571429 0.2857143 1.7142857 2.91666667
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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
2
3
server.1=zoo1:2888:3888
server.2=zoo2:2888:3888
server.3=zoo3: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。如下图所示。
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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)的窗口即结束。窗口结束时,可计算该用户的访问时间或者点击卡塔尔世界杯bobAPP手机端下载在线等。
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的可能原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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被修改的卡塔尔世界杯bobAPP手机端下载在线)。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卡塔尔世界杯bobAPP手机端下载在线。如果retry卡塔尔世界杯bobAPP手机端下载在线超过一定值,则使用lock方法申请锁。
这里使用自旋锁是因为自旋锁的效率比较高,但是它消耗CPU资源比较多,因此在自旋卡塔尔世界杯bobAPP手机端下载在线超过阈值时切换为互斥锁。
put、remove和get操作只需要关心一个Segment,而size操作需要遍历所有的Segment才能算出整个Map的大小。一个简单的方案是,先锁住所有Sgment,计算完后再解锁。但这样做,在做size操作时,不仅无法对Map进行写操作,同时也无法进行读操作,不利于对Map的并行操作。
为更好支持并发操作,ConcurrentHashMap会在不上锁的前提逐个Segment计算3次size,如果某相邻两次计算获取的所有Segment的更新卡塔尔世界杯bobAPP手机端下载在线(每个Segment都与HashMap一样通过modCount跟踪自己的修改卡塔尔世界杯bobAPP手机端下载在线,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。
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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自己)。每卡塔尔世界杯bobAPP手机端下载在线据写入时,只有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)
这一过程实际上发生了四卡塔尔世界杯bobAPP手机端下载在线据拷贝。首先通过系统调用将文件数据读入到内核态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
2
3
4
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
return fileChannel.transferTo(position, count, socketChannel);
}
注: 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)来减少实际网络传输和磁盘存储的数据规模,从而提高吞吐率。这里要注意,如果使用的序列化方法太慢,即使压缩比非常高,最终的效率也不一定高。
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 http://www.jasongj.com/ml/classification/
本文详述了如何通过数据预览,探索式数据分析,缺失数据填补,删除关联特征以及派生新特征等方法,在Kaggle的Titanic幸存预测这一分类问题竞赛中获得前2%排名的具体方法。
Titanic幸存预测是Kaggle上参赛人数最多的竞赛之一。它要求参赛选手通过训练数据集分析出什么类型的人更可能幸存,并预测出测试数据集中的所有乘客是否生还。
该项目是一个二元分类问题
在加载数据之前,先通过如下代码加载之后会用到的所有R库
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
library(readr) # File read / write
library(ggplot2) # Data visualization
library(ggthemes) # Data visualization
library(scales) # Data visualization
library(plyr)
library(stringr) # String manipulation
library(InformationValue) # IV / WOE calculation
library(MLmetrics) # Mache learning metrics.e.g. Recall, Precision, Accuracy, AUC
library(rpart) # Decision tree utils
library(randomForest) # Random Forest
library(dplyr) # Data manipulation
library(e1071) # SVM
library(Amelia) # Missing value utils
library(party) # Conditional inference trees
library(gbm) # AdaBoost
library(class) # KNN
library(scales)
通过如下代码将训练数据和测试数据分别加载到名为train和test的data.frame中
1
2
train <- read_csv("train.csv")
test <- read_csv("test.csv")
由于之后需要对训练数据和测试数据做相同的转换,为避免重复操作和出现不一至的情况,更为了避免可能碰到的Categorical类型新level的问题,这里建议将训练数据和测试数据合并,统一操作。
1
2
3
data <- bind_rows(train, test)
train.row <- 1:nrow(train)
test.row <- (1 + nrow(train)):(nrow(train) + nrow(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
2
3
4
5
6
7
8
ggplot(data = data[1:nrow(train),], mapping = aes(x = Pclass, y = ..count.., fill=Survived)) +
geom_bar(stat = "count", position='dodge') +
xlab('Pclass') +
ylab('Count') +
ggtitle('How Pclass impact survivor') +
scale_fill_manual(values=c("#FF0000", "#00FF00")) +
geom_text(stat = "count", aes(label = ..count..), position=position_dodge(width=1), , vjust=-0.5) +
theme(plot.title = element_text(hjust = 0.5), legend.position="bottom")
从上图可见,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
2
3
4
5
6
data$Title <- sapply(data$Name, FUN=function(x) {strsplit(x, split='[,.]')[[1]][2]})
data$Title <- sub(' ', '', data$Title)
data$Title[data$Title %in% c('Mme', 'Mlle')] <- 'Mlle'
data$Title[data$Title %in% c('Capt', 'Don', 'Major', 'Sir')] <- 'Sir'
data$Title[data$Title %in% c('Dona', 'Lady', 'the Countess', 'Jonkheer')] <- 'Lady'
data$Title <- factor(data$Title)
抽取完乘客的Title后,统计出不同Title的乘客的幸存与遇难人数
1
2
3
4
5
6
7
8
ggplot(data = data[1:nrow(train),], mapping = aes(x = Title, y = ..count.., fill=Survived)) +
geom_bar(stat = "count", position='stack') +
xlab('Title') +
ylab('Count') +
ggtitle('How Title impact survivor') +
scale_fill_discrete(name="Survived", breaks=c(0, 1), labels=c("Perish", "Survived")) +
geom_text(stat = "count", aes(label = ..count..), position=position_stack(vjust = 0.5)) +
theme(plot.title = element_text(hjust = 0.5), legend.position="bottom")
从上图可看出,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
2
3
4
5
6
7
8
data$Sex <- as.factor(data$Sex)
ggplot(data = data[1:nrow(train),], mapping = aes(x = Sex, y = ..count.., fill=Survived)) +
geom_bar(stat = 'count', position='dodge') +
xlab('Sex') +
ylab('Count') +
ggtitle('How Sex impact survivo') +
geom_text(stat = "count", aes(label = ..count..), position=position_dodge(width=1), , vjust=-0.5) +
theme(plot.title = element_text(hjust = 0.5), legend.position="bottom")
通过计算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
2
3
ggplot(data = data[(!is.na(data$Age)) & row(data[, 'Age']) <= 891, ], aes(x = Age, color=Survived)) +
geom_line(aes(label=..count..), stat = 'bin', binwidth=5) +
labs(title = "How Age impact survivor", x = "Age", y = "Count", fill = "Survived")
## Warning: Ignoring unknown aesthetics: label
对于SibSp变量,分别统计出幸存与遇难人数。
1
2
3
4
5
ggplot(data = data[1:nrow(train),], mapping = aes(x = SibSp, y = ..count.., fill=Survived)) +
geom_bar(stat = 'count', position='dodge') +
labs(title = "How SibSp impact survivor", x = "Sibsp", y = "Count", fill = "Survived") +
geom_text(stat = "count", aes(label = ..count..), position=position_dodge(width=1), , vjust=-0.5) +
theme(plot.title = element_text(hjust = 0.5), legend.position="bottom")
从上图可见,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
2
3
4
5
ggplot(data = data[1:nrow(train),], mapping = aes(x = Parch, y = ..count.., fill=Survived)) +
geom_bar(stat = 'count', position='dodge') +
labs(title = "How Parch impact survivor", x = "Parch", y = "Count", fill = "Survived") +
geom_text(stat = "count", aes(label = ..count..), position=position_dodge(width=1), , vjust=-0.5) +
theme(plot.title = element_text(hjust = 0.5), legend.position="bottom")
从上图可见,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
2
3
4
5
6
7
8
data$FamilySize <- data$SibSp + data$Parch + 1
ggplot(data = data[1:nrow(train),], mapping = aes(x = FamilySize, y = ..count.., fill=Survived)) +
geom_bar(stat = 'count', position='dodge') +
xlab('FamilySize') +
ylab('Count') +
ggtitle('How FamilySize impact survivor') +
geom_text(stat = "count", aes(label = ..count..), position=position_dodge(width=1), , vjust=-0.5) +
theme(plot.title = element_text(hjust = 0.5), legend.position="bottom")
计算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
2
3
4
5
6
7
8
9
data$TicketCount <- apply(data, 1, function(x) ticket.count[which(ticket.count[, 1] == x['Ticket']), 2])
data$TicketCount <- factor(sapply(data$TicketCount, function(x) ifelse(x > 1, 'Share', 'Unique')))
ggplot(data = data[1:nrow(train),], mapping = aes(x = TicketCount, y = ..count.., fill=Survived)) +
geom_bar(stat = 'count', position='dodge') +
xlab('TicketCount') +
ylab('Count') +
ggtitle('How TicketCount impact survivor') +
geom_text(stat = "count", aes(label = ..count..), position=position_dodge(width=1), , vjust=-0.5) +
theme(plot.title = element_text(hjust = 0.5), legend.position="bottom")
由上图可见,未与他人同票号的乘客,只有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
2
3
ggplot(data = data[(!is.na(data$Fare)) & row(data[, 'Fare']) <= 891, ], aes(x = Fare, color=Survived)) +
geom_line(aes(label=..count..), stat = 'bin', binwidth=10) +
labs(title = "How Fare impact survivor", x = "Fare", y = "Count", fill = "Survived")
对于Cabin变量,其值以字母开始,后面伴以数字。这里有一个猜想,字母代表某个区域,数据代表该区域的序号。类似于火车票即有车箱号又有座位号。因此,这里可尝试将Cabin的首字母提取出来,并分别统计出不同首字母仓位对应的乘客的幸存率。
1
2
3
4
5
6
7
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)) +
geom_bar(stat = 'count', position='dodge') +
xlab('Cabin') +
ylab('Count') +
ggtitle('How Cabin impact survivor') +
geom_text(stat = "count", aes(label = ..count..), position=position_dodge(width=1), , vjust=-0.5) +
theme(plot.title = element_text(hjust = 0.5), legend.position="bottom")
由上图可见,仓位号首字母为B,C,D,E,F的乘客幸存率均高于50%,而其它仓位的乘客幸存率均远低于50%。仓位变量的WOE及IV计算如下。由此可见,Cabin的IV为0.1866526,且“Highly Predictive”
1
2
data$Cabin <- sapply(data$Cabin, function(x) str_sub(x, start = 1, end = 1))
WOETable(X=as.factor(data$Cabin[1:nrow(train)]), Y=data$Survived[1:nrow(train)])
## 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
2
3
4
5
6
7
ggplot(data[1:nrow(train), ], mapping = aes(x = Embarked, y = ..count.., fill = Survived)) +
geom_bar(stat = 'count', position='dodge') +
xlab('Embarked') +
ylab('Count') +
ggtitle('How Embarked impact survivor') +
geom_text(stat = "count", aes(label = ..count..), position=position_dodge(width=1), , vjust=-0.5) +
theme(plot.title = element_text(hjust = 0.5), legend.position="bottom")
从上图可见,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
attach(data)
missing <- list(Pclass=nrow(data[is.na(Pclass), ]))
missing$Name <- nrow(data[is.na(Name), ])
missing$Sex <- nrow(data[is.na(Sex), ])
missing$Age <- nrow(data[is.na(Age), ])
missing$SibSp <- nrow(data[is.na(SibSp), ])
missing$Parch <- nrow(data[is.na(Parch), ])
missing$Ticket <- nrow(data[is.na(Ticket), ])
missing$Fare <- nrow(data[is.na(Fare), ])
missing$Cabin <- nrow(data[is.na(Cabin), ])
missing$Embarked <- nrow(data[is.na(Embarked), ])
for (name in names(missing)) {
if (missing[[name]][1] > 0) {
print(paste('', name, ' miss ', missing[[name]][1], ' values', sep = ''))
}
}
detach(data)
## [1] "Age miss 263 values"## [1] "Fare miss 1 values"## [1] "Cabin miss 1014 values"## [1] "Embarked miss 2 values"
缺失年龄信息的乘客数为263,缺失量比较大,不适合使用中位数或者平均值填补。一般通过使用其它变量预测或者直接将缺失值设置为默认值的方法填补,这里通过其它变量来预测缺失的年龄信息。
1
2
age.model <- rpart(Age ~ Pclass + Sex + SibSp + Parch + Fare + Embarked + Title + FamilySize, data=data[!is.na(data$Age), ], method='anova')
data$Age[is.na(data$Age)] <- predict(age.model, data[is.na(data$Age), ])
从如下数据可见,缺失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
2
3
4
ggplot(data[!is.na(data$Embarked),], aes(x=Embarked, y=Fare, fill=factor(Pclass))) +
geom_boxplot() +
geom_hline(aes(yintercept=80), color='red', linetype='dashed', lwd=2) +
scale_y_continuous(labels=dollar_format()) + theme_few()
因此可以将缺失的Embarked值设置为’C’。
1
2
data$Embarked[is.na(data$Embarked)] <- 'C'
data$Embarked <- as.factor(data$Embarked)
由于缺失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
2
set.seed(415)
model <- cforest(Survived ~ Pclass + Title + Sex + Age + SibSp + Parch + FamilySize + TicketCount + Fare + Cabin + Embarked, data = data[train.row, ], controls=cforest_unbiased(ntree=2000, mtry=3))
一般情况下,应该将训练数据分为两部分,一部分用于训练,另一部分用于验证。或者使用k-fold交叉验证。本文将所有训练数据都用于训练,然后随机选取30%数据集用于验证。
1
2
3
4
5
6
7
8
9
10
11
cv.summarize <- function(data.true, data.predict) {
print(paste('Recall:', Recall(data.true, data.predict)))
print(paste('Precision:', Precision(data.true, data.predict)))
print(paste('Accuracy:', Accuracy(data.predict, data.true)))
print(paste('AUC:', AUC(data.predict, data.true)))
}
set.seed(415)
cv.test.sample <- sample(1:nrow(train), as.integer(0.3 * nrow(train)), replace = TRUE)
cv.test <- data[cv.test.sample,]
cv.prediction <- predict(model, cv.test, OOB=TRUE, type = "response")
cv.summarize(cv.test$Survived, cv.prediction)
## [1] "Recall: 0.947976878612717"## [1] "Precision: 0.841025641025641"## [1] "Accuracy: 0.850187265917603"## [1] "AUC: 0.809094822285082"
1
2
3
predict.result <- predict(model, data[(1+nrow(train)):(nrow(data)), ], OOB=TRUE, type = "response")
output <- data.frame(PassengerId = test$PassengerId, Survived = predict.result)
write.csv(output, file = "cit1.csv", row.names = FALSE)
该模型预测结果在Kaggle的得分为0.80383,排第992名,前992/6292=15.8%。
由于FamilySize结合了SibSp与Parch的信息,因此可以尝试将SibSp与Parch从特征变量中移除。
1
2
3
4
5
set.seed(415)
model <- cforest(Survived ~ Pclass + Title + Sex + Age + FamilySize + TicketCount + Fare + Cabin + Embarked, data = data[train.row, ], controls=cforest_unbiased(ntree=2000, mtry=3))
predict.result <- predict(model, data[test.row, ], OOB=TRUE, type = "response")
submit <- data.frame(PassengerId = test$PassengerId, Survived = predict.result)
write.csv(submit, file = "cit2.csv", row.names = FALSE)
该模型预测结果在Kaggle的得分仍为0.80383。
由于Cabin的IV值相对较低,因此可以考虑将其从模型中移除。
1
2
3
4
5
set.seed(415)
model <- cforest(Survived ~ Pclass + Title + Sex + Age + FamilySize + TicketCount + Fare + Embarked, data = data[train.row, ], controls=cforest_unbiased(ntree=2000, mtry=3))
predict.result <- predict(model, data[test.row, ], OOB=TRUE, type = "response")
submit <- data.frame(PassengerId = test$PassengerId, Survived = predict.result)
write.csv(submit, file = "cit3.csv", row.names = FALSE)
该模型预测结果在Kaggle的得分仍为0.80383。
对于Name变量,上文从中派生出了Title变量。由于以下原因,可推测乘客的姓氏可能具有一定的预测作用
考虑到只出现一次的姓氏不可能同时出现在训练集和测试集中,不具辨识度和预测作用,因此将只出现一次的姓氏均命名为’Small’
1
2
3
4
5
6
7
8
9
data$Surname <- sapply(data$Name, FUN=function(x) {strsplit(x, split='[,.]')[[1]][1]})
data$FamilyID <- paste(as.character(data$FamilySize), data$Surname, sep="")
data$FamilyID[data$FamilySize <= 2] <- 'Small'
# Delete erroneous family IDs
famIDs <- data.frame(table(data$FamilyID))
famIDs <- famIDs[famIDs$Freq <= 2,]
data$FamilyID[data$FamilyID %in% famIDs$Var1] <- 'Small'
# Convert to a factor
data$FamilyID <- factor(data$FamilyID)
1
2
3
4
5
set.seed(415)
model <- cforest(as.factor(Survived) ~ Pclass + Sex + Age + Fare + Embarked + Title + FamilySize + FamilyID + TicketCount, data = data[train.row, ], controls=cforest_unbiased(ntree=2000, mtry=3))
predict.result <- predict(model, data[test.row, ], OOB=TRUE, type = "response")
submit <- data.frame(PassengerId = test$PassengerId, Survived = predict.result)
write.csv(submit, file = "cit4.csv", row.names = FALSE)
该模型预测结果在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
2
3
4
5
set.seed(415)
model <- cforest(as.factor(Survived) ~ Pclass + Sex + Age + Fare + Embarked + Title + FamilySize + FamilyID + TicketCount, data = data[train.row, ], controls=cforest_unbiased(ntree=2000, mtry=3))
predict.result <- predict(model, data[test.row, ], OOB=TRUE, type = "response")
submit <- data.frame(PassengerId = test$PassengerId, Survived = predict.result)
write.csv(submit, file = "cit5.csv", row.names = FALSE)
该模型预测结果在Kaggle的得分仍为0.82775,排第114名,前114/6292=1.8%
本文详述了如何通过数据预览,探索式数据分析,缺失数据填补,删除关联特征以及派生新特征等方法,在Kaggle的Titanic幸存预测这一分类问题竞赛中获得前2%排名的具体方法。
原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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
2
3
4
5
6
7
INSERT OVERWRITE TABLE test
SELECT CASE WHEN id < 940000000 THEN (9500000 + (CAST (RAND() * 8 AS INTEGER)) * 12 )
ELSE CAST(id/100 AS INTEGER)
END,
name
FROM student_external
WHERE id BETWEEN 900000000 AND 1050000000;
通过上述处理,一份可能造成后续数据倾斜的测试数据即以准备好。接下来,使用Spark读取该测试数据,并通过groupByKey(12)
对id分组处理,且Shuffle并行度为12。代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class SparkDataSkew {
public static void main(String[] args) {
SparkSession sparkSession = SparkSession.builder()
.appName("SparkDataSkewTunning")
.config("hive.metastore.uris", "thrift://hadoop1:9083")
.enableHiveSupport()
.getOrCreate();
Dataset<Row> dataframe = sparkSession.sql( "select * from test");
dataframe.toJavaRDD()
.mapToPair((Row row) -> new Tuple2<Integer, String>(row.getInt(0),row.getString(1)))
.groupByKey(12)
.mapToPair((Tuple2<Integer, Iterable<String>> tuple) -> {
int id = tuple._1();
AtomicInteger atomicInteger = new AtomicInteger(0);
tuple._2().forEach((String name) -> atomicInteger.incrementAndGet());
return new Tuple2<Integer, Integer>(id, atomicInteger.get());
}).count();
sparkSession.stop();
sparkSession.close();
}
}
本次实验所使用集群节点数为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
2
3
4
5
6
7
8
9
10
11
12
INSERT OVERWRITE TABLE test
SELECT CAST(CASE WHEN id < 908000000 THEN (9500000 + (CAST (RAND() * 2 AS INT) + 1) * 48 )
ELSE CAST(id/100 AS INT) END AS STRING),
name
FROM student_external
WHERE id BETWEEN 900000000 AND 1050000000;
INSERT OVERWRITE TABLE test_new
SELECT CAST(CAST(id/100 AS INT) AS STRING),
name
FROM student_delta_external
WHERE id BETWEEN 950000000 AND 950500000;
通过如下代码,读取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
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
public class SparkDataSkew{
public static void main(String[] args) {
int parallelism = 48;
SparkConf sparkConf = new SparkConf();
sparkConf.setAppName("SolveDataSkewWithRandomPrefix");
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]);
});
String[] skewedKeyArray = new String[]{"9500048", "9500096"};
Set<String> skewedKeySet = new HashSet<String>();
List<String> addList = new ArrayList<String>();
for(int i = 1; i <=24; i++) {
addList.add(i + "");
}
for(String key : skewedKeyArray) {
skewedKeySet.add(key);
}
Broadcast<Set<String>> skewedKeys = javaSparkContext.broadcast(skewedKeySet);
Broadcast<List<String>> addListKeys = javaSparkContext.broadcast(addList);
JavaPairRDD<String, String> leftSkewRDD = leftRDD
.filter((Tuple2<String, String> tuple) -> skewedKeys.value().contains(tuple._1()))
.mapToPair((Tuple2<String, String> tuple) -> new Tuple2<String, String>((new Random().nextInt(24) + 1) + "," + tuple._1(), tuple._2()));
JavaPairRDD<String, String> rightSkewRDD = rightRDD.filter((Tuple2<String, String> tuple) -> skewedKeys.value().contains(tuple._1()))
.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> skewedJoinRDD = leftSkewRDD
.join(rightSkewRDD, parallelism)
.mapToPair((Tuple2<String, Tuple2<String, String>> tuple) -> new Tuple2<String, String>(tuple._1().split(",")[1], tuple._2()._2()));
JavaPairRDD<String, String> leftUnSkewRDD = leftRDD.filter((Tuple2<String, String> tuple) -> !skewedKeys.value().contains(tuple._1()));
JavaPairRDD<String, String> unskewedJoinRDD = leftUnSkewRDD.join(rightRDD, parallelism).mapToPair((Tuple2<String, Tuple2<String, String>> tuple) -> new Tuple2<String, String>(tuple._1(), tuple._2()._2()));
skewedJoinRDD.union(unskewedJoinRDD).foreachPartition((Iterator<Tuple2<String, String>> iterator) -> {
AtomicInteger atomicInteger = new AtomicInteger();
iterator.forEachRemaining((Tuple2<String, String> tuple) -> atomicInteger.incrementAndGet());
});
javaSparkContext.stop();
javaSparkContext.close();
}
}
从下图可看出,整个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的多少等)综合使用上文所述的多种方法。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Processor {
private static final Logger LOGGER = LoggerFactory.getLogger(Processor.class);
private static final ExecutorService service = Executors.newFixedThreadPool(16);
public void process(SelectionKey selectionKey) {
service.submit(() -> {
ByteBuffer buffer = ByteBuffer.allocate(1024);
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
int count = socketChannel.read(buffer);
if (count < 0) {
socketChannel.close();
selectionKey.cancel();
LOGGER.info("{}\t Read ended", socketChannel);
return null;
} else if(count == 0) {
return null;
}
LOGGER.info("{}\t Read message {}", socketChannel, new String(buffer.array()));
return null;
});
}
}
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对象,并由一个独立的线程处理。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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类图中,对象间的多重性可在关联线上用一个数字或数字范围表示。常见的多重性表示方式如下表所示。
例如一个网页可能没有可点击按钮,也可能有多个按钮,但是该页面中的一个按钮只属于该页面,其关联多重性如下图所示。
聚合(Aggregation)关系表示整体与部分的关系。在聚合关系中,部分对象是整体对象的一部分,但是部分对象可以脱离整体对象独立存在,也即整体对象并不控制部分对象的生命周期。从代码实现上来讲,部分对象不由整体对象创建,一般通过整体类的带参构造方法或者Setter方法或其它业务方法传入到整体对象,并且有整体对象以外的对象持有部分对象的引用。
在UML类图中,聚合关系由带箭头的实线表示,并且实线的起点处以空心菱形表示,如下图所示。
《Java卡塔尔世界杯BOB体育官方APP登入(六)代理模式 vs. 装饰模式》一文中所述装饰模式中,装饰类的对象与被装饰类的对象即为聚合关系。
组合(Composition)关系也表示类之间整体和部分的关系,但是在组合关系中整体对象控制成员对象的生命周期,一旦整体对象不存在了,成员对象也即随之消亡。
从代码实现上看,一般在整体类的构造方法中直接实例化成员类,并且除整体类对象外,其它类的对象无法获取该对象的引用。
在UML类图中,组合关系的表示方式与聚合关系类似,区别在于实线以实心菱形表示。
《Java卡塔尔世界杯BOB体育官方APP登入(六)代理模式 vs. 装饰模式》一文中所述代理模式中,代理类的对象与被代理类的对象即为组合关系。
泛化(Generalization)关系,用于描述父类与子类之间的关系,父类又称作超类或者其类,子类又称为派生类。注意,父类和子类都可为抽象类或者具体类。
在Java中,我们使用面向对象的三大特性之一——继承来实现泛化关系,具体来说会用到extends
关键字。
在UML类图中,泛化关系用带空心三角形(指向父类)的实线表示。并且子类中不需要标明其从父类继承下来的属性和方法,只须注明其新增的属性和方法即可。
很多面向对象编程语言(如Java)中都引入了接口的概念。接口与接口之间可以有类与类之间类似的继承和依赖关系。同时接口与类之间还存在一种实现(Realization)关系,在这种关系中,类实现了接口中声明的方法。
在UML类图中,类与接口间的实现关系用带空心三角形的虚线表示。同时类中也需要列出接口中所声明的所有方法(这一点与类间的继承关系表示不同)。
聚合关系与组合关系都表示整体与部分的关系,有何区别?
聚合关系中,部分对象的生命周期独立于整体对象的生命周期,或者整体对象消亡后部分对象仍然可以独立存在,同时在代码中一般通过整体类的带参构造方法或Setter方法将部分类对象传入整体类的对象,UML中表示聚合关系的实线以空心菱形开始。
组合关系中,部分类对象的生命周期由整体对象控制,一旦整体对象消亡,部分类的对象随即消亡。代码中一般在整体类的构造方法内创建部分类的对象,UML中表示组合关系的实线以实心菱形开始。
同时在组合关系中,部分类的对象只属于某一个确定的整体类对象;而在聚合关系中,部分类对象可以属于一个或多个整体类对象。
如同《Java卡塔尔世界杯BOB体育官方APP登入(六)代理模式 vs. 装饰模式》一文中所述代理模式中,代理类的对象与被代理类的对象即为组合关系。装饰模式中,装饰类的对象与被装饰类的对象即为聚合关系。
聚合关系、组合关系与关联关系有何区别和联系?
聚合关系、组合关系和关联关系实质上是对象间的关系(继承和实现是类与类和类与接口间的关系)。从语意上讲,关联关系中两种对象间一般是平等的,而聚合和组合则代表整体和部分间的关系。而聚合与组合的区别主要体现在实现上和生命周期的管理上。
依赖关系与关联关系的区别是?
依赖关系是较弱的关系,一般表现为在局部变量中使用被依赖类的对象、以被依赖类的对象作为方法参数以及使用被依赖类的静态方法。而关联关系是相对较强的关系,一般表现为一个类包含一个类型为另外一个类的属性。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 http://www.jasongj.com/big_data/two_phase_commit/
分布式事务是指会涉及到操作多个数据库(或者提供事务语义的系统,如JMS)的事务。其实就是将对同一数据库事务的概念扩大到了对多个数据库的事务。目的是为了保证分布式系统中事务操作的原子性。分布式事务处理的关键是必须有一种方法可以知道事务在任何地方所做的所有动作,提交或回滚事务的决定必须产生统一的结果(全部提交或全部回滚)。
如同作者在《SQL优化(六) MVCC PostgreSQL实现事务和多版本并发控制的精华》一文中所讲,事务包含原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。
PostgreSQL针对ACID的实现技术如下表所示。
分布式事务的实现技术如下表所示。(以PostgreSQL作为事务参与方为例)
从上表可以看到,一致性、隔离性和持久性靠的是各分布式事务参与方自己原有的机制,而两阶段提交主要保证了分布式事务的原子性。
在分布式系统中,各个节点(或者事务参与方)之间在物理上相互独立,通过网络进行协调。每个独立的节点(或组件)由于存在事务机制,可以保证其数据操作的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
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
package com.jasongj.jta.resource;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.sql.DataSource;
import javax.transaction.NotSupportedException;
import javax.transaction.SystemException;
import javax.transaction.UserTransaction;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.WebApplicationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
"/jta") (
public class JTAResource {
private static final Logger LOGGER = LoggerFactory.getLogger(JTAResource.class);
public String test(@PathParam(value = "commit") boolean isCommit)
throws NamingException, SQLException, NotSupportedException, SystemException {
UserTransaction userTransaction = null;
try {
Context context = new InitialContext();
userTransaction = (UserTransaction) context.lookup("java:comp/UserTransaction");
userTransaction.setTransactionTimeout(600);
userTransaction.begin();
DataSource dataSource1 = (DataSource) context.lookup("java:comp/env/jdbc/1");
Connection xaConnection1 = dataSource1.getConnection();
DataSource dataSource2 = (DataSource) context.lookup("java:comp/env/jdbc/2");
Connection xaConnection2 = dataSource2.getConnection();
LOGGER.info("Connection autocommit : {}", xaConnection1.getAutoCommit());
Statement st1 = xaConnection1.createStatement();
Statement st2 = xaConnection2.createStatement();
LOGGER.info("Connection autocommit after created statement: {}", xaConnection1.getAutoCommit());
st1.execute("update casp.test set qtime=current_timestamp, value = 1");
st2.execute("update casp.test set qtime=current_timestamp, value = 2");
LOGGER.info("Autocommit after execution : ", xaConnection1.getAutoCommit());
userTransaction.commit();
LOGGER.info("Autocommit after commit: ", xaConnection1.getAutoCommit());
return "commit";
} catch (Exception ex) {
if (userTransaction != null) {
userTransaction.rollback();
}
LOGGER.info(ex.toString());
throw new WebApplicationException("failed", ex);
}
}
}
从上示代码中可以看到,虽然使用了Atomikos的JTA实现,但因为使用了面向接口编程特性,所以只出现了JTA相关的接口,而未显式使用Atomikos相关类。具体的Atomikos使用是在WebContent/META-INFO/context.xml
中配置。
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
<Context>
<Transaction factory="com.atomikos.icatch.jta.UserTransactionFactory" />
<Resource name="jdbc/1"
auth="Container"
type="com.atomikos.jdbc.AtomikosDataSourceBean"
factory="com.jasongj.jta.util.EnhancedTomcatAtomikosBeanFactory"
uniqueResourceName="DataSource_Resource1"
minPoolSize="2"
maxPoolSize="8"
testQuery="SELECT 1"
xaDataSourceClassName="org.postgresql.xa.PGXADataSource"
xaProperties.databaseName="postgres"
xaProperties.serverName="192.168.0.1"
xaProperties.portNumber="5432"
xaProperties.user="casp"
xaProperties.password=""/>
<Resource name="jdbc/2"
auth="Container"
type="com.atomikos.jdbc.AtomikosDataSourceBean"
factory="com.jasongj.jta.util.EnhancedTomcatAtomikosBeanFactory"
uniqueResourceName="DataSource_Resource2"
minPoolSize="2"
maxPoolSize="8"
testQuery="SELECT 1"
xaDataSourceClassName="org.postgresql.xa.PGXADataSource"
xaProperties.databaseName="postgres"
xaProperties.serverName="192.168.0.2"
xaProperties.portNumber="5432"
xaProperties.user="casp"
xaProperties.password=""/>
</Context>
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 http://www.jasongj.com/java/thread_communication/
Java多线程编程中经常会碰到这样一种场景——某个线程需要等待一个或多个线程操作结束(或达到某种状态)才开始执行。比如开发一个并发测试工具时,主线程需要等到所有测试线程均执行完成再开始统计总共耗费的时间,此时可以通过CountDownLatch轻松实现。
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
package com.test.thread;
import java.util.Date;
import java.util.concurrent.CountDownLatch;
public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
int totalThread = 3;
long start = System.currentTimeMillis();
CountDownLatch countDown = new CountDownLatch(totalThread);
for(int i = 0; i < totalThread; i++) {
final String threadName = "Thread " + i;
new Thread(() -> {
System.out.println(String.format("%s\t%s %s", new Date(), threadName, "started"));
try {
Thread.sleep(1000);
} catch (Exception ex) {
ex.printStackTrace();
}
countDown.countDown();
System.out.println(String.format("%s\t%s %s", new Date(), threadName, "ended"));
}).start();;
}
countDown.await();
long stop = System.currentTimeMillis();
System.out.println(String.format("Total time : %sms", (stop - start)));
}
}
执行结果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的卡塔尔世界杯bobAPP手机端下载在线达到预定指,所有等待都会立即被唤醒。
从使用场景上来说,CyclicBarrier是让多个线程互相等待某一事件的发生,然后同时被唤醒。而上文讲的CountDownLatch是让某一线程等待多个线程的状态,然后该线程被唤醒。
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
package com.test.thread;
import java.util.Date;
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierDemo {
public static void main(String[] args) {
int totalThread = 5;
CyclicBarrier barrier = new CyclicBarrier(totalThread);
for(int i = 0; i < totalThread; i++) {
String threadName = "Thread " + i;
new Thread(() -> {
System.out.println(String.format("%s\t%s %s", new Date(), threadName, " is waiting"));
try {
barrier.await();
} catch (Exception ex) {
ex.printStackTrace();
}
System.out.println(String.format("%s\t%s %s", new Date(), threadName, "ended"));
}).start();
}
}
}
执行结果如下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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class PhaserDemo {
public static void main(String[] args) throws IOException {
int parties = 3;
int phases = 4;
final Phaser phaser = new Phaser(parties) {
protected boolean onAdvance(int phase, int registeredParties) {
System.out.println("====== Phase : " + phase + " ======");
return registeredParties == 0;
}
};
for(int i = 0; i < parties; i++) {
int threadId = i;
Thread thread = new Thread(() -> {
for(int phase = 0; phase < phases; phase++) {
System.out.println(String.format("Thread %s, phase %s", threadId, phase));
phaser.arriveAndAwaitAdvance();
}
});
thread.start();
}
}
}
执行结果如下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主要接口如下
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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
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
package com.test.thread;
import java.util.Date;
public class Sleep {
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (Sleep.class) {
try {
System.out.println(new Date() + " Thread1 is running");
Thread.sleep(2000);
System.out.println(new Date() + " Thread1 ended");
} catch (Exception ex) {
ex.printStackTrace();
}
}
});
thread1.start();
Thread thread2 = new Thread(() -> {
synchronized (Sleep.class) {
try {
System.out.println(new Date() + " Thread2 is running");
Thread.sleep(2000);
System.out.println(new Date() + " Thread2 ended");
} catch (Exception ex) {
ex.printStackTrace();
}
}
for(long i = 0; i < 200000; i++) {
for(long j = 0; j < 100000; j++) {}
}
});
// 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
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
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
package com.test.thread;
import java.util.Date;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockDemo {
public static void main(String[] args) {
ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
new Thread(() -> {
readWriteLock.readLock().lock();
try {
System.out.println(new Date() + "\tThread 1 started with read lock");
try {
Thread.sleep(2000);
} catch (Exception ex) {
}
System.out.println(new Date() + "\tThread 1 ended");
} finally {
readWriteLock.readLock().unlock();
}
}).start();
new Thread(() -> {
readWriteLock.readLock().lock();
try {
System.out.println(new Date() + "\tThread 2 started with read lock");
try {
Thread.sleep(2000);
} catch (Exception ex) {
}
System.out.println(new Date() + "\tThread 2 ended");
} finally {
readWriteLock.readLock().unlock();
}
}).start();
new Thread(() -> {
Lock lock = readWriteLock.writeLock();
lock.lock();
try {
System.out.println(new Date() + "\tThread 3 started with write lock");
try {
Thread.sleep(2000);
} catch (Exception ex) {
ex.printStackTrace();
}
System.out.println(new Date() + "\tThread 3 ended");
} finally {
lock.unlock();
}
}).start();
}
}
执行结果如下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
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
package com.test.thread;
import java.util.Date;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionTest {
public static void main(String[] args) throws InterruptedException {
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
new Thread(() -> {
lock.lock();
try {
System.out.println(new Date() + "\tThread 1 is waiting");
try {
long waitTime = condition.awaitNanos(TimeUnit.SECONDS.toNanos(2));
System.out.println(new Date() + "\tThread 1 remaining time " + waitTime);
} catch (Exception ex) {
ex.printStackTrace();
}
System.out.println(new Date() + "\tThread 1 is waken up");
} finally {
lock.unlock();
}
}).start();
new Thread(() -> {
lock.lock();
try{
System.out.println(new Date() + "\tThread 2 is running");
try {
Thread.sleep(4000);
} catch (Exception ex) {
ex.printStackTrace();
}
condition.signal();
System.out.println(new Date() + "\tThread 2 ended");
} finally {
lock.unlock();
}
}).start();
}
}
执行结果如下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那样重新获取锁才能返回。或者可以理解成,只要可用许可满足需求,就已经获得了锁。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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
关键字后面括号内的对象。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 http://www.jasongj.com/sql/mvcc/
数据库事务包含如下四个特性
事务的实现原理可以解读为RDBMS采取何种技术确保事务的ACID特性,PostgreSQL针对ACID的实现技术如下表所示。
从上表可以看到,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的事务隔离级别分为如下四个级别
从上表中可以看出,从未提交读到串行读,要求越来越严格。
注意,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分为两种
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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卡塔尔世界杯BOB体育官方APP登入(九) 桥接模式》中介绍的桥接模式即是对这一原则的典型应用。
可以用一句话概括卡塔尔世界杯BOB体育官方APP登入———卡塔尔世界杯BOB体育官方APP登入是一种利用OOP的封闭、继承和多态三大特性,同时在遵循单一职责原则、开闭原则、里氏替换原则、迪米特法则、依赖倒置原则、接口隔离原则及合成/聚合复用原则的前提下,被总结出来的经过反复实践并被多数人知晓且经过分类和设计的可重用的软件设计方式。
每个卡塔尔世界杯BOB体育官方APP登入都有其适合范围,并解决特定问题。所以项目实践中应该针对特定使用场景选用合适的卡塔尔世界杯BOB体育官方APP登入,如果某些场景下现在的卡塔尔世界杯BOB体育官方APP登入都不能很完全的解决问题,那也不必拘泥于卡塔尔世界杯BOB体育官方APP登入本身。实际上,学习和使用卡塔尔世界杯BOB体育官方APP登入本身并不是目的,目的是通过学习和使用它,强化面向对象设计思路并用合适的方法解决工程问题。
有些人认为,在某些特定场景下,卡塔尔世界杯BOB体育官方APP登入并非最优方案,而自己的解决方案可能会更好。这个问题得分两个方面来讨论:一方面,如上文所述,所有卡塔尔世界杯BOB体育官方APP登入都有其适用场景,“one size does not fit all”;另一方面,确实有可能自己设计的方案比卡塔尔世界杯BOB体育官方APP登入更适合,但这并不影响你学习并使用卡塔尔世界杯BOB体育官方APP登入,因为卡塔尔世界杯BOB体育官方APP登入经过大量实战检验能在绝大多数情况下提供良好方案。
卡塔尔世界杯BOB体育官方APP登入虽然都有其相对固定的实现方式,但是它的精髓是利用OOP的三大特性,遵循OOD七大原则解决工程问题。所以学习卡塔尔世界杯BOB体育官方APP登入的目的不是学习卡塔尔世界杯BOB体育官方APP登入的固定实现方式本身,而是其思想。
卡塔尔世界杯BOB体育官方APP登入是被广泛接受和使用的,引导团队成员使用卡塔尔世界杯BOB体育官方APP登入可以减少沟通成本,而更专注于业务本身。也许你有自己的一套思路,但是你怎么能保证团队成员一定认可你的思路,进而将你的思路贯彻实施呢?统一使用卡塔尔世界杯BOB体育官方APP登入能让团队只使用20%的精力决解80%的问题。其它20%的问题,才是你需要花精力解决的。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.jasongj.strategy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
"StrategyB") .jasongj.annotation.Strategy(name=
public class ConcreteStrategyB implements Strategy {
private static final Logger LOG = LoggerFactory.getLogger(ConcreteStrategyB.class);
public void strategy(String input) {
LOG.info("Strategy B for input : {}", input);
}
}
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卡塔尔世界杯BOB体育官方APP登入(一) 简单工厂模式不简单》所述,简单工厂的实现方式比较多,可以结合《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");
}
}
从上面代码可以看出,引入简单工厂模式后,客户端不再需要直接实例化具体的策略类,也不需要判断应该使用何种策略,可以方便应对策略的切换。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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
属于外部状态,由客户端在调用时传入。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 http://www.jasongj.com/design_pattern/singleton/
对于系统中的某些类来说,只有一个实例很重要,例如,一个系统只能有一个窗口管理器或文件系统;一个系统只能有一个计时工具或ID(序号)生成器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.jasongj.singleton1;
public class Singleton {
private static Singleton INSTANCE;
private Singleton() {};
public static Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.jasongj.singleton2;
public class Singleton {
private static Singleton INSTANCE;
private Singleton() {};
public static synchronized Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.jasongj.singleton3;
public class Singleton {
private static Singleton INSTANCE;
private Singleton() {};
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
}
synchronized
,但本质上是线程不安全的。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.jasongj.singleton4;
public class Singleton {
private static volatile Singleton INSTANCE;
private Singleton() {};
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized(Singleton.class){
if(INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
注:
synchronized
已经保证了INSTANCE
写操作对其它线程读操作的可见性。具体原理请参考《Java进阶(二)当我们说线程安全时,到底在说什么》volatile
关键字的目的不是保证可见性(synchronized
已经保证了可见性),而是为了保证顺序性。具体来说,INSTANCE = new Singleton()
不是原子操作,实际上被拆分为了三步:1) 分配内存;2) 初始化对象;3) 将INSTANCE指向分配的对象内存地址。 如果没有volatile
,可能会发生指令重排,使得INSTANCE先指向内存地址,而对象尚未初始化,其它线程直接使用INSTANCE引用进行对象操作时出错。详细原理可参见《双重检查锁定与延迟初始化》1
2
3
4
5
6
7
8
9
10
11
12
13
package com.jasongj.singleton6;
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {};
public static Singleton getInstance() {
return INSTANCE;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.jasongj.singleton7;
public class Singleton {
private static Singleton INSTANCE;
static{
INSTANCE = new Singleton();
}
private Singleton() {};
public static Singleton getInstance() {
return INSTANCE;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.jasongj.singleton8;
public class Singleton {
private Singleton() {};
public static Singleton getInstance() {
return InnerClass.INSTANCE;
}
private static class InnerClass {
private static final Singleton INSTANCE = new Singleton();
}
}
getInstance
时才会装载内部类,才会创建实例。同时因为使用内部类时,先调用内部类的线程会获得类初始化锁,从而保证内部类的初始化(包括实例化它所引用的外部类对象)线程安全。即使内部类创建外部类的实例Singleton INSTANCE = new Singleton()
发生指令重排也不会引起双重检查(Double-Check)下的懒汉模式中提到的问题,因此无须使用volatile
关键字。1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.jasongj.singleton9;
public enum Singleton {
INSTANCE;
public void whatSoEverMethod() { }
// 该方法非必须,只是为了保证与其它方案一样使用静态方法得到实例
public static Singleton getInstance() {
return INSTANCE;
}
}
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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();
}
}
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.jasongj.client;
import com.jasongj.target.Adapter;
import com.jasongj.target.ConcreteTarget;
import com.jasongj.target.ITarget;
public class AdapterClient {
public static void main(String[] args) {
ITarget adapter = new Adapter();
adapter.request();
ITarget target = new ConcreteTarget();
target.request();
}
}
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 http://www.jasongj.com/design_pattern/dynamic_proxy_cglib/
静态代理,是指程序运行前就已经存在了代理类的字节码文件,代理类和被代理类的关系在运行前就已经确定。
上一篇文章《Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式》所讲的代理为静态代理。如上文所讲,一个静态代理类只代理一个具体类。如果需要对实现了同一接口的不同具体类作代理,静态代理需要为每一个具体类创建相应的代理类。
动态代理类的字节码是在程序运行期间动态生成,所以不存在代理类的字节码文件。代理类和被代理类的关系是在程序运行时确定的。
JDK从1.3开始引入动态代理。可通过java.lang.reflect.Proxy
类的静态方法Proxy.newProxyInstance
动态创建代理类和实例。并且由它动态创建出来的代理类都是Proxy类的子类。
代理类往往会在代理对象业务逻辑前后增加一些功能性的行为,如使用事务或者打印日志。本文把这些行为称之为代理行为。
使用JDK动态代理,需要创建一个实现java.lang.reflect.InvocationHandler
接口的类,并在该类中定义代理行为。
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
package com.jasongj.proxy.jdkproxy;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class SubjectProxyHandler implements InvocationHandler {
private static final Logger LOG = LoggerFactory.getLogger(SubjectProxyHandler.class);
private Object target;
"rawtypes") (
public SubjectProxyHandler(Class clazz) {
try {
this.target = clazz.newInstance();
} catch (InstantiationException | IllegalAccessException ex) {
LOG.error("Create proxy for {} failed", clazz.getName());
}
}
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
preAction();
Object result = method.invoke(target, args);
postAction();
LOG.info("Proxy class name {}", proxy.getClass().getName());
return result;
}
private void preAction() {
LOG.info("SubjectProxyHandler.preAction()");
}
private void postAction() {
LOG.info("SubjectProxyHandler.postAction()");
}
}
从上述代码中可以看到,被代理对象的类对象作为参数传给了构造方法,原因如下
注意,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.jasongj.client;
import com.jasongj.proxy.cglibproxy.SubjectInterceptor;
import com.jasongj.subject.ConcreteSubject;
import com.jasongj.subject.ISubject;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
public class CgLibProxyClient {
public static void main(String[] args) {
MethodInterceptor methodInterceptor = new SubjectInterceptor();
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(ConcreteSubject.class);
enhancer.setCallback(methodInterceptor);
ISubject subject = (ISubject)enhancer.create();
subject.action();
}
}
分别使用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下载
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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
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
package com.jasongj.decorator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jasongj.subject.ISubject;
public class SubjectPostDecorator implements ISubject {
private static final Logger LOG = LoggerFactory.getLogger(SubjectPostDecorator.class);
private ISubject subject;
public SubjectPostDecorator(ISubject subject) {
this.subject = subject;
}
public void action() {
subject.action();
postAction();
}
private void postAction() {
LOG.info("SubjectPostDecorator.preAction()");
}
}
装饰模式使用方法如下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)指定。
装饰模式的本质是动态组合。动态是手段,组合是目的。每个装饰类可以只负责添加一项额外功能,然后通过组合为被装饰类添加复杂功能。由于每个装饰类的职责比较简单单一,增加了这些装饰类的可重用性,同时也更符合单一职责原则。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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+"-"));
}
}
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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");
}
}
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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即可。
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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下载
原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线,原文链接 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