<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Hexo</title>
  
  
  <link href="/atom.xml" rel="self"/>
  
  <link href="http://crazycarry.github.io/"/>
  <updated>2018-04-05T16:07:33.491Z</updated>
  <id>http://crazycarry.github.io/</id>
  
  <author>
    <name>Crazy Carry</name>
    
  </author>
  
  <generator uri="http://hexo.io/">Hexo</generator>
  
  <entry>
    <title>从实际案例聊聊Java应用的GC优化</title>
    <link href="http://crazycarry.github.io/2018/04/06/java-gc-performance/"/>
    <id>http://crazycarry.github.io/2018/04/06/java-gc-performance/</id>
    <published>2018-04-05T16:01:44.000Z</published>
    <updated>2018-04-05T16:07:33.491Z</updated>
    
    <content type="html"><![CDATA[<p>当Java程序性能达不到既定目标，且其他优化手段都已经穷尽时，通常需要调整垃圾回收器来进一步提高性能，称为GC优化。但GC算法复杂，影响GC性能的参数众多，且参数调整又依赖于应用各自的特点，这些因素很大程度上增加了GC优化的难度。即便如此，GC调优也不是无章可循，仍然有一些通用的思考方法。本篇会介绍这些通用的GC优化策略和相关实践案例，主要包括如下内容：</p><blockquote><p>优化前准备: 简单回顾JVM相关知识、介绍GC优化的一些通用策略。<br>优化方法: 介绍调优的一般流程：明确优化目标→优化→跟踪优化结果。<br>优化案例: 简述笔者所在团队遇到的GC问题以及优化方案。<br><a id="more"></a></p></blockquote><h1 id="一、优化前的准备"><a href="#一、优化前的准备" class="headerlink" title="一、优化前的准备"></a>一、优化前的准备</h1><h2 id="GC优化需知"><a href="#GC优化需知" class="headerlink" title="GC优化需知"></a><a href="https://crazycarry.github.io/2018/02/06/java-performace-by-example/#GC%E4%BC%98%E5%8C%96%E9%9C%80%E7%9F%A5" title="GC优化需知"></a>GC优化需知</h2><p>为了更好地理解本篇所介绍的内容，你需要了解如下内容。</p><ol><li><p>GC相关基础知识，包括但不限于：<br> a) GC工作原理。<br> b) 理解新生代、老年代、晋升等术语含义。<br> c) 可以看懂GC日志。</p></li><li><p>GC优化不能解决一切性能问题，它是最后的调优手段。</p></li></ol><p>如果对第一点中提及的知识点不是很熟悉，可以先阅读小结-JVM基础回顾；如果已经很熟悉，可以跳过该节直接往下阅读。</p><h2 id="JVM基础回顾"><a href="#JVM基础回顾" class="headerlink" title="JVM基础回顾"></a>JVM基础回顾</h2><h3 id="JVM内存结构"><a href="#JVM内存结构" class="headerlink" title="JVM内存结构"></a>JVM内存结构</h3><p>简单介绍一下JVM内存结构和常见的垃圾回收器。</p><p>当代主流虚拟机（Hotspot VM）的垃圾回收都采用“分代回收”的算法。“分代回收”是基于这样一个事实：对象的生命周期不同，所以针对不同生命周期的对象可以采取不同的回收方式，以便提高回收效率。</p><p>Hotspot VM将内存划分为不同的物理区，就是“分代”思想的体现。如图所示，JVM内存主要由新生代、老年代、永久代构成。</p><p><a href="https://tech.meituan.com/img/JVM-optimize/JVMpart.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/JVM-optimize/JVMpart.png" alt="GC影响"></a></p><p>① 新生代（Young Generation）：大多数对象在新生代中被创建，其中很多对象的生命周期很短。每次新生代的垃圾回收（又称Minor GC）后只有少量对象存活，所以选用复制算法，只需要少量的复制成本就可以完成回收。</p><p>新生代内又分三个区：一个Eden区，两个Survivor区（一般而言），大部分对象在Eden区中生成。当Eden区满时，还存活的对象将被复制到两个Survivor区（中的一个）。当这个Survivor区满时，此区的存活且不满足“晋升”条件的对象将被复制到另外一个Survivor区。对象每经历一次Minor GC，年龄加1，达到“晋升年龄阈值”后，被放到老年代，这个过程也称为“晋升”。显然，“晋升年龄阈值”的大小直接影响着对象在新生代中的停留时间，在Serial和ParNew GC两种回收器中，“晋升年龄阈值”通过参数MaxTenuringThreshold设定，默认值为15。</p><p>② 老年代（Old Generation）：在新生代中经历了N次垃圾回收后仍然存活的对象，就会被放到年老代，该区域中对象存活率高。老年代的垃圾回收（又称Major GC）通常使用“标记-清理”或“标记-整理”算法。整堆包括新生代和老年代的垃圾回收称为Full GC（HotSpot VM里，除了CMS之外，其它能收集老年代的GC都会同时收集整个GC堆，包括新生代）。</p><p>③ 永久代（Perm Generation）：主要存放元数据，例如Class、Method的元信息，与垃圾回收要回收的Java对象关系不大。相对于新生代和年老代来说，该区域的划分对垃圾回收影响比较小。</p><h3 id="常见垃圾回收器"><a href="#常见垃圾回收器" class="headerlink" title="常见垃圾回收器"></a>常见垃圾回收器</h3><p>不同的垃圾回收器，适用于不同的场景。常用的垃圾回收器：</p><ul><li>串行（Serial）回收器是单线程的一个回收器，简单、易实现、效率高。</li><li>并行（ParNew）回收器是Serial的多线程版，可以充分的利用CPU资源，减少回收的时间。</li><li>吞吐量优先（Parallel Scavenge）回收器，侧重于吞吐量的控制。</li><li>并发标记清除（CMS，Concurrent Mark Sweep）回收器是一种以获取最短回收停顿时间为目标的回收器，该回收器是基于“标记-清除”算法实现的。</li></ul><h3 id="GC日志"><a href="#GC日志" class="headerlink" title="GC日志"></a>GC日志</h3><p>每一种回收器的日志格式都是由其自身的实现决定的，换而言之，每种回收器的日志格式都可以不一样。但虚拟机设计者为了方便用户阅读，将各个回收器的日志都维持一定的共性。<a href="http://blog.csdn.net/wanglha/article/details/48713217" target="_blank" rel="noopener">JavaGC日志</a> 中简单介绍了这些共性。</p><h2 id="参数基本策略"><a href="#参数基本策略" class="headerlink" title="参数基本策略"></a>参数基本策略</h2><p>各分区的大小对GC的性能影响很大。如何将各分区调整到合适的大小，分析活跃数据的大小是很好的切入点。</p><p><strong>活跃数据的大小</strong>是指，应用程序稳定运行时长期存活对象在堆中占用的空间大小，也就是Full GC后堆中老年代占用空间的大小。可以通过GC日志中Full GC之后老年代数据大小得出，比较准确的方法是在程序稳定后，多次获取GC数据，通过取平均值的方式计算活跃数据的大小。活跃数据和各分区之间的比例关系如下（见参考文献1）：</p><table><thead><tr><th style="text-align:left">空间</th><th style="text-align:left">倍数</th></tr></thead><tbody><tr><td style="text-align:left">总大小</td><td style="text-align:left"><strong>3-4</strong> 倍活跃数据的大小</td></tr><tr><td style="text-align:left">新生代</td><td style="text-align:left"><strong>1-1.5</strong> 活跃数据的大小</td></tr><tr><td style="text-align:left">老年代</td><td style="text-align:left"><strong>2-3</strong> 倍活跃数据的大小</td></tr><tr><td style="text-align:left">永久代</td><td style="text-align:left"><strong>1.2-1.5</strong> 倍Full GC后的永久代空间占用</td></tr></tbody></table><p>例如，根据GC日志获得老年代的活跃数据大小为300M，那么各分区大小可以设为：</p><blockquote><p>总堆：1200MB = 300MB × 4<em><br>新生代：450MB = 300MB × 1.5</em><br>老年代： 750MB = 1200MB - 450MB*</p></blockquote><p>这部分设置仅仅是堆大小的初始值，后面的优化中，可能会调整这些值，具体情况取决于应用程序的特性和需求。</p><h1 id="二、优化步骤"><a href="#二、优化步骤" class="headerlink" title="二、优化步骤"></a>二、优化步骤</h1><p>GC优化一般步骤可以概括为：确定目标、优化参数、验收结果。</p><h2 id="确定目标"><a href="#确定目标" class="headerlink" title="确定目标"></a>确定目标</h2><p>明确应用程序的系统需求是性能优化的基础，系统的需求是指应用程序运行时某方面的要求，譬如：</p><ul><li>高可用，可用性达到几个9。</li><li>低延迟，请求必须多少毫秒内完成响应。</li><li>高吞吐，每秒完成多少次事务。</li></ul><p>明确系统需求之所以重要，是因为上述性能指标间可能冲突。比如通常情况下，缩小延迟的代价是降低吞吐量或者消耗更多的内存或者两者同时发生。</p><p>由于笔者所在团队主要关注高可用和低延迟两项指标，所以接下来分析，如何量化GC时间和频率对于响应时间和可用性的影响。通过这个量化指标，可以计算出当前GC情况对服务的影响，也能评估出GC优化后对响应时间的收益，这两点对于低延迟服务很重要。</p><p>举例：假设单位时间T内发生一次持续25ms的GC，接口平均响应时间为50ms，且请求均匀到达，根据下图所示：</p><p><a href="https://tech.meituan.com/img/JVM-optimize/GCgs.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/JVM-optimize/GCgs.png" alt="GC影响"></a></p><p>那么有(50ms+25ms)/T比例的请求会受GC影响，其中GC前的50ms内到达的请求都会增加25ms，GC期间的25ms内到达的请求，会增加0-25ms不等，如果时间T内发生N次GC，<strong>受GC影响请求占比=(接口响应时间+GC时间)×N/T</strong> 。可见无论降低单次GC时间还是降低GC次数N都可以有效减少GC对响应时间的影响。</p><h3 id="优化"><a href="#优化" class="headerlink" title="优化"></a>优化</h3><p>通过收集GC信息，结合系统需求，确定优化方案，例如选用合适的GC回收器、重新设置内存比例、调整JVM参数等。</p><p>进行调整后，将不同的优化方案分别应用到多台机器上，然后比较这些机器上GC的性能差异，有针对性的做出选择，再通过不断的试验和观察，找到最合适的参数。</p><h2 id="验收优化结果"><a href="#验收优化结果" class="headerlink" title="验收优化结果"></a>验收优化结果</h2><p>将修改应用到所有服务器，判断优化结果是否符合预期，总结相关经验。</p><p>接下来，我们通过三个案例来实践以上的优化流程和基本原则（本文中三个案例使用的垃圾回收器均为ParNew+CMS，CMS失败时Serial Old替补)。</p><h1 id="三、GC优化案例"><a href="#三、GC优化案例" class="headerlink" title="三、GC优化案例"></a>三、GC优化案例</h1><h2 id="案例一-Major-GC和Minor-GC频繁"><a href="#案例一-Major-GC和Minor-GC频繁" class="headerlink" title="案例一 Major GC和Minor GC频繁"></a>案例一 Major GC和Minor GC频繁</h2><h3 id="确定目标-1"><a href="#确定目标-1" class="headerlink" title="确定目标"></a>确定目标</h3><p>服务情况：Minor GC每分钟100次 ，Major GC每4分钟一次，单次Minor GC耗时25ms，单次Major GC耗时200ms，接口响应时间50ms。</p><p>由于这个服务要求低延时高可用，结合上文中提到的GC对服务响应时间的影响，计算可知由于Minor GC的发生，12.5%的请求响应时间会增加，其中8.3%的请求响应时间会增加25ms，可见当前GC情况对响应时间影响较大。</p><p><em>（50ms+25ms）× 100次/60000ms = 12.5%，50ms × 100次/60000ms = 8.3%</em> 。</p><p>优化目标：降低TP99、TP90时间。</p><h3 id="优化-1"><a href="#优化-1" class="headerlink" title="优化"></a>优化</h3><p>首先优化Minor GC频繁问题。通常情况下，由于新生代空间较小，Eden区很快被填满，就会导致频繁Minor GC，因此可以通过增大新生代空间来降低Minor GC的频率。例如在相同的内存分配率的前提下，新生代中的Eden区增加一倍，Minor GC的次数就会减少一半。</p><p>这时很多人有这样的疑问，扩容Eden区虽然可以减少Minor GC的次数，但会增加单次Minor GC时间么？根据上面公式，如果单次Minor GC时间也增加，很难保证最后的优化效果。我们结合下面情况来分析，单次Minor GC时间主要受哪些因素影响？是否和新生代大小存在线性关系？<br>首先，单次Minor GC时间由以下两部分组成：T1（扫描新生代）和 T2（复制存活对象到Survivor区）如下图。（注：这里为了简化问题，我们认为T1只扫描新生代判断对象是否存活的时间，其实该阶段还需要扫描部分老年代，后面案例中有详细描述。）</p><p><a href="https://tech.meituan.com/img/JVM-optimize/YGCtime.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/JVM-optimize/YGCtime.png" alt="GC影响"></a></p><ul><li><p>扩容前：新生代容量为R ，假设对象A的存活时间为750ms，Minor GC间隔500ms，那么本次Minor GC时间= T1（扫描新生代R）+T2（复制对象A到S）。</p></li><li><p>扩容后：新生代容量为2R ，对象A的生命周期为750ms，那么Minor GC间隔增加为1000ms，此时Minor GC对象A已不再存活，不需要把它复制到Survivor区，那么本次GC时间 = 2 × T1（扫描新生代R），没有T2复制时间。</p></li></ul><p>可见，扩容后，Minor GC时增加了T1（扫描时间），但省去T2（复制对象）的时间，更重要的是对于虚拟机来说，复制对象的成本要远高于扫描成本，所以，单次<strong>Minor GC时间更多取决于GC后存活对象的数量，而非Eden区的大小</strong>。因此如果堆中短期对象很多，那么扩容新生代，单次Minor GC时间不会显著增加。下面需要确认下服务中对象的生命周期分布情况：</p><p><a href="https://tech.meituan.com/img/JVM-optimize/log1.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/JVM-optimize/log1.png" alt="GC影响"></a></p><p>通过上图GC日志中两处红色框标记内容可知：</p><ol><li>new threshold = 2（动态年龄判断，对象的晋升年龄阈值为2），对象仅经历2次Minor GC后就晋升到老年代，这样老年代会迅速被填满，直接导致了频繁的Major GC。</li><li>Major GC后老年代使用空间为300M+，意味着此时绝大多数(86% = 2G/2.3G)的对象已经不再存活，也就是说生命周期长的对象占比很小。</li></ol><p>由此可见，服务中存在大量短期临时对象，扩容新生代空间后，Minor GC频率降低，对象在新生代得到充分回收，只有生命周期长的对象才进入老年代。这样老年代增速变慢，Major GC频率自然也会降低。</p><h3 id="优化结果"><a href="#优化结果" class="headerlink" title="优化结果"></a>优化结果</h3><p>通过扩容新生代为为原来的三倍，单次Minor GC时间增加小于5ms，频率下降了60%，服务响应时间TP90，TP99都下降了10ms+，服务可用性得到提升。</p><p>调整前：<a href="https://tech.meituan.com/img/JVM-optimize/before.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/JVM-optimize/before.png" alt="GC影响"></a></p><p>调整后：<a href="https://tech.meituan.com/img/JVM-optimize/after.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/JVM-optimize/after.png" alt="GC影响"></a></p><h3 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h3><p>如何选择各分区大小应该依赖应用程序中<strong>对象生命周期的分布情况：如果应用存在大量的短期对象，应该选择较大的年轻代；如果存在相对较多的持久对象，老年代应该适当增大。</strong></p><h3 id="更多思考"><a href="#更多思考" class="headerlink" title="更多思考"></a>更多思考</h3><p>关于上文中提到晋升年龄阈值为2，很多同学有疑问，为什么设置了MaxTenuringThreshold=15，对象仍然仅经历2次Minor GC，就晋升到老年代？这里涉及到“动态年龄计算”的概念。</p><p><strong>动态年龄计算</strong>：Hotspot遍历所有对象时，按照年龄从小到大对其所占用的大小进行累积，当累积的某个年龄大小超过了survivor区的一半时，取这个年龄和MaxTenuringThreshold中更小的一个值，作为新的晋升年龄阈值。在本案例中，调优前：Survivor区 = 64M，desired survivor = 32M，此时Survivor区中age500ms时，新生代使用率都在75%以上。这样降低Remark阶段耗时问题转换成如何减少新生代对象数量。</p><p>新生代中对象的特点是“朝生夕灭”，这样如果Remark前执行一次Minor GC，大部分对象就会被回收。CMS就采用了这样的方式，在Remark前增加了一个可中断的并发预清理（CMS-concurrent-abortable-preclean），该阶段主要工作仍然是并发标记对象是否存活，只是这个过程可被中断。此阶段在Eden区使用超过2M时启动，当然2M是默认的阈值，可以通过参数修改。如果此阶段执行时等到了Minor GC，那么上述灰色对象将被回收，Reamark阶段需要扫描的对象就少了。</p><p>除此之外CMS为了避免这个阶段没有等到Minor GC而陷入无限等待，提供了参数CMSMaxAbortablePrecleanTime ，默认为5s，含义是如果可中断的预清理执行超过5s，不管发没发生Minor GC，都会中止此阶段，进入Remark。<br>根据GC日志红色标记2处显示，可中断的并发预清理执行了5.35s，超过了设置的5s被中断，期间没有等到Minor GC ，所以Remark时新生代中仍然有很多对象。</p><p>对于这种情况，CMS提供CMSScavengeBeforeRemark参数，用来保证Remark前强制进行一次Minor GC。</p><h3 id="优化结果-1"><a href="#优化结果-1" class="headerlink" title="优化结果"></a>优化结果</h3><p>经过增加CMSScavengeBeforeRemark参数，单次执行时间&gt;200ms的GC停顿消失，从监控上观察，GCtime和业务波动保持一致，不再有明显的毛刺。<br><a href="https://tech.meituan.com/img/JVM-optimize/2after.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/JVM-optimize/2after.png" alt="GC影响"></a></p><h3 id="小结-1"><a href="#小结-1" class="headerlink" title="小结"></a>小结</h3><p>通过案例分析了解到，由于跨代引用的存在，CMS在Remark阶段必须扫描整个堆，同时为了避免扫描时新生代有很多对象，增加了可中断的预清理阶段用来等待Minor GC的发生。只是该阶段有时间限制，如果超时等不到Minor GC，Remark时新生代仍然有很多对象，我们的调优策略是，通过参数强制Remark前进行一次Minor GC，从而降低Remark阶段的时间。</p><h3 id="更多思考-1"><a href="#更多思考-1" class="headerlink" title="更多思考"></a>更多思考</h3><p>案例中只涉及老年代GC，其实新生代GC存在同样的问题，即老年代可能持有新生代对象引用，所以Minor GC时也必须扫描老年代。</p><p><strong>JVM是如何避免Minor GC时扫描全堆的？</strong><br>经过统计信息显示，老年代持有新生代对象引用的情况不足1%，根据这一特性JVM引入了卡表（card table）来实现这一目的。如下图所示：</p><p><a href="https://tech.meituan.com/img/JVM-optimize/YGCcross.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/JVM-optimize/YGCcross.png" alt="GC影响"></a></p><p><strong>卡表</strong>的具体策略是将老年代的空间分成大小为512B的若干张卡（card）。卡表本身是单字节数组，数组中的每个元素对应着一张卡，当发生老年代引用新生代时，虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示，卡表3被标记为脏（卡表还有另外的作用，标识并发标记阶段哪些块被修改过），之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式，避免了全堆扫描。</p><p>总结来说，CMS的设计聚焦在获取最短的时延，为此它“不遗余力”地做了很多工作，包括尽量让应用程序和GC线程并发、增加可中断的并发预清理阶段、引入卡表等，虽然这些操作牺牲了一定吞吐量但获得了更短的回收停顿时间。</p><h2 id="案例三-发生Stop-The-World的GC"><a href="#案例三-发生Stop-The-World的GC" class="headerlink" title="案例三 发生Stop-The-World的GC"></a>案例三 发生Stop-The-World的GC</h2><h3 id="确定目标-2"><a href="#确定目标-2" class="headerlink" title="确定目标"></a>确定目标</h3><p>GC日志如下图（在GC日志中，Full GC是用来说明这次垃圾回收的停顿类型，代表STW类型的GC，并不特指老年代GC），根据GC日志可知本次Full GC耗时1.23s。这个在线服务同样要求低时延高可用。本次优化目标是降低单次STW回收停顿时间，提高可用性。</p><p><a href="https://tech.meituan.com/img/JVM-optimize/PermGC.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/JVM-optimize/PermGC.png" alt="GC影响"></a></p><h3 id="优化-2"><a href="#优化-2" class="headerlink" title="优化"></a>优化</h3><p>首先，什么时候可能会触发STW的Full GC呢？</p><ol><li>Perm空间不足；</li><li>CMS GC时出现promotion failed和concurrent mode failure（concurrent mode failure发生的原因一般是CMS正在进行，但是由于老年代空间不足，需要尽快回收老年代里面的不再被使用的对象，这时停止所有的线程，同时终止CMS，直接进行Serial Old GC）；</li><li>统计得到的Young GC晋升到老年代的平均大小大于老年代的剩余空间；</li><li>主动触发Full GC（执行jmap -histo:live [pid]）来避免碎片问题。</li></ol><p>然后，我们来逐一分析一下：</p><ul><li>排除原因2：如果是原因2中两种情况，日志中会有特殊标识，目前没有。</li><li>排除原因3：根据GC日志，当时老年代使用量仅为20%，也不存在大于2G的大对象产生。</li><li>排除原因4：因为当时没有相关命令执行。</li><li>锁定原因1：根据日志发现Full GC后，Perm区变大了，推断是由于永久代空间不足容量扩展导致的。</li></ul><p>找到原因后解决方法有两种：</p><ol><li>通过把-XX:PermSize参数和-XX:MaxPermSize设置成一样，强制虚拟机在启动的时候就把永久代的容量固定下来，避免运行时自动扩容。</li><li>CMS默认情况下不会回收Perm区，通过参数CMSPermGenSweepingEnabled、CMSClassUnloadingEnabled ，可以让CMS在Perm区容量不足时对其回收。</li></ol><p>由于该服务没有生成大量动态类，回收Perm区收益不大，所以我们采用方案1，启动时将Perm区大小固定，避免进行动态扩容。</p><h3 id="优化结果-2"><a href="#优化结果-2" class="headerlink" title="优化结果"></a>优化结果</h3><p>调整参数后，服务不再有Perm区扩容导致的STW GC发生。</p><h3 id="小结-2"><a href="#小结-2" class="headerlink" title="小结"></a>小结</h3><p>对于性能要求很高的服务，建议将MaxPermSize和MinPermSize设置成一致（JDK8开始，Perm区完全消失，转而使用元空间。而元空间是直接存在内存中，不在JVM中），Xms和Xmx也设置为相同，这样可以减少内存自动扩容和收缩带来的性能损失。虚拟机启动的时候就会把参数中所设定的内存全部化为私有，即使扩容前有一部分内存不会被用户代码用到，这部分内存在虚拟机中被标识为虚拟内存，也不会交给其他进程使用。</p><h1 id="四、总结"><a href="#四、总结" class="headerlink" title="四、总结"></a>四、总结</h1><p>结合上述GC优化案例做个总结：</p><ol><li>首先再次声明，在进行GC优化之前，需要确认项目的架构和代码等已经没有优化空间。我们不能指望一个系统架构有缺陷或者代码层次优化没有穷尽的应用，通过GC优化令其性能达到一个质的飞跃。</li><li>其次，通过上述分析，可以看出虚拟机内部已有很多优化来保证应用的稳定运行，所以不要为了调优而调优，不当的调优可能适得其反。</li><li>最后，GC优化是一个系统而复杂的工作，没有万能的调优策略可以满足所有的性能指标。GC优化必须建立在我们深入理解各种垃圾回收器的基础上，才能有事半功倍的效果。</li></ol><p>本文中案例均来北京业务安全中心（也称风控）对接服务的实践经验。同时感谢风控的小伙伴们，是他们专业负责的审阅，才让这篇文章更加完善。对于本文中涉及到的内容，欢迎大家指正和补充。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;当Java程序性能达不到既定目标，且其他优化手段都已经穷尽时，通常需要调整垃圾回收器来进一步提高性能，称为GC优化。但GC算法复杂，影响GC性能的参数众多，且参数调整又依赖于应用各自的特点，这些因素很大程度上增加了GC优化的难度。即便如此，GC调优也不是无章可循，仍然有一些通用的思考方法。本篇会介绍这些通用的GC优化策略和相关实践案例，主要包括如下内容：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;优化前准备: 简单回顾JVM相关知识、介绍GC优化的一些通用策略。&lt;br&gt;优化方法: 介绍调优的一般流程：明确优化目标→优化→跟踪优化结果。&lt;br&gt;优化案例: 简述笔者所在团队遇到的GC问题以及优化方案。&lt;br&gt;
    
    </summary>
    
      <category term="java" scheme="http://crazycarry.github.io/categories/java/"/>
    
    
      <category term="jvm" scheme="http://crazycarry.github.io/tags/jvm/"/>
    
      <category term="java 进阶" scheme="http://crazycarry.github.io/tags/java-%E8%BF%9B%E9%98%B6/"/>
    
      <category term="gc 优化" scheme="http://crazycarry.github.io/tags/gc-%E4%BC%98%E5%8C%96/"/>
    
  </entry>
  
  <entry>
    <title>深入分析CAS</title>
    <link href="http://crazycarry.github.io/2018/04/05/java-cas/"/>
    <id>http://crazycarry.github.io/2018/04/05/java-cas/</id>
    <published>2018-04-05T15:51:55.000Z</published>
    <updated>2018-04-05T15:59:56.446Z</updated>
    
    <content type="html"><![CDATA[<p>CAS，Compare And Swap，即比较并交换。Doug lea大神在同步组件中大量使用CAS技术鬼斧神工地实现了Java多线程的并发操作。整个AQS同步组件、Atomic原子类操作等等都是以CAS实现的，甚至ConcurrentHashMap在1.8的版本中也调整为了CAS+Synchronized。可以说CAS是整个JUC的基石。<br><a id="more"></a><br><a href="http://img.blog.csdn.net/20170407200302616?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvY2hlbnNzeQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20170407200302616?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvY2hlbnNzeQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast" alt="这里写图片描述"></a></p><h2 id="CAS分析"><a href="#CAS分析" class="headerlink" title="CAS分析"></a>CAS分析</h2><p>在CAS中有三个参数：内存值V、旧的预期值A、要更新的值B，当且仅当内存值V的值等于旧的预期值A时才会将内存值V的值修改为B，否则什么都不干。其伪代码如下：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span>(<span class="keyword">this</span>.value == A)&#123;</span><br><span class="line"> <span class="keyword">this</span>.value = B</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">true</span>;</span><br><span class="line">&#125;<span class="keyword">else</span>&#123;</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">false</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>JUC下的atomic类都是通过CAS来实现的，下面就以AtomicInteger为例来阐述CAS的实现。如下：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> Unsafe unsafe = Unsafe.getUnsafe();</span><br><span class="line"><span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> valueOffset;</span><br><span class="line"></span><br><span class="line"><span class="keyword">static</span> &#123;</span><br><span class="line"> <span class="keyword">try</span> &#123;</span><br><span class="line"> valueOffset = unsafe.objectFieldOffset</span><br><span class="line"> (AtomicInteger.class.getDeclaredField(<span class="string">"value"</span>));</span><br><span class="line"> &#125; <span class="keyword">catch</span> (Exception ex) &#123; <span class="keyword">throw</span> <span class="keyword">new</span> Error(ex); &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">private</span> <span class="keyword">volatile</span> <span class="keyword">int</span> value;</span><br></pre></td></tr></table></figure><p>Unsafe是CAS的核心类，Java无法直接访问底层操作系统，而是通过本地（native）方法来访问。不过尽管如此，JVM还是开了一个后门：Unsafe，它提供了硬件级别的原子操作。</p><p>valueOffset为变量值在内存中的偏移地址，unsafe就是通过偏移地址来得到数据的原值的。</p><p>value当前值，使用volatile修饰，保证多线程环境下看见的是同一个。</p><p>我们就以AtomicInteger的addAndGet()方法来做说明，先看源代码：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">final</span> <span class="keyword">int</span> <span class="title">addAndGet</span><span class="params">(<span class="keyword">int</span> delta)</span> </span>&#123;</span><br><span class="line"> <span class="keyword">return</span> unsafe.getAndAddInt(<span class="keyword">this</span>, valueOffset, delta) + delta;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">final</span> <span class="keyword">int</span> <span class="title">getAndAddInt</span><span class="params">(Object var1, <span class="keyword">long</span> var2, <span class="keyword">int</span> var4)</span> </span>&#123;</span><br><span class="line"> <span class="keyword">int</span> var5;</span><br><span class="line"> <span class="keyword">do</span> &#123;</span><br><span class="line"> var5 = <span class="keyword">this</span>.getIntVolatile(var1, var2);</span><br><span class="line"> &#125; <span class="keyword">while</span>(!<span class="keyword">this</span>.compareAndSwapInt(var1, var2, var5, var5 + var4));</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> var5;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>内部调用unsafe的getAndAddInt方法，在getAndAddInt方法中主要是看compareAndSwapInt方法：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);</span><br></pre></td></tr></table></figure><p>该方法为本地方法，有四个参数，分别代表：对象、对象的地址、预期值、修改值（有位伙伴告诉我他面试的时候就问到这四个变量是啥意思…+_+）。该方法的实现这里就不做详细介绍了，有兴趣的伙伴可以看看openjdk的源码。</p><p>CAS可以保证一次的读-改-写操作是原子操作，在单处理器上该操作容易实现，但是在多处理器上实现就有点儿复杂了。</p><p>CPU提供了两种方法来实现多处理器的原子操作：总线加锁或者缓存加锁。</p><p><em>总线加锁</em>：总线加锁就是就是使用处理器提供的一个LOCK#信号，当一个处理器在总线上输出此信号时，其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。但是这种处理方式显得有点儿霸道，不厚道，他把CPU和内存之间的通信锁住了，在锁定期间，其他处理器都不能其他内存地址的数据，其开销有点儿大。所以就有了缓存加锁。</p><p><strong>缓存加锁</strong>：其实针对于上面那种情况我们只需要保证在同一时刻对某个内存地址的操作是原子性的即可。缓存加锁就是缓存在内存区域的数据如果在加锁期间，当它执行锁操作写回内存时，处理器不在输出LOCK#信号，而是修改内部的内存地址，利用缓存一致性协议来保证原子性。缓存一致性机制可以保证同一个内存区域的数据仅能被一个处理器修改，也就是说当CPU1修改缓存行中的i时使用缓存锁定，那么CPU2就不能同时缓存了i的缓存行。</p><h2 id="CAS缺陷"><a href="#CAS缺陷" class="headerlink" title="CAS缺陷"></a>CAS缺陷</h2><p>CAS虽然高效地解决了原子操作，但是还是存在一些缺陷的，主要表现在三个方法：循环时间太长、只能保证一个共享变量原子操作、ABA问题。</p><p><strong>循环时间太长</strong></p><p>如果CAS一直不成功呢？这种情况绝对有可能发生，如果自旋CAS长时间地不成功，则会给CPU带来非常大的开销。在JUC中有些地方就限制了CAS自旋的次数，例如BlockingQueue的SynchronousQueue。</p><p><strong>只能保证一个共享变量原子操作</strong></p><p>看了CAS的实现就知道这只能针对一个共享变量，如果是多个共享变量就只能使用锁了，当然如果你有办法把多个变量整成一个变量，利用CAS也不错。例如读写锁中state的高地位</p><p><strong>ABA问题</strong></p><p>CAS需要检查操作值有没有发生改变，如果没有发生改变则更新。但是存在这样一种情况：如果一个值原来是A，变成了B，然后又变成了A，那么在CAS检查的时候会发现没有改变，但是实质上它已经发生了改变，这就是所谓的ABA问题。对于ABA问题其解决方案是加上版本号，即在每个变量都加上一个版本号，每次改变时加1，即A —&gt; B —&gt; A，变成1A —&gt; 2B —&gt; 3A。</p><p>用一个例子来阐述ABA问题所带来的影响。</p><p>有如下链表</p><p><a href="http://img.blog.csdn.net/20170407200342758?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvY2hlbnNzeQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20170407200342758?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvY2hlbnNzeQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast" alt="这里写图片描述"></a></p><p>假如我们想要把B替换为A，也就是compareAndSet(this,A,B)。线程1执行B替换A操作，线程2主要执行如下动作，A 、B出栈，然后C、A入栈，最终该链表如下：</p><p><a href="http://img.blog.csdn.net/20170407200435384?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvY2hlbnNzeQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20170407200435384?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvY2hlbnNzeQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast" alt="这里写图片描述"></a></p><p>完成后线程1发现仍然是A，那么compareAndSet(this,A,B)成功，但是这时会存在一个问题就是B.next = null,compareAndSet(this,A,B)后，会导致C丢失，改栈仅有一个B元素，平白无故把C给丢失了。</p><p>CAS的ABA隐患问题，解决方案则是版本号，Java提供了AtomicStampedReference来解决。AtomicStampedReference通过包装[E,Integer]的元组来对对象标记版本戳stamp，从而避免ABA问题。对于上面的案例应该线程1会失败。</p><p>AtomicStampedReference的compareAndSet()方法定义如下：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">boolean</span> <span class="title">compareAndSet</span><span class="params">(V   expectedReference,</span></span></span><br><span class="line"><span class="function"><span class="params"> V   newReference,</span></span></span><br><span class="line"><span class="function"><span class="params"> <span class="keyword">int</span> expectedStamp,</span></span></span><br><span class="line"><span class="function"><span class="params"> <span class="keyword">int</span> newStamp)</span> </span>&#123;</span><br><span class="line"> Pair current = pair;</span><br><span class="line"> <span class="keyword">return</span></span><br><span class="line"> expectedReference == current.reference &amp;&amp;</span><br><span class="line"> expectedStamp == current.stamp &amp;&amp;</span><br><span class="line"> ((newReference == current.reference &amp;&amp;</span><br><span class="line"> newStamp == current.stamp) ||</span><br><span class="line"> casPair(current, Pair.of(newReference, newStamp)));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>compareAndSet有四个参数，分别表示：预期引用、更新后的引用、预期标志、更新后的标志。源码部门很好理解预期的引用 == 当前引用，预期的标识 == 当前标识，如果更新后的引用和标志和当前的引用和标志相等则直接返回true，否则通过Pair生成一个新的pair对象与当前pair CAS替换。Pair为AtomicStampedReference的内部类，主要用于记录引用和版本戳信息（标识），定义如下：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="keyword">static</span> <span class="class"><span class="keyword">class</span> <span class="title">Pair</span> </span>&#123;</span><br><span class="line"> <span class="keyword">final</span> T reference;</span><br><span class="line"> <span class="keyword">final</span> <span class="keyword">int</span> stamp;</span><br><span class="line"> <span class="function"><span class="keyword">private</span> <span class="title">Pair</span><span class="params">(T reference, <span class="keyword">int</span> stamp)</span> </span>&#123;</span><br><span class="line"> <span class="keyword">this</span>.reference = reference;</span><br><span class="line"> <span class="keyword">this</span>.stamp = stamp;</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="function"><span class="keyword">static</span>  Pair <span class="title">of</span><span class="params">(T reference, <span class="keyword">int</span> stamp)</span> </span>&#123;</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> Pair(reference, stamp);</span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">private</span> <span class="keyword">volatile</span> Pair pair;</span><br></pre></td></tr></table></figure><p>Pair记录着对象的引用和版本戳，版本戳为int型，保持自增。同时Pair是一个不可变对象，其所有属性全部定义为final，对外提供一个of方法，该方法返回一个新建的Pari对象。pair对象定义为volatile，保证多线程环境下的可见性。在AtomicStampedReference中，大多方法都是通过调用Pair的of方法来产生一个新的Pair对象，然后赋值给变量pair。如set方法：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">set</span><span class="params">(V newReference, <span class="keyword">int</span> newStamp)</span> </span>&#123;</span><br><span class="line"> Pair current = pair;</span><br><span class="line"> <span class="keyword">if</span> (newReference != current.reference || newStamp != current.stamp)</span><br><span class="line"> <span class="keyword">this</span>.pair = Pair.of(newReference, newStamp);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>下面我们将通过一个例子可以可以看到AtomicStampedReference和AtomicInteger的区别。我们定义两个线程，线程1负责将100 —&gt; 110 —&gt; 100，线程2执行 100 —&gt;120，看两者之间的区别。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">Test</span> </span>&#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> AtomicInteger atomicInteger = <span class="keyword">new</span> AtomicInteger(<span class="number">100</span>);</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> AtomicStampedReference atomicStampedReference = <span class="keyword">new</span> AtomicStampedReference(<span class="number">100</span>,<span class="number">1</span>);</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title">main</span><span class="params">(String[] args)</span> <span class="keyword">throws</span> InterruptedException </span>&#123;</span><br><span class="line"></span><br><span class="line"> <span class="comment">//AtomicInteger</span></span><br><span class="line"> Thread at1 = <span class="keyword">new</span> Thread(<span class="keyword">new</span> Runnable() &#123;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">run</span><span class="params">()</span> </span>&#123;</span><br><span class="line"> atomicInteger.compareAndSet(<span class="number">100</span>,<span class="number">110</span>);</span><br><span class="line"> atomicInteger.compareAndSet(<span class="number">110</span>,<span class="number">100</span>);</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line"></span><br><span class="line"> Thread at2 = <span class="keyword">new</span> Thread(<span class="keyword">new</span> Runnable() &#123;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">run</span><span class="params">()</span> </span>&#123;</span><br><span class="line"> <span class="keyword">try</span> &#123;</span><br><span class="line"> TimeUnit.SECONDS.sleep(<span class="number">2</span>);      <span class="comment">// at1,执行完</span></span><br><span class="line"> &#125; <span class="keyword">catch</span> (InterruptedException e) &#123;</span><br><span class="line"> e.printStackTrace();</span><br><span class="line"> &#125;</span><br><span class="line"> System.out.println(<span class="string">"AtomicInteger:"</span> + atomicInteger.compareAndSet(<span class="number">100</span>,<span class="number">120</span>));</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line"></span><br><span class="line"> at1.start();</span><br><span class="line"> at2.start();</span><br><span class="line"></span><br><span class="line"> at1.join();</span><br><span class="line"> at2.join();</span><br><span class="line"></span><br><span class="line"> <span class="comment">//AtomicStampedReference</span></span><br><span class="line"></span><br><span class="line"> Thread tsf1 = <span class="keyword">new</span> Thread(<span class="keyword">new</span> Runnable() &#123;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">run</span><span class="params">()</span> </span>&#123;</span><br><span class="line"> <span class="keyword">try</span> &#123;</span><br><span class="line"> <span class="comment">//让 tsf2先获取stamp，导致预期时间戳不一致</span></span><br><span class="line"> TimeUnit.SECONDS.sleep(<span class="number">2</span>);</span><br><span class="line"> &#125; <span class="keyword">catch</span> (InterruptedException e) &#123;</span><br><span class="line"> e.printStackTrace();</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="comment">// 预期引用：100，更新后的引用：110，预期标识getStamp() 更新后的标识getStamp() + 1</span></span><br><span class="line"> atomicStampedReference.compareAndSet(<span class="number">100</span>,<span class="number">110</span>,atomicStampedReference.getStamp(),atomicStampedReference.getStamp() + <span class="number">1</span>);</span><br><span class="line"> atomicStampedReference.compareAndSet(<span class="number">110</span>,<span class="number">100</span>,atomicStampedReference.getStamp(),atomicStampedReference.getStamp() + <span class="number">1</span>);</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line"></span><br><span class="line"> Thread tsf2 = <span class="keyword">new</span> Thread(<span class="keyword">new</span> Runnable() &#123;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">run</span><span class="params">()</span> </span>&#123;</span><br><span class="line"> <span class="keyword">int</span> stamp = atomicStampedReference.getStamp();</span><br><span class="line"></span><br><span class="line"> <span class="keyword">try</span> &#123;</span><br><span class="line"> TimeUnit.SECONDS.sleep(<span class="number">2</span>);      <span class="comment">//线程tsf1执行完</span></span><br><span class="line"> &#125; <span class="keyword">catch</span> (InterruptedException e) &#123;</span><br><span class="line"> e.printStackTrace();</span><br><span class="line"> &#125;</span><br><span class="line"> System.out.println(<span class="string">"AtomicStampedReference:"</span> +atomicStampedReference.compareAndSet(<span class="number">100</span>,<span class="number">120</span>,stamp,stamp + <span class="number">1</span>));</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line"></span><br><span class="line"> tsf1.start();</span><br><span class="line"> tsf2.start();</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>运行结果：</p><p><a href="http://img.blog.csdn.net/20170407200500306?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvY2hlbnNzeQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20170407200500306?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvY2hlbnNzeQ==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast" alt="这里写图片描述"></a></p><p>运行结果充分展示了AtomicInteger的ABA问题和AtomicStampedReference解决ABA问题。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;CAS，Compare And Swap，即比较并交换。Doug lea大神在同步组件中大量使用CAS技术鬼斧神工地实现了Java多线程的并发操作。整个AQS同步组件、Atomic原子类操作等等都是以CAS实现的，甚至ConcurrentHashMap在1.8的版本中也调整为了CAS+Synchronized。可以说CAS是整个JUC的基石。&lt;br&gt;
    
    </summary>
    
      <category term="java" scheme="http://crazycarry.github.io/categories/java/"/>
    
    
      <category term="jvm" scheme="http://crazycarry.github.io/tags/jvm/"/>
    
      <category term="原理分析" scheme="http://crazycarry.github.io/tags/%E5%8E%9F%E7%90%86%E5%88%86%E6%9E%90/"/>
    
      <category term="java 进阶" scheme="http://crazycarry.github.io/tags/java-%E8%BF%9B%E9%98%B6/"/>
    
  </entry>
  
  <entry>
    <title>深入分析volatile</title>
    <link href="http://crazycarry.github.io/2018/04/05/java-volatile/"/>
    <id>http://crazycarry.github.io/2018/04/05/java-volatile/</id>
    <published>2018-04-05T15:37:15.000Z</published>
    <updated>2018-04-05T15:51:07.320Z</updated>
    
    <content type="html"><![CDATA[<p>volatile这个关键字可能很多朋友都听说过，或许也都用过。在Java 5之前，它是一个备受争议的关键字，因为在程序中使用它往往会导致出人意料的结果。在Java 5之后，volatile关键字才得以重获生机。<br>volatile关键字虽然从字面上理解起来比较简单，但是要用好不是一件容易的事情。由于volatile关键字是与Java的内存模型有关的，因此在讲述volatile关键之前，我们先来了解一下与内存模型相关的概念和知识，然后分析了volatile关键字的实现原理，最后给出了几个使用volatile关键字的场景。<br><a id="more"></a></p><h2 id="一-内存模型的相关概念"><a href="#一-内存模型的相关概念" class="headerlink" title="一.内存模型的相关概念"></a>一.内存模型的相关概念</h2><p>　　大家都知道，计算机在执行程序时，每条指令都是在CPU中执行的，而执行指令过程中，势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存（物理内存）当中的，这时就存在一个问题，由于CPU执行速度很快，而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多，因此如果任何时候对数据的操作都要通过和内存的交互来进行，会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。</p><p>　　也就是，当程序在运行过程中，会将运算需要的数据从主存复制一份到CPU的高速缓存当中，那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据，当运算结束之后，再将高速缓存中的数据刷新到主存当中。举个简单的例子，比如下面的这段代码：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">i = i+<span class="number">1</span>;</span><br></pre></td></tr></table></figure><p>　　当线程执行这个语句时，会先从主存当中读取i的值，然后复制一份到高速缓存当中，然后CPU执行指令对i进行加1操作，然后将数据写入高速缓存，最后将高速缓存中i最新的值刷新到主存当中。</p><p>　　这个代码在单线程中运行是没有任何问题的，但是在多线程中运行就会有问题了。在多核CPU中，每条线程可能运行于不同的CPU中，因此每个线程运行时有自己的高速缓存（对单核CPU来说，其实也会出现这种问题，只不过是以线程调度的形式来分别执行的）。本文我们以多核CPU为例。</p><p>　　比如同时有2个线程执行这段代码，假如初始时i的值为0，那么我们希望两个线程执行完之后i的值变为2。但是事实会是这样吗？</p><p>　　可能存在下面一种情况：初始时，两个线程分别读取i的值存入各自所在的CPU的高速缓存当中，然后线程1进行加1操作，然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0，进行加1操作之后，i的值为1，然后线程2把i的值写入内存。</p><p>　　最终结果i的值是1，而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。</p><p>　　也就是说，如果一个变量在多个CPU中都存在缓存（一般在多线程编程时才会出现），那么就可能存在缓存不一致的问题。</p><p>　　为了解决缓存不一致性问题，通常来说有以下2种解决方法：</p><p>　　1）通过在总线加LOCK#锁的方式</p><p>　　2）通过缓存一致性协议</p><p>　　这2种方式都是硬件层面上提供的方式。</p><p>　　在早期的CPU当中，是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的，如果对总线加LOCK#锁的话，也就是说阻塞了其他CPU对其他部件访问（如内存），从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1，如果在执行这段代码的过程中，在总线上发出了LCOK#锁的信号，那么只有等待这段代码完全执行完毕之后，其他CPU才能从变量i所在的内存读取变量，然后进行相应的操作。这样就解决了缓存不一致的问题。</p><p>　　但是上面的方式会有一个问题，由于在锁住总线期间，其他CPU无法访问内存，导致效率低下。</p><p>　　所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议，MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是：当CPU写数据时，如果发现操作的变量是共享变量，即在其他CPU中也存在该变量的副本，会发出信号通知其他CPU将该变量的缓存行置为无效状态，因此当其他CPU需要读取这个变量时，发现自己缓存中缓存该变量的缓存行是无效的，那么它就会从内存重新读取。</p><p><a href="https://images0.cnblogs.com/blog/288799/201408/212219343783699.jpg" target="_blank" rel="noopener"><img src="https://images0.cnblogs.com/blog/288799/201408/212219343783699.jpg" alt=""></a></p><h2 id="二-并发编程中的三个概念"><a href="#二-并发编程中的三个概念" class="headerlink" title="二.并发编程中的三个概念"></a>二.并发编程中的三个概念</h2><p>　　在并发编程中，我们通常会遇到以下三个问题：原子性问题，可见性问题，有序性问题。我们先看具体看一下这三个概念：</p><p><strong>1.原子性</strong></p><p>　　原子性：即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断，要么就都不执行。</p><p>　　一个很经典的例子就是银行账户转账问题：</p><p>　　比如从账户A向账户B转1000元，那么必然包括2个操作：从账户A减去1000元，往账户B加上1000元。</p><p>　　试想一下，如果这2个操作不具备原子性，会造成什么样的后果。假如从账户A减去1000元之后，操作突然中止。然后又从B取出了500元，取出500元之后，再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元，但是账户B没有收到这个转过来的1000元。</p><p>　　所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。</p><p>　　同样地反映到并发编程中会出现什么结果呢？</p><p>　　举个最简单的例子，大家想一下假如为一个32位的变量赋值过程不具备原子性的话，会发生什么后果？<br>　　<br><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">i = 9;</span><br></pre></td></tr></table></figure></p><p>　　假若一个线程执行到这个语句时，我暂且假设为一个32位的变量赋值包括两个过程：为低16位赋值，为高16位赋值。</p><p>　　那么就可能发生一种情况：当将低16位数值写入之后，突然被中断，而此时又有一个线程去读取i的值，那么读取到的就是错误的数据。</p><p><strong>2.可见性</strong></p><p>　　可见性是指当多个线程访问同一个变量时，一个线程修改了这个变量的值，其他线程能够立即看得到修改的值。</p><p>　　举个简单的例子，看下面这段代码：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">//线程1执行的代码</span><br><span class="line"></span><br><span class="line">int i =0;</span><br><span class="line"></span><br><span class="line">i =10;</span><br><span class="line"></span><br><span class="line">//线程2执行的代码</span><br><span class="line"></span><br><span class="line">j = i;</span><br></pre></td></tr></table></figure><p>　　假若执行线程1的是CPU1，执行线程2的是CPU2。由上面的分析可知，当线程1执行 i =10这句时，会先把i的初始值加载到CPU1的高速缓存中，然后赋值为10，那么在CPU1的高速缓存当中i的值变为10了，却没有立即写入到主存当中。</p><p>　　此时线程2执行 j = i，它会先去主存读取i的值并加载到CPU2的缓存当中，注意此时内存当中i的值还是0，那么就会使得j的值为0，而不是10.</p><p>　　这就是可见性问题，线程1对变量i修改了之后，线程2没有立即看到线程1修改的值。</p><p><strong>3.有序性</strong></p><p>　　有序性：即程序执行的顺序按照代码的先后顺序执行。举个简单的例子，看下面这段代码：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">int i =0;              </span><br><span class="line"></span><br><span class="line">boolean flag =false;</span><br><span class="line"></span><br><span class="line">i =1;              //语句1  </span><br><span class="line"></span><br><span class="line">flag = true;         //语句2</span><br></pre></td></tr></table></figure><p>　　上面代码定义了一个int型变量，定义了一个boolean类型变量，然后分别对两个变量进行赋值操作。从代码顺序上看，语句1是在语句2前面的，那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗？不一定，为什么呢？这里可能会发生指令重排序（Instruction Reorder）。</p><p>　　下面解释一下什么是指令重排序，一般来说，处理器为了提高程序运行效率，可能会对输入代码进行优化，它不保证程序中各个语句的执行先后顺序同代码中的顺序一致，但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。</p><p>　　比如上面的代码中，语句1和语句2谁先执行对最终的程序结果并没有影响，那么就有可能在执行过程中，语句2先执行而语句1后执行。</p><p>　　但是要注意，虽然处理器会对指令进行重排序，但是它会保证程序最终结果会和代码顺序执行结果相同，那么它靠什么保证的呢？再看下面一个例子：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">int a =10;   //语句1</span><br><span class="line"></span><br><span class="line">int r =2;  //语句2</span><br><span class="line"></span><br><span class="line">a = a +3;   //语句3</span><br><span class="line"></span><br><span class="line">r = a*a;   //语句4</span><br></pre></td></tr></table></figure><p>　　这段代码有4个语句，那么可能的一个执行顺序是：</p><p>　　<a href="https://images0.cnblogs.com/blog/288799/201408/212305263939989.jpg" target="_blank" rel="noopener"><img src="https://images0.cnblogs.com/blog/288799/201408/212305263939989.jpg" alt=""></a></p><p>　　那么可不可能是这个执行顺序呢： 语句2 语句1 语句4 语句3</p><p>　　不可能，因为处理器在进行重排序时是会考虑指令之间的数据依赖性，如果一个指令Instruction 2必须用到Instruction 1的结果，那么处理器会保证Instruction 1会在Instruction 2之前执行。</p><p>　　虽然重排序不会影响单个线程内程序执行的结果，但是多线程呢？下面看一个例子：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">//线程1</span></span><br><span class="line"></span><br><span class="line">context = loadContext();  <span class="comment">//语句1</span></span><br><span class="line"></span><br><span class="line">inited =<span class="keyword">true</span>;          <span class="comment">//语句2</span></span><br><span class="line"></span><br><span class="line"><span class="comment">//线程2</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">while</span>(!inited )&#123;</span><br><span class="line"></span><br><span class="line">sleep()</span><br><span class="line"></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">doSomethingwithconfig(context);</span><br></pre></td></tr></table></figure><p>上面代码中，由于语句1和语句2没有数据依赖性，因此可能会被重排序。假如发生了重排序，在线程1执行过程中先执行语句2，而此是线程2会以为初始化工作已经完成，那么就会跳出while循环，去执行doSomethingwithconfig(context)方法，而此时context并没有被初始化，就会导致程序出错。<br>从上面可以看出，指令重排序不会影响单个线程的执行，但是会影响到线程并发执行的正确性。也就是说，要想并发程序正确地执行，必须要保证原子性、可见性以及有序性。只要有一个没有被保证，就有可能会导致程序运行不正确。</p><h2 id="三-Java内存模型"><a href="#三-Java内存模型" class="headerlink" title="三.Java内存模型"></a>三.Java内存模型</h2><p>　　在前面谈到了一些关于内存模型以及并发编程中可能会出现的一些问题。下面我们来看一下Java内存模型，研究一下Java内存模型为我们提供了哪些保证以及在java中提供了哪些方法和机制来让我们在进行多线程编程时能够保证程序执行的正确性。</p><p>　　在Java虚拟机规范中试图定义一种Java内存模型（Java Memory Model，JMM）来屏蔽各个硬件平台和操作系统的内存访问差异，以实现让Java程序在各种平台下都能达到一致的内存访问效果。那么Java内存模型规定了哪些东西呢，它定义了程序中变量的访问规则，往大一点说是定义了程序执行的次序。注意，为了获得较好的执行性能，Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度，也没有限制编译器对指令进行重排序。也就是说，在java内存模型中，也会存在缓存一致性问题和指令重排序的问题。</p><p>　　Java内存模型规定所有的变量都是存在主存当中（类似于前面说的物理内存），每个线程都有自己的工作内存（类似于前面的高速缓存）。线程对变量的所有操作都必须在工作内存中进行，而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。</p><p>　　举个简单的例子：在java中，执行下面这个语句：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">i  =10;</span><br></pre></td></tr></table></figure><p>　　执行线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作，然后再写入主存当中。而不是直接将数值10写入主存当中。</p><p>　　那么Java语言 本身对 原子性、可见性以及有序性提供了哪些保证呢？</p><p><strong>1.原子性</strong></p><p>　　在Java中，对基本数据类型的变量的读取和赋值操作是原子性操作，即这些操作是不可被中断的，要么执行，要么不执行。</p><p>　　上面一句话虽然看起来简单，但是理解起来并不是那么容易。看下面一个例子i：</p><p>　　请分析以下哪些操作是原子性操作：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">x =<span class="number">10</span>;     <span class="comment">//语句1</span></span><br><span class="line"></span><br><span class="line">y = x;       <span class="comment">//语句2</span></span><br><span class="line"></span><br><span class="line">x++;       <span class="comment">//语句3</span></span><br><span class="line"></span><br><span class="line">x = x +<span class="number">1</span>;  <span class="comment">//语句4</span></span><br></pre></td></tr></table></figure><p>　　咋一看，有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作，其他三个语句都不是原子性操作。</p><p>　　语句1是直接将数值10赋值给x，也就是说线程执行这个语句的会直接将数值10写入到工作内存中。</p><p>　　语句2实际上包含2个操作，它先要去读取x的值，再将x的值写入工作内存，虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作，但是合起来就不是原子性操作了。</p><p>　　同样的，x++和 x = x+1包括3个操作：读取x的值，进行加1操作，写入新的值。</p><p>　　所以上面4个语句只有语句1的操作具备原子性。</p><p>　　也就是说，只有简单的读取、赋值（而且必须是将数字赋值给某个变量，变量之间的相互赋值不是原子操作）才是原子操作。</p><p>　　不过这里有一点需要注意：在32位平台下，对64位数据的读取和赋值是需要通过两个操作来完成的，不能保证其原子性。但是好像在最新的JDK中，JVM已经保证对64位数据的读取和赋值也是原子性操作了。</p><p>　　从上面可以看出，Java内存模型只保证了基本读取和赋值是原子性操作，如果要实现更大范围操作的原子性，可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块，那么自然就不存在原子性问题了，从而保证了原子性。</p><p><strong>2.可见性</strong></p><p>　　对于可见性，Java提供了volatile关键字来保证可见性。</p><p>　　当一个共享变量被volatile修饰时，它会保证修改的值会立即被更新到主存，当有其他线程需要读取时，它会去内存中读取新值。</p><p>　　而普通的共享变量不能保证可见性，因为普通共享变量被修改之后，什么时候被写入主存是不确定的，当其他线程去读取时，此时内存中可能还是原来的旧值，因此无法保证可见性。</p><p>　　另外，通过synchronized和Lock也能够保证可见性，synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码，并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。</p><p><strong>3.有序性</strong></p><p>　　在Java内存模型中，允许编译器和处理器对指令进行重排序，但是重排序过程不会影响到单线程程序的执行，却会影响到多线程并发执行的正确性。</p><p>　　在Java里面，可以通过volatile关键字来保证一定的“有序性”（具体原理在下一节讲述）。另外可以通过synchronized和Lock来保证有序性，很显然，synchronized和Lock保证每个时刻是有一个线程执行同步代码，相当于是让线程顺序执行同步代码，自然就保证了有序性。</p><p>　　另外，Java内存模型具备一些先天的“有序性”，即不需要通过任何手段就能够得到保证的有序性，这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来，那么它们就不能保证它们的有序性，虚拟机可以随意地对它们进行重排序。</p><p>　　下面就来具体介绍下happens-before原则（先行发生原则）：</p><ul><li>程序次序规则：一个线程内，按照代码顺序，书写在前面的操作先行发生于书写在后面的操作</li><li>锁定规则：一个unLock操作先行发生于后面对同一个锁额lock操作</li><li>volatile变量规则：对一个变量的写操作先行发生于后面对这个变量的读操作</li><li>传递规则：如果操作A先行发生于操作B，而操作B又先行发生于操作C，则可以得出操作A先行发生于操作C</li><li>线程启动规则：Thread对象的start()方法先行发生于此线程的每个一个动作</li><li>线程中断规则：对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生</li><li>线程终结规则：线程中所有的操作都先行发生于线程的终止检测，我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行</li><li>对象终结规则：一个对象的初始化完成先行发生于他的finalize()方法的开始</li></ul><p>　　这8条原则摘自《深入理解Java虚拟机》。</p><p>　　这8条规则中，前4条规则是比较重要的，后4条规则都是显而易见的。</p><p>　　下面我们来解释一下前4条规则：</p><p>　　对于程序次序规则来说，我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意，虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”，这个应该是程序看起来执行的顺序是按照代码顺序执行的，因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序，但是最终执行的结果是与程序顺序执行的结果一致的，它只会对不存在数据依赖性的指令进行重排序。因此，在单个线程中，程序执行看起来是有序执行的，这一点要注意理解。事实上，这个规则是用来保证程序在单线程中执行结果的正确性，但无法保证程序在多线程中执行的正确性。</p><p>　　第二条规则也比较容易理解，也就是说无论在单线程中还是多线程中，同一个锁如果出于被锁定的状态，那么必须先对锁进行了释放操作，后面才能继续进行lock操作。</p><p>　　第三条规则是一条比较重要的规则，也是后文将要重点讲述的内容。直观地解释就是，如果一个线程先去写一个变量，然后一个线程去进行读取，那么写入操作肯定会先行发生于读操作。</p><p>　　第四条规则实际上就是体现happens-before原则具备传递性。</p><h2 id="四-深入剖析volatile关键字"><a href="#四-深入剖析volatile关键字" class="headerlink" title="四.深入剖析volatile关键字"></a>四.深入剖析volatile关键字</h2><p>　　在前面讲述了很多东西，其实都是为讲述volatile关键字作铺垫，那么接下来我们就进入主题。</p><p><strong>1.volatile关键字的两层语义</strong></p><p>　　一旦一个共享变量（类的成员变量、类的静态成员变量）被volatile修饰之后，那么就具备了两层语义：</p><p>　　1）保证了不同线程对这个变量进行操作时的可见性，即一个线程修改了某个变量的值，这新值对其他线程来说是立即可见的。</p><p>　　2）禁止进行指令重排序。</p><p>　　先看一段代码，假如线程1先执行，线程2后执行：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="comment">//线程1</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">boolean</span> stop =<span class="keyword">false</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">while</span>(!stop)&#123;</span><br><span class="line"></span><br><span class="line">doSomething();</span><br><span class="line"></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">//线程2</span></span><br><span class="line"></span><br><span class="line">stop =<span class="keyword">true</span>;</span><br></pre></td></tr></table></figure><p>　　这段代码是很典型的一段代码，很多人在中断线程时可能都会采用这种标记办法。但是事实上，这段代码会完全运行正确么？即一定会将线程中断么？不一定，也许在大多数时候，这个代码能够把线程中断，但是也有可能会导致无法中断线程（虽然这个可能性很小，但是只要一旦发生这种情况就会造成死循环了）。</p><p>　　下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过，每个线程在运行过程中都有自己的工作内存，那么线程1在运行的时候，会将stop变量的值拷贝一份放在自己的工作内存当中。</p><p>　　那么当线程2更改了stop变量的值之后，但是还没来得及写入主存当中，线程2转去做其他事情了，那么线程1由于不知道线程2对stop变量的更改，因此还会一直循环下去。</p><p>　　但是用volatile修饰之后就变得不一样了：</p><p>　　第一：使用volatile关键字会强制将修改的值立即写入主存；</p><p>　　第二：使用volatile关键字的话，当线程2进行修改时，会导致线程1的工作内存中缓存变量stop的缓存行无效（反映到硬件层的话，就是CPU的L1或者L2缓存中对应的缓存行无效）；</p><p>　　第三：由于线程1的工作内存中缓存变量stop的缓存行无效，所以线程1再次读取变量stop的值时会去主存读取。</p><p>　　那么在线程2修改stop值时（当然这里包括2个操作，修改线程2工作内存中的值，然后将修改后的值写入内存），会使得线程1的工作内存中缓存变量stop的缓存行无效，然后线程1读取时，发现自己的缓存行无效，它会等待缓存行对应的主存地址被更新之后，然后去对应的主存读取最新的值。</p><p>　　那么线程1读取到的就是最新的正确的值。</p><p><strong>2.volatile保证原子性吗？</strong></p><p>　　从上面知道volatile关键字保证了操作的可见性，但是volatile能保证对变量的操作是原子性吗？</p><p>下面看一个例子：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">Test</span> </span>&#123;</span><br><span class="line"> <span class="keyword">public</span> <span class="keyword">volatile</span> <span class="keyword">int</span> inc = <span class="number">0</span>;</span><br><span class="line">  </span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">increase</span><span class="params">()</span> </span>&#123;</span><br><span class="line"> inc++;</span><br><span class="line"> &#125;</span><br><span class="line">  </span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title">main</span><span class="params">(String[] args)</span> </span>&#123;</span><br><span class="line"> <span class="keyword">final</span> Test test = <span class="keyword">new</span> Test();</span><br><span class="line"> <span class="keyword">for</span>(<span class="keyword">int</span> i=<span class="number">0</span>;i</span><br><span class="line"> <span class="keyword">new</span> Thread()&#123;</span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">run</span><span class="params">()</span> </span>&#123;</span><br><span class="line"> <span class="keyword">for</span>(<span class="keyword">int</span> j=<span class="number">0</span>;j</span><br><span class="line"> test.increase();</span><br><span class="line"> &#125;;</span><br><span class="line"> &#125;.start();</span><br><span class="line"> &#125;</span><br><span class="line">  </span><br><span class="line"> <span class="keyword">while</span>(Thread.activeCount()&gt;<span class="number">1</span>)  <span class="comment">//保证前面的线程都执行完</span></span><br><span class="line"> Thread.yield();</span><br><span class="line"> System.out.println(test.inc);</span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>　　大家想一下这段程序的输出结果是多少？也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致，都是一个小于10000的数字。</p><p>　　可能有的朋友就会有疑问，不对啊，上面是对变量inc进行自增操作，由于volatile保证了可见性，那么在每个线程中对inc自增完之后，在其他线程中都能看到修改后的值啊，所以有10个线程分别进行了1000次操作，那么最终inc的值应该是1000*10=10000。</p><p>　　这里面就有一个误区了，volatile关键字能保证可见性没有错，但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值，但是<strong>volatile没办法保证对变量的操作的原子性</strong>。</p><p>　　在前面已经提到过，自增操作是不具备原子性的，它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行，就有可能导致下面这种情况出现：</p><p>　　假如某个时刻变量inc的值为10，</p><p>　　线程1对变量进行自增操作，线程1先读取了变量inc的原始值，然后线程1被阻塞了；</p><p>　　然后线程2对变量进行自增操作，线程2也去读取变量inc的原始值，由于线程1只是对变量inc进行读取操作，而没有对变量进行修改操作，所以不会导致线程2的工作内存中缓存变量inc的缓存行无效，所以线程2会直接去主存读取inc的值，发现inc的值时10，然后进行加1操作，并把11写入工作内存，最后写入主存。</p><p>　　然后线程1接着进行加1操作，由于已经读取了inc的值，注意此时在线程1的工作内存中inc的值仍然为10，所以线程1对inc进行加1操作后inc的值为11，然后将11写入工作内存，最后写入主存。</p><p>　　那么两个线程分别进行了一次自增操作后，inc只增加了1。</p><p>　　解释到这里，可能有朋友会有疑问，不对啊，前面不是保证一个变量在修改volatile变量时，会让缓存行无效吗？然后其他线程去读就会读到新的值，对，这个没错。这个就是上面的happens-before规则中的volatile变量规则，但是要注意，线程1对变量进行读取操作之后，被阻塞了的话，并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的，但是线程1没有进行修改，所以线程2根本就不会看到修改的值。</p><p>　　根源就在这里，自增操作不是原子性操作，而且volatile也无法保证对变量的任何操作都是原子性的。</p><p>　　把上面的代码改成以下任何一种都可以达到效果：</p><p>　　采用synchronized：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">Test</span> </span>&#123; <span class="keyword">public</span>  <span class="keyword">int</span> inc = <span class="number">0</span>; <span class="function"><span class="keyword">public</span> <span class="keyword">synchronized</span> <span class="keyword">void</span> <span class="title">increase</span><span class="params">()</span> </span>&#123;</span><br><span class="line"> inc++;</span><br><span class="line"> &#125; <span class="function"><span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title">main</span><span class="params">(String[] args)</span> </span>&#123; <span class="keyword">final</span> Test test = <span class="keyword">new</span> Test(); <span class="keyword">for</span>(<span class="keyword">int</span> i=<span class="number">0</span>;i)&#123; <span class="keyword">new</span> Thread()&#123; <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">run</span><span class="params">()</span> </span>&#123; <span class="keyword">for</span>(<span class="keyword">int</span> j=<span class="number">0</span>;j)</span><br><span class="line"> test.increase();</span><br><span class="line"> &#125;;</span><br><span class="line"> &#125;.start();</span><br><span class="line"> &#125; <span class="keyword">while</span>(Thread.activeCount()&gt;<span class="number">1</span>)  <span class="comment">//保证前面的线程都执行完</span></span><br><span class="line"> Thread.yield();</span><br><span class="line"> System.out.println(test.inc);</span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>　　采用Lock：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">Test</span> </span>&#123; <span class="keyword">public</span>  <span class="keyword">int</span> inc = <span class="number">0</span>;</span><br><span class="line"> Lock lock = <span class="keyword">new</span> ReentrantLock(); </span><br><span class="line"> <span class="function"><span class="keyword">public</span>  <span class="keyword">void</span> <span class="title">increase</span><span class="params">()</span> </span>&#123;</span><br><span class="line"> lock.lock(); <span class="keyword">try</span> &#123;</span><br><span class="line"> inc++;</span><br><span class="line"> &#125; <span class="keyword">finally</span>&#123;</span><br><span class="line"> lock.unlock();</span><br><span class="line"> &#125;</span><br><span class="line"> &#125; <span class="function"><span class="keyword">public</span> <span class="keyword">static</span> <span class="keyword">void</span> <span class="title">main</span><span class="params">(String[] args)</span> </span>&#123; <span class="keyword">final</span> Test test = <span class="keyword">new</span> Test(); <span class="keyword">for</span>(<span class="keyword">int</span> i=<span class="number">0</span>;i)&#123; <span class="keyword">new</span> Thread()&#123; <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">run</span><span class="params">()</span> </span>&#123; <span class="keyword">for</span>(<span class="keyword">int</span> j=<span class="number">0</span>;j)</span><br><span class="line"> test.increase();</span><br><span class="line"> &#125;;</span><br><span class="line"> &#125;.start();</span><br><span class="line"> &#125; <span class="keyword">while</span>(Thread.activeCount()&gt;<span class="number">1</span>)  <span class="comment">//保证前面的线程都执行完</span></span><br><span class="line"> Thread.yield();</span><br><span class="line"> System.out.println(test.inc);</span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>　　采用AtomicInteger：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line">public class Test &#123;</span><br><span class="line"> public  AtomicInteger inc = new AtomicInteger();</span><br><span class="line">  </span><br><span class="line"> public  void increase() &#123;</span><br><span class="line"> inc.getAndIncrement();</span><br><span class="line"> &#125;</span><br><span class="line">  </span><br><span class="line"> public static void main(String[] args) &#123;</span><br><span class="line"> final Test test = new Test();</span><br><span class="line"> for(int i=0;i10;i++)&#123;</span><br><span class="line"> new Thread()&#123;</span><br><span class="line"> public void run() &#123;</span><br><span class="line"> for(int j=0;j1000;j++)</span><br><span class="line"> test.increase();</span><br><span class="line"> &#125;;</span><br><span class="line"> &#125;.start();</span><br><span class="line"> &#125;</span><br><span class="line">  </span><br><span class="line"> while(Thread.activeCount()&gt;1)  //保证前面的线程都执行完</span><br><span class="line"> Thread.yield();</span><br><span class="line"> System.out.println(test.inc);</span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>　　在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类，即对基本数据类型的 自增（加1操作），自减（减1操作）、以及加法操作（加一个数），减法操作（减一个数）进行了封装，保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的（Compare And Swap），CAS实际上是利用处理器提供的CMPXCHG指令实现的，而处理器执行CMPXCHG指令是一个原子性操作。</p><p><strong>3.volatile能保证有序性吗？</strong></p><p>　　在前面提到volatile关键字能禁止指令重排序，所以volatile能在一定程度上保证有序性。</p><p>　　volatile关键字禁止指令重排序有两层意思：</p><p>　　1）当程序执行到volatile变量的读操作或者写操作时，在其前面的操作的更改肯定全部已经进行，且结果已经对后面的操作可见；在其后面的操作肯定还没有进行；</p><p>　　2）在进行指令优化时，不能将在对volatile变量访问的语句放在其后面执行，也不能把volatile变量后面的语句放到其前面执行。</p><p>　　可能上面说的比较绕，举个简单的例子：</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">//x、y为非volatile变量</span><br><span class="line">//flag为volatile变量</span><br><span class="line">  </span><br><span class="line">x = 2;        //语句1</span><br><span class="line">y = 0;        //语句2</span><br><span class="line">flag = true;  //语句3</span><br><span class="line">x = 4;         //语句4</span><br><span class="line">y = -1;       //语句5</span><br></pre></td></tr></table></figure><p>　　由于flag变量为volatile变量，那么在进行指令重排序的过程的时候，不会将语句3放到语句1、语句2前面，也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。</p><p>　　并且volatile关键字能保证，执行到语句3时，语句1和语句2必定是执行完毕了的，且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。</p><p>　　那么我们回到前面举的一个例子：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">/线程<span class="number">1</span>:</span><br><span class="line">context = loadContext();   <span class="comment">//语句1</span></span><br><span class="line">inited = <span class="keyword">true</span>;             <span class="comment">//语句2</span></span><br><span class="line">  </span><br><span class="line"><span class="comment">//线程2:</span></span><br><span class="line"><span class="keyword">while</span>(!inited )&#123;</span><br><span class="line"> sleep()</span><br><span class="line">&#125;</span><br><span class="line">doSomethingwithconfig(context);</span><br></pre></td></tr></table></figure><p>　　前面举这个例子的时候，提到有可能语句2会在语句1之前执行，那么久可能导致context还没被初始化，而线程2中就使用未初始化的context去进行操作，导致程序出错。</p><p>　　这里如果用volatile关键字对inited变量进行修饰，就不会出现这种问题了，因为当执行到语句2时，必定能保证context已经初始化完毕。</p><p><strong>4.volatile的原理和实现机制</strong></p><p>　　前面讲述了源于volatile关键字的一些使用，下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。</p><p>　　下面这段话摘自《深入理解Java虚拟机》：</p><p>　　“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现，加入volatile关键字时，会多出一个lock前缀指令”</p><p>　　lock前缀指令实际上相当于一个内存屏障（也成内存栅栏），内存屏障会提供3个功能：</p><p>　　1）它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置，也不会把前面的指令排到内存屏障的后面；即在执行到内存屏障这句指令时，在它前面的操作已经全部完成；</p><p>　　2）它会强制将对缓存的修改操作立即写入主存；</p><p>　　3）如果是写操作，它会导致其他CPU中对应的缓存行无效。</p><h2 id="五-使用volatile关键字的场景"><a href="#五-使用volatile关键字的场景" class="headerlink" title="五.使用volatile关键字的场景"></a>五.使用volatile关键字的场景</h2><p>　　synchronized关键字是防止多个线程同时执行一段代码，那么就会很影响程序执行效率，而volatile关键字在某些情况下性能要优于synchronized，但是要注意volatile关键字是无法替代synchronized关键字的，因为volatile关键字无法保证操作的原子性。通常来说，使用volatile必须具备以下2个条件：</p><p>　　1）对变量的写操作不依赖于当前值</p><p>　　2）该变量没有包含在具有其他变量的不变式中</p><p>　　实际上，这些条件表明，可以被写入 volatile 变量的这些有效值独立于任何程序的状态，包括变量的当前状态。</p><p>　　事实上，我的理解就是上面的2个条件需要保证操作是原子性操作，才能保证使用volatile关键字的程序在并发时能够正确执行。</p><p>　　下面列举几个Java中使用volatile的几个场景。</p><p><strong>1.状态标记量</strong></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">volatile</span> <span class="keyword">boolean</span> flag = <span class="keyword">false</span>;</span><br><span class="line">  </span><br><span class="line"><span class="keyword">while</span>(!flag)&#123;</span><br><span class="line"> doSomething();</span><br><span class="line">&#125;</span><br><span class="line">  </span><br><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">setFlag</span><span class="params">()</span> </span>&#123;</span><br><span class="line"> flag = <span class="keyword">true</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">volatile boolean inited = false;</span><br><span class="line">//线程1:</span><br><span class="line">context = loadContext(); </span><br><span class="line">inited = true; </span><br><span class="line">  </span><br><span class="line">//线程2:</span><br><span class="line">while(!inited )&#123;</span><br><span class="line">sleep()</span><br><span class="line">&#125;</span><br><span class="line">doSomethingwithconfig(context);</span><br></pre></td></tr></table></figure><p><strong>2.double check</strong></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">Singleton</span></span>&#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">volatile</span> <span class="keyword">static</span> Singleton instance = <span class="keyword">null</span>; </span><br><span class="line"> <span class="function"><span class="keyword">private</span> <span class="title">Singleton</span><span class="params">()</span> </span>&#123; </span><br><span class="line"> &#125;</span><br><span class="line">  </span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">static</span> Singleton <span class="title">getInstance</span><span class="params">()</span> </span>&#123;</span><br><span class="line"> <span class="keyword">if</span>(instance==<span class="keyword">null</span>) &#123;</span><br><span class="line"> <span class="keyword">synchronized</span> (Singleton.class) &#123;</span><br><span class="line"> <span class="keyword">if</span>(instance==<span class="keyword">null</span>)</span><br><span class="line"> instance = <span class="keyword">new</span> Singleton();</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="keyword">return</span> instance;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>　　至于为何需要这么写请参考：</p><p>　　《Java 中的双重检查（Double-Check）》<a href="http://blog.csdn.net/dl88250/article/details/5439024" target="_blank" rel="noopener">http://blog.csdn.net/dl88250/article/details/5439024</a></p><p>　　和<a href="http://www.iteye.com/topic/652440" target="_blank" rel="noopener">http://www.iteye.com/topic/652440</a></p><p>　　参考资料：</p><p>　　《Java编程思想》</p><p>　　《深入理解Java虚拟机》</p><p>　　<a href="http://jiangzhengjun.iteye.com/blog/652532" target="_blank" rel="noopener">http://jiangzhengjun.iteye.com/blog/652532</a></p><p>　　<a href="http://blog.sina.com.cn/s/blog_7bee8dd50101fu8n.html" target="_blank" rel="noopener">http://blog.sina.com.cn/s/blog_7bee8dd50101fu8n.html</a></p><p>　　<a href="http://ifeve.com/volatile/" target="_blank" rel="noopener">http://ifeve.com/volatile/</a></p><p>　　<a href="http://blog.csdn.net/ccit0519/article/details/11241403" target="_blank" rel="noopener">http://blog.csdn.net/ccit0519/article/details/11241403</a></p><p>　　<a href="http://blog.csdn.net/ns_code/article/details/17101369" target="_blank" rel="noopener">http://blog.csdn.net/ns_code/article/details/17101369</a></p><p>　　<a href="http://www.cnblogs.com/kevinwu/archive/2012/05/02/2479464.html" target="_blank" rel="noopener">http://www.cnblogs.com/kevinwu/archive/2012/05/02/2479464.html</a></p><p>　　<a href="http://www.cppblog.com/elva/archive/2011/01/21/139019.html" target="_blank" rel="noopener">http://www.cppblog.com/elva/archive/2011/01/21/139019.html</a></p><p>　　<a href="http://ifeve.com/volatile-array-visiblity/" target="_blank" rel="noopener">http://ifeve.com/volatile-array-visiblity/</a></p><p>　　<a href="http://www.bdqn.cn/news/201312/12579.shtml" target="_blank" rel="noopener">http://www.bdqn.cn/news/201312/12579.shtml</a></p><p>　　<a href="http://exploer.blog.51cto.com/7123589/1193399" target="_blank" rel="noopener">http://exploer.blog.51cto.com/7123589/1193399</a></p><p>　　<a href="http://www.cnblogs.com/Mainz/p/3556430.html" target="_blank" rel="noopener">http://www.cnblogs.com/Mainz/p/3556430.html</a></p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;volatile这个关键字可能很多朋友都听说过，或许也都用过。在Java 5之前，它是一个备受争议的关键字，因为在程序中使用它往往会导致出人意料的结果。在Java 5之后，volatile关键字才得以重获生机。&lt;br&gt;volatile关键字虽然从字面上理解起来比较简单，但是要用好不是一件容易的事情。由于volatile关键字是与Java的内存模型有关的，因此在讲述volatile关键之前，我们先来了解一下与内存模型相关的概念和知识，然后分析了volatile关键字的实现原理，最后给出了几个使用volatile关键字的场景。&lt;br&gt;
    
    </summary>
    
      <category term="java" scheme="http://crazycarry.github.io/categories/java/"/>
    
    
      <category term="jvm" scheme="http://crazycarry.github.io/tags/jvm/"/>
    
      <category term="原理分析" scheme="http://crazycarry.github.io/tags/%E5%8E%9F%E7%90%86%E5%88%86%E6%9E%90/"/>
    
      <category term="java 进阶" scheme="http://crazycarry.github.io/tags/java-%E8%BF%9B%E9%98%B6/"/>
    
  </entry>
  
  <entry>
    <title>深入分析synchronized</title>
    <link href="http://crazycarry.github.io/2018/04/05/java-synchronized/"/>
    <id>http://crazycarry.github.io/2018/04/05/java-synchronized/</id>
    <published>2018-04-05T15:28:06.000Z</published>
    <updated>2018-04-05T15:36:23.407Z</updated>
    
    <content type="html"><![CDATA[<p>记得刚刚开始学习Java的时候，一遇到多线程情况就是synchronized，相对于当时的我们来说synchronized是这么的神奇而又强大，那个时候我们赋予它一个名字“同步”，也成为了我们解决多线程情况的百试不爽的良药。但是，随着我们学习的进行我们知道synchronized是一个重量级锁，相对于Lock，它会显得那么笨重，以至于我们认为它不是那么的高效而慢慢摒弃它。<br>诚然，随着Javs SE 1.6对synchronized进行的各种优化后，synchronized并不会显得那么重了。下面跟随LZ一起来探索synchronized的实现机制、Java是如何对它进行了优化、锁优化机制、锁的存储结构和升级过程；<br><a id="more"></a></p><h2 id="实现原理"><a href="#实现原理" class="headerlink" title="实现原理"></a>实现原理</h2><p>synchronized可以保证方法或者代码块在运行时，同一时刻只有一个方法可以进入到临界区，同时它还可以保证共享变量的内存可见性</p><p>Java中每一个对象都可以作为锁，这是synchronized实现同步的基础：</p><p>普通同步方法，锁是当前实例对象<br>静态同步方法，锁是当前类的class对象<br>同步方法块，锁是括号里面的对象<br>当一个线程访问同步代码块时，它首先是需要得到锁才能执行同步代码，当退出或者抛出异常时必须要释放锁，那么它是如何来实现这个机制的呢？我们先看一段简单的代码：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">SynchronizedTest</span> </span>&#123;</span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">synchronized</span> <span class="keyword">void</span> <span class="title">test1</span><span class="params">()</span></span>&#123;</span><br><span class="line"></span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">test2</span><span class="params">()</span></span>&#123;</span><br><span class="line"> <span class="keyword">synchronized</span> (<span class="keyword">this</span>)&#123;</span><br><span class="line"></span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>利用javap工具查看生成的class文件信息来分析Synchronize的实现<br><a href="http://cmsblogs.qiniudn.com/wp-content/uploads/2017/02/Synchronize-1-1.jpg" target="_blank" rel="noopener"><img src="http://cmsblogs.qiniudn.com/wp-content/uploads/2017/02/Synchronize-1-1.jpg" alt=""></a><br>从上面可以看出，同步代码块是使用monitorenter和monitorexit指令实现的，同步方法（在这看不出来需要看JVM底层实现）依靠的是方法修饰符上的ACC_SYNCHRONIZED实现。<br>同步代码块：monitorenter指令插入到同步代码块的开始位置，monitorexit指令插入到同步代码块的结束位置，JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联，当且一个monitor被持有之后，他将处于锁定状态。线程执行到monitorenter指令时，将会尝试获取对象所对应的monitor所有权，即尝试获取对象的锁；<br>同步方法：synchronized方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令，在VM字节码层面并没有任何特别的指令来实现被synchronized修饰的方法，而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1，表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。(摘自：<a href="http://www.cnblogs.com/javaminer/p/3889023.html" target="_blank" rel="noopener">http://www.cnblogs.com/javaminer/p/3889023.html</a>)</p><p>下面我们来继续分析，但是在深入之前我们需要了解两个重要的概念：Java对象头，Monitor。</p><h2 id="Java对象头、monitor"><a href="#Java对象头、monitor" class="headerlink" title="Java对象头、monitor"></a>Java对象头、monitor</h2><p>Java对象头和monitor是实现synchronized的基础！下面就这两个概念来做详细介绍。</p><p><strong>Java对象头</strong><br>synchronized用的锁是存在Java对象头里的，那么什么是Java对象头呢？Hotspot虚拟机的对象头主要包括两部分数据：Mark Word（标记字段）、Klass Pointer（类型指针）。其中Klass Point是是对象指向它的类元数据的指针，虚拟机通过这个指针来确定这个对象是哪个类的实例，Mark Word用于存储对象自身的运行时数据，它是实现轻量级锁和偏向锁的关键，所以下面将重点阐述<br>Mark Word。<br>Mark Word用于存储对象自身的运行时数据，如哈希码（HashCode）、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码（在32位虚拟机中，1个机器码等于4字节，也就是32bit），但是如果对象是数组类型，则需要三个机器码，因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小，但是无法从数组的元数据来确认数组的大小，所以用一块来记录数组长度。下图是Java对象头的存储结构（32位虚拟机）：<br><a href="http://cmsblogs.qiniudn.com/wp-content/uploads/2017/02/222222_2-1.jpg" target="_blank" rel="noopener"><img src="http://cmsblogs.qiniudn.com/wp-content/uploads/2017/02/222222_2-1.jpg" alt=""></a><br>对象头信息是与对象自身定义的数据无关的额外存储成本，但是考虑到虚拟机的空间效率，Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据，它会根据对象的状态复用自己的存储空间，也就是说，Mark Word会随着程序的运行发生变化，变化状态如下（32位虚拟机）：<br><a href="http://cmsblogs.qiniudn.com/wp-content/uploads/2017/02/11111111111_2-1.jpg" target="_blank" rel="noopener"><img src="http://cmsblogs.qiniudn.com/wp-content/uploads/2017/02/11111111111_2-1.jpg" alt=""></a></p><p>简单介绍了Java对象头，我们下面再看Monitor。</p><h2 id="Monitor"><a href="#Monitor" class="headerlink" title="Monitor"></a>Monitor</h2><p>什么是Monitor？我们可以把它理解为一个同步工具，也可以描述为一种同步机制，它通常被描述为一个对象。<br>与一切皆对象一样，所有的Java对象是天生的Monitor，每一个Java对象都有成为Monitor的潜质，因为在Java的设计中 ，每一个Java对象自打娘胎里出来就带了一把看不见的锁，它叫做内部锁或者Monitor锁。<br>Monitor 是线程私有的数据结构，每一个线程都有一个可用monitor record列表，同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联（对象头的MarkWord中的LockWord指向monitor的起始地址），同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识，表示该锁被这个线程占用。其结构如下：<br><a href="http://cmsblogs.qiniudn.com/wp-content/uploads/2017/02/44444-1.png" target="_blank" rel="noopener"><img src="http://cmsblogs.qiniudn.com/wp-content/uploads/2017/02/44444-1.png" alt=""></a><br>Owner：初始时为NULL表示当前没有任何线程拥有该monitor record，当线程成功拥有该锁后保存线程唯一标识，当锁被释放时又设置为NULL；<br>EntryQ:关联一个系统互斥锁（semaphore），阻塞所有试图锁住monitor record失败的线程。<br>RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。<br>Nest:用来实现重入锁的计数。<br>HashCode:保存从对象头拷贝过来的HashCode值（可能还包含GC age）。<br>Candidate:用来避免不必要的阻塞或等待线程唤醒，因为每一次只有一个线程能够成功拥有锁，如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程，会引起不必要的上下文切换（从阻塞到就绪然后因为竞争锁失败又被阻塞）从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。<br>摘自：<a href="http://blog.csdn.net/u012465296/article/details/53022317" target="_blank" rel="noopener">Java中synchronized的实现原理与应用</a><br>我们知道synchronized是重量级锁，效率不怎么滴，同时这个观念也一直存在我们脑海里，不过在jdk 1.6中对synchronize的实现进行了各种优化，使得它显得不是那么重了，那么JVM采用了那些优化手段呢？</p><h2 id="锁优化"><a href="#锁优化" class="headerlink" title="锁优化"></a>锁优化</h2><p>jdk1.6对锁的实现引入了大量的优化，如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。<br>锁主要存在四中状态，依次是：无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态，他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级，这种策略是为了提高获得锁和释放锁的效率。</p><p><strong>自旋锁</strong><br>线程的阻塞和唤醒需要CPU从用户态转为核心态，频繁的阻塞和唤醒对CPU来说是一件负担很重的工作，势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面，对象锁的锁状态只会持续很短一段时间，为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。<br>何谓自旋锁？<br>所谓自旋锁，就是让该线程等待一段时间，不会被立即挂起，看持有锁的线程是否会很快释放锁。怎么等待呢？执行一段无意义的循环即可（自旋）。<br>自旋等待不能替代阻塞，先不说对处理器数量的要求（多核，貌似现在没有单核的处理器了），虽然它可以避免线程切换带来的开销，但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁，那么自旋的效率就非常好，反之，自旋的线程就会白白消耗掉处理的资源，它不会做任何有意义的工作，典型的占着茅坑不拉屎，这样反而会带来性能上的浪费。所以说，自旋等待的时间（自旋的次数）必须要有一个限度，如果自旋超过了定义的时间仍然没有获取到锁，则应该被挂起。<br>自旋锁在JDK 1.4.2中引入，默认关闭，但是可以使用-XX:+UseSpinning开开启，在JDK1.6中默认开启。同时自旋的默认次数为10次，可以通过参数-XX:PreBlockSpin来调整；<br>如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数，会带来诸多不便。假如我将参数调整为10，但是系统很多线程都是等你刚刚退出的时候就释放了锁（假如你多自旋一两次就可以获取锁），你是不是很尴尬。于是JDK1.6引入自适应的自旋锁，让虚拟机会变得越来越聪明。</p><p><strong>适应自旋锁</strong><br>JDK 1.6引入了更加聪明的自旋锁，即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的，它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢？线程如果自旋成功了，那么下次自旋的次数会更加多，因为虚拟机认为既然上次成功了，那么此次自旋也很有可能会再次成功，那么它就会允许自旋等待持续的次数更多。反之，如果对于某个锁，很少有自旋能够成功的，那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程，以免浪费处理器资源。<br>有了自适应自旋锁，随着程序运行和性能监控信息的不断完善，虚拟机对程序锁的状况预测会越来越准确，虚拟机会变得越来越聪明。</p><p><strong>锁消除</strong><br>为了保证数据的完整性，我们在进行操作时需要对这部分操作进行同步控制，但是在有些情况下，JVM检测到不可能存在共享数据竞争，这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。<br>如果不存在竞争，为什么还需要加锁呢？所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸，对于虚拟机来说需要使用数据流分析来确定，但是对于我们程序员来说这还不清楚么？我们会在明明知道不存在数据竞争的代码块前加上同步吗？但是有时候程序并不是我们所想的那样？我们虽然没有显示使用锁，但是我们在使用一些JDK的内置API时，如StringBuffer、Vector、HashTable等，这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法，Vector的add()方法：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">vectorTest</span><span class="params">()</span></span>&#123;</span><br><span class="line"> Vector vector = <span class="keyword">new</span> Vector();</span><br><span class="line"> <span class="keyword">for</span>(<span class="keyword">int</span> i = <span class="number">0</span> ; i </span><br><span class="line"> vector.add(i + <span class="string">""</span>);</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> System.out.println(vector);</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>在运行这段代码时，JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外，所以JVM可以大胆地将vector内部的加锁操作消除。</p><p><strong>锁粗化</strong><br>我们知道在使用同步锁的时候，需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步，这样做的目的是为了使需要同步的操作数量尽可能缩小，如果存在锁竞争，那么等待锁的线程也能尽快拿到锁。<br>在大多数的情况下，上述观点是正确的，LZ也一直坚持着这个观点。但是如果一系列的连续加锁解锁操作，可能会导致不必要的性能损耗，所以引入锁粗话的概念。<br>锁粗话概念比较好理解，就是将多个连续的加锁、解锁操作连接在一起，扩展成一个范围更大的锁。如上面实例：vector每次add的时候都需要加锁操作，JVM检测到对同一个对象（vector）连续加锁、解锁操作，会合并一个更大范围的加锁、解锁操作，即加锁解锁操作会移到for循环之外。</p><p><strong>轻量级锁</strong><br>引入轻量级锁的主要目的是在多没有多线程竞争的前提下，减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁，则会尝试获取轻量级锁，其步骤如下：</p><ol><li>获取锁</li></ol><p>1) 判断当前对象是否处于无锁状态（hashcode、0、01），若是，则JVM首先将在当前线程的栈帧中建立一个名为锁记录（Lock Record）的空间，用于存储锁对象目前的Mark Word的拷贝（官方把这份拷贝加了一个Displaced前缀，即Displaced Mark Word）；否则执行步骤（3）；<br>2) JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正，如果成功表示竞争到锁，则将锁标志位变成00（表示此对象处于轻量级锁状态），执行同步操作；如果失败则执行步骤（3）；<br>3) 判断当前对象的Mark Word是否指向当前线程的栈帧，如果是则表示当前线程已经持有当前对象的锁，则直接执行同步代码块；否则只能说明该锁对象已经被其他线程抢占了，这时轻量级锁需要膨胀为重量级锁，锁标志位变成10，后面等待的线程将会进入阻塞状态；</p><ol><li>释放锁<br> 轻量级锁的释放也是通过CAS操作来进行的，主要步骤如下：</li></ol><p>1）取出在获取轻量级锁保存在Displaced Mark Word中的数据；<br>2）用CAS操作将取出的数据替换当前对象的Mark Word中，如果成功，则说明释放锁成功，否则执行（3）；<br>3）如果CAS操作替换失败，说明有其他线程尝试获取该锁，则需要在释放锁的同时需要唤醒被挂起的线程。<br>对于轻量级锁，其性能提升的依据是“对于绝大部分的锁，在整个生命周期内都是不会存在竞争的”，如果打破这个依据则除了互斥的开销外，还有额外的CAS操作，因此在有多线程竞争的情况下，轻量级锁比重量级锁更慢；</p><p>下图是轻量级锁的获取和释放过程<br><a href="http://cmsblogs.qiniudn.com/wp-content/uploads/2017/02/22222222222222-1.png" target="_blank" rel="noopener"><img src="http://cmsblogs.qiniudn.com/wp-content/uploads/2017/02/22222222222222-1.png" alt=""></a></p><p><strong>偏向锁</strong><br>引入偏向锁主要目的是：为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。上面提到了轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。那么偏向锁是如何来减少不必要的CAS操作呢？我们可以查看Mark work的结构就明白了。只需要检查是否为偏向锁、锁标识为以及ThreadID即可，处理流程如下：<br>获取锁</p><ol><li>检测Mark Word是否为可偏向状态，即是否为偏向锁1，锁标识位为01；</li><li>若为可偏向状态，则测试线程ID是否为当前线程ID，如果是，则执行步骤（5），否则执行步骤（3）；</li><li>如果线程ID不为当前线程ID，则通过CAS操作竞争锁，竞争成功，则将Mark Word的线程ID替换为当前线程ID，否则执行线程（4）；</li><li>通过CAS竞争锁失败，证明当前存在多线程竞争情况，当到达全局安全点，获得偏向锁的线程被挂起，偏向锁升级为轻量级锁，然后被阻塞在安全点的线程继续往下执行同步代码块；</li><li>执行同步代码块</li></ol><p>释放锁<br>偏向锁的释放采用了一种只有竞争才会释放锁的机制，线程是不会主动去释放偏向锁，需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点（这个时间点是上没有正在执行的代码）。其步骤如下：</p><ol><li>暂停拥有偏向锁的线程，判断锁对象石是否还处于被锁定状态；</li><li>撤销偏向苏，恢复到无锁状态（01）或者轻量级锁的状态；<br> 下图是偏向锁的获取和释放流程<br> <a href="http://cmsblogs.qiniudn.com/wp-content/uploads/2017/02/image2-1.png" target="_blank" rel="noopener"><img src="http://cmsblogs.qiniudn.com/wp-content/uploads/2017/02/image2-1.png" alt=""></a></li></ol><p><strong>重量级锁</strong><br>重量级锁通过对象内部的监视器（monitor）实现，其中monitor的本质是依赖于底层操作系统的Mutex Lock实现，操作系统实现线程之间的切换需要从用户态到内核态的切换，切换成本非常高。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;记得刚刚开始学习Java的时候，一遇到多线程情况就是synchronized，相对于当时的我们来说synchronized是这么的神奇而又强大，那个时候我们赋予它一个名字“同步”，也成为了我们解决多线程情况的百试不爽的良药。但是，随着我们学习的进行我们知道synchronized是一个重量级锁，相对于Lock，它会显得那么笨重，以至于我们认为它不是那么的高效而慢慢摒弃它。&lt;br&gt;诚然，随着Javs SE 1.6对synchronized进行的各种优化后，synchronized并不会显得那么重了。下面跟随LZ一起来探索synchronized的实现机制、Java是如何对它进行了优化、锁优化机制、锁的存储结构和升级过程；&lt;br&gt;
    
    </summary>
    
      <category term="java" scheme="http://crazycarry.github.io/categories/java/"/>
    
    
      <category term="jvm" scheme="http://crazycarry.github.io/tags/jvm/"/>
    
      <category term="原理分析" scheme="http://crazycarry.github.io/tags/%E5%8E%9F%E7%90%86%E5%88%86%E6%9E%90/"/>
    
      <category term="java 进阶" scheme="http://crazycarry.github.io/tags/java-%E8%BF%9B%E9%98%B6/"/>
    
      <category term="架构设计" scheme="http://crazycarry.github.io/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/"/>
    
  </entry>
  
  <entry>
    <title>Kafka设计解析（八）- Kafka Exactly Once语义与事务机制原理</title>
    <link href="http://crazycarry.github.io/2018/04/04/kafka-eos/"/>
    <id>http://crazycarry.github.io/2018/04/04/kafka-eos/</id>
    <published>2018-04-04T13:46:54.000Z</published>
    <updated>2018-04-04T13:58:21.461Z</updated>
    
    <content type="html"><![CDATA[<p>本文介绍了Kafka实现事务性的几个阶段——正好一次语义与原子操作。之后详细分析了Kafka事务机制的实现原理，并介绍了Kafka如何处理事务相关的异常情况，如Transaction Coordinator宕机。最后介绍了Kafka的事务机制与PostgreSQL的MVCC以及Zookeeper的原子广播实现事务的异同<br><a id="more"></a></p><h1 id="写在前面的话"><a href="#写在前面的话" class="headerlink" title="写在前面的话"></a>写在前面的话</h1><p>本文所有Kafka原理性的描述除特殊说明外均基于Kafka 1.0.0版本。</p><h1 id="为什么要提供事务机制"><a href="#为什么要提供事务机制" class="headerlink" title="为什么要提供事务机制"></a>为什么要提供事务机制</h1><p>Kafka事务机制的实现主要是为了支持</p><ul><li><code>Exactly Once</code>即正好一次语义</li><li>操作的原子性</li><li>有状态操作的可恢复性</li></ul><h2 id="Exactly-Once"><a href="#Exactly-Once" class="headerlink" title="Exactly Once"></a>Exactly Once</h2><p>《<a href="http://www.jasongj.com/2015/03/10/KafkaColumn1/#Kafka-delivery-guarantee" target="_blank" rel="noopener">Kafka背景及架构介绍</a>》一文中有说明Kafka在0.11.0.0之前的版本中只支持<code>At Least Once</code>和<code>At Most Once</code>语义，尚不支持<code>Exactly Once</code>语义。</p><p>但是在很多要求严格的场景下，如使用Kafka处理交易数据，<code>Exactly Once</code>语义是必须的。我们可以通过让下游系统具有幂等性来配合Kafka的<code>At Least Once</code>语义来间接实现<code>Exactly Once</code>。但是：</p><ul><li>该方案要求下游系统支持幂等操作，限制了Kafka的适用场景</li><li>实现门槛相对较高，需要用户对Kafka的工作机制非常了解</li><li>对于Kafka Stream而言，Kafka本身即是自己的下游系统，但Kafka在0.11.0.0版本之前不具有幂等发送能力</li></ul><p>因此，Kafka本身对<code>Exactly Once</code>语义的支持就非常必要。</p><h2 id="操作原子性"><a href="#操作原子性" class="headerlink" title="操作原子性"></a>操作原子性</h2><p>操作的原子性是指，多个操作要么全部成功要么全部失败，不存在部分成功部分失败的可能。</p><p>实现原子性操作的意义在于：</p><ul><li>操作结果更可控，有助于提升数据一致性</li><li>便于故障恢复。因为操作是原子的，从故障中恢复时只需要重试该操作（如果原操作失败）或者直接跳过该操作（如果原操作成功），而不需要记录中间状态，更不需要针对中间状态作特殊处理</li></ul><h1 id="实现事务机制的几个阶段"><a href="#实现事务机制的几个阶段" class="headerlink" title="实现事务机制的几个阶段"></a>实现事务机制的几个阶段</h1><h2 id="幂等性发送"><a href="#幂等性发送" class="headerlink" title="幂等性发送"></a>幂等性发送</h2><p>上文提到，实现<code>Exactly Once</code>的一种方法是让下游系统具有幂等处理特性，而在Kafka Stream中，Kafka Producer本身就是“下游”系统，因此如果能让Producer具有幂等处理特性，那就可以让Kafka Stream在一定程度上支持<code>Exactly once</code>语义。</p><p>为了实现Producer的幂等语义，Kafka引入了<code>Producer ID</code>（即<code>PID</code>）和<code>Sequence Number</code>。每个新的Producer在初始化的时候会被分配一个唯一的PID，该PID对用户完全透明而不会暴露给用户。</p><p>对于每个PID，该Producer发送数据的每个<code>&lt;Topic, Partition&gt;</code>都对应一个从0开始单调递增的<code>Sequence Number</code>。</p><p>类似地，Broker端也会为每个<code>&lt;PID, Topic, Partition&gt;</code>维护一个序号，并且每次Commit一条消息时将其对应序号递增。对于接收的每条消息，如果其序号比Broker维护的序号（即最后一次Commit的消息的序号）大一，则Broker会接受它，否则将其丢弃：</p><ul><li>如果消息序号比Broker维护的序号大一以上，说明中间有数据尚未写入，也即乱序，此时Broker拒绝该消息，Producer抛出<code>InvalidSequenceNumber</code></li><li>如果消息序号小于等于Broker维护的序号，说明该消息已被保存，即为重复消息，Broker直接丢弃该消息，Producer抛出<code>DuplicateSequenceNumber</code></li></ul><p>上述设计解决了0.11.0.0之前版本中的两个问题：</p><ul><li>Broker保存消息后，发送ACK前宕机，Producer认为消息未发送成功并重试，造成数据重复</li><li>前一条消息发送失败，后一条消息发送成功，前一条消息重试后成功，造成数据乱序</li></ul><h2 id="事务性保证"><a href="#事务性保证" class="headerlink" title="事务性保证"></a>事务性保证</h2><p>上述幂等设计只能保证单个Producer对于同一个<code>&lt;Topic, Partition&gt;</code>的<code>Exactly Once</code>语义。</p><p>另外，它并不能保证写操作的原子性——即多个写操作，要么全部被Commit要么全部不被Commit。</p><p>更不能保证多个读写操作的的原子性。尤其对于Kafka Stream应用而言，典型的操作即是从某个Topic消费数据，经过一系列转换后写回另一个Topic，保证从源Topic的读取与向目标Topic的写入的原子性有助于从故障中恢复。</p><p>事务保证可使得应用程序将生产数据和消费数据当作一个原子单元来处理，要么全部成功，要么全部失败，即使该生产或消费跨多个<code>&lt;Topic, Partition&gt;</code>。</p><p>另外，有状态的应用也可以保证重启后从断点处继续处理，也即事务恢复。</p><p>为了实现这种效果，应用程序必须提供一个稳定的（重启后不变）唯一的ID，也即<code>Transaction ID</code>。<code>Transactin ID</code>与<code>PID</code>可能一一对应。区别在于<code>Transaction ID</code>由用户提供，而<code>PID</code>是内部的实现对用户透明。</p><p>另外，为了保证新的Producer启动后，旧的具有相同<code>Transaction ID</code>的Producer即失效，每次Producer通过<code>Transaction ID</code>拿到PID的同时，还会获取一个单调递增的epoch。由于旧的Producer的epoch比新Producer的epoch小，Kafka可以很容易识别出该Producer是老的Producer并拒绝其请求。</p><p>有了<code>Transaction ID</code>后，Kafka可保证：</p><ul><li>跨Session的数据幂等发送。当具有相同<code>Transaction ID</code>的新的Producer实例被创建且工作时，旧的且拥有相同<code>Transaction ID</code>的Producer将不再工作。</li><li>跨Session的事务恢复。如果某个应用实例宕机，新的实例可以保证任何未完成的旧的事务要么Commit要么Abort，使得新实例从一个正常状态开始工作。</li></ul><p>需要注意的是，上述的事务保证是从Producer的角度去考虑的。从Consumer的角度来看，该保证会相对弱一些。尤其是不能保证所有被某事务Commit过的所有消息都被一起消费，因为：</p><ul><li>对于压缩的Topic而言，同一事务的某些消息可能被其它版本覆盖</li><li>事务包含的消息可能分布在多个Segment中（即使在同一个Partition内），当老的Segment被删除时，该事务的部分数据可能会丢失</li><li>Consumer在一个事务内可能通过seek方法访问任意Offset的消息，从而可能丢失部分消息</li><li>Consumer可能并不需要消费某一事务内的所有Partition，因此它将永远不会读取组成该事务的所有消息</li></ul><h1 id="事务机制原理"><a href="#事务机制原理" class="headerlink" title="事务机制原理"></a>事务机制原理</h1><h2 id="事务性消息传递"><a href="#事务性消息传递" class="headerlink" title="事务性消息传递"></a>事务性消息传递</h2><p>这一节所说的事务主要指原子性，也即Producer将多条消息作为一个事务批量发送，要么全部成功要么全部失败。</p><p>为了实现这一点，Kafka 0.11.0.0引入了一个服务器端的模块，名为<code>Transaction Coordinator</code>，用于管理Producer发送的消息的事务性。</p><p>该<code>Transaction Coordinator</code>维护<code>Transaction Log</code>，该log存于一个内部的Topic内。由于Topic数据具有持久性，因此事务的状态也具有持久性。</p><p>Producer并不直接读写<code>Transaction Log</code>，它与<code>Transaction Coordinator</code>通信，然后由<code>Transaction Coordinator</code>将该事务的状态插入相应的<code>Transaction Log</code>。</p><p><code>Transaction Log</code>的设计与<code>Offset Log</code>用于保存Consumer的Offset类似。</p><h2 id="事务中Offset的提交"><a href="#事务中Offset的提交" class="headerlink" title="事务中Offset的提交"></a>事务中Offset的提交</h2><p>许多基于Kafka的应用，尤其是Kafka Stream应用中同时包含Consumer和Producer，前者负责从Kafka中获取消息，后者负责将处理完的数据写回Kafka的其它Topic中。</p><p>为了实现该场景下的事务的原子性，Kafka需要保证对Consumer Offset的Commit与Producer对发送消息的Commit包含在同一个事务中。否则，如果在二者Commit中间发生异常，根据二者Commit的顺序可能会造成数据丢失和数据重复：</p><ul><li>如果先Commit Producer发送数据的事务再Commit Consumer的Offset，即<code>At Least Once</code>语义，可能造成数据重复。</li><li>如果先Commit Consumer的Offset，再Commit Producer数据发送事务，即<code>At Most Once</code>语义，可能造成数据丢失。</li></ul><h2 id="用于事务特性的控制型消息"><a href="#用于事务特性的控制型消息" class="headerlink" title="用于事务特性的控制型消息"></a>用于事务特性的控制型消息</h2><p>为了区分写入Partition的消息被Commit还是Abort，Kafka引入了一种特殊类型的消息，即<code>Control Message</code>。该类消息的Value内不包含任何应用相关的数据，并且不会暴露给应用程序。它只用于Broker与Client间的内部通信。</p><p>对于Producer端事务，Kafka以Control Message的形式引入一系列的<code>Transaction Marker</code>。Consumer即可通过该标记判定对应的消息被Commit了还是Abort了，然后结合该Consumer配置的隔离级别决定是否应该将该消息返回给应用程序。</p><h2 id="事务处理样例代码"><a href="#事务处理样例代码" class="headerlink" title="事务处理样例代码"></a>事务处理样例代码</h2><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br></pre></td><td class="code"><pre><span class="line">Producer producer = <span class="keyword">new</span> KafkaProducer(props);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 初始化事务，包括结束该Transaction ID对应的未完成的事务（如果有）</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 保证新的事务在一个正确的状态下启动</span></span><br><span class="line"></span><br><span class="line">producer.initTransactions();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 开始事务</span></span><br><span class="line"></span><br><span class="line">producer.beginTransaction();</span><br><span class="line"></span><br><span class="line"><span class="comment">// 消费数据</span></span><br><span class="line"></span><br><span class="line">ConsumerRecords records = consumer.poll(<span class="number">100</span>);</span><br><span class="line"></span><br><span class="line"><span class="keyword">try</span>&#123;</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 发送数据</span></span><br><span class="line"></span><br><span class="line"> producer.send(<span class="keyword">new</span> ProducerRecord(<span class="string">"Topic"</span>, <span class="string">"Key"</span>, <span class="string">"Value"</span>));</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 发送消费数据的Offset，将上述数据消费与数据发送纳入同一个Transaction内</span></span><br><span class="line"></span><br><span class="line"> producer.sendOffsetsToTransaction(offsets, <span class="string">"group1"</span>);</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 数据发送及Offset发送均成功的情况下，提交事务</span></span><br><span class="line"></span><br><span class="line"> producer.commitTransaction();</span><br><span class="line"></span><br><span class="line">&#125; <span class="keyword">catch</span> (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) &#123;</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 数据发送或者Offset发送出现异常时，终止事务</span></span><br><span class="line"></span><br><span class="line"> producer.abortTransaction();</span><br><span class="line"></span><br><span class="line">&#125; <span class="keyword">finally</span> &#123;</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 关闭Producer和Consumer</span></span><br><span class="line"></span><br><span class="line"> producer.close();</span><br><span class="line"></span><br><span class="line"> consumer.close();</span><br><span class="line"></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="完整事务过程"><a href="#完整事务过程" class="headerlink" title="完整事务过程"></a>完整事务过程</h2><p><a href="http://www.jasongj.com/img/kafka/KafkaColumn8/KafkaTransaction.png" target="_blank" rel="noopener"><img src="http://www.jasongj.com/img/kafka/KafkaColumn8/KafkaTransaction.png" alt="Kafka Transaction"></a></p><h3 id="找到Transaction-Coordinator"><a href="#找到Transaction-Coordinator" class="headerlink" title="找到Transaction Coordinator"></a>找到Transaction Coordinator</h3><p>由于<code>Transaction Coordinator</code>是分配PID和管理事务的核心，因此Producer要做的第一件事情就是通过向任意一个Broker发送<code>FindCoordinator</code>请求找到<code>Transaction Coordinator</code>的位置。</p><p>注意：只有应用程序为Producer配置了<code>Transaction ID</code>时才可使用事务特性，也才需要这一步。另外，由于事务性要求Producer开启幂等特性，因此通过将<code>transactional.id</code>设置为非空从而开启事务特性的同时也需要通过将<code>enable.idempotence</code>设置为true来开启幂等特性。</p><h3 id="获取PID"><a href="#获取PID" class="headerlink" title="获取PID"></a>获取PID</h3><p>找到<code>Transaction Coordinator</code>后，具有幂等特性的Producer必须发起<code>InitPidRequest</code>请求以获取PID。</p><p>注意：只要开启了幂等特性即必须执行该操作，而无须考虑该Producer是否开启了事务特性。</p><p><strong><em>如果事务特性被开启 </em></strong><br><code>InitPidRequest</code>会发送给<code>Transaction Coordinator</code>。如果<code>Transaction Coordinator</code>是第一次收到包含有该<code>Transaction ID</code>的InitPidRequest请求，它将会把该<code>&lt;TransactionID, PID&gt;</code>存入<code>Transaction Log</code>，如上图中步骤2.1所示。这样可保证该对应关系被持久化，从而保证即使<code>Transaction Coordinator</code>宕机该对应关系也不会丢失。</p><p>除了返回PID外，<code>InitPidRequest</code>还会执行如下任务：</p><ul><li>增加该PID对应的epoch。具有相同PID但epoch小于该epoch的其它Producer（如果有）新开启的事务将被拒绝。</li><li>恢复（Commit或Abort）之前的Producer未完成的事务（如果有）。</li></ul><p>注意：<code>InitPidRequest</code>的处理过程是同步阻塞的。一旦该调用正确返回，Producer即可开始新的事务。</p><p>另外，如果事务特性未开启，<code>InitPidRequest</code>可发送至任意Broker，并且会得到一个全新的唯一的PID。该Producer将只能使用幂等特性以及单一Session内的事务特性，而不能使用跨Session的事务特性。</p><h3 id="开启事务"><a href="#开启事务" class="headerlink" title="开启事务"></a>开启事务</h3><p>Kafka从0.11.0.0版本开始，提供<code>beginTransaction()</code>方法用于开启一个事务。调用该方法后，Producer本地会记录已经开启了事务，但<code>Transaction Coordinator</code>只有在Producer发送第一条消息后才认为事务已经开启。</p><h3 id="Consume-Transform-Produce"><a href="#Consume-Transform-Produce" class="headerlink" title="Consume-Transform-Produce"></a>Consume-Transform-Produce</h3><p>这一阶段，包含了整个事务的数据处理过程，并且包含了多种请求。</p><p><strong><em>AddPartitionsToTxnRequest</em></strong><br>一个Producer可能会给多个<code>&lt;Topic, Partition&gt;</code>发送数据，给一个新的<code>&lt;Topic, Partition&gt;</code>发送数据前，它需要先向<code>Transaction Coordinator</code>发送<code>AddPartitionsToTxnRequest</code>。</p><p><code>Transaction Coordinator</code>会将该<code>&lt;Transaction, Topic, Partition&gt;</code>存于<code>Transaction Log</code>内，并将其状态置为<code>BEGIN</code>，如上图中步骤4.1所示。有了该信息后，我们才可以在后续步骤中为每个<code>Topic, Partition&gt;</code>设置COMMIT或者ABORT标记（如上图中步骤5.2所示）。</p><p>另外，如果该<code>&lt;Topic, Partition&gt;</code>为该事务中第一个<code>&lt;Topic, Partition&gt;</code>，<code>Transaction Coordinator</code>还会启动对该事务的计时（每个事务都有自己的超时时间）。</p><p><strong><em>ProduceRequest</em></strong><br>Producer通过一个或多个<code>ProduceRequest</code>发送一系列消息。除了应用数据外，该请求还包含了PID，epoch，和<code>Sequence Number</code>。该过程如上图中步骤4.2所示。</p><p><strong><em>AddOffsetsToTxnRequest</em></strong><br>为了提供事务性，Producer新增了<code>sendOffsetsToTransaction</code>方法，该方法将多组消息的发送和消费放入同一批处理内。</p><p>该方法先判断在当前事务中该方法是否已经被调用并传入了相同的Group ID。若是，直接跳到下一步；若不是，则向<code>Transaction Coordinator</code>发送<code>AddOffsetsToTxnRequests</code>请求，<code>Transaction Coordinator</code>将对应的所有<code>&lt;Topic, Partition&gt;</code>存于<code>Transaction Log</code>中，并将其状态记为<code>BEGIN</code>，如上图中步骤4.3所示。该方法会阻塞直到收到响应。</p><p><strong><em>TxnOffsetCommitRequest</em></strong><br>作为<code>sendOffsetsToTransaction</code>方法的一部分，在处理完<code>AddOffsetsToTxnRequest</code>后，Producer也会发送<code>TxnOffsetCommit</code>请求给<code>Consumer Coordinator</code>从而将本事务包含的与读操作相关的各<code>&lt;Topic, Partition&gt;</code>的Offset持久化到内部的<code>__consumer_offsets</code>中，如上图步骤4.4所示。</p><p>在此过程中，<code>Consumer Coordinator</code>会通过PID和对应的epoch来验证是否应该允许该Producer的该请求。</p><p>这里需要注意：</p><ul><li>写入<code>__consumer_offsets</code>的Offset信息在当前事务Commit前对外是不可见的。也即在当前事务被Commit前，可认为该Offset尚未Commit，也即对应的消息尚未被完成处理。</li><li><code>Consumer Coordinator</code>并不会立即更新缓存中相应<code>&lt;Topic, Partition&gt;</code>的Offset，因为此时这些更新操作尚未被COMMIT或ABORT。</li></ul><h3 id="Commit或Abort事务"><a href="#Commit或Abort事务" class="headerlink" title="Commit或Abort事务"></a>Commit或Abort事务</h3><p>一旦上述数据写入操作完成，应用程序必须调用<code>KafkaProducer</code>的<code>commitTransaction</code>方法或者<code>abortTransaction</code>方法以结束当前事务。</p><p><strong><em>EndTxnRequest</em></strong><br><code>commitTransaction</code>方法使得Producer写入的数据对下游Consumer可见。<code>abortTransaction</code>方法通过<code>Transaction Marker</code>将Producer写入的数据标记为<code>Aborted</code>状态。下游的Consumer如果将<code>isolation.level</code>设置为<code>READ_COMMITTED</code>，则它读到被Abort的消息后直接将其丢弃而不会返回给客户程序，也即被Abort的消息对应用程序不可见。</p><p>无论是Commit还是Abort，Producer都会发送<code>EndTxnRequest</code>请求给<code>Transaction Coordinator</code>，并通过标志位标识是应该Commit还是Abort。</p><p>收到该请求后，<code>Transaction Coordinator</code>会进行如下操作</p><ol><li>将<code>PREPARE_COMMIT</code>或<code>PREPARE_ABORT</code>消息写入<code>Transaction Log</code>，如上图中步骤5.1所示</li><li>通过<code>WriteTxnMarker</code>请求以<code>Transaction Marker</code>的形式将<code>COMMIT</code>或<code>ABORT</code>信息写入用户数据日志以及<code>Offset Log</code>中，如上图中步骤5.2所示</li><li>最后将<code>COMPLETE_COMMIT</code>或<code>COMPLETE_ABORT</code>信息写入<code>Transaction Log</code>中，如上图中步骤5.3所示</li></ol><p>补充说明：对于<code>commitTransaction</code>方法，它会在发送<code>EndTxnRequest</code>之前先调用flush方法以确保所有发送出去的数据都得到相应的ACK。对于<code>abortTransaction</code>方法，在发送<code>EndTxnRequest</code>之前直接将当前Buffer中的事务性消息（如果有）全部丢弃，但必须等待所有被发送但尚未收到ACK的消息发送完成。</p><p>上述第二步是实现将一组读操作与写操作作为一个事务处理的关键。因为Producer写入的数据Topic以及记录Comsumer Offset的Topic会被写入相同的<code>Transactin Marker</code>，所以这一组读操作与写操作要么全部COMMIT要么全部ABORT。</p><p><strong><em>WriteTxnMarkerRequest</em></strong><br>上面提到的<code>WriteTxnMarkerRequest</code>由<code>Transaction Coordinator</code>发送给当前事务涉及到的每个<code>&lt;Topic, Partition&gt;</code>的Leader。收到该请求后，对应的Leader会将对应的<code>COMMIT(PID)</code>或者<code>ABORT(PID)</code>控制信息写入日志，如上图中步骤5.2所示。</p><p>该控制消息向Broker以及Consumer表明对应PID的消息被Commit了还是被Abort了。</p><p>这里要注意，如果事务也涉及到<code>__consumer_offsets</code>，即该事务中有消费数据的操作且将该消费的Offset存于<code>__consumer_offsets</code>中，<code>Transaction Coordinator</code>也需要向该内部Topic的各Partition的Leader发送<code>WriteTxnMarkerRequest</code>从而写入<code>COMMIT(PID)</code>或<code>COMMIT(PID)</code>控制信息。</p><p><strong><em>写入最终的<code>COMPLETE_COMMIT</code>或<code>COMPLETE_ABORT</code>消息</em></strong><br>写完所有的<code>Transaction Marker</code>后，<code>Transaction Coordinator</code>会将最终的<code>COMPLETE_COMMIT</code>或<code>COMPLETE_ABORT</code>消息写入<code>Transaction Log</code>中以标明该事务结束，如上图中步骤5.3所示。</p><p>此时，<code>Transaction Log</code>中所有关于该事务的消息全部可以移除。当然，由于Kafka内数据是Append Only的，不可直接更新和删除，这里说的移除只是将其标记为null从而在Log Compact时不再保留。</p><p>另外，<code>COMPLETE_COMMIT</code>或<code>COMPLETE_ABORT</code>的写入并不需要得到所有Rreplica的ACK，因为如果该消息丢失，可以根据事务协议重发。</p><p>补充说明，如果参与该事务的某些<code>&lt;Topic, Partition&gt;</code>在被写入<code>Transaction Marker</code>前不可用，它对<code>READ_COMMITTED</code>的Consumer不可见，但不影响其它可用<code>&lt;Topic, Partition&gt;</code>的COMMIT或ABORT。在该<code>&lt;Topic, Partition&gt;</code>恢复可用后，<code>Transaction Coordinator</code>会重新根据<code>PREPARE_COMMIT</code>或<code>PREPARE_ABORT</code>向该<code>&lt;Topic, Partition&gt;</code>发送<code>Transaction Marker</code>。</p><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><ul><li><code>PID</code>与<code>Sequence Number</code>的引入实现了写操作的幂等性</li><li>写操作的幂等性结合<code>At Least Once</code>语义实现了单一Session内的<code>Exactly Once</code>语义</li><li><code>Transaction Marker</code>与<code>PID</code>提供了识别消息是否应该被读取的能力，从而实现了事务的隔离性</li><li>Offset的更新标记了消息是否被读取，从而将对读操作的事务处理转换成了对写（Offset）操作的事务处理</li><li>Kafka事务的本质是，将一组写操作（如果有）对应的消息与一组读操作（如果有）对应的Offset的更新进行同样的标记（即<code>Transaction Marker</code>）来实现事务中涉及的所有读写操作同时对外可见或同时对外不可见</li><li>Kafka只提供对Kafka本身的读写操作的事务性，不提供包含外部系统的事务性</li></ul><h1 id="异常处理"><a href="#异常处理" class="headerlink" title="异常处理"></a>异常处理</h1><h2 id="Exception处理"><a href="#Exception处理" class="headerlink" title="Exception处理"></a>Exception处理</h2><p><strong><em>InvalidProducerEpoch</em></strong><br>这是一种Fatal Error，它说明当前Producer是一个过期的实例，有<code>Transaction ID</code>相同但epoch更新的Producer实例被创建并使用。此时Producer会停止并抛出Exception。</p><p><strong><em>InvalidPidMapping</em></strong><br><code>Transaction Coordinator</code>没有与该<code>Transaction ID</code>对应的PID。此时Producer会通过包含有<code>Transaction ID</code>的<code>InitPidRequest</code>请求创建一个新的PID。</p><p><strong><em>NotCorrdinatorForGTransactionalId</em></strong><br>该<code>Transaction Coordinator</code>不负责该当前事务。Producer会通过<code>FindCoordinatorRequest</code>请求重新寻找对应的<code>Transaction Coordinator</code>。</p><p><strong><em>InvalidTxnRequest</em></strong><br>违反了事务协议。正确的Client实现不应该出现这种Exception。如果该异常发生了，用户需要检查自己的客户端实现是否有问题。</p><p><strong><em>CoordinatorNotAvailable</em></strong><br><code>Transaction Coordinator</code>仍在初始化中。Producer只需要重试即可。</p><p><strong><em>DuplicateSequenceNumber</em></strong><br>发送的消息的序号低于Broker预期。该异常说明该消息已经被成功处理过，Producer可以直接忽略该异常并处理下一条消息</p><p><strong><em>InvalidSequenceNumber</em></strong><br>这是一个Fatal Error，它说明发送的消息中的序号大于Broker预期。此时有两种可能</p><ul><li>数据乱序。比如前面的消息发送失败后重试期间，新的消息被接收。正常情况下不应该出现该问题，因为当幂等发送启用时，<code>max.inflight.requests.per.connection</code>被强制设置为1，而<code>acks</code>被强制设置为all。故前面消息重试期间，后续消息不会被发送，也即不会发生乱序。并且只有ISR中所有Replica都ACK，Producer才会认为消息已经被发送，也即不存在Broker端数据丢失问题。</li><li>服务器由于日志被Truncate而造成数据丢失。此时应该停止Producer并将此Fatal Error报告给用户。</li></ul><p><strong><em>InvalidTransactionTimeout</em></strong><br><code>InitPidRequest</code>调用出现的Fatal Error。它表明Producer传入的timeout时间不在可接受范围内，应该停止Producer并报告给用户。</p><h2 id="处理Transaction-Coordinator失败"><a href="#处理Transaction-Coordinator失败" class="headerlink" title="处理Transaction Coordinator失败"></a>处理<code>Transaction Coordinator</code>失败</h2><h3 id="写PREPARE-COMMIT-PREPARE-ABORT前失败"><a href="#写PREPARE-COMMIT-PREPARE-ABORT前失败" class="headerlink" title="写PREPARE_COMMIT/PREPARE_ABORT前失败"></a>写<code>PREPARE_COMMIT/PREPARE_ABORT</code>前失败</h3><p>Producer通过<code>FindCoordinatorRequest</code>找到新的<code>Transaction Coordinator</code>，并通过<code>EndTxnRequest</code>请求发起<code>COMMIT</code>或<code>ABORT</code>流程，新的<code>Transaction Coordinator</code>继续处理<code>EndTxnRequest</code>请求——写<code>PREPARE_COMMIT</code>或<code>PREPARE_ABORT</code>，写<code>Transaction Marker</code>，写<code>COMPLETE_COMMIT</code>或<code>COMPLETE_ABORT</code>。</p><h3 id="写完PREPARE-COMMIT-PREPARE-ABORT后失败"><a href="#写完PREPARE-COMMIT-PREPARE-ABORT后失败" class="headerlink" title="写完PREPARE_COMMIT/PREPARE_ABORT后失败"></a>写完<code>PREPARE_COMMIT/PREPARE_ABORT</code>后失败</h3><p>此时旧的<code>Transaction Coordinator</code>可能已经成功写入部分<code>Transaction Marker</code>。新的<code>Transaction Coordinator</code>会重复这些操作，所以部分Partition中可能会存在重复的<code>COMMIT</code>或<code>ABORT</code>，但只要该Producer在此期间没有发起新的事务，这些重复的<code>Transaction Marker</code>就不是问题。</p><h3 id="写完COMPLETE-COMMIT-ABORT后失败"><a href="#写完COMPLETE-COMMIT-ABORT后失败" class="headerlink" title="写完COMPLETE_COMMIT/ABORT后失败"></a>写完<code>COMPLETE_COMMIT/ABORT</code>后失败</h3><p>旧的<code>Transaction Coordinator</code>可能已经写完了<code>COMPLETE_COMMIT</code>或<code>COMPLETE_ABORT</code>但在返回<code>EndTxnRequest</code>之前失败。该场景下，新的<code>Transaction Coordinator</code>会直接给Producer返回成功。</p><h2 id="事务过期机制"><a href="#事务过期机制" class="headerlink" title="事务过期机制"></a>事务过期机制</h2><h3 id="事务超时"><a href="#事务超时" class="headerlink" title="事务超时"></a>事务超时</h3><p><code>transaction.timeout.ms</code></p><h3 id="终止过期事务"><a href="#终止过期事务" class="headerlink" title="终止过期事务"></a>终止过期事务</h3><p>当Producer失败时，<code>Transaction Coordinator</code>必须能够主动的让某些进行中的事务过期。否则没有Producer的参与，<code>Transaction Coordinator</code>无法判断这些事务应该如何处理，这会造成：</p><ul><li>如果这种进行中事务太多，会造成<code>Transaction Coordinator</code>需要维护大量的事务状态，大量占用内存</li><li><code>Transaction Log</code>内也会存在大量数据，造成新的<code>Transaction Coordinator</code>启动缓慢</li><li><code>READ_COMMITTED</code>的Consumer需要缓存大量的消息，造成不必要的内存浪费甚至是OOM</li><li>如果多个<code>Transaction ID</code>不同的Producer交叉写同一个Partition，当一个Producer的事务状态不更新时，<code>READ_COMMITTED</code>的Consumer为了保证顺序消费而被阻塞</li></ul><p>为了避免上述问题，<code>Transaction Coordinator</code>会周期性遍历内存中的事务状态Map，并执行如下操作</p><ul><li>如果状态是<code>BEGIN</code>并且其最后更新时间与当前时间差大于<code>transaction.remove.expired.transaction.cleanup.interval.ms</code>（默认值为1小时），则主动将其终止：1）未避免原Producer临时恢复与当前终止流程冲突，增加该Producer对应的PID的epoch，并确保将该更新的信息写入<code>Transaction Log</code>；2）以更新后的epoch回滚事务，从而使得该事务相关的所有Broker都更新其缓存的该PID的epoch从而拒绝旧Producer的写操作</li><li>如果状态是<code>PREPARE_COMMIT</code>，完成后续的COMMIT流程————向各<code>&lt;Topic, Partition&gt;</code>写入<code>Transaction Marker</code>，在<code>Transaction Log</code>内写入<code>COMPLETE_COMMIT</code></li><li>如果状态是<code>PREPARE_ABORT</code>，完成后续ABORT流程</li></ul><h3 id="终止Transaction-ID"><a href="#终止Transaction-ID" class="headerlink" title="终止Transaction ID"></a>终止<code>Transaction ID</code></h3><p>某<code>Transaction ID</code>的Producer可能很长时间不再发送数据，<code>Transaction Coordinator</code>没必要再保存该<code>Transaction ID</code>与<code>PID</code>等的映射，否则可能会造成大量的资源浪费。因此需要有一个机制探测不再活跃的<code>Transaction ID</code>并将其信息删除。</p><p><code>Transaction Coordinator</code>会周期性遍历内存中的<code>Transaction ID</code>与<code>PID</code>映射，如果某<code>Transaction ID</code>没有对应的正在进行中的事务并且它对应的最后一个事务的结束时间与当前时间差大于<code>transactional.id.expiration.ms</code>（默认值是7天），则将其从内存中删除并在<code>Transaction Log</code>中将其对应的日志的值设置为null从而使得Log Compact可将其记录删除。</p><h1 id="与其它系统事务机制对比"><a href="#与其它系统事务机制对比" class="headerlink" title="与其它系统事务机制对比"></a>与其它系统事务机制对比</h1><h2 id="PostgreSQL-MVCC"><a href="#PostgreSQL-MVCC" class="headerlink" title="PostgreSQL MVCC"></a>PostgreSQL MVCC</h2><p>Kafka的事务机制与《<a href="http://www.jasongj.com/sql/mvcc/" target="_blank" rel="noopener">MVCC PostgreSQL实现事务和多版本并发控制的精华</a>》一文中介绍的PostgreSQL通过MVCC实现事务的机制非常类似，对于事务的回滚，并不需要删除已写入的数据，都是将写入数据的事务标记为Rollback/Abort从而在读数据时过滤该数据。</p><h2 id="两阶段提交"><a href="#两阶段提交" class="headerlink" title="两阶段提交"></a>两阶段提交</h2><p>Kafka的事务机制与《<a href="http://www.jasongj.com/big_data/two_phase_commit/#%E4%B8%A4%E9%98%B6%E6%AE%B5%E6%8F%90%E4%BA%A4%E5%8E%9F%E7%90%86" target="_blank" rel="noopener">分布式事务（一）两阶段提交及JTA</a>》一文中所介绍的两阶段提交机制看似相似，都分PREPARE阶段和最终COMMIT阶段，但又有很大不同。</p><ul><li>Kafka事务机制中，PREPARE时即要指明是<code>PREPARE_COMMIT</code>还是<code>PREPARE_ABORT</code>，并且只须在<code>Transaction Log</code>中标记即可，无须其它组件参与。而两阶段提交的PREPARE需要发送给所有的分布式事务参与方，并且事务参与方需要尽可能准备好，并根据准备情况返回<code>Prepared</code>或<code>Non-Prepared</code>状态给事务管理器。</li><li>Kafka事务中，一但发起<code>PREPARE_COMMIT</code>或<code>PREPARE_ABORT</code>，则确定该事务最终的结果应该是被<code>COMMIT</code>或<code>ABORT</code>。而分布式事务中，PREPARE后由各事务参与方返回状态，只有所有参与方均返回<code>Prepared</code>状态才会真正执行COMMIT，否则执行ROLLBACK</li><li>Kafka事务机制中，某几个Partition在COMMIT或ABORT过程中变为不可用，只影响该Partition不影响其它Partition。两阶段提交中，若唯一收到COMMIT命令参与者Crash，其它事务参与方无法判断事务状态从而使得整个事务阻塞</li><li>Kafka事务机制引入事务超时机制，有效避免了挂起的事务影响其它事务的问题</li><li>Kafka事务机制中存在多个<code>Transaction Coordinator</code>实例，而分布式事务中只有一个事务管理器</li></ul><h2 id="Zookeeper"><a href="#Zookeeper" class="headerlink" title="Zookeeper"></a>Zookeeper</h2><p>Zookeeper的原子广播协议与两阶段提交以及Kafka事务机制有相似之处，但又有各自的特点</p><ul><li>Kafka事务可COMMIT也可ABORT。而Zookeeper原子广播协议只有COMMIT没有ABORT。当然，Zookeeper不COMMIT某消息也即等效于ABORT该消息的更新。</li><li>Kafka存在多个<code>Transaction Coordinator</code>实例，扩展性较好。而Zookeeper写操作只能在Leader节点进行，所以其写性能远低于读性能。</li><li>Kafka事务是COMMIT还是ABORT完全取决于Producer即客户端。而Zookeeper原子广播协议中某条消息是否被COMMIT取决于是否有一大半FOLLOWER ACK该消息。</li></ul><h1 id="Kafka系列文章"><a href="#Kafka系列文章" class="headerlink" title="Kafka系列文章"></a>Kafka系列文章</h1><ul><li><a href="http://www.jasongj.com/2015/03/10/KafkaColumn1/" target="_blank" rel="noopener">Kafka设计解析（一）- Kafka背景及架构介绍</a></li><li><a href="http://www.jasongj.com/2015/04/24/KafkaColumn2/" target="_blank" rel="noopener">Kafka设计解析（二）- Kafka High Availability （上）</a></li><li><a href="http://www.jasongj.com/2015/06/08/KafkaColumn3/" target="_blank" rel="noopener">Kafka设计解析（三）- Kafka High Availability （下）</a></li><li><a href="http://www.jasongj.com/2015/08/09/KafkaColumn4/" target="_blank" rel="noopener">Kafka设计解析（四）- Kafka Consumer设计解析</a></li><li><a href="http://www.jasongj.com/2015/12/31/KafkaColumn5_kafka_benchmark/" target="_blank" rel="noopener">Kafka设计解析（五）- Kafka性能测试方法及Benchmark报告</a></li><li><a href="http://www.jasongj.com/kafka/high_throughput/" target="_blank" rel="noopener">Kafka设计解析（六）- Kafka高性能架构之道</a></li><li><a href="http://www.jasongj.com/kafka/kafka_stream/" target="_blank" rel="noopener">Kafka设计解析（七）- Kafka Stream</a></li><li><a href="http://www.jasongj.com/kafka/transaction/" target="_blank" rel="noopener">Kafka设计解析（八）- Kafka Exactly Once语义与事务机制原理</a></li></ul>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;本文介绍了Kafka实现事务性的几个阶段——正好一次语义与原子操作。之后详细分析了Kafka事务机制的实现原理，并介绍了Kafka如何处理事务相关的异常情况，如Transaction Coordinator宕机。最后介绍了Kafka的事务机制与PostgreSQL的MVCC以及Zookeeper的原子广播实现事务的异同&lt;br&gt;
    
    </summary>
    
      <category term="kafka" scheme="http://crazycarry.github.io/categories/kafka/"/>
    
    
      <category term="架构设计" scheme="http://crazycarry.github.io/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/"/>
    
      <category term="源码分析" scheme="http://crazycarry.github.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"/>
    
      <category term="消息中间件" scheme="http://crazycarry.github.io/tags/%E6%B6%88%E6%81%AF%E4%B8%AD%E9%97%B4%E4%BB%B6/"/>
    
      <category term="大数据" scheme="http://crazycarry.github.io/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
    
  </entry>
  
  <entry>
    <title>Kafka消费者重新实现的细节</title>
    <link href="http://crazycarry.github.io/2018/04/04/kafka-consume-detail/"/>
    <id>http://crazycarry.github.io/2018/04/04/kafka-consume-detail/</id>
    <published>2018-04-04T11:20:41.000Z</published>
    <updated>2018-04-04T11:30:45.884Z</updated>
    
    <content type="html"><![CDATA[<p>上一章分析的消费者高级API使用ConsumerGroup的语义管理多个消费者，但是在消费者或者Partition发生变化时都需要rebalance，它的实现对ZooKeeper依赖比较严重，<br>由Kafka内置实现了失败检测和Rebalance(ZKRebalancerListener)，但是它存在羊群效应和脑裂的问题，客户端代码实现低级API也不能解决这个问题。如果将失败探测和Rebalance的逻辑放到一个高可用的中心Coordinator，这两个问题即可解决。同时还可大大减少Zookeeper的负载，有利于Kafka Broker的扩展(Broker也会作为协调节点的角色存在)。<br><a id="more"></a></p><p>协调节点在前面分析Consumer的Offset(fetchOffsets和commitOffsets)分析过GroupCoordinator的处理逻辑。不过新消费者KafkaConsumer有自己的协调者ConsumerCoordinator。高级API使用ZookeeperConsumerConnector，其中的offset相关fetch和commit API，以及数据抓取线程对于新消费者都需要重新实现，ConsumerCoordinator作为新消费者KafkaConsumer的一部分用java代码重新实现了这些API。服务端的GroupCoordinator对新旧API都适用的。</p><p><a href="http://img.blog.csdn.net/20160228101305540" title="k_consumer_new_old" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160228101305540" alt="" title="k_consumer_new_old"></a></p><p>ConsumerCoordinator是KafkaConsumer的一个成员变量，所以每个消费者都要自己的ConsumerCoordinator，消费者的ConsumerCoordintor只是和服务端的GroupCoordinator通信的介质，下文中提到的<strong>协调者一般指的是服务端的GroupCoordinator</strong>。每个KafkaServer都有一个GroupCoordinator实例，服务端的GroupCoordinator管理消费组成员和offset，它可以管理多个消费组（因为Broker本身即使存储一个topic的消息，也可以被不同的消费组订阅）。注意：组成员的状态管理（比如GroupMetadata）是在服务端的GroupCoordinator完成的，而不是由消费组的ConsumerCoordinator完成（因为消费者只能看到自己的，无法看到和自己同组的其他成员）。</p><h3 id="消费组管理协议"><a href="#消费组管理协议" class="headerlink" title="消费组管理协议"></a>消费组管理协议</h3><p>在同一个消费组里多个consumer实例需要进行平衡操作。消费组会注册感兴趣的topics。这个消费组中的所有消费者会互相协调，每个消费者会互相拥有独一的partition集合。即同一个partition只会分配给消费组中的一个消费者。一个消费者可以有多个partition。当消费组成功平衡后，所有注册的topics的每个partition都会被唯一的消费者拥有(每个partition都会被分配给消费者去消费)。每个Broker节点会被选举为一部分消费组的协调节点，消费组的协调节点负责在组成员变化，或者注册的topics的partition变化时进行协调。协调节点同时负责在平衡操作时，将partition的所有权配置信息(partition分配给哪个消费者)在所有consumers之间进行交流。</p><p><strong>Consumer消费者的工作过程：</strong></p><ul><li>1.在启动时或者协调节点故障转移时，消费者发送ConsumerMetadataRequest给bootstrap brokers列表中的任意一个brokers。在ConsumerMetadataResponse中，它接收消费者对应的消费组所属的协调节点的位置信息。</li><li>2.消费者连接协调节点，并发送HeartbeatRequest。如果返回的HeartbeatResponse中返回IllegalGeneration错误码，说明协调节点已经在初始化平衡。消费者就会停止抓取数据，提交offsets，发送JoinGroupRequest给协调节点。在JoinGroupResponse，它接收消费者应该拥有的topic-partitions列表以及当前消费组的新的generation编号。这个时候消费组管理已经完成，消费者就可以开始抓取数据，并为它拥有的partitions提交offsets。</li><li>3.如果HeartbeatResponse没有错误返回，消费者会从它上次拥有的partitions列表继续抓取数据，这个过程是不会被中断的。</li></ul><p><a href="http://img.blog.csdn.net/20160228101325759" title="k_consumer_flow" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160228101325759" alt="" title="k_consumer_flow"></a></p><p><strong>Co-ordinator协调节点的工作过程：</strong></p><ul><li>1.在稳定状态下，协调节点通过<code>故障检测协议</code>跟踪每个消费组中每个消费者的健康状况。</li><li>2.在选举和启动时，协调节点读取它管理的消费组列表，以及从ZK中读取每个消费组的成员信息。如果之前没有成员信息，它不会做任何动作。只有在同一个消费组的第一个消费者注册进来时，协调节点才开始工作(即开始加载消费组的消费者成员信息)。</li><li>3.当协调节点完全加载完它所负责的消费组列表的所有组成员之前，它会在以下几种请求的响应中返回CoordinatorStartupNotComplete错误码：HeartbeatRequest，OffsetCommitRequest，JoinGroupRequest。这样消费者就会过段时间重试(直到完全加载，没有错误码返回为止)。</li><li>4.在选举或启动时，协调节点会对消费组中的所有消费者进行故障检测。根据故障检测协议被协调节点标记为Dead的消费者会从消费组中移除，这个时候协调节点会为Dead的消费者所属的消费组触发一个平衡操作(消费者Dead之后，这个消费者拥有的partition需要平衡给其他消费者)。</li><li>5.当HeartbeatResponse返回IllegalGeneration错误码，就会触发平衡操作。一旦所有存活的消费者通过JoinGroupRequests重新注册到协调节点，协调节点会将最新的partition所有权信息在JoinGroupResponse的每个消费者之间通信(同步)，然后就完成了平衡操作。</li><li>6.协调节点会跟踪任何一个消费者已经注册的topics的topic-partition的变更。如果它检测到某个topic新增的partition，就会触发平衡操作。当创建一个新的topics也会触发平衡操作，因为消费者可以在topic被创建之前就注册它感兴趣的topics。</li></ul><p>从上面两者的工作过程，我们大致知道了协调节点负责管理消费组中的消费者。而消费者会和协调节点通信。如果协调节点发生故障转移，则消费者需要寻找新的协调节点。如果协调节点检测到消费者发生了故障，则协调节点负责平衡操作。</p><h3 id="故障检测协议"><a href="#故障检测协议" class="headerlink" title="故障检测协议"></a>故障检测协议</h3><p>消费者在加入到消费组时，发送给协调者的JoinGroupRequest设置了session timeout。当消费者成功加入到消费组后，在消费者和协调者都会开始故障检测流程。消费者启动周期性的心跳(发送HeartbeatRequest)，每隔session.timeout.ms/heartbeat.frequency发送给协调者并等待响应。</p><ul><li>session.timeout 会话超时的最大时间，超过这个时间，消费者和协调者都会认为对方挂掉了</li><li>heartbeart.frequency 心跳频率，时间除于次数表示每一次心跳的时间间隔，间隔越短越容易发生rebalance</li></ul><p>如果协调者在session.timeout没有收到消费者的心跳请求，它会标记消费者为死亡状态。同样如果消费者在session.timeout内没有收到心跳响应，它会假设协调者挂掉了，消费者会启动重新发现协调者的流程(每个协调者只管理一部分消费者，一个消费者只被一个协调者管理，协调者是brokers中的一个)。</p><p>heartbeat.frequency(心跳频率)是消费者端的配置，它决定了消费者发送一次心跳给协调者的时间间隔。这个值是rebalance延迟的最低临界值，因为协调者是根据心跳响应通知消费者，进行rebalance操作的。(为什么心跳频率和rebalance有关，因为心跳和session.timeout有关，超时后会触发rebalance)。所以如果session.timeout.ms设置的非常大时，也要将心跳频率设置为相对有意义的比较大的值。当然也不能将心跳频率设置的太高(结果是心跳时间间隔很短)，导致brokers的负载太重了。</p><ul><li>1.在接收到ConsumerMetadataResponse或JoinGroupResponse后，消费者周期性地发送HeartbeatRequest给协调者。</li><li>2.协调者在收到HeartbeatRequesst时，首先检查generation id，消费者编号和消费组。如果消费者指定了一个无效或过期的generation id，协调者会发送带有IllegalGeneration错误码的HeartbeatResponse给消费者。</li><li>3.如果协调者在session timeout没有收到消费者的心跳请求，标记消费者挂掉，并触发消费组的rebalance流程。</li><li>4.如果消费者在session timeout没有收到协调者的心跳响应，认为协调者失败，并触发重新发现协调者的流程。</li></ul><p>当协调者发生故障时，消费者发现新的协调者的顺序可能发生在新的协调者完成故障处理(包括从zk中加载消费组元数据等)之前或之后。如果在完成故障处理之后才发现新的协调者，新的协调者就会像之前一样接收消费者的心跳请求。而如果是在之前，新的协调者则会拒绝消费者的心跳请求，会导致消费者重新发现协调者，并重新连接协调者。如果消费者太晚连接新的协调者，协调者可能会标记消费者挂掉了，消费者再次加入时，会认为这是一个新的消费者，并触发rebalance。</p><blockquote><p>消费者发现新的协调者(co-ordinator re-discovery)，包括两个步骤，首先确定新的协调者，然后消费者连接协调者。如果新的协调者确定了，并且消费者成功连接上新协调者，这样消费者发送的心跳请求就会被新的协调者正常接收。但是如果新协调者已经确定，而消费者并没有连接上新的协调者，消费者发送的心跳请求并不会被接收：因为连接都还没有建立!</p></blockquote><h3 id="状态图"><a href="#状态图" class="headerlink" title="状态图"></a>状态图</h3><p><strong>消费者状态机</strong></p><p><a href="https://cwiki.apache.org/confluence/download/attachments/38570548/Consumer%20state%20diagram.jpg?version=10&amp;modificationDate=1400109502000&amp;api=v2" title="consumer-state" target="_blank" rel="noopener"><img src="https://cwiki.apache.org/confluence/download/attachments/38570548/Consumer%20state%20diagram.jpg?version=10&amp;modificationDate=1400109502000&amp;api=v2" alt="" title="consumer-state"></a></p><ul><li><code>Down</code>：消费者进程挂掉了。</li><li><code>Start up &amp; discover co-ordinator</code>：在这个状态时，消费者为所属的组发现协调者。消费者一旦发现协调者后就会发送JoinGroupRequest(没有consumer id信息，表示消费组)。如果同一组中的其他消费者指定了和当前消费者存在冲突的partition分配策略。当前消费者就可能接收到InconsistentPartitioningStrategy错误码的响应。如果策略名称不被Brokers识别，会收到UnknownPartitioningStrategy错误码。这种情况消费者无法加入到消费组。</li><li><code>Part of a group</code>：如果收到的JoinGroupResponse没有错误码，有consumer id以及为整个组生成的generation id。消费者就会成为组的一个成员。这个状态下，消费者会发送HeartbeatRequest，根据心跳响应结果的错误码，它可以继续在当前状态，或者移动到Stopped Consumption或者Rediscover co-ordinator的状态。</li><li><code>Re-discover co-ordinator</code>：这个状态下，消费者并没有停止消费，但是会发送GroupCoordinator来尝试重新发现协调者，并且等待响应，直到收到没有错误码的响应(响应结果中会返回新发现的协调者)。</li><li><code>Stopped consumption</code>：消费者停止消费消息，然后提交offset，直到重新加入消费组中才会继续开始消费消息。</li></ul><p><strong>协调者状态机</strong></p><p><a href="https://cwiki.apache.org/confluence/download/attachments/38570548/Coordinator%20state%20diagram.jpg?version=5&amp;modificationDate=1399498790000&amp;api=v2" title="coordinator-state" target="_blank" rel="noopener"><img src="https://cwiki.apache.org/confluence/download/attachments/38570548/Coordinator%20state%20diagram.jpg?version=5&amp;modificationDate=1399498790000&amp;api=v2" alt="" title="coordinator-state"></a></p><ul><li><code>Down</code>：协调者进程挂掉了</li><li><code>Catch up</code>：协调者被选举出来了，但还还没有开始提供服务</li><li><code>Ready</code>：新选举出来的协调者已经完成加载它所负责的消费组的组元数据</li><li><code>Prepare for rebalance</code>：协调者发送IllegalGeneration的心跳响应给组中的所有消费者，并等待消费者发送JoinGroupRequest</li><li><code>Rebalancing</code>：协调者当前generation中收到消费者的JoinGroupRequest，然后增加group的generation id，并且为请求的消费者分配consumer ids(下面说到分配过程)，以及完成partition的分配(将partiton分配给消费者)</li><li><code>Steady</code>：协调者接受每个消费组的所有消费者发送的OffsetCommitRequest和心跳信息。</li></ul><h3 id="Consumer-id的分配"><a href="#Consumer-id的分配" class="headerlink" title="Consumer id的分配"></a>Consumer id的分配</h3><ul><li>1.消费者启动后，从协调者接收到的第一次JoinGroupResponse中有consumer id。从这里开始，消费者的每次心跳以及提交offset请求都必须要包含这个consumer id(作为一种标识，比如员工入职后分配了胸卡，你以后上班就都要佩戴胸卡了)。如果协调者收到的HeartbeatRequest和OffsetCommitRequest其中的consumer id和组中的任何一个consumer ids都不同，协调者就会在对应的响应信息中发送带有UnknownConsumer错误码的响应给发起请求的消费者。</li><li>2.协调者在成功rebalance时，会为消费者分配一个consumer id，返回在JoinGroupResponse中返回给消费者。消费者可以选择在接下来的JoinGroupRequest中包含这个id，直到消费者被关闭或者挂掉了。带上id的好处是可以降低rebalance操作的延迟，当rebalance触发时，协调者会等待在上一个generation id的所有消费者发送JoinGroupRequest。协调者定位一个消费者是通过它的consumer id。如果消费者选择不带consumer id的JoinGroupRequest，协调者只能等待完全的session timeout才能继续剩下的rebalance操作。这是因为没有办法将不带consumer id的JoinGroupRequest和一个不存在的consumer id的消费者映射起来(请求中没有带consumer id就没办法确定consumer id是否存在，因为无法比较)。而如果(每个)消费者发送的JoinGroupRequest带了consumer id，协调者就能立即确定这个消费者是不是存在，并且能在所有已知的消费者都发送JoinGroupRequest后，完成本次rebalance操作(而不需要等待session timeout才最终完成)。</li><li>3.协调者会在接收到一个消费组中所有存在的消费者发送了一个JoinGroupRequest之后开始分配consumer id。它会为JoinGroupRequest中没有consumer id的每个消费者分配新的group-uuid。前提是这样的消费者是刚刚启动的或者没有选择发送之前分配给它的consumer id。</li><li>4.如果消费者发送的JoinGroupRequest带了consumer id，但是不匹配当前组成员的ids，协调者会在JoinGroupResponse中返回UnknownConsumer错误码，避免这个消费者加入到不认识的消费组中。这也不会触发组中其他消费者的rebalance操作。</li></ul><h3 id="协议格式"><a href="#协议格式" class="headerlink" title="协议格式"></a>协议格式</h3><p>对于每个消费组，协调者会存储以下信息：<br>1) 对每个存在的topic，可以有多个消费组订阅同一个topic(对应消息系统中的广播)<br>2) 对每个消费组，元数据如下：</p><ul><li>消费组订阅的topics列表</li><li>Group配置信息，包括session timeout等</li><li>组中每个消费者的元数据。消费者元数据包括主机名，consumer id</li><li>每个正在消费的topic partition的当前offsets</li><li>Partition的ownership元数据，包括consumer到分配给消费者的partitions映射</li></ul><p><a href="http://img.blog.csdn.net/20160224164856609" title="k_protocol" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160224164856609" alt="" title="k_protocol"></a></p><h3 id="其他故障场景"><a href="#其他故障场景" class="headerlink" title="其他故障场景"></a>其他故障场景</h3><p><strong>协调者故障或者到连接协调者失败</strong></p><ul><li>1.协调者发生故障时，控制器会为受到影响的消费组子集选举出新的leader/协调者。作为成为offset-topic-partitions的leader，协调者从zookeeper中读取它负责的每个消费组的元数据。每个消费组的元数据包括了group的consumer ids，generation id，订阅的topics列表。在协调者从zk中读取所有的元数据之前，发送给消费者的心跳响应带有CoordinatorStartupNotComplete错误码。在这段时间如果消费者发送JoinGroupRequest是不合法的，此时返回消费者的错误码是IllegalProtocolState。</li><li>2。Broker发送UpdateMetadataRequest给Controller，它在接收到更新的group metadata之前，如果消费者发送了ConsumerMetadataRequest给这个Broker，响应结果会返回协调者过期的信息。这种情况下，消费者发送的心跳和offset提交就会收到错误为NotCoordinatorForGroup的响应结果。所以消费者应该退回重来，即重新发送ConsumerMetadataRequest(确保在update group metadata之后)。</li></ul><p><strong>订阅的topics的partition变化</strong></p><ul><li>1.消费组对应的协调者负责检测订阅的topics的partitions数量的变化，一旦partitions数量发生变化。</li><li>2.协调者标记消费组准备rebalance，此时如果消费者有心跳，返回IllegalGeneration错误码(因为即将新一轮的平衡)，同时消费者会停止抓取数据(平衡要开始了，大家不要拿数据)，并提交offset(先保存下状态)，然后发送JoinGroupRequest给协调者。</li><li>3.协调者等待这个组中所有的消费者都给它发送了JoinGroupRequest(大家都签到后才能开始哈)，然后会在zk中增加group的generation id(通知zk现在进入了一个新纪元)，计算新的partition分配(为每个人都重新分口粮)，最后在JoinGroupResponse中返回更新的partition分配信息，以及新的generation id(通知消费者完成了)。注意即使组成员没有变化，generation也会增加，即每次发生rebalance都会增加generation id(类似zk的epoch)。</li><li>4.消费者收到JoinGroupResponse，它会在本地存储generation id和自己的consumer id，然后为返回的重新分配到的partitions开始抓取数据。在这之后消费者发送给协调者的请求会使用这个新的generation id以及consumer id，这两个id都是上一次的JoinGroupResponse的返回信息。</li></ul><p><strong>在rebalance时的offset提交</strong></p><p>上面我们看到消费组开始rebalance时，消费者会停止抓取数据，提交offset。其中提交offsets是为了保存状态信息。</p><ul><li>1.如果消费者收到IllegalGeneration错误码(表示当前组正在rebalance)，它会在发送JoinGroupRequest给协调者之前停止抓取数据，并提交已经存在的offsets(发送JoinGroupRequest是rebalance的一部分工作，而停止抓取则是前提条件)。</li><li>2.协调者会检查OffsetCommitRequest中的generation id，如果请求中的generation id比协调者的值要高就会被拒绝。</li><li>3.协调者不允许消费者发送的OffsetCommitRequest中的generation ids比zk中当前组的generation id要旧。在rebalance时该约束没有问题，因为在所有消费者发送JoinGroupRequest之前，协调者不会增加zk中group的generation id。当协调者增加了generation id之后，在还没有发送JoinGroupResoponse之前，协调者并不期望收到OffsetCommitRequest(在当前最新的generation id里，因为还没有返回响应，组中任何消费者都不会发送最新generation的offset commit请求)。所以消费者发送的每个OffsetCommitRequest应该总是和协调者的当前generation id是匹配的。</li><li>4.当消费者遇到软件问题而失败，比如在协调者进行rebalance时，消费者发生了长时间的GC停顿，如果消费者停顿时间超过session timeout，协调者在session timeout时间内就不会收到消费者发送的JoinGroupRequest请求，会标记消费者挂掉。</li></ul><p><strong>在rebalance时的heartbeats</strong></p><ul><li>1.消费者每隔session.timeout.ms/heartbeat.frequency时间就周期性地发送心跳给协调者。如果消费者在心跳响应中收到IllegalGeneration错误码，它会停止抓取，然后提交offset，并向协调者发送JoinGroupRequest。在消费者收到JoinGroupResponse之前，它不会再向协调者发送任何的心跳请求。</li><li>2.设置更高的心跳频率可以确保更低延迟的rebalance操作(因为时间间隔变小，而rebalance是根据这个间隔而触发的)，因为协调者只有在HeartbeatResponse时才有可能触发消费者的rebalance操作(收到心跳响应后加入组就正式开始rebalance)。</li><li>3.当协调者收到消费者发送的JoinGroupRequest，在返回JoinGroupResponse给消费者之前，协调者会暂停对这个消费者的故障检测。当协调者把JoinGroupResponse发送出去时，就重新启动心跳计时器，如果在又一次的session timeout时间内没有收到这个消费者的心跳请求会标记这个消费者为Dead(即从JoinGroupResponse发送出去开始计时，在session timeout收到心跳请求才认为消费者正常)。协调者在rebalance时依赖于心跳而停止故障检测是由broker的socket server设计而决定的(协调者也是一个broker)。kafka只允许broker针对每个客户端一次只能读取或者处理一个未完成的请求(这是保证有序处理的简单做法)。这是为了防止对同一个客户端，消费者和broker同时处理心跳请求和join group请求。根据JoinGroupRequest来标记失败，防止协调者在rebalance操作时就将消费者标记为Dead。注意如果消费者在rebalance时遇到软件的问题而停顿，并不会阻碍rebalance操作的完成。如果消费者在发送JoinGroupRequest之前发生停顿，协调者会标记它Dead，然后完成rebalance操作，在新的generation中只包括其他的消费者(失联的那个消费者当然不会被包括在本次generation中了) 。如果消费者在发送JoinGroupRequest之后发生停顿，协调者在假设rebalance操作成功完成的情况下(这里generation包括了消费者)仍然会向它发送JoinGroupResponse，并且重新开始心跳计时器。如果消费者在session timeout之前就恢复了，它会和往常一样消费。如果在session timeout之后还处于停顿状态，它就会被协调者标记为Dead，然后又触发了一次rebalance操作。</li><li>4.协调者只在JoinGroupRequest中返回新的generation id和consumer id。一旦消费者接收到JoinGroupResponse，消费者在下一次发送HeartbeatRequest时附带上新的generation id和consumer id发送给协调者。</li></ul><p><strong>在rebalance时的协调者故障</strong></p><p>rebalance操作会有多个阶段：</p><ul><li>1.协调者收到rebalance的通知-可能在zk监视到topic/partition发生变化，新消费者注册，或者旧消费者挂掉。</li><li>2.协调者初始化rebalance操作，通过发送带有IllegalGeneration错误码的心跳响应给消费者(消费者发送了心跳请求)。</li><li>3.消费者发送JoinGroupRequest请求给协调者(在接收到心跳响应之后)。</li><li>4.协调者增加了zk中消费组的generation id，并在zk中写入新的partition ownership信息。</li><li>5.协调者发送JoinGroupResponse给消费者。</li></ul><p>协调者可能在上面任何一个步骤失败，下面讨论了在每个步骤如果协调者失败了是怎么处理的。</p><ul><li>1.协调者在步骤1失败：协调者在收到通知后，但是还没有机会做出反应就失败了，新的协调者为了完成故障处理需要有能力检测什么时候需要rebalance操作。(新)协调者会从zk中读取消费组的元数据，包括消费组订阅的topics列表以及之前的partition ownership。如果topics的数量或者订阅topics的partitions数量和之前的partition ownership决策(分配partition是一种决策)有出入，新的协调者就会认为需要为这个消费组开始进行一次rebalance操作。同样如果消费者连接到新的协调者和zk中group generation的元数据不同，协调者也会为这个消费组开始一次rebalance操作。</li><li>2.协调者在步骤2失败，它会发送带有错误码的HeartbeatResponse给一些消费者，但不是全部(挂掉之后当然无法在发送了)。和步骤1的失败类似，协调者会在失效备援(failover)后检测rebalance的需要并开始又一次rebalance操作(失败的协调者发生在它自己的rebalance时，而新的协调者接管后，也需要检测什么时候需要rebalance，所以它的rebalance叫做又一次)。 如果是因为一个消费者的失败而开始一次rebalance，但是消费者在协调者failover处理完成之前就恢复为正常状态，协调者不会又开始一次rebalance(如果消费者在session timeout后仍然没有恢复，协调者认为消费者dead，就又开始一次rebalance)。然而，如果只要有任意一个消费者向协调者发送一个JoinGroupRequest，协调者就会为整个消费组开始一次rebalance操作。</li><li>3.协调者在步骤3失败，它可能只会接收到消费组中部分consumers的JoinGroupRequest。在失效备援后，协调者可能会收到所有存活的消费者的HeartbeatRequest或者部分消费者的JoinGroupRequest。和步骤1类似，也会触发消费组的rebalance。</li><li>4.协调者在步骤4失败，它可能会在写入新的generation id和消费组成员到zk中后失败。generation id和成员信息是作为一个原子的zk写入操作。在失效备援后，消费者会发送旧的generation id的HeartbeatRequests给协调者。协调者比较消费者的心跳请求中的generation和zk不一致，就会返回错误码为IllegalGeneration的响应，让消费者重新发送JoinGroupRequest。所以在HeartbeatRequest和OffsetCommitRequest中附带generation id和consumer id是值得的。</li><li>5.协调者在步骤5失败，它可能会在发送JoinGroupResponse给消费组中的部分消费者后失败了。已经接收到JoinGroupResponse的消费者在要发送心跳或者提交offsets时会检测到失败的协调者。这时它会发现新的协调者，并向它以新的generation发送心跳。(新的协调者在这个时候会向消费者发送没有错误码的HeartbeatResponse。对于没有收到JoinGroupResponse的消费者也会发现新的协调者，并且向它发送JoinGroupRequest。这也同样会触发协调者为消费组触发rebalance操作。</li></ul><p><strong>慢的消费者</strong></p><p>消费速度慢的消费者会被协调者从消费组中移除，比如协调者在session timeout时间内没有收到慢的消费者的心跳请求。典型的场景是如果消费者的消息处理速度比session timeout还要慢，会导致poll调用的时间间隔超过session timeout。由于心跳请求只会在poll调用时才会发送，这会导致协调者标记比较慢的消费者为Dead。协调者处理慢消费者的步骤：</p><ul><li>1.如果协调者在session timeout没有收到心跳请求，它标记消费者dead，并且中断到消费者的socket连接。</li><li>2.同时协调者会将带有IllegalGeneration错误码的HeartbeatResponse发送给组中其他的消费组，并触发rebalance。</li><li>3.如果在协调者接收到其他任意一个消费者的HeartbeatRequest请求之前，慢的消费者先发送了HeartbeatRequest协调者会取消rebalance的尝试，并且返回没有错误码的HeartbeatResponse给慢的消费者(说明由慢状态渐渐好转了)</li><li>4.如果不是这种情况(其他消费者先发送心跳)，协调者继续rebalance，也向慢消费者发送IllegalGeneration错误码。</li><li>5.由于协调者只会等待存活的消费者的JoinGroupRequest，所以在它接收到其他消费者的join请求后，它说rebalance可以结束了。如果这时慢的消费者恰巧也发送了JoinGroupRequest(突然不慢了)，协调者会在当前generation里包括这个慢的消费者，如果除了这个慢的消费者外，协调者还没有发送一个JoinGroupResponse(是其他消费者都还没发送，还是什么情况?)。</li><li>6.如果协调者已经发送了JoinGroupResponse(向其他存活的消费者，而不是这个慢的消费者，因为慢的消费者才刚发送请求)，它会让这一轮的rebalance完成，然后又会紧接着触发下一次的rebalance(慢的消费者在这一轮上轮不上，得等到下一轮)。</li><li>7.如果当前这一轮的rebalance时间花的太长了，慢的消费者的JoinGroupResponse就会超时(因为慢的消费者只能等到其他消费者都接收完JoinGroupResponse之后，在第一轮rebalance结束之后，才会发送JoinGroupResponse给慢的消费者，而第一轮的rebalance耗费太长了，慢的消费者在session timeout内没有收到协调者发送的JoinGroupResponse而超时)，消费者会认为协调者发生故障，就会重新发现协调者，并向新的协调者发送JoinGroupRequest。</li></ul><h3 id="Offsets和消费者位置"><a href="#Offsets和消费者位置" class="headerlink" title="Offsets和消费者位置"></a>Offsets和消费者位置</h3><p>消费者可以定时自动地提交offset，或者手动控制什么时候提交offset。使用commitSync手动提交commitOffset，会阻塞调用线程，直到offsets成功被提交，或者在提交过程中发生错误。使用commitAsync则是非阻塞方式，会在成功提交或者失败时，触发OffsetCommitCallback回调函数的执行。</p><h3 id="消费者组和主题订阅"><a href="#消费者组和主题订阅" class="headerlink" title="消费者组和主题订阅"></a>消费者组和主题订阅</h3><p>当消费组发生自动重新分配(为partition分配consumer)时，消费者会通过ConsumerRebalanceListener被通知到。这样消费者就可以在监听器开始工作时做一些必要的应用程序处理逻辑，比如清除状态，手动提交offset。 同时消费组也可以通过assign(List)，将指定的partitions分配给消费者，这种方式需要关闭动态的partition分配。</p><h3 id="新消费者示例"><a href="#新消费者示例" class="headerlink" title="新消费者示例"></a>新消费者示例</h3><p>生产者向topic推送消息，消费者订阅topic，一旦topic有消息，消费者就会去拉数据。生产者的一条消息用ProducerRecord表示，消费者的批量消息是ConsumerRecords。生产消息时会指定消息的Key和Value，所以ConsumerRecord也有key和value(还有partition，offset其他属性)。</p><p>示例1：最简单的客户端消息消费</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">KafkaConsumer consumer = <span class="keyword">new</span> KafkaConsumer&lt;&gt;(props);</span><br><span class="line">consumer.subscribe(Collections.singletonList(<span class="keyword">this</span>.topic));</span><br><span class="line">ConsumerRecords records = consumer.poll(<span class="number">1000</span>);</span><br><span class="line"><span class="keyword">for</span> (ConsumerRecord record : records) &#123;</span><br><span class="line"> System.out.println(<span class="string">"Received message: ("</span> + record.key() + </span><br><span class="line"> <span class="string">", "</span> + record.value() + <span class="string">") at offset "</span> + record.offset());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>自动提交offset</strong></p><p>利用Kafka的消费组提供的语义，可以管理Consumer的负载均衡和故障处理(offset存储在kafka，并自动提交offset)。Broker使用心跳的方式自动检测消费组中失败的消费者进程，消费者会定时地向集群发送ping(心跳)表示自己存活。只要消费者能够做这件事情(ping)，就说明它是存活的，它就会保留对分配给它的partition的消费的权利。如果消费者超过sessionTimeOut没有发送心跳就会被认为死亡，它的partitions就会分配给其他的线程。</p><p>示例2：自动提交offset，获取ConsumerRecord的offset</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 配置信息</span></span><br><span class="line">Properties props = <span class="keyword">new</span> Properties();</span><br><span class="line">props.put(<span class="string">"bootstrap.servers"</span>, <span class="string">"localhost:9092"</span>);</span><br><span class="line">props.put(<span class="string">"group.id"</span>, <span class="string">"test"</span>);</span><br><span class="line">props.put(<span class="string">"enable.auto.commit"</span>, <span class="string">"true"</span>);</span><br><span class="line">props.put(<span class="string">"auto.commit.interval.ms"</span>, <span class="string">"1000"</span>);</span><br><span class="line">props.put(<span class="string">"session.timeout.ms"</span>, <span class="string">"30000"</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 创建消费者实例, 并且订阅topic</span></span><br><span class="line">KafkaConsumer consumer = <span class="keyword">new</span> KafkaConsumer(props);</span><br><span class="line">consumer.subscribe(Arrays.asList(<span class="string">"foo"</span>, <span class="string">"bar"</span>));</span><br><span class="line"></span><br><span class="line"><span class="comment">// 消费者消费消息</span></span><br><span class="line"><span class="keyword">while</span> (<span class="keyword">true</span>) &#123;</span><br><span class="line"> ConsumerRecords records = consumer.poll(<span class="number">100</span>);</span><br><span class="line"> <span class="keyword">for</span> (ConsumerRecord record : records)</span><br><span class="line"> System.out.printf(<span class="string">"offset = %d, key = %s, value = %s"</span>, record.offset(), record.key(), record.value());</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>手动管理offset</strong></p><p>当消息的消费和其他处理逻辑耦合在一起时，只有处理逻辑完成后，才能认为这条消息被成功消费。在下面的示例中，我们消费了一批记录，并且在内存中暂时保存，当有足够的记录时插入到数据库中。如果像前面的示例允许自动提交offset，当消费者获取出消息时就认为消费了一批消息，而我们的处理逻辑在放到内存后，在插入数据库之前如果失败了，就会导致这批消息并没有保存到数据库中，却被消费掉了(丢失)。为了防止这种问题的出现，我们只有在对应的消息插入到数据库之后，才执行一次手动提交offset的工作。通过这种方式，我们可以精确地控制什么时候消息被认为成功地消费了。但是这却引起了另外的一个潜在的问题：在插入到数据库之后，在提交offset之前，客户端应用程序挂掉了，这样应用程序下次启动时，因为offset没有更新，消费者线程会从上次提交的offset开始继续消费消息，就会插入重复的数据(最近的一批)到数据库中。所以这种方式，对于kafka而言，只能保证消息”至少发送一次”，但不能保证”正好一次”(交给了客户端自己实现)。</p><ul><li>1.commit offset=10</li><li>2.fetch from offset=10，get 5 msgs，offset=16</li><li>3.insert 5 msgs into db</li><li>4.client failed</li><li>5.still fetch from offset=10，get 5 msgs，offset=16</li><li>6.insert duplicated 5 msgs into db</li><li>7.commit offset=16</li><li>8.next time，fetch from offset=16</li></ul><p>示例3：客户端手动管理offset的提交</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">props.put(<span class="string">"enable.auto.commit"</span>, <span class="string">"false"</span>);  <span class="comment">// 设置autoCommit为false</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">int</span> commitInterval = <span class="number">200</span>;</span><br><span class="line">List&gt; buffer = <span class="keyword">new</span> ArrayList&gt;();</span><br><span class="line"><span class="keyword">while</span> (<span class="keyword">true</span>) &#123;</span><br><span class="line"> ConsumerRecords records = consumer.poll(<span class="number">100</span>);</span><br><span class="line"> <span class="keyword">for</span> (ConsumerRecord record : records) &#123;</span><br><span class="line"> buffer.add(record);</span><br><span class="line"> <span class="keyword">if</span> (buffer.size() &gt;= commitInterval) &#123;</span><br><span class="line"> insertIntoDb(buffer);</span><br><span class="line"> consumer.commitSync();</span><br><span class="line"> buffer.clear();</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>订阅指定的partition</strong></p><p>前面的示例我们订阅了感兴趣的topics，然后kafka会帮我们在这些topics公平地共享partitions。这种简单的负载均衡方式，能让客户端程序的多个实例(多个消费者进程)一起完成所有记录的处理工作。使用指定partition的方式，消费者只会分配到指定的partition，如果消费者挂掉后，并不会有负载均衡的工作，会将这个消费者的partitions分配给其他的消费者线程实例(相当于静态分配)。但是有几种场景是有意义的：</p><ul><li>如果消费者逻辑维护了和这个Partition相关的一些本地状态(比如本地的KV存储)，就应该只从它维护的本地磁盘对应的partition获取记录</li><li>消费者线程本身就是HA的，如果它失败了，会重启(比如使用集群管理框架，就像YARN，Mesos，或者作为流处理框架的一部分)。这种情况也不需要kafka检测失败以及重新分配partition(因为失败后重启，还会消费之前所属的partition)。</li></ul><p>在动态分配partition的场景下，消费者的加入和删除，都会导致partition的重新分配给其他的消费者。而静态分配partition下，如果消费者挂掉后，分配给这个消费者的partition并不会负载给其他消费者。静态分配partition的模式，消费者不是订阅主题，而是订阅指定的partition(当然partition也是由topic组成的)：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">String topic = <span class="string">"foo"</span>;</span><br><span class="line">TopicPartition partition0 = <span class="keyword">new</span> TopicPartition(topic, <span class="number">0</span>);</span><br><span class="line">TopicPartition partition1 = <span class="keyword">new</span> TopicPartition(topic, <span class="number">1</span>);</span><br><span class="line">consumer.assign(partition0);</span><br><span class="line">consumer.assign(partition1);</span><br></pre></td></tr></table></figure><p>consumer所指定的消费组仍然会用来提交offset(partition的offset是面向消费组的，而不是针对每个消费者，虽然partition是分配给消费者处理的，但如果offset记录在消费者上，当所属的消费者挂掉后，这个offset就会丢失掉了，所以应该记录在消费组上)。现在因为消费者固定分配了指定的partitions，只有指定了新的partitions，消费者的partitions集合才会变化，但仍然没有失败检测。注意：不可能为一个消费者实例同时混合订阅指定的partition(没有负载均衡)和订阅topic(有负载均衡)两种逻辑。</p><p>下面的示例consumer订阅了指定的topic和partitions，消费者在关闭之前会消费这些partitions到最近可用的消息。使用静态partition分配，就意味着自动放弃了消费组的管理功能。不过仍然要指定group.id来使用kafka的offset管理，但并不需要指定sessionTimeOut。因为只有使用group management时，在session超时后才会完成自动故障转移。</p><p>示例4：消费者订阅指定的partitions</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br></pre></td><td class="code"><pre><span class="line">Properties props = <span class="keyword">new</span> Properties();</span><br><span class="line">props.put(<span class="string">"metadata.broker.list"</span>, <span class="string">"localhost:9092"</span>);</span><br><span class="line">props.put(<span class="string">"group.id"</span>, <span class="string">"test"</span>);</span><br><span class="line">props.put(<span class="string">"enable.auto.commit"</span>, <span class="string">"true"</span>);</span><br><span class="line">props.put(<span class="string">"auto.commit.interval.ms"</span>, <span class="string">"10000"</span>);</span><br><span class="line">KafkaConsumer consumer = <span class="keyword">new</span> KafkaConsumer(props);</span><br><span class="line"></span><br><span class="line"><span class="comment">// subscribe to some partitions of topic foo</span></span><br><span class="line">TopicPartition partition0 = <span class="keyword">new</span> TopicPartition(<span class="string">"foo"</span>, <span class="number">0</span>);</span><br><span class="line">TopicPartition partition1 = <span class="keyword">new</span> TopicPartition(<span class="string">"foo"</span>, <span class="number">1</span>);</span><br><span class="line">TopicPartition[] partitions = <span class="keyword">new</span> TopicPartition[<span class="number">2</span>];</span><br><span class="line">partitions[<span class="number">0</span>] = partition0;</span><br><span class="line">partitions[<span class="number">1</span>] = partition1;</span><br><span class="line">consumer.subscribe(partitions);</span><br><span class="line"></span><br><span class="line"><span class="comment">// find the last committed offsets for partitions 0,1 of topic foo</span></span><br><span class="line">Map lastCommittedOffsets = consumer.committed(partition0, partition1);</span><br><span class="line"><span class="comment">// seek to the last committed offsets to avoid duplicates</span></span><br><span class="line">consumer.seek(lastCommittedOffsets);</span><br><span class="line"></span><br><span class="line"><span class="comment">// find the offsets of the latest available messages to know where to stop consumption</span></span><br><span class="line">Map latestAvailableOffsets = </span><br><span class="line"> consumer.offsetsBeforeTime(-<span class="number">2</span>, partition0, partition1);</span><br><span class="line"><span class="keyword">boolean</span> isRunning = <span class="keyword">true</span>;</span><br><span class="line">Map consumedOffsets = <span class="keyword">new</span> HashMap();</span><br><span class="line"><span class="keyword">while</span>(isRunning) &#123;</span><br><span class="line"> Map records = consumer.poll(<span class="number">100</span>, TimeUnit.MILLISECONDS);</span><br><span class="line"> Map lastConsumedOffsets = process(records);</span><br><span class="line"> consumedOffsets.putAll(lastConsumedOffsets);</span><br><span class="line"> <span class="keyword">for</span>(TopicPartition partition : partitions) &#123;</span><br><span class="line"> <span class="keyword">if</span>(consumedOffsets.get(partition) &gt;= latestAvailableOffsets.get(partition))</span><br><span class="line"> isRunning = <span class="keyword">false</span>;</span><br><span class="line"> <span class="keyword">else</span></span><br><span class="line"> isRunning = <span class="keyword">true</span>;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br><span class="line">consumer.commit();</span><br><span class="line">consumer.close();</span><br></pre></td></tr></table></figure><p><strong>存储offset到kafka之外</strong></p><p>消费者客户端应用程序并不一定要求将kafka作为内置的offset存储。可以将offset存储在自己选择的其他存储系统中。常见的用法是应用程序将offset和消费的结果以原子性/事务的方式存储在同一个系统中，当然原子性并不一定是需要的。但是选择这种方式，可以确保消费的”完全原子性”，能够保证”正好一次”的语义，这比kafka默认提供的”至少一次”语义要强壮。</p><ul><li>如果消费结果要保存到关系型数据库中，同时存储offset到数据库中，可以在一次事务中同时提交结果和offset。这种情况下，记录被消费，并成功存储，offset被更新表示事务成功。而事务失败时，结果不会存储，offset也不会被更新。</li><li>如果结果保存到本次存储，最好也将offset也一起保存到本地。</li></ul><p>由于每条记录都有自己的offset，为了管理你自己的offset，需要做下面的几个工作：</p><ul><li>配置enable.auto.commit=false，关闭自动提交offset</li><li>用每个ConsumerRecord的offset来保存你自己的position信息</li><li>在重启时，恢复consumer的position，调用seek(TopicPartition，long)</li></ul><p>如果partition的分配也采用手动静态分配的方式，上面的步骤会简单很多。如果是自动分配partition，在partition变化时有一些额外的工作需要做。调用subscribe(List，ConsumerRebalanceListener)中的Listener就完成了这个额外的工作。当partitions从消费者去掉，消费者会在Listener的onPartitionRevoked()为这些partition提交offset(最后一次机会了)。当partitions分配给一个消费者，消费者会查找这些新的partition的offset(就比如上面被去掉的partition)，然后初始化(这是一个新创建的)消费者到查找出来的那个offset位置。这是在监听器的onPartitionsAssigned方法中。</p><p>ConsumerRebalanceListener另一个通用做法是在移动partition到其他消费者时，刷新应用程序为partitions维护的任何缓存。因为缓存是根据partition的数据构建的，一旦partition迁移到其他消费者实例，原先的缓存在当前应用程序就失效了，所以需要刷新。</p><p>示例5：消费者订阅指定的partitions，并且使用外部存储offset</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br></pre></td><td class="code"><pre><span class="line">Properties props = <span class="keyword">new</span> Properties();</span><br><span class="line">props.put(<span class="string">"metadata.broker.list"</span>, <span class="string">"localhost:9092"</span>);</span><br><span class="line">KafkaConsumer consumer = <span class="keyword">new</span> KafkaConsumer(props);</span><br><span class="line"></span><br><span class="line"><span class="comment">// subscribe to some partitions of topic foo</span></span><br><span class="line">TopicPartition partition0 = <span class="keyword">new</span> TopicPartition(<span class="string">"foo"</span>, <span class="number">0</span>);</span><br><span class="line">TopicPartition partition1 = <span class="keyword">new</span> TopicPartition(<span class="string">"foo"</span>, <span class="number">1</span>);</span><br><span class="line">TopicPartition[] partitions = <span class="keyword">new</span> TopicPartition[<span class="number">2</span>];</span><br><span class="line">partitions[<span class="number">0</span>] = partition0;</span><br><span class="line">partitions[<span class="number">1</span>] = partition1;</span><br><span class="line">consumer.subscribe(partitions);</span><br><span class="line"></span><br><span class="line"><span class="comment">// seek to the last committed offsets to avoid duplicates</span></span><br><span class="line">Map lastCommittedOffsets = getLastCommittedOffsetsFromCustomStore();</span><br><span class="line">consumer.seek(lastCommittedOffsets); </span><br><span class="line"></span><br><span class="line"><span class="comment">// find the offsets of the latest available messages to know where to stop consumption</span></span><br><span class="line">Map latestAvailableOffsets = </span><br><span class="line"> consumer.offsetsBeforeTime(-<span class="number">2</span>, partition0, partition1);</span><br><span class="line"><span class="keyword">boolean</span> isRunning = <span class="keyword">true</span>;</span><br><span class="line">Map consumedOffsets = <span class="keyword">new</span> HashMap();</span><br><span class="line"><span class="keyword">while</span>(isRunning) &#123;</span><br><span class="line"> Map records = consumer.poll(<span class="number">100</span>, TimeUnit.MILLISECONDS);</span><br><span class="line"> Map lastConsumedOffsets = process(records);</span><br><span class="line"> consumedOffsets.putAll(lastConsumedOffsets);</span><br><span class="line"> <span class="comment">// commit offsets for partitions 0,1 for topic foo to custom store</span></span><br><span class="line"> commitOffsetsToCustomStore(consumedOffsets);</span><br><span class="line"> <span class="keyword">for</span>(TopicPartition partition : partitions) &#123;</span><br><span class="line"> <span class="keyword">if</span>(consumedOffsets.get(partition) &gt;= latestAvailableOffsets.get(partition))</span><br><span class="line"> isRunning = <span class="keyword">false</span>;</span><br><span class="line"> <span class="keyword">else</span> isRunning = <span class="keyword">true</span>;</span><br><span class="line"> &#125; </span><br><span class="line">&#125; </span><br><span class="line">commitOffsetsToCustomStore(consumedOffsets); </span><br><span class="line">consumer.close();</span><br></pre></td></tr></table></figure><p><strong>控制消费者的position</strong></p><p>在大多数情况下，消费者消费记录只是简单地从一开始到结束，并且定时地提交它的位置(不管是自动的还是手动的)。不过新的API也允许消费者手动控制它的位置，消费者可以在一个partition钟随意地往前或者往后移动位置。这就意味着消费者可以重新消费旧的记录(多次读取相同的记录)，或者直接跳到最近的记录，忽略掉中间的记录。</p><ul><li>消费者可能落后太多，并不尝试抓取所有落后的记录，而是直接跳到最近的记录。对时间敏感的记录，这种处理方式也是有意义的。</li><li>对于需要维护本地状态的系统，消费者在启动时会初始化它的位置，无论本地状态保存的是什么。而且如果本地状态数据被破坏<br>  (比如磁盘损坏)，本地状态可以通过重新消费所有的数据，在新的机器上重建状态信息(假设kafka保存了足够的历史数据)。</li></ul><p>kafka允许通过seek(TopicPartition，long)指定新的位置，或者seekToBeginning，seekToEnd定位到最早或最近的offset。下面的示例假设offsets保存在kafka中，并使用commit方法手动提交offset，如果消息消费失败，会重置consumer的offsets。注意seek重置offsets只对当前消费者起作用，它并不会触发consumer的rebalance，或者影响其他消费者的fetchOffsets。</p><p>示例6：消息消费失败时，重置offset</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">int</span> commitInterval = <span class="number">100</span>;</span><br><span class="line"><span class="keyword">int</span> numRecords = <span class="number">0</span>;</span><br><span class="line"><span class="keyword">boolean</span> isRunning = <span class="keyword">true</span>;</span><br><span class="line">Map consumedOffsets = <span class="keyword">new</span> HashMap();</span><br><span class="line"><span class="keyword">while</span>(isRunning) &#123;</span><br><span class="line"> Map records = consumer.poll(<span class="number">100</span>, TimeUnit.MILLISECONDS);</span><br><span class="line"> <span class="keyword">try</span> &#123;</span><br><span class="line"> Map lastConsumedOffsets = process(records);</span><br><span class="line"> consumedOffsets.putAll(lastConsumedOffsets);</span><br><span class="line"> numRecords += records.size();</span><br><span class="line"> <span class="comment">// commit offsets for all partitions of topics foo, bar synchronously, owned by this consumer instance</span></span><br><span class="line"> <span class="keyword">if</span>(numRecords % commitInterval == <span class="number">0</span>) consumer.commit();</span><br><span class="line"> &#125; <span class="keyword">catch</span>(Exception e) &#123;</span><br><span class="line"> <span class="keyword">try</span> &#123;</span><br><span class="line"> <span class="comment">// rewind consumer's offsets for failed partitions</span></span><br><span class="line"> <span class="comment">// assume failedPartitions() returns the list of partitions for which the processing of the last batch of messages failed</span></span><br><span class="line"> List failedPartitions = failedPartitions(); </span><br><span class="line"> Map offsetsToRewindTo = <span class="keyword">new</span> HashMap();</span><br><span class="line"> <span class="keyword">for</span>(TopicPartition failedPartition : failedPartitions) &#123;</span><br><span class="line"> <span class="comment">// rewind to the last consumed offset for the failed partition. Since process() failed for this partition, the consumed offset</span></span><br><span class="line"> <span class="comment">// should still be pointing to the last successfully processed offset and hence is the right offset to rewind consumption to.</span></span><br><span class="line"> offsetsToRewindTo.put(failedPartition, consumedOffsets.get(failedPartition));</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="comment">// seek to new offsets only for partitions that failed the last process()</span></span><br><span class="line"> consumer.seek(offsetsToRewindTo);</span><br><span class="line"> &#125; <span class="keyword">catch</span>(Exception e) &#123;  <span class="keyword">break</span>; &#125; <span class="comment">// rewind failed</span></span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br><span class="line">consumer.close();</span><br></pre></td></tr></table></figure><p>上面的process方法假设接收一批消息，返回每个partition最近处理过的消息的offset(consumedOffset，不是nextOffset)。在消费一批数据之后，将consumedOffsets保存在内存中。当有异常发生时，循环failedPartitions的每个partition，从内存中获取出partition对应的consumedOffset，让消费者实例重新seek(参数可以是多个Partition到offset的映射)。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">private</span> Map <span class="title">process</span><span class="params">(Map records)</span> </span>&#123;</span><br><span class="line"> Map processedOffsets = <span class="keyword">new</span> HashMap();</span><br><span class="line"> <span class="keyword">for</span>(Entry recordMetadata : records.entrySet()) &#123;</span><br><span class="line"> List recordsPerTopic = recordMetadata.getValue().records();</span><br><span class="line"> <span class="keyword">for</span>(<span class="keyword">int</span> i = <span class="number">0</span>;i </span><br><span class="line"> ConsumerRecord record = recordsPerTopic.get(i);</span><br><span class="line"> <span class="comment">// process record</span></span><br><span class="line"> processedOffsets.put(record.partition(), record.offset()); </span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="keyword">return</span> processedOffsets; </span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>示例7：对整个消费组倒回offsets</p><p>如果使用了kafka的group management(消费组管理功能具有consuers的自动负载均衡以及故障处理能力)，为每个消费者实例系统级地倒回offsets的准确位置是在ConsumerRebalanceListener回调函数里。 在consumer发生rebalance时，并且在消费消息之前，当consumer被分配到新的partitions集合后，会触发onPartitionAssigned回调函数的执行。在这里为consuer提供全新的倒回offset功能才是正确的。如果你能预知在当前的消费组管理中会一直重置consumer的offset，建议你总是配置consumer使用ConsumerRebalanceListener，并使用一个标志位用来判断是否启用offset的倒回逻辑功能。</p><p>倒回offset函数的作用是，在成功地消费了消息并且提交了offset之后，你发现了消息处理逻辑中存在的问题。这时你希望对整个消费组进行offset倒回，这还只是作为对处理逻辑修复的回滚操作的一部分工作。(消息处理逻辑存在问题，需要对已经消费的消息使用新的处理逻辑重新消费，所以需要回滚offset)这种情况下，你会为每个消费者实例开启倒回offset的配置标志位。并且依次滚动重启每个消费者实例。(消费逻辑存在问题，在修改消费者客户端代码后，必须要重启消费者进程才能以最新的逻辑消费消息)每次重启都会触发rebalance，最终所有的消费者实例都会对它们拥有的partitions倒回offsets。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line">KafkaConsumer consumer = <span class="keyword">new</span> KafkaConsumer(props,</span><br><span class="line"> <span class="keyword">new</span> ConsumerRebalanceListener() &#123;</span><br><span class="line"> <span class="keyword">boolean</span> rewindOffsets = <span class="keyword">true</span>;  <span class="comment">// should be retrieved from external application config</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">onPartitionsAssigned</span><span class="params">(Consumer consumer, TopicPartition...partitions)</span> </span>&#123;</span><br><span class="line"> Map latestCommittedOffsets = consumer.committed(partitions);</span><br><span class="line"> <span class="keyword">if</span>(rewindOffsets)</span><br><span class="line"> Map newOffsets = rewindOffsets(latestCommittedOffsets, <span class="number">100</span>);</span><br><span class="line"> consumer.seek(newOffsets);</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">onPartitionsRevoked</span><span class="params">(Consumer consumer, TopicPartition...partitions)</span> </span>&#123;</span><br><span class="line"> consumer.commit();</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="comment">// this API rewinds every partition back by numberOfMessagesToRewindBackTo messages</span></span><br><span class="line"> <span class="function"><span class="keyword">private</span> Map <span class="title">rewindOffsets</span><span class="params">(Map currentOffsets,</span></span></span><br><span class="line"><span class="function"><span class="params"> <span class="keyword">long</span> numberOfMessagesToRewindBackTo)</span> </span>&#123;</span><br><span class="line"> Map newOffsets = <span class="keyword">new</span> HashMap();</span><br><span class="line"> <span class="keyword">for</span>(Map.Entry offset : currentOffsets.entrySet()) </span><br><span class="line"> newOffsets.put(offset.getKey(), offset.getValue() - numberOfMessagesToRewindBackTo);</span><br><span class="line"> <span class="keyword">return</span> newOffsets;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;);</span><br><span class="line">consumer.subscribe(<span class="string">"foo"</span>, <span class="string">"bar"</span>);</span><br><span class="line"><span class="comment">//...同上调用了process消费消息,并保存到consumedOffsets内存中</span></span><br><span class="line">consumer.close();</span><br></pre></td></tr></table></figure><p>示例8：使用外部offset存储倒回offsets</p><p>由于将offset保存在外部存储系统中，消费者要倒回offset时，需要从自定义存储中读取offset提供给消费者。同样<code>onPartitionAssigned</code>回调函数也是将自定义存储的offsets提供给消费者的正确的地方。同时客户端代码还需要提供保存消费者的offsets到自定义存储系统中的方法(有读取就有存储)。因为<code>onPartitionsRevoked</code>会在消费者停止抓取数据之后，并partition的所有权更改之前调用。所以这里是为消费者拥有的partitions提交offsets的正确位置。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line">KafkaConsumer consumer = <span class="keyword">new</span> KafkaConsumer(props,</span><br><span class="line"> <span class="keyword">new</span> ConsumerRebalanceListener() &#123;</span><br><span class="line"> <span class="comment">// 从自定义存储中读取offset,让consumer重置offset</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">onPartitionsAssigned</span><span class="params">(Consumer consumer, TopicPartition...partitions)</span> </span>&#123;</span><br><span class="line"> Map lastCommittedOffsets = getLastCommittedOffsets(partitions);</span><br><span class="line"> consumer.seek(lastCommittedOffsets);</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="comment">// 提交offset,保存offset带外部存储中</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">onPartitionsRevoked</span><span class="params">(Consumer consumer, TopicPartition...partitions)</span> </span>&#123;</span><br><span class="line"> Map offsets = getLastConsumedOffsets(partitions);</span><br><span class="line"> commitOffsetsToCustomStore(offsets); </span><br><span class="line"> &#125;</span><br><span class="line"> <span class="comment">// following APIs should be implemented by the user for custom offset management</span></span><br><span class="line"> <span class="function"><span class="keyword">private</span> Map <span class="title">getLastCommittedOffsets</span><span class="params">(TopicPartition... partitions)</span> </span>&#123;<span class="keyword">return</span> <span class="keyword">null</span>;&#125;</span><br><span class="line"> <span class="function"><span class="keyword">private</span> Map <span class="title">getLastConsumedOffsets</span><span class="params">(TopicPartition... partitions)</span> </span>&#123;<span class="keyword">return</span> <span class="keyword">null</span>;&#125;</span><br><span class="line"> <span class="function"><span class="keyword">private</span> <span class="keyword">void</span> <span class="title">commitOffsetsToCustomStore</span><span class="params">(Map offsets)</span> </span>&#123;&#125;</span><br><span class="line">&#125;);</span><br><span class="line">Map consumedOffsets = <span class="keyword">new</span> HashMap();</span><br><span class="line"><span class="keyword">while</span>(isRunning) &#123;</span><br><span class="line"> Map records = consumer.poll(<span class="number">100</span>, TimeUnit.MILLISECONDS);</span><br><span class="line"> Map lastConsumedOffsets = process(records);</span><br><span class="line"> consumedOffsets.putAll(lastConsumedOffsets);</span><br><span class="line"> numRecords += records.size();</span><br><span class="line"> <span class="comment">// commit offsets for all partitions of topics foo, bar synchronously, owned by this consumer instance</span></span><br><span class="line"> <span class="keyword">if</span>(numRecords % commitInterval == <span class="number">0</span>) commitOffsetsToCustomStore(consumedOffsets);</span><br><span class="line">&#125;</span><br><span class="line">consumer.close();</span><br></pre></td></tr></table></figure><p><strong>消费流控制</strong></p><p>如果一个消费者要抓取多个分配的partitions，它会尝试同时消费所有partitions的消息，即这些partitions的优先级是相同的。但是在有些情况下，消费者要首先专注于对一部分partitions开足马力抓取数据，对其他partitions的抓取只有在优先级比较高的那些partitions只有很少数据，或者没有数据可以消费时(比较空闲的状态)，才去消费那些优先级比较低的partitions。典型的应用是流处理，比如处理器从两个topics抓取数据，并且在这两个流上运用join操作算子。当其中一个topic落后于另外一个流的消息太多，处理器应该要暂停抓取领先的流，而去抓取落后的流，让它赶上来(才能一起join)。另外一个场景是在消费者启动的时候，由于历史数据太多了，一时半会儿赶不上。而应用程序对于某些topics通常只需要得到最近的数据。所以对于这些topics会优先考虑抓取数据，而其他topics则会暂停(让出资源给优先级高的优先抓取，而资源共享会拖慢整体速度)。kafka支持动态的消息获取控制，pause会暂停获取某个partition的消息，而resume则恢复获取(在未来的某个时刻调用poll时)。</p><p><strong>多线程处理</strong></p><p>kafka的消费者(KafkaConsumer对象)并不是线程安全的。客户端代码需要自己确保多线程的访问是同步的。未同步的访问会抛出ConcurrentModificationException(比如对Map访问的同时又修改了Map也会报这个错)。 唯一例外的是wakeup方法(是线程安全的)：它可以被外部线程用来安全地中断一个进行中的操作。对于阻塞在wakeup方法上的线程会抛出WakeupException。可以被另外的线程用来作为关闭consumer的钩子。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">KafkaConsumerRunner</span> <span class="keyword">implements</span> <span class="title">Runnable</span> </span>&#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">final</span> AtomicBoolean closed = <span class="keyword">new</span> AtomicBoolean(<span class="keyword">false</span>);</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">final</span> KafkaConsumer consumer;</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">run</span><span class="params">()</span> </span>&#123;</span><br><span class="line"> <span class="keyword">try</span> &#123;</span><br><span class="line"> consumer.subscribe(<span class="string">"topic"</span>);</span><br><span class="line"> <span class="keyword">while</span> (!closed.get()) &#123;</span><br><span class="line"> ConsumerRecords records = consumer.poll(<span class="number">10000</span>);</span><br><span class="line"> <span class="comment">// 处理新的记录</span></span><br><span class="line"> &#125;</span><br><span class="line"> &#125; <span class="keyword">catch</span> (WakeupException e) &#123;</span><br><span class="line"> <span class="keyword">if</span> (!closed.get()) <span class="keyword">throw</span> e; <span class="comment">//如果关闭了忽略异常</span></span><br><span class="line"> &#125; <span class="keyword">finally</span> &#123;</span><br><span class="line"> consumer.close();</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="comment">// 关闭钩子,可以在另一个线程中调用</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">shutdown</span><span class="params">()</span> </span>&#123;</span><br><span class="line"> closed.set(<span class="keyword">true</span>);</span><br><span class="line"> consumer.wakeup();</span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>我们故意避免为了消息处理而实现特殊的线程模型(即Handle new records部分)，有多种方式实现多线程的消息处理。</p><p>1) 一个线程一个消费者</p><p>每个线程都有自己的消费者实例，消息消费逻辑和消息处理逻辑都在消费者线程中完成。这种方式的利弊：</p><ul><li>优点：很容易实现，执行很快，因为没有线程之间的交互和协调。</li><li>优点：对于每个partition要保证顺序处理比较容易实现。每个线程只需要按照顺序处理它接收到的消息即可。</li><li>缺点：更多的消费者意味着集群的TCP连接也很多。不过kafka处理连接是很高效的，所以这个代价并不是很大。</li><li>缺点：多个消费者意味着发送更多的请求给服务器，每一批发送的数据变少(发送更多批)，就会降低I/O吞吐量。</li><li>缺点：所有进程之间的线程数量会被partitions的数量所限制。</li></ul><p>2) 解耦消费和处理逻辑</p><p>另一种方式是有一个或多个消费者线程用来消费消息，并将消费结果ConsumerRecords转移一个阻塞队列中，<br>它会被消息处理线程池消费，消息处理线程顾名思义就是处理消息的线程。这种方式的利弊：</p><ul><li>优点：可以相互独立地扩展消费者数量和处理器数量。可以只用一个消费者线程服务于多个处理线程，避免partitions的限制。</li><li>缺点：在处理器线程之间保证消息处理的顺序是比较困难的。因为线程之间是独立的，线程之间的顺序是无法保证的。所以即使是比较早的数据块也有可能比靠后面的数据块更晚被处理到。如果要求消息的处理是无序的，当然是没有问题的。</li><li>缺点：手动提交offset变得困难，因为它需要所有的线程协调起来确保这个partition的消息已经被处理完毕。</li></ul><p>解决上面的缺点有多种方式。比如每个处理线程都可以有自己的队列，消费者可以对TopicPartition的hash结果放入不同处理线程的队列中，这样也可以确保消息被顺序地消费，并且简化提交offset的逻辑。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;上一章分析的消费者高级API使用ConsumerGroup的语义管理多个消费者，但是在消费者或者Partition发生变化时都需要rebalance，它的实现对ZooKeeper依赖比较严重，&lt;br&gt;由Kafka内置实现了失败检测和Rebalance(ZKRebalancerListener)，但是它存在羊群效应和脑裂的问题，客户端代码实现低级API也不能解决这个问题。如果将失败探测和Rebalance的逻辑放到一个高可用的中心Coordinator，这两个问题即可解决。同时还可大大减少Zookeeper的负载，有利于Kafka Broker的扩展(Broker也会作为协调节点的角色存在)。&lt;br&gt;
    
    </summary>
    
      <category term="kafka" scheme="http://crazycarry.github.io/categories/kafka/"/>
    
    
      <category term="架构设计" scheme="http://crazycarry.github.io/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/"/>
    
      <category term="源码分析" scheme="http://crazycarry.github.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"/>
    
      <category term="消息中间件" scheme="http://crazycarry.github.io/tags/%E6%B6%88%E6%81%AF%E4%B8%AD%E9%97%B4%E4%BB%B6/"/>
    
      <category term="大数据" scheme="http://crazycarry.github.io/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
    
  </entry>
  
  <entry>
    <title>Kafka设计解析（六）- Kafka高性能架构之道</title>
    <link href="http://crazycarry.github.io/2018/04/04/kafka-performance-why/"/>
    <id>http://crazycarry.github.io/2018/04/04/kafka-performance-why/</id>
    <published>2018-04-04T10:34:10.000Z</published>
    <updated>2018-04-04T10:46:32.699Z</updated>
    
    <content type="html"><![CDATA[<p>本文从宏观架构层面和微观实现层面分析了Kafka如何实现高性能。包含Kafka如何利用Partition实现并行处理和提供水平扩展能力，如何通过ISR实现可用性和数据一致性的动态平衡，如何使用NIO和Linux的sendfile实现零拷贝以及如何通过顺序读写和数据压缩实现磁盘的高效利用。<br><a id="more"></a></p><h1 id="摘要"><a href="#摘要" class="headerlink" title="摘要"></a>摘要</h1><p>上一篇文章《<a href="http://www.jasongj.com/2015/12/31/KafkaColumn5_kafka_benchmark/" target="_blank" rel="noopener">Kafka设计解析（五）- Kafka性能测试方法及Benchmark报告</a>》从测试角度说明了Kafka的性能。本文从宏观架构层面和具体实现层面分析了Kafka如何实现高性能。</p><h1 id="宏观架构层面"><a href="#宏观架构层面" class="headerlink" title="宏观架构层面"></a>宏观架构层面</h1><h2 id="利用Partition实现并行处理"><a href="#利用Partition实现并行处理" class="headerlink" title="利用Partition实现并行处理"></a>利用Partition实现并行处理</h2><h3 id="Partition提供并行处理的能力"><a href="#Partition提供并行处理的能力" class="headerlink" title="Partition提供并行处理的能力"></a>Partition提供并行处理的能力</h3><p>Kafka是一个Pub-Sub的消息系统，无论是发布还是订阅，都须指定Topic。如《<a href="http://www.jasongj.com/2015/03/10/KafkaColumn1" target="_blank" rel="noopener">Kafka设计解析（一）- Kafka背景及架构介绍</a>》一文所述，Topic只是一个逻辑的概念。每个Topic都包含一个或多个Partition，不同Partition可位于不同节点。同时Partition在物理上对应一个本地文件夹，每个Partition包含一个或多个Segment，每个Segment包含一个数据文件和一个与之对应的索引文件。在逻辑上，可以把一个Partition当作一个非常长的数组，可通过这个“数组”的索引（offset）去访问其数据。</p><p>一方面，由于不同Partition可位于不同机器，因此可以充分利用集群优势，实现机器间的并行处理。另一方面，由于Partition在物理上对应一个文件夹，即使多个Partition位于同一个节点，也可通过配置让同一节点上的不同Partition置于不同的disk drive上，从而实现磁盘间的并行处理，充分发挥多磁盘的优势。</p><p>利用多磁盘的具体方法是，将不同磁盘mount到不同目录，然后在server.properties中，将<code>log.dirs</code>设置为多目录（用逗号分隔）。Kafka会自动将所有Partition尽可能均匀分配到不同目录也即不同目录（也即不同disk）上。</p><p>注：虽然物理上最小单位是Segment，但Kafka并不提供同一Partition内不同Segment间的并行处理。因为对于写而言，每次只会写Partition内的一个Segment，而对于读而言，也只会顺序读取同一Partition内的不同Segment。</p><h3 id="Partition是最小并发粒度"><a href="#Partition是最小并发粒度" class="headerlink" title="Partition是最小并发粒度"></a>Partition是最小并发粒度</h3><p>如同《<a href="http://www.jasongj.com/2015/08/09/KafkaColumn4" target="_blank" rel="noopener">Kafka设计解析（四）- Kafka Consumer设计解析</a>》一文所述，多Consumer消费同一个Topic时，同一条消息只会被同一Consumer Group内的一个Consumer所消费。而数据并非按消息为单位分配，而是以Partition为单位分配，也即同一个Partition的数据只会被一个Consumer所消费（在不考虑Rebalance的前提下）。</p><p>如果Consumer的个数多于Partition的个数，那么会有部分Consumer无法消费该Topic的任何数据，也即当Consumer个数超过Partition后，增加Consumer并不能增加并行度。</p><p>简而言之，Partition个数决定了可能的最大并行度。如下图所示，由于Topic 2只包含3个Partition，故group2中的Consumer 3、Consumer 4、Consumer 5 可分别消费1个Partition的数据，而Consumer 6消费不到Topic 2的任何数据。<br><a href="http://www.jasongj.com/img/kafka/KafkaColumn6/kafka-consumer.png" target="_blank" rel="noopener"><img src="http://www.jasongj.com/img/kafka/KafkaColumn6/kafka-consumer.png" alt="Kafka Consumer"></a></p><p>以Spark消费Kafka数据为例，如果所消费的Topic的Partition数为N，则有效的Spark最大并行度也为N。即使将Spark的Executor数设置为N+M，最多也只有N个Executor可同时处理该Topic的数据。</p><h2 id="ISR实现可用性与数据一致性的动态平衡"><a href="#ISR实现可用性与数据一致性的动态平衡" class="headerlink" title="ISR实现可用性与数据一致性的动态平衡"></a>ISR实现可用性与数据一致性的动态平衡</h2><h3 id="CAP理论"><a href="#CAP理论" class="headerlink" title="CAP理论"></a>CAP理论</h3><p>CAP理论是指，分布式系统中，一致性、可用性和分区容忍性最多只能同时满足两个。</p><p><strong><em>一致性</em></strong></p><ul><li>通过某个节点的写操作结果对后面通过其它节点的读操作可见</li><li>如果更新数据后，并发访问情况下后续读操作可立即感知该更新，称为强一致性</li><li>如果允许之后部分或者全部感知不到该更新，称为弱一致性</li><li>若在之后的一段时间（通常该时间不固定）后，一定可以感知到该更新，称为最终一致性</li></ul><p><strong><em>可用性</em></strong></p><ul><li>任何一个没有发生故障的节点必须在有限的时间内返回合理的结果</li></ul><p><strong><em>分区容忍性</em></strong></p><ul><li>部分节点宕机或者无法与其它节点通信时，各分区间还可保持分布式系统的功能</li></ul><p>一般而言，都要求保证分区容忍性。所以在CAP理论下，更多的是需要在可用性和一致性之间做权衡。</p><h3 id="常用数据复制及一致性方案"><a href="#常用数据复制及一致性方案" class="headerlink" title="常用数据复制及一致性方案"></a>常用数据复制及一致性方案</h3><p><strong><em>Master-Slave</em></strong></p><ul><li>RDBMS的读写分离即为典型的Master-Slave方案</li><li>同步复制可保证强一致性但会影响可用性</li><li>异步复制可提供高可用性但会降低一致性</li></ul><p><strong><em>WNR</em></strong></p><ul><li>主要用于去中心化的分布式系统中。DynamoDB与Cassandra即采用此方案或其变种</li><li>N代表总副本数，W代表每次写操作要保证的最少写成功的副本数，R代表每次读至少要读取的副本数</li><li>当W+R&gt;N时，可保证每次读取的数据至少有一个副本拥有最新的数据</li><li>多个写操作的顺序难以保证，可能导致多副本间的写操作顺序不一致。Dynamo通过向量时钟保证最终一致性</li></ul><p><strong><em>Paxos及其变种</em></strong></p><ul><li>Google的Chubby，Zookeeper的原子广播协议（Zab），RAFT等</li></ul><p><strong><em>基于ISR的数据复制方案</em></strong><br>如《<a href="http://www.jasongj.com/2015/04/24/KafkaColumn2/#ACK%E5%89%8D%E9%9C%80%E8%A6%81%E4%BF%9D%E8%AF%81%E6%9C%89%E5%A4%9A%E5%B0%91%E4%B8%AA%E5%A4%87%E4%BB%BD" target="_blank" rel="noopener"> Kafka High Availability（上）</a>》一文所述，Kafka的数据复制是以Partition为单位的。而多个备份间的数据复制，通过Follower向Leader拉取数据完成。从一这点来讲，Kafka的数据复制方案接近于上文所讲的Master-Slave方案。不同的是，Kafka既不是完全的同步复制，也不是完全的异步复制，而是基于ISR的动态复制方案。</p><p>ISR，也即In-sync Replica。每个Partition的Leader都会维护这样一个列表，该列表中，包含了所有与之同步的Replica（包含Leader自己）。每次数据写入时，只有ISR中的所有Replica都复制完，Leader才会将其置为Commit，它才能被Consumer所消费。</p><p>这种方案，与同步复制非常接近。但不同的是，这个ISR是由Leader动态维护的。如果Follower不能紧“跟上”Leader，它将被Leader从ISR中移除，待它又重新“跟上”Leader后，会被Leader再次加加ISR中。每次改变ISR后，Leader都会将最新的ISR持久化到Zookeeper中。</p><p>至于如何判断某个Follower是否“跟上”Leader，不同版本的Kafka的策略稍微有些区别。</p><ul><li>对于0.8.*版本，如果Follower在<code>replica.lag.time.max.ms</code>时间内未向Leader发送Fetch请求（也即数据复制请求），则Leader会将其从ISR中移除。如果某Follower持续向Leader发送Fetch请求，但是它与Leader的数据差距在<code>replica.lag.max.messages</code>以上，也会被Leader从ISR中移除。</li><li>从0.9.0.0版本开始，<code>replica.lag.max.messages</code>被移除，故Leader不再考虑Follower落后的消息条数。另外，Leader不仅会判断Follower是否在<code>replica.lag.time.max.ms</code>时间内向其发送Fetch请求，同时还会考虑Follower是否在该时间内与之保持同步。</li><li>0.10.<em> 版本的策略与0.9.</em>版一致</li></ul><p>对于0.8.<em>版本的<code>replica.lag.max.messages</code>参数，很多读者曾留言提问，既然只有ISR中的所有Replica复制完后的消息才被认为Commit，那为何会出现Follower与Leader差距过大的情况。原因在于，Leader并不需要等到前一条消息被Commit才接收后一条消息。事实上，Leader可以按顺序接收大量消息，最新的一条消息的Offset被记为High Wartermark。而只有被ISR中所有Follower都复制过去的消息才会被Commit，Consumer只能消费被Commit的消息。由于Follower的复制是严格按顺序的，所以被Commit的消息之前的消息肯定也已经被Commit过。换句话说，High Watermark标记的是Leader所保存的最新消息的offset，而Commit Offset标记的是最新的可被消费的（已同步到ISR中的Follower）消息。而Leader对数据的接收与Follower对数据的复制是异步进行的，因此会出现Commit Offset与High Watermark存在一定差距的情况。0.8.</em>版本中<code>replica.lag.max.messages</code>限定了Leader允许的该差距的最大值。</p><p>Kafka基于ISR的数据复制方案原理如下图所示。<br><a href="http://www.jasongj.com/img/kafka/KafkaColumn6/kafka-replication.png" target="_blank" rel="noopener"><img src="http://www.jasongj.com/img/kafka/KafkaColumn6/kafka-replication.png" alt="Kafka Replication"></a></p><p>如上图所示，在第一步中，Leader A总共收到3条消息，故其high watermark为3，但由于ISR中的Follower只同步了第1条消息（m1），故只有m1被Commit，也即只有m1可被Consumer消费。此时Follower B与Leader A的差距是1，而Follower C与Leader A的差距是2，均未超过默认的<code>replica.lag.max.messages</code>，故得以保留在ISR中。在第二步中，由于旧的Leader A宕机，新的Leader B在<code>replica.lag.time.max.ms</code>时间内未收到来自A的Fetch请求，故将A从ISR中移除，此时ISR={B，C}。同时，由于此时新的Leader B中只有2条消息，并未包含m3（m3从未被任何Leader所Commit），所以m3无法被Consumer消费。第四步中，Follower A恢复正常，它先将宕机前未Commit的所有消息全部删除，然后从最后Commit过的消息的下一条消息开始追赶新的Leader B，直到它“赶上”新的Leader，才被重新加入新的ISR中。</p><h3 id="使用ISR方案的原因"><a href="#使用ISR方案的原因" class="headerlink" title="使用ISR方案的原因"></a>使用ISR方案的原因</h3><ul><li>由于Leader可移除不能及时与之同步的Follower，故与同步复制相比可避免最慢的Follower拖慢整体速度，也即ISR提高了系统可用性。</li><li>ISR中的所有Follower都包含了所有Commit过的消息，而只有Commit过的消息才会被Consumer消费，故从Consumer的角度而言，ISR中的所有Replica都始终处于同步状态，从而与异步复制方案相比提高了数据一致性。</li><li>ISR可动态调整，极限情况下，可以只包含Leader，极大提高了可容忍的宕机的Follower的数量。与<code>Majority Quorum</code>方案相比，容忍相同个数的节点失败，所要求的总节点数少了近一半。</li></ul><h3 id="ISR相关配置说明"><a href="#ISR相关配置说明" class="headerlink" title="ISR相关配置说明"></a>ISR相关配置说明</h3><ul><li>Broker的<code>min.insync.replicas</code>参数指定了Broker所要求的ISR最小长度，默认值为1。也即极限情况下ISR可以只包含Leader。但此时如果Leader宕机，则该Partition不可用，可用性得不到保证。</li><li>只有被ISR中所有Replica同步的消息才被Commit，但Producer发布数据时，Leader并不需要ISR中的所有Replica同步该数据才确认收到数据。Producer可以通过<code>acks</code>参数指定最少需要多少个Replica确认收到该消息才视为该消息发送成功。<code>acks</code>的默认值是1，即Leader收到该消息后立即告诉Producer收到该消息，此时如果在ISR中的消息复制完该消息前Leader宕机，那该条消息会丢失。而如果将该值设置为0，则Producer发送完数据后，立即认为该数据发送成功，不作任何等待，而实际上该数据可能发送失败，并且Producer的Retry机制将不生效。更推荐的做法是，将<code>acks</code>设置为<code>all</code>或者<code>-1</code>，此时只有ISR中的所有Replica都收到该数据（也即该消息被Commit），Leader才会告诉Producer该消息发送成功，从而保证不会有未知的数据丢失。</li></ul><h1 id="具体实现层面"><a href="#具体实现层面" class="headerlink" title="具体实现层面"></a>具体实现层面</h1><h2 id="高效使用磁盘"><a href="#高效使用磁盘" class="headerlink" title="高效使用磁盘"></a>高效使用磁盘</h2><h3 id="顺序写磁盘"><a href="#顺序写磁盘" class="headerlink" title="顺序写磁盘"></a>顺序写磁盘</h3><p>根据《<a href="http://deliveryimages.acm.org/10.1145/1570000/1563874/jacobs3.jpg" title="一些场景下顺序写磁盘快于随机写内存" target="_blank" rel="noopener">一些场景下顺序写磁盘快于随机写内存</a>》所述，将写磁盘的过程变为顺序写，可极大提高对磁盘的利用率。</p><p>Kafka的整个设计中，Partition相当于一个非常长的数组，而Broker接收到的所有消息顺序写入这个大数组中。同时Consumer通过Offset顺序消费这些数据，并且不删除已经消费的数据，从而避免了随机写磁盘的过程。</p><p>由于磁盘有限，不可能保存所有数据，实际上作为消息系统Kafka也没必要保存所有数据，需要删除旧的数据。而这个删除过程，并非通过使用“读-写”模式去修改文件，而是将Partition分为多个Segment，每个Segment对应一个物理文件，通过删除整个文件的方式去删除Partition内的数据。这种方式清除旧数据的方式，也避免了对文件的随机写操作。</p><p>通过如下代码可知，Kafka删除Segment的方式，是直接删除Segment对应的整个log文件和整个index文件而非删除文件中的部分内容。</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment"> * Delete this log segment from the filesystem.</span></span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment"> * @throws KafkaStorageException if the delete fails.</span></span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">delete</span></span>() &#123;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">val</span> deletedLog = log.delete()</span><br><span class="line"></span><br><span class="line"> <span class="keyword">val</span> deletedIndex = index.delete()</span><br><span class="line"></span><br><span class="line"> <span class="keyword">val</span> deletedTimeIndex = timeIndex.delete()</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span>(!deletedLog &amp;&amp; log.file.exists)</span><br><span class="line"></span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="type">KafkaStorageException</span>(<span class="string">"Delete of log "</span> + log.file.getName + <span class="string">" failed."</span>)</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span>(!deletedIndex &amp;&amp; index.file.exists)</span><br><span class="line"></span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="type">KafkaStorageException</span>(<span class="string">"Delete of index "</span> + index.file.getName + <span class="string">" failed."</span>)</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span>(!deletedTimeIndex &amp;&amp; timeIndex.file.exists)</span><br><span class="line"></span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="type">KafkaStorageException</span>(<span class="string">"Delete of time index "</span> + timeIndex.file.getName + <span class="string">" failed."</span>)</span><br><span class="line"></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="充分利用Page-Cache"><a href="#充分利用Page-Cache" class="headerlink" title="充分利用Page Cache"></a>充分利用Page Cache</h3><p>使用Page Cache的好处如下</p><ul><li>I/O Scheduler会将连续的小块写组装成大块的物理写从而提高性能</li><li>I/O Scheduler会尝试将一些写操作重新按顺序排好，从而减少磁盘头的移动时间</li><li>充分利用所有空闲内存（非JVM内存）。如果使用应用层Cache（即JVM堆内存），会增加GC负担</li><li>读操作可直接在Page Cache内进行。如果消费和生产速度相当，甚至不需要通过物理磁盘（直接通过Page Cache）交换数据</li><li>如果进程重启，JVM内的Cache会失效，但Page Cache仍然可用</li></ul><p>Broker收到数据后，写磁盘时只是将数据写入Page Cache，并不保证数据一定完全写入磁盘。从这一点看，可能会造成机器宕机时，Page Cache内的数据未写入磁盘从而造成数据丢失。但是这种丢失只发生在机器断电等造成操作系统不工作的场景，而这种场景完全可以由Kafka层面的Replication机制去解决。如果为了保证这种情况下数据不丢失而强制将Page Cache中的数据Flush到磁盘，反而会降低性能。也正因如此，Kafka虽然提供了<code>flush.messages</code>和<code>flush.ms</code>两个参数将Page Cache中的数据强制Flush到磁盘，但是Kafka并不建议使用。</p><p>如果数据消费速度与生产速度相当，甚至不需要通过物理磁盘交换数据，而是直接通过Page Cache交换数据。同时，Follower从Leader Fetch数据时，也可通过Page Cache完成。下图为某Partition的Leader节点的网络/磁盘读写信息。</p><p><a href="http://www.jasongj.com/img/kafka/KafkaColumn6/kafka_IO.png" target="_blank" rel="noopener"><img src="http://www.jasongj.com/img/kafka/KafkaColumn6/kafka_IO.png" alt="Kafka I/O page cache"></a></p><p>从上图可以看到，该Broker每秒通过网络从Producer接收约35MB数据，虽然有Follower从该Broker Fetch数据，但是该Broker基本无读磁盘。这是因为该Broker直接从Page Cache中将数据取出返回给了Follower。</p><h3 id="支持多Disk-Drive"><a href="#支持多Disk-Drive" class="headerlink" title="支持多Disk Drive"></a>支持多Disk Drive</h3><p>Broker的<code>log.dirs</code>配置项，允许配置多个文件夹。如果机器上有多个Disk Drive，可将不同的Disk挂载到不同的目录，然后将这些目录都配置到<code>log.dirs</code>里。Kafka会尽可能将不同的Partition分配到不同的目录，也即不同的Disk上，从而充分利用了多Disk的优势。</p><h2 id="零拷贝"><a href="#零拷贝" class="headerlink" title="零拷贝"></a>零拷贝</h2><p>Kafka中存在大量的网络数据持久化到磁盘（Producer到Broker）和磁盘文件通过网络发送（Broker到Consumer）的过程。这一过程的性能直接影响Kafka的整体吞吐量。</p><h3 id="传统模式下的四次拷贝与四次上下文切换"><a href="#传统模式下的四次拷贝与四次上下文切换" class="headerlink" title="传统模式下的四次拷贝与四次上下文切换"></a>传统模式下的四次拷贝与四次上下文切换</h3><p>以将磁盘文件通过网络发送为例。传统模式下，一般使用如下伪代码所示的方法先将文件数据读入内存，然后通过Socket将内存中的数据发送出去。</p><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">buffer = File.read</span><br><span class="line"></span><br><span class="line">Socket.send(buffer)</span><br></pre></td></tr></table></figure><p>这一过程实际上发生了四次数据拷贝。首先通过系统调用将文件数据读入到内核态Buffer（DMA拷贝），然后应用程序将内存态Buffer数据读入到用户态Buffer（CPU拷贝），接着用户程序通过Socket发送数据时将用户态Buffer数据拷贝到内核态Buffer（CPU拷贝），最后通过DMA拷贝将数据拷贝到NIC Buffer。同时，还伴随着四次上下文切换，如下图所示。</p><p><a href="http://www.jasongj.com/img/kafka/KafkaColumn6/BIO.png" target="_blank" rel="noopener"><img src="http://www.jasongj.com/img/kafka/KafkaColumn6/BIO.png" alt="BIO 四次拷贝 四次上下文切换"></a></p><h3 id="sendfile和transferTo实现零拷贝"><a href="#sendfile和transferTo实现零拷贝" class="headerlink" title="sendfile和transferTo实现零拷贝"></a>sendfile和transferTo实现零拷贝</h3><p>Linux 2.4+内核通过<code>sendfile</code>系统调用，提供了零拷贝。数据通过DMA拷贝到内核态Buffer后，直接通过DMA拷贝到NIC Buffer，无需CPU拷贝。这也是零拷贝这一说法的来源。除了减少数据拷贝外，因为整个读文件-网络发送由一个<code>sendfile</code>调用完成，整个过程只有两次上下文切换，因此大大提高了性能。零拷贝过程如下图所示。</p><p><a href="http://www.jasongj.com/img/kafka/KafkaColumn6/NIO.png" target="_blank" rel="noopener"><img src="http://www.jasongj.com/img/kafka/KafkaColumn6/NIO.png" alt="BIO 零拷贝 两次上下文切换"></a></p><p>从具体实现来看，Kafka的数据传输通过TransportLayer来完成，其子类<code>PlaintextTransportLayer</code>通过<a href="http://www.jasongj.com/java/nio_reactor/" target="_blank" rel="noopener">Java NIO</a>的FileChannel的<code>transferTo</code>和<code>transferFrom</code>方法实现零拷贝，如下所示。 </p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Override</span></span><br><span class="line"></span><br><span class="line">public long transferFrom(<span class="type">FileChannel</span> fileChannel, long position, long count) <span class="keyword">throws</span> <span class="type">IOException</span> &#123;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> fileChannel.transferTo(position, count, socketChannel);</span><br><span class="line"></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>注：</strong> <code>transferTo</code>和<code>transferFrom</code>并不保证一定能使用零拷贝。实际上是否能使用零拷贝与操作系统相关，如果操作系统提供<code>sendfile</code>这样的零拷贝系统调用，则这两个方法会通过这样的系统调用充分利用零拷贝的优势，否则并不能通过这两个方法本身实现零拷贝。</p><h2 id="减少网络开销"><a href="#减少网络开销" class="headerlink" title="减少网络开销"></a>减少网络开销</h2><h3 id="批处理"><a href="#批处理" class="headerlink" title="批处理"></a>批处理</h3><p>批处理是一种常用的用于提高I/O性能的方式。对Kafka而言，批处理既减少了网络传输的Overhead，又提高了写磁盘的效率。</p><p>Kafka 0.8.1及以前的Producer区分同步Producer和异步Producer。同步Producer的send方法主要分两种形式。一种是接受一个KeyedMessage作为参数，一次发送一条消息。另一种是接受一批KeyedMessage作为参数，一次性发送多条消息。而对于异步发送而言，无论是使用哪个send方法，实现上都不会立即将消息发送给Broker，而是先存到内部的队列中，直到消息条数达到阈值或者达到指定的Timeout才真正的将消息发送出去，从而实现了消息的批量发送。</p><p>Kafka 0.8.2开始支持新的Producer API，将同步Producer和异步Producer结合。虽然从send接口来看，一次只能发送一个ProducerRecord，而不能像之前版本的send方法一样接受消息列表，但是send方法并非立即将消息发送出去，而是通过<code>batch.size</code>和<code>linger.ms</code>控制实际发送频率，从而实现批量发送。</p><p>由于每次网络传输，除了传输消息本身以外，还要传输非常多的网络协议本身的一些内容（称为Overhead），所以将多条消息合并到一起传输，可有效减少网络传输的Overhead，进而提高了传输效率。</p><p>从<a href="http://www.jasongj.com/img/kafka/KafkaColumn6/kafka_IO.png" target="_blank" rel="noopener">零拷贝章节的图</a>中可以看到，虽然Broker持续从网络接收数据，但是写磁盘并非每秒都在发生，而是间隔一段时间写一次磁盘，并且每次写磁盘的数据量都非常大（最高达到718MB/S）。</p><h3 id="数据压缩降低网络负载"><a href="#数据压缩降低网络负载" class="headerlink" title="数据压缩降低网络负载"></a>数据压缩降低网络负载</h3><p>Kafka从0.7开始，即支持将数据压缩后再传输给Broker。除了可以将每条消息单独压缩然后传输外，Kafka还支持在批量发送时，将整个Batch的消息一起压缩后传输。数据压缩的一个基本原理是，重复数据越多压缩效果越好。因此将整个Batch的数据一起压缩能更大幅度减小数据量，从而更大程度提高网络传输效率。</p><p>Broker接收消息后，并不直接解压缩，而是直接将消息以压缩后的形式持久化到磁盘。Consumer Fetch到数据后再解压缩。因此Kafka的压缩不仅减少了Producer到Broker的网络传输负载，同时也降低了Broker磁盘操作的负载，也降低了Consumer与Broker间的网络传输量，从而极大得提高了传输效率，提高了吞吐量。</p><h3 id="高效的序列化方式"><a href="#高效的序列化方式" class="headerlink" title="高效的序列化方式"></a>高效的序列化方式</h3><p>Kafka消息的Key和Payload（或者说Value）的类型可自定义，只需同时提供相应的序列化器和反序列化器即可。因此用户可以通过使用快速且紧凑的序列化-反序列化方式（如Avro，Protocal Buffer）来减少实际网络传输和磁盘存储的数据规模，从而提高吞吐率。这里要注意，如果使用的序列化方法太慢，即使压缩比非常高，最终的效率也不一定高。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;本文从宏观架构层面和微观实现层面分析了Kafka如何实现高性能。包含Kafka如何利用Partition实现并行处理和提供水平扩展能力，如何通过ISR实现可用性和数据一致性的动态平衡，如何使用NIO和Linux的sendfile实现零拷贝以及如何通过顺序读写和数据压缩实现磁盘的高效利用。&lt;br&gt;
    
    </summary>
    
      <category term="kafka" scheme="http://crazycarry.github.io/categories/kafka/"/>
    
    
      <category term="架构设计" scheme="http://crazycarry.github.io/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/"/>
    
      <category term="源码分析" scheme="http://crazycarry.github.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"/>
    
      <category term="消息中间件" scheme="http://crazycarry.github.io/tags/%E6%B6%88%E6%81%AF%E4%B8%AD%E9%97%B4%E4%BB%B6/"/>
    
      <category term="大数据" scheme="http://crazycarry.github.io/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
    
  </entry>
  
  <entry>
    <title>Kafka controller架构分析</title>
    <link href="http://crazycarry.github.io/2018/04/04/kafka-controller-source/"/>
    <id>http://crazycarry.github.io/2018/04/04/kafka-controller-source/</id>
    <published>2018-04-04T09:42:52.000Z</published>
    <updated>2018-04-04T10:23:54.011Z</updated>
    
    <content type="html"><![CDATA[<p>kafka在0.8版本前没有提供Partition的Replication机制，一旦Broker宕机，其上的所有Partition就都无法提供服务，而Partition又没有备份数据，数据的可用性就大大降低了。所以0.8后提供了Replication机制来保证Broker的failover。由于Partition有多个副本，为了保证多个副本之间的数据同步，有多种方案：</p><ul><li>1.所有副本之间是无中心结构的，可同时读写数据，需要保证多个副本之间数据的同步 </li><li>2.在所有副本中选择一个Leader，生产者和消费者只和Leader副本交互，其他follower副本从Leader同步数据<a id="more"></a><h2 id="Replication"><a href="#Replication" class="headerlink" title="Replication"></a>Replication</h2></li></ul><h3 id="数据同步"><a href="#数据同步" class="headerlink" title="数据同步"></a>数据同步</h3><p>kafka在0.8版本前没有提供Partition的Replication机制，一旦Broker宕机，其上的所有Partition就都无法提供服务，而Partition又没有备份数据，数据的可用性就大大降低了。所以0.8后提供了Replication机制来保证Broker的failover。由于Partition有多个副本，为了保证多个副本之间的数据同步，有多种方案：</p><ul><li>1.所有副本之间是无中心结构的，可同时读写数据，需要保证多个副本之间数据的同步</li><li>2.在所有副本中选择一个Leader，生产者和消费者只和Leader副本交互，其他follower副本从Leader同步数据</li></ul><p>第一种方案看起来可以对客户端请求进行负载均衡，但是由于要在多个副本之间互相同步数据，数据的一致性和有序性难以保证。而第二种方案看起来客户端连接的节点会少点，而且其他副本同步数据可能没有那么及时，但是在正常情况下，客户端只需要和Leader一个副本通信即可，而其他follower只需要和Leader同步数据。假设有一个Partition有5个副本，4个follower只需要各自和leader建立一条链路通信，而对于第一种方案，5个副本之间要两两通信，确保Partition的每个副本的数据都是一致的。所以第一种方案虽然提供了客户端的负载均衡，但是对于服务端的设计带来比较大的复杂性，而第二种方案虽然限制了客户端只能连接Partition的Leader Replica，但这种简洁的设计使得服务端更加健壮。</p><p><a href="http://img.blog.csdn.net/20160310090207079" title="k_replica_design" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160310090207079" alt="" title="k_replica_design"></a></p><h3 id="同步策略"><a href="#同步策略" class="headerlink" title="同步策略"></a>同步策略</h3><p>存在Replication的目的是为了在Leader发生故障时，follower副本能够代替Leader副本继续工作，即新的Leader必须拥有原来的Leader提交过的所有消息，那么在任何时刻follower要保证和Leader比起来总是最新的，或者说follower要和leader的数据始终保持同步。</p><p>有两种策略保证副本和leader是同步的，<code>primary-backup replication</code>和<code>quorum-based replication</code>。这两种模式下都会选举一个副本作为leader，其他的副本都作为follower。所有的写请求都会经过leader，然后leader会将写传播给follower（kafka因为使用pull模式，所以是follower从leader拉取数据，不过总的来说，目的都是将leader的数据同步给所有的follower）。</p><p>使用基于<strong>quorum</strong>的复制方式（也叫做<strong>Majority Vote</strong>，少数服从多数），leader需要等待副本集合中大多数的写操作完成（大多数的概念是指一半以上）。如果一些副本当掉了，副本组的大小也不会发生变化，这就会导致写操作无法写到失败的副本上，就无法满足半数以上的限制条件。这种模式下，假设有2N+1个副本，在提交之前要确保有N+1个副本复制完消息，为了保证正确选出新的Leader，失败的副本数不能超过N个。因为在剩下的任意N+1个Replica里，至少有一个Replica包含有最新的所有消息。这种策略的缺点是：为了保证Leader选举的正常进行，它所能容忍的失败的follower个数比较少，如果要容忍N个follower挂掉，必须要有2N+1个以上的副本。</p><p><a href="http://img.blog.csdn.net/20160309085146309" title="k_major_vote" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160309085146309" alt="" title="k_major_vote"></a></p><p>使用<strong>主备</strong>模式复制，leader会等待组（所有的副本组成的集合）中所有副本写成功后才返回应答给客户端。如果其中一个副本当掉了，leader会将它从组中删除掉，并继续向剩余的副本写数据。一个失败的副本在恢复之后如果能赶上Leader，leader就会允许它重新加入组中。kafka中这个组的概念就是Leader副本的ISR集合。这个ISR里的所有副本都跟上了Leader，只有ISR里的成员才有被选为Leader的可能。这种模式下对于N+1个副本，一个Partition能在保证不丢失已经提交的消息的前提下容忍N个副本的失败（只要有一个副本没有失败，其他失败了都没有关系）。比较Majority Vote和ISR，为了容忍N个副本的失败，两者在<strong>提交前需要等待</strong>的副本数量是一样的（ISR中有N+1个副本才可以容忍N个副本失败，而Majority Vote副本总数=2N+1，N+1个副本成功，才能容忍N个副本失败，所以需要等待的副本都是N+1个。这里的等待一般是将请求发送到N+1个副本后，要等待这些副本应答后才表示成功提交），但是ISR需要的总的副本数几乎是Majority Vote的一半（假设ISR中所有副本都能跟上Leader，则一共也之后N+1个副本，而Majority Vote则需要2N+1个副本）。</p><h3 id="leader选举"><a href="#leader选举" class="headerlink" title="leader选举"></a>leader选举</h3><p>Leader选举本质上是一个分布式锁，有两种方式实现基于ZooKeeper的分布式锁：</p><ul><li>1.节点名称唯一性：多个客户端创建一个节点，只有成功创建节点的客户端才能获得锁</li><li>2.临时顺序节点：所有客户端在某个目录下创建自己的临时顺序节点，只有序号最小的才获得锁</li></ul><p>Majority Vote的选举策略和ZooKeeper中的Zab选举是类似的，实际上ZooKeeper内部本身就实现了少数服从多数的选举策略。kafka中对于Partition的leader副本的选举采用了第一种方法：为Partition分配副本，指定一个ZNode临时节点，第一个成功创建节点的副本就是Leader节点，其他副本会在这个ZNode节点上注册Watcher监听器，一旦Leader宕机，对应的临时节点就会被自动删除，这时注册在该节点上的所有Follower都会收到监听器事件，它们都会尝试创建该节点，只有创建成功的那个follower才会成为Leader（ZooKeeper保证对于一个节点只有一个客户端能创建成功），其他follower继续重新注册监听事件。</p><h3 id="副本放置策略"><a href="#副本放置策略" class="headerlink" title="副本放置策略"></a>副本放置策略</h3><p>kafka将一个逻辑意义的topic分成多个物理意义上的partition，每个partition是这个topic的一部分消息，将partition分布在多个brokers上，可以达到负载均衡的目的。除了Partition的分配，每个Partition都有Replcias，所以也要考虑Replica的分配策略，因为不能把一个Partition的所有Replicas都放在同一台机器上。一般我们希望将所有的Partition能够均匀地分布在集群中所有的Brokers上。Kafka分配Replica的算法如下：</p><ul><li>将所有存活的N个Brokers和待分配的Partition排序</li><li>将第i个Partition分配到第(i mod n)个Broker上，这个Partition的第一个Replica存在于这个分配的Broker上，并且会作为partition的优先副本</li><li>将第i个Partition的第j个Replica分配到第((i + j) mod n)个Broker上</li></ul><p>假设集群一共有4个brokers，一个topic有4个partition，每个Partition有3个副本。下图是每个Broker上的副本分配情况。</p><p><a href="http://img.blog.csdn.net/20160224133413052" title="k_partition" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160224133413052" alt="" title="k_partition"></a></p><h3 id="Follower-failure"><a href="#Follower-failure" class="headerlink" title="Follower failure"></a>Follower failure</h3><p>如果Follower Replica失败超过一定时间后，Leader会将这个失败的follower从ISR中移除（follower没有发送fetch请求）。由于ISR保存的是所有全部赶得上Leader的follower replicas，失败的follower肯定是赶不上了。虽然ISR现在少了一个，但是并不会引起的数据的丢失，ISR中剩余的replicas会继续同步数据（只要ISR中有一个follower，就不会丢失数据，实际上leader replica也是在ISR中的，所以即使所有的follower都挂掉了，只要leader没有问题，也不会出现问题的）。</p><p>当失败的follower恢复时，它首先将自己的日志截断到上次checkpointed时刻的HW（checkpoint是每个broker节点都有的）。因为checkpoint记录的是所有Partition的hw offset。当follower失败时，checkpoint中关于这个Partition的HW就不会再更新了。而这个时候存储的HW信息和follower partition replica的offset并不一定是一致的。比如这个follower获取消息比较快，但是ISR中有其他follower复制消息比较慢，这样Leader并不会很快地更新HW，这个快的follower的hw也不会更新（leader广播hw给follower）。这种情况下，这个follower日志的offset是比hw要大的。所以在它恢复之后，要将比hw多的部分截掉，然后继续从leader拉取消息（跟平时一样）。实际上，ISR中的每个follower日志的offset一定是比hw大的。因为只有ISR中所有follower都复制完消息，leader才会增加hw，而每个Replica复制消息后，都会增加自己的offset。也就是说有可能有些follower复制完了，而有另外一些follower还没有复制完，那么hw是不会增加的。</p><p><a href="http://img.blog.csdn.net/20160310090248232" title="k_replica_hw" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160310090248232" alt="" title="k_replica_hw"></a></p><p>Kafka的Replica实现到目前0.9版本为止，分别经历了三个阶段：</p><table><thead><tr><th style="text-align:center">阶段</th><th style="text-align:center">主要做法</th><th style="text-align:center">优缺点</th></tr></thead><tbody><tr><td style="text-align:center">1</td><td style="text-align:center">zookeeper，很多监听器</td><td style="text-align:center">脑裂，羊群效应，ZK集群负载过重</td></tr><tr><td style="text-align:center">2</td><td style="text-align:center">one brain，zk queue</td><td style="text-align:center">将Replcia改变的状态事件用ZK队列实现</td></tr><tr><td style="text-align:center">3</td><td style="text-align:center">state machine，controller, direct rpc</td><td style="text-align:center">直接RPC更快，状态机统一处理</td></tr></tbody></table><h3 id="zk-path-amp-listeners"><a href="#zk-path-amp-listeners" class="headerlink" title="zk path &amp; listeners"></a>zk path &amp; listeners</h3><p>下表汇总了zk中和partition相关的节点，对于不同版本，路径可能不同，不过要存储的信息都是类似的：</p><table><thead><tr><th style="text-align:center">zookeeper path</th><th style="text-align:center">type and creator</th><th style="text-align:center">value</th></tr></thead><tbody><tr><td style="text-align:center"><em>v1</em></td></tr><tr><td style="text-align:center">/brokers/ids/[broker_id]–&gt;host:port</td><td style="text-align:center">临时节点,admin</td><td style="text-align:center">所有存活的Brokers的信息</td></tr><tr><td style="text-align:center">/brokers/topics/[topic]/[partition_id]/<strong>replicas</strong>–&gt;{broker_id …}</td><td style="text-align:center">admin</td><td style="text-align:center">每个Partition当前分配的Replicas(AR)</td></tr><tr><td style="text-align:center">brokers/topics/[topic]/[partition_id]/<strong>leader</strong>–&gt;broker_id</td><td style="text-align:center">临时节点,leader</td><td style="text-align:center">这个partition的当前leader副本</td></tr><tr><td style="text-align:center">/brokers/topics/[topic]/[partition_id]/<strong>ISR</strong>–&gt;{broker_id, …}</td><td style="text-align:center">leader</td><td style="text-align:center">保持和leader同步的replica编号</td></tr><tr><td style="text-align:center"><em>v2</em></td></tr><tr><td style="text-align:center">/brokers/topics/[topic]/[partition_id]/<strong>leaderAndISR</strong>–&gt;   {leader:broker_id,ISR:{broker1..}}</td><td style="text-align:center">controller,leader</td><td style="text-align:center">Partition的Leader和ISR</td></tr><tr><td style="text-align:center"><em>v3</em></td></tr><tr><td style="text-align:center">/brokers/topics/<strong>[topic]</strong>–&gt;{part1: [broker1, broker2]…}</td><td style="text-align:center">admin</td><td style="text-align:center">topic中所有Partition的AR</td></tr><tr><td style="text-align:center">/brokers/topics/[topic]/[partition_id]/<strong>state</strong>–&gt;   {leader:broker_id,ISR:{broker1..}}</td><td style="text-align:center">controller,leader</td><td style="text-align:center">Partition的状态信息类似leaderAndISR</td></tr><tr><td style="text-align:center">/controller –&gt; {brokerid}</td><td style="text-align:center">controller</td><td style="text-align:center">当前集群的controller节点</td></tr><tr><td style="text-align:center"><em>common</em></td></tr><tr><td style="text-align:center">/admin/partitions_reassigned/[topic]/[partition_id]–&gt;{broker_id …}</td><td style="text-align:center">admin</td><td style="text-align:center">重新分配partition</td></tr><tr><td style="text-align:center">/admin/partitions_add/[topic]/[partition_id]–&gt;{broker_id …}</td><td style="text-align:center">admin</td><td style="text-align:center">新添加partition对应的AR</td></tr><tr><td style="text-align:center">/admin/partitions_remove/[topic]/[partition_id]</td><td style="text-align:center">admin</td><td style="text-align:center">删除topic中已经存在的partition</td></tr></tbody></table><p>在v2版本中，Partition重新分配的监听器只注册在leader的Broker上，而Leader和State变化的监听器注册在所有Broker上。一个Partition的多个Replica会分布在多个Broker上，只有在Leader Replica的Broker上才注册Partition-Reassigned监听器。而Leader和State变化对于Partition的所有副本都要能够感知，所以一个Partition所有Replica分布的Broker都要注册Leader-change和State-change监听器。</p><p><a href="http://img.blog.csdn.net/20160310104717825" title="k_replica_listeners" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160310104717825" alt="" title="k_replica_listeners"></a></p><h3 id="Broker-Partition和Replica的事件流"><a href="#Broker-Partition和Replica的事件流" class="headerlink" title="Broker,Partition和Replica的事件流"></a>Broker,Partition和Replica的事件流</h3><p>Partition的Replica存储在Broker上，并且和ZooKeeper互相交互，主要完成这些工作：</p><ul><li>1.Broker启动读取ZK中Partition的所有AR</li><li>2.每个Replica判断Leader是否存在，不存在则进入选举阶段，存在则使自己成为Follower</li><li>3.每个Replica都会在leader上注册监听器，当Leader发生变化时，所有Replica会开始选举Leader</li><li>4.选举Leader时，最先创建leader节点的Replica成为Leader，其他Replica再次成为Follower</li></ul><p><a href="http://img.blog.csdn.net/20160310130841867" title="k_p_r_b_z" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160310130841867" alt="" title="k_p_r_b_z"></a></p><p>v1版本对于每个replica变化都会触发replicaStateChange调用。</p><p><a href="http://img.blog.csdn.net/20160310104453777" title="k_replica_v1" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160310104453777" alt="" title="k_replica_v1"></a></p><p>v1版本中，由于各种事件严重依赖ZooKeeper，存在着以下问题：</p><ul><li>脑裂：虽然ZooKeeper能保证注册到节点上的所有监听器都会按顺序被触发，但并不能保证同一个时刻所有副本看到的状态是一样的，可能造成不同副本的响应不一致</li><li>羊群效应：如果宕机的那个Broker的Partition数量很多，会造成多个Watch被触发，引起集群内大量的调整</li><li>每个副本都要在ZK的Partition上注册Watcher，当集群内Partition数量很多时，会造成ZooKeeper负载过重</li></ul><p><a href="http://img.blog.csdn.net/20160309092701811" title="zk_listener_problem" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160309092701811" alt="" title="zk_listener_problem"></a></p><p>v2版本不再为replicas注册Replica变化的事件，而是放到broker级别的状态变化事件。而且v2的startReplica和v1的replicaStateChange功能是相似的，都是完成replicas发生变化时判断是否需要选举或成为follower。</p><p><a href="http://img.blog.csdn.net/20160310104507199" title="k_replica_v2" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160310104507199" alt="" title="k_replica_v2"></a></p><p>所有的brokers只对状态变化事件进行响应，而且状态变化事件也只由Leader决定，状态变化是通过onPartitionsReassigned写到zk的<code>/brokers/state/[broker_id]</code>的请求事件队列触发的，而这个事件只注册在Partition的Leader Replica上。也就是说follower状态的改变只基于partition的Leader发送请求时才会发生，如果leader没有对某个follower说要改变状态，则follower是不会有任何事件发生的，这种方式类似于事件驱动的模式（引入了一个中间的状态机）。而第一版中任何一个replica改变，都有可能导致所有其他follower都要做出相应（直接触发）。</p><p><a href="http://img.blog.csdn.net/20160310145330545" title="k_replica2state" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160310145330545" alt="" title="k_replica2state"></a></p><p>注意：replicas的状态变化通过队列的形式并不会立即被触发，而leader变化事件要能立即被触发，所以要给这个事件单独注册Leader-Change监听器，而不能统一都放在State-Change监听器里，否则的话，leader变化事件可能会淹没在replicas的状态变化事件里，而没有被及时地处理（Leade变化这个事件是大BOSS发话，要立即执行，replicas的状态变化是其他领导发话，要进行排期依次处理）。</p><p>在这两个版本中，Partition都有一个commitQ队列用来缓存生产请求，并且每个Partition都有一个commit线程负责将消息写到Leader的本地日志中，并等待ISR中所有副本复制。Follower会向Leader抓取数据来保持和Leader的消息同步。</p><p><a href="http://img.blog.csdn.net/20160311085103389" title="k_produce_fetch" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160311085103389" alt="" title="k_produce_fetch"></a></p><p>在Follower向Leader抓取数据时，要根据Follower的抓取进度判断是否应该把它加入ISR或从从ISR中移除：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">maybe_change_ISR() &#123;</span><br><span class="line"> <span class="comment">// 如果follower太慢会被leader从ISR中移除,确保leader可以顺利提交消息尽管isr的副本数变少了</span></span><br><span class="line"> find the smallest leo (leo_min) of every replica in <span class="type">ISR</span></span><br><span class="line"> <span class="keyword">if</span> ( leader.leo - leo_min &gt; <span class="type">MAX_BYTES_LAG</span> || </span><br><span class="line"> the replica <span class="keyword">with</span> leo_min hasn<span class="symbol">'t</span> updated leo <span class="keyword">for</span> more than <span class="type">MAX_TIME_LAG</span>)</span><br><span class="line"> newISR = <span class="type">ISR</span> - replica <span class="keyword">with</span> leo_min</span><br><span class="line">  </span><br><span class="line"> <span class="comment">//如果follower赶上了leader,加入到ISR中</span></span><br><span class="line"> <span class="keyword">for</span> each replica r not in <span class="type">ISR</span></span><br><span class="line"> <span class="keyword">if</span> ( r.leo - leo_min </span><br><span class="line"> newISR = <span class="type">ISR</span> + r</span><br><span class="line">  </span><br><span class="line"> update the <span class="type">LeaderAndISR</span> path in <span class="type">ZK</span> <span class="keyword">with</span> newISR</span><br><span class="line"> <span class="keyword">if</span> (update in <span class="type">ZK</span> successful) &#123;</span><br><span class="line"> leader.partition.<span class="type">ISR</span> = <span class="keyword">new</span> <span class="type">ISR</span></span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="Controller要解决的问题"><a href="#Controller要解决的问题" class="headerlink" title="Controller要解决的问题"></a>Controller要解决的问题</h2><p>Replication的v3版本采用Controller实现，相比v2版本的主要不同点是：</p><ul><li>Leader的变化从监听器改为由Controller管理</li><li>控制器负责检测Broker的失败，并为每个受影响的Partition选举新的Leader</li><li>控制器会将每个Leader的变化事件发送给受影响的每个Broker</li><li>控制器和Broker之间的通信采用直接的RPC，而不是通过ZK队列</li></ul><p>虽然因为引入了Controller，需要实现Controller的failover。但它的优点有：</p><ul><li>因为Leader管理被更加集中地管理，比较容易调试问题</li><li>Leader变化针对ZK的读写可以批量操作，减少在failover过程中端到端的延迟</li><li>更少的ZooKeeper监听器</li><li>使用直接RPC协议相比队列实现的ZK，能够更加高效地在节点之间通信</li></ul><p>从整个集群的所有Brokers中选举出一个Controller，它主要负责：</p><ul><li>Partition的Leader变化事件</li><li>新创建或删除一个topic</li><li>重新分配Partition</li><li>管理分区的状态机和副本的状态机</li></ul><p>当控制器完成决策之后（决定了Partition新的Leader和ISR），它会将这个决策持久化到ZK中（将LeaderAndISR写入到ZK节点），并且向所有受到影响的Brokers通过直接RPC的形式直接发送新的决策（发送LeaderAndISRRequest）。这些决策（持久化到ZK中的数据）是真理之源，它们会被客户端用来路由请求（客户端只跟决策信息里的Leader交互），并且每个Broker启动的时候会用来恢复它的状态（Partition分配到Broker上，说明Broker现在拥有了分配到的Partition）。当Broker启动之后，它会接收控制器通过直接RPC的形式发送过来的最新的决策。</p><p>每个KafkaServer中都会创建一个KafkaController对象，但是集群中只允许存在一个Leader Controller，这是通过ZooKeeper选举出来的：第一个成功创建zk节点的那个Controller会成为Leader，其他的Controller会一直存在，但是并不会发挥作用。只有当原先的Controller挂掉后，才会选举出新的Controller。<strong>集群中所有Brokers选举一个Controller</strong>和<strong>Partition中所有Replicas选举一个Leader</strong>是类似的。有点类似于Hadoop-1.x中的SecondaryNameNode：同时启动NameNode和SecondaryNameNode，当NameNode挂掉后，SecondaryNameNode会代替成为NameNode角色。在系统运行过程中，SecondaryNameNode要同步NameNode的数据，这样在NameNode发生故障时，SecondaryNameNode切换为NameNode时数据并不会丢失或落后太多。Hadoop-2.x的HA使用了ZKFailoverController有两个Master：Active和Standby，工作原理和SNN是类似的，只不过Active Master将信息写入共享存储，Standby从共享存储中读取信息，保持与Active Master的同步，从而减少故障时的切换时间。不过KafkaController的failover机制并不会在系统运行过程中将其他所有Controller实例的数据和Leader同步，而是在Leader发生故障时重新选举，并且重新恢复数据。</p><p>原先在没有Controller时，Leader或者Replica的变化都是通过监听器完成的，现在引入Controller之后，不仅要处理Broker的failover，也要处理Controller的failover。Broker和Controller的failover也是通过注册在ZK上的Watcher监听器完成的，所有的Brokers监听一个Controller，Controller也监听所有的Controller，两者互相关注。</p><p><a href="http://img.blog.csdn.net/20160311085124561" title="k_controller_failover" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160311085124561" alt="" title="k_controller_failover"></a></p><h3 id="Broker-Failover：on-broker-change"><a href="#Broker-Failover：on-broker-change" class="headerlink" title="Broker Failover：on_broker_change"></a>Broker Failover：on_broker_change</h3><p>Broker失败，Controller注册在/brokers/ids的Watcher会触发调用onBrokerFailure。失败的Broker上的Replica可能是某个Partition的Leader也可能是某个Partition的follower。如果是Partition的Leader，那么Controller要确保有其他Broker成为这个Partition的Leader，如果是Partition的Follower，虽然不需要重新选举Leader，但是有可能Partition的ISR会变化。所以Controller首先会读取ZK中的ISR/AR选举新的Leader和ISR，然后把这个信息先保存到ZK中，最后把包含了新Leader和ISR的LeaderAndISR指令发送给受到影响的Brokers。收到指令的Broker如果Controller命令它成为某个Partition的Leader，那么原先为Follower副本的Replica就会becomeLeader。下面举个例子说明这个过程：</p><ul><li>Partition1有三个副本：Replica1，Replica2，Replica3。其中Replica1是Leader，ISR=[1,2,3]</li><li>Replica1所在的Broker1挂掉了（现在Partition没有了Leader），Controller触发onBrokerFailure</li><li>Controller读取ZK的leaderAndISR，选举出新的leader和ISR。Leader=Replica2，ISR=[2,3]</li><li>Controller将最新的leaderAndISR写到ZK中（leader=Replica2，ISR=[2,3]）</li><li>Controller将最新的leaderAndISR构造成LeaderAndISRRequest发送给Broker2，Broker3</li><li>Replica2所在的Broker2收到指令，因为最新的leader指示Replica2是Leader，所以Replica2成为Partition1的Leader</li><li>Replica3所在的Broker3收到指令，Replica3仍然是follower，并且在ISR中，becomeFollower</li></ul><p>Broker失败后Controller端的处理步骤如下：</p><ol><li>从ZK中读取现存的brokers</li><li>broker宕机，会引起partition的Leader或ISR变化，获取在在这个broker上的partitions：set_p</li><li>对set_p的每个Partition P<br> 3.1 从ZK的leaderAndISR节点读取P的当前ISR<br> 3.2 决定P的新Leader和新ISR（优先级分别是ISR中存活的broker，AR中任意存活的作为Leader）<br> 3.3 将P最新的leader，ISR，epoch回写到ZK的leaderAndISR节点</li><li>将set_p中每个Partition的LeaderAndISR指令（包括最新的leaderAndISR数据）发送给受到影响的brokers</li></ol><p>最后一步采用发送指令的方式实际上是一种RPC请求，之前的版本中采用的是在ZK的leader节点注册监听器来监控leader的变化，不过我们已经看到了这种方式会使得ZK的负载过重，而使用RPC的方式可以在不依赖ZK的情况下同样可以处理leader的变化。</p><p><code>如何决定P的新Leader和新ISR？</code></p><p>假设AR=[1,2,3,4,5]，ISR=[1,2,3]，但是存活的Brokers=[2,3,5]。选择Leader的方式是ISR中目前存活的Brokers，比如目前存活的Broker是[2,3,4]，所以ISR中的副本1是不能作为Leader的，也不会再作为ISR了。Leader的选举是选举目前还存活的[2,3]中的一个，ISR的选举是选举在ISR中当前仍然存活的Broker=[2,3]。所以最后Leader=2，ISR=[2,3]。</p><p>假设AR=[1,2,3,4,5]，ISR=[1,2,3]，但是存活的Brokers=[4,6,7]。因为ISR中没有一个Broker在当前处于存活状态，所以只能退而求其次从AR中选择，幸运的是AR中的4目前是存活的，所以Leader=4，ISR=[4]。由于4不再ISR中，所以这种情况有可能会造成数据丢失，因为只有选举处于ISR中的，才不会丢失数据，但是现在ISR中的没有一个存活，所以也只好选择有可能丢失的Broekr，总比找不到任何的Broker要好吧。</p><p><code>什么叫做受到影响的brokers？</code></p><p>Partition有多个Replicas，Replica是分布在Broker上的物理表示，所以一个Broker上受到影响的Replica的Partition肯定还有其他副本分布在其他Broker上，所有含有宕机Broker的Partition的节点都是受到影响的brokers。假如Broker1上有三个Partition，这些Partition有些是Leader（p1）有些则是Follower（p2，p3），如果是Leader，则受影响的brokers要负责选出这个Replica对应的Partition的新Leader；如果是follower，也有可能影响了Partition的ISR，所以Leader要负责更新ISR。</p><p><a href="http://img.blog.csdn.net/20160311084942405" title="k_affected_brokers" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160311084942405" alt="" title="k_affected_brokers"></a></p><h3 id="创建或删除topics：on-topic-change"><a href="#创建或删除topics：on-topic-change" class="headerlink" title="创建或删除topics：on_topic_change"></a>创建或删除topics：on_topic_change</h3><ol><li>新创建一个topic，会同时指定Partition的个数，每个Partition都有AR信息写到ZK中</li><li>为新创建的set_p 每个Partition P初始化leader：<br> 2.1 选择AR中的一个存活的Broker作为新Leader，ISR=AR<br> 2.2 将新Leader和ISR写到ZK的leaderAndISR节点</li><li>发送LeaderAndISRCommand给受到影响的brokers</li><li>如果是删除一个topic，则发送StopReplicaCommand给受影响的brokers</li></ol><p>Controller会在/brokers/topics上注册Watcher，所以有新topic创建/删除时，Controller会通过Watch得到新创建/删除的Topic的Partition/Replica分配。对于新创建的Topic，分配给Partition的AR中所有的Replica都还没有数据，可认为它们都是同步的，也即都在ISR中（ISR=AR），任意一个Replica都可作为Leader。</p><p>创建或删除topic的过程和onBrokerFailure类似都要经过三个步骤：1) 选举Leader和ISR；2) 将leaderAndISR写到ZK中；3) 将最新leaderAndISR的LeaderAndISR指令发送给受到影响的Brokers。这是因为brokerChange导致Partition的Leader或者ISR发生变化，而新创建topic时，根本就没有Leader和ISR，所以两者都需要为Partition选择Leader和ISR。</p><p><a href="http://img.blog.csdn.net/20160313104935879" title="k_broker_topic_change3" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160313104935879" alt="" title="k_broker_topic_change3"></a></p><h3 id="Broker处理Controller发送的Command"><a href="#Broker处理Controller发送的Command" class="headerlink" title="Broker处理Controller发送的Command**"></a>Broker处理Controller发送的Command**</h3><p><code>on_LeaderAndISRCommand</code></p><ol><li>读取命令中的set_P</li><li>对每个partition P<br> 2.1 如果P在本地不存在，调用startReplica()创建一个新的Replia<br> 2.2 指令要求这个Broker成为P的新Leader，调用becomeLeader<br> 2.3 指令要求这个Broker作为Leader l的follower，调用becomeFollower</li><li>如果指令中有INIT标记，则删除不在set_p中的所有本地partitions</li></ol><p>对于新创建的Partition，由于Replica都还没有在Broker上被创建，所以有可能指令中Partition的本地文件不存在，就需要新创建一个Replica。而如果是Broker失败发送过来的LeaderAndISRCommand，则一般受到影响接收LeaderAndISRCommand指定的Broker是有存在Replica的，那么指令里就会要求这个Broker上的副本要么成为Leader，要么成为Follower。比如上面A的图例中，Broker1宕机，其上的p1副本是Leader，现在集群中p1没有leader了，因此指令就会要求broker2的p1副本由原先的follower要转变为Leader了。</p><p>becomeLeader指的是当前接收LeaderAndISRCommand指令的Broker，原先是一个follower，现在要转变为Leader。由于作为Follower期间，它会从Leader抓取数据，而现在Leader不在了，所以首先要停止抓取数据线程。follower转变为Leader之后，要负责读写数据，所以要启动提交线程负责将消息存储到本地日志文件中。</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">becomeLeader(r: <span class="type">Replica</span>, command) &#123;</span><br><span class="line"> stop the <span class="type">ReplicaFetcherThread</span> to the old leader</span><br><span class="line"> <span class="comment">//after this, no more messages from the old leader can be appended to r</span></span><br><span class="line"> r.partition.<span class="type">ISR</span> = command.<span class="type">ISR</span></span><br><span class="line"> r.isLeader = <span class="literal">true</span> <span class="comment">//enables reads/writes to this partition on this broker</span></span><br><span class="line"> start a commit thread on r</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>注意有可能新leader的HW会比之前的leader的HW要落后，这是因为新leader有可能是ISR，也有可能是AR中的replica。而原先作为Follower的replica，它的HW只会在向Leader发送抓取请求时，Leader在抓取响应中除了返回消息也会附带自己的HW给follower，Follower收到消息和HW后，才会更新自己的replica的HW，这中间有一定的时间间隔会导致Follower的HW会比Leader的HW要低。因此Follower在转变为Leader之后，它的HW是有可能比老的Leader的HW要低的。如果在leader角色转变之后，一个消费者客户端请求的offset可能比新的Leader的HW要大（因为消费者最多只消费到Leader的HW位置，但是消费者并不关心Leader到底有没有变化，所以如果旧的Leader的HW=10，那么客户端就可以消费到offset=10这个位置，而Leader发生转变后，HW可能降低为9，而这个时候客户端继续发送offset=10，就有可能比Leader的HW要大了！）。这种情况下，如果消费者要消费Leader的HW到LEO之间的数据，Broker会返回空的集合，而如果消费者请求的offset比LEO还要大，就会抛出OffsetOutofRangeException（LEO表示的是日志的最新位置，HW比LEO要小，客户端只能消费到HW位置，更不可能消费到LEO了）。</p><p>对于要转变为Follower的replica，原先如果是Leader的话，则要停止提交线程，由于当前Replica的leader可能会发生变化，所以在开始时要停止抓取线程，在最后要新创建到Replica最新leader的抓取线程，这中间还要截断日志到Replica的HW位置。</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">becomeFollower(r: <span class="type">Replica</span>) &#123;</span><br><span class="line"> stop the <span class="type">ReplicaFetcherThread</span> to the old leader </span><br><span class="line"> r.isLeader = <span class="literal">false</span> </span><br><span class="line">  <span class="comment">//disables reads/writes to this partition on this broker</span></span><br><span class="line"> stop the commit thread, <span class="keyword">if</span> any</span><br><span class="line"> truncate the log to r.hw</span><br><span class="line"> start a <span class="keyword">new</span> <span class="type">ReplicaFetcherThread</span> to the current leader of r, from offset r.leo</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>startReplica表示启动一个Replica，如果不存在Partition目录，则创建。并启动Replica的HW checkpoint线程，我们已经知道了Follower的HW是通过发送抓取请求，接收应答中包含了Leader的HW，设置为Follower Replica的HW（而Leader的HW又是由ISR提交来决定的，所以说ISR决定了HW能够增加，而Follower的HW则来自于Leader的HW）。</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">startReplica(r: <span class="type">Replica</span>) &#123;</span><br><span class="line"> create the partition directory locally, <span class="keyword">if</span> not present</span><br><span class="line"> start the <span class="type">HW</span> checkpoint thread <span class="keyword">for</span> r</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>下表是Broker收到LeaderAndISR指令后的动作，角色变化，以及线程的变化：</p><table><thead><tr><th style="text-align:center">action</th><th style="text-align:center">state transition</th><th style="text-align:center">thread</th></tr></thead><tbody><tr><td style="text-align:center">startReplica</td><td style="text-align:center">new replica</td><td style="text-align:center">start HW checkpoint</td></tr><tr><td style="text-align:center">becomeFollower</td><td style="text-align:center">leader to follower</td><td style="text-align:center">stop commit thread,start fetch thread</td></tr><tr><td style="text-align:center">becoleLeader</td><td style="text-align:center">follower to leader</td><td style="text-align:center">stop fetch thread,start commit thread</td></tr></tbody></table><p><code>on_StopReplicaCommand</code></p><p>Replica既然可以start，也可以被stop掉，即通过StopReplicaCommand指令要求Broker停止掉Replica。</p><ol><li>从指令中读取partitions集合</li><li>对每个partition P的每个Replica ，调用stopReplica<br> 2.1 停止和r关联的抓取线程（当然针对的是Follower Replica）<br> 2.2 停止r的HW checkpoint线程<br> 2.3 删除partition目录</li></ol><h3 id="Controller-Failover：on-controller-failover"><a href="#Controller-Failover：on-controller-failover" class="headerlink" title="Controller Failover：on_controller_failover"></a>Controller Failover：on_controller_failover</h3><p>由于Controller是从Brokers中选举出来的，所以Controller所在的节点也会作为Partition的存储节点的。当Controller挂掉后，Controller本身作为Broker也会触发新的Controller调用on_broker_change。但是在还没有选举出新的Controller之前，挂掉的Broker的on_broker_change不会被新的Controller调用（因为根本就没有可用的Controller）。所以对于挂掉的Controller节点，最紧迫的任务是首先选举出新的Controller，然后再由新的Controller触发挂掉的那个Controller的on_broker_change。</p><p><a href="http://img.blog.csdn.net/20160313104859597" title="k_onControllerFailover" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160313104859597" alt="" title="k_onControllerFailover"></a></p><p>还有一点Broker失败和Controller失败是不同的：Broker的failover是在Controller端处理的，因为我们知道Broker挂掉了，Controller负责在挂掉Controller和受影响的Broker之间更新数据（将新的leaderAndISR发送给受影响的Broker）。而Controller的failover则是在Broker处理的（成功创建Controller的那一个Broker）。实际上理解起来也很简单，任何角色A会在ZK上注册节点信息，并有另外的角色B负责监听，当角色A挂掉后，会由节点的监听器B处理A的failover。</p><p>因为Controller是集群中各种事件状态变化的中央控制器，比如Controller负责将LeaderAndISRCommand/StopReplicaCommand指令发送给集群中的brokers。因为Controller存在的作用就是负责处理某个Broker的事件变化，转换为请求，发送给其他的Broker，而Broker之间是不需要直接通信的。因此如果Controller这个大脑发生故障了，那么就等于各个Broker之间无法通信了。举个例子公司中开发某个项目并不需要所有人都围坐在一起从方案到需求到设计，通常在不同阶段有不同的人开不同的会议，不过每个会议都要求有一个项目组长（Controller）参与。在需求会议，项目组长收集需求人员的需求（源事件），项目组长整理需求后，在设计会议上，他会召集开发人员进行设计评审并分配不同的任务给不同的开发人员（由Controller分配任务给不同的Broker），可是有一天项目组长请假了，需求人员和开发人员就不知道如何通信了，因为他们之间的媒介无法连接了。那么怎么办呢，可以引入一个共享存储ZooKeeper：Controller将状态改变的事件都存储在ZooKeeper上确保数据不会丢失，这样即使Controller挂掉了，在新的Controller掌权之后，也能够从ZooKeeper中读取出所有事件，这种方式跟v2版本的Replica设计中ZK的state change队列（存储的是每个broker的state change requests）是类似的。但是真的需要为此再引入ZKQueue吗？实际上只要Controller节点保存的数据是无状态的，那么切换新的Controller之后能够恢复之前的集群状态信息就无需引入ZK队列。</p><ol><li>创建<code>/controller-&gt;当前broker_id</code>的ZK节点，如果不成功则返回，表示竞选失败</li><li>读取ZK中每个Partition的leaderAndISR节点数据</li><li>对每个Partition都发送LeaderAndISRCommand（带上INIT标记位）给相关的Brokers</li><li>触发调用on_broker_change()</li><li>对没有leader的Partition，调用init_leaders(set_p)，这里跟创建topic时类似</li><li>调用on_partitions_reassigned()重新分配Partition</li><li>调用on_partitions_add()创建Partition</li><li>调用on_partitons_remove()删除Partition</li></ol><p>第一步创建/controller节点，监听了这个节点的所有brokers都想要创建，但是只有一个Broker节点才能创建成功。on_controller_failover表示之前的Controller已经挂掉了，要在监听的broker并且成功创建/controller的节点处理旧的controller的failover（故障转移处理）。Controller挂掉时，它之前创建的/controller会被自动删除掉（并不在这个方法里处理，而是由ZK在controller进程挂掉时自动删除），所以就让其他一直默默潜水的brokers有机会成为新的Controller。</p><h3 id="Broker-Startup：on-broker-startup"><a href="#Broker-Startup：on-broker-startup" class="headerlink" title="Broker Startup：on_broker_startup"></a>Broker Startup：on_broker_startup</h3><p>由于broker不管是启动还是发生故障，都会操作ZK中的/brokers/ids节点，而Controller牢牢地监控了这个节点的变化情况。当Broker刚启动时（这里指的是failover中的startup，如果说启动一个全新的broker，它上面是没有Replica的，而failover是之前存在Replica，发生故障后恢复过来的startup上面仍然有Replica的），为了让它所拥有的Replica能够正常提供服务，需要startReplica，同时其上的每个Replica要么成为Leader，要么成为Follower。</p><p><a href="http://img.blog.csdn.net/20160311091731316" title="k_on_broker" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160311091731316" alt="" title="k_on_broker"></a></p><p>在on_broker_change里，Controller会将失败Broker上的Partition的指令发送给受影响的brokers（肯定不能发送给失败的Broker了），收到指令的brokers会对自己所拥有的Replica判断是否成为Leader或者Followr。而on_broker_start则是Controller直接处理刚刚启动的Broker。即on_broker_start是通过监听器直接处理，而on_broker_change是通过Controller发送命令给brokers处理，对于Broker而言都是针对Partition的Replica在Leader和Follower之间进行角色转换操作。</p><p><a href="http://img.blog.csdn.net/20160312092301167" title="k_broker_change_start" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160312092301167" alt="" title="k_broker_change_start"></a></p><ol><li>读取ZK中topic路径下每个topic所有Partition的AR（和partition路径的AR功能类似）</li><li>读取ZK中分配给当前启动的Broker的每个Partition的leader和ISR（leaderAndISR）</li><li>对分配给当前Broker的每个Replica<br> 3.1 start replica<br> 3.2 这个Broker是这个Partition的leader，becomeLeader<br> 3.3 这个Broker是这个Partition的follower，becomeFollower</li><li>删除不再分配给当前Broker的本地Partitions</li></ol><p>最后一步Broker在启动的时候需要删除不属于自己的partitions。这种场景可能是在这个broker当掉的时候，属于这个broker的topic被删除后又立马被重新创建。在删除topic的时候，所有broker都应该把这个topic的所有partition都删除掉，但是如果broker挂掉了，是无法进行任何操作的。在重新创建topic的时候，也不会将partition分配给当掉的broker，而只是分配partition给其他存活的broker。所以当掉的broker恢复正常后，应该把在删除topic时候的那些partition都删除掉。尽管又重新创建了topic，但是当掉broker上的partition是不能作为新创建的topic的partitions的，因为在当掉期间ZK中根本就不会记录属于当掉broker的partition分配信息。</p><h3 id="讨论"><a href="#讨论" class="headerlink" title="讨论"></a>讨论</h3><p><code>broker失败期间端到端的延迟</code></p><ol><li>broker关闭(关闭socket server后，需要关闭请求处理器和log)</li><li>controller上的broker监听器被触发</li><li>controller负责leadership变更，将新leader和isr发布到zk（每个受影响的partition都写一次zk）</li><li>通过写到<code>ZKQueue</code>中通知每个broker leadership变化(每个broker写一次关于leader变化事件的zk)</li><li>新leader等待isr中的follower成功连接上(Kafka RPC)</li><li>follower首先截断日志，然后开始从leader抓取数据</li></ol><p>端到端的延迟指的是从Producer提交消息到这条消息被Consumer/Follower消费到的时间间隔。最耗时的是步骤三：对每个partition都要将新的leaderAndISR写一次zk。假设在broker发生故障时需要为10K个partition改变leader，每次zk写花费4ms，则总共会花费40s。不过可以使用zk中的multi()将所有Partition的写操作用批处理就只需要一次写。</p><p>步骤四中：controller会将最新的leadershipi变化事件通知给每个broker。在Replica的版本2中对于state change事件则采用队列的形式，对于leader的变化事件是通过监听器的方式通知注册在leader的所有broker节点。现在由于leader的管理交给了Controller，所以可以使用直接RPC代替监听器和队列。</p><p><code>ZKQueue vs 直接RPC</code></p><p>通过ZK完成Controller和brokers的通信是非常低效的，因为每次通信都需要2次ZK写（每个写操作花费2个RPC），一次监听器的触发（写之后数据改变触发监听器调用），一次ZK读（监听器要获取最新的写操作结果），所以对于一次通信就花费了6次RPC（2*2+1+1=6，RPC指的是在不同节点之间的通信，controller和brokers是在不同节点上，需要通过RPC通信才能读写数据）。</p><p>如果controler和所有的brokers之间为了能够直接通信，在Broker端实现一个admin RPC，那么每次通信只需要一个RPC。使用RPC意味着当一个broker当掉后，它可能会丢失一些来自Controller的命令，所以在broker启动时它需要读取zk中保存的状态信息来恢复自己的状态。</p><p><code>如何处理多个leader?</code></p><p>存在这样一种情况：有多个broker同时声称自己是某个partition的leader副本。比如broker A是partition的初始Leader，partition的ISR是{A,B,C}。然后broker A因为GC失去它在ZK中的注册信息。这时controller会认为broker A当掉了，并将partition的leader分配给了broker B，设置新的ISR为{B,C}，并写到ZK中（每次partition的leader发生变化，epoch也会增加）。在broker B成为leader的同时，broker A从GC中恢复过来，但是还没有收到controller发送的leadershipi变化指令。现在broker A和B都认为自己是leader，如果我们允许broker A和B能同时提交消息那就非常不幸了，因为所有副本之间的数据同步就会不一致了。不过当前的设计中实际上是不会允许出现这种情况的：<strong>当broker B成为leader之后，broker A是无法再提交任何新的消息的</strong>。</p><p>假设允许存在两个leader，生产者会同时往这两个leader写数据，但是能不能提交消息就类似于两阶段提交协议了：kafka的leader要能够提交一个消息的保证是ISR中的所有副本都复制成功。对于broker A为了保证能投提交消息m，它需要ISR中的每个副本（A,B,C）都要接收到消息m，而这个时候broker A仍然认为ISR是{A,B,C}，（这是broker A的一份本地拷贝，虽然这个时候ZK中的ISR已经被broker B改变了），但是ISR中的broker B是不会再接收到消息m的。因为在broker B成为Leader的时候，它会首先关掉到之前旧的broker A的数据抓取线程（broker B之前是follower，会向leader抓取数据，只有follower抓取数据，leader才能判断消息是否能提交），因为broker B的抓取线程被关闭了，broker A可能会认为B无法赶上Leader，既然因为B受到影响不能提交消息m，broker A干脆就想要把B从ISR中移除，这个时候broker A要将自己认为的最新的ISR写到ZK中。不过不幸的是broker A并不能完成这个操作：因为在写ZK的时候broker A会发现自己的epoch版本和ZK中的当前值并不匹配（broker B在选举为Leader之后会写到ZK中，并将epoch增加1，任何新的写操作的epoch都不能比当前epoch小），直到这个时刻，broker A才意识到它已经不再是partition的leader了（举个例子：一个团伙中大领导A突然消失了一段时间，众人在这段时间里选举出了新的领导B，并且向所有的成员都发话了，只有B的指令是管用的。在A回来时，没有人告诉他B已经成功上位，A还傻傻地认为自己还是最高BOSS，不过当他要发布指令的时候，发现每个人的指令只接受B的了，这时A才意识到MD自己已经被踢出局了）。</p><p><a href="http://img.blog.csdn.net/20160312113420173" title="k_2leader" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160312113420173" alt="" title="k_2leader"></a></p><p><code>broker故障客户端如何路由</code></p><p>controller首先将受影响的partitions的新leader写到zk的leaderAndISR节点，然后才会向brokers发送leader改变的命令，brokers收到命令后会对leader改变的事件作出响应。由于客户端请求会使用leaderAndISR的数据来连接leader所在的节点，客户端的请求被路由到新leader所在的broker节点，但如果那个broker还没有准备好成为leader，就存在一个时间窗口对于客户端而言是不可用的。HBase只在regionserver响应了打开或关闭region的命令之后才更新元数据（这个元数据也用来响应客户端的请求，即客户端可以读取哪些region），而kafka这里则是controller先更新元数据（写入leaderAndISR）后才发送命令给broker，因此可能元数据已经更新，但是broker还没收到命令，或者收到命令后还没准备好成为leader。</p><p><a href="http://img.blog.csdn.net/20160312132504155" title="k_client_route" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160312132504155" alt="" title="k_client_route"></a></p><p>所以可能你会认为不要让controller先更新leaderAndISR节点，而是发送指令给brokers，每个broker在收到指令并处理完成后才，让每个broker来更新这个节点的数据。不过这里让controller来更新leaderAndISR节点是有原因的：我们是依赖ZK的leaderAndISR节点来保持controller和partition的leader的同步。当controller选举出新的leader之后，它并不希望ISR（新leader的ISR）被当前的leader（旧的leader）改变。否则的话（假设ISR可以被旧leader改变），新选举出来的leader在接管正式成为leader之前可能会被当前leader从ISR中剔除出去。通过立即将新leader发布到leaderAndISR节点，controller能够防止当前leader更新ISR（选举新的leader在写到ZK时，epoch增加，而如果当前leader想要更新ISR比如将新选举的leader从ISR中剔除掉，因为epoch不匹配，所以当前leader就不再有机会更新ISR了）。</p><p>一种方式是使用额外的ZK路径比如ExternalView来路由客户端请求解决这种短暂的不可用窗口。controller只会在broker响应leader改变的指令之后才更新ExternalView。对所有的partitions使用一个ExternalView或者每个partition都使用一个ExternalView各有利弊。前者对ZK有更少的负载，但是可能会强制客户端触发不必要的重新平衡。</p><p>正常情况下，leadership改变的指令会很快地被执行，所以在转换的这段时间里，我们可以依赖于客户端的重试机制，相对而言客户端的代价更小。即使broker成为新的leader花费了太长的时间，controller也会得到超时通知并触发一条告警信息通知admin。</p><p><code>leadership改变时，offset超过HW</code></p><p>通常情况下，follower的HW总是落后于leader的HW。所以在leader变化时，消费者发送的offset中可能会落在新leader的HW和LEO之间（因为消费者的消费进度依赖于Leader的HW，旧Leader的HW比较高，而原先的follower作为新Leader，它的HW还是落后于旧Leader，所以消费者的offset虽然比旧Leader的HW低，但是有可能比新Leader的HW要大）。如果没有发生leader变化，服务端会返回OffsetOutOfRangeException给客户端。但这种情况下如果请求的offset介于HW和LEO之间，服务端会返回空消息集给消费者。</p><p><a href="http://img.blog.csdn.net/20160312135626735" title="k_client_hw_out" target="_blank" rel="noopener"><img src="http://img.blog.csdn.net/20160312135626735" alt="" title="k_client_hw_out"></a></p><h2 id="小结"><a href="#小结" class="headerlink" title="小结"></a>小结</h2><p>当前的KafkaController实现是多线程的控制器，并且模拟了状态机，它维护的状态有：</p><ul><li>在每个节点上维护所有partitions的Replicas</li><li>所有partitions的Leaders</li></ul><p>引起状态改变的输入事件有：</p><ol><li>注册在ZooKeeper上的监听器<br> 1) BrokerChangeListener（Broker挂掉）<br> 2) DeleteTopicListener（删除Topic）<br> 3) TopicChangeListener（更改Topic）<br> 4) AddPartitionsListener（为Topic新增加Partition）<br> 5) PartitionReassignedListener（Admin）<br> 6) PreferredReplicaElectionListener（Admin）<br> 7) ReassignedPartitionsIsrChangeListener</li><li>到brokers的连接通道（controller被关闭）</li><li>内部的调度任务（prefered leader选举）</li></ol><h2 id="Ref"><a href="#Ref" class="headerlink" title="Ref"></a>Ref</h2><ul><li><a href="https://cwiki.apache.org/confluence/display/KAFKA/Kafka+Controller+Internals" target="_blank" rel="noopener">https://cwiki.apache.org/confluence/display/KAFKA/Kafka+Controller+Internals</a></li></ul>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;kafka在0.8版本前没有提供Partition的Replication机制，一旦Broker宕机，其上的所有Partition就都无法提供服务，而Partition又没有备份数据，数据的可用性就大大降低了。所以0.8后提供了Replication机制来保证Broker的failover。由于Partition有多个副本，为了保证多个副本之间的数据同步，有多种方案：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;1.所有副本之间是无中心结构的，可同时读写数据，需要保证多个副本之间数据的同步 &lt;/li&gt;
&lt;li&gt;2.在所有副本中选择一个Leader，生产者和消费者只和Leader副本交互，其他follower副本从Leader同步数据
    
    </summary>
    
      <category term="kafka" scheme="http://crazycarry.github.io/categories/kafka/"/>
    
    
      <category term="架构设计" scheme="http://crazycarry.github.io/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/"/>
    
      <category term="源码分析" scheme="http://crazycarry.github.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"/>
    
      <category term="消息中间件" scheme="http://crazycarry.github.io/tags/%E6%B6%88%E6%81%AF%E4%B8%AD%E9%97%B4%E4%BB%B6/"/>
    
      <category term="大数据" scheme="http://crazycarry.github.io/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
    
  </entry>
  
  <entry>
    <title>Kafka controller 设计分析</title>
    <link href="http://crazycarry.github.io/2018/04/04/kafka-controller-01/"/>
    <id>http://crazycarry.github.io/2018/04/04/kafka-controller-01/</id>
    <published>2018-04-04T08:30:34.000Z</published>
    <updated>2018-04-04T08:57:41.930Z</updated>
    
    <content type="html"><![CDATA[<p>本文主要参考社区0.11版本Controller的重设计方案，试图给大家梳理一下Kafka controller这个组件在设计上的一些重要思考。众所周知，Kafka中有个关键组件叫controller，负责管理和协调Kafka集群。网上关于controller的源码分析也有很多，本文就不再大段地列出代码重复做这件事情了。实际上，对于controller的代码我一直觉得写的非常混乱，各种调用关系十分复杂，想要完整地理解它的工作原理确实不易。好在我们就是普通的使用者，大致了解controller的工作原理即可。下面我就带各位简要了解一下当前Kafka controller的原理架构以及社区为什么要在大改controller的设计。<br><a id="more"></a></p><h3 id="Controller是做什么的"><a href="#Controller是做什么的" class="headerlink" title="Controller是做什么的"></a>Controller是做什么的</h3><p>　　“负责管理和协调Kafka集群”的说法实在没有什么营养，上点干货吧——具体来说Controller目前主要提供多达10种的Kafka服务功能的实现，它们分别是：</p><ul><li>UpdateMetadataRequest：更新元数据请求。topic分区状态经常会发生变更(比如leader重新选举了或副本集合变化了等)。由于当前clients只能与分区的leader broker进行交互，那么一旦发生变更，controller会将最新的元数据广播给所有存活的broker。具体方式就是给所有broker发送UpdateMetadataRequest请求</li><li>CreateTopics: 创建topic请求。当前不管是通过API方式、脚本方式抑或是CreateTopics请求方式来创建topic，做法几乎都是在Zookeeper的/brokers/topics下创建znode来触发创建逻辑，而controller会监听该path下的变更来执行真正的“创建topic”逻辑</li><li>DeleteTopics：删除topic请求。和CreateTopics类似，也是通过创建Zookeeper下的/admin/delete_topics/节点来触发删除topic，controller执行真正的逻辑</li><li>分区重分配：即kafka-reassign-partitions脚本做的事情。同样是与Zookeeper结合使用，脚本写入/admin/reassign_partitions节点来触发，controller负责按照方案分配分区</li><li>Preferred leader分配：preferred leader选举当前有两种触发方式：1. 自动触发(auto.leader.rebalance.enable = true)；2. kafka-preferred-replica-election脚本触发。两者“玩法”相同，向Zookeeper的/admin/preferred_replica_election写数据，controller提取数据执行preferred leader分配</li><li>分区扩展：即增加topic分区数。标准做法也是通过kafka-reassign-partitions脚本完成，不过用户可直接往Zookeeper中写数据来实现，比如直接把新增分区的副本集合写入到/brokers/topics/下，然后controller会为你自动地选出leader并增加分区</li><li>集群扩展：新增broker时Zookeeper中/brokers/ids下会新增znode，controller自动完成服务发现的工作</li><li>broker崩溃：同样地，controller通过Zookeeper可实时侦测broker状态。一旦有broker挂掉了，controller可立即感知并为受影响分区选举新的leader</li><li>ControlledShutdown：broker除了崩溃，还能“优雅”地退出。broker一旦自行终止，controller会接收到一个ControlledShudownRequest请求，然后controller会妥善处理该请求并执行各种收尾工作</li><li>Controller leader选举：controller必然要提供自己的leader选举以防这个全局唯一的组件崩溃宕机导致服务中断。这个功能也是通过Zookeeper的帮助实现的</li></ul><h3 id="Controller当前设计"><a href="#Controller当前设计" class="headerlink" title="Controller当前设计"></a>Controller当前设计</h3><p>　　当前controller启动时会为集群中所有broker创建一个各自的连接。这么说吧，假设你的集群中有100台broker，那么controller启动时会创建100个Socket连接(也包括与它自己的连接！)。当前新版本的Kafka统一使用了NetworkClient类来建模底层的网络连接(有兴趣研究源码的可以去看下这个类，它主要依赖于Java NIO的Selector)。Controller会为每个连接都创建一个对应的请求发送线程，专门负责给对应的broker发送请求。也就是说，如果还是那100台broker，那么controller启动时还会创建100个RequestSendThread线程。当前的设计中Controller只能给broker发送三类请求，它们是：</p><ul><li>UpdateMetadataRequest：更新元数据</li><li>LeaderAndIsrRequest：创建分区、副本以及完成必要的leader和/或follower角色的工作</li><li>StopReplicaRequest：停止副本请求，还可能删除分区副本</li></ul><p>　　Controller通常都是发送请求给broker的，只有上面谈到的controller 10大功能中的ControlledShutdownRequest请求是例外：这个请求是待关闭的broker通过RPC发送给controller的，即它的方向是反的。另外这个请求还有一个特别之处就是其他所有功能或是请求都是通过Zookeeper间接与controller交互的，只有它是直接与controller进行交互的。</p><h3 id="Controller组成"><a href="#Controller组成" class="headerlink" title="Controller组成"></a>Controller组成</h3><p>构成controller的组件太多了，多到我已经不想用文字表达了，直接上图吧：</p><p><a href="https://ws4.sinaimg.cn/large/006tNc79ly1fq0q45no1yj30qb0eu0v1.jpg" target="_blank" rel="noopener"><img src="https://ws4.sinaimg.cn/large/006tNc79ly1fq0q45no1yj30qb0eu0v1.jpg" alt=""></a></p><p>其中比较重要的组件包括：</p><ul><li>ControllerContext：可以说是controller的缓存。当前controller为人诟病的原因之一就是用了大量的同步机制来保护这个东西。ControllerContext的构成如下图所示：</li></ul><p><a href="https://ws1.sinaimg.cn/large/006tNc79ly1fq0q63jcswj30z80esadr.jpg" target="_blank" rel="noopener"><img src="https://ws1.sinaimg.cn/large/006tNc79ly1fq0q63jcswj30z80esadr.jpg" alt=""></a></p><p>缓存内容十分丰富，这也是controller可以协调管理整个cluster的基础。</p><ul><li>TopicDeletionManager：负责删除topic的组件</li><li><em>**</em>Selector：controller提供的各种功能的leader选举器</li><li><em>**</em>Listener：controller注册的各种Zookeeper监听器。想要让controller无所不能，必然要注册各种”触角” 才能实时感知各种变化</li></ul><h3 id="Controller当前问题"><a href="#Controller当前问题" class="headerlink" title="Controller当前问题"></a>Controller当前问题</h3><p>　　 不谦虚地说，我混迹社区也有些日子了。在里面碰到过很多关于controller的bug。社区对于这些bug有个很共性的特点，那就是没有什么人愿意(敢去)改这部分代码，因为它实在是太复杂了。具体的问题包括：</p><h4 id="1-需要在多线程间共享状态"><a href="#1-需要在多线程间共享状态" class="headerlink" title="1. 需要在多线程间共享状态"></a>1. 需要在多线程间共享状态</h4><p>　　编写正确的多线程程序一直是Java开发者的痛点。在Controller的实现类KafkaController中创建了很多线程，比如之前提到的RequestSendThread线程，另外ZkClient也会创建单独的线程来处理zookeeper回调，这还不算TopicDeletionManager创建的线程和其他IO线程等。几乎所有这些线程都需要访问ControllerContext(RequestSendThread只操作它们专属的请求队列，不会访问ControllerContext)，因此必要的多线程同步机制是一定需要的。当前是使用controllerLock锁来实现的，因此可以说没有并行度可言。</p><h4 id="2-代码组织混乱"><a href="#2-代码组织混乱" class="headerlink" title="2. 代码组织混乱"></a>2. 代码组织混乱</h4><p>　　看过源代码的人相信对这一点深有体会。KafkaController、PartitionStateMachine和ReplicaStateMachine每个都是500+行的大类且彼此混调的现象明显，比如KafkaController的stopOldReplicasOfReassignedPartition方法调用ReplicaStateMachine的handleStateChanges方法，而后者又会调用KafkaController的remoteReplicaFromIsr方法。类似的情况还发生在KafkaController和ControllerChannelManager之间。</p><h4 id="3-管理类请求与数据类请求未分离"><a href="#3-管理类请求与数据类请求未分离" class="headerlink" title="3. 管理类请求与数据类请求未分离"></a>3. 管理类请求与数据类请求未分离</h4><p>　　当前broker对入站请求类型不做任何优先级处理，不论是PRODUCE请求、FETCH请求还是Controller类的请求。这就可能造成一个问题：即clients发送的数据类请求积压导致controller推迟了管理类请求的处理。设想这样的场景，假设controller向broker广播了leader发生变更。于是新leader开始接收clients端请求，而同时老leader所在的broker由于出现了数据类请求的积压使得它一直忙于处理这些请求而无法处理controller发来的LeaderAndIsrRequest请求，因此这是就会出现“双主”的情况——也就是所谓的脑裂。此时倘若client发送的一个PRODUCE请求未指定acks=-1，那么因为日志水位截断的缘故这个请求包含的消息就可能“丢失”了。现在社区中关于controller丢失数据的bug大多是因为这个原因造成的。</p><h4 id="4-Controller同步写Zookeeper且是一个分区一个分区地写"><a href="#4-Controller同步写Zookeeper且是一个分区一个分区地写" class="headerlink" title="4. Controller同步写Zookeeper且是一个分区一个分区地写"></a>4. Controller同步写Zookeeper且是一个分区一个分区地写</h4><p>　　当前controller操作Zookeeper是通过ZkClient来完成的。ZkClient目前是同步写入Zookeeper，而同步通常意味着性能不高。更为严重的是，controller是一个分区一个分区进行写入的，对于分区数很多的集群来说，这无疑是个巨大的性能瓶颈。如果用户仔细查看源代码，可以发现PartitionStateMachine的electLeaderForPartition就是一个分区一个分区地选举的。</p><h4 id="5-Controller按照一个分区一个分区的发送请求"><a href="#5-Controller按照一个分区一个分区的发送请求" class="headerlink" title="5. Controller按照一个分区一个分区的发送请求"></a>5. Controller按照一个分区一个分区的发送请求</h4><p>　　Controller当前发送请求都是按照分区级别发送的，即一个分区一个分区地发送。没有任何batch或并行可言，效率很低。</p><h4 id="6-Controller给broker的请求无版本号信息"><a href="#6-Controller给broker的请求无版本号信息" class="headerlink" title="6. Controller给broker的请求无版本号信息"></a>6. Controller给broker的请求无版本号信息</h4><p>这里的版本号类似于new consumer的generation，总之是要有一种机制告诉controller broker的版本信息。因为有些情况下broker会处理本已过期或失效的请求导致broker状态不一致。举个例子，如果一个broker正常关闭过程中“宕机”了，那么重启之后这个broker就有可能处理之前controller发送过来的StopReplicaRequest，导致某些副本被置成offline从而无法使用。而这肯定不是我们希望看到的结果，对吧？</p><h4 id="7-ZkClient阻碍状态管理"><a href="#7-ZkClient阻碍状态管理" class="headerlink" title="7. ZkClient阻碍状态管理"></a>7. ZkClient阻碍状态管理</h4><p>Contoller目前是使用了ZkClient这个开源工具，它可以自动重建会话并使用特有的线程顺序处理所有的Zookeeper监听消息。因为是顺序处理，它就有可能无法及时响应最新的状态变更导致Kafka集群状态的不一致。</p><h3 id="Controller改进方案"><a href="#Controller改进方案" class="headerlink" title="Controller改进方案"></a>Controller改进方案</h3><h4 id="1-单线程事件模型"><a href="#1-单线程事件模型" class="headerlink" title="1. 单线程事件模型"></a><a href="https://crazycarry.github.io/2018/01/10/Kafka%20controller%E9%87%8D%E8%AE%BE%E8%AE%A1/#1-%E5%8D%95%E7%BA%BF%E7%A8%8B%E4%BA%8B%E4%BB%B6%E6%A8%A1%E5%9E%8B" title="1\. 单线程事件模型"></a>1. 单线程事件模型</h4><p>和new consumer类似，controller摒弃多线程的模型，采用单线程的事件队列模型。这样简化了设计同时也避免了复杂的同步机制。各位在最新的trunk分支上已然可以看到这种变化：增加了ControllerEventManager类以及对应的ControllerEventThread线程类专门负责处理ControllerEvent。目前总共有9种controller event，它们分别是：</p><ul><li>Idle</li><li>ControllerChange</li><li>BrokerChange</li><li>TopicChange</li><li>TopicDeletion</li><li>PartitionReassignment</li><li>AutoLeaderBalance</li><li>ManualLeaderBalance</li><li>ControlledShutdown</li><li>IsrChange</li></ul><p>我们基本上可以从名字就能判断出它们分别代表了什么事件。</p><h4 id="2-使用Zookeeper的async-API"><a href="#2-使用Zookeeper的async-API" class="headerlink" title="2. 使用Zookeeper的async API"></a><a href="https://crazycarry.github.io/2018/01/10/Kafka%20controller%E9%87%8D%E8%AE%BE%E8%AE%A1/#2-%E4%BD%BF%E7%94%A8Zookeeper%E7%9A%84async-API" title="2\. 使用Zookeeper的async API"></a>2. 使用Zookeeper的async API</h4><p>　　将所有同步操作Zookeeper的地方都改成异步调用+回调的方式。实际上Apache Zookeeper客户端执行请求的方式有三种：同步、异步和batch。通常以batch性能最好，但Kafka社区目前还是倾向于用async替换sync。毕竟实现起来相对简单同时性能上也能得到不少提升。</p><h4 id="3-重构状态管理"><a href="#3-重构状态管理" class="headerlink" title="3. 重构状态管理"></a><a href="https://crazycarry.github.io/2018/01/10/Kafka%20controller%E9%87%8D%E8%AE%BE%E8%AE%A1/#3-%E9%87%8D%E6%9E%84%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86" title="3\. 重构状态管理"></a>3. 重构状态管理</h4><p>可能摒弃之前状态机的方式，采用和GroupCoordinator类似的方式，让controller保存所有的状态并且负责状态的流转以及状态流转过程中的逻辑。当然，具体的实现还要再结合0.11最终代码才能确定。</p><h4 id="4-对请求排定优先级"><a href="#4-对请求排定优先级" class="headerlink" title="4. 对请求排定优先级"></a><a href="https://crazycarry.github.io/2018/01/10/Kafka%20controller%E9%87%8D%E8%AE%BE%E8%AE%A1/#4-%E5%AF%B9%E8%AF%B7%E6%B1%82%E6%8E%92%E5%AE%9A%E4%BC%98%E5%85%88%E7%BA%A7" title="4\. 对请求排定优先级"></a>4. 对请求排定优先级</h4><p>　　对管理类请求和数据类请求区分优先级。比如使用优先级队列替换现有的BlockingQueue——社区应该已经实现了这个功能，开发了一个叫PrioritizationAwareBlockingQueue的类来做这件事情，后续大家可以看下这个类的源代码</p><h4 id="5-为controller发送的请求匹配broker版本信息"><a href="#5-为controller发送的请求匹配broker版本信息" class="headerlink" title="5. 为controller发送的请求匹配broker版本信息"></a><a href="https://crazycarry.github.io/2018/01/10/Kafka%20controller%E9%87%8D%E8%AE%BE%E8%AE%A1/#5-%E4%B8%BAcontroller%E5%8F%91%E9%80%81%E7%9A%84%E8%AF%B7%E6%B1%82%E5%8C%B9%E9%85%8Dbroker%E7%89%88%E6%9C%AC%E4%BF%A1%E6%81%AF" title="5\. 为controller发送的请求匹配broker版本信息"></a>5. 为controller发送的请求匹配broker版本信息</h4><p>为broker设定版本号(generation id)。如果controller发送过来的请求中包含的generation与broker自己的generation不匹配， 那么broker会拒绝该请求。</p><h4 id="6-抛弃ZkClient，使用原生Zookeeper-client"><a href="#6-抛弃ZkClient，使用原生Zookeeper-client" class="headerlink" title="6. 抛弃ZkClient，使用原生Zookeeper client"></a><a href="https://crazycarry.github.io/2018/01/10/Kafka%20controller%E9%87%8D%E8%AE%BE%E8%AE%A1/#6-%E6%8A%9B%E5%BC%83ZkClient%EF%BC%8C%E4%BD%BF%E7%94%A8%E5%8E%9F%E7%94%9FZookeeper-client" title="6\. 抛弃ZkClient，使用原生Zookeeper client"></a>6. 抛弃ZkClient，使用原生Zookeeper client</h4><p>ZkClient是同步顺序处理ZK事件的，而原生Zookeeper client支持async方式。另外使用原生API还能够在接收到状态变更通知时便马上开始处理，而ZkClient的特定线程则必须要在队列中顺序处理到这条变更消息时才能处理。</p><h3 id="结语"><a href="#结语" class="headerlink" title="结语"></a><a href="https://crazycarry.github.io/2018/01/10/Kafka%20controller%E9%87%8D%E8%AE%BE%E8%AE%A1/#%E7%BB%93%E8%AF%AD" title="结语"></a>结语</h3><p>以上就是关于Kafka controller的一些讨论，包括了它当前的组件构成、设计问题以及对应的改进方案。有很多地方可能理解的还不是透彻，期待着在Kafka 0.11正式版本中可以看到全新的controller组件</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;本文主要参考社区0.11版本Controller的重设计方案，试图给大家梳理一下Kafka controller这个组件在设计上的一些重要思考。众所周知，Kafka中有个关键组件叫controller，负责管理和协调Kafka集群。网上关于controller的源码分析也有很多，本文就不再大段地列出代码重复做这件事情了。实际上，对于controller的代码我一直觉得写的非常混乱，各种调用关系十分复杂，想要完整地理解它的工作原理确实不易。好在我们就是普通的使用者，大致了解controller的工作原理即可。下面我就带各位简要了解一下当前Kafka controller的原理架构以及社区为什么要在大改controller的设计。&lt;br&gt;
    
    </summary>
    
      <category term="kafka" scheme="http://crazycarry.github.io/categories/kafka/"/>
    
    
      <category term="架构设计" scheme="http://crazycarry.github.io/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/"/>
    
      <category term="源码分析" scheme="http://crazycarry.github.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"/>
    
      <category term="消息中间件" scheme="http://crazycarry.github.io/tags/%E6%B6%88%E6%81%AF%E4%B8%AD%E9%97%B4%E4%BB%B6/"/>
    
      <category term="大数据" scheme="http://crazycarry.github.io/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
    
  </entry>
  
  <entry>
    <title>Spark 内存管理之UnifiedMemoryManager</title>
    <link href="http://crazycarry.github.io/2018/04/04/spark-memory-manager-02/"/>
    <id>http://crazycarry.github.io/2018/04/04/spark-memory-manager-02/</id>
    <published>2018-04-04T08:19:35.000Z</published>
    <updated>2018-04-04T08:26:00.674Z</updated>
    
    <content type="html"><![CDATA[<p>Spark的内存使用，大体上可以分为两类：Execution内存和Storage内存。在Spark 1.5版本之前，内存管理使用的是StaticMemoryManager，该内存管理模型最大的特点就是，可以为Execution内存区与Storage内存区配置一个静态的boundary，这种方式实现起来比较简单，但是存在一些问题：</p><ol><li>没有一个合理的默认值能够适应不同计算场景下的Workload</li><li>内存调优困难，需要对Spark内部原理非常熟悉才能做好</li><li>对不需要Cache的Application的计算场景，只能使用很少一部分内存<a id="more"></a></li></ol><p>为了克服上述提到的问题，尽量提高Spark计算的通用性，降低内存调优难度，减少OOM导致的失败问题，从Spark 1.6版本开始，新增了UnifiedMemoryManager（统一内存管理）内存管理模型的实现。UnifiedMemoryManager依赖的一些组件类及其关系，如下类图所示：</p><p><a href="http://www.uml.org.cn/bigdata/images/2017072831.png" target="_blank" rel="noopener"><img src="http://www.uml.org.cn/bigdata/images/2017072831.png" alt=""></a></p><p>从上图可以看出，最直接最核心的就是StorageMemoryPool 和ExecutionMemoryPool，它们实现了动态内存池（Memory Pool）的功能，能够动态调整Storage内存区与Execution内存区之间的Soft boundary，使内存管理更加灵活。下面我们从内存布局和内存控制两个方面，来分析UnifiedMemoryManager内存管理模型。</p><h2 id="内存布局"><a href="#内存布局" class="headerlink" title="内存布局"></a>内存布局</h2><p>UnifiedMemoryManager是MemoryManager的一种实现，是基于StaticMemoryManager的改进。这种模型也是将某个执行Task的Executor JVM内存划分为两类内存区域：</p><ul><li><p>Storage内存区<br>  Storage内存，用来缓存Task数据、在Spark集群中传输（Propagation）内部数据。</p></li><li><p>Execution内存区<br>  Execution内存，用于满足Shuffle、Join、Sort、Aggregation计算过程中对内存的需求。</p></li></ul><p>这种新的内存管理模型，在Storage内存区与Execution内存区之间抽象出一个Soft boundary，能够满足当某一个内存区中内存用量不足的时候，可以从另一个内存区中借用。我们可以理解为，上面Storage内存和Execution堆内存是受Spark管理的，而且每一个内存区是可以动态伸缩的。这样的好处是，当某一个内存区内存使用量达到初始分配值，如果不能够动态伸缩，不能在两类内存区之间进行动态调整（Borrow），或者如果某个Task计算的数据量很大超过限制，就会出现OOM异常导致Task执行失败。应该说，在一定程度上，UnifiedMemoryManager内存管理模型降低了发生OOM的概率。</p><p>我们知道，在Spark Application提交以后，最终会在Worker上启动独立的Executor JVM，Task就运行在Executor里面。在一个Executor JVM内部，基于UnifiedMemoryManager这种内存管理模型，堆内存的布局如下图所示：</p><p><a href="http://www.uml.org.cn/bigdata/images/2017072832.png" target="_blank" rel="noopener"><img src="http://www.uml.org.cn/bigdata/images/2017072832.png" alt=""></a></p><p>上图中，systemMemory是Executor JVM的全部堆内存，在全部堆内存基础上reservedMemory是预留内存，默认300M，则用于Spark计算使用堆内存大小默认是：</p><p>| maxHeapMemory = (systemMemory - reservedMemory) * 0.6 |</p><p>受Spark管理的堆内存，使用去除预留内存后的、剩余内存的百分比，可以通过参数spark.memory.fraction来配置，默认值是0.6。Executor JVM堆内存，去除预留的reservedMemory内存，默认剩下堆内存的60%用于execution和storage这两类堆内存，默认情况下，Execution和Storage内存区各占50%，这个也可以通过参数spark.memory.storageFraction来配置，默认值是0.5。比如，在所有参数使用默认值的情况下，我们的Executor JVM内存为指定为2G，那么Unified Memory大小为(1024 <em>2 – 300) </em>0.6 = 1048MB，其中，Execution和Storage内存区大小分别为1048 * 0.5 = 524MB。</p><p>另外，还有一个用来保证Spark Application能够计算的最小Executor JVM内存大小限制，即为minSystemMemory = reservedMemory <em>1.5 = 300 </em>1.5 = 450MB，我们假设Executor JVM配置了这个默认最小限制值450MB，则受Spark管理的堆内存大小为(450 – 300) <em>0.6 = 90MB，其中Execution和Storage内存大小分别为90 </em>0.5 = 45MB，这种情况对一些小内存用量的Spark计算也能够很好的支持。</p><p>上面，我们详细说明了受Spark管理的堆内存（OnHeap Memory）的布局，UnifiedMemoryManager也能够对非堆内存（OffHeap Memory）进行管理。Spark堆内存和非堆内存的布局，如下图所示：</p><p><a href="http://www.uml.org.cn/bigdata/images/2017072833.png" target="_blank" rel="noopener"><img src="http://www.uml.org.cn/bigdata/images/2017072833.png" alt=""></a></p><p>通过上图可以看到，非堆内存（OffHeap Memory）默认大小配置值为0，表示不使用非堆内存，可以通过参数spark.memory.offHeap.size来设置非堆内存的大小。无论是对堆内存，还是对非堆内存，都分为Execution内存和Storage内存两部分，他们的分配大小比例通过参数spark.memory.storageFraction来控制，默认是0.5。</p><h2 id="内存控制"><a href="#内存控制" class="headerlink" title="内存控制"></a>内存控制</h2><p>通过上面，我们了解了UnifiedMemoryManager这种内存管理模型的内存布局状况。接下来，我们看一下，通过UnifiedMemoryManager的API，如何对内存进行控制（分配/回收）。内存的控制，也对应于Execution内存与Storage内存，分别有一个StorageMemoryPool 和ExecutionMemoryPool，在实现类UnifiedMemoryManager中可以看到通过这两个MemoryPool实现来控制内存大小的伸缩（Increment/Decrement）。</p><p>获取当前堆上的最大可用Storage内存，如下maxOnHeapStorageMemory方法所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">def</span> <span class="title">maxOnHeapStorageMemory</span></span>: <span class="type">Long</span> = synchronized &#123; maxHeapMemory - onHeapExecutionMemoryPool.memoryUsed</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>可以看到，maxHeapMemory表示堆上可用的Execution内存与Storage内存总量之和，减去Execution内存中已经被占用的内存，剩余的都是堆上的最大可用Storage内存。</p><p>在UnifiedMemoryManager中，两类最核心的操作，就是申请/释放Storage内存、申请/释放Execution内存，分别说明如下：</p><h3 id="申请Storage内存"><a href="#申请Storage内存" class="headerlink" title="申请Storage内存"></a>申请Storage内存</h3><p>申请Storage内存的逻辑，实现代码如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">def</span> <span class="title">acquireStorageMemory</span></span>( blockId: <span class="type">BlockId</span>,</span><br><span class="line">numBytes: <span class="type">Long</span>,</span><br><span class="line">memoryMode: <span class="type">MemoryMode</span>): <span class="type">Boolean</span> = synchronized &#123; <span class="comment">// 为blockId申请numBytes字节大小的内存</span></span><br><span class="line">assertInvariants()</span><br><span class="line">assert(numBytes &gt;= <span class="number">0</span>)</span><br><span class="line"><span class="keyword">val</span> (executionPool, storagePool, maxMemory) = memoryMode <span class="keyword">match</span> &#123; <span class="comment">// 根据memoryMode值，返回对应的StorageMemoryPool与ExecutionMemoryPool</span></span><br><span class="line"><span class="keyword">case</span> <span class="type">MemoryMode</span>.<span class="type">ON_HEAP</span> =&gt; (</span><br><span class="line">onHeapExecutionMemoryPool,</span><br><span class="line">onHeapStorageMemoryPool,</span><br><span class="line">maxOnHeapStorageMemory)</span><br><span class="line"><span class="keyword">case</span> <span class="type">MemoryMode</span>.<span class="type">OFF_HEAP</span> =&gt; (</span><br><span class="line">offHeapExecutionMemoryPool,</span><br><span class="line">offHeapStorageMemoryPool,</span><br><span class="line">maxOffHeapMemory)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> (numBytes &gt; maxMemory) &#123; <span class="comment">// 如果申请的内存大于最大的Storage内存量（对应上面方法maxOnHeapStorageMemory()返回的内存大小），申请失败</span></span><br><span class="line"><span class="comment">// Fail fast if the block simply won't fit</span></span><br><span class="line">logInfo(<span class="string">s"Will not store <span class="subst">$blockId</span> as the required space (<span class="subst">$numBytes</span> bytes) exceeds our "</span> +</span><br><span class="line"><span class="string">s"memory limit (<span class="subst">$maxMemory</span> bytes)"</span>)</span><br><span class="line"><span class="keyword">return</span> <span class="literal">false</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> (numBytes &gt; storagePool.memoryFree) &#123; <span class="comment">// 如果Storage内存块中没有足够可用内存给blockId使用，则计算当前Storage内存区缺少多少内存，然后从Execution内存区中借用</span></span><br><span class="line"><span class="comment">// There is not enough free memory in the storage pool, so try to borrow free memory from</span></span><br><span class="line"><span class="comment">// the execution pool.</span></span><br><span class="line"><span class="keyword">val</span> memoryBorrowedFromExecution = <span class="type">Math</span>.min(executionPool.memoryFree, numBytes)</span><br><span class="line">executionPool.decrementPoolSize(memoryBorrowedFromExecution) <span class="comment">// Execution内存区减掉借用内存量</span></span><br><span class="line">storagePool.incrementPoolSize(memoryBorrowedFromExecution) <span class="comment">// Storage内存区增加借用内存量</span></span><br><span class="line">&#125;</span><br><span class="line">storagePool.acquireMemory(blockId, numBytes) <span class="comment">// 如果Storage内存区可以为blockId分配内存，直接成功分配；否则，如果从Execution内存区中借用的内存能够满足blockId，则分配成功，不能满足则分配失败。</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>如果Storage内存区可用内存满足申请大小，则直接成功分配内存；如果Storage内存区可用内存大于0且小于申请的内存大小，则需要从Execution内存区借用满足分配大小的内存，如果借用成功，则直接成功分配内存，否则分配失败；如果申请的内存超过了Storage内存区的最大内存量，则分配失败。</p><p>另外，UnifiedMemoryManager.acquireUnrollMemory()方法提供了对Unroll内存的申请，Unroll内存就是Storage内存：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">def</span> <span class="title">acquireUnrollMemory</span></span>( blockId: <span class="type">BlockId</span>,</span><br><span class="line">numBytes: <span class="type">Long</span>,</span><br><span class="line">memoryMode: <span class="type">MemoryMode</span>): <span class="type">Boolean</span> = synchronized &#123;</span><br><span class="line">acquireStorageMemory(blockId, numBytes, memoryMode)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Unroll内存 ，被用来在Storage内存中Unroll（展开）指定的Block数据。</p><h3 id="释放Storage内存"><a href="#释放Storage内存" class="headerlink" title="释放Storage内存"></a>释放Storage内存</h3><p>释放Storage内存比较简单，只需要更新Storage内存计量变量即可，如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">releaseMemory</span></span>(size: <span class="type">Long</span>): <span class="type">Unit</span> = lock.synchronized &#123; <span class="keyword">if</span> (size &gt; _memoryUsed) &#123;</span><br><span class="line">logWarning(<span class="string">s"Attempted to release <span class="subst">$size</span> bytes of storage "</span> +</span><br><span class="line"><span class="string">s"memory when we only have <span class="subst">$&#123;_memoryUsed&#125;</span> bytes"</span>)</span><br><span class="line">_memoryUsed = <span class="number">0</span></span><br><span class="line">&#125; <span class="keyword">else</span> &#123;</span><br><span class="line">_memoryUsed -= size</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="申请Execution内存"><a href="#申请Execution内存" class="headerlink" title="申请Execution内存"></a>申请Execution内存</h3><p>申请Execution内存，相对复杂一些，调用acquireExecutionMemory()方法可能会阻塞，直到Execution内存区有可用内存为止。UnifiedMemoryManager的acquireExecutionMemory()方法实现如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"> <span class="keyword">override</span> <span class="keyword">private</span>[memory] <span class="function"><span class="keyword">def</span> <span class="title">acquireExecutionMemory</span></span>( numBytes: <span class="type">Long</span>,</span><br><span class="line">taskAttemptId: <span class="type">Long</span>,</span><br><span class="line">memoryMode: <span class="type">MemoryMode</span>): <span class="type">Long</span> = synchronized &#123;</span><br><span class="line">... ...</span><br><span class="line">executionPool.acquireMemory(</span><br><span class="line">numBytes, taskAttemptId, maybeGrowExecutionPool, computeMaxExecutionPoolSize)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>上面代码，调用了ExecutionMemoryPool的acquireMemory()方法，该方法的参数需要2个函数（maybeGrowExecutionPool函数用来控制如何增加Execution内存区对应Pool的大小，computeMaxExecutionPoolSize函数用来获取当前Execution内存区对应Pool的大小）。ExecutionMemoryPool的acquireMemory()方法签名，如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span>[memory] <span class="function"><span class="keyword">def</span> <span class="title">acquireMemory</span></span>( numBytes: <span class="type">Long</span>,</span><br><span class="line">taskAttemptId: <span class="type">Long</span>,</span><br><span class="line">maybeGrowPool: <span class="type">Long</span> =&gt; <span class="type">Unit</span> = (additionalSpaceNeeded: <span class="type">Long</span>) =&gt; <span class="type">Unit</span>,</span><br><span class="line">computeMaxPoolSize: () =&gt; <span class="type">Long</span> = () =&gt; poolSize): <span class="type">Long</span> = lock.synchronized &#123;</span><br></pre></td></tr></table></figure><p>在UnifiedMemoryManager内部，实现了如何动态增加Execution内存区对应Pool大小的函数，即为maybeGrowExecutionPool函数，代码如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">maybeGrowExecutionPool</span></span>(extraMemoryNeeded: <span class="type">Long</span>): <span class="type">Unit</span> = &#123; <span class="keyword">if</span> (extraMemoryNeeded &gt; <span class="number">0</span>) &#123;</span><br><span class="line"><span class="comment">// There is not enough free memory in the execution pool, so try to reclaim memory from</span></span><br><span class="line"><span class="comment">// storage. We can reclaim any free memory from the storage pool. If the storage pool</span></span><br><span class="line"><span class="comment">// has grown to become larger than `storageRegionSize`, we can evict blocks and reclaim</span></span><br><span class="line"><span class="comment">// the memory that storage has borrowed from execution.</span></span><br><span class="line"><span class="keyword">val</span> memoryReclaimableFromStorage = math.max( storagePool.memoryFree, storagePool.poolSize - storageRegionSize) </span><br><span class="line"><span class="keyword">if</span> (memoryReclaimableFromStorage &gt; <span class="number">0</span>) &#123; <span class="comment">// 这里memoryReclaimableFromStorage大于0，说明当前Storage内存区有可用内存，可以Shrink该Pool的内存，作为Execution内存区的可用内存使用</span></span><br><span class="line"><span class="comment">// Only reclaim as much space as is necessary and available:</span></span><br><span class="line"><span class="keyword">val</span> spaceToReclaim = storagePool.freeSpaceToShrinkPool( math.min(extraMemoryNeeded, memoryReclaimableFromStorage)) <span class="comment">// 对Storage内存区进行Shrink操作，如果可用内存大于请求内存extraMemoryNeeded，则直接将Storage内存区内存Shrink大小为extraMemoryNeeded，否则Shrink大小为Storage内存区的全部可用内存大小</span></span><br><span class="line">storagePool.decrementPoolSize(spaceToReclaim) <span class="comment">// Storage内存区减掉借用内存量</span></span><br><span class="line">executionPool.incrementPoolSize(spaceToReclaim) <span class="comment">// Execution内存区增加借用内存量</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>需要说明的是，上面的storagePool.poolSize的大小可能大于Storage内存区初始最大内存大小，主要是通过借用Execution内存区的内存导致的。这里，storagePool.freeSpaceToShrinkPool()方法会Shrink掉Storage内存区可用内存，我们可以看下StorageMemoryPool中如何Shrink Storage内存，方法如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">freeSpaceToShrinkPool</span></span>(spaceToFree: <span class="type">Long</span>): <span class="type">Long</span> = lock.synchronized &#123; <span class="keyword">val</span> spaceFreedByReleasingUnusedMemory = math.min(spaceToFree, memoryFree)</span><br><span class="line"><span class="keyword">val</span> remainingSpaceToFree = spaceToFree - spaceFreedByReleasingUnusedMemory <span class="comment">// Storage内存区需要释放remainingSpaceToFree大小的内存</span></span><br><span class="line"><span class="keyword">if</span> (remainingSpaceToFree &gt; <span class="number">0</span>) &#123; <span class="comment">// 大于0表示当前Storage内存区已经无可用内存，需要通过清理Storage内存区的block来实现Shrink操作</span></span><br><span class="line"><span class="comment">// If reclaiming free memory did not adequately shrink the pool, begin evicting blocks:</span></span><br><span class="line"><span class="keyword">val</span> spaceFreedByEviction = memoryStore.evictBlocksToFreeSpace(<span class="type">None</span>, remainingSpaceToFree, memoryMode) <span class="comment">// 通过清理Storage内存区的block释放的内存大小</span></span><br><span class="line"><span class="comment">// When a block is released, BlockManager.dropFromMemory() calls releaseMemory(), so we do</span></span><br><span class="line"><span class="comment">// not need to decrement _memoryUsed here. However, we do need to decrement the pool size.</span></span><br><span class="line">spaceFreedByReleasingUnusedMemory + spaceFreedByEviction</span><br><span class="line">&#125; <span class="keyword">else</span> &#123; <span class="comment">// remainingSpaceToFree Unit = (additionalSpaceNeeded: Long) =&gt; Unit,</span></span><br><span class="line">computeMaxPoolSize: () =&gt; <span class="type">Long</span> = () =&gt; poolSize): <span class="type">Long</span> = lock.synchronized &#123;</span><br><span class="line">assert(numBytes &gt; <span class="number">0</span>, <span class="string">s"invalid number of bytes requested: <span class="subst">$numBytes</span>"</span>)</span><br><span class="line"><span class="keyword">if</span> (!memoryForTask.contains(taskAttemptId)) &#123; <span class="comment">// ExecutionMemoryPool内部维护了一个HashMap</span></span><br><span class="line">memoryForTask(taskAttemptId) = <span class="number">0</span>L</span><br><span class="line"><span class="comment">// This will later cause waiting tasks to wake up and check numTasks again</span></span><br><span class="line">lock.notifyAll()</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">while</span> (<span class="literal">true</span>) &#123;</span><br><span class="line"><span class="keyword">val</span> numActiveTasks = memoryForTask.keys.size <span class="comment">// 当前活跃的Task数量</span></span><br><span class="line"><span class="keyword">val</span> curMem = memoryForTask(taskAttemptId) <span class="comment">// 当前Task使用的内存量</span></span><br><span class="line">maybeGrowPool(numBytes - memoryFree) <span class="comment">// 如果需要，通过Shrink Storage内存区对应的Pool内存来增加Execution内存区内存大小</span></span><br><span class="line"><span class="keyword">val</span> maxPoolSize = computeMaxPoolSize() <span class="comment">// 计算当前Execution内存区对应Pool的大小</span></span><br><span class="line"><span class="keyword">val</span> maxMemoryPerTask = maxPoolSize / numActiveTasks <span class="comment">// 计算1/N：将当前Execution内存区对应Pool的大小，平均分配给所有活跃的Task，得到每个Task能够获取到的最大内存大小</span></span><br><span class="line"><span class="keyword">val</span> minMemoryPerTask = poolSize / (<span class="number">2</span> * numActiveTasks) <span class="comment">// 计算1/2N：每个Task能够获取到的最小内存大小</span></span><br><span class="line"><span class="keyword">val</span> maxToGrant = math.min(numBytes, math.max(<span class="number">0</span>, maxMemoryPerTask - curMem)) <span class="comment">// 允许当前Task获取到最大内存（范围：0 </span></span><br><span class="line"><span class="keyword">val</span> toGrant = math.min(maxToGrant, memoryFree) <span class="comment">// 计算能分配给当前Task的内存大小</span></span><br><span class="line"><span class="comment">// 如果当前Task无法获取到1 / (2 * numActiveTasks)的内存，并且能分配给当前Task的内存大小无法满足申请的内存量，则阻塞等待其他Task释放内存后在lock上通知</span></span><br><span class="line"><span class="keyword">if</span> (toGrant </span><br><span class="line">logInfo(<span class="string">s"TID <span class="subst">$taskAttemptId</span> waiting for at least 1/2N of <span class="subst">$poolName</span> pool to be free"</span>)</span><br><span class="line">lock.wait() <span class="comment">// 在ExecutionMemoryPool.releaseMemory()方法中会通知其他申请内存并在lock上wait的Task，内存已经释放</span></span><br><span class="line">&#125; <span class="keyword">else</span> &#123;</span><br><span class="line">memoryForTask(taskAttemptId) += toGrant <span class="comment">// 当前Task获取到内存，需要登记到memoryForTask表中</span></span><br><span class="line"><span class="keyword">return</span> toGrant</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"><span class="number">0</span>L <span class="comment">// Never reached</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="释放Execution内存"><a href="#释放Execution内存" class="headerlink" title="释放Execution内存"></a>释放Execution内存</h3><p>相对应的，ExecutionMemoryPool.releaseMemory()方法实现了对Execution内存的释放操作，方法实现代码如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">releaseMemory</span></span>(numBytes: <span class="type">Long</span>, taskAttemptId: <span class="type">Long</span>): <span class="type">Unit</span> = lock.synchronized &#123; <span class="keyword">val</span> curMem = memoryForTask.getOrElse(taskAttemptId, <span class="number">0</span>L)</span><br><span class="line"><span class="keyword">var</span> memoryToFree = <span class="keyword">if</span> (curMem <span class="comment">// 计算释放内存大小</span></span><br><span class="line">logWarning( <span class="string">s"Internal error: release called on <span class="subst">$numBytes</span> bytes but task only has <span class="subst">$curMem</span> bytes "</span> +</span><br><span class="line"><span class="string">s"of memory from the <span class="subst">$poolName</span> pool"</span>)</span><br><span class="line">curMem</span><br><span class="line">&#125; <span class="keyword">else</span> &#123;</span><br><span class="line">numBytes</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> (memoryForTask.contains(taskAttemptId)) &#123; <span class="comment">// Task执行完成，从内部维护的memoryForTask中移除</span></span><br><span class="line">memoryForTask(taskAttemptId) -= memoryToFree</span><br><span class="line"><span class="keyword">if</span> (memoryForTask(taskAttemptId) <span class="number">0</span>) &#123;</span><br><span class="line">memoryForTask.remove(taskAttemptId)</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">lock.notifyAll() <span class="comment">// 通知调用acquireMemory()方法申请内存的Task内存已经释放</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>需要注意的，每个Executor JVM中只存在一个UnifiedMemoryManager实例，该对象统一控制该JVM内对Storage和Execution内存的申请和释放操作。</p><p>通过上面的分析，UnifiedMemoryManager可以看做一个统一的内存管理控制器，底层通过StorageMemoryPool 与ExecutionMemoryPool提供的申请内存、释放内存的功能，实现最基本的bookkeeping功能。再向底层，实际操作Block及其Java对象等数据的功能，都是在MemoryStore中进行的，MemoryStore被用来在内存中存储数据，主要包括block、反序列化的Java对象数组、序列化的ByteBuffer，同时它提供了存取内存中各种格式数据的操作。关于MemoryStore的基本结构和原理，我们后续会单独分析。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;Spark的内存使用，大体上可以分为两类：Execution内存和Storage内存。在Spark 1.5版本之前，内存管理使用的是StaticMemoryManager，该内存管理模型最大的特点就是，可以为Execution内存区与Storage内存区配置一个静态的boundary，这种方式实现起来比较简单，但是存在一些问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;没有一个合理的默认值能够适应不同计算场景下的Workload&lt;/li&gt;
&lt;li&gt;内存调优困难，需要对Spark内部原理非常熟悉才能做好&lt;/li&gt;
&lt;li&gt;对不需要Cache的Application的计算场景，只能使用很少一部分内存
    
    </summary>
    
      <category term="spark" scheme="http://crazycarry.github.io/categories/spark/"/>
    
    
      <category term="源码分析" scheme="http://crazycarry.github.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"/>
    
      <category term="大数据" scheme="http://crazycarry.github.io/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
    
      <category term="性能优化" scheme="http://crazycarry.github.io/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"/>
    
      <category term="内存管理" scheme="http://crazycarry.github.io/tags/%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86/"/>
    
  </entry>
  
  <entry>
    <title>Spark RPC通信层设计原理分析</title>
    <link href="http://crazycarry.github.io/2018/04/04/spark-network-rpc/"/>
    <id>http://crazycarry.github.io/2018/04/04/spark-network-rpc/</id>
    <published>2018-04-04T08:08:40.000Z</published>
    <updated>2018-04-04T08:17:23.091Z</updated>
    
    <content type="html"><![CDATA[<p>Spark将RPC通信层设计的非常巧妙，融合了各种设计/架构模式，将一个分布式集群系统的通信层细节完全屏蔽，这样在上层的计算框架的设计中能够获得很好的灵活性。同时，如果上层想要增加各种新的特性，或者对来自不同企业或组织的程序员贡献的特性，也能够很容易地增加进来，可以避开复杂的通信层而将注意力集中在上层计算框架的处理和优化上，入手难度非常小。另外，对上层计算框架中的各个核心组件的开发、功能增强，以及Bug修复等都会变得更加容易<br><a id="more"></a></p><h2 id="Spark-RPC层设计概览"><a href="#Spark-RPC层设计概览" class="headerlink" title="Spark RPC层设计概览"></a>Spark RPC层设计概览</h2><p>Spark RPC层是基于优秀的网络通信框架Netty设计开发的，同时获得了Netty所具有的网络通信的可靠性和高效性。我们先把Spark中与RPC相关的一些类的关系梳理一下，为了能够更直观地表达RPC的设计，我们先从类的设计来看，如下图所示<br><a href="http://7xim8y.com1.z0.glb.clouddn.com/SparkRPC-ClassDiagram.png?imageView2/1/w/800/h/500" target="_blank" rel="noopener"><img src="http://7xim8y.com1.z0.glb.clouddn.com/SparkRPC-ClassDiagram.png?imageView2/1/w/800/h/500" alt=""></a><br>通过上图，可以清晰地将RPC设计分离出来，能够对RPC层有一个整体的印象。了解Spark RPC层的几个核心的概念（我们通过Spark源码中对应的类名来标识），能够更好地理解设计</p><h3 id="RpcEndpoint"><a href="#RpcEndpoint" class="headerlink" title="RpcEndpoint"></a>RpcEndpoint</h3><p>RpcEndpoint定义了RPC通信过程中的通信端对象，除了具有管理一个RpcEndpoint生命周期的操作（constructor -&gt; onStart -&gt; receive* -&gt; onStop），并给出了通信过程中一个RpcEndpoint所具有的基于事件驱动的行为（连接、断开、网络异常），实际上对于Spark框架来说主要是接收消息并处理，具体可以看对应特质RpcEndpoint的代码定义，如下所示</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span>[spark] <span class="class"><span class="keyword">trait</span> <span class="title">RpcEndpoint</span> </span>&#123;</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * The [[RpcEnv]] that this [[RpcEndpoint]] is registered to.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="keyword">val</span> rpcEnv: <span class="type">RpcEnv</span></span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * The [[RpcEndpointRef]] of this [[RpcEndpoint]]. `self` will become valid when `onStart` is</span></span><br><span class="line"><span class="comment"> * called. And `self` will become `null` when `onStop` is called.</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * Note: Because before `onStart`, [[RpcEndpoint]] has not yet been registered and there is not</span></span><br><span class="line"><span class="comment"> * valid [[RpcEndpointRef]] for it. So don't call `self` before `onStart` is called.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="keyword">final</span> <span class="function"><span class="keyword">def</span> <span class="title">self</span></span>: <span class="type">RpcEndpointRef</span> = &#123;</span><br><span class="line"> require(rpcEnv != <span class="literal">null</span>, <span class="string">"rpcEnv has not been initialized"</span>)</span><br><span class="line"> rpcEnv.endpointRef(<span class="keyword">this</span>)</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Process messages from `RpcEndpointRef.send` or `RpcCallContext.reply`. If receiving a</span></span><br><span class="line"><span class="comment"> * unmatched message, `SparkException` will be thrown and sent to `onError`.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">receive</span></span>: <span class="type">PartialFunction</span>[<span class="type">Any</span>, <span class="type">Unit</span>] = &#123;</span><br><span class="line"> <span class="keyword">case</span> _ =&gt; <span class="keyword">throw</span> <span class="keyword">new</span> <span class="type">SparkException</span>(self + <span class="string">" does not implement 'receive'"</span>)</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Process messages from `RpcEndpointRef.ask`. If receiving a unmatched message,</span></span><br><span class="line"><span class="comment"> * `SparkException` will be thrown and sent to `onError`.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">receiveAndReply</span></span>(context: <span class="type">RpcCallContext</span>): <span class="type">PartialFunction</span>[<span class="type">Any</span>, <span class="type">Unit</span>] = &#123;</span><br><span class="line"> <span class="keyword">case</span> _ =&gt; context.sendFailure(<span class="keyword">new</span> <span class="type">SparkException</span>(self + <span class="string">" won't reply anything"</span>))</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Invoked when any exception is thrown during handling messages.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">onError</span></span>(cause: <span class="type">Throwable</span>): <span class="type">Unit</span> = &#123;</span><br><span class="line"> <span class="comment">// By default, throw e and let RpcEnv handle it</span></span><br><span class="line"> <span class="keyword">throw</span> cause</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Invoked when `remoteAddress` is connected to the current node.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">onConnected</span></span>(remoteAddress: <span class="type">RpcAddress</span>): <span class="type">Unit</span> = &#123;</span><br><span class="line"> <span class="comment">// By default, do nothing.</span></span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Invoked when `remoteAddress` is lost.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">onDisconnected</span></span>(remoteAddress: <span class="type">RpcAddress</span>): <span class="type">Unit</span> = &#123;</span><br><span class="line"> <span class="comment">// By default, do nothing.</span></span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Invoked when some network error happens in the connection between the current node and</span></span><br><span class="line"><span class="comment"> * `remoteAddress`.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">onNetworkError</span></span>(cause: <span class="type">Throwable</span>, remoteAddress: <span class="type">RpcAddress</span>): <span class="type">Unit</span> = &#123;</span><br><span class="line"> <span class="comment">// By default, do nothing.</span></span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Invoked before [[RpcEndpoint]] starts to handle any message.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">onStart</span></span>(): <span class="type">Unit</span> = &#123;</span><br><span class="line"> <span class="comment">// By default, do nothing.</span></span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Invoked when [[RpcEndpoint]] is stopping. `self` will be `null` in this method and you cannot</span></span><br><span class="line"><span class="comment"> * use it to send or ask messages.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">onStop</span></span>(): <span class="type">Unit</span> = &#123;</span><br><span class="line"> <span class="comment">// By default, do nothing.</span></span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>通过上面的receive方法，接收由RpcEndpointRef.send方法发送的消息，该类消息不需要进行响应消息（Reply），而只是在RpcEndpoint端进行处理。通过receiveAndReply方法，接收由RpcEndpointRef.ask发送的消息，RpcEndpoint端处理完消息后，需要给调用RpcEndpointRef.ask的通信端响应消息（Reply）</p><h3 id="RpcEndpointRef"><a href="#RpcEndpointRef" class="headerlink" title="RpcEndpointRef"></a>RpcEndpointRef</h3><p>RpcEndpointRef是一个对RpcEndpoint的远程引用对象，通过它可以向远程的RpcEndpoint端发送消息以进行通信。RpcEndpointRef特质的定义，代码如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span>[spark] <span class="keyword">abstract</span> <span class="class"><span class="keyword">class</span> <span class="title">RpcEndpointRef</span>(<span class="params">conf: <span class="type">SparkConf</span></span>)</span></span><br><span class="line"><span class="class"> <span class="keyword">extends</span> <span class="title">Serializable</span> <span class="keyword">with</span> <span class="title">Logging</span> </span>&#123;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">private</span>[<span class="keyword">this</span>] <span class="keyword">val</span> maxRetries = <span class="type">RpcUtils</span>.numRetries(conf)</span><br><span class="line"> <span class="keyword">private</span>[<span class="keyword">this</span>] <span class="keyword">val</span> retryWaitMs = <span class="type">RpcUtils</span>.retryWaitMs(conf)</span><br><span class="line"> <span class="keyword">private</span>[<span class="keyword">this</span>] <span class="keyword">val</span> defaultAskTimeout = <span class="type">RpcUtils</span>.askRpcTimeout(conf)</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * return the address for the [[RpcEndpointRef]]</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">address</span></span>: <span class="type">RpcAddress</span></span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">name</span></span>: <span class="type">String</span></span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Sends a one-way asynchronous message. Fire-and-forget semantics.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">send</span></span>(message: <span class="type">Any</span>): <span class="type">Unit</span></span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Send a message to the corresponding [[RpcEndpoint.receiveAndReply)]] and return a [[Future]] to</span></span><br><span class="line"><span class="comment"> * receive the reply within the specified timeout.</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * This method only sends the message once and never retries.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">ask</span></span>[<span class="type">T</span>: <span class="type">ClassTag</span>](message: <span class="type">Any</span>, timeout: <span class="type">RpcTimeout</span>): <span class="type">Future</span>[<span class="type">T</span>]</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Send a message to the corresponding [[RpcEndpoint.receiveAndReply)]] and return a [[Future]] to</span></span><br><span class="line"><span class="comment"> * receive the reply within a default timeout.</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * This method only sends the message once and never retries.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">ask</span></span>[<span class="type">T</span>: <span class="type">ClassTag</span>](message: <span class="type">Any</span>): <span class="type">Future</span>[<span class="type">T</span>] = ask(message, defaultAskTimeout)</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Send a message to the corresponding [[RpcEndpoint.receiveAndReply]] and get its result within a</span></span><br><span class="line"><span class="comment"> * default timeout, throw an exception if this fails.</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * Note: this is a blocking action which may cost a lot of time,  so don't call it in a message</span></span><br><span class="line"><span class="comment"> * loop of [[RpcEndpoint]].</span></span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment"> * @param message the message to send</span></span><br><span class="line"><span class="comment"> * @tparam T type of the reply message</span></span><br><span class="line"><span class="comment"> * @return the reply message from the corresponding [[RpcEndpoint]]</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">askSync</span></span>[<span class="type">T</span>: <span class="type">ClassTag</span>](message: <span class="type">Any</span>): <span class="type">T</span> = askSync(message, defaultAskTimeout)</span><br><span class="line"></span><br><span class="line"> <span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Send a message to the corresponding [[RpcEndpoint.receiveAndReply]] and get its result within a</span></span><br><span class="line"><span class="comment"> * specified timeout, throw an exception if this fails.</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * Note: this is a blocking action which may cost a lot of time, so don't call it in a message</span></span><br><span class="line"><span class="comment"> * loop of [[RpcEndpoint]].</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * @param message the message to send</span></span><br><span class="line"><span class="comment"> * @param timeout the timeout duration</span></span><br><span class="line"><span class="comment"> * @tparam T type of the reply message</span></span><br><span class="line"><span class="comment"> * @return the reply message from the corresponding [[RpcEndpoint]]</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">askSync</span></span>[<span class="type">T</span>: <span class="type">ClassTag</span>](message: <span class="type">Any</span>, timeout: <span class="type">RpcTimeout</span>): <span class="type">T</span> = &#123;</span><br><span class="line"> <span class="keyword">val</span> future = ask[<span class="type">T</span>](message, timeout)</span><br><span class="line"> timeout.awaitResult(future)</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>send方法发送消息后不等待响应，亦即Send-and-forget，Spark中基于Netty实现，实现在NettyRpcEndpointRef中</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">override</span> <span class="function"><span class="keyword">def</span> <span class="title">ask</span></span>[<span class="type">T</span>: <span class="type">ClassTag</span>](message: <span class="type">Any</span>, timeout: <span class="type">RpcTimeout</span>): <span class="type">Future</span>[<span class="type">T</span>] = &#123;</span><br><span class="line"> nettyEnv.ask(<span class="keyword">new</span> <span class="type">RequestMessage</span>(nettyEnv.address, <span class="keyword">this</span>, message), timeout)</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><h3 id="RpcEnv"><a href="#RpcEnv" class="headerlink" title="RpcEnv"></a>RpcEnv</h3><p>一个RpcEnv是一个RPC环境对象，它负责管理RpcEndpoint的注册，以及如何从一个RpcEndpoint获取到一个RpcEndpointRef。RpcEndpoint是一个通信端，例如Spark集群中的Master，或Worker，都是一个RpcEndpoint。但是，如果想要与一个RpcEndpoint端进行通信，一定需要获取到该RpcEndpoint一个RpcEndpointRef，而获取该RpcEndpointRef只能通过一个RpcEnv环境对象来获取。所以说，一个RpcEnv对象才是RPC通信过程中的“指挥官”，在RpcEnv类中，有一个核心的方法：<br>def setupEndpoint(name: String, endpoint: RpcEndpoint): RpcEndpointRef<br>通过上面方法，可以注册一个RpcEndpoint到RpcEnv环境对象中，有RpcEnv来管理RpcEndpoint到RpcEndpointRef的绑定关系。在注册RpcEndpoint时，每个RpcEndpoint都需要有一个唯一的名称。<br>Spark中基于Netty实现通信，所以对应的RpcEnv实现为NettyRpcEnv，上面方法的实现，如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">override</span> <span class="function"><span class="keyword">def</span> <span class="title">setupEndpoint</span></span>(name: <span class="type">String</span>, endpoint: <span class="type">RpcEndpoint</span>): <span class="type">RpcEndpointRef</span> = &#123;</span><br><span class="line"> dispatcher.registerRpcEndpoint(name, endpoint)</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>调用NettyRpcEnv内部的Dispatcher对象注册一个RpcEndpoint</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">registerRpcEndpoint</span></span>(name: <span class="type">String</span>, endpoint: <span class="type">RpcEndpoint</span>): <span class="type">NettyRpcEndpointRef</span> = &#123;</span><br><span class="line"> <span class="keyword">val</span> addr = <span class="type">RpcEndpointAddress</span>(nettyEnv.address, name)</span><br><span class="line"> <span class="keyword">val</span> endpointRef = <span class="keyword">new</span> <span class="type">NettyRpcEndpointRef</span>(nettyEnv.conf, addr, nettyEnv)</span><br><span class="line"> synchronized &#123;</span><br><span class="line"> <span class="keyword">if</span> (stopped) &#123;</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="type">IllegalStateException</span>(<span class="string">"RpcEnv has been stopped"</span>)</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="keyword">if</span> (endpoints.putIfAbsent(name, <span class="keyword">new</span> <span class="type">EndpointData</span>(name, endpoint, endpointRef)) != <span class="literal">null</span>) &#123;</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="type">IllegalArgumentException</span>(<span class="string">s"There is already an RpcEndpoint called <span class="subst">$name</span>"</span>)</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="keyword">val</span> data = endpoints.get(name)</span><br><span class="line"> endpointRefs.put(data.endpoint, data.ref)</span><br><span class="line"> receivers.offer(data)  <span class="comment">// for the OnStart message</span></span><br><span class="line"> &#125;</span><br><span class="line"> endpointRef</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>一个RpcEndpoint只能注册一次（根据RpcEndpoint的名称来检查唯一性），这样在Dispatcher内部注册并维护RpcEndpoint与RpcEndpointRef的绑定关系，通过如下两个内部结构：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="keyword">val</span> endpoints: <span class="type">ConcurrentMap</span>[<span class="type">String</span>, <span class="type">EndpointData</span>] =</span><br><span class="line"> <span class="keyword">new</span> <span class="type">ConcurrentHashMap</span>[<span class="type">String</span>, <span class="type">EndpointData</span>]</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">val</span> endpointRefs: <span class="type">ConcurrentMap</span>[<span class="type">RpcEndpoint</span>, <span class="type">RpcEndpointRef</span>] =</span><br><span class="line"> <span class="keyword">new</span> <span class="type">ConcurrentHashMap</span>[<span class="type">RpcEndpoint</span>, <span class="type">RpcEndpointRef</span>]</span><br></pre></td></tr></table></figure><p>可以看到，一个命名唯一的RpcEndpoint在Dispatcher中对应一个EndpointData来维护其信息，该数据结构定义，如下所示:</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="class"><span class="keyword">class</span> <span class="title">EndpointData</span>(<span class="params"></span></span></span><br><span class="line"><span class="class"><span class="params"> val name: <span class="type">String</span>,</span></span></span><br><span class="line"><span class="class"><span class="params"> val endpoint: <span class="type">RpcEndpoint</span>,</span></span></span><br><span class="line"><span class="class"><span class="params">val ref: <span class="type">NettyRpcEndpointRef</span></span>) </span>&#123;</span><br><span class="line"> <span class="keyword">val</span> inbox = <span class="keyword">new</span> <span class="type">Inbox</span>(ref, endpoint)</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>这里，每一个命名唯一的RpcEndpoint对应一个线程安全的Inbox，所有发送给一个RpcEndpoint的消息，都由对应的Inbox将对应的消息路由给RpcEndpoint进行处理，后面我们会详细分析Inbox</p><h2 id="创建NettyRpcEnv环境对象"><a href="#创建NettyRpcEnv环境对象" class="headerlink" title="创建NettyRpcEnv环境对象"></a>创建NettyRpcEnv环境对象</h2><p>创建NettyRpcEnv对象，是一个非常重的操作，所以在框架里使用过程中要尽量避免重复创建。创建NettyRpcEnv，会创建很多用来处理底层RPC通信的线程和数据结构。具体的创建过程，如下图所示：<br><a href="http://res.cloudinary.com/dod7hzkn2/image/upload/v1517463382/SparkRPC-Create-NettyRpcEnv_xdhorq.png" target="_blank" rel="noopener"><img src="http://res.cloudinary.com/dod7hzkn2/image/upload/v1517463382/SparkRPC-Create-NettyRpcEnv_xdhorq.png" alt=""></a></p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span>[rpc] <span class="class"><span class="keyword">class</span> <span class="title">NettyRpcEnvFactory</span> <span class="keyword">extends</span> <span class="title">RpcEnvFactory</span> <span class="keyword">with</span> <span class="title">Logging</span> </span>&#123;</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">create</span></span>(config: <span class="type">RpcEnvConfig</span>): <span class="type">RpcEnv</span> = &#123;</span><br><span class="line"> <span class="keyword">val</span> sparkConf = config.conf</span><br><span class="line"> <span class="comment">// Use JavaSerializerInstance in multiple threads is safe. However, if we plan to support</span></span><br><span class="line"> <span class="comment">// KryoSerializer in future, we have to use ThreadLocal to store SerializerInstance</span></span><br><span class="line"> <span class="keyword">val</span> javaSerializerInstance =</span><br><span class="line"> <span class="keyword">new</span> <span class="type">JavaSerializer</span>(sparkConf).newInstance().asInstanceOf[<span class="type">JavaSerializerInstance</span>]</span><br><span class="line"> <span class="keyword">val</span> nettyEnv =</span><br><span class="line"> <span class="keyword">new</span> <span class="type">NettyRpcEnv</span>(sparkConf, javaSerializerInstance, config.advertiseAddress,</span><br><span class="line"> config.securityManager)</span><br><span class="line"> <span class="keyword">if</span> (!config.clientMode) &#123;</span><br><span class="line"> <span class="keyword">val</span> startNettyRpcEnv: <span class="type">Int</span> =&gt; (<span class="type">NettyRpcEnv</span>, <span class="type">Int</span>) = &#123; actualPort =&gt;</span><br><span class="line"> nettyEnv.startServer(config.bindAddress, actualPort)</span><br><span class="line"> (nettyEnv, nettyEnv.address.port)</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="keyword">try</span> &#123;</span><br><span class="line"> <span class="type">Utils</span>.startServiceOnPort(config.port, startNettyRpcEnv, sparkConf, config.name)._1</span><br><span class="line"> &#125; <span class="keyword">catch</span> &#123;</span><br><span class="line"> <span class="keyword">case</span> <span class="type">NonFatal</span>(e) =&gt;</span><br><span class="line"> nettyEnv.shutdown()</span><br><span class="line"> <span class="keyword">throw</span> e</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br><span class="line"> nettyEnv</span><br><span class="line"> &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>具体要点，描述如下：</p><ul><li>创建一个NettyRpcEnv对象对象，需要通过NettyRpcEnvFactory来创建</li><li>Dispatcher负责RPC消息的路由，它能够将消息路由到对应的RpcEndpoint进行处理</li><li>NettyStreamManager负责提供文件服务（文件、JAR文件、目录）</li><li>NettyRpcHandler负责处理网络IO事件，接收RPC调用请求，并通过Dispatcher派发消息</li><li>TransportContext负责管理网路传输上下文信息：创建MessageEncoder、MessageDecoder、TransportClientFactory、TransportServer</li><li>TransportServer配置并启动一个RPC Server服务</li></ul><h2 id="消息路由过程分析"><a href="#消息路由过程分析" class="headerlink" title="消息路由过程分析"></a>消息路由过程分析</h2><p>基于Standalone模式，Spark集群具有Master和一组Worker，Worker与Master之间需要进行通信，我们以此为例，来说明基于Spark PRC层是如何实现消息的路由的。<br>首先看Master端实现，代码如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">startRpcEnvAndEndpoint</span></span>(</span><br><span class="line"> host: <span class="type">String</span>,</span><br><span class="line"> port: <span class="type">Int</span>,</span><br><span class="line"> webUiPort: <span class="type">Int</span>,</span><br><span class="line"> conf: <span class="type">SparkConf</span>): (<span class="type">RpcEnv</span>, <span class="type">Int</span>, <span class="type">Option</span>[<span class="type">Int</span>]) = &#123;</span><br><span class="line"> <span class="keyword">val</span> securityMgr = <span class="keyword">new</span> <span class="type">SecurityManager</span>(conf)</span><br><span class="line"> <span class="keyword">val</span> rpcEnv = <span class="type">RpcEnv</span>.create(<span class="type">SYSTEM_NAME</span>, host, port, conf, securityMgr)</span><br><span class="line"> <span class="keyword">val</span> masterEndpoint = rpcEnv.setupEndpoint(<span class="type">ENDPOINT_NAME</span>,</span><br><span class="line"> <span class="keyword">new</span> <span class="type">Master</span>(rpcEnv, rpcEnv.address, webUiPort, securityMgr, conf))</span><br><span class="line"> <span class="keyword">val</span> portsResponse = masterEndpoint.askSync[<span class="type">BoundPortsResponse</span>](<span class="type">BoundPortsRequest</span>)</span><br><span class="line"> (rpcEnv, portsResponse.webUIPort, portsResponse.restPort)</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>上面代码中，创建一个RpcEnv对象，通过创建一个NettyRpcEnvFactory对象来完成该RpcEnv对象的创建，实际创建了一个NettyRpcEnv对象。接着，通过setupEndpoint方法注册一个RpcEndpoint，这里Master就是一个RpcEndpoint，返回的masterEndpoint是Master的RpcEndpointRef引用对象。下面，我们看一下，发送一个BoundPortsRequest消息，具体的消息路由过程，如下图所示：<br><a href="http://res.cloudinary.com/dod7hzkn2/image/upload/v1517393595/Master_agnadl.png" target="_blank" rel="noopener"><img src="http://res.cloudinary.com/dod7hzkn2/image/upload/v1517393595/Master_agnadl.png" alt=""></a></p><p>上图中显示本地消息和远程消息派发的流程，最主要的区别是在接收消息时：接收消息走的是Inbox，发送消息走的是Outbox</p><h2 id="本地消息路由"><a href="#本地消息路由" class="headerlink" title="本地消息路由"></a>本地消息路由</h2><p>发送一个BoundPortsRequest消息，实际走的是本地消息路由，直接放到对应的Inbox中，对应的代码处理逻辑如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Posts a message to a specific endpoint.</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * @param endpointName name of the endpoint.</span></span><br><span class="line"><span class="comment"> * @param message the message to post</span></span><br><span class="line"><span class="comment"> * @param callbackIfStopped callback function if the endpoint is stopped.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">private</span> <span class="function"><span class="keyword">def</span> <span class="title">postMessage</span></span>(</span><br><span class="line"> endpointName: <span class="type">String</span>,</span><br><span class="line"> message: <span class="type">InboxMessage</span>,</span><br><span class="line"> callbackIfStopped: (<span class="type">Exception</span>) =&gt; <span class="type">Unit</span>): <span class="type">Unit</span> = &#123;</span><br><span class="line"> <span class="keyword">val</span> error = synchronized &#123;</span><br><span class="line"> <span class="keyword">val</span> data = endpoints.get(endpointName)</span><br><span class="line"> <span class="keyword">if</span> (stopped) &#123;</span><br><span class="line"> <span class="type">Some</span>(<span class="keyword">new</span> <span class="type">RpcEnvStoppedException</span>())</span><br><span class="line"> &#125; <span class="keyword">else</span> <span class="keyword">if</span> (data == <span class="literal">null</span>) &#123;</span><br><span class="line"> <span class="type">Some</span>(<span class="keyword">new</span> <span class="type">SparkException</span>(<span class="string">s"Could not find <span class="subst">$endpointName</span>."</span>))</span><br><span class="line"> &#125; <span class="keyword">else</span> &#123;</span><br><span class="line"> data.inbox.post(message)</span><br><span class="line"> receivers.offer(data)</span><br><span class="line"> <span class="type">None</span></span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="comment">// We don't need to call `onStop` in the `synchronized` block</span></span><br><span class="line"> error.foreach(callbackIfStopped)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>上面通过data.inbox派发消息，然后将消息data :EndpointData放入到receivers队列，触发Dispatcher内部的MessageLoop线程去消费，如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/** Message loop used for dispatching messages. */</span></span><br><span class="line"> <span class="keyword">private</span> <span class="class"><span class="keyword">class</span> <span class="title">MessageLoop</span> <span class="keyword">extends</span> <span class="title">Runnable</span> </span>&#123;</span><br><span class="line"> <span class="keyword">override</span> <span class="function"><span class="keyword">def</span> <span class="title">run</span></span>(): <span class="type">Unit</span> = &#123;</span><br><span class="line"> <span class="keyword">try</span> &#123;</span><br><span class="line"> <span class="keyword">while</span> (<span class="literal">true</span>) &#123;</span><br><span class="line"> <span class="keyword">try</span> &#123;</span><br><span class="line"> <span class="keyword">val</span> data = receivers.take()</span><br><span class="line"> <span class="keyword">if</span> (data == <span class="type">PoisonPill</span>) &#123;</span><br><span class="line"> <span class="comment">// Put PoisonPill back so that other MessageLoops can see it.</span></span><br><span class="line"> receivers.offer(<span class="type">PoisonPill</span>)</span><br><span class="line"> <span class="keyword">return</span></span><br><span class="line"> &#125;</span><br><span class="line"> data.inbox.process(<span class="type">Dispatcher</span>.<span class="keyword">this</span>)</span><br><span class="line"> &#125; <span class="keyword">catch</span> &#123;</span><br><span class="line"> <span class="keyword">case</span> <span class="type">NonFatal</span>(e) =&gt; logError(e.getMessage, e)</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br><span class="line"> &#125; <span class="keyword">catch</span> &#123;</span><br><span class="line"> <span class="keyword">case</span> ie: <span class="type">InterruptedException</span> =&gt; <span class="comment">// exit</span></span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>上面的过程在Dispatcher 内部线程池执行。内部线程池如下</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="keyword">val</span> threadpool: <span class="type">ThreadPoolExecutor</span> = &#123;</span><br><span class="line"> <span class="keyword">val</span> numThreads = nettyEnv.conf.getInt(<span class="string">"spark.rpc.netty.dispatcher.numThreads"</span>,</span><br><span class="line"> math.max(<span class="number">2</span>, <span class="type">Runtime</span>.getRuntime.availableProcessors()))</span><br><span class="line"> <span class="keyword">val</span> pool = <span class="type">ThreadUtils</span>.newDaemonFixedThreadPool(numThreads, <span class="string">"dispatcher-event-loop"</span>)</span><br><span class="line"> <span class="keyword">for</span> (i </span><br><span class="line"> pool.execute(<span class="keyword">new</span> <span class="type">MessageLoop</span>)</span><br><span class="line"> &#125;</span><br><span class="line"> pool</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>这里，又继续调用Inbox的process方法来派发消息到指定的RpcEndpoint。通过上面的序列图，我们可以通过源码分析看到，原始消息被层层封装为一个RpcMessage ，该消息在Inbox的process方法中处理派发逻辑，如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">case</span> <span class="type">RpcMessage</span>(_sender, content, context) =&gt;</span><br><span class="line"> <span class="keyword">try</span> &#123;</span><br><span class="line"> endpoint.receiveAndReply(context).applyOrElse[<span class="type">Any</span>, <span class="type">Unit</span>](content, &#123; msg =&gt;</span><br><span class="line"> <span class="keyword">throw</span> <span class="keyword">new</span> <span class="type">SparkException</span>(<span class="string">s"Unsupported message <span class="subst">$message</span> from <span class="subst">$&#123;_sender&#125;</span>"</span>)</span><br><span class="line"> &#125;)</span><br><span class="line"> &#125; <span class="keyword">catch</span> &#123;</span><br><span class="line"> <span class="keyword">case</span> <span class="type">NonFatal</span>(e) =&gt;</span><br><span class="line"> context.sendFailure(e)</span><br><span class="line"> <span class="comment">// Throw the exception -- this exception will be caught by the safelyCall function.</span></span><br><span class="line"> <span class="comment">// The endpoint's onError function will be called.</span></span><br><span class="line"> <span class="keyword">throw</span> e</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>到这里，消息已经发送给对应的RpcEndpoint的receiveAndReply方法，我们这里实际上是Master实现类，这里的消息解包后为content: BoundPortsRequest，接下来应该看Master的receiveAndReply方法如何处理该本地消息，代码如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">case</span> <span class="type">BoundPortsRequest</span> =&gt;</span><br><span class="line"> context.reply(<span class="type">BoundPortsResponse</span>(address.port, webUi.boundPort, restServerBoundPort))</span><br></pre></td></tr></table></figure><p>可以看出，实际上上面的处理逻辑没有什么处理，只是通过BoundPortsResponse返回了几个Master端的几个端口号数据。</p><h2 id="远程消息路由"><a href="#远程消息路由" class="headerlink" title="远程消息路由"></a>远程消息路由</h2><p>我们都知道，Worker启动时，会向Master注册，通过该场景我们分析一下远程消息路由的过程。<br>先看一下Worker端向Master注册过程，如下图所示：<br><a href="http://res.cloudinary.com/dod7hzkn2/image/upload/v1517457688/Worker.ask__xjou68.png" target="_blank" rel="noopener"><img src="http://res.cloudinary.com/dod7hzkn2/image/upload/v1517457688/Worker.ask__xjou68.png" alt=""></a></p><p>Worker启动时，会首先获取到一个Master的RpcEndpointRef远程引用，通过该引用对象能够与Master进行RPC通信，经过上面消息派发，最终通过Netty的Channel将消息发送到远程Master端。<br>通过前面说明，我们知道Worker向Master注册的消息RegisterWorker应该最终会被路由到Master对应的Inbox中，然后派发给Master进行处理。下面，我们看一下Master端接收并处理消息的过程，如下图所示：<br><a href="http://res.cloudinary.com/dod7hzkn2/image/upload/v1517457805/Master.receiveAndReply_vdzfwo.png" target="_blank" rel="noopener"><img src="http://res.cloudinary.com/dod7hzkn2/image/upload/v1517457805/Master.receiveAndReply_vdzfwo.png" alt=""></a></p><p>上图分为两部分：一部分是从远端接收消息RegisterWorker，将接收到的消息放入到Inbox中；另一部分是触发MessageLoop线程处理该消息，进而通过调用Inbox的process方法，继续调用RpcEndpoint（Master）的receiveAndReply方法，处理消息RegisterWorker，如下所示：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">case</span> <span class="type">RegisterWorker</span>(</span><br><span class="line"> id, workerHost, workerPort, workerRef, cores, memory, workerWebUiUrl, masterAddress) =&gt;</span><br><span class="line"> logInfo(<span class="string">"Registering worker %s:%d with %d cores, %s RAM"</span>.format(</span><br><span class="line"> workerHost, workerPort, cores, <span class="type">Utils</span>.megabytesToString(memory)))</span><br><span class="line"> <span class="keyword">if</span> (state == <span class="type">RecoveryState</span>.<span class="type">STANDBY</span>) &#123;</span><br><span class="line"> workerRef.send(<span class="type">MasterInStandby</span>)</span><br><span class="line"> &#125; <span class="keyword">else</span> <span class="keyword">if</span> (idToWorker.contains(id)) &#123;</span><br><span class="line"> workerRef.send(<span class="type">RegisterWorkerFailed</span>(<span class="string">"Duplicate worker ID"</span>))</span><br><span class="line"> &#125; <span class="keyword">else</span> &#123;</span><br><span class="line"> <span class="keyword">val</span> worker = <span class="keyword">new</span> <span class="type">WorkerInfo</span>(id, workerHost, workerPort, cores, memory,</span><br><span class="line"> workerRef, workerWebUiUrl)</span><br><span class="line"> <span class="keyword">if</span> (registerWorker(worker)) &#123;</span><br><span class="line"> persistenceEngine.addWorker(worker)</span><br><span class="line"> workerRef.send(<span class="type">RegisteredWorker</span>(self, masterWebUiUrl, masterAddress))</span><br><span class="line"> schedule()</span><br><span class="line"> &#125; <span class="keyword">else</span> &#123;</span><br><span class="line"> <span class="keyword">val</span> workerAddress = worker.endpoint.address</span><br><span class="line"> logWarning(<span class="string">"Worker registration failed. Attempted to re-register worker at same "</span> +</span><br><span class="line"> <span class="string">"address: "</span> + workerAddress)</span><br><span class="line"> workerRef.send(<span class="type">RegisterWorkerFailed</span>(<span class="string">"Attempted to re-register worker at same address: "</span></span><br><span class="line"> + workerAddress))</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>如果Worker注册成功，则Master会通过context对象回复Worker响应</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">context.reply(<span class="type">RegisteredWorker</span>(self, masterWebUiUrl))</span><br></pre></td></tr></table></figure><p>这样，如果一切正常，则Worker会收到RegisteredWorker响应消息，从而获取到Master的RpcEndpointRef引用对象，能够通过该引用对象与Master交互。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;Spark将RPC通信层设计的非常巧妙，融合了各种设计/架构模式，将一个分布式集群系统的通信层细节完全屏蔽，这样在上层的计算框架的设计中能够获得很好的灵活性。同时，如果上层想要增加各种新的特性，或者对来自不同企业或组织的程序员贡献的特性，也能够很容易地增加进来，可以避开复杂的通信层而将注意力集中在上层计算框架的处理和优化上，入手难度非常小。另外，对上层计算框架中的各个核心组件的开发、功能增强，以及Bug修复等都会变得更加容易&lt;br&gt;
    
    </summary>
    
      <category term="spark" scheme="http://crazycarry.github.io/categories/spark/"/>
    
    
      <category term="源码分析" scheme="http://crazycarry.github.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"/>
    
      <category term="大数据" scheme="http://crazycarry.github.io/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
    
      <category term="rpc" scheme="http://crazycarry.github.io/tags/rpc/"/>
    
  </entry>
  
  <entry>
    <title>Spark BlockManager分析</title>
    <link href="http://crazycarry.github.io/2018/04/04/spark-blockmanager/"/>
    <id>http://crazycarry.github.io/2018/04/04/spark-blockmanager/</id>
    <published>2018-04-04T07:59:48.000Z</published>
    <updated>2018-04-04T08:07:23.043Z</updated>
    
    <content type="html"><![CDATA[<p>BlockManager 是 spark 中至关重要的一个组件， 在 spark的的运行过程中到处都有 BlockManager 的身影， 只有搞清楚 BlockManager 的原理和机制，你才能更加深入的理解 spark。 今天我们来揭开 BlockaManager 的底层原理和设计思路。<br><a id="more"></a></p><h2 id="整体架构"><a href="#整体架构" class="headerlink" title="整体架构"></a>整体架构</h2><p>BlockManager 是一个嵌入在 spark 中的 key-value型分布式存储系统，是为 spark 量身打造的，BlockManager 在一个 spark 应用中作为一个本地缓存运行在所有的节点上， 包括所有 driver 和 executor上。 BlockManager 对本地和远程提供一致的 get 和set 数据块接口， BlockManager 本身使用不同的存储方式来存储这些数据， 包括 memory, disk, off-heap。<br><a href="http://7xim8y.com1.z0.glb.clouddn.com/QQ20170330-2.png?imageView2/1/w/600/h/400" target="_blank" rel="noopener"><img src="http://7xim8y.com1.z0.glb.clouddn.com/QQ20170330-2.png?imageView2/1/w/600/h/400" alt=""></a></p><p>上面是一个整体的架构图， BlockManagerMaster拥有BlockManagerMasterEndpoint 的actor和所有BlockManagerSlaveEndpoint的ref， 可以通过这些引用对 slave 下达命令</p><p>executor 节点上的BlockManagerMaster 则拥有BlockManagerMasterEndpoint的ref和自身BlockManagerSlaveEndpoint的actor。可以通过 Master的引用注册自己。</p><p>在master 和 slave 可以正常的通信之后， 就可以根据设计的交互协议进行交互， 整个分布式缓存系统也就运转起来了</p><h2 id="初始化"><a href="#初始化" class="headerlink" title="初始化"></a>初始化</h2><p>我们知道， sparkEnv 启动的时候会启动各个组件， BlockManager 也不例外， 也是这个时候启动的<br>sparkEnv调用create 方法启动。<br>Spark context启动时候会初始化SparkEnv</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">_env = createSparkEnv(_conf, isLocal, listenerBus)</span><br><span class="line"><span class="type">SparkEnv</span>.set(_env)</span><br></pre></td></tr></table></figure><p>调用如下方法createSparkEnv</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span>[spark] <span class="function"><span class="keyword">def</span> <span class="title">createSparkEnv</span></span>(</span><br><span class="line"> conf: <span class="type">SparkConf</span>,</span><br><span class="line"> isLocal: <span class="type">Boolean</span>,</span><br><span class="line"> listenerBus: <span class="type">LiveListenerBus</span>): <span class="type">SparkEnv</span> = &#123;</span><br><span class="line"> <span class="type">SparkEnv</span>.createDriverEnv(conf, isLocal, listenerBus, <span class="type">SparkContext</span>.numDriverCores(master))</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>执行SparkEnv.createDriverEnv</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span>[spark] <span class="function"><span class="keyword">def</span> <span class="title">createDriverEnv</span></span>(</span><br><span class="line"> conf: <span class="type">SparkConf</span>,</span><br><span class="line"> isLocal: <span class="type">Boolean</span>,</span><br><span class="line"> listenerBus: <span class="type">LiveListenerBus</span>,</span><br><span class="line"> numCores: <span class="type">Int</span>,</span><br><span class="line"> mockOutputCommitCoordinator: <span class="type">Option</span>[<span class="type">OutputCommitCoordinator</span>] = <span class="type">None</span>): <span class="type">SparkEnv</span> = &#123;</span><br><span class="line"> assert(conf.contains(<span class="type">DRIVER_HOST_ADDRESS</span>),</span><br><span class="line"> <span class="string">s"<span class="subst">$&#123;DRIVER_HOST_ADDRESS.key&#125;</span> is not set on the driver!"</span>)</span><br><span class="line"> assert(conf.contains(<span class="string">"spark.driver.port"</span>), <span class="string">"spark.driver.port is not set on the driver!"</span>)</span><br><span class="line"> <span class="keyword">val</span> bindAddress = conf.get(<span class="type">DRIVER_BIND_ADDRESS</span>)</span><br><span class="line"> <span class="keyword">val</span> advertiseAddress = conf.get(<span class="type">DRIVER_HOST_ADDRESS</span>)</span><br><span class="line"> <span class="keyword">val</span> port = conf.get(<span class="string">"spark.driver.port"</span>).toInt</span><br><span class="line"> <span class="keyword">val</span> ioEncryptionKey = <span class="keyword">if</span> (conf.get(<span class="type">IO_ENCRYPTION_ENABLED</span>)) &#123;</span><br><span class="line"> <span class="type">Some</span>(<span class="type">CryptoStreamUtils</span>.createKey(conf))</span><br><span class="line"> &#125; <span class="keyword">else</span> &#123;</span><br><span class="line"> <span class="type">None</span></span><br><span class="line"> &#125;</span><br><span class="line"> create(</span><br><span class="line"> conf,</span><br><span class="line"> <span class="type">SparkContext</span>.<span class="type">DRIVER_IDENTIFIER</span>,</span><br><span class="line"> bindAddress,</span><br><span class="line"> advertiseAddress,</span><br><span class="line"> port,</span><br><span class="line"> isLocal,</span><br><span class="line"> numCores,</span><br><span class="line"> ioEncryptionKey,</span><br><span class="line"> listenerBus = listenerBus,</span><br><span class="line"> mockOutputCommitCoordinator = mockOutputCommitCoordinator</span><br><span class="line"> )</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>最终执行create方法，create 方法中初始化blockManagerMaster,blockManager</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">val</span> blockManagerMaster = <span class="keyword">new</span> <span class="type">BlockManagerMaster</span>(registerOrLookupEndpoint(</span><br><span class="line"> <span class="type">BlockManagerMaster</span>.<span class="type">DRIVER_ENDPOINT_NAME</span>,</span><br><span class="line"> <span class="keyword">new</span> <span class="type">BlockManagerMasterEndpoint</span>(rpcEnv, isLocal, conf, listenerBus)),</span><br><span class="line"> conf, isDriver)</span><br><span class="line">  </span><br><span class="line"><span class="keyword">val</span> blockManager = <span class="keyword">new</span> <span class="type">BlockManager</span>(executorId, rpcEnv, blockManagerMaster,</span><br><span class="line"> serializerManager, conf, memoryManager, mapOutputTracker, shuffleManager,</span><br><span class="line"> blockTransferService, securityManager, numUsableCores)</span><br></pre></td></tr></table></figure><p>启动的时候会根据自己是在 driver 还是 executor 上进行不同的启动过程</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">registerOrLookupEndpoint</span></span>(</span><br><span class="line"> name: <span class="type">String</span>, endpointCreator: =&gt; <span class="type">RpcEndpoint</span>):</span><br><span class="line"> <span class="type">RpcEndpointRef</span> = &#123;</span><br><span class="line"> <span class="keyword">if</span> (isDriver) &#123;</span><br><span class="line"> logInfo(<span class="string">"Registering "</span> + name)</span><br><span class="line"> rpcEnv.setupEndpoint(name, endpointCreator)</span><br><span class="line"> &#125; <span class="keyword">else</span> &#123;</span><br><span class="line"> <span class="type">RpcUtils</span>.makeDriverRef(name, conf, rpcEnv)</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>sparkEnv 在 master上启动的时候， 构造了一个 BlockManagerMasterEndpoint, 然后把这个Endpoint 注册在 rpcEnv中， 同时也会启动自己的 BlockManager<br><a href="http://7xim8y.com1.z0.glb.clouddn.com/sparkenv-driver-blockmanager1.png?imageView2/1/w/600/h/500" target="_blank" rel="noopener"><img src="http://7xim8y.com1.z0.glb.clouddn.com/sparkenv-driver-blockmanager1.png?imageView2/1/w/600/h/500" alt=""></a></p><p>sparkEnv 在executor上启动的时候， 通过 setupEndpointRef 方法获取到了 BlockManagerMaster的引用 BlockManagerMasterRef， 同时也会启动自己的 BlockManager<br><a href="http://7xim8y.com1.z0.glb.clouddn.com/sparkenv-executor-blockmanager1.png?imageView2/1/w/600/h/500" target="_blank" rel="noopener"><img src="http://7xim8y.com1.z0.glb.clouddn.com/sparkenv-executor-blockmanager1.png?imageView2/1/w/600/h/500" alt=""></a></p><p>在 BlockManager 初始化自己的时候， 会向 BlockManagerMasterEndpoint 注册自己， BlockManagerMasterEndpoint 发送 registerBlockManager消息， BlockManagerMasterEndpoint 接受到消息， 把 BlockManagerSlaveEndpoint 的引用 保存在自己的 blockManagerInfo 数据结构中以待后用</p><p>在SparkContext初始化时候调用</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">_env.blockManager.initialize(_applicationId)</span><br></pre></td></tr></table></figure><p>方法如下：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Initializes the BlockManager with the given appId. This is not performed in the constructor as</span></span><br><span class="line"><span class="comment"> * the appId may not be known at BlockManager instantiation time (in particular for the driver,</span></span><br><span class="line"><span class="comment"> * where it is only learned after registration with the TaskScheduler).</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * This method initializes the BlockTransferService and ShuffleClient, registers with the</span></span><br><span class="line"><span class="comment"> * BlockManagerMaster, starts the BlockManagerWorker endpoint, and registers with a local shuffle</span></span><br><span class="line"><span class="comment"> * service if configured.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">initialize</span></span>(appId: <span class="type">String</span>): <span class="type">Unit</span> = &#123;</span><br><span class="line"> blockTransferService.init(<span class="keyword">this</span>)</span><br><span class="line"> shuffleClient.init(appId)</span><br><span class="line"></span><br><span class="line"> blockReplicationPolicy = &#123;</span><br><span class="line"> <span class="keyword">val</span> priorityClass = conf.get(</span><br><span class="line"> <span class="string">"spark.storage.replication.policy"</span>, classOf[<span class="type">RandomBlockReplicationPolicy</span>].getName)</span><br><span class="line"> <span class="keyword">val</span> clazz = <span class="type">Utils</span>.classForName(priorityClass)</span><br><span class="line"> <span class="keyword">val</span> ret = clazz.newInstance.asInstanceOf[<span class="type">BlockReplicationPolicy</span>]</span><br><span class="line"> logInfo(<span class="string">s"Using <span class="subst">$priorityClass</span> for block replication policy"</span>)</span><br><span class="line"> ret</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">val</span> id =</span><br><span class="line"> <span class="type">BlockManagerId</span>(executorId, blockTransferService.hostName, blockTransferService.port, <span class="type">None</span>)</span><br><span class="line"></span><br><span class="line"> <span class="keyword">val</span> idFromMaster = master.registerBlockManager(</span><br><span class="line"> id,</span><br><span class="line"> maxOnHeapMemory,</span><br><span class="line"> maxOffHeapMemory,</span><br><span class="line"> slaveEndpoint)</span><br><span class="line"></span><br><span class="line"> blockManagerId = <span class="keyword">if</span> (idFromMaster != <span class="literal">null</span>) idFromMaster <span class="keyword">else</span> id</span><br><span class="line"></span><br><span class="line"> shuffleServerId = <span class="keyword">if</span> (externalShuffleServiceEnabled) &#123;</span><br><span class="line"> logInfo(<span class="string">s"external shuffle service port = <span class="subst">$externalShuffleServicePort</span>"</span>)</span><br><span class="line"> <span class="type">BlockManagerId</span>(executorId, blockTransferService.hostName, externalShuffleServicePort)</span><br><span class="line"> &#125; <span class="keyword">else</span> &#123;</span><br><span class="line"> blockManagerId</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> <span class="comment">// Register Executors' configuration with the local shuffle service, if one should exist.</span></span><br><span class="line"> <span class="keyword">if</span> (externalShuffleServiceEnabled &amp;&amp; !blockManagerId.isDriver) &#123;</span><br><span class="line"> registerWithExternalShuffleServer()</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> logInfo(<span class="string">s"Initialized BlockManager: <span class="subst">$blockManagerId</span>"</span>)</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>调用master 如下方法</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">val</span> idFromMaster = master.registerBlockManager(</span><br><span class="line"> id,</span><br><span class="line"> maxOnHeapMemory,</span><br><span class="line"> maxOffHeapMemory,</span><br><span class="line"> slaveEndpoint)</span><br></pre></td></tr></table></figure><p>继续往下：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Register the BlockManager's id with the driver. The input BlockManagerId does not contain</span></span><br><span class="line"><span class="comment"> * topology information. This information is obtained from the master and we respond with an</span></span><br><span class="line"><span class="comment"> * updated BlockManagerId fleshed out with this information.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="function"><span class="keyword">def</span> <span class="title">registerBlockManager</span></span>(</span><br><span class="line"> blockManagerId: <span class="type">BlockManagerId</span>,</span><br><span class="line"> maxOnHeapMemSize: <span class="type">Long</span>,</span><br><span class="line"> maxOffHeapMemSize: <span class="type">Long</span>,</span><br><span class="line"> slaveEndpoint: <span class="type">RpcEndpointRef</span>): <span class="type">BlockManagerId</span> = &#123;</span><br><span class="line"> logInfo(<span class="string">s"Registering BlockManager <span class="subst">$blockManagerId</span>"</span>)</span><br><span class="line"> <span class="keyword">val</span> updatedId = driverEndpoint.askSync[<span class="type">BlockManagerId</span>](</span><br><span class="line"> <span class="type">RegisterBlockManager</span>(blockManagerId, maxOnHeapMemSize, maxOffHeapMemSize, slaveEndpoint))</span><br><span class="line"> logInfo(<span class="string">s"Registered BlockManager <span class="subst">$updatedId</span>"</span>)</span><br><span class="line"> updatedId</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><p>BlockManagerMasterEndpoint接受到消息 RegisterBlockManager</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">case</span> <span class="type">RegisterBlockManager</span>(blockManagerId, maxOnHeapMemSize, maxOffHeapMemSize, slaveEndpoint) =&gt;</span><br><span class="line"> context.reply(register(blockManagerId, maxOnHeapMemSize, maxOffHeapMemSize, slaveEndpoint))</span><br></pre></td></tr></table></figure><p>register 方法把 BlockManagerSlaveEndpoint 的引用 保存在自己的 blockManagerInfo 数据结构</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Returns the BlockManagerId with topology information populated, if available.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"> <span class="keyword">private</span> <span class="function"><span class="keyword">def</span> <span class="title">register</span></span>(</span><br><span class="line"> idWithoutTopologyInfo: <span class="type">BlockManagerId</span>,</span><br><span class="line"> maxOnHeapMemSize: <span class="type">Long</span>,</span><br><span class="line"> maxOffHeapMemSize: <span class="type">Long</span>,</span><br><span class="line"> slaveEndpoint: <span class="type">RpcEndpointRef</span>): <span class="type">BlockManagerId</span> = &#123;</span><br><span class="line"> <span class="comment">// the dummy id is not expected to contain the topology information.</span></span><br><span class="line"> <span class="comment">// we get that info here and respond back with a more fleshed out block manager id</span></span><br><span class="line"> <span class="keyword">val</span> id = <span class="type">BlockManagerId</span>(</span><br><span class="line"> idWithoutTopologyInfo.executorId,</span><br><span class="line"> idWithoutTopologyInfo.host,</span><br><span class="line"> idWithoutTopologyInfo.port,</span><br><span class="line"> topologyMapper.getTopologyForHost(idWithoutTopologyInfo.host))</span><br><span class="line"></span><br><span class="line"> <span class="keyword">val</span> time = <span class="type">System</span>.currentTimeMillis()</span><br><span class="line"> <span class="keyword">if</span> (!blockManagerInfo.contains(id)) &#123;</span><br><span class="line"> blockManagerIdByExecutor.get(id.executorId) <span class="keyword">match</span> &#123;</span><br><span class="line"> <span class="keyword">case</span> <span class="type">Some</span>(oldId) =&gt;</span><br><span class="line"> <span class="comment">// A block manager of the same executor already exists, so remove it (assumed dead)</span></span><br><span class="line"> logError(<span class="string">"Got two different block manager registrations on same executor - "</span></span><br><span class="line"> + <span class="string">s" will replace old one <span class="subst">$oldId</span> with new one <span class="subst">$id</span>"</span>)</span><br><span class="line"> removeExecutor(id.executorId)</span><br><span class="line"> <span class="keyword">case</span> <span class="type">None</span> =&gt;</span><br><span class="line"> &#125;</span><br><span class="line"> logInfo(<span class="string">"Registering block manager %s with %s RAM, %s"</span>.format(</span><br><span class="line"> id.hostPort, <span class="type">Utils</span>.bytesToString(maxOnHeapMemSize + maxOffHeapMemSize), id))</span><br><span class="line"></span><br><span class="line"> blockManagerIdByExecutor(id.executorId) = id</span><br><span class="line"></span><br><span class="line"> blockManagerInfo(id) = <span class="keyword">new</span> <span class="type">BlockManagerInfo</span>(</span><br><span class="line"> id, <span class="type">System</span>.currentTimeMillis(), maxOnHeapMemSize, maxOffHeapMemSize, slaveEndpoint)</span><br><span class="line"> &#125;</span><br><span class="line"> listenerBus.post(<span class="type">SparkListenerBlockManagerAdded</span>(time, id, maxOnHeapMemSize + maxOffHeapMemSize,</span><br><span class="line"> <span class="type">Some</span>(maxOnHeapMemSize), <span class="type">Some</span>(maxOffHeapMemSize)))</span><br><span class="line"> id</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><h2 id="分布式协议"><a href="#分布式协议" class="headerlink" title="分布式协议"></a>分布式协议</h2><p>下面的一个表格是 master 和 slave 接受到各种类型的消息， 以及接受到消息后，做的处理。</p><ul><li>BlockManagerMasterEndpoint 接受的消息</li></ul><table><thead><tr><th style="text-align:left">消息</th><th style="text-align:right">处理</th></tr></thead><tbody><tr><td style="text-align:left">RegisterBlockManager</td><td style="text-align:right">slave 注册自己的消息，会保存在自己的blockManagerInfo中</td></tr><tr><td style="text-align:left">UpdateBlockInfo</td><td style="text-align:right">一个Block的更新消息，BlockId作为一个Block的唯一标识，会保存Block所在的节点和位置关系，以及block 存储级别，大小 占用内存和磁盘大小</td></tr><tr><td style="text-align:left">GetLocationsMultipleBlockIds</td><td style="text-align:right">获取多个Block所在 的位置，位置中会反映Block位于哪个 executor, host 和端口</td></tr><tr><td style="text-align:left">GetPeers</td><td style="text-align:right">一个block有可能在多个节点上存在，返回一个节点列表</td></tr><tr><td style="text-align:left">GetExecutorEndpointRef</td><td style="text-align:right">根据BlockId,获取所在executorEndpointRef 也就是 BlockManagerSlaveEndpoint的引用</td></tr><tr><td style="text-align:left">GetMemoryStatus</td><td style="text-align:right">获取所有节点上的BlockManager的最大内存和剩余内存</td></tr><tr><td style="text-align:left">GetStorageStatus</td><td style="text-align:right">获取所有节点上的BlockManager的最大磁盘空间和剩余磁盘空间</td></tr><tr><td style="text-align:left">GetBlockStatus</td><td style="text-align:right">获取一个Block的状态信息，位置，占用内存和磁盘大小</td></tr><tr><td style="text-align:left">GetMatchingBlockIds</td><td style="text-align:right">获取一个Block的存储级别和所占内存和磁盘大小</td></tr><tr><td style="text-align:left">RemoveRdd</td><td style="text-align:right">删除Rdd对应的Block数据</td></tr><tr><td style="text-align:left">RemoveBroadcast</td><td style="text-align:right">删除Broadcast对应的Block数据</td></tr><tr><td style="text-align:left">RemoveBlock</td><td style="text-align:right">删除一个Block数据，会找到数据所在的slave,然后向slave发送一个删除消息</td></tr><tr><td style="text-align:left">RemoveExecutor</td><td style="text-align:right">从BlockManagerInfo中删除一个BlockManager, 并且删除这个 BlockManager上的所有的Blocks</td></tr><tr><td style="text-align:left">BlockManagerHeartbeat</td><td style="text-align:right">slave 发送心跳给 master , 证明自己还活着</td></tr></tbody></table><ul><li>BlockManagerSlaveEndpoint 接受的消息</li></ul><table><thead><tr><th style="text-align:left">消息</th><th style="text-align:right">处理</th></tr></thead><tbody><tr><td style="text-align:left">RemoveBlock</td><td style="text-align:right">slave删除自己BlockManager上的一个Block</td></tr><tr><td style="text-align:left">RemoveRdd</td><td style="text-align:right">删除Rdd对应的Block数据</td></tr><tr><td style="text-align:left">RemoveShuffle</td><td style="text-align:right">删除 shuffleId对应的BlockId的Block</td></tr><tr><td style="text-align:left">RemoveBroadcast</td><td style="text-align:right">删除 BroadcastId对应的BlockId的Block</td></tr><tr><td style="text-align:left">GetBlockStatus</td><td style="text-align:right">获取一个Block的存储级别和所占内存和磁盘大小</td></tr></tbody></table><p>根据以上的协议， 相信我们可以很清楚的猜测整个交互的流程， 一般过程应该是这样的， slave的 BlockManager 在自己接的上存储一个 Block, 然后把这个 BlockId 汇报到master的BlockManager , 经过 cache, shuffle 或者 Broadcast后，别的节点需要上一步的Block的时候， 会到 master 获取数据所在位置， 然后去相应节点上去 fetch</p><h2 id="存储层"><a href="#存储层" class="headerlink" title="存储层"></a>存储层</h2><p>在RDD层面上我们了解到RDD是由不同的partition组成的，我们所进行的transformation和action是在partition上面进行的；而在storage模块内部，RDD又被视为由不同的block组成，对于RDD的存取是以block为单位进行的，本质上partition和block是等价的，只是看待的角度不同。在Spark storage模块中中存取数据的最小单位是block，所有的操作都是以block为单位进行的。<br><a href="http://7xim8y.com1.z0.glb.clouddn.com/storage_layer.png?imageView2/1/w/600/h/500" target="_blank" rel="noopener"><img src="http://7xim8y.com1.z0.glb.clouddn.com/storage_layer.png?imageView2/1/w/600/h/500" alt=""></a></p><p>BlockManager对象被创建的时候会创建出MemoryStore和DiskStore对象用以存取block，如果内存中拥有足够的内存， 就 使用 MemoryStore存储， 如果 不够， 就 spill 到 磁盘中， 通过 DiskStore进行存储。</p><ul><li><p>DiskStore 有一个DiskBlockManager,DiskBlockManager 主要用来创建并持有逻辑 blocks 与磁盘上的 blocks之间的映射，一个逻辑 block 通过 BlockId 映射到一个磁盘上的文件。 在 DiskStore 中会调用 diskManager.getFile 方法， 如果子文件夹不存在，会进行创建， 文件夹的命名方式为(spark-local-yyyyMMddHHmmss-xxxx, xxxx是一个随机数)， 所有的block都会存储在所创建的folder里面。</p></li><li><p>MemoryStore 相对于DiskStore需要根据block id hash计算出文件路径并将block存放到对应的文件里面，MemoryStore管理block就显得非常简单：MemoryStore内部维护了一个hash map来管理所有的block，以block id为key将block存放到hash map中。而从MemoryStore中取得block则非常简单，只需从hash map中取出block id对应的value即可。</p></li></ul><p>BlockManager 的 PUT 和GET接口</p><ul><li><p>GET操作 如果 local 中存在就直接返回， 从本地获取一个Block, 会先判断如果是 useMemory， 直接从内存中取出， 如果是 useDisk， 会从磁盘中取出返回， 然后根据useMemory判断是否在内存中缓存一下，方便下次获取， 如果local 不存在， 从其他节点上获取， 当然元信息是存在 drive上的，要根据我们上文中提到的 GETlocation 协议获取 Block 所在节点位置， 然后到其他节点上获取。</p></li><li><p>PUT操作 操作之前会加锁来避免多线程的问题， 存储的时候会根据 存储级别， 调用对应的是 memoryStore 还是 diskStore， 然后在具体存储器上面调用 存储接口。 如果有 replication 需求， 会把数据备份到其他的机器上面</p></li></ul>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;BlockManager 是 spark 中至关重要的一个组件， 在 spark的的运行过程中到处都有 BlockManager 的身影， 只有搞清楚 BlockManager 的原理和机制，你才能更加深入的理解 spark。 今天我们来揭开 BlockaManager 的底层原理和设计思路。&lt;br&gt;
    
    </summary>
    
      <category term="spark" scheme="http://crazycarry.github.io/categories/spark/"/>
    
    
      <category term="源码分析" scheme="http://crazycarry.github.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"/>
    
      <category term="大数据" scheme="http://crazycarry.github.io/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
    
      <category term="性能优化" scheme="http://crazycarry.github.io/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"/>
    
      <category term="内存管理" scheme="http://crazycarry.github.io/tags/%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86/"/>
    
  </entry>
  
  <entry>
    <title>Spark内存管理</title>
    <link href="http://crazycarry.github.io/2018/04/04/spark-memory-manager/"/>
    <id>http://crazycarry.github.io/2018/04/04/spark-memory-manager/</id>
    <published>2018-04-04T07:27:58.000Z</published>
    <updated>2018-04-04T07:57:49.687Z</updated>
    
    <content type="html"><![CDATA[<p>Spark 作为一个基于内存的分布式计算引擎，其内存管理模块在整个系统中扮演着非常重要的角色。理解 Spark 内存管理的基本原理，有助于更好地开发 Spark 应用程序和进行性能调优。本文旨在梳理出 Spark 内存管理的脉络，抛砖引玉，引出读者对这个话题的深入探讨。本文中阐述的原理基于 Spark 2.1 版本，阅读本文需要读者有一定的 Spark 和 Java 基础，了解 RDD、Shuffle、JVM 等相关概念。<br><a id="more"></a></p><h1 id="Spark内存管理详解——内存分配"><a href="#Spark内存管理详解——内存分配" class="headerlink" title="Spark内存管理详解——内存分配"></a>Spark内存管理详解——内存分配</h1><p>在执行 Spark 的应用程序时，Spark 集群会启动 Driver 和 Executor 两种 JVM 进程，前者为主控进程，负责创建 Spark 上下文，提交 Spark 作业（Job），并将作业转化为计算任务（Task），在各个 Executor 进程间协调任务的调度，后者负责在工作节点上执行具体的计算任务，并将结果返回给 Driver，同时为需要持久化的 RDD 提供存储功能[1]。由于 Driver 的内存管理相对来说较为简单，本文主要对 Executor 的内存管理进行分析，下文中的 Spark 内存均特指 Executor 的内存。</p><h2 id="1-堆内和堆外内存规划"><a href="#1-堆内和堆外内存规划" class="headerlink" title="1. 堆内和堆外内存规划"></a>1. 堆内和堆外内存规划</h2><p>作为一个 JVM 进程，Executor 的内存管理建立在 JVM 的内存管理之上，Spark 对 JVM 的堆内（On-heap）空间进行了更为详细的分配，以充分利用内存。同时，Spark 引入了堆外（Off-heap）内存，使之可以直接在工作节点的系统内存中开辟空间，进一步优化了内存的使用。</p><h5 id="图-1-堆内和堆外内存示意图"><a href="#图-1-堆内和堆外内存示意图" class="headerlink" title="图 1 . 堆内和堆外内存示意图"></a><a href="https://crazycarry.github.io/2018/02/05/spark-memorymanager-01/#%E5%9B%BE-1-%E5%A0%86%E5%86%85%E5%92%8C%E5%A0%86%E5%A4%96%E5%86%85%E5%AD%98%E7%A4%BA%E6%84%8F%E5%9B%BE" title="图 1 . 堆内和堆外内存示意图"></a>图 1 . 堆内和堆外内存示意图</h5><p><a href="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image001.png" target="_blank" rel="noopener"><img src="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image001.png" alt=""></a></p><h3 id="1-1-堆内内存"><a href="#1-1-堆内内存" class="headerlink" title="1.1 堆内内存"></a>1.1 堆内内存</h3><p>堆内内存的大小，由 Spark 应用程序启动时的 –executor-memory 或 spark.executor.memory 参数配置。Executor 内运行的并发任务共享 JVM 堆内内存，这些任务在缓存 RDD 数据和广播（Broadcast）数据时占用的内存被规划为存储（Storage）内存，而这些任务在执行 Shuffle 时占用的内存被规划为执行（Execution）内存，剩余的部分不做特殊规划，那些 Spark 内部的对象实例，或者用户定义的 Spark 应用程序中的对象实例，均占用剩余的空间。不同的管理模式下，这三部分占用的空间大小各不相同（下面第 2 小节会进行介绍）。</p><p>Spark 对堆内内存的管理是一种逻辑上的”规划式”的管理，因为对象实例占用内存的申请和释放都由 JVM 完成，Spark 只能在申请后和释放前<strong>记录</strong>这些内存，我们来看其具体流程：</p><ul><li><strong>申请内存</strong>：</li></ul><ol><li>Spark 在代码中 new 一个对象实例</li><li>JVM 从堆内内存分配空间，创建对象并返回对象引用</li><li>Spark 保存该对象的引用，记录该对象占用的内存</li></ol><ul><li><strong>释放内存</strong>：</li></ul><ol><li>Spark 记录该对象释放的内存，删除该对象的引用</li><li>等待 JVM 的垃圾回收机制释放该对象占用的堆内内存</li></ol><p>我们知道，JVM 的对象可以以序列化的方式存储，序列化的过程是将对象转换为二进制字节流，本质上可以理解为将非连续空间的链式存储转化为连续空间或块存储，在访问时则需要进行序列化的逆过程——反序列化，将字节流转化为对象，序列化的方式可以节省存储空间，但增加了存储和读取时候的计算开销。</p><p>对于 Spark 中序列化的对象，由于是字节流的形式，其占用的内存大小可直接计算，而对于非序列化的对象，其占用的内存是通过周期性地采样近似估算而得，即并不是每次新增的数据项都会计算一次占用的内存大小，这种方法降低了时间开销但是有可能误差较大，导致某一时刻的实际内存有可能远远超出预期[2]。此外，在被 Spark 标记为释放的对象实例，很有可能在实际上并没有被 JVM 回收，导致实际可用的内存小于 Spark 记录的可用内存。所以 Spark 并不能准确记录实际可用的堆内内存，从而也就无法完全避免内存溢出（OOM, Out of Memory）的异常。</p><p>虽然不能精准控制堆内内存的申请和释放，但 Spark 通过对存储内存和执行内存各自独立的规划管理，可以决定是否要在存储内存里缓存新的 RDD，以及是否为新的任务分配执行内存，在一定程度上可以提升内存的利用率，减少异常的出现。</p><h3 id="1-2-堆外内存"><a href="#1-2-堆外内存" class="headerlink" title="1.2 堆外内存"></a>1.2 堆外内存</h3><p>为了进一步优化内存的使用以及提高 Shuffle 时排序的效率，Spark 引入了堆外（Off-heap）内存，使之可以直接在工作节点的系统内存中开辟空间，存储经过序列化的二进制数据。利用 JDK Unsafe API（从 Spark 2.0 开始，在管理堆外的存储内存时不再基于 Tachyon，而是与堆外的执行内存一样，基于 JDK Unsafe API 实现[3]），Spark 可以直接操作系统堆外内存，减少了不必要的内存开销，以及频繁的 GC 扫描和回收，提升了处理性能。堆外内存可以被精确地申请和释放，而且序列化的数据占用的空间可以被精确计算，所以相比堆内内存来说降低了管理的难度，也降低了误差。</p><p>在默认情况下堆外内存并不启用，可通过配置 spark.memory.offHeap.enabled 参数启用，并由 spark.memory.offHeap.size 参数设定堆外空间的大小。除了没有 other 空间，堆外内存与堆内内存的划分方式相同，所有运行中的并发任务共享存储内存和执行内存。</p><h3 id="1-3-内存管理接口"><a href="#1-3-内存管理接口" class="headerlink" title="1.3 内存管理接口"></a>1.3 内存管理接口</h3><p>Spark 为存储内存和执行内存的管理提供了统一的接口——MemoryManager，同一个 Executor 内的任务都调用这个接口的方法来申请或释放内存:</p><h4 id="清单-1-内存管理接口的主要方法"><a href="#清单-1-内存管理接口的主要方法" class="headerlink" title="清单 1 . 内存管理接口的主要方法"></a>清单 1 . 内存管理接口的主要方法</h4><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">//申请存储内存</span></span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">acquireStorageMemory</span></span>(blockId: <span class="type">BlockId</span>, numBytes: <span class="type">Long</span>, memoryMode: <span class="type">MemoryMode</span>): <span class="type">Boolean</span></span><br><span class="line"><span class="comment">//申请展开内存</span></span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">acquireUnrollMemory</span></span>(blockId: <span class="type">BlockId</span>, numBytes: <span class="type">Long</span>, memoryMode: <span class="type">MemoryMode</span>): <span class="type">Boolean</span></span><br><span class="line"><span class="comment">//申请执行内存</span></span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">acquireExecutionMemory</span></span>(numBytes: <span class="type">Long</span>, taskAttemptId: <span class="type">Long</span>, memoryMode: <span class="type">MemoryMode</span>): <span class="type">Long</span></span><br><span class="line"><span class="comment">//释放存储内存</span></span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">releaseStorageMemory</span></span>(numBytes: <span class="type">Long</span>, memoryMode: <span class="type">MemoryMode</span>): <span class="type">Unit</span></span><br><span class="line"><span class="comment">//释放执行内存</span></span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">releaseExecutionMemory</span></span>(numBytes: <span class="type">Long</span>, taskAttemptId: <span class="type">Long</span>, memoryMode: <span class="type">MemoryMode</span>): <span class="type">Unit</span></span><br><span class="line"><span class="comment">//释放展开内存</span></span><br><span class="line"><span class="function"><span class="keyword">def</span> <span class="title">releaseUnrollMemory</span></span>(numBytes: <span class="type">Long</span>, memoryMode: <span class="type">MemoryMode</span>): <span class="type">Unit</span></span><br></pre></td></tr></table></figure><p>我们看到，在调用这些方法时都需要指定其内存模式（MemoryMode），这个参数决定了是在堆内还是堆外完成这次操作。</p><p>MemoryManager 的具体实现上，Spark 1.6 之后默认为统一管理（<a href="https://github.com/apache/spark/blob/v2.1.0/core/src/main/scala/org/apache/spark/memory/UnifiedMemoryManager.scala" target="_blank" rel="noopener">Unified Memory Manager</a>）方式，1.6 之前采用的静态管理（<a href="https://github.com/apache/spark/blob/v2.1.0/core/src/main/scala/org/apache/spark/memory/StaticMemoryManager.scala" target="_blank" rel="noopener">Static Memory Manager</a>）方式仍被保留，可通过配置 spark.memory.useLegacyMode 参数启用。两种方式的区别在于对空间分配的方式，下面的第 2 小节会分别对这两种方式进行介绍。</p><h2 id="2-内存空间分配"><a href="#2-内存空间分配" class="headerlink" title="2 . 内存空间分配"></a>2 . 内存空间分配</h2><h3 id="2-1-静态内存管理"><a href="#2-1-静态内存管理" class="headerlink" title="2.1 静态内存管理"></a>2.1 静态内存管理</h3><p>在 Spark 最初采用的静态内存管理机制下，存储内存、执行内存和其他内存的大小在 Spark 应用程序运行期间均为固定的，但用户可以应用程序启动前进行配置，堆内内存的分配如图 2 所示：</p><h5 id="图-2-静态内存管理图示——堆内"><a href="#图-2-静态内存管理图示——堆内" class="headerlink" title="图 2 . 静态内存管理图示——堆内"></a>图 2 . 静态内存管理图示——堆内</h5><p><a href="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image002.png" target="_blank" rel="noopener"><img src="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image002.png" alt=""></a></p><p>可以看到，可用的堆内内存的大小需要按照下面的方式计算：</p><h4 id="清单-2-可用堆内内存空间"><a href="#清单-2-可用堆内内存空间" class="headerlink" title="清单 2 . 可用堆内内存空间"></a>清单 2 . 可用堆内内存空间</h4><figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">可用的存储内存 = systemMaxMemory * spark.storage.memoryFraction * spark.storage.safetyFraction</span><br><span class="line">可用的执行内存 = systemMaxMemory * spark.shuffle.memoryFraction * spark.shuffle.safetyFraction</span><br></pre></td></tr></table></figure><p>其中 systemMaxMemory 取决于当前 JVM 堆内内存的大小，最后可用的执行内存或者存储内存要在此基础上与各自的 memoryFraction 参数和 safetyFraction 参数相乘得出。上述计算公式中的两个 safetyFraction 参数，其意义在于在逻辑上预留出 1-safetyFraction 这么一块保险区域，降低因实际内存超出当前预设范围而导致 OOM 的风险（上文提到，对于非序列化对象的内存采样估算会产生误差）。值得注意的是，这个预留的保险区域仅仅是一种逻辑上的规划，在具体使用时 Spark 并没有区别对待，和”其它内存”一样交给了 JVM 去管理。</p><p>堆外的空间分配较为简单，只有存储内存和执行内存，如图 3 所示。可用的执行内存和存储内存占用的空间大小直接由参数 spark.memory.storageFraction 决定，由于堆外内存占用的空间可以被精确计算，所以无需再设定保险区域。</p><h5 id="图-3-静态内存管理图示——堆外"><a href="#图-3-静态内存管理图示——堆外" class="headerlink" title="图 3 . 静态内存管理图示——堆外"></a>图 3 . 静态内存管理图示——堆外</h5><p><a href="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image003.png" target="_blank" rel="noopener"><img src="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image003.png" alt=""></a></p><p>静态内存管理机制实现起来较为简单，但如果用户不熟悉 Spark 的存储机制，或没有根据具体的数据规模和计算任务或做相应的配置，很容易造成”一半海水，一半火焰”的局面，即存储内存和执行内存中的一方剩余大量的空间，而另一方却早早被占满，不得不淘汰或移出旧的内容以存储新的内容。由于新的内存管理机制的出现，这种方式目前已经很少有开发者使用，出于兼容旧版本的应用程序的目的，Spark 仍然保留了它的实现。</p><h3 id="2-2-统一内存管理"><a href="#2-2-统一内存管理" class="headerlink" title="2.2 统一内存管理"></a>2.2 统一内存管理</h3><p>Spark 1.6 之后引入的统一内存管理机制，与静态内存管理的区别在于存储内存和执行内存共享同一块空间，可以动态占用对方的空闲区域，如图 4 和图 5 所示</p><h5 id="图-4-统一内存管理图示——堆内"><a href="#图-4-统一内存管理图示——堆内" class="headerlink" title="图 4 . 统一内存管理图示——堆内"></a>图 4 . 统一内存管理图示——堆内</h5><p><a href="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image004.png" target="_blank" rel="noopener"><img src="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image004.png" alt=""></a></p><h5 id="图-5-统一内存管理图示——堆外"><a href="#图-5-统一内存管理图示——堆外" class="headerlink" title="图 5 . 统一内存管理图示——堆外"></a>图 5 . 统一内存管理图示——堆外</h5><p><a href="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image005.png" target="_blank" rel="noopener"><img src="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image005.png" alt=""></a></p><p>其中最重要的优化在于动态占用机制，其规则如下：</p><ul><li>设定基本的存储内存和执行内存区域（spark.storage.storageFraction 参数），该设定确定了双方各自拥有的空间的范围</li><li>双方的空间都不足时，则存储到硬盘；若己方空间不足而对方空余时，可借用对方的空间;（存储空间不足是指不足以放下一个完整的 Block）</li><li>执行内存的空间被对方占用后，可让对方将占用的部分转存到硬盘，然后”归还”借用的空间</li><li>存储内存的空间被对方占用后，无法让对方”归还”，因为需要考虑 Shuffle 过程中的很多因素，实现起来较为复杂[4]</li></ul><h5 id="图-6-动态占用机制图示"><a href="#图-6-动态占用机制图示" class="headerlink" title="图 6 . 动态占用机制图示"></a>图 6 . 动态占用机制图示</h5><p><a href="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image006.png" target="_blank" rel="noopener"><img src="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image006.png" alt=""></a></p><p>凭借统一内存管理机制，Spark 在一定程度上提高了堆内和堆外内存资源的利用率，降低了开发者维护 Spark 内存的难度，但并不意味着开发者可以高枕无忧。譬如，所以如果存储内存的空间太大或者说缓存的数据过多，反而会导致频繁的全量垃圾回收，降低任务执行时的性能，因为缓存的 RDD 数据通常都是长期驻留内存的 [5] 。所以要想充分发挥 Spark 的性能，需要开发者进一步了解存储内存和执行内存各自的管理方式和实现原理。</p><h2 id="3-存储内存管理"><a href="#3-存储内存管理" class="headerlink" title="3. 存储内存管理"></a>3. 存储内存管理</h2><h3 id="3-1-RDD-的持久化机制"><a href="#3-1-RDD-的持久化机制" class="headerlink" title="3.1 RDD 的持久化机制"></a><a href="https://crazycarry.github.io/2018/02/05/spark-memorymanager-01/#3-1-RDD-%E7%9A%84%E6%8C%81%E4%B9%85%E5%8C%96%E6%9C%BA%E5%88%B6" title="3.1 RDD 的持久化机制"></a>3.1 RDD 的持久化机制</h3><p>弹性分布式数据集（RDD）作为 Spark 最根本的数据抽象，是只读的分区记录（Partition）的集合，只能基于在稳定物理存储中的数据集上创建，或者在其他已有的 RDD 上执行转换（Transformation）操作产生一个新的 RDD。转换后的 RDD 与原始的 RDD 之间产生的依赖关系，构成了血统（Lineage）。凭借血统，Spark 保证了每一个 RDD 都可以被重新恢复。但 RDD 的所有转换都是惰性的，即只有当一个返回结果给 Driver 的行动（Action）发生时，Spark 才会创建任务读取 RDD，然后真正触发转换的执行。<br>Task 在启动之初读取一个分区时，会先判断这个分区是否已经被持久化，如果没有则需要检查 Checkpoint 或按照血统重新计算。所以如果一个 RDD 上要执行多次行动，可以在第一次行动中使用 persist 或 cache 方法，在内存或磁盘中持久化或缓存这个 RDD，从而在后面的行动时提升计算速度。事实上，cache 方法是使用默认的 MEMORY_ONLY 的存储级别将 RDD 持久化到内存，故缓存是一种特殊的持久化。 <strong>堆内和堆外存储内存的设计，便可以对缓存 **</strong>RDD <strong><strong>时使用的内存做统一的规划和管 </strong></strong>理 **（存储内存的其他应用场景，如缓存 broadcast 数据，暂时不在本文的讨论范围之内）。</p><p>RDD 的持久化由 Spark 的 Storage 模块 [7] 负责，实现了 RDD 与物理存储的解耦合。Storage 模块负责管理 Spark 在计算过程中产生的数据，将那些在内存或磁盘、在本地或远程存取数据的功能封装了起来。在具体实现时 Driver 端和 Executor 端的 Storage 模块构成了主从式的架构，即 Driver 端的 BlockManager 为 Master，Executor 端的 BlockManager 为 Slave。Storage 模块在逻辑上以 Block 为基本存储单位，RDD 的每个 Partition 经过处理后唯一对应一个 Block（BlockId 的格式为 rdd_RDD-ID_PARTITION-ID ）。Master 负责整个 Spark 应用程序的 Block 的元数据信息的管理和维护，而 Slave 需要将 Block 的更新等状态上报到 Master，同时接收 Master 的命令，例如新增或删除一个 RDD。</p><h5 id="图-7-Storage-模块示意图"><a href="#图-7-Storage-模块示意图" class="headerlink" title="图 7 . Storage 模块示意图"></a>图 7 . Storage 模块示意图</h5><p><a href="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image007.png" target="_blank" rel="noopener"><img src="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image007.png" alt=""></a></p><p>在对 RDD 持久化时，Spark 规定了 MEMORY_ONLY、MEMORY_AND_DISK 等 7 种不同的 <a href="http://spark.apache.org/docs/latest/programming-guide.html#rdd-persistence" target="_blank" rel="noopener">存储级别 </a>，而存储级别是以下 5 个变量的组合：</p><h4 id="清单-3-存储级别"><a href="#清单-3-存储级别" class="headerlink" title="清单 3 . 存储级别"></a>清单 3 . 存储级别</h4><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="title">StorageLevel</span> <span class="title">private</span>(<span class="params"></span></span></span><br><span class="line"><span class="class"><span class="params">private var _useDisk: <span class="type">Boolean</span>, //磁盘</span></span></span><br><span class="line"><span class="class"><span class="params">private var _useMemory: <span class="type">Boolean</span>, //这里其实是指堆内内存</span></span></span><br><span class="line"><span class="class"><span class="params">private var _useOffHeap: <span class="type">Boolean</span>, //堆外内存</span></span></span><br><span class="line"><span class="class"><span class="params">private var _deserialized: <span class="type">Boolean</span>, //是否为非序列化</span></span></span><br><span class="line"><span class="class"><span class="params">private var _replication: <span class="type">Int</span> = 1 //副本个数</span></span></span><br><span class="line"><span class="class"><span class="params"></span>)</span></span><br></pre></td></tr></table></figure><p>通过对数据结构的分析，可以看出存储级别从三个维度定义了 RDD 的 Partition（同时也就是 Block）的存储方式：</p><ul><li>存储位置：磁盘／堆内内存／堆外内存。如 MEMORY_AND_DISK 是同时在磁盘和堆内内存上存储，实现了冗余备份。OFF_HEAP 则是只在堆外内存存储，目前选择堆外内存时不能同时存储到其他位置。</li><li>存储形式：Block 缓存到存储内存后，是否为非序列化的形式。如 MEMORY_ONLY 是非序列化方式存储，OFF_HEAP 是序列化方式存储。</li><li>副本数量：大于 1 时需要远程冗余备份到其他节点。如 DISK_ONLY_2 需要远程备份 1 个副本。</li></ul><h3 id="3-2-RDD-缓存的过程"><a href="#3-2-RDD-缓存的过程" class="headerlink" title="3.2 RDD 缓存的过程"></a>3.2 RDD 缓存的过程</h3><p>RDD 在缓存到存储内存之前，Partition 中的数据一般以迭代器（<a href="http://www.scala-lang.org/docu/files/collections-api/collections_43.html" target="_blank" rel="noopener">Iterator</a>）的数据结构来访问，这是 Scala 语言中一种遍历数据集合的方法。通过 Iterator 可以获取分区中每一条序列化或者非序列化的数据项(Record)，这些 Record 的对象实例在逻辑上占用了 JVM 堆内内存的 other 部分的空间，同一 Partition 的不同 Record 的空间并不连续。</p><p>RDD 在缓存到存储内存之后，Partition 被转换成 Block，Record 在堆内或堆外存储内存中占用一块连续的空间。<strong>将**</strong>Partition<strong><strong>由不连续的存储空间转换为连续存储空间的过程，Spark</strong></strong>称之为”展开”（Unroll）**。Block 有序列化和非序列化两种存储格式，具体以哪种方式取决于该 RDD 的存储级别。非序列化的 Block 以一种 DeserializedMemoryEntry 的数据结构定义，用一个数组存储所有的对象实例，序列化的 Block 则以 SerializedMemoryEntry的数据结构定义，用字节缓冲区（ByteBuffer）来存储二进制数据。每个 Executor 的 Storage 模块用一个链式 Map 结构（LinkedHashMap）来管理堆内和堆外存储内存中所有的 Block 对象的实例[6]，对这个 LinkedHashMap 新增和删除间接记录了内存的申请和释放。</p><p>因为不能保证存储空间可以一次容纳 Iterator 中的所有数据，当前的计算任务在 Unroll 时要向 MemoryManager 申请足够的 Unroll 空间来临时占位，空间不足则 Unroll 失败，空间足够时可以继续进行。对于序列化的 Partition，其所需的 Unroll 空间可以直接累加计算，一次申请。而非序列化的 Partition 则要在遍历 Record 的过程中依次申请，即每读取一条 Record，采样估算其所需的 Unroll 空间并进行申请，空间不足时可以中断，释放已占用的 Unroll 空间。如果最终 Unroll 成功，当前 Partition 所占用的 Unroll 空间被转换为正常的缓存 RDD 的存储空间，如下图 8 所示。</p><h5 id="图-8-Spark-Unroll-示意图"><a href="#图-8-Spark-Unroll-示意图" class="headerlink" title="图 8. Spark Unroll 示意图"></a>图 8. Spark Unroll 示意图</h5><p><a href="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image008.png" target="_blank" rel="noopener"><img src="https://www.ibm.com/developerworks/cn/analytics/library/ba-cn-apache-spark-memory-management/image008.png" alt=""></a></p><p>在图 3 和图 5 中可以看到，在静态内存管理时，Spark 在存储内存中专门划分了一块 Unroll 空间，其大小是固定的，统一内存管理时则没有对 Unroll 空间进行特别区分，当存储空间不足时会根据动态占用机制进行处理。</p><h3 id="3-3-淘汰和落盘"><a href="#3-3-淘汰和落盘" class="headerlink" title="3.3 淘汰和落盘"></a>3.3 淘汰和落盘</h3><p>由于同一个 Executor 的所有的计算任务共享有限的存储内存空间，当有新的 Block 需要缓存但是剩余空间不足且无法动态占用时，就要对 LinkedHashMap 中的旧 Block 进行淘汰（Eviction），而被淘汰的 Block 如果其存储级别中同时包含存储到磁盘的要求，则要对其进行落盘（Drop），否则直接删除该 Block。</p><p>存储内存的淘汰规则为：</p><ul><li>被淘汰的旧 Block 要与新 Block 的 MemoryMode 相同，即同属于堆外或堆内内存</li><li>新旧 Block 不能属于同一个 RDD，避免循环淘汰</li><li>旧 Block 所属 RDD 不能处于被读状态，避免引发一致性问题</li><li>遍历 LinkedHashMap 中 Block，按照最近最少使用（LRU）的顺序淘汰，直到满足新 Block 所需的空间。其中 LRU 是 LinkedHashMap 的特性。</li></ul><p>落盘的流程则比较简单，如果其存储级别符合_useDisk 为 true 的条件，再根据其_deserialized 判断是否是非序列化的形式，若是则对其进行序列化，最后将数据存储到磁盘，在 Storage 模块中更新其信息。</p><h2 id="4-执行内存管理"><a href="#4-执行内存管理" class="headerlink" title="4. 执行内存管理"></a>4. 执行内存管理</h2><h3 id="4-1-多任务间内存分配"><a href="#4-1-多任务间内存分配" class="headerlink" title="4.1 多任务间内存分配"></a>4.1 多任务间内存分配</h3><p>Executor 内运行的任务同样共享执行内存，Spark 用一个 HashMap 结构保存了任务到内存耗费的映射。每个任务可占用的执行内存大小的范围为 1/2N ~ 1/N，其中 N 为当前 Executor 内正在运行的任务的个数。每个任务在启动之时，要向 MemoryManager 请求申请最少为 1/2N 的执行内存，如果不能被满足要求则该任务被阻塞，直到有其他任务释放了足够的执行内存，该任务才可以被唤醒。</p><h3 id="4-2-Shuffle-的内存占用"><a href="#4-2-Shuffle-的内存占用" class="headerlink" title="4.2 Shuffle 的内存占用"></a>4.2 Shuffle 的内存占用</h3><p>执行内存主要用来存储任务在执行 Shuffle 时占用的内存，Shuffle 是按照一定规则对 RDD 数据重新分区的过程，我们来看 Shuffle 的 Write 和 Read 两阶段对执行内存的使用：</p><ul><li>Shuffle Write</li></ul><ol><li>若在 map 端选择普通的排序方式，会采用 ExternalSorter 进行外排，在内存中存储数据时主要占用堆内执行空间。</li><li>若在 map 端选择 Tungsten 的排序方式，则采用 ShuffleExternalSorter 直接对以序列化形式存储的数据排序，在内存中存储数据时可以占用堆外或堆内执行空间，取决于用户是否开启了堆外内存以及堆外执行内存是否足够。</li></ol><ul><li>Shuffle Read</li></ul><ol><li>在对 reduce 端的数据进行聚合时，要将数据交给 Aggregator 处理，在内存中存储数据时占用堆内执行空间。</li><li>如果需要进行最终结果排序，则要将再次将数据交给 ExternalSorter 处理，占用堆内执行空间。</li></ol><p>在 ExternalSorter 和 Aggregator 中，Spark 会使用一种叫 AppendOnlyMap 的哈希表在堆内执行内存中存储数据，但在 Shuffle 过程中所有数据并不能都保存到该哈希表中，当这个哈希表占用的内存会进行周期性地采样估算，当其大到一定程度，无法再从 MemoryManager 申请到新的执行内存时，Spark 就会将其全部内容存储到磁盘文件中，这个过程被称为溢存(Spill)，溢存到磁盘的文件最后会被归并(Merge)。</p><p>Shuffle Write 阶段中用到的 Tungsten 是 Databricks 公司提出的对 Spark 优化内存和 CPU 使用的计划[9]，解决了一些 JVM 在性能上的限制和弊端。Spark 会根据 Shuffle 的情况来自动选择是否采用 Tungsten 排序。Tungsten 采用的页式内存管理机制建立在 MemoryManager 之上，即 Tungsten 对执行内存的使用进行了一步的抽象，这样在 Shuffle 过程中无需关心数据具体存储在堆内还是堆外。每个内存页用一个 MemoryBlock 来定义，并用 Object obj 和 long offset 这两个变量统一标识一个内存页在系统内存中的地址。堆内的 MemoryBlock 是以 long 型数组的形式分配的内存，其 obj 的值为是这个数组的对象引用，offset 是 long 型数组的在 JVM 中的初始偏移地址，两者配合使用可以定位这个数组在堆内的绝对地址；堆外的 MemoryBlock 是直接申请到的内存块，其 obj 为 null，offset 是这个内存块在系统内存中的 64 位绝对地址。Spark 用 MemoryBlock 巧妙地将堆内和堆外内存页统一抽象封装，并用页表(pageTable)管理每个 Task 申请到的内存页。</p><p>Tungsten 页式管理下的所有内存用 64 位的逻辑地址表示，由页号和页内偏移量组成：</p><ul><li>页号：占 13 位，唯一标识一个内存页，Spark 在申请内存页之前要先申请空闲页号。</li><li>页内偏移量：占 51 位，是在使用内存页存储数据时，数据在页内的偏移地址。</li></ul><p>有了统一的寻址方式，Spark 可以用 64 位逻辑地址的指针定位到堆内或堆外的内存，整个 Shuffle Write 排序的过程只需要对指针进行排序，并且无需反序列化，整个过程非常高效，对于内存访问效率和 CPU 使用效率带来了明显的提升[10]。</p><p>Spark 的存储内存和执行内存有着截然不同的管理方式：对于存储内存来说，Spark 用一个 LinkedHashMap 来集中管理所有的 Block，Block 由需要缓存的 RDD 的 Partition 转化而成；而对于执行内存，Spark 用 AppendOnlyMap 来存储 Shuffle 过程中的数据，在 Tungsten 排序中甚至抽象成为页式内存管理，开辟了全新的 JVM 内存管理机制。</p><h2 id="结束语"><a href="#结束语" class="headerlink" title="结束语"></a>结束语</h2><p>Spark 的内存管理是一套复杂的机制，且 Spark 的版本更新比较快，笔者水平有限，难免有叙述不清、错误的地方，若读者有好的建议和更深的理解，还望不吝赐教。</p><h2 id="参考资源"><a href="#参考资源" class="headerlink" title="参考资源"></a>参考资源</h2><ol><li><a href="http://spark.apache.org/docs/latest/cluster-overview.html" target="_blank" rel="noopener">Spark Cluster Mode Overview</a></li><li><a href="http://www.jianshu.com/p/c83bb237caa8" target="_blank" rel="noopener">Spark Sort Based Shuffle 内存分析</a></li><li><a href="http://www.jianshu.com/p/c6f6d4071560" target="_blank" rel="noopener">Spark OFF_HEAP</a></li><li><a href="https://issues.apache.org/jira/secure/attachment/12765646/unified-memory-management-spark-10000.pdf" target="_blank" rel="noopener">Unified Memory Management in Spark 1.6</a></li><li><a href="http://spark.apache.org/docs/latest/tuning.html#garbage-collection-tuning" target="_blank" rel="noopener">Tuning Spark: Garbage Collection Tuning</a></li><li><a href="https://0x0fff.com/spark-architecture/" target="_blank" rel="noopener">Spark Architecture</a></li><li><a href="https://book.douban.com/subject/26649141/" target="_blank" rel="noopener">《Spark 技术内幕：深入解析 Spark 内核架构于实现原理》第 8 章 Storage 模块详解</a></li><li><a href="http://www.jianshu.com/p/c83bb237caa8" target="_blank" rel="noopener">Spark Sort Based Shuffle 内存分析</a></li><li><a href="https://databricks.com/blog/2015/04/28/project-tungsten-bringing-spark-closer-to-bare-metal.html" target="_blank" rel="noopener">Project Tungsten: Bringing Apache Spark Closer to Bare Metal</a></li><li><a href="http://www.jianshu.com/p/d328c96aebfd" target="_blank" rel="noopener">Spark Tungsten-sort Based Shuffle 分析</a></li><li><a href="https://github.com/hustnn/TungstenSecret/tree/master" target="_blank" rel="noopener">探索 Spark Tungsten 的秘密</a></li><li><a href="http://www.jianshu.com/p/8f9ed2d58a26" target="_blank" rel="noopener">Spark Task 内存管理（on-heap&amp;off-heap）</a></li></ol>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;Spark 作为一个基于内存的分布式计算引擎，其内存管理模块在整个系统中扮演着非常重要的角色。理解 Spark 内存管理的基本原理，有助于更好地开发 Spark 应用程序和进行性能调优。本文旨在梳理出 Spark 内存管理的脉络，抛砖引玉，引出读者对这个话题的深入探讨。本文中阐述的原理基于 Spark 2.1 版本，阅读本文需要读者有一定的 Spark 和 Java 基础，了解 RDD、Shuffle、JVM 等相关概念。&lt;br&gt;
    
    </summary>
    
      <category term="spark" scheme="http://crazycarry.github.io/categories/spark/"/>
    
    
      <category term="源码分析" scheme="http://crazycarry.github.io/tags/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/"/>
    
      <category term="大数据" scheme="http://crazycarry.github.io/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
    
      <category term="性能优化" scheme="http://crazycarry.github.io/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"/>
    
      <category term="内存管理" scheme="http://crazycarry.github.io/tags/%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86/"/>
    
  </entry>
  
  <entry>
    <title>Spark性能优化指南——高级篇</title>
    <link href="http://crazycarry.github.io/2018/04/04/spark-performance-02/"/>
    <id>http://crazycarry.github.io/2018/04/04/spark-performance-02/</id>
    <published>2018-04-04T06:28:52.000Z</published>
    <updated>2018-04-04T06:52:43.469Z</updated>
    
    <content type="html"><![CDATA[<p>继基础篇讲解了每个Spark开发人员都必须熟知的开发调优与资源调优之后，本文作为《Spark性能优化指南》的高级篇，将深入分析数据倾斜调优与shuffle调优，以解决更加棘手的性能问题<br><a id="more"></a></p><h1 id="数据倾斜调优"><a href="#数据倾斜调优" class="headerlink" title="数据倾斜调优"></a>数据倾斜调优</h1><h2 id="调优概述"><a href="#调优概述" class="headerlink" title="调优概述"></a>调优概述</h2><p>有的时候，我们可能会遇到大数据计算中一个最棘手的问题——数据倾斜，此时Spark作业的性能会比期望差很多。数据倾斜调优，就是使用各种技术方案解决不同类型的数据倾斜问题，以保证Spark作业的性能。</p><h2 id="数据倾斜发生时的现象"><a href="#数据倾斜发生时的现象" class="headerlink" title="数据倾斜发生时的现象"></a>数据倾斜发生时的现象</h2><ul><li><p>绝大多数task执行得都非常快，但个别task执行极慢。比如，总共有1000个task，997个task都在1分钟之内执行完了，但是剩余两三个task却要一两个小时。这种情况很常见。</p></li><li><p>原本能够正常执行的Spark作业，某天突然报出OOM（内存溢出）异常，观察异常栈，是我们写的业务代码造成的。这种情况比较少见。</p></li></ul><h2 id="数据倾斜发生的原理"><a href="#数据倾斜发生的原理" class="headerlink" title="数据倾斜发生的原理"></a>数据倾斜发生的原理</h2><p>数据倾斜的原理很简单：在进行shuffle的时候，必须将各个节点上相同的key拉取到某个节点上的一个task来进行处理，比如按照key进行聚合或join等操作。此时如果某个key对应的数据量特别大的话，就会发生数据倾斜。比如大部分key对应10条数据，但是个别key却对应了100万条数据，那么大部分task可能就只会分配到10条数据，然后1秒钟就运行完了；但是个别task可能分配到了100万数据，要运行一两个小时。因此，整个Spark作业的运行进度是由运行时间最长的那个task决定的。</p><p>因此出现数据倾斜的时候，Spark作业看起来会运行得非常缓慢，甚至可能因为某个task处理的数据量过大导致内存溢出。</p><p>下图就是一个很清晰的例子：hello这个key，在三个节点上对应了总共7条数据，这些数据都会被拉取到同一个task中进行处理；而world和you这两个key分别才对应1条数据，所以另外两个task只要分别处理1条数据即可。此时第一个task的运行时间可能是另外两个task的7倍，而整个stage的运行速度也由运行最慢的那个task所决定。</p><p><a href="https://tech.meituan.com/img/spark-tuning/skwed-mech.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/spark-tuning/skwed-mech.png" alt="数据倾斜原理"></a></p><h2 id="如何定位导致数据倾斜的代码"><a href="#如何定位导致数据倾斜的代码" class="headerlink" title="如何定位导致数据倾斜的代码"></a>如何定位导致数据倾斜的代码</h2><p>数据倾斜只会发生在shuffle过程中。这里给大家罗列一些常用的并且可能会触发shuffle操作的算子：distinct、groupByKey、reduceByKey、aggregateByKey、join、cogroup、repartition等。出现数据倾斜时，可能就是你的代码中使用了这些算子中的某一个所导致的。</p><h3 id="某个task执行特别慢的情况"><a href="#某个task执行特别慢的情况" class="headerlink" title="某个task执行特别慢的情况"></a>某个task执行特别慢的情况</h3><p>首先要看的，就是数据倾斜发生在第几个stage中。</p><p>如果是用yarn-client模式提交，那么本地是直接可以看到log的，可以在log中找到当前运行到了第几个stage；如果是用yarn-cluster模式提交，则可以通过Spark Web UI来查看当前运行到了第几个stage。此外，无论是使用yarn-client模式还是yarn-cluster模式，我们都可以在Spark Web UI上深入看一下当前这个stage各个task分配的数据量，从而进一步确定是不是task分配的数据不均匀导致了数据倾斜。</p><p>比如下图中，倒数第三列显示了每个task的运行时间。明显可以看到，有的task运行特别快，只需要几秒钟就可以运行完；而有的task运行特别慢，需要几分钟才能运行完，此时单从运行时间上看就已经能够确定发生数据倾斜了。此外，倒数第一列显示了每个task处理的数据量，明显可以看到，运行时间特别短的task只需要处理几百KB的数据即可，而运行时间特别长的task需要处理几千KB的数据，处理的数据量差了10倍。此时更加能够确定是发生了数据倾斜。</p><p><a href="https://tech.meituan.com/img/spark-tuning/shuffle-skwed-web-ui-demo.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/spark-tuning/shuffle-skwed-web-ui-demo.png" alt=""></a></p><p>知道数据倾斜发生在哪一个stage之后，接着我们就需要根据stage划分原理，推算出来发生倾斜的那个stage对应代码中的哪一部分，这部分代码中肯定会有一个shuffle类算子。精准推算stage与代码的对应关系，需要对Spark的源码有深入的理解，这里我们可以介绍一个相对简单实用的推算方法：只要看到Spark代码中出现了一个shuffle类算子或者是Spark SQL的SQL语句中出现了会导致shuffle的语句（比如group by语句），那么就可以判定，以那个地方为界限划分出了前后两个stage。</p><p>这里我们就以Spark最基础的入门程序——单词计数来举例，如何用最简单的方法大致推算出一个stage对应的代码。如下示例，在整个代码中，只有一个reduceByKey是会发生shuffle的算子，因此就可以认为，以这个算子为界限，会划分出前后两个stage。</p><ul><li>stage0，主要是执行从textFile到map操作，以及执行shuffle write操作。shuffle write操作，我们可以简单理解为对pairs RDD中的数据进行分区操作，每个task处理的数据中，相同的key会写入同一个磁盘文件内。</li><li>stage1，主要是执行从reduceByKey到collect操作，stage1的各个task一开始运行，就会首先执行shuffle read操作。执行shuffle read操作的task，会从stage0的各个task所在节点拉取属于自己处理的那些key，然后对同一个key进行全局性的聚合或join等操作，在这里就是对key的value值进行累加。stage1在执行完reduceByKey算子之后，就计算出了最终的wordCounts RDD，然后会执行collect算子，将所有数据拉取到Driver上，供我们遍历和打印输出。</li></ul><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">val</span> conf = <span class="keyword">new</span> <span class="type">SparkConf</span>()</span><br><span class="line"><span class="keyword">val</span> sc = <span class="keyword">new</span> <span class="type">SparkContext</span>(conf)</span><br><span class="line"></span><br><span class="line"><span class="keyword">val</span> lines = sc.textFile(<span class="string">"hdfs://..."</span>)</span><br><span class="line"><span class="keyword">val</span> words = lines.flatMap(_.split(<span class="string">" "</span>))</span><br><span class="line"><span class="keyword">val</span> pairs = words.map((_, <span class="number">1</span>))</span><br><span class="line"><span class="keyword">val</span> wordCounts = pairs.reduceByKey(_ + _)</span><br><span class="line"></span><br><span class="line">wordCounts.collect().foreach(println(_))</span><br></pre></td></tr></table></figure><p>通过对单词计数程序的分析，希望能够让大家了解最基本的stage划分的原理，以及stage划分后shuffle操作是如何在两个stage的边界处执行的。然后我们就知道如何快速定位出发生数据倾斜的stage对应代码的哪一个部分了。比如我们在Spark Web UI或者本地log中发现，stage1的某几个task执行得特别慢，判定stage1出现了数据倾斜，那么就可以回到代码中定位出stage1主要包括了reduceByKey这个shuffle类算子，此时基本就可以确定是由educeByKey算子导致的数据倾斜问题。比如某个单词出现了100万次，其他单词才出现10次，那么stage1的某个task就要处理100万数据，整个stage的速度就会被这个task拖慢。</p><h3 id="某个task莫名其妙内存溢出的情况"><a href="#某个task莫名其妙内存溢出的情况" class="headerlink" title="某个task莫名其妙内存溢出的情况"></a>某个task莫名其妙内存溢出的情况</h3><p>这种情况下去定位出问题的代码就比较容易了。我们建议直接看yarn-client模式下本地log的异常栈，或者是通过YARN查看yarn-cluster模式下的log中的异常栈。一般来说，通过异常栈信息就可以定位到你的代码中哪一行发生了内存溢出。然后在那行代码附近找找，一般也会有shuffle类算子，此时很可能就是这个算子导致了数据倾斜。</p><p>但是大家要注意的是，不能单纯靠偶然的内存溢出就判定发生了数据倾斜。因为自己编写的代码的bug，以及偶然出现的数据异常，也可能会导致内存溢出。因此还是要按照上面所讲的方法，通过Spark Web UI查看报错的那个stage的各个task的运行时间以及分配的数据量，才能确定是否是由于数据倾斜才导致了这次内存溢出。</p><h2 id="查看导致数据倾斜的key的数据分布情况"><a href="#查看导致数据倾斜的key的数据分布情况" class="headerlink" title="查看导致数据倾斜的key的数据分布情况"></a>查看导致数据倾斜的key的数据分布情况</h2><p>知道了数据倾斜发生在哪里之后，通常需要分析一下那个执行了shuffle操作并且导致了数据倾斜的RDD/Hive表，查看一下其中key的分布情况。这主要是为之后选择哪一种技术方案提供依据。针对不同的key分布与不同的shuffle算子组合起来的各种情况，可能需要选择不同的技术方案来解决。</p><p>此时根据你执行操作的情况不同，可以有很多种查看key分布的方式：</p><ol><li>如果是Spark SQL中的group by、join语句导致的数据倾斜，那么就查询一下SQL中使用的表的key分布情况。</li><li>如果是对Spark RDD执行shuffle算子导致的数据倾斜，那么可以在Spark作业中加入查看key分布的代码，比如RDD.countByKey()。然后对统计出来的各个key出现的次数，collect/take到客户端打印一下，就可以看到key的分布情况。</li></ol><p>举例来说，对于上面所说的单词计数程序，如果确定了是stage1的reduceByKey算子导致了数据倾斜，那么就应该看看进行reduceByKey操作的RDD中的key分布情况，在这个例子中指的就是pairs RDD。如下示例，我们可以先对pairs采样10%的样本数据，然后使用countByKey算子统计出每个key出现的次数，最后在客户端遍历和打印样本数据中各个key的出现次数。</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">val</span> sampledPairs = pairs.sample(<span class="literal">false</span>, <span class="number">0.1</span>)</span><br><span class="line"><span class="keyword">val</span> sampledWordCounts = sampledPairs.countByKey()</span><br><span class="line">sampledWordCounts.foreach(println(_))</span><br></pre></td></tr></table></figure><h2 id="数据倾斜的解决方案"><a href="#数据倾斜的解决方案" class="headerlink" title="数据倾斜的解决方案"></a>数据倾斜的解决方案</h2><h3 id="解决方案一：使用Hive-ETL预处理数据"><a href="#解决方案一：使用Hive-ETL预处理数据" class="headerlink" title="解决方案一：使用Hive ETL预处理数据"></a>解决方案一：使用Hive ETL预处理数据</h3><p><strong>方案适用场景：</strong>导致数据倾斜的是Hive表。如果该Hive表中的数据本身很不均匀（比如某个key对应了100万数据，其他key才对应了10条数据），而且业务场景需要频繁使用Spark对Hive表执行某个分析操作，那么比较适合使用这种技术方案。</p><p><strong>方案实现思路：</strong>此时可以评估一下，是否可以通过Hive来进行数据预处理（即通过Hive ETL预先对数据按照key进行聚合，或者是预先和其他表进行join），然后在Spark作业中针对的数据源就不是原来的Hive表了，而是预处理后的Hive表。此时由于数据已经预先进行过聚合或join操作了，那么在Spark作业中也就不需要使用原先的shuffle类算子执行这类操作了。</p><p><strong>方案实现原理：</strong>这种方案从根源上解决了数据倾斜，因为彻底避免了在Spark中执行shuffle类算子，那么肯定就不会有数据倾斜的问题了。但是这里也要提醒一下大家，这种方式属于治标不治本。因为毕竟数据本身就存在分布不均匀的问题，所以Hive ETL中进行group by或者join等shuffle操作时，还是会出现数据倾斜，导致Hive ETL的速度很慢。我们只是把数据倾斜的发生提前到了Hive ETL中，避免Spark程序发生数据倾斜而已。</p><p><strong>方案优点：</strong>实现起来简单便捷，效果还非常好，完全规避掉了数据倾斜，Spark作业的性能会大幅度提升。</p><p><strong>方案缺点：</strong>治标不治本，Hive ETL中还是会发生数据倾斜。</p><p><strong>方案实践经验：</strong>在一些Java系统与Spark结合使用的项目中，会出现Java代码频繁调用Spark作业的场景，而且对Spark作业的执行性能要求很高，就比较适合使用这种方案。将数据倾斜提前到上游的Hive ETL，每天仅执行一次，只有那一次是比较慢的，而之后每次Java调用Spark作业时，执行速度都会很快，能够提供更好的用户体验。</p><p><strong>项目实践经验：</strong>在美团·点评的交互式用户行为分析系统中使用了这种方案，该系统主要是允许用户通过Java Web系统提交数据分析统计任务，后端通过Java提交Spark作业进行数据分析统计。要求Spark作业速度必须要快，尽量在10分钟以内，否则速度太慢，用户体验会很差。所以我们将有些Spark作业的shuffle操作提前到了Hive ETL中，从而让Spark直接使用预处理的Hive中间表，尽可能地减少Spark的shuffle操作，大幅度提升了性能，将部分作业的性能提升了6倍以上。</p><h3 id="解决方案二：过滤少数导致倾斜的key"><a href="#解决方案二：过滤少数导致倾斜的key" class="headerlink" title="解决方案二：过滤少数导致倾斜的key"></a>解决方案二：过滤少数导致倾斜的key</h3><p><strong>方案适用场景：</strong>如果发现导致倾斜的key就少数几个，而且对计算本身的影响并不大的话，那么很适合使用这种方案。比如99%的key就对应10条数据，但是只有一个key对应了100万数据，从而导致了数据倾斜。</p><p><strong>方案实现思路：</strong>如果我们判断那少数几个数据量特别多的key，对作业的执行和计算结果不是特别重要的话，那么干脆就直接过滤掉那少数几个key。比如，在Spark SQL中可以使用where子句过滤掉这些key或者在Spark Core中对RDD执行filter算子过滤掉这些key。如果需要每次作业执行时，动态判定哪些key的数据量最多然后再进行过滤，那么可以使用sample算子对RDD进行采样，然后计算出每个key的数量，取数据量最多的key过滤掉即可。</p><p><strong>方案实现原理：</strong>将导致数据倾斜的key给过滤掉之后，这些key就不会参与计算了，自然不可能产生数据倾斜。</p><p><strong>方案优点：</strong>实现简单，而且效果也很好，可以完全规避掉数据倾斜。</p><p><strong>方案缺点：</strong>适用场景不多，大多数情况下，导致倾斜的key还是很多的，并不是只有少数几个。</p><p><strong>方案实践经验：</strong>在项目中我们也采用过这种方案解决数据倾斜。有一次发现某一天Spark作业在运行的时候突然OOM了，追查之后发现，是Hive表中的某一个key在那天数据异常，导致数据量暴增。因此就采取每次执行前先进行采样，计算出样本中数据量最大的几个key之后，直接在程序中将那些key给过滤掉。</p><h3 id="解决方案三：提高shuffle操作的并行度"><a href="#解决方案三：提高shuffle操作的并行度" class="headerlink" title="解决方案三：提高shuffle操作的并行度"></a>解决方案三：提高shuffle操作的并行度</h3><p><strong>方案适用场景：</strong>如果我们必须要对数据倾斜迎难而上，那么建议优先使用这种方案，因为这是处理数据倾斜最简单的一种方案。</p><p><strong>方案实现思路：</strong>在对RDD执行shuffle算子时，给shuffle算子传入一个参数，比如reduceByKey(1000)，该参数就设置了这个shuffle算子执行时shuffle read task的数量。对于Spark SQL中的shuffle类语句，比如group by、join等，需要设置一个参数，即spark.sql.shuffle.partitions，该参数代表了shuffle read task的并行度，该值默认是200，对于很多场景来说都有点过小。</p><p><strong>方案实现原理：</strong>增加shuffle read task的数量，可以让原本分配给一个task的多个key分配给多个task，从而让每个task处理比原来更少的数据。举例来说，如果原本有5个key，每个key对应10条数据，这5个key都是分配给一个task的，那么这个task就要处理50条数据。而增加了shuffle read task以后，每个task就分配到一个key，即每个task就处理10条数据，那么自然每个task的执行时间都会变短了。具体原理如下图所示。</p><p><strong>方案优点：</strong>实现起来比较简单，可以有效缓解和减轻数据倾斜的影响。</p><p><strong>方案缺点：</strong>只是缓解了数据倾斜而已，没有彻底根除问题，根据实践经验来看，其效果有限。</p><p><strong>方案实践经验：</strong>该方案通常无法彻底解决数据倾斜，因为如果出现一些极端情况，比如某个key对应的数据量有100万，那么无论你的task数量增加到多少，这个对应着100万数据的key肯定还是会分配到一个task中去处理，因此注定还是会发生数据倾斜的。所以这种方案只能说是在发现数据倾斜时尝试使用的第一种手段，尝试去用嘴简单的方法缓解数据倾斜而已，或者是和其他方案结合起来使用。</p><p><a href="https://tech.meituan.com/img/spark-tuning/shuffle-skwed-add-partition.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/spark-tuning/shuffle-skwed-add-partition.png" alt=""></a></p><h3 id="解决方案四：两阶段聚合（局部聚合-全局聚合）"><a href="#解决方案四：两阶段聚合（局部聚合-全局聚合）" class="headerlink" title="解决方案四：两阶段聚合（局部聚合+全局聚合）"></a>解决方案四：两阶段聚合（局部聚合+全局聚合）</h3><p><strong>方案适用场景：</strong>对RDD执行reduceByKey等聚合类shuffle算子或者在Spark SQL中使用group by语句进行分组聚合时，比较适用这种方案。</p><p><strong>方案实现思路：</strong>这个方案的核心实现思路就是进行两阶段聚合。第一次是局部聚合，先给每个key都打上一个随机数，比如10以内的随机数，此时原先一样的key就变成不一样的了，比如(hello, 1) (hello, 1) (hello, 1) (hello, 1)，就会变成(1_hello, 1) (1_hello, 1) (2_hello, 1) (2_hello, 1)。接着对打上随机数后的数据，执行reduceByKey等聚合操作，进行局部聚合，那么局部聚合结果，就会变成了(1_hello, 2) (2_hello, 2)。然后将各个key的前缀给去掉，就会变成(hello,2)(hello,2)，再次进行全局聚合操作，就可以得到最终结果了，比如(hello, 4)。</p><p><strong>方案实现原理：</strong>将原本相同的key通过附加随机前缀的方式，变成多个不同的key，就可以让原本被一个task处理的数据分散到多个task上去做局部聚合，进而解决单个task处理数据量过多的问题。接着去除掉随机前缀，再次进行全局聚合，就可以得到最终的结果。具体原理见下图。</p><p><strong>方案优点：</strong>对于聚合类的shuffle操作导致的数据倾斜，效果是非常不错的。通常都可以解决掉数据倾斜，或者至少是大幅度缓解数据倾斜，将Spark作业的性能提升数倍以上。</p><p><strong>方案缺点：</strong>仅仅适用于聚合类的shuffle操作，适用范围相对较窄。如果是join类的shuffle操作，还得用其他的解决方案。</p><p><a href="https://tech.meituan.com/img/spark-tuning/shuffle-skwed-two-phase-aggr.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/spark-tuning/shuffle-skwed-two-phase-aggr.png" alt=""></a></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 第一步，给RDD中的每个key都打上一个随机前缀。</span></span><br><span class="line">JavaPairRDD randomPrefixRdd = rdd.mapToPair(</span><br><span class="line"> <span class="keyword">new</span> PairFunction, String, Long&gt;() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> Tuple2 <span class="title">call</span><span class="params">(Tuple2 tuple)</span></span></span><br><span class="line"><span class="function"> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line"> Random random = <span class="keyword">new</span> Random();</span><br><span class="line"> <span class="keyword">int</span> prefix = random.nextInt(<span class="number">10</span>);</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> Tuple2(prefix + <span class="string">"_"</span> + tuple._1, tuple._2);</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 第二步，对打上随机前缀的key进行局部聚合。</span></span><br><span class="line">JavaPairRDD localAggrRdd = randomPrefixRdd.reduceByKey(</span><br><span class="line"> <span class="keyword">new</span> Function2() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> Long <span class="title">call</span><span class="params">(Long v1, Long v2)</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line"> <span class="keyword">return</span> v1 + v2;</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 第三步，去除RDD中每个key的随机前缀。</span></span><br><span class="line">JavaPairRDD removedRandomPrefixRdd = localAggrRdd.mapToPair(</span><br><span class="line"> <span class="keyword">new</span> PairFunction, Long, Long&gt;() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> Tuple2 <span class="title">call</span><span class="params">(Tuple2 tuple)</span></span></span><br><span class="line"><span class="function"> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line"> <span class="keyword">long</span> originalKey = Long.valueOf(tuple._1.split(<span class="string">"_"</span>)[<span class="number">1</span>]);</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> Tuple2(originalKey, tuple._2);</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 第四步，对去除了随机前缀的RDD进行全局聚合。</span></span><br><span class="line">JavaPairRDD globalAggrRdd = removedRandomPrefixRdd.reduceByKey(</span><br><span class="line"> <span class="keyword">new</span> Function2() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> Long <span class="title">call</span><span class="params">(Long v1, Long v2)</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line"> <span class="keyword">return</span> v1 + v2;</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br></pre></td></tr></table></figure><h3 id="解决方案五：将reduce-join转为map-join"><a href="#解决方案五：将reduce-join转为map-join" class="headerlink" title="解决方案五：将reduce join转为map join"></a>解决方案五：将reduce join转为map join</h3><p><strong>方案适用场景：</strong>在对RDD使用join类操作，或者是在Spark SQL中使用join语句时，而且join操作中的一个RDD或表的数据量比较小（比如几百M或者一两G），比较适用此方案。</p><p><strong>方案实现思路：</strong>不使用join算子进行连接操作，而使用Broadcast变量与map类算子实现join操作，进而完全规避掉shuffle类的操作，彻底避免数据倾斜的发生和出现。将较小RDD中的数据直接通过collect算子拉取到Driver端的内存中来，然后对其创建一个Broadcast变量；接着对另外一个RDD执行map类算子，在算子函数内，从Broadcast变量中获取较小RDD的全量数据，与当前RDD的每一条数据按照连接key进行比对，如果连接key相同的话，那么就将两个RDD的数据用你需要的方式连接起来。</p><p><strong>方案实现原理：</strong>普通的join是会走shuffle过程的，而一旦shuffle，就相当于会将相同key的数据拉取到一个shuffle read task中再进行join，此时就是reduce join。但是如果一个RDD是比较小的，则可以采用广播小RDD全量数据+map算子来实现与join同样的效果，也就是map join，此时就不会发生shuffle操作，也就不会发生数据倾斜。具体原理如下图所示。</p><p><strong>方案优点：</strong>对join操作导致的数据倾斜，效果非常好，因为根本就不会发生shuffle，也就根本不会发生数据倾斜。</p><p><strong>方案缺点：</strong>适用场景较少，因为这个方案只适用于一个大表和一个小表的情况。毕竟我们需要将小表进行广播，此时会比较消耗内存资源，driver和每个Executor内存中都会驻留一份小RDD的全量数据。如果我们广播出去的RDD数据比较大，比如10G以上，那么就可能发生内存溢出了。因此并不适合两个都是大表的情况。</p><p><a href="https://tech.meituan.com/img/spark-tuning/shuffle-skwed-map-join.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/spark-tuning/shuffle-skwed-map-join.png" alt=""></a></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 首先将数据量比较小的RDD的数据，collect到Driver中来。</span></span><br><span class="line">List&gt; rdd1Data = rdd1.collect()</span><br><span class="line"><span class="comment">// 然后使用Spark的广播功能，将小RDD的数据转换成广播变量，这样每个Executor就只有一份RDD的数据。</span></span><br><span class="line"><span class="comment">// 可以尽可能节省内存空间，并且减少网络传输性能开销。</span></span><br><span class="line"><span class="keyword">final</span> Broadcast&gt;&gt; rdd1DataBroadcast = sc.broadcast(rdd1Data);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 对另外一个RDD执行map类操作，而不再是join类操作。</span></span><br><span class="line">JavaPairRDD&gt; joinedRdd = rdd2.mapToPair(</span><br><span class="line"> <span class="keyword">new</span> PairFunction, String, Tuple2&gt;() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="keyword">public</span> Tuple2&gt; call(Tuple2 tuple)</span><br><span class="line"> <span class="keyword">throws</span> Exception &#123;</span><br><span class="line"> <span class="comment">// 在算子函数中，通过广播变量，获取到本地Executor中的rdd1数据。</span></span><br><span class="line"> List&gt; rdd1Data = rdd1DataBroadcast.value();</span><br><span class="line"> <span class="comment">// 可以将rdd1的数据转换为一个Map，便于后面进行join操作。</span></span><br><span class="line"> Map rdd1DataMap = <span class="keyword">new</span> HashMap();</span><br><span class="line"> <span class="keyword">for</span>(Tuple2 data : rdd1Data) &#123;</span><br><span class="line"> rdd1DataMap.put(data._1, data._2);</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="comment">// 获取当前RDD数据的key以及value。</span></span><br><span class="line"> String key = tuple._1;</span><br><span class="line"> String value = tuple._2;</span><br><span class="line"> <span class="comment">// 从rdd1数据Map中，根据key获取到可以join到的数据。</span></span><br><span class="line"> Row rdd1Value = rdd1DataMap.get(key);</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> Tuple2(key, <span class="keyword">new</span> Tuple2(value, rdd1Value));</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 这里得提示一下。</span></span><br><span class="line"><span class="comment">// 上面的做法，仅仅适用于rdd1中的key没有重复，全部是唯一的场景。</span></span><br><span class="line"><span class="comment">// 如果rdd1中有多个相同的key，那么就得用flatMap类的操作，在进行join的时候不能用map，而是得遍历rdd1所有数据进行join。</span></span><br><span class="line"><span class="comment">// rdd2中每条数据都可能会返回多条join后的数据。</span></span><br></pre></td></tr></table></figure><h3 id="解决方案六：采样倾斜key并分拆join操作"><a href="#解决方案六：采样倾斜key并分拆join操作" class="headerlink" title="解决方案六：采样倾斜key并分拆join操作"></a>解决方案六：采样倾斜key并分拆join操作</h3><p><strong>方案适用场景：</strong>两个RDD/Hive表进行join的时候，如果数据量都比较大，无法采用“解决方案五”，那么此时可以看一下两个RDD/Hive表中的key分布情况。如果出现数据倾斜，是因为其中某一个RDD/Hive表中的少数几个key的数据量过大，而另一个RDD/Hive表中的所有key都分布比较均匀，那么采用这个解决方案是比较合适的。</p><p><strong>方案实现思路：</strong></p><ul><li>对包含少数几个数据量过大的key的那个RDD，通过sample算子采样出一份样本来，然后统计一下每个key的数量，计算出来数据量最大的是哪几个key。</li><li>然后将这几个key对应的数据从原来的RDD中拆分出来，形成一个单独的RDD，并给每个key都打上n以内的随机数作为前缀，而不会导致倾斜的大部分key形成另外一个RDD。</li><li>接着将需要join的另一个RDD，也过滤出来那几个倾斜key对应的数据并形成一个单独的RDD，将每条数据膨胀成n条数据，这n条数据都按顺序附加一个0~n的前缀，不会导致倾斜的大部分key也形成另外一个RDD。</li><li>再将附加了随机前缀的独立RDD与另一个膨胀n倍的独立RDD进行join，此时就可以将原先相同的key打散成n份，分散到多个task中去进行join了。</li><li>而另外两个普通的RDD就照常join即可。</li><li>最后将两次join的结果使用union算子合并起来即可，就是最终的join结果。</li></ul><p><strong>方案实现原理：</strong>对于join导致的数据倾斜，如果只是某几个key导致了倾斜，可以将少数几个key分拆成独立RDD，并附加随机前缀打散成n份去进行join，此时这几个key对应的数据就不会集中在少数几个task上，而是分散到多个task进行join了。具体原理见下图。</p><p><strong>方案优点：</strong>对于join导致的数据倾斜，如果只是某几个key导致了倾斜，采用该方式可以用最有效的方式打散key进行join。而且只需要针对少数倾斜key对应的数据进行扩容n倍，不需要对全量数据进行扩容。避免了占用过多内存。</p><p><strong>方案缺点：</strong>如果导致倾斜的key特别多的话，比如成千上万个key都导致数据倾斜，那么这种方式也不适合。</p><p><a href="https://tech.meituan.com/img/spark-tuning/shuffle-skwed-sample-expand.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/spark-tuning/shuffle-skwed-sample-expand.png" alt=""></a></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 首先从包含了少数几个导致数据倾斜key的rdd1中，采样10%的样本数据。</span></span><br><span class="line">JavaPairRDD sampledRDD = rdd1.sample(<span class="keyword">false</span>, <span class="number">0.1</span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 对样本数据RDD统计出每个key的出现次数，并按出现次数降序排序。</span></span><br><span class="line"><span class="comment">// 对降序排序后的数据，取出top 1或者top 100的数据，也就是key最多的前n个数据。</span></span><br><span class="line"><span class="comment">// 具体取出多少个数据量最多的key，由大家自己决定，我们这里就取1个作为示范。</span></span><br><span class="line">JavaPairRDD mappedSampledRDD = sampledRDD.mapToPair(</span><br><span class="line"> <span class="keyword">new</span> PairFunction, Long, Long&gt;() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> Tuple2 <span class="title">call</span><span class="params">(Tuple2 tuple)</span></span></span><br><span class="line"><span class="function"> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> Tuple2(tuple._1, <span class="number">1L</span>);</span><br><span class="line"> &#125; </span><br><span class="line"> &#125;);</span><br><span class="line">JavaPairRDD countedSampledRDD = mappedSampledRDD.reduceByKey(</span><br><span class="line"> <span class="keyword">new</span> Function2() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> Long <span class="title">call</span><span class="params">(Long v1, Long v2)</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line"> <span class="keyword">return</span> v1 + v2;</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line">JavaPairRDD reversedSampledRDD = countedSampledRDD.mapToPair( </span><br><span class="line"> <span class="keyword">new</span> PairFunction, Long, Long&gt;() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> Tuple2 <span class="title">call</span><span class="params">(Tuple2 tuple)</span></span></span><br><span class="line"><span class="function"> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> Tuple2(tuple._2, tuple._1);</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line"><span class="keyword">final</span> Long skewedUserid = reversedSampledRDD.sortByKey(<span class="keyword">false</span>).take(<span class="number">1</span>).get(<span class="number">0</span>)._2;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 从rdd1中分拆出导致数据倾斜的key，形成独立的RDD。</span></span><br><span class="line">JavaPairRDD skewedRDD = rdd1.filter(</span><br><span class="line"> <span class="keyword">new</span> Function, Boolean&gt;() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> Boolean <span class="title">call</span><span class="params">(Tuple2 tuple)</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line"> <span class="keyword">return</span> tuple._1.equals(skewedUserid);</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line"><span class="comment">// 从rdd1中分拆出不导致数据倾斜的普通key，形成独立的RDD。</span></span><br><span class="line">JavaPairRDD commonRDD = rdd1.filter(</span><br><span class="line"> <span class="keyword">new</span> Function, Boolean&gt;() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> Boolean <span class="title">call</span><span class="params">(Tuple2 tuple)</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line"> <span class="keyword">return</span> !tuple._1.equals(skewedUserid);</span><br><span class="line"> &#125; </span><br><span class="line"> &#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// rdd2，就是那个所有key的分布相对较为均匀的rdd。</span></span><br><span class="line"><span class="comment">// 这里将rdd2中，前面获取到的key对应的数据，过滤出来，分拆成单独的rdd，并对rdd中的数据使用flatMap算子都扩容100倍。</span></span><br><span class="line"><span class="comment">// 对扩容的每条数据，都打上0～100的前缀。</span></span><br><span class="line">JavaPairRDD skewedRdd2 = rdd2.filter(</span><br><span class="line"> <span class="keyword">new</span> Function, Boolean&gt;() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> Boolean <span class="title">call</span><span class="params">(Tuple2 tuple)</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line"> <span class="keyword">return</span> tuple._1.equals(skewedUserid);</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;).flatMapToPair(<span class="keyword">new</span> PairFlatMapFunction, String, Row&gt;() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="keyword">public</span> Iterable&gt; call(</span><br><span class="line"> Tuple2 tuple) <span class="keyword">throws</span> Exception &#123;</span><br><span class="line"> Random random = <span class="keyword">new</span> Random();</span><br><span class="line"> List&gt; list = <span class="keyword">new</span> ArrayList&gt;();</span><br><span class="line"> <span class="keyword">for</span>(<span class="keyword">int</span> i = <span class="number">0</span>; i </span><br><span class="line"> list.add(<span class="keyword">new</span> Tuple2(i + <span class="string">"_"</span> + tuple._1, tuple._2));</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="keyword">return</span> list;</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line"> &#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 将rdd1中分拆出来的导致倾斜的key的独立rdd，每条数据都打上100以内的随机前缀。</span></span><br><span class="line"><span class="comment">// 然后将这个rdd1中分拆出来的独立rdd，与上面rdd2中分拆出来的独立rdd，进行join。</span></span><br><span class="line">JavaPairRDD&gt; joinedRDD1 = skewedRDD.mapToPair(</span><br><span class="line"> <span class="keyword">new</span> PairFunction, String, String&gt;() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> Tuple2 <span class="title">call</span><span class="params">(Tuple2 tuple)</span></span></span><br><span class="line"><span class="function"> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line"> Random random = <span class="keyword">new</span> Random();</span><br><span class="line"> <span class="keyword">int</span> prefix = random.nextInt(<span class="number">100</span>);</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> Tuple2(prefix + <span class="string">"_"</span> + tuple._1, tuple._2);</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;)</span><br><span class="line"> .join(skewedUserid2infoRDD)</span><br><span class="line"> .mapToPair(<span class="keyword">new</span> PairFunction&gt;, Long, Tuple2&gt;() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="keyword">public</span> Tuple2&gt; call(</span><br><span class="line"> Tuple2&gt; tuple)</span><br><span class="line"> <span class="keyword">throws</span> Exception &#123;</span><br><span class="line"> <span class="keyword">long</span> key = Long.valueOf(tuple._1.split(<span class="string">"_"</span>)[<span class="number">1</span>]);</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> Tuple2&gt;(key, tuple._2);</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 将rdd1中分拆出来的包含普通key的独立rdd，直接与rdd2进行join。</span></span><br><span class="line">JavaPairRDD&gt; joinedRDD2 = commonRDD.join(rdd2);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 将倾斜key join后的结果与普通key join后的结果，uinon起来。</span></span><br><span class="line"><span class="comment">// 就是最终的join结果。</span></span><br><span class="line">JavaPairRDD&gt; joinedRDD = joinedRDD1.union(joinedRDD2);</span><br></pre></td></tr></table></figure><h3 id="解决方案七：使用随机前缀和扩容RDD进行join"><a href="#解决方案七：使用随机前缀和扩容RDD进行join" class="headerlink" title="解决方案七：使用随机前缀和扩容RDD进行join"></a>解决方案七：使用随机前缀和扩容RDD进行join</h3><p><strong>方案适用场景：</strong>如果在进行join操作时，RDD中有大量的key导致数据倾斜，那么进行分拆key也没什么意义，此时就只能使用最后一种方案来解决问题了。</p><p><strong>方案实现思路：</strong></p><ul><li>该方案的实现思路基本和“解决方案六”类似，首先查看RDD/Hive表中的数据分布情况，找到那个造成数据倾斜的RDD/Hive表，比如有多个key都对应了超过1万条数据。</li><li>然后将该RDD的每条数据都打上一个n以内的随机前缀。</li><li>同时对另外一个正常的RDD进行扩容，将每条数据都扩容成n条数据，扩容出来的每条数据都依次打上一个0~n的前缀。</li><li>最后将两个处理后的RDD进行join即可。</li></ul><p><strong>方案实现原理：</strong>将原先一样的key通过附加随机前缀变成不一样的key，然后就可以将这些处理后的“不同key”分散到多个task中去处理，而不是让一个task处理大量的相同key。该方案与“解决方案六”的不同之处就在于，上一种方案是尽量只对少数倾斜key对应的数据进行特殊处理，由于处理过程需要扩容RDD，因此上一种方案扩容RDD后对内存的占用并不大；而这一种方案是针对有大量倾斜key的情况，没法将部分key拆分出来进行单独处理，因此只能对整个RDD进行数据扩容，对内存资源要求很高。</p><p><strong>方案优点：</strong>对join类型的数据倾斜基本都可以处理，而且效果也相对比较显著，性能提升效果非常不错。</p><p><strong>方案缺点：</strong>该方案更多的是缓解数据倾斜，而不是彻底避免数据倾斜。而且需要对整个RDD进行扩容，对内存资源要求很高。</p><p><strong>方案实践经验：</strong>曾经开发一个数据需求的时候，发现一个join导致了数据倾斜。优化之前，作业的执行时间大约是60分钟左右；使用该方案优化之后，执行时间缩短到10分钟左右，性能提升了6倍。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 首先将其中一个key分布相对较为均匀的RDD膨胀100倍。</span></span><br><span class="line">JavaPairRDD expandedRDD = rdd1.flatMapToPair(</span><br><span class="line"> <span class="keyword">new</span> PairFlatMapFunction, String, Row&gt;() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="keyword">public</span> Iterable&gt; call(Tuple2 tuple)</span><br><span class="line"> <span class="keyword">throws</span> Exception &#123;</span><br><span class="line"> List&gt; list = <span class="keyword">new</span> ArrayList&gt;();</span><br><span class="line"> <span class="keyword">for</span>(<span class="keyword">int</span> i = <span class="number">0</span>; i </span><br><span class="line"> list.add(<span class="keyword">new</span> Tuple2(<span class="number">0</span> + <span class="string">"_"</span> + tuple._1, tuple._2));</span><br><span class="line"> &#125;</span><br><span class="line"> <span class="keyword">return</span> list;</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 其次，将另一个有数据倾斜key的RDD，每条数据都打上100以内的随机前缀。</span></span><br><span class="line">JavaPairRDD mappedRDD = rdd2.mapToPair(</span><br><span class="line"> <span class="keyword">new</span> PairFunction, String, String&gt;() &#123;</span><br><span class="line"> <span class="keyword">private</span> <span class="keyword">static</span> <span class="keyword">final</span> <span class="keyword">long</span> serialVersionUID = <span class="number">1L</span>;</span><br><span class="line"> <span class="meta">@Override</span></span><br><span class="line"> <span class="function"><span class="keyword">public</span> Tuple2 <span class="title">call</span><span class="params">(Tuple2 tuple)</span></span></span><br><span class="line"><span class="function"> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line"> Random random = <span class="keyword">new</span> Random();</span><br><span class="line"> <span class="keyword">int</span> prefix = random.nextInt(<span class="number">100</span>);</span><br><span class="line"> <span class="keyword">return</span> <span class="keyword">new</span> Tuple2(prefix + <span class="string">"_"</span> + tuple._1, tuple._2);</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 将两个处理后的RDD进行join即可。</span></span><br><span class="line">JavaPairRDD&gt; joinedRDD = mappedRDD.join(expandedRDD);</span><br></pre></td></tr></table></figure><h3 id="解决方案八：多种方案组合使用"><a href="#解决方案八：多种方案组合使用" class="headerlink" title="解决方案八：多种方案组合使用"></a>解决方案八：多种方案组合使用</h3><p>在实践中发现，很多情况下，如果只是处理较为简单的数据倾斜场景，那么使用上述方案中的某一种基本就可以解决。但是如果要处理一个较为复杂的数据倾斜场景，那么可能需要将多种方案组合起来使用。比如说，我们针对出现了多个数据倾斜环节的Spark作业，可以先运用解决方案一和二，预处理一部分数据，并过滤一部分数据来缓解；其次可以对某些shuffle操作提升并行度，优化其性能；最后还可以针对不同的聚合或join操作，选择一种方案来优化其性能。大家需要对这些方案的思路和原理都透彻理解之后，在实践中根据各种不同的情况，灵活运用多种方案，来解决自己的数据倾斜问题。</p><h1 id="shuffle调优"><a href="#shuffle调优" class="headerlink" title="shuffle调优"></a>shuffle调优</h1><h2 id="调优概述-1"><a href="#调优概述-1" class="headerlink" title="调优概述"></a>调优概述</h2><p>大多数Spark作业的性能主要就是消耗在了shuffle环节，因为该环节包含了大量的磁盘IO、序列化、网络数据传输等操作。因此，如果要让作业的性能更上一层楼，就有必要对shuffle过程进行调优。但是也必须提醒大家的是，影响一个Spark作业性能的因素，主要还是代码开发、资源参数以及数据倾斜，shuffle调优只能在整个Spark的性能调优中占到一小部分而已。因此大家务必把握住调优的基本原则，千万不要舍本逐末。下面我们就给大家详细讲解shuffle的原理，以及相关参数的说明，同时给出各个参数的调优建议。</p><h2 id="ShuffleManager发展概述"><a href="#ShuffleManager发展概述" class="headerlink" title="ShuffleManager发展概述"></a>ShuffleManager发展概述</h2><p>在Spark的源码中，负责shuffle过程的执行、计算和处理的组件主要就是ShuffleManager，也即shuffle管理器。而随着Spark的版本的发展，ShuffleManager也在不断迭代，变得越来越先进。</p><p>在Spark 1.2以前，默认的shuffle计算引擎是HashShuffleManager。该ShuffleManager而HashShuffleManager有着一个非常严重的弊端，就是会产生大量的中间磁盘文件，进而由大量的磁盘IO操作影响了性能。</p><p>因此在Spark 1.2以后的版本中，默认的ShuffleManager改成了SortShuffleManager。SortShuffleManager相较于HashShuffleManager来说，有了一定的改进。主要就在于，每个Task在进行shuffle操作时，虽然也会产生较多的临时磁盘文件，但是最后会将所有的临时文件合并（merge）成一个磁盘文件，因此每个Task就只有一个磁盘文件。在下一个stage的shuffle read task拉取自己的数据时，只要根据索引读取每个磁盘文件中的部分数据即可。</p><p>下面我们详细分析一下HashShuffleManager和SortShuffleManager的原理。</p><h2 id="HashShuffleManager运行原理"><a href="#HashShuffleManager运行原理" class="headerlink" title="HashShuffleManager运行原理"></a>HashShuffleManager运行原理</h2><h3 id="未经优化的HashShuffleManager"><a href="#未经优化的HashShuffleManager" class="headerlink" title="未经优化的HashShuffleManager"></a>未经优化的HashShuffleManager</h3><p>下图说明了未经优化的HashShuffleManager的原理。这里我们先明确一个假设前提：每个Executor只有1个CPU core，也就是说，无论这个Executor上分配多少个task线程，同一时间都只能执行一个task线程。</p><p>我们先从shuffle write开始说起。shuffle write阶段，主要就是在一个stage结束计算之后，为了下一个stage可以执行shuffle类的算子（比如reduceByKey），而将每个task处理的数据按key进行“分类”。所谓“分类”，就是对相同的key执行hash算法，从而将相同key都写入同一个磁盘文件中，而每一个磁盘文件都只属于下游stage的一个task。在将数据写入磁盘之前，会先将数据写入内存缓冲中，当内存缓冲填满之后，才会溢写到磁盘文件中去。</p><p>那么每个执行shuffle write的task，要为下一个stage创建多少个磁盘文件呢？很简单，下一个stage的task有多少个，当前stage的每个task就要创建多少份磁盘文件。比如下一个stage总共有100个task，那么当前stage的每个task都要创建100份磁盘文件。如果当前stage有50个task，总共有10个Executor，每个Executor执行5个Task，那么每个Executor上总共就要创建500个磁盘文件，所有Executor上会创建5000个磁盘文件。由此可见，未经优化的shuffle write操作所产生的磁盘文件的数量是极其惊人的。</p><p>接着我们来说说shuffle read。shuffle read，通常就是一个stage刚开始时要做的事情。此时该stage的每一个task就需要将上一个stage的计算结果中的所有相同key，从各个节点上通过网络都拉取到自己所在的节点上，然后进行key的聚合或连接等操作。由于shuffle write的过程中，task给下游stage的每个task都创建了一个磁盘文件，因此shuffle read的过程中，每个task只要从上游stage的所有task所在节点上，拉取属于自己的那一个磁盘文件即可。</p><p>shuffle read的拉取过程是一边拉取一边进行聚合的。每个shuffle read task都会有一个自己的buffer缓冲，每次都只能拉取与buffer缓冲相同大小的数据，然后通过内存中的一个Map进行聚合等操作。聚合完一批数据后，再拉取下一批数据，并放到buffer缓冲中进行聚合操作。以此类推，直到最后将所有数据到拉取完，并得到最终的结果。</p><p><a href="https://tech.meituan.com/img/spark-tuning/hash-shuffle-common.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/spark-tuning/hash-shuffle-common.png" alt=""></a></p><h3 id="优化后的HashShuffleManager"><a href="#优化后的HashShuffleManager" class="headerlink" title="优化后的HashShuffleManager"></a>优化后的HashShuffleManager</h3><p>下图说明了优化后的HashShuffleManager的原理。这里说的优化，是指我们可以设置一个参数，spark.shuffle.consolidateFiles。该参数默认值为false，将其设置为true即可开启优化机制。通常来说，如果我们使用HashShuffleManager，那么都建议开启这个选项。</p><p>开启consolidate机制之后，在shuffle write过程中，task就不是为下游stage的每个task创建一个磁盘文件了。此时会出现shuffleFileGroup的概念，每个shuffleFileGroup会对应一批磁盘文件，磁盘文件的数量与下游stage的task数量是相同的。一个Executor上有多少个CPU core，就可以并行执行多少个task。而第一批并行执行的每个task都会创建一个shuffleFileGroup，并将数据写入对应的磁盘文件内。</p><p>当Executor的CPU core执行完一批task，接着执行下一批task时，下一批task就会复用之前已有的shuffleFileGroup，包括其中的磁盘文件。也就是说，此时task会将数据写入已有的磁盘文件中，而不会写入新的磁盘文件中。因此，consolidate机制允许不同的task复用同一批磁盘文件，这样就可以有效将多个task的磁盘文件进行一定程度上的合并，从而大幅度减少磁盘文件的数量，进而提升shuffle write的性能。</p><p>假设第二个stage有100个task，第一个stage有50个task，总共还是有10个Executor，每个Executor执行5个task。那么原本使用未经优化的HashShuffleManager时，每个Executor会产生500个磁盘文件，所有Executor会产生5000个磁盘文件的。但是此时经过优化之后，每个Executor创建的磁盘文件的数量的计算公式为：CPU core的数量 * 下一个stage的task数量。也就是说，每个Executor此时只会创建100个磁盘文件，所有Executor只会创建1000个磁盘文件。</p><p><a href="https://tech.meituan.com/img/spark-tuning/hash-shuffle-consolidate.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/spark-tuning/hash-shuffle-consolidate.png" alt=""></a></p><h2 id="SortShuffleManager运行原理"><a href="#SortShuffleManager运行原理" class="headerlink" title="SortShuffleManager运行原理"></a>SortShuffleManager运行原理</h2><p>SortShuffleManager的运行机制主要分成两种，一种是普通运行机制，另一种是bypass运行机制。当shuffle read task的数量小于等于spark.shuffle.sort.bypassMergeThreshold参数的值时（默认为200），就会启用bypass机制。</p><h3 id="普通运行机制"><a href="#普通运行机制" class="headerlink" title="普通运行机制"></a>普通运行机制</h3><p>下图说明了普通的SortShuffleManager的原理。在该模式下，数据会先写入一个内存数据结构中，此时根据不同的shuffle算子，可能选用不同的数据结构。如果是reduceByKey这种聚合类的shuffle算子，那么会选用Map数据结构，一边通过Map进行聚合，一边写入内存；如果是join这种普通的shuffle算子，那么会选用Array数据结构，直接写入内存。接着，每写一条数据进入内存数据结构之后，就会判断一下，是否达到了某个临界阈值。如果达到临界阈值的话，那么就会尝试将内存数据结构中的数据溢写到磁盘，然后清空内存数据结构。</p><p>在溢写到磁盘文件之前，会先根据key对内存数据结构中已有的数据进行排序。排序过后，会分批将数据写入磁盘文件。默认的batch数量是10000条，也就是说，排序好的数据，会以每批1万条数据的形式分批写入磁盘文件。写入磁盘文件是通过Java的BufferedOutputStream实现的。BufferedOutputStream是Java的缓冲输出流，首先会将数据缓冲在内存中，当内存缓冲满溢之后再一次写入磁盘文件中，这样可以减少磁盘IO次数，提升性能。</p><p>一个task将所有数据写入内存数据结构的过程中，会发生多次磁盘溢写操作，也就会产生多个临时文件。最后会将之前所有的临时磁盘文件都进行合并，这就是merge过程，此时会将之前所有临时磁盘文件中的数据读取出来，然后依次写入最终的磁盘文件之中。此外，由于一个task就只对应一个磁盘文件，也就意味着该task为下游stage的task准备的数据都在这一个文件中，因此还会单独写一份索引文件，其中标识了下游各个task的数据在文件中的start offset与end offset。</p><p>SortShuffleManager由于有一个磁盘文件merge的过程，因此大大减少了文件数量。比如第一个stage有50个task，总共有10个Executor，每个Executor执行5个task，而第二个stage有100个task。由于每个task最终只有一个磁盘文件，因此此时每个Executor上只有5个磁盘文件，所有Executor只有50个磁盘文件。<br><a href="https://tech.meituan.com/img/spark-tuning/sort-shuffle-common.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/spark-tuning/sort-shuffle-common.png" alt=""></a></p><h3 id="bypass运行机制"><a href="#bypass运行机制" class="headerlink" title="bypass运行机制"></a>bypass运行机制</h3><p>下图说明了bypass SortShuffleManager的原理。bypass运行机制的触发条件如下：</p><ul><li>shuffle map task数量小于spark.shuffle.sort.bypassMergeThreshold参数的值。</li><li>不是聚合类的shuffle算子（比如reduceByKey）。</li></ul><p>此时task会为每个下游task都创建一个临时磁盘文件，并将数据按key进行hash然后根据key的hash值，将key写入对应的磁盘文件之中。当然，写入磁盘文件时也是先写入内存缓冲，缓冲写满之后再溢写到磁盘文件的。最后，同样会将所有临时磁盘文件都合并成一个磁盘文件，并创建一个单独的索引文件。</p><p>该过程的磁盘写机制其实跟未经优化的HashShuffleManager是一模一样的，因为都要创建数量惊人的磁盘文件，只是在最后会做一个磁盘文件的合并而已。因此少量的最终磁盘文件，也让该机制相对未经优化的HashShuffleManager来说，shuffle read的性能会更好。</p><p>而该机制与普通SortShuffleManager运行机制的不同在于：第一，磁盘写机制不同；第二，不会进行排序。也就是说，启用该机制的最大好处在于，shuffle write过程中，不需要进行数据的排序操作，也就节省掉了这部分的性能开销。<br><a href="https://tech.meituan.com/img/spark-tuning/sort-shuffle-bypass.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/spark-tuning/sort-shuffle-bypass.png" alt=""></a></p><h2 id="shuffle相关参数调优"><a href="#shuffle相关参数调优" class="headerlink" title="shuffle相关参数调优"></a>shuffle相关参数调优</h2><p>以下是Shffule过程中的一些主要参数，这里详细讲解了各个参数的功能、默认值以及基于实践经验给出的调优建议。</p><h3 id="spark-shuffle-file-buffer"><a href="#spark-shuffle-file-buffer" class="headerlink" title="spark.shuffle.file.buffer"></a>spark.shuffle.file.buffer</h3><ul><li>默认值：32k</li><li>参数说明：该参数用于设置shuffle write task的BufferedOutputStream的buffer缓冲大小。将数据写到磁盘文件之前，会先写入buffer缓冲中，待缓冲写满之后，才会溢写到磁盘。</li><li>调优建议：如果作业可用的内存资源较为充足的话，可以适当增加这个参数的大小（比如64k），从而减少shuffle write过程中溢写磁盘文件的次数，也就可以减少磁盘IO次数，进而提升性能。在实践中发现，合理调节该参数，性能会有1%~5%的提升。</li></ul><h3 id="spark-reducer-maxSizeInFlight"><a href="#spark-reducer-maxSizeInFlight" class="headerlink" title="spark.reducer.maxSizeInFlight"></a>spark.reducer.maxSizeInFlight</h3><ul><li>默认值：48m</li><li>参数说明：该参数用于设置shuffle read task的buffer缓冲大小，而这个buffer缓冲决定了每次能够拉取多少数据。</li><li>调优建议：如果作业可用的内存资源较为充足的话，可以适当增加这个参数的大小（比如96m），从而减少拉取数据的次数，也就可以减少网络传输的次数，进而提升性能。在实践中发现，合理调节该参数，性能会有1%~5%的提升。</li></ul><h3 id="spark-shuffle-io-maxRetries"><a href="#spark-shuffle-io-maxRetries" class="headerlink" title="spark.shuffle.io.maxRetries"></a>spark.shuffle.io.maxRetries</h3><ul><li>默认值：3</li><li>参数说明：shuffle read task从shuffle write task所在节点拉取属于自己的数据时，如果因为网络异常导致拉取失败，是会自动进行重试的。该参数就代表了可以重试的最大次数。如果在指定次数之内拉取还是没有成功，就可能会导致作业执行失败。</li><li>调优建议：对于那些包含了特别耗时的shuffle操作的作业，建议增加重试最大次数（比如60次），以避免由于JVM的full gc或者网络不稳定等因素导致的数据拉取失败。在实践中发现，对于针对超大数据量（数十亿~上百亿）的shuffle过程，调节该参数可以大幅度提升稳定性。</li></ul><h3 id="spark-shuffle-io-retryWait"><a href="#spark-shuffle-io-retryWait" class="headerlink" title="spark.shuffle.io.retryWait"></a>spark.shuffle.io.retryWait</h3><ul><li>默认值：5s</li><li>参数说明：具体解释同上，该参数代表了每次重试拉取数据的等待间隔，默认是5s。</li><li>调优建议：建议加大间隔时长（比如60s），以增加shuffle操作的稳定性。</li></ul><h3 id="spark-shuffle-memoryFraction"><a href="#spark-shuffle-memoryFraction" class="headerlink" title="spark.shuffle.memoryFraction"></a>spark.shuffle.memoryFraction</h3><ul><li>默认值：0.2</li><li>参数说明：该参数代表了Executor内存中，分配给shuffle read task进行聚合操作的内存比例，默认是20%。</li><li>调优建议：在资源参数调优中讲解过这个参数。如果内存充足，而且很少使用持久化操作，建议调高这个比例，给shuffle read的聚合操作更多内存，以避免由于内存不足导致聚合过程中频繁读写磁盘。在实践中发现，合理调节该参数可以将性能提升10%左右。</li></ul><h3 id="spark-shuffle-manager"><a href="#spark-shuffle-manager" class="headerlink" title="spark.shuffle.manager"></a>spark.shuffle.manager</h3><ul><li>默认值：sort</li><li>参数说明：该参数用于设置ShuffleManager的类型。Spark 1.5以后，有三个可选项：hash、sort和tungsten-sort。HashShuffleManager是Spark 1.2以前的默认选项，但是Spark 1.2以及之后的版本默认都是SortShuffleManager了。tungsten-sort与sort类似，但是使用了tungsten计划中的堆外内存管理机制，内存使用效率更高。</li><li>调优建议：由于SortShuffleManager默认会对数据进行排序，因此如果你的业务逻辑中需要该排序机制的话，则使用默认的SortShuffleManager就可以；而如果你的业务逻辑不需要对数据进行排序，那么建议参考后面的几个参数调优，通过bypass机制或优化的HashShuffleManager来避免排序操作，同时提供较好的磁盘读写性能。这里要注意的是，tungsten-sort要慎用，因为之前发现了一些相应的bug。</li></ul><h3 id="spark-shuffle-sort-bypassMergeThreshold"><a href="#spark-shuffle-sort-bypassMergeThreshold" class="headerlink" title="spark.shuffle.sort.bypassMergeThreshold"></a>spark.shuffle.sort.bypassMergeThreshold</h3><ul><li>默认值：200</li><li>参数说明：当ShuffleManager为SortShuffleManager时，如果shuffle read task的数量小于这个阈值（默认是200），则shuffle write过程中不会进行排序操作，而是直接按照未经优化的HashShuffleManager的方式去写数据，但是最后会将每个task产生的所有临时磁盘文件都合并成一个文件，并会创建单独的索引文件。</li><li>调优建议：当你使用SortShuffleManager时，如果的确不需要排序操作，那么建议将这个参数调大一些，大于shuffle read task的数量。那么此时就会自动启用bypass机制，map-side就不会进行排序了，减少了排序的性能开销。但是这种方式下，依然会产生大量的磁盘文件，因此shuffle write性能有待提高。</li></ul><h3 id="spark-shuffle-consolidateFiles"><a href="#spark-shuffle-consolidateFiles" class="headerlink" title="spark.shuffle.consolidateFiles"></a>spark.shuffle.consolidateFiles</h3><ul><li>默认值：false</li><li>参数说明：如果使用HashShuffleManager，该参数有效。如果设置为true，那么就会开启consolidate机制，会大幅度合并shuffle write的输出文件，对于shuffle read task数量特别多的情况下，这种方法可以极大地减少磁盘IO开销，提升性能。</li><li>调优建议：如果的确不需要SortShuffleManager的排序机制，那么除了使用bypass机制，还可以尝试将spark.shffle.manager参数手动指定为hash，使用HashShuffleManager，同时开启consolidate机制。在实践中尝试过，发现其性能比开启了bypass机制的SortShuffleManager要高出10%~30%。</li></ul><h1 id="写在最后的话"><a href="#写在最后的话" class="headerlink" title="写在最后的话"></a>写在最后的话</h1><p>本文分别讲解了开发过程中的优化原则、运行前的资源参数设置调优、运行中的数据倾斜的解决方案、为了精益求精的shuffle调优。希望大家能够在阅读本文之后，记住这些性能调优的原则以及方案，在Spark作业开发、测试以及运行的过程中多尝试，只有这样，我们才能开发出更优的Spark作业，不断提升其性能。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;继基础篇讲解了每个Spark开发人员都必须熟知的开发调优与资源调优之后，本文作为《Spark性能优化指南》的高级篇，将深入分析数据倾斜调优与shuffle调优，以解决更加棘手的性能问题&lt;br&gt;
    
    </summary>
    
      <category term="spark" scheme="http://crazycarry.github.io/categories/spark/"/>
    
    
      <category term="大数据" scheme="http://crazycarry.github.io/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
    
      <category term="性能优化" scheme="http://crazycarry.github.io/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"/>
    
      <category term="spark" scheme="http://crazycarry.github.io/tags/spark/"/>
    
  </entry>
  
  <entry>
    <title>Spark性能优化指南——基础篇</title>
    <link href="http://crazycarry.github.io/2018/04/04/spark-performance-01/"/>
    <id>http://crazycarry.github.io/2018/04/04/spark-performance-01/</id>
    <published>2018-04-04T05:56:57.000Z</published>
    <updated>2018-04-04T06:20:44.679Z</updated>
    
    <content type="html"><![CDATA[<p>在大数据计算领域，Spark已经成为了越来越流行、越来越受欢迎的计算平台之一。Spark的功能涵盖了大数据领域的离线批处理、SQL类处理、流式/实时计算、机器学习、图计算等各种不同类型的计算操作，应用范围与前景非常广泛。在美团•大众点评，已经有很多同学在各种项目中尝试使用Spark。大多数同学（包括笔者在内），最初开始尝试使用Spark的原因很简单，主要就是为了让大数据计算作业的执行速度更快、性能更高。</p><p>然而，通过Spark开发出高性能的大数据计算作业，并不是那么简单的。如果没有对Spark作业进行合理的调优，Spark作业的执行速度可能会很慢，这样就完全体现不出Spark作为一种快速大数据计算引擎的优势来。因此，想要用好Spark，就必须对其进行合理的性能优化。</p><p>Spark的性能调优实际上是由很多部分组成的，不是调节几个参数就可以立竿见影提升作业性能的。我们需要根据不同的业务场景以及数据情况，对Spark作业进行综合性的分析，然后进行多个方面的调节和优化，才能获得最佳性能。</p><p>笔者根据之前的Spark作业开发经验以及实践积累，总结出了一套Spark作业的性能优化方案。整套方案主要分为开发调优、资源调优、数据倾斜调优、shuffle调优几个部分。开发调优和资源调优是所有Spark作业都需要注意和遵循的一些基本原则，是高性能Spark作业的基础；数据倾斜调优，主要讲解了一套完整的用来解决Spark作业数据倾斜的解决方案；shuffle调优，面向的是对Spark的原理有较深层次掌握和研究的同学，主要讲解了如何对Spark作业的shuffle运行过程以及细节进行调优。<br><a id="more"></a></p><p>本文作为Spark性能优化指南的基础篇，主要讲解开发调优以及资源调优。</p><h1 id="开发调优"><a href="#开发调优" class="headerlink" title="开发调优"></a>开发调优</h1><h2 id="调优概述"><a href="#调优概述" class="headerlink" title="调优概述"></a>调优概述</h2><p>Spark性能优化的第一步，就是要在开发Spark作业的过程中注意和应用一些性能优化的基本原则。开发调优，就是要让大家了解以下一些Spark基本开发原则，包括：RDD lineage设计、算子的合理使用、特殊操作的优化等。在开发过程中，时时刻刻都应该注意以上原则，并将这些原则根据具体的业务以及实际的应用场景，灵活地运用到自己的Spark作业中。</p><h2 id="原则一：避免创建重复的RDD"><a href="#原则一：避免创建重复的RDD" class="headerlink" title="原则一：避免创建重复的RDD"></a>原则一：避免创建重复的RDD</h2><p>通常来说，我们在开发一个Spark作业时，首先是基于某个数据源（比如Hive表或HDFS文件）创建一个初始的RDD；接着对这个RDD执行某个算子操作，然后得到下一个RDD；以此类推，循环往复，直到计算出最终我们需要的结果。在这个过程中，多个RDD会通过不同的算子操作（比如map、reduce等）串起来，这个“RDD串”，就是RDD lineage，也就是“RDD的血缘关系链”。</p><p>我们在开发过程中要注意：对于同一份数据，只应该创建一个RDD，不能创建多个RDD来代表同一份数据。</p><p>一些Spark初学者在刚开始开发Spark作业时，或者是有经验的工程师在开发RDD lineage极其冗长的Spark作业时，可能会忘了自己之前对于某一份数据已经创建过一个RDD了，从而导致对于同一份数据，创建了多个RDD。这就意味着，我们的Spark作业会进行多次重复计算来创建多个代表相同数据的RDD，进而增加了作业的性能开销。</p><h3 id="一个简单的例子"><a href="#一个简单的例子" class="headerlink" title="一个简单的例子"></a>一个简单的例子</h3><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 需要对名为“hello.txt”的HDFS文件进行一次map操作，再进行一次reduce操作。也就是说，需要对一份数据执行两次算子操作。</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 错误的做法：对于同一份数据执行多次算子操作时，创建多个RDD。</span></span><br><span class="line"><span class="comment">// 这里执行了两次textFile方法，针对同一个HDFS文件，创建了两个RDD出来，然后分别对每个RDD都执行了一个算子操作。</span></span><br><span class="line"><span class="comment">// 这种情况下，Spark需要从HDFS上两次加载hello.txt文件的内容，并创建两个单独的RDD；第二次加载HDFS文件以及创建RDD的性能开销，很明显是白白浪费掉的。</span></span><br><span class="line"><span class="keyword">val</span> rdd1 = sc.textFile(<span class="string">"hdfs://192.168.0.1:9000/hello.txt"</span>)</span><br><span class="line">rdd1.map(...)</span><br><span class="line"><span class="keyword">val</span> rdd2 = sc.textFile(<span class="string">"hdfs://192.168.0.1:9000/hello.txt"</span>)</span><br><span class="line">rdd2.reduce(...)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 正确的用法：对于一份数据执行多次算子操作时，只使用一个RDD。</span></span><br><span class="line"><span class="comment">// 这种写法很明显比上一种写法要好多了，因为我们对于同一份数据只创建了一个RDD，然后对这一个RDD执行了多次算子操作。</span></span><br><span class="line"><span class="comment">// 但是要注意到这里为止优化还没有结束，由于rdd1被执行了两次算子操作，第二次执行reduce操作的时候，还会再次从源头处重新计算一次rdd1的数据，因此还是会有重复计算的性能开销。</span></span><br><span class="line"><span class="comment">// 要彻底解决这个问题，必须结合“原则三：对多次使用的RDD进行持久化”，才能保证一个RDD被多次使用时只被计算一次。</span></span><br><span class="line"><span class="keyword">val</span> rdd1 = sc.textFile(<span class="string">"hdfs://192.168.0.1:9000/hello.txt"</span>)</span><br><span class="line">rdd1.map(...)</span><br><span class="line">rdd1.reduce(...)</span><br></pre></td></tr></table></figure><h2 id="原则二：尽可能复用同一个RDD"><a href="#原则二：尽可能复用同一个RDD" class="headerlink" title="原则二：尽可能复用同一个RDD"></a>原则二：尽可能复用同一个RDD</h2><p>除了要避免在开发过程中对一份完全相同的数据创建多个RDD之外，在对不同的数据执行算子操作时还要尽可能地复用一个RDD。比如说，有一个RDD的数据格式是key-value类型的，另一个是单value类型的，这两个RDD的value数据是完全一样的。那么此时我们可以只使用key-value类型的那个RDD，因为其中已经包含了另一个的数据。对于类似这种多个RDD的数据有重叠或者包含的情况，我们应该尽量复用一个RDD，这样可以尽可能地减少RDD的数量，从而尽可能减少算子执行的次数。</p><h3 id="一个简单的例子-1"><a href="#一个简单的例子-1" class="headerlink" title="一个简单的例子"></a>一个简单的例子</h3><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 错误的做法。</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 有一个格式的RDD，即rdd1。</span></span><br><span class="line"><span class="comment">// 接着由于业务需要，对rdd1执行了一个map操作，创建了一个rdd2，而rdd2中的数据仅仅是rdd1中的value值而已，也就是说，rdd2是rdd1的子集。</span></span><br><span class="line"><span class="type">JavaPairRDD</span> rdd1 = ...</span><br><span class="line"><span class="type">JavaRDD</span> rdd2 = rdd1.map(...)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 分别对rdd1和rdd2执行了不同的算子操作。</span></span><br><span class="line">rdd1.reduceByKey(...)</span><br><span class="line">rdd2.map(...)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 正确的做法。</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 上面这个case中，其实rdd1和rdd2的区别无非就是数据格式不同而已，rdd2的数据完全就是rdd1的子集而已，却创建了两个rdd，并对两个rdd都执行了一次算子操作。</span></span><br><span class="line"><span class="comment">// 此时会因为对rdd1执行map算子来创建rdd2，而多执行一次算子操作，进而增加性能开销。</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 其实在这种情况下完全可以复用同一个RDD。</span></span><br><span class="line"><span class="comment">// 我们可以使用rdd1，既做reduceByKey操作，也做map操作。</span></span><br><span class="line"><span class="comment">// 在进行第二个map操作时，只使用每个数据的tuple._2，也就是rdd1中的value值，即可。</span></span><br><span class="line"><span class="type">JavaPairRDD</span> rdd1 = ...</span><br><span class="line">rdd1.reduceByKey(...)</span><br><span class="line">rdd1.map(tuple._2...)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 第二种方式相较于第一种方式而言，很明显减少了一次rdd2的计算开销。</span></span><br><span class="line"><span class="comment">// 但是到这里为止，优化还没有结束，对rdd1我们还是执行了两次算子操作，rdd1实际上还是会被计算两次。</span></span><br><span class="line"><span class="comment">// 因此还需要配合“原则三：对多次使用的RDD进行持久化”进行使用，才能保证一个RDD被多次使用时只被计算一次。</span></span><br></pre></td></tr></table></figure><h2 id="原则三：对多次使用的RDD进行持久化"><a href="#原则三：对多次使用的RDD进行持久化" class="headerlink" title="原则三：对多次使用的RDD进行持久化"></a>原则三：对多次使用的RDD进行持久化</h2><p>当你在Spark代码中多次对一个RDD做了算子操作后，恭喜，你已经实现Spark作业第一步的优化了，也就是尽可能复用RDD。此时就该在这个基础之上，进行第二步优化了，也就是要保证对一个RDD执行多次算子操作时，这个RDD本身仅仅被计算一次。</p><p>Spark中对于一个RDD执行多次算子的默认原理是这样的：每次你对一个RDD执行一个算子操作时，都会重新从源头处计算一遍，计算出那个RDD来，然后再对这个RDD执行你的算子操作。这种方式的性能是很差的。</p><p>因此对于这种情况，我们的建议是：对多次使用的RDD进行持久化。此时Spark就会根据你的持久化策略，将RDD中的数据保存到内存或者磁盘中。以后每次对这个RDD进行算子操作时，都会直接从内存或磁盘中提取持久化的RDD数据，然后执行算子，而不会从源头处重新计算一遍这个RDD，再执行算子操作。</p><h3 id="对多次使用的RDD进行持久化的代码示例"><a href="#对多次使用的RDD进行持久化的代码示例" class="headerlink" title="对多次使用的RDD进行持久化的代码示例"></a>对多次使用的RDD进行持久化的代码示例</h3><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 如果要对一个RDD进行持久化，只要对这个RDD调用cache()和persist()即可。</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 正确的做法。</span></span><br><span class="line"><span class="comment">// cache()方法表示：使用非序列化的方式将RDD中的数据全部尝试持久化到内存中。</span></span><br><span class="line"><span class="comment">// 此时再对rdd1执行两次算子操作时，只有在第一次执行map算子时，才会将这个rdd1从源头处计算一次。</span></span><br><span class="line"><span class="comment">// 第二次执行reduce算子时，就会直接从内存中提取数据进行计算，不会重复计算一个rdd。</span></span><br><span class="line"><span class="keyword">val</span> rdd1 = sc.textFile(<span class="string">"hdfs://192.168.0.1:9000/hello.txt"</span>).cache()</span><br><span class="line">rdd1.map(...)</span><br><span class="line">rdd1.reduce(...)</span><br><span class="line"></span><br><span class="line"><span class="comment">// persist()方法表示：手动选择持久化级别，并使用指定的方式进行持久化。</span></span><br><span class="line"><span class="comment">// 比如说，StorageLevel.MEMORY_AND_DISK_SER表示，内存充足时优先持久化到内存中，内存不充足时持久化到磁盘文件中。</span></span><br><span class="line"><span class="comment">// 而且其中的_SER后缀表示，使用序列化的方式来保存RDD数据，此时RDD中的每个partition都会序列化成一个大的字节数组，然后再持久化到内存或磁盘中。</span></span><br><span class="line"><span class="comment">// 序列化的方式可以减少持久化的数据对内存/磁盘的占用量，进而避免内存被持久化数据占用过多，从而发生频繁GC。</span></span><br><span class="line"><span class="keyword">val</span> rdd1 = sc.textFile(<span class="string">"hdfs://192.168.0.1:9000/hello.txt"</span>).persist(<span class="type">StorageLevel</span>.<span class="type">MEMORY_AND_DISK_SER</span>)</span><br><span class="line">rdd1.map(...)</span><br><span class="line">rdd1.reduce(...)</span><br></pre></td></tr></table></figure><p>对于persist()方法而言，我们可以根据不同的业务场景选择不同的持久化级别。</p><h3 id="Spark的持久化级别"><a href="#Spark的持久化级别" class="headerlink" title="Spark的持久化级别"></a>Spark的持久化级别</h3><table><thead><tr><th style="text-align:left">持久化级别</th><th style="text-align:left">含义解释</th></tr></thead><tbody><tr><td style="text-align:left">MEMORY_ONLY</td><td style="text-align:left">使用未序列化的Java对象格式，将数据保存在内存中。如果内存不够存放所有的数据，则数据可能就不会进行持久化。那么下次对这个RDD执行算子操作时，那些没有被持久化的数据，需要从源头处重新计算一遍。这是默认的持久化策略，使用cache()方法时，实际就是使用的这种持久化策略。</td></tr><tr><td style="text-align:left">MEMORY_AND_DISK</td><td style="text-align:left">使用未序列化的Java对象格式，优先尝试将数据保存在内存中。如果内存不够存放所有的数据，会将数据写入磁盘文件中，下次对这个RDD执行算子时，持久化在磁盘文件中的数据会被读取出来使用。</td></tr><tr><td style="text-align:left">MEMORY_ONLY_SER</td><td style="text-align:left">基本含义同MEMORY_ONLY。唯一的区别是，会将RDD中的数据进行序列化，RDD的每个partition会被序列化成一个字节数组。这种方式更加节省内存，从而可以避免持久化的数据占用过多内存导致频繁GC。</td></tr><tr><td style="text-align:left">MEMORY_AND_DISK_SER</td><td style="text-align:left">基本含义同MEMORY_AND_DISK。唯一的区别是，会将RDD中的数据进行序列化，RDD的每个partition会被序列化成一个字节数组。这种方式更加节省内存，从而可以避免持久化的数据占用过多内存导致频繁GC。</td></tr><tr><td style="text-align:left">DISK_ONLY</td><td style="text-align:left">使用未序列化的Java对象格式，将数据全部写入磁盘文件中。</td></tr><tr><td style="text-align:left">MEMORY_ONLY_2, MEMORY_AND_DISK_2, 等等.</td><td style="text-align:left">对于上述任意一种持久化策略，如果加上后缀_2，代表的是将每个持久化的数据，都复制一份副本，并将副本保存到其他节点上。这种基于副本的持久化机制主要用于进行容错。假如某个节点挂掉，节点的内存或磁盘中的持久化数据丢失了，那么后续对RDD计算时还可以使用该数据在其他节点上的副本。如果没有副本的话，就只能将这些数据从源头处重新计算一遍了。</td></tr></tbody></table><h3 id="如何选择一种最合适的持久化策略"><a href="#如何选择一种最合适的持久化策略" class="headerlink" title="如何选择一种最合适的持久化策略"></a>如何选择一种最合适的持久化策略</h3><ul><li><p>默认情况下，性能最高的当然是MEMORY_ONLY，但前提是你的内存必须足够足够大，可以绰绰有余地存放下整个RDD的所有数据。因为不进行序列化与反序列化操作，就避免了这部分的性能开销；对这个RDD的后续算子操作，都是基于纯内存中的数据的操作，不需要从磁盘文件中读取数据，性能也很高；而且不需要复制一份数据副本，并远程传送到其他节点上。但是这里必须要注意的是，在实际的生产环境中，恐怕能够直接用这种策略的场景还是有限的，如果RDD中数据比较多时（比如几十亿），直接用这种持久化级别，会导致JVM的OOM内存溢出异常。</p></li><li><p>如果使用MEMORY_ONLY级别时发生了内存溢出，那么建议尝试使用MEMORY_ONLY_SER级别。该级别会将RDD数据序列化后再保存在内存中，此时每个partition仅仅是一个字节数组而已，大大减少了对象数量，并降低了内存占用。这种级别比MEMORY_ONLY多出来的性能开销，主要就是序列化与反序列化的开销。但是后续算子可以基于纯内存进行操作，因此性能总体还是比较高的。此外，可能发生的问题同上，如果RDD中的数据量过多的话，还是可能会导致OOM内存溢出的异常。</p></li><li><p>如果纯内存的级别都无法使用，那么建议使用MEMORY_AND_DISK_SER策略，而不是MEMORY_AND_DISK策略。因为既然到了这一步，就说明RDD的数据量很大，内存无法完全放下。序列化后的数据比较少，可以节省内存和磁盘的空间开销。同时该策略会优先尽量尝试将数据缓存在内存中，内存缓存不下才会写入磁盘。</p></li><li><p>通常不建议使用DISK_ONLY和后缀为_2的级别：因为完全基于磁盘文件进行数据的读写，会导致性能急剧降低，有时还不如重新计算一次所有RDD。后缀为_2的级别，必须将所有数据都复制一份副本，并发送到其他节点上，数据复制以及网络传输会导致较大的性能开销，除非是要求作业的高可用性，否则不建议使用。</p></li></ul><h2 id="原则四：尽量避免使用shuffle类算子"><a href="#原则四：尽量避免使用shuffle类算子" class="headerlink" title="原则四：尽量避免使用shuffle类算子"></a>原则四：尽量避免使用shuffle类算子</h2><p>如果有可能的话，要尽量避免使用shuffle类算子。因为Spark作业运行过程中，最消耗性能的地方就是shuffle过程。shuffle过程，简单来说，就是将分布在集群中多个节点上的同一个key，拉取到同一个节点上，进行聚合或join等操作。比如reduceByKey、join等算子，都会触发shuffle操作。</p><p>shuffle过程中，各个节点上的相同key都会先写入本地磁盘文件中，然后其他节点需要通过网络传输拉取各个节点上的磁盘文件中的相同key。而且相同key都拉取到同一个节点进行聚合操作时，还有可能会因为一个节点上处理的key过多，导致内存不够存放，进而溢写到磁盘文件中。因此在shuffle过程中，可能会发生大量的磁盘文件读写的IO操作，以及数据的网络传输操作。磁盘IO和网络数据传输也是shuffle性能较差的主要原因。</p><p>因此在我们的开发过程中，能避免则尽可能避免使用reduceByKey、join、distinct、repartition等会进行shuffle的算子，尽量使用map类的非shuffle算子。这样的话，没有shuffle操作或者仅有较少shuffle操作的Spark作业，可以大大减少性能开销。</p><h3 id="Broadcast与map进行join代码示例"><a href="#Broadcast与map进行join代码示例" class="headerlink" title="Broadcast与map进行join代码示例"></a>Broadcast与map进行join代码示例</h3><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 传统的join操作会导致shuffle操作。</span></span><br><span class="line"><span class="comment">// 因为两个RDD中，相同的key都需要通过网络拉取到一个节点上，由一个task进行join操作。</span></span><br><span class="line"><span class="keyword">val</span> rdd3 = rdd1.join(rdd2)</span><br><span class="line"></span><br><span class="line"><span class="comment">// Broadcast+map的join操作，不会导致shuffle操作。</span></span><br><span class="line"><span class="comment">// 使用Broadcast将一个数据量较小的RDD作为广播变量。</span></span><br><span class="line"><span class="keyword">val</span> rdd2Data = rdd2.collect()</span><br><span class="line"><span class="keyword">val</span> rdd2DataBroadcast = sc.broadcast(rdd2Data)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 在rdd1.map算子中，可以从rdd2DataBroadcast中，获取rdd2的所有数据。</span></span><br><span class="line"><span class="comment">// 然后进行遍历，如果发现rdd2中某条数据的key与rdd1的当前数据的key是相同的，那么就判定可以进行join。</span></span><br><span class="line"><span class="comment">// 此时就可以根据自己需要的方式，将rdd1当前数据与rdd2中可以连接的数据，拼接在一起（String或Tuple）。</span></span><br><span class="line"><span class="keyword">val</span> rdd3 = rdd1.map(rdd2DataBroadcast...)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 注意，以上操作，建议仅仅在rdd2的数据量比较少（比如几百M，或者一两G）的情况下使用。</span></span><br><span class="line"><span class="comment">// 因为每个Executor的内存中，都会驻留一份rdd2的全量数据。</span></span><br></pre></td></tr></table></figure><h2 id="原则五：使用map-side预聚合的shuffle操作"><a href="#原则五：使用map-side预聚合的shuffle操作" class="headerlink" title="原则五：使用map-side预聚合的shuffle操作"></a>原则五：使用map-side预聚合的shuffle操作</h2><p>如果因为业务需要，一定要使用shuffle操作，无法用map类的算子来替代，那么尽量使用可以map-side预聚合的算子。</p><p>所谓的map-side预聚合，说的是在每个节点本地对相同的key进行一次聚合操作，类似于MapReduce中的本地combiner。map-side预聚合之后，每个节点本地就只会有一条相同的key，因为多条相同的key都被聚合起来了。其他节点在拉取所有节点上的相同key时，就会大大减少需要拉取的数据数量，从而也就减少了磁盘IO以及网络传输开销。通常来说，在可能的情况下，建议使用reduceByKey或者aggregateByKey算子来替代掉groupByKey算子。因为reduceByKey和aggregateByKey算子都会使用用户自定义的函数对每个节点本地的相同key进行预聚合。而groupByKey算子是不会进行预聚合的，全量的数据会在集群的各个节点之间分发和传输，性能相对来说比较差。</p><p>比如如下两幅图，就是典型的例子，分别基于reduceByKey和groupByKey进行单词计数。其中第一张图是groupByKey的原理图，可以看到，没有进行任何本地聚合时，所有数据都会在集群节点之间传输；第二张图是reduceByKey的原理图，可以看到，每个节点本地的相同key数据，都进行了预聚合，然后才传输到其他节点上进行全局聚合。</p><p><a href="https://tech.meituan.com/img/spark-tuning/group-by-key-wordcount.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/spark-tuning/group-by-key-wordcount.png" alt="groupByKey实现wordcount原理"></a></p><p><a href="https://tech.meituan.com/img/spark-tuning/reduce-by-key-wordcount.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/spark-tuning/reduce-by-key-wordcount.png" alt="reduceByKey实现wordcount原理"></a></p><h2 id="原则六：使用高性能的算子"><a href="#原则六：使用高性能的算子" class="headerlink" title="原则六：使用高性能的算子"></a>原则六：使用高性能的算子</h2><p>除了shuffle相关的算子有优化原则之外，其他的算子也都有着相应的优化原则。</p><h3 id="使用reduceByKey-aggregateByKey替代groupByKey"><a href="#使用reduceByKey-aggregateByKey替代groupByKey" class="headerlink" title="使用reduceByKey/aggregateByKey替代groupByKey"></a>使用reduceByKey/aggregateByKey替代groupByKey</h3><p>详情见“原则五：使用map-side预聚合的shuffle操作”。</p><h3 id="使用mapPartitions替代普通map"><a href="#使用mapPartitions替代普通map" class="headerlink" title="使用mapPartitions替代普通map"></a>使用mapPartitions替代普通map</h3><p>mapPartitions类的算子，一次函数调用会处理一个partition所有的数据，而不是一次函数调用处理一条，性能相对来说会高一些。但是有的时候，使用mapPartitions会出现OOM（内存溢出）的问题。因为单次函数调用就要处理掉一个partition所有的数据，如果内存不够，垃圾回收时是无法回收掉太多对象的，很可能出现OOM异常。所以使用这类操作时要慎重！</p><h3 id="使用foreachPartitions替代foreach"><a href="#使用foreachPartitions替代foreach" class="headerlink" title="使用foreachPartitions替代foreach"></a>使用foreachPartitions替代foreach</h3><p>原理类似于“使用mapPartitions替代map”，也是一次函数调用处理一个partition的所有数据，而不是一次函数调用处理一条数据。在实践中发现，foreachPartitions类的算子，对性能的提升还是很有帮助的。比如在foreach函数中，将RDD中所有数据写MySQL，那么如果是普通的foreach算子，就会一条数据一条数据地写，每次函数调用可能就会创建一个数据库连接，此时就势必会频繁地创建和销毁数据库连接，性能是非常低下；但是如果用foreachPartitions算子一次性处理一个partition的数据，那么对于每个partition，只要创建一个数据库连接即可，然后执行批量插入操作，此时性能是比较高的。实践中发现，对于1万条左右的数据量写MySQL，性能可以提升30%以上。</p><h3 id="使用filter之后进行coalesce操作"><a href="#使用filter之后进行coalesce操作" class="headerlink" title="使用filter之后进行coalesce操作"></a>使用filter之后进行coalesce操作</h3><p>通常对一个RDD执行filter算子过滤掉RDD中较多数据后（比如30%以上的数据），建议使用coalesce算子，手动减少RDD的partition数量，将RDD中的数据压缩到更少的partition中去。因为filter之后，RDD的每个partition中都会有很多数据被过滤掉，此时如果照常进行后续的计算，其实每个task处理的partition中的数据量并不是很多，有一点资源浪费，而且此时处理的task越多，可能速度反而越慢。因此用coalesce减少partition数量，将RDD中的数据压缩到更少的partition之后，只要使用更少的task即可处理完所有的partition。在某些场景下，对于性能的提升会有一定的帮助。</p><h3 id="使用repartitionAndSortWithinPartitions替代repartition与sort类操作"><a href="#使用repartitionAndSortWithinPartitions替代repartition与sort类操作" class="headerlink" title="使用repartitionAndSortWithinPartitions替代repartition与sort类操作"></a>使用repartitionAndSortWithinPartitions替代repartition与sort类操作</h3><p>repartitionAndSortWithinPartitions是Spark官网推荐的一个算子，官方建议，如果需要在repartition重分区之后，还要进行排序，建议直接使用repartitionAndSortWithinPartitions算子。因为该算子可以一边进行重分区的shuffle操作，一边进行排序。shuffle与sort两个操作同时进行，比先shuffle再sort来说，性能可能是要高的。</p><h2 id="原则七：广播大变量"><a href="#原则七：广播大变量" class="headerlink" title="原则七：广播大变量"></a>原则七：广播大变量</h2><p>有时在开发过程中，会遇到需要在算子函数中使用外部变量的场景（尤其是大变量，比如100M以上的大集合），那么此时就应该使用Spark的广播（Broadcast）功能来提升性能。</p><p>在算子函数中使用到外部变量时，默认情况下，Spark会将该变量复制多个副本，通过网络传输到task中，此时每个task都有一个变量副本。如果变量本身比较大的话（比如100M，甚至1G），那么大量的变量副本在网络中传输的性能开销，以及在各个节点的Executor中占用过多内存导致的频繁GC，都会极大地影响性能。</p><p>因此对于上述情况，如果使用的外部变量比较大，建议使用Spark的广播功能，对该变量进行广播。广播后的变量，会保证每个Executor的内存中，只驻留一份变量副本，而Executor中的task执行时共享该Executor中的那份变量副本。这样的话，可以大大减少变量副本的数量，从而减少网络传输的性能开销，并减少对Executor内存的占用开销，降低GC的频率。</p><h3 id="广播大变量的代码示例"><a href="#广播大变量的代码示例" class="headerlink" title="广播大变量的代码示例"></a>广播大变量的代码示例</h3><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 以下代码在算子函数中，使用了外部的变量。</span></span><br><span class="line"><span class="comment">// 此时没有做任何特殊操作，每个task都会有一份list1的副本。</span></span><br><span class="line"><span class="keyword">val</span> list1 = ...</span><br><span class="line">rdd1.map(list1...)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 以下代码将list1封装成了Broadcast类型的广播变量。</span></span><br><span class="line"><span class="comment">// 在算子函数中，使用广播变量时，首先会判断当前task所在Executor内存中，是否有变量副本。</span></span><br><span class="line"><span class="comment">// 如果有则直接使用；如果没有则从Driver或者其他Executor节点上远程拉取一份放到本地Executor内存中。</span></span><br><span class="line"><span class="comment">// 每个Executor内存中，就只会驻留一份广播变量副本。</span></span><br><span class="line"><span class="keyword">val</span> list1 = ...</span><br><span class="line"><span class="keyword">val</span> list1Broadcast = sc.broadcast(list1)</span><br><span class="line">rdd1.map(list1Broadcast...)</span><br></pre></td></tr></table></figure><h2 id="原则八：使用Kryo优化序列化性能"><a href="#原则八：使用Kryo优化序列化性能" class="headerlink" title="原则八：使用Kryo优化序列化性能"></a>原则八：使用Kryo优化序列化性能</h2><p>在Spark中，主要有三个地方涉及到了序列化：</p><ul><li>在算子函数中使用到外部变量时，该变量会被序列化后进行网络传输（见“原则七：广播大变量”中的讲解）。</li><li>将自定义的类型作为RDD的泛型类型时（比如JavaRDD，Student是自定义类型），所有自定义类型对象，都会进行序列化。因此这种情况下，也要求自定义的类必须实现Serializable接口。</li><li>使用可序列化的持久化策略时（比如MEMORY_ONLY_SER），Spark会将RDD中的每个partition都序列化成一个大的字节数组。</li></ul><p>对于这三种出现序列化的地方，我们都可以通过使用Kryo序列化类库，来优化序列化和反序列化的性能。Spark默认使用的是Java的序列化机制，也就是ObjectOutputStream/ObjectInputStream API来进行序列化和反序列化。但是Spark同时支持使用Kryo序列化库，Kryo序列化类库的性能比Java序列化类库的性能要高很多。官方介绍，Kryo序列化机制比Java序列化机制，性能高10倍左右。Spark之所以默认没有使用Kryo作为序列化类库，是因为Kryo要求最好要注册所有需要进行序列化的自定义类型，因此对于开发者来说，这种方式比较麻烦。</p><p>以下是使用Kryo的代码示例，我们只要设置序列化类，再注册要序列化的自定义类型即可（比如算子函数中使用到的外部变量类型、作为RDD泛型类型的自定义类型等）：</p><figure class="highlight scala"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 创建SparkConf对象。</span></span><br><span class="line"><span class="keyword">val</span> conf = <span class="keyword">new</span> <span class="type">SparkConf</span>().setMaster(...).setAppName(...)</span><br><span class="line"><span class="comment">// 设置序列化器为KryoSerializer。</span></span><br><span class="line">conf.set(<span class="string">"spark.serializer"</span>, <span class="string">"org.apache.spark.serializer.KryoSerializer"</span>)</span><br><span class="line"><span class="comment">// 注册要序列化的自定义类型。</span></span><br><span class="line">conf.registerKryoClasses(<span class="type">Array</span>(classOf[<span class="type">MyClass1</span>], classOf[<span class="type">MyClass2</span>]))</span><br></pre></td></tr></table></figure><h2 id="原则九：优化数据结构"><a href="#原则九：优化数据结构" class="headerlink" title="原则九：优化数据结构"></a>原则九：优化数据结构</h2><p>Java中，有三种类型比较耗费内存：</p><ul><li>对象，每个Java对象都有对象头、引用等额外的信息，因此比较占用内存空间。</li><li>字符串，每个字符串内部都有一个字符数组以及长度等额外信息。</li><li>集合类型，比如HashMap、LinkedList等，因为集合类型内部通常会使用一些内部类来封装集合元素，比如Map.Entry。</li></ul><p>因此Spark官方建议，在Spark编码实现中，特别是对于算子函数中的代码，尽量不要使用上述三种数据结构，尽量使用字符串替代对象，使用原始类型（比如Int、Long）替代字符串，使用数组替代集合类型，这样尽可能地减少内存占用，从而降低GC频率，提升性能。</p><p>但是在笔者的编码实践中发现，要做到该原则其实并不容易。因为我们同时要考虑到代码的可维护性，如果一个代码中，完全没有任何对象抽象，全部是字符串拼接的方式，那么对于后续的代码维护和修改，无疑是一场巨大的灾难。同理，如果所有操作都基于数组实现，而不使用HashMap、LinkedList等集合类型，那么对于我们的编码难度以及代码可维护性，也是一个极大的挑战。因此笔者建议，在可能以及合适的情况下，使用占用内存较少的数据结构，但是前提是要保证代码的可维护性。</p><h1 id="资源调优"><a href="#资源调优" class="headerlink" title="资源调优"></a>资源调优</h1><h2 id="调优概述-1"><a href="#调优概述-1" class="headerlink" title="调优概述"></a>调优概述</h2><p>在开发完Spark作业之后，就该为作业配置合适的资源了。Spark的资源参数，基本都可以在spark-submit命令中作为参数设置。很多Spark初学者，通常不知道该设置哪些必要的参数，以及如何设置这些参数，最后就只能胡乱设置，甚至压根儿不设置。资源参数设置的不合理，可能会导致没有充分利用集群资源，作业运行会极其缓慢；或者设置的资源过大，队列没有足够的资源来提供，进而导致各种异常。总之，无论是哪种情况，都会导致Spark作业的运行效率低下，甚至根本无法运行。因此我们必须对Spark作业的资源使用原理有一个清晰的认识，并知道在Spark作业运行过程中，有哪些资源参数是可以设置的，以及如何设置合适的参数值。</p><h2 id="Spark作业基本运行原理"><a href="#Spark作业基本运行原理" class="headerlink" title="Spark作业基本运行原理"></a>Spark作业基本运行原理</h2><p><a href="https://tech.meituan.com/img/spark-tuning/spark-base-mech.png" target="_blank" rel="noopener"><img src="https://tech.meituan.com/img/spark-tuning/spark-base-mech.png" alt="Spark基本运行原理"></a></p><p>详细原理见上图。我们使用spark-submit提交一个Spark作业之后，这个作业就会启动一个对应的Driver进程。根据你使用的部署模式（deploy-mode）不同，Driver进程可能在本地启动，也可能在集群中某个工作节点上启动。Driver进程本身会根据我们设置的参数，占有一定数量的内存和CPU core。而Driver进程要做的第一件事情，就是向集群管理器（可以是Spark Standalone集群，也可以是其他的资源管理集群，美团•大众点评使用的是YARN作为资源管理集群）申请运行Spark作业需要使用的资源，这里的资源指的就是Executor进程。YARN集群管理器会根据我们为Spark作业设置的资源参数，在各个工作节点上，启动一定数量的Executor进程，每个Executor进程都占有一定数量的内存和CPU core。</p><p>在申请到了作业执行所需的资源之后，Driver进程就会开始调度和执行我们编写的作业代码了。Driver进程会将我们编写的Spark作业代码分拆为多个stage，每个stage执行一部分代码片段，并为每个stage创建一批task，然后将这些task分配到各个Executor进程中执行。task是最小的计算单元，负责执行一模一样的计算逻辑（也就是我们自己编写的某个代码片段），只是每个task处理的数据不同而已。一个stage的所有task都执行完毕之后，会在各个节点本地的磁盘文件中写入计算中间结果，然后Driver就会调度运行下一个stage。下一个stage的task的输入数据就是上一个stage输出的中间结果。如此循环往复，直到将我们自己编写的代码逻辑全部执行完，并且计算完所有的数据，得到我们想要的结果为止。</p><p>Spark是根据shuffle类算子来进行stage的划分。如果我们的代码中执行了某个shuffle类算子（比如reduceByKey、join等），那么就会在该算子处，划分出一个stage界限来。可以大致理解为，shuffle算子执行之前的代码会被划分为一个stage，shuffle算子执行以及之后的代码会被划分为下一个stage。因此一个stage刚开始执行的时候，它的每个task可能都会从上一个stage的task所在的节点，去通过网络传输拉取需要自己处理的所有key，然后对拉取到的所有相同的key使用我们自己编写的算子函数执行聚合操作（比如reduceByKey()算子接收的函数）。这个过程就是shuffle。</p><p>当我们在代码中执行了cache/persist等持久化操作时，根据我们选择的持久化级别的不同，每个task计算出来的数据也会保存到Executor进程的内存或者所在节点的磁盘文件中。</p><p>因此Executor的内存主要分为三块：第一块是让task执行我们自己编写的代码时使用，默认是占Executor总内存的20%；第二块是让task通过shuffle过程拉取了上一个stage的task的输出后，进行聚合等操作时使用，默认也是占Executor总内存的20%；第三块是让RDD持久化时使用，默认占Executor总内存的60%。</p><p>task的执行速度是跟每个Executor进程的CPU core数量有直接关系的。一个CPU core同一时间只能执行一个线程。而每个Executor进程上分配到的多个task，都是以每个task一条线程的方式，多线程并发运行的。如果CPU core数量比较充足，而且分配到的task数量比较合理，那么通常来说，可以比较快速和高效地执行完这些task线程。</p><p>以上就是Spark作业的基本运行原理的说明，大家可以结合上图来理解。理解作业基本原理，是我们进行资源参数调优的基本前提。</p><h2 id="资源参数调优"><a href="#资源参数调优" class="headerlink" title="资源参数调优"></a>资源参数调优</h2><p>了解完了Spark作业运行的基本原理之后，对资源相关的参数就容易理解了。所谓的Spark资源参数调优，其实主要就是对Spark运行过程中各个使用资源的地方，通过调节各种参数，来优化资源使用的效率，从而提升Spark作业的执行性能。以下参数就是Spark中主要的资源参数，每个参数都对应着作业运行原理中的某个部分，我们同时也给出了一个调优的参考值。</p><h3 id="num-executors"><a href="#num-executors" class="headerlink" title="num-executors"></a>num-executors</h3><ul><li>参数说明：该参数用于设置Spark作业总共要用多少个Executor进程来执行。Driver在向YARN集群管理器申请资源时，YARN集群管理器会尽可能按照你的设置来在集群的各个工作节点上，启动相应数量的Executor进程。这个参数非常之重要，如果不设置的话，默认只会给你启动少量的Executor进程，此时你的Spark作业的运行速度是非常慢的。</li><li>参数调优建议：每个Spark作业的运行一般设置50~100个左右的Executor进程比较合适，设置太少或太多的Executor进程都不好。设置的太少，无法充分利用集群资源；设置的太多的话，大部分队列可能无法给予充分的资源。</li></ul><h3 id="executor-memory"><a href="#executor-memory" class="headerlink" title="executor-memory"></a>executor-memory</h3><ul><li>参数说明：该参数用于设置每个Executor进程的内存。Executor内存的大小，很多时候直接决定了Spark作业的性能，而且跟常见的JVM OOM异常，也有直接的关联。</li><li>参数调优建议：每个Executor进程的内存设置4G~8G较为合适。但是这只是一个参考值，具体的设置还是得根据不同部门的资源队列来定。可以看看自己团队的资源队列的最大内存限制是多少，num-executors乘以executor-memory，是不能超过队列的最大内存量的。此外，如果你是跟团队里其他人共享这个资源队列，那么申请的内存量最好不要超过资源队列最大总内存的1/3~1/2，避免你自己的Spark作业占用了队列所有的资源，导致别的同学的作业无法运行。</li></ul><h3 id="executor-cores"><a href="#executor-cores" class="headerlink" title="executor-cores"></a>executor-cores</h3><ul><li>参数说明：该参数用于设置每个Executor进程的CPU core数量。这个参数决定了每个Executor进程并行执行task线程的能力。因为每个CPU core同一时间只能执行一个task线程，因此每个Executor进程的CPU core数量越多，越能够快速地执行完分配给自己的所有task线程。</li><li>参数调优建议：Executor的CPU core数量设置为2~4个较为合适。同样得根据不同部门的资源队列来定，可以看看自己的资源队列的最大CPU core限制是多少，再依据设置的Executor数量，来决定每个Executor进程可以分配到几个CPU core。同样建议，如果是跟他人共享这个队列，那么num-executors * executor-cores不要超过队列总CPU core的1/3~1/2左右比较合适，也是避免影响其他同学的作业运行。</li></ul><h3 id="driver-memory"><a href="#driver-memory" class="headerlink" title="driver-memory"></a>driver-memory</h3><ul><li>参数说明：该参数用于设置Driver进程的内存。</li><li>参数调优建议：Driver的内存通常来说不设置，或者设置1G左右应该就够了。唯一需要注意的一点是，如果需要使用collect算子将RDD的数据全部拉取到Driver上进行处理，那么必须确保Driver的内存足够大，否则会出现OOM内存溢出的问题。</li></ul><h3 id="spark-default-parallelism"><a href="#spark-default-parallelism" class="headerlink" title="spark.default.parallelism"></a>spark.default.parallelism</h3><ul><li>参数说明：该参数用于设置每个stage的默认task数量。这个参数极为重要，如果不设置可能会直接影响你的Spark作业性能。</li><li>参数调优建议：Spark作业的默认task数量为500~1000个较为合适。很多同学常犯的一个错误就是不去设置这个参数，那么此时就会导致Spark自己根据底层HDFS的block数量来设置task的数量，默认是一个HDFS block对应一个task。通常来说，Spark默认设置的数量是偏少的（比如就几十个task），如果task数量偏少的话，就会导致你前面设置好的Executor的参数都前功尽弃。试想一下，无论你的Executor进程有多少个，内存和CPU有多大，但是task只有1个或者10个，那么90%的Executor进程可能根本就没有task执行，也就是白白浪费了资源！因此Spark官网建议的设置原则是，设置该参数为num-executors * executor-cores的2~3倍较为合适，比如Executor的总CPU core数量为300个，那么设置1000个task是可以的，此时可以充分地利用Spark集群的资源。</li></ul><h3 id="spark-storage-memoryFraction"><a href="#spark-storage-memoryFraction" class="headerlink" title="spark.storage.memoryFraction"></a>spark.storage.memoryFraction</h3><ul><li>参数说明：该参数用于设置RDD持久化数据在Executor内存中能占的比例，默认是0.6。也就是说，默认Executor 60%的内存，可以用来保存持久化的RDD数据。根据你选择的不同的持久化策略，如果内存不够时，可能数据就不会持久化，或者数据会写入磁盘。</li><li>参数调优建议：如果Spark作业中，有较多的RDD持久化操作，该参数的值可以适当提高一些，保证持久化的数据能够容纳在内存中。避免内存不够缓存所有的数据，导致数据只能写入磁盘中，降低了性能。但是如果Spark作业中的shuffle类操作比较多，而持久化操作比较少，那么这个参数的值适当降低一些比较合适。此外，如果发现作业由于频繁的gc导致运行缓慢（通过spark web ui可以观察到作业的gc耗时），意味着task执行用户代码的内存不够用，那么同样建议调低这个参数的值。</li></ul><h3 id="spark-shuffle-memoryFraction"><a href="#spark-shuffle-memoryFraction" class="headerlink" title="spark.shuffle.memoryFraction"></a>spark.shuffle.memoryFraction</h3><ul><li>参数说明：该参数用于设置shuffle过程中一个task拉取到上个stage的task的输出后，进行聚合操作时能够使用的Executor内存的比例，默认是0.2。也就是说，Executor默认只有20%的内存用来进行该操作。shuffle操作在进行聚合时，如果发现使用的内存超出了这个20%的限制，那么多余的数据就会溢写到磁盘文件中去，此时就会极大地降低性能。</li><li>参数调优建议：如果Spark作业中的RDD持久化操作较少，shuffle操作较多时，建议降低持久化操作的内存占比，提高shuffle操作的内存占比比例，避免shuffle过程中数据过多时内存不够用，必须溢写到磁盘上，降低了性能。此外，如果发现作业由于频繁的gc导致运行缓慢，意味着task执行用户代码的内存不够用，那么同样建议调低这个参数的值。</li></ul><p>资源参数的调优，没有一个固定的值，需要同学们根据自己的实际情况（包括Spark作业中的shuffle操作数量、RDD持久化操作数量以及spark web ui中显示的作业gc情况），同时参考本篇文章中给出的原理以及调优建议，合理地设置上述参数。</p><h2 id="资源参数参考示例"><a href="#资源参数参考示例" class="headerlink" title="资源参数参考示例"></a>资源参数参考示例</h2><p>以下是一份spark-submit命令的示例，大家可以参考一下，并根据自己的实际情况进行调节：</p><figure class="highlight shell"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">./bin/spark-submit \</span><br><span class="line"> --master yarn-cluster \</span><br><span class="line"> --num-executors 100 \</span><br><span class="line"> --executor-memory 6G \</span><br><span class="line"> --executor-cores 4 \</span><br><span class="line"> --driver-memory 1G \</span><br><span class="line"> --conf spark.default.parallelism=1000 \</span><br><span class="line"> --conf spark.storage.memoryFraction=0.5 \</span><br><span class="line"> --conf spark.shuffle.memoryFraction=0.3 \</span><br></pre></td></tr></table></figure><h1 id="写在最后的话"><a href="#写在最后的话" class="headerlink" title="写在最后的话"></a>写在最后的话</h1><p>根据实践经验来看，大部分Spark作业经过本次基础篇所讲解的开发调优与资源调优之后，一般都能以较高的性能运行了，足以满足我们的需求。但是在不同的生产环境和项目背景下，可能会遇到其他更加棘手的问题（比如各种数据倾斜），也可能会遇到更高的性能要求。为了应对这些挑战，需要使用更高级的技巧来处理这类问题。在后续的《Spark性能优化指南——高级篇》中，我们会详细讲解数据倾斜调优以及Shuffle调优。</p>]]></content>
    
    <summary type="html">
    
      &lt;p&gt;在大数据计算领域，Spark已经成为了越来越流行、越来越受欢迎的计算平台之一。Spark的功能涵盖了大数据领域的离线批处理、SQL类处理、流式/实时计算、机器学习、图计算等各种不同类型的计算操作，应用范围与前景非常广泛。在美团•大众点评，已经有很多同学在各种项目中尝试使用Spark。大多数同学（包括笔者在内），最初开始尝试使用Spark的原因很简单，主要就是为了让大数据计算作业的执行速度更快、性能更高。&lt;/p&gt;
&lt;p&gt;然而，通过Spark开发出高性能的大数据计算作业，并不是那么简单的。如果没有对Spark作业进行合理的调优，Spark作业的执行速度可能会很慢，这样就完全体现不出Spark作为一种快速大数据计算引擎的优势来。因此，想要用好Spark，就必须对其进行合理的性能优化。&lt;/p&gt;
&lt;p&gt;Spark的性能调优实际上是由很多部分组成的，不是调节几个参数就可以立竿见影提升作业性能的。我们需要根据不同的业务场景以及数据情况，对Spark作业进行综合性的分析，然后进行多个方面的调节和优化，才能获得最佳性能。&lt;/p&gt;
&lt;p&gt;笔者根据之前的Spark作业开发经验以及实践积累，总结出了一套Spark作业的性能优化方案。整套方案主要分为开发调优、资源调优、数据倾斜调优、shuffle调优几个部分。开发调优和资源调优是所有Spark作业都需要注意和遵循的一些基本原则，是高性能Spark作业的基础；数据倾斜调优，主要讲解了一套完整的用来解决Spark作业数据倾斜的解决方案；shuffle调优，面向的是对Spark的原理有较深层次掌握和研究的同学，主要讲解了如何对Spark作业的shuffle运行过程以及细节进行调优。&lt;br&gt;
    
    </summary>
    
      <category term="spark" scheme="http://crazycarry.github.io/categories/spark/"/>
    
    
      <category term="大数据" scheme="http://crazycarry.github.io/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"/>
    
      <category term="性能优化" scheme="http://crazycarry.github.io/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"/>
    
      <category term="spark" scheme="http://crazycarry.github.io/tags/spark/"/>
    
  </entry>
  
</feed>
