<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>流处理 on 方糖猫Blog</title>
    <link>https://ftmao.com/blog/tags/%E6%B5%81%E5%A4%84%E7%90%86/</link>
    <description>AI 视频、AI Agent 与工程实践笔记</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <lastBuildDate>Fri, 02 Oct 2026 19:00:00 +0800</lastBuildDate><atom:link href="https://ftmao.com/blog/tags/%E6%B5%81%E5%A4%84%E7%90%86/index.xml" rel="self" type="application/rss+xml" /><item>
      <title>Spark 与 Flink 入门：批与流的两种血统，视频公司里各干什么活</title>
      <link>https://ftmao.com/blog/posts/spark-flink-basics-video-company/</link>
      <pubDate>Fri, 02 Oct 2026 19:00:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/spark-flink-basics-video-company/</guid>
      <description>&lt;h1 id=&#34;spark-与-flink-入门批与流的两种血统视频公司里各干什么活&#34;&gt;Spark 与 Flink 入门：批与流的两种血统，视频公司里各干什么活&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;目标读者：四类人——&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;后端/数据工程师&lt;/strong&gt;：要上手 Spark 或 Flink，需要心智模型加能跑的代码（看&amp;quot;基础&amp;quot;两节，附 Java API 示例）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;架构师/技术负责人&lt;/strong&gt;：在给数据平台选型，想知道两者的边界和硬件投入（看&amp;quot;血统对比&amp;quot;&amp;ldquo;算力规格&amp;quot;和&amp;quot;选型决策&amp;rdquo;）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;运维/SRE&lt;/strong&gt;：要看监控什么、出事怎么处理（看&amp;quot;监控&amp;quot;和&amp;quot;故障与恢复&amp;quot;）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;正在踩坑的人&lt;/strong&gt;：直接翻到文末两家的避坑清单。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;阅读前提：了解 Kafka 是什么即可，不需要大数据背景。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;前两篇讲推荐 Feed 架构时，Spark 和 Flink 反复出现：Spark 做离线训练，Flink 做近线特征。有读者问：Spark 不就是内存分布式计算吗，和 Flink 功能差不多？&lt;/p&gt;
