<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Coding Husky</title>
  
  
  <link href="https://ericfu.me/atom.xml" rel="self"/>
  
  <link href="https://ericfu.me/"/>
  <updated>2026-03-01T04:22:43.398Z</updated>
  <id>https://ericfu.me/</id>
  
  <author>
    <name>Eric Fu</name>
    
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>LanceDB: 二进制数据湖的崛起</title>
    <link href="https://ericfu.me/lancedb-and-binary-data-lake/"/>
    <id>https://ericfu.me/lancedb-and-binary-data-lake/</id>
    <published>2026-02-27T10:00:00.000Z</published>
    <updated>2026-03-01T04:22:43.398Z</updated>
    
    <content type="html"><![CDATA[<p><imgsrc="https://ericfu.me/images/lancedb-and-binary-data-lake/lancedb-logo.png" /></p><p>数据湖曾承诺可以存储任意格式的数据，但一直未能兑现——直到 AI大模型时代对海量图像、视频、文本的需求涌现。本文以 LanceDB为例，探讨如何通过列式存储、向量索引等技术，构建一套的二进制数据湖系统。</p><span id="more"></span><h2 id="背景一个迟到十年的-use-case">背景：一个迟到十年的 Use Case</h2><p>2010 年前后，数据湖（DataLake）概念横空出世。它的核心卖点之一就是：<strong>不像数仓只能存结构化数据，数据湖可以原样存储任意格式——包括图片、视频、音频</strong>。</p><figure><imgsrc="https://ericfu.me/images/lancedb-and-binary-data-lake/wikipedia-datalake.jpg"alt="2016年的维基百科对“Data Lake”的描述" /><figcaption aria-hidden="true">2016年的维基百科对“DataLake”的描述</figcaption></figure><p>然而事实上，这不过是一句空话。数据湖的主流用户是数据分析师和数据工程师，他们的世界里只有CSV、JSON、Parquet。图片和视频成了 PPT 中的一句空谈。御三家Iceberg、Delta Lake、Hudi 以及 Spark、Hive等查询引擎都专注于结构化数据，本质上仍是在取代数据仓库的位置。</p><p>直到 AI 大模型的席卷而来。训练 GPT、Stable Diffusion这样的模型，需要海量的图片、视频、音频、文本作为训练数据。经典做法是将每个文件单独存成S3 上的一个 object，然后在数据库或 Parquet里维护一张元数据表，通过文件路径去关联。但是，是否有专用的、更革命性的方式呢？</p><p>数据湖宣传了十年的那个 usecase，终于真的来了，可是它却没有准备好。</p><h2 id="解决方案以-lancedb-为例">解决方案：以 LanceDB 为例</h2><p>设想一下，你要为这些新场景开发下一代二进制数据湖，要解决哪些核心问题？LanceDB是当今最具代表性的二进制数据湖，它底层是 <strong>Lance</strong>的列式存储格式（用 Rust实现），专门针对多模态场景设计。下面我们以它为例，逐一分析这些挑战与解决方案。</p><figure><imgsrc="https://ericfu.me/images/lancedb-and-binary-data-lake/lance-arch.svg"alt="Lance 主要组件（LanceDB/Lance Namespace/Format）与经典数据湖技术栈的类比" /><figcaption aria-hidden="true">Lance 主要组件（LanceDB/LanceNamespace/Format）与经典数据湖技术栈的类比</figcaption></figure><h3 id="存储效率">存储效率</h3><p>"每个文件一个 Object"的方式下，哪怕一张图片只有几十KB，它也会在我们的元数据 DB 以及 S3 上产生一条元数据记录，独占一次 HTTP请求。当你有 10 亿张图片时，元数据开销和 API调用成本将变得不可忽视。理想的做法是将大量小文件合并打包成少数几个大文件，并在文件内部建立高效的随机访问索引——既降低IO 次数，又减少 metadata 开销。</p><p>Lance 对此给出的方案是：<strong>将二进制 Blob数据（图片、视频、音频字节）与结构化元数据（标签、时间戳、embedding向量）存储在同一张表里</strong>，并且允许直接用 offset+size的方式读取。相比于经典方案，这样也不再需要维护元数据和对象存储这两套系统之间的一致性。</p><figure><imgsrc="https://ericfu.me/images/lancedb-and-binary-data-lake/blob-as-file.png"alt="Lance 将 Blob 数据平铺地合并成一个列，并通过 offset+size 进行随机读取" /><figcaption aria-hidden="true">Lance 将 Blob数据平铺地合并成一个列，并通过 offset+size 进行随机读取</figcaption></figure><p>对于大型二进制字段，Lance 推荐将 Blob 数据存储在独立的文件中（称为 <ahref="https://lance.org/guide/blob/#example-packed-external-blobs-single-container-file">PackedExternal Blobs</a>），而 Parquet 列只保存指向它的偏移量。读取时可以lazy，只有当 reader 确实需要文件内容时再去发起 IO。</p><h3 id="检索能力">检索能力</h3><p>把数据存起来只是第一步，能查才有价值。</p><p><strong>按 ID 查询</strong>是最基本的需求——给定一个或一批 rowID，取出对应的图片和标注。这类点查需要 O(1)的时间复杂度，不能全表扫描。<ahref="https://arxiv.org/html/2504.15247v1">LanceDB 的论文</a>对此给出了针对性的格式设计，兼顾随机访问和批处理。</p><blockquote><p><strong>Lance Format 的设计</strong></p><p>Lance format试图在随机访问和批处理之间取得平衡：对于大字段（向量、图片等 ≥128字节的字段），采用"全 zip 编码"将所有 buffer按行交错排列，使得任意行的随机读取最多只需 2 次IOPS，与数据嵌套深度无关；对于小型数据，则使用类 Parquet 的 miniblock分块方案。</p><p>注意这套设计的收益主要体现在 NVMe 本地缓存层（可达数十万IOPS），在直接访问 S3 等对象存储时，由于 IOPS上限本身就只有数万，优化空间相对有限。论文的评估也基于 NVMe介质进行。</p><figure><imgsrc="https://ericfu.me/images/lancedb-and-binary-data-lake/format-comparison.png"alt="LanceDB 格式对比 Parquet/Arrow 格式，来自论文 [1]" /><figcaption aria-hidden="true">LanceDB 格式对比 Parquet/Arrow格式，来自论文 [1]</figcaption></figure></blockquote><p>二进制数据也带来了一种全新的检索范式：<strong>语义检索</strong>，比如<em>“找出所有与这张图片语义相似的图片”</em>。估计你已经猜到了，这是通过<strong>近似向量搜索（ANN）</strong>完成的。Lance格式内置了<ahref="https://lance.org/format/table/index/vector/">向量索引</a>。</p><blockquote><p>Lance 的向量索引是一个全局索引（i.e. 不按 Fragment分区），它分为3个独立层：</p><ol type="1"><li>聚类 Clustering：通过全局 K-means选出中心点，可以看作是向量索引自己的“分区”</li><li>子索引 Sub-index: 分区后的向量如何索引，可选Flat（平铺，不索引）或者 HNSW 索引</li><li>量化层：可选通过量化算法压缩长度、加速计算，可选 PQ/SQ/RabitQ等量化算法</li></ol><p>写入时，也采用了类似 LSM-Tree 的增量 compaction 方案。详细设计可以看<ahref="https://ericfu.me/images/lancedb-and-binary-data-lake/lance-vector-index.html">Claude老师的总结</a>。</p></blockquote><p>最后，LanceDB 将多种检索范式统一在一个查询接口里，允许混合查询：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">results = (</span><br><span class="line">    table.search(<span class="string">&quot;a cat sitting on a windowsill&quot;</span>, query_type=<span class="string">&quot;hybrid&quot;</span>)</span><br><span class="line">    .where(<span class="string">&quot;date &gt; &#x27;2024-01-01&#x27; AND resolution = &#x27;4K&#x27;&quot;</span>)</span><br><span class="line">    .reranker(<span class="string">&quot;cross_encoder&quot;</span>)</span><br><span class="line">    .select([<span class="string">&quot;id&quot;</span>, <span class="string">&quot;label&quot;</span>, <span class="string">&quot;embedding&quot;</span>])</span><br><span class="line">    .limit(<span class="number">20</span>)</span><br><span class="line">    .to_pandas()</span><br><span class="line">)</span><br></pre></td></tr></table></figure><h3 id="生态集成">生态集成</h3><p>最后一个问题往往被低估：<strong>数据存好了，怎么使用？</strong></p><p>数据科学家和 ML 工程师的主要工具链是 Python。他们习惯用 PandasDataFrame 操作数据，用 PyTorch DataLoader 加载训练数据，用 Ray Data做分布式数据预处理。</p><p>如果只提供 SQL 接口和 Java SDK，工程师需要自己写胶水代码才能接进PyTorch，那用户体验将是灾难性的。一套好用的 Python API 以及与PyTorch、Ray 等软件的原生集成，是二进制数据湖的必选项。</p><p>这里还藏着一个容易被忽视的难题：<strong>现有的 Python 乃至非 Python生态库，在设计时压根不知道什么是"数据湖"</strong>——它们统统假设数据以普通文件的形式存在于文件系统中。无论是OpenCV 读图片、<code>av</code> 解码视频，还是 HuggingFace 的Dataset，它们的 API 接受的都是一个 <code>File</code>（文件描述符）或文件路径。把 blob数据从列式存储里读出来之后，如何让它<strong>看起来像一个文件</strong>，是这条路上绕不过去的兼容性挑战。</p><p>为了对接大量假设“数据就是文件”的既有库，Lance 提供了 <ahref="https://github.com/lance-format/lance/blob/5989d1a9033c468cb999dddca603d72739ef3ccd/python/python/lance/blob.py#L236-L289"><code>BlobFile</code>对象</a>——一个实现了 Python <code>io.RawIOBase</code> 接口的Wrapper，可以懒加载列式存储中的 blob 数据，让下游库（如<code>av</code>、OpenCV）误以为自己在读普通文件：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">ds = lance.dataset(<span class="string">&quot;./videos.lance&quot;</span>)</span><br><span class="line">blobs = ds.take_blobs(<span class="string">&quot;video&quot;</span>, ids=[<span class="number">0</span>])</span><br><span class="line"><span class="comment"># blobs[0] 是一个 file-like 对象，可以直接传给 av、OpenCV 等库</span></span><br><span class="line"><span class="keyword">with</span> av.<span class="built_in">open</span>(blobs[<span class="number">0</span>]) <span class="keyword">as</span> container:</span><br><span class="line">    <span class="keyword">for</span> frame <span class="keyword">in</span> container.decode(video=<span class="number">0</span>):</span><br><span class="line">        process(frame)</span><br></pre></td></tr></table></figure><p>当然，<code>BlobFile</code> 仅仅是 hack——通过实现<code>read()</code>、<code>seek()</code>、<code>tell()</code>等方法来"伪装"成文件，而非真正的文件系统路径。一旦某个库在内部绕过标准接口（比如直接传文件路径给C 扩展、调用 <code>os.fstat()</code>、或使用<code>mmap</code>），这层包装就会失效。</p><p>更进一步，每个框架还有自己更高层的封装。举例来说，Lance 官方为了支持<ahref="https://lance.org/integrations/pytorch/">PyTorch</a>，提供了原生的PyTorch DataLoader：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 直接作为 PyTorch DataLoader 使用</span></span><br><span class="line"><span class="keyword">for</span> batch <span class="keyword">in</span> DataLoader(</span><br><span class="line">    table.where(<span class="string">&quot;video_height &gt;= 720&quot;</span>).shuffle()</span><br><span class="line">):</span><br><span class="line">    images, labels = batch[<span class="string">&quot;image&quot;</span>], batch[<span class="string">&quot;label&quot;</span>]</span><br><span class="line">    outputs = model(images)</span><br></pre></td></tr></table></figure><p>换句话说，只有经过官方验证过的框架和库才能放心使用。在 AI领域框架更新迭代极快的今天，追着各种新框架逐一适配，可能是一件耗时费力的持续性工程投入。Paimon的 <ahref="https://github.com/apache/paimon/pull/6374"><code>blob-as-descriptor</code></a>方案也面临同样的困境。</p><h2 id="传统数据湖的跟进">传统数据湖的跟进</h2><p>JVM生态的传统数据湖格式也开始意识到多模态数据的重要性，不过各家的进展和侧重点差异显著。</p><h3 id="apache-paimon最积极的跟进者">Apache Paimon：最积极的跟进者</h3><p>Apache Paimon 是阿里巴巴主导的流批一体数据湖格式，2024 年正式从Apache 孵化器毕业成为顶级项目。在目前几个主流格式里，Paimon在二进制数据存储方面的工程投入是最为系统的。</p><p>Paimon 引入了专门的 Blob 存储机制 <em><ahref="https://cwiki.apache.org/confluence/display/PAIMON/PIP-35:+Introduce+Blob+to+store+multimodal+data">PIP-35:Introduce Blob to store multimodal data</a></em>，核心设计思路与 LanceDB有几分相似：</p><ul><li><strong>独立 Blob 文件</strong>：Blob 列不再嵌入 Parquet文件，而是存储在独立的 blob 文件中，Parquet 只保存偏移量引用。</li><li><strong>新 BlobType</strong>：在 Java API 中引入<code>BlobType</code>，Flink/Spark SQL 中通过 <code>blob-field</code>option 声明，支持延迟流式读取。</li><li><strong>解耦 Compaction</strong>：blob 列的 compaction策略与结构化列分离，互不影响。</li></ul><p>此外，Paimon 还引入了 <ahref="https://paimon.apache.org/docs/master/concepts/rest/tables/#object-table">ObjectTable</a>，把对象存储中的非结构化数据（图片、视频目录）直接映射为 Paimon表，方便用户用熟悉的 API 进行操作。</p><h3 id="apache-iceberg-delta-lake暂无动作">Apache Iceberg / DeltaLake：暂无动作</h3><p>Iceberg v3（2025 年）和 Delta Lake 4.0（2025 年 9月）不约而同地引入了跨生态统一的 <strong>Variant类型</strong>，解决了半结构化 JSON数据的存储问题，这是两个项目在"半结构化"方向最大的动作。</p><p>然而对于非结构化数据（图片、视频字节），两者目前仍然建议：不在格式内处理，建议将文件存对象存储、路径引用存表。Iceberg社区早在 2020 年就有 unstructured module的提案，至今仍停留在概念阶段。这是 Parquet 列式 Row Group机制的架构决定的——混入大体积 blob 极易触发OOM，短期内难以根本性改变。</p><h3 id="apache-hudi已制定-roadmap">Apache Hudi：已制定 Roadmap</h3><p><ahref="https://hudi.apache.org/blog/2024/12/16/announcing-hudi-1-0-0/">Hudi1.x 路线图</a>明确将非结构化 Blob的原生支持（索引、更新、变更捕获）列为重点，并在引入向量索引能力，朝“AI原生 Lakehouse”方向演进。相关进展包括：<em><ahref="https://github.com/apache/hudi/blob/master/rfc/rfc-100/rfc-100.md">RFC-100:Unstructured Data Storage in Hudi</a></em>, <em><ahref="https://github.com/apache/hudi/issues/14290">#14290 Add Supportfor Vector Indexing in Apache Hudi</a></em> 等。社区贡献者也在探索将LanceDB 集成进 Hudi 生态，用 Hudi 管理 ACID 语义、用 LanceDB提供向量检索。不过完整的生产级实现尚未到来，目前仍是 Roadmap。</p><h2 id="但是你真的需要吗">但是，你真的需要吗？</h2><p>技术选型是 trade-off的艺术。对象存储的高延迟是不可避免的，无论格式多先进都无法改变。即使是Lance 声称的“100x 快于 Parquet的随机访问”，其绝对延迟仍在毫秒到数十毫秒量级——与具备 buffer pool和本地盘的数据库、数据仓库相比，仍有数量级的差距。</p><p>那么，二进制数据湖适合哪些场景呢？</p><ol type="1"><li><strong>对延迟不敏感的批处理任务，尤其是模型训练。</strong>大模型训练的 DataLoader 可以在 GPU计算的同时预取下一批数据，只要数据吞吐量够高，几十毫秒的单次读取延迟完全可以被流水线掩盖。</li><li><strong>PB 级数据量，降低存储成本成为最高优先级。</strong>当数据规模达到 PB 级，S3 存储成本（相对于SSD）的优势就会变得极其显著——可能相差 10 到 100倍。此时<em>勉强够用的延迟 +极低的存储成本</em>往往是最优解，没得选。</li></ol><p>而像在线推理的实时特征获取、以及任何 P99 延迟要求在 50ms以内的服务。这类场景应该选择向量数据库+Object等低延迟方案。</p><h2 id="references">References</h2><ol type="1"><li><a href="https://www.arxiv.org/abs/2504.15247v1">Lance: EfficientRandom Access in Columnar Storage through Adaptive StructuralEncoding</a></li><li><a href="https://lance.org/guide/blob/#blob-v2-write-patterns">BlobAPI - Lance</a></li><li><a href="https://zhuanlan.zhihu.com/p/2000219357679224319">ApachePaimon多模态数据湖实践：从结构化到非结构化的技术演进</a></li><li><ahref="https://mp.weixin.qq.com/s/nOTLb-o61qp5qOcPW42QCQ">阿里ALake数据湖：多模态数据存储处理方案</a></li><li><ahref="https://lancedb.com/blog/from-bi-to-ai-lance-and-iceberg/">From BIto AI: A Modern Lakehouse Stack with Lance and Iceberg</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img
src=&quot;https://ericfu.me/images/lancedb-and-binary-data-lake/lancedb-logo.png&quot; /&gt;&lt;/p&gt;
&lt;p&gt;数据湖曾承诺可以存储任意格式的数据，但一直未能兑现——直到 AI
大模型时代对海量图像、视频、文本的需求涌现。本文以 LanceDB
为例，探讨如何通过列式存储、向量索引等技术，构建一套的二进制数据湖系统。&lt;/p&gt;</summary>
    
    
    
    
    <category term="lance" scheme="https://ericfu.me/tags/lance/"/>
    
    <category term="big data" scheme="https://ericfu.me/tags/big-data/"/>
    
    <category term="lancedb" scheme="https://ericfu.me/tags/lancedb/"/>
    
    <category term="data lake" scheme="https://ericfu.me/tags/data-lake/"/>
    
  </entry>
  
  <entry>
    <title>Daft：Python 原生的分布式查询引擎</title>
    <link href="https://ericfu.me/daft/"/>
    <id>https://ericfu.me/daft/</id>
    <published>2026-02-24T15:53:25.000Z</published>
    <updated>2026-02-27T03:48:16.265Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/images/daft/daft-logo.png" /></p><p><a href="https://docs.daft.ai/en/stable/">Daft</a>是一个高性能、分布式的 Python 计算引擎。相比于 Spark，它提供了原生Python 接口、更好的性能，以及对 AI 和多模态数据的原生支持。</p><span id="more"></span><h2 id="背景大数据的历史包袱">背景：大数据的历史包袱</h2><p>过去二十年，企业大数据生态基本由 JVM 系工具主导。Hadoop、Hive、Spark都诞生于那个年代，它们的设计目标是打造高可靠、高性能的面相结构化数据的SQL 分析引擎。</p><p>但时代变了。如今 80% 的 Spark 用户使用 Python（这是 Spark 联合创始人Reynold Xin <ahref="https://sudipchakrabarti.substack.com/p/from-spark-to-databricks-sparks-origins#:~:text=these%20days%20probably%2080%25%20of%20the%20Spark%20users%20use%20Python">自己说的</a>）。AI/ML数据工程师的工作语言是Python，他们处理的数据也不再只是整齐的结构化表格，而是包括图片、视频、向量等等。</p><p>一个很大的问题是，当你用 PySpark 时，Python代码本质上是在“调用”JVM，中间存在大量的序列化/反序列化开销。想在 Spark里跑一个自定义 Python UDF？那性能会比 Java/Scala <ahref="https://sudipchakrabarti.substack.com/p/from-spark-to-databricks-sparks-origins#:~:text=Python%20was%20probably%205%20to%2010%20times%20slower%20than%20any%20JVM%20language%20on%20Scala%20and%20Java%20for%20Spark%20users">慢上5～10倍</a>。</p><figure><img src="/images/daft/pyspark-error.png"alt="PySpark 的一个运行时错误，来自 JVM 的 stacktrace 只会让用户束手无策，截图来自 [5]" /><figcaption aria-hidden="true">PySpark 的一个运行时错误，来自 JVM 的stacktrace 只会让用户束手无策，截图来自 [5]</figcaption></figure><p>Daft 正是在这个背景下诞生的——它不再是对 Spark 的修补，而是一次为 AI时代的需求从头设计的分布式查询引擎。</p><h2 id="daft-是什么">Daft 是什么？</h2><p><strong>Daft</strong> 是一个高性能、分布式的 Python计算引擎。相比于上个时代统治地位的 Spark 以及 PySpark，Daft的核心优势在于：<strong>原生 Python 接口 + Native Code 的性能（Rust） +原生 AI 和多模态支持</strong>。稍后我们具体展开。</p><p>Daft 也有完整的单机支持——并非仅仅是一个 Worker节点运行那么简单。当你在本地运行 Daft 时，它会使用 Swordfish引擎，不依赖分布式框架；而在集群模式下，Daft 使用 Ray作为分布式调度框架，并借助 Ray 的 Shared Object Store进行节点间数据交换。</p><p>使用 Daft DataFrame API 的体验非常接近数据科学家最熟悉的 Pandas：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> daft</span><br><span class="line"></span><br><span class="line">df = daft.read_parquet(<span class="string">&quot;s3://my-bucket/data/*.parquet&quot;</span>)</span><br><span class="line">df = df.where(df[<span class="string">&quot;label&quot;</span>] == <span class="string">&quot;cat&quot;</span>)</span><br><span class="line">df = df.with_column(<span class="string">&quot;thumbnail&quot;</span>, df[<span class="string">&quot;image_path&quot;</span>].url.download().image.decode())</span><br><span class="line">df.show()</span><br></pre></td></tr></table></figure><p>就这几行代码，Daft 完成了读取 S3 上的 Parquet、过滤行、下载 URL图片、解码图像——全部可以在分布式集群上执行。</p><p>Daft 也支持 PostgreSQL 方言的 SQL 查询，用户可以直接用 SQL来操作数据，这和 DuckDB 非常类似。</p><h2 id="架构设计">架构设计</h2><figure><img src="/images/daft/daft-architecture.webp" alt="Daft 的架构设计" /><figcaption aria-hidden="true">Daft 的架构设计</figcaption></figure><h3 id="用户接口-查询优化">用户接口 &amp; 查询优化</h3><p>这两层的设计与主流计算引擎（Spark、DuckDB）整体相似，不再赘述，简要说明如下：</p><ul><li><strong>用户接口（Interface）</strong>：用户通过 DataFrame API 或者SQL API 操作数据，所有操作均为惰性的（Lazy）——调用<code>.filter()</code> 或 <code>.with_column()</code>时不会立即执行，而是将操作记录进逻辑计划（LogicalPlan）。</li><li><strong>查询优化（Optimizer）</strong>：在执行前对 Logical Plan做各种变换，同样是经典的先 RBO（Rule-Based Optimizer）、再CBO（Cost-Based Optimizer），完成一系列的包括谓词下推、列裁剪、Limit下推等 Rule。最后形成 Physical Plan，交给执行器执行。</li></ul><h3 id="本地执行引擎swordfish">本地执行引擎：Swordfish</h3><p>Swordfish 是用 Rust 实现的多线程异步执行引擎。它的核心设计理念是Morsel-driven流式处理：数据不再被切成固定大小的分区，而是在运行时动态拆成小块（morsel）流式处理，让CPU 核心始终保持满负荷。</p><figure><img src="/images/daft/swordfish.webp"alt="Swordfish 的 Morsel-driven 示意图" /><figcaption aria-hidden="true">Swordfish 的 Morsel-driven示意图</figcaption></figure><p>Swordfish 引擎是 Daft 性能的根基。除了 Native vs JVM带来的性能提升，Swordfish 还采用了以下几个关键技术：</p><ul><li><strong>Apache Arrow 内存格式 +零拷贝</strong>：列式内存布局让数据在 Daft、PyTorch、NumPy之间可以直接共享指针，无需序列化复制</li><li><strong>SIMD 向量化</strong>：充分利用现代 CPU 的 SIMD指令集，对数值计算实施批量加速。作为对比，Spark 的 Tungsten 引擎基于CodeGen JIT 编译，性能已经逐渐落后于向量化加速，<ahref="https://gluten.apache.org/">Gluten 项目</a>正在尝试引入 Velox引擎来解决这个问题，但目前还处于早期阶段。</li><li><strong>Morsel-driven流式执行</strong>：本地执行时以小块（morsel）为单位动态调度，避免大分区带来的内存峰值，也使pipeline 中各算子可以真正并行流水</li><li><strong>高性能异步 I/O</strong>：基于 Rust tokio 异步运行时的 S3/GCS下载器，官方声称是业界最快的对象存储读取实现。</li></ul><h3 id="分布式执行引擎flotilla">分布式执行引擎：Flotilla</h3><p>当数据规模超出单机极限，切换到 Flotilla（代码中也称为 RayRunner）只需一行代码：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">daft.context.set_runner_ray(address=<span class="string">&quot;ray://my-cluster:10001&quot;</span>)</span><br></pre></td></tr></table></figure><p>Flotilla 采用经典的 Driver/Worker 架构：集群每个节点启动一个SwordfishWorker，充分利用节点全部资源（CPU、GPU、内存、磁盘、网络）；Driver 上的Flotilla Scheduler 负责任务分发、进度监控，并根据数据 locality和负载均衡决策调度。</p><figure><img src="/images/daft/flotilla.webp"alt="Flotilla 作为中心 Scheduler，将 Physical Plan 中的各个 Task 下发到各个 Swordfish Worker 节点上执行" /><figcaption aria-hidden="true">Flotilla 作为中心 Scheduler，将 PhysicalPlan 中的各个 Task 下发到各个 Swordfish Worker 节点上执行</figcaption></figure><p>对于需要跨节点数据移动的操作，Flotilla 提供两种 shuffle 机制：</p><ul><li><strong>Ray Object StoreShuffle</strong>：数据可放入内存时优先使用，简单高效，Ray负责数据传输和内存管理（spill 等）</li><li><strong>Flight Shuffle（Beta）</strong>：基于 Apache Arrow FlightRPC，支持落盘（NVMe）、二进制传输、压缩和多线程并行</li></ul><h2 id="daft-vs-spark">Daft vs Spark</h2><table><colgroup><col style="width: 18%" /><col style="width: 40%" /><col style="width: 40%" /></colgroup><thead><tr class="header"><th></th><th>Apache Spark</th><th>Daft</th></tr></thead><tbody><tr class="odd"><td><strong>用户接口</strong></td><td>DataFrame + SQL</td><td>DataFrame + SQL</td></tr><tr class="even"><td><strong>核心语言</strong></td><td>JVM（Scala &amp; Java）</td><td>Python 接口 + Rust 后端</td></tr><tr class="odd"><td><strong>Python 支持</strong></td><td>PySpark，有 JVM 序列化和调用开销</td><td>Python 原生，无额外开销</td></tr><tr class="even"><td><strong>执行引擎</strong></td><td>Tungsten（JIT 编译）</td><td>Rust + SIMD 向量化</td></tr><tr class="odd"><td><strong>内存结构</strong></td><td><code>UnsafeRow</code> 紧凑行式结构</td><td>Apache Arrow 列式内存结构</td></tr><tr class="even"><td><strong>分布式框架</strong></td><td>Spark standalone / YARN / K8s</td><td>Ray</td></tr><tr class="odd"><td><strong>多模态数据</strong></td><td>-</td><td>原生支持图片、视频、向量等多模数据</td></tr></tbody></table><h3 id="关键差异python-udf">关键差异：Python UDF</h3><p>对 AI/ML 场景来说，Python UDF 是不可或缺的，而这也是 PySpark最被诟病的地方：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># PySpark UDF 需要序列化 Python 对象到 JVM，再反序列化</span></span><br><span class="line"><span class="keyword">from</span> pyspark.sql.functions <span class="keyword">import</span> udf</span><br><span class="line"><span class="meta">@udf(<span class="params">returnType=FloatType(<span class="params"></span>)</span>)</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">run_model</span>(<span class="params">image_bytes</span>):</span><br><span class="line">    <span class="comment"># 每次调用都有 JVM &lt;-&gt; Python 进程通信开销</span></span><br><span class="line">    <span class="keyword">return</span> model.predict(image_bytes)</span><br></pre></td></tr></table></figure><p>而 Daft 的 Python UDF 完全没有这样的问题：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Daft UDF 直接在 Python 进程中运行，无任何跨进程开销</span></span><br><span class="line"><span class="meta">@daft.udf(<span class="params">return_dtype=daft.DataType.float32(<span class="params"></span>)</span>)</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">run_model</span>(<span class="params">images: daft.Series</span>) -&gt; <span class="built_in">list</span>:</span><br><span class="line">    <span class="comment"># 批量处理，直接操作 Arrow 内存，零拷贝传递给 PyTorch</span></span><br><span class="line">    <span class="keyword">return</span> model.predict(images.to_pylist())</span><br></pre></td></tr></table></figure><h2 id="daft-vs-其他竞品">Daft vs 其他竞品</h2><ul><li><strong>Daft vs Polars</strong>: Polars 是另一个 Rust 实现的DataFrame 库，性能极佳，但<strong>仅支持单机</strong>。Daft可以理解为分布式的 Polars + 多模态支持 + 补全了 SQL 查询优化能力。</li><li><strong>Daft vs Dask</strong>: Dask 是 Python原生的分布式计算框架，但它本质上是 Pandas 的并行包装，<strong>性能受限于Pandas</strong>，且没有查询优化器。</li><li><strong>Daft vs Ray Data</strong>: Ray Data 是 Ray生态内的数据处理组件，设计比较简单，数据 shuffle需要用户自行编程解决，也没有查询优化器。</li><li><strong>Daft vs DuckDB</strong>: DuckDB是一个嵌入式分析数据库，单机性能极佳，但<strong>不支持分布式</strong>。Daft则是为分布式场景设计的，同时也提供了单机模式。</li><li><strong>Daft vs Smallpond</strong>: <ahref="https://github.com/deepseek-ai/smallpond">Smallpond</a> 是DeepSeek 开源的分布式数据处理框架，它的思路是用 DuckDB作为单机算子，基于 Ray 实现分布式调度和 shuffle。Smallpond 没有 SQL优化器，用户需要手动编写分布式执行计划。</li></ul><h2 id="总结">总结</h2><ul><li><strong>Python 原生，无 JVM 包袱</strong>：消除了 PySpark的跨语言开销，对 Python 用户更友好</li><li><strong>Rust 执行引擎</strong>：Apache Arrow 内存模型、SIMD向量化、高性能异步 IO</li><li><strong>单机无缝衔接分布式</strong>：单机引擎 Swordfish 与基于 Ray的分布式引擎 Flotilla 无缝切换</li><li><strong>多模态原生支持</strong>：重视 AI和多模场景，将图片、音频、向量等类型作为一等公民</li></ul><h2 id="references">References</h2><ol type="1"><li><a href="https://github.com/Eventual-Inc/Daft">Daft GitHubRepository</a></li><li><a href="https://docs.daft.ai/en/stable/architecture/">DaftDocumentation - Architecture</a></li><li><a href="https://cfp.scipy.org/2024/talk/A7CC7W/">Building Daft:Python + Rust = a better distributed query engine (SciPy 2024)</a></li><li><a href="https://www.daft.ai/blog/introducing-daft">IntroducingDaft: A High-Performance Distributed Dataframe</a></li><li><a href="https://www.youtube.com/watch?v=eYXDSuNpKTk">Life AfterApache Spark: Why and How We’re Building the Daft Query Engine</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img src=&quot;/images/daft/daft-logo.png&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.daft.ai/en/stable/&quot;&gt;Daft&lt;/a&gt;
是一个高性能、分布式的 Python 计算引擎。相比于 Spark，它提供了原生
Python 接口、更好的性能，以及对 AI 和多模态数据的原生支持。&lt;/p&gt;</summary>
    
    
    
    
    <category term="big data" scheme="https://ericfu.me/tags/big-data/"/>
    
    <category term="daft" scheme="https://ericfu.me/tags/daft/"/>
    
  </entry>
  
  <entry>
    <title>到底什么是时序数据库？</title>
    <link href="https://ericfu.me/what-exactly-is-timeseries-db/"/>
    <id>https://ericfu.me/what-exactly-is-timeseries-db/</id>
    <published>2024-12-04T15:27:02.000Z</published>
    <updated>2026-02-27T03:48:16.269Z</updated>
    
    <content type="html"><![CDATA[<p><imgsrc="/images/what-exactly-is-timeseries-db/banner_chronomia.webp" /></p><p>在我听说过的许多数据库领域的名词中，“时序数据库”是一个严重过载的概念。即使听上去很简单，但是在许多商业产品的包装下，它的定义变得十分模棱两可。在这篇文章中，我想给“时序数据”一个清晰的定义，以帮助你理解为什么会有这样的一类数据库、它应该用于什么场景、以及如何将你的数据正确建模成时序数据。</p><span id="more"></span><h2 id="定义时序数据模型">定义时序数据模型</h2><p>数据模型（datamodel）是个最基础却也常被忽略的概念。我们都知道，关系型数据库基于关系代数定义了关系表和各种运算，文档型数据库则是把每个数据看作一个半结构化（JSON）的对象。但很不幸，市面上的时序数据库没有完全就数据模型达成一致。</p><p>让我们从最简单的开始。时序数据（timeseries）是一个由「时间戳-值」对组成的序列，通常表示某一个特定东西在时间上的变化。由于变化往往是连续的，我们常常将它们用线图来可视化。</p><figure><img src="/images/what-exactly-is-timeseries-db/a-single-timeseries.png"alt="单个 timeseires 以及可视化" /><figcaption aria-hidden="true">单个 timeseires 以及可视化</figcaption></figure><p>实际中，时序数据往往是成组出现的。例如，如果我们给服务器集群装监控，那么每个节点都会有一个对应的<code>cpu_usage</code> time series，这些 time series都叫同样的名字（<code>cpu_usage</code>），并且用标签（label 或tag）来区分彼此，这些标签通常是 <code>&#123;key=value&#125;</code> 的形式。</p><table><thead><tr class="header"><th>指标名称</th><th>Labels</th><th>Value 的含义</th></tr></thead><tbody><tr class="odd"><td><code>cpu_usage</code></td><td>集群名、节点名</td><td>CPU 使用率百分比</td></tr><tr class="even"><td><code>temperature</code></td><td>Sensor ID、经纬度</td><td>传感器记录的当前温度</td></tr><tr class="odd"><td><code>stock_price</code></td><td>股票代码</td><td>某只股票的交易价格</td></tr></tbody></table><p>到目前为止，我们已经有3个维度了：时间戳（timestamp）,指标名称（metric name 或者 table）, 以及标签集合（labelset），它们组合起来才能唯一确定一个数据点（value）。</p><p><span class="math display">\[(time, name, labels) \rightarrow value\]</span></p><p>把 labels的所有枚举平铺展开成一张表，就可以画出下面的示意图。其中，XY方向的平面对应 name 和 labels，Z 轴对应时间。</p><figure><imgsrc="/images/what-exactly-is-timeseries-db/multiple-timeseries.webp"alt="时序数据模型的 3D 表示：你可以将它看作 2D 表在时间维度上的延伸" /><figcaption aria-hidden="true">时序数据模型的 3D 表示：你可以将它看作 2D表在时间维度上的延伸</figcaption></figure><p>有趣的是，你可以从两个视角来看待这个模型：</p><ol type="1"><li><span class="math inline">\((name, labels) \rightarrowtimeseries\)</span>：在这个视角中，你通过 name 和 labels找到一个（或一组） time series 向量，后续的操作也都作用于当前 timeseries 的所有数据点。这个视角更接近于传统的关系型数据库，例如 InfluxDB的 InfluxQL 就是基于这个视角设计的。</li></ol><figure><imgsrc="/images/what-exactly-is-timeseries-db/select-timeseires-by-name-labels.webp"alt="视角1: 将向量看作一个整体对象被取出和计算" /><figcaption aria-hidden="true">视角1:将向量看作一个整体对象被取出和计算</figcaption></figure><ol start="2" type="1"><li><span class="math inline">\((time) \rightarrow (table, labels,value)\)</span>，在这个视角中，你先选定一个特定时间点的<strong>切片</strong>（snapshot），这个切片上面的value 对应于所有 time series在这个时间点的值。查询语言只需描述对单个时间切片的数据应该如何处理，因为每个切面都会重复完全一样的计算。Prometheus的 PromQL 就是这样设计的。<a href="#fn1" class="footnote-ref"id="fnref1" role="doc-noteref"><sup>1</sup></a></li></ol><figure><imgsrc="/images/what-exactly-is-timeseries-db/select-snapshot-by-timestamp.webp"alt="视角2: 每一个时间切片下都是普通的二维表，表示当前时间对应的值" /><figcaption aria-hidden="true">视角2:每一个时间切片下都是普通的二维表，表示当前时间对应的值</figcaption></figure><h2 id="时序数据的计算">时序数据的计算</h2><p>有了这个数据模型，我们就可以基于它定义计算和查询。相比于关系模型的「二维表」，时序数据是一组「三维表」，多出的维度即是时间。我们稍后会看到时间维度带来的特殊性。</p><h3 id="标量运算">标量运算</h3><p>标量（scalar value）其实就是对单个值的计算，例如</p><ul><li><code>cpu_usage * 100</code> 将 cpu_usage数值从小数转换为百分比</li><li><code>net_read + net_write</code> 将网络读、写速率相加</li><li><code>cpu_usage &gt; 80</code> 将大于 80%的 CPU 使用率标记为 true<ahref="#fn2" class="footnote-ref" id="fnref2"role="doc-noteref"><sup>2</sup></a></li></ul><p>标量计算是最基础的计算，你可以试着用视角1和视角2分别理解一下它们，当然，一定会导出相同的结果。</p><h3 id="聚合">聚合</h3><p>时序数据库的聚合可以分为两类：</p><ul><li><strong>时间维度的聚合</strong>，亦是应用在每一条 timeseries上的操作，典型的有分桶聚合、求导（增长率）、线性回归等，例如：每5分钟窗口的最大CPU使用率</li><li><strong>标签维度的聚合</strong>，亦是应用在每一个时间切片上的操作，典型的有求和、Quantile、TopK等等，例如：集群中各节点的最高CPU使用率</li></ul><h4 id="时间维度的聚合">时间维度的聚合</h4><p>以最常见的 downsample 为例，常常要借助时间窗口函数或者<code>extract()</code>函数对齐对特定时间，例如，按小时或按天，然后再进行聚合操作。由于聚合发生在时间维度上，因此它是对每个time series的操作：<code>fn(input: timeseries) -&gt; timeseries</code>。</p><figure><imgsrc="/images/what-exactly-is-timeseries-db/max-over-5min-window.webp"alt="从视角1理解时间维度的聚合更容易" /><figcaptionaria-hidden="true">从视角1理解时间维度的聚合更容易</figcaption></figure><p>特别地，当时间维度的聚合遇到某个时间段恰好缺失数据点的时候，常常会用空值进行填充或者通过前后数据点进行线性插值（gap-filling），让数据更规整方便后续处理。而标签维度的聚合显然不存在这个问题。</p><h4 id="标签维度的聚合">标签维度的聚合</h4><p>以 <code>sum (cpu_usage) by (cluster)</code>为例，它可以被理解在每一个时间切片上对 <code>cpu_usage</code>进行分组聚合（视角二） ：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> <span class="keyword">each</span> <span class="type">timestamp</span>:</span><br><span class="line">   <span class="keyword">SELECT</span> cluster, <span class="built_in">SUM</span>(cpu_usage) <span class="keyword">FROM</span> cpu_usage <span class="keyword">GROUP</span> <span class="keyword">BY</span> cluster</span><br></pre></td></tr></table></figure><figure><img src="/images/what-exactly-is-timeseries-db/max-by-cluster.webp"alt="从视角2理解标签维度的聚合" /><figcaption aria-hidden="true">从视角2理解标签维度的聚合</figcaption></figure><p>你也可以将它看作对 time series向量整体进行的操作（视角一）。用后者实现的效率相比前者高很多，不仅因为每次<code>GROUP BY</code>的过程其实是完全一样的，还因为可以利用向量化加速计算。</p><p>由于被聚合的通常是多个 time series，可以记作<code>fn(inputs: List[timeseries]) -&gt; timeseries</code>。又由于聚合可以看作发生在每个时间切片的内部，时间维度本身（timestamp向量）不会变化。</p><table><colgroup><col style="width: 6%" /><col style="width: 20%" /><col style="width: 20%" /><col style="width: 9%" /><col style="width: 17%" /><col style="width: 24%" /></colgroup><thead><tr class="header"><th>聚合类型</th><th>输入</th><th>输出</th><th>改变向量长度</th><th>Gap-filling</th><th>例子</th></tr></thead><tbody><tr class="odd"><td>时间维度</td><td>单个 time series</td><td>单个 time series</td><td>是</td><td>可选</td><td>每5分钟窗口的最大CPU使用率</td></tr><tr class="even"><td>标签维度</td><td>多个 time series</td><td>单个 time series</td><td>否</td><td>无</td><td>集群中各节点的最高CPU使用率</td></tr></tbody></table><p>除了概念上的，两者的不同也体现在实现上。大多数时序数据库都有特化的time series 向量数据结构，在文件和内存中使用列式结构连续地保存 timestamp和value，从而压缩空间、提升查询速度等。尽管都能受益于列式内存结构，但是<strong>时间和标签维度聚合是完全不同的算子实现</strong>，这一点光是从输入上就能看出来：前者是输入单个time series，后者是输入多个 time series。</p><blockquote><p>在一些兼容 SQL 的时序数据库中，两种聚合都用 <code>GROUP BY</code>表示，最后实际采用哪个实现取决于 <code>GROUP BY</code>的字段是时间戳还是标签。</p></blockquote><h3 id="join-操作">Join 操作</h3><p>Join 操作通常同时发生在时间维度和标签维度上，它是将两个 time series向量按照时间戳对齐，每个 value 也与另一个 value相对应。注意由于采集的延迟，每个 time series的时间戳可能并不完全对齐。</p><p>从视角2理解 Join操作则比较容易：在某个时间切片上，像对待二维关系表那样进行 Join操作即可。唯一剩下的问题是如何把两个 time series对齐到同一个时间切片上，一种简单的方式按特定间隔对齐，例如将时间戳都取整到分钟；另一种常见的处理方式是<code>AS OF JOIN</code>，例如 <code>t1 AS OF JOIN t2</code> 将 t2的时间戳关联到<strong>最新且小于</strong> t1的时间戳，它表达的含义是：在 t1 发生时（“as of t1 happens”），t2的值是多少。</p><h2 id="案例分析">案例分析</h2><h3 id="prometheus">Prometheus</h3><p>Prometheus 的数据模型很大程度上符合上面的定义。在 Prometheus中，每个指标（metric）都是一个 time series，它有一个名字（metricname）以及一组标签（labels），例如<code>cpu_usage&#123;cluster="prod", node="node1"&#125;</code>。</p><p>Prometheus 的查询语言 PromQL 基于视角2设计，即在特定的时间切片上对value 进行运算，例如<code>max(cpu_usage&#123;cluster="prod"&#125;)</code>，“特定时间”作为另一个参数会同query 一起被调用。对于某些跨越多个时间的操作，例如 <code>rate</code>增长率 = (最新值 - 最旧值) / 经过时间。对此，PromQL 也提供了<code>[]</code> 操作符来取出从当前查询时间点往前一段时间的 timeseries，例如 <code>rate(count_requests[1m])</code>，计算过去 1分钟的请求吞吐量。</p><p>实际使用中，为了绘制变化曲线，往往要一次性查询一段时间范围内的数据，这时候就需要利用<ahref="https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries">Rangequery API</a>, 指定开始时间、结束时间以及步长，例如</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ curl <span class="string">&#x27;http://localhost:9090/api/v1/query_range?query=up&amp;start=2015-07-01T20:10:30.781Z&amp;end=2015-07-01T20:11:00.781Z&amp;step=15s&#x27;</span></span><br></pre></td></tr></table></figure><p>它完全等价于发出多个 Instant query 然后将结果合并成一个时间序列。</p><p>PromQL 的设计非常简洁易懂，但对于某些场景也稍显表达力不足，例如为了<code>max_over_time</code>，不得不引入了难懂且低效的<ahref="https://prometheus.io/blog/2019/01/28/subquery-support/">子查询</a>。</p><h3 id="influxdb">InfluxDB</h3><p>InfluxDB 是另一个流行的时序数据库，它的<ahref="https://docs.influxdata.com/influxdb/cloud/reference/key-concepts/data-elements">数据模型</a>同样符合本文的定义。在InfluxDB 中，每个 time series都有一个名字（measurement），以及一组标签（tags）和字段（fields）。它的查询语言InfluxQL 模仿了 SQL 语法，基于视角1或者说 data-oriented 的视角设计。</p><p>InfluxDB 的内部存储引擎 <ahref="https://docs.influxdata.com/influxdb/v2/reference/internals/storage-engine/#time-structured-merge-tree-tsm">Time-StructuredMerge Tree (TSM)</a> 按照 tags 将同一个表分成多个 time series向量，并按照时间戳排序。由于 tags 被用于标识不同的 timeseries，因此你也要小心保证 tags 的数量不要过多，否则会导致 time series向量过多，影响查询性能。</p><h3 id="greptime">GrepTime</h3><p>GrepTime 是一个新兴的时序数据库，它的<ahref="https://docs.greptime.com/user-guide/concepts/data-model/">数据模型</a>符合本文定义，每个time series 都有一个名字（metric name）以及一组 tags，可以包含多个fields。由于都是借用了关系模型，和 InfluxDB 也很相似。</p><p>GrepTime 采用了 LSM-Tree 的<ahref="https://docs.greptime.com/zh/contributor-guide/datanode/storage-engine/">存储引擎</a>，但要注意它的SST 文件采用列式格式（Parquet），并且按照 (tag-1, ..., tag-m, timestamp)的顺序排序，猜测这样可以在保证查询性能的同时兼顾 update和点查的性能。</p><h3 id="tdengine">TDEngine</h3><p>TDEngine 的<ahref="https://docs.taosdata.com/basic/model/">数据模型</a>符合本文定义，每张表都有一个名字以及一组tags，可以包含多个 fields。由于都是借用了关系模型，和 InfluxDB也很相似。</p><p>早期 TDEngine出于效率考虑，建议用户为每个采集点创建一张表，但是随后发现，随着规模增大，需要不断改写查询引用更多的表。所以TDEngine 又引入了“<ahref="https://docs.taosdata.com/basic/model/#%E8%B6%85%E7%BA%A7%E8%A1%A8">超级表</a>”的概念，将一张逻辑表通过不同的tag 分成不同子表。这和 InfluxDB TSM 的设计如出一辙。</p><h3 id="timescaledb">TimescaleDB</h3><p>TimescaleDB<strong>不符合</strong>本文对时序数据库的定义，它更像是一个为冷数据特别优化过的关系型数据库。考虑到它其实是PostgreSQL 的一个扩展，这也不奇怪。</p><p><ahref="https://www.timescale.com/blog/building-columnar-compression-in-a-row-oriented-database/">这篇博客文章</a>揭示了TimescaleDB 的内部存储结构：在 Posgres原有表结构的基础上，它将“冷却”的数据用类似于 array的格式重新组织，牺牲更新性能换取了扫描性能和压缩比。</p><p>TimescaleDB遵循一般的关系型数据库模型，因此你可以几乎将一切带有时间的数据都存储在TimescaleDB 中。它的优势在于可以利用 PostgreSQL的强大功能，例如索引、事务、外键等等。但是，由于它并没有特化的 timeseries 向量对象，因此处理标准的时序数据（例如metrics）就无法发挥出太多性能优势。</p><h3 id="questdb">QuestDB</h3><p>QuestDB 同样<strong>不符合</strong>本文对时序数据库的定义，它是一个为append-mostly workload 优化的数据库，具有极高的 ingestion性能。它基于一个简化版的关系模型，包括 index、join 等。</p><p>QuestDB 采用最简单直接的<ahref="https://questdb.io/docs/concept/storage-model/">列式存储结构</a>：新的数据不断被append 到最新的 chunk 中。但对于 update，就需要引入更复杂的 versioning以及后台 <ahref="https://questdb.io/docs/operations/updating-data/">Vacuum机制</a>，这也是为什么 QuestDB 不适合频繁更新的场景。</p><p>同样地，如果你的数据完美符合时序数据的定义，那么你应该选择专门的时序数据库。</p><aside id="footnotes" class="footnotes footnotes-end-of-document"role="doc-endnotes"><hr /><ol><li id="fn1"><p>PromQL 文档中引入了 <ahref="https://prometheus.io/docs/prometheus/latest/querying/basics/">Instantvector</a> 这个概念以区分 metrics 和常量（例如<code>cpu_usage &gt; 80</code>）。但这仅仅是术语的差异。<ahref="#fnref1" class="footnote-back" role="doc-backlink">↩︎</a></p></li><li id="fn2"><p>在 Prometheus 的 PromQL中，<code>cpu_usage &gt; 80</code> 会筛选出大于 80%的数据点，而不是返回一个布尔值的 time series。<a href="#fnref2"class="footnote-back" role="doc-backlink">↩︎</a></p></li></ol></aside>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img
src=&quot;/images/what-exactly-is-timeseries-db/banner_chronomia.webp&quot; /&gt;&lt;/p&gt;
&lt;p&gt;在我听说过的许多数据库领域的名词中，“时序数据库”是一个严重过载的概念。即使听上去很简单，但是在许多商业产品的包装下，它的定义变得十分模棱两可。在这篇文章中，我想给“时序数据”一个清晰的定义，以帮助你理解为什么会有这样的一类数据库、它应该用于什么场景、以及如何将你的数据正确建模成时序数据。&lt;/p&gt;</summary>
    
    
    
    
    <category term="database" scheme="https://ericfu.me/tags/database/"/>
    
    <category term="architecture" scheme="https://ericfu.me/tags/architecture/"/>
    
    <category term="timeseries" scheme="https://ericfu.me/tags/timeseries/"/>
    
  </entry>
  
  <entry>
    <title>Stable Diffusion 训练指南 (LyCORIS)</title>
    <link href="https://ericfu.me/stable-diffusion-finetune-guide/"/>
    <id>https://ericfu.me/stable-diffusion-finetune-guide/</id>
    <published>2023-07-16T06:57:50.000Z</published>
    <updated>2026-02-27T03:48:16.268Z</updated>
    
    <content type="html"><![CDATA[<p><imgsrc="/images/stable-diffusioon-finetune-guide/107144354_p32_cropped.jpg" /></p><p>Stable Diffusion 文字生成图片的教程已经很多了。这篇文章是讲解如何用Kohya Trainer 在 Google Colab 上训练一个 LyCORIS模型。在读之前希望你已经至少玩过 Stable Diffusion。</p><span id="more"></span><h2 id="理论基础">理论基础</h2><blockquote><p>这部分对于理解参数的含义很重要。但你也可以先用默认参数试玩再来阅读这部分。</p></blockquote><p>Stable Diffusion是一个由文本生成图像（text-to-image）的生成模型（Generativemode）。输入一段文字提示（prompt），输出一段匹配这段文字的图像。</p><p>训练过程中，我们先对输入的图像不断添加噪声，如下图所示。如果能把这个过程反过来，由一张完全是噪声的图像，一点点去除噪声得到原始的图像（当然是在模型以及prompt text 的引导之下），也就完成了 text-to-image 的任务。</p><figure><imgsrc="/images/stable-diffusioon-finetune-guide/forward-diffusion.webp"alt="Forward diffusion: 对原图逐渐增加噪声" /><figcaption aria-hidden="true">Forward diffusion:对原图逐渐增加噪声</figcaption></figure><figure><imgsrc="/images/stable-diffusioon-finetune-guide/reverse-diffusion.webp"alt="Reverse diffusion: 在模型的引导下逐渐去除噪声" /><figcaption aria-hidden="true">Reverse diffusion:在模型的引导下逐渐去除噪声</figcaption></figure><p>Stable Diffusion 能领先其他模型（比如DALL-E）的关键在于它<strong>并非</strong>在直接在像素空间进行上述的reverse diffusion 过程，而是在潜空间（latent space）。Latent space大幅地将空间维度缩小到了原来的1/48。它的工作原理像一个有损压缩算法，既能够压缩也能解压缩，虽然不保证解压结果和压缩前完全一致，但是基本上没差。这个encode/decode 的过程也是由一个深度学习模型完成，该模型称为<strong>VAE</strong> (Variational Autoencoder)。</p><figure><imgsrc="/images/stable-diffusioon-finetune-guide/latent-diffusion-models.png"alt="Latent Diffusion Models" /><figcaption aria-hidden="true">Latent Diffusion Models</figcaption></figure><p><strong>噪音预测器</strong>（noise preditctor）由一个 U-Net模型负责，这也是整个 Stable Diffusion 的最关键的模型。其网络结构包括一堆ResNet 卷积矩阵和 Cross-Attention 矩阵。Stable Diffusion 包含大约 860M参数，以 float32 的精度编码大概需要 3.4G的存储空间。更多关于它的信息可以参考 <ahref="https://zhuanlan.zhihu.com/p/582266032">Stable Diffusion UNET结构</a>。</p><p>最后，还有一个 text embedding模型，即将一段变长的文字转换成固定维度的向量。Stable Diffusion 1.x用的是 OpenAI 开源的 <ahref="https://github.com/CompVis/stable-diffusion">ViT-L/14</a> CLIP模型，2.x 用的是 <ahref="https://stability.ai/blog/stable-diffusion-v2-release">OpenClip</a>模型。</p><p>综上所述，Stable Diffusion 中一共有三个模型</p><ul><li><strong>CLIP</strong>：用于对 prompt text 进行 embedding 然后输入给U-Net</li><li><strong>VAE</strong>: 将图像从 pixel space encode 到 latent space以及最后 decode 回来</li><li><strong>U-Net</strong>：迭代 denoise所用的模型，是最关键的模型，我们主要 fine-tune 它</li></ul><h3 id="checkpoint">Checkpoint</h3><p>Checkpoint 就是指将网络参数全部打包保存。Stable Diffusion 的 U-Net包含约 860M 的参数，以 float32 的精度编码大概需要 3.4G 的存储空间。</p><h3 id="lora">LoRA</h3><p>LoRA指的是一种对矩阵进行<strong>近似</strong>数值分解的数学方法，同时也是一种有损压缩，可以大幅降低矩阵的参数数量。LoRA作用于 U-Net 中的 cross-attention layers（网络结构图中的 QKV方框）。例如，我们以其中一个矩阵为例，设 fine-tune 之前的原始权重为<span class="math inline">\(W\)</span>，则这一层的计算可以表达为：</p><p><span class="math display">\[Y = W \cdot X\]</span></p><p>Fine-tune 对 <span class="math inline">\(W\)</span>产生了一些微调，这些变化记作 <spanclass="math inline">\(W&#39;\)</span>。</p><p><span class="math display">\[Y = (W + W&#39;) \cdot X = W \cdot X + W&#39; \cdot X\]</span></p><p>LoRA 所做的事情就是将 <span class="math inline">\(W&#39;\)</span>分解：</p><p><span class="math display">\[W&#39; = AB\]</span></p><p>假设 <span class="math inline">\(W\)</span> 维度为 <spanclass="math inline">\((n,m)\)</span>，那么 <spanclass="math inline">\(A\)</span> 维度为 <spanclass="math inline">\((n,dim)\)</span>，<spanclass="math inline">\(B\)</span> 维度为 <spanclass="math inline">\((dim,m)\)</span>，不难发现， <spanclass="math inline">\(dim\)</span> 取的越小，<spanclass="math inline">\(A\)</span> 和 <spanclass="math inline">\(B\)</span> 的参数量就越小，相应地， <spanclass="math inline">\(|W&#39; - AB|\)</span> 的近似度就越差。</p><h3 id="lycoris">LyCORIS</h3><p><a href="https://github.com/KohakuBlueleaf/LyCORIS">LyCORIS</a> 是对LoRA 的增强，其实主要包含两个独立的改进：</p><ul><li><strong>LoCon</strong> (Conventional LoRA): LoRA 只调整了cross-attention layers，LoCon 还用同样的方法调整了 ResNet矩阵。更多信息参见 <ahref="https://github.com/KohakuBlueleaf/LyCORIS/tree/locon-archive">LoCon- LoRA for Convolution Network</a>。</li><li><strong>LoHa</strong> (LoRA with Hadamard Product): 用 HadamardProduct 替换掉了原方法中的矩阵点乘，理论上在相同的 <spanclass="math inline">\(dim\)</span>下能容纳更多（丢失更少）的信息。该方法来自论文 <ahref="https://openreview.net/pdf?id=d71n4ftoCBy">FedPara Low-RankHadamard Product For Communication-Efficient FederatedLearning</a>。</li></ul><blockquote><p>LyCORIS 还实现了其他几种对 LoRA改进的变体，因为很少有人用，这里不展开介绍。</p></blockquote><p>感谢 LoHa，LyCORIS 的模型在 fine-tune更多层的前提下，反而可以用更小的 <spanclass="math inline">\(dim\)</span>，因此输出的模型体积也更小。</p><p>如果你刚刚开始，<strong>建议无脑选择 LyCORIS模型</strong>。本文也将会以 LyCORIS 模型讲解后面的实操步骤。</p><h2 id="准备训练集">准备训练集</h2><p>收集整理需要训练的角色的图片，20 张以上即可。原则是：</p><ul><li>要能清晰地体现出角色特征，例如训练集要覆盖角色的正脸、侧脸、全身、站坐姿等</li><li>在保留角色特征的基础上，其他方面尽可能various，例如不同的角度、场景、风格等</li></ul><p>将图片正则化，缩放并裁剪到 512x512 或 512x768 或 768x512 这 3种尺寸之一，并放置到三个不同的目录中。这步不是必须的，对于实在无法裁剪的部分图片可以跳过，但是SD 模型本身是用 512x512图片训练的，使用相同的尺寸能获得更好的效果。裁剪图片可以用 <ahref="https://www.birme.net/?target_width=512&amp;target_height=768">Birme.net</a>。</p><p>Stable Diffusion同一次训练中只能处理一种尺寸的图片（推理也一样）。如果你的图片并非全都是512x512，Kohya Trainer 中已经自带了bucketize，长宽比相同的图片会被分类到同一个 bucket作为同一批次训练。因此，即便你做不到把图片全都统一到512x512，最好也做到仅有少数几种长宽比。</p><figure><imgsrc="/images/stable-diffusioon-finetune-guide/preview-train-set.png"alt="训练集样例。注意，其中部分图片为 512x768，部分为 512x512，在 bucketize 的时候会被自动分成 2 组" /><figcaption aria-hidden="true">训练集样例。注意，其中部分图片为512x768，部分为 512x512，在 bucketize 的时候会被自动分成 2组</figcaption></figure><p>图片加 Tag 的过程通常是自动标注结合手动筛选，自动标注的过程在 KohyaTrainer 脚本中已经包含，因此现在只要先准备好训练集就行了。</p><h2 id="训练">训练</h2><p>推荐使用 <ahref="https://github.com/Linaqruf/kohya-trainer/tree/main">KohyaTrainer</a>。由于咱没有足够好的显卡（训练至少需要 6GBVRAM），无论训练还是推理都是通过 Google Colab 进行。该脚本也很好地适配了Google Colab，完全做到了一键部署运行。</p><p>点击 “Kohya LoRA Dreambooth” 后面的 <code>Open in Colab</code>按钮开启今天的旅程。</p><h3 id="i.-install-kohya-trainer">I. Install Kohya Trainer</h3><p>安装所需的各种依赖。</p><ul><li><code>install_xformers</code> （默认勾选）<code>xformer</code> 是NVIDIA CPU 特有的一个硬件加速库，能够加速计算并减少 VRAM 使用。</li><li><code>mount_drive</code> （推荐勾选）映射 Google Drive 到<code>/mount/</code> 目录，方便最后保存结果到 Google Drive</li></ul><h3 id="ii.-pretrained-model-selection">II. Pretrained ModelSelection</h3><p>下载 Stable diffusion 基础模型。</p><p>Stable Diffusion 2.x 虽然训练步数更多，但是训练集中过滤掉了 NSFW的图片。注意：SD 1.5 和 2.x 不兼容，但基于 SD1.5训练的模型可以用在任何一个基于 SD1.5 的 checkpoint上。而社区的大部分二次元 Checkpoint 模型基于 SD1.5 训练。</p><p>如果你在训练二次元 waifu，建议选择基于 SD1.5 的 checkpoint作为基础模型，例如 <ahref="https://civitai.com/models/9409/anything-v5-or-anything-diffusion-original">AnythingV5</a>、<ahref="https://civitai.com/models/4468/counterfeit-v30">CounterfeitV3</a>、<ahref="https://huggingface.co/WarriorMama777/OrangeMixs/tree/main/Models/AbyssOrangeMix3">AbyssOrangeMix3</a>等。</p><h3 id="download-available-vae-optional">2.3. Download Available VAE(Optional)</h3><p>Stable Diffusion 是自带 VAE 的，这一步的含义是是否要下载一个 VAE替换原来的 VAE 模型。三次元图更接近 SD 原始训练集，一般不需要。</p><p>二次元模型可以选择你的基础模型配套的 VAE，或者选择 notebook 中推荐的anime.vae。</p><h3 id="iii.-data-acquisition">III. Data Acquisition</h3><p>把之前准备好的图片放到 <code>train_data_dir</code>（training set）中。可以有子目录，也可以没有。例如：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">$ tree /content/LoRA/train_data</span><br><span class="line">.</span><br><span class="line">├── head_and_pouch</span><br><span class="line">│   ├── A_125.jpg</span><br><span class="line">│   ├── A_144.jpg</span><br><span class="line">│   ...</span><br><span class="line">└── full_body</span><br><span class="line">    ├── 1082561_p0.jpg</span><br><span class="line">    ├── 17489814_p0.jpg</span><br><span class="line">    ...</span><br></pre></td></tr></table></figure><h3 id="data-annotation">4.2. Data Annotation</h3><p>这一步为训练集自动生成 prompttext。脚本的注释中已经给了明确的说明：</p><ul><li>Use BLIP Captioning for: General Images</li><li>Use Waifu Diffusion 1.4 Tagger V2 for: Anime and Manga-styleImages</li></ul><p>建议从生成的 tags中移除掉角色自身的特征，比如：<code>long hair, wolf ears, wolf girl, red eyes, brown hair</code>等。移除掉 tag 代表着将模型将这些特征当作 general的情况去对待，换句话说，我们希望模型输出的所有图片都带有这些特征。相反，角色本身之外的特征应当用tag标识出，比如角色的几件特定穿着（皮肤），相应的，在画图时也可以通过相同的tag 来触发这些特征。</p><p>参数 <code>undesired_tags</code>可以快速地做到这一点。如果你时间充裕，咱也建议你以把生成的 prompt下载到本地，逐个人工校对一遍。</p><blockquote><p>如果你想让你的模型拥有一个 tigger word（例如角色的名字），即，仅当trigger word 出现在 prompt 中时才绘制对应的角色，那么你可以为所有生成的prompt text 都加上这个 trigger word并放在最前面。咱觉得这个没什么用，因此跳过。</p></blockquote><p>最终得到的训练集中，每个图片都有一个对应的 <code>.txt</code> 或<code>.caption</code> 的 prompt</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">$ tree /content/LoRA/train_data</span><br><span class="line">.</span><br><span class="line">├── head_and_pouch</span><br><span class="line">│   ├── A_125.jpg</span><br><span class="line">│   ├── A_125.txt</span><br><span class="line">│   ├── A_144.jpg</span><br><span class="line">│   ├── A_144.txt</span><br><span class="line">│   ...</span><br></pre></td></tr></table></figure><p>建议将这个目录打包存放到本地/Google Drive，方便之后调参。</p><h3 id="model-config">5.1. Model Config</h3><p><code>v2</code> 以及 <code>v_parameterization</code> 需要和当前的 SD模型相对应。SD 1.5 两个都不需要选。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">print</span>(<span class="string">&quot;Model Version: Stable Diffusion V1.x&quot;</span>) <span class="keyword">if</span> <span class="keyword">not</span> v2 <span class="keyword">else</span> <span class="string">&quot;&quot;</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;Model Version: Stable Diffusion V2.x&quot;</span>) <span class="keyword">if</span> v2 <span class="keyword">and</span> <span class="keyword">not</span> v_parameterization <span class="keyword">else</span> <span class="string">&quot;&quot;</span></span><br><span class="line"><span class="built_in">print</span>(<span class="string">&quot;Model Version: Stable Diffusion V2.x 768v&quot;</span>) <span class="keyword">if</span> v2 <span class="keyword">and</span> v_parameterization <span class="keyword">else</span> <span class="string">&quot;&quot;</span></span><br></pre></td></tr></table></figure><p><code>pretrained_model_name_or_path</code> 是你要 fine-tune的基础模型。先前在 <code>II. Pretrained Model Selection</code>步骤中已经下载好了，把它的路径复制过来。<code>vae</code> 也同样。有时候vae 和 U-Net 可能放在同一个 <code>.safetensor</code>文件中，这时候两个路径填同一个文件就行了。</p><h3 id="dataset-config">5.2. Dataset Config</h3><p><code>dataset_repeats</code> 的含义是在每个 epoch为训练集合内的图片迭代多少次。通常总迭代次数在 1000～3000次就会有不错的效果，咱的建议每 500 张图片作为一个epoch，这样就能在训练到 500、1000、1500 ... 3000 的时候分别获得 6个模型输出，然后根据实际画图效果选取最好的那个。假设一共有 100张训练图，那么 repeats 就可以设置为 500/100 = 5。</p><p><code>caption_extension</code> 对应 <code>4.2. Data Annotation</code>中生成的 prompt text 文件名后缀，一般是 <code>.caption</code> 或者<code>.txt</code>。</p><p><code>resolution</code> 一般选择 512 或 768。如果你之前已经手动裁剪并resize 过训练集，可以在 Python 代码中设置<code>bucket_no_upscale = false</code>，防止 512x512 的图片被放大。</p><p><code>shuffle_caption</code>（默认<code>True</code>）表示自动打乱逗号分隔的所有单词。<code>keep_token</code>保留前几个标签位置不被 shuffle（默认 0），如果你有 triggerword，则根据需要调整。</p><h3 id="lora-and-optimizer-config">5.3. LoRA and Optimizer Config</h3><p><code>network_category</code> 选择 <code>LoCon_Lycoris</code>。</p><p>下面 4 个参数可能是争议最多的参数（等号后的数值为咱推荐的数值）：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">network_dim = 32</span><br><span class="line">network_alpha = 16</span><br><span class="line">conv_dim = 32</span><br><span class="line">conv_alpha = 16</span><br></pre></td></tr></table></figure><p>解释一下：</p><ul><li><code>dim</code>（有时也称为 <code>rank</code>）表示 LoRA/LoHa方法中保留多少维度，<span class="math inline">\(dim\)</span>越高表示模型的参数量越大，能承载更丰富的特征，同时也更容易过拟合，通常取值范围<span class="math inline">\([1, 64]\)</span>，对于 LyCORIS 推荐取值<span class="math inline">\(\{8, 16, 32\}\)</span></li><li><code>alpha</code> 用于调整模型输出 <spanclass="math inline">\(W&#39;\)</span> 的系数，<spanclass="math inline">\(W&#39;_{out} = W&#39; \cdotalpha/dim\)</span>，<span class="math inline">\(alpha\)</span>越高模型越倾向于拟合更多的细节，学习速率也越快，通常取值范围 <spanclass="math inline">\([1, dim]\)</span>，对于 LyCORIS 推荐取值 <spanclass="math inline">\(\{dim/2, dim\}\)</span></li><li><code>network</code> 表示作用于 cross-attention 矩阵</li><li><code>conv</code> 表示作用于 ResNet 卷积矩阵</li></ul><p>注意 LyCORIS 和 LoRA 的推荐配置有很大不同。LyCORIS 模型作者推荐<code>alpha</code> 设置为 1（咱猜测应该是指 <spanclass="math inline">\(alpha/dim\)</span> 设置为1），<code>dimension &lt;= 32</code>（大于 64 的值会导致 $rank = dim^2 $超过原矩阵维度） 。<ahref="https://ashejunius.com/alpha-and-dimensions-two-wild-settings-of-training-lora-in-stable-diffusion-d7ad3e3a3b0a">这篇文章</a>对 <code>dim</code> 和 <code>rank</code> 的取值做了大量实验，对于LyCORIS，<code>dim</code> 取值似乎并没有很大的影响。</p><p>Optimizer Config基本上只影响训练速度，建议全部保留默认值。如果有兴趣可以自行搜索DAdaptation optimizer 的使用，否则就用默认的<code>AdamW8bit</code>。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">optimizer_type = &#x27;AdamW8bit&#x27;</span><br><span class="line">train_unet = true</span><br><span class="line">unet_lr = 1e-4</span><br><span class="line">train_text_encoder = true</span><br><span class="line">text_encoder_lr = 5e-5</span><br><span class="line">lr_scheduler = constant</span><br><span class="line">lr_warmup_steps = 0</span><br></pre></td></tr></table></figure><blockquote><p>其中 <code>train_text_encoder</code> 这一项，按照咱的理解，至少对于LoRA/LyCORIS 模型是不生效的，在训练的过程中应该都是直接使用了 CLIP模型的默认参数。但是没有查到相关资料。</p></blockquote><h3 id="training-config">5.4. Training Config</h3><p><code>num_epochs</code> 控制一共训练多少步骤。上文提到过，图片总数 ×重复次数(repeats) × epoch 数大约在 1000～3000 之间，这里选择合适的 epoch数使得总数大于等于 3000。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">vae_batch_size</span><br><span class="line">train_batch_size</span><br></pre></td></tr></table></figure><p><code>batch_size</code> 取决于你的 VRAM，在 VRAM 够用（不抛出 CUDAout-of-memory 错误）的情况下越大越好、训练速度越快。对于 512x512的图片、16 GB VRAM 的配置，推荐设置<code>batch_size = 6</code>，其他配置可以自己调整尝试。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">mixed_precision = fp16</span><br><span class="line">save_precision = fp16</span><br></pre></td></tr></table></figure><p>精度保持 <code>fp16</code> 即可。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">save_n_epochs_type = save_every_n_epochs</span><br><span class="line">save_n_epochs_type_value = 1</span><br></pre></td></tr></table></figure><p>决定在什么时机保存当前训练的模型状态，因为训练太多次往往会出现过拟合，体现为生成出的图像有明显的风格化（stylish），这时就需要找一个更早些的模型。建议1 epoch 保存一次。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">max_token_length = 225</span><br><span class="line">clip_skip = 2</span><br></pre></td></tr></table></figure><p>这部分涉及到 CLIP 模型，即 text embedding 所用的模型。</p><ul><li><code>max_token_length</code> 指输入 CLIP 进行 text embedding 最大token 数，常见取值有 <span class="math inline">\(\{75, 150,225\}\)</span>，一般这几个值都足够用了</li><li><code>clip_skip</code> 指从后往前跳过的层数，CLIP 模型输出一共有 12层，越往后的所在层数越高、信息越具体，跳过过于具体的信息可以防止过拟合。更详细的解释参考这个<ahref="https://github.com/AUTOMATIC1111/stable-diffusion-webui/discussions/5674">discussion</a>。经验上，推荐二次元模型选择<code>clip_skip = 2</code>，现实模型选择 <code>clip_skip = 1</code></li></ul><p>其他杂项：</p><ul><li><code>lowram</code>：在可以的时候从 VRAM从卸载掉不必要的参数，节省内存。建议设置为 true。</li><li><code>enable_sample_prompt</code>：边训练边测试，个人习惯打开，可以在训练的差不多的时候终止掉。</li><li><code>sampler</code>: 和生成图片时的一样含义，影响不是很大，推荐<code>Euler A</code></li></ul><p>如果使用了 <code>enable_sample_prompt = true</code>，记得编辑<code>/content/LoRA/config/sample_prompt.txt</code>将其内容调整为需要测试的prompt。想不出来的话可以从训练集随便挑一个。</p><h3 id="start-training">5.5. Start Training</h3><p>之前的步骤生成的配置会保存在<code>./LoRA/config/dataset_config.toml</code> 和<code>./LoRA/config/config_file.toml</code>这两个文件中，开始训练前可以再 review 一遍。</p><p>开始训练之后，注意 log 中的 bucket resolution以及图片数是否符合预期。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">number of images (including repeats)</span><br><span class="line">bucket 0: resolution (512, 512), count: 164</span><br><span class="line">bucket 1: resolution (512, 768), count: 320</span><br><span class="line">bucket 2: resolution (512, 1152), count: 28</span><br><span class="line">bucket 3: resolution (768, 512), count: 4</span><br></pre></td></tr></table></figure><p>然后就是等待结果了。</p><h2 id="保存现场">保存现场</h2><p>最后，无比将整个训练过程保存下来，方便以后改进，包括</p><ul><li><code>/content/LoRA/output</code>: 输出的模型</li><li><code>/content/LoRA/config</code>: 训练配置</li><li><code>/content/LoRA/train_data</code>: 训练数据</li><li><code>/logs/&#123;model&#125;_&#123;timestamp&#125;</code>：日志</li></ul><h2 id="推荐阅读">推荐阅读</h2><ol type="1"><li>官方介绍：<ahref="https://stable-diffusion-art.com/how-stable-diffusion-work/">Howdoes Stable Diffusion work?</a></li><li>Paper: <ahref="https://arxiv.org/pdf/2112.10752.pdf">High-Resolution ImageSynthesis with Latent Diffusion Models</a></li><li><a href="https://zhuanlan.zhihu.com/p/617134893">文生图模型之StableDiffusion</a></li><li><a href="https://imgur.com/a/mrTteIt#TjsDxqp">Do Fine-tunning WithLoRA (V3)</a></li></ol><p>最后的最后，附上之前训练的模型：<ahref="https://civitai.com/models/16104/holo-spice-and-wolf">Holo (Spiceand Wolf) - v3 - Civitai</a> 🥰</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img
src=&quot;/images/stable-diffusioon-finetune-guide/107144354_p32_cropped.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Stable Diffusion 文字生成图片的教程已经很多了。这篇文章是讲解如何用
Kohya Trainer 在 Google Colab 上训练一个 LyCORIS
模型。在读之前希望你已经至少玩过 Stable Diffusion。&lt;/p&gt;</summary>
    
    
    
    
    <category term="stable diffusion" scheme="https://ericfu.me/tags/stable-diffusion/"/>
    
  </entry>
  
  <entry>
    <title>流计算系统技术对比</title>
    <link href="https://ericfu.me/compare-streaming-systems/"/>
    <id>https://ericfu.me/compare-streaming-systems/</id>
    <published>2023-07-08T08:21:12.000Z</published>
    <updated>2026-02-27T03:48:16.265Z</updated>
    
    <content type="html"><![CDATA[<p><imgsrc="/images/compare-streaming-systems/streaming-banner.png" /></p><p><strong>前言：</strong>大数据浪潮已经火了十几年，但是流处理领域似乎一直不温不火。直到近两年，从Confluent（Kafka 背后的商业化公司）上市，到 Snowflake、Databricks纷纷投资 Streaming，再到 Decodable、Immerok 这些 start-up公司的涌现。今年 2023 SIGMOD Systems Award 意外颁发给了 ApacheFlink，让人不免有些兴奋——流计算的好时代终于到来了吗？</p><p>今天从技术的角度聊聊流计算（Streaming）技术。尽管概念上有许多共通之处，例如时间窗口、水位（Watermark）等等，但其实在实现层面上，各个系统几乎都有独特的设计。所谓“存在即合理”，这种系统设计的多样性也正呼应了流计算应用场景的多样性，而并非简单的单一维度上的孰好孰坏。</p><p>本文从内部实现的角度，深入对比了市面上常见的流计算系统，包括 ApacheFlink、RisingWave、Spark Streaming、ksqlDB等。希望这篇文章能在技术选型时对你有帮助。</p><span id="more"></span><h2 id="apache-flink">Apache Flink</h2><p>Flink 诞生之初就提出“流批一体”的构想，即将流计算和批处理使用同一套Runtime解决。具体来说，它将批处理看作是流处理的一个特例，二者无非是有界和无界数据流的区别。现在看来，尽管流批一体的设想还没有那么深入人心，但是Flink 的确凭借它的出色设计，成为了最流行的开源流计算框架。</p><p>和众多大数据框架一样，Flink 计算运行在 JVM 之上。Flink 的编程接口叫作DataStream API，相对地，还有一套批处理接口称为 DataSetAPI，在这两个编程接口之上，还提供了方便处理关系型数据的 Table API 以及Flink SQL。上述接口底层共享 Runtime、调度、数据传输层等实现。</p><p><img src="/images/compare-streaming-systems/flink-arch.png" /></p><p>Runtime 部分基本上和常见的 MPP 系统一致：算子以 DAG方式组织在一起，并通过本地和网络 channel交换数据，分片并行处理。下文中很多系统也是类似，对于这些共同之处，我们不再赘述。</p><p>不同于很多批处理系统标配了列式结构，Flink内存中的表示是行式结构，即每个 event（或message）作为一个单元进行计算以及传输时的序列化。为了加速执行，Flink SQL中使用了 codegen技术即时生成和编译算子代码，让每行的计算尽可能高效。DataStream API则只能依赖 JVM 自身的 JIT 来优化用户代码。</p><h3 id="状态管理">状态管理</h3><p>Flink 是首个引入状态的流计算系统，它将 stateful operator看作一等公民。今天我们已经很清楚，Streaming 中常用的Join、聚合等算子都需要状态。状态管理是 Streaming中不可或缺的一环，它直接决定了故障恢复的设计、一致性语义等等。</p><p>Flink 的算子状态保存在算子本地的 RocksDB 实例中（这里仅讨论开源版Flink 的实现）。RocksDB 的 LSM-Tree结构使得它能很容易获得一个<strong>增量</strong>的快照，这是因为当前版本中的大部分SST文件和上个版本是重合的，因此拷贝最新快照时只需要拷贝变化的部分即可。Flink利用了这一特性对本地状态进行 checkpoint，最后将全局 checkpoint保存在持久化存储上（例如 HDFS 或 S3）。</p><blockquote><p>Flink 1.15 中引入了 <ahref="https://cwiki.apache.org/confluence/display/FLINK/FLIP-158%3A+Generalized+incremental+checkpoints">Generalizedincremental checkpoints</a> 脱离 RocksDB 自行实现了增量checkpoint，有兴趣的读者可以阅读<ahref="https://flink.apache.org/2022/05/30/improving-speed-and-stability-of-checkpointing-with-generic-log-based-incremental-checkpoints/">官方博客</a>。</p></blockquote><p>正确进行 checkpoint 的关键如何获得全局一致的 checkpoint，这一点上Flink 采用了 <ahref="https://en.wikipedia.org/wiki/Chandy%E2%80%93Lamport_algorithm">Chandy-Lamport算法</a>，我认为这是 Flink最大的设计亮点。具体来说，我们从数据流的源头（source）注入一些特殊的消息，称为Barrier。Barrier 将随着数据流中的其他消息一同流过整个 DAG，每经过一个stateful operator 就会触发相应相应的算子的 checkpoint 操作。而当 Barrier流完整个 DAG 时，之前所有这些 checkpoint 就构成了一次一致的全局checkpoint。</p><p><imgsrc="/images/compare-streaming-systems/chandy-lamport.png" /></p><p>Barrier在遇到多输入或多输出的算子时会进行对齐（align），这也是它能保证全局一致的关键所在，同时也是它引入的唯一overhead。考虑到即便没有Barrier，大多数流计算任务也需要免不了对齐（例如窗口的计算），这个代价并不大。总体来看，Flink以比较优雅的方式解决了一致性 checkpoint。</p><figure><img src="/images/compare-streaming-systems/barrier-merge.png"alt="Barrier 的对齐：收集到所有 fan-in 的 barrier 后，再向所有 fan-out 发射 barrier" /><figcaption aria-hidden="true">Barrier 的对齐：收集到所有 fan-in 的barrier 后，再向所有 fan-out 发射 barrier</figcaption></figure><p>基于上述的 checkpoint 机制，at-least once 以及 exactly-once delivery都很容易实现。例如，对于 replayable source（例如 Kafka）和 idempotentsink（例如 Redis），唯一需要做的事情就是将 Kafka 当前消费 offset作为状态的一部分记录在 checkpoint 中，就轻松实现了 exactly-oncedelivery。对于一些更复杂的情形，一些 Sink也允许通过两阶段提交（2PC）和外部系统配合实现 exactly-once。</p><h2 id="risingwave">RisingWave</h2><p>RisingWave是一个年轻的流计算开源产品，也是我本人现在正在开发的项目。它对自身的定位是流数据库（StreamingDatabase）而非通用的流计算框架，允许用户使用 SQL以物化视图的形式定义流计算任务，其设计目标是为了让流计算尽可能简单易上手。它不提供编程API，如有必要用户可以通过 UDF 引入自定义的代码逻辑。</p><p>RisingWave 使用 Rust语言编写。除了众所周知的内存以及并发安全上的优势，Rust 语言内置的 async支持以及丰富的第三方库也极大地帮助了我们高效应对流计算这样的 IO密集场景。RisingWave 的流计算任务由许多个独立的 Actor 构成，Actor可以看作一个协程，由用户态Runtime（tokio）进行高效的调度。同时，这也使得算子内部的实现能够采用高效的单线程内存数据结构，例如Hash Join 所用的哈希表。</p><p><imgsrc="/images/compare-streaming-systems/risingwave-compute.png" /></p><p>除了流计算，RisingWave 也能像数据库那样直接提供查询能力，而且提供snapshot read的正确性保证。具体来说，只要在一个事务中，直接查询物化视图的结果一定与执行其定义SQL 的结果一致。这很大程度上简化了用户验证 Streaming 任务的正确性。</p><h3 id="状态管理-1">状态管理</h3><p>上述的读一致性保证和其内部的 checkpoint 机制密不可分。RisingWave采用与 Flink 类似的基于 Barrier 的全局一致 checkpoint机制，但是频率要高得多，默认为 1s 一次（Flink 默认为30min）。用户的读请求作用于这些 checkpoint上，因此总是能获得一致的结果。</p><p>存储方面，RisingWave 并没有直接使用 RocksDB之类的开源组件，而是从头打造了一套基于 LSM-Tree和共享存储的存储引擎。这样做的原因有许多，其中最主要的是为了计算节点能更加轻量地scale out/in，而不需要像 Flink 那样需要将 RocksDB的状态文件拷贝到新的节点上。同时，我们也希望能够更好地利用云对象存储的优势，例如S3 的低成本以及高可靠性。RisingWave内置存储引擎，并基于此实现了类似数据库的 serving查询的能力，是它相比其他系统的一大不同。</p><p>需要说明的是，Flink 后来引入的 Table Store (<ahref="https://paimon.apache.org/">Paimon</a>) 存储弥补了 Flink没有内置表存储的遗憾，但是 Table Store的主要存储为列式结构，更适合分析型查询。而 RisingWave的存储引擎为行式，更适合点查这样的 OLTP 查询。</p><h2 id="spark-streaming">Spark Streaming</h2><p>Apache Spark 原本被设计为一个批处理引擎。得益于 RDD 的设计，Spark拥有比 Hadoop MapReduce 更优秀的性能。有兴趣的读者可以看我之前写的<ahref="https://ericfu.me/apache-spark-in-nutshell/">《一文读懂 ApacheSpark》</a>。</p><p>Spark Streaming 使用的技术称为 D-Stream（DiscretizedStreams）。不同于其他流计算框架会长期运行算子的实例，Spark Streaming将流数据切分成一个个批处理任务（micro-batch），用一系列的短暂、无状态、确定性的批处理实现流处理。</p><blockquote><p>Spark 2.x 中还引入了一个全新的 Continuous ProcessingMode，但似乎不太流行，我们这里不去讨论。</p></blockquote><p>下面两张图描述了 Spark 如何通过 RDD 来实现 micro-batch的流计算。对于无状态的计算（例如<code>map</code>），那其实和批计算中没有任何不同。对于有状态的计算（例如聚合），状态的变迁可以视作是RDD 的迭代，就像右图中最右侧的 <code>counts</code> RDD那样，它的祖先（lineage）除了计算的上游，还有<strong>自己的前一个版本</strong>的RDD。</p><figure><img src="/images/compare-streaming-systems/spark-dstream.png"alt="D-Straem 处理模型：（左）对于每个时间间隔，生成相应的基于 RDD 的计算图；（右）对于有状态算子，它的祖先还包括上一时刻的 RDD" /><figcaption aria-hidden="true">D-Straem处理模型：（左）对于每个时间间隔，生成相应的基于 RDD的计算图；（右）对于有状态算子，它的祖先还包括上一时刻的RDD</figcaption></figure><p>Spark Streaming 非常巧妙地将流计算转换成了基于 RDD的批处理，也自然地复用了 RDD 的错误容忍机制：只要将失败节点上丢失的 RDDPartition 重算即可。不过，很显然这里有个问题是 D-Stream RDD的祖先会不断延长，导致恢复代价变得越来越高，更别说 replayable source往往是有 retention 限制的。Spark Streaming 通过每隔一段时间调用 D-StreamRDD 的 <code>checkpoint()</code> 函数将其持久化，以截断祖先链。</p><p>事实证明，上述 micro-batch 方案可以达到秒级至分钟级的延迟。StreamingSystems一书的作者也承认，大多数情况下，这样的延迟已经能满足需求了，“充其量是一个小小的抱怨”。但是也要承认，D-Stream毕竟只是对 stateful operator的一种拙劣模仿，在保持设计简洁性的同时，也需要付出更高的代价才能达到相同的计算性能。</p><h2 id="google-dataflow-windmill">Google Dataflow (WindMill)</h2><p>Google Dataflow，或者它的开源版本 ApacheBeam，其实仅仅是一个统一的编程接口，背后支持多种不同的后端 Runtime，包括Apache Flink、Spark 等。我们这里仅仅探讨 Google 自家的 WindMill引擎。它更为人熟知的名字是 MillWheel，我对它了解也主要来自于 VLDB'13的论文 [7]。</p><p>MillWheel 的计算和状态管理是完全解藕的。用户编写的算子通过 State API读写以 Key-Value 模型保存的持久化状态（论文上为 BigTable）。MillWheel没有全局 checkpoint的机制，每个算子在向下游发射出数据<strong>之前</strong>，需要先将状态写入持久化存储，类似数据库的WAL。这样做的好处是，算子本身保持了无状态的优良特性，可以非常方便地进行故障恢复、调度等，但它的代价是高昂的，所有状态的读写都需要通过RPC 完成。</p><figure><img src="/images/compare-streaming-systems/millwheel.png"alt="MillWheel 的用户代码只需实现 ProcessRecord 接口，并可以通过 State API 接口保存状态" /><figcaption aria-hidden="true">MillWheel 的用户代码只需实现ProcessRecord 接口，并可以通过 State API 接口保存状态</figcaption></figure><p>没有全局一致性的 checkpoint 也给实现 exactly-once delivery带来了挑战。除非算子逻辑具有幂等性，否则算子需要对输入进行去重，防止宕机恢复时有重复消息被处理多次，为此又需要在外部存储上保存一段时间内的message log。总体来说，该方案消耗了很多无谓的 RPC 代价。</p><h2 id="apache-kafka-ksqldb">Apache Kafka (ksqlDB)</h2><p>Kafka 无疑是 Streaming市场中最大的玩家，它首次将持久性（durability）引入中间件领域，奠定了整个流计算尤其是exactly-once delivery的基石。但是之所以放在这里才讲，是因为它的角色主要仍然是 MessageBroker，而在计算方面乏善可陈。</p><p>ksqlDB （原名 KSQL）是一个构建在 Kafka 上的流处理引擎，由 Confluent研发。ksqlDB将流-表对偶性的概念发扬光大，也引入了物化视图这样的概念，允许用户通过SQL 定义流计算任务。尽管看起来很美好，ksqlDB设计上有着诸多的限制和妥协，这可能和它轻量级插件的定位有关，但这也让许多用户场景不得不寻求其他的解决方案。</p><p>ksqlDB 对于状态的处理就是一个妥协的例子。ksqlDB 利用 Kafka topic保存状态的 changelog，并借助 RocksDB 将这些 changelog物化成表，以便算子进行高效地查询（看！一个流-表对偶性的实践）。这样迂回的方式导致ksqlDB 需要为相同数量的状态消耗了数倍的资源，一不小心还可能引起这样的<ahref="https://www.confluent.io/blog/ksqldb-state-stores-in-recovery/">数据不一致的bug</a>。</p><p>另外，由于 ksqlDB 的任务总是运行在单个 Kafka 节点上（不支持 MPP那样的 shuffle），无论聚合还是 join都需要用户小心地确保数据已经按正确的方式分区。必要时，需要创建额外的repartition 的 topic 才能让跑起来。这也限制了 ksqlDB 对复杂 SQL的处理能力。</p><h2 id="其他">其他</h2><p>以下这些系统大多已经不再流行，但是它们的设计思路以及取舍仍然值得我们学习。</p><p><strong>Flume/FlumeJava</strong> 最初由 Google研发，可能是已知的最早的流计算系统，诞生于 2007年，最初定位于一套方便开发流计算的编程框架，后来也被用于实现MillWheel。它的核心是一个叫做 PCollection的数据模型，它是一个不可变的、有序的、可重复的数据集合，类似于 Spark 的RDD，而 PTransform 定义了如何对 PCollection 进行转换。Flume没有内置状态管理，用户需要自己借助外部数据库等方式实现。</p><p><strong>Apache Storm</strong> 由 Twitter开源，是另一个早期的流计算系统，它的核心是一个叫作 Tuple的数据模型，类似 PCollection。相比于其他系统在 exactly-once delivery上的努力，Storm 选择了追求更快的性能而放弃一致性保证，它仅支持 at-leastonce 的语义，这让它的实现变得相对简单高效。不令人意外，Storm也没有内置状态管理，用户需要自己借助外部数据库等方式实现。</p><p><strong>Materialize</strong> 可能是最早提出 Streaming Database这一概念的产品。和 RisingWave 类似，它仅提供 SQL接口，允许用户定义表、物化视图等。Materialize 基于名叫 <ahref="https://github.com/TimelyDataflow/differential-dataflow">DifferentialDataflow</a> 的 Rust 流计算框架开发，它支持对 Collection进行各种变换以定义数据流。算子状态保存在内存中的 <ahref="https://materialize.com/docs/get-started/arrangements/">Arrangement</a>结构中，这一设计导致它事实上成为了一个单节点的内存数据库，限制了它能处理的数据规模。它也不具备checkpoint 功能，需要通过重放恢复状态。</p><h2 id="总结">总结</h2><table style="width:100%;"><colgroup><col style="width: 16%" /><col style="width: 16%" /><col style="width: 16%" /><col style="width: 16%" /><col style="width: 16%" /><col style="width: 16%" /></colgroup><thead><tr class="header"><th></th><th>Apache Flink</th><th>RisingWave</th><th>Spark Streaming</th><th>Google Dataflow</th><th>Kafka (ksqlDB)</th></tr></thead><tbody><tr class="odd"><td>用户接口</td><td>DataStream API + SQL</td><td>SQL</td><td>DataStream API</td><td>Beam API</td><td>SQL</td></tr><tr class="even"><td>数据模型</td><td>Object / Table</td><td>Table</td><td>Object / Table</td><td>Object</td><td>Kafka Message</td></tr><tr class="odd"><td>一致性保证</td><td>exactly &amp; at-least once</td><td>exactly &amp; at-least once</td><td>exactly &amp; at-least once</td><td>exactly &amp; at-least once</td><td>exactly &amp; at-least once</td></tr><tr class="even"><td>状态实现</td><td>RocksDB</td><td>内存数据结构 (Cache) + Object Store</td><td>RDD (D-Stream)</td><td>BigTable</td><td>RocksDB</td></tr><tr class="odd"><td>Checkpoint 存储</td><td>HDFS</td><td>Object Store</td><td>HDFS</td><td>BigTable</td><td>Kafka Topics (changelog)</td></tr><tr class="even"><td>Checkpoint 机制</td><td>Chandy-Lamport</td><td>Chandy-Lamport</td><td>RDD checkpoint</td><td>-</td><td>-</td></tr></tbody></table><h2 id="references">References</h2><ol type="1"><li><a href="http://www.vldb.org/pvldb/vol10/p1718-carbone.pdf">Statemanagement in Apache Flink: consistent stateful distributed streamprocessing</a></li><li><ahref="https://asterios.katsifodimos.com/assets/publications/flink-deb.pdf">Apacheflink: Stream and batch processing in a single engine</a></li><li><a href="https://github.com/risingwavelabs/risingwave">GitHub -risingwavelabs/risingwave</a></li><li><ahref="https://people.csail.mit.edu/matei/papers/2013/sosp_spark_streaming.pdf">Discretizedstreams: Fault-tolerant streaming computation at scale</a></li><li><ahref="https://cs.stanford.edu/~matei/papers/2018/sigmod_structured_streaming.pdf">Structuredstreaming: A declarative api for real-time applications in apachespark</a></li><li><ahref="https://cloud.google.com/blog/products/data-analytics/cloud-batch-and-stream-processing-for-analytics">DataflowUnder the Hood: Understanding Dataflow techniques - Sam McVeety, RyanLippert</a></li><li><ahref="https://static.googleusercontent.com//images/compare-streaming-systems/research.google.com/en//pubs/archive/41378.pdf">Millwheel:Fault-tolerant stream processing at internet scale.</a></li><li><ahref="https://docs.ksqldb.io/en/latest/operate-and-deploy/performance-guidelines/#topic-repartitioning">ksqlDBPerformance Guidelines - ksqlDB Documentation</a></li><li><ahref="https://www.oreilly.com/library/view/streaming-systems/9781491983867/">StreamingSystems - Reuven Lax, Slava Chernyak, and Tyler Akidau</a></li><li><ahref="https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/35650.pdf">FlumeJava:Easy, Efficient Data-Parallel Pipelines</a></li><li><a href="https://github.com/MaterializeInc/materialize">GitHub -MaterializeInc/materialize</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img
src=&quot;/images/compare-streaming-systems/streaming-banner.png&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;前言：&lt;/strong&gt;
大数据浪潮已经火了十几年，但是流处理领域似乎一直不温不火。直到近两年，从
Confluent（Kafka 背后的商业化公司）上市，到 Snowflake、Databricks
纷纷投资 Streaming，再到 Decodable、Immerok 这些 start-up
公司的涌现。今年 2023 SIGMOD Systems Award 意外颁发给了 Apache
Flink，让人不免有些兴奋——流计算的好时代终于到来了吗？&lt;/p&gt;
&lt;p&gt;今天从技术的角度聊聊流计算（Streaming）技术。尽管概念上有许多共通之处，例如时间窗口、水位（Watermark）等等，但其实在实现层面上，各个系统几乎都有独特的设计。所谓“存在即合理”，这种系统设计的多样性也正呼应了流计算应用场景的多样性，而并非简单的单一维度上的孰好孰坏。&lt;/p&gt;
&lt;p&gt;本文从内部实现的角度，深入对比了市面上常见的流计算系统，包括 Apache
Flink、RisingWave、Spark Streaming、ksqlDB
等。希望这篇文章能在技术选型时对你有帮助。&lt;/p&gt;</summary>
    
    
    
    
    <category term="big data" scheme="https://ericfu.me/tags/big-data/"/>
    
    <category term="streaming" scheme="https://ericfu.me/tags/streaming/"/>
    
  </entry>
  
  <entry>
    <title>Calcite 中新增的 Top-down 优化器</title>
    <link href="https://ericfu.me/calcite-top-down-planner/"/>
    <id>https://ericfu.me/calcite-top-down-planner/</id>
    <published>2021-10-31T09:45:52.000Z</published>
    <updated>2026-02-27T03:48:16.265Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/images/2021/11/banner-Kaltsit.jpg" /></p><p>众所周知，Apache Calcite 是为数不多的开源 Volcano/Cascades查询优化器实现之一，最早脱胎于 Hive 的优化器，后来也被 Flink等一众项目采用。</p><p>但事实上 Calcite 中的 <code>VolcanoPlanner</code>并非像论文中描述的那样是一个 top-down 优化器。去年阿里云 MaxCompute团队向 Calcite 提交了 PR，引入了新的 top-down优化选项，同时也弥补了之前缺失的剪枝、pass-through 等特性。</p><p>本文假设读者已经对 Apache Calcite 以及 Volcano/Cascades优化器的原理比较熟悉。</p><span id="more"></span><h2 id="背景">背景</h2><p>Calcite 中原来的 <code>VolcanoPlanner</code>并非对论文的标准实现。具体来说，论文中给出的实现是一个自顶向下（top-down）的递归算法，<strong>在每个递归节点上</strong>，可以通过某些规则决定apply 规则的先后顺序。而 Calcite的实现则是一个<strong>全局的优先队列</strong>，即 apply规则的顺序由全局唯一的优先队列控制。（优先队列的实现可参见我之前的文章<a href="/understand-importance-in-calcite/">Calcite 对 Volcano优化器优先队列的实现</a>）</p><p>这样做的好处是，如果不希望遍历整个搜索空间，该策略能够在给定的有限步数内给出较优解（从我个人经历来看，似乎很少有人这么用）。但代价则是代码逻辑变得十分难懂，也无法进行进行剪枝优化。从使用者的角度看，原本top-down 优化中 apply rule 一定是先父节点、后子节点，而 Calcite中的优化则是“随机”发生在 plan tree 的各个节点上，这也给编写 rule带来了一些麻烦。</p><p>2020 年 4 月阿里云 MaxCompute（ODPS）团队提出了 <ahref="https://issues.apache.org/jira/browse/CALCITE-3916">CALCITE-3916:Support cascades style top-down driven ruleapply</a>，即新增一个真正意义上的 top-down优化器。这过程中还经历了一些插曲，首次提交的 PR <ahref="https://github.com/apache/calcite/pull/1950">#1950</a>直接新增了一个 <code>CascadesPlanner</code>可能因为修改过大并没有被接受，之后又重构了一版 <ahref="https://github.com/apache/calcite/pull/1991">#1991</a>，将同样的功能实现在了<code>VolcanoPlanner</code> 内部并提供了 <ahref="https://calcite.apache.org/javadocAggregate/org/apache/calcite/config/CalciteConnectionProperty.html#TOPDOWN_OPT"><code>TOPDOWN_OPT</code></a>选项用于启用或关闭。该功能最终在 2020 年 7 月完成进入主分支。</p><h2 id="核心逻辑topdownruledriver">核心逻辑：TopDownRuleDriver</h2><p>为了将新旧两种优化器合并在 <code>VolcanoPlanner</code> 中，#1991抽象出了 <code>RuleDriver</code> 和 <code>RuleQueue</code> 两个类。当top-down 优化器开启时，<code>VolcanoPlanner</code>中的以下逻辑会被替换：</p><ul><li><code>RuleDriver</code>：从 <code>IterativeRuleDriver</code> 替换成<code>TopDownRuleDriver</code></li><li><code>RuleQueue</code>：从 <code>IterativeRuleQueue</code> 替换成<code>TopDownRuleQueue</code></li></ul><p>其中 <code>TopDownRuleQueue</code> 逻辑很简单：由于 Calcite 是在新<code>RelNode</code> 生成的时候对其进行匹配的，这里用一个<code>RelNode -&gt; Deque&lt;VolcanoRuleMatch&gt;</code>的映射按照匹配的节点存放 rule match的队列，供以后递归到相应节点的时候再进行 apply。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * A rule queue that manages rule matches for cascades planner.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">TopDownRuleQueue</span> <span class="keyword">extends</span> <span class="title class_">RuleQueue</span> &#123;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">private</span> <span class="keyword">final</span> Map&lt;RelNode, Deque&lt;VolcanoRuleMatch&gt;&gt; matches = <span class="keyword">new</span> <span class="title class_">HashMap</span>&lt;&gt;();</span><br></pre></td></tr></table></figure><p>我们重点看 <code>TopDownRuleDriver</code>。它的设计参考了 Columbia优化器，其内部并非是一个简单的递归函数，而是用栈 <code>tasks</code>模拟了整个 top-down 的过程。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * A rule driver that applies rules in a Top-Down manner.</span></span><br><span class="line"><span class="comment"> * By ensuring rule applying orders, there could be ways for</span></span><br><span class="line"><span class="comment"> * space pruning and rule mutual exclusivity check.</span></span><br><span class="line"><span class="comment"> *</span></span><br><span class="line"><span class="comment"> * &lt;p&gt;This implementation uses tasks to manage rule matches.</span></span><br><span class="line"><span class="comment"> * A Task is a piece of work to be executed, it may apply some rules</span></span><br><span class="line"><span class="comment"> * or schedule other tasks.&lt;/p&gt;</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">TopDownRuleDriver</span> <span class="keyword">implements</span> <span class="title class_">RuleDriver</span> &#123;</span><br><span class="line"></span><br><span class="line">  <span class="comment">/**</span></span><br><span class="line"><span class="comment">   * The rule queue designed for top-down rule applying.</span></span><br><span class="line"><span class="comment">   */</span></span><br><span class="line">  <span class="keyword">private</span> <span class="keyword">final</span> TopDownRuleQueue ruleQueue;</span><br><span class="line"></span><br><span class="line">  <span class="comment">/**</span></span><br><span class="line"><span class="comment">   * All tasks waiting for execution.</span></span><br><span class="line"><span class="comment">   */</span></span><br><span class="line">  <span class="keyword">private</span> <span class="keyword">final</span> Stack&lt;Task&gt; tasks = <span class="keyword">new</span> <span class="title class_">Stack</span>&lt;&gt;();</span><br></pre></td></tr></table></figure><p>整个优化过程由下面的循环驱动：不断从栈顶取出 Task 执行，Task执行中又会产生新的Task，重复这一过程直到栈为空。本质上这和递归没什么区别。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Applies rules.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Override</span> <span class="keyword">public</span> <span class="keyword">void</span> <span class="title function_">drive</span><span class="params">()</span> &#123;</span><br><span class="line">    tasks.push(</span><br><span class="line">        <span class="keyword">new</span> <span class="title class_">OptimizeGroup</span>(</span><br><span class="line">            requireNonNull(planner.root, <span class="string">&quot;planner.root&quot;</span>),</span><br><span class="line">            planner.infCost));</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Iterates until the root is fully optimized.</span></span><br><span class="line">    <span class="keyword">while</span> (!tasks.isEmpty()) &#123;</span><br><span class="line">        <span class="type">Task</span> <span class="variable">task</span> <span class="operator">=</span> tasks.pop();</span><br><span class="line">        task.perform();</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>可以看到，一切优化都是从一个名为 <code>OptimizeGroup(root)</code> 的task 开始的。下面我们依次看看有哪些 Task以及它们分别在干什么。在开始之前先解释一些术语：</p><table><colgroup><col style="width: 20%" /><col style="width: 20%" /><col style="width: 60%" /></colgroup><thead><tr class="header"><th>Calcite 的命名</th><th>Columbia 的命名</th><th>解释</th></tr></thead><tbody><tr class="odd"><td>RelNode</td><td>expression</td><td>一个 plan（或 subplan，下文中不区分 plan 和 subplan）</td></tr><tr class="even"><td>-</td><td>multi-expression</td><td>在 VolcanoPlanner 内部时，RelNode 的子节点会被替换成RelSubset（而非具体的 plan），这时该 RelNode 也就是所谓的multi-expression</td></tr><tr class="odd"><td>RelSet</td><td>Group</td><td>relational expression 相同的 plan 集合</td></tr><tr class="even"><td>RelSubset</td><td>-</td><td>relational expression 和 physical properties 相同的 plan 集合</td></tr><tr class="odd"><td>TransformationRule</td><td>transformation rule</td><td>从 logical plan 到 logical plan 的等价变化</td></tr><tr class="even"><td>ConverterRule</td><td>implementation rule</td><td>将 logical plan 转化为 physical plan 的转换规则</td></tr><tr class="odd"><td>RelTrait</td><td>physical properties</td><td>物理属性，典型的就是排序（collation）和分布（distribution）</td></tr></tbody></table><h3 id="optimizegroup"><code>OptimizeGroup</code></h3><p><code>OptimizeGroup</code> 用于优化一个<code>RelSubset</code>，类似于 Columbia 中的 <code>O_GROUP</code>。</p><ol type="1"><li>递归优化当前 <code>RelSubset</code> 中的每个 physical plan（生成<code>OptimizeInputs</code>）</li><li>递归优化当前 <code>RelSubset</code> 中的每个 logical plan（生成<code>OptimizeMExpr</code>）</li></ol><p>注意，这里故意先探索 physical plan 再探索 logical plan（即explore），这是因为搜索 physical plan 的过程中可能生成可行 plan从而能帮助剪枝。</p><h3 id="optimizeinputs-以及-optimizeinput1"><code>OptimizeInputs</code>以及 <code>OptimizeInput1</code></h3><p><code>OptimizeInputs</code> 依次为调用每个子节点的<code>OptimizeGroup</code>，对应 Columbia 中的<code>O_INPUTS</code>。</p><p><code>OptimizeInput1</code> 是 <code>OptimizeInputs</code>在只有一个子节点情况下的简化版本。</p><h3 id="optimizemexpr"><code>OptimizeMExpr</code></h3><p><code>OptimizeMExpr</code> 用于优化一个 logical plan，类似于 Columbia中的 <code>E_GROUP</code>。这里 <code>MExpr</code> 的命名是借鉴自Columbia 中的 <code>M_EXPR</code>（multi-expression）</p><ol type="1"><li>依次 explore 每个子节点 <code>RelSubset</code>（生成<code>ExploreInput</code>）</li><li>在当前节点匹配所有可能的规则（生成 <code>ApplyRules</code>）</li></ol><h3 id="exploreinput"><code>ExploreInput</code></h3><p><code>ExploreInput</code> 为当前 <code>RelSubset</code> 中的每个logical plan 生成<code>OptimizeMExpr</code>。不难看出，它们俩来回调用构成了整个 explore过程。</p><h3 id="applyrules-以及-applyrule"><code>ApplyRules</code> 以及<code>ApplyRule</code></h3><p>故名思义 <code>ApplyRules</code> 为当前节点找到所有的 rule match并生成相应的 <code>ApplyRule</code>，后者 apply rule 生成新的 plan。新plan 产生后必然会进入某个<code>RelSubset</code>，进而又会进一步触发后续的优化任务（这部分位于<code>onProduce</code>）：</p><ul><li>如果产生的是 logical plan 则生成 <code>OptimizeMExpr</code></li><li>如果产生的是 physical plan 则生成 <code>OptimizeInputs</code></li></ul><p>和上面 <code>OptimizeGroup</code> 做的事情如出一辙。</p><p>到此为止，上述这些 task 共同构成了 top-down优化的递归过程。下图是各个 task之间的调用关系，蓝色回边意味着递归进入下一层节点。</p><p><img src="/images/2021/11/calcite-top-down-tasks.png" /></p><h2 id="剪枝的实现">剪枝的实现</h2><p>Volcano/Cascades 优化器的论文中提到，top-down 相比 bottom-up的一大优势是可以进行剪枝（pruning 或 branch-and-bound）。在 Calcite原本的 <code>VolcanoPlanner</code> 中这也是做不到的。</p><p>新引入的 top-down 优化器同时也带来了剪枝特性。剪枝的原理可以参见Columbia 论文 4.3.1 章节，一图以概之：</p><p><img src="/images/2021/11/Lower-Bound-Pruning.jpg" /></p><p>上图中的 context 在 Calcite 的实现中即是 <code>OptimizeInputs</code>这个 task。其中，<code>upperBound</code> 在 <code>OptimizeGroup</code>时传入 <code>RelSubset</code> 最后又传到这里。一旦优化中发现<code>lowerBound &gt; upperBound</code>，则可以不再优化其他子节点、放弃当前<code>RelSubset</code>。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Optimizes a physical node&#x27;s inputs.</span></span><br><span class="line"><span class="comment"> * This task calculates a proper upper bound for the input and invokes</span></span><br><span class="line"><span class="comment"> * the OptimizeGroup task. Group pruning mainly happens here when</span></span><br><span class="line"><span class="comment"> * the upper bound for an input is less than the input&#x27;s lower bound</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">private</span> <span class="keyword">class</span> <span class="title class_">OptimizeInputs</span> <span class="keyword">implements</span> <span class="title class_">Task</span> &#123;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">private</span> <span class="keyword">final</span> RelNode mExpr;</span><br><span class="line">  <span class="keyword">private</span> <span class="keyword">final</span> RelSubset group;</span><br><span class="line">  </span><br><span class="line">  <span class="keyword">private</span> RelOptCost upperBound;</span><br><span class="line">  <span class="keyword">private</span> RelOptCost upperForInput;</span><br><span class="line">  <span class="keyword">private</span> <span class="meta">@Nullable</span> List&lt;RelOptCost&gt; lowerBounds;</span><br><span class="line">  <span class="keyword">private</span> <span class="meta">@Nullable</span> RelOptCost lowerBoundSum;</span><br></pre></td></tr></table></figure><p>Pruning 发生在 <code>OptimizeInputs</code> 的过程中：</p><ol type="1"><li>初始化：对每个（尚未优化的）子节点通过 <code>RelMetadataQuery</code>中的 <code>LowerBoundCost</code> 接口获取最低cost。（<code>LowerBoundCost</code>这个接口需要额外实现，如果没有实现就是 0）</li><li>每当 <code>OptimizeGroup</code> 优化完一个子节点，另一个任务<code>CheckInput</code> 会用实际的 cost 替代（抬升）之前的 lowerbound</li><li>直到完成所有的子节点的 <code>OptimizeGroup</code></li></ol><p>上述 1～3 每个步骤之后都有能出现<code>lowerBound &gt; upperBound</code>，进而中止当前的<code>OptimizeInputs</code> 过程，达到剪枝的效果。</p><h2 id="pass-through-和-derive">Pass-through 和 derive</h2><p>回忆一下 Volcano/Cascades 优化器中，递归调用的输入参数不仅包括logical plan，还包括上层所需的 physicalproperties。二者共同组成了动态规划的最优子结构。</p><p>但是 Calcite 原本的 <code>VolcanoPlanner</code>中并没有向下传递所需的 physical properties，而是通过临时放置一个<code>AbstractConvertor</code> 作为所需 <code>RelSubset</code> 的placeholder，在之后 apply rule 的过程中如果能“恰好”产生同一<code>RelSubset</code> 的 plan，则可能会作为 best被选出。这一过程中，apply rule 或是算子并不知道上层需要怎样的 physicalproperties，因此比较低效。</p><p>新增的 top-down 优化器引入了一个新特性，允许算子主动处理上层要求的physical properties，该特性称为 pass-through。</p><p>由于 pass-through 处理的是 physicalproperties，显然只有物理算子才需要实现pass-through，相应的接口如下：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Physical node in a planner that is capable of doing</span></span><br><span class="line"><span class="comment"> * physical trait propagation and derivation.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">interface</span> <span class="title class_">PhysicalNode</span> <span class="keyword">extends</span> <span class="title class_">RelNode</span> &#123;</span><br><span class="line"></span><br><span class="line">  <span class="comment">/**</span></span><br><span class="line"><span class="comment">   * Pass required traitset from parent node to child nodes,</span></span><br><span class="line"><span class="comment">   * returns a pair of traits after traits is passed down.</span></span><br><span class="line"><span class="comment">   *</span></span><br><span class="line"><span class="comment">   * &lt;p&gt;Pair.left: the new traitset</span></span><br><span class="line"><span class="comment">   * &lt;p&gt;Pair.right: the list of required traitsets for child nodes</span></span><br><span class="line"><span class="comment">   */</span></span><br><span class="line">  Pair&lt;RelTraitSet, List&lt;RelTraitSet&gt;&gt; <span class="title function_">passThroughTraits</span><span class="params">(RelTraitSet required)</span>;</span><br><span class="line">      </span><br><span class="line">  <span class="comment">/**</span></span><br><span class="line"><span class="comment">   * Derive traitset from child node, returns a pair of traits after</span></span><br><span class="line"><span class="comment">   * traits derivation.</span></span><br><span class="line"><span class="comment">   *</span></span><br><span class="line"><span class="comment">   * &lt;p&gt;Pair.left: the new traitset</span></span><br><span class="line"><span class="comment">   * &lt;p&gt;Pair.right: the list of required traitsets for child nodes</span></span><br><span class="line"><span class="comment">   */</span></span><br><span class="line">  Pair&lt;RelTraitSet, List&lt;RelTraitSet&gt;&gt; <span class="title function_">deriveTraits</span><span class="params">(RelTraitSet childTraits, <span class="type">int</span> childId)</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>对于 Project、Filter这样的简单算子，几乎只要直接穿透就可以了。举个稍复杂的例子：<code>EnumerableHashJoin</code> 算子依次对 probe side 的每一行进行join，因此不会改变 probe side的顺序（collation）；如果所需的排序键恰好位于<code>EnumerableHashJoin</code> 的 probeside，那么可以将其直接向下穿透到 probe side 的子节点上。</p><p>有了 pass-through 之后，<code>AbstractConvertor</code>也就用不着了。相对的，在 top-down 过程中，一旦有新的 physical properties产生，就会调用下层各个物理算子的 pass-through 接口以及 converterrule，从中挑选出 best plan。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Tries to convert the physical node to another trait sets, either by converter rule</span></span><br><span class="line"><span class="comment"> * or traits pass through.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">private</span> RelNode <span class="title function_">convert</span><span class="params">(RelNode rel, RelSubset group)</span> &#123;</span><br><span class="line">  <span class="keyword">if</span> (!passThroughCache.contains(rel)) &#123;</span><br><span class="line">    <span class="type">RelNode</span> <span class="variable">passThrough</span> <span class="operator">=</span> group.passThrough(rel);</span><br><span class="line">    <span class="keyword">if</span> (passThrough != <span class="literal">null</span>) &#123;</span><br><span class="line">      passThroughCache.add(passThrough);</span><br><span class="line">      <span class="keyword">return</span> passThrough;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="type">VolcanoRuleMatch</span> <span class="variable">match</span> <span class="operator">=</span> <span class="comment">/* find matched converter rule */</span>;</span><br><span class="line">  <span class="keyword">if</span> (match != <span class="literal">null</span>) &#123;</span><br><span class="line">    tasks.add(<span class="keyword">new</span> <span class="title class_">ApplyRule</span>(match, group, <span class="literal">false</span>));</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">return</span> <span class="literal">null</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>为了配合 pass-through，<code>OptimizeGroup</code> 中优化某个<code>RelSubset</code> 时不仅会检查当前 <code>RelSubset</code> 包含的plan，实际上它检查的是所属的 <code>RelSet</code> 中所有的 plan。对于其中physical properties 不同的 plan，它会调用上面的 <code>convert</code>方法触发 pass-through 以及 converter rule。</p><p>最后再说说 derive。我们说过，pass-through用于<strong>自上而下</strong>传递所需的 physicalproperties。但是在某些情况下这还不够。例如考虑 broadcast join的生成过程，其中 Join 算子的 distribution 需要和其中一个输入节点（例如TableScan）保持一致，另一边则通过 Exchange(broadcast) 将数据重分布到所有Join 上。注意这里 Join 算子的 distribution 来自于它的子节点TableScan，这一<strong>自下而上</strong>的传递过程就依赖 derive。</p><p><code>DeriveTrait</code> 任务总是在一个 physical plan生成后被调用，用于调用 derive 接口。如果 derive 产生了新的 trait则为之生成相应的 <code>RelSubset</code>。</p><h2 id="总结">总结</h2><p>新引入的 top-down 优化器实现了真正的自顶向下搜索。相比 Calcite 原来的<code>VolcanoPlanner</code> 实现，它具有以下优势：</p><ol type="1"><li>实现更接近论文中的描述，更加简单易懂</li><li>支持 lower-bound pruning，节约优化时间</li><li>支持 pass-through，改进 physical properties 相关的优化性能</li></ol><h2 id="references">References</h2><ol type="1"><li><a href="https://calcite.apache.org/">Apache Calcite</a> - <ahref="https://github.com/apache/calcite/">Source Code</a></li><li><ahref="https://issues.apache.org/jira/browse/CALCITE-3916">CALCITE-3916:Support cascades style top-down driven rule apply</a></li><li><a href="https://github.com/apache/calcite/pull/1991">CALCITE-3916:Support top-down rule apply and upper bound space pruning #1991</a></li><li><ahref="https://15721.courses.cs.cmu.edu/spring2018/papers/15-optimizer1/xu-columbia-thesis1998.pdf">Efficiencyin the Columbia Database Query Optimizer - YONGWEN XU</a></li></ol><p><em>附件：<ahref="/images/2021/11/top_down_trace.zip">top_down_trace.zip</a> -一个简单的两表 Join 的优化过程 trace log，包括优化过程中 plan的可视化（graphviz + svg）</em></p>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img src=&quot;/images/2021/11/banner-Kaltsit.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;众所周知，Apache Calcite 是为数不多的开源 Volcano/Cascades
查询优化器实现之一，最早脱胎于 Hive 的优化器，后来也被 Flink
等一众项目采用。&lt;/p&gt;
&lt;p&gt;但事实上 Calcite 中的 &lt;code&gt;VolcanoPlanner&lt;/code&gt;
并非像论文中描述的那样是一个 top-down 优化器。去年阿里云 MaxCompute
团队向 Calcite 提交了 PR，引入了新的 top-down
优化选项，同时也弥补了之前缺失的剪枝、pass-through 等特性。&lt;/p&gt;
&lt;p&gt;本文假设读者已经对 Apache Calcite 以及 Volcano/Cascades
优化器的原理比较熟悉。&lt;/p&gt;</summary>
    
    
    
    
    <category term="calcite" scheme="https://ericfu.me/tags/calcite/"/>
    
    <category term="optimizer" scheme="https://ericfu.me/tags/optimizer/"/>
    
  </entry>
  
  <entry>
    <title>SIGMOD21 | Milvus: 向量数据库</title>
    <link href="https://ericfu.me/sigmod21-milvus-paper/"/>
    <id>https://ericfu.me/sigmod21-milvus-paper/</id>
    <published>2021-07-27T18:07:57.000Z</published>
    <updated>2021-07-29T00:42:00.000Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/images/sigmod21-milvus-paper/milvus-logo.png" /></p><p>Milvus是一个用于向量（Vector）存储和检索的特殊数据库，由国内的创业公司 Zilliz开发。本文内容来自 Milvus 在 SIGMOD'21 上的论文 <ahref="/images/sigmod21-milvus-paper/https://link.zhihu.com/?target=https%3A//www.cs.purdue.edu/homes/csjgwang/pubs/SIGMOD21_Milvus.pdf">Milvus:A Purpose-Built Vector Data Management System</a>。</p><span id="more"></span><p>所谓向量，可以看作一个长度为 N 的元组。很多 AI/ML系统（例如推荐系统、图片相似度检测等）都有类似的需求：<strong>这些系统首先将海量数据集经过特征提取得到很多向量，使用时给定一个向量，从数据集向量中快速检索出和它最"相似"的的K个向量</strong>。相似度的定义有多种，最常见的有余弦距离、欧几里得距离等。</p><figure><imgsrc="/images/sigmod21-milvus-paper/similarity-detection-example.png"alt="一个相似度检测的例子，背后是相似向量检索" /><figcaptionaria-hidden="true">一个相似度检测的例子，背后是相似向量检索</figcaption></figure><p>为了做到这一点，最 naive的方法就是让给定向量和所有数据库中的向量依次做比较，但显然这个做法太慢了。RDBMS中有索引的概念，那我们能不能为向量的相似度也建立索引呢？当然是可以的！</p><p>这个问题称为<strong>向量相似度检索</strong>（vector similaritysearch），Facebook 开源的 <ahref="/images/sigmod21-milvus-paper/https://github.com/facebookresearch/faiss">Faiss</a> 就是这样一个C++ library，它内置了多种索引，例如 IVF_FLAT、IVF_FQ8、IVF_PQ等（这些算法不是本文的重点）。Milvus 基于 Faiss 开发，Milvus添加了存储组件，使之成为一个完整的数据库产品（而不仅是个libaray），同时也做了很多工程上的优化。</p><h2 id="存储格式">存储格式</h2><p>Milvus 的数据模型允许每行数据（文中称为 entity）包含 1 个或多个vector 以及可选的<strong>数值属性</strong>（numericattribute）。其中数值属性一般起到过滤作用，比如年龄、身高之类的，可以作为查询过滤条件的一部分。</p><p>每个 vector 本身显然是要连续排列的（vector 一定是以整体参与运算），而vector 之间按列排列。比如一张表有 v1、v2 两个 vector 列、{A,B,C}三行数据，那么在存储上的排列就是 {A.v1, B.v1, C.v1, A.v2, B.v2, C.v2}。</p><p>数值属性的排列比较有意思，同样是先按列分开，每个列内部类似一个有序的倒排索引：属性的数值-&gt; Row ID，通过 RowID 就可以找到相应的vector。这样的设计是为了支持高效的 point/range query（comment:但同时也意味着 select 这些属性的代价变得很高，所以估计不支持 select吧，若理解有误欢迎指正）。</p><p>存储采用类似于 LSM-Tree 的分层 compaction 设计。新写入的数据会进入MemTable，MemTable 会刷到磁盘上，同时构建索引。和很多 OLTP系统的不同之处是，Milvus并不保证写后读，除非手动调用 <code>flush()</code> API否则可能查不到新写入的数据（之所以这样也和后面的 shared-storage架构有关）。但是 Milvus 可以保证读到的 Snapshot是一致的（例如不会读到写了一半的数据），实现原理也很简单：在读取时记录下当前所有SS-Table 的文件集合快照，从这个快照中读取。</p><p>Milvus 的分布式架构是个基于共享 object storage的一写多读架构，有点类似于 Snowflake。writer始终只有一个，因此也不会用到分布式事务。reader 可以横向扩展，通过coordinator 可以将一个查询根据分片+路由的方式打到所有 reader上，将查询在多个节点上并行起来。每个 reader都可以利用本地的磁盘和内存缓存一些热数据。</p><p><img src="/images/sigmod21-milvus-paper/milvus-arch.png" /></p><p>Milvus 通过 WAL 保证原子性和持久性，WAL同样位于共享存储层上。（comment: 这样延迟可能会比较大？）</p><h2 id="索引选择">索引选择</h2><p>索引的原理超出本文的 scope，这里只介绍最基本的 idea：在 build索引时，会通过聚类算法选出几个中心点（v0~v9 聚类得到图中 c0~c2三个中心点），当给定查询 q 时，算法能快速找到离 q 最近的 k个中心点（k=2，得到 c0、c1），之后只要从 c0、c1的邻居中（v0~v6）搜索即可。</p><p><img src="/images/sigmod21-milvus-paper/neigbour-search.png" /></p><p>显然，索引是一个和数据相关的 immutable 的数据结构，这一点和 LSMTree的结构天然契合：从 MemTable 写到磁盘的时候或者 compaction 的时候 build索引即可。</p><p>索引选择的实现是基于 cost 的：</p><p><img src="/images/sigmod21-milvus-paper/index-selection.png" /></p><p><strong>策略A</strong>（vector不走索引，数值条件走索引）：先通过数值属性的倒排索引过滤，再在过滤出来的所有数据上扫描（逐个计算相似度，不依靠vector的索引）</p><p><strong>策略B</strong>（vector走索引，数值条件走索引）：通过数值属性的倒排索引拿到过滤结果bitmap，然后在 vector 上利用相似度索引得所有相似的向量，根据 bitmap只留下复合过滤条件的那些，再取 TopK</p><p><strong>策略C</strong>（vector走索引，数值条件不走索引）：在 vector上利用相似度索引得所有相似的向量，然后按数值条件过滤</p><p><strong>策略D</strong>：基于代价在 A/B/C中选择一个，至于怎么选应该很容易想到吧 :)</p><p><strong>策略E</strong>：是对 D 的进一步改进，也是 Milvus使用的策略。具体来说，Milvus 首先根据某个数值属性将整个 dataset分区（比如 price 可以分为 [1, 100], [101, 200], [201, 300], [301, 400]），之后，如果查询条件带有分区键，则可以进行"分区裁剪"（比如对于 pricein [50, 250]，可以直接裁剪出 [1, 100], [101, 200], [201, 300]这三个分区），并且对每个分区采取 cost-based 策略（比如中间的 [101, 200]区间不需要对 price 进行过滤，因为一定满足条件）</p><h2 id="工程优化">工程优化</h2><ul><li>对 Faiss 的 cache-miss 问题做了优化，性能提升 1.5x ~ 2.7x- 支持SIMD，支持根据 cpu 指令集选择最高效的 SIMD指令集（SSE/AVX/AVX2/AVX512）- 更好的 GPU 支持：允许更大的 Top k，允许多GPU- GPU &amp; CPU 混合计算</li></ul><p>优化效果可以参见原文 Evaluation 一节，这里不贴了。</p><p>补充：据说论文的架构是Milvus1.x的架构，2.0新架构大幅重构了，见文档 <ahref="/images/sigmod21-milvus-paper/https://milvus.io/docs/architecture_overview.md#Milvus-Architecture-Overview">MilvusArchitecture Overview - Milvus documentation</a></p>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img src=&quot;/images/sigmod21-milvus-paper/milvus-logo.png&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Milvus
是一个用于向量（Vector）存储和检索的特殊数据库，由国内的创业公司 Zilliz
开发。本文内容来自 Milvus 在 SIGMOD&#39;21 上的论文 &lt;a
href=&quot;/images/sigmod21-milvus-paper/https://link.zhihu.com/?target=https%3A//www.cs.purdue.edu/homes/csjgwang/pubs/SIGMOD21_Milvus.pdf&quot;&gt;Milvus:
A Purpose-Built Vector Data Management System&lt;/a&gt;。&lt;/p&gt;</summary>
    
    
    
    
    <category term="database" scheme="https://ericfu.me/tags/database/"/>
    
    <category term="paper" scheme="https://ericfu.me/tags/paper/"/>
    
    <category term="sigmod" scheme="https://ericfu.me/tags/sigmod/"/>
    
  </entry>
  
  <entry>
    <title>从 Google Mesa 到 Apache Doris</title>
    <link href="https://ericfu.me/from-mesa-to-doris/"/>
    <id>https://ericfu.me/from-mesa-to-doris/</id>
    <published>2021-03-06T08:33:23.000Z</published>
    <updated>2026-02-27T03:48:16.266Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/images/2021/03/doris-logo.png" /></p><p><a href="https://doris.apache.org/">Apache Doris</a> 原名是Palo，由百度于 2017 年开源，Palo 这个词来自 OLAP 的反转，寓意这是一个OLAP 系统。</p><p>初次看到它的时候以为是又一个数据仓库产品，没怎么关注，直到最近才发现和我们熟悉的Greemplum、Impala 等等有不少区别，其中最有特色的是它的数据模型，借鉴自Google 2014 年公开在 VLDB 上的 Mesa。本文的前半部分也会聊聊 Doris/Mesa的数据模型是怎样的。</p><span id="more"></span><h2 id="mesa预聚合数据模型">Mesa：预聚合数据模型</h2><p>Mesa 是为了解决 Google广告业务的实时分析需求而诞生的。广告业务的特点是其数据量特别大，每次广告的展示、点击都会产生一条数据，存储这些原始数据不但会消耗大量的存储资源，也会给实时计算聚合结果（比如“某个广告主截止目前已经消费了多少预算”）带来很大困难。</p><p>Mesa为了解决上面提到的两个问题，提出了一个<strong>预聚合</strong>的存储模型。Mesa中的所有表都是预聚合表，以下图中的 Table A 为例：其中竖线之前的Date、PublisherId、Country 三列是 <strong>Key列</strong>，表示聚合的维度，语义等同于 Group-By；竖线之后的 Clicks 和Cost 列是 <strong>Value 列</strong>，表示被聚合的结果。例如第一行表示2013-12-31 这一天，ID 为 100 的 Publisher 在 US 一共发生了 10次点击、价值 32 块钱。</p><figure><img src="/images/2021/03/mesa-table-examples.png"alt="Example of Mesa Tables" /><figcaption aria-hidden="true">Example of Mesa Tables</figcaption></figure><p>你可能已经发现了，上面的 Table A～D其实表示的是<strong>同一批原始数据</strong>的在四个不同维度的聚合结果，供不同的业务查询使用。是不是有点像MOLAP 或者说 Data Cube的概念？本质上是一样的，都是用预先定义和计算聚合（简称“预聚合”）来加速特定模式的查询。</p><p>和很多数仓产品一样，Mesa 只支持按 batch（或micro-batch）进行更新。更新具有原子性保证，因此不用担心上面各个表的数据不一致。每个更新版本包含这个batch 内发生的所有变化值（delta）。Mesa 要求所有的 Value列都需要定义它的聚合函数，因此 delta 就能和之前的数据进一步合并。</p><figure><img src="/images/2021/03/mesa-updates-examples.png"alt="Example of Updates in Mesa" /><figcaption aria-hidden="true">Example of Updates in Mesa</figcaption></figure><p>Mesa 在后台会异步地对每次导入的 delta 数据做 Compaction。为了让更新和Compaction 的效率更高，也为了支持一定时间范围内的历史读能力，Mesa 的Compaction 分为两层，第一层是对近期数据（比如当天的数据）的合并，称为<strong>Cumulatives</strong>，第二层是对某个时间点之前（比如今天以前的）的所有历史数据的合并，称为<strong>Base</strong>。下面是一个 Compaction 策略的例子：</p><figure><img src="/images/2021/03/mesa-compaction-policy.png"alt="Example of Compaction Policy" /><figcaption aria-hidden="true">Example of Compaction Policy</figcaption></figure><p><strong>这样的设计让 Mesa能够快速查询实时聚合结果</strong>，而不像传统 Data Cube那样需要在全量数据上重新Build。查询聚合结果时，我们选出<strong>最小覆盖集</strong>（spanningset）进行二次聚合即可，比如上图的例子中，为了查询版本 92的聚合结果，我们只需要读取 0-60、61-90、91、92 这 4个文件并合并即可。</p><p>个人认为<strong>论文主要的贡献就是这个预聚合数据模型的定义和实现</strong>，其他特性诸如高可用设计、存储格式、跨DC 部署架构等，有兴趣的同学请自行读论文。</p><h2 id="doris混合的数据模型">Doris：混合的数据模型</h2><blockquote><p>一言以概之：Apache Doris = 一般 MPP 数仓 + 借鉴自 Mesa的预聚合模型</p></blockquote><p>Doris 的诞生背景和 Mesa非常相似，都是来自广告业务的实时报表需求，从<ahref="https://zhuanlan.zhihu.com/p/66637804">这篇文章</a>看来，2012年百度从 Google 挖来一名高T，“带来了当时业界最领先的底层报表引擎技术”，带着团队做出了 Palo也就是今天的 Doris。</p><figure><img src="/images/2021/03/palo-architecture.jpg"alt="Apache Doris Architecture" /><figcaption aria-hidden="true">Apache Doris Architecture</figcaption></figure><p>Doris 的数据模型稍复杂一些，支持以下三种模型的表：</p><ol type="1"><li>Aggregate 表：需要定义 Key 列和 Value 列，Key列相同的数据行会自动合并，合并时 Value列的数据按预先定义好的聚合函数进行聚合</li><li>Duplicate 表：不会自动合并 Key相同的数据，其语义类似于其他数据库中的关系表，Key 表示表的排序键（sortkey）而不是唯一键</li><li>Uniq 表，Key 列相同时新的行覆盖（Replace）旧的行，本质上是一种特化的Aggregate 表</li></ol><p>官方文档对如何选择上述 3 种模型给出的建议如下：</p><ol type="1"><li>Aggregate表可以通过预聚合，极大地降低聚合查询时所需扫描的数据量和查询的计算量，非常适合有固定模式的报表类查询场景</li><li>Uniq表针对需要唯一主键约束的场景，可以保证主键唯一性约束。但是无法利用ROLLUP 等预聚合带来的查询优势</li><li>Duplicate 适合任意维度的 Ad-hoc查询。虽然同样无法利用预聚合的特性，但是不受聚合模型的约束，可以发挥列存模型的优势</li></ol><p>这个模型给我一种缝合怪的感觉。Aggregate 模型显然直接对应于 Doris最初的使用场景（广告数据实时聚合），而另外两个 Uniq 和 Duplicate模型则是对应于其他数据仓库中的关系表。之所以这么设计，猜测是因为还有很多使用场景无法用预聚合模型表示，例如维表和明细表，它们天然地不包含聚合的语义。</p><p>Aggregate 表带来了一些令人困惑的特性。Doris 通过 SQL 接口进行查询，在SQL 语义中，Aggregate 表也会被视为普通的关系表。继续以上文 Mesa Table A为例，在 Doris 中，用户执行下面的这些语句是“正确”的：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> <span class="built_in">SUM</span>(Clicks) <span class="keyword">FROM</span> PublisherClicks <span class="keyword">GROUP</span> <span class="keyword">BY</span> `<span class="type">Date</span>`, PublisherId, Country</span><br></pre></td></tr></table></figure><p>但是用户也完全可以执行这样的语句：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> <span class="built_in">MAX</span>(Clicks) <span class="comment">/* &lt;- 奇怪的语义 */</span> <span class="keyword">FROM</span> PublisherClicks <span class="keyword">GROUP</span> <span class="keyword">BY</span> `<span class="type">Date</span>`, PublisherId, Country</span><br></pre></td></tr></table></figure><p>由于 Clicks 本身已经是加和的结果，对它取 <code>MAX</code>并没有实际的意义。更糟糕的是，计算 <code>MAX(Clicks)</code>的代价要比计算 <code>SUM(Clicks)</code> 大的多，为何会这样呢？上一小节说Mesa 的时候提到，每次导入的 delta数据不会立即和基线数据合并，而是会先以单独的文件存在，积累一定数量后再做Compaction。而计算 <code>MAX(Clicks)</code> 时必须基于完全合并（对每一个Key 计算 <code>SUM(Clicks)</code>）的数据，才能正确算出 <code>MAX</code>的结果，代价很高。</p><blockquote><p>在<ahref="http://doris.apache.org/master/zh-CN/getting-started/data-model-rollup.html#%E8%81%9A%E5%90%88%E6%A8%A1%E5%9E%8B%E7%9A%84%E5%B1%80%E9%99%90%E6%80%A7">文档</a>中特别以<code>COUNT(*)</code>的例子阐述了这个问题，目测不少用户在这里踩过坑。</p></blockquote><p>在 Mesa 中没有这个问题，Mesa仅仅提供了针对预聚合查询（MOLAP）的特殊查询接口而非SQL，如果用户需要和其他数据作关联，则需要通过 F1 Query之类的联合查询引擎。个人觉得 Doris 的 Aggregate表有点弄巧成拙的意思，语义上比较奇怪。</p><h2 id="rollup-与物化视图">ROLLUP 与物化视图</h2><p>Mesa允许用户指定同一份数据的多种维度的预聚合表，并能保证更新时的原子性。这一套设计同样也被搬到了Doris 中。不过，在 Mesa 中，这些更新数据是由外部系统构建的，Mesa本身仅仅提供增量聚合的能力。</p><p>而在 Doris 中，用户需要创建一个<strong>数据最详尽</strong>的 Base表，然后再在上面创建不同的 ROLLUP，以获得更 high-level 的聚合结果。这个ROLLUP 的概念和 SQL 中的 ROLLUP 语法没关系，它的语义是以另一个维度对Base 表进行进一步的聚合，当 Base 表发生更新时 Doris 也会自动地同步更新ROLLUP 数据。例如，对于上面 Mesa 的例子，我们先定义一个包含所有 Key 列和Value 列的 Aggregate 表，并在此基础上创建 ROLLUP：</p><figure><img src="/images/2021/03/doris-base-table-and-rollup-example.png"alt="Example of Doris Table and Rollup" /><figcaption aria-hidden="true">Example of Doris Table andRollup</figcaption></figure><p>创建 ROLLUP 的前提是 Base 表必须是 Aggregate 表，这样 ROLLUP才知道如何聚合各个 Value 列。ROLLUP 的 Key 列也必须被包含在 Base 表的Key 列中，但顺序可以和原来不一致。</p><p>ROLLUP 的引入是 Doris 的创新点之一，它确实简化了数据导入的流程。在Mesa 中用户还需要再构建一个 pipeline 生成增量数据，而 Doris 通过引入ROLLUP “内置” 了这一过程。但是另一方面，ROLLUP必须基于一个包含所有聚合维度的 Base 表（比如上面的 Base 表包含Date、Publisher、Advertiser、Country 这些维度），真的有必要这样吗？</p><p>为了解开 “ROLLUP 必须基于 Aggregate 表”的这个奇怪限制，Doris后来又引入了物化视图的概念，<strong>允许用户基于明细表（Duplicate表）定义预聚合</strong>。Doris的物化视图仅支持定义聚合（Group-By），并且对聚合函数也<ahref="http://doris.apache.org/master/zh-CN/administrator-guide/materialized_view.html#%E4%BD%BF%E7%94%A8%E7%89%A9%E5%8C%96%E8%A7%86%E5%9B%BE">有所限定</a>。Doris的物化视图和 ROLLUP 一样都是增量更新的，内部很有可能是相同的实现。</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 创建 ROLLUP 的语法</span></span><br><span class="line"><span class="keyword">ALTER</span> <span class="keyword">TABLE</span> ads <span class="keyword">ADD</span> <span class="keyword">ROLLUP</span> `PublisherRollup` (`<span class="type">Date</span>`, PublisherId, Clicks, `Cost`)</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 创建物化视图的语法</span></span><br><span class="line"><span class="keyword">CREATE</span> MATERIALIZED <span class="keyword">VIEW</span> `PublisherMView` <span class="keyword">AS</span></span><br><span class="line"><span class="keyword">SELECT</span> `<span class="type">Date</span>`, PublisherId, <span class="built_in">SUM</span>(Clicks), <span class="built_in">SUM</span>(`Cost`) <span class="keyword">FROM</span> ads <span class="keyword">GROUP</span> <span class="keyword">BY</span> `<span class="type">Date</span>`, PublisherId</span><br></pre></td></tr></table></figure><p>个人认为，<strong>把预聚合模型抽象为物化视图要比 ROLLUP更优雅、更符合 SQL语义</strong>，可惜在文档的编排中这个功能似乎只是被当作一个辅助出现的。依我看还不如把ROLLUP 特性废弃掉算了。</p><p>如果用户的查询中用到了预聚合的值，查询优化器可以自动选择 ROLLUP或物化视图来加速查询，这部分的实现是基于规则（比如最长匹配原则）而非基于代价的，有兴趣的读者可以去看<ahref="http://doris.apache.org/master/zh-CN/getting-started/hit-the-rollup.html#%E7%B4%A2%E5%BC%95">文档</a>。</p><h2 id="doris-as-a-mpp-data-warehouse">Doris as a MPP DataWarehouse</h2><p>好了，至此为止，我觉得有意思的部分就讲完了。Doris的其他部分是一个类似 Impala 或 Greemplum的中规中矩的数据仓库产品，没有太多亮点。不过既然是 MPP数仓就意味着它能执行各种各样的 SQL，像 TPC-H、TPC-DS 这样的 Ad-hoc查询当然也不在话下。</p><p>快速地过一遍其他特性：</p><ul><li>部署架构：分为 FE（前端）和 BE（后端）两个组件<ul><li>FE 负责接受用户请求、优化、调度查询，由 Java 编写</li><li>BE 负责存储数据、执行 MPP 计划中的各个片段，类似于 Worker 的角色，由C++ 编写</li><li>FE 还内置了 BerkeleyDB 用于保存元数据，并通过多副本保证高可用</li></ul></li><li>分区方式：支持逻辑和物理两层分区<ul><li>逻辑分区通常是时间日期，方便冷热数据分离，数据仓库标配</li><li>物理分区通常是哈希，用于打散数据、均摊负载</li></ul></li><li>存储格式：毫无疑问用的是列存，类似 ORC 格式<ul><li>通过 sort key 支持点查</li><li>多副本保证高可用性</li></ul></li><li>支持向量化（含 SIMD），不支持 JIT</li><li>支持 Online Schema Change</li></ul><h2 id="后记">后记</h2><p>翻看几篇 Doris 应用实践（比如<ahref="https://mp.weixin.qq.com/s/nrmtDMIJ9-AvgOVCgI8Zng">这篇</a>、<ahref="https://tech.meituan.com/2020/04/09/doris-in-meituan-waimai.html">这篇</a>和<ahref="https://zhuanlan.zhihu.com/p/257183139">这篇</a>），发现几个有意思的共通点：</p><ol type="1"><li>业务方普遍选择将明细数据也保存在 Doris中，而不是仅有聚合数据，其用法更接近于一般数仓而不是 Mesa。</li><li>预聚合模型是对 Ad-hoc的很好补充，对于实时报表等场景有极大提升。其他的实时数仓产品例如GP、Impala、ClickHouse、TiDB（雾）是否也可以借鉴一下呢？</li><li>Aggregate 表常被用于统计 UV（某 URL被多少个不同的用户访问过），被聚合的 Value 是用户 ID 的 bitmap，这可能是Doris 团队自己都没有想到的。</li></ol><p>最后发表下我的观点：<strong>Doris借助物化视图等概念将预聚合（MOLAP）能力引入到 ROLAP体系中，并且通过分层合并做到快速更新、快速查询，是对实时数仓系统的一个很好的增强</strong>。</p><h2 id="references">References</h2><ol type="1"><li><ahref="https://doris.apache.org/master/zh-CN/getting-started/basic-usage.html">ApacheDoris 官方文档</a></li><li><a href="https://research.google.com/pubs/archive/42851.pdf">Mesa:Geo-Replicated, Near Real-Time, Scalable Data Warehousing</a></li><li><a href="https://www.infoq.cn/article/vxup94ub59ya*k0tnefe">ApacheDoris (Incubating) 原理与实践</a></li><li><ahref="https://tech.meituan.com/2020/04/09/doris-in-meituan-waimai.html">ApacheDoris在美团外卖数仓中的应用实践</a></li><li><a href="https://mp.weixin.qq.com/s/nrmtDMIJ9-AvgOVCgI8Zng">ApacheDoris在京东广告的应用实践</a></li><li><a href="https://zhuanlan.zhihu.com/p/257183139">Apache Doris 在WeLab实时大数据平台的应用实践</a></li><li><ahref="https://zhuanlan.zhihu.com/p/66637804">Doris简史-为分析而生的11年</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img src=&quot;/images/2021/03/doris-logo.png&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://doris.apache.org/&quot;&gt;Apache Doris&lt;/a&gt; 原名是
Palo，由百度于 2017 年开源，Palo 这个词来自 OLAP 的反转，寓意这是一个
OLAP 系统。&lt;/p&gt;
&lt;p&gt;初次看到它的时候以为是又一个数据仓库产品，没怎么关注，直到最近才发现和我们熟悉的
Greemplum、Impala 等等有不少区别，其中最有特色的是它的数据模型，借鉴自
Google 2014 年公开在 VLDB 上的 Mesa。本文的前半部分也会聊聊 Doris/Mesa
的数据模型是怎样的。&lt;/p&gt;</summary>
    
    
    
    
    <category term="database" scheme="https://ericfu.me/tags/database/"/>
    
    <category term="olap" scheme="https://ericfu.me/tags/olap/"/>
    
  </entry>
  
  <entry>
    <title>分布式事务中的时间戳</title>
    <link href="https://ericfu.me/timestamp-in-distributed-trans/"/>
    <id>https://ericfu.me/timestamp-in-distributed-trans/</id>
    <published>2020-12-03T14:30:36.000Z</published>
    <updated>2026-02-27T03:48:16.269Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/images/2020/12/clock-banner.jpg" /></p><p>时间戳（timestamp）是分布式事务中绕不开的重要概念，有意思的是，现在主流的几个分布式数据库对它的实现都不尽相同，甚至是主要区分点之一。本文聊一聊时间戳的前世今生，为了把讨论集中在主题上，假设读者已经对数据库的MVCC、2PC、一致性、隔离级别等概念有个基本的了解。</p><span id="more"></span><h2 id="为什么需要时间戳">为什么需要时间戳？</h2><p>自从 MVCC被发明出来之后，那个时代的几乎所有数据库都抛弃（或部分抛弃）了两阶段锁的并发控制方法，原因无它——性能太差了。当分布式数据库逐渐兴起时，设计者们几乎都选择MVCC 作为并发控制方案。</p><figure><img src="/images/2020/12/concurrency-control-schemes.png"alt="并发控制的几种方法" /><figcaption aria-hidden="true">并发控制的几种方法</figcaption></figure><p>MVCC 的全称是多版本并发控制（Multi-Version ConcurrencyControl），这个名字似乎暗示我们一定会有个版本号（时间戳）存在。然而事实上，时间戳还真不是必须的。<ahref="http://mysql.taobao.org/monthly/2018/03/01/">MySQL 的 ReadView实现</a>就是基于事务 ID 大小以及活跃事务列表进行可见性判断。</p><blockquote><p>事务 ID 在事务开启时分配，体现了事务 begin 的顺序；提交时间戳commit_ts 在事务提交时分配，体现了事务 commit 的顺序。</p></blockquote><p>分布式数据库 Postgres-XL也用了同样的方案，只是将这套逻辑放在全局事务管理器（GTM）中，由 GTM集中式地维护集群中所有事务状态，并为各个事务生成它们的Snapshot。这种中心化的设计很容易出现性能瓶颈，制约了集群的扩展性。</p><p>另一套方案就是引入时间戳，只要比较数据的写入时间戳（即写入该数据的事务的提交时间戳）和Snapshot的读时间戳，即可判断出可见性。在单机数据库中产生时间戳很简单，用原子自增的整数就能以很高的性能分配时间戳。Oracle用的就是这个方案。</p><figure><img src="/images/2020/12/mvcc-example.png"alt="MVCC 原理示意：比较 Snapshot 读取时间戳和数据上的写入时间戳，其中最大但不超过读时间戳的版本，即为可见的版本" /><figcaption aria-hidden="true">MVCC 原理示意：比较 Snapshot读取时间戳和数据上的写入时间戳，其中最大但不超过读时间戳的版本，即为可见的版本</figcaption></figure><p>而在分布式数据库中，最直接的替代方案是引入一个集中式的分配器，称为<strong>TSO</strong>（Timestamp Oracle，此 Oracle 非彼 Oracle），由 TSO提供单调递增的时间戳。TSO看似还是个单点，但是考虑到各个节点取时间戳可以批量（一次取 K个），即便集群的负载很高，对 TSO 也不会造成很大的压力。TiDB用的就是这套方案。</p><blockquote><p>MVCC 和 Snapshot Isolation有什么区别？前者是侧重于描述数据库的并发控制<strong>实现</strong>，后者从隔离级别的角度定义了一种<strong>语义</strong>。本文中我们不区分这两个概念。</p></blockquote><h2 id="可线性化">可线性化</h2><p><strong>可线性化</strong>（linearizable）或<strong>线性一致性</strong>意味着操作的时序和（外部观察者所看到的）物理时间一致，因此有时也称为<strong>外部一致性</strong>。具体来说，可线性化假设读写操作都需要执行一段时间，但是在这段时间内必然能找出一个时间点，对应操作真正“发生”的时刻。</p><figure><img src="/images/2020/12/linearizable.png"alt="线性一致性的解释。其中 (a)、(b) 满足线性一致性，因为如图所示的时间轴即能解释线程 A、B 的行为；(c) 是不允许的，无论如何 A 都应当看到 B 的写入" /><figcaption aria-hidden="true">线性一致性的解释。其中 (a)、(b)满足线性一致性，因为如图所示的时间轴即能解释线程 A、B 的行为；(c)是不允许的，无论如何 A 都应当看到 B 的写入</figcaption></figure><p>注意不要把一致性和隔离级别混为一谈，这完全是不同维度的概念。理想情况下的数据库应该满足strict serializability，即隔离级别做到 serializable、一致性做到linearizabile。本文主要关注一致性。</p><figure><img src="/images/2020/12/isolation-and-consistency.png"alt="隔离性（Isolation）与一致性（Consistency）" /><figcaptionaria-hidden="true">隔离性（Isolation）与一致性（Consistency）</figcaption></figure><p><strong>TSO时间戳能够提供线性一致性保证</strong>。完整的证明超出了本文的范畴，这里只说说直觉的解释：用于判断可见性的snapshot_ts 和 commit_ts 都是来自于集群中唯一的 TSO，而 TSO作为一个单点，能够确保时间戳的顺序关系与分配时间戳的物理时序一致。</p><p>可线性化是一个极好的特性，用户完全不用考虑一致性方面的问题，但是代价是必须引入一个中心化的TSO。我们后边会看到，想在去中心化的情况下保持可线性化是极为困难的。</p><h2 id="truetime">TrueTime</h2><p>Google Spanner 是一个定位于全球部署的数据库。如果用 TSO方案则需要横跨半个地球拿时间戳，这个延迟可能就奔着秒级去了。但是 Google的工程师认为 linearizable 是必不可少的，这就有了 TrueTime。</p><p>TrueTime 利用原子钟和 GPS 实现了时间戳的去中心化。但是原子钟和 GPS提供的时间也是有误差的，在 Spanner 中这个误差范围 <spanclass="math inline">\(\varepsilon\)</span> 被设定为7ms。换句话说，如果两个时间戳相差小于 <spanclass="math inline">\(2\varepsilon\)</span>，我们就无法确定它们的物理先后顺序，称之为“不确定性窗口”。</p><figure><img src="/images/2020/12/commit-wait-in-truetime.png"alt="Commit Wait in TrueTime" /><figcaption aria-hidden="true">Commit Wait in TrueTime</figcaption></figure><p>Spanner对此的处理方法也很简单——<strong>等待不确定性窗口时间过去</strong>。在事务提交过程中Spanner 会做额外的等待，直到满足 <span class="math inline">\(TT.now() -T_{start} &gt;2\varepsilon\)</span>，然后才将提交成功返回给客户端。在此之后，无论从哪里发起的读请求必然会拿到一个更大的时间戳，因而必然能读到刚刚的写入。</p><h2 id="lamport-时钟与-hlc">Lamport 时钟与 HLC</h2><p>Lamport 时钟是最简单的逻辑时钟（LogicalClock）实现，它用一个整数表示时间，记录事件的先后/因果关系（causality）：如果A 事件导致了 B 事件，那么 A 的时间戳一定小于B。当分布式系统的节点间传递消息时，消息会附带发送者的时间戳，而接收方总是用消息中的时间戳“推高”本地时间戳：<spanclass="math inline">\(T_{local} = \max(T_{msg}, T_{local}) +1\)</span>。</p><figure><img src="/images/2020/12/lamport-clocks-example.png"alt="Lamport 时钟" /><figcaption aria-hidden="true">Lamport 时钟</figcaption></figure><p>Lamport Clock 只是个从 0开始增长的整数，为了让它更有意义，我们可以在它的高位存放物理时间戳、低位存放逻辑时间戳，当物理时间戳增加时逻辑位清零，这就是<strong>HLC</strong>（Hybrid LogicalClock）。很显然，从大小关系的角度看，HLC 和 LC 并没有什么不同。</p><figure><img src="/images/2020/12/hlc-timestamp.png" alt="HLC Timestamp" /><figcaption aria-hidden="true">HLC Timestamp</figcaption></figure><p>HLC/LC 也可以用在分布式事务中，我们将时间戳附加到所有事务相关的 RPC中，也就是 Begin、Prepare 和 Commit 这几个消息中：</p><ul><li><strong>Begin</strong>：取本地时间戳 local_ts 作为事务读时间戳snapshot_ts</li><li><strong>Snapshot Read</strong>: 用 snapshot_ts读取其他节点数据（MVCC）</li><li><strong>Prepare</strong>：收集所有事务参与者的当前时间戳，记作prepare_ts</li><li><strong>Commit</strong>：计算推高后的本地时间戳，即 commit_ts = max{prepare_ts } + 1</li></ul><p><strong>HLC/LC并不满足线性一致性</strong>。我们可以构造出这样的场景，事务 A 和事务 B发生在不相交的节点上，比如事务 <span class="math inline">\(T_A\)</span>位于节点 1、事务 <span class="math inline">\(T_B\)</span> 位于节点2，那么这种情况下 <span class="math inline">\(T_A\)</span>、<spanclass="math inline">\(T_B\)</span>的时间戳是彼此独立产生的，二者之前没有任何先后关系保证。具体来说，假设<span class="math inline">\(T_A\)</span> 物理上先于 <spanclass="math inline">\(T_B\)</span> 提交，但是节点 2 上发起的 <spanclass="math inline">\(T_B\)</span> 的 snapshot_ts可能滞后（偏小），因此无法读到 <span class="math inline">\(T_A\)</span>写入的数据。</p><p><img src="/images/2020/12/hlc-is-not-linearizable.png" /></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">T1: w(C1)</span><br><span class="line">T1: commit</span><br><span class="line">T2: r(C2)   (not visible! assuming T2.snapshot_ts &lt; T1.commit_ts)</span><br></pre></td></tr></table></figure><p>HLC/LC 满足因果一致性（Causal Consistency）或 Session一致性，然而对于数据库来说这并不足以满足用户需求。想象一个场景：应用程序中使用了连接池，它有可能先用Session A 提交事务 <spanclass="math inline">\(T_A\)</span>（用户注册），再用 Session B 进行事务<span class="math inline">\(T_B\)</span>（下订单），但是 <spanclass="math inline">\(T_B\)</span> 却查不到下单用户的记录。</p><blockquote><p>如果连接池的例子不能说服你，可以想象一下：微服务节点 A负责用户注册，之后它向微服务节点 B 发送消息，通知节点 B 进行下订单，此时B却查不到这条用户的记录。根本问题在于应用无法感知数据库的时间戳，如果应用也能向数据库一样在RPC 调用时传递时间戳，或许因果一致性就够用了。</p></blockquote><h2 id="有限误差的-hlc">有限误差的 HLC</h2><p>上个小节中介绍的 HLC物理时间戳部分仅供观赏，并没有发挥实质性的作用。CockroachDB创造性地引入了 NTP 对时协议。NTP 的精度当然远远不如原子钟，误差大约在100ms 到 250ms 之间，如此大的误差下如果再套用 TrueTime的做法，事务延迟会高到无法接受。</p><p>CockroachDB 要求所有数据库节点间的时钟偏移不能超过250ms，后台线程会不断探测节点间的时钟偏移量，一旦超过阈值立即自杀。通过这种方式，节点间的时钟偏移量被限制在一个有限的范围内，即所谓的<strong>半同步时钟</strong>（semi-synchronizedclocks）。</p><p>下面是最关键的部分：进行 Snapshot Read 的过程中，一旦遇到 commit_ts位于不确定性窗口<code>[snapshot_ts, snapshot_ts + max_clock_shift]</code>内的数据，则意味着无法确定这条记录到底是否可见，这时将会<strong>重启整个事务</strong>（并等待max_clock_shift 过去），取一个新的 snapshot_ts 进行读取。</p><figure><img src="/images/2020/12/crdb-read-restart.png"alt="CockroachDB 的 Read Restart 机制" /><figcaption aria-hidden="true">CockroachDB 的 Read Restart机制</figcaption></figure><p>有了这套额外的机制，上一节中的“写后读”场景下，可以保证读事务 <spanclass="math inline">\(T_B\)</span> 一定能读到 <spanclass="math inline">\(T_A\)</span> 的写入。具体来说，由于 <spanclass="math inline">\(T_A\)</span> 提交先于 <spanclass="math inline">\(T_B\)</span> 发起，<spanclass="math inline">\(T_A\)</span> 的写入时间戳一定小于 B.snapshot_ts +max_clock_shift，因此要么读到可见的结果（A.commit_ts &lt;B.snapshot_ts），要么事务重启、用新的时间戳读到可见的结果。</p><p>那么，CockroachDB是否满足可线性化呢？<strong>答案是否定的</strong>。Jepsen 的一篇<ahref="https://jepsen.io/analyses/cockroachdb-beta-20160829">测试报告</a>中提到以下这个“双写”场景（其中，数据C1、C2 位于不同节点上）：</p><p><imgsrc="/images/2020/12/TSO-with-async-commit-is-not-linearizable.png" /></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">                        T3: r(C1)      (not found)</span><br><span class="line">T1: w(C1)</span><br><span class="line">T1: commit</span><br><span class="line">            T2: w(C2)</span><br><span class="line">            T2: commit                 (assuming T2.commit_ts &lt; T3.snapshot_ts due to clock shift)</span><br><span class="line">                        T3: r(C2)      (found)</span><br><span class="line">                        T3: commit</span><br></pre></td></tr></table></figure><p>虽然 T1 先于 T2 写入，但是 T3 却看到了 T2 而没有看到T1，此时事务的表现等价于这样的串行执行序列：T2 -&gt; T3 -&gt;T1（因此符合可串行化），与物理顺序 T1 -&gt; T2不同，违反了可线性化。归根结底是因为 T1、T2两个事务的时间戳<strong>由各自的节点独立产生，无法保证先后关系</strong>，而Read Restart机制只能防止数据<strong>存在</strong>的情况，对于这种尚不存在的数据（C1）就无能为力了。</p><p>Jepsen 对此总结为：CockroachDB仅对单行事务保证可线性化，对于涉及多行的事务则无法保证。这样的一致性级别是否能满足业务需要呢？这个问题就留给读者判断吧。</p><h2 id="结合-tso-与-hlc">结合 TSO 与 HLC</h2><p>最近看到 TiDB 的 <ahref="https://github.com/tikv/sig-transaction/blob/master/design/async-commit/initial-design.md">AsyncCommit 设计文档</a> 引起了我的兴趣。Async Commit的设计动机是为了降低提交延迟，在 TiDB 原本的 Percolator 2PC实现中，需要经过以下 4 个步骤：</p><ol type="1"><li>Prewrite：将 buffer 的修改写入 TiKV 中</li><li>从 TSO 获取提交时间戳 commit_ts</li><li>Commit Primary Key</li><li>Commit 其他 Key（异步进行）</li></ol><p>为了降低提交延迟，我们希望将第 3 步也异步化。但是第 2 步中获取的commit_ts 需要由第 3 步来保证持久化，否则一旦协调者在 2、3步之间宕机，事务恢复时就不知道用什么 commit_ts 继续提交（rollforward）。为了避开这个麻烦的问题，设计文档对 TSO时间戳模型的事务提交部分做了修改，引入 HLC 的提交方法：</p><ul><li><strong>Prewrite</strong>：<ol type="1"><li>TiDB 向各参与事务的 TiKV 节点发出 Prewrite 请求</li><li>TiKV 持久化 Prewrite 的数据以及 min_commit_ts，其中 min_commit_ts =本地最大时间戳 max_ts</li><li>TiKV 返回 Prewrite 成功消息，包含刚刚的 min_commit_ts</li></ol></li><li><strong>Finalize</strong>（异步）：计算 commit_ts = max{min_commit_ts }，用该时间戳进行提交<ol type="1"><li>Commit Primary Key</li><li>Commit 其他 Key</li></ol></li></ul><p>上述流程和 HLC 提交流程基本是一样的。注意，事务开始时仍然是从 TSO获取 snapshot_ts，这一点保持原状。</p><p>我们尝试代入上一节的“双写”场景发现：由于依赖 TSO 提供的snapshot_ts，T1、T2的时间戳依然能保证正确的先后关系，但是只要稍作修改，即可构造出失败场景（这里假设snapshot_ts 在事务 begin 时获取）：</p><p><imgsrc="/images/2020/12/TSO-with-async-commit-is-not-linearizable.png" /></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">T1: begin   T2: begin   T3: begin       (concurrently)</span><br><span class="line">T1: w(C1)</span><br><span class="line">T1: commit                              (assuming commit_ts = 105)</span><br><span class="line">            T2: w(C2)</span><br><span class="line">            T2: commit                  (assuming commit_ts = 103)</span><br><span class="line">                        T3: r(C1)       (not found)</span><br><span class="line">                        T3: r(C2)       (found)</span><br><span class="line">                        T3: commit</span><br></pre></td></tr></table></figure><p>虽然 T1 先于 T2 写入，但 T2 的提交时间戳却小于 T1，于是，并发的读事务T3 看到了 T2 而没有看到 T1，<strong>违反了可线性化</strong>。根本原因和CockroachDB 一样：T1、T2两个事务的提交时间戳由各自节点计算得出，无法确保先后关系。</p><h2 id="async-commit-done-right">Async Commit Done Right</h2><p>上个小节给出的 Async Commit 方案破坏了原本 TSO时间戳的线性一致性（虽然仅仅是个非常边缘的场景）。这里特别感谢 @<ahref="https://twitter.com/Zhifeng4cs">Zhifeng Hu</a> 的提醒，在 <ahref="https://github.com/tikv/tikv/issues/8589">#8589</a>中给出了一个巧妙的解决方案：引入 prewrite_ts 时间戳<a href="#fn1"class="footnote-ref" id="fnref1"role="doc-noteref"><sup>1</sup></a>，即可让并发事务的 commit_ts重新变得有序。完整流程如下，注意 Prewrite 的第 1、2 步：</p><ul><li><strong>Prewrite</strong>：<ol type="1"><li>TiDB 从 TSO 获取一个 prewrite_ts，附带在其中一个 Prewrite请求上发送给 TiKV</li><li>TiKV 用 prewrite_ts（如果收到的话）推高本地最大时间戳 max_ts</li><li>TiKV 持久化 Prewrite 的数据以及 min_commit_ts = max_ts</li><li>TiKV 返回 Prewrite 成功消息，包含刚刚的 min_commit_ts</li></ol></li><li><strong>Finalize</strong>（异步）：计算 commit_ts = max{min_commit_ts }，用该时间戳进行提交<ol type="1"><li>Commit Primary Key</li><li>Commit 其他 Key</li></ol></li></ul><p>对应到上面的用例中，现在 T1、T2两个事务的提交时间戳不再是独立计算，依靠 TSO 提供的 prewrite_ts可以构建出 T1、T2 的正确顺序：T2.commit_ts &gt;= T2.prewrite_ts &gt;T1.commit_ts，从而避免了上述异常。</p><p>更进一步，<strong>该方案能够满足线性一致性</strong>。这里只给一个直觉的解释：我们将TSO 看作是外部物理时间，依靠 prewrite_ts 可以保证 commit_ts 的取值位于commit 请求<strong>开始之后</strong>，而通过本地 max_ts 计算出的commit_ts 一定在 commit 请求<strong>结束之前</strong>，故 commit_ts取值落在执行提交请求的时间范围内，满足线性一致性。</p><h2 id="总结">总结</h2><ol type="1"><li>上述已知的时间戳方案中，仅有 TSO 和 TrueTime能够保证线性一致性；</li><li>Logical Clock 方案仅能保证 Session 一致性；</li><li>Cockroach 的 HLC方案仅能保证行级线性一致性，不保证多行事务的线性一致性；</li><li>TiDB Async Commit 通过引入 Prewrite时间戳保持了外部一致性；但如果去掉 Prewrite 时间戳、使用 HLC的提交方式，则不保证多行的并发事务的线性一致性。</li></ol><h2 id="references">References</h2><ol type="1"><li><a href="https://en.wikipedia.org/wiki/Lamport_timestamp">Lamporttimestamp - Wikipedia</a></li><li><ahref="https://www.slideshare.net/josemariafuster1/spanner-osdi2012-39872703">Spanner:Google’s Globally-Distributed Database - OSDI'12 Presentation</a></li><li><ahref="https://jepsen.io/analyses/cockroachdb-beta-20160829">Jepsen:CockroachDB beta-20160829</a></li><li><ahref="https://www.cockroachlabs.com/blog/living-without-atomic-clocks/">LivingWithout Atomic Clocks - Cockroach Labs</a></li><li><ahref="https://sergeiturukin.com/2017/06/29/eventual-consistency.html">Consistency,causal and eventual - Sergei Turukin</a></li><li><ahref="https://github.com/tikv/sig-transaction/blob/master/design/async-commit/initial-design.md">TiDBAsync Commit (initial design)</a></li><li><a href="https://github.com/tikv/tikv/issues/8589">Async commit doesnot ensure linearizability - GitHub</a></li></ol><aside id="footnotes" class="footnotes footnotes-end-of-document"role="doc-endnotes"><hr /><ol><li id="fn1"><p>TiDB 的实现中将这个时间戳命名为min_commit_ts，这里为了和 TiKV 返回的 min_commit_ts 区分开，暂且称它为prewrite_ts。<a href="#fnref1" class="footnote-back"role="doc-backlink">↩︎</a></p></li></ol></aside>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img src=&quot;/images/2020/12/clock-banner.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;时间戳（timestamp）是分布式事务中绕不开的重要概念，有意思的是，现在主流的几个分布式数据库对它的实现都不尽相同，甚至是主要区分点之一。本文聊一聊时间戳的前世今生，为了把讨论集中在主题上，假设读者已经对数据库的
MVCC、2PC、一致性、隔离级别等概念有个基本的了解。&lt;/p&gt;</summary>
    
    
    
    
    <category term="database" scheme="https://ericfu.me/tags/database/"/>
    
    <category term="transaction" scheme="https://ericfu.me/tags/transaction/"/>
    
  </entry>
  
  <entry>
    <title>YugabyteDB 介绍</title>
    <link href="https://ericfu.me/yugabyte-db-introduction/"/>
    <id>https://ericfu.me/yugabyte-db-introduction/</id>
    <published>2020-01-13T07:50:11.000Z</published>
    <updated>2026-02-27T03:48:16.269Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/images/2020/01/yogabytedb-logo.png" /></p><p><a href="https://www.yugabyte.com/">Yugabyte DB</a>是一个全球部署的分布式数据库，和国内的 TiDB 和国外的 CockroachDB类似，也是受到 Spanner论文启发，所以在很多地方这几个数据库存在不少相似之处。</p><p>与 Cockroach 类似，Yugabyte也主打全球分布式的事务数据库——不仅能把节点部署到全球各地，还能完整支持ACID事务，这是他最大的卖点。除此以外还有一些独特的特性，比如支持文档数据库接口。如果我猜的没错，Yugabyte早期被设计成一个文档数据库，后来才调整技术路线开始主打 SQL 接口。</p><span id="more"></span><p>本文信息主要来自于 Yugabyte 的<ahref="https://docs.yugabyte.com/">官方文档</a> 以及其 <ahref="https://github.com/yugabyte/yugabyte-db">GitHub 主页</a>。</p><h2 id="系统架构">系统架构</h2><p>逻辑上，Yugabyte采用两层架构：查询层和存储层。不过这个架构仅仅是逻辑上的，部署结构中，这两层都位于TServer 进程中。这一点和 TiDB 不同。</p><p>Yugabyte 的查询层支持同时 SQL 和 CQL 两种 API，其中 CQL 是兼容Cassandra 的一种方言语法，对应于文档数据库的存储模型；而 SQL API是直接基于 PostgresQL 魔改的，能比较好地兼容 PG语法，据官方说这样可以更方便地跟随 PG新特性，有没有官方说的这么美好我们就不得而知了。</p><p>Yugabyte 的存储层才是重头戏。其中 TServer 负责存储 tablet，每个tablet 对应一个 RaftGroup，分布在三个不同的节点上，以此保证高可用性。Master负责元数据管理，除了 tablet 的位置信息，还包括表结构等信息。Master本身也依靠 Raft 实现高可用。</p><p><img src="/images/2020/01/yb-architecture.jpg" /></p><h2 id="基于-tablet-的分布式存储">基于 Tablet 的分布式存储</h2><p>这一部分是 HBase/Spanner 精髓部分，Cockroach/TiDB的做法几乎也是一模一样的。如下图所示，每张表被分成很多个 tablet，tablet是数据分布的最小单元，通过在节点间搬运 tablet 以及 tablet的分裂与合并，就可以实现几乎无上限的 scale out。每个 tablet有多个副本，形成一个 Raft Group，通过 Raft协议保证数据的高可用和持久性，Group Leader 负责处理所有的写入负载，其他Follower 作为备份。</p><p>下图是一个例子：一张表被分成 16 个 tablet，tablet 的副本和 Raft Groupleader 均匀分布在各个节点上，分别保证了数据的均衡和负载的均衡。</p><p><img src="/images/2020/01/tserver.jpg" /></p><p>和其他产品一样，Master 节点会负责协调 tablet的搬运、分裂等操作，保证集群的负载均衡。这些操作是直接基于 Raft Group实现的。这里就不再展开了。</p><p>有趣的是，Yugabyte采用哈希和范围结合的分区方式：可以只有哈希分区、也可以只有范围分区、也可以先按哈希再按范围分区。之所以这么设计，猜测也是因为Cassandra 的影响。相比之下，TiDB 和 Cockroach 都只支持范围分区。</p><p>哈希分区的方式是将 key 哈希映射到 2 字节的空间中（即<code>0x0000</code> 到<code>0xFFFF</code>），这个空间又被划分成多个范围，比如下图的例子中被划分为16 个范围，每个范围的 key 落在一个 tablet 中。理论上说最多可能有 64K 个tablet，这对实际使用足够了。</p><p><img src="/images/2020/01/hash-keyspace.jpg" /></p><p>哈希分区的好处是插入数据（尤其是从尾部 append数据）时不会出现热点；坏处是对于小范围的范围扫描（例如<code>pk BETWEEN 1 AND 10</code>）性能会比较吃亏。</p><h2 id="基于-rocksdb-的本地存储">基于 RocksDB 的本地存储</h2><p>每个 TServer 节点上的本地存储称为 DocDB。和 TiDB/Cockroach一样，Yugabyte 也用 RocksDB 来做本地存储。这一层需要将关系型 tuple以及文档编码为 key-value 保存到 RocksDB中，下图是对文档数据的编码方式，其中有不少是为了兼容 Cassandra设计的，我们忽略这些，主要关注以下几个部分：</p><ul><li><strong>key 中包含</strong><ul><li>16-bit hash：依靠这个值才能做到哈希分区</li><li>主键数据（对应图中 hash/range columns）</li><li>column ID：因为每个 tuple 有多个列，每个列在这里需要用一个 key-value来表示</li><li>hybrid timestamp：用于 MVCC 的时间戳</li></ul></li><li><strong>value 中包含</strong><ul><li>column 的值</li></ul></li></ul><p><img src="/images/2020/01/key-value-encoding.jpg" /></p><p>如果撇开文档模型，key-value 的设计很像 Cockroach：每个 cell（一行中的一列数据）对应一个 key-value。而 TiDB 是每个 tuple 打包成一个key-value。个人比较偏好 TiDB 的做法。</p><h2 id="分布式事务2pc-mvcc">分布式事务：2PC &amp; MVCC</h2><p>和 TiDB/Cockroach 一样，Yugabyte 也采用了 MVCC 结合 2PC的事务实现。</p><h3 id="时间戳">时间戳</h3><p>时间戳是分布式事务的关键选型之一。Yugabyte 和 Cockroach 一样选择的是Hybrid Logical Clock (HLC)。</p><p>HLC 将时间戳分成物理（高位）和逻辑（低位）两部分，物理部分对应 UNIX时间戳，逻辑部分对应 Lamport时钟。在同一毫秒以内，物理时钟不变，而逻辑时钟就和 Lamport时钟一样处理——每当发生信息交换（RPC）就需要更新时间戳，从而确保操作与操作之间能够形成一个偏序关系；当下一个毫秒到来时，逻辑时钟部分归零。</p><p>不难看出，HLC 的正确性其实是由 Logical Clock 来保证的：它相比 LogicalClock 只是在每个毫秒引入了一个额外的增量，显然这不会破坏 Logical Clock的正确性。但是，物理部分的存在将原本无意义的时间戳赋予了物理意义，提高了实用性。</p><p>个人认为，HLC 是除了 TrueTime以外最好的时间戳实现了，唯一的缺点是不能提供真正意义上的外部一致性，仅仅能保证相关事务之间的“外部一致性”。另一种方案是引入中心授时节点（TSO），也就是TiDB 使用的方案。TSO 方案要求所有事务必须从 TSO获取时间戳，实现相对简单，但引入了更多的网络 RPC，而且 TSO过于关键——短时间的不可用也是极为危险的。</p><p>HLC 的实现中有一些很 tricky 的地方，比如文档中提到的 <ahref="https://docs.yugabyte.com/latest/architecture/transactions/single-row-transactions/#safe-timestamp-assignment-for-a-read-request">Safetimestamp assignment for a read request</a>。对于同一事务中的多次read，问题还要更复杂，有兴趣的读者可以看 Cockroach 团队的这篇博客 <ahref="https://www.cockroachlabs.com/blog/living-without-atomic-clocks/">LivingWithout Atomic Clocks</a>。</p><h3 id="事务提交">事务提交</h3><p>毫不惊奇，Yugabyte 的分布式事务同样是基于 2PC 的。他的做法接近Cockroach。事务提交过程中，他会在 DocDB存储里面写入一些临时的记录（provisionalrecords），包括以下三种类型：</p><ul><li>Primary provisionalrecords：还未提交完成的数据，多了一个事务ID，也扮演锁的角色</li><li>Transaction metadata：事务状态所在的 tabletID。因为事务状态表很特殊，不是按照 hash key分片的，所以需要在这里记录一下它的位置。</li><li>Reverse Index：所有本事务中的 primary provisionalrecords，便于恢复使用</li></ul><p><img src="/images/2020/01/provisional_record_storage.svg" /></p><p>事务的状态信息保存在另一个 tablet上，包括三种可能的状态：Pending、Committed 或 Aborted。事务从 Pending状态开始，终结于 Committed 或 Aborted。</p><p>事务状态就是 Commit Point 的那个“开关”，当事务状态切换到 Commited的一瞬间，就意味着事务的成功提交。这是保证整个事务原子性的关键。</p><p>完整的提交流程如下图所示：</p><p><img src="/images/2020/01/distributed_txn_write_path.svg" /></p><p>另外，Yugabyte 文档中提到它除了 Snapshot Isolation 还支持Serializable 隔离级别，但是似乎没有看到他是如何规避 Write Skew问题的。从 Release Notes 看来这应该是 2.0 GA中新增加的功能，等更多信息放出后再研究吧！</p><h2 id="竞品对比">竞品对比</h2><p>以下表格摘自 <ahref="https://docs.yugabyte.com/latest/comparisons/">Compare YugabyteDBto other databases</a>：</p><p><img src="/images/2020/01/comparisions.jpg" /></p><h2 id="references">References</h2><ol type="1"><li><a href="https://www.yugabyte.com/">Yugabyte DB</a></li><li><a href="https://docs.yugabyte.com/">Yugabyte DB Documents</a></li><li><ahref="https://www.cockroachlabs.com/blog/living-without-atomic-clocks/">LivingWithout Atomic Clocks - Cockroach Labs</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img src=&quot;/images/2020/01/yogabytedb-logo.png&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.yugabyte.com/&quot;&gt;Yugabyte DB&lt;/a&gt;
是一个全球部署的分布式数据库，和国内的 TiDB 和国外的 CockroachDB
类似，也是受到 Spanner
论文启发，所以在很多地方这几个数据库存在不少相似之处。&lt;/p&gt;
&lt;p&gt;与 Cockroach 类似，Yugabyte
也主打全球分布式的事务数据库——不仅能把节点部署到全球各地，还能完整支持
ACID
事务，这是他最大的卖点。除此以外还有一些独特的特性，比如支持文档数据库接口。如果我猜的没错，Yugabyte
早期被设计成一个文档数据库，后来才调整技术路线开始主打 SQL 接口。&lt;/p&gt;</summary>
    
    
    
    
    <category term="database" scheme="https://ericfu.me/tags/database/"/>
    
    <category term="sql" scheme="https://ericfu.me/tags/sql/"/>
    
  </entry>
  
  <entry>
    <title>G1 垃圾收集器</title>
    <link href="https://ericfu.me/g1-garbage-collector/"/>
    <id>https://ericfu.me/g1-garbage-collector/</id>
    <published>2019-11-01T03:26:03.000Z</published>
    <updated>2026-02-27T03:48:16.266Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/images/2019/10/g1-banner.jpg" /></p><p>在过去很长一段时间内，HotSpot JVM 的首选垃圾收集器都是 ParNew + CMS组合。直到 JDK7 中 Hotspot 团队首次公布了 G1（Garbage-First），并在 JDK9中用 G1 作为默认的垃圾收集器。我们团队最近也将用了很多年的 CMS 换成了 G1垃圾收集器。</p><p>本文主要从 G1 的论文 <ahref="http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.63.6386&amp;rep=rep1&amp;type=pdf">Garbage-FirstGarbage Collection</a> 出发，结合其他较新的白皮书等，讲解 G1垃圾收集器的工作原理。</p><span id="more"></span><h2 id="motivation">Motivation</h2><p>关于为什么要重新设计一个 G1垃圾收集器，论文中给出的理由相当简单：现有的垃圾收集器无法满足<strong>软实时（SoftReal-time）</strong>特性：即让 GC停顿能大致控制在某个阈值以内，但是又不必像实时系统那样非常严格。这也是很多业务系统都有的诉求。</p><p>在过去的 JVM 设计中，如下图所示，堆内存被分割成几个区域 ——Eden、Survivor、Old 的大小都是预先划分好的。对于总内存 64GB 的机器，可能Old 区大小就有32GB，即使用并行的方式收集一次仍然需要数秒。近十年，随着内存越来越大，这一问题也变得更为严重。</p><figure><img src="/images/2019/10/hotspot-legacy-heap-structure.png"alt="Hotspot JVM 经典内存布局" /><figcaption aria-hidden="true">Hotspot JVM 经典内存布局</figcaption></figure><p>为了达到软实时的目标，同时也是为了更好地应对大内存，G1将中不再使用上述的内存布局。</p><h2 id="基本数据结构">基本数据结构</h2><p>首先，我们介绍 G1 种最核心的两个概念：Region 和 Remember Set。</p><h3 id="heap-regions">Heap Regions</h3><p>如下图所示，G1 垃圾收集器将堆内存空间分成等分的Regions，物理上不一定连续，逻辑上构成连续的堆地址空间。各个 Mutator线程（即用户应用的线程）拥有各自的 Thread-Local Allocation Buffer(TLAB），用于降低各个线程分配内存的冲突。</p><figure><img src="/images/2019/10/g1-heap-regions.png"alt="G1 内存布局：Heap Regions" /><figcaption aria-hidden="true">G1 内存布局：Heap Regions</figcaption></figure><p>要特别注意的是，<strong>巨型对象（HumongousObject）</strong>，即大小超过 3/4 的 Region大小的对象会作特殊处理，分配到由一个或多个连续 Region构成的区域。巨型对象会引起其他一些问题，不过这些已经超出了本文的范畴，总之记得尽量别用就好了。</p><p>默认配置下，在满足 Region Size 是 2 的整数幂的前提下，G1将总内存尽量划分成大约 2048 个 Region。</p><h3 id="remember-set-rset">Remember Set (RSet)</h3><p>为什么要把堆空间分成 Region 呢？<strong>其主要目的是让各个 Region相对独立，可以分别进行GC</strong>，而不是一次性地把所有垃圾收集掉。我们知道现代 GC算法都是基于可达性标记，而这个过程必须遍历所有 Live Objects才能完成。那问题来了，如果为了收集一个 Region 的垃圾，却完整的遍历所有Live Objects，这也太浪费了！</p><p>所以，我们需要一个机制来让各个 Region 能独立地进行垃圾收集，这也就是Remember Set 存在的意义。每个 Region 会有一个对应的 RememberSet，它记录了<strong>哪些内存区域中存在对当前 Region中对象的引用。</strong>（<em>all locations that might contain pointersto (live) objects within the region</em>）</p><figure><img src="/images/2019/10/g1-remember-sets.png"alt="Remember Set 记录引用当前 Region 中对象的 Cards" /><figcaption aria-hidden="true">Remember Set 记录引用当前 Region 中对象的Cards</figcaption></figure><p>注意 Remember Set 不是直接记录对象地址，而是记录了那些对象所在的 Card编号。所谓 Card 就是表示一小块（512bytes）的内存空间，这里面很可能存在不止一个对象。但是这已经足够了：当我们需要确定当前Region有哪些对象存在外部引用时（这些对象是可达的，不能被回收），只要扫描一下这块Card 中的所有对象即可，这比扫描所有 live objects 要容易的多。</p><p>实现上，Remember Set 的实现就是一个 Card 的 Hash Set，并且为每个 GC线程都有一个本地的 Hash Set，最后的 Remember Set 实际上是这些 Hash Set的并集。当 Card 数量特别多的时候会退化到 Region粒度，这时候就要扫描更多的区域来寻找引用，时间换空间。</p><h3 id="remember-set-的维护">Remember Set 的维护</h3><p>维护上面所说的 Remember Set 势必需要记录对象的引用，通常的做法是在set 一个引用的时候插入一段代码，这称为 Write Barrier。为了尽可能降低对Mutator 线程的影响，Write Barrier 的代码应当尽可能简化。G1 的 WriteBarrier 实际上只是一个“通知”：将当前 set 引用的事件放到 Remember Set Log队列中，交给后台专门的 GC 线程处理。</p><figure><img src="/images/2019/10/g1-remember-set-maintenance.png"alt="RememberSet 的维护" /><figcaption aria-hidden="true">RememberSet 的维护</figcaption></figure><p>Write Barrier 具体实现如下。当发生 <code>X.f = Y</code> 时，假设<code>rX</code> 为 X 对象的地址，<code>rY</code> 为 Y 对象的地址，则Write 的同时还会执行以下逻辑：</p><figure class="highlight c"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">t = (rX XOR rY) &gt;&gt; LogOfRegionSize  <span class="comment">// 对 X, Y 地址右移得到 Region 编号，并将二者做个 XOR</span></span><br><span class="line"><span class="keyword">if</span> (rY == <span class="literal">NULL</span> ? <span class="number">0</span> : t)  <span class="comment">// 忽略两种情况： X.f 被赋值为 NULL，或 X 和 Y 位于同一个 Region 内</span></span><br><span class="line">   rs_enqueue(rX)        <span class="comment">// 如果 Card(X) 还不是 dirty 的，将 X 的地址放进 Log，并把该 card 置为 dirty</span></span><br></pre></td></tr></table></figure><p>这里 Dirty Bit 的作用是去除重复的 Cards，考虑到一个 Cards内经常发生密集的引用赋值（比如对象初始化），去重一下能大幅减少冗余。</p><p>最后，后台的 GC 线程则负责从 Remember Set Log不断取出这些引用赋值发生的 Cards，扫描上面所有的对象，然后更新相应Region 的 Remember Set。在并发标记发生之前，G1 会确保 Remember Set Log中的记录都处理完，从而保证并发标记算法一定能拿到最新的、正确的 RememberSet。</p><p>极端情况下，如果后台的 GC 进程追不上 Mutator 进程写入的速度，这时候Mutator 线程会退化到自己处理更新，形成反压机制。</p><h2 id="generational-garbage-first">Generational Garbage-First</h2><p>G1 名字来自于 Garbage-First这个理念，即，<strong>以收集到尽可能多的垃圾为第一目标</strong>。每次收集时G1 会选出垃圾最多的几个 Region，进行一次 Stop-the-world 的收集过程。</p><p>有趣的是，另一方面 G1 又是一个 Generational（分代）的垃圾收集器，它会从逻辑上将 Region 分成 Young、Old 等不同的Generation，然后针对它们各自特点应用不同的策略。</p><p>G1 论文中提到它有一个 Pure Garbage-First的模式，但在现在的资料中已经很难看到它的踪影，我猜测实际使用中Generational 模式要效果好的多。以下我们也会只讨论 Generational模式的工作方式。</p><p>经典的内存布局中，各代的内存区域是完全分开的，而 G1 中的 Generation只是 Region 的一个动态标志，下图是一个标记了 Generation 的例子。各个Region 的 Generation 是随着 GC 的进行而不断变化的，甚至各个代有多少Region 这个比例也是随时调整的。</p><figure><img src="/images/2019/10/g1-generation-regions-example.png"alt="Generational Regions" /><figcaption aria-hidden="true">Generational Regions</figcaption></figure><h2 id="evacuation">Evacuation</h2><p>为了方便读者理解 G1 收集的过程，我们先看下 Evacuation的过程，之后再看如何做 Marking。</p><p><strong>Generational 模式下 G1 的垃圾收集分为两种：Young GC 和 MixedGC</strong>。Young GC 只会涉及到 Young Regions，它将 Eden Region中存活的对象移动到一个或多个新分配的 Survivor Region，之前的 Eden Region就被归还到 Free list，供以后的新对象分配使用。</p><figure><img src="/images/2019/10/g1-generation-regions-young-gc-1.png"alt="Young GC - 1" /><figcaption aria-hidden="true">Young GC - 1</figcaption></figure><p>当区域中对象的 Survive次数超过阈值（<code>TenuringThreshold</code>）时，Survivor Regions的对象被移动到 Old Regions；否则和 Eden 的对象一样，继续留在 SurvivorRegions 里。</p><figure><img src="/images/2019/10/g1-generation-regions-young-gc-2.png"alt="Young GC - 2" /><figcaption aria-hidden="true">Young GC - 2</figcaption></figure><p>多次 Young GC 之后，Old Regions慢慢累积，直到到达阈值（<code>InitiatingHeapOccupancyPercent</code>，简称IHOP），我们不得不对 Old Regions 做收集。这个阈值在 G1中是根据用户设定的 GC 停顿时间动态调整的，也可以人为干预。</p><p>对 Old Regions 的收集会同时涉及若干个 Young 和 OldRegions，因此被称为 <strong>Mixed GC</strong>。Mixed GC 很多地方都和Young GC 类似，不同之处是：它还会选择若干最有潜力的 OldRegions（收集垃圾的效率最高的 Regions），这些选出来要被 Evacuate 的Region 称为本次的 Collection Set (CSet)。</p><figure><img src="/images/2019/10/g1-generation-regions-mixed-gc.png"alt="Mixed GC" /><figcaption aria-hidden="true">Mixed GC</figcaption></figure><p>Mixed GC 的重要性不言而喻：Old Regions的垃圾就是在这个阶段被收集掉的，也正是因为这样，Mixed GC是工作量最为繁重的一个环节，如果不加以控制，就会像 CMS 一样发生长时间的Full GC 停顿。这时候 Region 的设计就发挥出优越性了：只要把每次的Collection Set 规模控制在一定范围，就能把每次收集的停顿时间软性地控制在<code>MaxGCPauseMillis</code> 以内。起初这个控制可能不太精准，随着 JVM的运行估算会越来越准确。</p><p>那来不及收集的那些 Region 呢？多来几次就可以了。所以你在 GC日志中会看到 <code>continue mixed GCs</code>的字样，代表分批进行的各次收集。这个过程会多次重复，直到垃圾的百分比降到<code>G1HeapWastePercent</code> 以内，或者到达<code>G1MixedGCCountTarget</code> 上限。</p><p>对于 Young Regions，我们对它有以下特殊优化：</p><ol type="1"><li>Evacuation 的时候，Young Regions 一定会被放到待收集的 Regions集合（Collection Set）中，原因很简单，绝大多数对象寿命都很短，在 YoungRegions 做收集往往绝大部分都是垃圾。</li><li>由于 Young Regions 一定会被收集，我们获得了一个可观的收益：RememberSet 的维护工作不需要考虑 Young 内的引用修改（换句话说 RSet 只关心old-to-young 和 old-to-old 的引用），当 Young Region 上发生 Evacuation时我们再去扫描并构建出它的 RSet 即可。</li></ol><h2 id="concurrent-marking">Concurrent Marking</h2><p>在 Evacuation之前，我们要通过并发标记来确定哪些对象是垃圾、哪些还活着。G1 中的Concurrent Marking 是以 Region为单位的，为了保证结果的正确性，这里用到了Snapshot-at-the-beginning（SATB）算法。</p><p>SATB 算法顾名思义是对 Marking 开始时的一个（逻辑上的）Snapshot进行标记。为什么要用 Snapshot呢？下面就是一个直接标记导致问题的例子：对象 X由于没有被标记到而被标记为垃圾，导致 B 引用失效。</p><figure><img src="/images/2019/10/illustrate-why-need-satb.png"alt="如果只是对现场情况做标记，可能会漏掉某些对象" /><figcaptionaria-hidden="true">如果只是对现场情况做标记，可能会漏掉某些对象</figcaption></figure><p>SATB 算法为了解决这一问题，在修改引用 <code>X.f = B</code>之前插入了一个 WriteBarrier，记录下被覆写之前的引用地址。这些地址最终也会被 Marking线程处理，从而确保了所有在 Marking 开始时的引用一定会被标记到。这个Write Barrier 伪代码如下：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">t = the previous referenced address  // 记录原本的引用地址</span><br><span class="line">if (t has been marked &amp;&amp; t != NULL)  // 如果地址 t 还没来的及标记，且 t 不为 NULL</span><br><span class="line">    satb_enqueue(t) // 放到 SATB 的待处理队列中，之后会去扫描这个引用</span><br></pre></td></tr></table></figure><p>通过以上措施，SATB 确保 Marking 开始时存活的对象一定会被标记到。</p><p>标记的过程和 CMS 中是类似的，可以看作一个优化版的DFS：记当前已经标记到的 offset 为 <code>cur</code>，随着标记的进行 cur不断向后推进。每当访问到地址 &lt; cur的对象，就对它做深度扫描，递归标记所有应用；反之，对于地址 &gt; cur的对象，只标记不扫描，等到 cur 推进到那边的时候再去做扫描。</p><figure><img src="/images/2019/10/concurrent-marking.jpg"alt="基于 cur 指针实现 Concurrent Marking" /><figcaption aria-hidden="true">基于 cur 指针实现 ConcurrentMarking</figcaption></figure><p>上图中，假设当前 cur 指向对象 c，c有两个引用：a 和 e，其中 a的地址小于 cur，因而做了扫描；而 e 则仅仅是标记。扫描 a的过程中又发现了对象 b，b 同样被标记并继续扫描。但是 b 引用的 d 在 cur之后，所以 d 仅仅是被标记，不再继续扫描。</p><p>最后一个问题是：如何处理 Concurrent Marking 中新产生的对象？因为 SATB算法只保证能标记到开始时 snapshot的对象，对于新出现的那些对象，我们可以简单地认为它们全都是存活的，毕竟数量不是很多。</p><h2 id="references">References</h2><ol type="1"><li>Detlefs, David, et al <strong>Garbage-First GarbageCollection</strong>. Proceedings of the 4th international symposium onMemory management. ACM, 2004.</li><li>Printezis, Tony, and David Detlefs. <strong>A GenerationalMostly-Concurrent Garbage Collector</strong>. Vol. 36. No. 1. ACM,2000.</li><li><ahref="https://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf">MemoryManagement in the Java HotSpot™ Virtual Machine (2006)</a></li><li><ahref="https://www.redhat.com/en/blog/part-1-introduction-g1-garbage-collector?source=author&amp;term=22991">Introductionto the G1 Garbage Collector - Matt Robson - RedHat</a></li><li><ahref="https://www.redhat.com/en/blog/collecting-and-reading-g1-garbage-collector-logs-part-2?source=author&amp;term=22991">Collectingand reading G1 garbage collector logs - Matt Robson - RedHat</a></li><li><a href="https://www.slideshare.net/MonicaBeckwith/con5497">GarbageFirst Garbage Collector (G1 GC): Current and Future Adaptability andErgonomics - Monica Beckwith - Slideshare</a></li><li><ahref="https://blog.csdn.net/coderlius/article/details/79272773">详解 JVMGarbage First(G1) 垃圾收集器 - coderlius - CSDN</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img src=&quot;/images/2019/10/g1-banner.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;在过去很长一段时间内，HotSpot JVM 的首选垃圾收集器都是 ParNew + CMS
组合。直到 JDK7 中 Hotspot 团队首次公布了 G1（Garbage-First），并在 JDK9
中用 G1 作为默认的垃圾收集器。我们团队最近也将用了很多年的 CMS 换成了 G1
垃圾收集器。&lt;/p&gt;
&lt;p&gt;本文主要从 G1 的论文 &lt;a
href=&quot;http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.63.6386&amp;amp;rep=rep1&amp;amp;type=pdf&quot;&gt;Garbage-First
Garbage Collection&lt;/a&gt; 出发，结合其他较新的白皮书等，讲解 G1
垃圾收集器的工作原理。&lt;/p&gt;</summary>
    
    
    
    
    <category term="java" scheme="https://ericfu.me/tags/java/"/>
    
    <category term="jvm" scheme="https://ericfu.me/tags/jvm/"/>
    
  </entry>
  
  <entry>
    <title>Javadoc 最佳实践</title>
    <link href="https://ericfu.me/javadoc-coding-standards/"/>
    <id>https://ericfu.me/javadoc-coding-standards/</id>
    <published>2019-09-05T16:34:10.000Z</published>
    <updated>2026-02-27T03:48:16.266Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p>本文翻译自 <ahref="https://blog.joda.org/2012/11/javadoc-coding-standards.html">Javadoccoding standards - Stephen Colebourne's blog</a></p></blockquote><p>Javadoc 是 Java编程中很重要的一部分，然而却很少有人谈论如何去写好一个的Javadoc。如果想写好 Javdoc，首先最好有一份代码规范。</p><h3 id="javadoc-代码规范">Javadoc 代码规范</h3><p>我之前尝试过一些 Javadoc的标准。考虑到每个人喜好不同，我这里只想谈谈最基本的一些原则，不去涉及方方面面的细节。另外，我们只讨论Javadoc 的<strong>格式</strong>，其内容不在本文范围之内。</p><span id="more"></span><p>这里有一份 <ahref="http://www.oracle.com/technetwork/java/javase/documentation/index-137868.html">Oracle家的指南</a> 要比本文详细的多，不过大部份要求都是一致的。</p><p>以下所有条目我都尽可能说的简明，并用一些例子去阐述。</p><p><strong>让 Javadoc 像代码一样可读</strong></p><p>当你听到 “Javadoc” 这个词的时候，你首先想到的可能是 Javadoc 生成的HTML网页，然而实际情况绝非如此。多数情况下，其他人都是在看源代码的时候用到这些Javadoc，比如你看同事的代码、或是研究第三方库的代码。时刻记住：让Javadoc 像 Java 代码一样保持可读性。</p><p><strong>Public 和 Protected</strong></p><p>所有 Public 和 Protected 方法都应当有相应的 Javadoc。Package 和Private 方法不强求，但是如果有帮助的话加上也很好。</p><p>如果子类覆盖了父类中的某个方法，一般来说不需要Javadoc，除非这个覆盖的实现和原有的差别很大，这时候需要用 Javadoc说明差异的那部分。<code>@Override</code>注解不仅标记了方法覆盖，另一方面也是暗示读者要参考原来方法上的文档一起看。</p><p><strong>使用标准的 Javadoc 风格注释</strong></p><p>Javadoc 以 <code>/**</code> 开头、以 <code>*/</code>结尾，并且每行要以星号开头：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Standard comment.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">public</span> ...</span><br><span class="line"></span><br><span class="line"><span class="comment">/** Compressed comment. */</span></span><br><span class="line"><span class="keyword">public</span> ...</span><br></pre></td></tr></table></figure><p>注意别用 <code>**/</code> 作结尾。</p><p><strong>用简单的 HTML tags 就行了，不需要 XHTML</strong></p><p>Javadoc 用 HTML tags 来识别段落、列表等等。很多开发者可能觉得 <ahref="https://zh.wikipedia.org/wiki/XHTML">XHTML</a>（HTML的一种“严格版本”）会更好，其实不然。XHTML 常常会多出一些tag，这会导致代码变得更复杂了，可读性更差。</p><p>此外，Javadoc 的 parser 其实会帮你把没闭合的 tags自动闭合的，别担心。</p><p><strong>用单个 <code>&lt;p&gt;</code> 来分割段落</strong></p><p>Javadoc经常会需要分成好几段。所以问题来了：怎样优雅地加上段落标记？答案是，在两段之间写上一行<code>&lt;p&gt;</code> 就可以了，不用加 <code>&lt;/p&gt;</code>闭合它。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * First paragraph.</span></span><br><span class="line"><span class="comment"> * &lt;p&gt;</span></span><br><span class="line"><span class="comment"> * Second paragraph.</span></span><br><span class="line"><span class="comment"> * May be on multiple lines.</span></span><br><span class="line"><span class="comment"> * &lt;p&gt;</span></span><br><span class="line"><span class="comment"> * Third paragraph.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">public</span> ...</span><br></pre></td></tr></table></figure><p><strong>用单个 <code>&lt;li&gt;</code> 来标记列表项</strong></p><p>列表在 Javadoc中也很常用，比如用来表示一组选项、一些问题等等。推荐的做法是用一个<code>&lt;li&gt;</code>作为每项的开头，同样不需要闭合。此外，别忘了加段落 tag：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * First paragraph.</span></span><br><span class="line"><span class="comment"> * &lt;p&gt;&lt;ul&gt;</span></span><br><span class="line"><span class="comment"> * &lt;li&gt;the first item</span></span><br><span class="line"><span class="comment"> * &lt;li&gt;the second item</span></span><br><span class="line"><span class="comment"> * &lt;li&gt;the third item</span></span><br><span class="line"><span class="comment"> * &lt;/ul&gt;&lt;p&gt;</span></span><br><span class="line"><span class="comment"> * Second paragraph.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">public</span> ...</span><br></pre></td></tr></table></figure><p><strong>首句很重要</strong></p><p>Javadoc 的首句（用英文句号结束）也被作为这个 Javadoc的摘要，在折叠的时候只会显示这一句。因此首句必须是个总结性的描述，它最好简洁有力，不能太长。</p><p>虽然没有强制要求，我们建议首句自成一个段落，这让代码看起来更清晰。</p><p>对于英文注释，推荐使用第三人称来描述，比如 “Gets the foo”、“Sets thebar”、“Consumes the baz”。避免使用第二人称，比如 “Get the foo”。</p><p><strong>用 “this” 指代类的对象</strong></p><p>当你想描述这个类的一个实例（对象）的时候，用 “this” 来指代它，比如“Returns a copy of this foo with the bar value updated”</p><p><strong>别写太长的句子</strong></p><p>尽量让一句话能容纳在一行中，一般来说一行有 80 到 120 个字符。</p><p>新的句子就另起一行，这会让代码可读性更好，也会让以后改写 Javadoc容易很多。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * This is the first paragraph, on one line.</span></span><br><span class="line"><span class="comment"> * &lt;p&gt;</span></span><br><span class="line"><span class="comment"> * This is the first sentence of the second paragraph, on one line.</span></span><br><span class="line"><span class="comment"> * This is the second sentence of the second paragraph, on one line.</span></span><br><span class="line"><span class="comment"> * This is the third sentence of the second paragraph which is a bit longer so has been</span></span><br><span class="line"><span class="comment"> * split onto a second line, as that makes sense.</span></span><br><span class="line"><span class="comment"> * This is the fourth sentence, which starts a new line, even though there is space above.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">public</span> ...</span><br></pre></td></tr></table></figure><p><strong>正确使用 <span class="citation"data-cites="link">@link</span> 和 <span class="citation"data-cites="code">@code</span></strong></p><p>很多地方的描述需要涉及到其他类或方法，这时最好用 <spanclass="citation" data-cites="link">@link</span> 和 <spanclass="citation" data-cites="code">@code</span>。</p><p><span class="citation" data-cites="link">@link</span>会最终变成一个超链接，它有以下几种形式：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * First paragraph.</span></span><br><span class="line"><span class="comment"> * &lt;p&gt;</span></span><br><span class="line"><span class="comment"> * Link to a class named &#x27;Foo&#x27;: &#123;<span class="doctag">@link</span> Foo&#125;.</span></span><br><span class="line"><span class="comment"> * Link to a method &#x27;bar&#x27; on a class named &#x27;Foo&#x27;: &#123;<span class="doctag">@link</span> Foo#bar&#125;.</span></span><br><span class="line"><span class="comment"> * Link to a method &#x27;baz&#x27; on this class: &#123;<span class="doctag">@link</span> #baz&#125;.</span></span><br><span class="line"><span class="comment"> * Link specifying text of the hyperlink after a space: &#123;<span class="doctag">@link</span> Foo the Foo class&#125;.</span></span><br><span class="line"><span class="comment"> * Link to a method handling method overload &#123;<span class="doctag">@link</span> Foo#bar(String,int)&#125;.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">public</span> ...</span><br></pre></td></tr></table></figure><p><span class="citation" data-cites="code">@code</span>用来标记一小段等宽字体，也可以用来标记某个类或方法，但不会生成超链接。</p><p>建议在第一次提到某个类或方法的时候用 <span class="citation"data-cites="link">@link</span>，此后直接用 <span class="citation"data-cites="code">@code</span> 即可。</p><p><strong>不要在首句中使用 <span class="citation"data-cites="link">@link</span></strong></p><p>之前提到，Javadoc的首句也被用作概要，首句中的超链接会让读者感到混乱。如果一定要在第一句话中引用其它类或方法，始终用<span class="citation" data-cites="code">@code</span> 而不是 <spanclass="citation" data-cites="link">@link</span>，第二句开始再用 <spanclass="citation" data-cites="link">@link</span>。</p><p><strong>null、true、false 不必用 <span class="citation"data-cites="code">@code</span> 标记</strong></p><p>null、true、false 这些词在 Javadoc 中太常用了，如果每次都加上 <spanclass="citation"data-cites="code">@code</span>，无论是对读者还是作者都是个负担。</p><p><strong>使用 <span class="citation"data-cites="param">@param</span>、<span class="citation"data-cites="return">@return</span> 和 <span class="citation"data-cites="throws">@throws</span></strong></p><p>几乎所有方法都会输入几个参数、输出一个结果，<span class="citation"data-cites="param">@param</span> 和 <span class="citation"data-cites="return">@return</span> 就是用来描述这些输入输出参数的，<spanclass="citation" data-cites="throws">@throws</span>用于描述方法抛出的异常。</p><p>如果有多个输入参数，<span class="citation"data-cites="param">@param</span> 的顺序也要和参数一致。<spanclass="citation" data-cites="return">@return</span> 应当始终放在 <spanclass="citation" data-cites="param">@param</span> 之后，然后才是 <spanclass="citation" data-cites="throws">@throws</span>。</p><p><strong>为范型参数加上 <span class="citation"data-cites="param">@param</span></strong></p><p>如果一个类或方法有范型参数（例如<code>&lt;T&gt;</code>），这些参数也应当被文档化，推荐的做法是给<code>&lt;T&gt;</code> 也加上一个 <span class="citation"data-cites="param">@param</span> 说明。</p><p><strong>在 <span class="citation" data-cites="param">@param</span>之前空一行</strong></p><p>始终在 Javadoc 的内容和 <span class="citation"data-cites="param">@param</span>、<span class="citation"data-cites="return">@return</span>之间留个空行，这让代码的可读性更佳。</p><p><strong>用短语来描述 <span class="citation"data-cites="param">@param</span> 和 <span class="citation"data-cites="return">@return</span></strong></p><p><span class="citation" data-cites="param">@param</span> 和 <spanclass="citation" data-cites="return">@return</span>后面跟的的描述是个短语，而非完整的句子，因此它得用小写字母开头（经常是the），结尾也不需要用句号。</p><p><strong>用 if-句来描述 <span class="citation"data-cites="throws">@throws</span></strong></p><p><span class="citation" data-cites="throws">@throws</span>通常跟着一个 “if” 句子来描述抛异常的情形，比如 “<span class="citation"data-cites="throws">@throws</span> IllegalArgumentException if the filecould not be found”。</p><p><strong><span class="citation" data-cites="param">@param</span>的参数名之后空两格</strong></p><p>在源代码中阅读 Javadoc的时候，如果参数名后面只有一个空格，读起来会有点困难，两个空格就好很多。另外，避免把参数按列对齐，否则参数改名、增减参数的时候会很麻烦。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Javadoc text.</span></span><br><span class="line"><span class="comment"> * </span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> foo  the foo parameter</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> bar  the bar parameter</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@return</span> the baz content</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">public</span> String <span class="title function_">process</span><span class="params">(String foo, String bar)</span> &#123;...&#125;</span><br></pre></td></tr></table></figure><p><strong>写明各参数和返回值的 null 行为</strong></p><p>一个方法是否接受 null、会不会返回 null对于其他开发者是十分重要的信息。除非是原始类型，<span class="citation"data-cites="param">@param</span> 和 <span class="citation"data-cites="return">@return</span> 都应该注明它是否接受或返回null。以下标准若适用请务必遵循：</p><ul><li>“not null” 表明不接受 null，若输入 null 可能导致异常，例如NullPointerException</li><li>“may be null” 表明可以传入 null 参数</li><li>“null treated as xxx” 表明 null 值等价于某个值</li><li>“null returns xxx” 表明如果输入 null 则一定会返回某个值</li></ul><p>定义清楚这些之后，<strong>不</strong>要再为 NullPointerException 写<span class="citation" data-cites="throws">@throws</span>。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Javadoc text.</span></span><br><span class="line"><span class="comment"> * </span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> foo  the foo parameter, not null</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> bar  the bar parameter, null returns null</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@return</span> the baz content, null if not processed</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="keyword">public</span> String <span class="title function_">process</span><span class="params">(String foo, String bar)</span> &#123;...&#125;</span><br></pre></td></tr></table></figure><p>有人可能想在某个地方（像是类或包的 Javadoc）集中定义 null相关行为，但我们不建议你这么做，因为这对别人并没有帮助。方法上的 Javadoc很容易就能看到，而类或包层级的 Javadoc 要去翻一遍才能找到。</p><p>其他简单的约束条件也建议写到 Javadoc 里，比如 “not empty, notnull”。原始类型也可以加上边界约束，比如 “from 1 to 5” 或 “notnegative”</p><p><strong>给 Specification 加上 implementation notes</strong></p><p>如果某个接口允许第三方来实现，而你为这个接口写了个正式的规格说明（specification），这时候考虑加个“implementation notes” 章节。这通常出现在类的 Javadoc上，用于描述一些不太好写在特定方法上的东西，或者一些其他人不感兴趣的东西。参考<ahref="https://github.com/ThreeTen/threeten/blob/0b071a60997f409e44b9bbccde013b004f24fe22/src/main/java/javax/time/Clock.java#L74">这个例子</a>。</p><p><strong>不要用 <span class="citation"data-cites="author">@author</span></strong></p><p><span class="citation" data-cites="author">@author</span>用来标记类的作者，这个功能已经过时了，不要用。版本控制系统（例如git）会记住作者的。</p><p><strong>例子</strong></p><p>这个 <a href="https://github.com/ThreeTen/threeten">ThreeTen</a>项目里有一些更完整的<ahref="https://github.com/ThreeTen/threeten/blob/0b071a60997f409e44b9bbccde013b004f24fe22/src/main/java/javax/time/LocalDate.java#L73">例子</a></p><h3 id="总结">总结</h3><p>希望这些建议能帮你写出更好的Javadoc。当然，这只是一份建议，你也可以选择其他标准来参考。</p>]]></content>
    
    
    <summary type="html">&lt;blockquote&gt;
&lt;p&gt;本文翻译自 &lt;a
href=&quot;https://blog.joda.org/2012/11/javadoc-coding-standards.html&quot;&gt;Javadoc
coding standards - Stephen Colebourne&#39;s blog&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Javadoc 是 Java
编程中很重要的一部分，然而却很少有人谈论如何去写好一个的
Javadoc。如果想写好 Javdoc，首先最好有一份代码规范。&lt;/p&gt;
&lt;h3 id=&quot;javadoc-代码规范&quot;&gt;Javadoc 代码规范&lt;/h3&gt;
&lt;p&gt;我之前尝试过一些 Javadoc
的标准。考虑到每个人喜好不同，我这里只想谈谈最基本的一些原则，不去涉及方方面面的细节。另外，我们只讨论
Javadoc 的&lt;strong&gt;格式&lt;/strong&gt;，其内容不在本文范围之内。&lt;/p&gt;</summary>
    
    
    
    
    <category term="java" scheme="https://ericfu.me/tags/java/"/>
    
  </entry>
  
  <entry>
    <title>SQL 窗口函数的优化和执行</title>
    <link href="https://ericfu.me/sql-window-function/"/>
    <id>https://ericfu.me/sql-window-function/</id>
    <published>2019-08-21T15:24:21.000Z</published>
    <updated>2026-02-27T03:48:16.268Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/images/2019/08/banner-cat-on-window.jpg" /></p><p><strong>窗口函数（Window Function）</strong>是 SQL2003标准中定义的一项新特性，并在 SQL2011、SQL2016中又加以完善，添加了若干处拓展。窗口函数不同于我们熟悉的普通函数和聚合函数，它为每行数据进行一次计算：<strong>输入多行（一个窗口）、返回一个值</strong>。在报表等分析型查询中，窗口函数能优雅地表达某些需求，发挥不可替代的作用。</p><p>本文首先介绍窗口函数的定义及基本语法，之后将介绍在 DBMS和大数据系统中是如何实现高效计算窗口函数的，包括窗口函数的优化、执行以及并行执行。</p><span id="more"></span><h2 id="什么是窗口函数">什么是窗口函数？</h2><p>窗口函数出现在 SELECT 子句的表达式列表中，它最显著的特点就是<code>OVER</code> 关键字。语法定义如下：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">window_function (expression) <span class="keyword">OVER</span> (</span><br><span class="line">   [ <span class="keyword">PARTITION</span> <span class="keyword">BY</span> part_list ]</span><br><span class="line">   [ <span class="keyword">ORDER</span> <span class="keyword">BY</span> order_list ]</span><br><span class="line">   [ &#123; <span class="keyword">ROWS</span> <span class="operator">|</span> <span class="keyword">RANGE</span> &#125; <span class="keyword">BETWEEN</span> frame_start <span class="keyword">AND</span> frame_end ] )  </span><br></pre></td></tr></table></figure><p>其中包括以下可选项：</p><ul><li><strong>PARTITION BY</strong> 表示将数据先按 <code>part_list</code>进行分区</li><li><strong>ORDER BY</strong> 表示将各个分区内的数据按<code>order_list</code> 进行排序</li></ul><figure><img src="/images/2019/08/windows-function-concepts.png"alt="Figure 1. 窗口函数的基本概念" /><figcaption aria-hidden="true">Figure 1. 窗口函数的基本概念</figcaption></figure><p>最后一项表示 Frame 的定义，即：当前窗口包含哪些数据？</p><ul><li><strong>ROWS</strong> 选择前后几行，例如<code>ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING</code> 表示往前 3行到往后 3 行，一共 7 行数据（或小于 7 行，如果碰到了边界）</li><li><strong>RANGE</strong> 选择数据范围，例如<code>RANGE BETWEEN 3 PRECEDING AND 3 FOLLOWING</code> 表示所有值在<span class="math inline">\([c-3,c+3]\)</span> 这个范围内的行，<spanclass="math inline">\(c\)</span> 为当前行的值</li></ul><figure><img src="/images/2019/08/frame-rows-range.png"alt="Figure 2. Rows 窗口和 Range 窗口" /><figcaption aria-hidden="true">Figure 2. Rows 窗口和 Range窗口</figcaption></figure><p>逻辑语义上说，一个窗口函数的计算“过程”如下：</p><ol type="1"><li>按窗口定义，将所有输入数据分区、再排序（如果需要的话）</li><li>对每一行数据，计算它的 Frame 范围</li><li>将 Frame 内的行集合输入窗口函数，计算结果填入当前行</li></ol><p>举个例子：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> dealer_id, emp_name, sales,</span><br><span class="line">       <span class="built_in">ROW_NUMBER</span>() <span class="keyword">OVER</span> (<span class="keyword">PARTITION</span> <span class="keyword">BY</span> dealer_id <span class="keyword">ORDER</span> <span class="keyword">BY</span> sales) <span class="keyword">AS</span> rank,</span><br><span class="line">       <span class="built_in">AVG</span>(sales) <span class="keyword">OVER</span> (<span class="keyword">PARTITION</span> <span class="keyword">BY</span> dealer_id) <span class="keyword">AS</span> avgsales </span><br><span class="line"><span class="keyword">FROM</span> sales</span><br></pre></td></tr></table></figure><p>上述查询中，<code>rank</code>列表示在当前经销商下，该雇员的销售排名；<code>avgsales</code>表示当前经销商下所有雇员的平均销售额。查询结果如下：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">+------------+-----------------+--------+------+---------------+</span><br><span class="line">| dealer_id  | emp_name        | sales  | rank | avgsales      |</span><br><span class="line">+------------+-----------------+--------+------+---------------+</span><br><span class="line">| 1          | Raphael Hull    | 8227   | 1    | 14356         |</span><br><span class="line">| 1          | Jack Salazar    | 9710   | 2    | 14356         |</span><br><span class="line">| 1          | Ferris Brown    | 19745  | 3    | 14356         |</span><br><span class="line">| 1          | Noel Meyer      | 19745  | 4    | 14356         |</span><br><span class="line">| 2          | Haviva Montoya  | 9308   | 1    | 13924         |</span><br><span class="line">| 2          | Beverly Lang    | 16233  | 2    | 13924         |</span><br><span class="line">| 2          | Kameko French   | 16233  | 3    | 13924         |</span><br><span class="line">| 3          | May Stout       | 9308   | 1    | 12368         |</span><br><span class="line">| 3          | Abel Kim        | 12369  | 2    | 12368         |</span><br><span class="line">| 3          | Ursa George     | 15427  | 3    | 12368         |</span><br><span class="line">+------------+-----------------+--------+------+---------------+</span><br></pre></td></tr></table></figure><blockquote><p>注：语法中每个部分都是可选的：</p><ul><li>如果不指定<code>PARTITION BY</code>，则不对数据进行分区；换句话说，所有数据看作同一个分区</li><li>如果不指定<code>ORDER BY</code>，则不对各分区做排序，通常用于那些顺序无关的窗口函数，例如<code>SUM()</code></li><li>如果不指定 Frame 子句，则默认采用以下的 Frame 定义：<ul><li>若不指定 <code>ORDER BY</code>，默认使用分区内所有行<code>RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING</code></li><li>若指定了 <code>ORDER BY</code>，默认使用分区内第一行到当前值<code>RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW</code></li></ul></li></ul></blockquote><p>最后，窗口函数可以分为以下 3 类：</p><ul><li><strong>聚合（Aggregate）</strong>：<code>AVG()</code>,<code>COUNT()</code>, <code>MIN()</code>, <code>MAX()</code>,<code>SUM()</code>...</li><li><strong>取值（Value）</strong>：<code>FIRST_VALUE()</code>,<code>LAST_VALUE()</code>, <code>LEAD()</code>,<code>LAG()</code>...</li><li><strong>排序（Ranking）</strong>：<code>RANK()</code>,<code>DENSE_RANK()</code>, <code>ROW_NUMBER()</code>,<code>NTILE()</code>...</li></ul><p>受限于篇幅，本文不去探讨各个窗口函数的含义，有兴趣的读者可以参考<ahref="https://drill.apache.org/docs/sql-window-functions-introduction/#types-of-window-functions">这篇文档</a>。</p><blockquote><p>注：Frame 定义并非所有窗口函数都适用，比如<code>ROW_NUMBER()</code>、<code>RANK()</code>、<code>LEAD()</code>等。这些函数总是应用于整个分区，而非当前 Frame。</p></blockquote><h2 id="窗口函数-vs.-聚合函数">窗口函数 VS. 聚合函数</h2><p>从<em>聚合</em>这个意义上出发，似乎窗口函数和 Group By聚合函数都能做到同样的事情。但是，它们之间的相似点也仅限于此了！这其中的关键区别在于：<strong>窗口函数仅仅只会将结果附加到当前的结果上，它不会对已有的行或列做任何修改</strong>。而Group By 的做法完全不同：对于各个 Group 它仅仅会保留一行聚合结果。</p><p>有的读者可能会问，加了窗口函数之后返回结果的顺序明显发生了变化，这不算一种修改吗？因为SQL 及关系代数都是以 multi-set为基础定义的，<strong>结果集本身并没有顺序可言</strong>，<code>ORDER BY</code>仅仅是最终呈现结果的顺序。</p><p>另一方面，从逻辑语义上说，SELECT语句的各个部分可以看作是按以下顺序“执行”的：</p><figure><img src="/images/2019/08/sql-logical-evaluate-order.png"alt="Figure 3. SQL 各部分的逻辑执行顺序" /><figcaption aria-hidden="true">Figure 3. SQL各部分的逻辑执行顺序</figcaption></figure><p>注意到窗口函数的求值仅仅位于 <code>ORDER BY</code> 之前，而位于 SQL的绝大部分之后。这也和窗口函数<strong>只附加、不修改</strong>的语义是呼应的——结果集在此时已经确定好了，再依此计算窗口函数。</p><h2 id="窗口函数的执行">窗口函数的执行</h2><p>窗口函数经典的执行方式分为<strong>排序</strong>和<strong>函数求值</strong>这2 步。</p><figure><img src="/images/2019/08/window-function-execution.png"alt="Figure 4. 一个窗口函数的执行过程，通常分为排序和求值 2 步" /><figcaption aria-hidden="true">Figure 4.一个窗口函数的执行过程，通常分为排序和求值 2 步</figcaption></figure><p>窗口定义中的 <code>PARTITION BY</code> 和 <code>ORDER BY</code>都很容易通过排序完成。例如，对于窗口<code>PARTITION BY a, b ORDER BY c, d</code>，我们可以对输入数据按 <spanclass="math inline">\((a, b, c, d)\)</span> 或 <spanclass="math inline">\((b, a, c, d)\)</span> 做排序，之后数据就排列成Figure 1 中那样了。</p><p>接下来考虑：<strong>如何处理 Frame？</strong></p><ul><li>对于整个分区的 Frame（例如<code>RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING</code>），只要对整个分区计算一次即可，没什么好说的；</li><li>对于逐渐增长的 Frame（例如<code>RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW</code>），可以用Aggregator 维护累加的状态，这也很容易实现；</li><li>对于滑动的 Frame（例如<code>ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING</code>）相对困难一些。一种经典的做法是要求Aggregator不仅支持增加还支持删除（Removable），这可能比你想的要更复杂，例如考虑下<code>MAX()</code> 的实现。</li></ul><h2 id="窗口函数的优化">窗口函数的优化</h2><p>对于窗口函数，优化器能做的优化有限。这里为了行文的完整性，仍然做一个简要的说明。</p><p>通常，我们首先会把窗口函数从 Project中抽取出来，成为一个独立的算子称之为 Window。</p><figure><img src="/images/2019/08/window-function-optimization.png"alt="Figure 5. 窗口函数的优化过程" /><figcaption aria-hidden="true">Figure 5. 窗口函数的优化过程</figcaption></figure><p>有时候，一个 SELECT语句中包含多个窗口函数，它们的窗口定义（<code>OVER</code>子句）可能相同、也可能不同。显然，对于相同的窗口，完全没必要再做一次分区和排序，我们可以将它们合并成一个Window 算子。</p><p>对于不同的窗口，最朴素地，我们可以将其全部分成不同的Window，如上图所示。实际执行时，<strong>每个 Window都需要先做一次排序</strong>，代价不小。</p><p>那是否可能利用一次排序计算多个窗口函数呢？某些情况下，这是可能的。例如本文例子中的2 个窗口函数：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">... <span class="built_in">ROW_NUMBER</span>() <span class="keyword">OVER</span> (<span class="keyword">PARTITION</span> <span class="keyword">BY</span> dealer_id <span class="keyword">ORDER</span> <span class="keyword">BY</span> sales) <span class="keyword">AS</span> rank,</span><br><span class="line">    <span class="built_in">AVG</span>(sales) <span class="keyword">OVER</span> (<span class="keyword">PARTITION</span> <span class="keyword">BY</span> dealer_id) <span class="keyword">AS</span> avgsales ...</span><br></pre></td></tr></table></figure><p>虽然这 2 个窗口并非完全一致，但是 <code>AVG(sales)</code>不关心分区内的顺序，完全可以复用 <code>ROW_NUMBER()</code> 的窗口。<ahref="http://vldb.org/pvldb/vol5/p1244_yucao_vldb2012.pdf">这篇论文</a>提供了一种启发式的算法，能尽可能利用能够复用的机会。</p><h2 id="窗口函数的并行执行">窗口函数的并行执行 *</h2><p>现代 DBMS大多支持并行执行。对于窗口函数，由于各个分区之间的计算完全不相关，我们可以很容易地将各个分区分派给不同的节点（线程），从而达到<strong>分区间并行</strong>。</p><p>但是，如果窗口函数只有一个全局分区（无 <code>PARTITION BY</code>子句），或者分区数量很少、不足以充分并行时，怎么办呢？上文中我们提到的Removable Aggregator 的技术显然无法继续使用了，它依赖于单个 Aggregator的内部状态，很难有效地并行起来。</p><p>TUM 的<ahref="http://www.vldb.org/pvldb/vol8/p1058-leis.pdf">这篇论文</a>中提出使用<strong>线段树</strong>（SegmentTree）实现高效的<strong>分区内并行</strong>。<ahref="https://en.wikipedia.org/wiki/Segment_tree">线段树</a>是一个 N叉树数据结构，每个节点包含当前节点下的部分聚合结果。</p><p>下图是一个使用二叉线段树计算 <code>SUM()</code>的例子。例如下图中第三行的 <spanclass="math inline">\(12\)</span>，表示叶节点 <spanclass="math inline">\(5+7\)</span> 的聚合结果；而它上方的 <spanclass="math inline">\(25\)</span> 表示叶节点 <spanclass="math inline">\(5+7+3+10\)</span> 的聚合结果。</p><figure><img src="/images/2019/08/segment-tree-sum.jpg"alt="Figure 6. 使用线段树计算给定范围的总和" /><figcaption aria-hidden="true">Figure 6.使用线段树计算给定范围的总和</figcaption></figure><p>假设当前 Frame 是第 2 到第 8 行，即需要计算 <spanclass="math inline">\(7+3+10+...+4\)</span>区间之和。有了线段树以后，我们可以直接利用 <spanclass="math inline">\(7+13+20\)</span>（图中红色字体）计算出聚合结果。</p><p>线段树可以在 <span class="math inline">\(O(n\log{n})\)</span>时间内构造，并能在 <span class="math inline">\(O(\log{n})\)</span>时间内查询任意区间的聚合结果。更棒的是，不仅查询可以多线程并发互不干扰，而且线段树的构造过程也能被很好地并行起来。</p><h2 id="references">References</h2><ol type="1"><li><a href="http://www.vldb.org/pvldb/vol8/p1058-leis.pdf">EfficientProcessing of Window Functions in Analytical SQL Queries - Leis, Viktor,et al. (VLDB'15)</a></li><li><ahref="http://vldb.org/pvldb/vol5/p1244_yucao_vldb2012.pdf">Optimizationof Analytic Window Functions - Cao, Yu, et al. (VLDB'12)</a></li><li><ahref="https://drill.apache.org/docs/sql-window-functions-introduction/">SQLWindow Functions Introduction - Apache Drill</a></li><li><ahref="https://modern-sql.com/blog/2019-02/postgresql-11">PostgreSQL 11Reestablishes Window Functions Leadership</a></li><li><ahref="https://www.red-gate.com/simple-talk/sql/learn-sql-server/window-functions-in-sql-server/">WindowFunctions in SQL Server</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img src=&quot;/images/2019/08/banner-cat-on-window.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;窗口函数（Window Function）&lt;/strong&gt;是 SQL2003
标准中定义的一项新特性，并在 SQL2011、SQL2016
中又加以完善，添加了若干处拓展。窗口函数不同于我们熟悉的普通函数和聚合函数，它为每行数据进行一次计算：&lt;strong&gt;输入多行（一个窗口）、返回一个值&lt;/strong&gt;。在报表等分析型查询中，窗口函数能优雅地表达某些需求，发挥不可替代的作用。&lt;/p&gt;
&lt;p&gt;本文首先介绍窗口函数的定义及基本语法，之后将介绍在 DBMS
和大数据系统中是如何实现高效计算窗口函数的，包括窗口函数的优化、执行以及并行执行。&lt;/p&gt;</summary>
    
    
    
    
    <category term="database" scheme="https://ericfu.me/tags/database/"/>
    
    <category term="optimizer" scheme="https://ericfu.me/tags/optimizer/"/>
    
    <category term="sql" scheme="https://ericfu.me/tags/sql/"/>
    
  </entry>
  
  <entry>
    <title>SQL 子查询的优化</title>
    <link href="https://ericfu.me/subquery-optimization/"/>
    <id>https://ericfu.me/subquery-optimization/</id>
    <published>2019-03-20T09:04:53.000Z</published>
    <updated>2026-02-27T03:48:16.268Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/images/2019/04/banner-subquery-optimization.png" /></p><style type="text/css">.image-captain {    margin-top: -20px;}.marked-code {    color:red;    font-weight:bold;}</style><p><strong>子查询</strong>（Subquery）的优化一直以来都是 SQL查询优化中的难点之一。关联子查询的基本执行方式类似于Nested-Loop，但是这种执行方式的效率常常低到难以忍受。当数据量稍大时，必须在优化器中对其进行<strong>去关联化</strong>（Decoorelation或 Unnesting），将其改写为类似于 Semi-Join 这样的更高效的算子。</p><p>前人已经总结出一套完整的方法论，理论上能对任意一个查询进行去关联化。本文结合SQL Server 以及 HyPer的几篇经典论文，由浅入深地讲解一下这套去关联化的理论体系。它们二者所用的方法大同小异，基本思想是想通的。</p><span id="more"></span><p>本文的例子都基于 TPC-H 的表结构，<ahref="/images/2019/04/The-TPC-H-Schema.png">这里</a>有一份供你参考。</p><h2 id="子查询简介">子查询简介</h2><p>子查询是定义在 SQL 标准中一种语法，它可以出现在 SQL的几乎任何地方，包括 SELECT, FROM, WHERE 等子句中。</p><p>总的来说，子查询可以分为<strong>关联子查询（CorrelatedSubquery）</strong>和<strong>非关联子查询（Non-correlatedSubquery）</strong>。后者非关联子查询是个很简单的问题，最简单地，只要先执行它、得到结果集并物化，再执行外层查询即可。下面是一个例子：</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> c_count, <span class="built_in">count</span>(<span class="operator">*</span>) <span class="keyword">AS</span> custdist</span><br><span class="line"><span class="keyword">FROM</span> (</span><br><span class="line">     <span class="keyword">SELECT</span> c_custkey, <span class="built_in">count</span>(o_orderkey) <span class="keyword">AS</span> c_count</span><br><span class="line">     <span class="keyword">FROM</span> CUSTOMER</span><br><span class="line">     <span class="keyword">LEFT</span> <span class="keyword">OUTER</span> <span class="keyword">JOIN</span> ORDERS <span class="keyword">ON</span> c_custkey <span class="operator">=</span> o_custkey</span><br><span class="line">     <span class="keyword">AND</span> o_comment <span class="keyword">NOT</span> <span class="keyword">LIKE</span> <span class="string">&#x27;%pending%deposits%&#x27;</span></span><br><span class="line">     <span class="keyword">GROUP</span> <span class="keyword">BY</span> c_custkey</span><br><span class="line">     ) c_orders</span><br><span class="line"><span class="keyword">GROUP</span> <span class="keyword">BY</span> c_count</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> custdist <span class="keyword">DESC</span>, c_count <span class="keyword">DESC</span>;</span><br></pre></td></tr></table></figure><p class="image-captain">▲ TPCH-13 是一个非关联子查询</p><p><strong>非关联子查询不在本文讨论范围之列</strong>，除非特别声明，以下我们说的子查询都是指关联子查询。</p><p>关联子查询的特别之处在于，其本身是不完整的：<strong>它的闭包中包含一些外层查询提供的参数</strong>。显然，只有知道这些参数才能运行该查询，所以我们不能像对待非关联子查询那样。</p><p>根据产生的数据来分类，子查询可以分成以下几种：</p><p><strong>标量（Scalar-valued）</strong>子查询：输出一个只有一行一列的结果表，这个标量值就是它的结果。如果结果为空（0行），则输出一个 NULL。但是注意，超过 1行结果是不被允许的，会产生一个运行时异常。</p><p>标量子查询可以出现在任意包含标量的地方，例如 SELECT、WHERE等子句里。下面是一个例子：</p><pre><code>SELECT c_custkeyFROM CUSTOMERWHERE 1000000 < (    SELECT SUM(o_totalprice)    FROM ORDERS    WHERE o_custkey = <span class="marked-code">c_custkey</span>)</code></pre><p class="image-captain">▲ Query 1: 一个出现在 WHERE 子句中的标量子查询，关联参数用红色字体标明了</p><pre><code>SELECT o_orderkey, (    SELECT c_name    FROM CUSTOMER    WHERE c_custkey = <span class="marked-code">o_custkey</span>) AS c_name FROM ORDERS</code></pre><p class="image-captain">▲ Query 2: 一个出现在 SELECT 子句中的标量子查询</p><p><strong>存在性检测（Existential Test）</strong>子查询：特指 EXISTS的子查询，返回一个布尔值。如果出现在 WHERE 中，这就是我们熟悉的Semi-Join。当然，它可能出现在任何可以放布尔值的地方。</p><pre><code>SELECT c_custkeyFROM CUSTOMERWHERE c_nationkey = 86 AND EXISTS(    SELECT * FROM ORDERS    WHERE o_custkey = <span class="marked-code">c_custkey</span>)</code></pre><p class="image-captain">▲ Query 3: 一个 Semi-Join 的例子</p><p><strong>集合比较（Quantified Comparision）</strong>子查询：特指IN、SOME、ANY的查询，返回一个布尔值，常用的形式有：<code>x = SOME(Q)</code> （等价于<code>x IN Q</code>）或 <code>X &lt;&gt; ALL(Q)</code>（等价于<code>x NOT IN Q</code>）。同上，它可能出现在任何可以放布尔值的地方。</p><pre><code>SELECT c_nameFROM CUSTOMERWHERE c_nationkey <> ALL (SELECT s_nationkey FROM SUPPLIER)</code></pre><p class="image-captain">▲ Query 4: 一个集合比较的非关联子查询</p><h2 id="原始执行计划">原始执行计划</h2><p>我们以 Query 1为例，直观地感受一下，为什么说关联子查询的去关联化是十分必要的。</p><p>下面是 Query 1 的未经去关联化的原始查询计划（RelationTree）。与其他查询计划不一样的是，我们特地画出了表达式树（ExpressionTree），可以清晰地看到：子查询是实际上是挂在 Filter的条件表达式下面的。</p><p><imgsrc="/images/2019/04/subquery-optimization-query-1-relnode-1.png" /></p><p>实际执行时，查询计划执行器（Executor）在执行到 Filter时，调用表达式执行器（Evaluator）；由于这个条件表达式中包含一个标量子查询，所以Evaluator 又会调用 Executor 计算标量子查询的结果。</p><p><strong>这种 Executor - Evaluator - Executor的交替调用十分低效</strong>！考虑到 Filter上可能会有上百万行数据经过，如果为每行数据都执行一次子查询，那查询执行的总时长显然是不可接受的。</p><h2 id="apply-算子">Apply 算子</h2><p>上文说到的 Relation - Expression - Relation这种交替引用不仅执行性能堪忧，而且，对于优化器也是个麻烦的存在——我们的优化规则都是在匹配并且对Relation 进行变换，而这里的子查询却藏在 Expression里，令人无从下手。</p><p>为此，在开始去关联化之前，我们引入 Apply 算子：</p><p><strong>Apply 算子</strong>（也称作 CorrelatedJoin）接收两个关系树的输入，与一般 Join 不同的是，Apply 的 Inner输入（图中是右子树）是一个带有参数的关系树。</p><p>Apply 的含义用下图右半部分的集合表达式定义：对于 Outer Relation <spanclass="math inline">\(R\)</span> 中的每一条数据 <spanclass="math inline">\(r\)</span>，计算 Inner Relation <spanclass="math inline">\(E(r)\)</span>，输出它们连接（Join）起来的结果<span class="math inline">\({r} \otimes E(r)\)</span>。Apply的结果是所有这些结果的并集（本文中说的并集指的是 Bag语义下的并集，也就是 UNION ALL）。</p><p><imgsrc="/images/2019/04/subquery-optimization-query-apply-operator.png" /></p><blockquote><p>Apply 是 SQL Server 的命名，它在 HyPer 的文章中叫做 CorrelatedJoin。它们是完全等价的。考虑到 SQL Server的文章发表更早、影响更广，本文中都沿用它的命名。</p></blockquote><p>根据连接方式（<spanclass="math inline">\(\otimes\)</span>）的不同，Apply 又有 4种形式：</p><ul><li><strong>Cross Apply</strong> <spanclass="math inline">\(A^{\times}\)</span>：这是最基本的形式，行为刚刚我们已经描述过了；</li><li><strong>Left Outer Apply</strong> <spanclass="math inline">\(A^{LOJ}\)</span>：即使 <spanclass="math inline">\(E(r)\)</span> 为空，也生成一个 <spanclass="math inline">\(r \circ \{NULLs\}\)</span>。</li><li><strong>Semi Apply</strong> <spanclass="math inline">\(A^{\exists}\)</span>：如果 <spanclass="math inline">\(E(r)\)</span> 不为空则返回 <spanclass="math inline">\(r\)</span>，否则丢弃；</li><li><strong>Anti-Semi Apply</strong> <spanclass="math inline">\(A^{\nexists}\)</span>：如果 <spanclass="math inline">\(E(r)\)</span> 为空则返回 <spanclass="math inline">\(r\)</span>，否则丢弃；</li></ul><p>我们用刚刚定义的 Apply 算子来改写之前的例子：把子查询从 Expression内部提取出来。结果如下：</p><p><imgsrc="/images/2019/04/subquery-optimization-subquery-using-apply.png" /></p><p>上面的例子中，我们可以肯定 Scalar Agg子查询<strong>有且只有</strong>一行结果，所以可以直接转成Apply。但某些情况下，可能无法肯定子查询一定能返回 0 或 1行结果（例如，想象一下 Query 2 如果 c_custkey 不是唯一的），为了确保 SQL语义，还要在 Apply 右边加一个 <spanclass="math inline">\(\textit{Max1Row}\)</span> 算子：</p><p><span class="math display">\[\textit{Max1Row}(E)=\begin{cases}    \textit{Null}, &amp; \text{if}\ |E|=0 \\    E, &amp; \text{if}\ |E|=1 \\    \textit{error}, &amp; \text{otherwise}\end{cases}\]</span></p><p>理论上，我们<strong>可以将所有的子查询转换成 Apply算子</strong>，一个通用的方法如下：</p><ol type="1"><li>如果某个算子的表达式中出现了子查询，我们就把这个子查询提取到该算子下面（留下一个子查询的结果变量），构成一个<span class="math inline">\(A^{LOJ}\)</span>算子。如果不止一个子查询，则会产生多个 <spanclass="math inline">\(A^{LOJ}\)</span>。必要的时候加上 <spanclass="math inline">\(\textit{Max1Row}\)</span> 算子。</li><li>然后应用其他一些规则，将 <spanclass="math inline">\(A^{LOJ}\)</span> 转换成 <spanclass="math inline">\(A^{\times}\)</span>、<spanclass="math inline">\(A^{\exists}\)</span>、<spanclass="math inline">\(A^{\nexists}\)</span>。例如上面例子中的子查询结果<span class="math inline">\(X\)</span> 被用作 Filter 的过滤条件，NULL值会被过滤掉，因此可以安全地转换成 <spanclass="math inline">\(A^{\times}\)</span>。</li></ol><p>下面这个例子中，Filter 条件表达式中包含 <spanclass="math inline">\(Q_1\)</span>、<spanclass="math inline">\(Q_2\)</span> 两个子查询。转换之后分别生成了对应的Apply 算子。其中 <span class="math inline">\(Q_2\)</span>无法确定只会生成恰好一条记录，所以还加上了 <spanclass="math inline">\(\textit{Max1Row}\)</span> 算子。</p><p><img src="/images/2019/04/convert-subquery-to-apply.png" /></p><h2 id="基本消除规则">基本消除规则</h2><p>第一组规则是最基本的规则，等式中的 <spanclass="math inline">\(\otimes\)</span> 说明它不限制连接类型，可以是<span class="math inline">\(\{ \times, LOJ, \exists, \nexists\}\)</span> 中的任意一个。</p><p><img src="/images/2019/04/rule-set-1.png" /></p><p>这两条规则是非常显而易见的，翻译成大白话就是：如果 Apply的右边不包含来自左边的参数，那它就和直接 Join 是等价的。</p><p>下面是对 Query 3 应用规则 (2) 的例子：</p><p><imgsrc="/images/2019/04/subquery-decorrlation-rule-set-1-example.png" /></p><h2 id="project-和-filter-的去关联化">Project 和 Filter 的去关联化</h2><p>第二组规则描述了如何处理子查询中的 Project 和Filter，其思想可以用一句话来描述：<strong>尽可能把 Apply 往下推、把Apply 下面的算子向上提</strong>。</p><p><img src="/images/2019/04/rule-set-2-1.png" /></p><p><imgsrc="/images/2019/04/subquery-decorration-project-filter-rules%20-1-.png" /></p><p>注意这些规则仅处理 Cross Apply 这一种情况。其他 3 种 Apply的变体，理论上都可以转换成 CrossApply，暂时我们只要知道这个事实就可以了。</p><p>你可能会问：通常我们都是尽可能把 Filter、Project往下推，为什么这里会反其道而行呢？关键在于：Filter、Project里面原本包含了带有关联变量的表达式，但是把它提到 Apply上方之后，<strong>关联变量就变成普通变量了！</strong>这正是我们想要的。</p><p>我们稍后就会看到这样做的巨大收益：<strong>当 Apply被推最下面时，就可以应用第一组规则，直接把 Apply 变成Join</strong>，也就完成了子查询去关联化的优化过程。</p><p>下面是对 Query 2 应用规则 (3) 的例子。之后再应用规则(1)，就完成了去关联化过程。</p><p><imgsrc="/images/2019/04/subquery-decorrlation-rule-set-2-example.png" /></p><h2 id="aggregate-的去关联化">Aggregate 的去关联化</h2><p>第三组规则描述如何处理子查询中的 Aggregate（即 GroupBy）。和上一组一样，我们的指导思想仍然是：<strong>尽可能把 Apply往下推、把 Apply 下面的算子向上提</strong>。</p><p>下面等式中，<span class="math inline">\(G_{A,F}\)</span> 表示带有Group By 分组的聚合（Group Agg），其中 <spanclass="math inline">\(A\)</span> 表示分组的列，<spanclass="math inline">\(F\)</span> 表示聚合函数的列；<spanclass="math inline">\(G_{F}^{1}\)</span> 表示不带有分组的聚合（ScalarAgg）。</p><p><img src="/images/2019/04/rule-set-3.png" /></p><p>这一组规则不像之前那么简单直白，我们先看一个例子找找感觉。下面是对Query 1 运用规则 (9) 的结果：</p><p><imgsrc="/images/2019/04/subquery-decorrelation-agg-example.png" /></p><p>规则 (9) 在下推 Apply 的同时，还将 ScalarAgg 变成了GroupAgg，其中，<strong>分组列就是 R 的 key</strong>，在这里也就是CUSTOMER 的主键 c_custkey。</p><blockquote><p>如果 R 没有主键或唯一键，理论上，我们可以在 Scan 时生成一个。</p></blockquote><p>为什么变换前后是等价的呢？变换前，我们是给每个 R 的行做了一次ScalarAgg聚合计算，然后再把聚合的结果合并起来；变换后，我们先是将所有要聚合的数据准备好（这被称为augment），然后使用 GroupAgg 一次性地做完所有聚合。</p><p>这也解释了为什么我们要用 <span class="math inline">\(A^{LOJ}\)</span>而不是原本的 <span class="math inline">\(A^{\times}\)</span> ：原来的ScalarAgg 上，即使输入是空集，也会输出一个 NULL。如果我们这里用 <spanclass="math inline">\(A^{LOJ}\)</span>，恰好也会得到一样的行为（＊）；反之，如果用<span class="math inline">\(A^{\times}\)</span> 就有问题了——没有对应ORDERS 的客户在结果中消失了！</p><p>规则 (8) 处理的是GroupAgg，道理也是一样的，只不过原来的分组列也要留着。</p><p><strong>ScalarAgg 转换中的细节＊</strong></p><p>细心的读者可能注意到，规则 (9) 右边产生的聚合函数是 <spanclass="math inline">\(F&#39;\)</span>，多了一个单引号，这暗示它和原来的聚合函数<span class="math inline">\(F\)</span>可能是有些不同的。那什么情况下会不同呢？这个话题比较深入了，不感兴趣的同学可以跳过。</p><p>首先我们思考下，GroupAgg 以及 <spanclass="math inline">\(A^{LOJ}\)</span>的行为真的和变换前一模一样吗？其实不然。举个反例：</p><pre><code class="language-text">SELECT c_custkey, (    SELECT <span class="marked-code">COUNT(*)</span>    FROM ORDERS    WHERE o_custkey = c_custkey) AS count_ordersFROM CUSTOMER</code></pre><p>设想一下：客户 Eric 没有任何订单，那么这个查询应当返回一个<code>['Eric', 0]</code> 的行。但是，当我们应用了规则 (9)做变换之后，却得到了一个 <code>['Eric', 1]</code> 的值，结果出错了！</p><p>为何会这样呢？变换之后，我们是先用 LeftOuterJoin准备好中间数据（augment），然后用 GroupAgg 做聚合。LeftOuterJoin 为客户Eric 生成了一个 <code>['Eric', NULL, NULL, ...]</code> 的行；之后的GroupAgg 中，聚合函数 <code>COUNT(*)</code> 认为 Eric 这个分组有 1行数据，所以输出了 <code>['Eric', 1]</code>。</p><p>下面是个更复杂的例子，也有类似的问题：</p><pre><code class="language-text">SELECT c_custkeyFROM CUSTOMERWHERE 200000 < (    SELECT <span class="marked-code">MAX(IF_NULL(o_totalprice, 42))</span> -- o_totalprice may be NULL    FROM ORDERS    WHERE o_custkey = c_custkey)</code></pre><p>作为总结，问题的根源在于：<span class="math inline">\(F(\emptyset)\neq F(\{\textit{NULL}\})\)</span>，这样的聚合函数 <spanclass="math inline">\(F\)</span> 都有这个问题。</p><p><strong>变换后的 GroupAgg 无法区分它看到的 NULL 数据到底是 OuterJoin产生的，还是原本就存在的</strong>，有时候，这两种情形在变换前的ScalarAgg 中会产生不同的结果。</p><p>幸运的是，SQL 标准中定义的聚合函数 <spanclass="math inline">\(F(col)\)</span> 都是 OK 的——它们都满足 <spanclass="math inline">\(F(\emptyset) =F(\{\textit{NULL}\})\)</span>，我们只要对 <spanclass="math inline">\(F\)</span> 稍加变换就能解决这个问题。</p><ul><li>对于例子一，将 <code>COUNT(*)</code>替换成一个对非空列（例如主键）的 Count即可，例如：<code>COUNT(o_orderkey)</code>；</li><li>对于例子二，需要把 <code>MIN(IF_NULL(o_totalprice, 42))</code>分成两步来做：定义中间变量 <code>X</code>，先用 Project 计算<code>X = IF_NULL(o_totalprice, 42)</code>，再对聚合函数<code>MIN(X)</code> 进行去关联化即可。</li></ul><h2 id="集合运算的去关联化">集合运算的去关联化</h2><p>最后一组优化规则用来处理带有 Union（对应<code>UNION ALL</code>）、Subtract（对应 <code>EXCEPT ALL</code>） 和Inner Join 算子的子查询。再强调一遍，我们的指导思想是：<strong>尽可能把Apply 往下推、把 Apply 下面的算子向上提</strong>。</p><p>下面的等式中，<span class="math inline">\(\times\)</span> 表示 CrossJoin，<span class="math inline">\(\bowtie_{R.key}\)</span> 表示按照<span class="math inline">\(R\)</span> 的 Key 做自然连接：<spanclass="math inline">\(r \circ e_1 \circ e_2\)</span>。和之前一样，我们假设 <span class="math inline">\(R\)</span>存在主键或唯一键，如果没有也可以在 Scan 的时候加上一个。</p><p><img src="/images/2019/04/rule-set-4.png" /></p><p><imgsrc="/images/2019/04/subquery-decorration-set-op-rules.png" /></p><p>注意到，这些规则与之前我们见过的规则有个显著的不同：等式右边 <spanclass="math inline">\(R\)</span>出现了两次。这样一来，要么我们把这颗子树拷贝一份，要么做成一个 DAG的执行计划，总之会麻烦许多。</p><p>事实上，这一组规则很少能派上用场。在 [2] 中提到，在 TPC-H 的 Schema下甚至很难写出一个带有 Union All 的、有意义的子查询。</p><h2 id="其他">其他</h2><p>有几个我认为比较重要的点，用 FAQ 的形式列在下面。</p><p><strong>► 是否任意的关联子查询都可以被去关联化？</strong></p><p>可以说是这样的，在加上少量限定之后，理论上可以证明：任意的关联子查询都可以被去关联化。</p><p>证明方法在 [1]、[3] 中都有提及。以 [1] 中为例，思路大致是：</p><ol type="1"><li>对于任意的查询关系树，首先将关联子查询从表达式中提取出来，用 Apply算子表示；</li><li>一步步去掉其中非基本关系算子，首先，通过等价变换去掉 Union 和Subtract；</li><li>进一步缩小算子集合，去掉 OuterJoin、<spanclass="math inline">\(A^{LOJ}\)</span>、<spanclass="math inline">\(A^{\exists}\)</span>、<spanclass="math inline">\(A^{\nexists}\)</span>；</li><li>最后，去掉所有的 <spanclass="math inline">\(A^{\times}\)</span>，剩下的关系树仅包含基本的一些关系算子，即完成了去关联化。</li></ol><p>另一方面，现实世界中用户使用的子查询大多是比较简单的，本文中描述的这些规则可能已经覆盖到99%的场景。虽然理论上任意子查询都可以处理，但是实际上，没有任何一个已知的DBMS 实现了所有这些变换规则。</p><p><strong>► HyPer 和 SQL Server 的做法有什么异同？</strong></p><p>HyPer 的理论覆盖了更多的去关联化场景。例如各种 Join 等算子，[3]中都给出了相应的等价变换规则（作为例子，下图是对 Outer Join的变换）。而在 [1]中仅仅是证明了这些情况都可以被规约到可处理的情形（实际上嘛，可想而知，一定是没有处理的）。</p><p><img src="/images/2019/04/hyper-outer-join-example.png" /></p><p>另一个细节是，HyPer 中还存在这样一条规则：</p><p><img src="/images/2019/04/hyper-general-unnesting.png" /></p><p>其中，<span class="math inline">\(D=\Pi_{F(T_2)\cap A(T_1)}(T_1)\)</span>，表示对 <span class="math inline">\(T_1\)</span> 的Distinct Project 结果（所谓的 <em>MagicSet</em>）。直接看等式比较晦涩，看下面的例子就容易理解了：</p><p><img src="/images/2019/04/hyper-general-unnesting-example.png" /></p><p>图中，在做 Apply 之前，先拿到需要 Apply 的列的 Distinct值集合，拿这些值做 Apply，之后再用普通的 Join 把 Apply的结果连接上去。</p><p>这样做的好处是：如果被 Apply 的数据存在大量重复，则 Distinct Project之后需要 Apply 的行数大大减少。这样一来，即使之后 Apply没有被优化掉，迭代执行的代价也会减小不少。</p><p><strong>► 本文说的这些变换规则，应该用在 RBO 还是 CBO中呢？换句话说，去关联化后之后的执行计划一定比去关联化之前更好吗？</strong></p><p>答案是，不一定。</p><p>直观的看，如果 Apply 的左边数据量比较少（例如，仅有 1条数据），那直接带入 Apply的右边计算反而是更好的方式。另一种情况是，右边有合适的索引，这种情况下，多次Apply 的代价也并非不可接受。</p><p>所以把这些规则放进一个 CBO的优化器是更合适的，优化器根据代价估计选出最优的计划来。甚至，在某些情况下，我们还会自右向左地运用这些等式，做“加关联化”。</p><p>这和用 HashJoin 还是 NestedLoopJoin是同样的道理。事实上，NestedLoopJoin 就是 Apply的一个特例。如果存在合适的索引，NestedLoopJoin 效率高于 HashJoin是很常见的事情。</p><h2 id="references">References</h2><ol type="1"><li><ahref="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-2000-31.pdf">ParameterizedQueries and Nesting Equivalencies - C Galindo-Legaria</a></li><li><ahref="http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.563.8492&amp;rep=rep1&amp;type=pdf">OrthogonalOptimization of Subqueries and Aggregation - C Galindo-Legaria, MJoshi</a></li><li><ahref="https://dl.gi.de/bitstream/handle/20.500.12116/2418/383.pdf?sequence=1">UnnestingArbitrary Queries - T Neumann, A Kemper</a></li><li><ahref="https://dl.gi.de/bitstream/handle/20.500.12116/657/paper04.pdf?sequence=1&amp;isAllowed=y">TheComplete Story of Joins (inHyPer) - T Neumann, V Leis, A Kemper</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img src=&quot;/images/2019/04/banner-subquery-optimization.png&quot; /&gt;&lt;/p&gt;
&lt;style type=&quot;text/css&quot;&gt;
.image-captain {
    margin-top: -20px;
}
.marked-code {
    color:red;
    font-weight:bold;
}
&lt;/style&gt;
&lt;p&gt;&lt;strong&gt;子查询&lt;/strong&gt;（Subquery）的优化一直以来都是 SQL
查询优化中的难点之一。关联子查询的基本执行方式类似于
Nested-Loop，但是这种执行方式的效率常常低到难以忍受。当数据量稍大时，必须在优化器中对其进行&lt;strong&gt;去关联化&lt;/strong&gt;（Decoorelation
或 Unnesting），将其改写为类似于 Semi-Join 这样的更高效的算子。&lt;/p&gt;
&lt;p&gt;前人已经总结出一套完整的方法论，理论上能对任意一个查询进行去关联化。本文结合
SQL Server 以及 HyPer
的几篇经典论文，由浅入深地讲解一下这套去关联化的理论体系。它们二者所用的方法大同小异，基本思想是想通的。&lt;/p&gt;</summary>
    
    
    
    
    <category term="database" scheme="https://ericfu.me/tags/database/"/>
    
    <category term="optimizer" scheme="https://ericfu.me/tags/optimizer/"/>
    
    <category term="sql" scheme="https://ericfu.me/tags/sql/"/>
    
  </entry>
  
  <entry>
    <title>JIT 代码生成技术（二）查询编译执行</title>
    <link href="https://ericfu.me/code-gen-of-query/"/>
    <id>https://ericfu.me/code-gen-of-query/</id>
    <published>2019-02-28T12:14:27.000Z</published>
    <updated>2026-02-27T03:48:16.265Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/images/2019/03/code-gen-2-banner.jpg" /></p><p><strong>代码生成（CodeGeneration）</strong>技术广泛应用于现代的数据系统中。代码生成是将用户输入的表达式、查询、存储过程等现场编译成二进制代码再执行，相比解释执行的方式，运行效率要高得多。</p><p>上一篇文章 <ahref="/code-gen-of-expression">代码生成技术（一）表达式编译</a>中提到，虽然表面上都叫“代码生成”，但是实际可以分出几种粒度的实现方式，比如表达式的代码生成、查询的代码生成、存储过程的代码生成等。今天我们要讲的是<strong>查询级别</strong>的代码生成，有时也称作<strong>算子间</strong>（intra-operator）级别，这也是主流数据系统所用的编译执行方式。</p><span id="more"></span><p>本文主要参考了 HyPer 团队发表在 VLDB'11 的文章 <ahref="https://www.vldb.org/pvldb/vol4/p539-neumann.pdf">EfficientlyCompiling Efficient Query Plans for Modern Hardware</a>。</p><h2 id="volcano-经典执行模型">Volcano 经典执行模型</h2><blockquote><p>为什么要用编译执行？编译执行有哪几种实现？这些问题的答案都写在前一篇文章里，还有困惑的同学务必先看完<a href="/code-gen-of-expression">前一篇文章</a> 再回来。</p></blockquote><p>今天说的主角是查询（Query）的编译执行，在讲它之前，看看经典 Volcano模型是怎么做的。Volcano模型十分简单（这也是它流行的主要原因）：<strong>每个算子需要实现一个<code>next()</code> 接口，意为返回下一个 Tuple</strong>。</p><p><img src="/images/2019/03/volcano-execution-example.png" /></p><p><strong>Query 1</strong> 是一个很简单的查询，Project 会调用 Filter 的<code>next()</code> 获得数据，Filter 的 <code>next()</code> 又会调用TableScan 的 <code>next()</code>，TableScan读出表中的一行数据并返回。如此往复，直到数据全部处理完。</p><p><strong>Query 2</strong> 复杂一些，它包含一个 HashJoin。我们知道HashJoin 的两个子节点是不对称的，一边称为 build-side，另一边称为 probe或 stream-side。执行时，必须等待 build-side处理完<strong>全部</strong>数据、构建出哈希表之后，才能运行stream-side。</p><p>因为这个原因，执行的过程其实被分成了两个阶段（图中浅灰色的背景）。在Volcano 模型中，这也很容易实现，我们试着写一下 HashJoin 的伪代码：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">Row HashJoin::next() &#123;</span><br><span class="line">    <span class="comment">// Stage 1: Build Hash Table (HT)</span></span><br><span class="line">    <span class="keyword">if</span> (HT is not built yet) <span class="comment">// 注意：Build 仅在第一次调用 next() 时发生</span></span><br><span class="line">        <span class="keyword">while</span> ((r = left.next()) != END)</span><br><span class="line">            ht.put(buildKey(r), buildValue(r))</span><br><span class="line">    <span class="comment">// Stage 2: Probe tuples one by one</span></span><br><span class="line">    <span class="keyword">while</span> (r = right.next())</span><br><span class="line">        <span class="keyword">if</span> (HT contains r)</span><br><span class="line">            output joined row;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这个构建哈希表的过程，我们称为<strong>物化（Materialize）</strong>，意味着Tuple 不能继续往上传递，而是被暂存到某个 buffer里。而大多数时候，例如执行 Filter 等算子时，Tuple是被一路传上去的，这被称为<strong>Pipeline</strong>。显然物化的代价是比较高的，我们希望尽可能多的Pipeline 而避免物化。</p><p><strong>Query 3</strong> 中的 Aggregate 算子也有类似的情况：在Aggregate 返回第一条结果之前，我们要把下面所有的数据都聚合完成才行。</p><p><strong>我们称 HashJoin、HashAgg 这种打断 Pipeline 的算子为 PipelineBreaker</strong>，它们的存在使得执行过程被分成了不止一个阶段。值得注意的是，这里之所以分成多个阶段，是因为HashJoin 或 HashAgg 算法本身决定的，跟 Volcano 执行模型无关。</p><h2 id="volcano-的性能问题">Volcano 的性能问题</h2><p>Volcano 执行模型胜在简单易懂，在那个硬盘速度跟不上 CPU的时代，性能方面并不需要考虑太多。然而随着硬件的进步，IO很多时候已经不再是瓶颈，这时候人们就开始重新审视 Volcano模型，于是产生了两种改进思路：</p><ol type="1"><li>将 Volcano 迭代模型和向量化模型结合，每次返回一批而不是一个Tuple；</li><li>利用代码生成技术，消除迭代计算的性能损耗。</li></ol><blockquote><p>关于这两个方案哪个更优，这里有一篇<ahref="http://www.vldb.org/pvldb/vol11/p2209-kersten.pdf">非常棒的论文</a>做了很详尽的实验和分析。</p></blockquote><p>当然，作为今天的主题，我们只看第二个思路。就像表达式解析执行一样，Volcano其实是对算子树的解释执行，它也同样存在这些问题：</p><ul><li>每产生一条结果就要做很多次虚函数调用，消耗了大量的 CPU 时间；</li><li>过多的函数调用导致不能很好的利用寄存器。</li></ul><p>我们来思考一个问题，<strong>如果让你去把 Query 1写成代码来执行，会是什么样的呢</strong>？答案非常短，短的令人惊讶：</p><p><img src="/images/2019/03/code-gen-example-1.png" /></p><p>右图中用不同颜色标出了原来的算子，其中 <code>condition = true</code>是一个表达式，<strong>按照上一篇文章讲解的方法就能生成出代码</strong>，然后放到这边<code>if</code> 的条件上即可。</p><p>这两个的执行效率应该很容易看出差距吧！生成出的代码完全消除了虚函数调用，而且Tuple几乎一直在高速缓存甚至寄存器中。论文中也提到，随便找个本科生手写代码，执行性能都能甩迭代模型几条街。</p><p>再看个更复杂的例子找找感觉，以下查询（记作 <strong>Query4</strong>）混合了 Join、Aggragate 甚至子查询，之前我们说到，这些算子是PipelineBreaker，执行过程被不可避免的分成几个阶段；除此以外，我们希望其他部分尽可能地做到Pipeline 执行。</p><p><img src="/images/2019/03/code-gen-example-2.png" /></p><p>这个例子有点长，但如果你能花上两三分钟看懂它，相信你对代码生成已经有了些直觉上的理解，这对你理解掌握下一章节的内容大有帮助。</p><p>图中我用不同颜色出了 HashJoin、HashAgg三个算子各自的代码，可以看出，它们各自的代码逻辑被“分散”到了不止一处地方，甚至代码中已经很难分辨出各个算子，而是全都<strong>融合</strong>（Fusion）到一块儿了。</p><p>这就是我们想要的结果！好了，下一步终于进入了正题：如何自动生成出这样的代码呢？</p><blockquote><p>很多人有个错觉，以为数据库查询过程那么复杂，生成的代码一定也很复杂吧。其实不然，查询中复杂的部分，例如HashJoin 中哈希表实现、TableScan读取数据的实现等，<strong>这些并不用生成很多代码，仅仅只是调用现有的函数即可</strong>，比如LLVM IR 可以调用已存在的任何函数。</p><p>换个角度看，生成的代码不过是把这些算子的实现以更高效的方式串联在了一起：算子自身逻辑就像齿轮，生成的代码好比连接齿轮的链条。</p></blockquote><h2 id="hyper-的解决方案">HyPer 的解决方案</h2><p>代码生成是个纯粹的工程问题。工程问题没有什么不能解的，难就难在找到其中最漂亮的解。比如现在这个问题，为了编程的优雅，我们希望造一个可扩展的框架：<strong>不论哪个算子，只要实现某种接口</strong>（就像Volcano 模型要求实现 <code>next()</code>接口一样）<strong>，就能参与到代码生成中</strong>。</p><p>论文中给出的解法可以说是十分优雅了，模型要求所有算子实现以下两个接口函数：</p><ul><li><code>produce()</code></li><li><code>consume(attributes, node)</code></li></ul><p>代码生成的过程总是从调用根节点的 <code>produce()</code> 开始；而<code>consume()</code>类似于一个回调函数，当下层的算子完成自己的使命之后，调用上层的<code>consume()</code> 来消费刚刚产生的tuples——注意这里并不是真的消费。</p><p>用例子来说明。下面是一个伪代码版本的若干算子实现。<code>produce()</code>和 <code>consume()</code>返回的类型都是生成的代码片段，这里为了方便演示直接用字符串表示。真实世界中当然要更复杂一些。</p><p><img src="/images/2019/03/code-gen-interface-example.png" /></p><p>表中红色的字符串是生成的代码，黑色的则是 code-gen本身的代码。回忆一下：代码生成其实就是用各种手段拼出代码（字符串）来，没什么神秘的。</p><p><img src="/images/2019/03/code-gen-demo-animated.gif" /></p><p>不满足于伪代码的同学可以尝试阅读 HyPer 的 <ahref="https://www.vldb.org/pvldb/vol4/p539-neumann.pdf">论文</a>（生成LLVM IR） 或者 Spark SQL 中的 <ahref="https://github.com/apache/spark/blob/master/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/codegen/CodeGenerator.scala">CodeGenerator实现</a>（生成 Java 代码），后者的代码相对更容易理解些。</p><h2 id="思考这是唯一的解法吗">思考：这是唯一的解法吗？*</h2><p>为什么是 produce/consume呢？是否存在更简单的解呢？<strong>这里给出我的推导思路，你可以跳过这一段</strong>，毕竟每个人的脑回路是不一样的。</p><p>首先，如果只有一个接口函数，不妨叫它<code>produce()</code>，一定是不够用的。为什么这么说呢？一个函数充其量只能做出类似DFS 的效果：每个算子只会被经过一次。这对 Query 1还不是问题，但对于上文中复杂的 Query 4，HashJoin的两部分代码离得那么远，用 DFS 就很难做到了。</p><p>为了处理HashJoin，我们该增加一个怎样的函数呢？<strong>我认为它应该类似于一个回调</strong>，比如Query 4 中，当 DFS 进行到 <spanclass="math inline">\(\Join_{a=b}\)</span>时，我希望通过一种某种方式告诉下面的 <spanclass="math inline">\(\sigma_{x=7}\)</span>：当你拿到结果后，只要用我传给你的方法去消费这些Tuples（生成消费这些 Tuples 的代码）。这个方法，不妨叫做<code>consume()</code>。</p><p>顺理成章的，<code>consume()</code> 至少有个参数来传递需要消费的tuples有哪些列。另外，还需要一个参数用来指示：调用者是左孩子还是右孩子？这等价于传<code>this</code>。</p><p>以上。因此我倾向于了认为，论文提出的 produce/consume模式可能是唯一正确的方法，即使存在其他算法，我猜想也是大同小异。</p><h2 id="references">References</h2><ol type="1"><li><ahref="https://www.vldb.org/pvldb/vol4/p539-neumann.pdf">EfficientlyCompiling Efficient Query Plans for Modern Hardware - VLDB'11</a></li><li><ahref="https://issues.apache.org/jira/browse/SPARK-12795">SPARK-12795 -Whole stage codegen</a></li><li><ahref="http://www.vldb.org/pvldb/vol11/p2209-kersten.pdf">Everything YouAlways Wanted to Know About Compiled and Vectorized Queries But WereAfraid to Ask - VLDB'18</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img src=&quot;/images/2019/03/code-gen-2-banner.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;代码生成（Code
Generation）&lt;/strong&gt;技术广泛应用于现代的数据系统中。代码生成是将用户输入的表达式、查询、存储过程等现场编译成二进制代码再执行，相比解释执行的方式，运行效率要高得多。&lt;/p&gt;
&lt;p&gt;上一篇文章 &lt;a
href=&quot;/code-gen-of-expression&quot;&gt;代码生成技术（一）表达式编译&lt;/a&gt;
中提到，虽然表面上都叫“代码生成”，但是实际可以分出几种粒度的实现方式，比如表达式的代码生成、查询的代码生成、存储过程的代码生成等。今天我们要讲的是&lt;strong&gt;查询级别&lt;/strong&gt;的代码生成，有时也称作&lt;strong&gt;算子间&lt;/strong&gt;（intra-operator）级别，这也是主流数据系统所用的编译执行方式。&lt;/p&gt;</summary>
    
    
    
    
    <category term="database" scheme="https://ericfu.me/tags/database/"/>
    
    <category term="jit" scheme="https://ericfu.me/tags/jit/"/>
    
  </entry>
  
  <entry>
    <title>从 Weld 论文看执行器的优化技术</title>
    <link href="https://ericfu.me/weld-the-query-exeution-engine/"/>
    <id>https://ericfu.me/weld-the-query-exeution-engine/</id>
    <published>2019-01-31T01:50:17.000Z</published>
    <updated>2026-02-27T03:48:16.269Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/images/2019/02/weld-banner.jpg" /></p><p><a href="https://weld.rs/">Weld</a> 是一个用于数据计算分析的高性能Runtime（<em>High-performance runtime for data analyticsapplications</em>），使用 Rust编写，可以很容易地集成到各种大数据计算框架中，比如 Spark SQL、NumPy&amp; Pandas、TensorFlow 等，带来大幅的性能提升。</p><p>除了 Weld本身的贡献，论文中提到的各种用于执行阶段的优化技术也很有意思，其中的大部分都借鉴自关系型数据库或编译器。本文除了介绍Weld 之外，也是想对这些技术做个梳理。</p><p>本文主要内容来自于 Weld 发表在 <ahref="https://anilshanbhag.in/static/papers/weld_vldb18.pdf">VLDB'18的论文</a>。</p><span id="more"></span><h2 id="整体架构">整体架构</h2><p>之前说到，Weld 是一个用于数据计算的Runtime，它的上层通常是一些计算框架，例如 Spark SQL、NumPy等。用户用这些计算框架编写程序，这些框架将用户需要的计算翻译成 Weld中间表示（IR），然后 Weld对其进行一系列的优化，最后生成代码并编译运行。</p><p>做个类比，这就像 LLVM的工作方式一样：各种语言的编译前端将高级语言翻译成 LLVM IR，LLVM 再对 IR做一系列的优化，最后再编译成二进制。</p><p>虽然都是 IR，但实际上 Weld IR 和 LLVM IR 有很大不同：</p><ul><li><strong>Weld IR是声明式的</strong>：只表达计算流程，不包含具体的实现。比如下面会提到的Builder，上层不需要指定用什么方式构建数组或是哈希表等数据结构，这些是由Weld 优化器决定的；</li><li><strong>Weld IR 是 Lazy 的</strong>：只有当需要输出结果时，相应的DAG 计算才会真正开始运行。</li></ul><p><img src="/images/2019/02/weld-architecture.png" /></p><p>上图是 Weld 的整体工作过程：</p><ol type="1"><li>上层调用 Weld 的 API 输入需要计算的 IR 程序，它会被解析成 AST；</li><li>当需要执行时，相关的函数 IR 会被拼在一起，方便进行整体优化；</li><li>Weld 优化器使用一系列的启发式规则进行优化，注意结果仍然是 AST；</li><li>最后生成代码并借助 LLVM 编译成二进制。</li></ol><p>Weld 主要由两个部分组成：IR 和 Runtime，接下来我们依次进行介绍。</p><h2 id="weld-ir">Weld IR</h2><p>Weld IR 支持 <code>int</code>、<code>float</code>等基本数据类型、<code>struct</code>类型，以及两种容器类型：<code>vec</code> 和<code>dict</code>，顾名思义，分别是（变长）数组和字典。另外还支持他们的各种组合，就像JSON 那样。</p><p>和数据库的执行器不同，Weld不考虑数据拉取之类的问题。它假设输入数据都在内存中以数组形式存在，例如：<code>int[100]</code>、<code>struct&#123;int, float&#125;[100]</code>。</p><p><strong>Weld IR 的计算都通过 Builder 和 Merger 来完成</strong>，由于Merger 和 Builder 的接口是一样的，Weld论文中并没有把二者区分开来。下面我们统称为 Builder。</p><table><colgroup><col style="width: 28%" /><col style="width: 12%" /><col style="width: 22%" /><col style="width: 36%" /></colgroup><thead><tr class="header"><th>Builder</th><th>输入</th><th>输出</th><th>备注</th></tr></thead><tbody><tr class="odd"><td><code>vecbuilder[T]</code></td><td><code>T</code></td><td><code>vec[T]</code></td><td>通过 append 构建数组</td></tr><tr class="even"><td><code>dictmerger[K,V,op]</code></td><td><code>(K,V)</code></td><td><code>dict[K,V]</code></td><td>通过 put 构建字典</td></tr><tr class="odd"><td><code>merger[T,op]</code></td><td><code>T</code></td><td><code>T</code></td><td>聚合计算（例如 add）</td></tr><tr class="even"><td><code>vecmerger[T, op]</code></td><td><code>(idx,T)</code></td><td><code>vec[T]</code></td><td>把 T 填在给定位置 idx 上</td></tr><tr class="odd"><td><code>groupbuilder[K,V]</code></td><td><code>(K,V)</code></td><td><code>dict[K,vec[V]]</code></td><td>对数据分组 Group by K</td></tr></tbody></table><p>Builder 提供两个接口方法：</p><ul><li><code>merge(b, v)</code>：向 Builder <code>b</code>添加新的元素；</li><li><code>result(b)</code>：拿到 <code>b</code>的结果，注意之后不能再添加元素了。</li></ul><p>下面是使用 Builder 的例子：</p><p><img src="/images/2019/02/weld-example-of-builders.png" /></p><p>代码中还有个 <code>for</code>，它的语法是<code>for(vector, builders, (builders, index, elem) =&gt; builders)</code>，用来<strong>并行地</strong>对数据做处理——也就是往Builder 里加元素，这是 Weld 中唯一的计算方式。</p><p><code>for</code> 还可以同时处理多个Builder，这个特性在优化的时候很有用，可以避免同一个数据扫描多次。</p><p>Weld IR 还有些别的特性（比如方便编程的macro），但不是本文的重点，有兴趣的同学自己看原文吧。</p><h2 id="weld-runtime">Weld Runtime</h2><p><img src="/images/2019/02/weld-optimizing.png" /></p><p>当上层输入 IR 并发出开始计算的指令时，就轮到 Weld Runtime登场了。在代码生成之前，Weld Runtime 会对 IR做优化，优化可以分为两种：</p><ol type="1"><li>Rule-Based Optimizer (RBO)：和我们熟悉的 RBO优化类似，是基于规则匹配的优化；</li><li>Adaptive Optimizer：运行时 sample数据，然后决定用哪种算法执行，勉强可以对应 CBO。</li></ol><p>为什么不是 CBO？关系型数据库的 CBO 是需要以统计信息为基础的，但是Weld 作为一个通用的 Runtime，上层框架不一定能提供统计信息（比如NumPy）。</p><p><strong>Weld 应用规则是依次进行的，每次运行一种优化规则，称为一个pass</strong>。Pass 之间会进行剪枝，去掉无用的代码。以下我们逐条看看Weld 做了哪些优化。</p><h2 id="pipeline">Pipeline</h2><p>Pipeline 在 OLAP 系统中很常见，最经典的是 HyPer 团队提出的 <ahref="http://www.vldb.org/pvldb/vol4/p539-neumann.pdf">consume/produce代码生成机制</a>，可以在代码生成时尽可能生成 Pipeline 的代码。</p><p><img src="/images/2019/02/hyper-pipeline-codegen.png" /></p><p>为什么需要 Pipeline？设想一下使用代码生成、但是不使用 Pipeline会怎么样，那么 <span class="math inline">\(R_1\)</span> 和 <spanclass="math inline">\(\sigma_{x=7}\)</span> 就会分成独立的两步，<spanclass="math inline">\(R_1\)</span> (即TableScan）的结果被物化到内存中，再进行 <spanclass="math inline">\(\sigma_{x=7}\)</span>（Filter）。</p><p>而 Pipeline 的代码省略了中间的物化，仅仅用了一个 <code>if</code>就解决了 filter，这个代价要低得多：计算 if表达式时相关数据基本还在寄存器或 Cache 里，充分利用 DataLocality，这比去内存取数据快 1～2 个数量级。</p><p>Pipeline 优化规则会在 AST 中匹配这样的模式：<strong>A 的输出就是 B的输入</strong>，对匹配到的节点应用 pipeline优化，下面是一个简单例子：</p><p><img src="/images/2019/02/weld-pipeline-optimize.png" /></p><h2 id="horizontal-fusion">Horizontal Fusion</h2><p>Fusion 意为把两段代码融合成一段更精炼的代码，刚刚说的 Pipeline也是一种 Fusion。所谓 Horizontal Fusion是找出<strong>被重复处理的数据</strong>，然后将几次处理合在一起。</p><p>例如下面图中的 IR，<code>v0</code> 原本被 loop over了两次，如果把两次循环合成一次，能尽可能利用 DataLocality，减少一半的内存读取代价。</p><p><img src="/images/2019/02/weld-horizontal-fusion.png" /></p><p>硬要说的话，这个规则与关系代数优化中的 Project Merge规则最相似。论文中给了一个更好的例子来说明它的用处：像 Pandas这类的计算框架，由于 API 设计一次只能处理一列，必须借助 HorizontalFusion 实现一次处理多列。</p><h2 id="向量化和-adaptive-优化">向量化和 Adaptive 优化</h2><p>向量化（Vectorization）优化也不是新鲜事，很多编译器（比如LLVM）都能自动把循环编译成 SIMD 指令，JVM 甚至可以在运行时生成 SIMD代码。</p><p>SIMD全称是<strong>单条指令、多个数据</strong>，即用一条指令处理多个数据计算，比如原本计算4 个整数加法要用 4 次加法指令，用了 SIMD 之后只要 1次。没错，就这么简单！</p><p><img src="/images/2019/02/simd-illustrate.png" /></p><p>在这个 pass 中仅处理简单的、没有条件分支的 for循环，如果满足这一条件，优化器会将被循环的数据从 <code>T</code> 转换成<code>simd[T]</code>，最后 code-gen 的时候为其生成 SIMD 代码。</p><p>那对于带有条件分支的 for循环，能否进行向量化呢？答案是，<strong>可以，但是不一定有用。</strong></p><p>我们先设想一下：对于有条件分支的 for循环，它向量化之后是什么样的？SIMD 指令本身是没法处理分支的（compare这种特别简单的除外），如果一定要用 SIMD，可以<strong>假设分支条件全都为true 或 false</strong>，最后根据条件表达式的计算结果（true 或false），利用 <code>select</code> 指令选出相应的结果。</p><p>这种方式相比普通的带分支的指令，有得有失：</p><ul><li>优势：用 SIMD 指令集可以加速计算；</li><li>劣势：原本只要算一个分支，现在两个分支都要算。</li></ul><blockquote><p>注：另一个优势是，SIMD 去掉了条件跳转，不存在打断 CPU流水线的问题。但是论文中没有提到这一点，我猜测可能是它的影响因素比较小，或是作者没有找到一个合适的代价计算方式。</p></blockquote><p>论文只给出了对 <code>if(cond, merge(b, body), b)</code>这样单分支条件的代价建模，有兴趣的同学可以看原论文上的式子。这里只说一个粗糙的结论：当选择率（即进入if-body 的概率）很小时，有分支的代码更优；当选择率比较大时，SIMD代码更优。</p><p>我们之前说过，Weld假设上层无法提供统计信息，因而在这一步，<strong>由于缺乏关键的选择率信息</strong>，它只能采取一种Adaptive 的思路：<strong>同时生成有分支的代码和 SIMD代码，运行时，首先对输入数据做个 Sample估算一下选择率，再决定走哪个算法。</strong></p><p>选择率（selectivity）这个概念在数据库优化器中也很常用，比如估算 RowCount时就频繁用到了选择率估计。如果能在优化时直接拿到这个信息，想必不需要这么折腾。</p><h2 id="adaptive-hash-table">Adaptive Hash Table</h2><p>Weld 的 <code>dictbuilder</code> 和 <code>groupbuilder</code>中都需要构建哈希表，这里也有个 trade-off：是用 Partitioned Hash Table还是 Global Hash Table？</p><ul><li>Partitioned Hash Table 是将 build 过程分成两步，先各个线程本地做build，最后再 merge 成完整的结果；</li><li>Global Hash Table只有一张全局的哈希表，通过加锁等方式做了控制并发写入。</li></ul><p>一般而言，如果 Group by 的基数（Cardinality）比较小，Partitioned方式更有优势，因为并发冲突会很多；相反，如果基数很大，Global更占优势，因为无需再做多一次 merge。</p><p>Weld的做法很巧妙地实现了二者取折中：<strong>各线程先写到本地的哈希表，但如果大小超过阈值，就写到全局的哈希表</strong>。最后把本地数据再merge 进全局哈希表。这个实现被它称为 Adaptive Hash Table。</p><p><img src="/images/2019/02/weld-adaptive-hash-table.png" /></p><h2 id="misc.">Misc.</h2><p>Weld 中还有还有一些优化手段，比较简单：</p><p><strong>循环展开</strong>（LoopUnrolling）是编译器中很常见的优化，如果编译期已知 for循环的次数很小（例如，对于一个 N*3 的矩阵，第二维度长度仅为3），就将循环展开，避免条件跳转指令打断 CPU 流水线。</p><p><strong>数组预分配</strong>（Preallocation）在矩阵运算中也很有用，例如，默认<code>vecbuiler</code>的实现是自动生长的动态数组。如果预先知道数组长度，就能避免数组生长的拷贝代价。</p><h2 id="评估和总结">评估和总结</h2><p>下面是 Weld官网放出的性能评估，对于文中提到的这几个框架，的确做到了可观的性能提升。</p><p><img src="/images/2019/02/weld-evaluation.png" /></p><blockquote><p>注：这里 TensorFlow 性能是用 CPU 运行的，而非 GPU。</p></blockquote><p><strong>Weld 的最大贡献是抽象出了一个通用的执行器Runtime</strong>。这个抽象的层级要比“代码生成”中的“代码”（比如 LLVMIR）高级（high-level）不少，但又比关系代数或是线性代数低级（low-level），从而有更好的通用性。更可贵的是，WeldIR 仅仅包含 Builder 以及 for、if 这些最基本的语句，极其之简单。</p><p>上文提到的很多优化规则，不少来源于编译器或关系型数据库。例如 PipelineFusion的思想，在编译器中其实也有体现——编译器会尽可能连续的利用寄存器、避免store/load。但是 Weld IR独特的抽象层级令它能做层级更高的优化，达到和数据库的 Pipeline一样的效果。</p><h2 id="references">References</h2><ol type="1"><li><ahref="https://anilshanbhag.in/static/papers/weld_vldb18.pdf">EvaluatingEnd-to-End Optimization for Data Analytics Applications in Weld(VLDB'18)</a></li><li><ahref="http://www.vldb.org/pvldb/vol4/p539-neumann.pdf">EfficientlyCompiling Efficient Query Plans for Modern Hardware (VLDB'11)</a></li><li><a href="https://weld.rs/">Weld - Official Website</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img src=&quot;/images/2019/02/weld-banner.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://weld.rs/&quot;&gt;Weld&lt;/a&gt; 是一个用于数据计算分析的高性能
Runtime（&lt;em&gt;High-performance runtime for data analytics
applications&lt;/em&gt;），使用 Rust
编写，可以很容易地集成到各种大数据计算框架中，比如 Spark SQL、NumPy
&amp;amp; Pandas、TensorFlow 等，带来大幅的性能提升。&lt;/p&gt;
&lt;p&gt;除了 Weld
本身的贡献，论文中提到的各种用于执行阶段的优化技术也很有意思，其中的大部分都借鉴自关系型数据库或编译器。本文除了介绍
Weld 之外，也是想对这些技术做个梳理。&lt;/p&gt;
&lt;p&gt;本文主要内容来自于 Weld 发表在 &lt;a
href=&quot;https://anilshanbhag.in/static/papers/weld_vldb18.pdf&quot;&gt;VLDB&#39;18
的论文&lt;/a&gt;。&lt;/p&gt;</summary>
    
    
    
    
    <category term="database" scheme="https://ericfu.me/tags/database/"/>
    
    <category term="sql" scheme="https://ericfu.me/tags/sql/"/>
    
  </entry>
  
  <entry>
    <title>CompletableFuture 也没有那么废柴嘛！</title>
    <link href="https://ericfu.me/completable-future-not-so-bad/"/>
    <id>https://ericfu.me/completable-future-not-so-bad/</id>
    <published>2019-01-08T16:09:05.000Z</published>
    <updated>2026-02-27T03:48:16.265Z</updated>
    
    <content type="html"><![CDATA[<p><a href="/several-ways-to-aync">上篇文章</a>中提到，Java 里把 Promise叫作 CompletableFuture，相比那个只能用于线程同步的Future，它新增了很多方法用于串联异步事件，比如常用的<code>thenApply</code>、<code>thenCompose</code>、<code>thenAccept</code>等。</p><p>如果不引入任何第三方库，CompletableFuture 仍是目前 Java上最好的异步编程方式。之前一直觉得这个东西难用，直到我想明白一件事，证明了CompletableFuture虽然麻烦了点但是能做任何事情，然后用它的时候心里就没那么膈应了。</p><p>本文会以一个例子来讲解：<strong>如何把任意函数转换成异步调用风格</strong>。其实不一定要用CompletableFuture，任何语言和框架都是适用的。</p><span id="more"></span><p>这篇文章不会涉及 CompletableFuture 的用法，你可以参考 Javadoc 或者<ahref="https://colobu.com/2016/02/29/Java-CompletableFuture/">这篇文章</a>。</p><h2 id="证明-completablefuture-是足够的">证明 CompletableFuture是足够的</h2><p>首先来（极不严谨地）说明一件事情，<strong>为什么 CompletableFuture是足够用的</strong>，换句话说，证明 <strong>CompletableFuture能表达一切计算流程</strong>。</p><p>如果你有一些函数式编程的基础，比如会一点Haskell，这就是一句话的事情：CompletableFuture 其实是一个 Monad ——因为它的 <code>thenCompose</code> 实现了 Monad 的 <code>&gt;&gt;=</code>操作符。既然 Monad 能用来表示任何计算过程，CompletableFuture当然也能。</p><figure class="highlight haskell"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="class"><span class="keyword">class</span> <span class="type">Applicative</span> m =&gt; <span class="type">Monad</span> (<span class="title">m</span> :: * -&gt; *) <span class="keyword">where</span></span></span><br><span class="line">  (&gt;&gt;=) :: m a -&gt; (a -&gt; m b) -&gt; m b  <span class="comment">-- thenCompose 实现了它 </span></span><br><span class="line">  (&gt;&gt;) :: m a -&gt; m b -&gt; m b</span><br><span class="line">  return :: a -&gt; m a</span><br><span class="line">  fail :: <span class="type">String</span> -&gt; m a</span><br><span class="line">  <span class="meta">&#123;-# MINIMAL (&gt;&gt;=) #-&#125;</span> <span class="comment">-- 这是在说：只要实现 (&gt;&gt;=) 就够了</span></span><br></pre></td></tr></table></figure><p>其实想想也很明白，Monad 表示一个带 context的计算过程，比如可能抛异常之类的（纯函数是不会抛异常的）。CompletableFuture也一样，他包裹一串计算过程并且处理异常。</p><p>如果看不懂上面的也没关系，我们用另一种方式再说明一下：</p><p>任何程序的流程控制都可以用 <code>if</code> 和 <code>goto</code>来组合起来。无论是 <code>for</code> 还是 <code>while</code>循环，desurge 之后不过就是 <code>if</code> 和 <code>goto</code>的组合。<strong>通过 <code>thenCompose</code> 就可以表达 <code>if</code>和 <code>goto</code></strong>：</p><blockquote><p>这里说的不够严谨，其实 if 也是 surge，最终会变成条件跳转指令。</p></blockquote><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">cf.thenCompose(v -&gt; &#123;</span><br><span class="line">    <span class="keyword">if</span> (v &lt; <span class="number">100</span>) &#123;</span><br><span class="line">        <span class="keyword">return</span> doStage1(); <span class="comment">// doStage1() 返回一个 CompletableFuture，决定下一步做什么，相当于 goto</span></span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> doStage2(); <span class="comment">// 同上</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>你看这个例子，<code>if</code> 和 <code>goto</code>都有了，所以无论程序的控制流多复杂，我们都能组合出来。怎么组合？别急，下面我们就来讲这个。</p><h2 id="completablefuture-in-practice">CompletableFuture inPractice</h2><p>我们从一个普通的函数开始。考虑到复杂性和完整性，我们用 <em>Merge 2Sorted Streams</em> 作为演示，如果你不清楚这个是干嘛的，可以先做一下<ahref="https://leetcode.com/problems/merge-sorted-array/">这道算法题</a>。</p><p>下面是最普通的实现，输入两个数组，输出一个数组：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">Stream <span class="title function_">merge</span><span class="params">(Stream inputA, Stream inputB)</span> &#123;</span><br><span class="line">    List&lt;Integer&gt; results = <span class="keyword">new</span> <span class="title class_">ArrayList</span>&lt;&gt;();</span><br><span class="line">    <span class="type">Integer</span> <span class="variable">headA</span> <span class="operator">=</span> inputA.next();</span><br><span class="line">    <span class="type">Integer</span> <span class="variable">headB</span> <span class="operator">=</span> inputB.next();</span><br><span class="line">    <span class="keyword">while</span> (headA != <span class="literal">null</span> || headB != <span class="literal">null</span>) &#123;</span><br><span class="line">        <span class="keyword">if</span> (headA == <span class="literal">null</span> || headB != <span class="literal">null</span> &amp;&amp; headA &gt; headB) &#123;</span><br><span class="line">            results.add(headB);</span><br><span class="line">            headB = inputB.next();</span><br><span class="line">        &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">            results.add(headA);</span><br><span class="line">            headA = inputA.next();</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">Stream</span>(results);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">Stream</span> &#123;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> Queue&lt;Integer&gt; numbers;</span><br><span class="line">    <span class="keyword">public</span> <span class="title function_">Stream</span><span class="params">(List&lt;Integer&gt; numbers)</span> &#123; <span class="built_in">this</span>.numbers = <span class="keyword">new</span> <span class="title class_">LinkedList</span>&lt;&gt;(numbers); &#125;</span><br><span class="line">    <span class="keyword">public</span> Integer <span class="title function_">next</span><span class="params">()</span> &#123; <span class="keyword">return</span> numbers.poll(); &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这个实现有什么问题呢？作为算法足够 OK。但是从工程意义上说，如果输入的Stream 很大，包含 million 级的元素，那更好的方式是把 Stream的输入输出作为 Iterator，只在 <code>next()</code>的时候计算下一个需要的元素。这样内存占用是常数级的，完全不用担心数据量过大呢！</p><p>为了看清一步一步的变化过程，我们先假装 Java 有 <ahref="https://wiki.python.org/moin/Generators">Generator语法</a>。标记为 Generator 的函数不再是一个函数，而是类似一个Iterator。一旦调用 <code>next()</code>，“函数”代码运行到<code>yield</code>返回一个值，然后函数似乎<strong>停在</strong>了这里。下次<code>next()</code>，“函数”又接着刚刚的地方运行。</p><p>如果有 Generator 的话，函数应该长下面这样，注意<code>[yield]</code>:</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">Stream <span class="title function_">merge</span><span class="params">(Stream inputA, Stream inputB)</span> &#123;</span><br><span class="line">    <span class="type">Integer</span> <span class="variable">headA</span> <span class="operator">=</span> inputA.next();</span><br><span class="line">    <span class="type">Integer</span> <span class="variable">headB</span> <span class="operator">=</span> inputB.next();</span><br><span class="line">    <span class="keyword">while</span> (headA != <span class="literal">null</span> || headB != <span class="literal">null</span>) &#123;</span><br><span class="line">        <span class="keyword">if</span> (headA == <span class="literal">null</span> || headB != <span class="literal">null</span> &amp;&amp; headA &gt; headB) &#123;</span><br><span class="line">            [<span class="keyword">yield</span>] headB;</span><br><span class="line">            headB = inputB.next();</span><br><span class="line">        &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">            [<span class="keyword">yield</span>] headA;</span><br><span class="line">            headA = inputA.next();</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">    [<span class="keyword">yield</span>] <span class="literal">null</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>哇，这个函数几乎没有改动，真是太方便了！（然而并没有卵用）</p><h2 id="function-iterator">Function → Iterator</h2><p>现在我们回到现实：Java 并没有 Generator 语法，所以我们要人肉实现一个Generator。</p><p>为了通用性，首先做一个 desurge，把 while 循环改成 <code>if</code> 和<code>goto</code> 的组合，这太简单了：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line">Stream <span class="title function_">merge</span><span class="params">(Stream inputA, Stream inputB)</span> &#123;</span><br><span class="line">    <span class="type">Integer</span> <span class="variable">headA</span> <span class="operator">=</span> inputA.next();</span><br><span class="line">    <span class="type">Integer</span> <span class="variable">headB</span> <span class="operator">=</span> inputB.next();</span><br><span class="line">    WHILE_LOOP:</span><br><span class="line">    <span class="keyword">if</span> (headA != <span class="literal">null</span> || headB != <span class="literal">null</span>) &#123;</span><br><span class="line">        <span class="keyword">if</span> (headA == <span class="literal">null</span> || headB != <span class="literal">null</span> &amp;&amp; headA &gt; headB) &#123;</span><br><span class="line">            [<span class="keyword">yield</span>] headB;</span><br><span class="line">            headB = inputB.next();</span><br><span class="line">        &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">            [<span class="keyword">yield</span>] headA;</span><br><span class="line">            headA = inputA.next();</span><br><span class="line">        &#125;</span><br><span class="line">        goto WHILE_LOOP; <span class="comment">// again，假设 Java 也有 goto</span></span><br><span class="line">    &#125;</span><br><span class="line">    [<span class="keyword">yield</span>] <span class="literal">null</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>下一步是去掉 <code>yield</code>，刚刚说到 Generator 的每次<code>next()</code>似乎会让函数<strong>停在</strong>一个地方，如何实现<strong>停在</strong>一个地方？记下来呗！加一个标记<strong>状态</strong>的变量，这个状态会告诉我下次<code>next()</code> 的时候从哪里继续运行。</p><p>首先画出函数的控制流图，然后做一件事：想象所有的 <code>yield</code>之后都有一个断点，我们在断点处切开，标记它为某个 State，这样下次<code>next()</code> 的时候就能从断点继续。</p><p>下图的 S0 ～ S2 是我标记好的断点，S0 就是起始位置，S1 是两个<code>yield result</code> 之后断下来的地方（恰好是同一个地方），S2 是<code>yield null</code> 之后断下来的地方。</p><p><img src="/images/2019/01/flow-graph-iterator.png" /></p><p>我们按照图中的 <code>State</code>标记机械地把它切开，就得到了下面这个类，它就是由 <code>merge()</code>变换得到的 Generator：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">Merger</span> <span class="keyword">implements</span> <span class="title class_">Iterator</span>&lt;Integer&gt; &#123; </span><br><span class="line">    <span class="comment">// Arguments</span></span><br><span class="line">    <span class="keyword">final</span> Iterator inputA;</span><br><span class="line">    <span class="keyword">final</span> Iterator inputB;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">// Internal states</span></span><br><span class="line">    <span class="keyword">private</span> <span class="type">int</span> <span class="variable">state</span> <span class="operator">=</span> <span class="number">0</span>; <span class="comment">// 我们加上的状态变量</span></span><br><span class="line">    <span class="keyword">private</span> Integer headA; <span class="comment">// 变换前的局部变量，因为跨了多次 next() 调用，不能再是局部变量了</span></span><br><span class="line">    <span class="keyword">private</span> Integer headB; <span class="comment">// 同上</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="title function_">Merger</span><span class="params">(Iterator inputA, Iterator inputB)</span> &#123;</span><br><span class="line">        <span class="built_in">this</span>.inputA = inputA;</span><br><span class="line">        <span class="built_in">this</span>.inputB = inputB;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> Integer <span class="title function_">next</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">for</span> (;;) &#123; <span class="comment">// 这个循环是有用的，往下看几行</span></span><br><span class="line">            <span class="keyword">switch</span> (state) &#123;</span><br><span class="line">            <span class="keyword">case</span> <span class="number">0</span>:</span><br><span class="line">                headA = inputA.next();</span><br><span class="line">                headB = inputB.next();</span><br><span class="line">                state = <span class="number">1</span>;</span><br><span class="line">                <span class="keyword">break</span>; <span class="comment">// 这里就用上了外层的循环</span></span><br><span class="line">            <span class="keyword">case</span> <span class="number">1</span>:</span><br><span class="line">                <span class="keyword">if</span> (headA != <span class="literal">null</span> || headB != <span class="literal">null</span>) &#123;</span><br><span class="line">                    <span class="keyword">if</span> (headA == <span class="literal">null</span> || headB != <span class="literal">null</span> &amp;&amp; headA &gt; headB ) &#123;</span><br><span class="line">                        <span class="keyword">final</span> <span class="type">int</span> <span class="variable">result</span> <span class="operator">=</span> headB;</span><br><span class="line">                        headB = inputB.next();</span><br><span class="line">                        state = <span class="number">1</span>; <span class="comment">// 可以省略</span></span><br><span class="line">                        <span class="keyword">return</span> result; <span class="comment">// 变换前是 yield result</span></span><br><span class="line">                    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">                        <span class="keyword">final</span> <span class="type">int</span> <span class="variable">result</span> <span class="operator">=</span> headA;</span><br><span class="line">                        headA = inputA.next();</span><br><span class="line">                        state = <span class="number">1</span>; <span class="comment">// 可以省略</span></span><br><span class="line">                        <span class="keyword">return</span> result; <span class="comment">// 变换前是 yield result</span></span><br><span class="line">                    &#125;</span><br><span class="line">                &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">                    state = <span class="number">2</span>;</span><br><span class="line">                    <span class="keyword">return</span> <span class="literal">null</span>; <span class="comment">// 变换前是 yield null</span></span><br><span class="line">                &#125;</span><br><span class="line">            <span class="keyword">case</span> <span class="number">2</span>:</span><br><span class="line">                <span class="comment">// Generator 已经终结了（变换前：函数已经走到底了）</span></span><br><span class="line">                <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">IllegalStateException</span>(<span class="string">&quot;Generator has been exhausted!&quot;</span>);</span><br><span class="line">            <span class="keyword">default</span>:</span><br><span class="line">                <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">AssertionError</span>(<span class="string">&quot;Unreachable!&quot;</span>);</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><blockquote><p>别急，最后我们会简化这些充满废话的代码。</p></blockquote><p>阶段性总结一下：到现在为止，我们做了一件伟大的事情——<strong>把一个函数变成了Iterator，函数已经不再是函数，而是一个状态机，这个状态记录了下次调用<code>next()</code> 需要从哪继续</strong>。</p><blockquote><p>套用一下术语：“从哪继续”就是 <ahref="https://en.wikipedia.org/wiki/Continuation">Continuation</a>，把Continuation 搞出来的这个过程称为 <ahref="https://en.wikipedia.org/wiki/Continuation-passing_style">CPS变换</a>。</p></blockquote><h2 id="iterator-asynciterator">Iterator → AsyncIterator</h2><p>呃…… 说好的 CompletableFuture 呢？离 CompletableFuture只有一步之遥了！</p><p>先从接口下手。想象两个 Stream Input 都是从 IO 拿到的数据，所以每次<code>next()</code> 其实背后都是一次 IO，应该把它用 CompletableFuture包成异步的，接口大概长这样：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">interface</span> <span class="title class_">AsyncIterator</span>&lt;T&gt; &#123;</span><br><span class="line">    CompletableFuture&lt;T&gt; <span class="title function_">next</span><span class="params">()</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>类似刚刚引入 Generator 一样，我们再假装有 <code>await</code>关键字。<code>await</code>关键字表示异步地等待结果返回，有了它，函数就魔法般的暂停在等待异步 IO的地方：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">Stream <span class="title function_">merge</span><span class="params">(Stream inputA, Stream inputB)</span> &#123;</span><br><span class="line">    <span class="type">Integer</span> <span class="variable">headA</span> <span class="operator">=</span> inputA.next();</span><br><span class="line">    <span class="type">Integer</span> <span class="variable">headB</span> <span class="operator">=</span> inputB.next();</span><br><span class="line">    WHILE_LOOP:</span><br><span class="line">    <span class="keyword">if</span> (headA != <span class="literal">null</span> || headB != <span class="literal">null</span>) &#123;</span><br><span class="line">        <span class="keyword">if</span> (headA == <span class="literal">null</span> || headB != <span class="literal">null</span> &amp;&amp; headA &gt; headB) &#123;</span><br><span class="line">            <span class="type">Integer</span> <span class="variable">result</span> <span class="operator">=</span> headB;</span><br><span class="line">            headB = [await] inputB.next(); <span class="comment">// await 会魔法般地等待 next() 完成再继续运行</span></span><br><span class="line">            [<span class="keyword">yield</span>] result;</span><br><span class="line">        &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">            <span class="type">Integer</span> <span class="variable">result</span> <span class="operator">=</span> headA;</span><br><span class="line">            headA = [await] inputA.next();</span><br><span class="line">            [<span class="keyword">yield</span>] result;</span><br><span class="line">        &#125;</span><br><span class="line">        goto WHILE_LOOP;</span><br><span class="line">    &#125;</span><br><span class="line">    [<span class="keyword">yield</span>] <span class="literal">null</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>因为 <code>await</code> 也会暂停这个“函数”，所以和刚刚对<code>yield</code> 的处理一样，我们想象 <code>await</code>这里有一个断点，我们也要为它设置 State 标记：</p><p><img src="/images/2019/01/flow-graph-async.png" /></p><p>糟糕！这状态数有点多啊！好在 Java 8 提供了 Lambda 表达式，和CompletableFuture 搭配食用口味更佳。图中的大多数状态都可以借助 Lambda表达式来实现，节约了不少代码：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">Merger</span> <span class="keyword">implements</span> <span class="title class_">AsyncIterator</span>&lt;Integer&gt; &#123;</span><br><span class="line">    <span class="comment">// Arguments</span></span><br><span class="line">    <span class="keyword">final</span> Stream inputA;</span><br><span class="line">    <span class="keyword">final</span> Stream inputB;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Internal states</span></span><br><span class="line">    <span class="keyword">private</span> <span class="type">int</span> <span class="variable">state</span> <span class="operator">=</span> <span class="number">0</span>;</span><br><span class="line">    <span class="keyword">private</span> Integer headA;</span><br><span class="line">    <span class="keyword">private</span> Integer headB;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="title function_">Merger</span><span class="params">(Stream inputA, Stream inputB)</span> &#123;</span><br><span class="line">        <span class="built_in">this</span>.inputA = inputA;</span><br><span class="line">        <span class="built_in">this</span>.inputB = inputB;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> CompletableFuture&lt;Integer&gt; <span class="title function_">next</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">switch</span> (state) &#123;</span><br><span class="line">        <span class="keyword">case</span> <span class="number">0</span>:</span><br><span class="line">            <span class="keyword">return</span> inputA.next().thenCompose(a -&gt; &#123; <span class="comment">// State 1 在这里！</span></span><br><span class="line">                headA = a;</span><br><span class="line">                <span class="keyword">return</span> inputB.next();</span><br><span class="line">            &#125;).thenCompose(b -&gt; &#123; <span class="comment">// State 2 在这里！</span></span><br><span class="line">                headB = b;</span><br><span class="line">                state = <span class="number">3</span>;</span><br><span class="line">                <span class="keyword">return</span> next(); <span class="comment">// 相当于原来的外层循环</span></span><br><span class="line">            &#125;);</span><br><span class="line">        <span class="keyword">case</span> <span class="number">3</span>:</span><br><span class="line">            <span class="keyword">if</span> (headA != <span class="literal">null</span> || headB != <span class="literal">null</span>) &#123;</span><br><span class="line">                <span class="keyword">if</span> (headA == <span class="literal">null</span> || headB != <span class="literal">null</span> &amp;&amp; headA &gt; headB) &#123;</span><br><span class="line">                    <span class="keyword">final</span> <span class="type">Integer</span> <span class="variable">result</span> <span class="operator">=</span> headB;</span><br><span class="line">                    <span class="keyword">return</span> inputB.next().thenCompose(b -&gt; &#123; <span class="comment">// State 4 在这里！</span></span><br><span class="line">                        headB = b;</span><br><span class="line">                        state = <span class="number">3</span>; <span class="comment">// 可以省略</span></span><br><span class="line">                        <span class="keyword">return</span> CompletableFuture.completedFuture(result);</span><br><span class="line">                    &#125;);</span><br><span class="line">                &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">                    <span class="keyword">final</span> <span class="type">Integer</span> <span class="variable">result</span> <span class="operator">=</span> headA;</span><br><span class="line">                    <span class="keyword">return</span> inputA.next().thenCompose(a -&gt; &#123; <span class="comment">// State 5 在这里！</span></span><br><span class="line">                        headA = a;</span><br><span class="line">                        state = <span class="number">3</span>; <span class="comment">// 可以省略</span></span><br><span class="line">                        <span class="keyword">return</span> CompletableFuture.completedFuture(result);</span><br><span class="line">                    &#125;);</span><br><span class="line">                &#125;</span><br><span class="line">            &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">                state = <span class="number">6</span>;</span><br><span class="line">                <span class="keyword">return</span> CompletableFuture.completedFuture(<span class="literal">null</span>);</span><br><span class="line">            &#125;</span><br><span class="line">        <span class="keyword">case</span> <span class="number">6</span>:</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">IllegalStateException</span>(<span class="string">&quot;Generator has been exhausted!&quot;</span>);</span><br><span class="line">        <span class="keyword">default</span>:</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">AssertionError</span>(<span class="string">&quot;Unreachable!&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="refinement">Refinement</h2><p>上面我们只用了 <code>thenCompose</code>，理论上这是 OK 的，但是实际上CompletableFuture 有上百个方法，最合适的才是坠吼的。</p><ul><li>如果仅仅是返回一个值（而非阶段），可以用<code>thenApply</code>；</li><li><code>thenCombine</code> 等待两个 CompletableFuture 都完成了再去调用BiFunction <code>(T, U) -&gt; R</code> 来消费。</li></ul><blockquote><p>思考题：有兴趣的读者可以思考一下 <code>thenCombine</code>的实现。</p></blockquote><p>整理一下上面的代码，比如这样：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">Merger</span> <span class="keyword">implements</span> <span class="title class_">AsyncIterator</span>&lt;Integer&gt; &#123;</span><br><span class="line">    <span class="comment">// States</span></span><br><span class="line">    <span class="keyword">enum</span> <span class="title class_">State</span> &#123; START, ITERATING, DONE &#125;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">// Arguments</span></span><br><span class="line">    <span class="keyword">final</span> Stream inputA;</span><br><span class="line">    <span class="keyword">final</span> Stream inputB;</span><br><span class="line">    </span><br><span class="line">    <span class="comment">// Internal states</span></span><br><span class="line">    <span class="keyword">private</span> <span class="type">State</span> <span class="variable">state</span> <span class="operator">=</span> State.START;</span><br><span class="line">    <span class="keyword">private</span> Integer headA;</span><br><span class="line">    <span class="keyword">private</span> Integer headB;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="title function_">Merger</span><span class="params">(Stream inputA, Stream inputB)</span> &#123;</span><br><span class="line">        <span class="built_in">this</span>.inputA = inputA;</span><br><span class="line">        <span class="built_in">this</span>.inputB = inputB;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> CompletableFuture&lt;Integer&gt; <span class="title function_">next</span><span class="params">()</span> &#123;</span><br><span class="line">        <span class="keyword">switch</span> (state) &#123;</span><br><span class="line">        <span class="keyword">case</span> START:</span><br><span class="line">            <span class="comment">// 这里做了小小的优化：这两个 next() 可以并行等待</span></span><br><span class="line">            <span class="keyword">return</span> inputA.next().thenCombine(inputB.next(), (a, b) -&gt; &#123;</span><br><span class="line">                headA = a;</span><br><span class="line">                headB = b;</span><br><span class="line">                state = State.ITERATING;</span><br><span class="line">                <span class="keyword">return</span> (Void)<span class="literal">null</span>;</span><br><span class="line">            &#125;).thenCompose(__ -&gt; next());</span><br><span class="line">        <span class="keyword">case</span> ITERATING:</span><br><span class="line">            <span class="keyword">if</span> (headA != <span class="literal">null</span> || headB != <span class="literal">null</span>) &#123;</span><br><span class="line">                <span class="keyword">if</span> (headA == <span class="literal">null</span> || headB != <span class="literal">null</span> &amp;&amp; headA &gt; headB) &#123;</span><br><span class="line">                    <span class="keyword">final</span> <span class="type">Integer</span> <span class="variable">result</span> <span class="operator">=</span> headB;</span><br><span class="line">                    <span class="keyword">return</span> inputB.next().thenApply(b -&gt; &#123; <span class="comment">// thenCompose 某个值 &lt;=&gt; thenApply</span></span><br><span class="line">                        headB = b;</span><br><span class="line">                        <span class="keyword">return</span> result;</span><br><span class="line">                    &#125;);</span><br><span class="line">                &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">                    <span class="keyword">final</span> <span class="type">Integer</span> <span class="variable">result</span> <span class="operator">=</span> headA;</span><br><span class="line">                    <span class="keyword">return</span> inputA.next().thenApply(a -&gt; &#123; <span class="comment">// 同上</span></span><br><span class="line">                        headA = a;</span><br><span class="line">                        <span class="keyword">return</span> result;</span><br><span class="line">                    &#125;);</span><br><span class="line">                &#125;</span><br><span class="line">            &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">                state = State.DONE;</span><br><span class="line">                <span class="keyword">return</span> CompletableFuture.completedFuture(<span class="literal">null</span>);</span><br><span class="line">            &#125;</span><br><span class="line">        <span class="keyword">case</span> DONE:</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">IllegalStateException</span>(<span class="string">&quot;Generator has been exhausted!&quot;</span>);</span><br><span class="line">        <span class="keyword">default</span>:</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">AssertionError</span>(<span class="string">&quot;Unreachable!&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="总结">总结</h2><p>任何函数都可以用 CompletableFuture 实现异步化，最通用的方式如下：</p><ol type="1"><li>在函数里加上 <code>yield</code>（返回下一个结果）和<code>await</code>（等待输入值）来标记断点；</li><li>画出控制流图，注意要在 <code>yield</code> 和 <code>await</code>处断开，断开处标记为状态；</li><li>实现一个状态机类，把控制流图中的代码块、状态都无脑填进去，搞定。</li></ol><p>这一刻，我们都是（人肉）编译器。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;a href=&quot;/several-ways-to-aync&quot;&gt;上篇文章&lt;/a&gt;中提到，Java 里把 Promise
叫作 CompletableFuture，相比那个只能用于线程同步的
Future，它新增了很多方法用于串联异步事件，比如常用的
&lt;code&gt;thenApply&lt;/code&gt;、&lt;code&gt;thenCompose&lt;/code&gt;、&lt;code&gt;thenAccept&lt;/code&gt;
等。&lt;/p&gt;
&lt;p&gt;如果不引入任何第三方库，CompletableFuture 仍是目前 Java
上最好的异步编程方式。之前一直觉得这个东西难用，直到我想明白一件事，证明了
CompletableFuture
虽然麻烦了点但是能做任何事情，然后用它的时候心里就没那么膈应了。&lt;/p&gt;
&lt;p&gt;本文会以一个例子来讲解：&lt;strong&gt;如何把任意函数转换成异步调用风格&lt;/strong&gt;。其实不一定要用
CompletableFuture，任何语言和框架都是适用的。&lt;/p&gt;</summary>
    
    
    
    
    <category term="java" scheme="https://ericfu.me/tags/java/"/>
    
    <category term="async" scheme="https://ericfu.me/tags/async/"/>
    
  </entry>
  
  <entry>
    <title>异步编程的几种方式</title>
    <link href="https://ericfu.me/several-ways-to-aync/"/>
    <id>https://ericfu.me/several-ways-to-aync/</id>
    <published>2019-01-03T10:12:43.000Z</published>
    <updated>2026-02-27T03:48:16.267Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/images/2019/01/banner-async-await.png" /></p><p>近期尝试在搬砖专用语言 Java上实现异步，起因和过程就不再详述了，总而言之，心中一万头草泥马奔过。但这个过程也没有白白浪费，趁机回顾了一下各种异步编程的实现。</p><p>这篇文章会涉及到回调、Promise、反应式、async/await、用户态线程等异步编程的实现方案。如果你熟悉它们中的一两种，那应该也能很快理解其他几个。</p><span id="more"></span><h2 id="为什么需要异步">为什么需要异步？</h2><p>操作系统可以看作是个虚拟机（VM），进程生活在操作系统创造的虚拟世界里。进程不用知道到底有多少core多少内存，只要进程不要索取的太过分，操作系统就假装有无限多的资源可用。</p><p>基于这个思想，线程（Thread）的个数并不受硬件限制：你的程序可以只有一个线程、也可以有成百上千个。操作系统会默默做好调度，让诸多线程共享有限的CPU 时间片。这个调度的过程对线程是<strong>完全透明</strong>的。</p><p>那么，操作系统是怎样做到在线程无感知的情况下调度呢？答案是<strong>上下文切换（ContextSwitch）</strong>，简单来说，操作系统利用软中断机制，把程序从任意位置打断，然后保存当前所有寄存器——包括最重要的指令寄存器PC 和栈顶指针 SP，还有一些线程控制信息（TCB），整个过程会产生<ahref="https://blog.tsunanet.net/2010/11/how-long-does-it-take-to-make-context.html">数个微秒的overhead</a>。</p><p><img src="/images/2019/01/cpu-threads-illustration.jpg" /></p><p>然而作为一位合格的程序员，你一定也听说过，线程是昂贵的：</p><ul><li>线程的上下文切换有不少的代价，占用宝贵的 CPU 时间；</li><li>每个线程都会占用一些（至少 1 页）内存。</li></ul><p>这两个原因驱使我们<strong>尽可能避免创建太多的线程</strong>，而异步编程的目的就是消除IO wait阻塞——绝大多数时候，这是我们创建一堆线程、甚至引入线程池的罪魁祸首。</p><h2 id="continuation">Continuation</h2><p>回调函数知道的人很多，但了解 Continuation 的人不多。Continuation有时被晦涩地翻译成“计算续体”，咱们还是直接用单词好了。</p><p><strong>把一个计算过程在中间打断，剩下的部分用一个对象表示，这就是Continuation</strong>。操作系统暂停一个线程时保存的那些现场数据，也可以看作一个Continuation。有了它，我们就能在这个点接着刚刚的断点继续执行。</p><p>打断一个计算过程听起来很厉害吧！实际上它每时每刻都在发生——假设函数<code>f()</code> 中间调用了 <code>g()</code>，那 <code>g()</code>运行完成时，要返回到 <code>f()</code> 刚刚调用 <code>g()</code>的地方接着执行。这个过程再自然不过了，以至于所有编程语言（汇编除外）都把它掩藏起来，让你在编程中感觉不到调用栈的存在。</p><p><img src="/images/2019/01/call-stack-and-continuation.jpg" /></p><p>操作系统用昂贵的软中断机制实现了栈的保存和恢复。那有没有别的方式实现Continuation呢？最朴素的想法就是，把所有用得到的信息包成一个函数对象，在调用<code>g()</code> 的时候一起传进去，并约定：一旦 <code>g()</code>完成，就拿着结果去调用这个 Continuation。</p><p>这种编程模式被称为 Continuation-passing style（CPS）：</p><ol type="1"><li>把调用者 <code>f()</code> 还未执行的部分包成一个函数对象<code>cont</code>，一同传给被调用者 <code>g()</code>；</li><li>正常运行 <code>g()</code> 函数体；</li><li><code>g()</code> 完成后，连同它的结果一起回调<code>cont</code>，从而继续执行 <code>f()</code> 里剩余的代码。</li></ol><p>再拿 Wikipedia 上的定义巩固一下：</p><blockquote><p>A function written in continuation-passing style takes an extraargument: an explicit "continuation", i.e. a function of one argument.When the CPS function has computed its result value, it "returns" it bycalling the continuation function with this value as the argument.</p><p>CPS 风格的函数带一个额外的参数：一个显式的Continuation，具体来说就是个仅有一个参数的函数。当 CPS函数计算完返回值时，它“返回”的方式就是拿着返回值调用那个Continuation。</p></blockquote><p>你应该已经发现了，这也就是回调函数，我只是换了个名字而已。</p><h2 id="异步的朴素实现callback">异步的朴素实现：Callback</h2><p>光有回调函数其实并没有卵用。对于纯粹的计算工作，Call Stack就很好，为何要费时费力用回调来做 Continuation 呢？你说的对，但仅限于没有IO 的情况。我们知道 IO 通常要比 CPU 慢上好几个数量级，在 BIO中，线程发起 IO 之后只能暂停，然后等待 IO 完成再由操作系统唤醒。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">var</span> <span class="variable">input</span> <span class="operator">=</span> recv_from_socket()  <span class="comment">// Block at syscall recv()</span></span><br><span class="line"><span class="type">var</span> <span class="variable">result</span> <span class="operator">=</span> calculator.calculate(input)</span><br><span class="line">send_to_socket(result) <span class="comment">// Block at syscall send()</span></span><br></pre></td></tr></table></figure><p>而异步 IO 中，进程发起 IO 操作时也会一并输入回调（也就是Continuation），这大大解放了生产力——现场无需等待，可以立即返回去做其他事情。一旦IO 成功后，AIO 的 Event Loop会调用刚刚设置的回调函数，把剩下的工作完成。这种模式有时也被称为 Fireand Forget。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">recv_from_socket((input) -&gt; &#123;</span><br><span class="line">    <span class="type">var</span> <span class="variable">result</span> <span class="operator">=</span> calculator.calculate(input)</span><br><span class="line">    send_to_socket(result) <span class="comment">// ignore result</span></span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>就这么简单，通过我们自己实现的 Continuation，线程不再受 IO阻塞，可以自由自在地跑满 CPU。</p><h2 id="一颗语法糖promise">一颗语法糖：Promise</h2><p>回调函数哪里都好，就是不大好用，以及太丑了。</p><p>第一个问题是可读性大大下降，由于我们绕开操作系统自制Continuation，所有函数调用都要传入一个 lambda表达式，你的代码看起来就像要起飞一样，缩进止不住地往右挪（the "CallbackHell"）。</p><p>第二个问题是各种细节处理起来很麻烦，比如，考虑下异常处理，看来传一个Continuation 还不够，最好再传个异常处理的 callback。</p><p><strong>Promise 是对异步调用结果的一个封装</strong>，在 Java 中它叫作CompletableFuture (JDK8) 或者 ListenableFuture (Guava)。Promise有两层含义：</p><p>第一层含义是：<strong>我现在还不是真正的结果，但是承诺以后会拿到这个结果</strong>。这很容易理解，异步的任务迟早会完成，调用者如果比较蠢萌，他也可以用<code>Promise.get()</code>强行要拿到结果，顺便阻塞了当前线程，异步变成了同步。</p><p>第二层含义是：<strong>如果你（调用者）有什么吩咐，就告诉我好了</strong>。这就有趣了，换句话说，回调函数不再是传给<code>g()</code>，而是 <code>g()</code> 返回的Promise，比如之前那段代码，我们用 Promise 来书写，看起来顺眼了不少。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">var</span> <span class="variable">promise_input</span> <span class="operator">=</span> recv_from_socket()</span><br><span class="line">promise_input.then((input) -&gt; &#123;</span><br><span class="line">    <span class="type">var</span> <span class="variable">result</span> <span class="operator">=</span> calculator.calculate(input)</span><br><span class="line">    send_to_socket(result) <span class="comment">// ignore result</span></span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p>Promise 改善了 Callback的可读性，也让异常处理稍稍优雅了些，但终究是颗语法糖。</p><h2 id="反应式编程">反应式编程</h2><p>反应式（Reactive）最早源于函数式编程中的一种模式，随着微软发起ReactiveX 项目并一步步壮大，被移植到各种语言和平台上。Reactive 最初在GUI编程中有广泛的应用，由于异步调用的高性能，很快也在服务器后端领域遍地开花。</p><p><strong>Reactive 可以看作是对 Promise 的极大增强，相比Promise，反应式引入了流（Flow）的概念</strong>。ReactiveX中的事件流从一个 Observable 对象流出，这个对象可以是一个按钮，也可以是Restful API，总之，它能被外界触发。与 Promise不同的是，事件可能被触发多次，所以处理代码也会被多次调用。</p><p>一旦允许调用多次，<strong>从数据流动的角度看，事实上模型已经是 Push而非Pull</strong>。那么问题来了，如果调用频率非常高，以至于我们处理速度跟不上了怎么办？所以RX 框架又引入了 Backpressure 机制来进行流控，最简单的流控方式就是：一旦buffer 满，就丢弃掉之后的事件。</p><p>ReactiveX框架的另一个优点是内置了很多好用的算子，比如：<code>merge</code>（Flow合并），<code>debounce</code>（开关除颤）等等，方便了业务开发。下面是一个RxJava 的例子：</p><p><img src="/images/2019/01/rxjava-example.gif" /></p><h2 id="cps-变换coroutine-与-asyncawait">CPS 变换：Coroutine 与async/await</h2><p>无论是反应式还是 Promise，说到底仍然没有摆脱手工构造Continuation：开发者要把业务逻辑写成回调函数。对于线性的逻辑基本可以应付自如，但是如果逻辑复杂一点呢？（比如，考虑下包含循环的情况）</p><p><img src="/images/2019/01/csp-background-problems.jpg" /></p><p>有些语言例如 C#，JavaScript 和 Python 提供了 <code>async/await</code>关键字。与 Reactive 一样，这同样出自微软 C#语言。在这些语言中，你会感到前所未有的爽感：异步编程终于摆脱了回调函数！唯一要做的只是在异步函数调用时加上<code>await</code>，编译器就会自动把它转化为协程（Coroutine），而非昂贵的线程。</p><p>魔法的背后是 CPS 变换，<strong>CPS 变换把普通函数转换成一个 CPS的函数，即 Continuation也能作为一个调用参数</strong>。函数不仅能从头运行，还能根据 Continuation的指示继续某个点（比如调用 IO 的地方）运行。</p><p>例子可以参见我的<ahref="/completable-future-not-so-bad">下一篇文章</a>。由于代码太长，就不贴在这儿了。</p><p>可以看到，<strong>函数已经不再是一个函数了，而是变成一个状态机</strong>。每次call 它、或者它 call其他异步函数时，状态机都会做一些计算和状态轮转。说好的 Continuation在哪呢？就是对象自己（<code>this</code>）啊。</p><p>CPS 变换实现非常复杂，尤其是考虑到 try-catch之后。但是没关系，复杂性都在编译器里，用户只要学两个关键词即可。这个特性非常优雅，比Java 那个废柴的 <code>CompletableFuture</code>不知道高到哪去了。（更新：<ahref="/completable-future-not-so-bad">也没有那么废柴啦</a>）</p><blockquote><p>JVM 上也有一个实现：<ahref="https://github.com/electronicarts/ea-async">electronicarts/ea-async</a>，原理和C# 的 async/await 类似，在编译期修改 Bytecode 实现 CPS 变换。</p></blockquote><h2 id="终极方案用户态线程">终极方案：用户态线程</h2><p>有了<code>async/await</code>，代码已经简洁很多了，基本上和同步代码无异。是否有可能让异步代码和同步代码完全一样呢？听起来就像免费午餐，但是的确可以做到！</p><p>用户态线程的代表是 Golang。JVM 上也有些实现，比如 <ahref="http://docs.paralleluniverse.co/quasar/">Quasar</a>，不过因为JDBC、Spring 这些周边生态（它们占据了大部分 IO操作）的缺失基本没有什么用。</p><p><strong>用户态线程是把操作系统提供的线程机制完全抛弃</strong>，换句话说，不去用这个VM 的虚拟化机制。比如硬件有 8 个核心，那就创建 8 个系统线程，然后把 N个用户线程调度到这 8 个系统线程上跑。N个用户线程的调度在用户进程里实现，由于一切都在进程内部，切换代价要远远小于操作系统Context Switch。</p><p><img src="/images/2019/01/goroutine-illustration.png" /></p><p>另一方面，所有可能阻塞系统级线程的事情，例如<code>sleep()</code>、<code>recv()</code>等，用户态线程一定不能碰，否则它一旦阻塞住也就带着那 8个系统线程中的一个阻塞了。Go Runtime接管了所有这样的系统调用，并用一个统一的 Event loop 来轮询和分发。</p><p>另外，由于用户态线程很轻量，我们完全没必要再用线程池，如果需要开线程就直接创建。比如Java 中的 WebServer 几乎一定有个线程池，而 Go 可以给每个请求开辟一个goroutine 去处理。并发编程从未如此美好！</p><h2 id="总结">总结</h2><p>以上方案中，Promise、Reactive本质上还是回调函数，只是框架的存在一定程度上降低了开发者的心智负担。而<code>async/await</code>和用户态线程的解决方案要优雅和彻底的多，前者通过编译期的 CPS变换帮用户创造出 CPS式的函数调用；后者则绕开操作系统、重新实现一套线程机制，一切调度工作由Runtime 接管。</p><p>不知道是不是因为历史包袱太重，Java语言本身提供的异步编程支持弱得可怜，即便是 CompletableFuture 还是在 Java8 才引入，其后果就是很多库都没有异步的支持。虽然 Quasar在没有语言级支持的情况下引入了 CPS变换，但是由于缺少周边生态的支持，实际很难用在项目中。</p><h2 id="references">References</h2><ol type="1"><li><ahref="https://blog.tsunanet.net/2010/11/how-long-does-it-take-to-make-context.html">Howlong does it take to make a context switch?</a></li><li><a href="http://reactivex.io/">ReactiveX</a></li><li><ahref="https://zhuanlan.zhihu.com/p/25964339">考不上三本也能给自己心爱的语言加上Coroutine</a></li><li><a href="http://docs.paralleluniverse.co/quasar/">Quasar</a></li><li><a href="http://morsmachine.dk/go-scheduler">The Goscheduler</a></li><li><ahref="https://medium.com/@ThatGuyTinus/callbacks-vs-promises-vs-async-await-f65ed7c2b9b4">CallbacksVS Promises VS Async/Await</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img src=&quot;/images/2019/01/banner-async-await.png&quot; /&gt;&lt;/p&gt;
&lt;p&gt;近期尝试在搬砖专用语言 Java
上实现异步，起因和过程就不再详述了，总而言之，心中一万头草泥马奔过。但这个过程也没有白白浪费，趁机回顾了一下各种异步编程的实现。&lt;/p&gt;
&lt;p&gt;这篇文章会涉及到回调、Promise、反应式、async/await、用户态线程等异步编程的实现方案。如果你熟悉它们中的一两种，那应该也能很快理解其他几个。&lt;/p&gt;</summary>
    
    
    
    
    <category term="java" scheme="https://ericfu.me/tags/java/"/>
    
    <category term="async" scheme="https://ericfu.me/tags/async/"/>
    
    <category term="socket" scheme="https://ericfu.me/tags/socket/"/>
    
  </entry>
  
  <entry>
    <title>JIT 代码生成技术（一）表达式编译</title>
    <link href="https://ericfu.me/code-gen-of-expression/"/>
    <id>https://ericfu.me/code-gen-of-expression/</id>
    <published>2018-11-28T17:42:39.000Z</published>
    <updated>2026-02-27T03:48:16.265Z</updated>
    
    <content type="html"><![CDATA[<p><img src="/images/2018/12/code-generation-banner.png" /></p><p><strong>代码生成（CodeGeneration）</strong>技术广泛应用于现代的数据系统中。代码生成是将用户输入的表达式、查询、存储过程等现场编译成二进制代码再执行，相比解释执行的方式，运行效率要高得多。尤其是对于计算密集型查询、或频繁重复使用的计算过程，运用代码生成技术能达到数十倍的性能提升。</p><span id="more"></span><h2id="当我们谈论代码生成时我们在谈论什么">当我们谈论代码生成时我们在谈论什么</h2><p>很多大数据产品都将代码生成技术作为卖点，然而事实上他们往往谈论的不是一件事情。比如，之前就有人提问：Spark1.x 就已经有代码生成技术，为什么 Spark 2.0又把代码生成吹了一番？其中的原因在于，虽然都是代码生成，但是各个产品生成代码的粒度是不同的：</p><ul><li>最简单的，例如 Spark1.4，使用代码生成技术加速<strong>表达式计算</strong>；</li><li>Spark 2.0 支持将同一个 Stage的<strong>多个算子组合编译</strong>成一段二进制；</li><li>更有甚者，支持将<strong>自定义函数、存储过程</strong>等编译成一段二进制，例如SQL Server。</li></ul><p><img src="/images/2018/12/three-levels-of-code-gen.png" /></p><p>本文主要讲上面最简单的表达式编译。让我们通过一个简单的例子，初步了解代码生成的流程。</p><h2 id="解析执行的缺陷">解析执行的缺陷</h2><p>在讲代码生成之前，我们回顾一下解释执行。以上面图中的表达式 <spanclass="math inline">\(X \times 5 + \log (10)\)</span>为例，计算过程是一个深度优先搜索（DFS）的过程：</p><ol type="1"><li>调用根节点 <code>+</code> 的 <code>visit()</code>函数：分别调用左、右子节点的 <code>visit()</code> 再相加；</li><li>调用乘法节点 <code>*</code> 的 <code>visit()</code>函数：分别调用左、右子节点的 <code>visit()</code> 再相乘；</li><li>调用变量节点 <code>X</code> 的 <code>visit()</code>函数：从环境中读取 <span class="math inline">\(X\)</span>的值以及类型。</li></ol><p>（……略）最终，DFS 回到根节点，得到最终结果。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Override</span> <span class="keyword">public</span> Object <span class="title function_">visitPlus</span><span class="params">(CalculatorParser.PlusContext ctx)</span> &#123;</span><br><span class="line">    <span class="type">Object</span> <span class="variable">left</span> <span class="operator">=</span> visit(ctx.plusOrMinus());</span><br><span class="line">    <span class="type">Object</span> <span class="variable">right</span> <span class="operator">=</span> visit(ctx.multOrDiv());</span><br><span class="line">    <span class="keyword">if</span> (left <span class="keyword">instanceof</span> Long &amp;&amp; right <span class="keyword">instanceof</span> Long) &#123;</span><br><span class="line">        <span class="keyword">return</span> (Long) left + (Long) right;</span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (left <span class="keyword">instanceof</span> Long &amp;&amp; right <span class="keyword">instanceof</span> Double) &#123;</span><br><span class="line">        <span class="keyword">return</span> (Long) left + (Double) right;</span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (left <span class="keyword">instanceof</span> Double &amp;&amp; right <span class="keyword">instanceof</span> Long) &#123;</span><br><span class="line">        <span class="keyword">return</span> (Double) left + (Long) right;</span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (left <span class="keyword">instanceof</span> Double &amp;&amp; right <span class="keyword">instanceof</span> Double) &#123;</span><br><span class="line">        <span class="keyword">return</span> (Double) left + (Double) right;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">IllegalArgumentException</span>();</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>上述过程中有几个显而易见的性能问题：</p><ul><li>涉及到大量的<strong>虚函数调用</strong>、即函数绑定的过程，例如<code>visit()</code> 函数，虚函数调用是一个非确定性的跳转指令， CPU无法做预测分支，从而导致打断 CPU 流水线；</li><li>在计算之前不能确定类型，因而各个算子的实现中会出现很多<strong>动态类型判断</strong>，例如：如果<code>+</code> 左边是 DECIMAL 类型，而右边是 DOUBLE，需要先把左边转换成DOUBLE 再相加；</li><li>递归中的<strong>函数调用打断了计算过程</strong>，不仅调用本身需要额外的指令，而且函数调用传参是通过栈完成的，不能很好的利用寄存器（这一点在现代的编译器和硬件体系中已经有所缓解，但显然比不上连续的计算指令）。</li></ul><h2 id="代码生成基本过程">代码生成基本过程</h2><p>代码生成执行，顾名思义，最核心的部分是生成出我们需要的执行代码。</p><p>拜编译器所赐，我们并不需要写难懂的汇编或字节码。在 native程序中，通常用 LLVM 的中间语言（IR）作为生成代码的语言。而 JVM上更简单，因为 Java 编译本身很快，利用运行在 JVM 上的轻量级编译器janino，我们可以直接生成 Java 代码。</p><p>无论是 LLVM IR 还是 Java都是静态类型的语言，在生成的代码中再去判断类型显然不是个明智的选择。<strong>通常的做法是在编译之前就确定所有值的类型</strong>。幸运的是，表达式和SQL 执行计划都可以事先做类型推导。</p><p>所以，综上所述，代码生成往往是个 2-pass的过程：<strong>先做类型推导，再做真正的代码生成</strong>。第一步中，类型推导的同时其实也是在检查表达式是否合法，因此很多地方也称之为<strong>验证（Validate）</strong>。</p><p>在代码生成完成后，<strong>调用编译器编译</strong>，我们得到了所需的函数（类），调用它即可得到计算结果。如果函数包含参数，例如上面例子中的<code>X</code>，每次计算可以传入不同的参数，<strong>编译一次、计算多次</strong>。</p><p><strong>以下的代码实现都可以在 GitHub 项目 <ahref="https://github.com/fuyufjh/calculator">fuyufjh/calculator</a>找到。</strong></p><h2 id="验证validate">验证（Validate）</h2><blockquote><p>为了尽可能简单，例子中仅涉及两种类型：Long 和 Double</p></blockquote><p><img src="/images/2018/12/ast-to-algebra-tree.png" /></p><p>这一步中，我们将合法的表达式 AST 转换成 AlgebraNode，这是一个递归语法树的过程，下面是一个例子（由于 Plus 接收Long/Double 的任意类型组合，所以此处没有做类型检查）：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Override</span> <span class="keyword">public</span> AlgebraNode <span class="title function_">visitPlus</span><span class="params">(CalculatorParser.PlusContext ctx)</span> &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">PlusNode</span>(visit(ctx.plusOrMinus()), visit(ctx.multOrDiv()));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>AlgebraNode 接口定义如下：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">interface</span> <span class="title class_">AlgebraNode</span> &#123;</span><br><span class="line">    DataType <span class="title function_">getType</span><span class="params">()</span>; <span class="comment">// Validate 和 CodeGen 都会用到</span></span><br><span class="line">    String <span class="title function_">generateCode</span><span class="params">()</span>; <span class="comment">// CodeGen 使用</span></span><br><span class="line">    List&lt;AlgebraNode&gt; <span class="title function_">getInputs</span><span class="params">()</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>实现类大致与 AST 的中的节点相对应，如下图。</p><p><img src="/images/2018/12/algebra-node-uml.png" /></p><p>对于加法，类型推导的过程很简单——如果两个操作数都是 Long 则结果为Long，否则为 Double。</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Override</span> <span class="keyword">public</span> DataType <span class="title function_">getType</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> (dataType == <span class="literal">null</span>) &#123;</span><br><span class="line">        dataType = inferTypeFromInputs();</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> dataType;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">private</span> DataType <span class="title function_">inferTypeFromInputs</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">for</span> (AlgebraNode input : getInputs()) &#123;</span><br><span class="line">        <span class="keyword">if</span> (input.getType() == DataType.DOUBLE) &#123;</span><br><span class="line">            <span class="keyword">return</span> DataType.DOUBLE;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">return</span> DataType.LONG;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="生成代码">生成代码</h2><p>依旧以加法为例，利用上面实现的<code>getType()</code>，我们可以确定输入、输出的类型，生成出强类型的代码：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Override</span> <span class="keyword">public</span> String <span class="title function_">generateCode</span><span class="params">()</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> (getLeft().getType() == DataType.DOUBLE &amp;&amp; getRight().getType() == DataType.DOUBLE) &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="string">&quot;(&quot;</span> + getLeft().generateCode() + <span class="string">&quot; + &quot;</span> + getRight().generateCode() + <span class="string">&quot;)&quot;</span>;</span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (getLeft().getType() == DataType.DOUBLE &amp;&amp; getRight().getType() == DataType.LONG) &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="string">&quot;(&quot;</span> + getLeft().generateCode() + <span class="string">&quot; + (double)&quot;</span> + getRight().generateCode() + <span class="string">&quot;)&quot;</span>;</span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (getLeft().getType() == DataType.LONG &amp;&amp; getRight().getType() == DataType.DOUBLE) &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="string">&quot;((double)&quot;</span> + getLeft().generateCode() + <span class="string">&quot; + &quot;</span> + getRight().generateCode() + <span class="string">&quot;)&quot;</span>;</span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (getLeft().getType() == DataType.LONG &amp;&amp; getRight().getType() == DataType.LONG) &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="string">&quot;(&quot;</span> + getLeft().generateCode() + <span class="string">&quot; + &quot;</span> + getRight().generateCode() + <span class="string">&quot;)&quot;</span>;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">IllegalStateException</span>();</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>注意，目前代码还是以 String形式存在的，递归调用的过程中通过字符串拼接，一步步拼成完整的表达式函数。</p><p>以表达式 <code>a + 2*3 - 2/x + log(x+1)</code>为例，最终生成的代码如下：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">(((<span class="type">double</span>)(a + (<span class="number">2</span> * <span class="number">3</span>)) - ((<span class="type">double</span>)<span class="number">2</span> / x)) + java.lang.Math.log((x + (<span class="type">double</span>)<span class="number">1</span>)))</span><br></pre></td></tr></table></figure><p>其中，<code>a</code>、<code>x</code>都是未知数，但类型是已经确定的，分别是 Long 型和 Double 型。</p><h2 id="编译器编译">编译器编译</h2><p><a href="https://janino-compiler.github.io/janino/">Janino</a>是一个流行的轻量级 Java 编译器，与常用的 <code>javac</code>相比它最大的优势是：可以在 JVM上直接调用，直接在进程内存中运行编译，速度很快。</p><p>上述代码仅仅是一个表达式、并不是完整的 Java 代码，但 janino提供了方便的 API 能直接编译表达式：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">ExpressionEvaluator</span> <span class="variable">evaluator</span> <span class="operator">=</span> <span class="keyword">new</span> <span class="title class_">ExpressionEvaluator</span>();</span><br><span class="line">evaluator.setParameters(parameterNames, parameterTypes); <span class="comment">// 输入参数名及类型</span></span><br><span class="line">evaluator.setExpressionType(rootNode.getType() == DataType.DOUBLE ? <span class="type">double</span>.class : <span class="type">long</span>.class); <span class="comment">// 输出类型</span></span><br><span class="line">evaluator.cook(code); <span class="comment">// 编译代码</span></span><br></pre></td></tr></table></figure><p>实际上，你也可以手工拼接出如下的类代码，交给 janino编译，效果是完全相同的：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">MyGeneratedClass</span> &#123;</span><br><span class="line">    <span class="keyword">public</span> <span class="type">double</span> <span class="title function_">calculate</span><span class="params">(<span class="type">long</span> a, <span class="type">double</span> x)</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> (((<span class="type">double</span>)(a + (<span class="number">2</span> * <span class="number">3</span>)) - ((<span class="type">double</span>)<span class="number">2</span> / x)) + java.lang.Math.log((x + (<span class="type">double</span>)<span class="number">1</span>)));</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>最后，依次输入所有参数即可调用刚刚编译的函数：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">Object</span> <span class="variable">result</span> <span class="operator">=</span> evaluator.evaluate(parameterValues);</span><br></pre></td></tr></table></figure><h2 id="references">References</h2><ul><li><a href="https://github.com/apache/spark">Apache Spark -GitHub</a></li><li><a href="https://janino-compiler.github.io/janino">Janino byjanino-compiler</a></li><li><a href="https://github.com/fuyufjh/calculator">fuyufjh/calculator:A simple calculator to demonstrate code gen technology</a></li></ul>]]></content>
    
    
    <summary type="html">&lt;p&gt;&lt;img src=&quot;/images/2018/12/code-generation-banner.png&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;代码生成（Code
Generation）&lt;/strong&gt;技术广泛应用于现代的数据系统中。代码生成是将用户输入的表达式、查询、存储过程等现场编译成二进制代码再执行，相比解释执行的方式，运行效率要高得多。尤其是对于计算密集型查询、或频繁重复使用的计算过程，运用代码生成技术能达到数十倍的性能提升。&lt;/p&gt;</summary>
    
    
    
    
    <category term="database" scheme="https://ericfu.me/tags/database/"/>
    
    <category term="java" scheme="https://ericfu.me/tags/java/"/>
    
  </entry>
  
  <entry>
    <title>Calcite 对 Volcano 优化器优先队列的实现</title>
    <link href="https://ericfu.me/understand-importance-in-calcite/"/>
    <id>https://ericfu.me/understand-importance-in-calcite/</id>
    <published>2018-11-05T07:00:44.000Z</published>
    <updated>2026-02-27T03:48:16.269Z</updated>
    
    <content type="html"><![CDATA[<p>Apache Calcite 中的 VolcanoPlanner 是对 Volcano/Cascades优化器的实现。我们知道，Volcano优化器是在搜索空间中用动态规划（DP）的方式寻找最优解，即使在用了 DP的情况下，我们也不大可能把搜索空间遍历完。Volcano的解决方案是定义一个优先队列，优先采用看起来更有希望的 Rule。</p><p>于是问题来了，怎样定义一个 Rule的优先级？论文中并没有给出答案。Calcite 代码中为此定义了 Importance的概念。然而相关的资料非常少，本文总结一下我自己的猜测和理解，如果你有不同的观点，欢迎留言讨论。</p><span id="more"></span><h2 id="术语">术语</h2><p>本文假设读者已经充分理解 Volcano 优化器。对以下概念有疑问的，请参考Valcano/Cascades 原论文。</p><ul><li><strong>RelSet</strong> 描述一组逻辑上相等的 RelationExpression</li><li><strong>RelSubset</strong> 描述一组物理上相等的 RelationExpression，即具有相同的 Physical Properties</li><li><strong>RuleMatch</strong> 描述一次成功的匹配，包含 Rule和被匹配的节点</li><li><strong>Importance</strong> 描述 RuleMatch的重要程度，越大越应该优先处理</li></ul><h2 id="基本原则">基本原则</h2><p>为了能在短时间内得到一个较优解，我们的基本原则是：<strong>尽量对代价大的做优化</strong>，从而尽可能在有限的优化次数内获得更大的收益。这又可以分成三个方面来说：</p><ol type="1"><li>优先应用 Transformation Rules 生成各式各样的关系表达式（即优先进行explore 过程）；</li><li>一般来说，父节点比子节点数据量更大，所以优先处理父节点；</li><li>同级的节点中，代价大的一边应该得到更多的优化。</li></ol><p>为了达成 1，我们只要把逻辑算子的代价设为无穷大即可。为了达成2、3，我们将 importance 和 cost 关联起来——简单来说就是 cost越大、importance 也越大。</p><h2 id="实现分析">实现分析</h2><p>原理上说，RuleQueue 是一个优先队列，包含当前所有可行的RuleMatch，<code>findBestExpr()</code>时每次循环中我们从中取出优先级最高的并 apply，再根据 apply的结果更新队列……如此往复，直到满足终止条件。</p><p>但因为性能原因，实际上 RuleQueue没有使用最大堆之类的数据结构，而是每次选出 importance最大的那个。这是因为经常需要对 RelSubset 的 importance做大量调整，用最大堆处理得不偿失。</p><p>RuleMatch 的 importance 定义为以下两个中比较大的一个：</p><ol type="1"><li>输入的 RelSubset 的 importance</li><li>输出的 RelSubset 的 importance</li></ol><blockquote><p>以上参考 <code>VolcanoRuleMatch:computeImportance</code></p></blockquote><p>那 RelSubset 的 importance 如何决定？这边的实现比较 tricky：RuleQueue的成员变量 <code>subsetImportances</code> 中保存了各个 RelSubset 的importance，但这并不是 <code>getImportance()</code>返回的结果。为了区分清楚，我们把 <code>getImportance()</code>返回的结果称为调整后的 importance，把 <code>subsetImportances</code>里存的值称为真实 importance。</p><p><strong>调整后的 importance</strong>定义为以下两个中比较大的一个：</p><ul><li>该 RelSubset 本身的真实 importance</li><li>逻辑上相等的（即位于同一个 RelSet 中）任意一个 RelSubset 的真实importance 除以 2</li></ul><p>之所以要这么做，注释中的解释是让 Conversion 尽快发生。</p><blockquote><p>以上参考 <code>RuleQueue:getImportance(RelSubset)</code></p></blockquote><p>下一个问题，<strong>真实 importance</strong> 怎么计算呢？</p><ul><li>根节点的 importance 始终是 1.0</li><li>否则，假设它父节点的代价是 <spanclass="math inline">\(c_{parent}\)</span>，这个节点本身的代价是 <spanclass="math inline">\(c_{child}\)</span>，则定义节点本身的 <spanclass="math inline">\(I_{child} =\frac{c_{child}}{c_{parent}}I_{parent}\)</span></li></ul><p><img src="/images/2018/11/calcite-importance-parent-child.png" /></p><p>这里说的 cost 是 RelSubset 的 cost，也就是当前这个 RelSubset 的中最佳Physical Plan 的 cost。DP 算法会保留每个 RelSubset 的最佳 plan 以及对应cost。</p><blockquote><p>以上参考 <code>RuleQueue:computeImportance(RelSubset)</code></p></blockquote><p>这个定义又引出了下面两个问题：</p><p><strong>1. 如果一个 RelSubset 里还没有 Physical Plan，那它的 cost是无穷大，怎么处理？</strong></p><ul><li><p>初始设置为 <span class="math inline">\(0.9^n\)</span>，其中 <spanclass="math inline">\(n\)</span> 是 RelSet 所在的层数（参考<code>VolcanoPlanner:setInitialImportance</code>）</p></li><li><p>其他时候，比例 <spanclass="math inline">\(\frac{c_{child}}{c_{parent}}\)</span>限制最大不超过 <span class="math inline">\(0.99\)</span>（参考<code>RuleQueue:computeImportanceOfChild</code>）</p></li></ul><p>PS. 理论上只要是一个小于 1的系数都可以，不知道为什么这里两个系数不一样。</p><p><strong>2. 如果某个 RelSubset 的 cost 降低了（例如找到了一种 PhysicalPlan），那么 importance 也应该相应的被更新。</strong></p><ul><li>要更新的不仅是该 Plan 本身所在的一个或多个 RelSubset，还有可能是这些RelSubset 的父节点、父节点的父节点……所以这是一个向上递归的过程。（参考<code>RelSubset:propagateCostImprovements</code>）</li></ul><h2 id="references">References</h2><ol type="1"><li><ahref="https://pdfs.semanticscholar.org/a817/a3e74d1663d9eb35b4baf3161ab16f57df85.pdf">TheVolcano Optimizer Generator: Extensibility and Efficient Search - GoetzGraefe</a></li><li>The Cascades Framework for Query Optimization - Goetz Graefe</li><li><a href="https://github.com/apache/calcite">Apache Calcite SourceCode</a></li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;Apache Calcite 中的 VolcanoPlanner 是对 Volcano/Cascades
优化器的实现。我们知道，Volcano
优化器是在搜索空间中用动态规划（DP）的方式寻找最优解，即使在用了 DP
的情况下，我们也不大可能把搜索空间遍历完。Volcano
的解决方案是定义一个优先队列，优先采用看起来更有希望的 Rule。&lt;/p&gt;
&lt;p&gt;于是问题来了，怎样定义一个 Rule
的优先级？论文中并没有给出答案。Calcite 代码中为此定义了 Importance
的概念。然而相关的资料非常少，本文总结一下我自己的猜测和理解，如果你有不同的观点，欢迎留言讨论。&lt;/p&gt;</summary>
    
    
    
    
    <category term="database" scheme="https://ericfu.me/tags/database/"/>
    
    <category term="optimizer" scheme="https://ericfu.me/tags/optimizer/"/>
    
  </entry>
  
</feed>
