<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>early (赵伟)</title>
    <link>https://ruby-china.org/early</link>
    <description>让知识更容易传播</description>
    <language>en-us</language>
    <item>
      <title>理解直播业务 (三)：平台在干什么？通过系统运作拿到结果</title>
      <description>&lt;ul&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/93456677" rel="nofollow" target="_blank" title=""&gt;直播 (上) -- 底层逻辑浅析&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/144920219" rel="nofollow" target="_blank" title=""&gt;直播 (中) -- 核心流程梳理&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/144920551" rel="nofollow" target="_blank" title=""&gt;直播 (下) -- 业务结构简介&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/484124484" rel="nofollow" target="_blank" title=""&gt;理解直播业务 (一)：直播因何存在？信息升维引爆内容供给&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/500894192" rel="nofollow" target="_blank" title=""&gt;理解直播业务 (二)：用户为何打赏？人类隐密需求撑起商业循环&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/517143091" rel="nofollow" target="_blank" title=""&gt;理解直播业务 (三)：平台在干什么？通过系统运作拿到结果&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;几年前我刚踏入直播业务时，有不少疑惑。为什么运营的话语权这么大？几乎是号令三军，产品技术都是小弟。一旦业绩不好，也是运营老大在一波波撤换。&lt;/p&gt;

&lt;p&gt;限于当时技术工具的视野，只模糊地知道一个结论：直播是运营驱动的。但对这个点理解既不透彻，也不明白平台运营到底意味着什么。&lt;/p&gt;

&lt;p&gt;随着搬的砖越来越多，慢慢地发现，这需要在俯瞰业务本质的基础上，从平台运作的视角去获得答案。&lt;/p&gt;

&lt;p&gt;带着这些疑问，我们继续围绕直播业务理解框架，展开第三部分的探讨：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;因何出现&lt;/li&gt;
&lt;li&gt;因何持续存在&lt;/li&gt;
&lt;li&gt;平台在干什么&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;作为本系列的收尾，我们将立足于前面对直播业务底盘的推演，和对用户需求的剖析，从直播系统操盘手的视角，粗浅地分析直播平台都在干什么 (what)，应该干什么 (why)，以此实现整体性理解。&lt;/p&gt;

&lt;p&gt;为了更好的聚焦，本文讨论范围依然围绕经典的打赏模式。&lt;/p&gt;
&lt;h2 id="平台"&gt;平台&lt;/h2&gt;
&lt;p&gt;网络直播的崛起，源于技术的升级，利用技术生产出的实时内容，让用户稳定聚集了起来。基于聚集形成的社交互动环境，平台创造了以礼物为桥梁的快乐通道，因为满足了人对快感 (爽) 源源不断的需求，平台打通了商业正循环，蓬勃发展至今。&lt;/p&gt;

&lt;p&gt;到此，我们可以做一次概括性的总结，直播业务始于技术、归于内容、成于社交、基于人性。简称一个根基 (人性)，三个要点 (技术、内容、社交)。&lt;/p&gt;

&lt;p&gt;以打赏为核心的直播业务，实际上就是平台在搭台子，撮合主播和用户进行快感 (满足) 交易。这和其他中介性平台有很多相似的地方。&lt;/p&gt;

&lt;p&gt;更近一步看，直播平台到底是什么呢？&lt;/p&gt;

&lt;p&gt;当我们想打开某个直播 APP 时，就意味着这款产品在心里有一定份量，至少是有使用习惯。进去后可以选择自己感兴趣的直播间，在情绪积累到一定程度时，送送礼发发弹幕，在互动中可以获得快乐，随后满足地离开。&lt;/p&gt;

&lt;p&gt;由此可见，平台有几个核心组成：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;系统能力。主播能生产内容，用户可观看，通过互动可以获得快乐&lt;/li&gt;
&lt;li&gt;用户&lt;/li&gt;
&lt;li&gt;内容 (主播)&lt;/li&gt;
&lt;li&gt;氛围。调性、知名度、用户粘性&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;从用户视角看，平台是获得快乐的地方；
从主播的视角看，平台是变现禀赋的地方；
从操盘手 (平台) 的角度看，平台是创造商业价值的工具。&lt;/p&gt;
&lt;h2 id="目标"&gt;目标&lt;/h2&gt;
&lt;p&gt;作为商业组织，平台有其明确的商业目标。不限于社区能力延伸、规模化的用户量、商业价值等。&lt;/p&gt;

&lt;p&gt;用接地气的话来讲，终极目标主要就是挣钱。趁着当前的技术红利、资本预期、用户未厌倦，尽可能快、尽可能多地变现。&lt;/p&gt;

&lt;p&gt;一旦新的技术变量成熟 (VR)，或提供出新的场景 (源宇宙)，能更好地满足用户对快感的获取时，现有的平台会迅速崩塌，就像没落的贴吧。&lt;/p&gt;

&lt;p&gt;当然挣钱更多是结果，平台实际上在培育一只能下金蛋的鸡，给鸡投喂资源，或进行调教，它能不断下蛋。&lt;/p&gt;
&lt;h2 id="两个视野"&gt;两个视野&lt;/h2&gt;
&lt;p&gt;从产品角度看，这只鸡就是一个能给用户带来确定性的系统，只要投入时间和金钱，就能获得快感。从这个点切入，可以获得平台操盘手的第一视野：&lt;/p&gt;
&lt;h3 id="1. 识别需求和痛点"&gt;1. 识别需求和痛点&lt;/h3&gt;
&lt;p&gt;从人性出发，心理精神空乏下，对快感有持续需求。草根群体很难有简单的获取途径。&lt;/p&gt;
&lt;h3 id="2. 商业模式设计"&gt;2. 商业模式设计&lt;/h3&gt;
&lt;p&gt;通过以主播为核心的社交互动虚拟社会化环境，为用户提供即时快乐，并通过模拟满足马斯洛上三层需求，便捷获得快感。主播和用户通过礼物道具体系达成交易。&lt;/p&gt;
&lt;h3 id="3. 打造能力"&gt;3. 打造能力&lt;/h3&gt;
&lt;p&gt;通过技术打造实时内容供应分发、实时互动、数字化打赏等能力。&lt;/p&gt;
&lt;h3 id="4. 系统设计"&gt;4. 系统设计&lt;/h3&gt;
&lt;p&gt;基于人性的社会性/心理需求，整合各项能力落地商业模式。设计出一套完整闭环自洽的礼物道具、互动环境、视觉特效、排行榜等体系，让用户在打赏前后，通过情感、身份、等级、关系、特权等，在情绪的不断积累和释放中，持续获得快感。&lt;/p&gt;

&lt;p&gt;第一视野所见，就是我们常说的产品、技术主要在做的事情。本质上是在打造能力，或者说功能。就像建造出的一座工厂，或锻炼出的口才。&lt;/p&gt;

&lt;p&gt;落到实处，就是我们平常使用的 APP 或网站。主播可以开播、用户可以看直播、送礼物、和主播互动等，并在过程中获得满足。&lt;/p&gt;

&lt;p&gt;当能力具备后，便可以进入操盘手的第二视野：&lt;/p&gt;
&lt;h3 id="1.系统运作"&gt;1.系统运作&lt;/h3&gt;
&lt;p&gt;通过导入主播、规划内容、引流用户等资源性投入行为，把能力用起来。相当于给鸡喂食养大，让鸡下蛋。这个过程会逐渐建立社区氛围，获得用户留存和主播增长。&lt;/p&gt;

&lt;p&gt;一个鲜活的平台便随之诞生。&lt;/p&gt;
&lt;h3 id="2. 系统迭代"&gt;2. 系统迭代&lt;/h3&gt;
&lt;p&gt;根据系统运作过程中得到的反馈，优化成本、完善系统能力、社区氛围引导等，对系统能力进行优化。&lt;/p&gt;
&lt;h3 id="3. 系统正循环"&gt;3. 系统正循环&lt;/h3&gt;
&lt;p&gt;当平台跑起来后，需要设计一个良性循环的结构，让其能可持续发展。&lt;/p&gt;

&lt;p&gt;首先要让系统中各个要素能相互链接并形成增强回路，如更多优质内容-&amp;gt;更多的用户聚集-&amp;gt;更多的打赏-&amp;gt;更多优质内容。平台可以针对增强回路中的要素，进行刺激性投入，让平台加速增长。&lt;/p&gt;

&lt;p&gt;其次，需要设计调节回路，限制资源的畸形发展影响平台稳定。就像某个平台被几大主播垄断流量后，平台就会通过各种策略限制他们，并扶持中长尾的主播，避免平台利益受损。&lt;/p&gt;

&lt;p&gt;第二视野所见，主要就是我们常说的运营。本质上是把产品和技术 (第一视野) 打造能力或系统用起来，在运作中拿到结果，比如主播/用户增长、影响力积累、营收增长。&lt;/p&gt;
&lt;h2 id="运作"&gt;运作&lt;/h2&gt;
&lt;p&gt;简单理解，第一视野提供工具、能力、功能，也就是造一个能下蛋的鸡。第二视野投入资源，通过对能力和工具的使用获得结果 (平台做大、挣钱)，也就是给鸡喂食，让它能持续性下蛋。&lt;/p&gt;

&lt;p&gt;这两大视野所见，其实就是平台每天在干的事情。当一个体系在运作时，零零碎碎的行为，和无章法的协作，是无法在激烈的竞争中存活下来的。&lt;/p&gt;

&lt;p&gt;这需要用一套模型来简化整个系统。首先提炼出关键要素，其次通过系统设计让要素建能相互连通、相互转化，实现类似 A(做大活跃用户) -&amp;gt;B(强化付费意愿) -&amp;gt; C(做大付费用户) 这种协同链条，让 C 可以站在 B 的肩膀上近一步向目标靠近。&lt;/p&gt;

&lt;p&gt;最后让要素链条最终首尾相连，形成闭合的增强循环。平台才可能在不断地运作中持续积累，跻身行业头部。简要的系统运作模型可以见下图：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/2f74549a-7f2c-4975-aa20-7661225ab968.png!large" title="" alt="简要系统模型"&gt;&lt;/p&gt;

&lt;p&gt;系统所见：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;系统目标是：营收、用户快乐&lt;/li&gt;
&lt;li&gt;核心要素有：流量、内容、直播用户、付费意愿、快乐生产 (系统能力之一)&lt;/li&gt;
&lt;li&gt;要素链如上图。有两个闭环要素链 (红线)。打赏意愿-&amp;gt;快乐-&amp;gt;打赏意愿、内容-&amp;gt;营收-&amp;gt;内容&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="该做什么"&gt;该做什么&lt;/h2&gt;
&lt;p&gt;有了系统的运作模型，基于要素链，我们可以回答平台应该干什么？这个问题了。&lt;/p&gt;

&lt;p&gt;答案实际上很简单，主要是两个点：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;不断提升要素链条之间的转化率（系统能力建设）&lt;/li&gt;
&lt;li&gt;有章法地刺激核心要素 (导流、导入主播、促销)，加速模型迭代、引导自我完善，冲击更高目标&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;当直播业务系统冷启动成功后，要素间的增强回路，会让系统正循环增长，好的内容可以不断吸引新用户。&lt;/p&gt;

&lt;p&gt;但在争分夺秒的市场瓜分竞赛中，这个过程太缓慢了，而且过程无法被平台良好掌控。顺其自然往往会被迅速甩在身后，某些重要阶段，哪怕领先一个月，结局也会天差地别。&lt;/p&gt;

&lt;p&gt;所以在系统能力建设同时，平台需要投入大量的资源刺激核心要素，以迅速占领用户份额，或近一步提升营收 (变现)。常见的刺激点有：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;用户流量。导入新流量，提升潜在直播用户基数&lt;/li&gt;
&lt;li&gt;内容。引入类型丰富、优质的主播、积累和大盘用户兴趣匹配的内容&lt;/li&gt;
&lt;li&gt;用户付费意愿。刺激主播更卖力、烘托直播间氛围、降价促销&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;基于根本的营收目标，我们可以通过数学公式的方式简单量化：（仅做示意，并不严谨）&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;直播用户 = 大盘用户 * 渗透率
付费意愿强度 = 主播投入程度 * 主播禀赋 * 用户需求匹配度 * 氛围 * K （K为某系数）
付费用户数 = 直播用户 * 付费意愿率 * K
付费金额 = K * 付费意愿强度 （K为某系数）

平台营收 = 付费用户数 * 平均付费金额
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="在做什么"&gt;在做什么&lt;/h2&gt;
&lt;p&gt;搞清楚了目标，以及靠近目标的路径，接下来理解平台行为就非常自然。&lt;/p&gt;
&lt;h3 id="转化率"&gt;转化率&lt;/h3&gt;
&lt;p&gt;不同的平台会基于自身的禀赋，设定对应的流量策略和引流渠道。但不管是基于兴趣、还是关系链或其他，大的目标都是让用户消费到匹配的内容，做大直播活跃用户，提升渗透率和观看时长。用户的点击、时长等反馈，也会对主播进行优胜劣汰，实现优质主播的转化筛选。&lt;/p&gt;

&lt;p&gt;稳定的观看体验、实时的互动效果、华丽的礼物特效、醒目的榜单或标签等等，都是为了让用户的情绪能逐步蓄积，在反馈刺激中突破打赏的临界点，通过丰富的礼物道具释放情绪，在互动中转化成快乐。&lt;/p&gt;

&lt;p&gt;每一个环节的转化率提升，依托于增强回路，都会直接或间接强化平台营收能力。要素间的转化率几乎是所有优化或实验的根本目标，而这直接体现为系统能力。&lt;/p&gt;

&lt;p&gt;就像好的筛子能够滤出更多金子一样。接下来就可以不断地给它喂沙子。&lt;/p&gt;
&lt;h3 id="要素刺激"&gt;要素刺激&lt;/h3&gt;&lt;h5 id="内容"&gt;内容&lt;/h5&gt;
&lt;p&gt;平台的壁垒说到底还是优质内容的生产能力，这需要对内容品类做精心布局，所以大量的成本花在了大主播的签约引入、主播资源的维系。&lt;/p&gt;

&lt;p&gt;当对内容及主播专业能力有近一步需求时，还得依托于公会。公会这种组织和娱乐经纪公司的性质类似，专门做主播批量孵化、培训，研究如何让用户快乐，刺激用户打赏。引入公会资源，并给予特定的分成份额，会显著增加平台的内容供应及营收增长。&lt;/p&gt;
&lt;h5 id="流量"&gt;流量&lt;/h5&gt;
&lt;p&gt;当有充沛的内容后，平台可以增大流量的供应，比如 B 站/抖音在视频推荐里增加直播的比例，在特定流量口增加直播间的跳转入口等等。流量的注入，会引发各个要素链条的反应，洪水越猛，冲到碗里的水就越满。&lt;/p&gt;
&lt;h5 id="付费意愿"&gt;付费意愿&lt;/h5&gt;
&lt;p&gt;当用户的需求量稳定时，想要提升营收，就只能刺激消费欲望。就像超市想要提升销售额时，一般会搞促销降价，即使用不了那么多，但人为了贪便宜还是会超额消费。&lt;/p&gt;

&lt;p&gt;对于直播来讲，要提升用户的需求量，需要刺激主播更加卖力地工作。而要刺激用户消费欲望，往往需要设置任务奖励、消费打折等诱饵，或者在让其不经意的游玩中顺便消费。&lt;/p&gt;

&lt;p&gt;落到业务上，就是各种精心策划的营收活动，新鲜有趣、有便宜占、有成就感。给主播或用户设置奖励、优惠、荣誉，需求和欲望会被自然激发。&lt;/p&gt;

&lt;p&gt;当主播和用户走上平台设定的获取路径，消费自然会增加。对于平台的虚拟商品模式，只要多消费，无论价格怎样，对平台来说都是增益。&lt;/p&gt;

&lt;p&gt;对于人的群体效应，需求量的刺激还有更隐秘的方式。就像明星的运作，当明星在机场被堵得水泄不通时，有多少人是在“工作”，有多少人是在追星？&lt;/p&gt;

&lt;p&gt;基于人的盲从心理和环境氛围对人的影响，看到别人在追，自己也更容易参与。直播间内的打赏氛围也是类似，不少公会会照着这个套路，引导吃瓜群众参与打赏。&lt;/p&gt;
&lt;h2 id="驱动"&gt;驱动&lt;/h2&gt;
&lt;p&gt;到此，我们简要从 why 和 what 的角度分析了平台的行为，接下来可以解答文章开头的疑惑：直播为什么是运营驱动？&lt;/p&gt;

&lt;p&gt;驱动的本质是赋能，谁能将平台推向新台阶，谁在赚钱上起核心作用，谁就具备更大的话语权。这其实需要分阶段来看。&lt;/p&gt;

&lt;p&gt;当某个业务形态刚孵化成型时，拥有核心技术，提供实时稳定的体验和互动效果平台会更胜一筹，此时平台由技术驱动。&lt;/p&gt;

&lt;p&gt;当技术成为行业基础设施时，决定平台竞争力的是产品能力，符合用户需求体验的平台更容易赢得市场，此时平台由产品驱动。&lt;/p&gt;

&lt;p&gt;当行业处于成熟期，各家的产品创意挖掘殆尽，大家都在相互抄作业时，不管是从技术还是产品角度，都没有差异化的竞争优势，平台也就很难迈向更高的高度。此时系统能力不再作为第一驱动点，平台开始拼资源。&lt;/p&gt;

&lt;p&gt;不管系统能力建设有多厉害，没有优质的主播或内容，营收都是空谈。此时用户流量、主播资源成为决定性因素。谁能用有限的资源转化出更多的流量和内容，并在精细化运作中最大化盘活存量，谁才能赢得竞争。这背后就是通俗意义的运营能力。&lt;/p&gt;

&lt;p&gt;我们只是恰好处在这个阶段。如果出现新的技术变革，也许驱动点又会开始新的轮回。&lt;/p&gt;
&lt;h2 id="总结"&gt;总结&lt;/h2&gt;
&lt;p&gt;本文从平台的目标出发，引出两个视野，通过展开两个视野，勾勒出了简单的系统运作模型图。&lt;/p&gt;

&lt;p&gt;基于系统的基本运作方法，我们知道了平台一方面在建设系统能力，也就是提升各要素的转化率。另一方面通过资源投入，并辅以精细化运营策略盘活存量，在对系统能力的运用中获得营收和市场份额。&lt;/p&gt;

&lt;p&gt;旁观平台获得收益的过程，你会发现这是本质是一个”创业“的模型：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;首先，捕获技术的变革，运用技术能满足人的需求。这是可能性，作为一种认知。&lt;/li&gt;
&lt;li&gt;其次，基于认知探索对应的商业策略，并打造成系统能力，能为他人提供价值。&lt;/li&gt;
&lt;li&gt;最后，投入资源 (风险、代价)，将系统能力运用起来，让系统在不断迭代中逐渐壮大 (竞争)，创造出价值，也随之获得收益。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这个过程对个体有着明显的启示意义。人人都想获得收益或成长，但绝大部分人都停滞在了想想而已。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;当悲观失意时，是不是自己没有足够多的学习沉淀，获得相应的认知？&lt;/li&gt;
&lt;li&gt;当欲求不满时，是不是自己没有从自身情况出发，根据客观规律打造扎实的能力？&lt;/li&gt;
&lt;li&gt;当怀才不遇时，是不是自己没有付出足够的代价，将能力运用起来，创造出足够多的价值？&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="展望"&gt;展望&lt;/h2&gt;
&lt;p&gt;到此，基于“理解直播业务”这个出发点，本系列已经全部收尾。宏观上来看，内容围绕着 What 和 Why 两个维度，属于认知提升范畴。也就是上面提到的“创业”模型的第一个阶段。&lt;/p&gt;

&lt;p&gt;有能力才有创造价值的可能性。后续将会站在本系列的肩膀上，走入第二阶段，从 How 这个维度来展开，实现能力的打造。&lt;/p&gt;

&lt;p&gt;结尾我们重温一段郭德纲的视频:
&lt;span class="embed-responsive embed-responsive-16by9"&gt;&lt;iframe class="embed-responsive-item" src="//player.bilibili.com/player.html?bvid=1J54y1L7zN" allowfullscreen=""&gt;&lt;/iframe&gt;&lt;/span&gt;&lt;/p&gt;</description>
      <author>early</author>
      <pubDate>Sat, 21 May 2022 22:54:34 +0800</pubDate>
      <link>https://ruby-china.org/topics/42413</link>
      <guid>https://ruby-china.org/topics/42413</guid>
    </item>
    <item>
      <title>理解直播业务 (二)：用户为何打赏？人类隐密需求撑起商业循环</title>
      <description>&lt;ul&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/93456677" rel="nofollow" target="_blank" title=""&gt;直播 (上) -- 底层逻辑浅析&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/144920219" rel="nofollow" target="_blank" title=""&gt;直播 (中) -- 核心流程梳理&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/144920551" rel="nofollow" target="_blank" title=""&gt;直播 (下) -- 业务结构简介&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/484124484" rel="nofollow" target="_blank" title=""&gt;理解直播业务 (一)：直播因何存在？信息升维引爆内容供给&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/500894192" rel="nofollow" target="_blank" title=""&gt;理解直播业务 (二)：用户为何打赏？人类隐密需求撑起商业循环&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/517143091" rel="nofollow" target="_blank" title=""&gt;理解直播业务 (三)：平台在干什么？通过系统运作拿到结果&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;上篇文章我们探讨了直播本质是一种信息升维，是数字化技术叠加网速大幅提升的结果。&lt;/p&gt;

&lt;p&gt;信息能力提升带来了充沛的内容供给，这些前所未有且只能在直播间品尝的内容，吸入了大量的流量，当人群围绕主播 (或特定内容) 形成稳定聚集后，便形成了虚拟的社群，构建起动态稳定的交易 (互动) 环境。&lt;/p&gt;

&lt;p&gt;这些就是平常看到的直播平台，他们因为跑通了商业循环，所以仍存于市场。以此系统解释了直播因何出现。&lt;/p&gt;

&lt;p&gt;围绕上文铺垫的直播业务理解框架：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;因何出现&lt;/li&gt;
&lt;li&gt;因何持续存在&lt;/li&gt;
&lt;li&gt;平台主要在干什么&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;本文继续展开第二部分，探讨直播平台为何能持续存在，这是平台商业正循环背后的根基。由于围绕直播有大量的商业形态，我们限定讨论范围在网络直播间中的打赏付费行为。&lt;/p&gt;

&lt;p&gt;借此，通过引用心理学和社会学的解释框架，一窥亚文化社区形成的过程，理解在 B 站等社区广泛传播的符号，类似“yyds”背后的意义，以及直播虚拟社区给用户带来的价值。&lt;/p&gt;

&lt;p&gt;全文 7 千余字，以下是内容目录。
&lt;img src="https://l.ruby-china.com/photo/early/70500397-e620-46f3-83a2-547eca628010.png!large" title="" alt="内容结构"&gt;&lt;/p&gt;
&lt;h2 id="1）持续的付费"&gt;1）持续的付费&lt;/h2&gt;
&lt;p&gt;平台能持续存在，就意味着肯定跑通了某个商业循环，用人话来讲，就是平台挣钱&amp;gt;=平台花钱，而且平台的收入和成本在短期内是稳定可预期的。&lt;/p&gt;

&lt;p&gt;当前的网络直播平台收入大头还是用户对虚拟商品的消费，例如打赏、购买虚拟身份等。这种模式并非依赖广告，这意味着其营收并不直接依靠流量、点击率等，关注点在用户付费收入，类似 ARPPU 这种指标上。&lt;/p&gt;

&lt;p&gt;不同于互联网经典的收租模式，直播平台需要用户不断花钱才能维持运转，将用户围绕主播消费的钱，通过某种比例给主播分成，以此达成正循环。&lt;/p&gt;

&lt;p&gt;主播在更好的内容运营-&amp;gt;更多的用户打赏-&amp;gt;更多的分成中不断迭代；平台在更多的主播-&amp;gt;更丰富的内容-&amp;gt;内容吸引更多的用户中形成闭环增强，通过边际成本递减的业务系统持续扩大盈利规模。&lt;/p&gt;

&lt;p&gt;这一切的核心都是要用户付费，而且是持续的付费。&lt;/p&gt;
&lt;h2 id="2）用户不为内容付费"&gt;2）用户不为内容付费&lt;/h2&gt;
&lt;p&gt;关键问题来了，用户为什么会付费？&lt;/p&gt;

&lt;p&gt;上文分析过，直播的根基是内容，用户因为线下接触不到的内容，而聚集在直播间。那用户是为直播间里看到的内容付费吗？(这里内容是指在直播间看到的东西，下文同理)&lt;/p&gt;

&lt;p&gt;答案难以肯定，因为直播内容是免费的，我不付钱也可以看。送了礼的人和没送礼的人看到的内容差别并不大，那用户为什么要花钱？&lt;/p&gt;

&lt;p&gt;简单的内容逻辑、观赏价值很难带来持续稳定的消费。这并不能解释很多直播间发生的持续性地砸锅卖铁、挪用公款式的大额消费；还有持续不间断的小额付费。不断默默帮助主播冲榜的粉丝，他们并不是在买内容，也不用买。&lt;/p&gt;

&lt;p&gt;内容本身带来的主要是用户聚集 (注意力)，并不能直接导致金钱消费，直播没有“门票”的逻辑。那些疯狂挥金、持续为某个主播消费的家伙，一定是有更神秘持久的东西在牵引着他们，从而产生源源不断的付费。&lt;/p&gt;
&lt;h2 id="3）消费的背后是需求"&gt;3）消费的背后是需求&lt;/h2&gt;
&lt;p&gt;付费的本质实际上是交易，通过钱 (礼物) 换取某样东西。这里有两个要素，其一，买方有某种需求待满足，其二，卖方有能力满足。&lt;/p&gt;

&lt;p&gt;类比到实际的产业，为什么超市、服装、房地产等行业能持续运行？因为它们在满足人们衣食住行的需求，而这种需求只要人活着，便有日复一日的需求，直到生命终结。&lt;/p&gt;

&lt;p&gt;对应到直播付费，我们也可以推出几个要点：&lt;/p&gt;

&lt;p&gt;用户一定有某种需求，而且这种需求有一定的持续性
主播和平台，有能力和场景满足这种需求，或者说本身就是在售卖某种商品
送礼打赏等行为，在交易中起到了桥梁的作用，充当货币或桥接的功能
我们去餐馆花钱吃饭，钱买的是食物，目的是满足食欲，那打赏到底买的是什么？目的又是什么？核心在于，搞清楚这神秘的需求是什么，主播和平台如何满足需求。&lt;/p&gt;

&lt;p&gt;有声音认为神秘的地方就在于，网络直播提供了更优良的实时互动能力，基于互动呈现出强烈的社交属性。那用户是因为这个打赏吗？&lt;/p&gt;

&lt;p&gt;似乎难以直接回答，但能延伸出大量的疑问。为什么是社交和互动？为什么用户只给特定的主播打赏？社交互动给用户到底带来了什么？&lt;/p&gt;
&lt;h2 id="4）互动带来狂欢"&gt;4）互动带来狂欢&lt;/h2&gt;
&lt;p&gt;说到社交互动，就不得不提到亚文化社区。用户一般通过弹幕或评论相互感知，时常在特定时刻达成群体高潮。&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/0ae16ff5-db92-43fc-99eb-37cec3e71114.png!large" title="" alt="群体高潮 [1]"&gt;&lt;/p&gt;

&lt;p&gt;这种由特定符号或场景触发的集体情绪，会让“身在其中”的用户获得满足和快乐。张小龙说社交的本质是寻找同类，当大量的人和自己发出一样的弹幕，体验共同的情绪、感受或回忆，这种同类间的集体共鸣，会在虚拟社群中营造出归属感。&lt;/p&gt;

&lt;p&gt;对此，《互动仪式链》等社会学著作认为，互动是一种仪式，人类基于生存的本能，对团结 (被认同)、归属感有天生诉求，这可以在集体 (两人+) 中参与互动来获得，是人的原始社交动机。&lt;/p&gt;

&lt;p&gt;通过互动仪式链可以获得情感能量的满足，因而人会有意识地参与互动，寻求自己情感能量的最大化。情感能量指积极的心理/精神体验，如权力、归属感、满足感等，可以简单理解为爽/快感，对应一种多巴胺反应。&lt;/p&gt;

&lt;p&gt;互动仪式链的形成环境有四个条件，可以达成四个结果：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/68fa3e73-fa3a-4ac7-afca-a69e979f3836.png!large" title="" alt="四个条件和四个结果[2]"&gt;&lt;/p&gt;

&lt;p&gt;可以看出直播间是天然的互动场所。用户对直播间里人或事感兴趣，而聚集在一起，天然排除了局外人，在共同的关注点上很容易勾起相似的情感状态。&lt;/p&gt;

&lt;p&gt;此时用户通过弹幕等方式来互动表达情绪，相似的情绪会在直播间立即引发群体共鸣，这种群体情绪一般节奏快且强烈，仪式过后会带来一系列的产物，其中最核心的是情感能量的增强，和关系符号的产生。&lt;/p&gt;

&lt;p&gt;每一次诞生的情感能量或符号，可以作为下一次互动的基础，将情感能量进一步推高，诞生新的符号。这些符号可以组合，在叠加串联中形一个复杂的链条不断迭代，所以称为互动仪式链。&lt;/p&gt;

&lt;p&gt;互动仪式链比较抽象，我们简单套用理论，融合 yyds 这个符号的诞生过程来辅助理解。yyds 的诞生于网络直播间 [3]，其简要过程如下：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;喜欢游戏的用户聚集在山泥若的直播间中，他是 Uzi 的粉丝&lt;/li&gt;
&lt;li&gt;山泥若和用户在直播间聊他们都共同关注的游戏话题&lt;/li&gt;
&lt;li&gt;山泥若提到 Uzi，并表达对他的崇拜，称他为“永远滴神”&lt;/li&gt;
&lt;li&gt;房间中有大量也关注 uzi 的粉丝，随即引发大量用户跟随表达认同，这种崇拜情感迅速引发群体共鸣&lt;/li&gt;
&lt;li&gt;群体互动让众人获得情感能量，直播间诞生出集体团结，这种团结情绪，会排斥贬低 uzi 的声音，作为一种群体的道德感沉淀下来&lt;/li&gt;
&lt;li&gt;山泥若后续在直播间，又对 Uzi 多次“永远滴神”的称赞，持续地引发群体共鸣，情感能量在持续积累中到达临界值，导致群体符号 yyds 的诞生，这种符号代表了群体情感的结晶，是互动仪式链的重要产物。&lt;/li&gt;
&lt;li&gt;yyds 作为新的群体符号，在东京奥运会互动场景中被不断使用，引发更大群体共鸣，卷入更多用户，最终出圈。&lt;/li&gt;
&lt;li&gt;时至今日，yyds 作为通用的群体符号，表达对某人的崇拜，可在互动中直接使用。在发现其他人不懂 yyds 是什么时，会反向衬托出群体认同感，这种排他性会额外带来优越感，增强情绪强度&lt;/li&gt;
&lt;li&gt;例如，用户聚集在偶像的直播间中，当偶像达成新的成绩时，群体基于关注和认同，会用 yyds 直接来表达情绪，这让直播间的用户立即感受到群体共鸣，助推情感能量进一步积累，在某个时刻可能会再次创造出新的符号&lt;/li&gt;
&lt;li&gt;新符号可能作为其他互动场景的表达语言，并助推新的情感能量和符号，这便是互动仪式链的简要内涵。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="5）用户时刻在追求情感能量"&gt;5）用户时刻在追求情感能量&lt;/h2&gt;
&lt;p&gt;上面我们套用互动仪式链理论，解释了风靡的群体符号诞生过程，这其实是亚文化社区的形成缩影。有几个核心要点：&lt;/p&gt;

&lt;p&gt;在群体中，用户会因其关注的内容或事件，参与互动仪式，目的是寻求情感能量
互动仪式可以给用户带来情感能量，这会反向强化其持续参与互动
社群积累的情绪、团结、符号等，会将用户串联交织在一起，使情感能量能持续迭代
简单来理解，互动是一种仪式，人可以通过仪式获得情感能量，就像婚礼仪式强化感情，表白仪式激发爱意一样。互动仪式学说认为，基于生存的本能，人会有意识地通过互动仪式，不断强化自己的情感能量。具体表现是：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;找与自己能量相当，且有共同关注点的群体互动，寻求共鸣和归宿感（或打发时间）&lt;/li&gt;
&lt;li&gt;找自己喜欢或崇拜的高能量群体互动，寻求认可（2 和 3 之间是交换）&lt;/li&gt;
&lt;li&gt;找部分比自己能量低的群体互动，享受权力地位&lt;/li&gt;
&lt;li&gt;拒绝部分比自己能量低的群体互动，避免能量损耗&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这也解释了为什么无聊会让人痛苦，因为此时处于情感能量的真空。人会按耐不住，主动去找朋友陪伴，或在感兴趣的事情上消磨时间。&lt;/p&gt;

&lt;p&gt;到此，我们明白了社交互动给用户带来的是情感能量 (爽)，人也在不断地追求情感能量的最大化，这是原始需求。&lt;/p&gt;

&lt;p&gt;接下来我们回到直播间。&lt;/p&gt;
&lt;h2 id="6）直播间的能量分布"&gt;6）直播间的能量分布&lt;/h2&gt;
&lt;p&gt;直播间的群体主要分为三种。&lt;/p&gt;

&lt;p&gt;其一是主播，拟剧理论将人的生活场景分为前台和后台，前台是人按照自己的角色表演的地方，会隐藏和伪装，就像演员在台上表演。后台是准备前台表演的地方，例如家里，是展露真实自己的地方。&lt;/p&gt;

&lt;p&gt;网络直播间一般设在主播的房间里，镜头直接对着主播的脸，这算是一种后台直播，将私密场景暴露给用户，后台的内容更符合人窥私的欲望，可以更好吸引关注。但实际上，在镜头中露出的主播，会根据自己的特点精心装扮，用拟定好的人设展开表演。&lt;/p&gt;

&lt;p&gt;用后台的视角进行前台表演，能很好吸引用户关注，并在用户面前呈现出高维度的情感能量。&lt;/p&gt;

&lt;p&gt;其二是观看中的用户，在直播间逗留的用户，要么是对主播感兴趣，要么是关注直播间的内容。换句话说，留下的用户是经过情感能量筛选的结果。&lt;/p&gt;

&lt;p&gt;其三是互动中的用户，这部分和第二部分类似，不过他们已经在强化自己的情感能量，互动的过程中，他们也变成了直播间内容的一部分，这可以吸引有共同关注点的用户。&lt;/p&gt;

&lt;p&gt;直播间中的局势是，处于情感能量低潮的用户，为寻求情感能量满足，围绕在高情感势能的主播周围，形成一个蓄势待发的互动仪式链环境，犹如干柴堆在火炉旁边。&lt;/p&gt;
&lt;h2 id="7）主播激发情感需求"&gt;7）主播激发情感需求&lt;/h2&gt;&lt;h3 id="情感唤醒"&gt;情感唤醒&lt;/h3&gt;
&lt;p&gt;房间中的用户，可能会在主播产生的内容，例如学识、美貌、个性、趣闻、经历、牛逼操作等，或互动用户产生的内容中，遇到自己的关注点。&lt;/p&gt;

&lt;p&gt;在人性的窥私、色欲、虚荣、贪婪、懒惰等天性加持下，用户的情感能量需求可以被轻易激发。在高实时的群体互动氛围中，产生互动意愿。此时大部分用户会更投入地观看，有些会蠢蠢欲动想要发个弹幕参与互动，获取情绪释放和能量满足。&lt;/p&gt;
&lt;h3 id="情感卷入"&gt;情感卷入&lt;/h3&gt;
&lt;p&gt;当互动意愿积累到一定程度，有些用户会开始忍不住要参与互动。一般从简单的浅层互动开始，发个弹幕参与起来，例如弹幕夸奖主播“你好漂亮啊”。这个过程可能会和其他用户产生情绪共鸣引发群体情绪共振，也可能主播会回应用户的弹幕内容，达成双向互动，这都可能强化用户的情绪。&lt;/p&gt;

&lt;p&gt;当这种良性反馈持续发生时，用户的情感能量会持续积累，同时和主播的情感连接渴望会逐步增强。在情绪积累的过程中，用户会不自觉地想要得到更加强烈的情感能量。&lt;/p&gt;

&lt;p&gt;此时便会发生情感卷入，较弱的卷入，会让用户持续关注主播间或通过弹幕频繁互动。强烈的卷入会对主播产生爱慕、崇拜、认同、共鸣等情感，此时简单的弹幕浅层互动已经无法满足更高的情感能量需求，只单向夸主播牛逼已经不能满足，开始渴望和主播的搭上话，让自己被关注。这时到达负多巴胺的状态，求而不得，需要更强的渠道释放。&lt;/p&gt;

&lt;p&gt;就像看到一个帅哥，虽然有些好感，但忍一忍不去想也就淡忘了。但如果一不小心搭个讪聊了会天 (互动)，好感可能就会巩固下来。一旦感情上的需求被唤醒，在撩拨或持续的接触中，便可能会一发不可收拾。&lt;/p&gt;

&lt;p&gt;在直播间这种连贯的实时互动场景下，情绪的积累会悄无声息的进行，并在持续的投入中不断累加。身在其中的人往往会被无声的卷入。&lt;/p&gt;

&lt;p&gt;高段位的主播，往往有套路来引导这个过程 [4]。&lt;/p&gt;
&lt;h2 id="8）用户忍不住开始打赏"&gt;8）用户忍不住开始打赏&lt;/h2&gt;&lt;h3 id="基于情感的打赏"&gt;基于情感的打赏&lt;/h3&gt;
&lt;p&gt;当用户情绪到达临界值，而弹幕无法满足诉求时，用户需要借助更深入的互动手段，这就是送礼打赏。&lt;/p&gt;

&lt;p&gt;从互动仪式上来讲，送礼是更加正式的互动形式，在长期的文化印象中，礼物更是友谊和社会纽带的象征。一方面这种更强烈的仪式有更好的能量反哺，另一方面礼物的送出会使得主播立即作出回应，这都满足了更强力度的互动需要。&lt;/p&gt;

&lt;p&gt;礼物的设置本身也学问满满，首先是丰富的品类，其次是有明显的价格梯度。用户可以根据当前情绪强度和自身的财力作出选择。喜爱者送个鲜花，表达认同的人送个赞传递鼓励。&lt;/p&gt;

&lt;p&gt;礼物送出后，用户的名字会在直播间显眼处被所有人看到，并在特有的礼物特效和主播的回应中，得到强烈的快感和满足。情感能量，以及和主播之间的情感连接都再次加强。遇到会撩的主播，真诚/暧昧/亲密的回应可能会让用户忍不住再次打赏。&lt;/p&gt;

&lt;p&gt;在直播营收功能设计上，消费金额的积累一般都会转化成用户身份或符号，不管是“榜单大哥”，还是得到某种等级、特权、荣誉，这些身份符号，都会让用户的情感能量得以沉淀。&lt;/p&gt;

&lt;p&gt;于此同时，身份、符号等会在直播间这个特定的环境中，烘托出社会地位和权利的影子。&lt;/p&gt;
&lt;h3 id="基于竞争的打赏"&gt;基于竞争的打赏&lt;/h3&gt;
&lt;p&gt;不管是友情还是爱慕都有相当的排他性。当主播展露出情感吸引力，对于想获得主播情感的用户来讲，其他人都是竞争对手。&lt;/p&gt;

&lt;p&gt;最直观的就是主播的回应和态度，每个人可能都想要得到最亲密的回馈，也想在直播间里更加耀眼。不同的打赏礼物和数量，会在用户间形成攀比。数额少的人可能会产生羞耻，在获得主播冷淡回应后产生嫉妒，这些负面情绪，很大程度会引导用户产生攀比竞争，引发更大额度的打赏行为。&lt;/p&gt;

&lt;p&gt;另一种竞争关乎地位。慷慨的打赏，让主播和用户都交口称赞，大量的用户对打赏者发出崇拜的声音，在直播间形成较高的社会地位 (榜单排名、众人夸赞、特权)，在炫耀中获得情感能量。&lt;/p&gt;

&lt;p&gt;这种权力效应可能会让打赏者在快感中再次挥金。更进一步，权力本身的排他性，也可能会引发其他大哥嫉妒，激发占有欲。于是基于权力地位的竞争，可能继续引发更疯狂的礼物竞赛，争夺榜单头名，或成最靓的仔。&lt;/p&gt;
&lt;h3 id="基于投射的打赏"&gt;基于投射的打赏&lt;/h3&gt;
&lt;p&gt;直播间中，用户均是以前台匿名的身份参与互动，没人管 ta 从何而来，在现实生活中如何。直播间提供了一个全新的社会环境，可以基于情感、人设、社会地位等对自己进行重新构建，投射到自己理想的人设和生活状态。&lt;/p&gt;

&lt;p&gt;将主播当成知己/爱人来陪伴；通过送鲜花礼物，不断体验友谊升华/求爱成功；或者在大方的打赏中获取群体认同、社会地位、主播的崇拜。&lt;/p&gt;

&lt;p&gt;现实生活可能不尽如人意，但在虚拟世界，一切都可以按照自己的预想来运转，只要花钱就可以让自己获取超现实的体验，扮演一个情感丰富、受人尊重、大方豪爽的“人”，获得友谊、陪伴、认可、自我实现。&lt;/p&gt;
&lt;h3 id="重构生活"&gt;重构生活&lt;/h3&gt;
&lt;p&gt;我们可以看出，网络直播实际上提供了一种虚拟的环境，让用户可以获得快乐。用户的情绪在不断地积累和释放，每次都将情感能量推向更高水平，这在现实生活中很难简单实现。&lt;/p&gt;

&lt;p&gt;人说到底是多巴胺的奴隶，获取快感的方式和途径，便决定了人的生活方式，如何处理时间，如何度过人生。习惯了在直播间这种虚拟环境获取快感，那自然会持续性消费打赏，形成路径依赖，甚至可以成为生活的一部分。当然凡事皆应有度，过犹不及。
&lt;img src="https://l.ruby-china.com/photo/early/e13811b7-f599-4d22-a456-0bcce8e5742c.png" title="" alt="直播间里的情感能量"&gt;&lt;/p&gt;
&lt;h2 id="9）总结"&gt;9）总结&lt;/h2&gt;
&lt;p&gt;基于上文简单的推导，我们来做一次总结。&lt;/p&gt;
&lt;h3 id="直播间是情感能量的孵化场"&gt;直播间是情感能量的孵化场&lt;/h3&gt;
&lt;p&gt;自然聚集的直播间，将一群有相似关注点的人连接在一起，在关注点高度匹配，高实时互动的直播间中，极易引发群体兴奋。&lt;/p&gt;

&lt;p&gt;在主播这个魅力体的操盘下，用户的情感需求会被逐渐唤醒，在持续的互动中加深情感卷入程度。随后在情感释放、竞争裹挟、自我投射等因素驱动下，寻求更大强度的互动。&lt;/p&gt;

&lt;p&gt;所谓在河边容易湿鞋，湿了鞋顺便洗个脚，如果缺水的滋养，还要洗个澡。人性里的那些欲望，在直播间这个特质的熔炉中，会像干柴一样剧烈燃烧。&lt;/p&gt;
&lt;h3 id="礼物是快乐高速上的收费站"&gt;礼物是快乐高速上的收费站&lt;/h3&gt;
&lt;p&gt;礼物打赏，本质上是对互动行为的付费。送礼是为了互动，互动会带来快感。打赏模式很好的利用了群体互动，依赖仪式的特点。将礼物文化和直播间场景相结合，捆绑打造出更强烈度的互动方式，在情感能量上升的关键要道设卡收费，通过精细化、多梯度的礼物道具设计，实现对情感能量的量化“征税”。&lt;/p&gt;

&lt;p&gt;直播间对有相同关注点的人群来讲，是一个情感能量孵化场所，这满足了人天然追求情感能量的需求。直播打赏的运作形式可以简单理解为：主播激发潜在情感需求、平台提供收费工具、用户在欲望的积累和释放中获得快乐。&lt;/p&gt;
&lt;h2 id="10）不足"&gt;10）不足&lt;/h2&gt;
&lt;p&gt;本文探讨的内容属于解释性学科范畴，也参考了很多质性研究的结论。鉴于笔者水平有限，在部分内容的表述上可能会有明显的局限性。&lt;/p&gt;
&lt;h2 id="参考资料"&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[1] &lt;a href="https://baijiahao.baidu.com/s?id=1681605476541978235&amp;amp;wfr=spider&amp;amp;for=pc" rel="nofollow" target="_blank"&gt;https://baijiahao.baidu.com/s?id=1681605476541978235&amp;amp;wfr=spider&amp;amp;for=pc&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[2] &lt;a href="https://new.qq.com/rain/a/20220217a09i0k00" rel="nofollow" target="_blank"&gt;https://new.qq.com/rain/a/20220217a09i0k00&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[3] &lt;a href="https://shishang.jiangzi.com/tuwen/shenghuo/153955.html" rel="nofollow" target="_blank"&gt;https://shishang.jiangzi.com/tuwen/shenghuo/153955.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[4] &lt;a href="https://gb.global.cnki.net/KCMS/detail/detail.aspx?dbcode=CJFD&amp;amp;dbname=CJFDLAST2019&amp;amp;filename=QNYJ201904001" rel="nofollow" target="_blank"&gt;https://gb.global.cnki.net/KCMS/detail/detail.aspx?dbcode=CJFD&amp;amp;dbname=CJFDLAST2019&amp;amp;filename=QNYJ201904001&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[5] &lt;a href="https://www.sohu.com/a/164115150_483391" rel="nofollow" target="_blank"&gt;https://www.sohu.com/a/164115150_483391&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[6] &lt;a href="https://mp.weixin.qq.com/s/Es4T9GfoVE-ZU1J-CBS5vg" rel="nofollow" target="_blank"&gt;https://mp.weixin.qq.com/s/Es4T9GfoVE-ZU1J-CBS5vg&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[7] &lt;a href="http://www.doc88.com/p-4107422687197.html" rel="nofollow" target="_blank"&gt;http://www.doc88.com/p-4107422687197.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[8] &lt;a href="https://www.doc88.com/p-3052520109501.html" rel="nofollow" target="_blank"&gt;https://www.doc88.com/p-3052520109501.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[9] &lt;a href="http://media.people.com.cn/n/2013/0106/c354014-20108461.html" rel="nofollow" target="_blank"&gt;http://media.people.com.cn/n/2013/0106/c354014-20108461.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[10] &lt;a href="https://zhuanlan.zhihu.com/p/136577918" rel="nofollow" target="_blank"&gt;https://zhuanlan.zhihu.com/p/136577918&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[11] &lt;a href="https://gb.global.cnki.net/KCMS/detail/detail.aspx?dbcode=CJFD&amp;amp;dbname=CJFDLAST2019&amp;amp;filename=SZXY201904039" rel="nofollow" target="_blank"&gt;https://gb.global.cnki.net/KCMS/detail/detail.aspx?dbcode=CJFD&amp;amp;dbname=CJFDLAST2019&amp;amp;filename=SZXY201904039&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[12] &lt;a href="https://gb.global.cnki.net/KCMS/detail/detail.aspx?dbcode=CMFD&amp;amp;dbname=CMFD201702&amp;amp;filename=1017159337.nh" rel="nofollow" target="_blank"&gt;https://gb.global.cnki.net/KCMS/detail/detail.aspx?dbcode=CMFD&amp;amp;dbname=CMFD201702&amp;amp;filename=1017159337.nh&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[13] &lt;a href="https://gb.global.cnki.net/KCMS/detail/detail.aspx?dbcode=CJFD&amp;amp;dbname=CJFDLAST2016&amp;amp;filename=BJZY201611011" rel="nofollow" target="_blank"&gt;https://gb.global.cnki.net/KCMS/detail/detail.aspx?dbcode=CJFD&amp;amp;dbname=CJFDLAST2016&amp;amp;filename=BJZY201611011&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>early</author>
      <pubDate>Sat, 21 May 2022 22:43:53 +0800</pubDate>
      <link>https://ruby-china.org/topics/42412</link>
      <guid>https://ruby-china.org/topics/42412</guid>
    </item>
    <item>
      <title>理解直播业务 (一)： 直播因何存在？ 信息升维引爆内容供给</title>
      <description>&lt;ul&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/93456677" rel="nofollow" target="_blank" title=""&gt;直播 (上) -- 底层逻辑浅析&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/144920219" rel="nofollow" target="_blank" title=""&gt;直播 (中) -- 核心流程梳理&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/144920551" rel="nofollow" target="_blank" title=""&gt;直播 (下) -- 业务结构简介&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/484124484" rel="nofollow" target="_blank" title=""&gt;理解直播业务 (一)：直播因何存在？信息升维引爆内容供给&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/500894192" rel="nofollow" target="_blank" title=""&gt;理解直播业务 (二)：用户为何打赏？人类隐密需求撑起商业循环&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/517143091" rel="nofollow" target="_blank" title=""&gt;理解直播业务 (三)：平台在干什么？通过系统运作拿到结果&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;上一个系列从偏技术视角对直播做了简单的介绍，本次将尝试以更宏观的角度，对直播做更深刻的透视，算是对这两年，在业务理解上的粗浅沉淀做一次总结。&lt;/p&gt;
&lt;h2 id="1) 理解框架"&gt;1) 理解框架&lt;/h2&gt;
&lt;p&gt;每个系列第一篇文章往往最为重要，因为需要先搭起一个架子，让内容和思路能顺势填充，最后形成一个自洽的整体。最近在看《系统之美》，将直播当成一个系统去勾勒，我想正是本系列的关键。&lt;/p&gt;

&lt;p&gt;我们周围的一切都是叠加到当前态上，不断往前滚动。就像一份新感情，是基于当下的才华/颜值/心灵/需求，再叠加上一次次的碰撞升级而成，中间少了哪怕一次关系升华可能都会烟消云散。当然感情也可能在产生后，由于某个变量改变而逐渐消亡。&lt;/p&gt;

&lt;p&gt;一个事物不会凭空产生，也不会悬空一直存在。其出现、存续、消亡都有原因，而且内在逻辑都是基于当前现状的逐步演进。&lt;/p&gt;

&lt;p&gt;基于上面的推演，我们埋几个问题，搭建起本系列的理解框架：&lt;/p&gt;
&lt;h3 id="1. 直播因何出现？"&gt;1. 直播因何出现？&lt;/h3&gt;
&lt;p&gt;基于什么样的基础 + 新的变量，导致其诞生？我们需要理解增量如何演化，最终形成了稳定的直播业务。&lt;/p&gt;

&lt;p&gt;(本文探讨范围为“网络直播”)&lt;/p&gt;
&lt;h3 id="2. 直播平台为什么能持续存在？"&gt;2. 直播平台为什么能持续存在？&lt;/h3&gt;
&lt;p&gt;持续存在意味着一定跑通了商业循环，这里面一定有稳定的需求被持续满足。基于当前主流的商业模式，核心原因是用户在持续地消费。&lt;/p&gt;

&lt;p&gt;但为什么用户愿意不断地花钱？这背后是什么隐秘而持久的需求？&lt;/p&gt;
&lt;h3 id="3. 平台主要在干什么？（目标/功能）"&gt;3. 平台主要在干什么？（目标/功能）&lt;/h3&gt;
&lt;p&gt;前两个点是核心要素，平台作为商业组织，一定有其盈利目标。而其目标往往是基于对核心要素的利用或强化而达成，我们需要基于当前框架更好地理解平台的行为。&lt;/p&gt;

&lt;p&gt;本系列将围绕上面三个问题逐步展开。&lt;/p&gt;
&lt;h2 id="2) 信息升维"&gt;2) 信息升维&lt;/h2&gt;
&lt;p&gt;当图片的载体从胶卷转移到数码相机后，图片和视频信息便实现了数字化，可以借助网络传播。近些年网速的大幅提升，让视频能直接在线播放。直播实际上是数字化技术，叠加网速大幅提升的结果。&lt;/p&gt;

&lt;p&gt;从图片到视频，画面可以被连续性地采集，叠加了空间维度的信息。从视频到直播，是在信息采集的基础上，叠加了实时性。&lt;/p&gt;

&lt;p&gt;可以看出，文字-&amp;gt;图片-&amp;gt;视频-&amp;gt;直播，这个演变过程，逐步叠加了视觉、空间、时间等多个维度。从最早的飞鸽传书，通过书面文字延时传播信息，过渡到人类视听感官实时隔空感知信息。这是信息能力的升维。&lt;/p&gt;

&lt;p&gt;直播就是在视频的基础上叠加了时间维度，本质是依赖科技打破物理学刚性约束，实现进一步的信息升维。从不同的视角看，这提供了两种新的能力：&lt;/p&gt;

&lt;p&gt;消费端。普罗大众可以通过视听感官，实时捕获另一个时空正在发生的事，并在互动中得到身临其境的体验，不再受限于自己的视觉和听力。
生产端。某个时空的人或物，可以接近 0 边际成本，将自己与数量无限的人实时连接互动，不再受限于教室或体育场的大小。
&lt;img src="https://l.ruby-china.com/photo/early/3873187e-83df-4759-bb76-b2a395bae279.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;近几十年的发展，实际上都是得益于信息的逐步升维。先信息革命，信息低成本流通，组织起前所未有的生产力，然后导致社会一系列的巨大发展。直播是在这个过程中的进一步延续。基于数字化技术的沉淀，在网速这个变量大幅提升后，诞生出的新能力。&lt;/p&gt;

&lt;p&gt;能力圈的扩充，会直接导致新事物迸发。二生三，三生万物。&lt;/p&gt;
&lt;h2 id="3) 充沛的内容供给"&gt;3) 充沛的内容供给&lt;/h2&gt;
&lt;p&gt;在网络直播普及前，电视直播早已经是家常便饭，但其在信息能力上是相对匮乏的。从生产的角度看，内容的供给非常有限，仅限于少量节目，内容领域狭窄。从体验角度看，观众只能凑个热闹，没有实时互动能力，属于单向输出。&lt;/p&gt;

&lt;p&gt;而网络直播，则是将内容生产能力下放给了千家万户，内容形式角色极大丰富。主要体现在几个方面：&lt;/p&gt;

&lt;p&gt;内容维度非常丰富，线下见过的一切都可以线上化。
实时互动，弹幕、连麦，活生生造了一个真人面对面。
创作成本大幅降低，开启摄像头直接表达就行，视频投稿剪片子就得一星期。
主播门槛大幅降低。美颜、变声、游戏等。
这直接带来了充沛的内容供给。&lt;/p&gt;

&lt;p&gt;相较于线下，内容线上化后，相当于无中生有地提供了稀缺的内容。坐在跟前一对一唱歌卖萌的小姐姐，声音酥软的治愈男声，身边根本遇不到。偶像/魅力体面对面身临其境，去机场堵几个小时人影可能都看不到。还有无数从未见过、听过的、无法接触到的人、物、声音、个性化、故事···&lt;/p&gt;

&lt;p&gt;现在通过网络直播，打开手机随时随地都能看到。&lt;/p&gt;

&lt;p&gt;因为实时可互动，这些内容构造了近似一对一的即时体验，一般人在线下根本接触不到，如果是看视频又会退化成凑热闹。更要命的是，这些供给都可以完美匹配人性那点欲望，窥私、猎奇、荷尔蒙、慕强、幻想、情感依托···&lt;/p&gt;

&lt;p&gt;这让直播刚出来就迅速占领大量人的注意力，成为一种新的消费场景，杀时间利器。&lt;/p&gt;
&lt;h2 id="4) 千播大战"&gt;4) 千播大战&lt;/h2&gt;
&lt;p&gt;C 端业务的根基，实际上就是用户的注意力，“国民总时间”吸收的越多越稳定，平台就有无限可能。直播新能力创造的无限内容，能以全新的战斗力攫取用户的注意力，这意味着有新的商业处女地可以开发。&lt;/p&gt;

&lt;p&gt;社会是一个无限游戏的当前时，当前的稳态一般呈现为：&lt;/p&gt;

&lt;p&gt;战功分配，前期流血流汗/抓住机会的人占住了位置 (市场份额)，并享有特有权利或分利份额。不管后来的人能力如何牛逼，如果不能制造出新的增量，“老人”的位置便无可撼动。没有战功的新兵无法得到提拔。
行为习惯，假设我现在抄袭微信做一个新的 APP，不能分走微信的丝毫份额，因为用户已经形成习惯和关系网络，他们没有任何迁移的动力。
只有全新的能力和体验，才能从当前稳定的存量中分得份额，或者创造出增量。围绕着：新变量-&amp;gt; 开拓-&amp;gt;争夺-&amp;gt;逐渐稳态-&amp;gt;胜者分利-&amp;gt;新来的没汤喝的逻辑，当直播这个新物种出现时，意味着现有市场份额的重新洗牌，可预见平台竞争会蜂拥而至。&lt;/p&gt;

&lt;p&gt;平台利用直播的能力，先抢到用户时间，获得市场份额，随后再慢慢寻求商业闭环，让自己能一直运行下去。现在还持续存活的平台，大多都是做到了这点。&lt;/p&gt;
&lt;h2 id="5) 用户聚集形成虚拟社会"&gt;5) 用户聚集形成虚拟社会&lt;/h2&gt;
&lt;p&gt;直播的内容看得见的是主播，看不见的是氛围。用户之间，可以通过弹幕、和主播互动、排行榜等感受到彼此的存在，并在等级、特权、排名、和主播关系等细节上感受到彼此的差异。&lt;/p&gt;

&lt;p&gt;当用户汇集于直播间中，且数量达到一定规模和稳定性，能彼此感知的用户便在直播间这个虚拟空间中形成了一个团体，类似于线下的班级、小区。俗话说有人的地方就有江湖，有江湖便有纷争比较，一切都围绕着主播展开。&lt;/p&gt;

&lt;p&gt;或基于慕强、攀比心理，引导用户竞争，相互比较自己和主播的关系、亲密程度；或基于社会地位、虚荣引导用户挥金，成为当前圈子最闪耀的仔。或在爱慕或对强者的崇拜中，达成心理投射。当有了排他性的身份标识或排名榜单，江湖自然就会卷起来。&lt;/p&gt;
&lt;h2 id="6) 虚拟社会引导消费"&gt;6) 虚拟社会引导消费&lt;/h2&gt;
&lt;p&gt;聚集形成的虚拟社会，和真实世界大致相似。不同的是，在现实中没能力得到，或得不到的情感、地位、满足感、心理体验等等，在这里花一点钱就可以反复获得。&lt;/p&gt;

&lt;p&gt;用户通过花钱获得这些体验的途径，便是直播营收能力的核心功能，往往是一套自洽的礼物、身份、关系、排行榜、互动能力、特权体系。本质上是基于人的社会性/心理需求，构建出的一套解决方案，让用户通过花钱得到满足，没有沮丧挫折，不会被拒绝，一切都在自己掌控之中。&lt;/p&gt;

&lt;p&gt;这些消费是平台跑通用户-&amp;gt;主播-&amp;gt;平台商业循环的核心，而其根基就是大量用户聚集形成的虚拟社会，主播存在感越强，"社会"氛围越健壮越此起彼伏，消费就会越活跃。&lt;/p&gt;

&lt;p&gt;当大量用户对这种获得满足的方式形成了依赖，便会产生持续的平台消费。这会反哺氛围，让消费和虚拟环境形成相互强化，将平台收入推到更高的位置。&lt;/p&gt;
&lt;h2 id="7） 平台引导正循环"&gt;7）平台引导正循环&lt;/h2&gt;
&lt;p&gt;上面我们逐步推导了，从信息升维到最后产生平台收益的整个过程。&lt;/p&gt;

&lt;p&gt;下面通过一张图，梳理所有关键要素以及传导关系，从最底层信息升维开始，要素间相互作用，彼此增强，最后平台收益反哺主播强化内容生产意愿，整个系统跑通正循环。&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/009b3a2b-86ca-4e49-b615-ba7ee83b0d50.png!large" title="" alt="直播系统回路"&gt;&lt;/p&gt;

&lt;p&gt;如上图，直播的业务可以大致分为三层：&lt;/p&gt;

&lt;p&gt;第一层：内容供给，作为平台的根基
第二层：虚拟社区，用户、主播发生交易，作为商业化的根基
第三层：平台运作激发正循环，快速低成本达成业务目标
第一层和第二层可以自然形成，但往往强度不够、过程缓慢。主播和用户间缺乏更强的刺激建立连接，当用户对内容供给逐渐失去新鲜感后，第一二层的循环便可能出现松动。&lt;/p&gt;

&lt;p&gt;平台基于自身目标，会提供更加常态化、外部通道来强化主播和用户之前的连接，并刺激内容生产，通过外部干预的方式强化正循环，也为了刺激更出更高的增长实现商业变现。&lt;/p&gt;
&lt;h2 id="8) 总结"&gt;8) 总结&lt;/h2&gt;
&lt;p&gt;本文作为系列的第一篇，我们以三个问题构建了一套理解框架。并从系统思维角度，通过对要素的演进和关联关系的推导，对第一个问题：直播因何出现？做了回答。&lt;/p&gt;

&lt;p&gt;简要逻辑链如下：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;一切的源头在于信息升维，技术的变革导致生产能力的跳跃性提升&lt;/li&gt;
&lt;li&gt;生产能力解放使得原本稀缺的内容被大规模生产&lt;/li&gt;
&lt;li&gt;新鲜的内容能更好地满足了人性需求，用户注意力开始大量转移&lt;/li&gt;
&lt;li&gt;消费直播成为常态后，用户在直播间稳定聚集&lt;/li&gt;
&lt;li&gt;相互能感知的人群，在直播间形成了虚拟的社会&lt;/li&gt;
&lt;li&gt;虚拟社会为用户的心理或社会性需求提供便利的满足方式&lt;/li&gt;
&lt;li&gt;用户开始付费，并在形成依赖后，为平台创造稳定收益&lt;/li&gt;
&lt;li&gt;平台收益反哺主播，刺激主播更多内容创作、更好地氛围营造&lt;/li&gt;
&lt;li&gt;平台跑通商业循环，头部平台持续存在&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;其中营收产生的关键在于用户对于心理或社会性需求的持续消费，其内在逻辑隐秘而强大，我们需要对其进行更细致的分析，此部分将在第二篇文章展开，届时将尝试对人的隐秘需求进行探讨。&lt;/p&gt;

&lt;p&gt;平台的运营，本质上是基于整个系统，对关键要素和彼此的影响回路进行刺激干预，让系统以最少的成本，在最快的时间内达成正循环，并最终获得盈利。此部分将在第三篇文章中进一步探讨。&lt;/p&gt;</description>
      <author>early</author>
      <pubDate>Sat, 21 May 2022 22:36:49 +0800</pubDate>
      <link>https://ruby-china.org/topics/42411</link>
      <guid>https://ruby-china.org/topics/42411</guid>
    </item>
    <item>
      <title>解决问题的套路：数据库高写入挑战</title>
      <description>&lt;p&gt;在平时解决问题时，往往开始会一头雾水，在跌跌撞撞试错很多后，才勉强得到了合格的答案。但反观有些人非常有章法且迅速地就拿出了解决方案，有板有眼，既有说服力又预留了不少可能性。高下立判。&lt;/p&gt;

&lt;p&gt;其实对于大部分常规问题，都是有套路可循的，别人动作快质量高，其实是基于套路的迅速复制，而且在大量复制的过程中，沉淀出了场景应变能力，将各类知识串起来，形成了高维度的能力代差。&lt;/p&gt;

&lt;p&gt;本文开启一个系列，基于历史上对问题解决过程的复盘，我简单总结了一个套路。本文及后续的系列，会通过实际案例来对套路进行阐述及迭代。&lt;/p&gt;

&lt;p&gt;不仅限于问题解决方案的探讨，将注意力往下沉，关注解决方案如何诞生。通过对元能力的持续总结，提升沉淀效果。&lt;/p&gt;

&lt;p&gt;套路分为几步：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;第一性原理：通过连续追问直到问题根源，形成一颗树&lt;/li&gt;
&lt;li&gt;演绎回溯：从树的底部，往上逐层脑爆解决方案&lt;/li&gt;
&lt;li&gt;归纳：将脑爆方案分类梳理组合，得出优缺点&lt;/li&gt;
&lt;li&gt;带入变量：带入系统变量，通过优缺点变化趋势，找出最佳匹配&lt;/li&gt;
&lt;li&gt;更进一步：场景扩展延伸，形成应对预案，夯实系统扩展性&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="场景"&gt;场景&lt;/h2&gt;
&lt;p&gt;榜单是直播非常重要的场景，这里承载了用户的存在感，主播荣誉感，直接影响商业变现。&lt;/p&gt;

&lt;p&gt;榜单的本质是，某种积分规则在一定时间区间的排序结果，例如用户打赏排名，对花钱金额做排序；如主播营收榜，对主播挣的钱做排序。这种排序一般有一个时间区间限制，例如日榜、周榜。&lt;/p&gt;

&lt;p&gt;每一次积分变化，都会对积分值做更新，产生新的排序结果。&lt;/p&gt;
&lt;h2 id="冲突"&gt;冲突&lt;/h2&gt;
&lt;p&gt;一般为了持久化和可靠性，都会用数据库做存储。在积分变化时，对应的就是数据库的一次 update 操作：score = score + 增量，对一致性要求高的场景，还需要在事务中操作并写一次流水。&lt;/p&gt;

&lt;p&gt;当某个大主播房间出现大规模送礼时，问题来了：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;热点问题：主播榜所有用户送礼对应的积分会写同一行数据&lt;/li&gt;
&lt;li&gt;写放大：当同时有很多个榜时，一次送礼会在榜单出现多次写&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;一般的系统设计，在送礼高峰下数据库很可能会被打瘫痪。如果强行做缓冲，那更新会延迟，损害用户体验。&lt;/p&gt;

&lt;p&gt;怎么办？我们来套一下上面的框架。&lt;/p&gt;
&lt;h2 id="解决方案"&gt;解决方案&lt;/h2&gt;&lt;h3 id="第一性原理"&gt;第一性原理&lt;/h3&gt;
&lt;p&gt;通过连续追问形成一颗问题树，直到无法拆解或无法解决。
&lt;img src="https://l.ruby-china.com/photo/early/0c2045a8-f64b-4f1f-936c-f9623a2e2440.png!large" title="" alt=""&gt;&lt;/p&gt;
&lt;h3 id="演绎回溯"&gt;演绎回溯&lt;/h3&gt;
&lt;p&gt;从问题树根部往上逐层提出解决方案，不设定约束进行头脑风暴：
&lt;img src="https://l.ruby-china.com/photo/early/91679e09-75d3-4e47-915c-74c06d77171b.png!large" title="" alt=""&gt;&lt;/p&gt;
&lt;h3 id="归纳"&gt;归纳&lt;/h3&gt;
&lt;p&gt;基于头脑风暴的内容，整合出关键的解决方案。
&lt;img src="https://l.ruby-china.com/photo/early/e8658df5-4596-480a-aee2-aeb7672fdc5e.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;方案之间往往需要组合，甚至是通过开关控制每个 feature 是否启用。&lt;/p&gt;
&lt;h3 id="带入变量"&gt;带入变量&lt;/h3&gt;
&lt;p&gt;方案清单出来了，但是该选哪一个？怎么组合？不通的场景需求，答案往往有差异，此时需要带入具体的业务场景变量，看优缺点如何变化，选择可以接受的那个。&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/dad3e417-d738-47b0-a905-3b131c31afe5.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;如果主播寡头化明显，写打散的方案明显不行；
如果实时性、可靠性进一步要求提升，写合并方案必须要能应对，否则不能用。
如果用户进一步增长，分库分表肯定要搞。
如果业务逻辑复杂化，则批量写入的方案不可取。&lt;/p&gt;

&lt;p&gt;根据实际变量选择最合适的就行，随着环境改变、基建升级，可以重新跑一次套路。总体来说，一般的场景，需要分库分表 + 写聚合配合进行，这两者本身可以做到彼此解耦合。&lt;/p&gt;
&lt;h3 id="更进一步"&gt;更进一步&lt;/h3&gt;
&lt;p&gt;在上面的变量带入中，有两个陷阱：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;有些变量拿不准&lt;/li&gt;
&lt;li&gt;环境往往有限制，例如资源有限&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;此时要在可扩展性上做余留。以下是举例。&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/19d3b2e8-1cb0-4453-95ed-9418ea221b0a.png!large" title="" alt=""&gt;&lt;/p&gt;
&lt;h2 id="总结"&gt;总结&lt;/h2&gt;
&lt;p&gt;以上是基于榜单这个业务，对套路做的一次简单演练，要真实体验套路的威力，还是得挑一个问题动手试试。&lt;/p&gt;

&lt;p&gt;高速成长 = 套路*重复次数。&lt;/p&gt;

&lt;p&gt;另外，套路本质是思路和决策的加速器，一切的根基来源于朴素知识的格物致知。&lt;/p&gt;

&lt;p&gt;所以基础知识的充实非常重要，这不是简单学习几个套路和方法论就可得到的。&lt;/p&gt;</description>
      <author>early</author>
      <pubDate>Sun, 26 Dec 2021 17:02:57 +0800</pubDate>
      <link>https://ruby-china.org/topics/42028</link>
      <guid>https://ruby-china.org/topics/42028</guid>
    </item>
    <item>
      <title>直播 -- 从技术保障聊一点成长感悟</title>
      <description>&lt;p&gt;直播业务有类似“双 11”的时刻，本文简单聊聊直播业务系统保障的一些点。为降低写作成本，以陈列式展开。&lt;/p&gt;
&lt;h2 id="1. 直播业务的基本特点"&gt;1. 直播业务的基本特点&lt;/h2&gt;&lt;h4 id="一、横向业务面很广"&gt;一、横向业务面很广&lt;/h4&gt;
&lt;p&gt;直播的业务点非常的繁杂。其一是需要为用户 - 主播之间搭建足够丰富的互动桥梁，其二是需要为用户提供足够宽泛的方式释放存在感。有的是为试错，有的是为能覆盖更广泛的群体。总之言之，横向的业务面很广，这从很多直播平台花样百出的功能就可以感受到。&lt;/p&gt;

&lt;p&gt;大部分情况下，特别是高速发展期，业务追求快速迭代，一般会牺牲技术质量。&lt;/p&gt;
&lt;h4 id="二、纵向业务深度浅"&gt;二、纵向业务深度浅&lt;/h4&gt;
&lt;p&gt;这里可以拿电商的业务做对比，电商的基本核心对于用户来讲，无非是：选商品、下单、选优惠券、支付。但在简单的操作后面，还有非常复杂和冗长的业务流程，涉及类似订单、库存、支付、商户、物流、供应链等等。纵深长、技术挑战大，所以电商业务也是最早实施全链路压测的业务。&lt;/p&gt;

&lt;p&gt;回到直播，虽然横向面广，但大部分业务纵深较浅，大多是重客户端逻辑，服务端接口调用层次一般不深。除了营收业务类似送礼、购买等业务，这部分和电商类似，但复杂度也要少一些。&lt;/p&gt;
&lt;h4 id="三、业务和流量不确定"&gt;三、业务和流量不确定&lt;/h4&gt;
&lt;p&gt;直播业务繁多，不同的直播间其业务功能可能有差异，有些功能在特定的场景和条件下才会出现，这里特别容易出现疏漏或信息不对称。&lt;/p&gt;

&lt;p&gt;同时，直播业务的流量波动很大，小房间或平常时间流量很小，但是一到重大节假日、重大赛事流量就会出现几十倍的突增，类似电商领域的双 11。典型的就是刚刚过去的 S11 总决赛。&lt;/p&gt;
&lt;h2 id="2. 大型活动常常需要做保障"&gt;2. 大型活动常常需要做保障&lt;/h2&gt;
&lt;p&gt;基于上面的特点，在大型活动前夕，平台都需要做大型技术保障。技术团队需要保证业务系统在此期间能正常运转。其原因有：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;业务很多很散，很容易出现漏网之鱼 (有瓶颈或不合理)，在大流量下出现问题&lt;/li&gt;
&lt;li&gt;业务追求快速迭代，历史经验或结论不牢靠，容易轻信而埋下祸根&lt;/li&gt;
&lt;li&gt;流量不确定，场景多样，有些临界连锁反应可能会被忽视&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;假设三个月前刚刚做过一次大流量考验，但马上又要来一次，假设流量量级接近，那还需要做技术保障吗？&lt;/p&gt;

&lt;p&gt;其实还是需要的，有几个原因：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;业务场景不同，例如有些是晚会，有些是赛事，不同模块承受的压力有差异&lt;/li&gt;
&lt;li&gt;业务一直在迭代，你不知道谁可能埋了什么隐患，只是因为流量小未暴露&lt;/li&gt;
&lt;li&gt;依赖方可能发生变化&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这在横向业务繁多的情况下，是很难简单梳理下就能放心的。&lt;/p&gt;
&lt;h2 id="3. 技术保障到底在干什么？"&gt;3. 技术保障到底在干什么？&lt;/h2&gt;
&lt;p&gt;技术保障的目标很简单，就是基于当前的 case，在流量可能大幅突增的情况下，保证技术系统能平稳度过。&lt;/p&gt;

&lt;p&gt;整个过程分为两个阶段：一、前期准备；二、现场保障。需要思考下整个过程中，到底在做什么？ &lt;/p&gt;

&lt;p&gt;现场保障，往往在做几个事情：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;根据容量监控执行扩容、根据预案降级&lt;/li&gt;
&lt;li&gt;分析各种告警、报错，找到问题原因以及应对&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;应对问题时，极端情况下有可能会改代码、改数据，但如果临时做这些事，往往无法解决问题，还可能雪上加霜，因为人在临场会慌，时间也紧，叠加信息不对称一般都会闯祸。&lt;/p&gt;

&lt;p&gt;换个视角，当按照预案扩容、降级或其他操作后，应对了挑战。实际上这些动作本质上算是结果，因为系统可以通过扩容解决问题、系统可降级、你知道该怎么操作。&lt;/p&gt;

&lt;p&gt;人的注意力容易聚焦在现场的应对操作，关键点其实在准备阶段。&lt;/p&gt;
&lt;h2 id="4. 技术保障的核心"&gt;4. 技术保障的核心&lt;/h2&gt;
&lt;p&gt;根据实际成功经验，准备阶段最本质的要点有几个：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;解决信息不对称。对所有业务场景、触发条件、交互方式做细致的梳理。5H2W&lt;/li&gt;
&lt;li&gt;针对场景做压测。基于流量预期，模拟场景、发现问题、解决问题。&lt;/li&gt;
&lt;li&gt;场景容量建模。场景容量和 QPS、在线人数的数学关系&lt;/li&gt;
&lt;li&gt;问题应对方案。每个场景，要清楚挑战有哪些，遇到了该如何应对。降级、限流、扩容还是什么，需要将操作细化并进行演练。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;将要点进一步升华，可以发现最本质的目标是：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;系统可以横向扩展的，只要简单扩容就可以应对流量&lt;/li&gt;
&lt;li&gt;系统是可以降级的，以用户弱感知的方式牺牲部分功能&lt;/li&gt;
&lt;li&gt;系统可自保护，难以扩容的场景如 DB，需要限流自保护。&lt;/li&gt;
&lt;li&gt;有预案、有演练。可能会面临问题以及详尽的应对方案，演练让临场不慌。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;到了现场保障时，关键点是决策和执行。保证信息、指令、动作有章法，即按照计划行事即可。&lt;/p&gt;

&lt;p&gt;如果真实流量远超过预期，那将是难得的经历。&lt;/p&gt;
&lt;h2 id="5. 技术保障的产物"&gt;5. 技术保障的产物&lt;/h2&gt;
&lt;p&gt;技术保障最直观的产物就是：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;系统熵骤降，问题暴露、信息透明&lt;/li&gt;
&lt;li&gt;系统架构更健康，提供标准和沉淀&lt;/li&gt;
&lt;li&gt;系统容量上升&lt;/li&gt;
&lt;li&gt;团队整体协作能力提升&lt;/li&gt;
&lt;li&gt;个人成就感&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;平时可以达到这些吗？我想很难，其一是平常难有对应的流量契机，其二是平常难以集中组织资源攻关，其三是人性是懒惰短时的，感觉没问题就不会动，也没有足够动力。&lt;/p&gt;
&lt;h2 id="6. 一些小感悟"&gt;6. 一些小感悟&lt;/h2&gt;&lt;h5 id="技术系统"&gt;技术系统&lt;/h5&gt;
&lt;p&gt;业务 (or 技术系统) 只能在持续的挑战下才能往更健康的方向发展。如果系统容量远超过需求，实际上是浪费。技术系统说到底是实现业务的工具，需要吃大量的资源，背后都是机会成本。资源有限需要放到更急迫的地方。&lt;/p&gt;
&lt;h5 id="团队"&gt;团队&lt;/h5&gt;
&lt;p&gt;一个组织要往更好的方向发展，其实也是这样。如果你面临一个组织或团队，心里觉得破烂不堪，心里可能有抱怨或想要去改变它。但如果你能脱离个人视角，从公司运作的角度看，这个团队其实是以刚刚好的成本，满足了业务的需求，没有拖后腿，也没有浪费资源。&lt;/p&gt;

&lt;p&gt;当业务或公司需要它更强大时，投入资源其自然会变得更好，只不过个体面临的可能是动荡甚至利益损失，但从产权方 (公司 or 老板) 的角度看，它一定可以变得更好。&lt;/p&gt;

&lt;p&gt;它可以变得更好，只是当下还不需要。&lt;/p&gt;
&lt;h5 id="个人"&gt;个人&lt;/h5&gt;
&lt;p&gt;如果个人想要变得更好怎么办？实际上是一样的道理。&lt;/p&gt;

&lt;p&gt;其一，让自己面临更高的外部要求，其二，让自己有更多的资源投入。你在压力或推动下自然而然就会变得更好。离开了这两个条件你都不可能成长。&lt;/p&gt;

&lt;p&gt;所以脱离环境谈走出舒适区是很难的，如果环境不需要你更强，你还会返回舒适区，只是扒上井沿看了一眼而已，最终还是会倦缩回去。人性如此，万不能对抗。&lt;/p&gt;

&lt;p&gt;承担更大的责任、选择要求更高的环境、去资源投入更充足的地方。人是环境的产物，选择和自己同步伐的环境，让自己配得上环境，让环境带自己起飞。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/39187" title=""&gt;直播 (上) 底层逻辑浅析&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/39254" title=""&gt;直播 (中) 核心流程梳理&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/39328" title=""&gt;直播 (下)  业务结构浅析&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41856" title=""&gt;理解直播业务 (一)：信息升维引爆内容供给&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>early</author>
      <pubDate>Sat, 20 Nov 2021 18:43:30 +0800</pubDate>
      <link>https://ruby-china.org/topics/41901</link>
      <guid>https://ruby-china.org/topics/41901</guid>
    </item>
    <item>
      <title>理解直播业务 (一)： 信息升维引爆内容供给</title>
      <description>&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/39187" title=""&gt;直播 (上) 底层逻辑浅析&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/39254" title=""&gt;直播 (中) 核心流程梳理&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/39328" title=""&gt;直播 (下)  业务结构浅析&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;之前写过一个系列简单聊了直播，本系列计划站在平台的视角，简单梳理下对直播业务的一些简单的理解，以形成更整体的认识。&lt;/p&gt;
&lt;h3 id="1. 理解框架"&gt;1. 理解框架&lt;/h3&gt;
&lt;p&gt;我们周围的一切都是叠加到当前态上，不断往前滚动。就像一份新感情，是基于基本的才华/颜值/心灵/需求，再叠加上一次次的碰撞升级而成，中间少了哪怕一次关系升华可能都会烟消云散。感情也可能先诞生，而后由于某个变量改变而逐渐消亡。&lt;/p&gt;

&lt;p&gt;一个事物不会凭空产生，也不会悬空一直存在。其出现、存在、消亡都有原因，而且内在逻辑都是基于当前现状。&lt;/p&gt;

&lt;p&gt;我们埋几个问题，搭建起本系列的理解框架：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;直播因何出现？基于什么样的基础 + 新的变量，导致其产生，然后带来了什么&lt;/li&gt;
&lt;li&gt;直播为什么能存在？新事物带来了什么，让其因为被需要而存在。&lt;/li&gt;
&lt;li&gt;直播平台为什么能持续存在？跑通一个怎样的商业闭环&lt;/li&gt;
&lt;li&gt;平台主要在干什么？&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="2. 直播本质是一种信息升维"&gt;2. 直播本质是一种信息升维&lt;/h3&gt;
&lt;p&gt;当图片的载体从胶卷转移到数码相机后，图片和视频便实现了数字化，可以借助网络传播。近些年网速的大幅提升，也让视频能直接在线播放。&lt;strong&gt;直播实际上是数字化技术，叠加网速大幅提升的结果&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;从图片到视频，画面可以被连续性地采集，叠加了空间维度的信息。从视频到直播，是在信息采集的基础上，叠加了实时性。&lt;/p&gt;

&lt;p&gt;可以看出，文字-&amp;gt;图片-&amp;gt;视频-&amp;gt;直播，这个演变过程，逐步叠加了视觉、空间、时间等多个维度。从最早的飞鸽传书，通过书面文字延时传播信息，过渡到人类视听感官实时隔空感知信息。这是信息能力的升维。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;直播就是在视频的基础上叠加了时间维度，本质是更进一步的信息升维&lt;/strong&gt;。(下一步应该是 AR)&lt;/p&gt;

&lt;p&gt;1) 普罗大众可以通过视听感官，实时捕获另一个时空正在发生的事，不再受限于自己的视觉和听力。&lt;/p&gt;

&lt;p&gt;2) 某个时空的人或物，也可以接近 0 成本，将自己与数量无限的人实时连接互动，不再受限于教室或体育场的大小。&lt;/p&gt;

&lt;p&gt;进几十年的发展，实际上都是得益于信息的逐步升维。先信息革命，信息低成本流通组织起前所未有的生产力，然后导致社会一系列的巨大发展。直播是在这个过程中的进一步延续。&lt;/p&gt;
&lt;h3 id="3. 直播平台的崛起的原因"&gt;3. 直播平台的崛起的原因&lt;/h3&gt;
&lt;p&gt;社会是一个无限游戏的运行时，当前的稳态一般呈现为：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;利益分配 (战功)，前期流血流汗/抓住机会的人占住了位置 (市场份额)，并享有特有权利或分利份额。不管后来的人能力如何牛逼，如果不能制造出新的增量，“老人”的位置便无可撼动。没有战功的新兵无法得到提拔。&lt;/li&gt;
&lt;li&gt;行为习惯，假设我现在抄袭微信做一个新的 APP，不能分走微信的丝毫份额，因为用户已经形成习惯和关系网络，他们没有任何迁移的动力。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;新变量-&amp;gt; 开拓-&amp;gt;争夺-&amp;gt;逐渐稳态-&amp;gt;胜者分利-&amp;gt;新来的没汤喝。直播是新变量，现有平台是胜者。&lt;/p&gt;

&lt;p&gt;现在看到的直播平台，均是利用了直播在信息传播上提供的新能力，新能力满足了人类的需求或欲望，让他们的注意力逐步迁移过来。直播平台打通能力 + 需求 + 商业价值，形成新的稳态，因此而存在。&lt;/p&gt;

&lt;p&gt;满足了什么需求呢？其实是更便捷地满足人性里那点欲望。从最原始的窥私、猎奇、荷尔蒙，到慕强 (成为粉丝)，最后到臆想 (心理/精神)。这是杀时间的利器，游戏、娱乐、新闻、卖艺、日常、聊天都来了。&lt;/p&gt;

&lt;p&gt;当看直播成为习惯，可以看到直播带货起来了。基于直播信息优势，让粉丝能以身临其境的体验，连接上超级魅力体，通过商品买卖释放慕强需求。直播以视觉 + 听觉的声色俱厉地刺激人的感官干预购买决策，加上价格优势和群体效应，没需求也能创造出冲动消费，但一般也限于大 V。&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/0ab20abe-575c-4b13-b8de-cedd81181b19.png!large" title="" alt="from 陆奇"&gt;&lt;/p&gt;
&lt;h3 id="4. 直播带来了充沛的内容供给"&gt;4. 直播带来了充沛的内容供给&lt;/h3&gt;
&lt;p&gt;由于技术升级，直播直接带来了充沛的内容供给，主要分为几个方面：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;内容维度非常丰富，线下见过的一切都可以线上化&lt;/li&gt;
&lt;li&gt;内容线上化后，变得可消费。你想小姐姐坐在你对面唱歌，线下哪里找得到？&lt;/li&gt;
&lt;li&gt;创作成本大幅降低，开启摄像头直接表达就行，视频投稿剪片子就得一星期&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;相较于线下，内容线上化后，相当于无中生有地提供了稀缺的内容：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;面对面&lt;/strong&gt;唱歌的小姐姐&lt;/li&gt;
&lt;li&gt;偶像/魅力体&lt;strong&gt;面对面&lt;/strong&gt;身临其境、互动&lt;/li&gt;
&lt;li&gt;实时看游戏大佬神操作&lt;/li&gt;
&lt;li&gt;其他不曾见过的新奇 &lt;/li&gt;
&lt;li&gt;身心酥软的治愈声音&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;因为实时，这些内容提供了一对一的及时体验，一般人在线下根本体验不到这些，如果是看视频会完全没味道。更要命的是，这些供给都可以完美匹配人性那点欲望，窥私、猎奇、荷尔蒙、慕强、幻想、情感依托···这都让直播刚出来就迅速占领大量人的注意力，成为一种新的杀时间利器。&lt;/p&gt;
&lt;h3 id="5. 用户聚集形成虚拟社会"&gt;5. 用户聚集形成虚拟社会&lt;/h3&gt;
&lt;p&gt;直播的内容一般可以分为几部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;主播本身&lt;/li&gt;
&lt;li&gt;主播创造的内容&lt;/li&gt;
&lt;li&gt;主播和用户互动形成的氛围&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;主播和大量用户汇集于直播间中，当用户数量达到一定规模和稳定性，便在直播间这个虚拟空间中形成了一个团体，类似于线下的班级、小区。俗话说有人的地方就有江湖，有江湖便有纷争比较，一切都围绕着主播展开。&lt;/p&gt;

&lt;p&gt;或基于慕强、攀比心理，引导用户竞争，相互比较自己和主播的关系、亲密程度；或基于社会地位、虚荣引导用户挥金，成为当前圈子最闪耀的仔。当有了排他性的身份标识或排名榜单，江湖自然就会卷起来。&lt;/p&gt;
&lt;h3 id="6. 平台主要建设什么功能"&gt;6. 平台主要建设什么功能&lt;/h3&gt;
&lt;p&gt;直播带来了充沛供给，内容会吸引大量用户。平台在这个基础上主要迭代的东西分为两类。&lt;/p&gt;
&lt;h5 id="第一类，内容生产，也分为几类。"&gt;
&lt;strong&gt;第一类，内容生产&lt;/strong&gt;，也分为几类。&lt;/h5&gt;
&lt;p&gt;1）稳定实时生产内容&lt;/p&gt;

&lt;p&gt;在生产端建设生产工具，让主播可以更稳定、实时、简单地录制内容，并稳定及时传播到直播间。&lt;/p&gt;

&lt;p&gt;2）互动能力&lt;/p&gt;

&lt;p&gt;网络直播相较于电视主播，主要是具备互动能力，以及用户聚集能形成虚拟社会。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;连麦让用户和主播可以互动；&lt;/li&gt;
&lt;li&gt;PK 让主播和主播可以对抗&lt;/li&gt;
&lt;li&gt;弹幕让主播和用户、用户和用户能感知； &lt;/li&gt;
&lt;li&gt;炫酷特效让用户感受存在感。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;3）内容触达策略&lt;/p&gt;

&lt;p&gt;通过一些列策略，主要做两个事情：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;让丰富的内容能精准触达用户，用户满足自己的需求，主播圈到粉丝。&lt;/li&gt;
&lt;li&gt;奖励优质内容，获得更多曝光机会；惩罚低质内容，限定触达用户数量&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id="第二类，基于人性构建表达工具。"&gt;第二类，基于人性构建表达工具。&lt;/h5&gt;
&lt;p&gt;主要是给用户表达自己、体现存在感的工具和途径，并引导用户竞争。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;细分的礼物、道具，通过送礼表达自己&lt;/li&gt;
&lt;li&gt;用户等级，体现自己的独特&lt;/li&gt;
&lt;li&gt;用户标识，体现和主播关系&lt;/li&gt;
&lt;li&gt;榜单排名，优越感、社会地位&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;让虚拟社会能够运转起来。&lt;/p&gt;
&lt;h3 id="7. 直播业务分层结构"&gt;7. 直播业务分层结构&lt;/h3&gt;
&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/7267daab-c358-4496-b9ba-86b611393c29.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;我觉得直播的业务可以分为三层：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;第一层：内容，作为平台的根基&lt;/li&gt;
&lt;li&gt;第二层：虚拟社区，用户、主播交换价值&lt;/li&gt;
&lt;li&gt;第三层：平台运作激发正循环&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;形成逻辑顺序为：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;直播创造充沛内容&lt;/li&gt;
&lt;li&gt;内容吸引了用户注意力&lt;/li&gt;
&lt;li&gt;用户逐渐形成使用习惯&lt;/li&gt;
&lt;li&gt;用户产生稳定聚集进而形成虚拟社区&lt;/li&gt;
&lt;li&gt;主播和用户在社区中产生稳定价值交换&lt;/li&gt;
&lt;li&gt;创造商业利润，正循环&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;第一层和第二层可以自然形成，但往往强度不够、过程缓慢。主播和用户间缺乏更强的通道建立连接，当用户对内容供给逐渐失去新鲜感后，第一二层的循环便会出现松动甚至奔溃。&lt;/p&gt;

&lt;p&gt;平台需要提供更加常态化、外部通道来强化主播和用户之前的连接，通过外部干预的方式强化正循环，也为了刺激更出更高的增长实现商业变现。一般是通过公会利用人的羊群效应实现交易环境的冷启动，通过活动体系刺激主播竞争、用户活跃。这部分下篇详细讲。&lt;/p&gt;</description>
      <author>early</author>
      <pubDate>Sun, 07 Nov 2021 23:39:15 +0800</pubDate>
      <link>https://ruby-china.org/topics/41856</link>
      <guid>https://ruby-china.org/topics/41856</guid>
    </item>
    <item>
      <title>kafka(四) 可靠性探讨</title>
      <description>&lt;p&gt;本系列计划分四大部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41003" title=""&gt;kafka(一) 消息队列的本质&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41713" title=""&gt;kafka(二) 消息的生产&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41714" title=""&gt;kafka(三) 消息的消费&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41715" title=""&gt;kafka(四) 可靠性探讨&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;kafka(五) 操作系统的大腿 (待)&lt;/li&gt;
&lt;li&gt;kafka(六) 王朝式微，Pulsar 的冲击 (待)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;本系列前三篇介绍了消息队列的要素，以及 kafka 的生产和消费过程，实现从抽象到实际的一次知识缝合。在串联知识点的过程中，跳过了一些关键点，缺了它们会有隔靴搔痒之感。&lt;/p&gt;

&lt;p&gt;本文主要探讨可靠性方面的一些点，主要内容围绕：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;在故障发生时，数据一致性靠谱吗？&lt;/li&gt;
&lt;li&gt;kafka 可以保证消息不丢失吗？&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="不变的阳谋"&gt;不变的阳谋&lt;/h2&gt;
&lt;p&gt;分布式系统可靠性的建设 (机器)，和公司团队可靠性建设 (人)，其底层逻辑是一致的。大道至简，大道相通。&lt;/p&gt;

&lt;p&gt;一般情况是 20-%的人创造了 80+%的核心价值，但人一旦吃饱了就容易骄纵懈怠，战斗力就缩水了。想要搭建一个战斗力爆棚同时产出稳健的团队可以怎么做？在定制好良性竞争机制之后，核心点就是堆人 (才)，在切割单点不可替代性后，还要建立纵向梯度。你虽然是首席，但二把手可以随时上，三把手也在等着。&lt;/p&gt;

&lt;p&gt;分布式系统呢，如何稳健？实际上是一样的：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;堆足够量的机器，&amp;gt;=50% 作为副本存在&lt;/li&gt;
&lt;li&gt;将集群压力切割分散在众多机器上&lt;/li&gt;
&lt;li&gt;设立 leader 节点 (Master)，数据同步给副本（一致性）&lt;/li&gt;
&lt;li&gt;副本可以随时替代 leader（可用性）&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="可用性的来源"&gt;可用性的来源&lt;/h2&gt;
&lt;p&gt;当集群的压力分散隔离到多个机器上时，个别 broker 出现故障，并不会影响其他 Topic 的生产和消费 (broker 量较多)。因为数据同步给了副本，可以替代 leader 迅速恢复服务，以此便实现高可用。&lt;/p&gt;

&lt;p&gt;聚焦到本文的主题，我们需要理清楚两个问题：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;数据同步相关的细节&lt;/li&gt;
&lt;li&gt;故障恢复时一致性问题&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;在此基础上，尝试探讨 kafka 是否能保证消息不丢失。&lt;/p&gt;
&lt;h2 id="副本数据同步"&gt;副本数据同步&lt;/h2&gt;
&lt;p&gt;回顾&lt;a href="https://zhuanlan.zhihu.com/p/386562384" rel="nofollow" target="_blank" title=""&gt;kafka(二) 消息的生产&lt;/a&gt;中数据同步的部分，每个 Partition 有一个 leader，有 x 个副本，生产者直接向 leader 创建消息，副本通过接口向 leader 轮询获取最新消息，实现数据同步。
&lt;img src="https://l.ruby-china.com/photo/early/8cc66b29-9377-449b-8d83-83f4de0a9ee9.png!large" title="" alt=""&gt; 涞源 1&lt;/p&gt;

&lt;p&gt;当 ack=all 时，leader 会等所有 ISR 集合的节点拉到消息后才会返回成功，确保数据同步到了副本上。每次副本轮询时会带上想要的 offset(fetch_offset)，这是副本上最新的消息偏移量，由此 leader 便可以知道每个副本现在同步到哪儿来了，意味着小于 fetch_offset 的消息都已经同步成功了。(类似 TCP 的 ack)&lt;/p&gt;

&lt;p&gt;通过数据同步时延 (replica.lag.time.max.ms) 可以对 ISR 集合进行伸缩，将慢节点踢掉，如果速度恢复了会再加回来。其原理是，每当副本的 fetch_offset 等于 leader 的 LEO(下一条消息的偏移量)，leader 会更新对应节点的 lastCaughtUpTimeMs 值 (完全追上后才会更新)，kafka 通过定时任务扫每个节点的该值，如果 time.now-lastCaughtUpTimeMs &amp;gt; replica.lag.time.max.ms，则该副本落后了，会记录起来，通过另一定时任务将其踢掉。&lt;/p&gt;

&lt;p&gt;ISR 本质上是个优质副本集合，也可能只有 leader 副本一个节点 (其他因为慢被踢掉了)，这算是“少数服从多数的”一种实现 (PacificA)。类似 raft 的超过半数提交，实际上是通过最快的部分节点实现“少数服从多数”，从对比上看同等规模下 kafka 可以实现更少的副本节点 (副本越多生产越耗时)，使用者也可以通过 min.insync.replicas 实现数量控制，这将灵活性交给了使用者。  &lt;/p&gt;
&lt;h2 id="消息可见性"&gt;消息可见性&lt;/h2&gt;
&lt;p&gt;kafka 消息的生产和消费都是围绕 leader 进行的，副本全程只是默默复制数据。那当消息写入 leader 时，哪些消息能被消费者看到？如果写入 leader 就被消费者看到，假设此时消息还未同步到副本，leader 节点又挂了，副本重新成为 leader 后，便会出现“幻读”，有些消费者消费到了该消息，有些则消费不到，因为新 leader 上没有。
&lt;img src="https://l.ruby-china.com/photo/early/57e5e5fb-5759-43e6-b5d4-c87c960a1a70.png!large" title="" alt=""&gt; 涞源 1&lt;/p&gt;

&lt;p&gt;对于此，kafka 提供了分区高水位的概念，一方面定义消息的可见性，另一方面也辅助了消息同步到副本。其值计算逻辑很简单，HW = max(currentHW, min(副本 LEO))。也就是说分区高水位取决于最慢节点的最新一次 fetch_offset 值，这直接衡量了副本的同步情况。&lt;/p&gt;

&lt;p&gt;比高水位大的消息对消费者不可见，也被称为未提交消息。副本在拉消息接口中，leader 也会将高水位值返回给副本，副本接着更新自己的高水位 (min(leader-HW, 自己 LEO))。但分区高水位是指 leader 高水位，副本高水位用于故障恢复时日志截断。&lt;/p&gt;

&lt;p&gt;总结下两个概念：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;分区高水位 HW，也是 leader 节点高水位值，控制消费者可见性，避免“幻读”，也直接记录副本同步情况。由节点列表中 (在 ISR 中且未同步延迟) 最慢的节点决定。leader 更新公式：max(currentHW, min(副本 LEO))，副本公式：min(leader-HW, 自己 LEO)&lt;/li&gt;
&lt;li&gt;LEO(Log End Offset)，末端位移，下一条将要插入消息的 offset。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="故障恢复"&gt;故障恢复&lt;/h2&gt;
&lt;p&gt;每个 broker 和 zookeeper 有心跳，当某个 leader 没响应了，controller 可以通过 watch 机制得知，并开启故障转移。(见&lt;a href="https://zhuanlan.zhihu.com/p/386562384" rel="nofollow" target="_blank" title=""&gt;kafka(二) 消息的生产&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;当发生 leader 切换时，数据一致性便会经受考验。在 kafka0.11 之前，当 leader 发生切换时，副本会将自己本地的日志按照高水位 (副本自己的) 截断删除，然后将 fetch_offset 设置为高水位值向新 leader 发起同步请求，这里面可能会出现数据一致性问题。&lt;/p&gt;
&lt;h2 id="高水位的缺陷"&gt;高水位的缺陷&lt;/h2&gt;
&lt;p&gt;根源在于 leader 的 HW 更新和副本 HW 更新存在时间差。下面是几个回合 HW 的更新，右边是 leader，左边是某个副本：
&lt;img src="https://l.ruby-china.com/photo/early/037bddb3-a2e8-455b-8620-eeaf0af59be8.png!large" title="" alt=""&gt; 涞源 2&lt;/p&gt;

&lt;p&gt;图上共有三排，第一排时序：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;leader B 中有两条消息 m1 m2&lt;/li&gt;
&lt;li&gt;副本 A 带上 fetch_offset=1 拉 m2 这条消息&lt;/li&gt;
&lt;li&gt;此时 B、A 的 LEO 均为 2，HW 为 1&lt;/li&gt;
&lt;li&gt;副本 A 带上 fetch_offset=2 继续拉消息&lt;/li&gt;
&lt;li&gt;B 将 HW 修改为 2，A 需要等接口返回后才能将 HW 修改为 2，此时会出现时间差。如果 B 中没有新消息则会延迟返回，时间差还会进一步拉大。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;假设在这个时间差之间，A 崩溃了，时序如下：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A 奔溃，此时其 HW 为 1（上图第二排）&lt;/li&gt;
&lt;li&gt;A 恢复后，则会将 m2 删除&lt;/li&gt;
&lt;li&gt;以 fetch_offset=1 向 leader 拉消息。假设此时 B 也奔溃了&lt;/li&gt;
&lt;li&gt;A 会成为新 leader&lt;/li&gt;
&lt;li&gt;B 恢复后成为副本，其 HW 为 2，比 leader 高，会将 HW 改为 1 并截断 m2，fetch_offset=1 向 A 拉消息&lt;/li&gt;
&lt;li&gt;最终结果是：m2 这条消息丢了&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;假设 leader 等副本更新完 HW 后，才更新自己的 HW，则可以避免上面的问题，但这需要多增加一轮数据同步请求。而且换一个时序，也会出现数据不一致问题。
&lt;img src="https://l.ruby-china.com/photo/early/5a7134d2-ac14-44b8-98be-f2859651f679.png!large" title="" alt=""&gt; 涞源 2&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A 为 leader，LEO=HW=1。B 为副本，LEO=HW=1&lt;/li&gt;
&lt;li&gt;假设 A、B 同时挂掉&lt;/li&gt;
&lt;li&gt;B 先恢复，成为新 leader&lt;/li&gt;
&lt;li&gt;生产者向 B 创建了一条新消息 m3，此时 HW=LEO=2&lt;/li&gt;
&lt;li&gt;A 恢复称为副本，HW 和 A 一样，不需要截断，fetch_offset=2 也拉不到新消息&lt;/li&gt;
&lt;li&gt;最终结果是：A 和 B 之间数据不一致。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;上面的问题会出现在 min.insync.replicas=1 的情况下，也就是某时刻 ISR 中只有 leader 节点，由于 min.insync.replicas=1，此时也可以生产消息，数据只提交到了 leader 节点，便返回成功给生产者。&lt;/p&gt;

&lt;p&gt;假设 min.insync.replicas&amp;gt;1，上面问题理论上不会出现。（严肃性故障下文讨论）&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;如果 ISR 中只有 leader 副本，此时 kafka 会返回错误，告诉生产者现在不能生产，这样不会有数据问题。&lt;/li&gt;
&lt;li&gt;如果 ISR 中有其他副本，leader 会等其他节点 fetch_offset&amp;gt;[消息的 offset] 时，才会返回成功给生产者，此时 HW 均已经更新了。
## Leader Epoch
上面数据问题的根源在于，故障恢复后 HW 被新 leader 更新，但副本的 HW 还是原来的，一旦同步便会产生问题。如果能将 HW 按照变更前的状态返回给副本，则可以避免问题。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Leader Epoch 便是起这样的作用：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Epoch。一个单调增加的版本号。每当副本领导权发生变更时，都会增加该版本号。小版本号的 Leader 被认为是过期 Leader，不能再行使 Leader 权力。&lt;/li&gt;
&lt;li&gt;起始位移（Start Offset）。Leader 副本在该 Epoch 值上写入的首条消息的位移。&lt;/li&gt;
&lt;li&gt;组合起来 Leader Epoch 便类似于： 。epoch 为当前版本，leader 变更后会 +1，startOffset 是当前新 leader 第一条消息的 offset。
&lt;/li&gt;
&lt;li&gt;broker 会在内存和磁盘中存放 leader 的 Epoch 值。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;当有副本重启后归来，带上自己保存的 leader epoch 值，先向 leader 发起一轮请求，获取当前 leader 的 epoch 值。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;如果两者 epoch 值一样，则 leader 返回当前自己的 LEO 给副本。此时副本 LEO 理论上&amp;lt;=leader LEO，不需要截断，正常同步数据。&lt;/li&gt;
&lt;li&gt;如果两个 epoch 不一样，说明副本持有的 epoch 是上一轮老 leader 的 (可能落后多轮)，此时新 leader 根据老 epoch 返回 [老 epoch+1] 记录的 startOffset 给副本。（假设只差一轮的话，则返回当前 epoch 的 startOffset）&lt;/li&gt;
&lt;li&gt;副本根据 startOffset 进行截断逻辑。如果返回的 startOffset 大于等于自己的 HW，则不会删除消息，原消息得到保留。否则截断从 leader 重新拉取，这避免了不一致。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/2e646d55-859b-46ef-92ed-7397953ccc5b.png!large" title="" alt=""&gt; 涞源 2&lt;/p&gt;
&lt;h2 id="消息能不丢么"&gt;消息能不丢么&lt;/h2&gt;
&lt;p&gt;上文大致梳理了数据同步，以及故障恢复的一些细节。现在我们需要问一个更大的问题： &lt;/p&gt;

&lt;p&gt;kafka 能保证消息不丢么，或者说要做到消息不丢失，该如何配置 kafka？
前文提到了，当：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;生产者得到 kafka 生产成功回复时，才认为成功，否则重试&lt;/li&gt;
&lt;li&gt;ack=all，消息同步到 ISR 中时，才返回成功。&lt;/li&gt;
&lt;li&gt;replication.factor &amp;gt;= 3，副本数量多一些&lt;/li&gt;
&lt;li&gt;min.insync.replicas &amp;gt; 1, ISR 数量超过 1 个才提供服务。&lt;/li&gt;
&lt;li&gt;replication.factor &amp;gt; min.insync.replicas，如果相等只要一个节点挂了，便会拒绝服务&lt;/li&gt;
&lt;li&gt;unclean.leader.election.enable=false，必须要 ISR 的成员才能称为新 leader&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;上面几个条件同时生效时，kafka 的明确告知生产成功的消息是有保障的，至少落到了某个 ISR 节点中，等待重启完毕便可以恢复数据。&lt;/p&gt;

&lt;p&gt;但这是有前提的，因为 kafka 的消息是写入内存便认为提交，副本同步也是一样：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;副本告诉 leader 消息已经同步成功，实际上可能还在内存中没有刷盘，奔溃后数据丢失&lt;/li&gt;
&lt;li&gt;副本告诉 leader 消息同步成功，此时已经刷盘，但这个副本磁盘故障，数据会丢失&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;当出现了上面的情况时，kafka 依然会丢失消息，只不过副本数量越多，上面参数配置的越苛刻，丢失数据的概率会减小，毕竟没有向 MySQL 那样同步刷 redolog 到磁盘。&lt;/p&gt;

&lt;p&gt;这个问题的答案是，当上面的一系列参数都配置正确，kafka 对"已提交"的消息，可以在一定程度上保证数据不丢失：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;已提交。上面 ack=all 等参数配置，且 kafka 明确返回成功&lt;/li&gt;
&lt;li&gt;一定程度。kafka 的多个副本至少有一个没出问题 (刷盘前 crash 或磁盘故障等)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="参考资料"&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[1] &lt;a href="https://time.geekbang.org/column/article/110388" rel="nofollow" target="_blank"&gt;https://time.geekbang.org/column/article/110388&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[2] &lt;a href="https://zhuanlan.zhihu.com/p/46658003" rel="nofollow" target="_blank"&gt;https://zhuanlan.zhihu.com/p/46658003&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>early</author>
      <pubDate>Sat, 25 Sep 2021 18:17:46 +0800</pubDate>
      <link>https://ruby-china.org/topics/41715</link>
      <guid>https://ruby-china.org/topics/41715</guid>
    </item>
    <item>
      <title>kafka(三) 消息的消费</title>
      <description>&lt;p&gt;本系列计划分四大部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41003" title=""&gt;kafka(一) 消息队列的本质&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41713" title=""&gt;kafka(二) 消息的生产&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41714" title=""&gt;kafka(三) 消息的消费&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41715" title=""&gt;kafka(四) 可靠性探讨&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;kafka(五) 操作系统的大腿 (待)&lt;/li&gt;
&lt;li&gt;kafka(六) 王朝式微，Pulsar 的冲击 (待)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;在 (一) 中简要探讨了消息队列的抽象模型，可分为两部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;生产。生产 RPC + 存储&lt;/li&gt;
&lt;li&gt;消费。读取 + 消费 RPC&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;上一篇 (二) 中，我们从 client 和 server 两个视角，梳理了消息生产的整个过程，涉及请求的处理过程，以及集群的一些方案和权衡。这其实是 kafka 对于消息队列在消息生产上的具体实现。&lt;/p&gt;

&lt;p&gt;本文我们将再次回到消息队列本身属性，来看待 kafka 的消费过程。&lt;/p&gt;
&lt;h2 id="消费的要素"&gt;消费的要素&lt;/h2&gt;
&lt;p&gt;消费消息是个很简单的概念，其实就是拿到生产者创建的数据，kafka 在生产者和消费者之间做了一次转手。我们从 kafka 的视角出发，如何把消息转给消费者，这个过程中有哪些基本诉求？可见的有：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;简单、可靠、快&lt;/li&gt;
&lt;li&gt;消费能力可扩展&lt;/li&gt;
&lt;li&gt;基本的单播、广播能力 (基于消费者 Group)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;我们先从消息存放开始。&lt;/p&gt;
&lt;h2 id="消息存放"&gt;消息存放&lt;/h2&gt;
&lt;p&gt;消息在逻辑上属于某个 Topic，每个 Topic 有多个 Partition。在物理形态上，Topic 和 Partition 都是以文件夹的形态存在，消息数据就存放于这些文件夹里面。&lt;/p&gt;
&lt;h3 id="Segment 分而治之"&gt;Segment 分而治之&lt;/h3&gt;
&lt;p&gt;逻辑上消息存放在对应 Partition 目录中的某个文件里，为了防止单个文件过大，在物理形态上，会分成多个数据分段 (Segment)，每个分段存放的数据量大致固定，以追加的方式存于文件末尾，当文件体积到一定阈值，则创建新的分段 (消息只会写入最新分段中)，Partition 的消息就分布在这些分段文件上。
&lt;img src="https://l.ruby-china.com/photo/early/73125e57-74c6-44fc-b2d6-443a17dcd1f5.png!large" title="" alt="图片来自网络"&gt;&lt;/p&gt;

&lt;p&gt;类比数据库，当数据写入后可以得到一个 ID，通过 ID 可以找到数据。kafka 不需要这么复杂，每个消息写入文件时都是追加在文件末尾，追加时可以根据当前 Partition 的消息量 (N)，得到当前消息的偏移量 (offset=N-1)，并将改偏移量写入消息体中，消费者按照偏移量读取即可。&lt;/p&gt;

&lt;p&gt;###查询必有索引
消息存放完毕后，问题来了，当消费者希望读取某个 offset 的消息时，如何快速地定位到数据？就像数据库中以 ID 来读取数据一样。&lt;/p&gt;

&lt;p&gt;要快速查询，必然需要索引，否则可能涉及遍历整个 Segment，kafka 在存数据时，会维护 offset 对应的索引：【offset=&amp;gt;物理地址】，应对快速查询。每个 Segment 都有独立的索引文件。&lt;/p&gt;

&lt;p&gt;索引很简单，其目的是为了快速找到对应 offset 的数据。只要维护一个 offset 和其存放物理地址的映射即可解决。
&lt;img src="https://l.ruby-china.com/photo/early/8a9c40fc-7014-4699-b06f-952923372b40.png!large" title="" alt="图片来自网络"&gt;&lt;/p&gt;

&lt;p&gt;由于消息量巨大，不能每个 offset 都维护一个映射，kafka 选择了稀疏索引，即隔一段才存一个映射。根据二分查找可以找到小于等于目标 offset 的消息物理地址，再从分段文件中顺序读取即可。相应的逻辑，kafka 还会维护一个【时间戳=&amp;gt;偏移量】的索引，方便按照时间戳查询数据。&lt;/p&gt;
&lt;h2 id="消费过程"&gt;消费过程&lt;/h2&gt;
&lt;p&gt;消费过程，其实就是把上面 Segment 中的数据按某种策略提取出来。从消息队列的视角出发，这个过程有几个核心的方向：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;框架：消息队列的推拉模型、消息队列的单播广播能力&lt;/li&gt;
&lt;li&gt;记录：消费过程的记录，需要标记哪些消费过了哪些没有消费&lt;/li&gt;
&lt;li&gt;扩展：消费能力的横向伸缩、负载均衡&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;接下来将从以上几个点展开，通过梳理消费过程，串起平时接触到的知识碎片。&lt;/p&gt;
&lt;h2 id="推拉框架"&gt;推拉框架&lt;/h2&gt;
&lt;p&gt;消息队列的消费有着两个基础模型：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;推：消息队列将消息推给消费者&lt;/li&gt;
&lt;li&gt;拉：消费者自己从消息队列拉消息 (轮训)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这两者的核心差别在于： &lt;strong&gt;谁驱动消费行为，谁为消费动作负主要责任&lt;/strong&gt;。推模型将消息消费的主要责任放在了消息队列这一边，对应的职责则有：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; 新消息产生后，主动推给消费者，敦促其处理&lt;/li&gt;
&lt;li&gt;对消费过程负责，将消息数据有章法地推给消费者，达到负载均衡效果&lt;/li&gt;
&lt;li&gt;对消费结果负责，如果消费失败，涉及自动重试，消费成功则标记&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;而拉模型则是反过来，将以上职责主要推给了消费者。&lt;/p&gt;

&lt;p&gt;我们都知道 kafka 选择的是拉模型，其目的也非常明确，在 (二) 中我们已经提到过，kafka 的定位在于超高量级的吞吐能力，如果以上几个职责需要消息队列负责，其实现复杂度会较高，吞吐能力很难上去。rabbitmq 和 kafka 的吞吐能力差别就是很好的例证。&lt;/p&gt;

&lt;p&gt;推拉模型决定了消息队列的消费行为，推拉之间差别巨大。消费者的一切弯弯绕其实就是在完成上面的三个职责。&lt;/p&gt;
&lt;h2 id="如何获取新产生的消息"&gt;如何获取新产生的消息&lt;/h2&gt;
&lt;p&gt;当生产者创建了新消息时，kafka 会将其存放到对应的 Partition 分段文件中，按照先后顺序有序追加到文件末尾，每个消息都有一个偏移量，由生产的先后顺序决定。&lt;/p&gt;

&lt;p&gt;拉模型决定了，kafka 把消息存放好后，就啥事也不干了。因为处理新消息是消费者自己的职责。消费者如何感知到新消息创建，并拉取到呢？理论上只能轮询。&lt;/p&gt;

&lt;p&gt;实际上，消费者就是通过 kafka 提供的 FETCH 接口，不断地轮询，一批批地从 kafka 将消息拉下来，然后分发处理，处理完后继续拉下一批。&lt;/p&gt;

&lt;p&gt;FETCH 接口的处理流程，和消息生产类似，本质都是接口，只是一个是读一个是写，详细过程在 (二) 中已经交代过，感兴趣可以回看。&lt;/p&gt;
&lt;h2 id="可扩展的消费能力"&gt;可扩展的消费能力&lt;/h2&gt;
&lt;p&gt;当消息量级到一定程度时，可扩展的消费能力就极为重要，这本质是负载均衡策略。&lt;/p&gt;

&lt;p&gt;kafka 在生产消息时，将写压力分散到 Patition 上，因为 Partition 数量可横向增加，生产能力便可以随之扩展。在消息的消费端，kafka 沿用了这种能力，消费者的负载均衡策略也是基于 Partition 展开。&lt;/p&gt;
&lt;h3 id="消费能力基于Partition扩展"&gt;消费能力基于 Partition 扩展&lt;/h3&gt;
&lt;p&gt;一个 Partition 最多被一个消费者同时消费 (同一个 Group)，按照消息偏移量 offset 顺序 FETCH 即可，一个消费者可以同时消费多个 partition 上的数据。同一个消费者 Group 下消费者数量的最大值=对应 Topic 的 Partition 数量，理想情况下是一个消费者独立消费一个 Partition 的消息。(多出的消费者消费不到数据)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;当消费能力出现瓶颈时，增加 Partition 的数量，就可以增加消费者的数量，消费能力便自然得到扩展。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;基于此，kafka 可以实现消息的顺序消费，同一个 Partition 上的消息，可以被对应的消费者顺序读取处理 (生产时的顺序)。这是一个非常重要的能力，可以极大简化对消息顺序敏感的业务的技术实现，例如基于 binlog 消息的处理。&lt;/p&gt;

&lt;p&gt;消费者和 Partition 的消费关系，有诸多疑问：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;谁来决定某个 Consumer 消费哪个 Partition？&lt;/li&gt;
&lt;li&gt;当有 Consumer 新增或退出后怎么办？&lt;/li&gt;
&lt;li&gt;谁来负责管理 Consumer？如果其奔溃假死怎么办？&lt;/li&gt;
&lt;li&gt;整个流程框架是什么？&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="消费核心流程"&gt;消费核心流程&lt;/h2&gt;
&lt;p&gt;在拉模型下，一切都需要消费者自驱动，kafka 只提供能力和流程。&lt;/p&gt;

&lt;p&gt;要实现上面提到的消费者和 Partition 的消费结构，必然需要有一个角色来掌控整个消费流程，就像生产流程中的 Leader 副本一样。&lt;/p&gt;
&lt;h3 id="消费协调者"&gt;消费协调者&lt;/h3&gt;
&lt;p&gt;为此 kafka 设计了一个 Coordinator 组件来支撑消费过程，消费者想要消费某个 Topic 的数据：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;需要先向 Coordinator 注册 (JoinGroup)，Coordinator 是某一个 Broker(下文交待)&lt;/li&gt;
&lt;li&gt;Coordinator 会在消费者中选择一个作为 leader(一般是第一个 JoinGroup)，Coordinator(kafka) 将所有注册的消费者发给 leader (每个 Group 一个 leader，从消费者中选出)&lt;/li&gt;
&lt;li&gt;由该 leader 分配哪个 consumer 消费哪个 Partition，然后将分配结果发给 Coordinator&lt;/li&gt;
&lt;li&gt;Coordinator 再分别告诉消费者结果，消费者自行从目标 Partition 所在 Broker 拉消息&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;每个消费者需要定期向 Coordinator 发送心跳消息，表明自己还在正常消费数据。&lt;/p&gt;

&lt;p&gt;当发现有 consumer 超过一定时间未发送心跳时，Coordinator 会认为他退出了，此时消费者会少一个，这个消费者对应的 Partition 便没人消费了，此时 Coordinator 会重新触发上面的流程，这就是大名鼎鼎的： &lt;strong&gt;Rebalance(重平衡)&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;rebalance 的触发时机有：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;组成员数量发生变化，Consumer 新增、推出、失联 (心跳停止)，Coordinator 可以感知到&lt;/li&gt;
&lt;li&gt;订阅主题数量发生变化&lt;/li&gt;
&lt;li&gt;订阅主题的分区数发生变化&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;当上面任一个时机触发，Coordinator 会在心跳请求 response 中告诉所有消费者，所以 Consumer 最快会在一个心跳周期中，知道要 rebalance，然后重新发起消费请求，等待分配结果，然后连上目标 Partition 重新开始消费。&lt;/p&gt;

&lt;p&gt;Consumer 得知开始 rebalance 后，会停止从当前的 Partition 拉消息，等待新的分配结果。新结果很可能不是当前的 Partition，因此 rebalance 时，整个 Group 的消费行为会停止，直到整个过程完毕。&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/2b30bea4-8e1e-458e-8491-43dba4532a1e.png!large" title="" alt=""&gt; 涞源 1&lt;/p&gt;

&lt;p&gt;rebalance 是为了保证整个消费结构平衡，实现消费者顺序消费 Partition 的消息。这也导致了非常非常多的问题，最最常见的之一，就是有些消费者有 bug，不断地在断开重连 (或心跳超时)，使得 Coordinator 始终在做 rebalance 的调度，消费行为一直停滞不前。&lt;/p&gt;
&lt;h3 id="消费位移记录"&gt;消费位移记录&lt;/h3&gt;
&lt;p&gt;每个消费者 Group 可以独立消费 Partition 上的数据，他们必须要记录自己消费到什么地方了 (offeset)，以免 rebalance 或重启后，不知道该从什么地方开始消费。&lt;/p&gt;

&lt;p&gt;最早 kafka 是依赖 zookerper 保存，由于其写性能差，现在 kafka 创建了一个内部的 Topic 来保存 Group 针对 Partition 的消费位移记录，这算是某种意义上的“自举”了。(__consumer_offsets)&lt;/p&gt;

&lt;p&gt;Consumer 可以通过在处理完消息后，向 kafka 提交自己处理的 offeset，由 kafka 帮忙保存这个值，需要时从 kafka 读取即可.kafka 只提供保存能力，行为由 Consumer 自行负责。&lt;/p&gt;

&lt;p&gt;对应消费者 Group 的 Coodinator，就是通过这个 Topic 来决定的，其 Partition 分布到 kafka 的众多 Broker 上。通过：partitionId=Math.abs(groupId.hashCode() % offsetsTopicPartitionCount) 算出一个 PartitionId，位移数据就提交给这个 Partition，这个 Partition 的 Leader 副本所在 Broker 即为对应的 Coodinator。&lt;/p&gt;
&lt;h3 id="消费者的坑"&gt;消费者的坑&lt;/h3&gt;
&lt;p&gt;除了上面提到的一直重平衡导致消费行为停滞，消费过程中还有一个典型的巨坑：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Consumer 拉到一批消息&lt;/li&gt;
&lt;li&gt;假设 offset 为 A 的处理失败，offset 为 B 的处理成功。B &amp;gt; A&lt;/li&gt;
&lt;li&gt;A 消息未提交位移，B 消息提交了位移&lt;/li&gt;
&lt;li&gt;此时发生重平衡，消费者拉到最新消费位移为 B，从 B 开始消费&lt;/li&gt;
&lt;li&gt;A 消息不能被重新消费到，导致 A 消息未被成功处理 (不重平衡也会这样)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这是一个蛋疼的点，要避开上面的问题，需要顺序处理消息，如果处理失败不能直接跳过，否则 offset 较大的消息处理成功并提交后，失败消息可能就丢失了，但这会严重拖慢消费速度。&lt;/p&gt;

&lt;p&gt;所以一般消费者程序要根据实际场景，考虑是否增加失败队列来应对这种情况。&lt;/p&gt;
&lt;h2 id="参考资料"&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[1] &lt;a href="https://time.geekbang.org/column/article/111226" rel="nofollow" target="_blank"&gt;https://time.geekbang.org/column/article/111226&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>early</author>
      <pubDate>Sat, 25 Sep 2021 18:14:48 +0800</pubDate>
      <link>https://ruby-china.org/topics/41714</link>
      <guid>https://ruby-china.org/topics/41714</guid>
    </item>
    <item>
      <title>kafka(二) 消息的生产</title>
      <description>&lt;p&gt;本系列计划分四大部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41003" title=""&gt;kafka(一) 消息队列的本质&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41713" title=""&gt;kafka(二) 消息的生产&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41714" title=""&gt;kafka(三) 消息的消费&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41715" title=""&gt;kafka(四) 可靠性探讨&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;kafka(五) 操作系统的大腿 (待)&lt;/li&gt;
&lt;li&gt;kafka(六) 王朝式微，Pulsar 的冲击 (待)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;上一篇文章简要探讨了消息队列的抽象模型，可简要分为两部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;生产。生产 RPC + 存储&lt;/li&gt;
&lt;li&gt;消费。读取 + 消费 RPC&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;本文将聚焦第一部分，消息的生产。以一个工具使用者的视角展开，侧重于理解其运行机制。如上面所述，这将涉及两个方面：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;其一为生产 RPC，我们将从生产 client 和 kafka 两端透视整个 RPC 的过程。&lt;/li&gt;
&lt;li&gt;其二为储存，我们需要从分布式系统的角度，来梳理 kafka 在高可用、可扩展的、高吞吐等方面的思路。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="回归本质"&gt;回归本质&lt;/h2&gt;
&lt;p&gt;client 发起一次 RPC，将数据发送到 kafka，kafka 将数据保存起来后返回，这便是生产消息的全过程。不过这个过程非常的眼熟，因为这和往数据库插入一条数据没有任何本质差别。&lt;/p&gt;

&lt;p&gt;这个过程中，有几个疑问不可绕过：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;kafka 作为集群存在，生产 Client 怎么知道该将数据发送到哪个实例？&lt;/li&gt;
&lt;li&gt;kafka 收到生产请求后，是怎么处理数据的？怎么存储的？&lt;/li&gt;
&lt;li&gt;如何尽可能实现高吞吐、高可扩展···？&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;本文尝试逐一回答上面的问题，这需要先从分布式集群的角度来切入。&lt;/p&gt;
&lt;h2 id="数据分布"&gt;数据分布&lt;/h2&gt;
&lt;p&gt;作为一个分布式集群，kafka 需要具备高吞吐、高可用、灵活扩展 (扩容) 的能力，要达到这些目的，需要： &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;将海量的写入请求均匀地分散到集群中的机器上&lt;/li&gt;
&lt;li&gt;当写入压力进一步增大时，能通过简单扩容的方式解决 (增加 partition 数量)&lt;/li&gt;
&lt;li&gt;当某台机器故障 (宕机)，集群要能快速恢复服务（备选）&lt;/li&gt;
&lt;li&gt;当某台机器磁盘故障，要能避免数据丢失 (备份)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;我们知道这需要一种数据分片策略，这种分片在 Elasticsearch 叫 Shard，在 HBase 中叫 Region，kafka 中则叫 Partition(分区)：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;每个 Topic 的数据分散在多个 Partition 上，实现数据分片 (每个 Topic 有自己的 partition)&lt;/li&gt;
&lt;li&gt;Partition 可以分散到不同的机器上 (Broker)，实现负载均衡&lt;/li&gt;
&lt;li&gt;Partition(leader) 可设置副本 (follower)，并分布在其他机器上，避免数据丢失&lt;/li&gt;
&lt;li&gt;当某个 Partition 故障后，副本可迅速取代之，实现快速恢复&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/88078dc2-9fa2-46dd-9d84-fa431bc533bc.png!large" title="" alt="图片来自网络"&gt;&lt;/p&gt;
&lt;h2 id="Producer"&gt;Producer&lt;/h2&gt;
&lt;p&gt;为了方便我们将生产 Client 称为 producer。当生产一条消息时 (某个 Topic)，它只会属于某一个分片，这个归属是怎么确定的呢？procducer 怎么知道该发送到哪个机器上？&lt;/p&gt;

&lt;p&gt;这类问题的解决方案一般有两种：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;有 proxy 专门负责这类判定、转发，client 对细节无感知 (类似 MySQL 中间件代理)&lt;/li&gt;
&lt;li&gt;Producer(client) 端掌握 server 端的详细信息，实现重型 Client(类似 redis cluster)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;kafka 选择的是第二种方式，Producer 可以通过 bootstrap.servers 中任意一个 kafka 实例，拉取到所有元信息，和生产有关的比如：某个 Topic 有多少个 Partition，每个 Partition 的 leader 的地址，这些元信息 Producer 会定时轮询更新。&lt;/p&gt;

&lt;p&gt;每个 kafka 节点都有完整的元信息，Producer 可以通过任意节点拉取，源头维护于 Zookeeper 之中，当集群中的 Partition 等元信息发生变更，Controller 节点会逐一推送给其他 Broker 最新信息 [4]。zookeeper 的作用其实主要是两个，一是作为存储，二是基于其 Watch 能力做事件驱动 (例如元信息更新推送)。 
&lt;img src="https://l.ruby-china.com/photo/early/725e001d-45ba-4ca9-8141-d22c4ed240a1.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;当要生产一条消息时：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Producer 会根据策略先决定好这条消息归属于某个 Partition，策略一般有 轮训、随机、基于某个 key( Key-ordering) 这三种。&lt;/li&gt;
&lt;li&gt;将该消息直接发往 kafka 目标 Partition 的 leader 所在节点。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;也就是说，负载均衡策略实际上是 Producer 在决定。&lt;/p&gt;

&lt;p&gt;kafka 的 Producer 实现相对复杂，需要关注 kafka 集群的细节，也要处理不少边界情况，例如 Partition 发生重选举后 leader 节点变化等，不同的语言要重复写一遍；好处是 kafka 本身不关心这些细节，实现上清爽很多，也有利于灵活性和性能提升。&lt;/p&gt;

&lt;p&gt;其实现主要有几部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;定时任务，例如刷新集群元数据&lt;/li&gt;
&lt;li&gt;生产能力，例如负载均衡、自动重试、批量提交、server 端连接管理等等&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;好多中大型公司在暴露给业务部门 kafka 时，都会额外做一层 proxy，只给业务暴露极简单的生产 API，将细节屏蔽，通过专门的代理层实现上面所述的功能。一方面让业务部门简单接入，一方面从运维层面提升 Topic 生产消费的管控能力。&lt;/p&gt;
&lt;h2 id="Server"&gt;Server&lt;/h2&gt;
&lt;p&gt;解决了 client 端的疑问，接下来我们从 server 端的角度，看 kafka 收到请求后都做了些什么，以及背后的存储。&lt;/p&gt;

&lt;p&gt;作为一个提供 RPC 接口的 server，kafka 和其他服务器一样，有着高吞吐、低延迟处理请求的需求，这和其他 Web 服务器没有任何差别。实际上 kafka 也和其他 web 服务器一样，在服务端实现了 Reactor 模型提供高效率的并发模型：
&lt;img src="https://l.ruby-china.com/photo/early/05aa5200-8201-4f45-9338-aa5b483869df.png!large" title="" alt="涞源1"&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Acceptor 主要处理新 tcp 连接，核心就是执行 accept 调用，得到一个 socket，轮询分发给网络线程 (读取出完整的请求数据)&lt;/li&gt;
&lt;li&gt;网络线程将请求数据读取完毕后，会写入共享请求队列 (也会将返回数据回写 socket)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/3a34d051-fe36-439f-bd58-2934a83777d4.png!large" title="" alt="涞源1"&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IO 线程池从共享请求队列中取消息，执行真正的处理逻辑，例如检查、储存，将消息追加写入文件 (内存)&lt;/li&gt;
&lt;li&gt;处理完毕后，将响应写入响应队列，等待网络线程将处理结果返回 (correlationId 区别请求)&lt;/li&gt;
&lt;li&gt;当请求不能立即返回时，会写入 Purgatory 中，等待条件满足后才返回 (时间轮)。例如设置 ack=all 时，需要等 ISR 里其他节点拉到这条消息后，才能返回成功。[3]&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="日志储存"&gt;日志储存&lt;/h2&gt;
&lt;p&gt;大概理清楚了宏观的处理流程，关键点还有日志的存储，有两个点：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;日志以什么形式被存放的？&lt;/li&gt;
&lt;li&gt;日志被保存到了哪里？&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;对于日志的存放形式，我们在下一篇文章中再详细讨论，无非是组织形式，当然还会重点考虑读取便捷度和性能。&lt;/p&gt;

&lt;p&gt;日志最后会被持久化到磁盘中，这里有个常见的权衡：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;性能。因为写磁盘是一个极高成本的事情，如果每条消息都直接刷盘，则 kafka 的吞吐能力会受到极大限制。&lt;/li&gt;
&lt;li&gt;可靠性。但如果只刷到 PageCache(内存) 中，当机器故障，未刷入磁盘的数据就丢了 (可能已经告诉 Producer 成功了) 。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;MySQL 为了在事务中的解决方案是：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;在事务提交时，以顺序写的方式写入事务日志，默认直接刷盘，顺序写性能相对较好。如果这个失败了，那直接告诉请求方失败。[5]&lt;/li&gt;
&lt;li&gt;如果写顺序日志成功了，但由于宕机导致更新失败，则在启动流程中解决，做回滚或恢复。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;MySQL 在权衡中选择了可靠性，这也导致其单机更新能力极限一般在 万/s，瓶颈非常明显。而 kafka 为了更牛逼的吞吐能力，选择直接写入 PageCache 就返回成功，定时或条件触发时批量刷盘。 [6]&lt;/p&gt;

&lt;p&gt;也有参数可以控制刷盘机制，否则由 OS 决定刷盘时机：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;log.flush.interval.messages   //多少条消息刷盘 1 次&lt;/li&gt;
&lt;li&gt;log.flush.interval.ms  //隔多长时间刷盘 1 次&lt;/li&gt;
&lt;li&gt;log.flush.scheduler.interval.ms //周期性的刷盘&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;对于数据可靠性的补偿，kafka 提供 request.required.acks 的配置，可以设定当消息被复制到多个节点后才返回成功，这样数据可靠性就能明显提升，因为多个节点在某个特殊时机下同时故障导致数据丢失的概率会大大降低。&lt;/p&gt;
&lt;h2 id="数据复制"&gt;数据复制&lt;/h2&gt;
&lt;p&gt;我们知道 kafka 会通过数据复制的方式，将数据同步到副本 partition 上，一方面当 leader 故障时，副本能够顶上提供服务，另一方面当 leader 磁盘故障时，数据有备份避免丢失。&lt;/p&gt;

&lt;p&gt;这里再次出现一个权衡点，也就是到底如何将数据同步给副本？这也有两种常规的手法：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;同步复制。类似 Raft 协议这样，当有写入请求时，同步调用副本写入数据，等 x 个副本响应成功写入后，才算成功，此时数据可靠性很高，可见性能会很差，且易受最慢节点拖累。&lt;/li&gt;
&lt;li&gt;异步复制。类似 MySQL 的 binlog 机制，副本自己来消费 binlog 写入本地，leader 节点写入过程中完全不管 follower 的同步情况。此时写入流程不受影响，但 leader 和 follower 之间数据同步常出现延迟，且 MySQL 副本还会提供服务，这也是其节点级“幻读”的原因。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;kafka 本身的目标是超高量级吞吐，自然不会选择同步复制，但纯异步复制也显得不靠谱，特别是当对数据可靠性有一定要求时。kafka 从两种模式中取长补短，设计了一种新的异步模式，为不同的数据可靠性提供选择空间。&lt;/p&gt;

&lt;p&gt;大概总结思路是：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;异步同步数据。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;同步写副本数据太慢，那就让 follower 节点通过接口异步找 leader 节点拉数据。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;有限副本集合 ISR。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;只有数据写入副本后，才能提供数据可靠性保障，但等所有副本同步完成后再返回成功则太慢了，特别是会受慢节点拖累。&lt;/p&gt;

&lt;p&gt;kafka 提出了一个 ISR 副本集合的概念，其本质 leader 是维护一个“优质“的副本集合，是否优质的标准是落后 leader 的时间 (replica.lag.time.max.ms)，当节点满足“优质"条件则加入 ISR，如果同步变慢了则剔除出 ISR。 (如果担心 ISR 数量太少不可靠，可以通过 min.insync.replicas 兜底，如果小于此数量，kafka 会拒绝生产)&lt;/p&gt;

&lt;p&gt;当对数据可靠性要求高时，可以设置消息同步到所有 ISR 节点后才算成功 (request.required.acks=all)，否则给 Producer 返回超时或错误。当 leader 节点故障后，新的 leader 节点可以从 ISR 中诞生。&lt;/p&gt;

&lt;p&gt;当对数据可靠性要求不高时，可以设置 request.required.acks=1，此时写入 leader 成功就算成功提交。当然有些场景可以更激进地设置成 0。all,1,0 这三个值对应的吞吐能力依次大幅提升。&lt;/p&gt;
&lt;h2 id="故障转移"&gt;故障转移&lt;/h2&gt;
&lt;p&gt;梳理完整个消息生产流程，以及副本同步机制，离高可用以及故障恢复还有些距离。还得问以下几个问题：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;故障怎么检测？&lt;/li&gt;
&lt;li&gt;谁负责故障恢复？&lt;/li&gt;
&lt;li&gt;怎么恢复？&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;这里所说的故障，一般是指网络分区，比如某个节点连接不上了，要么网络出现问题，要么可能宕机了。这种情况的监测，kafka 是依靠 zookeeper 来实现的：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;每个节点在启动时会到 zookerper 注册创建一个临时节点&lt;/li&gt;
&lt;li&gt;当某个节点故障后，也会被 zookeeper 的心跳检测到，此时会将之前注册的临时节点删除&lt;/li&gt;
&lt;li&gt;zookeeper 提供节点/目录变更消息订阅通知&lt;/li&gt;
&lt;li&gt;订阅了相关变更消息的节点，当故障发生时即可检测到&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;那谁负责订阅这些变更消息呢？一般分布式系统中，都会有一个角色来统一负责，这种节点在 kafka 这里被叫做控制器 (Controller)，除了故障检测，还会负责 Topic/分区等注册更新，以及上文提到的集群元信息通知。&lt;/p&gt;

&lt;p&gt;当 Controller 发现节点故障后，会启动对应节点上的 partition leader 选举，让副本站出来当选 leader 对外提供服务。&lt;/p&gt;

&lt;p&gt;具体策略就是遍历对应 partition 的副本列表，如果在 ISR 队列中则直接发消息通知其成为新 leader，并分别通知其他 Broker。如果 ISR 为空，当 unclean.leader.election.enable=true 则选择副本列表 (AR) 第一个为新 leader，否则要等挂了的节点重启后才能完成选举。Producer 可以通过任意 Broker 获得对应 partition 新的 leader 地址 (轮询或生产消息时)。[7]&lt;/p&gt;

&lt;p&gt;当 Controller 挂了怎么办？每个节点在启动时都会去 zookeeper 检测/controler 节点是否注册情况，如果没有则会尝试自己注册，第一个注册成功的则为 Controller。如果没能成为 Controller，则会订阅/controller 的变更消息，当 Controller 挂掉后，zk 会删除节点，此时其他所有节点都会收到消息，并竞争成为新的 Controller。&lt;/p&gt;
&lt;h2 id="参考资料"&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[1] &lt;a href="https://zhuanlan.zhihu.com/p/149884955" rel="nofollow" target="_blank"&gt;https://zhuanlan.zhihu.com/p/149884955&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[2] &lt;a href="https://time.geekbang.org/column/article/110482" rel="nofollow" target="_blank"&gt;https://time.geekbang.org/column/article/110482&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[3] &lt;a href="https://cloud.tencent.com/developer/article/1329512" rel="nofollow" target="_blank"&gt;https://cloud.tencent.com/developer/article/1329512&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[4] &lt;a href="https://blog.csdn.net/weixin_42138376/article/details/112174618" rel="nofollow" target="_blank"&gt;https://blog.csdn.net/weixin_42138376/article/details/112174618&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[5] &lt;a href="https://xie.infoq.cn/article/81214b8ced45bd5cd423bec64" rel="nofollow" target="_blank"&gt;https://xie.infoq.cn/article/81214b8ced45bd5cd423bec64&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[6] &lt;a href="https://cloud.tencent.com/developer/article/1609151" rel="nofollow" target="_blank"&gt;https://cloud.tencent.com/developer/article/1609151&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[7] &lt;a href="https://book.douban.com/subject/30437872/" rel="nofollow" target="_blank"&gt;https://book.douban.com/subject/30437872/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>early</author>
      <pubDate>Sat, 25 Sep 2021 18:12:40 +0800</pubDate>
      <link>https://ruby-china.org/topics/41713</link>
      <guid>https://ruby-china.org/topics/41713</guid>
    </item>
    <item>
      <title>【上海】B 站招聘后端工程师 (golang) -- 直推</title>
      <description>&lt;h2 id="公司"&gt;公司&lt;/h2&gt;
&lt;p&gt;哔哩哔哩，目前视频赛道的头部企业。&lt;/p&gt;

&lt;p&gt;为数不多还处在迅猛增长企业，大盘的流量和用户数还在稳步前进，机会和空间都相对充足。&lt;/p&gt;

&lt;p&gt;没有因为增长放缓内部卷翻了天，或者大小周 996 榨干工具人的每一滴时间。&lt;/p&gt;

&lt;p&gt;另外漂亮小姐姐确实很多。&lt;/p&gt;
&lt;h2 id="业务"&gt;业务&lt;/h2&gt;
&lt;p&gt;bilibili 直播服务端核心业务，每年在以 double 的姿态发展，大盘营收增长涨势稳健。&lt;/p&gt;

&lt;p&gt;随着英雄联盟赛事的加持，直播流量增长非常猛，技术上有很多可以建设的地方，业务上增量也很多。&lt;/p&gt;
&lt;h2 id="职位要求"&gt;职位要求&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;1、一年以上 Golang 开发经验，熟悉 Golang 语言及相关开发工具，优秀者可放宽；&lt;/li&gt;
&lt;li&gt;2、熟练掌握 Golang 常用框架，深入了解框架提供的特性及其实现原理细节；&lt;/li&gt;
&lt;li&gt;3、具备良好的基本功，熟练使用基本的数据结构和算法，深入理解多线程、Socket 等相关技术；&lt;/li&gt;
&lt;li&gt;4、熟悉互联网行业主流分布式系统设计，了解分布式存储/缓存/数据库等基础系统的核心架构及原理&lt;/li&gt;
&lt;li&gt;5、有大规模分布式系统的设计和开发经验，能独立完成系统的设计及开发，并能持续对系统架构进行改造和优化。&lt;/li&gt;
&lt;li&gt;6、熟悉 Internet 常用协议，如 HTTP、TCPIP、熟悉 RESTful 规范；&lt;/li&gt;
&lt;li&gt;7、熟悉分布式系统，熟练掌握一种以上服务框架和消息中间件，了解其实现原理；&lt;/li&gt;
&lt;li&gt;8、有良好的学习能力，沟通能力，上进心和团队协作能力；&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="学习机会"&gt;学习机会&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;职场历练：B 站有完备的互联网公司体系，能得到大厂相近的视野与历练。&lt;/li&gt;
&lt;li&gt;技术挑战：高并发、高可用是基本诉求，比如我之前解决&lt;a href="https://ruby-china.org/topics/40596" title=""&gt;高并发热点挑战&lt;/a&gt;的方案。&lt;/li&gt;
&lt;li&gt;复杂场景：直播的功能 feature 及其庞杂，技术方案的多端交互、场景考量、极端情况处理等等非常考验人的综合技术实力。&lt;/li&gt;
&lt;li&gt;分布式系统：可以学习 golang 大规模分布式系统知识体系，少量知识可以看之前发布的 &lt;a href="https://ruby-china.org/topics/40823" title=""&gt;gRPC 系列&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="待遇"&gt;待遇&lt;/h2&gt;
&lt;p&gt;2020 年底开始薪资水平已经不设上限，而且港股上市后给股票也开始大方。收入水平按级别给，好多新进来 P3 序列都有股票。(P3 可以简单对标阿里 P6～P7)&lt;/p&gt;

&lt;p&gt;一般是 20K～40K，定级高的可能会有股票。&lt;/p&gt;

&lt;p&gt;现在是大好的时机，相比老人要幸运太多太多。&lt;/p&gt;
&lt;h2 id="福利"&gt;福利&lt;/h2&gt;
&lt;p&gt;一般节假日小福利不断，具体我就不梳理了。&lt;/p&gt;

&lt;p&gt;各种明星巡游，前几天甄子丹还过来了，一大群人都不干活了跑去追星。&lt;/p&gt;
&lt;h2 id="工作时间"&gt;工作时间&lt;/h2&gt;
&lt;p&gt;双休、大部分加班不多、一线氛围偏轻松、需求排期上温和&lt;/p&gt;
&lt;h2 id="联系我"&gt;联系我&lt;/h2&gt;
&lt;p&gt;合适自己的才是最好的。
感兴趣的可以把简历发我邮箱：zhaowei5612ATyeah.net，备注：ruby-china 内推。&lt;/p&gt;

&lt;p&gt;写的比较简略，如果想了解更细致的情况，可以加我微信，备注 ruby-china。更欢迎技术&amp;amp;人生交流，我们交个朋友。
&lt;img src="https://l.ruby-china.com/photo/early/c39e0136-5887-438b-ae28-899187c72b91.png!large" title="" alt=""&gt;&lt;/p&gt;</description>
      <author>early</author>
      <pubDate>Sun, 20 Jun 2021 11:59:14 +0800</pubDate>
      <link>https://ruby-china.org/topics/41389</link>
      <guid>https://ruby-china.org/topics/41389</guid>
    </item>
    <item>
      <title>kafka(一) 消息队列的本质</title>
      <description>&lt;p&gt;本系列计划分四大部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41003" title=""&gt;kafka(一) 消息队列的本质&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41713" title=""&gt;kafka(二) 消息的生产&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41714" title=""&gt;kafka(三) 消息的消费&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/41715" title=""&gt;kafka(四) 可靠性探讨&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;kafka(五) 操作系统的大腿 (待)&lt;/li&gt;
&lt;li&gt;kafka(六) 王朝式微，Pulsar 的冲击 (待)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="引"&gt;引&lt;/h2&gt;
&lt;p&gt;在 Apach Kafka 眼里，自己不仅仅是消息队列，还是一个数据储存系统，同时是一个流处理利器。但不管是数据存储能力，还是流处理能力，其实都可以算消息队列的加分项，它依然是一个消息队列，不过有更强大的适应能力。&lt;/p&gt;

&lt;p&gt;其实 MySQL 也可以实现消息队列，一头写，另一头读，Redis 更加容易实现这个。很多最终一致性的方案，就是落一条 DB 流水，然后再异步处理。这看起来也是一个消息队列，和基于 kafka 的不尽相似，又有明显不同。&lt;/p&gt;

&lt;p&gt;我们需要有更透彻的视角来剖析消息队列，不管是 kafka，还是上面提到 MySQL/Redis 案例，它们都是消息队列的一种实现而已。基于某种本质的需求，提供出或简单或专业的解决方案。这是本文着重想要探讨的点。&lt;/p&gt;
&lt;h2 id="消息队列的本质"&gt;消息队列的本质&lt;/h2&gt;
&lt;p&gt;有一种声音说 [1]，消息队列的本质是：两次 RPC+ 一次转储。这种提法很好，是从流程上对消息队列进行了一次透视。这给了我们一个切入点。&lt;/p&gt;

&lt;p&gt;其内在含义是：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;第一次 RPC，将消息投放给了暂存点（生产者）&lt;/li&gt;
&lt;li&gt;暂存点将消息储存起来（消息队列）&lt;/li&gt;
&lt;li&gt;第二次 RPC，需要消息的一方从暂存点读取消息，或推或拉（消费者）&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;不可否认，上面简练地概括了基于消息队列的消息流转过程。接下来我们要问两个问题：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;为什么能这样？&lt;/li&gt;
&lt;li&gt;这样达到了什么效果？&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;第一个问题。为什么消息队列能表现成两次 RPC+ 一次转存呢？其最核心的根基就是，&lt;strong&gt;消息生产者只关系消息是否成功传递给了消息队列，而并不关心消息被谁处理，也不即时依赖消息处理的结果&lt;/strong&gt;。这是消息队列的客观存在条件，如果没有这个前提，消息队列没有存在空间。这和同步的 RPC 请求形成了鲜明对比。&lt;/p&gt;

&lt;p&gt;第二个问题。这能达到什么结果？基于上面的前提，消息生产者不关心谁处理，也不即时依赖处理结果，这样直接达到了两个结果：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;解耦&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;异步&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;什么叫解耦呢？两个有联系的模块间解耦，大白话讲结果就是，&lt;strong&gt;一方发生变化 (发布、改代码、迭代等非协议性变化)，另一方不需要跟着变化，或者对变化无感知&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;例如分布式系统中，A 服务调用 B 服务，此时 A 需要知道 B 的地址，如果 A 服务将 B 的地址写死，当 B 服务发生迁移、故障等变化时，A 就要跟着修改 B 的地址，我发生了变化，你需要跟着变化。接入服务发现中心可以实现解耦，B 迁移时，A 可以通过服务发现自动适应。&lt;/p&gt;

&lt;p&gt;对于消息系统而言，消息的生产方和消费方，虽然都和消息本身强相关，但只要消息 (流程/事件) 本身不变 (协议)，双方对彼此的变化完全无感知。这是系统设计中的重中之重。&lt;/p&gt;

&lt;p&gt;而异步更多是一种结果，由于实现了解耦合 + 转存，消息本身的处理过程天然是异步进行的。而异步本身直接带来了缓冲、削峰、最终一致性等等能力，也可更专注地实现复杂的消息转发逻辑 (广播 + 单播等)。&lt;/p&gt;

&lt;p&gt;到此，我们简单进行了一次消息队列的透视，基于上面的推导，我们可以给消息队列下一个更深刻的定义，消息队列是：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;处理一种特定消息的系统，这种消息的处理结果不需要被即时依赖&lt;/li&gt;
&lt;li&gt;作为一个传递消息的中间角色，提供左右两端系统间解耦合的能力&lt;/li&gt;
&lt;li&gt;为消息提供异步处理的能力&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这便是我眼中消息队列的本质。至于广播/单播、消息重放、流处理、推拉模型、可靠投递等等都只是对消息队列本质的实现落地，而缓冲、削峰、最终一致性等是异步能力带来的结果。&lt;/p&gt;
&lt;h2 id="实现落地"&gt;实现落地&lt;/h2&gt;
&lt;p&gt;梳理完本质，我们立足于 kafka，看其是如何落地的。接下来我们对这部分做简单概述，从宏观上做一次鸟瞰，在本系列后续的内容中才逐步深入细节。&lt;/p&gt;
&lt;h3 id="角色化"&gt;角色化&lt;/h3&gt;
&lt;p&gt;消息队列作为一个解耦合的中间商，必然有上下游对接方，这一部分早已深入人心。消息队列有三大角色：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;消息生产者。产生消息者，它不关系消息的处理结果&lt;/li&gt;
&lt;li&gt;消息队列。Broker，集群模式下，会有多个 broker&lt;/li&gt;
&lt;li&gt;消息消费者。消息处理者，它不感知生产者，且可以存在任意数量，可以彼此完全独立。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;参与交互的系统、组件等都可以被划分为以上三者之一。角色化为系统的划分，消息的传递处理等提供了便捷的沟通语言。&lt;/p&gt;

&lt;p&gt;三大角色 来源 [2]：
&lt;img src="https://l.ruby-china.com/photo/early/d0f9c3ed-6db2-4b0f-88b3-41f99f07649b.png!large" title="" alt="三大角色"&gt;&lt;/p&gt;

&lt;p&gt;消息队列 (broker) 接收来自生产者的消息，存放到自己磁盘上，等消费者自己来拉数据。同一条消息可以被多种消费者消费，消费者直接可以不相关。&lt;/p&gt;

&lt;p&gt;每一条消息在逻辑上只属于某个 Topic，实际上 Broker 对于不同的 Topic 消息是已单独的文件存放，而消费者拉取时，也要明确指定拉哪个 Topic 的消息。生产和消费过程中，涉及 Topic、Partition、Group 等概念或角色，此处不再赘述这些深入人心的概念。&lt;/p&gt;
&lt;h3 id="专注核心"&gt;专注核心&lt;/h3&gt;
&lt;p&gt;在众多消息队列的落地实现中，kafka 算是一个极为专注核心的角色，它将很多东西都外放给生产者和消费者自己实现，自己则解决核心的消息存放、集群管理等。换成批评的角度来将，kafka 将生产和消费过程中的复杂度抛给了生产者和消费者，这使得 kafka 生产者和消费者的实现复杂度很高，使用成本也随之上涨。&lt;/p&gt;

&lt;p&gt;一、消息生产&lt;/p&gt;

&lt;p&gt;假设生产者往一个有多 broker 的 kafka 集群投递消息，某条消息到底该投递给哪个 broker？策略怎么定？ &lt;/p&gt;

&lt;p&gt;这些 kafka 自己是不管的，它将某个 Topic 的 partition 分布完全暴露给生产者，生产者自己定时来拉这些元信息 (通过任意 broker)。由生产者自己连对应的 broker，消息要发到哪个 broker 由生产者自己决定，是通过 key 做 hash，还是随机遍历投，由生产者自己定。(自己计算出 partition，并发到对应的 leader 节点)&lt;/p&gt;

&lt;p&gt;这完全不像有些消息队列，暴露代理节点出来，消息到底去哪儿对生产者透明，生产者只管无脑将消息发给代理进行，不需要关系集群的信息。&lt;/p&gt;

&lt;p&gt;二、消息消费&lt;/p&gt;

&lt;p&gt;消费模型有推拉两种之分。推就是消息队列自己主动将消息推给订阅者。例如 rabbitmq，会将消息推给订阅者，并提供 ack 机制，如果失败了还可以自动重试。消费者只需要连 rabbitmq 告诉它要订阅什么消息即可，实现相对简单。&lt;/p&gt;

&lt;p&gt;而 kafka 则选择的拉模式，消费者自己来 broker 拉消息，具体处理到什么位置了，可以告诉 broker，帮你记录下。至于消息消费到后怎么处理处理失败等情况由消费者自己解决，没有像 rabbitmq 那样可以自动触发重试。 &lt;/p&gt;

&lt;p&gt;消费过程相对复杂，在负载均衡上还涉及复杂的 rebalence 等。&lt;/p&gt;

&lt;p&gt;通过将生产和消费两端的策略外放，kafka 将自己本身的复杂度收拢到消息转存本身上，虽然使得使用方需要实现复杂的生产/消费程序，使用成本较高。但可以换来 kafka 本身的简洁，这背后的结果是技术透明、可扩展、高性能、灵活性等等好处。&lt;/p&gt;
&lt;h3 id="极致特性"&gt;极致特性&lt;/h3&gt;
&lt;p&gt;通过外放策略得到灵活和性能的同时，kafka 也利用了不少操作系统的 feature，例如顺序日志、异步刷盘、内存映射等。还有特有的文件存放策略，实现高效的消息定位等。这些使得 kafka 能轻松跑出百万级 tps，在性能上秒杀了众多消息队列。&lt;/p&gt;
&lt;h2 id="总结"&gt;总结&lt;/h2&gt;
&lt;p&gt;本文作为本系列的开端，着重从抽象的视觉来探讨消息队列的本质。与同步的 RPC 不同，消息队列处理那些不被即时关心处理结果的消息，并在过程中提供解耦 + 异步化的能力。&lt;/p&gt;

&lt;p&gt;这为后续的内容展开提供了宏观的知识框架，关于生产、消费、关键技术特性等将会在接下来的内容中逐步展开。&lt;/p&gt;
&lt;h2 id="参考资料"&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[1] &lt;a href="https://zhuanlan.zhihu.com/p/21649950" rel="nofollow" target="_blank"&gt;https://zhuanlan.zhihu.com/p/21649950&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[2] &lt;a href="https://my.oschina.net/andylucc/blog/603537" rel="nofollow" target="_blank"&gt;https://my.oschina.net/andylucc/blog/603537&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>early</author>
      <pubDate>Mon, 08 Mar 2021 00:01:39 +0800</pubDate>
      <link>https://ruby-china.org/topics/41003</link>
      <guid>https://ruby-china.org/topics/41003</guid>
    </item>
    <item>
      <title>gRPC 系列 (四)   框架如何赋能分布式系统</title>
      <description>&lt;p&gt;本系列分为四大部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/148139089" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (一)   什么是 RPC？&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/149821222" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (二)   如何用 Protobuf 组织内容&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/161577635" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (三)   如何借助 HTTP2 实现传输&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/344914169" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (四)   框架如何赋能分布式系统&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;前面的系列，我们已经从技术要素透视了 RPC 的本质，包括其三大要素：语义约定、网络传输、编解码。以及 gRPC 如何通过 Protobuf 和 HTTP2 实现这三大要素，并达到更低成本、更高效率、更高性能等终极目标。&lt;/p&gt;

&lt;p&gt;本文我们将回归到 RPC 的使用场景：分布式系统。从分布式系统的角度，来看待 gRPC 这个框架。框架本身的含义就意味着是一个集成者、整合者，提供出简单、全面的使用界面，以灵活适应不同的环境，并对外屏蔽足够多的细节。&lt;/p&gt;

&lt;p&gt;简单讲，框架的本质是技术落地的最后一厘米，为大规模低成本使用提供直接支撑。&lt;/p&gt;
&lt;h2 id="分布式系统"&gt;分布式系统&lt;/h2&gt;
&lt;p&gt;现代技术架构，已经重构了众多场景的生产关系，一方面服务海量的用户，另一方面承载了无数极重要的业务，并要及时作出调整，以适应环境的变化。&lt;/p&gt;

&lt;p&gt;这都使得技术系统，在保证灵活和效率的同时，还必须要有高度的稳定性和健壮性，任何一次故障都会带来难以估计的直接损失，还有背后商业上的信用本钱。&lt;/p&gt;

&lt;p&gt;受限于现代计算机的技术特性，以及通信的基础设施。一个技术系统想要在大流量下保持高健壮性、高稳定性，必然要将代码和数据分散在数量庞大的机器上，这些分散的机器一起组成一个个闭合的技术系统，完整业务需求由这些系统一起合作分工实现，这样便诞生了分布式的概念。&lt;/p&gt;

&lt;p&gt;分布式场景下，系统中的子系统或模块间需要相互通信，或传递信号，或传输数据。这就自然导致了 RPC 的诞生。这种通信场景可以简单分为三类：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;集群间的通信。如 Redis 集群基于 gossip 的数据交换，一般直接基于 TCP，一般会收敛于子系统内部。&lt;/li&gt;
&lt;li&gt;数据传输。应用读写 MySQL、Redis 等数据系统，一般直接基于 TCP，场景定制性高。&lt;/li&gt;
&lt;li&gt;消息传递。微服务之间相互接口调用。这种偏上层业务，需要适应复杂繁多的场景，并提供高度的可复用性、适应性、扩展性。所以一般会基于 TCP 再做一层封装，如 HTTP2。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;到此，我们可以从分布式系统的角度发现： &lt;strong&gt;RPC 是分布式系统通信的一种工具。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;而 gRPC 则是这种分布式系统通信场景中，偏上层应用的一种通信工具，也就是上面的第 3 类，我们且称为&lt;code&gt;业务型分布式系统&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;本文将聚焦业务分布式系统通信场景来展开对 gRPC 的学习。&lt;/p&gt;
&lt;h2 id="业务分布式系统"&gt;业务分布式系统&lt;/h2&gt;
&lt;p&gt;业务性分布式系统，可以对应于传统的单体应用。当一个单体应用以微服务或 SOA 等方式拆分后，就变成了一个分布式系统，这也是大中型互联网公司的后台系统。&lt;/p&gt;

&lt;p&gt;为了从虚到实地落地知识，我们暂且简单地把业务性分布式系统等价于微服务系统，从微服务的角度来看待 gRPC，才算是真正将讨论点落实到了实际环境中。&lt;/p&gt;

&lt;p&gt;实际上，gRPC 就是微服务类系统间通信的核心工具。到此，我们基本上将知识结构及相关关系梳理完毕，完成宏观和微观间的互通。接下来着眼于微服务系统的通信需求，看 gRPC 是如何为其赋能的。&lt;/p&gt;

&lt;p&gt;所谓赋能，用人话来讲，就是&lt;strong&gt;能低成本大规模使用&lt;/strong&gt;，一般就几个需求：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;有良好的适应能力。对于部分核心能力可以通过插件实现定制化能力，适应不同的环境。&lt;/li&gt;
&lt;li&gt;提供关键性问题解决方案。例如高效率的并发模型、加密、压缩等等。&lt;/li&gt;
&lt;li&gt;有足够的扩展能力。能通过配置、注入等方式灵活实现扩展功能或开启部分功能。&lt;/li&gt;
&lt;li&gt;足够简单。屏蔽底层细节，能无脑上手使用，不需要懂 http2、protobuf、IO 模型等等是什么&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;接下来我们就从以上几个点，来展开对 gRPC 的学习。由于框架庞大复杂，受限于作者水平和使用经验，讨论将聚焦于几个核心点展开。&lt;/p&gt;
&lt;h2 id="业务系统的通信需求"&gt;业务系统的通信需求&lt;/h2&gt;
&lt;p&gt;分布式系统中服务的相互调用看似复杂，其实本质就是两个点间的点对点通信，点与点相互连接串起一张网。&lt;/p&gt;

&lt;p&gt;点与点通信进一步拆解后，在微观可以分为&lt;code&gt;调用方&lt;/code&gt;和&lt;code&gt;服务方&lt;/code&gt;的关系，大部分情况下就是单向调用，在 stream 模式下可升级为相互调用，但每一次调用都不会逃离&lt;code&gt;调用方&lt;/code&gt;和&lt;code&gt;服务方&lt;/code&gt;两个角色。这为我们提供了很好的突破口，搞清楚了两点之间的调用，也自然能延伸全局。&lt;/p&gt;

&lt;p&gt;当 A 要调用 B 时，问题就来了。&lt;/p&gt;
&lt;h2 id="适应能力"&gt;适应能力&lt;/h2&gt;&lt;h3 id="服务发现"&gt;服务发现&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;A 要能得知 B 的可调用地址&lt;/li&gt;
&lt;li&gt;B 的部署方式可能多种多样&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;B 可能是以微服务的形式注册，通过注册中心可以拉到其节点列表，不同的公司注册中心实现也千差万别。但也可能是只暴露出了一个代理的地址 (如 Nginx/lvs)，甚至实现了传统的 DNS 模式。&lt;/p&gt;

&lt;p&gt;业务发展过程中什么情况都可能有，大概率是一个大杂烩。gRPC 作为一个落地的框架就必须要有足够的适应能力，覆盖繁杂的情形，通过提供自定义接入能力 (插件)，以适应复杂的环境。&lt;/p&gt;

&lt;p&gt;为此 gRPC 仿造 RFC 的标准设计了一套名称发现协议 [1]，一个服务的标示可以表示如下：&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;scheme://authority/endpoint_name&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;scheme 表示要使用的名称系统，例如 DNS，或一套自己的服务发现系统，例如 ectd、Eureka、consul，或任意自研服务发现系统的名字。&lt;/li&gt;
&lt;li&gt;authority 表示一些特定于方案的引导信息，例如对于 DNS，authority 可以提供一个解析 endpoint_name 的地址，相当于 DNS 服务器。(一般在 DNS 模式下才有用)&lt;/li&gt;
&lt;li&gt;endpoint_name 表示一个服务的具体名字。例如 login-service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;例如使用 DNS 时，B 服务的地址可以设计为：dns://somedns.com/addrOfServerB，其内涵为B服务通过dns这种名称系统来发现，B具体的地址可以定时轮询somedns.com得知(带上参数addrOfServerB)，实际请求发往解析得到的具体IP:port，这套机制即可满足上面我们说的暴露代理地址的模式。&lt;/p&gt;

&lt;p&gt;而常规的微服务模式则是有一个服务注册中心，B 服务的实例启动后将自己注册到服务中心，调用方通过服务中心拉取到可调用地址。目前的技术现状是，每个公司都恨不得自己搞一套服务注册系统，而实际上这就是中大厂的现状。通过插件接入对接自己的服务发现系统是不可绕过的刚需。&lt;/p&gt;

&lt;p&gt;假设我们这套服务注册中名字为 discovery，那 B 服务的地址也可以表示为：&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;discovery://someauthority/appID_B&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;B 服务的可用地址通过 discovery 来获取。具体怎么获取呢？gRPC 其实将这种能力完全外放了。提供了名称系统注册能力，实际上就是一个 interface:&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type Builder interface {
    Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)
    Scheme() string
}
// 注册一个名称系统
resolver.Register(&amp;amp;Builder{scheme: "discovery"})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;向 B 服务发送请求前，会解析&lt;code&gt;discovery://someauthority/appID_B&lt;/code&gt;出 scheme 的值，并用使用对应注册的名称系统来获取可调用的节点。相当于 gRPC 将这部分能力外放出去了。具体如何通过 appID_B 去获取可调用的地址列表，gRPC 不管，由使用方自己实现。当部署节点发生变化时，调用 gRPC 的接口 (NewAddress/UpdateState) 通知其即可。[2]&lt;/p&gt;

&lt;p&gt;简单理解为以下过程：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;注册一个名称系统的实例到 gRPC（一般启动时注册，可以注册任意数量）&lt;/li&gt;
&lt;li&gt;A 通过 gRPC 调用 B 时，gRPC 会解析出 B 的 scheme，从注册的名称系统获得可用的服务地址列表，一般是一批 IP:port（IP:port 如何得到 gRPC 不关心，使用方根据自身情况实现即可）&lt;/li&gt;
&lt;li&gt;gRPC 针对 IP:port 建立网络连接 &lt;/li&gt;
&lt;li&gt;gRPC 将请求发出去，接收回复 &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;通过上面的方式，使用方按照 scheme://authority/endpoint_name 定好服务名字，并实现相应的接口 (可调用地址的获取、变更等)，注册到 gRPC，便可以适应复杂的分布式调用场景。&lt;/p&gt;

&lt;p&gt;假设某个公司由于山头过多，实现了多个服务发现系统，短时间内还难以统一，服务分散注册到这几个系统。在用 gRPC 时，也能轻松应对。&lt;/p&gt;

&lt;p&gt;服务注册现状：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;discovery://someauthority/appID_B    appID_B 通过自研discovery系统注册
etcd123://someauthority/appID_C   appID_C 通过自研etcd123系统注册
consul456://someauthority/appID_D    appID_D 通过自研consul456系统注册
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;启动时向 gRPC 注册名称系统：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import "google.golang.org/grpc/resolver"
resolver.Register(&amp;amp;Builder{scheme: "discovery"})
resolver.Register(&amp;amp;BuilderEtcd{scheme: "etcd123"})
resolver.Register(&amp;amp;BuilderConsul{scheme: "consul456"})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当 A 通过 gRPC 调用 B、C、D 时，会解析出 schema，从注册的名称系统来获取实际调用地址。调用示例 [3]。Resolver 实现示例 [4]。&lt;/p&gt;
&lt;h3 id="负载均衡"&gt;负载均衡&lt;/h3&gt;
&lt;p&gt;上面解决了获取可调用地址的问题，紧接着问题又来了，如何做负载均衡？一批可调用的地址中，到底选哪个，怎么选？&lt;/p&gt;

&lt;p&gt;常规的负载均衡算法非常多，如轮询、随机、耗时最短、加权随机等等，由于技术系统的异构性，很多时候难以简单随机轮询。gRPC 为了提供出足够强的适应性，把负载均衡的策略也外放了。使用者可以在启动时设置负载均衡的对象，通过插件可只定义策略。&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type PickerBuilder interface {
    // gRPC将建立好的所有连接传给负载均衡器，创建一个picker
    Build(readySCs map[resolver.Address]balancer.SubConn) balancer.Picker
}
type Picker interface { 
       // 从gRPC给的连接中选一个可调用的节点返回给gRPC
    Pick(ctx context.Context, info PickInfo) (conn SubConn, done func(DoneInfo), err error)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;具体的实现相对简单：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;gRPC 会将封装好的网络连接丢给负载均衡对象，当连接变化时，由 PickerBuilder 新 Build 一个 picker。&lt;/li&gt;
&lt;li&gt;每次调用前调用 picker Pick 一个节点出来供使用&lt;/li&gt;
&lt;li&gt;Pick 接口会返回一个 done 函数，rpc 调用完毕后会回调，支持回传一些 balancer.DoneInfo&lt;/li&gt;
&lt;li&gt;balancer.DoneInfo 里面支持一些 metadata，也就是服务方可以通过 HTTP2 的 header 回传的一些 key:value&lt;/li&gt;
&lt;li&gt;服务方可以在返回请求时，将自己的 CPU、负载等反映压力的数据写到 metadata 中，这些数据可以通过 done 函数回写到 picker，供决策使用。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;根据上面开放的能力，例如你可以实现一种叫 p2c 的策略 [5]，先随机选择两个节点，然后根据记录的对端 CPU 负载等多种参数，最后选择一个最佳节点。这种相比轮询或随机具有更强的适应能力，可以避开部分出问题的节点。&lt;/p&gt;

&lt;p&gt;总结下，gRPC 对于负载均衡提供了以下能力：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;负载均衡策略外放&lt;/li&gt;
&lt;li&gt;支持 done 回调，透传服务方的一些数据 (需要在服务方支持，通过 grpc.SetTrailer 写入流即可)&lt;/li&gt;
&lt;li&gt;支持透传自定义命名系统回传的 metadata，这个里面可以携带众多信息，如权重等等。(resolver 包 struct Address)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;基于这些特性，便可以自由实现花样百出的负载均衡策略。&lt;/p&gt;

&lt;p&gt;整体结果简单示意图如下：
&lt;img src="https://l.ruby-china.com/photo/early/1e3aecae-9e79-4091-923d-9031eb06ee59.png!large" title="" alt=""&gt;&lt;/p&gt;
&lt;h2 id="关键问题"&gt;关键问题&lt;/h2&gt;
&lt;p&gt;框架在整合编解码、网络传输等 feature 同时，也需要提供部分核心功能，这些功能往往是系统刚需，存在重复劳动的地方。最常见的就是并发模型。&lt;/p&gt;

&lt;p&gt;并发模型一般是针对服务方而言的，服务方需要有高效率的 IO，在资源有限的情况下一方面快速处理请求，另一方面提供足够高的并发能力，实现高吞吐低延迟。&lt;/p&gt;

&lt;p&gt;这些都可以认为是 C10k 问题 [6] 的延伸。传统的服务方基于阻塞 IO 实现请求读写，这样一个线程/进程只能同时处理一个请求。当用户量暴增后，不能来一个请求就 fork 一个子进程或创建一个线程来处理，这样资源扛不住。&lt;/p&gt;

&lt;p&gt;所以得有更有效率的策略，得让一个线程/进程能同时处理多个请求。这便诞生了多路复用 [7] 的需求。让一个线程同时监听多个 socket 的状态，谁就绪才处理谁，而不是依赖操作系统的接口直接 hang 住，白白浪费 CPU 时间。(select/epoll)&lt;/p&gt;

&lt;p&gt;为了能实现高吞吐的目标，一方面要尽量减少在 IO 上的无效等待，另一方面要利用好多核。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;要减少无效等待，最好的策略之一就是基于事件驱动。&lt;/li&gt;
&lt;li&gt;要利用多核，就需要和 CPU 数量相匹配的线程数量来并发处理请求。同时尽量减少上下文切换&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;于是便诞生了大名鼎鼎的 Reactor 模型 [8]，linux 环境下大量号称高并发 server 都是实现了 Reactor 模型，例如 java 系的 netty。&lt;/p&gt;

&lt;p&gt;每一个好的 RPC 框架势必要提供高吞吐的并发模型，也就相当于实现 Reactor，屏蔽网络 IO 处理这堆复杂的细节，解决服务方高吞吐的刚需，让小白上手就能高并发。&lt;/p&gt;

&lt;p&gt;在 golang 出现之前，大量的语言例如 Java、python、ruby 等的 rpc 框架都要自己实现 Reactor 模型实现高吞吐，这其实是应用层的重复劳动。golang 从语言层面下沉了类似的实现，通过实现 netpoller[9]，让 golang 程序的网络 IO 读写规避掉无意义的等待，和上下文切换。简单讲就是几点：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;golang runtime 封装了非阻塞 IO，给应用程序暴露成阻塞 IO&lt;/li&gt;
&lt;li&gt;当 goutouting 操作一个未就绪的 socket 时，操作系统会返回 error，runtime 会拦截这个 error，将该 socket 加入状态监听队列 (可以简单认为是一个 epoll)，并将该 gourouting 挂起&lt;/li&gt;
&lt;li&gt;当监听到对应 socket 可读/可写时，会将对应的 gourouting 找到并让其立即等待执行&lt;/li&gt;
&lt;li&gt;第 2，3 步周而复始，从 gourouting 角度自己在操作阻塞 IO，然而并没有 CPU 时间浪费在等待上，也没有线程上下文切换&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;这样的结果就是 golang 的相关应用程序不再需要实现 Reactor 模型，来一个请求则创建一个 gourouting 去处理就行，这极大简化了并发模型。&lt;/p&gt;
&lt;h2 id="扩展能力"&gt;扩展能力&lt;/h2&gt;
&lt;p&gt;了解一般框架的人都知道，有不少标配能力，以提供高度自由的扩展功能，一般通过几种方式提供：&lt;/p&gt;
&lt;h3 id="1. 自定义插件"&gt;1. 自定义插件&lt;/h3&gt;
&lt;p&gt;例如上面的服务发现、负载均衡。因为 gRPC 天然和 protobuf 绑定，谈到扩展插件，就不得不提及 gRPC 在编码层的解耦。&lt;/p&gt;

&lt;p&gt;因为使用 protobuf 的前提是你得有对应的.proto 文件，这样才能进行编解码。但有些场景下，类似代理的角色没法持有所有的 proto，这便限制了下面的场景：
&lt;img src="https://l.ruby-china.com/photo/early/f1d56bca-b356-4fbb-b043-28f6a4d67eeb.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;假设有一个 http 的场景需要调用 gRPC 的接口实现功能，这需要有一个近似透传的代理来实现，但都通过 protobuf 包装数据，代理则要持有所有下游的 proto 文件，这不现实。&lt;/p&gt;

&lt;p&gt;但如果将编解码的方式解耦出来，例如通过 JSON 进行编解码，便能轻松解决问题，这带来了极大的灵活性。在 http 和 gRPC 的混用融合上价值不菲，而且 gRPC 请求的调用调试也可以像 http 那样简单。&lt;/p&gt;

&lt;p&gt;gRPC 提供了 codec 的插件注入能力，以实现自定义编解码：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type Codec interface {
    Marshal(v interface{}) ([]byte, error)
    Unmarshal(data []byte, v interface{}) error
    Name() string
}
// 注册一个编解码插件
import "google.golang.org/grpc/encoding"
encoding.RegisterCodec(JSON{}) // JSON实现了上面的interface
grpc.CallContentSubtype(JSON{}.Name()) // 通过option指定使用JSON
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只要服务方也注册了，且下游参数能通过 json 反序列化成 struct 对象，则调用便顺利进行。&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type Req struct {
    Platform string `protobuf:"bytes,1,opt,name=platform,proto3" json:"platform" form:"platform" validate:"required"`
    Build int64 `protobuf:"varint,2,opt,name=build,proto3" json:"build" form:"build"`
    XXX_NoUnkeyedLiteral struct{} `json:"-"`
    XXX_unrecognized     []byte   `json:"-"`
    XXX_sizecache        int32    `json:"-"`
}
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="2. 配置-调用时通过 option注入"&gt;2. 配置 - 调用时通过 option 注入&lt;/h3&gt;
&lt;p&gt;gRPC 可以通过 option 配置提供多种能力，例如：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;自动重试。(RetryableStatusCodes)&lt;/li&gt;
&lt;li&gt;加密&lt;/li&gt;
&lt;li&gt;压缩&lt;/li&gt;
&lt;li&gt;超时定制&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="3. 拦截器(Interceptor)，有些也称middleware"&gt;3. 拦截器 (Interceptor)，有些也称 middleware&lt;/h3&gt;
&lt;p&gt;拦截器可以在调用方和服务方同时存在。一般用来实现熔断、限流、日志收集、open-tracing、异常捕获、数据统计、鉴权、数据注入等等多种功能。可以一层包一层，支持任意数量。&lt;/p&gt;

&lt;p&gt;插句题外话，上面大部分扩展能力一般是以 SDK 的方式独立于业务代码，但都是运行在同一个进程中。如果把上面分布式治理部分功能剥离出来集中治理优化，并和业务进程隔离部署，就是一个 ServiceMesh 落地雏型。&lt;/p&gt;
&lt;h2 id="屏蔽细节"&gt;屏蔽细节&lt;/h2&gt;
&lt;p&gt;作为落地的最后一厘米，框架要尽最大能力屏蔽底层细节，特别是 HTTP2 相关细节：如何建立网络连接，如何发送数据，如何保持连接状态等等。gRPC 在这方面做得很好，使用方只需要注册实现几个接口，便可能无脑 run 起来。&lt;/p&gt;

&lt;p&gt;以下是一个简单的 gRPC 核心对象关系图：
&lt;img src="https://l.ruby-china.com/photo/early/25c938fa-aa60-42e3-a0a5-2e73f8c30753.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;对于使用方而言，只需要实现 Resolver 插件提供可调用的列表，以及 Picker 如何选择节点的逻辑。其余复杂的状态管理、网络处理等等都被完全屏蔽。&lt;/p&gt;
&lt;h2 id="开源参考资料"&gt;开源参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[1] &lt;a href="https://github.com/grpc/grpc/blob/master/doc/naming.md" rel="nofollow" target="_blank"&gt;https://github.com/grpc/grpc/blob/master/doc/naming.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[2] &lt;a href="https://github.com/grpc/grpc-go/blob/v1.35.0/resolver/resolver.go#L194" rel="nofollow" target="_blank"&gt;https://github.com/grpc/grpc-go/blob/v1.35.0/resolver/resolver.go#L194&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[3] &lt;a href="https://github.com/bilibili/kratos-demo/blob/master/api/client.go#L15" rel="nofollow" target="_blank"&gt;https://github.com/bilibili/kratos-demo/blob/master/api/client.go#L15&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[4] &lt;a href="https://github.com/go-kratos/kratos/blob/v0.6.0/pkg/net/rpc/warden/resolver/resolver.go#L31" rel="nofollow" target="_blank"&gt;https://github.com/go-kratos/kratos/blob/v0.6.0/pkg/net/rpc/warden/resolver/resolver.go#L31&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[5] &lt;a href="https://github.com/go-kratos/kratos/blob/v0.6.0/pkg/net/rpc/warden/balancer/p2c/p2c.go#L110" rel="nofollow" target="_blank"&gt;https://github.com/go-kratos/kratos/blob/v0.6.0/pkg/net/rpc/warden/balancer/p2c/p2c.go#L110&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[6] &lt;a href="https://blog.csdn.net/chenrui310/article/details/101685827" rel="nofollow" target="_blank"&gt;https://blog.csdn.net/chenrui310/article/details/101685827&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[7] &lt;a href="https://draveness.me/redis-io-multiplexing/" rel="nofollow" target="_blank"&gt;https://draveness.me/redis-io-multiplexing/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[8] &lt;a href="https://tech.youzan.com/yi-bu-wang-luo-mo-xing/" rel="nofollow" target="_blank"&gt;https://tech.youzan.com/yi-bu-wang-luo-mo-xing/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[9] &lt;a href="https://morsmachine.dk/netpoller" rel="nofollow" target="_blank"&gt;https://morsmachine.dk/netpoller&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>early</author>
      <pubDate>Sun, 17 Jan 2021 14:27:12 +0800</pubDate>
      <link>https://ruby-china.org/topics/40823</link>
      <guid>https://ruby-china.org/topics/40823</guid>
    </item>
    <item>
      <title>gRPC 系列 (三)  如何借助 HTTP2 实现传输</title>
      <description>&lt;p&gt;本系列分为四大部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/148139089" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (一)   什么是 RPC？&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/149821222" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (二)   如何用 Protobuf 组织内容&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/161577635" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (三)   如何借助 HTTP2 实现传输&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/344914169" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (四)   框架如何赋能分布式系统&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="回顾"&gt;回顾&lt;/h2&gt;
&lt;p&gt;在系列二中，我们一起学习了 gRPC 如何使用 Protobuf 来组织数据，达到高效编解码、高压缩率的目标。本文我们将更进一步，看看这些数据是如何在网络中被传输的，达到以更低的资源实现更高效传输的目标。内容将围绕以下几点展开：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP2 要解决的问题，HTTP1.1 的缺点&lt;/li&gt;
&lt;li&gt;HTTP2 的原理，它是如何降低传输成本，借此我们更深入理解何为&lt;code&gt;二进制编码&lt;/code&gt;；同时它是如何提高网络资源利用效率，重温&lt;code&gt;多路复用&lt;/code&gt;的思想&lt;/li&gt;
&lt;li&gt;拉通 Protobuf 和 HTTP2，通过抓包，从&lt;code&gt;数据和协议&lt;/code&gt;角度洞悉 gRPC 调用&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="网络传输的目标"&gt;网络传输的目标&lt;/h2&gt;
&lt;p&gt;数据的传输，都是被切割成一个个小块，包在层层网络协议头里，通过一个个路由器依次转发，最终到达目的地，被重新组装起来。这是网络传输的基本原理，在这个过程中，有两个亘古不变的目标：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;更快的传输。快的背后就是少，传输的数据越少、越小，整体的速度也就越快。&lt;/li&gt;
&lt;li&gt;更低的资源消耗。这背后是资源的高效利用，就像 cpu 那样，压榨的越厉害，就越节约资源。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;随着行业的发展，对上述两个目标的追求也更加极致，要想传输速度更快，传输的数据体积要小。数据体积拆开来看，有两个部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;请求本身的数据。这是系列 (二) 中讨论的核心，用 Protobuf 实现极致的压缩，感兴趣可以回看&lt;/li&gt;
&lt;li&gt;协议本身的消耗。协议需要自我表达，这会消耗一部分空间。这是本文的重点，会讨论 HTTP2 如何降低 HTTP1.1 在协议上的消耗&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="HTTP1.1 被视为差生"&gt;HTTP1.1 被视为差生&lt;/h2&gt;
&lt;p&gt;HTTP1.1 以其简单、可读性高、超高普及率、历史悠久，作为经典的存在，为互联网的普及做出了重要贡献。但在当今超高的流量、超高的使用频率背景下，打开一个页面动辄几十个请求，使得速度已经难以满足贪婪人类的需求。这主要表现在两个方面：&lt;/p&gt;

&lt;p&gt;一、 &lt;strong&gt;冗余文本过多，导致传输体积很大&lt;/strong&gt;
作为一款经典的无状态协议，它使得 Web 后端可以灵活地转发、横向扩展，但其代价是每个请求都会带上冗余重复的 Header，这些文本内容会消耗很多空间，和&lt;code&gt;更快传输&lt;/code&gt;的目标相左。&lt;/p&gt;

&lt;p&gt;二、 &lt;strong&gt;并发能力差，网络资源利用率低&lt;/strong&gt;
HTTP1.1 是基于文本的协议，请求的内容打包在 header/body 中，内容通过\r\n来分割，&lt;strong&gt;同一个 TCP 连接中，无法区分 request/response 是属于哪个请求&lt;/strong&gt;，所以无法通过一个 TCP 连接并发地发送多个请求，只能等上一个请求的 response 回来了，才能发送下一个请求，否则无法区分谁是谁。&lt;/p&gt;

&lt;p&gt;于是 H1.1 提出了一个 pipeline 的特性，允许请求方一口气并发多个 request，但对服务方有一个变态的要求，需要对应的 response 按照 request 的顺序严格排列，因为不按顺序排列就分不清楚 response 是属于哪个 request 的。这给 Proxy(Nginx 等) 带来了复杂性，同时如果第一个请求迟迟不返回，那后面的请求都会受影响，所以普及率不高。&lt;/p&gt;

&lt;p&gt;但当今的 Web 页面有玲琅满目的图片、js、css，如果让请求一个个串行执行，那页面的渲染会变得极慢。于是只能同时创建多个 TCP 连接，实现并发下载数据，快速渲染出页面。这会给浏览器造成较大的资源消耗，电脑会变卡。很多浏览器为了兼顾下载速度和资源消耗，会对同一个域名限制并发的 TCP 连接数量，如 Chrome 是 6 个左右，剩下的请求则需要排队，Network 下的 Waterfall 就可以观察排队情况 (见下图右边的颜色条)。&lt;/p&gt;

&lt;p&gt;H1.1 时，有 6 个并发连接，可以看到最下面三个请求在排队：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/39e7e1c5-06c3-47e9-8c1e-831be591b9da.png!large" title="" alt="图片涞源[5]"&gt;&lt;/p&gt;

&lt;p&gt;HTTP2 中，可以看出请求时同时发出的，没有排队，且只占用一个连接：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/2b9d3350-d479-4cee-912a-3ef8ba286358.png!large" title="" alt="图片涞源[5]"&gt;&lt;/p&gt;

&lt;p&gt;狡猾的人类为了避开这个数量限制，将图片、css、js 等资源放在不同域名下 (或二级域名)，避开排队导致的渲染延迟。快速下载的目标实现了，但这和&lt;code&gt;更低的资源消耗&lt;/code&gt;目标相违背，背后都是高昂的带宽、CDN 成本。&lt;/p&gt;
&lt;h2 id="HTTP2 当救世主"&gt;HTTP2 当救世主&lt;/h2&gt;
&lt;p&gt;H1.1 在速度和成本上的权衡让人纠结不已，HTTP2 的出现就是为了优化这些问题，在&lt;code&gt;更快的传输&lt;/code&gt;和&lt;code&gt;更低的成本&lt;/code&gt;两个目标上更进了一步。有以下几个基本点：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP2 未改变 HTTP 的语义 (如 GET/POST 等)，只是在传输上做了优化&lt;/li&gt;
&lt;li&gt;引入帧、流的概念，在 TCP 连接中，可以区分出多个 request/response&lt;/li&gt;
&lt;li&gt;一个域名只会有一个 TCP 连接，借助帧、流可以实现多路复用，降低资源消耗&lt;/li&gt;
&lt;li&gt;引入二进制编码，降低 header 带来的空间占用&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;核心可分为 &lt;code&gt;头部压缩&lt;/code&gt; 和 &lt;code&gt;多路复用&lt;/code&gt;。这两个点都服务于&lt;code&gt;更快的传输&lt;/code&gt;、&lt;code&gt;更低的资源消耗&lt;/code&gt;这两个目标，与上文呼应。&lt;/p&gt;
&lt;h2 id="头部压缩"&gt;头部压缩&lt;/h2&gt;
&lt;p&gt;现在的 web 页面，大多比较复杂，新打开一个地址，动辄产生几十个请求，这会发送大量的 header，大部分内容都是一样的内容，以 baidu 为例：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request:
GET  HTTP/1.1
Host: www.baidu.com
Cache-Control: no-cache
Postman-Token: a9702bac-94c4-c7da-2041-7c7ac5f85b6e

response:
Access-Control-Allow-Credentials: true
Connection: keep-alive
Content-Encoding: gzip
Content-Type: text/html; charset=UTF-8
Server: Apache
Transfer-Encoding: chunked
Vary: Accept-Encoding
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些文本内容一次次重复地发送，占用了大量的带宽，如何将这些成本降下去，而又保留 HTTP 无状态的优点呢？&lt;/p&gt;

&lt;p&gt;基于这个想法，诞生了 HPACK[2]，全称为&lt;code&gt;HTTP2头部压缩&lt;/code&gt;，它以极富创造力的方式，提供了两种方式极大地降低了 header 的传输占用。&lt;/p&gt;

&lt;p&gt;一、&lt;strong&gt;将高频使用的 Header 编成一个静态表&lt;/strong&gt;，每个 header 对应一个数组索引，每次只用传这个索引，而不是冗长的文本。表总共有 61 项，下图是前 30 项：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/0a23a64b-5f76-48b0-9f90-077e7c4afd84.png!large" title="" alt="图片来源[3]"&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; 传 3 代表 "POST"，这用一个字节表示了原来 4 个字节&lt;/li&gt;
&lt;li&gt; 传 28 代表 content-length，这用一个字节表示了原来 14 个字节（value 下文会讨论）&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;可以预见这种方式，在大量的请求环境下，可以明显降低传输内容。服务端根据内容查表，就可以还原出 header。&lt;/p&gt;

&lt;p&gt;二、&lt;strong&gt;支持动态地&lt;/strong&gt;在表中增加 header&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host: www.baidu.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在上面的实例中，打开 baidu 时，对应域名的所有请求都会带上 Host，这又是重复冗余的数据。但由于各家网站的 host 不相同，无法像上面那样做成一个静态的表。HPACK 支持动态地在表中增加 header，例如：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;62   Host: www.baidu.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在请求发起前，通过协议将上面 Header 添加到表中，则后面的请求都只用发送 62 即可，不用再发送文本，这又节约了大量空间。(请求方/服务方的&lt;code&gt;表成员&lt;/code&gt;会保持同步一致)&lt;/p&gt;

&lt;p&gt;上面两个分别被成为静态表和动态表。静态表是协议级别的约定，是不变的内容。动态表则是基于当前 TCP 连接进行协商的结果，发送请求时会相互设置好 header，让请求方和服务方维护同一份动态表，后续的请求可复用。连接销毁时，动态表也会注销。&lt;/p&gt;
&lt;h2 id="多路复用"&gt;多路复用&lt;/h2&gt;
&lt;p&gt;H1.1 核心的尴尬点在于，在同一个 TCP 连接中，没办法区分 response 是属于哪个请求，一旦多个请求返回的文本内容混在一起，就天下大乱，所以请求只能一个个串行排队发送。这直接导致了 TCP 资源的闲置。&lt;/p&gt;

&lt;p&gt;HTTP2 为了解决这个问题，提出了&lt;code&gt;流&lt;/code&gt;的概念，每一次请求对应一个流，有一个唯一 ID，用来区分不同的请求。基于流的概念，进一步提出了&lt;code&gt;帧&lt;/code&gt;，一个请求的数据会被分成多个帧，方便进行数据分割传输，每个帧都唯一属于某一个流 ID，将帧按照流 ID 进行分组，即可分离出不同的请求。这样同一个 TCP 连接中就可以同时并发多个请求，不同请求的帧数据可穿插在一起，根据流 ID 分组即可。&lt;strong&gt;这样直接解决了 H1.1 的核心痛点，通过这种复用 TCP 连接的方式，不用再同时建多个连接，提升了 TCP 的利用效率&lt;/strong&gt;。这也是&lt;code&gt;多路复用&lt;/code&gt;思想的一种落地方式，在很多消息队列协议中也广泛存在，如 AMQP[4]，其&lt;code&gt;channel&lt;/code&gt;的概念和&lt;code&gt;流&lt;/code&gt;如出一辙，大道相通。&lt;/p&gt;

&lt;p&gt;在 HTTP2 中，流是一个逻辑上的概念，实际上就是一个 int 类型的 ID，可顺序自增，只要不冲突即可，每条&lt;code&gt;帧&lt;/code&gt;数据都会携带一个流 ID，当一串串帧在 TCP 通道中传输时，通过其流 ID，即可区分出不同的请求。&lt;/p&gt;

&lt;p&gt;帧则有更多较为复杂的作用，HTTP2 几乎所有数据交互，都是以帧为单位进行的，包括 header、body、约定配置 (除了 Magic 串)，这天然地就需要给帧进行分类，于是协议约定了以下帧类型：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HEADERS：帧仅包含 HTTP header 信息。&lt;/li&gt;
&lt;li&gt;DATA：帧包含消息的所有或部分请求数据。&lt;/li&gt;
&lt;li&gt;PRIORITY：指定分配给流的优先级。服务方可先处理高优先请求&lt;/li&gt;
&lt;li&gt;RST_STREAM：错误通知：一个推送承诺遭到拒绝。终止某个流。&lt;/li&gt;
&lt;li&gt;SETTINGS：指定连接配置。(用于配置，流 ID 为 0) [会 ACK 确认收到]&lt;/li&gt;
&lt;li&gt;PUSH_PROMISE：通知一个将资源推送到客户端的意图。&lt;/li&gt;
&lt;li&gt;PING：检测信号和往返时间。（流 ID 为 0）[会 ACK]&lt;/li&gt;
&lt;li&gt;GOAWAY：停止为当前连接生成流的停止通知。&lt;/li&gt;
&lt;li&gt;WINDOW_UPDATE：用于流控制，约定发送窗口大小。&lt;/li&gt;
&lt;li&gt;CONTINUATION：用于继续传送 header 片段序列。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;一次 HTTP2 的请求有以下过程：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;通过一个或多个 SETTINGS 帧约定一些数据（会有 ACK 机制，确认约定内容）&lt;/li&gt;
&lt;li&gt;请求方通过 HEADERS 帧将&lt;code&gt;请求A&lt;/code&gt;header 打包发出&lt;/li&gt;
&lt;li&gt;&lt;em&gt;请求 B 可穿插···&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;请求方通过 DATA 帧将&lt;code&gt;请求A&lt;/code&gt;request 数据打包发出&lt;/li&gt;
&lt;li&gt;服务方通过 HEADERS 帧将&lt;code&gt;请求A&lt;/code&gt;response header 打包发出&lt;/li&gt;
&lt;li&gt;&lt;em&gt;请求 C 可穿插···&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;服务方通过 DATA 帧将&lt;code&gt;请求A&lt;/code&gt;response 数据打包发出
## 深入 HTTP2
前文简单介绍了，头部压缩和多路复用的具体思路和解决问题的方法，接下来我们深入 HTTP2，看看这两个特性是如何落地的，在数据上形成直观地把握，也借此了解何为&lt;code&gt;二进制编码&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;任何一个应用层的传输协议，都需要解决一个问题，那就是如何表示数据结尾，如何分割数据。在 H1.1 中，我们知道，它粗暴地先发 Header，再发 body，每个 header 通过&lt;code&gt;\r\n&lt;/code&gt;文本内容来分割，header 和 body 通过&lt;code&gt;\r\n\r\n&lt;/code&gt;来分割，通过 content-length 的值读取 body，一个请求的内容就成功结束。&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// 一次请求的返回
200 OK\r\nHeader1:Value1\r\nHeader2:Value2\r\nHeader3:Value3\r\n\r\nI am body
// 网络中实际传输的是上面文本的ascii编码
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;HTTP2 为了降低协议占用，不会使用文本分割，也不会使用文本来表示 header。它是如何表示一帧开始、一帧结束、header 传完了、body 传完了呢？ &lt;/p&gt;

&lt;p&gt;下面是帧格式，所有帧都是一个固定的 9 字节头部 (payload 之前) 跟一个指定长度的数据 (payload):&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+-----------------------------------------------+
|                 Length (24)                   |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+-------------------------------+
|R|                 Stream Identifier (31)                      |
+=+=============================================================+
|                   Frame Payload (0...)                      ...
+---------------------------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Length 代表整个帧的长度，用一个 24 位无符号整数表示。头部的 9 字节不算在这个长度里。从 payload 开始读 Length 这么多字节，一帧数据也就读完结束。&lt;/li&gt;
&lt;li&gt;Type 定义 帧 的类型，用 8 bits 表示。帧类型决定了帧的格式和语义，不同类型有差异&lt;/li&gt;
&lt;li&gt;Flags 是为帧类型相关而预留的布尔标识。标识对于不同的帧类型赋予了不同的语义，例如下面会提到的 Padding&lt;/li&gt;
&lt;li&gt;R 是一个保留的比特位。这个比特的语义没有定义，发送时它必须被设置为 (0x0), 接收时需要忽略。&lt;/li&gt;
&lt;li&gt;Stream Identifier 唯一标示一个流，用 31 位无符号整数表示。客户端建立的 sid 必须为奇数，服务端建立的 sid 必须为偶数，值 (0x0) 保留给与整个连接相关联的帧 (连接控制消息)，而不是单个流&lt;/li&gt;
&lt;li&gt;Frame Payload 是主体内容，由帧类型决定（上面的 9 个字节都是协议本身的消耗，payload 才是请求本身的主要内容）&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;不同的帧类型，有不同的 Payload 格式，我们分别介绍 DATA 帧和 HEADDERS 帧：&lt;/p&gt;

&lt;p&gt;DATA 帧的 Payload:&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+---------------+
|Pad Length? (8)|
+---------------+-----------------------------------------------+
|                            Data (*)                         ...
+---------------------------------------------------------------+
|                           Padding (*)                       ...
+---------------------------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Pad Length: ? 表示此字段的出现时有条件的，当帧的 Flags(8) 的第三位为 1 时，才有效，否则会被忽略&lt;/li&gt;
&lt;li&gt;Data: 传递的数据，其长度上限等于帧的 payload 长度减去其他出现的字段长度 (如果有 pad 的话)。在 gRPC 中，Data 这部分内容就是用 Protobuf 将数据编码的结果&lt;/li&gt;
&lt;li&gt;Padding: 填充字节，没有具体语义，发送时必须设为 0，作用是混淆报文长度，为安全目的服务&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Data 帧的 Flags(8) 目前有两个位有意义：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;END_STREAM: bit 0 设为 1 代表当前流的最后一帧，告诉接收方&lt;strong&gt;请求数据发送完毕&lt;/strong&gt;，否则还要继续等下一帧 (接收方)&lt;/li&gt;
&lt;li&gt;PADDED: bit 3 设为 1 代表存在 Padding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;HEADER 帧 Payload：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+---------------+
 |Pad Length? (8)|
 +-+-------------+-----------------------------------------------+
 |E|                 Stream Dependency? (31)                     |
 +-+-------------+-----------------------------------------------+
 |  Weight? (8)  |
 +-+-------------+-----------------------------------------------+
 |                   Header Block Fragment (*)                 ...
 +---------------------------------------------------------------+
 |                           Padding (*)                       ...
 +---------------------------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Pad Length: 同 DATA 帧&lt;/li&gt;
&lt;li&gt;E: 一个比特位声明流的依赖性是否是排他的，存在则代表 PRIORITY flag 被设置&lt;/li&gt;
&lt;li&gt;Stream Dependency: 指定一个 stream identifier，代表当前流所依赖的流的 id，存在则代表 PRIORITY flag 被设置&lt;/li&gt;
&lt;li&gt;Weight: 一个无符号 8 为整数，代表当前流的优先级权重值 (1~256)，存在则代表 PRIORITY flag 被设置&lt;/li&gt;
&lt;li&gt;Header Block Fragment: header 块片段，header 依次打包排列在里面&lt;/li&gt;
&lt;li&gt;Padding: 同 DATA 帧&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;HEADERS 帧有以下标识 (flags):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;END_STREAM: bit 0 设为 1 代表当前请求 header 发送完了 (可能有 CONTINUATION 帧，可以认为是 HEADERS 的一部分)&lt;/li&gt;
&lt;li&gt;END_HEADERS: bit 2 设为 1 代表 header 块结束&lt;/li&gt;
&lt;li&gt;PADDED: bit 3 设为 1 代表 Pad 被设置，存在 Pad Length 和 Padding&lt;/li&gt;
&lt;li&gt;PRIORITY: bit 5 设为 1 表示存在 Exclusive Flag (E), Stream Dependency, 和 Weight&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;请求的 header 打包在 Header Block Fragment，我们重点关注一下，以便理解 header 是如何被传输的。&lt;/p&gt;

&lt;p&gt;由于上面头部压缩的内容，我们知道 header 可以存在于静态表、动态表中。此时只需要传一个 index 即可表达对应的 header，减少传输内容。请求传递的 header 情况有以下几种：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;header 的 key、value 在静态表/动态表中，&lt;strong&gt;此时只需要传递一个 index 即可&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;header 的 key 在静态、动态表中，而 value 由于多种多样，不在表中 (如 Host)，此时 key 可以由 index 表示，但 value 需要传递原内容&lt;/li&gt;
&lt;li&gt;header 的 key、value 完全不在静态、动态表中，key、value 都需要传递原内容 (字符串)&lt;/li&gt;
&lt;li&gt;希望将本次传递的 header 写入动态表中，下次只需要传 index 即可&lt;/li&gt;
&lt;li&gt;不希望本次传递的 header 写入动态表中&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Header Block Fragment 中打包 header 的方式也就是按照上面几种情况展开，具体篇幅较多，本文找一个复杂点的例子： &lt;strong&gt;key、value 都不在表中，且需要添加进表中&lt;/strong&gt;的情况进行举例：(更详细 HPACK 细节可见 [6]、[7])&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+---+---+---+---+---+---+---+---+
| 0 | 1 |           0           |           // 通过头8个bit表示是哪种case
+---+---+-----------------------+
| H |     Key Length (7+)      |
+---+---------------------------+
|  Key String (Length octets)  |
+---+---------------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;头 8 个 bit 中&lt;code&gt;01&lt;/code&gt; &lt;code&gt;000000&lt;/code&gt; 表达了两点

&lt;ol&gt;
&lt;li&gt;header 的 key 不在表中 (&lt;code&gt;000000&lt;/code&gt;)、value 也不在 (需要传文本内容)&lt;/li&gt;
&lt;li&gt;希望将此 header 追加到动态表中，供下次使用 (&lt;code&gt;01&lt;/code&gt;开头表示需要追加到表中)&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Value Length 代表对应 value 的长度，借此可读取完整的 Value String&lt;/li&gt;
&lt;li&gt;其余的情况都可以用头 8 个 bit 表示 [7]&lt;/li&gt;
&lt;li&gt;多个上面的结构前后拼接在一起，就可以在一个 HEADERS 帧中表示多个 header 了&lt;/li&gt;
&lt;li&gt;第二行 H 为 1 表示 value 用了霍夫曼编码 [9]，可以理解为一种文本压缩策略&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;上面的场景下，header 的内容包含 key 的 Index，value 的长度、value 的文本内容，其实可分为两种：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;数字的表达。key 的 Index、key/value 文本内容的长度 &lt;/li&gt;
&lt;li&gt;字符串的表达。key 的内容 (如 custom-key)、value 的内容 (custom-value) &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;对于 &lt;code&gt;custom-key: custom-header&lt;/code&gt;表达示例：(来源 [10])&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;编码数据的十六进制表示：
   400a 6375 7374 6f6d 2d6b 6579 0d63 7573 | @.custom-key.cus
   746f 6d2d 6865 6164 6572                | tom-header

解码过程：

   40                                      | == Literal indexed ==         （01000000表示要追加到表中）
   0a                                      |   Literal name (len = 10)     （得到key长度）
   6375 7374 6f6d 2d6b 6579                | custom-key         
   0d                                      |   Literal value (len = 13)    （得到value长度）
   6375 7374 6f6d 2d68 6561 6465 72        | custom-header                 （一个key:value 读取完毕）


解码结果可得header：     custom-key:custom-header         并将其加入动态表，下次直接只传index                  
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上图中有&lt;code&gt;Key Length (7+)&lt;/code&gt; 和 &lt;code&gt;Value Length (7+)&lt;/code&gt;，这是上面提到的&lt;code&gt;数字的表达&lt;/code&gt;，可以看到有&lt;code&gt;7+&lt;/code&gt;这个表示。这里面有一个扩展问题，如果 Value 的长度比较大，7 个 bit 表示不了咋办。&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| ? | ? | ? | 1   1   1   1   1 |       第一个字节  N = 5
+---+---+---+-------------------+
| 1 |    Value-(2^N-1) LSB      |
+---+---------------------------+
               ...
+---+---------------------------+
| 0 |    Value-(2^N-1) MSB      |
+---+---------------------------+
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当长度 len 比较小，len &amp;lt; 2^N - 1, 则直接用第一个字节即可表达。（N &amp;lt;= 7）。如果 len &amp;gt;= 2^N - 1，则需要用后续的字节继续表达。规则是：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;选择一个 N，如上面 N=5，将第一个字节的后 N 位全部设为 1，则第一个字节表达了 2^N - 1，剩下的 len - (2^N - 1) 用后面的字节表示。&lt;/li&gt;
&lt;li&gt;将 len - (2^N - 1) 用二进制表示出来，将二进制位分别分给下面的字节&lt;/li&gt;
&lt;li&gt;只占用后面字节的后 7 位&lt;/li&gt;
&lt;li&gt;如果第一位为 0，则表示表达完毕，为 1 则表示下一个字节还在继续表示 len&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;示例： (来源 [10])&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;表达长度为：1337，设 N = 5&lt;/li&gt;
&lt;li&gt;1337 大于 31（2^5-1），并使用 5 位前缀表示。5 位前缀使用其最大值（31）填充&lt;/li&gt;
&lt;li&gt;除第一个字节外，后面字节表达 1337 - 31 = 1306&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;1036 二进制串为：010100011010，用多个字节表达&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
 0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| X | X | X | 1 | 1 | 1 | 1 | 1 |  第一个字节表达了 2^N - 1 = 31, 下面的字节表达 1337 - 31 = 1306
| 1 | 0 | 0 | 1 | 1 | 0 | 1 | 0 |   后面一截： 0011010   （低位）
| 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |   前面一截： 01010      （高位）
+---+---+---+---+---+---+---+---+
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="gRPC 请求抓包"&gt;gRPC 请求抓包&lt;/h2&gt;
&lt;p&gt;上文已经搞清楚了 HTTP2 的传输原理，接下来通过 wireshark 透视一下 gRPC 调用的过程。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;请求内容：
&lt;img src="https://l.ruby-china.com/photo/early/94ecfda7-9884-4272-a953-c9e0718af72f.png!large" title="" alt="请求"&gt;&lt;/p&gt;

&lt;p&gt;返回：
&lt;img src="https://l.ruby-china.com/photo/early/2bc02274-4057-4887-b09f-099fbf989d27.png!large" title="" alt="返回"&gt;&lt;/p&gt;

&lt;p&gt;帧示例：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/8f3ad4ea-649b-4b57-a8a3-89ad608b2cee.png!large" title="" alt="header帧"&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;先约定配置，SETTINGS 帧有 ACK 表达确认&lt;/li&gt;
&lt;li&gt;请求的 Method 在 header 中传递&lt;/li&gt;
&lt;li&gt;参数用 DATA 帧&lt;/li&gt;
&lt;li&gt;返回状态用 HEADER 帧&lt;/li&gt;
&lt;li&gt;返回数据用 DATA 帧&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;可见调用语义和 HTTP 并无差别，但通过协议优化，在很大程度上降低了传输的体积，节省资源的同时，也较好地提升了性能。&lt;/p&gt;

&lt;p&gt;看了单个请求的抓包样例，我们得再看看 gRPC 的 stream 是什么鬼，代码约定如下：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// proto
service XXX {
    rpc StreamTest(stream StreamTestReq) returns (stream StreamTestResp);
}
message StreamTestReq {
    int64 i = 1;
}
message StreamTestResp {
    int64 j = 1;
}
// server端代码
func (s *XXXService) StreamTest(re v1pb.XXX_StreamTestServer ) (err error) {
    for {
        data, err := re.Recv()
        if err != nil {
            break
        }
             // 将客户端发送来的值乘以10再返回给它
        err = re.Send(&amp;amp;v1pb.StreamTestResp{J: data.I * 10 }) 
    }
    return
}
// client 端代码
func TestStream(t *testing.T) {
    c, _ := service2.daClient.StreamTest(context.TODO())
    go func(){
        for {
            rec, err := c.Recv()
            if err != nil {
                break
            }
            fmt.Printf("resp: %v\n", rec.J)
        }
    }()
    for _, x := range []int64{1,2,3,4,5,6,7,8,9}{
        _ = c.Send(&amp;amp;dav1.StreamTestReq{I: x})
        time.Sleep(100*time.Millisecond)
    }
    _ = c.CloseSend()
}
// client端输出结果
resp: 10
resp: 20
resp: 30
resp: 40
resp: 50
resp: 60
resp: 70
resp: 80
resp: 90
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;上面是一个双向 stream 流&lt;/li&gt;
&lt;li&gt;client 和 server 端同时在收发数据&lt;/li&gt;
&lt;li&gt;client 连续发送 9 次后，中断过程。常规的流式服务，如视频编解码，可以一直持续直到结束&lt;/li&gt;
&lt;li&gt;服务端将 client 的参数*10 后返回&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;我们不禁要问，这种流式请求和常规的 gRPC 有没有区别？这从抓包便可知分晓：
&lt;img src="https://l.ruby-china.com/photo/early/b9a8bffb-9940-43fe-b21b-12435f6a0784.png!large" title="" alt="stream"&gt;&lt;/p&gt;

&lt;p&gt;上面只提取了 http2 和 grpc 的协议内容，否则会被 tcp 的 ack 打乱视野，可以从图上看到：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;请求的 method 只发送了一次&lt;/li&gt;
&lt;li&gt;服务端的回复 header 也只返回了一次 (200 OK 那行)&lt;/li&gt;
&lt;li&gt;剩下的就是：client 的 data 帧和 server 端的 data 帧交替&lt;/li&gt;
&lt;li&gt;其实全场就只有一次请求 (stream ID 未变化)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;stream 模式，其实就是 gRPC 从协议层支持了，在一次长请求中，分批地处理小量数据，达到多次请求的效果，像流水一样可以延绵不绝，直到某一方终止。&lt;/p&gt;

&lt;p&gt;试想下，如果 gRPC 内部不支持这种模式，其实也能自己实现流式的服务，只不过在形式上要多调用几次接口而已。从上面抓包来看，这种封装在无论在性能和语义上都更好。&lt;/p&gt;
&lt;h2 id="进一步提升"&gt;进一步提升&lt;/h2&gt;
&lt;p&gt;参见 HTTP3，抛弃 TCP 协议，拥抱 QUIC。&lt;/p&gt;
&lt;h2 id="参考资料"&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[1] &lt;a href="https://juejin.im/post/5b88a4f56fb9a01a0b31a67e" rel="nofollow" target="_blank" title=""&gt;https://juejin.im/post/5b88a4f56fb9a01a0b31a67e&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[2] &lt;a href="https://blog.csdn.net/u010129119/article/details/79392545" rel="nofollow" target="_blank" title=""&gt;https://blog.csdn.net/u010129119/article/details/79392545&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[3] &lt;a href="https://tools.ietf.org/html/rfc7541#section-2.3.1" rel="nofollow" target="_blank" title=""&gt;https://tools.ietf.org/html/rfc7541#section-2.3.1&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[4] &lt;a href="https://www.rabbitmq.com/tutorials/amqp-concepts.html" rel="nofollow" target="_blank" title=""&gt;https://www.rabbitmq.com/tutorials/amqp-concepts.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[5]&lt;a href="https://zhuanlan.zhihu.com/p/34662800" rel="nofollow" target="_blank" title=""&gt;https://zhuanlan.zhihu.com/p/34662800&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[6] &lt;a href="https://http2.github.io/http2-spec/compression.html#index.address.space" rel="nofollow" target="_blank" title=""&gt;https://http2.github.io/http2-spec/compression.html#index.address.space&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[7] &lt;a href="https://github.com/halfrost/Halfrost-Field/blob/master/contents/Protocol/HTTP:2_Header-Compression.md" rel="nofollow" target="_blank" title=""&gt;https://github.com/halfrost/Halfrost-Field/blob/master/contents/Protocol/HTTP:2_Header-Compression.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[8] &lt;a href="https://zhuanlan.zhihu.com/p/149821222" rel="nofollow" target="_blank" title=""&gt;https://zhuanlan.zhihu.com/p/149821222&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[9]&lt;a href="https://zh.wikipedia.org/zh-hans/%E9%9C%8D%E5%A4%AB%E6%9B%BC%E7%BC%96%E7%A0%81" rel="nofollow" target="_blank" title=""&gt;https://zh.wikipedia.org/zh-hans/%E9%9C%8D%E5%A4%AB%E6%9B%BC%E7%BC%96%E7%A0%81&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[10] &lt;a href="https://github.com/halfrost/Halfrost-Field/blob/master/contents/Protocol/HTTP:2_HPACK-Example.md#1-%E6%95%B4%E6%95%B0%E8%A1%A8%E7%A4%BA%E7%9A%84%E7%A4%BA%E4%BE%8B" rel="nofollow" target="_blank" title=""&gt;https://github.com/halfrost/Halfrost-Field/blob/master/contents/Protocol/HTTP:2_HPACK-Example.md#1-%E6%95%B4%E6%95%B0%E8%A1%A8%E7%A4%BA%E7%9A%84%E7%A4%BA%E4%BE%8B&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>early</author>
      <pubDate>Sun, 17 Jan 2021 14:23:50 +0800</pubDate>
      <link>https://ruby-china.org/topics/40822</link>
      <guid>https://ruby-china.org/topics/40822</guid>
    </item>
    <item>
      <title>gRPC 系列 (二)   如何用 Protobuf 组织内容</title>
      <description>&lt;p&gt;本系列分为四大部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/148139089" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (一)   什么是 RPC？&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/149821222" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (二)   如何用 Protobuf 组织内容&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/161577635" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (三)   如何借助 HTTP2 实现传输&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/344914169" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (四)   框架如何赋能分布式系统&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="回顾"&gt;回顾&lt;/h2&gt;
&lt;p&gt;在系列 (一) 中，我们从全局鸟瞰了 RPC，其有三大特点：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;具有需要约定调用语法&lt;/li&gt;
&lt;li&gt;需要约定内容编码方式&lt;/li&gt;
&lt;li&gt;需要网络传输&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;所有 RPC 框架都是在围绕这几个点不断优化，以更优的方案，达到更低的成本，更快的速度。要想达到这个目的，&lt;code&gt;内容编码方式&lt;/code&gt;就是一个非常重要的点，RPC 调用的 request 和 response 内容在调用过程中有着不小的消耗：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;内容的序列化、反序列化，如果效率更高，则对 CPU 消耗会更小&lt;/li&gt;
&lt;li&gt;内容会在网络中传输，协议栈拷贝成本、带宽成本、GC 等。体积越小，效率越高&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这是本文讨论的重点。&lt;/p&gt;
&lt;h2 id="目标是什么"&gt;目标是什么&lt;/h2&gt;
&lt;p&gt;一般的低流量场景，无须多考虑这些，因为远不会打满 cpu 或触及带宽上限。但是在高流量的环境下，这个点就会变得非常致命，一波 10W 的 qps 可能会将带宽瞬间打满，然后直接堵住，cpu 的消耗也需要更多的机器，这些都会直接拉高接口耗时。&lt;/p&gt;

&lt;p&gt;背后都是高成本及稳定性的损失。&lt;/p&gt;

&lt;p&gt;更常见的例子是，很多业务会将结果缓存起来到 Redis，避免查 DB，而有时结果集会很大，我目前听说过的最大 value 有 500M。缓存数据存放时都需要序列化，常规的方式是 json，但 json 序列化后的体积很大，对于大 key 是万万不行的，一波并发读取，Redis 分分种 CPU、带宽就吃紧了，此时就需要有一个更高效的序列化策略，使得 value 尽量小。&lt;/p&gt;

&lt;p&gt;在这方面，RPC 框架都有以下几个目标：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;尽可能快地序列化、反序列化&lt;/li&gt;
&lt;li&gt;序列化后的体积越小越好&lt;/li&gt;
&lt;li&gt;跨语言，和语言无关&lt;/li&gt;
&lt;li&gt;简单、类型明确&lt;/li&gt;
&lt;li&gt;易扩展，可以简单的迭代，向后兼容&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="解决方案"&gt;解决方案&lt;/h2&gt;
&lt;p&gt;gRPC 对此的解决方案是丢弃 json、xml 这种传统策略，使用 Protocol Buffers[1]，是 Google 开发的一种跨语言、跨平台、可扩展的用于序列化数据协议。&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// XXXX.proto
service Test {
    rpc HowRpcDefine (Request) returns (Response) ; // 定义一个RPC方法
}
message Request {
    //类型 | 字段名字|  标号
    int64    user_id  = 1;
    string   name     = 2;
}
message Response {
    repeated int64 ids = 1; // repeated 表示数组
    Value info = 2;         // 可嵌套对象
    map&amp;lt;int, Value&amp;gt; values = 3;    // 可输出map映射
}
message Value {
    bool is_man = 1;
    int age = 2;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;以上是一个使用样例，包含方法定义、入参、出参。可以看出有几个明确的特点：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;有明确的类型，支持的类型有多种&lt;/li&gt;
&lt;li&gt;每个 field 会有名字&lt;/li&gt;
&lt;li&gt;每个 field 有一个&lt;strong&gt;数字标号&lt;/strong&gt;，一般按顺序排列 (下文编解码会用到这个点)&lt;/li&gt;
&lt;li&gt;能表达数组、map 映射等类型&lt;/li&gt;
&lt;li&gt;通过嵌套 message 可以表达复杂的对象&lt;/li&gt;
&lt;li&gt;方法、参数的定义落到一个.proto 文件中，&lt;strong&gt;依赖双方需要同时持有这个文件，并依此进行编解码&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这可以满足 RPC 调用的需求，具体的使用语法此处不做赘述，详情可参考文档 [2]。&lt;/p&gt;

&lt;p&gt;作为一个以跨语言为目标的序列化方案，protobuf 能做到一份.proto 文件走天下，不管什么语言，都能以同一份 proto 文件作为约定，不用 A 语言写一份，B 语言写一份，各个依赖的服务将 proto 文件原样拷贝一份即可。&lt;/p&gt;

&lt;p&gt;但.proto 文件并不是代码，不能执行，要想直接跨语言是不行的，必须得有对应语言的中间代码才行，中间代码要有以下能力：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;将 message 转成对象，例如 golang 里是 struct，Ruby 里是 class，需要各自表达后，才能被理解&lt;/li&gt;
&lt;li&gt;需要有进行编解码的代码，能解码内容为自己语言的对象、能将对象编码为对应的数据&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;由于 message 是自己定义的，而且有特定的类型等，一套通用的编解码代码是不行的 (类似 json)，特定的 proto 需要对应的方法，对 message 编解码，不同的 message 编解码策略还不一样。&lt;/p&gt;

&lt;p&gt;这些代码用手写是不行的，protobuf 对此的解决方案是，提供一个统一的 protoc 工具，这个一个 C++”翻译“工具，可以通过 proto 文件，生成某特定语言的中间代码，实现上面说的两个能力。也就是说，protobuf 通过自动化编译器的方式统一提供了这种能力，避免人肉写。&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;//       依赖目录      生成golang中间代码   对应proto文件地址
protoc -I=$SRC_DIR --go_out=$DST_DIR  $SRC_DIR/XXX.proto
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/XXX.proto // 生成java中间代码
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行结果是对应语言的中间代码，以 golang 为例，会生成一个 xx.pb.go 文件，里面就是对应 rpc、message 的结构体，以及编解码的 function。[3]&lt;/p&gt;

&lt;p&gt;由于每个 field 有标号，当 proto 文件新增字段、message、rpc 时也能自然向后兼容，这涉及编解码的策略，下文会详细讨论。&lt;/p&gt;
&lt;h2 id="直观对比"&gt;直观对比&lt;/h2&gt;
&lt;p&gt;为什么选择 protobuf，而不是普及最广的 json 作为编码方案？可以做一个直观对比，以上文 proto 中的 Response 为例，一次输出 json 的结果是：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"{\"ids\":[123,456],\"info\":{\"is_man\":true,\"age\":20},\"values\":{\"110\":{\"is_man\":false,\"age\":18}}}"
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所有内容被打包成了一个字符串，里面包含字段名、value，当 Reponse 很大时，体积消耗很大，浪费主要在三个方面：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;字段名，例如上面的“ids”、“info”等，如果 json 体大，则重复会更多&lt;/li&gt;
&lt;li&gt;数字用字符串表达了，例如 123 数字变成了“123”，这在编码后体积由一个字节变成三字节&lt;/li&gt;
&lt;li&gt;类型字符，如 [  、 ]、{ 、}&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;但如果是 protobuf 呢？输出是一段人眼无法理解的二进制串，里面：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;去掉了字段名，转而以字段标号替代，通过标号可以在 proto 中找到字段名&lt;/li&gt;
&lt;li&gt;没有类型字符等&lt;/li&gt;
&lt;li&gt;用二进制表达内容，不会将数字转成字符串&lt;/li&gt;
&lt;li&gt;字段值按顺序依次排列&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这使得 protobuf 的编码结果体积，通常是 json 编码后的十分之一以下。同时由于排列简单，其解析算法的时空复杂度远小于 json，对 cpu 消耗也小很多。这使得 protobuf 在大数据量、高频率的数据交互场景下，远胜于 json，被大规模分布式 RPC 场景广泛使用。&lt;/p&gt;
&lt;h2 id="编解码详解"&gt;编解码详解&lt;/h2&gt;
&lt;p&gt;为什么它能有这个好的压缩效果？我们先从编码的角度来思考，如何对一个对象进行编解码。以 json 编码为例，当遇到下一个字段用&lt;code&gt;,&lt;/code&gt;隔开就行，遇到下一层级用&lt;code&gt;{&lt;/code&gt;表示，这样可以将内容依次铺开成一个完整的字符串。解析时按照&lt;code&gt;{ , }&lt;/code&gt;等字符也能原样还原字段和层次结构。&lt;/p&gt;

&lt;p&gt;但 protobuf 为了减小体积不能使用这些分隔符，抛几个问题：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;它该怎么&lt;code&gt;分隔字段&lt;/code&gt;、&lt;code&gt;表达层次结构&lt;/code&gt;呢？&lt;/li&gt;
&lt;li&gt;字段 value 一般分为两种，一种是定长的，例如一个 int，它最多 4 个字节；第二种是变长的，如字符串，你不知道它在哪儿结束。该如何表示？&lt;/li&gt;
&lt;li&gt;对于定长的 int，如果对应值是 1，那用 4 个字节表达是不是有些浪费，该如何节省？&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;对于此，protobuf 将数据类型做了&lt;strong&gt;分类 (Wire Type)&lt;/strong&gt;，并提供不同的编解码方式：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/18b0c820-e4d4-4fb3-a831-8cc7fa540444.png!large" title="" alt="来源于[4]"&gt;&lt;/p&gt;

&lt;p&gt;值得关注的有两种： &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Varint，解决定长类型的空间浪费，例如值为 1 的 int32 只用 1 字节，避免用四字节，达到压缩的效果。T - V&lt;/li&gt;
&lt;li&gt;Length-delimi，用来表达长度不定的内容，如 string、嵌套数据、数组。T - L - V
## T - V
T - V 的含义是：&lt;/li&gt;
&lt;li&gt;T：tag，包含两部分数据：对应字段的 Wire Type(这可以知道是那种分类)，字段的&lt;strong&gt;数字标号&lt;/strong&gt;(tagNum)(可以在 proto 中找到是哪个字段，&lt;strong&gt;这样就避开了传字段名&lt;/strong&gt;)。其打包方式是： &lt;strong&gt;(tagNum&amp;lt;&amp;lt;3) | WireType&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;如果在 proto 中没有找到对应的 tagNum 则会跳过，这样提供了兼容能力&lt;/li&gt;
&lt;li&gt;V：value, 对应字段的值，解析了 T，就知道 value 表达的是哪个字段、什么类型、如何解析了&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;protobuf 编码的结果就是一组组  &lt;code&gt;T-V&lt;/code&gt;对依次紧凑排列，message 有几个字段，就有几对。对于特定的 RPC 请求，proto 中是有明确的请求、回复 message 定义的，将 T-V 对去套对应的 message，即可解析出对象。&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/1a9caf33-0453-4fb2-9c3d-eb68fcecad1b.png!large" title="" alt="来源于[4]"&gt;&lt;/p&gt;

&lt;p&gt;紧接着上文预留的一个问题不可跳过，紧凑排列的 T-V 对，是如何进行分隔的？：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;如何分隔 T 和 V，该从哪个解析 V？&lt;/li&gt;
&lt;li&gt;如何分隔 T-V 对，该从哪儿开始解析下一对？&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;T - V 对是一堆紧凑排列二进制串，里面没有分隔符，其解决方案是：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;征用了每个字节的最高位，如果最高位是 1，说明数据没解析完，下个字节还要继续解析，如果字节高位是 0，说明当前 T 或 V 解析完了，下一个字节开始是其他的 T 或 V&lt;/li&gt;
&lt;li&gt;类小端排列，高位在右，低位左&lt;/li&gt;
&lt;li&gt;小于 128 的数字 都可以用 1 个字节 表示（用 8 个 bit 表达 7 个 bit，一 bit 当作标志位）&lt;/li&gt;
&lt;li&gt;大于 128 的数字，比如 300(00000001 00101100)，会用两个字节来表示：10101100 00000010&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;T - V 举例：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;message request {
    int63 user_id = 1; // tagNum = 1, wireType = 0, 
}

假设 value为 2, 则编码出的T-V为： 
+-----+---+-----------------+
|00001|000|00000010|
+-----+---+-----------------+
tagNum type   data


假设 value为 300, 则编码出的T-V为： 
 第一个字节    第二       第三
+-----+---+-----------------------+
|00001|000| 10101100  00000010| 下个T-V
+-----+---+-----------------------+
tagNum type     data

Tag高位=0： 一个byte
data的第一个字节最高位为1，说明下一个字节还要继续读
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;图解：
&lt;img src="https://l.ruby-china.com/photo/early/fe9a2221-66c4-4ac9-891f-4b5fb61788be.jpg!large" title="" alt=""&gt;&lt;/p&gt;
&lt;h2 id="T - L - V"&gt;T - L - V&lt;/h2&gt;
&lt;p&gt;T - L - V 就是在上面的基础上增加了 length，用来表达变长的内容：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/6d3b0331-a9a4-4fd2-aa0b-fec417286449.png!large" title="" alt="来源于[4]"&gt;&lt;/p&gt;

&lt;p&gt;由于是变长，例如数组、嵌套对象，有多个 value，此时就无法通过最高位是否是 1，来表示该字段是否解析完毕，必须要在 value 前增加一个 length，其他都和 T-V 一样。&lt;/p&gt;

&lt;p&gt;接下来我们学习两个点：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;数组如何表达&lt;/li&gt;
&lt;li&gt;嵌套对象如何表达&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;数组的表达其实比较简单，就是同一个 T 不断的重复 (tagNum 和 wireType 不变)，解析对应的 V 就行，然后组成一个数组：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/7b9cffeb-12b9-492a-9d01-2649b968c9ec.png!large" title="" alt="来源于[4]"&gt;&lt;/p&gt;

&lt;p&gt;嵌套对象稍微复杂点，每个 value 都能找到一个 message 去套，逐层解就行了：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/62067f93-9388-4235-a249-654b591bedab.png!large" title="" alt="来源于[4]"&gt;&lt;/p&gt;

&lt;p&gt;嵌套对象举例：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;message request {
    User user = 1; // tagNum = 1, wireType = 2, 
}
message User {
    int64 user_id = 1; // tagNum = 1
}

假设 request = { user_id: 2}, 则编码出的T-L-V为： 
    Tag     length        value
                       Tag      value
+---------+--------+---------+---------
|00001010 |00000010|000010000|00000010|
 1&amp;lt;&amp;lt;3 | 2   2 byte   1&amp;lt;&amp;lt;3 | 0    2

通过解request 知道第一个字段是User，再拿到第一个字段的value去解User，
知道User第一个字段是int64，解析出data为2。 一个嵌套对象即解析完毕
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="优缺点对比"&gt;优缺点对比&lt;/h2&gt;
&lt;p&gt;全文到此，基本解释清楚了 protobuf 如何编解码，以及为什么压缩率会比 json 高，可以看出其优点有：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;没有打包无用的数据，排列紧凑，体积小，利于传输&lt;/li&gt;
&lt;li&gt;解析策略简单，序列化/反序列化 速度快&lt;/li&gt;
&lt;li&gt;能较好的兼容字段 (不能解析到的会跳过)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;但缺点也非常明显：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;肉眼看不出来 value 是什么，无法自描述，难以 debug&lt;/li&gt;
&lt;li&gt;需要 proto 文件才能知道如何解析，否则是天书，这在灵活性上不如 json&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;其实在本质上，json 的设计是给人看的，protobuf 则是利于机器，适用场景不同，各有利弊。作为工具，讨论其快、好、差没有意义，在合适的地方，用合适的工具即可。&lt;/p&gt;
&lt;h2 id="参考资料"&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[1] &lt;a href="https://ivanzz1001.github.io/records/post/cplusplus/2017/11/29/cplusplus_protobuf" rel="nofollow" target="_blank" title=""&gt;https://ivanzz1001.github.io/records/post/cplusplus/2017/11/29/cplusplus_protobuf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[2] &lt;a href="https://www.jianshu.com/p/bdd94a32fbd1" rel="nofollow" target="_blank" title=""&gt;https://www.jianshu.com/p/bdd94a32fbd1&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[3] &lt;a href="https://developers.google.com/protocol-buffers/docs/gotutorial" rel="nofollow" target="_blank" title=""&gt;https://developers.google.com/protocol-buffers/docs/gotutorial&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[4] &lt;a href="https://blog.csdn.net/carson_ho/article/details/70568606" rel="nofollow" target="_blank" title=""&gt;https://blog.csdn.net/carson_ho/article/details/70568606&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[5] &lt;a href="https://zhuanlan.zhihu.com/p/73549334" rel="nofollow" target="_blank" title=""&gt;https://zhuanlan.zhihu.com/p/73549334&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>early</author>
      <pubDate>Sun, 17 Jan 2021 14:19:18 +0800</pubDate>
      <link>https://ruby-china.org/topics/40821</link>
      <guid>https://ruby-china.org/topics/40821</guid>
    </item>
    <item>
      <title>gRPC 系列 (一)   什么是 RPC？</title>
      <description>&lt;p&gt;本系列分为四大部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/148139089" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (一)   什么是 RPC？&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/149821222" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (二)   如何用 Protobuf 组织内容&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/161577635" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (三)   如何借助 HTTP2 实现传输&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zhuanlan.zhihu.com/p/344914169" rel="nofollow" target="_blank" title=""&gt;gRPC 系列 (四)   框架如何赋能分布式系统&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="初步印象"&gt;初步印象&lt;/h2&gt;
&lt;p&gt;RPC 的语义是远程过程调用，在一般的印象中，就是将一个服务调用封装在一个本地方法中，让调用者像使用本地方法一样调用服务，对其屏蔽实现细节。而具体的实现是通过调用方和服务方的一套约定，基于 TCP 长连接进行数据交互达成。&lt;/p&gt;

&lt;p&gt;上面的解释似云里雾里，仅仅了解到这种程度是远远不够的，还需要更进一步，以相对&lt;strong&gt;底层&lt;/strong&gt;和&lt;strong&gt;抽象&lt;/strong&gt;的视角来理解 RPC。&lt;/p&gt;
&lt;h2 id="三个特点"&gt;三个特点&lt;/h2&gt;
&lt;p&gt;广义上来讲，所有本应用程序外的调用都可以归类为 RPC，不管是分布式服务，第三方服务的 HTTP 接口，还是读写 Redis 的一次请求。从抽象的角度来讲，它们都一样是 RPC，由于不在本地执行，都有三个特点：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;需要事先约定调用的语义 (接口语法)&lt;/li&gt;
&lt;li&gt;需要网络传输&lt;/li&gt;
&lt;li&gt;需要约定网络传输中的内容格式&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;以一次 Redis 调用为例，执行&lt;code&gt;redis.set("rpc", 1)&lt;/code&gt;这个调用，其中：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;set&lt;/code&gt;及其参数&lt;code&gt;("rpc", 1)&lt;/code&gt;，就是对&lt;code&gt;调用语义&lt;/code&gt;的约定，由 redis 的 API 给出&lt;/li&gt;
&lt;li&gt;RedisServer 会监听一个服务端口，通过 TCP 传输内容，用异步事件驱动实现高并发&lt;/li&gt;
&lt;li&gt;底层库会约定数据如何进行编解码，如何标识命令和参数，如何表示结果，如何表示数据的结尾等等&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这三个特点都是因为&lt;code&gt;调用不在本地&lt;/code&gt;而不得不衍生出来的问题，也因此决定了 RPC 的形态。所有的 RPC 解决方案都是在解决这三个问题，不断地在提出更加优良的解决方案，试图达到更好的性能，更低的使用成本。本文也将围绕这三个特点来展开内容。&lt;/p&gt;

&lt;p&gt;常规的 RPC 一般都是基于一个大的内部服务，进行分布式拆分，由于其语义上以本地方法的作为入口，那么天然的就更倾向于具备高性能、支持复杂参数和返回值、跨语言等特性。下图是 RPC 调用的过程示意图：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/early/bab98504-2f60-4ecc-917d-165ed66b66c1.png!large" title="" alt=""&gt;&lt;/p&gt;
&lt;h2 id="内容组织约定"&gt;内容组织约定&lt;/h2&gt;
&lt;p&gt;Stub 会负责封装命令和参数，并以特定的数据格式进行打包。其中命令、参数和返回值的需要客户端和服务端的 Stub 事先进行协商，双方都需要维护一份完全一样的方法及参数列表。更进一步需要知道对方如何进行压缩打包，如何压缩结构体，如何压缩 Class 等等，并严格按照标准进行解压缩，中途有任何一丝的差错都会的导致调用失败。所以一般情况下可能会对数据进行一定的校验，同时要协商方法、参数等错误时如何返回。这是一个比较繁杂的过程，混合了&lt;code&gt;调用语法&lt;/code&gt;和 &lt;code&gt;内容解压缩&lt;/code&gt;两部分内容，可被理解为&lt;code&gt;如何组织内容&lt;/code&gt;的问题。&lt;/p&gt;
&lt;h2 id="网络传输"&gt;网络传输&lt;/h2&gt;
&lt;p&gt;搞定了协议约定问题后，接下来就是要通过 Runtime 进行内容传输了，这又是一大难题，一般是需要通过 Socket 编程来实现，使用 TCP 或 UDP 来进行传输，如果是 UDP 可以用数据报来区分每一次请求和回复。但如果是字节流的 TCP，就需要用特殊的方式来标示请求或回复的末尾，用来区分不同的请求。同时当对调用性能有要求时，可能会使用 Socket 的异步编程模型，消除等待中的消耗，这会引入事件机制，通过状态机来解析处理或回复请求。当出现超时、丢包等情况时还进行做重试、重传、报错等等。&lt;/p&gt;

&lt;p&gt;拆解到协议约定和网络传输时，就会发现实现 RPC 调用是一件非常复杂的事情，自己实现千难万难，接下来就了解一番已有的，针对协议约定和网络传输的解决方案。&lt;/p&gt;

&lt;p&gt;当然，在技术高度成熟的今天，已经又很多先烈将传输问题解决掉了，接下来就介绍几款常见的案例组合。&lt;/p&gt;
&lt;h4 id="ONC RPC"&gt;ONC RPC&lt;/h4&gt;
&lt;p&gt;ONC RPC 是相对早期的 RPC 解决方案，通过&lt;code&gt;外部数据表示法&lt;/code&gt;来约定数据的压缩方式：
&lt;img src="https://l.ruby-china.com/photo/early/ce269ad5-d8b4-4aec-b21e-1b1230d1aa9b.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;被传输的所有内容都需要通过上面的约定进行压缩，这样接收方就能顺利地按照同样的协议进行解压缩。 &lt;/p&gt;

&lt;p&gt;对于命令和参数列表的约定，会创建一份公共的协议文件，里面会定义被调用的方法名，参数列表，对象的列表等等。然后用特定工具将文件进行解析生成 Stub 程序，客户端和服务端都同时将 Stub 程序放在代码中。比如对方法名进行编号，将&lt;code&gt;GetUserName(userId)&lt;/code&gt;这个方法编号为 1，在调用时就将 1 传输给服务端，服务端通过协议文件就知道调用的方法，这样节省了大量的空间。
&lt;img src="https://l.ruby-china.com/photo/early/684a57f6-3b54-4dd7-995b-74ac78ed4a1a.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;传输则通过对应的类库实现，通过 Socket 编程实现的非常复杂的解决方案，包含了超时、失败、异常处理、状态转换等等功能。&lt;/p&gt;

&lt;p&gt;这种早期的方案，在每一次代码更新时都需要重新生成 Stub 程序，调用方和服务方都需要及时更新对应的文件。给某一个方法增加一个默认参数，都需要全部使用者同步升级，从迭代或多版本的场景看来，这是一场噩梦。&lt;/p&gt;
&lt;h4 id="RESTfull HTTP JSON"&gt;RESTfull HTTP JSON&lt;/h4&gt;
&lt;p&gt;RESTfull 是一种资源状态转换的架构风格，也可以用来实现 RPC，互联网对 HTTP 超广泛的支持，使得这相当简单，也是大多数情况下的首选。&lt;/p&gt;

&lt;p&gt;通过 HTTP 协议来进行内容传输，Header 用来约定编码、body 大小等，彼此以&lt;code&gt;\r\n&lt;/code&gt;来分割，Header 和 body 之间通过两个连续的&lt;code&gt;\r\n&lt;/code&gt;来间隔，能很容易地区分不同的请求。&lt;/p&gt;

&lt;p&gt;通过 Url 和对应参数来标示要调用的方法和参数。在 body 中用 JSON 对内容进行编码，极易跨语言，不需要约定特定的复杂编码格式和 Stub 文件。在版本兼容性上非常友好，扩展也很容易。&lt;/p&gt;

&lt;p&gt;众多的优点使得这种方案广受欢迎。不过也有其无法避开的弱点：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP 的 header 和 Json 的数据冗余和低压缩率使得传输性能差&lt;/li&gt;
&lt;li&gt;JSON 难以表达复杂的参数类型，如结构体等&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="gRPC HTTP2.0 Protobuf"&gt;gRPC HTTP2.0 Protobuf&lt;/h4&gt;
&lt;p&gt;gRPC 是一款 RPC 框架，也是本系列的主角，在性能和版本兼容上做了提升和让步：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Protobuf 进行数据编码，提高数据压缩率&lt;/li&gt;
&lt;li&gt;使用 HTTP2.0 弥补了 HTTP1.1 的不足&lt;/li&gt;
&lt;li&gt;同样在调用方和服务方使用协议约定文件，提供参数可选，为版本兼容留下缓冲空间&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/protocolbuffers/protobuf" rel="nofollow" target="_blank" title=""&gt;protobuf&lt;/a&gt;是一款用 C++ 开发的跨语言、二进制编码的数据序列化协议，以超高的压缩率著称。它和早期的 RPC 方案一样，需要双方维护一个协议约束文件，以.proto 结尾，使用 proto 命令对文件进行解析，会生成对应的 Stub 程序，客户端和服务端都需要保存这份 Stub 程序用来进行编解码。对于这种协议文件导致的升级困难问题，protobuf 3 中定义的字段默认都是可选的 (可以不传)，在接口升级时，部分客户端不需要升级自己的 Stub 程序。&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ***.proto文件
syntax = "proto3";
package id_rpc;
message BusinessType {// 定义参数
  string name = 1; //参数字段
}

message UniqueId {// 定义返回值
  uint64 id = 1;
  string business_type = 2;
}

service UniqueIdService {// 定义服务，可以调用 MakeUniqueId 方法
  rpc MakeUniqueId(BusinessType) returns (UniqueId){}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对于 JSON 等文本形式的序列化协议来说，protobuf 能有几十倍空间和性能提升，比如传输&lt;code&gt;123&lt;/code&gt;，文本类的需要 3 个字节 (ascii 31 32 33) 来传输，而二进制类只需要一个字节 (01111011) 就可以表示。&lt;/p&gt;

&lt;p&gt;同时 protobuf 会维护.proto 文件，这样在解析文件生成 Stub 程序时，可以对方法名等进行编号，传输时只传编号，而不用传方法的名字，这又可以节省大量字节，还有其他更多的精巧压缩方法，比如 TLV，详情可以参考&lt;a href="https://developers.google.com/protocol-buffers/docs/encoding" rel="nofollow" target="_blank" title=""&gt;proto encoding&lt;/a&gt; 。&lt;/p&gt;

&lt;p&gt;解决了数据体积的问题后，gRPC 使用 HTTP2 来改善传输性能。HTTP2 是在 HTTP1.1 的基础上做了大量的改进，HTTP1.1 虽然引入了 KeepAlive 复用 TCP 连接，但仍然有很多问题：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;使用 KeepAlive 的请求是串行执行 (非 pipeline 时)，pipeline 时有队首阻塞问题&lt;/li&gt;
&lt;li&gt;每次都需要发送不必要的 Header&lt;/li&gt;
&lt;li&gt;不能双向通信&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;简单补充一下 pipeline，HTTP1.1 中允许多个请求复用连接，同时可以一口气将请求全部发出去，不用一个返回后再发送第二个，提升并发性。而服务端需要将请求的结果，按照 pipeline 中发送的顺序进行顺序返回，如果靠前的请求阻塞了，那么靠后请求返回就会被动等待。&lt;/p&gt;

&lt;p&gt;HTTP2 解决了这些问题，引入了新的机制：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;在两端建立 Header 索引表，每次只发送索引，减小 header 体积&lt;/li&gt;
&lt;li&gt;建立虚拟通道，将数据拆分成多个流，每个流有自己的 ID 和优先级，并且流可以双向传输，每个流可以进一步拆成多个帧。可以将多个请求切成不同的流发送，每个流可以独立返回，避开 1.1 的串行或队首阻塞问题。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;同时，基于 HTTP2 的数据流机制，gRPC 客户端和服务端可以实现批量操作优化，客户端可以攒一些请求，一口气发给服务端，服务端也可以批量返回结果，借此实现流式 rpc。&lt;/p&gt;
&lt;h3 id="RabbitMQ"&gt;RabbitMQ&lt;/h3&gt;
&lt;p&gt;rpc 作为一种极常见的服务形态，以异步和解耦著称的 mq 也自然不会放过这个场景，rabbitmq 就为 rpc 调用提供了很好的支持。&lt;/p&gt;

&lt;p&gt;一般和 rabbitmq 的交互场景是发布或消费消息，是一个单向的过程，而 rpc 却是一种同步的双向交互过程，在使用上有些差异。要理解 rabbitmq 如何实现 rpc，还是可以从上面三个抽象的特点出发，万变不离其宗。&lt;/p&gt;
&lt;h4 id="如何协商调用语义"&gt;如何协商调用语义&lt;/h4&gt;
&lt;p&gt;mq 中的消息是从 exchange 分发到 queue 中，消费端在特定的 queue 中获取消息，rpc 的请求依然要走这条路径：&lt;code&gt;方法调用-&amp;gt;exchange-&amp;gt;queue-&amp;gt;方法执行&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;创建一个 direct 类型的 exchange，让每个 rpc 方法对应一个 queue，这个 exchange 通过 routing_key 分发到对应的 queue 中，让特定的消费者来实际执行 rpc 方法。这样 rpc 方法的语义就通过 queue 来约定，而方法的参数，可以放入消息中。&lt;/p&gt;
&lt;h4 id="如何将结果传递回客户端"&gt;如何将结果传递回客户端&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;方法调用-&amp;gt;exchange-&amp;gt;queue-&amp;gt;方法执行&lt;/code&gt;, 这条路是单行道，方法执行端执行完 rpc 方法后不能按照原路将结果返回给客户端。要实现结果回传，就得再开辟一条&lt;code&gt;结果回传端-&amp;gt;exchange-&amp;gt;queue-&amp;gt;结果等待端&lt;/code&gt;路径，一条用来发送 rpc 请求，另一条用来回传 rpc 结果，方法调用者和方法执行者都会扮演生产者和消费者。
&lt;img src="https://l.ruby-china.com/photo/early/eb8d2cc1-43c8-42a6-8b8a-569a0ad5c653.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;rabbitmq 中有&lt;code&gt;回调队列(Callback queue)&lt;/code&gt;来实现&lt;code&gt;调用结果&lt;/code&gt;回传，同时有&lt;code&gt;关联ID(Correlation Id)&lt;/code&gt;来唯一标识每一份&lt;code&gt;调用结果&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;rpc 调用方在发送请求时，会在数据中带上回调队列信息 (routing_key) 和关联 ID，rpc 执行方在执行完方法后，就将关联 ID 掺入执行结果中，并将结果通过 exchange 发往回调队列 (通过 routing_key)。rpc 调用方在发送请求后，紧接着在设置的回调队列中等结果就行。整个过程 (两条路径) 共用同一个 exchange。&lt;/p&gt;

&lt;p&gt;调用参数和调用结果的打包可以用 JSON，protobuf 等等，协商一致即可。&lt;a href="http://www.rabbitmq.com/tutorials/tutorial-six-ruby.html" rel="nofollow" target="_blank" title=""&gt;完整示例代码&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;使用 mq 实现 rpc，有其独有的优势，rpc 执行端可以轻松地横向扩展，rpc 调用方也不用考虑负载均衡，沿袭了 mq 解耦的优点。不过对于调用超时，执行端崩溃等等情况得做额外处理。调用方在等待结果时需要设置超时间，高性能的 rpc 调用还需要调用方能异步高效地通过关联 ID 将请求结果储存起来，等待调用者获取。&lt;a href="https://my.oschina.net/u/3967312/blog/2252996" rel="nofollow" target="_blank" title=""&gt;Spring 框架的实现方案&lt;/a&gt;就是用一个 HashMap 将结果保存起来，等待调用者以关联 ID 作为 key 来取结果。&lt;/p&gt;
&lt;h2 id="工程落地"&gt;工程落地&lt;/h2&gt;
&lt;p&gt;RPC 作为分布式系统的桥梁，在解决以上三大问题之外，还得需要进行工程落地，这就是&lt;code&gt;RPC框架&lt;/code&gt;的核心职责。其要解决的问题有：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;集成服务发现的能力&lt;/li&gt;
&lt;li&gt;负载均衡、限流、熔断等常规操作&lt;/li&gt;
&lt;li&gt;服务方并发能力、稳定性&lt;/li&gt;
&lt;li&gt;请求方资源利用、池化、容错等&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;整体的目标是将 RPC 调用落地，为分布式系统赋能。这是一个系统性的工程。&lt;/p&gt;
&lt;h2 id="总结"&gt;总结&lt;/h2&gt;
&lt;p&gt;RPC 从抽象的角度来看：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;具有需要约定调用语法&lt;/li&gt;
&lt;li&gt;需要约定内容编码方式&lt;/li&gt;
&lt;li&gt;需要网络传输&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这三大特点，进一步可以归纳为&lt;code&gt;协议约定问题&lt;/code&gt;和&lt;code&gt;网络传输问题&lt;/code&gt;，本文的主要内容都围绕这两大问题，并介绍常见的解决方案，借此对建立 RPC 更深的理解。&lt;/p&gt;

&lt;p&gt;接下来的内容，会走进 gRPC 这款强大的 RPC 框架，分别介绍 gRPC 是如何解决上面的三个点，并提供出框架级的能力，为分布式系统赋能。&lt;/p&gt;</description>
      <author>early</author>
      <pubDate>Sun, 17 Jan 2021 14:15:18 +0800</pubDate>
      <link>https://ruby-china.org/topics/40820</link>
      <guid>https://ruby-china.org/topics/40820</guid>
    </item>
    <item>
      <title>直播 -- 如何解决高并发下的热点挑战</title>
      <description>&lt;p&gt;直播是人类智慧的极佳体现，通过技术将模拟数据数字化，打破自然界光线和声音传播的刚性约束，实现隔空重放，让信息摆脱物理学的约束低成本传播，并自然地实现价值放大。&lt;/p&gt;

&lt;p&gt;在手机 APP、浏览器等终端上，我们可以通过一个个窗口，便捷地看到那些隔空重放的信息，或一个鲜活的人或一场热闹的戏或一堆精美的物品，商业上的人、场、”货“都能在这个虚拟的载体上呈现。&lt;/p&gt;

&lt;p&gt;这个载体我们一般称为直播间，或者叫房间。也引出不少耳熟的词汇，如&lt;code&gt;进房间&lt;/code&gt;、&lt;code&gt;出房间&lt;/code&gt;、&lt;code&gt;开启房间&lt;/code&gt;等等。&lt;/p&gt;

&lt;p&gt;直播的场景天然以人为核心，场由人造，“货”因人而存在，可以是商品、声音、荷尔蒙甚至陪伴。正因如此，业务上会想方设法围绕人营造热点，房间热度越高越好，影响力越大越好。通过热点房间连接到更多的人，多一双眼睛，就多一份价值。同时，基于人的内容天然会出现马太效应，大部分的流量因少部分人产生。&lt;/p&gt;

&lt;p&gt;不管是自然属性还是人为干预，直播的业务都必然呈现显著的冷热分布。&lt;/p&gt;

&lt;p&gt;业务爱热点、观众爱热点，几乎所有人都爱热点。除了做技术的小哥哥小姐姐。因为容易出问题啊。&lt;/p&gt;
&lt;h2 id="热点"&gt;热点&lt;/h2&gt;
&lt;p&gt;当下的技术系统之所以能为海量用户提供服务，其原因在于不同用户制造的访问压力，被技术系统分散在了不同的服务器之上。机器数量随着人 (数据量) 增加，只要访问压力和数据量能近似均匀地分摊到不同机器上，同时服务大规模的用户群体便不是问题。&lt;/p&gt;

&lt;p&gt;但热点天然会打破这种规律，常规情况下数据分散存放于服务器之中，但当海量的用户访问同一份数据时，访问压力就会集中在某部分服务器之上，一旦超过服务器承受能力，业务便面临瘫痪的风险。&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/2020/0052542d-56c3-480d-8d55-90ff8b8ce73f.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;在直播业务中，房间信息则是热点中的重灾区，以下是一个业务依赖示意图：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/2020/ab9013f4-74d6-4338-9ba3-16ccfd6864a8.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;房间信息提供标题封面、状态、标签等一系列数据，在直播各个核心业务场景中，被广泛且深度依赖。一方面被众多业务场景强依赖，一旦房间信息故障，涉及的业务将直接不可用，典型的场景则是&lt;code&gt;进房间&lt;/code&gt;，故障后房间内不可播放。另一方面会在业务流程中被依赖，在依赖链条中可能会被再次依赖。&lt;/p&gt;

&lt;p&gt;这直接导致房间信息服务面临业务流量几倍～几十倍不等的请求数放大，在此基础上要保证足够的可用性。&lt;/p&gt;

&lt;p&gt;例如刚刚过去的 S10[1]，单房间动辄同时在线数百万。期间一个活动、一次开场背后都是热点流量洪峰。S10 房间在各个流量、业务场景中均可会露出，决赛期间房间信息 QPS 峰值达数十万，其中热点相关数据几乎可占到一半。&lt;/p&gt;

&lt;p&gt;热点问题的本质在于，热点导致的访问压力没有被均匀地分散开，压力集中的情况下一旦洪峰足够强大，能轻易让服务瘫痪，而这不是简单堆机器所能解决的。&lt;/p&gt;

&lt;p&gt;这需要房间信息服务，根据自身业务场景，设立一套能探测热点、解决热点的机制。&lt;/p&gt;
&lt;h2 id="理论支撑"&gt;理论支撑&lt;/h2&gt;
&lt;p&gt;思考如何解决问题前，我们先来寻求一些理论上的参考，将知识串起来。&lt;/p&gt;

&lt;p&gt;几乎所有的具体技术落地，都是以理论为根基，根据自身实际情况，综合成本及取舍来实现，方式途径千差万别，但万变不离其宗。理论给予我们两个作用：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;提供设计思路，并奠定优化方向&lt;/li&gt;
&lt;li&gt;提供一套框架去分析理解其它技术设计&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CAP 定理是定位于分布式环境中，各个节点需要相互通信、相互分享数据的场景。于本文讨论的点有一定的相关性，或多或少我们能有所借鉴。&lt;/p&gt;

&lt;p&gt;CAP 三大概念：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;C 一致性。对某个指定的客户端来说，读操作保证能够返回最新的写操作结果。核心：从客户端角度要求数据完全一致。&lt;/li&gt;
&lt;li&gt;A 可用性。非故障节点在合理的时间内返回合理的响应。核心：及时返回合理的数据 (不一定是最新的)。&lt;/li&gt;
&lt;li&gt;P 分区容忍性。当出现网络分区后，系统能继续提供服务。核心：在节点之间的数据拷贝和通信出现问题后，整个系统还能对外提供服务 (系统不瘫痪)。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CAP 定理很简洁，就是说分布式系统中 (节点相互通信、相互分享)，只能同时保证以上两个。&lt;/p&gt;

&lt;p&gt;由于分布式系统相互通信依赖网络，而网络不能保证绝对可靠，所以分布式系统时不时可能出现“分区”，也就是节点间相互通信失败。而在部分节点通信失败时，系统其实在正常工作。&lt;/p&gt;

&lt;p&gt;也就是说分区容忍性天然存在，当分区发生时，系统天然可用。不管你愿不愿意，P 都已经给你选好了。所以接下来，系统要么是 AP，要么是 CP。为了一致性 C，当分区 P 时，需要禁止写入，所以 CP 牺牲了 A；为了可用性 A，当分区时，不同的节点数据会不一致，所以 AP 需要牺牲一致性。&lt;/p&gt;

&lt;p&gt;以上是 CAP 理论的一些基本点，其存在前提是节点间存在相互通信拷贝数据，且数据可由多个节点吐出的场景。看着和一般业务场景有差异。当然这不重要，核心在于它能否给我们理论启发，并作出补充。&lt;/p&gt;

&lt;p&gt;后来诞生的 BASE，也就是基本可用、软状态 (中间状态)、最终一致性，就是这样的存在，做了一个衍生和补充。AP 虽然牺牲了 C，但只要快速实现最终一致性，恢复正常后，系统其实同时提供了 C 和 A。&lt;/p&gt;

&lt;p&gt;回到热点处理，当有热点洪峰时，系统核心关注点在于保证可用性，更关注 AP，牺牲一些一致性。而对于基础信息等非余额、库存等业务来讲，天然对一致性要求更宽松。所以可以得到理论上的指向：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;对于非热点数据，可以通过扩容解决压力，正常情况下系统能同时保证 A 和 C。&lt;/li&gt;
&lt;li&gt;对于热点数据，牺牲一定的一致性，以提升可用性，通过及时保证最终一致性，使得系统完全正常。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="如何处理热点"&gt;如何处理热点&lt;/h2&gt;
&lt;p&gt;通过上面的分析，我们可以知道，提升可用性可以通过牺牲部分一致性来实现。一致性问题的本质，就是数据有多个副本，且副本之间的数据没有做到及时同步。传统的 CDN 缓存和 Nginx 缓存，其实就是一致性换可用性，直接拷贝了一份数据。&lt;/p&gt;

&lt;p&gt;有些暴力解决热点的办法，就是将数据拷贝多份，在读数据时随机获取其中一份，达到分散压力的作用。这种方法其实缺乏适应性，到底要设多少份拷贝才合理？如何降低设置多份拷贝的复杂性？如何保证最终一致性？对于这些问题稍微有些复杂。&lt;/p&gt;

&lt;p&gt;更常规的做法是将热点数据探测到，只对热点数据做拷贝，再针对性处理，这样可以抵挡相当程度的流量，成本也可控。其分为两部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;自动探测热点&lt;/li&gt;
&lt;li&gt;将热点复制于内存中，并及时实现最终一致性&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;以房间信息逻辑与数据分离结构为例：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/2020/d0d9b8d7-4d25-4acf-9a9e-43838be63222.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;对于热点的探测及处理，可以在逻辑层、数据层两个地方做：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;逻辑层。热点探测以房间 ID、主播 ID 为维度，同时处理数据。&lt;/li&gt;
&lt;li&gt;数据层。热点探测以房间 ID、主播 ID、缓存 key 都可以，同时处理数据。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;以 ID 为探测对象，需要在业务代码中埋点并进行缓存，对业务有一定的侵入。
以缓存 key 为对象则可以在缓存基础库中集成，可以做到对业务无感知。&lt;/p&gt;

&lt;p&gt;如上图所示，我们选择了以 ID 为探测对象，并在逻辑层处理缓存。基本的考量点是保持逻辑和数据分离，在当下的团队成员和基础设施条件下，做到足够简单轻量。&lt;/p&gt;
&lt;h3 id="自动探测热点"&gt;自动探测热点&lt;/h3&gt;
&lt;p&gt;热点处理业界一般常见的有几种算法：&lt;/p&gt;

&lt;p&gt;一、LRU。这是一个简单的栈结构，简单轻量。但本身无热点识别能力，在“SKU”较大的场景下很难起到作用。同时基于我们 ID 探测的策略，LRU 的使用需要下游数据层每次返回房间全量信息，这有极大成本。&lt;/p&gt;

&lt;p&gt;二、LIRS[2]。这在内存缓冲淘汰场景被广泛使用，例如数据库中的 BufferPool，在缓冲空间有限的情况下，踢掉冷数据为热数据提供空间。可以认为 LIRS 是 LRU 的进进阶版本，能够提供适应性更好的热点识别和数据淘汰。其本身有一定的实现复杂度，同时和 LRU 一样，基于 ID 探测时，需要下游返回一份完整的数据。(有时接口只想读取房间标题)&lt;/p&gt;

&lt;p&gt;三、LFU。简单版的 LFU 就是对被访问对象进行访问频次的计数，其本身可以基于访问次数来识别热点，但这种识别无法根据时间区间进行，并对“老热点”没有剔除的能力。当“SKU”非常大时，容易出现其他问题。&lt;/p&gt;

&lt;p&gt;基于业务实际场景，我们选择了简易版的 LFU，并根据其缺陷，辅助以滑动窗口提供根据时间区间识别热点的能力，窗口的保持时间，也就是热点的探测周期，一次滑动对应一次热点结算和上报，结算时通过优先队列获取 Top-K。&lt;/p&gt;

&lt;p&gt;总结下三个点：&lt;/p&gt;

&lt;p&gt;一、LFU。用以简单统计访问 ID 的访问频次，在实现上核心是一个 HashMap 并辅以统计。针对 LFU 难以处理大规模”SKU“的情况，设计实现时会设置一个统计对象数上限，超过一定比例时会强制采样。(防止有 bug 把 hashMap 写爆了) （还未实现数据淘汰，目前只是一个简单的频率统计）&lt;/p&gt;

&lt;p&gt;二、滑动窗口。滑动窗口的作用是辅以 LFU 实现基于时间区间的热点统计，并将结算、回调、数据上报等操作封装在窗口滑动流程之中。基于对房间场景时间区间内“SKU”的统计 + 分析判断 (脑拍)，单次窗口保持时间是 3s，也就是说热点探测的反应时间最快是 3s。&lt;/p&gt;

&lt;p&gt;三、优先队列。目的是以最快的速度在数万～数十万 SKU 中，计算出 Top-K。常规的全排序时间复杂度为 O(N*LogN)。优先队列的时间消耗：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; 1.构建大小为 K 的小顶堆，O(K) ；&lt;/li&gt;
&lt;li&gt; 2.遍历所有 SKU，尝试写入小顶堆，O(N)&lt;/li&gt;
&lt;li&gt; 3.如果小于小顶堆头部则丢弃，否则置换头部并重新堆话。O(log(K))&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;整体时间复杂度：O(K) + O(N*log(K))，当 K 最够小时，时间复杂度近似为 O(N)。&lt;/p&gt;
&lt;h3 id="缓存与及时最终一致性"&gt;缓存与及时最终一致性&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;当探测到热点后，会基于热点 ID 在内存中构建数据副本，对于房间信息来讲就是全量字段 (数十个)&lt;/li&gt;
&lt;li&gt;异步基于热点 ID，不断请求下游，刷新缓存数据，实现及时最终一致性。(周期、有效期均可实时由配置跳针)&lt;/li&gt;
&lt;li&gt;业务接口根据 ID 优先从缓存副本中读取，miss 后才真正访问下游数据层。&lt;/li&gt;
&lt;li&gt;热点的处理规则、刷新周期、有效期等通过配置实时干预&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="整体结构示意图："&gt;整体结构示意图：&lt;/h3&gt;
&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/2020/eaff4cce-7d07-4bf8-94cb-422cc13b688a.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;通过 SDK 集成热点探测能力，并通过业务回调，定时回传 Top-K 热点&lt;/li&gt;
&lt;li&gt;业务根据 Top-K 热点对复制热点数据，并保证及时最终一致性&lt;/li&gt;
&lt;li&gt;探测、热点处理均基于本地实例 (可以很便捷演进为分布式模式)&lt;/li&gt;
&lt;li&gt;无任何外部依赖，轻量、简单、灵活性高、开发周期短、易于问题排查分析&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="实际效果"&gt;实际效果&lt;/h2&gt;
&lt;p&gt;hits 为命中了缓存，miss 为未命中：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/2020/f072a89d-9809-454c-bd2c-00b267739dd0.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;对集群整体压测时，下游数据层在 3s 左右后 (单实例探测周期为 3s)，访问压力变成常数：
&lt;img src="https://l.ruby-china.com/photo/2020/cd7ed9ce-56d4-4ebb-a5ec-e9cf80f7c1c3.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;从业务表现和压测检验来看，热点探测让房间信息服务在热点洪峰下能对热点自适应探测，使得整体保持稳定。&lt;/p&gt;
&lt;h3 id="场景分析"&gt;场景分析&lt;/h3&gt;
&lt;p&gt;适用场景：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;流量洪峰相对，例如压测场景，表现良好；间隔性脉冲访问效果差。&lt;/li&gt;
&lt;li&gt;各个本地实例流量需近似均匀&lt;/li&gt;
&lt;li&gt;中小型节点规模。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;以上均暂时符合我们的场景。&lt;/p&gt;
&lt;h3 id="不足"&gt;不足&lt;/h3&gt;
&lt;p&gt;1）&lt;em&gt;一致性的报复&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;当热点处理彻底消灭了热点问题时，另一个被忽略的场景则出现问题，可简单总结为时序性依赖：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;房间信息变更 -&amp;gt; 消息队列投递消息（例如房间关播）&lt;/li&gt;
&lt;li&gt;某业务消费消息&lt;/li&gt;
&lt;li&gt;业务消费消息后立即调用房间信息 (根据开播状态中踢掉房间)&lt;/li&gt;
&lt;li&gt;房间信息还未及时保证一致性 (房间状态还是开播中)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;1～3 时间消耗大约在 10ms 以内，这是小于房间信息及时最终一致性周期的，也就是说在这种消息变更依赖场景下，房间信息的一致性未能满足需求。成也萧何，败也萧何。&lt;/p&gt;

&lt;p&gt;为了解决以上问题，我们提出了几个解决途径：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;消息投递时做一定延迟&lt;/li&gt;
&lt;li&gt;时序性状态依赖的特殊场景 (量很小无热点问题)，避开缓存&lt;/li&gt;
&lt;li&gt;说服调用方不要时序型依赖 (理解业务)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;2）&lt;em&gt;复制成本稍高&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;以上基于 ID 探测的方案优点很明显，简单轻量无任何外部依赖。但其代价是业务有感知，并有一定份复制成本，无法无缝复制到其他业务场景中。&lt;/p&gt;
&lt;h3 id="更理想的状态"&gt;更理想的状态&lt;/h3&gt;
&lt;p&gt;技术的一般追求是，通过技术的方式降本提效，改善生产力，实现人肉不能实现的能力，并在此基础上提供近乎零成本的复制。&lt;/p&gt;

&lt;p&gt;而具体的技术实现，则是在时间、成本、人力、场景、需求等综合因素作用下的产物，处于随时满足新的需求，并不断向往着理想的状态。&lt;/p&gt;

&lt;p&gt;我们在心里默念理想的状态：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;基于热点探测、及时最终一致性更加极致的实现&lt;/li&gt;
&lt;li&gt;业务零感知、零耦合&lt;/li&gt;
&lt;li&gt;近乎零的复制成本：无缝移植到其他场景&lt;/li&gt;
&lt;li&gt;良好的适应性：灵活且符合大量场景的规则配置&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这已经有开源方案作出了示例。[3]&lt;/p&gt;
&lt;h2 id="应对攻击"&gt;应对攻击&lt;/h2&gt;
&lt;p&gt;暴露在公网的房间，极易被攻击或被爬虫遍历 (房间 ID/主播 ID)。这背后是热点攻击，热点处理能很好的应对，而暴力遍历攻击则相对令人头疼。&lt;/p&gt;

&lt;p&gt;在被刷过数次后，房间信息也做了相应的策略应对。&lt;/p&gt;
&lt;h2 id="非法 房间ID 防御"&gt;非法 房间 ID 防御&lt;/h2&gt;
&lt;p&gt;由于房间 id 需要便于记忆，其生成规则是接近自增的，整体上房间 ID 的分布是接近连续的。此时业务上只要简单判断，请求中的 房间 ID 是否大于当前系统中最大的房间 ID，即可大致判断出该房间 ID 是否合法，将其以极低成本拒绝。  &lt;/p&gt;

&lt;p&gt;在房间服务中，定时获取当前最大房间 Maxid，并加入一定 buffer，当 requestId &amp;gt; Maxid + buffer 时，则直接丢弃。&lt;/p&gt;
&lt;h2 id="非法主播ID 防御"&gt;非法主播 ID 防御&lt;/h2&gt;
&lt;p&gt;主播数量比较庞大，且增长区间跳跃、范围极广。对于房间信息来讲是一个&lt;strong&gt;无限集&lt;/strong&gt;，无法像房间 ID 那样通过简单的策略做出判断。在面对大范围暴力遍历时面临挑战。&lt;/p&gt;

&lt;p&gt;首先想到的策略是通过 bitmap 做映射，合法的 uid = 主播数&amp;nbsp; = 房间数，这是个有限集，将全量数据映射到 bitmap 中，如果某个 uid 在 bitmap 中无法找到映射，则可以肯定其不是主播，可以直接丢弃。&amp;nbsp;否则大概率是主播，因为 bitmap 长度问题，可能有些精度上的丢失，但不成问题。&lt;/p&gt;

&lt;p&gt;布隆过滤器同理。&lt;/p&gt;
&lt;h3 id="为何放弃bitmap"&gt;为何放弃 bitmap&lt;/h3&gt;
&lt;p&gt;在此想法的验证过程中，通过业界成熟且广泛使用的 RoaringBitmap[4] 进行落地，虽然其提供优异的压缩能力，在将所有主播写入时，对象的大小达到了数十 M，这对于要通过缓存系统加载到内存的对象来讲，太大了。如果进行分片，则 uid 的增量更新 (新增主播) 成本会变高，同时由于主播 ID 可能超过 32 位，这也超出了 RoaringBitmap 的范围 (32 位版本)。 （如果 uid 增量更新没处理好，新增主播会找不到自己房间，业务会受损，因为 uid 被当作非法丢弃了）&lt;/p&gt;

&lt;p&gt;且在主播数量进一步扩张的未来，无法提供好的适应能力。这大块数据面临维护成本：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  及时更新内存中的 bitmap，否则新增主播无法拿到自己房间&lt;/li&gt;
&lt;li&gt;  整块数据需要在实例启动时加载到内存，成本高&lt;/li&gt;
&lt;li&gt;  整块数据需要在缓存中及时维护一份全量数据，可靠性不高。一旦数据丢失全量构建一次成本很高&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;如果基于分布式缓存维护 bitmap，可解决一部分问题，但其性价比则极剧下降。基于以上分析，bitmap 方案带来的工程上的隐患稍显棘手。 （布隆过滤器同理，且多份 bitmap 成本更高。）&lt;/p&gt;
&lt;h3 id="惹不起就躲"&gt;惹不起就躲&lt;/h3&gt;
&lt;p&gt;基于业务场景，我们选择了更加低成本的方案：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;隐藏部分通过主播 ID 获取房间信息的入口 (通过合法服务端调用)&lt;/li&gt;
&lt;li&gt;限制部分通过 uid 获取房间信息的能力&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这其实更加简单地解决了问题。&lt;/p&gt;
&lt;h2 id="总结"&gt;总结&lt;/h2&gt;
&lt;p&gt;从面临问题时的焦躁，到一步步抬起锄头东闯西撞地作出尝试，再到问题的基本解决。从名义上可美化为演进或迭代，但站在终点边缘回看，这都不是最佳的解决方式。往往在日常中，很多场景更喜欢有相关经验的人，因为他们见过做过，能更容易有基于记忆的一顺而下的解决方案。但又有谁能拥有足够多的经验呢？在变化中的行业能瞬间让经验作为过去时，这并不能意味着有解决问题的增量。&lt;/p&gt;

&lt;p&gt;更加合理的方式是，关注到问题的本源，通过理论知识的推导，得出大致解决思路及尝试方向。在此基础上通过对技术要素的组合，构建解决问题的方案。这才是更快速解决问题的途径，并具备解决经验之外问题的能力。这背后需要做两个事情：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;关注问题的本源，寻求理论知识疏导、拆解、归类等&lt;/li&gt;
&lt;li&gt;积累足够丰富的技术要素。算法、数据结构、以及更朴素的计算机思想 (如时空互换、批处理等)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这是姿势的大改造。&lt;/p&gt;
&lt;h2 id="代码实现"&gt;代码实现&lt;/h2&gt;
&lt;p&gt;精简版可见： &lt;a href="https://github.com/EarlyZhao/hotDetect" rel="nofollow" target="_blank"&gt;https://github.com/EarlyZhao/hotDetect&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="延伸阅读"&gt;延伸阅读&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/39187" title=""&gt;直播 (上) -- 底层逻辑浅析&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/39254" title=""&gt;直播 (中) -- 核心流程梳理&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/39328" title=""&gt;直播 (下) -- 业务结构简介&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/39574" title=""&gt;直播：弹幕系统的秘密&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="附录"&gt;附录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[1]  &lt;a href="https://live.bilibili.com/6" rel="nofollow" target="_blank"&gt;https://live.bilibili.com/6&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[2] &lt;a href="http://web.cse.ohio-state.edu/hpcs/WWW/HTML/publications/papers/TR-02-6.pdf" rel="nofollow" target="_blank"&gt;http://web.cse.ohio-state.edu/hpcs/WWW/HTML/publications/papers/TR-02-6.pdf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[3] &lt;a href="https://gitee.com/jd-platform-opensource/hotkey" rel="nofollow" target="_blank"&gt;https://gitee.com/jd-platform-opensource/hotkey&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[4] &lt;a href="http://github.com/RoaringBitmap/roaring" rel="nofollow" target="_blank"&gt;http://github.com/RoaringBitmap/roaring&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>early</author>
      <pubDate>Fri, 20 Nov 2020 18:48:36 +0800</pubDate>
      <link>https://ruby-china.org/topics/40596</link>
      <guid>https://ruby-china.org/topics/40596</guid>
    </item>
    <item>
      <title>Redis 6.0 多线程 IO 处理过程详解</title>
      <description>&lt;h2 id="引"&gt;引&lt;/h2&gt;
&lt;p&gt;大半年前，看到 Redis 即将推出“多线程 IO”的特性，基于当时的各种资料，和 unstable 分支的代码，写了&lt;a href="https://ruby-china.org/topics/38957" title=""&gt;《多线程的 Redis》&lt;/a&gt;，浅尝辄止地介绍了下特性，不够华也不实。本文将深入到实处，内容包含：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;介绍 Redis 单线程 IO 处理过程&lt;/li&gt;
&lt;li&gt;单线程的问题&lt;/li&gt;
&lt;li&gt;解析 Redis 多线程 IO 如何工作&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;要分析多线程 IO，必须先搞清楚经典的单线程异步 IO。文章会先介绍单线程 IO 的知识，然后再引出多线程 IO，如果已经熟悉，可以直接跳到多线程 IO 部分。&lt;/p&gt;

&lt;p&gt;接下来我们一起啃下这两块大骨头。代码基于： &lt;a href="https://github.com/antirez/redis/tree/6.0" rel="nofollow" target="_blank" title=""&gt;https://github.com/antirez/redis/tree/6.0&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="异步IO"&gt;异步 IO&lt;/h2&gt;
&lt;p&gt;Redis 核心的工作负荷是一个单线程在处理，但为什么还那么快？&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;其一是纯内存操作。&lt;/li&gt;
&lt;li&gt;其二就是异步 IO，每个命令从接收到处理，再到返回，会经历多个“不连续”的工序。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(&lt;em&gt;为避免歧义，此处的异步处理 IO 不是“同步/异步 IO”，特指 IO 处理过程是异步的，描述的对象是处理过程。&lt;/em&gt;)&lt;/p&gt;

&lt;p&gt;假设客户端发送了以下命令：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET key-how-to-be-a-better-man？
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;redis 回复：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;努力加把劲把文章写完
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;要处理命令，则 redis 必须完整地接收客户端的请求，并将命令解析出来，再将结果读出来，通过网络回写到客户端。整个工序分为以下几个部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;接收。通过 TCP 接收到命令，可能会历经多次 TCP 包、ack、IO 操作&lt;/li&gt;
&lt;li&gt;解析。将命令取出来&lt;/li&gt;
&lt;li&gt;执行。到对应的地方将 value 读出来&lt;/li&gt;
&lt;li&gt;返回。将 value 通过 TCP 返回给客户端，如果 value 较大，则 IO 负荷会更重&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;其中解析和执行是纯 cpu/内存操作，而接收和返回主要是 IO 操作，这是我们要关注的重点。以接收为例，redis 要完整接收客户端命令，有两种策略：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;接收客户端命令时一直等，直到接收到完整的命令，然后执行，再将结果返回，直到客户端收到完整结果，然后才处理下一个命令。这叫&lt;strong&gt;同步&lt;/strong&gt;。同步的过程中有很多等待的时间，例如有个客户端网络不好，那等它完整的命令就会更耗时。&lt;/li&gt;
&lt;li&gt;客户端的 TCP 包来一个才处理一个，将数据追加到缓冲区，处理完了就去立即找其他事做，不等待，下一个 TCP 包来了再继续处理。命令的接收过程是穿插的，不连续。一会儿接收这个命令，一会儿又在接收另一个。这叫做&lt;em&gt;异步&lt;/em&gt;，过程中没有额外的空闲等待时间。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;用聊天的例子做对应，假设你在回答多个人的问题，也有同步和异步的策略：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;聊天框中显示“正在输入”时，你一直等 ta 输入完毕，然后回答 ta 的问题，再发送出去，发送时会有等待，常规表现就是有个圆圈在转。你等发送完毕后，才去回答另一个人的问题。同步&lt;/li&gt;
&lt;li&gt;显示“正在输入”时，不等 ta，而是去回答其他输入完毕的问题，回答完后，不等发送完毕，又去回答其它问题。异步&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;很显然异步的效率更高，要实现高并发必须要异步，因为同步有太多时间浪费在等待上了，遇到网络不好的客户端直接就被拖垮。异步的策略简单可总结如下：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;网络包有数据了，就去读一下放到缓冲区，读完立马切到其他事情上，不等下一个包&lt;/li&gt;
&lt;li&gt;解析下缓冲区数据是否完整。如完整则执行命令，不完整切到其他事情上&lt;/li&gt;
&lt;li&gt;数据完整了，立即执行命令，将执行结果放到缓冲区&lt;/li&gt;
&lt;li&gt;将数据给客户端，如果一次给不完，就等&lt;code&gt;下次能给时&lt;/code&gt;再给，不等，直到全部给完&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="事件驱动"&gt;事件驱动&lt;/h2&gt;
&lt;p&gt;异步没有零散的等待，但有个问题是，如果 redis 不一直阻塞等命令来，咋个知道“网络包有数据了”、“下次能给时”这两个时机？如果一直去轮训问肯定效率很低，要有个高效的机制，来通知 redis 这两个时刻，由这些时刻来触发动作。这就是事件驱动。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;一个新TCP包来了&lt;/code&gt;、&lt;code&gt;可以再次发给客户端数据&lt;/code&gt;这两个时机都是事件。与之对应的就是 redis 和客户端之间 socket 的可读、可写事件 [1] ，就像微信聊天中新消息提醒一样。linux 中的 epoll 就是干这个事的，redis 基于 epoll 等机制抽象出了一套事件驱动框架 [2]，整个 server 完全由事件驱动，有事件发生就处理，没有就空闲等待。&lt;/p&gt;
&lt;h2 id="单线程IO处理过程"&gt;单线程 IO 处理过程&lt;/h2&gt;
&lt;p&gt;redis 启动后会进入一个死循环 aeMain，在这个循环里一直等待事件发生，事件分为 IO 事件和 timer 事件，timer 事件是一些定时执行的任务，如 expire key 等，本文只聊 IO 事件。&lt;/p&gt;

&lt;p&gt;epoll 处理的是 socket 的可读、可写事件，当事件发生后提供一种高效的通知方式，当想要异步监听某个 socket 的读写事件时，需要去事件驱动框架中注册要监听事件的 socket，以及对应事件的回调 function。然后死循环中可以通过 epoll_wait 不断地去拿发生了可读写事件的 socket，依次处理即可。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;可读&lt;/code&gt;可以简单理解为，对应的 socket 中有新的 tcp 数据包到来。
&lt;code&gt;可写&lt;/code&gt;可以简单理解为，对应的 socket 写缓冲区已经空了 (数据通过网络已经发给了客户端)&lt;/p&gt;

&lt;p&gt;一图胜前言，完整、详细流程图如下：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/2020/6401851b-f967-4aca-8c5e-fbf2f05eeb45.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;aeMain() 内部是一个死循环，会在 epoll_wait 处短暂休眠&lt;/li&gt;
&lt;li&gt;epoll_wait 返回的是当前可读、可写的 socket 列表&lt;/li&gt;
&lt;li&gt;beforeSleep 是进入休眠前执行的逻辑，核心是回写数据到 socket&lt;/li&gt;
&lt;li&gt;核心逻辑都是由 IO 事件触发，要么可读，要么可写，否则执行 timer 定时任务&lt;/li&gt;
&lt;li&gt;第一次的 IO 可读事件，是监听 socket(如监听 6379 的 socket)，当有握手请求时，会执行 accept 调用，得到一个连接 socket，注册可读回调 createClient，往后客户端和 redis 的数据都通过这个 socket 进行&lt;/li&gt;
&lt;li&gt;一个完整的命令，可能会通过多次 readQueryFromClient 才能从 socket 读完，这意味这多次可读 IO 事件&lt;/li&gt;
&lt;li&gt;命令执行的结果会写，也是这样，大概率会通过多次可写回调才能写完&lt;/li&gt;
&lt;li&gt;当命令被执行完后，对应的连接会被追加到 clients_pending_write，beforeSleep 会尝试回写到 socket，写不完会注册可写事件，下次继续写&lt;/li&gt;
&lt;li&gt;整个过程 IO 全部都是同步非阻塞，没有浪费等待时间&lt;/li&gt;
&lt;li&gt;注册事件的函数叫 aeCreateFileEvent&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="单线程IO的瓶颈"&gt;单线程 IO 的瓶颈&lt;/h2&gt;
&lt;p&gt;上面详细梳理了单线程 IO 的处理过程，IO 都是非阻塞，没有浪费一丁点时间，虽然是单线程，但动辄能上 10W QPS。不过也就这水平了，难以提供更多的自行车。&lt;/p&gt;

&lt;p&gt;同时这个模型有几个缺陷：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;只能用一个 cpu 核 (忽略后台线程)&lt;/li&gt;
&lt;li&gt;如果 value 比较大，redis 的 QPS 会下降得很厉害，有时一个大 key 就可以拖垮&lt;/li&gt;
&lt;li&gt;QPS 难以更上一层楼&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;redis 主线程的时间消耗主要在两个方面：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;逻辑计算的消耗&lt;/li&gt;
&lt;li&gt;同步 IO 读写，拷贝数据导致的消耗&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;当 value 比较大时，瓶颈会先出现在同步 IO 上 (假设带宽和内存足够)，这部分消耗在于两部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;从 socket 中读取请求数据，会从内核态将数据拷贝到用户态（read 调用）&lt;/li&gt;
&lt;li&gt;将数据回写到 socket，会将数据从用户态拷贝到内核态（write 调用）&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这部分数据读写会占用大量的 cpu 时间，也直接导致了瓶颈。如果能有多个线程来分担这部分消耗，那 redis 的吞吐量还能更上一层楼，这也是 redis 引入多线程 IO 的目的。[3]&lt;/p&gt;
&lt;h2 id="多线程IO"&gt;多线程 IO&lt;/h2&gt;
&lt;p&gt;上面已经梳理了单线程 IO 的处理流程，以及多线程 IO 要解决的问题，接下来将目光放到：如何用多线程分担 IO 的负荷。其做法用简单的话来说就是：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;用一组单独的线程专门进行 read/write socket 读写调用（同步 IO）&lt;/li&gt;
&lt;li&gt;读回调函数中不再读数据，而是将对应的连接追加到可读 clients_pending_read 的链表&lt;/li&gt;
&lt;li&gt;主线程在 beforeSleep 中将 IO 读任务分给 IO 线程组&lt;/li&gt;
&lt;li&gt;主线程自己也处理一个 IO 读任务，并自旋式等 IO 线程组处理完，再继续往下&lt;/li&gt;
&lt;li&gt;主线程在 beforeSleep 中将 IO 写任务分给 IO 线程组&lt;/li&gt;
&lt;li&gt;主线程自己也处理一个 IO 写任务，并自旋式等 IO 线程组处理完，再继续往下&lt;/li&gt;
&lt;li&gt;IO 线程组要么同时在读，要么同时在写&lt;/li&gt;
&lt;li&gt;命令的执行由主线程串行执行 (保持单线程)&lt;/li&gt;
&lt;li&gt;IO 线程数量可配置&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;完整流程图如下：
&lt;img src="https://l.ruby-china.com/photo/2020/f464e541-0d40-48f3-965d-42c889753c6c.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;beforesleep 中，先让 IO 线程读数据，然后再让 IO 线程写数据。读写时，多线程能并发执行，利用多核。&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;将读任务均匀分发到各个 IO 线程的任务链表 io_threads_list[i]，将 io_threads_pending[i] 设置为对应的任务数，此时 IO 线程将从死循环中被激活，开始执行任务，执行完毕后，会将 io_threads_pending[i] 清零。函数名为：handleClientsWithPendingReadsUsingThreads&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;将写任务均匀分发到各个 IO 线程的任务链表 io_threads_list[i]，将 io_threads_pending[i] 设置为对应的任务数，此时 IO 线程将从死循环中被激活，开始执行任务，执行完毕后，会将 io_threads_pending[i] 清零。函数名为：handleClientsWithPendingWritesUsingThreads&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;beforeSleep 中主线程也会执行其中一个任务 (图中忽略了)，执行完后自旋等待 IO 线程处理完。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;读任务要么在 beforeSleep 中被执行，要么在 IO 线程被执行，不会再在读回调中执行&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;写任务会分散到 beforeSleep、IO 线程、写回调中执行&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;主线程和 IO 线程交互是无锁的，通过标志位设置进行，不会同时写任务链表&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;性能据测试提升了一倍以上 (4 个 IO 线程)。 [4]&lt;/p&gt;

&lt;p&gt;欢迎您的提问、指正、建议等。&lt;a href="https://zhuanlan.zhihu.com/p/144805500" rel="nofollow" target="_blank" title=""&gt;首发在这里&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="参考"&gt;参考&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href="https://www.cnblogs.com/my_life/articles/10910375.html" rel="nofollow" target="_blank" title=""&gt;https://www.cnblogs.com/my_life/articles/10910375.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mp.weixin.qq.com/s/5SzbrBMpq-JowLfvfWNY-g" rel="nofollow" target="_blank"&gt;https://mp.weixin.qq.com/s/5SzbrBMpq-JowLfvfWNY-g&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.toutiao.com/a6816914695023231500/" rel="nofollow" target="_blank" title=""&gt;https://www.toutiao.com/a6816914695023231500/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.chainnews.com/articles/610212461536.htm" rel="nofollow" target="_blank" title=""&gt;https://www.chainnews.com/articles/610212461536.htm&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;</description>
      <author>early</author>
      <pubDate>Sun, 31 May 2020 02:01:57 +0800</pubDate>
      <link>https://ruby-china.org/topics/39925</link>
      <guid>https://ruby-china.org/topics/39925</guid>
    </item>
    <item>
      <title>TiDB 为什么能横向扩展？</title>
      <description>&lt;h2 id="蛋疼的数据量"&gt;蛋疼的数据量&lt;/h2&gt;
&lt;p&gt;之前做过 SASS 系统，业务中有几张核心数据表，数量都超过了 5000W，查询频率很高，界面上聚合的数据字段可以通过自定义扩展非常多。每天都会定时定点来一波故障，大多数都是 MySQL 慢查询，对业务影响很恶劣。&lt;/p&gt;

&lt;p&gt;咋个办呢？全部走 ES 搜索可以解决问题，但成本会比较高，而且还要较长的开发周期 (有部分模块接入了 ES)。最快解决问题的办法有几个，当时的先烈们已经把大盘搭好了：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;加从库，一个不够加两个&lt;/li&gt;
&lt;li&gt;使劲分表。SASS 应用是多租户的模式，各个公司的数据在逻辑上没有交叉&lt;/li&gt;
&lt;li&gt;把不参与核心数据 join 的表全部迁走&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;效果是有的，而且这种模式也支撑了业务好长一段时间。但是随着业务数据进一步扩大，不同公司的数据量差异很大，业务精湛的公司数据量远超其他公司，这使得对应的分表数据行数也迅速搞到大几百万，在复杂的查询下，会有不同组合的索引，当数据量大索引复杂时，MySQL 就会乱选索引，执行计划都偏离了实际的数据。&lt;/p&gt;

&lt;p&gt;再加上各个业务表都是数千万的量，由于 B 端变态的业务逻辑，有非常多的 join 联合查询，把这些大家伙分到不同的机器上非常困难。所以但 DB 的总数据量和查询压力非常大，到后来加从库也不顶用了，慢查询迅速就能堵住 DB。眼下就只有一条路，就是增加一个 MySQL 的中间件代理层。而业界的那些垃圾代理完全不靠谱，要么复杂要么完全满足不了 B 端的业务需要，而小公司根本养不起做代理层的人才。眼看数据库的机器已经是顶配了，程序员哥哥们都很焦虑啊。&lt;/p&gt;

&lt;p&gt;后面的做法就是把那几个大家伙的数据单独搞出来，放到另一个 DB，用单独的业务实例给他们服务，在进入层通过域名直接分流，他们挂了其他客户不受影响。就像起初微博给某些大 V 单独搞服务器是一个套路。 &lt;/p&gt;

&lt;p&gt;创业公司对成本太敏感，还是很难为技术哥哥们的，后面技术哥哥们提出限制单公司数据量的意见终于被产品爸爸们采纳了，“大 V”的增长势头由此被遏制住了。一个公司导入几百上千万数据根本没有意义，排序靠后的“百年难露面”，有时候问题的根源在于产品经理天马行空的想当然。&lt;/p&gt;

&lt;p&gt;再后来由于种种原因我跑路了。&lt;/p&gt;
&lt;h2 id="遇见TiDB"&gt;遇见 TiDB&lt;/h2&gt;
&lt;p&gt;后面断断续续听到 TiDB 的声音越来越多，而且现公司也有业务在开始使用。在零零碎碎的知识中，我知道 TiDB 最大的优势是可以避免业务自己分表，通过简单扩容就可以解决上面的问题，这是一件超级棒的事情。 &lt;/p&gt;

&lt;p&gt;目前所在 C 端的业务，有些场景数据量也比较大，但由于 C 端超极度简单的查询场景，数据直接分表、归档就行。而这个分表也是很恶心，有些同时按天、uid 尾号、月份分表，非常麻烦，代码看着也复杂。结合我之前 B 端的经验，我觉得 TiDB 是非常有价值的，也产生了浓厚的兴趣，想一窥原理。&lt;/p&gt;
&lt;h2 id="为什么能横向扩展"&gt;为什么能横向扩展&lt;/h2&gt;
&lt;p&gt;MySQL 的表数据、索引数据、表结构是分别放在一个文件中的，当数据量膨胀，表文件就会变大，这会让查询耗时急剧上升，而表文件只能在一台机器上加载。唯一的解决方案就是分表，当数据量进一步扩张时，还需要分库。这时候应用程序读库就需要通过复杂的代理层 (Vitess/Altas)，不过要么太复杂，要么难以满足 B 端的需求，所以很多 B 端业务是将数据整合成宽表，写到利好搜索的存储中。&lt;/p&gt;

&lt;p&gt;这种以表为单位集中存放数据的模式，在数据量增长的过程中成为扩展的瓶颈，这是根源问题。而业界屏蔽分库分表的复杂性的方案又不够争气。要解决这个问题，根源上是要打破以表为单位存放数据的这种模式，互联网架构的经验告诉我们，当遇到问题、遇到瓶颈时，要做的事就是一个字：拆。&lt;/p&gt;
&lt;h2 id="先将数据拆碎"&gt;先将数据拆碎&lt;/h2&gt;
&lt;p&gt;不管是微服务还是存储，都是这个思路，这是“体”级别的智慧。 &lt;strong&gt;拆的本质其实是将元素的组成单位变小，通过增加系统复杂度，来提高扩展能力&lt;/strong&gt;。TiDB 也是这样做的，它瞄上了 KV 存储，让数据的最小单位以“行”存在，每行数据有一个 Key 来表达，key 对应的 value 就是行数据的内容。表解构、表数据、表索引都可以用 KV 对来表达。&lt;a href="[https://pingcap.com/blog-cn/tidb-internal-2/](https://pingcap.com/blog-cn/tidb-internal-2/)" title=""&gt;引用官方文档中的内容举例&lt;/a&gt;：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KV对如何表达MySQL的行数据：
假设表中有 3 行数据：

1, "TiDB", "SQL Layer", 10
2, "TiKV", "KV Engine", 20
3, "PD", "Manager", 30
那么首先每行数据都会映射为一个 Key-Value pair，注意这个表有一个 Int 类型的 Primary Key，
所以 RowID 的值即为这个 Primary Key 的值。假设这个表的 Table ID 为 10，其 Row 的数据为：

t10_r1 --&amp;gt; ["TiDB", "SQL Layer", 10]
t10_r2 --&amp;gt; ["TiKV", "KV Engine", 20]
t10_r3 --&amp;gt; ["PD", "Manager", 30]
除了 Primary Key 之外，这个表还有一个 Index，假设这个 Index 的 ID 为 1，则其数据为：

t10_i1_10_1 --&amp;gt; null
t10_i1_20_2 --&amp;gt; null
t10_i1_30_3 --&amp;gt; null
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;每张表有一个全局唯一的 ID（Table ID）&lt;/li&gt;
&lt;li&gt;每个索引有一个表级别唯一的 ID（Index 的 ID）&lt;/li&gt;
&lt;li&gt;每条数据有一个表级别唯一的主键 ID（Primary Key）&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这样每张表的每条数据可以用一个全局唯一的 Key 来表达。Key 的组成有特定的规律： &lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t{TableId}_r{Primary Key} # 如上面的 t10_r1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种表达方式会让同一张表的行数据，Key 的头部都是一样的。而 TiDB 会将这些表数据存放在 TiKV 中 (处理 KV 存储的模块)，然后会使用超级大招： &lt;strong&gt;KV 的存储是按 Key 全局有序排列。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;这样的结果是：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;同一张表的数据是聚集在一个区间的&lt;/li&gt;
&lt;li&gt;这个区间中数据的顺序是按 Primary Key 来排列的&lt;/li&gt;
&lt;li&gt;扫描表数据会变得方便&lt;/li&gt;
&lt;li&gt;预测表数据在 KV 的什么位置很方便&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;存在 DB 中的数据是一个超大的有序列表，当需要扩容时将这个有序列表按区间，有序地分给对应的机器就行。当有新机器加入时，按区间分别分给新机器一点数据就行。然后维护一个中心节点，来记录管理数据区间在机器上的分布，按规则去取即可。&lt;/p&gt;
&lt;h2 id="再将数据组合起来"&gt;再将数据组合起来&lt;/h2&gt;
&lt;p&gt;将数据拆碎后，要实现类似 MySQL 的功能，还差得远。当一条 SQL 进来，MySQL 直接扫描索引或文件获取数据，而 TiDB 将数据拆散了按区间分布在多台机器上。这就意味这需要先解析 SQL，知道要取什么数据，然后根据数据分布记录，去对应的机器上将数据取出后组合起来返回给 client。有点类似数据库中间件的角色。但由于 TiDB 极细粒度的数据拆分，使得灵活性和适应性远远超过了中间件。 &lt;/p&gt;

&lt;p&gt;TiDB 的架构也就是如下图：
&lt;img src="https://download.pingcap.com/images/blog-cn/tidb-internal-2/1.png" title="" alt=""&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TiKV 按区间均分了数据库中的数据，数据量变大，加 TiKV 机器就行（区间按 Key 全局排序）&lt;/li&gt;
&lt;li&gt;TiDB Server 表达了一个 SQL 层，解析 SQL，根据数据区间的分布，去对应的 TiKV 取数据后同一聚合处理&lt;/li&gt;
&lt;li&gt;TiDB Server 无状态，client 随机连一个就行，负荷变大时，直接加机器就行&lt;/li&gt;
&lt;li&gt;PD 是全局调度，维护区间分布数据、保障数据、负荷等均匀&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;可以看出，这是以极高的架构复杂度、运维复杂度来换取灵活可扩展能力。也相当于将以前各种数据库中间件的复杂度统一整合、收拢起来，对外屏蔽复杂度，只暴露简单的操作接口。这也代表了技术沉淀和迭代的统一方向，web 领域的组件化、框架化、工具化，在发展趋势上本质也是一样的。&lt;/p&gt;

&lt;p&gt;由于水平有限，以上只简单介绍了 TiDB 零星的原理，关于横向扩展的基本原理。未能涉及 TiDB 庞大而复杂的系统，如在此架构中如何做到 ACID、高可用等等，更多的信息请阅读 PingCap 超高质量的官方技术文档：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://pingcap.com/docs-cn/" rel="nofollow" target="_blank" title=""&gt;https://pingcap.com/docs-cn/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>early</author>
      <pubDate>Sun, 15 Mar 2020 21:16:50 +0800</pubDate>
      <link>https://ruby-china.org/topics/39589</link>
      <guid>https://ruby-china.org/topics/39589</guid>
    </item>
    <item>
      <title>一文理解 Redis Cluster</title>
      <description>&lt;p&gt;通过 Redis 可以很简单的利用多种数据结构实现复杂的业务。最常见的莫过于缓存服务这一互联网不可或缺的重要部分，本文也将顺着缓存的思路梳理 Redis Cluster 的样貌，以对其形成更加扎实的认识。&lt;/p&gt;
&lt;h2 id="缓存的需求"&gt;&lt;strong&gt;缓存的需求&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;新起一个业务时，因为早期流量稀少，且有快速交付产品的需求，工程师们往往会选择直接读写数据库。&lt;/p&gt;

&lt;p&gt;而当流量逐渐上升，DB 负荷增加，慢慢地响应变慢，甚至有宕机的风险，此时需要增加一层缓存，避免频繁读取数据库，结构类似下面：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/2020/b94cc8c2-d8fa-4421-9f14-07fd3bec2a4e.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;当流量增加时，可以增加应用服务器的数量，进行横向扩展。不过，慢慢地 Redis 缓存服务就会遇到两大问题：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;单机 Redis 内存大小有限，已经放不下缓存的数据了&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;业务流量太高，所有请求都会经过 Redis，到达 QPS 极限&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这是单机模式在流量和数据量增大时必然会遇到的问题，本质上是单台计算机在内存和网络吞吐上的存在容量极限。基于当前计算机的特点，解决此问题的方案有两个：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;大型机，用更强悍的大型机来暂时 hold 住当前的性能需求&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;分布式，将数据分散到多台机器上&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;一般情况下，长久可靠的方案一般都是采用数据分片的策略，让单机变成集群。将数据量和性能需求打散到多台机器上，分而治之。&lt;/p&gt;
&lt;h2 id="单机到集群"&gt;&lt;strong&gt;单机到集群&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;将数据和请求分散到多台机器上，可以较好的解决上面流量和数据量两个方面的问题。不过紧接着得思考：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  如何让流量分散到多台机器上？&lt;/li&gt;
&lt;li&gt;&lt;p&gt;如何让数据分散到多台机器上？&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;如何让后期集群扩容更加便捷？&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;流量本质上是读写数据时产生的，数据的分布会引导流量的分布，所以前两个问题本质上是一个问题。&lt;/p&gt;

&lt;p&gt;当前最广泛的数据分布策略是通过哈希算法，对 Redis 缓存来说，就是对 Key 计算哈希值，理想情况下的哈希值计算有几个特点：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;相同的 key，每次哈希计算的值都一样（可保证数据分布是稳定的）&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;不同的 key，哈希计算的结果是均匀分布的（可保证数据均匀分布在多台机器上）&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;对哈希值取模就可以得到数据应该分布到哪台机器上，这样便可以让数据分散到多台机器。对于上面的单机模式来讲，现在多了一部分工作，就是在读写 Key 时需要先计算一下 Key 应该在哪台机器上，为了将这部分工作收拢，Redis 集群代理这个角色便出现了，它负责计算 key 应该存放到哪个节点，对应的也知道去哪个节点取对应的数据。&lt;/p&gt;

&lt;p&gt;从应用服务器来看它就是一台 Redis 服务器，常见的有 twemproxy 等。&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/2020/f9c8d657-d29d-49eb-b751-43f87915c82b.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;流量会根据哈希取模的操作分散到多个 Redis 服务器上，所有流量通过代理统一分发，代理本身也可以扩展为多台，来分散流量，只要各个 Redis 代理使用相同的哈希取模策略即可。&lt;/p&gt;

&lt;p&gt;到此万事大吉，除了集群扩展的便捷性。上面的模式知道 Redis 实例的确切数量 N，在其基础上取模，而当 Redis 数量增加或减少时，此套机制则面临挑战。X mod N 的计算方式，当 N 发生变化时，大部分计算结果也会变化，这会导致大部分 Key“重新分布”，影响面很广。&lt;/p&gt;

&lt;p&gt;为了减轻节点数变化时对数据分布的影响，在代理选择数据分布时不能使用简单粗暴的哈希取模，而应该使用&lt;strong&gt;一致性哈希&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/2020/d8c573fe-f695-4a97-b596-4f7f27bc81ca.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;特定的&lt;strong&gt;哈希函数&lt;/strong&gt;输出值有特定的&lt;strong&gt;区间&lt;/strong&gt;，例如 [0, 2^32], 函数输出最小值 0，最大 2^32。一致性哈希将这个区间抽象地看作一个闭合的圆。&amp;nbsp;&lt;/p&gt;

&lt;p&gt;假设当前有 4 个实例，将各个 redis 实例做哈希计算 (可以用 ip:端口)，哈希值都会落到区间中，由于哈希函数的均匀性，这个闭合的圆会被切分成 4 个弧 (虚线)。&lt;/p&gt;

&lt;p&gt;Hash(key) 的值落到哪个弧上，则这个 key 的数据就存放到对应的 Redis 实例上，&amp;nbsp;&lt;strong&gt;每个 Redis 实例上汇集的数据都是 Key 的哈希值在一段特定的连续区间内&lt;/strong&gt;，而简单的哈希取模方式则汇聚的是断断续续分散的区间。当有 Redis 实例新增或移除时，对数据的分布影响是有限的，只会影响其本身和相近的弧中的部分数据，这使得集群扩展的代价小了很多。&lt;/p&gt;
&lt;h2 id="Redis Cluster"&gt;&lt;strong&gt;Redis Cluster&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;上面的群集方式，仍然有大概两个缺点：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;需要中心化的代理，有性能损耗，且难支持 pipeline&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;节点增减时数据迁移困难，难以搞清楚到底需要迁移哪些数据&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Redis Cluter 实现了完全去中心化、线性扩展的集群方案。针对数据迁移的问题，Redis 提出了&lt;strong&gt;哈希槽&lt;/strong&gt;的概念，每个集群中固定有 [0,16383]，总共 16384 个槽，每个 key 属于某个特定的哈希槽，通过&lt;strong&gt;CRC16&lt;/strong&gt;算法和公式&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;slot=CRC16(key)/16384
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;来计算 key 属于哪个槽。这些槽会被大致均匀地分布在集群的实例上。如果有三个实例 A, B, C：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Node A contains hash slots from 0 to 5500.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Node B contains hash slots from 5501 to 11000.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Node C contains hash slots from 11001 to 16383.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;理论上集群最多可以有 16384 个节点，每个节点负责一个槽。&lt;/p&gt;

&lt;p&gt;哈希槽的设计对节点数据迁移很友好，节点会记录每个槽中存在哪些 key，当要新加入一个节点 D 时，在 ABC 中分别挑一部分槽的数据移动到 D 就行，移除也是一样，集群可便捷地进行线性扩展。&lt;/p&gt;

&lt;p&gt;Redis Cluster 没有中心化的代理节点，集群中的每个节点都可以暴露出来作为 client 节点接受读写请求。&lt;/p&gt;

&lt;p&gt;但是每个节点实际上只有一部分数据，通过公式可以知道，每个 key 其实是确切地属于某个哈希槽的，每个节点负责的哈希槽其实也是确定的。集群中每个节点都有一份数据分布“地图”，每个节点都知道 key 应该在哪个实例上，映射关系被保存在一个数组中，通过 index 可以 O(1) 读取：&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/2020/7e579c83-da9a-4c64-a6af-3fe1295e27e6.jpeg!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;当有请求到达某个节点，但 key 没有存放到自己的节点上时，Redis 会返回特定的 MOVED 重定向，告诉请求方，这个 key 属于哪个槽，在哪个节点上 (ip:port)。&amp;nbsp;&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET&amp;nbsp;xxxxx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对于常规的请求方就非常痛苦了，因为还要处理重定向的情况。&lt;/p&gt;

&lt;p&gt;这对 client 的要求一下子变高了，每个 client 需要知道上图的数据分布，维护和每个 Redis 实例的连接，每个请求前先计算 key 在哪个实例上，再选择对应的连接将请求发送出去。同时类似 pipeline 的操作将变得更加复杂。如果数据分布发生了变化，client 还需要及时进行更新，这种新的 client，被称为 Smart Client，也就是常说的 SDK，这里有更多的信息 (&lt;a href="https://redis.io/clients" rel="nofollow" target="_blank"&gt;https://redis.io/clients&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;这种 Smart Client 开发难度较以前相对较大，而且每个 client 都需要维护一份分布数据，当集群分布发生变化时，每个 client 都得重新刷新分布数据状态，这并不算一个好的结果，因为集群复杂性暴露给了调用方，即使只是 SDK。&lt;/p&gt;

&lt;p&gt;为了应对这种情况，对 client 屏蔽集群信息，一种新的代理又重新出现，代理会维护集群数据分布的数据，可以集中地支持像 pipeline 这样的操作，让 client 保持简单。饿了么的 corvus 就是其中一种实现 (&lt;a href="https://github.com/eleme/corvusSmart" rel="nofollow" target="_blank"&gt;https://github.com/eleme/corvusSmart&lt;/a&gt;)，它实际上就是一个 Proxy，实现了 Smart Client 处理集群信息的功能，有几个明显的优点：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;让 Client 保持简单，不必关心集群复杂性&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Proxy 可以简单扩展到多实例，无中心化问题，因为 Redis Cluster 的数据分布策略是稳定不变的&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;可以集中支持 pipeline 等操作，也可以同时支持 memecache 等协议&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这种和业务解耦的 Smart Proxy 模式被广泛的使用，当然代价是会增加分布式系统的复杂性，适合有明确团队分工的中大厂使用。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;分布式特性&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;从单节点变成集群，分布式复杂性一下变高，有几个问题值得思考：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;节点间的状态、槽归属、主从信息如何同步？&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;如何保证信息同步的可靠性？&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;第一个问题有个常规的解决方案，就是用一个状态同步服务进行统一管理，比如 zookeeper。所有节点将自己的状态上报 zookeeper 统一汇总，然后和各节点实时同步。&lt;/p&gt;

&lt;p&gt;这种中心化的处理方式实时性、可靠性都较高，但引入了新的依赖点，依赖点本身的性能和可靠可能会引发新的问题。redis cluster 选择了实时性相对较低的去中心化方案。&lt;/p&gt;

&lt;p&gt;信息的同步通过大概两种方式进行：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;节点间定时 (clusterCron) 相互发送心跳包 (ping) 来探测健康度、发现新节点、交换信息等&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;全局广播 (遍历节点分别发消息) 来更新关键的信息 (槽归属变更、主从变更、节点故障、索取投票等)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;定时任务每 100ms 执行一次，遍历所有节点，分别发送 ping 消息，消息体 (clusterMsg) 分为两个部分：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;header，&amp;nbsp;包含节点自己的信息，自己负责的槽、名字、ip 端口、状态等等&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;消息体 (clusterMsgData), 在&lt;strong&gt;节点列表&lt;/strong&gt;中随机选 1/10 个 (至少 2) 其他节点的信息 (ip 端口、名称、最近心跳时间等)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;每个节点在收到 ping 后会回复 pong 消息作为回应，pong 消息体内容和 ping 消息类似，除了返回自己本身的信息，还会一些其他节点的信息，实现信息互换。&lt;/p&gt;

&lt;p&gt;节点收到 ping 或 pong 消息后，会检查节点信息，如果没有和对应节点发生过链接，则会将其加到自己的节点列表中 (会去 ping)。这样便实现了节点&lt;strong&gt;自动发现，&lt;/strong&gt;新加入的节点一会儿就可以被所有节点知道。&lt;/p&gt;

&lt;p&gt;这种传播信息的方式被称作&lt;strong&gt;gossip&lt;/strong&gt;协议。&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/2020/27628ac2-599c-48ee-b5d5-3e53b555a080.png!large" title="" alt=""&gt;&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;union clusterMsgData { /*集群消息详情*/
    /* PING, MEET and PONG */
    struct {
        /* ping/pong/meet 交换的节点信息放在这个数组中 */
        clusterMsgDataGossip gossip[1];
    } ping;
    /* FAIL 谁挂了*/
    struct {
        clusterMsgDataFail about;
    } fail;
    /* PUBLISH pub/sub的支持*/
    struct {
        clusterMsgDataPublish msg;
    } publish;
    /* UPDATE 更新槽的归属 */
    struct {
        clusterMsgDataUpdate nodecfg;
    } update;
};
typedef struct {
    char sig[4];        /* Siganture "RCmb"  */
    uint32_t totlen;    /* Total length of this message */
    uint16_t ver;       /* Protocol version, currently set to 0. */
    uint16_t notused0;  /* 2 bytes not used. */
    uint16_t type;      /* ping pong publish fail update 等几种 */
    uint16_t count;     /* Only used for some kind of messages. */
    uint64_t currentEpoch;  /* 当前节点的epoch，用作分布式环境一致性校验 */
    uint64_t configEpoch;   /* The config epoch if it's a master, or the last
                               epoch advertised by its master if it is a
                               slave. */
    uint64_t offset;    /* Master replication offset if node is a master or
                           processed replication offset if node is a slave. */
    char sender[CLUSTER_NAMELEN]; /* Name of the sender node */
    unsigned char myslots[CLUSTER_SLOTS/8]; /*一个bitmap记录当前负责的槽*/
    char slaveof[CLUSTER_NAMELEN];
    char notused1[32];  /* 32 bytes reserved for future usage. */
    uint16_t port;      /* Sender TCP base port */
    uint16_t flags;     /* Sender node flags */
    unsigned char state; /* Cluster state from the POV of the sender */
    unsigned char mflags[3]; /* Message flags: CLUSTERMSG_FLAG[012]_... */
    union clusterMsgData data;  /*集群消息详情*/
} clusterMsg; /* 集群消息*/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;集群消息通过上面的两个结构体封装，clusterMsg 中：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;clusterMsgData data&amp;nbsp; 是集群消息详情，包含消息的内容&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;其他的就是消息的 header，主要包含发送者的信息&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;总结一下，信息通过集群消息进行同步，有两种形式：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;ping/pong口口相传，信息像谣言一样在集群中一传十十传百。用于新节点发现、问题节点投票、主从关系、复制偏移量等等，实时性较低。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;直接通知，遍历每个节点，依次通知。用于宣布新的槽归属、下线某个故障的 master 节点、slave 转为 master 等等，实时性相对较高。&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;集群的信息同步问题，通过 gossip 和直接通知的方式解决了。那么如何解决信息的可靠性呢？&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;假设 A 和 B 同时宣布自己负责 10～100 号槽，该信谁？&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;假设 C 的更新信息由于网络问题延迟到达了，是否会覆盖新数据？&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;在单机环境中，这个问题相对好解决，事件发生时取一个时间戳，数据以时间戳大为准。&lt;/p&gt;

&lt;p&gt;但在去中心化的集群中，时钟无法保证同步。需要额外想办法解决，redis cluster 选择的方案是：逻辑时钟。&lt;/p&gt;

&lt;p&gt;每个节点都有一个逻辑时钟，是一个整型数字。每当集群中有更新事件发生时这个时钟会加一，上文结构体中的 configEpoch 就是当前节点的逻辑时钟值，用来管理槽归属等事件。&lt;/p&gt;

&lt;p&gt;每个节点收到其他节点的集群消息时，会去比对 configEpoch 的值。如果发送方的 configEpoch&amp;nbsp;大于本地的 configEpoch 值，说明有新的更新事件发生，则将发送方带来的数据更新到本地。&lt;/p&gt;

&lt;p&gt;否则会忽略这条消息中的节点信息。&lt;/p&gt;

&lt;p&gt;当某个节点要发送更新消息时，它会先将 configEpoch 更新，然后再发送集群消息，这样才会被其他节点认可。同样的策略在于 slave 提升为 master 时一样，不过用了另一个独立的逻辑时钟 currentEpoch。&lt;/p&gt;

&lt;p&gt;由于新的 configEpoch 值是通过加一来创建的，这就必然导致多个节点持有的 configEpoch 会重复，导致无法判断事件的发生顺序。如果冲突的节点各自负责的槽完全不相关，那么不会出问题，但真实场景肯定会有重叠的情况。&lt;/p&gt;

&lt;p&gt;为了解决这个问题，redis cluster 设计了一套算法来保证各个节点持有的 configEpoch 彼此唯一：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;如果一个 master 节点发现其他 master 持有相同的 configEpoch。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;并且此 master 逻辑上持有较小的 nodeID（字典序）&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;然后此 master 将自己的 currentEpoch 加 1，并作为自己新的 configEpoch。（自己检测，然后更新，在 ping 时会带出去）&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;如果更新后继续遇到重复的 configEpoch，那么重复走上面的逻辑，直到没有冲突。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;槽迁移过程&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;当集群中新增一个节点时，首先通过 meet 让其被集群中其他节点发现并彼此相连。随后则需要迁移部分槽到新节点上，这涉及槽归属的变更，同时需要将此修改信息同步到各个节点，下面来看一下整个过程。&lt;/p&gt;

&lt;p&gt;通过 redis-trib reshard 可以迁移部分槽到新的节点，假设实际上是从 A 节点迁移 10～100 号槽到新节点 B。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A 上槽 10～100 的状态设置为 MIGRATING&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;B 上槽 10～100 的状态被设置为 IMPORTING&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;通过 migrate 将对应槽中的 key 从 A 拷贝到 B&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;当槽迁移完毕后，会清除 A 和 B 的对应槽的迁移状态&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;B 在清除状态时，&lt;strong&gt;会发送 update 消息告诉其他节点新的槽归属&lt;/strong&gt;(configEpoch 会更新)，其他节点会更新状态，告诉 client 重定向到 B&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;如果在迁移过程中，client 对槽中的 key 发起请求：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;迁移过程中，如果对应 key 还在 A 上，则 A 会正常处理。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;如果 key 已经拷贝到 B，但还未迁移完毕，此时 A 会将 client 的请求临时重定向到 B（ASK 错误，表示 key 正在迁移到 B 节点）。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;client 会向 B 节点发送 ASKING，随后发起向 B 发起重试&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;B 根据 ASKING 命令设置的状态，会处理对应 key（此时 B 还未清除 IMPORTING 状态）&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;当 A 和 B 都成功清除了迁移状态，还未更新状态的 client 会收到其他节点的 MOVED 重定向，client 记录本次重定向关系后，一切回复正常。&lt;/p&gt;

&lt;p&gt;redis cluster 在这个地方有个 bug(本文基于 3.2 源码)，在清除 A 和 B 的状态时，必须要先清除 B 的状态，让 B 先广播槽的归属，再清除 A 的状态。 &lt;/p&gt;

&lt;p&gt;如果 A 先清除状态，到 B 成功广播槽归属的这段时间内，槽的归属是混沌的。&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A 会将请求重定向到 B，因为 A 知道槽已经迁移到了 B&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;B 会将请求重新重定向到 A（因为状态还未清除，未迁移完毕）&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;在这个短暂的间隙中，请求对应 key 的 client 会收到连续的重定向，导致报错。&amp;nbsp; redis-trib 脚本在清除状态时，有时会先清除 A 再清除 B，就会导致上面的问题。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;故障转移&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;最后简单介绍一下故障转移，当某个 master 一下宕机了：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;其他节点如何感知？&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;如何让对应 master 的 slave 接管？&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;节点间 ping/pong 会交换大概 1/10 的节点信息，信息中包含节点的状态。当节点 A ping 节点 B，但未在指定时间内得到回复时，A 就认为 B 疑似宕机了。&lt;/p&gt;

&lt;p&gt;在 A 和 C 交换信息时，如果 B 在交换的节点列表中，那么 C 就会得知 A 认为 B 疑似宕机了，此时 C 会在自己本地记录中将 B 疑似宕机的计数加一。&lt;/p&gt;

&lt;p&gt;B 疑似宕机的消息会在集群中慢慢传播开来，如果其他节点也发现 B 没有响应了，也会通过 ping 告诉其他节点。当某个节点 D 发现自己本地记录的 B 疑似宕机计数值&amp;gt;(集群节点数/2 + 1) 时 (计数有一个时效性)，说明集群中一半以上的节点都认为 B 宕机了。 &lt;/p&gt;

&lt;p&gt;此时 D 会广播一个 fail 消息，正式公布 B 宕机的消息，B 对应的 slave 也会收到消息。&lt;/p&gt;

&lt;p&gt;此时 B 的一个或多个 slave 会更具相应的算法计算优先级 (数据同步状态等)，某个优先级高的 slave 会广播 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 消息，寻求各个 master 投票同意它成为新的 master(currentEpoch++)。&lt;/p&gt;

&lt;p&gt;如果在当前 currentEpoch 内，有超过一半的 master 同意 slave 成为新的 master，那么这个 slave 就会做两件事： &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;标记前 master 的槽全部由它接管、设置自己为 master &lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;将这个状态广播出去，实现故障转移&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;如果投票未能过半数，其他 slave 节点会继续重试，直到故障成功转移。client 和新的 master 建立新链接，服务恢复正常。&lt;/p&gt;

&lt;p&gt;（&lt;a href="https://mp.weixin.qq.com/s/WqKzF4i5OP8xSg6DwhkVbg" rel="nofollow" target="_blank" title=""&gt;本文写于 2019 年 8 月&lt;/a&gt;）&lt;/p&gt;</description>
      <author>early</author>
      <pubDate>Sat, 14 Mar 2020 10:35:50 +0800</pubDate>
      <link>https://ruby-china.org/topics/39586</link>
      <guid>https://ruby-china.org/topics/39586</guid>
    </item>
    <item>
      <title>直播 -- 弹幕系统简介</title>
      <description>&lt;h2 id="弹幕是什么"&gt;弹幕是什么&lt;/h2&gt;
&lt;p&gt;看过直播的人都难免会注意到屏幕上方飘过的留言，以用户端输入为主，很多视频中也有这种内容的呈现形态。&lt;/p&gt;

&lt;p&gt;如果要更细致地理解直播弹幕，用类比法，可以是：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;一种“群”消息，将某个用户 (也有系统) 产生的消息实时广播给全体“群成员”&lt;/li&gt;
&lt;li&gt;这个群动辄数百万成员&lt;/li&gt;
&lt;li&gt;这个群是临时的 (进了某个直播间，一会儿又退出了)&lt;/li&gt;
&lt;li&gt;消息基于直播间，进直播间才能收到消息&lt;/li&gt;
&lt;li&gt;这种群消息可分布在多端。APP、Web、H5&lt;/li&gt;
&lt;li&gt;这种消息阅后即焚，因为和视频画面呼应才有意义 (当然服务端会存档)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;简单点讲，弹幕消息就是一种群消息，所以技术界是将弹幕归类为 IM(即时通讯) 范畴，它背后是实时消息。弹幕是实时消息中能被用户看到的内容，还有一部分是系统消息，用来主动触发业务逻辑、行为等。&lt;/p&gt;

&lt;p&gt;在直播界，弹幕是一个举足轻重的角色，作为视觉和信息的直接载体，它身上承接了极重的业务形态：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;广播出和画面内容呼应的内容&lt;/li&gt;
&lt;li&gt;弹幕内容根据用户身份做特殊呈现 (给了钱的更显眼)&lt;/li&gt;
&lt;li&gt;弹幕触发其他业务，例如抽奖&lt;/li&gt;
&lt;li&gt;内容、频率要做管控&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这需要有一个中心化的弹幕业务节点，来处理业务逻辑，构造特色化的内容，然后让 IM 系统吐出。所以从系统角度来看，弹幕系统其实是&lt;code&gt;业务系统+IM系统&lt;/code&gt; 合作而成的。架构上 IM 系统要和业务解耦，IM 系统只知道将一堆数据吐给对应的用户。&lt;/p&gt;

&lt;p&gt;业务系统只是一些繁杂的逻辑组合，并无特别之处。解下来我们将目光转到&lt;code&gt;实时消息如何到达用户&lt;/code&gt;这个问题上来。&lt;/p&gt;
&lt;h2 id="挑战"&gt;挑战&lt;/h2&gt;
&lt;p&gt;弹幕的主要有两个挑战：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“群成员”太多，而且数量不稳定，一份消息产生后，需要实时广播给所有成员，消息体量会被放大百万、千万倍。&lt;/li&gt;
&lt;li&gt;群成员分散各地、跨运营商、跨平台。消息稳定性难保证。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;其实这也是 IM 的主要挑战。&lt;/p&gt;
&lt;h2 id="解决方案"&gt;解决方案&lt;/h2&gt;
&lt;p&gt;纵观国内解决方案，跳得最高、资料最丰富的是&lt;a href="https://github.com/Terry-Mao/goim" rel="nofollow" target="_blank" title=""&gt;goim&lt;/a&gt;，在业界的接纳度较高，也是用在 bilibili 生产环境的方案。&lt;/p&gt;

&lt;p&gt;在一头扎进 goim 之前，我们先来简单梳理一下 IM 的需求及可能的模块，在软件工程目标下做一次 pre 构想，再来看 goim 是如何实现这些目标。&lt;/p&gt;

&lt;p&gt;弹幕可见的模块有：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;连接管理层。处理和用户连接、传输数据给指定的目标用户。&lt;/li&gt;
&lt;li&gt;业务逻辑层。黑名单 IP、黑名单用户、附加信息、在线数统计等等&lt;/li&gt;
&lt;li&gt;数据统计层。哪些房间有多少人，某个用户连接情况等等&lt;/li&gt;
&lt;li&gt;数据处理层。接收消息请求，并转发到连接层&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;当用户体量小时，以上模块用一个简单的单体服务也可以实现，把逻辑写在一起，不用管什么层级。但是当用户规模大、稳定性要求高、成本敏感时，就需要拥抱软件工程的目标：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;独立可扩展&lt;/li&gt;
&lt;li&gt;健壮性&lt;/li&gt;
&lt;li&gt;高迭代效率&lt;/li&gt;
&lt;li&gt;鲁棒性 [1]&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;一般技术事故都是两个原因，一是外部流量洪峰；二是内部变化，例如改了代码、触发了部署。稳定性和迭代是矛盾的，那该如何尽可能既要熊掌又要鱼呢？&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;根据易变性、伸缩性、业务等拆分模块，让各自独立，通过网络交互，解耦合。&lt;/li&gt;
&lt;li&gt;通过系统架构设计保证，当流量洪峰来临，只需要简单扩容就可应对。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;例如&lt;code&gt;业务逻辑层&lt;/code&gt;就是经常变化的，代码改动概率很大。这时候业务逻辑代码的变化、部署在理论上不能影响到&lt;code&gt;连接管理层&lt;/code&gt;部分。相反连接管理部分一旦稳定变化会很少，但其伸缩需求大，要经常扩缩容，它的部署过程也不能影响其他模块。&lt;/p&gt;
&lt;h2 id="goim简介"&gt;goim 简介&lt;/h2&gt;
&lt;p&gt;梳理完目标，我们来看一下 goim 是如何设计，如何实现上面的目标。
goim 抽象出了四大模块：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;comet，用户连接，支持多协议，通过扩容可支撑海量用户&lt;/li&gt;
&lt;li&gt;job, 消息转发，将弹幕等消息转给 comet，最后到达用户。实现为消息队列&lt;/li&gt;
&lt;li&gt;logic，业务逻辑处理、投递消息给 job，无状态&lt;/li&gt;
&lt;li&gt;router，用户会话管理，数据统计。(最初 router 的意义 [2]，bilibili 最新实践中统计用 redis 实现，router 合并到了 logic。本文为科普暂保留 router 这个概念)&lt;/li&gt;
&lt;li&gt;依赖消息队列、服务发现&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;整体结构如下图：
&lt;img src="https://l.ruby-china.com/photo/2020/98ac4963-75fc-476f-bd8c-cfe46363f3db.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;各层通过 rpc 或消息队列交互。各层可以直接扩容，新的地址会通过服务发现通知到各层。看似系统设计的很复杂没有必要，其实就像有些人反对微服务或容器化一样，根本问题是他的系统的规模不够大，对稳定性、研发效率要求不高。到一定规模后，细化拆分、分而治之是唯一的路，但也得寻找适合当前的架构，网上有很多自己改的例子 [5]。 （更详细的资料建议查阅 goim 资料。）&lt;/p&gt;

&lt;p&gt;接下来回答几个问题：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;一条弹幕消息的链路是什么？&lt;/strong&gt; 如上图红色 (1)~(6) 所示。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;用户如何连接到 comet 节点？&lt;/strong&gt; 当用户进房间之后，通过接口拉取 comet 可用节点，直接连接，这个接口可以根据用户的 IP 等随机吐出 comet 节点，实现负载均衡。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;用户是随机散在所有 comet 节点上的，同一个房间里的用户也是散在所有 comet 节点上，此时房间弹幕如何发给每一个用户？&lt;/strong&gt;  对于房间弹幕，job 会从 router 中获取对应房间 id 的用户散落在哪些 comet 节点上，将信息同时转发给所有 comet 节点，每个 comet 会将消息发给房间内的用户。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;comet 上如何管理用户？&lt;/strong&gt; 用户连接 comet 后，会创建 channel 对象，管理连接。comet 上会构建多个 Bucket，通过 RoomId 取余找到目标 Bucket，此举为了降低锁竞争。Bucket 中有一个 Room 对象是 Hash，通过 RoomId 哈希取值，将该 channel 指针追加到对象尾部，遍历这个对象就可以找到对应房间里所有用户。同时 Bucket 有 Channel 哈希对象，将 channel 放入哈希中，可便捷找到对应用户。[3][6]&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;系统能否保证必达？&lt;/strong&gt; 不能，该方案未提供消息存放能力和用户拉取能力，推拉结合才能保证必达。而进房间这个事件有较大的临时性，房间弹幕必达需求也不够强烈。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="网络问题"&gt;网络问题&lt;/h2&gt;
&lt;p&gt;行文到此，弹幕系统已经简要理清楚了，但这样的系统会有掉线率高、不稳定的问题，需要在网络结构上做调整。[4]&lt;/p&gt;

&lt;p&gt;comet 和用户之间的长连接如果随意地通过公网连接，会因为地域、运营商的差异导致连接质量，为了解决这种问题，需要上 CDN 级的手段，例如：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;comet 需要同时部署到几大运营商的机房内&lt;/li&gt;
&lt;li&gt;comet 需要部署到多个区域&lt;/li&gt;
&lt;li&gt;除 comet 外的部署到一个或多个中心机房，中心机房和各运营商 comet 所在机房需要有高质量的网络通道&lt;/li&gt;
&lt;li&gt;comet 节点的选取依靠服务商的 DNS 调度，根据地区、运营商选择用户最适合的节点&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;结构如下图：
&lt;img src="https://l.ruby-china.com/photo/2020/53998448-a352-4f57-bfaa-6952d7e99c59.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;这种结构让服务发现变得难以实施，退而求其次可能需要通过配置文件写死各个服务地址，各个服务实现信号量检测热更新配置，或者对外只暴露代理地址。同时，跨公网调用风险极高，需要一大堆防火墙等策略保证安全。&lt;/p&gt;
&lt;h2 id="参考资料"&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[1] &lt;a href="https://blog.csdn.net/bigpudding24/article/details/49069805" rel="nofollow" target="_blank" title=""&gt;https://blog.csdn.net/bigpudding24/article/details/49069805&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[2] &lt;a href="https://github.com/Terry-Mao/goim/issues/33" rel="nofollow" target="_blank" title=""&gt;https://github.com/Terry-Mao/goim/issues/33&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[3] &lt;a href="https://github.com/Terry-Mao/goim" rel="nofollow" target="_blank" title=""&gt;https://github.com/Terry-Mao/goim&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[4] &lt;a href="https://juejin.im/entry/57b27e2ca341310060fbc40e" rel="nofollow" target="_blank" title=""&gt;Bilibili 高并发实时弹幕系统的实战之路&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[5] &lt;a href="https://juejin.im/post/5cbb9e68e51d456e51614aab" rel="nofollow" target="_blank" title=""&gt;goim 改造&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[6]&lt;a href="https://www.jianshu.com/p/aa8be29397ec" rel="nofollow" target="_blank" title=""&gt;源码分析&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="相关话题"&gt;相关话题&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/39187" title=""&gt;直播 (上) -- 底层逻辑浅析&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/39254" title=""&gt;直播 (中) -- 核心流程梳理&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ruby-china.org/topics/39328" title=""&gt;直播 (下) --- 业务结构简介&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>early</author>
      <pubDate>Sun, 08 Mar 2020 17:17:27 +0800</pubDate>
      <link>https://ruby-china.org/topics/39574</link>
      <guid>https://ruby-china.org/topics/39574</guid>
    </item>
  </channel>
</rss>