&lt;p&gt;看着像，血统完全不同。这篇把两者各自的基础概念讲清，然后放进一家类 Netflix 的视频公司里看它们分别干什么活，最后附上两家的避坑指南。&lt;/p&gt;
&lt;h2 id=&#34;spark-基础批处理血统的内存计算引擎&#34;&gt;Spark 基础：批处理血统的内存计算引擎&lt;/h2&gt;
&lt;p&gt;Spark 在 2010 年前后接棒 Hadoop MapReduce，核心卖点一句话：&lt;strong&gt;把中间结果放内存，迭代计算不用反复落盘&lt;/strong&gt;。MapReduce 每轮迭代都要读写 HDFS，Spark 把数据缓存进内存，迭代型任务（机器学习训练要扫数据几十遍）快了十到一百倍。&lt;/p&gt;
&lt;p&gt;三个核心概念：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RDD / DataFrame&lt;/strong&gt;：数据被抽象成分布式集合，切成分区（partition）摊在集群各节点上。RDD 是原始 API，现在日常用的是 DataFrame——带结构（schema）的表，配合 Spark SQL 直接写 SQL。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DAG 执行&lt;/strong&gt;：你对数据做的一串操作（filter、join、groupBy）不会立即执行，而是攒成一张有向无环图；遇到&amp;quot;要结果&amp;quot;的动作（count、写盘）才触发，调度器把图切成 stage、分发执行。惰性求值让引擎有机会做全局优化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shuffle&lt;/strong&gt;：数据按 key 重新分发的操作（groupBy、join 都会触发），是 Spark 作业里最贵的一步——数据要跨节点搬运、序列化、落盘。后面避坑指南里一半的坑都和它有关。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;血统决定视角：在 Spark 眼里，数据是一批一批的&lt;/strong&gt;——有界的、静态的数据集。它的流处理能力（Structured Streaming）是后来在批的骨架上加的：把数据流切成一小批一小批，攒一批算一次（micro-batch）。延迟天然是秒级到分钟级。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;上手：一个 ETL 作业的 Java 写法&lt;/strong&gt;。场景：把昨天的播放原始日志清洗成&amp;quot;每视频日播放量&amp;quot;聚合表：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;SparkSession spark &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; SparkSession.&lt;span style=&#34;color:#a6e22e&#34;&gt;builder&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;appName&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;play-events-etl&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;getOrCreate&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 读：数据湖里昨天的原始播放事件（分区表）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;Dataset&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;Row&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; events &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; spark.&lt;span style=&#34;color:#a6e22e&#34;&gt;read&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;parquet&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;s3://warehouse/raw/play_events/dt=2026-10-01/&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 处理：过滤脏数据、按 event_id 去重、换算播放分钟数&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;Dataset&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;Row&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; cleaned &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; events
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;filter&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;duration &amp;gt; 0 AND user_id IS NOT NULL&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;dropDuplicates&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;event_id&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;withColumn&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;play_minutes&amp;#34;&lt;/span&gt;, col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;duration&amp;#34;&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;divide&lt;/span&gt;(60));
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// join 内容维度表补标题和类目（小表广播，避免 shuffle）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;Dataset&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;Row&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; enriched &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; cleaned
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;join&lt;/span&gt;(broadcast(spark.&lt;span style=&#34;color:#a6e22e&#34;&gt;read&lt;/span&gt;().&lt;span style=&#34;color:#a6e22e&#34;&gt;parquet&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;s3://warehouse/dim/videos/&amp;#34;&lt;/span&gt;)), &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;video_id&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 聚合出结果并写回数仓（coalesce 控制输出文件数，防止小文件）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;enriched.&lt;span style=&#34;color:#a6e22e&#34;&gt;groupBy&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;video_id&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;category&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;agg&lt;/span&gt;(count(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;*&amp;#34;&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;alias&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;plays&amp;#34;&lt;/span&gt;), sum(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;play_minutes&amp;#34;&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;alias&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;total_minutes&amp;#34;&lt;/span&gt;))
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;coalesce&lt;/span&gt;(4)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;write&lt;/span&gt;()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;mode&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;overwrite&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;parquet&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;s3://warehouse/agg/daily_video_plays/dt=2026-10-01/&amp;#34;&lt;/span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;注意这段代码提交后&lt;strong&gt;什么都不会立刻发生&lt;/strong&gt;——filter、join、groupBy 只是在攒 DAG，最后的 &lt;code&gt;write()&lt;/code&gt; 这个动作才触发真正的执行。这就是前面说的惰性求值。&lt;/p&gt;
&lt;p&gt;这段代码里还有一件&amp;quot;看不见&amp;quot;的事值得说破：&lt;strong&gt;分区是自动切的，你全程没有指定过它&lt;/strong&gt;。分区的三个来源：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;读入分区由文件决定&lt;/strong&gt;：读 parquet 时 Spark 按文件切块，&lt;code&gt;spark.sql.files.maxPartitionBytes&lt;/code&gt;（默认 128MB）决定每块多大，每块一个分区。路径里的 &lt;code&gt;dt=2026-10-01/&lt;/code&gt; 本身就是物理分区，Spark 只扫这一天的目录，其余按分区裁剪跳过；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;shuffle 后分区由配置决定&lt;/strong&gt;：&lt;code&gt;groupBy&lt;/code&gt; 触发数据重分发，之后的分区数由 &lt;code&gt;spark.sql.shuffle.partitions&lt;/code&gt; 控制，&lt;strong&gt;默认 200&lt;/strong&gt;——小作业跑完写出 200 个小文件，罪魁祸首就是它；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;手动干预只有两个 API&lt;/strong&gt;：&lt;code&gt;repartition(n)&lt;/code&gt; 重新洗牌（会产生一次 shuffle，贵），&lt;code&gt;coalesce(n)&lt;/code&gt; 只合并不洗牌（便宜）。上面代码写出前的 &lt;code&gt;coalesce(4)&lt;/code&gt; 就是故意把默认的 200 个分区合并成 4 个输出文件。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;那&amp;quot;拉起几十台机器并行计算&amp;quot;体现在哪？答案是：不体现在代码里，体现在物理执行里。&lt;/strong&gt; 同一份代码在一台笔记本上（local 模式）和一百台机器上跑，一行都不用改——分布式是部署特征，不是代码特征。看这段 ETL 提交到集群时实际发生什么：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;spark-submit &lt;span style=&#34;color:#ae81ff&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  --class com.example.PlayEventsETL &lt;span style=&#34;color:#ae81ff&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  --master yarn &lt;span style=&#34;color:#ae81ff&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  --num-executors &lt;span style=&#34;color:#ae81ff&#34;&gt;50&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;\ &lt;/span&gt;       &lt;span style=&#34;color:#75715e&#34;&gt;# 拉起 50 个 executor 进程，分布在集群各节点&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  --executor-cores &lt;span style=&#34;color:#ae81ff&#34;&gt;5&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;\ &lt;/span&gt;       &lt;span style=&#34;color:#75715e&#34;&gt;# 每个 executor 5 核&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  --executor-memory 20g &lt;span style=&#34;color:#ae81ff&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  --driver-memory 8g &lt;span style=&#34;color:#ae81ff&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  app.jar&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;这里的 &lt;code&gt;app.jar&lt;/code&gt; 是&lt;strong&gt;你自己的应用代码打包产物&lt;/strong&gt;：前面那段 ETL 的 Java 代码用 Maven/Gradle 编译，打成包含全部依赖的 fat jar（Maven 用 shade 插件、Gradle 用 shadow 插件），&lt;code&gt;--class&lt;/code&gt; 指定里面的入口类。提交时这个 jar 被上传到集群，driver 拿到后把代码分发到各 executor——&lt;strong&gt;executor 上跑的 task，执行的就是这个 jar 里的字节码&lt;/strong&gt;。所以 Spark 作业的交付物就是一个 jar：改代码 = 重新打包重新提交。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;生产环境里谁来&amp;quot;敲&amp;quot;这个 spark-submit？&lt;/strong&gt; 凌晨两点跑批不是值班同学手动敲命令。业界的提交方式按层次分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SparkLauncher&lt;/strong&gt;：Spark 官方自带的 Java API，在 Java 进程里编程式提交作业，还返回句柄可轮询状态——自研平台的标准做法：&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;SparkAppHandle handle &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; SparkLauncher()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setAppResource&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;app.jar&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setMainClass&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;com.example.PlayEventsETL&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setMaster&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;yarn&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setConf&lt;/span&gt;(SparkLauncher.&lt;span style=&#34;color:#a6e22e&#34;&gt;EXECUTOR_MEMORY&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;20g&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;startApplication&lt;/span&gt;();   &lt;span style=&#34;color:#f92672&#34;&gt;//&lt;/span&gt; 异步返回&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;，&lt;/span&gt;拿 handle 跟踪作业状态&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Apache Livy&lt;/strong&gt;：把提交做成 REST 服务——HTTP POST 一个 jar、轮询状态、拉日志。Web 系统里嵌入 Spark 提交能力的标准件；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;调度系统&lt;/strong&gt;：Apache DolphinScheduler、Airflow 这类工作流平台，管的是&amp;quot;凌晨 2 点跑 ETL、成功了再跑模型训练&amp;quot;这种依赖编排，底层调的仍然是 spark-submit 或 Livy；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;云厂商 API&lt;/strong&gt;：EMR Steps、Databricks Jobs 这类托管服务，提交动作变成一个云 API 调用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;顺带纠个常见的偏：Apache Commons 系列里没有 Spark 专用提交工具——Commons Exec 只是通用的外部进程包装器，有些自研平台拿它包 spark-submit 命令行（拿退出码、控超时），能用但不是主流。&lt;strong&gt;想在 Java 里提交作业，正路是 SparkLauncher 或 Livy。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;50 个 executor × 5 核 = &lt;strong&gt;250 个任务槽位同时干活，1TB 堆内存的总池子&lt;/strong&gt;。作业跑起来后，逻辑计划被拆成物理 stage，每个分区对应一个 task，task 被调度到空闲槽位上：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    A[&amp;#34;Stage 1 读取+过滤&amp;lt;br/&amp;gt;2TB parquet 按 128MB 切块&amp;lt;br/&amp;gt;约 16000 个分区 = 16000 个 task&amp;#34;] --&amp;gt; B[&amp;#34;Stage 2 join&amp;lt;br/&amp;gt;维度表广播到每个 executor&amp;lt;br/&amp;gt;无 shuffle&amp;#34;]
    B --&amp;gt; C[&amp;#34;shuffle 重分发&amp;lt;br/&amp;gt;按 video_id 搬运数据&amp;#34;]
    C --&amp;gt; D[&amp;#34;Stage 3 聚合+写盘&amp;lt;br/&amp;gt;200 个 task 各自聚合自己那份&amp;#34;]
    A -.-&amp;gt;|&amp;#34;250 个槽位并发&amp;lt;br/&amp;gt;16000 个 task 分批消化&amp;#34;| A&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;几个数字感：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一天 100 亿条播放事件约 2TB parquet，切成约 16000 个分区，每个分区一个 task——&lt;strong&gt;250 个槽位并发，分批消化完这 16000 个 task&lt;/strong&gt;，这就是&amp;quot;拉起多个节点&amp;quot;的物理形态；&lt;/li&gt;
&lt;li&gt;谁挂了谁重试：某个 executor 宕机，它跑过的 task 在别的节点上按血统重算，作业整体不停；&lt;/li&gt;
&lt;li&gt;想更快就加机器：&lt;code&gt;--num-executors 100&lt;/code&gt;，槽位翻倍，16000 个 task 消化速度就近似翻倍——&lt;strong&gt;这就是水平扩展：代码不变，吞吐随机器数线性涨&lt;/strong&gt;（直到被 shuffle 或存储 IO 卡住）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;ALS 训练是同一个道理，只是把&amp;quot;一批 task&amp;quot;换成&amp;quot;几十轮、每轮一批 task&amp;quot;：每一轮迭代都是一次全集群的分布式计算，中间结果留在内存里不落盘——这正是 Spark 相比 MapReduce 快的根源。&lt;/p&gt;
&lt;p&gt;所以读 Spark 代码的正确姿势是：&lt;strong&gt;代码是蓝图，集群按分区把蓝图实体化成成千上万的 task&lt;/strong&gt;。你在代码里看不到&amp;quot;多节点&amp;quot;，正如你在建筑图纸上看不到施工队——但图纸上的每一堵墙，都会变成某个工人在某个脚手架上的具体动作。&lt;/p&gt;
&lt;p&gt;机器学习也一样是 DataFrame 风格的 API。Spark MLlib 里自带 ALS（交替最小二乘，就是矩阵分解），拿交互数据训练推荐模型只要几行：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;ALS als &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; ALS()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setMaxIter&lt;/span&gt;(10)          &lt;span style=&#34;color:#75715e&#34;&gt;// 迭代轮数：交替优化用户矩阵和物品矩阵&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setRegParam&lt;/span&gt;(0.&lt;span style=&#34;color:#a6e22e&#34;&gt;1&lt;/span&gt;)        &lt;span style=&#34;color:#75715e&#34;&gt;// 正则化，防过拟合&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setUserCol&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setItemCol&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;video_id&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setRatingCol&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;score&amp;#34;&lt;/span&gt;)   &lt;span style=&#34;color:#75715e&#34;&gt;// 行为强度：完播=3 点赞=2 点击=1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setColdStartStrategy&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;drop&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;ALSModel model &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; als.&lt;span style=&#34;color:#a6e22e&#34;&gt;fit&lt;/span&gt;(playEvents);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 输出用户向量和物品向量，供线上召回使用&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;model.&lt;span style=&#34;color:#a6e22e&#34;&gt;userFactors&lt;/span&gt;().&lt;span style=&#34;color:#a6e22e&#34;&gt;write&lt;/span&gt;().&lt;span style=&#34;color:#a6e22e&#34;&gt;parquet&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;s3://warehouse/rec/user_factors/&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;model.&lt;span style=&#34;color:#a6e22e&#34;&gt;itemFactors&lt;/span&gt;().&lt;span style=&#34;color:#a6e22e&#34;&gt;write&lt;/span&gt;().&lt;span style=&#34;color:#a6e22e&#34;&gt;parquet&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;s3://warehouse/rec/item_factors/&amp;#34;&lt;/span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;&lt;code&gt;fit()&lt;/code&gt; 跑完，&lt;code&gt;userFactors&lt;/code&gt; 就是前面推荐架构文章里说的&amp;quot;每个用户一个几百维的向量&amp;quot;——概念和代码在这里接上了。&lt;/p&gt;
&lt;h2 id=&#34;flink-基础流处理血统的计算引擎&#34;&gt;Flink 基础：流处理血统的计算引擎&lt;/h2&gt;
&lt;p&gt;Flink 的出发点反过来：&lt;strong&gt;世界本质是流，批只是流的特例&lt;/strong&gt;（有界流）。数据逐条进来逐条处理，毫秒到秒级延迟。&lt;/p&gt;
&lt;p&gt;四个核心概念：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;事件时间与水位线（event time &amp;amp; watermark）&lt;/strong&gt;：事件&amp;quot;发生的时间&amp;quot;和&amp;quot;到达系统的时间&amp;quot;经常不同（用户手机离线两小时，数据才补传上来）。Flink 允许按发生时间计算，水位线是&amp;quot;我认为早于 t 的事件基本都到齐了&amp;quot;的声明，晚到超过容忍度的数据进侧输出流。&lt;strong&gt;这是流处理区别于批处理最本质的概念&lt;/strong&gt;——批处理里根本没有&amp;quot;数据晚到&amp;quot;这个问题。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;状态（state）&lt;/strong&gt;：算子可以持有状态，比如&amp;quot;最近一小时的点击数&amp;quot;。状态按 key 分布在各并行实例上，大作业可以到 TB 级。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;checkpoint / exactly-once&lt;/strong&gt;：定期给所有算子的状态做全局一致性快照，故障后从快照恢复，保证每条数据恰好被计算一次。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;savepoint&lt;/strong&gt;：手动触发的快照，用于升级和扩缩容——代价是&amp;quot;停作业、改并行度、从快照重启&amp;quot;，这也是之前那篇 &lt;a href=&#34;https://ftmao.com/blog/blog/posts/flink-autoscaling-netflix-two-autoscalers/&#34;&gt;Netflix 给 Flink 做自动扩缩容&lt;/a&gt;那么难的原因。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;上手：实时热榜的 Java 写法&lt;/strong&gt;。场景：统计每个视频最近 10 分钟的播放量，每分钟刷新一次（滑动窗口）：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;StreamExecutionEnvironment env &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; StreamExecutionEnvironment.&lt;span style=&#34;color:#a6e22e&#34;&gt;getExecutionEnvironment&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 每 60 秒做一次 checkpoint，故障恢复就回到最近一次快照&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;env.&lt;span style=&#34;color:#a6e22e&#34;&gt;enableCheckpointing&lt;/span&gt;(60_000);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 数据源：Kafka&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;KafkaSource&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;PlayEvent&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; source &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; KafkaSource.&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;PlayEvent&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt;builder()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setBootstrapServers&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;kafka:9092&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setTopics&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;play-events&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setGroupId&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;hot-ranking&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setValueOnlyDeserializer&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; PlayEventSchema())
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;build&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 关键一步：声明用事件时间，容忍 5 分钟乱序&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 手机离线两小时补传的数据，仍然算回&amp;#34;发生那一刻&amp;#34;的窗口&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;WatermarkStrategy&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;PlayEvent&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; wm &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; WatermarkStrategy
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;PlayEvent&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt;forBoundedOutOfOrderness(Duration.&lt;span style=&#34;color:#a6e22e&#34;&gt;ofMinutes&lt;/span&gt;(5))
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;withTimestampAssigner&lt;/span&gt;((event, ts) &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; event.&lt;span style=&#34;color:#a6e22e&#34;&gt;getEventTime&lt;/span&gt;());
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;DataStream&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;PlayEvent&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; events &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; env.&lt;span style=&#34;color:#a6e22e&#34;&gt;fromSource&lt;/span&gt;(source, wm, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;kafka-play-events&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;events
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;keyBy&lt;/span&gt;(PlayEvent::getVideoId)                                  &lt;span style=&#34;color:#75715e&#34;&gt;// 按视频分流&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;window&lt;/span&gt;(SlidingEventTimeWindows.&lt;span style=&#34;color:#a6e22e&#34;&gt;of&lt;/span&gt;(Time.&lt;span style=&#34;color:#a6e22e&#34;&gt;minutes&lt;/span&gt;(10), Time.&lt;span style=&#34;color:#a6e22e&#34;&gt;minutes&lt;/span&gt;(1)))
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;aggregate&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; PlayCounter())                                  &lt;span style=&#34;color:#75715e&#34;&gt;// 窗口内计数&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;addSink&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; RedisSink&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&amp;gt;&lt;/span&gt;());                                   &lt;span style=&#34;color:#f92672&#34;&gt;//&lt;/span&gt; 结果写 Redis&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;，&lt;/span&gt;榜单服务读&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;这段代码里藏着 Flink 思维方式的全部要点：&lt;code&gt;keyBy&lt;/code&gt; 决定状态按什么分发（每个 video_id 一份计数状态），&lt;code&gt;window&lt;/code&gt; 按事件时间划分窗口而不是按到达时间，水位线声明&amp;quot;晚到 5 分钟以内的数据我等你&amp;quot;。&lt;/p&gt;
&lt;p&gt;状态的 TTL 管理（避坑指南第一条）在代码里长这样：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;StateTtlConfig ttl &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; StateTtlConfig.&lt;span style=&#34;color:#a6e22e&#34;&gt;newBuilder&lt;/span&gt;(Time.&lt;span style=&#34;color:#a6e22e&#34;&gt;days&lt;/span&gt;(7))
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setUpdateType&lt;/span&gt;(StateTtlConfig.&lt;span style=&#34;color:#a6e22e&#34;&gt;UpdateType&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;OnCreateAndWrite&lt;/span&gt;)   &lt;span style=&#34;color:#75715e&#34;&gt;// 写入时刷新过期时间&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;setStateVisibility&lt;/span&gt;(StateTtlConfig.&lt;span style=&#34;color:#a6e22e&#34;&gt;StateVisibility&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;NeverReturnExpired&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        .&lt;span style=&#34;color:#a6e22e&#34;&gt;build&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;ValueStateDescriptor&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;Long&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; desc &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; ValueStateDescriptor&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&amp;gt;&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;play-count&amp;#34;&lt;/span&gt;, Long.&lt;span style=&#34;color:#a6e22e&#34;&gt;class&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;desc.&lt;span style=&#34;color:#a6e22e&#34;&gt;enableTimeToLive&lt;/span&gt;(ttl);   &lt;span style=&#34;color:#f92672&#34;&gt;//&lt;/span&gt; 状态 7 天不活跃自动清理&lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;，&lt;/span&gt;防止无限膨胀&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;部署：对应的启动命令&lt;/strong&gt;。Flink 的交付物同样是一个 fat jar，但启动命令和 Spark 有一个本质区别——&lt;strong&gt;spark-submit 起的作业算完就退出，flink run 起的作业是常驻的&lt;/strong&gt;（流处理理论上永不结束）：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# 提交常驻流作业到 YARN（跑在 K8s 上则换成 -t kubernetes-application）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;flink run-application &lt;span style=&#34;color:#ae81ff&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  -t yarn-application &lt;span style=&#34;color:#ae81ff&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  -c com.example.HotRankingJob &lt;span style=&#34;color:#ae81ff&#34;&gt;\ &lt;/span&gt;     &lt;span style=&#34;color:#75715e&#34;&gt;# main 入口类&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  -p &lt;span style=&#34;color:#ae81ff&#34;&gt;64&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;\ &lt;/span&gt;                            &lt;span style=&#34;color:#75715e&#34;&gt;# 全局并行度：占用 64 个 slot&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  hot-ranking.jar
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# 升级代码或改并行度的标准动作：先打 savepoint，再从快照恢复&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;flink stop &amp;lt;jobId&amp;gt; --savepointPath s3://checkpoints/hot-ranking/
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;flink run-application &lt;span style=&#34;color:#ae81ff&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  -t yarn-application &lt;span style=&#34;color:#ae81ff&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  -c com.example.HotRankingJob &lt;span style=&#34;color:#ae81ff&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  -s s3://checkpoints/hot-ranking/savepoint-abc123 &lt;span style=&#34;color:#ae81ff&#34;&gt;\ &lt;/span&gt;  &lt;span style=&#34;color:#75715e&#34;&gt;# 从这份状态恢复&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  -p &lt;span style=&#34;color:#ae81ff&#34;&gt;128&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;\ &lt;/span&gt;                                            &lt;span style=&#34;color:#75715e&#34;&gt;# 并行度可以顺便改&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  hot-ranking.jar&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;对照着看两套命令的分工：Spark 用 &lt;code&gt;--num-executors / --executor-cores&lt;/code&gt; 申请资源（因为要装下一批又一批的 task）；Flink 用 &lt;code&gt;-p&lt;/code&gt; 声明并行度（因为作业结构固定，并行度就是常驻 worker 数）。而 &lt;code&gt;-s&lt;/code&gt; 恢复 savepoint 这个参数是 Flink 独有的——Spark 批作业没有&amp;quot;带着状态继续跑&amp;quot;的概念，它每次都是从头算一批新的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;生产环境谁来提交 Flink 作业？&lt;/strong&gt; 和 Spark 不完全对称——Flink 没有一个像 SparkLauncher 那样常用的官方 Java 提交 API，业界的答案按部署形态分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Flink Kubernetes Operator（当前主流）&lt;/strong&gt;：作业定义成一份 &lt;code&gt;FlinkDeployment&lt;/code&gt; YAML（K8s 自定义资源），&lt;code&gt;kubectl apply&lt;/code&gt; 即提交。Operator 托管整个生命周期：拉起、监控、&lt;strong&gt;升级时自动做 savepoint 并从快照恢复&lt;/strong&gt;——上面手动敲的 &lt;code&gt;flink stop --savepointPath&lt;/code&gt; 那套动作，它全自动；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Apache StreamPark（原 StreamX）&lt;/strong&gt;：作业管理平台，Web 界面管理 jar 上传、配置、启停和 savepoint，国内团队用得多；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ververica Platform&lt;/strong&gt;：Flink 创始团队公司的平台产品（有社区版）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JobManager REST API&lt;/strong&gt;：直接 HTTP 上传 jar 再触发 run——Flink Web UI 的提交走的就是它，自研平台想自己包一层时用；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;YARN 模式&lt;/strong&gt;：就是上面那条 &lt;code&gt;flink run-application&lt;/code&gt; 命令，CLI 直连，老集群常见。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;趋势很明确：&lt;strong&gt;K8s Operator 是官方主推方向&lt;/strong&gt;，新平台直接从这里起步，别再自建提交流程。&lt;/p&gt;
&lt;h2 id=&#34;血统对比一张表&#34;&gt;血统对比：一张表&lt;/h2&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;维度&lt;/th&gt;
					&lt;th&gt;Spark&lt;/th&gt;
					&lt;th&gt;Flink&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;数据观&lt;/td&gt;
					&lt;td&gt;有界批处理是本质，流是微批模拟&lt;/td&gt;
					&lt;td&gt;无界流是本质，批是有界流特例&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;典型延迟&lt;/td&gt;
					&lt;td&gt;秒级 - 分钟级&lt;/td&gt;
					&lt;td&gt;毫秒 - 秒级&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;状态管理&lt;/td&gt;
					&lt;td&gt;弱（流场景下才需要，能力有限）&lt;/td&gt;
					&lt;td&gt;一等公民（keyed state、TB 级）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;事件时间&lt;/td&gt;
					&lt;td&gt;支持，但受微批粒度约束&lt;/td&gt;
					&lt;td&gt;原生设计，水位线机制成熟&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;生态主场&lt;/td&gt;
					&lt;td&gt;数仓 SQL、MLlib、Python 数据科学&lt;/td&gt;
					&lt;td&gt;实时管道、实时数仓、CEP&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;思维负担&lt;/td&gt;
					&lt;td&gt;低：写 SQL / DataFrame 像写单机代码&lt;/td&gt;
					&lt;td&gt;高：要时刻想着时间、状态、背压&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一句话记忆：&lt;strong&gt;Spark 把流当批做，Flink 把批当流做；前者简单但延迟高，后者精确但心智负担重。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&#34;视频公司里各干什么活&#34;&gt;视频公司里各干什么活&lt;/h2&gt;
&lt;p&gt;场景设定：一家类 Netflix 的视频公司，几亿用户，每天产生百亿级行为事件。同一条数据流，两种引擎各管一段：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    A[&amp;#34;用户行为事件&amp;lt;br/&amp;gt;播放 暂停 点赞 卡顿&amp;#34;] --&amp;gt; B[&amp;#34;Kafka 事件总线&amp;#34;]
    B --&amp;gt; C[&amp;#34;Flink 实时链路&amp;lt;br/&amp;gt;秒级生效&amp;#34;]
    B --&amp;gt; D[&amp;#34;落地数仓&amp;lt;br/&amp;gt;Iceberg 数据湖&amp;#34;]
    D --&amp;gt; E[&amp;#34;Spark 离线链路&amp;lt;br/&amp;gt;小时级 天级&amp;#34;]
    C --&amp;gt; F[&amp;#34;在线服务&amp;lt;br/&amp;gt;画像 热榜 监控告警&amp;#34;]
    E --&amp;gt; G[&amp;#34;模型 报表 实验分析&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;Flink 的活——&amp;ldquo;这件事现在就要知道&amp;rdquo;：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;实时画像更新&lt;/strong&gt;：用户每看一条视频，行为事件进 Kafka，Flink 作业滚动更新&amp;quot;最近 N 次行为的加权兴趣向量&amp;quot;，秒级生效。下一刷推荐就变。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;实时热榜&lt;/strong&gt;：按事件时间滑动窗口统计各视频的播放增速，每分钟刷新 Trending 榜单。注意必须用事件时间——用户手机离线补传的数据不能算到&amp;quot;现在&amp;quot;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;播放质量监控（QoS）&lt;/strong&gt;：卡顿率、首帧时间、播放失败率按地区/运营商/客户端版本实时聚合，超过阈值就告警。某运营商骨干网抽风，值班同学三分钟内就该收到电话。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;实时风控&lt;/strong&gt;：同一设备十分钟内播放 200 个视频？异常刷量实时拦截，不等第二天对账。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Spark 的活——&amp;ldquo;这件事今天算完就行&amp;rdquo;：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;数仓 ETL&lt;/strong&gt;：Kafka 落地到 Iceberg 的原始日志，Spark 按天清洗、去重、补维度（join 用户表、内容表），产出标准明细表。全公司数据分析的地基。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;推荐模型训练&lt;/strong&gt;：拿过去 30 天的用户-内容交互矩阵，训练排序模型（交互矩阵怎么变成用户偏好，详见&lt;a href=&#34;https://ftmao.com/blog/blog/posts/personalized-infinite-feed-architecture/&#34;&gt;推荐 Feed 架构&lt;/a&gt;里&amp;quot;用户偏好怎么算出来&amp;quot;一节）。训练要扫数据几十遍，正是 Spark 内存迭代的甜点场景。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;经营报表&lt;/strong&gt;：每日播放时长、新增会员、留存率——管理层早上九点要看的看板，Spark 凌晨跑批产出。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A/B 实验分析&lt;/strong&gt;：实验组对照组的指标对比、显著性检验，数据科学家写 Spark SQL + Python 完成。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;同一条数据的两次旅程&lt;/strong&gt;：用户晚上 8 点点开一条视频——这个事件两秒内被 Flink 消费，画像更新、热榜计数、质量监控全部生效；凌晨 2 点，同一个事件（已从 Kafka 落盘）被 Spark 的 ETL 作业清洗进数仓，早上 6 点进入模型训练数据。&lt;strong&gt;实时链路管&amp;quot;现在&amp;quot;，离线链路管&amp;quot;沉淀&amp;quot;，谁也别抢谁的活。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&#34;算力规格硬件怎么配&#34;&gt;算力规格：硬件怎么配&lt;/h2&gt;
&lt;p&gt;先给结论再讲推理：&lt;strong&gt;大数据集群的钱主要花在内存和 SSD 上，CPU 反而不是瓶颈的主角&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Spark 节点的典型规格&lt;/strong&gt;（按 2026 年的行情）：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;角色&lt;/th&gt;
					&lt;th&gt;规格&lt;/th&gt;
					&lt;th&gt;要点&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Worker 节点&lt;/td&gt;
					&lt;td&gt;32-64 核 CPU、128-256GB 内存、4-8 块 NVMe SSD 做本地 shuffle 盘、10-25Gbps 网卡&lt;/td&gt;
					&lt;td&gt;shuffle 数据写本地盘，SSD 比内存更影响大 join 的稳定性&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Executor 切分&lt;/td&gt;
					&lt;td&gt;每个 executor 4-5 核、16-24GB 堆内存&lt;/td&gt;
					&lt;td&gt;一台 32 核 128GB 的机器切 5-6 个 executor；executor 太大 GC 停顿长，太小则 broadcast 等开销被重复多份&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Driver&lt;/td&gt;
					&lt;td&gt;8-16GB 内存起步&lt;/td&gt;
					&lt;td&gt;调度 + 收集元数据；大 job 的执行计划、broadcast 变量都压在这里&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Flink 节点的典型规格&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;角色&lt;/th&gt;
					&lt;th&gt;规格&lt;/th&gt;
					&lt;th&gt;要点&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;TaskManager&lt;/td&gt;
					&lt;td&gt;8-16 核、32-64GB 内存、&lt;strong&gt;本地 NVMe SSD 必须有&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;大状态用 RocksDB state backend，状态落在本地盘，SSD 的随机读写直接决定状态访问性能&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;slot 划分&lt;/td&gt;
					&lt;td&gt;slot 数 = CPU 核数（1:1 起步）&lt;/td&gt;
					&lt;td&gt;并行度不能超过总 slot 数；一个 slot 一个线程，避免 CPU 争抢&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;JobManager&lt;/td&gt;
					&lt;td&gt;4 核 8GB 起步，HA 模式至少两个&lt;/td&gt;
					&lt;td&gt;协调 checkpoint，不搬数据，轻&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;checkpoint 存储&lt;/td&gt;
					&lt;td&gt;S3 / HDFS / 对象存储&lt;/td&gt;
					&lt;td&gt;快照不占本地盘，占的是远程存储和网络带宽&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;容量估算的例子&lt;/strong&gt;，把数字走一遍：假设行为事件峰值 10 万条/秒、单条 1KB，实时链路（画像 + 热榜 + 监控）合计维护 500GB 状态。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;网络：10 万 × 1KB = 100MB/s，25Gbps 网卡毫无压力——&lt;strong&gt;数据管道的瓶颈很少是网络&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;状态：按单个 TaskManager 管理 64-128GB RocksDB 状态比较稳妥的经验值，500GB 状态需要 4-8 台 TaskManager；&lt;/li&gt;
&lt;li&gt;CPU：窗口聚合类作业每条事件的计算很轻，10 万条/秒摊到 64 个核上每核每秒 1500 条，富余；&lt;/li&gt;
&lt;li&gt;所以这套链路 8 台&amp;quot;16 核 64GB + NVMe&amp;quot;的机器跑得很从容，再留 30% 峰值冗余即可。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;反过来，凌晨的 Spark 训练作业要扫 30 天 × 百 TB 级日志，它的瓶颈是 &lt;strong&gt;shuffle 吞吐和内存&lt;/strong&gt;，机器数量按&amp;quot;凌晨 6 点前必须跑完&amp;quot;倒推——离线集群的规格是被截止时间定义的。&lt;/p&gt;
&lt;h2 id=&#34;监控两套引擎各看什么&#34;&gt;监控：两套引擎各看什么&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Spark 监控的主入口是 Spark UI&lt;/strong&gt;（每个作业一个 Web 界面），配合 Prometheus 抓指标。值班时按这个顺序看：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Stages 页&lt;/strong&gt;：哪个 stage 慢、shuffle read/write 多大——shuffle spill（内存放不下溢写磁盘）是性能第一警报；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tasks 页的任务时长分布&lt;/strong&gt;：p50 是 2 分钟、max 是 40 分钟 → 数据倾斜，不用怀疑；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Executors 页&lt;/strong&gt;：GC 时间占比超过 10% 说明内存配置或数据结构有问题；executor lost 频繁出现说明节点或内存超卖；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Event Log&lt;/strong&gt; 持久化到 HDFS/S3，作业挂了也能回放分析——务必开启，默认不一定开。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Flink 监控的主入口是 Web UI + 指标上报&lt;/strong&gt;（Prometheus reporter），核心看四组：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;指标&lt;/th&gt;
					&lt;th&gt;看什么&lt;/th&gt;
					&lt;th&gt;告警阈值参考&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;背压 &lt;code&gt;backPressuredTimeMsPerSecond&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;哪个算子被顶住&lt;/td&gt;
					&lt;td&gt;持续 &amp;gt;50% 就要查&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;checkpoint &lt;code&gt;lastCheckpointDuration&lt;/code&gt; / &lt;code&gt;lastCheckpointSize&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;快照耗时和体积增长趋势&lt;/td&gt;
					&lt;td&gt;耗时接近间隔、体积周环比暴涨都是事故前兆&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Kafka 消费 lag（&lt;code&gt;records-lag&lt;/code&gt;）&lt;/td&gt;
					&lt;td&gt;消费追不上生产 = 延迟在堆积&lt;/td&gt;
					&lt;td&gt;持续增长 10 分钟告警&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;水位线延迟&lt;/td&gt;
					&lt;td&gt;事件时间推进是否停滞&lt;/td&gt;
					&lt;td&gt;水位线不动 = 窗口永远不触发&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;告警的验收标准只有一条：每个告警都必须对应一个动作。&lt;/strong&gt;&amp;ldquo;CPU 高&amp;quot;不是告警，&amp;ldquo;热榜作业 Kafka lag 连续增长 10 分钟&amp;quot;才是——前者看了也不知道干嘛，后者直接指向扩容或查背压。&lt;/p&gt;
&lt;h2 id=&#34;故障与恢复出事时的标准动作&#34;&gt;故障与恢复：出事时的标准动作&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Spark 的故障模型是&amp;quot;重算&amp;rdquo;&lt;/strong&gt;，血统里带着 MapReduce 的基因：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;task 失败 → 自动重试（默认 4 次，&lt;code&gt;spark.task.maxFailures&lt;/code&gt; 可调），重试时只重算这个分区；&lt;/li&gt;
&lt;li&gt;executor 挂了 → 它算过的分区&lt;strong&gt;按 lineage（血统）重新算&lt;/strong&gt;——RDD 天生记得自己从哪算来的，这就是当年 RDD 论文的核心思想；&lt;/li&gt;
&lt;li&gt;个别 task 特别慢（straggler）→ 开推测执行（&lt;code&gt;spark.speculation=true&lt;/code&gt;），同样的任务在别的节点再跑一份，谁先完用谁；&lt;/li&gt;
&lt;li&gt;driver 挂了 → 最麻烦，批作业整体重跑；所以重要的 ETL 要幂等（写分区用 overwrite 而不是 append），重跑不出脏数据。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Flink 的故障模型是&amp;quot;回滚&amp;rdquo;&lt;/strong&gt;，回到最近一次 checkpoint：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TaskManager 挂了 → 作业自动从最近的成功 checkpoint 恢复，Kafka source 回到快照时记录的 offset 重新消费，配合两阶段提交的 sink 保证恰好一次；&lt;/li&gt;
&lt;li&gt;重启策略别用默认：生产上推荐 &lt;code&gt;failure-rate&lt;/code&gt;（10 分钟内允许失败 N 次，超过才放弃）配合重启间隔，防止依赖的 Kafka/Redis 抖动时作业无脑高频重启把对方打垮；&lt;/li&gt;
&lt;li&gt;JobManager 挂 → 必须配 HA（ZooKeeper 或 K8s 选主），否则协调者单点；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;checkpoint 是自动的、savepoint 是手动的&lt;/strong&gt;：代码升级、改并行度必须先做 savepoint 再停作业，从 savepoint 启动新版本。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;一个标准故障处置剧本&lt;/strong&gt;（实时画像更新作业 lag 暴涨）：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;看 Flink UI 背压页——找到链条上第一个变红的算子；&lt;/li&gt;
&lt;li&gt;是某个下游慢（比如 Redis 抖动）→ 修下游，作业自己追上来；&lt;/li&gt;
&lt;li&gt;是数据洪峰 → 临时调大并行度（savepoint → 重启 → 恢复），或者干脆等洪峰过去，Kafka 里的数据丢不了；&lt;/li&gt;
&lt;li&gt;恢复后盯 Kafka lag 回落到正常水位，确认 checkpoint 恢复绿色；&lt;/li&gt;
&lt;li&gt;事后复盘：这次故障该变成哪条告警规则？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;注意底气来自哪里：&lt;strong&gt;Kafka 保留了原始事件，Flink 保留了状态快照&lt;/strong&gt;——前者保证&amp;quot;数据丢不了&amp;quot;，后者保证&amp;quot;进度丢不了&amp;quot;。两个都在，故障就只是插曲不是事故。&lt;/p&gt;
&lt;h3 id=&#34;kafka--flink-组合失败后到底会不会重复会不会丢&#34;&gt;Kafka + Flink 组合：失败后到底会不会重复、会不会丢&lt;/h3&gt;
&lt;p&gt;这是面试和值班都绕不开的问题，拆开两个角色看就清楚了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Kafka 侧：消息几乎不可能&amp;quot;被消费掉&amp;quot;。&lt;/strong&gt; Kafka 是持久化日志，消费&lt;strong&gt;不删除&lt;/strong&gt;数据，保留期（比如 7 天）内随便重读。Flink 的 Kafka source 消费时不逐条 ack，只在 checkpoint 里记录各分区的 offset。所以消息丢失只有两种可能：保留期过了，或者 Kafka 集群自己故障。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Flink 侧：重启 = 状态回滚 + offset 回拨，两件事同步发生。&lt;/strong&gt; 故障后从最近一次成功的 checkpoint 恢复，同一时刻两个动作：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    A[&amp;#34;故障发生&amp;lt;br/&amp;gt;部分事件已消费已计算&amp;#34;] --&amp;gt; B[&amp;#34;从最近 checkpoint 恢复&amp;#34;]
    B --&amp;gt; C[&amp;#34;Kafka offset 回拨&amp;lt;br/&amp;gt;到快照时刻&amp;#34;]
    B --&amp;gt; D[&amp;#34;算子状态回滚&amp;lt;br/&amp;gt;到快照时刻&amp;#34;]
    C --&amp;gt; E[&amp;#34;快照之后的事件重新消费&amp;#34;]
    D --&amp;gt; E
    E --&amp;gt; F[&amp;#34;在回滚后的状态上重算&amp;lt;br/&amp;gt;内部不多算也不少算&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;假设 checkpoint 间隔 60 秒：故障意味着&lt;strong&gt;最多重放最近 60 秒的事件&lt;/strong&gt;——重放不是丢失，是进度倒退后追赶。由于状态和 offset 同步回滚，窗口计数这类状态计算在内部是精确的（exactly-once）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;但&amp;quot;对外不重复&amp;quot;取决于 sink，这是最容易踩的边界：&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;sink 类型&lt;/th&gt;
					&lt;th&gt;端到端语义&lt;/th&gt;
					&lt;th&gt;例子&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;两阶段提交 sink&lt;/td&gt;
					&lt;td&gt;exactly-once&lt;/td&gt;
					&lt;td&gt;Kafka sink（事务）、Iceberg&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;幂等写入&lt;/td&gt;
					&lt;td&gt;等效 exactly-once&lt;/td&gt;
					&lt;td&gt;Redis 按 key 覆盖写、MySQL upsert&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;普通追加&lt;/td&gt;
					&lt;td&gt;at-least-once，&lt;strong&gt;会重复&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;直接 insert 进 MySQL、对外发 HTTP 回调&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;重放的那 60 秒里，如果 sink 是普通 insert，下游表里就会出现重复行——作业内部算得再精确，写出去的那一刻语义就降级了。这就是&amp;quot;exactly-once 只在链路两端都配合时才成立&amp;quot;的含义。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;真正会丢、会重的边界情况清单：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;没开 checkpoint&lt;/strong&gt;：offset 只能周期性提交到 Kafka，重启从提交点读——退化成 at-least-once，且恢复语义模糊；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kafka 保留期 &amp;lt; 故障停机时间&lt;/strong&gt;：作业停 8 天、Kafka 只留 7 天，恢复时最早的数据真没了；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;异步 IO 和侧输出不参与 checkpoint&lt;/strong&gt;：算子里偷偷调外部接口、写侧输出流，这些动作不在快照里，重放时会重复执行；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;savepoint 与升级不兼容&lt;/strong&gt;：改了状态结构（比如给状态类加了字段）还想从旧 savepoint 恢复，直接恢复失败——升级前想清楚状态兼容性。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;多节点会不会重复消费同一个分区消费组的互斥语义&#34;&gt;多节点会不会重复消费同一个分区：消费组的互斥语义&lt;/h3&gt;
&lt;p&gt;顺带的另一个常见疑问：Flink 作业有几十个 source subtask 在多个节点上跑，会不会两个节点同时读同一个 Kafka 分区、把数据算两遍？&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;不会，这是 Kafka 消费组协议内建的互斥保证，不需要你做任何事：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;同一作业的所有 source subtask 用同一个 &lt;code&gt;group.id&lt;/code&gt; 注册，Kafka 的组协调器把分区&lt;strong&gt;互斥分配&lt;/strong&gt;——每个分区同一时刻只属于组内一个消费者。200 个分区、64 个 subtask，每个 subtask 分 3-4 个，互不重叠；&lt;/li&gt;
&lt;li&gt;节点挂了触发 rebalance，分区重新分配，但交接是&lt;strong&gt;排他的&lt;/strong&gt;：旧持有者被踢出组之后，新持有者才接手，不存在两个节点同时持有同一分区的窗口；&lt;/li&gt;
&lt;li&gt;所以同一作业内&amp;quot;重复消费&amp;quot;只有一种情况：故障恢复时的&lt;strong&gt;时间维度重放&lt;/strong&gt;（上一节讲的回滚），不存在&amp;quot;空间维度&amp;quot;的并行重复。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两个相关的配置纪律：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;source 并行度不要超过分区数&lt;/strong&gt;。并行度 64、分区只有 32 个时，32 个 subtask 永远分不到分区、空转白占资源。反过来分区数多于并行度没问题，一个 subtask 可以吃多个分区。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不同作业必须用不同的 &lt;code&gt;group.id&lt;/code&gt;&lt;/strong&gt;。消费组之间是广播语义——画像作业和热榜作业各自完整消费同一个 topic，互不干扰，这是特性。但两个本该独立的作业如果误配成同一个 group.id，就会&lt;strong&gt;瓜分分区、各自只读到一半数据&lt;/strong&gt;，这种 bug 症状隐蔽（数据看着都在流，就是量不对），值得配进上线检查清单。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&#34;数据源不止-kafka还能从哪读api-数据怎么办&#34;&gt;数据源不止 Kafka：还能从哪读，API 数据怎么办&lt;/h3&gt;
&lt;p&gt;Flink 的 source 生态按类别列全：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;类别&lt;/th&gt;
					&lt;th&gt;连接器&lt;/th&gt;
					&lt;th&gt;场景&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;消息队列&lt;/td&gt;
					&lt;td&gt;Kafka、Pulsar、Kinesis、RocketMQ（社区）&lt;/td&gt;
					&lt;td&gt;实时事件流的主力入口&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;数据库 CDC&lt;/td&gt;
					&lt;td&gt;MySQL CDC、PostgreSQL CDC、MongoDB CDC（Flink CDC 项目）&lt;/td&gt;
					&lt;td&gt;直接订阅 binlog，数据库变更实时变成流——替代&amp;quot;定时全量导库&amp;quot;，是这几年增量最大的方向&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;文件/对象存储&lt;/td&gt;
					&lt;td&gt;FileSource（S3 / HDFS / 本地），支持批读和持续监听目录&lt;/td&gt;
					&lt;td&gt;批处理、日志文件实时采集&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;JDBC&lt;/td&gt;
					&lt;td&gt;Table API 的 JDBC connector&lt;/td&gt;
					&lt;td&gt;轮询式读关系库，伪实时&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;测试&lt;/td&gt;
					&lt;td&gt;DataGen 数据生成器、socket 流&lt;/td&gt;
					&lt;td&gt;本地开发与压测&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;HTTP API 提供的数据怎么办？&lt;/strong&gt; 核心发行版里没有内置的 HTTP source，分两种情况：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;情况一：API 是数据源（想持续消费一个 REST 接口）&lt;/strong&gt;。正确姿势不是让 Flink 直接轮询 API，而是&lt;strong&gt;中间加一层 Kafka&lt;/strong&gt;：一个轻量采集服务定时调 API、把结果写进 Kafka，Flink 消费 Kafka。原因正是前两节的内容——Kafka 提供可回放、削峰、多消费者，API 直连则什么都没有：Flink 作业一挂，这段时间的 API 数据就永远丢了。非要直连也行：实现自定义 Source 接口自己轮询，但 offset 管理、故障恢复语义全得自己做，一般不值。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;情况二：API 是维表（事件流经过时补充字段）&lt;/strong&gt;。这是更常见的需求：每条事件只有 &lt;code&gt;video_id&lt;/code&gt;，需要调内容服务拿标题、类目、时长。Flink 有标准件——&lt;strong&gt;异步 IO（Async I/O）&lt;/strong&gt;：在算子里并发调用 HTTP 接口、回调组装结果，不阻塞主流；Table API 里对应的写法是 lookup join。配一层本地缓存（Guava/Caffeine）挡掉热点 key 的重复调用，避免把对方服务打挂。注意它的语义是&amp;quot;查询&amp;quot;不是&amp;quot;消费&amp;quot;——只在事件流经过时被动调用，永远不会主动拉数据。&lt;/p&gt;
&lt;h2 id=&#34;spark-避坑指南&#34;&gt;Spark 避坑指南&lt;/h2&gt;
&lt;p&gt;按踩坑频率排序：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;数据倾斜是第一杀手&lt;/strong&gt;。某个 key 的数据量特别大（比如头部视频占了 10% 播放量），groupBy/join 时对应分区要处理别人几十倍的数据，99% 的任务十分钟跑完，最后 1% 卡两小时。解法：给热点 key 加盐（key 后面拼随机后缀打散）、先聚合再关联、小表广播（broadcast join）避免 shuffle。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;小文件问题&lt;/strong&gt;。ETL 作业输出几万个几 KB 的小文件，下游读取慢、元数据服务被打爆。解法：写出前 &lt;code&gt;coalesce&lt;/code&gt;/&lt;code&gt;repartition&lt;/code&gt; 控制分区数，或开自适应合并。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;shuffle 爆炸&lt;/strong&gt;。join 两张大表，中间数据量是笛卡尔级增长。先看能不能把一张表过滤小再 join，能 broadcast 就 broadcast。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;滥用 cache&lt;/strong&gt;。&lt;code&gt;.cache()&lt;/code&gt; 不是免费的，内存不够会挤占执行内存反而更慢。只缓存&amp;quot;会被复用多次&amp;quot;的中间结果。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分区数拍脑袋&lt;/strong&gt;。分区太少 → 单任务处理量太大 OOM；太多 → 任务调度开销压垮 driver。经验值：单个分区处理 128MB 上下。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;driver OOM&lt;/strong&gt;。&lt;code&gt;collect()&lt;/code&gt; 把全量数据拉回 driver——生产事故的经典配方。要看几行数据用 &lt;code&gt;show()&lt;/code&gt;/&lt;code&gt;take()&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&#34;flink-避坑指南&#34;&gt;Flink 避坑指南&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;状态不设 TTL 无限膨胀&lt;/strong&gt;。keyed state 默认永不过期，跑一个月状态涨到 TB 级，checkpoint 从秒级拖到分钟级最后超时失败。用户维度状态几乎总该设 TTL（比如 7 天）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;checkpoint 间隔拍太短&lt;/strong&gt;。间隔小于一次 checkpoint 本身的耗时，系统永远在做快照没有空算数据。先看一次 checkpoint 要多久，间隔至少留它几倍。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;背压不会看&lt;/strong&gt;。作业 lag 持续上涨，先去 UI 看背压面板：红色背压的算子就是瓶颈，瓶颈的上游全会被&amp;quot;传染&amp;quot;成红色——&lt;strong&gt;找到链条上第一个变红的，那才是真正的病灶&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;事件时间和处理时间混用&lt;/strong&gt;。用处理时间算&amp;quot;每分钟播放量&amp;quot;，用户手机补传的数据会把昨天的量记到今天。涉及&amp;quot;按发生时间统计&amp;quot;的需求，必须事件时间 + 水位线，并接受乱序容忍度的权衡。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;扩缩容当成无状态服务做&lt;/strong&gt;。改并行度 = savepoint + 停作业 + 重启，大状态作业分钟级中断。扩缩容策略要保守，这个坑的完整故事见 &lt;a href=&#34;https://ftmao.com/blog/blog/posts/flink-autoscaling-netflix-two-autoscalers/&#34;&gt;Netflix 两代 autoscaler&lt;/a&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;exactly-once 当默认值&lt;/strong&gt;。端到端 exactly-once 要求 sink 支持两阶段提交，代价是吞吐下降。很多监控类场景 at-least-once + 下游幂等就够了，别为不需要的语义付费。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;通用坑：用 Spark 微批硬扛秒级需求&lt;/strong&gt;。需求方说&amp;quot;准实时&amp;quot;，你拿 Structured Streaming 把触发间隔压到秒级——能跑，但状态管理和乱序处理的坑会一个个冒出来。先问清楚：延迟到底是秒级还是分钟级需求？分钟级 Spark 很舒服，秒级请直接上 Flink，别硬扛。&lt;/p&gt;
&lt;h2 id=&#34;选型决策&#34;&gt;选型决策&lt;/h2&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;数据是有界的、延迟容忍分钟级以上 → Spark
数据是持续流、延迟要求秒级、或需要复杂状态/事件时间 → Flink&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;但必须诚实：&lt;strong&gt;边界在模糊化&lt;/strong&gt;。Spark 能做流、Flink 能做批，两个社区都在往对方的领地走；Flink 的 Table API 写 SQL 也越来越像 Spark SQL。真实的选型经常是这样决定的：团队现有栈是什么、招聘市场上谁会、云厂商托管服务谁更便宜。技术血统决定的是&amp;quot;哪个更顺&amp;quot;，不是&amp;quot;哪个能做&amp;quot;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;国内的实际格局&lt;/strong&gt;（2020 年代中期的现状，供校准）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;新上实时项目基本是 Flink&lt;/strong&gt;。阿里 2019 年收购 Flink 母公司后全力主推（内部版 Blink 撑双 11 实时大屏），字节、美团、腾讯、快手的实时平台全部 Flink 化，新立项用 Spark Streaming 的已很难见到；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;存量离线仍是 Spark 的天下&lt;/strong&gt;：数仓 ETL、T+1 报表、Hive 承接，生态太厚，替换动力不足；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;两边都在被挤压&lt;/strong&gt;：Spark 的机器学习场景在流失到 Python 原生生态（PyTorch/Ray）；Flink 在进攻离线——&amp;ldquo;流批一体&amp;rdquo;（一套 SQL 跑流也跑批）配合 Paimon 这类流式湖格式在落地，但离线存量太重，替换以年计；&lt;/li&gt;
&lt;li&gt;一句话：&lt;strong&gt;新实时 = Flink，存量离线 = Spark，交界处（实时数仓）在打架&lt;/strong&gt;。求职市场同构：实时岗位 Flink 是硬通货，离线岗位 Spark/Hive 是基础盘。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;一句话收拢：&lt;strong&gt;Spark 回答&amp;quot;这批数据算出来是什么&amp;quot;，Flink 回答&amp;quot;这件事现在发生了什么&amp;quot;&lt;/strong&gt;；先想清楚你的问题是哪一种，再选工具——反过来拿着工具定义问题，是大数据平台翻车的最常见起点。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;参考：Apache Spark 与 Apache Flink 官方文档；Structured Streaming 与 Flink DataStream 的设计文档。Netflix 场景参考其技术博客公开的数据平台架构。&lt;/em&gt;&lt;/p&gt;
</description>
    </item>
    <item>
      <title>三万个 Flink 作业怎么自动扩缩容：Netflix 两代 Autoscaler 的教训</title>
      <link>https://ftmao.com/blog/posts/flink-autoscaling-netflix-two-autoscalers/</link>
      <pubDate>Fri, 02 Oct 2026 17:00:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/flink-autoscaling-netflix-two-autoscalers/</guid>
      <description>&lt;h1 id=&#34;三万个-flink-作业怎么自动扩缩容netflix-两代-autoscaler-的教训&#34;&gt;三万个 Flink 作业怎么自动扩缩容：Netflix 两代 Autoscaler 的教训&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;目标读者：三类人——&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;平台/数据工程师&lt;/strong&gt;：正在或准备给 Flink 作业做自动扩缩容，想知道选哪个方案、坑在哪（看&amp;quot;背景二&amp;quot;和&amp;quot;三个工程缺口&amp;quot;）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;架构师&lt;/strong&gt;：在给实时计算平台做选型，需要&amp;quot;外部指标 vs 内部推理&amp;quot;两条路线的完整对比（看两代方案的对比表）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CTO/技术管理者&lt;/strong&gt;：关心自研和开源的边界，Netflix 从自建收敛到开源的过程是一个现成的决策样本（看&amp;quot;三条可以带走的教训&amp;quot;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;没跑过 Flink 也能读：正文前两节是背景知识；跑过的可以直接跳到&amp;quot;第一代&amp;quot;开始看。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;之前那篇&lt;a href=&#34;https://ftmao.com/blog/blog/posts/how-to-get-reliable-tech-knowledge-in-ai-era/&#34;&gt;《软件知识的分层与获取地图》&lt;/a&gt;说过：方案层的知识没有教材，只有案例，大厂技术博客是主力来源。最近去读了 Netflix 技术博客的 &lt;a href=&#34;https://netflixtechblog.com/a-tale-of-two-flink-autoscalers-e9f6a1b1492b&#34;&gt;A Tale of Two Flink Autoscalers&lt;/a&gt;，讲的是他们怎么做&lt;strong&gt;流处理作业的自动扩缩容&lt;/strong&gt;。这篇是我的学习笔记，把里面的架构知识点整理出来。&lt;/p&gt;
&lt;p&gt;问题的形状很普遍：作业负载随昼夜、活动、故障转移大幅波动，按峰值配资源浪费，按均值配资源高峰期 lag 爆炸。Netflix 的答案是先后造了两代 autoscaler，现在正在从自研收敛到开源。两代的设计差异和迁移教训，比算法本身更有价值。&lt;/p&gt;
&lt;p&gt;不过进入正题前，先补两段背景：Flink 是什么、扩缩容在这上面为什么天然就贵；以及开源社区现在有哪些现成方案、谁更好。&lt;/p&gt;
&lt;h2 id=&#34;背景一flink-是什么扩缩容为什么贵&#34;&gt;背景一：Flink 是什么，扩缩容为什么贵&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Apache Flink 是开源的流处理框架&lt;/strong&gt;，核心能力是&amp;quot;对无界数据流做有状态计算&amp;quot;：数据像水管里的水一样源源不断地来（Kafka 消息、日志、点击流），Flink 一边流一边算，算出实时结果（实时报表、实时推荐、风控告警）。&lt;/p&gt;
&lt;p&gt;一个 Flink 作业（job）在逻辑上是一张&lt;strong&gt;算子组成的有向无环图（DAG）&lt;/strong&gt;：数据从 source 进来，经过 map、keyBy、window、join 这些算子的处理，最后写到 sink。&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    A[&amp;#34;source&amp;lt;br/&amp;gt;读 Kafka&amp;#34;] --&amp;gt; B[&amp;#34;map / filter&amp;lt;br/&amp;gt;转换清洗&amp;#34;]
    B --&amp;gt; C[&amp;#34;keyBy + window&amp;lt;br/&amp;gt;分组聚合 带状态&amp;#34;]
    C --&amp;gt; D[&amp;#34;sink&amp;lt;br/&amp;gt;写下游&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;要理解扩缩容为什么难，需要知道四个机制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;并行度（parallelism）&lt;/strong&gt;：每个算子可以拆成多个并行实例（叫 subtask），分摊在集群的 TaskManager 进程里跑。所谓&amp;quot;扩缩容&amp;quot;，本质就是调整各算子的并行度、增减 TaskManager。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;状态（state）&lt;/strong&gt;：keyBy 之后的窗口、聚合结果都存在状态里，大作业的状态可以到 TB 级。状态按 key 分布在各个并行实例上。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;checkpoint / savepoint&lt;/strong&gt;：Flink 定期把状态整体快照下来，用于故障恢复。&lt;strong&gt;改并行度意味着状态要在新数量的实例间重新分配&lt;/strong&gt;——所以扩缩容的标准动作是&amp;quot;做 savepoint → 停作业 → 按新并行度重启 → 重新加载状态&amp;quot;，状态越大恢复越慢。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;背压（backpressure）&lt;/strong&gt;：下游算子处理不过来，会沿着数据流反向&amp;quot;顶住&amp;quot;上游。背压是判断&amp;quot;瓶颈在哪个算子&amp;quot;的关键信号，也是第二代 autoscaler 的数据基础。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一句话：&lt;strong&gt;无状态服务扩缩容是加台机器的事；有状态流处理扩缩容是一次&amp;quot;带着全部家当搬家&amp;quot;&lt;/strong&gt;。这就是为什么&amp;quot;自动扩缩容&amp;quot;在无状态微服务上早就普及，在 Flink 上却直到这两年才有成熟方案。&lt;/p&gt;
&lt;h2 id=&#34;背景二开源社区有什么方案谁更好&#34;&gt;背景二：开源社区有什么方案，谁更好&lt;/h2&gt;
&lt;p&gt;给流处理作业做自动扩缩容，社区里前后出现过几条路线：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;方案&lt;/th&gt;
					&lt;th&gt;原理&lt;/th&gt;
					&lt;th&gt;粒度&lt;/th&gt;
					&lt;th&gt;现状&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Flink Reactive Mode（1.13 起）&lt;/td&gt;
					&lt;td&gt;作业自适应集群里现有的 TaskManager 数量，配合 K8s HPA 按 CPU 伸缩容器&lt;/td&gt;
					&lt;td&gt;整个作业一刀切&lt;/td&gt;
					&lt;td&gt;简单可用，但按容器指标决策，看不见作业内部瓶颈&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Apache Flink Autoscaler（Flink Kubernetes Operator 内置）&lt;/td&gt;
					&lt;td&gt;从作业内部逐算子算&amp;quot;真实处理速率&amp;quot;，按需逐顶点调并行度&lt;/td&gt;
					&lt;td&gt;逐算子&lt;/td&gt;
					&lt;td&gt;社区主力方向，本文的主角&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;云厂商托管服务（如 AWS Managed Service for Apache Flink）&lt;/td&gt;
					&lt;td&gt;平台按容器利用率自动伸缩&lt;/td&gt;
					&lt;td&gt;整个作业一刀切&lt;/td&gt;
					&lt;td&gt;零运维但粒度粗，复杂有状态作业效果有限&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;各家自研（Netflix 第一代、当年 Twitter Heron 的 Dhalion 等）&lt;/td&gt;
					&lt;td&gt;外部指标（CPU、lag、背压）+ 自研策略&lt;/td&gt;
					&lt;td&gt;整个作业一刀切&lt;/td&gt;
					&lt;td&gt;大多是&amp;quot;社区没有成熟方案时&amp;quot;的历史产物&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;谁更好？&lt;strong&gt;对 2026 年刚开始做这件事的团队，答案基本收敛了&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;作业跑在 K8s 上 → 直接用 Flink Kubernetes Operator 自带的 autoscaler，顶点级、背压感知，是当前最通用的最优解；&lt;/li&gt;
&lt;li&gt;平台是自建的（不在 K8s 上、有自己的控制面）→ 核心算法库已经独立出来，可以像 Netflix 一样只接四个接口（下文详述），不必自建；&lt;/li&gt;
&lt;li&gt;作业简单、无状态、量不大 → Reactive Mode 或云托管的粗粒度伸缩就够用，别上重武器；&lt;/li&gt;
&lt;li&gt;从零自研外部指标 autoscaler → 2019 年的合理选择，2026 年不太成立——Netflix 自己正在从这条路迁走，这就是用脚投票。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;有了这个背景，Netflix 这两代方案的故事就好读了：它几乎把上面每条路线都趟了一遍。&lt;/p&gt;
&lt;h2 id=&#34;为什么这个问题在-netflix-躲不掉&#34;&gt;为什么这个问题在 Netflix 躲不掉&lt;/h2&gt;
&lt;p&gt;先看规模：2026 年，Netflix 在多个 AWS 区域跑着 &lt;strong&gt;3 万多个 Flink 作业&lt;/strong&gt;（2017 年开始用 Flink）。这决定了&amp;quot;人肉运维&amp;quot;这条路根本不存在。&lt;/p&gt;
&lt;p&gt;这三万个作业到底在干什么业务？按原文的分类可以拆成两大块：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;大头是托管平台 Data Mesh 自动生成的数据管道&lt;/strong&gt;——所以这三万个作业的主体确实是&amp;quot;大数据相关&amp;quot;，不是在跑在线业务服务。Data Mesh 是 Netflix 的托管数据搬运/处理平台（2023 年他们在博客公开过架构）：用户在界面上声明&amp;quot;数据从哪来、到哪去、中间做什么转换&amp;quot;，平台自动生成 Flink 作业——典型场景是 Kafka 之间的数据搬运、数据库变更（CDC）实时同步到数仓（Iceberg）、事件路由分发。用户大多根本不直接接触 Flink，自然也不会去管扩缩容。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;少数但增长快的是各团队自建自运维的自定义作业&lt;/strong&gt;，承载真正的实时业务逻辑：个性化（实时推荐特征计算）、广告（2022 年广告套餐上线后的曝光与计费遥测）、直播活动（体育赛事直播的实时监控）等。原文里靠 autoscaler 省下 110 万美元的案例，就来自做客户端遥测和日志的团队。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;形态差异也很大：从 Kafka 搬运工式的单算子作业，到带分支、join、TB 级状态的复杂流水线都有——记住这个差异，后面两代 autoscaler 的能力边界正好卡在这里。&lt;/p&gt;
&lt;p&gt;顺带澄清一个容易误解的点：&lt;strong&gt;首页千人千面的推荐，不是每个用户打开时都走一遍 Flink&lt;/strong&gt;。Flink 是流处理引擎，干的是&amp;quot;事件来了就算&amp;quot;，不在用户请求链路上。个性化系统是经典的三层结构（Netflix 早在 2013 年就公开过这套架构）：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    A[&amp;#34;离线 offline&amp;lt;br/&amp;gt;批量训练模型&amp;lt;br/&amp;gt;计算全量候选集&amp;#34;] --&amp;gt; D[&amp;#34;在线存储&amp;lt;br/&amp;gt;画像 特征 候选集&amp;#34;]
    B[&amp;#34;近线 nearline&amp;lt;br/&amp;gt;行为事件进 Kafka&amp;lt;br/&amp;gt;Flink 实时更新画像特征&amp;#34;] --&amp;gt; D
    D --&amp;gt; C[&amp;#34;在线 online&amp;lt;br/&amp;gt;打开首页时排序组装&amp;lt;br/&amp;gt;几十毫秒返回&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;你在观看、暂停、评分时产生的行为事件，由近线层的 Flink 作业实时消化、更新画像；你打开首页那一瞬间，在线服务只做&amp;quot;查 + 排&amp;quot;，几十毫秒返回。&lt;strong&gt;把重计算从请求路径挪到事件流上&lt;/strong&gt;，正是个性化系统扛住全球用户规模的关键设计——本文说的&amp;quot;个性化&amp;quot;自定义作业，干的就是近线这一层的活。&lt;/p&gt;
&lt;p&gt;更麻烦的是约束条件：&lt;strong&gt;在这个平台上，扩缩容不是免费的&lt;/strong&gt;。默认动作是&amp;quot;做 savepoint → 优雅停掉作业 → 按新规模重启&amp;quot;，大状态作业一次要几分钟。这意味着扩缩容策略不能冲动——每次动作都是分钟级的中断。&lt;/p&gt;
&lt;p&gt;于是一个真正难的问题摆在面前：&lt;strong&gt;如何在没有人的情况下，让每个作业在需要时拿到需要的资源，同时不把任何东西搞挂？&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&#34;第一代从外面看的-autoscaler2019&#34;&gt;第一代：从外面看的 autoscaler（2019）&lt;/h2&gt;
&lt;p&gt;Netflix 的第一个答案本身就是一条流处理作业：跑在自家 Mantis 平台上，消费遥测系统 Atlas 吐出的集群级实时指标——每个作业的 CPU、网络、Kafka lag、摄入速率、消费速率。&lt;/p&gt;
&lt;p&gt;决策逻辑是几种信号的组合：lag 推导出的追赶时间、CPU/网络利用率阈值、历史性能基线、对近期摄入速率做回归预测，然后决定扩容，或者判断&amp;quot;更小的集群也扛得住未来窗口&amp;quot;从而缩容。&lt;/p&gt;
&lt;p&gt;这个设计有两个当时看来很聪明、事后定义了天花板的选择：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;它独立于 Flink 平台运行&lt;/strong&gt;。Flink 自己出问题时扩缩容不受牵连；而且按&amp;quot;流处理作业&amp;quot;的形态来构建，天然好扩展——每个 autoscaler 节点负责一部分 Flink 作业的指标，从没写过什么分片协调逻辑。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;成绩单不差&lt;/strong&gt;：在几千个托管流水线上稳定砍下 25%-45% 的资源用量（Flink Forward 2020 分享过）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但&amp;quot;从外面看&amp;quot;是有代价的，原文叫它 ceiling（天花板）：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;粗粒度 + 单一旋钮&lt;/strong&gt;。它通过容器级指标推理整个集群，而且只能调一个数——TaskManager 总数，也就是作业里所有算子同比例缩放。对单算子简单流水线够用，对多算子、有状态的 DAG（广告、推荐、游戏团队越来越多拿来的正是这种）就无能为力——而这恰恰是它推理不了的作业类型。每接一种新 case 就得加定制逻辑，形不成通用能力。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;指标会骗你&lt;/strong&gt;。autoscaler 的天花板就是外部指标系统的天花板。有的作业明明忙得团团转，CPU 利用率上却毫无体现，作业卡在降级状态里，scaler 根本看不见。更狠的一次：一次网络迁移悄悄改变了部分流量的上报口径，Atlas 里 scaler 依赖的一组指标开始漏报——&lt;strong&gt;这个缺口在生产环境潜伏了很久才暴露&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;到这一步，build vs buy 被重新摆上了桌面。&lt;/p&gt;
&lt;h2 id=&#34;第二代从里面推理的-autoscaler开源方案&#34;&gt;第二代：从里面推理的 autoscaler（开源方案）&lt;/h2&gt;
&lt;p&gt;2019 年自建时，Flink 社区没有成熟的 autoscaler；等 Netflix 重新评估时，已经有了：Apache Flink Autoscaler。思路发生了根本变化——&lt;strong&gt;不从外面看容器，从作业内部推理&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;核心概念是&lt;strong&gt;真实处理速率（TPR, True Processing Rate）&lt;/strong&gt;：一个算子如果满负荷运转，能扛住的吞吐是多少。&lt;/p&gt;
&lt;p&gt;Flink 会按 subtask 上报&amp;quot;每秒真正干活的时间占比&amp;quot;（和背压时间、空闲时间分开统计）。用观测吞吐除以忙碌占比，就能把容量外推到满负荷：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;一个算子实测每秒处理 700 条记录、忙碌占比 70%，TPR = 700 / 0.7 = 1000 条/秒。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;有了这个数，autoscaler 从 source 开始遍历整个作业 DAG，结合每个算子的 TPR、输入输出比率和目标利用率，&lt;strong&gt;逐顶点计算需要的并行度&lt;/strong&gt;——保证没有任何一个算子成为瓶颈，而不是把整个集群当整体伸缩。&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    A[&amp;#34;Flink 上报&amp;lt;br/&amp;gt;每个 subtask 的忙碌占比&amp;#34;] --&amp;gt; B[&amp;#34;TPR = 观测吞吐 / 忙碌占比&amp;lt;br/&amp;gt;外推满负荷容量&amp;#34;]
    B --&amp;gt; C[&amp;#34;从 source 遍历作业 DAG&amp;lt;br/&amp;gt;结合输入输出比率&amp;#34;]
    C --&amp;gt; D[&amp;#34;逐顶点计算目标并行度&amp;lt;br/&amp;gt;没有算子成为瓶颈&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;两代方案的契约差异：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;维度&lt;/th&gt;
					&lt;th&gt;自研（外部观察）&lt;/th&gt;
					&lt;th&gt;开源（内部推理）&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;信号来源&lt;/td&gt;
					&lt;td&gt;容器级指标（Atlas）&lt;/td&gt;
					&lt;td&gt;作业内 per-vertex 指标&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;决策单元&lt;/td&gt;
					&lt;td&gt;整个集群一个旋钮&lt;/td&gt;
					&lt;td&gt;DAG 逐顶点并行度&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;适用作业&lt;/td&gt;
					&lt;td&gt;单算子简单流水线&lt;/td&gt;
					&lt;td&gt;多算子有状态 DAG&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;配置粒度&lt;/td&gt;
					&lt;td&gt;全局统一&lt;/td&gt;
					&lt;td&gt;每个作业可自定义&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;对 Netflix 来说，决定性的就是后两行：开源方案能扩缩的恰好是自研系统天生处理不了的那类作业，而且每个作业可以带自己的配置（稳定期、阈值等扩缩行为参数）——这正是那些一直被人工扩缩容的自定义作业需要的。&lt;/p&gt;
&lt;h2 id=&#34;落到三万个作业三个工程缺口&#34;&gt;落到三万个作业：三个工程缺口&lt;/h2&gt;
&lt;p&gt;算法层面拿来即用，社区已经把难的部分做完了。Netflix 的工作是让它在自己的体系里可靠运转——这才是&amp;quot;社区能跑&amp;quot;和&amp;quot;Netflix 规模能跑&amp;quot;之间的距离。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;接入方式：独立库 + 四个接口。&lt;/strong&gt; 开源 autoscaler 最初架构在 Flink Kubernetes Operator 里，而 Netflix 的 Flink 平台跑在自己的控制面上。社区后来做了一个关键决策：把核心逻辑抽成独立库，只留四个通用接口——携带作业元数据和 REST 信息的 context、状态存储、事件处理器、执行扩缩动作的 realizer。Netflix 的接入服务是一个 Spring Boot 应用，编排跑在 Temporal（持久化工作流引擎）上：一个编排工作流每分钟轮询一次控制面，为每个开启扩缩容的作业启动一个独立的长运行工作流。&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    A[&amp;#34;Temporal 编排工作流&amp;lt;br/&amp;gt;每分钟轮询控制面&amp;#34;] --&amp;gt; B[&amp;#34;每个作业一个&amp;lt;br/&amp;gt;独立长运行工作流&amp;#34;]
    B --&amp;gt; C[&amp;#34;从 JobManager 拉&amp;lt;br/&amp;gt;per-vertex 指标&amp;#34;]
    C --&amp;gt; D[&amp;#34;跑开源评估算法&amp;#34;]
    D --&amp;gt; E[&amp;#34;realizer 通过控制面&amp;lt;br/&amp;gt;执行扩缩&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&amp;ldquo;每个作业一个工作流&amp;quot;是直接被 pain 教育出来的设计：最初用单一批处理循环评估所有作业，&lt;strong&gt;一个慢作业或异常作业能卡住它后面所有作业的指标采集和扩缩&lt;/strong&gt;。拆开之后，单个作业出问题只在自己的工作流里失败重试，爆炸半径被隔离，接入更多作业时运行时水平扩展即可。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;三个必须自己补的工程缺口：&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;高并行度下的指标采集&lt;/strong&gt;。大作业上从 JobManager 拉指标成为瓶颈，部分原因在 Flink 运行时本身。Netflix 改了 JobManager：缓存瞬时指标名、统一清理而不是每次拉取都重扫，并加了服务端过滤让 autoscaler 只拉需要的指标。效果：可用规模从约 1000 个 subtask 提到 3000 个。这些改动在内部 fork 里，部分已贡献回上游（如 FLINK-36172）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;保住 forward chaining&lt;/strong&gt;。由 forward 边连接的两个顶点必须同并行度——记录是在内存里走固定本地通道交接的。如果只扩一个，Flink 不会报错，&lt;strong&gt;它会悄悄把这条边转成网络 shuffle&lt;/strong&gt;（性能完全另一回事）。Netflix 的 fork 会检测 forward 连接的子图，把它们当整体扩缩。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;尊重 sink 的容量上限&lt;/strong&gt;。有些 sink 写入能力有限，把上游作业扩上去只会把自己顶死。fork 里加了 async-sink 背压检测，阻止 autoscaler 把作业扩到 sink 吃不下。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最后还有 realizer 的&lt;strong&gt;安全检查&lt;/strong&gt;：比如公司级区域故障转移期间，拒绝在被撤离的区域缩容；执行前确认新集群磁盘放得下作业的 checkpoint 状态；大集群再加一点 standby 缓冲。&lt;/p&gt;
&lt;h2 id=&#34;效果与调校&#34;&gt;效果与调校&lt;/h2&gt;
&lt;p&gt;开源方案的 autoscaler 去年在 Netflix 自定义作业上 GA，早期结果里有个硬数字：客户端遥测日志团队的 Flink 计算支出&lt;strong&gt;年化下降 58%，约省 110 万美元&lt;/strong&gt;。效率来自三件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;静态配置永远要按峰值留量，autoscaler 吃掉了昼夜和周末低谷；&lt;/li&gt;
&lt;li&gt;性能优化或节后流量回落之后，不再依赖团队手工降配，autoscaler 持续跟进；&lt;/li&gt;
&lt;li&gt;统一容器规格带来更好的装箱（bin-packing）和更细的扩缩粒度。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但&amp;quot;缩得太狠&amp;quot;是另一个坑：砍过头 CPU 打满、lag 飙升，而系统没法立刻反应——每次重启后指标窗口和稳定期都要重建。Netflix 现在把&lt;strong&gt;目标利用率定在 0.45，低于社区默认的 0.7&lt;/strong&gt;，故意用一点效率换稳定：对大型有状态作业，更少、更平稳的扩缩动作值这个价。&lt;/p&gt;
&lt;p&gt;剩下的最大成本已经不是 scaler 的逻辑，而是&lt;strong&gt;重启和状态恢复本身&lt;/strong&gt;。Flink 2 的分离式状态架构（状态放外部存储而不是本地磁盘）有望让扩缩代价与状态大小解耦——Netflix 已开始支持 Flink 2.2，准备实验新 state backend。&lt;/p&gt;
&lt;h2 id=&#34;三条可以带走的教训&#34;&gt;三条可以带走的教训&lt;/h2&gt;
&lt;p&gt;文章结尾总结的三条，泛化到 Flink 之外依然成立：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;指标选择比算法精巧更重要。&lt;/strong&gt; 最有价值的调试很少是关于扩缩数学的，而是&amp;quot;该信哪个信号&amp;rdquo;。调算法之前，先搞懂你的指标——口径会变、会漏报、会测不到真正的瓶颈（第一代 scaler 的两次事故都是指标层的）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;默认值要合理，但留出调节空间。&lt;/strong&gt; 托管作业相似度够高，一套好默认值覆盖大多数——这正是平台的意义。但把单一配置强加给所有作业，是在惩罚不适配的那些：默认值 + 每作业覆盖，同时把需要深专业知识的旋钮藏起来。大多数团队永远不该需要思考 autoscaler 的存在。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;先采用，再扩展（Adopt, then extend）。&lt;/strong&gt; 2019 年自建是因为没有成熟选项；社区方案出现后，正确的动作既不是永远捍卫自己的投资，也不是连夜拆掉，而是：&lt;strong&gt;新工作负载先用开源的，把修复贡献回上游，再有计划地迁移存量&lt;/strong&gt;。自研基础设施的真实成本，要在&amp;quot;本来可以采用现成方案&amp;quot;的对照下衡量。&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;一句话收拢：自动扩缩容的难点从来不在&amp;quot;扩缩&amp;quot;二字，而在于&lt;strong&gt;你凭什么相信指标、怎么隔离故障、以及什么时候承认社区已经做得比你好了&lt;/strong&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;来源：Samuel Yeboah, Francesco Di Chiara, Mingliang Liu, &amp;ldquo;&lt;a href=&#34;https://netflixtechblog.com/a-tale-of-two-flink-autoscalers-e9f6a1b1492b&#34;&gt;A Tale of Two Flink Autoscalers&lt;/a&gt;&amp;rdquo;, Netflix TechBlog。&lt;/em&gt;&lt;/p&gt;
</description>
    </item>
    <item>
      <title>《设计数据密集型应用》学习总结：存量、流量、回路——每个设计决策都是三者的权衡</title>
      <link>https://ftmao.com/blog/bookstore/ddia-book-notes/</link>
      <pubDate>Tue, 08 Aug 2023 10:40:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/bookstore/ddia-book-notes/</guid>
      <description>&lt;h1 id=&#34;设计数据密集型应用学习总结存量流量回路每个设计决策都是三者的权衡&#34;&gt;《设计数据密集型应用》学习总结：存量、流量、回路——每个设计决策都是三者的权衡&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;目标读者：写过 CRUD、用过 MySQL/Redis/Kafka，但说不清它们&amp;quot;为什么这么设计&amp;quot;的工程师。&lt;br&gt;
参考书籍：Martin Kleppmann《Designing Data-Intensive Applications》（DDIA，那只野猪书）&lt;br&gt;
阅读目标：不背目录，而是拿到一个能套在任何存储/中间件上的分析框架——存量、流量、回路。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;0-写在前面别背组件背权衡&#34;&gt;0. 写在前面：别背组件，背权衡&lt;/h2&gt;
&lt;p&gt;市面上的&amp;quot;中间件教程&amp;quot;有个通病：教你 MySQL 怎么用、Redis 怎么用、Kafka 怎么用，但从不告诉你&lt;strong&gt;为什么它们长得这么不一样&lt;/strong&gt;。同样是存数据，Redis 敢把数据放内存，MySQL 不敢；同样是日志，Kafka 能当数据库用，而大多数人只拿它当管道。&lt;/p&gt;
&lt;p&gt;DDIA 这本书最值钱的地方，就是把这些&amp;quot;为什么&amp;quot;串成了一条线。读完我把它提炼成三个词：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;存量（State）&lt;/strong&gt;：数据怎么&lt;strong&gt;存&lt;/strong&gt;——存储引擎（LSM/B-Tree）、编码格式、行存列存。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;流量（Flow）&lt;/strong&gt;：数据怎么&lt;strong&gt;动&lt;/strong&gt;——复制、分区、派生数据、流处理。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;回路（Loop）&lt;/strong&gt;：系统怎么&lt;strong&gt;自我纠错&lt;/strong&gt;——事务、隔离级别、共识、线性一致性。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一句话理解：&lt;strong&gt;任何一个数据系统的设计决策，都是在存量成本、流量吞吐、回路强度三者之间做取舍。&lt;/strong&gt; 你要强一致（回路强），就得牺牲可用性或延迟（流量贵）；你要写入快（流量大），就得接受读时合并或后台压缩的代价（存量结构变复杂）。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;避坑指南 0&lt;/strong&gt;：读完这本书最大的误区是&amp;quot;上来就选最强的一致性&amp;quot;。Kleppmann 自己说过大意如此的话：一致性模型的选择应该由&lt;strong&gt;业务能不能容忍异常&lt;/strong&gt;决定，而不是由工程师的洁癖决定。能容忍就用最终一致，性能白捡。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;和博客已有的 Redis/MySQL/MQ 笔记是&amp;quot;原理 vs 用法&amp;quot;的关系：那些笔记讲&amp;quot;这个命令怎么用、这个参数怎么调&amp;quot;，这篇讲&amp;quot;这个组件为什么被设计成这样&amp;quot;。建议搭配阅读——看完这篇再回头看 Redis 的 RDB/AOF、MySQL 的 InnoDB redo log、Kafka 的副本协议，会有&amp;quot;原来如此&amp;quot;的感觉。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第一部分-存量数据怎么存&#34;&gt;第一部分 存量：数据怎么存&lt;/h2&gt;
&lt;h3 id=&#34;1-lsm-tree-vs-b-tree写的快和读的快不可兼得&#34;&gt;1. LSM-Tree vs B-Tree：写的快和读的快，不可兼得&lt;/h3&gt;
&lt;p&gt;数据库索引结构就两大流派，区别一句话：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;B-Tree&lt;/strong&gt;：读的时候顺藤摸瓜，树的高度就是磁盘寻道次数。写入是&amp;quot;就地更新&amp;quot;——找到那一页，改掉。MySQL InnoDB 就是这个。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LSM-Tree&lt;/strong&gt;：写入只往内存里的有序结构（Memtable）追加，内存写满了就整体刷到磁盘变成一个 &lt;strong&gt;SSTable&lt;/strong&gt;（Sorted String Table，一段有序的文件）。读取时从最新的 SSTable 往最旧的依次找。LevelDB、RocksDB、Cassandra、HBase 都是这个。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;SSTable 合并（compaction）是 LSM 的灵魂&lt;/strong&gt;。因为同一个 key 的新旧版本散落在多个 SSTable 里，后台必须定期把它们归并排序、去重、丢掉被删除的墓碑标记，否则文件越积越多，读放大失控。&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    W[&amp;#34;写入请求&amp;#34;] --&amp;gt; L[&amp;#34;WAL 追加日志&amp;lt;br/&amp;gt;（崩溃恢复用）&amp;#34;]
    L --&amp;gt; M[&amp;#34;Memtable&amp;lt;br/&amp;gt;（内存有序表）&amp;#34;]
    M --&amp;gt;|&amp;#34;写满阈值&amp;#34;| S1[&amp;#34;SSTable 1&amp;lt;br/&amp;gt;（不可变文件）&amp;#34;]
    M --&amp;gt;|&amp;#34;再写满&amp;#34;| S2[&amp;#34;SSTable 2&amp;#34;]
    M --&amp;gt;|&amp;#34;又写满&amp;#34;| S3[&amp;#34;SSTable 3&amp;#34;]
    S1 --&amp;gt; C[&amp;#34;后台 Compaction&amp;lt;br/&amp;gt;归并排序 + 去重 + 清墓碑&amp;#34;]
    S2 --&amp;gt; C
    S3 --&amp;gt; C
    C --&amp;gt; S4[&amp;#34;合并后的大 SSTable&amp;lt;br/&amp;gt;（旧文件丢弃）&amp;#34;]
    R[&amp;#34;读取请求&amp;#34;] --&amp;gt; M
    R --&amp;gt; S3
    R --&amp;gt; S2
    R --&amp;gt; S1
    R --&amp;gt;|&amp;#34;配合布隆过滤器&amp;lt;br/&amp;gt;跳过不含该 key 的文件&amp;#34;| MISS[&amp;#34;快速判不存在&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;小数字例子感受写放大：假设每写 1 GB 数据，经过 3 层 compaction，每层都要重写一遍，实际磁盘写入可能是 5~10 GB。这就是&amp;quot;写放大&amp;quot;，也是 RocksDB 调优的核心战场。&lt;/p&gt;
&lt;p&gt;LSM 读取慢怎么补救？靠&lt;strong&gt;布隆过滤器（Bloom filter）&lt;/strong&gt;：一个很小的内存位图，能告诉你&amp;quot;这个 key 肯定不在这个 SSTable 里&amp;quot;（可能有误报，但绝不漏报）。查一个不存在的 key 时，布隆过滤器直接拦掉，不用真的去翻磁盘文件——这对&amp;quot;查缓存未命中再查库&amp;quot;的场景是救命优化。Cassandra、HBase、RocksDB 全都内置。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;怎么选？&lt;/strong&gt; 记住这张对照表：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th style=&#34;text-align: left&#34;&gt;维度&lt;/th&gt;
					&lt;th style=&#34;text-align: left&#34;&gt;B-Tree&lt;/th&gt;
					&lt;th style=&#34;text-align: left&#34;&gt;LSM-Tree&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;写入&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;就地更新，写放大相对低但随机 IO&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;顺序追加，吞吐高但有 compaction 写放大&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;读取&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;稳定，一次查找几层树&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;要查多层文件，靠布隆过滤器和缓存补救&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;磁盘碎片&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;页分裂会产生碎片&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;文件整体重写，天然紧凑&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;典型代表&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;InnoDB、PostgreSQL&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;RocksDB、Cassandra、HBase&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;一句话理解&lt;/strong&gt;：B-Tree 是&amp;quot;图书馆书架&amp;quot;，书按位置摆好，找书快、插书要挪位置；LSM 是&amp;quot;先记在便签上，攒一批再上架&amp;quot;，记便签飞快，但定期要整理书架，找书得翻几摞便签。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&#34;2-wal所有存储引擎的保命符&#34;&gt;2. WAL：所有存储引擎的&amp;quot;保命符&amp;quot;&lt;/h3&gt;
&lt;p&gt;LSM 写 Memtable、B-Tree 改数据页，都是先动内存。断电怎么办？答案是 &lt;strong&gt;WAL（Write-Ahead Log，预写日志）&lt;/strong&gt;：&lt;strong&gt;任何修改先追加一条日志到磁盘，再改内存结构&lt;/strong&gt;。崩溃后重放日志恢复。&lt;/p&gt;
&lt;p&gt;这个思想无处不在：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;InnoDB 的 redo log 是 WAL；&lt;/li&gt;
&lt;li&gt;PostgreSQL 的 WAL 直接就叫 WAL；&lt;/li&gt;
&lt;li&gt;Redis 的 AOF 本质也是 WAL；&lt;/li&gt;
&lt;li&gt;Kafka 的消息日志、ZooKeeper 的事务日志，全是同一个套路。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;一句话理解&lt;/strong&gt;：WAL 就是把&amp;quot;随机写&amp;quot;变成&amp;quot;顺序写&amp;quot;——磁盘顺序追加比随机写快几个数量级，顺便白捡了崩溃恢复能力。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&#34;3-行存-vs-列存oltp-与-olap-的分水岭&#34;&gt;3. 行存 vs 列存：OLTP 与 OLAP 的分水岭&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;行存&lt;/strong&gt;：一行的所有列连续存放。适合&amp;quot;查一个人的全部信息&amp;quot;（点查、事务），即 OLTP。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;列存&lt;/strong&gt;：一列的所有值连续存放。适合&amp;quot;统计一百万人的平均年龄&amp;quot;（聚合、分析），即 OLAP。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;为什么列存分析快？小数字例子：一张 100 列的表，分析查询只用 5 列。行存要把 100 列全部从磁盘读出来再丢弃 95 列；列存只读那 5 列，IO 直接省 95%。而且单列数据类型相同，压缩率极高（比如&amp;quot;性别&amp;quot;列去重后就两个值，位图编码一下几乎不占空间）。&lt;/p&gt;
&lt;p&gt;代表：Parquet、ORC、ClickHouse、DuckDB 是列存；MySQL、PostgreSQL 是行存。Snowflake、BigQuery 这类数仓全部站在列存一边。&lt;/p&gt;
&lt;h3 id=&#34;4-编码与演进-schema-变了老数据怎么办&#34;&gt;4. 编码与演进： schema 变了，老数据怎么办&lt;/h3&gt;
&lt;p&gt;存量还有个隐藏维度：&lt;strong&gt;数据格式会随时间演化&lt;/strong&gt;。JSON 灵活但没约束；Protocol Buffers / Thrift / Avro 这类二进制编码通过&lt;strong&gt;字段编号&lt;/strong&gt;实现前后兼容——新增字段给新编号，删掉字段不动旧编号，新老代码就能和平共处。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;避坑指南 1&lt;/strong&gt;：滚动升级时&amp;quot;新代码读老数据&amp;quot;和&amp;quot;老代码读新数据&amp;quot;会同时发生。所以兼容性必须是&lt;strong&gt;双向&lt;/strong&gt;的：向后兼容（新读老）+ 向前兼容（老读新）。只考虑一个方向，升级到一半必出事。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第二部分-流量数据怎么动&#34;&gt;第二部分 流量：数据怎么动&lt;/h2&gt;
&lt;p&gt;单台机器存不下、扛不住、会宕机——这是&amp;quot;流量篇&amp;quot;要解决的三个问题。对应三板斧：&lt;strong&gt;复制&lt;/strong&gt;（同一份数据放多台机器）、&lt;strong&gt;分区&lt;/strong&gt;（不同数据放不同机器）、&lt;strong&gt;派生数据&lt;/strong&gt;（数据从一个系统流向另一个系统）。&lt;/p&gt;
&lt;h3 id=&#34;5-复制主从多主无主三种政治体制&#34;&gt;5. 复制：主从、多主、无主，三种政治体制&lt;/h3&gt;
&lt;p&gt;复制的目标是可用性和读扩展，但引入了全书最核心的矛盾：&lt;strong&gt;副本之间怎么保持一致&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;主从复制（单主）&lt;/strong&gt;：所有写请求走 Leader，Leader 把变更日志同步给 Follower。读可以分摊到 Follower。简单、直觉，MySQL、PostgreSQL、MongoDB（副本集）、Kafka 分区副本都是这个思路。&lt;/p&gt;
&lt;p&gt;但&amp;quot;Follower 的数据是旧版本&amp;quot;这件事，会带来一连串用户可感知的诡异现象：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;你刚发的朋友圈自己刷新后消失了&lt;/strong&gt; → 需要&lt;strong&gt;读己之写（read-your-writes）&lt;/strong&gt;：用户写完之后，读请求必须走 Leader 或带版本号校验。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;你的余额从 100 变成 80 又变回 100&lt;/strong&gt;（读到了更旧的副本）→ 需要&lt;strong&gt;单调读（monotonic reads）&lt;/strong&gt;：一个用户的连续读请求固定打到同一个副本。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;聊天里先看到回复再看到原帖&lt;/strong&gt;（分区的数据流向不同副本速度不同）→ 需要&lt;strong&gt;一致前缀读（consistent prefix reads）&lt;/strong&gt;：有因果关系的写入，任何读者都应该按相同顺序看到。&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;sequenceDiagram
    participant C as 客户端
    participant L as Leader（主库）
    participant F1 as Follower 1（同步副本）
    participant F2 as Follower 2（异步副本）

    C-&amp;gt;&amp;gt;L: 写 x = 1
    L-&amp;gt;&amp;gt;L: 写本地 WAL 并提交
    L-&amp;gt;&amp;gt;F1: 同步复制：等 F1 确认
    F1--&amp;gt;&amp;gt;L: ACK
    L--&amp;gt;&amp;gt;C: 写入成功
    L-&amp;gt;&amp;gt;F2: 异步复制：后台慢慢发
    Note over F2: 此时 F2 还是旧值 x = 0&amp;lt;br/&amp;gt;复制延迟窗口
    C-&amp;gt;&amp;gt;F2: 读 x
    F2--&amp;gt;&amp;gt;C: x = 0（读到旧数据！）
    Note over C,F2: 这就是&amp;#34;读己之写&amp;#34;被破坏的瞬间&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;同步 vs 异步是个硬权衡&lt;/strong&gt;：全同步，任何一个副本宕机写入就卡死；全异步，Leader 宕机时没同步完的写入直接丢失。实践中的折中是&amp;quot;半同步&amp;quot;——一个副本同步、其余异步（MySQL 的 semi-sync 就是这个）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;多主复制&lt;/strong&gt;：多个节点都能接受写，彼此同步。适合多数据中心就近写入，但&lt;strong&gt;写冲突&lt;/strong&gt;是噩梦——两个机房同时改同一条数据，谁说了算？常见解法：LWW（最后写入者胜，靠时间戳，会丢数据）、CRDT（无冲突数据结构）、应用层解决。CouchDB 走这条路。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;无主复制（Dynamo 风格）&lt;/strong&gt;：没有 Leader，写入时客户端把数据发给 N 个副本中的任意 W 个，读的时候从 R 个里读，只要 &lt;strong&gt;W + R &amp;gt; N&lt;/strong&gt; 就能保证读到最新值（法定人数，quorum）。经典参数 N=3, W=2, R=2。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;经典案例：Amazon Dynamo 论文与购物车&lt;/strong&gt;。2007 年 Amazon 发表 Dynamo 论文，动机极其务实：&lt;strong&gt;购物车绝不能拒绝写入&lt;/strong&gt;——用户加购物车被拒，就是真金白银的损失。所以 Dynamo 选择永远可写（AP），哪怕网络分区时也接受写入，事后再合并冲突版本（著名的&amp;quot;向量时钟&amp;quot;就出自这篇论文）。这篇论文直接催生了 Riak、Cassandra、Voldemort 一整代 NoSQL。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;一句话理解&lt;/strong&gt;：单主是&amp;quot;独裁&amp;quot;，多主是&amp;quot;联邦&amp;quot;，无主是&amp;quot;全民投票&amp;quot;。越民主写入越自由，但事后摆平分歧的成本越高。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&#34;6-分区数据切蛋糕的两种刀法&#34;&gt;6. 分区：数据切蛋糕的两种刀法&lt;/h3&gt;
&lt;p&gt;单机存不下就分区（分片）。两种切法：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;按 key 范围分区&lt;/strong&gt;：字典式切分，A&lt;del&gt;M 一个分区、N&lt;/del&gt;Z 一个分区。范围查询（&amp;ldquo;查 7 月的所有订单&amp;rdquo;）天然高效，但&lt;strong&gt;热点&lt;/strong&gt;问题明显——如果 key 是时间戳，所有新写入全砸在最后一个分区上。Bigtable、HBase 用这套。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;按键的哈希分区&lt;/strong&gt;：key 过一遍哈希函数打散。负载均匀，但范围查询要 scatter-gather 到所有分区，效率骤降。Cassandra、DynamoDB 用这套（Cassandra 实际上是&amp;quot;哈希定分区 + 分区内按 key 排序&amp;quot;的混合体）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;再平衡（rebalancing）&lt;/strong&gt; 是分区系统真正的试金石：加机器、减机器、某分区过热，数据都要搬家。要求搬家期间读写不停、搬迁量最小。&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    subgraph 扩容前[&amp;#34;扩容前：3 节点，每节点 4 个虚拟分区&amp;#34;]
        N1[&amp;#34;节点 A&amp;lt;br/&amp;gt;v0 v1 v2 v3&amp;#34;]
        N2[&amp;#34;节点 B&amp;lt;br/&amp;gt;v4 v5 v6 v7&amp;#34;]
        N3[&amp;#34;节点 C&amp;lt;br/&amp;gt;v8 v9 v10 v11&amp;#34;]
    end
    subgraph 扩容后[&amp;#34;扩容后：新节点 D 加入&amp;lt;br/&amp;gt;只搬走 3 个虚拟分区，其余不动&amp;#34;]
        N1b[&amp;#34;节点 A&amp;lt;br/&amp;gt;v0 v1 v2&amp;#34;]
        N2b[&amp;#34;节点 B&amp;lt;br/&amp;gt;v4 v5 v6&amp;#34;]
        N3b[&amp;#34;节点 C&amp;lt;br/&amp;gt;v8 v9 v10&amp;#34;]
        N4[&amp;#34;节点 D&amp;lt;br/&amp;gt;v3 v7 v11&amp;lt;br/&amp;gt;（从 A/B/C 各搬一个）&amp;#34;]
    end
    扩容前 --&amp;gt;|&amp;#34;固定数量虚拟分区策略&amp;lt;br/&amp;gt;只迁移少量 vNode&amp;#34;| 扩容后&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;主流策略就是图里这个：&lt;strong&gt;固定数量的虚拟分区&lt;/strong&gt;（vNode）——分区数远多于节点数，加节点时从每个老节点&amp;quot;匀&amp;quot;几个虚拟分区过来。Riak、Cassandra 都这么干。反例是取模哈希（hash mod N）：节点数一变，几乎全部 key 都要搬家，这就是一致性哈希要解决的原始问题。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;避坑指南 2&lt;/strong&gt;：分区键一旦选定，几乎不可更改。用 user_id 做分区键的系统，后来想做&amp;quot;按时间范围全局扫描&amp;quot;，会发现每个查询都要打满全部分区。选分区键时想清楚：&lt;strong&gt;你的主要查询模式是什么，热点在哪&lt;/strong&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&#34;7-派生数据一个数据系统的输出是另一个系统的输入&#34;&gt;7. 派生数据：一个数据系统的输出，是另一个系统的输入&lt;/h3&gt;
&lt;p&gt;Kleppmann 有个非常锋利的视角：&lt;strong&gt;缓存、搜索索引、数据仓库，本质上都是派生数据&lt;/strong&gt;——它们都是从某个&amp;quot;真相源&amp;quot;（source of truth）加工出来的冗余副本。&lt;/p&gt;
&lt;p&gt;一旦接受这个设定，很多架构问题豁然开朗：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;缓存和数据库不一致？因为派生数据没有和源数据同步更新。&lt;/li&gt;
&lt;li&gt;ES 里的搜索结果和 MySQL 对不上？同理。&lt;/li&gt;
&lt;li&gt;数仓报表延迟一天？因为派生管道是 T+1 的批处理。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;于是问题从&amp;quot;怎么让缓存不失效&amp;quot;变成&amp;quot;&lt;strong&gt;派生管道怎么设计&lt;/strong&gt;&amp;quot;，这正是流处理要回答的，下文第 10 节展开。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第三部分-回路系统怎么自我纠错&#34;&gt;第三部分 回路：系统怎么自我纠错&lt;/h2&gt;
&lt;p&gt;存量和流量解决了&amp;quot;存得下、流得动&amp;quot;，剩下的问题是：&lt;strong&gt;当多个客户端并发操作、机器会宕机、网络会分区时，系统凭什么保证数据不出错？&lt;/strong&gt; 这就是回路——一致性保障与反馈机制。&lt;/p&gt;
&lt;h3 id=&#34;8-事务与隔离级别用弱隔离换性能代价是异常&#34;&gt;8. 事务与隔离级别：用&amp;quot;弱隔离&amp;quot;换性能，代价是异常&lt;/h3&gt;
&lt;p&gt;事务（ACID）是数据库给程序员的最大善意：&lt;strong&gt;把一堆操作打包成原子单元，要么全成要么全不成，并发的麻烦我来扛&lt;/strong&gt;。但&amp;quot;我来扛&amp;quot;是有价格的——严格串行执行性能太差，于是数据库发明了隔离级别这个&amp;quot;滑杆&amp;quot;，让你自己选扛多少：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th style=&#34;text-align: left&#34;&gt;隔离级别&lt;/th&gt;
					&lt;th style=&#34;text-align: left&#34;&gt;防住的异常&lt;/th&gt;
					&lt;th style=&#34;text-align: left&#34;&gt;防不住的异常&lt;/th&gt;
					&lt;th style=&#34;text-align: left&#34;&gt;谁在用&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;读未提交&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;几乎啥都不防&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;脏读、一切&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;基本没人用&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;读提交（RC）&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;脏读、脏写&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;不可重复读、写倾斜&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;Oracle、PostgreSQL 的默认&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;快照隔离（SI）&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;上面所有 + 不可重复读&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;&lt;strong&gt;写倾斜&lt;/strong&gt;&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;PostgreSQL 的 RR、MySQL InnoDB 的 RR&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;串行化&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;一切&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;无&lt;/td&gt;
					&lt;td style=&#34;text-align: left&#34;&gt;性能最好时的梦想&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;快照隔离（Snapshot Isolation）&lt;/strong&gt; 值得单独说，因为它是现代数据库的主力。原理是 MVCC（多版本并发控制）：每次写入不覆盖旧值，而是生成新版本，每个事务看到&amp;quot;它开始那一刻&amp;quot;的数据快照。读不阻塞写、写不阻塞读，性能极佳。InnoDB 的 undo log、PostgreSQL 的多版本堆表，都是 MVCC 实现。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;写倾斜（write skew）是快照隔离最大的盲区&lt;/strong&gt;，也是面试和实战最容易踩的坑。小例子：医院排班系统要求&amp;quot;至少有一名医生值班&amp;quot;，当前有 Alice 和 Bob 两人值班。两人同时点&amp;quot;请假&amp;quot;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Alice 的事务读到&amp;quot;2 人值班&amp;quot;，判断&amp;quot;我请假后还剩 1 人，OK&amp;quot;，提交；&lt;/li&gt;
&lt;li&gt;Bob 的事务同样读到&amp;quot;2 人值班&amp;quot;（快照！他看不到 Alice 未提交的修改），同样判断 OK，提交；&lt;/li&gt;
&lt;li&gt;结果：两人都不值班了，约束被破坏。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;注意这不是脏读也不是不可重复读——每个事务内部看到的数据完全一致，但&lt;strong&gt;两个事务基于同一份过期前提做出了互相冲突的决定&lt;/strong&gt;。解法只有：升级到串行化，或者用 &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; 物化冲突（把隐含的读前提变成显式行锁）。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;避坑指南 3&lt;/strong&gt;：凡是有&amp;quot;读取一个约束条件，然后据此决定写入&amp;quot;模式的代码（库存扣减、名额抢占、余额校验），在 RR/快照隔离下都可能写倾斜。别指望 ORM 救你，老老实实 &lt;code&gt;FOR UPDATE&lt;/code&gt; 或上串行化。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;串行化的三条实现路线&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;真的串行执行&lt;/strong&gt;：单线程跑事务（Redis 单线程模型、VoltDB），简单暴力，内存够快时性能惊人；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;两阶段锁（2PL）&lt;/strong&gt;：读写都加锁，性能差但正确，MySQL 的串行化级别就是这么实现的，也是死锁的主要来源；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;可串行化快照隔离（SSI）&lt;/strong&gt;：快照隔离之上检测危险读写依赖，有冲突就中止重试。PostgreSQL 9.1+ 的串行化级别用的就是它，是近几十年事务领域最重要的学术成果落地。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&#34;9-共识与线性一致性分布式系统的终极回路&#34;&gt;9. 共识与线性一致性：分布式系统的&amp;quot;终极回路&amp;quot;&lt;/h3&gt;
&lt;p&gt;事务解决的是&amp;quot;单机/单系统内的并发正确性&amp;quot;，共识（consensus）解决的是更难的问题：&lt;strong&gt;一群会宕机、消息会延迟的机器，怎么对&amp;quot;某件事&amp;quot;达成一致&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;先纠正一个常见混淆：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;线性一致性（Linearizability）&lt;/strong&gt;：让系统表现得&amp;quot;像只有一份数据&amp;quot;——任何人读到 x=1 之后，不可能再读到 x=0。它是对&lt;strong&gt;单个对象&lt;/strong&gt;的读写一致性承诺。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;共识（Consensus）&lt;/strong&gt;：多个节点对&lt;strong&gt;某个值/某条决策&lt;/strong&gt;达成不可逆的一致，典型算法 Paxos、Raft、Zab。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两者的关系：要实现线性一致的存储，背后几乎必然要靠共识算法选主或定序。ZooKeeper（Zab）、etcd（Raft）就是共识系统的工业代表，整个 Kubernetes 的正确性底座就是 etcd 里的那套 Raft。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;经典案例：Google Spanner 与 TrueTime&lt;/strong&gt;。Spanner 要在全球范围提供&lt;strong&gt;外部一致性&lt;/strong&gt;（比线性一致更强，跨事务的全局顺序），最大的拦路虎是：分布式系统里各机器时钟不可靠，你没法靠时间戳判断两个事务谁先谁后。Google 的解法粗暴而硬核——&lt;strong&gt;给数据中心装 GPS 接收机和原子钟&lt;/strong&gt;，把时钟误差压缩到一个已知的小区间（TrueTime API 返回的不是一个时间点，而是&amp;quot;真实时间必然落在的这个区间&amp;quot;）。提交事务时，如果发现误差区间还没过去，就&lt;strong&gt;故意等待&lt;/strong&gt;（commit wait）直到确定。用硬件换确定性，用等待换正确性——这是&amp;quot;回路强度可以花钱买&amp;quot;的最著名示范。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;FLP 不可能定理&lt;/strong&gt;泼一盆冷水：在纯异步网络里，哪怕只有一台机器可能崩溃，也不存在&lt;strong&gt;确定性&lt;/strong&gt;的、保证能终止的共识算法。工程上的出路是引入&amp;quot;部分同步&amp;quot;假设（网络总会恢复）和超时机制——所以 Raft/Paxos 的正确性不依赖时钟，但&lt;strong&gt;活性&lt;/strong&gt;（liveness，能不能往前走）依赖。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;极端案例：比特币与拜占庭容错&lt;/strong&gt;。上面说的共识算法都假设节点&amp;quot;可能宕机但不会撒谎&amp;quot;（崩溃容错 CFT）。比特币面对的是更恶劣的环境：&lt;strong&gt;节点可能作恶&lt;/strong&gt;（拜占庭将军问题）。中本聪的解法是放弃确定性共识，改用工作量证明（PoW）+ 最长链规则做&lt;strong&gt;概率性共识&lt;/strong&gt;——一笔交易等 6 个区块确认，被推翻的概率指数级下降但永不为零。性能极差（比特币全网约 7 TPS，对比 Visa 峰值数万 TPS），换来的是&amp;quot;无许可环境下没人能篡改历史&amp;quot;。这是把回路强度推到极限、把流量吞吐牺牲殆尽的极端案例，也完美印证了我们的框架：&lt;strong&gt;一切都是权衡&lt;/strong&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;共识落地前，还有个更现实的问题：故障检测&lt;/strong&gt;。分布式系统判断&amp;quot;某节点死了&amp;quot;的唯一办法是超时——但超时无法区分&amp;quot;节点真死了&amp;quot;和&amp;quot;节点只是 GC 停顿了 30 秒&amp;quot;（Java 应用的 Full GC、虚拟机的 steal time、网络拥塞都会造成这种&amp;quot;假死&amp;quot;）。于是就有了&lt;strong&gt;脑裂（split-brain）&lt;/strong&gt;：老 Leader 以为自己还是 Leader，新 Leader 已经被选出来，两个&amp;quot;主&amp;quot;同时接受写入，数据互相覆盖。&lt;/p&gt;
&lt;p&gt;工程解法是 &lt;strong&gt;fencing token（防护令牌）&lt;/strong&gt;：每次选主，共识系统发一个单调递增的编号，存储层只接受&amp;quot;编号比上次大&amp;quot;的写入。老 Leader 拿着旧编号来写，直接被拒。这个小机制在 ZooKeeper（zxid）、etcd（revision）里都有对应物。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;业界掌故&lt;/strong&gt;：Kleppmann 本人就是分布式锁话题的&amp;quot;圈内名人&amp;quot;——2016 年他发文《How to do distributed locking》批判 Redis 的 Redlock 算法在时钟跳变和进程暂停下不安全，引来 Redis 作者 antirez 的公开反驳，两人来回论战了好几轮，成为分布式领域最著名的公开辩论之一。争论的核心正是上面这些：时钟不可信、暂停不可测，所以任何分布式锁要么上 fencing token，要么干脆用共识系统。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&#34;10-批处理-vs-流处理同一枚硬币的两面&#34;&gt;10. 批处理 vs 流处理：同一枚硬币的两面&lt;/h3&gt;
&lt;p&gt;最后回到流量，看数据处理的两种世界观：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;批处理&lt;/strong&gt;：数据是&lt;strong&gt;有界的&lt;/strong&gt;——一堆文件摆在那，MapReduce/Spark 跑一遍，输出结果。优点是吞吐高、可重跑、容错简单（任务挂了重跑）；缺点是延迟以小时计。Hadoop/MapReduce 的黄金时代（Google 2004 年论文、Yahoo 孵化 Hadoop）就是这套世界观。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;流处理&lt;/strong&gt;：数据是&lt;strong&gt;无界的&lt;/strong&gt;——事件源源不断地来，Kafka + Flink/Storm 逐条处理，延迟毫秒级。难点：乱序、恰好一次语义（exactly-once）、容错状态恢复。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Lambda 架构&lt;/strong&gt;：流处理快但不准（可能丢、可能重复），批处理准但慢，那就两条都跑——流处理给实时近似结果，批处理定期用精确结果覆盖。维护两套系统、两份代码，运维成本翻倍，是出名的&amp;quot;说起来美、做起来痛&amp;quot;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Kappa 架构&lt;/strong&gt;：LinkedIn 的 Jay Kreps（Kafka 作者）提出的简化——&lt;strong&gt;只要流处理一条腿&lt;/strong&gt;。历史数据重算？把 Kafka 的日志保留期拉长，让流处理程序从头重放一遍就行。这个架构能成立的前提，是 Kafka 把&amp;quot;消息队列&amp;quot;做成了&amp;quot;可重放的日志存储&amp;quot;。&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    subgraph 数据源
        DB[(&amp;#34;业务数据库&amp;lt;br/&amp;gt;MySQL&amp;#34;)]
        APP[&amp;#34;应用事件&amp;#34;]
    end
    DB --&amp;gt;|&amp;#34;CDC 变更数据捕获&amp;lt;br/&amp;gt;（Debezium 监听 binlog）&amp;#34;| K[&amp;#34;Kafka&amp;lt;br/&amp;gt;（可重放的日志存储）&amp;#34;]
    APP --&amp;gt; K
    K --&amp;gt; S[&amp;#34;流处理引擎&amp;lt;br/&amp;gt;Flink / Kafka Streams&amp;#34;]
    S --&amp;gt; V1[&amp;#34;派生视图：缓存 Redis&amp;#34;]
    S --&amp;gt; V2[&amp;#34;派生视图：搜索索引 ES&amp;#34;]
    S --&amp;gt; V3[&amp;#34;派生视图：数仓 ClickHouse&amp;#34;]
    K --&amp;gt;|&amp;#34;日志长期保留&amp;lt;br/&amp;gt;需要重算时从头重放&amp;#34;| S
    subgraph KP[&amp;#34;Kappa / 批流一体&amp;#34;]
        S
    end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;这张图里藏着 DDIA 全书最重要的思想之一：&lt;strong&gt;数据库和消息队列正在融合&lt;/strong&gt;。Kafka 是可以当数据库用的日志（有保留期、可重放、有流处理）；反过来，数据库的 binlog/WAL 通过 CDC（Change Data Capture）技术流出来，就变成了消息流。Debezium 监听 MySQL binlog 把每一次行变更变成 Kafka 消息，是当下&amp;quot;派生数据管道&amp;quot;的标准做法。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;经典案例：Kafka 从 LinkedIn 内部管道到行业标准&lt;/strong&gt;。Kafka 诞生时（2010 年前后）只是 LinkedIn 内部的一个活动流（activity stream）和日志管道，目标是扛住&amp;quot;每个用户的每个页面浏览都要记录&amp;quot;的巨大流量。它的关键设计——把消息组织成&lt;strong&gt;分区有序日志&lt;/strong&gt;、消费者自己维护 offset、依赖磁盘顺序写+page cache 而非内存——让它意外地发现：这个东西不只是消息队列，而是&amp;quot;以日志为中心的存储系统&amp;quot;。Confluent 成立后，Kafka 成了流处理事实标准，Kreps 那篇《The Log: What every software engineer should know about real-time data&amp;rsquo;s unifying abstraction》（2013）把&amp;quot;日志是数据系统统一抽象&amp;quot;的思想讲透了——和 DDIA 第 3 章&amp;quot;日志结构存储&amp;quot;一脉相承。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;11-全书串讲用三个词复盘四个案例&#34;&gt;11. 全书串讲：用三个词复盘四个案例&lt;/h2&gt;
&lt;p&gt;现在用&amp;quot;存量、流量、回路&amp;quot;的框架，把本文案例快速过一遍，你会发现它们只是同一个框架的不同参数组合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Kafka&lt;/strong&gt;：存量用顺序日志（LSM 的近亲），流量靠分区+副本，回路选择&amp;quot;至少一次/恰好一次可配&amp;quot;的务实路线。→ 流量优先，回路可调。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dynamo&lt;/strong&gt;：无主复制+永远可写，回路是最终一致+应用层冲突解决。→ 流量（可用性）压倒一切，回路最弱。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spanner&lt;/strong&gt;：全球分区+同步复制，回路用 TrueTime 堆到最强（外部一致性）。→ 回路优先，花钱买。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;比特币&lt;/strong&gt;：回路要求拜占庭容错（最强敌对环境），代价是吞吐 7 TPS。→ 回路极限，流量牺牲殆尽。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;没有银弹，只有取舍。&lt;/strong&gt; 这就是 Kleppmann 反复想说、而无数&amp;quot;中间件选型指南&amp;quot;不会告诉你的事。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;12-带走的清单&#34;&gt;12. 带走的清单&lt;/h2&gt;
&lt;p&gt;读完 DDIA，做系统设计时问自己这几句话：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;存量三问&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;写入多还是读取多？写多读少看 LSM（RocksDB/Cassandra），读多写少看 B-Tree（MySQL），分析查询上列存（ClickHouse/Parquet）。&lt;/li&gt;
&lt;li&gt;崩溃恢复靠什么？找到那个 WAL——它一定存在，可能只是叫别的名字（redo log / AOF / binlog / journal）。&lt;/li&gt;
&lt;li&gt;schema 演进支持双向兼容吗？向前向后都要，滚动升级时缺一不可。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;流量三问&lt;/strong&gt;&lt;/p&gt;
&lt;ol start=&#34;4&#34;&gt;
&lt;li&gt;复制延迟用户能感知吗？能，就按需要补读己之写、单调读、一致前缀读，别等用户来报 bug。&lt;/li&gt;
&lt;li&gt;分区键选对了吗？对照主要查询模式和热点，选错分区键≈架构重来。&lt;/li&gt;
&lt;li&gt;缓存/索引/数仓是不是派生数据？是，就把&amp;quot;同步管道&amp;quot;当成一等公民来设计（CDC + 流处理），而不是事后打补丁。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;回路三问&lt;/strong&gt;&lt;/p&gt;
&lt;ol start=&#34;7&#34;&gt;
&lt;li&gt;业务能容忍什么级别的一致性？能用最终一致就别上线性一致，省下的都是真金白银。&lt;/li&gt;
&lt;li&gt;有没有&amp;quot;读约束再写入&amp;quot;的代码？有，检查写倾斜——&lt;code&gt;FOR UPDATE&lt;/code&gt; 或串行化，二选一。&lt;/li&gt;
&lt;li&gt;选主/分布式锁靠什么？别自己造轮子，ZooKeeper/etcd 这类共识系统才是正解（Redis 分布式锁的争议——Martin Kleppmann 本人就是那篇著名批判文章《How to do distributed locking》的作者，还设计了&amp;quot;马丁小花&amp;quot;fencing token 方案）。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;最后的避坑清单&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;别把默认隔离级别当成&amp;quot;安全级别&amp;quot;——PostgreSQL/MySQL 的默认都不是串行化，写倾斜在看着你。&lt;/li&gt;
&lt;li&gt;别迷信&amp;quot;分布式 = 高可用&amp;quot;——复制和分区引入的故障模式（脑裂、时钟漂移、复制滞后）比单机复杂一个数量级，Jepsen 测试报告（Kyle Kingsbury 对各大数据库的&amp;quot;虐测&amp;quot;）里全是血泪。&lt;/li&gt;
&lt;li&gt;别在不了解权衡时引用 CAP——CAP 只说&amp;quot;网络分区时一致性和可用性二选一&amp;quot;，而网络不分区时你还有延迟与一致性的权衡（PACELC 才是完整版）。&lt;/li&gt;
&lt;li&gt;时间不可信：进程会 GC 暂停、线程会被调度、时钟会跳变。所有依赖&amp;quot;本地时钟先后关系&amp;quot;的分布式逻辑都值得怀疑——这正是 Spanner 要装原子钟的原因。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;13-结语野猪书为什么是神书&#34;&gt;13. 结语：野猪书为什么是神书&lt;/h2&gt;
&lt;p&gt;市面上讲分布式的书，要么讲理论（一堆证明，读完不会写代码），要么讲产品（一堆配置，换个组件就过时）。DDIA 恰好卡在中间：&lt;strong&gt;它讲的是&amp;quot;不会过时的权衡&amp;quot;&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;LSM 和 B-Tree 的取舍，二十年前成立，二十年后还会成立；复制滞后引发的那些&amp;quot;诡异现象&amp;quot;，今天在 MySQL 从库上出现，明天就会在你的新数据库上换个马甲出现。工具会死，权衡永存。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;顺带给个阅读路径建议&lt;/strong&gt;：这本书 12 章不必按顺序啃——&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;日常写业务代码的：先读第 3 章（存储引擎）、第 7 章（事务），性价比最高；&lt;/li&gt;
&lt;li&gt;做中间件/基础架构的：第 5、6 章（复制、分区）和第 9 章（共识）是主菜；&lt;/li&gt;
&lt;li&gt;做数据平台/实时数仓的：第 10、11 章（批处理、流处理）直接对口；&lt;/li&gt;
&lt;li&gt;第 8 章（分布式系统的麻烦）建议所有人都读，它是全书&amp;quot;劝退幻想&amp;quot;的一章，读完你会对时钟和网络产生健康的怀疑。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;用一句话收尾，也是这本书给我最大的思维升级：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;不要问&amp;quot;这个系统支持什么特性&amp;quot;，要问&amp;quot;这个系统在存量、流量、回路上各做了什么取舍，这些取舍和我的业务匹配吗&amp;quot;。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;能问出这个问题，这本书就没白读。&lt;/p&gt;
</description>
    </item>
    
  </channel>
</rss>
