技术世界 https://www.gravatar.com/avatar/b7c3335ef8378cf904c036c9f55912c9 分享交流大数据领域技术,包括但不限于Storm、Spark、Hadoop等流行分布式计算系统,Kafka、MetaQ等分布式消息系统,MongoDB、Cassandra等NoSQL,PostgreSQL、MySQL等RDBMS以及其它前沿技术 2018-10-16T23:02:13.000Z http://www.jasongj.com/ 郭俊 Jason jason.guo.vip@gmail.com Hexo 技术世界 http://www.jasongj.com/spark/adaptive_execution/ 2018-10-16T23:02:13.000Z 2018-10-16T23:02:13.000Z

原创文章,转载请务必将下面这段话置于文章开头处。
本文转发自卡塔尔世界杯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

1 背景

前面《Spark SQL / Catalyst 内部原理 与 RBO》与《Spark SQL 性能优化再进一步 CBO 基于代价的优化》介绍的优化,从查询本身与目标数据的特点的角度尽可能保证了最终生成的执行计划的高效性。但是

  • 执行计划一旦生成,便不可更改,即使执行过程中发现后续执行计划可以进一步优化,也只能按原计划执行
  • CBO 基于统计信息生成最优执行计划,需要提前生成统计信息,成本较大,且不适合数据更新频繁的场景
  • CBO 基于基础表的统计信息与操作对数据的影响推测中间结果的信息,只是估算,不够精确
  • 本文介绍的 Adaptive Execution 将可以根据执行过程中的中间数据优化后续执行,从而提高整体执行效率。核心在于两点

  • 执行计划可动态调整
  • 调整的依据是中间结果的精确统计信息
  • 2 动态设置 Shuffle Partition

    2.1 Spark Shuffle 原理

    Spark Shuffle 一般用于将上游 Stage 中的数据按 Key 分区,保证来自不同 Mapper (表示上游 Stage 的 Task)的相同的 Key 进入相同的 Reducer (表示下游 Stage 的 Task)。一般用于 group by 或者 Join 操作。
    Spark Shuffle 过程

    如上图所示,该 Shuffle 总共有 2 个 Mapper 与 5 个 Reducer。每个 Mapper 会按相同的规则(由 Partitioner 定义)将自己的数据分为五份。每个 Reducer 从这两个 Mapper 中拉取属于自己的那一份数据。

    2.2 原有 Shuffle 的问题

    使用 Spark SQL 时,可通过 spark.sql.shuffle.partitions 指定 Shuffle 时 Partition 个数,也即 Reducer 个数

    该参数决定了一个 Spark SQL Job 中包含的所有 Shuffle 的 Partition 个数。如下图所示,当该参数值为 3 时,所有 Shuffle 中 Reducer 个数都为 3
    Spark SQL with multiple Shuffle

    这种方法有如下问题

  • Partition 个数不宜设置过大
  • Reducer(代指 Spark Shuffle 过程中执行 Shuffle Read 的 Task) 个数过多,每个 Reducer 处理的数据量过小。大量小 Task 造成不必要的 Task 调度开销与可能的资源调度开销(如果开启了 Dynamic Allocation)
  • Reducer 个数过大,如果 Reducer 直接写 HDFS 会生成大量小文件,从而造成大量 addBlock RPC,Name node 可能成为瓶颈,并影响其它使用 HDFS 的应用
  • 过多 Reducer 写小文件,会造成后面读取这些小文件时产生大量 getBlock RPC,对 Name node 产生冲击
  • Partition 个数不宜设置过小
  • 每个 Reducer 处理的数据量太大,Spill 到磁盘开销增大
  • Reducer GC 时间增长
  • Reducer 如果写 HDFS,每个 Reducer 写入数据量较大,无法充分发挥并行处理优势
  • 很难保证所有 Shuffle 都最优
  • 不同的 Shuffle 对应的数据量不一样,因此最优的 Partition 个数也不一样。使用统一的 Partition 个数很难保证所有 Shuffle 都最优
  • 定时任务不同时段数据量不一样,相同的 Partition 数设置无法保证所有时间段执行时都最优
  • 2.3 自动设置 Shuffle Partition 原理

    Spark Shuffle 原理 一节图中所示,Stage 1 的 5 个 Partition 数据量分别为 60MB,40MB,1MB,2MB,50MB。其中 1MB 与 2MB 的 Partition 明显过小(实际场景中,部分小 Partition 只有几十 KB 及至几十字节)

    开启 Adaptive Execution 后

  • Spark 在 Stage 0 的 Shuffle Write 结束后,根据各 Mapper 输出,统计得到各 Partition 的数据量,即 60MB,40MB,1MB,2MB,50MB
  • 通过 ExchangeCoordinator 计算出合适的 post-shuffle Partition 个数(即 Reducer)个数(本例中 Reducer 个数设置为 3)
  • 启动相应个数的 Reducer 任务
  • 每个 Reducer 读取一个或多个 Shuffle Write Partition 数据(如下图所示,Reducer 0 读取 Partition 0,Reducer 1 读取 Partition 1、2、3,Reducer 2 读取 Partition 4)
    Spark SQL adaptive reducer 1
  • 三个 Reducer 这样分配是因为

  • targetPostShuffleInputSize 默认为 64MB,每个 Reducer 读取数据量不超过 64MB
  • 如果 Partition 0 与 Partition 2 结合,Partition 1 与 Partition 3 结合,虽然也都不超过 64 MB。但读完 Partition 0 再读 Partition 2,对于同一个 Mapper 而言,如果每个 Partition 数据比较少,跳着读多个 Partition 相当于随机读,在 HDD 上性能不高
  • 目前的做法是只结合相临的 Partition,从而保证顺序读,提高磁盘 IO 性能
  • 该方案只会合并多个小的 Partition,不会将大的 Partition 拆分,因为拆分过程需要引入一轮新的 Shuffle
  • 基于上面的原因,默认 Partition 个数(本例中为 5)可以大一点,然后由 ExchangeCoordinator 合并。如果设置的 Partition 个数太小,Adaptive Execution 在此场景下无法发挥作用
  • 由上图可见,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,从而提升了性能。
    Spark SQL adaptive reducer 2

    由于 Adaptive Execution 的自动设置 Reducer 是由 ExchangeCoordinator 根据 Shuffle Write 统计信息决定的,因此即使在同一个 Job 中不同 Shuffle 的 Reducer 个数都可以不一样,从而使得每次 Shuffle 都尽可能最优。

    上文 原有 Shuffle 的问题 一节中的例子,在启用 Adaptive Execution 后,三次 Shuffle 的 Reducer 个数从原来的全部为 3 变为 2、4、3。

    Spark SQL with adaptive Shuffle

    2.4 使用与优化方法

    可通过 spark.sql.adaptive.enabled=true 启用 Adaptive Execution 从而启用自动设置 Shuffle Reducer 这一特性

    通过 spark.sql.adaptive.shuffle.targetPostShuffleInputSize 可设置每个 Reducer 读取的目标数据量,其单位是字节,默认值为 64 MB。上文例子中,如果将该值设置为 50 MB,最终效果仍然如上文所示,而不会将 Partition 0 的 60MB 拆分。具体原因上文已说明

    3 动态调整执行计划

    3.1 固定执行计划的不足

    在不开启 Adaptive Execution 之前,执行计划一旦确定,即使发现后续执行计划可以优化,也不可更改。如下图所示,SortMergJoin 的 Shuffle Write 结束后,发现 Join 一方的 Shuffle 输出只有 46.9KB,仍然继续执行 SortMergeJoin
    Spark SQL with fixed DAG

    此时完全可将 SortMergeJoin 变更为 BroadcastJoin 从而提高整体执行效率。

    3.2 SortMergeJoin 原理

    SortMergeJoin 是常用的分布式 Join 方式,它几乎可使用于所有需要 Join 的场景。但有些场景下,它的性能并不是最好的。

    SortMergeJoin 的原理如下图所示

  • 将 Join 双方以 Join Key 为 Key 按照 HashPartitioner 分区,且保证分区数一致
  • Stage 0 与 Stage 1 的所有 Task 在 Shuffle Write 时,都将数据分为 5 个 Partition,并且每个 Partition 内按 Join Key 排序
  • Stage 2 启动 5 个 Task 分别去 Stage 0 与 Stage 1 中所有包含 Partition 分区数据的 Task 中取对应 Partition 的数据。(如果某个 Mapper 不包含该 Partition 的数据,则 Redcuer 无须向其发起读取请求)。
  • Stage 2 的 Task 2 分别从 Stage 0 的 Task 0、1、2 中读取 Partition 2 的数据,并且通过 MergeSort 对其进行排序
  • Stage 2 的 Task 2 分别从 Stage 1 的 Task 0、1 中读取 Partition 2 的数据,且通过 MergeSort 对其进行排序
  • Stage 2 的 Task 2 在上述两步 MergeSort 的同时,使用 SortMergeJoin 对二者进行 Join
  • Spark SQL SortMergeJoin

    3.3 BroadcastJoin 原理

    当参与 Join 的一方足够小,可全部置于 Executor 内存中时,可使用 Broadcast 机制将整个 RDD 数据广播到每一个 Executor 中,该 Executor 上运行的所有 Task 皆可直接读取其数据。(本文中,后续配图,为了方便展示,会将整个 RDD 的数据置于 Task 框内,而隐藏 Executor)

    对于大 RDD,按正常方式,每个 Task 读取并处理一个 Partition 的数据,同时读取 Executor 内的广播数据,该广播数据包含了小 RDD 的全量数据,因此可直接与每个 Task 处理的大 RDD 的部分数据直接 Join
    Spark SQL BroadcastJoin

    根据 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

    3.4 动态调整执行计划原理

    如上文 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,如下图所示
    Spark SQL Auto BroadcastJoin

    具体做法是

  • 将 Stage 1 全部 Shuffle Write 结果广播出去
  • 启动 Stage 2,Partition 个数与 Stage 0 一样,都为 3
  • 每个 Stage 2 每个 Task 读取 Stage 0 每个 Task 的 Shuffle Write 数据,同时与广播得到的 Stage 1 的全量数据进行 Join
  • 注:广播数据存于每个 Executor 中,其上所有 Task 共享,无须为每个 Task 广播一份数据。上图中,为了更清晰展示为什么能够直接 Join 而将 Stage 2 每个 Task 方框内都放置了一份 Stage 1 的全量数据

    虽然 Shuffle Write 已完成,将后续的 SortMergeJoin 改为 Broadcast 仍然能提升执行效率

  • SortMergeJoin 需要在 Shuffle Read 时对来自 Stage 0 与 Stage 1 的数据进行 Merge Sort,并且可能需要 Spill 到磁盘,开销较大
  • SortMergeJoin 时,Stage 2 的所有 Task 需要取 Stage 0 与 Stage 1 的所有 Task 的输出数据(如果有它要的数据 ),会造成大量的网络连接。且当 Stage 2 的 Task 较多时,会造成大量的磁盘随机读操作,效率不高,且影响相同机器上其它 Job 的执行效率
  • SortMergeJoin 时,Stage 2 每个 Task 需要从几乎所有 Stage 0 与 Stage 1 的 Task 取数据,无法很好利用 Locality
  • Stage 2 改用 Broadcast,每个 Task 直接读取 Stage 0 的每个 Task 的数据(一对一),可很好利用 Locality 特性。最好在 Stage 0 使用的 Executor 上直接启动 Stage 2 的 Task。如果 Stage 0 的 Shuffle Write 数据并未 Spill 而是在内存中,则 Stage 2 的 Task 可直接读取内存中的数据,效率非常高。如果有 Spill,那可直接从本地文件中读取数据,且是顺序读取,效率远比通过网络随机读数据效率高
  • 3.5 使用与优化方法

    该特性的使用方式如下

  • spark.sql.adaptive.enabledspark.sql.adaptive.join.enabled 都设置为 true 时,开启 Adaptive Execution 的动态调整 Join 功能
  • spark.sql.adaptiveBroadcastJoinThreshold 设置了 SortMergeJoin 转 BroadcastJoin 的阈值。如果不设置该参数,该阈值与 spark.sql.autoBroadcastJoinThreshold 的值相等
  • 除了本文所述 SortMergeJoin 转 BroadcastJoin,Adaptive Execution 还可提供其它 Join 优化策略。部分优化策略可能会需要增加 Shuffle。spark.sql.adaptive.allowAdditionalShuffle 参数决定了是否允许为了优化 Join 而增加 Shuffle。其默认值为 false
  • 4 自动处理数据倾斜

    4.1 解决数据倾斜典型方案

    Spark性能优化之道——解决Spark数据倾斜(Data Skew)的N种姿势》一文讲述了数据倾斜的危害,产生原因,以及典型解决方法

  • 保证文件可 Split 从而避免读 HDFS 时数据倾斜
  • 保证 Kafka 各 Partition 数据均衡从而避免读 Kafka 引起的数据倾斜
  • 调整并行度或自定义 Partitioner 从而分散分配给同一 Task 的大量不同 Key
  • 使用 BroadcastJoin 代替 ReduceJoin 消除 Shuffle 从而避免 Shuffle 引起的数据倾斜
  • 对倾斜 Key 使用随机前缀或后缀从而分散大量倾斜 Key,同时将参与 Join 的小表扩容,从而保证 Join 结果的正确性
  • 4.2 自动解决数据倾斜

    目前 Adaptive Execution 可解决 Join 时数据倾斜问题。其思路可理解为将部分倾斜的 Partition (倾斜的判断标准为该 Partition 数据是所有 Partition Shuffle Write 中位数的 N 倍) 进行单独处理,类似于 BroadcastJoin,如下图所示
    Spark SQL resolve joinm skew

    在上图中,左右两边分别是参与 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 0-0 与 Task 0-1
  • Task 0-0 读取 Stage 0 Task 0 中属于 Partition 0 的数据
  • Task 0-1 读取 Stage 0 Task 1 与 Task 2 中属于 Partition 0 的数据,并进行 MergeSort
  • Task 0-0 与 Task 0-1 都从 Stage 1 的两个 Task 中所有属于 Partition 0 的数据
  • Task 0-0 与 Task 0-1 使用 Stage 0 中属于 Partition 0 的部分数据与 Stage 1 中属于 Partition 0 的全量数据进行 Join
  • 通过该方法,原本由一个 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 中的变量广播实现

    4.3 使用与优化方法

    开启与调优该特性的方法如下

  • spark.sql.adaptive.skewedJoin.enabled 设置为 true 即可自动处理 Join 时数据倾斜
  • spark.sql.adaptive.skewedPartitionMaxSplits 控制处理一个倾斜 Partition 的 Task 个数上限,默认值为 5
  • spark.sql.adaptive.skewedPartitionRowCountThreshold 设置了一个 Partition 被视为倾斜 Partition 的行数下限,也即行数低于该值的 Partition 不会被当作倾斜 Partition 处理。其默认值为 10L * 1000 * 1000 即一千万
  • spark.sql.adaptive.skewedPartitionSizeThreshold 设置了一个 Partition 被视为倾斜 Partition 的大小下限,也即大小小于该值的 Partition 不会被视作倾斜 Partition。其默认值为 64 * 1024 * 1024 也即 64MB
  • spark.sql.adaptive.skewedPartitionFactor 该参数设置了倾斜因子。如果一个 Partition 的大小大于 spark.sql.adaptive.skewedPartitionSizeThreshold 的同时大于各 Partition 大小中位数与该因子的乘积,或者行数大于 spark.sql.adaptive.skewedPartitionRowCountThreshold 的同时大于各 Partition 行数中位数与该因子的乘积,则它会被视为倾斜的 Partition
  • 5 Spark 系列文章

  • Spark性能优化之道——解决Spark数据倾斜(Data Skew)的N种姿势
  • Spark SQL / Catalyst 内部原理 与 RBO
  • Spark SQL 性能优化再进一步 CBO 基于代价的优化
  • Spark CommitCoordinator 保证数据一致性
  • Spark 灰度发布在十万级节点上的成功实践 CI CD
  • Adaptive Execution 让 Spark SQL 更智能更好用
  • ]]>
    RBO 与 CBO 在逻辑计划优化阶段与物理计划生成阶段通过规则优化最终生成的 DAG。本文介绍的 Adaptive Execution 可在 Spark Job 执行过程中,自动基于中间结果的统计信息优化后续的执行计划从而提高整体执行效率,并降低使用门槛
    技术世界 http://www.jasongj.com/spark/ci_cd/ 2018-10-07T23:20:13.000Z 2018-10-07T23:20:13.000Z

    原创文章,转载请务必将下面这段话置于文章开头处。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/spark/ci_cd/

    Spark CI 持续集成实践

    CI 介绍

    持续集成是指,及时地将最新开发的且经过测试的代码集成到主干分支中。
    Continuous Integration

    持续集成的优点

  • 快速发现错误 每次更新都及时集成到主干分支中,并进行测试,可以快速发现错误,方便定位错误
  • 避免子分支大幅偏离主干分支 主干在不断更新,如果不经常集成,会产生后期集成难度变大,甚至难以集成,并造成不同开发人员间不必要的重复开发
  • 为快速迭代提供保障 持续集成为后文介绍的持续发布与持续部署提供了保证
  • Spark CI 实践

    目前主流的代码管理工具有,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 过程包含了

  • 编译 Spark 所有 module
  • 执行 Spark 所有单元测试
  • 执行性能测试
  • 检查测试结果。如果有任意测试用例失败,或者性能测试结果明显差于上一次测试,则 Jenkins 构建失败
  • Jenkins 将 build 结果通知 Gitlab,只有 Jenkins 构建成功,Gitlab 的 MR 页面才允许 Merge。否则 Gitlab 不允许 Merge

    另外,还需人工进行 Code Review。只有两个以上的 Reviewer 通过,才能进行最终 Merge

    所有测试与 Reivew 通过后,通过 Gitlab Merge 功能自动将代码 Fast forward Merge 到目标分支中

    该流程保证了

  • 所有合并进目标分支中的代码都经过了单元测试(白盒测试)与性能测试(黑盒测试)
  • 每次发起 MR 后都会及时自动发起测试,方便及时发现问题
  • 所有代码更新都能及时合并进目标分支
  • Spark CD 持续交付

    CD 持续交付介绍

    持续交付是指,及时地将软件的新版本,交付给质量保障团队或者用户,以供评审。持续交付可看作是持续集成的下一步。它强调的是,不管怎么更新,软件都是可随时交付的。

    这一阶段的评审,一般是将上文集成后的软件部署到尽可能贴近生产环境的 Staging 环境中,并使用贴近真实场景的用法(或者流量)进行测试。
    Continuous Delivery

    持续发布的优点

  • 快速发布 有了持续集成与持续发布,可快速将最新功能发布出来,也可快速修复已知 bug
  • 快速迭代 由于发布及时,可以快速判断产品是否符合产品经理的预期或者是否能满足用户的需求
  • Spark CD 持续发布实践

    这里有提供三种方案,供读者参考。推荐方案三

    方案一:单分支

    正常流程

    如下图所示,基于单分支的 Spark 持续交付方案如下

  • 所有开发都在 spark-src.git/dev(即 spark-src.git 的 dev branch) 上进行
  • 每周一将当前最新代码打包,放进 spark-bin.git/devspark-${ build # }(如图中第 2 周的 spark-72)文件夹内
  • spark-prod 指向当前 spark-dev 指向的文件夹(如图中的 spark-71 )
  • spark-dev 指向 spark-${ build # }(如图中的 spark-72)
  • 自动将 spark-bin.git 最新内容上线到 Staging 环境,并使用 spark-dev 进行测试
  • spark-prod 比 spark-dev 晚一周(一个 release 周期),这一周用于 Staging 环境中测试
  • Continuous Delivery Solution 1

    注:

  • 蓝色圆形是正常 commit
  • 垂直虚线是发布时间点,week 1、week 2、week 3、week 4
  • 最上方黑色粗横线是源码时间线
  • 下方黄色粗横线是 release 时间线
  • 绿色方框是每周生成的 release,带 build #
  • 蓝色方框是开发版本的 symbolic
  • 橘色方框是线上版本的 symbolic
  • bug fix

    在 Staging 环境中发现 spark-dev 的 bug 时,修复及集成和交付方案如下

  • 如果在 Staging 环境中发现了 spark-dev 的 bug,且必须要修复(如不修复,会带到下次的 spark-prod 的 release 中),则提交一个 commit,并且 commit message 包含 bugfix 字样(如图中黑色圆形 commit 9 所示)
  • 该 bugfix 被 Merge 后,Jenkins 得到通知
  • Jenkins 发现该 commit 是 bugfix,立即启动构建,生成spark-${ build \# }(如图中的 spark-73)
  • spark-dev 指向 spark-${ build \# } (如图中的 spark-73 )
  • Continuous Delivery Solution 1 bug fix

    hot fix

    生产环境中发现 bug 时修复及交付方案如下

  • 如果发现线上版本(即 spark-prod)有问题,须及时修复,则提交一个 commit,并且 commit message 包含 hotfix 字样 (如图中红色圆形 commit 9 所示)
  • 该 hotfix 被 Merge 后,Jenkins 得到通知
  • Jenkins 发现该 commit 是 hotfix,立即启动构建,生成 spark-${ build \# }(如图中的 spark-73)
  • spark-dev 与 spark-prod 均指向 spark-${ build \# } (如图中的 spark-73 )
  • Continuous Delivery Solution 1 hotfix

    Pros.

  • spark-src.git 与 spark-bin.git 都只有一个分支,维护方便
  • spark-prod 落后于 spark-dev 一周(一个 release),意味着 spark-prod 都成功通过了一周的 Staging 环境测试
  • Cons.

  • 使用 spark-prod 与 spark-dev 两个 symbolic,如果要做灰度发布,需要用户修改相应路径,成本较高
  • hotfix 时,引入了过去一周多(最多两周)未经 Staging 环境中通过 spark-dev 测试的 commit,增加了不确定性,也违背了所有非 hotfix commit 都经过了一个发布周期测试的原则
  • 方案二:两分支

    正常流程

    如下图所示,基于两分支的 Spark 持续交付方案如下

  • spark-src.gitspark-bin.git 均包含两个分支,即 dev branch 与 prod branch
  • 所有正常开发都在 spark-src.git/dev 上进行
  • 每周一(如果是 weekly release)从 spark-src.git/dev 打包出一个 release 放进 spark-bin.git/devspark-${ 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/prodspark-${ build \# } 文件夹内(如图中第 2 周下方的的 spark-1 )
  • spark-bin.git/prod 的 spark 作为 symbolic 指向 spark-${ build \# }
  • Continuous Delivery Solution 2

    bug fix

    在 Staging 环境中发现了 dev 版本的 bug 时,修复及集成和交付方案如下

  • spark-src.git/dev上提交一个 commit (如图中黑色的 commit 9),且 commit message 包含 bugfix 字样
  • Jenkins 发现该 commit 为 bugfix 后,立即构建,从 spark-src.git/dev 打包生成一个 release 并放进 spark-bin.git/devspark-${ build \# } 文件夹内(如图中第二周与第三周之间上方的的 spark-3 )
  • spark-bin.git/dev 中的 spark 作为 symbolic 指向 spark-${ build \# }
  • Continuous Delivery Solution 2 bugfix

    hot fix

    在生产环境中发现了 prod 版本的 bug 时,修复及集成和交付方案如下

  • spark-src.git/dev 上提交一个 commit(如图中红色的 commit 9),且 commit message 包含 hotfix 字样
  • Jenkins 发现该 commit 为 hotfix 后,立即将 spark-src.git/dev 打包生成 release 并 commit 到 spark-bin.git/devspark-${ build \# } (如图中上方的 spark-3 )文件夹内。 spark 作为 symbolic 指向该 spark-${ build \# }
  • 通过 cherry-pick 将 commit 9 double commit 到 spark-src.git/prod(如无冲突,则该流程全自动完成,无需人工参与。如发生冲突,通过告警系统通知开发人员手工解决冲突后提交)
  • spark-src.git/prod 打包生成 release 并 commit 到 spark-bin.git/prodspark-${ build \# } (如图中下方的 spark-3 )文件夹内。spark作为 symbolic 指向该spark-${ build \# }
  • Continuous Delivery Solution 2 hotfix

    Pros.

  • 无论是 dev 版还是 prod 版,路径都是 spark。切换版对用户透明,无迁移成本
  • 方便灰度发布
  • hotfix 不会引入未经测试的 commit,稳定性更有保障
  • prod 版落后于 dev 版一周(一个 release 周期),即 prod 经过了一个 release 周期的测试,稳定性强
  • Cons.

  • hot fix 时,使用 cherry-pick,但 spark-src.git/dev(包含 commit 1、2、3、4、5) 与 spark-src.git/prod(包含 commit 1) 的 base 不一样,有发生冲突的风险。一旦发生冲突,便需人工介入
  • hot fix 后再从 spark-src.git/dev 合并 commit 到 spark-src.git/prod 时需要使用 rebase 而不能直接 fast-forward merge。而该 rebase 可能再次发生冲突
  • bug fix 修复的是当前 spark-bin.git/dev 的 bug,即图中的 commit 1、2、3、4 后的 bug,而 bug fix commit 即 commit 9 的 base 是 commit 5,存在一定程度的不一致
  • bug fix 后,第 3 周时,最新的 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/devspark-src.git/prod 中包含的 commit 数一致(因为只允许 fast-forward merge),内容也最终一致。但是 commit 顺序不一致,且各 commit 内容也可能不一致。如果维护不当,容易造成两个分支差别越来越大,不易合并
  • 方案三:多分支

    正常流程

    如下图所示,基于多分支的 Spark 持续交付方案如下

  • 正常开发在 spark-src.git/master 上进行
  • 每周一通过 fast-forward merge 将 spark-src.git/master 最新代码合并到 spark-src.git/dev。如下图中,第 2 周将 commit 4 及之前所有 commit 合并到 spark-src.git/dev
  • spark-src.git/dev 打包生成 release 并提交到 spark-bin.git/devspark-${ build \# }(如下图中第 2 周的 spark-2) 文件夹内。spark 作为 symbolic,指向该 spark-${ build \# }
  • 每周一通过 fast-forward merge 将 spark-src.git/master 一周前最后一个 commit 合并到 spark-src.git/prod。如第 3 周合并 commit 4 及之前的 commit
  • 上一步中,如果 commit 4 后紧临有一个或多个 bugfix commit,均需合并到 spark-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/prodspark-${ build \# }(如下图中第 2 周的 spark-2) 文件夹内。spark 作为 symbolic,指向该 spark-${ build \# }
  • Continuous Delivery Solution 3

    bug fix

    在 Staging 环境中发现了 dev 版本的 bug 时,修复及集成和交付方案如下

  • 如下图中,第 2 周与第 3 周之间在 Staging 环境中发现 dev 版本的 bug,在 spark-src.git/dev(包含 commit 1、2、3、4) 上提交一个 commit(如图中黑色的 commit 9),且 commit message 中包含 bugfix 字样
  • Jenkins 发现该 bugfix 的 commit 后立即执行构建,将 spark-src.git/dev 打包生成 release 并提交到 spark-bin.git/devspark-${ 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 发生冲突,通过告警通知开发人员人工介入处理冲突
  • 在一个 release 周期内,如发现多个 dev 版本的 bug,都可按上述方式进行 bug fix,且这几个 bug fix 的 commit 在 spark-src.git/dev 上顺序相连。因此它们被 rebase 到 spark-src.git/master 后仍然顺序相连
  • Continuous Delivery Solution 3 bugfix

    hot fix

    在生产环境中发现了 prod 版本的 bug 时,修复及集成和交付方案如下

  • spark-src.git/prod 中提交一个 commit,且其 commit message 中包含 hotfix 字样
  • Jenkins 发现该 commit 为 hotfix,立即执行构建,将 spark-src.git/prod 打包生成 release 并提交到 spark-bin.git/prodspark-${ build \# }(如图中的 spark-3) 文件夹内,spark 作为 symbolic,指向该 spark-${ build \# }
  • 通过 git checkout master 切换到 spark-src.git/master,再通过 git rebase prod 将 hotfix rebase 到 spark-src.git/master
  • 在一个 release 周期内,如发现多个 prod 版本的 bug,都可按上述方式进行 hot fix
  • Continuous Delivery Solution 3 hotfix

    灰度发布

    本文介绍的实践中,不考虑多个版本(经实践检验,多个版本维护成本太高,且一般无必要),只考虑一个 prod 版本,一个 dev 版本

    上文介绍的持续发布中,可将 spark-bin.git/dev 部署至需要使用最新版的环境中(不一定是 Staging 环境,可以是部分生产环境)从而实现 dev 版的部署。将 spark-bin.git/prod 部署至需要使用稳定版的 prod 环境中

    回滚机制

    本文介绍的方法中,所有 release 都放到 spark-${ build \# } 中,由 spark 这一 symbolic 选择指向具体哪个 release。因此回滚方式比较直观

  • 对于同一个大版本(dev 或者 prod)的回滚,只需将 spark 指向 build # 较小的 release 即可
  • 如果是将部分环境中的 prod 版迁至 dev 版(或者 dev 版改为 prod 版)后,需要回滚,只需将 dev 改回 prod 版(或者将 prod 版改回 dev 版)即可
  • Pros.

  • 正常开发在 spark-src.git/master 上进行,Staging 环境的 bug fix 在 spark-src.git/dev 上进行,生产环境的 hot fix 在 spark-src.git/prod 上进行,清晰明了
  • bug fix 提交时的 code base 与 Staging 环境使用版本的 code 完全一致,从而可保证 bug fix 的正确性
  • bug fix 合并回 spark-src.git/master 时使用 rebase,从而保证了 spark-src.git/devspark-src.git/master 所有 commit 的顺序与内容的一致性,进而保证了这两个 branch 的一致性
  • hot fix 提交时的 code base 与 生产环境使用版本的 code 完全一致,从而可保证 hot fix 的正确性
  • hot fix 合并回 spark-src.git/master 时使用 rebase,从而保证了 spark-src.git/devspark-src.git/master 所有 commit 的顺序性及内容的一致性,进而保证了这两个 branch 的一致性
  • 开发人员只需要专注于新 feature 的开发,bug fix 的提交,与 hot fix 的提交。所有的版本维护工作全部自动完成。只有当 bug fix 或 hot fix rebase 回 spark-src.git/master 发生冲突时才需人工介入
  • spark-bin.git/devspark-bin.git/prod 将开发版本与生产版本分开,方便独立部署。而其路径统一,方便版本切换与灰度发布
  • Cons.

  • 在本地 spark-src.git/master 提交时,须先 rebase 远程分支,而不应直接使用 merge。在本方案中,这不仅是最佳实践,还是硬性要求
  • 虽然 bug fix 与 hot fix commit 都通过 rebase 进入 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 CD 持续部署

    持续部署是指,软件通过评审后,自动部署到生产环境中
    Continuous Deploy

    上述 Spark 持续发布实践的介绍都只到 “将 *** 提交到 spark-bin.git“ 结束。可使用基于 git 的部署(为了性能和扩展性,一般不直接在待部署机器上使用 git pull –rebase,而是使用自研的上线方案,此处不展开)将该 release 上线到 Staging 环境或生产环境

    该自动上线过程即是 Spark 持续部署的最后一环

    Spark 系列文章

  • Spark性能优化之道——解决Spark数据倾斜(Data Skew)的N种姿势
  • Spark SQL / Catalyst 内部原理 与 RBO
  • Spark SQL 性能优化再进一步 CBO 基于代价的优化
  • Spark CommitCoordinator 保证数据一致性
  • Spark 灰度发布的参考方法 CI CD
  • ]]>
    本文介绍 Spark 的 CI 与 CD & CD 参考方法。包含如何维护源代码,如何维护 Release 多版本,开发版与正式版,以及如何实现灰度发布,如何进行 hotfix。
    技术世界 http://www.jasongj.com/spark/committer/ 2018-09-25T23:20:13.000Z 2018-09-25T23:20:13.000Z

    原创文章,转载请务必将下面这段话置于文章开头处。
    本文转发自卡塔尔世界杯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 时,需要解决如下问题:

  • 由于多个 Task 同时写数据到 HDFS,如何保证要么所有 Task 写的所有文件要么同时对外可见,要么同时对外不可见,即保证数据一致性
  • 同一 Task 可能因为 Speculation 而存在两个完全相同的 Task 实例写相同的数据到 HDFS中,如何保证只有一个 commit 成功
  • 对于大 Job(如具有几万甚至几十万 Task),如何高效管理所有文件
  • commit 原理

    本文通过 Local mode 执行如下 Spark 程序详解 commit 原理

    1
    2
    3
    sparkContext.textFile("/json/input.zstd")
    .map(_.split(","))
    .saveAsTextFile("/jason/test/tmp")

    在详述 commit 原理前,需要说明几个述语

  • Task,即某个 Application 的某个 Job 内的某个 Stage 的一个 Task
  • TaskAttempt,Task 每次执行都视为一个 TaskAttempt。对于同一个 Task,可能同时存在多个 TaskAttemp
  • Application Attempt,即 Application 的一次执行
  • 在本文中,会使用如下缩写

  • ${output.dir.root} 即输出目录根路径
  • ${appAttempt} 即 Application Attempt ID,为整型,从 0 开始
  • ${taskAttemp} 即 Task Attetmp ID,为整型,从 0 开始
  • 检查 Job 输出目录

    在启动 Job 之前,Driver 首先通过 FileOutputFormat 的 checkOutputSpecs 方法检查输出目录是否已经存在。若已存在,则直接抛出 FileAlreadyExistsException
    Check output path

    Driver执行setupJob

    Job 开始前,由 Driver(本例使用 local mode,因此由 main 线程执行)调用 FileOuputCommitter.setupJob 创建 Application Attempt 目录,即 ${output.dir.root}/_temporary/${appAttempt}
    Setup job

    Task执行setupTask

    由各 Task 执行 FileOutputCommitter.setupTask 方法(本例使用 local mode,因此由 task 线程执行)。该方法不做任何事情,因为 Task 临时目录由 Task 按需创建。
    Setup task

    按需创建 Task 目录

    本例中,Task 写数据需要通过 TextOutputFormatgetRecordWriter 方法创建 LineRecordWriter。而创建前需要通过 FileOutputFormat.getTaskOutputPath设置 Task 输出路径,即 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}/${fileName}。该 Task Attempt 所有数据均写在该目录下的文件内
    Create task output file

    检查是否需要 commit

    Task 执行数据写完后,通过 FileOutputCommitter.needsTaskCommit 方法检查是否需要 commit 它写在 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt} 下的数据。

    检查依据是 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt} 目录是否存在
    Need commmit task

    如果需要 commit,并且开启了 Output commit coordination,还需要通过 RPC 由 Driver 侧的 OutputCommitCoordinator 判断该 Task Attempt 是否可以 commit
    Need commmit task detail

    之所以需要由 Driver 侧的 CommitCoordinator 判断是否可以 commit,是因为可能由于 speculation 或者其它原因(如之前的 TaskAttemp 未被 Kill 成功)存在同一 Task 的多个 Attemp 同时写数据且都申请 commit 的情况。

    CommitCoordinator

    当申请 commitTask 的 TaskAttempt 为失败的 Attempt,则直接拒绝

    若该 TaskAttempt 成功,并且 CommitCoordinator 未允许过该 Task 的其它 Attempt 的 commit 请求,则允许该 TaskAttempt 的 commit 请求

    若 CommitCoordinator 之前已允许过该 TaskAttempt 的 commit 请求,则继续同意该 TaskAttempt 的 commit 请求,即 CommitCoordinator 对该申请的处理是幂等的。

    若该 TaskAttempt 成功,且 CommitCoordinator 之前已允许该 Task 的其它 Attempt 的 commit 请求,则直接拒绝当前 TaskAttempt 的 commit 请求
    Coordinator handle request

    OutputCommitCoordinator 为了实现上述功能,为每个 ActiveStage 维护一个如下 StageState

    1
    2
    3
    4
    private 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

    commitTask

    当 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}
    Commit task v1

    mapreduce.fileoutputcommitter.algorithm.version 的值为 2,直接将taskAttemptPath 即 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt} 内的所有文件移动到 outputPath 即 ${output.dir.root}/
    Commit task v2

    commitJob

    当所有 Task 都执行成功后,由 Driver (本例由于使用 local model,故由 main 线程执行)执行 FileOutputCommitter.commitJob

    mapreduce.fileoutputcommitter.algorithm.version 的值为 1,则由 Driver 单线程遍历所有 committedTaskPath 即 ${output.dir.root}/_temporary/${appAttempt}/${taskAttempt},并将其下所有文件移动到 finalOutput 即 ${output.dir.root}
    Commit job v1

    mapreduce.fileoutputcommitter.algorithm.version 的值为 2,则无须移动任何文件。因为所有 Task 的输出文件已在 commitTask 内被移动到 finalOutput 即 ${output.dir.root}
    Commit job v2

    所有 commit 过的 Task 输出文件移动到 finalOutput 即 ${output.dir.root} 后,Driver 通过 cleanupJob 删除 ${output.dir.root}/_temporary/ 下所有内容
    Cleanup job

    recoverTask

    上文所述的 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}
    Recover task

    abortTask

    中止 Task 时,由 Task 调用 FileOutputCommitter.abortTask 方法删除 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
    Abort task

    abortJob

    中止 Job 由 Driver 调用 FileOutputCommitter.abortJob 方法完成。该方法通过 FileOutputCommitter.cleanupJob 方法删除 ${output.dir.root}/_temporary

    总结

    V1 vs. V2 committer 过程

    V1 committer(即 mapreduce.fileoutputcommitter.algorithm.version 的值为 1),commit 过程如下

  • Task 线程将 TaskAttempt 数据写入 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
  • commitTask 由 Task 线程将 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt} 移动到 ${output.dir.root}/_temporary/${appAttempt}/${taskAttempt}
  • commitJob 由 Driver 单线程依次将所有 ${output.dir.root}/_temporary/${appAttempt}/${taskAttempt} 移动到 ${output.dir.root},然后创建 _SUCCESS 标记文件
  • recoverTask 由 Task 线程将 ${output.dir.root}/_temporary/${preAppAttempt}/${preTaskAttempt} 移动到 ${output.dir.root}/_temporary/${appAttempt}/${taskAttempt}
  • V2 committer(即 mapreduce.fileoutputcommitter.algorithm.version 的值为 2),commit 过程如下

  • Task 线程将 TaskAttempt 数据写入 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt}
  • commitTask 由 Task 线程将 ${output.dir.root}/_temporary/${appAttempt}/_temporary/${taskAttempt} 移动到 ${output.dir.root}
  • commitJob 创建 _SUCCESS 标记文件
  • recoverTask 无需任何操作
  • V1 vs. V2 committer 性能对比

    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 vs. V2 committer 一致性对比

    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 更易发生数据一致性问题

    Spark 系列文章

  • Spark性能优化之道——解决Spark数据倾斜(Data Skew)的N种姿势
  • Spark SQL / Catalyst 内部原理 与 RBO
  • Spark SQL 性能优化再进一步 CBO 基于代价的优化
  • Spark CommitCoordinator 保证数据一致性
  • Spark 灰度发布在十万级节点上的成功实践 CI CD
  • ]]>
    本文结合实例介绍了 Spark 保证数据一致性的方法,OutputCommitter原理,CommitCoordinator原理,以及 fileoutputcommitter.algorithm v1 与 v2 的原理与区别
    技术世界 http://www.jasongj.com/spark/cbo/ 2018-09-16T23:02:13.000Z 2018-09-16T23:02:13.000Z

    原创文章,转载请务必将下面这段话置于文章开头处。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/spark/cbo/

    本文所述内容均基于 2018年9月17日 Spark 最新 Release 2.3.1 版本。后续将持续更新

    Spark CBO 背景

    上文Spark SQL 内部原理中介绍的 Optimizer 属于 RBO,实现简单有效。它属于 LogicalPlan 的优化,所有优化均基于 LogicalPlan 本身的特点,未考虑数据本身的特点,也未考虑算子本身的代价。

    本文将介绍 CBO,它充分考虑了数据本身的特点(如大小、分布)以及操作算子的特点(中间结果集的分布及大小)及代价,从而更好的选择执行代价最小的物理执行计划,即 SparkPlan。

    Spark CBO 原理

    CBO 原理是计算所有可能的物理计划的代价,并挑选出代价最小的物理执行计划。其核心在于评估一个给定的物理执行计划的代价。

    物理执行计划是一个树状结构,其代价等于每个执行节点的代价总合,如下图所示。


    CBO 总代价

    而每个执行节点的代价,分为两个部分

  • 该执行节点对数据集的影响,或者说该节点输出数据集的大小与分布
  • 该执行节点操作算子的代价
  • 每个操作算子的代价相对固定,可用规则来描述。而执行节点输出数据集的大小与分布,分为两个部分:1) 初始数据集,也即原始表,其数据集的大小与分布可直接通过统计得到;2)中间节点输出数据集的大小与分布可由其输入数据集的信息与操作本身的特点推算。

    所以,最终主要需要解决两个问题

  • 如何获取原始数据集的统计信息
  • 如何根据输入数据集估算特定算子的输出数据集
  • Statistics 收集

    通过如下 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
    13
    spark-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 的原因有二

  • 使用 HyperLogLog 计算 distinct count 速度快速
  • HyperLogLog 计算出的 distinct count 可以合并。例如可以直接将两个 bin 的 HyperLogLog 值合并算出这两个 bin 总共的 distinct count,而无须从重新计算,且合并结果的误差可控
  • 算子对数据集影响估计

    对于中间算子,可以根据输入数据集的统计信息以及算子的特性,可以估算出输出数据集的统计结果。


    Spark SQL CBO Operator Estimation

    本节以 Filter 为例说明算子对数据集的影响。

    对于常见的 Column A < value B Filter,可通过如下方式估算输出中间结果的统计信息

  • 若 B < A.min,则无数据被选中,输出结果为空
  • 若 B > A.max,则全部数据被选中,输出结果与 A 相同,且统计信息不变
  • 若 A.min < B < A.max,则被选中的数据占比为 (B.value - A.min) / (A.max - A.min),A.min 不变,A.max 更新为 B.value,A.ndv = A.ndv * (B.value - A.min) / (A.max - A.min)

  • Spark SQL CBO Filter Estimation



    上述估算的前提是,字段 A 数据均匀分布。但很多时候,数据分布并不均匀,且当数据倾斜严重是,上述估算误差较大。此时,可充分利用 histogram 进行更精确的估算


    Spark SQL CBO Filter Estimation with Histogram

    启用 Historgram 后,Filter Column A < value B的估算方法为

  • 若 B < A.min,则无数据被选中,输出结果为空
  • 若 B > A.max,则全部数据被选中,输出结果与 A 相同,且统计信息不变
  • 若 A.min < B < A.max,则被选中的数据占比为 height(<B) / height(All),A.min 不变,A.max = B.value,A.ndv = ndv(<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
    2
    Cost = rows * weight + size * (1 - weight)
    Cost = CostCPU * weight + CostIO * (1 - weight)

    其中 rows 即记录行数代表了 CPU 代价,size 代表了 IO 代价。weight 由 spark.sql.cbo.joinReorder.card.weight 决定,其默认值为 0.7。

    Build侧选择

    对于两表Hash Join,一般选择小表作为build size,构建哈希表,另一边作为 probe side。未开启 CBO 时,根据表原始数据大小选择 t2 作为build side


    Spark SQL build side without CBO

    而开启 CBO 后,基于估计的代价选择 t1 作为 build side。更适合本例


    Spark SQL build side with CBO

    优化 Join 类型

    在 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。


    Spark SQL join type selection with CBO

    优化多表 Join 顺序

    未开启 CBO 时,Spark SQL 按 SQL 中 join 顺序进行 Join。极端情况下,整个 Join 可能是 left-deep tree。在下图所示 TPC-DS Q25 中,多路 Join 存在如下问题,因此耗时 241 秒。

  • left-deep tree,因此所有后续 Join 都依赖于前面的 Join 结果,各 Join 间无法并行进行
  • 前面的两次 Join 输入输出数据量均非常大,属于大 Join,执行时间较长

  • Spark SQL multi join

    开启 CBO 后, Spark SQL 将执行计划优化如下


    Spark SQL multi join reorder with CBO

    优化后的 Join 有如下优势,因此执行时间降至 71 秒

  • Join 树不再是 left-deep tree,因此 Join 3 与 Join 4 可并行进行,Join 5 与 Join 6 可并行进行
  • 最大的 Join 5 输出数据只有两百万条结果,Join 6 有 1.49 亿条结果,Join 7相当于小 Join
  • Spark 系列文章

  • Spark性能优化之道——解决Spark数据倾斜(Data Skew)的N种姿势
  • Spark SQL / Catalyst 内部原理 与 RBO
  • Spark SQL 性能优化再进一步 CBO 基于代价的优化
  • Spark CommitCoordinator 保证数据一致性
  • Spark 灰度发布在十万级节点上的成功实践 CI CD
  • ]]>
    上文介绍的 RBO 属于逻辑计划的优化,只考虑查询,未考虑数据本身的特点。本文将介绍 CBO 如何利用数据本身的特点优化物理执行计划。
    技术世界 http://www.jasongj.com/spark/rbo/ 2018-09-09T23:02:13.000Z 2018-09-09T23:02:13.000Z

    原创文章,转载请务必将下面这段话置于文章开头处。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/spark/rbo/

    本文所述内容均基于 2018年9月10日 Spark 最新 Release 2.3.1 版本。后续将持续更新

    Spark SQL 架构

    Spark SQL 的整体架构如下图所示
    Spark SQL Catalyst

    从上图可见,无论是直接使用 SQL 语句还是使用 DataFrame,都会经过如下步骤转换成 DAG 对 RDD 的操作

  • Parser 解析 SQL,生成 Unresolved Logical Plan
  • 由 Analyzer 结合 Catalog 信息生成 Resolved Logical Plan
  • Optimizer根据预先定义好的规则对 Resolved Logical Plan 进行优化并生成 Optimized Logical Plan
  • Query Planner 将 Optimized Logical Plan 转换成多个 Physical Plan
  • CBO 根据 Cost Model 算出每个 Physical Plan 的代价并选取代价最小的 Physical Plan 作为最终的 Physical Plan
  • Spark 以 DAG 的方法执行上述 Physical Plan
  • 在执行 DAG 的过程中,Adaptive Execution 根据运行时信息动态调整执行计划从而提高执行效率
  • Parser

    Spark SQL 使用 Antlr 进行记法和语法解析,并生成 UnresolvedPlan。

    当用户使用 SparkSession.sql(sqlText : String) 提交 SQL 时,SparkSession 最终会调用 SparkSqlParser 的 parsePlan 方法。该方法分两步

  • 使用 Antlr 生成的 SqlBaseLexer 对 SQL 进行词法分析,生成 CommonTokenStream
  • 使用 Antlr 生成的 SqlBaseParser 进行语法分析,得到 LogicalPlan
  • 现在两张表,分别定义如下

    1
    2
    3
    4
    5
    CREATE 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
    9
    SELECT 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 Parser

    从上图可见

  • 查询涉及的两张表,被解析成了两个 UnresolvedRelation,也即只知道这们是两张表,却并不知道它们是 EXTERNAL TABLE 还是 MANAGED TABLE,也不知道它们的数据存在哪儿,更不知道它们的表结构如何
  • sum(v) 的结果未命名
  • Project 部分只知道是选择出了属性,却并不知道这些属性属于哪张表,更不知道其数据类型
  • Filter 部分也不知道数据类型
  • 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

    从 Analyzer 的构造方法可见

  • Analyzer 持有一个 SessionCatalog 对象的引用
  • Analyzer 继承自 RuleExecutor[LogicalPlan],因此可对 LogicalPlan 进行转换
  • 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
    56
    lazy 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 对比如下

    Spark SQL Analyzer

    由上图可见,分析后,每张表对应的字段集,字段类型,数据存储位置都已确定。Project 与 Filter 操作的字段类型以及在表中的位置也已确定。

    有了这些信息,已经可以直接将该 LogicalPlan 转换为 Physical Plan 进行执行。

    但是由于不同用户提交的 SQL 质量不同,直接执行会造成不同用户提交的语义相同的不同 SQL 执行效率差距甚远。换句话说,如果要保证较高的执行效率,用户需要做大量的 SQL 优化,使用体验大大降低。

    为了尽可能保证无论用户是否熟悉 SQL 优化,提交的 SQL 质量如何, Spark SQL 都能以较高效率执行,还需在执行前进行 LogicalPlan 优化。

    Optimizer

    Spark SQL 目前的优化主要是基于规则的优化,即 RBO (Rule-based optimization)

  • 每个优化以 Rule 的形式存在,每条 Rule 都是对 Analyzed Plan 的等价转换
  • RBO 设计良好,易于扩展,新的规则可以非常方便地嵌入进 Optimizer
  • RBO 目前已经足够好,但仍然需要更多规则来 cover 更多的场景
  • 优化思路主要是减少参与计算的数据量以及计算本身的代价
  • PushdownPredicate
    PushdownPredicate 是最常见的用于减少参与计算的数据量的方法。

    前文中直接对两表进行 Join 操作,然后再 进行 Filter 操作。引入 PushdownPredicate 后,可先对两表进行 Filter 再进行 Join,如下图所示。

    Spark SQL RBO Predicate Pushdown

    当 Filter 可过滤掉大部分数据时,参与 Join 的数据量大大减少,从而使得 Join 操作速度大大提高。

    这里需要说明的是,此处的优化是 LogicalPlan 的优化,从逻辑上保证了将 Filter 下推后由于参与 Join 的数据量变少而提高了性能。另一方面,在物理层面,Filter 下推后,对于支持 Filter 下推的 Storage,并不需要将表的全量数据扫描出来再过滤,而是直接只扫描符合 Filter 条件的数据,从而在物理层面极大减少了扫描表的开销,提高了执行速度。

    ConstantFolding
    本文的 SQL 查询中,Project 部分包含了 100 + 800 + match_score + english_score 。如果不进行优化,那如果有一亿条记录,就会计算一亿次 100 + 80,非常浪费资源。因此可通过 ConstantFolding 将这些常量合并,从而减少不必要的计算,提高执行速度。

    Spark SQL RBO Constant Folding

    ColumnPruning
    在上图中,Filter 与 Join 操作会保留两边所有字段,然后在 Project 操作中筛选出需要的特定列。如果能将 Project 下推,在扫描表时就只筛选出满足后续操作的最小字段集,则能大大减少 Filter 与 Project 操作的中间结果集数据量,从而极大提高执行速度。

    Spark SQL RBO Column Pruning

    这里需要说明的是,此处的优化是逻辑上的优化。在物理上,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]

    SparkPlanner

    得到优化后的 LogicalPlan 后,SparkPlanner 将其转化为 SparkPlan 即物理计划。

    本例中由于 score 表数据量较小,Spark 使用了 BroadcastJoin。因此 score 表经过 Filter 后直接使用 BroadcastExchangeExec 将数据广播出去,然后结合广播数据对 people 表使用 BroadcastHashJoinExec 进行 Join。再经过 Project 后使用 HashAggregateExec 进行分组聚合。

    Spark SQL RBO Column Pruning

    至此,一条 SQL 从提交到解析、分析、优化以及执行的完整过程就介绍完毕。

    本文介绍的 Optimizer 属于 RBO,实现简单有效。它属于 LogicalPlan 的优化,所有优化均基于 LogicalPlan 本身的特点,未考虑数据本身的特点,也未考虑算子本身的代价。下文将介绍 CBO,它充分考虑了数据本身的特点(如大小、分布)以及操作算子的特点(中间结果集的分布及大小)及代价,从而更好的选择执行代价最小的物理执行计划,即 SparkPlan。

    Spark 系列文章

  • Spark性能优化之道——解决Spark数据倾斜(Data Skew)的N种姿势
  • Spark SQL / Catalyst 内部原理 与 RBO
  • Spark SQL 性能优化再进一步 CBO 基于代价的优化
  • Spark CommitCoordinator 保证数据一致性
  • Spark 灰度发布在十万级节点上的成功实践 CI CD
  • ]]>
    本文结合案例详述了 Spark SQL 的工作原理,包括但不限于 Parser,Analyzer,Optimizer,Rule-based optimization等内容。
    技术世界 http://www.jasongj.com/java/threadlocal/ 2017-12-19T07:42:26.000Z 2017-12-19T07:43:26.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/java/threadlocal/

    ThreadLocal解决什么问题

    由于 ThreadLocal 支持范型,如 ThreadLocal< StringBuilder >,为表述方便,后文用 变量 代表 ThreadLocal 本身,而用 实例 代表具体类型(如 StringBuidler )的实例。

    不恰当的理解

    写这篇文章的一个原因在于,网上很多博客关于 ThreadLocal 的适用场景以及解决的问题,描述的并不清楚,甚至是错的。下面是常见的对于 ThreadLocal的介绍

    ThreadLocal为解决多线程程序的并发问题提供了一种新的思路
    ThreadLocal的目的是为了解决多线程访问资源时的共享问题

    还有很多文章在对比 ThreadLocal 与 synchronize 的异同。既然是作比较,那应该是认为这两者解决相同或类似的问题。

    上面的描述,问题在于,ThreadLocal 并不解决多线程 共享 变量的问题。既然变量不共享,那就更谈不上同步的问题。

    合理的理解

    ThreadLoal 变量,它的基本原理是,同一个 ThreadLocal 所包含的对象(对ThreadLocal< String >而言即为 String 类型变量),在不同的 Thread 中有不同的副本(实际是不同的实例,后文会详细阐述)。这里有几点需要注意

  • 因为每个 Thread 内有自己的实例副本,且该副本只能由当前 Thread 使用。这是也是 ThreadLocal 命名的由来
  • 既然每个 Thread 有自己的实例副本,且其它 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用法

    实例代码

    下面通过如下代码说明 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
    58
    public 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>() {
    @Override
    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
    15
    Thread 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

    从上面的输出可看出

  • 从第1-3行输出可见,每个线程通过 ThreadLocal 的 get() 方法拿到的是不同的 StringBuilder 实例
  • 第1-3行输出表明,每个线程所访问到的是同一个 ThreadLocal 变量
  • 从7、12、13行输出以及第30行代码可见,虽然从代码上都是对 Counter 类的静态 counter 字段进行 get() 得到 StringBuilder 实例并追加字符串,但是这并不会将所有线程追加的字符串都放进同一个 StringBuilder 中,而是每个线程将字符串追加进各自的 StringBuidler 实例内
  • 对比第1行与第15行输出并结合第38行代码可知,使用 set(T t) 方法后,ThreadLocal 变量所指向的 StringBuilder 实例被替换
  • ThreadLocal原理

    ThreadLocal维护线程与实例的映射

    既然每个访问 ThreadLocal 变量的线程都有自己的一个“本地”实例副本。一个可能的方案是 ThreadLocal 维护一个 Map,键是 Thread,值是它在该 Thread 内的实例。线程通过该 ThreadLocal 的 get() 方案获取实例时,只需要以线程为键,从 Map 中找出对应的实例即可。该方案如下图所示


    ThreadLocal side Map

    该方案可满足上文提到的每个线程内一个独立备份的要求。每个新线程访问该 ThreadLocal 时,需要向 Map 中添加一个映射,而每个线程结束时,应该清除该映射。这里就有两个问题:

  • 增加线程与减少线程均需要写 Map,故需保证该 Map 线程安全。虽然从ConcurrentHashMap的演进看Java多线程核心技术一文介绍了几种实现线程安全 Map 的方式,但它或多或少都需要锁来保证线程的安全性
  • 线程结束时,需要保证它所访问的所有 ThreadLocal 中对应的映射均删除,否则可能会引起内存泄漏。(后文会介绍避免内存泄漏的方法)
  • 其中锁的问题,是 JDK 未采用该方案的一个原因。

    Thread维护ThreadLocal与实例的映射

    上述方案中,出现锁的问题,原因在于多线程访问同一个 Map。如果该 Map 由 Thread 维护,从而使得每个 Thread 只访问自己的 Map,那就不存在多线程写的问题,也就不需要锁。该方案如下图所示。


    ThreadLocal side Map

    该方案虽然没有锁的问题,但是由于每个线程访问某 ThreadLocal 变量后,都会在自己的 Map 内维护该 ThreadLocal 变量与具体实例的映射,如果不删除这些引用(映射),则这些 ThreadLocal 不能被回收,可能会造成内存泄漏。后文会介绍 JDK 如何解决该问题。

    ThreadLocal 在 JDK 8 中的实现

    ThreadLocalMap与内存泄漏

    该方案中,Map 由 ThreadLocal 类的静态内部类 ThreadLocalMap 提供。该类的实例维护某个 ThreadLocal 与具体实例的映射。与 HashMap 不同的是,ThreadLocalMap 的每个 Entry 都是一个对 的弱引用,这一点从super(k)可看出。另外,每个 Entry 都包含了一个对 的强引用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    static 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
    13
    public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
    ThreadLocalMap.Entry e = map.getEntry(this);
    if (e != null) {
    @SuppressWarnings("unchecked")
    T result = (T)e.value;
    return result;
    }
    }
    return setInitialValue();
    }

    读取实例时,线程首先通过getMap(t)方法获取自身的 ThreadLocalMap。从如下该方法的定义可见,该 ThreadLocalMap 的实例是 Thread 类的一个字段,即由 Thread 维护 ThreadLocal 对象与具体实例的映射,这一点与上文分析一致。

    1
    2
    3
    ThreadLocalMap 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
    10
    private 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
    8
    public 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
    21
    private 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
    36
    public class SessionHandler {

    @Data
    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
    37
    public class SessionHandler {

    public static ThreadLocal<Session> session = new ThreadLocal<Session>();

    @Data
    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再合适不过。

    总结

  • ThreadLocal 并不解决线程间共享数据的问题
  • ThreadLocal 通过隐式的在不同线程内创建独立实例副本避免了实例线程安全的问题
  • 每个线程持有一个 Map 并维护了 ThreadLocal 对象与具体实例的映射,该 Map 由于只被持有它的线程访问,故不存在线程安全以及锁的问题
  • ThreadLocalMap 的 Entry 对 ThreadLocal 的引用为弱引用,避免了 ThreadLocal 对象无法被回收的问题
  • ThreadLocalMap 的 set 方法通过调用 replaceStaleEntry 方法回收键为 null 的 Entry 对象的值(即为具体实例)以及 Entry 对象本身从而防止内存泄漏
  • ThreadLocal 适用于变量在线程间隔离且在方法间共享的场景
  • Java进阶系列

  • Java进阶(一)Annotation(注解)
  • Java进阶(二)当我们说线程安全时,到底在说什么
  • Java进阶(三)多线程开发关键技术
  • Java进阶(四)线程间通信方式对比
  • Java进阶(五)NIO和Reactor模式进阶
  • Java进阶(六)从ConcurrentHashMap的演进看Java多线程核心技术
  • Java进阶(七)正确理解Thread Local的原理与适用场景
  • ]]>
    本文结合实例介绍了 Thread Local 的原理与实现方法,并分析了其适用场景。
    技术世界 http://www.jasongj.com/zookeeper/distributedlock/ 2017-12-05T09:35:01.000Z 2017-12-05T09:35:01.000Z

    原创文章,转载请务必将下面这段话置于文章开头处。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/zookeeper/distributedlock/

    Zookeeper特点

    Zookeeper节点类型

    如上文《Zookeeper架构及FastLeaderElection机制》所述,Zookeeper 提供了一个类似于 Linux 文件系统的树形结构。该树形结构中每个节点被称为 znode ,可按如下两个维度分类

  • Persist vs. Ephemeral
  • Persist节点,一旦被创建,便不会意外丢失,即使服务器全部重启也依然存在。每个 Persist 节点即可包含数据,也可包含子节点
  • Ephemeral节点,在创建它的客户端与服务器间的 Session 结束时自动被删除。服务器重启会导致 Session 结束,因此 Ephemeral 类型的 znode 此时也会自动删除
  • Sequence vs. Non-sequence
  • Non-sequence节点,多个客户端同时创建同一 Non-sequence 节点时,只有一个可创建成功,其它匀失败。并且创建出的节点名称与创建时指定的节点名完全一样
  • Sequence节点,创建出的节点名在指定的名称之后带有10位10进制数的序号。多个客户端创建同一名称的节点时,都能创建成功,只是序号不同
  • Zookeeper语义保证

    Zookeeper 简单高效,同时提供如下语义保证,从而使得我们可以利用这些特性提供复杂的服务。

  • 顺序性 客户端发起的更新会按发送顺序被应用到 Zookeeper 上
  • 原子性 更新操作要么成功要么失败,不会出现中间状态
  • 单一系统镜像 一个客户端无论连接到哪一个服务器都能看到完全一样的系统镜像(即完全一样的树形结构)。注:根据上文《Zookeeper架构及FastLeaderElection机制》介绍的 ZAB 协议,写操作并不保证更新被所有的 Follower 立即确认,因此通过部分 Follower 读取数据并不能保证读到最新的数据,而部分 Follwer 及 Leader 可读到最新数据。如果一定要保证单一系统镜像,可在读操作前使用 sync 方法。
  • 可靠性 一个更新操作一旦被接受即不会意外丢失,除非被其它更新操作覆盖
  • 最终一致性 写操作最终(而非立即)会对客户端可见
  • Zookeeper Watch机制

    所有对 Zookeeper 的读操作,都可附带一个 Watch 。一旦相应的数据有变化,该 Watch 即被触发。Watch 有如下特点

  • 主动推送 Watch被触发时,由 Zookeeper 服务器主动将更新推送给客户端,而不需要客户端轮询。
  • 一次性 数据变化时,Watch 只会被触发一次。如果客户端想得到后续更新的通知,必须要在 Watch 被触发后重新注册一个 Watch。
  • 可见性 如果一个客户端在读请求中附带 Watch,Watch 被触发的同时再次读取数据,客户端在得到 Watch 消息之前肯定不可能看到更新后的数据。换句话说,更新通知先于更新结果。
  • 顺序性 如果多个更新触发了多个 Watch ,那 Watch 被触发的顺序与更新顺序一致。
  • 分布式锁与领导选举关键点

    最多一个获取锁 / 成为Leader

    对于分布式锁(这里特指排它锁)而言,任意时刻,最多只有一个进程(对于单进程内的锁而言是单线程)可以获得锁。

    对于领导选举而言,任意时间,最多只有一个成功当选为Leader。否则即出现脑裂(Split brain)

    锁重入 / 确认自己是Leader

    对于分布式锁,需要保证获得锁的进程在释放锁之前可再次获得锁,即锁的可重入性。

    对于领导选举,Leader需要能够确认自己已经获得领导权,即确认自己是Leader。

    释放锁 / 放弃领导权

    锁的获得者应该能够正确释放已经获得的锁,并且当获得锁的进程宕机时,锁应该自动释放,从而使得其它竞争方可以获得该锁,从而避免出现死锁的状态。

    领导应该可以主动放弃领导权,并且当领导所在进程宕机时,领导权应该自动释放,从而使得其它参与者可重新竞争领导而避免进入无主状态。

    感知锁释放 / 领导权的放弃

    当获得锁的一方释放锁时,其它对于锁的竞争方需要能够感知到锁的释放,并再次尝试获取锁。

    原来的Leader放弃领导权时,其它参与方应该能够感知该事件,并重新发起选举流程。

    非公平领导选举

    从上面几个方面可见,分布式锁与领导选举的技术要点非常相似,实际上其实现机制也相近。本章就以领导选举为例来说明二者的实现原理,分布式锁的实现原理也几乎一致。

    选主过程

    假设有三个Zookeeper的客户端,如下图所示,同时竞争Leader。这三个客户端同时向Zookeeper集群注册EphemeralNon-sequence类型的节点,路径都为/zkroot/leader(工程实践中,路径名可自定义)。


    Unfair Leader Election

    如上图所示,由于是Non-sequence节点,这三个客户端只会有一个创建成功,其它节点均创建失败。此时,创建成功的客户端(即上图中的Client 1)即成功竞选为 Leader 。其它客户端(即上图中的Client 2Client 3)此时匀为 Follower。

    放弃领导权

    如果 Leader 打算主动放弃领导权,直接删除/zkroot/leader节点即可。

    如果 Leader 进程意外宕机,其与 Zookeeper 间的 Session 也结束,该节点由于是Ephemeral类型的节点,因此也会自动被删除。

    此时/zkroot/leader节点不复存在,对于其它参与竞选的客户端而言,之前的 Leader 已经放弃了领导权。

    感知领导权的放弃

    由上图可见,创建节点失败的节点,除了成为 Follower 以外,还会向/zkroot/leader注册一个 Watch ,一旦 Leader 放弃领导权,也即该节点被删除,所有的 Follower 会收到通知。

    重新选举

    感知到旧 Leader 放弃领导权后,所有的 Follower 可以再次发起新一轮的领导选举,如下图所示。


    Unfair Leader Reelection

    从上图中可见

  • 新一轮的领导选举方法与最初的领导选举方法完全一样,都是发起节点创建请求,创建成功即为 Leader,否则为 Follower ,且 Follower 会 Watch 该节点
  • 新一轮的选举结果,无法预测,与它们在第一轮选举中的顺序无关。这也是该方案被称为非公平模式的原因
  • 非公平模式总结

  • 非公平模式实现简单,每一轮选举方法都完全一样
  • 竞争参与方不多的情况下,效率高。每个 Follower 通过 Watch 感知到节点被删除的时间不完全一样,只要有一个 Follower 得到通知即发起竞选,即可保证当时有新的 Leader 被选出
  • 给Zookeeper 集群造成的负载大,因此扩展性差。如果有上万个客户端都参与竞选,意味着同时会有上万个写请求发送给 Zookeper。如《Zookeeper架构》一文所述,Zookeeper 存在单点写的问题,写性能不高。同时一旦 Leader 放弃领导权,Zookeeper 需要同时通知上万个 Follower,负载较大。
  • 公平领导选举

    选主过程

    如下图所示,公平领导选举中,各客户端均创建/zkroot/leader节点,且其类型为EphemeralSequence


    Fair Leader Election

    由于是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/leader1Client 3 Watch /zkroot/leader2。(注:序号应该是10位数字,而非一位数字,这里为了方便,以一位数字代替)

    一旦 Leader 宕机,/zkroot/leader1被删除,Client 2可得到通知。此时Client 3由于 Watch 的是/zkroot/leader2,故不会得到通知。

    重新选举

    Client 2得到/zkroot/leader1被删除的通知后,不会立即成为新的 Leader 。而是先判断自己的序号 2 是不是当前最小的序号。在该场景下,其序号确为最小。因此Client 2成为新的 Leader 。


    Fair Leader Reelection

    这里要注意,如果在Client 1放弃领导权之前,Client 2就宕机了,Client 3会收到通知。此时Client 3不会立即成为Leader,而是要先判断自己的序号 3 是否为当前最小序号。很显然,由于Client 1创建的/zkroot/leader1还在,因此Client 3不会成为新的 Leader ,并向Client 2序号 2 前面的序号,也即 1 创建 Watch。该过程如下图所示。


    Fair Leader Exception

    公平模式总结

  • 实现相对复杂
  • 扩展性好,每个客户端都只 Watch 一个节点且每次节点被删除只须通知一个客户端
  • 旧 Leader 放弃领导权时,其它客户端根据竞选的先后顺序(也即节点序号)成为新 Leader,这也是公平模式的由来
  • 延迟相对非公平模式要高,因为它必须等待特定节点得到通知才能选出新的 Leader
  • 总结

    基于 Zookeeper 的领导选举或者分布式锁的实现均基于 Zookeeper 节点的特性及通知机制。充分利用这些特性,还可以开发出适用于其它场景的分布式应用。

    ]]>
    本文结合实例演示了使用Zookeeper实现分布式锁与领导选举的原理与具体实现方法。
    技术世界 http://www.jasongj.com/kafka/transaction/ 2017-11-21T00:01:01.000Z 2017-11-28T00:01:01.000Z

    原创文章,转载请务必将下面这段话置于文章开头处。
    本文转发自卡塔尔世界杯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 OnceAt Most Once语义,尚不支持Exactly Once语义。

    但是在很多要求严格的场景下,如使用Kafka处理交易数据,Exactly Once语义是必须的。我们可以通过让下游系统具有幂等性来配合Kafka的At Least Once语义来间接实现Exactly Once。但是:

  • 该方案要求下游系统支持幂等操作,限制了Kafka的适用场景
  • 实现门槛相对较高,需要用户对Kafka的工作机制非常了解
  • 对于Kafka Stream而言,Kafka本身即是自己的下游系统,但Kafka在0.11.0.0版本之前不具有幂等发送能力
  • 因此,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会接受它,否则将其丢弃:

  • 如果消息序号比Broker维护的序号大一以上,说明中间有数据尚未写入,也即乱序,此时Broker拒绝该消息,Producer抛出InvalidSequenceNumber
  • 如果消息序号小于等于Broker维护的序号,说明该消息已被保存,即为重复消息,Broker直接丢弃该消息,Producer抛出DuplicateSequenceNumber
  • 上述设计解决了0.11.0.0之前版本中的两个问题:

  • Broker保存消息后,发送ACK前宕机,Producer认为消息未发送成功并重试,造成数据重复
  • 前一条消息发送失败,后一条消息发送成功,前一条消息重试后成功,造成数据乱序
  • 事务性保证

    上述幂等设计只能保证单个Producer对于同一个<Topic, Partition>Exactly Once语义。

    另外,它并不能保证写操作的原子性——即多个写操作,要么全部被Commit要么全部不被Commit。

    更不能保证多个读写操作的的原子性。尤其对于Kafka Stream应用而言,典型的操作即是从某个Topic消费数据,经过一系列转换后写回另一个Topic,保证从源Topic的读取与向目标Topic的写入的原子性有助于从故障中恢复。

    事务保证可使得应用程序将生产数据和消费数据当作一个原子单元来处理,要么全部成功,要么全部失败,即使该生产或消费跨多个<Topic, Partition>

    另外,有状态的应用也可以保证重启后从断点处继续处理,也即事务恢复。

    为了实现这种效果,应用程序必须提供一个稳定的(重启后不变)唯一的ID,也即Transaction IDTransactin IDPID可能一一对应。区别在于Transaction ID由用户提供,而PID是内部的实现对用户透明。

    另外,为了保证新的Producer启动后,旧的具有相同Transaction ID的Producer即失效,每次Producer通过Transaction ID拿到PID的同时,还会获取一个单调递增的epoch。由于旧的Producer的epoch比新Producer的epoch小,Kafka可以很容易识别出该Producer是老的Producer并拒绝其请求。

    有了Transaction ID后,Kafka可保证:

  • 跨Session的数据幂等发送。当具有相同Transaction ID的新的Producer实例被创建且工作时,旧的且拥有相同Transaction ID的Producer将不再工作。
  • 跨Session的事务恢复。如果某个应用实例宕机,新的实例可以保证任何未完成的旧的事务要么Commit要么Abort,使得新实例从一个正常状态开始工作。
  • 需要注意的是,上述的事务保证是从Producer的角度去考虑的。从Consumer的角度来看,该保证会相对弱一些。尤其是不能保证所有被某事务Commit过的所有消息都被一起消费,因为:

  • 对于压缩的Topic而言,同一事务的某些消息可能被其它版本覆盖
  • 事务包含的消息可能分布在多个Segment中(即使在同一个Partition内),当老的Segment被删除时,该事务的部分数据可能会丢失
  • Consumer在一个事务内可能通过seek方法访问任意Offset的消息,从而可能丢失部分消息
  • Consumer可能并不需要消费某一事务内的所有Partition,因此它将永远不会读取组成该事务的所有消息
  • 事务机制原理

    事务性消息传递

    这一节所说的事务主要指原子性,也即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类似。

    事务中Offset的提交

    许多基于Kafka的应用,尤其是Kafka Stream应用中同时包含Consumer和Producer,前者负责从Kafka中获取消息,后者负责将处理完的数据写回Kafka的其它Topic中。

    为了实现该场景下的事务的原子性,Kafka需要保证对Consumer Offset的Commit与Producer对发送消息的Commit包含在同一个事务中。否则,如果在二者Commit中间发生异常,根据二者Commit的顺序可能会造成数据丢失和数据重复:

  • 如果先Commit Producer发送数据的事务再Commit Consumer的Offset,即At Least Once语义,可能造成数据重复。
  • 如果先Commit Consumer的Offset,再Commit Producer数据发送事务,即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();
    }

    完整事务过程

    Kafka Transaction

    找到Transaction Coordinator

    由于Transaction Coordinator是分配PID和管理事务的核心,因此Producer要做的第一件事情就是通过向任意一个Broker发送FindCoordinator请求找到Transaction Coordinator的位置。

    注意:只有应用程序为Producer配置了Transaction ID时才可使用事务特性,也才需要这一步。另外,由于事务性要求Producer开启幂等特性,因此通过将transactional.id设置为非空从而开启事务特性的同时也需要通过将enable.idempotence设置为true来开启幂等特性。

    获取PID

    找到Transaction Coordinator后,具有幂等特性的Producer必须发起InitPidRequest请求以获取PID。

    注意:只要开启了幂等特性即必须执行该操作,而无须考虑该Producer是否开启了事务特性。

    如果事务特性被开启
    InitPidRequest会发送给Transaction Coordinator。如果Transaction Coordinator是第一次收到包含有该Transaction ID的InitPidRequest请求,它将会把该<TransactionID, PID>存入Transaction Log,如上图中步骤2.1所示。这样可保证该对应关系被持久化,从而保证即使Transaction Coordinator宕机该对应关系也不会丢失。

    除了返回PID外,InitPidRequest还会执行如下任务:

  • 增加该PID对应的epoch。具有相同PID但epoch小于该epoch的其它Producer(如果有)新开启的事务将被拒绝。
  • 恢复(Commit或Abort)之前的Producer未完成的事务(如果有)。
  • 注意:InitPidRequest的处理过程是同步阻塞的。一旦该调用正确返回,Producer即可开始新的事务。

    另外,如果事务特性未开启,InitPidRequest可发送至任意Broker,并且会得到一个全新的唯一的PID。该Producer将只能使用幂等特性以及单一Session内的事务特性,而不能使用跨Session的事务特性。

    开启事务

    Kafka从0.11.0.0版本开始,提供beginTransaction()方法用于开启一个事务。调用该方法后,Producer本地会记录已经开启了事务,但Transaction Coordinator只有在Producer发送第一条消息后才认为事务已经开启。

    Consume-Transform-Produce

    这一阶段,包含了整个事务的数据处理过程,并且包含了多种请求。

    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。
  • Commit或Abort事务

    一旦上述数据写入操作完成,应用程序必须调用KafkaProducercommitTransaction方法或者abortTransaction方法以结束当前事务。

    EndTxnRequest
    commitTransaction方法使得Producer写入的数据对下游Consumer可见。abortTransaction方法通过Transaction Marker将Producer写入的数据标记为Aborted状态。下游的Consumer如果将isolation.level设置为READ_COMMITTED,则它读到被Abort的消息后直接将其丢弃而不会返回给客户程序,也即被Abort的消息对应用程序不可见。

    无论是Commit还是Abort,Producer都会发送EndTxnRequest请求给Transaction Coordinator,并通过标志位标识是应该Commit还是Abort。

    收到该请求后,Transaction Coordinator会进行如下操作

    1. PREPARE_COMMITPREPARE_ABORT消息写入Transaction Log,如上图中步骤5.1所示
    2. 通过WriteTxnMarker请求以Transaction Marker的形式将COMMITABORT信息写入用户数据日志以及Offset Log中,如上图中步骤5.2所示
    3. 最后将COMPLETE_COMMITCOMPLETE_ABORT信息写入Transaction Log中,如上图中步骤5.3所示

    补充说明:对于commitTransaction方法,它会在发送EndTxnRequest之前先调用flush方法以确保所有发送出去的数据都得到相应的ACK。对于abortTransaction方法,在发送EndTxnRequest之前直接将当前Buffer中的事务性消息(如果有)全部丢弃,但必须等待所有被发送但尚未收到ACK的消息发送完成。

    上述第二步是实现将一组读操作与写操作作为一个事务处理的关键。因为Producer写入的数据Topic以及记录Comsumer Offset的Topic会被写入相同的Transactin Marker,所以这一组读操作与写操作要么全部COMMIT要么全部ABORT。

    WriteTxnMarkerRequest
    上面提到的WriteTxnMarkerRequestTransaction 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_COMMITCOMPLETE_ABORT消息
    写完所有的Transaction Marker后,Transaction Coordinator会将最终的COMPLETE_COMMITCOMPLETE_ABORT消息写入Transaction Log中以标明该事务结束,如上图中步骤5.3所示。

    此时,Transaction Log中所有关于该事务的消息全部可以移除。当然,由于Kafka内数据是Append Only的,不可直接更新和删除,这里说的移除只是将其标记为null从而在Log Compact时不再保留。

    另外,COMPLETE_COMMITCOMPLETE_ABORT的写入并不需要得到所有Rreplica的ACK,因为如果该消息丢失,可以根据事务协议重发。

    补充说明,如果参与该事务的某些<Topic, Partition>在被写入Transaction Marker前不可用,它对READ_COMMITTED的Consumer不可见,但不影响其它可用<Topic, Partition>的COMMIT或ABORT。在该<Topic, Partition>恢复可用后,Transaction Coordinator会重新根据PREPARE_COMMITPREPARE_ABORT向该<Topic, Partition>发送Transaction Marker

    总结

  • PIDSequence Number的引入实现了写操作的幂等性
  • 写操作的幂等性结合At Least Once语义实现了单一Session内的Exactly Once语义
  • Transaction MarkerPID提供了识别消息是否应该被读取的能力,从而实现了事务的隔离性
  • Offset的更新标记了消息是否被读取,从而将对读操作的事务处理转换成了对写(Offset)操作的事务处理
  • Kafka事务的本质是,将一组写操作(如果有)对应的消息与一组读操作(如果有)对应的Offset的更新进行同样的标记(即Transaction Marker)来实现事务中涉及的所有读写操作同时对外可见或同时对外不可见
  • Kafka只提供对Kafka本身的读写操作的事务性,不提供包含外部系统的事务性
  • 异常处理

    Exception处理

    InvalidProducerEpoch
    这是一种Fatal Error,它说明当前Producer是一个过期的实例,有Transaction ID相同但epoch更新的Producer实例被创建并使用。此时Producer会停止并抛出Exception。

    InvalidPidMapping
    Transaction Coordinator没有与该Transaction ID对应的PID。此时Producer会通过包含有Transaction IDInitPidRequest请求创建一个新的PID。

    NotCorrdinatorForGTransactionalId
    Transaction Coordinator不负责该当前事务。Producer会通过FindCoordinatorRequest请求重新寻找对应的Transaction Coordinator

    InvalidTxnRequest
    违反了事务协议。正确的Client实现不应该出现这种Exception。如果该异常发生了,用户需要检查自己的客户端实现是否有问题。

    CoordinatorNotAvailable
    Transaction Coordinator仍在初始化中。Producer只需要重试即可。

    DuplicateSequenceNumber
    发送的消息的序号低于Broker预期。该异常说明该消息已经被成功处理过,Producer可以直接忽略该异常并处理下一条消息

    InvalidSequenceNumber
    这是一个Fatal Error,它说明发送的消息中的序号大于Broker预期。此时有两种可能

  • 数据乱序。比如前面的消息发送失败后重试期间,新的消息被接收。正常情况下不应该出现该问题,因为当幂等发送启用时,max.inflight.requests.per.connection被强制设置为1,而acks被强制设置为all。故前面消息重试期间,后续消息不会被发送,也即不会发生乱序。并且只有ISR中所有Replica都ACK,Producer才会认为消息已经被发送,也即不存在Broker端数据丢失问题。
  • 服务器由于日志被Truncate而造成数据丢失。此时应该停止Producer并将此Fatal Error报告给用户。
  • InvalidTransactionTimeout
    InitPidRequest调用出现的Fatal Error。它表明Producer传入的timeout时间不在可接受范围内,应该停止Producer并报告给用户。

    处理Transaction Coordinator失败

    PREPARE_COMMIT/PREPARE_ABORT前失败

    Producer通过FindCoordinatorRequest找到新的Transaction Coordinator,并通过EndTxnRequest请求发起COMMITABORT流程,新的Transaction Coordinator继续处理EndTxnRequest请求——写PREPARE_COMMITPREPARE_ABORT,写Transaction Marker,写COMPLETE_COMMITCOMPLETE_ABORT

    写完PREPARE_COMMIT/PREPARE_ABORT后失败

    此时旧的Transaction Coordinator可能已经成功写入部分Transaction Marker。新的Transaction Coordinator会重复这些操作,所以部分Partition中可能会存在重复的COMMITABORT,但只要该Producer在此期间没有发起新的事务,这些重复的Transaction Marker就不是问题。

    写完COMPLETE_COMMIT/ABORT后失败

    旧的Transaction Coordinator可能已经写完了COMPLETE_COMMITCOMPLETE_ABORT但在返回EndTxnRequest之前失败。该场景下,新的Transaction Coordinator会直接给Producer返回成功。

    事务过期机制

    事务超时

    transaction.timeout.ms

    终止过期事务

    当Producer失败时,Transaction Coordinator必须能够主动的让某些进行中的事务过期。否则没有Producer的参与,Transaction Coordinator无法判断这些事务应该如何处理,这会造成:

  • 如果这种进行中事务太多,会造成Transaction Coordinator需要维护大量的事务状态,大量占用内存
  • Transaction Log内也会存在大量数据,造成新的Transaction Coordinator启动缓慢
  • READ_COMMITTED的Consumer需要缓存大量的消息,造成不必要的内存浪费甚至是OOM
  • 如果多个Transaction 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 IDPID等的映射,否则可能会造成大量的资源浪费。因此需要有一个机制探测不再活跃的Transaction ID并将其信息删除。

    Transaction Coordinator会周期性遍历内存中的Transaction IDPID映射,如果某Transaction ID没有对应的正在进行中的事务并且它对应的最后一个事务的结束时间与当前时间差大于transactional.id.expiration.ms(默认值是7天),则将其从内存中删除并在Transaction Log中将其对应的日志的值设置为null从而使得Log Compact可将其记录删除。

    与其它系统事务机制对比

    PostgreSQL MVCC

    Kafka的事务机制与《MVCC PostgreSQL实现事务和多版本并发控制的精华》一文中介绍的PostgreSQL通过MVCC实现事务的机制非常类似,对于事务的回滚,并不需要删除已写入的数据,都是将写入数据的事务标记为Rollback/Abort从而在读数据时过滤该数据。

    两阶段提交

    Kafka的事务机制与《分布式事务(一)两阶段提交及JTA》一文中所介绍的两阶段提交机制看似相似,都分PREPARE阶段和最终COMMIT阶段,但又有很大不同。

  • Kafka事务机制中,PREPARE时即要指明是PREPARE_COMMIT还是PREPARE_ABORT,并且只须在Transaction Log中标记即可,无须其它组件参与。而两阶段提交的PREPARE需要发送给所有的分布式事务参与方,并且事务参与方需要尽可能准备好,并根据准备情况返回PreparedNon-Prepared状态给事务管理器。
  • Kafka事务中,一但发起PREPARE_COMMITPREPARE_ABORT,则确定该事务最终的结果应该是被COMMITABORT。而分布式事务中,PREPARE后由各事务参与方返回状态,只有所有参与方均返回Prepared状态才会真正执行COMMIT,否则执行ROLLBACK
  • Kafka事务机制中,某几个Partition在COMMIT或ABORT过程中变为不可用,只影响该Partition不影响其它Partition。两阶段提交中,若唯一收到COMMIT命令参与者Crash,其它事务参与方无法判断事务状态从而使得整个事务阻塞
  • Kafka事务机制引入事务超时机制,有效避免了挂起的事务影响其它事务的问题
  • Kafka事务机制中存在多个Transaction Coordinator实例,而分布式事务中只有一个事务管理器
  • Zookeeper

    Zookeeper的原子广播协议与两阶段提交以及Kafka事务机制有相似之处,但又有各自的特点

  • Kafka事务可COMMIT也可ABORT。而Zookeeper原子广播协议只有COMMIT没有ABORT。当然,Zookeeper不COMMIT某消息也即等效于ABORT该消息的更新。
  • Kafka存在多个Transaction Coordinator实例,扩展性较好。而Zookeeper写操作只能在Leader节点进行,所以其写性能远低于读性能。
  • Kafka事务是COMMIT还是ABORT完全取决于Producer即客户端。而Zookeeper原子广播协议中某条消息是否被COMMIT取决于是否有一大半FOLLOWER ACK该消息。
  • Kafka系列文章

  • Kafka设计解析(一)- Kafka背景及架构介绍
  • Kafka设计解析(二)- Kafka High Availability (上)
  • Kafka设计解析(三)- Kafka High Availability (下)
  • Kafka设计解析(四)- Kafka Consumer设计解析
  • Kafka设计解析(五)- Kafka性能测试方法及Benchmark报告
  • Kafka设计解析(六)- Kafka高性能架构之道
  • Kafka设计解析(七)- Kafka Stream
  • Kafka设计解析(八)- Kafka Exactly Once语义与事务机制原理
  • ]]>
    本文介绍了Kafka实现事务性的几个阶段——正好一次语义与原子操作。之后详细分析了Kafka事务机制的实现原理,并介绍了Kafka如何处理事务相关的异常情况,如Transaction Coordinator宕机。最后介绍了Kafka的事务机制与PostgreSQL的MVCC以及Zookeeper的原子广播实现事务的异同
    技术世界 http://www.jasongj.com/ml/associationrules/ 2017-11-20T23:01:11.000Z 2017-11-21T09:07:01.000Z

    原创文章,转载请务必将下面这段话置于文章开头处。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/ml/associationrules/

    关联规则背景

    关联规则来源

    上个世纪,美国连锁超市活尔玛通过大量的数据分析发现了一个非常有趣的现象:尿布与啤酒这两种看起来风马牛不相及的商品销售数据曲线非常相似,并且尿布与啤酒经常被同时购买,也即购买尿布的顾客一般也同时购买了啤酒。于是超市将尿布与啤酒摆在一起,这一举措使得尿布和啤酒的销量大幅增加。

    原来,美国的妇女通常全职在家照顾孩子,并且她们经常会嘱咐丈夫在下班回家的路上为孩子买尿布,而丈夫在买尿布的同时又会顺手购买自己爱喝的啤酒。

    注: 此案例很精典,切勿盲目模仿案例本身,而应了解其背后原理。它发生在美国,而且是上个世纪,有些东西并不一定适用于现在,更不一定适用于中国。目前国内网购非常普遍,并不一定需要去超市线下购买,而网购主力军是女性,因此不一定会出现尿布与啤酒同时购买的问题。另外,对于线下销售,很多超市流行的做法不是把经常同时购买的商品放在一起,而是尽量分开放到不同的地方,这样顾客为了同时购买不得不穿过其它商品展示区,从而可能购买原来未打算购买的商品。

    但是本案例中背后的机器学习算法——关联规则,仍然适用于非常多的场景。目前很多电商网站也会根据类似的关联规则给用户进行推荐,如比较常见的“购买该商品的客户还购买过**”。其背后的逻辑在于,某两种或几种商品经常被一起购买,它们中间可能存在某种联系,当某位顾客购买了其中一种商品时,他/她可能也需要另外一种或几种商品,因此电商网站会将这几种商吕推荐给客户。

    什么是关联规则

    如同上述啤酒与尿布的故事所示,关联规则是指从一组数据中发现数据项之间的隐藏关系,它是一种典型的无监督学习。

    关联规则的核心概念

    本节以上述超市购物的场景为例,介绍关联规则的几个核心概念

    项目
    一系列事件中的一个事件。对于超市购物而言,即一次购物中的一件商品,如啤酒

    事务
    一起发生的一系列事件。在超市购物场景中,即一次购买行为中包含的所有商品的集合。如 $\{尿布,啤酒,牛奶,面包\}$

    项集
    一个事务中包含的若干个项目的集合,如 $\{尿布,啤酒\}$

    支持度
    项集 $\{A,B\}$ 在全部项集中出现的概率。支持度越高,说明规则 $A \rightarrow B$ 越具代表性。

    频繁项集
    某个项集的支持度大于设定的阈值(人为根据数据分布和经验设定),该项集即为频繁项集。

    假设超市某段时间总共有 5 笔交易。下面数据中,数字代表交易编号,字母代表项目,每行代表一个交易对应的项目集

    1
    2
    3
    4
    5
    1: 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 时,该规则才被认为是有价值的。

    Apriori算法

    关联规则中,关键
    点是:1)找出频繁项集;2)合理地设置三种阈值;3)找出强关联规则

    直接遍历所有的项目集,并计算其支持度、置信度和提升度,计算量太大,无法应用于工程实践。

    Apriori算法可用于快速找出频繁项集。

    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-$ 频繁项目集即可。

    Apriori生成频繁项集

    有项目集合 $I=\{A,B,C,D,E\}$ ,事务集 $T$ :

    1
    2
    3
    4
    5
    6
    7
    A,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$

    直接穷举所有可能的频繁项目集,可能的频繁项目集如下


    Full search

    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-$ 频繁项目集全部排除(如下图中红色十字所示),该过程即剪枝。剪枝减少了后续的候选项,极大降低了计算量从而大幅度提升了算法性能。


    Apriori prune 2

    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 prune 3

    生成强关联规则

    穷举法
    得到频繁项目集后,可以穷举所有可能的规则,如下图所示。然后通过置信度阈值筛选出强关联规则。


    Apriori prune 4

    Apriori剪枝
    Apriori算法可根据原理三原理四进行剪枝,从而提升算法性能。

    上述步骤得到了3-频繁项集 $\{A,B,C\}$。先生成 $1-$ 后件(即箭头后只有一个项目)的关联规则

  • $\{A,B\} \rightarrow C $ 置信度 $3/4 > 5/7$,是强关联规则
  • $\{A,C\} \rightarrow B $ 置信度为 $3/5 < 5/7$,不是强关联规则
  • $\{B,C\} \rightarrow A $ 置信度为 $3/3 > 5/7$,是强关联规则
  • 此时可将 $\{A,C\} \rightarrow B $ 排除,并将相应的 $2-$ 后件关联规则 $\{A\} \rightarrow \{B,C\} $ 与 $\{C\} \rightarrow \{A,B\} $ 排除,如下图所示。


    Apriori prune 5

    根据原理四,由 $1-$ 后件强关联规则,生成 $2-$ 后件关联规则 $\{B\} \rightarrow \{A,C\} $,置信度 $3/5 < 5/7$,不是强关联规则。

    至此,本例中通过Apriori算法得到强关联规则 $\{A,B\} \rightarrow C $ 与 $\{B,C\} \rightarrow A $。

    注:这里只演示了从 $3-$ 频繁项目集中挖掘强关联规则。实际上同样还可以从上文中得到的 $2-$ 频繁项目集中挖掘出更多强关联规则,这里不过多演示。

    总结

    Aprior原理和实现简单,相对穷举法有其优势,但也有其局限

  • 从单元素项集开始,通过组合满足最小支持度要求的项集来形成更大的集合
  • 通过上述四条原理,进行剪枝,降低了计算量,从而提升了计算速度
  • 每次增加频繁项目集的大小,都需要重新扫描整个数据集(计算支持度)
  • 当数据集很大时,频繁项目集的生成速度会显著降低
  • 需要频繁扫描数据集从而从候选项目集中筛选出频繁项目集,开销较大
  • FP-growth算法

    构建FP树

    生成交易数据集
    设有交易数据集如下

    1
    2
    3
    4
    5
    6
    1: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
    6
    1: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 $。

    第一条记录与第二条记录的添加过程如下图所示。


    Frequent pattern growth 1

    添加第三条记录 $\{F\}$ 时,由于 $FP$ 树中已经存在该节点且该节点为根节点的直接子节点,直接将 $F$ 的卡塔尔世界杯bobAPP手机端下载在线加一即可。

    添加第四条记录 $\{D,B,A\}$ 时,由于与 $FP$ 树无共同前缀,因此直接在根节点下依次添加节点 $ \lt D:1 \gt $, $ \lt B:1 \gt $, $ \lt A:1 \gt $。

    第三条记录与第四条记录添加过程如下所示


    Frequent pattern growth 2

    添加第五条记录 $\{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手机端下载在线分别加一即可。

    第五条记录与第六条记录添加过程如下图所示。


    Frequent pattern growth 3

    建立头表
    为了便于对整棵FP树进行遍历,可建立一张项目头表。这张表记录各 $1-$ 频繁项的出现卡塔尔世界杯bobAPP手机端下载在线,并指向该频繁项在 $FP$ 树中的节点,如下图所示。


    Head Table

    从FP树中挖掘频繁项目集

    构建好 $FP$ 树后,即可抽取频繁项目集,其思路与 Apriori 算法类似——先从 $1-$ 频繁项目集开始,然后逐步构建更大的频繁项目集。

    从 $FP$ 树中抽取频繁项目集的三个基本步骤如下:

    1. 从 $FP$ 树中获得条件模式基(conditional pattern base)
    2. 根据条件模式基构建 $条件FP树$
    3. 重复 $步骤1$ 与 $步骤2$ ,直到$ 条件FP树$ 只包含一个项目为止

    抽取条件模式基
    条件模式基(conditaional pattern base)是以所查元素为结尾的路径集合。这里的每一个路径称为一条前缀路径(prefix path)前缀路径是介于所查元素与 $FP$ 树根节点间的所有元素。

    每个 $1-$ 频繁项目的条件模式基如下所示

    $1-$ 频繁项目条件模式基
    F
    Ø:5
    A
    $\{D,B\}:1$,$\{F,D,E\}:1$,$\{F\}:1$
    D
    $\{F\}:3$,Ø:1
    E
    $\{F,D\}:3$
    B
    $\{F,D,E\}:2$,$\{D\}:1$
    C
    $\{F,D,E,B\}:2$,$\{F,D,E,A\}:1$

    构建条件$FP$树
    对于每个频繁项目,可以条件模式基作为输入数据构建一棵 $条件FP树$ 。

    对于 $C$ ,其条件模式基为 $\{F,D,E,B\}:2$,$\{F,D,E,A\}:1$ 。根据支持度阈值 $3/6$ 可排除掉 $A$ 与 $B$ 。以满足最小支持度的 $\{F,D,E\}:2$ 与 $\{F,D,E\}:1$ 构建 $条件FP树$ 如下图所示。


    Conditaional Frequent Pattern Tree

    递归查找频繁项集
    基于上述步骤中生成的 $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$ ,递归生成频繁项目集的过程如下图所示。


    Conditaional Frequent Pattern Tree for C

    上图中红色部分即为频繁项目集。同理可得其它频繁项目集。

    生成强关联规则

    得到频繁项目集后,即可以上述同样方式得到强关联规则。

    总结

    $FP-growth$ 算法相对 $Apriori$ 有优化之处,但也有其不足

  • 无论数据集多复杂,只需扫描原始数据集两遍,速度比 $Apriori$ 算法快
  • 实现比 $Apriori$ 算法复杂
  • Apriori算法R语言实战

    加载数据集

    $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
    5
    Usage:
    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
    6
    1,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

    总结

  • 关联规则可用于发现项目间的共生关系
  • 支持度与置信度阈值可筛选出强关联规则
  • 《机器学习》系列文章

  • 机器学习(一) 从一个R语言案例学线性回归
  • 机器学习(二) 如何做到Kaggle排名前2%
  • 机器学习(三) 关联规则R语言实战Apriori
  • ]]>
    本文由尿布与啤酒的精典案例开始介绍了关联规则的起源及核心概念,并详细阐述了Apriori算法的原理,生成频繁项目集的具体过程及抽取强关联规则的方法。之后结合案例介绍了构建FP树的具体步骤及从FP树挖掘频繁项目集的过程。最后给出了在R语言中使用Apriori算法进行关联规则挖掘的实战案例。
    技术世界 http://www.jasongj.com/zookeeper/fastleaderelection/ 2017-11-08T00:01:01.000Z 2017-11-12T00:01:01.000Z

    原创文章,转载请务必将下面这段话置于文章开头处。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/zookeeper/fastleaderelection/

    Zookeeper是什么

    Zookeeper是一个分布式协调服务,可用于服务发现,分布式锁,分布式领导选举,配置管理等。

    这一切的基础,都是Zookeeper提供了一个类似于Linux文件系统的树形结构(可认为是轻量级的内存文件系统,但只适合存少量信息,完全不适合存储大量文件或者大文件),同时提供了对于每个节点的监控与通知机制。

    既然是一个文件系统,就不得不提Zookeeper是如何保证数据的一致性的。本文将介绍Zookeeper如何保证数据一致性,如何进行领导选举,以及数据监控/通知机制的语义保证。

    Zookeeper架构

    角色

    Zookeeper集群是一个基于主从复制的高可用集群,每个服务器承担如下三种角色中的一种

  • Leader 一个Zookeeper集群同一时间只会有一个实际工作的Leader,它会发起并维护与各Follwer及Observer间的心跳。所有的写操作必须要通过Leader完成再由Leader将写操作广播给其它服务器。
  • Follower 一个Zookeeper集群可能同时存在多个Follower,它会响应Leader的心跳。Follower可直接处理并返回客户端的读请求,同时会将写请求转发给Leader处理,并且负责在Leader处理写请求时对请求进行投票。
  • Observer 角色与Follower类似,但是无投票权。

  • Zookeeper Architecture

    原子广播(ZAB)

    为了保证写操作的一致性与可用性,Zookeeper专门设计了一种名为原子广播(ZAB)的支持崩溃恢复的一致性协议。基于该协议,Zookeeper实现了一种主从模式的系统架构来保持集群中各个副本之间的数据一致性。

    根据ZAB协议,所有的写操作都必须通过Leader完成,Leader写入本地日志后再复制到所有的Follower节点。

    一旦Leader节点无法工作,ZAB协议能够自动从Follower节点中重新选出一个合适的替代者,即新的Leader,该过程即为领导选举。该领导选举过程,是ZAB协议中最为重要和复杂的过程。

    写操作

    写Leader

    通过Leader进行写操作流程如下图所示


    Zookeeper Leader Write

    由上图可见,通过Leader进行写操作,主要分为五步:

    1. 客户端向Leader发起写请求
    2. Leader将写请求以Proposal的形式发给所有Follower并等待ACK
    3. Follower收到Leader的Proposal后返回ACK
    4. Leader得到过半数的ACK(Leader对自己默认有一个ACK)后向所有的Follower和Observer发送Commmit
    5. Leader将处理结果返回给客户端

    这里要注意

  • Leader并不需要得到Observer的ACK,即Observer无投票权
  • Leader不需要得到所有Follower的ACK,只要收到过半的ACK即可,同时Leader本身对自己有一个ACK。上图中有4个Follower,只需其中两个返回ACK即可,因为(2+1) / (4+1) > 1/2
  • Observer虽然无投票权,但仍须同步Leader的数据从而在处理读请求时可以返回尽可能新的数据
  • 写Follower/Observer

    通过Follower/Observer进行写操作流程如下图所示:


    Zookeeper Follower/Observer Write

    从上图可见

  • Follower/Observer均可接受写请求,但不能直接处理,而需要将写请求转发给Leader处理
  • 除了多了一步请求转发,其它流程与直接写Leader无任何区别
  • 读操作

    Leader/Follower/Observer都可直接处理读请求,从本地内存中读取数据并返回给客户端即可。


    Zookeeper Read

    由于处理读请求不需要服务器之间的交互,Follower/Observer越多,整体可处理的读请求量越大,也即读性能越好。

    FastLeaderElection原理

    术语介绍

    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的LeaderElection
  • 1 基于UDP的FastLeaderElection
  • 2 基于UDP和认证的FastLeaderElection
  • 3 基于TCP的FastLeaderElection
  • 在3.4.10版本中,默认值为3,也即基于TCP的FastLeaderElection。另外三种算法已经被弃用,并且有计划在之后的版本中将它们彻底删除而不再支持。

    FastLeaderElection

    FastLeaderElection选举算法是标准的Fast Paxos算法实现,可解决LeaderElection选举算法收敛速度慢的问题。

    服务器状态

  • LOOKING 不确定Leader状态。该状态下的服务器认为当前集群中没有Leader,会发起Leader选举
  • FOLLOWING 跟随者状态。表明当前服务器角色是Follower,并且它知道Leader是谁
  • LEADING 领导者状态。表明当前服务器角色是Leader,它会维护与Follower间的心跳
  • OBSERVING 观察者状态。表明当前服务器角色是Observer,与Folower唯一的不同在于不参与选举,也不参与集群写操作时的投票
  • 选票数据结构

    每个服务器在进行领导选举时,会发送如下关键信息

  • logicClock 每个服务器会维护一个自增的整数,名为logicClock,它表示这是该服务器发起的第多少轮投票
  • state 当前服务器的状态
  • self_id 当前服务器的myid
  • self_zxid 当前服务器上所保存的数据的最大zxid
  • vote_id 被推举的服务器的myid
  • vote_zxid 被推举的服务器上所保存的数据的最大zxid
  • 投票流程

    自增选举轮次
    Zookeeper规定所有有效的投票都必须在同一轮次中。每个服务器在开始新一轮投票时,会先对自己维护的logicClock进行自增操作。

    初始化选票
    每个服务器在广播自己的选票前,会将自己的投票箱清空。该投票箱记录了所收到的选票。例:服务器2投票给服务器3,服务器3投票给服务器1,则服务器1的投票箱为(2, 3), (3, 1), (1, 1)。票箱中只会记录每一投票者的最后一票,如投票者更新自己的选票,则其它服务器收到该新选票后会在自己票箱中更新该服务器的选票。

    发送初始化选票
    每个服务器最开始都是通过广播把票投给自己。

    接收外部投票
    服务器会尝试从其它服务器获取投票,并记入自己的投票箱内。如果无法获取任何外部投票,则会确认自己是否与集群中其它服务器保持着有效连接。如果是,则再次发送自己的投票;如果否,则马上与之建立连接。

    判断选举轮次
    收到外部投票后,首先会根据投票信息中所包含的logicClock来进行不同处理

  • 外部投票的logicClock大于自己的logicClock。说明该服务器的选举轮次落后于其它服务器的选举轮次,立即清空自己的投票箱并将自己的logicClock更新为收到的logicClock,然后再对比自己之前的投票与收到的投票以确定是否需要变更自己的投票,最终再次将自己的投票广播出去。
  • 外部投票的logicClock小于自己的logicClock。当前服务器直接忽略该投票,继续处理下一个投票。
  • 外部投票的logickClock与自己的相等。当时进行选票PK。
  • 选票PK
    选票PK是基于(self_id, self_zxid)与(vote_id, vote_zxid)的对比

  • 外部投票的logicClock大于自己的logicClock,则将自己的logicClock及自己的选票的logicClock变更为收到的logicClock
  • 若logicClock一致,则对比二者的vote_zxid,若外部投票的vote_zxid比较大,则将自己的票中的vote_zxid与vote_myid更新为收到的票中的vote_zxid与vote_myid并广播出去,另外将收到的票及自己更新后的票放入自己的票箱。如果票箱内已存在(self_myid, self_zxid)相同的选票,则直接覆盖
  • 若二者vote_zxid一致,则比较二者的vote_myid,若外部投票的vote_myid比较大,则将自己的票中的vote_myid更新为收到的票中的vote_myid并广播出去,另外将收到的票及自己更新后的票放入自己的票箱
  • 统计选票
    如果已经确定有过半服务器认可了自己的投票(可能是更新后的投票),则终止投票。否则继续接收其它服务器的投票。

    更新服务器状态
    投票终止后,服务器开始更新自身状态。若过半的票投给了自己,则将自己的服务器状态更新为LEADING,否则将自己的状态更新为FOLLOWING

    几种领导选举场景

    集群启动领导选举

    初始投票给自己
    集群刚启动时,所有服务器的logicClock都为1,zxid都为0。

    各服务器初始化后,都投票给自己,并将自己的一票存入自己的票箱,如下图所示。


    Cluster start election step 1

    在上图中,(1, 1, 0)第一位数代表投出该选票的服务器的logicClock,第二位数代表被推荐的服务器的myid,第三位代表被推荐的服务器的最大的zxid。由于该步骤中所有选票都投给自己,所以第二位的myid即是自己的myid,第三位的zxid即是自己的zxid。

    此时各自的票箱中只有自己投给自己的一票。

    更新选票
    服务器收到外部投票后,进行选票PK,相应更新自己的选票并广播出去,并将合适的选票存入自己的票箱,如下图所示。


    Cluster start election step 2

    服务器1收到服务器2的选票(1, 2, 0)和服务器3的选票(1, 3, 0)后,由于所有的logicClock都相等,所有的zxid都相等,因此根据myid判断应该将自己的选票按照服务器3的选票更新为(1, 3, 0),并将自己的票箱全部清空,再将服务器3的选票与自己的选票存入自己的票箱,接着将自己更新后的选票广播出去。此时服务器1票箱内的选票为(1, 3),(3, 3)。

    同理,服务器2收到服务器3的选票后也将自己的选票更新为(1, 3, 0)并存入票箱然后广播。此时服务器2票箱内的选票为(2, 3),(3, ,3)。

    服务器3根据上述规则,无须更新选票,自身的票箱内选票仍为(3, 3)。

    服务器1与服务器2更新后的选票广播出去后,由于三个服务器最新选票都相同,最后三者的票箱内都包含三张投给服务器3的选票。

    根据选票确定角色
    根据上述选票,三个服务器一致认为此时服务器3应该是Leader。因此服务器1和2都进入FOLLOWING状态,而服务器3进入LEADING状态。之后Leader发起并维护与Follower间的心跳。


    Cluster start election step 3

    Follower重启

    Follower重启投票给自己
    Follower重启,或者发生网络分区后找不到Leader,会进入LOOKING状态并发起新的一轮投票。


    Follower restart election step 1

    发现已有Leader后成为Follower
    服务器3收到服务器1的投票后,将自己的状态LEADING以及选票返回给服务器1。服务器2收到服务器1的投票后,将自己的状态FOLLOWING及选票返回给服务器1。此时服务器1知道服务器3是Leader,并且通过服务器2与服务器3的选票可以确定服务器3确实得到了超过半数的选票。因此服务器1进入FOLLOWING状态。


    Follower restart election step 2

    Leader重启

    Follower发起新投票
    Leader(服务器3)宕机后,Follower(服务器1和2)发现Leader不工作了,因此进入LOOKING状态并发起新的一轮投票,并且都将票投给自己。


    Leader restart election step 1

    广播更新选票
    服务器1和2根据外部投票确定是否要更新自身的选票。这里有两种情况

  • 服务器1和2的zxid相同。例如在服务器3宕机前服务器1与2完全与之同步。此时选票的更新主要取决于myid的大小
  • 服务器1和2的zxid不同。在旧Leader宕机之前,其所主导的写操作,只需过半服务器确认即可,而不需所有服务器确认。换句话说,服务器1和2可能一个与旧Leader同步(即zxid与之相同)另一个不同步(即zxid比之小)。此时选票的更新主要取决于谁的zxid较大
  • 在上图中,服务器1的zxid为11,而服务器2的zxid为10,因此服务器2将自身选票更新为(3, 1, 11),如下图所示。


    Leader restart election step 2

    选出新Leader
    经过上一步选票更新后,服务器1与服务器2均将选票投给服务器1,因此服务器2成为Follower,而服务器1成为新的Leader并维护与服务器2的心跳。


    Leader restart election step 3

    旧Leader恢复后发起选举
    旧的Leader恢复后,进入LOOKING状态并发起新一轮领导选举,并将选票投给自己。此时服务器1会将自己的LEADING状态及选票(3, 1, 11)返回给服务器3,而服务器2将自己的FOLLOWING状态及选票(3, 1, 11)返回给服务器3。如下图所示。


    Leader restart election step 4

    旧Leader成为Follower
    服务器3了解到Leader为服务器1,且根据选票了解到服务器1确实得到过半服务器的选票,因此自己进入FOLLOWING状态。


    Leader restart election step 5

    一致性保证

    ZAB协议保证了在Leader选举的过程中,已经被Commit的数据不会丢失,未被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 Failover step 1

    这里要注意

  • 由于A没有C3,意味着收到P3的服务器的总个数不会超过一半,也即包含A在内最多只有两台服务器收到P3。在这里A和B收到P3,其它服务器均未收到P3
  • 由于A已写入C1、C2,说明它已经Commit了P1、P2,因此整个集群有超过一半的服务器,即最少三个服务器收到P1、P2。在这里所有服务器都收到了P1,除E外其它服务器也都收到了P2
  • 选出新Leader
    旧Leader也即A宕机后,其它服务器根据上述FastLeaderElection算法选出B作为新的Leader。C、D和E成为Follower且以B为Leader后,会主动将自己最大的zxid发送给B,B会将Follower的zxid与自身zxid间的所有被Commit过的消息同步给Follower,如下图所示。


    Leader Failover step 2

    在上图中

  • P1和P2都被A Commit,因此B会通过同步保证P1、P2、C1与C2都存在于C、D和E中
  • P3由于未被A Commit,同时幸存的所有服务器中P3未存在于大多数据服务器中,因此它不会被同步到其它Follower
  • 通知Follower可对外服务
    同步完数据后,B会向D、C和E发送NEWLEADER命令并等待大多数服务器的ACK(下图中D和E已返回ACK,加上B自身,已经占集群的大多数),然后向所有服务器广播UPTODATE命令。收到该命令后的服务器即可对外提供服务。


    Leader Failover step 3

    未Commit过的消息对客户端不可见

    在上例中,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根据这些信息作出如下操作

  • 如果Follower的max_zxid与Leader的max_zxid相等,说明该Follower与Leader完全同步,无须同步任何数据
  • 如果Follower的max_zxid在Leader的(min_zxid,max_zxid)范围内,Leader会通过TRUNC命令通知Follower将其zxid_set中大于Follower的max_zxid(如果有)的所有消息全部删除
  • 上述操作保证了未被Commit过的消息不会被Commit从而对外不可见。

    上述例子中Follower上并不存在未被Commit的消息。但可考虑这种情况,如果将上述例子中的服务器数量从五增加到七,服务器F包含P1、P2、C1、P3,服务器G包含P1、P2。此时服务器F、A和B都包含P3,但是因为票数未过半,因此B作为Leader不会Commit P3,而会通过TRUNC命令通知F删除P3。如下图所示。


    Leader Failover step 4

    总结

  • 由于使用主从复制模式,所有的写操作都要由Leader主导完成,而读操作可通过任意节点完成,因此Zookeeper读性能远好于写性能,更适合读多写少的场景
  • 虽然使用主从复制模式,同一时间只有一个Leader,但是Failover机制保证了集群不存在单点失败(SPOF)的问题
  • ZAB协议保证了Failover过程中的数据一致性
  • 服务器收到数据后先写本地文件再进行处理,保证了数据的持久性
  • ]]>
    本文介绍了Zookeeper的架构,并组合实例分析了原子广播(ZAB)协议的原理,包括但不限于Zookeeper的读写流程,FastLeaderElection算法的原理,ZAB如何保证Leader Failover过程中的数据一致性。
    技术世界 http://www.jasongj.com/kafka/kafka_stream/ 2017-08-07T00:01:01.000Z 2017-08-07T00:01:01.000Z

    原创文章,转载请务必将下面这段话置于文章开头处。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/kafka/kafka_stream/

    Kafka Stream背景

    Kafka Stream是什么

    Kafka Stream是Apache Kafka从0.10版本引入的一个新Feature。它是提供了对存储于Kafka内的数据进行流式处理和分析的功能。

    Kafka Stream的特点如下:

  • Kafka Stream提供了一个非常简单而轻量的Library,它可以非常方便地嵌入任意Java应用中,也可以任意方式打包和部署
  • 除了Kafka外,无任何外部依赖
  • 充分利用Kafka分区机制实现水平扩展和顺序性保证
  • 通过可容错的state store实现高效的状态操作(如windowed join和aggregation)
  • 支持正好一次处理语义
  • 提供记录级的处理能力,从而实现毫秒级的低延迟
  • 支持基于事件时间的窗口操作,并且可处理晚到的数据(late arrival of records)
  • 同时提供底层的处理原语Processor(类似于Storm的spout和bolt),以及高层抽象的DSL(类似于Spark的map/group/reduce)
  • 什么是流式计算

    一般流式计算会与批量计算相比较。在流式计算模型中,输入是持续的,可以认为在时间上是无界的,也就意味着,永远拿不到全量数据去做计算。同时,计算结果是持续输出的,也即计算结果在时间上也是无界的。流式计算一般对实时性要求较高,同时一般是先定义目标计算,然后数据到来之后将计算逻辑应用于数据。同时为了提高计算效率,往往尽可能采用增量计算代替全量计算。


    Stream Processing

    批量处理模型中,一般先有全量数据集,然后定义计算逻辑,并将计算应用于全量数据。特点是全量计算,并且计算结果一次性全量输出。


    Batch Processing

    为什么要有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作为流式处理类库,直接提供具体的类给开发者调用,整个应用的运行方式主要由开发者控制,方便使用和调试。


    Library vs. Framework

    第二,虽然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 Stream整体架构

    Kafka Stream的整体架构图如下所示。


    Kafka Stream Architecture

    目前(Kafka 0.11.0.0)Kafka Stream的数据源只能如上图所示是Kafka。但是处理结果并不一定要如上图所示输出到Kafka。实际上KStream和Ktable的实例化都需要指定Topic。

    1
    2
    3
    KStream<String, String> stream = builder.stream("words-stream");

    KTable<String, String> table = builder.table("words-table", "words-store");

    另外,上图中的Consumer和Producer并不需要开发者在应用中显示实例化,而是由Kafka Stream根据参数隐式实例化和管理,从而降低了使用门槛。开发者只需要专注于开发核心业务逻辑,也即上图中Task内的部分。

    Processor Topology

    基于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
    38
    public class WordCountProcessor implements Processor<String, String> {

    private ProcessorContext context;
    private KeyValueStore<String, Integer> kvStore;

    @SuppressWarnings("unchecked")
    @Override
    public void init(ProcessorContext context) {
    this.context = context;
    this.context.schedule(1000);
    this.kvStore = (KeyValueStore<String, Integer>) context.getStateStore("Counts");
    }

    @Override
    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);
    });
    }

    @Override
    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();
    }

    @Override
    public void close() {
    this.kvStore.close();
    }

    }

    从上述代码中可见

  • process定义了对每条记录的处理逻辑,也印证了Kafka可具有记录级的数据处理能力。
  • context.scheduler定义了punctuate被执行的周期,从而提供了实现窗口操作的能力。
  • context.getStateStore提供的状态存储为有状态计算(如窗口,聚合)提供了可能。
  • Kafka Stream并行模型

    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全部在一个线程中运行。


    1 thread

    为了充分利用多线程的优势,可以设置Kafka Stream的线程数。下图展示了线程数为2时的并行模型。


    2 threads

    前文有提到,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机制拿到互补的数据集。


    2 instances

    既然实现了多进程部署,可以以同样的方式实现多机器部署。该部署方式也要求所有进程的APPLICATION_ID_CONFIG完全一样。从图上也可以看到,每个实例中的线程数并不要求一样。但是无论如何部署,Task总数总会保证一致。


    2 servers

    注意:Kafka Stream的并行模型,非常依赖于《Kafka设计解析(一)- Kafka背景及架构介绍》一文中介绍的Kafka分区机制和《Kafka设计解析(四)- Kafka Consumer设计解析》中介绍的Consumer的Rebalance机制。强烈建议不太熟悉这两种机制的朋友,先行阅读这两篇文章。

    这里对比一下Kafka Stream的Processor Topology与Storm的Topology。

  • Storm的Topology由Spout和Bolt组成,Spout提供数据源,而Bolt提供计算和数据导出。Kafka Stream的Processor Topology完全由Processor组成,因为它的数据固定由Kafka的Topic提供。
  • Storm的不同Bolt运行在不同的Executor中,很可能位于不同的机器,需要通过网络通信传输数据。而Kafka Stream的Processor Topology的不同Processor完全运行于同一个Task中,也就完全处于同一个线程,无需网络通信。
  • Storm的Topology可以同时包含Shuffle部分和非Shuffle部分,并且往往一个Topology就是一个完整的应用。而Kafka Stream的一个物理Topology只包含非Shuffle部分,而Shuffle部分需要通过through操作显示完成,该操作将一个大的Topology分成了2个子Topology。
  • Storm的Topology内,不同Bolt/Spout的并行度可以不一样,而Kafka Stream的子Topology内,所有Processor的并行度完全一样。
  • Storm的一个Task只包含一个Spout或者Bolt的实例,而Kafka Stream的一个Task包含了一个子Topology的所有Processor。
  • KTable vs. KStream

    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 vs. KTable

    此时如果对该KStream和KTable分别基于key做Group,对Value进行Sum,得到的结果将会不同。对KStream的计算结果是<Jack,4><Lily,7><Mike,4>。而对Ktable的计算结果是<Mike,4><Jack,3><Lily,5>

    State store

    流式处理中,部分操作是无状态的,例如过滤操作(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 Stream如何解决流式系统中关键问题

    时间

    在流式数据处理中,时间是数据的一个非常重要的属性。从Kafka 0.10开始,每条记录除了Key和Value外,还增加了timestamp属性。目前Kafka Stream支持三种时间

  • 事件发生时间。事件发生的时间,包含在数据记录中。发生时间由Producer在构造ProducerRecord时指定。并且需要Broker或者Topic将message.timestamp.type设置为CreateTime(默认值)才能生效。
  • 消息接收时间,也即消息存入Broker的时间。当Broker或Topic将message.timestamp.type设置为LogAppendTime时生效。此时Broker会在接收到消息后,存入磁盘前,将其timestamp属性值设置为当前机器时间。一般消息接收时间比较接近于事件发生时间,部分场景下可代替事件发生时间。
  • 消息处理时间,也即Kafka Stream处理消息时的时间。
  • 注:Kafka Stream允许通过实现org.apache.kafka.streams.processor.TimestampExtractor接口自定义记录时间。

    窗口

    前文提到,流式数据是在时间上无界的数据。而聚合操作只能作用在特定的数据集,也即有界的数据集上。因此需要通过某种方式从无界的数据集上按特定的语义选取出有界的数据。窗口是一种非常常用的设定计算边界的方式。不同的流式处理系统支持的窗口类似,但不尽相同。

    Kafka Stream支持的窗口如下。

    1. Hopping Time Window 该窗口定义如下图所示。它有两个属性,一个是Window size,一个是Advance interval。Window size指定了窗口的大小,也即每次计算的数据集的大小。而Advance interval定义输出的时间间隔。一个典型的应用场景是,每隔5秒钟输出一次过去1个小时内网站的PV或者UV。


      Hopping Time Window
    2. Tumbling Time Window该窗口定义如下图所示。可以认为它是Hopping Time Window的一种特例,也即Window size和Advance interval相等。它的特点是各个Window之间完全不相交。


      Tumbling Time Window
    3. Sliding Window该窗口只用于2个KStream进行Join计算时。该窗口的大小定义了Join两侧KStream的数据记录被认为在同一个窗口的最大时间差。假设该窗口的大小为5秒,则参与Join的2个KStream中,记录时间差小于5的记录被认为在同一个窗口中,可以进行Join计算。

    4. Session Window该窗口用于对Key做Group后的聚合操作中。它需要对Key做分组,然后对组内的数据根据业务需求定义一个窗口的起始点和结束点。一个典型的案例是,希望通过Session Window计算某个用户访问网站的时间。对于一个特定的用户(用Key表示)而言,当发生登录操作时,该用户(Key)的窗口即开始,当发生退出操作或者超时时,该用户(Key)的窗口即结束。窗口结束时,可计算该用户的访问时间或者点击卡塔尔世界杯bobAPP手机端下载在线等。

    Join

    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。具体方法是

  • 参与Join的KTable或KStream的Key类型相同(实际上,业务含意也应该相同)
  • 参与Join的KTable或KStream对应的Topic的Partition数相同
  • Partitioner策略的最终结果等效(实现不需要完全一样,只要效果一样即可),也即Key相同的情况下,被分配到ID相同的Partition内
  • 如果上述条件不满足,可通过调用如下方法使得它满足上述条件。

    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从如下几个方面进行容错

  • 高可用的Partition保证无数据丢失。每个Task计算一个Partition,而Kafka数据复制机制保证了Partition内数据的高可用性,故无数据丢失风险。同时由于数据是持久化的,即使任务失败,依然可以重新计算。
  • 状态存储实现快速故障恢复和从故障点继续处理。对于Join和聚合及窗口等有状态计算,状态存储可保存中间状态。即使发生Failover或Consumer Rebalance,仍然可以通过状态存储恢复中间状态,从而可以继续从Failover或Consumer Rebalance前的点继续计算。
  • KTable与retention period提供了对乱序数据的处理能力。
  • Kafka Stream应用示例

    下面结合一个案例来讲解如何开发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
    11
    public class OrderTimestampExtractor implements TimestampExtractor {

    @Override
    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
    9
    orderUserStream = 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
    10
    orderUserStrea
    .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。

    总结

  • Kafka Stream的并行模型完全基于Kafka的分区机制和Rebalance机制,实现了在线动态调整并行度
  • 同一Task包含了一个子Topology的所有Processor,使得所有处理逻辑都在同一线程内完成,避免了不必的网络通信开销,从而提高了效率。
  • through方法提供了类似Spark的Shuffle机制,为使用不同分区策略的数据提供了Join的可能
  • log compact提高了基于Kafka的state store的加载效率
  • state store为状态计算提供了可能
  • 基于offset的计算进度管理以及基于state store的中间状态管理为发生Consumer rebalance或Failover时从断点处继续处理提供了可能,并为系统容错性提供了保障
  • KTable的引入,使得聚合计算拥用了处理乱序问题的能力
  • Kafka系列文章

  • Kafka设计解析(一)- Kafka背景及架构介绍
  • Kafka设计解析(二)- Kafka High Availability (上)
  • Kafka设计解析(三)- Kafka High Availability (下)
  • Kafka设计解析(四)- Kafka Consumer设计解析
  • Kafka设计解析(五)- Kafka性能测试方法及Benchmark报告
  • Kafka设计解析(六)- Kafka高性能架构之道
  • Kafka设计解析(七)- Kafka Stream
  • ]]>
    本文介绍了Kafka Stream的背景,如Kafka Stream是什么,什么是流式计算,以及为什么要有Kafka Stream。接着介绍了Kafka Stream的整体架构,并行模型,状态存储,以及主要的两种数据集KStream和KTable。并且分析了Kafka Stream如何解决流式系统中的关键问题,如时间定义,窗口操作,Join操作,聚合操作,以及如何处理乱序和提供容错能力。最后结合示例讲解了如何使用Kafka Stream。
    技术世界 http://www.jasongj.com/java/concurrenthashmap/ 2017-05-30T22:42:26.000Z 2017-05-30T22:42:26.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/java/concurrenthashmap/

    线程不安全的HashMap

    众所周知,HashMap是非线程安全的。而HashMap的线程不安全主要体现在resize时的死循环及使用迭代器时的fast-fail上。

    注:本章的代码均基于JDK 1.7.0_67

    HashMap工作原理

    HashMap数据结构

    常用的底层数据结构主要有数组和链表。数组存储区间连续,占用内存较多,寻址容易,插入和删除困难。链表存储区间离散,占用内存较少,寻址困难,插入和删除容易。

    HashMap要实现的是哈希表的效果,尽量实现O(1)级别的增删改查。它的具体实现则是同时使用了数组和链表,可以认为最外层是一个数组,数组的每个元素是一个链表的表头。

    HashMap寻址方式

    对于新插入的数据或者待读取的数据,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
    8
    public 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
    4
    int h = hashSeed;
    h ^= k.hashCode();
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);

    resize死循环

    transfer方法

    当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
    15
    void 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可见,转移时链表顺序反转。

    1. 遍历原数组中的元素
    2. 对链表上的每一个节点遍历:用next取得要转移那个元素的下一个,将e转移到新数组的头部,使用头插法插入节点
    3. 循环2,直到链表节点全部转移
    4. 循环1,直到所有元素全部转移

    单线程rehash

    单线程情况下,rehash无问题。下图演示了单线程条件下的rehash过程
    HashMap rehash single thread

    多线程并发下的rehash

    这里假设有两个线程同时执行了put操作并引发了rehash,执行了transfer方法,并假设线程一进入transfer方法并执行完next = e.next后,因为线程调度所分配时间片用完而“暂停”,此时线程二完成了transfer方法的执行。此时状态如下。

    HashMap rehash multi thread step 1

    接着线程1被唤醒,继续执行第一轮循环的剩余部分

    1
    2
    3
    e.next = newTable[1] = null
    newTable[1] = e = key(5)
    e = next = key(9)

    结果如下图所示
    HashMap rehash multi thread step 2

    接着执行下一轮循环,结果状态图如下所示
    HashMap rehash multi thread step 3

    继续下一轮循环,结果状态图如下所示
    HashMap rehash multi thread step 4

    此时循环链表形成,并且key(11)无法加入到线程1的新数组。在下一次访问该链表时会出现死循环。

    Fast-fail

    产生原因

    在使用迭代器的过程中如果HashMap被修改,那么ConcurrentModificationException将被抛出,也即Fast-fail策略。

    当HashMap的iterator()方法被调用时,会构造并返回一个新的EntryIterator对象,并将EntryIterator的expectedModCount设置为HashMap的modCount(该变量记录了HashMap被修改的卡塔尔世界杯bobAPP手机端下载在线)。

    1
    2
    3
    4
    5
    6
    7
    8
    HashIterator() {
    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。

    Java 7基于分段锁的ConcurrentHashMap

    注:本章的代码均基于JDK 1.7.0_67

    数据结构

    Java 7中的ConcurrentHashMap的底层数据结构仍然是数组和链表。与HashMap不同的是,ConcurrentHashMap最外层不是一个大的数组,而是一个Segment的数组。每个Segment包含一个与HashMap数据结构差不多的链表数组。整体数据结构如下图所示。
    JAVA 7 ConcurrentHashMap

    寻址方式

    在读写某个Key时,先取该Key的哈希值。并将哈希值的高N位对Segment个数取模从而得到该Key应该属于哪个Segment,接着如同操作HashMap一样操作这个Segment。为了保证不同的值均匀分布到不同的Segment,需要通过如下方法计算哈希值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    private 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
    9
    int 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
    2
    HashEntry<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手机端下载在线超过阈值时切换为互斥锁。

    size操作

    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
    37
    public 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相比,有以下不同点

  • ConcurrentHashMap线程安全,而HashMap非线程安全
  • HashMap允许Key和Value为null,而ConcurrentHashMap不允许
  • HashMap不允许通过Iterator遍历的同时通过HashMap修改,而ConcurrentHashMap允许该行为,并且该更新对后续的遍历可见
  • Java 8基于CAS的ConcurrentHashMap

    注:本章的代码均基于JDK 1.8.0_111

    数据结构

    Java 7为实现并行访问,引入了Segment这一结构,实现了分段锁,理论上最大并发度与Segment个数相等。Java 8为进一步提高并发性,摒弃了分段锁的方案,而是直接使用一个大的数组。同时为了提高哈希碰撞下的寻址性能,Java 8在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红黑树(寻址时间复杂度为O(long(N)))。其数据结构如下图所示


    JAVA 8 ConcurrentHashMap

    寻址方式

    Java 8的ConcurrentHashMap同样是通过Key的哈希值与数组长度取模确定该Key在数组中的索引。同样为了避免不太好的Key的hashCode设计,它通过如下方法计算得到Key的最终哈希值。不同的是,Java 8的ConcurrentHashMap作者认为引入红黑树后,即使哈希冲突比较严重,寻址效率也足够高,所以作者并未在哈希值的计算上做过多设计,只是将Key的hashCode值与其高16位作异或并保证最高位为0(从而保证最终结果为正整数)。

    1
    2
    3
    static 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
    6
    static 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
    3
    static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
    }

    size操作

    put方法和remove方法都会通过addCount方法维护Map的size。size方法通过sumCount获取由addCount方法维护的Map的size。

    Java进阶系列

  • Java进阶(一)Annotation(注解)
  • Java进阶(二)当我们说线程安全时,到底在说什么
  • Java进阶(三)多线程开发关键技术
  • Java进阶(四)线程间通信方式对比
  • Java进阶(五)NIO和Reactor模式进阶
  • Java进阶(六)从ConcurrentHashMap的演进看Java多线程核心技术
  • ]]>
    本文分析了HashMap的实现原理,以及resize可能引起死循环和Fast-fail等线程不安全行为。同时结合源码从数据结构,寻址方式,同步方式,计算size等角度分析了JDK 1.7和JDK 1.8中ConcurrentHashMap的实现原理。
    技术世界 http://www.jasongj.com/kafka/high_throughput/ 2017-04-17T01:53:13.000Z 2018-08-31T00:31:35.000Z

    原创文章,转载请务必将下面这段话置于文章开头处。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/kafka/high_throughput/

    摘要

    上一篇文章《Kafka设计解析(五)- Kafka性能测试方法及Benchmark报告》从测试角度说明了Kafka的性能。本文从宏观架构层面和具体实现层面分析了Kafka如何实现高性能。

    宏观架构层面

    利用Partition实现并行处理

    Partition提供并行处理的能力

    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。

    Partition是最小并发粒度

    如同《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的任何数据。
    Kafka Consumer

    以Spark消费Kafka数据为例,如果所消费的Topic的Partition数为N,则有效的Spark最大并行度也为N。即使将Spark的Executor数设置为N+M,最多也只有N个Executor可同时处理该Topic的数据。

    ISR实现可用性与数据一致性的动态平衡

    CAP理论

    CAP理论是指,分布式系统中,一致性、可用性和分区容忍性最多只能同时满足两个。

    一致性

  • 通过某个节点的写操作结果对后面通过其它节点的读操作可见
  • 如果更新数据后,并发访问情况下后续读操作可立即感知该更新,称为强一致性
  • 如果允许之后部分或者全部感知不到该更新,称为弱一致性
  • 若在之后的一段时间(通常该时间不固定)后,一定可以感知到该更新,称为最终一致性
  • 可用性

  • 任何一个没有发生故障的节点必须在有限的时间内返回合理的结果
  • 分区容忍性

  • 部分节点宕机或者无法与其它节点通信时,各分区间还可保持分布式系统的功能
  • 一般而言,都要求保证分区容忍性。所以在CAP理论下,更多的是需要在可用性和一致性之间做权衡。

    常用数据复制及一致性方案

    Master-Slave

  • RDBMS的读写分离即为典型的Master-Slave方案
  • 同步复制可保证强一致性但会影响可用性
  • 异步复制可提供高可用性但会降低一致性
  • WNR

  • 主要用于去中心化的分布式系统中。DynamoDB与Cassandra即采用此方案或其变种
  • N代表总副本数,W代表每次写操作要保证的最少写成功的副本数,R代表每次读至少要读取的副本数
  • 当W+R>N时,可保证每次读取的数据至少有一个副本拥有最新的数据
  • 多个写操作的顺序难以保证,可能导致多副本间的写操作顺序不一致。Dynamo通过向量时钟保证最终一致性
  • Paxos及其变种

  • Google的Chubby,Zookeeper的原子广播协议(Zab),RAFT等
  • 基于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的策略稍微有些区别。

  • 对于0.8.*版本,如果Follower在replica.lag.time.max.ms时间内未向Leader发送Fetch请求(也即数据复制请求),则Leader会将其从ISR中移除。如果某Follower持续向Leader发送Fetch请求,但是它与Leader的数据差距在replica.lag.max.messages以上,也会被Leader从ISR中移除。
  • 从0.9.0.0版本开始,replica.lag.max.messages被移除,故Leader不再考虑Follower落后的消息条数。另外,Leader不仅会判断Follower是否在replica.lag.time.max.ms时间内向其发送Fetch请求,同时还会考虑Follower是否在该时间内与之保持同步。
  • 0.10.* 版本的策略与0.9.*版一致
  • 对于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的数据复制方案原理如下图所示。
    Kafka Replication

    如上图所示,在第一步中,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中。

    使用ISR方案的原因

  • 由于Leader可移除不能及时与之同步的Follower,故与同步复制相比可避免最慢的Follower拖慢整体速度,也即ISR提高了系统可用性。
  • ISR中的所有Follower都包含了所有Commit过的消息,而只有Commit过的消息才会被Consumer消费,故从Consumer的角度而言,ISR中的所有Replica都始终处于同步状态,从而与异步复制方案相比提高了数据一致性。
  • ISR可动态调整,极限情况下,可以只包含Leader,极大提高了可容忍的宕机的Follower的数量。与Majority Quorum方案相比,容忍相同个数的节点失败,所要求的总节点数少了近一半。
  • ISR相关配置说明

  • Broker的min.insync.replicas参数指定了Broker所要求的ISR最小长度,默认值为1。也即极限情况下ISR可以只包含Leader。但此时如果Leader宕机,则该Partition不可用,可用性得不到保证。
  • 只有被ISR中所有Replica同步的消息才被Commit,但Producer发布数据时,Leader并不需要ISR中的所有Replica同步该数据才确认收到数据。Producer可以通过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

    使用Page Cache的好处如下

  • I/O Scheduler会将连续的小块写组装成大块的物理写从而提高性能
  • I/O Scheduler会尝试将一些写操作重新按顺序排好,从而减少磁盘头的移动时间
  • 充分利用所有空闲内存(非JVM内存)。如果使用应用层Cache(即JVM堆内存),会增加GC负担
  • 读操作可直接在Page Cache内进行。如果消费和生产速度相当,甚至不需要通过物理磁盘(直接通过Page Cache)交换数据
  • 如果进程重启,JVM内的Cache会失效,但Page Cache仍然可用
  • Broker收到数据后,写磁盘时只是将数据写入Page Cache,并不保证数据一定完全写入磁盘。从这一点看,可能会造成机器宕机时,Page Cache内的数据未写入磁盘从而造成数据丢失。但是这种丢失只发生在机器断电等造成操作系统不工作的场景,而这种场景完全可以由Kafka层面的Replication机制去解决。如果为了保证这种情况下数据不丢失而强制将Page Cache中的数据Flush到磁盘,反而会降低性能。也正因如此,Kafka虽然提供了flush.messagesflush.ms两个参数将Page Cache中的数据强制Flush到磁盘,但是Kafka并不建议使用。

    如果数据消费速度与生产速度相当,甚至不需要通过物理磁盘交换数据,而是直接通过Page Cache交换数据。同时,Follower从Leader Fetch数据时,也可通过Page Cache完成。下图为某Partition的Leader节点的网络/磁盘读写信息。

    Kafka I/O page cache

    从上图可以看到,该Broker每秒通过网络从Producer接收约35MB数据,虽然有Follower从该Broker Fetch数据,但是该Broker基本无读磁盘。这是因为该Broker直接从Page Cache中将数据取出返回给了Follower。

    支持多Disk Drive

    Broker的log.dirs配置项,允许配置多个文件夹。如果机器上有多个Disk Drive,可将不同的Disk挂载到不同的目录,然后将这些目录都配置到log.dirs里。Kafka会尽可能将不同的Partition分配到不同的目录,也即不同的Disk上,从而充分利用了多Disk的优势。

    零拷贝

    Kafka中存在大量的网络数据持久化到磁盘(Producer到Broker)和磁盘文件通过网络发送(Broker到Consumer)的过程。这一过程的性能直接影响Kafka的整体吞吐量。

    传统模式下的四次拷贝与四次上下文切换

    以将磁盘文件通过网络发送为例。传统模式下,一般使用如下伪代码所示的方法先将文件数据读入内存,然后通过Socket将内存中的数据发送出去。

    1
    2
    buffer = File.read
    Socket.send(buffer)

    这一过程实际上发生了四卡塔尔世界杯bobAPP手机端下载在线据拷贝。首先通过系统调用将文件数据读入到内核态Buffer(DMA拷贝),然后应用程序将内存态Buffer数据读入到用户态Buffer(CPU拷贝),接着用户程序通过Socket发送数据时将用户态Buffer数据拷贝到内核态Buffer(CPU拷贝),最后通过DMA拷贝将数据拷贝到NIC Buffer。同时,还伴随着四次上下文切换,如下图所示。

    BIO 四次拷贝 四次上下文切换

    sendfile和transferTo实现零拷贝

    Linux 2.4+内核通过sendfile系统调用,提供了零拷贝。数据通过DMA拷贝到内核态Buffer后,直接通过DMA拷贝到NIC Buffer,无需CPU拷贝。这也是零拷贝这一说法的来源。除了减少数据拷贝外,因为整个读文件-网络发送由一个sendfile调用完成,整个过程只有两次上下文切换,因此大大提高了性能。零拷贝过程如下图所示。

    BIO 零拷贝 两次上下文切换

    从具体实现来看,Kafka的数据传输通过TransportLayer来完成,其子类PlaintextTransportLayer通过Java NIO的FileChannel的transferTotransferFrom方法实现零拷贝,如下所示。

    1
    2
    3
    4
    @Override
    public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
    return fileChannel.transferTo(position, count, socketChannel);
    }

    注: transferTotransferFrom并不保证一定能使用零拷贝。实际上是否能使用零拷贝与操作系统相关,如果操作系统提供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.sizelinger.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)来减少实际网络传输和磁盘存储的数据规模,从而提高吞吐率。这里要注意,如果使用的序列化方法太慢,即使压缩比非常高,最终的效率也不一定高。

    Kafka系列文章

  • Kafka设计解析(一)- Kafka背景及架构介绍
  • Kafka设计解析(二)- Kafka High Availability (上)
  • Kafka设计解析(三)- Kafka High Availability (下)
  • Kafka设计解析(四)- Kafka Consumer设计解析
  • Kafka设计解析(五)- Kafka性能测试方法及Benchmark报告
  • Kafka设计解析(六)- Kafka高性能架构之道
  • ]]>
    本文从宏观架构层面和微观实现层面分析了Kafka如何实现高性能。包含Kafka如何利用Partition实现并行处理和提供水平扩展能力,如何通过ISR实现可用性和数据一致性的动态平衡,如何使用NIO和Linux的sendfile实现零拷贝以及如何通过顺序读写和数据压缩实现磁盘的高效利用。
    技术世界 http://www.jasongj.com/ml/classification/ 2017-04-11T23:02:13.000Z 2017-11-12T00:01:01.000Z

    原创文章,转载请务必将下面这段话置于文章开头处。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/ml/classification/

    摘要

    本文详述了如何通过数据预览,探索式数据分析,缺失数据填补,删除关联特征以及派生新特征等方法,在Kaggle的Titanic幸存预测这一分类问题竞赛中获得前2%排名的具体方法。

    竞赛内容介绍

    Titanic幸存预测是Kaggle上参赛人数最多的竞赛之一。它要求参赛选手通过训练数据集分析出什么类型的人更可能幸存,并预测出测试数据集中的所有乘客是否生还。

    该项目是一个二元分类问题

    如何取得排名前2%的成绩

    加载数据

    在加载数据之前,先通过如下代码加载之后会用到的所有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条为测试数据

  • PassengerId 整型变量,标识乘客的ID,递增变量,对预测无帮助
  • Survived 整型变量,标识该乘客是否幸存。0表示遇难,1表示幸存。将其转换为factor变量比较方便处理
  • Pclass 整型变量,标识乘客的社会-经济状态,1代表Upper,2代表Middle,3代表Lower
  • Name 字符型变量,除包含姓和名以外,还包含Mr. Mrs. Dr.这样的具有西方文化特点的信息
  • Sex 字符型变量,标识乘客性别,适合转换为factor类型变量
  • Age 整型变量,标识乘客年龄,有缺失值
  • SibSp 整型变量,代表兄弟姐妹及配偶的个数。其中Sib代表Sibling也即兄弟姐妹,Sp代表Spouse也即配偶
  • Parch 整型变量,代表父母或子女的个数。其中Par代表Parent也即父母,Ch代表Child也即子女
  • Ticket 字符型变量,代表乘客的船票号
  • Fare 数值型,代表乘客的船票价
  • Cabin 字符型,代表乘客所在的舱位,有缺失值
  • Embarked 字符型,代表乘客登船口岸,适合转换为factor型变量
  • 探索式数据分析

    乘客社会等级越高,幸存率越高

    对于第一个变量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"

    不同Title的乘客幸存率不同

    乘客姓名重复度太低,不适合直接使用。而姓名中包含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"

    父母与子女数为1到3的乘客更可能幸存

    对于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"

    FamilySize为2到4的乘客幸存可能性较高

    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为S的乘客幸存率较低

    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值

    从如下数据可见,缺失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()

    Fare median value of each Embarked and Pclass

    因此可以将缺失的Embarked值设置为’C’。

    1
    2
    data$Embarked[is.na(data$Embarked)] <- 'C'
    data$Embarked <- as.factor(data$Embarked)

    中位数填补一个缺失的Fare值

    由于缺失Fare值的记录非常少,一般可直接使用平均值或者中位数填补该缺失值。这里使用乘客的Fare中位数填补缺失值。

    1
    data$Fare[is.na(data$Fare)] <- median(data$Fare, na.rm=TRUE)

    将缺失的Cabin设置为默认值

    缺失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。

    去掉IV较低的Cabin

    由于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
    2
    data$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 rank first 2%

    总结

    本文详述了如何通过数据预览,探索式数据分析,缺失数据填补,删除关联特征以及派生新特征等方法,在Kaggle的Titanic幸存预测这一分类问题竞赛中获得前2%排名的具体方法。

    《机器学习》系列文章

  • 机器学习(一) 从一个R语言案例学线性回归
  • 机器学习(二) 如何做到Kaggle排名前2%
  • 机器学习(三) 关联规则R语言实战Apriori
  • ]]>
    本文详述了如何通过数据预览,探索式数据分析,缺失数据填补,删除关联特征以及派生新特征等方法,在Kaggle的Titanic幸存预测这一分类问题竞赛中获得前2%排名的具体方法。
    技术世界 http://www.jasongj.com/spark/skew/ 2017-02-28T01:02:13.000Z 2017-10-17T00:24:53.000Z

    原创文章,转载请务必将下面这段话置于文章开头处。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/spark/skew/

    摘要

    本文结合实例详细阐明了Spark数据倾斜的几种场景以及对应的解决方案,包括避免数据源倾斜,调整并行度,使用自定义Partitioner,使用Map侧Join代替Reduce侧Join,给倾斜Key加上随机前缀等。

    为何要处理数据倾斜(Data Skew)

    什么是数据倾斜

    对Spark/Hadoop这样的大数据系统来讲,数据量大并不可怕,可怕的是数据倾斜。

    何谓数据倾斜?数据倾斜指的是,并行处理的数据集中,某一部分(如Spark或Kafka的一个Partition)的数据显著多于其它部分,从而使得该部分的处理速度成为整个数据集处理的瓶颈。

    对于分布式系统而言,理想情况下,随着系统规模(节点数量)的增加,应用整体耗时线性下降。如果一台机器处理一批大量数据需要120分钟,当机器数量增加到三时,理想的耗时为120 / 3 = 40分钟,如下图所示
    ideal scale out
      
    但是,上述情况只是理想情况,实际上将单机任务转换成分布式任务后,会有overhead,使得总的任务量较之单机时有所增加,所以每台机器的执行时间加起来比单台机器时更大。这里暂不考虑这些overhead,假设单机任务转换成分布式任务后,总任务量不变。
      
    但即使如此,想做到分布式情况下每台机器执行时间是单机时的1 / N,就必须保证每台机器的任务量相等。不幸的是,很多时候,任务的分配是不均匀的,甚至不均匀到大部分任务被分配到个别机器上,其它大部分机器所分配的任务量只占总得的小部分。比如一台机器负责处理80%的任务,另外两台机器各处理10%的任务,如下图所示
    unideal scale out
      
    在上图中,机器数据增加为三倍,但执行时间只降为原来的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的数据来源主要分为如下两类

  • 从数据源直接读取。如读取HDFS,Kafka
  • 读取上一个Stage的Shuffle数据
  • 如何缓解/消除数据倾斜

    避免数据源的数据倾斜 ———— 读Kafka

    以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
    3
    protected 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
    7
    SparkConf 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。
    uncompressed files

    之后将8.5GB大小的文件使用gzip压缩,压缩后大小仅为25.3MB。
    compressed files

    使用如上代码对未压缩文件夹进行单词计数操作。Split大小为 max(minSize, min(goalSize, blockSize) = max(1 B, min((271.9 10+8.5 1024) / 1 MB, 128 MB) = 128MB。无明显数据倾斜。
    splitable_unskewed

    使用同样代码对包含压缩文件的文件夹进行同样的单词计数操作。未压缩文件的Split大小仍然为128MB,而压缩文件(gzip压缩)由于不可切分,且大小仅为25.3MB,因此该文件作为一个单独的Split/Partition。虽然该文件相对较小,但是它由8.5GB文件压缩而来,包含数据量是其它未压缩文件的32倍,因此处理该Split/Partition/文件的Task耗时为4.4分钟,远高于其它Task的10秒。
    compressed file skew

    由于上述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秒。
    compressed unsplitable file skew

    总结

    适用场景
    数据源侧存在不可切分文件,且文件内包含的数据量相差较大。

    解决方案
    尽量使用可切分的格式代替不可切分的格式,或者保证各文件实际包含数据量大致相同。

    优势
    可撤底消除数据源侧数据倾斜,效果显著。

    劣势
    数据源一般来源于外部系统,需要外部系统的支持。

    调整并行度分散同一个Task的不同Key

    原理

    Spark在做Shuffle时,默认使用HashPartitioner(非Hash Shuffle)对数据进行分区。如果并行度设置的不合适,可能造成大量不相同的Key对应的数据被分配到了同一个Task上,造成该Task所处理的数据远大于其它Task,从而造成数据倾斜。

    如果调整Shuffle时的并行度,使得原本被分配到同一Task的不同Key发配到不同Task上处理,则可降低原Task所需处理的数据量,从而缓解数据倾斜问题造成的短板效应。
    spark change parallelism

    案例

    现有一张测试表,名为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决定。
    data skew

    在这种情况下,可以通过调整Shuffle并行度,使得原来被分配到同一个Task(即该例中的Task 8)的不同Key分配到不同Task,从而降低Task 8所需处理的数据量,缓解数据倾斜。

    通过groupByKey(48)将Shuffle并行度调整为48,重新提交到Spark。新的Job的GroupBy Stage所有Task状态如下图所示。
    add parallelism

    从上图可知,记录数最多的Task 20处理的记录数约为1125万,相比于并行度为12时Task 8的4500万,降低了75%左右,而其耗时从原来Task 8的38秒降到了24秒。

    在这种场景下,调整并行度,并不意味着一定要增加并行度,也可能是减小并行度。如果通过groupByKey(11)将Shuffle并行度调整为11,重新提交到Spark。新Job的GroupBy Stage的所有Task状态如下图所示。
    reduce parallelism

    从上图可见,处理记录数最多的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

    原理

    使用自定义的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() {
    @Override
    public int numPartitions() {
    return 12;
    }

    @Override
    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所处理的数据集大小相当。
    customizec partitioner

    总结

    适用场景
    大量不同的Key被分配到了相同的Task造成该Task数据量过大。

    解决方案
    使用自定义的Partitioner实现类代替默认的HashPartitioner,尽量将所有不同的Key均匀分配到不同的Task中。

    优势
    不影响原有的并行度设计。如果改变并行度,后续Stage的并行度也会默认改变,可能会影响后续Stage。

    劣势
    适用场景有限,只能将不同Key分散开,对于同一Key对应数据集非常大的场景不适用。效果与调整并行度类似,只能缓解数据倾斜而不能完全消除数据倾斜。而且需要根据数据特点自定义专用的Partitioner,不够灵活。

    将Reduce side Join转变为Map side Join

    原理

    通过Spark的Broadcast机制,将Reduce侧Join转化为Map侧Join,避免Shuffle从而完全消除Shuffle带来的数据倾斜。
    spark map join

    案例

    通过如下SQL创建一张具有倾斜Key且总记录数为1.5亿的大表test。

    1
    2
    3
    4
    5
    6
    INSERT 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
    5
    INSERT 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
    5
    INSERT 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中。
    reduce join DAG

    从下图可见,Join Stage各Task处理的数据倾斜严重,处理数据量最大的Task耗时7.1分钟,远高于其它无数据倾斜的Task约2秒的耗时。
    reduce join DAG

    接下来,尝试通过Broadcast实现Map侧Join。实现Map侧Join的方法,并非直接通过CACHE TABLE test_new将小表test_new进行cache。现通过如下SQL进行Join。

    1
    2
    3
    4
    5
    6
    CACHE 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表,而是扫描内存中缓存的表。
    reduce join DAG

    并且数据倾斜仍然存在。如下图所示,最慢的Task耗时为7.1分钟,远高于其它Task的约2秒。
    reduce join DAG

    正确的使用Broadcast实现Map侧Join的方式是,通过SET spark.sql.autoBroadcastJoinThreshold=104857600;将Broadcast的阈值设置得足够大。

    再次通过如下SQL进行Join。

    1
    2
    3
    4
    5
    6
    SET 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。
    reduce join DAG

    并且从下图可见,各Task耗时相当,无明显数据倾斜现象。并且总耗时为1.5分钟,远低于Reduce侧Join的7.3分钟。
    reduce join DAG

    总结

    适用场景
    参与Join的一边数据集足够小,可被加载进Driver并通过Broadcast方法广播到各个Executor中。

    解决方案
    在Java/Scala代码中将小数据集数据拉取到Driver,然后通过Broadcast方案将小数据集的数据广播到各Executor。或者在使用SQL前,将Broadcast的阈值调整得足够大,从而使用Broadcast生效。进而将Reduce侧Join替换为Map侧Join。

    优势
    避免了Shuffle,彻底消除了数据倾斜产生的条件,可极大提升性能。

    劣势
    要求参与Join的一侧数据集足够小,并且主要适用于Join的场景,不适合聚合的场景,适用条件有限。

    为skew的key增加随机前/后缀

    原理

    为数据量特别大的Key增加随机前/后缀,使得原来Key相同的数据变为Key不相同的数据,从而使倾斜的数据集分散到不同的Task中,彻底解决数据倾斜问题。Join另一则的数据中,与倾斜Key对应的部分数据,与随机前缀集作笛卡尔乘积,从而保证无论数据倾斜侧倾斜Key如何加前缀,都能与之正常Join。
    spark random prefix

    案例

    通过如下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
    30
    public 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分钟。
    few skewed key join

    通过分析Join Stage的所有Task可知,在其它Task所处理记录数为192.71万的同时Task 32的处理的记录数为992.72万,故它耗时为1.7分钟,远高于其它Task的约10秒。这与上文准备数据集时,将id为9500048为9500096对应的数据量设置非常大,其它id对应的数据集非常均匀相符合。
    few skewed key join

    现通过如下操作,实现倾斜Key的分散处理

  • 将leftRDD中倾斜的key(即9500048与9500096)对应的数据单独过滤出来,且加上1到24的随机前缀,并将前缀与原数据用逗号分隔(以方便之后去掉前缀)形成单独的leftSkewRDD
  • 将rightRDD中倾斜key对应的数据抽取出来,并通过flatMap操作将该数据集中每条数据均转换为24条数据(每条分别加上1到24的随机前缀),形成单独的rightSkewRDD
  • 将leftSkewRDD与rightSkewRDD进行Join,并将并行度设置为48,且在Join过程中将随机前缀去掉,得到倾斜数据集的Join结果skewedJoinRDD
  • 将leftRDD中不包含倾斜Key的数据抽取出来作为单独的leftUnSkewRDD
  • 对leftUnSkewRDD与原始的rightRDD进行Join,并行度也设置为48,得到Join结果unskewedJoinRDD
  • 通过union算子将skewedJoinRDD与unskewedJoinRDD进行合并,从而得到完整的Join结果集
  • 具体实现代码如下

    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秒。
    few skewed key join

    通过分析Join Stage的所有Task可知

  • 由于Join分倾斜数据集Join和非倾斜数据集Join,而各Join的并行度均为48,故总的并行度为96
  • 由于提交任务时,设置的Executor个数为4,每个Executor的core数为12,故可用Core数为48,所以前48个Task同时启动(其Launch时间相同),后48个Task的启动时间各不相同(等待前面的Task结束才开始)
  • 由于倾斜Key被加上随机前缀,原本相同的Key变为不同的Key,被分散到不同的Task处理,故在所有Task中,未发现所处理数据集明显高于其它Task的情况
  • few skewed key join

    实际上,由于倾斜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分开处理,需要扫描数据集两遍,增加了开销。

    大表随机添加N种随机前缀,小表扩大N倍

    原理

    如果出现数据倾斜的Key比较多,上一种方法将这些大量的倾斜Key分拆出来,意义不大。此时更适合直接对存在数据倾斜的数据集全部加上随机前缀,然后对另外一个不存在严重数据倾斜的数据集整体与随机前缀集作笛卡尔乘积(即将数据量扩大N倍)。
    spark random prefix

    案例

    这里给出示例代码,读者可参考上文中分拆出少数倾斜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
    public 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的多少等)综合使用上文所述的多种方法。

    Spark 系列文章

  • Spark性能优化之道——解决Spark数据倾斜(Data Skew)的N种姿势
  • Spark SQL / Catalyst 内部原理 与 RBO
  • Spark SQL 性能优化再进一步 CBO 基于代价的优化
  • Spark CommitCoordinator 保证数据一致性
  • Spark 灰度发布在十万级节点上的成功实践 CI CD
  • ]]>
    本文结合实例详细阐明了Spark数据倾斜的几种场景以及对应的解决方案,包括避免数据源倾斜,调整并行度,使用自定义Partitioner,使用Map侧Join代替Reduce侧Join,给倾斜Key加上随机前缀等。
    技术世界 http://www.jasongj.com/java/nio_reactor/ 2016-08-22T22:55:29.000Z 2017-02-15T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/java/nio_reactor/

    Java I/O模型

    同步 vs. 异步

    同步I/O 每个请求必须逐个地被处理,一个请求的处理会导致整个流程的暂时等待,这些事件无法并发地执行。用户线程发起I/O请求后需要等待或者轮询内核I/O操作完成后才能继续执行。

    异步I/O 多个请求可以并发地执行,一个请求或者任务的执行不会导致整个流程的暂时等待。用户线程发起I/O请求后仍然继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数。

    阻塞 vs. 非阻塞

    阻塞 某个请求发出后,由于该请求操作需要的条件不满足,请求操作一直阻塞,不会返回,直到条件满足。

    非阻塞 请求发出后,若该请求需要的条件不满足,则立即返回一个标志信息告知条件不满足,而不会一直等待。一般需要通过循环判断请求条件是否满足来获取请求结果。

    需要注意的是,阻塞并不等价于同步,而非阻塞并非等价于异步。事实上这两组概念描述的是I/O模型中的两个不同维度。

    同步和异步着重点在于多个任务执行过程中,后发起的任务是否必须等先发起的任务完成之后再进行。而不管先发起的任务请求是阻塞等待完成,还是立即返回通过循环等待请求成功。

    而阻塞和非阻塞重点在于请求的方法是否立即返回(或者说是否在条件不满足时被阻塞)。

    Unix下五种I/O模型

    Unix 下共有五种 I/O 模型:

  • 阻塞 I/O
  • 非阻塞 I/O
  • I/O 多路复用(select和poll)
  • 信号驱动 I/O(SIGIO)
  • 异步 I/O(Posix.1的aio_系列函数)
  • 阻塞I/O

    如上文所述,阻塞I/O下请求无法立即完成则保持阻塞。阻塞I/O分为如下两个阶段。

  • 阶段1:等待数据就绪。网络 I/O 的情况就是等待远端数据陆续抵达;磁盘I/O的情况就是等待磁盘数据从磁盘上读取到内核态内存中。
  • 阶段2:数据拷贝。出于系统安全,用户态的程序没有权限直接读取内核态内存,因此内核负责把内核态内存中的数据拷贝一份到用户态内存中。
  • 非阻塞I/O

    非阻塞I/O请求包含如下三个阶段

  • socket设置为 NONBLOCK(非阻塞)就是告诉内核,当所请求的I/O操作无法完成时,不要将线程睡眠,而是返回一个错误码(EWOULDBLOCK) ,这样请求就不会阻塞。
  • I/O操作函数将不断的测试数据是否已经准备好,如果没有准备好,继续测试,直到数据准备好为止。整个I/O 请求的过程中,虽然用户线程每次发起I/O请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的 CPU 的资源。
  • 数据准备好了,从内核拷贝到用户空间。
  • 一般很少直接使用这种模型,而是在其他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轮询,模拟实现了阶段一的异步化。

    信号驱动I/O(SIGIO)

    首先我们允许socket进行信号驱动I/O,并安装一个信号处理函数,线程继续运行并不阻塞。当数据准备好时,线程会收到一个SIGIO 信号,可以在信号处理函数中调用I/O操作函数处理数据。

    异步I/O

    调用aio_read 函数,告诉内核描述字,缓冲区指针,缓冲区大小,文件偏移以及通知的方式,然后立即返回。当内核将数据拷贝到缓冲区后,再通知应用程序。所以异步I/O模式下,阶段1和阶段2全部由内核完成,完成不需要用户线程的参与。

    几种I/O模型对比

    除异步I/O外,其它四种模型的阶段2基本相同,都是从内核态拷贝数据到用户态。区别在于阶段1不同。前四种都属于同步I/O。

    Java中四种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模型的实现。

    从IO到NIO

    面向流 vs. 面向缓冲

    Java IO是面向流的,每次从流(InputStream/OutputStream)中读一个或多个字节,直到读取完所有字节,它们没有被缓存在任何地方。另外,它不能前后移动流中的数据,如需前后移动处理,需要先将其缓存至一个缓冲区。

    Java NIO面向缓冲,数据会被读取到一个缓冲区,需要时可以在缓冲区中前后移动处理,这增加了处理过程的灵活性。但与此同时在处理缓冲区前需要检查该缓冲区中是否包含有所需要处理的数据,并需要确保更多数据读入缓冲区时,不会覆盖缓冲区内尚未处理的数据。

    阻塞 vs. 非阻塞

    Java IO的各种流是阻塞的。当某个线程调用read()或write()方法时,该线程被阻塞,直到有数据被读取到或者数据完全写入。阻塞期间该线程无法处理任何其它事情。

    Java NIO为非阻塞模式。读写请求并不会阻塞当前线程,在数据可读/写前当前线程可以继续做其它事情,所以一个单独的线程可以管理多个输入和输出通道。

    选择器(Selector)

    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
    16
    public 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下的服务器实现

    单线程逐个处理所有请求

    使用阻塞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
    public 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操作。
    阻塞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
    30
    public 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
    35
    public 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模式示意图如下所示。
    精典Reactor

    在Reactor模式中,包含如下角色

  • Reactor 将I/O事件发派给对应的Handler
  • Acceptor 处理客户端连接请求
  • Handlers 执行非阻塞读/写
  • 最简单的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
    40
    public 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模式

    经典Reactor模式中,尽管一个线程可同时监控多个请求(Channel),但是所有读/写请求以及对新连接请求的处理都在同一个线程中处理,无法充分利用多CPU的优势,同时读/写操作也会阻塞对新连接请求的处理。因此可以引入多线程,并行处理多个读/写操作,如下图所示。
    多线程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
    public 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;
    });
    }
    }

    多Reactor

    Netty中使用的Reactor模式,引入了多Reactor,也即一个主Reactor负责监控所有的连接请求,多个子Reactor负责监控并处理读/写请求,减轻了主Reactor的压力,降低了主Reactor压力太大而造成的延迟。
    并且每个子Reactor分别属于一个独立的线程,每个成功连接后的Channel的所有操作由同一个线程处理。这样保证了同一请求的所有状态和上下文在同一个线程中,避免了不必要的上下文切换,同时也方便了监控请求响应状态。

    多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
    public 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
    52
    public 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对象,并由一个独立的线程处理。

    Java进阶系列

  • Java进阶(一)Annotation(注解)
  • Java进阶(二)当我们说线程安全时,到底在说什么
  • Java进阶(三)多线程开发关键技术
  • Java进阶(四)线程间通信方式对比
  • Java进阶(五)NIO和Reactor模式进阶
  • Java进阶(六)从ConcurrentHashMap的演进看Java多线程核心技术
  • ]]>
    本文介绍了Java中的四种I/O模型,同步阻塞,同步非阻塞,多路复用,异步阻塞。同时将NIO和BIO进行了对比,并详细分析了基于NIO的Reactor模式,包括经典单线程模型以及多线程模式和多Reactor模式。
    技术世界 http://www.jasongj.com/uml/class_diagram/ 2016-08-07T22:55:29.000Z 2017-03-15T13:17:39.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/uml/class_diagram/

    UML类图

    UML类图介绍

    在UML 2.*的13种图形中,类图是使用频率最高的UML图之一。类图用于描述系统中所包含的类以及它们之间的相互关系,帮助开发人员理解系统,它是系统分析和设计阶段的重要产物,也是系统编码和测试的重要模型依据。

    类的UML图示

    在UML类图中,类使用包含类名、属性和方法且带有分隔线的长方形来表示。如一个Employee类,它包含private属性age,protected属性name,public属性email,package属性gender,public方法work()。其UML类图表示如下图所示。
    Class in Class Diagram

    属性及方法表示形式

    UML规定类图中属性的表示方式为

    1
    可见性 名称 : 类型 [=缺省值]

    方法表示形式为

    1
    可见性 方法名 [参数名 : 参数类型] : 返回值类型

    方法的多个参数间用逗号隔开,无返回值时,其类型为void

    属性及方法可见性

  • public+表示
  • private-表示
  • protected#表示
  • package~表示
  • 接口的UML图示

    Class in Class Diagram

    接口的表示形式与类类似,区别在于接口名须以尖括号包裹,同时接口无属性框,方法可见性只可能为public,这是由接口本身的特性决定的。

    类间关系

    依赖关系

    依赖关系说明

    依赖关系是一种偶然的、较弱的使用关系,特定事物的改变可能影响到使用该事情的其它事物,在需要表示一个事物使用另一个事物时使用依赖关系。

    依赖关系UML表示

    UML中使用带箭头的虚线表示类间的依赖(Dependency)关系,箭头由依赖类指向被依赖类。下图表示Dirver类依赖于Car类
    Class in Class Diagram

    依赖关系的表现形式

  • B类的实例作为A类方法的参数
  • B类的实例作为A类方法的局部变量
  • A类调用B类的静态方法
  • 关联关系

    关联(Association)关系是一种结构化关系,用于表示一类对象与另一类对象之间的联系。在Java中实现关联关系时,通常将一个类的对象作为另一个类的成员变量。

    在UML类图中,用实线连接有关联关系的类,并可在关联线上标注角色名或关系名。

    在UML中,关联关系包含如下四种形式

    双向关联

    默认情况下,关联是双向的。例如数据库管理员(DBA)管理数据库(DB),同时每个数据库都被某位管理员管理。因此,DBA和DB之间具有双向关联关系,如下图所示。

    Dual Association

    从上图可看出,双向关联的类的实例,互相持有对方的实例,并且可在关联线上注明二者的关系,必须同时注明两种关系(如上图中的manage和managed by)。

    单向关联

    单向关联用带箭头的实线表示,同时一方持有另一方的实例,并且由于是单向关联,如果在关联线上注明关系,则只可注明单向的关系,如下图所示。

    One-way Association

    自关联

    自关联是指属性类型为该类本身。例如在链表中,每个节点持有下一个节点的实例,如下图所示。

    Self Association

    多重性关联

    多重性(Multiplicity)关联关系,表示两个对象在数量上的对应关系。在UML类图中,对象间的多重性可在关联线上用一个数字或数字范围表示。常见的多重性表示方式如下表所示。

    表示方式多重性说明
    1..1
    另一个类的一个对象只与该类的一个对象有关系
    0..*
    另一个类的一个对象只与该类的零个或多个对象有关系
    1..*
    另一个类的一个对象与该类的一个或多个对象有关系
    0..1
    另一个类的一个对象与该类的对象没关系或者只与该类的一个对象有关系
    m..n
    另一个类的一个对象与该类最少m,最多n个对象有关系

    例如一个网页可能没有可点击按钮,也可能有多个按钮,但是该页面中的一个按钮只属于该页面,其关联多重性如下图所示。
    Multiplicity

    聚合关系

    聚合(Aggregation)关系表示整体与部分的关系。在聚合关系中,部分对象是整体对象的一部分,但是部分对象可以脱离整体对象独立存在,也即整体对象并不控制部分对象的生命周期。从代码实现上来讲,部分对象不由整体对象创建,一般通过整体类的带参构造方法或者Setter方法或其它业务方法传入到整体对象,并且有整体对象以外的对象持有部分对象的引用。

    在UML类图中,聚合关系由带箭头的实线表示,并且实线的起点处以空心菱形表示,如下图所示。
    Aggregation

    Java卡塔尔世界杯BOB体育官方APP登入(六)代理模式 vs. 装饰模式》一文中所述装饰模式中,装饰类的对象与被装饰类的对象即为聚合关系。

    组合关系

    组合(Composition)关系也表示类之间整体和部分的关系,但是在组合关系中整体对象控制成员对象的生命周期,一旦整体对象不存在了,成员对象也即随之消亡。

    从代码实现上看,一般在整体类的构造方法中直接实例化成员类,并且除整体类对象外,其它类的对象无法获取该对象的引用。

    在UML类图中,组合关系的表示方式与聚合关系类似,区别在于实线以实心菱形表示。
    Composition

    Java卡塔尔世界杯BOB体育官方APP登入(六)代理模式 vs. 装饰模式》一文中所述代理模式中,代理类的对象与被代理类的对象即为组合关系。

    泛化关系/继承关系

    泛化(Generalization)关系,用于描述父类与子类之间的关系,父类又称作超类或者其类,子类又称为派生类。注意,父类和子类都可为抽象类或者具体类。

    在Java中,我们使用面向对象的三大特性之一——继承来实现泛化关系,具体来说会用到extends关键字。

    在UML类图中,泛化关系用带空心三角形(指向父类)的实线表示。并且子类中不需要标明其从父类继承下来的属性和方法,只须注明其新增的属性和方法即可。
    Generalization

    实现关系

    很多面向对象编程语言(如Java)中都引入了接口的概念。接口与接口之间可以有类与类之间类似的继承和依赖关系。同时接口与类之间还存在一种实现(Realization)关系,在这种关系中,类实现了接口中声明的方法。

    在UML类图中,类与接口间的实现关系用带空心三角形的虚线表示。同时类中也需要列出接口中所声明的所有方法(这一点与类间的继承关系表示不同)。
    Realization

    UML类图十万个为什么

    聚合关系与组合关系都表示整体与部分的关系,有何区别?
    聚合关系中,部分对象的生命周期独立于整体对象的生命周期,或者整体对象消亡后部分对象仍然可以独立存在,同时在代码中一般通过整体类的带参构造方法或Setter方法将部分类对象传入整体类的对象,UML中表示聚合关系的实线以空心菱形开始。
    组合关系中,部分类对象的生命周期由整体对象控制,一旦整体对象消亡,部分类的对象随即消亡。代码中一般在整体类的构造方法内创建部分类的对象,UML中表示组合关系的实线以实心菱形开始。
    同时在组合关系中,部分类的对象只属于某一个确定的整体类对象;而在聚合关系中,部分类对象可以属于一个或多个整体类对象。
    如同《Java卡塔尔世界杯BOB体育官方APP登入(六)代理模式 vs. 装饰模式》一文中所述代理模式中,代理类的对象与被代理类的对象即为组合关系。装饰模式中,装饰类的对象与被装饰类的对象即为聚合关系。

    聚合关系、组合关系与关联关系有何区别和联系?
    聚合关系、组合关系和关联关系实质上是对象间的关系(继承和实现是类与类和类与接口间的关系)。从语意上讲,关联关系中两种对象间一般是平等的,而聚合和组合则代表整体和部分间的关系。而聚合与组合的区别主要体现在实现上和生命周期的管理上。

    依赖关系与关联关系的区别是?
    依赖关系是较弱的关系,一般表现为在局部变量中使用被依赖类的对象、以被依赖类的对象作为方法参数以及使用被依赖类的静态方法。而关联关系是相对较强的关系,一般表现为一个类包含一个类型为另外一个类的属性。

    ]]>
    在UML 2.*的13种图形中,类图是使用频率最高的UML图之一,它表示了类与类之间的关系,帮助开发人员理解系统。它是系统分析和设计阶段的重要产物,也是系统编码和测试的重要模型依据。本文详细介绍了类间的依赖关系,关联关系(聚合、组合等),实现关系以及继承关系的UML表示形式及其在代码中的实现方式。
    技术世界 http://www.jasongj.com/big_data/two_phase_commit/ 2016-07-31T22:55:29.000Z 2017-02-18T11:34:21.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/big_data/two_phase_commit/

    分布式事务

    分布式事务简介

    分布式事务是指会涉及到操作多个数据库(或者提供事务语义的系统,如JMS)的事务。其实就是将对同一数据库事务的概念扩大到了对多个数据库的事务。目的是为了保证分布式系统中事务操作的原子性。分布式事务处理的关键是必须有一种方法可以知道事务在任何地方所做的所有动作,提交或回滚事务的决定必须产生统一的结果(全部提交或全部回滚)。

    分布式事务实现机制

    如同作者在《SQL优化(六) MVCC PostgreSQL实现事务和多版本并发控制的精华》一文中所讲,事务包含原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。

    PostgreSQL针对ACID的实现技术如下表所示。

    ACID实现技术
    原子性(Atomicity)
    MVCC
    一致性(Consistency)
    约束(主键、外键等)
    隔离性
    MVCC
    持久性
    WAL

    分布式事务的实现技术如下表所示。(以PostgreSQL作为事务参与方为例)

    分布式ACID实现技术
    原子性(Atomicity)
    MVCC + 两阶段提交
    一致性(Consistency)
    约束(主键、外键等)
    隔离性
    MVCC
    持久性
    WAL

    从上表可以看到,一致性、隔离性和持久性靠的是各分布式事务参与方自己原有的机制,而两阶段提交主要保证了分布式事务的原子性。

    两阶段提交

    分布式事务如何保证原子性

    在分布式系统中,各个节点(或者事务参与方)之间在物理上相互独立,通过网络进行协调。每个独立的节点(或组件)由于存在事务机制,可以保证其数据操作的ACID特性。但是,各节点之间由于相互独立,无法确切地知道其经节点中的事务执行情况,所以多节点之间很难保证ACID,尤其是原子性。

    如果要实现分布式系统的原子性,则须保证所有节点的数据写操作,要不全部都执行(生效),要么全部都不执行(生效)。但是,一个节点在执行本地事务的时候无法知道其它机器的本地事务的执行结果,所以它就不知道本次事务到底应该commit还是 roolback。常规的解决办法是引入一个“协调者”的组件来统一调度所有分布式节点的执行。

    XA规范

    XA是由X/Open组织提出的分布式事务的规范。XA规范主要定义了(全局)事务管理器(Transaction Manager)和(局部)资源管理器(Resource Manager)之间的接口。XA接口是双向的系统接口,在事务管理器(Transaction Manager)以及一个或多个资源管理器(Resource Manager)之间形成通信桥梁。XA引入的事务管理器充当上文所述全局事务中的“协调者”角色。事务管理器控制着全局事务,管理事务生命周期,并协调资源。资源管理器负责控制和管理实际资源(如数据库或JMS队列)。目前,Oracle、Informix、DB2、Sybase和PostgreSQL等各主流数据库都提供了对XA的支持。

    XA规范中,事务管理器主要通过以下的接口对资源管理器进行管理

  • xa_open,xa_close:建立和关闭与资源管理器的连接。
  • xa_start,xa_end:开始和结束一个本地事务。
  • xa_prepare,xa_commit,xa_rollback:预提交、提交和回滚一个本地事务。
  • xa_recover:回滚一个已进行预提交的事务。
  • 两阶段提交原理

    二阶段提交的算法思路可以概括为:协调者询问参与者是否准备好了提交,并根据所有参与者的反馈情况决定向所有参与者发送commit或者rollback指令(协调者向所有参与者发送相同的指令)。

    所谓的两个阶段是指

  • 准备阶段 又称投票阶段。在这一阶段,协调者询问所有参与者是否准备好提交,参与者如果已经准备好提交则回复Prepared,否则回复Non-Prepared
  • 提交阶段 又称执行阶段。协调者如果在上一阶段收到所有参与者回复的Prepared,则在此阶段向所有参与者发送commit指令,所有参与者立即执行commit操作;否则协调者向所有参与者发送rollback指令,参与者立即执行rollback操作。
  • 两阶段提交中,协调者和参与方的交互过程如下图所示。
    Two-phase commit

    两阶段提交前提条件

  • 网络通信是可信的。虽然网络并不可靠,但两阶段提交的主要目标并不是解决诸如拜占庭问题的网络问题。同时两阶段提交的主要网络通信危险期(In-doubt Time)在事务提交阶段,而该阶段非常短。
  • 所有crash的节点最终都会恢复,不会一直处于crash状态。
  • 每个分布式事务参与方都有WAL日志,并且该日志存于稳定的存储上。
  • 各节点上的本地事务状态即使碰到机器crash都可从WAL日志上恢复。
  • 两阶段提交容错方式

    两阶段提交中的异常主要分为如下三种情况

    1. 协调者正常,参与方crash
    2. 协调者crash,参与者正常
    3. 协调者和参与方都crash

    对于第一种情况,若参与方在准备阶段crash,则协调者收不到Prepared回复,协调方不会发送commit命令,事务不会真正提交。若参与方在提交阶段提交,当它恢复后可以通过从其它参与方或者协调方获取事务是否应该提交,并作出相应的响应。

    第二种情况,可以通过选出新的协调者解决。

    第三种情况,是两阶段提交无法完美解决的情况。尤其是当协调者发送出commit命令后,唯一收到commit命令的参与者也crash,此时其它参与方不能从协调者和已经crash的参与者那儿了解事务提交状态。但如同上一节两阶段提交前提条件所述,两阶段提交的前提条件之一是所有crash的节点最终都会恢复,所以当收到commit的参与方恢复后,其它节点可从它那里获取事务状态并作出相应操作。

    JTA

    JTA介绍

    作为java平台上事务规范JTA(Java Transaction API)也定义了对XA事务的支持,实际上,JTA是基于XA架构上建模的。在JTA 中,事务管理器抽象为javax.transaction.TransactionManager接口,并通过底层事务服务(即Java Transaction Service)实现。像很多其他的Java规范一样,JTA仅仅定义了接口,具体的实现则是由供应商(如J2EE厂商)负责提供,目前JTA的实现主要有以下几种:

  • J2EE容器所提供的JTA实现(如JBoss)。
  • 独立的JTA实现:如JOTM(Java Open Transaction Manager),Atomikos。这些实现可以应用在那些不使用J2EE应用服务器的环境里用以提供分布事事务保证。
  • PostgreSQL两阶段提交接口

  • 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
    11
    postgres=> 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
    13
    postgres=> \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

    PostgreSQL两阶段提交注意事项

  • PREPARE TRANSACTION transaction_id命令后,事务状态完全保存在磁盘上。
  • PREPARE TRANSACTION transaction_id命令后,事务就不再和当前会话关联,因此当前session可继续执行其它事务。
  • COMMIT PREPAREDROLLBACK PREPARED可在任何会话中执行,而并不要求在提交准备的会话中执行。
  • 不允许对那些执行了涉及临时表或者是创建了带WITH HOLD游标的事务进行PREPARE。 这些特性和当前会话绑定得实在是太紧密了,因此在一个准备好的事务里没什么可用的。
  • 如果事务用SET修改了运行时参数,这些效果在PREPARE TRANSACTION之后保留,并且不会被任何以后的COMMIT PREPAREDROLLBACK PREPARED所影响,因为SET的生效范围是当前session。
  • 从性能的角度来看,把一个事务长时间停在准备好的状态是不明智的,因为它会影响VACUUM回收存储的能力。
  • 已准备好的事务会继续持有它们获得的锁,直到该事务被commit或者rollback。所以如果已进入准备阶段的事务一直不被处理,其它事务可能会因为获取不到锁而被block或者失败。
  • 默认情况下,PostgreSQL并不开启两阶段提交,可以通过在postgresql.conf文件中设置max_prepared_transactions配置项开启PostgreSQL的两阶段提交。
  • JTA实现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;

    @Path("/jta")
    public class JTAResource {
    private static final Logger LOGGER = LoggerFactory.getLogger(JTAResource.class);

    @GET
    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>
    ]]>
    分布式事务与本地事务一样,包含原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。两阶段提交是保证分布式事务中原子性的重要方法。本文重点介绍了两阶段提交的原理,PostgreSQL中两阶段提交接口,以及Java中两阶段提交接口规范JTA的使用方式。
    技术世界 http://www.jasongj.com/java/thread_communication/ 2016-06-22T22:55:29.000Z 2017-02-15T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/java/thread_communication/

    CountDownLatch

    CountDownLatch适用场景

    Java多线程编程中经常会碰到这样一种场景——某个线程需要等待一个或多个线程操作结束(或达到某种状态)才开始执行。比如开发一个并发测试工具时,主线程需要等到所有测试线程均执行完成再开始统计总共耗费的时间,此时可以通过CountDownLatch轻松实现。

    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
    7
    Sun 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主要接口分析

    CountDownLatch工作原理相对简单,可以简单看成一个倒计数器,在构造方法中指定初始值,每次调用countDown()方法时将计数器减1,而await()会等待计数器变为0。CountDownLatch关键接口如下

  • countDown() 如果当前计数器的值大于1,则将其减1;若当前值为1,则将其置为0并唤醒所有通过await等待的线程;若当前值为0,则什么也不做直接返回。
  • await() 等待计数器的值为0,若计数器的值为0则该方法返回;若等待期间该线程被中断,则抛出InterruptedException并清除该线程的中断状态。
  • await(long timeout, TimeUnit unit) 在指定的时间内等待计数器的值为0,若在指定时间内计数器的值变为0,则该方法返回true;若指定时间内计数器的值仍未变为0,则返回false;若指定时间内计数器的值变为0之前当前线程被中断,则抛出InterruptedException并清除该线程的中断状态。
  • getCount() 读取当前计数器的值,一般用于调试或者测试。
  • CyclicBarrier

    CyclicBarrier适用场景

    在《当我们说线程安全时,到底在说什么》一文中讲过内存屏障,它能保证屏障之前的代码一定在屏障之后的代码之前被执行。CyclicBarrier可以译为循环屏障,也有类似的功能。CyclicBarrier可以在构造时指定需要在屏障前执行await的个数,所有对await的调用都会等待,直到调用await的卡塔尔世界杯bobAPP手机端下载在线达到预定指,所有等待都会立即被唤醒。

    从使用场景上来说,CyclicBarrier是让多个线程互相等待某一事件的发生,然后同时被唤醒。而上文讲的CountDownLatch是让某一线程等待多个线程的状态,然后该线程被唤醒。

    CyclicBarrier实例

    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
    10
    Sun 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主要接口分析

    CyclicBarrier提供的关键方法如下

  • await() 等待其它参与方的到来(调用await())。如果当前调用是最后一个调用,则唤醒所有其它的线程的等待并且如果在构造CyclicBarrier时指定了action,当前线程会去执行该action,然后该方法返回该线程调用await的次序(getParties()-1说明该线程是第一个调用await的,0说明该线程是最后一个执行await的),接着该线程继续执行await后的代码;如果该调用不是最后一个调用,则阻塞等待;如果等待过程中,当前线程被中断,则抛出InterruptedException;如果等待过程中,其它等待的线程被中断,或者其它线程等待超时,或者该barrier被reset,或者当前线程在执行barrier构造时注册的action时因为抛出异常而失败,则抛出BrokenBarrierException
  • await(long timeout, TimeUnit unit)await()唯一的不同点在于设置了等待超时时间,等待超时时会抛出TimeoutException
  • reset() 该方法会将该barrier重置为它的初始状态,并使得所有对该barrier的await调用抛出BrokenBarrierException
  • Phaser

    Phaser适用场景

    CountDownLatch和CyclicBarrier都是JDK 1.5引入的,而Phaser是JDK 1.7引入的。Phaser的功能与CountDownLatch和CyclicBarrier有部分重叠,同时也提供了更丰富的语义和更灵活的用法。

    Phaser顾名思义,与阶段相关。Phaser比较适合这样一种场景,一种任务可以分为多个阶段,现希望多个线程去处理该批任务,对于每个阶段,多个线程可以并发进行,但是希望保证只有前面一个阶段的任务完成之后才能开始后面的任务。这种场景可以使用多个CyclicBarrier来实现,每个CyclicBarrier负责等待一个阶段的任务全部完成。但是使用CyclicBarrier的缺点在于,需要明确知道总共有多少个阶段,同时并行的任务数需要提前预定义好,且无法动态修改。而Phaser可同时解决这两个问题。

    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) {
    @Override
    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
    16
    Thread 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主要接口分析

    Phaser主要接口如下

  • arriveAndAwaitAdvance() 当前线程当前阶段执行完毕,等待其它线程完成当前阶段。如果当前线程是该阶段最后一个未到达的,则该方法直接返回下一个阶段的序号(阶段序号从0开始),同时其它线程的该方法也返回下一个阶段的序号。
  • arriveAndDeregister() 该方法立即返回下一阶段的序号,并且其它线程需要等待的个数减一,并且把当前线程从之后需要等待的成员中移除。如果该Phaser是另外一个Phaser的子Phaser(层次化Phaser会在后文中讲到),并且该操作导致当前Phaser的成员数为0,则该操作也会将当前Phaser从其父Phaser中移除。
  • arrive() 该方法不作任何等待,直接返回下一阶段的序号。
  • awaitAdvance(int phase) 该方法等待某一阶段执行完毕。如果当前阶段不等于指定的阶段或者该Phaser已经被终止,则立即返回。该阶段数一般由arrive()方法或者arriveAndDeregister()方法返回。返回下一阶段的序号,或者返回参数指定的值(如果该参数为负数),或者直接返回当前阶段序号(如果当前Phaser已经被终止)。
  • awaitAdvanceInterruptibly(int phase) 效果与awaitAdvance(int phase)相当,唯一的不同在于若该线程在该方法等待时被中断,则该方法抛出InterruptedException
  • awaitAdvanceInterruptibly(int phase, long timeout, TimeUnit unit) 效果与awaitAdvanceInterruptibly(int phase)相当,区别在于如果超时则抛出TimeoutException
  • bulkRegister(int parties) 注册多个party。如果当前phaser已经被终止,则该方法无效,并返回负数。如果调用该方法时,onAdvance方法正在执行,则该方法等待其执行完毕。如果该Phaser有父Phaser则指定的party数大于0,且之前该Phaser的party数为0,那么该Phaser会被注册到其父Phaser中。
  • forceTermination() 强制让该Phaser进入终止状态。已经注册的party数不受影响。如果该Phaser有子Phaser,则其所有的子Phaser均进入终止状态。如果该Phaser已经处于终止状态,该方法调用不造成任何影响。
  • Java进阶系列

  • Java进阶(一)Annotation(注解)
  • Java进阶(二)当我们说线程安全时,到底在说什么
  • Java进阶(三)多线程开发关键技术
  • Java进阶(四)线程间通信方式对比
  • Java进阶(五)NIO和Reactor模式进阶
  • Java进阶(六)从ConcurrentHashMap的演进看Java多线程核心技术
  • ]]>
    本文将介绍常用的线程间通信工具CountDownLatch、CyclicBarrier和Phaser的用法,并结合实例介绍它们各自的适用场景及相同点和不同点。
    技术世界 http://www.jasongj.com/java/multi_thread/ 2016-06-19T22:55:29.000Z 2017-02-15T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/java/multi_thread/

    sleep和wait到底什么区别

    其实这个问题应该这么问——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
    46
    import 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
    5
    Tue 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

    从运行结果可以看出

  • thread1执行wait后,暂停执行
  • thread2执行notify后,thread1并没有继续执行,因为此时thread2尚未释放锁,thread1因为得不到锁而不能继续执行
  • thread2执行完synchronized语句块后释放锁,thread1得到通知并获得锁,进而继续执行
  • 注意: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
    4
    Thu 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)

    synchronized几种用法

    每个Java对象都可以用做一个实现同步的互斥锁,这些锁被称为内置锁。线程进入同步代码块或方法时自动获得内置锁,退出同步代码块或方法时自动释放该内置锁。进入同步代码块或者同步方法是获得内置锁的唯一途径。

    实例同步方法

    synchronized用于修饰实例方法(非静态方法)时,执行该方法需要获得的是该类实例对象的内置锁(同一个类的不同实例拥有不同的内置锁)。如果多个实例方法都被synchronized修饰,则当多个线程调用同一实例的不同同步方法(或者同一方法)时,需要竞争锁。但当调用的是不同实例的方法时,并不需要竞争锁。

    静态同步方法

    synchronized用于修饰静态方法时,执行该方法需要获得的是该类的class对象的内置锁(一个类只有唯一一个class对象)。调用同一个类的不同静态同步方法时会产生锁竞争。

    同步代码块

    synchronized用于修饰代码块时,进入同步代码块需要获得synchronized关键字后面括号内的对象(可以是实例对象也可以是class对象)的内置锁。

    synchronized使用总结

    锁的使用是为了操作临界资源的正确性,而往往一个方法中并非所有的代码都操作临界资源。换句话说,方法中的代码往往并不都需要同步。此时建议不使用同步方法,而使用同步代码块,只对操作临界资源的代码,也即需要同步的代码加锁。这样做的好处是,当一个线程在执行同步代码块时,其它线程仍然可以执行该方法内同步代码块以外的部分,充分发挥多线程并发的优势,从而相较于同步整个方法而言提升性能。

    释放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中的锁

    重入锁

    Java中的重入锁(即ReentrantLock)与Java内置锁一样,是一种排它锁。使用synchronized的地方一定可以用ReentrantLock代替。

    重入锁需要显示请求获取锁,并显示释放锁。为了避免获得锁后,没有释放锁,而造成其它线程无法获得锁而造成死锁,一般建议将释放锁操作放在finally块里,如下所示。

    1
    2
    3
    4
    5
    6
    try{
    renentrantLock.lock();
    // 用户操作
    } finally {
    renentrantLock.unlock();
    }

    如果重入锁已经被其它线程持有,则当前线程的lock操作会被阻塞。除了lock()方法之外,重入锁(或者说锁接口)还提供了其它获取锁的方法以实现不同的效果。

  • lockInterruptibly() 该方法尝试获取锁,若获取成功立即返回;若获取不成功则阻塞等待。与lock方法不同的是,在阻塞期间,如果当前线程被打断(interrupt)则该方法抛出InterruptedException。该方法提供了一种解除死锁的途径。
  • tryLock() 该方法试图获取锁,若该锁当前可用,则该方法立即获得锁并立即返回true;若锁当前不可用,则立即返回false。该方法不会阻塞,并提供给用户对于成功获利锁与获取锁失败进行不同操作的可能性。
  • tryLock(long time, TimeUnit unit) 该方法试图获得锁,若该锁当前可用,则立即获得锁并立即返回true。若锁当前不可用,则等待相应的时间(由该方法的两个参数决定):1)若该时间内锁可用,则获得锁,并返回true;2)若等待期间当前线程被打断,则抛出InterruptedException;3)若等待时间结束仍未获得锁,则返回false。
  • 重入锁可定义为公平锁或非公平锁,默认实现为非公平锁。

  • 公平锁是指多个线程获取锁被阻塞的情况下,锁变为可用时,最新申请锁的线程获得锁。可通过在重入锁(RenentrantLock)的构造方法中传入true构建公平锁,如Lock lock = new RenentrantLock(true)
  • 非公平锁是指多个线程等待锁的情况下,锁变为可用状态时,哪个线程获得锁是随机的。synchonized相当于非公平锁。可通过在重入锁的构造方法中传入false或者使用无参构造方法构建非公平锁。
  • 使用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
    6
    Sat 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()方法绑定若干个条件对象。

    条件对象提供以下方法以实现不同的等待语义

  • await() 调用该方法的前提是,当前线程已经成功获得与该条件对象绑定的重入锁,否则调用该方法时会抛出IllegalMonitorStateException。调用该方法外,当前线程会释放当前已经获得的锁(这一点与上文讲述的Java内置锁的wait方法一致),并且等待其它线程调用该条件对象的signal()或者signalAll()方法(这一点与Java内置锁wait后等待notify()notifyAll()很像)。或者在等待期间,当前线程被打断,则wait()方法会抛出InterruptedException并清除当前线程的打断状态。
  • await(long time, TimeUnit unit) 适用条件和行为与await()基本一致,唯一不同点在于,指定时间之内没有收到signal()signalALL()信号或者线程中断时该方法会返回false;其它情况返回true。
  • awaitNanos(long nanosTimeout) 调用该方法的前提是,当前线程已经成功获得与该条件对象绑定的重入锁,否则调用该方法时会抛出IllegalMonitorStateExceptionnanosTimeout指定该方法等待信号的的最大时间(单位为纳秒)。若指定时间内收到signal()signalALL()则返回nanosTimeout减去已经等待的时间;若指定时间内有其它线程中断该线程,则抛出InterruptedException并清除当前线程的打断状态;若指定时间内未收到通知,则返回0或负数。
  • awaitUninterruptibly() 调用该方法的前提是,当前线程已经成功获得与该条件对象绑定的重入锁,否则调用该方法时会抛出IllegalMonitorStateException。调用该方法后,结束等待的唯一方法是其它线程调用该条件对象的signal()signalALL()方法。等待过程中如果当前线程被中断,该方法仍然会继续等待,同时保留该线程的中断状态。
  • awaitUntil(Date deadline) 适用条件与行为与awaitNanos(long nanosTimeout)完全一样,唯一不同点在于它不是等待指定时间,而是等待由参数指定的某一时刻。
  • 调用条件等待的注意事项

  • 调用上述任意条件等待方法的前提都是当前线程已经获得与该条件对象对应的重入锁。
  • 调用条件等待后,当前线程让出CPU资源。
  • 上述等待方法结束后,方法返回的前提是它能重新获得与该条件对象对应的重入锁。如果无法获得锁,仍然会继续等待。这也是awaitNanos(long nanosTimeout)可能会返回负值的原因。
  • 一旦条件等待方法返回,则当前线程肯定已经获得了对应的重入锁。
  • 重入锁可以创建若干个条件对象,signal()signalAll()方法只能唤醒相同条件对象的等待。
  • 一个重入锁上可以生成多个条件变量,不同线程可以等待不同的条件,从而实现更加细粒度的的线程间通信。
  • signal()signalAll()

  • signal() 若有一个或若干个线程在等待该条件变量,则该方法会唤醒其中的一个(具体哪一个,无法预测)。调用该方法的前提是当前线程持有该条件变量对应的锁,否则抛出IllegalMonitorStateException
  • signalALL() 若有一个或若干个线程在等待该条件变量,则该方法会唤醒所有等待。调用该方法的前提是当前线程持有该条件变量对应的锁,否则抛出IllegalMonitorStateException
  • 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
    5
    Sun 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秒钟后才返回,并且返回负值。

    信号量Semaphore

    信号量维护一个许可集,可通过acquire()获取许可(若无可用许可则阻塞),通过release()释放许可,从而可能唤醒一个阻塞等待许可的线程。

    与互斥锁类似,信号量限制了同一时间访问临界资源的线程的个数,并且信号量也分公平信号量与非公平信号量。而不同的是,互斥锁保证同一时间只会有一个线程访问临界资源,而信号量可以允许同一时间多个线程访问特定资源。所以信号量并不能保证原子性。

    信号量的一个典型使用场景是限制系统访问量。每个请求进来后,处理之前都通过acquire获取许可,若获取许可成功则处理该请求,若获取失败则等待处理或者直接不处理该请求。

    信号量的使用方法

  • acquire(int permits) 申请permits(必须为非负数)个许可,若获取成功,则该方法返回并且当前可用许可数减permits;若当前可用许可数少于permits指定的个数,则继续等待可用许可数大于等于permits;若等待过程中当前线程被中断,则抛出InterruptedException
  • acquire() 等价于acquire(1)
  • acquireUninterruptibly(int permits) 申请permits(必须为非负数)个许可,若获取成功,则该方法返回并且当前可用许可数减permits;若当前许可数少于permits,则继续等待可用许可数大于等于permits;若等待过程中当前线程被中断,继续等待可用许可数大于等于permits,并且获取成功后设置线程中断状态。
  • acquireUninterruptibly() 等价于acquireUninterruptibly(1)
  • drainPermits() 获取所有可用许可,并返回获取到的许可个数,该方法不阻塞。
  • tryAcquire(int permits) 尝试获取permits个可用许可,如果当前许可个数大于等于permits,则返回true并且可用许可数减permits;否则返回false并且可用许可数不变。
  • tryAcquire() 等价于tryAcquire(1)
  • tryAcquire(int permits, long timeout, TimeUnit unit) 尝试获取permits(必须为非负数)个许可,若在指定时间内获取成功则返回true并且可用许可数减permits;若指定时间内当前线程被中断,则抛出InterruptedException;若指定时间内可用许可数均小于permits,则返回false。
  • tryAcquire(long timeout, TimeUnit unit) 等价于tryAcquire(1, long timeout, TimeUnit unit)*
  • release(int permits) 释放permits个许可,该方法不阻塞并且某线程调用release方法前并不需要先调用acquire方法。
  • release() 等价于release(1)
  • 注意:与wait/notify和await/signal不同,acquire/release完全与锁无关,因此acquire等待过程中,可用许可满足要求时acquire可立即返回,而不用像锁的wait和条件变量的await那样重新获取锁才能返回。或者可以理解成,只要可用许可满足需求,就已经获得了锁。

    Java进阶系列

  • Java进阶(一)Annotation(注解)
  • Java进阶(二)当我们说线程安全时,到底在说什么
  • Java进阶(三)多线程开发关键技术
  • Java进阶(四)线程间通信方式对比
  • Java进阶(五)NIO和Reactor模式进阶
  • Java进阶(六)从ConcurrentHashMap的演进看Java多线程核心技术
  • ]]>
    本文将介绍Java多线程开发必不可少的锁和同步机制,同时介绍sleep和wait等常用的暂停线程执行的方法,并详述synchronized的几种使用方式,以及Java中的重入锁(ReentrantLock)和读写锁(ReadWriteLock),之后结合实例分析了重入锁条件变量(Condition)的使用技巧,最后介绍了信号量(Semaphore)的适用场景和使用技巧。
    技术世界 http://www.jasongj.com/java/thread_safe/ 2016-06-12T23:11:29.000Z 2017-02-15T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯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
    4
    boolean started = false; // 语句1
    long counter = 0L; // 语句2
    counter = 1; // 语句3
    started = true; // 语句4

    从代码顺序上看,上面四条语句应该依次执行,但实际上JVM真正在执行这段代码时,并不保证它们一定完全按照此顺序执行。

    处理器为了提高程序整体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码顺序,按照更高效的顺序执行代码。

    讲到这里,有人要着急了——什么,CPU不按照我的代码顺序执行代码,那怎么保证得到我们想要的效果呢?实际上,大家大可放心,CPU虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致。

    Java如何解决多线程并发问题

    Java如何保证原子性

    锁和同步

    常用的保证Java操作原子性的工具是锁和同步方法(或者同步代码块)。使用锁,可以保证同一时间只有一个线程能拿到锁,也就保证了同一时间只有一个线程能执行申请锁和释放锁之间的代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public void testLock () {
    lock.lock();
    try{
    int j = i;
    i = j + 1;
    } finally {
    lock.unlock();
    }
    }

    与锁类似的是同步方法或者同步代码块。使用非静态同步方法时,锁住的是当前实例;使用静态同步方法时,锁住的是该类的Class对象;使用静态代码块时,锁住的是synchronized关键字后面括号内的对象。下面是同步代码块示例

    1
    2
    3
    4
    5
    6
    public void testLock () {
    synchronized (anyObject){
    int j = i;
    i = j + 1;
    }
    }

    无论使用锁还是synchronized,本质都是一样,通过锁来实现资源的排它性,从而实际目标代码段同一时间只会被一个线程执行,进而保证了目标代码段的原子性。这是一种以牺牲性能为代价的方法。

    CAS(compare and swap)

    基础类型变量自增(i++)是一种常被新手误以为是原子操作而实际不是的操作。Java中提供了对应的原子操作类来实现该操作,并保证原子性,其本质是利用了CPU级别的CAS指令。由于是CPU级别的指令,其开销比需要操作系统参与的锁的开销小。AtomicInteger使用方法如下。

    1
    2
    3
    4
    5
    6
    7
    8
    AtomicInteger atomicInteger = new AtomicInteger();
    for(int b = 0; b < numThreads; b++) {
    new Thread(() -> {
    for(int a = 0; a < iteration; a++) {
    atomicInteger.incrementAndGet();
    }
    }).start();
    }

    Java如何保证可见性

    Java提供了volatile关键字来保证可见性。当使用volatile修饰某个变量时,它会保证对该变量的修改会立即被更新到内存中,并且将其它缓存中对该变量的缓存设置成无效,因此其它线程需要读取该值时必须从主内存中读取,从而得到最新的值。

    Java如何保证顺序性

    上文讲过编译器和处理器对指令进行重新排序时,会保证重新排序后的执行结果和代码顺序执行的结果一致,所以重新排序过程并不会影响单线程程序的执行,却可能影响多线程程序并发执行的正确性。

    Java中可通过volatile在一定程序上保证顺序性,另外还可以通过synchronized和锁来保证顺序性。

    synchronized和锁保证顺序性的原理和保证原子性一样,都是通过保证同一时间只会有一个线程执行目标代码段来实现的。

    除了从应用层面保证目标代码段执行的顺序性外,JVM还通过被称为happens-before原则隐式地保证顺序性。两个操作的执行顺序只要可以通过happens-before推导出来,则JVM会保证其顺序性,反之JVM对其顺序性不作任何保证,可对其进行任意必要的重新排序以获取高效率。

    happens-before原则(先行发生原则)

  • 传递规则:如果操作1在操作2前面,而操作2在操作3前面,则操作1肯定会在操作3前发生。该规则说明了happens-before原则具有传递性
  • 锁定规则:一个unlock操作肯定会在后面对同一个锁的lock操作前发生。这个很好理解,锁只有被释放了才会被再次获取
  • volatile变量规则:对一个被volatile修饰的写操作先发生于后面对该变量的读操作
  • 程序次序规则:一个线程内,按照代码顺序执行
  • 线程启动规则:Thread对象的start()方法先发生于此线程的其它动作
  • 线程终结原则:线程的终止检测后发生于线程中其它的所有操作
  • 线程中断规则: 对线程interrupt()方法的调用先发生于对该中断异常的获取
  • 对象终结规则:一个对象构造先于它的finalize发生
  • volatile适用场景

    volatile适用于不需要保证原子性,但却需要保证可见性的场景。一种典型的使用场景是用它修饰用于停止线程的状态标记。如下所示

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    boolean 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关键字后面括号内的对象。

    Java进阶系列

  • Java进阶(一)Annotation(注解)
  • Java进阶(二)当我们说线程安全时,到底在说什么
  • Java进阶(三)多线程开发关键技术
  • Java进阶(四)线程间通信方式对比
  • Java进阶(五)NIO和Reactor模式进阶
  • Java进阶(六)从ConcurrentHashMap的演进看Java多线程核心技术
  • ]]>
    提到线程安全,可能大家的第一反应是要确保接口对共享变量的操作要具体原子性。实际上,在多线程编程中我们需要同时关注可见性、顺序性和原子性问题。本篇文章将从这三个问题出发,结合实例详解volatile如何保证可见性及一定程序上保证顺序性,同时例讲synchronized如何同时保证可见性和原子性,最后对比volatile和synchronized的适用场景。
    技术世界 http://www.jasongj.com/sql/mvcc/ 2016-06-05T23:09:04.000Z 2017-03-15T13:17:39.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/sql/mvcc/

    PostgreSQL针对ACID的实现机制

    数据库ACID

    数据库事务包含如下四个特性

  • 原子性(Atomicity) 指一个事务要么全部执行,要么不执行。也即一个事务不可能只执行一半就停止(哪怕是因为意外也不行)。比如从取款机取钱,这个事务可以分成两个步骤:1)划卡;2)出钱。不可能划了卡,而钱却没出来。这两步必须同时完成,或者同时不完成。
  • 一致性(Consistency) 事务的运行不可改变数据库中数据的一致性,事务必须将数据库中的数据从一个正确的状态带到另一个正确的状态。事务在开始时,完全可以假定数据库中的数据是处于正确(一致)状态的,而不必作过多验证(从而提升效率),同时也必须保证事务结束时数据库数据处于正确(一致)状态。例如,完整性约束了a+b=10,一个事务改变了a,那么b也应该随之改变。
  • 隔离性(Isolation) 在并发数据操作时,不同的事务拥有各自的数据空间,其操作不会对对方产生干扰。隔离性允许事务行为独立或隔离于其它事务并发运行。
  • 持久性(Durability)事务执行成功以后,该事务对数据库所作的更改是持久的保存在数据库之中,不会无缘无故的回滚。
  • ACID在PostgreSQL中的实现原理

    事务的实现原理可以解读为RDBMS采取何种技术确保事务的ACID特性,PostgreSQL针对ACID的实现技术如下表所示。

    ACID实现技术
    原子性(Atomicity)
    MVCC
    一致性(Consistency)
    约束(主键、外键等)
    隔离性
    MVCC
    持久性
    WAL

    从上表可以看到,PostgreSQL主要使用MVCC和WAL两项技术实现ACID特性。实际上,MVCC和WAL这两项技术都比较成熟,主流关系型数据库中都有相应的实现,但每个数据库中具体的实现方式往往存在较大的差异。本文将介绍PostgreSQL中的MVCC实现原理。

    PostgreSQL中的MVCC原理

    事务ID

    在PostgreSQL中,每个事务都有一个唯一的事务ID,被称为XID。注意:除了被BEGIN - COMMIT/ROLLBACK包裹的一组语句会被当作一个事务对待外,不显示指定BEGIN - COMMIT/ROLLBACK的单条语句也是一个事务。

    数据库中的事务ID递增。可通过txid_current()函数获取当前事务的ID。

    隐藏多版本标记字段

    PostgreSQL中,对于每一行数据(称为一个tuple),包含有4个隐藏字段。这四个字段是隐藏的,但可直接访问。

  • xmin 在创建(insert)记录(tuple)时,记录此值为插入tuple的事务ID
  • xmax 默认值为0.在删除tuple时,记录此值
  • cmin和cmax 标识在同一个事务中多个语句命令的序列值,从0开始,用于同一个事务中实现版本可见性判断
  • 下面通过实验具体看看这些标记如何工作。在此之前,先创建测试表

    1
    2
    3
    4
    5
    CREATE TABLE test 
    (
    id INTEGER,
    value TEXT
    );

    开启一个事务,查询当前事务ID(值为3277),并插入一条数据,xmin为3277,与当前事务ID相等。符合上文所述——插入tuple时记录xmin,记录未被删除时xmax为0

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    postgres=> 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
    9
    INSERT 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
    12
    UPDATE 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
    15
    BEGIN;
    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,即3278

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    UPDATE 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
    9
    BEGIN;
    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对应的value和xmin、xmax、cmin/cmax均不相同,实际上它们是该tuple的2个不同版本
  • 在旧窗口中,更新之前,数据的顺序是2,3,1,4,5,更新后变为3,1,4,5,2。因为在PostgreSQL中更新实际上是将旧tuple标记为删除,并插入更新后的新数据,所以更新后id为2的tuple从原来最前面变成了最后面
  • 在新窗口中,id为2的tuple仍然如旧窗口中更新之前一样,排在最前面。这是因为旧窗口中的事务未提交,更新对新窗口不可见,新窗口看到的仍然是旧版本的数据
  • 提交旧窗口中的事务后,新旧窗口中看到数据完全一致——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)

    MVCC保证原子性

    原子性(Atomicity)指得是一个事务是一个不可分割的工作单位,事务中包括的所有操作要么都做,要么都不做。

    对于插入操作,PostgreSQL会将当前事务ID存于xmin中。对于删除操作,其事务ID会存于xmax中。对于更新操作,PostgreSQL会将当前事务ID存于旧数据的xmax中,并存于新数据的xin中。换句话说,事务对增、删和改所操作的数据上都留有其事务ID,可以很方便的提交该批操作或者完全撤销操作,从而实现了事务的原子性。

    MVCC保证事物的隔离性

    隔离性(Isolation)指一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。

    标准SQL的事务隔离级别分为如下四个级别

    隔离级别脏读不可重复读幻读
    未提交读(read uncommitted)
    可能
    可能
    可能
    提交读(read committed)
    不可能
    可能
    可能
    可重复读(repeatable read)
    不可能
    不可能
    可能
    串行读(serializable)
    不可能
    不可能
    不可能

    从上表中可以看出,从未提交读到串行读,要求越来越严格。

    注意,SQL标准规定,具体数据库实现时,对于标准规定不允许发生的,绝不可发生;对于可能发生的,并不要求一定能发生。换句话说,具体数据库实现时,对应的隔离级别只可更严格,不可更宽松。

    事实中,PostgreSQL实现了三种隔离级别——未提交读和提交读实际上都被实现为提交读。

    下面将讨论提交读和可重复读的实现方式

    MVCC提交读

    提交读只可读取其它已提交事务的结果。PostgreSQL中通过pg_clog来记录哪些事务已经被提交,哪些未被提交。具体实现方式将在下一篇文章《SQL优化(七) WAL PostgreSQL实现事务和高并发的重要技术》中讲述。

    MVCC可重复读

    相对于提交读,重复读要求在同一事务中,前后两次带条件查询所得到的结果集相同。实际中,PostgreSQL的实现更严格,不紧要求可重复读,还不允许出现幻读。它是通过只读取在当前事务开启之前已经提交的数据实现的。结合上文的四个隐藏系统字段来讲,PostgreSQL的可重复读是通过只读取xmin小于当前事务ID且已提交的事务的结果来实现的。

    PostgreSQL中的MVCC优势

  • 使用MVCC,读操作不会阻塞写,写操作也不会阻塞读,提高了并发访问下的性能
  • 事务的回滚可立即完成,无论事务进行了多少操作
  • 数据可以进行大量更新,不像MySQL和Innodb引擎和Oracle那样需要保证回滚段不会被耗尽
  • PostgreSQL中的MVCC缺点

    事务ID个数有限制

    事务ID由32位数保存,而事务ID递增,当事务ID用完时,会出现wraparound问题。

    PostgreSQL通过VACUUM机制来解决该问题。对于事务ID,PostgreSQL有三个事务ID有特殊意义:

  • 0代表invalid事务号
  • 1代表bootstrap事务号
  • 2代表frozon事务。frozon transaction id比任何事务都要老
  • 可用的有效最小事务ID为3。VACUUM时将所有已提交的事务ID均设置为2,即frozon。之后所有的事务都比frozon事务新,因此VACUUM之前的所有已提交的数据都对之后的事务可见。PostgreSQL通过这种方式实现了事务ID的循环利用。

    大量过期数据占用磁盘并降低查询性能

    由于上文提到的,PostgreSQL更新数据并非真正更改记录值,而是通过将旧数据标记为删除,再插入新的数据来实现。对于更新或删除频繁的表,会累积大量过期数据,占用大量磁盘,并且由于需要扫描更多数据,使得查询性能降低。

    PostgreSQL解决该问题的方式也是VACUUM机制。从释放磁盘的角度,VACUUM分为两种

  • VACUUM 该操作并不要求获得排它锁,因此它可以和其它的读写表操作并行进行。同时它只是简单的将dead tuple对应的磁盘空间标记为可用状态,新的数据可以重用这部分磁盘空间。但是这部分磁盘并不会被真正释放,也即不会被交还给操作系统,因此不能被系统中其它程序所使用,并且可能会产生磁盘碎片。
  • VACUUM FULL 需要获得排它锁,它通过“标记-复制”的方式将所有有效数据(非dead tuple)复制到新的磁盘文件中,并将原数据文件全部删除,并将未使用的磁盘空间还给操作系统,因此系统中其它进程可使用该空间,并且不会因此产生磁盘碎片。
  • SQL优化系列

  • SQL优化(一) Merge Join vs. Hash Join vs. Nested Loop
  • SQL优化(二) 快速计算Distinct Count
  • SQL优化(三) PostgreSQL Table Partitioning
  • SQL优化(四) Postgre Sql存储过程
  • SQL优化(五) PostgreSQL (递归)CTE 通用表表达式
  • SQL优化(六) MVCC PostgreSQL实现事务和多版本并发控制的精华  
  • ]]>
    数据库事务隔离性可通过锁机制或者MVCC实现,PostgreSQL默认使用MVCC。本文结合实例介绍了PostgreSQL的MVCC实现机制,并介绍了PostgreSQL如何通过MVCC保证事务的原子性和隔离性,最后介绍了PostgreSQL如何通过VACUUM机制克服MVCC带来的副作用。
    技术世界 http://www.jasongj.com/design_pattern/summary/ 2016-06-01T23:26:09.000Z 2017-02-17T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/design_pattern/summary/

    OOP三大基本特性

    封装

    封装,也就是把客观事物封装成抽象的类,并且类可以把自己的属性和方法只让可信的类操作,对不可信的进行信息隐藏。

    继承

    继承是指这样一种能力,它可以使用现有的类的所有功能,并在无需重新编写原来类的情况下对这些功能进行扩展。

    多态

    多态指一个类实例的相同方法在不同情形有不同的表现形式。具体来说就是不同实现类对公共接口有不同的实现方式,但这些操作可以通过相同的方式(公共接口)予以调用。

    OOD大原则

    面向对象设计(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登入

    可以用一句话概括卡塔尔世界杯BOB体育官方APP登入———卡塔尔世界杯BOB体育官方APP登入是一种利用OOP的封闭、继承和多态三大特性,同时在遵循单一职责原则、开闭原则、里氏替换原则、迪米特法则、依赖倒置原则、接口隔离原则及合成/聚合复用原则的前提下,被总结出来的经过反复实践并被多数人知晓且经过分类和设计的可重用的软件设计方式。

    卡塔尔世界杯BOB体育官方APP登入十万个为什么

    为什么要用卡塔尔世界杯BOB体育官方APP登入

  • 卡塔尔世界杯BOB体育官方APP登入是高级软件工程师和架构师面试基本必问的项目(先通过面试进入这个门槛我们再谈其它)
  • 卡塔尔世界杯BOB体育官方APP登入是经过大量实践检验的安全高效可复用的解决方案。不要重复发明轮子,而且大多数时候你发明的轮子还没有已有的好
  • 卡塔尔世界杯BOB体育官方APP登入是被主流工程师/架构师所广泛接受和使用的,你使用它,方便与别人沟通,也方便别人code review(这个够实在吧)
  • 使用卡塔尔世界杯BOB体育官方APP登入可以帮你快速解决80%的代码设计问题,从而让你更专注于业务本身
  • 卡塔尔世界杯BOB体育官方APP登入本身是对几大特性的利用和对几大设计原则的践行,代码量积累到一定程度,你会发现你已经或多或少的在使用某些卡塔尔世界杯BOB体育官方APP登入了
  • 架构师或者team leader教授初级工程师卡塔尔世界杯BOB体育官方APP登入,可以很方便的以大家认可以方式提高初级工程师的代码设计水平,从而有利于提高团队工程实力
  • 是不是一定要尽可能使用卡塔尔世界杯BOB体育官方APP登入

    每个卡塔尔世界杯BOB体育官方APP登入都有其适合范围,并解决特定问题。所以项目实践中应该针对特定使用场景选用合适的卡塔尔世界杯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登入太教条化

    卡塔尔世界杯BOB体育官方APP登入虽然都有其相对固定的实现方式,但是它的精髓是利用OOP的三大特性,遵循OOD七大原则解决工程问题。所以学习卡塔尔世界杯BOB体育官方APP登入的目的不是学习卡塔尔世界杯BOB体育官方APP登入的固定实现方式本身,而是其思想。

    我有自己的一套思路,没必要引导团队成员学习卡塔尔世界杯BOB体育官方APP登入

    卡塔尔世界杯BOB体育官方APP登入是被广泛接受和使用的,引导团队成员使用卡塔尔世界杯BOB体育官方APP登入可以减少沟通成本,而更专注于业务本身。也许你有自己的一套思路,但是你怎么能保证团队成员一定认可你的思路,进而将你的思路贯彻实施呢?统一使用卡塔尔世界杯BOB体育官方APP登入能让团队只使用20%的精力决解80%的问题。其它20%的问题,才是你需要花精力解决的。

    Java卡塔尔世界杯BOB体育官方APP登入系列

  • Java卡塔尔世界杯BOB体育官方APP登入(一) 简单工厂模式不简单
  • Java卡塔尔世界杯BOB体育官方APP登入(二) 工厂方法模式
  • Java卡塔尔世界杯BOB体育官方APP登入(三) 抽象工厂模式
  • Java卡塔尔世界杯BOB体育官方APP登入(四) 观察者模式
  • Java卡塔尔世界杯BOB体育官方APP登入(五) 组合模式
  • Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式
  • Java卡塔尔世界杯BOB体育官方APP登入(七) Spring AOP JDK动态代理 vs. cglib
  • Java卡塔尔世界杯BOB体育官方APP登入(八) 适配器模式
  • Java卡塔尔世界杯BOB体育官方APP登入(九) 桥接模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十) 你真的用对单例模式了吗?
  • Java卡塔尔世界杯BOB体育官方APP登入(十一) 享元模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十二) 策略模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十三) 别人再问你卡塔尔世界杯BOB体育官方APP登入,叫他看这篇文章
  • ]]>
    本文讲解了卡塔尔世界杯BOB体育官方APP登入与OOP的三大特性及OOP七项原则间的关系,并讲解了使用卡塔尔世界杯BOB体育官方APP登入的好处及为何需要使用卡塔尔世界杯BOB体育官方APP登入。最后通过问答形式讲解了卡塔尔世界杯BOB体育官方APP登入相关的常见问题
    技术世界 http://www.jasongj.com/design_pattern/strategy/ 2016-05-29T23:46:09.000Z 2017-02-17T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/design_pattern/strategy/

    策略模式介绍

    策略模式定义

    策略模式(Strategy Pattern),将各种算法封装到具体的类中,作为一个抽象策略类的子类,使得它们可以互换。客户端可以自行决定使用哪种算法。

    策略模式类图

    策略模式类图如下
    Strategy Pattern Class Diagram

    策略模式角色划分

  • Strategy 策略接口或者(抽象策略类),定义策略执行接口
  • ConcreteStrategy 具体策略类
  • Context 上下文类,持有具体策略类的实例,并负责调用相关的算法
  • 策略模式实例解析

    本文代码可从作者Github下载

    典型策略模式实现

    策略接口,定义策略执行接口

    1
    2
    3
    4
    5
    6
    7
    package 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
    16
    package com.jasongj.strategy;

    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;

    @com.jasongj.annotation.Strategy(name="StrategyA")
    public class ConcreteStrategyA implements Strategy {

    private static final Logger LOG = LoggerFactory.getLogger(ConcreteStrategyB.class);

    @Override
    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;

    @com.jasongj.annotation.Strategy(name="StrategyB")
    public class ConcreteStrategyB implements Strategy {

    private static final Logger LOG = LoggerFactory.getLogger(ConcreteStrategyB.class);

    @Override
    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
    17
    package 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
    15
    package 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");
    }

    }

    使用Annotation和简单工厂模式增强策略模式

    上面的实现中,客户端需要显示决定具体使用何种策略,并且一旦需要换用其它策略,需要修改客户端的代码。解决这个问题,一个比较好的方式是使用简单工厂,使得客户端都不需要知道策略类的实例化过程,甚至都不需要具体哪种策略被使用。

    如《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
    63
    package 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
    12
    package 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");
    }

    }

    从上面代码可以看出,引入简单工厂模式后,客户端不再需要直接实例化具体的策略类,也不需要判断应该使用何种策略,可以方便应对策略的切换。

    策略模式分析

    策略模式优点

  • 策略模式提供了对“开闭原则”的完美支持,用户可以在不修改原有系统的基础上选择算法(策略),并且可以灵活地增加新的算法(策略)。
  • 策略模式通过Context类提供了管理具体策略类(算法族)的办法。
  • 结合简单工厂模式和Annotation,策略模式可以方便的在不修改客户端代码的前提下切换算法(策略)。
  • 策略模式缺点

  • 传统的策略模式实现方式中,客户端必须知道所有的具体策略类,并须自行显示决定使用哪一个策略类。但通过本文介绍的通过和Annotation和简单工厂模式结合,可以有效避免该问题
  • 如果使用不当,策略模式可能创建很多具体策略类的实例,但可以通过使用上文《Java卡塔尔世界杯BOB体育官方APP登入(十一) 享元模式》介绍的享元模式有效减少对象的数量。
  • 策略模式已(未)遵循的OOP原则

    已遵循的OOP原则

  • 依赖倒置原则
  • 迪米特法则
  • 里氏替换原则
  • 接口隔离原则
  • 单一职责原则
  • 开闭原则
  • 未遵循的OOP原则

  • NA
  • Java卡塔尔世界杯BOB体育官方APP登入系列

  • Java卡塔尔世界杯BOB体育官方APP登入(一) 简单工厂模式不简单
  • Java卡塔尔世界杯BOB体育官方APP登入(二) 工厂方法模式
  • Java卡塔尔世界杯BOB体育官方APP登入(三) 抽象工厂模式
  • Java卡塔尔世界杯BOB体育官方APP登入(四) 观察者模式
  • Java卡塔尔世界杯BOB体育官方APP登入(五) 组合模式
  • Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式
  • Java卡塔尔世界杯BOB体育官方APP登入(七) Spring AOP JDK动态代理 vs. cglib
  • Java卡塔尔世界杯BOB体育官方APP登入(八) 适配器模式
  • Java卡塔尔世界杯BOB体育官方APP登入(九) 桥接模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十) 你真的用对单例模式了吗?
  • Java卡塔尔世界杯BOB体育官方APP登入(十一) 享元模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十二) 策略模式
  • ]]>
    本文结合实例详述了策略模式的实现方式,并介绍了如何结合简单工厂模式及Annotation优化策略模式。最后分析了策略模式的优缺点及已(未)遵循的OOP原则
    技术世界 http://www.jasongj.com/design_pattern/flyweight/ 2016-05-22T23:34:46.000Z 2017-02-17T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/design_pattern/flyweight/

    享元模式介绍

    享元模式适用场景

    面向对象技术可以很好的解决一些灵活性或可扩展性问题,但在很多情况下需要在系统中增加类和对象的个数。当对象数量太多时,将导致对象创建及垃圾回收的代价过高,造成性能下降等问题。享元模式通过共享相同或者相似的细粒度对象解决了这一类问题。

    享元模式定义

    享元模式(Flyweight Pattern),又称轻量级模式(这也是其英文名为FlyWeight的原因),通过共享技术有效地实现了大量细粒度对象的复用。

    享元模式类图

    享元模式类图如下
    FlyWeight Pattern Class Diagram

    享元模式角色划分

  • FlyWeight 享元接口或者(抽象享元类),定义共享接口
  • ConcreteFlyWeight 具体享元类,该类实例将实现共享
  • UnSharedConcreteFlyWeight 非共享享元实现类
  • FlyWeightFactory 享元工厂类,控制实例的创建和共享
  • 内部状态 vs. 外部状态

  • 内部状态是存储在享元对象内部,一般在构造时确定或通过setter设置,并且不会随环境改变而改变的状态,因此内部状态可以共享。
  • 外部状态是随环境改变而改变、不可以共享的状态。外部状态在需要使用时通过客户端传入享元对象。外部状态必须由客户端保存。
  • 享元模式实例解析

    本文代码可从作者Github下载

    享元接口,定义共享接口

    1
    2
    3
    4
    5
    6
    7
    package 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
    21
    package 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;
    }

    @Override
    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
    31
    package 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属于外部状态,由客户端在调用时传入。

    享元模式分析

    享元模式优点

  • 享元模式的外部状态相对独立,使得对象可以在不同的环境中被复用(共享对象可以适应不同的外部环境)
  • 享元模式可共享相同或相似的细粒度对象,从而减少了内存消耗,同时降低了对象创建与垃圾回收的开销
  • 享元模式缺点

  • 外部状态由客户端保存,共享对象读取外部状态的开销可能比较大
  • 享元模式要求将内部状态与外部状态分离,这使得程序的逻辑复杂化,同时也增加了状态维护成本
  • 享元模式已(未)遵循的OOP原则

    已遵循的OOP原则

  • 依赖倒置原则
  • 迪米特法则
  • 里氏替换原则
  • 接口隔离原则
  • 单一职责原则
  • 开闭原则
  • 未遵循的OOP原则

  • NA
  • Java卡塔尔世界杯BOB体育官方APP登入系列

  • Java卡塔尔世界杯BOB体育官方APP登入(一) 简单工厂模式不简单
  • Java卡塔尔世界杯BOB体育官方APP登入(二) 工厂方法模式
  • Java卡塔尔世界杯BOB体育官方APP登入(三) 抽象工厂模式
  • Java卡塔尔世界杯BOB体育官方APP登入(四) 观察者模式
  • Java卡塔尔世界杯BOB体育官方APP登入(五) 组合模式
  • Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式
  • Java卡塔尔世界杯BOB体育官方APP登入(七) Spring AOP JDK动态代理 vs. cglib
  • Java卡塔尔世界杯BOB体育官方APP登入(八) 适配器模式
  • Java卡塔尔世界杯BOB体育官方APP登入(九) 桥接模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十) 你真的用对单例模式了吗?
  • Java卡塔尔世界杯BOB体育官方APP登入(十一) 享元模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十二) 策略模式
  • ]]>
    本文介绍了享元模式的适用场景,并结合实例详述了享元模式的实现方式。最后分析了享元模式的优缺点及已(未)遵循的OOP原则
    技术世界 http://www.jasongj.com/design_pattern/singleton/ 2016-05-15T23:34:46.000Z 2017-11-12T07:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/design_pattern/singleton/

    为何需要单例模式

    对于系统中的某些类来说,只有一个实例很重要,例如,一个系统只能有一个窗口管理器或文件系统;一个系统只能有一个计时工具或ID(序号)生成器。

    单例模式设计要点

  • 保证该类只有一个实例。将该类的构造方法定义为私有方法,这样其他处的代码就无法通过调用该类的构造方法来实例化该类的对象
  • 提供一个该实例的访问点。一般由该类自己负责创建实例,并提供一个静态方法作为该实例的访问点
  • 饿汉 vs. 懒汉

  • 饿汉 声明实例引用时即实例化
  • 懒汉 静态方法第一次被调用前不实例化,也即懒加载。对于创建实例代价大,且不定会使用时,使用懒加载模式可以减少开销
  • 实现单例模式的九种方法

    线程不安全的懒汉 - 多线程不可用

    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;
    }

    }
  • 优点:达到了Lazy Loading的效果
  • 缺点:只有在单线程下能保证只有一个实例,多线程下有创建多个实例的风险
  • 同步方法下的懒汉 - 可用,不推荐

    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,但本质上是线程不安全的。
  • 双重检查(Double Check)下的懒汉 - 推荐

    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;
    }

    }
  • 优点:使用了双重检查,避免了线程不安全,同时也避免了不必要的锁开销。
  • 缺点:NA
  • 注:

  • 但是这里的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;
    }

    }
  • 优点:无线程同步问题
  • 缺点:类装载时创建实例,无Lazy Loading。实例一直未被使用时,会浪费资源
  • 静态内部类 推荐

    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();
    }

    }
  • 优点:无线程同步问题,实现了懒加载(Lazy Loading)。因为只有调用getInstance时才会装载内部类,才会创建实例。同时因为使用内部类时,先调用内部类的线程会获得类初始化锁,从而保证内部类的初始化(包括实例化它所引用的外部类对象)线程安全。即使内部类创建外部类的实例Singleton INSTANCE = new Singleton()发生指令重排也不会引起双重检查(Double-Check)下的懒汉模式中提到的问题,因此无须使用volatile关键字。
  • 缺点:NA
  • 枚举 强烈推荐

    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;
    }

    }
  • 优点:枚举本身是线程安全的,且能防止通过反射和反序列化创建多实例。
  • 缺点:使用的是枚举,而非类。  
  • Java卡塔尔世界杯BOB体育官方APP登入系列

  • Java卡塔尔世界杯BOB体育官方APP登入(一) 简单工厂模式不简单
  • Java卡塔尔世界杯BOB体育官方APP登入(二) 工厂方法模式
  • Java卡塔尔世界杯BOB体育官方APP登入(三) 抽象工厂模式
  • Java卡塔尔世界杯BOB体育官方APP登入(四) 观察者模式
  • Java卡塔尔世界杯BOB体育官方APP登入(五) 组合模式
  • Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式
  • Java卡塔尔世界杯BOB体育官方APP登入(七) Spring AOP JDK动态代理 vs. cglib
  • Java卡塔尔世界杯BOB体育官方APP登入(八) 适配器模式
  • Java卡塔尔世界杯BOB体育官方APP登入(九) 桥接模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十) 你真的用对单例模式了吗?
  • Java卡塔尔世界杯BOB体育官方APP登入(十一) 享元模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十二) 策略模式
  • ]]>
    本文介绍了为何需要单例模式,单例模式的设计要点,饿汉和懒汉的区别,并通过实例介绍了实现单例模式的八种实现方式及其优缺点。
    技术世界 http://www.jasongj.com/design_pattern/bridge/ 2016-05-11T23:34:46.000Z 2017-02-17T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/design_pattern/bridge/

    桥接模式定义

    桥接模式(Bridge Pattern),将抽象部分与它的实现部分分离,使它们都可以独立地变化。更容易理解的表述是:实现系统可从多种维度分类,桥接模式将各维度抽象出来,各维度独立变化,之后可通过聚合,将各维度组合起来,减少了各维度间的耦合。

    例讲桥接模式

    不必要的继承导致类爆炸

    汽车可按品牌分(本例中只考虑BMT,BenZ,Land Rover),也可按手动档、自动档、手自一体来分。如果对于每一种车都实现一个具体类,则一共要实现3*3=9个类。

    使用继承方式的类图如下
    Bridge pattern inherit class diagram

    从上图可以看到,对于每种组合都需要创建一个具体类,如果有N个维度,每个维度有M种变化,则需要$M^N$个具体类,类非常多,并且非常多的重复功能。

    如果某一维度,如Transmission多一种可能,比如手自一体档(AMT),则需要增加3个类,BMWAMT,BenZAMT,LandRoverAMT。

    桥接模式类图

    桥接模式类图如下
    Bridge pattern class diagram

    从上图可知,当把每个维度拆分开来,只需要M*N个类,并且由于每个维度独立变化,基本不会出现重复代码。

    此时如果增加手自一体档,只需要增加一个AMT类即可

    桥接模式实例解析

    本文代码可从作者Github下载

    抽象车

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    package 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
    16
    package 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);

    @Override
    public void run() {
    gear.gear();
    LOG.info("BMW is running");
    };

    }

    BenZCar

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    package 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);

    @Override
    public void run() {
    gear.gear();
    LOG.info("BenZCar is running");
    };

    }

    LandRoverCar

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    package 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);

    @Override
    public void run() {
    gear.gear();
    LOG.info("LandRoverCar is running");
    };

    }

    抽象变速器

    1
    2
    3
    4
    5
    6
    7
    package com.jasongj.transmission;

    public abstract class Transmission{

    public abstract void gear();

    }

    手动档

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    package 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);

    @Override
    public void gear() {
    LOG.info("Manual transmission");
    }
    }

    自动档

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    package 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);

    @Override
    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
    25
    package 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();
    }

    }

    桥接模式与OOP原则

    已遵循的原则

  • 依赖倒置原则
  • 迪米特法则
  • 里氏替换原则
  • 接口隔离原则
  • 单一职责原则
  • 开闭原则
  • 未遵循的原则

  • NA
  • Java卡塔尔世界杯BOB体育官方APP登入系列

  • Java卡塔尔世界杯BOB体育官方APP登入(一) 简单工厂模式不简单
  • Java卡塔尔世界杯BOB体育官方APP登入(二) 工厂方法模式
  • Java卡塔尔世界杯BOB体育官方APP登入(三) 抽象工厂模式
  • Java卡塔尔世界杯BOB体育官方APP登入(四) 观察者模式
  • Java卡塔尔世界杯BOB体育官方APP登入(五) 组合模式
  • Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式
  • Java卡塔尔世界杯BOB体育官方APP登入(七) Spring AOP JDK动态代理 vs. cglib
  • Java卡塔尔世界杯BOB体育官方APP登入(八) 适配器模式
  • Java卡塔尔世界杯BOB体育官方APP登入(九) 桥接模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十) 你真的用对单例模式了吗?
  • Java卡塔尔世界杯BOB体育官方APP登入(十一) 享元模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十二) 策略模式
  • ]]>
    当一种事物可在多种维度变化(如两个维度,每个维度三种可能)时,如果为每一种可能创建一个子类,则每增加一个维度上的可能需要增加多个类,这会造成类爆炸(3*3=9)。若使用桥接模式,使用类聚合,而非继承,将可缓解类爆炸,并增强可扩展性。
    技术世界 http://www.jasongj.com/design_pattern/adapter/ 2016-05-08T23:04:46.000Z 2017-02-17T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/design_pattern/adapter/

    适配器模式介绍

    适配器模式定义

    适配器模式(Adapter Pattern),将一个类的接口转换成客户希望的另外一个接口。适配器模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。

    适配器模式类图

    适配器模式类图如下
    Adapter pattern class diagram

    适配器模式角色划分

  • 目标接口,如上图中的ITarget
  • 具体目标实现,如ConcreteTarget
  • 适配器,Adapter
  • 待适配类,Adaptee
  • 实例解析

    本文代码可从作者Github下载

    目标接口

    1
    2
    3
    4
    5
    6
    7
    package com.jasongj.target;

    public interface ITarget {

    void request();

    }

    目标接口实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    package com.jasongj.target;

    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;


    public class ConcreteTarget implements ITarget {

    private static Logger LOG = LoggerFactory.getLogger(ConcreteTarget.class);

    @Override
    public void request() {
    LOG.info("ConcreteTarget.request()");
    }

    }

    待适配类,其接口名为onRequest,而非目标接口request

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    package 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
    20
    package 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();

    @Override
    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();
    }

    }

    适配器模式适用场景

  • 调用双方接口不一致且都不容易修改时,可以使用适配器模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作
  • 多个组件功能类似,但接口不统一且可能会经常切换时,可使用适配器模式,使得客户端可以以统一的接口使用它们
  • 适配器模式优缺点

    适配器模式优点

  • 客户端可以以统一的方式使用ConcreteTarget和Adaptee
  • 适配器负责适配过程,而不需要修改待适配类,其它直接依赖于待适配类的调用方不受适配过程的影响
  • 可以为不同的目标接口实现不同的适配器,而不需要修改待适配类,符合开放-关闭原则
  • 适配器模式与OOP原则

    已遵循的原则

  • 依赖倒置原则
  • 迪米特法则
  • 里氏替换原则
  • 接口隔离原则
  • 单一职责原则
  • 开闭原则
  • 未遵循的原则

  • NA
  • Java卡塔尔世界杯BOB体育官方APP登入系列

  • Java卡塔尔世界杯BOB体育官方APP登入(一) 简单工厂模式不简单
  • Java卡塔尔世界杯BOB体育官方APP登入(二) 工厂方法模式
  • Java卡塔尔世界杯BOB体育官方APP登入(三) 抽象工厂模式
  • Java卡塔尔世界杯BOB体育官方APP登入(四) 观察者模式
  • Java卡塔尔世界杯BOB体育官方APP登入(五) 组合模式
  • Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式
  • Java卡塔尔世界杯BOB体育官方APP登入(七) Spring AOP JDK动态代理 vs. cglib
  • Java卡塔尔世界杯BOB体育官方APP登入(八) 适配器模式
  • Java卡塔尔世界杯BOB体育官方APP登入(九) 桥接模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十) 你真的用对单例模式了吗?
  • Java卡塔尔世界杯BOB体育官方APP登入(十一) 享元模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十二) 策略模式
  • ]]>
    适配器模式可将一个类的接口转换成调用方希望的另一个接口。这种需求往往发生在后期维护阶段,因此有观点认为适配器模式只是前期系统接口设计缺乏的一种弥补。从实际工程来看,并不完全这样,有时不同产商的功能类似但接口很难完全一样,而为了系统使用方式的一致性,也会用到适配器模式。
    技术世界 http://www.jasongj.com/design_pattern/dynamic_proxy_cglib/ 2016-05-02T12:42:46.000Z 2017-02-17T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/design_pattern/dynamic_proxy_cglib/

    静态代理 VS. 动态代理

    静态代理,是指程序运行前就已经存在了代理类的字节码文件,代理类和被代理类的关系在运行前就已经确定。

    上一篇文章《Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式》所讲的代理为静态代理。如上文所讲,一个静态代理类只代理一个具体类。如果需要对实现了同一接口的不同具体类作代理,静态代理需要为每一个具体类创建相应的代理类。

    动态代理类的字节码是在程序运行期间动态生成,所以不存在代理类的字节码文件。代理类和被代理类的关系是在程序运行时确定的。

    JDK动态代理

    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;

    @SuppressWarnings("rawtypes")
    public SubjectProxyHandler(Class clazz) {
    try {
    this.target = clazz.newInstance();
    } catch (InstantiationException | IllegalAccessException ex) {
    LOG.error("Create proxy for {} failed", clazz.getName());
    }
    }

    @Override
    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()");
    }

    }

    从上述代码中可以看到,被代理对象的类对象作为参数传给了构造方法,原因如下

  • 如上文所述,动态代理可以代理多种类,而且具体代理哪种类并非台静态代理那样编译时确定,而是在运行时指定
  • 之所以不传被代理类的实例而是传类对象,是为了与上文《Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式》吻合——被代理对象不由客户端创建而由代理创建,客户端甚至都不需要知道被代理对象的存在。具体传被代理类的实例还是传类对象,并无严格规定
  • 一些讲JDK动态代理的例子会专门使用一个public方法去接收该参数。但笔者个人认为最好不要在具体类中实现未出现在接口定义中的public方法
  • 注意,SubjectProxyHandler定义的是代理行为而非代理类本身。实际上代理类及其实例是在运行时通过反射动态创建出来的。

    JDK动态代理使用方式

    代理行为定义好后,先实例化SubjectProxyHandler(在构造方法中指明被代理类),然后通过Proxy.newProxyInstance动态创建代理类的实例。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    package 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
    4
    SubjectProxyHandler.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
    71
    import 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

    cglib介绍

    cglib是一个强大的高性能代码生成库,它的底层是通过使用一个小而快的字节码处理框架ASM(Java字节码操控框架)来转换字节码并生成新的类。

    cglib方法拦截器

    使用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
    32
    package 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);

    @Override
    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方法(该方法名可以自定义)获取动态代理的实例,并且可以通过向该方法传入类对象指定被代理对象的类型。

    cglib使用方式

    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
    82
    package 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
    4
    JDK 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倍

    JDK动态代理与cglib对比

  • 字节码创建方式:JDK动态代理通过JVM实现代理类字节码的创建,cglib通过ASM创建字节码
  • 对被代理对象的要求:JDK动态代理要求被代理对象实现接口,cglib要求被代理对象未被final修饰
  • 代理对象创建速度:JDK动态代理创建代理对象速度比cglib快
  • 代理对象执行速度:JDK动态代理代理对象执行速度比cglib快
  • 本文所有示例代理均可从作者Github下载

    Java卡塔尔世界杯BOB体育官方APP登入系列

  • Java卡塔尔世界杯BOB体育官方APP登入(一) 简单工厂模式不简单
  • Java卡塔尔世界杯BOB体育官方APP登入(二) 工厂方法模式
  • Java卡塔尔世界杯BOB体育官方APP登入(三) 抽象工厂模式
  • Java卡塔尔世界杯BOB体育官方APP登入(四) 观察者模式
  • Java卡塔尔世界杯BOB体育官方APP登入(五) 组合模式
  • Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式
  • Java卡塔尔世界杯BOB体育官方APP登入(七) Spring AOP JDK动态代理 vs. cglib
  • Java卡塔尔世界杯BOB体育官方APP登入(八) 适配器模式
  • Java卡塔尔世界杯BOB体育官方APP登入(九) 桥接模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十) 你真的用对单例模式了吗?
  • Java卡塔尔世界杯BOB体育官方APP登入(十一) 享元模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十二) 策略模式
  • ]]>
    Spring的AOP有JDK动态代理和cglib两种实现方式。JDK动态代理要求被代理对象实现接口;cglib通过动态继承实现,因此不能代理被final修饰的类;JDK动态代理生成代理对象速度比cglib快;cglib生成的代理对象比JDK动态代理生成的代理对象执行效率高。
    技术世界 http://www.jasongj.com/design_pattern/proxy_decorator/ 2016-04-29T12:42:46.000Z 2017-02-17T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/design_pattern/proxy_decorator/

    模式介绍

  • 代理模式(Proxy Pattern),为其它对象提供一种代理以控制对这个对象的访问。
  • 装饰模式(Decorator Pattern),动态地给一个对象添加一些额外的职责。
  • 从语意上讲,代理模式的目标是控制对被代理对象的访问,而装饰模式是给原对象增加额外功能。

    类图

    代理模式类图如下
    Proxy pattern class diagram

    装饰模式类图如下
    Decorator pattern class diagram

    从上图可以看到,代理模式和装饰模式的类图非常类似。下面结合具体的代码讲解两者的不同。

    代码解析

    本文所有代码均可从作者Github下载

    相同部分

    代理模式和装饰模式都包含ISubject和ConcreteSubject,并且这两种模式中这两个Component的实现没有任何区别。

    ISubject代码如下

    1
    2
    3
    4
    5
    6
    7
    package com.jasongj.subject;

    public interface ISubject {

    void action();

    }

    ConcreteSubject代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    package 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);

    @Override
    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
    39
    package 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();
    }

    @Override
    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
    13
    package 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
    28
    package 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;
    }

    @Override
    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;
    }

    @Override
    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
    17
    package 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)指定。

    装饰模式的本质是动态组合。动态是手段,组合是目的。每个装饰类可以只负责添加一项额外功能,然后通过组合为被装饰类添加复杂功能。由于每个装饰类的职责比较简单单一,增加了这些装饰类的可重用性,同时也更符合单一职责原则。

    总结

  • 从语意上讲,代理模式是为控制对被代理对象的访问,而装饰模式是为了增加被装饰对象的功能
  • 代理类所能代理的类完全由代理类确定,装饰类装饰的对象需要根据实际使用时客户端的组合来确定
  • 被代理对象由代理对象创建,客户端甚至不需要知道被代理类的存在;被装饰对象由客户端创建并传给装饰对象
  • Java卡塔尔世界杯BOB体育官方APP登入系列

  • Java卡塔尔世界杯BOB体育官方APP登入(一) 简单工厂模式不简单
  • Java卡塔尔世界杯BOB体育官方APP登入(二) 工厂方法模式
  • Java卡塔尔世界杯BOB体育官方APP登入(三) 抽象工厂模式
  • Java卡塔尔世界杯BOB体育官方APP登入(四) 观察者模式
  • Java卡塔尔世界杯BOB体育官方APP登入(五) 组合模式
  • Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式
  • Java卡塔尔世界杯BOB体育官方APP登入(七) Spring AOP JDK动态代理 vs. cglib
  • Java卡塔尔世界杯BOB体育官方APP登入(八) 适配器模式
  • Java卡塔尔世界杯BOB体育官方APP登入(九) 桥接模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十) 你真的用对单例模式了吗?
  • Java卡塔尔世界杯BOB体育官方APP登入(十一) 享元模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十二) 策略模式
  • ]]>
    代理模式与装饰模式在代码组织结构上非常相近,以至于很多读者很难区分它们。本文将结合实例对比代理模式和装饰模式的适用场景,实现方式。
    技术世界 http://www.jasongj.com/design_pattern/composite/ 2016-04-23T23:09:46.000Z 2017-02-17T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/design_pattern/composite/

    组合模式介绍

    组合模式定义

    组合模式(Composite Pattern)将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户可以使用一致的方法操作单个对象和组合对象。

    组合模式类图

    组合模式类图如下
    Composite pattern class diagram

    组合模式角色划分

  • 抽象组件,如上图中的Component
  • 简单组件,如上图中的SimpleComponent
  • 复合组件,如上图中的CompositeComponent
  • 组合模式实例

    实例介绍

    对于一家大型公司,每当公司高层有重要事项需要通知到总部每个部门以及分公司的各个部门时,并不希望逐一通知,而只希望通过总部各部门及分公司,再由分公司通知其所有部门。这样,对于总公司而言,不需要关心通知的是总部的部门还是分公司。

    实例类图

    组合模式实例类图如下(点击可查看大图)
    Composite pattern example class diagram

    实例解析

    本例代码可从作者Github下载

    抽象组件

    抽象组件定义了组件的通知接口,并实现了增删子组件及获取所有子组件的方法。同时重写了hashCodeequales方法(至于原因,请读者自行思考。如有疑问,请在评论区留言)。

    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
    package 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);

    @Override
    public int hashCode(){
    return this.name.hashCode();
    }

    @Override
    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
    18
    package 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
    22
    package 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+"-"));
    }

    }

    组合模式优缺点

    组合模式优点

  • 高层模块调用简单。组合模式中,用户不用关心到底是处理简单组件还是复合组件,可以按照统一的接口处理。不必判断组件类型,更不用为不同类型组件分开处理。
  • 组合模式可以很容易的增加新的组件。若要增加一个简单组件或复合组件,只须找到它的父节点即可,非常容易扩展,符合“开放-关闭”原则。
  • 组合模式缺点

  • 无法限制组合组件中的子组件类型。在需要检测组件类型时,不能依靠编译期的类型约束来实现,必须在运行期间动态检测。
  • 组合模式与OOP原则

    已遵循的原则

  • 依赖倒置原则(复合类型不依赖于任何具体的组件而依赖于抽象组件)
  • 迪米特法则
  • 里氏替换原则
  • 接口隔离原则
  • 单一职责原则
  • 开闭原则
  • 未遵循的原则

  • NA
  • Java卡塔尔世界杯BOB体育官方APP登入系列

  • Java卡塔尔世界杯BOB体育官方APP登入(一) 简单工厂模式不简单
  • Java卡塔尔世界杯BOB体育官方APP登入(二) 工厂方法模式
  • Java卡塔尔世界杯BOB体育官方APP登入(三) 抽象工厂模式
  • Java卡塔尔世界杯BOB体育官方APP登入(四) 观察者模式
  • Java卡塔尔世界杯BOB体育官方APP登入(五) 组合模式
  • Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式
  • Java卡塔尔世界杯BOB体育官方APP登入(七) Spring AOP JDK动态代理 vs. cglib
  • Java卡塔尔世界杯BOB体育官方APP登入(八) 适配器模式
  • Java卡塔尔世界杯BOB体育官方APP登入(九) 桥接模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十) 你真的用对单例模式了吗?
  • Java卡塔尔世界杯BOB体育官方APP登入(十一) 享元模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十二) 策略模式
  • ]]>
    本文介绍了组合模式的概念,UML类图,优缺点,实例讲解以及组合模式(未)遵循的OOP原则。
    技术世界 http://www.jasongj.com/design_pattern/observer/ 2016-04-13T12:13:46.000Z 2017-02-17T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/design_pattern/observer/

    观察者模式介绍

    观察者模式定义

    观察者模式又叫发布-订阅模式,它定义了一种一对多的依赖关系,多个观察者对象可同时监听某一主题对象,当该主题对象状态发生变化时,相应的所有观察者对象都可收到通知。

    观察者模式类图

    观察者模式类图如下(点击可查看大图)
    Observer pattern class diagram

    观察者模式角色划分

  • 主题,抽象类或接口,如上面类图中的AbstractSubject
  • 具体主题,如上面类图中的Subject1,Subject2
  • 观察者,如上面类图中的IObserver
  • 具体观察者,如上面类图中的Observer1,Observer2,Observer3
  • 观察者模式实例

    实例介绍

    猎头或者HR往往会有很多职位信息,求职者可以在猎头或者HR那里注册,当猎头或者HR有新的岗位信息时,即会通知这些注册过的求职者。这是一个典型的观察者模式使用场景。

    实例类图

    观察者模式实例类图如下(点击可查看大图)
    Observer pattern example class diagram

    实例解析

    本例代码可从作者Github下载

    观察者接口(或抽象观察者,如本例中的ITalent)需要定义回调接口,如下

    1
    2
    3
    4
    5
    6
    7
    package 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
    15
    package 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);

    @Override
    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
    22
    package 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
    10
    package com.jasongj.subject;

    public class HeadHunter extends AbstractHR {

    @Override
    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
    25
    package 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");
    }

    }

    观察者模式优缺点

    观察者模式优点

  • 抽象主题只依赖于抽象观察者
  • 观察者模式支持广播通信
  • 观察者模式使信息产生层和响应层分离
  • 观察者模式缺点

  • 如一个主题被大量观察者注册,则通知所有观察者会花费较高代价
  • 如果某些观察者的响应方法被阻塞,整个通知过程即被阻塞,其它观察者不能及时被通知
  • 观察者模式与OOP原则

    已遵循的原则

  • 依赖倒置原则(主题类依赖于抽象观察者而非具体观察者)
  • 迪米特法则
  • 里氏替换原则
  • 接口隔离原则
  • 单一职责原则
  • 开闭原则
  • 未遵循的原则

  • NA
  • Java卡塔尔世界杯BOB体育官方APP登入系列

  • Java卡塔尔世界杯BOB体育官方APP登入(一) 简单工厂模式不简单
  • Java卡塔尔世界杯BOB体育官方APP登入(二) 工厂方法模式
  • Java卡塔尔世界杯BOB体育官方APP登入(三) 抽象工厂模式
  • Java卡塔尔世界杯BOB体育官方APP登入(四) 观察者模式
  • Java卡塔尔世界杯BOB体育官方APP登入(五) 组合模式
  • Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式
  • Java卡塔尔世界杯BOB体育官方APP登入(七) Spring AOP JDK动态代理 vs. cglib
  • Java卡塔尔世界杯BOB体育官方APP登入(八) 适配器模式
  • Java卡塔尔世界杯BOB体育官方APP登入(九) 桥接模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十) 你真的用对单例模式了吗?
  • Java卡塔尔世界杯BOB体育官方APP登入(十一) 享元模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十二) 策略模式
  • ]]>
    本文介绍了观察者模式的概念,UML类图,优缺点,实例分析以及观察者模式(未)遵循的OOP原则。
    技术世界 http://www.jasongj.com/design_pattern/abstract_factory/ 2016-04-09T12:39:46.000Z 2017-02-17T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/design_pattern/abstract_factory/

    抽象工厂模式解决的问题

    上文《工厂方法模式》中提到,在工厂方法模式中一种工厂只能创建一种具体产品。而在抽象工厂模式中一种具体工厂可以创建多个种类的具体产品。

    抽象工厂模式

    抽象工厂模式介绍

    抽象工厂模式(Factory Method Pattern)中,抽象工厂提供一系列创建多个抽象产品的接口,而具体的工厂负责实现具体的产品实例。抽象工厂模式与工厂方法模式最大的区别在于抽象工厂中每个工厂可以创建多个种类的产品。

    抽象工厂模式类图

    抽象工厂模式类图如下 (点击可查看大图)
    Factory Method Pattern Class Diagram

    抽象工厂模式角色划分

  • 抽象产品(或者产品接口),如上文类图中的IUserDao,IRoleDao,IProductDao
  • 具体产品,如PostgreSQLProductDao
  • 抽象工厂(或者工厂接口),如IFactory
  • 具体工厂,如果MySQLFactory
  • 产品族,如Oracle产品族,包含OracleUserDao,OracleRoleDao,OracleProductDao
  • 抽象工厂模式使用方式

    与工厂方法模式类似,在创建具体产品时,客户端通过实例化具体的工厂类,并调用其创建目标产品的方法创建具体产品类的实例。根据依赖倒置原则,具体工厂类的实例由工厂接口引用,具体产品的实例由产品接口引用。具体调用代码如下

    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
    package 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即可。

    抽象工厂模式优点

  • 因为每个具体工厂类只负责创建产品,没有简单工厂中的逻辑判断,因此符合单一职责原则。
  • 与简单工厂模式不同,抽象工厂并不使用静态工厂方法,可以形成基于继承的等级结构。
  • 新增一个产品族(如上文类图中的MySQLUserDao,MySQLRoleDao,MySQLProductDao)时,只需要增加相应的具体产品和对应的具体工厂类即可。相比于简单工厂模式需要修改判断逻辑而言,抽象工厂模式更符合开-闭原则。
  • 抽象工厂模式缺点

  • 新增产品种类(如上文类图中的UserDao,RoleDao,ProductDao)时,需要修改工厂接口(或者抽象工厂)及所有具体工厂,此时不符合开-闭原则。抽象工厂模式对于新的产品族符合开-闭原则而对于新的产品种类不符合开-闭原则,这一特性也被称为开-闭原则的倾斜性。
  • 抽象工厂模式与OOP原则

    已遵循的原则

  • 依赖倒置原则(工厂构建产品的方法均返回产品接口而非具体产品,从而使客户端依赖于产品抽象而非具体)
  • 迪米特法则
  • 里氏替换原则
  • 接口隔离原则
  • 单一职责原则(每个工厂只负责创建自己的具体产品族,没有简单工厂中的逻辑判断)
  • 开闭原则(增加新的产品族,不像简单工厂那样需要修改已有的工厂,而只需增加相应的具体工厂类)
  • 未遵循的原则

  • 开闭原则(虽然对新增产品族符合开-闭原则,但对新增产品种类不符合开-闭原则)
  • Java卡塔尔世界杯BOB体育官方APP登入系列

  • Java卡塔尔世界杯BOB体育官方APP登入(一) 简单工厂模式不简单
  • Java卡塔尔世界杯BOB体育官方APP登入(二) 工厂方法模式
  • Java卡塔尔世界杯BOB体育官方APP登入(三) 抽象工厂模式
  • Java卡塔尔世界杯BOB体育官方APP登入(四) 观察者模式
  • Java卡塔尔世界杯BOB体育官方APP登入(五) 组合模式
  • Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式
  • Java卡塔尔世界杯BOB体育官方APP登入(七) Spring AOP JDK动态代理 vs. cglib
  • Java卡塔尔世界杯BOB体育官方APP登入(八) 适配器模式
  • Java卡塔尔世界杯BOB体育官方APP登入(九) 桥接模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十) 你真的用对单例模式了吗?
  • Java卡塔尔世界杯BOB体育官方APP登入(十一) 享元模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十二) 策略模式
  • ]]>
    本文介绍了抽象工厂模式的概念,UML类图,优缺点,实现方式以及(未)遵循的OOP原则。同时结合J2EE中常用的DAO实例详解了抽象工厂模式的实现。
    技术世界 http://www.jasongj.com/design_pattern/factory_method/ 2016-04-02T00:00:01.000Z 2017-02-18T12:31:23.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/design_pattern/factory_method/

    工厂方法模式解决的问题

    上文《简单工厂模式不简单》中提到,简单工厂模式有如下缺点,而工厂方法模式可以解决这些问题

  • 由于工厂类集中了所有实例的创建逻辑,这就直接导致一旦这个工厂出了问题,所有的客户端都会受到牵连。
  • 由于简单工厂模式的产品是基于一个共同的抽象类或者接口,这样一来,产品的种类增加的时候,即有不同的产品接口或者抽象类的时候,工厂类就需要判断何时创建何种接口的产品,这就和创建何种种类的产品相互混淆在了一起,违背了单一职责原则,导致系统丧失灵活性和可维护性。
  • 简单工厂模式违背了“开放-关闭原则”,因为当我们新增加一个产品的时候必须修改工厂类,相应的工厂类就需要重新编译一遍。
  • 简单工厂模式由于使用了静态工厂方法,造成工厂角色无法形成基于继承的等级结构。
  • 工厂方法模式

    工厂方法模式介绍

    工厂方法模式(Factory Method Pattern)又称为工厂模式,也叫多态工厂模式或者虚拟构造器模式。在工厂方法模式中,工厂父类定义创建产品对象的公共接口,具体的工厂子类负责创建具体的产品对象。每一个工厂子类负责创建一种具体产品。

    工厂方法模式类图

    工厂模式类图如下 (点击可查看大图)
    Factory Method Pattern Class Diagram

    工厂方法模式角色划分

  • 抽象产品(或者产品接口),如上图中IUserDao
  • 具体产品,如上图中的MySQLUserDao,PostgreSQLUserDao和OracleUserDao
  • 抽象工厂(或者工厂接口),如IFactory
  • 具体工厂,如MySQLFactory,PostgreSQLFactory和OracleFactory
  • 工厂方法模式使用方式

    如简单工厂模式直接使用静态工厂方法创建产品对象不同,在工厂方法,客户端通过实例化具体的工厂类,并调用其创建实例接口创建具体产品类的实例。根据依赖倒置原则,具体工厂类的实例由工厂接口引用(客户端依赖于抽象工厂而非具体工厂),具体产品的实例由产品接口引用(客户端和工厂依赖于抽象产品而非具体产品)。具体调用代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    package 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下载

    工厂方法模式优点

  • 因为每个具体工厂类只负责创建产品,没有简单工厂中的逻辑判断,因此符合单一职责原则。
  • 与简单工厂模式不同,工厂方法并不使用静态工厂方法,可以形成基于继承的等级结构。
  • 新增一种产品时,只需要增加相应的具体产品类和相应的工厂子类即可,相比于简单工厂模式需要修改判断逻辑而言,工厂方法模式更符合开-闭原则。
  • 工厂方法模式缺点

  • 添加新产品时,除了增加新产品类外,还要提供与之对应的具体工厂类,系统类的个数将成对增加,在一定程度上增加了系统的复杂度,有更多的类需要编译和运行,会给系统带来一些额外的开销。
  • 虽然保证了工厂方法内的对修改关闭,但对于使用工厂方法的类,如果要换用另外一种产品,仍然需要修改实例化的具体工厂。
  • 一个具体工厂只能创建一种具体产品
  • 简单工厂模式与OOP原则

    已遵循的原则

  • 依赖倒置原则
  • 迪米特法则
  • 里氏替换原则
  • 接口隔离原则
  • 单一职责原则(每个工厂只负责创建自己的具体产品,没有简单工厂中的逻辑判断)
  • 开闭原则(增加新的产品,不像简单工厂那样需要修改已有的工厂,而只需增加相应的具体工厂类)
  • 未遵循的原则

  • 开闭原则(虽然工厂对修改关闭了,但更换产品时,客户代码还是需要修改)
  • Java卡塔尔世界杯BOB体育官方APP登入系列

  • Java卡塔尔世界杯BOB体育官方APP登入(一) 简单工厂模式不简单
  • Java卡塔尔世界杯BOB体育官方APP登入(二) 工厂方法模式
  • Java卡塔尔世界杯BOB体育官方APP登入(三) 抽象工厂模式
  • Java卡塔尔世界杯BOB体育官方APP登入(四) 观察者模式
  • Java卡塔尔世界杯BOB体育官方APP登入(五) 组合模式
  • Java卡塔尔世界杯BOB体育官方APP登入(六) 代理模式 VS. 装饰模式
  • Java卡塔尔世界杯BOB体育官方APP登入(七) Spring AOP JDK动态代理 vs. cglib
  • Java卡塔尔世界杯BOB体育官方APP登入(八) 适配器模式
  • Java卡塔尔世界杯BOB体育官方APP登入(九) 桥接模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十) 你真的用对单例模式了吗?
  • Java卡塔尔世界杯BOB体育官方APP登入(十一) 享元模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十二) 策略模式
  • Java卡塔尔世界杯BOB体育官方APP登入(十三) 别人再问你卡塔尔世界杯BOB体育官方APP登入,叫他看这篇文章
  • ]]>
    本文介绍了工厂方法模式的概念,优缺点,实现方式,UML类图,并介绍了工厂方法(未)遵循的OOP原则
    技术世界 http://www.jasongj.com/sql/cte/ 2016-03-18T12:49:04.000Z 2017-03-15T13:17:39.000Z

    原创文章,转载请务必将下面这段话置于文章开头处(保留超链接)。
    本文转发自卡塔尔世界杯BOB体育官方APP登入-卡塔尔世界杯bobAPP手机端下载在线原文链接 http://www.jasongj.com/sql/cte/

    CTE or WITH

    WITH语句通常被称为通用表表达式(Common Table Expressions)或者CTEs。

    WITH语句作为一个辅助语句依附于主语句,WITH语句和主语句都可以是SELECTINSERTUPDATEDELETE中的任何一种语句。

    例讲CTE

    WITH语句最基本的功能是把复杂查询语句拆分成多个简单的部分,如下例所示

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    WITH 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中使用数据修改语句

    文章开头处提到,WITH中可以不仅可以使用SELECT语句,同时还能使用DELETEUPDATEINSERT语句。因此,可以使用WITH,在一条SQL语句中进行不同的操作,如下例所示。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    WITH 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
    8
    WITH d AS (
    DELETE FROM foo
    ),
    u as (
    UPDATE foo SET a = 1
    WHERE b = 2
    )
    DELETE FROM bar;

    WITH使用注意事项

    1. WITH中的数据修改语句会被执行一次,并且肯定会完全执行,无论主语句是否读取或者是否读取所有其输出。而WITH中的SELECT语句则只输出主语句中所需要记录数。
    2. WITH中使用多个子句时,这些子句和主语句会并行执行,所以当存在多个修改子语句修改相同的记录时,它们的结果不可预测。
    3. 所有的子句所能“看”到的数据集是一样的,所以它们看不到其它语句对目标数据集的影响。这也缓解了多子句执行顺序的不可预测性造成的影响。
    4. 如果在一条SQL语句中,更新同一记录多次,只有其中一条会生效,并且很难预测哪一个会生效。
    5. 如果在一条SQL语句中,同时更新和删除某条记录,则只有更新会生效。
    6. 目前,任何一个被数据修改CTE的表,不允许使用条件规则,和ALSO规则以及INSTEAD规则。

    WITH RECURSIVE

    WITH语句还可以通过增加RECURSIVE修饰符来引入它自己,从而实现递归

    WITH RECURSIVE实例

    WITH RECURSIVE一般用于处理逻辑上层次化或树状结构的数据,典型的使用场景是寻找直接及间接子结点。

    定义下面这样的表,存储每个区域(省、市、区)的id,名字及上级区域的id

    1
    2
    3
    4
    5
    6
    CREATE 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
    19
    WITH 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 执行过程

    从上面的例子可以看出,WITH RECURSIVE语句包含了两个部分

  • non-recursive term(非递归部分),即上例中的union all前面部分
  • recursive term(递归部分),即上例中union all后面部分
  • 执行步骤如下

    1. 执行non-recursive term。(如果使用的是union而非union all,则需对结果去重)其结果作为recursive term中对result的引用,同时将这部分结果放入临时的working table中
    2. 重复执行如下步骤,直到working table为空:用working table的内容替换递归的自引用,执行recursive term,(如果使用union而非union all,去除重复数据),并用该结果(如果使用union而非union all,则是去重后的结果)替换working table

    以上面的query为例,来看看具体过程
    1.执行

    1
    2
    3
    4
    5
    SELECT
    id,
    name
    FROM chinamap
    WHERE id = 11

    结果集和working table为

    1
    11 | 湖北

    2.执行

    1
    2
    3
    4
    5
    6
    SELECT
    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
    8
    110 | 湖北省 > 武汉市
    120 | 湖北省 > 孝感市
    130 | 湖北省 > 宜昌市
    140 | 湖北省 > 随州市
    150 | 湖北省 > 仙桃市
    160 | 湖北省 > 荆门市
    170 | 湖北省 > 枝江市
    180 | 湖北省 > 神农架市

    3.再次执行recursive query,结果集和working table为

    1
    2
    3
    4
    5
    6
    7
    111 | 湖北省 > 武汉市 > 武昌区
    112 | 湖北省 > 武汉市 > 下城区
    113 | 湖北省 > 武汉市 > 江岸区
    114 | 湖北省 > 武汉市 > 江汉区
    115 | 湖北省 > 武汉市 > 汉阳区
    116 | 湖北省 > 武汉市 > 洪山区
    117 | 湖北省 > 武汉市 > 青山区

    4.继续执行recursive query,结果集和working table为空
    5.结束递归,将前三个步骤的结果集合并,即得到最终的WITH RECURSIVE的结果集

    严格来讲,这个过程实现上是一个迭代的过程而非递归,不过RECURSIVE这个关键词是SQL标准委员会定立的,所以PostgreSQL也延用了RECURSIVE这一关键词。

    WITH RECURSIVE 防止死循环

    从上一节中可以看到,决定是否继续迭代的working table是否为空,如果它永不为空,则该CTE将陷入无限循环中。
    对于本身并不会形成循环引用的数据集,无段作特别处理。而对于本身可能形成循环引用的数据集,则须通过SQL处理。

    一种方式是使用UNION而非UNION ALL,从而每次recursive term的计算结果都会将已经存在的数据清除后再存入working table,使得working table最终会为空,从而结束迭代。

    然而,这种方法并不总是有效的,因为有时可能需要这些重复数据。同时UNION只能去除那些所有字段都完全一样的记录,而很有可能特定字段集相同的记录即应该被删除。此时可以通过数组(单字段)或者ROW(多字段)记录已经访问过的记录,从而实现去重的目的。

    WITH RECURSIVE 求最短路径

    定义无向有环图如下图所示
    Non-directional cycle graph

    定义如下表并存入每条边的权重

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    CREATE 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);

    计算思路如下:

  • 因为是无向图,所以首先要将各条边的id和neighbor交换一次以方便后续计算。
  • 利用WITH RECURSIVE算出所有可能的路径并计算其总权重。
  • 因为该图有环,为避免无限循环,同时为了计算路径,将经过的结点存于数据中,当下一个结点已经在数据中时,说明该结点已被计算。
  • 最终可算出所有可能的路径及其总权重
  • 实现如下

    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