<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title><![CDATA[Ao Li - Elasticsearch Labs]]></title>
    <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Ao Li - Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/cn/search-labs/author/ao-li</link>
    </image>
    <link>https://www.elastic.co/cn/search-labs/author/ao-li</link>
    <atom:link href="https://www.elastic.co/cn/search-labs/rss/author/ao-li.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[cn]]></language>
    <lastBuildDate>Tue, 29 Sep 2026 06:14:22 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Lucene 中的并发错误：如何修复乐观并发故障]]></title>
    <description><![CDATA[多亏了 CMU PASTA 实验室的确定性并发测试框架 Fray，我们追踪到了一个棘手的 Lucene 错误，并将其解决了]]></description>
    <content:encoded><![CDATA[<p>没错，又是一个修复漏洞的博客。但这一次有一个转折，一位开源英雄突然出现，拯救了世界。 </p><p>调试并发错误并非易事，但我们将着手进行。Fray 是 CMU PASTA 实验室推出的确定性并发测试框架，它能将不稳定的故障转化为可靠的可重现故障。得益于 Fray 聪明的阴影锁设计和精确的线程控制，我们追踪到了一个棘手的 Lucene bug，并最终将其解决。本篇文章将探讨开源英雄和工具如何让并发调试不再痛苦，让软件世界变得更加美好。</p><h2>并发错误：软件工程师的克星</h2><p>并发错误是最糟糕的。它们不仅难以修复，最难的是让它们可靠地失效。以这次测试失败为例，<a href="https://github.com/apache/lucene/issues/13127"><code>TestIDVersionPostingsFormat#testGlobalVersions</code></a> 。它会产生多个文档编写和更新线程，这对 Lucene 的乐观并发模型提出了挑战。该测试暴露了乐观并发控制中的一个竞赛条件。也就是说，文件操作可能会谎称自己是一系列操作中的最新操作😱 。这意味着，在某些条件下，更新或删除操作可能会成功，而在乐观并发约束条件下，该操作本应失败。</p><p></p>org.apache.lucene.sandbox.codecs.idversion.TestIDVersionPostingsFormat &gt; testGlobalVersions FAILED
    java.lang.AssertionError: maxSeqNo must be greater or equal to 7442 but was 7441
        at __randomizedtesting.SeedInfo.seed([B97A2BDBC7E40BF6:B4D76006D5101E6]:0)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.DocumentsWriterDeleteQueue.close(DocumentsWriterDeleteQueue.java:325)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.DocumentsWriter.flushAllThreads(DocumentsWriter.java:659)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.IndexWriter.getReader(IndexWriter.java:576)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.StandardDirectoryReader.doOpenFromWriter(StandardDirectoryReader.java:381)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.StandardDirectoryReader.doOpenIfChanged(StandardDirectoryReader.java:355)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.StandardDirectoryReader.doOpenIfChanged(StandardDirectoryReader.java:345)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.index.DirectoryReader.openIfChanged(DirectoryReader.java:170)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.search.SearcherManager.refreshIfNeeded(SearcherManager.java:144)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.search.SearcherManager.refreshIfNeeded(SearcherManager.java:52)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.search.ReferenceManager.doMaybeRefresh(ReferenceManager.java:167)
        at org.apache.lucene.core@10.0.0-SNAPSHOT/org.apache.lucene.search.ReferenceManager.maybeRefresh(ReferenceManager.java:213)<p>向那些讨厌 Java 堆栈跟踪的人致歉。注意，删除并不一定意味着 "删除"。它还可以表示文档的 "更新"，因为 Lucene 的段是只读的。
</p><p>Apache Lucene 通过<a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java"><code>DocumentsWriter</code></a> 类管理每个写入文档的线程。该类将创建或重复使用线程进行文档编写，每个写入操作都在<a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java"><code>DocumentsWriterPerThread</code></a> (DWPT) 类中控制其信息。此外，撰写人还会跟踪<a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java"><code>DocumentsWriterDeleteQueue</code></a> (DWDQ)中删除了哪些文件。这些结构会将所有文档突变操作保存在内存中，并定期刷新，以释放内存资源并将结构持久化到磁盘上。</p><p></p><p>为了防止<a href="https://en.wikipedia.org/wiki/Blocking_(computing)">阻塞线程</a>并确保并发系统的高吞吐量，Apache Lucene 只在非常关键的部分<a href="https://docs.oracle.com/javase/tutorial/essential/concurrency/syncmeth.html">进行同步</a>。虽然这在实践中可能很好，但就像任何并发系统一样，也有龙的存在。</p><h2>
一个虚幻的希望</h2><p>初步调查显示，有几个关键部分没有适当同步。与特定<a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java"><code>DocumentsWriterDeleteQueue</code></a> 的所有互动都受其外围<a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java"><code>DocumentsWriter</code></a> 的控制。因此，虽然在<a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java"><code>DocumentsWriterDeleteQueue</code></a> 中可能没有对单个方法进行适当的同步，但它们对世界的访问是同步的（或应该是同步的）。(我们暂且不去深究它是如何混淆所有权和使用权的--这是一个由众多贡献者共同完成的长期项目。少说两句吧）。</p><p></p><p>不过，我在<a href="https://github.com/apache/lucene/blob/40060f8b7080d06a218518445a0a1dfc520c812a/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterFlushControl.java#L572-L576"> 冲洗过程中 发现</a> 有一处 没有同步。</p>// Advance the queue, meaning create a new one to keep track of deletes
// Since we have been flushed, let's starting tracking again
DocumentsWriterDeleteQueue newQueue = documentsWriter.deleteQueue.advanceQueue(perThreadPool.size());
// OK, get the current new maximum sequence number for optimistic concurrency
seqNo = documentsWriter.deleteQueue.getMaxSeqNo();
// Reset to the new queue
documentsWriter.resetDeleteQueue(newQueue);<p>这些操作不会同步为一个原子操作。也就是说，在创建<code>newQueue</code> 和调用<code>getMaxSeqNo</code> 之间，其他代码可能执行了<code>documentsWriter</code> 类中的序列号递增。我发现了错误！</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbc55033280b45913/6a1706b4dc55dec27fe00d30/3f2617a102d28735e755a7539b047c28beda41be-1600x453.jpg" alt="" /><p>
但是，与大多数复杂的错误一样，找到根本原因并不简单。这时，一位英雄挺身而出。</p><h2>战场上的英雄</h2><p>我们的英雄登场了：<a href="https://aoli.al/">李敖</a>和他在 PASTA 实验室的同事们。我会让他解释他们是如何用 Fray 来拯救世界的。</p><p><a href="https://github.com/cmu-pasta/fray">Fray</a>是由卡内基梅隆大学<a href="https://pastalab.org/">PASTA 实验室</a>的研究人员开发的确定性并发测试框架。构建 Fray 的动机源于学术界和业界之间存在的一个明显差距：确定性并发测试在学术研究中已被广泛研究了 20 多年，但实践者们仍然依赖于压力测试--一种公认为不可靠且不稳定的方法--来测试他们的并发程序。因此，我们希望以通用性和实际应用性为首要目标，设计并实现一个确定性并发测试框架。</p><p></p><h2>核心理念</h2><p>Fray 的核心是利用一个简单而有力的原则：顺序执行。Java 的并发模型提供了一个关键<a href="https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4.3">特性--如果</a>程序中没有数据竞赛，那么所有的执行都会在顺序上保持一致。这意味着程序的行为可以用一系列程序语句来表示。</p><p>Fray 以顺序方式运行目标程序：每一步都会暂停除一个线程之外的所有线程，从而使 Fray 能够精确控制线程调度。线程是随机选择的，以模拟并发性，但选择会被记录下来，以便随后进行确定性重放。为了优化执行，Fray 只在线程即将执行同步指令（如锁定或原子/易失性访问）时执行上下文切换。数据竞赛自由的一个很好的特性是，这种有限的上下文切换足以探索任何线程交错导致的所有可观察行为<a href="https://arxiv.org/abs/2501.12618">（我们的论文</a>中有一个证明草图）。</p><p></p><h2>挑战：控制线程调度</h2><p>虽然核心理念看似简单，但实施 Fray 却面临着巨大的挑战。为了控制线程调度，Fray 必须管理每个应用程序线程的执行。乍一看，这似乎很简单--用定制的实现方法取代并发基元。然而，JVM 中的并发控制错综复杂，涉及<a href="https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-6.html">字节码指令</a>、<a href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/locks/ReentrantLock.html">高级库</a>和<a href="https://github.com/openjdk/jdk/blob/b720517cb33c2119ec6ed85504bce321de748228/src/java.base/share/classes/java/lang/Object.java#L394">本地方法的</a>混合。</p><p></p><p>结果发现这是一个兔子洞：</p><p></p><ul><li><p>例如，每条<code>MONITORENTER</code> 指令都必须在同一方法中具有相应的<code>MONITOREXIT</code> 。如果 Fray 将<code>MONITORENTER</code> 替换为对存根/模拟的方法调用，它还需要替换<code>MONITOREXIT</code> 。</p></li><li><p>在使用<code>object.wait/notify</code> 的代码中，如果替换了<code>MONITORENTER</code> ，也必须替换相应的<code>object.wait</code> 。该替换链可延伸至<code>object.notify</code> 及更远。</p></li><li><p>JVM 会在本地代码中调用某些与并发相关的方法（例如，当线程结束时，<code>object.notify</code> ）。替换这些操作需要修改 JVM 本身。</p></li><li><p>JVM 功能（如类加载器和垃圾收集 (GC) 线程）也使用并发基元。修改这些基元会导致与这些 JVM 函数不匹配。</p></li><li><p>在 JDK 中替换并发基元往往会导致 JVM 在初始化阶段崩溃。</p></li></ul><p></p><p>这些挑战清楚地表明，全面替换并发基元是不可行的。</p><h2>
我们的解决方案：影子锁设计</h2><p>为了应对这些挑战，Fray 使用了一种新颖的影子锁机制来协调线程执行，而无需替换并发基元。影子锁充当引导线程执行的中介。例如，在获取锁之前，应用线程必须与相应的影子锁交互。影子锁决定线程能否获取锁。如果线程无法继续执行，影子锁会阻止它，并允许其他线程执行，从而避免死锁，实现可控并发。这种设计使 Fray 能够透明地控制线程交错，同时保持并发语义的正确性。每个并发基元都在影子锁框架内进行了仔细建模，以确保合理性和完整性。更多技术细节可参见我们的论文。</p><p></p><p>此外，这种设计还旨在面向未来。通过只要求对并发基元的影子锁进行检测，它可以确保与较新版本的 JVM 兼容。这是可行的，因为 JVM 中并发基元的接口相对稳定，多年来一直保持不变。</p><h2>
测试</h2><p>建立 Fray 之后，下一步就是评估。幸运的是，许多应用程序（如 Apache Lucene）已经包含并发测试。这种并发测试是常规的 JUnit 测试，会产生多个线程，执行一些工作，然后（通常）等待这些线程结束，然后断言一些属性。大多数情况下，这些测试都能通过，因为它们只进行了一次交织。更糟糕的是，如前所述，有些测试只是在 CI/CD 环境中偶尔失败，这使得这些失败极难调试。当我们使用 Fray 执行同样的测试时，我们发现了许多错误。值得注意的是，Fray 重新发现了以前报告过的由于缺乏可靠的重现而一直未修复的错误，包括本博客的重点：<a href="https://github.com/apache/lucene/issues/13127"><code>TestIDVersionPostingsFormat.testGlobalVersions</code></a> 。幸运的是，有了 Fray，我们可以确定性地重放它们，并为开发人员提供详细信息，使他们能够可靠地重现和修复问题。</p><p></p><h2>Fray 的下一步行动</h2><p>我们非常高兴地听到 Elastic 的开发人员说，Fray 对调试并发错误很有帮助。我们将继续开发 Fray，让更多的开发者可以使用它。</p><p>我们的短期目标包括增强 Fray 确定性重放时间表的能力，即使在存在其他非确定性操作（如随机值生成器或使用<code>object.hashcode</code> ）的情况下也是如此。我们还致力于提高 Fray 的可用性，使开发人员无需任何人工干预即可分析和调试现有并发测试。最重要的是，如果您在调试或测试程序中的并发问题时遇到困难，我们很乐意听取您的意见。请随时在<a href="https://github.com/cmu-pasta/fray">Fray Github 代码库中</a>创建问题。</p><p></p><h2>修复并发错误的时候到了</h2><p>多亏了李敖和 PASTA 实验室，我们现在才有了这个测试的可靠失败实例！我们终于可以修好它了关键问题在于<a href="https://github.com/apache/lucene/blob/f2e7ae40af0b28b1d5f2edc31f8858229a8523f4/lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThreadPool.java"><code>DocumentsWriterPerThreadPool</code></a> 如何实现线程和资源的重复使用。</p>1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_0, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t0 20 getNextSequenceNumber 1 called from stack:
&lt;snip&gt;...&lt;/snip&gt;
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_1, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_5, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_2, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_6, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_3, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; new Writer: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_4, aborted=false, numDocsInRAM=0, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]<p>在这里，我们可以看到每个线程都是参照第 0 代的初始删除队列创建的。</p><p>然后，队列会在刷新时前进，正确查看队列中的前 7 个操作。</p>1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t020 advanceQueue called from stack with maxSeq 9 lastSeqNo: 1 maxNumPendingOps: 7:
&lt;snip&gt;...&lt;/snip&gt;
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t525 getNextSequenceNumber 2 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t828 getNextSequenceNumber 3 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t727 getNextSequenceNumber 4 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t626 getNextSequenceNumber 5 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t424 getNextSequenceNumber 6 called from stack:
1&gt; DWDQ: [ generation: 0 ] id: 245403753 tid: t323 getNextSequenceNumber 7 called from stack:<p>但是，在所有线程完成冲洗之前，又有两个线程被重新使用，用于处理另一个文件：</p>1&gt; getAndLock: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_0, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]
1&gt; getAndLock: DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_3, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds]<p>这将使<code>seqNo</code> 的增量超过假定的最大值，在冲洗过程中计算出的最大值为 7。请注意，<code>_3</code> 和 段的额外<code>numDocsInRAM</code> 。 <code>_0</code></p>1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_2, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_3, aborted=false, numDocsInRAM=2, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_6, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_4, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_5, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_1, aborted=false, numDocsInRAM=1, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove
1&gt; DocumentsWriterPerThread [pendingDeletes=gen=0, segment=_0, aborted=false, numDocsInRAM=2, deleteQueue=DWDQ: [ generation: 0 ], 0 deleted docIds] checkout to remove<p>这样，Lucene 就会错误地考虑冲洗过程中文档操作的顺序，从而导致测试失败。</p><p>就像所有优秀的错误修复一样，实际修复只需<a href="https://github.com/apache/lucene/pull/13627/files">10 行代码</a>。但两位工程师花了好几天才真正弄明白：</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt71d78f22ce6ec215/6a1706b61949f7bcd0e7a94a/7915e5ba2feba0fb8e846da0af11bfdca470c467-1600x622.jpg" alt="" /><h2>并非所有英雄都穿斗篷</h2><p>是的，这是老生常谈，但却是事实。</p><p></p><p>并行程序调试非常重要。这些棘手的并发错误需要花费大量时间来调试和解决。虽然像 Rust 这样的新语言已经内置了一些机制来帮助防止类似的竞赛条件，但世界上大多数软件都是用<a href="https://www.rust-lang.org/">Rust</a> 以外的语言编写的。Java 经过这么多年的发展，仍然是最常用的语言之一。改进基于 JVM 语言的调试，让软件工程世界更美好。鉴于有些人认为代码将由大型语言模型编写，也许我们工程师的工作最终将只是调试糟糕的大型语言模型代码，而不是调试我们自己的糟糕代码。但是，无论软件工程的未来如何，并行程序调试对于维护和构建软件仍然至关重要。</p><p></p><p>感谢李敖和他的 PASTA 实验室同事们，是你们让这一切变得更加美好。</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/concurrency-bugs-lucene-debugging</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/concurrency-bugs-lucene-debugging</guid>
    <category><![CDATA[Lucene]]></category>
    <dc:creator><![CDATA[Benjamin Trent,Ao Li]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf9fcbf843affc566/6a1706b8964ceaea3208bab9/7884e35f40c15fe349379ef7ae4648b21708962f-1070x712.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 07 Feb 2025 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>